tephpy API visibility — design specification#

Estimated reading time: 23 minutes

Living document. This specification is maintained alongside the code, not archived behind it. The how-to pages it describes cite it by section — visibility spec §3.3 and the like — so these sections are the reasoning behind what those pages say, and where the two ever diverge it is the specification that gets corrected. Read it as current.

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

  • Status: living design specification

  • Citation prefix: visibility spec §… — the subject is what a reader is shown, not what exists. howtos spec was rejected because §3.5 amends a tutorial too, and api spec because nothing here changes an API

  • Scope: three new how-to pages, four amendments to existing user pages, and the registration each needs; no change to src/tephpy/

  • Parent spec: 2026-07-22-tephpy-design.md§5 is the units policy §3.3 finally teaches, and §1 case 4 the vector-output promise §3.4 closes

  • Sibling spec: 2026-08-27-narrative-quadrants-design.mdnarrative spec §3.6 built the one reader how-to; this specification covers the routes into a Sounding that do not come from a file

  • Tracked by: #212, which this specification answers except for its Tier 3

1. Purpose#

An audit of the public API against the user quadrants (#212) found capability that every install pays for and no page advertises. The API reference documents all of it. That is the distinction this specification is built on: the gap is not in what exists, it is in what a reader is ever shown.

The counts were taken across docs/src/{howtos,tutorials,explanation}, src/tephpy/examples and README.md, and re-checked on 2026-08-29 before this document was written. Files mentioning each symbol:

symbol

files

note

Sounding.from_dataframe

0

pandas is a hard runtime dependency

Sounding.from_dataset

0

xarray is a hard runtime dependency

edge_axis

0

the axis-label story

labels= edge option

0

the prerequisite for edge_axis

mixing_ratios accessor

0

against isotherms at 5

moist_adiabats accessor

0

as above

SoundingIndices

0

the tutorial shows the panel, never that fields are readable

samples.available()

0

no documented way to discover sample names

config.source, config.reset

0

load, save and context appear once each

units=

1

spec §5 is an entire policy

“pint”

1

a comment in one gallery example

Three of these are worse than a thin count.

  • from_dataframe and from_dataset are the largest gap. requirements/pypi-core.txt pins pandas>=2.3 and xarray>=2024.10, so every installed tephpy carries both. Most scientific Python users arrive holding a DataFrame, and the page they would check — Read a Sounding From an Archive — covers only files on disk.

  • Spec §5 is an entire units policy and a reader learns none of it. Not that pint quantities go in directly, not that any pint-parseable unit is accepted, not that what comes back is pint on MetPy’s registry.

  • Spec §1 case 4 promises “publication-quality (vector) output”. It appears in the corpus exactly once, as a commented-out line in plot_sounding_comparison.py.

2. Decisions#

  1. Per theme, not one page. The how-to quadrant’s own index promises that “each page answers one question and stops there”. A single grab-bag would make a reader holding a DataFrame scroll past unit conversion and PDF export to reach their answer, and its title could not say what it answers.

  2. Three new pages, not the four #212 proposed. Vector output was measured before it was scoped (§5) and found to have nothing tephpy-specific to teach. It becomes the closing section of §3.4 rather than a page that would only say “call savefig”.

  3. A tier-2 symbol goes to the page that already owns its subject, not to a new page. config.reset belongs beside config.load; samples.available() belongs where samples.path(...) is already explained. Routing them to new pages would put two pages in the reader’s way for one question, which is decision 1 read backwards.

  4. edge_axis is a documentation defect and not an API defect. #212 suspected the labels= then edge_axis two-step might argue for an API change. It does not, and §3.6 records the measurement that settles it.

  5. No new sample files. §3.2’s page constructs its own DataFrame inline. A reader whose data is already in Python does not need tephpy to ship them a file, and the shipped samples exist to serve pages about acquisition.

  6. Tier 3 is not in scope. The exception taxonomy is reference-quadrant material, and §7 files it rather than dropping it.

3. Architecture#

3.1 The routing rule#

Three new pages and four amendments. The rule that separates them: a new page is warranted when the question has no existing owner and a reader would search for it by name; an amendment is right when a page already answers the neighbouring question and the symbol is the sentence it is missing.

page

title

question

howtos/build-a-sounding.rst

Build a Sounding From Your Own Data

my data is already in Python

howtos/units.rst

Work With Units

what units does it want, and what do I get back

howtos/label-and-compose.rst

Label and Compose the Diagram

how do I title it, or put it beside something else

3.2 Build a Sounding From Your Own Data#

docs/src/howtos/build-a-sounding.rst. The reader holds a DataFrame or a Dataset and wants a Sounding.

The arc is three escalating calls on the pandas side — column names that already match the field names, names that need the keyword mapping (pressure="p", temperature="T", dewpoint="Td"), and what happens when a required column is absent — then the xarray half, then one figure proving the result plots like any other sounding.

The page’s substance is the asymmetry between the two constructors, which is invisible from their signatures and is why this page is worth more than its examples:

  • from_dataframe reads bare arrays out of the columns. Nothing carries a unit, so the units= mapping is required for every field present.

  • from_dataset reads each variable’s attrs["units"] by the xarray/CF convention, and treats units= as the override. A CF-compliant Dataset needs no units= at all; a variable with neither raises TephpyUnitsError naming the field, the variable and the fix.

Both take station=, time= and label=, and both raise KeyError for a missing required or explicitly mapped column, and TypeError for a mapping keyword that names no field.

The data is a ~10-level DataFrame built inline with literal arrays, its levels taken from the shipped Camborne ascent so the atmosphere is real. Decision 5 explains why it is not a file.

3.3 Work With Units#

docs/src/howtos/units.rst. Spec §5’s policy, taught in the order a reader is surprised by it. Every statement below was executed on 2026-08-29 before it was written down, except where a correction below says otherwise.

  1. Pint quantities go in directly, with no units= at all. Sounding(pressure=[1000., 900.] * units.hPa, ...) is accepted as-is.

  2. Any pint-parseable unit is accepted, not a tephpy shortlist, and tephpy then works in it. units={"pressure": "inHg", "temperature": "degF"} is accepted, and the field is tagged rather than converted: sounding.pressure reads back [29.5 26.0] inch_Hg, the magnitudes unchanged. Conversion is on demand — .to("hPa") gives 998.98 hPa and .to("degC") gives 20.0 °C. A sounding in those units plots and analyses without complaint, and derived quantities come back in tephpy’s canonical units rather than the input’s: calc.parcel_path on a degF sounding returns a degree_Celsius Profile. (The input is a Sounding; “profile” names what comes back, and §3.3’s page was corrected for the same confusion.)

    Corrected 2026-08-29, before the page shipped. This section previously said the data was “stored converted, not merely tagged”. That was false, and it was false because the check behind it called .to() and read the converted output as evidence about storage. Task 2’s implementer caught it. The rule this cost is worth restating: a claim about what a value is must be checked by reading the value, not by reading a conversion of it.

    Corrected again 2026-08-29, in final review, and restored once the bug behind it was fixed. This section said the sounding “plots and analyses without complaint”; the “analyses” half was never executed and was false at the time, because calc.parcel_path raised DimensionalityError when pressure was inHg or mmHg. That was a tephpy bug rather than a design decision — three dry_lapse call sites passed a reference pressure in the sounding’s own unit while the array beside it had been rebuilt in hPa — and it is fixed (#214). The claim is restored, and is now pinned by a parametrised test over ten pressure units that requires the answers to agree rather than the calls to succeed. The episode is left recorded rather than tidied away: the false half survived because the sentence was written from the section’s gist, and the narrower claim beside it (parcel_path returning degC) was true, which is what made the wider one look safe.

  3. What comes back is pint, on MetPy’s registry. So .to("hPa") works, and the quantities feed MetPy’s own calc functions without conversion. metpy.units.units is an ApplicationRegistry proxy — a plain literal, because pint’s inventory publishes pint.UnitRegistry and pint.Quantity and carries no ApplicationRegistry target — and the quantities sit on the pint.UnitRegistry it wraps. The page states that consequence and does not invite the reader to test registry identity: comparing against metpy.units.units compares against the proxy and reports False, which is the misleading way to check a claim that is true.

The page closes on TephpyUnitsError — the one member of the exception hierarchy whose subject is units. It is named, not tabulated: the taxonomy is §7’s, and a how-to that grew an exception table would have stopped answering one question.

3.4 Label and Compose the Diagram#

docs/src/howtos/label-and-compose.rst. Three sections, in the order a figure is finished.

Labels. labels defaults to True, which draws each family’s labels inline along its own isopleths. labels="left" moves them to that edge, and claiming an edge supplies its axis title from EDGE_AXIS_TITLESax.isobars(labels="left") alone yields an axis titled “Pressure (hPa)”. ax.edge_axis(...) appears second, as the override it actually is, together with the fact that no edge is claimed by default: a fresh tephigram carries no axis titles because no family has asked for an edge.

Composition. The section that earns the page its length, and the finding that is not in #212. TephigramAxes.clear sets aspect=1.0, adjustable="box", so a tephigram placed with fig.add_subplot(1, 2, 1, projection="tephigram") shrinks inside its slot while a plain neighbour fills its own, and the pair reads as a mistake. Two figures, before and after; the remedy is width_ratios on the subplots, layout="constrained", and set_box_aspect(1.0) on the neighbour. subplot_mosaic(..., per_subplot_kw=...) is noted as the second route to a mixed figure.

Vector output. Three sentences and no figure, per decision 2. savefig(..., format= "pdf") and .svg are matplotlib’s, they work, and the page says what was measured: an SVG of a full ascent comes out as several hundred <path> elements and zero <image> — nothing is rasterised — and the PDF is a real %PDF-. The path count is not quoted: it tracks the isopleth intervals and the extent, so a figure drawn differently reports a different number and a page naming one would be making a claim about a figure it is not showing. plot_sounding_comparison.py’s commented-out line is uncommented or removed in the same change, so the corpus stops half-saying it.

3.5 The four amendments#

Each is a section or a sentence on a page that already answers the neighbouring question (decision 3).

page

symbol

where it lands

howtos/read-a-sounding.rst

samples.available()

the sentence already explaining that samples.path(...) is only how the page gets hold of a file

howtos/emphasis.rst

mixing_ratios, moist_adiabats

the page exercises isotherms and isobars; the missing claim is the shared core of §3.7, and the two places it does not hold

howtos/configuration.rst

config.source, config.reset

beside the existing load and save sections

tutorials/analyse-a-sounding.rst

SoundingIndices

calc.indices(snd) is currently passed straight into the panel; one sentence and a link that its fields are readable

The tutorial amendment is deliberately one sentence. Narrative spec §3.1 constrains a tutorial to state a fact and link rather than derive one, and “the object you just passed to the panel has fields you can read” is a fact.

3.6 The edge_axis finding, and why it is not an API change#

#212 reported the two-step as “a finding nobody would guess” and asked whether it argued for an API change. Measured on 2026-08-29, it does not:

ax.isobars(labels="left")
ax.edge_axis("left").get_label_text()   # 'Pressure (hPa)'

The title is already correct, supplied from EDGE_AXIS_TITLES when the family claims the edge. edge_axis is needed only to override it, or to reach the stock matplotlib the docstring already offers from the claim onwards. The one-step gets a reader a labelled pressure axis, so what was missing was a page saying so.

What remains true is that edge_axis raises ValueError on an unclaimed edge, and that its message is currently the only place the sequence is explained. §3.4 moves that explanation onto a page, which is the correct home for it; the message stays as it is, because an error a reader meets at the prompt should still teach.

3.7 What the five family accessors actually share#

The emphasis.rst amendment of §3.5 rests on a claim that has to be stated precisely, because the obvious version of it is wrong. Checked against the signatures on 2026-08-29, all five accessors — isotherms, isobars, dry_adiabats, moist_adiabats, mixing_ratios — share a core of seven keyword-only options: values, color, linewidth, alpha, labels, emphasis, visible.

Two do not stop there, and this is the part a reader cannot guess:

  • mixing_ratios has no interval. The other four take one. A mixing-ratio ladder is not evenly spaced, so there is no interval to name.

  • moist_adiabats adds truncation. No other family has it.

“All five take the same options” is therefore the sentence the page must not write. The amendment states the shared core, names both exceptions, and draws one figure using mixing_ratios and moist_adiabats so the two least-shown families appear at least once in the corpus.

4. Companion changes#

  • docs/src/howtos/index.rst — three toctree entries, and its opening paragraph extended to name the new questions. That paragraph currently enumerates the quadrant’s subjects and would otherwise describe a smaller quadrant than the one it indexes.

  • docs/src/developer/specs/index.rst — the visibility spec §… row and the toctree entry. A new specification declares a prefix unique across the collection.

  • docs/baseline/ — one approved PNG per published figure, named by the figure’s :filename-prefix:.

  • src/tephpy/examples/plot_sounding_comparison.py — the commented-out vector line, per §3.4.

  • changelog/ — one <PR>.documentation.rst fragment per pull request, ending with (:user:`bjlittle`).

5. Alternatives considered#

  • A fourth page, Save a Figure for Publication#212’s own table proposed it and its own note doubted it. Measuring settled the doubt: savefig is matplotlib’s, and nothing tephpy-specific goes wrong — no rasterisation, no glyph loss, a real PDF. A page whose honest content is “this works” is a page a reader opens for nothing.

  • One page, Beyond the Archives, carrying all of it — cheaper to write and cheaper to register, and rejected by decision 1. The title could not say what the page answers.

  • Absorbing every tier-2 symbol into the three new pages#212’s table implies it. Rejected because it would leave configuration.rst still not mentioning config.reset while a different page did, which is worse than either page alone.

  • Round-tripping an existing Sounding through a DataFrame in §3.2 — gives a full-resolution figure for free, and is circular: a reader with their own data has no Sounding to start from, so the page would demonstrate its premise by assuming it away.

  • Reading the shipped Camborne CSV with pandas.read_csv — real and full-resolution, and rejected because Read a Sounding From an Archive tells the reader to use wyoming.parse for exactly that file. The page would teach the worse route to the one file for which a better route is documented.

  • Changing edge_axis to claim an edge implicitly — considered under §3.6 and rejected. A getter with a side effect that builds a secondary axes is a worse API than a raise, and the docstring’s stated reason for the raise still holds.

6. Testing#

No new test module. Every page here is carried by machinery that already exists, and the work is to satisfy it rather than extend it.

  • docs spec §3.9’s snippet gate executes every python block on each new page, as one script per page, on every supported Python. The pages are written against it: no network, no file the reader must find, and every name a later block uses defined in an earlier one.

  • plots spec §3.1/§3.5 — the first plot directive on a page takes :context: reset and every later one :context:; each published figure declares a :filename-prefix: unique across the documentation, and .github/scripts/check_docs_figures.py compares the built image against its baseline.

  • The glossary gate (#211) — first mention of a glossary term per page carries :term:. Any term these pages reach for that has no entry is added in the same change, because the build is fail-on-warning.

  • tests/test_docs_snippets.py discovers pages by directory, so the three new pages are collected without registration; test_a_page_publishes_figures_or_it_does_not and test_a_figure_name_is_unique_across_the_documentation are the two most likely to catch a mistake here.

7. Scope#

In scope. #212’s Tier 1 and Tier 2 in full: three new how-to pages, four amendments, and the registration and baselines they need.

Out of scope, and filed rather than dropped. #212’s Tier 3 — seven of the ten public exception types appear on no user page: TephpyError, TephpyUnitsError, MissingDataError, ProfileTooShortError, TephpyValidationError, NonMonotonicPressureError, DewpointExceedsTemperatureError. Spec §6 designed the hierarchy so a caller can catch precisely, and nothing tells a caller it exists. That is reference-quadrant material, belonging beside the generated configuration options rather than in a how-to, and it is tracked by #213. §3.3 names TephpyUnitsError in passing; that is the whole of this specification’s contact with the taxonomy.

Explicitly not changed. Nothing under src/tephpy/ except plot_sounding_comparison.py’s commented line. §3.6 records why the one API change this work might have argued for is not warranted.

8. Open items#

  • Closed (2026-09-11, #66) — the developer and contributor guide. This specification closed the user-facing half of the how-to quadrant’s coverage gap and left that untouched; it was taken up separately as contributor spec and tour spec.

  • Closed (2026-09-01, #206) — prose that names a function makes an unchecked claim about it. Every page here names functions heavily, and the review question that issue asked for now exists: Does it say how, and did you read that?, the fourth of docs-style.rst’s Reviewing Claims. It remains a review question rather than a gate.

9. References#

Each citation above repeats its prefix rather than sharing one across a list. A bare §5 trailing Spec §1 resolves against this document, which has a §5 of its own, so the list would silently cite the wrong specification (#197).