Measuring asteroid rotation periods in adam-core
What the new solver does, how we validated it, and what the confidence verdict means
We’ve added a rotation-period subsystem to adam-core: give
it asteroid photometry and it estimates the spin period. Most of the
work went into confidence and minimizing false verdicts: alongside the
period the solver returns a verdict (single_period, period_family,
or insufficient_data), calibrated against reference datasets.
What you get back
estimate_rotation_period returns one row per object: the period in
hours, an uncertainty interval, alternate candidates, and diagnostics.
The contract says to read the verdict first:
single_period. One period is believed; use the value directly.period_family. The signal is real, but a harmonic ambiguity (typically 2× or ½×) can’t be ruled out; rival candidates are reported rather than suppressed.insufficient_data. The data can’t responsibly support a period, andinsufficiency_reasonssays why.
A reliability_code rides along (“3”, “2”, “1”, mirroring the LCDB U
quality scale). It’s a label, not a score; don’t average it.
One panel per verdict. Left: 1011 Laodamia, a
clean single_period fold. Middle: 520 Franziska, a period_family -
3,267 points whose primary period matches the reference, still hedged
because a rival harmonic survives. Right: one night of 702 Alauda - 13%
of a rotation, amplitude below the noise, refused.
How it works
The model is deliberately classical. Observations are reduced to intrinsic brightness (light-time correction, distances removed via mag − 5 log₁₀(r·Δ), the solar phase-angle trend fit inside the model), and at each trial frequency the solver fits a truncated Fourier series by weighted least squares. Multi-band photometry is fit jointly - one shared shape, one offset per band - and per-night session offsets are adopted only when a Bayesian information criterion (BIC) test says the data demand them; without that gate, calibration steps between nights masquerade as slow rotation.
The search runs a dense frequency grid, coarse-then-fine, at Fourier orders 2 through 6. A periodogram would stop here and hand you the peak. Instead the solver re-evaluates the grid at the chosen order, keeps every frequency statistically indistinguishable from the best fit at 95%, and clusters the survivors into period families; the cluster count is the measured alias ambiguity. Three gates and two final guardrails then decide the verdict - the diagram has the details.
The verdict ladder, annotated with a fresh run over the LCDB
calibration set (115 objects, best apparition each). single_period
requires surviving the signal checks, the alias inspection, and a
precision cut; a competitive harmonic demotes to period_family; weak
data is refused with the reason attached.
Fold it yourself
Here is real photometry of the asteroid 1323 Tugela - 508 points over four January nights - folded at whichever period you pick. The presets are the half-period best fit, the reference period, its double, and an off-period control.
At 9.72 hours the fitted fold is the best on the grid; at 19.44 hours
it is 1.3% behind, showing the two slightly unequal peaks an elongated
body should produce. Fit quality does not settle which is the spin
period - both survive the competitiveness cut - but shape does: the
9.72-hour fold has one brightness peak per rotation, the mark of a
nearly round body, while the 19.44-hour fold has the two peaks that
elongation produces, and most asteroids are elongated enough to show
them. When that half/full doubling is the only ambiguity left, the
solver commits to the two-peaked reading: it reports 19.44 hours, marks
the verdict single_period, and attaches a doubled_fold_assumed flag
so the one assumption it made is on the record. LCDB’s answer, from
campaigns with more leverage, is 19.5 hours.
The flag is doing real work, because the doubling is a genuine
assumption. These are differential magnitudes with free per-night zero
points, and a single night covers the second peak, so the two-peaked
asymmetry is suggestive, not proof; a truly round body would make the
doubled claim a 2x alias. Disclosing the assumption rather than hiding
it is the compromise, and claim_doubled_fold=False keeps the pure
hedge for callers who want it. Across the three validation sets,
confident calls rise from 82 to 97 under this rule with strict
precision unchanged, especially in the sparse survey data.
What the solver actually minimizes: post-fit residual scatter at each
trial spin rate for Tugela. Two dips survive the 95% competitiveness
cut - 9.72 h (one peak per rotation) and 19.44 h (two peaks), the same
lightcurve twice-folded - so the solver doubles onto the two-peaked
shape, claims 19.44 h, and flags the assumption. The nearest rivals
miss the cut by 6% and 12%: the cut does real work in both directions.
How we know it works
On the 118 LCDB calibration asteroids - securely known periods, mostly derived by humans in the loop from longer campaigns with alias-killing follow-up - the pipeline gets the period strictly right for 63 (53%) and within 3% of a harmonic of the reference for 83 (70%; chance alone would pass that harmonic test about 15% of the time), fully automated. We bias hard against false confidence, and recall is the price: some of the amber and gray below is data a patient human could have fit. The verdict layer is what makes that trade usable.
The verdicts are calibrated against LCDB standard candles and DAMIT shape models, and regression-tested against the published periods of Greenstreet et al. 2026 - the first asteroid discoveries from the Vera C. Rubin Observatory.
The scatter shows the two calibration sets. single_period points hug
the 1:1 line. The off-diagonal population is almost entirely
period_family amber and insufficient_data gray: some of it on the
harmonic diagonals, some on diurnal sampling aliases that lie off them,
some simply wrong, none of it claimed. The survey sample is tabulated
below.
| Reference set | Solved | single_period calls | Strictly within tolerance | Within 3% of a harmonic | Wrong family |
|---|---|---|---|---|---|
| LCDB standard candles | 115/118 | 51 | 43/51 | 48/51 | 1 - 3295 Murakami |
| DAMIT shape models | 60/61 | 35 | 34/35 | 34/35 | 1 - 478 Tergeste |
| Greenstreet et al. 2026 | 76/76 | 11 | 11/11 | 11/11 | 0 |
Across the two calibration sets that is 86 confident calls: 77 strictly within each object’s tolerance, 82 of 86 within 3% of a harmonic of it, and two wrong outright - 3295 Murakami, 5.610 h against a 3.080 h reference, and 478 Tergeste, 40.24 h against 16.10 h. Three of the eight LCDB misses sit against reference periods LCDB itself grades uncertain. And when the solver is strictly wrong it has usually said so first: 84% of wrong periods on LCDB were hedged or refused rather than claimed, 95% on DAMIT.
The Greenstreet row deserves a closer look, because the hedging there
is not the solver missing. Of the 59 objects that came back
period_family, 43 have the strictly correct period as their primary
answer (within 2%, no harmonic forgiveness), and 46 sit within 1% of
the published period or a clean harmonic of it. The sparse survey
lightcurves are the culprit: a typical object here has about as many
points as an LCDB campaign (median 291 vs 297) but split across three
bands at roughly one visit per night, and that sampling comb leaves
rival periods standing - for the hedged-but-right objects, a median of
four rival periods survive the 95% competitiveness cut. So the solver
reports the family. What sparse cadence costs is confidence, not
correctness: all eleven single_period calls matched the published
period to 1.19% at worst, and the route to more confident claims on
survey data is longer arcs, not a looser solver.
Verdicts across the three validation sets, single_period split into
within-tolerance (blue) and outside it (red). Six of the eight red
LCDB claims sit within 3% of a harmonic of the reference; the two
wrong-family calls are named in the text. On the survey sample: zero.
Using it
The subsystem is on main today and ships with the next adam-core
release on PyPI:
pip install git+https://github.com/B612-Asteroid-Institute/adam_core.git
# real ALCDEF photometry of (261) Prymno, committed as a validation fixture:
curl -LO https://raw.githubusercontent.com/B612-Asteroid-Institute/adam_core/main/src/adam_core/photometry/tests/data/rotation_period_validation_fixture_261_Prymno.npz
import numpy as np
from adam_core.photometry import RotationPeriodObservations, estimate_rotation_period
from adam_core.time import Timestamp
# 472 measurements over five nights in March 2009, two bands (R + clear).
data = np.load("rotation_period_validation_fixture_261_Prymno.npz", allow_pickle=True)
observations = RotationPeriodObservations.from_kwargs(
time=Timestamp.from_iso8601(data["time_iso"].tolist(), scale="utc"),
mag=data["mag_obs"],
mag_sigma=data["mag_sigma"],
filter=data["filter"].tolist(), # bands are fit jointly
session_id=data["session_id"].tolist(), # per-night labels
r_au=data["r_au"], # heliocentric distance
delta_au=data["delta_au"], # distance to observer
phase_angle_deg=data["phase_angle_deg"],
)
result = estimate_rotation_period(
observations,
max_frequency_cycles_per_day=24.0, # don't hunt below 1 h
)
verdict = result.period_verdict[0].as_py() # read this before the period
if verdict == "single_period":
print(f"Rotation period: {result.period_hours[0].as_py():.4f} h "
f"(reliability {result.reliability_code[0].as_py()})")
print(f" amplitude SNR: {result.amplitude_snr[0].as_py():.1f}")
print(f" phase coverage: {result.phase_coverage_fraction[0].as_py():.2f}")
print(f" rotations spanned: {result.n_rotations_spanned[0].as_py():.1f}")
# period_family -> result.alternate_period_days
# insufficient_data -> result.insufficiency_reasons
Rotation period: 8.0015 h (reliability 3)
amplitude SNR: 14.8
phase coverage: 1.00
rotations spanned: 21.8
The LCDB reference for Prymno is 8.002 h. The one non-default argument,
max_frequency_cycles_per_day=24.0, is the cap our validation suite
uses - a survey-style choice, not the default.
For survey work there’s a grouped entry point that takes adam-core detections, exposures, and heliocentric coordinates directly:
results = estimate_rotation_period_from_detections_grouped(
detections, exposures, object_coords, object_ids
) # exactly one result row per distinct object id
It never silently drops an object: expected data failures come back as
insufficient_data rows flagged solve_error; anything unexpected is
re-raised with the offending object id attached. And when one object’s
photometry spans several apparitions, don’t pool them:
estimate_rotation_period_best_apparition solves each separately and
keeps the most confident result, which is how the calibration numbers
above were produced.
The defaults are tuned to hold the false-confidence line, and several
strictness choices are exposed if your tolerance differs:
min_rotations_in_span relaxes the span gate, session_mode="use"
forces per-night offsets where the automatic BIC gate declines (worth
trying on suspected slow rotators), auto_session_bic_improvement
lowers that gate’s bar, fourier_orders and clip_sigma widen the fit
itself, and claim_doubled_fold=False keeps the doubled-fold cases from
the fold-explorer section hedged as period_family instead of claimed.
The two bluntest gates - 30 points in the best-sampled band, amplitude
at 3× the noise - are deliberately not knobs; they do the most work
keeping false confidence out.
Performance
The solver’s default backend is NumPy; passing
exact_evaluation_backend="jax" runs the same staged search roughly
2–5× faster on large grids, with bit-identical results. On an M-series
MacBook the Prymno solve takes ~15 s on NumPy and ~7.5 s on warm JAX,
so the JIT compile roughly cancels the gain for a one-shot script;
batching is where it pays (the 118-object calibration set solves in
about 33 minutes across three workers).
What it doesn’t do
- Guarantee zero aliases.
single_periodis high-confidence, not alias-proof; Murakami and Tergeste are that risk realized, and a known alias case is pinned in CI as a strict expected-failure so it can’t silently regress. - Promise the family contains the truth.
period_familymeans a real signal with unresolved harmonics, no more. - Model everything that spins. One shape repeating at one frequency; tumblers and binaries are outside the model.
- Chase coverage.
insufficient_datasometimes withholds a period that would have been right. We think that’s the correct trade for a tool whose claims are meant to be actionable.
Data
This work sits on community datasets. Period ground truth comes from the Asteroid Lightcurve Database (LCDB; Warner, Harris & Pravec 2009). Photometry fixtures are small, attributed excerpts from ALCDEF (NASA grant 80NSSC18K0851); the Prymno and Tugela observations were submitted by Brian D. Warner, and the fixture directory carries a NOTICE file crediting each observer. Sidereal cross-check periods come from DAMIT (Durech, Sidorin & Kaasalainen 2010, CC BY 4.0); we ship no DAMIT-derived data. The survey comparison uses the published periods of Greenstreet et al. 2026 (ApJ Letters).
Source and issues: github.com/B612-Asteroid-Institute/adam_core. The rotation-periods cookbook page in the docs walks the full result schema and the verdict semantics. Try it on the fixtures above, or on your own photometry - and read the verdict before the period.