Skip to content

Release v1.2.0: pre-whitening, native multi-band, SuperSmoother, LINCC interop - #17

Open
tjayasinghe wants to merge 32 commits into
mainfrom
release/v1.2.0
Open

Release v1.2.0: pre-whitening, native multi-band, SuperSmoother, LINCC interop#17
tjayasinghe wants to merge 32 commits into
mainfrom
release/v1.2.0

Conversation

@tjayasinghe

Copy link
Copy Markdown
Owner

Cuts v1.2.0. main is an ancestor of this branch, so it fast-forwards.

What's in the release

  • Automated pre-whitening for classical pulsators (cuperiod.prewhiten): iterative
    sinusoid extraction with principled stopping criteria, error propagation,
    combination-frequency identification, and g-mode period-spacing tools — plus a GUI
    analysis mode and prewhiten / batch-prewhiten CLI commands.
  • Native multi-band GLS on every backend, replacing the astropy delegation, with three
    joint models (GLSSettings.mb_model) and bootstrap FAP; pooled multi-band PDM, CE and
    string-length; multi-band SuperSmoother.
  • SuperSmoother — Friedman's variable-span smoother as an eighth period-search method,
    pinned against the supersmoother and gatspy reference implementations, with a
    numba-parallel CPU tier and array-API kernels for cupy/torch.
  • Alias/window diagnostics (cuperiod.alias_diagnostics) for any periodogram.
  • LINCC interoperability (cuperiod.interop): run periodograms directly over
    nested-pandas NestedFrame and lsdb HATS catalogues.
  • Real-data validation of every multi-band method on 100 SDSS Stripe 82 RR Lyrae.

Pre-release audit

The last two commits are a pre-push audit that compared every user-facing claim against the
code and the benchmark parquets. It found and fixed:

  • Three code bugs, each now with a regression test — pre-whitening reported a
    Schwarzenberg-Czerny D the bootstrap estimator never applied (and wrote it to every
    batch_prewhiten row); MultiBandLightCurve.from_dataframe discarded band_column when
    columns= was also given (this one shipped in v1.1.0); and the LINCC kernel skipped the
    supports_multiband check, so a multi-band run with a single-band method returned a
    silently all-NaN frame.
  • Stale benchmark numbers never re-synced after the auto-batch re-runs: 587 → 574 lc/s,
    18× → 20×, 2106× → 2177×, plus the multi-band timing column and the GPU parity bounds.
  • Release metadata that was never cut: version, CITATION.cff (still 1.1.0, and still
    claiming seven methods), and the [Unreleased] changelog heading.
  • ~40 further documentation corrections where the text contradicted the code.

Verification

Gate Result
pytest -q 589 passed, 2 skipped
ruff check src tests clean
mypy clean, 79 source files
sphinx-build -W --keep-going 0 warnings
uv build + twine check both distributions PASSED
clean-venv wheel install imports, CLI, and both new methods work on core deps alone

After merge

Tag v1.2.0 and push it — release.yml publishes to PyPI on v* tags via Trusted Publishing.

tjayasinghe and others added 30 commits August 11, 2026 23:21
…tors

Frequency analysis of classical pulsators (delta Scuti, gamma Dor, SPB) still
funnels through interactive Period04-style sessions, one star at a time, with
the stopping decision left to the operator's eye. This adds the whole loop as a
batch-capable, GPU-accelerated function whose every judgement call is an
explicit, recorded setting.

  solution = cup.prewhiten((time, mag, mag_err))
  print(solution.summary())

- prewhiten/spectrum.py: a least-squares amplitude spectrum on the cufinufft /
  finufft / torch / numpy backends. The Gram terms of the normal equations
  depend only on the observation times, so SpectrumEngine computes them once and
  each iteration costs a *single* transform: a 50-frequency solution needs ~52
  transforms, not ~150. Matches a brute-force weighted least-squares fit to
  5e-16; backends agree to 1e-9.
- prewhiten/fit.py: simultaneous multi-sinusoid least squares by variable
  projection with Kaufman's Jacobian, so the optimiser sees K frequencies rather
  than 3K+1 parameters and every linear parameter is solved exactly. Three
  refinement policies; narrow designs go through Cholesky normal equations,
  wider ones through a rank-revealing gelsy.
- prewhiten/engine.py: the extraction loop, with combinable stopping criteria
  (Breger S/N, Baluev FAP, delta-BIC, amplitude floor), a Loumos & Deeming
  resolution guard, bounded refinement so a frequency cannot slide onto its
  neighbour, and a post-polish prune so every reported component still passes
  the test it was admitted by. The reason a run stopped is always recorded.
- prewhiten/uncertainty.py: least-squares covariance (default, and the only
  estimator that sees the correlations between close frequencies), the
  Montgomery & O'Donoghue analytic formulae, and a residual bootstrap, each
  optionally inflated by the Schwarzenberg-Czerny correlation factor. Phases are
  referenced to the weighted mean epoch, at which phase and frequency are
  uncorrelated. Monte Carlo over 200 realisations: reported 1-sigma errors match
  the realised scatter to within ~5% in frequency, amplitude and phase.
- prewhiten/combinations.py: harmonics and combination frequencies with
  uncertainty-aware tolerances and a chance-coincidence rate per identification,
  so a meaningless match is visible as one.
- prewhiten/spacing.py: g-mode comb scan (immune to missing radial orders, and
  promoted past the sub-multiple ambiguity that makes a naive scan report dP/2),
  tilted period-spacing series extraction, echelle coordinates, buoyancy radius.
- prewhiten/batch.py: the same over many curves on the existing worker pools,
  one row per extracted component.

Timings on this machine (CPU, finufft): 3000 points / 3 modes 0.05 s;
20 000 / 15 modes 1.2 s; 100 000 / 20 modes 6.3 s. GPU and CPU solutions agree
to 3.5e-15 in frequency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  cuperiod prewhiten star.csv --snr 4.6 -n 30 --spacing --csv modes.csv
  cuperiod batch-prewhiten "lcs/*.csv" --out modes.parquet --workers 8

`prewhiten` prints the ranked components with their uncertainties and S/N and
the reason the run stopped, optionally searches the independent modes for a
g-mode period-spacing pattern (--spacing), and can write the full solution as
JSON, the component table as CSV, and the amplitude spectra as .npz.
`batch-prewhiten` takes the same extraction options and the same worker/sink
options as `batch`, writing one row per extracted component.

Both build a validated PreWhitenSettings from the flags, so an impossible
combination is a BadParameter rather than a traceback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An **Analysis** picker switches the window between *Periodogram* and
*Pre-whitening*. Everything shared stays shared — the same controller, the same
loaders and source browser, the same spectrum / phased / raw views, the same
off-thread compute with generation gating and result caching — and only the
result docks swap. The existing workflow is untouched: no periodogram code path
changed behaviour, and the three result docks are tabbed together so switching
analysis never rearranges a layout the user has arranged.

- controls_panel: the analysis picker hides the method and peak-count rows and
  rebuilds the options form from PreWhitenSettings. The form is generated by the
  existing PydanticSettingsForm, so every pre-whitening knob is exposed with the
  right type, bounds and tooltip without a single bespoke widget. Settings are
  cached per analysis, so switching back restores your values.
- compute: PreWhitenTask runs the extraction on the worker pool, sharing the
  generation counter with PeriodogramTask so switching mid-run supersedes
  cleanly.
- state: run_prewhiten() with its own LRU cache (ResultCache is now generic over
  its value type), so flipping between analyses of the same star is instant.
- solution_panel: the extracted components with 1-sigma uncertainties, S/N, FAP
  and any combination identification, plus the stopping reason and fit
  statistics. Selecting a row folds the phased view on that component, exactly
  as a periodogram peak does. Copy and full-precision CSV export.
- spacing_panel: the comb scan over trial spacings and the echelle diagram, run
  on demand over the *independent* components so identified combinations cannot
  pollute the pattern. Reports the mean spacing, its tilt, the rms, and Pi_0.
- spectrum_view: gains an overlay trace, so pre-whitening reuses the full
  spectrum view — downsampled full-resolution rendering, draggable selection
  band, log axes, crosshair readout, CSV/PNG export — with the amplitude
  spectrum as the main curve, the residual spectrum overlaid, and the components
  as peak markers.

Fixes a bug found driving the window headlessly: switching analysis cleared the
phased view's light curve (not just its fold), leaving an empty panel until the
next file load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- guide/prewhitening.md: what one iteration does, the stopping criteria and how
  pure a frequency list they actually buy, the three uncertainty estimators and
  why phases are referenced to the weighted mean epoch, combination frequencies
  and their chance-coincidence rate, the g-mode period-spacing tools, batch and
  CLI usage, and a settings reference. With the primary literature it implements.
- Wired into the guide index, the site index (toctree + card + intro), the API
  reference (a Pre-whitening section plus the two new settings models), the CLI
  guide (both new commands with full option tables), and the GUI guide (the
  Analysis picker and the two new docks).
- README gains a pre-whitening section and the new CLI lines; the quickstart
  gains a short pulsator example; CITATION's abstract describes the capability.
- version -> 1.2.0.dev0 (bumped to 1.2.0 at release), docs conf fallback -> 1.2.0,
  and asteroseismology/pulsations/pre-whitening added to the package keywords.

Docs build clean under -W.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five independent reviewers over the new code, each finding verified by two
skeptics who tried to refute it by running it. Eight candidates, five survived.

spacing.py — a legitimate period-spacing series was silently reported as absent.
  The refit loop aborted whenever the fitted *intercept* of `dP(P) = a + b*P`
  went non-positive. `a` is a nuisance parameter of the parameterisation, not a
  spacing: it is negative for any series whose spacing grows steeply, and the
  guard also fired on the noisier intermediate fits made on partial chains. Only
  `a + b*P` over the observed range must be positive, and `_longest_chain`
  already rejects pairs where it is not. Guard removed; step counts now go
  through `_step_counts`, which treats a non-positive prediction as "one order"
  instead of letting it produce a negative count. A 20-mode gamma Dor series
  (dP 1858 -> 4190 s) went from `None` to 20/20 modes; over 200 realisations
  with 20 s scatter, `None` returns went from 165 to 0.

spacing.py — the sub-multiple promotion could report a *multiple* of the truth.
  The promotion test asks whether widening the comb keeps its teeth, which is a
  counting argument, but it was judged on the amplitude-weighted response: with
  a 20:1 amplitude spread, dropping every other (weak) mode barely moved it and
  a 12-mode comb at dP=0.03 was promoted to 0.06 — a factor-two error in the
  headline Pi_0. It is now judged on the unweighted response of the winning
  peak's *own members* (`_comb_members`), which is blind to both the amplitude
  spread and to contaminating peaks that are on neither comb. The peak itself is
  still chosen on the weighted response. Over 300 combs with lognormal(0, 2)
  amplitudes, wrong spacings went from ~7% to 0.

gui/state.py — switching the Analysis picker mid-run wedged the app. Clearing
  the pending key means no completion handler will ever fire, so the
  `busy_changed(False)` that re-enables Compute never arrived and the button
  stayed disabled for the rest of the session (Ctrl+Enter too, since it gates on
  the same state). `set_analysis` now releases the busy state itself.

gui/widgets/controls_panel.py — pre-whitening a multiband curve could silently
  analyse a raw all-band stack. `set_bands` passed the *hidden* method combo's
  text instead of the pre-whitening sentinel, so a curve loaded after switching
  analysis offered and selected "combined (all bands)". On a two-band curve
  0.8 mag apart that returned 0 components (rms 0.40) where the single band
  recovers the planted signal cleanly. The two load orders now agree. The
  "stacked" option additionally centres each band first (`_stack_centred`) —
  pre-whitening fits one constant for the whole curve and cannot absorb per-band
  zero points.

prewhiten/batch.py — a directory sink could produce an unreadable dataset.
  Component cells were Python `None`, so a chunk in which no star had an
  identified combination typed that column `null` while a later chunk typed it
  `string`; a pyarrow dataset takes its schema from the first fragment and the
  cast is not implemented. Cells are now NaN/"" filled with stable types (`rank`
  included, as float), matching what `periodogram_to_row` already did.

Nine regression tests added, each failing before its fix. 414 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Improvements from a second hardening pass over the v1.2 subsystem:

- fix: bootstrap frequency errors were exactly zero for every component
  except the newest. The engine handed its per-iteration refinement policy
  (default last, which pins all established frequencies) to the replicate
  fits, so their scatter collapsed. Replicates now always sweep every
  frequency, boxed by the same per-frequency bounds as the fit they
  characterise.
- feat: the spectral window as a first-class diagnostic. SpectrumEngine
  .window(), cuperiod.spectral_window(), and PreWhitenResult.window expose
  |W(f)| at zero extra transforms (its sums are already part of the cached
  normal equations). The GUI spectrum view gains a window overlay toggle
  scaled to the tallest peak, and prewhiten --save-spectrum writes it into
  the .npz.
- perf: the Baluev (2008) false-alarm probability is evaluated natively
  (cuperiod.baluev_fap), matching astropy to 1e-12 on centred times and
  staying accurate on raw Julian dates where astropy's one-pass time
  variance loses ~11 digits. Pre-whitening no longer imports
  astropy.timeseries: ~1 s off the first solution per process.
- perf: the VarPro Jacobian is built lazily, so trust-region steps the
  optimiser rejects never pay for derivatives; batch_prewhiten forces
  store_spectra=False in workers.
- gui: reclaimed spectrum-toolbar space (tighter spacing, Reset, pinned
  x-combo width) so the new toggle never squeezes buttons into ellipses.

441 tests pass (+21 new), ruff/mypy clean, docs build under -W.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The default search band topped out at the median-gap pseudo-Nyquist with
nyquist_factor=1, which for nightly ground-based sampling is ~0.5-2.5
cycles/day. On the bundled ASAS-SN HADS demo (VSX P = 0.089757 d,
f = 11.14 c/d) the entire signal sat out of band, so the extraction fitted
its daily aliases instead: P = 0.123 d, residual rms 0.245, chi2_r 581 --
a plausible-looking, completely wrong frequency solution.

The automatic band is now max(pseudo-Nyquist x nyquist_factor, 50 c/d),
with nyquist_factor defaulting to 5 like every periodogram method, exposed
as cuperiod.prewhiten.default_maximum_frequency. The GUI computes its auto
value (grid hint and greyed spin box) through the same helper, replacing
its hard-coded per-analysis factor and its own too-low 10 c/d floor for
the pre-whitening analysis.

The demo star now resolves to P = 0.089757 d -- the VSX period to the last
digit -- with the 2f/3f/4f harmonics extracted and combination-labelled,
residual rms 0.073, and a clean HADS sawtooth in the phased view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The frequency-domain periodograms pay one trig sum per trial frequency,
so the wide band that keeps delta Scuti / HADS stars in reach is free for
them. Their GUI auto maximum now shares the pre-whitening floor
(max(pseudo-Nyquist x factor, 50 c/d)) through one predicate,
gui.meta.reaches_short_periods(), used by both the controller tuning and
the grid-hint display. GLS now recovers the bundled HADS demo (best
P = 0.0897567 d, FAP 7e-203) where its old 10 c/d auto ceiling saw only
aliases.

Fold-based methods (PDM, CE, string-length) pay a full fold per trial
frequency and deliberately keep the 10 c/d floor; library-side defaults
for GLS/MHAOV are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The markers were placed at each component fitted amplitude while the curve
underneath is the single-frequency amplitude spectrum. The two agree for a
well-separated mode -- which is why synthetic tests never caught it -- but
diverge as soon as components are correlated.

On the bundled ASAS-SN HADS demo every harmonic carries yearly alias
sidelobes (df = 1/365.25 d = 0.00274 c/d, ~6.6 Rayleigh), so three or four
components sit around each harmonic and trade amplitude in the joint fit.
F3 (3f) is fitted at 0.0855 where the data holds 0.0466 -- confirmed by an
independent single-sinusoid fit, 0.0467 -- and F9 at 0.0335 where the
spectrum has 0.0073. The high ones hung in empty space above the curve,
claiming peaks that are not there; the low ones hid inside the forest and
merely looked plausible.

A marker on a spectrum plot asserts that the curve is that high there, so
it is now drawn at the plotted spectrum sampled at the component frequency
(vectorized: the pre-whitening grid runs past a million samples and this is
on the UI thread). The fitted amplitude is not lost -- it moves to the hover
readout with the component S/N, next to the Frequencies dock that already
lists both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A fitted amplitude comes from the joint solution of every component at
once; the amplitude spectrum reads each frequency as if it were alone.
They agree for a mode resolved from its neighbours, and diverge when a
component is correlated with another -- close pairs, and far more often in
ground-based data, a mode beside its own alias sidelobe.

Every Sinusoid now records spectrum_amplitude (the direct, single-frequency
measurement), the derived amplitude_ratio, and a blended flag governed by
PreWhitenSettings.blend_tolerance (default 2x -- loose enough to be a ~3
sigma statement even for a component that only just cleared S/N 4, since
the ratio carries the noise of both measurements). On the bundled HADS demo
4 of 17 components are flagged, from 0.24x to 4.62x.

It is deliberately not a significance test: an alias sidelobe is really in
the data, and nothing is dropped because of the flag. It says the amplitude
means something only alongside the components it is correlated with.

Surfaced everywhere: PreWhitenResult.n_blended, an A/Asp column and a
footnote in summary(), two float columns in the batch catalogue (no boolean
-- a NaN fill has no boolean equivalent for a star that yielded nothing),
and a marked column in the GUI Frequencies dock.

Also in the GUI, from inspecting the same star:
- the data curve gets its own toggle, so the residual and window traces it
  draws over can be read on their own;
- double-clicking the spectrum restores the default view;
- fix: the Frequencies table ignored its own per-column number formats. The
  sort key was written to EditRole, which QTableWidgetItem stores in the
  same slot as DisplayRole, so every numeric column rendered Qt six-digit
  default -- too few digits for a frequency, too many for an uncertainty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hover label is a TextItem anchored to a marker in plot coordinates,
and nothing ever hid it once that marker stopped existing. Unchecking
peaks right after hovering one to read its numbers -- the natural order,
since you hover to read and then hide the clutter -- left the label
stranded over an empty plot. The same stale label survived a new result
(showing the previous solution numbers over the new spectrum) and a
log/axis switch that moved its anchor to coordinates it no longer matched.

It is now dismissed whenever the markers are redrawn, which covers all
three, and it reappears on the next hover event -- those fire on every
mouse move over a marker, so the label is back within a pixel of movement.
Hovering a glow halo, which carries no peak index, also dismisses it
instead of leaving a different peak numbers up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…kers

The shaded band marks a selected peak, so it belongs to the peak layer.
Leaving it on screen when the markers were hidden kept the single most
prominent piece of clutter the toggle is usually turned off to be rid of.

Band visibility is now one rule -- shown iff something is selected and
peaks are being marked -- applied through a single helper rather than the
five scattered setVisible calls it replaces. The selection itself is
untouched while hidden, so the phased view keeps folding on the same
period and the band returns in place when peaks come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the astropy-delegated multi-band Lomb-Scargle with three native
models (VanderPlas & Ivezić 2015), all NUFFT/GPU-fast on finufft,
cufinufft, and torch:

- offsets (default): the shared-phase (1,0) model in closed form — the
  per-band constant offsets are profiled out analytically, leaving a
  band-projected Zechmeister-Kürster assembly fed by K+2 NUFFTs. ~400x
  faster than astropy's flexible solver on a 6-band, 200k-frequency
  search, and exactly the single-band GLS when K=1.
- perband: the multi-phase (0,1) model with the *published* chi2_0
  weighting of eq. 23 (astropy's 'fast' method mis-weights bands by the
  summed squared periodogram, which depends on the frequency grid).
- flex: astropy's flexible regularized model reproduced to ~2e-10 from
  per-band harmonic trig sums plus batched normal-equation solves,
  including the trace-scaled ridge and both center/ridge conventions.

Multi-band false-alarm probabilities (astropy has none): a within-band
bootstrap of the null peak power. Times never change under the null, so
on NUFFT backends all resamples batch through the same K+2 transforms
via n_trans stacking. Exposed as cup.multiband_fap() and, via
GLSSettings(mb_fap_bootstrap=...), as extras['fap'] at spectrum peaks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ength

Each band keeps its own mean light curve / histogram / normalized fold;
only the statistics are pooled — dof-weighted for PDM (Stellingwerf
fixed-dof convention), point-count-weighted for CE and string length.
Every periodogram method now accepts a MultiBandLightCurve, on every
backend the single-band kernels support.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MultiBandLightCurve.from_file reads a long-format CSV/ECSV/FITS/Parquet
table and splits it on a (auto-detected or named) band column without a
pandas dependency. The CLI's 'run --band' now actually produces a joint
multi-band fit (it silently dropped to single-band before), and
file-path batch inputs honor band_column, so a directory of survey
tables runs multi-band end to end.

Also adds benchmarks/multiband_recovery.py: single-band vs multi-band
recovery on simulated Rubin-like six-band cadence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cup.alias_diagnostics(pg, data) measures the sampling's spectral window,
predicts the alias family of the best peak (window-peak aliases f0 +/- m*f_w,
harmonics, subharmonics; classic sidereal-day/solar-day/synodic-month/year
suspects when no data is given), matches each prediction to the
periodogram's local optima within a Rayleigh-width tolerance, scores the
competitors, and issues an AMBIGUOUS verdict when a non-harmonic alias
rivals the chosen peak. Works with both objective senses (GLS/BLS/MHAOV
maxima and PDM/CE/string-length minima) and multi-band input.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two tiers over the same column-resolution core (survey presets for
ZTF DR22, ZTF alerts, and Rubin DP1 object/DIA forced sources, plus
ColumnMap auto-detection from the nest's schema without computing):

- nested_periodogram: row-wise map_rows adapter for a NestedFrame or an
  lsdb Catalog (meta auto-built), single- or multi-band, per-row
  failures yield NaN instead of killing a survey-scale run.
- partition_periodogram: partition-wise batched kernel that pulls the
  flat Arrow buffers + list offsets once per partition and reuses one
  GPU engine (cufinufft plan cache) across every object in it — the
  catalog-scale GPU path nothing in the LINCC stack provides today.

Targets the post-rename API (map_rows/join_nested) so one code path
covers nested-pandas 0.6.10 (the lsdb/pandas-2 world) and 0.7.x
(pandas 3). New optional extras [nested] and [lsdb]; a [tool.uv]
conflict forks the lock so the lsdb extra's nested-pandas<0.7 pin
cannot downgrade the dev environment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The (chunk, p, p) normal-equation stack now targets a fixed byte budget
instead of a fixed frequency count, so large-nterms/many-band models
cannot blow device memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…99.7% joint

300 simulated faint RRab (0.2 mag noise, six-band WFD epoch shares) at
30/60/120 total epochs over 3 years. The shared-phase offsets model
recovers 81.7%/99.7%/100% where the best single band recovers
0%/37.3%/97.3% — and it dominates the per-band-phase models (17.7%/20.0%
at 30 epochs), confirming shared phase is what buys sparse-cadence
recovery and motivating it as cuPeriod's multi-band default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewrites docs/guide/multiband.md around the native subsystem (three GLS
models with selection guidance and the Rubin-cadence recovery table,
pooled fold methods, bootstrap FAP, alias diagnostics), adds
docs/guide/interop.md for the nested-pandas/lsdb adapter (both tiers,
survey presets, dask-GPU deployment, the two dependency worlds, Rubin
flux-domain guidance), updates the API reference, quickstart, index,
installation, README positioning, and the benchmark suite README, and
merges the multi-band entries into the unreleased 1.2.0 changelog block.
Also fixes a stale example in the multiband_fap docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The batch runner and the interop partition kernel built a CufinufftGLS
engine per worker/partition but dropped it on the multi-band branch, so
every star paid full plan setup again. multiband_power now accepts the
same engine the single-band path uses: the offsets model's K+2
transforms, the flex harmonic sums, and the perband per-band powers all
route through the engine's bucketed plan cache (a plan is fixed by mode
count and n_trans alone, so one pair serves every band, harmonic, and
star). The bootstrap-FAP pass deliberately stays planless - its n_trans
varies with the grid, and caching a plan per value would balloon device
memory. Fold methods accept and ignore the parameter; their kernels are
already module-cached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k section

COLUMN_PRESETS was re-exported through a bare import, which autosummary
cannot attach a docstring to; an explicit assignment with a #: comment
puts it back in the API reference. The benchmarks page gains a section
mirroring the multi-band recovery table (0% -> 81.7% at 30 epochs) so
the headline result is visible next to the other benchmark evidence,
not only in the guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Friedman's variable-span smoother as a phase-folding period search - the
last method named in the v1.2 goal. One vectorized array-API kernel
(numpy CPU / cupy CUDA / torch anywhere) plus a numba-parallel CPU tier
that runs 20k trial frequencies on a 600-point curve in 0.05 s. Every
window sum comes from prefix sums over circularly padded folds, so the
periodic path is exact and the plain smoother's edge pathologies cannot
occur; the leave-one-out span selection subtracts each point's own
contribution, matching the reference algebra identically.

Scores follow gatspy (1 - mean|resid|/dy over the baseline deviation,
maximized); the multiband variant pools per-band scores with gatspy's
baseline-error weights. Parity vs the supersmoother package and gatspy
is asserted to ~1e-9 on point counts where the upstream odd-window fix
(adopted here) coincides with the released 0.4 truncation semantics.
Deliberate deviations, all documented: degenerate (duplicate-phase)
windows fall back to the weighted mean instead of raising, and the
bass-enhancement factor is clamped to close the reference's alpha-in-
(9,10) NaN bug. Both reference packages join the dev extra (BSD-2) as
test-only pins; the numba mypy override now tolerates releases without
py.typed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A deliberately synthetic sparse six-band RRab (the one simulated star in
the tour, and the notebook now says so): 61 epochs over three years at
WFD-like shares, where the deepest single band lands 21% from the truth
with the true period nowhere in its top five, and the joint offsets
search recovers it to 0.004%. The section walks the full loop - the
pseudo-Nyquist grid gotcha (both the single- and multi-band defaults
top out below the true frequency on this cadence), bootstrap FAP at the
1/(n+1) floor, an alias_diagnostics clean verdict over the +-1 c/d
daily aliases, and all six bands folded onto one shape. Executed
end-to-end; outputs embedded like the rest of the notebook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Method catalogue section (statistic, non-parametric strengths, the
integer-multiples caveat and how to read a high-scoring family), rows in
the decision/backends/performance/multiband tables, tuning highlights,
and changelog entries covering the method, its backends (0.05 s numba vs
6.6 s numpy at 20k frequencies), the gatspy-exact multiband pooling, and
the reference-parity pins with the four documented deviations. Package-
wide counts move to eight methods while every benchmark-suite claim
stays scoped to the seven methods the suite actually ran - SuperSmoother
is pinned against its reference packages in unit tests instead, and the
benchmarks page now says exactly that. Also records the multi-band GLS
plan-cache reuse under a new Performance heading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…RR Lyrae

Bundle 100 SDSS Stripe 82 RR Lyrae (Sesar et al. 2010) with literature
periods -- real ugriz cadence, 80 RRab + 20 RRc, first-100-by-ID with no
quality selection -- via a re-runnable download script pulling from the
astroML-data mirror (the original MPIA host is dead), with the parsed
column layout pinned against gatspy doctest values.

benchmarks/multiband_real.py runs one identical blind search per star
(0.15-1.2 d, 5 samples per Rayleigh, ~97k frequencies, default settings)
through all nine multi-band models plus per-band GLS baselines, scored
strict (1%, no harmonic credit) and harmonic-aware (2%), CPU scored and
GPU compared. Headlines: PDM and String-Length 93% strict (String-Length
97% harmonic-aware), SuperSmoother 96% harmonic-aware with the documented
2P-multiples gap concentrated on RRc, the three GLS models comparable at
76-78% on this dense data (the simulated benchmark covers the sparse
regime where offsets wins), 54% of non-harmonic misses on the +-1/+-2 c/d
window aliases, GPU picking the identical top period on 100% of runs.

make_report.py renders it as REPORT.md section 4 (table with Wilson CIs,
alias-loci figure, data-driven prose) and renumbers later sections,
fixing a pre-existing duplicate Table 3 label along the way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gelog

New section 7 on the docs benchmarks page and a matching section in
benchmarks/README (setup, nine-model table, single-band baseline, honest
reading incl. the dense-vs-sparse regime split and the SuperSmoother
2P-multiples gap); SuperSmoother suite-scoping paragraphs updated now that
it is exercised on real data; stale REPORT.md deep link fixed for the
section renumbering; one-sentence Stripe 82 headline in the README
validation bullet; changelog entries for the dataset and the validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
batch_periods now defaults to 0 = auto-sized from a transient-memory
budget (~512 MiB of workspaces on device backends, the previous chunk
sizes on host numpy, always adapted to the light-curve length), and
SuperSmoother accumulates scores on the device with one final host
copy instead of a stream-synchronizing copy per chunk. Chunking never
affects the result; explicit batch_periods values are honored as
before, and estimate_device_bytes now reflects the device budget.

Single-shot GPU multiband on the Stripe 82 validation stars: MHAOV
23.8 s -> 0.48 s/star (now at CPU-tier parity), SuperSmoother
23.7 s -> 0.87 s/star, identical spectra on every star.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fresh cpu+gpu passes over all 100 stars with the fixed chunking:
recovery is bit-identical (deterministic search), GPU top-period
agreement is again 100.0% of 900 runs, and the GPU timing columns now
show MHAOV 0.48 s/star and SuperSmoother 0.87 s/star instead of ~24 s.
The report generator's laggard sentence no longer attributes slowness
to the old fixed batch_periods chunking (and agrees in number), and
the single-curve performance section notes that its recorded MHAOV
GPU rows predate the fix. multiband_real.py gains --out so partial
verification runs cannot clobber the committed results parquet;
docs/benchmarks.md CPU medians synced to the fresh run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
benchmark.py gains a --sections CLI (default keeps the resumable
skip-if-parquet-exists behaviour; naming sections forces a re-measure)
and SuperSmoother joins FREQ_SETTINGS and both scaling sweeps, giving
it its first recorded single-curve numbers (cpu 0.099 s, gpu 0.284 s,
torch:cuda 0.222 s at the 30k grid). Reference-tool timings now fail
soft to a blank cell when the ad-hoc install was pruned.

Fresh sweep on a quiet machine with torch 2.11.0+cu128 restored:
untouched methods reproduce the July numbers to within noise, and the
v1.2 auto-batch fix shows up exactly where expected - single-band
MHAOV GPU 0.135 s -> 0.038 s (0.18x -> 0.68x vs the numba CPU tier,
and a near-wash across the whole grid-size sweep). make_report.py now
computes the win/wash/slower buckets, the torch and batch-throughput
claims, and the section-8 method lists from the parquets instead of
hard-coding them, and the predates-the-fix caveat is gone. Docs
tables and prose in docs/benchmarks.md and docs/guide/backends.md
synced to the new numbers, including retiring the SuperSmoother
not-yet-measured row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tjayasinghe and others added 2 commits August 14, 2026 23:27
- Pre-whitening reported a correlation factor the bootstrap never applied.
  The engine recomputed Schwarzenberg-Czerny D from the residuals and stored
  it whatever the estimator was, but `component_uncertainties` deliberately
  skips the sqrt(D) inflation for `uncertainty="bootstrap"`. So a bootstrap
  run printed `D=6.25` over error bars carrying no factor, and wrote the same
  wrong number to every `batch_prewhiten` catalogue row. The stored value is
  now the one the estimator actually applied.

- `MultiBandLightCurve.from_dataframe` ignored `band_column` when `columns=`
  was also given: the explicit ColumnMap replaced the map built from
  `band_column`, so the call raised ColumnResolutionError telling the caller
  to pass `band_column` — which they had. Now merged, matching `from_file`.

- The LINCC interop kernel called `multiband_power` without the
  `supports_multiband` check `api.py` performs, and `_Kernel.evaluate` turns
  per-object failures into NaN rows — so a multi-band run with a single-band
  method returned a silently all-NaN frame. Validation is hoisted into
  `_Kernel.validate()` and called up front by both entry points.

Each gets a regression test. Also corrects the docstrings that stated the
old behaviour (the "all three estimators are inflated" claim, the
"re-fit all components simultaneously" step, `max_gap`'s units,
`prune`'s coupling to `stop_criteria`, `SpacingSpectrum.best_spacing`'s
sub-multiple promotion, the 152-vs-60 coefficient-vector count, and
`SpectrumEngine`'s two- vs three-point minimum).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pre-push audit compared every user-facing claim against the code and the
benchmark parquets. Fixes, by theme:

Release metadata (following the v1.1.0 pattern in f1e83d0): version 1.2.0 in
pyproject, the __init__ fallback and uv.lock; CITATION.cff bumped to 1.2.0 /
2026-08-14 and its abstract corrected to eight methods (SuperSmoother was
missing); CHANGELOG [Unreleased] retitled to [1.2.0] with a compare link;
README BibTeX synced.

Stale benchmark numbers, re-synced to benchmarks/results/*.parquet after the
auto-batch re-runs: peak batch throughput 587 -> 574 lc/s (README,
docs/guide/batch.md), CPU-vs-reference 18x -> 20x and 2106x -> 2177x (README),
the multi-band median CPU s/star column (benchmarks/README.md), the sinusoid
CE/String-Length injection cell 42-98% -> 98-100% (docs/benchmarks.md), and
the GLS/MHAOV GPU parity bound ~1e-6/1e-7 -> ~1e-6 and ~1e-5.

Claims that contradicted the code: the [fast] extra covers every method but
GLS, not all eight; SuperSmoother's 2P/3P family is bounded by
minimum_frequency, not maximum_frequency; the BLS peak extra is `t0`, not
`transit_time`; `--band` is the multi-band switch, not a column-name
override; multiband_fap's second argument is a GLSSettings; nyquist_factor
means the average-sampling Nyquist for GLS and the median-sampling one
elsewhere; precision="auto" forces float32 on MPS only; `cuperiod methods`
lists every backend, not the installed ones; n_skipped counts light curves;
only PDM is benchmarked at batch parity; the GUI has no menu bar, its Band
selector is in Controls, and it opens empty rather than with a demo curve
(examples/data is not shipped in the wheel).

CHANGELOG: bug fixes moved out of ### Performance into a ### Fixed section,
the 16 MyST roles stripped so they render on GitHub and PyPI, and the HADS
"before" numbers corrected — they had been quoted from two different runs.

benchmarks/REPORT.md regenerated from the committed parquets;
examples/cuperiod_tour.ipynb re-executed (its stored output still advertised
version 1.0.0 without SuperSmoother, and printed the BLS depth as 0.0000
because it is an absolute flux, now shown as a fraction).

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.

1 participant