Changelog#

✨ New Features#

  • Added the tephigram coordinate transforms and a minimal "tephigram" Matplotlib projection, cross-validated against tephi. (@claude) (#9)

  • Added the five zoom-aware background isopleth families with per-family accessor methods, TephigramAxes.set_extent, the tephpy.config runtime configuration layer, and pytest-mpl image baselines. (@claude) (#15)

  • Added the Sounding data model with ingest-time validation and pandas/xarray constructors, the pint units machinery over MetPy’s registry with the public tephpy.exceptions hierarchy, and TephigramAxes.plot_profile/plot_sounding with multi-sounding overlays, derived legends, and profile image baselines. (@claude) (#19)

  • Added the tephpy.calc thermodynamic analysis layer — parcel_path() with surface and mixed-layer parcels and the operational cloud-base correction, normand_point(), indices(), and the Profile/SoundingIndices dataclasses — together with TephigramAxes.plot_profile accepting a parcel Profile, CAPE/CIN shading via shade_cape/shade_cin, the annotate_indices side panel, and their image baselines; dropped the unconsumed scipy dependency declaration. (@claude) (#26)

  • Added wind barbs and data ingest — plot_barbs() drawing Met Office barbs (flag 50 kt, full 10 kt, half 5 kt, 5 kt binning) on a zoom-aware right-hand gutter staff, with the side panels sharing one divider so the gutter and the indices panel compose in either call order; the tephpy.io readers fetch() (University of Wyoming) and read() (IGRA v2) returning validated Sounding objects, with recorded, provenance-tracked fixtures; TephpyIOError; and the barb image baselines. (@claude) (#40)

  • Added the emphasis option to every isopleth family, so a member can be drawn with a distinguishing style: isotherms() and its four siblings, and the matching tephpy.config sections, map a member value to color, linewidth, linestyle and alpha overrides. An emphasised member is always drawn, whatever the zoom ladder would select, so ax.isotherms(emphasis={0.0: {}}) marks the freezing level and emphasis={-12.0: {}, -18.0: {}} marks the dendritic growth zone that no isotherm interval lands on. (@claude) (#62)

  • Added add_logo(), which places the tephpy logo on a figure or an axes in one call. The position is relative to whatever it is called on — an axes anchors inside the plotting box, a figure against the figure edges — using the legend() placement vocabulary, and size is a height in inches, so add_logo(ax, loc="lower right") renders the same size at 100 dpi and at 600. theme="auto" picks the light or dark variant from the target’s background. The six brand masters ship inside the wheel, so it needs no docs tree and no network. (@claude) (#71)

  • Added a YAML configuration file, so a tephigram house style no longer has to be retyped at the top of every script. tephpy config generate writes a fully-commented template of every option at its current default, and tephpy config path reports which file is in force. tephpy reads the first match of $TEPHPYRC, ./tephpyrc.yaml, and the file in your user configuration directory. See Configure tephpy From a File. (@claude) (#112)

🚀 Enhancements#

  • Added Python 3.14 support per the SPEC 0 window; the default development environments now run the latest supported Python. (@claude) (#5)

  • Added a minimum_separation keyword to plot_barbs(), so a call picks its own barb thinning distance — an overlaid staff, or a longer length= glyph, wants one wider than the BARB_MIN_SEPARATION convention. Passing it previously raised TypeError from the staff constructor. (@claude) (#41)

  • The interactive cursor readout (the matplotlib navigation toolbar’s coordinate text) over a tephigram now reports diagram-meaningful values — 850 hPa, -4.2 °C, θ 8.6 °C — instead of the raw rotated (x, y) data space. tephpy.config.cursor.fields selects and orders the readout from a five-field registry ("pressure", "temperature", "theta", "mixing_ratio", "theta_w"), and assigning format_coord() remains the stock full-custom path. (@claude) (#48)

  • The labels option of every isopleth family accessor — for example isobars() — now places labels on the diagram’s edges as well as inline. Alongside True and False it accepts the edge names "bottom", "top", "left" and "right", singly or as a tuple, so ax.isobars(labels=("bottom", "left")) builds the printed chart’s pressure scale. Listed edges label the members that reach them and every member left over is labelled inline, so nothing goes missing when a family only partly reaches an edge. Edge labels are native matplotlib ticks and track pan, zoom and set_extent(). One family may hold an edge at a time; a second claimant raises TypeError. Hiding a family — with visible=False or set_visible() — releases the edge it held for another family to take, and showing it again reclaims it (raising TypeError if another family took the edge meanwhile). The out-of-the-box diagram is unchanged. When an edge label set crowds (e.g. the default 50 hPa isobar family on the left edge), pass interval= to thin the family; the isobars() reference configuration uses interval=150 for a legible pressure scale. (@claude) (#51)

  • edge_axis() returns the matplotlib Axis that draws one diagram edge’s isopleth ticks, keyed by the same "bottom", "top", "left" and "right" names the labels option takes. The top and right edges are drawn by lazily created secondary axes and had no public handle, so stock matplotlib styling could not reach them; ax.edge_axis("top").set_tick_params(labelsize=12) now works on all four edges alike. Releasing a top or right edge hides its secondary axes instead of destroying it, so a handle held across the release stays live and its ticks and title survive the reclaim. (#52, @claude) (#56)

  • tephpy config generate now wraps the line of prose above each option, so no line of the file it writes runs past 88 columns (#106). Eleven lines used to overrun, the longest reaching 117. Option lines themselves are left alone — a wrapped YAML value is not YAML — so what you uncomment and edit is unchanged. (@claude) (#123)

🐛 Bug Fixes#

  • Fixed matplotlib.figure.Figure.clear() raising AttributeError once a diagram carried a side panel: the panel teardown now stands down for the figure’s own clear, which removes every axes itself. Hardened the tephpy.io readers on the same pass — fetch() maps the http.client failures urlopen() leaves unwrapped (a truncated body, a malformed status line) and an unreadable HTTP error page to TephpyIOError, and rejects a body that is not CSV, a row shorter than the header, or a header with no data rows rather than raising IndexError or a misleading TephpyValidationError; read() maps a corrupt zip (a bad CRC, central directory, or deflate stream) and says so plainly when an unmatched time= has no nominal launch times to suggest; and both readers now pass a one-sided wind column as absent wind, so the failure downstream is MissingDataError from plot_barbs(). (@claude) (#41)

  • Fixed the shade_cape() and shade_cin() fills pulling away from the plotted profiles between sounding levels (#42): the region builders now sample both curves along the drawn polylines — the straight segments matplotlib draws between profile levels in tephigram (x, y) space — and pinch each region at the exact drawn-segment intersections, instead of interpolating temperature linearly in ln p, which bowed the fill away from the drawn lines mid-segment, visibly at larger figure scales. Geometry only: the annotated numbers still come from metpy.calc.cape_cin(). (@claude) (#43)

  • Hardened two Sounding ingest edges deferred from the Plan 4 review (#22): a bare field holding non-numeric values — for example an object-dtype from_dataframe() column carrying string missing-markers such as "-----" — now raises TephpyValidationError naming the field instead of leaking a bare ValueError, and a numpy.datetime64 NaT launch time is rejected by an error that names NaT rather than reporting got <class 'NoneType'>. (@claude) (#44)

  • Isopleth member labels now honour a family’s alpha option, both inline and on a claimed diagram edge. alpha is documented as applying to a family’s lines and its labels, but it reached only the lines, so isotherms() with alpha=0.4 drew fully opaque labels over translucent isotherms. (@claude) (#51)

  • Styling a claimed diagram edge’s ticks with tick_params() now lasts. tephpy re-applied its own tick conventions on every isopleth family resolve, so an unrelated family — ax.isotherms(color="grey") after ax.isobars(labels="left") — silently reverted the tick label size, tick length and padding a user had set, and re-suppressed gridlines an explicit grid() call had enabled. The conventions are now stamped once, when the edge axis is created, and an axis title cleared with ax.set_ylabel("") likewise stays cleared for as long as the edge is labelled. (#52, @claude) (#56)

  • Fixed an isopleth family drawing a stale geometry after a tephpy.config change (#63): configure() decided whether to rebuild its cached members from the keyword names it was handed, so a values, interval, truncation or emphasis set on tephpy.config was reported by the family’s resolved options yet never drawn. It now compares the resolved geometry, which also spares a rebuild when a caller re-passes a value the family already has. (@claude) (#62)

  • Fixed an isopleth family holding on to label artists it no longer draws (#54): IsoplethFamily pools one Text per inline label and grew that pool to fit each draw, but never gave the surplus back. Anything that shrinks the labelled set — zooming back out after a finer ladder step, or claiming an edge, which hands the members reaching it to the axis ticks — left the high-water mark pinned for the life of the axes; a round trip through the default view’s zoom ladder retained 40 labels that would never be drawn again. Switching labelling off, or hiding the family, stranded the whole pool the same way. The pool is now trimmed to what each draw actually labels, and released outright when a draw labels nothing. (@claude) (#67)

  • Fixed the configuration-file warnings blaming a file inside tephpy rather than the code that triggered them (#107). An unknown option, a null option value, and an unreadable configuration file each reported a tephpy frame the reader cannot act on; they now name your own tephpy.config.load call, or the import tephpy that ran the auto-load. Code suppressing these by module — filterwarnings(..., module="tephpy") — no longer matches as a result, and should filter on the TephpyConfigWarning category instead, which is unaffected. (@claude) (#113)

  • Fixed a configuration file value of the wrong type being applied unchecked (#105). linewidth: thick loaded silently and failed much later inside Matplotlib, naming neither the file nor the option; linewidth: true and visible: maybe loaded and drew the wrong thing without any error at all; and extent: 5 did the opposite, rejecting the whole file over one line. Every one of these now warns, naming the file and the option, and skips just that option — the rest of the file still applies. An integer is still accepted wherever a number is wanted, so linewidth: 1 is unaffected. A quoted number is not: values: ['1000', '850'] and an emphasis key of '850' used to be read as numbers, and a number in fields used to be read as a string; all three now warn and are skipped. An integer of 309 or more digits has no number to convert to, and used to stop import tephpy outright; it now warns and is skipped like any other. The two errors that do reject a whole file — an unknown section, and a section that is not a mapping — now name the file too. (@claude) (#115)

  • Fixed a configuration file value being applied without any check that its option can accept it (#116). color: notacolour is a string and interval: 0 is a number, so both passed the type check added in #105 and loaded in silence; matplotlib or tephpy then rejected them at the first draw, in a traceback naming neither the file nor the line you edited. Some did not even do that — linewidth: -1.0, linewidth: .inf, values: [0, .nan] and truncation: .nan drew a diagram that was simply not the one the file asked for. Every one of these now warns as the file is read, naming the file, the option and what the option can accept, and skips just that option — the rest of the file still applies. Where the legal set is closed, the warning lists it. color: b0b0b0 is told it is probably missing a #, the mirror image of the color: #b0b0b0 trap that YAML reads as a comment. A compound option is skipped whole: one bad member of an emphasis mapping costs the whole emphasis option. One value that used to work is now refused: linewidth: 0 is how matplotlib is told to draw no line, and visible: false is the option that means it. Values set through Python — assigning to tephpy.config.isotherms.color directly — are unaffected, and still fail at the draw as before. (@claude) (#126)

🔗 Dependencies#

  • Raised the matplotlib floor to >=3.10. tephpy’s zoom-aware artists pass the root keyword to get_figure(), which matplotlib added in 3.10, so the declared >=3.9 could not draw a labelled diagram at its own floor. 3.10 is also the oldest matplotlib inside the SPEC 0 window. (@claude) (#41)

  • Added click, platformdirs and pyyaml to tephpy’s core dependencies, and sphinx-click to the documentation extras. The three runtime additions carry the configuration file and the tephpy config command. pyyaml is floored at >=6.0.1 rather than >=6.0: the 6.0 sdist has no cp312 wheel and fails to build under current setuptools/Cython, so it cannot install on tephpy’s supported Python versions. (@claude) (#112)

  • The sphinx-click >=6.0 floor declared for the documentation extras is now verified rather than assumed (#109). It was taken from the package’s changelog when the extras gained it, unlike the three core floors declared alongside it, which were each resolved by hand — and nothing tephpy runs resolves a declared minimum, since every pixi task passes --frozen, so a floor set too low fails for one person only, the user who happens to resolve that version. Resolved now against the rest of the documentation dependencies, 6.0.0 builds the documentation clean, and the floor stands: the sole difference from the version the lockfile pins is that 6.2.0 labels each command’s usage block on the command-line reference page with a Usage heading. (@claude) (#133)

  • The declared floors for matplotlib, metpy and pint are raised to minimums that hold, from >=3.10, >=1.6 and >=0.24 to >=3.11, >=1.7 and >=0.24.4. Each was a version tephpy could not run at. LineCollection only accepts a nested 'none' from set_linestyle — the oracle the configuration domain validation asks — from 3.11.0 (#135), and saturation_mixing_ratio() only returns a non-finite value where the quantity is undefined, which the supersaturated cursor readout relies on to omit the field, from 1.7.0 (#137). pint is raised further than the conda channel alone requires: every release before 0.24.4 declares flexparser>=0.3 uncapped on PyPI, so a lowest-direct resolution pairs it with a flexparser whose non-frozen dataclasses pint cannot inherit from, and the import chain dies before any test runs (#136). All three were found by the weekly ci-floors job on its first live run — the first thing tephpy runs that resolves a declared minimum rather than the lockfile. (@claude) (#147)

  • The declared floors for sphinx-design and sphinx-autoapi are raised to minimums that hold, from >=0.6 and >=3.3 to >=0.6.1 and >=3.6.1 (#138). sphinx-design 0.6.0 requires sphinx >=5,<8, which the sphinx >=8.0 declared beside it contradicts, so the documentation tier never resolved at its floors; and every sphinx-autoapi up to and including 3.6.0 declares astroid >=3 uncapped, so a resolution at the floors pairs it with astroid 4, which it cannot drive — it reports every module of the package unreadable, emits no API objects, and the build then fails under --fail-on-warning on the cross-references to pages that were never written. Both were found by the weekly ci-floors job, the last of the three tiers it reported on its first live run. (@claude) (#148)

  • The test tier now declares a build backend that works at its floors, which the wheel the test suite builds needs and neither declaration site supplied (#152). packaging is raised from >=24.0 to >=24.2, the first release carrying packaging.licenses: without it the setuptools >=77.0.3 declared beside it raises ImportError instead of canonicalizing the SPDX license expression of this project, and the build ends before it starts. On the PyPI half setuptools and setuptools_scm were never declared at all, so a resolution at the floors left no backend installed to build with; they join the test extra rather than the core requirements, because neither is a runtime requirement of the wheel. Both were found by the weekly ci-floors job, which could not attribute either — the test that fails skips itself in the diagnosis probe, a blind spot now tracked separately (#154). (@claude) (#153)

📚 Documentation#

  • Added the developer documentation-style guide (title style and glossary rules). (@claude) (#1)

  • Added logo branding assets. (@bjlittle) (#11)

  • Documented the changelog author attribution convention using the :user: extlink role. (@claude) (#12)

  • Documented the changelog-fragment convention of cross-referencing documented APIs with Sphinx roles instead of quoting the names, and applied it to the existing fragments. (@claude) (#27)

  • Glossary definitions now cross-reference the tephpy APIs they name — for example Sounding, parcel_path(), and shade_cape — straight into the API documentation, and the documentation style guide records the convention. (@claude) (#31)

  • Third-party APIs in the documentation now cross-reference into their upstream docs through intersphinx — for example metpy.calc.moist_lapse(), pint.Quantity, pandas.DataFrame, and xarray.Dataset — and every numpydoc parameter and return type links automatically, with the convention recorded in the documentation style guide. (@claude) (#32)

  • The documentation is now built with Sphinx’s nitpicky mode enabled, so any unresolved cross-reference fails the build instead of silently rendering as plain text. Enabling it caught and fixed references that were not linking — tephpy’s own exceptions such as TephpyUnitsError in Raises sections, and IsoplethFamily in the tephigram axes accessors — which now resolve to their API documentation. (@claude) (#33)

  • Migrated readthedocs building infrastructure to use their pixi plugin. (@bjlittle) (#36)

  • Documented the attribute-documentation convention for the API reference in the developer style guide. Because sphinx-autoapi parses the source statically, Sphinx #: doc-comments are dropped from the rendered page, so a documented attribute uses the numpydoc Attributes section or a PEP 224 attribute docstring (a triple-quoted string below the assignment) instead. (@claude) (#37)

  • Swapped the design spec’s canonical usage example — and the station/time identity echoed through the Sounding and fetch() docstrings, label examples, and tests — from a stable Camborne profile whose 4 J/kg of CAPE rendered shade_cape() as a sub-pixel sliver, to Norman, Oklahoma at 2013-05-20 12Z (the Moore EF5 morning sounding: 1810 J/kg of CAPE, −271 J/kg of CIN), so the canonical example visibly demonstrates every call it makes — now including shade_cin(). (@claude) (#45)

  • Fixed the project banner not rendering on PyPI: the <picture> sources in README.md were repository-relative paths, which resolve on GitHub but 404 once PyPI serves the same markup from pypi.org, so they are now absolute raw.githubusercontent.com URLs. Added docs/src/_static/brand/assets/README.md mapping every published brand file to its counterpart in logo-bundle.zip, recording which are consumed by conf.py and the project README, and naming what the bundle carries that tephpy does not ship. Kept that directory out of the built site with exclude_patterns, whose entries are matched relative to the html_static_path root for static-file copying. (@claude) (#69)

  • Published tephpy’s design specifications in the developer guide (#65). The spec §… citations throughout src/ and tests/ now resolve to a section a reader can reach: every numbered section carries a stable anchor keyed to its number, rather than the slug docutils derives from the heading text — which discarded the number and silently collided where two sections shared a title. The implementation plans stay tracked in the repository but are deliberately unpublished. Every unresolved item in the roadmap now carries a status tag and a tracked issue. (@claude) (#83)

  • Brought every published design specification under the status contract, not just the parent one. The contract — each unsettled item carries a status tag and points at a tracked issue — was written around the parent specification’s “Assumptions and open decisions” and “Open questions” sections, which the add_logo() and published-specifications documents do not have, so both sat outside it while carrying untracked work. A specification with neither section now records its open items in its scope section instead, to the same rule. (@claude) (#87)

  • Design specification citations such as spec §3.2 now render as links to the section they name (#85), throughout the documentation and the API reference. The citations themselves are unchanged: a Sphinx transform resolves them while the doctree is built, so nothing under src/ was edited and the written form is still the plain text it always was. A companion check asserts that every rendered citation became a link — the converse of the existing citation-integrity hook — and the style guide records the placement rule that follows from it: cite a section in body prose. (@claude) (#90)

  • The check that every rendered specification citation became a link now reports the unlinked ones by where they sat — a page title, navigation chrome, an API signature or body text — and gives the advice that follows from each placement, rather than one message written for all of them. Citations nested inside a link are explained too, having previously been counted and left unexplained. A toctree :caption: is recorded as a fourth placement that cannot carry a citation. (@claude) (#97)

  • The glossary now defines parcel ascent, the process the landing page has always named, and the README links its thermodynamic terms — tephigram, parcel ascent, CAPE, CIN, LCL, LFC and EL — to those definitions, so the repository front page is a second way into the documentation. The documentation build checks those links resolve, which nothing else could: the README is not one of its sources. (@claude) (#99)

  • Every reference to a tephpy issue or pull request in the design specifications is now a link the reader can follow: 59 of them were plain text or a hand-written URL, and each is written with the :issue: or :pull: extlink role instead, so the URL is stated once in the documentation configuration. A pre-commit gate and Sphinx’s own extlinks_detect_hardcoded_links keep it that way. The pull-request number towncrier appends to every changelog entry is now a link too. (@claude) (#103)

  • Added the design specification for tephpy configuration files, published in the developer guide under the configfile spec §… citation prefix. It specifies a YAML file discovered by a matplotlibrc-style first-hit-wins cascade and loaded at import as a persistence tier beneath tephpy.config, so a house style no longer has to be retyped at the top of every script; a tephpy config console script that writes a fully-commented template of every option at its current default, and reports which file is in force; and the gates that keep the template’s defaults from drifting away from the ones the plotting code actually applies. The specification is not yet implemented. (@claude) (#104)

  • Added a Code Examples section to the documentation style guide, stating what an author may rely on across the blocks of a page, and replaced the # noqa comment in two how-to snippets with the reason the import is there. (#114, @claude) (#118)

  • tephpy.config now has an options reference page, Configuration Options, listing every option with the type it accepts and the default in force, and giving each one a target that documentation and docstrings can link to — tephpy.config.isotherms.emphasis and the rest, alongside tephpy.config.load() and its three companions (#108). The page and the file tephpy config generate writes are two renderings of one table, so they cannot come to describe different options. (@claude) (#123)

  • Gave every method on the configuration options reference page a worked example (#124). A signature and a sentence say what tephpy.config.context() is; they do not say that its keyword arguments are section names mapped to {option: value} mappings. That method and tephpy.config.reset() act on the configuration already in memory, so the configuration how-to covers neither, and the reference page was the only place in the documentation either one appeared at all. The examples are executed by the test suite, against the live API rather than a transcript of it. (@claude) (#125)

  • Configure tephpy From a File now covers a value of the right type that its option still cannot accept, and Configuration Options lists the closed sets of legal values — the diagram edges for labels, the style keys for emphasis, and the cursor readout fields — from the same objects the loader checks against, so the page cannot document a value the loader rejects (#116). (@claude) (#126)

  • Configuration Options now shows the values a reader has to type as inline literals — the diagram edges for labels, the readout fields for cursor.fields, the opacity bounds for alpha, and the corner shape for diagram.extent — rather than as prose they blend into. The file tephpy config generate writes is unchanged, since the same descriptions have to read there as plain-text YAML comments (#127). (@claude) (#129)

  • The published-specifications design no longer records counts that go stale (#94). Every figure it measured from content a pull request can change — how many citations name each specification, how many links the build renders, how many the transform leaves plain — was true when written, and nothing reported that it had turned, in a document whose readers are invited to trust it. Each is replaced by the invariant behind it and the way to obtain the number, leaving the counting to the gates, which are current by construction; the figures that stay record what was measured when a decision was taken, and say so. docs spec §4 states the rule, so a figure written later is judged by it rather than by this change. The documentation style guide now also warns against citing a section in a heading, which the build reports even where the author wrote the link themselves (#96). (@claude) (#132)

  • Added the design specification for a scheduled job that resolves tephpy’s declared dependency floors, published in the developer guide under the floors spec §… citation prefix. Because every task passes --frozen, no check in continuous integration has ever resolved a declared minimum, so the floors in pyproject.toml and requirements/ are assertions nothing tests (#109). The specification covers the two declaration sites, the generator that turns each declared floor into a pin, what each tier runs once pinned there, attributing a failure to a single package by relaxing it, the upward scan for the lowest version that passes, and the issue the job files. The specification is not yet implemented. Resolving the floors by hand while writing it found four defects the job will report on its first run (#135, #136, #137, #138). (@claude) (#139)

  • Added an experimental browser tutorial that lazily runs the current tephpy wheel entirely client-side, plots its bundled sounding on an interactive matplotlib canvas, accepts local CSV uploads, and exposes the normalized plotted values in a collapsible table. The tutorial explains each matplotlib navigation control and links to the browser-runtime projects the demo depends on. (@openai) (#143)

  • Three counted claims in the published design specifications were corrected against the code they describe. The dependency floors specification said thirteen of the test tier’s tests guard on a repository being there, which became fourteen in #164; the parent specification gave the docs job a 30-minute bound, which #165 raised to 35. That passage now also records why neither retried attempt may escalate privilege, the constraint #166 settled. The floors specification additionally stated the rule that no test reads the manifest the job rewrites without recording that #164 gates it, leaving a checked property reading as a convention. (@claude) (#167)

💼 Internal#

  • Established the project foundation: packaging, pixi workflow, linting, typing, tests, documentation skeleton, and CI. (@claude) (#1)

  • The docs pixi task now depends on docs-clean, so pixi run docs always rebuilds from a clean tree — avoiding stale incremental renders (e.g. the sphinx_changelog towncrier draft, whose fragment files Sphinx does not track as build dependencies). (@claude) (#28)

  • Hardened the isopleth edge-label tests and tidied the bookkeeping they left behind (#53). An assertion that could not fail was replaced: the edge locator’s tick_values was compared against the very list object its __call__ assigns to positions. The side-panel gutter test’s unlabelled pad, until now only ordered against the widened one, is pinned to its expected value. New coverage was added for a family moving its own claim from the top edge to the right — a transition that releases one secondary axes and builds another inside a single resolve. TephigramAxes._claim_edge no longer takes the family that its name argument already identifies. (@claude) (#59)

  • Covered the two isopleth edge-label transitions that had none (#60). The right to top claim move is now pinned alongside its top to right mirror — the harder of the two orderings, because EDGES visits top first, so it claims the new edge before releasing the old one. The barb gutter pad, until now only pinned as it widens to clear the right edge’s tick labels, is pinned as it narrows back on release too, by both routes that give the edge up: moving the claim to another edge, and dropping the labels with labels=False. A third test records that isobars() reads labels=None as “not passed” and so releases nothing. (@claude) (#64)

  • Added a pre-commit hook that fails the commit when a design-specification citation stops resolving (#86). Citations name a section by number, so renumbering one stranded every reference to it silently — a stale citation is still a well-formed sentence. The hook also found 36 citations that did not meet the rule, including the add_logo() specification’s own banner, which cited the parent’s plotting section but resolved to its own. (@claude) (#89)

  • The documentation-link gate now checks every file named in its SOURCES list rather than README.md alone, and reads a URL written inside a quoted string (#100) — so .github/scripts/changelog.py, which sends a contributor to the documentation style guide by absolute URL, fails the build when that page moves rather than pointing at a 404 nobody notices. The gate is renamed check_documentation_links.py to match what it now checks. (@claude) (#101)

  • Every python code example in the how-to, tutorial and explanation documentation is now executed by the test suite, as one script per page and in document order, so that a snippet cannot silently stop working (#114). Each page’s figures are drawn as well as built, because matplotlib defers most of its validation to draw time. (@claude) (#118)

  • The two Sphinx extension modules behind the design-specification citation cross-references are renamed tephpy_citations and tephpy_citation_xrefs, so that a documentation build no longer claims a generic top-level name ahead of every installed package on sys.path (#92). pixi run docs now runs the two gates that read the HTML it just built, which until now ran only in CI, so a change breaking the rendered cross-references failed after pushing rather than locally (#91) — pixi run docs-html builds without checking. The details that govern how those gates and the shared citation grammar behave, each verified by mutation, are recorded beside the code they govern (#93). (@claude) (#119)

  • Collapsed the duplicated $TEPHPYRC lookup in the configuration-file discovery cascade (#110). Building the cascade and discovering the file in force each read the environment variable independently, so a change to one would have diverged silently from the other, and the two could answer differently about which file was named. Both now take that answer from a single helper. What they do with it stays deliberately asymmetric: the cascade lists a named path whether or not it exists, so tephpy config path can report it as [absent], while discovery treats a named file that is missing as an error rather than falling through. (@claude) (#123)

  • A design-specification citation written in a section heading now fails the documentation build (#96). The theme rebuilds its page navigation out of the headings, keeping the citation’s text and dropping its anchor, so a reader was offered a link to the section the citation sits in rather than to the one it names — and the gate that reads the built HTML scored it linked, because it cannot tell one anchor from another. tephpy_citation_xrefs now warns as it converts such a citation, naming the heading, which --fail-on-warning turns into a failure. The citation is still converted, so nothing renders differently; a table, topic or admonition caption is unaffected, being copied into no navigation. What is reported is wider than what is converted: a citation the author wrapped in a link by hand is left alone, because nesting anchors is invalid HTML, but it is reported from a heading all the same — Sphinx builds that navigation with a filter that discards an author’s link exactly as it discards the build’s. A citation quoted as a literal survives into the navigation as one and stays silent.

    The notebook branch of the citation pre-commit gate is now exercised by a notebook nbformat wrote (#95). It locates a source line by searching for the shape nbformat writes it in, which a fixture built in that shape by hand can only restate, and nbformat is declared in the test environment so that the check runs where the CI matrix runs rather than skipping there. The documentation-link gate gained the test that holds it to resolving its default sources against the repository root rather than the working directory (#102). (@claude) (#132)

  • dependabot’s pip ecosystem is parked at open-pull-requests-limit: 0, which stops version updates over requirements/ and leaves security updates running. Those files are not a lockfile to keep fresh: their >= lines declare tephpy’s dependency floors, and through [tool.setuptools.dynamic] they are also the published dependencies metadata. Its first run against them proposed lifting every floor to the newest release — xarray>=2024.10 to >=2026.7.0 among them — which narrows what a user may install tephpy beside without any evidence that the old floor was broken, and would have left the planned ci-floors job resolving a ceiling rather than a floor (#131). An advisory remains the one case where a floor rises automatically, with a person reading it. (@claude) (#140)

  • The ci-floors workflow resolves tephpy’s declared dependency minimums each week, at both sites that declare them — the pixi tables in pyproject.toml and the requirements/pypi-*.txt files — installs what resolves, and then exercises it: the test tier runs the suite, and the docs tier builds the documentation and checks its output. Every other job installs from pixi.lock, so until now each >= was a claim nothing had ever resolved (#78). A failure is attributed to a single package by relaxing one declaration at a time and re-solving, scanned upward for the lowest version that both resolves and passes, and filed as one issue per broken floor naming both declaration sites to edit. It is scheduled rather than a pull-request gate: it solves fresh against a live channel, so it goes red for reasons no pull request caused. Four declared floors are already known broken, and its first run reports them rather than this change fixing them (#135, #136, #137, #138). (@claude) (#141)

  • The ci-floors job that files an issue per broken dependency floor runs on the runner’s interpreter. It called pixi exec while installing no pixi, so the first live run diagnosed both broken tiers and then filed nothing (#141). (@claude) (#144)

  • The test asserting that the docs tier declares the wheel builder now reads pyproject.toml from the index rather than the working tree. The conda half of the weekly ci-floors job rewrites every declared floor in the checkout to an == pin before running the suite there, so a literal specifier read from the tree fails the test tier on every run and is reported as a floor that would not hold (#155). The declaration is a property of the repository, not of the tree that job leaves behind. (@claude) (#156)

  • The generated floors manifest no longer defines features its one environment cannot reach. Only that environment survives generation, so every other feature was left defined and used by nothing, and the block of pixi warnings that produced opened each issue the weekly ci-floors job files ahead of the failure the reader came for (#150). Whole features are dropped rather than the warning suppressed, which the generated manifest wants kept for its own defects. (@claude) (#157)

  • The dependency floors job now resolves the floors a pixi pypi-dependencies table declares, against PyPI rather than against the conda channel. It read the four conda dependency tables and no others, so the docs tier’s playwright floor was never pinned and that tier ran green each week on a floor it had never tested (#151). The declaring table is carried through to the diagnosis, so the upward scan climbs the index the tier installs from and the issue the job files names the table to edit. (@claude)

    The tests that assert what pyproject.toml declares now read it from the index. The conda half of that job rewrites the manifest in the checkout it then runs the suite in, dropping every feature its one generated environment cannot reach, so three tests reading the working tree failed the test tier on the first run after they were written (#155). (@claude) (#159)

  • The dependency floors diagnosis now copies the index into each probe. It stripped .git, so the thirteen tests of the test tier’s exercise that need a repository skipped themselves there – including the one that builds a wheel from git archive HEAD, which is the test a leg had failed on. The probe re-ran the exercise green and reported the failure as a step it does not run, when the step was a test it had (#154). Attribution also drops each probe as soon as it has answered, so a diagnosis holds one environment on the runner’s disk rather than one for every floor the tier declares. (@claude) (#160)

  • A dependency floors issue now quotes what the highest version the scan tried failed on, beside the failure of the floors as declared. Where no candidate passes, the reason is usually a second broken floor, and the trace naming it was thrown away with the probe – leaving a verdict that read as “this package has no good version” when the package was sound (#145, #149). The issue also says why the two are not a contradiction: a relaxation only has to resolve, where a scanned version has to resolve and pass the tier’s exercise. (@claude) (#161)

  • The PyPI half of the weekly dependency floors job now diagnoses its own failures. Attribution and the upward scan ran for the conda half alone, and the filing job’s gate named only that half, so a PyPI-only failure went red in its own log and filed nothing. The two are one implementation over two resolvers now, pixi against the channel and uv against the package index – with the three things the two declaration sites do not share reconciled: a relaxation pins what the default resolution chose, since --resolution lowest-direct has no per-package escape; the packages the two sites spell differently key on one name, so one broken floor raises one issue; and the requirements file named is the one that declares the floor, which need not be the tier the pixi table sits in (#142). (@claude) (#162)

  • The dependency floors test that reconciles the two declaration sites’ package names now reads pyproject.toml from the index rather than the working tree, and a gate reports any other test that hands the manifest path to a call. The conda half of the floors workflow runs the test suite in a checkout whose manifest the generator has rewritten in place, so a test reading that file directly passes everywhere except the one job it exists to run in (#163). (@claude) (#164)

  • The documentation workflow’s browser steps now bound each attempt they make, so a stalled browser install or demo check fails over to its retry instead of consuming the whole job budget, and the timeouts inside check_browser_demo.py are now small enough to fit within one such attempt. A test holds the two sets of budgets against each other. (@claude) (#165)

  • The docs job now installs the browser demo’s Chromium without --with-deps, which escalated to sudo apt-get and stalled downloading a font package from the Ubuntu mirror. The retry beside it could not recover: timeout signals its own process group, but the runner may not signal a root process, so the stalled apt outlived its bound, kept the dpkg lock and failed the next attempt on the lock rather than on the network. Nothing is lost by dropping the flag — every library it installed is already current on the runner, and the only additions were fonts the demo never renders. A gate now rejects every spelling of the escalation in a retried step, because an attempt that cannot be killed is not one a retry can rescue. (@claude) (#166)

  • The draw-time refusal tests now pin how the refusals split between those that raise and those that draw in silence, rather than only their total. A docstring had claimed sixteen and four of a table holding seventeen and five since #126 introduced both, because adding a row to each table and bumping the total leaves every other gate in the file green. (@claude) (#167)

  • Two figures the design specifications quote from the code are now gated against it. The documentation job’s budget is read out of its workflow and held against the figure the parent specification quotes, and the count of tests that stand down without a repository is counted by collecting them, four of the ten functions involved being parametrised. Both had gone stale in the pull requests that moved the value they quote, reported by nothing. (@claude) (#168)

  • The docs job now names the pixi task that owns each documentation gate rather than spelling its command out, so a contributor reproducing a failure runs what the job runs and there is no second copy of the command left to drift (#120). Every gate but the first skips its dependencies: pixi deduplicates a shared dependency within one invocation and not across several, so without the flag each step would clean and rebuild the documentation in full — the saving the issue assumed was already being made. --skip-deps skips every dependency though, not only the one an earlier step supplied, so a gate given a new dependency later would quietly be run without it. A gate now holds each use of the flag to a dependency an earlier step has already run — counting only what a step is certain to have run, since a task reached down the far side of a || need never be reached at all. The test that finds the job’s network-facing steps resolves a task name to every command running it runs, dependencies included, having until now recognised those steps by a script path the workflow no longer spells out. (@claude) (#170)