From e5aaa900faebd72b705435930d780966b9df2f1f Mon Sep 17 00:00:00 2001 From: Lingfeng Wei Date: Tue, 3 Nov 2026 13:52:49 -0800 Subject: [PATCH 1/3] Docs: furo theme, with the same sidebar navigation as before html_theme is furo instead of sphinx_rtd_theme. furo's sidebar lists only the page titles, so a html-page-context hook builds it from the toctree three levels deep and expanded, as sphinx_rtd_theme's navigation_depth=3 and collapse_navigation=False did: on the index, motion_models and alignment_example pages the sidebar has the same 101 entries, captions and nesting as the previous build. The GitHub "edit this page" links use furo's source_repository, source_branch and source_directory, from the same repository URL in pyproject.toml. docs/requirements.txt installs furo in place of sphinx-rtd-theme. Co-Authored-By: Claude Opus 5.5 --- docs/conf.py | 43 ++++++++++++++++++++++++++++--------------- docs/requirements.txt | 2 +- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index bc4834f..a43ac6e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -148,23 +148,36 @@ # -- HTML output -------------------------------------------------------------- -html_theme = 'sphinx_rtd_theme' -html_theme_options = { - 'navigation_depth': 3, - 'collapse_navigation': False, -} +html_theme = 'furo' +html_title = f'{project} {version}' html_static_path = ['_static'] templates_path = ['_templates'] -# Derived from the repository URL in pyproject.toml, so there is one place -# to change it. -github_project = _project.get('urls', {}).get( +# "Edit this page" and "View source" links. The repository URL comes from +# pyproject.toml, so there is one place to change it. +github_url = _project.get('urls', {}).get( 'repository', 'https://github.com/MovingUniverseLab/flystar' -).rstrip('/').removeprefix('https://github.com/') -html_context = { - 'display_github': True, - 'github_user': github_project.split('/')[0], - 'github_repo': github_project.split('/')[-1], - 'github_version': os.environ.get('READTHEDOCS_GIT_IDENTIFIER', 'main'), - 'conf_py_path': '/docs/', +).rstrip('/') +html_theme_options = { + 'source_repository': f'{github_url}/', + 'source_branch': os.environ.get('READTHEDOCS_GIT_IDENTIFIER', 'main'), + 'source_directory': 'docs/', } + +# The sidebar lists each page's sections three levels deep, all expanded, as +# the previous theme did (sphinx_rtd_theme with navigation_depth=3 and +# collapse_navigation=False). furo's own sidebar lists only the page titles. +SIDEBAR_DEPTH = 3 + + +def _sidebar_with_sections(app, pagename, templatename, context, doctree): + """List the sections of each page in furo's sidebar, not only the page titles.""" + from furo.navigation import get_navigation_tree + if 'toctree' in context: + context['furo_navigation_tree'] = get_navigation_tree( + context['toctree'](collapse=False, titles_only=False, maxdepth=SIDEBAR_DEPTH, includehidden=True)) + + +def setup(app): + """Run _sidebar_with_sections after furo has set its sidebar.""" + app.connect('html-page-context', _sidebar_with_sections, priority=900) diff --git a/docs/requirements.txt b/docs/requirements.txt index 53e05fb..18dad34 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,7 +4,7 @@ # API reference is produced by sphinx-autoapi via static analysis, so the # science stack (numpy/scipy/astropy/matplotlib) is not needed here. sphinx>=7.0 -sphinx-rtd-theme>=2.0 +furo sphinx-autoapi>=3.0 myst-nb>=1.0 From b80d1b9699ffaea792219660589366a634910d0b Mon Sep 17 00:00:00 2001 From: Lingfeng Wei Date: Tue, 3 Nov 2026 14:50:02 -0800 Subject: [PATCH 2/3] Docs: split the home page into a landing page, Installation and First alignment - index.rst keeps the summary, "How the pieces fit" and "Where to go next", which now also lists the two new pages. It is no longer listed in the sidebar as "Getting started". - The sidebar has a "Getting started" caption with two pages: installation.rst (the former Installation section) and first_alignment.rst (the former "A first alignment, start to finish" and "With your own data" sections, unchanged). - The "Which version is this?" note names the dev branch, which the docs are built from, in place of mm_rework_lingfeng. - overview.rst and make_example_figures.py point to first_alignment.rst for the end-to-end example. Co-Authored-By: Claude Opus 5.5 --- docs/first_alignment.rst | 148 ++++++++++++++++++++++++++++ docs/index.rst | 186 +++-------------------------------- docs/installation.rst | 26 +++++ docs/make_example_figures.py | 2 +- docs/overview.rst | 2 +- 5 files changed, 188 insertions(+), 176 deletions(-) create mode 100644 docs/first_alignment.rst create mode 100644 docs/installation.rst diff --git a/docs/first_alignment.rst b/docs/first_alignment.rst new file mode 100644 index 0000000..0d14e6c --- /dev/null +++ b/docs/first_alignment.rst @@ -0,0 +1,148 @@ +=============== +First alignment +=============== + +Start to finish +--------------- + +This runs as written -- no data files, no downloads. + +First the data, which is the stand-in for whatever produces your own star lists: +250 stars observed at four epochs two years apart, each epoch given its own +coordinate system -- a shift of up to 40 pixels and a small rotation about the +field centre -- with 12% of the stars missing from any one epoch and 0.05 +pixels of noise on the rest. That becomes one +:class:`~flystar.starlists.StarList` per epoch, each tagged with when it was +taken: + +.. code-block:: python + + import numpy as np + from flystar import align, starlists, transforms + + rng = np.random.default_rng(42) + N, YEARS, ERR = 250, np.array([2015.0, 2017.0, 2019.0, 2021.0]), 0.05 + t0 = YEARS.mean() + + # Truth: positions at t0, proper motions in pixels/year, magnitudes. + x0 = rng.uniform(0, 1000, N); y0 = rng.uniform(0, 1000, N) + vx = rng.normal(0, 0.3, N); vy = rng.normal(0, 0.3, N) + mag = rng.uniform(12, 19, N) + names = np.array([f'S{j:03d}' for j in range(N)]) + + # Each epoch gets its own frame: a shift of up to 40 pixels plus a small + # rotation about the field centre. That is what the alignment has to undo. + shift_x = np.array([0., 18., -25., 40.]) + shift_y = np.array([0., -12., 30., -20.]) + angle = np.deg2rad(np.array([0., 0.3, -0.5, 0.7])) + + lists = [] + for i, yr in enumerate(YEARS): + dt = yr - t0 + xt, yt = x0 + vx * dt, y0 + vy * dt # where the stars really are + xc, yc = xt - 500., yt - 500. + c, s = np.cos(angle[i]), np.sin(angle[i]) + xo = (c * xc - s * yc) + 500. + shift_x[i] + rng.normal(0, ERR, N) + yo = (s * xc + c * yc) + 500. + shift_y[i] + rng.normal(0, ERR, N) + seen = rng.random(N) > 0.12 # ~12% non-detections per epoch + + sl = starlists.StarList( + name=names[seen], x=xo[seen], y=yo[seen], m=mag[seen], + xe=np.full(seen.sum(), ERR), ye=np.full(seen.sum(), ERR), + me=np.full(seen.sum(), 0.05), + ) + sl.meta['list_time'] = yr # decimal year, UTC + lists.append(sl) + +The alignment itself is one object and one call. Then the answer is read off +the ``StarTable`` it built -- here checked against the truth that went in, +which is the only part of this a real data set would not have: + +.. code-block:: python + + msc = align.MosaicSelfRef( + lists, + dr_tol=[60., 10., 5.], # match radius in pixels, per iteration + dm_tol=[1., 1., 1.], + trans_class=transforms.PolyTransform, + trans_args={'order': 1}, # shift + rotation + scale + motion_models='Linear', # fit x0, vx, y0, vy for every star + init_guess_mode='miracle', # blind triangle match -- names not needed + ) + msc.fit() + ref = msc.ref_table + + good = np.asarray(ref['n_detect']) == len(YEARS) + i_of = {n: j for j, n in enumerate(names)} + idx = np.array([i_of.get(n, -1) for n in np.asarray(ref['name']).astype(str)]) + ok = good & (idx >= 0) + + print(f"{len(ref)} stars, {int(good.sum())} seen in all four epochs") + print(f"vx recovered to {np.std(np.asarray(ref['vx'])[ok] - vx[idx[ok]]) * 1000:.1f} mpix/yr") + +.. code-block:: text + + 254 stars, 144 seen in all four epochs + vx recovered to 19.3 mpix/yr + +254 rows for the 250 stars injected, and 144 seen in every epoch -- close to the +:math:`250 \times 0.88^4 \approx 150` expected from a 12% per-epoch drop-out. +The few extra rows are stars matched in only one or two epochs. + +That every epoch ends up in one frame is the thing to look at. On the left each +star is measured in four visibly different places, one per epoch, with grey +lines joining the four measurements of the same star; on the right, the same +stars after transformation. ``ref_table['x_orig']``/``['y_orig']`` keep the +untransformed positions, so plotting them against ``['x']``/``['y']`` is the +before-and-after directly: + +.. image:: _static/align_before_after.png + :alt: Star positions before and after alignment + :align: center + +The proper motions are the stronger check. FlyStar was never told what they +were, and the frames were rotating underneath them, so recovering them to 20 +milli-pixels per year -- against a 50 milli-pixel per-epoch measurement error +over a six-year baseline -- says the alignment has not absorbed the stars' +motion into the frame solution, which is the failure mode that matters: + +.. image:: _static/align_proper_motion.png + :alt: Recovered versus true proper motion + :align: center + +:doc:`examples/alignment_example` is this same example at greater length, with +the code for these figures, the residual distribution, and what each argument +above is doing. + +With your own data +------------------ + +Real lists come off disk rather than out of a random number generator, but +nothing else changes -- build one +:class:`~flystar.starlists.StarList` per epoch, tag each with its epoch, and +hand them to an aligner: + +.. code-block:: python + + from flystar import align, starlists, transforms + + lists = [] + for path, year in [('epoch1.lis', 2015.5), ('epoch2.lis', 2017.4)]: + sl = starlists.StarList.from_lis_file(path) + sl.meta['list_time'] = year # decimal year, UTC + lists.append(sl) + + msc = align.MosaicSelfRef(lists, + dr_tol=[1.0, 0.5, 0.3], + dm_tol=[2.0, 1.0, 1.0], + trans_class=transforms.PolyTransform, + trans_args=[{'order': 1}, {'order': 2}, {'order': 2}], + motion_models=['Linear']) + msc.fit() + + ref = msc.ref_table + ref['x0'], ref['vx'], ref['vx_err'], ref['motion_model_used'] + +Note ``motion_models=['Linear']``: the default is ``['Empty', 'Fixed']``, which +fits no proper motions at all. + diff --git a/docs/index.rst b/docs/index.rst index 3263353..2e4e2cc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -27,7 +27,7 @@ one self-consistent astrometric solution.** .. admonition:: Which version is this? :class: important - Built from the ``mm_rework_lingfeng`` branch, and describes *that* branch's + Built from the ``dev`` branch, and describes *that* branch's API. The motion-model framework in :doc:`motion_models` does not exist on ``main``. Every page here, the :doc:`API reference ` included, is generated from this branch's source. @@ -61,181 +61,16 @@ Three objects, in the order you meet them: In one line: **StarList → StarTable → align**. You assemble star lists, hand them to an aligner, and read the answer off the ``StarTable`` it produces. -Getting started -=============== - -Installation ------------- - -FlyStar is not on PyPI; install it from the repository: - -.. code-block:: bash - - git clone https://github.com/MovingUniverseLab/flystar.git - cd flystar - pip install -e . - -That pulls in the whole runtime set -- numpy, scipy, astropy, matplotlib, -pandas, joblib and tqdm -- all of which the package imports at module level. -Python 3.7 or newer. - -Three further packages are imported lazily, inside the functions that use them, -so install them only if you want those features: ``shapely`` -(polygon-restricted initial guesses via ``starlist_vertices``), ``astroquery`` -(Gaia queries in :mod:`flystar.analysis`) and ``plotly`` (interactive plots in -:mod:`flystar.plots`). They are grouped as an extra: - -.. code-block:: bash - - pip install -e '.[optional]' - -A first alignment, start to finish ----------------------------------- - -This runs as written -- no data files, no downloads. - -First the data, which is the stand-in for whatever produces your own star lists: -250 stars observed at four epochs two years apart, each epoch given its own -coordinate system -- a shift of up to 40 pixels and a small rotation about the -field centre -- with 12% of the stars missing from any one epoch and 0.05 -pixels of noise on the rest. That becomes one -:class:`~flystar.starlists.StarList` per epoch, each tagged with when it was -taken: - -.. code-block:: python - - import numpy as np - from flystar import align, starlists, transforms - - rng = np.random.default_rng(42) - N, YEARS, ERR = 250, np.array([2015.0, 2017.0, 2019.0, 2021.0]), 0.05 - t0 = YEARS.mean() - - # Truth: positions at t0, proper motions in pixels/year, magnitudes. - x0 = rng.uniform(0, 1000, N); y0 = rng.uniform(0, 1000, N) - vx = rng.normal(0, 0.3, N); vy = rng.normal(0, 0.3, N) - mag = rng.uniform(12, 19, N) - names = np.array([f'S{j:03d}' for j in range(N)]) - - # Each epoch gets its own frame: a shift of up to 40 pixels plus a small - # rotation about the field centre. That is what the alignment has to undo. - shift_x = np.array([0., 18., -25., 40.]) - shift_y = np.array([0., -12., 30., -20.]) - angle = np.deg2rad(np.array([0., 0.3, -0.5, 0.7])) - - lists = [] - for i, yr in enumerate(YEARS): - dt = yr - t0 - xt, yt = x0 + vx * dt, y0 + vy * dt # where the stars really are - xc, yc = xt - 500., yt - 500. - c, s = np.cos(angle[i]), np.sin(angle[i]) - xo = (c * xc - s * yc) + 500. + shift_x[i] + rng.normal(0, ERR, N) - yo = (s * xc + c * yc) + 500. + shift_y[i] + rng.normal(0, ERR, N) - seen = rng.random(N) > 0.12 # ~12% non-detections per epoch - - sl = starlists.StarList( - name=names[seen], x=xo[seen], y=yo[seen], m=mag[seen], - xe=np.full(seen.sum(), ERR), ye=np.full(seen.sum(), ERR), - me=np.full(seen.sum(), 0.05), - ) - sl.meta['list_time'] = yr # decimal year, UTC - lists.append(sl) - -The alignment itself is one object and one call. Then the answer is read off -the ``StarTable`` it built -- here checked against the truth that went in, -which is the only part of this a real data set would not have: - -.. code-block:: python - - msc = align.MosaicSelfRef( - lists, - dr_tol=[60., 10., 5.], # match radius in pixels, per iteration - dm_tol=[1., 1., 1.], - trans_class=transforms.PolyTransform, - trans_args={'order': 1}, # shift + rotation + scale - motion_models='Linear', # fit x0, vx, y0, vy for every star - init_guess_mode='miracle', # blind triangle match -- names not needed - ) - msc.fit() - ref = msc.ref_table - - good = np.asarray(ref['n_detect']) == len(YEARS) - i_of = {n: j for j, n in enumerate(names)} - idx = np.array([i_of.get(n, -1) for n in np.asarray(ref['name']).astype(str)]) - ok = good & (idx >= 0) - - print(f"{len(ref)} stars, {int(good.sum())} seen in all four epochs") - print(f"vx recovered to {np.std(np.asarray(ref['vx'])[ok] - vx[idx[ok]]) * 1000:.1f} mpix/yr") - -.. code-block:: text - - 254 stars, 144 seen in all four epochs - vx recovered to 19.3 mpix/yr - -254 rows for the 250 stars injected, and 144 seen in every epoch -- close to the -:math:`250 \times 0.88^4 \approx 150` expected from a 12% per-epoch drop-out. -The few extra rows are stars matched in only one or two epochs. - -That every epoch ends up in one frame is the thing to look at. On the left each -star is measured in four visibly different places, one per epoch, with grey -lines joining the four measurements of the same star; on the right, the same -stars after transformation. ``ref_table['x_orig']``/``['y_orig']`` keep the -untransformed positions, so plotting them against ``['x']``/``['y']`` is the -before-and-after directly: - -.. image:: _static/align_before_after.png - :alt: Star positions before and after alignment - :align: center - -The proper motions are the stronger check. FlyStar was never told what they -were, and the frames were rotating underneath them, so recovering them to 20 -milli-pixels per year -- against a 50 milli-pixel per-epoch measurement error -over a six-year baseline -- says the alignment has not absorbed the stars' -motion into the frame solution, which is the failure mode that matters: - -.. image:: _static/align_proper_motion.png - :alt: Recovered versus true proper motion - :align: center - -:doc:`examples/alignment_example` is this same example at greater length, with -the code for these figures, the residual distribution, and what each argument -above is doing. - -With your own data ------------------- - -Real lists come off disk rather than out of a random number generator, but -nothing else changes -- build one -:class:`~flystar.starlists.StarList` per epoch, tag each with its epoch, and -hand them to an aligner: - -.. code-block:: python - - from flystar import align, starlists, transforms - - lists = [] - for path, year in [('epoch1.lis', 2015.5), ('epoch2.lis', 2017.4)]: - sl = starlists.StarList.from_lis_file(path) - sl.meta['list_time'] = year # decimal year, UTC - lists.append(sl) - - msc = align.MosaicSelfRef(lists, - dr_tol=[1.0, 0.5, 0.3], - dm_tol=[2.0, 1.0, 1.0], - trans_class=transforms.PolyTransform, - trans_args=[{'order': 1}, {'order': 2}, {'order': 2}], - motion_models=['Linear']) - msc.fit() - - ref = msc.ref_table - ref['x0'], ref['vx'], ref['vx_err'], ref['motion_model_used'] - -Note ``motion_models=['Linear']``: the default is ``['Empty', 'Fixed']``, which -fits no proper motions at all. - Where to go next ================ +:doc:`installation` + Installing FlyStar from the repository, and the optional extras. + +:doc:`first_alignment` + A complete alignment of synthetic star lists that runs as written, and the + same call on your own lists. + :doc:`overview` The two data objects in full -- what lives in a ``StarList`` versus a ``StarTable``, and the column-naming conventions the code dispatches on. @@ -255,9 +90,12 @@ Where to go next the initial guess, and how to control which stars drive the fit. .. toctree:: + :maxdepth: 2 :hidden: + :caption: Getting started - Getting started + installation + first_alignment .. toctree:: :maxdepth: 2 diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..f3a07a0 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,26 @@ +============ +Installation +============ + +FlyStar is not on PyPI; install it from the repository: + +.. code-block:: bash + + git clone https://github.com/MovingUniverseLab/flystar.git + cd flystar + pip install -e . + +That pulls in the whole runtime set -- numpy, scipy, astropy, matplotlib, +pandas, joblib and tqdm -- all of which the package imports at module level. +Python 3.7 or newer. + +Three further packages are imported lazily, inside the functions that use them, +so install them only if you want those features: ``shapely`` +(polygon-restricted initial guesses via ``starlist_vertices``), ``astroquery`` +(Gaia queries in :mod:`flystar.analysis`) and ``plotly`` (interactive plots in +:mod:`flystar.plots`). They are grouped as an extra: + +.. code-block:: bash + + pip install -e '.[optional]' + diff --git a/docs/make_example_figures.py b/docs/make_example_figures.py index 275bb40..b35a1dc 100644 --- a/docs/make_example_figures.py +++ b/docs/make_example_figures.py @@ -1,5 +1,5 @@ """ -Regenerate the figures used by docs/index.rst and docs/examples/alignment_example.rst. +Regenerate the figures used by docs/first_alignment.rst and docs/examples/alignment_example.rst. Run from anywhere with flystar importable: diff --git a/docs/overview.rst b/docs/overview.rst index 914c34c..745fb19 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -106,7 +106,7 @@ a leap second could still be announced before then. Putting it together =================== -The landing page has the end-to-end example. In short: build one +:doc:`first_alignment` has the end-to-end example. In short: build one :class:`~flystar.starlists.StarList` per epoch, hand the list of them to :class:`~flystar.align.MosaicSelfRef` (self-defined frame) or :class:`~flystar.align.MosaicToRef` (external reference), call ``fit()``, and From 1e0ba5b2512b0a1115febc1ccfdc18e6c1e31f6a Mon Sep 17 00:00:00 2001 From: Lingfeng Wei Date: Tue, 3 Nov 2026 15:26:00 -0800 Subject: [PATCH 3/3] README: the docs are built from dev, and start at Getting started Co-Authored-By: Claude Opus 5.5 --- README.rst | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index 710253a..c635345 100644 --- a/README.rst +++ b/README.rst @@ -14,25 +14,25 @@ parallaxes, with uncertainties. Documentation ------------- -Two builds of the same source, both from the ``mm_rework_lingfeng`` branch: +Two builds of the same source, both from the ``dev`` branch: -* **Read the Docs** -- https://flystar.readthedocs.io/en/mm_rework_lingfeng/ +* **Read the Docs** -- https://flystar.readthedocs.io/en/dev/ Rebuilt on every push, and keeps a version per branch under ``/en//``. - Note that ``/en/latest/`` tracks the default branch, which does not have these + Note that ``/en/latest/`` does not track ``dev`` and does not have these pages. * **GitHub Pages** -- https://wei-lingfeng.github.io/flystar/ - An ad-free mirror of the same build. One version only: whatever - ``mm_rework_lingfeng`` last published. + A mirror without ads, built by ``.github/workflows/docs.yml`` on each push to + ``dev`` that changes ``docs/`` or ``flystar/``. One version only. -Start on either front page: it installs FlyStar and then builds a synthetic -four-epoch data set, aligns it, and checks the recovered positions and proper -motions against the truth that went in. +Start with "Getting started" in the sidebar: Installation, then First +alignment, which builds a synthetic four-epoch data set, aligns it, and checks +the recovered positions and proper motions against the truth that went in. .. warning:: - These describe the ``mm_rework_lingfeng`` branch, whose motion-model - framework does not exist on ``main``. The API reference on both sites is - generated from this branch's source. + These describe the ``dev`` branch, whose motion-model framework does not + exist on ``main``. The API reference on both sites is generated from this + branch's source. Installation ------------