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 ------------ 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/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 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