tephpy — design specification#
Living document. This specification is maintained alongside the code, not archived behind it. tephpy’s source cites it by section — you will find
spec §6and the like throughoutsrc/— so these sections are the reasoning behind what the code does, and where the two ever diverge it is the specification that gets corrected. Read it as current.
Date: 2026-07-22 (originated; maintained since)
Status: living design specification, implemented incrementally by the plans in
docs/src/developer/plans/License: BSD-3-Clause (repo already carries it)
Repository: https://github.com/bjlittle/tephpy (PyPI name
tephpyverified free on 2026-07-22)Engineering standards baseline: bjlittle/geovista is the minimum bar — pixi-led workflow, SPEC 0 support window, Diátaxis docs, and the geovista pre-commit/CI conventions. See §8.
1. Purpose#
tephpy is a greenfield Python package for plotting and analysing tephigrams. It draws
on the proven core of SciTools/tephi — the
T–ln θ coordinate transform and zoom-aware isopleth artists — and adds the layer tephi
never had: parcel analysis and derived thermodynamic parameters, delegated to MetPy.
The requirements come from a verified research pass (2026-07-22) over Met Office Factsheet 13, Stull’s Practical Meteorology, University of Reading teaching material, COMET/UCAR training, and NWS/HKO operational guides, cross-checked against the tephi 0.4.0.dev0 codebase. In summary, tephigram users need:
The diagram: true rotated temperature–entropy axes (isotherms and dry adiabats exactly perpendicular; pressure a derived curve, not an axis) with five isopleth families — isotherms, isobars, humidity mixing-ratio lines, dry adiabats, moist adiabats. All intervals/extents/truncations are conventions and must be configurable.
Sounding plotting: temperature and dewpoint profiles against pressure in distinguishable colours, and wind barbs on a right-hand vertical staff using standard symbology (flag 50 kt, full barb 10 kt, half barb 5 kt).
Analysis: parcel ascent (dry adiabat from surface T meets the mixing-ratio line from surface Td at Normand’s point/LCL, then moist adiabat to the EL), with automatic CAPE, CIN, LCL, LFC, EL, wet-bulb potential temperature, and stability indices; the −25 mb operational cloud-base correction available explicitly.
Operational practice: overlaying multiple soundings (times, forecast vs observed) with distinguishable styles, legends carrying station identifier and UTC time, fixed comparable plot extents, indices displayed alongside the diagram, and publication-quality (vector) output.
tephi covers (1), (2) and much of (4); it has none of (3), no units handling, and only bespoke text-file ingest. tephpy exists to cover all four.
2. Decisions#
Decision |
Choice |
Rationale |
|---|---|---|
Relationship to tephi |
Greenfield successor (new repo, new API) |
The analysis layer, units handling, and ingest are a scope expansion that would break tephi’s plotting-only philosophy and API anyway |
Name |
|
Owner’s choice; PyPI name free |
Thermodynamics |
|
One unconditional API; inherited, community-validated parcel math; coherent pint units story. Accepted cost: heavier install, coupling to MetPy releases |
Data ingest |
Arrays + light readers |
Core accepts numpy/pandas/xarray with pint units; small |
Primary audience |
Research scientists |
Jupyter/scripting-first, composable API, publication output. Forecaster features are built as capabilities, not the organizing principle |
Architecture |
Layered library around a matplotlib projection |
See §3. Chosen over a sounding-centric god object and over a MetPy- |
Engineering standards |
Mirror geovista (§8) |
pixi-led, SPEC 0, Diátaxis, geovista pre-commit/ruff/mypy/CI conventions. geovista is the explicit minimum bar |
Build backend |
|
Matches geovista; dynamic version written to |
CI scope at v1 |
Core gates now, maintenance bots as fast-follow |
Load-bearing quality gates from day one; lockfile/canary/linkcheck/stale/JOSS bots deferred so a new repo isn’t buried in bot noise (§8.6) |
3. Architecture#
src/tephpy/
├── transforms.py # T–lnθ math (pure numpy)
├── plotting/
│ ├── axes.py # TephigramAxes + "tephigram" projection registration
│ ├── isopleths.py # 5 line families as zoom-aware artists
│ ├── barbs.py # wind-staff gutter, Met Office symbology
│ └── shading.py # CAPE/CIN area fills
├── calc.py # tephigram-native wrappers over metpy.calc
├── sounding.py # Sounding dataclass (data + metadata, pint units)
├── io/
│ ├── wyoming.py # University of Wyoming text reader
│ └── igra.py # IGRA v2 reader
├── examples/ # sphinx-gallery sources (one per use case)
├── exceptions.py # public shared exception hierarchy (§6)
├── _config.py # tephpy.config: typed runtime configuration (§3.5)
├── _constants.py # conventions: intervals, extents, colours (overridable)
├── _units.py # boundary units coercion over MetPy's registry (§5)
└── _version.py # written by setuptools_scm (not committed)
Dependency rule: transforms ← plotting, and transforms/sounding ←
(calc, io) — sounding sits below the analysis and ingest layers that consume
it. calc never imports plotting (plotting sees calc/sounding types only
under TYPE_CHECKING); indices can be computed headless, and plotting works
without ever touching calc.
3.1 transforms#
Pure functions (p, T) ↔ (T, θ) ↔ (x, y) — x = MA·ln θ + T, y = MA·ln θ − T with MA = 300 — derived from the published construction (Met Office Factsheet 13; Stull) and cross-validated against tephi as an oracle, not ported from it on trust (§7). Bare numpy arrays in diagram-native units (hPa, °C): the §5 units policy applies to the user-facing data boundaries above this module, not to the geometry engine matplotlib calls on every draw. Depends only on numpy; no knowledge of soundings, pint, or MetPy.
The matplotlib projection named "tephigram" is registered by plotting/axes.py —
a minimal TephigramAxes ships in Plan 2 and Plan 3 extends it in place — so that
plt.subplot(projection="tephigram") works with stock matplotlib idioms while
preserving the layering (plotting imports transforms, never the reverse).
The Plan 2 minimum: an invertible matplotlib Transform wrapping the transform
functions, equal aspect locked (the isotherm ⊥ dry-adiabat invariant must be visually
true), sensible default extents, zoom/pan working through the transform, and native
x/y ticks hidden by default — meaningful labelling arrives with Plan 3’s isopleths, and
§3.2’s edge labelling later reclaims those axes for a family that asks. Out-of-domain
input (p ≤ 0, unphysical T) propagates NaN rather than raising: exception-carrying
validation belongs to the quantified boundaries above (§6). Plan 2 also seeds
_constants.py (MA, the θ reference pressure, default extents) per §3.5’s
no-hard-coding rule. Oracle fixtures are generated by running tephi 0.4.0.post0 and
recording input/output pairs with a provenance header (generation script and tephi
version) — generated outputs, not copied source.
3.2 plotting#
Extended by a child specification. Branding —
tephpy.plotting.add_logo, which places the tephpy logo on a figure or an axes — is specified separately in2026-08-01-add-logo-design.md, which inherits this document’s error-handling (§6), testing (§7) and engineering-standards (§8) rules unchanged.
TephigramAxes draws the exactly-orthogonal isotherm/dry-adiabat grid and the three
curved families as zoom-aware artists, reimplementing tephi’s locator/refresh design
as one custom IsoplethFamily artist per family (plotting/isopleths.py). Member
polylines are precomputed as bare numpy arrays over a generous physical domain — the
straight families and isobars from transforms, moist adiabats and mixing-ratio
lines via metpy.calc (function-local imports keep import tephpy light; item 10) —
cached on the artist, and rebuilt only when family parameters or the domain change.
Each draw() clips the cached geometry to the current view rectangle, selects the
members appropriate to the zoom level, and re-places the family’s labels: pure numpy
per draw, with pan/zoom/resize/set_extent automatically current because matplotlib
calls draw on every render. Computing the curved families with MetPy keeps one
source of moist-thermodynamic truth — the background moist adiabats are exactly the
curves Plan 5’s parcel paths follow.
Differences from tephi:
Background isopleths are on by default, individually removable/configurable via accessor methods —
ax.isotherms(...),ax.isobars(...),ax.dry_adiabats(...),ax.moist_adiabats(...),ax.mixing_ratios(...). With no arguments an accessor returns the family artist; with kwargs (values=/interval=,color=,labels=,visible=, …) it reconfigures and returns it.Isopleth labels place inline or on the diagram’s edges — the declutter control, and the existing
labels=widened rather than joined by a new option, so the API grows no names. A placement isTrue(every member labelled inline — the default, unchanged),False(none), or an edge name"bottom"/"top"/"left"/"right", singly or as a tuple; a bare string and a one-tuple are identical, and a family may claim several edges. The rule is one sentence: listed edges label the members that reach them; every member left over is labelled inline. Soax.isobars(labels=("bottom", "left"))builds the printed chart’s pressure scale, andax.isotherms(labels=("bottom", "left"))labels the warm isotherms below the frame and the cold ones beside it (the coverage table below). Deliberate gaps are not expressible: thinning a family isvalues/interval’s job, and label placement must not become a second member filter. A member meeting a listed edge more than once — a curved isobar leaving and re-entering — is ticked at each crossing, and an invisible family (visible=False) labels nothing and holds no edge. Edge labels are native matplotlib ticks, not drawn text. Bottom and left claim the axes’ ownxaxis/yaxis(hidden by default per §3.1); top and right claim a lazily createdsecondary_xaxis/secondary_yaxiswith identity functions — verified 2026-07-29 to track both the equal-aspect shrunk position and theaxes_grid1divider. Each claimed edge takes a locator/formatter pair: the locator intersects the family’s currently selected members with the edge segment (the free functionedge_crossingsinisopleths.py— the module’s pure-builder pattern, headlessly testable against the analytic case, an isotherm crossingy = y0at exactlyx = y0 + 2T), and the formatter reads the member values cached alongside those positions, formatted"{value:g}"as inline labels already are — no inverse math, no MetPy, exact for all five families. Because matplotlib calls the locator on every draw, pan, zoom, resize andset_extentstay correct with no new refresh machinery, and a tight-bboxsavefig,tight_layout,tick_paramsandset_xlabelall work unwrapped. The crossings are computed twice per draw — once by the locator, once by the family filtering its inline remainder — because tick location and artist drawing have no guaranteed ordering; it is pure numpy over ~20 short polylines, and correctness beats the cache. A claimed edge also takes an axis title and its tick conventions from_constants; who owns each of those afterwards is the next bullet. One family per edge: two claimants raiseTypeErrornaming both and the edge, checked by the axes — which owns all five families and funnels both the accessor and creation paths — so atephpy.configconflict surfaces at axes creation rather than at first draw. An unknown placement raisesTypeErrornaming it and the valid set (theformat_coordstyle), the bare-string check preventing a silent per-character iteration.TephigramAxes.cleardrops the cached secondary axes alongside its existing xaxis/yaxis re-hiding. Not tephi’s design: tephi labels inline only.A claimed edge’s ticks are stock matplotlib and yours to style. tephpy stamps its tick conventions on an edge axis once, when that axis is created —
LABEL_FONTSIZE, the_constantstick length and pad, the bottom/left ticks-position pin (the classic style mirrors ticks onto the opposite edge, where another family may live), and the gridline suppression. That moment isclear()for the axes’ own xaxis/yaxis and the lazy build for a top or right secondary. Thereafter tephpy never touches presentation again: the only thing a later claim or sync changes is the tick colour, and only when the owning family’s own colour or alpha changes. Claiming an edge is then pure identity — locator, formatter, visibility, colour, title — and releasing it pure teardown. The first implementation re-asserted presentation on every sync, so an unrelated family’s resolve silently reverted a user’stick_params, andax.grid(True)after a claim survived only until the next resolve (both reproduced 2026-07-30). Nothing needed re-asserting: the locator holds a live family reference and recomputes on every draw. Matplotlib gives no provenance onset_tick_params, so the split has to be by when rather than by what the user touched. Tick colour is the exception because it is what ties a tick to the line it labels: the axes remembers the owner and the RGBA it last applied to each edge and re-applies only on a difference, so restyling the owning family reaches its ticks and nothing else does. That memory survives release, making a family visibility toggle a true round trip; the owner is part of the key because a bare RGBA memory suppresses a new owner’s claim whenever its colour matches the last one’s, stranding the ticks in a colour that ties them to nothing (reproduced 2026-07-30). Grid suppression lands at axis creation, which is afterAxes.clearreadsrcParams["axes.grid"], so a style still cannot smuggle in gridlines of constant data-space x or y — but an explicitax.grid(True)is now the user’s call.ax.clear()is the reset.The axis title splits the same way: its text is identity — from
_constants, one per family,Temperature (°C)throughMixing ratio (g kg⁻¹)— and its styling is presentation tephpy never touches. The fill-when-empty guard stands unchanged, but it now runs only on a first claim, and that alone makes it honest: a user’sset_xlabelstill wins whether it precedes or follows the accessor call, andset_ylabel("")durably means “ticks, no title” rather than reappearing on the next resolve, because no later sync looks at the label again. Releasing clears and forgets tephpy’s own title while leaving a user’s replacement alone, so a reclaim stamps afresh, a new owner restamps with its own, and the disable holds for the life of the claim. Nothing richer is needed — release always forgets, so a first claim never meets a title tephpy still remembers writing, and a provenance check could not differ from the guard.ax.edge_axis(edge)is the uniform public handle on all four edges, returning the matplotlibAxisthat draws that edge’s ticks, keyed by the same edge vocabularylabels=uses. Without it, top and right are reachable only through a private_secondary_axesor an undifferentiatedchild_axesthat must be sniffed to tell one from the other. An unknown name raisesTypeErrornaming it and the valid set (theformat_coordstyle); an unlabelled edge raisesValueErrorsaying so and how to claim one, because probing must not materialise a secondary axes nobody is using and an unclaimed edge renders nothing to style. Releasing a top or right edge hides its secondary axes rather than removing it, so a held handle stays live and its ticks and title survive a release/reclaim exactly as bottom and left do. It is the whole secondary axes that hides, not merely itsAxis, or its spine would keep drawing; a claim correspondingly shows both, since showing the container alone would leave anAxisthe user had hidden drawing no ticks on an edge that has just been claimed; an invisible secondary returnsNonefromget_tightbboxandAxes.clearemptieschild_axes(both verified 2026-07-30), so the persistence costs nothing in layout andTephigramAxes.clearstill reaps them.Any member of any family can be emphasised.
emphasis=on all five accessors and on everytephpy.configfamily section maps a member value to a mapping of style overrides —color,linewidth,linestyle,alpha— and an omitted key falls back to the family’s own resolved style, soax.isotherms(emphasis={0.0: {}})is the 0 °C isotherm atEMPHASIS_LINEWIDTHin the family’s own ink, while an empty mapping (emphasis={}) emphasises nothing and is how atephpy.configemphasis is cleared at the accessor. The motivating case is the freezing level, which operational practice singles out everywhere and no library provides: MetPy’s advanced-sounding example hand-rolls it (# first, we add a matplotlib axvline to highlight the 0-degree isotherm), SHARPpy labels the 0, −20 and −30 °C levels in dark blue, and NWS skew-T training treats the 0 °C crossing as a named index (FRZ) — all verified 2026-07-30. On a tephigram that isotherm is slanted, so theaxvlineescape hatch skew-T users rely on does not exist and a tephpy user today has no supported way at all; tephi’s documented customisation is whole-family and does not cover isotherms at all. It is deliberately not a “zero isotherm” feature: −20 °C bounds the airframe icing band, a mandatory isobar is the same gesture on another family, and one option on the sharedLineOptionsbeats five special cases. Nothing is emphasised by default — every other tephpy default cites a printed-chart convention, and the evidence found is operational software rather than Factsheet 13, whose published URL now 404s (2026-07-30); defaulting it on would be inventing a convention, and flipping that decision later is a one-line change.EMPHASIS_LINEWIDTHis the single new constant, because emphasis defaults to the monochrome printed-chart idiom — same ink, heavier line — so no colour convention is invented and the SHARPpy look stays one keyword away.linestyleis accepted per member though the family has no family-levellinestyle: dashing is the dominant emphasis idiom, and the wider option can follow without conflict. Malformed emphasis raises fromconfigureinside its existing rollback — a non-mapping, a key that will not convert to float, a member value that is not a mapping, or an unknown style key all raiseTypeErrornaming the family and listing the four accepted keys; a non-positive or non-finitelinewidth, or analphaoutside[0, 1], raisesValueErrormirroring theintervalcheck.colorandlinestyleare left to matplotlib, exactly as the family-levelcoloralready is.Emphasis forces its member to be drawn, which is what lets it double as the reference-line mechanism rather than needing one:
emphasisjoins the geometry keys, its keys union into the candidate values the family builds, and the zoom mask forces them true, soemphasis={-12.0: ..., -18.0: ...}marks the dendritic growth zone’s bounds on a 10 °C ladder that would never select them. The view mask still applies, so an off-screen member stays off screen — which is also why a value outside the family’s generous_constantsdomain is a no-op rather than an error. That no-op is silent on the three straight/analytic families; on the curved two the builder can complain before the mask ever runs —moist_adiabats(emphasis={500.0: {}})emits a MetPyUserWarningabout an undefined saturation mixing ratio andmixing_ratios(emphasis={0.0: {}})two numpyRuntimeWarnings (both verified 2026-07-30). The fragility is the builders’, not emphasis’s —values=[0.0]does the same without any emphasis — so it is not a regression, but it is why the curved families’ accessor docstrings pick an in-domain example rather than promising a silence they cannot deliver. Because_selected_membersis shared with_EdgeLocator, a forced member gets its edge tick for free; the tick’s colour does not follow, sinceset_tick_paramsis whole-axis and per-Tickstyling would fight the presentation-stamped-once rule above — a documented limitation, and emphasis is a per-member gesture where inline labelling is the common case. Draw order stays inside the family:drawpartitions the selected members base-then-emphasised on the single existingLineCollection, whosecolor,linewidthandlinestyleall accept per-segment sequences (verified on matplotlib 3.11.1, 2026-07-30; the declared floor is 3.10).alphais the exception: it takes a per-segment sequence within a single call but not across redraws, becauseLineCollection.set_colorcallsto_rgba_array(c, self._alpha)eagerly, so an array-valued_alphaleft over from the previous draw raises the moment the segment count changes — which is every zoom. Emphasis alpha is therefore baked into the RGBA 4-tuple and_alphais held atNoneon that path; that is load-bearing, not incidental, and “simplifying” it back to a per-segmentset_alphareintroduces the crash. An emphasised member therefore wins against its own family’s neighbours, while the families above it still nick it with a 0.5 pt overpaint at each crossing — accepted rather than bought off with a sixth axes-owned artist to create, sync and tear down. Inline labels take the same per-member style, being per-memberTextalready. The one trap:mixing_ratiosselects by stride over member index, not by value, so an emphasis-only addition would shift every later index and silently change which members the stride picks at every zoom level; the build therefore records which members exist only because emphasis asked for them, and the stride mask is computed over the canonical members by their canonical position. The resolved mapping is deep-copied when it resolves, for the same reasonvaluesmaterialises a generator to a tuple: the snapshot must not alias a dict the caller can still mutate.ax.plot_profile(pressure, temperature, *, units=None, label=None, **kwargs)accepts pint quantities — or bare arrays with the §5units=mapping — converts to diagram-native units, plots through the tephigram transform machinery, and returns theLine2D; matplotlib kwargs pass through untouched. The same signature also accepts acalc.Profile(e.g. the return ofcalc.parcel_path) as its only positional argument (the first parameter keeps its Plan 4 name; theProfileform is positional). Dispatch is duck-typed on theProfileshape — thetemperatureparameter omitted and arraypressure/temperatureattributes pluslcl_pressurepresent (Soundinglackslcl_pressure;SoundingIndiceslacks the arrays) — soplottingnever importscalc(the §3 layering; the sameTYPE_CHECKINGtrickplot_soundinguses forSounding), typed with@overload. Label precedence:label=argument >profile.label> no entry. Wrong argument combinations stayTypeErrors, never units errors: aProfiletogether withtemperatureorunits=, and equallytemperatureomitted when the sole argument is notProfile-shaped (a bare pressure array, or aSoundingpassed by mistake). In both formsplot_profilesets no style defaults — it is the low-level primitive (§4 styles parcel paths explicitly at the call site).ax.plot_sounding(snd, *, label=None, **kwargs)plots temperature plus dewpoint-when-present as two profile lines in the conventional colours (temperature red, dewpoint green — the operational/MetPy convention; colours, linewidth, and a zorder above the isopleth families all live in_constants). One legend entry per sounding, attached to the temperature line (the dewpoint line is"_nolegend_"); label precedence islabel=argument >snd.label> no entry. Returns(temperature_line, dewpoint_line | None). Legends stay stock matplotlib — tephpy sets labels, the user callsax.legend().ax.plot_barbs(snd, *, x=None, minimum_separation=None, **kwargs)— the sounding’s wind barbs on a right-hand gutter staff (Met Office symbology: flag 50 kt, full barb 10 kt, half barb 5 kt, rounded to 5 kt bins), raisingMissingDataErrorwhen the sounding has no wind (§6). The staff is drawn by a zoom-aware artist inplotting/barbs.py(theisopleths.pyrefresh pattern): each draw selects the levels whose isobars cross the current view, thins them to the densest subset at least a minimum vertical separation apart — zooming in reveals more levels — and places each barb at the y where its level’s isobar meets the diagram’s right edge (the printed-form staff convention, where the staff’s pressure marks sit where the isobars cross it), interpolated along the isobar polyline in pure numpy. Wind speed converts to knots; u/v come frommetpy.calc.wind_components(function-local import — the §3.2/§3.3 one-source-of-truth idiom). Calm levels render as matplotlib’s native small circle — which is the Met Office calm symbol (verified at plan drafting, 2026-07-27).xpositions the staff as a fraction across the gutter andminimum_separationsets the thinning distance in points (both default to their_constantsvalue): overlaid soundings pick different positions, separations, and a colour — the explicit-styles convention profile overlays already use — within one fixed-width gutter. Returns the staff artist; matplotlib kwargs pass through to the barbs. Gutter width and pad, staff position, minimum separation, and the barb increments live in_constantswith their source conventions cited (Factsheet 13) — staff position and minimum separation being the two a call overrides, the constants supplying their defaults; like profile lines and shading, notephpy.configsection at v1.ax.shade_cape(snd, parcel)/ax.shade_cin(snd, parcel)— area fills between the environment temperature and the parcel path, bounded exactly as MetPy’scape_cinintegrates so the shading always matches the annotated numbers: CAPE is the positive-buoyancy region from the LFC to the EL (to the profile top when EL is NaN with CAPE > 0, §6), CIN the negative-buoyancy region from the parcel start to the LFC. Pure builders inplotting/shading.pysample both curves onto their merged pressure grid along the drawn polylines — the straight segments in tephigram (x, y) space that matplotlib draws between profile levels, so the fill closes on the plotted lines at every figure scale (a pressure-space interpolation bows away from the drawn chords between levels; issue #42) — locate the crossings where the drawn segments intersect, and return the region’s closed polygons in (T, θ) space — plural when the region is interrupted — theisopleths.pyfree-builder pattern, headlessly testable. The axes methods draw them through the tephigram transform as one compound-pathPathPatchper call; zero area returnsNone— 0 is an answer, not an error (§6). Styling is matplotlib kwargs over_constantsconventions (colours, alpha, a zorder between the isopleth families and the profile lines); notephpy.configsection at v1, matching the profile-line treatment.ax.annotate_indices(indices)— a text panel of derived parameters beside the diagram: the first consumer of the side-of-axes contract below, appended with theaxes_grid1divider, one formatted line perSoundingIndicesfield (NaN renders as an em dash); field formats and the panel width live in_constants. Returns the panel axes so users can restyle; calling it again updates the panel in place rather than stacking a second one. Withaxes_grid1, append order is position order; Plan 6 makes call order irrelevant rather than enforcing it — a laterplot_barbsrelocates the existing panel outside the new gutter (the relayout in the layout contract below).ax.set_extent(...)— fixed extents from ((p, T), (p, T)) corners so successive figures are directly comparable; disables autoscaling so overlays don’t drift the window. (The cartopy idiom — the earlierset_anchorname collided with matplotlib’s ownAxes.set_anchor.)ax.format_coord(x, y)— the interactive cursor readout (the navigation toolbar’s coordinate text) reports diagram-meaningful values instead of the raw rotated data-space (x, y): the cursor position inverts throughtransforms.temperature_theta_from_xy, pressure derives viatransforms.pressure_from_temperature_theta, and the configured fields render in listed order, e.g.850 hPa, -4.2 °C, θ 8.6 °C(whole hPa, one decimal for temperatures). Fields name entries in a five-strong registry mirroring the isopleth families:"pressure","temperature","theta"— closed-form, the default trio — plus opt-in"mixing_ratio"(saturation mixing ratio at the point, g/kg, one decimal) and"theta_w"(the moist adiabat through the point), the latter two viametpy.calcwith function-local imports (the one-source-of-truth idiom above; a user who never lists them never pays for them). Selection resolves as instance assignment >tephpy.config>_constants:config.cursor.fieldsis read live on every mouse event — soconfig.context(cursor={"fields": ...})scopes cleanly — and full customisation stays stock matplotlib: assigningax.format_coord = fnshadows the method (documented, not wrapped). Out-of-domain positions (the inverse yields NaN) return""so the toolbar goes blank rather than showing garbage; an unknown field name raisesTypeErrornaming it and the valid names (the family-configurestyle), surfacing on the first mouse move. Headlessly testable —format_coordis a plain string-returning method.
Edge coverage decides which pairing suits each family. Measured 2026-07-29 against the
real families at DEFAULT_EXTENT (matplotlib 3.11.1) — of the members the zoom ladder
selects, how many reach each edge, and how many reach at least one:
family |
members |
bottom |
top |
left |
right |
any |
|---|---|---|---|---|---|---|
isotherms |
19 |
11 |
16 |
8 |
3 |
18 |
isobars |
19 |
1 |
2 |
18 |
3 |
19 |
dry adiabats |
35 |
9 |
20 |
7 |
3 |
27 |
moist adiabats |
21 |
0 |
7 |
1 |
0 |
8 |
mixing ratios |
8 |
0 |
8 |
1 |
0 |
8 |
No single edge covers a family, which is why placements are a tuple and why the inline remainder is automatic rather than optional. The pairings the numbers recommend:
Isobars
("bottom", "left"), interval=150— at the default 50 hPa spacing all 19 members tick (none doubled, nothing left inline: the left edge carries 150–1000 hPa and the bottom edge the 1050 hPa isobar alone), but the left-edge labels crowd;interval=150gives a legible ~6-label scale. The printed chart’s pressure scale.Isotherms
("bottom", "left")— 18 of 19, the warm 11 below (−40 to 60 °C) and the cold 8 beside (−110 to −40 °C). The two edges are not disjoint: −40 °C passes through the corner and is ticked on both, and −120 °C reaches no edge at all and falls to the inline remainder. Both rules above, visible in one call.Mixing ratios
"top"— 8 of 8, a complete scale from one token.Dry adiabats
("top", "left")— 27 of 35; the 8 that reach nothing stay inline.Moist adiabats — 8 of 21 at best, because they are truncated curves that mostly begin and end inside the view. Not an edge family: leave them inline or
labels=False.
The counts are extent-dependent — set_extent changes every one of them — which is
precisely why the crossings are recomputed by the locator on each draw rather than fixed
when the family is built.
Side-of-axes layout contract (decided in Plan 3, built by the consuming plans):
panels beside the diagram are appended with mpl_toolkits.axes_grid1’s axes
divider, which tracks the equal-aspect box height — right side, inside-out: Plan 6’s
barb gutter, then Plan 5’s indices panel. Panel widths join _constants with their
plans. All side panels must share one cached divider: a second
make_axes_locatable(self) call builds a fresh AxesDivider and replaces the parent
locator, detaching the earlier panel so it draws over the newcomer. Plan 5 has the
sole panel (annotate_indices), so it creates and owns the divider inline; when Plan 6
adds plot_barbs it must reuse that divider (cache it on the axes and share it
across the side-panel methods), not call make_axes_locatable again. (Raised in the
Plan 5 review; deferred here because the two-panel path is only reachable — and
testable — once the barb gutter exists.) Resolved 2026-07-27 (Plan 6): the divider
is created once, cached privately on the axes, and shared — annotate_indices is
refactored onto it, plot_barbs appends the gutter through it, and
make_axes_locatable is called exactly once per axes. Call order is made irrelevant
rather than enforced: one relayout helper rebuilds the divider’s horizontal stack
(diagram, gutter, indices panel — skipping absent panels) and reassigns every
locator whenever a panel appears, so the inside-out contract holds on every call
path with no panel teardown or re-rendering. (Refined at plan drafting, same day:
axes_grid1’s append_axes only ever appends to the size stack, so the earlier
remove-and-re-append sketch would leave a stale width slot — a phantom gap;
relayout is the clean mechanism, verified empirically.) The right edge is the one
contested by both features: BARB_GUTTER_PAD is 0.1 in, narrower than an 8 pt tick
label, so right-edge isopleth labels would land on the gutter. Rather than forbid the
combination or document a collision, the relayout helper substitutes a wider
_constants pad when the right edge carries labels — one lookup in a helper that
already rebuilds the stack on every panel call, and no rule for the user to remember.
3.3 calc#
Physics is delegated to metpy.calc; only tephigram-native compositions live
here, and everything returns pint quantities on the shared registry (§5).
Sounding-level functions take a Sounding — constructing one is the §3.4
one-liner that already validates units, monotonic pressure, and Td ≤ T, so
calc keeps a single validation path — while normand_point is the one
quantity-level function. calc imports transforms and sounding, never
plotting; MetPy stays behind function-local imports (the established idiom,
policed by the import-cost guard test), so calc re-exports eagerly at the
top level and import tephpy stays light (item 10). Two frozen dataclasses
(the Sounding idiom: coerced and validated at construction) carry results:
Profile:pressure/temperaturequantities for the full ascent (surface-first), scalarlcl_pressure/lcl_temperature(the Normand’s point the path actually uses — i.e. the corrected one when a correction was requested),parcel("surface"|"mixed-layer"), andlabel(legend text;None= no entry). Construction mirrorsSounding: bare arrays take the §5units=mapping, fields are dimension-checked quantities on the shared registry, and__post_init__validates 1-D equal-length arrays of at least two levels with strictly decreasing pressure (TephpyValidationError), the LCL inside the path’s pressure span, and theparcelliteral (ValueError). Plain plottable data:plot_profiledraws it and the shading builders consume it, and neither re-derives the LCL.SoundingIndices: ten scalar quantity fields —cape,cin,lcl_pressure,lcl_temperature,lfc_pressure,lfc_temperature,el_pressure,el_temperature,theta_w,lifted_index— each dimension-checked at construction and documented with the §6 NaN-versus-zero semantics (no cross-field validation: NaN fields are answers).theta_wis the lifted parcel’s wet-bulb potential temperature, evaluated at the parcel start (p, T, Td), so it follows theparcel=option. The v1 set is a decision (§11): Showalter, K-index, and Total Totals stay one-linemetpy.calccalls for users, shown in a docs example rather than wrapped.
Functions:
parcel_path(snd, *, parcel="surface", cloud_base_correction=None, label=None)→Profile(dry adiabat → Normand’s point → moist adiabat, spanning parcel start pressure to profile top; requiressnd.dewpoint, §6).parcelselects the lifted parcel:"surface"(default) or"mixed-layer"(metpy.calc.mixed_parcel; its 100 hPa default depth is the operational convention); an unknown value is aValueError(bad code, not bad data).cloud_base_correctionis a pressure-dimension quantity applied to the LCL only when explicitly requested — the operational −25 mb value lives in_constantswith its source convention cited, and the corrected LCL temperature is re-read from the dry adiabat at the corrected pressure. The moist leg is integrated withmetpy.calc.moist_lapse(..., reference_pressure=p_lcl)at the background family’s 5 hPa step — same integrator, same sampling, same anchoring as §3.2’s moist adiabats — so a parcel whose θw equals a member value lies exactly on that background curve; the LCL vertex is spliced in exactly. The dry leg samples the same 5 hPa step (a dry adiabat is straight in (T, θ), but uniform sampling keeps the §3.2 shading interpolation faithful). (θw reported byindices()useswet_bulb_potential_temperature, whose Davies-Jones formulation differs from the ODE by ≲0.1 °C; the path is drawn by the integrator, the number by the named function, and the divergence is documented.)normand_point(pressure, temperature, dewpoint)→ (p, T) of the LCL — scalar quantities (bare values take the §5units=mapping), always the uncorrected geometric construction.parcel_pathcomposes it.indices(snd, *, parcel="surface", cloud_base_correction=None)→SoundingIndices, with the same parcel options asparcel_path. The mechanism: derive the parcel curve on the environment levels under the same parcel-selection and correction rules asparcel_path, then feed it to the genericmetpy.calcfunctions that take a parcel-profile argument (cape_cin,lfc,el,lifted_index); thelcl_*fields report the point the path uses (corrected when requested) andtheta_wthe parcel start, mirroringProfile. With the defaults this reduces to plain surface-parcel delegation — which is what §7’s field-equality test targets — and composition, not thermodynamics, is what tephpy tests (§7).
3.4 sounding + io#
Sounding: a frozen dataclass holding pressure/temperature/dewpoint/wind-speed/
wind-direction arrays as pint quantities. Pressure and temperature are required;
dewpoint and wind are optional (a Sounding without wind plots profiles but raises on
plot_barbs; one without dewpoint raises on parcel analysis), and the two wind
fields must arrive together. Inputs are coerced in __post_init__ — bare arrays
need the §5 units= mapping — so a constructed Sounding always holds quantities.
station and time are optional metadata; label is the legend text. An explicit
label= stands as-is; otherwise it derives as "72357 2013-05-20 12Z" when both
station and time are present (naive datetimes read as UTC, aware ones converted to
UTC; format string in _constants per §3.5), otherwise None — and None means
no legend entry. Distinguishing forecast-vs-observed overlays of one station/time
is the label override’s job; there is no dedicated field for it.
Validation at construction (§6 — fail at ingest, not mid-plot): 1-D equal-length
arrays of at least two levels; finite, strictly monotonic pressure accepted in
either direction and normalized to decreasing (surface-first) storage with all
arrays reversed together, so downstream metpy.calc sees one orientation; where
dewpoint and temperature are both non-NaN, Td > T is rejected (equality —
saturation — is physical). NaN gaps are data everywhere except pressure.
Constructors: Sounding(...) from quantities or bare arrays + units=;
Sounding.from_dataframe(df, **column_map) — column names default to field names,
column_map overrides, bare columns take the units= mapping, and
pd.Timestamp/datetime64 are accepted for time;
Sounding.from_dataset(ds, **var_map) — units read from each variable’s
attrs["units"] (the xarray/CF convention) parsed through the registry, units=
as the explicit override, TephpyUnitsError when neither exists. pandas and
xarray are declared runtime dependencies, but tephpy never imports either at
runtime — the constructors are duck-typed over the objects handed to them, with
TYPE_CHECKING-only annotation imports (item 9). Sounding re-exports eagerly
at the top level — from tephpy import Sounding (item 10). Readers (io.wyoming.fetch, io.igra.read) return
Sounding objects, so the §6 ingest validation applies to fetched data unchanged;
both keep their network/archive imports function-local (the established idiom,
policed by the import-cost guard test) and tephpy.io re-exports eagerly (item 10).
wyoming.fetch(station, time, *, timeout=None)fetches one ascent from the University of Wyoming archive over stdliburllib— no new dependency; the timeout default lives in_constants— and hands theTEXT:CSVbody to a pure, transport-free parser. (TEXT:CSVis the post-2024 wsgi interface’s machine-readable form, bare self-describing CSV; the classiccgi-binTEXT:LIST endpoint is gone — probed live 2026-07-27.) The parser reads the pressure/temperature/dew-point/wind columns in the header’s units (hPa, °C, degrees, m/s — the classic knots column no longer exists), blank fields → NaN (NaN gaps are data, §3.4), keeps rows only while pressure strictly undercuts the running minimum (first occurrence wins; the dense BUFR-era ascents must satisfySounding’s strict monotonicity), and treats an entirely-NaN optional field as absent — the wind pair as a unit, so a one-sided wind column passes as absent rather than trippingSounding’s pairing rule — keepingMissingDataErrormeaningful (§6).stationis the WMO identifier ("72357");timeis a datetime or ISO string, naive read as UTC (theSoundingconvention). Station and time land as metadata, so the legend label derives for free. Network failures and HTTP errors — the archive replies 400 “no data at that time” / 404 “unknown station” with a one-line plain-text body — raiseTephpyIOErrorsummarising the upstream reply (§6).igra.read(path, *, time=None)reads one ascent from an IGRA v2 per-station file — the as-distributed.zipor the extracted.txt, sniffed withzipfile.is_zipfilerather than by suffix. The fixed-width records parse with the IGRA sentinels (−9999/−8888) → NaN, dewpoint derived as temperature − dewpoint depression, wind converted from tenths of m/s, records without a pressure value dropped (Soundingrequires finite pressure), and the same running-minimum monotonicity filter and entirely-NaN-optional-field rule as the Wyoming parser applied.time=selects the ascent and may be omitted only when the file holds exactly one sounding (trimmed research subsets, fixtures); an ambiguous read raisesTephpyIOErrorreporting the file’s sounding count and time span, an unmatchedtime=reports the nearest ascents (or that the file records no nominal launch times), as does malformed input (§6).
3.5 _constants + tephpy.config#
All conventions — 10 °C isotherm interval, 10 mb isobar interval, moist-adiabat
truncation temperature, gutter width, colours — live in _constants.py as defaults;
nothing numeric is hard-coded at point of use, and docstrings cite the source
convention (e.g. Met Office Factsheet 13). The mutable runtime layer over them is
tephpy.config (_config.py): a typed singleton of per-family dataclass sections
plus diagram-wide and cursor sections (e.g. config.isobars.interval,
config.moist_adiabats.truncation, config.diagram.extent,
config.cursor.fields), with a config.context(...) manager for temporary
overrides. Precedence: accessor kwargs > tephpy.config > _constants. Config is
read when a family is created or reconfigured; changing it does not retroactively
restyle existing axes (matplotlib rcParams semantics). The cursor readout is the
one exception: config.cursor is read live per mouse event (§3.2), so a context
override applies to existing axes for its duration.
3.6 Browser documentation demo#
The documentation carries one experimental, entirely client-side tephigram demo. A
reader explicitly launches a lazy-created iframe; only then does it download PyScript
2026.7.3 and Pyodide 314.0.4, install the current checkout’s wheel, and render through
matplotlib’s WebAgg-derived Pyodide backend. Every documentation build entry point,
including the Read the Docs custom build, creates that wheel and stages it with the
application under docs/_build/browser/ before Sphinx runs; Sphinx publishes the staging
root through html_extra_path. Generated wheels remain build artifacts and are never
committed.
The browser runtime has a checked-in lock manifest. MetPy 1.7.1 and Pint 0.25.3, their resolved pure-Python dependency chain, package hashes, and CDN URLs are exact; compiled dependencies come from the package lock belonging to the pinned Pyodide runtime. The current checkout’s generated wheel is installed with the SHA-256 fragment recorded in the generated runtime manifest. The UI reports progress throughout installation and exposes a readable, live-region error if any dependency fails. Chromium is the tested browser; Firefox and Safari are best-effort.
The bundled example plots at startup. A local upload replaces it only after parsing,
Sounding construction, and creation of the successor figure succeed, so an invalid file
does not destroy a good plot. Before a successful replacement, the prior figure is closed,
its WebAgg DOM is removed, and its Python callback proxies are destroyed. The resulting
canvas retains matplotlib’s pan, zoom, coordinate readout, home/reset, and download tools.
WebAgg’s prose hover hints are suppressed because inserting them into the toolbar moves its
buttons; their accessible image labels and the backend-driven coordinate readout remain.
The controls are left-aligned and the coordinate readout is right-aligned, so text appearing
on the right does not shift the buttons beneath the pointer.
WebAgg still focuses its canvas interaction layer for keyboard navigation, but does so with
preventScroll so the first pointer press cannot snap either the iframe or its parent page.
The tutorial replaces those hints with a compact guide to all six controls, using the
corresponding icon artwork from the pinned Matplotlib runtime without links or decorative
containers.
A collapsed, scrollable table below the canvas exposes the normalized quantities used by
the plot with sticky unit-bearing headers; it retains its expanded state across successful
replacements and remains paired with the prior plot after an invalid upload. The uploaded
filename is both the plot label and title; wind data, when present, is passed to
plot_barbs.
The demo CSV contract is deliberately not a package-level reader:
pressure_hPaandtemperature_Care required.dewpoint_Cis optional.wind_speed_m_sandwind_direction_degreeare optional as a pair.Blank cells become NaN; an absent optional column becomes
None.Missing or duplicate headers, rows whose cell count differs from the header count, nonnumeric nonblank cells, empty data, and a one-sided wind pair are structural errors. Physical validation remains
Sounding’s responsibility.
The experiment adds no public Python API. University of Wyoming wyoming.fetch, live
archive access, persistent configuration, analysis controls, offline caching, and other
live network data are out of scope. In particular, it does not reverse §9’s supported-product
decision against a tephpy GUI or dashboard: this is a documentation example with a small
local-file boundary, not an application surface.
4. Canonical usage#
import matplotlib.pyplot as plt
import tephpy
from tephpy.io import wyoming
snd = wyoming.fetch("72357", "2013-05-20 12:00") # → Sounding
fig, ax = plt.subplots(subplot_kw={"projection": "tephigram"})
ax.plot_sounding(snd) # T + Td, legend "72357 2013-05-20 12Z"
ax.plot_barbs(snd)
parcel = tephpy.calc.parcel_path(snd)
ax.plot_profile(parcel, color="k", linestyle="--")
ax.shade_cape(snd, parcel)
ax.shade_cin(snd, parcel)
ax.annotate_indices(tephpy.calc.indices(snd))
fig.savefig("sounding.pdf")
The station/time is deliberate: Norman, Oklahoma on the morning of the 2013
Moore EF5 tornado — a profile with ≈1800 J/kg of CAPE and ≈−270 J/kg of CIN —
so shade_cape and shade_cin have visible regions to fill and every call in
the example demonstrably renders. A stable profile reduces the shading to
invisible slivers.
Comparing soundings is two plot_sounding calls with different styles; set_extent
keeps extents identical across figures.
5. Units policy#
Every public boundary accepts pint quantities and converts internally (hPa/°C are the
diagram’s native units; K/Pa inputs just work). Bare arrays are accepted only with
an explicit units= argument — never silently assumed. Return values are always
quantities. This is a deliberate fix for tephi’s hard-wired hPa/°C/knots.
One documented exemption: the transforms geometry layer (§3.1) trades in bare numpy
arrays in diagram-native units (hPa/°C), because matplotlib’s per-draw transform
pipeline consumes bare arrays; every layer above it converts before calling down.
The machinery (_units.py, private): tephpy standardizes on MetPy’s pint
registry — one registry across tephpy, MetPy, and user code, so quantities flow
into metpy.calc without cross-registry errors (MetPy imported function-locally to
keep import tephpy light). A single boundary helper
as_quantity(value, *, name, units=None, dimension) checks a quantity’s
dimensionality, wraps a bare array (units= required), and raises
TephpyUnitsError naming the argument and the one-line fix — for unit-less input,
wrong dimensionality, or the ambiguous quantity-plus-units= case alike. At
multi-argument boundaries units= is a mapping keyed by argument/field name
(units={"pressure": "hPa", "temperature": "degC"}) — one mechanism at every
signature rather than per-signature positional conventions.
6. Error handling#
Unit-less input without
units=→TephpyUnitsErrornaming the argument and the one-line fix.Physically impossible input (Td > T, non-monotonic pressure) → specific exception types identifying the offending levels;
Soundingvalidates at construction so bad data fails at ingest, not mid-plot. Analysis-time data errors (missing dewpoint, a profile too short for the requested parcel ascent) raise at thecalcboundary instead — the earliest point they are knowable, since they depend on theparcel=/correction options.Analysis results distinguish “does not exist” from “zero” (verified against MetPy 1.7.1 — item 11):
metpy.calcreturns NaN quantities for a missing LFC/EL and0 J/kg— never NaN — for zero CAPE/CIN, and tephpy passes both through, documented perSoundingIndicesfield. EL can be NaN while CAPE > 0 (the parcel is still buoyant at the profile top). A profile topping out below 500 hPa makeslifted_indexNaN with a MetPyUserWarning; tephpy suppresses that specific warning at the call site and returns the NaN field — a meteorological answer that keepsfilterwarnings = ["error"]test suites (including tephpy’s own) green. Interior NaN gaps in temperature/dewpoint pass through to MetPy, which tolerates them.Reader failures (network, unrecognised station, malformed archive) →
TephpyIOErrorwith the upstream response summarised.The shared hierarchy lives in public
tephpy/exceptions.py(users catch these):TephpyErrorat the root;TephpyUnitsError;TephpyValidationErrorcarryinglevels: tuple[int, ...]of offending indices, specialized byNonMonotonicPressureErrorandDewpointExceedsTemperatureError(Plan 4),MissingDataError— a sounding lacking the field an operation needs, e.g. dewpoint for parcel analysis; Plan 6’splot_barbsreuses it for absent wind — andProfileTooShortError— the profile tops out at or below the LCL the path would use (the corrected one when a correction is requested), so no moist ascent exists;parcel_pathandindicesboth raise it, since every parcel-derived field would be meaningless (both exceptions Plan 5). Plan 6 addsTephpyIOError.
7. Testing#
Transforms (verify-first, tephi as oracle): each function is derived from the published construction and challenged per case rather than ported on trust — (1) hypothesis round-trip property tests ((p,T) → (x,y) → (p,T) ≡ identity) over the physical domain; (2) analytic fixed points whose derivations are recorded alongside the test; (3) the isotherm ⊥ dry-adiabat invariant asserted directly in display space; (4) cross-checks against recorded tephi outputs for the same inputs, within tolerance. Disagreement with the oracle triggers investigation; first principles and documented convention win, and divergences are recorded. Attribution attaches only where tephi artifacts are actually copied (per case, via a NOTICE file if needed).
Plotting: image-baseline tests via pytest-mpl (small in-repo PNGs, tolerance-tuned) for each isopleth family, profiles, barbs, shading, the printed-chart edge-labelling configuration, member emphasis, and the composed §4 figure. Deliberately not tephi’s external image-hash repo, which is a contributor-hostile maintenance burden. Curved-family geometry is additionally cross-checked against recorded tephi outputs informationally — MetPy’s and tephi’s moist-thermo formulations differ, so divergences are investigated and documented, not forced to zero.
Calc: test composition, not thermodynamics — parcel path passes through Normand’s point;
indices()fields equal directmetpy.calccalls on the same profile (the uncorrected surface-parcel default; corrected and mixed-layer runs assert against the hand-built parcel curve fed to the generic functions); the −25 mb correction applies only when requested. One integration test against a published worked example with known CAPE/LCL.IO: recorded-fixture tests (no live network in CI).
Browser documentation demo: CPython tests own the CSV boundary and wheel staging; a Playwright Chromium test launches the built page, proves the checkout wheel imported under the Pyodide backend, exercises the interactive canvas and toolbar, replaces the example from a valid local CSV, and confirms an invalid upload reports an accessible error without replacing the previous plot. DOM and application state are assertions; rendering fidelity remains the responsibility of the existing pytest-mpl baselines.
8. Engineering standards (geovista as the minimum bar)#
geovista is the reference for how this repo is built, tested, documented, and released. tephpy mirrors it, deviating only where tephpy’s matplotlib nature, greenfield status, or a deliberate documentation-UX preference makes a different choice better (those deviations are called out explicitly).
8.1 Packaging and layout#
src/tephpy/layout; singlepyproject.toml;py.typedshipped.Build backend
setuptools+setuptools_scm(version_scheme = "release-branch-semver",local_scheme = "dirty-tag",write_to = "src/tephpy/_version.py"), matching geovista..git_archival.txt+.gitattributes export-substfor archive versioning;MANIFEST.incheck-manifestin CI.
Runtime dependencies: matplotlib, numpy, pint, metpy, pandas, xarray — pandas/xarray declared directly because the
Soundingconstructors’ public API consumes their types (item 9); MetPy already requires both, so the declaration adds no install weight. scipy was declared speculatively and dropped in Plan 5 when no direct consumer materialized (item 14; MetPy keeps it transitively). All are conda-forge packages, so pixi resolves them cleanly.requirements/split mirrors geovista:pypi-core.txt+pypi-optional-{docs,test,devs}.txtfeeding[tool.setuptools.dynamic], so PyPI extras and pixi features stay in sync.
8.2 pixi-led workflow (leading tool)#
pixi is the primary interface for environments, tasks, and CI, configured in
[tool.pixi.*] within pyproject.toml (no standalone pixi.toml).
Platforms:
linux-64only — the initial platform support, matching geovista. tephpy is pure matplotlib with no headless-GL constraint, so it is portable in principle; widening toosx-arm64,osx-64, andwin-64is a deliberate future expansion (revisited once the package has domain functionality), not an omission.Features:
test,docs,devs, plus per-Pythonpy312/py313/py314.Environments / solve-groups: a
defaultgroup (pinned to the latest supported Python, currently 3.14) and per-Python groups (py312,py313,py314), each composingtest/docs/devs— the geovista pattern.Tasks (pixi
[tool.pixi.feature.*.tasks]):tests/tests-clean,docs(build),serve-html,doctest,lint(pre-commit run). Matplotlib image baselines are regenerated via abaselinestask (pytest-mpl--mpl-generate-path);tests-cleanremoves pytest-mpl and coverage artifacts.Lockfile:
pixi.lockcommitted;.gitattributesmarks itmerge=binary linguist-generated=true;check-added-large-filesexcludes it. All CI and RTD invocations usepixi run --frozen.
8.3 SPEC 0 support policy#
Follows Scientific Python SPEC 0: Python 3.12, 3.13, and 3.14 at launch — the full SPEC 0 window as of 2026-07 (3.11 is outside it). Dependency minimums tracked to the SPEC 0 schedule; the support window is revisited at implementation time and on each SPEC 0 rotation.
Enforced by: README SPEC 0 badge, a docs statement in the developer/packaging guide, the CI Python matrix (
py312/py313/py314), the per-Python pixi solve-groups, and thesp-repo-reviewpre-commit hook.
8.4 Code quality (pre-commit + lint + types)#
Ruff as linter + formatter:
select = ["ALL"]with a curated ignore list (the geovista set, trimmed to tephpy), numpy docstring convention, isort withrequired-imports = ["from __future__ import annotations"], andCPY001copyright-header enforcement (every source file carries the 4-line BSD header with tephpy’s notice regex).mypy
strictoversrc/tephpy,warn_unreachable = true. The numeric core (transforms,calc) must be clean with no per-module relaxations.numpydoc validation (same rule-set exceptions as geovista) — all public API carries numpy-style docstrings.
Pre-commit hooks (mirroring geovista, same
ci:block —autofix_prs: false, weeklyautoupdate):validate-pyproject,blacken-docs,ruff-check(--fix) +ruff-format,codespell,mypy,numpydoc-validation, thepre-commit-hooksbattery (check-ast/-toml/-yaml, end-of-file-fixer, trailing-whitespace, no-commit-to-branch, check-added-large-files, …),pygrep-hooks,check-jsonschema(dependabot / workflows / readthedocs),sp-repo-review,taplo-format,sphinx-lint, andzizmor(GitHub Actions security audit).
8.5 Testing#
pytest (
--strict-config --strict-markers --import-mode=importlib,xfail_strict,filterwarnings = ["error", …]) + hypothesis + pytest-cov + codecov (projecttarget: auto,threshold: 5%, patch off).Test tree mirrors the package —
tests/reproduces thesrc/tephpylayout: tests for top-level modules live at thetests/root (test_transforms.pythroughtest_sounding.pytoday;test_calc.pylands with Plan 5) and each subpackage gets a matching directory (tests/plotting/today;tests/io/when that layer lands). New test modules are placed at the level of the module they exercise. Sharedtests/fixtures/andtests/baseline/stay at the root.Image baselines via pytest-mpl (deviation: geovista uses pytest-pyvista for VTK scenes; pytest-mpl is the matplotlib equivalent) — small tolerance-tuned PNGs in-repo for each isopleth family, profiles, barbs, shading, and the composed §4 figure.
Test content per §7 (transforms round-trips, calc composition against
metpy.calc, recorded-fixture IO tests, one worked-example integration test).
8.6 Documentation — Diátaxis#
Sphinx on
pydata-sphinx-theme(deviation: geovista usessphinx-book-theme; tephpy prefers the pydata theme’s top-navbar + section layout for an API-reference-heavy scientific library), sources underdocs/src/.Four Diátaxis quadrants as real directories with landing
sphinx-designgrid cards:tutorials/(myst-nb notebooks),howtos/,explanation/(tephigram theory, the T–ln θ construction, parcel/Normand’s-point derivations),reference/(autoapi API + glossary — see “Glossary” below).Extensions per geovista:
sphinx-autoapi(API reference generated fromsrc/),numpydoc,myst-nb,sphinx-gallery(one example per identified use case, scraped fromsrc/tephpy/examples),sphinx-design,sphinx-copybutton,sphinx-togglebutton,sphinxcontrib-bibtex(cited meteorology references),sphinx-tags.Changelog: towncrier news fragments in
changelog/<PR>.<type>.rst(same type taxonomy as geovista), rendered live viasphinx_changelog; assembled intoCHANGELOG.rstat release. Aci-changelogcheck enforces a fragment per PR (escape hatch:skip-changeloglabel).ReadTheDocs versioned hosting, built through
pixi run --frozen --environment docs.The §3.6 browser demo is staged as extra HTML rather than a Sphinx source page. Its tutorial launcher is lazy so an ordinary documentation visit downloads no Python runtime, and it states both the client-side data boundary and experimental support.
Title style. All hand-authored page and section titles follow Chicago Manual of Style headline style: capitalize the first and last words and all major words; lowercase articles (a/an/the), coordinating conjunctions (and/but/or/nor/for/so/yet), prepositions, and the infinitive “to”. Hyphenated compounds capitalize both significant elements (“Wet-Bulb Potential Temperature”, “How-To Guides”) while preserving a technical token’s literal case (“Skew-T”). Documented exceptions — literal case is preserved even at the start or end of a title:
Code and API identifiers, filenames, config keys, CLI commands, env vars, and paths (
plot_sounding,TephigramAxes,pyproject.toml).Project/library names in their own canonical casing (matplotlib, numpy, pint, metpy, pixi, tephpy); where such a name would otherwise lead a title, reword rather than re-case it.
Acronyms, initialisms, and scientific symbols (CAPE, CIN, LCL, WMO, SPEC 0, θ, “T–ln θ”).
Fully exempt from the rule: sphinx-autoapi-generated API pages (titles are object names),
numpydoc section headers (“Parameters”, “Returns”, …), towncrier changelog category and
fragment titles, and anything that is a full sentence — figure captions, admonition body
text, tooltips, alt text, and docstring summary lines — which use sentence case. Bibliography
entries reproduce each source’s published title. Enforced by a developer-docs review
checklist; an optional, non-blocking titlecase wordlist check (encoding the identifier
and project-name exceptions) may assist over hand-authored .rst/.md headings, but must not
gate the build given the volume of legitimate exceptions.
Glossary (reference quadrant). Built with the Sphinx glossary directive and cited in
prose with :term:. It exists to make the meteorology legible to the package’s actual
audience — scientific software engineers — so its rules are audience-first:
Audience. Definitions are written for software engineers, not meteorologists. Each entry gives the concept in one plain sentence, then says how it appears in tephpy — the data it involves, its units, and the API type or argument that carries it (e.g. “Sounding — a vertical profile of atmospheric measurements; in tephpy the
Soundingdataclass holding pressure/temperature/dewpoint arrays as pint quantities”). Deeper physics is linked to the Explanation quadrant, not derived inline. No thermodynamics background is assumed.What earns an entry. Domain jargon and project coinages an engineer would not already know: tephigram, sounding, radiosonde, parcel, adiabat (dry/moist), lapse rate (DALR/SALR), isopleth, isotherm/isobar/isohume, humidity mixing ratio, potential temperature (θ), wet-bulb potential temperature, dewpoint, LCL/LFC/EL/CAPE/CIN, Normand’s point, wind barb — plus any term tephpy uses in a specific sense (e.g. “projection” in the matplotlib sense versus a map projection; “profile”). Common software terms are not glossed. Every acronym gets an entry and is expanded on first use per page.
When to cross-reference. Link the first mention of a term per page (or per major section on long pages), not every occurrence. Link only in narrative prose (tutorials/how-tos/explanation/narrative reference) — never in titles, code blocks, API signatures, or admonition labels. Within a glossary definition, link related terms but never the term itself. Keep one canonical spelling per concept, with
:term:aliases for plural and variant forms.Sourcing. An entry may cite an authoritative external reference (e.g. the AMS Glossary of Meteorology, Met Office) via
sphinxcontrib-bibtex, but the definition must stand alone without following the link.
8.7 CI/CD (GitHub Actions)#
All workflows: SHA-pinned actions, permissions: {} default, persist-credentials: false,
concurrency cancel-in-progress, pixi via prefix-dev/setup-pixi with frozen: true.
v1 core gates:
ci-tests(matrixpy312/py313/py314onlinux-64, coverage → codecov),ci-docs(build + doctest),ci-wheels(build sdist/wheel, test in pixi envs, publish to Test PyPI on main and PyPI onv*tags via Trusted Publishing OIDC),ci-changelog,ci-citation(validateCITATION.cff), CodeQL, pre-commit.ci, dependabot (github-actions grouped; thepipecosystem is declared but parked atopen-pull-requests-limit: 0, so security updates run and version updates do not —requirements/*.txtdeclare floors rather than pins, and a bot raising one is the automatic floor raise floors spec §2 rejects).ci-docsalso installs Playwright’s pinned Chromium, serves the completed build locally, and runs the §7 browser smoke test. The external install and smoke steps each retry once for transient CDN failures, each attempt bounded from inside the shell and neither escalating privilege — a root-owned child outlives the bound meant to end it and fails the next attempt on the lock it is still holding, so a retry around one rescues nothing (#166). The job has a 35-minute bound, and a persistent CDN or dependency-install failure still fails it; no static-image fallback is published.Scheduled, not gating:
ci-floors(weekly) resolves every dependency minimum tephpy declares — at both declaration sites — exercises what it resolves, and files one issue per broken floor, attributed to a single package (floors spec §1). It is deliberately not a required check: it solves fresh against a live channel, so it goes red for reasons no pull request caused, and a required check like that is one people learn to ignore (floors spec §2). It is the lower end of the declaration whose upper endci-locksmoves.Fast-follow (documented, not built at v1):
ci-locks(weekly lockfile-update bot),ci-tests-lock(daily fresh-resolve canary),ci-tests-pypi(daily pip-only install canary),ci-linkcheck,ci-stale,ci-first-contribution, and a JOSS paper build. The spec records these so the gap is a deliberate schedule, not an omission.
8.8 Repo hygiene and community files#
CITATION.cff (validated in CI), codecov.yml, .github/dependabot.yml,
CODE_OF_CONDUCT.md (Contributor Covenant), CONTRIBUTING.md (points at the developer
docs), SECURITY.md, issue/PR templates, .github/labeler.yml (incl. a spec-0 label
rule), CODEOWNERS, and per-directory AGENTS.md files (root, docs/, tests/). SemVer
with a 0.x honesty period.
9. v1 scope#
Everything in §1 items 1–3 and the core of item 4: full diagram, profiles, barbs, multi-sounding overlay + anchoring, parcel path, Normand’s point, CAPE/CIN with shading, LCL/LFC/EL, θw, lifted index, indices panel, Wyoming/IGRA readers, vector output. Documentation ships all four Diátaxis quadrants with a seeded glossary (§8.6) covering the domain terms above.
Non-goals for v1 (decisions, not omissions — stated in the README)#
No TEMP (TTAA/TTBB) or BUFR decoding — recipe docs point at eccodes.
No skew-T projection — MetPy owns that space.
No hodograph — MetPy’s
Hodographcomposes alongside; a gallery example shows it.No GUI or interactive dashboard.
No fog-point or layer-cloud constructions (v1.x candidates).
No aviation overlays (icing, MINTRA contrail curves) — flagged open question below.
10. Plan roadmap#
Seven plans deliver the v1 scope (§9). Each plan gets its own spec-derived implementation
plan in docs/src/developer/plans/, and a plan is executed and merged before any plan that
depends on it is written. The dependencies form a partial order, not a chain: Plans 5
and 6 are mutually independent and may proceed in parallel once Plan 4 has merged. The
ordering follows the §3 layering (transforms ← plotting ← (calc, sounding, io)):
geometry first, then the drawing machinery, then the data model, then the analysis and
ingest layers above them. (calc itself stays headless per §3 — its pairing with shading
and the indices panel in Plan 5 is delivery convenience, not an import dependency.)
# |
Plan |
Scope (spec §) |
Depends on |
Status |
|---|---|---|---|---|
1 |
Foundation & scaffolding |
§8 end to end: packaging, pixi, lint/type/test tooling, docs skeleton, CI core gates (residual deferrals: item 15 below) |
— |
|
2 |
Transforms & the tephigram projection |
§3.1: T–ln θ math derived from published sources with tephi as oracle; minimal |
1 |
✅ complete (PR #9) |
3 |
Isopleth plotting |
§3.2 grid + five isopleth families as zoom-aware artists, accessor methods, |
2 |
✅ complete (PR #15) |
4 |
Sounding data model & profile plotting |
§3.4 |
3 |
✅ complete (PR #19) |
5 |
Thermodynamic analysis |
§3.3 |
3, 4 |
✅ complete (PR #26) |
6 |
Wind barbs & data ingest |
§3.2 |
3, 4 |
|
7 |
Examples gallery & documentation completion |
§8.6: sphinx-gallery examples (one per §1 use case, incl. the hodograph composition example from §9), |
2–6 |
next |
Cross-cutting rules (apply to every plan rather than one row):
Image baselines ship with their feature. §7/§8.5 enumerate baselines for the isopleth families, profiles, shading, barbs, and the composed §4 figure; each lands in the plan that builds the feature (3, 4, 5, 6, and 7 respectively, as tabled above).
Glossary entries ship with their terms. The docs build is fail-on-warning, so a
:term:reference written in Plan N breaks the build unless Plan N seeds the entry; “glossary completion” in Plan 7 is a sweep, not the sole delivery._constantsaccretes per feature. Plan 2 seeded the module; Plan 3 establishestephpy.configover it; later plans add their own conventions (e.g. gutter width arrives with Plan 6’s barbs).
Outside the roadmap:
The §8.7 fast-follow CI bots (lockfile updates, resolve/pip canaries, linkcheck, stale, first-contribution, JOSS build) are post-v1 continuous work, adopted on need rather than assigned to a plan.
Release execution — towncrier assembly into
CHANGELOG.rst, thev0.xtag that triggers PyPI Trusted Publishing, RTD version activation,CITATION.cffrelease metadata — follows Plan 7 as release ops, not a plan.Service provisioning is operational, not planned. Test PyPI Trusted Publishing, codecov, and pre-commit.ci are verified live (green on
mainas of 2026-07-23); the production PyPI Trusted Publisher (first exercised by av*tag), the RTD project, and the GitHub Discussions link in the issue templates remain to be verified. Verified 2026-08-03: the RTD project is live — it buildslatestfrommainand reports adocs/readthedocs.org:tephpycheck on pull requests. Versioned hosting (stable,v0.x) still waits on the first tag, per release execution above. The GitHub Discussions link in the issue templates resolves — Discussions are enabled and.github/ISSUE_TEMPLATE/config.ymlcarries the contact link. Only the production PyPI Trusted Publisher is still genuinely unverified, pending the firstv*tag.
Assumptions and open decisions#
Enumerated so they are visible decisions, not silent drift. Items 1–2 are decisions this roadmap makes; the remainder are open questions assigned to the plan that must answer them, ordered by owning plan.
Resolved (2026-07-28, PR #19, #26, #40) — The Plan 4–6 slicing is inferred, not inherited. Only Plans 1–3 and 7 were anchored in writing when Plan 1 shipped (“Plan 3” for image tests, “Plan 7” for the gallery). The split above keeps one subsystem per plan along the §3 layering; viable alternatives (barbs inside Plan 4;
ioas its own plan; examples accreting per-plan instead of batching in Plan 7) were consciously not taken.Resolved (2026-07-26, PR #26) —
Profileis defined in Plan 5 but referenced by Plan 4. §3.2 saysplot_profileaccepts pint quantities or aProfile; Plan 4 ships the quantities signature, and Plan 5 adds theProfileoverload together withcalc.parcel_path. Resolved 2026-07-26:Profileis a frozen dataclass incalc(§3.3); the overload dispatches by duck-typing soplottingnever importscalc(§3.2).Resolved (2026-07-23, PR #9) — Plan 2 — the TephigramAxes seam. Resolved 2026-07-23: the
"tephigram"projection and a minimalTephigramAxeslive inplotting/axes.pyfrom Plan 2 (Plan 3 extends the same class in place);transforms.pystays pure numpy math. §3.1 updated accordingly.Resolved (2026-07-23, PR #9) — Plan 2 — units at the transforms boundary. Resolved 2026-07-23:
transformsis the documented exemption to §5 — bare numpy arrays in diagram-native units (hPa/°C), because matplotlib’s per-draw pipeline consumes bare arrays; every layer above converts before calling down. §5 updated accordingly.Resolved (2026-07-23, PR #9) — Plan 2 — tephi provenance and attribution. Resolved 2026-07-23: verify-first stance — derive each function from the published sources and challenge it per case (§7’s four-layer battery), with tephi as a recorded oracle rather than a source to copy. Attribution attaches only to artifacts actually copied, per case, via a NOTICE file if needed. The same stance applies to Plan 3’s locator/refresh reimplementation.
Resolved (2026-07-24, PR #15) — Plan 3 — config object and accessor naming. The §3.5
tephpy.rcparams-style object is named but not designed. §3.2 names accessors for only three of the five isopleth families, and the spec alternates between “saturated” and “wet” adiabats — pick canonical names (the glossary rule: one spelling per concept). Resolved 2026-07-24: the canonical family name is moist adiabat — the AMS Glossary headword and MetPy’s own vocabulary — with saturation/saturated/wet adiabat as glossary aliases; the five accessors areisotherms/isobars/dry_adiabats/moist_adiabats/mixing_ratios; the config object is the typedtephpy.configsingleton (§3.5). The fixed-extents API isset_extent— the earlierset_anchorcollided with matplotlib’s ownAxes.set_anchor(DEFAULT_ANCHORrenames toDEFAULT_EXTENT). §1/§3.2/§3.5/§4 updated accordingly.Resolved (2026-07-24, PR #15) — Plan 3 — side-of-axes layout seam. The barb gutter (Plan 6) and the indices panel (Plan 5) both need space beside the diagram; Plan 3 decides whether the axes pre-builds that layout or each consumer manages its own. Resolved 2026-07-24: decide the contract, build later — §3.2 fixes the mechanism (
axes_grid1divider) and the right-side inside-out ordering (barb gutter, then indices panel); no layout code ships until Plans 5/6 consume it.Resolved (2026-07-25, PR #19) — Plan 4 — Sounding contract details. Label/legend format (§4 hints
"72357 2013-05-20 12Z"), station/time optionality (§3.4 states requiredness only for the data arrays), and how forecast-vs-observed overlays of the same station/time stay distinguishable in a legend. Resolved 2026-07-25: station and time are optional metadata — ad-hoc arrays plot without ceremony, operational users get comparable legends for free.labelderives as"72357 2013-05-20 12Z"when both are present, an explicitlabel=always wins, and with neither there is no legend entry. Forecast-vs-observed distinguishability is the label override’s job — no dedicated field. §3.2/§3.4 updated accordingly.Refined (2026-07-26, PR #19) — Plan 4 — pandas/xarray dependency status.
from_dataframe/from_dataset(§3.4) and the §2 ingest decision need pandas/xarray, but §8.1’s runtime list omits them (today they arrive transitively via MetPy). Decide: direct declaration, optional extra, or typing-only treatment. Resolved 2026-07-25: declared directly — the constructors’ public API consumes pandas/xarray types, so leaning on MetPy’s transitive guarantee would be a silent contract, and the declaration adds no install weight. Imported function-locally inside the constructors to keepimport tephpylight. §8.1 updated accordingly. Refined by Plan 4 (PR #19): the shipped constructors are duck-typed over the objects handed to them, so no runtime pandas/xarray import exists at all — annotations areTYPE_CHECKING-only, and the Plan 4 subprocess test enforces it.Resolved (2026-07-26, PR #26) — Plan 4/5 — top-level namespace policy. §4 requires
tephpy.calc.parcel_pathto work afterimport tephpy, implying eager subpackage import (and MetPy’s import cost) or lazy loading; also which names (e.g.Sounding) re-export at top level. Plan 3 keeps MetPy behind function-local imports in the isopleth builders, leaving this item open; candidate mechanism: scientific-pythonlazy-loader(SPEC 1), with PEP 810 explicit lazy imports as the native successor once the SPEC 0 floor reaches Python 3.15. Plan 4 slice resolved 2026-07-25:Soundingre-exports eagerly at the top level — cheap becausesounding.pykeeps MetPy/pandas/xarray imports function-local. The lazy-loading mechanism decision stays with Plan 5, wherecalcmakes the import cost real. Resolved 2026-07-26: no lazy-loading machinery at all.calc.pyadds no heavy module-level imports — its internaltransforms/soundingimports are cheap by construction, and everymetpy.calccall sites its import function-locally (the idiom the import-cost guard test polices) — socalcre-exports eagerly alongsideSoundingandtephpy.calc.parcel_pathworks per §4 at no import cost.lazy-loader/PEP 810 are not adopted; Plan 6 applies the same pattern toio.Resolved (2026-07-26, PR #26) — Plan 5 — MetPy behaviour verification. §6 asserts NaN pass-through, but MetPy returns 0 (not NaN) for zero CAPE and warns on some degenerate profiles — and pytest’s
filterwarnings = ["error"]turns those warnings into failures. Verify the §6 contract and the availability ofwet_bulb_potential_temperature/lifted_index/mixed_parcelagainst the pinned floor (metpy>=1.6), adjusting §6 or the pin. Resolved 2026-07-26: verified empirically against the locked metpy 1.7.1 (the floor stays>=1.6; all three names exist there per the MetPy release history). All fourteen functions the design needs exist. Zero CAPE/CIN returns0 J/kg, never NaN; LFC/EL return NaN quantities; EL can be NaN while CAPE > 0. Warning tripwires: duplicate pressure levels (unreachable —Soundingenforces strict monotonicity) and out-of-bounds interpolation fromlifted_indexon profiles topping out below 500 hPa (suppressed at the call site, returning the NaN field). §6 amended accordingly. The floor-vs-verified gap is explicit: the Plan 5 implementation plan verifies the §6 semantics (not just name availability) against ametpy==1.6.*resolve and raises the floor if they diverge.Resolved (2026-07-26, PR #26) — Plan 5 — “layer highlights”. The §3 tree comment on
shading.pynames layer highlights, but no API, §9 scope item, or plan covers them; treated as not-in-v1 unless Plan 5’s design deliberately includes them. Resolved 2026-07-26: not in v1 — Plan 5 shipsshade_cape/shade_cinonly and the §3 tree comment is corrected; layer highlights remain a v1.x candidate. The v1.x candidacy is tracked in #79.Resolved (2026-07-27, PR #26, #40) — Plans 2/5/6 — third-party data provenance. Any tephi artifacts actually copied (item 5), the §7 published worked example (which publication, and is its data redistributable?), and recorded Wyoming/IGRA fixtures all embed external data; each owning plan records source, capture method, and attribution. Plan 5 slice: the worked example’s primary candidate is a CAPE/LCL example from Stull, Practical Meteorology (CC BY-NC-SA 4.0 — a handful of fixture numbers with full citation); the final source, capture method, and attribution are pinned in the Plan 5 implementation plan and recorded alongside the fixture. Redistribution stance: the fixture is a few cited numeric values used as facts, not licensed expression; if that comfort fails for the pinned source, fall back to a public-domain (NWS/NOAA) profile. Plan 6 slice resolved 2026-07-27: the recorded fixtures are one captured Wyoming ascent and one trimmed IGRA v2 station file under
tests/fixtures/io/, each with a sidecar provenance note recording source URL, capture date and method, and attribution. IGRA is NOAA/NCEI public domain; the Wyoming ascent is a single recorded sounding used as test facts, with the archive credited in the provenance note.Resolved (2026-07-26, PR #26) — scipy is declared but unowned. §8.1 lists scipy as a runtime dependency, yet no §3 module names it (plausible first consumers: interpolation in Plan 2 or Plan 5). If Plan 5 completes without it, drop the dependency. Resolved 2026-07-26: Plan 5’s design needs no direct scipy (the shading interpolation is plain numpy; MetPy keeps scipy transitively), and
src/tephpyhas no scipy import today — the direct declaration is dropped in Plan 5 (§8.1 updated; the implementation plan also removes scipy from the declared-dependencies tuple intests/test_import.py).Deferred (Plan 7 — #76) — Residual Plan 1 deferrals, re-homed: sphinx-tags (§8.6) → Plan 7;
doctesttask +ci-docsdoctest run (§8.2/§8.7) → Plan 7;tests-cleantask (§8.2) → reconciled in Plan 3 (decided 2026-07-24:tests-cleanremoves test artifacts; abaselinestask regenerates the pytest-mpl baselines); wheel-install smoke test → Plan 2 (decided 2026-07-23); check-manifest CI gate → revisit once the wheel carries domain code; the §8.3 packaging-guide SPEC 0 docs statement → Plan 7.Per-deferral status:
Deferred (Plan 7 — #76): the
doctesttask and theci-docsdoctest run (§8.2/§8.7).Deferred (Plan 7 — #76): the §8.3 packaging-guide SPEC 0 statement.
Resolved (2026-07-24, PR #15): the
tests-cleantask, withbaselinesalongside it.Resolved (2026-07-23, PR #9): the wheel-install smoke test.
Open (#77): the check-manifest CI gate — nothing runs it, and
MANIFEST.inhas already drifted once.
Resolved (2026-07-29, PR #41) — matplotlib floor vs.
Artist.get_figure(root=...). §8.1 names matplotlib without a version and the pins carried>=3.9, but therootkeyword arrived only in matplotlib 3.10, and three zoom-aware artists pass it:isopleths.py(Plan 3),barbs.py(Plan 6), andaxes.py(Plan 6 hardening). Resolved 2026-07-29: floor raised tomatplotlib>=3.10inrequirements/pypi-core.txtand[tool.pixi.dependencies]; the call sites keep the explicitroot=, which is load-bearing inaxes.py— theFigure.clearframe check must match the enclosing (Sub)Figure — and future-proof elsewhere. Verified against real installs: matplotlib 3.9.4 fails 26 of the 445 tests, every failure the sameTypeError: ... unexpected keyword argument 'root'; 3.10 passes all 445 on unmodified source. 3.10 is also the §8.3 SPEC 0 floor, matplotlib 3.9.0 (2024-05-15) having left the 24-month window on 2026-05-15. No CI job resolves the declared minimums — every workflow ispixi run --frozenagainst a lock pinned to 3.11.1, and the wheel smoke test takes the newest satisfying release — which is how the wrong floor survived three plans; a lowest-direct-resolution gate is re-homed to Plan 7.Residual: Deferred (Plan 7 — #78) — the lowest-direct-resolution gate.
11. Open questions (carried from research)#
Deferred (v1.x — #79) — Which aviation-specific overlays (icing layers, MINTRA) do operational users actually need built in, versus composing themselves? Partly answered: member emphasis (§3.2) gives the icing band’s 0 °C and −20 °C bounds as isotherms, so what remains open is whether the shaded layer between them is wanted, which belongs with the layer highlights already deferred to v1.x (§10 item 12).
Blocked (on a citable published chart — #80) — Whether a current Met Office Factsheet 13 — or a University of Reading blank tephigram — shows the 0 °C isotherm drawn distinctively on the printed chart. Its published URL 404s (2026-07-30), so member emphasis ships off by default; a citation would justify revisiting that.
Open (#81) — Which named stability indices beyond the v1 set (Showalter, K-index, Total Totals) are worth wrapping, given all are one-line
metpy.calccalls for users?Deferred (post-v1, demand-driven — #82) — Whether BUFR ingest demand justifies an optional
tephpy[bufr]extra later.
12. References#
Met Office Factsheet 13 — Upper air observations (2023)
Stull, Practical Meteorology, ch. 5 (thermo-diagram construction, stability)
University of Reading tephigram teaching notes
COMET/UCAR tephigram training module; NWS and HKO operational guides
SciTools/tephi 0.4.0.dev0 source (transform and isopleth-artist design)