Skip to content

Mm rework lingfeng into Dev - #26

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

Mm rework lingfeng into Dev#26
wei-lingfeng wants to merge 424 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

Macy Huston and others added 30 commits January 12, 2026 14:02
Pull Lingfeng's updates into main repo new branch
…ter ref table has been created in align; Added save path in plots; Moved all testing data files under flystar/test/test_data; Removed trailing spaces
…or; motion_model_used column does not need to be set separately outside of the motion model fit function
…otion_model_used column problem: Now it will only be determined by motion model fit, and the column will be removed if provided in the reference list
… and update_ref_table_aggregates; Fixed the data dimensions when N_times=1
The README was the astropy template's stub -- a badge, a licence and the
imposter-syndrome note -- with no statement of what FlyStar does and no link to
either documentation build. It now opens with the one-sentence description, then
names both: Read the Docs (per-branch, /en/mm_rework_lingfeng/, rebuilt on every
push) and the ad-free GitHub Pages mirror (one version, whatever the branch last
published), with the warning that both describe a branch whose motion-model
framework is not on main, and the install lines including the optional extra.

The front page ran the whole example as one block, so the reader could not see
where their own work would start. It is now two: building the synthetic lists,
which stands in for whatever produces the reader's star lists, and then the
alignment, which is one object and one call. The check against the injected
truth is called out as the part real data would not have.

The sidebar gained a "Getting started" entry under FlyStar. sphinx_rtd_theme
will not expand the master document's own sections -- `:maxdepth:` on the `self`
toctree does nothing -- and a toctree cannot carry an anchor, so this is a second
titled `self` entry. It lands at the top of the front page rather than on the
section, which is the closest the theme allows without splitting the content
back out into its own page.
Two toctree entries pointed at the same document, so the theme marked both as
current and clicking FlyStar highlighted Getting started with it. The plain
`self` entry was redundant anyway: sphinx_rtd_theme already puts a home link
above the navigation, so the front page now has exactly one entry, and exactly
one thing highlights on any page.

Not a caption: in this theme a caption heads a group of pages -- Components,
Examples, API, Indices -- and this is one page, so a caption would be a heading
with nothing under it but the page it names.

The home link read `flystar`, taken from the distribution name in setup.cfg,
which has to stay lower case for pip. The documentation calls it FlyStar
everywhere else, so the displayed name is now set explicitly, which also fixes
the page titles ("FlyStar dev documentation").
wei-lingfeng and others added 4 commits August 25, 2026 22:58
Aligning a mosaic peaked at ~9.5x the size of the reference table it was
building: 8 starlists of 10k stars produced a 122 MB table but a 264 MB
live working set and ~1.2 GB peak RSS. Most of that gap is allocation
churn -- the table is grown one starlist at a time and every step
reallocates every column -- and freed buffers are not returned to the OS,
so RSS tracks cumulative allocation rather than live memory.

The largest single object in the table was 'name_in_list', an
(N_stars x N_lists) array of U30: 120 bytes per entry, rebuilt in full
every time the table grew, and 44% of the whole table. The name of star i
in list j is already recoverable as star_lists[j]['name'][k], so store the
int32 k instead. That column drops from 54.05 MB to 1.80 MB at 8 lists.
It is renamed 'idx_in_list' so the column name still says what it holds,
and align.names_in_list() recovers the strings.

Also:

- Size motion_model_input/motion_model_used to the widest motion-model
  name that exists rather than a flat U20, derived from
  motion_model_map() so a new model widens the column automatically.
- Stop casting 'name' straight to U30, which silently truncated longer
  identifiers (a 35-character Gaia-style name came back cut at 30). It
  now widens past 30 instead, keeping the 30 characters of headroom that
  analysis.py relies on when assigning label names in place.
- Replace set(range(N)) - set(idx_list) in add_rows_for_new_stars with a
  boolean mask: same result, ~100x faster (208 ms -> 2.1 ms at 1e6 stars)
  and without building a Python int per star.

Measured on a synthetic scan mosaic, comparing against the same run
before the change:

              table          live peak        peak RSS
   8 lists    122 -> 68 MB   264 -> 147 MB    ~1192 -> ~600 MB
  16 lists    443 -> 235 MB  920 -> 489 MB    ~3480 -> ~1800 MB

Output is byte-identical at both scales, including a cross-check with
magnitude-dependent errors and trans_weights='both,var' active.

Note this changes the output format: tables written by earlier versions
carry a string 'name_in_list'. plots.py and analysis.py read either, but
external code that reads ref_table['name_in_list'] should move to
align.names_in_list().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
align.py had 10 definitions with no docstring at all -- including both
public classes -- and the ones that existed used six different spellings
of the numpydoc section headers, so parts of the module did not render as
API documentation.

Every definition in the module now has a docstring, and all 51 parse
cleanly under numpydoc.docscrape.

Sections:

- Inputs/Input -> Parameters, Output/Outputs -> Returns, Example ->
  Examples, "Parameters:" -> Parameters, and underlines resized to match
  their headers.
- "Required Parameters"/"Optional Parameters" folded into one Parameters
  section, with optionality moved onto the type line where numpydoc
  expects it.
- 37 entries written "name: type" corrected to "name : type", without
  which numpydoc does not recognise them as parameters at all.

Content:

- Docstrings added for MosaicSelfRef, MosaicToRef,
  fix_iterable_conditions, get_weights_for_lists, calc_mag_avg_all_stars,
  check_trans_input, update_old_and_new_names, get_weighting_scheme,
  logger and suppress_meta_warnings.
- Parameters/Returns filled in where they were missing, most notably
  trans_initial_guess, which documented none of its 16 arguments.
- Stale entries removed: motion_model_for_new_star (the code that would
  have used it is commented out), mosaic_object (it describes self), and
  find_transform's 'trans' (not one of its arguments).
- find_transform's transModel default was documented as four_paramNW
  where the signature says PolyTransform.

No code changed: stripping every docstring from this revision and from
the previous one yields identical ASTs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Attributes section added to MosaicSelfRef listed star_lists, which
sphinx-autoapi already documents on its own: it is assigned in __init__,
and autoapi_python_class_content='both' merges the class and __init__
docs, so the attribute was described twice.

    api/flystar/align/index.rst:910: WARNING: duplicate object
    description of flystar.align.MosaicSelfRef.star_lists

Read the Docs sets fail_on_warning: false and so was unaffected, but
.github/workflows/docs.yml builds with -W --keep-going, where this was
enough to fail the GitHub Pages job.

ref_table, trans_list and trans_list_inverse are assigned in fit() rather
than __init__, so autoapi does not pick those up and they stay in the
Attributes section. star_lists moves into the prose above it, which keeps
the pointer to names_in_list that made it worth mentioning.

Verified by building the docs exactly as the workflow does
(sphinx -b html -W --keep-going): clean, where the same build on this
commit's parent failed on the warning above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wei-lingfeng and others added 4 commits August 25, 2026 23:46
Four things stood between this and a working `pip install flystar`.

jplephem was undeclared. astropy reaches for it whenever a solar-system
ephemeris is evaluated, which the Parallax motion model does, so a clean
install raised ModuleNotFoundError there and failed 7 of the 71 tests.
It is a hard requirement, not an extra.

pyproject.toml's [project] silently shadowed setup.cfg. setuptools reads
[project] in preference to [metadata]/[options] and says so:

    SetuptoolsWarning: `install_requires` overwritten in `pyproject.toml`
    SetuptoolsWarning: `extras_require` overwritten in `pyproject.toml`

So setup.cfg's dependency list, its `optional` extra (shapely,
astroquery, plotly), its python_requires and its long-stale
`url = https://bitbucket.org/mwhosek/flystar` were all dead text. The
duplicated sections are removed and pyproject is now the single source;
setup.cfg keeps only [tool:pytest] and [coverage:*], which have no
[project] equivalent. The `optional` extra is carried over, so
`pip install flystar[optional]` works again -- those three are imported
lazily inside the functions that use them, so the package runs without
them but those features do not.

There were no classifiers at all, so the PyPI page would have carried no
license, Python versions or subject area, and the package would not have
appeared under any filtered search. `project.license` also moves from the
deprecated TOML table to an SPDX string, with license-files pointing at
the license this repo actually ships (licenses/LICENSE.rst, not the
LICENSE.rst that MANIFEST.in imagines). That form needs setuptools>=77,
now the build-system floor.

flystar/version.py was tracked in git. setuptools_scm rewrites it during
every build, which dirtied the tree and appended a ".d<date>" suffix to
the version -- so even a clean tagged build would not have produced a
plain release version. It is untracked and ignored; it is still generated
at build time, and nothing imports it except _astropy_init, which is
itself dead code.

Not addressed here: the wheel is still 96% tests/test_data (23.9 MB of a
9.5 MB wheel), and MANIFEST.in still references a CHANGES.rst and
LICENSE.rst that do not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wheel was 96% test data. flystar/tests/test_data is 24 MB, 22 MB of it
a single FITS catalogue (test_catalog.fits) that five tests in
test_startable.py read. Every user installing flystar was downloading and
storing it.

Tests now stay in the repository rather than in the distributions. Half
measures do not work here: the suite locates its fixtures through
flystar.__path__, so shipping the test modules without their data would
leave an installed suite that cannot run at all.

MANIFEST.in also referenced several things that were not there. It asked
for a CHANGES.rst and a top-level LICENSE.rst that do not exist (the
licence this repo ships is licenses/LICENSE.rst), and recursed into
cextern/ and scripts/, which do not exist either, and into *.pyx/*.c/*.pxd
for a package with no compiled extensions. It also pulled in all of
docs/examples, ~10 MB of .lis and .fits inputs for the Gaia notebook that
Read the Docs takes from the repository anyway.

CHANGES.rst is added rather than dropped from MANIFEST.in, since a release
wants release notes.

              before      after
    wheel     9.09 MB     0.19 MB
    sdist    16.66 MB     0.96 MB

Verified the sdist still builds a wheel once extracted outside git, and
that the wheel carries every module, the licence, and no tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Publishing with an API token means a long-lived secret that has to live in
the repository settings and be rotated by hand. PyPI Trusted Publishing
(OpenID Connect) avoids it: PyPI verifies the workflow's identity directly,
so there is no secret to store at all.

The upload runs when a GitHub Release is published, not when a tag is
pushed. Creating a tag therefore stays reversible, and the upload is a
deliberate second step -- worth having, because setuptools_scm takes the
version from the tag and PyPI will never let that version number be
uploaded again.

A manual workflow_dispatch run publishes to TestPyPI instead, so the whole
path can be rehearsed without spending a real version number.

Requires a one-time setup on PyPI (Publishing -> Add a new pending
publisher) naming this repository, publish.yml, and the pypi environment;
the header comment records the exact values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removing the shadowed [metadata] section from setup.cfg broke the docs
build, which read the author and the GitHub project out of it:

    configparser.NoSectionError: No section: 'metadata'

conf.py now parses pyproject.toml, which is the single source for project
metadata. github_project is derived from the repository URL there rather
than being a second copy of it, and the author is joined from the authors
table. tomllib is stdlib from 3.11; tomli is added to the docs
requirements for older interpreters.

Caught by CI rather than locally, because the docs build I had run was
from before the packaging change. Verified this time with the workflow's
own command, sphinx -b html -W --keep-going.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wei-lingfeng and others added 3 commits August 31, 2026 17:44
…dule

The aligners took an iters argument that was never read: the iteration
count has always come from len(dr_tol), so a call passing a disagreeing
iters was already running the dr_tol schedule, silently. Two sources of
truth for one number, with no check that they agreed. iters is gone.

dr_tol becomes required, and moves ahead of the optional arguments.
Matching is a radius search, so there is no meaningful default -- the old
dr_tol=[1.0] silently applied a one-unit radius in whatever units the
reference frame happened to use.

Deriving the count from dr_tol alone was itself arbitrary, though, and it
made the broadcast one-directional: dr_tol=[0.5, 0.3], dm_tol=1 worked
while dr_tol=0.5, dm_tol=[1, 0.5] raised, even though the second is a
perfectly sensible request for two passes at a constant radius. The count
is now the length of the longest schedule -- dr_tol, dm_tol, outlier_tol
or trans_args -- with single values broadcast up to it. Only single values
broadcast, so two sequences of differing length remain an error rather
than a guess, and nothing silently papers over a mismatch.

mag_lim does not vote. Its [min, max] form is a pair, not a schedule, and
a naive length vote would read mag_lim=[13, 21] as two iterations; it is
still checked against the resulting count.

dm_tol now defaults to None, no magnitude cut at all. The old default of
[1.0] rejected pairs more than one magnitude apart, which is wrong across
filters and arbitrary within one.

fix_iterable_conditions needed no logic change -- its broadcast-and-assert
loop was already right once self.iters was -- only sharper assertion
messages, which used to name iters as though the caller could set it.

Verified with the full suite (72 pass) and test_iters_from_longest_schedule,
which covers each schedule setting the count in turn, the broadcast
reaching every iteration rather than just the first, the conflict cases,
and mag_lim abstaining. The docs build was not re-run: sphinx in this
environment is missing the autoapi extension.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One flush-left line in _normalize_schedule dropped the docstring's common
indentation to zero, so every other line parsed as an indented block and the
Raises heading landed inside it: "Unexpected section title". Sphinx runs with
-W, so that lone docutils error failed the docs job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were added on this branch and run in MovingUniverseLab/flystar, where the
Claude Code GitHub App is not installed and CLAUDE_CODE_OAUTH_TOKEN does not
exist, so the app-token exchange 401s. 78 runs, 78 failures, none successful.
Merging them into dev would plant two permanently-red workflows, and claude.yml
fires on every issue and PR comment repo-wide.

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

suppress_meta_warnings() renamed over-long meta keys in place (e.g.
'list_times' -> 'HIERARCH list_times') to dodge a FITS-only warning, but
calc_bootstrap_errors() called it unconditionally before two plain
pickle.dump()s that never needed it, permanently breaking plain
meta['list_times'] lookups on the live ref_table regardless of output
format. It now returns a new dict instead of mutating the table, and
MosaicSelfRef/MosaicToRef gain a save_format parameter ('hdf5' default,
'fits', or 'pkl') so FITS's HIERARCH handling is applied only when
actually writing FITS, on a column-sharing copy of ref_table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
wei-lingfeng and others added 2 commits September 1, 2026 01:59
ref_mag_lim was applied identically on every iteration, with no way to
tighten/loosen the reference magnitude cut pass-to-pass the way mag_lim,
dr_tol, dm_tol and outlier_tol already can. Added MosaicToRef.fix_ref_mag_lim,
called from __init__ alongside the base class's fix_iterable_conditions, to
normalize None / a flat [min, max] / a per-iteration sequence into a
length-iters list -- flat (N_iters, 2), not mag_lim's (N_iters, N_lists, 2),
since there's exactly one reference list. match_and_transform's call in
fit() now indexes self.ref_mag_lim[nn] instead of passing the same value
every time, and the old ad hoc None->inf substitution (which only handled
the single-pair case) is gone, folded into fix_ref_mag_lim.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Update docs/alignment.rst to match the previous commit: ref_mag_lim now
takes a schedule shape ([min, max] or a sequence of iters entries),
mirroring mag_lim minus the per-list axis (there's exactly one reference
list). Added it to the per-iteration schedules table and the "which
stars drive the fit" table, and a matching bullet alongside mag_lim in
the narrative section.

Verified with a full sphinx build (-W --keep-going): alignment.rst and
the align.py API page build clean, no warnings from either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
wei-lingfeng and others added 5 commits September 5, 2026 23:48
infer_positions built each model's fit_params as
np.array([self[p][idx] for p in fit_param_names]).T, which collapses to
shape (0,) for Empty -- whose fit_param_names is [] -- no matter how many
stars that model covers. Empty.model then calls np.atleast_2d on it,
getting (1, 0), and reads N_stars = 1 from it, while times_grid[unique_index]
correctly carries one row per Empty star. broadcast_times compares the two
and raises:

    ValueError: Empty.model: 2D time array must have one row per star --
    got shape (10385, 1) for 1 star(s)

Any table with two or more stars whose parameters support no model at all
hit this, which is routine: a mosaic ref list carries a row per star that
was fit with too few valid epochs to place, and MosaicToRef propagates the
whole table to each list's epoch before matching. It surfaced as a crash
20 minutes into a MosaicToRef.fit(), naming a shape rather than the rows
responsible.

Build the no-fit-params case as np.empty((len(unique_index), 0)) instead,
so the star count survives and every model reaches model() with the same
(N_stars_this_model, N_params) convention. fit_param_errs gets the same
treatment, keeping the shapes equal for _check_param_dimensions' assert.

Verified against StarTables mixing Linear and Empty rows: a scalar epoch,
a shared multi-time grid, a per-star (N, 1) grid, an all-Empty table and a
single-Empty-star table all return nan positions / inf errors for the Empty
rows and leave the Linear rows unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Parallax.run_fit built its shared time axis with np.unique(t) over the
whole array and handed it to Time(..., format='decimalyear'). A star that
was not detected in an epoch carries nan there in t as well as in x/y --
StarTable._set_invalid_list_values fills every float column for an
undetected list, t included -- and Time rejects non-finite input outright:

    ValueError: Input values did not match the format class decimalyear:
    TypeError: Input values for decimalyear class must be finite doubles

So a single undetected epoch anywhere in the table killed the entire batch
fit. In a real mosaic that is not an edge case: with 27 lists and four
pointings per epoch, only 27 of 73695 stars in the ref table were detected
in every list, so effectively every row carried nan times.

The masking that already handles these epochs (`valid` zeroes them out of
every weighted sum, via weight_from_sigma and the np.where(valid, ..., 0)
block) all runs after the conversion. Every other operation in the routine
is arithmetic that propagates nan harmlessly until masked; Time is the one
step that validates its input, and it sits upstream of the mask.

Build the unique-time axis from the finite times alone, substituting a real
epoch from the batch where t is nan -- pvec for those entries is never read
-- and intersect them out of `valid` too, so a nan time arriving beside a
finite x/y cannot feed a nan dt into the sums.

Linear.run_fit never noticed because it does not convert times to MJD. The
test suite missed it on three counts: the only end-to-end MosaicToRef run
with motion_models=['Linear','Parallax'] sits under `if __name__ ==
'__main__'` and is never collected; test_startable.py never exercises
fit_motion_models with Parallax at all; and the nan-padding sweep covers
only Fixed/Linear/Acceleration, padding x/y while leaving t finite.

Verified on ragged StarTables: 5 epochs with stars missing one, missing
the first, and having only two, now fit (the two-epoch star correctly
demoting to Linear rather than fitting a 5-parameter model), while a table
with no nan times gives bit-identical results to before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fit() writes the ref table through _write_ref_table, which respects the
save_format constructor option added in cf9c468 ('hdf5' by default).
calc_bootstrap_errors instead pickled it unconditionally, so a run
configured for hdf5 got PREFIX_ref_table.hdf5 from fit() and
PREFIX_ref_table_bootstrap.pkl from the bootstrap -- the same table, in
two formats, one of them not the one that was asked for. It also dumped
the whole object unconditionally, ignoring save_object, which fit() does
respect; for a mosaic that pickle runs to hundreds of MB.

Route the bootstrap table through _write_ref_table as well, giving that
helper a `suffix` argument so the existing PREFIX_ref_table_bootstrap
name is preserved rather than becoming PREFIX_bootstrap_ref_table. A
run with save_format='pkl' therefore produces exactly the same filename
as before; only the default (hdf5) changes what it writes. Gate the
object dump on save_object, whose default is True, so default behavior
is unchanged there too.

Both save_path docstrings now list the bootstrap outputs, which were
previously undocumented.

Verified for all three save_format values that fit()-style and
bootstrap-style writes land side by side with the right extensions, and
that the hdf5 bootstrap table round-trips with its meta intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setup_ref_table_from_starlist builds the ref table with
StarTable(**col_arrays), from the input list's columns alone, so anything
the caller set in that list's meta is dropped. For MosaicToRef the input
is the user's ref_list, which is the natural place to attach the ra/dec a
Parallax fit needs -- and doing so failed with

    KeyError: fit_motion_models: Missing required fixed parameter(s) for
    the motion models used: 'ra', 'dec'! Please provide them in
    fixed_params_dict, or as columns in the table, or as table metadata.

an error that names table metadata as a valid source while the metadata
in question had already been discarded. determine_motion_models and
infer_positions both consult meta the same way, so the gap was not
specific to fitting.

Copy across the meta keys that are motion model fixed parameters (ra,
dec, pa, obsLocation, t0), taken from the model classes rather than
hardcoded. Only those: the rest of a ref list's meta (n_lists, n_stars,
list_times, EPNAMES) describes the table it came from, not the one being
built, and a blanket copy would overwrite this table's own bookkeeping --
a ref list carried over from an earlier alignment reports that alignment's
list count where the new table starts with one. Keys already set by the
StarTable constructor are never overwritten, and an explicit
fixed_params_dict still takes precedence, since every lookup consults it
before the table.

Verified that ra/dec/pa on an input StarList reach the constructed ref
table while n_lists/n_stars/list_times planted alongside them do not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HDF5 and FITS store strings as fixed-length bytes, so a table written
with a 'U' column reads back with an 'S' one: 'Linear' becomes b'Linear'.
That compares unequal to every str literal, so code that works on a
freshly fit table silently stops working once the table has been written
and reloaded, without raising anything. A `motion_model_used ==
'Parallax'` branch simply never fires, selecting the wrong degrees of
freedom; `ref_table['name'] == 'star_012345'` and np.isin against a list
of names match nothing at all.

Override read() to call astropy's own convert_bytestring_to_unicode() on
the result, so masked and multidimensional string columns are handled the
way astropy handles them everywhere else, and a saved table behaves like
the one that was written.

Verified on a saved mosaic ref table: motion_model_used comes back <U12
with all 29130 rows matching 'Linear' (previously none did), name comes
back <U30, no bytes columns remain, and the numeric columns are untouched.

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.

3 participants