Skip to content

Mm rework lingfeng into Dev - #26

Merged
jluastro merged 475 commits into
devfrom
mm_rework_lingfeng
Oct 29, 2026
Merged

jluastro merged 475 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_dict → fixed_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_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_velocities parameters removed or replaced: mask_val → mask_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_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 one model() method that vectorizes automatically based on input shape; motion_model.get_weights → calc_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**2 → sigma = 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 nan — 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.
  • 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 April 29, 2026 18:32
…or plotly_stars; Added show_plot control for quiver residual plots
…cified in fixed_params_dict, will try to look for them in the table columns; For optional fixed params, will use the default value if not provided
…upport for 2d time arrays (if length==len(stars), assumes a corresponding relation instead of returning a 2D)
…ping regions; Added briteN control in MosaicSelfRef and MosaicToRef
…lumns -> meta data -> default value; Added random seed control in bootstrap functions.
…mbine_lists; Added support for artificial star motion model fitting with x/y out (index 1).
jluastro and others added 27 commits October 12, 2026 09:13
Orbit has 6 free orbital elements (orb_P, orb_t0, orb_e, orb_i,
orb_Omega, orb_omega), so n_params should be 6, not 3.

With demote=True, a star labeled Orbit that has fewer than 6 unique
epochs is automatically demoted to a simpler model (e.g. Linear).
This handles the case where align adds a new star to the reference
with motion_model_input='Orbit' but only a handful of detections.

Frozen orbit stars are protected by the existing frozen_motion_mask
subtable mechanism: they are excluded from fit_motion_models before
the demotion block is reached, so demote=True has no effect on them.

Test added: test_orbit_star_not_in_reference_demoted_to_linear
Test present: test_frozen_orbit_is_bitwise_unchanged_with_few_detections

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
Add docs/examples/name_match_example.ipynb demonstrating the name-matching
workflow. Update docs/index.rst to include the new notebook in the Examples
toctree.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
…fill_value

_element_jacobian accepted a `seed` argument that was immediately deleted
inside the function with no use. Remove it from the signature, its docstring
entry, and the single call site in _solve_one_orbit.

Also remove the `del fill_value` statement in Orbit.run_fit, which was an
unnecessary explicit deletion of a parameter that is simply not used in
that code path.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
Replace the `**self.name_match_kwargs(...)` splat with explicit
`if self.name_match: name_kws = {...}` blocks at all three match.match
call sites.  Readers can now see exactly which keyword arguments
match.match receives without following an indirection through a helper
method.

The lazy `name_key` column initialisation is moved to a single setup
block before the iteration loop (and at the top of match_lists), so
the call sites just read from an already-existing column.

The now-unused `name_match_kwargs` method is removed.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
name_prefer2 was a tiebreaker for when the reference list contained
multiple stars with the same name. The reference list is now required
to have unique names (enforced by a new ValueError check in
MosaicToRef.__init__), and flystar assigns unique names to every new
star added during an alignment run. With duplicates impossible,
name_prefer2 is dead code and has been removed from all three
match.match call sites in align.py.

The duplicate-name check fires on the name column only when present
and reports the offending names (first 5, with "..." if more).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
The reference list is now guaranteed to have unique names (checked at
MosaicToRef construction), so duplicate same-name candidates in catalog
2 cannot arise. name_prefer2 / prefer2 — the tiebreaker that picked the
preferred row among duplicates — is therefore dead code.

Removed from:
- same_name_candidates(): prefer2 param, pref array, and the
  prefer2-weighted argmin logic
- match_name_override(): name_prefer2 param and pass-through to
  same_name_candidates
- match(): name_prefer2 param, pass to match_name_override, and
  name_opts dict entry; same in the tiebreak path
- match_chi2(): name_prefer2 key in the names dict description and
  the prefer2= kwarg on the same_name_candidates call

Also removed test_prefer_original_row_and_availability and the
"duplicate ref rows" sub-test in test_override_exclusion_and_duplicates,
which both tested the now-deleted feature.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
Replace the name_kws dict + **name_kws splat pattern with explicit
keyword arguments on each of the three match.match call sites. All
five name-match arguments (names1, names2, name_dr_tol, name_exclude,
name_mode) are now visible inline; the conditional `if self.name_match
else None/default` form makes the no-name-match case equally clear
without a separate else branch.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
…one_orbit → Orbit.fit_one_star

- Move all 10 orbit-only private helpers (_angdiff, _nearest_angle, _sky_xy,
  _row_value, _cov_for_star, _cov_state, _position_sigma, _elements_from_internal,
  _choose_branch, _element_jacobian) from motion_model.py to orbits.py, where the
  underlying Kepler mechanics already live.
- Fix _sky_xy to call kep2xyz directly (same module) instead of orbits.kep2xyz.
- Rename _solve_one_orbit → Orbit.fit_one_star (@staticmethod); update call site.
- Import helpers from flystar.orbits in motion_model.py.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
…_broadcast_seeds

Instead of calling _row_value (scalar-or-array dispatch) n_stars times per
parameter inside the model/run_fit loops, pre-broadcast all optional fixed
params and element seeds to (n_stars,) float arrays once before each loop.
Defaults come from self.optional_fixed_params, so they live in one place.

- Add Orbit._broadcast_fixed: normalizes optional fixed params to (n_stars,)
  arrays, falling back to optional_fixed_params for absent keys.
- Add Orbit._broadcast_seeds: normalizes orbital-element seeds (orb_P …
  orb_omega) from fixed_params_dict to a (n_stars, 6) float array.
- Replace all _row_value call sites in Orbit.model and Orbit.run_fit.
- Remove _row_value from flystar/orbits.py and from the motion_model import.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
Replace the NaN/inf encoding in orb_cov with an explicit integer status
column that records the outcome of each orbit fit:

  0 = success (finite, positive-definite covariance)
  1 = singular covariance (solver converged but dof <= 0 or gram singular)
  2 = optimizer failed (least_squares did not converge)
  3 = skipped / no data (idx == 0, bad seed, or fewer than 3 epochs)

Key changes:
- Orbit.prediction_columns: add 'orb_fit_status' alongside 'orb_cov'.
- Orbit.model: pre-broadcast fit_status_arr before the per-star loop;
  use status codes to decide xe/ye rather than the 'missing'/'bad'/
  'finite' encoding from _cov_state. Legacy tables that predate the
  column fall back to the old _cov_state logic.
- fit_one_star: rename cov_bad to cov_nan (np.nan fill); add status arg
  to _fail(); tag each early-return path with its status code; success
  path defaults to status=1 and promotes to status=0 when the full
  covariance is finite and all diagonal entries are positive.
- run_fit: initialize cov to NaN (was inf); add status array (default 3);
  unpack the new 8-tuple from fit_one_star; include 'orb_fit_status' in
  the diagnostics dict returned to startables.
- startables._write_diagnostics_to_table: update docstring to document
  the status column semantics; the -1 sentinel fill for new int columns
  already handles 'never fit' stars correctly.
- Orbit.demote: set to False so stars classified as Orbit stay Orbit even
  when the current frame has too few epochs to solve; the seed is kept
  and the star uses orb_fit_status=3 instead of being demoted to Fixed.
- test_orbits: add orb_fit_status assertions to the converged-fit,
  too-few-epochs, no-dof, and bad-seed test cases.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
Both functions were defined at module level in orbits.py but are orbit-
specific and only called from Orbit.model / Orbit.fit_one_star in
motion_model.py.  Grouping them inside the class:

* makes the dependency explicit — _sky_xy is now Orbit._sky_xy
* allows subclasses to override the sky-projection or Jacobian (e.g.
  for a relativistic variant)
* eliminates the circular-import hazard that would result from keeping
  _sky_xy in orbits.py while _position_sigma needs to call it

Changes:
- orbits.py: remove _sky_xy and _position_sigma definitions
- motion_model.py: import list drops _sky_xy / _position_sigma
- motion_model.py: Orbit._sky_xy(self, ...) and Orbit._position_sigma(self, ...)
  added after _broadcast_seeds
- motion_model.py: Orbit.fit_one_star demoted from @staticmethod to a
  regular instance method so its _chi2 / residual closures can reach
  self._sky_xy
- All call sites updated to self._sky_xy / self._position_sigma

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
Replace the _with_fit_seed hack that tunnelled orbital element starting
guesses through fixed_params_dict with an explicit params_init_guess_dict
parameter on run_fit() and fit().  This cleanly separates truly-fixed
physics constraints from optimizer initial guesses.

Changes:
- motion_model.py: add params_init_guess_dict= to all run_fit() stubs
  and to Orbit.run_fit() / fit(); pass it through to _broadcast_seeds()
  (which now reads from params_init_guess_dict instead of fixed_params_dict);
  standardize every run_fit() to return a 5-tuple
  (params, param_errs, chi2x, chi2y, diagnostics) — non-Orbit models
  return {} as diagnostics; fit() always returns a 5-tuple when
  return_chi2=True (diagnostics={} for non-Orbit, squeezed dict for Orbit)
- startables.py: remove _with_fit_seed entirely; build
  params_init_guess_dict inline from table columns in all three call
  paths (batch, multiprocessing, sequential); update
  _fit_motion_models_worker signature; remove len(result)>4 guards now
  that the interface is uniform
- tests/test_motion_model.py: update 4-tuple unpacks to *_ star-unpack

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
…an column

Remove the two-mechanism freeze system (fixed_motion_models list +
per-star fit_motion string column) and replace it with a single
boolean column update_ref_motion on the reference StarTable.

  True  (default) — refit this star's motion model each iteration
  False            — hold this star's motion model frozen

Changes:
- motion_model.py: frozen_motion_mask() now reads only
  update_ref_motion; removed _FIT_MOTION_VALUES constant.
  Updated Orbit docstring to name the new column.
- startables.py: fit_motion_models() loses fixed_motion_models
  parameter. StarTable.__init__() injects update_ref_motion=True
  for all stars when the column is absent.
- align.py: MosaicToRef.__init__() loses fixed_motion_models
  parameter. All internal callers updated. Column list that keeps
  per-star columns 1-D updated from fit_motion to update_ref_motion.
- tests/test_motion_model.py: rewrite freeze tests to set
  update_ref_motion directly instead of fixed_motion_models list or
  fit_motion string column.
- tests/test_align.py: _tiny_mosaic helper, and the three frozen-
  orbit align tests updated to use update_ref_motion.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
Bug fix
-------
add_rows_for_new_stars now explicitly sets update_ref_motion=True for
every new star added to ref_table during alignment.  Previously the
generic boolean-column fallback set it to False (frozen), meaning newly
discovered stars could never have their motion models refit.

Code documentation
------------------
* MosaicToRef class docstring: note that update_ref_motion provides
  per-star freeze control independent of update_ref_orig.
* MosaicToRef.__init__ docstring: document the update_ref_motion column
  near update_ref_orig, with a table of per-star semantics.
* update_ref_table_aggregates docstring: explain the interaction between
  update_ref_orig (catalog-level policy) and update_ref_motion (per-star
  unconditional freeze), including how the frozen mask is OR-ed into
  keep_orig.
* add_rows_for_new_stars docstring: note the explicit True default.

ReadTheDocs (docs/alignment.rst)
---------------------------------
Expand the update_ref_orig table entry to reference update_ref_motion.
Add a new section "Per-star freeze control: update_ref_motion" with:
  - a two-row meaning table (True / False),
  - a code snippet showing how to freeze anchor stars,
  - an admonition explaining the update_ref_orig/update_ref_motion
    interaction with a four-row truth table.

Example notebooks
-----------------
* motion_model_example.ipynb: new section 2.5 "Freezing specific stars
  with update_ref_motion" with runnable code showing StarTable injection
  and a code-block showing MosaicToRef usage with frozen anchors.
* flystar_mosaic_to_gaia/gaia_flystar_example.ipynb: new markdown cell
  after the pass-3 MosaicToRef call showing how to freeze Gaia anchor
  stars using update_ref_motion.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
Commit 14fe153 set demote=True so that stars labelled Orbit but with
fewer than n_params=6 unique epochs are automatically demoted to a
simpler model (e.g. Linear) rather than kept as Orbit with an
under-determined fit.  A subsequent commit reverted demote back to
False (while leaving n_params=n_fit_params intact), breaking
test_orbit_star_not_in_reference_demoted_to_linear.

The comment added alongside demote=False ("Keep the star as Orbit even
when too few epochs …") described the opposite of the intended behaviour
and is removed along with the revert.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TKVLLQoFaG93mAU9u7q2P7
…el-1130

Add a Newtonian Orbit motion model
…raw values

The bootstrap errors were computed from running sums of the raw transformed
positions (and motion parameters) and their squares, so each variance was the
small difference of two large, nearly equal sums. With positions far from the
origin, or a spread close to zero, rounding made it 0 or negative, giving
zero or NaN errors: with the test field shifted 1e4 units, 11 xe_boot values
came out NaN and 10 zero.

Sum each value's difference from the first iteration's instead, and clip the
variance at 0. The errors are the same standard deviation as before, without
the rounding. New test: test_bootstrap_errors_far_from_origin.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Fix NaN and zero bootstrap errors from rounding in calc_bootstrap_errors
- setup.py: local_scheme='no-local-version', so an untagged commit builds
  e.g. 0.1.dev948 rather than 0.1.dev948+g<hash>, which PyPI and TestPyPI
  reject; the publish workflow's manual TestPyPI dry run needs this. Drop the
  unused import of setuptools.config.read_configuration.
- Delete flystar/_astropy_init.py: leftover from the astropy package
  template, imported by nothing, and failing to import under astropy 8.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Packaging fixes for PyPI publishing
… truth

The test showed that chi2 matching leaves fewer duplicate rows than legacy
matching, but not that its matches are right: fewer rows could also come from
combining different stars. Every list in the test keeps its stars in the same
order, so a detection's idx_in_list is its true star. The test now also counts,
per mode, the detections combined with another star's (wrong matches) and the
stars kept whole (all 4 detections in one row), and asserts that chi2 makes no
wrong matches and keeps more stars whole than legacy.

On the test's field (400 stars, 4 epochs, seed 17):
  legacy: 429 rows, 390 of 400 stars whole, 0 wrong matches
  chi2:   400 rows, 400 of 400 stars whole, 0 wrong matches

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Index predicted positions by epoch column ([:, ee]) instead of star row
  in plot_quiver_residuals_with_orig_all_epochs,
  plot_mag_scatter_multi_trans_all_epochs and
  plot_quiver_residuals_magcolor_all_epochs.
- plot_stars_nfilt: default epoch_array_list/color_list so it runs with
  defaults; drop duplicated prediction call.
- plot_quiver_residuals_magcolor: use the colormap (not the matplotlib.cm
  module) and set up colors in both pixel and arcsec units.
- plot_quiver_residuals_orig, plot_mag_scatter, plot_y_scatter: accept the
  show_plot/save_path arguments their caller passes (new _finish_figure
  helper); fix rgood_new.data2 typo.
- Restore plot_mag_scatter_multi_trans (deleted helper) and fix
  plot_mag_scatter_multi_trans_all_epochs: per-table predictions,
  per-epoch grouping instead of wrong stride.
- Remove commented-out validate_motion_model_dict lines and the unused
  make_fake_starlists_poly1_par test helper.

Co-authored-by: Cursor <[email protected]>
Replace the boolean MotionModel.demote with one of DEMOTE_OPTIONS:
'always' (default), 'never', or 'on-missing-init'. Orbit uses
'on-missing-init': an under-detected star with a complete finite set of
initial orbital elements stays Orbit (fit skipped, initial parameters
kept); one without them (e.g. newly added to the reference) is demoted.

- New MotionModel.keep_model_mask(tab, rows) decides per star; used by
  StarTable.fit_motion_models and by MosaicSelfRef's guard that keeps
  such stars out of combine_lists_xym averaging (dead since demote=True).
- motion_model_map() rejects an unknown demote value.
- Fixes test_too_few_epochs_and_a_failed_solve_keep_the_initial_params
  and test_mosaic_fit_and_fixed_orbit_with_update_ref_orig while keeping
  test_orbit_star_not_in_reference_demoted_to_linear passing.
- New tests for the three modes, partial initial parameters, the
  validation error, and fit_motion_models end to end.
- docs/motion_models.rst documents demote.

Rename 'seed' (the fit's starting values) to 'initial parameters' in
Orbit code, orbits.py helpers, tests and docstrings, so 'seed' only
refers to random number generators. Orbit.run_fit's docstring now
documents params_init_guess_dict, which is where the initial elements
actually arrive.

Co-authored-by: Cursor <[email protected]>
- Exclude docs/plans/** (internal design notes) from the build; it was
  not in any toctree.
- name_match_example.ipynb: point the table of contents at the actual
  heading anchors for sections 4 and 5.

Co-authored-by: Cursor <[email protected]>
@jluastro
jluastro merged commit 1ccd879 into dev Oct 29, 2026
1 of 2 checks passed
@jluastro jluastro mentioned this pull request Oct 29, 2026

This branch had an error being deployed

1 failed deployment
github-pages — a281c8cc Deployed Oct 29, 2026 by jluastro via deploy #55
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.

6 participants