docs: record why the fit is done in the sensor's natural units - #31
Open
sgjholt wants to merge 22 commits into
Open
docs: record why the fit is done in the sensor's natural units#31sgjholt wants to merge 22 commits into
sgjholt wants to merge 22 commits into
Conversation
§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
force-pushed
the
claude/fft-modernization-docs-0qy5wd
branch
from
August 13, 2026 17:39
418d9f3 to
88370a0
Compare
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
§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_guessis 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
llpspis the displacement plateau whicheverdomain 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