Mm rework lingfeng into Dev - #26
Open
wei-lingfeng wants to merge 355 commits into
Open
Conversation
…ed absolute sigma in Linear model
Bootstrap memory
minor cleanup
…odel error calculations
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This merges
mm_rework_lingfengintodev. It supersedes #25 (which compared this branch againstmm_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_reworkmm_reworkalready has amotion_model.pyand aFixed/Linear/Acceleration/Parallaxsystem of its own, so unlike the rest of this description, the API differences below are specifically against that branch's design (notdev, 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'sMosaicSelfRef.__init__takesdefault_motion_model='Fixed', andfit_velocitiesseparately takes its owndefault_motion_model='Linear'— one model name, used as a single global fallback. Both are replaced by onemotion_modelsparameter (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_dict→fixed_params_dict, andt0stops being a special case:mm_rework'srun_fit(self, t, x, y, xe, ye, t0, ...)takest0as its own required positional argument everywhere, with a separate, loosely-typedmotion_model_dict={}alongside it for anything else. Both are unified into onefixed_params_dict(holdingt0forLinear/Acceleration;t0,ra,dec,pa,obsLocationforParallax), so every model's non-fit parameters flow through the same mechanism instead oft0being threaded separately from everything else.Emptymotion model:mm_reworkonly hasFixed/Linear/Acceleration/Parallax.Empty(a trivial placeholder for a star with no usable data — alwaysfill_value/inf) is new.StarTable.fit_velocities→StarTable.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_velocitiesparameters removed or replaced:mask_val→mask_value;fixed_t0=False(a special-cased boolean/array just fort0) is gone now thatt0is an ordinaryfixed_params_dictentry;show_progress/reassign_motion_modelare gone (reassignment now happens automatically every call from each star's current epoch count — seekeep_existing, new, to control whether stars not being refit keep their existing values instead of being overwritten withnan, described further down). New, with nomm_reworkequivalent at all:keep_existing,seed,method(scipy method name),fill_value,art_star, and the multiprocessing controlsprocesses/chunksize/mp_star_threshold.MotionModelsubclass internals renamed (relevant only if calling code subclasses or touchesMotionModeldirectly, rather than going throughStarTable/align):n_pts_req(hardcoded per class, e.g.Parallax.n_pts_req = 4) →n_params(derived asceil(n_fit_params / 2), givingParallax.n_params = 3— the same fix as then_pts_reqpoint already covered above);fitter_param_names→fit_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 onemodel()method that vectorizes automatically based on input shape;motion_model.get_weights→calc_sigma(see "Uncertainty model" above); newrun_fit_batchonEmpty/Fixed/Linear, with nomm_reworkequivalent (see "Vectorized, multiprocessing-aware motion-model fitting" below).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.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 ofFixedcomputing something structurally different fromLinear/Acceleration/Parallax.combine_listsswitched to error propagation (also removing its now-unnecessary >1-epoch requirement for weighted averaging), andFixed.run_fit's uncertainty was brought in line with the scipy-covariance convention every other model already used.motion_model.get_weights'sx_wt = 1/xe**2→sigma = 1/x_wt**0.5round-trip accumulated floating-point error across repeatedalign()iterations; replaced withcalc_sigma, which returnsxeorsqrt(xe)directly usable by scipy, computing an inverse-variance weight only where one's actually still needed.Parallax.run_fithad two real bugs from not using that same path:sigmawas never square-rooted before being passed tocurve_fit, andabsolute_sigmawas silently ignored. Both fixed by routing it throughcalc_sigma+absolute_sigmalike everything else. (Worked example provingcurve_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.)MosaicSelfRef/MosaicToRef'sabsolute_sigmadefault changed fromFalsetoTrue— required for scipy's covariance matrix to actually mean "error propagation" rather than a reduced-chi2-rescaled quantity.Uncertainty columns:
inf, nevernan, for "no information"A second, related convention, enforced consistently for the first time: a star's uncertainty is
infwhen there's no real information to compute one from — nevernan.1/inf**2 == 0, so aninfuncertainty correctly contributes zero weight anywhere it's combined with other stars' measurements;naninstead poisons every downstream computation it touches (x + nan == nan) and vanishes silently from filters, sincenan == nanisFalseandnan < anythingisFalse. Several real bugs were all instances of this convention being violated in different places:xe/ye/medefaulted tonaninstead ofinffor invalid entries in the newly-vectorizedFixed/Emptybatch paths — corrected toinf.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 toinfafterward — fragile, and exactly the kind of place a fabricated finite value could silently leak through instead. Rewritten soinffalls out naturally: the weight sum is built only from real, known uncertainties, sostd = sqrt(1/wgt_sum)isinfvia ordinary1/0whenever none exist, with no override to remember.absolute_sigma=False's chi2 rescaling could turn a singular fit's correctinferror intonan—inf * 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.NaN-propagation bugs (not theinfconvention itself, but bugs the convention's violation exposed on real, imperfect data): a non-finitem0could never be excluded by a magnitude-range filter (NaNcomparisons are alwaysFalse), letting bad reference stars corrupt the magnitude-zeropoint transform; andfit_motion_models' defaultt0could silently come outNaNfor 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_modelsnow 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:FixedandEmpty(trivial/closed-form) andLinear(closed-form 2×2 solve). On a real dataset,processes=3dropped 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 forEmptystars.numpy.maindexing bottleneck (tens of millions of slow per-elementnumpy.ma.core.__getitem__calls) madefit_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 aworkersparameter (default 1) threadingscipy.spatial.KDTree.query_ball_point;align.pyexposes it asmatch_workers(default 1 — production runs typically share a machine, so the speedup is opt-in). Verified this doesn't affect the delicatedm_min == dr_mintie-break, which depends on within-list neighbor order:workers=1vsworkers=-1give identical, order-preserved neighbor lists, checked against dense/duplicate-point edge cases and a full end-to-end run with all 37ref_tableoutput columns byte-identical.mp_star_threshold(default 100,000): a multiprocessing pool for motion-model fitting (Acceleration/Parallax, or any model withbootstrap > 0— the models with no vectorized path) is only spun up when the number of stars actually needing it meets this threshold, even ifprocesses > 1was 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
StarTableconstruction gained an opt-incopy=Falseand now builds all columns in one constructor call instead of manyadd_column()calls (~1.2s / +4.6GB → ~0.001s / ~0GB for ~29 columns at 1.4M rows).vstack-ed it onto the growingref_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_modelsdid 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 incombine_lists/fit_motion_models's array prep (anarange-based fancy-index copy stacked on top of an already-copyingmasked_invalid/deepcopycall) 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_idxsno longer wipes unrelated stars: refitting only a subset of stars used to overwrite every other star's existing result withnan; now keeps existing values by default and only touches the requested subset.Parallax's minimum-epoch requirement wasn_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 themotion_model_inputcolumn.inherit_n_detect: newMosaicSelfRef/MosaicToRefparameter (defaultTrue) so a star'sn_detectreflects the total number of raw detections it represents across nested alignment layers, not just 1 per input starlist.StarListwarning: every pickle round-trip (astropy passes columns positionally to__init__) incorrectly warned about missing required arguments.align()iterations) kept stale params (vx/vy) from its old model indefinitely; now reset tofill_value/infright after reclassification.parallax.parallax_in_direction'sTime(mjd + 2400000.5, format='jd', ...)simplified toTime(mjd, format='mjd', ...); a misleadingtqdmprogress bar over a handful-of-motion-model-types loop (never per-star) removed.Documentation
.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 freshpip install .for anyone on a current Python:pyproject.toml's[build-system]pinnedcython==0.29.14(leftover astropy-template boilerplate for a package with zero.pyx/C extensions), which imports the stdlibcgimodule removed in Python 3.13, breaking install outright; the declareddependencieswere missingscipy,matplotlib,tqdm,joblib, andpandas, all imported unconditionally at module level; andsetup.cfg'sgithub_projectwas still the template default (astropy/astropy), whichconf.pyuses unconditionally to build doc issue-links. Verified via two independent fresh-venv builds (editable and the exact non-editablepip install .[docs]Read the Docs runs) —sphinx-buildsucceeds, 92 warnings, all pre-existing docstring formatting nits unrelated to this change.wei-lingfeng/flystarrather than this repo directly: importingMovingUniverseLab/flystarinto Read the Docs needs repo-admin access (to add its webhook) not currently available on this account, and theflystarproject 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) andtest_generic_match(test data contains no finite values). (Thetest_MosaicSelfRef_velfailure present at one point inmm_rework— see the magnitude-weighting fix above — is resolved on this branch.)Worked example:
scipy.curve_fitvs. analytic error propagation (referenced above)Output: