Skip to content

Mm rework lingfeng into Dev - #26

Open
wei-lingfeng wants to merge 355 commits into
devfrom
mm_rework_lingfeng
Open

Mm rework lingfeng into Dev#26
wei-lingfeng wants to merge 355 commits into
devfrom
mm_rework_lingfeng

Conversation

@wei-lingfeng

@wei-lingfeng wei-lingfeng commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

This merges mm_rework_lingfeng into dev. It supersedes #25 (which compared this branch against mm_rework) — everything from that comparison is folded into the sections below, organized by what actually changed and why, alongside a substantial follow-on pass of correctness, performance, memory, and documentation work done since.

API changes from mm_rework

mm_rework already has a motion_model.py and a Fixed/Linear/Acceleration/Parallax system of its own, so unlike the rest of this description, the API differences below are specifically against that branch's design (not dev, which predates motion models entirely and has neither side of any of this).

  • default_motion_model (a single model name) → motion_models (a list): mm_rework's MosaicSelfRef.__init__ takes default_motion_model='Fixed', and fit_velocities separately takes its own default_motion_model='Linear' — one model name, used as a single global fallback. Both are replaced by one motion_models parameter (a list, e.g. motion_models=['Linear']): each star is now fit with the most complex model its own number of valid epochs actually supports, rather than every star sharing one default.
  • motion_model_dictfixed_params_dict, and t0 stops being a special case: mm_rework's run_fit(self, t, x, y, xe, ye, t0, ...) takes t0 as its own required positional argument everywhere, with a separate, loosely-typed motion_model_dict={} alongside it for anything else. Both are unified into one fixed_params_dict (holding t0 for Linear/Acceleration; t0, ra, dec, pa, obsLocation for Parallax), so every model's non-fit parameters flow through the same mechanism instead of t0 being threaded separately from everything else.
  • New Empty motion model: mm_rework only has Fixed/Linear/Acceleration/Parallax. Empty (a trivial placeholder for a star with no usable data — always fill_value/inf) is new.
  • Method rename: StarTable.fit_velocitiesStarTable.fit_motion_models, reflecting the move from "always fits a velocity" to "fits whichever model applies." fit_velocities_all_detected, a separate helper for the "every star has every epoch" case, has no equivalent — the general per-star model selection already covers it.
  • fit_velocities parameters removed or replaced: mask_valmask_value; fixed_t0=False (a special-cased boolean/array just for t0) is gone now that t0 is an ordinary fixed_params_dict entry; show_progress/reassign_motion_model are gone (reassignment now happens automatically every call from each star's current epoch count — see keep_existing, new, to control whether stars not being refit keep their existing values instead of being overwritten with nan, described further down). New, with no mm_rework equivalent at all: keep_existing, seed, method (scipy method name), fill_value, art_star, and the multiprocessing controls processes/chunksize/mp_star_threshold.
  • MotionModel subclass internals renamed (relevant only if calling code subclasses or touches MotionModel directly, rather than going through StarTable/align): n_pts_req (hardcoded per class, e.g. Parallax.n_pts_req = 4) → n_params (derived as ceil(n_fit_params / 2), giving Parallax.n_params = 3 — the same fix as the n_pts_req point already covered above); fitter_param_namesfit_param_names; optional_param_names (a bare list of names) → optional_fixed_params (a dict of name → actual default value); get_pos_at_time/get_batch_pos_at_time (separate per-star and per-batch methods) unified into one model() method that vectorizes automatically based on input shape; motion_model.get_weightscalc_sigma (see "Uncertainty model" above); new run_fit_batch on Empty/Fixed/Linear, with no mm_rework equivalent (see "Vectorized, multiprocessing-aware motion-model fitting" below).
  • Also new since mm_rework, covered in their own sections below: inherit_n_detect, match_workers, mp_star_threshold.

Uncertainty model: error propagation, not weighted variance

The codebase's biggest conceptual change: uncertainty on an averaged/fit quantity is now computed by analytic error propagation (the classic "error on the mean," sigma = sqrt(1 / sum(1/sigma_i^2))) everywhere, instead of the sqrt of a weighted sample variance.

  • Why: sample variance is ill-defined at 1 point (forcing a special case) and deceptively well-behaved at 2–3 points — a line fits 2 points exactly, a plane-ish model fits 3, so the "spread of residuals" collapses toward zero regardless of how uncertain the underlying measurements actually were. That's not a real uncertainty estimate, and patching it back up would mean flystar imposing its own peculiar rescaling assumptions; that's a decision for calling code, not this package. Error propagation has neither problem, and is what scipy.curve_fit's covariance matrix already computes internally for models that use it — so this also makes every motion model's reported uncertainty consistent with every other one, instead of Fixed computing something structurally different from Linear/Acceleration/Parallax.
  • combine_lists switched to error propagation (also removing its now-unnecessary >1-epoch requirement for weighted averaging), and Fixed.run_fit's uncertainty was brought in line with the scipy-covariance convention every other model already used.
  • motion_model.get_weights's x_wt = 1/xe**2sigma = 1/x_wt**0.5 round-trip accumulated floating-point error across repeated align() iterations; replaced with calc_sigma, which returns xe or sqrt(xe) directly usable by scipy, computing an inverse-variance weight only where one's actually still needed.
  • Parallax.run_fit had two real bugs from not using that same path: sigma was never square-rooted before being passed to curve_fit, and absolute_sigma was silently ignored. Both fixed by routing it through calc_sigma + absolute_sigma like everything else. (Worked example proving curve_fit(..., absolute_sigma=True) and analytic error propagation agree bit-for-bit is preserved from Update mm_rework to match mm_rework_lingfeng for comparison #25, at the bottom of this description.)
  • Related default flip: MosaicSelfRef/MosaicToRef's absolute_sigma default changed from False to True — required for scipy's covariance matrix to actually mean "error propagation" rather than a reduced-chi2-rescaled quantity.

Uncertainty columns: inf, never nan, for "no information"

A second, related convention, enforced consistently for the first time: a star's uncertainty is inf when there's no real information to compute one from — never nan. 1/inf**2 == 0, so an inf uncertainty correctly contributes zero weight anywhere it's combined with other stars' measurements; nan instead poisons every downstream computation it touches (x + nan == nan) and vanishes silently from filters, since nan == nan is False and nan < anything is False. Several real bugs were all instances of this convention being violated in different places:

  • xe/ye/me defaulted to nan instead of inf for invalid entries in the newly-vectorized Fixed/Empty batch paths — corrected to inf.
  • combine_lists' weighted "no usable uncertainty anywhere" fallback used to patch a fake weight into the raw error array, fit through that, and then had to remember to force the reported error back to inf afterward — fragile, and exactly the kind of place a fabricated finite value could silently leak through instead. Rewritten so inf falls out naturally: the weight sum is built only from real, known uncertainties, so std = sqrt(1/wgt_sum) is inf via ordinary 1/0 whenever none exist, with no override to remember.
  • absolute_sigma=False's chi2 rescaling could turn a singular fit's correct inf error into naninf * sqrt(nan) == nan. Fixed by reapplying the singular/insufficient-data override unconditionally as the fit's final step, so nothing downstream can rescale it away.
  • Two related, genuine NaN-propagation bugs (not the inf convention itself, but bugs the convention's violation exposed on real, imperfect data): a non-finite m0 could never be excluded by a magnitude-range filter (NaN comparisons are always False), letting bad reference stars corrupt the magnitude-zeropoint transform; and fit_motion_models' default t0 could silently come out NaN for some multi-epoch stars from mixing a masked weights array with plain (not masked) averaging. Both fixed.

Vectorized, multiprocessing-aware motion-model fitting

  • fit_motion_models now fits a whole group of stars sharing a motion model in one vectorized pass instead of one star at a time, for every model that supports it: Fixed and Empty (trivial/closed-form) and Linear (closed-form 2×2 solve). On a real dataset, processes=3 dropped from 7.07s to 2.68s (matching serial's 2.66s) once all three were batched — a multiprocessing pool no longer needs to spin up at all just to run an O(1) fill for Empty stars.
  • A numpy.ma indexing bottleneck (tens of millions of slow per-element numpy.ma.core.__getitem__ calls) made fit_motion_models ~38% faster to fix; one multiprocessing pool is now reused across the whole call instead of respawned per motion-model group.
  • match() gained a workers parameter (default 1) threading scipy.spatial.KDTree.query_ball_point; align.py exposes it as match_workers (default 1 — production runs typically share a machine, so the speedup is opt-in). Verified this doesn't affect the delicate dm_min == dr_min tie-break, which depends on within-list neighbor order: workers=1 vs workers=-1 give identical, order-preserved neighbor lists, checked against dense/duplicate-point edge cases and a full end-to-end run with all 37 ref_table output columns byte-identical.
  • Added a configurable mp_star_threshold (default 100,000): a multiprocessing pool for motion-model fitting (Acceleration/Parallax, or any model with bootstrap > 0 — the models with no vectorized path) is only spun up when the number of stars actually needing it meets this threshold, even if processes > 1 was requested. Measured break-even for that fixed pool-spawn/IPC overhead was between 20,000 and 100,000 stars on a 10-core machine.

Memory footprint at mosaic scale

  • StarTable construction gained an opt-in copy=False and now builds all columns in one constructor call instead of many add_column() calls (~1.2s / +4.6GB → ~0.001s / ~0GB for ~29 columns at 1.4M rows).
  • Adding new-star rows built a whole parallel table and vstack-ed it onto the growing ref_table, transiently holding old + new + concatenated data for every column at once — roughly doubling peak memory on every "add new stars" step. Now concatenates columns directly and drops old references immediately.
  • fit_motion_models did an O(N_stars) data-prep step regardless of how few stars actually needed refitting; now sliced down to just the selected rows first. A redundant double-copy in combine_lists/fit_motion_models's array prep (an arange-based fancy-index copy stacked on top of an already-copying masked_invalid/deepcopy call) was removed. A per-star fixed-params dict is now built lazily, only for stars whose motion model actually needs it — skipped for ~84% of stars (those already handled by the vectorized batch path, above) in one benchmark.

Other correctness & behavior changes

  • fit_star_idxs no longer wipes unrelated stars: refitting only a subset of stars used to overwrite every other star's existing result with nan; now keeps existing values by default and only touches the requested subset.
  • Parallax's minimum-epoch requirement was n_pts_req=4; 3 (x, y) pairs are enough to solve its 5-parameter model, so lowered to 3 — a caller wanting to require 4+ can still do so via the motion_model_input column.
  • inherit_n_detect: new MosaicSelfRef/MosaicToRef parameter (default True) so a star's n_detect reflects the total number of raw detections it represents across nested alignment layers, not just 1 per input starlist.
  • False-positive StarList warning: every pickle round-trip (astropy passes columns positionally to __init__) incorrectly warned about missing required arguments.
  • Stale motion-model params: a star that dropped to a simpler motion model (e.g. Linear → Fixed, since epoch counts aren't monotonic across align() iterations) kept stale params (vx/vy) from its old model indefinitely; now reset to fill_value/inf right after reclassification.
  • Minor: parallax.parallax_in_direction's Time(mjd + 2400000.5, format='jd', ...) simplified to Time(mjd, format='mjd', ...); a misleading tqdm progress bar over a handful-of-motion-model-types loop (never per-star) removed.

Documentation

  • Added .readthedocs.yaml. Getting a working build also required fixing three real, pre-existing bugs unrelated to Read the Docs specifically — they'd have broken a fresh pip install . for anyone on a current Python: pyproject.toml's [build-system] pinned cython==0.29.14 (leftover astropy-template boilerplate for a package with zero .pyx/C extensions), which imports the stdlib cgi module removed in Python 3.13, breaking install outright; the declared dependencies were missing scipy, matplotlib, tqdm, joblib, and pandas, all imported unconditionally at module level; and setup.cfg's github_project was still the template default (astropy/astropy), which conf.py uses unconditionally to build doc issue-links. Verified via two independent fresh-venv builds (editable and the exact non-editable pip install .[docs] Read the Docs runs) — sphinx-build succeeds, 92 warnings, all pre-existing docstring formatting nits unrelated to this change.
  • Docs are currently configured to build from wei-lingfeng/flystar rather than this repo directly: importing MovingUniverseLab/flystar into Read the Docs needs repo-admin access (to add its webhook) not currently available on this account, and the flystar project name is already taken there by an existing (currently stale) project for this same package — re-pointing that one at this branch, rather than creating a competing project, needs maintainer access to it instead. The fork tracks this branch 1:1 in the meantime, so content is identical either way.

Validation

Each change was validated individually — synthetic fuzz tests against reference implementations (thousands to tens of thousands of cases per change), byte-for-bit output comparison on real multi-thousand and multi-million-star datasets, and dedicated new unit tests where behavior changed. Full test suite passes except two pre-existing, environment/data-dependent failures unrelated to any change here: test_masked_cols (missing test fixture file) and test_generic_match (test data contains no finite values). (The test_MosaicSelfRef_vel failure present at one point in mm_rework — see the magnitude-weighting fix above — is resolved on this branch.)

Worked example: scipy.curve_fit vs. analytic error propagation (referenced above)
import numpy as np
from scipy.optimize import curve_fit

# 1. Define dummy data and explicit measurement errors
y_data = np.array([10.2, 9.8, 10.5])
sigma_y = np.array([0.1, 0.4, 0.2])   # Different errors for each point
x_dummy = np.zeros_like(y_data)       # x is required but ignored by a constant function

# --- Method A: Analytical Error Propagation ---
weights = 1.0 / (sigma_y ** 2)
c_prop = np.sum(y_data * weights) / np.sum(weights)
sigma_c_prop = np.sqrt(1.0 / np.sum(weights))

# --- Method B: SciPy Curve Fit ---
def constant_model(x, c):
    return c

# absolute_sigma=True is MANDATORY to match pure error propagation
popt, pcov = curve_fit(constant_model, x_dummy, y_data, sigma=sigma_y, absolute_sigma=True)

c_fit = popt[0]
sigma_c_fit = np.sqrt(pcov[0, 0]) # Square root of the variance element

# --- Display Results ---
print(f"Propagation:  Value = {c_prop:.5f}, Error = {sigma_c_prop:.5f}")
print(f"Curve Fit:    Value = {c_fit:.5f}, Error = {sigma_c_fit:.5f}")

Output:

Propagation:  Value = 10.23810, Error = 0.08729
Curve Fit:    Value = 10.23810, Error = 0.08729

wei-lingfeng and others added 30 commits August 28, 2025 11:42
wei-lingfeng and others added 30 commits August 18, 2026 01:52
…rans_list.pkl

Bundling trans_list_inverse into trans_list.pkl as a dict (when
calc_trans_inverse=True) meant that file's shape was inconsistent -- a
plain list normally, a dict with an extra key sometimes, depending on a
constructor flag the person reading the saved file later has no way to
see. Split into its own file (PREFIX_trans_list_inverse.pkl, matching
the self.trans_list_inverse attribute name it holds) that either exists
or doesn't, so trans_list.pkl's content is always the same shape, and
the presence of the inverse file itself signals whether it was computed.

Verified: trans_list.pkl is always a plain list in both cases;
trans_list_inverse.pkl is written only when calc_trans_inverse=True and
absent otherwise; both load back as real, usable PolyTransform objects.
Test suite passes (26/26, one pre-existing unrelated failure deselected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d a dtype-fragile fallback

1. transforms.py: PolyTransform.evaluate_error/evaluate_vel_err computed
   error propagation as sqrt(sum((deriv * err)**2)) directly, which hits
   IEEE-754's 0*inf=nan whenever a derivative term is exactly zero and the
   corresponding uncertainty is inf (this repo's convention for "unknown").
   This isn't a rare edge case: the trans_input = PolyTransform(order=0,
   px=[0], py=[0]) idiom used throughout this session's testing is actually
   an identity transform (poly_order=1, coefficients [0,1,0]), which has
   df'/dy=0 exactly, so any star with ye=inf triggered it. Mathematically,
   a transform with zero sensitivity to an input genuinely propagates zero
   uncertainty from it -- the correct limit is 0, not the general 0*inf
   indeterminate form. Added _deriv_times_error(), which computes this
   explicitly (np.where(deriv==0, 0.0, deriv*err) under errstate) instead
   of relying on IEEE arithmetic to land on the right answer by accident.
   Verified: reproduced the exact warning and nan output pre-fix, confirmed
   both are gone post-fix, with the still-legitimate inf correctly
   propagating through nonzero-derivative terms.

2. transforms.py: four_paramNW.evaluate_error, found while checking sibling
   methods for the same pattern -- had a typo (np.hpyot, would crash if
   ever called) and used self.px instead of self.py for the y-error term
   (copy-paste bug, unrelated to #1). Fixed both.

3. align.py: add_rows_for_new_stars picked a fill value for new rows using
   exact dtype equality (dtype == np.dtype('int'), etc.), which silently
   falls through to a nan fallback for any non-default-width numeric
   column (e.g. int32), casting nan into an integer array and raising
   numpy's "invalid value encountered in cast". Switched to .kind-based
   checks (dtype.kind in 'iu', etc.), matching the more robust pattern
   already used elsewhere in this codebase (StarTable's
   _invalid_float_value), and added an explicit string-column case.

Verified: full test suite unaffected (44 passed, 1 pre-existing unrelated
failure, 1 deselected); a real end-to-end run confirms n_detect/
n_detect_list keep their expected int64 dtype and the fit completes
normally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Defaults to True so a saved PREFIX.pkl can be reloaded later and used
with calc_bootstrap_errors() (and other instance methods) directly,
without reconstructing the mosaic object or juggling its config by hand.
Lets callers add arbitrary named fields (e.g. chi2_x, n_detect) to the
hover label without editing plotly_stars itself. Also switches the
customdata index bookkeeping from recomputing len(customdata) each
time to a single incremented customidx pointer.
n_detect_list is purely a summed quantity (inherit_n_detect's per-epoch
detection count), so 0 is the correct "no data" value here, unlike other
int columns where 0 is a legitimate measured value distinct from
"never filled". This lets StarTable.detections() compute the aggregate
n_detect as a direct sum(n_detect_list, axis=1) instead of a masked sum
against x/y, which is also faster.
…for batch fitting

API changes:
- Every motion model (Empty, Fixed, Linear, Acceleration, Parallax) is now
  fit with closed-form, vectorized linear algebra only -- scipy.optimize.curve_fit
  is no longer used anywhere in motion_model.py. The use_scipy and scipy_method
  parameters are removed entirely, all the way up the call chain: MotionModel.fit(),
  StarTable.fit_motion_models(), and MosaicSelfRef/MosaicToRef's constructors no
  longer accept them (a breaking change for any caller passing them explicitly).

- run_fit_batch is renamed to run_fit, and is now the single override hook every
  concrete MotionModel subclass must implement -- there is no more separate
  per-star run_fit. It always takes a 2D (n_stars, n_epochs) batch (even for a
  single star) plus a boolean valid mask, and is never called directly from
  outside motion_model.py. The base class's run_fit now raises NotImplementedError
  instead of silently returning fill_value/inf, so a future model that forgets to
  implement it fails loudly rather than producing silent all-NaN fits. A model
  that can't vectorize its fit across stars can still satisfy this contract by
  looping over stars internally (e.g. calling curve_fit once per row).

- MotionModel.fit() is now the one dual-shape entry point: 1D input is the
  existing single-star convenience path (unchanged from a caller's perspective);
  2D input is a new batch path that derives valid = isfinite(x) & isfinite(y)
  directly from the data -- no separate mask is built or passed by the caller.
  fit()'s bootstrap resampling is also now a single vectorized run_fit() call
  across all bootstrap draws instead of one curve_fit call per draw.

- StarTable.fit_motion_models() now calls fit() instead of run_fit directly for
  its batch path, and no longer threads a valid array into that call. This
  required two correctness fixes in its own masking: mask_value-masked cells and
  near-zero (np.isclose) xe/ye cells were previously only flagged via a masked
  array's .mask, not written as real nan in the underlying data -- now they are
  (only when those paths are actually used), so the isfinite-derived validity in
  fit() correctly excludes them too.

- sigma_from_error (formerly MotionModel.calc_sigma, a method that never used
  self) is now a plain module-level function alongside weight_from_sigma.

Also fixes two flaky tests in test_motion_model.py that used unseeded
np.random.normal for simulated data (only bootstrap resampling was seeded);
now use a per-test seeded np.random.default_rng.
'propagation' (default, unchanged behavior): the formal error-propagated
uncertainty of the weighted mean, sqrt(1/sum(weights)) -- trusts the
per-epoch input errors as correct.

'empirical': the weighted standard deviation of the epochs themselves
around their weighted mean, for when input errors are systematically
underestimated. Computed manually (not np.average, which raises if any
row's weights sum to zero) and explicitly forced to inf wherever there's
no usable error or degree of freedom is 0, matching 'propagation''s
existing inf behavior in those cases rather than the 0 the naive formula
would otherwise give.

Threaded through combine_lists/combine_lists_xym (startables.py) and as a
new std_method='propagation' constructor parameter on MosaicSelfRef/
MosaicToRef (align.py), affecting only Fixed/Empty-eligible ("simple")
stars -- stars fit via Linear/Acceleration/Parallax get their error from
that fit's own covariance, controlled separately by absolute_sigma.
Fixed.run_fit computed chi2 as residual**2/xe**2 instead of weighting by
x_wt/y_wt, the (weighting-scheme) weights the fit itself used, as
Linear/Acceleration/Parallax all do.

Those two are the same only for weighting='var', where sigma=|xe| so
1/sigma**2 == 1/xe**2. Under weighting='std' the fit's weight is 1/xe, so
the reported chi2 described a different fit than the one performed -- and
through the absolute_sigma=False sqrt(chi2/dof) rescaling, that produced
parameter errors disagreeing with scipy.optimize.curve_fit.

Using the weights also drops an epoch whose xe/ye is unusable (weight 0)
out of chi2 cleanly, instead of poisoning the whole sum with nan the way
dividing by a nan xe did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every MotionModel.run_fit is now a closed-form vectorized weighted
least-squares solve rather than a curve_fit call, so nothing structurally
guarantees it still follows scipy's conventions. These tests pin that down
across all four models, both weighting schemes, both absolute_sigma
settings, epoch counts down to exactly n_params (dof == 0), and nan-padded
epochs. Parallax is checked against a single joint 5-parameter curve_fit
over the stacked [x, y] data, since pi is shared across both directions.

This is the test that caught the Fixed chi2 bug fixed in the previous
commit; it fails with that bug restored.

Two things the comparison has to get right to avoid false failures:
  - nan padding goes through the 2D batch path, since the 1D path's
    contract is data the caller already filtered to real epochs.
  - chi2 is compared using scipy's definition evaluated at flystar's own
    params. Comparing chi2 at curve_fit's params instead folds in the
    optimizer's residual convergence error, which chi2 amplifies hugely
    for a near-exact (low-dof) fit: at dof=1 a 3e-11 relative parameter
    difference moves chi2 by 2e-4 relative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
combine_lists gained a std_method='propagation'|'empirical' parameter to
choose between the formal error-propagated uncertainty of the weighted mean
and the epochs' own scatter. But every MotionModel.run_fit already exposes
exactly that choice as absolute_sigma, following
scipy.optimize.curve_fit's convention -- so a table could be configured
with two overlapping, independently-set knobs that disagree, and stars
would get their errors computed by different rules depending only on which
code path happened to fit them.

Fold the choice into the single existing absolute_sigma parameter, so one
setting governs error computation for every star regardless of whether it
was combined by a weighted average or fit by a motion model:

  absolute_sigma=True  -> sqrt(1/sum(weights)), trusting the input errors
  absolute_sigma=False -> that, rescaled by sqrt(chi2/dof)

The empirical branch now uses the dof-normalized rescaling (scipy's
pcov *= chi2/dof) rather than std_method='empirical''s un-normalized
sqrt(chi2/sum(weights)). Besides matching scipy, dof normalization is what
makes the degenerate case behave: at dof <= 0 there is no residual
information to estimate scatter from, and the existing dof_pos guard
already reports inf there, where the un-normalized form silently returned
0 for a single measurement's zero residual.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed is possible

update_ref_table_aggregates decided which stars could skip
fit_motion_models using an epoch-count proxy: a star with at most 1 valid
epoch can only ever be Empty or Fixed, and combine_lists_xym reproduces
both exactly, so those stars take the fast vectorized path.

That proxy is conservative but incomplete. When Empty/Fixed are the only
motion models requested, there is no classification to do at all -- every
star is Empty or Fixed no matter how many epochs it has -- yet any star
with 2+ epochs still went the slow way. Check self.motion_models directly
for that case and route every star through combine_lists_xym.

organize_motion_models normalizes self.motion_models to MotionModel
classes and always includes Empty and Fixed, so the subset test is cheap
and always well-defined. The needs_error_fallback exclusion is unchanged
and still applies to both branches, preserving fit_motion_models'
unit-weight fallback for stars whose xe/ye are invalid in every epoch.

Beyond being faster, this makes the error convention consistent: these
stars now get their x0_err/y0_err from combine_lists, which honors
absolute_sigma, instead of depending on which path a star's epoch count
happened to select.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When a star has no usable uncertainty, fit_motion_models substitutes a unit
error (sigma=1) so a position can still be measured -- either because the
table has no xe/ye columns at all, or because that star's own xe/ye are
invalid in every epoch (fill_with_one).

With absolute_sigma=True the reported error is then purely a function of
that fabricated sigma: exactly 1/sqrt(N_valid) for Fixed, so 0.577 for a
3-epoch star. It looks like a real uncertainty in the data's units and is
sized plausibly, but carries no measurement information whatsoever -- and
in a table where other stars have real errors, nothing distinguishes the
two.

fill_with_one was already overridden to inf for exactly this reason, but
the override was gated on with_xe_ye, so the identical situation with the
columns absent entirely leaked the fabricated value instead. Treat both as
the one case they are.

With absolute_sigma=False the sqrt(chi2/dof) rescaling cancels the
fabricated sigma back out, leaving the epochs' own empirical scatter -- a
genuine, correctly-scaled uncertainty (verified equal to
std(x, ddof=1)/sqrt(N)) -- so that is now kept rather than also being
overridden to inf. It is the way to get real uncertainties out of a table
that has none on input.

Positions were never affected: an unweighted mean is the correct estimate
when no errors are known, and only the reported error was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MotionModel.model() inferred "one time per star" from len(t) == N_stars.
That made a single 1D array mean different things depending on how many
stars the table happened to hold, and when a table's star count coincided
with its epoch count every model silently took the per-star branch --
returning a flattened result that infer_positions then failed to broadcast
into its (N_stars, N_times) output ("could not be broadcast to indexing
result of shape (6,6)" for any 6-star, 6-epoch table).

infer_positions already had the unambiguous contract (it allocates
(N_stars, N_times) and documents scalar / (N_times,) / (N_stars, N_times));
only model() disagreed with it. Add motion_model.broadcast_times as the one
place that resolves a time argument, and use it in all five models and in
infer_positions:

    scalar              one time, every star
    (N_times,)          one shared grid, every star -- always, even
                        when N_times == N_stars
    (1, N_times)        the same, written explicitly
    (N_stars, N_times)  each star has its own times

Per-star times are now spelled as a 2D column/grid, so no meaning is ever
inferred from a coincidence of sizes, and any other shape raises ValueError
naming both legal spellings instead of being guessed at.

Two things this also fixes:
  - (N_stars, N_times) input was documented by infer_positions but raised
    a broadcasting error in model(), since N_times was read as len(t).
    infer_positions now hands each motion-model group its own rows of the
    grid, aligned with the fit/fixed params it already slices per group.
  - Parallax.model left a scalar ra/dec at shape (1,), so its parallax
    vector had one row -- which only happens to broadcast for a shared
    grid. Broadcast the per-star fixed params to (N_stars,) as run_fit
    already does, and evaluate the parallax vector over the unique times
    of the grid, gathering back per (star, epoch).

Empty.model additionally returned (N_stars,) when N_times == 1 or
N_stars == 1, disagreeing with every other model's squeeze convention;
it now follows the same rule.

Squeeze behavior is deliberately left alone: the output is still flattened
when N_stars == 1 or N_times == 1. Verified align is untouched by dumping
every numeric ref_table column from MosaicSelfRef (Fixed/Linear, both
absolute_sigma) and MosaicToRef runs plus direct infer_positions calls --
all 130 arrays bit-identical before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pins motion_model.broadcast_times and every model's handling of scalar /
shared-grid / per-star times, plus infer_positions end to end.

The important case is the coincidence one, N_stars == N_times: it had no
coverage anywhere in the suite, which is exactly why the old
len(t) == N_stars heuristic could sit there returning wrong shapes (and
crashing infer_positions) without any test noticing. All three tests fail
if that heuristic is reintroduced.

Per-star results are checked row by row against independent single-star
evaluations, so a shape that merely looks right but mixes up which star got
which time is caught too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shape rule was only written in broadcast_times, while the model()
docstrings still described t as "shape (N_times,)" -- which no longer says
what a user needs to know and omits the per-star form entirely.

Put the full RST table (renders under sphinx-astropy) in the three places
that define the contract: broadcast_times, the base MotionModel.model, and
StarTable.infer_positions. The five concrete model() methods get a compact
prose form plus a cross-reference, rather than five verbatim copies of the
table that would drift apart.

MotionModel.model had no docstring at all; it now documents the contract
that every subclass implements, including the squeeze convention (output is
(N_stars, N_times), flattened when N_stars == 1 or N_times == 1).

Also notes at infer_positions that propagating a table to a single new
epoch does not need per-star times: a scalar epoch plus a per-star t0
already gives each star its own dt.

No behavior change -- 130/130 align ref_table arrays still bit-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
align's `motion_models` was doing double duty: it selected which models to
FIT for the observed stars, and -- through the 'motion_model_used' column it
produces -- also decided how every star was PROPAGATED to an observed epoch.

Those are different questions. Which model to fit is a choice about the
observations. How far a star must move to reach this epoch is a property of
the star: a reference imported from an external catalog (Gaia, say) can carry
vx/vy/t0 that were never fit here at all, and has to move with Linear even
when motion_models=['Fixed']. Previously it did not -- the velocities were
carried into ref_table and then silently ignored, freezing the reference at
its catalog epoch with no warning, so matching ran against stale positions.

Propagation now asks determine_motion_models(table, None), i.e. the most
complex model whose own parameters are all present and finite for that star.
This degrades correctly in both directions without needing a flag: a star fit
with Fixed has nan vx and so propagates as Fixed, while a reference star with
real velocities propagates as Linear. 'motion_model_used' keeps recording
what was fit, and fitting is still restricted to motion_models.

Applied at both propagation sites that feed matching -- get_ref_list_from_table
and match_lists (whose docstring already promised "propogated to the
appropriate epoch"). StarTable.infer_positions gains a motion_model_used
override so a caller can supply the per-star choice explicitly instead of
being tied to the column.

Verified: with a reference carrying per-star vx/vy, motion_models=['Fixed']
now propagates at exactly the reference velocity (per-star slope matches vx to
1e-6) while still fitting only Fixed. Tables without reference velocities are
unaffected -- all 130 align ref_table arrays bit-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pins that motion_models controls fitting only: with a reference carrying
per-star vx/vy/t0, motion_models=['Fixed'] must still propagate the reference
at its own velocity, while motion_model_used stays 'Fixed'.

Two things the test has to do to be meaningful, both learned the hard way:
  - velocities must be PER-STAR. A uniform proper motion is degenerate with
    the per-epoch transformation, which absorbs it, and the test passes
    vacuously (an earlier attempt measured a fitted vx of -0.0000 against a
    truth of +1.00 for exactly this reason).
  - the Linear params must be attached as columns, not passed to StarList(),
    whose __init__ accepts only x/y/m/xe/ye/me/corr and silently drops
    everything else -- so the reference never carried velocities at all.

Fails if propagation is put back on self.motion_models.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
StarList's keyword constructor accepted only
('x','y','m','xe','ye','me','corr') plus name and the list_time/list_name
meta, and silently discarded every other keyword. Building a reference list
for a Linear motion model the obvious way,

    StarList(name=.., x=.., y=.., m=.., vx=.., vy=.., t0=..)

therefore produced a list with no velocities at all and raised nothing to say
so. The data loss only surfaced much later, and far from its cause, as an
align reference that would not propagate: the velocities align was looking for
had never made it past the constructor.

Unrecognized keywords become columns now. They must be 1D with one entry per
star, so a typo or a wrongly-shaped argument raises ValueError here rather
than turning into a bogus column. Masks survive too -- the check is against
np.ma.MaskedArray rather than MaskedColumn, since MaskedColumn subclasses it
and a plain np.ma.masked_array would otherwise have its mask stripped by
np.asarray. A caller-supplied `meta` is now merged as well, rather than being
overwritten by this branch's own self.meta assignment.

Nothing in the package relied on the old behavior: analysis.py used only
recognized keywords and then attached extras as columns afterward (the
workaround this removes the need for), and the tests build lists through the
positional/names= path, which is untouched. All 130 align ref_table arrays
remain bit-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Propagation picked the most complex model each star's finite parameters
support, which is right for a reference carrying velocities but gives the
caller no way to say "propagate this star as Fixed even though it has a vx".
The original get_star_positions_at_time did offer that: it batched stars by
the 'motion_model_input' column and only fell back for the leftovers.

Restore that per-star control as determine_propagation_models:

  1. 'motion_model_input', where it is usable -- every parameter that model
     needs is present (table column or fixed_params_dict) and finite for that
     star, so a request that cannot actually be evaluated does not silently
     produce nan.
  2. otherwise the most complex model whose own parameters are all finite.

Unlike the original, the fallback is an explicit finiteness check rather than
catching an exception and retrying whatever came out nan, so a genuinely
broken evaluation still raises instead of being quietly papered over.

One trap this has to avoid: setup_ref_table_from_starlist AUTO-FILLS
'motion_model_input' with motion_models[-1].name when the input starlist has
no such column. That value is not a request -- it is the fitting setting
restated per row -- so honoring it would tie propagation straight back to
`motion_models`, and with motion_models=['Fixed'] every row would read
'Fixed' and a reference with real velocities would be frozen at its catalog
epoch, undoing the previous commit. Verified that happens. The flag
motion_model_input_from_user records which case applies, and only a genuine
request is honored.

Verified: no column supplied -> velocities still honored (slope matches vx to
1e-6); all rows requesting Fixed -> frozen; all requesting Linear -> move at
their own vx; mixed requests resolved per star. Fitting stays confined to
motion_models throughout. All 130 align ref_table arrays bit-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers determine_propagation_models at unit level (usable request honored,
including an explicit downgrade; unusable/unrecognized/absent falling back to
most-complex-available; the honor flag switching it off) and end to end
through MosaicToRef with per-star and mixed requests.

The load-bearing assertion is that an AUTO-FILLED 'motion_model_input' must
not suppress a reference's velocities: setting honor_motion_model_input=True
unconditionally makes it fail with "auto-filled motion_model_input froze the
reference", which is the regression that showed up while implementing this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
determine_propagation_models took an honor_motion_model_input flag so callers
could tell it when the column was a real per-star request rather than one
align had invented. That put the burden in the wrong place -- the rule should
just be "honor motion_model_input when present, else most complex available",
with no parameter to thread through.

Making that rule correct means "present" has to mean the caller supplied it.
setup_ref_table_from_starlist was filling the column with
motion_models[-1].name whenever the input starlist lacked it, so the column
was always present and a genuine request was indistinguishable from the
fitting setting restated per row. Remove that auto-fill and the flag with it.

Fitting is unaffected. fit_motion_models already handles the column being
absent, and its no-column branch -- most complex model in motion_models with
n_fit >= n_params -- is exactly what a uniformly auto-filled column produced:
rows with enough epochs kept that model, rows without were reassigned by the
same np.digitize call. Confirmed empirically: all 130 align ref_table arrays
bit-identical.

The observable change is that ref_table no longer carries a
'motion_model_input' column unless the input did. Every consumer already
guards on its presence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
determine_propagation_models was determine_motion_models(table, None) plus a
'motion_model_input' override, which is a thin wrapper for two functions
answering nearly the same question. Fold the override in and delete it. One
function, one precedence:

  1. 'motion_model_input', where that model can actually be evaluated for
     that star -- every parameter it needs present and finite
  2. otherwise the most complex model in `motion_models` whose parameters are
     all present and finite
  3. motion_models=None means "any model", so step 2 becomes "the most
     complex model this star's parameters support"

The restricted-list vs None distinction is now the only thing separating the
two uses: fitting calls pass their list to stay confined to it, propagation
passes None because how far a star must move is a property of the star, not
of which models were chosen for fitting.

Giving the request top priority also removes a disagreement rather than
creating one: fit_motion_models already honors 'motion_model_input' ahead of
the motion_models list -- it resolves requests through the full model map --
so determine_motion_models was previously re-deriving something else and only
agreeing by accident, because a Fixed-fit star happens to have nan vx.

A request downgrades on its own when its parameters are missing: a star
asking for Acceleration with no ax/ay columns, or with ax nan because it had
too few epochs, falls through to step 2 instead of producing nan positions.
Verified per star -- ax=[0.5, nan, 0.5, nan] with input='Acceleration' gives
[Acceleration, Linear, Acceleration, Linear].

One behavior change worth calling out: because a usable request now outranks
`motion_models`, motion_model_used follows the request. Requesting Linear
per-star under motion_models=['Fixed'] labels those rows 'Linear' rather than
clamping them to 'Fixed'. That is the intent -- and it only labels rows whose
Linear parameters actually exist and are finite, so the label never points at
nan. A test assertion encoding the old clamped behavior was updated.

all_mm_map is now built unconditionally; it was only bound when motion_models
arrived as a list of strings, and the request lookup needs it always.

All 130 align ref_table arrays remain bit-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
infer_positions took a motion_model_used array so align could tell it which
model to move each star with. That exposed an internal decision as public API:
callers should not have to work out the per-star model, and nothing outside
align ever wanted to.

It now decides for itself, following the two-tier shape upstream/mm_rework's
get_star_positions_at_time used -- a 'motion_model_input' request where that
model can actually be evaluated for the star, otherwise the most complex model
that star's own parameters support, resolved per star. Unlike mm_rework, the
fallback is an explicit finiteness check rather than a bare `except: pass`
followed by retrying whatever came out nan, so a genuinely broken evaluation
still raises.

Two consequences worth noting:
  - a 'motion_model_used' column is no longer required. A table carrying
    x0/vx/t0 can be propagated without having been through
    fit_motion_models, where before this asserted.
  - infer_positions no longer consults 'motion_model_used'. For a star that
    was fit the two agree anyway (parameters outside its own model are nan,
    so they cannot be selected), which is why every align result is
    unchanged.

determine_motion_models' implementation moves to motion_model.py so startables
can reach it -- align imports startables, so it could not have imported align.
align.determine_motion_models stays as a thin delegator, keeping its signature
including the unused processes/chunksize arguments, so existing calls and
external code keep working.

All 130 align ref_table arrays bit-identical; scipy agreement still 432/432.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'motion_model_used' was assigned by classifying against the fitting
configuration, even for stars that were never fit. A frozen reference star
carrying catalog x0/vx/t0 under motion_models=['Fixed'] therefore came out as

    motion_model_used = 'Fixed'    n_params = 1    vx = <finite catalog value>

a row holding Linear parameters while claiming to be Fixed. The column was
describing the caller's fitting choice rather than the row's contents, which
is not something a reader of ref_table can distinguish.

The first assignment now classifies with motion_models=None, i.e. by which
model the row's own parameters actually constitute. Nothing has been fit at
that point, so that is the only truthful answer available; rows that later get
fit are re-classified against self.motion_models after the fit, as before.

    update_ref_orig=False  ->  Linear, n_params=2, vx finite   (frozen catalog)
    update_ref_orig=True   ->  Fixed,  n_params=1, vx nan      (re-derived)

Both are now self-consistent, and this incidentally repairs a partial-update
case: a reference star updated from a single epoch used to end up with a fresh
x0 beside the catalog's vx and the catalog's t0 -- asserting x(t0) at a t0 the
new x0 was never measured at. It now gets t0 moved with x0 and the unusable
velocity cleared:

    before:  x0=50.300  vx=+0.300 (stale)  t0=2020.0  ->  incoherent
    after:   x0=50.300  vx=nan             t0=2021.0  ->  Fixed, coherent

A test assertion is rewritten rather than merely fixed: it asserted
motion_model_used == 'Fixed' under motion_models=['Fixed'], which encoded the
old "label describes the fitting configuration" meaning. It now asserts the
new intent -- frozen reference rows labeled Linear, n_params agreeing, and the
label pointing at finite parameters.

Not attempted here: mm_rework also guaranteed that whichever path updates a
star writes that star's COMPLETE parameter set, by choosing between
combine_lists_xym and the motion fit for the whole table at once
(np.all(motion_model_input=='Fixed')). Restoring that as a per-star condition
-- excluding rows holding richer parameters from the combine_lists fast path --
was tried and reverted: it also caught stars carrying a leftover vx from an
earlier iteration while now having <=1 valid epoch, rerouting them and
perturbing matching (66 -> 72 stars, 101 of 130 baseline arrays changed). A
narrower condition would be needed.

All 130 align ref_table arrays bit-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
match_and_transform rejects outliers twice: once before deriving the
transformation, and again against the derived transformation, re-deriving it
without any star that is still an outlier. The second pass never ran.

outlier_rejection_indices returns a boolean MASK over idx2, so len(keepers) is
always len(idx2). The guard

    if len(keepers) < len(idx2):

was therefore never satisfiable, and the accompanying message printed
len(idx2) - len(keepers) == 0 regardless. The first pass gets this right by
counting with sum(keepers); only the second used len(). Count the True entries
instead.

The work was being done and thrown away, not skipped: instrumenting
outlier_rejection_indices on a case with three injected outliers per epoch
shows the second call per starlist identifying 2 and 1 further outliers, which
were then discarded. With the guard fixed, derive_transform goes from 6 to 8
calls -- the two starlists with surviving outliers now re-derive.

Two more problems in the same block:

  - star_list_T was rebuilt from the untrimmed star_list, but idx1 indexes
    star_list_orig_trim (mag_lim applied), so star_list_T[idx1] read the wrong
    rows whenever mag_lim trimmed anything. The first pass builds it from the
    trimmed list; this now matches.

  - the match.match call here computed idx_lis/idx_ref/dr/dm that nothing read
    -- the final match further down reassigns all four before any use. Removed,
    saving a full KDTree match per starlist per iteration.

This changes results wherever outlier_tol is set, which is the point. Every
existing test and the align baseline pass outlier_tol=None, so nothing
exercised this path -- which is how it stayed dead -- and all 130 baseline
arrays remain bit-identical.

The improvement is modest on a case whose outliers are gross enough for the
first pass to catch: max x0 residual 0.01777 -> 0.01701, rms 0.00551 ->
0.00556. The second pass matters for outliers subtle enough to survive the
first cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Asserts the second outlier-rejection pass actually runs, by counting
derive_transform calls with outlier_tol=2.0 against outlier_tol=None on data
with three injected outliers per epoch. Fails with the old
`len(keepers) < len(idx2)` guard restored.

Every other test in the suite passes outlier_tol=None, so this path had no
coverage at all -- which is why a guard that could never fire survived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A MosaicSelfRef fit with a finite outlier_tol died with

    ValueError: x1 does not contain any finite values!

raised from match.match, while the same fit with outlier_tol=None worked.
The tolerance was not the problem. On the first iteration the reference
list's own starlist is matched against a reference table built from it,
under an identity transform, so every residual is exactly 0. The rejection
threshold is median + outlier_tol * sigma, which with no scatter collapses
onto the median, and a strict '<' then rejected 100% of the matches.
derive_transform fit 3 free parameters to 0 stars, returned NaN
coefficients without raising, and the NaNs travelled to the next match.

Outlier rejection:
- Keep stars sitting exactly at the threshold ('<=', not '<'), in both
  MosaicSelfRef.outlier_rejection_indices and the module-level function.
- Centre the module-level threshold on the median residual, as the method
  already did. Without it a tight cluster of residuals around a nonzero
  offset is rejected wholesale. Nothing in flystar calls this function.
- Add min_stars_for_transform() and guard_outlier_rejection(): a rejection
  pass that would leave fewer stars than the transformation has free
  parameters per axis is refused, keeping all matched stars with a warning.
  A fit including some outliers beats a fit made of NaNs.

Fail loudly at the fit instead of an iteration later:
- Add check_transform_finite(), called after every derive_transform and
  find_transform. The error names the starlist, the stage, the order and
  the number of stars the fit was given, rather than leaving NaNs to
  surface far from their cause.

generic_match had three separate bugs, all found by its own failing test:
- The refinement loop ignored i_loop when indexing order_dr, reading
  order_dr[0] as the order and order_dr[1] as dr_tol. order_dr is
  documented as (n, 2) rows executed in order, and the match_name branch
  already indexed it that way. Normalize with atleast_2d so a flat pair
  still works, then index the current row. The flat default (1, 1.0) also
  made len(order_dr) == 2, running one requested pass twice.
- init_mode='match_name' called model(x, y, xref, yref, order=...), the
  pre-API-change convention; PolyTransform.__init__ now takes (order, px,
  py) coefficients, so it raised TypeError. Use derive_transform.
- test_generic_match asked for dr_tol=1.0 on data whose true counterparts
  sit 1.3-3.2 px apart after the initial align, so the refinement matched
  nothing. Widen to 4.0, which straddles the gap to the 246+ px decoys,
  and give the test the assertions it never had.

Also make test_update_old_and_new_names' debug plt.show() non-blocking. On
an interactive backend it hung the whole test session waiting for a window
that pytest never shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In a NIRCam pointing, one star appeared in the reference table three times --
s1_nrca1_e1_23562, 3_s1_nrca1_e4_23697 and 1_s1_nrca1_e2_23786, all within
6 mas of each other, each holding a single exposure. Both matching rules
were at fault, and each fed the other.

A star with several candidates inside dr_tol was matched only if its
nearest candidate in position was also its nearest in magnitude, with no
regard for how lopsided the evidence was. For the star above, a counterpart
3.6 mas away lost to an unrelated star 82.9 mas away -- 23x farther -- that
happened to be 0.06 mag closer. Position and magnitude were compared as
equals. On one exposure of this pointing, 184 stars were discarded that way
and another 249 by the duplicate arbitration, which used the same
both-must-agree test after the fact.

Each discarded star was then added to the reference table as a new row a few
mas from the true one. Every later starlist saw two nearly coincident rows,
was ambiguous by construction, and split in turn: exposure e2 matched
correctly in iteration 1 and was dropped in iteration 2 purely because e4's
drop had created a second row. One bad tie-break seeds the next.

match(matching='chi2') scores every candidate pair as

    chi2 = (dx^2 + dy^2) / sigma_pos^2 + dm^2 / sigma_mag^2

and keeps a pair when it is both stars' lowest-chi2 candidate and beats each
star's runner-up by dchi2_tol (default 9, a 3-sigma margin). Scoring in
units of the measured scatter lets each piece of evidence carry the weight
it has earned: a 20x closer candidate produces a chi2 difference in the
hundreds, a fraction of a magnitude produces a few, so magnitude decides
only when the positions are genuinely coincident. Requiring the match to be
reciprocal makes one-to-one symmetric by construction and retires the
duplicate arbitration -- the result no longer depends on catalog order.

The scales are measured from the starlists, needing no error columns, in
three tiers: the robust scatter of unambiguous (single-candidate) pairs;
failing that, of each star's nearest candidate; failing that, dr_tol/10 with
the magnitude term switched off. The magnitude term is used only when its
scale was actually measured -- without one there is no defensible exchange
rate between arcseconds and magnitudes, and deriving one from the ratio of
the two tolerances is the very mistake this removes. dm_tol keeps working as
a hard gate throughout. A scatter of exactly zero (a list matched against
itself) falls through to the fallback rather than dividing by zero.

Plumbed through MosaicSelfRef and MosaicToRef as matching / dchi2_tol /
match_sigma_pos / match_sigma_mag, forwarded to all three match.match call
sites and recorded in the saved input params. The default is
matching='legacy', so no existing result changes until a caller opts in.

On the pointing above, iters=2, dr_tol=[0.1, 0.05], dm_tol=[0.5, 0.5]:

                                 legacy         chi2
    ref_table rows                65168        63349
    split pairs <10 mas            2195          783
    stars in all 4 exposures      25349        25695
    median position error (mas)    2.785        2.819

Split pairs -- rows within 10 mas that never share an exposure, the
signature of one star recorded twice -- drop 64%; about 150 of the
remainder are chance coincidences at this density. 2655 fewer
single-detection rows, 346 more stars measured in all four exposures. The
three rows above become one, detected in exposures [0, 1, 3]. The median
position error rises very slightly, as expected: previously split stars now
average over more epochs, including their noisier detections.

Co-Authored-By: Claude Opus 5 (1M context) <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.

5 participants