Changelog#
✨ New Features#
Added the tephigram coordinate
transformsand 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, thetephpy.configruntime configuration layer, and pytest-mpl image baselines. (@claude) (#15)Added the
Soundingdata model with ingest-time validation and pandas/xarray constructors, the pint units machinery over MetPy’s registry with the publictephpy.exceptionshierarchy, andTephigramAxes.plot_profile/plot_soundingwith multi-sounding overlays, derived legends, and profile image baselines. (@claude) (#19)Added the
tephpy.calcthermodynamic analysis layer —parcel_path()with surface and mixed-layer parcels and the operational cloud-base correction,normand_point(),indices(), and theProfile/SoundingIndicesdataclasses — together withTephigramAxes.plot_profileaccepting a parcelProfile, CAPE/CIN shading viashade_cape/shade_cin, theannotate_indicesside 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; thetephpy.ioreadersfetch()(University of Wyoming) andread()(IGRA v2) returning validatedSoundingobjects, with recorded, provenance-tracked fixtures;TephpyIOError; and the barb image baselines. (@claude) (#40)Added the
emphasisoption to every isopleth family, so a member can be drawn with a distinguishing style:isotherms()and its four siblings, and the matchingtephpy.configsections, map a member value tocolor,linewidth,linestyleandalphaoverrides. An emphasised member is always drawn, whatever the zoom ladder would select, soax.isotherms(emphasis={0.0: {}})marks the freezing level andemphasis={-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 thelegend()placement vocabulary, andsizeis a height in inches, soadd_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 generatewrites a fully-commented template of every option at its current default, andtephpy config pathreports 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_separationkeyword toplot_barbs(), so a call picks its own barb thinning distance — an overlaid staff, or a longerlength=glyph, wants one wider than theBARB_MIN_SEPARATIONconvention. Passing it previously raisedTypeErrorfrom 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.fieldsselects and orders the readout from a five-field registry ("pressure","temperature","theta","mixing_ratio","theta_w"), and assigningformat_coord()remains the stock full-custom path. (@claude) (#48)The
labelsoption of every isopleth family accessor — for exampleisobars()— now places labels on the diagram’s edges as well as inline. AlongsideTrueandFalseit accepts the edge names"bottom","top","left"and"right", singly or as a tuple, soax.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 andset_extent(). One family may hold an edge at a time; a second claimant raisesTypeError. Hiding a family — withvisible=Falseorset_visible()— releases the edge it held for another family to take, and showing it again reclaims it (raisingTypeErrorif 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), passinterval=to thin the family; theisobars()reference configuration usesinterval=150for a legible pressure scale. (@claude) (#51)edge_axis()returns the matplotlibAxisthat draws one diagram edge’s isopleth ticks, keyed by the same"bottom","top","left"and"right"names thelabelsoption 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 generatenow 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()raisingAttributeErroronce a diagram carried a side panel: the panel teardown now stands down for the figure’s own clear, which removes every axes itself. Hardened thetephpy.ioreaders on the same pass —fetch()maps thehttp.clientfailuresurlopen()leaves unwrapped (a truncated body, a malformed status line) and an unreadable HTTP error page toTephpyIOError, and rejects a body that is not CSV, a row shorter than the header, or a header with no data rows rather than raisingIndexErroror a misleadingTephpyValidationError;read()maps a corrupt zip (a bad CRC, central directory, or deflate stream) and says so plainly when an unmatchedtime=has no nominal launch times to suggest; and both readers now pass a one-sided wind column as absent wind, so the failure downstream isMissingDataErrorfromplot_barbs(). (@claude) (#41)Fixed the
shade_cape()andshade_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 frommetpy.calc.cape_cin(). (@claude) (#43)Hardened two
Soundingingest edges deferred from the Plan 4 review (#22): a bare field holding non-numeric values — for example an object-dtypefrom_dataframe()column carrying string missing-markers such as"-----"— now raisesTephpyValidationErrornaming the field instead of leaking a bareValueError, and anumpy.datetime64NaTlaunch time is rejected by an error that names NaT rather than reportinggot <class 'NoneType'>. (@claude) (#44)Isopleth member labels now honour a family’s
alphaoption, both inline and on a claimed diagram edge.alphais documented as applying to a family’s lines and its labels, but it reached only the lines, soisotherms()withalpha=0.4drew 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")afterax.isobars(labels="left")— silently reverted the tick label size, tick length and padding a user had set, and re-suppressed gridlines an explicitgrid()call had enabled. The conventions are now stamped once, when the edge axis is created, and an axis title cleared withax.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.configchange (#63):configure()decided whether to rebuild its cached members from the keyword names it was handed, so avalues,interval,truncationoremphasisset ontephpy.configwas 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):
IsoplethFamilypools oneTextper 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
tephpyrather 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 owntephpy.config.loadcall, or theimport tephpythat ran the auto-load. Code suppressing these by module —filterwarnings(..., module="tephpy")— no longer matches as a result, and should filter on theTephpyConfigWarningcategory instead, which is unaffected. (@claude) (#113)Fixed a configuration file value of the wrong type being applied unchecked (#105).
linewidth: thickloaded silently and failed much later inside Matplotlib, naming neither the file nor the option;linewidth: trueandvisible: maybeloaded and drew the wrong thing without any error at all; andextent: 5did 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, solinewidth: 1is unaffected. A quoted number is not:values: ['1000', '850']and anemphasiskey of'850'used to be read as numbers, and a number infieldsused 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 stopimport tephpyoutright; 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: notacolouris a string andinterval: 0is 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]andtruncation: .nandrew 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: b0b0b0is told it is probably missing a#, the mirror image of thecolor: #b0b0b0trap that YAML reads as a comment. A compound option is skipped whole: one bad member of anemphasismapping costs the wholeemphasisoption. One value that used to work is now refused:linewidth: 0is how matplotlib is told to draw no line, andvisible: falseis the option that means it. Values set through Python — assigning totephpy.config.isotherms.colordirectly — 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 therootkeyword toget_figure(), which matplotlib added in 3.10, so the declared>=3.9could 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,platformdirsandpyyamlto tephpy’s core dependencies, andsphinx-clickto the documentation extras. The three runtime additions carry the configuration file and thetephpy configcommand.pyyamlis floored at>=6.0.1rather than>=6.0: the6.0sdist 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.0floor 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.0builds the documentation clean, and the floor stands: the sole difference from the version the lockfile pins is that6.2.0labels each command’s usage block on the command-line reference page with aUsageheading. (@claude) (#133)The declared floors for
matplotlib,metpyandpintare raised to minimums that hold, from>=3.10,>=1.6and>=0.24to>=3.11,>=1.7and>=0.24.4. Each was a version tephpy could not run at.LineCollectiononly accepts a nested'none'fromset_linestyle— the oracle the configuration domain validation asks — from3.11.0(#135), andsaturation_mixing_ratio()only returns a non-finite value where the quantity is undefined, which the supersaturated cursor readout relies on to omit the field, from1.7.0(#137). pint is raised further than the conda channel alone requires: every release before0.24.4declaresflexparser>=0.3uncapped 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 weeklyci-floorsjob 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-designandsphinx-autoapiare raised to minimums that hold, from>=0.6and>=3.3to>=0.6.1and>=3.6.1(#138).sphinx-design 0.6.0requiressphinx >=5,<8, which thesphinx >=8.0declared beside it contradicts, so the documentation tier never resolved at its floors; and everysphinx-autoapiup to and including3.6.0declaresastroid >=3uncapped, 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-warningon the cross-references to pages that were never written. Both were found by the weeklyci-floorsjob, the last of the three tiers it reported on its first live run. (@claude) (#148)The
testtier now declares a build backend that works at its floors, which the wheel the test suite builds needs and neither declaration site supplied (#152).packagingis raised from>=24.0to>=24.2, the first release carryingpackaging.licenses: without it thesetuptools >=77.0.3declared beside it raisesImportErrorinstead of canonicalizing the SPDXlicenseexpression of this project, and the build ends before it starts. On the PyPI halfsetuptoolsandsetuptools_scmwere never declared at all, so a resolution at the floors left no backend installed to build with; they join thetestextra rather than the core requirements, because neither is a runtime requirement of the wheel. Both were found by the weeklyci-floorsjob, 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)
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(), andshade_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, andxarray.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
nitpickymode 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 asTephpyUnitsErrorinRaisessections, andIsoplethFamilyin the tephigram axes accessors — which now resolve to their API documentation. (@claude) (#33)Migrated
readthedocsbuilding infrastructure to use theirpixiplugin. (@bjlittle) (#36)Documented the attribute-documentation convention for the API reference in the developer style guide. Because
sphinx-autoapiparses the source statically, Sphinx#:doc-comments are dropped from the rendered page, so a documented attribute uses the numpydocAttributessection 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
Soundingandfetch()docstrings, label examples, and tests — from a stable Camborne profile whose 4 J/kg of CAPE renderedshade_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 includingshade_cin(). (@claude) (#45)Fixed the project banner not rendering on PyPI: the
<picture>sources inREADME.mdwere repository-relative paths, which resolve on GitHub but 404 once PyPI serves the same markup frompypi.org, so they are now absoluteraw.githubusercontent.comURLs. Addeddocs/src/_static/brand/assets/README.mdmapping every published brand file to its counterpart inlogo-bundle.zip, recording which are consumed byconf.pyand the project README, and naming what the bundle carries that tephpy does not ship. Kept that directory out of the built site withexclude_patterns, whose entries are matched relative to thehtml_static_pathroot for static-file copying. (@claude) (#69)Published tephpy’s design specifications in the developer guide (#65). The
spec §…citations throughoutsrc/andtests/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.2now 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 undersrc/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 ownextlinks_detect_hardcoded_linkskeep 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 amatplotlibrc-style first-hit-wins cascade and loaded at import as a persistence tier beneathtephpy.config, so a house style no longer has to be retyped at the top of every script; atephpy configconsole 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
# noqacomment in two how-to snippets with the reason the import is there. (#114, @claude) (#118)tephpy.confignow 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.emphasisand the rest, alongsidetephpy.config.load()and its three companions (#108). The page and the filetephpy config generatewrites 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 andtephpy.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 foremphasis, 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 forcursor.fields, the opacity bounds foralpha, and the corner shape fordiagram.extent— rather than as prose they blend into. The filetephpy config generatewrites 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 inpyproject.tomlandrequirements/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
testtier’s tests guard on a repository being there, which became fourteen in #164; the parent specification gave thedocsjob 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)The Emphasise a Reference Isopleth and Add the tephpy Logo how-to guides now show the figures their snippets produce, rendered from those snippets by the documentation build rather than described in prose. Every python block on a page that publishes figures is a
.. plot::, so the block a reader is invited to copy is the one that drew the picture above it. The rules a page follows to do that are written down in the “Published Figures” section of the documentation style guide. (@claude) (#172)
💼 Internal#
Established the project foundation: packaging, pixi workflow, linting, typing, tests, documentation skeleton, and CI. (@claude) (#1)
The
docspixi task now depends ondocs-clean, sopixi run docsalways rebuilds from a clean tree — avoiding stale incremental renders (e.g. thesphinx_changelogtowncrier 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_valueswas compared against the very list object its__call__assigns topositions. 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_edgeno longer takes the family that itsnameargument already identifies. (@claude) (#59)Covered the two isopleth edge-label transitions that had none (#60). The
righttotopclaim move is now pinned alongside itstoptorightmirror — the harder of the two orderings, becauseEDGESvisitstopfirst, 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 withlabels=False. A third test records thatisobars()readslabels=Noneas “not passed” and so releases nothing. (@claude) (#64)Added a
pre-commithook 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 theadd_logo()specification’s own banner, which cited the parent’splottingsection but resolved to its own. (@claude) (#89)The documentation-link gate now checks every file named in its
SOURCESlist rather thanREADME.mdalone, 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 renamedcheck_documentation_links.pyto 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
matplotlibdefers most of its validation to draw time. (@claude) (#118)The two Sphinx extension modules behind the design-specification citation cross-references are renamed
tephpy_citationsandtephpy_citation_xrefs, so that a documentation build no longer claims a generic top-level name ahead of every installed package onsys.path(#92).pixi run docsnow 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-htmlbuilds 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
$TEPHPYRClookup 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, sotephpy config pathcan 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_xrefsnow warns as it converts such a citation, naming the heading, which--fail-on-warningturns 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
nbformatwrote (#95). It locates a source line by searching for the shapenbformatwrites it in, which a fixture built in that shape by hand can only restate, andnbformatis 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’spipecosystem is parked atopen-pull-requests-limit: 0, which stops version updates overrequirements/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 publisheddependenciesmetadata. Its first run against them proposed lifting every floor to the newest release —xarray>=2024.10to>=2026.7.0among them — which narrows what a user may install tephpy beside without any evidence that the old floor was broken, and would have left the plannedci-floorsjob 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-floorsworkflow resolves tephpy’s declared dependency minimums each week, at both sites that declare them — thepixitables inpyproject.tomland therequirements/pypi-*.txtfiles — installs what resolves, and then exercises it: thetesttier runs the suite, and thedocstier builds the documentation and checks its output. Every other job installs frompixi.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-floorsjob that files an issue per broken dependency floor runs on the runner’s interpreter. It calledpixi execwhile installing no pixi, so the first live run diagnosed both broken tiers and then filed nothing (#141). (@claude) (#144)The test asserting that the
docstier declares the wheel builder now readspyproject.tomlfrom the index rather than the working tree. The conda half of the weeklyci-floorsjob rewrites every declared floor in the checkout to an==pin before running the suite there, so a literal specifier read from the tree fails thetesttier 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-floorsjob 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-dependenciestable declares, against PyPI rather than against the conda channel. It read the four conda dependency tables and no others, so thedocstier’splaywrightfloor 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.tomldeclares 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 thetesttier 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 thetesttier’s exercise that need a repository skipped themselves there – including the one that builds a wheel fromgit 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
uvagainst 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-directhas 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.tomlfrom 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.pyare now small enough to fit within one such attempt. A test holds the two sets of budgets against each other. (@claude) (#165)The
docsjob now installs the browser demo’s Chromium without--with-deps, which escalated tosudo apt-getand stalled downloading a font package from the Ubuntu mirror. The retry beside it could not recover:timeoutsignals its own process group, but the runner may not signal a root process, so the stalledaptoutlived its bound, kept thedpkglock 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
docsjob 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-depsskips 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)Every figure the documentation publishes is now compared against an approved baseline whenever the documentation is built, keyed on the name each block declares rather than on a per-document counter, so inserting a section no longer renumbers every image after it. A change in what a snippet draws fails the build rather than reaching the published page unnoticed. Re-approve an intended change with
pixi run docs-figures, which reports the RMS of each image it updates. The gate runs in CI beside the citation and link gates, and the baselines are pruned from the source distribution. (@claude) (#172)