Skip to content

docs: record why the fit is done in the sensor's natural units - #31

Open
sgjholt wants to merge 22 commits into
mainfrom
claude/fft-modernization-docs-0qy5wd
Open

docs: record why the fit is done in the sensor's natural units#31
sgjholt wants to merge 22 commits into
mainfrom
claude/fft-modernization-docs-0qy5wd

Conversation

@sgjholt

@sgjholt sgjholt commented Aug 10, 2026

Copy link
Copy Markdown
Owner

§5's tier-2 note had this the wrong way round. It read the
velocity-versus-displacement business as a sharp edge in initial_guess,
with detecting or refusing a motion as the candidate fix. The rule is the
other way about: the fit belongs in the units the sensor recorded, and
initial_guess is built for that rather than in spite of it.

Converting first is not a neutral change of view. Integrating to
displacement divides by 2pif and implicitly low-passes; differentiating
to acceleration multiplies by it and blows high-frequency noise up. The
model's motion factor means llpsp is the displacement plateau whichever
domain is fitted, so what a conversion changes is the record and its
noise, not the answer's meaning.

Measured while writing this up: taking the spectral peak as the fc guess
is exact for a Brune source in velocity, not a heuristic. Velocity is
2pif times the displacement shape, maximised at f = fc — on a
20001-point grid the peak lands at 7.999 Hz for a true 8.0. Displacement
and acceleration are monotonic over the band, so their peaks are the band
edges: 0.1 Hz and 100 Hz on the same grid.

What survives from the original note is only that the wrong call is
silent, which is a warning rather than a redesign.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

claude and others added 15 commits August 10, 2026 04:46
§5's tier-2 note had this the wrong way round. It read the
velocity-versus-displacement business as a sharp edge in `initial_guess`,
with detecting or refusing a motion as the candidate fix. The rule is the
other way about: the fit belongs in the units the sensor recorded, and
`initial_guess` is built for that rather than in spite of it.

Converting first is not a neutral change of view. Integrating to
displacement divides by 2*pi*f and implicitly low-passes; differentiating
to acceleration multiplies by it and blows high-frequency noise up. The
model's motion factor means `llpsp` is the displacement plateau whichever
domain is fitted, so what a conversion changes is the record and its
noise, not the answer's meaning.

Measured while writing this up: taking the spectral peak as the fc guess
is *exact* for a Brune source in velocity, not a heuristic. Velocity is
2*pi*f times the displacement shape, maximised at f = fc — on a
20001-point grid the peak lands at 7.999 Hz for a true 8.0. Displacement
and acceleration are monotonic over the band, so their peaks are the band
edges: 0.1 Hz and 100 Hz on the same grid.

What survives from the original note is only that the wrong call is
silent, which is a warning rather than a redesign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recorded as a Brune property yesterday; it is more general than that, and
the generality is the part that matters for anyone adding a source model.

Maximising f * [1 + (f/fc)^(g*n)]^(-1/g) puts the stationary point at
(n - 1) * (f/fc)^(g*n) = 1, which is f = fc whenever n = 2, for any g. The
corner's sharpness does not enter, so the property belongs to the
omega-squared falloff rather than to Brune. Measured on a 400001-point
grid, both registered sources peak at 8.0000 Hz for a true 8.0 — brune
(g=1) and boatwright (g=2) alike.

Which also says where it would stop: a source with n != 2 moves the peak
to fc*(n-1)^(-1/(g*n)) and the guess quietly stops being exact. Noted at
the point someone would need it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The silent failure tier 2 turned up. `initial_guess` takes the frequency
of the spectral peak as the fc guess, which is the corner only in
velocity; in displacement and acceleration the spectrum is monotonic
across the band, so the peak is whichever edge it was handed.
`FitSpectra(spectra.to_motion("displacement"))` is a natural thing to
write and returned fc 1.6 against a true 8.0, with Omega 0.6 magnitude
units low and nothing said.

The design is not what was wrong — a fit belongs in the units the sensor
recorded, because integrating implicitly low-passes and differentiating
amplifies high-frequency noise. Only the silence was wrong, so this is a
warning naming the station and the motion, not a change of behaviour.

`FittableView` grew a `motion` property to make it possible. It reads
through to the pair like `freq`, `amp` and `band` already do, and a fitter
has to know the domain for precisely this reason — the view is meant to
present what a fitter reads, and this was missing from it.

Measured on the three synthetic stations, and pinned: displacement guesses
of 0.71, 2.12 and 1.02 against velocity's 6.47, 7.83 and 5.72, for a true
8.0. Peak-equals-corner is exact for the noiseless model; on a measured
spectrum the argmax wanders, so velocity gives a starting point in the
right neighbourhood rather than the answer — which is all a guess has to
be.

825 passed, up from 821.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 5's `Fitting.py -> fitting/`. Four modules along the seams that
were already there:

- `base` — the structural aliases, `PLOT_COLUMNS`, and the attribute list
  `FitSpectrum` reads off whatever it is handed
- `guess` — `fittable_signal`, `selected_band`, `initial_guess`: what to
  fit and where to start
- `spectrum` — `FitSpectrum`, and the lmfit-stubs note that only applies
  here
- `event` — `FitSpectra` and its table

Purely structural: no behaviour changes and no signatures move. Every
name the module exported is re-exported from the package, so
`from specmod.fitting import FitSpectra` and the rest are untouched —
`staged`, `plotting` and five test modules import it that way and none
needed editing.

The dependency graph came out acyclic without rearranging anything, which
is the evidence the seams were real: base <- guess <- spectrum <- event.
Only two names needed an import that the single module had got for free —
`Mapping` in `event`, and `selected_band`, which is what makes `spectrum`
depend on `guess` rather than the two being siblings.

829 passed, up from 825. The four are `test_import.py`'s package walk
finding four modules where there was one, not new behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cwt` was the one estimator held at a loose tolerance for a CI
disagreement nobody could explain. The note recording it framed the open
question as "why does macOS agree with the reference and Linux not, when
the reference was generated on Linux". That framing is wrong: this box is
Linux, matches the reference environment exactly, and reproduces `cwt` to
3.8e-16. It is that runner versus this one, not an OS split.

Six mechanisms proposed for it, all measured, none survives:

- a remaining discontinuity in the lift — the noise path is linear on all
  28 windows, 1e-13 in giving 8.7e-14 out with no step. Which is the
  decisive one: linear means a 1-2% output difference needs a 1-2% *input*
  difference, and last-bit floating point cannot supply that.
- a sample on a bin edge — the closest `cwt` sample is 5.7e-4 of a bin
  from an interior edge, against 1.4e-6 for `fft`.
- a window differing on CI — one sample fewer moves `fft`'s noise 8.5% and
  `cwt`'s 3.6%, so `fft` would disagree first, and `fft` is exact.
- quantile fragility from `cwt`'s shorter arrays (51 against 109) — a
  1e-15 perturbation moves its worst quantile 2.4e-14, better than `fft`.
- PyWavelets — not used; the estimator is hand-written.
- threading — the transform is a batched numpy.fft.ifft.

So the CWT is as stable as every other estimator, and 5e-2 was twelve
orders of magnitude looser than anything reproducible: wide enough to hide
the real difference rather than describe it.

1e-3 is an experiment rather than a calibration — still 5e4 times looser
than the worst response measured. If the residual is real CI reports it,
with the per-station detail `_compare` already prints. If it is not, the
entry can go. Both outcomes beat the number that was there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2b's documentation half. `docs/conf.py`, an `index` that says where
to start, and `api.md` covering 301 objects through autodoc. The four
existing pages join a toctree; `sphinx-build -b html docs docs/_build/html`
produces a site with no warnings but the network-inventory ones.

Four things the build turned up, each fixed rather than tolerated:

- Documenting a package *and* its submodules gave every re-exported name
  two targets, so every cross-reference to `PickSet`, `SensorID`,
  `Resolution` and `FitSpectra` was ambiguous. Packages are now documented
  at the path you import from, with `:imported-members:`.
- `processing.md` linked to `REFACTOR_PLAN.md`, which is deliberately not
  in the site — it is a working document, not documentation. The link now
  points at GitHub.
- `notes/` was excluded on the same reasoning, wrongly:
  `choosing_a_transform.md` links to `notes/window_position.md` for a
  per-trace table, so it is documentation and is now built.
- autodoc cannot format a signature for a callable dataclass *instance*
  documented as module data, so `HOLT_2019_UTAH` raised. `inspect.signature`
  handles it; autodoc's own formatter does not. Excluded, with the value
  written out in prose.

`sphinx-autodoc-typehints` is dropped. Measured both ways it produced the
same 367 documented objects as `sphinx.ext.autodoc` with
`autodoc_typehints = "description"`, while calling an API Sphinx 10 removes
— a deprecation warning per module for no gain.

`ci/workflows/docs.yml` builds on every PR and deploys only from main. No
`-W`: intersphinx resolves seven inventories over the network and warns
when one is briefly unreachable, and a third party's downtime should not
be a red build.

**The lint job will fail until `ci/workflows/docs.yml` is copied to
`.github/workflows/docs.yml`** — that is the mirror check doing its job,
and `ci/README.md` now carries the row and the note that Pages has to be
switched to "GitHub Actions" once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`notes/window_position.md` was included in the build but reachable from no
toctree, so Sphinx warned and the page had no place in the navigation — it is
linked from `choosing_a_transform.md` for a per-trace table, which is what
makes it documentation rather than a working note. A hidden toctree on the
page that cites it puts it where it belongs and takes the build to zero
warnings that are not intersphinx reaching the network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the release half of Phase 2b: a standing release pull request
carrying the generated changelog, and on merge a tag, a GitHub Release, a PyPI
upload through Trusted Publishing and a Zenodo DOI. Nothing publishes until a
human merges that PR, which is the point — a DOI cannot be retracted.

Three settings are load-bearing, each chosen against a measured consequence:

- `include-component-in-tag: false`. Left on, the tag is `specmod-v0.2.0`,
  which pyproject's `--match v[0-9]*` does not describe and its `tag_regex`
  does not parse; the wheel would build as `0.1.1.postN.devN` and upload under
  that name, permanently. `tests/test_release_config.py` asserts the two
  formats agree and `tools/check_built_version.py` re-checks the artefact
  between the build and the upload, because a config test cannot see a build.
- `bump-minor-pre-major: true`. Two breaking commits are already in the
  history; without it either proposes 1.0.0 — a version that says the API has
  stopped moving, with a DOI attached.
- Explicit `changelog-sections`. The default preset hides refactor, docs,
  build, test, ci, style and chore: measured over this repository's 146
  conventional commits it would print 73 and drop 73.

The publish job is here rather than in a `publish.yml` keyed on
`release: published`, which the plan described and which would never have
fired — release-please creates the release with the default GITHUB_TOKEN, and
events triggered by that token do not start a workflow. The alternative is a
personal access token in secrets, which is what Trusted Publishing exists to
avoid. Zenodo is unaffected: it listens to the release webhook.

`release-type: simple` suits a project with no version string to update — its
only other updater targets `version.txt` with `createIfMissing: false`, so
with no such file it writes the changelog and nothing else. Read in
release-please's source rather than assumed, since a second source of truth
for the version would defeat hatch-vcs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three additions to the site, all of them things a reader currently has to
infer from the refactor plan or from the code:

`releasing.md` — the companion to `releasing-data.md`, covering `v*` tags
rather than `data-v*`. What each piece decides, the six repository settings
that cannot be expressed in a commit (Pages, Actions-may-open-PRs, the pypi
environment, the trusted publisher, the Zenodo webhook, branch protection),
the cycle itself, and why the publish job sits where it does.

`roadmap.md` — §7 of the plan restated for a reader: stages, no durations and
no phase numbers. The estimates in the plan have already been wrong, and
publishing them would turn an estimate into a promise. Stages become
milestones against released versions once there are releases to anchor them
to, and the page says so.

The alpha status was in the README and nowhere on the site. It is now the
first thing on the index, and specific rather than vague: names move between
0.x releases without a deprecation cycle, some numbers still move where the
refactor has not reached, pin an exact version for published work — and what
does not move quietly, which is the units conventions and the golden
references.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
§6.3 was still written in the future tense after the Sphinx site was built,
and still named things the build had dropped. It now records what changed:
`sphinx-autodoc-typehints` gone (measured — the same 367 documented objects as
the built-in extension, while calling an API Sphinx 10 removes), the four
faults the first build found, and no `-W`, because intersphinx warns whenever
one of seven inventories is briefly unreachable and a third party's downtime
is not a check.

Two predictions in that section were testable once there was a renderer, and
one was wrong: the display block spanning two lines and the 26 underscored
inline expressions were expected to break. Built, the two prose pages contain
no literal `$` at all and 120 math nodes between them. `amsmath` turned out
unnecessary — the one `\begin{cases}` sits inside `$$`.

§6.4 now records the release configuration and why each setting is what it is;
§6.5's two release workflows are one, because the second could never have
fired. Both are added to §6.6's table, along with the `commitlint` claim that
was corrected in §7 and left standing in §6.4 in the same document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 1e-3 experiment has its answer. One CI run, six test jobs, one commit:
ubuntu 3.11 and 3.13 fail with the *same* 8 differences, the same three
windows and the same magnitudes to three significant figures; ubuntu 3.12 and
all three macOS jobs pass. So it is deterministic rather than flaky, and it is
neither the OS nor the Python version nor a package version that tracks the
Python version — a third ubuntu job in the same run reproduces the reference
exactly. What is left is which machine the job landed on, which is the
same-run control the earlier round could not get.

The datum that localises it: on the machine that disagrees, fft, welch,
multitaper and quadratic all still reproduce the reference byte for byte. It
is in the CWT path, not in that machine's arithmetic.

2e-2 is the worst observed difference (1.44e-2, UR.AQ10.00.HHN bsnr) plus
about 40% of headroom — 2.5 times tighter than the 5e-2 it replaces, and
labelled in the test as a bound rather than a calibration. A CWT noise
regression under 2% would pass it; the other four estimators stay at 1e-6,
and they share every step of the pipeline these windows go through.

Going further needs the failing machine: the cwt path run there and on a
passing one, diffing the three named windows before binning rather than after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were noise in every CI run, and neither was a fault in the code under
test.

`pytest.warns(match=...)` re-emits the warnings that do not match, so a test
asserting on S00's wrong-motion warning let S01's and S02's through. The test
now asserts on all three, which is the better assertion anyway: the warning is
per station, and a run over a mixed collection should not name only whichever
spectrum came first.

ObsPy's NORDIC writer warns about a pick with no evaluation mode set. Third
party, unrelated to what the roster tests assert, and now filtered by message
so a different NORDIC warning would still surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `floors` job reported 439 warnings against 4 everywhere else. Almost all
of them are matplotlib's mathtext calling pyparsing's camelCase API, which
pyparsing 3.3 deprecates — visible only on `--resolution lowest-direct`,
because that is where an old matplotlib meets a current pyparsing.

Measured, with pyparsing 3.3.2 throughout: matplotlib 3.9.0, 3.9.4 and 3.10.0
each produce 328 of them in `test_io_and_plotting.py` alone; 3.11.1 produces
none. So it is fixed above the declared `matplotlib>=3.9` floor — but raising
the floor to silence a warning that says nothing about this package would drop
two matplotlib minors for cosmetics. Filtered by category instead, with a note
to delete the line when the floor passes 3.11.

`ci/workflows/docs.yml` also picks up the `uv venv` step added to the live
workflow to make the docs job install: the staged copy is meant to be the
intended state, and the live file was ahead of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sgjholt
sgjholt force-pushed the claude/fft-modernization-docs-0qy5wd branch from 418d9f3 to 88370a0 Compare August 13, 2026 17:39
claude added 7 commits August 13, 2026 17:47
`pre-commit install` wires only the pre-commit stage by default, so the
commit-msg hook that rejects session links was inert in every clone that
followed the documented setup — including one that then published three of
them. `default_install_hook_types` names both stages, so one `pre-commit
install` installs both. Verified: the hook files now appear for both.

`AGENTS.md` carries the rules an agent must not get wrong, with `CLAUDE.md`
pointing at it so there is one copy rather than two that drift. Every entry is
there because it has actually happened here: no session links in anything
published, Conventional Commits because release-please reads them, workflow
files go through `ci/` because the token cannot push them, run the suite the
way CI runs it, and do not regenerate a golden file or widen a tolerance to
reach green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two pages, both in the toctree, answering questions that until now could only
be answered by reading the plan or the workflow files.

`development.md` is the hub: quick start, the repository mapped, the daily
loop, a reference for every tool and `tools/` script, the test tiers and the
one rule about golden references, what each CI job is for, and the `ci/`
mirror. Its longest section is the one the rest of the repository assumes and
never states — that merging to `main` releases nothing. Three clocks,
separately controlled: development moves on any merge, a software release
moves when a human merges the release pull request, a data release moves when
someone tags `data-v*`. Plus how the version comes to exist at all, which is
`git describe` and nothing else, and why the two tag prefixes have to differ.

`documentation.md` covers the part with no obvious answer: local build versus
the pull request build versus the published site, how to actually look at what
CI built (download the artefact — there is no preview URL), and what is not
built. Historic versions are the honest gap: one site, tracking `main`, while
users are told to pin an exact version. Both routes are written up with their
costs — versioned subdirectories plus the theme's own switcher, checked
against pydata-sphinx-theme 0.20.0's validation code, or Read the Docs — with
a recommendation to do it at the first release, since there is nothing to keep
historic until a `v*` tag exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The section listed options without explaining the constraint that decides
between them: `actions/deploy-pages` publishes an artefact that becomes the
whole site, so each deployment replaces everything and directories cannot
accumulate. Versioned docs therefore mean either moving to a `gh-pages` branch
that accumulates, or rebuilding every tag on every deploy.

Recorded with the layout it produces — `/stable/`, `/dev/`, one directory per
release, a redirect at the root — since that shape is the thing people
recognise and the abstract description was not landing. Accumulating is the
better trade: historic documentation should be frozen at what it said when
that version shipped, and rebuilding an old tag years later is a failure mode
rather than a feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Changed before anything was deployed, which is the cheapest moment to change a
decision like this.

The reason is versions. `actions/deploy-pages` publishes an artefact that
becomes the whole site, so every deploy replaces everything: one site, always
showing `main`. That is incompatible with an alpha package whose own
documentation tells people to pin an exact version — the docs they need would
never be the docs they get. Keeping history on Pages means either accumulating
directories on a `gh-pages` branch or rebuilding every tag on every deploy.
Read the Docs does a build per tag, a `stable` alias, a switcher, cross-version
search and per-pull-request previews with none of that machinery.

`.readthedocs.yaml` carries one non-obvious line. Read the Docs clones shallow
and without tags; hatch-vcs derives the version from `git describe`, so without
the `post_checkout` unshallow every build reports the 0.0.0 fallback in the
sidebar — including tagged ones, which is exactly where it would be believed.
The install takes `[docs,io]` for the same reason CI does: autodoc imports
every module it documents, and `specmod.io` imports h5py and pyarrow.

`docs.yml` stays, reduced to a build check with a downloadable artefact. Two
reasons survive the move: it does not depend on a third party being up, and it
is an import check as much as a docs check. It now needs no repository setting
at all, so the Pages step drops out of `ci/README.md`.

`documentation.md` is rewritten around what people actually read — `stable`,
`latest`, and a frozen build per version — with the five one-time Read the Docs
steps, including the automation rule without which a new tag is built and then
left unpublished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sphinx substitutes `%Y` in `copyright` with the build year, so the hardcoded
2026 does not need editing every January. It also honours `SOURCE_DATE_EPOCH`,
which matters now that Read the Docs builds a frozen page per tag: a rebuild
of an old version stamps the source date rather than the day it happened to
run. Verified both ways — the site renders 2020-2026, and 2020-2024 with
SOURCE_DATE_EPOCH set into 2024.

2020 is the year in LICENSE and the year the history starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`specmod.api` re-exports a small, frozen subset of core so that packages built
on SpecMod import one thing that does not move while the internals do. It adds
no science: estimation, comparison and fitting already existed in the shape
required, and the module's work is choosing the names, freezing the
signatures, and holding five properties that the internals do not hold
uniformly — path-free, deterministic, non-mutating, quiet, typed errors.

`specmod.exceptions` roots those errors at `SpecModError`, splitting a
caller's bad input from a missing optional backend from an internal bug,
because the caller does something different with each. Each subclasses the
builtin the internals raise today, so `except ValueError` keeps working and
`specmod.api` translates at its own boundary rather than requiring a flag day
inside.

`available_estimators()` answers what will actually run here rather than what
is registered. Which backends need an extra was measured, not read off the
extras table: `cwt` and `quadratic` are implemented against numpy and scipy
and work without their nominal extras, and only `prieto` requires one.
Inferring it from `pyproject.toml` would have marked three unavailable.

`fit_spectrum` returns a frozen `SpectrumFit` carrying the covariance, not
just point estimates — and reports its absence honestly. The shipped
`[fitting] method = "powell"` estimates no covariance at all: measured on one
synthetic station, powell, nelder, leastsq and least_squares agree on `fc` to
three decimals (7.925 against a true 8.0) and only the least-squares family
reports an error for it, or the 0.84 `fc`-`t*` correlation that is the reason
neither should be quoted alone. Empty `stderr` and `covariance=None` say "not
measured"; a zero would say "certain".

Tests pin the export list against a duplicated literal, so an addition is a
diff in review; that determinism holds; that no export opens a file, checked
by making `open` raise; and that every name `available_estimators()` returns
can actually estimate, under both installs.

CONTRIBUTING.md states the promise and its exact scope: one minor release of
`DeprecationWarning` before anything on this surface changes, even at 0.x, and
no promise at all about internals or about numerical output.

The audit behind it is in docs/notes/api_audit.md, including the measurement
that was blocking a downstream design decision: a full re-estimation on a
window change — multitaper on signal and noise, Parseval rescale,
interpolation, log binning, per-bin SNR and band search — is 7.0 ms median on
a 3.7 s window and ~25 ms on a 20 s one.

Two findings are defects in core rather than in the surface, recorded rather
than fixed here: an import-time `load_config()` in `fitting/base.py` that
freezes whatever directory the process started in, and nine `print()` calls on
paths a caller reaches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects the api audit turned up, both in core rather than in the surface.

**Configuration read at import time.** `fitting/base.py` evaluated
`load_config().config.viz.plot_columns` at module level, and `load_config()`
resolves against the current working directory. At module level that answer is
frozen for the life of the interpreter: reproduced by importing from a project
whose `specmod.toml` said 5, moving to one resolving to 3, and reading 5. A
worker serving two projects would use the first one's settings for both.

It is now `fitting.plot_columns()`, resolved per call. Both call sites are
subplot layout, so nothing numerical moves. The old name still imports, via a
module `__getattr__` that warns and names both the replacement and the release
it goes in — deliberately not a `from .base import`, since that would run the
lookup once at import and rebind the result, which is the frozen behaviour
this replaces reintroduced one level up.

**Nine `print()` calls** in `fitting/event.py` and `utils.py`, on paths a
caller reaches. A service capturing logs per job got nothing from them; a CLI
writing to a pipe got its output corrupted. Each became a `warnings.warn` or a
module-logger call, and the split is not taste: `warnings` deduplicates by code
location, so the per-station failure inside `fit_spectra`'s loop would report
one station and silently drop the rest — and a station that could not be fitted
is missing from the results. That site logs; the caller-actionable ones warn.
No `basicConfig` anywhere: a library that configures logging decides format and
destination for its host.

`tests/test_ambient_state.py` parses every module in the package and fails on a
module-level `load_config()` or any `print`, because both are the kind of thing
that comes back one file at a time. Its first version was wrong in an
instructive way: `ast.walk` descends into method bodies, so it flagged three
modules that read configuration perfectly properly at call time. It now stops
at function bodies while still checking default arguments and decorators, which
do run at import and are where this would hide next. Nothing else in the
package is caught, and the print count is zero.

One existing test exercised the unsorted-stream path and silently absorbed the
new warning; it now asserts it, which is the better test — a stream returned in
input order is otherwise indistinguishable from one already sorted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants