tephpy view framing — design specification#

Estimated reading time: 28 minutes

Living document. This specification is maintained alongside the code, not archived behind it. src/tephpy/plotting/axes.py, src/tephpy/_constants.py and src/tephpy/_configfile.py cite it by section — framing spec §3.1 and the like — so these sections are the reasoning behind what they do, and where the two ever diverge it is the specification that gets corrected. Read it as current.

  • Date: 2026-08-25 (originated; maintained since)

  • Status: living design specification

  • Citation prefix: framing spec §… — the question is how a view gets chosen, which is neither the extent machinery alone nor the axes class alone

  • Scope: TephigramAxes.set_extent’s signature, the new TephigramAxes.fit, the config.diagram options behind both, and the migration of every caller. The transforms are unchanged; so is everything that draws

  • Parent spec: 2026-07-22-tephpy-design.md — Plan 8 of spec §10, inserted between Plans 7b and 7c

  • Tracked by: #184, which this specification answers in full

1. Purpose#

set_extent is the only way to choose a view, and it asks for the wrong thing in the wrong shape. Five defects follow from one decision — that a view is named by two (pressure, temperature) corners — and all five are measurable rather than aesthetic. Every number below was produced by running the code on 2026-08-25, not inferred.

The corner naming is false for ordinary input. axes.py documents the two points as “bottom-left and top-right corners”, then maps both through the transforms and takes min/max on each axis independently — the axis-aligned bounding box of two points in a rotated space. Whether the first point lands bottom-left depends on whether the θ separation outweighs the temperature separation, and on a shallow-pressure, wide-temperature window it does not:

extent

where the first named corner actually lands

((900, -65), (200, 5)) — the default

bottom-left

((1000, 30), (900, -10))

bottom-right

((1000, 40), (700, -20))

bottom-right

((950, 25), (850, 0))

bottom-right

All four are accepted without complaint. The failing shape is a boundary-layer window, which is the kind plot_sounding_comparison.py draws.

The nested pair is order-ambiguous. Both members are bare floats, so (p, T) against (T, p) is a silent coin-flip. The pressure > 0 guard catches the transposition only when a temperature happens to be negative. set_extent(((20, 1000), (40, 200))) — written by someone reading the order as (T, p) — is accepted and produces an x range of 2324–3480 where the intended reading gives 1724–1902.

The cited precedent is not the shape in use. axes.py and spec §3.2 both call this “the cartopy idiom”, but cartopy’s set_extent takes a flat four-tuple of ranges, [x0, x1, y0, y1]. The concept was adopted and the packaging inverted.

The view can exclude the region the caller named. This is the defect #184 did not find, and it is the worst of them. Mapping two corners and taking the extremes gives the bounding box of those two points — not of the pressure × temperature region they delimit. The other two corners of that region are simply not consulted, and in a rotated space they can fall outside the result. For ((1000, 30), (900, -10)), measured:

named corner

inside the view it asked for?

(1000 hPa, 30 °C)

yes

(1000 hPa, −10 °C)

no

(900 hPa, 30 °C)

no

(900 hPa, −10 °C)

yes

Half the named region is outside the view. A caller framing a boundary-layer window can lose the data they framed it around, silently, and nothing in the API’s vocabulary would lead them to suspect it.

Underneath all four is a fifth problem, which no signature fixes and which the current one hides: the caller also gets more than they name. The view is an axis-aligned rectangle and pressure is not an axis, so a named region always reaches further than its bounds. For the default extent the view’s other two corners work out to 84.9 hPa / −137.9 °C and 1058.4 hPa / +77.9 °C. Nothing draws there because it is unphysical, but an API that speaks of corners implies it delivers the corners it was given and no others, and it does not.

Separately, set_extent answers “make these figures directly comparable”. It does not answer “frame this neatly” — which is what a reader reaches for first — and, without a pressure clamp beside it, there is no API for that at all. plot_sounding_comparison.py meets that need today by hand-picking a literal EXTENT — a worked example demonstrating the absence of the thing it needs.

Nothing has been released. Both changes are free now and cost a deprecation cycle later, which is the whole reason this plan sits before v0.1 and before the narrative documentation of Plan 7c that would otherwise teach the shape being replaced.

2. Decisions#

  1. A view is named by ranges of the quantities a caller thinks inpressure= and temperature= — not by points. Keyword-only, both required. Neither is an axis of the drawn plane, which is exactly why naming points in them goes wrong.

  2. Each range is sorted internally. Order within a range carries no meaning, so it is not allowed to carry a bug.

  3. fit frames whatever it is given, variadically, over any object the diagram plots. One rule, no privileged argument.

  4. Margin is a fraction of the fitted span, applied in the drawn plane, resolved keyword > config.diagram.margin > _constants.

  5. The configuration follows the API. One concept, one shape, everywhere.

  6. Both methods disable autoscaling, because a caller who fixed a window meant it.

  7. The docstrings state what the caller also gets, since §1’s fifth problem survives every signature.

3. Architecture#

3.1 set_extent by ranges#

ax.set_extent(pressure=(900.0, 200.0), temperature=(-65.0, 5.0))

Keyword-only, both required. Keyword-only because the whole defect of §1 is two positional sequences that cannot be told apart; leaving either positional preserves the failure for the caller who passes one. Both required because set_extent(pressure=...) has no defensible reading for the other axis — “leave temperature as it is” is a different operation from “fix the view”, and mixing them is how a method acquires two jobs.

Each range is sorted before use, so (900, 200) and (200, 900) are the same window. This is not leniency: within a range the order is genuinely meaningless, and the old API’s equivalent freedom was the transposition bug of §1 rather than a kindness.

The implementation changes, and the change is half the fix. The transforms are the same — theta_from_pressure_temperature then xy_from_temperature_theta — but they are applied to the corners of the named region rather than to the two points a caller happened to write. That is what makes the view contain the region it names, which §1’s fourth defect shows the current code does not.

Four corners are not enough, and assuming they were is the same mistake one layer down. The review of #194 found it. Writing y out at fixed pressure,

y = MA·ln(T + 273.15) + MA·κ·ln(P_REF/p) T

gives dy/dT = MA/(T + 273.15) 1, which vanishes at T + 273.15 = MA — that is T = 26.85 °C — and d²y/dT² < 0 there, so it is a maximum strictly inside any temperature range that spans it. A corner-only search misses it. Measured: set_extent(pressure=(1000, 900), temperature=(-50, 100)) returned yhi = 1685.63 while (900 hPa, 26.85 °C) maps to y = 1693.32, outside the view that named it.

The extremum is unique and it is the only one. x = MA·ln(T + 273.15) + MA·κ·ln(P_REF/p) + T has dx/dT = MA/(T + 273.15) + 1 > 0 for every physical temperature, so x is strictly increasing in T; and pressure enters both coordinates only through MA·κ·ln(P_REF/p), monotonic in p. So the candidate set is the four corners plus, when T* = MA 273.15 falls strictly inside the temperature range, the two points (p_lo, T*) and (p_hi, T*). Verified numerically across the pressure domain: argmax y sits at T* regardless of p, and x is monotonic throughout.

This is worth stating rather than merely fixing, because it is §1’s own error recurring: §1 records that mapping two points and taking extremes assumes the extremes live at those points, and mapping four corners assumes the same thing about a rectangle whose image is not a rectangle. T* is a property of the projection — it is MA expressed in Celsius — so it belongs beside MA in _constants rather than as a literal in the geometry.

Measured on 2026-08-25, the two agree on the default extent and differ on every one of §1’s three mis-named cases — the same cases, because both defects have the same cause. So the change is observable, and §6 pins it. What it does not do is move a shipped figure: both plot_tephigram.py and plot_sounding_comparison.py use extents on which two-corner and four-corner mapping agree, so no gallery baseline shifts under this change alone.

Validation. A non-finite bound, or a pressure at or below zero, raises ValueError as today; the message names the offending keyword rather than reprinting a nested tuple. A range whose two bounds are equal is degenerate and refused, as the current x[0] == x[1] or y[0] == y[1] test refuses its equivalent.

The docstring carries §1’s fifth problem. It says outright that the view is an axis-aligned rectangle in a rotated space, so the pressures and temperatures reachable inside it exceed those named, and it gives the default extent’s other two corners — 84.9 hPa / −137.9 °C and 1058.4 hPa / +77.9 °C — as the worked instance. An API that stopped claiming false corners without saying what it does produce would have traded one silence for another.

3.2 fit by data#

ax.fit(sounding, pressure=(950, 300))     # one ascent, over the layer of interest
ax.fit(sounding, parcel, pressure=(950, 300))   # environment and the path it is read against
ax.fit(*ascents, pressure=(1000, 200))    # a station's day, every panel framed alike

Variadic over anything the diagram plots — Sounding and Profile today — with one rule: frame everything you were given. The alternative shapes were considered and rejected in §5.

What fit promises, stated exactly. Nothing you gave it falls outside the frame. It does not promise a neat-looking diagram, and an earlier draft of this section said it did. That was written without rendering a single figure, and rendering one falsifies it: a radiosonde ascent does not stop at the tropopause. Measured over the two shipped samples, an unclamped fit spans pressure=(966.4, 10.2) — into the mid-stratosphere — and θ grows fast enough up there that the resulting view is a narrow diagonal band of isopleths in a mostly empty rectangle. The temperature range is barely implicated: clamping to (1000, 200) hPa changes the fitted temperature only from (-79.4, 27.5) to (-72.3, 27.5), and turns an unusable figure into a conventional one.

So fit takes a pressure= clamp, and it is the parameter that makes the method useful rather than merely correct. Given one, the view’s pressure range is the clamp and the temperature range is fitted to the data inside it. This is the meteorological selection a reader actually makes — a layer — and it is why pressure is clamped and temperature is not: a pressure band names a part of the atmosphere, a temperature band names nothing.

There is deliberately no default clamp. fit(sounding) frames the whole ascent, which is wide and is correct — it is the honest answer to “frame all of this”. A default would have to pick a pressure silently, and silently discarding the stratospheric half of an ascent is a worse failure than a wide view, because it is invisible. §5 records this.

What bounds the view. Pressure, temperature and dewpoint. Those are the diagram’s coordinates; wind is not, because plot_barbs draws into the right-hand gutter rather than into the plane, so a sounding’s winds cannot fall outside a view that contains its profile. Dewpoint is optional on Sounding and absent from Profile, so it contributes where it exists.

The reduction is nan-aware. Spec §3.4 makes NaN gaps data everywhere except pressure, so a level with no dewpoint is a level whose dewpoint does not bound anything, not a level that poisons the result. That rule is about finiteness, not about the clamp, and the two are not the same failure. An argument carrying no finite data at all is a caller error, checked per argument before any clamp is applied, and raises MissingDataError naming which one. An argument whose finite data simply falls outside the pressure= clamp is different: it contributes nothing, silently, and that is fine — a caller framing a layer may legitimately pass a sounding that does not reach it. Only when nothing survives the clamp across every argument does fit raise, exactly as it does with no clamp at all.

The type dispatch is one small internal function rather than isinstance chains at each use, and it is the only place fit knows what a Sounding or a Profile is. Adding a third plottable later means teaching that function, not editing fit.

Why the parcel matters enough to be an argument. calc.parcel_path returns a Profile over the sounding’s own pressure span, but a parcel is warmer than its environment through the CAPE region — so fitting to the sounding alone clips the very path the parcel analysis exists to show. Making the parcel just another argument dissolves the problem rather than documenting it, and §6 pins the case with a test, because a defect an API was built to prevent is the one worth asserting.

3.3 Margin#

A fraction of the fitted span, applied symmetrically in the drawn x/y plane. Fractional because it is then scale-free — the same value frames a boundary-layer window and a full-troposphere one — and applied in the drawn plane because isotropic padding there is what “neatly framed” means to the eye. Padding in pressure and temperature instead would be anisotropic once transformed, and would need two numbers to say one thing.

Resolution is keyword > config.diagram.margin > _constants, which is the order every tunable in this project already uses. margin earns a configuration entry where the wind-barb gutter did not, because DiagramOptions already exposes extent: how the view is framed is established as a user preference, and a house framing style set once is the same kind of wish as a preferred default view.

margin=0 is legal and fits exactly. It is the right answer for a caller composing panels whose frames must agree to the pixel. A negative or non-finite margin is not legal by any route. The configuration file has always refused one; the review of #194 found that the margin= keyword and a programmatic config.diagram.margin did not, so a negative value silently shrank the limits and clipped the data fit exists to contain, and a non-finite one failed later inside matplotlib with a message about axis limits. The check belongs where the value is resolved, so every route reaches it.

Margin is applied after the clamp of §3.2, so pressure= names the layer and margin= decides how tightly it is drawn. A clamp with margin=0 gives exactly the named band.

3.4 The configuration, reshaped#

config.diagram.extent carries the corner-pair shape in six coupled places, and decision 5 moves all of them together:

where

today

after

_config.py

Extent = tuple[tuple[float, float], tuple[float, float]]

a mapping of two named ranges

_config.py

DiagramOptions.extent

unchanged in name, new in shape; joined by margin

_constants.py

DEFAULT_EXTENT as two corners

the same window as two ranges

_configfile.py

_as_extent, expecting a list of two corners

expecting a mapping of two ranges

_configfile.py

_domain_extent, iterating corners

iterating ranges

_configfile.py

the template text, “Default view corners as [[pressure, temperature], …]

the range form

In a configuration file that reads:

diagram:
  extent: {pressure: [900, 200], temperature: [-65, 5]}
  margin: 0.05

The shape is better YAML as well as better API. #128 records that YAML spells a tuple as a list and that the difference bites; nested bare pairs are exactly the shape that makes it bite, and a mapping of two named ranges is a thing YAML expresses natively and a reader can check by eye. extent stays one key that is set or unset rather than splitting into two that must agree, which keeps Extent | None meaning what it says.

_domain_extent’s docstring currently says its rule is “Lifted from axes.TephigramAxes.set_extent”. That coupling is real and is preserved: the validator keeps testing finiteness and pressure positivity, against ranges instead of corners, and the docstring keeps saying where the rule comes from.

3.5 Autoscaling#

Both methods call set_autoscale_on(False). set_extent does today, and the reason given in its docstring — that later overlays never drift a window the caller fixed — applies identically to fit. A fit that left autoscaling on would be undone by the next plot_sounding, which is the one thing a caller who just framed their data did not ask for.

4. Migration#

Nothing is released, so there is no deprecation path, no alias and no shim. Every caller moves in the same change.

  • 29 set_extent occurrences across src/ and tests/, counted 2026-08-25.

  • src/tephpy/examples/plot_tephigram.py takes the new keywords.

  • src/tephpy/examples/plot_sounding_comparison.py drops its literal EXTENT for ax.fit(...) over both soundings. #184 names this example as exactly what fit is for, and an example that hand-picks a window to demonstrate comparison is evidence the API was missing. Its gallery figure changes with it; it carries no baseline of its own (§6).

  • Five design specifications mention set_extentspec §3.2 and spec §3.4, domain spec §3.3, plots spec, gallery spec and scope spec. #184 counted four; the scope specification landed after it was written. The parent, domain and gallery specs are corrected in place, alongside the config-file spec’s template, which this count does not name; the plots spec needs no correction, and the scope spec’s own stale count and quotation are corrected in place with it. The frozen implementation plans are not (docs spec §3.1).

  • The generated configuration reference re-renders from the reshaped template.

5. Alternatives considered#

  • A flat four-tuple, cartopy’s actual shapeset_extent([p0, p1, t0, t1]). It fixes the corner lie but keeps every other defect: four bare floats in a sequence are more order-ambiguous than two pairs, not less, and nothing in the call says which two are pressures. Rejected: the precedent §1 cites is worth abandoning rather than adopting correctly.

  • fit(soundings, parcel=None) — a named parameter for the parcel. More discoverable in the signature, but it privileges one composition, has no reading for two parcels, and needs a second rule the moment several soundings arrive. Rejected in favour of decision 3.

  • fit fitting the environment only, documented — simplest to specify and to test, and rejected because it ships the clipping defect of §3.2 in the API whose one promise is that nothing given to it falls outside the frame. Documenting a footgun is not the same as not having one.

  • Absolute margin in pressure and temperature — domain-meaningful and predictable, but two numbers for one idea, anisotropic once transformed, and needing revision every time the fitted span changes. Rejected in favour of §3.3.

  • A default pressure clamp on fit — tempting, because the unclamped view is poor for every real radiosonde and fit(snd) is the first thing anyone types. Rejected: any default is an arbitrary number that silently drops the data above it, and an invisible omission is worse than a visibly wide view. The how-to of §7 carries the burden instead, by making the clamp the second thing a reader meets.

  • A temperature= clamp beside pressure=, for symmetry with set_extent — rejected as speculative. A pressure band selects a layer of the atmosphere; a temperature band selects nothing a reader is thinking in. Symmetry with a method that names a view is not a reason for a method that derives one.

  • Dropping config.diagram.extent once fit exists — considered because fit answers the common case. Rejected: a preferred default view is a legitimate standing preference, and fit needs data, which a freshly created axes does not have.

6. Testing#

Per spec §7, and weighted towards the defects this change exists to remove.

what

how

a range pair yields the window the corner pair used to, where the two agree

equivalence test over the default extent

the view contains the whole region it names

the four corners of the named region all fall inside the resulting limits — the §1 case where two of them did not is asserted directly

the interior temperature extremum is bounded

a range spanning T* = MA 273.15 contains y(p, T*) at both pressure bounds — the (1000, 900) × (−50, 100) case of §3.1, which a corner-only search excludes

the resolved margin is validated wherever it arrives

keyword and config.diagram.margin are checked like a configuration file’s, not only at file load

order within a range is irrelevant

property test: (a, b) and (b, a) give identical limits

the §1 transposition is now unwritable

the old call shape raises TypeError, being neither keyword

a degenerate or unphysical range is refused

ValueError, message naming the keyword

fit over one sounding, over several, over sounding-plus-parcel

limits contain every finite datum of every argument

the parcel-clipping case

fit(snd) clips a parcel that fit(snd, parcel) contains — asserted directly, because it is the defect fit exists to prevent

a NaN-gapped dewpoint bounds nothing and poisons nothing

fit over a sounding whose dewpoint is partly NaN

an argument with no finite data at all raises, naming it

checked per argument, before any clamp is applied

an argument whose finite data falls entirely outside the pressure= clamp

contributes nothing, silently — not an error

nothing survives the clamp across every argument

raises MissingDataError, as with no clamp at all

margin resolution order

keyword beats config beats constant; margin=0 fits exactly

autoscaling is off after both calls

and a later plot_sounding does not move the window

the reshaped configuration round-trips

file → config.diagram.extent → applied view

the framing how-to’s figures

docs/baseline, via the published-figure gate (plots spec §3.5)

Five new image baselines, one per figure the how-to of §7 publishes: framing-fit-unclamped, framing-fit-clamped, framing-fit-parcel, framing-set-extent and framing-margin. plot_sounding_comparison carries no baseline of its own — the only mpl_image_compare in tests/examples/ covers plot_parcel_analysis, which this change does not touch — so its gallery figure changes with no baseline to update.

7. Documentation#

A framing how-to under docs/src/howtos/, publishing figures. Its spine is the contrast of §3.2: ax.fit(sounding) unclamped, which frames a whole ascent into the stratosphere and looks it, beside the same call with pressure= — the defect that shaped this section, used as the thing it teaches. Then the parcel case as the reason fit takes more than a sounding, and set_extent for “make these figures directly comparable”. It ships here rather than waiting for Plan 7c because 7c owns tutorials and explanation, and because shipping new API undocumented until a later plan is how the gaps Plan 7b spent itself closing came to exist.

The configuration how-to’s mention of “a preferred extent” gains the new shape. The glossary needs no new term: profile, parcel ascent and sounding already carry what the page says.

8. Scope#

In scope. set_extent’s signature and validation; fit and its reduction; the config.diagram reshape and the new margin; the constants behind both; the migration of §4; the tests of §6; the how-to of §7.

Out of scope. Everything Plan 7c owns. Layer shading (#79), which is a drawing feature and not a framing one. The transforms, which are unchanged — this specification changes which numbers reach them, never what they compute.

Open items, tagged per docs spec §3.5.

  • Resolved (2026-08-25, PR #194) — the whole of this specification. #184 closes with it.