Measuring asteroid rotation periods in adam-core

What the new solver does, how we validated it, and what the confidence verdict means

Nate Nate · · 07 Mins read
Real photometry of asteroid 261 Prymno phase-folded at its 8.0015-hour rotation period, with the fitted Fourier model overlaid

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, and insufficiency_reasons says 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.

Three phase-folded lightcurves illustrating the three verdicts 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.

Decision-ladder diagram from lightcurve to the three verdicts 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.

Residual scatter versus trial frequency for 1323 Tugela, with the two surviving dips annotated 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 setSolvedsingle_period callsStrictly within toleranceWithin 3% of a harmonicWrong family
LCDB standard candles115/1185143/5148/511 - 3295 Murakami
DAMIT shape models60/613534/3534/351 - 478 Tergeste
Greenstreet et al. 202676/761111/1111/110

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.

Stacked verdict scoreboard across LCDB, DAMIT, and the Greenstreet survey sample 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_period is 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_family means 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_data sometimes 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.