Rotation Periods and Measured Confidence

adam_core.photometry can recover an asteroid’s rotation period from sparse, multi-band photometry and, crucially, return a measured confidence verdict instead of a bare number. The estimator fits a truncated-harmonic Fourier model to the distance-reduced, light-time-corrected lightcurve, searches a frequency grid, clusters harmonic aliases, and classifies the outcome.

The headline is the verdict, not the period: the API is built so that a caller can tell, per object, whether to trust the number.

What You Get Back

Every solve returns a one-row RotationPeriodResult whose fields fall into three groups:

  • The answerperiod_hours / period_days, frequency_cycles_per_day, an uncertainty interval (period_lower_days / period_upper_days / relative_period_uncertainty), and alternate_period_days (the other candidates considered – this is where harmonic aliases live).

  • How sure it isperiod_verdict and reliability_code (read these first; see below).

  • Whyconfidence_flags and insufficiency_reasons (machine-readable rationale), plus diagnostics you can sanity-check yourself: amplitude_snr, phase_coverage_fraction, n_rotations_spanned, n_observations.

Confidence Model and Guarantees

period_verdict takes one of three values, and it is a decision guide:

  • single_period – one period is believed. In validation against LCDB/DAMIT standard-candle asteroids these calls are correct the large majority of the time. Use the value directly.

  • period_family – there is a real signal, but a harmonic ambiguity (typically 2x / 0.5x) cannot be ruled out. The reported period may be off by an integer factor; consult alternate_period_days for the candidate family.

  • insufficient_data – the data cannot responsibly support a period. The period fields are NaN; insufficiency_reasons gives a first reason as to why a reliable period could not be fit (too few observations, too little phase coverage, spans too few rotations, …).

reliability_code mirrors the LCDB “U” quality scale as a string"3" (secure), "2" (some ambiguity), "1" (weak). It is deterministic from the verdict (single_period -> "3", period_family -> "2", insufficient_data -> "1"). Do not sort or compare it numerically.

Note that single_period is high-confidence but not a zero-alias guarantee. A

confident call can, rarely, be a harmonic alias of the true period. If a period that is wrong by an integer factor would be costly, cross-check a single_period result against alternate_period_days and reliability_code rather than treating it as infallible.

One disclosed assumption: when the best fit is a single-peaked fold whose only surviving ambiguity is the half/full octave, the solver doubles the period on the two-peaks-per-rotation shape prior and, by default, claims single_period with the confidence flag doubled_fold_assumed. Measured on the LCDB/DAMIT standard-candle sets this raises confident claims (76 to 83 pooled) with strict precision improving and no new wrong-family claim; on the Rubin survey regression it raises claims from 6 to 11 of 76, all strictly correct. A genuinely single-peaked (spheroidal) body would make such a claim a 2x alias, so pass claim_doubled_fold=False to keep these cases as period_family (reason single_max_alias). Filter on the flag to treat them separately in bulk results.

Data Requirements

The solver works on RotationPeriodObservations: one row per photometric measurement, carrying the observing geometry so the intrinsic rotational variation can be isolated.

  • Required per row: time, mag, r_au (heliocentric distance), delta_au (observer distance), phase_angle_deg.

  • Optional: mag_sigma (per-point uncertainty), filter (multi-band is fit jointly with per-band offsets), session_id (per-night labels used for session-offset handling).

  • Reductions are applied internally: distances are removed via mag - 5 * log10(r_au * delta_au) and times are light-time corrected.

Estimating From Arrays

The most direct path: build the observations table and call the estimator.

import numpy as np
from adam_core.photometry import (
    RotationPeriodObservations,
    estimate_rotation_period,
)
from adam_core.time import Timestamp

observations = RotationPeriodObservations.from_kwargs(
    time=Timestamp.from_mjd(mjd, scale="tdb"),
    mag=mag,
    mag_sigma=mag_sigma,          # optional
    filter=filters,               # optional; e.g. ["g", "r", "g", ...]
    session_id=session_ids,       # optional; per-night labels
    r_au=r_au,
    delta_au=delta_au,
    phase_angle_deg=phase_angle_deg,
)

result = estimate_rotation_period(observations)

verdict = result.period_verdict[0].as_py()
if verdict == "single_period":
    print(f"P = {result.period_hours[0].as_py():.3f} h "
          f"(reliability {result.reliability_code[0].as_py()})")
elif verdict == "period_family":
    print("period family; candidates:",
          result.alternate_period_days[0].as_py())
else:
    print("insufficient:", result.insufficiency_reasons[0].as_py())

Estimating From Detections and Exposures

If you are already working with adam_core observation primitives, the geometry is derived for you from aligned detections, exposures, and heliocentric object coordinates.

from adam_core.photometry import estimate_rotation_period_from_detections

# object_coords must be heliocentric (origin=SUN), row-aligned with detections.
result = estimate_rotation_period_from_detections(
    detections=detections,
    exposures=exposures,
    object_coords=object_coords,
)

The same geometry pipeline is available directly as RotationPeriodObservations.from_point_source_observations(detections, exposures, object_coords) if you want the observations table rather than a solve.

Survey Scale: One Row Per Object

For many objects at once, the grouped wrapper solves per object id and returns a GroupedRotationPeriodResults table.

from adam_core.photometry import estimate_rotation_period_from_detections_grouped

grouped = estimate_rotation_period_from_detections_grouped(
    detections=detections,
    exposures=exposures,
    object_coords=object_coords,
    object_ids=object_ids,
)

# grouped.object_id  -> the object ids
# grouped.result     -> a RotationPeriodResult column, one row per id

The grouped API never silently drops an object: an object whose solve fails on expected bad/insufficient data comes back as an insufficient_data row carrying a solve_error flag, so the output always has exactly one row per distinct input id. Unexpected (programmer/contract) errors are re-raised with the offending object id attached rather than swallowed.

Multiple Apparitions: Solve Each, Keep the Most Confident

If your photometry spans more than one apparition (observing seasons separated by months), do not pool it into a single solve. Apparitions differ in viewing aspect, noise, and nightly cadence, so each has its own alias structure – and one of them often samples the rotation cleanly where the densest one locks onto a sampling alias or hedges. estimate_rotation_period_best_apparition partitions the observations at gaps longer than apparition_gap_days (default 120), solves each apparition independently, and returns the most confident result (verdict rank, then amplitude SNR; the rule never sees any reference answer).

from adam_core.photometry import estimate_rotation_period_best_apparition

result = estimate_rotation_period_best_apparition(observations)
# result.confidence_flags[0] includes "apparition_selected_<k>_of_<n>"

Measured on the 118-object LCDB standard-candle calibration set, this policy raised confident (single_period) results from 35 to 43 objects while the strict precision of those claims improved (0.80 to 0.84) and no new wrong-family claim appeared. The safety is measured on that set, not structural: a clean apparition can still be an alias, so the verdict semantics are unchanged. Requiring a second apparition to corroborate before claiming was also tested and is deliberately not applied: on the calibration set it only removed correct claims, because an object’s systematic harmonic aliases repeat across apparitions and corroboration therefore does not catch them.

Performance

The defaults are validated and correct out of the box. The solver has an optional JAX backend that is numerically identical to the NumPy path but faster on large frequency grids:

result = estimate_rotation_period(
    observations,
    search_fidelity="validated_staged",   # default; coarse-then-refine on big grids
    exact_evaluation_backend="jax",        # ~2-5x faster than "numpy", same numbers
)

session_mode (default "auto") controls how multi-night magnitude offsets are handled; the default decides per object whether to fit per-session offsets.

Entry Points

  • estimate_rotation_period: you already have a per-object RotationPeriodObservations table.

  • estimate_rotation_period_from_detections: single object, from detections / exposures / heliocentric coordinates.

  • estimate_rotation_period_from_detections_grouped: many objects; guarantees one result row per id.

  • estimate_rotation_period_best_apparition: multi-apparition photometry of one object; solves each apparition and keeps the most confident result.

  • RotationPeriodObservations.from_point_source_observations: build the observations table (e.g. to inspect or reuse) without solving.