diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..5a3ca60 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,71 @@ +# Build the Sphinx docs and publish them to GitHub Pages. +# +# This is an ad-free mirror of the Read the Docs build, not a replacement: +# RTD still provides the per-branch versions (/en//) and rebuilds on +# every push. GitHub Pages serves a single site per repository, so this +# publishes one version -- the branch this workflow runs on. +# +# Before the first run, enable Pages on the repository: +# Settings -> Pages -> Build and deployment -> Source: "GitHub Actions" +# That needs repo admin. The site then appears at +# https://.github.io// +# +# Note the docs build never imports flystar (sphinx-autoapi reads the source +# statically), so the science stack is deliberately not installed here. + +name: docs + +on: + push: + branches: [mm_rework_lingfeng] + paths: + - 'docs/**' + - 'flystar/**' + - '.readthedocs.yaml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Let a newer run supersede an in-flight one, but never cancel a deploy midway. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: docs/requirements.txt + + - name: Install documentation requirements + run: | + python -m pip install --upgrade pip + python -m pip install -r docs/requirements.txt + + - name: Build HTML + run: | + python -m sphinx -b html -W --keep-going docs docs/_build/html + + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build/html + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..e8813d9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,79 @@ +# Build the distributions and publish them to PyPI. +# +# Authentication is PyPI Trusted Publishing (OpenID Connect), so there is no +# API token to store or rotate: PyPI verifies the workflow's identity directly. +# That needs a one-time setup on PyPI, under the project's +# Publishing -> Add a new pending publisher: +# +# PyPI project name : flystar +# Owner : MovingUniverseLab +# Repository : flystar +# Workflow name : publish.yml +# Environment : pypi +# +# Publishing is triggered by publishing a GitHub Release, not by pushing a tag, +# so creating the tag stays a reversible act and the upload takes a deliberate +# second step. The version comes from the tag via setuptools_scm, so the tag is +# what decides the version that is uploaded -- and a version can never be +# re-uploaded to PyPI, so it is worth being sure of. + +name: publish + +on: + release: + types: [published] + workflow_dispatch: # manual run, for a TestPyPI dry run + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # setuptools_scm needs the tags to find the version + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Build sdist and wheel + run: | + python -m pip install --upgrade build twine + python -m build + twine check dist/* + - name: Show what will be uploaded + run: ls -l dist/ + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish-testpypi: + # Manual runs go to TestPyPI, so the whole path can be rehearsed without + # burning a version number on PyPI proper. + if: github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + if: github.event_name == 'release' + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index b56c81e..29e26a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +*.png +*random*.fits +*random*.lis +*.DS_Store *.pyc .ipynb_checkpoints/ @@ -66,6 +70,7 @@ instance/ # Sphinx documentation docs/_build/ +docs/api/ # PyBuilder target/ @@ -106,3 +111,14 @@ ENV/ # mypy .mypy_cache/ +docs/jupyter_execute/ + +# Documentation figures are generated by docs/make_example_figures.py but are +# committed, so the docs build needs no science stack. Exempt them from the +# blanket *.png rule above. +!docs/_static/*.png + +# Written at build time by setuptools_scm (see setup.py use_scm_version). +# Tracking it makes every build dirty the tree, which appends a ".d" +# suffix to the version -- including on tagged release builds. +flystar/version.py diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..01b5a85 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,25 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + +sphinx: + configuration: docs/conf.py + fail_on_warning: false + +# flystar itself is deliberately NOT installed here. The API reference is +# generated by sphinx-autoapi from the source tree via static analysis, so the +# package never has to be imported and its science-stack dependencies +# (numpy/scipy/astropy/matplotlib) don't need to resolve for a docs build to +# succeed. +python: + install: + - requirements: docs/requirements.txt + +formats: + - htmlzip diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..0f50b8f --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,60 @@ +========= +Changelog +========= + +Unreleased +========== + +API changes to ``MosaicSelfRef`` and ``MosaicToRef`` +---------------------------------------------------- + +- ``dr_tol`` is now a required argument, given before the optional ones. + 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. +- The ``iters`` argument is gone. It had never been read -- the iteration + count has always come from the length of ``dr_tol`` -- so calls that passed + a disagreeing ``iters`` were already running the ``dr_tol`` schedule. Drop + it from the call. +- The number of iterations is now the length of the longest schedule given: + ``dr_tol``, ``dm_tol``, ``outlier_tol`` or ``trans_args``. Any of them may + be a single value, broadcast to that length, so ``dr_tol=0.5, + dm_tol=[1.0, 0.5]`` is two passes at a constant radius, and all single + values is a single pass. Two schedules of differing length remain an error. + ``mag_lim`` does not take part -- its ``[min, max]`` form is a pair, not a + schedule -- and is still checked against the resulting count. +- ``dm_tol`` now defaults to ``None``, which places no magnitude cut on the + match. The old default of ``[1.0]`` rejected pairs more than one magnitude + apart, which is wrong across filters and arbitrary within one. + +0.1.0 (2026-08-26) +================== + +First release on PyPI. + +FlyStar cross-matches and aligns stellar astrometry catalogues. It derives +the transformations between a stack of starlists and a common reference +frame, matches stars across them, and fits a motion model per star +(``Empty``, ``Fixed``, ``Linear``, ``Acceleration`` or ``Parallax``). + +``MosaicSelfRef`` builds the reference frame from the starlists themselves; +``MosaicToRef`` aligns them to an external catalogue such as Gaia. + +Notes for this release +---------------------- + +- The reference table records each star's identity in each starlist as + ``idx_in_list``, an integer index into that starlist, rather than as a copy + of the name. Use :func:`flystar.align.names_in_list` to recover the names. + Tables written by pre-release versions of the code carry a string + ``name_in_list`` column instead; ``flystar.plots`` and ``flystar.analysis`` + read either. +- ``jplephem`` is a hard requirement, not an extra: astropy uses it whenever a + solar-system ephemeris is evaluated, which the ``Parallax`` motion model + does. +- ``shapely``, ``astroquery`` and ``plotly`` are imported lazily by the + features that need them (polygon footprints for the initial guess, Gaia and + JPL Horizons queries, and interactive diagnostic plots respectively). Install + them with ``pip install flystar[optional]``. +- Tests are not included in the distributions; run them from a checkout of the + repository. diff --git a/MANIFEST.in b/MANIFEST.in index b2556fb..439300c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,17 +1,21 @@ include README.rst include CHANGES.rst -include setup.cfg -include LICENSE.rst include pyproject.toml - -recursive-include flystar *.pyx *.c *.pxd -recursive-include docs * +include setup.cfg recursive-include licenses * -recursive-include cextern * -recursive-include scripts * + +# Documentation sources, but not the example data: docs/examples carries ~10 MB +# of .lis/.fits inputs for the Gaia notebook, which Read the Docs takes from the +# repository rather than from an sdist. +recursive-include docs *.rst *.py *.ipynb *.txt Makefile make.bat +recursive-include docs/_static * +prune docs/examples/flystar_mosaic_to_gaia + +# Tests are not distributed -- see the packages.find exclude in pyproject.toml. +prune flystar/tests prune build prune docs/_build prune docs/api -global-exclude *.pyc *.o +global-exclude *.pyc *.pyo *.o __pycache__ diff --git a/README.rst b/README.rst index 17c607f..710253a 100644 --- a/README.rst +++ b/README.rst @@ -5,6 +5,51 @@ FlyStar :target: http://www.astropy.org :alt: Powered by Astropy Badge +FlyStar aligns many star lists into one common frame and fits each star's motion +there, choosing per star from a set of motion models. It cross-matches the stars +across every list, solves for the transformation that carries each list into a +common reference frame, and fits positions, proper motions and where wanted +parallaxes, with uncertainties. + +Documentation +------------- + +Two builds of the same source, both from the ``mm_rework_lingfeng`` branch: + +* **Read the Docs** -- https://flystar.readthedocs.io/en/mm_rework_lingfeng/ + 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 + 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. + +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. + +.. 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. + +Installation +------------ + +Not on PyPI; install from the repository:: + + git clone https://github.com/MovingUniverseLab/flystar.git + cd flystar + pip install -e . + +That pulls in 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 and only needed for the features that use them -- +``shapely``, ``astroquery`` and ``plotly`` -- grouped as an extra:: + + pip install -e '.[optional]' + License ------- @@ -21,29 +66,3 @@ Contributing We love contributions! FlyStar is open source, built on open source, and we'd love to have you hang out in our community. - -**Imposter syndrome disclaimer**: We want your help. No, really. - -There may be a little voice inside your head that is telling you that you're not -ready to be an open source contributor; that your skills aren't nearly good -enough to contribute. What could you possibly offer a project like this one? - -We assure you - the little voice in your head is wrong. If you can write code at -all, you can contribute code to open source. Contributing to open source -projects is a fantastic way to advance one's coding skills. Writing perfect code -isn't the measure of a good developer (that would disqualify all of us!); it's -trying to create something, making mistakes, and learning from those -mistakes. That's how we all improve, and we are happy to help others learn. - -Being an open source contributor doesn't just mean writing code, either. You can -help out by writing documentation, tests, or even giving feedback about the -project (and yes - that includes giving feedback about the contribution -process). Some of these contributions may be the most valuable to the project as -a whole, because you're coming to the project with fresh eyes, so you can see -the errors and assumptions that seasoned contributors have glossed over. - -Note: This disclaimer was originally written by -`Adrienne Lowe `_ for a -`PyCon talk `_, and was adapted by -FlyStar based on its use in the README file for the -`MetPy project `_. diff --git a/docs/Makefile b/docs/Makefile index fb03f26..df66e5c 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -4,6 +4,7 @@ # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build +SPHINXAUTOBUILD = sphinx-autobuild PAPER = BUILDDIR = _build @@ -12,7 +13,7 @@ PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest +.PHONY: help clean livehtml html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest #This is needed with git because git doesn't create a dir if it's empty $(shell [ -d "_static" ] || mkdir -p _static) @@ -20,6 +21,7 @@ $(shell [ -d "_static" ] || mkdir -p _static) help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" + @echo " livehtml to serve the docs at http://127.0.0.1:8000 and rebuild on every save" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @@ -45,6 +47,21 @@ html: @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." +# Live preview: serves the docs and rebuilds whenever a file changes, so you +# don't re-run `make html` after every fix. ../flystar is watched explicitly +# because sphinx-autobuild only watches the source dir by default, and the API +# reference is generated from the package source -- without it, editing a +# docstring would not trigger a rebuild. The generated api/ tree and myst-nb's +# jupyter_execute/ are ignored so their churn doesn't cause a rebuild loop. +# Requires: pip install -r requirements-dev.txt +livehtml: + $(SPHINXAUTOBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/livehtml \ + --watch ../flystar \ + --ignore "*/api/*" \ + --ignore "*/jupyter_execute/*" \ + --ignore "*/_build/*" \ + --open-browser + dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/_static/align_before_after.png b/docs/_static/align_before_after.png new file mode 100644 index 0000000..8a718d0 Binary files /dev/null and b/docs/_static/align_before_after.png differ diff --git a/docs/_static/align_proper_motion.png b/docs/_static/align_proper_motion.png new file mode 100644 index 0000000..ea94427 Binary files /dev/null and b/docs/_static/align_proper_motion.png differ diff --git a/docs/_static/align_residuals.png b/docs/_static/align_residuals.png new file mode 100644 index 0000000..3ec2253 Binary files /dev/null and b/docs/_static/align_residuals.png differ diff --git a/docs/_static/motion_model_performance.png b/docs/_static/motion_model_performance.png new file mode 100644 index 0000000..75fb787 Binary files /dev/null and b/docs/_static/motion_model_performance.png differ diff --git a/docs/_static/motion_model_performance_stars.png b/docs/_static/motion_model_performance_stars.png new file mode 100644 index 0000000..a312b66 Binary files /dev/null and b/docs/_static/motion_model_performance_stars.png differ diff --git a/docs/alignment.rst b/docs/alignment.rst new file mode 100644 index 0000000..33ff328 --- /dev/null +++ b/docs/alignment.rst @@ -0,0 +1,505 @@ +========= +Alignment +========= + +Alignment is the iterative loop that turns a pile of single-epoch star lists +into one cross-matched :class:`~flystar.startables.StarTable` in a common +coordinate frame. Two driver classes in :mod:`flystar.align` implement it, and +they differ only in what the common frame *is*. + +:class:`~flystar.align.MosaicSelfRef` + The frame is defined by the data itself. One list is nominated as the + reference for the first pass (``ref_index``, default 0); from the second + iteration onward the reference becomes the sigma-clipped mean position of + all lists. Use this when you have no external astrometric anchor and want a + self-consistent relative frame. + +:class:`~flystar.align.MosaicToRef` + The frame is an external reference list you supply -- Gaia, an HST catalog, + a previous alignment. The reference is not redefined by the data. + +Both are constructed with their parameters and then run with ``fit()``: + +.. code-block:: python + + from flystar import align, transforms + + msc = align.MosaicSelfRef( + list_of_starlists, + dr_tol=[1.0, 0.5, 0.3], # one entry per iteration + dm_tol=[2.0, 1.0, 1.0], + outlier_tol=[None, 3.0, 3.0], + trans_class=transforms.PolyTransform, + trans_args=[{'order': 1}, {'order': 2}, {'order': 2}], + motion_models=['Linear'], + matching='chi2', + ) + msc.fit() + + ref = msc.ref_table # the StarTable result + trans = msc.trans_list # the derived transform objects, one per list + +Note the per-iteration lists. ``dr_tol``, ``dm_tol``, ``outlier_tol`` and +``trans_args`` each take one entry per iteration, which is how you tighten +tolerances and raise the transformation order as the solution converges. + +The parameters, group by group +============================== + +Both aligners take the same arguments except where noted; there are a lot, so +they are grouped by what they control. Only ``list_of_starlists`` (and, for +:class:`~flystar.align.MosaicToRef`, ``ref_list``) is required. + +Per-iteration schedules +----------------------- + +These are the ones that take **one entry per iteration**, and are how the +solution converges: match loosely at first, then tighten as the transformation +becomes better known. + +.. list-table:: + :header-rows: 1 + :widths: 22 14 64 + + * - Parameter + - Default + - What it does + * - ``dr_tol`` + - required + - Match radius per iteration, or one value for all of them. In + reference-frame units, and must be generous enough on the first pass to + cover the initial frame offsets. + * - ``dm_tol`` + - ``None`` + - Match magnitude tolerance per iteration, or one value for all of them. + ``None`` matches on position alone, with no magnitude cut. + * - ``outlier_tol`` + - ``None`` + - Sigma clipping on the transformation residuals, per iteration, or one + value for all of them. ``None`` in a slot means no rejection that pass. + Rejection re-derives the transformation, so the rejected stars stop + influencing it. + * - ``trans_args`` + - ``{'order': 1}`` + - Arguments to ``trans_class``. A single dict applies to every iteration; + a list of dicts raises the order as the fit converges. + * - ``mag_lim`` + - ``None`` + - Magnitude range for deriving the transformation. ``[min, max]`` applies + everywhere; ``(N_iters, 2)`` varies by iteration; ``(N_iters, N_lists, + 2)`` varies by list as well. + * - ``ref_mag_lim`` + - ``None`` + - :class:`~flystar.align.MosaicToRef` only. Magnitude range on the + reference list. ``[min, max]`` applies everywhere; a sequence of + ``iters`` entries (each ``None`` or a ``[min, max]`` pair) varies by + iteration. There is exactly one reference list, so unlike ``mag_lim`` + there is no per-list form. + +The number of match/transform/average passes is the length of the longest +schedule given: ``dr_tol``, ``dm_tol``, ``outlier_tol`` or ``trans_args``. +Single values are broadcast to it, so ``dr_tol=0.5, dm_tol=[1.0, 0.5]`` is two +passes at a constant radius, and all single values is a single pass. Two +schedules of differing length are an error rather than a guess. ``mag_lim`` +and ``ref_mag_lim`` do not take part: a ``[min, max]`` pair is not itself a +schedule, so it is checked against the number of passes rather than setting +it. + +.. list-table:: + :header-rows: 1 + :widths: 56 44 + + * - Arguments + - Result + * - ``dr_tol=0.5`` + - One pass. + * - ``dr_tol=[1.0, 0.5]`` + - Two passes, tightening the radius. + * - ``dr_tol=0.5, dm_tol=[1.0, 0.5]`` + - Two passes at a constant radius, tightening the magnitude cut. + * - ``dr_tol=0.5, outlier_tol=[None, 3, 3]`` + - Three passes, no clipping on the first. + * - ``dr_tol=0.5, trans_args=[{'order': 1}, {'order': 2}]`` + - Two passes, raising the polynomial order once there are enough good + matches to constrain it. + * - ``dr_tol=0.5, mag_lim=[10, 15]`` + - One pass. ``mag_lim`` is a pair, not a schedule, so it does not add a + second. + * - ``dr_tol=[1.0, 0.5], outlier_tol=[None, 3, 3]`` + - ``AssertionError``: two schedules, of length 2 and 3. + * - ``dr_tol=[1.0, 0.5, 0.3], dm_tol=[1.0, 1.0]`` + - ``AssertionError``: two schedules, of length 3 and 2. + +Matching +-------- + +.. list-table:: + :header-rows: 1 + :widths: 22 14 64 + + * - Parameter + - Default + - What it does + * - ``matching`` + - ``'legacy'`` + - How a star with several candidates inside the tolerances is resolved. + See the section above; use ``'chi2'`` in crowded fields. + * - ``dchi2_tol`` + - ``9.0`` + - ``'chi2'`` only. How much better the winner must be than the runner-up, + in :math:`\chi^2`. Below it the star is treated as ambiguous and left + unmatched. 9.0 is a 3-sigma margin. + * - ``match_sigma_pos`` + - ``None`` + - ``'chi2'`` only. Position scale for the :math:`\chi^2`. ``None`` + measures it from each list's own unambiguous pairs, so no error columns + are needed. + * - ``match_sigma_mag`` + - ``None`` + - ``'chi2'`` only. Magnitude scale, measured the same way. + +The initial guess +----------------- + +The first pass has no transformation yet, so one has to be found blind. + +.. list-table:: + :header-rows: 1 + :widths: 22 14 64 + + * - Parameter + - Default + - What it does + * - ``init_guess_mode`` + - ``'miracle'`` + - ``'miracle'`` blind-matches triangles among the brightest stars, using + only positions and magnitudes. ``'name'`` intersects the ``name`` + columns -- cheaper and more robust, but only if your lists share a + naming scheme. + * - ``init_order`` + - ``1`` + - Order of the transformation used for that first guess. + * - ``briteN`` + - ``None`` + - ``'miracle'`` only. How many of the brightest stars to triangle-match. + ``None`` uses ``min(50, len(star_list))``. + * - ``ignore_contains`` + - ``'star'`` + - ``'name'`` only. Excludes names containing this substring; see the note + above. ``None`` matches on every name. + * - ``trans_input`` + - ``None`` + - A list of ready-made transform objects to start from, one per list. Skips + the blind guess entirely. + * - ``starlist_vertices`` + - ``None`` + - Polygon vertices per list, shape ``(N_lists, N_vertices, 2)``, confining + the initial guess to stars inside them. For partially overlapping lists. + :class:`~flystar.align.MosaicToRef` also takes ``reflist_vertex`` for the + reference. + +The transformation +------------------ + +.. list-table:: + :header-rows: 1 + :widths: 22 14 64 + + * - Parameter + - Default + - What it does + * - ``trans_class`` + - ``PolyTransform`` + - The transformation model. See :doc:`transformations`. + * - ``trans_weights`` + - ``None`` + - Which uncertainties weight the transformation fit: + ``'both,var'``, ``'list,var'``, ``'ref,var'`` (or the ``,std`` + variants). ``None`` is unweighted. Needs position errors to exist. + * - ``calc_trans_inverse`` + - ``False`` + - Also derive the inverse transformations, into ``trans_list_inverse``. + * - ``mag_trans`` + - ``True`` + - Fit a magnitude offset per list alongside the positional transformation. + +Which stars drive the fit +------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 22 14 64 + + * - Parameter + - Default + - What it does + * - ``mag_lim`` + - ``None`` + - Magnitude range used for deriving transformations, before ``mag_trans``. + Accepts ``[min, max]``, ``(N_iters, 2)`` for per-iteration limits, or + ``(N_iters, N_lists, 2)`` to vary by list as well. The single axis is + iterations, like ``dr_tol``. + * - ``iter_callback`` + - ``None`` + - Called with ``(ref_table, iteration)`` after every iteration, and once + more with the number of iterations. Set ``use_in_trans = False`` on a + row to drop that star from later transformations while keeping it in + the output. + * - ``ref_index`` + - ``0`` + - :class:`~flystar.align.MosaicSelfRef` only. Which list seeds the frame on + the first pass; later passes use the sigma-clipped mean of all lists. + * - ``ref_mag_lim`` + - ``None`` + - :class:`~flystar.align.MosaicToRef` only. Magnitude range on the + reference list, before ``mag_trans``. Accepts ``[min, max]`` for every + iteration, or a sequence of ``iters`` entries (each ``None`` or a + ``[min, max]`` pair) to vary by iteration -- the single axis is + iterations, like ``mag_lim``, but there is no per-list form since + there is exactly one reference list. + * - ``use_ref_new`` + - ``False`` + - :class:`~flystar.align.MosaicToRef` only. Whether stars newly added to + ``ref_table`` are used in subsequent transformations, or merely carried. + * - ``update_ref_orig`` + - ``False`` + - :class:`~flystar.align.MosaicToRef` only. Whether to update the reference + positions and motions as lists are transformed. Keep ``False`` to hold an + absolute frame such as Gaia; ``True`` treats the reference as an initial + guess. + +Motion fitting +-------------- + +These are handed straight to +:meth:`~flystar.startables.StarTable.fit_motion_models`; see +:doc:`motion_models`. + +.. list-table:: + :header-rows: 1 + :widths: 22 16 62 + + * - Parameter + - Default + - What it does + * - ``motion_models`` + - ``['Empty', 'Fixed']`` + - Candidate models. Each star gets the most complex one it has enough + epochs to support, unless a ``motion_model_input`` column requests + otherwise. Add ``'Linear'`` if you want proper motions at all -- the + default fits none. Accepts a single name, a list of names, the + :class:`~flystar.motion_model.MotionModel` subclasses themselves, or a + mixture; see the note below. + * - ``fixed_params_dict`` + - ``None`` + - Fixed model parameters, e.g. ``{'ra': ..., 'dec': ...}`` for + ``Parallax``. ``t0`` is computed per star if omitted. + * - ``vel_weights`` + - ``'var'`` + - Weighting scheme for the motion fit: ``'var'`` for + :math:`1/\sigma^2`, ``'std'`` for :math:`1/|\sigma|`. + * - ``absolute_sigma`` + - ``True`` + - ``scipy``'s convention for the reported errors. + +.. admonition:: Ways to name a motion model + :class: note + + ``motion_models`` accepts any of these, and mixes them freely: + + .. code-block:: python + + motion_models='Linear' # a single name + motion_models=['Linear'] # a list of names + motion_models=['Linear', 'Acceleration'] # several + motion_models=[motion_model.Linear] # the classes themselves + motion_models=[motion_model.Linear, 'Acceleration'] # mixed + motion_models='linear' # case does not matter + + The valid names are ``'Empty'``, ``'Fixed'``, ``'Linear'``, + ``'Acceleration'`` and ``'Parallax'``, matched case-insensitively, so + ``'linear'`` and ``'Linear'`` are the same model. Only the canonical + spelling propagates -- ``motion_model_used`` in the output reads ``'Linear'`` + however you spelled the input -- so there is still exactly one name to + compare against downstream. A genuine misspelling raises rather than being + silently ignored; the match is case-insensitive, not fuzzy. + + Whatever you pass, ``'Empty'`` and ``'Fixed'`` are always added if absent, + and the list is sorted by increasing parameter count, since that ordering is + what "the most complex model this star can support" is resolved against. So + ``motion_models='Linear'`` becomes ``['Empty', 'Fixed', 'Linear']``, and + ``msc.motion_models`` reports the expanded list rather than what you passed. + + ``trans_args`` is similar but per-iteration rather than per-star: a single + dict is broadcast to every iteration, so ``trans_args={'order': 1}`` and + ``trans_args=[{'order': 1}] * iters`` are equivalent. See + :doc:`transformations`. + +Output and bookkeeping +---------------------- + +.. list-table:: + :header-rows: 1 + :widths: 22 14 64 + + * - Parameter + - Default + - What it does + * - ``save_path`` + - ``None`` + - Directory for the results: ``PREFIX_input.txt``, + ``PREFIX_ref_table.hdf5``, ``PREFIX_trans_list.pkl``. + * - ``prefix_name`` + - ``'msr'`` / ``'mtr'`` + - Prefix for those file names. + * - ``save_object`` + - ``True`` + - Also pickle the whole mosaic object. Heavier and tied to flystar's class + definitions, but lets you reload and call methods like + ``calc_bootstrap_errors`` later. + * - ``save_plot`` + - ``True`` + - Also save a transformation diagnostic plot per (list, iteration). Real + cost on large lists -- an unthinned scatter at dpi=300 each time. + * - ``inherit_n_detect`` + - ``True`` + - If an input list carries its own ``n_detect`` (because it is itself the + output of an earlier align), add that instead of counting 1, so + ``n_detect`` totals raw detections. + * - ``verbose`` + - ``True`` + - ``0``-``9``, or ``False``/``True`` for least/most. + +What ``fit()`` produces +======================= + +``fit()``'s output is ``self.ref_table``. It carries both the full 2D record of +every matched measurement -- ``x``, ``y``, ``m`` and their uncertainties, one +column per input list -- and the collapsed per-star quantities: ``x0``, ``y0``, +``m0``, their ``_err`` counterparts, ``n_detect``, and the motion-model +parameters with ``motion_model_used`` recording which model each star got. + +``self.trans_list`` holds the derived :class:`~flystar.transforms.Transform2D` +objects, one per input list. If ``calc_trans_inverse=True`` was set, +``self.trans_list_inverse`` holds their inverses. + +Passing ``save_path`` writes the results to disk: ``PREFIX_input.txt`` (the fit +parameters), ``PREFIX_ref_table.hdf5``, and ``PREFIX_trans_list.pkl`` (the +transform objects need pickling, as they are not plain data). + +Matching +======== + +The ``matching`` keyword selects how a star with more than one candidate inside +the tolerances is resolved. + +``'legacy'`` (default) + The historical rule: a multi-candidate star is matched only if its nearest + candidate in position is also its nearest in magnitude. In a crowded field + this discards good matches -- a candidate 20x closer in position loses to + one a few hundredths of a magnitude nearer -- and each discarded star then + becomes a duplicate reference row that makes the next star list ambiguous in + turn, so one split seeds the next. + +``'chi2'`` + Candidates are scored as ``(dr/sigma_pos)**2 + (dm/sigma_mag)**2`` and only + reciprocal best pairs winning by ``dchi2_tol`` (default 9.0, a 3-sigma + margin) are kept; below that margin the star is treated as genuinely + ambiguous and left unmatched. The scales are measured from the star lists' + own unambiguous pairs by default, so no error columns are required. See + :func:`~flystar.match.match_chi2`. + +If you are aligning a crowded field and seeing single stars split into several +rows, ``matching='chi2'`` is the fix. + +The initial guess for the first transformation comes from +``init_guess_mode='miracle'`` (:func:`~flystar.match.miracle_match_briteN`, +a triangle match on the ``briteN`` brightest stars) at order ``init_order``. +Where the lists only partially overlap, ``starlist_vertices`` restricts the +initial guess to stars inside the given polygons. + +``init_guess_mode='name'`` is the alternative, and is much more robust when +your star lists already carry consistent names. It has one sharp edge worth +knowing: ``ignore_contains`` (default ``'star'``) excludes any name containing +that substring from the match. The default is deliberate -- auto-detected +sources labelled ``star_1``, ``star_2``, ... carry per-epoch detection indices +rather than stable identities, so matching on them would pair unrelated stars. +But it means a catalog whose names happen to contain ``'star'`` gets filtered +away. Pass ``ignore_contains=None`` when your names are genuinely stable across +epochs. The filter warns when it excludes anything, and ``''`` is rejected +rather than treated as "off". + +Controlling which stars drive the fit +===================================== + +Several mechanisms narrow the set of stars used to *derive* transformations, +without dropping them from the output. In order of increasing specificity: + +``mag_lim`` + A magnitude range per list. + +``ref_mag_lim`` + :class:`~flystar.align.MosaicToRef` only. The same idea, on the single + reference list rather than per starlist. + +``outlier_tol`` + Per-iteration sigma clipping on the transformation residuals. Rejection + triggers a re-derivation of the transformation, so the rejected stars do + not continue to influence it. + +The ``'w'`` column + An optional per-star weight column on an input + :class:`~flystar.starlists.StarList`. The weight entering the + transformation is ``star_list['w'] * ref_list['w'] * weight_from_keyword``. + This is the most flexible knob, letting you decide as a function of both + star and epoch what is good enough to constrain the transformation. Being + usable (``w > 0``) is necessary but not sufficient -- magnitude limits and + outlier rejection still take precedence. + +``iter_callback`` + A function called with ``(ref_table, iteration)`` at the end of every + iteration, and once more after the final re-matching pass with an index + equal to the number of iterations -- one past the last iteration -- so + that final call can be told apart from the end of the last iteration. The table handed in is the live + ``ref_table``, so setting ``use_in_trans = False`` on a row excludes that + star from subsequent transformations while keeping it in the output. + +.. code-block:: python + + def reject_bright_saturated(table, i): + if i == 0: + table['use_in_trans'][table['m0'] < 10.0] = False + + msc = align.MosaicSelfRef(lists, iter_callback=reject_bright_saturated, + dr_tol=[1., .5, .3], dm_tol=[2., 1., 1.]) + msc.fit() + +Parallelism +=========== + +``fit()`` takes ``processes`` for the per-star motion-model fitting (see +:doc:`motion_models` -- it only helps when ``bootstrap > 0``) and, +independently, ``match_workers`` for the KDTree neighbour search inside +:func:`~flystar.match.match`. + +``match_workers`` defaults to 1, deliberately: grabbing every core is the wrong +default on a shared machine. Set ``-1`` to use all cores, which is measurably +faster on large star lists and does not change the matching result -- the +per-query neighbour lists are identical, order included, regardless of thread +count. + +Getting to an absolute frame +============================ + +After a relative alignment, :mod:`flystar.analysis` provides the Gaia path: +:func:`~flystar.analysis.query_gaia`, +:func:`~flystar.analysis.prepare_gaia_for_flystar` and +:func:`~flystar.analysis.project_gaia` to build the reference list, then +:meth:`~flystar.startables.StarTable.shift_reference_frame` to apply the +resulting velocity and parallax offsets to both the fitted motion parameters +and the time-series astrometry. + +Note that :func:`~flystar.analysis.query_gaia` contacts the live Gaia TAP +service and the astroquery version pinned here exposes no timeout, so a slow +day at ESA will block rather than fail. Cache the catalog once and read it from +disk in anything automated. diff --git a/docs/benchmark_motion_models.py b/docs/benchmark_motion_models.py new file mode 100644 index 0000000..03be5a1 --- /dev/null +++ b/docs/benchmark_motion_models.py @@ -0,0 +1,225 @@ +""" +Time the motion-model fits on both branches and draw the figures used by the +Performance section of docs/motion_models.rst. + +Two sweeps: wall-clock against the number of epochs, at a fixed 10,000 stars, +and against the number of stars, at a fixed 5 epochs. + +The comparison spans two branches, so it needs two checkouts of flystar. Add +one as a worktree, time each sweep on each, then plot: + + git worktree add /tmp/wt_mmrework mm_rework + + python docs/benchmark_motion_models.py epochs /tmp/wt_mmrework \\ + mm_rework /tmp/epochs_per_star.json + python docs/benchmark_motion_models.py epochs . \\ + mm_rework_lingfeng /tmp/epochs_batched.json + + python docs/benchmark_motion_models.py stars /tmp/wt_mmrework \\ + mm_rework /tmp/stars_per_star.json + python docs/benchmark_motion_models.py stars . \\ + mm_rework_lingfeng /tmp/stars_batched.json + + python docs/benchmark_motion_models.py plot-epochs \\ + /tmp/epochs_batched.json /tmp/epochs_per_star.json + python docs/benchmark_motion_models.py plot-stars \\ + /tmp/stars_batched.json /tmp/stars_per_star.json + +Timing runs are separate processes so that each imports the flystar it is +timing, and are run one after another rather than concurrently so that they do +not compete for cores. The figures are committed, so building the documentation +never runs any of this. +""" +import contextlib +import io +import json +import os +import sys +import time +import warnings + +import numpy as np + +MODELS = ['Fixed', 'Linear', 'Acceleration', 'Parallax'] +FIXED_PARAMS = {'ra': 18.0, 'dec': -30.0, 'pa': 0.0, 'obsLocation': 'earth'} + +# The epoch sweep holds the number of stars fixed, and the star sweep holds the +# number of epochs fixed at a value where every model here is determined -- so +# that nothing in that sweep is a fallback. +EPOCHS = [2] + list(range(3, 21, 2)) +N_STARS = 10_000 +STARS = list(range(1000, 20_000, 2000)) +N_EPOCHS = 5 + + +def make_table(n_stars, n_epochs, seed=1): + """Build a StarTable of linearly moving stars, observed at every epoch.""" + from flystar.startables import StarTable + + rng = np.random.default_rng(seed) + t = np.tile(np.linspace(2015., 2015. + 2*(n_epochs - 1), n_epochs), (n_stars, 1)) + x0 = rng.uniform(0, 1000, (n_stars, 1)) + y0 = rng.uniform(0, 1000, (n_stars, 1)) + vx = rng.normal(0, 0.3, (n_stars, 1)) + vy = rng.normal(0, 0.3, (n_stars, 1)) + dt = t - t.mean() + x = x0 + vx*dt + rng.normal(0, 0.05, (n_stars, n_epochs)) + y = y0 + vy*dt + rng.normal(0, 0.05, (n_stars, n_epochs)) + m = np.tile(rng.uniform(12, 19, (n_stars, 1)), (1, n_epochs)) + e = np.full((n_stars, n_epochs), 0.05) + + return StarTable(name=np.array([f'S{i:06d}' for i in range(n_stars)]), + x=x, y=y, m=m, xe=e, ye=e, me=e, t=t) + + +def time_one(branch, model, n_epochs, n_stars=N_STARS): + """Time a single fit, and report which model the stars actually got.""" + from flystar import motion_model as MM + + tab = make_table(n_stars, n_epochs) + + t0 = time.perf_counter() + with contextlib.redirect_stdout(io.StringIO()): + if branch == 'mm_rework_lingfeng': + tab.fit_motion_models(motion_models=[model], + fixed_params_dict=FIXED_PARAMS, verbose=False) + else: + # The old API: one model for everything, and Parallax needs an + # instance carrying the parameters that are fixed_params_dict now. + mmd = ({'Parallax': MM.Parallax(RA=FIXED_PARAMS['ra'], + Dec=FIXED_PARAMS['dec'], + PA=FIXED_PARAMS['pa'], + obsLocation=FIXED_PARAMS['obsLocation'])} + if model == 'Parallax' else {}) + tab.fit_velocities(default_motion_model=model, motion_model_dict=mmd, + show_progress=False, verbose=False) + elapsed = time.perf_counter() - t0 + + # Which model the stars actually got, not just whether it was the one + # asked for: too few epochs for the requested model and both branches + # quietly fall back, so a cell can time something other than its label. + used = np.asarray(tab['motion_model_used']).astype(str) + names, counts = np.unique(used, return_counts=True) + return elapsed, float(np.mean(used == model)), str(names[counts.argmax()]) + + +def sweep_points(axis): + """The (x value, n_stars, n_epochs) of each cell of a sweep.""" + if axis == 'epochs': + return [(n_epochs, N_STARS, n_epochs) for n_epochs in EPOCHS] + return [(n_stars, n_stars, N_EPOCHS) for n_stars in STARS] + + +def run_timings(axis, flystar_dir, branch, out_json): + warnings.filterwarnings('ignore') + flystar_dir = os.path.abspath(flystar_dir) + sys.path.insert(0, flystar_dir) + import flystar + assert flystar.__path__[0].startswith(flystar_dir), flystar.__path__ + + print(f'{axis} sweep, branch={branch}, flystar={flystar.__path__[0]}', flush=True) + results = {} + for model in MODELS: + # One throwaway fit on a tiny table first. The first fit of a model in + # a process pays a one-time set-up the rest do not -- ~0.03 s for + # Parallax, roughly two thirds of a whole batched 10,000-star fit, + # which would otherwise land entirely on whichever cell was timed + # first. At the widest epoch grid point, so that the model being warmed + # up is one the stars actually get: where there are fewer epochs than + # parameters the fit falls back, warming the wrong path. + time_one(branch, model, max(EPOCHS), n_stars=50) + + for x, n_stars, n_epochs in sweep_points(axis): + elapsed, frac, used = time_one(branch, model, n_epochs, n_stars=n_stars) + results[f'{model}|{x}'] = {'sec': elapsed, 'frac_used': frac, + 'used_model': used, 'n_stars': n_stars, + 'n_epochs': n_epochs} + print(f' {model:13} {n_stars:6d} stars {n_epochs:2d} epochs: ' + f'{elapsed:8.3f}s ({frac*100:.0f}% got {model}, ' + f'most got {used})', flush=True) + + json.dump({'branch': branch, 'axis': axis, 'results': results}, + open(out_json, 'w'), indent=1) + print('wrote', out_json, flush=True) + + +def plot(axis, batched_json, per_star_json, out_png=None): + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + + if out_png is None: + name = ('motion_model_performance.png' if axis == 'epochs' + else 'motion_model_performance_stars.png') + out_png = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '_static', name) + batched = json.load(open(batched_json))['results'] + per_star = json.load(open(per_star_json))['results'] + xs = np.array([x for x, _, _ in sweep_points(axis)]) + + def series(res, model, key='sec'): + return np.array([res[f'{model}|{x}'][key] for x in xs]) + + colors = dict(zip(MODELS, plt.get_cmap('tab10').colors)) + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.2)) + + for model in MODELS: + c = colors[model] + per, bat = series(per_star, model), series(batched, model) + # A cell where the requested model was not what the stars actually got + # timed a fallback instead, so it is not a like-for-like comparison: + # draw it hollow, break the line rather than run it through a model it + # did not fit, and quote no ratio between two different models. + same = ((series(per_star, model, 'frac_used') == 1) + & (series(batched, model, 'frac_used') == 1)) + + ax1.plot(xs, np.where(same, per, np.nan), '--', color=c, lw=1.4, + label=f'{model}, per-star') + ax1.plot(xs, np.where(same, bat, np.nan), '-', color=c, lw=1.8, + label=f'{model}, batched') + for y in (per, bat): + ax1.plot(xs[same], y[same], 'o', color=c, ms=4) + ax1.plot(xs[~same], y[~same], 'o', ms=5, mfc='white', mec=c, mew=1.2) + ax2.plot(xs, np.where(same, per/bat, np.nan), 'o-', color=c, ms=4, + lw=1.8, label=model) + + if axis == 'epochs': + xlabel, held = 'Number of epochs', f'{N_STARS:,} stars' + ticks, ticklabels = xs, [str(x) for x in xs] + else: + xlabel, held = 'Number of stars', f'{N_EPOCHS} epochs' + ticks, ticklabels = xs, [f'{x//1000}k' for x in xs] + + ax1.set(xlabel=xlabel, ylabel='Seconds for one fit', yscale='log', + title=f'Fitting {held}') + if axis == 'stars': + # Both branches are linear in the number of stars, which is a straight + # line only on log-log; on a log y against a linear x it reads as a + # curve, which is the opposite of the point. + ax1.set_xscale('log') + ax2.set_xscale('log') + ax1.legend(fontsize=7.5, ncol=2, loc='center right') + ax2.set(xlabel=xlabel, ylabel='Speed-up (per-star / batched)', + title='Batched speed-up') + ax2.legend(fontsize=8) + + for ax in (ax1, ax2): + ax.set_xticks(ticks) + # Rotated on the star axis: ten labels bunch up towards the top of a + # log scale, and dropping every other one would leave an end unlabelled. + ax.set_xticklabels(ticklabels, rotation=45 if axis == 'stars' else 0, + ha='right' if axis == 'stars' else 'center') + ax.set_xticks([], minor=True) # a log axis adds its own otherwise + ax.grid(alpha=0.25, lw=0.6) + + fig.tight_layout() + fig.savefig(out_png, dpi=150) + print('wrote', out_png) + + +if __name__ == '__main__': + mode = sys.argv[1] + if mode in ('epochs', 'stars'): + run_timings(mode, *sys.argv[2:5]) + else: + plot(mode.replace('plot-', ''), *sys.argv[2:]) diff --git a/docs/conf.py b/docs/conf.py index 787ff2d..add8612 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,194 +1,168 @@ -# -*- coding: utf-8 -*- -# Licensed under a 3-clause BSD style license - see LICENSE.rst +# Configuration file for the Sphinx documentation builder. # -# Astropy documentation build configuration file. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this file. -# -# All configuration values have a default. Some values are defined in -# the global Astropy configuration which is loaded here before anything else. -# See astropy.sphinx.conf for which values are set there. - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# sys.path.insert(0, os.path.abspath('..')) -# IMPORTANT: the above commented section was generated by sphinx-quickstart, but -# is *NOT* appropriate for astropy or Astropy affiliated packages. It is left -# commented out with this explanation to make it clear why this should not be -# done. If the sys.path entry above is added, when the astropy.sphinx.conf -# import occurs, it will import the *source* version of astropy instead of the -# version installed (if invoked as "make html" or directly with sphinx), or the -# version in the build directory (if "python setup.py build_sphinx" is used). -# Thus, any C-extensions that are needed to build the documentation will *not* -# be accessible, and the documentation will not build correctly. +# This build documents the source tree it is checked out alongside: the API +# reference is generated by sphinx-autoapi from ../flystar by static analysis, +# so the package does NOT need to be importable (and its runtime dependencies +# do not need to be installed) for the docs to build. import os import sys import datetime -from importlib import import_module - -try: - from sphinx_astropy.conf.v1 import * # noqa -except ImportError: - print('ERROR: the documentation requires the sphinx-astropy package to be installed') - sys.exit(1) - -# Get configuration information from setup.cfg -from configparser import ConfigParser -conf = ConfigParser() - -conf.read([os.path.join(os.path.dirname(__file__), '..', 'setup.cfg')]) -setup_cfg = dict(conf.items('metadata')) - -# -- General configuration ---------------------------------------------------- - -# By default, highlight as Python 3. -highlight_language = 'python3' - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.2' - -# To perform a Sphinx version check that needs to be more specific than -# major.minor, call `check_sphinx_version("x.y.z")` here. -# check_sphinx_version("1.2.1") -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns.append('_templates') - -# This is added to the end of RST files - a good place to put substitutions to -# be used globally. -rst_epilog += """ -""" +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib # -- Project information ------------------------------------------------------ -# This does not *have* to match the package name, but typically does -project = setup_cfg['name'] -author = setup_cfg['author'] -copyright = '{0}, {1}'.format( - datetime.datetime.now().year, setup_cfg['author']) - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. - -import_module(setup_cfg['name']) -package = sys.modules[setup_cfg['name']] - -# The short X.Y version. -version = package.__version__.split('-', 1)[0] -# The full version, including alpha/beta/rc tags. -release = package.__version__ +# Metadata comes from pyproject.toml, which is the single source for it -- +# setuptools reads [project] in preference to setup.cfg's [metadata], so the +# latter no longer exists. +with open(os.path.join(os.path.dirname(__file__), '..', 'pyproject.toml'), 'rb') as _f: + _project = tomllib.load(_f)['project'] + +# The distribution is `flystar`, lower case, as pip requires; the documentation +# calls it FlyStar throughout, and this is what the sidebar's home link and the +# page titles show. +project = 'FlyStar' +author = ', '.join(a['name'] for a in _project.get('authors', [])) or 'the FlyStar authors' +copyright = '{0}, {1}'.format(datetime.datetime.now().year, author) + +# The package is deliberately not imported (see above), so take the version from +# installed metadata when it happens to be available and fall back to whatever +# Read the Docs is building otherwise. +try: + from importlib.metadata import version as _get_version + release = _get_version(project) +except Exception: + release = os.environ.get('READTHEDOCS_VERSION', 'dev') +version = release.split('+')[0] +# -- General configuration ---------------------------------------------------- -# -- Options for HTML output -------------------------------------------------- +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.mathjax', + 'sphinx.ext.napoleon', + 'sphinx.ext.viewcode', + 'autoapi.extension', + 'myst_nb', +] + +exclude_patterns = [ + '_build', + '**.ipynb_checkpoints', + 'Thumbs.db', + '.DS_Store', + # Written against the pre-mm_rework API and needs a live Gaia query. + # Kept in the tree, excluded from the build until it is updated. + 'examples/flystar_mosaic_to_gaia/**', +] -# A NOTE ON HTML THEMES -# The global astropy configuration uses a custom theme, 'bootstrap-astropy', -# which is installed along with astropy. A different theme can be used or -# the options for this theme can be modified by overriding some of the -# variables set in the global configuration. The variables set in the -# global configuration are listed below, commented out. +highlight_language = 'python3' +nitpicky = False + +# -- Napoleon (numpydoc-style docstrings) ------------------------------------- + +napoleon_google_docstring = False +napoleon_numpy_docstring = True +napoleon_use_param = True +napoleon_use_rtype = True + +# This codebase's docstrings use several non-standard section headings. Map each +# onto the standard section it actually means, so napoleon renders them as real +# parameter/return lists instead of leaving them as stray RST section titles. +napoleon_custom_sections = [ + ('Required Parameters', 'Parameters'), + ('Optional Parameters', 'Parameters'), + ('Input', 'Parameters'), + ('Inputs', 'Parameters'), + ('Optional Inputs', 'Parameters'), + ('Optional Input', 'Parameters'), + ('Outputs', 'Returns'), + ('Optional Keywords', 'Parameters'), + ('Required table columns (input as keywords)', 'Parameters'), + ('Optional table columns (input as keywords)', 'Parameters'), + ('Optional table meta data', 'Parameters'), + ('Output', 'Returns'), +] + +# -- sphinx-autoapi ----------------------------------------------------------- +# +# autoapi walks the source statically, so every module on this branch is picked +# up automatically -- including ones that post-date the old hand-maintained +# automodapi list (motion_model, parallax, startables ...). + +autoapi_dirs = ['../flystar'] +autoapi_root = 'api' +autoapi_type = 'python' +autoapi_member_order = 'groupwise' +autoapi_options = [ + 'members', + 'undoc-members', + 'show-inheritance', + 'show-module-summary', + 'imported-members', +] +autoapi_ignore = [ + '*/conftest.py', + '*/tests/*', + '*/_astropy_init*', + '*/version.py', + '*/setup_package*', + '*/conf.py', + '*/parallax_cache/*', + '*/extern/*', + '*/data/*', +] +autoapi_add_toctree_entry = False # the toctree entry lives in index.rst +autoapi_keep_files = False +autoapi_python_class_content = 'both' + +# -- myst-nb (renders the example notebooks) --------------------------------- +# +# Notebooks are rendered from their stored outputs and are never executed at +# build time: executing them would require the full science stack plus the data +# files each example reads. +nb_execution_mode = 'off' +myst_heading_anchors = 4 +myst_enable_extensions = ['dollarmath', 'amsmath', 'colon_fence'] +suppress_warnings = ['mystnb.unknown_mime_type'] -# Add any paths that contain custom themes here, relative to this directory. -# To use a different custom theme, add the directory containing the theme. -#html_theme_path = [] +# -- Intersphinx -------------------------------------------------------------- -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. To override the custom theme, set this to the -# name of a builtin theme or the name of a custom theme in html_theme_path. -#html_theme = None +intersphinx_mapping = { + 'python': ('https://docs.python.org/3/', None), + 'numpy': ('https://numpy.org/doc/stable/', None), + 'scipy': ('https://docs.scipy.org/doc/scipy/', None), + 'astropy': ('https://docs.astropy.org/en/stable/', None), + 'matplotlib': ('https://matplotlib.org/stable/', None), +} +# -- HTML output -------------------------------------------------------------- +html_theme = 'sphinx_rtd_theme' html_theme_options = { - 'logotext1': 'FlyStar', # white, semi-bold - 'logotext2': '', # orange, light - 'logotext3': ':docs' # white, light - } - - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = '' - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = '' - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '' - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -html_title = '{0} v{1}'.format(project, release) - -# Output file base name for HTML help builder. -htmlhelp_basename = project + 'doc' - - -# -- Options for LaTeX output ------------------------------------------------- - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [('index', project + '.tex', project + u' Documentation', - author, 'manual')] - - -# -- Options for manual page output ------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [('index', project.lower(), project + u' Documentation', - [author], 1)] - - -# -- Options for the edit_on_github extension --------------------------------- - -if setup_cfg.get('edit_on_github').lower() == 'true': - - extensions += ['sphinx_astropy.ext.edit_on_github'] - - edit_on_github_project = setup_cfg['github_project'] - edit_on_github_branch = "master" - - edit_on_github_source_root = "" - edit_on_github_doc_root = "docs" - -# -- Resolving issue number to links in changelog ----------------------------- -github_issues_url = 'https://github.com/{0}/issues/'.format(setup_cfg['github_project']) - -# -- Turn on nitpicky mode for sphinx (to warn about references not found) ---- -# -# nitpicky = True -# nitpick_ignore = [] -# -# Some warnings are impossible to suppress, and you can list specific references -# that should be ignored in a nitpick-exceptions file which should be inside -# the docs/ directory. The format of the file should be: -# -# -# -# for example: -# -# py:class astropy.io.votable.tree.Element -# py:class astropy.io.votable.tree.SimpleElement -# py:class astropy.io.votable.tree.SimpleElementWithContent -# -# Uncomment the following lines to enable the exceptions: -# -# for line in open('nitpick-exceptions'): -# if line.strip() == "" or line.startswith("#"): -# continue -# dtype, target = line.split(None, 1) -# target = target.strip() -# nitpick_ignore.append((dtype, six.u(target))) + 'navigation_depth': 3, + 'collapse_navigation': False, +} +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( + '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/', +} diff --git a/docs/examples/alignment_example.rst b/docs/examples/alignment_example.rst new file mode 100644 index 0000000..ebddf2e --- /dev/null +++ b/docs/examples/alignment_example.rst @@ -0,0 +1,246 @@ +Alignment example +================= + +This page builds a synthetic data set with numpy, aligns it, and checks the +answer against the truth that went in. Everything runs as-is -- no data files +needed -- so you can paste it into a session and watch it work. + +The problem we are setting up +----------------------------- + +250 stars in a 1000-pixel field, observed at four epochs two years apart. Each +epoch is deliberately given its own coordinate system -- a shift of up to 40 +pixels, a rotation of up to 0.7 degrees about the field centre -- because that +is the situation FlyStar exists to resolve. On top of that, each star has a +small proper motion, each measurement has 0.05 pixel noise, and roughly 12% of +stars go undetected in any given epoch. + +.. 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, 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) # pixels / year + 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 plus a small rotation. + 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])) + +.. admonition:: Names are not needed here + :class: note + + ``init_guess_mode='miracle'`` bootstraps the first transformation by + *blind triangle matching* on the brightest stars + (:func:`~flystar.match.miracle_match_briteN`) -- it uses only positions and + magnitudes, so the lists need share no naming scheme at all. That is the + realistic case: separate reductions rarely agree on labels. + + If your lists *do* carry consistent names, ``init_guess_mode='name'`` is + cheaper and more robust. It has one sharp edge -- ``ignore_contains``, + default ``'star'``, excludes any name containing that substring, since + auto-detected ``star_1``, ``star_2``, ... are per-epoch detection indices + rather than identities. Pass ``ignore_contains=None`` when your names really + are stable. See :doc:`../alignment`. + +Building one StarList per epoch +------------------------------- + +A :class:`~flystar.starlists.StarList` is one epoch's detection list. Note +``meta['list_time']``: that is how the motion fit learns when each list was +taken, and it is a **decimal year in UTC** (see :doc:`../overview`). + +.. code-block:: python + + lists = [] + for i, yr in enumerate(YEARS): + dt = yr - t0 + + # Where the stars really are on the sky at this epoch. + xt, yt = x0 + vx * dt, y0 + vy * dt + + # Now push them into this epoch's own frame: rotate about the centre, + # then shift. This is the distortion the alignment has to undo. + 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 this 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 + lists.append(sl) + +Aligning them +------------- + +``dr_tol``, ``dm_tol`` and ``trans_args`` take **one entry per iteration**. +That is how the solution converges: the first pass matches loosely, because +the frames are still tens of pixels apart, and each pass afterwards tightens +the tolerance now that the transformation is better known. + +.. code-block:: python + + msc = align.MosaicSelfRef( + lists, + dr_tol=[60., 10., 5.], # match radius, pixels, per iteration + dm_tol=[1., 1., 1.], # match magnitude tolerance + trans_class=transforms.PolyTransform, + trans_args={'order': 1}, # order 1 = shift + rotation + scale + motion_models='Linear', # fit x0, vx, y0, vy per star + init_guess_mode='miracle', # blind triangle match, no names needed + ) + msc.fit() + + ref = msc.ref_table + print(f"{len(ref)} stars; {int((ref['n_detect'] == 4).sum())} seen in all four epochs") + +.. code-block:: text + + 254 stars; 144 seen in all four epochs + +254 rows against the 250 we injected, and 144 stars detected in every epoch -- +close to the :math:`250 \times 0.88^4 \approx 150` you would expect from a 12% +per-epoch drop-out. The handful of extra rows are stars matched in only one or +two epochs. + +Did it work? +------------ + +The point of the alignment is that every epoch ends up in one frame. On the +left, each star is measured at four visibly different places, because each +epoch has its own coordinate system; grey lines join the four measurements of +a single star. On the right, the same stars after transformation. + +``ref_table['x_orig']``/``['y_orig']`` keep each list's *untransformed* +positions, so plotting them against ``['x']``/``['y']`` is the before-and-after +directly. Both are ``(N_stars, N_lists)``, one column per epoch. We zoom into a +corner of the field, since at full scale the offsets are smaller than the point +spacing: + +.. code-block:: python + + import matplotlib.pyplot as plt + + COLORS = ['C0', 'C3', 'C2', 'C4'] + WIN = (100, 320) # a zoomed corner of the 1000-pixel field + + xr, yr = np.asarray(ref['x_orig']), np.asarray(ref['y_orig']) + xt, yt = np.asarray(ref['x']), np.asarray(ref['y']) + good = np.asarray(ref['n_detect']) == len(YEARS) + sel = (good + & (np.asarray(ref['x0']) > WIN[0]) & (np.asarray(ref['x0']) < WIN[1]) + & (np.asarray(ref['y0']) > WIN[0]) & (np.asarray(ref['y0']) < WIN[1])) + + fig, (a1, a2) = plt.subplots(1, 2, figsize=(14, 6)) + panels = [(a1, xr[sel], yr[sel], 'Before: one star, four frames (grey joins the same star)'), + (a2, xt[sel], yt[sel], 'After: all epochs in the common frame')] + for ax, X, Y, title in panels: + if ax is a1: # join each star's four measurements + for i in range(X.shape[0]): + ax.plot(X[i], Y[i], '-', color='0.7', lw=0.8, zorder=1) + for j, yr_lab in enumerate(YEARS): + ax.scatter(X[:, j], Y[:, j], s=40, color=COLORS[j], + label=f'{yr_lab:.0f}', zorder=2) + ax.set_xlim(*WIN); ax.set_ylim(*WIN) + ax.set_xlabel('x (pixels)'); ax.set_ylabel('y (pixels)') + ax.set_title(title); ax.legend(title='epoch') + plt.tight_layout() + +.. image:: ../_static/align_before_after.png + :alt: Star positions before and after alignment + :align: center + +The transformed positions live in ``ref_table['x']`` and ``['y']``, which are +2D, ``(N_stars, N_lists)`` -- one column per epoch. The per-star averages are +``x0``/``y0``. + +Residuals are the real test. Subtracting each star's own fitted motion from its +measured positions should leave nothing but noise: + +.. code-block:: python + + good = np.asarray(ref['n_detect']) == len(YEARS) + xm, ym, _, _ = ref.infer_positions(YEARS) # model positions + dx = (np.asarray(ref['x']) - xm)[good] * 1000 # milli-pixels + dy = (np.asarray(ref['y']) - ym)[good] * 1000 + print(f"residual scatter: {np.nanstd(dx):.1f} / {np.nanstd(dy):.1f} mpix") + +.. code-block:: text + + residual scatter: 36.7 / 38.0 mpix + +.. code-block:: python + + fig, ax = plt.subplots(figsize=(8, 5)) + bins = np.linspace(min(dx.min(), dy.min()), max(dx.max(), dy.max()), 60) + ax.hist(dx.ravel(), bins=bins, alpha=0.7, color='steelblue', label='x') + ax.hist(dy.ravel(), bins=bins, alpha=0.7, color='indianred', label='y') + ax.axvline(0, color='k', ls='--', lw=1) + ax.set_xlabel('residual from fitted motion (milli-pixels)') + ax.set_ylabel('count') + ax.set_title(f'Post-alignment residuals (injected noise {ERR*1000:.0f} mpix)') + ax.legend() + plt.tight_layout() + +.. image:: ../_static/align_residuals.png + :alt: Post-alignment residual distribution + :align: center + +We injected 50 milli-pixels of noise and recovered 37. That is not the +alignment beating the noise -- it is the expected effect of fitting two +parameters per coordinate to four epochs, which absorbs part of the scatter: +:math:`50 \times \sqrt{1 - 2/4} = 35`. Getting 37 is the sign the fit is +behaving. + +Finally, the proper motions. We never told FlyStar what they were, and the +frames were rotating underneath them, so recovering them is the strongest check +that the alignment is right: + +.. code-block:: python + + name_to_i = {n: j for j, n in enumerate(names)} + idx = np.array([name_to_i.get(n, -1) for n in np.asarray(ref['name']).astype(str)]) + ok = good & (idx >= 0) + print(f"vx recovered to {np.std(np.asarray(ref['vx'])[ok] - vx[idx[ok]]) * 1000:.1f} mpix/yr") + +.. code-block:: text + + vx recovered to 19.3 mpix/yr + +.. code-block:: python + + fig, axes = plt.subplots(1, 2, figsize=(14, 6)) + for ax, v_true, v_fit, lab in [ + (axes[0], vx[idx[ok]], np.asarray(ref['vx'])[ok], 'v_x'), + (axes[1], vy[idx[ok]], np.asarray(ref['vy'])[ok], 'v_y')]: + ax.scatter(v_true, v_fit, s=25, alpha=0.7) + lo, hi = min(v_true.min(), v_fit.min()), max(v_true.max(), v_fit.max()) + ax.plot([lo, hi], [lo, hi], 'k--', lw=1) # 1:1, not a fit + ax.set_xlabel(f'true {lab} (pix/yr)') + ax.set_ylabel(f'recovered {lab} (pix/yr)') + ax.set_title(f'{lab}: scatter = {np.std(v_fit - v_true)*1000:.1f} mpix/yr') + ax.set_aspect('equal', adjustable='box') + plt.tight_layout() + +.. image:: ../_static/align_proper_motion.png + :alt: Recovered versus true proper motion + :align: center + +Proper motions recovered to about 20 milli-pixels per year against a 0.05 pixel +per-epoch measurement error over a six-year baseline -- so the alignment has +not absorbed the stellar motion into the frame solution, which is the failure +mode that matters here. diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/gaia_flystar_example.ipynb b/docs/examples/flystar_mosaic_to_gaia/gaia_flystar_example.ipynb similarity index 99% rename from docs/flystar/examples/flystar_mosaic_to_gaia/gaia_flystar_example.ipynb rename to docs/examples/flystar_mosaic_to_gaia/gaia_flystar_example.ipynb index e2922b8..e357464 100644 --- a/docs/flystar/examples/flystar_mosaic_to_gaia/gaia_flystar_example.ipynb +++ b/docs/examples/flystar_mosaic_to_gaia/gaia_flystar_example.ipynb @@ -489,7 +489,7 @@ "\n", "\n", "print(my_gaia)\n", - "msc = align.MosaicToRef(my_gaia, list_of_starlists, iters=2,\n", + "msc = align.MosaicToRef(my_gaia, list_of_starlists,\n", " dr_tol=[0.2, 0.1], dm_tol=[1, 1],\n", " trans_class=transforms.PolyTransform,\n", " trans_args=[{'order': 1}, {'order': 1}], \n", @@ -1552,7 +1552,7 @@ } ], "source": [ - "msc2 = align.MosaicToRef(my_gaia, list_of_starlists, iters=3,\n", + "msc2 = align.MosaicToRef(my_gaia, list_of_starlists,\n", " dr_tol=[0.2, 0.1, 0.05], dm_tol=[1, 1, 0.5],\n", " outlier_tol=[5, 3, 3], mag_lim=[1,19],\n", " trans_class=transforms.PolyTransform,\n", @@ -2968,7 +2968,7 @@ "source": [ "\n", "\n", - "msc3 = align.MosaicToRef(p3_ref_list, new_list_of_starlists, iters=4,\n", + "msc3 = align.MosaicToRef(p3_ref_list, new_list_of_starlists,\n", " dr_tol=[0.2, 0.1, 0.05, 0.04], dm_tol=[1, 1, 0.5, 0.5],\n", " outlier_tol=[5, 3, 3, 3], mag_lim=None,\n", " trans_class=transforms.PolyTransform,\n", diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/mag15jul23_ob150029_kp_rms_named.lis b/docs/examples/flystar_mosaic_to_gaia/mag15jul23_ob150029_kp_rms_named.lis similarity index 100% rename from docs/flystar/examples/flystar_mosaic_to_gaia/mag15jul23_ob150029_kp_rms_named.lis rename to docs/examples/flystar_mosaic_to_gaia/mag15jul23_ob150029_kp_rms_named.lis diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/mag15jun07_ob150029_kp_rms_named.lis b/docs/examples/flystar_mosaic_to_gaia/mag15jun07_ob150029_kp_rms_named.lis similarity index 100% rename from docs/flystar/examples/flystar_mosaic_to_gaia/mag15jun07_ob150029_kp_rms_named.lis rename to docs/examples/flystar_mosaic_to_gaia/mag15jun07_ob150029_kp_rms_named.lis diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/mag16jul14_ob150029_kp_rms_named.lis b/docs/examples/flystar_mosaic_to_gaia/mag16jul14_ob150029_kp_rms_named.lis similarity index 100% rename from docs/flystar/examples/flystar_mosaic_to_gaia/mag16jul14_ob150029_kp_rms_named.lis rename to docs/examples/flystar_mosaic_to_gaia/mag16jul14_ob150029_kp_rms_named.lis diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/mag16may24_ob150029_kp_rms_named.lis b/docs/examples/flystar_mosaic_to_gaia/mag16may24_ob150029_kp_rms_named.lis similarity index 100% rename from docs/flystar/examples/flystar_mosaic_to_gaia/mag16may24_ob150029_kp_rms_named.lis rename to docs/examples/flystar_mosaic_to_gaia/mag16may24_ob150029_kp_rms_named.lis diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/mag17jul14_ob150029_kp_rms_named.lis b/docs/examples/flystar_mosaic_to_gaia/mag17jul14_ob150029_kp_rms_named.lis similarity index 100% rename from docs/flystar/examples/flystar_mosaic_to_gaia/mag17jul14_ob150029_kp_rms_named.lis rename to docs/examples/flystar_mosaic_to_gaia/mag17jul14_ob150029_kp_rms_named.lis diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/mag17jul19_ob150029_kp.fits b/docs/examples/flystar_mosaic_to_gaia/mag17jul19_ob150029_kp.fits similarity index 100% rename from docs/flystar/examples/flystar_mosaic_to_gaia/mag17jul19_ob150029_kp.fits rename to docs/examples/flystar_mosaic_to_gaia/mag17jul19_ob150029_kp.fits diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/mag17jul19_ob150029_kp_rms_named.lis b/docs/examples/flystar_mosaic_to_gaia/mag17jul19_ob150029_kp_rms_named.lis similarity index 100% rename from docs/flystar/examples/flystar_mosaic_to_gaia/mag17jul19_ob150029_kp_rms_named.lis rename to docs/examples/flystar_mosaic_to_gaia/mag17jul19_ob150029_kp_rms_named.lis diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/mag17may21_ob150029_kp_rms_named.lis b/docs/examples/flystar_mosaic_to_gaia/mag17may21_ob150029_kp_rms_named.lis similarity index 100% rename from docs/flystar/examples/flystar_mosaic_to_gaia/mag17may21_ob150029_kp_rms_named.lis rename to docs/examples/flystar_mosaic_to_gaia/mag17may21_ob150029_kp_rms_named.lis diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/mag18aug21_ob150029_kp_rms_named.lis b/docs/examples/flystar_mosaic_to_gaia/mag18aug21_ob150029_kp_rms_named.lis similarity index 100% rename from docs/flystar/examples/flystar_mosaic_to_gaia/mag18aug21_ob150029_kp_rms_named.lis rename to docs/examples/flystar_mosaic_to_gaia/mag18aug21_ob150029_kp_rms_named.lis diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/mag19apr17_ob150029_kp_rms_named.lis b/docs/examples/flystar_mosaic_to_gaia/mag19apr17_ob150029_kp_rms_named.lis similarity index 100% rename from docs/flystar/examples/flystar_mosaic_to_gaia/mag19apr17_ob150029_kp_rms_named.lis rename to docs/examples/flystar_mosaic_to_gaia/mag19apr17_ob150029_kp_rms_named.lis diff --git a/docs/flystar/examples/flystar_mosaic_to_gaia/ob150029_label.txt b/docs/examples/flystar_mosaic_to_gaia/ob150029_label.txt similarity index 100% rename from docs/flystar/examples/flystar_mosaic_to_gaia/ob150029_label.txt rename to docs/examples/flystar_mosaic_to_gaia/ob150029_label.txt diff --git a/docs/examples/motion_model_example.ipynb b/docs/examples/motion_model_example.ipynb new file mode 100644 index 0000000..e34b912 --- /dev/null +++ b/docs/examples/motion_model_example.ipynb @@ -0,0 +1,1294 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "333cd262", + "metadata": {}, + "source": [ + "# Motion model examples" + ] + }, + { + "cell_type": "markdown", + "id": "1e4364ed", + "metadata": {}, + "source": [ + "- [1. Motion model](#1-motion-model)\n", + " - [1.1. Example: linear model fit](#11-example-linear-model-fit)\n", + " - [1.2. Example: acceleration model fit](#12-example-acceleration-model-fit)\n", + " - [1.3. Example: parallax model fit](#13-example-parallax-model-fit)\n", + "- [2. Fit motion model in StarTable](#2-fit-motion-model-in-startable)\n", + " - [2.1. Example: default fitting](#21-example-default-fitting)\n", + " - [2.2. Example: specify motion models](#22-example-specify-motion-models)\n", + " - [2.3. Example: specify the `motion_model_input` column](#23-example-specify-the-motion_model_input-column)\n", + " - [2.4. Example: infer positions](#24-example-infer-positions)\n" + ] + }, + { + "cell_type": "markdown", + "id": "4bd92a9d", + "metadata": {}, + "source": [ + "## 1. Motion model" + ] + }, + { + "cell_type": "markdown", + "id": "0d084c38", + "metadata": {}, + "source": [ + "Summary of currently implemented motion models" + ] + }, + { + "cell_type": "markdown", + "id": "faddd6d8", + "metadata": {}, + "source": [ + "The available models, their parameters and their position equations are\n", + "tabulated in the [Motion models](../motion_models.rst) page of the\n", + "documentation. This notebook demonstrates using them.\n" + ] + }, + { + "cell_type": "markdown", + "id": "6fdc98af", + "metadata": {}, + "source": [ + "Examples on using `flystar.MotionModel`:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "51c963a1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:15.800766Z", + "iopub.status.busy": "2026-08-24T01:33:15.800472Z", + "iopub.status.idle": "2026-08-24T01:33:15.854247Z", + "shell.execute_reply": "2026-08-24T01:33:15.853791Z" + } + }, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "markdown", + "id": "473b0674", + "metadata": {}, + "source": [ + "Imports" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ce4edb88", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:15.856366Z", + "iopub.status.busy": "2026-08-24T01:33:15.856188Z", + "iopub.status.idle": "2026-08-24T01:33:18.015291Z", + "shell.execute_reply": "2026-08-24T01:33:18.014763Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "from flystar import motion_model\n", + "from flystar.startables import StarTable\n", + "from flystar.motion_model import Empty, Fixed, Linear, Acceleration, Parallax" + ] + }, + { + "cell_type": "markdown", + "id": "8c0e8559", + "metadata": {}, + "source": [ + "Prepare data" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "86b6319d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.017598Z", + "iopub.status.busy": "2026-08-24T01:33:18.017285Z", + "iopub.status.idle": "2026-08-24T01:33:18.046530Z", + "shell.execute_reply": "2026-08-24T01:33:18.046037Z" + } + }, + "outputs": [], + "source": [ + "t = np.array([0, 1., 2.2, 3.5, 5.]) + 2020.0\n", + "x = np.array([0., 0.5, 2.1, 3.2, 8.0])\n", + "y = np.array([10.2, 8.5, 9.1, 10.5, 13.0])\n", + "xe = np.array([0.2, 0.5, 0.3, 0.4, 0.6])\n", + "ye = np.array([0.3, 0.2, 0.5, 0.2, 0.4])\n", + "t_test = np.linspace(2020.0, 2025.0, 100) # Test times for model evaluation" + ] + }, + { + "cell_type": "markdown", + "id": "b1a87102", + "metadata": {}, + "source": [ + "### 1.1. Example: linear model fit" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0926c0a8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.048433Z", + "iopub.status.busy": "2026-08-24T01:33:18.048247Z", + "iopub.status.idle": "2026-08-24T01:33:18.068399Z", + "shell.execute_reply": "2026-08-24T01:33:18.067999Z" + } + }, + "outputs": [], + "source": [ + "mm = Linear()\n", + "params, param_errs = mm.fit(t, x, y, xe, ye)" + ] + }, + { + "cell_type": "markdown", + "id": "1fad1962", + "metadata": {}, + "source": [ + "Evaluate model at time t:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "840693ae", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.070167Z", + "iopub.status.busy": "2026-08-24T01:33:18.069990Z", + "iopub.status.idle": "2026-08-24T01:33:18.089479Z", + "shell.execute_reply": "2026-08-24T01:33:18.089007Z" + } + }, + "outputs": [], + "source": [ + "x_model, y_model = mm.model(t, params)" + ] + }, + { + "cell_type": "markdown", + "id": "42fbd575", + "metadata": {}, + "source": [ + "Or if uncertainties of parameters is provided at the same time, the model will return the model uncertainties as well:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "8fcbdc5d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.091362Z", + "iopub.status.busy": "2026-08-24T01:33:18.091189Z", + "iopub.status.idle": "2026-08-24T01:33:18.110684Z", + "shell.execute_reply": "2026-08-24T01:33:18.110226Z" + } + }, + "outputs": [], + "source": [ + "x_model, y_model, xe_model, ye_model = mm.model(t, params, param_errs)" + ] + }, + { + "cell_type": "markdown", + "id": "6f9954ef", + "metadata": {}, + "source": [ + "Note that we did not provide the `fixed_params_dict` parameter in the `model` function, so the MotionModel will use the saved self.fixed_params_dict. One can also specify the fixed_params_dict as:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "6752e477", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.112458Z", + "iopub.status.busy": "2026-08-24T01:33:18.112290Z", + "iopub.status.idle": "2026-08-24T01:33:18.132771Z", + "shell.execute_reply": "2026-08-24T01:33:18.132352Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "{'t0': np.float64(2022.0454838983064)}" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mm.fixed_params_dict" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "eba675c8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.134507Z", + "iopub.status.busy": "2026-08-24T01:33:18.134334Z", + "iopub.status.idle": "2026-08-24T01:33:18.152973Z", + "shell.execute_reply": "2026-08-24T01:33:18.152566Z" + } + }, + "outputs": [], + "source": [ + "x_model, y_model, xe_model, ye_model = mm.model(t_test, params, param_errs, mm.fixed_params_dict)" + ] + }, + { + "cell_type": "markdown", + "id": "a2acbe90", + "metadata": {}, + "source": [ + "Define a helper function to visualize result" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "7dba325f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.154776Z", + "iopub.status.busy": "2026-08-24T01:33:18.154607Z", + "iopub.status.idle": "2026-08-24T01:33:18.176169Z", + "shell.execute_reply": "2026-08-24T01:33:18.175741Z" + } + }, + "outputs": [], + "source": [ + "def visualize_fit(t, x, y, xe, ye, x_model, y_model, xe_model, ye_model, mm_name, t_test=None):\n", + " if t_test is None:\n", + " t_test = t\n", + " x = np.atleast_2d(x)\n", + " y = np.atleast_2d(y)\n", + " xe = np.atleast_2d(xe)\n", + " ye = np.atleast_2d(ye)\n", + " x_model = np.atleast_2d(x_model)\n", + " y_model = np.atleast_2d(y_model)\n", + " xe_model = np.atleast_2d(xe_model)\n", + " ye_model = np.atleast_2d(ye_model)\n", + " \n", + " N_cases = x.shape[0]\n", + " fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 5))\n", + " for i in range(N_cases):\n", + " l0 = ax1.errorbar(t, x[i], yerr=xe[i], fmt='o', color=f'C{i%10}', label='Data')\n", + " l1, = ax1.plot(t_test, x_model[i], label=f'{mm_name} Fit')\n", + " l2 = ax1.fill_between(t_test, x_model[i] - xe_model[i], x_model[i] + xe_model[i], color=f'C{i%10}', alpha=0.3, label='Model Uncertainty')\n", + "\n", + " r0 = ax2.errorbar(t, y[i], yerr=ye[i], fmt='o', color=f'C{i%10}', label='Data')\n", + " r1, = ax2.plot(t_test, y_model[i], label=f'{mm_name} Fit')\n", + " r2 = ax2.fill_between(t_test, y_model[i] - ye_model[i], y_model[i] + ye_model[i], color=f'C{i%10}', alpha=0.3, label='Model Uncertainty')\n", + " ax1.set_xlabel('Time')\n", + " ax1.set_ylabel('X Position')\n", + " ax1.set_title(f'{mm_name} Motion Model Fit')\n", + " ax1.legend(\n", + " [l0, (l1, l2)], \n", + " ['Data', 'Model Fit'],\n", + " )\n", + " \n", + " ax2.set_xlabel('Time')\n", + " ax2.set_ylabel('Y Position')\n", + " ax2.set_title(f'{mm_name} Motion Model Fit')\n", + " ax2.legend(\n", + " [r0, (r1, r2)], \n", + " ['Data', 'Model Fit'],\n", + " )\n", + " plt.tight_layout()\n", + " plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "ad03fc67", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.177882Z", + "iopub.status.busy": "2026-08-24T01:33:18.177713Z", + "iopub.status.idle": "2026-08-24T01:33:18.291861Z", + "shell.execute_reply": "2026-08-24T01:33:18.291289Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA9wAAAHqCAYAAAD27EaEAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAAtBJJREFUeJzs3Qd4G+S1N/B/vGTLluQ95ZG99yaMsELYM1Aos6W3Ld3c3ra0XxldQHvL7aalt5dZRkNC2HsEKITs6ezhvYckD8my7O85x7GxHcuWbMtL/9/z+Ikly5Isg1+d9z1jXGtrayuIiIiIiIiIaFCFDO7dEREREREREREDbiIiIiIiIqIA4Qk3ERERERERUQAw4CYiIiIiIiIKAAbcRERERERERAHAgJuIiIiIiIgoABhwExEREREREQUAA24iIiIiIiKiAGDATURERERERBQADLhpxHvssccwbtw4bN261ettTpw4obeR245GH3zwgT7/3n6Gc845R7+ek5PTr8f4y1/+0uN9D+drd++99+pjh4SE4NixY6d8vb6+HmazWW9z6623DtrjDuRnbv9dyb++3K6nj2uuuUZvI5/La9AuNzdXL8vzIyIaC7iGt+EazjWcglfYcD8BosGQlpaGTz/9FBMnThzVL6jJZMI//vGPU4LL48ePawAnwWd/ScCdmJh4yn2PhNcuJiYGjz76KH7+8593uX7t2rVwu90IDw/HaPWrX/0KZ599dpfrEhIS9F953a1Wa5eA+7777sPKlSv7vbFCRDTajIR1aDBwDecazjWcesITbhoTDAYDli1bhqSkJIxUDQ0Nfd7muuuuw8cff4zDhw93uf7//u//kJGRgRUrVozJ105+7scffxwtLS1drpfNhyuvvBIREREYrSZPnqyvb+cPuU7I550DbiKiYDQS1qG+cA33jms4Ue8YcNOY0FOKcHu68r59+3D99dfDYrEgJSUFX/rSl2Cz2bp8f2trq54Az5s3D1FRUYiLi9O03+5pzm+//TYuv/xyDZIiIyMxadIkfPWrX0VlZWWX27U/9vbt2/V+5P582bk///zzkZmZqQF2OwlCJRi95ZZbNPW6O6fTibvuugvjx4/XwFQC82984xuora3tuI2clsrrsHHjxo605vYTVG/p1RL4n3vuubpjbzQacdppp+HVV1/tMVXw/fffx9e//nU9QZfT26uuugrFxcXwlfxOCgoK9PVtd+jQIX0O8rWe5Ofn48Ybb0RycrK+WZs+fTp++9vfnhK0y/O49tpr9eeQ/wbkjUFpaWmP9yllC5dddhni4+P19zt//nz861//QqB0TimX13LNmjX6uZyI91ViQEQ0VnAN5xrONZzGMgbcNOZdffXVmDJlCtatW4cf/ehHePrpp/G9732vy20kaP7ud7+L8847Dxs2bNDgWwJUCTLLyso6bnf06FEsX74cDz/8MN566y3cfffd+Oyzz3D66adr6nN3EnhKUC6p0X/961/7fK4SUEvK9xNPPAGPx6PXyeMUFhbitttuO+X2slFwxRVX4L//+79x0003aUB85513aoAu9WIul0tv98ILL2DChAkaQErannzIdd5IYC7fLxsTcsr8zDPPaMB66aWX4rnnnjvl9rfffrumfctr++tf/1rT3yUY9pWc+J5xxhldNhrkc9kUkKC/u4qKCv3dyGsjaegvvfSS/u6+//3v45vf/GbH7RobG/V6ud3999+vv4fU1FQNuruTTQPJIJCNCvldvfjii7oBI7cdSNArGwDNzc1dPnpy8cUXa/q5+POf/9zxe5LriYiCFddwruFcw2nUayUa4R599NFW+U91y5YtXm9z/PhxvY3ctt0999yj1/3617/ucts77rijNTIysrWlpUUvf/rpp3q73/72t11uV1BQ0BoVFdX6gx/8oMfHlO93u92teXl5+v0vvvjiKY999913+/Qzvv/++3r7tWvXth47dqx13Lhxra+88op+bc2aNa0rV67Uzy+++OLW7Ozsju974403evwZn3vuOb3+kUce6bhu5syZrWeddZZPr92yZctak5OTWx0OR8d1zc3NrbNmzWq1Wq0dr13770Ze087k+cj1JSUlvf7c7a9TRUWF3pfBYGitqqrSx0pLS2u999579XbR0dGtt9xyS8f3/ehHP9Lv++yzz7rc39e//nV97Q4ePKiXH3744VN+N+IrX/nKKT/ztGnTWufPn6+/084uueQSfS4ej6fL70r+7U377Xr6OHz4sN5GPpfXoJ38/n25byKi0YJrONdwruEU7HjCTWOepAh3NmfOHE3DLi8v18uvvPKKpu7KiWznU0g5CZ07d26XbtTyPV/72tc07TssLExPdbOzs/Vr+/fv73Fn3l+SGi5Ns+SEt6qqSk9avaVVv/fee/pv90ZokpocHR2Nd9991+/Hl87gcmovqfDSzKxdaGionqLLafvBgwf7fI1FXl6ez48rz1lS4v/5z3/itdde07Rvb53J5eeeMWMGlixZ0uV6ub3Ese2vi5xay8l89+d3ww03dLl85MgRHDhwAF/84hf1cuf/Di666CKUlJSc8jP76sEHH8SWLVu6fMh/P0RE1Deu4f7hGs41nEYedimnMa+9I3Q7qfdtTzcWkjIuQZrUd/dEUrHbU4NXrVqlNcE//elPMXv2bA1q5Xpp9tJ+f907r/bHl7/8ZU0hf+ihh7SmvH2MVHcSkEvg373RjGwgyIaBfN1fNTU1+nr09NzT09M7Htef19gX8lpK+rZsNMgmhqSCt29mdCeP31MX7+7PT/7t6fcqr01n7WUDkpIuHz3pXqfvK/nvZ9GiRf36XiKiYMc13D9cw7mG08jDgJuCnjT6kgD1o48+6ggUO2u/bu/evdi1a5fW80oDs86no97I/faH1H5L47MHHngAX/nKVzTo9vZGRE5hpaa5c9AtAbOcEC9evNjvx5YGb1JLLqe63bU3QpPXLBDkJP9///d/sXv3bj3p9kZ+bl+en9xu8+bNp9yue9O09ttL8zl57XsydepUP38aIiIKNK7hXXEN5xpOIw9TyinoXXLJJRqgFhUV6Ulk9w85ye4cPHcPyv/2t78N+msoAbY0ZJMmZdL925v2hmJPPfVUl+ulQZyklXVuOCbP25cTZzlpXrp0KdavX9/l9nKSL48jHdqlCV0gSEM6CbplFJh8eCM/l8ysli7wnUmzOfk9tc+9ln8dDoc2VetMmrt1D6alcZtsqPT034B8SGp6oPUnM4CIKJhxDe+KazjXcBp5eMJNo4bU5crokO6kxnYgpDP1f/zHf2gKt4yFOvPMM3XBkhNUGUslAbcEvdOmTdPRXtLpXAJ0GR318ssvdxllNZik27h89DVG7IILLsAPf/hD2O12/VnkdPiee+7RjuRSc91Ofo5nn31Wu4xLmrOMvWrfTOhOOnrLfUvAKinWUlstndvllF86lvf35N4X0hW9L9JlXoJr6eD9s5/9TFPPpUO7PEf5XbVvCNx88834n//5H/33l7/8pQbVUh/+5ptvnnKfsnFy4YUX6uspteAyXq26ulpr8yWwlw7ngTZr1iz995FHHtEAX35HUtPfPaWSiGi04Rp+Kq7hXMMpODDgplFDgsqeHD9+fMD3LcGW1GHLvxK0yWmu1ANLANvemEsapEmA/Z3vfEfHiEnttNQZv/POO8jKysJwkMBXxpjJLOdHH31Ug0pJr5NAW0ZMdT6Nv++++3QTQVLU5dRXgtSeNjDEWWedpW+OJHCX4FNeD2kgJyfFcpow3CR9/pNPPtEUcPmQzQbZRJCRZJ03KWR+uPwc8juTjRJ5vaQOXzYeZKxYZ7K5IOnn8hrKiDipg5NAV5qzyRzvoSDB9e9+9zv8/ve/18Z5MhpOfq/emscREY0WXMNPxTWcazgFh3HSqny4nwQRERERERHRWMMabiIiIiIiIqIAYMBNREREREREFAAMuImIiIiIiIgCgAE3ERERERERUQAw4CYiIiIiIiIKAAbcRERERERERAEwqudwy2zg4uJimEwmnWVIREQ0WshUTofDgfT0dISEBN/+N9dwIiIKhjV8VAfcEmxnZmYO99MgIiLqt4KCAlit1qB7BbmGExFRMKzhozrglpPt9h/UbDYP99MhIiLymd1u103j9rUs2HANJyKiYFjDR3XA3Z5GLsE2A24iIhqNgrUkims4EREFwxoefEVjREREREREREOAATcRERERERFRADDgJiIiIiIiIgqAUV3D7SuPxwO32z3cT4P8EB4ejtDQUL5mRERBjmv46MM1nIgoSAJumY9WWlqK2tra4X4q1A+xsbFITU0N2oZCRETBjGv46MY1nIgoCALu9mA7OTkZRqORgdsoepPV0NCA8vJyvZyWljbcT4mIiIYY1/DRiWs4EVGQBNySgtYebCckJAz30yE/RUVF6b8SdMvvkOnlRETBg2v46MY1nIgoCJqmtddsy8k2jU7tvzvW3xMRBReu4aMf13AiojEecLdj/e/oxd8dEVFw4zowevF3R0QUJAH3YGhoakbOj17VD/mciIiIRj6u30RENNwYcBMREREREREFAANuH3haWjs+33y8usvlQLj11ls1FUs+ZJZlSkoKzj//fPzf//0fWlpafL6fxx57TMdyEBERBaOhXr8F13AiIuqMAXcf3thbgvMe2thx+dZHt+D0B9/T6wNp9erVKCkpwYkTJ/D666/j7LPPxne+8x1ccsklaG5mWjsREdFIXL8F13AiImrHgLsXsih//antKLO7ulxfanPq9YFctA0GA1JTU5GRkYEFCxbgxz/+MV588UUNvuXkWjz00EOYPXs2oqOjkZmZiTvuuAN1dXX6tQ8++AC33XYbbDZbx2n5vffeq1976qmnsGjRIphMJn2MG264oWPmNRER0Wg3nOu34BpORETtGHB7IWln972ci56Sz9qvk68PRXpau3POOQdz587F+vXr9XJISAj+8Ic/YO/evXj88cfx3nvv4Qc/+IF+7bTTTsPvfvc7mM1mPSmXj+9///v6taamJvz85z/Hrl27sGHDBhw/flxT4IiIiEa7kbh+C67hRETBKWy4n8BIJbVeJTan16/LMi1fl9stn5gwZM9r2rRp2L17t37+3e9+t+P68ePHaxD99a9/HX/5y18QEREBi8WiJ9tyit3Zl770pY7PJ0yYoEH7kiVL9HQ8JiZmyH4WIiKiYFm/BddwIqLgwxNuL8odzkG93WBpbW3tmG35/vvvazM1STuX9PCbb74ZVVVVqK+v7/U+duzYgcsvvxzZ2dn6fStXrtTr8/Pzh+RnICIajThianQYqeu34BpORBR8azgDbi+STZGDervBsn//fj3NzsvLw0UXXYRZs2Zh3bp12LZtG/785z/rbdxut9fvl2B81apVepIttdxbtmzBCy+80JFqTkRENJqN1PVbcA0nIgo+TCn3Ysn4eKRZIrXBSk9VXnLGnGqJ1NsNFanR3rNnD773ve9h69at2q38t7/9rdZyi3/9619dbi9p5R6Pp8t1Bw4cQGVlJR544AFttCbkvoiIiMaCkbh+C67hRETBiSfcXoSGjMM9l87Qz9sSuD/Xflm+LrcLBJfLhdLSUhQVFWH79u341a9+pWngMhZMUscnTpyoAfcf//hHHDt2DE8++ST++te/drmPnJwcrct+9913NchuaGhAVlaWBuLt3/fSSy9p7TcREdFYMNzrt+AaTkRE7Rhw92L1rDQ8fOMCJJsNXa6XnXG5Xr4eKG+88QbS0tI0aJZ5nlKvLc3NZDRYaGgo5s2bp2PBHnzwQU0r/+c//4n777+/y31Ip/Kvfe1ruO6665CUlIRf//rX+q+MFVu7di1mzJihJ93//d//HbCfg4iIKJjWb8E1nIiI2o1rlQ4eo5TdbtdO3DJrWsZfdeZ0OnXcldQ7R0YOrE7L4XRj9r1v6eeP3bYYZ0xOCujOOA3+75CIaKCkycqMu9/Uz3N/dgGMEWEBW8OCwVCs4Vy/hw/XcCIaSRqGcQ3nCbcPOgfXUvPFYJuIiGjk4/pNRETDjU3TfCA7ICceuDjwvw0iIiIaNFy/iYhouPGEm4iIiIiIiIgBNxEREREREdHowBNuIiIiIiIiogBgwE1EREREREQUAAy4iYiIiIiIiAKAATcRERERERFRADDgJiIiIiIiIgqAoJzD7XR70ORpGZLHiggNQWR4KEaaDz74AGeffTZqamoQGxvr0/fk5OTgu9/9rn7469Zbb0VtbS02bNjQj2dLRETUJtjXcK7fRESjS1gwLtRv7SuFzekeksezRIZj1cxUvxZsCU4ff/xxfPWrX8Vf//rXLl+744478PDDD+OWW27BY489hpHk3nvvxX333XfK9W+//TZ+//vfo7W1teO6lStXYt68efjd7343xM+SiIhGq5G+hnP9JiIiBHvALbvislBHhoXCEBbYjHpXc9tjyWP6u0OemZmJZ599Fv/zP/+DqKgovc7pdOKZZ55BVlYWRqqZM2finXfe6XJdfHw8IiIihu05ERHR2DAa1nCu30RE1FnQ1nDLQm2MCAvox0DeDCxYsEAD6/Xr13dcJ5/LQj5//vwut3W5XPj2t7+N5ORkREZG4vTTT8eWLVu63Oa1117DlClTNHiXVPITJ06c8piffPIJzjzzTL2NPI7cZ319vV/POywsDKmpqV0+JNiWXf8rrrhCbyOfb9y4UU+9x40bpx89PR8iIqLRtoZz/SYioqANuCWlubGpGe7mFjQ1t8DV7AnohzyGPFbnVGp/3HbbbXj00Uc7Lv/f//0fvvSlL51yux/84AdYt26dpqFv374dkyZNwgUXXIDq6mr9ekFBAa666ipcdNFF2LlzJ26//Xb86Ec/6nIfe/bs0e+R2+3evRvPPfccPv74Y3zzm9/EYJNAe/ny5fjKV76CkpIS/ZAAn4hoJPO0fP63fPPx6i6XKfBG0xrO9ZuIiEZESnlzc7PW/f7zn/9EaWkp0tLS9PTz//2//4eQkMHfC2h0e7D0V+9hqF08Jw2Wtqxwv9x0002466679PRXToH//e9/a5q5NExpJyfQUtMt9dwXXnihXvf3v/9d66b/8Y9/4L/+67/06xMmTND0dLmfqVOnaoD94IMPdtzPb37zG9xwww0dDdEmT56MP/zhDzjrrLP0++Xk3BdyvzExMR2XZ8yYgc2bN3e5jcVi0VNvo9GoJ+BERCPdG3tLcM9L+zou3/roFqRZInHPpTOwelbasD63YDGa1nCu30RENCICbgn4pCmYnMxK7e/WrVt1V1gCsu985zsIdomJibj44ov19ZEddvlcruvs6NGjcLvdWLFiRcd14eHhWLJkCfbv36+X5d9ly5ZpsN1OTpg727ZtG44cOaKbH+3kMVtaWnD8+HFMnz7dp+cswfxLL73UcdlgMPTjJyciGlnB9tef2o7u55ylNqde//CNCxh0Uxdcv4mIaEQE3J9++ikuv/xyDSTbx05JUzAJvAMhKjwUn/34HLy6uwTmyHBERQR21Edjkwd2ae4ygJEikkLentb95z//+ZSvt6e6dQ6m269vv86XdDgJrKUrutRtd+dPkzY5uZaUdiKisUDSxu97OfeUYFvIdfJXVr5+/oxUhIZ0/TtMwb2Gc/0mIqJhD7iluZeccB86dEgbeu3atUvrhr2NipLmYPLRzm63+/V4EoBGRYQhPCwEEWEhMISFBvyNmjxW92DYH6tXr0ZTU5N+LjXW3UlwK0GuvG6SEi7kxFs2LdrTwyWtu/v8602bNp3S5GXfvn1DFizLc/Z4PEPyWERE/SW12iU2p9evS9AtX5fbLZ+YwBc6gEbbGs71m4iIhr1p2g9/+ENcf/31mDZtmqZBS/dtCRLlup7cf//9mm7e/hEMjbZCQ0M1JVw+5PPuoqOj8fWvf11rtd944w3k5uZqM7KGhgZ8+ctf1tt87Wtf09TzO++8EwcPHsTTTz99ygxv+V1IxsE3vvENbax2+PBhTQ3/1re+FZCfS7IZPvvsM61Pr6ys1BN2IqKRptzhHNTbjRYffvghLr30UqSnp2vA2X3TVvqvyNota1BcXBzOO+88/ZtOn+P6TUQ0cgxn49NhDbilE/ZTTz2lAaB015Za5f/+7//Wf3siDcRsNlvHh3TfHsh8zYam5oB+yGMMBrPZrB/ePPDAA7j66qu1SYucVEst9ptvvqlvgtpTwqWL+csvv4y5c+dqVsGvfvWrLvcxZ84cHdUlgfYZZ5yhmx8//elPtZFdIHz/+9/XNyNy+p6UlIT8/PyAPA4R0UAkmyIH9XajhTTklPXiT3/6U49fl6w0+Zo0ypQMK9lEXbVqFSoqKobk+Y2WNZzrNxHRyOjFct5DG7s0Pj39wff0+qEwrrW/M6sGgZxQy3gqOVVt94tf/EKD8AMHDvT5/ZJSLifdEnx3D0idTqc2+xo/fnyXDttOtwdv7SuFzenGULBEhmPVzNQB1XEHK2+/QyKioSI74LIoS4O0nhZLSTZOtUTi4x+e43cNd29r2EgiJ9wvvPACrrjiij5/lnfeeQfnnnuuT/fLNXxs4xpORCO58em4k//2t/GpP2v4sNZwS9pz9/FfcuoZyPRiCXwlAG7yDE0Kc0RoCINtIqJRSoJoGf0li7Uszq09LNby9WBumCZ9Rh555BF94yGn4oHENZyIiEZb49NhDbilPuyXv/ylpjzLWLAdO3bgoYce0s6egV6weeJMRES+kJ1v2QGXOdxl9s8bd6YG+RzuV155BV/4whd081zKj95+++1TRlcOZuPTdlzDiYhoNDU+HdaA+49//KPWCd9xxx0oLy/X5iwymuruu+8ezqdFRETUhQTVKyYlYva9b+nlx25bjDMmJwX1yfbZZ5+tTTal8eXf//53XHvttdo4LTk52Wvj0/vuu2/InycREQWn8hHS+HRYm6aZTCYdAZaXl4fGxkbtpC013DIyioiIaCTpHFwvGR8f1MG2kA7lMkpy2bJl+Mc//oGwsDD915vBbHxKREQ0WhqfDusJNxEREY0N0oO1c8p4dwaDQT+IiIiGgmyOp1ki+2x8KrcbsyfcQ4HznUcv/u6IiIZHXV2dpovLh5CJEfK5jHCUkWE//vGPsWnTJs1Qk7Get99+OwoLC7FmzZpBfR5cB0Yv/u6IaKQ0PhXdc9KGsvHpmD3hlrR06YBeXFysc57lsow2odFxSiJdb2Weq/wOWWJARDS0tm7dqjXa7e68807995ZbbsFf//pXHd35+OOPa/12QkICFi9ejI8++kgboA4GruGjF9dwIhpJVo+AxqdjNuCWQE3mN5eUlGjQTaOP0WjUDvbdR8cREVFgrVy5UgMnb9avXx/Qx+caPvpxDSeikWL1MDc+HbMBd/sOuQRszc3N8Hg8w/10yA8yj10a8DArgYgoOHENH724hhPRSBM6jI1Px3TALSRgCw8P1w8iIiIaPbiGExHRaMdcXSIiIiIiIqIAYMBNREREREREFAAMuImIiIiIiIgCgAE3ERERERERUQAw4CYiIiIiIiIKAAbcRERERERERAHAgJuIiIiIiIgoABhwExEREREREQUAA24iIiIiIiKiAGDATURERERERBQADLiJiIiIiIiIAoABNxEREREREVEAhAXiTomIiMYaY0QYTjxw8XA/DSIiIhpFeMJNREREREREFAAMuImIiIiIiIgCgAE3ERERERERUQAw4CYiIiIiIiIKAAbcRERERERERAHAgJuIiIiIiIgoABhwExEREREREQUAA24iIiIiIiKiAAgLxJ0SERERERERjQTGiDCceODiYXlsnnATERERERERBQADbiIiIiIiIqIAYMBNREREREREFAAMuImIiIiIiIgCgAE3ERERERERUQAw4CYiIiIiIiIKAAbcRERERERERAHAgJuIiIiIiIgoABhwExEREREREQUAA24iIiIiIiKiAGDATURERERERBQADLiJiIiIiIiIAoABNxEREREREVEAMOAmIiIiIiIiCgAG3EREREREREQBwICbiIiIiIiIKAAYcBMREREREREFAANuIiIiIiIiogBgwE1EREREREQUAAy4iYiIiIiIiAKAATcRERERERFRADDgJiIiIiIiIgoABtxEREREREREAcCAm4iIiIiIiCgAGHATERERERERBQADbiIiIiIiIqIAYMBNREREREREFAAMuImIiIiIiIgCgAE3ERERERERUQAw4CYiIiIiIiIKAAbcRERERERERAHAgJuIiIiIiIgoABhwExEREREREQUAA24iIiIiIiKiAGDATURERERERBQADLiJiIiIiIiIAoABNxEREREREVEAMOAmIiIiIiIiCgAG3ERERD5qaWmFp6V1zL9eH374IS699FKkp6dj3Lhx2LBhQ8fX3G43fvjDH2L27NmIjo7W29x8880oLi4e1udMREQ0EjHgJiIi8kFtQxM+PFyBbSeqx/zrVV9fj7lz5+JPf/rTKV9raGjA9u3b8dOf/lT/Xb9+PQ4dOoTLLrtsWJ4rERHRSBY23E+AiIhoJJMT7WMVddiRX4tiWyPmZcZirLvwwgv1oycWiwVvv/12l+v++Mc/YsmSJcjPz0dWVtYQPUsiIqKRjwE3ERGRF7YGN3YU1OBIeR2MEaGIi4rga9XT62Szaep5bKz3zQiXy6Uf7ex2O19LIiIa84Y9pbyoqAg33ngjEhISYDQaMW/ePGzbtm24nxYREQV5rfaRcgfeyi3FwVIHUs2RSDZFAuOG+5mNPE6nEz/60Y9www03wGw2e73d/fffr6fj7R+ZmZlD+jyJiIiCLuCuqanBihUrEB4ejtdffx25ubn4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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "visualize_fit(t, x, y, xe, ye, x_model, y_model, xe_model, ye_model, mm.name, t_test)" + ] + }, + { + "cell_type": "markdown", + "id": "98d3e2c4", + "metadata": {}, + "source": [ + "### 1.2. Example: acceleration model fit" + ] + }, + { + "cell_type": "markdown", + "id": "ede486e5", + "metadata": {}, + "source": [ + "Upon further inspection, acceleration model seems to be a better representation of the data" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "0a0d9d1f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.293613Z", + "iopub.status.busy": "2026-08-24T01:33:18.293432Z", + "iopub.status.idle": "2026-08-24T01:33:18.313825Z", + "shell.execute_reply": "2026-08-24T01:33:18.313239Z" + } + }, + "outputs": [], + "source": [ + "mm = Acceleration()\n", + "params, param_errs = mm.fit(t, x, y, xe, ye)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "b3d63417", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.315652Z", + "iopub.status.busy": "2026-08-24T01:33:18.315479Z", + "iopub.status.idle": "2026-08-24T01:33:18.414447Z", + "shell.execute_reply": "2026-08-24T01:33:18.414005Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA9wAAAHqCAYAAAD27EaEAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAA03VJREFUeJzs3QV4pNXZN/D/xt3ddd1hd1nfxaUUWqq0xSpYCy1V3r5fC+1bqBeKtdAWKYUaFCnFl91lWWHdJe4uk5GMz3fdJ5s02U0yzzOxSfL/XVfYSTLJTISc5z7nlhkej8cDIiIiIiIiIhpVAaP76YiIiIiIiIiIATcRERERERHRGOEJNxEREREREdEYYMBNRERERERENAYYcBMRERERERGNAQbcRERERERERGOAATcRERERERHRGGDATURERERERDQGGHATERERERERjQEG3DQqfvvb32LGjBmYP3++33xHN2zYoF4mynPPPYcHHnhg0PfJ9+qee+4Z9+f01FNPqceWl82bN5/1fo/Hg6KiIvV+X7939913H1566aWz3i6PN9TjjrUbbrhBPXZ0dDRMJtNZ76+qqkJAQMCo/1xG8jX3/qwqKys13W+wl29961vq4+W23K/X9u3b1dfZ2dnp09dFRFML1/CzcQ0fiGu4PlzDqT8G3DQq/vSnP6l/jx49il27dvG76mWx3rFjB770pS9N2PdJAs8//vGPZ719y5YtKCsrU+/31VAB99KlS9XXLf9OhODgYDidTvztb387631PPvnkiL5mfyBfg3x/+7/ccccdSE9PV7evuOKKAQH3vffey4CbiBSu4WfjGj4Q1/CxxTV8amPATSO2Z88eHDx4sO+CfrBAbiro7u4etc913nnnISsrCxPl05/+NF544QV0dXUNeLv87FauXImcnJxRf8yYmBj1dcu/EyEkJARXX31134Vl/1N92YmW78lkJtkl8v3t/yI/x9DQUHU7OTl5op8iEfkhruH6cQ0ff1zDuYZPZgy4acR6A+yf/vSnWLVqFf7617/CYrGcdb+6ujp85StfQXZ2tvrDmZGRgU984hNoamrqu4+kuH7zm99EQUGBChRSUlJw+eWX48SJE333sdvt+L//+z/Mnj1b3UcCiRtvvBEtLS1en6vWj83Ly8NHPvIRvPjii1iyZAnCwsLUiaB45JFHsG7dOvXcIiMjsWDB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yf+DAQEBgz5HIpq6uIZzDecaPhDXcBpLM+Q/4xngExEREREREU0HHAtGRERERERENAYYcBMRERERERGNAQbcRERERERERGOAATcRERERERHRGGDATURERERERDQGGHATERERERERjYEgTGJutxv19fWIjo7GjBkzJvrpEBERaSZTOY1GIzIyMhAQMP32v7mGExHRdFjDJ3XALcF2dnb2RD8NIiIin9XU1CArK2vafQe5hhMR0XRYwyd1wC0n271faExMzEQ/HSIiIs26urrUpnHvWjbdcA0nIqLpsIZP6oC7N41cgm0G3ERENBlN15IoruFERDQd1vDpVzRGRERERERENA4YcBMRERERERGNAQbcRERERERERGNgUtdwa+VyueBwOCb6aZAOwcHBCAwM5PeMiGia4xo++XANJyKaJgG3zEdrbGxEZ2fnRD8V8kFcXBzS0tKmbUMhIqLpjGv45MY1nIhoGgTcvcF2SkoKIiIiGLhNoossi8WC5uZm9Xp6evpEPyUiIhpnXMMnJ67hRETTJOCWFLTeYDsxMXGinw7pFB4erv6VoFt+hkwvJyKaPriGT25cw4mIpkHTtN6abTnZpsmp92fH+nsioumFa/jkxzWciGiKB9y9WP87efFnR0Q0vXEdmLz4syMimiYB92iw2J3I+95r6kVuExERkf/j+k1ERBONATcRERERERHRGGDArYHL7em7/WFF+4DXx8INN9ygUrHkRWZZpqam4qKLLsKf/vQnuN1uzZ/nqaeeUmM5iIiIpqPxXr8F13AiIuqPAbcXbxxpwIW/3tL3+g1P7saan21Sbx9Ll156KRoaGlBZWYnXX38dGzduxJ133omPfOQjcDqZ1k5EROSP67fgGk5ERL0YcA9DFuVbn92Hpi7bgLc3Gqzq7WO5aIeGhiItLQ2ZmZlYunQp/ud//gcvv/yyCr7l5Fr8+te/xoIFCxAZGYns7GzcdtttMJlM6n2bN2/GjTfeCIPB0Hdafs8996j3Pfvsszj33HMRHR2tHuPaa6/tm3lNREQ02U3k+i24hhMRUS8G3EOQtLN7Xz2GwZLPet8m7x+P9LRe559/PhYtWoQXX3xRvR4QEIDf/va3OHLkCJ5++mls2rQJ3/nOd9T7Vq1ahQceeAAxMTHqpFxevvWtb6n32e12/PjHP8bBgwfx0ksvoaKiQqXAERERia1b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zfI6xqM3/by2sxuz0qKxqigR4SFM66KJcdttt+HgwYPo6OjAvHnz8Morr/BHQUQ0Tk41GlHeakZGnPcT59cONagSNLneuG5lrq7661aTHVFhQViaEz/kNQ35J80/rSNHjmDBggUq6L7//vvhdrvVqfaFF16I73znO/j1r3+N119/fWyfLRGRBhJEbyttVSfWEmwHBw7+p04am/zyrZNq8YsOC8K3LpmFnETv3T4l/VxSwuakR2NlYSJrqGjC5efnY9OmTfjf//1fddK9cOFCLF26dMALERGNLuk0frC2E3HhIV6D4PIWE1473KBuf25Frq6xoXLdYbY7sTQ33uspOvkfzUfSMTExeOaZZ9RCfvPNN+Nvf/sbKioqsHLlSnXqnZ2dPbbPlIhI41zKD0pbUdVmUcF20BDBtjQd+eXbp9BosCI2PBjfvGimpt1pST+Xj5mfGYtleQncZSa/UVVVhRdeeAEJCQm46qqrmHVGRDSGZITovqoOdDtcyE0I81qC9odtFXB7gOV5CVien6D5caSjeb2hG/MyYlCkodyN/I/uHPAVK1aok+53330XkZGR6nSbwTYR+QODxYEPylpR025BbmIEggIChmxuIsF2i9GG+IhgfOviWZrmX5qsTjSbrFiUHad2mYc6OScab0888QS++c1vqqwzyUhLTk7mD4GISCfZVJ/7gzfV7WM/umTY3izH6rp6xoHGe8+M+/veWjU3W645PrciR9dzajBY1TXK4px4BARwBNhkpOtq8fnnn1f1YZJOfvz4cdx666247LLLcOedd6K7u3vsniURkRcdZjveL2lBbcfwwbakf/38zZMq2JY5md+5ZLamYFtOzlvMNizJicc5DLbJj1x66aX47ne/i4cffhgvvvgig20iojEmZWWH6wxIigz1uvkuKedbTrWo2zetzkekjmkmUvqGGVDXHZyCMg0C7k984hP4yle+gnvuuUedbs+aNQs///nPsXnzZrzxxhtYtGgRduzYMbbPlohoiBPrrSUtahc4LyFyyGC7vrMbP3vjpBqpkRoTqoJtLeM4DN0OtJvtOFeC7Zz4IdPUhUvyxU77sKJ9wOtEY8HlcuHQoUO47rrr+A0mIhqHU/C9le1wezyqJM1bwPzU9kp1+6I5qbq6i0v38xaTDQsyY5Gd4P0UnfyX5i2WhoYG7N+/H0VFRQPeLjXc0h1V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z4iIiCgWGX6nej8iIkrQgHvNmjXS1tYmDzzwgDQ0NEhNTY1s3rxZZs2aFbfngIYgaAyCLp3YYzt6ZRvB9hmzfDLd0N0T22qgJtuakaYp4jWlHu2cnudikE1ERESxy/DDMgOy5XA/IiKaGouBLmVJCjXcXq9Xurq6xOOZWhfxnoGgLLn/b/r5nRdVSk1p9rStbONX4j+2bcZAaEj3icTq9axchxR6siTXZTO1iQkRESXXGJaMZvrrT5Qu5RB9IRi5uohFl3IiolQ1kTFs2ruUJ4roALa60B33YBsdQ7FPNmqysU2G05YuhR6blOc4de/vHEdmQqW2ExERUfIaL8OvaBL7cBMR0fgYcE8j7DvZPRCUnkBQp5fdWVaZm++UMp9d8l1Z4rFnsLs4ERERmQJB9TlVeSMZfv/rKyvlvOp8ZtEREcUQA+6ovaTfXv8p+T8f1GsKtxmGDUP6AkMaZCNV3JqWJh6HVZYV+KTQnSV57kx9HkRERETxzvBDzTZL1oiIYovRXRxWsVEfjnTxIcMQpy1DU8XLchyS57JpAzRreprZT4OIiIiIiIjijAF3jA0fq8XuCYR0Cy8E0x67VRaXerXhGQJsTxZTxYmIiIiIiFIdA+4YdBTvGxzSALtvMCRpFou4bBlSmm2Xkmy75LoyuYpNREREREQ0AzHgnkSA3R8c0lVsBNjDBuq/08XnyJTFJR5NE0eQzVpsIiIiIiKimY0B9ykG2P7AkPg1wDbEbk3XjuLYPkzrsB2Z7ChOREREREREx2HAPQYE1kgT7wuGxDBkJMCuKnRpejgCbK/dyn2xiYiIiIiIaFwMuKNYLOHtwfqCQxpQLyz2iM+ZKT6HVTxZDLCJiIgoteC6p/bHV0730yAiSlkMuKPkOjPlgvn54s7K0MZnFkTgRERERERERJPAgDv6ZKSnaWdxIiIiIiIioqlKm/IjEBEREREREdEJGHATERERERERmYABNxEREREREZEJGHATERERERERmYABNxEREREREZEJGHATERERERERmYABNxEREREREZEJGHATERERERERmYABNxEREREREZEJGHATERERERERmYABNxEREREREZEJGHATERERERERmYABNxEREREREZEJGHATERERERERmYABNxEREREREZEJMiSJGYahH7u7u6f7qRAREU1IZOyKjGUzDcdwIiKaCWN4UgfcPT09+rG8vHy6nwoREdGkxzKv1zvjzh7HcCIimgljuMVI4qn14eFhqa+vF7fbLRaLJSYzFQjeDx8+LB6PJybPcabgueN543suefD/a2KcOwy/GKhLSkokLW3mVXhxDE8c/JvA88b3XPLg/9fkG8OTeoUbL66srCzmj4tfAANunrt44nuO52468H03/eduJq5sR3AMTzz8m8Dzxvdc8uD/1+QZw2felDoRERERERFRHDDgJiIiIiIiIjIBA+4oNptN7rvvPv1IE8NzNzk8b5PHc8dzNx34vktc/N3w3PE9lzz4/5Xnbia975K6aRoRERERERFRouIKNxEREREREZEJGHATERERERERmYABNxEREREREZEJUirg3rhxo6xcuVLcbrcUFBTItddeK3v27DnuPihZv//++3WTcrvdLhdeeKF89NFHI99vb2+Xb33rWzJ//nxxOBxSUVEhd955p3R1dR33OB0dHXLTTTfp/ms48HlnZ6ckq3ieu4ceekjOPvtsvU92drYku3idu9raWrnttttkzpw5+hiVlZXa+GFwcFCSUTzfc1dffbV+LysrS4qLi/X/a319vSSreJ67iEAgIMuWLROLxSI7duyQZBXPczd79mw9X9HHPffcE7fXmmw4hifHueMYzjE83u85juEcw5N+DDdSyKc//WnjqaeeMnbu3Gns2LHDuPLKK42Kigqjt7d35D4//vGPDbfbbfzxj380PvzwQ2PNmjVGcXGx0d3drd/Hbdddd53x4osvGvv27TNefvllo7q62rj++uuP+1mXX365UVNTY2zdulUPfH7VVVcZySqe5+7ee+81Hn30UWPdunWG1+s1kl28zt1f//pX45ZbbjFeeuklY//+/cYLL7xgFBQUGN/97neNZBTP9xzeb2+99ZZRW1trvPnmm8bq1av1SFbxPHcRd955p3HFFVegyabx3nvvGckqnudu1qxZxgMPPGA0NDSMHD09PXF/zcmCY3hynDuO4RzD4/2e4xjOMTzZx/CUCrhHa25u1ovDLVu26NfDw8NGUVGR/iIiBgYGNOj71a9+Ne7jPPfcc0ZmZqYRDAb16127dunjvv322yP3wcU8btu9e7eRCsw6d9HwHyYVAu7pOHcRjzzyiDFnzhwjFcTzvGGywmKxGIODg0YqMPvcbd682ViwYIHx0UcfJX3AHc9zh8H6pz/9qcmvIHVxDE+8cxeNYzjH8Hi/5yI4hk/s3HEMH5j2MTylUspHi6QG5OTk6MeDBw9KY2OjXHbZZSP3wT5sF1xwgWzduvWkj+PxeCQjI0O/fuuttzSN/Kyzzhq5z6pVq/S2kz1OMjHr3M0E8Tx3uE/k5yS7eJ03pBI988wzWtZgtVolFZh57pqamuSrX/2qPP3005p6lWrMft89/PDDkpubq+n4SMVN1hKQ6cAxPPHO3UzAMTyxzxvHcI7hyTiGp2zAjdX7devWybnnnis1NTV6G34BUFhYeNx98XXke6O1tbXJgw8+KF/72tdGbsN9UTcwGm4b73GSiZnnLtXF89zt379fHn/8cVm7dq0ku3ict+9973vidDr1D2ddXZ288MILkgrMPHd47FtuuUXfYytWrJBUY/b77q677pJnn31WXn31VfnmN78pjz32mHz961837fWkEo7hiXnuUh3H8MQ9bxzDOYYn8xieslOWODEffPCBvPHGGyd8D0Xvo39ho2+D7u5uufLKK2XRokXanOpkj3Gyx0k2Zp+7VBavc4eGX5dffrl8/vOfl9tvv12SXTzO2913361N5w4dOiQbNmyQL3/5y/KXv/wl6f/PmnnuMKGD761fv15Skdnvu+985zsjny9dulR8Pp987nOfG5kxp+n73XAMn/y5S2UcwxP3vHEM5xiezGN4Sq5wo/Pciy++qDMSZWVlI7cXFRXpx9EzHM3NzSfMhPT09GhA43K55Pnnnz8u9RSPgzTL0VpaWk54nGRj9rlLZfE6dwi2L7roIlm9erU8+eSTkuzidd7y8vJk3rx5cumll+qM5ebNm+Xtt9+WZGb2uXvllVf0HCEdC2lWVVVVejtWu2+++WZJZtPxtw6lR7Bv374YvpLUwzE8cc9dKuMYntjnjWM4x/CkHsONFIIGDd/4xjeMkpISY+/evWN+Hw0cHn744ZHbAoHACYX0XV1dxqpVq4wLLrjA8Pv9JzxOpGnatm3bRm5DA7VkbpoWr3OXig1X4nnujhw5op0Ub7jhBiMUChnJbDrecxF1dXX6//XVV181klG8zt2hQ4e0m2fkQId8nLc//OEPxuHDh41kNJ3vuz//+c96/nBeafp+NxzDY/O+5hjOMZxj+ORwDJ95Y3hKBdx33HGHntDXXnvtuBbufX19I/dBt0Tc509/+pNeQH7hC184rlU8Pp511lnGkiVLtFV89ONEBzjYFmzp0qXanRwH7p/M24LF89zhjYouxxs2bDBcLpd+jiNZt8uJ17k7evSoUVVVZVx88cUaeEffJxnF67xhYuzxxx/X9xi2BXvllVeMc88916isrNSOqckonv9fox08eDDpu5TH69xhu0hsZYNzdeDAAWPTpk16gXD11VdP22tPdBzDk+PccQznGB7P9xzHcI7hqTCGp1TAjQvBsQ7MwkbPfNx33306+2Gz2Yzzzz9ffxkRWPEa73FwsRnR1tZm3HjjjbrPGw583tHRYSSreJ67m2++ecz7JOtqY7zOHR5vvPsko3idtw8++MC46KKLjJycHH2M2bNnG2vXrtVJi2QVz/+vqRZwx+vcbd++XQd0DPpZWVnG/Pnz9TFPdSZ9JuIYnhznjmM4x/B4vuc4hnMMT4Ux3HLsyRMRERERERFRDKVk0zQiIiIiIiKi6caAm4iIiIiIiMgEDLiJiIiIiIiITMCAm4iIiIiIiMgEDLiJiIiIiIiITMCAm4iIiIiIiMgEDLiJiIiIiIiITMCAm4iIiIiIiMgEDLiJZrj7779fli1bNt1Pg4iIiCaIYzhR4rMYhmFM95MgInNYLJaTfv/mm2+WX/ziFxIIBCQ3N5e/BiIiogTBMZwoNTDgJkphjY2NI59v2rRJ7r33XtmzZ8/IbXa7Xbxe7zQ9OyIiIhoPx3Ci1MCUcqIUVlRUNHIgsMZs+ejbRqej3XLLLXLttdfKj370IyksLJTs7GzZsGGDhEIhufvuuyUnJ0fKysrkt7/97XE/6+jRo7JmzRrx+Xy6Wn7NNddIbW3tNLxqIiKi5McxnCg1MOAmohO88sorUl9fL//4xz/k0Ucf1aD8qquu0mB627ZtsnbtWj0OHz6s9+/r65OLLrpIXC6X/ps33nhDP7/88stlcHCQZ5iIiChOOIYTJRYG3ER0Aqxi//znP5f58+fLrbfeqh8RVH//+9+X6upqWb9+vWRmZsqbb76p93/22WclLS1NfvOb38iSJUtk4cKF8tRTT0ldXZ289tprPMNERERxwjGcKLFkTPcTIKLEs3jxYg2gI5BaXlNTM/J1enq6po03Nzfr19u3b5d9+/aJ2+0+7nEGBgZk//79cXzmREREMxvHcKLEwoCbiE5gtVqP+xq132PdNjw8rJ/j4xlnnCHPPPPMCY+Vn5/PM0xERBQnHMOJEgsDbiKasuXLl2sX9IKCAvF4PDyjRERESYJjOJG5WMNNRFN24403Sl5ennYmf/311+XgwYOyZcsWueuuu+TIkSM8w0RERAmKYziRuRhwE9GUORwO7U5eUVEh1113nTZNQ7O1/v5+rngTERElMI7hROayGIZhmPwziIiIiIiIiGYcrnATERERERERmYABNxEREREREZEJGHATERERERERmYABNxEREREREZEJGHATERERERERmYABNxEREREREZEJGHATERERERERmYABNxEREREREZEJGHATERERERERmYABNxEREREREZEJGHATERERERERmYABNxEREREREZHE3v8HvZLYXhzMEcAAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "x_model, y_model, xe_model, ye_model = mm.model(t_test, params, param_errs)\n", + "visualize_fit(t, x, y, xe, ye, x_model, y_model, xe_model, ye_model, mm.name, t_test)" + ] + }, + { + "cell_type": "markdown", + "id": "9d1f63b4", + "metadata": {}, + "source": [ + "Moreover, `MotionModel.model` is fully vectorized, and can infer positions of multiple stars at multiple times, and the resulting inferred positions has shape (N_stars, N_times). See the example below:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "d1e406c5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.416404Z", + "iopub.status.busy": "2026-08-24T01:33:18.416213Z", + "iopub.status.idle": "2026-08-24T01:33:18.438927Z", + "shell.execute_reply": "2026-08-24T01:33:18.438516Z" + } + }, + "outputs": [], + "source": [ + "xs = np.array([\n", + " [0., 0.5, 2.1, 3.2, 8.0],\n", + " [10.0, 8.9, 9.2, 7.4, 7.0],\n", + " [2.5, 6.2, 5.2, 3.2, 5.0]\n", + "])\n", + "\n", + "ys = np.array([\n", + " [10.2, 8.5, 9.1, 10.5, 13.0],\n", + " [8.0, 9.9, 8.2, 7.4, 7.0],\n", + " [5.2, 6.2, 4.7, 3.2, 6.0]\n", + "])\n", + "\n", + "xes = np.array([\n", + " [0.2, 0.5, 0.3, 0.4, 0.6],\n", + " [0.5, 0.2, 0.7, 0.3, 0.2],\n", + " [0.5, 0.7, 0.6, 0.4, 0.3]\n", + "])\n", + "\n", + "yes = np.array([\n", + " [0.3, 0.2, 0.5, 0.2, 0.4],\n", + " [0.2, 0.5, 0.6, 0.4, 0.2],\n", + " [0.4, 0.2, 0.3, 0.4, 0.5]\n", + "])" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "4adebbe8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.440755Z", + "iopub.status.busy": "2026-08-24T01:33:18.440580Z", + "iopub.status.idle": "2026-08-24T01:33:18.462265Z", + "shell.execute_reply": "2026-08-24T01:33:18.461856Z" + } + }, + "outputs": [], + "source": [ + "# fit() is vectorized across stars: hand it 2D (n_stars, n_epochs) arrays\n", + "# and every star is solved in one closed-form call -- no Python loop.\n", + "#\n", + "# Two things differ from the single-star call above:\n", + "# * t must be 2D as well; fit() dispatches on t.ndim.\n", + "# * the batch path does not fill in t0 for you, so pass it explicitly.\n", + "t2d = np.broadcast_to(t, xs.shape)\n", + "t0 = np.average(t2d, weights=1. / np.hypot(xes, yes), axis=1)\n", + "\n", + "# The batch path also returns chi2 alongside the parameters.\n", + "params, param_errs, chi2_x, chi2_y = mm.fit(\n", + " t2d, xs, ys, xes, yes, fixed_params_dict={'t0': t0}\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "4e0424df", + "metadata": {}, + "source": [ + "Once we have the params and param errors, we can infer the model positions at any given time." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "95745baa", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.464089Z", + "iopub.status.busy": "2026-08-24T01:33:18.463923Z", + "iopub.status.idle": "2026-08-24T01:33:18.483954Z", + "shell.execute_reply": "2026-08-24T01:33:18.483548Z" + } + }, + "outputs": [], + "source": [ + "# model() needs t0 too: self.fixed_params_dict is only remembered by the\n", + "# single-star path, so pass the same t0 the batch fit used.\n", + "x_model, y_model, xe_model, ye_model = mm.model(t_test, params, param_errs, {'t0': t0})" + ] + }, + { + "cell_type": "markdown", + "id": "06fdca50", + "metadata": {}, + "source": [ + "The inferred positions should have shape (N_stars, N_times):" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "54206834", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.485714Z", + "iopub.status.busy": "2026-08-24T01:33:18.485533Z", + "iopub.status.idle": "2026-08-24T01:33:18.505914Z", + "shell.execute_reply": "2026-08-24T01:33:18.505493Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "(3, 100)" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "x_model.shape" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "e6a4e42e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.507864Z", + "iopub.status.busy": "2026-08-24T01:33:18.507654Z", + "iopub.status.idle": "2026-08-24T01:33:18.616077Z", + "shell.execute_reply": "2026-08-24T01:33:18.615599Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "visualize_fit(t, xs, ys, xes, yes, x_model, y_model, xe_model, ye_model, mm.name, t_test)" + ] + }, + { + "cell_type": "markdown", + "id": "f7ae3e7f", + "metadata": {}, + "source": [ + "### 1.3. Example: parallax model fit" + ] + }, + { + "cell_type": "markdown", + "id": "08eceab5", + "metadata": {}, + "source": [ + "Parallax model requires some fixed parameters: `ra`, `dec`, `pa`, `obsLocation`, and `t0`.\n", + "- `ra` and `dec` are required parameters. \n", + "- `pa = 0` by default\n", + "- `obsLocation = 'earth'` by default\n", + "- `t0 = np.average(t, 1./np.hypot(xe, ye))` by default\n", + "\n", + "We need to provide the fixed parameters in the `fixed_params_dict`. Also, since we made up this data, the deviation from the parallax model is quite large. So we will choose `absolute_sigma=False` to rescale the uncertainties." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "018fc13a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.618032Z", + "iopub.status.busy": "2026-08-24T01:33:18.617852Z", + "iopub.status.idle": "2026-08-24T01:33:18.674769Z", + "shell.execute_reply": "2026-08-24T01:33:18.674071Z" + } + }, + "outputs": [], + "source": [ + "mm = Parallax()\n", + "fixed_params_dict = {'ra': 0., 'dec': 10., 'pa': 0., 'obsLocation': 'earth'}\n", + "params, param_errs = mm.fit(t, x, y, xe, ye, fixed_params_dict, absolute_sigma=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "73dafb1f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.676999Z", + "iopub.status.busy": "2026-08-24T01:33:18.676801Z", + "iopub.status.idle": "2026-08-24T01:33:18.779158Z", + "shell.execute_reply": "2026-08-24T01:33:18.778683Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA9wAAAHqCAYAAAD27EaEAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAA2pxJREFUeJzsnQd85GW1/k96z6T3nu19ly3sLiy9gyBYEJWioChiu5arXhXUC/YLCvIXVFBRQKSj9LLLUrb3nt57MpmeSTL/z3OyWbO72d1MMn2e74cfO5mZTH4zk8z7nnOe85wIl8vlEkIIIYQQQgghhHiUSM8+HCGEEEIIIYQQQhhwE0IIIYQQQgghXoIVbkIIIYQQQgghxAsw4CaEEEIIIYQQQrwAA25CCCGEEEIIIcQLMOAmhBBCCCGEEEK8AANuQgghhBBCCCHECzDgJoQQQgghhBBCvAADbkIIIYQQQgghxAsw4CZBwSOPPCIRERFHjujoaCkqKpKbbrpJmpub/XJON954o5SVlR11Hb7G9b7m7LPP1teloqJCXC7XcbevW7fuyGuH19Jd9u7dK3fccYfU1dVN6HXwFaPP6USv+Y9+9KMj9xnv3CfLVJ4z3iscE7nf2N/5scfu3bv1/cDlsfzud7+b1PtLCCGegGv1yeFazbUacK0OPxhwk6Di4Ycflvfff19ee+01ueWWW+Sxxx6TM888UywWi4Q7KSkpUltbK2+++eZxt/3pT3+S1NTUST82Au4777xz3KD1+9//vjzzzDPiz+f95JNPislkOup6JB6w+ZvK8/Y3SKDg9/3Yo7KyUm6++Wa9PBYu4oSQQIBr9YnhWs21mmt1+MGAmwQV8+bNk9NPP13OOecc+eEPfyjf+ta3NMh89tlnp/zYNptNgpmSkhJ9bRBcjwWBKALSj3/84175uQj+Fi9eLP7iyiuv1OD68ccfP+p6JB7wu+Gt5+0LEhIS9D099sD1UHjgMiGEBBpcq08M12qu1ST8YMBNgprRgKO+vl7/RRV2xYoVkpGRoZXNJUuWyB//+MfjZNaQA19++eXy9NNPa7AYHx+v3wvuv/9+WbNmjeTk5EhSUpLMnz9ffv7zn4vT6XT7/Ox2u/zXf/2XLFq0SAwGg57XypUr5bnnnjvqfggWIQ++7777jroeSYWoqCit6E+Ez3zmM/qc+vr6jnpscO211477PevXr5fzzjtPs+6JiYmyatUq+de//nXkdlSJP/rRj+plJDqOlaaPJ6/G8/7Od74j5eXlEhsbK4WFhXLbbbcddV5j34eXX35Z3ysEkrNmzTouaXAy8Lp++MMfPu578PXq1atlxowZ434fbl+4cKG+93hf8Bj79u077n54njNnzpS4uDiZPXu2/OUvfxn38QYGBuQnP/mJnj/um52drS0PnZ2d4g2OlZTjtdyzZ4+sXbv2yHvkL6k/IYSMhWv10XCt/g9cq7lWhwPR/j4BQqZCVVWV/ovgBkDy/PnPf14zyOCDDz6Q22+/Xfu8f/CDHxz1vVu3btUA63/+5380MERwDaqrq+W66647Eizu2LFD/vd//1f279/vViAIHA6H9PT0yDe+8Q0NOhGUvf7663L11Ver5O76668/EgwjUEJwjo3J0qVLtUKLAO673/2uXHDBBRP6eXicr33tayq1/8IXvqDXIeHwkY98ZFxpNX4mHnvBggV6PwSKkDpdccUV+hioDl922WVy11136XkgGYHAeLSyPR5Iblx11VXyxhtvaNANyf/OnTs1eTAqicbPGQWvL573f//3f0tubq784Q9/kM9+9rMybdo0TXxMBNwfSQO8nwiKEdgj8YDn0t3dfdz97777bn0+n/jEJ/Qy7oMAFsmQTZs2yfTp048E2wiaUUX/1a9+JUajUe+H9zUy8j/5yuHhYb3PO++8o6oLJC2QBMJzRs/e5s2bNZkwGQYHB4/6Gj937M8eBbJ+vM9IQOB5g7GvMyGE+Auu1UfDtZprNdfqMMNFSBDw8MMPo0Tt+uCDD1xOp9NlMplcL774ois7O9uVkpLiamtrO+57hoaG9L4/+tGPXJmZma7h4eEjt5WWlrqioqJcBw4cOOnPHX2Mv/zlL3r/np6eI7fdcMMN+jhjwde4/kQMDg7q4332s591LV68+Kjb7Ha7XldeXu7au3evKzc313XWWWfp95wK3G/u3LlHzmvp0qV6ec+ePfq6vf32265NmzbpZbyWo5x++umunJwcfT3HnuO8efNcRUVFR16zJ598Ur/3rbfeOu5nH/s6vPzyy3rfn//850fd74knntDrH3zwwaNer/j4eFd9ff2R62w2mysjI8P1+c9//pTPG49322236XnidfvGN76h199///2u5ORkfV6/+MUv9H61tbV6W29vryshIcF16aWX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vajXxz755BPL7kLVbifS/yHZPeCORLv1+5d/+RersrLSmgjC2W46WOFwOCyv12vZWajb7MUXX7RmzJhhffjhh7YOuMPZbtqJ/9u//VuIf4KJi/46etrsRPTV9NXh/Lz1o68eWZvRV7sj2lfHbEr5yfrTCLKzs82/+/btk8OHD8sll1wy8Brds+3CCy+ULVu2DPs+GRkZkpCQYO6/+eabJo188eLFA68599xzzWPDvU+st9tEF85209f0fx+7C1e7abrR008/bZY0JCYmip2Fss0aGhrkK1/5ivz85z83aVkTSag/a4888ojk5OSYVHxNybXrso9IoL+Onjab6Oiro7vd6KtH1mb01c6I99UE3MfXSNx7772ydOlSqa6uNg2j/2NQBQUFgxpM7/c/d7KmpiZ5+OGH5atf/erAY/paXZtxMn3sdO9jF6Fst4ksnO22d+9e2bBhg6xatUrsLhztdt9990laWpr5n+uBAwfkueeeEzsLZZvpe996663ms7Vw4UKZSEL9Wbv77rvl2WeflT/+8Y/yjW98Qx577DG56667QvbzTCT019HVZhMZfXX0tht9NX213frq2BiiPANtxJ07d8rmzZtPeU4XyJ/8yz35MdXe3i5XXHGFzJo1yxSpGu49hnsfOwl1u01U4Wo3Lfi1fPlyue666+TOO+8UuwtHu61evdoUndu/f7+sXbtWbr75Zvnd735n27/VULaZDuToc2vWrJGJJtSftXvuuWfg9ty5cyUrK0u+9KUvDYykI3K/m4nYX9NXR3e70VfTV9NXT/y+OuZnuLVK3fPPP29GL0pKSgYaprCw0Px78mhIY2PjKaMmHR0dJrBJT0+X3/72t4NSUPV9NJXjZEeOHDnlfewk1O02UYWr3bQDX7ZsmSxZskSeeOIJsbtwtVtubq5MmzZNPv/5z5tRzRdffFHeeustsaNQt9mmTZtM22haoKZgTZkyxTyus9233HKL2FUk/t+my4zUnj17gviTTDz019HXZhMVfXV0txt9NX31N+3WV1sxSgs4fP3rX7eKi4ut3bt3D/m8Fnh45JFHBh7zeDynFHhoa2uzzj33XOvCCy+0urq6Tnmf/qJpW7duHXhMC6jZtWhauNptohViCWe71dXVmWqLK1eutHw+n2Vnkfi89Ttw4ID5O/3jH/9o2Um42mz//v2m0mf/oZXxtb1+9atfWbW1tZbdRPKz9sILL5i20zZF5H43E6m/pq+O/najr6avpq+Onb46ZgPur33ta6bxX3311UHl3ru7uwdeo9UU9TW/+c1vzAXlDTfcMKgcv/67ePFia86cOaas/Invc2Kgo9uCzZ0711Qn10Nfb9dtwcLZbvqB1orHa9eutdLT081tPey4fU642u3gwYPWlClTrIsvvth05ie+xo7C1W56gb1hwwbz+dJtwTZt2mQtXbrUqqqqMpVU7SScf6Mn2rdvn62rlIer3XRrSN2CTtvp008/tTZu3GguHK6++uqI/ezRjv46utuMvpq+mr46uv9GT0RfbUWkr47ZgFsvDIc6dDb1xFGSBx54wIyUOJ1O63Of+5z5wPfTma/TvY9+oPs1NTVZN954o9lLTw+93dLSYtlRONvtlltuGfI1dptxDGe76fud7jV2FK5227lzp7Vs2TIrOzvbvEdFRYW1atUqM2hhN+H8G51InXi42m379u2mo9eLqOTkZGv69OnmPUc6wh6L6K+ju83oq+mr6auj+2/0RPTVVkT6asfxXzoAAAAAAAiimC+aBgAAAABAKBBwAwAAAAAQAgTcAAAAAACEAAE3AAAAAAAhQMANAAAAAEAIEHADAAAAABACBNwAAAAAAIQAATcAAAAAACFAwA1gWA8++KDMmzePVgIAIIrRXwPRyWFZlhXpkwAQGQ6HY9jnb7nlFvnBD34gHo9HcnJywnZeAADgM/TXgH0RcAMx7PDhwwO3N27cKPfff7/s2rVr4LGUlBRxu90ROjsAAKDorwH7IqUciGGFhYUDhwbWOoJ+8mMnp6jdeuutcs0118g///M/S0FBgWRmZsratWvF5/PJ6tWrJTs7W0pKSuSnP/3poO918OBBuf766yUrK8vMlq9YsUJqamoi8FMDAGAv9NeAfRFwAxi1TZs2yaFDh+RPf/qTrF+/3gTlV155pQmmt27dKqtWrTJHbW2teX13d7csW7ZM0tPTzdds3rzZ3F6+fLl4vV5+AwAAhAD9NRB5BNwARk1nsb///e/L9OnT5fbbbzf/alD993//9zJ16lRZs2aNJCUlyRtvvGFe/+yzz0pcXJz85Cc/kTlz5sjMmTPlySeflAMHDsirr77KbwAAgBCgvwYiLyHSJwDAfmbPnm0C6H6aWl5dXT1wPz4+3qSNNzY2mvvbt2+XPXv2iMvlGvQ+vb29snfv3jCeOQAAsYP+Gog8Am4Ao5aYmDjovq79HuqxQCBgbuu/CxYskKeffvqU98rLy+M3AABACNBfA5FHwA0g5ObPn2+qoOfn50tGRgYtDgBAFKK/BoKPNdwAQu7GG2+U3NxcU5n89ddfl3379slrr70md999t9TV1fEbAAAgCtBfA8FHwA0g5FJTU0118rKyMrn22mtN0TQtttbT08OMNwAAUYL+Ggg+h2VZVgjeFwAAAACAmMYMNwAAAAAAIUDADQAAAABACBBwAwAAAAAQAgTcAAAAAACEAAE3AAAAAAAhQMANAAAAAEAIEHADAAAAABACBNwAAAAAAIQAATcAAAAAACFAwA0AAAAAQAgQcAMAAAAAEAIE3AAAAAAASPD9f2VoxtqjfOJBAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "x_model, y_model, xe_model, ye_model = mm.model(t_test, params, param_errs)\n", + "visualize_fit(t, x, y, xe, ye, x_model, y_model, xe_model, ye_model, mm.name, t_test)" + ] + }, + { + "cell_type": "markdown", + "id": "5be8fb7e", + "metadata": {}, + "source": [ + "## 2. Fit motion model in StarTable" + ] + }, + { + "cell_type": "markdown", + "id": "3bd8dec7", + "metadata": {}, + "source": [ + "Examples on `flystar.StarTable.fit_motion_models`. Prepare the data with invalid values:" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "aa698e86", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.781036Z", + "iopub.status.busy": "2026-08-24T01:33:18.780851Z", + "iopub.status.idle": "2026-08-24T01:33:18.806251Z", + "shell.execute_reply": "2026-08-24T01:33:18.805828Z" + } + }, + "outputs": [], + "source": [ + "x = np.array([\n", + " [0., 0.5, 2.1, 3.2, 8.0], # Increasing 5 Epochs\n", + " [10.0, 8.9, 9.2, 7.4, 7.0], # Decreasing 5 Epochs\n", + " [2.5, np.nan, 5.2, np.nan, 5.0], # 3 Epochs\n", + " [np.nan, 6.2, np.nan, np.nan, 9.2], # 2 Epochs\n", + " [np.nan, 2.0, np.nan, np.nan, np.nan], # 1 Epoch\n", + " [np.nan, np.nan, np.nan, np.nan, np.nan] # All NaNs\n", + "])\n", + "\n", + "y = np.array([\n", + " [10.2, 8.5, 9.1, 10.5, 13.0], # Increasing 5 Epochs\n", + " [8.0, 9.9, 8.2, 7.4, 7.0], # Decreasing 5 Epochs\n", + " [5.2, np.nan, 4.7, np.nan, 6.0], # 3 Epochs\n", + " [np.nan, 1.2, np.nan, np.nan, 3.2], # 2 Epochs\n", + " [np.nan, 2.0, np.nan, np.nan, np.nan], # 1 Epoch\n", + " [np.nan, np.nan, np.nan, np.nan, np.nan] # All NaNs\n", + "])\n", + "\n", + "xe = np.array([\n", + " [0.2, 0.5, 0.3, 0.4, 0.6],\n", + " [0.5, 0.2, 0.7, 0.3, 0.2],\n", + " [0.5, np.nan, 0.6, np.nan, 0.3],\n", + " [np.nan, 0.6, np.nan, np.nan, 0.3],\n", + " [np.nan, 0.4, np.nan, np.nan, np.nan],\n", + " [np.nan, np.nan, np.nan, np.nan, np.nan]\n", + "])\n", + "\n", + "ye = np.array([\n", + " [0.3, 0.2, 0.5, 0.2, 0.4],\n", + " [0.2, 0.5, 0.6, 0.4, 0.2],\n", + " [0.7, np.nan, 0.5, np.nan, 0.2],\n", + " [np.nan, 0.4, np.nan, np.nan, 0.5],\n", + " [np.nan, 0.5, np.nan, np.nan, np.nan],\n", + " [np.nan, np.nan, np.nan, np.nan, np.nan]\n", + "])\n", + "\n", + "x = np.ma.masked_invalid(x)\n", + "y = np.ma.masked_invalid(y)\n", + "xe = np.ma.masked_invalid(xe)\n", + "ye = np.ma.masked_invalid(ye)\n", + "mask = np.ma.getmaskarray(x) | np.ma.getmaskarray(y) | np.ma.getmaskarray(xe) | np.ma.getmaskarray(ye)\n", + "\n", + "tab = StarTable({\n", + " 'x': x,\n", + " 'y': y,\n", + " 'xe': xe,\n", + " 'ye': ye\n", + "})\n", + "tab.meta['list_times'] = t" + ] + }, + { + "cell_type": "markdown", + "id": "9201897f", + "metadata": {}, + "source": [ + "There are a 2 ways to specify the desired motion models:\n", + "1. Let MotionModel automatically determine which motion model to use among the given `motion_models` list based on the number of valid observations. MotionModel will choose the motion model that has enough observations, i.e. $n_\\text{fit} \\geq n_\\text{params}$. \n", + "2. Specify a motion model for each star in the `motion_model_input` column. In case there is not enough observations, MotionModel will \"downgrade\" to a model with less parameters until $n_\\text{fit} \\geq n_\\text{params}$ among all the unique motion models specified in the column.\n", + "\n", + "Note that when `absolute_sigma=False` and `n_fit == n_params`, we don't have enough degree of freedom to rescale the uncertainties, so the uncertainties will be set to infinity -- the same behavior as `scipy.optimize.curve_fit`.
By default `motion_models = [Empty, Fixed, Linear]`. `Empty` and `Fixed` will always be added in the list to handle 0 and 1 point cases. See examples below for details. Let's start with the most basic usage." + ] + }, + { + "cell_type": "markdown", + "id": "e58f429d", + "metadata": {}, + "source": [ + "### 2.1. Example: default fitting" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "02642d3b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.808094Z", + "iopub.status.busy": "2026-08-24T01:33:18.807913Z", + "iopub.status.idle": "2026-08-24T01:33:18.833232Z", + "shell.execute_reply": "2026-08-24T01:33:18.832823Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Fitting Empty motion model: vectorized batch fit for 1 star(s)\n", + "Fitting Fixed motion model: vectorized batch fit for 1 star(s)\n", + "Fitting Linear motion model: vectorized batch fit for 4 star(s)\n" + ] + } + ], + "source": [ + "tab.fit_motion_models()" + ] + }, + { + "cell_type": "markdown", + "id": "81059189", + "metadata": {}, + "source": [ + "Since we do not specify the `motion_models` parameter in the `fit_motion_models` function, the default motion model of `Empty`, `Fixed` and `Linear` will be used. The function automatically determines which motion models among the three to use based on the number of valid observations, i.e., $n_\\text{fit} \\geq n_\\text{params}$:" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "a7573e51", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.834890Z", + "iopub.status.busy": "2026-08-24T01:33:18.834706Z", + "iopub.status.idle": "2026-08-24T01:33:18.857079Z", + "shell.execute_reply": "2026-08-24T01:33:18.856598Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
StarTable length=6\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
n_fitn_requiredmotion_model_used
int64int64str20
52Linear
52Linear
32Linear
22Linear
12Fixed
02Empty
" + ], + "text/plain": [ + "\n", + "n_fit n_required motion_model_used\n", + "int64 int64 str20 \n", + "----- ---------- -----------------\n", + " 5 2 Linear\n", + " 5 2 Linear\n", + " 3 2 Linear\n", + " 2 2 Linear\n", + " 1 2 Fixed\n", + " 0 2 Empty" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tab['n_required'] = 2\n", + "tab[['n_fit', 'n_required', 'motion_model_used']]" + ] + }, + { + "cell_type": "markdown", + "id": "20470c6e", + "metadata": {}, + "source": [ + "Next, let's try `absolute_sigma=False`. As mentioned above, we don't have enough degree of freedom to rescale the uncertainties for the forth star. In this case, the parameter uncertainties will be set to infinity, which is the same behavior as `scipy.optimize.curve_fit`. The same `OptmizieWarning` as in `scipy` will be raised." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "26b11593", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.858799Z", + "iopub.status.busy": "2026-08-24T01:33:18.858625Z", + "iopub.status.idle": "2026-08-24T01:33:18.881635Z", + "shell.execute_reply": "2026-08-24T01:33:18.881193Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Fitting Empty motion model: vectorized batch fit for 1 star(s)\n", + "Fitting Fixed motion model: vectorized batch fit for 1 star(s)\n", + "Fitting Linear motion model: vectorized batch fit for 4 star(s)\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/lwei-local/Software/flystar/flystar/motion_model.py:346: OptimizeWarning: Degree of freedom <= 0 for some star(s). Covariance of the parameters could not be estimated. Setting parameter uncertainties to np.inf.\n", + " result = self.run_fit(\n" + ] + } + ], + "source": [ + "tab.fit_motion_models(absolute_sigma=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "a411e006", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.883262Z", + "iopub.status.busy": "2026-08-24T01:33:18.883098Z", + "iopub.status.idle": "2026-08-24T01:33:18.903478Z", + "shell.execute_reply": "2026-08-24T01:33:18.903063Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "<Column name='vx_err' dtype='float64' length=6>\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
0.23980256894092755
0.07197698078673949
0.2672310900442148
inf
inf
inf
" + ], + "text/plain": [ + "\n", + "0.23980256894092755\n", + "0.07197698078673949\n", + " 0.2672310900442148\n", + " inf\n", + " inf\n", + " inf" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tab['vx_err']" + ] + }, + { + "cell_type": "markdown", + "id": "241ab6d6", + "metadata": {}, + "source": [ + "### 2.2. Example: specify motion models" + ] + }, + { + "cell_type": "markdown", + "id": "220922c5", + "metadata": {}, + "source": [ + "Alternatively, one can specify a list of motion models to use, and the function will also automatically determine which model to use for each star depending on the valid observed epochs. In the following example, we specify `Acceleration` model, but **the function will always implicitly add `Empty` and `Fixed`** to handle the 0 or 1 epoch stars." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "a596c8e8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.905215Z", + "iopub.status.busy": "2026-08-24T01:33:18.905046Z", + "iopub.status.idle": "2026-08-24T01:33:18.927889Z", + "shell.execute_reply": "2026-08-24T01:33:18.927470Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Fitting Acceleration motion model: vectorized batch fit for 3 star(s)\n", + "Fitting Empty motion model: vectorized batch fit for 1 star(s)\n", + "Fitting Fixed motion model: vectorized batch fit for 2 star(s)\n" + ] + } + ], + "source": [ + "tab.fit_motion_models(motion_models=['Acceleration'])" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "7d66e979", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.929584Z", + "iopub.status.busy": "2026-08-24T01:33:18.929409Z", + "iopub.status.idle": "2026-08-24T01:33:18.950052Z", + "shell.execute_reply": "2026-08-24T01:33:18.949630Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
StarTable length=6\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
n_fitmotion_model_used
int64str20
5Acceleration
5Acceleration
3Acceleration
2Fixed
1Fixed
0Empty
" + ], + "text/plain": [ + "\n", + "n_fit motion_model_used\n", + "int64 str20 \n", + "----- -----------------\n", + " 5 Acceleration\n", + " 5 Acceleration\n", + " 3 Acceleration\n", + " 2 Fixed\n", + " 1 Fixed\n", + " 0 Empty" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tab[['n_fit', 'motion_model_used']]" + ] + }, + { + "cell_type": "markdown", + "id": "188290a9", + "metadata": {}, + "source": [ + "### 2.3. Example: specify the `motion_model_input` column" + ] + }, + { + "cell_type": "markdown", + "id": "99624463", + "metadata": {}, + "source": [ + "One can also specify a motion model for each star as a column in the star table. However, the function will \"downgrade\" the model to one with fewer parameters until $n_\\text{fit} \\geq n_\\text{params}$:" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "04db5f9e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.951862Z", + "iopub.status.busy": "2026-08-24T01:33:18.951699Z", + "iopub.status.idle": "2026-08-24T01:33:18.972310Z", + "shell.execute_reply": "2026-08-24T01:33:18.971838Z" + } + }, + "outputs": [], + "source": [ + "ra = np.zeros(len(x))\n", + "dec = np.zeros(len(x))\n", + "pa = np.zeros(len(x))\n", + "\n", + "motion_model_input = [\n", + " 'Acceleration', # Will use Acceleration\n", + " 'Parallax', # Will use Parallax\n", + " 'Linear', # Will use Linear\n", + " 'Acceleration', # Will use Linear, as n_fit = 2 < 3\n", + " 'Linear', # Will use Fixed, as n_fit = 1 < 2\n", + " 'Fixed' # Will use Empty, as n_fit = 0 < 1\n", + "]\n", + "tab = StarTable({\n", + " 'x': x,\n", + " 'y': y,\n", + " 'xe': xe,\n", + " 'ye': ye,\n", + " 'ra': ra,\n", + " 'dec': dec,\n", + " 'pa': pa,\n", + " 'motion_model_input': motion_model_input\n", + "})\n", + "tab.meta['list_times'] = t" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "2b61fbcf", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:18.974094Z", + "iopub.status.busy": "2026-08-24T01:33:18.973931Z", + "iopub.status.idle": "2026-08-24T01:33:19.002687Z", + "shell.execute_reply": "2026-08-24T01:33:19.002174Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Fitting Acceleration motion model: vectorized batch fit for 1 star(s)\n", + "Fitting Empty motion model: vectorized batch fit for 1 star(s)\n", + "Fitting Fixed motion model: vectorized batch fit for 1 star(s)\n", + "Fitting Linear motion model: vectorized batch fit for 2 star(s)\n", + "Fitting Parallax motion model: vectorized batch fit for 1 star(s)\n" + ] + } + ], + "source": [ + "tab.fit_motion_models(fixed_params_dict={\n", + " 'ra': ra, \n", + " 'dec': dec, \n", + " 'pa': pa,\n", + " 'obsLocation': 'earth'\n", + "})" + ] + }, + { + "cell_type": "markdown", + "id": "5a625ccb", + "metadata": {}, + "source": [ + "Let's check if the actually used motion model is corrected:" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "b30ffb16", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:19.004338Z", + "iopub.status.busy": "2026-08-24T01:33:19.004173Z", + "iopub.status.idle": "2026-08-24T01:33:19.024874Z", + "shell.execute_reply": "2026-08-24T01:33:19.024477Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
StarTable length=6\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
n_fitn_requiredmotion_model_inputmotion_model_used
int64int64str12str12
53AccelerationAcceleration
53ParallaxParallax
32LinearLinear
23AccelerationLinear
12LinearFixed
01FixedEmpty
" + ], + "text/plain": [ + "\n", + "n_fit n_required motion_model_input motion_model_used\n", + "int64 int64 str12 str12 \n", + "----- ---------- ------------------ -----------------\n", + " 5 3 Acceleration Acceleration\n", + " 5 3 Parallax Parallax\n", + " 3 2 Linear Linear\n", + " 2 3 Acceleration Linear\n", + " 1 2 Linear Fixed\n", + " 0 1 Fixed Empty" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "all_mm_map = motion_model.motion_model_map()\n", + "tab['n_required'] = np.array([all_mm_map[mm].n_params for mm in tab['motion_model_input']], dtype=int)\n", + "tab[['n_fit', 'n_required', 'motion_model_input', 'motion_model_used']]" + ] + }, + { + "cell_type": "markdown", + "id": "d4f96fcb", + "metadata": {}, + "source": [ + "### 2.4. Example: infer positions" + ] + }, + { + "cell_type": "markdown", + "id": "c660ec98", + "metadata": {}, + "source": [ + "Continuing from the previous example: Once we fit the motion models and the parameters are added into the table, we can infer the positions at arbitrary times with `StarTable.infer_positions`" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "095be28f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:19.026676Z", + "iopub.status.busy": "2026-08-24T01:33:19.026511Z", + "iopub.status.idle": "2026-08-24T01:33:19.048446Z", + "shell.execute_reply": "2026-08-24T01:33:19.048042Z" + } + }, + "outputs": [], + "source": [ + "x_model, y_model, xe_model, ye_model = tab.infer_positions(t_test)" + ] + }, + { + "cell_type": "markdown", + "id": "a4df5458", + "metadata": {}, + "source": [ + "As in `MotionModel.model`, `StarTable.infer_positions` is also vectorized and returns positions and uncertainties in shapes of $(N_\\text{stars}, N_\\text{times})$" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "2f7e8b7a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:19.050169Z", + "iopub.status.busy": "2026-08-24T01:33:19.050008Z", + "iopub.status.idle": "2026-08-24T01:33:19.069410Z", + "shell.execute_reply": "2026-08-24T01:33:19.069005Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "(6, 100)" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "x_model.shape" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "7aab0868", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-24T01:33:19.071157Z", + "iopub.status.busy": "2026-08-24T01:33:19.070988Z", + "iopub.status.idle": "2026-08-24T01:33:19.207840Z", + "shell.execute_reply": "2026-08-24T01:33:19.207396Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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gGqnmMeN3vuKK67g39fxxx+PWqLYv7ePfexjuPvuu/H3v/8dH/nIR/DNb34T11xzTdE+T60g43XlHbdaRsbryj1uMl7LeF2N4/XcmKbMAR28l156CY8//viUx6gwPvNLzdxGjI+P44ILLsDq1avZpGq695jufaqJYh+3WqVUx40Mv84//3xccskluPLKK1HtlOK4/fu//zubzu3btw833XQT3vve9+L3v/99Vf9bLeZxo8kceuyzn/0sao1i/94+8YlPpNbXrl2L+vp6vPOd70zNogvl+V5kvJbxupS/NxUZr2W8LvbvTcbryhiv52yEm9zpHnzwQZ61mD9/fmp7W1sb32bOgvT390+ZLfF6vSxsnE4nfvOb36SloNL7UApHJgMDA1Pep5oo9nGrVUp13GjwPvPMM3HSSSfhzjvvRLVTquPW1NSE5cuX401vehPPZv7xj3/E008/jWql2MftkUce4eNDaYGUfrV06VLeTtHuyy+/HNVKOc5vVGpE7Ny5cxY/SW0h43VlHrdaRcbryj5uMl7LeF2V43VijkHmDddee22io6Mj8frrr2d9nMwdbr311tS2UCg0xdxhbGwsceKJJyZOP/30hM/nm/I+qmnaM888k9pGBmrVappWquNWayYspTxuBw8eZJfFd73rXYloNJqoZsrxe1PZv38//zv9+9//nqg2SnXc9u3bxy6f6kLu+HTM7r333sSBAwcS1UY5f2+/+93v+NjRMRXK873IeC3jdanPAzJey3gt4/XcGq/nnOD+8Ic/zAd98+bNaTbvfr8/9RxyUqTn3H///Xwx+e53vzvNhp9uN27cmDj66KPZTl77PlqhQ23B1q5dy+7ktNDzq7UtWCmPG/2Qye34pptuSjidTl6npRpb55TquB06dCixdOnSxFlnncUDufY51UipjhtNiH3729/m3xe1BXvkkUcSp5xySmLJkiXsolptlPLfqZY9e/ZUtUt5qY4btYekNnR0nHbv3p245557+KLhoosuKttnr2RkvK784ybjtYzXMl5X/r9TLTJeJ8oyXs85wU0XhdkWiqZqZ0e++MUv8gyJxWJJnHbaafxDV6HIV673oR+yytDQUOKyyy7jHnq00PrIyEiiGinlcbv88suzPqcaI46lOm70frmeU42U6ri99NJLiTPPPDPR0NDA77Fo0aLE1VdfzZMW1Ugp/53W0gBequO2ZcsWHuTpAspqtSZWrFjB75nv7PpcQ8bryj9uMl7LeC3jdeX/O9Ui43WiLOO1LvmlC4IgCIIgCIIgCIIwi8xZ0zRBEARBEARBEARBKCYiuAVBEARBEARBEAShCIjgFgRBEARBEARBEIQiIIJbEARBEARBEARBEIqACG5BEARBEARBEARBKAIiuAVBEARBEARBEAShCIjgFgRBEARBEARBEIQiIIJbEARBEARBEARBEIqACG5BELJy44034thjj5WjIwiCIAgVjozZglC56BKJRKLcOyEIQmnR6XTTPn755ZfjO9/5DkKhEBobG0u2X4IgCIIgpCNjtiBUNyK4BWEO0tvbm1q/55578IUvfAGvvfZaapvNZoPH4ynT3gmCIAiCoCJjtiBUN5JSLghzkLa2ttRCwppmzzO3ZaanXXHFFXjrW9+Kr371q2htbUVdXR1uuukmRKNR/Pu//zsaGhowf/58/OQnP0n7W4cOHcKll16K+vp6jpZffPHF2Lt3bxk+tSAIgiBUHzJmC0J1I4JbEIS8eeSRR9Dd3Y3HHnsMt912G4vyCy+8kMX0M888g6uvvpqXAwcO8PP9fj/OPPNMOJ1Ofs3jjz/O6+effz7C4bAceUEQBEEoEjJmC0JlIIJbEIS8oSj27bffjhUrVuD9738/35Ko/tznPodly5bhs5/9LMxmM5544gl+/t133w29Xo8f/ehHOProo7Fq1Srcdddd2L9/PzZv3ixHXhAEQRCKhIzZglAZGMu9A4IgVA9HHXUUC2gVSi1fs2ZN6r7BYOC08f7+fr6/ZcsW7Ny5Ey6XK+19gsEgdu3aVcI9FwRBEIS5hYzZglAZiOAWBCFvTCZT2n2q/c62LR6P8zrdrl+/Hj//+c+nvFdzc7MceUEQBEEoEjJmC0JlIIJbEISisW7dOnZBb2lpgdvtliMtCIIgCBWKjNmCUBykhlsQhKJx2WWXoampiZ3J//GPf2DPnj149NFH8bGPfQwHDx6UIy8IgiAIFYKM2YJQHERwC4JQNOx2O7uTL1iwAG9/+9vZNI3M1gKBgES8BUEQBKGCkDFbEIqDLpFIJIr03oIgCIIgCIIgCIIwZ5EItyAIgiAIgiAIgiAUARHcgiAIgiAIgiAIglAERHALgiAIgiAIgiAIQhEQwS0IgiAIgiAIgiAIRUAEtyAIgiAIgiAIgiAUARHcgiAIgiAIgiAIglAERHALgiAIgiAIgiAIQhEQwS0IgiAIgiAIgiAIRUAEtyAIgiAIgiAIgiAUARHcgiAIgiAIgiAIglAERHALgiAIgiAIgiAIQhEQwS0IgiAIgiAIgiAImH3+f+wsflzXxNFBAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "visualize_fit(t, x, y, xe, ye, x_model, y_model, xe_model, ye_model, mm.name, t_test)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "jwst_latest", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/flystar/index.rst b/docs/flystar/index.rst deleted file mode 100644 index 99e85a8..0000000 --- a/docs/flystar/index.rst +++ /dev/null @@ -1,10 +0,0 @@ -********************* -FlyStar Documentation -********************* - -This is the documentation for FlyStar. - -Reference/API -============= - -.. automodapi:: flystar diff --git a/docs/index.rst b/docs/index.rst index a7a24e4..09f9c42 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,14 +1,292 @@ -Documentation -============= +======= +FlyStar +======= -This is the documentation for FlyStar. +**FlyStar aligns many star lists into one common frame and fits each star's +motion there, choosing per star from a set of motion models.** + +That one sentence is the whole package: *align the stars, then describe how they +move.* The rest of this page unpacks it. + +Astrometry of a crowded field is never taken in one frame. You have many +images -- different nights, different instruments, different pointings, each +with its own distortion and its own arbitrary pixel coordinate system. Every +one of them tells you about the same stars, but none of them agrees with the +others about where those stars are. + +FlyStar's job is to make them agree. It cross-matches the stars across all +those lists, solves for the coordinate transformation that carries each list +into one common reference frame, and then -- with every epoch on the same +footing -- fits each star's motion through time. What comes out is a single +table of positions, proper motions, and where wanted parallaxes, with +uncertainties that mean something. + +That is the whole point of the package: **turn many disagreeing star lists into +one self-consistent astrometric solution.** + +.. admonition:: Which version is this? + :class: important + + Built from the ``mm_rework_lingfeng`` 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. + +How the pieces fit +================== + +Three objects, in the order you meet them: + +.. list-table:: + :header-rows: 1 + :widths: 26 22 52 + + * - Stage + - Object + - What it holds + * - **1. One epoch** + - :class:`~flystar.starlists.StarList` + - One detection list from one image. 1D columns, one row per star: + ``x``, ``y``, ``m`` and their uncertainties. + * - **2. Many epochs** + - :class:`~flystar.startables.StarTable` + - The cross-matched result. 2D columns of shape + ``(N_stars, N_lists)``, so ``t['x'][i, j]`` is star ``i`` in list ``j``. + * - **3. The solution** + - :class:`~flystar.align.MosaicSelfRef` / + :class:`~flystar.align.MosaicToRef` + - The iterative match/transform/average loop that builds the StarTable + and fits each star's motion. + +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:`overview` + The two data objects in full -- what lives in a ``StarList`` versus a + ``StarTable``, and the column-naming conventions the code dispatches on. + +:doc:`motion_models` + The per-star motion models, their equations, how FlyStar picks one per star, + and how the reported uncertainties are computed. Worth reading before the + aligner, whose ``motion_models`` argument only makes sense once you know + what it is choosing between. + +:doc:`transformations` + The coordinate transformation models, which to pick for a given kind of + frame difference, and how to raise the order as the fit converges. + +:doc:`alignment` + The aligners in depth, parameter by parameter -- matching strategies, + the initial guess, and how to control which stars drive the fit. + +.. toctree:: + :hidden: + + Getting started .. toctree:: - :maxdepth: 2 + :maxdepth: 2 + :hidden: + :caption: Components - flystar/index.rst + overview + motion_models + transformations + alignment + +.. toctree:: + :maxdepth: 2 + :hidden: + :caption: Examples + + examples/alignment_example + examples/motion_model_example + +.. toctree:: + :maxdepth: 2 + :hidden: + :caption: API + + api/flystar/index + +.. toctree:: + :maxdepth: 1 + :hidden: + :caption: Indices -.. note:: The layout of this directory is simply a suggestion. To follow - traditional practice, do *not* edit this page, but instead place - all documentation for the package inside ``flystar/``. - You can follow this practice or choose your own layout. + genindex diff --git a/docs/make_example_figures.py b/docs/make_example_figures.py new file mode 100644 index 0000000..2fd53f5 --- /dev/null +++ b/docs/make_example_figures.py @@ -0,0 +1,118 @@ +""" +Regenerate the figures used by docs/index.rst and docs/examples/alignment_example.rst. + +Run from anywhere with flystar importable: + + python docs/make_example_figures.py + +The figures are committed so that the documentation build needs no science +stack (sphinx-autoapi reads the source statically and never imports flystar). +This script is the record of how they were produced -- keep it in step with the +code blocks on that page, which are meant to be identical. +""" +import os +import numpy as np, matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from flystar import align, starlists, transforms + +OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '_static') +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() + +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)]) + +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, raw = [], [] +for i, yr in enumerate(YEARS): + dt = yr - t0 + xt, yt = x0 + vx*dt, y0 + vy*dt + 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 + raw.append((xo, yo, seen)) + 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 + lists.append(sl) + +msc = align.MosaicSelfRef(lists, dr_tol=[60., 10., 5.], dm_tol=[1., 1., 1.], + trans_class=transforms.PolyTransform, + trans_args=[{'order': 1}]*3, + motion_models=['Linear'], init_guess_mode='miracle', verbose=False) +msc.fit() +ref = msc.ref_table +print(f"ref_table: {len(ref)} rows; {int((ref['n_detect']==4).sum())} stars in all 4 epochs") + +COL = ['#1f77b4', '#d62728', '#2ca02c', '#9467bd'] + +# ---- Figure 1: before vs after, zoomed so the frame offsets are visible ----- +fig, (a1, a2) = plt.subplots(1, 2, figsize=(11, 5.2)) +lo, hi = 100, 260 +# Join the four measurements of one star, so the spread reads as one star seen +# in four misaligned frames rather than as four different stars. +in_box = np.all([(raw[i][0] > lo) & (raw[i][0] < hi) & + (raw[i][1] > lo) & (raw[i][1] < hi) & raw[i][2] + for i in range(len(YEARS))], axis=0) +for j in np.flatnonzero(in_box): + a1.plot([raw[i][0][j] for i in range(len(YEARS))], + [raw[i][1][j] for i in range(len(YEARS))], + '-', color='0.75', lw=.8, zorder=1) +for i, yr in enumerate(YEARS): + xo, yo, seen = raw[i] + k = seen & (xo > lo) & (xo < hi) & (yo > lo) & (yo < hi) + a1.scatter(xo[k], yo[k], s=26, color=COL[i], alpha=.85, label=f'{yr:.0f}', zorder=2) +a1.set_title('Before: one star, four frames (grey joins the same star)') +a1.legend(title='epoch', fontsize=8); a1.set_xlabel('x (pixels)'); a1.set_ylabel('y (pixels)') + +xt_all, yt_all = np.asarray(ref['x']), np.asarray(ref['y']) +sel = (np.asarray(ref['x0']) > lo) & (np.asarray(ref['x0']) < hi) & \ + (np.asarray(ref['y0']) > lo) & (np.asarray(ref['y0']) < hi) +for i, yr in enumerate(YEARS): + a2.scatter(xt_all[sel, i], yt_all[sel, i], s=26, color=COL[i], alpha=.85, label=f'{yr:.0f}') +a2.set_title('After: all epochs in the common frame') +a2.legend(title='epoch', fontsize=8); a2.set_xlabel('x (pixels)'); a2.set_ylabel('y (pixels)') +for a in (a1, a2): + a.set_xlim(lo, hi); a.set_ylim(lo, hi); a.set_aspect('equal') +fig.tight_layout(); fig.savefig(f'{OUT}/align_before_after.png', dpi=130); plt.close(fig) + +# ---- Figure 2: residuals per epoch about the fitted model ------------------- +fig, ax = plt.subplots(figsize=(6.4, 4.4)) +good = np.asarray(ref['n_detect']) == len(YEARS) +xm, ym, _, _ = ref.infer_positions(YEARS) +dx = (xt_all - xm)[good].ravel() * 1000 +dy = (yt_all - ym)[good].ravel() * 1000 +ax.hist(dx[np.isfinite(dx)], bins=60, alpha=.7, label='x', color=COL[0]) +ax.hist(dy[np.isfinite(dy)], bins=60, alpha=.7, label='y', color=COL[1]) +ax.axvline(0, color='k', lw=.8, ls='--') +ax.set_xlabel('residual from fitted motion (milli-pixels)'); ax.set_ylabel('count') +ax.set_title(f'Post-alignment residuals (injected noise {ERR*1000:.0f} mpix)') +ax.legend(); fig.tight_layout(); fig.savefig(f'{OUT}/align_residuals.png', dpi=130); plt.close(fig) +print(f"residual scatter: x {np.nanstd(dx):.1f} mpix, y {np.nanstd(dy):.1f} mpix (injected {ERR*1000:.0f})") + +# ---- Figure 3: recovered proper motion vs truth ----------------------------- +name_to_i = {n: j for j, n in enumerate(names)} +rn = np.asarray(ref['name']).astype(str) +idx = np.array([name_to_i.get(n, -1) for n in rn]) +ok = good & (idx >= 0) +fig, (b1, b2) = plt.subplots(1, 2, figsize=(11, 4.6)) +for a, rec, tru, lab in ((b1, np.asarray(ref['vx'])[ok], vx[idx[ok]], 'v_x'), + (b2, np.asarray(ref['vy'])[ok], vy[idx[ok]], 'v_y')): + a.scatter(tru, rec, s=16, alpha=.7, color=COL[0]) + lim = [min(tru.min(), rec.min()) - .1, max(tru.max(), rec.max()) + .1] + a.plot(lim, lim, 'k--', lw=.9) + a.set_xlim(lim); a.set_ylim(lim); a.set_aspect('equal') + a.set_xlabel(f'true {lab} (pix/yr)'); a.set_ylabel(f'recovered {lab} (pix/yr)') + a.set_title(f'{lab}: scatter = {np.std(rec - tru)*1000:.1f} mpix/yr') +fig.tight_layout(); fig.savefig(f'{OUT}/align_proper_motion.png', dpi=130); plt.close(fig) +print(f"proper motion recovery: vx rms {np.std(np.asarray(ref['vx'])[ok]-vx[idx[ok]])*1000:.1f} mpix/yr, " + f"n={ok.sum()}") diff --git a/docs/motion_models.rst b/docs/motion_models.rst new file mode 100644 index 0000000..292ae80 --- /dev/null +++ b/docs/motion_models.rst @@ -0,0 +1,939 @@ +============= +Motion models +============= + +Once every epoch sits in a common frame, each star has a time series of +positions, and the question becomes what curve to put through it. A *motion +model* is that curve. On this branch these are first-class pluggable classes in +:mod:`flystar.motion_model`, all deriving from +:class:`~flystar.motion_model.MotionModel`, and FlyStar chooses between them +**per star** rather than imposing one on the whole table. + +This is the part of the API that differs most from ``main``. + +.. _the-models: + +The models +========== + +Every model predicts a position at time :math:`t` from parameters it *fits* and +parameters you *fix*. ``n_params`` is how many epochs a star needs before the +model is fittable, per coordinate; it is derived as +``int((n_fit_params + 1) / 2)``, not set by hand, and it is also the quantity +models are ranked by when FlyStar decides what a star can support. + +Throughout, :math:`\Delta t \equiv t - t_0`. + +.. list-table:: + :header-rows: 1 + :widths: 14 8 24 54 + + * - Model + - ``n_params`` + - Fit parameters + - Position model + * - :class:`~flystar.motion_model.Empty` + - 0 + - -- + - :math:`x(t) =` ``fill_value`` (NaN by default), :math:`\sigma_x(t) = \infty`. + For stars with no usable detection at all. + * - :class:`~flystar.motion_model.Fixed` + - 1 + - :math:`x_0,\ y_0` + - :math:`x(t) = x_0`, the weighted mean position -- no time dependence. + For stars seen once, or held still on purpose. + * - :class:`~flystar.motion_model.Linear` + - 2 + - :math:`x_0, v_x,\ y_0, v_y` + - :math:`x(t) = x_0 + v_x\,\Delta t`. Constant proper motion; the + workhorse. + * - :class:`~flystar.motion_model.Acceleration` + - 3 + - :math:`x_0, v_{x0}, a_x,\ y_0, v_{y0}, a_y` + - :math:`x(t) = x_0 + v_{x0}\,\Delta t + \tfrac{1}{2} a_x\,\Delta t^2`. + For stars whose motion visibly curves. + * - :class:`~flystar.motion_model.Parallax` + - 3 + - :math:`x_0, v_x, \pi,\ y_0, v_y` + - :math:`x(t) = x_0 + v_x\,\Delta t + \pi\,P_x(t)`. Linear motion plus + annual parallax. + +Note where :math:`\pi` sits in the parallax model: it multiplies the **parallax +vector** :math:`\boldsymbol{P}(t)`, not :math:`\Delta t`. Written out for both +coordinates, + +.. math:: + + x(t) &= x_0 + v_x\,(t - t_0) + \pi\,P_x(t) \\ + y(t) &= y_0 + v_y\,(t - t_0) + \pi\,P_y(t) + +:math:`\boldsymbol{P}(t)` is computed by +:meth:`~flystar.motion_model.Parallax.calc_parallax_vector` from the star's +``ra``/``dec``, the position angle ``pa``, and the observatory location +``obsLocation``. Note that ``fit_motion_models`` currently applies a single +``obsLocation`` to every star in the table. + +Fixed parameters +---------------- + +*Fit* parameters are solved for; *fixed* parameters you supply, either as a +column on the table or through ``fixed_params_dict`` (scalars apply to every +star, arrays must have length ``N_stars``). + +.. list-table:: + :header-rows: 1 + :widths: 20 22 58 + + * - Model + - Required + - Optional + * - ``Empty``, ``Fixed`` + - -- + - -- + * - ``Linear``, ``Acceleration`` + - :math:`t_0` + - -- + * - ``Parallax`` + - :math:`t_0`, ``ra``, ``dec`` + - ``pa`` (default 0), ``obsLocation`` (default ``'earth'``) + +:math:`t_0` is the one you can always omit. Left unset, it is computed per star +as the uncertainty-weighted mean of that star's own epochs, + +.. math:: + + t_0 = \frac{\sum_i t_i / \sqrt{\sigma_{x,i}^2 + \sigma_{y,i}^2}} + {\sum_i 1 / \sqrt{\sigma_{x,i}^2 + \sigma_{y,i}^2}} + +which puts the reference epoch near the star's best-measured time and so +minimises the covariance between :math:`x_0` and :math:`v_x`. + +Fitted parameters land in per-star columns named after the parameter, with +uncertainties in ``_err`` -- ``vx`` and ``vx_err``, ``pi`` and +``pi_err``. How those errors are computed is below. + +Where fixed parameters live +--------------------------- + +A fixed parameter can come from three places, and both +:meth:`~flystar.startables.StarTable.fit_motion_models` and +:meth:`~flystar.startables.StarTable.infer_positions` resolve them in the same +order: + +1. ``fixed_params_dict``, if the key is there +2. a **table column** of that name +3. **table metadata** of that name + +For a *required* parameter, exhausting all three raises ``KeyError``. An +*optional* one falls back to the model's default (``pa=0``, +``obsLocation='earth'``). + +The order is worth committing to memory, because the column beats the metadata, +and ``fixed_params_dict`` beats both. Passing ``fixed_params_dict`` to +``infer_positions`` therefore overrides whatever the table is carrying, rather +than being ignored in its favour. + +Fitting writes them back, so a table carries the parameters it was fitted with +and ``infer_positions`` propagates each star with exactly what its fit used -- +no need to supply them a second time. The single rule is that the values the +fit used end up under ````, the name the lookup above searches: + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Situation + - Result + * - no column of that name, value uniform across stars + - ``table.meta['']``, one scalar + * - no column of that name, value varies per star + - a column ```` + * - a column exists and already agrees + - left untouched + * - a column exists and disagrees + - ```` takes the used values; the ones you supplied move to + ``_orig`` + * - no column, but metadata of that name disagrees + - ``table.meta['']`` is overwritten, and the old value is **not** + kept + +Metadata is used only where no column of that name exists, which is what makes +it safe: a column would shadow it in the resolution order, so a value written to +metadata underneath one could never be read back. Where that is not a risk, one +entry in metadata beats the same number repeated down a column of every row. + +Note the asymmetry in the last two rows. A conflicting *column* is preserved +under ``_orig``; a conflicting *metadata* entry is simply overwritten. +Metadata is a record of what the table was fitted with, and keeping it in step +with the fit is what lets ``infer_positions`` propagate without being handed the +parameters again -- but it does mean that if you need the value the table +carried before the fit, you have to keep it yourself. + +A column can disagree with the fit because ``fixed_params_dict`` outranks it -- +pass ``fixed_params_dict={'ra': ...}`` for a table that already has an ``ra`` +column and the fit uses the dict. Your column is not overwritten so much as +moved aside: ``_orig`` keeps it, the same convention +:class:`~flystar.align.MosaicSelfRef` follows when it replaces ``x``/``y``/``m`` +with transformed values and leaves ``x_orig``/``y_orig``/``m_orig`` behind. +``_orig`` is written only the first time, so refitting with different +values cannot overwrite your original with the previous fit's substitute. + +How a model gets chosen +======================= + +Two columns govern this, and the asymmetry between them matters: + +``motion_model_input`` + Optional, supplied by you. A per-star *request*. + +``motion_model_used`` + Always written by the fit. What actually *happened*. + +:meth:`~flystar.startables.StarTable.fit_motion_models` resolves them so: + +1. **No** ``motion_model_input`` **column.** Each star gets the most complex + model in your ``motion_models`` list it has enough epochs to support + (``n_fit >= n_params``). If two candidates share the same ``n_params`` this + is ambiguous and raises ``AssertionError`` rather than guessing -- supply + ``motion_model_input`` to disambiguate. +2. **With a** ``motion_model_input`` **column.** Your request wins wherever the + star can support it. A star that cannot falls back to the most complex model + it *can* support, drawn from the union of ``motion_models`` and the models + named in the column. + +``Empty`` and ``Fixed`` are always added to the candidate list even if you did +not ask for them, so stars detected zero or one time still get a well-defined +model instead of failing. + +.. code-block:: python + + # Everything linear where possible; a few known parallax targets get more. + table['motion_model_input'] = 'Linear' + table['motion_model_input'][is_target] = 'Parallax' + + table.fit_motion_models(motion_models=['Linear', 'Parallax'], + fixed_params_dict={'ra': ra_deg, 'dec': dec_deg}) + + # Always check what you got, not what you asked for. + import numpy as np + print(np.unique(table['motion_model_used'], return_counts=True)) + +A star demoted to a simpler model has any leftover parameters from its +previously-assigned model reset, so ``vx`` is never left holding a stale value +from an earlier, more complex fit. + +Choosing a model versus propagating with one +-------------------------------------------- + +:func:`~flystar.motion_model.determine_motion_models` answers a related but +distinct question, and the ``motion_models`` argument separates them: + +* Pass your list to ask *which of the models I requested was this star fit + with* -- the answer must stay inside the set you asked for. +* Pass ``None`` to ask *how well can this star be propagated at all* -- a + property of the star's own parameters, not of what you chose to fit. A + reference star imported with a full parallax solution should be propagated + with it even if this run only fit linear motion. + +The time-argument contract +========================== + +Every model's :meth:`~flystar.motion_model.MotionModel.model`, and +:meth:`~flystar.startables.StarTable.infer_positions`, take times under one +rule: **shape decides meaning.** Nothing is inferred from ``len(t)`` happening +to equal ``N_stars``. + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - ``t`` + - Meaning + * - scalar + - One time, every star. + * - ``(N_times,)`` + - One shared grid, every star -- always, even when + ``N_times == N_stars``. + * - ``(1, N_times)`` + - The same, written explicitly. + * - ``(N_stars, N_times)`` + - Each star has its own times. + +For **one time per star**, pass a column vector ``t[:, np.newaxis]`` of shape +``(N_stars, 1)``. A bare 1D array of length ``N_stars`` means a shared grid, not +per-star times. Any other shape raises ``ValueError`` rather than being guessed +at. See :func:`~flystar.motion_model.broadcast_times`. + +Propagating the whole table to a single new epoch does not need the +column-vector form -- pass the scalar epoch and let each star's own :math:`t_0` +supply the difference: + +.. code-block:: python + + x, y, xe, ye = table.infer_positions(2026.5) + +.. _uncertainties: + +Uncertainties +============= + +Every model reports parameter errors the same way, and it is the way +:func:`scipy.optimize.curve_fit` does. There is no per-model convention to +learn. + +Weighting +--------- + +``weighting`` decides how a per-epoch uncertainty becomes a fit weight, via +:func:`~flystar.motion_model.sigma_from_error` and then +:func:`~flystar.motion_model.weight_from_sigma`, which computes +:math:`w = 1/\sigma^2`: + +.. list-table:: + :header-rows: 1 + :widths: 18 28 54 + + * - ``weighting`` + - :math:`\sigma_i` used + - Resulting weight + * - ``'var'`` (default) + - :math:`|\sigma_{x,i}|` + - :math:`w_i = 1/\sigma_{x,i}^2` -- true inverse-variance weighting. + * - ``'std'`` + - :math:`\sqrt{|\sigma_{x,i}|}` + - :math:`w_i = 1/|\sigma_{x,i}|` -- standard-error weighting, a gentler + down-weighting of poorly measured epochs. + +Use ``'var'`` unless you have a specific reason: it is the correct choice when +your input errors are trustworthy, and it minimises the propagated uncertainty. + +The ``absolute_sigma`` convention +--------------------------------- + +Let :math:`\hat{\sigma}_p` be the formal error on parameter :math:`p` from the +weighted least-squares covariance, :math:`\chi^2` the weighted sum of squared +residuals, and :math:`\nu` the degrees of freedom +(:math:`n_\mathrm{valid} - n_\mathrm{params}`). Then: + +.. math:: + + \sigma_p = + \begin{cases} + \hat{\sigma}_p, & \texttt{absolute\_sigma=True (default)} \\[4pt] + \hat{\sigma}_p \sqrt{\chi^2 / \nu}, & \texttt{absolute\_sigma=False} + \end{cases} + +``True`` takes your input errors at face value and propagates them. ``False`` +rescales by the reduced :math:`\chi^2`, so only the *relative* magnitudes of the +input errors matter and the result reflects the epochs' own disagreement -- +the more honest choice when the input errors are known to be systematically +underestimated. When :math:`\nu \le 0` there is nothing to rescale by and the +error is reported as :math:`\infty` rather than as a 0/0 NaN. + +This is exactly ``curve_fit``'s meaning of the flag, and the equivalence is +enforced rather than asserted. ``flystar/tests/test_motion_model.py`` fits +``Fixed``, ``Linear`` and ``Acceleration`` against their own ``curve_fit`` call, +and ``Parallax`` against a joint five-parameter ``curve_fit`` over the stacked +:math:`[x, y]` data, comparing parameters, parameter errors *and* :math:`\chi^2` +across both weighting schemes, both ``absolute_sigma`` settings, several epoch +counts including :math:`\nu = 0`, and nan-padded epochs. Those tests caught a +real bug: ``Fixed`` had computed :math:`\chi^2` as +:math:`\mathrm{resid}^2/\sigma_x^2` rather than with the fit's own weights, +which coincide only for ``weighting='var'``. + +The same flag, with the same meaning, applies to +:meth:`~flystar.startables.StarTable.combine_lists`, which collapses a per-list +column into a per-star one (``x`` → ``x0``, ``x0_err``). There the reported +value is always the uncertainty **of the mean**, never the scatter of the +points. Sigma clipping runs first, so one bad epoch does not drag the average. + +Unusable uncertainties get weight zero, not a bad weight +-------------------------------------------------------- + +A naive :math:`1/\sigma^2` turns a missing or pathological uncertainty into an +infinite or NaN weight, corrupting the whole sum rather than excluding one +point. :func:`~flystar.motion_model.weight_from_sigma` instead assigns +**exactly zero** whenever :math:`\sigma` is NaN, infinite, exactly zero, or so +small that squaring it underflows -- and to any epoch marked invalid. Such an +epoch drops cleanly out of both the fit and the :math:`\chi^2`. + +If *every* epoch of a star has weight zero there is no weighted mean to report, +and FlyStar does not invent one: the value falls back to the unweighted mean +where one is defined, and the uncertainty is :math:`\infty`. Per-star error +columns are filled with ``inf``, not ``nan``, precisely so that "we don't know" +stays distinguishable from "no data" and can never be mistaken for precision. + +A degenerate fit is reported, not guessed +----------------------------------------- + +When the normal equations are singular -- every valid epoch at the same time, +say -- only some combination of the parameters is constrained, not any +individual one. FlyStar detects this against a scaled determinant tolerance and +returns ``fill_value`` with :math:`\infty` errors, rather than the arbitrary +minimum-norm answer a pseudo-inverse would hand back. + +Empirical errors by bootstrap +----------------------------- + +The formulae above are analytic and assume the model is right. To get errors +that make no such assumption, resample: + +.. code-block:: python + + table.fit_motion_models(motion_models=['Linear'], bootstrap=100, seed=42) + +Each star's epochs are drawn with replacement ``bootstrap`` times, the model is +refit on each draw, and the spread of the resulting parameters becomes the +reported error. + +At the alignment level, +:meth:`~flystar.align.MosaicToRef.calc_bootstrap_errors` does the analogous +thing one level up: it resamples the *reference stars*, re-derives the +transformations, and takes the scatter of the transformed positions as the +transformation error -- capturing uncertainty in the frame itself, which the +per-star fit cannot see. + +Fitting: one star or a whole table +================================== + +:meth:`~flystar.motion_model.MotionModel.fit` handles both, dispatching on +dimensionality: + +* **1D** arrays of shape ``(n_epochs,)`` -- a single star, already filtered to + its real epochs. +* **2D** arrays of shape ``(n_stars, n_epochs)`` -- a batch packed + rectangularly, with ``nan`` marking padding where a star has fewer real + epochs than the widest row. + +Every model's solve is closed-form and vectorized across the batch, so both +paths run through the same non-iterative code -- the single-star case is just a +batch of one row. There is no ``scipy.optimize.curve_fit`` call and no +``use_scipy`` switch on this branch; both were removed in favour of the +closed-form solves, which are tested for agreement with ``curve_fit``. In +practice you rarely call ``fit`` directly: +:meth:`~flystar.startables.StarTable.fit_motion_models` is the entry point and +takes the 2D path. + +Two asymmetries between the two paths +------------------------------------- + +Worth knowing before you call ``fit`` directly, because neither is obvious: + +**The batch path does not fill in** :math:`t_0`. The single-star (1D) path +computes the weighted-mean :math:`t_0` for you and remembers it on +``self.fixed_params_dict``, so a later ``model(t, params)`` call works with no +further arguments. The batch (2D) path does neither -- pass +``fixed_params_dict={'t0': ...}`` to ``fit``, and the same ``t0`` again to +``model``: + +.. code-block:: python + + t2d = np.broadcast_to(t, x.shape) # fit dispatches on t.ndim + t0 = np.average(t2d, weights=1./np.hypot(xe, ye), axis=1) + params, param_errs, chi2_x, chi2_y = mm.fit(t2d, x, y, xe, ye, + fixed_params_dict={'t0': t0}) + x_model, y_model, xe_model, ye_model = mm.model(t_new, params, param_errs, + {'t0': t0}) + +Note also that dispatch is on ``t.ndim``, not on ``x`` -- a 1D ``t`` with 2D +``x`` takes the single-star path and then fails an assertion about ``x``. + +**The two paths return different numbers of values.** The single-star path +returns ``(params, param_errs)``, honouring ``return_chi2``. The batch path +returns ``(params, param_errs, chi2_x, chi2_y)`` regardless of +``return_chi2``. Unpack accordingly. + +Bootstrap and parallelism +------------------------- + +``bootstrap=N`` resamples each star's epochs ``N`` times for empirical +parameter errors. It is the one path *not* vectorized across stars, so it is +also the only reason to reach for multiprocessing: + +.. code-block:: python + + table.fit_motion_models(motion_models=['Linear'], + bootstrap=100, seed=42, + processes=8) + +``processes > 1`` only spins up a pool once the number of stars needing the +per-star path exceeds ``mp_star_threshold`` (default 100,000); below that, pool +startup and pickling the shared arrays cost more than they save, so fitting +stays serial. Measured break-even was between 20,000 and 100,000 stars on a +10-core machine. + +Performance +=========== + +Every motion model in FlyStar is **linear in its fit parameters**. That is the +fact the whole fitting path is built on, and it is not the same as being linear +in time: ``Acceleration`` is quadratic in :math:`t` yet still linear in +:math:`(x_0, v_x, a_x)`, because :math:`t` only ever appears in the *basis* +:math:`[1, \Delta t, \tfrac{1}{2}\Delta t^2]` that multiplies them. A model +linear in its parameters has a closed-form weighted least-squares solution -- +the normal equations -- so fitting it needs no iterative optimizer, no initial +guess, and no convergence check. + +Which means the per-star loop was never necessary. The normal equations for +10,000 stars are 10,000 small independent linear systems, and numpy assembles +and solves them in a batch: + +.. list-table:: + :header-rows: 1 + :widths: 16 10 74 + + * - Model + - Params + - How the batch is solved + * - ``Fixed`` + - 1 + - A weighted average. The whole batch is a pair of + ``.sum(axis=1)`` calls over the epoch axis. + * - ``Linear`` + - 2 + - A 2x2 system per star. The five weighted sums it needs come from + ``.sum(axis=1)`` across the batch, and the 2x2 is inverted by its + closed-form adjugate-over-determinant -- rather than building an + ``(n_epochs, n_epochs)`` diagonal weight matrix and calling an + SVD-based ``pinv`` per star for what is always a 2x2. + * - ``Acceleration`` + - 3 + - The same, one basis function wider: a 3x3 system solved with a batched + ``np.linalg.inv``. Hand-deriving a 3x3 adjugate is error-prone for + little gain over LAPACK, which is closed-form too. + * - ``Parallax`` + - 5 + - Linear once the parallax factors :math:`P_x, P_y` are precomputed from + each star's ``ra``/``dec``. Here :math:`x` and :math:`y` are **not** + independent -- :math:`\pi` is shared -- so all five parameters are fit + jointly from the stacked :math:`[x, y]` data as one coupled 5x5 system, + batched the same way. The :math:`(x_0, v_x)` and :math:`(y_0, v_y)` + blocks meet only through the shared :math:`\pi` row and column. + +.. admonition:: A non-linear model would not fit this pattern + :class: important + + The batching above is a consequence of linearity in the parameters, not a + general technique. Add a model whose parameters enter non-linearly -- an + orbit, a variable-period term, anything needing a starting guess -- and + there are no normal equations to assemble: it needs an iterative optimizer, + :func:`scipy.optimize.curve_fit` or similar, and it will run one star at a + time. Such a model can still live alongside these: only the stars actually + assigned to it pay the per-star cost, since the model is chosen per star. + But do not expect the timings below to carry over to it. + +On number of stars +------------------ + +``mm_rework`` is the predecessor branch, which fits star by star through +:func:`scipy.optimize.curve_fit`; ``mm_rework_lingfeng`` is the batched +implementation described above. Both were run on the same synthetic data in the +same environment at default settings, one after the other rather than +concurrently, so that they never competed for cores. + +Here the epoch count is held at five -- where every model is determined, so +nothing below is a fallback -- and the number of stars swept from 1,000 to +19,000 in ten steps. + +.. image:: _static/motion_model_performance_stars.png + +Log-log, unlike the epoch figure below: both branches are strictly linear in +the number of stars, and a straight line through the origin is only straight on +log-log. + +.. list-table:: Seconds for one fit at 5 epochs: batched / per-star (speed-up) + :header-rows: 1 + :widths: 12 22 22 22 22 + + * - Stars + - ``Fixed`` + - ``Linear`` + - ``Acceleration`` + - ``Parallax`` + * - 1,000 + - 0.003 / 1.6 (488x) + - 0.006 / 1.8 (327x) + - 0.009 / 1.9 (219x) + - 0.009 / 3.7 (402x) + * - 3,000 + - 0.006 / 4.7 (818x) + - 0.008 / 5.5 (698x) + - 0.014 / 5.7 (416x) + - 0.017 / 11.2 (663x) + * - 5,000 + - 0.009 / 7.8 (892x) + - 0.011 / 9.1 (814x) + - 0.020 / 9.5 (478x) + - 0.025 / 18.7 (761x) + * - 7,000 + - 0.011 / 10.9 (955x) + - 0.016 / 12.8 (812x) + - 0.026 / 13.3 (518x) + - 0.031 / 26.1 (846x) + * - 9,000 + - 0.016 / 14.0 (853x) + - 0.017 / 16.3 (943x) + - 0.032 / 17.1 (528x) + - 0.039 / 33.5 (854x) + * - 11,000 + - 0.017 / 17.1 (976x) + - 0.021 / 20.0 (960x) + - 0.040 / 20.8 (517x) + - 0.048 / 41.0 (853x) + * - 13,000 + - 0.020 / 20.2 (1017x) + - 0.025 / 23.7 (962x) + - 0.046 / 24.7 (536x) + - 0.056 / 48.4 (863x) + * - 15,000 + - 0.024 / 23.3 (988x) + - 0.028 / 27.3 (992x) + - 0.052 / 28.5 (543x) + - 0.064 / 56.4 (879x) + * - 17,000 + - 0.026 / 26.4 (1011x) + - 0.032 / 30.9 (972x) + - 0.060 / 32.2 (535x) + - 0.072 / 63.8 (881x) + * - 19,000 + - 0.030 / 29.5 (985x) + - 0.036 / 34.6 (951x) + - 0.069 / 36.0 (521x) + - 0.080 / 70.7 (889x) + +The per-star branch is proportional to the star count and nothing else. A +straight line fits every one of its four series with :math:`R^2 = 0.9999` or +better, through an intercept of a few milliseconds at most, at 1.56, 1.82, 1.90 +and 3.74 ms per star for ``Fixed``, ``Linear``, ``Acceleration`` and +``Parallax``. That is the cost of one Python-level ``curve_fit`` call, paid once +per star, and it is what makes the model's complexity visible: fitting five +coupled parameters costs a little over twice what fitting one does, per star, +every star. + +The batched branch is linear too, but with a per-star term 570 to 1,100 times +smaller: 1.5, 1.7, 3.3 and 4.0 *micro*\ seconds per star, on top of a fixed +overhead of 1.5 to 4.6 ms that does not depend on the star count at all. The +overhead is the vectorized assembly and the batched solve -- work done once per +table rather than once per star. + +So the speed-up **grows** with the catalogue and then plateaus, from 220-490x +at 1,000 stars to 520-990x at 19,000, because at a thousand stars the batched +fit's fixed few milliseconds is still a large fraction of its total and by +nineteen thousand it is not. The batched path is at its best exactly where it +matters -- the large mosaics -- and its worst case is a catalogue small enough +to fit in the time it takes to read the file. + +On number of epochs +------------------- + +The same comparison the other way round: 10,000 stars held fixed, and the +epoch count swept. The grid is two epochs, then three to nineteen in steps of +two -- two is the fewest anything here can fit, and is kept because it is where +the more complex models are not yet determined and fall back to a simpler +one. + +.. image:: _static/motion_model_performance.png + +Solid lines are batched, dashed per-star; note the log scale on the left, where +the two are three orders of magnitude apart. Hollow markers are cells that did +not fit the model of their column at all, so the lines break rather than run +through them and the right panel draws no ratio for them -- see the footnote. + +.. list-table:: Seconds for one fit of 10,000 stars: batched / per-star (speed-up) + :header-rows: 1 + :widths: 10 23 23 23 23 + + * - Epochs + - ``Fixed`` + - ``Linear`` + - ``Acceleration`` + - ``Parallax`` + * - 2 + - 0.012 / 15.6 (1248x) + - 0.014 / 18.2 (1272x) + - 0.011 / 15.7 (not comparable) \* + - 0.013 / 15.6 (not comparable) \* + * - 3 + - 0.014 / 15.6 (1102x) + - 0.018 / 18.2 (1026x) + - 0.032 / 15.7 (not comparable) \* + - 0.042 / 37.1 (883x) + * - 5 + - 0.016 / 15.6 (973x) + - 0.021 / 18.3 (885x) + - 0.037 / 19.1 (520x) + - 0.045 / 37.1 (828x) + * - 7 + - 0.018 / 15.6 (892x) + - 0.023 / 18.3 (797x) + - 0.041 / 19.1 (461x) + - 0.048 / 37.3 (776x) + * - 9 + - 0.020 / 15.6 (794x) + - 0.025 / 18.4 (738x) + - 0.042 / 19.1 (454x) + - 0.055 / 43.6 (799x) + * - 11 + - 0.022 / 15.7 (715x) + - 0.027 / 18.4 (668x) + - 0.048 / 19.2 (397x) + - 0.058 / 43.6 (756x) + * - 13 + - 0.024 / 15.6 (638x) + - 0.030 / 18.3 (606x) + - 0.049 / 19.2 (390x) + - 0.062 / 43.7 (707x) + * - 15 + - 0.027 / 15.7 (577x) + - 0.033 / 18.4 (563x) + - 0.052 / 19.2 (369x) + - 0.065 / 43.6 (669x) + * - 17 + - 0.030 / 15.6 (525x) + - 0.034 / 18.4 (545x) + - 0.054 / 19.2 (356x) + - 0.069 / 43.6 (636x) + * - 19 + - 0.031 / 15.6 (497x) + - 0.038 / 18.4 (487x) + - 0.060 / 19.2 (322x) + - 0.077 / 43.7 (570x) + +\* Three cells do not time the model their column names. At two epochs neither +``Acceleration`` (3 parameters per direction) nor ``Parallax`` (5, fitted +jointly) has as many data points as parameters, so both branches fall back, to +``Fixed`` on both sides, and those two cells simply repeat the ``Fixed`` +column. At three epochs ``Acceleration`` has exactly as many points +per direction as parameters, and here the branches disagree: the batched one +fits it, with no degrees of freedom left, so the model passes exactly through +the data and there is no residual to estimate an uncertainty from -- the +parameter errors come back infinite. The predecessor instead requires strictly +more epochs than parameters, and demotes those stars past ``Linear`` all the +way to ``Fixed``, so that cell would be timing ``Acceleration`` against +``Fixed``. + +Two shapes stand out. The per-star branch is **flat in the number of epochs** +and set almost entirely by the number of stars -- 10,000 Python-level optimizer +calls cost the same whether each is handed 2 points or 19. The batched branch +instead grows mildly with epochs, roughly doubling across the grid, which is +the only part of the work that is genuinely proportional to the amount of data. +Between them the speed-up falls from about 1250x at two epochs to 320-570x at +nineteen -- which is not the batched fit degrading, but the flat cost it is +measured against staying flat while its own grows. That is the opposite +direction to the star sweep above, and the two together say why: more stars +scale both branches, so their ratio tends towards the ratio of their per-star +costs, while more epochs scale only the batched one. + +The batched fit's cost is also nearly independent of how complicated the model +is: a 5x5 coupled ``Parallax`` solve lands within a factor of three of a +1x1 ``Fixed`` weighted average, because both are one vectorized assembly plus +one batched solve, and neither iterates. + +Three caveats on reading these numbers. Each cell is a single run on one +machine, though a stable one: every per-star series holds to better than 1% +across the whole grid, the single exception being ``Parallax``, which steps up +once between 7 and 9 epochs and is flat either side of it -- in three separate +runs, so that step is real. Second, each model is given one throwaway fit on a +tiny table before it is timed: the first fit of a model in a process pays a +one-time set-up that the rest do not, about 0.03 s for ``Parallax`` -- roughly +two thirds of a whole batched 10,000-star fit -- which would otherwise land +entirely on whichever cell was timed first. Third, the comparison is of fitting +only: ``bootstrap`` is excluded throughout, as it is the one path still +per-star and so is unaffected by any of this. + +The two sweeps overlap at 10,000 stars and 5 epochs, and agree there: the table +above gives ``Fixed`` as 0.016 / 15.6 s at five epochs, and the star sweep +brackets it with 0.016 / 14.0 at 9,000 stars and 0.017 / 17.1 at 11,000. + +How it was measured +------------------- + +The script below produced both tables and both figures. Comparing two branches +needs two checkouts, so it takes the flystar to time as an argument, and runs +once per sweep per branch, then once more per figure to plot. The figures are +committed, so building this documentation runs none of it. + +.. literalinclude:: benchmark_motion_models.py + :language: python + +Adding new models +================= + +New motion models are welcome, and adding one does not mean touching the +fitting machinery. A model is a single class in :mod:`flystar.motion_model` +that subclasses :class:`~flystar.motion_model.MotionModel`, declares what it +fits and what it needs held fixed, and implements two methods. There is no +registry to edit: :func:`~flystar.motion_model.motion_model_map` discovers +models through ``MotionModel.__subclasses__()``, so the class is selectable by +name -- ``motion_models=['Wobble']`` -- as soon as the module defining it is +imported. + +.. admonition:: It has to be a *direct* subclass + :class: warning + + ``__subclasses__()`` is not recursive. Subclassing ``Linear`` to reuse its + parts produces a model that never appears in the map and cannot be selected + by name at all. Inherit from ``MotionModel`` and call into ``Linear`` if you + want to borrow from it. + +What to declare +--------------- + +.. list-table:: + :header-rows: 1 + :widths: 32 68 + + * - Attribute + - Meaning + * - ``name`` + - The string users pass. Names are resolved with ``str.capitalize()``, so + keep it one capitalised word. + * - ``fit_param_names`` + - Every fitted parameter, x-direction first, then y, with any parameter + shared between the two last -- ``Parallax`` is + ``['x0', 'vx', 'y0', 'vy', 'pi']``. + * - ``n_fit_params`` + - ``len(fit_param_names)``. + * - ``n_params`` + - Parameters **per direction**, ``int((n_fit_params + 1) / 2)``. It + doubles as the fewest distinct epochs the model can be fitted from, and + as the sort key that orders models by complexity. + * - ``required_fixed_param_names`` + - Names that must be resolvable from ``fixed_params_dict``, a column or + metadata, or fitting raises ``KeyError``. + * - ``optional_fixed_params`` + - ``{name: default}`` for the ones that fall back instead + (``Parallax``'s ``{'pa': 0., 'obsLocation': 'earth'}``). + * - ``fixed_param_names`` + - ``required_fixed_param_names + list(optional_fixed_params)``. + +What to implement +----------------- + +.. list-table:: + :header-rows: 1 + :widths: 22 78 + + * - Method + - Contract + * - ``model_fit`` + - The bare functional form, ``(dt, *params) -> position``. A house + convention rather than a requirement: nothing outside your own class + calls it, but every model in the tree has one, and keeping the algebra + in a single small method is what makes the rest readable. + * - ``model`` + - ``(t, fit_params, fit_param_errs=None, fixed_params_dict=None)``, + returning ``(x, y)`` -- or ``(x, y, xe, ye)`` when errors are passed in. + This is what propagation calls, so it must honour the time-argument + contract above; use :func:`~flystar.motion_model.broadcast_times` + rather than reimplementing it. + * - ``run_fit`` + - ``(t, x, y, xe, ye, valid, fixed_params_dict=None, weighting='var', + absolute_sigma=True, fill_value=np.nan, verbose=True)``, returning + ``(params, param_errs, chi2x, chi2y)`` with shapes + ``(n_stars, n_fit_params)``, the same, ``(n_stars,)`` and + ``(n_stars,)``. Stars with fewer valid epochs than parameters must come + back as ``fill_value`` / ``inf`` / ``nan`` rather than as whatever the + arithmetic produced. + +The skeleton, then, is:: + + class Wobble(MotionModel): + name = 'Wobble' + fit_param_names = ['x0', 'vx', 'y0', 'vy', 'amp'] + n_fit_params = len(fit_param_names) + n_params = int((n_fit_params + 1) / 2) + + required_fixed_param_names = ['t0', 'period'] + optional_fixed_params = {'phase': 0.} + fixed_param_names = required_fixed_param_names + list(optional_fixed_params) + + def model_fit(self, dt, x0, vx, amp, period, phase): + return x0 + vx*dt + amp * np.sin(2*np.pi*dt/period + phase) + + def model(self, t, fit_params, fit_param_errs=None, fixed_params_dict=None): + ... # broadcast_times(t, ...), then model_fit per direction + + def run_fit(self, t, x, y, xe, ye, valid, fixed_params_dict=None, **kw): + ... # whole batch in, (params, param_errs, chi2x, chi2y) out + +Pick ``n_params`` with the others in mind +----------------------------------------- + +Which model each star gets is decided by how many distinct epochs it has, +matched against the candidate models' ``n_params`` with :func:`numpy.digitize` +-- which needs those values to be **unique** across the models being fitted +together. They are not unique in the tree already: ``Acceleration`` (6 +parameters, 3 per direction) and ``Parallax`` (5, fitted jointly, also 3) +collide, so + +.. code-block:: python + + table.fit_motion_models(motion_models=['Acceleration', 'Parallax']) + +raises ``AssertionError`` rather than guessing. The way to fit two models of +equal complexity in one pass is a ``motion_model_input`` column naming the +model per star, which replaces the epoch-count heuristic with your choice and +lifts the uniqueness requirement. + +Non-linear models +----------------- + +Everything in the tree today is linear in its parameters, which is why the fits +are closed-form and batched (see `Performance`_ above). A model that is not -- +an orbit, a free period, anything needing a starting guess -- does not fit that +pattern, and does not have to. + +``run_fit`` is a *batch interface*, not a promise of a closed form. A model +whose fit cannot be vectorized satisfies the same signature by looping inside +it: + +.. code-block:: python + + def run_fit(self, t, x, y, xe, ye, valid, fixed_params_dict=None, **kw): + n_stars, _ = x.shape + params = np.full((n_stars, self.n_fit_params), kw.get('fill_value', np.nan)) + param_errs = np.full((n_stars, self.n_fit_params), np.inf) + chi2x = np.full(n_stars, np.nan) + chi2y = np.full(n_stars, np.nan) + + for i in range(n_stars): + good = valid[i] + if good.sum() < self.n_params: + continue # leave fill_value / inf / nan + popt, pcov = curve_fit(...) # one star at a time + params[i], param_errs[i] = popt, np.sqrt(np.diag(pcov)) + chi2x[i], chi2y[i] = ... + + return params, param_errs, chi2x, chi2y + +Two things make that acceptable rather than a regression. The cost is paid only +by the stars actually assigned to your model, since the model is chosen per +star and each is fitted with its own. And the measurements above put a number +on what it costs: a per-star ``curve_fit`` loop runs at roughly 1.6 to 3.7 +milliseconds per star, against 1.5 to 4.0 *micro*\ seconds for a batched fit -- +so a non-linear model over a 10,000-star mosaic should be expected to take tens +of seconds, and to dominate the runtime of any table it is used on. + +If your model is linear in its parameters but you are unsure whether it can be +batched, it can: assemble the normal equations with ``.sum(axis=1)`` over the +epoch axis and solve them with a batched :func:`numpy.linalg.inv`, exactly as +``Acceleration`` does. + +Testing a new model +------------------- + +``flystar/tests/test_motion_model.py`` has the patterns worth copying: + +* **Agreement with scipy**, star by star -- ``test_scipy_agreement_*`` fit the + same synthetic data with :func:`scipy.optimize.curve_fit` and compare + parameters, parameter errors *and* :math:`\chi^2`. This is the test that + catches an algebra slip in a hand-derived solve, and it is worth writing + first. +* **The time-argument contract** -- ``test_model_time_shape_contract`` checks + that each accepted shape of ``t`` gives the documented output shape and that + anything else raises. +* **The fixed-parameter round trip** -- fit, then + :meth:`~flystar.startables.StarTable.infer_positions` with nothing passed, + and confirm the model is still selected. This is what fails when a new fixed + parameter is written somewhere the lookup does not search. diff --git a/docs/overview.rst b/docs/overview.rst new file mode 100644 index 0000000..914c34c --- /dev/null +++ b/docs/overview.rst @@ -0,0 +1,146 @@ +======================= +StarList and StarTable +======================= + +The data model +============== + +FlyStar has two container types, both subclasses of :class:`astropy.table.Table`, +and the distinction between them is the shape of their columns. + +:class:`~flystar.starlists.StarList` + **One epoch.** Columns are 1D, with one row per star: ``x``, ``y``, ``m`` + and, optionally, the uncertainties ``xe``, ``ye``, ``me``. This is what a + single detection list from a single image looks like after being read off + disk by :func:`~flystar.starlists.read_starlist` or + :meth:`~flystar.starlists.StarList.from_lis_file`. + +:class:`~flystar.startables.StarTable` + **Many epochs, cross-matched.** Columns are 2D with shape + ``(N_stars, N_lists)``, so ``table['x'][i, j]`` is star ``i`` as measured in + list ``j``. A star not detected in list ``j`` has ``nan`` in that cell -- + that is the "no data" convention throughout the codebase, and it is what + ``valid = isfinite(x) & isfinite(y)`` is derived from internally rather than + from a mask the caller has to build. + + Alongside the 2D per-list columns sit 1D per-star columns holding + quantities collapsed along the list axis: ``x0``, ``y0``, ``m0`` and their + ``_err`` counterparts, plus the motion-model parameters. + +Column naming conventions +========================= + +These conventions are load-bearing -- much of the code dispatches on the +suffix of a column name, so they are worth learning before reading further. + +.. list-table:: + :header-rows: 1 + :widths: 22 18 60 + + * - Pattern + - Shape + - Meaning + * - ``x``, ``y``, ``m`` + - 2D + - Per-list measurement, one column per star list. + * - ``xe``, ``ye``, ``me`` + - 2D + - Per-list uncertainty on the above. + * - ``x0``, ``y0``, ``m0`` + - 1D + - Per-star value collapsed across lists, produced by + :meth:`~flystar.startables.StarTable.combine_lists` / + :meth:`~flystar.startables.StarTable.combine_lists_xym`. + * - ``_err`` + - 1D + - Uncertainty on a per-star quantity -- ``x0_err``, ``vx_err``, ``pi_err``. + Note the suffix is ``_err`` for per-star columns and a bare ``e`` for the + 2D per-list ones. + * - ``t0`` + - 1D or meta + - The reference epoch each star's motion model is expressed about. + * - ``motion_model_input`` + - 1D + - *Optional input.* The caller's per-star request for which model to use. + * - ``motion_model_used`` + - 1D + - *Output.* Which model was actually fit for that star. + +A missing per-star uncertainty is filled with ``inf``, not ``nan``, and not with +a fabricated finite number: a star with no uncertainty information reports an +infinite error rather than a misleadingly precise one. + +Epochs are UTC decimal years +============================ + +Times -- ``list_time`` on a :class:`~flystar.starlists.StarList`, the ``t`` +column, ``t0`` -- are **decimal years interpreted as UTC**, which is what you +get by converting an observation timestamp (a FITS ``DATE-OBS``, say) to a +fraction of a year. Nothing else is expected of you. + +Where a uniform timescale is genuinely required, FlyStar converts internally. +The parallax model needs the Earth's barycentric position, whose ephemeris is +indexed in TDB, so :class:`~flystar.motion_model.Parallax` does the +``utc -> tdb`` conversion itself before evaluating +:func:`~flystar.parallax.parallax_in_direction`. + +You do not need to keep the distinction in mind, but it is a real one, not a +rounding artifact: a decimal year derived from a full timestamp is good to +about a microsecond, so the 69.184 s between TDB and UTC (as of 2026) is far +above your input's precision. Getting the conversion wrong is a genuine error +in the epoch, which is why FlyStar does it explicitly. + +What makes it safe to not think about is the size of its effect on the answer, +rather than on the epoch. A 69 s shift moves the parallax factor by about +:math:`1.3 \times 10^{-5}` of its amplitude; on a fitted parallax that works +out to roughly 0.07 per cent of the parallax's own uncertainty. Worth getting +right, never worth worrying about. + +One consequence worth knowing: converting out of UTC consults a leap-second +table, and ERFA warns ``"dubious year"`` for epochs more than about five years +past the table shipped in the installed ``pyerfa`` (around 2028 for 2.0.1.5). +Real observations are always inside it. If you evaluate a model at epochs far +in the future you may see that warning, and it is telling you something true -- +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 +: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 +read the resulting :class:`~flystar.startables.StarTable` off ``ref_table``. + +:doc:`alignment` covers the aligners; :doc:`motion_models` covers the per-star +motion fit, including what the error columns mean. + +Where the pieces live +===================== + +.. list-table:: + :header-rows: 1 + :widths: 32 68 + + * - Module + - Responsibility + * - :mod:`flystar.starlists` + - Reading, writing and trimming single-epoch lists. + * - :mod:`flystar.startables` + - The cross-matched multi-epoch table and its per-star operations. + * - :mod:`flystar.match` + - Star matching, including the triangle-based blind match + (:func:`~flystar.match.miracle_match_briteN`) and the position/magnitude + tolerance match (:func:`~flystar.match.match`, + :func:`~flystar.match.match_chi2`). + * - :mod:`flystar.transforms` + - Coordinate transformation models -- shifts, four-parameter, + polynomial, Legendre, and spline/clipped variants. + * - :mod:`flystar.motion_model` + - Per-star motion models and the machinery that chooses between them. + * - :mod:`flystar.align` + - The iterative match/transform/average loop that drives everything else. + * - :mod:`flystar.analysis` + - Gaia cross-matching and absolute-frame helpers. + * - :mod:`flystar.plots` + - Diagnostic plots: residual quivers, VPDs, chi-squared distributions. diff --git a/docs/requirements-dev.txt b/docs/requirements-dev.txt new file mode 100644 index 0000000..c680282 --- /dev/null +++ b/docs/requirements-dev.txt @@ -0,0 +1,4 @@ +# Local documentation development. Read the Docs installs requirements.txt +# only -- sphinx-autobuild is for `make livehtml` on your own machine. +-r requirements.txt +sphinx-autobuild>=2024.2.4 diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..53e05fb --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,12 @@ +# Documentation build requirements. +# +# Note: flystar itself is intentionally NOT installed for the docs build. The +# 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 +sphinx-autoapi>=3.0 +myst-nb>=1.0 + +# conf.py reads project metadata from pyproject.toml; tomllib is stdlib on 3.11+. +tomli; python_version < "3.11" diff --git a/docs/transformations.rst b/docs/transformations.rst new file mode 100644 index 0000000..e905f96 --- /dev/null +++ b/docs/transformations.rst @@ -0,0 +1,61 @@ +=============== +Transformations +=============== + +A transformation is the coordinate mapping that carries one star list into the +common reference frame. Every list gets its own: it is what absorbs the +arbitrary pixel origin, rotation, plate scale and distortion of the image the +list came from, so that a star's position means the same thing in every epoch. + +The aligner derives these for you. ``trans_class`` picks the functional form +and ``trans_args`` supplies its arguments, both described in +:doc:`alignment`; the fitted objects come back as ``trans_list``, one +:class:`~flystar.transforms.Transform2D` per input list. This page is about +which form to pick. + +Choosing a model +================ + +``trans_class`` and ``trans_args`` select the transformation model from +:mod:`flystar.transforms`. The useful ones: + +.. list-table:: + :header-rows: 1 + :widths: 34 66 + + * - Class + - Use for + * - :class:`~flystar.transforms.Shift` + - Translation only. + * - :class:`~flystar.transforms.four_paramNW` + - Translation, rotation, single scale. + * - :class:`~flystar.transforms.PolyTransform` + - General polynomial of ``order``; the default (``order=1``). + * - :class:`~flystar.transforms.LegTransform` + - Legendre basis -- better conditioned than a raw polynomial at high + order. + * - :class:`~flystar.transforms.PolyClipTransform`, + :class:`~flystar.transforms.LegClipTransform` + - Clipped variants, for keeping the fit inside a valid domain. + * - :class:`~flystar.transforms.SplineTransform`, and the + ``*ClipSplineTransform`` variants + - Spatially varying distortion that a global polynomial cannot absorb. + +Raising the order as the fit converges +====================================== + +``trans_args`` takes either a single dict, applied to every iteration, or one +dict per iteration. The per-iteration form is how you start loose and tighten: + +.. code-block:: python + + trans_args=[{'order': 1}, {'order': 2}, {'order': 2}] + +The first pass has only the blind initial guess to work from, so a low order is +all the matches can support. Once the frame is roughly right and the matching +has tightened, a higher order has enough well-matched stars to be worth +fitting. Going straight to a high order on the first pass fits the order to the +mismatches instead. + +See :doc:`alignment` for the per-iteration schedules in general, and for +``trans_weights``, ``trans_input`` and ``calc_trans_inverse``. diff --git a/flystar/align.py b/flystar/align.py index b0fcd6d..251aa57 100755 --- a/flystar/align.py +++ b/flystar/align.py @@ -1,40 +1,112 @@ +import os +import gc +import pdb +import copy +import pickle +import warnings +import datetime import numpy as np -from flystar import match -from flystar import transforms -from flystar import plots +import matplotlib.pyplot as plt +from tqdm import tqdm +from flystar import match, transforms, plots, motion_model from flystar.starlists import StarList +from flystar import startables from flystar.startables import StarTable from astropy.table import Table, Column, vstack -import datetime -import copy -import os -import pdb -import time -import warnings from astropy.utils.exceptions import AstropyUserWarning -# Keep a list of columns that are "aggregated" motion model terms. -motion_model_col_names = ['x0', 'x0e', 'y0', 'y0e', - 'vx', 'vxe', 'vy', 'vye', - 'ax', 'axe', 'ay', 'aye', - 't0', 'm0', 'm0e', 'use_in_trans'] class MosaicSelfRef(object): - def __init__(self, list_of_starlists, ref_index=0, iters=2, - dr_tol=[1, 1], dm_tol=[2, 1], - outlier_tol=[None, None], - trans_args=[{'order': 2}, {'order': 2}], - init_order=1, - mag_trans=True, mag_lim=None, weights=None, - trans_input=None, trans_class=transforms.PolyTransform, - use_vel=False, calc_trans_inverse=False, - init_guess_mode='miracle', iter_callback=None, - verbose=True): - + """ + Align a stack of starlists to a reference frame built from the lists + themselves. + + The first iteration uses one of the input lists (``ref_index``) as the + reference. Every iteration after that aligns against the sigma-clipped + average of all the lists, so the reference frame is progressively defined + by the whole stack rather than by any single list. Use + :class:`MosaicToRef` instead when the alignment should be tied to an + external reference catalog (Gaia, say) that is not one of the starlists. + + Construct the object with the alignment settings, then call + :meth:`fit` to run the alignment. The results are left on the object: + ``ref_table`` (the combined :class:`~flystar.startables.StarTable`) and + ``trans_list`` (one transformation per starlist). The input starlists + are kept as ``star_lists``, which is what maps + ``ref_table['idx_in_list']`` back to per-list names -- see + :func:`names_in_list`. + + See :meth:`__init__` for the full list of settings. + + Attributes + ---------- + ref_table : StarTable + The combined table produced by :meth:`fit`, holding both the + per-list (2D) quantities and the averaged (1D) ones. + trans_list : list of transforms.Transform2D + The best-fit transformation for each starlist, in input order. + trans_list_inverse : list of transforms.Transform2D + The reference-to-starlist transformations. Only present when the + object was constructed with ``calc_trans_inverse=True``. + + Examples + -------- + + .. code-block:: python + + msc = align.MosaicSelfRef(list_of_starlists, dr_tol=[1.0, 0.5], + ref_index=0, dm_tol=[2.0, 1.0], + trans_class=transforms.PolyTransform, + trans_args={'order': 1}) + msc.fit() + msc.ref_table['x0'] # averaged positions + msc.trans_list[0].px # transformation for the first starlist + """ + def __init__( + self, + list_of_starlists, + dr_tol, + starlist_vertices=None, + # Alignment parameters + ref_index=0, + dm_tol=None, + outlier_tol=None, + matching='legacy', + dchi2_tol=9.0, + match_sigma_pos=None, + match_sigma_mag=None, + # Transformation parameters + trans_class=transforms.PolyTransform, + trans_args={'order': 1}, + trans_input=None, + trans_weights=None, + init_order=1, + init_guess_mode='miracle', + briteN=None, + ignore_contains='star', + calc_trans_inverse=False, + # Magnitude parameters + mag_trans=True, + mag_lim=None, + # Motion model parameters + motion_models=['Empty', 'Fixed'], + fixed_params_dict=None, + vel_weights='var', + absolute_sigma=True, + # Advanced options + inherit_n_detect=True, + iter_callback=None, + save_path=None, + save_plot=True, + save_object=True, + save_format='hdf5', + prefix_name='msr', + verbose=True + ): """ - Make a mosaic object by passing in a list of starlists and then running fit(). + Make a mosaic object by passing in a list of starlists and then running fit(). - Required Parameters + Parameters ---------- list_of_starlists : array of StarList objects An array or list of flystar.starlists.StarList objects (which are Astropy Tables). @@ -43,156 +115,355 @@ def __init__(self, list_of_starlists, ref_index=0, iters=2, Note that there is an optional weights column called 'w'. If this column exists in any of the lists, it will be queried to determine if an individual star can be used to derive the transformations between starlists. This is the most flexible way - to allow you to determine, as a function of time and star, which ones are good enough - in the transformation. Note that just because it can be used (i.e. w_in=1), - doesn't meant that it will be used. The mag limits and outliers still take precedence. - Note also that the weights that go into the transformation are + to allow you to determine, as a function of time and star, which ones are good enough + in the transformation. Note that just because it can be used (i.e. w_in=1), + doesn't meant that it will be used. The mag limits and outliers still take precedence. + Note also that the weights that go into the transformation are star_list['w'] * ref_list['w'] * weight_from_keyword (see the weights parameter) - for those stars not trimmed out by the other criteria. - - - Optional Parameters - ---------- - ref_index : int + for those stars not trimmed out by the other criteria. + starlist_vertices : list or array, optional + A list or array of polygon vertices coordinates for each starlist. Initial guess will only use stars in overlapping regions defined by these polygons. + Shape of (N_lists, N_vertices, 2) in the format of [[x1, y1], [x2, y2], ..., [xN, yN]] for each starlist, by default None + ref_index : int, optional The index of the reference epoch. (default = 0). Note that this is the reference - list only for the first iteration. Subsequent iterations will utilize the sigma-clipped - mean of the positions from all the starlists. - - iters : int - The number of iterations used in the matching and transformation. TO DO: INNER/OUTER? - - dr_tol : list or array - The delta-radius (dr) tolerance for matching in units of the reference coordinate system. - This is a list of dr values, one for each iteration of matching/transformation. - - dm_tol : list or array + list only for the first iteration. Subsequent iterations will utilize the sigma-clipped + mean of the positions from all the starlists. + dr_tol : float, list or array + The delta-radius (dr) tolerance for matching in units of the reference + coordinate system. Required: matching is a radius search, so there is no + meaningful default. This is a list of dr values, one for each iteration of + matching/transformation, or a single value used for every iteration. + dm_tol : float, list or array, or None, optional The delta-magnitude (dm) tolerance for matching in units of the reference coordinate system. - This is a list of dm values, one for each iteration of matching/transformation. - - outlier_tol : list or array - The outlier tolerance (in units of sigma) for rejecting outlier stars. - This is a list of tol values, one for each iteration of matching/transformation. - - mag_trans : boolean - If true, this will also calculate and (temporarily) apply a zeropoint offset to - magnitudes in each list to bring them into a common magnitude system. This is - essential for matching (with finite dm_tol) starlists of different filters or - starlists that are not photometrically calibrated. Note that the final_table columns - of 'm', 'm0', and 'm0e' will contain the transformed magnitudes while the - final_table column 'm_orig' will contain the original un-transformed magnitudes. - If mag_trans = False, then no such zeropoint offset it applied at any point. - - mag_lim : array - If different from None, it indicates the minimum and maximum magnitude - on the catalogs for finding the transformations. Note, if you want specify the mag_lim - separately for each list and each iteration, you need to pass in a 2D array that - has shape (N_lists, 2). - - weights : str + This is a list of dm values, one for each iteration of matching/transformation, + or a single value used for every iteration. None (the default) places + no magnitude cut on the match, matching on position alone. + outlier_tol : float, list or array, optional + The outlier tolerance (in units of sigma) for rejecting outlier stars. + This is a list of tol values, one for each iteration of matching/transformation, + or a single value used for every iteration, by default None. + + The number of iterations is the length of the longest of ``dr_tol``, + ``dm_tol``, ``outlier_tol`` and ``trans_args``; single values are + broadcast to it, and two sequences of differing length are an error. + All single values therefore means a single iteration. + matching : str, optional + How a star with more than one candidate inside the tolerances is + resolved, and how one-to-one is enforced. 'legacy' (default) keeps + the historical behavior: a multi-candidate star is matched only if + its nearest candidate in position is also its nearest in + magnitude. In a crowded field that discards good matches -- a + candidate 20x closer loses to one a few hundredths of a magnitude + nearer -- and each discarded star then becomes a duplicate + reference row that makes the next starlist ambiguous in turn, so + one split seeds the next. 'chi2' scores candidates as + (dr/sigma_pos)^2 + (dm/sigma_mag)^2, with the scales measured from + the starlists themselves, and keeps reciprocal best pairs that win + by dchi2_tol. See match.match_chi2 for the details. + By default 'legacy'. + dchi2_tol : float, optional + matching='chi2' only. How much better the best candidate must be + than the runner-up, in chi^2. Default 9.0, a 3-sigma margin. Below + it the star is treated as genuinely ambiguous and left unmatched, by default 9.0. + match_sigma_pos : float or None, optional + matching='chi2' only. Position scale for the chi^2, in reference + coordinate units. None (default) measures it from the unambiguous + pairs of each starlist, so no error columns are required, by default None. + match_sigma_mag : float or None, optional + matching='chi2' only. Magnitude scale for the chi^2. None (default) + measures it the same way. + If not provided, will be None for each iteration, by default None. + trans_class : transforms.Transform2D object (or subclass), optional + The transform class that will be used to when deriving the optimal + transformation parameters between each list and the reference list, by default transforms.PolyTransform. + trans_args : dict or list of dict, optional + A dictionary containing any extra keywords that are needed in the + transformation object (for instance, "order"), applied to every + iteration -- or a list of such dictionaries, one per iteration, to + use a different transformation argument (e.g. increasing order) + in later iterations. If a list is passed in, its length must + equal the number of iterations. By default {'order': 1}. + trans_input : transform object, or array or list of them, optional + If not None, then this should contain an array or list of transform + objects that will be used as the initial guess in the alignment and matching. + There must be one per starlist, in the same order; entries may be + None. A single transform object (rather than a sequence) is used as + the initial guess for every starlist. + By default None. + trans_weights : str, optional Either None (def), 'both,var', 'list,var', or 'ref,var' depending on whether you want to weight by the positional uncertainties (variances) in the individual starlists, or also with the uncertainties in the reference frame itself. Note weighting only works when there are positional uncertainties availabe. Other options include 'both,std', 'list,std', 'list,var'. - - trans_input : array or list of transform objects - def = None. If not None, then this should contain an array or list of transform - objects that will be used as the initial guess in the alignment and matching. - - trans_class : transforms.Transform2D object (or subclass) - The transform class that will be used to when deriving the optimal - transformation parameters between each list and the reference list. - - trans_args : dictionary - A dictionary (or a list of dictionaries) containing any extra keywords that are needed - in the transformation object. For instance, "order". Note that if a list is passed in, - then the transformation argument (i.e. order) will be changed for every iteration in - iters. - - use_vel : boolean - If velocities are present in the reference list and use_vel == True, then during - each iteration of the alignment, the reference list will be propogated in time - using the velocity information. So all transformations will be derived w.r.t. - the propogated positions. See also update_vel. - - calc_trans_inverse: boolean + By default None. + init_order : int, optional + The order of the initial transformation used for the first iteration, by default 1. + init_guess_mode : str, optional + If no initial transformations are passed in via the trans_input keyword, then we have + to make the initial transformation and matching blindly. We can do this in a couple of + different ways. Options are 'miracle' or 'name' (see trans_initial_guess() for more details). + By default 'miracle'. + ignore_contains : str or None, optional + Only used when init_guess_mode='name'. Names containing this + substring are left out of the name match. The default 'star' + is there because auto-detected sources are conventionally + labelled star_1, star_2, ... per epoch -- those indices are + per-list detection numbers, not stable identities, so matching + on them pairs unrelated stars. Genuinely named sources (S0-2, + irs16NE, ...) mean the same thing in every list. + Pass None to match on every name, which is what you want when + the names really are stable identifiers across epochs (a + cross-matched catalog, or synthetic data). '' is rejected + rather than treated as "off": every name contains the empty + string, so it would discard everything. By default 'star'. + briteN : int, optional + If init_guess_mode is 'miracle', this is the number of brightest stars to use in the miracle match. + Default is min(50, len(star_list)). + calc_trans_inverse : boolean, optional If true, then calculate the inverse transformation (from reference to starlist) in addition to the normal transformation (from starlist to reference). The inverse calculation is calculated by switching the order to the positions in match_and_transform. The inverse transformations are saved in self.trans_list_inverse. - - self.trans_list_inverse doesn't exist if calc_trans_inverse == False - - init_guess_mode : string - If no initial transformations are passed in via the trans_input keyword, then we have - to make the initial transformation and matching blindly. We can do this in a couple of - different ways. Options are 'miracle' or 'name' (see trans_initial_guess() for more details). - - iter_callback : None or function + self.trans_list_inverse doesn't exist if calc_trans_inverse == False, by default False. + mag_trans : bool, optional + If true, this will also calculate and (temporarily) apply a zeropoint offset to + magnitudes in each list to bring them into a common magnitude system. This is + essential for matching (with finite dm_tol) starlists of different filters or + starlists that are not photometrically calibrated. Note that the final_table columns + of 'm', 'm0', and 'm0_err' will contain the transformed magnitudes while the + final_table column 'm_orig' will contain the original un-transformed magnitudes. + If mag_trans = False, then no such zeropoint offset it applied at any point, by default True. + mag_lim : array, optional + Magnitude range on the starlists used for finding the + transformations, applied BEFORE the magnitude transformation. Its + single axis indexes iterations, exactly like dr_tol, dm_tol and + outlier_tol: + + ========================= ========================================= + shape meaning + ========================= ========================================= + None no limit anywhere (default) + ``(2,)`` one [min, max] everywhere + ``(N_iters, 2)`` per iteration, same for every list + ``(N_iters, N_lists, 2)`` per iteration and per list + ========================= ========================================= + + Per-starlist limits are given with the 3D form. Note that the 2D + form previously meant ``(N_lists, 2)``; it now means + ``(N_iters, 2)`` so that one axis means the same thing across every + schedule argument, by default None. + motion_models : list of MotionModel or str, or str, optional + Motion models or their names to use for new or unassigned stars. 'Empty' and 'Fixed' will always be added. + Can be a single string (e.g., 'Linear') or a list of motion models string or class (e.g., ['Linear', 'Parallax'], [Linear, Acceleration]) + Note that the provided motion models have to have different numbers of parameters, otherwise the code will not know which one to use for new stars. + The most complex motion model will be used for new stars, by default None. + fixed_params_dict : None or dict, optional + Dictionary of motion model fixed parameters, e.g., ra, dec, pa, obsLocation, t0, etc. See motion_model classes for details. + By default None. + vel_weights : str, optional + Either 'var' (def) or 'std', depending on whether you want to weight the motion model + fits by the variance or standard deviation of the position data, by default 'var'. + absolute_sigma : bool, optional + Controls how x0_err/y0_err/m0_err (and Linear/Acceleration/ + Parallax's own fit_param_errs) are computed, for every star + regardless of which motion model actually ends up fitting it -- + stars combined via a plain weighted average (Fixed/Empty-eligible + stars, via StarTable.combine_lists) and stars fit with + Linear/Acceleration/Parallax etc. (via MotionModel.run_fit) both + honor this the same way. If True (default), the formal + error-propagated uncertainty, trusting the per-epoch input + errors (xe/ye/me) as correct. If False, that propagated + uncertainty is instead rescaled by sqrt(chi2/dof) (scipy's own + absolute_sigma=False convention) -- how much the epochs actually + disagree, regardless of what their individual errors claim. More + honest than absolute_sigma=True when input errors are + systematically underestimated, at the cost of not shrinking as + more epochs are added (unlike a true standard-error-of-the-mean). + See StarTable.combine_lists and MotionModel.run_fit for details. + By default True. + inherit_n_detect : bool, optional + If True, and an input starlist already has its own 'n_detect' column + (e.g. it is itself the output of a previous, lower-level align pass), + use that starlist's own n_detect value -- instead of counting 1 -- + as the contribution from that starlist when computing this mosaic's + n_detect. So a star's final n_detect reflects the total number of + raw detections it represents, however many alignment layers deep. + Starlists without their own 'n_detect' still contribute 1 per + detection, same as when this is False. By default True. + iter_callback : None or function, optional A function to call (that accepts a StarTable object and an iteration number) - at the end of every iteration. This can be used for plotting or printing state. - - verbose : int (0 to 9, inclusive) + at the end of every iteration, and once more after the final + re-matching pass with an index equal to the number of iterations + (one past the last iteration), so that last call can be told apart + from the end of the last iteration. Useful for plotting or printing state, and for + rejecting stars between iterations: the table handed in is the live + ref_table, so setting `use_in_trans = False` on a row excludes it + from subsequent transformations while keeping the star in the + output, by default None. + save_path : str, optional + Directory to save fit results to: PREFIX_input.txt (the fit + parameters), PREFIX_ref_table. (self.ref_table, extension + set by save_format), and PREFIX_trans_list.pkl (self.trans_list + -- the derived transform objects, which aren't plain data and so + need pickling). If calc_trans_inverse is True, + PREFIX_trans_list_inverse.pkl (self.trans_list_inverse) is also + saved. calc_bootstrap_errors writes + PREFIX_ref_table_bootstrap. (and PREFIX_bootstrap.pkl when + save_object is True) to the same directory. By default None + (nothing saved). + save_plot : bool, optional + If save_path is set, also save a transformation diagnostic plot + for every (starlist, iteration) under + save_path/transformation_plots/iterN/. These are a real cost on + large starlists (an unthinned scatter of every star, saved at + dpi=300, once per starlist per iteration) -- set to False to + keep saving results without paying for them. Ignored if + save_path is None. By default True. + save_object : bool, optional + If save_path is set, also pickle the entire mosaic object (self) + to PREFIX.pkl. This is a much heavier, less stable file + than the ref_table.hdf5/trans_list.pkl saved by default (it's + tied to flystar's class definitions, so it can break across + flystar versions) -- but it lets you reload the whole object + later (e.g. `with open(...) as f: msc = pickle.load(f)`) and + keep using its normal instance methods (e.g. + calc_bootstrap_errors) with their usual, self-contained + parameter list, rather than having to supply every piece of + alignment config by hand. Ignored if save_path is None. By + default True. + save_format : {'hdf5', 'fits', 'pkl'}, optional + File format for PREFIX_ref_table.. 'hdf5' (default) stores + nan as plain nan and has no header keyword length limit. + 'fits' round-trips nan through a MaskedColumn on read (silently + changing every nan-containing column's dtype), and FITS header + keywords are capped at 8 characters, so meta keys longer than + that (e.g. 'list_times') are written using the HIERARCH + convention -- handled here on a column-sharing copy of + ref_table, so self.ref_table's own meta keys are never renamed. + 'pkl' pickles self.ref_table directly: slower to load and tied + to flystar's/astropy's class definitions like save_object, but + preserves every column and meta key exactly as-is. Ignored if + save_path is None. By default 'hdf5'. + prefix_name : str, optional + Prefix for the saved file names (see save_path), by default 'msr'. + verbose : bool or int (0 to 9, inclusive), optional Controls the verbosity of print statements. (0 least, 9 most verbose). For backwards compatibility, 0 = False, 9 = True. (Note: technically right now no checks on whether the number is an integer or not...) - Example - ---------- - msc = align.MosaicToRef(list_of_starlists, iters=1, - dr_tol=[0.1], dm_tol=[5], - outlier_tol=[None], mag_lim=[13, 21], - trans_class=transforms.PolyTransform, - trans_args=[{'order': 1}], - weights='both,std', - init_guess_mode='miracle', verbose=False) - msc.fit() - - # Access a list of all the transformation parameters: - trans_list = msc.trans_list - - # Access the fully-combined reference table. - stars_table = msc.ref_table - - # Plot the magnitude of the first star vs. time: - # Overplot the mean magnitude. - plt.plot(stars_table['t'][0, :], stars_table['m'][0, :], 'k.') - plt.axhline(stars_table['m0'][0]) - - # Plot the X position of the first star vs. time: - # Overplot the best-fit proper motion. - times = stars_table['t'][0, :] - plt.errorbar(times, stars_table['x'][0, :], yerr=stars_table['xe'][0, :]) - plt.axhline(stars_table['x0'][0] + stars_table['vx'][0]*(times - stars_table['t0'][0])) - + Examples + -------- + .. code-block:: python + + mtr = align.MosaicToRef(list_of_starlists, + dr_tol=0.1, dm_tol=5, + outlier_tol=[None], mag_lim=[13, 21], + trans_class=transforms.PolyTransform, + trans_args=[{'order': 1}], + weights='both,std', + init_guess_mode='miracle', verbose=False) + mtr.fit() + + # Access a list of all the transformation parameters: + trans_list = mtr.trans_list + + # Access the fully-combined reference table. + stars_table = mtr.ref_table + + # Plot the magnitude of the first star vs. time: + # Overplot the mean magnitude. + plt.plot(stars_table['t'][0, :], stars_table['m'][0, :], 'k.') + plt.axhline(stars_table['m0'][0]) + + # Plot the X position of the first star vs. time: + # Overplot the best-fit proper motion. + times = stars_table['t'][0, :] + plt.errorbar(times, stars_table['x'][0, :], yerr=stars_table['xe'][0, :]) + plt.axhline(stars_table['x0'][0] + stars_table['vx'][0]*(times - stars_table['t0'][0])), by default True. """ + # The number of iterations is just how long the per-iteration + # schedules are. Every one of them gets a vote: whichever is given as + # a sequence sets the count, and a single value is broadcast up to it + # in fix_iterable_conditions(). dr_tol is required -- matching is a + # radius search, so there is no meaningful default -- but it is not + # privileged in setting the length, so dr_tol=0.5 with + # dm_tol=[1., 0.5] is two passes at a constant radius. Two sequences + # that disagree are still an error, raised in + # fix_iterable_conditions(); only single values broadcast, so nothing + # here can silently paper over a mismatch. mag_lim does not vote: its + # [min, max] form is a pair, not a schedule, and would claim two + # iterations. + self.iters = max(schedule_len(tol) + for tol in (dr_tol, dm_tol, outlier_tol, trans_args)) self.star_lists = list_of_starlists + self.starlist_vertices = starlist_vertices self.ref_index = ref_index - self.iters = iters self.dr_tol = dr_tol self.dm_tol = dm_tol - self.outlier_tol = outlier_tol self.trans_args = trans_args self.init_order = init_order self.mag_trans = mag_trans self.mag_lim = mag_lim - self.weights = weights + self.trans_weighting = trans_weights + self.vel_weighting = vel_weights self.trans_input = trans_input self.trans_class = trans_class - self.calc_trans_inverse = calc_trans_inverse - self.use_vel = use_vel + self.calc_trans_inverse = calc_trans_inverse + self.absolute_sigma = absolute_sigma + self.inherit_n_detect = inherit_n_detect + self.fixed_params_dict = fixed_params_dict self.init_guess_mode = init_guess_mode + self.briteN = briteN + self.ignore_contains = ignore_contains self.iter_callback = iter_callback + self.save_path = save_path + self.save_plot = save_plot + self.save_object = save_object + if save_format not in ('hdf5', 'fits', 'pkl'): + raise ValueError(f"save_format must be 'hdf5', 'fits', or 'pkl', got {save_format!r}") + self.save_format = save_format + self.prefix_name = prefix_name self.verbose = verbose - # For backwards compatibility. - if self.verbose is True: - self.verbose = 9 - if self.verbose is False: - self.verbose = 0 - + if self.starlist_vertices is not None: + import shapely + self.reflist_polygon = shapely.make_valid(shapely.Polygon(self.starlist_vertices[self.ref_index])) + else: + self.reflist_polygon = None + + for ii in range(len(self.star_lists)): + # Check x and y are 1d + if self.star_lists[ii]['x'].ndim != 1 or self.star_lists[ii]['y'].ndim != 1: + raise ValueError(f"StarList at index {ii} has x and y that are not 1D. x.ndim={self.star_lists[ii]['x'].ndim}, y.ndim={self.star_lists[ii]['y'].ndim}. Please flatten these columns to be 1D.") + # Add list_time to meta if not present + if 'list_time' not in self.star_lists[ii].meta: + assert 't' in self.star_lists[ii].colnames, f"StarList at index {ii} does not have 'list_time' in meta and does not have 't' column. Please add one of these." + unique_t = np.unique(self.star_lists[ii]['t']) + assert unique_t.size == 1, f"The time values of starlist at index {ii} are not unique." + self.star_lists[ii].meta['list_time'] = unique_t[0] + + if outlier_tol is None: + self.outlier_tol = [None] * self.iters + else: + self.outlier_tol = outlier_tol + + self.matching = matching + self.dchi2_tol = dchi2_tol + self.match_sigma_pos = match_sigma_pos + self.match_sigma_mag = match_sigma_mag + + # Organize motion models into a list of MotionModel classes, sorted by increasing number of parameters. + self.motion_models = motion_model.organize_motion_models(motion_models) + + # if motion_model_for_new_star is None: + # self.motion_model_for_new_star = self.motion_models[-1] + # elif isinstance(motion_model_for_new_star, str): + # assert motion_model_for_new_star in all_mm_map.keys(), f"motion_model_for_new_star must be in {list(all_mm_map.keys())}" + # self.motion_model_for_new_star = all_mm_map[motion_model_for_new_star] + self.N_lists = len(self.star_lists) # Hard-coded values: @@ -202,8 +473,17 @@ def = None. If not None, then this should contain an array or list of transform ########## # Error checking for parameters. ########## - self.fix_iterable_conditions() # fix dr_tol, dm_tol, outlier_tol, mag_lim to be iterable. - check_iter_tolerances(self.iters, self.dr_tol, self.dm_tol, self.outlier_tol) + self.fix_iterable_conditions() # fix dr_tol, dm_tol, outlier_tol, mag_lim, trans_args to be iterable. + + # A single transformation object means "use this one as the initial + # guess for every starlist", so replicate it up to one per list before + # anything downstream tries to index or len() it. Note the entries are + # the same object, not copies -- the alignment only reads an input + # transformation and then replaces it with a derived one, exactly as + # trans_args replicates a single dict across iterations. + if (self.trans_input is not None) and (not isinstance(self.trans_input, (list, tuple, np.ndarray))): + self.trans_input = [self.trans_input] * self.N_lists + check_trans_input(self.star_lists, self.trans_input, self.mag_trans) ########## @@ -214,41 +494,118 @@ def = None. If not None, then this should contain an array or list of transform # is passed in, replicate for all star lists, all loop iterations. ########## self.setup_trans_info() - return def fix_iterable_conditions(self): - if not np.iterable(self.dr_tol): - self.dr_tol = np.repeat(self.dr_tol, self.iters) - assert len(self.dr_tol) == self.iters - - if not np.iterable(self.dm_tol): - self.dm_tol = np.repeat(self.dm_tol, self.iters) - assert len(self.dm_tol) == self.iters - - if not np.iterable(self.outlier_tol): - self.outlier_tol = np.repeat(self.outlier_tol, self.iters) - assert len(self.outlier_tol) == self.iters - + """ + Normalize the per-iteration settings into arrays of length ``iters``. + + ``iters`` is the length of the longest schedule, set in ``__init__``. + ``dr_tol``, ``dm_tol`` and ``outlier_tol`` may each be given as a + single value (used for every iteration) or as a sequence with one + entry per iteration; single values are broadcast here and the lengths + are checked, so two sequences that disagree are an error. + ``trans_args`` is treated the same way, a bare dict being replicated + for every iteration. + + ``mag_lim`` is normalized to shape ``(N_iters, N_lists, 2)``. Its + accepted forms are: + + - ``None`` -- no magnitude cut anywhere, + - ``[min, max]`` -- that cut on every list, every iteration, + - ``(N_iters, 2)`` -- per iteration, the same for every list, + - ``(N_iters, N_lists, 2)`` -- fully specified. + + Note that the single-axis form indexes ITERATIONS, matching + ``dr_tol``/``dm_tol``/``outlier_tol``, so that one axis means the + same thing across every schedule argument. Per-starlist limits must + use the 3D form. + + Raises + ------ + AssertionError + If a sequence-valued setting has a length other than ``iters``, + or ``mag_lim`` has a 3D shape other than + ``(iters, N_lists, 2)``. + ValueError + If ``mag_lim`` has a shape that is not one of the forms above. + """ + for name in ('dr_tol', 'dm_tol', 'outlier_tol'): + tol = np.atleast_1d(getattr(self, name)) + if len(tol) == 1: + tol = np.repeat(tol, self.iters) + assert len(tol) == self.iters, \ + (f'len({name})={len(tol)} != iters={self.iters}. The per-iteration ' + f'settings must be single values or sequences of the same length; ' + f'iters is the longest one given.') + setattr(self, name, tol) + + # Format self.mag_lim to be (N_iters, N_lists, 2) array. If only a single mag_lim is passed in, replicate for all lists. + # mag_lim accepts, and is normalized to, (N_iters, N_lists, 2). Its + # single-axis form indexes ITERATIONS, exactly like dr_tol, dm_tol and + # outlier_tol: + # None -> no cut anywhere + # [min, max] -> that cut on every list, every iteration + # (N_iters, 2) -> per iteration, same for every list + # (N_iters, N_lists, 2) -> fully specified + # Per-starlist limits are expressed with the 3D form. The 2D form used + # to mean (N_lists, 2); it now means (N_iters, 2), so that one axis + # means the same thing across every schedule argument. if self.mag_lim is None: - self.mag_lim = np.repeat([[None, None]], len(self.star_lists), axis=0) - elif (len(self.mag_lim) == 2): - self.mag_lim = np.repeat([self.mag_lim], len(self.star_lists), axis=0) - assert len(self.mag_lim) == len(self.star_lists) + self.mag_lim = np.array([[None] * len(self.star_lists)] * self.iters) + else: + # asarray first, so lists work as readily as arrays. + self.mag_lim = np.asarray(self.mag_lim) + + if (self.mag_lim.ndim == 1) and (len(self.mag_lim) == 2): + # One pair for everything. + self.mag_lim = np.array([[self.mag_lim] * len(self.star_lists)] * self.iters) + elif (self.mag_lim.ndim == 2) and (self.mag_lim.shape == (self.iters, 2)): + # Per iteration; same for every starlist. + self.mag_lim = np.repeat(self.mag_lim[:, np.newaxis, :], + len(self.star_lists), axis=1) + elif self.mag_lim.ndim == 3: + assert self.mag_lim.shape == (self.iters, len(self.star_lists), 2), \ + (f'mag_lim must have shape (iters, N_lists, 2) = ' + f'({self.iters}, {len(self.star_lists)}, 2), but has shape {self.mag_lim.shape}') + else: + extra = '' + if self.mag_lim.ndim == 2 and self.mag_lim.shape == (len(self.star_lists), 2): + extra = (' Note that the 2D form now indexes iterations, not starlists, to match ' + 'dr_tol/dm_tol/outlier_tol. For per-starlist limits use the 3D form.') + raise ValueError( + f'mag_lim must be None, a 2-element array, a (N_iters, 2) = ' + f'({self.iters}, 2) array, or a (N_iters, N_lists, 2) = ' + f'({self.iters}, {len(self.star_lists)}, 2) array. ' + f'Got shape {self.mag_lim.shape}.' + extra + ) + + # Keep a list of trans_args, one per iteration. If only a single dict + # is passed in, replicate it for every iteration -- this is also why + # the default is a bare dict rather than a length-1 list: a bare dict + # stays valid for whatever `iters` is set to, while a list has to be + # kept in sync with `iters` by hand. + if type(self.trans_args) == dict: + tmp = self.trans_args + self.trans_args = [tmp for ii in range(self.iters)] + assert len(self.trans_args) == self.iters, \ + (f'len(trans_args)={len(self.trans_args)} != iters={self.iters}. The ' + f'per-iteration settings must be single values or sequences of the ' + f'same length; iters is the longest one given.') return - - - def fit(self): + + + def fit(self, processes=1, chunksize=None, match_workers=1, mp_star_threshold=100_000): """ Using the current parameter settings, match and transform all the lists to a reference position. Note in the first pass, the reference position is just the specified input reference starlist. In subsequent iterations, - this is updated. + this is updated. The ultimate outcome is the creation of self.ref_table. This reference - table will contain "averaged" quantites as well as a big 2D array of all - the matched original and transformed quantities. + table will contain "averaged" quantities as well as a big 2D array of all + the matched original and transformed quantities. Averaged columns on ref_table: x0 @@ -257,12 +614,74 @@ def fit(self): x0e y0e m0e - vx (only if use_vel=True) - vy (only if use_vel=True) - vxe (only if use_vel=True) - vye (only if use_vel=True) + additional motion_model columns + Parameters + ---------- + processes : int, optional + Number of processes to use for parallel processing, maximum os.cpu_count(), by default 1 (no multiprocessing) + chunksize : int, optional + Chunk size for multiprocessing, by default None (auto) + match_workers : int, optional + Number of worker threads scipy uses for the KDTree neighbor search inside + match.match(). Default is 1 (single-threaded), which is the safe choice on + shared/multi-tenant machines where grabbing all cores would step on other + users' jobs. Set to -1 to use all available CPU cores (measurably faster on + large starlists, with no change in matching results -- the neighbor lists + returned per query point are identical, order included, regardless of thread + count), or to a specific positive integer to cap the thread count on a shared + machine. + mp_star_threshold : int, optional + Minimum number of stars needing the per-star motion-model fitting + path before a multiprocessing Pool is used for fitting, even if + processes > 1. A star needs that path only when bootstrap > 0 + (bootstrap resampling isn't vectorized across stars). Below this + threshold, fitting runs serially instead -- Pool startup/IPC + overhead isn't worth it for small workloads. See + StarTable.fit_motion_models for details. By default 100_000. """ + # Setup save_path: + if self.save_path: + if not os.path.exists(os.path.dirname(self.save_path)): + os.makedirs(os.path.dirname(self.save_path)) + + # Save input params + input_filename = f'{self.prefix_name}_input.txt' + input_dict = { + 'ref_index': self.ref_index, + 'iters': self.iters, + 'dr_tol': self.dr_tol, + 'dm_tol': self.dm_tol, + 'outlier_tol': self.outlier_tol, + 'matching': self.matching, + 'dchi2_tol': self.dchi2_tol, + 'match_sigma_pos': self.match_sigma_pos, + 'match_sigma_mag': self.match_sigma_mag, + 'trans_class': self.trans_class, + 'trans_args': self.trans_args, + 'trans_input': self.trans_input, + 'trans_weights': self.trans_weighting, + 'init_order': self.init_order, + 'init_guess_mode': self.init_guess_mode, + 'calc_trans_inverse': self.calc_trans_inverse, + 'mag_trans': self.mag_trans, + 'mag_lim': self.mag_lim, + 'motion_models': self.motion_models, + 'fixed_params_dict': self.fixed_params_dict, + 'vel_weights': self.vel_weighting, + 'absolute_sigma': self.absolute_sigma, + 'iter_callback': self.iter_callback, + 'save_path': self.save_path, + 'prefix_name': self.prefix_name, + 'verbose': self.verbose + } + if self.save_path is not None: + if not os.path.exists(self.save_path): + os.makedirs(self.save_path) + with open(os.path.join(self.save_path, input_filename), 'w') as file: + for key, value in input_dict.items(): + file.write(f'{key}:\t{value}\n') + ########## # Setup a reference table to store data. It will contain: # x0, y0, m0 -- the running average of positions: 1D @@ -270,8 +689,9 @@ def fit(self): # x_orig, y_orig, m_orig, (opt. errors) -- the transformed errors for the lists: 2D # w, w_orig (optiona) -- the input and output weights of stars in transform: 2D ########## + if 't0' in self.star_lists[self.ref_index].colnames: self.t0_provided = True + else: self.t0_provided = False self.ref_table = self.setup_ref_table_from_starlist(self.star_lists[self.ref_index]) - # Save the reference index to the meta data on the reference list. self.ref_table.meta['ref_list'] = self.ref_index @@ -281,44 +701,52 @@ def fit(self): # ########## for nn in range(self.iters): - - # If we are on subsequent iterations, remove matching results from the + + # If we are on subsequent iterations, remove matching results from the # prior iteration. This leaves aggregated (1D) columns alone. if nn > 0: self.reset_ref_values() if self.verbose > 0: - print(" ") - print("**********") + print("\n**********") print("**********") print('Starting iter {0:d} with ref_table shape:'.format(nn), self.ref_table['x'].shape) print("**********") print("**********") # ALL the action is in here. Match and transform the stack of starlists. - # This updates trans objects and the ref_table. - self.match_and_transform(self.mag_lim[self.ref_index], - self.dr_tol[nn], self.dm_tol[nn], self.outlier_tol[nn], - self.trans_args[nn]) - + # This updates trans objects and the ref_table. + self.match_and_transform( + self.mag_lim[nn][self.ref_index], + self.dr_tol[nn], + self.dm_tol[nn], + self.outlier_tol[nn], + self.trans_args[nn], + nn, + processes=processes, + chunksize=chunksize, + match_workers=match_workers, + mp_star_threshold=mp_star_threshold + ) # Clean up the reference table # Find where stars are detected. - self.ref_table.detections() + self.ref_table.detections(weight_col='n_detect_list' if self.inherit_n_detect else None) ### Drop all stars that have 0 detections. - idx = np.where(self.ref_table['n_detect'] == 0)[0] - print(' *** Getting rid of {0:d} out of {1:d} junk sources'.format(len(idx), len(self.ref_table))) + idx = np.where((self.ref_table['n_detect'] == 0))[0] + if self.verbose: + print(' *** Getting rid of {0:d} out of {1:d} junk sources'.format(len(idx), len(self.ref_table))) self.ref_table.remove_rows(idx) - if self.iter_callback != None: + if self.iter_callback is not None: self.iter_callback(self.ref_table, nn) - + ########## # # Re-do all matching given final transformations. # No trimming this time. - # First rest the reference table 2D values. + # First rest the reference table 2D values. ########## self.reset_ref_values(exclude=['used_in_trans']) @@ -327,50 +755,255 @@ def fit(self): print("Final Matching") print("**********") - self.match_lists(self.dr_tol[-1], self.dm_tol[-1]) - self.update_ref_table_aggregates() + self.match_lists(self.dr_tol[-1], self.dm_tol[-1], workers=match_workers) + # Hard-coded not to keep ref values for MosaicSelfRef + self.update_ref_table_aggregates(processes=processes, chunksize=chunksize, mp_star_threshold=mp_star_threshold) ########## # Clean up output table. - # + # ########## # Find where stars are detected. if self.verbose > 0: print('') print(' Preparing the reference table...') - - self.ref_table.detections() + + self.ref_table.detections(weight_col='n_detect_list' if self.inherit_n_detect else None) ### Drop all stars that have 0 detections. - idx = np.where(self.ref_table['n_detect'] == 0)[0] - print(' *** Getting rid of {0:d} out of {1:d} junk sources'.format(len(idx), len(self.ref_table))) + idx = np.where((self.ref_table['n_detect'] == 0))[0] + if self.verbose: + print(f' *** Getting rid of {len(idx):d} out of {len(self.ref_table):d} junk sources') self.ref_table.remove_rows(idx) - if self.iter_callback != None: - self.iter_callback(self.ref_table, nn) - + if self.iter_callback is not None: + # nn + 1, not nn: the loop above already called back with nn at the + # end of that iteration, and this call comes after a further stage + # (the final re-match and aggregate update). Reusing nn made the + # two indistinguishable, so a callback with side effects -- say one + # that accumulates outlier rejections -- ran twice for the last + # iteration with no way to tell. nn + 1 == self.iters marks "after + # the final matching pass". + self.iter_callback(self.ref_table, nn + 1) + + # Add times into ref_table meta data + all_epochs = [s.meta['list_time'] for s in self.star_lists] + self.ref_table.meta['list_times'] = all_epochs + + # Update chi2 values in ref table, as motion_model_used may have changed + x_inferred, y_inferred, _, _ = self.ref_table.infer_positions(all_epochs) + # Ensure x_inferred and y_inferred is 2D for chi2 calculation + if x_inferred.ndim == 1: + x_inferred = x_inferred[:, np.newaxis] + if y_inferred.ndim == 1: + y_inferred = y_inferred[:, np.newaxis] + weighted_xy = ('xe' in self.ref_table.colnames) and ('ye' in self.ref_table.colnames) + if weighted_xy: + chi2_x_2d = ((self.ref_table['x'] - x_inferred) / self.ref_table['xe'])**2 + chi2_y_2d = ((self.ref_table['y'] - y_inferred) / self.ref_table['ye'])**2 + else: + chi2_x_2d = (self.ref_table['x'] - x_inferred)**2 + chi2_y_2d = (self.ref_table['y'] - y_inferred)**2 + chi2_x = np.nansum(chi2_x_2d, axis=1) + chi2_y = np.nansum(chi2_y_2d, axis=1) + chi2_x[~np.isfinite(chi2_x_2d).any(axis=1)] = np.nan + chi2_y[~np.isfinite(chi2_y_2d).any(axis=1)] = np.nan + self.ref_table['chi2_x'] = chi2_x + self.ref_table['chi2_y'] = chi2_y + + # Update t0 and n_fit when no fitting is run because all motion_model_input==Fixed. + # 't0' may already exist as a column (e.g. supplied by the input + # reference list) without being populated for every row -- newly + # added stars get a NaN placeholder when their row is created (see + # add_rows_for_new_stars), and nothing else ever fills it in for the + # all-Fixed case. So the check has to be "which rows still need a + # value", not just "does the column exist". + needs_t0 = ( + np.ones(len(self.ref_table), dtype=bool) if 't0' not in self.ref_table.colnames + else ~np.isfinite(self.ref_table['t0']) + ) + needs_n_fit = 'n_fit' not in self.ref_table.colnames + + if needs_t0.any() or needs_n_fit: + x_data = np.ma.masked_invalid(self.ref_table['x'].data, copy=True) + y_data = np.ma.masked_invalid(self.ref_table['y'].data, copy=True) + if weighted_xy: + xe_data = np.ma.masked_invalid(self.ref_table['xe'].data, copy=True) + ye_data = np.ma.masked_invalid(self.ref_table['ye'].data, copy=True) + xe_data.mask[np.isclose(xe_data, 0.)] = True + ye_data.mask[np.isclose(ye_data, 0.)] = True + fill_with_one = np.all(xe_data.mask, axis=1) & np.all(ye_data.mask, axis=1) + xe_data[fill_with_one] = 1. + ye_data[fill_with_one] = 1. + else: + xe_data = None + ye_data = None + + if np.ndim(x_data) == 1: + x_data = x_data[:, np.newaxis] + if np.ndim(y_data) == 1: + y_data = y_data[:, np.newaxis] + if weighted_xy: + if np.ndim(xe_data) == 1: + xe_data = xe_data[:, np.newaxis] + if np.ndim(ye_data) == 1: + ye_data = ye_data[:, np.newaxis] + + if 't' in self.ref_table.colnames: + t_data = copy.deepcopy(self.ref_table['t'].data) + else: + t_data = np.array(self.ref_table.meta['list_times']) + t_data = np.broadcast_to(t_data, xe_data.shape) + + # Update t0, adapted from startables.fit_motion_models. Only the + # rows that need it are written -- rows that already have a + # valid t0 (e.g. from the input reference list) are left alone. + if needs_t0.any(): + weights = 1. / np.hypot(xe_data, ye_data) if weighted_xy else None + # t_data must be masked (not just weights) and np.ma.average + # (not plain np.average) must be used here: for the + # fill_with_one rows above (no usable xe/ye anywhere at all), + # the substitute weight is uniform/unmasked, but t can still + # be genuinely NaN in undetected epochs. Plain np.average's + # weight-sum denominator doesn't respect t's own mask in that + # case, silently corrupting the result. np.ma.average does, + # and with a uniform weight that's equivalent to + # combine_lists()'s plain (unweighted) mean of just the valid + # epochs -- i.e. these stars' t0 still reflects their real + # detections, it's just not astrometric-error-weighted. + t0_new = np.ma.average(np.ma.masked_invalid(t_data), axis=1, weights=weights).filled(np.nan) + if 't0' not in self.ref_table.colnames: + self.ref_table['t0'] = t0_new + else: + self.ref_table['t0'][needs_t0] = t0_new[needs_t0] + + # Update n_fit: unique epochs with valid data + if needs_n_fit: + xy_mask = ~ (x_data.mask | y_data.mask) + if weighted_xy: + xy_mask &= ~ (xe_data.mask | ye_data.mask) + + self.ref_table['n_fit'] = np.array([ + len(set(t_data[i][xy_mask[i]])) + for i in range(len(self.ref_table)) + ]) + + if self.save_path is not None: + # HDF5 (unlike FITS) stores nan as plain nan -- astropy.io.fits.open() + # round-trips nan through a MaskedColumn instead, silently changing + # every nan-containing column's type on read back -- and it's just + # column data, not tied to flystar's class definitions the way a + # pickle of self or self.ref_table would be. The transform objects + # still need pickling (they're real objects, not plain data), but + # that's a much smaller, more stable pickle than the whole self. + # trans_list_inverse is saved as its own file, only when requested + # (calc_trans_inverse) -- keeping it out of trans_list.pkl means that + # file's content is always the same shape, rather than sometimes a + # plain list and sometimes a dict depending on calc_trans_inverse. + self._write_ref_table(self.save_path, self.prefix_name) + with open(os.path.join(self.save_path, f'{self.prefix_name}_trans_list.pkl'), 'wb') as file: + pickle.dump(self.trans_list, file) + if self.calc_trans_inverse: + with open(os.path.join(self.save_path, f'{self.prefix_name}_trans_list_inverse.pkl'), 'wb') as file: + pickle.dump(self.trans_list_inverse, file) + if self.save_object: + with open(os.path.join(self.save_path, f'{self.prefix_name}.pkl'), 'wb') as file: + pickle.dump(self, file) + + if self.verbose > 0: + print('===================================') + print('========== Done with fit ==========') + print('===================================') return - def match_and_transform(self, ref_mag_lim, dr_tol, dm_tol, outlier_tol, trans_args): + def _write_ref_table(self, save_path, prefix_name, suffix=''): + """ + Write self.ref_table to save_path/PREFIX_ref_table., in + self.save_format. `suffix` distinguishes a table written outside of + fit() -- calc_bootstrap_errors passes '_bootstrap' -- and must not + change the format that save_format asked for. + + 'fits' needs meta keys renamed for the HIERARCH convention (see + suppress_meta_warnings), which is done on a column-sharing copy so + self.ref_table's own meta is never touched -- a later save in a + different format, or a plain `self.ref_table.meta['list_times']` + lookup, must keep working regardless of what was last written here. + """ + if self.save_format == 'hdf5': + self.ref_table.write(os.path.join(save_path, f'{prefix_name}_ref_table{suffix}.hdf5'), path='data', overwrite=True) + elif self.save_format == 'fits': + ref_table_out = self.ref_table.copy(copy_data=False) + ref_table_out.meta = suppress_meta_warnings(self.ref_table) + ref_table_out.write(os.path.join(save_path, f'{prefix_name}_ref_table{suffix}.fits'), overwrite=True) + elif self.save_format == 'pkl': + with open(os.path.join(save_path, f'{prefix_name}_ref_table{suffix}.pkl'), 'wb') as file: + pickle.dump(self.ref_table, file) + + def match_and_transform(self, ref_mag_lim, dr_tol, dm_tol, outlier_tol, trans_args, nn=None, processes=1, chunksize=None, match_workers=1, mp_star_threshold=100_000): """ Given some reference list of positions, loop through all the starlists transform and match them. + + One call is one iteration: every starlist is transformed onto the + current reference frame, matched against it, and its transformation + re-derived from the matches. ``self.trans_list`` and + ``self.ref_table`` are both updated in place, the latter growing by + the stars that no reference star matched. + + Parameters + ---------- + ref_mag_lim : array or None + Magnitude limits applied to the reference list, as [min, max]. + Stars outside are flagged out of the transformation fit rather + than removed. None applies no cut. + dr_tol : float + Matching radius for this iteration, in reference coordinate units. + dm_tol : float + Matching magnitude tolerance for this iteration, in magnitudes. + outlier_tol : float or None + Sigma threshold for rejecting matched stars from the + transformation fit. None does no rejection. + trans_args : dict + Extra keywords for the transformation class this iteration, + e.g. {'order': 2}. + nn : int, optional + Index of the current iteration, used only for progress messages, + by default None. + processes : int, optional + Number of processes for the motion-model fitting, by default 1. + chunksize : int, optional + Chunk size for that multiprocessing, by default None (auto). + match_workers : int, optional + Worker threads for the KDTree neighbour search in match.match(). + See :meth:`fit`, by default 1. + mp_star_threshold : int, optional + Minimum number of stars before a multiprocessing Pool is used for + fitting. See :meth:`fit`, by default 100_000. + + Returns + ------- + None """ + if self.starlist_vertices is not None: + import shapely for ii in range(len(self.star_lists)): if self.verbose > 0: - msg = ' Matching catalog {0} / {1} with {2:d} stars' - msg2 = ' {0:8s} < {1:0.3f}' - print(" ") - print(" **********") - print(msg.format((ii + 1), len(self.star_lists), len(self.star_lists[ii]))) - print(msg2.format('dr', dr_tol)) - print(msg2.format('|dm|', dm_tol)) - print(' outlier tol: ', outlier_tol) - print(' mag_lim: ', self.mag_lim[ii]) - print(" **********") + print() + print(" **********") + if nn is not None: + print(f" Iteration {nn+1} / {self.iters}") + print(f' Matching catalog {ii + 1} / {len(self.star_lists)} with {len(self.star_lists[ii]):d} stars') + print(f' dr < {dr_tol}') + print(f' |dm| < {dm_tol}') + print(f' outlier tol: {outlier_tol}') + print(f' mag_lim: {self.mag_lim[nn][ii]}') + print(" **********") star_list = self.star_lists[ii] - ref_list = self.get_ref_list_from_table(star_list['t'][0]) + + list_epoch = star_list.meta['list_time'] + ref_list = self.get_ref_list_from_table(list_epoch, processes=processes, chunksize=chunksize) + trans = self.trans_list[ii] # Trim a COPY of the reference and star lists based on magnitude. @@ -378,87 +1011,146 @@ def match_and_transform(self, ref_mag_lim, dr_tol, dm_tol, outlier_tol, trans_ar # star_list_orig_trim is actually trimmed but not yet transformed. # star_list_T is trimmed and transformed self.apply_mag_lim_via_use_in_trans(ref_list, ref_mag_lim) - star_list_orig_trim = apply_mag_lim(star_list, self.mag_lim[ii]) # trimmed, untransformed copy - star_list_T = apply_mag_lim(star_list, self.mag_lim[ii]) # trimmed, will be transformed copy + star_list_orig_trim = apply_mag_lim(star_list, self.mag_lim[nn][ii]) # trimmed, untransformed copy + star_list_T = StarList(star_list_orig_trim, copy=True) # trimmed, will be transformed copy + + assert len(star_list_orig_trim) > 0, f"No stars remain after applying mag_lim={self.mag_lim[nn][ii]} to star_list at index {ii}. Please check your mag_lim." ### Initial match and transform: 1st order (if we haven't already). if trans is None: # Only use "use_in_trans" reference stars, even for initial guessing. - keepers = np.where(ref_list['use_in_trans'] == True)[0] - - trans = trans_initial_guess(ref_list[keepers], star_list_orig_trim, self.trans_args[0], - mode=self.init_guess_mode, - order=self.init_order, - verbose=self.verbose, - mag_trans=self.mag_trans) + keepers = ref_list['use_in_trans'] + trans = trans_initial_guess( + ref_list=ref_list[keepers], + star_list=star_list_orig_trim, + trans_args=self.trans_args[0], + mode=self.init_guess_mode, + order=self.init_order, + briteN=self.briteN, + ignore_contains=self.ignore_contains, + polygon_reflist=self.reflist_polygon, + polygon_starlist=shapely.Polygon(self.starlist_vertices[ii]) if self.starlist_vertices is not None else None, + buffer=dr_tol, + motion_models=self.motion_models, + fixed_params_dict=self.fixed_params_dict, + mag_trans=self.mag_trans, + verbose=self.verbose + ) + if np.isnan(trans.px.parameters).any() or np.isnan(trans.py.parameters).any(): + raise ValueError(f"Initial transformation contains NaN parameters. trans.px={trans.px.parameters}, trans.py={trans.py.parameters}.") if self.mag_trans: star_list_T.transform_xym(trans) # trimmed, transformed else: - star_list_T.transform_xy(trans) - + star_list_T.transform_xy(trans) + # Match stars between the transformed, trimmed lists. - idx1, idx2, dr, dm = match.match(star_list_T['x'], star_list_T['y'], star_list_T['m'], - ref_list['x'], ref_list['y'], ref_list['m'], - dr_tol=dr_tol, dm_tol=dm_tol, verbose=self.verbose) + # Only use stars specified by "use_in_trans" column. + use_in_trans = ref_list['use_in_trans'] + idx1, idx2, dr, dm = match.match( + star_list_T['x'], star_list_T['y'], star_list_T['m'], + ref_list['x'][use_in_trans], ref_list['y'][use_in_trans], ref_list['m'][use_in_trans], + dr_tol=dr_tol, dm_tol=dm_tol, workers=match_workers, verbose=self.verbose, + matching=self.matching, dchi2_tol=self.dchi2_tol, + sigma_pos=self.match_sigma_pos, sigma_mag=self.match_sigma_mag + ) + # Restore idx2 to the full reference list indices + idx2 = np.where(use_in_trans)[0][idx2] + + if len(idx1) == 0 or len(idx2) == 0: + try: + import plotly.graph_objects as go + fig = go.Figure() + plots.plotly_stars( + x=star_list_T['x'], + y=star_list_T['y'], + color='C0', + label='Transformed Starlist', + fig=fig + ) + plots.plotly_stars( + x=ref_list['x'][use_in_trans], + y=ref_list['y'][use_in_trans], + color='C3', + label='Reference List (use_in_trans=True)', + fig=fig + ) + fig.show() + except ImportError: + fig, ax = plt.subplots() + ax.scatter(star_list_T['x'], star_list_T['y'], s=1, c='C0', alpha=0.5, label='Transformed Starlist') + ax.scatter(ref_list['x'][use_in_trans], ref_list['y'][use_in_trans], s=1, c='C3', alpha=0.5, label='Reference List (use_in_trans=True)') + ax.set_xlabel('X') + ax.set_ylabel('Y') + ax.set_title(f'Matching Results for Catalog {ii + 1}') + ax.legend() + plt.show() + raise ValueError(f"align.match_and_transform: No matches found between star_list at index {ii} and the reference list. Check your dr_tol={dr_tol} and dm_tol={dm_tol} values.") + if self.verbose > 1: print( ' Match 1: Found ', len(idx1), ' matches out of ', len(star_list_T), '. If match count is low, check dr_tol, dm_tol.' ) - # Outlier rejection on ref_stars - if outlier_tol != None: - keepers = self.outlier_rejection_indices(star_list_T[idx1], ref_list[idx2], - outlier_tol) + # Outlier rejection + if outlier_tol is not None: + keepers = self.outlier_rejection_indices(star_list_T[idx1], ref_list[idx2], outlier_tol, verbose=self.verbose) + keepers = self.guard_outlier_rejection(keepers, trans_args, ii, 'pre-fit') if self.verbose > 1: - print( ' Rejected ', len(idx1) - len(keepers), ' outliers.' ) - - idx1 = idx1[keepers] - idx2 = idx2[keepers] + print( ' Rejected ', len(idx1) - sum(keepers), ' outliers.' ) - # Only use stars specified by "use_in_trans" column. - if 'use_in_trans' in ref_list.colnames: - keepers = np.where(ref_list[idx2]['use_in_trans'] == True)[0] - - if self.verbose > 1: - print( ' Rejected ', len(idx1) - len(keepers), ' with use_in_trans=False.' ) - idx1 = idx1[keepers] idx2 = idx2[keepers] # Determine weights in the fit. - weight = self.get_weights_for_lists(ref_list[idx2], star_list_T[idx1]) + weight = self.get_weights_for_lists(ref_list[idx2], star_list_T[idx1]) - # Derive the best-fit transformation parameters. + # Derive the best-fit transformation parameters. if self.verbose > 1: print( ' Using ', len(idx1), ' stars in transformation.' ) - trans = self.trans_class.derive_transform(star_list_orig_trim['x'][idx1], star_list_orig_trim['y'][idx1], - ref_list['x'][idx2], ref_list['y'][idx2], - **trans_args, - m=star_list_orig_trim['m'][idx1], mref=ref_list['m'][idx2], - weights=weight, mag_trans=self.mag_trans) - - # Outlier rejection: ref stars in final transformation, if desired + trans = self.trans_class.derive_transform( + star_list_orig_trim['x'][idx1], star_list_orig_trim['y'][idx1], + ref_list['x'][idx2], ref_list['y'][idx2], + **trans_args, + m=star_list_orig_trim['m'][idx1], mref=ref_list['m'][idx2], + weights=weight, mag_trans=self.mag_trans + ) + check_transform_finite( + trans, len(idx1), + f'align.match_and_transform: starlist {ii}, ' + f'order={trans_args.get("order")} fit' + ) + + # Outlier rejection: ref stars in final transformation, if desired. + # This is a second pass: the transformation derived just above is + # applied, residuals are re-measured against it, and if any star is + # now an outlier the transformation is derived again without it. if outlier_tol != None: - # Apply transformation to starlist, run match between starlist and ref_list - star_list_T = copy.deepcopy(star_list) + # Re-transform the TRIMMED list, not the full star_list: idx1 + # indexes star_list_orig_trim (mag_lim applied), so building + # this from star_list would read the wrong rows whenever + # mag_lim actually trimmed something. + star_list_T = StarList(star_list_orig_trim, copy=True) if self.mag_trans: star_list_T.transform_xym(trans) else: star_list_T.transform_xy(trans) - idx_lis, idx_ref, dr, dm = match.match(star_list_T['x'], star_list_T['y'], star_list_T['m'], - ref_list['x'], ref_list['y'], ref_list['m'], - dr_tol=dr_tol, dm_tol=dm_tol, verbose=self.verbose) - # Let's look at just the ref stars used in the transformation, which are idx1 and idx2 keepers = self.outlier_rejection_indices(star_list_T[idx1], ref_list[idx2], outlier_tol) + keepers = self.guard_outlier_rejection(keepers, trans_args, ii, 'post-fit') + + # keepers is a boolean MASK over idx2, so len(keepers) is always + # len(idx2) -- counting with len() made the message read 0 and + # the guard below unsatisfiable, so this whole second pass never + # ran. Count the True entries instead. + n_keep = np.count_nonzero(keepers) if self.verbose > 1: - print( ' Rejected ', len(idx2) - len(keepers), ' outliers, final trans.' ) + print( ' Rejected ', len(idx2) - n_keep, ' outliers, final trans.' ) # If at least 1 ref star was eliminated, redo transformation - if len(keepers) < len(idx2): + if n_keep < len(idx2): # Return print statment if verbose high enough if self.verbose > 7: print('=========================') @@ -468,7 +1160,7 @@ def match_and_transform(self, ref_mag_lim, dr_tol, dm_tol, outlier_tol, trans_ar for jj in outlier_names: print('{0}'.format(jj)) print('=========================') - + # Update set of ref stars (indices are idx1, idx2 here, to be compatible downstream) idx1 = idx1[keepers] idx2 = idx2[keepers] @@ -479,11 +1171,17 @@ def match_and_transform(self, ref_mag_lim, dr_tol, dm_tol, outlier_tol, trans_ar # Redo transformation if self.verbose > 1: print( 'Recalculating trans after outlier reject. Using ', len(idx1), ' stars in transformation.' ) - trans = self.trans_class.derive_transform(star_list_orig_trim['x'][idx1], star_list_orig_trim['y'][idx1], + trans = self.trans_class.derive_transform(star_list_orig_trim['x'][idx1], star_list_orig_trim['y'][idx1], ref_list['x'][idx2], ref_list['y'][idx2], **trans_args, m=star_list_orig_trim['m'][idx1], mref=ref_list['m'][idx2], weights=weight, mag_trans=self.mag_trans) + check_transform_finite( + trans, len(idx1), + f'align.match_and_transform: starlist {ii}, ' + f'order={trans_args.get("order")} refit after outlier ' + f'rejection' + ) # Save the final transformation self.trans_list[ii] = trans @@ -492,22 +1190,25 @@ def match_and_transform(self, ref_mag_lim, dr_tol, dm_tol, outlier_tol, trans_ar # NOTE: We will not recalculate weights here if self.calc_trans_inverse: if self.verbose > 1: - print('Doing inverse') - trans_inv = self.trans_class.derive_transform(ref_list['x'][idx2], ref_list['y'][idx2], - star_list_orig_trim['x'][idx1], star_list_orig_trim['y'][idx1], - trans_args['order'], m=ref_list['m'][idx2], - mref=star_list_orig_trim['m'][idx1], weights=weight, - mag_trans=self.mag_trans) + print('Calculating inverse transformation...') + + trans_inv = self.trans_class.derive_transform( + ref_list['x'][idx2], ref_list['y'][idx2], + star_list_orig_trim['x'][idx1], star_list_orig_trim['y'][idx1], + trans_args['order'], m=ref_list['m'][idx2], + mref=star_list_orig_trim['m'][idx1], weights=weight, + mag_trans=self.mag_trans + ) self.trans_list_inverse[ii] = trans_inv # Apply the XY transformation to a new copy of the starlist and # do one final match between the two (now transformed) lists. - star_list_T = copy.deepcopy(star_list) + star_list_T = StarList(star_list, copy=True) if self.mag_trans: star_list_T.transform_xym(self.trans_list[ii]) else: star_list_T.transform_xy(self.trans_list[ii]) - + if self.verbose > 7: hdr = '{nr:20s} {n:s} {xl:9s} {xr:9s} {yl:9s} {yr:9s} {ml:6s} {mr:6s} ' hdr += '{dx:7s} {dy:7s} {dm:6s} {xo:9s} {yo:9s} {mo:6s}' @@ -517,7 +1218,7 @@ def match_and_transform(self, ref_mag_lim, dr_tol, dm_tol, outlier_tol, trans_ar ml='m_lis_T', mr='m_ref', dx='dx_mpix', dy='dy_mpix', dm='dm', xo='x_orig', yo='y_orig', mo='m_orig')) - + fmt = '{nr:20s} {n:s} {xl:9.5f} {xr:9.5f} {yl:9.5f} {yr:9.5f} {ml:6.2f} {mr:6.2f} ' fmt += '{dx:7.2f} {dy:7.2f} {dm:6.2f} {xo:9.5f} {yo:9.5f} {mo:6.2f}' for foo in range(len(idx1)): @@ -527,118 +1228,187 @@ def match_and_transform(self, ref_mag_lim, dr_tol, dm_tol, outlier_tol, trans_ar print(fmt.format(nr=star_r['name'], n=star_s['name'], xl=star_t['x'], xr=star_r['x'], yl=star_t['y'], yr=star_r['y'], ml=star_t['m'], mr=star_r['m'], - dx=(star_t['x'] - star_r['x']) * 1e3, + dx=(star_t['x'] - star_r['x']) * 1e3, dy=(star_t['y'] - star_r['y']) * 1e3, dm=(star_t['m'] - star_r['m']), xo=star_s['x'], yo=star_s['y'], mo=star_s['m'])) - - idx_lis, idx_ref, dr, dm = match.match(star_list_T['x'], star_list_T['y'], star_list_T['m'], - ref_list['x'], ref_list['y'], ref_list['m'], - dr_tol=dr_tol, dm_tol=dm_tol, verbose=self.verbose) - + + idx_lis, idx_ref, dr, dm = match.match( + star_list_T['x'], star_list_T['y'], star_list_T['m'], + ref_list['x'], ref_list['y'], ref_list['m'], + dr_tol=dr_tol, dm_tol=dm_tol, workers=match_workers, verbose=self.verbose, + matching=self.matching, dchi2_tol=self.dchi2_tol, + sigma_pos=self.match_sigma_pos, sigma_mag=self.match_sigma_mag + ) + if self.verbose > 1: print( ' Match 2: After trans, found ', len(idx_lis), ' matches out of ', len(star_list_T), '. If match count is low, check dr_tol, dm_tol.' ) ## Make plot, if desired - plots.trans_positions(ref_list, ref_list[idx_ref], star_list_T, star_list_T[idx_lis], - fileName='ep{0}'.format(ii)) + if self.save_path and self.save_plot: + plot_path = os.path.join(self.save_path, 'transformation_plots', f'iter{nn}', f"Transformed_Positions_Starlist_{ii}_t_{list_epoch}.png") + plots.trans_positions(ref_list, ref_list[idx_ref], star_list_T, star_list_T[idx_lis], save_path=plot_path, show_plot=False) ### Update the observed (but transformed) values in the reference table. self.update_ref_table_from_list(star_list, star_list_T, ii, idx_ref, idx_lis, idx2) - + ### Update the "average" values to be used as the reference frame for the next list. - if self.update_ref_orig != 'periter': - self.update_ref_table_aggregates() + keep_ref_orig = (self.update_ref_orig==False) or (self.update_ref_orig=='atend') or (self.update_ref_orig=='periter' and ii<(len(self.star_lists) - 1)) + if keep_ref_orig and ii < (len(self.star_lists) - 1): + keep_orig = self.ref_table['ref_orig'] | (~np.isfinite(self.ref_table['x'][:,ii])) + elif keep_ref_orig: + keep_orig = self.ref_table['ref_orig'] + elif ii < (len(self.star_lists) - 1): + keep_orig = ~np.isfinite(self.ref_table['x'][:,ii]) + else: + keep_orig=None + self.update_ref_table_aggregates(keep_orig=keep_orig, processes=processes, chunksize=chunksize, mp_star_threshold=mp_star_threshold) + + # Update ref list polygon + if self.starlist_vertices is not None: + self.reflist_polygon = shapely.make_valid(self.reflist_polygon.union(shapely.Polygon(self.starlist_vertices[ii]))) # Print out some metrics if self.verbose > 0: msg1 = ' {0:2s} (mean and std) for {1:10s}: {2:8.5f} +/- {3:8.5f}' - print(' Residuals: ') + print(' Residuals: ') print(msg1.format('dr', 'all stars', dr.mean(), dr.std())) - print(msg1.format('dm', 'all stars', dm.mean(), dm.std())) + print(msg1.format('dm', 'all stars', dm.mean(), dm.std())) # ref_list - ref_table # Calculate the residuals just for those used in the transformation - used = np.where(self.ref_table['used_in_trans'][:, ii] == True)[0] - used_good = used[ np.where(np.isin(used, idx_ref) == True)[0] ] - - dr_u = np.hypot(self.ref_table['x'][used_good, ii] - ref_list['x'][used_good], - self.ref_table['y'][used_good, ii] - ref_list['y'][used_good]) - dm_u = np.abs(self.ref_table['m'][used_good, ii] - ref_list['m'][used_good]) + used = np.where(self.ref_table['used_in_trans'][:, ii])[0] + used_good = used[np.isin(used, idx_ref)] + + dr_u = np.hypot(ref_list['x'][used_good] - self.ref_table['x'][used_good, ii], + ref_list['y'][used_good] - self.ref_table['y'][used_good, ii]) + dm_u = ref_list['m'][used_good] - self.ref_table['m'][used_good, ii] print(msg1.format('dr', 'trans stars', dr_u.mean(), dr_u.std())) print(msg1.format('dm', 'trans stars', dm_u.mean(), dm_u.std())) print(' Used {0:d} trans ref stars.'.format(len(used))) print(' Dropped {0:d} matches after transform.'.format(len(used) - len(used_good))) + gc.collect() # clean up memory after each iteration + + # Save ref_table after each iteration + # print(f"Saving self after iteration {ii=}") + # if self.save_path: + # with open(os.path.join(self.save_path, f"{self.prefix_name}_iter.pkl"), 'wb') as file: + # pickle.dump(self, file) return - + def setup_trans_info(self): """ Setup transformation info into a usable format. + Parameters + ---------- trans_input : list or None - trans_args : dict or None + trans_args : list of dict + Already broadcast/validated by fix_iterable_conditions -- + one dict per iteration. N_lists : int iters : int """ trans_input = self.trans_input - trans_args = self.trans_args N_lists = len(self.star_lists) - iters = self.iters - + trans_list = [None for ii in range(N_lists)] - if trans_input != None: + if trans_input is not None: trans_list = [trans_input[ii] for ii in range(N_lists)] - # Keep a list of trans_args, one for each starlist. If only - # a single is passed in, replicate for all star lists, all loop iterations. - if type(trans_args) == dict: - tmp = trans_args - trans_args = [tmp for ii in range(iters)] - self.trans_list = trans_list - self.trans_args = trans_args # Add inverse trans list, if desired if self.calc_trans_inverse: - trans_list_inverse = [None for ii in range(N_lists)] + trans_list_inverse = [None] * N_lists self.trans_list_inverse = trans_list_inverse return def setup_ref_table_from_starlist(self, star_list): - """ + """ Start with the reference list.... this will change and grow over time, so make a copy that we will keep updating. - The reference table will contain one columne for every named + The reference table will contain one column for every named array in the original reference star list. + + Parameters + ---------- + star_list : StarList + The starlist to seed the reference table with -- for + :class:`MosaicSelfRef` this is ``star_lists[ref_index]``. + + Returns + ------- + StarTable + The seeded reference table. Per-list quantities get a length-1 + epoch axis that grows as further starlists are added; motion + model parameters stay 1D. """ col_arrays = {} + + motion_model_col_names = motion_model.all_motion_model_param_names(with_errors=True, with_fixed=True) + ['m0','m0_err','use_in_trans', 'motion_model_input', 'motion_model_used'] for col_name in star_list.colnames: if col_name == 'name': - # The "name" column will be 1D; but we will also add a "name_in_list" column. + # The "name" column is 1D. Per-list identity is carried by + # "idx_in_list": the row index this star occupies in each + # input starlist (-1 where it wasn't detected). That recovers + # the per-list name as star_lists[j]['name'][idx] -- see + # names_in_list() -- for 4 bytes an entry instead of the 120 + # a U30 copy of the name cost. On a mosaic that column is the + # single largest thing in the reference table, and it is + # rebuilt in full every time the table grows. col_arrays['name'] = star_list[col_name].data - new_col_name = "name_in_list" + col_arrays['idx_in_list'] = np.arange( + len(star_list), dtype=np.int32)[:, np.newaxis] + continue + elif col_name == 'n_detect' and self.inherit_n_detect: + # Don't let this collide with the 1D 'n_detect' aggregate + # that update_n_detect() computes -- store this starlist's + # own per-star detection count (e.g. from a previous, + # lower-level align pass) under its per-list name instead, + # same as every other list-column. + new_col_name = 'n_detect_list' else: new_col_name = col_name - # Make every column's 2D arrays except "name" and those + # Make every column's 2D arrays per star except "name" and those # columns used for the motion model. if col_name in motion_model_col_names: col_arrays[new_col_name] = star_list[col_name].data else: - new_col_data = np.array([star_list[col_name].data]).T + new_col_data = star_list[col_name].data[:, np.newaxis] col_arrays[new_col_name] = new_col_data # Use the columns from the ref list to make the ref_table. ref_table = StarTable(**col_arrays) + # That constructor builds the table from the input list's COLUMNS, so + # anything the caller put in the input's meta is gone. Carry over the + # motion model fixed parameters (ra, dec, pa, obsLocation, ...): both + # fit_motion_models and determine_motion_models look up fixed parameters + # in table meta -- their "or as table metadata" error message promises + # exactly that -- and without this the promise cannot be kept for a + # MosaicToRef ref_list, which is the natural place to attach an ra/dec + # for Parallax. Only recognized fixed-parameter names are copied: the + # rest of the input's meta (n_lists, n_stars, list_times, EPNAMES) is + # bookkeeping for the table it came from -- a ref list carried over from + # an earlier alignment describes that alignment's lists, not the single + # one this table starts with -- and would corrupt this table's own. An explicit fixed_params_dict still wins, + # since every lookup consults it before the table. + fixed_param_names = set() + for mm_class in motion_model.motion_model_map().values(): + fixed_param_names |= set(mm_class.fixed_param_names) + fixed_param_names |= set(mm_class.optional_fixed_params) + for key in sorted(fixed_param_names): + if key in star_list.meta and key not in ref_table.meta: + ref_table.meta[key] = star_list.meta[key] + # Make new columns to hold original values. These will be copies # of the old columns and will only include x, y, m, xe, ye, me. - # The columns we have already created will hold transformed values. + # The columns we have already created will hold transformed values. trans_col_names = ['x', 'y', 'm', 'xe', 'ye', 'me', 'w'] - for tt in range(len(trans_col_names)): - old_name = trans_col_names[tt] - + for old_name in trans_col_names: if old_name in ref_table.colnames: new_col = ref_table[old_name].copy() new_col.name = old_name + '_orig' @@ -646,125 +1416,162 @@ def setup_ref_table_from_starlist(self, star_list): # Make sure ref_table has the necessary x0, y0, m0 and associated # error columns. If they don't exist, then add them as a copy of - # the original x,y,m etc columns. + # the original x,y,m etc columns. new_cols_arr = ['x0', 'y0', 'm0'] orig_cols_arr = ['x', 'y', 'm'] ref_cols = ref_table.keys() - for ii in range(len(new_cols_arr)): - if not new_cols_arr[ii] in ref_cols: + for new_col, orig_col in zip(new_cols_arr, orig_cols_arr): + if new_col not in ref_cols: # Some munging to convert data shape from (N,1) to (N,), # since these are all 1D cols - vals = np.transpose(np.array(ref_table[orig_cols_arr[ii]]))[0] + vals = np.array(ref_table[orig_col]).flatten() # Now add to ref_table - new_col = Column(vals, name=new_cols_arr[ii]) - ref_table.add_column(new_col) + ref_table.add_column(vals, name=new_col) # Do the same thing for the x0e, y0e, m0e columns, but # ONLY IF THEY ALREADY EXIST IN REF_TABLE! Otherwise, # just fill these tables with zeros. We need something # in these columns in order for the error propagation to # work later on. - new_err_cols = ['x0e', 'y0e', 'm0e'] + new_err_cols = ['x0_err', 'y0_err', 'm0_err'] orig_err_cols = ['xe', 'ye', 'me'] - for ii in range(len(new_err_cols)): + for new_err_col, orig_err_col in zip(new_err_cols, orig_err_cols): # If the orig col name (e.g. xe) is in the ref_table, but the new col name # (e.g. x0e) doesn't exist, then add the x0e column as a duplicate of xe. - if (orig_err_cols[ii] in ref_cols) & (not new_err_cols[ii] in ref_cols): + if (orig_err_col in ref_cols) and (new_err_col not in ref_cols): # Some munging to convert data shape from (N,1) to (N,), # since these are all 1D cols - vals = np.transpose(np.array(ref_table[orig_err_cols[ii]]))[0] - + vals = np.transpose(np.array(ref_table[orig_err_col]))[0] # Now add to ref_table - new_col = Column(vals, name=new_err_cols[ii]) - ref_table.add_column(new_col) - elif (not orig_err_cols[ii] in ref_cols) & (not new_err_cols[ii] in ref_cols): + ref_table.add_column(vals, name=new_err_col) + elif (orig_err_col not in ref_cols) and (new_err_col not in ref_cols): # If neither the orig_err_col or new_err_col is in the ref_table, put in the # new_err_cols as an array of zeros vals = np.zeros(len(ref_table)) - new_col = Column(vals, name=new_err_cols[ii]) - ref_table.add_column(new_col) + ref_table.add_column(vals, name=new_err_col) # Final check: ref_table should now have x0, y0, m0, x0e, y0e, and m0e columns # This is necessary for later steps, even if the columns are just zeros. final_new_cols = np.concatenate((new_cols_arr, new_err_cols)) for ii in final_new_cols: - assert ii in ref_table.keys() - + assert ii in ref_table.keys(), f"ref_table is missing necessary column {ii}." + # Make sure we have a column to indicate whether each star # CAN BE USED in the transformation. This will be 1D if 'use_in_trans' not in ref_table.colnames: - new_col = Column(np.ones(len(ref_table), dtype=bool), name='use_in_trans') - ref_table.add_column(new_col) + ref_table.add_column(np.ones(len(ref_table), dtype=bool), name='use_in_trans') # Make sure we have a column to indicate whether each star # IS USED in the transformation. This will be 2D if 'used_in_trans' not in ref_table.colnames: - new_col = Column(np.zeros([len(ref_table),1], dtype=bool), name='used_in_trans') - ref_table.add_column(new_col) - + ref_table.add_column(np.zeros([len(ref_table), 1], dtype=bool), name='used_in_trans') + # Keep track of whether this is an original reference star. - col_ref_orig = Column(np.ones(len(ref_table), dtype=bool), name='ref_orig') - ref_table.add_column(col_ref_orig) + ref_table.add_column(np.ones(len(ref_table), dtype=bool), name='ref_orig') + + # Make sure we have a per-list column to track each starlist's + # detection-count contribution, even if this particular (seed) + # starlist doesn't provide its own 'n_detect' -- a later starlist + # in the mosaic still might, and copy_over_values needs somewhere + # to write it. Gets reset to invalid below like any other 2D + # column, then correctly (re)populated once this starlist goes + # through its own match/copy_over_values pass. + if self.inherit_n_detect and 'n_detect_list' not in ref_table.colnames: + ref_table.add_column(np.zeros((len(ref_table), 1), dtype=int), name='n_detect_list') # Now reset the original values to invalids... they will be filled in # at later times. Preserve content only in the columns: name, x0, y0, m0 (and 0e). # Note that these are all the 1D columsn. for col_name in ref_table.colnames: if len(ref_table[col_name].data.shape) == 2: # Find the 2D columns - ref_table._set_invalid_list_values(col_name, -1) + if col_name in ['xe', 'ye', 'me']: + ref_table[col_name][:, -1] = np.inf + else: + ref_table._set_invalid_list_values(col_name, -1) + + # 'motion_model_input' is deliberately NOT auto-filled here. It used to + # be populated with motion_models[-1].name whenever the input starlist + # lacked it, which made the column always present and therefore + # indistinguishable from a real per-star request -- so propagation, + # which honors the column when present (see + # determine_motion_models), would have been tied straight back to + # the fitting setting: with motion_models=['Fixed'] every row would + # read 'Fixed' and a reference carrying real velocities would be frozen + # at its catalog epoch. Leaving it absent keeps "present" meaning "the + # caller asked for this". Fitting is unaffected: fit_motion_models + # already handles the column being absent, and its no-column branch + # ("most complex model in motion_models with n_fit >= n_params") is + # exactly what the uniformly-auto-filled column used to produce. + + # Add time column if it doesn't exist + if 't' not in ref_table.colnames: + ref_table.add_column(np.full((len(ref_table), 1), np.nan), name='t') return ref_table def apply_mag_lim_via_use_in_trans(self, ref_list, ref_mag_lim): - """Set the use_in_trans flag to False for any star in the - star list that falls beyond the magnitude limits. + """Set the use_in_trans flag to False for any star in the + star list that falls beyond the magnitude limits. This should really only be applied to reference star lists. + + Parameters + ---------- + ref_list : StarList + The reference list to flag. Modified in place: its + 'use_in_trans' column is cleared for stars outside the limits. + Uses 'm0' if present, otherwise 'm'. + ref_mag_lim : array or None + The [min, max] magnitudes to keep in the transformation. None + leaves every star flagged in. + + Returns + ------- + None """ - if ((ref_mag_lim is not None) and (ref_mag_lim[0] is not None)): + if ref_mag_lim is not None: # Support 'm0' (primary) or 'm' column name. if 'm0' in ref_list.colnames: mcol = 'm0' else: mcol = 'm' - no_use = np.where((ref_list[mcol] < ref_mag_lim[0]) | - (ref_list[mcol] >= ref_mag_lim[1])) + # NaN comparisons are always False, so a star with a non-finite + # magnitude (e.g. no usable 'me' to weight it by) would otherwise + # never get excluded by the range check below and would flood + # into use_in_trans with an unknown magnitude. + no_use = ~np.isfinite(ref_list[mcol]) | (ref_list[mcol] < ref_mag_lim[0]) | (ref_list[mcol] >= ref_mag_lim[1]) ref_list['use_in_trans'][no_use] = False - + return def outlier_rejection_indices(self, star_list, ref_list, outlier_tol, verbose=True): """ Determine the outliers based on the residual positions between two different - starlists and some threshold (in sigma). Return the indices of the stars - to keep (that shouldn't be rejected as outliers). + starlists and some threshold (in sigma). Return the indices of the stars + to keep (that shouldn't be rejected as outliers). Note that we assume that the star_list and ref_list are already transformed and - matched. + matched. Parameters ---------- star_list : StarList starlist with 'x', 'y' - ref_list : StarList starlist with 'x0', 'y0' - outlier_tol : float - Number of sigma inside which we keep stars and outside of which we - reject stars as outliers. + Number of sigma inside which we keep stars and outside of which we + reject stars as outliers. - Optional Parameters - -------------------- - verbose : boolean + verbose : boolean, optional Returns - ---------- - keepers : nd.array - The indicies of the stars to keep. + ------- + keepers : boolean array + The boolean array of the stars to keep. """ # Optionally propogate the reference positions forward in time. xref = ref_list['x'] @@ -776,20 +1583,77 @@ def outlier_rejection_indices(self, star_list, ref_list, outlier_tol, verbose=Tr resid_on_old_trans = np.hypot(x_resid_on_old_trans, y_resid_on_old_trans) threshold = np.median(resid_on_old_trans) + (outlier_tol * resid_on_old_trans.std()) - keepers = np.where(resid_on_old_trans < threshold)[0] + + # Keep stars sitting exactly AT the threshold. When every residual is + # identical the std is 0 and the threshold collapses onto the median, + # so a strict '<' rejects all of them -- which is precisely what + # happens to the reference list's own starlist under an identity + # transform, where the residuals are all exactly 0. Rejecting 100% of + # the matches leaves derive_transform with nothing to fit and it + # returns NaN coefficients, which only surface much later as + # 'x1 does not contain any finite values!' out of match.match. + keepers = resid_on_old_trans <= threshold if verbose: msg = ' Outlier Rejection: Keeping {0:d} of {1:d}' - print(msg.format(len(keepers), len(resid_on_old_trans))) + print(msg.format(sum(keepers), len(resid_on_old_trans))) return keepers + def guard_outlier_rejection(self, keepers, trans_args, ii, stage): + """ + Refuse an outlier rejection that would starve the transformation fit. + + Outlier rejection is a heuristic; the transformation is the result. If a + rejection pass leaves fewer stars than the transformation has free + parameters per axis, derive_transform returns NaN coefficients rather + than raising, and the NaNs propagate silently into the transformed + positions and the reference table. Keeping every matched star -- a + transformation fit including some outliers -- is strictly better than a + transformation made of NaNs, so drop the rejection and say so. + + Parameters + ---------- + keepers : boolean array + The mask returned by outlier_rejection_indices. + trans_args : dict + The derive_transform keywords for this iteration. + ii : int + Index of the starlist being matched, for the warning message. + stage : str + Which rejection pass this is, for the warning message. + + Returns + ------- + keepers : boolean array + The input mask, or an all-True mask if the rejection was refused. + """ + n_keep = np.count_nonzero(keepers) + n_req = min_stars_for_transform(trans_args) + + if n_keep >= n_req: + return keepers + + warnings.warn( + f'align.match_and_transform: outlier rejection ({stage}) on starlist ' + f'{ii} would leave {n_keep} of {len(keepers)} matched stars, fewer ' + f'than the {n_req} needed for an order={trans_args.get("order")} ' + f'transformation. Keeping all matched stars instead. This usually ' + f'means the residuals are nearly all identical -- e.g. a starlist ' + f'matched against a reference built from itself -- so the ' + f'median + outlier_tol * sigma threshold has no scatter to work ' + f'with.', + AstropyUserWarning + ) + + return np.ones(len(keepers), dtype=bool) + def update_ref_table_from_list(self, star_list, star_list_T, ii, idx_ref, idx_lis, idx_ref_in_trans): """ - Inputs + Parameters ---------- star_list : StarList - The original star list. + The original star list. star_list_T : StarList The original star list now transformed into the reference coordinate system. @@ -804,113 +1668,268 @@ def update_ref_table_from_list(self, star_list, star_list_T, ii, idx_ref, idx_li The indices of the matched targets in the origin starlist (epoch). idx_ref_in_trans : np.array dtype=int - The indices in the reference table (self.ref_table). + The indices in the reference table (self.ref_table). """ ### Update the reference table for matched stars. # Add the matched stars to the reference table. # For every epoch except the reference, we need to add a starlist. + if ((self.ref_table['x'].shape[1] != len(self.star_lists)) and (ii != self.ref_index) and (ii >= self.ref_table['x'].shape[1])): - - self.ref_table.add_starlist() - + # This call only grows the table by one blank list-column -- + # copy_over_values() below fills in the real x/y/m/etc data for + # it. list_times is tracked as per-list meta, not a column, and + # add_starlist() has no value for it to give here -- but that's + # fine, since fit() unconditionally rebuilds the whole list_times + # array from self.star_lists every iteration (a few lines below + # match_and_transform's return), superseding whatever this call + # would set anyway. Silence the "missing" warning rather than + # compute a value here just to have it immediately overwritten. + self.ref_table.add_starlist(warn_missing_meta=False) + copy_over_values(self.ref_table, star_list, star_list_T, ii, idx_ref, idx_lis) self.ref_table['used_in_trans'][idx_ref_in_trans, ii] = True ### Add the unmatched stars and grow the size of the reference table. - self.ref_table, idx_lis_new, idx_ref_new = add_rows_for_new_stars(self.ref_table, star_list, idx_lis) + self.ref_table, idx_lis_new, idx_ref_new = add_rows_for_new_stars( + self.ref_table, + star_list, + idx_lis, + # motion_model_name=self.motion_model_for_new_star.name + motion_model_name=self.motion_models[-1].name, + fixed_params_dict=self.fixed_params_dict + ) + if len(idx_ref_new) > 0: if self.verbose > 0: print(' Adding {0:d} new stars to the reference table.'.format(len(idx_ref_new))) - + copy_over_values(self.ref_table, star_list, star_list_T, ii, idx_ref_new, idx_lis_new) # Copy the single-epoch values to the aggregate (only for new stars). self.ref_table['x0'][idx_ref_new] = star_list_T['x'][idx_lis_new] self.ref_table['y0'][idx_ref_new] = star_list_T['y'][idx_lis_new] self.ref_table['m0'][idx_ref_new] = star_list_T['m'][idx_lis_new] - - self.ref_table['name'] = update_old_and_new_names(self.ref_table, ii, idx_ref_new) + + self.ref_table['name'] = update_old_and_new_names(self.ref_table, star_list, ii, idx_ref_new) if self.use_ref_new == True: self.ref_table['use_in_trans'][idx_ref_new] = True else: self.ref_table['use_in_trans'][idx_ref_new] = False - + return - - def update_ref_table_aggregates(self, n_boot=0, weighting='var', use_scipy=True, absolute_sigma=False, show_progress=True): - """ - Average positions or fit velocities. + + def update_ref_table_aggregates(self, keep_orig=None, n_boot=0, seed=None, processes=1, chunksize=None, mp_star_threshold=100_000): + """ Average positions or fit velocities. Average magnitudes. Calculate bootstrap errors if desired. - Update the use_in_trans values as needed. + Update the use_in_trans values as needed. TODO: ????. Updates aggregate columns in self.ref_table in place. + + + Parameters + ---------- + keep_orig : array-like of bool, optional + Boolean array indicating which stars to keep original values for, by default None + n_boot : int, optional + Number of bootstrap iterations, by default 0 + seed : int, optional + Random seed for reproducible bootstrap results, by default None + processes : int, optional + Number of processes used to fit the motion models, by default 1 + (no multiprocessing). + chunksize : int, optional + Chunk size for that multiprocessing, by default None (auto). + mp_star_threshold : int, optional + Minimum number of stars needing the per-star fitting path before + a multiprocessing Pool is used, even when processes > 1. See + :meth:`fit`, by default 100_000. + + Returns + ------- + None """ # Keep track of the original reference values. # In certain cases, we will NOT update these. - if not self.update_ref_orig: - ref_orig_idx = np.where(self.ref_table['ref_orig'] == True)[0] - x0_orig = self.ref_table['x0'][ref_orig_idx] - y0_orig = self.ref_table['y0'][ref_orig_idx] - m0_orig = self.ref_table['m0'][ref_orig_idx] - x0e_orig = self.ref_table['x0e'][ref_orig_idx] - y0e_orig = self.ref_table['y0e'][ref_orig_idx] - m0e_orig = self.ref_table['m0e'][ref_orig_idx] - - if self.use_vel: - vx_orig = self.ref_table['vx'][ref_orig_idx] - vy_orig = self.ref_table['vy'][ref_orig_idx] - vxe_orig = self.ref_table['vxe'][ref_orig_idx] - vye_orig = self.ref_table['vye'][ref_orig_idx] - t0_orig = self.ref_table['t0'][ref_orig_idx] - - if self.use_vel: - # Combine positions with a velocity fit. - self.ref_table.fit_velocities(weighting=weighting, use_scipy=use_scipy, absolute_sigma=absolute_sigma, bootstrap=n_boot, verbose=self.verbose, show_progress=show_progress) - + if (keep_orig is not None) and (np.count_nonzero(keep_orig) > 0): + vals_orig = {} + vals_orig['m0'] = self.ref_table['m0'][keep_orig] + vals_orig['m0_err'] = self.ref_table['m0_err'][keep_orig] + # Collect all motion model parameter names + motion_model_class_names = [] + if 'motion_model_input' in self.ref_table.keys(): + motion_model_class_names += self.ref_table['motion_model_input'].tolist() + if 'motion_model_used' in self.ref_table.keys(): + motion_model_class_names += self.ref_table['motion_model_used'][keep_orig].tolist() + vals_orig['motion_model_used'] = self.ref_table['motion_model_used'][keep_orig] + vals_orig['n_params'] = self.ref_table['n_params'][keep_orig] + motion_model_col_names = motion_model.motion_model_param_names(motion_model_class_names, with_errors=True, with_fixed=True) + for mm in motion_model_col_names: + if mm in self.ref_table.keys(): + vals_orig[mm] = self.ref_table[mm][keep_orig] + fit_star_idxs = ~keep_orig + else: + fit_star_idxs = None + + weighted_xy = ('xe' in self.ref_table.colnames) and ('ye' in self.ref_table.colnames) + weighted_m = ('me' in self.ref_table.colnames) + + # Route each star to the fastest applicable fitting path instead of + # an all-or-nothing check on the *requested* motion_model_input. + # + # If Empty/Fixed are the only motion models even possible (nothing + # more complex was requested), there's no ambiguity to resolve at + # all: every star is guaranteed to end up Empty or Fixed regardless + # of how many epochs it has, so route all of them through the fast, + # vectorized combine_lists_xym without needing fit_motion_models's + # fuller classification. (combine_lists honors absolute_sigma the + # same way Fixed.run_fit does, so these stars' errors are computed + # consistently either way.) + # + # Otherwise (Linear/Parallax/etc. are also possible), a star with at + # most 1 valid (finite x, y, xe, ye) epoch can still only ever + # qualify for Empty or Fixed -- and combine_lists_xym already + # produces identical output for both (0 valid epochs -> nan/inf, + # matching Empty; >=1 -> weighted average, matching Fixed) -- so + # it's safe to route those stars the same way, regardless of + # whether *other* stars need something more complex. This is a + # conservative (never-wrong) check: the raw count here is always >= + # the deduplicated-unique-times count fit_motion_models itself + # uses, so a star this flags as "<=1" can never actually qualify + # for a model needing more. Only stars with >=2 valid epochs (which + # MIGHT qualify for Linear/Parallax/etc.) go through + # fit_motion_models's fuller (and more expensive) classification. + # Previously, a single star needing something other than Fixed + # forced ALL stars -- including a huge Fixed/Empty majority -- through + # the slower fit_motion_models. + if {mm.name for mm in self.motion_models} <= {'Empty', 'Fixed'}: + guaranteed_simple = np.ones(len(self.ref_table), dtype=bool) + else: + valid_epoch = np.isfinite(self.ref_table['x']) & np.isfinite(self.ref_table['y']) + if weighted_xy: + valid_epoch &= np.isfinite(self.ref_table['xe']) & np.isfinite(self.ref_table['ye']) + guaranteed_simple = valid_epoch.sum(axis=1) <= 1 + + if weighted_xy: + # fit_motion_models falls back to a unit weight (xe=ye=1) for a + # star whose xe/ye are invalid (or ~0) across *every* epoch, so + # it still gets a position instead of being dropped -- mirrored + # here from startables.py's fill_with_one logic. combine_lists + # has no such fallback and would produce nan/inf for these + # stars instead of the same weighted-by-1 result, so keep them + # out of the "simple" bucket and let fit_motion_models handle + # them regardless of how few epochs they have. + xe_bad = ~np.isfinite(self.ref_table['xe']) | np.isclose(self.ref_table['xe'], 0) + ye_bad = ~np.isfinite(self.ref_table['ye']) | np.isclose(self.ref_table['ye'], 0) + needs_error_fallback = xe_bad.all(axis=1) & ye_bad.all(axis=1) + guaranteed_simple &= ~needs_error_fallback + + need_update = fit_star_idxs if fit_star_idxs is not None else np.ones(len(self.ref_table), dtype=bool) + simple_idxs = guaranteed_simple & need_update + complex_idxs = (~guaranteed_simple) & need_update + + if np.any(simple_idxs): + # Only (re)average the rows that actually changed this round + # (fit_star_idxs) -- for a mosaic that keeps growing across many + # starlists, recomputing every already-settled row every time + # this is called would make the total cost grow quadratically in + # the number of starlists. + if self.verbose > 0: + print(f'Fixed/Empty motion model: combining lists for {np.count_nonzero(simple_idxs)} stars.') + self.ref_table.combine_lists_xym(weighted_xy=weighted_xy, weighted_m=weighted_m, select_stars=simple_idxs, + absolute_sigma=self.absolute_sigma) + + if np.any(complex_idxs): + self.ref_table.fit_motion_models( + motion_models=self.motion_models, + fixed_params_dict=self.fixed_params_dict, + weighting=self.vel_weighting, + absolute_sigma=self.absolute_sigma, + select_stars=complex_idxs, + bootstrap=n_boot, + seed=seed, + processes=processes, + chunksize=chunksize, + mp_star_threshold=mp_star_threshold, + verbose=self.verbose + ) # Combine (transformed) magnitudes if 'me' in self.ref_table.colnames: - weights_col = None - else: weights_col = 'me' - - self.ref_table.combine_lists('m', weights_col=weights_col, ismag=True) + else: + weights_col = None + self.ref_table.combine_lists('m', weights_col=weights_col, ismag=True, select_stars=complex_idxs, + absolute_sigma=self.absolute_sigma) + + # if (keep_orig is not None) and (sum(keep_orig) > 0): + # Determine motion_model_used for keep_orig stars + # Filter possible motion models based on available columns + # Only take the selective path if these columns already exist -- on + # the very first call they don't, so every row needs a value + # regardless of fit_star_idxs. + mm_cols_exist = ('motion_model_used' in self.ref_table.colnames) and ('n_params' in self.ref_table.colnames) + if (fit_star_idxs is not None) and mm_cols_exist: + # As above: only re-classify the rows that changed this round. + motion_model_used_new, n_params_new = determine_motion_models( + self.ref_table[fit_star_idxs], self.motion_models, self.fixed_params_dict, processes, chunksize, self.verbose > 0 + ) + motion_model_used = np.array(self.ref_table['motion_model_used'], dtype=object) + n_params = np.array(self.ref_table['n_params']) + motion_model_used[fit_star_idxs] = motion_model_used_new + n_params[fit_star_idxs] = n_params_new else: - weighted_xy = ('xe' in self.ref_table.colnames) and ('ye' in self.ref_table.colnames) - weighted_m = ('me' in self.ref_table.colnames) - - self.ref_table.combine_lists_xym(weighted_xy=weighted_xy, weighted_m=weighted_m) + motion_model_used, n_params = determine_motion_models(self.ref_table, self.motion_models, self.fixed_params_dict, processes, chunksize, self.verbose > 0) + + # Assign the determined motion models + self.ref_table['motion_model_used'] = Column(motion_model_used, name='motion_model_used', + dtype=f'U{startables._MOTION_MODEL_NAME_WIDTH}') + self.ref_table['n_params'] = Column(n_params, name='n_params', dtype=int) # Replace the originals if we are supposed to keep them fixed. - if not self.update_ref_orig: - self.ref_table['x0'][ref_orig_idx] = x0_orig - self.ref_table['y0'][ref_orig_idx] = y0_orig - self.ref_table['m0'][ref_orig_idx] = m0_orig - self.ref_table['x0e'][ref_orig_idx] = x0e_orig - self.ref_table['y0e'][ref_orig_idx] = y0e_orig - self.ref_table['m0e'][ref_orig_idx] = m0e_orig - - if self.use_vel: - self.ref_table['vx'][ref_orig_idx] = vx_orig - self.ref_table['vy'][ref_orig_idx] = vy_orig - self.ref_table['vxe'][ref_orig_idx] = vxe_orig - self.ref_table['vye'][ref_orig_idx] = vye_orig - self.ref_table['t0'][ref_orig_idx] = t0_orig + if (keep_orig is not None) and (np.count_nonzero(keep_orig) > 0): + for val in vals_orig.keys(): + self.ref_table[val][keep_orig] = vals_orig[val] return - + def get_weights_for_lists(self, ref_list, star_list): + """ + Build the per-star weights used when fitting a transformation. + + The scheme is set by the object's ``trans_weights`` setting, which + selects whose uncertainties are used (the reference list, the + starlist, or both) and whether the weight goes as the inverse + variance or the inverse standard deviation. Stars whose weight comes + out non-finite (e.g. from a zero uncertainty) are given zero weight. + + Parameters + ---------- + ref_list : StarList + The reference stars taking part in the fit, already matched + row-for-row with ``star_list``. Uses its 'xe'/'ye' columns if + they exist. + star_list : StarList + The starlist stars taking part in the fit, matched row-for-row + with ``ref_list``. Uses its 'xe'/'ye' columns if they exist. + + Returns + ------- + numpy.ndarray or None + One weight per matched star, or None when ``trans_weights`` is + None or neither list carries uncertainties (in which case the + fit is unweighted). + """ if 'xe' in ref_list.colnames: var_xref = ref_list['xe']**2 var_yref = ref_list['ye']**2 else: var_xref = 0.0 var_yref = 0.0 - + if 'xe' in star_list.colnames: var_xlis = star_list['xe']**2 var_ylis = star_list['ye']**2 @@ -918,19 +1937,24 @@ def get_weights_for_lists(self, ref_list, star_list): var_xlis = 0.0 var_ylis = 0.0 - if self.weights != None: - if self.weights == 'both,var': - weight = 1.0 / (var_xref + var_xlis + var_yref + var_ylis) - if self.weights == 'both,std': - weight = 1.0 / np.sqrt(var_xref + var_xlis + var_yref + var_ylis) - if self.weights == 'ref,var': - weight = 1.0 / (var_xref + var_yref) - if self.weights == 'ref,std': - weight = 1.0 / np.sqrt(var_xref + var_yref) - if self.weights == 'list,var': - weight = 1.0 / (var_xlis + var_ylis) - if self.weights == 'list,std': - weight = 1.0 / np.sqrt(var_xlis, var_ylis) + if self.trans_weighting is not None: + # A star with zero variance here (e.g. xe=ye=0) deliberately + # produces inf, which the isfinite check right below this block + # already catches and zeroes out -- this is expected, not a bug, + # so silence the warning numpy would otherwise raise for it. + with np.errstate(divide='ignore'): + if self.trans_weighting == 'both,var': + weight = 1.0 / (var_xref + var_xlis + var_yref + var_ylis) + if self.trans_weighting == 'both,std': + weight = 1.0 / np.sqrt(var_xref + var_xlis + var_yref + var_ylis) + if self.trans_weighting == 'ref,var': + weight = 1.0 / (var_xref + var_yref) + if self.trans_weighting == 'ref,std': + weight = 1.0 / np.sqrt(var_xref + var_yref) + if self.trans_weighting == 'list,var': + weight = 1.0 / (var_xlis + var_ylis) + if self.trans_weighting == 'list,std': + weight = 1.0 / np.sqrt(var_xlis + var_ylis) else: weight = None @@ -940,8 +1964,8 @@ def get_weights_for_lists(self, ref_list, star_list): weight = None if weight is not None: - bad = np.where(np.isfinite(weight) == False)[0] - if len(bad) == len(weight): + bad = np.isfinite(weight) == False + if sum(bad) == len(weight): # Catch the case where we had no positional errors at all... # The fit should be unweighted. weight = None @@ -949,48 +1973,75 @@ def get_weights_for_lists(self, ref_list, star_list): # Fix bad weights: weight[bad] = 0.0 + if weight is not None and np.all(weight == 0.0): + # Catch the case where all weights were bad. + weight = None + return weight - - def match_lists(self, dr_tol, dm_tol): + + def match_lists(self, dr_tol, dm_tol, workers=1): """ Using the existing trans objects, match all the starlists to the - reference starlist (self.ref_table), propogated to the appropriate epoch. + reference starlist (self.ref_table), propogated to the appropriate epoch. No trimming of stars. - No new transformations derived. + No new transformations derived. The resulting matched values will be used to update self.ref_table + + Parameters + ---------- + dr_tol : float + Matching radius, in reference coordinate units. + dm_tol : float + Matching magnitude tolerance, in magnitudes. + workers : int, optional + Number of worker threads scipy uses for the KDTree neighbor search + inside match.match(). By default 1. See MosaicSelfRef.fit for details. + + Returns + ------- + None """ for ii in range(self.N_lists): # Apply the XY transformation to a new copy of the starlist and # do one final match between the two (now transformed) lists. - star_list_T = copy.deepcopy(self.star_lists[ii]) + star_list_T = StarList(self.star_lists[ii], copy=True) if self.mag_trans: star_list_T.transform_xym(self.trans_list[ii]) else: star_list_T.transform_xy(self.trans_list[ii]) - - xref, yref = get_pos_at_time(star_list_T['t'][0], self.ref_table, use_vel=self.use_vel) # optional velocity propogation. + + xref, yref, _, _ = self.ref_table.infer_positions( + star_list_T.meta['list_time'], + fixed_params_dict=self.fixed_params_dict + ) mref = self.ref_table['m0'] idx_lis, idx_ref, dr, dm = match.match(star_list_T['x'], star_list_T['y'], star_list_T['m'], xref, yref, mref, - dr_tol=dr_tol, dm_tol=dm_tol, verbose=self.verbose) + dr_tol=dr_tol, dm_tol=dm_tol, workers=workers, + verbose=self.verbose, + matching=self.matching, dchi2_tol=self.dchi2_tol, + sigma_pos=self.match_sigma_pos, + sigma_mag=self.match_sigma_mag) + if self.verbose > 0: - fmt = 'Matched {0:5d} out of {1:5d} stars in list {2:2d} [dr = {3:7.4f} +/- {4:6.4f}, dm = {5:5.2f} +/- {6:4.2f}' + fmt = 'Matched {0:5d} out of {1:5d} stars in list {2:2d} [dr = {3:7.4f} ± {4:6.4f}, dm = {5:5.2f} ± {6:4.2f}]' print(fmt.format(len(idx_lis), len(star_list_T), ii, dr.mean(), dr.std(), dm.mean(), dm.std())) copy_over_values(self.ref_table, self.star_lists[ii], star_list_T, ii, idx_ref, idx_lis) return - def get_ref_list_from_table(self, epoch): + def get_ref_list_from_table(self, epoch, processes=1, chunksize=None): """ Convert the averaged quantites in self.ref_table into a StarList object - appropriate for the specified epoch. + appropriate for the specified epoch. Columns in resulting reference list will include: + name x y @@ -999,40 +2050,61 @@ def get_ref_list_from_table(self, epoch): ye (optional) me (optional) use_in_trans (optional) + + Parameters + ---------- + epoch : float + The time to propagate the reference stars to, in the same units + as the table's 't0' column (usually decimal years). + processes : int, optional + Number of processes used if the motion models still need to be + determined, by default 1. + chunksize : int, optional + Chunk size for that multiprocessing, by default None (auto). + + Returns + ------- + StarList + The reference stars at ``epoch``, ready to be matched against a + transformed starlist. """ - # Reference stars will be named. + # Reference stars will be named. name = self.ref_table['name'] - - if self.use_vel and ('vx' in self.ref_table.colnames): - # First check if we should use velocities and if they exist. - dt = epoch - self.ref_table['t0'] - x = self.ref_table['x0'] + (self.ref_table['vx'] * dt) - y = self.ref_table['y0'] + (self.ref_table['vy'] * dt) - - xe = np.hypot(self.ref_table['x0e'], self.ref_table['vxe']*dt) - ye = np.hypot(self.ref_table['y0e'], self.ref_table['vye']*dt) - - idx = np.where(np.isfinite(self.ref_table['vx']) == False)[0] - x[idx] = self.ref_table['x0'][idx] - y[idx] = self.ref_table['y0'][idx] - xe[idx] = self.ref_table['x0e'][idx] - ye[idx] = self.ref_table['y0e'][idx] - else: - # No velocities... just used average positions. - x = self.ref_table['x0'] - y = self.ref_table['y0'] - - if 'x0e' in self.ref_table.colnames: - xe = self.ref_table['x0e'] - ye = self.ref_table['y0e'] - else: - xe = None - ye = None + # Calculate x, y, xe, ye + + if 'motion_model_used' not in self.ref_table.colnames: + # motion_models=None, not self.motion_models: nothing has been fit + # yet at this point, so the only truthful thing this column can say + # is which model the row's own parameters constitute. Restricting to + # the fitting configuration would label a reference star carrying + # catalog vx/vy as 'Fixed' -- a row holding Linear parameters while + # claiming to be Fixed, with n_params=1. Rows that later get fit are + # re-classified against self.motion_models after the fit. + motion_model_used, n_params = determine_motion_models(self.ref_table, None, self.fixed_params_dict, processes, chunksize, self.verbose > 0) + self.ref_table['motion_model_used'] = Column(motion_model_used, name='motion_model_used', + dtype=f'U{startables._MOTION_MODEL_NAME_WIDTH}') + self.ref_table['n_params'] = Column(n_params, name='n_params', dtype=int) + + # Propagation is deliberately NOT restricted to self.motion_models. + # That setting says which models to FIT for the observed stars; how far + # a star should be moved to reach this epoch is a separate question, + # answered by the parameters that star actually has. A reference star + # imported from an external catalog (Gaia, say) may carry vx/vy/t0 that + # were never fit here, and must still move with Linear even when + # motion_models=['Fixed'] -- otherwise its velocity sits unused in the + # table and the reference is silently frozen at its catalog epoch. + # infer_positions decides per star which model moves it: a + # 'motion_model_input' request where usable, else the most complex model + # that star's own parameters support. Deliberately not restricted to + # self.motion_models, which governs fitting only. + x, y, xe, ye = self.ref_table.infer_positions( + epoch, fixed_params_dict=self.fixed_params_dict + ) m = self.ref_table['m0'] - - if 'm0e' in self.ref_table.colnames: - me = self.ref_table['m0e'] + + if 'm0_err' in self.ref_table.colnames: + me = self.ref_table['m0_err'] else: me = None @@ -1060,190 +2132,228 @@ def reset_ref_values(self, exclude=None): """ Reset all the 2D arrays in the reference table. This is the action we take at the beginning of each new iteration. We don't preserve matching - results from the prior iterations. + results from the prior iterations. + + Parameters + ---------- + exclude : list of str, optional + Column names to leave untouched, e.g. ['used_in_trans'] when the + record of which stars fed the transformations has to survive into + the final matching pass. By default None, which resets every 2D + column. + + Returns + ------- + None """ # All 2D columns should be reset. for col_name in self.ref_table.colnames: - if (exclude != None) and (col_name in exclude): + if (exclude is not None) and (col_name in exclude): continue - + if len(self.ref_table[col_name].data.shape) == 2: # Find the 2D columns # Loop through epochs for this array. for cc in range(self.ref_table[col_name].shape[1]): self.ref_table._set_invalid_list_values(col_name, cc) return - - def calc_bootstrap_errors(self, n_boot=100, boot_epochs_min=-1, calc_vel_in_bootstrap=True, weighting='var', use_scipy=True, absolute_sigma=False, show_progress=True): + + def calc_bootstrap_errors(self, n_boot=100, seed=None, boot_epochs_min=-1, calc_vel_in_bootstrap=True, update_errors=False, processes=1, chunksize=None, mp_star_threshold=100_000, verbose=True): """ Function to calculate bootstrap errors for the transformations as well as the proper motions. For each iteration, this will: - 1) Draw full-size bootstrap w/replacement sample from reference stars in - ref_table and re-calculate the transformations for each epoch + 1) Draw full-size bootstrap w/replacement sample from reference stars in + ref_table and re-calculate the transformations for each epoch 2) Apply transformation to all stars in each epoch - If calc_vel_in_bootstraps: - 3) For each star, draw full-size boostrap sample w/replacement from epochs - 4) Calculate proper motion for each star using resampled epochs - + 3) If calc_vel_in_bootstrap, for each star draw a full-size bootstrap + sample w/replacement from epochs + 4) If calc_vel_in_bootstrap, calculate proper motion for each star + using the resampled epochs + The saved outputs will be: x_trans, y_trans, m_trans (transformed postions/mags), as well as the proper motion fit parameters. Final calculated errors: - std(x_trans) ---> x-direction transformation error (and likewise for y_trans, m_trans) - std(x0) --> x0e (and same with all proper motion fit parameters) - - Parameters: - ---------- - mosaic_object: MosaicToRef object - MosaicToRef object after the complete match_and_transform process - n_boot: int, must be greater than 0 - Number of bootstrap iterations when calculating transformations and the proper motion. - PM bootstrap is only done for final proper motion - calculation (e.g., not for each iteration of the starlist for matching) + - ``std(x_trans)`` -> x-direction transformation error (and likewise + for y_trans, m_trans) + - ``std(x0)`` -> x0e (and same with all proper motion fit parameters) - boot_epochs_min: int or -1 - In order to be included in bootstrap analysis, non-reference stars must be detected in - at least boot_epochs_min epochs. If boot_epochs_min = -1, then all stars will + Parameters + ---------- + n_boot : int, optional + Number of bootstrap iterations when calculating transformations and the proper motion. + PM bootstrap is only done for final proper motion calculation + (e.g., not for each iteration of the starlist for matching), by default 100 + seed : int, optional + Random seed for reproducible bootstrap results, by default None. + mp_star_threshold : int, optional + Minimum number of stars needing the per-star fitting path before a + multiprocessing Pool is used, even when processes > 1. See + :meth:`fit`, by default 100_000. + boot_epochs_min : int, optional + In order to be included in bootstrap analysis, non-reference stars must be detected in + at least boot_epochs_min epochs. If boot_epochs_min = -1, then all stars will be included in the analysis, regardless of the number of epochs detected. - For stars that fail boot_epochs_min criteria, np.nan is used - - calc_vel_in_bootstrap: boolean - If true, do bootstrap sample w/ replacement over the epochs and calculate + For stars that fail boot_epochs_min criteria, np.nan is used, by default -1 + calc_vel_in_bootstrap : boolean, optional + If true, do bootstrap sample w/ replacement over the epochs and calculate stellar proper motions, as well as the bootstrap over reference stars - to calculate positional alignment errors. If false, only - calculate position alignment errors. - - weighting: str - 'var' or 'std' weighting for velocity fitting, by default 'var'. If 'var', use the variance of the residuals to weight the fit. - If 'std', use the standard deviation of the residuals to weight the fit. - - use_scipy: boolean - If True, use scipy.optimize.curve_fit to fit the velocity. If False, use flystar.fit_velocity.linear_fit, by default True. - - absolute_sigma: boolean - If True, use the absolute sigma in the velocity fitting. If False, use the relative sigma, by default False. - - - Output: - ------ - Seven new columns will be added to self.ref_table: + to calculate positional alignment errors. If false, only + calculate position alignment errors, by default True + update_errors : boolean, optional + If True, save the starlist errors as xe_list, bootstrap errors as xe_boot, and their quad sum as xe (and likewise for ye and me). If False (default), leave the starlist errors in place as xe and bootstrap errors as xe_boot. + By default False. + processes : int, optional + Number of processes to use for parallel processing, maximum os.cpu_count(), by default 1 (no multiprocessing) + chunksize : int, optional + Chunk size for multiprocessing, by default None (auto) + verbose : boolean, optional + Print verbose information or not, by default True + + Returns + ------- + New columns will be added to self.ref_table: 'xe_boot', 2D column: bootstrap x pos uncertainties due to transformation for each epoch 'ye_boot', 2D column: bootstrap y pos uncertainties due to transformation for each epoch 'me_boot', 2D column: bootstrap mag uncertainties due to transformation for each epoch - + If calc_vel_in_bootstrap: - 'x0e_boot', 1D column: bootstrap uncertainties in x0 for PM fit - 'y0e_boot', 1D column: bootstrap uncertainties in y0 for PM fit - 'vxe_boot', 1D column: bootstrap uncertainties in vx for PM fit - 'vye_boot', 1D column: bootstrap uncertainties in vy for PM fit + '_err_boot', 1D column: bootstrap uncertainties in for motion model fit For stars that fail boot_epochs_min criteria, np.nan is used """ # First, assert than n_boot > 0 - assert n_boot > 0 + assert n_boot > 0, f'{n_boot=} is not possive!' - ref_table = copy.deepcopy(self.ref_table) + ref_table = StarTable(self.ref_table, copy=True) n_epochs = len(ref_table['x'][0]) - t_arr = get_all_epochs(ref_table) - #t_arr = ref_table['t'][np.where(ref_table['n_detect'] == np.max(ref_table['n_detect']))[0][0]] + t_arr = np.array(ref_table.meta['list_times']) t0_arr = ref_table['t0'] # Identify reference stars. If desired, trim ref_table to only stars to only # reference stars and those that pass boot_epochs_min criteria if boot_epochs_min > 0: - idx_good = np.where( (ref_table['n_detect'] >= boot_epochs_min) | (ref_table['use_in_trans']) ) + idx_good = (ref_table['n_detect'] >= boot_epochs_min) | (ref_table['use_in_trans']) ref_table = ref_table[idx_good] t0_arr = t0_arr[idx_good] else: - idx_good = np.arange(0, len(ref_table), 1) - idx_ref = np.where(ref_table['use_in_trans'] == True) - - # Initialize output arrays - x_trans_arr = np.ones((len(ref_table['x']), n_boot, n_epochs)) * -999 - y_trans_arr = np.ones((len(ref_table['x']), n_boot, n_epochs)) * -999 - m_trans_arr = np.ones((len(ref_table['x']), n_boot, n_epochs)) * -999 - xe_trans_arr = np.ones((len(ref_table['x']), n_boot, n_epochs)) * -999 - ye_trans_arr = np.ones((len(ref_table['x']), n_boot, n_epochs)) * -999 - me_trans_arr = np.ones((len(ref_table['x']), n_boot, n_epochs)) * -999 + idx_good = np.ones(len(ref_table), dtype=bool) + + # Initialize sums for output + x_boot_sum = np.zeros((len(ref_table['x']), n_epochs)) + x2_boot_sum = np.zeros((len(ref_table['x']), n_epochs)) + y_boot_sum = np.zeros((len(ref_table['x']), n_epochs)) + y2_boot_sum = np.zeros((len(ref_table['x']), n_epochs)) + m_boot_sum = np.zeros((len(ref_table['x']), n_epochs)) + m2_boot_sum = np.zeros((len(ref_table['x']), n_epochs)) + + # Set up motion model parameters + if 'motion_model_used' in ref_table.keys(): + motion_model_list = np.unique(ref_table['motion_model_used']).tolist() + elif 'motion_model_input' in ref_table.keys(): + motion_model_list = np.unique(ref_table['motion_model_input']).tolist() + + if 'Empty' not in motion_model_list: + motion_model_list.append('Empty') + if 'Fixed' not in motion_model_list: + motion_model_list.append('Fixed') + + motion_col_list = motion_model.motion_model_param_names(motion_model_list, with_errors=False, with_fixed=False) if calc_vel_in_bootstrap: - x0_arr = np.ones((len(ref_table['x']), n_boot)) * -999 - y0_arr = np.ones((len(ref_table['x']), n_boot)) * -999 - vx_arr = np.ones((len(ref_table['x']), n_boot)) * -999 - vy_arr = np.ones((len(ref_table['x']), n_boot)) * -999 + motion_boot_sum = {} + motion2_boot_sum = {} + for col in motion_col_list: + motion_boot_sum[col] = np.zeros((len(ref_table['x']))) + motion2_boot_sum[col] = np.zeros((len(ref_table['x']))) + + all_mm_map = motion_model.motion_model_map() + motion_model_list = [all_mm_map[mm_name] for mm_name in motion_model_list] + motion_boot_min_epochs = np.max([mm.n_params for mm in motion_model_list]) ### IF MEMORY PROBLEMS HERE: ### DEFINE MEAN, STD VARIABLES AND BUILD THEM RATHER THAN SAVING FULL ARRAY ### DECREASE PRECISION ON ARRAYS (32 bit instead of 64: dtype=np.float32) ### AT SOME POINT, NEED TO CONVERT BACK (LOOK UP HOW TO DO THIS CAREFULLY) - t1 = time.time() - for ii in range(n_boot): + rng = np.random.default_rng(seed) + for ii in tqdm(range(n_boot), desc='Bootstrap iterations', disable=not verbose): # Recalculate transformations using bootstrap sample of # reference stars. Use a loop for each epoch here, so we # can handle case where different reference stars are used # in different epochs + + # Initialize data arrays + x_trans_arr = np.ones((len(ref_table['x']), n_epochs)) * -999 + y_trans_arr = np.ones((len(ref_table['x']), n_epochs)) * -999 + m_trans_arr = np.ones((len(ref_table['x']), n_epochs)) * -999 + xe_trans_arr = np.ones((len(ref_table['x']), n_epochs)) * -999 + ye_trans_arr = np.ones((len(ref_table['x']), n_epochs)) * -999 + me_trans_arr = np.ones((len(ref_table['x']), n_epochs)) * -999 + for jj in range(n_epochs): - # Extract bootstrap sample of matched reference stars, using only ref stars - # used in this epoch - good = np.where( (ref_table['used_in_trans'][idx_ref][:,jj] == True) & - (~np.isnan(ref_table['x_orig'][idx_ref][:,jj])) ) - #good = np.where(~np.isnan(ref_table['x_orig'][idx_ref][:,jj])) - samp_idx = np.random.choice(good[0], len(good[0]), replace=True) - + # Extract bootstrap sample of matched reference stars for this epoch + good = np.where((ref_table['used_in_trans'][:,jj] == True) & (~np.isnan(ref_table['x_orig'][:,jj])))[0] + samp_idx = rng.choice(good, len(good), replace=True) + # Get reference star positions in particular epoch from ref_list. t_epoch = t_arr[jj] - ref_orig = self.get_ref_list_from_table(t_epoch) - - # Get idx of reference stars in bootstrap sample in the ref_orig. - # Then, use these to build reference starlist for the alignment - idx_tmp = [] - for ff in range(len(samp_idx)): - name_tmp = ref_table['name'][samp_idx[ff]] - foo = np.where(ref_orig['name'] == name_tmp)[0][0] - idx_tmp.append(foo) - - ref_boot = StarList(name=ref_orig['name'][idx_tmp], - x=ref_orig['x'][idx_tmp], - y=ref_orig['y'][idx_tmp], - m=ref_orig['m'][idx_tmp], - xe=ref_orig['xe'][idx_tmp], - ye=ref_orig['ye'][idx_tmp], - me=ref_orig['me'][idx_tmp]) + ref_orig = self.get_ref_list_from_table(t_epoch, processes=processes, chunksize=chunksize)[idx_good] + + ## Get idx of reference stars in bootstrap sample in the ref_orig. + ## Then, use these to build reference starlist for the alignment + #idx_tmp = [] + #for ff in range(len(samp_idx)): + # name_tmp = ref_table['name'][idx_ref][samp_idx[ff]] + # foo = np.where(ref_orig['name'] == name_tmp)[0][0] + # idx_tmp.append(foo) + + ref_boot = StarList(name=ref_orig['name'][samp_idx], + x=ref_orig['x'][samp_idx], + y=ref_orig['y'][samp_idx], + m=ref_orig['m'][samp_idx], + xe=ref_orig['xe'][samp_idx], + ye=ref_orig['ye'][samp_idx], + me=ref_orig['me'][samp_idx]) # Now build star list with original positions of the reference stars # in the bootstrap sample - starlist_boot = StarList(name=ref_table['name'][idx_ref][samp_idx], - x=ref_table['x_orig'][:,jj][idx_ref][samp_idx], - y=ref_table['y_orig'][:,jj][idx_ref][samp_idx], - m=ref_table['m_orig'][:,jj][idx_ref][samp_idx], - xe=ref_table['xe_orig'][:,jj][idx_ref][samp_idx], - ye=ref_table['ye_orig'][:,jj][idx_ref][samp_idx], - me=ref_table['me_orig'][:,jj][idx_ref][samp_idx]) - + starlist_boot = StarList(name=ref_table['name'][samp_idx], + x=ref_table['x_orig'][:,jj][samp_idx], + y=ref_table['y_orig'][:,jj][samp_idx], + m=ref_table['m_orig'][:,jj][samp_idx], + xe=ref_table['xe_orig'][:,jj][samp_idx], + ye=ref_table['ye_orig'][:,jj][samp_idx], + me=ref_table['me_orig'][:,jj][samp_idx]) + + # Sanity check: makes sure names match between ref_boot and starlist_boot, + # since they need to line up + assert np.all(ref_boot['name'] == starlist_boot['name']) + # Calculate weights based on weights keyword. If weights desired, will need to # make starlist objects for this - if self.weights != None: + if self.trans_weighting is not None: # In order for weights calculation to work, we need to apply a transformation # to the star_list_T so it is in the same units as ref_boot. So, we'll apply # the final transformation for the epoch to get close enough for the # purposes of the bootstrap calculation - starlist_boot_T = copy.deepcopy(starlist_boot) + starlist_boot_T = StarList(starlist_boot, copy=True) if self.mag_trans: starlist_boot_T.transform_xym(self.trans_list[jj]) else: starlist_boot_T.transform_xy(self.trans_list[jj]) - + weight = self.get_weights_for_lists(ref_boot, starlist_boot_T) else: weight = None - + # Recalculate transformation trans = self.trans_class.derive_transform(starlist_boot['x'], starlist_boot['y'], ref_boot['x'], ref_boot['y'], self.trans_args[0]['order'], m=starlist_boot['m'], mref=ref_boot['m'], weights=weight, mag_trans=self.mag_trans) + #print(jj) # Apply transformation to *all* orig positions in this epoch. Need to make a new # FLYSTAR starlist object with the original positions for this. We don't @@ -1255,54 +2365,80 @@ def calc_bootstrap_errors(self, n_boot=100, boot_epochs_min=-1, calc_vel_in_boot xe=ref_table['xe_orig'][:,jj], ye=ref_table['ye_orig'][:,jj], me=ref_table['me_orig'][:,jj]) - starlist_T = copy.deepcopy(starlist) + starlist_T = StarList(starlist, copy=True) if self.mag_trans: starlist_T.transform_xym(trans) else: starlist_T.transform_xy(trans) - + # Add output to pos arrays - x_trans_arr[:,ii,jj] = starlist_T['x'] - y_trans_arr[:,ii,jj] = starlist_T['y'] - m_trans_arr[:,ii,jj] = starlist_T['m'] - xe_trans_arr[:,ii,jj] = starlist_T['xe'] - ye_trans_arr[:,ii,jj] = starlist_T['ye'] - me_trans_arr[:,ii,jj] = starlist_T['me'] - - t2 = time.time() + x_trans_arr[:,jj] = starlist_T['x'] + y_trans_arr[:,jj] = starlist_T['y'] + m_trans_arr[:,jj] = starlist_T['m'] + xe_trans_arr[:,jj] = starlist_T['xe'] + ye_trans_arr[:,jj] = starlist_T['ye'] + me_trans_arr[:,jj] = starlist_T['me'] + + x_boot_sum += x_trans_arr + x2_boot_sum += x_trans_arr**2 + y_boot_sum += y_trans_arr + y2_boot_sum += y_trans_arr**2 + if self.mag_trans: + m_boot_sum += m_trans_arr + m2_boot_sum += m_trans_arr**2 + + # t2 = time.time() #print('=================================================') #print('Time to do {0} epochs: {1}s'.format(n_epochs, t2-t1)) #print('=================================================') - + # Finally, calculate proper motions for this bootstrap iteration # for each star, if desired. Draw a full-sample bootstrap over the epochs # for each star, and then run it through the startable fit_velocities machinery if calc_vel_in_bootstrap: - boot_idx = np.random.choice(np.arange(0, n_epochs, 1), size=n_epochs) + boot_idx = rng.choice(np.arange(0, n_epochs, 1), size=n_epochs) + while len(np.unique(boot_idx)) < motion_boot_min_epochs: + boot_idx = rng.choice(np.arange(0, n_epochs, 1), size=n_epochs) t_boot = t_arr[boot_idx] - + star_table = StarTable(name=ref_table['name'], - x=x_trans_arr[:,ii,boot_idx], - y=y_trans_arr[:,ii,boot_idx], - m=m_trans_arr[:,ii,boot_idx], - xe=xe_trans_arr[:,ii,boot_idx], - ye=ye_trans_arr[:,ii,boot_idx], - me=me_trans_arr[:,ii,boot_idx], - t=np.tile(t_boot, (len(ref_table),1)) ) + x=x_trans_arr[:,boot_idx], + y=y_trans_arr[:,boot_idx], + m=m_trans_arr[:,boot_idx], + xe=xe_trans_arr[:,boot_idx], + ye=ye_trans_arr[:,boot_idx], + me=me_trans_arr[:,boot_idx], + t=np.tile(t_boot, (len(ref_table),1))) + if 'motion_model_used' in ref_table.columns: + star_table['motion_model_input'] = ref_table['motion_model_used'] # Now, do proper motion calculation, making sure to fix t0 to the # orig value (so we can get a reasonable error on x0, y0) - star_table.fit_velocities(weighting=weighting, use_scipy=use_scipy, absolute_sigma=absolute_sigma, fixed_t0=t0_arr, show_progress=show_progress) + if self.fixed_params_dict is None: + fixed_params_dict = {'t0': t0_arr} + elif 't0' not in self.fixed_params_dict.keys(): + fixed_params_dict = self.fixed_params_dict.copy() + fixed_params_dict['t0'] = t0_arr + + star_table.fit_motion_models( + motion_models=self.motion_models, + fixed_params_dict=fixed_params_dict, + weighting=self.vel_weighting, + absolute_sigma=self.absolute_sigma, + processes=processes, + chunksize=chunksize, + mp_star_threshold=mp_star_threshold, + verbose=False + ) # Save proper motion fit results to output arrays - x0_arr[:,ii] = star_table['x0'] - y0_arr[:,ii] = star_table['y0'] - vx_arr[:,ii] = star_table['vx'] - vy_arr[:,ii] = star_table['vy'] + for col in motion_col_list: + motion_boot_sum[col] += star_table[col] + motion2_boot_sum[col] += star_table[col]**2 # Quick check to make sure bootstrap calc was valid: output t0 should be # same as input t0_arr, since we used fixed_t0 option - assert np.sum(abs(star_table['t0'] - t0_arr) == 0) + np.testing.assert_array_equal(star_table['t0'], t0_arr) #t3 = time.time() #print('=================================================') @@ -1310,75 +2446,179 @@ def calc_bootstrap_errors(self, n_boot=100, boot_epochs_min=-1, calc_vel_in_boot #print('=================================================') # Calculate the bootstrap error values. - x_err_b = np.std(x_trans_arr, ddof=1, axis=1) - y_err_b = np.std(y_trans_arr, ddof=1, axis=1) - m_err_b = np.std(m_trans_arr, ddof=1, axis=1) - + x_boot_mean = x_boot_sum/n_boot + x_err_b = np.sqrt((x2_boot_sum - 2*x_boot_mean*x_boot_sum + n_boot*x_boot_mean**2)/n_boot) + y_boot_mean = y_boot_sum/n_boot + y_err_b = np.sqrt((y2_boot_sum - 2*y_boot_mean*y_boot_sum + n_boot*y_boot_mean**2)/n_boot) + m_boot_mean = m_boot_sum/n_boot + m_err_b = np.sqrt((m2_boot_sum - 2*m_boot_mean*m_boot_sum + n_boot*m_boot_mean**2)/n_boot) + + motion_data_err = {} if calc_vel_in_bootstrap: - x0_err_b = np.std(x0_arr, ddof=1, axis=1) - y0_err_b = np.std(y0_arr, ddof=1, axis=1) - vx_err_b = np.std(vx_arr, ddof=1, axis=1) - vy_err_b = np.std(vy_arr, ddof=1, axis=1) + for col in motion_col_list: + mot_boot_mean = motion_boot_sum[col]/n_boot + motion_data_err[col] = np.sqrt((motion2_boot_sum[col] - + 2*mot_boot_mean*motion_boot_sum[col] + n_boot*mot_boot_mean**2)/n_boot) else: - x0_err_b = np.nan - y0_err_b = np.nan - vx_err_b = np.nan - vy_err_b = np.nan + for col in motion_col_list: + motion_data_err[col] = np.nan # Add summary statistics to *original* ref_table, i.e. ref_table # hanging off of mosaic object. col_heads_2D = ['xe_boot', 'ye_boot', 'me_boot'] - data_dict = {'xe_boot': x_err_b, 'ye_boot': y_err_b, 'me_boot': m_err_b, - 'x0e_boot': x0_err_b, 'y0e_boot': y0_err_b, - 'vxe_boot': vx_err_b, 'vye_boot': vy_err_b} - + data_dict = {'xe_boot': x_err_b, 'ye_boot': y_err_b, 'me_boot': m_err_b} + for col in motion_col_list: + data_dict[col+'_err_boot'] = motion_data_err[col] + for ff in col_heads_2D: col = Column(np.ones((len(self.ref_table), n_epochs)), name=ff) col.fill(np.nan) - + col[idx_good] = data_dict[ff] self.ref_table.add_column(col) + # # Calculate chi^2 with bootstrap positional errors + # # Determine which motion model to use: + # motion_model_list = sorted(motion_model_list, key=lambda mm: mm.n_params) + # mm_n_params = np.sort([mm.n_params for mm in motion_model_list]) + + # required_params = [all_mm_map[mm_name].n_params for mm_name in self.ref_table['motion_model_input']] + # mm_digitized = np.digitize( + # x=np.minimum(np.array(self.ref_table['n_detect']), required_params), + # bins=mm_n_params + # ) - 1 + # self.ref_table['motion_model_used'] = np.array([motion_model_list[d].name for d in mm_digitized], dtype='U20') + + + x_pred, y_pred, _, _ = self.ref_table.infer_positions(t_arr, fixed_params_dict=self.fixed_params_dict) + if np.ndim(x_pred) == 1: + x_pred = x_pred[:, np.newaxis] + if np.ndim(y_pred) == 1: + y_pred = y_pred[:, np.newaxis] + xe_comb = np.hypot(self.ref_table['xe'], self.ref_table['xe_boot']) + ye_comb = np.hypot(self.ref_table['ye'], self.ref_table['ye_boot']) + data_dict['chi2_x_boot'] = np.nansum((self.ref_table['x'] - x_pred)**2 / xe_comb**2, axis=1) + data_dict['chi2_y_boot'] = np.nansum((self.ref_table['y'] - y_pred)**2 / ye_comb**2, axis=1) + for ff in ['chi2_x_boot', 'chi2_y_boot']: + col = Column(np.ones(len(self.ref_table)), name=ff) + col.fill(np.nan) + + col[idx_good] = data_dict[ff][idx_good] + self.ref_table.add_column(col) + # Now handle the velocities, if they were calculated if calc_vel_in_bootstrap: - col_heads_1D = [ 'x0e_boot', 'y0e_boot', 'vxe_boot', 'vye_boot'] - + col_heads_1D = [col+'_err_boot' for col in motion_col_list] + for ff in col_heads_1D: col = Column(np.ones(len(self.ref_table)), name=ff) col.fill(np.nan) - + col[idx_good] = data_dict[ff] self.ref_table.add_column(col) - print('===============================') - print('Done with bootstrap') - print('===============================') - + if verbose: + print('===================================') + print('======= Done with bootstrap =======') + print('===================================') + + if update_errors: + self.ref_table['xe_list'] = self.ref_table['xe'] + self.ref_table['ye_list'] = self.ref_table['ye'] + self.ref_table['me_list'] = self.ref_table['me'] + self.ref_table['xe'] = np.hypot(self.ref_table['xe_list'], self.ref_table['xe_boot']) + self.ref_table['ye'] = np.hypot(self.ref_table['ye_list'], self.ref_table['ye_boot']) + self.ref_table['me'] = np.hypot(self.ref_table['me_list'], self.ref_table['me_boot']) + print("Saved starlist errors to xe_list and added xe_boot to xe in quadrature.") + print("The same was done for ye and me.") + + if self.save_path is not None: + # Same save options fit() honors: the ref table goes out in + # save_format (not hardwired to pickle), and the object itself is + # only dumped when save_object was asked for. + self._write_ref_table(self.save_path, self.prefix_name, suffix='_bootstrap') + if self.save_object: + with open(os.path.join(self.save_path, self.prefix_name+'_bootstrap.pkl'), 'wb') as file: + pickle.dump(self, file) + return - + class MosaicToRef(MosaicSelfRef): - def __init__(self, ref_list, list_of_starlists, iters=2, - dr_tol=[1, 1], dm_tol=[2, 1], - outlier_tol=[None, None], - trans_args=[{'order': 2}, {'order': 2}], - init_order=1, - mag_trans=True, mag_lim=None, ref_mag_lim=None, - weights=None, - trans_input=None, - trans_class=transforms.PolyTransform, - calc_trans_inverse=False, - use_ref_new=False, - use_vel=False, update_ref_orig=False, - init_guess_mode='miracle', - iter_callback=None, - verbose=True): + """ + Align a stack of starlists to an external reference list. + + Same machinery as :class:`MosaicSelfRef`, except that the reference frame + is anchored by a catalog passed in separately (``ref_list``) rather than + being built from the starlists. That reference list is not one of the + aligned lists: it supplies the coordinate system, and may carry its own + motion-model parameters (proper motions, parallax) so that it can be + propagated to each starlist's epoch. + + Whether stars found only in the starlists are allowed to join the + reference frame, and whether the reference stars' own positions are + updated by the fit, are controlled by ``use_ref_new`` and + ``update_ref_orig``. Set both to False to hold the input catalog fixed + and simply transform everything onto it. + + See :meth:`__init__` for the full list of settings, and + :class:`MosaicSelfRef` for the attributes left behind by :meth:`fit`. + """ + def __init__( + self, + ref_list, + list_of_starlists, + dr_tol, + reflist_vertex=None, + starlist_vertices=None, + # Alignment parameters + dm_tol=None, + outlier_tol=None, + matching='legacy', + dchi2_tol=9.0, + match_sigma_pos=None, + match_sigma_mag=None, + # Reference behavior (MosiacToRef specific) + use_ref_new=False, + update_ref_orig=False, + # Transformation parameters + trans_class=transforms.PolyTransform, + trans_args={'order': 1}, + trans_input=None, + trans_weights=None, + init_order=1, + init_guess_mode='miracle', + briteN=None, + ignore_contains='star', + calc_trans_inverse=False, + # Magnitude parameters + mag_trans=True, + mag_lim=None, + ref_mag_lim=None, + # Motion model parameters + motion_models=['Empty', 'Fixed'], + fixed_params_dict=None, + vel_weights='var', + absolute_sigma=True, + # Advanced options + inherit_n_detect=True, + iter_callback=None, + save_path=None, + save_plot=True, + save_object=True, + save_format='hdf5', + prefix_name='mtr', + verbose=True + ): """ - Required Parameters + Parameters ---------- ref_list : StarList object - Can optionally have velocities. All starlists will be aligned to this one. + All starlists will be aligned to this one. + Must have columns (x, y, m, xe, ye, me) or (x0, y0, m0, x0_err, y0_err, m0_err). + May have t or t0 columns. + May have motion model parameters list_of_starlists : array of StarList objects An array or list of flystar.starlists.StarList objects (which are Astropy Tables). @@ -1387,89 +2627,83 @@ def __init__(self, ref_list, list_of_starlists, iters=2, Note that there is an optional weights column called 'w'. If this column exists in any of the lists, it will be queried to determine if an individual star can be used to derive the transformations between starlists. This is the most flexible way - to allow you to determine, as a function of time and star, which ones are good enough - in the transformation. Note that just because it can be used (i.e. w_in=1), - doesn't meant that it will be used. The mag limits and outliers still take precedence. - Note also that the weights that go into the transformation are + to allow you to determine, as a function of time and star, which ones are good enough + in the transformation. Note that just because it can be used (i.e. w_in=1), + doesn't meant that it will be used. The mag limits and outliers still take precedence. + Note also that the weights that go into the transformation are star_list['w'] * ref_list['w'] * weight_from_keyword (see the weights parameter) - for those stars not trimmed out by the other criteria. - - - Optional Parameters - ---------- - iters : int - The number of iterations used in the matching and transformation. TO DO: INNER/OUTER? - - dr_tol : list or array - The delta-radius (dr) tolerance for matching in units of the reference coordinate system. - This is a list of dr values, one for each iteration of matching/transformation. - - dm_tol : list or array - The delta-magnitude (dm) tolerance for matching in units of the reference coordinate system. - This is a list of dm values, one for each iteration of matching/transformation. - - outlier_tol : list or array - The outlier tolerance (in units of sigma) for rejecting outlier stars. - This is a list of tol values, one for each iteration of matching/transformation. - - mag_trans : boolean - If true, this will also calculate and (temporarily) apply a zeropoint offset to - magnitudes in each list to bring them into a common magnitude system. This is - essential for matching (with finite dm_tol) starlists of different filters or - starlists that are not photometrically calibrated. Note that the final_table columns - of 'm', 'm0', and 'm0e' will contain the transformed magnitudes while the - final_table column 'm_orig' will contain the original un-transformed magnitudes. - If mag_trans = False, then no such zeropoint offset it applied at any point. - - mag_lim : array - If different from None, it indicates the minimum and maximum magnitude - on the catalogs for finding the transformations. Note, if you want specify the mag_lim - separately for each list and each iteration, you need to pass in a 2D array that - has shape (N_lists, 2). - - ref_mag_lim : array - If different from None, it indicates the minimum and maximum magnitude - on the reference catalog for finding the transformations. - - weights : str - Either None (def), 'both,var', 'list,var', or 'ref,var' depending on whether you want - to weight by the positional uncertainties (variances) in the individual starlists, or also with - the uncertainties in the reference frame itself. Note weighting only works when there - are positional uncertainties availabe. Other options include 'both,std', 'list,std', 'list,var'. + for those stars not trimmed out by the other criteria. - trans_input : array or list of transform objects - def = None. If not None, then this should contain an array or list of transform - objects that will be used as the initial guess in the alignment and matching. - trans_class : transforms.Transform2D object (or subclass) - The transform class that will be used to when deriving the optimal - transformation parameters between each list and the reference list. + reflist_vertex : array, optional + An array of polygon vertices coordinates for the reference starlist. Initial guess will only use stars in overlapping regions defined by these polygons. + Shape of (N_vertices, 2) in the format of [[x1, y1], [x2, y2], ..., [xN, yN]] for the reference starlist, by default None - trans_args : dictionary - A dictionary (or a list of dictionaries) containing any extra keywords that are needed - in the transformation object. For instance, "order". Note that if a list is passed in, - then the transformation argument (i.e. order) will be changed for every iteration in - iters. + starlist_vertices : list or array, optional + A list or array of polygon vertices coordinates for each starlist. Initial guess will only use stars in overlapping regions defined by these polygons. + Shape of (N_lists, N_vertices, 2) in the format of [[x1, y1], [x2, y2], ..., [xN, yN]] for each starlist, by default None - init_order: int - Polynomial transformation order to use for initial guess transformation. - Order=1 should be used in most cases, but sometimes higher order is needed - calc_trans_inverse: boolean - If true, then calculate the inverse transformation (from reference to starlist) - in addition to the normal transformation (from starlist to reference). The inverse - calculation is calculated by switching the order to the positions in match_and_transform. - The inverse transformations are saved in self.trans_list_inverse. + dr_tol : float, list or array + The delta-radius (dr) tolerance for matching in units of the reference + coordinate system. Required: matching is a radius search, so there is no + meaningful default. This is a list of dr values, one for each iteration of + matching/transformation, or a single value used for every iteration. - self.trans_list_inverse doesn't exist if calc_trans_inverse == False + dm_tol : float, list or array, or None, optional + The delta-magnitude (dm) tolerance for matching in units of the reference coordinate system. + This is a list of dm values, one for each iteration of matching/transformation, + or a single value used for every iteration. None (the default) places + no magnitude cut on the match, matching on position alone. + + outlier_tol : float, list or array, optional + The outlier tolerance (in units of sigma) for rejecting outlier stars. + This is a list of tol values, one for each iteration of matching/transformation, + or a single value used for every iteration. + + The number of iterations is the length of the longest of ``dr_tol``, + ``dm_tol``, ``outlier_tol`` and ``trans_args``; single values are + broadcast to it, and two sequences of differing length are an error. + All single values therefore means a single iteration. + matching : str, optional + How a star with more than one candidate inside the tolerances is + resolved, and how one-to-one is enforced. 'legacy' (default) keeps + the historical behavior: a multi-candidate star is matched only if + its nearest candidate in position is also its nearest in + magnitude. In a crowded field that discards good matches -- a + candidate 20x closer loses to one a few hundredths of a magnitude + nearer -- and each discarded star then becomes a duplicate + reference row that makes the next starlist ambiguous in turn, so + one split seeds the next. 'chi2' scores candidates as + (dr/sigma_pos)^2 + (dm/sigma_mag)^2, with the scales measured from + the starlists themselves, and keeps reciprocal best pairs that win + by dchi2_tol. See match.match_chi2 for the details. + dchi2_tol : float, optional + matching='chi2' only. How much better the best candidate must be + than the runner-up, in chi^2. Default 9.0, a 3-sigma margin. Below + it the star is treated as genuinely ambiguous and left unmatched. + match_sigma_pos : float or None, optional + matching='chi2' only. Position scale for the chi^2, in reference + coordinate units. None (default) measures it from the unambiguous + pairs of each starlist, so no error columns are required. + match_sigma_mag : float or None, optional + matching='chi2' only. Magnitude scale for the chi^2. None (default) + measures it the same way. + + use_ref_new : boolean, optional + Each pass, new stars are matched and added to the ref_table. However, we don't + necessarily want to use these in the reference frame in subsequent passes. + If True, then the new stars will be used in later passes/iterations. + If False, then the new stars will be carried, but not used in the transformation. + We determine which stars to use through setting a boolean use_in_trans flag. - update_ref_orig : boolean or str + update_ref_orig : boolean or str, optional Should we update the reference values (position, velocity, t0) after each starlist - is transformed in each iteration? + is transformed in each iteration? - False if you want to get into an absolute reference frame and are using Gaia data. + False if you want to get into an absolute reference frame and are using Gaia data. True if you want to use the reference list as more of an initial guess. 'periter' if you want to align all the starlists, then calculate the velocity. @@ -1477,103 +2711,384 @@ def = None. If not None, then this should contain an array or list of transform newly identified stars that end up in ref_table will always be updated; but not always used for transformation fitting. - use_ref_new : boolean - Each pass, new stars are matched and added to the ref_table. However, we don't - necessarily want to use these in the reference frame in subsequent passes. - If True, then the new stars will be used in later passes/iterations. - If False, then the new stars will be carried, but not used in the transformation. - We determine which stars to use through setting a boolean use_in_trans flag. + trans_class : transforms.Transform2D object (or subclass), optional + The transform class that will be used to when deriving the optimal + transformation parameters between each list and the reference list. - use_vel : boolean - If velocities are present in the reference list and use_vel == True, then during - each iteration of the alignment, the reference list will be propogated in time - using the velocity information. So all transformations will be derived w.r.t. - the propogated positions. See also update_vel. + trans_args : dict or list of dict, optional + A dictionary containing any extra keywords that are needed in the + transformation object (for instance, "order"), applied to every + iteration -- or a list of such dictionaries, one per iteration, to + use a different transformation argument (e.g. increasing order) + in later iterations. If a list is passed in, its length must + equal the number of iterations. By default {'order': 1}. - init_guess_mode : string - If no initial transformations are passed in via the trans_input keyword, then we have - to make the initial transformation and matching blindly. We can do this in a couple of - different ways. Options are 'miracle' or 'name' (see trans_initial_guess() for more details). + trans_input : transform object, or array or list of them, optional + def = None. If not None, then this should contain an array or list of transform + objects that will be used as the initial guess in the alignment and matching. + There must be one per starlist, in the same order; entries may be + None. A single transform object (rather than a sequence) is used as + the initial guess for every starlist. - iter_callback : None or function - A function to call (that accepts a StarTable object and an iteration number) - at the end of every iteration. This can be used for plotting or printing state. + trans_weights : str, optional + Either None (def), 'both,var', 'list,var', or 'ref,var' depending on whether you want + to weight by the positional uncertainties (variances) in the individual starlists, or also with + the uncertainties in the reference frame itself. Note weighting only works when there + are positional uncertainties availabe. Other options include 'both,std', 'list,std', 'list,var'. - Example - ---------- - msc = align.MosaicToRef(my_gaia, list_of_starlists, iters=1, - dr_tol=[0.1], dm_tol=[5], - outlier_tol=[None], mag_lim=[13, 21], - trans_class=transforms.PolyTransform, - trans_args=[{'order': 1}], - use_vel=True, - use_ref_new=False, - update_ref_orig=False, - mag_trans=False, - weights='both,std', - init_guess_mode='miracle', verbose=False) - msc.fit() + init_order : int, optional + Polynomial transformation order to use for initial guess transformation. + Order=1 should be used in most cases, but sometimes higher order is needed - # Access a list of all the transformation parameters: - trans_list = msc.trans_list + init_guess_mode : string, optional + If no initial transformations are passed in via the trans_input keyword, then we have + to make the initial transformation and matching blindly. We can do this in a couple of + different ways. Options are 'miracle' or 'name' (see trans_initial_guess() for more details). - # Access the fully-combined reference table. - stars_table = msc.ref_table + ignore_contains : str or None, optional + Only used when init_guess_mode='name'. Names containing this + substring are left out of the name match. The default 'star' + is there because auto-detected sources are conventionally + labelled star_1, star_2, ... per epoch -- those indices are + per-list detection numbers, not stable identities, so matching + on them pairs unrelated stars. Genuinely named sources (S0-2, + irs16NE, ...) mean the same thing in every list. + + Pass None to match on every name, which is what you want when + the names really are stable identifiers across epochs (a + cross-matched catalog, or synthetic data). '' is rejected + rather than treated as "off": every name contains the empty + string, so it would discard everything. By default 'star'. + + briteN : int, optional + If init_guess_mode is 'miracle', this is the number of brightest stars to use in the miracle match. + Default is min(50, len(star_list)). + + calc_trans_inverse : boolean, optional + If true, then calculate the inverse transformation (from reference to starlist) + in addition to the normal transformation (from starlist to reference). The inverse + calculation is calculated by switching the order to the positions in match_and_transform. + The inverse transformations are saved in self.trans_list_inverse. + self.trans_list_inverse doesn't exist if calc_trans_inverse == False - # Plot the magnitude of the first star vs. time: - # Overplot the mean magnitude. - plt.plot(stars_table['t'][0, :], stars_table['m'][0, :], 'k.') - plt.axhline(stars_table['m0'][0]) + mag_trans : boolean, optional + If true, this will also calculate and (temporarily) apply a zeropoint offset to + magnitudes in each list to bring them into a common magnitude system. This is + essential for matching (with finite dm_tol) starlists of different filters or + starlists that are not photometrically calibrated. Note that the final_table columns + of 'm', 'm0', and 'm0_err' will contain the transformed magnitudes while the + final_table column 'm_orig' will contain the original un-transformed magnitudes. + If mag_trans = False, then no such zeropoint offset it applied at any point. + + mag_lim : array, optional + Magnitude range on the starlists used for finding the + transformations, applied BEFORE the magnitude transformation. Its + single axis indexes iterations, exactly like dr_tol, dm_tol and + outlier_tol: + + ========================= ========================================= + shape meaning + ========================= ========================================= + None no limit anywhere (default) + ``(2,)`` one [min, max] everywhere + ``(N_iters, 2)`` per iteration, same for every list + ``(N_iters, N_lists, 2)`` per iteration and per list + ========================= ========================================= + + Per-starlist limits are given with the 3D form. Note that the 2D + form previously meant ``(N_lists, 2)``; it now means + ``(N_iters, 2)`` so that one axis means the same thing across every + schedule argument. + + ref_mag_lim : array, optional + Magnitude range on the reference catalog for finding the + transformations. There is exactly one reference list, so unlike + mag_lim there's no per-list axis -- its single-axis form indexes + iterations, same as dr_tol/dm_tol/mag_lim: + + ========================= ========================================= + shape meaning + ========================= ========================================= + None no limit anywhere (default) + ``(2,)`` one [min, max] every iteration + ``(N_iters, 2)`` per iteration + ========================= ========================================= + + Either side of a pair may itself be None for an open bound, e.g. + ``[13, None]``. Entries broadcast/validate the same way as + dr_tol/dm_tol/mag_lim -- see fix_ref_mag_lim. + + motion_models : list of str or MotionModel objects, optional + List of motion model names (strings) or MotionModel objects to use + + + fixed_params_dict : None or dict, optional + Dictionary of fixed parameters for motion models + + vel_weights : str, optional + Either 'var' (def) or 'std', depending on whether you want to weight the motion model + fits by the variance or standard deviation of the position data + + absolute_sigma : bool, optional + If True, the velocity fit will use absolute errors in the data. If False, relative errors will be used, by default False. + + inherit_n_detect : bool, optional + If True, and an input starlist already has its own 'n_detect' column + (e.g. it is itself the output of a previous, lower-level align pass), + use that starlist's own n_detect value -- instead of counting 1 -- + as the contribution from that starlist when computing this mosaic's + n_detect. So a star's final n_detect reflects the total number of + raw detections it represents, however many alignment layers deep. + Starlists without their own 'n_detect' still contribute 1 per + detection, same as when this is False. By default True. + + iter_callback : None or function, optional + A function to call (that accepts a StarTable object and an iteration number) + at the end of every iteration, and once more after the final + re-matching pass with an index equal to the number of iterations + (one past the last iteration), so that last call can be told apart + from the end of the last iteration. Useful for plotting or printing state, and for + rejecting stars between iterations: the table handed in is the live + ref_table, so setting `use_in_trans = False` on a row excludes it + from subsequent transformations while keeping the star in the + output. + + save_path : str, optional + Directory to save fit results to: PREFIX_input.txt (the fit + parameters), PREFIX_ref_table. (self.ref_table, extension + set by save_format), and PREFIX_trans_list.pkl (self.trans_list + -- the derived transform objects, which aren't plain data and so + need pickling). If calc_trans_inverse is True, + PREFIX_trans_list_inverse.pkl (self.trans_list_inverse) is also + saved. calc_bootstrap_errors writes + PREFIX_ref_table_bootstrap. (and PREFIX_bootstrap.pkl when + save_object is True) to the same directory. By default None + (nothing saved). + + prefix_name : str, optional + Filename prefix for everything written under ``save_path``, by + default 'mtr'. + save_plot : bool, optional + If save_path is set, also save a transformation diagnostic plot + for every (starlist, iteration) under + save_path/transformation_plots/iterN/. These are a real cost on + large starlists (an unthinned scatter of every star, saved at + dpi=300, once per starlist per iteration) -- set to False to + keep saving results without paying for them. Ignored if + save_path is None. By default True. + + save_object : bool, optional + If save_path is set, also pickle the entire mosaic object (self) + to PREFIX.pkl. This is a much heavier, less stable file + than the ref_table.hdf5/trans_list.pkl saved by default (it's + tied to flystar's class definitions, so it can break across + flystar versions) -- but it lets you reload the whole object + later (e.g. `with open(...) as f: mtr = pickle.load(f)`) and + keep using its normal instance methods (e.g. + calc_bootstrap_errors) with their usual, self-contained + parameter list, rather than having to supply every piece of + alignment config by hand. Ignored if save_path is None. By + default True. + + save_format : {'hdf5', 'fits', 'pkl'}, optional + File format for PREFIX_ref_table.. 'hdf5' (default) stores + nan as plain nan and has no header keyword length limit. + 'fits' round-trips nan through a MaskedColumn on read (silently + changing every nan-containing column's dtype), and FITS header + keywords are capped at 8 characters, so meta keys longer than + that (e.g. 'list_times') are written using the HIERARCH + convention -- handled on a column-sharing copy of ref_table, so + self.ref_table's own meta keys are never renamed. 'pkl' pickles + self.ref_table directly: slower to load and tied to + flystar's/astropy's class definitions like save_object, but + preserves every column and meta key exactly as-is. Ignored if + save_path is None. By default 'hdf5'. + + verbose : bool or int (0 to 9, inclusive), optional + Controls the verbosity of print statements. (0 least, 9 most verbose). + For backwards compatibility, 0 = False, 9 = True. + (Note: technically right now no checks on whether the number is an integer or not...) - # Plot the X position of the first star vs. time: - # Overplot the best-fit proper motion. - times = stars_table['t'][0, :] - plt.errorbar(times, stars_table['x'][0, :], yerr=stars_table['xe'][0, :]) - plt.axhline(stars_table['x0'][0] + stars_table['vx'][0]*(times - stars_table['t0'][0])) + Examples + -------- + + .. code-block:: python + + mtr = align.MosaicToRef(my_gaia, list_of_starlists, + dr_tol=0.1, dm_tol=5, + outlier_tol=[None], mag_lim=[13, 21], + trans_class=transforms.PolyTransform, + trans_args=[{'order': 1}], + use_ref_new=False, + update_ref_orig=False, + mag_trans=False, + weights='both,std', + init_guess_mode='miracle', verbose=False) + mtr.fit() + + # Access a list of all the transformation parameters: + trans_list = mtr.trans_list + + # Access the fully-combined reference table. + stars_table = mtr.ref_table + + # Plot the magnitude of the first star vs. time: + # Overplot the mean magnitude. + plt.plot(stars_table['t'][0, :], stars_table['m'][0, :], 'k.') + plt.axhline(stars_table['m0'][0]) + + # Plot the X position of the first star vs. time: + # Overplot the best-fit proper motion. + times = stars_table['t'][0, :] + plt.errorbar(times, stars_table['x'][0, :], yerr=stars_table['xe'][0, :]) + plt.axhline(stars_table['x0'][0] + stars_table['vx'][0]*(times - stars_table['t0'][0])) """ - super().__init__(list_of_starlists, ref_index=-1, iters=iters, - dr_tol=dr_tol, dm_tol=dm_tol, - outlier_tol=outlier_tol, trans_args=trans_args, - init_order=init_order, - mag_trans=mag_trans, mag_lim=mag_lim, weights=weights, - trans_input=trans_input, trans_class=trans_class, - calc_trans_inverse=calc_trans_inverse, use_vel=use_vel, - init_guess_mode=init_guess_mode, - iter_callback=iter_callback, - verbose=verbose) - - self.ref_list = copy.deepcopy(ref_list) + super().__init__( + list_of_starlists, + # Alignment parameters + ref_index=-1, + dr_tol=dr_tol, + dm_tol=dm_tol, + outlier_tol=outlier_tol, + matching=matching, + dchi2_tol=dchi2_tol, + match_sigma_pos=match_sigma_pos, + match_sigma_mag=match_sigma_mag, + # Transformation parameters + trans_class=trans_class, + trans_args=trans_args, + trans_input=trans_input, + trans_weights=trans_weights, + init_order=init_order, + init_guess_mode=init_guess_mode, + briteN=briteN, + ignore_contains=ignore_contains, + calc_trans_inverse=calc_trans_inverse, + # Magnitude parameters + mag_trans=mag_trans, + mag_lim=mag_lim, + # Motion model parameters + motion_models=motion_models, + # motion_model_for_new_star=motion_model_for_new_star, + fixed_params_dict=fixed_params_dict, + vel_weights=vel_weights, + absolute_sigma=absolute_sigma, + # Advanced options + inherit_n_detect=inherit_n_detect, + iter_callback=iter_callback, + save_path=save_path, + save_plot=save_plot, + save_object=save_object, + save_format=save_format, + prefix_name=prefix_name, + verbose=verbose + ) + + self.starlist_vertices = starlist_vertices + self.ref_list = StarList(ref_list, copy=True) self.ref_mag_lim = ref_mag_lim + self.fix_ref_mag_lim() self.update_ref_orig = update_ref_orig self.use_ref_new = use_ref_new + if reflist_vertex is not None: + import shapely + self.reflist_polygon = shapely.make_valid(shapely.Polygon(reflist_vertex)) + else: + self.reflist_polygon = None + + # If motion_model_used in columns but params columns are missing, raise a warning and remove motion_model_used column to avoid confusion. + # if 'motion_model_used' in self.ref_list.colnames: + # motion_model_params = motion_model.motion_model_param_names(np.unique(self.ref_list['motion_model_used']), with_errors=False, with_fixed=True) + # missing_params = [param for param in motion_model_params if (param not in self.ref_list.colnames) and (f'{param}_err' not in self.ref_list.colnames) and (param not in self.fixed_params_dict.keys())] + # if len(missing_params) > 0: + # warnings.warn("Warning: 'motion_model_used' column found in ref_list, but the following motion model parameter columns are missing: " + ", ".join(missing_params) + ". Removing 'motion_model_used' column to avoid confusion.") + # self.ref_list.remove_column('motion_model_used') + + # If motion_model_used in columns, remove it and raise a warning, since it will only be determined after the fit. + if 'motion_model_used' in self.ref_list.colnames: + warnings.warn("Warning: 'motion_model_used' column found in ref_list. This column will be determined after the fit, so it is being removed from the input ref_list to avoid confusion.") + self.ref_list.remove_column('motion_model_used') + # Do some temporary clean up of the reference list. if ('x' not in self.ref_list.colnames) and ('x0' in self.ref_list.colnames): self.ref_list['x'] = self.ref_list['x0'] self.ref_list['y'] = self.ref_list['y0'] - if ('xe' not in self.ref_list.colnames) and ('x0e' in self.ref_list.colnames): - self.ref_list['xe'] = self.ref_list['x0e'] - self.ref_list['ye'] = self.ref_list['y0e'] + if ('xe' not in self.ref_list.colnames) and ('x0_err' in self.ref_list.colnames): + self.ref_list['xe'] = self.ref_list['x0_err'] + self.ref_list['ye'] = self.ref_list['y0_err'] if ('m' not in self.ref_list.colnames) and ('m0' in self.ref_list.colnames): self.ref_list['m'] = self.ref_list['m0'] - if ('me' not in self.ref_list.colnames) and ('m0e' in self.ref_list.colnames): - self.ref_list['me'] = self.ref_list['m0e'] + if ('me' not in self.ref_list.colnames) and ('m0_err' in self.ref_list.colnames): + self.ref_list['me'] = self.ref_list['m0_err'] if ('t' not in self.ref_list.colnames) and ('t0' in self.ref_list.colnames): self.ref_list['t'] = self.ref_list['t0'] return - - def fit(self): + def fix_ref_mag_lim(self): + """ + Normalize ref_mag_lim into a length-``iters`` list, one entry per + iteration, mirroring fix_iterable_conditions' treatment of + dr_tol/dm_tol/mag_lim: a single value broadcasts to every + iteration, a sequence must have one entry per iteration, and + ``iters`` is the length of the longest schedule set in __init__. + + Unlike mag_lim, there's exactly one reference list, so the + per-iteration form is flat -- ``(N_iters, 2)``, not + ``(N_iters, N_lists, 2)``. + + A flat ``[min, max]`` pair is told apart from a per-iteration + sequence by content, not just length: a pair's two sides are each + None or a scalar, where a per-iteration sequence's entries are each + None or a pair. So unlike mag_lim's old (N_lists, 2) vs + (N_iters, 2) ambiguity, there's no shape collision to worry about + here regardless of how many iterations there are. + + Each resolved entry is either ``None`` (no cut that iteration -- + left as None so apply_mag_lim_via_use_in_trans's own `is not None` + check can skip the cut entirely, same as before this method + existed) or a ``[min, max]`` pair with any open (None) side + resolved to +/-inf, since the comparisons downstream + (`ref_list[mcol] < ref_mag_lim[0]`) can't handle a bare None. + + Raises + ------ + AssertionError + If a per-iteration sequence's length is not iters. + """ + def is_single_pair(val): + if val is None: + return True + return len(val) == 2 and all(v is None or np.isscalar(v) for v in val) + + if is_single_pair(self.ref_mag_lim): + schedule = [self.ref_mag_lim] * self.iters + else: + schedule = list(self.ref_mag_lim) + assert len(schedule) == self.iters, \ + (f'len(ref_mag_lim)={len(schedule)} != iters={self.iters}. ref_mag_lim must ' + f'be None, a 2-element [min, max], or a sequence of those with one entry ' + f'per iteration; iters is the longest schedule given.') + + def resolve(lim): + if lim is None: + return None + lo, hi = lim + return [lo if lo is not None else -np.inf, hi if hi is not None else np.inf] + + self.ref_mag_lim = [resolve(lim) for lim in schedule] + + def fit(self, processes=1, chunksize=None, match_workers=1, mp_star_threshold=100_000): """ Using the current parameter settings, match and transform all the lists to a reference position. Note in the first pass, the reference position is just the specified input reference starlist. In subsequent iterations, - this is (optionally) updated. + this is (optionally) updated. The ultimate outcome is the creation of self.ref_table. This reference - table will contain "averaged" quantites as well as a big 2D array of all - the matched original and transformed quantities. + table will contain "averaged" quantities as well as a big 2D array of all + the matched original and transformed quantities. Averaged columns on ref_table: x0 @@ -1582,34 +3097,67 @@ def fit(self): x0e y0e m0e - vx (only if use_vel=True) - vy (only if use_vel=True) - vxe (only if use_vel=True) - vye (only if use_vel=True) + addl. motion_model parameters + Parameters + ---------- + processes : int, optional + Number of processes to use for parallel processing, maximum os.cpu_count(), by default 1 (no multiprocessing) + chunksize : int, optional + Chunk size for multiprocessing, by default None (auto) + match_workers : int, optional + Number of worker threads scipy uses for the KDTree neighbor search inside + match.match(). Default is 1 (single-threaded), which is the safe choice on + shared/multi-tenant machines where grabbing all cores would step on other + users' jobs. Set to -1 to use all available CPU cores (measurably faster on + large starlists, with no change in matching results). See MosaicSelfRef.fit + for details. + mp_star_threshold : int, optional + Minimum number of stars actually requiring the per-star motion-model + fitting path before a multiprocessing Pool is used for fitting, even + if processes > 1. See StarTable.fit_motion_models for details. + By default 100_000. """ # Create a log file of the parameters used in the fit. - with open('MosaicToRef_input_params.log', 'w',) as _log: - logger(_log, 'Parameters used for fit: ', self.verbose) - logger(_log, '------------------------- ', self.verbose) - logger(_log, ' dr_tol = ' + str(self.dr_tol), self.verbose) - logger(_log, ' dm_tol = ' + str(self.dm_tol), self.verbose) - logger(_log, ' outlier_tol = ' + str(self.outlier_tol), self.verbose) - logger(_log, ' trans_args = ' + str(self.trans_args), self.verbose) - logger(_log, ' mag_trans = ' + str(self.mag_trans), self.verbose) - logger(_log, ' mag_lim = ' + str(self.mag_lim), self.verbose) - logger(_log, ' ref_mag_lim = ' + str(self.ref_mag_lim), self.verbose) - logger(_log, ' weights = ' + str(self.weights), self.verbose) - logger(_log, ' trans_input = ' + str(self.trans_input), self.verbose) - logger(_log, ' trans_class = ' + str(self.trans_class), self.verbose) - logger(_log, ' calc_trans_inverse = ' + str(self.calc_trans_inverse), self.verbose) - logger(_log, ' use_ref_new = ' + str(self.use_ref_new), self.verbose) - logger(_log, ' use_vel = ' + str(self.use_vel), self.verbose) - logger(_log, ' update_ref_orig = ' + str(self.update_ref_orig), self.verbose) - logger(_log, ' init_guess_mode = ' + str(self.init_guess_mode), self.verbose) - logger(_log, ' iter_callback = ' + str(self.iter_callback), self.verbose) - logger(_log, '-------------------------\n', self.verbose) - + # Setup save_path: + if self.save_path: + if not os.path.exists(os.path.dirname(self.save_path)): + os.makedirs(os.path.dirname(self.save_path)) + + # Save input params + input_filename = f'{self.prefix_name}_input.txt' + input_dict = { + 'iters': self.iters, + 'dr_tol': self.dr_tol, + 'dm_tol': self.dm_tol, + 'outlier_tol': self.outlier_tol, + 'use_ref_new': self.use_ref_new, + 'update_ref_orig': self.update_ref_orig, + 'trans_class': self.trans_class, + 'trans_args': self.trans_args, + 'trans_input': self.trans_input, + 'trans_weights': self.trans_weighting, + 'init_order': self.init_order, + 'init_guess_mode': self.init_guess_mode, + 'calc_trans_inverse': self.calc_trans_inverse, + 'mag_trans': self.mag_trans, + 'mag_lim': self.mag_lim, + 'ref_mag_lim': self.ref_mag_lim, + 'motion_models': self.motion_models, + 'fixed_params_dict': self.fixed_params_dict, + 'vel_weights': self.vel_weighting, + 'absolute_sigma': self.absolute_sigma, + 'iter_callback': self.iter_callback, + 'save_path': self.save_path, + 'prefix_name': self.prefix_name, + 'verbose': self.verbose + } + if self.save_path is not None: + if not os.path.exists(self.save_path): + os.makedirs(self.save_path) + with open(os.path.join(self.save_path, input_filename), 'w') as file: + for key, value in input_dict.items(): + file.write(f'{key}:\t{value}\n') ########## # Setup a reference table to store data. It will contain: @@ -1618,26 +3166,15 @@ def fit(self): # x_orig, y_orig, m_orig, (opt. errors) -- the transformed errors for the lists: 2D # w, w_orig (optiona) -- the input and output weights of stars in transform: 2D ########## + if 't0' in self.ref_list.colnames: self.t0_provided = True + else: self.t0_provided = False self.ref_table = self.setup_ref_table_from_starlist(self.ref_list) - - # copy over velocities if they exist in the reference list - if 'vx' in self.ref_list.colnames: - self.ref_table['vx'] = self.ref_list['vx'] - self.ref_table['vy'] = self.ref_list['vy'] - self.ref_table['t0'] = self.ref_list['t0'] - if 'vxe' in self.ref_list.colnames: - self.ref_table['vxe'] = self.ref_list['vxe'] - self.ref_table['vye'] = self.ref_list['vye'] - ########## - # # Repeat transform + match of all the starlists several times. - # ########## for nn in range(self.iters): - - # If we are on subsequent iterations, remove matching results from the + # If we are on subsequent iterations, remove matching results from the # prior iteration. This leaves aggregated (1D) columns alone. if nn > 0: self.reset_ref_values() @@ -1649,70 +3186,373 @@ def fit(self): print('Starting iter {0:d} with ref_table shape:'.format(nn), self.ref_table['x'].shape) print("**********") print("**********") - + # ALL the action is in here. Match and transform the stack of starlists. - # This updates trans objects and the ref_table. - self.match_and_transform(self.ref_mag_lim, - self.dr_tol[nn], self.dm_tol[nn], self.outlier_tol[nn], - self.trans_args[nn]) + # This updates trans objects and the ref_table. + self.match_and_transform( + self.ref_mag_lim[nn], + self.dr_tol[nn], + self.dm_tol[nn], + self.outlier_tol[nn], + self.trans_args[nn], + nn, + processes=processes, + chunksize=chunksize, + match_workers=match_workers, + mp_star_threshold=mp_star_threshold + ) # Clean up the reference table # Find where stars are detected. - self.ref_table.detections() + self.ref_table.detections(weight_col='n_detect_list' if self.inherit_n_detect else None) ### Drop all stars that have 0 detections. - idx = np.where((self.ref_table['n_detect'] == 0) & (self.ref_table['ref_orig'] == False))[0] + idx = np.where((self.ref_table['n_detect'] == 0))[0] # & (self.ref_table['ref_orig'] == False))[0] if self.verbose > 0: - print(' *** Getting rid of {0:d} out of {1:d} junk sources'.format(len(idx), len(self.ref_table))) + print(' *** Getting rid of {0:d} out of {1:d} junk sources'.format(len(idx), len(self.ref_table))) self.ref_table.remove_rows(idx) - if self.iter_callback != None: + if self.iter_callback is not None: self.iter_callback(self.ref_table, nn) ########## - # # Re-do all matching given final transformations. - # No trimming this time. - # First rest the reference table 2D values. + # No trimming this time. + # First reset the reference table 2D values. ########## self.reset_ref_values(exclude=['used_in_trans']) - + if self.verbose > 0: print("**********") print("Final Matching") print("**********") - self.match_lists(self.dr_tol[-1], self.dm_tol[-1]) - self.update_ref_table_aggregates() + self.match_lists(self.dr_tol[-1], self.dm_tol[-1], workers=match_workers) + if self.update_ref_orig: + keep_orig=None + else: + keep_orig = self.ref_table['ref_orig'] + self.update_ref_table_aggregates(keep_orig=keep_orig, processes=processes, chunksize=chunksize, mp_star_threshold=mp_star_threshold) ########## # Clean up output table. - # ########## # Find where stars are detected. if self.verbose > 0: - print('') - print(' Preparing the reference table...') - - self.ref_table.detections() + print(' Preparing the reference table...') + + self.ref_table.detections(weight_col='n_detect_list' if self.inherit_n_detect else None) ### Drop all stars that have 0 detections. - idx = np.where(self.ref_table['n_detect'] == 0)[0] - print(' *** Getting rid of {0:d} out of {1:d} junk sources'.format(len(idx), len(self.ref_table))) + idx = np.where((self.ref_table['n_detect'] == 0))[0] # & (self.ref_table['ref_orig'] == False))[0] + if self.verbose: + print(' *** Getting rid of {0:d} out of {1:d} junk sources'.format(len(idx), len(self.ref_table))) self.ref_table.remove_rows(idx) - if self.iter_callback != None: - self.iter_callback(self.ref_table, nn) + if self.iter_callback is not None: + # nn + 1, not nn: the loop above already called back with nn at the + # end of that iteration, and this call comes after a further stage + # (the final re-match and aggregate update). Reusing nn made the + # two indistinguishable, so a callback with side effects -- say one + # that accumulates outlier rejections -- ran twice for the last + # iteration with no way to tell. nn + 1 == self.iters marks "after + # the final matching pass". + self.iter_callback(self.ref_table, nn + 1) + + # Add times into ref_table meta data + all_epochs = [s.meta['list_time'] for s in self.star_lists] + self.ref_table.meta['list_times'] = all_epochs + + # Update chi2 values in ref table, as motion_model_used may have changed + x_inferred, y_inferred, _, _ = self.ref_table.infer_positions(all_epochs, fixed_params_dict=self.fixed_params_dict) + # Convert x_inferred and y_inferred to 2D arrays if they are 1D (i.e. if only one epoch), so that the chi2 calculation works correctly. + if x_inferred.ndim == 1: + x_inferred = x_inferred[:, np.newaxis] + if y_inferred.ndim == 1: + y_inferred = y_inferred[:, np.newaxis] + weighted_xy = ('xe' in self.ref_table.colnames) and ('ye' in self.ref_table.colnames) + if weighted_xy: + chi2_x_2d = ((self.ref_table['x'] - x_inferred) / self.ref_table['xe'])**2 + chi2_y_2d = ((self.ref_table['y'] - y_inferred) / self.ref_table['ye'])**2 + else: + chi2_x_2d = (self.ref_table['x'] - x_inferred)**2 + chi2_y_2d = (self.ref_table['y'] - y_inferred)**2 + chi2_x = np.nansum(chi2_x_2d, axis=1) + chi2_y = np.nansum(chi2_y_2d, axis=1) + chi2_x[~np.isfinite(chi2_x_2d).any(axis=1)] = np.nan + chi2_y[~np.isfinite(chi2_y_2d).any(axis=1)] = np.nan + self.ref_table['chi2_x'] = chi2_x + self.ref_table['chi2_y'] = chi2_y + + # Update t0 and n_fit when no fitting is run because all motion_model_input==Fixed. + # 't0' may already exist as a column (e.g. supplied by the input + # reference list) without being populated for every row -- newly + # added stars get a NaN placeholder when their row is created (see + # add_rows_for_new_stars), and nothing else ever fills it in for the + # all-Fixed case. So the check has to be "which rows still need a + # value", not just "does the column exist". + needs_t0 = ( + np.ones(len(self.ref_table), dtype=bool) if 't0' not in self.ref_table.colnames + else ~np.isfinite(self.ref_table['t0']) + ) + needs_n_fit = 'n_fit' not in self.ref_table.colnames + + if needs_t0.any() or needs_n_fit: + x_data = np.ma.masked_invalid(self.ref_table['x'].data, copy=True) + y_data = np.ma.masked_invalid(self.ref_table['y'].data, copy=True) + if weighted_xy: + xe_data = np.ma.masked_invalid(self.ref_table['xe'].data, copy=True) + ye_data = np.ma.masked_invalid(self.ref_table['ye'].data, copy=True) + xe_data.mask[np.isclose(xe_data, 0.)] = True + ye_data.mask[np.isclose(ye_data, 0.)] = True + fill_with_one = np.all(xe_data.mask, axis=1) & np.all(ye_data.mask, axis=1) + xe_data[fill_with_one] = 1. + ye_data[fill_with_one] = 1. + else: + xe_data = None + ye_data = None + + if np.ndim(x_data) == 1: + x_data = x_data[:, np.newaxis] + if np.ndim(y_data) == 1: + y_data = y_data[:, np.newaxis] + if weighted_xy: + if np.ndim(xe_data) == 1: + xe_data = xe_data[:, np.newaxis] + if np.ndim(ye_data) == 1: + ye_data = ye_data[:, np.newaxis] + + if 't' in self.ref_table.colnames: + t_data = self.ref_table['t'].data + else: + t_data = np.array(self.ref_table.meta['list_times']) + t_data = np.broadcast_to(t_data, xe_data.shape) + + # Update t0, adapted from startables.fit_motion_models. Only the + # rows that need it are written -- rows that already have a + # valid t0 (e.g. from the input reference list) are left alone. + if needs_t0.any(): + weights = 1. / np.hypot(xe_data, ye_data) if weighted_xy else None + # t_data must be masked (not just weights) and np.ma.average + # (not plain np.average) must be used here: for the + # fill_with_one rows above (no usable xe/ye anywhere at all), + # the substitute weight is uniform/unmasked, but t can still + # be genuinely NaN in undetected epochs. Plain np.average's + # weight-sum denominator doesn't respect t's own mask in that + # case, silently corrupting the result. np.ma.average does, + # and with a uniform weight that's equivalent to + # combine_lists()'s plain (unweighted) mean of just the valid + # epochs -- i.e. these stars' t0 still reflects their real + # detections, it's just not astrometric-error-weighted. + t0_new = np.ma.average(np.ma.masked_invalid(t_data), axis=1, weights=weights).filled(np.nan) + if 't0' not in self.ref_table.colnames: + self.ref_table['t0'] = t0_new + else: + self.ref_table['t0'][needs_t0] = t0_new[needs_t0] + + # Update n_fit: unique epochs with valid data + if needs_n_fit: + xy_mask = ~ (x_data.mask | y_data.mask) + if weighted_xy: + xy_mask &= ~ (xe_data.mask | ye_data.mask) + + self.ref_table['n_fit'] = np.array([ + len(set(t_data[i][xy_mask[i]])) + for i in range(len(self.ref_table)) + ]) + + if self.save_path is not None: + # HDF5 (unlike FITS) stores nan as plain nan -- astropy.io.fits.open() + # round-trips nan through a MaskedColumn instead, silently changing + # every nan-containing column's type on read back -- and it's just + # column data, not tied to flystar's class definitions the way a + # pickle of self or self.ref_table would be. The transform objects + # still need pickling (they're real objects, not plain data), but + # that's a much smaller, more stable pickle than the whole self. + # trans_list_inverse is saved as its own file, only when requested + # (calc_trans_inverse) -- keeping it out of trans_list.pkl means that + # file's content is always the same shape, rather than sometimes a + # plain list and sometimes a dict depending on calc_trans_inverse. + self._write_ref_table(self.save_path, self.prefix_name) + with open(os.path.join(self.save_path, f'{self.prefix_name}_trans_list.pkl'), 'wb') as file: + pickle.dump(self.trans_list, file) + if self.calc_trans_inverse: + with open(os.path.join(self.save_path, f'{self.prefix_name}_trans_list_inverse.pkl'), 'wb') as file: + pickle.dump(self.trans_list_inverse, file) + if self.save_object: + with open(os.path.join(self.save_path, f'{self.prefix_name}.pkl'), 'wb') as file: + pickle.dump(self, file) + if self.verbose > 0: + print('===================================') + print('========== Done with fit ==========') + print('===================================') return +def schedule_len(value): + """ + The number of iterations a per-iteration setting asks for. + + A single value -- a scalar, None, or a bare dict of transformation + arguments -- asks for one, and is broadcast to however many iterations + the other settings call for. A sequence asks for one iteration per + entry. + + Parameters + ---------- + value : scalar, None, dict, or sequence + A schedule argument: ``dr_tol``, ``dm_tol``, ``outlier_tol`` or + ``trans_args``. + + Returns + ------- + int + ``len(value)`` for a sequence, 1 otherwise. + """ + if (value is None) or isinstance(value, dict) or (not np.iterable(value)): + return 1 + + return len(value) + + +# TODO: This is sometimes run on a startable, not a starlist, at least as currently used +def infer_positions(t, startable, motion_models=None, fixed_params_dict=None, return_errors=False): + """ + Take a startable, check to see if it has motion/velocity columns. + If it does, then propagate the positions forward in time + to the desired epoch. If no motion/velocities exist, then just + use ['x0', 'y0'] or ['x', 'y'] + + Parameters + ---------- + t : float + The time to propagate to. Usually in decimal years; + but it should be in the same units + as the 't0' column in starlist. + startable : StarTable + Startable that needs to be inferred. + motion_models : list of MotionModel classes or strings, optional + The motion models to check for in the startable, by default None. + fixed_params_dict : dict, optional + Values for motion-model parameters that are held fixed rather than + fit (e.g. 't0', 'obsLocation'). Takes precedence over columns of the + same name on the startable, by default None. + return_errors : boolean, optional + Whether to return the inferred position errors. If True, then the function returns x, y, xe, ye. If False, then it just returns x, y, by default False. + + Returns + ------- + x, y, (xe, ye) : tuple + Inferred position (and errors) at time t + """ + if ('motion_model_used' in startable.colnames): + x, y, xe, ye = startable.infer_positions(t, fixed_params_dict=fixed_params_dict) + if return_errors: + return x, y, xe, ye + else: + return x, y + + # Convert motion_models from strings to MotionModel classes if needed. + if motion_models is None: + # Setting the default to None to avoid mutable default argument issue + # See https://stackoverflow.com/questions/15189245/assigning-class-variable-as-default-value-to-class-method-argument + motion_models = [motion_model.Empty, motion_model.Fixed] + all_mm_map = motion_model.motion_model_map() + if all(isinstance(mm, str) for mm in motion_models): + mm_names = motion_models + motion_models = [all_mm_map[mm] for mm in motion_models] + else: + mm_names = [mm.name for mm in motion_models] + + # Always add Empty and Fixed in motion models + if 'Fixed' not in mm_names: + motion_models.insert(0, motion_model.Fixed) + if 'Empty' not in mm_names: + motion_models.insert(0, motion_model.Empty) + + # Otherwise, infer positions using the most complex motion model with the existing columns, until it reaches Fixed or Empty + # Sort motion models inversely by mm.n_params + motion_models = sorted(motion_models, key=lambda mm: mm.n_params, reverse=True) + for mm in motion_models: + if mm.name == 'Empty': + x = startable['x'] + y = startable['y'] + return x, y + + required_columns = mm.fit_param_names + mm.fixed_param_names + if all([param in startable.colnames for param in required_columns]): + # Check if the values are finite for non-string columns in the required columns for this motion model. If not, skip to the next motion model. + if not all([np.isfinite(startable[param]).all() for param in required_columns if startable[param].dtype.kind in 'if']): + continue + + # If we have error columns for all fit parameters, then use them in the model inference. Otherwise, just use the fit parameters without errors. + x, y = mm().model( + t=t, + fit_params=np.array([startable[param] for param in mm.fit_param_names]).T, + fixed_params_dict={param: startable[param] for param in mm.fixed_param_names} + ) + break + + return x, y + + +def determine_motion_models(startable, motion_models=None, fixed_params_dict=None, processes=1, chunksize=None, verbose=True): + """Determine, per star, which motion model to use. + + Thin wrapper kept for backward compatibility -- the implementation lives in + motion_model.determine_motion_models, so that startables (which align + imports) can use it too. See there for the precedence rules. + + The `processes`/`chunksize` arguments are unused; the implementation is + vectorized. They are accepted so existing calls keep working. + + Parameters + ---------- + startable : StarTable + The table whose stars are to be classified. + motion_models : list of MotionModel classes or strings, optional + The models a star may be assigned. None lets each star take the most + complex model its own parameters support, by default None. + fixed_params_dict : dict, optional + Motion-model parameters supplied by the caller rather than read from + the table, by default None. + processes : int, optional + Unused; accepted for backward compatibility, by default 1. + chunksize : int, optional + Unused; accepted for backward compatibility, by default None. + verbose : bool, optional + Unused; accepted for backward compatibility, by default True. + + Returns + ------- + motion_model_used : numpy.ndarray of str + The chosen model name for each star. + n_params : numpy.ndarray of int + The number of fitted parameters that model implies, per star. + """ + return motion_model.determine_motion_models( + startable, motion_models=motion_models, fixed_params_dict=fixed_params_dict + ) + + def get_all_epochs(t): """ Helper function to get times of all epochs from a ref table. - This is required because our previous approach - of simply taking the time array of the star with the most detections - fails for mosaicked catalogs, because it is then possible that + This is required because our previous approach + of simply taking the time array of the star with the most detections + fails for mosaicked catalogs, because it is then possible that no star is detected in all fields. + + Parameters + ---------- + t : numpy.ndarray + The table's 2D 't' column, shape (N_stars, N_lists). Undetected + entries are non-finite. + + Returns + ------- + numpy.ndarray + One time per list, taken from whichever star was detected in it. """ nepochs = len(t['t'][0]) @@ -1728,24 +3568,44 @@ def get_all_epochs(t): all_epochs = np.array(all_epochs) return all_epochs - -def setup_ref_table_from_starlist(star_list): - """ + +def setup_ref_table_from_starlist(star_list, motion_models): + """ Start with the reference list.... this will change and grow over time, so make a copy that we will keep updating. - The reference table will contain one columne for every named + The reference table will contain one column for every named array in the original reference star list. + + Parameters + ---------- + star_list : StarList + The starlist to seed the reference table with. + motion_models : list of MotionModel classes or strings + Which motion models are in play. Their parameter columns are kept + 1D (one value per star) rather than being given a per-list axis. + + Returns + ------- + StarTable + The seeded reference table, with one per-list column filled in from + ``star_list`` and matching '_orig' columns for the + untransformed values. """ col_arrays = {} + motion_model_col_names = motion_model.motion_model_param_names(motion_models, with_errors=True) for col_name in star_list.colnames: if col_name == 'name': - # The "name" column will be 1D; but we will also add a "name_in_list" column. + # 1D "name", plus "idx_in_list" carrying per-list identity as a + # row index rather than a copy of the name. See the method of the + # same name on MosaicSelfRef. col_arrays['name'] = star_list[col_name].data - new_col_name = "name_in_list" + col_arrays['idx_in_list'] = np.arange( + len(star_list), dtype=np.int32)[:, np.newaxis] + continue else: new_col_name = col_name - + # Make every column's 2D arrays except "name" and those # columns used for the motion model. if col_name in motion_model_col_names: @@ -1759,7 +3619,7 @@ def setup_ref_table_from_starlist(star_list): # Make new columns to hold original values. These will be copies # of the old columns and will only include x, y, m, xe, ye, me. - # The columns we have already created will hold transformed values. + # The columns we have already created will hold transformed values. trans_col_names = ['x', 'y', 'm', 'xe', 'ye', 'me', 'w'] for tt in range(len(trans_col_names)): old_name = trans_col_names[tt] @@ -1771,32 +3631,30 @@ def setup_ref_table_from_starlist(star_list): # Make sure ref_table has the necessary x0, y0, m0 and associated # error columns. If they don't exist, then add them as a copy of - # the original x,y,m etc columns. - new_cols_arr = ['x0', 'x0e', 'y0', 'y0e', 'm0', 'm0e'] + # the original x,y,m etc columns. + new_cols_arr = ['x0', 'x0_err', 'y0', 'y0_err', 'm0', 'm0_err'] orig_cols_arr = ['x', 'xe', 'y', 'ye', 'm', 'me'] assert len(new_cols_arr) == len(orig_cols_arr) ref_cols = ref_table.keys() - for ii in range(len(new_cols_arr)): - if not new_cols_arr[ii] in ref_cols: + for new_col, orig_col in zip(new_cols_arr, orig_cols_arr): + if new_col not in ref_cols: # Some munging to convert data shape from (N,1) to (N,), # since these are all 1D cols - vals = np.transpose(np.array(ref_table[orig_cols_arr[ii]]))[0] + vals = np.array(ref_table[orig_col]).flatten() # Now add to ref_table - new_col = Column(vals, name=new_cols_arr[ii]) - ref_table.add_column(new_col) - + ref_table.add_column(vals, name=new_col) + if 'use_in_trans' not in ref_table.colnames: - new_col = Column(np.ones(len(ref_table), dtype=bool), name='use_in_trans') - ref_table.add_column(new_col) + ref_table.add_column(np.ones(len(ref_table), dtype=bool), name='use_in_trans') # Now reset the original values to invalids... they will be filled in # at later times. Preserve content only in the columns: name, x0, y0, m0 (and 0e). - # Note that these are all the 1D columsn. + # Note that these are all the 1D columns. for col_name in ref_table.colnames: - if len(ref_table[col_name].data.shape) == 2: # Find the 2D columns - ref_table._set_invalid_list_values(col_name, -1) + if np.ndim(ref_table[col_name].data) == 2: # Find the 2D columns + ref_table._set_invalid_list_values(col_name, -1) return ref_table @@ -1806,33 +3664,77 @@ def copy_over_values(ref_table, star_list, star_list_T, idx_epoch, idx_ref, idx_ into the reference table we carry around and that is the final output product. Copy only those values for stars that match. - Copy all columns that are in both ref_table and star_list_T. + Copy all columns that are in both ref_table and star_list_T. Copy all columns that are also in star_list but copy them into _orig. Parameters ---------- ref_table : StarTable The table we will be copying values into. Note the columns with the appropriate - names and dimensions must already exist. + names and dimensions must already exist. star_list : StarList The astropy table to copy values from. These should be untransformed (orig) values. star_list_T : StarList The astropy table to copy values from. These should be transformed values. + idx_epoch : int + Which per-list (2D) column to write into, i.e. the position of this + starlist in the stack. idx_ref : list or array The indices into the ref_table where values are copied to. idx_lis : list or array - The indices into the star_list or star_lsit_T where values are copied from. + The indices into the star_list or star_list_T where values are copied from. """ + idx_lis = np.array(idx_lis) for col_name in ref_table.colnames: + if col_name == 'n_detect': + # 'n_detect' in ref_table is the 1D aggregate computed by + # detections()/update_n_detect(), not a per-list column -- a + # starlist's own 'n_detect' (used by inherit_n_detect) is + # handled separately below via 'n_detect_list', never here. + continue if col_name in star_list_T.colnames: if col_name == 'name': - ref_table['name_in_list'][idx_ref, idx_epoch] = star_list_T[col_name][list(idx_lis)] + # Record which row of this starlist each star came from. The + # name itself is recoverable from that index, and unlike a + # copy of the name it needs no dtype widening as new lists + # bring in longer names. + ref_table['idx_in_list'][idx_ref, idx_epoch] = idx_lis + elif np.ndim(ref_table[col_name]) != 2: + # Only per-list (2D) columns can take a per-epoch write. A + # shared name whose ref_table column is 1D is an aggregate or a + # per-star flag -- 'use_in_trans', 'x0', 'n_params' and so on -- + # and has no epoch axis to index. Writing it here raised + # "too many indices for array", so simply supplying a + # 'use_in_trans' column on an input starlist crashed the run. + # Leave those columns to whoever owns them. + continue else: - ref_table[col_name][idx_ref, idx_epoch] = star_list_T[col_name][list(idx_lis)] + ref_table[col_name][idx_ref, idx_epoch] = star_list_T[col_name][idx_lis] orig_col_name = col_name + '_orig' if orig_col_name in ref_table.colnames: - ref_table[orig_col_name][idx_ref, idx_epoch] = star_list[col_name][list(idx_lis)] + ref_table[orig_col_name][idx_ref, idx_epoch] = star_list[col_name][idx_lis] + + # Special case for n_detect_list (used by inherit_n_detect): the source + # column is named 'n_detect' (not 'n_detect_list'), so the by-name loop + # above never touches it -- copy it explicitly here. A starlist that's + # itself the output of a previous, lower-level align pass has its own + # 'n_detect'; one without it still contributes a weight of 1 per + # detection. + if 'n_detect_list' in ref_table.colnames: + if 'n_detect' in star_list.colnames: + ref_table['n_detect_list'][idx_ref, idx_epoch] = star_list['n_detect'][idx_lis] + else: + ref_table['n_detect_list'][idx_ref, idx_epoch] = 1 + + # Special case for list_time + if 't' not in star_list.colnames: + ref_table['t'][idx_ref, idx_epoch] = star_list.meta['list_time'] + # Add list_times in meta + if 'list_times' not in ref_table.meta: + ref_table.meta['list_times'] = [star_list.meta['list_time']] + else: + ref_table.meta['list_times'].append(star_list.meta['list_time']) return @@ -1840,453 +3742,180 @@ def reset_ref_values(ref_table): """ Reset all the 2D arrays in the reference table. This is the action we take at the beginning of each new iteration. We don't preserve matching - results from the prior iterations. + results from the prior iterations. + + Parameters + ---------- + ref_table : StarTable + The reference table to clear. Modified in place; only the per-list + (2D) columns are touched, so the aggregated 1D columns survive. + + Returns + ------- + None """ # All 2D columns should be reset. for col_name in ref_table.colnames: - if len(ref_table[col_name].data.shape) == 2: # Find the 2D columns + if np.ndim(ref_table[col_name].data) == 2: # Find the 2D columns # Loop through epochs for this array. for cc in range(ref_table[col_name].shape[1]): ref_table._set_invalid_list_values(col_name, cc) - + return -def add_rows_for_new_stars(ref_table, star_list, idx_lis): +def add_rows_for_new_stars(ref_table, star_list, idx_list, motion_model_name='Fixed', fixed_params_dict=None): """ - For each star that is in star_list and NOT in idx_list, make a - new row in the reference table. The values will be empty (None, NAN, etc.). + For each star that is in star_list and NOT in idx_list, make a + new row in the reference table. The values will be empty (None, NAN, etc.). Parameters ---------- ref_table : StarTable The reference table that the rows will be added to. - star_list : StarList The starlist that will be used to estimate how many new stars there are. - - idx_lis : array or list + idx_list : array or list The indices of the non-new stars (those that matched already). The complement of this array will be used as the new stars. + motion_model_name : str, optional + The motion model name to assign to the new stars, by default 'Fixed'. + fixed_params_dict : dict, optional + The default fixed parameters to assign to the new stars, by default None. Returns - ---------- + ------- ref_table : StarTable The reference table with rows added into. idx_lis_new : list The list of indices into the star_list object for the "new" stars. idx_ref_new : list - The list of indices into the ref_table object for the "new" stars. + The list of indices into the ref_table object for the "new" stars. """ last_star_idx = len(ref_table) - idx_lis_orig = np.arange(len(star_list)) - idx_lis_new = np.array(list(set(idx_lis_orig) - set(idx_lis))) - - if len(idx_lis_new) > 0: - col_arrays = {} - - for col_name in ref_table.colnames: - new_col_name = col_name - - if ref_table[col_name].dtype == np.dtype('float'): + # Which stars in star_list did NOT match anything in the reference table. + # A boolean mask rather than set(range(N)) - set(idx_list): the set form + # builds a Python int object per star, which at a million-row starlist is + # ~100x slower and allocates far more than the mask does. + is_new = np.ones(len(star_list), dtype=bool) + is_new[np.asarray(idx_list, dtype=np.intp)] = False + idx_lis_new = np.where(is_new)[0] + N_newstars = len(idx_lis_new) + + mm_map = motion_model.motion_model_map() + mm = mm_map[motion_model_name] + + # Add optional fixed params default values into fixed params dict, prioritizing values in fixed_params_dict + if fixed_params_dict is not None: + fixed_params_dict.update({k: v for k, v in mm.optional_fixed_params.items() if k not in fixed_params_dict}) + else: + fixed_params_dict = mm.optional_fixed_params.copy() + + if N_newstars > 0: + # Build each column's new rows and concatenate them onto the + # existing column data one column at a time, dropping the old + # column's reference immediately afterward -- instead of building a + # whole parallel StarTable for the new rows and then vstack()-ing + # it onto ref_table, which transiently holds the old table, the new + # (parallel) table, AND vstack's own freshly-concatenated result all + # in memory simultaneously (every column, all at once). That + # transient roughly doubles peak memory on every single "add new + # stars" step, which dominates total memory use for a mosaic that + # grows into the millions of rows across many starlists. Building + # concatenated arrays directly (and letting each old column's array + # be freed as soon as it's replaced) avoids ever needing a second + # full copy of the whole table at once. + colnames = list(ref_table.colnames) + new_col_arrays = {} + for col_name in colnames: + old_col = ref_table[col_name] + dtype = old_col.dtype + + if col_name in fixed_params_dict.keys(): + new_col_empty = fixed_params_dict[col_name] + elif col_name=='n_params': + new_col_empty = mm.n_params + elif col_name=='motion_model_input': + new_col_empty = motion_model_name + elif col_name=='motion_model_used': + new_col_empty = 'Empty' + elif col_name == 'n_detect_list': + # Unlike other int columns, 0 (not -1) is the correct "no + # data" value here -- it lets n_detect (the aggregate) be + # computed as a direct sum(n_detect_list, axis=1) instead of + # a masked sum against x/y. + new_col_empty = 0 + elif col_name in ['xe', 'ye', 'me'] or col_name.endswith('_err'): + new_col_empty = np.inf + elif dtype.kind == 'f': new_col_empty = np.nan - elif ref_table[col_name].dtype == np.dtype('int'): + elif dtype.kind in 'iu': new_col_empty = -1 - elif ref_table[col_name].dtype == np.dtype('bool'): + elif dtype.kind == 'b': new_col_empty = False + elif dtype.kind in 'US': + new_col_empty = '' else: new_col_empty = np.nan - - if len(ref_table[col_name].shape) == 1: - new_col_shape = len(idx_lis_new) - else: - new_col_shape = [len(idx_lis_new), ref_table[col_name].shape[1]] - - new_col_data = Column(data=np.tile(new_col_empty, new_col_shape), - name=col_name, dtype=ref_table[col_name].dtype) - col_arrays[new_col_name] = new_col_data - ref_table_new = StarTable(**col_arrays) - ref_table_nstars = ref_table.meta['n_stars'] + ref_table_new.meta['n_stars'] - ref_table.meta['n_stars'] = ref_table_nstars - ref_table_new.meta['n_stars'] = ref_table_nstars - ref_table_new.meta['ref_list'] = ref_table.meta['ref_list'] - ref_table = vstack([ref_table, ref_table_new]) + if np.ndim(old_col.data) == 1: + new_col_shape = N_newstars + else: + new_col_shape = (N_newstars, old_col.shape[1]) + + new_rows = np.full(new_col_shape, new_col_empty, dtype=dtype) + new_col_arrays[col_name] = np.concatenate([old_col.data, new_rows], axis=0) + + # Drop ref_table's reference to the old column now, before + # moving on to the next one, so it can be freed immediately + # rather than staying alive until every column has been + # processed. + del ref_table[col_name] + del old_col, new_rows + + ref_table_nstars = ref_table.meta['n_stars'] + N_newstars + ref_table_meta = dict(ref_table.meta) + ref_table_meta['n_stars'] = ref_table_nstars + del ref_table + + # Build the new table directly from the already-concatenated, + # already-correctly-shaped/typed arrays with copy=False, so + # StarTable.__init__ uses them as-is instead of silently copying + # the whole (now full-size) table all over again right at the end. + ref_table = StarTable(**new_col_arrays, copy=False) + ref_table.meta.update(ref_table_meta) idx_ref_new = np.arange(last_star_idx, len(ref_table)) - - return ref_table, idx_lis_new, idx_ref_new - - -def run_align_iter(catalog, trans_order=1, poly_deg=1, ref_mag_lim=19, ref_radius_lim=300): - # Load up data with matched stars. - d = Table.read(catalog) - - # Determine how many epochs there are. - N_epochs = len([n for n, c in enumerate(d.colnames) if c.startswith('name')]) - - # Determine how many stars there are. - N_stars = len(d) - - # Determine the reference epoch - ref = d.meta['L_REF'] - - # Figure out the number of free parameters for the specified - # poly2d order. - poly2d = models.Polynomial2D(trans_order) - N_par_trans_per_epoch = 2.0 * poly2d.get_num_coeff(2) # one poly2d for each dimension (X, Y) - N_par_trans = N_par_trans_per_epoch * N_epochs - - ########## - # First iteration -- align everything to REF epoch with zero velocities. - ########## - print('ALIGN_EPOCHS: run_align_iter() -- PASS 1') - ee_ref = d.meta['L_REF'] - - target_name = 'OB120169' - - trans1, used1 = calc_transform_ref_epoch(d, target_name, ee_ref, ref_mag_lim, ref_radius_lim) - - ########## - # Derive the velocity of each stars using the round 1 transforms. - ########## - calc_polyfit_all_stars(d, poly_deg, init_fig_idx=0) - - calc_mag_avg_all_stars(d) - - tdx = np.where((d['name_0'] == 'OB120169') | (d['name_0'] == 'OB120169_L'))[0] - print(d[tdx]['name_0', 't0', 'mag', 'x0', 'vx', 'x0e', 'vxe', 'chi2x', 'y0', 'vy', 'y0e', 'vye', 'chi2y', 'dof']) - - ########## - # Second iteration -- align everything to reference positions derived from iteration 1 - ########## - print('ALIGN_EPOCHS: run_align_iter() -- PASS 2') - target_name = 'OB120169' - - trans2, used2 = calc_transform_ref_poly(d, target_name, poly_deg, ref_mag_lim, ref_radius_lim) - - ########## - # Derive the velocity of each stars using the round 1 transforms. - ########## - calc_polyfit_all_stars(d, poly_deg, init_fig_idx=4) - - ########## - # Save output - ########## - d.write(catalog.replace('.fits', '_aln.fits'), overwrite=True) - - return - -def calc_transform_ref_epoch(d, target_name, ee_ref, ref_mag_lim, ref_radius_lim): - # Determine how many epochs there are. - N_epochs = len([n for n, c in enumerate(d.colnames) if c.startswith('name')]) - - # output array - trans = [] - used = [] - - # Find the target - tdx = np.where(d['name_0'] == 'OB120169')[0][0] - - # Reference values - t_ref = d['t_{0:d}'.format(ee_ref)] - m_ref = d['m_{0:d}'.format(ee_ref)] - x_ref = d['x_{0:d}'.format(ee_ref)] - y_ref = d['y_{0:d}'.format(ee_ref)] - xe_ref = d['xe_{0:d}'.format(ee_ref)] - ye_ref = d['ye_{0:d}'.format(ee_ref)] - - # Calculate some quanitites we use for selecting reference stars. - r_ref = np.hypot(x_ref - x_ref[tdx], y_ref - y_ref[tdx]) - - # Loop through and align each epoch to the reference epoch. - for ee in range(N_epochs): - # Pull out the X, Y positions (and errors) for the two - # starlists we are going to align. - x_epo = d['x_{0:d}'.format(ee)] - y_epo = d['y_{0:d}'.format(ee)] - t_epo = d['t_{0:d}'.format(ee)] - xe_epo = d['xe_{0:d}'.format(ee)] - ye_epo = d['ye_{0:d}'.format(ee)] - - # Figure out the set of stars detected in both epochs. - idx = np.where((t_ref != 0) & (t_epo != 0) & (xe_ref != 0) & (xe_epo != 0))[0] - - # Find those in both epochs AND reference stars. This is [idx][rdx] - rdx = np.where((r_ref[idx] < ref_radius_lim) & (m_ref[idx] < ref_mag_lim))[0] - - # Average the positional errors together to get one weight per star. - xye_ref = (xe_ref + ye_ref) / 2.0 - xye_epo = (xe_epo + ye_epo) / 2.0 - xye_wgt = (xye_ref**2 + xye_epo**2)**0.5 - - # Calculate transform based on the matched stars - trans_tmp = transforms.PolyTransform(x_epo[idx][rdx], y_epo[idx][rdx], x_ref[idx][rdx], y_ref[idx][rdx], - weights=xye_wgt[idx][rdx], order=2) - - trans.append(trans_tmp) - - - # Apply thte transformation to the stars positions and errors: - xt_epo = np.zeros(len(d), dtype=float) - yt_epo = np.zeros(len(d), dtype=float) - xet_epo = np.zeros(len(d), dtype=float) - yet_epo = np.zeros(len(d), dtype=float) - - xt_epo[idx], xet_epo[idx], yt_epo[idx], yet_epo[idx] = trans_tmp.evaluate_errors(x_epo[idx], xe_epo[idx], - y_epo[idx], ye_epo[idx], - nsim=100) - - d['xt_{0:d}'.format(ee)] = xt_epo - d['yt_{0:d}'.format(ee)] = yt_epo - d['xet_{0:d}'.format(ee)] = xet_epo - d['yet_{0:d}'.format(ee)] = yet_epo - - # Record which stars we used in the transform. - used_tmp = np.zeros(len(d), dtype=bool) - used_tmp[idx[rdx]] = True - - used.append(used_tmp) - - if True: - plot_quiver_residuals(xt_epo, yt_epo, x_ref, y_ref, idx, rdx, 'Epoch: ' + str(ee)) - - used = np.array(used) - - return trans, used - - -def calc_transform_ref_poly(d, target_name, poly_deg, ref_mag_lim, ref_radius_lim): - # Determine how many epochs there are. - N_epochs = len([n for n, c in enumerate(d.colnames) if c.startswith('name')]) - # output array - trans = [] - used = [] + return ref_table, idx_lis_new, idx_ref_new - # Find the target - tdx = np.where(d['name_0'] == 'OB120169')[0][0] +""" +Functions specific to OB120169 moved to align_old_functions,py +""" - # Temporary Reference values - t_ref = d['t0'] - m_ref = d['mag'] - x_ref = d['x0'] - y_ref = d['y0'] - xe_ref = d['x0e'] - ye_ref = d['y0e'] - - # Calculate some quanitites we use for selecting reference stars. - r_ref = np.hypot(x_ref - x_ref[tdx], y_ref - y_ref[tdx]) +def calc_mag_avg_all_stars(d): + """ + Add a flux-averaged magnitude column to a table of per-epoch magnitudes. - for ee in range(N_epochs): - # Pull out the X, Y positions (and errors) for the two - # starlists we are going to align. - x_epo = d['x_{0:d}'.format(ee)] - y_epo = d['y_{0:d}'.format(ee)] - t_epo = d['t_{0:d}'.format(ee)] - xe_epo = d['xe_{0:d}'.format(ee)] - ye_epo = d['ye_{0:d}'.format(ee)] - - # Shift the reference position by the polyfit for each star. - dt = t_epo - t_ref - if poly_deg >= 0: - x_ref_ee = x_ref - y_ref_ee = y_ref - xe_ref_ee = x_ref - ye_ref_ee = y_ref - - if poly_deg >= 1: - x_ref_ee += d['vx'] * dt - y_ref_ee += d['vy'] * dt - xe_ref_ee = np.hypot(xe_ref_ee, d['vxe'] * dt) - ye_ref_ee = np.hypot(ye_ref_ee, d['vye'] * dt) - - if poly_deg >= 2: - x_ref_ee += d['ax'] * dt - y_ref_ee += d['ay'] * dt - xe_ref_ee = np.hypot(xe_ref_ee, d['axe'] * dt) - ye_ref_ee = np.hypot(ye_ref_ee, d['aye'] * dt) - - # Figure out the set of stars detected in both. - idx = np.where((t_ref != 0) & (t_epo != 0) & (xe_ref != 0) & (xe_epo != 0))[0] - - # Find those in both AND reference stars. This is [idx][rdx] - rdx = np.where((r_ref[idx] < ref_radius_lim) & (m_ref[idx] < ref_mag_lim))[0] - - # Average the positional errors together to get one weight per star. - xye_ref = (xe_ref_ee + ye_ref_ee) / 2.0 - xye_epo = (xe_epo + ye_epo) / 2.0 - xye_wgt = (xye_ref**2 + xye_epo**2)**0.5 - - # Calculate transform based on the matched stars - trans_tmp = transforms.PolyTransform(x_epo[idx][rdx], y_epo[idx][rdx], x_ref_ee[idx][rdx], y_ref_ee[idx][rdx], - weights=xye_wgt[idx][rdx], order=2) - trans.append(trans_tmp) - - # Apply thte transformation to the stars positions and errors: - xt_epo = np.zeros(len(d), dtype=float) - yt_epo = np.zeros(len(d), dtype=float) - xet_epo = np.zeros(len(d), dtype=float) - yet_epo = np.zeros(len(d), dtype=float) - - xt_epo[idx], xet_epo[idx], yt_epo[idx], yet_epo[idx] = trans_tmp.evaluate_errors(x_epo[idx], xe_epo[idx], - y_epo[idx], ye_epo[idx], - nsim=100) - d['xt_{0:d}'.format(ee)] = xt_epo - d['yt_{0:d}'.format(ee)] = yt_epo - d['xet_{0:d}'.format(ee)] = xet_epo - d['yet_{0:d}'.format(ee)] = yet_epo - - # Record which stars we used in the transform. - used_tmp = np.zeros(len(d), dtype=bool) - used_tmp[idx[rdx]] = True - - used.append(used_tmp) - - if True: - plot_quiver_residuals(xt_epo, yt_epo, x_ref_ee, y_ref_ee, idx, rdx, 'Epoch: ' + str(ee)) - - used = np.array(used) - - return trans, used - -def calc_polyfit_all_stars(d, poly_deg, init_fig_idx=0): - # Determine how many stars there are. - N_stars = len(d) + Magnitudes are converted to fluxes, averaged over the epochs in which the + star was detected, and converted back. Averaging in flux rather than in + magnitude is what makes the result the mean brightness rather than the + mean of a logarithm. Entries equal to 0 are treated as "not detected" and + are left out of the average. - # Determine how many epochs there are. - N_epochs = len([n for n, c in enumerate(d.colnames) if c.startswith('name')]) - - # Setup some variables to save the results - t0_all = [] - px_all = [] - py_all = [] - pxe_all = [] - pye_all = [] - chi2x_all = [] - chi2y_all = [] - dof_all = [] - - # Get the time array, which is the same for all stars. - # Also, sort the time indices. - t = np.array([d['t_{0:d}'.format(ee)][0] for ee in range(N_epochs)]) - tdx = t.argsort() - t_sorted = t[tdx] - - # Run polyfit on each star. - for ss in range(N_stars): - # Get the x, y, xe, ye, and t arrays for this star. - xt = np.array([d['xt_{0:d}'.format(ee)][ss] for ee in range(N_epochs)]) - yt = np.array([d['yt_{0:d}'.format(ee)][ss] for ee in range(N_epochs)]) - xet = np.array([d['xet_{0:d}'.format(ee)][ss] for ee in range(N_epochs)]) - yet = np.array([d['yet_{0:d}'.format(ee)][ss] for ee in range(N_epochs)]) - t_tmp = np.array([d['t_{0:d}'.format(ee)][ss] for ee in range(N_epochs)]) - - # Sort these arrays. - xt_sorted = xt[tdx] - yt_sorted = yt[tdx] - xet_sorted = xet[tdx] - yet_sorted = yet[tdx] - t_tmp_sorted = t_tmp[tdx] - - # Get only the detected epochs. - edx = np.where(t_tmp_sorted != 0)[0] - - # Calculate the weighted t0 (using the transformed errors). - weight_for_t0 = 1.0 / np.hypot(xet_sorted, yet_sorted) - t0 = np.average(t_sorted[edx], weights=weight_for_t0[edx]) - - # for ee in edx: - # print('{0:8.3f} {1:10.5f} {2:10.5f} {3:8.5f} {4:8.5f}'.format(t[ee], xt[ee], yt[ee], xet[ee], yet[ee])) - # pdb.set_trace() - - # Run polyfit - dt = t_sorted - t0 - px, covx = np.polyfit(dt[edx], xt_sorted[edx], poly_deg, w=1./xet_sorted[edx], cov=True) - py, covy = np.polyfit(dt[edx], yt_sorted[edx], poly_deg, w=1./yet_sorted[edx], cov=True) - - pxe = np.sqrt(np.diag(covx)) - pye = np.sqrt(np.diag(covy)) - - - x_mod = np.polyval(px, dt[edx]) - y_mod = np.polyval(py, dt[edx]) - chi2x = np.sum( ((x_mod - xt_sorted[edx]) / xet_sorted[edx])**2 ) - chi2y = np.sum( ((y_mod - yt_sorted[edx]) / yet_sorted[edx])**2 ) - dof = len(edx) - (poly_deg + 1) - - # Save results: - t0_all.append(t0) - px_all.append(px) - py_all.append(py) - pxe_all.append(pxe) - pye_all.append(pye) - chi2x_all.append(chi2x) - chi2y_all.append(chi2y) - dof_all.append(dof) - - if d[ss]['name_0'] in ['OB120169', 'OB120169_L']: - gs = GridSpec(3, 2) # 3 rows, 1 column - fig = plt.figure(ss + 1 + init_fig_idx, figsize=(12, 8)) - a0 = fig.add_subplot(gs[0:2, 0]) - a1 = fig.add_subplot(gs[2, 0]) - a2 = fig.add_subplot(gs[0:2, 1]) - a3 = fig.add_subplot(gs[2, 1]) - - a0.errorbar(t_sorted[edx], xt_sorted[edx], yerr=xet_sorted[edx], fmt='ro') - a0.plot(t_sorted[edx], x_mod, 'k-') - a0.set_title(d[ss]['name_0'] + ' X') - a1.errorbar(t_sorted[edx], xt_sorted[edx] - x_mod, yerr=xet_sorted[edx], fmt='ro') - a1.axhline(0, linestyle='--') - a1.set_xlabel('Time (yrs)') - a2.errorbar(t_sorted[edx], yt_sorted[edx], yerr=yet_sorted[edx], fmt='ro') - a2.plot(t_sorted[edx], y_mod, 'k-') - a2.set_title(d[ss]['name_0'] + ' Y') - a3.errorbar(t_sorted[edx], yt_sorted[edx] - y_mod, yerr=yet_sorted[edx], fmt='ro') - a3.axhline(0, linestyle='--') - a3.set_xlabel('Time (yrs)') - - - - t0_all = np.array(t0_all) - px_all = np.array(px_all) - py_all = np.array(py_all) - pxe_all = np.array(pxe_all) - pye_all = np.array(pye_all) - chi2x_all = np.array(chi2x_all) - chi2y_all = np.array(chi2y_all) - dof_all = np.array(dof_all) - - # Done with all the stars... recast as numpy arrays and save to output table. - d['t0'] = t0_all - d['chi2x'] = chi2x_all - d['chi2y'] = chi2y_all - d['dof'] = dof_all - if poly_deg >= 0: - d['x0'] = px_all[:, -1] - d['y0'] = py_all[:, -1] - d['x0e'] = pxe_all[:, -1] - d['y0e'] = pye_all[:, -1] - - if poly_deg >= 1: - d['vx'] = px_all[:, -2] - d['vy'] = py_all[:, -2] - d['vxe'] = pxe_all[:, -2] - d['vye'] = pye_all[:, -2] - - if poly_deg >= 2: - d['ax'] = px_all[:, -3] - d['ay'] = py_all[:, -3] - d['axe'] = pxe_all[:, -3] - d['aye'] = pye_all[:, -3] - - pdb.set_trace() - - return + Parameters + ---------- + d : astropy.table.Table + Table with one 'm_' column per epoch, and a matching set of + 'name<...>' columns from which the number of epochs is counted. -def calc_mag_avg_all_stars(d): - # Determine how many stars there are. + Returns + ------- + None + ``d`` is modified in place, gaining a 'mag' column. + """ + # Determine how many stars there are. N_stars = len(d) # Determine how many epochs there are. @@ -2310,8 +3939,7 @@ def calc_mag_avg_all_stars(d): -def initial_align(table1, table2, briteN=100, - transformModel=transforms.PolyTransform, order=1, req_match=5): +def initial_align(table1, table2, briteN=100, transformModel=transforms.PolyTransform, order=1): """ Calculates an initial (unweighted) transformation from table1 starlist into table2 starlist (i.e., table2 is the reference starlist). Matching is done using @@ -2328,7 +3956,7 @@ def initial_align(table1, table2, briteN=100, y: y position xe: error in x position ye: error in y position - + vx: proper motion in x direction vy proper motion in y direction vxe: error in x proper motion @@ -2336,39 +3964,32 @@ def initial_align(table1, table2, briteN=100, m: magnitude me: magnitude error - + t0: linear motion time zero point use: specify use in transformation - - Parameters: + + Parameters ---------- - -table1: astropy.table + table1 : astropy.table contains name,m,x,y,xe,ye,vx,vy,vxe,vye,t0. - - -table2: astropy.table + table2 : astropy.table contains name,m,x,y,xe,ye. this is the reference template - - -briteN: int - The number of brightest stars used to match two starlists. - - -transformModel: transformation model object (class) + briteN : int, optional + The number of brightest stars used to match two starlists, by default 100. + transformModel : flystar.transforms.Transform, optional The transformation model class that will be instantiated to find the best-fit transformation parameters between matched table1 and table2. - eg: transforms.four_paramNW, transforms.PolyTransform - - -order: int - Order of the transformation. Not relevant for 4 parameter or spline fit - - -req_match: int - Number of required matches of the input catalog to the total reference + eg : transforms.four_paramNW, transforms.PolyTransform, by default transforms.PolyTransform. + order : int, optional + Order of the transformation. Not relevant for 4 parameter or spline fit, by default 1. - Output: - ------ + Returns + ------- Transformation object - + """ # Extract necessary information from tables (x, y, m) x1 = table1['x'] @@ -2394,7 +4015,7 @@ def initial_align(table1, table2, briteN=100, -def transform_and_match(table1, table2, transform, dr_tol=1.0, dm_tol=None, verbose=True): +def transform_and_match(table1, table2, transform, dr_tol=1.0, dm_tol=None, workers=1, verbose=True): """ apply transformation to starlist1 and match stars to given radius and magnitude tolerance. @@ -2402,29 +4023,34 @@ def transform_and_match(table1, table2, transform, dr_tol=1.0, dm_tol=None, verb Starlists must be astropy tables with standard columns names as specified in initial_align. - Parameters: - ----------- - -table1: astropy.table + Parameters + ---------- + table1 : astropy.table contains name,m,x,y,xe,ye,vx,vy,vxe,vye,t0. - - -table2: astropy.table + table2 : astropy.table contains name,m,x,y,xe,ye. this is the reference template - - -dr_tol: float (default=1.0) + transform : transforms.Transform2D + The transformation applied to table1 before matching. + dr_tol : float (default=1.0), optional The search radius for the matching algorithm, in the same units as the - starlist file positions. + starlist file positions, by default 1.0. + dm_tol : float or None, optional + Magnitude tolerance for a match, in magnitudes. None accepts a match + regardless of the brightness difference, by default None. + workers : int (default=1), optional + Number of worker threads for the KDTree neighbor search. -1 uses all + available CPU cores. See match.match() for details. + By default 1. + verbose : bool, optional + Prints on screen information on the matching, by default True. - -transform: transformation object - - -verbose: bool, optional - Prints on screen information on the matching - - - Output: + Returns ------- - -idx1: indicies of matched stars from table1 - -idx2: indicies of matched stars from tabel2 + idx1 : numpy.ndarray of int + Indices of the matched stars in table1. + idx2 : numpy.ndarray of int + Indices of the matched stars in table2, in the same order. """ # Extract necessary information from tables (x, y, m) @@ -2435,12 +4061,11 @@ def transform_and_match(table1, table2, transform, dr_tol=1.0, dm_tol=None, verb y2 = table2['y'] m2 = table2['m'] - # Transform x, y coordinates from starlist 1 into starlist 2 x1t, y1t = transform.evaluate(x1, y1) # Match starlist 1 and 2 - idx1, idx2, dr, dm = match.match(x1t, y1t, m1, x2, y2, m2, dr_tol, dm_tol, verbose=verbose) + idx1, idx2, dr, dm = match.match(x1t, y1t, m1, x2, y2, m2, dr_tol, dm_tol, workers=workers, verbose=verbose) if verbose: print(( '{0} of {1} stars matched'.format(len(idx1), len(x1t)))) @@ -2454,48 +4079,52 @@ def find_transform(table1, table1_trans, table2, transModel=transforms.PolyTrans Given a matched starlist, derive a new transform. This transformation is calculated for starlist 1 into starlist 2 - Parameters: - ----------- - table1: astropy table + Parameters + ---------- + table1 : astropy table Table which we have calculated the transformation for, trimmed to only stars which match with table2. Original coords, not transformed into reference frame. - table1_trans: astropy table + table1_trans : astropy table Table which we calculated the transformation fo, trimmed to only stars which match with table2. Contains transformed coords. Only used when calculating weights. - table2: astropy table + table2 : astropy table Table with the reference starlist. Trimmed to only stars which match table1. - trans: transformation object - Transformation used to transform table1 coords in transform_and_match - in order to do the star matching. + transModel : transformation class, optional + Desired transform to apply to matched stars, e.g. four_paramNW or + PolyTransform. If PolyTransform is selected, ``order`` defines the + order of polynomial used. By default transforms.PolyTransform. - transModel: transformation class (default: transform.four_paramNW) - Desired transform to apply to matched stars, e.g. four_paramNW or PolyTransform. - If PolyTransform is selected, order defines the order of polynomial used - - order: int (default=1) + order : int, optional Order of polynomial to use in the transformation. Only active if - PolyTransform is selected - - weights: string (default=None) - if weights=='both', we use both position error in transformed starlist and - reference starlist as uncertanty. And weights is the reciprocal of this uncertanty. - if weights=='starlist', we only use postion error in transformed starlist. - if weights=='reference', we only use position error in reference starlist. - if weights==None, we don't use weights. - - verbose: bool (default=True) - Prints on screen information on the matching - - Output: - ------ - -transformation object - -number of stars used in transform + PolyTransform is selected, by default 1. + + weights : str or None, optional + Which uncertainties to weight the fit by: + + - 'both' -- position errors from both the transformed starlist and + the reference starlist; the weight is the reciprocal of the + combined uncertainty. + - 'starlist' -- position errors from the transformed starlist only. + - 'reference' -- position errors from the reference starlist only. + - None -- unweighted. + + By default None. + + verbose : bool, optional + Prints on screen information on the matching, by default True. + + Returns + ------- + transform : transforms.Transform2D + The derived transformation, taking table1 onto table2. + N_trans : int + Number of stars used to derive it. """ # First, check that desired transform is supported if ( (transModel != transforms.four_paramNW) & @@ -2504,7 +4133,7 @@ def find_transform(table1, table1_trans, table2, transModel=transforms.PolyTrans (transModel != transforms.LegTransform) ): print(( '{0} not supported yet!'.format(transModel))) return - + # Extract *untransformed* coordinates from starlist 1 # and the matching coordinates from starlist 2 x1 = table1['x'] @@ -2516,7 +4145,7 @@ def find_transform(table1, table1_trans, table2, transModel=transforms.PolyTrans # calculate weights from *transformed* coords. This is where we use the # transformation object - if (table1_trans != None) and ('xe' in table1_trans.colnames): + if (table1_trans is not None) and ('xe' in table1_trans.colnames): x1e = table1_trans['xe'] y1e = table1_trans['ye'] @@ -2552,50 +4181,58 @@ def find_transform_new(table1_mat, table2_mat, Given a matched starlist, derive a new transform. This transformation is calculated for starlist 1 into starlist 2 - Parameters: - ----------- - table1_mat: astropy table + Parameters + ---------- + table1_mat : astropy table Table with matched stars from starlist 1, with original positions (not transformed into starlist 2 frame) - table2_mat: astropy table + table2_mat : astropy table Table with matched stars from starlist 2, in starlist 2 frame. - transModel: transformation class (default: transform.four_paramNW) + transModel : transformation class, optional Specify desired transform, e.g. four_paramNW or PolyTransform. If - PolyTransform is selected, order defines the order of polynomial used + PolyTransform is selected, ``order`` defines the order of polynomial + used. By default transforms.four_paramNW. - order: int (default=1) + order : int, optional Order of polynomial to use in the transformation. Only active if - PolyTransform is selected + PolyTransform is selected, by default 1. - weights: string (default=None) - if weights=='both', we use position error in transformed - starlist and reference starlist as uncertanties. And weights is the reciprocal - of this uncertanty. - if weights=='starlist', we only use postion error and velocity error in transformed - starlist as uncertainty. - if weights=='reference', we only use position error in reference starlist as uncertainty. - if weights==None, we don't use weights. - - transInit: Transform Object (default=None) - if weights = 'both' or 'starlist' then the positions in table 1 are first transformed - using the transInit object. This is necessary if the plate scales are very different - between the table 1 and the reference list. - - verbose: bool (default=True) - Prints on screen information on the matching - - Output: - ------ - -transformation object - -number of stars used in transform + weights : str or None, optional + Which uncertainties to weight the fit by: + + - 'both' -- position errors from both the transformed starlist and + the reference starlist; the weight is the reciprocal of the + combined uncertainty. + - 'starlist' -- position and velocity errors from the transformed + starlist only. + - 'reference' -- position errors from the reference starlist only. + - None -- unweighted. + + By default None. + + transInit : transforms.Transform2D, optional + If ``weights`` is 'both' or 'starlist', the positions in table1 are + first transformed using this object. This is necessary when the plate + scales of table1 and the reference list differ greatly, since the + uncertainties have to be compared in a common frame. By default None. + + verbose : bool, optional + Prints on screen information on the matching, by default True. + + Returns + ------- + transform : transforms.Transform2D + The derived transformation, taking table1_mat onto table2_mat. + N_trans : int + Number of stars used to derive it. """ # First, check that desired transform is supported if ( (transModel != transforms.four_paramNW) & (transModel != transforms.PolyTransform) ): print(( '{0} not supported yet!'.format(transModel))) return - + # Extract *untransformed* coordinates from starlist 1 # and the matching coordinates from starlist 2 x1 = table1_mat['x'] @@ -2604,18 +4241,18 @@ def find_transform_new(table1_mat, table2_mat, y2 = table2_mat['y'] # Get the uncertainties (if needed) and calculate the weights. - if weights != None: + if weights is not None: x1e = table1_mat['xe'] y1e = table1_mat['ye'] x2e = table2_mat['xe'] y2e = table2_mat['ye'] - if transInit != None: + if transInit is not None: table1T_mat = table1_mat.copy() - table1T_mat = transform_by_object(table1T_mat, transInit) + table1T_mat = transform_from_object(table1T_mat, transInit) - x1e = table1T_mag['xe'] - y1e = table1T_mag['ye'] + x1e = table1T_mat['xe'] + y1e = table1T_mat['ye'] # Calculate weights as to user specification if weights == 'both': @@ -2648,50 +4285,43 @@ def write_transform(transform, starlist, reference, N_trans, deltaMag=0, restric x' = a0 + a1*x + a2*y + a3*x**2. + a4*x*y + a5*y**2. + ... y' = b0 + b1*x + b2*y + b3*x**2. + b4*x*y + b5*y**2. + ... - Parameters: + Parameters ---------- - transform: transformation object + transform : transformation object Transformation object we want to feed into java align - - starlist: string + starlist : string File name of starlist; this is the starlist the transformation should be applied to. For output purposes only - - reference: string + reference : string File name of reference; this is what the starlist is transformed to. For output purposes only - - N_trans: int + N_trans : int Number of stars used in the transformation - - deltaMag: float (default = 0) + deltaMag : float (default = 0), optional Average magnitude difference between reference and starlist - (reference - starlist) - - restrict: boolean (default=False) + (reference - starlist), by default 0. + restrict : boolean (default=False), optional Set to True if transformation restricted to stars with use > 2. Purely - for output purposes - - weights: string (default=None) + for output purposes, by default False. + weights : string (default=None), optional if weights=='both', we use both position error and velocity error in transformed starlist and reference starlist as uncertanties. And weights is the reciprocal - of this uncertanty. + of this uncertanty. if weights=='starlist', we only use postion error and velocity error in transformed starlist as uncertainty. if weights=='reference', we only use position error in reference starlist as uncertainty - if weights==None, we don't use weights. - - outFile: string (default: 'outTrans.txt') + if weights==None, we don't use weights, by default None. + outFile : string (default: 'outTrans.txt'), optional Name of output text file - - Output: - ------ - txt file with the file name outFile + + Returns + ------- + txt file with the file name outFile, by default 'outTrans.txt'. """ # Extract info about transformation trans_name = transform.__class__.__name__ trans_order = transform.order - + # Extract X, Y coefficients from transform if trans_name == 'four_paramNW': Xcoeff = transform.px @@ -2700,12 +4330,11 @@ def write_transform(transform, starlist, reference, N_trans, deltaMag=0, restric Xcoeff = transform.px.parameters Ycoeff = transform.py.parameters else: - print(( '{0} not yet supported!'.format(transType))) - return - + raise Exception(f'{trans_name} not yet supported!') + # Write output _out = open(outFile, 'w') - + # Write the header. DO NOT CHANGE, HARDCODED IN JAVA ALIGN _out.write('## Date: {0}\n'.format(datetime.date.today()) ) _out.write('## File: {0}, Reference: {1}\n'.format(starlist, reference) ) @@ -2718,7 +4347,7 @@ def write_transform(transform, starlist, reference, N_trans, deltaMag=0, restric _out.write('## N_trans: {0}\n'.format(N_trans)) _out.write('## Delta Mag: {0}\n'.format(deltaMag)) _out.write('{0:16s} {1:16s}\n'.format('# Xcoeff', 'Ycoeff')) - + # Write the coefficients such that the orders are together as defined in # documentation. This is a pain because PolyTransform output is weird. # (see astropy Polynomial2D documentation) @@ -2729,12 +4358,12 @@ def write_transform(transform, starlist, reference, N_trans, deltaMag=0, restric # CODE TO GET INDICIES N = trans_order - 1 idx_list = list() - + # when trans_order=1, N=0 idx_list.append(0) idx_list.append(1) idx_list.append(N+2) - + if trans_order >= 2: for k in range(2, N+2): idx_list.append(k) @@ -2743,22 +4372,17 @@ def write_transform(transform, starlist, reference, N_trans, deltaMag=0, restric idx_list.append(int(2*N +2 +j + (2*N+2-i)*(i-1)/2.)) idx_list.append(N+1+k) - #_out.write('{0:16.6e} {1:16.6e}\n'.format(Xcoeff[0], Ycoeff[0]) ) - #_out.write('{0:16.6e} {1:16.6e}\n'.format(Xcoeff[1], Ycoeff[1]) ) - #_out.write('{0:16.6e} {1:16.6e}\n'.format(Xcoeff[3], Ycoeff[3]) ) - #_out.write('{0:16.6e} {1:16.6e}\n'.format(Xcoeff[2], Ycoeff[2]) ) - #_out.write('{0:16.6e} {1:16.6e}\n'.format(Xcoeff[5], Ycoeff[5]) ) - #_out.write('{0:16.6e} {1:16.6e}'.format(Xcoeff[4], Ycoeff[4]) ) - for i in idx_list: _out.write('{0:16.6e} {1:16.6e}\n'.format(Xcoeff[i], Ycoeff[i]) ) _out.close() - + return +# Transform_from_file original version moved to align_old_functions.py +# This version makes the transFile an object and uses transform_from_object def transform_from_file(starlist, transFile): """ Apply transformation from transFile to starlist. Returns astropy table with @@ -2766,196 +4390,84 @@ def transform_from_file(starlist, transFile): positions/position errors, plus velocities and velocity errors if they are present in starlist. - WARNING: THIS CODE WILL NOT WORK FOR LEGENDRE POLYNOMIAL - TRANSFORMS - - Parameters: + WARNING: THIS CODE WORKS FOR POLYTRANSFORM + + Parameters ---------- - starlist: astropy table + starlist : astropy table Starlist we want to apply the transformation too. Must already have standard column headers - - transFile: ascii file + transFile : ascii file File with the transformation coefficients. Assumed to be output of write_transform, with coefficients specified as code documents - Output: - ------ + Returns + ------- Copy of starlist astropy table with transformed coordinates. """ - # Make a copy of starlist. This is what we will eventually modify with - # the transformed coordinates - starlist_f = copy.deepcopy(starlist) - - # Check to see if velocities are present in starlist. If so, we will - # need to transform these as well as positions - vel = False - keys = list(starlist.keys()) - if 'vx' in keys: - vel = True - - # Extract needed information from starlist - x_orig = starlist['x'] - y_orig = starlist['y'] - xe_orig = starlist['xe'] - ye_orig = starlist['ye'] - - if vel: - x0_orig = starlist['x0'] - y0_orig = starlist['y0'] - x0e_orig = starlist['x0e'] - y0e_orig = starlist['y0e'] - - vx_orig = starlist['vx'] - vy_orig = starlist['vy'] - vxe_orig = starlist['vxe'] - vye_orig = starlist['vye'] - - # Read transFile - trans = Table.read(transFile, format='ascii.commented_header', header_start=-1) - Xcoeff = trans['Xcoeff'] - Ycoeff = trans['Ycoeff'] - - #-----------------------------------------------# - # General equation for applying the transform - #-----------------------------------------------# - #""" + # Make transform object + trans_table = Table.read(transFile, format='ascii.commented_header', header_start=-1) + Xcoeff = trans_table['Xcoeff'] + Ycoeff = trans_table['Ycoeff'] # First determine the order based on the number of terms # Comes from Nterms = (N+1)*(N+2) / 2. order = (np.sqrt(1 + 8*len(Xcoeff)) - 3) / 2. - if order%1 != 0: print( 'Incorrect number of coefficients for polynomial') print( 'Stopping') return order = int(order) + # Do transform + transform = transforms.PolyTransform(order, Xcoeff, Ycoeff) + return transform_from_object(starlist, transform) - # Position transformation - x_new, y_new = transform_pos_from_file(Xcoeff, Ycoeff, order, x_orig, - y_orig) - - if vel: - x0_new, y0_new = transform_pos_from_file(Xcoeff, Ycoeff, order, x0_orig, - y0_orig) - - # Position error transformation - xe_new, ye_new = transform_poserr_from_file(Xcoeff, Ycoeff, order, xe_orig, - ye_orig, x_orig, y_orig) - - if vel: - x0e_new, y0e_new = transform_poserr_from_file(Xcoeff, Ycoeff, order, x0e_orig, - y0e_orig, x0_orig, y0_orig) - - if vel: - # Velocity transformation - vx_new, vy_new = transform_vel_from_file(Xcoeff, Ycoeff, order, vx_orig, - vy_orig, x_orig, y_orig) - - # Velocity error transformation - vxe_new, vye_new = transform_velerr_from_file(Xcoeff, Ycoeff, order, - vxe_orig, vye_orig, - vx_orig, vy_orig, - xe_orig, ye_orig, - x_orig, y_orig) - - #----------------------------------------# - # Hard coded example: old but functional - #----------------------------------------# - """ - # How the transformation is applied depends on the type of transform. - # This can be determined by the length of Xcoeff, Ycoeff - if len(Xcoeff) == 3: - x_new = Xcoeff[0] + Xcoeff[1] * x_orig + Xcoeff[2] * y_orig - y_new = Ycoeff[0] + Ycoeff[1] * x_orig + Ycoeff[2] * y_orig - xe_new = np.sqrt( (Xcoeff[1] * xe_orig)**2 + (Xcoeff[2] * ye_orig)**2 ) - ye_new = np.sqrt( (Ycoeff[1] * xe_orig)**2 + (Ycoeff[2] * ye_orig)**2 ) - - if vel: - vx_new = Xcoeff[1] * vx_orig + Xcoeff[2] * vy_orig - vy_new = Ycoeff[1] * vx_orig + Ycoeff[2] * vy_orig - vxe_new = np.sqrt( (Xcoeff[1] * vxe_orig)**2 + (Xcoeff[2] * vye_orig)**2 ) - vye_new = np.sqrt( (Ycoeff[1] * vxe_orig)**2 + (Ycoeff[2] * vye_orig)**2 ) - - elif len(Xcoeff) == 6: - x_new = Xcoeff[0] + Xcoeff[1]*x_orig + Xcoeff[3]*x_orig**2 + Xcoeff[2]*y_orig + \ - Xcoeff[5]*y_orig**2. + Xcoeff[4]*x_orig*y_orig - - y_new = Ycoeff[0] + Ycoeff[1]*x_orig + Ycoeff[3]*x_orig**2 + Ycoeff[2]*y_orig + \ - Ycoeff[5]*y_orig**2. + Ycoeff[4]*x_orig*y_orig - - xe_new = np.sqrt( (Xcoeff[1] + 2*Xcoeff[3]*x_orig + Xcoeff[4]*y_orig)**2 * xe_orig**2 + \ - (Xcoeff[2] + 2*Xcoeff[5]*y_orig + Xcoeff[4]*x_orig)**2 * ye_orig**2 ) - - ye_new = np.sqrt( (Ycoeff[1] + 2*Ycoeff[3]*x_orig + Ycoeff[4]*y_orig)**2 * xe_orig**2 + \ - (Ycoeff[2] + 2*Ycoeff[5]*y_orig + Ycoeff[4]*x_orig)**2 * ye_orig**2 ) - - if vel: - vx_new = Xcoeff[1]*vx_orig + 2*Xcoeff[3]*x_orig*vx_orig + Xcoeff[2]*vy_orig + \ - 2.*Xcoeff[5]*y_orig*vy_orig + Xcoeff[4]*(x_orig*vy_orig + vx_orig*y_orig) - - vy_new = Ycoeff[1]*vx_orig + 2*Ycoeff[3]*x_orig*vx_orig + Ycoeff[2]*vy_orig + \ - 2.*Ycoeff[5]*y_orig*vy_orig + Ycoeff[4]*(x_orig*vy_orig + vx_orig*y_orig) - - vxe_new = np.sqrt( (Xcoeff[1] + 2*Xcoeff[3]*x_orig + Xcoeff[4]*y_orig)**2 * vxe_orig**2 + \ - (Xcoeff[2] + 2*Xcoeff[5]*y_orig + Xcoeff[4]*x_orig)**2 * vye_orig**2 + \ - (2*Xcoeff[3]*vx_orig + Xcoeff[4]*vy_orig)**2 * xe_orig**2 + \ - (2*Xcoeff[5]*vy_orig + Xcoeff[4]*vx_orig)**2 * ye_orig**2 ) - - vye_new = np.sqrt( (Ycoeff[1] + 2*Ycoeff[3]*x_orig + Ycoeff[4]*y_orig)**2 * vxe_orig**2 + \ - (Ycoeff[2] + 2*Ycoeff[5]*y_orig + Ycoeff[4]*x_orig)**2 * vye_orig**2 + \ - (2*Ycoeff[3]*vx_orig + Ycoeff[4]*vy_orig)**2 * xe_orig**2 + \ - (2*Ycoeff[5]*vy_orig + Ycoeff[4]*vx_orig)**2 * ye_orig**2 ) - """ - #Update transformed coords to copy of astropy table - starlist_f['x'] = x_new - starlist_f['y'] = y_new - starlist_f['xe'] = xe_new - starlist_f['ye'] = ye_new - - if vel: - starlist_f['x0'] = x0_new - starlist_f['y0'] = y0_new - starlist_f['x0e'] = x0e_new - starlist_f['y0e'] = y0e_new - starlist_f['vx'] = vx_new - starlist_f['vy'] = vy_new - starlist_f['vxe'] = vxe_new - starlist_f['vye'] = vye_new - - return starlist_f def transform_from_object(starlist, transform): """ Apply transformation to starlist. Returns astropy table with transformed positions/position errors, velocities and velocity errors - if they are present in starlits - - Parameters: + if they are present in starlits. If a more complex motion_model is + implemented, the motion parameters are set to nan, as we need the full time + series to refit. + + Parameters ---------- - starlist: astropy table + starlist : astropy table Starlist we want to apply the transformation too. Must already have standard column headers x0, y0, x0e, y0e, vx, vy, vxe, vye, x, y, xe, ye + transform : transformation object - transform: transformation object - - Output: - ------ + Returns + ------- Copy of starlist astropy table with transformed x0, y0, x0e, y0e, vx, vy, vxe, vye, x, y, xe, ye """ # Make a copy of starlist. This is what we will eventually modify with # the transformed coordinates - starlist_f = copy.deepcopy(starlist) + starlist_f = StarList(starlist, copy=True) keys = list(starlist.keys()) - # Check to see if velocities are present in starlist. If so, we will - # need to transform these as well as positions - vel = 'vx' in keys + # Check to see if velocities or motion_model are present in starlist. + vel = ('vx' in keys) and ("motion_model_input" not in keys) + mot = ("motion_model_input" in keys) + # If the only motion models used are Fixed and Linear, we can still transform velocities. + if mot: + motion_models_unique = list(np.unique(starlist_f['motion_model_input'])) + if 'Linear' in motion_models_unique: + motion_models_unique.remove('Linear') + if 'Fixed' in motion_models_unique: + motion_models_unique.remove('Fixed') + if len(motion_models_unique)==0: + vel=True + mot=False + + # Prior code before motion_model implementation + # Can still be used as shortcut for Linear+Fixed motion_model only err = 'xe' in keys - + # Extract needed information from starlist x = starlist_f['x'] y = starlist_f['y'] @@ -2963,49 +4475,50 @@ def transform_from_object(starlist, transform): if err: xe = starlist_f['xe'] ye = starlist_f['ye'] + else: + xe = np.zeros(len(starlist_f)) + ye = np.zeros(len(starlist_f)) if vel: x0 = starlist_f['x0'] y0 = starlist_f['y0'] - x0e = starlist_f['x0e'] - y0e = starlist_f['y0e'] + x0e = starlist_f['x0_err'] + y0e = starlist_f['y0_err'] vx = starlist_f['vx'] vy = starlist_f['vy'] - vxe = starlist_f['vxe'] - vye = starlist_f['vye'] - + vxe = starlist_f['vx_err'] + vye = starlist_f['vy_err'] + # calculate the transformed position and velocity - - # (x_new, y_new, xe_new, ye_new) in (x,y) x_new, y_new, xe_new, ye_new = position_transform_from_object(x, y, xe, ye, transform) - - if vel: - # (x0_new, y0_new, x0e_new, y0e_new) in (x0, y0, x0e, y0e) - x0_new, y0_new, x0e_new, y0e_new = position_transform_from_object(x0, y0, x0e, y0e, transform) - # (vx_new, vy_new, vxe_new, vye_new) in (x0, y0, x0e, y0e, vx, vy, vxe, vye) - vx_new, vy_new, vxe_new, vye_new = velocity_transform_from_object(x0, y0, x0e, y0e, vx, vy, vxe, vye, transform) - # update transformed coords to copy of astropy table starlist_f['x'] = x_new starlist_f['y'] = y_new starlist_f['xe'] = xe_new starlist_f['ye'] = ye_new - + if vel: + x0_new, y0_new, x0e_new, y0e_new = position_transform_from_object(x0, y0, x0e, y0e, transform) + vx_new, vy_new, vxe_new, vye_new = velocity_transform_from_object(x0, y0, x0e, y0e, vx, vy, vxe, vye, transform) starlist_f['x0'] = x0_new starlist_f['y0'] = y0_new - starlist_f['x0e'] = x0e_new - starlist_f['y0e'] = y0e_new + starlist_f['x0_err'] = x0e_new + starlist_f['y0_err'] = y0e_new starlist_f['vx'] = vx_new starlist_f['vy'] = vy_new - starlist_f['vxe'] = vxe_new - starlist_f['vye'] = vye_new - - return starlist_f - + starlist_f['vx_err'] = vxe_new + starlist_f['vy_err'] = vye_new + # For more complicated motion_models, + # we can't easily transform them, set the values to nans and refit later. + if mot: + motion_model_params = motion_model.motion_model_param_names() + for param in motion_model_params: + if param in keys: + starlist_f[param] = np.nan + return starlist_f def position_transform_from_object(x, y, xe, ye, transform): @@ -3013,14 +4526,23 @@ def position_transform_from_object(x, y, xe, ye, transform): given the orginal position and position error, calculate the transformed position and position error based on transformation object from astropy.modeling.models.polynomial2D. - Input: - - x, y: original position - - xe, ye: original position error - - transform: transformation object from astropy.modeling.models.polynomial2D - - Outpus: - - x_new, y_new: transformed position - - xe_new, ye_new: transformed position error + + Parameters + ---------- + x, y : array-like + Original positions. + xe, ye : array-like + Uncertainties on the original positions. + transform : transforms.Transform2D + The transformation to apply. Its polynomial coefficients are + differentiated to propagate the uncertainties. + + Returns + ------- + x_new, y_new : numpy.ndarray + Transformed positions. + xe_new, ye_new : numpy.ndarray + Transformed position uncertainties. """ # Read transformation: Extract X, Y coefficients from transform @@ -3034,8 +4556,8 @@ def position_transform_from_object(x, y, xe, ye, transform): order = transform.order else: txt = 'Transform not yet supported by position_transform_from_object' - raise StandardError(txt) - + raise Exception(txt) + # How the transformation is applied depends on the type of transform. # This can be determined by the length of Xcoeff, Ycoeff N = order - 1 @@ -3060,9 +4582,8 @@ def position_transform_from_object(x, y, xe, ye, transform): sub = int(2*N + 2 + j + (2*N+2-i) * (i-1)/2.) y_new += Ycoeff[sub] * (x**i) * (y**j) - """ - THIS IS WRONG BELOW! + THIS IS WRONG BELOW! - NOTE: I don't think this is wrong any more Currently doing: ((A + B + C) * xe)**2 @@ -3070,7 +4591,7 @@ def position_transform_from_object(x, y, xe, ye, transform): Should be doing: ((A**2 + B**2 + C**2) * xe**2) """ - + # xe_new & ye_new in (x,y,xe,ye) xe_new = 0 temp1 = 0 @@ -3113,16 +4634,35 @@ def velocity_transform_from_object(x0, y0, x0e, y0e, vx, vy, vxe, vye, transform """ given the orginal position & position error & velocity & veolicty error, calculat the transformed velocity and velocity error based on transformation - from astropy.modling.models.polynomial2D. - Input: - - x0, y0, x0e, y0e: original position and position error - - vx, vy, vxe, vye: original velocity and velocity error - - transform: transformation object from astropy.modeling.models.polynomial2D - - Outpus: - - vx_new, vy_new, vxe_new, vye_new: transformed velocity and velocity error - """ - + from astropy.modeling.models.polynomial2D. + + The transformation is in general position dependent, so the velocity at a + star's own position is transformed using the local derivative of the + polynomial there -- hence the positions are needed as well as the + velocities. + + Parameters + ---------- + x0, y0 : array-like + Original positions, at which the transformation's local derivative + is evaluated. + x0e, y0e : array-like + Uncertainties on the original positions. + vx, vy : array-like + Original velocities. + vxe, vye : array-like + Uncertainties on the original velocities. + transform : transforms.Transform2D + The transformation to apply. + + Returns + ------- + vx_new, vy_new : numpy.ndarray + Transformed velocities. + vxe_new, vye_new : numpy.ndarray + Transformed velocity uncertainties. + """ + # Read transformation: Extract X, Y coefficients from transform if transform.__class__.__name__ == 'four_paramNW': Xcoeff = transform.px @@ -3134,8 +4674,8 @@ def velocity_transform_from_object(x0, y0, x0e, y0e, vx, vy, vxe, vye, transform order = transform.order else: txt = 'Transform not yet supported by velocity_transform_from_object' - raise StandardError(txt) - + raise Exception(txt) + # How the transformation is applied depends on the type of transform. # This can be determined by the length of Xcoeff, Ycoeff N = order - 1 @@ -3196,7 +4736,7 @@ def velocity_transform_from_object(x0, y0, x0e, y0e, vx, vy, vxe, vye, transform for i in range(1, N+1): for j in range(1, N+2-i): sub = 2*N + 2 + j + (2*N+2-i) * (i-1)/2. - temp3 += i * Xcoeff[int(sub)] * (x0**(i-1)) * (y0**j) + temp3 += i * Xcoeff[int(sub)] * (x0**(i-1)) * (y0**j) for j in range(1, N+2): temp4 += j * Xcoeff[N+1+j] * (y0**(j-1)) @@ -3207,7 +4747,6 @@ def velocity_transform_from_object(x0, y0, x0e, y0e, vx, vy, vxe, vye, transform vxe_new = np.sqrt((temp1*x0e)**2 + (temp2*y0e)**2 + (temp3*vxe)**2 + (temp4*vye)**2) - vye_new = 0 temp1 = 0 temp2 = 0 @@ -3240,7 +4779,7 @@ def velocity_transform_from_object(x0, y0, x0e, y0e, vx, vy, vxe, vye, transform for i in range(1, N+1): for j in range(1, N+2-i): sub = 2*N + 2 + j + (2*N+2-i) * (i-1)/2. - temp3 += i * Ycoeff[int(sub)] * (x0**(i-1)) * (y0**j) + temp3 += i * Ycoeff[int(sub)] * (x0**(i-1)) * (y0**j) for j in range(1, N+2): temp4 += j * Ycoeff[N+1+j] * (y0**(j-1)) @@ -3254,344 +4793,206 @@ def velocity_transform_from_object(x0, y0, x0e, y0e, vx, vy, vxe, vye, transform return vx_new, vy_new, vxe_new, vye_new -def transform_pos_from_file(Xcoeff, Ycoeff, order, x_orig, y_orig): +def check_trans_input(list_of_starlists, trans_input, mag_trans): """ - Given the read-in coefficients from transform_from_file, apply the - transformation to the observed positions. This is generalized to - work with any order polynomial transform. + Validate initial transformations supplied by the caller. - WARNING: THIS CODE WILL NOT WORK FOR LEGENDRE POLYNOMIAL - TRANSFORMS + Checks that there is one transformation per starlist and, when magnitudes + are being transformed, that each one carries a ``mag_offset``. A + transformation missing that attribute has it set to 0.0 rather than + raising, since a purely positional transformation is a reasonable thing + to pass in. - Parameters: + Parameters ---------- - Xcoeff: Array - Array with the coefficients of the X pos transformation + list_of_starlists : list of StarList + The starlists the transformations correspond to. + trans_input : list of transforms.Transform2D or None + One initial transformation per starlist, in the same order. Entries + may be None. Nothing is checked when this is None. + mag_trans : bool + Whether magnitudes are being transformed too, which is what makes + ``mag_offset`` required. - Ycoeff: Array - Array with the coefficients of the Y pos transformation - - order: int - Order of transformation - - x_orig: array - Array with the original X positions - - y_orig: array - Array with the original Y positions + Returns + ------- + None + ``trans_input`` may be modified in place, gaining ``mag_offset`` + attributes where they were missing. - Output: + Raises ------ - x_new: array - Transformed X positions - - y_new: array - Transformed Y positions - + AssertionError + If ``trans_input`` is not the same length as ``list_of_starlists``. """ - idx = 0 # coeff index - x_new = 0.0 - y_new = 0.0 - for i in range(order+1): - for j in range(i+1): - x_new += Xcoeff[idx] * x_orig**(i-j) * y_orig**j - y_new += Ycoeff[idx] * x_orig**(i-j) * y_orig**j + # Check trans_input + # If we are transforming magnitudes and their are input transformations, + # then they need to have a mag_offset on them. + if trans_input is not None: + assert len(trans_input) == len(list_of_starlists), f'trans_input (len={len(trans_input)}) must have the same length as list_of_starlists (len={len(list_of_starlists)})!' - idx += 1 + if mag_trans: + for ii, trans in enumerate(trans_input): + if (trans is not None) and (not hasattr(trans, 'mag_offset')): + print('Missing trans.mag_offset on trans_input[{0:d}], setting to 0.'.format(ii)) + trans.mag_offset = 0.0 - return x_new, y_new + return -def transform_poserr_from_file(Xcoeff, Ycoeff, order, xe_orig, ye_orig, x_orig, y_orig): +def trans_initial_guess( + ref_list, + star_list, + trans_args, + mode='miracle', + indices=None, + order=1, + briteN=None, + n_req_match=3, + polygon_reflist=None, + polygon_starlist=None, + buffer=0, + motion_models=None, + fixed_params_dict=None, + ignore_contains='star', + mag_trans=True, + verbose=True +): """ - Given the read-in coefficients from transform_from_file, apply the - transformation to the observed position errors. This is generalized to - work with any order transform. - - WARNING: THIS CODE WILL NOT WORK FOR LEGENDRE POLYNOMIAL - TRANSFORMS - - Parameters: - ---------- - Xcoeff: Array - Array with the coefficients of the X pos transformation - - Ycoeff: Array - Array with the coefficients of the Y pos transformation - - order: int - Order of transformation - - xe_orig: array - Array with the original X position errs - - ye_orig: array - Array with the original Y position errs - - x_orig: array - Array with the original X positions - - y_orig: array - Array with the original Y positions - - Output: - ------ - xe_new: array - Transformed X position errs - - ye_new: array - Transformed Y position errs - """ - idx = 0 # coeff index - xe_new_tmp1 = 0.0 - ye_new_tmp1 = 0.0 - xe_new_tmp2 = 0.0 - ye_new_tmp2 = 0.0 - - # First loop: dx'/dx - for i in range(order+1): - for j in range(i+1): - xe_new_tmp1 += Xcoeff[idx] * (i - j) * x_orig**(i-j-1) * y_orig**j - ye_new_tmp1 += Ycoeff[idx] * (i - j) * x_orig**(i-j-1) * y_orig**j - - idx += 1 - - # Second loop: dy'/dy - idx = 0 # coeff index - for i in range(order+1): - for j in range(i+1): - xe_new_tmp2 += Xcoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) - ye_new_tmp2 += Ycoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) - - idx += 1 - # Take square root for xe/ye_new - xe_new = np.sqrt((xe_new_tmp1 * xe_orig)**2 + (xe_new_tmp2 * ye_orig)**2) - ye_new = np.sqrt((ye_new_tmp1 * ye_orig)**2 + (ye_new_tmp2 * ye_orig)**2) - - return xe_new, ye_new - -def transform_vel_from_file(Xcoeff, Ycoeff, order, vx_orig, vy_orig, x_orig, y_orig): - """ - Given the read-in coefficients from transform_from_file, apply the - transformation to the observed proper motions. This is generalized to - work with any order transform. - - WARNING: THIS CODE WILL NOT WORK FOR LEGENDRE POLYNOMIAL - TRANSFORMS - - Parameters: - ---------- - Xcoeff: Array - Array with the coefficients of the X pos transformation - - Ycoeff: Array - Array with the coefficients of the Y pos transformation - - order: int - Order of transformation - - vx_orig: array - Array with the original X proper motions - - vy_orig: array - Array with the original Y proper motions - - x_orig: array - Array with the original X positions + Take two starlists and perform an initial matching and transformation. - y_orig: array - Array with the original Y positions + This is the bootstrap step: before any transformation is known, the two + lists have to be matched by something other than position agreement. + Which "something" is chosen by ``mode``. - Output: - ------ - vx_new: array - Transformed X proper motions - - vy_new: array - Transformed Y proper motions - """ - idx = 0 # coeff index - vx_new = 0.0 - vy_new = 0.0 - # First loop: dx'/dx - for i in range(order+1): - for j in range(i+1): - vx_new += Xcoeff[idx] * (i - j) * x_orig**(i-j-1) * y_orig**j * vx_orig - vy_new += Ycoeff[idx] * (i - j) * x_orig**(i-j-1) * y_orig**j * vx_orig - - idx += 1 - # Second loop: dy'/dy - idx = 0 # coeff index - for i in range(order+1): - for j in range(i+1): - vx_new += Xcoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) * vy_orig - vy_new += Ycoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) * vy_orig - - idx += 1 - - return vx_new, vy_new - -def transform_velerr_from_file(Xcoeff, Ycoeff, order, vxe_orig, vye_orig, vx_orig, - vy_orig, xe_orig, ye_orig, x_orig, y_orig): - """ - Given the read-in coefficients from transform_from_file, apply the - transformation to the observed proper motion errors. This is generalized to - work with any order transform. - - WARNING: THIS CODE WILL NOT WORK FOR LEGENDRE POLYNOMIAL - TRANSFORMS - - Parameters: + Parameters ---------- - Xcoeff: Array - Array with the coefficients of the X pos transformation - - Ycoeff: Array - Array with the coefficients of the Y pos transformation - - order: int - Order of transformation - - vxe_orig: array - Array with the original X proper motion errs - - vye_orig: array - Array with the original Y proper motion errs - - vx_orig: array - Array with the original X proper motions - - vy_orig: array - Array with the original Y proper motions - - xe_orig: array - Array with the original X position errs - - ye_orig: array - Array with the original Y position errs - - x_orig: array - Array with the original X positions + ref_list : StarList + The reference stars, in the reference coordinate system. Needs 'x', + 'y' and 'm' (or 'x0'/'y0'/'m0'), and 'name' for mode='name'. + star_list : StarList + The starlist to be brought onto the reference frame. + trans_args : dict + Extra keywords for the transformation class, e.g. {'order': 1}. + Only 'order' is consulted here, and only as a fallback for ``order``. + mode : str, optional + How to make the initial match: + + - 'miracle' (default) -- blind triangle matching on the brightest + ``briteN`` stars. Needs no names, but needs enough overlap. + - 'name' -- match stars that share a name. Cheap and exact when the + names are stable identifiers across lists; see ``ignore_contains``. + - 'indices' -- the caller already knows the correspondence and + passes it in via ``indices``. + + indices : tuple of array, optional + ``(idx_ref, idx_star)``, the already-known correspondence. Required + for mode='indices' and ignored otherwise, by default None. + order : int, optional + Polynomial order of the initial transformation, by default 1. + briteN : int, optional + mode='miracle' only. How many of the brightest stars to attempt the + triangle match on. By default min(50, len(star_list)). + n_req_match : int, optional + Minimum number of matched stars required before a transformation is + derived. Below this the function raises rather than fitting to noise, + by default 3. + polygon_reflist : shapely.Polygon, optional + mode='miracle' only. Restricts the reference stars considered to + those inside this footprint, by default None. + polygon_starlist : shapely.Polygon, optional + mode='miracle' only. Restricts the starlist stars considered to those + inside this footprint. Combined with ``polygon_reflist`` this limits + the blind match to the region the two lists actually share, which is + what makes it tractable on a mosaic, by default None. + buffer : float, optional + Slack, in reference coordinate units, allowed when testing the + polygon footprints, by default 0. + motion_models : list of MotionModel classes or strings, optional + mode='miracle' only. Used to propagate the reference stars to the + starlist's epoch before matching, by default None. + fixed_params_dict : dict, optional + Motion-model parameters held fixed during that propagation, by + default None. + ignore_contains : str or None, optional + mode='name' only. Names containing this substring are left out of the + name match, because auto-detected sources are conventionally labelled + star_1, star_2, ... per list -- those indices are per-list detection + numbers, not stable identities. Pass None to match on every name. + '' is rejected rather than treated as "off", since every name + contains the empty string. By default 'star'. + mag_trans : bool, optional + Whether to also solve for a magnitude zeropoint offset, by default + True. + verbose : bool or int, optional + Print diagnostics about the match, by default True. - y_orig: array - Array with the original Y positions + Returns + ------- + transforms.PolyTransform + The initial transformation taking ``star_list`` onto ``ref_list``. - Output: + Raises ------ - vxe_new: array - Transformed X proper motion errs - - vye_new: array - Transformed Y proper motion errs - """ - idx = 0 - vxe_new_tmp1 = 0.0 - vye_new_tmp1 = 0.0 - vxe_new_tmp2 = 0.0 - vye_new_tmp2 = 0.0 - vxe_new_tmp3 = 0.0 - vye_new_tmp3 = 0.0 - vxe_new_tmp4 = 0.0 - vye_new_tmp4 = 0.0 - - - # First loop: dvx' / dx - for i in range(order+1): - for j in range(i+1): - vxe_new_tmp1 += Xcoeff[idx] * (i-j) * (i-j-1) * x_orig**(i-j-2) * y_orig**j * vx_orig - vxe_new_tmp1 += Xcoeff[idx] * (j) * (i-j) * x_orig**(i-j-1) * y_orig**(j-1) * vy_orig - vye_new_tmp1 += Ycoeff[idx] * (i-j) * (i-j-1) * x_orig**(i-j-2) * y_orig**j * vx_orig - vye_new_tmp1 += Ycoeff[idx] * (j) * (i-j) * x_orig**(i-j-1) * y_orig**(j-1) * vy_orig - - idx += 1 - - # Second loop: dvx' / dy - idx = 0 - for i in range(order+1): - for j in range(i+1): - vxe_new_tmp2 += Xcoeff[idx] * (i-j) * (j) * x_orig**(i-j-1) * y_orig**(j-1) * vx_orig - vxe_new_tmp2 += Xcoeff[idx] * (j) * (j-1) * x_orig**(i-j-1) * y_orig**(j-2) * vy_orig - vye_new_tmp2 += Ycoeff[idx] * (i-j) * (j) * x_orig**(i-j-1) * y_orig**(j-1) * vx_orig - vye_new_tmp2 += Ycoeff[idx] * (j) * (j-1) * x_orig**(i-j-1) * y_orig**(j-2) * vy_orig - - idx += 1 - - # Third loop: dvx' / dvx - idx = 0 - for i in range(order+1): - for j in range(i+1): - vxe_new_tmp3 += Xcoeff[idx] * (i-j) * x_orig**(i-j-1) * y_orig**j - vye_new_tmp3 += Ycoeff[idx] * (i-j) * x_orig**(i-j-1) * y_orig**j - - idx += 1 - - # Fourth loop: dvx' / dvy - idx = 0 - for i in range(order+1): - for j in range(i+1): - vxe_new_tmp4 += Xcoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) - vye_new_tmp4 += Ycoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) - - idx += 1 - - vxe_new = np.sqrt((vxe_new_tmp1 * xe_orig)**2 + (vxe_new_tmp2 * ye_orig)**2 + \ - (vxe_new_tmp3 * vxe_orig)**2 + (vxe_new_tmp4 * vye_orig)**2) - vye_new = np.sqrt((vye_new_tmp1 * xe_orig)**2 + (vye_new_tmp2 * ye_orig)**2 + \ - (vye_new_tmp3 * vxe_orig)**2 + (vye_new_tmp4 * vye_orig)**2) - - return vxe_new, vye_new - - -def check_iter_tolerances(iters, dr_tol, dm_tol, outlier_tol): - # iteration tolerances must match the number of iterations requested. - assert iters == len(dr_tol) - assert iters == len(dm_tol) - assert iters == len(outlier_tol) - - return - -def check_trans_input(list_of_starlists, trans_input, mag_trans): - # Check trans_input - # If we are transforming magnitudes and their are input transformations, - # then they need to have a mag_offset on them. - if trans_input != None: - assert len(trans_input) == len(list_of_starlists) - - if mag_trans: - for ii in range(len(trans_input)): - if trans_input[ii] != None: - try: - trans_input[ii].mag_offset - except NameError: - print('Missing trans.mag_offset on trans_input[{0:d}].'.format(ii)) - print('Setting mag_offset = 0 and dm_tol[0] = 100 and hoping for the best!!') - trans_input[ii].mag_offset = 0.0 - - return - -def trans_initial_guess(ref_list, star_list, trans_args, mode='miracle', - ignore_contains='star', verbose=True, n_req_match=3, - mag_trans=True, order=1): - """ - Take two starlists and perform an initial matching and transformation. - - This function will grow with time to handle difference types of initial - guess transformations (triangle matching, match by name, etc.). For now it - is just blind triangle matching on the brightest 50 stars. + AssertionError + If fewer than ``n_req_match`` stars could be matched. A diagnostic + scatter plot of the two lists is drawn first, on a best-effort basis, + to show why. + ValueError + If ``mode`` is not one of the values above, if ``ignore_contains`` + is '', or if mode='miracle' and the starlist has neither a 't' + column nor a 'list_time' meta key. """ warnings.filterwarnings('ignore', category=AstropyUserWarning) - + if motion_models is None: + motion_models = [] + + # Only the 'miracle' branch computes these; the failure diagnostic below + # falls back to the reference list's own columns when they are unset. + xref = yref = None + + # Match by name if mode == 'name': - # First trim the two lists down to only those that don't contain - # the "ignore_contains" string. - idx_r = np.flatnonzero(np.char.find(ref_list['name'], ignore_contains) == -1) - idx_s = np.flatnonzero(np.char.find(star_list['name'], ignore_contains) == -1) + # Trim both lists to names that don't contain the "ignore_contains" + # string. The point is to skip auto-detected labels (star_1, star_2, + # ... are per-epoch detection indices, so the same label means + # different objects in different lists) and match only on genuinely + # named sources. ignore_contains=None skips the trim entirely, for + # catalogs whose names really are stable across epochs. + # + # Note '' is not a way to disable it: every name contains the empty + # string, so np.char.find returns 0 everywhere and the trim would + # discard everything. None is the off switch. + if ignore_contains is None: + idx_r = np.arange(len(ref_list)) + idx_s = np.arange(len(star_list)) + else: + if ignore_contains == '': + raise ValueError( + "trans_initial_guess: ignore_contains='' would discard every star " + '(every name contains the empty string). Pass None to match on all names.' + ) + idx_r = np.flatnonzero(np.char.find(ref_list['name'].astype(str), ignore_contains) == -1) + idx_s = np.flatnonzero(np.char.find(star_list['name'].astype(str), ignore_contains) == -1) + + # Do not drop stars silently: this filter is the reason a name + # match "mysteriously" finds nothing when sources are called + # star_0, star_1, ... + n_cut_s = len(star_list) - len(idx_s) + n_cut_r = len(ref_list) - len(idx_r) + if n_cut_s or n_cut_r: + msg = (f'trans_initial_guess: ignore_contains={ignore_contains!r} excluded ' + f'{n_cut_s} of {len(star_list)} star_list and ' + f'{n_cut_r} of {len(ref_list)} ref_list names from the name match.') + if len(idx_s) == 0 or len(idx_r) == 0: + msg += ' Nothing is left to match on -- pass ignore_contains=None if these names are stable across epochs.' + warnings.warn(msg, stacklevel=2) # Match the star names name_matches, ndx_r, ndx_s = np.intersect1d(ref_list['name'][idx_r], star_list['name'][idx_s], assume_unique=True, return_indices=True) - + x1m = star_list['x'][idx_s][ndx_s] y1m = star_list['y'][idx_s][ndx_s] m1m = star_list['m'][idx_s][ndx_s] @@ -3600,31 +5001,93 @@ def trans_initial_guess(ref_list, star_list, trans_args, mode='miracle', m2m = ref_list['m'][idx_r][ndx_r] N = len(x1m) - else: - # Default is miracle match. - briteN = min(50, len(star_list)) + # Default is miracle match. + elif mode == 'miracle': + if briteN is None: + briteN = min(50, len(star_list)) + else: + assert (type(briteN) == int) and (briteN > 0), f'briteN must be a positive integer, but got {briteN}.' # If there are velocities in the reference list, use them. # We assume velocities are in the same units as the positions. - xref, yref = get_pos_at_time(star_list['t'][0], ref_list) + if 't' in ref_list.colnames: + epoch = star_list['t'][0] + elif 'list_time' in star_list.meta: + epoch = star_list.meta['list_time'] + else: + raise ValueError('star_list must have either a "t" column or a "list_time" meta key to use miracle matching.') + + xref, yref = infer_positions(epoch, ref_list, motion_models, fixed_params_dict=fixed_params_dict) if 'm' in ref_list.colnames: mref = ref_list['m'] else: mref = ref_list['m0'] - - N, x1m, y1m, m1m, x2m, y2m, m2m = match.miracle_match_briteN(star_list['x'], - star_list['y'], - star_list['m'], - xref, - yref, - mref, - briteN) - - err_msg = 'Failed to find more than '+str(n_req_match) - err_msg += ' (only ' + str(len(x1m)) + ') matches, giving up.' - assert len(x1m) >= n_req_match, err_msg + + N, x1m, y1m, m1m, x2m, y2m, m2m = match.miracle_match_briteN( + star_list['x'], + star_list['y'], + star_list['m'], + xref, + yref, + mref, + briteN, + polygon_reflist, + polygon_starlist, + buffer=buffer + ) + elif mode == 'indices': + idx_r, idx_s = indices + x1m = star_list['x'][idx_s] + y1m = star_list['y'][idx_s] + m1m = star_list['m'][idx_s] + x2m = ref_list['x'][idx_r] + y2m = ref_list['y'][idx_r] + m2m = ref_list['m'][idx_r] + N = len(indices) + + else: + raise ValueError(f'flystar.align.trans_initial_guess: Unknown mode: {mode}. Must be one of ["name", "miracle"].') + + if len(x1m) < n_req_match: + # Diagnostic plot, best-effort only. xref/yref are computed by the + # 'miracle' branch alone, so for mode='name'/'indices' fall back to the + # reference list's own columns. Guarded because this block exists to + # explain a failure -- it must never replace the AssertionError below + # with an error of its own (it used to raise UnboundLocalError on + # xref for every non-miracle mode, hiding the real message). + try: + if xref is None: + xref = ref_list['x'] if 'x' in ref_list.colnames else ref_list['x0'] + yref = ref_list['y'] if 'y' in ref_list.colnames else ref_list['y0'] + fig, ax = plt.subplots() + ax.scatter(star_list['x'], star_list['y'], s=1, label='star_list') + ax.scatter(xref, yref, s=1, label='ref_list') + ax.legend() + ax.set_aspect('equal') + plt.show() + except Exception as err: + warnings.warn(f'trans_initial_guess: could not draw the diagnostic plot ({err}).', + stacklevel=2) + # Only blame ignore_contains when it actually removed something -- + # otherwise the note points at a cause that isn't one, which is the + # same wrong-turn this message exists to prevent. + hint = '' + if mode == 'name': + n_cut = len(star_list) - len(idx_s) + if n_cut: + hint = (f" Note that ignore_contains={ignore_contains!r} excluded {n_cut} of " + f'{len(star_list)} star_list names from the match; pass ' + 'ignore_contains=None if those names are stable across epochs.') + else: + hint = (' ignore_contains excluded nothing, so the lists simply have too few ' + 'names in common; init_guess_mode=\'miracle\' does not need names.') + raise AssertionError( + f'Failed to find more than {n_req_match} (only {len(x1m)}) matches, giving up. ' + f'mode={mode!r}.' + hint + ) + if verbose > 1: - print('initial_guess: {0:d} stars matched between starlist and reference list'.format(N)) + print('Initial_guess: {0:d} stars matched between starlist and reference list'.format(N)) # Calculate position transformation based on matches if ('order' in trans_args) and (trans_args['order'] == 0): @@ -3641,45 +5104,119 @@ def trans_initial_guess(ref_list, star_list, trans_args, mode='miracle', trans.mag_offset = np.mean(m2m - m1m) else: trans.mag_offset = 0 - + if verbose > 1: - print('init guess: ', trans.px.parameters, trans.py.parameters) + print('Initial guess:') + print(f'{trans.px.parameters=}') + print(f'{trans.py.parameters=}') + print(f'{trans.mag_offset=}') warnings.filterwarnings('default', category=AstropyUserWarning) - + return trans -def update_old_and_new_names(ref_table, list_index, idx_ref_new): - # Make new ref_list names for the new stars. - new_names = [] - new_name_len_max = 0 +def names_in_list(ref_table, star_lists, list_index=None): + """ + Recover the per-list star names that 'idx_in_list' indexes into. + + The reference table stores each star's identity in each starlist as a row + index rather than a copy of the name (see setup_ref_table_from_starlist), + so recovering the name needs the starlists that were aligned. + + Parameters + ---------- + ref_table : StarTable + A reference table carrying an 'idx_in_list' column. + star_lists : list of StarList + The starlists that were passed to the aligner, in the same order. + list_index : int, optional + Return names for this starlist only. By default None, which returns + the full (N_stars, N_lists) array. + + Returns + ------- + numpy.ndarray of str + Names, with '' wherever the star was not detected in that list. + """ + idx = np.asarray(ref_table['idx_in_list']) + + def _one(jj): + col = idx[:, jj] + names = np.asarray(star_lists[jj]['name']) + out = np.full(len(col), '', dtype=names.dtype) + found = col >= 0 + out[found] = names[col[found].astype(np.intp)] + return out + + if list_index is not None: + return _one(list_index) + + return np.column_stack([_one(jj) for jj in range(idx.shape[1])]) + + +def update_old_and_new_names(ref_table, star_list, list_index, idx_ref_new): + """ + Name the stars that a starlist has just added to the reference table. - for ss in idx_ref_new: - new_name = '{0:3d}_{1:s}'.format(list_index, ref_table['name_in_list'][ss, list_index]) - new_names.append(new_name) - new_name_len_max = max(new_name_len_max, len(new_name)) + New stars are named ``"_"``, so a + star's name records which list first contributed it. The per-list + identity is stored in the table as an index rather than a copy of the + name (see :func:`names_in_list`), so the names are read out of + ``star_list`` here. + + The 'name' column's dtype is widened when, and only when, the incoming + names are longer than it can hold, so that nothing is silently truncated. + + Parameters + ---------- + ref_table : StarTable + The reference table being grown. Read-only here; the caller assigns + the returned array back onto it. + star_list : StarList + The starlist the new stars came from, supplying their names. + list_index : int + Index of that starlist, used as the name prefix. + idx_ref_new : array of int + Rows of ``ref_table`` holding the newly added stars. + + Returns + ------- + numpy.ndarray of str + The full 'name' column, with the new rows filled in -- widened to a + larger unicode dtype if the new names required it. + """ + # Make new ref_list names for the new stars. Their per-list identity is + # stored as an index into star_list, so read the names from there. + idx_lis_new = np.asarray(ref_table['idx_in_list'][idx_ref_new, list_index], + dtype=np.intp) + src_names = np.asarray(star_list['name']) + + new_names = [f"{list_index:3d}_{name}" for name in src_names[idx_lis_new]] + new_name_len_max = np.max([len(new_name) for new_name in new_names]) old_names = ref_table['name'] - old_name_len = [len(old_name) for old_name in old_names] - old_name_len_max = np.max(old_name_len) + # old_names is a fixed-width numpy unicode array, so its dtype already + # encodes the longest string it can hold without truncation -- no need to + # loop over every element (up to millions of rows) to find the max length. + old_name_len_max = old_names.dtype.itemsize // np.dtype('U1').itemsize if new_name_len_max > old_name_len_max: all_names = old_names.astype('U{0:d}'.format(new_name_len_max)) else: all_names = old_names - + all_names[idx_ref_new] = new_names - + return all_names def copy_and_rename_for_ref(star_list): """ Make a deep copy of the starlist and rename the columns to include - "0". This only applies to x, y, m and xe, ye, me (if they exist) + "0". This only applies to x, y, m and xe, ye, me (if they exist) columns. - Input + Parameters ---------- star_list : StarList The starlist to copy. @@ -3689,81 +5226,176 @@ def copy_and_rename_for_ref(star_list): if 'xe' in star_list.colnames: old_cols += ['xe'] - new_cols += ['x0e'] + new_cols += ['x0_err'] if 'ye' in star_list.colnames: old_cols += ['ye'] - new_cols += ['y0e'] + new_cols += ['y0_err'] if 'me' in star_list.colnames: old_cols += ['me'] - new_cols += ['m0e'] + new_cols += ['m0_err'] if 'w' in star_list.colnames: old_cols += ['w'] new_cols += ['w'] - - ref_list = copy.deepcopy(star_list) + + ref_list = StarList(star_list, copy=True) for ii in range(len(old_cols)): ref_list.rename_column(old_cols[ii], new_cols[ii]) return ref_list -def outlier_rejection_indices(star_list, ref_list, outlier_tol, verbose=True): +def check_transform_finite(trans, n_stars, context): + """ + Raise if a freshly derived transformation contains non-finite parameters. + + derive_transform / find_transform run a least-squares fit that returns NaN + coefficients instead of raising when it is underdetermined -- most often + because it was handed fewer stars than the transformation has free + parameters, or none at all. The NaNs then propagate into transformed + positions and only announce themselves much later, and far from the cause, + as 'x1 does not contain any finite values!' out of match.match. Check here + so the error names the fit that actually failed. + + Parameters + ---------- + trans : Transform2D + The transformation just derived. + n_stars : int + Number of stars the fit was given, for the error message. + context : str + Where this fit came from, for the error message. + + Raises + ------ + ValueError + If any x or y parameter of the transformation is not finite. + """ + bad_x = not np.isfinite(np.asarray(trans.px.parameters)).all() + bad_y = not np.isfinite(np.asarray(trans.py.parameters)).all() + + if not (bad_x or bad_y): + return + + raise ValueError( + f'{context}: the derived transformation has non-finite parameters ' + f'(px={np.asarray(trans.px.parameters)}, ' + f'py={np.asarray(trans.py.parameters)}) after being fit to ' + f'{n_stars} star(s). The fit was underdetermined -- with too few ' + f'matched stars for the transformation order, the least-squares ' + f'solve returns NaN rather than raising. Loosen dr_tol / dm_tol, ' + f'widen mag_lim, or lower the transformation order.' + ) + + +def min_stars_for_transform(trans_args): + """ + The minimum number of matched stars needed to constrain a transformation. + + A 2D polynomial (or Legendre) transformation of a given order has + (order+1)(order+2)/2 free coefficients per axis -- 3 for order 1, 6 for + order 2 -- and order 0 is the special case of a pure shift, 1 coefficient + per axis. Handed fewer stars than that, derive_transform runs a degenerate + least-squares fit and returns NaN coefficients without raising. + + Parameters + ---------- + trans_args : dict + The keyword arguments passed to trans_class.derive_transform for this + iteration. Only 'order' is consulted; if it is absent we assume the + linear case, the smallest order with more than one free parameter. + + Returns + ------- + n_req : int + The minimum usable number of stars. + """ + order = trans_args.get('order', 1) if trans_args is not None else 1 + + if order == 0: + return 1 + + return (order + 1) * (order + 2) // 2 + + +def outlier_rejection_indices(star_list, ref_list, outlier_tol, motion_models, fixed_params_dict=None, verbose=True): """ Determine the outliers based on the residual positions between two different - starlists and some threshold (in sigma). Return the indices of the stars - to keep (that shouldn't be rejected as outliers). + starlists and some threshold (in sigma). Return the indices of the stars + to keep (that shouldn't be rejected as outliers). Note that we assume that the star_list and ref_list are already transformed and - matched. + matched. Parameters ---------- star_list : StarList starlist with 'x', 'y' - ref_list : StarList starlist with 'x0', 'y0' - outlier_tol : float - Number of sigma inside which we keep stars and outside of which we - reject stars as outliers. - - Optional Parameters - -------------------- - verbose : boolean + Number of sigma inside which we keep stars and outside of which we + reject stars as outliers. + motion_models : list of motion_model objects + The motion models to use in the star_list + fixed_params_dict : dict or None, optional + Dictionary of fixed parameters for motion models, by default None + verbose : bool, optional + If True, print information about the outlier rejection process, by default True Returns - ---------- - keepers : nd.array - The indicies of the stars to keep. + ------- + keepers : bool array + The boolean array of the stars to keep. """ # Optionally propogate the reference positions forward in time. - xref, yref = get_pos_in_time(star_list['t'][0], ref_list) - + xref, yref = infer_positions(star_list['t'][0], ref_list, motion_models, fixed_params_dict=fixed_params_dict) + # Residuals x_resid_on_old_trans = star_list['x'] - xref y_resid_on_old_trans = star_list['y'] - yref resid_on_old_trans = np.hypot(x_resid_on_old_trans, y_resid_on_old_trans) - threshold = outlier_tol * resid_on_old_trans.std() - keepers = np.where(resid_on_old_trans < threshold)[0] + # Centre the threshold on the median residual, as + # MosaicSelfRef.outlier_rejection_indices does. Without the median term a + # tight cluster of residuals around a nonzero offset is rejected wholesale, + # and the '<' rejects everything when the std is 0 (all residuals equal). + threshold = np.median(resid_on_old_trans) + (outlier_tol * resid_on_old_trans.std()) + keepers = resid_on_old_trans <= threshold if verbose > 0: msg = ' Outlier Rejection: Keeping {0:d} of {1:d}' - print(msg.format(len(keepers), len(resid_on_old_trans))) - + print(msg.format(sum(keepers), len(resid_on_old_trans))) + return keepers def setup_trans_info(trans_input, trans_args, N_lists, iters): - """ Setup transformation info into a usable format. + """ + Setup transformation info into a usable format. + Parameters + ---------- trans_input : list or None - trans_args : dict or None + One initial transformation per starlist, or None to start each + starlist with no transformation (it is then derived from an initial + guess on the first iteration). + trans_args : dict or list of dict + Extra keywords for the transformation class. A single dict is + replicated for every iteration; a list must have one entry per + iteration. N_lists : int + Number of starlists being aligned. iters : int + Number of matching/transformation iterations. + + Returns + ------- + trans_list : list + One entry per starlist, holding the initial transformation or None. + trans_args : list of dict + One dict per iteration. """ trans_list = [None for ii in range(N_lists)] - if trans_input != None: + if trans_input is not None: trans_list = [trans_input[ii] for ii in range(N_lists)] # Keep a list of trans_args, one for each starlist. If only @@ -3771,21 +5403,29 @@ def setup_trans_info(trans_input, trans_args, N_lists, iters): if type(trans_args) == dict: tmp = trans_args trans_args = [tmp for ii in range(iters)] - + return trans_list, trans_args def apply_mag_lim(star_list, mag_lim): - """ Apply a magnitude limit to the list. If no magnitude limit is - specified, then return a copy of the list. This works on a + """ Apply a magnitude limit to the list. If no magnitude limit is + specified, then return a copy of the list. This works on a reference list (with 'm0') or a star_list ('m') with 'm0' taking priority. - mag_lim : 2 element array - Contains the minimum and maximum magnitude cut to apply. If none, - no magnitude cut is applied. + Parameters + ---------- + star_list : StarList + The list to cut. Its 'm0' column is used if present, otherwise 'm'. + mag_lim : 2 element array or None + The minimum and maximum magnitude to keep. None applies no cut. + Returns + ------- + StarList + A copy of the input holding only the stars inside the limits (a + plain copy when ``mag_lim`` is None). """ - star_list_T = copy.deepcopy(star_list) + star_list_T = StarList(star_list, copy=True) if (mag_lim is not None): # Support 'm0' (primary) or 'm' column name. @@ -3795,7 +5435,7 @@ def apply_mag_lim(star_list, mag_lim): mcol = 'm' conditions = {} - + cond_key = '{0:s}_min'.format(mcol) conditions[cond_key] = mag_lim[0] @@ -3807,13 +5447,41 @@ def apply_mag_lim(star_list, mag_lim): return star_list_T def get_weighting_scheme(weights, ref_list, star_list): + """ + Build per-star fit weights from a weighting-scheme name. + + The free-function equivalent of + :meth:`MosaicSelfRef.get_weights_for_lists`, for callers that are not + working through a mosaic object. + + Parameters + ---------- + weights : str or None + Which uncertainties to use and how. One of 'both,var', 'both,std', + 'ref,var', 'ref,std', 'list,var', 'list,std' -- the first part + selecting the reference list, the starlist, or both, and the second + selecting inverse variance or inverse standard deviation. None means + an unweighted fit. + ref_list : StarList + Reference stars, matched row-for-row with ``star_list``. Uses its + 'xe'/'ye' columns if they exist. + star_list : StarList + Starlist stars, matched row-for-row with ``ref_list``. Uses its + 'xe'/'ye' columns if they exist. + + Returns + ------- + numpy.ndarray or None + One weight per matched star, or None when ``weights`` is None or + neither list carries uncertainties. + """ if 'xe' in ref_list.colnames: var_xref = ref_list['xe']**2 var_yref = ref_list['ye']**2 else: var_xref = 0.0 var_yref = 0.0 - + if 'xe' in star_list.colnames: var_xlis = star_list['xe']**2 var_ylis = star_list['ye']**2 @@ -3821,7 +5489,7 @@ def get_weighting_scheme(weights, ref_list, star_list): var_xlis = 0.0 var_ylis = 0.0 - if weights != None: + if weights is not None: if weights == 'both,var': weight = 1.0 / (var_xref + var_xlis + var_yref + var_ylis) if weights == 'both,std': @@ -3844,36 +5512,286 @@ def get_weighting_scheme(weights, ref_list, star_list): return weight -def get_pos_at_time(t, starlist, use_vel=True): + +def logger(logfile, message, verbose = 9): """ - Take a starlist, check to see if it has velocity columns. - If it does, then propogate the positions forward in time - to the desired epoch. If no velocities exist, then just - use ['x0', 'y0'] or ['x', 'y'] + Write a message to a log file, and to stdout when verbose enough. - Inputs + Parameters ---------- - t_array : float - The time to propogate to. Usually in decimal years; - but it should be in the same units - as the 't0' column in starlist. - """ - if use_vel and ('vx' in starlist.colnames) and ('vy' in starlist.colnames): - dt = t - starlist['t0'] - x = starlist['x0'] + (starlist['vx'] * dt) - y = starlist['y0'] + (starlist['vy'] * dt) - else: - if ('x0' in starlist.colnames) and ('y0' in starlist.colnames): - x = starlist['x0'] - y = starlist['y0'] - else: - x = starlist['x'] - y = starlist['y'] - - return (x, y) + logfile : file object + An open, writable file. A newline is appended to each message. + message : str + The line to log. + verbose : int, optional + The message is also printed to stdout when this is greater than 4. + By default 9, i.e. print. -def logger(logfile, message, verbose = 9): + Returns + ------- + None + """ if verbose > 4: print(message) logfile.write(message + '\n') return + + +def generic_match(sl1, sl2, init_mode='triangle', + model=transforms.PolyTransform, order_dr=(1, 1.0), + dr_final=1.0, + xy_match=(None, None, None, None, None, None, None, None), + m_match=(None, None, None, None), sigma_match=None, + n_bright=100, verbose=True, **kwargs): + """ + Finds the transformation between two starlists using the first one + as reference frame. Different matching methods can be used. If no + transformation is found, it returns an error message. + + + Parameters + ---------- + sl1 : StarList + starlist used for reference frame + sl2 : StarList + starlist transformed + init_mode : str, optional + Initial matching method. + If 'triangle', uses the blind triangle method. + If 'match_name', uses match by name + If 'load', uses the transformation from a loaded file, by default 'triangle'. + model : str, optional + Transformation model to be used with the 'triangle' initial mode, by default transforms.PolyTransform. + poly_order : int, optional + Order of the transformation model. Passed through ``**kwargs``. + order_dr : int, float [n, 2], optional + Combinations of polinomial order (first column) and search radius + (second column) to refine the transformation. Rows are executed in + orders, by default (1, 1.0). + dr_final : float, optional + Search radius used for the final matching, by default 1.0. + n_bright : int, optional + Number of bright stars used in the initial blind triangles matching, by default 100. + xy_match : array, optional + Area of the images to remove in the matching [reference catalog min x, + reference catalog max x, reference catalog min y, reference catalog max y, + transformed catalog min x, transformed catalog max x, + transformed catalog min y, transformed catalog max y]. Use None for values not used. + By default (None, None, None, None, None, None, None, None). + m_match : array, optional + Magnitude limits of matching stars used to find transformations + [reference catalog min mag, reference catalog max mag, transformed + catalog min mag, transformed catalog max mag]. Use None for values not + used, by default (None, None, None, None). + sigma_match : array, optional + Number of Deltap movement sigmas [0] used for sigma-cutting matched + stars for a number of times [1]. Use None for no sigma-cut. The last + polynomial order and search radius in 'order_dr' are used, by default None. + transf_file : str, optional + File name and path of the transformation file used with the 'load' + init_mode. Passed through ``**kwargs``. + verbose : bool, optional + Prints on screen information on the matching, by default True. + + Returns + ------- + transf : Transform2D + Transformation of the second starlist respect to the first + st : StarTable + Startable of the two matched catalogs + + """ + from flystar import starlists, startables + + # order_dr is documented as (n, 2): one (order, dr_tol) row per refinement + # loop. A single pair may be passed flat, e.g. (1, 1.0) -- as the default + # does -- so normalize to 2D. Without this, len(order_dr) is 2 for a flat + # pair and the refinement loop runs twice for one requested pass. + order_dr = np.atleast_2d(order_dr) + + # Check the input StarLists and transform them into astropy Tables + if not isinstance(sl1, starlists.StarList): + raise TypeError("The first catalog has to be a StarList") + if not isinstance(sl2, starlists.StarList): + raise TypeError("The second catalog has to be a StarList") + + # Find the initial transformation + if init_mode == 'triangle': # Blind triangles method + + # Prepare the reduced starlists for matching + sl1_cut = StarList(sl1, copy=True) + sl2_cut = StarList(sl2, copy=True) + sl1_cut.restrict_by_value(x_min=xy_match[0], x_max=xy_match[1], + y_min=xy_match[2], y_max=xy_match[3]) + sl2_cut.restrict_by_value(x_min=xy_match[4], x_max=xy_match[5], + y_min=xy_match[6], y_max=xy_match[7]) + sl1_cut.restrict_by_value(m_min=m_match[0], m_max=m_match[1]) + sl2_cut.restrict_by_value(m_min=m_match[2], m_max=m_match[3]) + + # Find the transformation + # TODO: test 'initial_align' with StarList input + transf = initial_align(sl1_cut, sl2_cut, briteN=n_bright, transformModel=model, + order=int(order_dr[0][0])) + + elif init_mode == 'match_name': # Name match + sl1_idx_init, sl2_idx_init, _ = starlists.restrict_by_name(sl1, sl2) + # derive_transform, not the constructor: model(...) is the old calling + # convention and PolyTransform.__init__ now takes (order, px, py) + # coefficients, so passing positions to it raises TypeError. + transf = model.derive_transform(sl2['x'][sl2_idx_init], sl2['y'][sl2_idx_init], + sl1['x'][sl1_idx_init], sl1['y'][sl1_idx_init], + int(order_dr[0][0]), + m=sl2['m'][sl2_idx_init], + mref=sl1['m'][sl1_idx_init]) + check_transform_finite( + transf, len(sl1_idx_init), + f'align.generic_match: match_name initial guess ' + f'(order={int(order_dr[0][0])})' + ) + + elif init_mode == 'load': # Load a transformation file + transf = transforms.Transform2D.from_file(kwargs['transf_file']) + + else: # None of the above + raise TypeError("Unrecognized initial matching method") + + # Restrict the matching catalogs + sl1_match = StarList(sl1, copy=True) + sl2_match = StarList(sl2, copy=True) + sl1_match.restrict_by_value(m_min=m_match[0], m_max=m_match[1]) + sl2_match.restrict_by_value(m_min=m_match[2], m_max=m_match[3]) + + # Refine the transformation + if sigma_match: + order_dr_len = len(order_dr) + + for i_loop in range(sigma_match[1]): + order_dr = np.vstack((np.array(order_dr), np.array(order_dr[-1]))) + + for i_loop in range(len(order_dr)): + + # Transform and match the catalog to the reference frame + sl2_idx, sl1_idx = transform_and_match(sl2_match, sl1_match, transf, + dr_tol=order_dr[i_loop][1], + verbose=verbose) + + # Transform the catalog to the reference frame + sl2_transf_match = transform_from_object(sl2_match, transf) + + # Sigma-rejection + if sigma_match and (i_loop >= order_dr_len): + resid = np.sqrt((sl1_match['x'][sl1_idx] - + sl2_transf_match['x'][sl2_idx])**2 + + (sl1_match['y'][sl1_idx] - + sl2_transf_match['y'][sl2_idx])**2) + sl1_idx = sl1_idx[resid <= (sigma_match[0] * np.std(resid))] + sl2_idx = sl2_idx[resid <= (sigma_match[0] * np.std(resid))] + + # Test section to observe the matching catalogs before refining the transformation + """ + from matplotlib import pyplot + + _, axarr = pyplot.subplots(nrows=1, ncols=1, figsize=(10,10)) + axarr.scatter(sl1_match['x'][sl1_idx], sl1_match['y'][sl1_idx]) + xlim = axarr.get_xlim() + ylim = axarr.get_ylim() + + _, axarr = pyplot.subplots(nrows=1, ncols=1, figsize=(10, 10)) + axarr.scatter(sl2_transf_match['x'][sl2_idx], sl2_transf_match['y'][sl2_idx]) + axarr.set_xlim(xlim) + axarr.set_ylim(ylim) + """ + + # Find a better transformation + transf, _ = find_transform( + sl2_match[sl2_idx], + sl2_transf_match[sl2_idx], + sl1_match[sl1_idx], transModel=model, + order=int(order_dr[i_loop][0]), verbose=verbose + ) + check_transform_finite( + transf, len(sl1_idx), + f'align.generic_match: refinement loop {i_loop} ' + f'(order={int(order_dr[i_loop][0])}, dr_tol={order_dr[i_loop][1]})' + ) + + # This section was used for testing transformations with normalized + # coordinates. Only several catalogs had reduced residuals when using + # high order polynomials (>3), some of them became unstable + """sl1_match_norm = sl1_match[sl1_idx] + sl2_match_norm = sl2_match[sl2_idx] + sl2_transf_match_norm = sl2_transf_match[sl2_idx] + mm = max(max(sl1_match_norm['x']), max(sl1_match_norm['y']), + max(sl2_transf_match_norm['x']), max(sl2_transf_match_norm['y'])) + sl1_match_norm['x'] = sl1_match_norm['x'] / mm + sl1_match_norm['y'] = sl1_match_norm['y'] / mm + sl2_match_norm['x'] = sl2_match_norm['x'] / mm + sl2_match_norm['y'] = sl2_match_norm['y'] / mm + sl2_transf_match_norm['x'] = sl2_transf_match_norm['x'] / mm + sl2_transf_match_norm['y'] = sl2_transf_match_norm['y'] / mm + transf, _ = align.find_transform(sl2_match_norm, sl2_transf_match_norm, + sl1_match_norm, transModel=model, + order=poly_order, verbose=verbose) + c_exp = np.zeros(len(transf.px._parameters)) + + for i_c in range(len(transf.px._parameters)): + c_exp[i_c] = int(transf.px._param_names[i_c][1:].split('_')[0]) +\ + int(transf.px._param_names[i_c][1:].split('_')[1]) + + c_corr = mm ** (1 - c_exp) + transf.px._parameters = transf.px._parameters * c_corr + transf.py._parameters = transf.py._parameters * c_corr""" + + # Do the final transformation and matching using + sl2_idx, sl1_idx = transform_and_match(sl2, sl1, transf, dr_tol=dr_final, verbose=verbose) + # StarTable output + sl2_transf = transform_from_object(sl2, transf) + unames = np.array(range(len(sl1_idx))) + st = startables.StarTable(name=unames, + x=np.column_stack((np.array(sl1['x'][sl1_idx]), np.array(sl2_transf['x'][sl2_idx]))), + y=np.column_stack((np.array(sl1['y'][sl1_idx]), np.array(sl2_transf['y'][sl2_idx]))), + m=np.column_stack((np.array(sl1['m'][sl1_idx]), np.array(sl2_transf['m'][sl2_idx]))), + ep_name=np.column_stack((np.array(sl1['name'][sl1_idx]), np.array(sl2_transf['name'][sl2_idx]))) + ) + + for col in sl1.colnames: + if col in sl2.colnames: + if col not in ['name', 'x', 'y', 'm']: + st.add_column(Column(np.column_stack((np.array(sl1[col][sl1_idx]),np.array(sl2_transf[col][sl2_idx]))), name=col)) + + return transf, st + + +def suppress_meta_warnings(table): + """ + Build a copy of a table's meta dict with over-long keys renamed, so + writing it to FITS does not warn. + + A FITS header keyword is limited to 8 characters; astropy will write a + longer one using the HIERARCH convention but warns each time it does. + Prefixing those keys with 'HIERARCH ' up front asks for the same result + explicitly, which suppresses the warning without changing what is + written. + + This returns a new dict rather than renaming ``table.meta`` in place: + the HIERARCH prefix is only meaningful to the FITS writer, so baking it + into the table's real meta keys would break plain key lookups (e.g. + ``ref_table.meta['list_times']``) for every other consumer -- including + a later, unrelated save in a different format. + + Parameters + ---------- + table : astropy.table.Table + Table to read ``meta`` from. Not modified. + + Returns + ------- + dict + A new meta dict with long keys HIERARCH-prefixed, suitable for + assigning onto a table (or a copy of one) that is about to be + written as FITS. + """ + return { + (f'HIERARCH {k}' if len(k) > 8 else k): v + for k, v in table.meta.items() + } \ No newline at end of file diff --git a/flystar/align_old_functions.py b/flystar/align_old_functions.py new file mode 100755 index 0000000..fd2b4f4 --- /dev/null +++ b/flystar/align_old_functions.py @@ -0,0 +1,797 @@ +""" +Old functions that are only referenced in examples and template +""" +def transform_from_file(starlist, transFile): + """ + Apply transformation from transFile to starlist. Returns astropy table with + added columns with the transformed coordinates. NOTE: Transforms + positions/position errors, plus velocities and velocity errors if they + are present in starlist. + + WARNING: THIS CODE WILL NOT WORK FOR LEGENDRE POLYNOMIAL + TRANSFORMS + + Parameters + ---------- + starlist: astropy table + Starlist we want to apply the transformation too. Must already + have standard column headers + transFile: ascii file + File with the transformation coefficients. Assumed to be output of + write_transform, with coefficients specified as code documents + + Output + ------ + Copy of starlist astropy table with transformed coordinates. + """ + # Make a copy of starlist. This is what we will eventually modify with + # the transformed coordinates + starlist_f = copy.deepcopy(starlist) + + # Check to see if velocities are present in starlist. If so, we will + # need to transform these as well as positions + vel = False + keys = list(starlist.keys()) + if 'vx' in keys: + vel = True + + # Extract needed information from starlist + x_orig = starlist['x'] + y_orig = starlist['y'] + xe_orig = starlist['xe'] + ye_orig = starlist['ye'] + + if vel: + x0_orig = starlist['x0'] + y0_orig = starlist['y0'] + x0e_orig = starlist['x0_err'] + y0e_orig = starlist['y0_err'] + + vx_orig = starlist['vx'] + vy_orig = starlist['vy'] + vxe_orig = starlist['vx_err'] + vye_orig = starlist['vy_err'] + + # Read transFile + trans = Table.read(transFile, format='ascii.commented_header', header_start=-1) + Xcoeff = trans['Xcoeff'] + Ycoeff = trans['Ycoeff'] + + #-----------------------------------------------# + # General equation for applying the transform + #-----------------------------------------------# + #""" + # First determine the order based on the number of terms + # Comes from Nterms = (N+1)*(N+2) / 2. + order = (np.sqrt(1 + 8*len(Xcoeff)) - 3) / 2. + + if order%1 != 0: + print( 'Incorrect number of coefficients for polynomial') + print( 'Stopping') + return + order = int(order) + + # Position transformation + x_new, y_new = transform_pos_from_file(Xcoeff, Ycoeff, order, x_orig, + y_orig) + + if vel: + x0_new, y0_new = transform_pos_from_file(Xcoeff, Ycoeff, order, x0_orig, + y0_orig) + + # Position error transformation + xe_new, ye_new = transform_poserr_from_file(Xcoeff, Ycoeff, order, xe_orig, + ye_orig, x_orig, y_orig) + + if vel: + x0e_new, y0e_new = transform_poserr_from_file(Xcoeff, Ycoeff, order, x0e_orig, + y0e_orig, x0_orig, y0_orig) + + if vel: + # Velocity transformation + vx_new, vy_new = transform_vel_from_file(Xcoeff, Ycoeff, order, vx_orig, + vy_orig, x_orig, y_orig) + + # Velocity error transformation + vxe_new, vye_new = transform_velerr_from_file(Xcoeff, Ycoeff, order, + vxe_orig, vye_orig, + vx_orig, vy_orig, + xe_orig, ye_orig, + x_orig, y_orig) + + #----------------------------------------# + # Hard coded example: old but functional + #----------------------------------------# + """ + # How the transformation is applied depends on the type of transform. + # This can be determined by the length of Xcoeff, Ycoeff + if len(Xcoeff) == 3: + x_new = Xcoeff[0] + Xcoeff[1] * x_orig + Xcoeff[2] * y_orig + y_new = Ycoeff[0] + Ycoeff[1] * x_orig + Ycoeff[2] * y_orig + xe_new = np.sqrt( (Xcoeff[1] * xe_orig)**2 + (Xcoeff[2] * ye_orig)**2 ) + ye_new = np.sqrt( (Ycoeff[1] * xe_orig)**2 + (Ycoeff[2] * ye_orig)**2 ) + + if vel: + vx_new = Xcoeff[1] * vx_orig + Xcoeff[2] * vy_orig + vy_new = Ycoeff[1] * vx_orig + Ycoeff[2] * vy_orig + vxe_new = np.sqrt( (Xcoeff[1] * vxe_orig)**2 + (Xcoeff[2] * vye_orig)**2 ) + vye_new = np.sqrt( (Ycoeff[1] * vxe_orig)**2 + (Ycoeff[2] * vye_orig)**2 ) + + elif len(Xcoeff) == 6: + x_new = Xcoeff[0] + Xcoeff[1]*x_orig + Xcoeff[3]*x_orig**2 + Xcoeff[2]*y_orig + \ + Xcoeff[5]*y_orig**2. + Xcoeff[4]*x_orig*y_orig + + y_new = Ycoeff[0] + Ycoeff[1]*x_orig + Ycoeff[3]*x_orig**2 + Ycoeff[2]*y_orig + \ + Ycoeff[5]*y_orig**2. + Ycoeff[4]*x_orig*y_orig + + xe_new = np.sqrt( (Xcoeff[1] + 2*Xcoeff[3]*x_orig + Xcoeff[4]*y_orig)**2 * xe_orig**2 + \ + (Xcoeff[2] + 2*Xcoeff[5]*y_orig + Xcoeff[4]*x_orig)**2 * ye_orig**2 ) + + ye_new = np.sqrt( (Ycoeff[1] + 2*Ycoeff[3]*x_orig + Ycoeff[4]*y_orig)**2 * xe_orig**2 + \ + (Ycoeff[2] + 2*Ycoeff[5]*y_orig + Ycoeff[4]*x_orig)**2 * ye_orig**2 ) + + if vel: + vx_new = Xcoeff[1]*vx_orig + 2*Xcoeff[3]*x_orig*vx_orig + Xcoeff[2]*vy_orig + \ + 2.*Xcoeff[5]*y_orig*vy_orig + Xcoeff[4]*(x_orig*vy_orig + vx_orig*y_orig) + + vy_new = Ycoeff[1]*vx_orig + 2*Ycoeff[3]*x_orig*vx_orig + Ycoeff[2]*vy_orig + \ + 2.*Ycoeff[5]*y_orig*vy_orig + Ycoeff[4]*(x_orig*vy_orig + vx_orig*y_orig) + + vxe_new = np.sqrt( (Xcoeff[1] + 2*Xcoeff[3]*x_orig + Xcoeff[4]*y_orig)**2 * vxe_orig**2 + \ + (Xcoeff[2] + 2*Xcoeff[5]*y_orig + Xcoeff[4]*x_orig)**2 * vye_orig**2 + \ + (2*Xcoeff[3]*vx_orig + Xcoeff[4]*vy_orig)**2 * xe_orig**2 + \ + (2*Xcoeff[5]*vy_orig + Xcoeff[4]*vx_orig)**2 * ye_orig**2 ) + + vye_new = np.sqrt( (Ycoeff[1] + 2*Ycoeff[3]*x_orig + Ycoeff[4]*y_orig)**2 * vxe_orig**2 + \ + (Ycoeff[2] + 2*Ycoeff[5]*y_orig + Ycoeff[4]*x_orig)**2 * vye_orig**2 + \ + (2*Ycoeff[3]*vx_orig + Ycoeff[4]*vy_orig)**2 * xe_orig**2 + \ + (2*Ycoeff[5]*vy_orig + Ycoeff[4]*vx_orig)**2 * ye_orig**2 ) + """ + #Update transformed coords to copy of astropy table + starlist_f['x'] = x_new + starlist_f['y'] = y_new + starlist_f['xe'] = xe_new + starlist_f['ye'] = ye_new + + if vel: + starlist_f['x0'] = x0_new + starlist_f['y0'] = y0_new + starlist_f['x0_err'] = x0e_new + starlist_f['y0_err'] = y0e_new + starlist_f['vx'] = vx_new + starlist_f['vy'] = vy_new + starlist_f['vx_err'] = vxe_new + starlist_f['vy_err'] = vye_new + + return starlist_f + +def transform_pos_from_file(Xcoeff, Ycoeff, order, x_orig, y_orig): + """ + Given the read-in coefficients from transform_from_file, apply the + transformation to the observed positions. This is generalized to + work with any order polynomial transform. + + WARNING: THIS CODE WILL NOT WORK FOR LEGENDRE POLYNOMIAL + TRANSFORMS + + Parameters + ---------- + Xcoeff: Array + Array with the coefficients of the X pos transformation + Ycoeff: Array + Array with the coefficients of the Y pos transformation + order: int + Order of transformation + x_orig: array + Array with the original X positions + y_orig: array + Array with the original Y positions + + Output + ------ + x_new: array + Transformed X positions + y_new: array + Transformed Y positions + + """ + idx = 0 # coeff index + x_new = 0.0 + y_new = 0.0 + for i in range(order+1): + for j in range(i+1): + x_new += Xcoeff[idx] * x_orig**(i-j) * y_orig**j + y_new += Ycoeff[idx] * x_orig**(i-j) * y_orig**j + + idx += 1 + + return x_new, y_new + +def transform_poserr_from_file(Xcoeff, Ycoeff, order, xe_orig, ye_orig, x_orig, y_orig): + """ + Given the read-in coefficients from transform_from_file, apply the + transformation to the observed position errors. This is generalized to + work with any order transform. + + WARNING: THIS CODE WILL NOT WORK FOR LEGENDRE POLYNOMIAL + TRANSFORMS + + Parameters + ---------- + Xcoeff: Array + Array with the coefficients of the X pos transformation + Ycoeff: Array + Array with the coefficients of the Y pos transformation + order: int + Order of transformation + xe_orig: array + Array with the original X position errs + ye_orig: array + Array with the original Y position errs + x_orig: array + Array with the original X positions + y_orig: array + Array with the original Y positions + + Output + ------ + xe_new: array + Transformed X position errs + ye_new: array + Transformed Y position errs + """ + idx = 0 # coeff index + xe_new_tmp1 = 0.0 + ye_new_tmp1 = 0.0 + xe_new_tmp2 = 0.0 + ye_new_tmp2 = 0.0 + + # First loop: dx'/dx + for i in range(order+1): + for j in range(i+1): + xe_new_tmp1 += Xcoeff[idx] * (i - j) * x_orig**(i-j-1) * y_orig**j + ye_new_tmp1 += Ycoeff[idx] * (i - j) * x_orig**(i-j-1) * y_orig**j + + idx += 1 + + # Second loop: dy'/dy + idx = 0 # coeff index + for i in range(order+1): + for j in range(i+1): + xe_new_tmp2 += Xcoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) + ye_new_tmp2 += Ycoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) + + idx += 1 + # Take square root for xe/ye_new + xe_new = np.sqrt((xe_new_tmp1 * xe_orig)**2 + (xe_new_tmp2 * ye_orig)**2) + ye_new = np.sqrt((ye_new_tmp1 * ye_orig)**2 + (ye_new_tmp2 * ye_orig)**2) + + return xe_new, ye_new + +def transform_vel_from_file(Xcoeff, Ycoeff, order, vx_orig, vy_orig, x_orig, y_orig): + """ + Given the read-in coefficients from transform_from_file, apply the + transformation to the observed proper motions. This is generalized to + work with any order transform. + + WARNING: THIS CODE WILL NOT WORK FOR LEGENDRE POLYNOMIAL + TRANSFORMS + + Parameters + ---------- + Xcoeff: Array + Array with the coefficients of the X pos transformation + Ycoeff: Array + Array with the coefficients of the Y pos transformation + order: int + Order of transformation + vx_orig: array + Array with the original X proper motions + vy_orig: array + Array with the original Y proper motions + x_orig: array + Array with the original X positions + y_orig: array + Array with the original Y positions + + Output + ------ + vx_new: array + Transformed X proper motions + vy_new: array + Transformed Y proper motions + """ + idx = 0 # coeff index + vx_new = 0.0 + vy_new = 0.0 + # First loop: dx'/dx + for i in range(order+1): + for j in range(i+1): + vx_new += Xcoeff[idx] * (i - j) * x_orig**(i-j-1) * y_orig**j * vx_orig + vy_new += Ycoeff[idx] * (i - j) * x_orig**(i-j-1) * y_orig**j * vx_orig + + idx += 1 + # Second loop: dy'/dy + idx = 0 # coeff index + for i in range(order+1): + for j in range(i+1): + vx_new += Xcoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) * vy_orig + vy_new += Ycoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) * vy_orig + + idx += 1 + + return vx_new, vy_new + +def transform_velerr_from_file(Xcoeff, Ycoeff, order, vxe_orig, vye_orig, vx_orig, + vy_orig, xe_orig, ye_orig, x_orig, y_orig): + """ + Given the read-in coefficients from transform_from_file, apply the + transformation to the observed proper motion errors. This is generalized to + work with any order transform. + + WARNING: THIS CODE WILL NOT WORK FOR LEGENDRE POLYNOMIAL + TRANSFORMS + + Parameters + ---------- + Xcoeff: Array + Array with the coefficients of the X pos transformation + Ycoeff: Array + Array with the coefficients of the Y pos transformation + order: int + Order of transformation + vxe_orig: array + Array with the original X proper motion errs + vye_orig: array + Array with the original Y proper motion errs + vx_orig: array + Array with the original X proper motions + vy_orig: array + Array with the original Y proper motions + xe_orig: array + Array with the original X position errs + ye_orig: array + Array with the original Y position errs + x_orig: array + Array with the original X positions + y_orig: array + Array with the original Y positions + + Output + ------ + vxe_new: array + Transformed X proper motion errs + vye_new: array + Transformed Y proper motion errs + """ + idx = 0 + vxe_new_tmp1 = 0.0 + vye_new_tmp1 = 0.0 + vxe_new_tmp2 = 0.0 + vye_new_tmp2 = 0.0 + vxe_new_tmp3 = 0.0 + vye_new_tmp3 = 0.0 + vxe_new_tmp4 = 0.0 + vye_new_tmp4 = 0.0 + + + # First loop: dvx' / dx + for i in range(order+1): + for j in range(i+1): + vxe_new_tmp1 += Xcoeff[idx] * (i-j) * (i-j-1) * x_orig**(i-j-2) * y_orig**j * vx_orig + vxe_new_tmp1 += Xcoeff[idx] * (j) * (i-j) * x_orig**(i-j-1) * y_orig**(j-1) * vy_orig + vye_new_tmp1 += Ycoeff[idx] * (i-j) * (i-j-1) * x_orig**(i-j-2) * y_orig**j * vx_orig + vye_new_tmp1 += Ycoeff[idx] * (j) * (i-j) * x_orig**(i-j-1) * y_orig**(j-1) * vy_orig + + idx += 1 + + # Second loop: dvx' / dy + idx = 0 + for i in range(order+1): + for j in range(i+1): + vxe_new_tmp2 += Xcoeff[idx] * (i-j) * (j) * x_orig**(i-j-1) * y_orig**(j-1) * vx_orig + vxe_new_tmp2 += Xcoeff[idx] * (j) * (j-1) * x_orig**(i-j-1) * y_orig**(j-2) * vy_orig + vye_new_tmp2 += Ycoeff[idx] * (i-j) * (j) * x_orig**(i-j-1) * y_orig**(j-1) * vx_orig + vye_new_tmp2 += Ycoeff[idx] * (j) * (j-1) * x_orig**(i-j-1) * y_orig**(j-2) * vy_orig + + idx += 1 + + # Third loop: dvx' / dvx + idx = 0 + for i in range(order+1): + for j in range(i+1): + vxe_new_tmp3 += Xcoeff[idx] * (i-j) * x_orig**(i-j-1) * y_orig**j + vye_new_tmp3 += Ycoeff[idx] * (i-j) * x_orig**(i-j-1) * y_orig**j + + idx += 1 + + # Fourth loop: dvx' / dvy + idx = 0 + for i in range(order+1): + for j in range(i+1): + vxe_new_tmp4 += Xcoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) + vye_new_tmp4 += Ycoeff[idx] * (j) * x_orig**(i-j) * y_orig**(j-1) + + idx += 1 + + vxe_new = np.sqrt((vxe_new_tmp1 * xe_orig)**2 + (vxe_new_tmp2 * ye_orig)**2 + \ + (vxe_new_tmp3 * vxe_orig)**2 + (vxe_new_tmp4 * vye_orig)**2) + vye_new = np.sqrt((vye_new_tmp1 * xe_orig)**2 + (vye_new_tmp2 * ye_orig)**2 + \ + (vye_new_tmp3 * vxe_orig)**2 + (vye_new_tmp4 * vye_orig)**2) + + return vxe_new, vye_new + + + + + + +""" +Old functions with things hard-coded for OB120169 +""" + +def run_align_iter(catalog, trans_order=1, poly_deg=1, ref_mag_lim=19, ref_radius_lim=300): + # Load up data with matched stars. + d = Table.read(catalog) + + # Determine how many epochs there are. + N_epochs = len([n for n, c in enumerate(d.colnames) if c.startswith('name')]) + + # Determine how many stars there are. + N_stars = len(d) + + # Determine the reference epoch + ref = d.meta['L_REF'] + + # Figure out the number of free parameters for the specified + # poly2d order. + poly2d = models.Polynomial2D(trans_order) + N_par_trans_per_epoch = 2.0 * poly2d.get_num_coeff(2) # one poly2d for each dimension (X, Y) + N_par_trans = N_par_trans_per_epoch * N_epochs + + ########## + # First iteration -- align everything to REF epoch with zero velocities. + ########## + print('ALIGN_EPOCHS: run_align_iter() -- PASS 1') + ee_ref = d.meta['L_REF'] + + target_name = 'OB120169' + + trans1, used1 = calc_transform_ref_epoch(d, target_name, ee_ref, ref_mag_lim, ref_radius_lim) + + ########## + # Derive the velocity of each stars using the round 1 transforms. + ########## + calc_polyfit_all_stars(d, poly_deg, init_fig_idx=0) + + calc_mag_avg_all_stars(d) + + tdx = np.where((d['name_0'] == 'OB120169') | (d['name_0'] == 'OB120169_L'))[0] + print(d[tdx]['name_0', 't0', 'mag', 'x0', 'vx', 'x0_err', 'vx_err', 'chi2x', 'y0', 'vy', 'y0_err', 'vy_err', 'chi2y', 'dof']) + + ########## + # Second iteration -- align everything to reference positions derived from iteration 1 + ########## + print('ALIGN_EPOCHS: run_align_iter() -- PASS 2') + target_name = 'OB120169' + + trans2, used2 = calc_transform_ref_poly(d, target_name, poly_deg, ref_mag_lim, ref_radius_lim) + + ########## + # Derive the velocity of each stars using the round 1 transforms. + ########## + calc_polyfit_all_stars(d, poly_deg, init_fig_idx=4) + + ########## + # Save output + ########## + d.write(catalog.replace('.fits', '_aln.fits'), overwrite=True) + + return + +def calc_transform_ref_epoch(d, target_name, ee_ref, ref_mag_lim, ref_radius_lim): + # Determine how many epochs there are. + N_epochs = len([n for n, c in enumerate(d.colnames) if c.startswith('name')]) + + # output array + trans = [] + used = [] + + # Find the target + tdx = np.where(d['name_0'] == 'OB120169')[0][0] + + # Reference values + t_ref = d['t_{0:d}'.format(ee_ref)] + m_ref = d['m_{0:d}'.format(ee_ref)] + x_ref = d['x_{0:d}'.format(ee_ref)] + y_ref = d['y_{0:d}'.format(ee_ref)] + xe_ref = d['xe_{0:d}'.format(ee_ref)] + ye_ref = d['ye_{0:d}'.format(ee_ref)] + + # Calculate some quanitites we use for selecting reference stars. + r_ref = np.hypot(x_ref - x_ref[tdx], y_ref - y_ref[tdx]) + + # Loop through and align each epoch to the reference epoch. + for ee in range(N_epochs): + # Pull out the X, Y positions (and errors) for the two + # starlists we are going to align. + x_epo = d['x_{0:d}'.format(ee)] + y_epo = d['y_{0:d}'.format(ee)] + t_epo = d['t_{0:d}'.format(ee)] + xe_epo = d['xe_{0:d}'.format(ee)] + ye_epo = d['ye_{0:d}'.format(ee)] + + # Figure out the set of stars detected in both epochs. + idx = np.where((t_ref != 0) & (t_epo != 0) & (xe_ref != 0) & (xe_epo != 0))[0] + + # Find those in both epochs AND reference stars. This is [idx][rdx] + rdx = np.where((r_ref[idx] < ref_radius_lim) & (m_ref[idx] < ref_mag_lim))[0] + + # Average the positional errors together to get one weight per star. + xye_ref = (xe_ref + ye_ref) / 2.0 + xye_epo = (xe_epo + ye_epo) / 2.0 + xye_wgt = (xye_ref**2 + xye_epo**2)**0.5 + + # Calculate transform based on the matched stars + trans_tmp = transforms.PolyTransform(x_epo[idx][rdx], y_epo[idx][rdx], x_ref[idx][rdx], y_ref[idx][rdx], + weights=xye_wgt[idx][rdx], order=2) + + trans.append(trans_tmp) + + + # Apply thte transformation to the stars positions and errors: + xt_epo = np.zeros(len(d), dtype=float) + yt_epo = np.zeros(len(d), dtype=float) + xet_epo = np.zeros(len(d), dtype=float) + yet_epo = np.zeros(len(d), dtype=float) + + xt_epo[idx], xet_epo[idx], yt_epo[idx], yet_epo[idx] = trans_tmp.evaluate_errors(x_epo[idx], xe_epo[idx], + y_epo[idx], ye_epo[idx], + nsim=100) + + d['xt_{0:d}'.format(ee)] = xt_epo + d['yt_{0:d}'.format(ee)] = yt_epo + d['xet_{0:d}'.format(ee)] = xet_epo + d['yet_{0:d}'.format(ee)] = yet_epo + + # Record which stars we used in the transform. + used_tmp = np.zeros(len(d), dtype=bool) + used_tmp[idx[rdx]] = True + + used.append(used_tmp) + + if True: + plot_quiver_residuals(xt_epo, yt_epo, x_ref, y_ref, idx, rdx, 'Epoch: ' + str(ee)) + + used = np.array(used) + + return trans, used + +def calc_transform_ref_poly(d, target_name, poly_deg, ref_mag_lim, ref_radius_lim): + # Determine how many epochs there are. + N_epochs = len([n for n, c in enumerate(d.colnames) if c.startswith('name')]) + + # output array + trans = [] + used = [] + + # Find the target + tdx = np.where(d['name_0'] == 'OB120169')[0][0] + + # Temporary Reference values + t_ref = d['t0'] + m_ref = d['mag'] + x_ref = d['x0'] + y_ref = d['y0'] + xe_ref = d['x0_err'] + ye_ref = d['y0_err'] + + # Calculate some quanitites we use for selecting reference stars. + r_ref = np.hypot(x_ref - x_ref[tdx], y_ref - y_ref[tdx]) + + for ee in range(N_epochs): + # Pull out the X, Y positions (and errors) for the two + # starlists we are going to align. + x_epo = d['x_{0:d}'.format(ee)] + y_epo = d['y_{0:d}'.format(ee)] + t_epo = d['t_{0:d}'.format(ee)] + xe_epo = d['xe_{0:d}'.format(ee)] + ye_epo = d['ye_{0:d}'.format(ee)] + + # Shift the reference position by the polyfit for each star. + dt = t_epo - t_ref + if poly_deg >= 0: + x_ref_ee = x_ref + y_ref_ee = y_ref + xe_ref_ee = x_ref + ye_ref_ee = y_ref + + if poly_deg >= 1: + x_ref_ee += d['vx'] * dt + y_ref_ee += d['vy'] * dt + xe_ref_ee = np.hypot(xe_ref_ee, d['vx_err'] * dt) + ye_ref_ee = np.hypot(ye_ref_ee, d['vy_err'] * dt) + + if poly_deg >= 2: + x_ref_ee += d['ax'] * dt + y_ref_ee += d['ay'] * dt + xe_ref_ee = np.hypot(xe_ref_ee, d['axe'] * dt) + ye_ref_ee = np.hypot(ye_ref_ee, d['aye'] * dt) + + # Figure out the set of stars detected in both. + idx = np.where((t_ref != 0) & (t_epo != 0) & (xe_ref != 0) & (xe_epo != 0))[0] + + # Find those in both AND reference stars. This is [idx][rdx] + rdx = np.where((r_ref[idx] < ref_radius_lim) & (m_ref[idx] < ref_mag_lim))[0] + + # Average the positional errors together to get one weight per star. + xye_ref = (xe_ref_ee + ye_ref_ee) / 2.0 + xye_epo = (xe_epo + ye_epo) / 2.0 + xye_wgt = (xye_ref**2 + xye_epo**2)**0.5 + + # Calculate transform based on the matched stars + trans_tmp = transforms.PolyTransform(x_epo[idx][rdx], y_epo[idx][rdx], x_ref_ee[idx][rdx], y_ref_ee[idx][rdx], + weights=xye_wgt[idx][rdx], order=2) + trans.append(trans_tmp) + + # Apply thte transformation to the stars positions and errors: + xt_epo = np.zeros(len(d), dtype=float) + yt_epo = np.zeros(len(d), dtype=float) + xet_epo = np.zeros(len(d), dtype=float) + yet_epo = np.zeros(len(d), dtype=float) + + xt_epo[idx], xet_epo[idx], yt_epo[idx], yet_epo[idx] = trans_tmp.evaluate_errors(x_epo[idx], xe_epo[idx], + y_epo[idx], ye_epo[idx], + nsim=100) + d['xt_{0:d}'.format(ee)] = xt_epo + d['yt_{0:d}'.format(ee)] = yt_epo + d['xet_{0:d}'.format(ee)] = xet_epo + d['yet_{0:d}'.format(ee)] = yet_epo + + # Record which stars we used in the transform. + used_tmp = np.zeros(len(d), dtype=bool) + used_tmp[idx[rdx]] = True + + used.append(used_tmp) + + if True: + plot_quiver_residuals(xt_epo, yt_epo, x_ref_ee, y_ref_ee, idx, rdx, 'Epoch: ' + str(ee)) + + used = np.array(used) + + return trans, used + +def calc_polyfit_all_stars(d, poly_deg, init_fig_idx=0): + # Determine how many stars there are. + N_stars = len(d) + + # Determine how many epochs there are. + N_epochs = len([n for n, c in enumerate(d.colnames) if c.startswith('name')]) + + # Setup some variables to save the results + t0_all = [] + px_all = [] + py_all = [] + pxe_all = [] + pye_all = [] + chi2x_all = [] + chi2y_all = [] + dof_all = [] + + # Get the time array, which is the same for all stars. + # Also, sort the time indices. + t = np.array([d['t_{0:d}'.format(ee)][0] for ee in range(N_epochs)]) + tdx = t.argsort() + t_sorted = t[tdx] + + # Run polyfit on each star. + for ss in range(N_stars): + # Get the x, y, xe, ye, and t arrays for this star. + xt = np.array([d['xt_{0:d}'.format(ee)][ss] for ee in range(N_epochs)]) + yt = np.array([d['yt_{0:d}'.format(ee)][ss] for ee in range(N_epochs)]) + xet = np.array([d['xet_{0:d}'.format(ee)][ss] for ee in range(N_epochs)]) + yet = np.array([d['yet_{0:d}'.format(ee)][ss] for ee in range(N_epochs)]) + t_tmp = np.array([d['t_{0:d}'.format(ee)][ss] for ee in range(N_epochs)]) + + # Sort these arrays. + xt_sorted = xt[tdx] + yt_sorted = yt[tdx] + xet_sorted = xet[tdx] + yet_sorted = yet[tdx] + t_tmp_sorted = t_tmp[tdx] + + # Get only the detected epochs. + edx = np.where(t_tmp_sorted != 0)[0] + + # Calculate the weighted t0 (using the transformed errors). + weight_for_t0 = 1.0 / np.hypot(xet_sorted, yet_sorted) + t0 = np.average(t_sorted[edx], weights=weight_for_t0[edx]) + + # for ee in edx: + # print('{0:8.3f} {1:10.5f} {2:10.5f} {3:8.5f} {4:8.5f}'.format(t[ee], xt[ee], yt[ee], xet[ee], yet[ee])) + # pdb.set_trace() + + # Run polyfit + dt = t_sorted - t0 + px, covx = np.polyfit(dt[edx], xt_sorted[edx], poly_deg, w=1./xet_sorted[edx], cov=True) + py, covy = np.polyfit(dt[edx], yt_sorted[edx], poly_deg, w=1./yet_sorted[edx], cov=True) + + pxe = np.sqrt(np.diag(covx)) + pye = np.sqrt(np.diag(covy)) + + + x_mod = np.polyval(px, dt[edx]) + y_mod = np.polyval(py, dt[edx]) + chi2x = np.sum( ((x_mod - xt_sorted[edx]) / xet_sorted[edx])**2 ) + chi2y = np.sum( ((y_mod - yt_sorted[edx]) / yet_sorted[edx])**2 ) + dof = len(edx) - (poly_deg + 1) + + # Save results: + t0_all.append(t0) + px_all.append(px) + py_all.append(py) + pxe_all.append(pxe) + pye_all.append(pye) + chi2x_all.append(chi2x) + chi2y_all.append(chi2y) + dof_all.append(dof) + + if d[ss]['name_0'] in ['OB120169', 'OB120169_L']: + gs = GridSpec(3, 2) # 3 rows, 1 column + fig = plt.figure(ss + 1 + init_fig_idx, figsize=(12, 8)) + a0 = fig.add_subplot(gs[0:2, 0]) + a1 = fig.add_subplot(gs[2, 0]) + a2 = fig.add_subplot(gs[0:2, 1]) + a3 = fig.add_subplot(gs[2, 1]) + + a0.errorbar(t_sorted[edx], xt_sorted[edx], yerr=xet_sorted[edx], fmt='ro') + a0.plot(t_sorted[edx], x_mod, 'k-') + a0.set_title(d[ss]['name_0'] + ' X') + a1.errorbar(t_sorted[edx], xt_sorted[edx] - x_mod, yerr=xet_sorted[edx], fmt='ro') + a1.axhline(0, linestyle='--') + a1.set_xlabel('Time (yrs)') + a2.errorbar(t_sorted[edx], yt_sorted[edx], yerr=yet_sorted[edx], fmt='ro') + a2.plot(t_sorted[edx], y_mod, 'k-') + a2.set_title(d[ss]['name_0'] + ' Y') + a3.errorbar(t_sorted[edx], yt_sorted[edx] - y_mod, yerr=yet_sorted[edx], fmt='ro') + a3.axhline(0, linestyle='--') + a3.set_xlabel('Time (yrs)') + + + + t0_all = np.array(t0_all) + px_all = np.array(px_all) + py_all = np.array(py_all) + pxe_all = np.array(pxe_all) + pye_all = np.array(pye_all) + chi2x_all = np.array(chi2x_all) + chi2y_all = np.array(chi2y_all) + dof_all = np.array(dof_all) + + # Done with all the stars... recast as numpy arrays and save to output table. + d['t0'] = t0_all + d['chi2x'] = chi2x_all + d['chi2y'] = chi2y_all + d['dof'] = dof_all + if poly_deg >= 0: + d['x0'] = px_all[:, -1] + d['y0'] = py_all[:, -1] + d['x0_err'] = pxe_all[:, -1] + d['y0_err'] = pye_all[:, -1] + + if poly_deg >= 1: + d['vx'] = px_all[:, -2] + d['vy'] = py_all[:, -2] + d['vx_err'] = pxe_all[:, -2] + d['vy_err'] = pye_all[:, -2] + + if poly_deg >= 2: + d['ax'] = px_all[:, -3] + d['ay'] = py_all[:, -3] + d['axe'] = pxe_all[:, -3] + d['aye'] = pye_all[:, -3] + + pdb.set_trace() + + return + diff --git a/flystar/analysis.py b/flystar/analysis.py index 953461b..c661a17 100644 --- a/flystar/analysis.py +++ b/flystar/analysis.py @@ -1,27 +1,19 @@ +import copy import numpy as np import pylab as plt -from flystar import starlists -from flystar import startables -from flystar import align -from flystar import match -from flystar import transforms +from scipy.stats import f from astropy import table from astropy.table import Table, Column from astropy.coordinates import SkyCoord from astropy import units as u -from astropy.wcs import WCS -from astroquery.gaia import Gaia -from astroquery.mast import Observations, Catalogs -import pdb, copy -import math -from scipy.stats import f +from flystar import starlists, match ################################################## # New codes for velocity support in FlyStar and using -# the new StarTable and StarList format. +# the new StarTable and StarList format. ################################################## -def query_gaia(ra, dec, search_radius=30.0, table_name='gaiadr2'): +def query_gaia(ra, dec, search_radius=30.0, table_name='gaiadr3'): """ Query the Gaia database at the specified location and with the specified search radius @@ -35,13 +27,14 @@ def query_gaia(ra, dec, search_radius=30.0, table_name='gaiadr2'): Dec. in degrees in the format such as '-29:00:28.0' search_radius : float - The search radius in arcseconds. + The search radius in arcseconds. Optional Input -------------- table_name : string Options are 'gaiadr2' or 'gaiaedr3' """ + from astroquery.gaia import Gaia target_coords = SkyCoord(ra, dec, unit=(u.hourangle, u.deg), frame='icrs') ra = target_coords.ra.degree dec = target_coords.dec.degree @@ -49,20 +42,72 @@ def query_gaia(ra, dec, search_radius=30.0, table_name='gaiadr2'): search_radius *= u.arcsec Gaia.ROW_LIMIT = 50000 - gaia_job = Gaia.cone_search_async(target_coords, search_radius, table_name = table_name + '.gaia_source') + gaia_job = Gaia.cone_search_async(target_coords, radius=search_radius, table_name=table_name + '.gaia_source') gaia = gaia_job.get_results() #Change new 'SOURCE_ID' column header back to lowercase 'source_id' so all subsequent functions still work: - gaia['SOURCE_ID'].name = 'source_id' + if 'SOURCE_ID' in gaia.colnames: + gaia.rename_column('SOURCE_ID', 'source_id') return gaia +def check_gaia_parallaxes(ra,dec,search_radius=10.0,table_name='gaiadr3',target='(unnamed)', + file_ext=''): + """ + Query the Gaia database at the specified location + and with the specified search radius, and plot + parallaxes. + + Input + ---------- + ra : string + R.A. in hours in the format such as '17:45:40.3' -def prepare_gaia_for_flystar(gaia, ra, dec, targets_dict=None, match_dr_max=0.2): + dec : string + Dec. in degrees in the format such as '-29:00:28.0' + + search_radius : float + The search radius in arcseconds. + + Optional Input + -------------- + table_name : string + Options are 'gaiadr2' or 'gaiadr3' + """ + # Query Gaia + gaia = query_gaia(ra,dec,search_radius=search_radius,table_name=table_name) + # Set up reasonable histogram bins + plim0,plim1 = np.min(gaia['parallax']),np.max(gaia['parallax']) + pplim0,pplim1 = np.min(gaia['parallax']/gaia['parallax_error']),np.max(gaia['parallax']/gaia['parallax_error']) + binwidth = 1 + pbins = np.arange(np.floor(plim0),np.ceil(plim1)+binwidth,binwidth) + ppbins = np.arange(np.floor(pplim0),np.ceil(pplim1)+binwidth,binwidth) + # Find number where plx/plx_err>3 + p_perr = (gaia['parallax']/gaia['parallax_error']).compressed() + nppe3 = sum((p_perr>3).astype(int)) + nppen3 = sum((p_perr<-3).astype(int)) + print(table_name,'stars within',search_radius,'\" with plx/plx_err>3: ', nppe3, ' of ', len(gaia['parallax'])) + print(table_name,'stars within',search_radius,'\" with plx/plx_err<-3: ', nppen3, ' of ', len(gaia['parallax'])) + # Plot + plt.subplots(nrows=1,ncols=2,figsize=(12,6)) + plt.subplot(121) + plt.xlabel('parallax (mas)'); plt.ylabel('N stars') + plt.hist(gaia['parallax'],bins=pbins) + plt.yscale('log') + plt.title(table_name+' parallax histograms, '+str(search_radius)+'\" radius around '+target, loc='left') + plt.subplot(122) + plt.xlabel('parallax/parallax_error') + plt.hist(gaia['parallax']/gaia['parallax_error'],bins=ppbins) + plt.yscale('log') + plt.tight_layout() + plt.savefig('gaiaplx'+file_ext+'.png') + + +def prepare_gaia_for_flystar(gaia, ra, dec, targets_dict=None, match_dr_max=0.2, pi_err_limit=0.4, default_motion_model='Linear'): """ Take a Gaia table (from astroquery) and produce a new table with a tangential projection - and shift such that the origin is centered on the target of interest. - Convert everything into arcseconds and name columns such that they are + and shift such that the origin is centered on the target of interest. + Convert everything into arcseconds and name columns such that they are ready for FlyStar input. Inputs @@ -79,7 +124,7 @@ def prepare_gaia_for_flystar(gaia, ra, dec, targets_dict=None, match_dr_max=0.2) target_coords = SkyCoord(ra, dec, unit=(u.hourangle, u.deg), frame='icrs') ra = target_coords.ra.degree # in decimal degrees dec = target_coords.dec.degree # in decimal degrees - + cos_dec = np.cos(np.radians(dec)) x = (gaia['ra'] - ra) * cos_dec * 3600.0 # arcsec y = (gaia['dec'] - dec) * 3600.0 # arcsec @@ -90,15 +135,15 @@ def prepare_gaia_for_flystar(gaia, ra, dec, targets_dict=None, match_dr_max=0.2) gaia_new['x0'] = x * -1.0 gaia_new['y0'] = y - gaia_new['x0e'] = xe - gaia_new['y0e'] = ye + gaia_new['x0_err'] = xe + gaia_new['y0_err'] = ye # Also convert the velocities. Note that Gaia PM are already * cos(dec) gaia_new['vx'] = gaia['pmra'].data * -1.0 / 1e3 # asec/yr gaia_new['vy'] = gaia['pmdec'].data / 1e3 - gaia_new['vxe'] = gaia['pmra_error'].data / 1e3 - gaia_new['vye'] = gaia['pmdec_error'].data / 1e3 - + gaia_new['vx_err'] = gaia['pmra_error'].data / 1e3 + gaia_new['vy_err'] = gaia['pmdec_error'].data / 1e3 + gaia_new['t0'] = gaia['ref_epoch'].data gaia_new['source_id'] = gaia['source_id'].data.astype('S19') @@ -106,40 +151,84 @@ def prepare_gaia_for_flystar(gaia, ra, dec, targets_dict=None, match_dr_max=0.2) idx = np.where(gaia['pmdec'].mask == True)[0] gaia_new['vx'][idx] = 0.0 gaia_new['vy'][idx] = 0.0 - gaia_new['vxe'][idx] = 0.0 - gaia_new['vye'][idx] = 0.0 - + gaia_new['vx_err'][idx] = 0.0 + gaia_new['vy_err'][idx] = 0.0 + gaia_new['m'] = gaia['phot_g_mean_mag'] gaia_new['me'] = 1.09/gaia['phot_g_mean_flux_over_error'] - gaia_new['parallax'] = gaia['parallax'] - gaia_new['parallax_error'] = gaia['parallax_error'] + gaia_new['pi'] = gaia['parallax'].data*1e-3 + gaia_new['pi_err'] = gaia['parallax_error'].data*1e-3 # Set the velocities (and uncertainties) to zero if they aren't measured. idx = np.where(np.isnan(gaia_new['vx']) == True)[0] gaia_new['vx'][idx] = 0.0 - gaia_new['vxe'][idx] = 0.0 + gaia_new['vx_err'][idx] = 0.0 gaia_new['vy'][idx] = 0.0 - gaia_new['vye'][idx] = 0.0 + gaia_new['vy_err'][idx] = 0.0 + + # Cut out stars with high plx error and set motion models + idx = np.where((gaia_new['pi_err']>(pi_err_limit/1e3)) | (gaia['parallax'].mask == True))[0] + gaia_new['pi'][idx] = 0.0 + gaia_new['pi_err'][idx] = 0.0 + if default_motion_model=='Parallax': + gaia_new['motion_model_input'] = 'Parallax' + gaia_new['motion_model_used'] = 'Parallax' + gaia_new['motion_model_used'][idx] = 'Linear' + gaia_new['n_params'] = 3 + gaia_new['n_params'][idx] = 2 + elif default_motion_model=='Linear': + gaia_new['motion_model_input'] = 'Linear' + gaia_new['motion_model_used'] = 'Linear' + gaia_new['n_params'] = 2 + elif default_motion_model=='Fixed': + gaia_new['motion_model_input'] = 'Fixed' + gaia_new['motion_model_used'] = 'Fixed' + gaia_new['n_params'] = 1 + elif default_motion_model=='Empty': + gaia_new['motion_model_input'] = 'Empty' + gaia_new['motion_model_used'] = 'Empty' + gaia_new['n_params'] = 0 + else: + print("Invalid motion model",default_motion_model,"- none assigned") + + #macy additions to try to fix wild magnitude values + #gaia_new['ruwe'] = gaia['ruwe'] + #try: + # gaia_new = gaia_new[~gaia_new['m'].mask] + #except: + # print('no invalig mags') gaia_new = gaia_new.filled() #convert masked colunms to regular columns if targets_dict != None: - for targ_name, targ_coo in targets_dict.items(): - dx = gaia_new['x0'] - (targ_coo[0] * -1.0) - dy = gaia_new['y0'] - targ_coo[1] +# for targ_name, targ_coo in targets_dict.items(): +# dx = gaia_new['x0'] - (targ_coo[0] * -1.0) +# dy = gaia_new['y0'] - targ_coo[1] +# dr = np.hypot(dx, dy) +# +# idx = dr.argmin() +# +# if dr[idx] < match_dr_max: +# gaia_new['name'][idx] = targ_name +# print('Found match for: ', targ_name, ' - ',gaia_new['source_id'][idx]) + targ_names = [x for x in targets_dict] + targ_xs = np.array([targets_dict[x][0] for x in targets_dict]) + targ_ys = np.array([targets_dict[x][1] for x in targets_dict]) + for i_gaia in range(len(gaia_new)): + dx = gaia_new['x0'][i_gaia] - (targ_xs * -1.0) + dy = gaia_new['y0'][i_gaia] - targ_ys dr = np.hypot(dx, dy) idx = dr.argmin() if dr[idx] < match_dr_max: - gaia_new['name'][idx] = targ_name - print('Found match for: ', targ_name) + gaia_new['name'][i_gaia] = targ_names[idx] + print('Found match for: ', targ_names[idx], ' - ',gaia_new['source_id'][i_gaia]) return gaia_new - def run_flystar(): - + test_file = '/u/jlu/work/microlens/OB150211/a_2018_10_19/a_ob150211_2018_10_19/lis/stars_matched2.fits' t = Table.read(test_file) @@ -171,39 +260,39 @@ def run_flystar(): ym_t = y0 + vy * (t - t0) # Model distorted positions - - + + return def project_gaia(gaia, epoch, ra, dec): """ Take the Gaia measurements, forward them in time, and then convert them into a tangential projection. - + Inputs ---------- epoch : float (year) The decimal year to project the measurement to. Note that we use 365.25 days per year. - + ra : float (deg) The right ascension (J2000) in decimal degrees of the center of the field. - + dec : float (deg) The declination (J2000) in decimal degrees of the center of the field. - + """ t0 = gaia['ref_epoch'] x0 = (gaia['ra'] - ra) * np.cos(np.radians(dec)) * 3600.0 # Arcsec y0 = (gaia['dec'] - dec) * 3600.0 x0e = gaia['ra_error'] / 1.0e3 # arcsec, already in alpha* (multiplied by cos(delta)) y0e = gaia['dec_error'] / 1.0e3 # arcsec - - + + vx = gaia['pmra'] / 1.0e3 # arcsec / yr - vy = gaia['pmdec'] / 1.0e3 + vy = gaia['pmdec'] / 1.0e3 vxe = gaia['pmra_error'] / 1.0e3 # arcsec / yr vye = gaia['pmdec_error'] / 1.0e3 - + # Modify any vx/vy, etc. that are zero and make a regular (unmasked) numpy array. vx[vx.mask] = 0.0 vy[vy.mask] = 0.0 @@ -213,29 +302,29 @@ def project_gaia(gaia, epoch, ra, dec): vy = np.array(vy) vxe = np.array(vxe) vye = np.array(vye) - + dt = epoch - t0 x_now = (x0 + (vx * dt)) * -1.0 # Switch to a left-handed coordinate system, like detector pixels. y_now = (y0 + (vy * dt)) xe_now = np.hypot(x0e, vxe*dt) ye_now = np.hypot(y0e, vye*dt) - + # Format as a starlist - gaia_lis = starlists.StarList(name=gaia['source_id'], + gaia_lis = starlists.StarList(name=gaia['source_id'], x=x_now, y=y_now, m=gaia['phot_g_mean_mag'], xe=xe_now, ye=ye_now, me=1.0/gaia['phot_g_mean_flux_over_error']) - + # Duplicate columns to 'x_avg', etc. Needed for initial guessing. gaia_lis['x_avg'] = gaia_lis['x'] gaia_lis['y_avg'] = gaia_lis['y'] - gaia_lis['m_avg'] = gaia_lis['m'] - + gaia_lis['m_avg'] = gaia_lis['m'] + return gaia_lis def rename_after_flystar(star_tab, label_dat_file, new_copy=True, dr_tol=0.05, dm_tol=0.3, verbose=False): """ - Take a StarTable output from FlyStar MosaicToRef that has been + Take a StarTable output from FlyStar MosaicToRef that has been aligned into R.A. and Dec. (usually by way of Gaia). Align the output to a label.dat file for this source and rename everything. @@ -263,25 +352,28 @@ def rename_after_flystar(star_tab, label_dat_file, new_copy=True, dr_tol=0.05, d x_lab[ndx_lab[ii]], star_tab['x0'][ndx_star[ii]], y_lab[ndx_lab[ii]], star_tab['y0'][ndx_star[ii]], m_lab[ndx_lab[ii]], star_tab['m0'][ndx_star[ii]])) - + print('Temporary shift transformations: ') print(' dm = {0:8.4f} +/- {1:8.4f}'.format(dm.mean(), dm.std())) print(' dx = {0:8.4f} +/- {1:8.4f}'.format(dx.mean(), dx.std())) print(' dy = {0:8.4f} +/- {1:8.4f}'.format(dy.mean(), dy.std())) - + m_lab = label_tab['m'] + dm.mean() x_lab += dx.mean() y_lab += dy.mean() - + # Now that we are in a common coordinate and magnitude # system, lets match the whole lists by coordinates. - idx_lab, idx_star, dr, dm = match.match(x_lab, y_lab, m_lab, + idx_lab, idx_star, dr, dm = match.match(x_lab, y_lab, m_lab, star_tab['x0'], star_tab['y0'], star_tab['m0'], dr_tol=dr_tol, dm_tol=dm_tol, verbose=verbose) + #print('idx_lab:') + #for iii in range(len(idx_lab)): + # print(label_tab["name"][idx_lab[iii]], star_tab["name"][idx_star[iii]]) print('Renaming {0:d} out of {1:d} stars'.format(len(idx_lab), len(star_tab))) - + # Make a copy of the table, UNLESS, the user specifies. if new_copy: new_tab = copy.deepcopy(star_tab) @@ -291,9 +383,9 @@ def rename_after_flystar(star_tab, label_dat_file, new_copy=True, dr_tol=0.05, d # copy over the original names... don't overwrite (this could mean data loss) if 'name_orig' not in new_tab.colnames: new_tab.add_column(Column(star_tab['name'].data, name='name_orig')) - + new_tab['name'][idx_star] = label_tab[idx_lab]['name'] - + return new_tab def pick_good_ref_stars(star_tab, r_cut=None, m_cut=None, p_err_cut=None, pm_err_cut=None, name_cut=None, reset=True): @@ -317,12 +409,12 @@ def pick_good_ref_stars(star_tab, r_cut=None, m_cut=None, p_err_cut=None, pm_err print('pick_good_ref_stars: Use {0:d} stars after m<{1:.2f}.'.format(use.sum(), m_cut)) if p_err_cut is not None: - p_err = np.mean((star_tab['x0e'], star_tab['y0e']), axis=0) + p_err = np.mean((star_tab['x0_err'], star_tab['y0_err']), axis=0) use = use & (p_err < p_err_cut) print('pick_good_ref_stars: Use {0:d} stars after p_err<{1:.5f}.'.format(use.sum(), p_err_cut)) if pm_err_cut is not None: - pm_err = np.mean((star_tab['vxe'], star_tab['vye']), axis=0) + pm_err = np.mean((star_tab['vx_err'], star_tab['vy_err']), axis=0) use = use & (pm_err < pm_err_cut) print('pick_good_ref_stars: Use {0:d} stars after pm_err<{1:.5f}.'.format(use.sum(), pm_err_cut)) @@ -338,44 +430,29 @@ def pick_good_ref_stars(star_tab, r_cut=None, m_cut=None, p_err_cut=None, pm_err def startable_subset(tab, idx, mag_trans=True, mag_trans_orig=False): """ - Input is MosaicToRef table from alignment of multiple filters, + Input is MosaicToRef table from alignment of multiple filters, such that the astrometry is combined but the photometry is not. - This function is used to separate out a selected filter from the + This function is used to separate out a selected filter from the combined astrometry + uncombined photometry table. """ # Multiples: ['x', 'y', 'm', 'name_in_list', 'xe', 'ye', 'me', 't', - # 'x_orig', 'y_orig', 'm_orig', 'xe_orig', 'ye_orig', 'me_orig', 'used_in_trans'] - # Single: ['name', 'm0', 'm0e', 'use_in_trans', 'ref_orig', 'n_detect', - # 'x0', 'vx', 'y0', 'vy', 'x0e', 'vxe', 'y0e', 'vye', 't0'] + # 'x_orig', 'y_orig', 'm_orig', 'xe_orig', 'ye_orig', 'me_orig', 'used_in_trans', + # 'xe_boot','ye_boot','me_boot'] + # Single: ['name', 'm0', 'm0_err', 'use_in_trans', 'ref_orig', 'n_detect', + # 'x0', 'vx', 'y0', 'vy', 'x0_err', 'vx_err', 'y0_err', 'vy_err', 't0'] # Don't include n_vfit - new_tab = startables.StarTable(name=tab['name'].data, - x=tab['x'][:,idx].data, - y=tab['y'][:,idx].data, - m=tab['m'][:,idx].data, - xe=tab['xe'][:,idx].data, - ye=tab['ye'][:,idx].data, - me=tab['me'][:,idx].data, - t=tab['t'][:,idx].data, - x_orig=tab['x_orig'][:,idx].data, - y_orig=tab['y_orig'][:,idx].data, - m_orig=tab['m_orig'][:,idx].data, - xe_orig=tab['xe_orig'][:,idx].data, - ye_orig=tab['ye_orig'][:,idx].data, - me_orig=tab['me_orig'][:,idx].data, - used_in_trans=tab['used_in_trans'][:,idx].data, - m0=tab['m0'].data, - m0e=tab['m0e'].data, - use_in_trans=tab['use_in_trans'].data, - x0=tab['x0'].data, - vx=tab['vx'].data, - y0=tab['y0'].data, - vy=tab['vy'].data, - x0e=tab['x0e'].data, - vxe=tab['vxe'].data, - y0e=tab['y0e'].data, - vye=tab['vye'].data, - t0=tab['t0'].data) + new_tab = copy.deepcopy(tab) + #new_tab.remove_column('n_fit') + new_tab.remove_column('n_detect') + for col in ['x','y','m','idx_in_list','name_in_list','xe','ye','me','t', + 'x_orig','y_orig','m_orig','xe_orig','ye_orig','me_orig', + 'used_in_trans','xe_boot','ye_boot','me_boot']: + # 'idx_in_list'/'name_in_list' are alternatives (the latter only on + # tables written before the switch to indices), and several of the + # rest are optional, so slice whichever are actually present. + if col in tab.colnames: + new_tab[col] = tab[col][:,idx] new_tab.combine_lists('m', weights_col='me', sigma=3, ismag=True) @@ -392,7 +469,7 @@ def startable_subset(tab, idx, mag_trans=True, mag_trans_orig=False): # Update the original table. if mag_trans_orig: tab['m'][:,idx[ii]] += mag_offset - + return new_tab @@ -400,123 +477,58 @@ def startable_subset(tab, idx, mag_trans=True, mag_trans_orig=False): # Old codes. ################################################## -def calc_chi2(ref_mat, starlist_mat, transform, errs='both'): - """ - calculate the chi2 and reduced chi2 of the position - between two matched starlists. - Input: - ref_mat: astropy table - Reference starlist only containing matched stars that were used in the - transformation. Standard column headers are assumed. - - starlist_mat: astropy table - Transformed starlist only containing the matched stars used in - the transformation. Standard column headers are assumed. - - transform: transformation object - Transformation object of final transform. Used in chi-square - determination - - errs: string; 'both', 'reference', or 'starlist' - If both, add starlist errors in quadrature with reference errors. - - If reference, only consider reference errors. This should be used if the starlist - does not have valid errors - - If starlist, only consider starlist errors. This should be used if the reference - does not have valid errors - - Output: - chi_sq: float - chi2 = sum (diff_x**2 / xerr**2 + diff_y**2 /yerr**2) - chi_sq_red: float - reduced chi2 = chi2/ degree of freedom - deg_freedom: int - degree of freedom - - """ - diff_x = ref_mat['x'] - starlist_mat['x'] - diff_y = ref_mat['y'] - starlist_mat['y'] - - # Set errors as per user input - if errs == 'both': - xerr = np.hypot(ref_mat['xe'], starlist_mat['xe']) - yerr = np.hypot(ref_mat['ye'], starlist_mat['ye']) - elif errs == 'reference': - xerr = ref_mat['xe'] - yerr = ref_mat['ye'] - elif errs == 'starlist': - xerr = starlist_mat['xe'] - yerr = starlist_mat['ye'] - - - # For both X and Y, calculate chi-square. Combine arrays to get combined - # chi-square - chi_sq_x = diff_x**2. / xerr**2. - chi_sq_y = diff_y**2. / yerr**2. - - chi_sq = np.append(chi_sq_x, chi_sq_y) - - # Calculate degrees of freedom in transformation - num_mod_params = calc_nparam(transform) - deg_freedom = len(chi_sq) - num_mod_params - - # Calculate reduced chi-square - chi_sq = np.sum(chi_sq) - chi_sq_red = chi_sq / deg_freedom - - return chi_sq, chi_sq_red, deg_freedom - - -def calc_nparam(transformation): - """ - calculate the degree of freedom for a transformation - """ - # Read transformation: Extract X, Y coefficients from transform - if transformation.__class__.__name__ == 'four_paramNW': - nparam = 4 - elif transformation.__class__.__name__ == 'PolyTransform': - order = transformation.order - nparam = (order+1) * (order+2) - return nparam - def calc_F(red_chi2_1, red_chi2_2, v1, v2): """ compare two different models to get the proper polynomial fitting order - Input: - red_chi2_1: reduced chi2 for the first model - red_chi2_2: reduced chi2 for the second model - v1 = degree of freedom for the first model - = 2*(N_star_matched) - model_parameters - v2 = degree of freedom for the second mdoel + Parameters + ---------- + red_chi2_1 : float + reduced chi2 for the first model + red_chi2_2 : float + reduced chi2 for the second model + v1 : int + degree of freedom for the first model, + ``= 2*(N_star_matched) - model_parameters`` + v2 : int + degree of freedom for the second mdoel + + Returns + ------- + P : float + The probability that the first model is better + + Notes + ----- + - make sure the first model is the simple model + and the second model is the more complicated model + - the return value represents the probability that + the first model is better than the second model, in other words, + the small P means the more colicated model is needed. + the large P means the simple model is good enough. + - normally, the P value will increase from model1->model2, to + model2->model3, to model3->model4. The user can decide a + critical value (eg, 0.7) to find the proper model. - Output: - P: The probability that the first model is better + Examples + -------- + For 1st order polynomial fitting:: - Example: - for 1st order polynomial fitting: x' = a0 + a1*x + a2*y y' = b0 + b1*x + b2*y - v1 = 2*N1 - 2*3 (2*: because x and y direction) + v1 = 2*N1 - 2*3 (2*: because x and y direction) red_chi2_1 = chi2/v1 - for 2nd order polynomial fitting: + + For 2nd order polynomial fitting:: + x' = a0 + a1*x + a2*y + a3*x**2 + a4*y**2 + a5*x*y y' = b0 + b1*x + b2*y + b3*x**2 + b4*y**2 + b5*x*y - v1 = 2*N1 - 2*6 + v1 = 2*N1 - 2*6 red_chi2_2 = chi2/v2 - calc_F(red_chi2_1, red_chi2_2, v1, v2) - - ***Note*** - * make sure the first model is the simple model - and the second model is the more complicated model - * the return value represents the probability that - the first model is better than the second model, in other words, - the small P means the more colicated model is needed. - the large P means the simple model is good enough. - * normally, the P value will increase from model1->model2, to - model2->model3, to model3->model4. The user can decide a - critical value (eg, 0.7) to find the proper model. + + then:: + + calc_F(red_chi2_1, red_chi2_2, v1, v2) """ f_value = red_chi2_1/red_chi2_2 diff --git a/flystar/conftest.py b/flystar/conftest.py index 672b273..da164b5 100644 --- a/flystar/conftest.py +++ b/flystar/conftest.py @@ -31,9 +31,9 @@ def pytest_configure(config): PYTEST_HEADER_MODULES.pop('Pandas', None) PYTEST_HEADER_MODULES['scikit-image'] = 'skimage' - from . import __version__ + #from . import __version__ packagename = os.path.basename(os.path.dirname(__file__)) - TESTED_VERSIONS[packagename] = __version__ + #TESTED_VERSIONS[packagename] = __version__ # Uncomment the last two lines in this block to treat all DeprecationWarnings as # exceptions. For Astropy v2.0 or later, there are 2 additional keywords, diff --git a/flystar/examples.py b/flystar/examples.py index 8059562..16d893a 100644 --- a/flystar/examples.py +++ b/flystar/examples.py @@ -1,11 +1,5 @@ -from flystar import transforms -from flystar import match -from flystar import align -from flystar import starlists -from flystar import plots import numpy as np -import copy -import pdb +from flystar import transforms, match, align, starlists, plots def align_example(labelFile, reference, transModel=transforms.four_paramNW, order=1, N_loop=2, @@ -38,7 +32,7 @@ def align_example(labelFile, reference, transModel=transforms.four_paramNW, orde dr_tol: float (default = 1.0) The search radius for the matching algorithm, in the same units as the starlist file positions. - + dm_tol: float or None If float, sets the maximum magnitude difference allowed in matching between label.dat and starlist. Note that this should be set to @@ -54,10 +48,10 @@ def align_example(labelFile, reference, transModel=transforms.four_paramNW, orde outFile: string (default = 'outTrans.txt') Name of output ascii file which contains the transform parameters. - - Output: + + Output ------ - + """ # Read in label.dat file and reference starlist, changing columns to their # standard column headers/epochs/orientations @@ -72,7 +66,7 @@ def align_example(labelFile, reference, transModel=transforms.four_paramNW, orde # Apply intial transformation to label.dat (for error weighting purposes below) label_trans = align.transform_from_object(label, trans) - + # Use transformation to match starlists, then recalculate transformation. # Iterate on this as many times as desired for i in range(N_loop): @@ -80,10 +74,10 @@ def align_example(labelFile, reference, transModel=transforms.four_paramNW, orde trans, dr_tol=dr_tol, dm_tol=dm_tol) - + trans, N_trans = align.find_transform(label[idx_label], label_trans[idx_label], - starlist_mat[idx_starlist], + starlist[idx_starlist], transModel=transModel, order=order, weights=weights) @@ -91,14 +85,14 @@ def align_example(labelFile, reference, transModel=transforms.four_paramNW, orde # Write final transform in java align format print('Write transform to {0}'.format(outFile)) align.write_transform(trans, labelFile, reference, N_trans, outFile=outFile) - + # Test transform: apply final transformation to label.dat label_trans2 = align.transform(label, outFile) # Make diagnostic plots - + return - + def align_Arches(labelFile, reference, transModel=transforms.four_paramNW, order=1, N_loop=2, dr_tol=1.0, dm_tol=None, briteN=100, weights=None, restrict=False, @@ -131,7 +125,7 @@ def align_Arches(labelFile, reference, transModel=transforms.four_paramNW, order dr_tol: float (default = 1.0) The search radius for the matching algorithm, in the same units as the starlist file positions. - + dm_tol: float or None (default = None) If float, sets the maximum magnitude difference allowed in matching between label.dat and starlist. Note that this should be set to @@ -143,8 +137,8 @@ def align_Arches(labelFile, reference, transModel=transforms.four_paramNW, order weights: string (default=None) if weights=='both', we use both position error and velocity error in transformed - starlist and reference starlist as uncertanties. And weights is the reciprocal - of this uncertanty. + starlist and reference starlist as uncertanties. And weights is the reciprocal + of this uncertanty. if weights=='starlist', we only use postion error and velocity error in transformed starlist as uncertainty. if weights=='reference', we only use position error in reference starlist as uncertainty. @@ -156,8 +150,8 @@ def align_Arches(labelFile, reference, transModel=transforms.four_paramNW, order outFile: string (default = 'outTrans.txt') Name of output ascii file which contains the transform parameters. - - Output: + + Output ------ outFile is written containing the tranformation coefficients @@ -170,11 +164,11 @@ def align_Arches(labelFile, reference, transModel=transforms.four_paramNW, order positions and the label.dat positions after transformation. -Positions_quiver.png: Quiver plot showing the difference between reference - positions and transformed label.dat positions as a function of location. - + positions and transformed label.dat positions as a function of location. + -Magnitude_hist.png: Histogram of the difference between the reference list magnitude and label.dat magnitude for matched stars. - + """ # Read in label.dat file and reference starlist, changing columns to their # standard column headers/epochs/orientations @@ -192,10 +186,10 @@ def align_Arches(labelFile, reference, transModel=transforms.four_paramNW, order # Perform blind matching of 100 brightest stars and calculate initial transform trans = align.initial_align(label_r, starlist, briteN, transformModel=transModel, order=order) - + # Apply transformation to label.dat file, for weighting purposes. label_trans = align.transform_from_object(label, trans) - + # Use transformation to match starlists, then recalculate transformation. # Iterate on this as many times as desired for i in range(N_loop): @@ -223,7 +217,7 @@ def align_Arches(labelFile, reference, transModel=transforms.four_paramNW, order print('Write transform to {0}'.format(outFile)) align.write_transform(trans, labelFile, reference, N_trans, deltaMag=delta_m, restrict=restrict, weights=weights, outFile=outFile) - + # Test transform: apply to label.dat, make diagnostic plots label_trans2 = align.transform_from_file(label, outFile) @@ -241,7 +235,7 @@ def align_Arches(labelFile, reference, transModel=transforms.four_paramNW, order label_trans2[idx_label], xlim=xlim, ylim=ylim) # Histogram of difference in transformed and reference positions for - # matched stars + # matched stars plots.pos_diff_hist(starlist[idx_starlist], label_trans2[idx_label]) # Histogram of difference in transformed and reference positions for @@ -250,7 +244,7 @@ def align_Arches(labelFile, reference, transModel=transforms.four_paramNW, order plots.pos_diff_err_hist(starlist[idx_starlist], label_trans2[idx_label], trans, errs='both', bin_width=0.5, xlim=[-6,6]) - # Histogram of difference in the magnitudes for the matched stars + # Histogram of difference in the magnitudes for the matched stars plots.mag_diff_hist(starlist[idx_starlist], label_trans2[idx_label]) # Quiver plot showing difference between transformed and reference @@ -260,7 +254,7 @@ def align_Arches(labelFile, reference, transModel=transforms.four_paramNW, order ylim=ylim, outlier_reject=None) print('Done with plots') - print('Done with plots') + print('Done with plots') return @@ -274,7 +268,7 @@ def align_gc(starFile, refFile, transModel=transforms.PolyTransform, order=1, N_ Parameters: ----------- starFile: string - Starlist we would like to transform into the reference frame, eg:label.dat + Starlist we would like to transform into the reference frame, eg:label.dat refFile: string Starlist that defines the reference frame. @@ -299,7 +293,7 @@ def align_gc(starFile, refFile, transModel=transforms.PolyTransform, order=1, N_ weights: string (default='both') if weights=='both', we use both position error in transformed starlist and - reference starlist as uncertanty. And weights is the reciprocal of this uncertanty. + reference starlist as uncertanty. And weights is the reciprocal of this uncertanty. if weights=='starlist', we only use postion error in transformed starlist. if weights=='reference', we only use position error in reference starlist. if weights==None, we don't use weights. @@ -312,7 +306,7 @@ def align_gc(starFile, refFile, transModel=transforms.PolyTransform, order=1, N_ """ #---------------------------------------------- - # Read in starlist and reference + # Read in starlist and reference #---------------------------------------------- # starlist has postion & postion err ref = starlists.read_starlist(refFile, error=True) @@ -400,7 +394,7 @@ def align_starlists(starlist, ref, transModel=transforms.PolyTransform, order=2, Parameters: ----------- starlist: Table - Starlist we would like to transform into the reference frame, eg:label.dat + Starlist we would like to transform into the reference frame, eg:label.dat ref: Table Starlist that defines the reference frame. @@ -425,7 +419,7 @@ def align_starlists(starlist, ref, transModel=transforms.PolyTransform, order=2, weights: string (default='both') if weights=='both', we use both position error in transformed starlist and - reference starlist as uncertanty. And weights is the reciprocal of this uncertanty. + reference starlist as uncertanty. And weights is the reciprocal of this uncertanty. if weights=='starlist', we only use postion error in transformed starlist. if weights=='reference', we only use position error in reference starlist. if weights==None, we don't use weights. @@ -433,7 +427,7 @@ def align_starlists(starlist, ref, transModel=transforms.PolyTransform, order=2, outFile: string('outTrans.txt') the name of the output transformation file """ - + #-------------------------------------------------- # Initial transformation with brightest briteN stars #-------------------------------------------------- diff --git a/flystar/fit_velocity.py b/flystar/fit_velocity.py deleted file mode 100755 index 0317322..0000000 --- a/flystar/fit_velocity.py +++ /dev/null @@ -1,205 +0,0 @@ -from tqdm import tqdm -import numpy as np -import pandas as pd - -def linear(x, k, b): - return k*x + b - -def linear_fit(x, y, sigma=None, absolute_sigma=True): - """Weighted linear regression (See https://en.wikipedia.org/wiki/Weighted_least_squares#Solution). Recommended for low-dimension, non-degenerate data. Otherwise, please use scipy.curve_fit. - - Parameters - ---------- - x : array-like - x data - y : array-like - y data - sigma : array-like, optional - Weighted by 1/sigma**2. If not provided, weight = 1, by default None - absolute_sigma : bool, optional - If True (default), sigma is used in an absolute sense and the estimated parameter uncertainty reflects these absolute values. If False, only the relative magnitudes of the sigma values matter, by default True - - Returns - ------- - result : dictionary - Dictionary with keys 'slope', 'e_slope', 'intercept', 'e_intercept', and 'chi2' if return_chi2=True. - """ - x = np.array(x) - y = np.array(y) - if sigma is None: - sigma = np.ones_like(x) - else: - sigma = np.array(sigma) - - X = np.vander(x, 2) - W = np.diag(1/sigma**2) - XTWX = X.T @ W @ X - pcov = np.linalg.inv(XTWX) # Covariance Matrix - popt = pcov @ X.T @ W @ y # Linear Solution - perr = np.sqrt(np.diag(pcov)) # Uncertainty of Linear Solution - - residual = y - X @ popt - chi2 = residual.T @ W @ residual - - if not absolute_sigma: - reduced_chi2 = chi2/(len(x) - 2) - perr *= reduced_chi2**0.5 - - result = { - 'slope': popt[0], - 'intercept': popt[1], - 'e_slope': perr[0], - 'e_intercept': perr[1], - 'chi2': chi2 - } - - return result - - -def calc_chi2(x, y, sigma, slope, intercept): - popt = np.array([slope, intercept]) - X = np.vander(x, 2) - W = np.diag(1/sigma**2) - residual = y - X @ popt - return residual.T @ W @ residual - - -def fit_velocity(startable, weighting='var', use_scipy=False, absolute_sigma=True, epoch_cols='all', art_star=False): - """Fit proper motion with weighted linear regression equations (see https://en.wikipedia.org/wiki/Weighted_least_squares#Solution). - Assumes that all data are valid. - - Parameters - ---------- - startable : StarTable - StarTable object - weighting : str, optional - Weighting by variance (1/ye**2) or standard deviation (1/ye), by default 'var' - use_scipy : bool, optional - Use scipy.curve_fit or flystar.fit_velocity.linear_fit, by default False - absolute_sigma : bool, optional - If True (default), sigma is used in an absolute sense and the estimated parameter uncertainty reflects these absolute values. If False, only the relative magnitudes of the sigma values matter, by default True - epoch_cols : str or list of intergers, optional - List of indicies of columns to use. If 'all', use all columns, by default 'all' - art_star : bool, optional - Artificial star catalog or not. If True, use startable['x'][:, epoch_ols, 1] as the location, by default False. - - Returns - ------- - result : pd.DataFrame - Proper motion dataframe with keys vx, vxe, vy, vye, x0, x0e, y0, y0e - - Raises - ------ - ValueError - If weighting is neither 'std' nor 'var' - """ - if weighting not in ['std', 'var']: - raise ValueError(f"Weighting must be either 'std' or 'var', not '{weighting}'.") - if epoch_cols is None: - epoch_cols = np.arange(len(startable.meta['YEARS'])) # use all cols if not specified - - N = len(startable) - vx = np.zeros(N) - vy = np.zeros(N) - vxe = np.zeros(N) - vye = np.zeros(N) - x0 = np.zeros(N) - y0 = np.zeros(N) - x0e = np.zeros(N) - y0e = np.zeros(N) - chi2_vx = np.zeros(N) - chi2_vy = np.zeros(N) - t0 = np.zeros(N) - - time = np.array(startable.meta['YEARS'])[epoch_cols] - - if not art_star: - x_arr = startable['x'][:, epoch_cols] - y_arr = startable['y'][:, epoch_cols] - else: - x_arr = startable['x'][:, epoch_cols, 1] - y_arr = startable['y'][:, epoch_cols, 1] - - xe_arr = startable['xe'][:, epoch_cols] - ye_arr = startable['ye'][:, epoch_cols] - - if weighting=='std': - sigma_x_arr = np.abs(xe_arr)**0.5 - sigma_y_arr = np.abs(ye_arr)**0.5 - elif weighting=='var': - sigma_x_arr = xe_arr - sigma_y_arr = ye_arr - - # For each star - for i in tqdm(range(len(startable))): - x = x_arr[i] - y = y_arr[i] - xe = xe_arr[i] - ye = ye_arr[i] - sigma_x = sigma_x_arr[i] - sigma_y = sigma_y_arr[i] - - t_weight = 1. / np.hypot(xe, ye) - t0[i] = np.average(time, weights=t_weight) - dt = time - t0[i] - - if use_scipy: - p0x = np.array([0., x.mean()]) - p0y = np.array([0., y.mean()]) - - # Use scipy.curve_fit to fit for velocity - vx_opt, vx_cov = curve_fit(linear, dt, x, p0=p0x, sigma=sigma_x, absolute_sigma=absolute_sigma) - vy_opt, vy_cov = curve_fit(linear, dt, y, p0=p0y, sigma=sigma_y, absolute_sigma=absolute_sigma) - - vx[i] = vx_opt[0] - vy[i] = vy_opt[0] - x0[i] = vx_opt[1] - y0[i] = vy_opt[1] - vxe[i], x0e[i] = np.sqrt(vx_cov.diagonal()) - vye[i], y0e[i] = np.sqrt(vy_cov.diagonal()) - chi2_vx[i] = calc_chi2(dt, x, sigma_x, *vx_opt) - chi2_vy[i] = calc_chi2(dt, y, sigma_y, *vy_opt) - - else: - vx_result = linear_fit(dt, x, sigma=sigma_x, absolute_sigma=absolute_sigma) - vy_result = linear_fit(dt, y, sigma=sigma_y, absolute_sigma=absolute_sigma) - - vx[i] = vx_result['slope'] - vxe[i] = vx_result['e_slope'] - x0[i] = vx_result['intercept'] - x0e[i] = vx_result['e_intercept'] - chi2_vx[i] = vx_result['chi2'] - - vy[i] = vy_result['slope'] - vye[i] = vy_result['e_slope'] - y0[i] = vy_result['intercept'] - y0e[i] = vy_result['e_intercept'] - chi2_vy[i] = vy_result['chi2'] - - result = pd.DataFrame({ - 'vx': vx, 'vy': vy, - 'vxe': vxe, 'vye': vye, - 'x0': x0, 'y0': y0, - 'x0e': x0e, 'y0e': y0e, - 'chi2_vx': chi2_vx, - 'chi2_vy': chi2_vy, - 't0': t0 - }) - return result - - -# Test -if __name__=='__main__': - from scipy.optimize import curve_fit - - x = np.array([1,2,3,4]) - y = np.array([1,2,5,6]) - sigma = np.array([.4,.2,.1,.3]) - - for absolute_sigma in [True, False]: - result = linear_fit(x, y, sigma=sigma, absolute_sigma=absolute_sigma) - popt, pcov = curve_fit(linear, x, y, sigma=sigma, absolute_sigma=absolute_sigma) - perr = np.sqrt(np.diag(pcov)) - print(f'Absolute Sigma = {absolute_sigma}') - print(f"linear_fit: slope = {result['slope']:.3f} ± {result['e_slope']:.3f}, intercept = {result['intercept']:.3f} ± {result['e_intercept']:.3f}, chi2={result['chi2']:.3f}") - print(f'curve_fit: slope = {popt[0]:.3f} ± {perr[0]:.3f}, intercept = {popt[1]:.3f} ± {perr[1]:.3f}, chi2={calc_chi2(x, y, sigma, *popt):.3f}\n') \ No newline at end of file diff --git a/flystar/archive_io.py b/flystar/io.py similarity index 77% rename from flystar/archive_io.py rename to flystar/io.py index 88de5cb..2177e40 100755 --- a/flystar/archive_io.py +++ b/flystar/io.py @@ -1,9 +1,9 @@ import pickle -# Need to add these functions to a utility .py file rather than storing them in general structure. +# Need to add these functions to a utility .py file rather than storing them in general structure. def open_archive(file_name): """ - Helper function to open archived files. + Helper function to open archived files. """ with open(file_name, 'rb') as file_archive: file_dict = pickle.load(file_archive) @@ -11,7 +11,7 @@ def open_archive(file_name): def save_archive(file_name, save_data): """ - Helper function to archive a file. + Helper function to archive a file. """ with open(file_name, 'wb') as outfile: pickle.dump(save_data, outfile, protocol=pickle.HIGHEST_PROTOCOL) diff --git a/flystar/match.py b/flystar/match.py index e989579..f3818ca 100644 --- a/flystar/match.py +++ b/flystar/match.py @@ -1,21 +1,14 @@ +import itertools import numpy as np -from flystar import starlists, transforms, startables, align from collections import Counter -from scipy.spatial import cKDTree as KDT -from astropy.table import Column, Table -import itertools -import copy -import scipy.signal -from scipy.spatial import distance -import math -import pdb +from scipy.spatial import KDTree as KDT def miracle_match_briteN(xin1, yin1, min1, xin2, yin2, min2, Nbrite, - Nbins_vmax=200, Nbins_angle=360,verbose=False): + polygon1=None, polygon2=None, buffer=0, Nbins_vmax=200, Nbins_angle=360,verbose=False): """ Take two input starlists and select the brightest stars from - each. Then performa a triangle matching algorithm along the lines of + each. Then perform a triangle matching algorithm along the lines of Groth 1986. For every possible triangle (combination of 3 stars) in a starlist, @@ -30,23 +23,115 @@ def miracle_match_briteN(xin1, yin1, min1, xin2, yin2, min2, Nbrite, and brightness uncertainties, the more bigger the bin sizes should really be. But this isn't well tested. """ - + if verbose: print( '') print( ' miracle_match_briteN: use brightest {0}'.format(Nbrite)) print( ' miracle_match_briteN: ') print( ' miracle_match_briteN: ') + xin1 = np.array(xin1) + yin1 = np.array(yin1) + min1 = np.array(min1) + xin2 = np.array(xin2) + yin2 = np.array(yin2) + min2 = np.array(min2) + + if (polygon1 is not None) and (polygon2 is not None): + import shapely + points1 = shapely.points(xin1, yin1) + points2 = shapely.points(xin2, yin2) + overlap = polygon1.intersection(polygon2).buffer(buffer) + in_poly1 = shapely.contains(overlap, points1) + in_poly2 = shapely.contains(overlap, points2) + xin1 = xin1[in_poly1] + yin1 = yin1[in_poly1] + min1 = min1[in_poly1] + xin2 = xin2[in_poly2] + yin2 = yin2[in_poly2] + min2 = min2[in_poly2] + # else: + # # Only look for matches within overlapping minimum-bounding-boxes of the 2 lists + # valid1 = (np.isfinite(xin1)) & (np.isfinite(yin1)) & (np.isfinite(min1)) + # valid2 = (np.isfinite(xin2)) & (np.isfinite(yin2)) & (np.isfinite(min2)) + # if (sum(valid1) < Nbrite) or (sum(valid2) < Nbrite): + # raise ValueError( + # f'Not enough valid stars to find matches! Need at least {Nbrite} valid stars.\n' + + # f'Valid stars in list 1: {sum(valid1)}\n' + + # f'Valid stars in list 2: {sum(valid2)}\n' + # ) + + # xin1 = xin1[valid1] + # yin1 = yin1[valid1] + # min1 = min1[valid1] + # xin2 = xin2[valid2] + # yin2 = yin2[valid2] + # min2 = min2[valid2] + + # xmin1, xmax1 = np.min(xin1), np.max(xin1) + # ymin1, ymax1 = np.min(yin1), np.max(yin1) + # xmin2, xmax2 = np.min(xin2), np.max(xin2) + # ymin2, ymax2 = np.min(yin2), np.max(yin2) + + # # Find the overlapping minimum bounding box + # x_overlap = (max(xmin1, xmin2), min(xmax1, xmax2)) + # y_overlap = (max(ymin1, ymin2), min(ymax1, ymax2)) + # if x_overlap[0] >= x_overlap[1] or y_overlap[0] >= y_overlap[1]: + # fig, ax = plt.subplots() + # ax.scatter(xin1, yin1, s=1, label='List 1') + # ax.scatter(xin2, yin2, s=1, label='List 2') + # ax.set_aspect('equal') + # ax.legend() + # plt.show() + # raise ValueError('The two star lists do not have an overlapping region!') + + # # Select overlapping regions + # in_overlap1 = (xin1 >= x_overlap[0]) & (xin1 <= x_overlap[1]) & (yin1 >= y_overlap[0]) & (yin1 <= y_overlap[1]) + # in_overlap2 = (xin2 >= x_overlap[0]) & (xin2 <= x_overlap[1]) & (yin2 >= y_overlap[0]) & (yin2 <= y_overlap[1]) + # if sum(in_overlap1) < Nbrite or sum(in_overlap2) < Nbrite: + # raise ValueError( + # 'Not enough stars in the overlapping region to find matches!\n' + + # f'Stars in overlap for list 1: {sum(in_overlap1)}\n' + + # f'Stars in overlap for list 2: {sum(in_overlap2)}\n' + # ) + + # from matplotlib.patches import Rectangle + # fig, ax = plt.subplots() + # polygon1 = Rectangle((xmin1, ymin1), xmax1-xmin1, ymax1-ymin1, fill=True, edgecolor='C0', facecolor='C0', alpha=0.5, label='MBB List 1') + # polygon2 = Rectangle((xmin2, ymin2), xmax2-xmin2, ymax2-ymin2, fill=True, edgecolor='C2', facecolor='C2', alpha=0.5, label='MBB List 2') + # polygon_overlap = Rectangle((x_overlap[0], y_overlap[0]), x_overlap[1]-x_overlap[0], y_overlap[1]-y_overlap[0], fill=True, edgecolor='red', facecolor='C3', alpha=0.5, label='Overlap Region') + # ax.scatter(xin1, yin1, s=1, label='List 1') + # ax.scatter(xin2, yin2, s=1, label='List 2') + # ax.add_patch(polygon1) + # ax.add_patch(polygon2) + # ax.add_patch(polygon_overlap) + # ax.set_aspect('equal') + # ax.legend() + # plt.show() + + # xin1 = xin1[in_overlap1] + # yin1 = yin1[in_overlap1] + # min1 = min1[in_overlap1] + # xin2 = xin2[in_overlap2] + # yin2 = yin2[in_overlap2] + # min2 = min2[in_overlap2] + # Get/check the lengths of the two starlists nin1 = len(xin1) nin2 = len(xin2) if (nin1 < Nbrite) or (nin2 < Nbrite): - print(( 'You need at least {0} to '.format(Nbrite))) - print( 'find the matches...') - print(( 'NIN1: ', nin1)) - print(( 'NIN2: ', nin2)) - return (0, None, None, None, None, None, None) + raise ValueError( + f'Not enough stars in the overlapping region to find matches! Need at least {Nbrite} valid stars.\n' + + f'Stars in overlap for list 1: {nin1}\n' + + f'Stars in overlap for list 2: {nin2}\n' + ) + # print(f'WARNING: You need at least {Nbrite} to find the matches...') + # print(f'NIN1: {nin1}') + # print(f'NIN2: {nin2}') + # # Nbrite = min(nin1, nin2) + # # print(f'Updating Nbrite to {Nbrite}...') + # return (0, None, None, None, None, None, None) # Take the Nbrite brightest stars from each list and order by brightness. if verbose: @@ -55,7 +140,7 @@ def miracle_match_briteN(xin1, yin1, min1, xin2, yin2, min2, Nbrite, print( ' miracle_match_briteN: ') x1, y1, m1 = order_by_brite(xin1, yin1, min1, Nbrite, verbose=verbose) x2, y2, m2 = order_by_brite(xin2, yin2, min2, Nbrite, verbose=verbose) - + #################### # # Triangle Matching @@ -111,7 +196,6 @@ def miracle_match_briteN(xin1, yin1, min1, xin2, yin2, min2, Nbrite, idx2_vmax_hist = idx2_vmax_hist[good_idx2] idx2_angl_hist = idx2_angl_hist[good_idx2] - ########## # Possible Matches ########## @@ -125,7 +209,7 @@ def miracle_match_briteN(xin1, yin1, min1, xin2, yin2, min2, Nbrite, # Now vote for all stars in the triangles that have possible matches (same vmax, angle) # between the first and second lists. votes = np.zeros((Nbrite, Nbrite)) - + matches = np.where(stars_in1_matches2[:,0] >= 0)[0] match_stars1 = stars_in1_matches2[matches,:] match_stars2 = stars_in_tri2[matches,:] @@ -138,7 +222,7 @@ def miracle_match_briteN(xin1, yin1, min1, xin2, yin2, min2, Nbrite, add_votes(votes, match_stars1[:,0], match_stars2[:,0]) add_votes(votes, match_stars1[:,1], match_stars2[:,1]) add_votes(votes, match_stars1[:,2], match_stars2[:,2]) - + ########## # Find matching triangles with most votes (and that pass threshold) ########## @@ -166,7 +250,6 @@ def miracle_match_briteN(xin1, yin1, min1, xin2, yin2, min2, Nbrite, x1_mat = x1[votes_sdx[0, good]] y1_mat = y1[votes_sdx[0, good]] m1_mat = m1[votes_sdx[0, good]] - return len(x1_mat), x1_mat, y1_mat, m1_mat, x2_mat, y2_mat, m2_mat @@ -196,7 +279,296 @@ def order_by_brite(xi, yi, mi, Nout, verbose=True): return xo, yo, mo -def match(x1, y1, m1, x2, y2, m2, dr_tol, dm_tol=None, verbose=True): +def robust_sigma(values): + """ + Gaussian-consistent robust scatter, 1.4826 * median absolute deviation. + + Used to measure the real offset scatter between two matched catalogs + without trusting any per-star error columns. NaNs are ignored; returns NaN + if nothing finite is left. + """ + v = np.asarray(values, dtype=float) + v = v[np.isfinite(v)] + + if len(v) == 0: + return np.nan + + return 1.4826 * np.median(np.abs(v - np.median(v))) + + +def best_and_runner_up(keys, score, n_keys): + """ + For each key, find its lowest-scoring pair and the score of the runner-up. + + Parameters + ---------- + keys : int array + Group label of every candidate pair -- the catalog-1 index when ranking + each list star's candidates, the catalog-2 index when ranking each + reference star's suitors. + score : float array + The score (chi^2) of every candidate pair. Same length as keys. + n_keys : int + Size of the catalog the keys index into, so the returned arrays can be + addressed directly by catalog index. + + Returns + ---------- + best_pair : int array, length n_keys + Index into keys/score of each key's best pair, or -1 if the key has no + candidate pairs at all. + delta : float array, length n_keys + score(runner-up) - score(best) for each key: how decisively the best + pair wins. inf when the key has exactly one candidate (nothing to be + confused with), and 0.0 for keys with no candidates. + """ + best_pair = np.full(n_keys, -1, dtype=int) + delta = np.zeros(n_keys, dtype=float) + + if len(keys) == 0: + return best_pair, delta + + # Sort by key, then by score within each key, so each group's best pair is + # its first element and the runner-up is the one right after it. + order = np.lexsort((score, keys)) + k_sorted = keys[order] + s_sorted = score[order] + + is_first = np.ones(len(k_sorted), dtype=bool) + is_first[1:] = k_sorted[1:] != k_sorted[:-1] + i_first = np.flatnonzero(is_first) + + best_pair[k_sorted[i_first]] = order[i_first] + + # The runner-up exists only if the next entry belongs to the same key. + i_next = np.minimum(i_first + 1, len(k_sorted) - 1) + has_runner_up = (i_first + 1 < len(k_sorted)) & (k_sorted[i_next] == k_sorted[i_first]) + runner_up = np.where(has_runner_up, s_sorted[i_next], np.inf) + + delta[k_sorted[i_first]] = runner_up - s_sorted[i_first] + + return best_pair, delta + + +def calibrate_match_scales(pair_i, dx, dy, dm, n_stars, dr_tol, dm_tol, + verbose=True, min_pairs=10): + """ + Measure the position and magnitude scatter between two catalogs from the + catalogs themselves, so the chi^2 needs no per-star error columns. + + A star with exactly one candidate inside the tolerances needs no + tie-breaking, so the spread of those offsets is a clean estimate of how far + apart the same star lands in the two catalogs -- centroiding error, + transformation error and any systematic, all folded in. That is precisely + the scale needed to judge whether one candidate is decisively closer than + another. + + Three tiers, in order of preference: + + 1. Unambiguous (single-candidate) pairs. Cleanest, and what any real + catalog pairing supplies in bulk. + 2. Each star's nearest candidate. Contains some wrong pairs, which inflates + the scale and so errs toward calling things ambiguous. + 3. dr_tol / 10, with the magnitude term switched off. Reached only when + there are barely any candidates to learn from. dr_tol is a search + radius, chosen with room to spare, so the true scatter sits well inside + it; anchoring the scale AT the tolerance would make a candidate 20x + closer than its rival look like a coin toss. + + The magnitude term is used only if its scale was actually measured (tier 1 + or 2). Without a measured scale there is no defensible exchange rate + between arcseconds and magnitudes, and inventing one from the ratio of the + two tolerances is the very mistake this is meant to remove -- dm_tol keeps + working as a hard gate regardless. + + Parameters + ---------- + pair_i : int array + Catalog-1 index of every candidate pair that passed the tolerances. + + dx, dy, dm : float array + Offsets of those pairs. + n_stars : int + Length of catalog 1. + dr_tol : float + Match radius, for the tier-3 fallback. + dm_tol : float or None + Magnitude tolerance. None means magnitudes are not compared at all. + min_pairs : int, optional + Fewest pairs a tier needs before its scatter is trusted, by default 10. + + Returns + ---------- + sigma_pos : float + Per-axis position scatter. + sigma_mag : float or None + Magnitude scatter, or None to score on position alone. + """ + n_cand = np.bincount(pair_i, minlength=n_stars) + + def scales_from(mask): + if int(mask.sum()) < min_pairs: + return np.nan, np.nan + sx = robust_sigma(dx[mask]) + sy = robust_sigma(dy[mask]) + s_pos = np.sqrt(0.5 * (sx**2 + sy**2)) if np.isfinite(sx) and np.isfinite(sy) else np.nan + s_mag = robust_sigma(dm[mask]) if dm_tol is not None else np.nan + return s_pos, s_mag + + # Tier 1: pairs belonging to a star that had exactly one candidate. + tier = 'unambiguous pairs' + sigma_pos, sigma_mag = scales_from(n_cand[pair_i] == 1) + + # Tier 2: each star's nearest candidate, ambiguous stars included. + if not np.isfinite(sigma_pos) or sigma_pos <= 0: + dr = np.hypot(dx, dy) + nearest = np.zeros(len(pair_i), dtype=bool) + order = np.lexsort((dr, pair_i)) + i_sorted = pair_i[order] + is_first = np.ones(len(i_sorted), dtype=bool) + is_first[1:] = i_sorted[1:] != i_sorted[:-1] + nearest[order[is_first]] = True + tier = 'nearest candidates' + sigma_pos, sigma_mag = scales_from(nearest) + + # Tier 3: nothing to learn from. + if not np.isfinite(sigma_pos) or sigma_pos <= 0: + tier = 'dr_tol/10 fallback' + sigma_pos = dr_tol / 10.0 + sigma_mag = np.nan + + if dm_tol is None or not np.isfinite(sigma_mag) or sigma_mag <= 0: + sigma_mag = None + + if verbose > 2: + mag_msg = 'position only' if sigma_mag is None else f'sigma_mag={sigma_mag:.4f}' + print(f' Match scales from {tier}: sigma_pos={sigma_pos:.6f}, {mag_msg}') + + return sigma_pos, sigma_mag + + +def match_chi2(x1, y1, m1, x2, y2, m2, i2_match, dr_tol, dm_tol, + dchi2_tol=9.0, sigma_pos=None, sigma_mag=None, verbose=True): + r""" + Resolve candidate matches by chi^2, keeping only reciprocal best pairs. + + Scores every candidate pair as + + .. math:: + + \chi^2 = \frac{\Delta x^2 + \Delta y^2}{\sigma_\mathrm{pos}^2} + + \frac{\Delta m^2}{\sigma_\mathrm{mag}^2} + + and matches a pair when it is BOTH stars' lowest-chi^2 candidate and wins + by at least dchi2_tol over each star's runner-up. + + This replaces two rules that were losing good matches in crowded fields. + The first required a star's nearest candidate in position to also be its + nearest in magnitude, which discarded a 3.6 mas match because a star 83 mas + away happened to be 0.06 mag closer -- position and magnitude were compared + as equals no matter how lopsided the evidence. Scoring in units of the + measured scatter lets each carry the weight it has earned: a 20x closer + candidate produces a chi^2 difference in the hundreds, while a fraction of + a magnitude produces a few, so magnitude only decides when the positions + are genuinely coincident. Second, one-to-one was enforced by resolving + duplicates after the fact with the same both-must-agree rule; requiring the + match to be reciprocal is symmetric by construction and needs no + arbitration. + + Parameters + ---------- + x1, y1, m1, x2, y2, m2 : float array + The two catalogs, already on a common system. + i2_match : list of lists + Candidate catalog-2 indices within dr_tol of each catalog-1 star, as + returned by the KD-tree radius query. + + dr_tol, dm_tol : float, float or None + Hard search tolerances, already applied to i2_match for dr. + dchi2_tol : float, optional + How much better the best candidate must be than the runner-up, in + chi^2. The default 9 is a 3-sigma margin. Below it the pair is treated + as genuinely ambiguous and left unmatched. + + sigma_pos, sigma_mag : float or None + Scales for the chi^2. None (the default) measures them from the + unambiguous pairs of these two catalogs -- no error columns needed, by default 9.0. + + Returns + ---------- + idxs1, idxs2, dr, dm : arrays + As match(). + """ + n_cand = np.array([len(c) for c in i2_match]) + n_pairs_total = int(n_cand.sum()) + + if n_pairs_total == 0: + empty_i = np.zeros(0, dtype=int) + empty_f = np.zeros(0, dtype=float) + return empty_i, empty_i, empty_f, empty_f + + pair_i = np.repeat(np.arange(len(x1)), n_cand) + pair_j = np.fromiter(itertools.chain.from_iterable(i2_match), dtype=int, + count=n_pairs_total) + + dx = x2[pair_j] - x1[pair_i] + dy = y2[pair_j] - y1[pair_i] + dm = m2[pair_j] - m1[pair_i] + + # Apply the hard gates. A non-finite offset can never be a match, and the + # KD-tree was built with non-finite catalog-2 coordinates replaced by 0, so + # those rows must be dropped here rather than scored. + good = np.isfinite(dx) & np.isfinite(dy) + if dm_tol is not None: + good &= np.isfinite(dm) & (np.abs(dm) < dm_tol) + + pair_i, pair_j = pair_i[good], pair_j[good] + dx, dy, dm = dx[good], dy[good], dm[good] + + if len(pair_i) == 0: + empty_i = np.zeros(0, dtype=int) + empty_f = np.zeros(0, dtype=float) + return empty_i, empty_i, empty_f, empty_f + + if sigma_pos is None or sigma_mag is None: + auto_pos, auto_mag = calibrate_match_scales( + pair_i, dx, dy, dm, len(x1), dr_tol, dm_tol, verbose=verbose + ) + if sigma_pos is None: + sigma_pos = auto_pos + if sigma_mag is None: + sigma_mag = auto_mag + + chi2 = (dx**2 + dy**2) / sigma_pos**2 + if dm_tol is not None and sigma_mag is not None: + chi2 = chi2 + dm**2 / sigma_mag**2 + + best_of_i, delta_i = best_and_runner_up(pair_i, chi2, len(x1)) + best_of_j, delta_j = best_and_runner_up(pair_j, chi2, len(x2)) + + # Keep a pair only if each star prefers the other, and each prefers it + # decisively. The reciprocity makes the result independent of which catalog + # is which; the margin is what used to be called "confused". + p = np.arange(len(pair_i)) + keep = ((best_of_i[pair_i] == p) & (best_of_j[pair_j] == p) & + (delta_i[pair_i] >= dchi2_tol) & (delta_j[pair_j] >= dchi2_tol)) + + if verbose > 2: + n_contested = int(((best_of_i[pair_i] == p) & (best_of_j[pair_j] == p)).sum()) + mag_msg = 'off' if sigma_mag is None else f'{sigma_mag:.3f}' + print(f' chi2 matching: sigma_pos={sigma_pos:.5f}, sigma_mag={mag_msg}, ' + f'dchi2_tol={dchi2_tol}') + print(f' {int(keep.sum())} matched; {n_contested - int(keep.sum())} ' + f'reciprocal pairs dropped as ambiguous') + + idxs1 = pair_i[keep] + idxs2 = pair_j[keep] + + return idxs1, idxs2, np.hypot(dx[keep], dy[keep]), dm[keep] + + +def match(x1, y1, m1, x2, y2, m2, dr_tol, dm_tol=None, workers=1, verbose=True, + matching='legacy', dchi2_tol=9.0, sigma_pos=None, sigma_mag=None): """ Finds matches between two different catalogs. No transformations are done and it is assumed that the two catalogs are already on the same coordinate system @@ -205,11 +577,12 @@ def match(x1, y1, m1, x2, y2, m2, dr_tol, dm_tol=None, verbose=True): For two stars to be matched, they must be within a specified radius (dr_tol) and delta-magnitude (dm_tol). For stars with more than 1 neighbor (within the tolerances), if one is found that is the best match in both brightness and positional offsets - (closest in both), then the match is made. Otherwise, - their is a conflict and no match is returned for the star. - - + (closest in both), then the match is made. + Otherwise, their is a conflict and no match is returned for the star. + + Parameters + ---------- x1 : array-like X coordinate in the first catalog y1 : array-like @@ -228,11 +601,39 @@ def match(x1, y1, m1, x2, y2, m2, dr_tol, dm_tol=None, verbose=True): and the closest in delta-mag is chosen. dm_tol : float or None, optional How close in delta-magnitude a match has to be to count as a match. - If None, then any delta-magnitude is allowed. + If None, then any delta-magnitude is allowed, by default None. + workers : int, optional + Number of jobs to schedule for parallel processing. If -1 is given all processors are used. Default: 1. + By default 1. verbose : bool or int, optional - Prints on screen information on the matching. Higher verbose values - (up to 9) provide more detail. - + Prints on screen information on the matching. Higher verbose values + (up to 9) provide more detail, by default True. + matching : {'legacy', 'chi2'}, optional + How to resolve a star with more than one candidate, and how to enforce + one-to-one. + + 'legacy' (default) keeps the historical rules: a multi-candidate star + is matched only if its nearest candidate in position is also its + nearest in magnitude, and duplicates are arbitrated afterwards by the + same both-must-agree test. In a crowded field this discards good + matches -- a candidate 20x closer loses to one a few hundredths of a + magnitude nearer in brightness -- and every discarded star then becomes + a duplicate reference entry that makes the next catalog ambiguous too. + + 'chi2' scores each candidate as (dr/sigma_pos)^2 + (dm/sigma_mag)^2 and + keeps reciprocal best pairs that win by dchi2_tol. See match_chi2(). + By default 'legacy'. + dchi2_tol : float, optional + matching='chi2' only. Required chi^2 margin over the runner-up. + Default 9.0, a 3-sigma margin, by default 9.0. + sigma_pos : float or None, optional + matching='chi2' only. Position scale for the chi^2, in the units of + x1/y1. None (default) measures it from the unambiguous pairs of these + two catalogs, so no per-star error columns are needed, by default None. + sigma_mag : float or None, optional + matching='chi2' only. Magnitude scale for the chi^2. None (default) + measures it the same way, by default None. + Returns ------- idx1 : int array @@ -245,27 +646,34 @@ def match(x1, y1, m1, x2, y2, m2, dr_tol, dm_tol=None, verbose=True): Distance between the matches. dm : float array Delta-mag between the matches. (m1 - m2) - + + Raises + ------ + ValueError + If the input arrays do not have the same shape or if they do not contain any finite values. + Or when no match is found between the two catalogs. """ - + x1 = np.array(x1, copy=False) y1 = np.array(y1, copy=False) m1 = np.array(m1, copy=False) x2 = np.array(x2, copy=False) y2 = np.array(y2, copy=False) m2 = np.array(m2, copy=False) - - if x1.shape != y1.shape: - raise ValueError('x1 and y1 do not match!') - if x2.shape != y2.shape: - raise ValueError('x2 and y2 do not match!') - + + for val, name in zip([x1, y1, m1, x2, y2, m2], ['x1', 'y1', 'm1', 'x2', 'y2', 'm2']): + if not np.isfinite(val).any(): + raise ValueError(f'{name} does not contain any finite values!') + + assert x1.shape == y1.shape, 'x1 and y1 do not match!' + assert x2.shape == y2.shape, 'x2 and y2 do not match!' + # Setup coords1 pairs and coords 2 pairs # this is equivalent to, but faster than just doing np.array([x1, y1]) coords1 = np.empty((x1.size, 2)) coords1[:, 0] = x1 coords1[:, 1] = y1 - + # this is equivalent to, but faster than just doing np.array([x1, y1]) coords2 = np.empty((x2.size, 2)) coords2[:, 0] = x2 @@ -279,18 +687,28 @@ def match(x1, y1, m1, x2, y2, m2, dr_tol, dm_tol=None, verbose=True): idxs2 = np.ones(x1.size, dtype=int) * -1 # The matching will be done using a KDTree. - kdt = KDT(coords2, balanced_tree=False) + #kdt = KDT(coords2, balanced_tree=False) + #KDTree handling of NaNs throws error in scipy v1.10.1 and newer. + #Replace NaNs in coords2 with zero (0). -SKT + kdt = KDT(np.where(np.isfinite(coords2), coords2, 0), balanced_tree=False) # This returns the number of neighbors within the specified # radius. We will use this to find those stars that have no or one # match and deal with them easily. The more complicated conflict # cases will be dealt with afterward. - i2_match = kdt.query_ball_point(coords1, dr_tol) + i2_match = kdt.query_ball_point(coords1, dr_tol, workers=workers) + + if matching == 'chi2': + return match_chi2(x1, y1, m1, x2, y2, m2, i2_match, dr_tol, dm_tol, + dchi2_tol=dchi2_tol, sigma_pos=sigma_pos, + sigma_mag=sigma_mag, verbose=verbose) + elif matching != 'legacy': + raise ValueError(f"matching must be 'legacy' or 'chi2', got {matching!r}") + Nmatch = np.array([len(idxs) for idxs in i2_match]) # What is the largest number of matches we have for a given star? Nmatch_max = Nmatch.max() - # Loop through and handle all the different numbers of matches. # This turns out to be the most efficient so we can use numpy # array operations. Remember, skip the Nmatch=0 objects... they @@ -303,7 +721,7 @@ def match(x1, y1, m1, x2, y2, m2, dr_tol, dm_tol=None, verbose=True): if nn == 1: i2_nn = np.array([i2_match[mm][0] for mm in i1_nn]) - if dm_tol != None: + if dm_tol is not None: dm = np.abs(m1[i1_nn] - m2[i2_nn]) keep = dm < dm_tol idxs1[i1_nn[keep]] = i1_nn[keep] @@ -314,20 +732,18 @@ def match(x1, y1, m1, x2, y2, m2, dr_tol, dm_tol=None, verbose=True): else: i2_tmp = np.array([i2_match[mm] for mm in i1_nn]) - # Repeat star list 1 positions and magnitudes - # for nn times (tile then transpose) - x1_nn = np.tile(x1[i1_nn], (nn, 1)).T - y1_nn = np.tile(y1[i1_nn], (nn, 1)).T - m1_nn = np.tile(m1[i1_nn], (nn, 1)).T + x1_nn = x1[i1_nn] + y1_nn = y1[i1_nn] + m1_nn = m1[i1_nn] # Get out star list 2 positions and magnitudes x2_nn = x2[i2_tmp] y2_nn = y2[i2_tmp] m2_nn = m2[i2_tmp] - dr = np.abs(x1_nn - x2_nn, y1_nn - y2_nn) - dm = np.abs(m1_nn - m2_nn) + dr = np.hypot(x2_nn - x1_nn[:, np.newaxis], y2_nn - y1_nn[:, np.newaxis]) + dm = np.abs(m2_nn - m1_nn[:, np.newaxis]) - if dm_tol != None: + if dm_tol is not None: # Don't even consider stars that exceed our # delta-mag threshold. dr_msk = np.ma.masked_where(dm > dm_tol, dr) @@ -342,7 +758,7 @@ def match(x1, y1, m1, x2, y2, m2, dr_tol, dm_tol=None, verbose=True): # Double check that "min" choice is still within our # detla-mag tolerence. - dm_tmp = np.array([dm.T[dm_min[I]][I] for I in np.lib.index_tricks.ndindex(dm_min.shape)]) + dm_tmp = np.array([dm.T[dm_min[I]][I] for I in np.ndindex(dm_min.shape)]) keep = (dm_min == dr_min) & (dm_tmp < dm_tol) else: @@ -361,8 +777,8 @@ def match(x1, y1, m1, x2, y2, m2, dr_tol, dm_tol=None, verbose=True): idxs1 = idxs1[idxs1 >= 0] idxs2 = idxs2[idxs2 >= 0] - dr = np.hypot(x1[idxs1] - x2[idxs2], y1[idxs1] - y2[idxs2]) - dm = m1[idxs1] - m2[idxs2] + dr = np.hypot(x2[idxs2] - x1[idxs1], y2[idxs2] - y1[idxs1]) + dm = m2[idxs2] - m1[idxs1] # Deal with duplicates duplicates = [item for item, count in list(Counter(idxs2).items()) if count > 1] @@ -373,36 +789,29 @@ def match(x1, y1, m1, x2, y2, m2, dr_tol, dm_tol=None, verbose=True): # Index into the idxs1, idxs2 array of this duplicate. dups = np.where(idxs2 == duplicates[dd])[0] - # Assume the duplicates are confused first... see if we - # can resolve the confusion below. + # Assume the duplicates are confused first... see if we can resolve the confusion below. keep[dups] = False - - dm_dups = m1[idxs1[dups]] - m2[idxs2[dups]] - dr_dups = np.hypot(x1[idxs1[dups]] - x2[idxs2[dups]], y1[idxs1[dups]] - y2[idxs2[dups]]) - - dm_min = np.abs(dm_dups).argmin() - dr_min = np.abs(dr_dups).argmin() + best_dm = np.abs(m2[idxs2[dups]] - m1[idxs1[dups]]).argmin() + best_dr = np.hypot(x2[idxs2[dups]] - x1[idxs1[dups]], y2[idxs2[dups]] - y1[idxs1[dups]]).argmin() # If there is a clearly preferred match (closest in distance and brightness), then - # keep it and dump the other duplicates. - if dm_min == dr_min: - keep[dups[dm_min]] = True - else: - if verbose > 8: - print(' confused, dropping') - + # keep it and dump the other duplicates. Otherwise, drop the match as confused. + if best_dm == best_dr: + keep[dups[best_dm]] = True + elif verbose > 3: + print(' confused, dropping star at',x2[idxs2[dups]][0],y2[idxs2[dups]][0]) # Clean up the duplicates idxs1 = idxs1[keep] idxs2 = idxs2[keep] dr = dr[keep] dm = dm[keep] - + return idxs1, idxs2, dr, dm def calc_triangles_vmax_angle(x, y): idx = np.arange(len(x), dtype=np.int16) - + # Option 1 -- this takes 0.217 seconds for 50 objects # t1 = time.time() # combo_iter1 = itertools.combinations(idx1, 3) @@ -411,258 +820,51 @@ def calc_triangles_vmax_angle(x, y): # print( 'Finished Option 1: ', t2 - t1) # print( combo_idx1_1.shape) # print( combo_idx1_1) - + # Option 2 -- this takes 0.016 seconds for 50 objects combo_iter = itertools.combinations(idx, 3) combo_dt = np.dtype('i2,i2,i2') combo_idx_tmp = np.fromiter(combo_iter, dtype=combo_dt) combo_idx = combo_idx_tmp.view(np.int16).reshape(-1, 3) - + ii0 = combo_idx[:,0] ii1 = combo_idx[:,1] ii2 = combo_idx[:,2] - + dxab = x[ii1] - x[ii0] dyab = y[ii1] - y[ii0] dxac = x[ii2] - x[ii0] dyac = y[ii2] - y[ii0] - + dab = np.hypot(dxab, dyab) dac = np.hypot(dxac, dyac) - + dmax = np.max([dab, dac], axis=0) dmin = np.min([dab, dac], axis=0) - + vmax = dmin ** 2 / dmax ** 2 vmax[dab < dac] *= -1 - + vdprod = dxab * dxac + dyab * dyac vcprod = dxab * dyac - dyab * dxac - + angle = np.degrees( np.arctan2( vdprod, vcprod) ) angle[angle < 0] += 360.0 angle[angle > 360] -= 360.0 - + return combo_idx, vmax, angle def add_votes(votes, match1, match2): # Construct a histogram of how often a bin is matched... then add the delta flat_idx = np.ravel_multi_index((match1, match2), dims=votes.shape) - + # extract the unique indices and their position unique_idx, idx_idx = np.unique(flat_idx, return_inverse=True) - + # aggregate the repeated indices deltas = np.bincount(idx_idx) - + # Sum them to the array votes.flat[unique_idx] += deltas - - return - - -def generic_match(sl1, sl2, init_mode='triangle', - model=transforms.PolyTransform, order_dr=(1, 1.0), - dr_final=1.0, - xy_match=(None, None, None, None, None, None, None, None), - m_match=(None, None, None, None), sigma_match=None, - n_bright=100, verbose=True, **kwargs): - """ - Finds the transformation between two starlists using the first one - as reference frame. Different matching methods can be used. If no - transformation is found, it returns an error message. - - Parameters - sl1 : StarList - starlist used for reference frame - sl2 : StarList - starlist transformed - init_mode : str - Initial matching method. - If 'triangle', uses the blind triangle method. - If 'match_name', uses match by name - If 'load', uses the transformation from a loaded file - model : str - Transformation model to be used with the 'triangle' initial mode - poly_order : int - Order of the transformation model - order_dr : int, float [n, 2] - Combinations of polinomial order (first column) and search radius - (second column) to refine the transformation. Rows are executed in - orders - dr_final: float - Search radius used for the final matching - n_bright : int - Number of bright stars used in the initial blind triangles matching - xy_match : array - Area of the images to remove in the matching [reference catalog min x, - reference catalog max x, reference catalog min y, reference catalog max y, - transformed catalog min x, transformed catalog max x, - transformed catalog min y, transformed catalog max y]. Use None for values not used. - m_match : array - Magnitude limits of matching stars used to find transformations - [reference catalog min mag, reference catalog max mag, transformed - catalog min mag, transformed catalog max mag]. Use None for values not - used - sigma_match : array - Number of Deltap movement sigmas [0] used for sigma-cutting matched - stars for a number of times [1]. Use None for no sigma-cut. The last - polynomial order and search radius in 'order_dr' are used - transf_file : str - File name and path of the transformation file used with the 'load' - init_mode - verbose : bool, optional - Prints on screen information on the matching - - Returns - ------- - transf : Transform2D - Transformation of the second starlist respect to the first - st : StarTable - Startable of the two matched catalogs - - """ - - # Check the input StarLists and transform them into astropy Tables - if not isinstance(sl1, starlists.StarList): - raise TypeError("The first catalog has to be a StarList") - if not isinstance(sl2, starlists.StarList): - raise TypeError("The second catalog has to be a StarList") - - # Find the initial transformation - if init_mode == 'triangle': # Blind triangles method - - # Prepare the reduced starlists for matching - sl1_cut = copy.deepcopy(sl1) - sl2_cut = copy.deepcopy(sl2) - sl1_cut.restrict_by_value(x_min=xy_match[0], x_max=xy_match[1], - y_min=xy_match[2], y_max=xy_match[3]) - sl2_cut.restrict_by_value(x_min=xy_match[4], x_max=xy_match[5], - y_min=xy_match[6], y_max=xy_match[7]) - sl1_cut.restrict_by_value(m_min=m_match[0], m_max=m_match[1]) - sl2_cut.restrict_by_value(m_min=m_match[2], m_max=m_match[3]) - - # Find the transformation - # TODO: test 'initial_align' with StarList input - transf = align.initial_align(sl1_cut, sl2_cut, briteN=n_bright, - transformModel=model, order=order_dr[0]) #order_dr[i_loop][0] ? - - elif init_mode == 'match_name': # Name match - sl1_idx_init, sl2_idx_init, _ = starlists.restrict_by_name(sl1, sl2) - transf = model(sl2['x'][sl2_idx_init], sl2['y'][sl2_idx_init], - sl1['x'][sl1_idx_init], sl1['y'][sl1_idx_init], - order=int(order_dr[0][0])) - - elif init_mode == 'load': # Load a transformation file - transf = transforms.Transform2D.from_file(kwargs['transf_file']) - - else: # None of the above - raise TypeError("Unrecognized initial matching method") - - # Restrict the matching catalogs - sl1_match = copy.deepcopy(sl1) - sl2_match = copy.deepcopy(sl2) - sl1_match.restrict_by_value(m_min=m_match[0], m_max=m_match[1]) - sl2_match.restrict_by_value(m_min=m_match[2], m_max=m_match[3]) - - # Refine the transformation - if sigma_match: - order_dr_len = len(order_dr) - - for i_loop in range(sigma_match[1]): - order_dr = np.vstack((np.array(order_dr), np.array(order_dr[-1]))) - - for i_loop in range(len(order_dr)): - - # Transform and match the catalog to the reference frame -# sl2_idx, sl1_idx = align.transform_and_match(sl2_match, sl1_match, transf, -# dr_tol=order_dr[i_loop][1], -# verbose=verbose) - - sl2_idx, sl1_idx = align.transform_and_match(sl2_match, sl1_match, transf, - dr_tol=order_dr[1], - verbose=verbose) - - # Transform the catalog to the reference frame - sl2_transf_match = align.transform_from_object(sl2_match, transf) - - # Sigma-rejection - if sigma_match and (i_loop >= order_dr_len): - resid = np.sqrt((sl1_match['x'][sl1_idx] - - sl2_transf_match['x'][sl2_idx])**2 + - (sl1_match['y'][sl1_idx] - - sl2_transf_match['y'][sl2_idx])**2) - sl1_idx = sl1_idx[resid <= (sigma_match[0] * np.std(resid))] - sl2_idx = sl2_idx[resid <= (sigma_match[0] * np.std(resid))] - - # Test section to observe the matching catalogs before refining the transformation - """ - from matplotlib import pyplot - - _, axarr = pyplot.subplots(nrows=1, ncols=1, figsize=(10,10)) - axarr.scatter(sl1_match['x'][sl1_idx], sl1_match['y'][sl1_idx]) - xlim = axarr.get_xlim() - ylim = axarr.get_ylim() - - _, axarr = pyplot.subplots(nrows=1, ncols=1, figsize=(10, 10)) - axarr.scatter(sl2_transf_match['x'][sl2_idx], sl2_transf_match['y'][sl2_idx]) - axarr.set_xlim(xlim) - axarr.set_ylim(ylim) - """ - - # Find a better transformation - transf, _ = align.find_transform(sl2_match[sl2_idx], - sl2_transf_match[sl2_idx], - sl1_match[sl1_idx], transModel=model, - order=order_dr[0], verbose=verbose) -# order=int(order_dr[i_loop][0]), verbose=verbose) - - # This section was used for testing transformations with normalized - # coordinates. Only several catalogs had reduced residuals when using - # high order polynomials (>3), some of them became unstable - """sl1_match_norm = sl1_match[sl1_idx] - sl2_match_norm = sl2_match[sl2_idx] - sl2_transf_match_norm = sl2_transf_match[sl2_idx] - mm = max(max(sl1_match_norm['x']), max(sl1_match_norm['y']), - max(sl2_transf_match_norm['x']), max(sl2_transf_match_norm['y'])) - sl1_match_norm['x'] = sl1_match_norm['x'] / mm - sl1_match_norm['y'] = sl1_match_norm['y'] / mm - sl2_match_norm['x'] = sl2_match_norm['x'] / mm - sl2_match_norm['y'] = sl2_match_norm['y'] / mm - sl2_transf_match_norm['x'] = sl2_transf_match_norm['x'] / mm - sl2_transf_match_norm['y'] = sl2_transf_match_norm['y'] / mm - transf, _ = align.find_transform(sl2_match_norm, sl2_transf_match_norm, - sl1_match_norm, transModel=model, - order=poly_order, verbose=verbose) - c_exp = np.zeros(len(transf.px._parameters)) - - for i_c in range(len(transf.px._parameters)): - c_exp[i_c] = int(transf.px._param_names[i_c][1:].split('_')[0]) +\ - int(transf.px._param_names[i_c][1:].split('_')[1]) - - c_corr = mm ** (1 - c_exp) - transf.px._parameters = transf.px._parameters * c_corr - transf.py._parameters = transf.py._parameters * c_corr""" - - # Do the final transformation and matching using - sl2_idx, sl1_idx = align.transform_and_match(sl2, sl1, transf, dr_tol=dr_final, - verbose=verbose) - # StarTable output - sl2_transf = align.transform_from_object(sl2, transf) - unames = np.array(range(len(sl1_idx))) - st = startables.StarTable(name=unames, - x=np.column_stack((np.array(sl1['x'][sl1_idx]), np.array(sl2_transf['x'][sl2_idx]))), - y=np.column_stack((np.array(sl1['y'][sl1_idx]), np.array(sl2_transf['y'][sl2_idx]))), - m=np.column_stack((np.array(sl1['m'][sl1_idx]), np.array(sl2_transf['m'][sl2_idx]))), - ep_name=np.column_stack((np.array(sl1['name'][sl1_idx]), np.array(sl2_transf['name'][sl2_idx])))) -# ep_name=np.column_stack((np.array(sl1['name'][sl1_idx]), np.array(sl2_transf['name'][sl2_idx]))), -# list_times=[sl1.meta['list_time'], sl2.meta['list_time']], -# list_names=[sl1.meta['list_name'], sl2.meta['list_name']]) - - for col in sl1.colnames: - if col in sl2.colnames: - if col not in ['name', 'x', 'y', 'm']: - st.add_column(Column(np.column_stack((np.array(sl1[col][sl1_idx]),np.array(sl2_transf[col][sl2_idx]))), name=col)) - - return transf, st + return diff --git a/flystar/motion_model.py b/flystar/motion_model.py new file mode 100644 index 0000000..37cfa4d --- /dev/null +++ b/flystar/motion_model.py @@ -0,0 +1,2086 @@ +import warnings +import numpy as np +from abc import ABC +from flystar import parallax +from astropy.time import Time +from scipy.optimize import OptimizeWarning + + +def weight_from_sigma(sigma, valid=None): + """ + Convert an uncertainty (sigma) array into a safe inverse-variance + weight (1/sigma**2), for use in a weighted sum/average. + + A point with no real uncertainty information should contribute + nothing to a weighted sum -- but naively computing 1/sigma**2 can + instead produce an infinite or NaN weight (sigma is NaN/inf/exactly + zero, or so small that squaring it underflows to zero), which would + corrupt rather than exclude that point. This handles all of those + cases uniformly: any sigma that doesn't produce a finite weight, or + any point explicitly marked invalid via `valid`, gets a weight of + exactly 0. + + This does NOT handle the "every point has weight 0" case for you -- + a weighted average built from these weights still needs its own + explicit fallback for that (see combine_lists/fit_motion_models), + since there's no single value this function could return that fixes + an otherwise-undefined 0/0 average. + + Parameters + ---------- + sigma : array-like + Uncertainty values (any invalid/zero/overflow-inducing value is + safely handled). + valid : array-like of bool, optional + If given, points where this is False also get weight 0, + regardless of sigma, by default None. + + Returns + ------- + weight : ndarray + Same shape as sigma. + """ + sigma = np.asarray(sigma, dtype=float) + with np.errstate(divide='ignore', invalid='ignore'): + weight = 1. / sigma**2 + if valid is not None: + weight = np.where(valid, weight, 0.0) + weight[~np.isfinite(weight)] = 0.0 + return weight + + +def broadcast_times(t, n_stars, caller='model'): + """ + Normalize a time argument into an explicit (n_stars, n_times) grid. + + The SHAPE of `t` alone decides whether the times are shared across stars + or are per-star. There is deliberately no inference from + len(t) == n_stars: that used to be read as "one time per star", so the + very same 1D array changed meaning depending on how many stars the table + happened to contain, and a table whose star count coincided with its + epoch count silently took the wrong branch. + + ========================== ==================================== + `t` meaning + ========================== ==================================== + scalar one time, every star + ``(n_times,)`` one shared grid, every star -- always, + even when n_times == n_stars + ``(1, n_times)`` the same, written explicitly + ``(n_stars, n_times)`` each star has its own times + ========================== ==================================== + + To evaluate every star at its own single time, pass a column vector -- + ``t[:, np.newaxis]``, of shape ``(n_stars, 1)`` -- not a bare 1D array. + Note that propagating a whole table to one new epoch does NOT need this: + pass the scalar epoch and a per-star ``t0`` fixed parameter, and each + star's dt = t - t0[star] already differs. + + Parameters + ---------- + t : scalar or array-like + Times, in one of the shapes above. + n_stars : int + Number of stars the times are being broadcast against. + caller : str, optional + Name used in the error message, by default 'model'. + + Returns + ------- + ndarray, shape (n_stars, n_times) + May be a read-only broadcast view -- do not write into it. + """ + t = np.asarray(t, dtype=float) + + if t.ndim == 0: + return np.full((n_stars, 1), float(t)) + if t.ndim == 1: + return np.broadcast_to(t[np.newaxis, :], (n_stars, t.shape[0])) + if t.ndim == 2: + if t.shape[0] == n_stars: + return t + if t.shape[0] == 1: + return np.broadcast_to(t, (n_stars, t.shape[1])) + raise ValueError( + f"{caller}: 2D time array must have one row per star -- got shape " + f"{t.shape} for {n_stars} star(s). Pass a shared time grid as 1D " + f"(n_times,), or per-star times as (n_stars, n_times); for one " + f"time per star use t[:, np.newaxis], of shape ({n_stars}, 1)." + ) + raise ValueError( + f"{caller}: time array must be scalar, 1D (n_times,), or 2D " + f"(n_stars, n_times) -- got a {t.ndim}D array of shape {t.shape}." + ) + + +def sigma_from_error(xe, ye, weighting='var'): + """ + Convert x/y position errors into the sigma values a weighted fit + should use, based on the requested weighting scheme. + + weighting : str, optional + 'var': sigma = ``abs(xe)``, ``abs(ye)``, so a later 1/sigma**2 gives + true inverse-variance weighting (w=1/xe**2, 1/ye**2). + 'std': sigma = ``sqrt(abs(xe))``, ``sqrt(abs(ye))``, so the same later + 1/sigma**2 instead gives standard-error weighting (w=1/xe, 1/ye). + By default 'var'. + """ + if weighting=='std': + return np.sqrt(np.abs(xe)), np.sqrt(np.abs(ye)) + elif weighting=='var': + return np.abs(xe), np.abs(ye) + else: + warnings.warn("Invalid weighting, using default weighting scheme var.", UserWarning) + return np.abs(xe), np.abs(ye) + + +class MotionModel(ABC): + name = "MotionModel" + + # Fit paramters: Shared fit parameters + fit_param_names = [] + n_fit_params = len(fit_param_names) + # Number of fit parameters/required observations in each direction + n_params = int((n_fit_params + 1) / 2) + + # Fixed parameters: These are parameters that are required for the model, but are not + # fit quantities. For example, RA and Dec in a parallax model. + fixed_param_names = [] + required_fixed_param_names = [] + optional_fixed_params = {} + + fixed_meta_data = [] + + # Non-fit paramters: Custom paramters that will not be fit. + # These parameters should be derived from the fit parameters and + # they must exist as a variable on the model object + + def __init__(self, *args, **kwargs): + """ + Make a motion model object. This object defines the fit and fixed parameters, + and contains functions to fit the model to data and infer positions at given times. + Each instance corresponds to a given motion model, not an individual star, + and thus the fit values are only input/returned in functions, not stored in the object. + """ + return + + def _check_param_dimensions(self, fit_params, fit_params_errs, fixed_params_dict): + """Check that parameters is either a scalar or length of N_stars + + Parameters + ---------- + fit_params: array-like + Fit parameters, shape (N_fit_params,) or (n_stars, N_fit_params) + fit_params_errs: array-like + Errors of fit parameters, shape (N_fit_params,) or (n_stars, N_fit_params) + fixed_params_dict : dict + Dictionary of fixed parameters + """ + N_stars = fit_params.shape[0] if fit_params.ndim > 1 else 1 + if fit_params_errs is not None: + assert fit_params_errs.shape == fit_params.shape, "fit_params and fit_params_errs must have the same shape!" + + if fixed_params_dict is not None: + for key, value in fixed_params_dict.items(): + # assert key in fixed_params_dict, f"Missing fixed parameter {key} in fixed_params_dict!" + value = fixed_params_dict[key] + if np.isscalar(value): + continue + else: + assert len(value) == N_stars, f"Length of fixed parameter {key} must be either 1 or N_stars={N_stars}!" + + def model_fit(self, dt): + return np.full_like(dt, np.nan) + + def model(self, t, fit_params, fit_param_errs=None, fixed_params_dict=None): + """Evaluate the model at the given time(s). + + Every concrete subclass overrides this. The time argument follows one + contract across all of them, resolved by :func:`broadcast_times`: + + Parameters + ---------- + t : scalar or array-like + Time(s) at which to evaluate the model. The SHAPE decides + whether the times are shared across stars or are per-star -- + nothing is inferred from ``len(t)`` matching ``N_stars``: + + ====================== ========================================= + ``t`` meaning + ====================== ========================================= + scalar one time, every star + ``(N_times,)`` one shared grid, every star -- always, + even when ``N_times == N_stars`` + ``(1, N_times)`` the same, written explicitly + ``(N_stars, N_times)`` each star has its own times + ====================== ========================================= + + For one time per star, pass a column vector + ``t[:, np.newaxis]`` of shape ``(N_stars, 1)`` -- not a bare 1D + array. Any other shape raises ``ValueError`` rather than being + guessed at. See :func:`broadcast_times`. + fit_params : array-like + Fit parameters, shape (N_fit_params,) or (N_stars, N_fit_params). + fit_param_errs : array-like, optional + Uncertainties on fit_params, same shape, by default None. + fixed_params_dict : dict, optional + Fixed (non-fit) parameters; see each subclass's + fixed_param_names, by default None. + + Returns + ------- + x, y (, xe, ye) + Predicted positions, and uncertainties if fit_param_errs is + given, with shape (N_stars, N_times) -- flattened when + N_stars == 1 or N_times == 1. + """ + self._check_param_dimensions(fit_params, fit_param_errs, fixed_params_dict) + if fit_param_errs is None: + return np.full_like(t, np.nan), np.full_like(t, np.nan) + return np.full_like(t, np.nan), np.full_like(t, np.nan), np.full_like(t, np.inf), np.full_like(t, np.inf) + + def run_fit(self, t, x, y, xe, ye, valid, fixed_params_dict=None, weighting='var', + absolute_sigma=True, fill_value=np.nan, verbose=True): + """ + Fit a batch of stars at once (used both for the main fit and for + fit()'s bootstrap iterations). Every concrete MotionModel subclass + must override this -- there is no per-star fallback anymore, so a + subclass that doesn't override it would otherwise silently inherit + this stub and produce all-fill_value/inf fits with no warning. A + subclass whose fit genuinely can't be vectorized across stars can + still satisfy this same batch-in/batch-out signature by looping + over stars internally (e.g. calling scipy.optimize.curve_fit once + per row) -- nothing requires the implementation to be closed-form, + only the interface to accept/return a whole batch at once. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement run_fit(t, x, y, xe, ye, valid, ...)." + ) + + def fit( + self, t, x, y, xe, ye, + fixed_params_dict=None, + weighting='var', + absolute_sigma=True, + fill_value=np.nan, + return_chi2=False, + bootstrap=0, + seed=None, + verbose=True + ): + """Fit stellar motion parameters -- for one star, or for a whole + batch of stars at once. + + t, x, y, xe, ye : 1D, shape (n_epochs,) + A single star's data. The caller is expected to have already + filtered this down to that star's own real epochs -- no + padding, nothing to mask. + t, x, y, xe, ye : 2D, shape (n_stars, n_epochs) + A batch of many stars packed into one rectangular array (this + is the path StarTable.fit_motion_models uses for real + performance -- run_fit is never called directly from outside + this module). Since stars don't all have the same number of + real epochs, some cells are padding; padding is marked by nan + in x and/or y (this codebase's existing "no data" convention), + not by a separate mask the caller has to build. valid = + isfinite(x) & isfinite(y) is derived here and handed to + run_fit(), which does the actual (closed-form, vectorized) + solve for the whole batch in one call. + + Every concrete model's run_fit is closed-form: the single-star + case just wraps the star's data into a batch of one row (and, for + bootstrap, into a batch of `bootstrap` rows -- one resampled + subset/order of this star's epochs per row), so every case above + goes through the same vectorized, non-iterative solve. + + Parameters + ---------- + t : array-like + Times of measurements + x : array-like + x-coordinates + y : array-like + y-coordinates + xe : array-like + Uncertainty of x + ye : array-like + Uncertainty of y + fixed_params_dict : dict, optional + Dictionary of fixed parameters, see each motion model's fixed_param_names for details, by default None + weighting : str, optional + Use standard error weighting ('std': w=1/xe, 1/ye) or variance weighting ('var': w=1/xe**2, 1/ye**2), by default 'var' + absolute_sigma : bool, optional + Absolute sigma. If False, parameter errors are rescaled by the reduced chi^2, by default True + fill_value : float, optional + Fill value for parameters when not enough data points to fit model, by default np.nan + return_chi2 : bool, optional + Return chi^2 values along with parameters and uncertainties in params, param_errs, chi2_x, chi2_y, by default False. + Ignored for the 2D (batch) case, which always returns all four. + bootstrap : int, optional + Bootstrapping uncertainties (single-star case only), by default 0 + seed : int, optional + Seed for the random number generator, by default None + verbose : bool, optional + Print warning messages, by default True + + Returns + ------- + params, param_errs(, chi2_x, chi2_y) + Parameters, uncertainties, and chi squares if return_chi2 is True (always for the batch case). The corresponding parameter names are in self.fit_param_names. + """ + t = np.asarray(t) + x = np.asarray(x) + y = np.asarray(y) + xe = np.asarray(xe) + ye = np.asarray(ye) + + if not verbose: + warnings.filterwarnings("ignore", category=OptimizeWarning) + + if t.ndim == 2: + # Batch path: many stars packed into one rectangular array. + # No separate mask is built or passed by the caller -- padding + # epochs are wherever x or y is nan, and that's exactly what + # isfinite picks out. + valid = np.isfinite(x) & np.isfinite(y) + result = self.run_fit( + t, x, y, xe, ye, valid, + fixed_params_dict=fixed_params_dict, weighting=weighting, absolute_sigma=absolute_sigma, + fill_value=fill_value, verbose=verbose + ) + if not verbose: + warnings.resetwarnings() + return result + + for variable, name in zip([t, x, y, xe, ye], ['t', 'x', 'y', 'xe', 'ye']): + assert np.ndim(variable) == 1, f"Input {name} array must be 1D (single star) or 2D (batch)! Got shape {np.shape(variable)}" + if name != 't': + assert len(t) == len(variable), f'Input {name} must have the same length as t! Got len(t)={len(t)}, len({name})={len(variable)}' + + # Copy (rather than mutate the caller's dict) before filling in a + # default t0 -- same convenience the old per-star run_fit() gave + # Linear/Acceleration/Parallax when t0 wasn't supplied. + fixed_params_dict = dict(fixed_params_dict) if fixed_params_dict is not None else {} + if ('t0' in self.required_fixed_param_names) and ('t0' not in fixed_params_dict): + fixed_params_dict['t0'] = np.average(t, weights=1. / np.hypot(xe, ye)) + # Remembered so a later self.model(t, params) call (without its own + # fixed_params_dict) can fall back to what this fit used -- same + # convenience the old per-star run_fit() provided. + self.fixed_params_dict = fixed_params_dict + + n_obs = len(t) + valid = np.ones((1, n_obs), dtype=bool) + params, param_errs, chi2_x, chi2_y = self.run_fit( + t[np.newaxis, :], x[np.newaxis, :], y[np.newaxis, :], xe[np.newaxis, :], ye[np.newaxis, :], valid, + fixed_params_dict=fixed_params_dict, weighting=weighting, absolute_sigma=absolute_sigma, + fill_value=fill_value, verbose=verbose + ) + params, param_errs, chi2_x, chi2_y = params[0], param_errs[0], chi2_x[0], chi2_y[0] + + # Bootstrap errors + if (bootstrap > 0) and (n_obs > self.n_params): + rng = np.random.default_rng(seed) + edx = np.arange(n_obs, dtype=int) + # Precompute All Bootstrap Draws at Once + # Ensure there are enough unique points in each bootstrap sample + bdx_unique = np.stack([ + rng.choice(edx, size=self.n_params, replace=False) + for _ in range(bootstrap) + ]) + # Draw with replacement for the rest + bdx_extra = np.stack([ + rng.choice(edx, size=n_obs - self.n_params, replace=True) + for _ in range(bootstrap) + ]) + bdx_all = np.hstack((bdx_unique, bdx_extra)) # shape (bootstrap, n_obs) + + # All bootstrap draws of this one star are fit in a single + # run_fit call -- each draw is just a "row" with its own + # resampled subset/order of this star's epochs (valid is + # all-True since every entry in a row is a real, if repeated, + # epoch). + valid_boot = np.ones_like(bdx_all, dtype=bool) + bb_params, bb_param_errs, _, _ = self.run_fit( + t[bdx_all], x[bdx_all], y[bdx_all], xe[bdx_all], ye[bdx_all], valid_boot, + fixed_params_dict=fixed_params_dict, weighting=weighting, absolute_sigma=absolute_sigma, + fill_value=fill_value, verbose=verbose + ) + + # Save the errors from the bootstrap + param_errs = np.std(bb_params, axis=0) + + # Account for odd case + inf_errs = np.all(bb_param_errs == np.inf, axis=0) + param_errs[inf_errs] = 0.0 + + if not verbose: + warnings.resetwarnings() + + if return_chi2: + return params, param_errs, chi2_x, chi2_y + else: + return params, param_errs + + + # def calc_chi2(self, dt, x, y, x_wt, y_wt, popt_x, popt_y, reduced=False, parallax=False): + # X_mat_t = np.vander(dt, 2) + # residual_x = x - X_mat_t @ popt_x + # residual_y = y - X_mat_t @ popt_y + + # W_mat_x = np.diag(x_wt) + # W_mat_y = np.diag(y_wt) + + # chi2_x = residual_x.T @ W_mat_x @ residual_x + # chi2_y = residual_y.T @ W_mat_y @ residual_y + + # if reduced: + # if len(dt) == self.n_params: + # return np.inf, np.inf + # if not parallax: + # degree_of_freedom = len(x) - self.n_params + # else: + # degree_of_freedom = 2*len(x) - len(self.fit_param_names) + # chi2_x, chi2_y = chi2_x / degree_of_freedom, chi2_y / degree_of_freedom + # return chi2_x, chi2_y + + def calc_chi2(self, t, x, y, xe, ye, fit_params, fixed_params_dict=None, reduced=False, parallax=False): + """ + Get the chi^2 value for the input motion model parameters and data. + """ + x_pred, y_pred = self.model(t, fit_params, fixed_params_dict=fixed_params_dict) + chi2x = np.sum((x - x_pred)**2 / xe**2) + chi2y = np.sum((y - y_pred)**2 / ye**2) + if reduced: + if len(t) == self.n_params: + return np.inf, np.inf + if parallax: + degree_of_freedom = 2*len(x) - len(self.fit_param_names) + else: + degree_of_freedom = len(x) - self.n_params + chi2x, chi2y = chi2x / degree_of_freedom, chi2y / degree_of_freedom + return chi2x, chi2y + +class Empty(MotionModel): + name = "Empty" + fit_param_names = [] + fixed_param_names = [] + required_fixed_param_names = [] + optional_fixed_params = {} + + n_fit_params = len(fit_param_names) + # Number of fit parameters/required observations in each direction + n_params = int((n_fit_params + 1) / 2) + + def __init__(self, **kwargs): + """Empty motion model, returns nan for values and inf for uncertainties. + """ + super().__init__() + return + + def model_fit(self, dt): + return np.full_like(dt, np.nan) + + def model(self, t, fit_params, fit_param_errs=None, fixed_params_dict=None): + """Predicted positions (and uncertainties, if fit_param_errs is provided) at time t of Empty model. + + Parameters + ---------- + t : scalar or array-like + Time(s) at which to evaluate the model. The shape decides + whether the times are shared across stars or are per-star -- + nothing is inferred from ``len(t)`` matching ``N_stars``. Accepts + a scalar (one time, every star), ``(N_times,)`` or + ``(1, N_times)`` (one shared grid for every star -- always, even + when ``N_times == N_stars``), or ``(N_stars, N_times)`` (each star + its own times); for one time per star pass ``t[:, np.newaxis]``. + Any other shape raises ``ValueError``. See + :func:`broadcast_times` for the full table. + fit_params : array-like + Fit parameters, shape (N_fit_params,) or (N_stars, N_fit_params) + fit_param_errs : array-like, optional + Uncertainties for fit parameters, not applicable for Empty model, by default None + fixed_params_dict : dict, optional + Not applicable for Empty model, by default None + + Returns + ------- + x, y (, xe, ye) + Predicted position (and uncertainties) of Empty model, shape (N_times,) + """ + self._check_param_dimensions(fit_params, fit_param_errs, fixed_params_dict) + + fit_params = np.atleast_2d(fit_params) # (N_stars, N_fit_params) + + N_stars = fit_params.shape[0] + # See broadcast_times: t's shape alone says shared-grid vs per-star. + N_times = broadcast_times(t, N_stars, caller='Empty.model').shape[1] + + x = np.full((N_stars, N_times), np.nan) + y = np.full((N_stars, N_times), np.nan) + + if N_stars == 1 or N_times == 1: + # Same squeeze convention as every other model + x = x.flatten() + y = y.flatten() + + if fit_param_errs is None: + return x, y + return x, y, np.full_like(x, np.inf), np.full_like(y, np.inf) + + def run_fit(self, t, x, y, xe, ye, valid, fixed_params_dict=None, weighting='var', + absolute_sigma=True, fill_value=np.nan, verbose=True): + """ + Batch fit for many stars at once. Empty's "fit" never looks at any + data -- it's always fill_value/inf regardless of what's passed in + -- so there's no actual computation to batch. This exists purely + so that a table containing some Empty stars (there is almost + always at least a handful, e.g. stars with 0 valid epochs) + doesn't force the caller to spin up a + multiprocessing pool -- and pay its real, fixed per-worker spawn + cost -- just to run this trivial, zero-cost case one star at a time. + + Parameters + ---------- + t, x, y, xe, ye, valid : array-like, shape (n_stars, n_epochs) + Unused -- accepted only for interface consistency with other + motion models' run_fit. + fixed_params_dict, weighting, absolute_sigma : unused. + fill_value : float, optional + Fill value for parameters when not enough data points to fit model, by default np.nan + verbose : bool, optional + Print warning messages, by default True + + Returns + ------- + params : ndarray, shape (n_stars, 0) + param_errs : ndarray, shape (n_stars, 0) + chi2_x, chi2_y : ndarray, shape (n_stars,), all nan + """ + n_stars = t.shape[0] + params = np.full((n_stars, self.n_fit_params), fill_value) + param_errs = np.full((n_stars, self.n_fit_params), np.inf) + chi2x = np.full(n_stars, np.nan) + chi2y = np.full(n_stars, np.nan) + return params, param_errs, chi2x, chi2y + + +class Fixed(MotionModel): + """ + A non-moving motion model for a star on the sky. + """ + name = "Fixed" + fit_param_names = ['x0','y0'] + fixed_param_names = [] + required_fixed_param_names = [] + optional_fixed_params = {} + + n_fit_params = len(fit_param_names) + # Number of fit parameters/required observations in each direction + n_params = int((n_fit_params + 1) / 2) + + def __init__(self, **kwargs): + # Must call after setting parameters. + # This checks for proper parameter formatting. + super().__init__() + return + + def model_fit(self, dt, x0): + """Fit function for Fixed motion model + + Parameters + ---------- + dt : array-like + Time offset, shape (N_times,) + x0 : float or array-like + Average positions, scalar or shape (N_stars,) + + Returns + ------- + x : array-like + Predicted positions, shape (N_times,) if scalar x0, else (N_stars, N_times) + """ + return x0 + np.zeros_like(x0) * dt + + def model(self, t, fit_params, fit_param_errs=None, fixed_params_dict=None): + """Predicted positions (and uncertainties, if fit_param_errs is provided) at time t of Fixed model. + + Parameters + ---------- + t : scalar or array-like + Time(s) at which to evaluate the model. The shape decides + whether the times are shared across stars or are per-star -- + nothing is inferred from ``len(t)`` matching ``N_stars``. Accepts + a scalar (one time, every star), ``(N_times,)`` or + ``(1, N_times)`` (one shared grid for every star -- always, even + when ``N_times == N_stars``), or ``(N_stars, N_times)`` (each star + its own times); for one time per star pass ``t[:, np.newaxis]``. + Any other shape raises ``ValueError``. See + :func:`broadcast_times` for the full table. + fit_params : array-like + x0, y0 in shape (N_fit_params,) or (N_stars, N_fit_params) + fit_param_errs : array-like, optional + Uncertainties for x0, y0 in shape (N_fit_params,) or (N_stars, N_fit_params), by default None + fixed_params_dict : dict, optional + Not applicable for Fixed, by default None + + + Returns + ------- + x, y (, xe, ye) + Predicted position (and uncertainties) of Fixed model, shape (N_stars, N_times), or (N_times,) if N_stars=1, or (N_stars,) if N_times=1 + """ + self.fixed_params_dict = fixed_params_dict + fit_params = np.atleast_2d(fit_params) # (N_stars, N_fit_params) + self._check_param_dimensions(fit_params, fit_param_errs, fixed_params_dict) + + N_stars = fit_params.shape[0] + # See broadcast_times: t's shape alone says shared-grid vs per-star. + dt = broadcast_times(t, N_stars, caller='Fixed.model') # (N_stars, N_times) + N_times = dt.shape[1] + x0, y0 = fit_params.T # Each shape (N_stars,) + + # Return results in (N_stars, N_times) shape. Fixed is + # time-independent, so dt only sets the output shape here. + x = self.model_fit(dt, x0[:, np.newaxis]) # Shape (N_stars, N_times) + y = self.model_fit(dt, y0[:, np.newaxis]) # Shape (N_stars, N_times) + + if N_stars == 1 or N_times == 1: + # If only one star, return flattened arrays + x = x.flatten() + y = y.flatten() + + if fit_param_errs is None: + return x, y + + fit_param_errs = np.atleast_2d(fit_param_errs) # (N_stars, N_fit_params) + x0_err, y0_err = fit_param_errs.T + + # Return results in (N_stars, N_times) shape + x_err = np.broadcast_to(x0_err[:, np.newaxis], (N_stars, N_times)) + y_err = np.broadcast_to(y0_err[:, np.newaxis], (N_stars, N_times)) + + if N_stars == 1 or N_times == 1: + # If only one star, return flattened arrays + x_err = x_err.flatten() + y_err = y_err.flatten() + + return x, y, x_err, y_err + + def run_fit(self, t, x, y, xe, ye, valid, fixed_params_dict=None, weighting='var', + absolute_sigma=True, fill_value=np.nan, verbose=True): + """ + Batch fit for many stars at once. Fixed's fit is closed-form (a + weighted average -- no iterative optimizer), so nothing about it + actually requires fitting one star at a time; this fits the whole + batch in one pass instead of looping (or spinning up + multiprocessing for) each star individually. + + Parameters + ---------- + t, x, y, xe, ye : array-like, shape (n_stars, n_epochs) + Per-star, per-epoch data. Entries where `valid` is False are + ignored -- their content does not matter (e.g. they can be NaN + placeholders for undetected epochs). + valid : array-like of bool, shape (n_stars, n_epochs) + Which entries are usable for each star. + fixed_params_dict : dict, optional + Unused -- Fixed has no fixed params -- accepted only so callers + can call run_fit() uniformly across motion models (e.g. + Linear requires fixed_params_dict={'t0': ...}), by default None. + weighting : str, optional + 'var' (w=1/xe**2, 1/ye**2) or 'std' (w=1/xe, 1/ye), by default 'var' + absolute_sigma : bool, optional + If False, parameter errors are rescaled by the reduced chi^2, by default True + fill_value : float, optional + Fill value for parameters when not enough data points to fit model, by default np.nan + verbose : bool, optional + Print warning messages, by default True + + Returns + ------- + params : ndarray, shape (n_stars, 2) + param_errs : ndarray, shape (n_stars, 2) + chi2_x, chi2_y : ndarray, shape (n_stars,) + """ + n_valid = valid.sum(axis=1) + has_data = n_valid >= self.n_params # degree_of_freedom >= 0 + + if verbose and np.any(~has_data): + warnings.warn( + f'Not enough data points to fit model for {np.sum(~has_data)} star(s). ' + f'Setting parameters to {fill_value} and uncertainties to np.inf.', + OptimizeWarning, stacklevel=2 + ) + + sigma_x, sigma_y = sigma_from_error(xe, ye, weighting=weighting) + x_wt = weight_from_sigma(sigma_x, valid) + y_wt = weight_from_sigma(sigma_y, valid) + + x_wt_sum = x_wt.sum(axis=1) + y_wt_sum = y_wt.sum(axis=1) + x_masked = np.where(valid, x, 0.0) + y_masked = np.where(valid, y, 0.0) + + with np.errstate(divide='ignore', invalid='ignore'): + x0 = (x_masked * x_wt).sum(axis=1) / x_wt_sum + y0 = (y_masked * y_wt).sum(axis=1) / y_wt_sum + x0e = 1. / np.sqrt(x_wt_sum) + y0e = 1. / np.sqrt(y_wt_sum) + + params = np.column_stack([x0, y0]) + param_errs = np.column_stack([x0e, y0e]) + + # chi2: Fixed's prediction is time-independent (x_pred == x0 for every + # epoch). Weighted with x_wt/y_wt -- the same (weighting-scheme) + # weights the fit itself used, as in Linear/Acceleration/Parallax -- + # NOT a re-derived 1/xe**2. Those two only coincide for + # weighting='var'; under weighting='std' the fit's weight is 1/xe, so + # dividing by xe**2 here would report a chi2 inconsistent with the fit + # (and, via the absolute_sigma=False rescaling below, wrong parameter + # errors that disagree with scipy.optimize.curve_fit). Using the + # weights also makes an epoch with unusable xe/ye (weight 0) drop out + # of chi2 cleanly instead of poisoning the whole sum with nan. + with np.errstate(divide='ignore', invalid='ignore'): + chi2x = (x_wt * (x_masked - x0[:, np.newaxis])**2).sum(axis=1) + chi2y = (y_wt * (y_masked - y0[:, np.newaxis])**2).sum(axis=1) + + if not absolute_sigma: + dof = n_valid - self.n_params + dof_pos = dof > 0 + with np.errstate(divide='ignore', invalid='ignore'): + reduced_chi2x = np.where(dof_pos, chi2x / np.where(dof_pos, dof, 1), 1.0) + reduced_chi2y = np.where(dof_pos, chi2y / np.where(dof_pos, dof, 1), 1.0) + param_errs[:, 0] = np.where(dof_pos, param_errs[:, 0] * np.sqrt(reduced_chi2x), np.inf) + param_errs[:, 1] = np.where(dof_pos, param_errs[:, 1] * np.sqrt(reduced_chi2y), np.inf) + if verbose and np.any(has_data & ~dof_pos): + warnings.warn( + 'Degree of freedom <= 0 for some star(s). Covariance of the parameters could not be ' + 'estimated. Setting parameter uncertainties to np.inf.', + OptimizeWarning, stacklevel=2 + ) + + # Not-enough-data stars: overwrite with fill_value/inf/nan regardless + # of whatever the (meaningless, e.g. 0/0) computation above produced. + params[~has_data] = fill_value + param_errs[~has_data] = np.inf + chi2x[~has_data] = np.nan + chi2y[~has_data] = np.nan + + return params, param_errs, chi2x, chi2y + +class Linear(MotionModel): + """ + A 2D linear motion model for a star on the sky. + """ + name = "Linear" + fit_param_names = ['x0', 'vx', 'y0', 'vy'] + required_fixed_param_names = ['t0'] + optional_fixed_params = {} + fixed_param_names = required_fixed_param_names + list(optional_fixed_params.keys()) + + n_fit_params = len(fit_param_names) + # Number of fit parameters/required observations in each direction + n_params = int((n_fit_params + 1) / 2) + + def __init__(self, **kwargs): + # Must call after setting parameters. + # This checks for proper parameter formatting. + super().__init__() + return + + def model_fit(self, dt, x0, v): + """Linear motion model fit function + + Parameters + ---------- + dt : array-like + Time offset, shape (N_times,) + x0 : float or array-like + Initial position, shape (N_stars,) or scalar + v : float or array-like + Velocity, shape (N_stars,) or scalar + + Returns + ------- + x : array-like + Predicted position(s) + """ + return x0 + v * dt + + def model(self, t, fit_params, fit_param_errs=None, fixed_params_dict=None): + """Model positions (and uncertainties, if fit_param_errs is provided) at time t of Linear model. + + Parameters + ---------- + t : scalar or array-like + Time(s) at which to evaluate the model. The shape decides + whether the times are shared across stars or are per-star -- + nothing is inferred from ``len(t)`` matching ``N_stars``. Accepts + a scalar (one time, every star), ``(N_times,)`` or + ``(1, N_times)`` (one shared grid for every star -- always, even + when ``N_times == N_stars``), or ``(N_stars, N_times)`` (each star + its own times); for one time per star pass ``t[:, np.newaxis]``. + Any other shape raises ``ValueError``. See + :func:`broadcast_times` for the full table. + fit_params : array-like + x0, vx, y0, vy in shape (N_fit_params,) or (N_stars, N_fit_params) + fit_param_errs : array-like, optional + Uncertainties of fit parameters in shape (N_fit_params,) or (N_stars, N_fit_params), by default None + fixed_params_dict : dict, optional + t0, shape (1,) or (N_stars,), by default None. + + Returns + ------- + x, y (, xe, ye) + Predicted positions (and uncertainties, if fit_param_errs is provided) with shape (N_stars, N_times), or (N_times,) if N_stars=1, or (N_stars,) if N_times=1 + """ + if fixed_params_dict is None: + fixed_params_dict = self.fixed_params_dict + assert 't0' in fixed_params_dict, "Fixed parameter t0 is required for Linear model." + self._check_param_dimensions(fit_params, fit_param_errs, fixed_params_dict) + + fit_params = np.atleast_2d(fit_params) # (N_stars, N_fit_params) + + N_stars = fit_params.shape[0] + # See broadcast_times: t's shape alone says shared-grid vs per-star. + t_grid = broadcast_times(t, N_stars, caller='Linear.model') # (N_stars, N_times) + N_times = t_grid.shape[1] + + x0, vx, y0, vy = fit_params.T # Each shape (N_stars,) + t0 = np.broadcast_to(np.atleast_1d(fixed_params_dict['t0']), (N_stars,)) + + dt = t_grid - np.asarray(t0)[:, np.newaxis] # Shape (N_stars, N_times) + + x = self.model_fit(dt, x0[:, np.newaxis], vx[:, np.newaxis]) # Shape (N_stars, N_times) + y = self.model_fit(dt, y0[:, np.newaxis], vy[:, np.newaxis]) # Shape (N_stars, N_times) + + if N_stars == 1 or N_times == 1: + # If only one star, return flattened arrays + x = x.flatten() + y = y.flatten() + + if fit_param_errs is None: + return x, y + + fit_param_errs = np.atleast_2d(fit_param_errs) # (N_stars, N_fit_params) + x0_err, vx_err, y0_err, vy_err = fit_param_errs.T # Each shape (N_stars,) + x_err = np.hypot(x0_err[:, np.newaxis], vx_err[:, np.newaxis] * dt) # Shape (N_stars, N_times) + y_err = np.hypot(y0_err[:, np.newaxis], vy_err[:, np.newaxis] * dt) # Shape (N_stars, N_times) + + if N_stars == 1 or N_times == 1: + # If only one star, return flattened arrays + x_err = x_err.flatten() + y_err = y_err.flatten() + return x, y, x_err, y_err + + + def run_fit(self, t, x, y, xe, ye, valid, fixed_params_dict=None, weighting='var', + absolute_sigma=True, fill_value=np.nan, verbose=True): + """ + Batch fit for many stars at once. Linear's weighted least-squares + fit is closed-form (the normal equations, no iterative optimizer) + -- so, like Fixed, it doesn't actually need to run one star at a + time. Rather than building a full (n_epochs, n_epochs) diagonal + weight matrix and calling np.linalg.pinv/matrix_rank (SVD-based) + per star -- wasteful work for what's always exactly a 2x2 system + -- this computes the five weighted sums the 2x2 normal-equations + matrix needs via vectorized .sum(axis=1) calls across the whole + batch, and solves/inverts that 2x2 system with its closed-form + (adjugate-over-determinant) formula. + + Parameters + ---------- + t, x, y, xe, ye : array-like, shape (n_stars, n_epochs) + Per-star, per-epoch data. Entries where `valid` is False are + ignored -- their content does not matter (e.g. they can be NaN + placeholders for undetected epochs). + valid : array-like of bool, shape (n_stars, n_epochs) + Which entries are usable for each star. + fixed_params_dict : dict, optional + Must contain 't0', either a scalar or shape (n_stars,), by default None. + weighting : str, optional + 'var' (w=1/xe**2, 1/ye**2) or 'std' (w=1/xe, 1/ye), by default 'var' + absolute_sigma : bool, optional + If False, parameter errors are rescaled by the reduced chi^2, by default True + fill_value : float, optional + Fill value for parameters when not enough data points to fit model, by default np.nan + verbose : bool, optional + Print warning messages, by default True + + Returns + ------- + params : ndarray, shape (n_stars, 4) -- [x0, vx, y0, vy] + param_errs : ndarray, shape (n_stars, 4) + chi2_x, chi2_y : ndarray, shape (n_stars,) + """ + assert fixed_params_dict is not None and 't0' in fixed_params_dict, \ + "Linear.run_fit requires fixed_params_dict={'t0': ...}." + + n_stars, n_epochs = t.shape + t0 = np.broadcast_to(np.atleast_1d(fixed_params_dict['t0']), (n_stars,)).astype(float) + dt = t - t0[:, np.newaxis] + + n_valid = valid.sum(axis=1) + has_data = n_valid >= self.n_params # degree_of_freedom >= 0 + + if verbose and np.any(~has_data): + warnings.warn( + f'Not enough data points to fit model for {np.sum(~has_data)} star(s). ' + f'Setting parameters to {fill_value} and uncertainties to np.inf.', + OptimizeWarning, stacklevel=2 + ) + + sigma_x, sigma_y = sigma_from_error(xe, ye, weighting=weighting) + x_wt = weight_from_sigma(sigma_x, valid) + y_wt = weight_from_sigma(sigma_y, valid) + + dt_m = np.where(valid, dt, 0.0) + x_m = np.where(valid, x, 0.0) + y_m = np.where(valid, y, 0.0) + + def solve(wt, val_m): + # Weighted normal-equations matrix for [v, x0] (basis [dt, 1]): + # [[Swdt2, Swdt], [Swdt, Sw]] @ [v, x0] = [Swdtv, Swv] + # Solved and inverted in closed form (2x2 adjugate/det) rather + # than via np.linalg.pinv/matrix_rank. + Sw = wt.sum(axis=1) + Swdt = (wt * dt_m).sum(axis=1) + Swdt2 = (wt * dt_m**2).sum(axis=1) + Swv = (wt * val_m).sum(axis=1) + Swdtv = (wt * dt_m * val_m).sum(axis=1) + + det = Swdt2 * Sw - Swdt**2 + # Singular (e.g. every valid epoch at the same time): a direct + # determinant tolerance instead of an SVD-based matrix_rank check. + scale = np.maximum(Sw * Swdt2, np.finfo(float).tiny) + singular = has_data & (np.abs(det) <= 1e-12 * scale) + + with np.errstate(divide='ignore', invalid='ignore'): + v = (Sw * Swdtv - Swdt * Swv) / det + v0 = (Swdt2 * Swv - Swdt * Swdtv) / det + v_err = np.sqrt(Sw / det) + v0_err = np.sqrt(Swdt2 / det) + + if verbose and np.any(singular): + warnings.warn( + 'Singular matrix. Covariance of the parameters could not be estimated. ' + 'Setting parameter uncertainties to np.inf.', + OptimizeWarning, stacklevel=2 + ) + v_err[singular] = np.inf + v0_err[singular] = np.inf + # A singular system (e.g. every valid epoch at the same time) has + # no well-defined [v, x0] split -- only their particular combination + # is constrained -- so unlike np.linalg.pinv's arbitrary + # minimum-norm choice, report fill_value here rather than a + # specific-but-meaningless number. The error is inf either way, + # so nothing downstream should be trusting this value regardless. + v[singular] = fill_value + v0[singular] = fill_value + + return v0, v, v0_err, v_err, singular + + x0, vx, x0e, vxe, singular_x = solve(x_wt, x_m) + y0, vy, y0e, vye, singular_y = solve(y_wt, y_m) + + params = np.column_stack([x0, vx, y0, vy]) + param_errs = np.column_stack([x0e, vxe, y0e, vye]) + + # chi2 = weighted sum of squared residuals (residual.T @ W @ residual), + # using the same (weighting-scheme) weights the fit itself used. + with np.errstate(divide='ignore', invalid='ignore'): + chi2x = (x_wt * (x_m - (vx[:, np.newaxis] * dt_m + x0[:, np.newaxis]))**2).sum(axis=1) + chi2y = (y_wt * (y_m - (vy[:, np.newaxis] * dt_m + y0[:, np.newaxis]))**2).sum(axis=1) + # A singular fit has no real params to compute a residual from + # (regardless of what fill_value happens to be) -- nan them + # explicitly rather than relying on fill_value being nan. + chi2x[singular_x] = np.nan + chi2y[singular_y] = np.nan + + if not absolute_sigma: + dof = n_valid - self.n_params + dof_pos = dof > 0 + with np.errstate(divide='ignore', invalid='ignore'): + reduced_chi2x = np.where(dof_pos, chi2x / np.where(dof_pos, dof, 1), 1.0) + reduced_chi2y = np.where(dof_pos, chi2y / np.where(dof_pos, dof, 1), 1.0) + param_errs[:, 0] = np.where(dof_pos, param_errs[:, 0] * np.sqrt(reduced_chi2x), np.inf) + param_errs[:, 1] = np.where(dof_pos, param_errs[:, 1] * np.sqrt(reduced_chi2x), np.inf) + param_errs[:, 2] = np.where(dof_pos, param_errs[:, 2] * np.sqrt(reduced_chi2y), np.inf) + param_errs[:, 3] = np.where(dof_pos, param_errs[:, 3] * np.sqrt(reduced_chi2y), np.inf) + if verbose and np.any(has_data & ~dof_pos): + warnings.warn( + 'Degree of freedom <= 0 for some star(s). Covariance of the parameters could not be ' + 'estimated. Setting parameter uncertainties to np.inf.', + OptimizeWarning, stacklevel=2 + ) + + # Not-enough-data and singular stars: overwrite with fill_value/inf/nan + # regardless of whatever the (meaningless, e.g. 0/0, or inf*nan from + # the absolute_sigma=False rescaling above) computation produced. + # This must come last -- e.g. the rescaling above would otherwise + # silently turn a singular star's correct inf error into nan + # (inf * sqrt(nan) == nan, not inf). + params[~has_data] = fill_value + param_errs[~has_data] = np.inf + chi2x[~has_data] = np.nan + chi2y[~has_data] = np.nan + param_errs[singular_x, 0] = np.inf + param_errs[singular_x, 1] = np.inf + param_errs[singular_y, 2] = np.inf + param_errs[singular_y, 3] = np.inf + + return params, param_errs, chi2x, chi2y + +class Acceleration(MotionModel): + """ + A 2D accelerating motion model for a star on the sky. + """ + name = "Acceleration" + fit_param_names = ['x0', 'vx0', 'ax', 'y0', 'vy0', 'ay'] + required_fixed_param_names = ['t0'] + optional_fixed_params = {} + fixed_param_names = required_fixed_param_names + list(optional_fixed_params.keys()) + + n_fit_params = len(fit_param_names) + # Number of required observations in each direction + n_params = int((n_fit_params + 1) / 2) + + def __init__(self): + # Must call after setting parameters. + # This checks for proper parameter formatting. + super().__init__() + return + + def model_fit(self, t, x0, v0, a): + """Model positions at time t of Acceleration model. + + Parameters + ---------- + t : float or array-like + Time(s) at which to evaluate the model + x0 : float or array-like + Initial position(s) + v0 : float or array-like + Initial velocity(ies) + a : float or array-like + Acceleration(s) + + Returns + ------- + float or array-like + Model positions at time t of Acceleration model + """ + return x0 + v0*t + 0.5*a*t**2 + + def model(self, t, fit_params, fit_param_errs=None, fixed_params_dict=None): + """Model positions (and uncertainties, if fit_param_errs is provided) at time t of Acceleration model. + + Parameters + ---------- + t : scalar or array-like + Time(s) at which to evaluate the model. The shape decides + whether the times are shared across stars or are per-star -- + nothing is inferred from ``len(t)`` matching ``N_stars``. Accepts + a scalar (one time, every star), ``(N_times,)`` or + ``(1, N_times)`` (one shared grid for every star -- always, even + when ``N_times == N_stars``), or ``(N_stars, N_times)`` (each star + its own times); for one time per star pass ``t[:, np.newaxis]``. + Any other shape raises ``ValueError``. See + :func:`broadcast_times` for the full table. + fit_params : array-like + x0, vx, ax, y0, vy, ay in shape (N_fit_params,) or (N_stars, N_fit_params) + fit_param_errs : array-like, optional + Fit parameter uncertainties with shape (N_stars, N_fit_params) or (N_fit_params,), by default None + fixed_params_dict : dict, optional + t0, shape (1,) or (N_stars,), by default None. + + Returns + ------- + x, y (, xe, ye) + Predicted positions (and uncertainties, if fit_param_errs is provided) with shape (N_stars, N_times), or (N_times,) if N_stars=1, or (N_stars,) if N_times=1 + """ + if fixed_params_dict is None: + fixed_params_dict = self.fixed_params_dict + assert 't0' in fixed_params_dict, "Fixed parameter t0 is required for Acceleration model." + self._check_param_dimensions(fit_params, fit_param_errs, fixed_params_dict) + + fit_params = np.atleast_2d(fit_params) # (N_stars, N_fit_params) + + N_stars = fit_params.shape[0] + # See broadcast_times: t's shape alone says shared-grid vs per-star. + t_grid = broadcast_times(t, N_stars, caller='Acceleration.model') # (N_stars, N_times) + N_times = t_grid.shape[1] + + x0, vx0, ax, y0, vy0, ay = fit_params.T # Each shape (N_stars,) + t0 = np.broadcast_to(np.atleast_1d(fixed_params_dict['t0']), (N_stars,)) + + dt = t_grid - np.asarray(t0)[:, np.newaxis] # Shape (N_stars, N_times) + + x = self.model_fit(dt, x0[:, np.newaxis], vx0[:, np.newaxis], ax[:, np.newaxis]) # Shape (N_stars, N_times) + y = self.model_fit(dt, y0[:, np.newaxis], vy0[:, np.newaxis], ay[:, np.newaxis]) # Shape (N_stars, N_times) + + if N_stars == 1 or N_times == 1: + # If only one star, return flattened arrays + x = x.flatten() + y = y.flatten() + + if fit_param_errs is None: + return x, y + + fit_param_errs = np.atleast_2d(fit_param_errs) # (N_stars, N_fit_params) + x0_err, vx0_err, ax_err, y0_err, vy0_err, ay_err = fit_param_errs.T + x_err = np.sqrt(x0_err[:, np.newaxis]**2 + (vx0_err[:, np.newaxis] * dt)**2 + (0.5 * ax_err[:, np.newaxis] * dt**2)**2) # Shape (N_stars, N_times) + y_err = np.sqrt(y0_err[:, np.newaxis]**2 + (vy0_err[:, np.newaxis] * dt)**2 + (0.5 * ay_err[:, np.newaxis] * dt**2)**2) # Shape (N_stars, N_times) + + if N_stars == 1 or N_times == 1: + # If only one star, return flattened arrays + x_err = x_err.flatten() + y_err = y_err.flatten() + + return x, y, x_err, y_err + + + + def run_fit(self, t, x, y, xe, ye, valid, fixed_params_dict=None, weighting='var', + absolute_sigma=True, fill_value=np.nan, verbose=True): + """ + Batch fit for many stars at once. Acceleration's model + (x0 + vx0*dt + 0.5*ax*dt**2) is linear + in its fit parameters despite being quadratic in time, so its + weighted least-squares fit is closed-form too -- same situation as + Linear, just with a 3-parameter (instead of 2-parameter) basis + [1, dt, 0.5*dt**2] per direction. Unlike Linear, this solves the + 3x3 normal-equations system with a batched np.linalg.inv rather + than a hand-derived closed-form adjugate -- deriving that by hand + for a 3x3 system is error-prone for little extra speed over + LAPACK's own (also closed-form, non-iterative) solver. + + Parameters + ---------- + t, x, y, xe, ye : array-like, shape (n_stars, n_epochs) + Per-star, per-epoch data. Entries where `valid` is False are + ignored -- their content does not matter (e.g. they can be NaN + placeholders for undetected epochs). + valid : array-like of bool, shape (n_stars, n_epochs) + Which entries are usable for each star. + fixed_params_dict : dict, optional + Must contain 't0', either a scalar or shape (n_stars,), by default None. + weighting : str, optional + 'var' (w=1/xe**2, 1/ye**2) or 'std' (w=1/xe, 1/ye), by default 'var' + absolute_sigma : bool, optional + If False, parameter errors are rescaled by the reduced chi^2, by default True + fill_value : float, optional + Fill value for parameters when not enough data points to fit model, by default np.nan + verbose : bool, optional + Print warning messages, by default True + + Returns + ------- + params : ndarray, shape (n_stars, 6) -- [x0, vx0, ax, y0, vy0, ay] + param_errs : ndarray, shape (n_stars, 6) + chi2_x, chi2_y : ndarray, shape (n_stars,) + """ + assert fixed_params_dict is not None and 't0' in fixed_params_dict, \ + "Acceleration.run_fit requires fixed_params_dict={'t0': ...}." + + n_stars, n_epochs = t.shape + t0 = np.broadcast_to(np.atleast_1d(fixed_params_dict['t0']), (n_stars,)).astype(float) + dt = t - t0[:, np.newaxis] + + n_valid = valid.sum(axis=1) + has_data = n_valid >= self.n_params # degree_of_freedom >= 0 + + if verbose and np.any(~has_data): + warnings.warn( + f'Not enough data points to fit model for {np.sum(~has_data)} star(s). ' + f'Setting parameters to {fill_value} and uncertainties to np.inf.', + OptimizeWarning, stacklevel=2 + ) + + sigma_x, sigma_y = sigma_from_error(xe, ye, weighting=weighting) + x_wt = weight_from_sigma(sigma_x, valid) + y_wt = weight_from_sigma(sigma_y, valid) + + dt_m = np.where(valid, dt, 0.0) + dt2_m = 0.5 * dt_m**2 + x_m = np.where(valid, x, 0.0) + y_m = np.where(valid, y, 0.0) + + def solve(wt, val_m): + # Weighted normal-equations matrix for [x0, v0, a] built from + # basis [1, dt, 0.5*dt**2]. + S0 = wt.sum(axis=1) + S1 = (wt * dt_m).sum(axis=1) + S2 = (wt * dt2_m).sum(axis=1) + S11 = (wt * dt_m**2).sum(axis=1) + S12 = (wt * dt_m * dt2_m).sum(axis=1) + S22 = (wt * dt2_m**2).sum(axis=1) + + r0 = (wt * val_m).sum(axis=1) + r1 = (wt * val_m * dt_m).sum(axis=1) + r2 = (wt * val_m * dt2_m).sum(axis=1) + + M = np.zeros((n_stars, 3, 3)) + M[:, 0, 0] = S0 + M[:, 0, 1] = M[:, 1, 0] = S1 + M[:, 0, 2] = M[:, 2, 0] = S2 + M[:, 1, 1] = S11 + M[:, 1, 2] = M[:, 2, 1] = S12 + M[:, 2, 2] = S22 + r = np.column_stack([r0, r1, r2]) + + # Singular (e.g. fewer than 3 unique valid times): a direct + # determinant tolerance instead of an SVD-based matrix_rank check. + det = np.linalg.det(M) + scale = np.maximum(np.abs(S0 * S11 * S22), np.finfo(float).tiny) + singular = has_data & (np.abs(det) <= 1e-12 * scale) + unsafe = singular | ~has_data + + if verbose and np.any(singular): + warnings.warn( + 'Singular matrix. Covariance of the parameters could not be estimated. ' + 'Setting parameter uncertainties to np.inf.', + OptimizeWarning, stacklevel=2 + ) + + # Stars with too little/degenerate data would otherwise send a + # singular matrix into np.linalg.inv for the whole batch (which + # raises, unlike a per-star pinv) -- swap those in for a safe + # placeholder first; their real params/errs get overwritten + # with fill_value/inf below regardless of what this produces. + M_safe = M.copy() + M_safe[unsafe] = np.eye(3) + + cov = np.linalg.inv(M_safe) + params = np.einsum('nij,nj->ni', cov, r) + param_errs = np.sqrt(np.diagonal(cov, axis1=1, axis2=2)) + + params[unsafe] = fill_value + param_errs[unsafe] = np.inf + + return params, param_errs, singular + + x_params, x_errs, singular_x = solve(x_wt, x_m) + y_params, y_errs, singular_y = solve(y_wt, y_m) + + params = np.column_stack([x_params, y_params]) + param_errs = np.column_stack([x_errs, y_errs]) + + # chi2 = weighted sum of squared residuals (residual.T @ W @ residual), + # using the same (weighting-scheme) weights the fit itself used. + with np.errstate(divide='ignore', invalid='ignore'): + x_model = x_params[:, 0:1] + x_params[:, 1:2] * dt_m + x_params[:, 2:3] * dt2_m + y_model = y_params[:, 0:1] + y_params[:, 1:2] * dt_m + y_params[:, 2:3] * dt2_m + chi2x = (x_wt * (x_m - x_model)**2).sum(axis=1) + chi2y = (y_wt * (y_m - y_model)**2).sum(axis=1) + # A singular fit has no real params to compute a residual from + # (regardless of what fill_value happens to be) -- nan them + # explicitly rather than relying on fill_value being nan. + chi2x[singular_x] = np.nan + chi2y[singular_y] = np.nan + + if not absolute_sigma: + dof = n_valid - self.n_params + dof_pos = dof > 0 + with np.errstate(divide='ignore', invalid='ignore'): + reduced_chi2x = np.where(dof_pos, chi2x / np.where(dof_pos, dof, 1), 1.0) + reduced_chi2y = np.where(dof_pos, chi2y / np.where(dof_pos, dof, 1), 1.0) + for jj in range(3): + param_errs[:, jj] = np.where(dof_pos, param_errs[:, jj] * np.sqrt(reduced_chi2x), np.inf) + param_errs[:, 3 + jj] = np.where(dof_pos, param_errs[:, 3 + jj] * np.sqrt(reduced_chi2y), np.inf) + if verbose and np.any(has_data & ~dof_pos): + warnings.warn( + 'Degree of freedom <= 0 for some star(s). Covariance of the parameters could not be ' + 'estimated. Setting parameter uncertainties to np.inf.', + OptimizeWarning, stacklevel=2 + ) + + # Not-enough-data and singular stars: overwrite with fill_value/inf/nan + # regardless of whatever the (meaningless, e.g. 0/0, or inf*nan from + # the absolute_sigma=False rescaling above) computation produced. + # This must come last -- e.g. the rescaling above would otherwise + # silently turn a singular star's correct inf error into nan + # (inf * sqrt(nan) == nan, not inf). + params[~has_data] = fill_value + param_errs[~has_data] = np.inf + chi2x[~has_data] = np.nan + chi2y[~has_data] = np.nan + for jj in range(3): + param_errs[singular_x, jj] = np.inf + param_errs[singular_y, 3 + jj] = np.inf + + return params, param_errs, chi2x, chi2y + +class Parallax(MotionModel): + """ + Motion model for linear proper motion + parallax + + Requires RA and Dec J2000 (degrees) for parallax calculation. + Optional PA is counterclockwise offset of the image y-axis from North. + Optional obs parameter describes observer location, default is 'earth'. + """ + name = "Parallax" + fit_param_names = ['x0', 'vx', 'y0', 'vy', 'pi'] + required_fixed_param_names = ['t0', 'ra', 'dec'] + optional_fixed_params = {'pa': 0., 'obsLocation': 'earth'} + fixed_param_names = required_fixed_param_names + list(optional_fixed_params.keys()) + + + n_fit_params = len(fit_param_names) + # Number of required observations in each direction + n_params = int((n_fit_params + 1) / 2) + + def __init__(self): + super().__init__() + self.pvec_cached = None # Cache for parallax vector + self.t_mjd_cached = None # Cache for times corresponding to cached parallax vector + return + + def calc_parallax_vector(self, t_mjd, ra, dec, pa=0., obsLocation='earth'): + """Calculate parallax vector of shape (N_stars, 2, N_times) + + Parameters + ---------- + t_mjd : array-like + Time array in MJD, **in the TDB scale**. parallax_in_direction + declares its input TDB without converting, so a UTC MJD would be + read as TDB and shift every epoch by ~69 s. Callers converting from + decimal years should use + ``Time(t, format='decimalyear', scale='utc').tdb.mjd``. + ra : float or array-like + Right ascension(s) in degrees + dec : float or array-like + Declination(s) in degrees + pa : float or array-like, optional + Position angle(s) of image y-axis from North in degrees, by default 0. + obsLocation : str, optional + Observer location, by default 'earth' + + Returns + ------- + pvec + Parallax vector of shape (N_stars, 2, N_times), where 2 corresponds to (x, y) components. + """ + if self.pvec_cached is not None: + t_mjd = np.atleast_1d(t_mjd) + t_mjd_cached = self.t_mjd_cached + if np.array_equal(t_mjd, t_mjd_cached): + # If cached values match input times, return cached values + return self.pvec_cached + + elif all(np.isin(t_mjd, t_mjd_cached)): + # If all input times are in cached values, return those + # Calculate pvec_idxs such that t_mjd_cached[ pvec_idxs ] == t_mjd + pvec_idxs = np.array([np.where(t_mjd_cached == t_mjd_i)[0][0] for t_mjd_i in t_mjd]) + pvec = self.pvec_cached[:, :, pvec_idxs] + return pvec + + pvec = parallax.parallax_in_direction(ra, dec, t_mjd, obsLocation=obsLocation, pa=pa) # Shape (N_stars, 2, N_times) + # self.plx_vector_cached = [t_mjd, pvec] + self.t_mjd_cached = t_mjd + self.pvec_cached = pvec + return pvec + + def model_fit(self, dt, x0, vx, y0, vy, pi): + """Model positions at time t of Parallax model. + + Parameters + ---------- + dt : float or array-like + Time(s) at which to evaluate the model + x0 : float or array-like + Initial position(s) + vx : float or array-like + Velocity(ies) + y0 : float or array-like + Initial position(s) + vy : float or array-like + Velocity(ies) + pi : float or array-like + Parallax factor(s) + + Returns + ------- + x_result, y_result : array-like + Model positions at time t of Parallax model, shape (N_stars, N_times) + """ + # x0, vx, y0, vy, pi are all shape (N_stars, N_times) + x_result = x0 + vx * dt + pi * self.pvec[:, 0, :] # Parallax contribution in x direction + y_result = y0 + vy * dt + pi * self.pvec[:, 1, :] # Parallax contribution in y direction + return x_result, y_result + + def model(self, t, fit_params, fit_param_errs=None, fixed_params_dict=None): + """Model positions (and uncertainties, if fit_param_errs is provided) at time t of Parallax model. + + Parameters + ---------- + t : scalar or array-like + Time(s) at which to evaluate the model. The shape decides + whether the times are shared across stars or are per-star -- + nothing is inferred from ``len(t)`` matching ``N_stars``. Accepts + a scalar (one time, every star), ``(N_times,)`` or + ``(1, N_times)`` (one shared grid for every star -- always, even + when ``N_times == N_stars``), or ``(N_stars, N_times)`` (each star + its own times); for one time per star pass ``t[:, np.newaxis]``. + Any other shape raises ``ValueError``. See + :func:`broadcast_times` for the full table. + fit_params : array-like + x0, vx, y0, vy, pi in shape (N_fit_params,) or (N_stars, N_fit_params) + fit_param_errs : array-like, optional + Uncertainties in fit parameters, by default None + fixed_params : dict + - t0, shape (N_stars,) or (1,). + - ra, shape (N_stars,) or (1,). + - dec, shape (N_stars,) or (1,). + - pa, optional, shape (N_stars,) or (1,), by default 0. + - obsLocation, optional, string, by default 'earth' + + Returns + ------- + x, y (, xe, ye) + Predicted positions (and uncertainties, if fit_param_errs is provided) with shape (N_stars, N_times), or (N_times,) if N_stars=1, or (N_stars,) if N_times=1 + """ + if fixed_params_dict is None: + fixed_params_dict = self.fixed_params_dict + assert all([_ in fixed_params_dict for _ in ['t0', 'ra', 'dec']]), "Fixed parameters t0, ra, and dec are required for Parallax model." + self._check_param_dimensions(fit_params, fit_param_errs, fixed_params_dict) + + fit_params = np.atleast_2d(fit_params) # (N_stars, N_fit_params) + + N_stars = fit_params.shape[0] + # See broadcast_times: t's shape alone says shared-grid vs per-star. + t_grid = broadcast_times(t, N_stars, caller='Parallax.model') # (N_stars, N_times) + N_times = t_grid.shape[1] + + x0, vx, y0, vy, pi = fit_params.T # Each shape (N_stars,) + # Broadcast every per-star fixed param to (N_stars,), as run_fit does. + # A scalar ra/dec would otherwise leave the parallax vector with a + # single row, which only happens to broadcast for a shared time grid + # and indexes out of bounds for a per-star one. + t0 = np.broadcast_to(np.atleast_1d(fixed_params_dict['t0']), (N_stars,)) + ra = np.broadcast_to(np.atleast_1d(fixed_params_dict['ra']), (N_stars,)) + dec = np.broadcast_to(np.atleast_1d(fixed_params_dict['dec']), (N_stars,)) + pa = np.broadcast_to(np.atleast_1d(fixed_params_dict.get('pa', 0.0)), (N_stars,)) + obsLocation = fixed_params_dict.get('obsLocation', 'earth') + + # TODO: vectorize parallax.parallax_in_direction to handle multiple obsLocation? + assert isinstance(obsLocation, str) or (np.unique(obsLocation).size == 1), "obsLocation must be a single string for all stars at this time." + if not isinstance(obsLocation, str): + obsLocation = np.unique(obsLocation)[0] + + + dt = t_grid - np.asarray(t0)[:, np.newaxis] # Shape (N_stars, N_times) + + # parallax_in_direction takes a single shared mjd axis, but t_grid may + # give each star its own times -- so evaluate it once for the unique + # times across the whole grid and gather back per (star, epoch). When + # every star shares one grid (the common case) unique_t is just that + # grid and this reduces to a reshape, same as run_fit does. + unique_t, inverse_idx = np.unique(t_grid, return_inverse=True) + inverse_idx = inverse_idx.reshape(t_grid.shape) + # Observation epochs come from UTC timestamps, so convert UTC -> TDB + # properly rather than relabelling the number. parallax_in_direction + # declares its input TDB without converting, so handing it a bare UTC + # MJD shifts every epoch by TDB-UTC (69.184 s as of 2026). + # + # UTC conversion consults the leap-second table, and ERFA warns + # ("dubious year") for dates more than ~5 years past the table in the + # installed pyerfa -- around 2028 for 2.0.1.5. That only affects + # epochs in the future; any real observation is inside the table. + t_mjd = Time(unique_t, format='decimalyear', scale='utc').tdb.mjd + pvec_unique = self.calc_parallax_vector(t_mjd, ra, dec, pa=pa, obsLocation=obsLocation) # (N_stars, 2, n_unique) + star_idx = np.arange(N_stars)[:, np.newaxis] + self.pvec = np.stack( + [pvec_unique[:, 0, :][star_idx, inverse_idx], + pvec_unique[:, 1, :][star_idx, inverse_idx]], + axis=1 + ) # Shape (N_stars, 2, N_times) + x, y = self.model_fit(dt, x0[:, np.newaxis], vx[:, np.newaxis], y0[:, np.newaxis], vy[:, np.newaxis], pi[:, np.newaxis]) # Shape (N_stars, N_times) + + if N_stars == 1 or N_times == 1: + # If only one star, return flattened arrays + x = x.flatten() + y = y.flatten() + + if fit_param_errs is None: + return x, y + + fit_param_errs = np.atleast_2d(fit_param_errs) # (N_stars, N_fit_params) + x0_err, vx_err, y0_err, vy_err, pi_err = fit_param_errs.T + x_err = np.sqrt(x0_err[:, np.newaxis]**2 + (vx_err[:, np.newaxis] * dt)**2 + (pi_err[:, np.newaxis] * self.pvec[:, 0, :])**2) # Shape (N_stars, N_times) + y_err = np.sqrt(y0_err[:, np.newaxis]**2 + (vy_err[:, np.newaxis] * dt)**2 + (pi_err[:, np.newaxis] * self.pvec[:, 1, :])**2) # Shape (N_stars, N_times) + + if N_stars == 1 or N_times == 1: + # If only one star, return flattened arrays + x_err = x_err.flatten() + y_err = y_err.flatten() + return x, y, x_err, y_err + + + + def run_fit(self, t, x, y, xe, ye, valid, fixed_params_dict=None, weighting='var', + absolute_sigma=True, fill_value=np.nan, verbose=True): + """ + Batch fit for many stars at once. Parallax's model + (x0 + vx*dt + pi*Px(t), y0 + vy*dt + pi*Py(t)) is linear in its + fit parameters once the parallax factors Px, Py are precomputed + from each star's fixed ra/dec -- so, like Linear/Acceleration, it + has a closed-form weighted least-squares solution. Unlike those + two, x and y are NOT independent here: pi is shared between both + directions (all 5 params are fit jointly from the stacked [x, y] + data), so this is one coupled 5x5 normal-equations system per + star -- the x0/vx block and y0/vy block only interact with each + other through the shared pi row/column -- solved via a batched + np.linalg.inv. + + Parameters + ---------- + t, x, y, xe, ye : array-like, shape (n_stars, n_epochs) + Per-star, per-epoch data. Entries where `valid` is False are + ignored -- their content does not matter (e.g. they can be NaN + placeholders for undetected epochs). All stars must share the + same observation times (parallax_in_direction only supports + one shared time axis for the whole batch, and ref_table's 2D + 't' column is populated the same way for every star at a + given epoch) -- t[0] is taken as that shared grid. + valid : array-like of bool, shape (n_stars, n_epochs) + Which entries are usable for each star. + fixed_params_dict : dict, optional + Must contain 't0', 'ra', 'dec' (each scalar or shape + (n_stars,)), and optionally 'pa', 'obsLocation', by default None. + weighting : str, optional + 'var' (w=1/xe**2, 1/ye**2) or 'std' (w=1/xe, 1/ye), by default 'var' + absolute_sigma : bool, optional + If False, parameter errors are rescaled by the reduced chi^2, by default True + fill_value : float, optional + Fill value for parameters when not enough data points to fit model, by default np.nan + verbose : bool, optional + Print warning messages, by default True + + Returns + ------- + params : ndarray, shape (n_stars, 5) -- [x0, vx, y0, vy, pi] + param_errs : ndarray, shape (n_stars, 5) + chi2_x, chi2_y : ndarray, shape (n_stars,) + """ + assert fixed_params_dict is not None and all(k in fixed_params_dict for k in ['t0', 'ra', 'dec']), \ + "Parallax.run_fit requires fixed_params_dict={'t0':..., 'ra':..., 'dec':...}." + + n_stars, n_epochs = t.shape + t0 = np.broadcast_to(np.atleast_1d(fixed_params_dict['t0']), (n_stars,)).astype(float) + ra = np.broadcast_to(np.atleast_1d(fixed_params_dict['ra']), (n_stars,)).astype(float) + dec = np.broadcast_to(np.atleast_1d(fixed_params_dict['dec']), (n_stars,)).astype(float) + pa = np.broadcast_to(np.atleast_1d(fixed_params_dict.get('pa', 0.0)), (n_stars,)).astype(float) + obsLocation = fixed_params_dict.get('obsLocation', 'earth') + assert isinstance(obsLocation, str) or (np.unique(obsLocation).size == 1), \ + "obsLocation must be a single string for all stars at this time." + if not isinstance(obsLocation, str): + obsLocation = np.unique(obsLocation)[0] + + # A star that wasn't observed in an epoch carries nan there in x/y and + # in t alike, and Time(..., format='decimalyear') rejects non-finite + # input outright -- so the shared unique-time axis below has to be built + # from the finite times alone, or one undetected epoch anywhere in the + # table kills the whole batch fit. Those epochs contribute nothing + # regardless: `valid` zeroes them out of every weighted sum, so whatever + # pvec ends up holding for them is never read. Substitute a real time + # purely so the UTC->TDB conversion runs, and drop them from `valid` + # too, in case a nan time ever arrives alongside finite x/y -- that + # would otherwise contribute a nan dt to the sums. + t_finite = np.isfinite(t) + if not t_finite.all(): + valid = valid & t_finite + # J2000 is an arbitrary stand-in; any finite epoch would do, and a + # real one from this batch keeps the unique-time axis short. + fill_t = t[t_finite][0] if t_finite.any() else 2000.0 + t = np.where(t_finite, t, fill_t) + + dt = t - t0[:, np.newaxis] + + n_valid = valid.sum(axis=1) + has_data = n_valid >= self.n_params # degree_of_freedom >= 0 + + if verbose and np.any(~has_data): + warnings.warn( + f'Not enough data points to fit model for {np.sum(~has_data)} star(s). ' + f'Setting parameters to {fill_value} and uncertainties to np.inf.', + OptimizeWarning, stacklevel=2 + ) + + # Rows don't have to share the same times (e.g. fit()'s bootstrap + # resampling gives each row its own resampled subset/order of one + # star's epochs) -- parallax_in_direction only takes one shared mjd + # axis, so compute it once for the *unique* times across the whole + # batch, then gather back per (row, epoch). When every row does + # share the same grid (the normal multi-star case), unique_t is + # just that grid and this is a no-op reshape. + unique_t, inverse_idx = np.unique(t, return_inverse=True) + inverse_idx = inverse_idx.reshape(t.shape) + t_mjd = Time(unique_t, format='decimalyear', scale='utc').tdb.mjd + pvec_unique = self.calc_parallax_vector(t_mjd, ra, dec, pa=pa, obsLocation=obsLocation) # (n_stars, 2, n_unique_times) + star_idx = np.arange(n_stars)[:, np.newaxis] + Px = pvec_unique[:, 0, :][star_idx, inverse_idx] # (n_stars, n_epochs) + Py = pvec_unique[:, 1, :][star_idx, inverse_idx] + + sigma_x, sigma_y = sigma_from_error(xe, ye, weighting=weighting) + x_wt = weight_from_sigma(sigma_x, valid) + y_wt = weight_from_sigma(sigma_y, valid) + + dt_m = np.where(valid, dt, 0.0) + x_m = np.where(valid, x, 0.0) + y_m = np.where(valid, y, 0.0) + Px_m = np.where(valid, Px, 0.0) + Py_m = np.where(valid, Py, 0.0) + + # Per-direction weighted sums -- the x0/vx block and y0/vy block + # never mix with each other, only (separately) with the shared pi + # row/column below. + Sx0 = x_wt.sum(axis=1) + Sx1 = (x_wt * dt_m).sum(axis=1) + Sx11 = (x_wt * dt_m**2).sum(axis=1) + SxP = (x_wt * Px_m).sum(axis=1) + Sx1P = (x_wt * dt_m * Px_m).sum(axis=1) + SxPP = (x_wt * Px_m**2).sum(axis=1) + + Sy0 = y_wt.sum(axis=1) + Sy1 = (y_wt * dt_m).sum(axis=1) + Sy11 = (y_wt * dt_m**2).sum(axis=1) + SyP = (y_wt * Py_m).sum(axis=1) + Sy1P = (y_wt * dt_m * Py_m).sum(axis=1) + SyPP = (y_wt * Py_m**2).sum(axis=1) + + rx0 = (x_wt * x_m).sum(axis=1) + rx1 = (x_wt * x_m * dt_m).sum(axis=1) + ry0 = (y_wt * y_m).sum(axis=1) + ry1 = (y_wt * y_m * dt_m).sum(axis=1) + # Both x and y data feed into the pi row/column -- this is exactly + # where x and y stop being independent. + rP = (x_wt * x_m * Px_m).sum(axis=1) + (y_wt * y_m * Py_m).sum(axis=1) + + M = np.zeros((n_stars, 5, 5)) + M[:, 0, 0] = Sx0 + M[:, 0, 1] = M[:, 1, 0] = Sx1 + M[:, 1, 1] = Sx11 + M[:, 0, 4] = M[:, 4, 0] = SxP + M[:, 1, 4] = M[:, 4, 1] = Sx1P + + M[:, 2, 2] = Sy0 + M[:, 2, 3] = M[:, 3, 2] = Sy1 + M[:, 3, 3] = Sy11 + M[:, 2, 4] = M[:, 4, 2] = SyP + M[:, 3, 4] = M[:, 4, 3] = Sy1P + + M[:, 4, 4] = SxPP + SyPP + + r = np.column_stack([rx0, rx1, ry0, ry1, rP]) + + # Singular (e.g. fewer than 3 unique valid times, or a star whose + # parallax factor is degenerate over its valid epochs): a direct + # determinant tolerance instead of an SVD-based matrix_rank check. + det = np.linalg.det(M) + scale = np.maximum(np.abs(Sx0 * Sx11 * Sy0 * Sy11 * M[:, 4, 4]), np.finfo(float).tiny) + singular = has_data & (np.abs(det) <= 1e-12 * scale) + unsafe = singular | ~has_data + + if verbose and np.any(singular): + warnings.warn( + 'Singular matrix. Covariance of the parameters could not be estimated. ' + 'Setting parameter uncertainties to np.inf.', + OptimizeWarning, stacklevel=2 + ) + + # Stars with too little/degenerate data would otherwise send a + # singular matrix into np.linalg.inv for the whole batch (which + # raises, unlike a per-star pinv) -- swap those in for a safe + # placeholder first; their real params/errs get overwritten with + # fill_value/inf below regardless of what this produces. + M_safe = M.copy() + M_safe[unsafe] = np.eye(5) + + cov = np.linalg.inv(M_safe) + params = np.einsum('nij,nj->ni', cov, r) + param_errs = np.sqrt(np.diagonal(cov, axis1=1, axis2=2)) + + params[unsafe] = fill_value + param_errs[unsafe] = np.inf + + x0, vx, y0, vy, pi = params.T + with np.errstate(divide='ignore', invalid='ignore'): + x_model = x0[:, np.newaxis] + vx[:, np.newaxis] * dt_m + pi[:, np.newaxis] * Px_m + y_model = y0[:, np.newaxis] + vy[:, np.newaxis] * dt_m + pi[:, np.newaxis] * Py_m + chi2x = (x_wt * (x_m - x_model)**2).sum(axis=1) + chi2y = (y_wt * (y_m - y_model)**2).sum(axis=1) + # A singular fit has no real params to compute a residual from + # (regardless of what fill_value happens to be) -- nan them + # explicitly rather than relying on fill_value being nan. + chi2x[singular] = np.nan + chi2y[singular] = np.nan + + if not absolute_sigma: + # Unlike Linear/Acceleration (two independent per-direction + # fits, each rescaled by its own reduced chi2), this is one + # joint 5-parameter fit over the combined [x, y] data -- so + # scipy's own curve_fit(absolute_sigma=False) rescales the + # whole covariance by a single reduced chi2 built from the + # combined residuals and the true combined degrees of freedom + # (2*n_valid data points minus all 5 params), not + # self.n_params (which is only a "min epochs needed" heuristic + # shared with the has_data check above, not the real dof here). + dof = 2 * n_valid - self.n_fit_params + dof_pos = dof > 0 + chi2 = chi2x + chi2y + with np.errstate(divide='ignore', invalid='ignore'): + reduced_chi2 = np.where(dof_pos, chi2 / np.where(dof_pos, dof, 1), 1.0) + for jj in range(5): + param_errs[:, jj] = np.where(dof_pos, param_errs[:, jj] * np.sqrt(reduced_chi2), np.inf) + if verbose and np.any(has_data & ~dof_pos): + warnings.warn( + 'Degree of freedom <= 0 for some star(s). Covariance of the parameters could not be ' + 'estimated. Setting parameter uncertainties to np.inf.', + OptimizeWarning, stacklevel=2 + ) + + # Not-enough-data and singular stars: overwrite with fill_value/inf/nan + # regardless of whatever the (meaningless, e.g. 0/0, or inf*nan from + # the absolute_sigma=False rescaling above) computation produced. + # This must come last -- e.g. the rescaling above would otherwise + # silently turn a singular star's correct inf error into nan + # (inf * sqrt(nan) == nan, not inf). + params[~has_data] = fill_value + param_errs[~has_data] = np.inf + chi2x[~has_data] = np.nan + chi2y[~has_data] = np.nan + param_errs[singular] = np.inf + + return params, param_errs, chi2x, chi2y + + +def determine_motion_models(startable, motion_models=None, fixed_params_dict=None): + """Determine, per star, which motion model to use. + + Precedence: + + 1. A ``motion_model_input`` column -- the caller's explicit per-star + request -- wherever that model can actually be evaluated for that star + (every parameter it needs present and finite). This is the same + priority fit_motion_models gives the column. + 2. Otherwise the most complex model in `motion_models` whose parameters + are all present and finite for that star. + 3. `motion_models=None` means "any model", so step 2 becomes "the most + complex model this star's parameters support". + + The distinction between a restricted list and None is what separates the + two questions this answers. Which model a star was FIT with is confined to + the models that were requested, so callers pass their list. How far a star + must move to reach some other epoch is a property of the star, not of what + you chose to fit -- a reference imported from an external catalog can carry + vx/vy/t0 that were never fit here and still has to move with Linear -- so + propagation passes None. + + Parameters + ---------- + startable : startable + Startable with motion model parameter columns + motion_models : list of MotionModel or str, optional + List of motion model classes or their names to select from. + If None, all available motion models will be considered, by default None + fixed_params_dict : dict, optional + Dictionary of fixed parameters, by default None + + Returns + ------- + motion_model_used : list + List of motion model used for each star + n_params : list + List of n parameters per direction for each star + """ + + # Needed unconditionally: both for resolving a list of model names and for + # resolving 'motion_model_input' requests further down, which are looked up + # against every known model rather than just the ones passed in. + all_mm_map = motion_model_map() + + if motion_models is None: + motion_models = MotionModel.__subclasses__() + elif all(isinstance(mm, str) for mm in motion_models): + motion_models = [all_mm_map[mm] for mm in motion_models] + + if fixed_params_dict is None: + fixed_params_dict = {} + + # A fixed parameter counts as available from any of the three places the + # rest of the code will actually look it up in: fixed_params_dict, a table + # column, or table metadata. Metadata has to be included here or this + # function contradicts the lookup it is gating: fit_motion_models stores a + # fixed parameter that is uniform across stars in meta (only a per-star one + # becomes a column), so after fitting Parallax with a single ra/dec/pa for + # the whole table, 'pa' and 'obsLocation' live in meta -- and omitting meta + # made Parallax un-selectable, silently demoting those stars to Linear and + # dropping the parallax term from infer_positions. + meta_keys = set(getattr(startable, 'meta', None) or {}) + + motion_models_possible = [] + for mm in motion_models: + required_columns = mm.fit_param_names + mm.fixed_param_names + req_col_in_table = [col for col in required_columns if (col in startable.colnames)] + req_col_in_dict = [col for col in required_columns if (col in fixed_params_dict.keys())] + req_col_in_meta = [col for col in required_columns + if (col not in startable.colnames) + and (col not in fixed_params_dict.keys()) + and (col in meta_keys)] + req_cols = startable[req_col_in_table] + if all((col in startable.colnames) or (col in fixed_params_dict.keys()) + or (col in meta_keys) for col in required_columns): + motion_models_possible.append( + (mm, req_col_in_table, req_cols, req_col_in_dict, req_col_in_meta)) + + # Vectorized replacement for the old per-star Python loop (which called + # np.isfinite/np.issubdtype once per star per required column -- millions + # of times for large mosaics). For each candidate motion model, checked in + # the same priority order as before (last-declared model first), compute a + # whole-table boolean mask of which stars have all of that model's required + # *numeric* columns finite, then assign that model to every not-yet-assigned + # star the mask covers. Whether the fixed_params_dict/meta entries are + # finite doesn't depend on which star is being assigned, so each is checked + # once per model instead of once per star. This makes the `processes`/`chunksize` arguments unnecessary for + # this function; they are kept in the signature for backward compatibility. + n_stars = len(startable) + motion_model_used = np.empty(n_stars, dtype=object) + n_params = np.empty(n_stars, dtype=int) + assigned = np.zeros(n_stars, dtype=bool) + + for mm, req_col_in_table, req_cols, req_col_in_dict, req_col_in_meta in motion_models_possible[::-1]: + # np.all(), not the bare truth value: a fixed parameter may legitimately + # be an array of length n_stars (fit_motion_models documents scalars as + # applying to every star and arrays as per-star), and np.isfinite() of + # an array cannot be used in a boolean context -- which raised + # "truth value of an array ... is ambiguous" for exactly the per-star + # form the API invites. + def _finite(value): + arr = np.asarray(value) + if not np.issubdtype(arr.dtype, np.number): + return True # strings such as obsLocation: nothing to check + return bool(np.all(np.isfinite(arr))) + + fixed_ok = (all(_finite(fixed_params_dict[col]) for col in req_col_in_dict) + and all(_finite(startable.meta[col]) for col in req_col_in_meta)) + if not fixed_ok: + continue + + satisfies = np.ones(n_stars, dtype=bool) + for col in req_col_in_table: + col_data = req_cols[col] + if np.issubdtype(col_data.dtype, np.number): + satisfies &= np.isfinite(col_data) + + newly_assigned = satisfies & ~assigned + motion_model_used[newly_assigned] = mm.name + n_params[newly_assigned] = mm.n_params + assigned |= newly_assigned + + # Highest priority: an explicit per-star request in 'motion_model_input', + # wherever that model can actually be evaluated for that star. Applied + # last so it overrides the choice made from `motion_models` above. + # + # This is the same priority fit_motion_models already gives the column -- + # it resolves requests through the full model map rather than the + # restricted list -- so honoring it here keeps the two in agreement + # instead of having this function silently re-derive something else. + # + # "Can be evaluated" means every parameter that model needs is present (a + # table column or a fixed_params_dict entry) and finite for that star. So + # a request downgrades by itself when its parameters are missing: a star + # asking for Acceleration with no ax/ay, or with ax nan because it had too + # few epochs to fit, falls through to the choice above rather than + # silently producing nan positions. + if 'motion_model_input' in startable.colnames: + requested = np.asarray(startable['motion_model_input']) + for name in np.unique(requested): + if name not in all_mm_map: + # Unrecognized request -- leave those rows as assigned above. + continue + mm = all_mm_map[name] + rows = np.flatnonzero(requested == name) + if rows.size == 0: + continue + + usable = np.ones(rows.size, dtype=bool) + for col in mm.fit_param_names + mm.fixed_param_names: + if col in startable.colnames: + col_data = np.asarray(startable[col][rows]) + if np.issubdtype(col_data.dtype, np.number): + usable &= np.isfinite(col_data) + elif col in fixed_params_dict: + value = np.asarray(fixed_params_dict[col]) + if np.issubdtype(value.dtype, np.number) and not np.all(np.isfinite(value)): + usable[:] = False + else: + # The requested model needs something this table lacks. + usable[:] = False + break + + honored = rows[usable] + motion_model_used[honored] = mm.name + n_params[honored] = mm.n_params + assigned[honored] = True + + # Stars that matched no motion model are dropped, matching the old + # behavior of simply never appending an entry for them. + motion_model_used = motion_model_used[assigned].tolist() + n_params = n_params[assigned].tolist() + + return motion_model_used, n_params + + +def motion_model_param_names(motion_models, with_errors=True, with_fixed=True): + """Get the motion model parameter names from a list of MotionModels. + + Parameters + ---------- + motion_models : MotionModel, str, or list of MotionModels/strings. + Motion model to query parameter names from. If str, should be the name of a MotionModel class. + with_errors : bool, optional + Add uncertainty names with '_err' suffix or not, by default True + with_fixed : bool, optional + Add fixed param names with '_fixed' suffix or not, by default True + + Returns + ------- + list + List of all unique parameter names across all motion models + """ + list_of_parameters = [] + + def list_add(name): + if name not in list_of_parameters: + list_of_parameters.append(name) + + motion_models = np.atleast_1d(motion_models) + + # Callers (e.g. align.update_ref_table_aggregates) may pass one entry per + # star -- mostly repeats of the same handful of motion model names/classes. + # Re-expanding fit_param_names/fixed_param_names for every repeat is pure + # waste, since list_add() is a no-op for names already seen. Dedup up front + # (preserving first-occurrence order, which is what determines the order of + # list_of_parameters below) so each distinct motion model is expanded once. + seen = set() + unique_motion_models = [] + for mm in motion_models: + key = mm if isinstance(mm, str) else id(mm) + if key not in seen: + seen.add(key) + unique_motion_models.append(mm) + motion_models = unique_motion_models + + mm_map = motion_model_map() + for mm in motion_models: + if isinstance(mm, str): + mm = mm_map[mm] + for param in mm.fit_param_names: + # Fitter params + list_add(param) + # Error params + if with_errors: + list_add(param + '_err') + # Fixed params + if with_fixed: + for param in mm.fixed_param_names: + list_add(param) + return list_of_parameters + + +def all_motion_model_param_names(with_errors=True, with_fixed=True): + """Get all motion model parameter names from all available MotionModels. + + Parameters + ---------- + with_errors : bool, optional + Add uncertainty names with '_err' suffix or not, by default True + with_fixed : bool, optional + Add fixed param names with '_fixed' suffix or not, by default True + + Returns + ------- + list + List of all unique parameter names across all motion models + """ + return motion_model_param_names(MotionModel.__subclasses__(), with_errors=with_errors, with_fixed=with_fixed) + +def motion_model_map(): + """Get a dictionary mapping motion model names to MotionModel classes. + + Returns + ------- + mm_map : dict + Dictionary mapping motion model names to MotionModel classes. + """ + mm_map = dict( + [(mm.__name__, mm) for mm in MotionModel.__subclasses__()] + ) + # Sort by required epochs + mm_map = dict(sorted(mm_map.items(), key=lambda item: item[1].n_params)) + return mm_map + +def organize_motion_models(motion_models): + """ + Organize a list of motion models of type str or MotionModel into a list of MotionModel classes, + sorted by increasing number of required parameters. Empty and Fixed are always added if not already present. + To be used in align and StarTable.fit_motion_models. + + Parameters + ---------- + motion_models : MotionModel, str, or list of MotionModels/strings. + Motion model(s) to organize. Names are matched case-insensitively -- + 'linear' and 'Linear' are the same model -- and only the canonical + spelling propagates, so the caller's casing never reaches the output. + + Returns + ------- + list + List of MotionModel classes sorted by increasing number of required parameters. + """ + + all_mm_map = motion_model_map() + + def class_from_name(name): + """ + Resolve one model name to its class, case-insensitively. + + Every model name is a single word ('Empty', 'Fixed', 'Linear', + 'Acceleration', 'Parallax'), so str.capitalize() is an exact + normalization: it upper-cases the first character and lower-cases the + rest, mapping 'linear', 'LINEAR' and 'lInEaR' all onto 'Linear'. Only + the canonical name goes any further -- what is returned is the class + itself, and the name that reaches the ref_table comes from that class's + .name attribute, so nothing downstream ever sees the caller's casing. + """ + canonical = name.capitalize() + assert canonical in all_mm_map.keys(), \ + f"motion_model must be in {list(all_mm_map.keys())}, but got '{name}'" + return all_mm_map[canonical] + + # Change to list if not + motion_model_classes = [] + if motion_models is None: + motion_models = [Empty, Fixed] + elif isinstance(motion_models, str): + motion_model_classes = [class_from_name(motion_models)] + elif isinstance(motion_models, type) and issubclass(motion_models, MotionModel): + motion_model_classes = [motion_models] + elif isinstance(motion_models, (list, tuple, np.ndarray)): + for mm in motion_models: + if isinstance(mm, str): + motion_model_classes.append(class_from_name(mm)) + else: + assert issubclass(mm, MotionModel), f"motion_model must be a string or a MotionModel object, but got {type(mm)}" + motion_model_classes.append(mm) + + mm_names = [mm.name for mm in motion_model_classes] + if 'Empty' not in mm_names: + motion_model_classes.append(all_mm_map['Empty']) + if 'Fixed' not in mm_names: + motion_model_classes.append(all_mm_map['Fixed']) + + # Sort by increasing n_params + motion_model_classes = sorted(motion_model_classes, key=lambda mm: mm.n_params) + return motion_model_classes diff --git a/flystar/parallax.py b/flystar/parallax.py new file mode 100755 index 0000000..4535f4f --- /dev/null +++ b/flystar/parallax.py @@ -0,0 +1,170 @@ +# Parallax calculation module for motion models involving parallax +# Adapted from BAGLE's parallax.py + +import os +import numpy as np +from joblib import Memory +from astropy.time import Time +from astropy import units, units as u +from astropy.coordinates import SkyCoord, get_body_barycentric, get_body_barycentric_posvel, solar_system_ephemeris, \ + CartesianRepresentation + +# FIXME: Do we still need this? +# Setup a parallax cache +try: + cache_dir = os.environ['PARALLAX_CACHE_DIR'] +except: + cache_dir = os.path.dirname(__file__) + '/parallax_cache/' +cache_memory = Memory(cache_dir, verbose=0) +# Default cache size is 1 GB +cache_memory.reduce_size() + +# @cache_memory.cache() +def parallax_in_direction(ra, dec, mjd, obsLocation='earth', pa=0.): + """ + Calculate the parallax vector in a given direction following MulensModel. + + Parameters + ---------- + RA : float or array-like + Right Ascension in degrees. (J2000) + Dec : float or array-like + Declination in degrees. (J2000) + mjd : float or array-like + Modified Julian Date(s), **in the TDB scale**. Not converted; see the + note in the body. + Modified Julian Date. + obsLocation : str, optional + Observer location, by default 'earth'. + PA : float, optional + Position angle in degrees (counterclockwise offset of the image y-axis from North), by default 0. + + Returns + ------- + pvec : ndarray + Parallax vector components, shape of (N_stars, 2, N_times), where the second dimension corresponds to the x or y components. + """ + # Munge inputs into astropy format. + # times = Time(mjd + 2400000.5, format='jd', scale='tdb') + ra = np.atleast_1d(ra) + dec = np.atleast_1d(dec) + mjd = np.atleast_1d(mjd) + pa = np.atleast_1d(pa) + # Declares the incoming MJD to be TDB -- it does NOT convert. Callers + # must hand over a TDB MJD; passing a UTC one silently shifts every + # epoch by TDB-UTC (69.184 s as of 2026). + times = Time(mjd, format='mjd', scale='tdb') + coord = SkyCoord(ra, dec, unit=(units.deg, units.deg)) # Shape (N_stars,) + + directions = coord.cartesian.xyz.value.T # Shape (N_stars, 3) + north = np.array([0., 0., 1.]) + # Cross product of each star with north vector + _east_projected = np.cross(north, directions) + _east_projected /= np.linalg.norm(_east_projected, axis=1)[:, np.newaxis] # Shape (N_stars, 3) + _north_projected = np.cross(directions, _east_projected) + _north_projected /= np.linalg.norm(_north_projected, axis=1)[:, np.newaxis] # Shape (N_stars, 3) + + obs_pos = get_observer_barycentric(obsLocation, times) # Shape (N_times,) + sun_pos = get_body_barycentric(body='sun', time=times) # Shape (N_times,) + + sun_obs_pos = sun_pos - obs_pos + + pos = sun_obs_pos.xyz.T.to(units.au).value # Shape (N_times, 3) + # Broadcast pos to (N_stars, 3, N_times) and take dot product with east and north unit vectors to get components in those directions. + pos = np.broadcast_to(pos.T, (directions.shape[0], 3, pos.shape[0])) # Shape (N_stars, 3, N_times) + + e = np.einsum('sdt,sd->st', pos, _east_projected) # Shape (N_stars, N_times) + n = np.einsum('sdt,sd->st', pos, _north_projected) # Shape (N_stars, N_times) + + # Rotate frame e,n->x,y accounting for PA + pa = np.deg2rad(pa) # shape (N_stars,) + x = -e * np.cos(pa[:, np.newaxis]) + n * np.sin(pa[:, np.newaxis]) # Shape (N_stars, N_times) + y = e * np.sin(pa[:, np.newaxis]) + n * np.cos(pa[:, np.newaxis]) # Shape (N_stars, N_times) + # pvec Shape (N_stars, 2, N_times) + pvec = np.stack((x, y), axis=1) + return pvec + + +def get_observer_barycentric(body, times, min_ephem_step=1, velocity=False): + """ + Get the barycentric position of a satellite or other Solar System body + using JPL emphemerides through the Horizon app. + + The ephemeris is queried at a decimated time step set by min_ephem_step + (def=1 day) that must be 1 day or larger. The positions + (and optionally velocities) are then interpolated onto the desired + time array. + + Inputs + ------ + body : str + The name of the Solar System body. Must use the JPL Horizon + naming scheme. + + times : astropy.time.Time array + Array of times (astropy.time.core.Time) objects at which to + fetch the position of the specified Solar System body. + + Optional Inputs + --------------- + min_ephem_step : int + Minimum time step to query JPL in days. Must not be <1 and must + be in integer days. + + velocity : bool + If true, return both position and velocity vectors over time. + + Return + ------ + coord : astropy.coordinates.CartesianRepresentation + The xyz coordinates in the plane of the Solar System at the + input times. + """ + # Use the JPL ephemerides. + solar_system_ephemeris.set('jpl') + + if body in solar_system_ephemeris.bodies: + if velocity: + obs_pos, obs_vel = get_body_barycentric_posvel(body=body, time=times) + else: + obs_pos = get_body_barycentric(body=body, time=times) + else: + # Figure out a cadence for the ephemerides, not smaller than 1 day. + dt = np.median(np.diff(times)).jd + if dt < min_ephem_step: + dt = min_ephem_step + + # Get the date range, add some padding on each side. + t_min = times.min() + t_max = times.max() + t_min.format = 'iso' + t_max.format = 'iso' + t_min = str(t_min - dt*u.day).split()[0] + t_max = str(t_max + dt*u.day).split()[0] + step = f'{dt:.0f}d' + + # Fetch the Horizons ephemeris. + from astroquery.jplhorizons import Horizons + obj = Horizons(id=body, epochs={'start':t_min, 'stop':t_max, 'step':step}) + obj_data = obj.vectors() + + ephem_jd = obj_data['datetime_jd'] + + # Interpolate to the actual time array. + obj_x_at_t = np.interp(times.jd, ephem_jd, obj_data['x'].to('km')) * u.km + obj_y_at_t = np.interp(times.jd, ephem_jd, obj_data['y'].to('km')) * u.km + obj_z_at_t = np.interp(times.jd, ephem_jd, obj_data['z'].to('km')) * u.km + + if velocity: + obj_vx_at_t = np.interp(times.jd, ephem_jd, obj_data['vx'].to('km/s')) * u.km / u.s + obj_vy_at_t = np.interp(times.jd, ephem_jd, obj_data['vy'].to('km/s')) * u.km / u.s + obj_vz_at_t = np.interp(times.jd, ephem_jd, obj_data['vz'].to('km/s')) * u.km / u.s + + obs_vel = CartesianRepresentation(obj_vx_at_t, obj_vy_at_t, obj_vz_at_t) + + obs_pos = CartesianRepresentation(obj_x_at_t, obj_y_at_t, obj_z_at_t) + + if velocity: + return (obs_pos, obs_vel) + else: + return obs_pos \ No newline at end of file diff --git a/flystar/plots.py b/flystar/plots.py index c675170..52d8b5b 100755 --- a/flystar/plots.py +++ b/flystar/plots.py @@ -1,19 +1,100 @@ -from flystar import analysis -import pylab as py -import pylab as plt -import numpy as np -import matplotlib.mlab as mlab -import matplotlib -from matplotlib import colors -import matplotlib.cm as cm -from scipy.stats import chi2 -from scipy.optimize import curve_fit -from scipy.stats import norm -import pdb +import os import math import astropy +import matplotlib +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.colors as mcolors +from matplotlib import cm +from matplotlib.ticker import FormatStrFormatter +from scipy.stats import chi2, norm +from scipy.optimize import curve_fit +from astropy import units as u from astropy.table import Table -from astropy.io import ascii +from astropy.coordinates import SkyCoord + + +# Moved from analysis old codes +def calc_chi2(ref_mat, starlist_mat, transform, errs='both'): + """ + calculate the chi2 and reduced chi2 of the position + between two matched starlists. + + Parameters + ---------- + ref_mat : astropy table + Reference starlist only containing matched stars that were used in the + transformation. Standard column headers are assumed. + starlist_mat : astropy table + Transformed starlist only containing the matched stars used in + the transformation. Standard column headers are assumed. + transform : transformation object + Transformation object of final transform. Used in chi-square + determination + errs : string; 'both', 'reference', or 'starlist', optional + If both, add starlist errors in quadrature with reference errors. + + If reference, only consider reference errors. This should be used if the starlist + does not have valid errors + + If starlist, only consider starlist errors. This should be used if the reference + does not have valid errors, by default 'both'. + + Returns + ------- + chi_sq : float + chi2 = sum (diff_x**2 / xerr**2 + diff_y**2 /yerr**2) + chi_sq_red : float + reduced chi2 = chi2/ degree of freedom + deg_freedom : int + degree of freedom + + """ + diff_x = ref_mat['x'] - starlist_mat['x'] + diff_y = ref_mat['y'] - starlist_mat['y'] + + # Set errors as per user input + if errs == 'both': + xerr = np.hypot(ref_mat['xe'], starlist_mat['xe']) + yerr = np.hypot(ref_mat['ye'], starlist_mat['ye']) + elif errs == 'reference': + xerr = ref_mat['xe'] + yerr = ref_mat['ye'] + elif errs == 'starlist': + xerr = starlist_mat['xe'] + yerr = starlist_mat['ye'] + + + # For both X and Y, calculate chi-square. Combine arrays to get combined + # chi-square + chi_sq_x = diff_x**2. / xerr**2. + chi_sq_y = diff_y**2. / yerr**2. + + chi_sq = np.append(chi_sq_x, chi_sq_y) + + # Calculate degrees of freedom in transformation + num_mod_params = calc_nparam(transform) + deg_freedom = len(chi_sq) - num_mod_params + + # Calculate reduced chi-square + chi_sq = np.sum(chi_sq) + chi_sq_red = chi_sq / deg_freedom + + return chi_sq, chi_sq_red, deg_freedom + + +def calc_nparam(transformation): + """ + calculate the degree of freedom for a transformation + """ + # Read transformation: Extract X, Y coefficients from transform + if transformation.__class__.__name__ == 'four_paramNW': + nparam = 4 + elif transformation.__class__.__name__ == 'PolyTransform': + order = transformation.order + nparam = (order+1) * (order+2) + return nparam + #################################################### # Code for making diagnostic plots for astrometry @@ -21,63 +102,79 @@ #################################################### -def trans_positions(ref, ref_mat, starlist, starlist_mat, xlim=None, ylim=None, fileName=None, - equal_axis=True, root='./'): +def trans_positions(ref, ref_mat, starlist, starlist_mat, xlim=None, ylim=None, + equal_axis=True, save_path=None, show_plot=True, max_points=5000): """ Plot positions of stars in reference list and the transformed starlist, in reference list coordinates. Stars used in the transformation are highlighted. - Parameters: + Parameters ---------- ref: astropy table Reference starlist, with standard column headers - ref_mat: astropy table Reference starlist only containing matched stars that were used in the transformation. Standard column headers are assumed. - starlist: astropy table - Transformed starist with the reference starlist coordinates. + Transformed starlist with the reference starlist coordinates. Standard column headers are assumed - starlist_mat: astropy table Transformed starlist only containing the matched stars used in the transformation. Standard column headers are assumed. - - xlim: None or list/array [xmin, xmax] - If not None, sets the xmin and xmax limit of the plot - - ylim: None or list/array [ymin, ymax] - If not None, sets the ymin and ymax limit of the plot - - equal_axis: boolean + xlim: None or list/array [xmin, xmax], optional + If not None, sets the xmin and xmax limit of the plot, by default None. + ylim: None or list/array [ymin, ymax], optional + If not None, sets the ymin and ymax limit of the plot, by default None. + equal_axis: boolean, optional If true, make axes equal. True by default - + save_path: string, optional + Path to save the figure to. Default is None + show_plot: boolean, optional + If true, show the plot. Default is True + max_points: int, optional + Each of the four inputs is randomly subsampled to at most this many + points before plotting, since a diagnostic scatter plot doesn't need + every star to be legible, and rendering/saving hundreds of thousands + of markers at dpi=300 is dramatically slower than the alignment + computation itself. By default 5000. """ - py.figure(figsize=(10,10)) - py.clf() - py.plot(ref['x'], ref['y'], 'g+', ms=5, label='Reference') - py.plot(starlist['x'], starlist['y'], 'rx', ms=5, label='starlist') - py.plot(ref_mat['x'], ref_mat['y'], color='skyblue', marker='s', ms=10, alpha=0.3, + rng = np.random.default_rng(0) + def _subsample(tab): + if len(tab) > max_points: + idx = rng.choice(len(tab), size=max_points, replace=False) + return tab[idx] + return tab + ref, ref_mat, starlist, starlist_mat = ( + _subsample(ref), _subsample(ref_mat), _subsample(starlist), _subsample(starlist_mat) + ) + + plt.figure(figsize=(6, 6)) + plt.clf() + plt.plot(ref['x'], ref['y'], 'g+', ms=5, label='Reference') + plt.plot(starlist['x'], starlist['y'], 'rx', ms=5, label='starlist') + plt.plot(ref_mat['x'], ref_mat['y'], color='skyblue', marker='s', ms=10, alpha=0.3, linestyle='None', label='Matched Reference') - py.plot(starlist_mat['x'], starlist_mat['y'], color='darkblue', marker='s', ms=5, alpha=0.3, + plt.plot(starlist_mat['x'], starlist_mat['y'], color='darkblue', marker='s', ms=5, alpha=0.3, linestyle='None', label='Matched starlist') - py.xlabel('X position (Reference Coords)') - py.ylabel('Y position (Reference Coords)') - py.legend(numpoints=1) - py.title('Label.dat Positions After Transformation') + plt.xlabel('X position (Reference Coords)') + plt.ylabel('Y position (Reference Coords)') + plt.legend(numpoints=1, loc='lower right') + plt.title('Label.dat Positions After Transformation') if xlim != None: - py.axis([xlim[0], xlim[1], ylim[0], ylim[1]]) + plt.axis([xlim[0], xlim[1], ylim[0], ylim[1]]) if equal_axis: - py.axis('equal') - if fileName!=None: - #py.savefig(root + fileName[3:8] + 'Transformed_positions_' + '.png') - py.savefig(root + 'Transformed_positions_{0}'.format(fileName) + '.png') - else: - py.savefig(root + 'Transformed_positions.png') + plt.axis('equal') - py.close() + if save_path: + if not os.path.exists(os.path.dirname(save_path)): + os.makedirs(os.path.dirname(save_path)) + plt.tight_layout() + plt.savefig(save_path, dpi=300) + if show_plot: + plt.show() + else: + plt.close() return @@ -91,10 +188,10 @@ def pos_diff_hist(ref_mat, starlist_mat, nbins=25, bin_width=None, xlim=None, fi ref_mat: astropy table Reference starlist only containing matched stars that were used in the transformation. Standard column headers are assumed. - + starlist_mat: astropy table Transformed starlist only containing the matched stars used in - the transformation. Standard column headers are assumed. + the transformation. Standard column headers are assumed. nbins: int Number of bins used in histogram, regardless of data range. This is @@ -106,7 +203,7 @@ def pos_diff_hist(ref_mat, starlist_mat, nbins=25, bin_width=None, xlim=None, fi xlim: None or [xmin, xmax] If not none, set the X range of the plot - + """ diff_x = ref_mat['x'] - starlist_mat['x'] diff_y = ref_mat['y'] - starlist_mat['y'] @@ -118,23 +215,24 @@ def pos_diff_hist(ref_mat, starlist_mat, nbins=25, bin_width=None, xlim=None, fi max_range = max([max(diff_x), max(diff_y)]) bins = np.arange(min_range, max_range+bin_width, bin_width) - - py.figure(figsize=(10,10)) - py.clf() - py.hist(diff_x, histtype='step', bins=bins, color='blue', label='X') - py.hist(diff_y, histtype='step', bins=bins, color='red', label='Y') - py.xlabel('Reference Position - starlist Position') - py.ylabel('N stars') - py.title('Position Differences for matched stars') + + plt.figure(figsize=(6, 6)) + plt.clf() + plt.hist(diff_x, histtype='step', bins=bins, color='blue', label='X') + plt.hist(diff_y, histtype='step', bins=bins, color='red', label='Y') + plt.xlabel('Reference Position - starlist Position') + plt.ylabel('N stars') + plt.title('Position Differences for matched stars') if xlim != None: - py.xlim([xlim[0], xlim[1]]) - py.legend() + plt.xlim([xlim[0], xlim[1]]) + plt.legend() + plt.tight_layout() if fileName != None: - py.savefig(root + fileName[3:8] + 'Positions_hist_' + '.png') + plt.savefig(root + fileName[3:8] + 'Positions_hist_' + '.png', dpi=300) else: - py.savefig(root + 'Positions_hist.png') + plt.savefig(root + 'Positions_hist.png', dpi=300) - py.close() + plt.close() return def pos_diff_err_hist(ref_mat, starlist_mat, transform, nbins=25, bin_width=None, errs='both', xlim=None, @@ -152,7 +250,7 @@ def pos_diff_err_hist(ref_mat, starlist_mat, transform, nbins=25, bin_width=None ref_mat: astropy table Reference starlist only containing matched stars that were used in the transformation. Standard column headers are assumed. - + starlist_mat: astropy table Transformed starlist only containing the matched stars used in the transformation. Standard column headers are assumed. @@ -183,8 +281,8 @@ def pos_diff_err_hist(ref_mat, starlist_mat, transform, nbins=25, bin_width=None outlier: float (default = 10) Defines how many sigma away from 0 a star must be in order to be considered - an outlier. - + an outlier. + """ diff_x = ref_mat['x'] - starlist_mat['x'] diff_y = ref_mat['y'] - starlist_mat['y'] @@ -199,7 +297,7 @@ def pos_diff_err_hist(ref_mat, starlist_mat, transform, nbins=25, bin_width=None elif errs == 'starlist': xerr = starlist_mat['xe'] yerr = starlist_mat['ye'] - + # Calculate ratio between differences and the combined error. This is # what we will plot ratio_x = diff_x / xerr @@ -207,7 +305,7 @@ def pos_diff_err_hist(ref_mat, starlist_mat, transform, nbins=25, bin_width=None # Identify non-outliers, within +/- sigma away from 0 good = np.where( (np.abs(ratio_x) < outlier) & (np.abs(ratio_y) < outlier) ) - + """ # For both X and Y, calculate chi-square. Combine arrays to get combined # chi-square @@ -215,29 +313,29 @@ def pos_diff_err_hist(ref_mat, starlist_mat, transform, nbins=25, bin_width=None chi_sq_y = diff_y**2. / yerr**2. chi_sq = np.append(chi_sq_x, chi_sq_y) - + # Calculate degrees of freedom in transformation num_mod_params = calc_nparam(transform) deg_freedom = len(chi_sq) - num_mod_params - + # Calculate reduced chi-square chi_sq_red = np.sum(chi_sq) / deg_freedom """ # Chi-square analysis for all stars, including outliers - chi_sq, chi_sq_red, deg_freedom = analysis.calc_chi2(ref_mat, starlist_mat, + chi_sq, chi_sq_red, deg_freedom = calc_chi2(ref_mat, starlist_mat, transform, errs=errs) # Chi-square analysis for only non-outlier stars - chi_sq_good, chi_sq_red_good, deg_freedom_good = analysis.calc_chi2(ref_mat[good], + chi_sq_good, chi_sq_red_good, deg_freedom_good = calc_chi2(ref_mat[good], starlist_mat[good], transform, errs=errs) - - num_mod_params = analysis.calc_nparam(transform) + + num_mod_params = calc_nparam(transform) #-------------------------------------------# # Plotting #-------------------------------------------# - + # Set the binning as per user input bins = nbins if bin_width != None: @@ -245,52 +343,53 @@ def pos_diff_err_hist(ref_mat, starlist_mat, transform, nbins=25, bin_width=None max_range = max([max(ratio_x), max(ratio_y)]) bins = np.arange(min_range, max_range+bin_width, bin_width) - - py.figure(figsize=(10,10)) - py.clf() - n_x, bins_x, p = py.hist(ratio_x, histtype='step', bins=bins, color='blue', + + plt.figure(figsize=(6, 6)) + plt.clf() + n_x, bins_x, p = plt.hist(ratio_x, histtype='step', bins=bins, color='blue', label='X', density=True, linewidth=2) - n_y, bins_y, p = py.hist(ratio_y, histtype='step', bins=bins, color='red', + n_y, bins_y, p = plt.hist(ratio_y, histtype='step', bins=bins, color='red', label='Y', density=True, linewidth=2) # Overplot a Gaussian, as well mean = 0 sigma = 1 x = np.arange(-6, 6, 0.1) - py.plot(x, norm.pdf(x,mean,sigma), 'g-', linewidth=2) - + plt.plot(x, norm.pdf(x,mean,sigma), 'g-', linewidth=2) + # Annotate reduced chi-sqared values in plot: with outliers - xstr = '$\chi^2_r$ = {0}'.format(np.round(chi_sq_red, decimals=3)) - py.annotate(xstr, xy=(0.3, 0.77), xycoords='figure fraction', color='black') + xstr = r'$\chi^2_r$ = {0}'.format(np.round(chi_sq_red, decimals=3)) + plt.annotate(xstr, xy=(0.3, 0.77), xycoords='figure fraction', color='black') txt = r'$\nu$ = 2*{0} - {1} = {2}'.format(len(diff_x), num_mod_params, deg_freedom) - py.annotate(txt, xy=(0.25,0.74), xycoords='figure fraction', color='black') + plt.annotate(txt, xy=(0.25,0.74), xycoords='figure fraction', color='black') xstr2 = 'With Outliers' - xstr3 = '{0} with +/- {1}+ sigma'.format(len(ratio_x) - len(good[0]), outlier) - py.annotate(xstr2, xy=(0.29, 0.83), xycoords='figure fraction', color='black') - py.annotate(xstr3, xy=(0.25, 0.80), xycoords='figure fraction', color='black') - + xstr3 = '{0} with ± {1}+ sigma'.format(len(ratio_x) - len(good[0]), outlier) + plt.annotate(xstr2, xy=(0.29, 0.83), xycoords='figure fraction', color='black') + plt.annotate(xstr3, xy=(0.25, 0.80), xycoords='figure fraction', color='black') + # Annotate reduced chi-sqared values in plot: without outliers - xstr = '$\chi^2_r$ = {0}'.format(np.round(chi_sq_red_good, decimals=3)) - py.annotate(xstr, xy=(0.7, 0.8), xycoords='figure fraction', color='black') + xstr = r'$\chi^2_r$ = {0}'.format(np.round(chi_sq_red_good, decimals=3)) + plt.annotate(xstr, xy=(0.7, 0.8), xycoords='figure fraction', color='black') txt = r'$\nu$ = 2*{0} - {1} = {2}'.format(len(good[0]), num_mod_params, deg_freedom_good) - py.annotate(txt, xy=(0.65,0.77), xycoords='figure fraction', color='black') + plt.annotate(txt, xy=(0.65,0.77), xycoords='figure fraction', color='black') xstr2 = 'Without Outliers' - py.annotate(xstr2, xy=(0.67, 0.83), xycoords='figure fraction', color='black') - - py.xlabel('(Ref Pos - TransStarlist Pos) / Ast. Error') - py.ylabel('N stars (normalized)') - py.title('Position Residuals for Matched Stars') + plt.annotate(xstr2, xy=(0.67, 0.83), xycoords='figure fraction', color='black') + + plt.xlabel('(Ref Pos - TransStarlist Pos) / Ast. Error') + plt.ylabel('N stars (normalized)') + plt.title('Position Residuals for Matched Stars') if xlim != None: - py.xlim([xlim[0], xlim[1]]) - py.legend() + plt.xlim([xlim[0], xlim[1]]) + plt.legend() + plt.tight_layout() if fileName != None: - py.savefig(root + fileName[3:8] + 'Positions_err_ratio_hist_' + '.png') + plt.savefig(root + fileName[3:8] + 'Positions_err_ratio_hist_' + '.png', dpi=300) else: - py.savefig(root + 'Positions_err_ratio_hist.png') + plt.savefig(root + 'Positions_err_ratio_hist.png', dpi=300) - py.close() + plt.close() return @@ -304,10 +403,10 @@ def mag_diff_hist(ref_mat, starlist_mat, bins=25, fileName=None, root='./'): ref_mat: astropy table Reference starlist only containing matched stars that were used in the transformation. Standard column headers are assumed. - + starlist_mat: astropy table Transformed starlist only containing the matched stars used in - the transformation. Standard column headers are assumed. + the transformation. Standard column headers are assumed. """ diff_m = ref_mat['m'] - starlist_mat['m'] @@ -316,19 +415,20 @@ def mag_diff_hist(ref_mat, starlist_mat, bins=25, fileName=None, root='./'): bad = np.isnan(diff_m) bad2 = np.where(bad == True) diff_m = np.delete(diff_m, bad2) - - py.figure(figsize=(10,10)) - py.clf() - py.hist(diff_m, bins=bins) - py.xlabel('Reference Mag - TransStarlist Mag') - py.ylabel('N stars') - py.title('Magnitude Difference for matched stars') + + plt.figure(figsize=(6, 6)) + plt.clf() + plt.hist(diff_m, bins=bins) + plt.xlabel('Reference Mag - TransStarlist Mag') + plt.ylabel('N stars') + plt.title('Magnitude Difference for matched stars') + plt.tight_layout() if fileName != None: - py.savefig(root + fileName[3:8] + 'Magnitude_hist_' + '.png') + plt.savefig(root + fileName[3:8] + 'Magnitude_hist_' + '.png', dpi=300) else: - py.savefig(root + 'Magnitude_hist.png') + plt.savefig(root + 'Magnitude_hist.png', dpi=300) - py.close() + plt.close() return def pos_diff_quiver(ref_mat, starlist_mat, qscale=10, keyLength=0.2, xlim=None, ylim=None, @@ -342,7 +442,7 @@ def pos_diff_quiver(ref_mat, starlist_mat, qscale=10, keyLength=0.2, xlim=None, ref_mat: astropy table Reference starlist only containing matched stars that were used in the transformation. Standard column headers are assumed. - + starlist_mat: astropy table Transformed starlist only containing the matched stars used in the transformation. Standard column headers are assumed. @@ -387,7 +487,7 @@ def pos_diff_quiver(ref_mat, starlist_mat, qscale=10, keyLength=0.2, xlim=None, diff_y = diff_y[good] xpos = xpos[good] ypos = ypos[good] - + # Divide differences by reference error, if desired if sigma: @@ -408,36 +508,39 @@ def pos_diff_quiver(ref_mat, starlist_mat, qscale=10, keyLength=0.2, xlim=None, diff_y = np.append(diff_y, 0) s = len(xpos) - - py.figure(figsize=(10,10)) - py.clf() - q = py.quiver(xpos, ypos, diff_x, diff_y, scale=qscale) + + plt.figure(figsize=(6, 6)) + plt.clf() + q = plt.quiver(xpos, ypos, diff_x, diff_y, scale=qscale) fmt = '{0} ref units'.format(keyLength) - #py.quiverkey(q, 0.2, 0.92, keyLength, fmt, coordinates='figure', color='black') + #plt.quiverkey(q, 0.2, 0.92, keyLength, fmt, coordinates='figure', color='black') # Make our reference arrow a different color - q2 = py.quiver(xpos[s-2:s], ypos[s-2:s], diff_x[s-2:s], diff_y[s-2:s], scale=qscale, color='red') + q2 = plt.quiver(xpos[s-2:s], ypos[s-2:s], diff_x[s-2:s], diff_y[s-2:s], scale=qscale, color='red') # Annotate our reference quiver arrow - py.annotate(fmt, xy=(xpos[-1]-2, ypos[-1]+0.5), color='red') - py.xlabel('X Position (Reference coords)') - py.ylabel('Y Position (Reference coords)') + plt.annotate(fmt, xy=(xpos[-1]-2, ypos[-1]+0.5), color='red') + plt.xlabel('X Position (Reference coords)') + plt.ylabel('Y Position (Reference coords)') if xlim != None: - py.axis([xlim[0], ylim[1], ylim[0], ylim[1]]) + plt.axis([xlim[0], ylim[1], ylim[0], ylim[1]]) if sigma: if fileName != None: - py.title('(Reference - Transformed Starlist positions) / sigma') - py.savefig(root + fileName[3:8] + 'Positions_quiver_sigma_' + '.png') + title = '(Reference - Transformed Starlist positions) / sigma' + save_path = root + fileName[3:8] + 'Positions_quiver_sigma.png' else: - py.title('(Reference - Transformed Starlist positions) / sigma') - py.savefig(root + 'Positions_quiver_sigma.png') + title = '(Reference - Transformed Starlist positions) / sigma' + save_path = root + 'Positions_quiver_sigma.png' else: if fileName != None: - py.title('Reference - Transformed Starlist positions') - py.savefig(root + fileName[3:8] + 'Positions_quiver_' + '.png') + title = 'Reference - Transformed Starlist positions' + save_path = root + fileName[3:8] + 'Positions_quiver.png' else: - py.title('Reference - Transformed Starlist positions') - py.savefig(root + 'Positions_quiver.png') + title = 'Reference - Transformed Starlist positions' + save_path = root + 'Positions_quiver.png' - py.close() + plt.title(title) + plt.tight_layout() + plt.savefig(save_path, dpi=300) + plt.close() return def vpd(ref, starlist_trans, vxlim, vylim): @@ -448,21 +551,18 @@ def vpd(ref, starlist_trans, vxlim, vylim): Note: we need velocities in both starlists in order for this to work. - Parameters: + Parameters ---------- ref: astropy table Reference starlist which contains velocity info. Standard column names are assumed - starlist_trans: astropy table Transformed starlist which also contains velocity info. Standard column names are assumed. - vxlim: None or list/array [vxmin, vxmax] If not None, sets the vxmin and vxmax limit of the plot - vylim: None or list/array [vymin, vymax] - If not None, sets the vymin and vymax limit of the plot + If not None, sets the vymin and vymax limit of the plot """ # Extract velocities ref_vx = ref['vx'] @@ -470,17 +570,19 @@ def vpd(ref, starlist_trans, vxlim, vylim): trans_vx = starlist_trans['vx'] trans_vy = starlist_trans['vy'] - py.figure(figsize=(10,10)) - py.clf() - py.plot(trans_vx, trans_vy, 'k.', ms=8, label='Transformed', alpha=0.4) - py.plot(ref_vx, ref_vy, 'r.', ms=8, label='Reference', alpha=0.4) - py.xlabel('Vx (Reference units)') - py.ylabel('Vy (Reference units)') + plt.figure(figsize=(6, 6)) + plt.clf() + plt.plot(trans_vx, trans_vy, 'k.', ms=8, label='Transformed', alpha=0.4) + plt.plot(ref_vx, ref_vy, 'r.', ms=8, label='Reference', alpha=0.4) + plt.xlabel('Vx (Reference units)') + plt.ylabel('Vy (Reference units)') if vxlim != None: - py.axis([vxlim[0], vylim[1], vylim[0], vylim[1]]) - py.title('Reference and Transformed Proper Motions') - py.legend() - py.savefig('Transformed_velocities.png') + plt.axis([vxlim[0], vylim[1], vylim[0], vylim[1]]) + plt.title('Reference and Transformed Proper Motions') + plt.legend() + plt.tight_layout() + plt.savefig('Transformed_velocities.png', dpi=300) + plt.close() return @@ -505,7 +607,7 @@ def vel_diff_err_hist(ref_mat, starlist_mat, nbins=25, bin_width=None, vxlim=Non bin_width: None or float If float, sets the width of the bins used in the histograms. Will override nbins - + vxlim: None or [vx_min, vx_max] If not none, set the X axis of the Vx plot by defining the minimum and maximum values @@ -517,9 +619,9 @@ def vel_diff_err_hist(ref_mat, starlist_mat, nbins=25, bin_width=None, vxlim=Non # Will produce 2-panel plot: Vx resid and Vy resid diff_vx = ref_mat['vx'] - starlist_mat['vx'] diff_vy = ref_mat['vy'] - starlist_mat['vy'] - - vx_err = np.hypot(ref_mat['vxe'], starlist_mat['vxe']) - vy_err = np.hypot(ref_mat['vye'], starlist_mat['vye']) + + vx_err = np.hypot(ref_mat['vx_err'], starlist_mat['vx_err']) + vy_err = np.hypot(ref_mat['vy_err'], starlist_mat['vy_err']) ratio_vx = diff_vx / vx_err ratio_vy = diff_vy / vy_err @@ -535,28 +637,28 @@ def vel_diff_err_hist(ref_mat, starlist_mat, nbins=25, bin_width=None, vxlim=Non mean = 0 sigma = 1 x = np.arange(-6, 6, 0.1) - - py.figure(figsize=(20,10)) - py.subplot(121) - py.subplots_adjust(left=0.1) - py.hist(ratio_vx, bins=xbins, histtype='step', color='black', density=True, + + plt.figure(figsize=(12, 6)) + plt.subplot(121) + plt.subplots_adjust(left=0.1) + plt.hist(ratio_vx, bins=xbins, histtype='step', color='black', density=True, linewidth=2) - py.plot(x, norm.pdf(x,mean,sigma), 'r-', linewidth=2) - py.xlabel('(Ref Vx - Trans Vx) / Vxe') - py.ylabel('N_stars') - py.title('Vx Residuals, Matched') + plt.plot(x, norm.pdf(x,mean,sigma), 'r-', linewidth=2) + plt.xlabel('(Ref Vx - Trans Vx) / Vxe') + plt.ylabel('N_stars') + plt.title('Vx Residuals, Matched') if vxlim != None: - py.xlim([vxlim[0], vxlim[1]]) - py.subplot(122) - py.hist(ratio_vy, bins=ybins, histtype='step', color='black', density=True, + plt.xlim([vxlim[0], vxlim[1]]) + plt.subplot(122) + plt.hist(ratio_vy, bins=ybins, histtype='step', color='black', density=True, linewidth=2) - py.plot(x, norm.pdf(x,mean,sigma), 'r-', linewidth=2) - py.xlabel('(Ref Vy - Trans Vy) / Vye') - py.ylabel('N_stars') - py.title('Vy Residuals, Matched') + plt.plot(x, norm.pdf(x,mean,sigma), 'r-', linewidth=2) + plt.xlabel('(Ref Vy - Trans Vy) / Vye') + plt.ylabel('N_stars') + plt.title('Vy Residuals, Matched') if vylim != None: - py.xlim([vylim[0], vylim[1]]) - py.savefig('Vel_err_ratio_dist.png') + plt.xlim([vylim[0], vylim[1]]) + plt.savefig('Vel_err_ratio_dist.png', dpi=300) return @@ -579,7 +681,7 @@ def residual_vpd(ref_mat, starlist_trans_mat, pscale=None): If float, convert all values to mas/yr using pscale as the plate scale. Assumes pscale is conversion from pixels to milliarcsecs - Output: + Output ------ Creates (reference - transformed) VPD """ @@ -589,10 +691,10 @@ def residual_vpd(ref_mat, starlist_trans_mat, pscale=None): # Error calculation depends on if we are converting to mas/yr if pscale != None: - xerr_frac = np.hypot((ref_mat['vxe'] / ref_mat['vx']), - (starlist_trans_mat['vxe'] / starlist_trans_mat['vx'])) - yerr_frac = np.hypot((ref_mat['vye'] / ref_mat['vy']), - (starlist_trans_mat['vye'] / starlist_trans_mat['vy'])) + xerr_frac = np.hypot((ref_mat['vx_err'] / ref_mat['vx']), + (starlist_trans_mat['vx_err'] / starlist_trans_mat['vx'])) + yerr_frac = np.hypot((ref_mat['vy_err'] / ref_mat['vy']), + (starlist_trans_mat['vy_err'] / starlist_trans_mat['vy'])) # Now apply the plate scale to convert to mas/yr diff_x *= pscale @@ -600,31 +702,33 @@ def residual_vpd(ref_mat, starlist_trans_mat, pscale=None): xerr = diff_x * xerr_frac yerr = diff_y * yerr_frac else: - xerr = np.hypot(ref_mat['vxe'], starlist_trans_mat['vxe']) - yerr = np.hypot(ref_mat['vye'], starlist_trans_mat['vye']) + xerr = np.hypot(ref_mat['vx_err'], starlist_trans_mat['vx_err']) + yerr = np.hypot(ref_mat['vy_err'], starlist_trans_mat['vy_err']) # Plotting - py.figure(figsize=(10,10)) - py.clf() - py.errorbar(diff_x, diff_y, xerr=xerr, yerr=yerr, fmt='k.', ms=8, alpha=0.5) + plt.figure(figsize=(6, 6)) + plt.clf() + plt.errorbar(diff_x, diff_y, xerr=xerr, yerr=yerr, fmt='k.', ms=8, alpha=0.5) if pscale != None: - py.xlabel('Reference_vx - Transformed_vx (mas/yr)') - py.ylabel('Reference_vy - Transformed_vy (mas/yr)') + plt.xlabel('Reference_vx - Transformed_vx (mas/yr)') + plt.ylabel('Reference_vy - Transformed_vy (mas/yr)') else: - py.xlabel('Reference_vx - Transformed_vx (reference coords)') - py.ylabel('Reference_vy - Transformed_vy (reference coords)') - py.title('Proper Motion Residuals') - py.savefig('resid_vpd.png') + plt.xlabel('Reference_vx - Transformed_vx (reference coords)') + plt.ylabel('Reference_vy - Transformed_vy (reference coords)') + plt.title('Proper Motion Residuals') + plt.tight_layout() + plt.savefig('resid_vpd.png', dpi=300) + plt.close() return def plotStar(starNames, rootDir='./', align='align/align_d_rms_1000_abs_t', - poly='polyfit_d/fit', points='points_d/', radial=False, NcolMax=3, figsize=(15,15)): + poly='polyfit_d/fit', points='points_d/', radial=False, NcolMax=3, figsize=(6, 6)): print( 'Creating residuals plots for star(s):' ) print( starNames ) - + s = starset.StarSet(rootDir + align) s.loadPolyfit(rootDir + poly, accel=0, arcsec=0) Nstars = len(starNames) @@ -634,18 +738,18 @@ def plotStar(starNames, rootDir='./', align='align/align_d_rms_1000_abs_t', else: Nrows = math.ceil(Nstars / (Ncols / 2)) * 3 - py.close('all') - py.figure(2, figsize=figsize) + plt.close('all') + plt.figure(2, figsize=figsize) names = s.getArray('name') mag = s.getArray('mag') x = s.getArray('x') y = s.getArray('y') r = np.hypot(x,y) - + for i in range(Nstars): - + starName = starNames[i] - + ii = names.index(starName) star = s.stars[ii] @@ -726,9 +830,9 @@ def plotStar(starNames, rootDir='./', align='align/align_d_rms_1000_abs_t', idx = np.where(abs(sig) > 4) print( 'Star: ', starName ) - print( '\tX Chi^2 = %5.2f (%6.2f for %2d dof)' % + print( '\tX Chi^2 = %5.2f (%6.2f for %2d dof)' % (fitx.chi2red, fitx.chi2, fitx.dof)) - print( '\tY Chi^2 = %5.2f (%6.2f for %2d dof)' % + print( '\tY Chi^2 = %5.2f (%6.2f for %2d dof)' % (fity.chi2red, fity.chi2, fity.dof)) # print( 'X Outliers: ', time[idxX] ) # print( 'Y Outliers: ', time[idxY] ) @@ -743,8 +847,8 @@ def plotStar(starNames, rootDir='./', align='align/align_d_rms_1000_abs_t', t0 = int(np.floor(np.min(time))) tO = int(np.ceil(np.max(time))) - - dateTicLoc = py.MultipleLocator(3) + + dateTicLoc = plt.MultipleLocator(3) dateTicRng = [t0-1, tO+1] dateTics = np.arange(t0, tO+1) DateTicsLabel = dateTics-2000 @@ -752,7 +856,7 @@ def plotStar(starNames, rootDir='./', align='align/align_d_rms_1000_abs_t', # See if we are using MJD instead. if time[0] > 50000: print('MJD') - dateTicLoc = py.MultipleLocator(1000) + dateTicLoc = plt.MultipleLocator(1000) t0 = int(np.round(np.min(time), 50)) tO = int(np.round(np.max(time), 50)) dateTicRng = [t0-200, tO+200] @@ -763,7 +867,6 @@ def plotStar(starNames, rootDir='./', align='align/align_d_rms_1000_abs_t', maxErr = np.array([xerr, yerr]).max() resTicRng = [-1.1*maxErr, 1.1*maxErr] - from matplotlib.ticker import FormatStrFormatter fmtX = FormatStrFormatter('%5i') fmtY = FormatStrFormatter('%6.2f') fontsize1 = 10 @@ -773,125 +876,125 @@ def plotStar(starNames, rootDir='./', align='align/align_d_rms_1000_abs_t', row = 1 else: col = 1 + 2*(i % (Ncols/2)) - row = 1 + 3*(i//(Ncols/2)) - + row = 1 + 3*(i//(Ncols/2)) + ind = (row-1)*Ncols + col - paxes = py.subplot(Nrows, Ncols, ind) - py.plot(time, fitLineX, 'b-') - py.plot(time, fitLineX + fitSigX, 'b--') - py.plot(time, fitLineX - fitSigX, 'b--') - py.errorbar(time, x, yerr=xerr, fmt='k.') - rng = py.axis() - py.ylim(np.min(x-xerr-0.1),np.max(x+xerr+0.1)) - py.xlabel('Date - 2000 (yrs)', fontsize=fontsize1) + paxes = plt.subplot(Nrows, Ncols, ind) + plt.plot(time, fitLineX, 'b-') + plt.plot(time, fitLineX + fitSigX, 'b--') + plt.plot(time, fitLineX - fitSigX, 'b--') + plt.errorbar(time, x, yerr=xerr, fmt='k.') + rng = plt.axis() + plt.ylim(np.min(x-xerr-0.1),np.max(x+xerr+0.1)) + plt.xlabel('Date - 2000 (yrs)', fontsize=fontsize1) if time[0] > 50000: - py.xlabel('Date (MJD)', fontsize=fontsize1) - py.ylabel('X (pix)', fontsize=fontsize1) + plt.xlabel('Date (MJD)', fontsize=fontsize1) + plt.ylabel('X (pix)', fontsize=fontsize1) paxes.xaxis.set_major_formatter(fmtX) paxes.get_xaxis().set_major_locator(dateTicLoc) paxes.yaxis.set_major_formatter(fmtY) paxes.tick_params(axis='both', which='major', labelsize=fontsize1) - py.yticks(np.arange(np.min(x-xerr-0.1), np.max(x+xerr+0.1), 0.2)) - py.xticks(dateTics, DateTicsLabel) - py.xlim(np.min(dateTics), np.max(dateTics)) - py.annotate(starName,xy=(1.0,1.1), xycoords='axes fraction', fontsize=12, color='red') + plt.yticks(np.arange(np.min(x-xerr-0.1), np.max(x+xerr+0.1), 0.2)) + plt.xticks(dateTics, DateTicsLabel) + plt.xlim(np.min(dateTics), np.max(dateTics)) + plt.annotate(starName,xy=(1.0,1.1), xycoords='axes fraction', fontsize=12, color='red') col = col + 1 ind = (row-1)*Ncols + col - paxes = py.subplot(Nrows, Ncols, ind) - py.plot(time, fitLineY, 'b-') - py.plot(time, fitLineY + fitSigY, 'b--') - py.plot(time, fitLineY - fitSigY, 'b--') - py.errorbar(time, y, yerr=yerr, fmt='k.') - rng = py.axis() - py.axis(dateTicRng + [rng[2], rng[3]], fontsize=fontsize1) - py.xlabel('Date - 2000 (yrs)', fontsize=fontsize1) + paxes = plt.subplot(Nrows, Ncols, ind) + plt.plot(time, fitLineY, 'b-') + plt.plot(time, fitLineY + fitSigY, 'b--') + plt.plot(time, fitLineY - fitSigY, 'b--') + plt.errorbar(time, y, yerr=yerr, fmt='k.') + rng = plt.axis() + plt.axis(dateTicRng + [rng[2], rng[3]], fontsize=fontsize1) + plt.xlabel('Date - 2000 (yrs)', fontsize=fontsize1) if time[0] > 50000: - py.xlabel('Date (MJD)', fontsize=fontsize1) - py.ylabel('Y (pix)', fontsize=fontsize1) + plt.xlabel('Date (MJD)', fontsize=fontsize1) + plt.ylabel('Y (pix)', fontsize=fontsize1) #paxes.get_xaxis().set_major_locator(dateTicLoc) paxes.xaxis.set_major_formatter(fmtX) paxes.get_xaxis().set_major_locator(dateTicLoc) paxes.yaxis.set_major_formatter(fmtY) paxes.tick_params(axis='both', which='major', labelsize=12) - py.ylim(np.min(y-yerr-0.1),np.max(y+yerr+0.1)) - py.yticks(np.arange(np.min(y-yerr-0.1), np.max(y+yerr+0.1), 0.2)) - py.xticks(dateTics, DateTicsLabel) - py.xlim(np.min(dateTics), np.max(dateTics)) + plt.ylim(np.min(y-yerr-0.1),np.max(y+yerr+0.1)) + plt.yticks(np.arange(np.min(y-yerr-0.1), np.max(y+yerr+0.1), 0.2)) + plt.xticks(dateTics, DateTicsLabel) + plt.xlim(np.min(dateTics), np.max(dateTics)) row = row + 1 col = col - 1 ind = (row-1)*Ncols + col - paxes = py.subplot(Nrows, Ncols, ind) - py.plot(time, np.zeros(len(time)), 'b-') - py.plot(time, fitSigX, 'b--') - py.plot(time, -fitSigX, 'b--') - py.errorbar(time, x - fitLineX, yerr=xerr, fmt='k.') - py.axis(dateTicRng + resTicRng, fontsize=fontsize1) - py.xlabel('Date - 2000 (yrs)', fontsize=fontsize1) + paxes = plt.subplot(Nrows, Ncols, ind) + plt.plot(time, np.zeros(len(time)), 'b-') + plt.plot(time, fitSigX, 'b--') + plt.plot(time, -fitSigX, 'b--') + plt.errorbar(time, x - fitLineX, yerr=xerr, fmt='k.') + plt.axis(dateTicRng + resTicRng, fontsize=fontsize1) + plt.xlabel('Date - 2000 (yrs)', fontsize=fontsize1) if time[0] > 50000: - py.xlabel('Date (MJD)', fontsize=fontsize1) - py.ylabel('X Residuals (pix)', fontsize=fontsize1) + plt.xlabel('Date (MJD)', fontsize=fontsize1) + plt.ylabel('X Residuals (pix)', fontsize=fontsize1) paxes.get_xaxis().set_major_locator(dateTicLoc) paxes.xaxis.set_major_formatter(fmtX) paxes.tick_params(axis='both', which='major', labelsize=fontsize1) - py.xticks(dateTics, DateTicsLabel) - py.xlim(np.min(dateTics), np.max(dateTics)) + plt.xticks(dateTics, DateTicsLabel) + plt.xlim(np.min(dateTics), np.max(dateTics)) col = col + 1 ind = (row-1)*Ncols + col - paxes = py.subplot(Nrows, Ncols, ind) - py.plot(time, np.zeros(len(time)), 'b-') - py.plot(time, fitSigY, 'b--') - py.plot(time, -fitSigY, 'b--') - py.errorbar(time, y - fitLineY, yerr=yerr, fmt='k.') - py.axis(dateTicRng + resTicRng, fontsize=fontsize1) - py.xlabel('Date -2000 (yrs)', fontsize=fontsize1) + paxes = plt.subplot(Nrows, Ncols, ind) + plt.plot(time, np.zeros(len(time)), 'b-') + plt.plot(time, fitSigY, 'b--') + plt.plot(time, -fitSigY, 'b--') + plt.errorbar(time, y - fitLineY, yerr=yerr, fmt='k.') + plt.axis(dateTicRng + resTicRng, fontsize=fontsize1) + plt.xlabel('Date -2000 (yrs)', fontsize=fontsize1) if time[0] > 50000: - py.xlabel('Date (MJD)', fontsize=fontsize1) - py.ylabel('Y Residuals (pix)', fontsize=fontsize1) + plt.xlabel('Date (MJD)', fontsize=fontsize1) + plt.ylabel('Y Residuals (pix)', fontsize=fontsize1) paxes.get_xaxis().set_major_locator(dateTicLoc) paxes.xaxis.set_major_formatter(fmtX) paxes.tick_params(axis='both', which='major', labelsize=fontsize1) - py.xticks(dateTics, DateTicsLabel) - py.xlim(np.min(dateTics), np.max(dateTics)) + plt.xticks(dateTics, DateTicsLabel) + plt.xlim(np.min(dateTics), np.max(dateTics)) row = row + 1 col = col - 1 ind = (row-1)*Ncols + col - paxes = py.subplot(Nrows, Ncols, ind) - py.errorbar(x,y, xerr=xerr, yerr=yerr, fmt='k.') - py.yticks(np.arange(np.min(y-yerr-0.1), np.max(y+yerr+0.1), 0.2)) - py.xticks(np.arange(np.min(x-xerr-0.1), np.max(x+xerr+0.1), 0.2), rotation = 270) - py.axis('equal') + paxes = plt.subplot(Nrows, Ncols, ind) + plt.errorbar(x,y, xerr=xerr, yerr=yerr, fmt='k.') + plt.yticks(np.arange(np.min(y-yerr-0.1), np.max(y+yerr+0.1), 0.2)) + plt.xticks(np.arange(np.min(x-xerr-0.1), np.max(x+xerr+0.1), 0.2), rotation = 270) + plt.axis('equal') paxes.tick_params(axis='both', which='major', labelsize=fontsize1) paxes.yaxis.set_major_formatter(FormatStrFormatter('%.2f')) paxes.xaxis.set_major_formatter(FormatStrFormatter('%.2f')) - py.xlabel('X (pix)', fontsize=fontsize1) - py.ylabel('Y (pix)', fontsize=fontsize1) - py.plot(fitLineX, fitLineY, 'b-') + plt.xlabel('X (pix)', fontsize=fontsize1) + plt.ylabel('Y (pix)', fontsize=fontsize1) + plt.plot(fitLineX, fitLineY, 'b-') col = col + 1 ind = (row-1)*Ncols + col bins = np.arange(-7.5, 7.5, 1) - paxes = py.subplot(Nrows, Ncols, ind) + paxes = plt.subplot(Nrows, Ncols, ind) id = np.where(diffY < 0)[0] - sig[id] = -1.*sig[id] - (n, b, p) = py.hist(sigX, bins, histtype='stepfilled', color='b', label='X') - py.setp(p, 'facecolor', 'b') - (n, b, p) = py.hist(sigY, bins, histtype='step', color='r', label='Y') - py.axis([-7, 7, 0, 8], fontsize=10) - py.legend() - py.xlabel('Residuals (sigma)', fontsize=fontsize1) - py.ylabel('Number of Epochs', fontsize=fontsize1) + sig[id] = -1.*sig[id] + (n, b, p) = plt.hist(sigX, bins, histtype='stepfilled', color='b', label='X') + plt.setp(p, 'facecolor', 'b') + (n, b, p) = plt.hist(sigY, bins, histtype='step', color='r', label='Y') + plt.axis([-7, 7, 0, 8], fontsize=10) + plt.legend() + plt.xlabel('Residuals (sigma)', fontsize=fontsize1) + plt.ylabel('Number of Epochs', fontsize=fontsize1) ########## # @@ -899,111 +1002,110 @@ def plotStar(starNames, rootDir='./', align='align/align_d_rms_1000_abs_t', # ########## if (radial == True): - py.clf() + plt.clf() - dateTicLoc = py.MultipleLocator(3) + dateTicLoc = plt.MultipleLocator(3) maxErr = np.array([rerr, terr]).max() resTicRng = [-3*maxErr, 3*maxErr] - from matplotlib.ticker import FormatStrFormatter fmtX = FormatStrFormatter('%5i') fmtY = FormatStrFormatter('%6.2f') - paxes = py.subplot(3,2,1) - py.plot(time, fitLineR, 'b-') - py.plot(time, fitLineR + fitSigR, 'b--') - py.plot(time, fitLineR - fitSigR, 'b--') - py.errorbar(time, r, yerr=rerr, fmt='k.') - rng = py.axis() - py.axis(dateTicRng + [rng[2], rng[3]]) - py.xlabel('Date (yrs)') - py.ylabel('R (pix)') + paxes = plt.subplot(3,2,1) + plt.plot(time, fitLineR, 'b-') + plt.plot(time, fitLineR + fitSigR, 'b--') + plt.plot(time, fitLineR - fitSigR, 'b--') + plt.errorbar(time, r, yerr=rerr, fmt='k.') + rng = plt.axis() + plt.axis(dateTicRng + [rng[2], rng[3]]) + plt.xlabel('Date (yrs)') + plt.ylabel('R (pix)') paxes.xaxis.set_major_formatter(fmtX) paxes.get_xaxis().set_major_locator(dateTicLoc) paxes.yaxis.set_major_formatter(fmtY) - paxes = py.subplot(3, 2, 2) - py.plot(time, fitLineT, 'b-') - py.plot(time, fitLineT + fitSigT, 'b--') - py.plot(time, fitLineT - fitSigT, 'b--') - py.errorbar(time, t, yerr=terr, fmt='k.') - rng = py.axis() - py.axis(dateTicRng + [rng[2], rng[3]]) - py.xlabel('Date (yrs)') - py.ylabel('T (pix)') + paxes = plt.subplot(3, 2, 2) + plt.plot(time, fitLineT, 'b-') + plt.plot(time, fitLineT + fitSigT, 'b--') + plt.plot(time, fitLineT - fitSigT, 'b--') + plt.errorbar(time, t, yerr=terr, fmt='k.') + rng = plt.axis() + plt.axis(dateTicRng + [rng[2], rng[3]]) + plt.xlabel('Date (yrs)') + plt.ylabel('T (pix)') paxes.xaxis.set_major_formatter(fmtX) paxes.get_xaxis().set_major_locator(dateTicLoc) paxes.yaxis.set_major_formatter(fmtY) - paxes = py.subplot(3, 2, 3) - py.plot(time, np.zeros(len(time)), 'b-') - py.plot(time, fitSigR, 'b--') - py.plot(time, -fitSigR, 'b--') - py.errorbar(time, r - fitLineR, yerr=rerr, fmt='k.') - py.axis(dateTicRng + resTicRng) - py.xlabel('Date (yrs)') - py.ylabel('R Residuals (pix)') + paxes = plt.subplot(3, 2, 3) + plt.plot(time, np.zeros(len(time)), 'b-') + plt.plot(time, fitSigR, 'b--') + plt.plot(time, -fitSigR, 'b--') + plt.errorbar(time, r - fitLineR, yerr=rerr, fmt='k.') + plt.axis(dateTicRng + resTicRng) + plt.xlabel('Date (yrs)') + plt.ylabel('R Residuals (pix)') paxes.get_xaxis().set_major_locator(dateTicLoc) - paxes = py.subplot(3, 2, 4) - py.plot(time, np.zeros(len(time)), 'b-') - py.plot(time, fitSigT, 'b--') - py.plot(time, -fitSigT, 'b--') - py.errorbar(time, t - fitLineT, yerr=terr, fmt='k.') - py.axis(dateTicRng + resTicRng) - py.xlabel('Date (yrs)') - py.ylabel('T Residuals (pix)') + paxes = plt.subplot(3, 2, 4) + plt.plot(time, np.zeros(len(time)), 'b-') + plt.plot(time, fitSigT, 'b--') + plt.plot(time, -fitSigT, 'b--') + plt.errorbar(time, t - fitLineT, yerr=terr, fmt='k.') + plt.axis(dateTicRng + resTicRng) + plt.xlabel('Date (yrs)') + plt.ylabel('T Residuals (pix)') paxes.get_xaxis().set_major_locator(dateTicLoc) bins = np.arange(-7, 7, 1) - py.subplot(3, 2, 5) - (n, b, p) = py.hist(sigR, bins) - py.setp(p, 'facecolor', 'k') - py.axis([-5, 5, 0, 20]) - py.xlabel('T Residuals (sigma)') - py.ylabel('Number of Epochs') - - py.subplot(3, 2, 6) - (n, b, p) = py.hist(sigT, bins) - py.axis([-5, 5, 0, 20]) - py.setp(p, 'facecolor', 'k') - py.xlabel('Y Residuals (sigma)') - py.ylabel('Number of Epochs') - - py.subplots_adjust(wspace=0.4, hspace=0.4, right=0.95, top=0.95) - py.savefig(rootDir+'plots/plotStarRadial_' + starName + '.png') - py.show() + plt.subplot(3, 2, 5) + (n, b, p) = plt.hist(sigR, bins) + plt.setp(p, 'facecolor', 'k') + plt.axis([-5, 5, 0, 20]) + plt.xlabel('T Residuals (sigma)') + plt.ylabel('Number of Epochs') + + plt.subplot(3, 2, 6) + (n, b, p) = plt.hist(sigT, bins) + plt.axis([-5, 5, 0, 20]) + plt.setp(p, 'facecolor', 'k') + plt.xlabel('Y Residuals (sigma)') + plt.ylabel('Number of Epochs') + + plt.subplots_adjust(wspace=0.4, hspace=0.4, right=0.95, top=0.95) + plt.savefig(rootDir+'plots/plotStarRadial_' + starName + '.png', dpi=300) + plt.show() title = rootDir.split('/')[-2] - py.suptitle(title, x=0.5, y=0.97) + plt.suptitle(title, x=0.5, y=0.97) if Nstars == 1: - py.subplots_adjust(wspace=0.4, hspace=0.4, left = 0.15, bottom = 0.1, right=0.9, top=0.9) - py.savefig(rootDir+'plots/plotStar_' + starName + '.png') + plt.subplots_adjust(wspace=0.4, hspace=0.4, left = 0.15, bottom = 0.1, right=0.9, top=0.9) + plt.savefig(rootDir+'plots/plotStar_' + starName + '.png', dpi=300) else: - py.subplots_adjust(wspace=0.6, hspace=0.6, left = 0.08, bottom = 0.05, right=0.95, top=0.90) - py.savefig(rootDir+'plots/plotStar_all.png') - py.show() + plt.subplots_adjust(wspace=0.6, hspace=0.6, left = 0.08, bottom = 0.05, right=0.95, top=0.90) + plt.savefig(rootDir+'plots/plotStar_all.png', dpi=300) + plt.show() - py.show() + plt.show() print('Fubar') - + ################################################## # New codes for velocity support in FlyStar and using -# the new StarTable and StarList format. +# the new StarTable and StarList format. ################################################## def plot_pm(tab): - plt.figure(figsize=(6,6)) + plt.figure(figsize=(6, 6)) plt.clf() plt.subplots_adjust(top=0.85) q = plt.quiver(tab['x0'].data, tab['y0'].data, tab['vx'].data*1e3, tab['vy'].data*1e3, scale=1e2, angles='xy') - plt.quiverkey(q, 0.5, 0.8, 10, '10 mas/yr', color='red', + plt.quiverkey(q, 0.5, 0.8, 10, '10 mas/yr', color='red', coordinates='figure', labelpos='E') plt.xlabel(r'$\Delta \alpha$ (")') plt.ylabel(r'$\Delta \delta$ (")') @@ -1020,16 +1122,16 @@ def plot_gaia(gaia): d_ra_tan = (ra_tan - ra_tan_mean) * cos_dec * 3600.0 d_de_tan = (de_tan - de_tan_mean) * 3600.0 - + pmra = gaia['pmra'] pmdec = gaia['pmdec'] - plt.figure(figsize=(6,6)) + plt.figure(figsize=(6, 6)) plt.clf() plt.subplots_adjust(top=0.85) q = plt.quiver(d_ra_tan.data, d_de_tan.data, pmra.data, pmdec.data, scale=1e2, angles='xy') - plt.quiverkey(q, 0.5, 0.8, 10, '10 mas/yr', color='red', + plt.quiverkey(q, 0.5, 0.8, 10, '10 mas/yr', color='red', coordinates='figure', labelpos='E') plt.xlabel(r'$\Delta \alpha \cos \delta$ ('')') plt.ylabel(r'$\Delta \delta$ ('')') @@ -1037,41 +1139,90 @@ def plot_gaia(gaia): fmt = r'[$\alpha$, $\delta$] = [{0:8.3f}$^\circ$, {1:8.3f}$^\circ$]' plt.title(fmt.format(ra_tan_mean, de_tan_mean)) plt.gca().invert_xaxis() - - return -def plot_pm_error(tab): - plt.figure(figsize=(6,6)) - plt.clf() - plt.semilogy(tab['m0'], tab['vxe']*1e3, 'r.', label=r'$\sigma_{\mu_{\alpha *}}$', alpha=0.4) - plt.semilogy(tab['m0'], tab['vye']*1e3, 'b.', label=r'$\sigma_{\mu_{\delta}}$', alpha=0.4) - plt.legend() - plt.xlabel('Mag') - plt.ylabel('PM Error (mas/yr)') +def plot_pm_error(tab, save_path=None): + fig, ax = plt.subplots(1, 1, figsize=(6, 6)) + ax.semilogy(tab['m0'], tab['vx_err']*1e3, color='C0', marker='.', ls='none', ms=3, label=r'$\sigma_{\mu_{\alpha *}}$', alpha=0.3) + ax.semilogy(tab['m0'], tab['vy_err']*1e3, color='C3', marker='.', ls='none', ms=3, label=r'$\sigma_{\mu_{\delta}}$', alpha=0.3) + ax.legend() + ax.set_xlabel('Mag') + ax.set_ylabel('PM Error (mas/yr)') + plt.tight_layout() + if save_path is not None: + plt.savefig(save_path, dpi=300) + plt.show() + return +def plot_mag_error(tab, save_path=None): + fig, ax = plt.subplots(1, 1, figsize=(6, 6)) + ax.semilogy(tab['m0'], tab['m0_err'], color='C0', marker='.', ls='none', alpha=0.4) + ax.legend() + ax.set_xlabel('Mag') + ax.set_ylabel('Mag Error (mag)') + plt.tight_layout() + if save_path is not None: + plt.savefig(save_path, dpi=300) + plt.show() return -def plot_mag_error(tab): - plt.figure(figsize=(6,6)) - plt.clf() - plt.semilogy(tab['m0'], tab['m0e'], 'r.', alpha=0.4) - plt.legend() - plt.xlabel('Mag') - plt.ylabel('Mag Error (mag)') +def _epoch_times(tab): + """ + One time per epoch, as a 1D array of length N_lists. + + A StarTable's 't' column is 2D (N_stars, N_lists) but is filled one + column at a time from a single per-list time, so every star in an epoch + shares that epoch's time -- except where a star wasn't detected, which + leaves nan. Recovering the epoch grid therefore does not require any + star to be detected in every epoch: take each column's finite entries. + Falls back to meta['list_times'] when there is no 't' column at all. + + Parameters + ---------- + tab : StarTable + + Returns + ------- + times : ndarray, shape (N_lists,) + + Raises + ------ + KeyError + If the table carries no time information at all. + ValueError + If some epoch has no finite time in any star, so its time is + genuinely unknown. + """ + if 't' in tab.colnames: + t = np.asarray(tab['t'], dtype=float) + finite = np.isfinite(t) + times = np.full(t.shape[1], np.nan) + for j in np.flatnonzero(finite.any(axis=0)): + times[j] = np.median(t[finite[:, j], j]) + elif 'list_times' in tab.meta: + times = np.asarray(tab.meta['list_times'], dtype=float) + else: + raise KeyError("Failed to access time values: no 't' column in the " + "table and no 'list_times' in its meta.") + + if not np.all(np.isfinite(times)): + bad = np.flatnonzero(~np.isfinite(times)) + raise ValueError(f"No finite time for epoch(s) {bad.tolist()} -- no star " + "in those epochs has a time, so the epoch grid is unknown.") + + return times - return def plot_mean_residuals_by_epoch(tab): """ Plot mean position and magnitude residuals vs. epoch. - Note we are plotting the mean( |dx} ) to see + Note we are plotting the ``mean(|dx|)`` to see the size of the mean residual. """ - # Predicted model positions at each epoch - dt = tab['t'] - tab['t0'][:, np.newaxis] - xt_mod = tab['x0'][:, np.newaxis] + tab['vx'][:, np.newaxis] * dt - yt_mod = tab['y0'][:, np.newaxis] + tab['vy'][:, np.newaxis] * dt + # Predicted model positions at each epoch. A 1D array of times is one grid + # shared by every star, so this returns shape (N_stars, N_lists). + epoch_times = _epoch_times(tab) + xt_mod, yt_mod, xt_mod_err, yt_mod_err = tab.infer_positions(epoch_times) # Residuals dx = tab['x'] - xt_mod @@ -1102,9 +1253,9 @@ def plot_mean_residuals_by_epoch(tab): plt.subplots_adjust(wspace=0.4) ax1 = plt.subplot(2, 1, 1) - plt.errorbar(tab['t'][0, :], dx_mean, yerr=dx_std, + plt.errorbar(epoch_times, dx_mean, yerr=dx_std, marker='s', linestyle='none', color='blue', ecolor='blue', label='X') - plt.errorbar(tab['t'][0, :], dy_mean, yerr=dy_std, + plt.errorbar(epoch_times, dy_mean, yerr=dy_std, marker='x', linestyle='none', color='red', ecolor='red', label='Y') plt.axhline(0, ls='--', color='black') plt.legend() @@ -1112,15 +1263,15 @@ def plot_mean_residuals_by_epoch(tab): plt.ylabel('Pos Residuals') plt.subplot(2, 1, 2, sharex=ax1) - plt.errorbar(tab['t'][0, :], dm_mean, yerr=dm_std, + plt.errorbar(epoch_times, dm_mean, yerr=dm_std, marker='o', linestyle='none', color='black', ecolor='black') plt.axhline(0, ls='--', color='black') plt.xlabel('Time (yr)') plt.ylabel('Mag Residuals') - + return -def plot_quiver_residuals_all_epochs(tab, unit='arcsec', scale=None, plotlim=None): +def plot_quiver_residuals_all_epochs(tab, unit='arcsec', scale=None, plotlim=None, save_path=None, show_plot=True): # Keep track of the residuals for averaging. dr_good = np.zeros(len(tab), dtype=float) @@ -1128,19 +1279,32 @@ def plot_quiver_residuals_all_epochs(tab, unit='arcsec', scale=None, plotlim=Non dr_ref = np.zeros(len(tab), dtype=float) n_ref = np.zeros(len(tab), dtype=int) + # motion_model_dict = motion_model.validate_motion_model_dict(motion_model_dict, tab, None) + complete_times = np.array([np.unique(col[~np.isnan(col)])[0] for col in tab['t'].T]) + # xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.get_star_positions_at_time(complete_times, motion_model_dict, allow_alt_models=True) + xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.infer_positions(complete_times) + for ee in range(tab['x'].shape[1]): - dt = tab['t'][:, ee] - tab['t0'] - xt_mod = tab['x0'] + tab['vx'] * dt - yt_mod = tab['y0'] + tab['vy'] * dt - + xt_mod = xt_mod_all[:,ee] + yt_mod = yt_mod_all[:,ee] + good_idx = np.where(np.isfinite(tab['x'][:, ee]) == True)[0] ref_idx = np.where(tab[good_idx]['used_in_trans'][:, ee] == True)[0] - dx, dy = plot_quiver_residuals(tab['x'][:, ee], tab['y'][:, ee], - xt_mod, yt_mod, - good_idx, ref_idx, - 'Epoch {0:d}'.format(ee), - unit=unit, scale=scale, plotlim=plotlim) + dx, dy = plot_quiver_residuals( + tab['x'][:, ee], + tab['y'][:, ee], + xt_mod, + yt_mod, + good_idx, + ref_idx, + 'Epoch {0:d}'.format(ee), + unit=unit, + scale=scale, + plotlim=plotlim, + show_plot=show_plot, + save_path=f'{save_path}/Quiver_Residual_{ee}.png' if save_path else None + ) # Building up average dr for a set of stars. dr = np.hypot(dx, dy) @@ -1154,13 +1318,13 @@ def plot_quiver_residuals_all_epochs(tab, unit='arcsec', scale=None, plotlim=Non dr_good_avg = np.zeros(len(tab), dtype=float) idx = np.where(n_good > 0)[0] dr_good_avg[idx] = dr_good[idx] / n_good[idx] - + dr_ref_avg = np.zeros(len(tab), dtype=float) idx = np.where(n_ref > 0)[0] dr_ref_avg[idx] = dr_ref[idx] / n_ref[idx] - hdr = '{name:>16s} {mag:>5s} {dr:>6s} {x:>6s} {y:>6s} {r:>6s}' - fmt = '{name:16s} {mag:5.2f} {dr:6.4f} {x:6.3f} {y:6.3f} {r:6.3f}' + # hdr = '{name:>16s} {mag:>5s} {dr:>6s} {x:>6s} {y:>6s} {r:>6s}' + # fmt = '{name:16s} {mag:5.2f} {dr:6.4f} {x:6.3f} {y:6.3f} {r:6.3f}' # print() # print('##########') @@ -1180,11 +1344,11 @@ def plot_quiver_residuals_all_epochs(tab, unit='arcsec', scale=None, plotlim=Non # if (dr_ref_avg[rr] > 0): # print(fmt.format(name=tab['name'][rr], mag=tab['m0'][rr], dr=dr_ref_avg[rr], # x=tab['x0'][rr], y=tab['y0'][rr], r=np.hypot(tab['x0'][rr], tab['y0'][rr]))) - + return -def plot_quiver_residuals_with_orig_all_epochs(tab, trans_list, unit='arcsec', scale=None, plotlim=None, scale_orig=None, cte_fit=None, mlim=15): +def plot_quiver_residuals_with_orig_all_epochs(tab, trans_list, unit='arcsec', scale=None, plotlim=None, scale_orig=None, cte_fit=None, mlim=15, show_plot=True, save_path=None): # Keep track of the residuals for averaging. dr_good = np.zeros(len(tab), dtype=float) @@ -1192,59 +1356,64 @@ def plot_quiver_residuals_with_orig_all_epochs(tab, trans_list, unit='arcsec', s dr_ref = np.zeros(len(tab), dtype=float) n_ref = np.zeros(len(tab), dtype=int) + # motion_model_dict = motion_model.validate_motion_model_dict(motion_model_dict, tab, None) + i_all_detected = np.where(~np.any(np.isnan(tab['t']),axis=1))[0][0] + # xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.get_star_positions_at_time(tab['t'][i_all_detected], motion_model_dict, allow_alt_models=True) + xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.infer_positions(tab['t'][i_all_detected]) + for ee in range(tab['x'].shape[1]): dt = tab['t'][:, ee] - tab['t0'] - xt_mod = tab['x0'] + tab['vx'] * dt - yt_mod = tab['y0'] + tab['vy'] * dt - + xt_mod = xt_mod_all[ee] + yt_mod = yt_mod_all[ee] + good_idx = np.where(np.isfinite(tab['x'][:, ee]) == True)[0] ref_idx = np.where(tab[good_idx]['used_in_trans'][:, ee] == True)[0] da = calc_da(trans_list[ee]) - dx, dy = plot_quiver_residuals(tab['x'][:, ee], tab['y'][:, ee], - xt_mod, yt_mod, + dx, dy = plot_quiver_residuals(tab['x'][:, ee], tab['y'][:, ee], + xt_mod, yt_mod, good_idx, ref_idx, - 'Epoch {0:d}'.format(ee), - unit=unit, scale=scale, plotlim=plotlim) + 'Epoch {0:d}'.format(ee), + unit=unit, scale=scale, plotlim=plotlim, show_plot=show_plot, save_path=f'{save_path}/Quiver_Residual_{ee}.png' if save_path else None) - plot_quiver_residuals_orig(tab['x'][:, ee], tab['y'][:, ee], - xt_mod, yt_mod, + plot_quiver_residuals_orig(tab['x'][:, ee], tab['y'][:, ee], + xt_mod, yt_mod, good_idx, ref_idx, tab['x_orig'][:, ee], tab['y_orig'][:, ee], da, - 'Epoch {0:d}'.format(ee), - scale=scale_orig, plotlim=plotlim) + 'Epoch {0:d}'.format(ee), + scale=scale_orig, plotlim=plotlim, show_plot=show_plot, save_path=f'{save_path}/Quiver_Residual_Orig_{ee}.png' if save_path else None) - plot_mag_scatter(tab['m'][:, ee], - tab['m0'], tab['m0e'], - tab['x'][:, ee], tab['y'][:, ee], + plot_mag_scatter(tab['m'][:, ee], + tab['m0'], tab['m0_err'], + tab['x'][:, ee], tab['y'][:, ee], tab['xe'][:, ee], tab['ye'][:, ee], - xt_mod, yt_mod, + xt_mod, yt_mod, good_idx, ref_idx, 'Epoch {0:d}'.format(ee), da=da, xorig=tab['x_orig'][:, ee], yorig=tab['y_orig'][:, ee], - cte_fit=cte_fit, mlim=mlim) + cte_fit=cte_fit, mlim=mlim, show_plot=show_plot, save_path=f'{save_path}/Mag_Scatter_{ee}.png' if save_path else None) - plot_y_scatter(tab['m'][:, ee], - tab['m0'], tab['m0e'], - tab['x'][:, ee], tab['y'][:, ee], + plot_y_scatter(tab['m'][:, ee], + tab['m0'], tab['m0_err'], + tab['x'][:, ee], tab['y'][:, ee], tab['xe'][:, ee], tab['ye'][:, ee], - xt_mod, yt_mod, + xt_mod, yt_mod, good_idx, ref_idx, 'Epoch {0:d}'.format(ee), da=da, xorig=tab['x_orig'][:, ee], yorig=tab['y_orig'][:, ee], - cte_fit=cte_fit, mlim=mlim) + cte_fit=cte_fit, mlim=mlim, show_plot=show_plot, save_path=f'{save_path}/Y_Scatter_{ee}.png' if save_path else None) # plot_quiver_residuals_orig_angle_xy(tab['x'][:, ee], tab['y'][:, ee], -# xt_mod, yt_mod, +# xt_mod, yt_mod, # good_idx, ref_idx, # tab['x_orig'][:, ee], tab['y_orig'][:, ee], da, # 'Epoch {0:d}'.format(ee)) # # plot_quiver_residuals_vs_pos_err(dx, dy, good_idx, ref_idx, -# 1e3 * tab['xe'][:, ee], 1e3 * tab['ye'][:, ee], +# 1e3 * tab['xe'][:, ee], 1e3 * tab['ye'][:, ee], # 'positional err (mas)', 'Epoch {0:d}'.format(ee), da=da) - + # Building up average dr for a set of stars. dr = np.hypot(dx, dy) @@ -1257,13 +1426,13 @@ def plot_quiver_residuals_with_orig_all_epochs(tab, trans_list, unit='arcsec', s dr_good_avg = np.zeros(len(tab), dtype=float) idx = np.where(n_good > 0)[0] dr_good_avg[idx] = dr_good[idx] / n_good[idx] - + dr_ref_avg = np.zeros(len(tab), dtype=float) idx = np.where(n_ref > 0)[0] dr_ref_avg[idx] = dr_ref[idx] / n_ref[idx] - hdr = '{name:>16s} {mag:>5s} {dr:>6s} {x:>6s} {y:>6s} {r:>6s}' - fmt = '{name:16s} {mag:5.2f} {dr:6.4f} {x:6.3f} {y:6.3f} {r:6.3f}' + # hdr = '{name:>16s} {mag:>5s} {dr:>6s} {x:>6s} {y:>6s} {r:>6s}' + # fmt = '{name:16s} {mag:5.2f} {dr:6.4f} {x:6.3f} {y:6.3f} {r:6.3f}' # print() # print('##########') @@ -1283,7 +1452,7 @@ def plot_quiver_residuals_with_orig_all_epochs(tab, trans_list, unit='arcsec', s # if (dr_ref_avg[rr] > 0): # print(fmt.format(name=tab['name'][rr], mag=tab['m0'][rr], dr=dr_ref_avg[rr], # x=tab['x0'][rr], y=tab['y0'][rr], r=np.hypot(tab['x0'][rr], tab['y0'][rr]))) - + return @@ -1291,24 +1460,28 @@ def plot_mag_scatter_multi_trans_all_epochs(tab_list, trans_list_list, unit='arc m_t_list = [] x_t_list = [] y_t_list = [] - xe_t_list = [] + xe_t_list = [] ye_t_list = [] x_ref_list = [] - y_ref_list = [] - good_idx_list = [] - ref_idx_list =[] + y_ref_list = [] + good_idx_list = [] + ref_idx_list =[] da_list = [] ntrans = len(tab_list) + # motion_model_dict = motion_model.validate_motion_model_dict(motion_model_dict, tab, None) + i_all_detected = np.where(~np.any(np.isnan(tab['t']),axis=1))[0][0] + # xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.get_star_positions_at_time(tab['t'][i_all_detected], motion_model_dict, allow_alt_models=True) + xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.infer_positions(tab['t'][i_all_detected]) for mm in range(ntrans): tab = tab_list[mm] trans_list = trans_list_list[mm] for ee in range(tab['x'].shape[1]): dt = tab['t'][:, ee] - tab['t0'] - xt_mod = tab['x0'] + tab['vx'] * dt - yt_mod = tab['y0'] + tab['vy'] * dt - + xt_mod = xt_mod_all[ee] + yt_mod = yt_mod_all[ee] + good_idx = np.where(np.isfinite(tab['x'][:, ee]) == True)[0] ref_idx = np.where(tab[good_idx]['used_in_trans'][:, ee] == True)[0] @@ -1317,19 +1490,19 @@ def plot_mag_scatter_multi_trans_all_epochs(tab_list, trans_list_list, unit='arc m_t_list.append(tab['m'][:, ee]) x_t_list.append(tab['x'][:, ee]) y_t_list.append(tab['y'][:, ee]) - xe_t_list.append(tab['xe'][:, ee]) + xe_t_list.append(tab['xe'][:, ee]) ye_t_list.append(tab['ye'][:, ee]) x_ref_list.append(xt_mod) y_ref_list.append(yt_mod) - good_idx_list.append(good_idx) - ref_idx_list.append(ref_idx) + good_idx_list.append(good_idx) + ref_idx_list.append(ref_idx) da_list.append(da) for ee in range(tab_list[0]['x'].shape[1]): - plot_mag_scatter_multi_trans(m_t_list[ee::ntrans], x_t_list[ee::ntrans], y_t_list[ee::ntrans], - xe_t_list[ee::ntrans], ye_t_list[ee::ntrans], x_ref_list[ee::ntrans], y_ref_list[ee::ntrans], + plot_mag_scatter_multi_trans(m_t_list[ee::ntrans], x_t_list[ee::ntrans], y_t_list[ee::ntrans], + xe_t_list[ee::ntrans], ye_t_list[ee::ntrans], x_ref_list[ee::ntrans], y_ref_list[ee::ntrans], good_idx_list[ee::ntrans], ref_idx_list[ee::ntrans], 'Epoch {0:d}'.format(ee), da_list[ee::ntrans]) - + return @@ -1351,7 +1524,7 @@ def calc_da(trans_list): c01 = trans_list.px.parameters[c01_idx] c10 = trans_list.px.parameters[c10_idx] da = np.degrees(np.arctan2(-c01, c10)) - + return da @@ -1359,7 +1532,7 @@ def plot_mag_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, # Residual dx = (x_t - x_ref) dy = (y_t - y_ref) - + # Magnitude mgood = m_t[good_idx] mref = m_t[good_idx][ref_idx] @@ -1397,8 +1570,7 @@ def plot_mag_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, xgood = np.cos(np.radians(agood)) * rgood xref = np.cos(np.radians(aref)) * rref - fig, ax = plt.subplots(7, 1, figsize=(6,18), sharex=True, num=103) -# plt.clf() + fig, ax = plt.subplots(7, 1, figsize=(6, 18), sharex=True, num=103) plt.subplots_adjust(hspace=0.01) ax[0].scatter(mgood, agood, color='black', alpha=0.3, s=2) ax[0].scatter(mref, aref, color='red', alpha=0.3, s=2) @@ -1448,15 +1620,17 @@ def plot_mag_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, ax[6].axhline(y=0) ax[0].set_title(title) + plt.tight_layout() plt.show() plt.pause(1) + return def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, ref_idx, title, da=0, xorig=None, yorig=None, cte_fit=None, mlim=15): # Residual dx = (x_t - x_ref) dy = (y_t - y_ref) - + # Magnitude mgood = m_t[good_idx] mref = m_t[good_idx][ref_idx] @@ -1494,7 +1668,7 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r xgood = np.cos(np.radians(agood)) * rgood xref = np.cos(np.radians(aref)) * rref - fig, ax = plt.subplots(7, 1, figsize=(6,18), sharex=True, num=103) + fig, ax = plt.subplots(7, 1, figsize=(6, 18), sharex=True, num=103) # plt.clf() plt.subplots_adjust(hspace=0.01) ax[0].scatter(yorig[good_idx], agood, color='black', alpha=0.3, s=2) @@ -1547,6 +1721,7 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r ax[6].axhline(y=0) ax[0].set_title(title) + plt.tight_layout() plt.show() plt.pause(1) @@ -1567,24 +1742,24 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r if cte_fit=='power': idx = np.where(mgood > mlim)[0] gpopt, gpcov = curve_fit(T_cte_y, mgood[idx], ygood[idx], maxfev=100000) - + marr = np.linspace(13, 24, 1000) - + # Corrected values ygood_new = ygood - T_cte_y(mgood, *gpopt) yref_new = yref - T_cte_y(mref, *gpopt) - + agood = angle_from_xy(xgood, ygood) % 360 rgood = np.hypot(xgood, ygood) aref = angle_from_xy(xref, yref) % 360 rref = np.hypot(xref, yref) - + agood_new = angle_from_xy(xgood, ygood_new) % 360 rgood_new = np.hypot(xgood, ygood_new) aref_new = angle_from_xy(xref, yref_new) % 360 rref_new = np.hypot(xref, yref_new) - - fig, ax = plt.subplots(4, 2, figsize=(12,12), sharex=True, sharey='row', num=105) + + fig, ax = plt.subplots(4, 2, figsize=(12, 12), sharex=True, sharey='row', num=105) plt.subplots_adjust(hspace=0.01, wspace=0.01) ax[0,0].scatter(mgood, ygood, color='black', alpha=0.3, s=2) ax[0,0].scatter(mref, yref, color='red', alpha=0.3, s=2) @@ -1593,24 +1768,24 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r ax[0,0].axhline(y=0) ax[0,0].plot(marr, T_cte_y(marr, *gpopt), 'k-') ax[0,0].set_title('No correction') - + ax[0,1].scatter(mgood, ygood_new, color='black', alpha=0.3, s=2) ax[0,1].scatter(mref, yref_new, color='red', alpha=0.3, s=2) ax[0,1].set_ylim(-0.01, 0.01) ax[0,1].axhline(y=0) ax[0,1].set_title('Corrected') - + ax[1,0].scatter(mgood, ygood/yegood, color='black', alpha=0.3, s=2) ax[1,0].scatter(mref, yref/yeref, color='red', alpha=0.3, s=2) ax[1,0].set_ylabel('Res/Pos Err, y') ax[1,0].set_ylim(-10, 10) ax[1,0].axhline(y=0) - + ax[1,1].scatter(mgood, ygood_new/yegood, color='black', alpha=0.3, s=2) ax[1,1].scatter(mref, yref_new/yeref, color='red', alpha=0.3, s=2) ax[1,1].set_ylim(-10, 10) ax[1,1].axhline(y=0) - + ax[2,0].scatter(mgood, rgood, color='black', alpha=0.3, s=2) ax[2,0].scatter(mref, rref, color='red', alpha=0.3, s=2) ax[2,0].set_ylabel('Modulus (arcsec)') @@ -1619,7 +1794,7 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r ax[2,0].set_ylim(1e-6, 1.1 * np.max(np.concatenate([rgood.data, rref.data]))) else: ax[2,0].set_ylim(1e-6, 1.1 * np.max(np.concatenate([rgood, rref]))) - + ax[2,1].scatter(mgood, rgood_new, color='black', alpha=0.3, s=2) ax[2,1].scatter(mref, rref_new, color='red', alpha=0.3, s=2) ax[2,1].set_yscale('log') @@ -1627,16 +1802,19 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r ax[2,1].set_ylim(1e-6, 1.1 * np.max(np.concatenate([rgood_new.data, rref_new.data]))) else: ax[2,1].set_ylim(1e-6, 1.1 * np.max(np.concatenate([rgood_new, rref_new]))) - + ax[3,0].scatter(mgood, agood, color='black', alpha=0.3, s=2) ax[3,0].scatter(mref, aref, color='red', alpha=0.3, s=2) ax[3,0].set_ylabel('Angle (deg)') ax[3,0].set_xlabel('mag') - + ax[3,1].scatter(mgood, agood_new, color='black', alpha=0.3, s=2) ax[3,1].scatter(mref, aref_new, color='red', alpha=0.3, s=2) ax[3,1].set_xlabel('mag') + plt.tight_layout() + plt.show() + if cte_fit=='power_line': idx1 = np.where((mgood > 15) & (mgood < 18.5))[0] idx2 = np.where(mgood > 18.5)[0] @@ -1646,7 +1824,7 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r gpopt1, gpcov1 = curve_fit(T_line, mgood[idx1], ygood[idx1], maxfev=100000) gpopt2, gpcov2 = curve_fit(T_cte_y, mgood[idx2], ygood[idx2], maxfev=100000) - + marr1 = np.linspace(13, 18.5, 1000) marr2 = np.linspace(18.5, 24, 1000) @@ -1674,7 +1852,7 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r xeref2 = xeref[idx2r] yeref1 = yeref[idx1r] yeref2 = yeref[idx2r] - + # Corrected values ygood_new1 = ygood1 - T_line(mgood1, *gpopt1) yref_new1 = yref1 - T_line(mref1, *gpopt1) @@ -1700,8 +1878,8 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r rgood_new2 = np.hypot(xgood2, ygood_new2) aref_new2 = angle_from_xy(xref2, yref_new2) % 360 rref_new2 = np.hypot(xref2, yref_new2) - - fig, ax = plt.subplots(4, 2, figsize=(12,12), sharex=True, sharey='row', num=105) + + fig, ax = plt.subplots(4, 2, figsize=(12, 12), sharex=True, sharey='row', num=105) plt.subplots_adjust(hspace=0.01, wspace=0.01) ax[0,0].scatter(mgood, ygood, color='black', alpha=0.3, s=2) ax[0,0].scatter(mref, yref, color='red', alpha=0.3, s=2) @@ -1711,7 +1889,7 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r ax[0,0].plot(marr1, T_line(marr1, *gpopt1), 'b-') ax[0,0].plot(marr2, T_cte_y(marr2, *gpopt2), 'b-') ax[0,0].set_title('No correction') - + ax[0,1].scatter(mgood1, ygood_new1, color='black', alpha=0.3, s=2) ax[0,1].scatter(mref1, yref_new1, color='red', alpha=0.3, s=2) ax[0,1].scatter(mgood2, ygood_new2, color='black', alpha=0.3, s=2) @@ -1719,20 +1897,20 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r ax[0,1].set_ylim(-0.01, 0.01) ax[0,1].axhline(y=0) ax[0,1].set_title('Corrected') - + ax[1,0].scatter(mgood, ygood/yegood, color='black', alpha=0.3, s=2) ax[1,0].scatter(mref, yref/yeref, color='red', alpha=0.3, s=2) ax[1,0].set_ylabel('Res/Pos Err, y') ax[1,0].set_ylim(-10, 10) ax[1,0].axhline(y=0) - + ax[1,1].scatter(mgood1, ygood_new1/yegood1, color='black', alpha=0.3, s=2) ax[1,1].scatter(mref1, yref_new1/yeref1, color='red', alpha=0.3, s=2) ax[1,1].scatter(mgood2, ygood_new2/yegood2, color='black', alpha=0.3, s=2) ax[1,1].scatter(mref2, yref_new2/yeref2, color='red', alpha=0.3, s=2) ax[1,1].set_ylim(-10, 10) ax[1,1].axhline(y=0) - + ax[2,0].scatter(mgood, rgood, color='black', alpha=0.3, s=2) ax[2,0].scatter(mref, rref, color='red', alpha=0.3, s=2) ax[2,0].set_ylabel('Modulus (arcsec)') @@ -1741,7 +1919,7 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r ax[2,0].set_ylim(1e-6, 1.1 * np.max(np.concatenate([rgood.data, rref.data]))) else: ax[2,0].set_ylim(1e-6, 1.1 * np.max(np.concatenate([rgood, rref]))) - + ax[2,1].scatter(mgood1, rgood_new1, color='black', alpha=0.3, s=2) ax[2,1].scatter(mref1, rref_new1, color='red', alpha=0.3, s=2) ax[2,1].scatter(mgood2, rgood_new2, color='black', alpha=0.3, s=2) @@ -1751,18 +1929,18 @@ def plot_y_scatter(m_t, m0, m0e, x_t, y_t, xe_t, ye_t, x_ref, y_ref, good_idx, r ax[2,1].set_ylim(1e-6, 1.1 * np.max(np.concatenate([rgood_new.data2, rref_new.data2]))) else: ax[2,1].set_ylim(1e-6, 1.1 * np.max(np.concatenate([rgood_new2, rref_new2]))) - + ax[3,0].scatter(mgood, agood, color='black', alpha=0.3, s=2) ax[3,0].scatter(mref, aref, color='red', alpha=0.3, s=2) ax[3,0].set_ylabel('Angle (deg)') ax[3,0].set_xlabel('mag') - + ax[3,1].scatter(mgood1, agood_new1, color='black', alpha=0.3, s=2) ax[3,1].scatter(mref1, aref_new1, color='red', alpha=0.3, s=2) ax[3,1].scatter(mgood2, agood_new2, color='black', alpha=0.3, s=2) ax[3,1].scatter(mref2, aref_new2, color='red', alpha=0.3, s=2) ax[3,1].set_xlabel('mag') - + def T_cte_y(m, A, m0, alpha, m1): base = m/m0 @@ -1772,16 +1950,16 @@ def T_line(m, a, b): return a + m*b -def plot_quiver_residuals(x_t, y_t, x_ref, y_ref, good_idx, ref_idx, title, - unit='pixel', scale=None, plotlim=None): +def plot_quiver_residuals(x_t, y_t, x_ref, y_ref, good_idx, ref_idx, title, + unit='pixel', scale=None, plotlim=None, save_path=None, show_plot=True): """ unit : str 'pixel' or 'arcsec' The pixel units of the input values. Note, if arcsec, then the values will be - converted to milli-arcsec for plotting when appropriate. + converted to milli-arcsec for plotting when appropriate. scale : float - The quiver scale. If none, then default units will be used appropriate to the unit. + The quiver scale. If none, then default units will be used appropriate to the unit. plotlim : float (positive) Sets the size of the plotted figure. If None, then default is used. @@ -1810,33 +1988,37 @@ def plot_quiver_residuals(x_t, y_t, x_ref, y_ref, good_idx, ref_idx, title, unit2 = 'mas' - plt.figure(101, figsize=(6,6)) - plt.clf() - q = plt.quiver(x_ref[good_idx], y_ref[good_idx], dx[good_idx], dy[good_idx], + fig, ax = plt.subplots(1, 1, figsize=(6, 6)) + q = ax.quiver(x_ref[good_idx], y_ref[good_idx], dx[good_idx], dy[good_idx], color='black', scale=quiv_scale, angles='xy', alpha=0.5) - plt.quiver(x_ref[good_idx][ref_idx], y_ref[good_idx][ref_idx], dx[good_idx][ref_idx], dy[good_idx][ref_idx], + ax.quiver(x_ref[good_idx][ref_idx], y_ref[good_idx][ref_idx], dx[good_idx][ref_idx], dy[good_idx][ref_idx], color='red', scale=quiv_scale, angles='xy') - plt.quiverkey(q, 0.5, 0.85, quiv_label_val, quiv_label, + ax.quiverkey(q, 0.5, 0.85, quiv_label_val, quiv_label, coordinates='figure', labelpos='E', color='green') - plt.xlabel('X (ref ' + unit + ')') - plt.ylabel('Y (ref ' + unit + ')') - plt.title(title) - plt.axis('equal') + ax.set_xlabel('X (ref ' + unit + ')') + ax.set_ylabel('Y (ref ' + unit + ')') + ax.set_title(title) + ax.axis('equal') if plotlim is not None: - plt.xlim(-1 * plotlim, plotlim) - plt.ylim(-1 * plotlim, plotlim) - plt.show() - plt.pause(1) + ax.set_xlim(-1 * plotlim, plotlim) + ax.set_ylim(-1 * plotlim, plotlim) + plt.tight_layout() + if save_path: + plt.savefig(save_path, dpi=300) + if show_plot: + plt.show() + else: + plt.close() - str_fmt = 'Residuals (mean, std): dx = {0:7.3f} +/- {1:7.3f} {5:s} dy = {2:7.3f} +/- {3:7.3f} {5:s} for {4:s} stars' + str_fmt = '{0:s}: Residuals (mean, std): dx = {1:7.3f} ± {2:7.3f} {6:s} dy = {3:7.3f} ± {4:7.3f} {6:s} for {5:s} stars' if len(ref_idx) > 1: - print(str_fmt.format(dx[good_idx][ref_idx].mean(), dx[good_idx][ref_idx].std(), + print(str_fmt.format(title, dx[good_idx][ref_idx].mean(), dx[good_idx][ref_idx].std(), dy[good_idx][ref_idx].mean(), dy[good_idx][ref_idx].std(), 'REF', unit2)) else: - print(str_fmt.format(dx[good_idx][ref_idx].mean(), 0.0, + print(str_fmt.format(title, dx[good_idx][ref_idx].mean(), 0.0, dy[good_idx][ref_idx].mean(), 0.0, 'REF', unit2)) - - print(str_fmt.format(dx[good_idx].mean(), dx[good_idx].std(), + + print(str_fmt.format(title, dx[good_idx].mean(), dx[good_idx].std(), dy[good_idx].mean(), dy[good_idx].std(), 'GOOD', unit2)) @@ -1849,23 +2031,27 @@ def plot_quiver_residuals_magcolor_all_epochs(tab, unit='arcsec', scale=None, pl dr_ref = np.zeros(len(tab), dtype=float) n_ref = np.zeros(len(tab), dtype=int) - idx = np.where((tab['m0'] < lower_mag) & + idx = np.where((tab['m0'] < lower_mag) & (tab['m0'] > upper_mag))[0] tab = tab[idx] + # motion_model_dict = motion_model.validate_motion_model_dict(motion_model_dict, tab, None) + i_all_detected = np.where(~np.any(np.isnan(tab['t']),axis=1))[0][0] + # xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.get_star_positions_at_time(tab['t'][i_all_detected], motion_model_dict, allow_alt_models=True) + xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.infer_positions(tab['t'][i_all_detected]) for ee in range(tab['x'].shape[1]): dt = tab['t'][:, ee] - tab['t0'] - xt_mod = tab['x0'] + tab['vx'] * dt - yt_mod = tab['y0'] + tab['vy'] * dt + xt_mod = xt_mod_all[ee] + yt_mod = yt_mod_all[ee] mag = tab['m0'] good_idx = np.where(np.isfinite(tab['x'][:, ee]) == True)[0] ref_idx = np.where(tab[good_idx]['used_in_trans'][:, ee] == True)[0] - dx, dy = plot_quiver_residuals_magcolor(tab['x'][:, ee], tab['y'][:, ee], + dx, dy = plot_quiver_residuals_magcolor(tab['x'][:, ee], tab['y'][:, ee], xt_mod, yt_mod, mag, good_idx, ref_idx, - 'Epoch {0:d}'.format(ee), + 'Epoch {0:d}'.format(ee), unit=unit, scale=scale, plotlim=plotlim) # Building up average dr for a set of stars. @@ -1880,7 +2066,7 @@ def plot_quiver_residuals_magcolor_all_epochs(tab, unit='arcsec', scale=None, pl dr_good_avg = np.zeros(len(tab), dtype=float) idx = np.where(n_good > 0)[0] dr_good_avg[idx] = dr_good[idx] / n_good[idx] - + dr_ref_avg = np.zeros(len(tab), dtype=float) idx = np.where(n_ref > 0)[0] dr_ref_avg[idx] = dr_ref[idx] / n_ref[idx] @@ -1890,16 +2076,16 @@ def plot_quiver_residuals_magcolor_all_epochs(tab, unit='arcsec', scale=None, pl -def plot_quiver_residuals_magcolor(x_t, y_t, x_ref, y_ref, mag, good_idx, ref_idx, title, +def plot_quiver_residuals_magcolor(x_t, y_t, x_ref, y_ref, mag, good_idx, ref_idx, title, unit='pixel', scale=None, plotlim=None): """ unit : str 'pixel' or 'arcsec' The pixel units of the input values. Note, if arcsec, then the values will be - converted to milli-arcsec for plotting when appropriate. + converted to milli-arcsec for plotting when appropriate. scale : float - The quiver scale. If none, then default units will be used appropriate to the unit. + The quiver scale. If none, then default units will be used appropriate to the unit. plotlim : float (positive) Sets the size of the plotted figure. If None, then default is used. @@ -1927,51 +2113,48 @@ def plot_quiver_residuals_magcolor(x_t, y_t, x_ref, y_ref, mag, good_idx, ref_id quiv_label_val = 1.0 unit2 = 'mas' - norm = matplotlib.colors.Normalize() + norm = mcolors.Normalize() norm.autoscale(mag) - cm = matplotlib.cm.viridis - sm = matplotlib.cm.ScalarMappable(cmap=cm, norm=norm) + cmap = matplotlib.colormaps['viridis'] + sm = matplotlib.cm.ScalarMappable(cmap=cmap, norm=norm) # cmap = mpl.cm.cool -# norm = mpl.colors.Normalize(vmin=np.min(mag), vmax=np.max(mag)) -# +# norm = mpl.mcolors.Normalize(vmin=np.min(mag), vmax=np.max(mag)) +# # cb1 = mpl.colorbar.ColorbarBase(ax, cmap=cmap, # norm=norm, # orientation='horizontal') - plt.figure(101, figsize=(6,6)) - plt.clf() - q = plt.quiver(x_ref[good_idx], y_ref[good_idx], dx[good_idx], dy[good_idx], + fig, ax=plt.subplots(1, 1, figsize=(6, 6)) + q = ax.quiver(x_ref[good_idx], y_ref[good_idx], dx[good_idx], dy[good_idx], color=cm(norm(mag[good_idx])), scale=quiv_scale, angles='xy', alpha=0.8) - plt.quiverkey(q, 0.5, 0.85, quiv_label_val, quiv_label, + ax.quiverkey(q, 0.5, 0.85, quiv_label_val, quiv_label, coordinates='figure', labelpos='E', color='green') - plt.colorbar(sm) - plt.xlabel('X (ref ' + unit + ')') - plt.ylabel('Y (ref ' + unit + ')') - plt.title(title + ', Good') - plt.axis('equal') + fig.colorbar(sm, ax=ax) + ax.set_xlabel('X (ref ' + unit + ')') + ax.set_ylabel('Y (ref ' + unit + ')') + ax.set_title(title + ', Good') + ax.axis('equal') if plotlim is not None: - plt.xlim(-1 * plotlim, plotlim) - plt.ylim(-1 * plotlim, plotlim) + ax.set_xlim(-1 * plotlim, plotlim) + ax.set_ylim(-1 * plotlim, plotlim) + plt.tight_layout() plt.show() - plt.pause(1) - plt.figure(102, figsize=(6,6)) - plt.clf() - q = plt.quiver(x_ref[good_idx][ref_idx], y_ref[good_idx][ref_idx], dx[good_idx][ref_idx], dy[good_idx][ref_idx], + fig, ax = plt.subplots(1, 1, figsize=(6, 6)) + q = ax.quiver(x_ref[good_idx][ref_idx], y_ref[good_idx][ref_idx], dx[good_idx][ref_idx], dy[good_idx][ref_idx], color=cm(norm(mag[good_idx][ref_idx])), scale=quiv_scale, angles='xy', alpha=0.8) - plt.quiverkey(q, 0.5, 0.85, quiv_label_val, quiv_label, + ax.quiverkey(q, 0.5, 0.85, quiv_label_val, quiv_label, coordinates='figure', labelpos='E', color='green') - plt.colorbar(sm) - plt.xlabel('X (ref ' + unit + ')') - plt.ylabel('Y (ref ' + unit + ')') - plt.title(title + ', Ref') - plt.axis('equal') + fig.colorbar(sm, ax=ax) + ax.set_xlabel('X (ref ' + unit + ')') + ax.set_ylabel('Y (ref ' + unit + ')') + ax.set_title(title + ', Ref') + ax.axis('equal') if plotlim is not None: - plt.xlim(-1 * plotlim, plotlim) - plt.ylim(-1 * plotlim, plotlim) + ax.set_xlim(-1 * plotlim, plotlim) + ax.set_ylim(-1 * plotlim, plotlim) plt.show() - plt.pause(1) str_fmt = 'Residuals (mean, std): dx = {0:7.3f} +/- {1:7.3f} {5:s} dy = {2:7.3f} +/- {3:7.3f} {5:s} for {4:s} stars' if len(ref_idx) > 1: @@ -1980,7 +2163,7 @@ def plot_quiver_residuals_magcolor(x_t, y_t, x_ref, y_ref, mag, good_idx, ref_id else: print(str_fmt.format(dx[good_idx][ref_idx].mean(), 0.0, dy[good_idx][ref_idx].mean(), 0.0, 'REF', unit2)) - + print(str_fmt.format(dx[good_idx].mean(), dx[good_idx].std(), dy[good_idx].mean(), dy[good_idx].std(), 'GOOD', unit2)) @@ -1988,17 +2171,17 @@ def plot_quiver_residuals_magcolor(x_t, y_t, x_ref, y_ref, mag, good_idx, ref_id return (dx, dy) -def plot_quiver_residuals_orig(x_t, y_t, x_ref, y_ref, good_idx, ref_idx, - x_orig, y_orig, da, title, - scale=None, plotlim=None): +def plot_quiver_residuals_orig(x_t, y_t, x_ref, y_ref, good_idx, ref_idx, + x_orig, y_orig, da, title, + scale=None, plotlim=None, save_path=None): """ unit : str 'pixel' or 'arcsec' The pixel units of the input values. Note, if arcsec, then the values will be - converted to milli-arcsec for plotting when appropriate. + converted to milli-arcsec for plotting when appropriate. scale : float - The quiver scale. If none, then default units will be used appropriate to the unit. + The quiver scale. If none, then default units will be used appropriate to the unit. plotlim : float (positive) Sets the size of the plotted figure. If None, then default is used. @@ -2013,8 +2196,8 @@ def plot_quiver_residuals_orig(x_t, y_t, x_ref, y_ref, good_idx, ref_idx, dy /= 0.04 # Residual modulus - r_good = np.hypot(dx[good_idx], dy[good_idx]) - r_ref = np.hypot(dx[good_idx][ref_idx], dy[good_idx][ref_idx]) + # r_good = np.hypot(dx[good_idx], dy[good_idx]) + # r_ref = np.hypot(dx[good_idx][ref_idx], dy[good_idx][ref_idx]) # Residual angle agood = angle_from_xy(dx[good_idx], dy[good_idx]) @@ -2030,21 +2213,23 @@ def plot_quiver_residuals_orig(x_t, y_t, x_ref, y_ref, good_idx, ref_idx, dx_ref_new, dy_ref_new = rotate(dx[good_idx][ref_idx], dy[good_idx][ref_idx], -da) print('Rotation angle between HST and Gaia (deg): ', da) - plt.figure(102, figsize=(6,6)) - plt.clf() - q = plt.quiver(x_orig[good_idx], y_orig[good_idx], dx_good_new, dy_good_new, + fig, ax = plt.subplots(1, 1, figsize=(6, 6)) + q = ax.quiver(x_orig[good_idx], y_orig[good_idx], dx_good_new, dy_good_new, color='black', scale=scale, angles='xy', alpha=0.5) - plt.quiver(x_orig[good_idx][ref_idx], y_orig[good_idx][ref_idx], dx_ref_new, dy_ref_new, + ax.quiver(x_orig[good_idx][ref_idx], y_orig[good_idx][ref_idx], dx_ref_new, dy_ref_new, color='red', scale=scale, angles='xy') - plt.quiverkey(q, 0.5, 0.85, 0.3, '0.3 pix', + ax.quiverkey(q, 0.5, 0.85, 0.3, '0.3 pix', coordinates='figure', labelpos='E', color='green') - plt.xlabel('X (ref pix)') - plt.ylabel('Y (ref pix)') - plt.title(title) - plt.axis('equal') + ax.set_xlabel('X (ref pix)') + ax.set_ylabel('Y (ref pix)') + ax.set_title(title) + ax.axis('equal') if plotlim is not None: - plt.xlim(-1 * plotlim, plotlim) - plt.ylim(-1 * plotlim, plotlim) + ax.set_xlim(-1 * plotlim, plotlim) + ax.set_ylim(-1 * plotlim, plotlim) + plt.tight_layout() + if save_path: + plt.savefig(save_path, dpi=300) plt.show() plt.pause(1) @@ -2058,11 +2243,11 @@ def plot_quiver_residuals_orig(x_t, y_t, x_ref, y_ref, good_idx, ref_idx, # ax1.hist(aref ,color='red', histtype = 'step', # alpha=0.8, bins = 36, density=True) # ax1.set_xlabel('Quiver angle (degrees), HST camera') -# -# ax2.scatter(x_orig[good_idx], y_orig[good_idx], +# +# ax2.scatter(x_orig[good_idx], y_orig[good_idx], # s=5e3 * r_good**2, alpha=0.3, color='black') -# ax2.scatter(x_orig[good_idx][ref_idx], y_orig[good_idx][ref_idx], -# s=5e3 * r_ref**2, alpha=0.5, color='red') +# ax2.scatter(x_orig[good_idx][ref_idx], y_orig[good_idx][ref_idx], +# s=5e3 * r_ref**2, alpha=0.5, color='red') # ax2.set_xlabel('X (orig pix)') # ax2.set_ylabel('Y (orig pix)') # plt.title(title) @@ -2088,16 +2273,16 @@ def rotate(x, y, theta): return xnew, ynew -def plot_quiver_residuals_orig_angle_xy(x_t, y_t, x_ref, y_ref, good_idx, ref_idx, +def plot_quiver_residuals_orig_angle_xy(x_t, y_t, x_ref, y_ref, good_idx, ref_idx, x_orig, y_orig, da, title, scale=None, plotlim=None): """ unit : str 'pixel' or 'arcsec' The pixel units of the input values. Note, if arcsec, then the values will be - converted to milli-arcsec for plotting when appropriate. + converted to milli-arcsec for plotting when appropriate. scale : float - The quiver scale. If none, then default units will be used appropriate to the unit. + The quiver scale. If none, then default units will be used appropriate to the unit. plotlim : float (positive) Sets the size of the plotted figure. If None, then default is used. @@ -2105,7 +2290,7 @@ def plot_quiver_residuals_orig_angle_xy(x_t, y_t, x_ref, y_ref, good_idx, ref_id """ dx = (x_t - x_ref) dy = (y_t - y_ref) - + # Residual modulus r_good = np.hypot(dx[good_idx], dy[good_idx]) r_ref = np.hypot(dx[good_idx][ref_idx], dy[good_idx][ref_idx]) @@ -2120,13 +2305,13 @@ def plot_quiver_residuals_orig_angle_xy(x_t, y_t, x_ref, y_ref, good_idx, ref_id agood = agood % 360 aref = aref % 360 - plt.figure(figsize=(14,6)) - plt.clf() - ax1 = plt.subplot(1, 2, 1) - ax2 = plt.subplot(1, 2, 2) - plt.subplots_adjust(wspace=0.3) + # plt.figure(figsize=(12,6)) + # plt.clf() + # ax1 = plt.subplot(1, 2, 1) + # ax2 = plt.subplot(1, 2, 2) + # plt.subplots_adjust(wspace=0.3) - plt.clf() + # plt.clf() fig, ax = plt.subplots(1, 2, figsize=(12,6), sharey=True) # plt.clf() plt.subplots_adjust(wspace=0.01) @@ -2143,13 +2328,14 @@ def plot_quiver_residuals_orig_angle_xy(x_t, y_t, x_ref, y_ref, good_idx, ref_id if plotlim is not None: plt.xlim(-1 * plotlim, plotlim) plt.ylim(-1 * plotlim, plotlim) + plt.tight_layout() plt.show() plt.pause(1) return -def plot_chi2_dist(tab, Ndetect, xlim=40, n_bins=50): +def plot_chi2_dist(tab, Ndetect, xlim=40, n_bins=50, boot_err=False): """ tab = flystar table Ndetect = Number of epochs star detected in @@ -2158,26 +2344,33 @@ def plot_chi2_dist(tab, Ndetect, xlim=40, n_bins=50): chi2_y_list = [] fnd_list = [] # Number of non-NaN error measurements + # motion_model_dict = motion_model.validate_motion_model_dict(motion_model_dict, tab, None) + i_all_detected = np.where(~np.any(np.isnan(tab['t']),axis=1))[0][0] + # xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.get_star_positions_at_time(tab['t'][i_all_detected], motion_model_dict, allow_alt_models=True) + xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.infer_positions(tab['t'][i_all_detected]) + for ii in range(len(tab)): - # Ignore the NaNs + # Ignore the NaNs fnd = np.argwhere(~np.isnan(tab['xe'][ii,:])) -# fnd = np.where(tab['xe'][ii, :] > 0)[0] fnd_list.append(len(fnd)) - + x = tab['x'][ii, fnd] y = tab['y'][ii, fnd] - xerr = tab['xe'][ii, fnd] - yerr = tab['ye'][ii, fnd] + if boot_err: + xerr = np.hypot(tab['xe_boot'][ii, fnd], tab['xe'][ii, fnd]) + yerr = np.hypot(tab['ye_boot'][ii, fnd], tab['ye'][ii, fnd]) + else: + xerr = tab['xe'][ii, fnd] + yerr = tab['ye'][ii, fnd] - dt = tab['t'][ii, fnd] - tab['t0'][ii] - fitLineX = tab['x0'][ii] + (tab['vx'][ii] * dt) - fitLineY = tab['y0'][ii] + (tab['vy'][ii] * dt) + fitLineX = xt_mod_all[ii, fnd] + fitLineY = yt_mod_all[ii,fnd] diffX = x - fitLineX diffY = y - fitLineY sigX = diffX / xerr sigY = diffY / yerr - + chi2_x = np.sum(sigX**2) chi2_y = np.sum(sigY**2) chi2_x_list.append(chi2_x) @@ -2186,27 +2379,117 @@ def plot_chi2_dist(tab, Ndetect, xlim=40, n_bins=50): x = np.array(chi2_x_list) y = np.array(chi2_y_list) fnd = np.array(fnd_list) - + idx = np.where(fnd == Ndetect)[0] # Fitting position and velocity... so subtract 2 to get Ndof - Ndof = Ndetect - 2 + n_params = np.nanmean(tab['n_params'][idx]) + Ndof = Ndetect - n_params + if len(np.unique(tab['n_params'][idx]))>1: + print("** Warning: using average Ndof for multiple motion models. **") + print("** Consider using plot_chi2_reduced_dist. **") + print(f"Ndof={Ndof:.2f}, Ndetect={Ndetect}, Nparams={n_params:.2f}") + else: + print(f"Ndof={Ndof}, Ndetect={Ndetect}, Nparams={n_params}") chi2_xaxis = np.linspace(0, xlim, xlim*3) chi2_bins = np.linspace(0, xlim, n_bins) - plt.figure(figsize=(6,4)) + plt.figure(figsize=(6, 4)) plt.clf() plt.hist(x[idx], bins=chi2_bins, histtype='step', label='X', density=True) plt.hist(y[idx], bins=chi2_bins, histtype='step', label='Y', density=True) - plt.plot(chi2_xaxis, chi2.pdf(chi2_xaxis, Ndof), 'r-', alpha=0.6, - label='$\chi^2$ ' + str(Ndof) + ' dof') - plt.title('$N_{epoch} = $' + str(Ndetect) + ', $N_{dof} = $' + str(Ndof)) + plt.plot(chi2_xaxis, chi2.pdf(chi2_xaxis, Ndof), 'r-', alpha=0.6, + label=r'$\chi^2$ ' + str(round(Ndof,2)) + ' dof') + plt.title('$N_{epoch} = $' + str(Ndetect) + ', $N_{dof} = $' + str(round(Ndof,2))) + plt.xlim(0, xlim) + plt.legend() + plt.tight_layout() + plt.show() + + chi2red_x = x / Ndof + chi2red_y = y / Ndof + chi2red_t = (x + y) / (2.0 * Ndof) + + print('Mean reduced chi^2: (Ndetect = {0:d} of {1:d})'.format(len(idx), len(tab))) + fmt = ' {0:s} = {1:.1f} for N_detect and {2:.1f} for all' + med_chi2red_x_f = np.median(chi2red_x[idx]) + med_chi2red_x_a = np.median(chi2red_x) + med_chi2red_y_f = np.median(chi2red_y[idx]) + med_chi2red_y_a = np.median(chi2red_y) + med_chi2red_t_f = np.median(chi2red_t[idx]) + med_chi2red_t_a = np.median(chi2red_t) + print(fmt.format(' X', med_chi2red_x_f, med_chi2red_x_a)) + print(fmt.format(' Y', med_chi2red_y_f, med_chi2red_y_a)) + print(fmt.format('Tot', med_chi2red_t_f, med_chi2red_t_a)) + + return + +def plot_chi2_reduced_dist(tab, Ndetect, xlim=8, n_bins=50, boot_err=False): + """ + tab = flystar table + Ndetect = Number of epochs star detected in + """ + chi2_x_list = [] + chi2_y_list = [] + fnd_list = [] # Number of non-NaN error measurements + + # motion_model_dict = motion_model.validate_motion_model_dict(motion_model_dict, tab, None) + i_all_detected = np.where(~np.any(np.isnan(tab['t']),axis=1))[0][0] + # xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.get_star_positions_at_time(tab['t'][i_all_detected], motion_model_dict, allow_alt_models=True) + xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.infer_positions(tab['t'][i_all_detected]) + + for ii in range(len(tab)): + # Ignore the NaNs + fnd = np.argwhere(~np.isnan(tab['xe'][ii,:])) + fnd_list.append(len(fnd)) + + x = tab['x'][ii, fnd] + y = tab['y'][ii, fnd] + if boot_err: + xerr = np.hypot(tab['xe_boot'][ii, fnd], tab['xe'][ii, fnd]) + yerr = np.hypot(tab['ye_boot'][ii, fnd], tab['ye'][ii, fnd]) + else: + xerr = tab['xe'][ii, fnd] + yerr = tab['ye'][ii, fnd] + + fitLineX = xt_mod_all[ii, fnd] + fitLineY = yt_mod_all[ii,fnd] + + diffX = x - fitLineX + diffY = y - fitLineY + sigX = diffX / xerr + sigY = diffY / yerr + + chi2_x = np.sum(sigX**2) + chi2_y = np.sum(sigY**2) + chi2_x_list.append(chi2_x) + chi2_y_list.append(chi2_y) + + x = np.array(chi2_x_list) + y = np.array(chi2_y_list) + fnd = np.array(fnd_list) + + idx = np.where(fnd == Ndetect)[0] + n_params = tab['n_params'] + Ndof = Ndetect - n_params + print("Reduced chi2 for Ndetect="+str(Ndetect)) + chi2_bins = np.linspace(0, xlim, n_bins) + + plt.figure(figsize=(6, 4)) + plt.clf() + plt.hist(x[idx]/Ndof[idx], bins=chi2_bins, histtype='step', label='X', density=True) + plt.hist(y[idx]/Ndof[idx], bins=chi2_bins, histtype='step', label='Y', density=True) + plt.axvline(np.median(x[idx]/Ndof[idx]), color='C0', linestyle='--', label='X median') + plt.axvline(np.median(y[idx]/Ndof[idx]), color='C1', linestyle='--', label='Y median') + plt.title('Reduced chi2, $N_{epoch} = $' + str(Ndetect)) plt.xlim(0, xlim) plt.legend() + plt.tight_layout() + plt.show() + + chi2red_x = x / Ndof + chi2red_y = y / Ndof + chi2red_t = (x + y) / (2.0 * Ndof + 1*(tab['motion_model_used']=='Parallax')) - chi2red_x = x / (fnd - 2) - chi2red_y = y / (fnd - 2) - chi2red_t = (x + y) / (2.0 * (fnd - 2)) - print('Mean reduced chi^2: (Ndetect = {0:d} of {1:d})'.format(len(idx), len(tab))) fmt = ' {0:s} = {1:.1f} for N_detect and {2:.1f} for all' med_chi2red_x_f = np.median(chi2red_x[idx]) @@ -2222,35 +2505,134 @@ def plot_chi2_dist(tab, Ndetect, xlim=40, n_bins=50): return -def plot_chi2_dist_per_epoch(tab, Ndetect, xlim, ylim = [-1, 1], target_idx = 0): +def plot_chi2_dist_per_filter(tab, Ndetect, xlim=40, n_bins=50, filter=None, boot_err=False): """ tab = flystar table Ndetect = Number of epochs star detected in """ - diffX_arr = -99 * np.ones((len(tab['xe']), Ndetect)) - diffY_arr = -99 * np.ones((len(tab['xe']), Ndetect)) - errX_arr = -99 * np.ones((len(tab['xe']), Ndetect)) - errY_arr = -99 * np.ones((len(tab['xe']), Ndetect)) - sigX_arr = -99 * np.ones((len(tab['xe']), Ndetect)) - sigY_arr = -99 * np.ones((len(tab['xe']), Ndetect)) - m_arr = -99 * np.ones((len(tab['xe']), Ndetect)) + chi2_x_list = [] + chi2_y_list = [] + fnd_list = [] # Number of non-NaN error measurements + + # motion_model_dict = motion_model.validate_motion_model_dict(motion_model_dict, tab, None) + i_all_detected = np.where(~np.any(np.isnan(tab['t']),axis=1))[0][0] + # xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.get_star_positions_at_time(tab['t'][i_all_detected], motion_model_dict, allow_alt_models=True) + xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.infer_positions(tab['t'][i_all_detected]) + + for ii in range(len(tab)): + # Ignore the NaNs + fnd = np.argwhere(~np.isnan(tab['xe'][ii,:])) + fnd_list.append(len(fnd)) + + x = tab['x'][ii, fnd] + y = tab['y'][ii, fnd] + if boot_err: + xerr = np.hypot(tab['xe_boot'][ii, fnd], tab['xe'][ii, fnd]) + yerr = np.hypot(tab['ye_boot'][ii, fnd], tab['ye'][ii, fnd]) + else: + xerr = tab['xe'][ii, fnd] + yerr = tab['ye'][ii, fnd] + + fitLineX = xt_mod_all[ii, fnd] + fitLineY = yt_mod_all[ii,fnd] + + diffX = x - fitLineX + diffY = y - fitLineY + sigX = diffX / xerr + sigY = diffY / yerr + + chi2_x = np.sum(sigX**2) + chi2_y = np.sum(sigY**2) + chi2_x_list.append(chi2_x) + chi2_y_list.append(chi2_y) + #print(fitLineX, x, xerr) + + x = np.array(chi2_x_list) + y = np.array(chi2_y_list) + fnd = np.array(fnd_list) + + + idx = np.where(fnd == Ndetect)[0] + # Fitting position and velocity... so subtract n_params to get Ndof + n_params = np.nanmean(tab['n_params'][idx]) + Ndof = Ndetect - n_params + print(f"Ndof={Ndof}, Ndetect={Ndetect}, Nparams={n_params}") + chi2_xaxis = np.linspace(0, xlim, xlim*3) + chi2_bins = np.linspace(0, xlim, n_bins) + print(x[idx]) + + plt.figure(figsize=(6, 4)) + plt.clf() + plt.hist(x[idx], bins=chi2_bins, histtype='stepfilled', label='RA', density=True, color='skyblue', alpha=0.8, edgecolor='k') + plt.hist(y[idx], bins=chi2_bins, histtype='stepfilled', label='DEC', density=True, color='orange', alpha=0.8, edgecolor='k') + plt.plot(chi2_xaxis, chi2.pdf(chi2_xaxis, Ndof), 'r-', alpha=0.6, + label=r'$\chi^2$ ' + str(Ndof) + ' dof') + #plt.title('$N_{epoch} = $' + str(Ndetect) + ', $N_{dof} = $' + str(Ndof)) + plt.title(str(filter)+' (N = '+str(len(chi2_x_list))+')', fontsize=22) + plt.xlim(0, xlim) + plt.ylabel(r'PDF', fontsize=28) + plt.legend(fontsize=20) + plt.tick_params(labelsize=20, direction='in', right=True, top=True) + plt.tight_layout() + plt.savefig(str(filter)+'_chi2_dist.png', dpi=300) + plt.close() + + chi2red_x = x / Ndof + chi2red_y = y / Ndof + chi2red_t = (x + y) / (2.0 * Ndof) + + print('Mean reduced chi^2: (Ndetect = {0:d} of {1:d})'.format(len(idx), len(tab))) + fmt = ' {0:s} = {1:.1f} for N_detect and {2:.1f} for all' + med_chi2red_x_f = np.median(chi2red_x[idx]) + med_chi2red_x_a = np.median(chi2red_x) + med_chi2red_y_f = np.median(chi2red_y[idx]) + med_chi2red_y_a = np.median(chi2red_y) + med_chi2red_t_f = np.median(chi2red_t[idx]) + med_chi2red_t_a = np.median(chi2red_t) + print(fmt.format(' X', med_chi2red_x_f, med_chi2red_x_a)) + print(fmt.format(' Y', med_chi2red_y_f, med_chi2red_y_a)) + print(fmt.format('Tot', med_chi2red_t_f, med_chi2red_t_a)) + + return + + +def plot_chi2_dist_per_epoch(tab, Ndetect, mlim=[14, 21], ylim=[-1, 1], target_idx=0, boot_err=False): + """ + tab = flystar table + Ndetect = Number of epochs star detected in + """ + diffX_arr = np.nan * np.ones((len(tab['xe']), Ndetect)) + diffY_arr = np.nan * np.ones((len(tab['xe']), Ndetect)) + errX_arr = np.nan * np.ones((len(tab['xe']), Ndetect)) + errY_arr = np.nan * np.ones((len(tab['xe']), Ndetect)) + sigX_arr = np.nan * np.ones((len(tab['xe']), Ndetect)) + sigY_arr = np.nan * np.ones((len(tab['xe']), Ndetect)) + m_arr = np.nan * np.ones((len(tab['xe']), Ndetect)) + + # motion_model_dict = motion_model.validate_motion_model_dict(motion_model_dict, tab, None) + i_all_detected = np.where(~np.any(np.isnan(tab['t']),axis=1))[0][0] + # xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.get_star_positions_at_time(tab['t'][i_all_detected], motion_model_dict, allow_alt_models=True) + xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.infer_positions(tab['t'][i_all_detected]) for ii in range(len(tab['xe'])): - # Ignore the NaNs + # Ignore the NaNs fnd = np.argwhere(~np.isnan(tab['xe'][ii,:])) - if len(fnd) == Ndetect: + if len(fnd) == Ndetect and tab['use_in_trans'][ii]: time = tab['t'][ii, fnd] x = tab['x'][ii, fnd] y = tab['y'][ii, fnd] m = tab['m'][ii, fnd] - xerr = tab['xe'][ii, fnd] - yerr = tab['ye'][ii, fnd] + if boot_err: + xerr = np.hypot(tab['xe_boot'][ii, fnd], tab['xe'][ii, fnd]) + yerr = np.hypot(tab['ye_boot'][ii, fnd], tab['ye'][ii, fnd]) + else: + xerr = tab['xe'][ii, fnd] + yerr = tab['ye'][ii, fnd] + + fitLineX = xt_mod_all[ii, fnd] + fitLineY = yt_mod_all[ii, fnd] - dt = tab['t'][ii, fnd] - tab['t0'][ii] - fitLineX = tab['x0'][ii] + (tab['vx'][ii] * dt) - fitLineY = tab['y0'][ii] + (tab['vy'][ii] * dt) - diffX = x - fitLineX diffY = y - fitLineY sigX = diffX / xerr @@ -2259,7 +2641,7 @@ def plot_chi2_dist_per_epoch(tab, Ndetect, xlim, ylim = [-1, 1], target_idx = 0) diffX_arr[ii] = diffX.reshape(Ndetect,) diffY_arr[ii] = diffY.reshape(Ndetect,) errX_arr[ii] = xerr.reshape(Ndetect,) - errY_arr[ii] = yerr.reshape(Ndetect,) + errY_arr[ii] = yerr.reshape(Ndetect,) sigX_arr[ii] = sigX.reshape(Ndetect,) sigY_arr[ii] = sigY.reshape(Ndetect,) m_arr[ii] = m.reshape(Ndetect,) @@ -2281,32 +2663,157 @@ def plot_chi2_dist_per_epoch(tab, Ndetect, xlim, ylim = [-1, 1], target_idx = 0) if target_idx is not None: ax2.plot(m_arr[target_idx, ii], sigX_arr[target_idx, ii], 's', color='black', ms=5) ax2.plot(m_arr[target_idx, ii], sigY_arr[target_idx, ii], 'o', color='black', ms=5) - ax2.set_xlim(xlim[0], xlim[1]) + ax2.set_xlim(mlim[0], mlim[1]) ax2.set_ylim(-5, 5) ax2.axhline(y=0, color='black', alpha=0.9, zorder=1000) + ax2.axhline(y=np.nanmean(sigX_arr[:, ii]), color='tab:blue', alpha=0.9,linestyle='dotted', zorder=1001) + ax2.axhline(y=np.nanmean(sigY_arr[:, ii]), color='tab:orange', alpha=0.9,linestyle='dotted', zorder=1002) ax2.set_xlabel('mag') ax2.set_ylabel('sigma') ax2.set_title('Epoch {0}'.format(ii)) ax2.legend() - ax3.errorbar(m_arr[:, ii], diffX_arr[:, ii]*1E3, yerr=errX_arr[:, ii]*1E3, + #print(errX_arr[:, ii]) + ax3.errorbar(m_arr[:, ii], diffX_arr[:, ii]*1E3, yerr=errX_arr[:, ii]*1E3, marker='s', label = 'X', ls='none', color='tab:blue', alpha=0.4, ms=5) - ax3.errorbar(m_arr[:, ii], diffY_arr[:, ii]*1E3, yerr=errY_arr[:, ii]*1E3, + ax3.errorbar(m_arr[:, ii], diffY_arr[:, ii]*1E3, yerr=errY_arr[:, ii]*1E3, marker='o', label = 'Y', ls='none', color='tab:orange', alpha=0.4, ms=5) if target_idx is not None: - ax3.errorbar(m_arr[target_idx, ii], diffX_arr[target_idx, ii]*1E3, yerr=errX_arr[target_idx, ii]*1E3, + ax3.errorbar(m_arr[target_idx, ii], diffX_arr[target_idx, ii]*1E3, yerr=errX_arr[target_idx, ii]*1E3, marker='s', ls='none', color='black', ms=5) - ax3.errorbar(m_arr[target_idx, ii], diffY_arr[target_idx, ii]*1E3, yerr=errY_arr[target_idx, ii]*1E3, + ax3.errorbar(m_arr[target_idx, ii], diffY_arr[target_idx, ii]*1E3, yerr=errY_arr[target_idx, ii]*1E3, marker='o', ls='none', color='black', ms=5) - ax3.set_xlim(xlim[0], xlim[1]) + ax3.set_xlim(mlim[0], mlim[1]) ax3.set_ylim(ylim[0], ylim[1]) ax3.axhline(y=0, color='black', alpha=0.9, zorder=1000) + ax3.axhline(y=np.nanmean(diffX_arr[:, ii]*1E3), color='tab:blue', alpha=0.9,linestyle='dotted', zorder=1001) + ax3.axhline(y=np.nanmean(diffY_arr[:, ii]*1E3), color='tab:orange', alpha=0.9,linestyle='dotted', zorder=1002) ax3.set_xlabel('mag') ax3.set_ylabel('residual (mas)') return -def plot_chi2_dist_mag(tab, Ndetect, mlim=40, n_bins=30): +# TODO: update for motion model +def plot_chi2_ecliptic_per_epoch(tab, Ndetect,ra,dec, mlim=[14,21], ylim = [-1, 1], target_idx = 0): + """ + tab = flystar table + Ndetect = Number of epochs star detected in + """ + diffX_arr = -99 * np.ones((len(tab['xe']), Ndetect)) + diffY_arr = -99 * np.ones((len(tab['xe']), Ndetect)) + errX_arr = 99 * np.ones((len(tab['xe']), Ndetect)) + errY_arr = 99 * np.ones((len(tab['xe']), Ndetect)) + sigX_arr = -99 * np.ones((len(tab['xe']), Ndetect)) + sigY_arr = -99 * np.ones((len(tab['xe']), Ndetect)) + m_arr = -99 * np.ones((len(tab['xe']), Ndetect)) + + rad_to_as = 180/np.pi * 60 * 60 + deg_to_as = 60 * 60 + def eq_to_ec(ra,dec): + e = 23.446 * np.pi/180 + sinb = np.sin(dec)*np.cos(e) - np.cos(dec)*np.sin(e)*np.sin(ra) + cosb = np.cos(np.arcsin(sinb)) + cosg = np.cos(ra)*np.cos(dec)/cosb + sing = (np.sin(dec)*np.sin(e) + np.cos(dec)*np.cos(e)*np.sin(ra))/cosb + g,b = np.arctan2(sing,cosg)*180/np.pi,np.arcsin(sinb)*180/np.pi + g = 360+g + return g*deg_to_as,b*deg_to_as + coord0 = SkyCoord(ra=ra,dec=dec,unit=(u.hourangle, u.deg),frame='icrs') + + for ii in range(len(tab['xe'])): + # Ignore the NaNs + fnd = np.argwhere(~np.isnan(tab['xe'][ii,:])) + if len(fnd) == Ndetect and tab['use_in_trans'][ii]: + time = tab['t'][ii, fnd] + x = tab['x'][ii, fnd] + y = tab['y'][ii, fnd] + m = tab['m'][ii, fnd] + vx = tab['vx'][ii] + vy = tab['vy'][ii] + lambda_0,beta_0 = eq_to_ec((coord0.ra - tab['x0'][ii]*u.arcsec).radian, + (coord0.dec + tab['y0'][ii]*u.arcsec).radian) + x1 = coord0.ra - u.arcsec*x + y1 = coord0.dec + u.arcsec*y + ra_rad,dec_rad = x1.radian, y1.radian + lambda_obs,beta_obs = eq_to_ec(ra_rad,dec_rad) + x2 = coord0.ra - tab['x0'][ii]*u.arcsec - (time-tab['t0'][ii])*vx*u.arcsec + y2 = coord0.dec + tab['y0'][ii]*u.arcsec + (time-tab['t0'][ii])*vy*u.arcsec + ra_rad,dec_rad = x2.radian, y2.radian + lambda_pm,beta_pm = eq_to_ec(ra_rad,dec_rad) + + xerr = tab['xe'][ii, fnd] + yerr = tab['ye'][ii, fnd] + + dt = tab['t'][ii, fnd] - tab['t0'][ii] + fitLineX = lambda_pm + fitLineY = beta_pm + + diffX = lambda_obs - fitLineX + diffY = beta_obs - fitLineY + sigX = diffX / xerr + sigY = diffY / yerr + + diffX_arr[ii] = diffX.reshape(Ndetect,) + diffY_arr[ii] = diffY.reshape(Ndetect,) + errX_arr[ii] = xerr.reshape(Ndetect,) + errY_arr[ii] = yerr.reshape(Ndetect,) + sigX_arr[ii] = sigX.reshape(Ndetect,) + sigY_arr[ii] = sigY.reshape(Ndetect,) + m_arr[ii] = m.reshape(Ndetect,) + + ts_folded = tab['t'][0]%1 + i_sort = np.argsort(ts_folded) + print(ts_folded,i_sort) + for ii in i_sort: +# fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(15, 4), +# gridspec_kw={'width_ratios': [1, 2, 2]}) +# plt.subplots_adjust(wspace=0.5) +# ax1.hist(sigX_arr[:, ii], label = 'X', histtype='step', bins=np.linspace(-10, 10)) +# ax1.hist(sigY_arr[:, ii], label = 'Y', histtype='step', bins=np.linspace(-10, 10)) +# ax1.set_xlabel('sigma') +# ax1.legend() + + fig, (ax2, ax3) = plt.subplots(1, 2, figsize=(14, 4)) + plt.subplots_adjust(wspace=0.25) + + '''ax2.plot(m_arr[:, ii], sigX_arr[:, ii], 's', label = 'lambda', color='tab:blue', alpha=0.4, ms=5) + ax2.plot(m_arr[:, ii], sigY_arr[:, ii], 'o', label = 'beta', color='tab:orange', alpha=0.4, ms=5) + if target_idx is not None: + ax2.plot(m_arr[target_idx, ii], sigX_arr[target_idx, ii], 's', color='black', ms=5) + ax2.plot(m_arr[target_idx, ii], sigY_arr[target_idx, ii], 'o', color='black', ms=5) + ax2.set_xlim(mlim[0], mlim[1]) + ax2.set_ylim(-5, 5) + ax2.axhline(y=0, color='black', alpha=0.9, zorder=1000) + ax2.set_xlabel('mag') + ax2.set_ylabel('sigma')''' + ax2.set_title('Epoch {0}'.format(ii)+', phase='+str(tab['t'][0][ii]%1)[:5]) + + #print(errX_arr[:, ii]) + ax2.errorbar(m_arr[:, ii], diffX_arr[:, ii]*1E3, yerr=errX_arr[:, ii]*1E3, + marker='s', label = 'lambda', ls='none', color='tab:blue', alpha=0.4, ms=5) + ax3.errorbar(m_arr[:, ii], diffY_arr[:, ii]*1E3, yerr=errY_arr[:, ii]*1E3, + marker='o', label = 'beta', ls='none', color='tab:orange', alpha=0.4, ms=5) + if target_idx is not None: + #print('target',m_arr[target_idx, ii],diffX_arr[target_idx, ii]*1E3,diffY_arr[target_idx, ii]*1E3) + ax2.errorbar(m_arr[target_idx, ii], diffX_arr[target_idx, ii]*1E3, yerr=errX_arr[target_idx, ii]*1E3, + marker='s', ls='none', color='black', ms=5) + ax3.errorbar(m_arr[target_idx, ii], diffY_arr[target_idx, ii]*1E3, yerr=errY_arr[target_idx, ii]*1E3, + marker='o', ls='none', color='black', ms=5) + ax2.legend() + ax3.legend() + ax2.set_xlim(mlim[0], mlim[1]) + ax3.set_xlim(mlim[0], mlim[1]) + ax2.set_ylim(ylim[0], ylim[1]) + ax3.set_ylim(ylim[0], ylim[1]) + ax2.axhline(y=0, color='black', alpha=0.9, zorder=1000) + ax3.axhline(y=0, color='black', alpha=0.9, zorder=1000) + ax2.set_xlabel('mag') + ax2.set_ylabel('residual (mas)') + ax3.set_xlabel('mag') + ax3.set_ylabel('residual (mas)') + return + +def plot_chi2_dist_mag(tab, Ndetect, xlim=40, n_bins=30, boot_err=False): """ tab = flystar table Ndetect = Number of epochs star detected in @@ -2315,18 +2822,21 @@ def plot_chi2_dist_mag(tab, Ndetect, mlim=40, n_bins=30): fnd_list = [] # Number of non-NaN error measurements for ii in range(len(tab['me'])): - # Ignore the NaNs + # Ignore the NaNs fnd = np.argwhere(~np.isnan(tab['me'][ii,:])) fnd_list.append(len(fnd)) - + m = tab['m'][ii, fnd] - merr = tab['me'][ii, fnd] + if boot_err: + merr = np.hypot(tab['me_boot'][ii, fnd], tab['me'][ii, fnd]) + else: + merr = tab['me'][ii, fnd] m0 = tab['m0'][ii] - m0err = tab['m0e'][ii] + m0err = tab['m0_err'][ii] diff_m = m0 - m sig_m = diff_m/merr - + chi2_m = np.sum(sig_m**2) chi2_m_list.append(chi2_m) @@ -2337,34 +2847,95 @@ def plot_chi2_dist_mag(tab, Ndetect, mlim=40, n_bins=30): # Fitting mean magnitude... so subtract 1 to get Ndof Ndof = Ndetect - 1 - chi2_maxis = np.linspace(0, mlim, mlim*3) - chi2_bins = np.linspace(0, mlim, n_bins) + chi2_maxis = np.linspace(0, xlim, xlim*3) + chi2_bins = np.linspace(0, xlim, n_bins) - plt.figure(figsize=(6,4)) + plt.figure(figsize=(6, 4)) plt.clf() - plt.hist(chi2_m[idx], bins=np.arange(mlim*10), histtype='step', density=True) - plt.plot(chi2_maxis, chi2.pdf(chi2_maxis, Ndof), 'r-', alpha=0.6, - label='$\chi^2$ ' + str(Ndof) + ' dof') + plt.hist(chi2_m[idx], bins=np.arange(xlim*10), histtype='step', density=True) + plt.plot(chi2_maxis, chi2.pdf(chi2_maxis, Ndof), 'r-', alpha=0.6, + label=r'$\chi^2$ ' + str(Ndof) + ' dof') plt.title('$N_{epoch} = $' + str(Ndetect) + ', $N_{dof} = $' + str(Ndof)) - plt.xlim(0, mlim) + plt.xlim(0, xlim) plt.legend() + plt.tight_layout() + plt.show() + + print('Mean reduced chi^2: (Ndetect = {0:d} of {1:d})'.format(len(idx), len(tab))) + fmt = ' {0:s} = {1:.1f} for N_detect and {2:.1f} for all' + print(fmt.format('M', np.median(chi2_m[idx] / (fnd[idx] - 2)), np.median(chi2_m / (fnd - 2)))) + + return + +def plot_chi2_dist_mag_per_filter(tab, Ndetect, mlim=40, n_bins=30, xlim=40, file_name=None, filter=None): + """ + tab = flystar table + Ndetect = Number of epochs star detected in + """ + chi2_m_list = [] + fnd_list = [] # Number of non-NaN error measurements + + for ii in range(len(tab['me'])): + # Ignore the NaNs + fnd = np.argwhere(~np.isnan(tab['me'][ii,:])) + fnd_list.append(len(fnd)) + + m = tab['m'][ii, fnd] + merr = tab['me'][ii, fnd] + m0 = tab['m0'][ii] + m0err = tab['m0_err'][ii] + + diff_m = m0 - m + sig_m = diff_m/merr + + chi2_m = np.sum(sig_m**2) + chi2_m_list.append(chi2_m) + + chi2_m = np.array(chi2_m_list) + fnd = np.array(fnd_list) + + idx = np.where(fnd == Ndetect)[0] + + # Fitting mean magnitude... so subtract 1 to get Ndof + Ndof = Ndetect - 1 + chi2_maxis = np.linspace(0, xlim, xlim*3) + chi2_bins = np.linspace(0, xlim, n_bins) + + plt.figure(figsize=(6, 4)) + plt.clf() + plt.hist(chi2_m[idx], bins=np.arange(xlim*10), label='mag', histtype='stepfilled', density=True, color='green', alpha=0.7, edgecolor='k') + plt.plot(chi2_maxis, chi2.pdf(chi2_maxis, Ndof), 'r-', alpha=0.6, + label=r'$\chi^2$ ' + str(Ndof) + ' dof') + #plt.title('$N_{epoch} = $' + str(Ndetect) + ', $N_{dof} = $' + str(Ndof)) + plt.xlim(0, xlim) + plt.xlabel(r'$\chi^{2}$', fontsize=28) + plt.ylabel(r'PDF', fontsize=28) + plt.legend(fontsize=20) + plt.tick_params(labelsize=20, direction='in', right=True, top=True) + plt.tight_layout() + plt.savefig(str(filter)+'_chi2_dist_mag.png', dpi=300) + plt.close() print('Mean reduced chi^2: (Ndetect = {0:d} of {1:d})'.format(len(idx), len(tab))) fmt = ' {0:s} = {1:.1f} for N_detect and {2:.1f} for all' print(fmt.format('M', np.median(chi2_m[idx] / (fnd[idx] - 2)), np.median(chi2_m / (fnd - 2)))) - + return -def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), color_time=False): +def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), color_time=False, boot_err=False): """ - Plot a set of stars positions, flux and residuals over time. + Plot a set of stars positions, flux and residuals over time. epoch_array : None, array Array of the epoch indicies to plot. If None, plots all epochs. """ + + def rs(x): + return x.reshape(len(x)) + print( 'Creating residuals plots for star(s):' ) print( star_names ) - + Nstars = len(star_names) Ncols = 3 * np.min([Nstars, NcolMax]) if Nstars <= Ncols/3: @@ -2379,10 +2950,17 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), x = tab['x0'] y = tab['y0'] r = np.hypot(x, y) - + # motion_model_dict = motion_model.validate_motion_model_dict(motion_model_dict, tab, None) + i_all_detected = np.where(~np.any(np.isnan(tab['t']),axis=1))[0][0] + cont_times = np.arange(np.min(tab['t'][i_all_detected]), np.max(tab['t'][i_all_detected]), 0.01) + # xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.get_star_positions_at_time(tab['t'][i_all_detected], motion_model_dict, allow_alt_models=True) + xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.infer_positions(tab['t'][i_all_detected]) + # xt_cont_all, yt_cont_all, xt_cont_err, yt_cont_err = tab.get_star_positions_at_time(cont_times, motion_model_dict, allow_alt_models=True) + xt_cont_all, yt_cont_all, xt_cont_err, yt_cont_err = tab.infer_positions(cont_times) + for i in range(Nstars): starName = star_names[i] - + try: ii = np.where(tab['name'] == starName)[0][0] except IndexError: @@ -2397,24 +2975,30 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), fnd = fnd.reshape(len(fnd),1) time = tab['t'][ii, fnd] - dtime = time.data % 1 + dtime = time.data % 1 x = tab['x'][ii, fnd] y = tab['y'][ii, fnd] m = tab['m'][ii, fnd] - xerr = tab['xe'][ii, fnd] - yerr = tab['ye'][ii, fnd] - merr = tab['me'][ii, fnd] + if boot_err: + xerr = np.hypot(tab['xe'][ii, fnd], tab['xe_boot'][ii, fnd]) + yerr = np.hypot(tab['ye'][ii, fnd], tab['ye_boot'][ii, fnd]) + merr = np.hypot(tab['me'][ii, fnd], tab['me_boot'][ii, fnd]) + else: + xerr = tab['xe'][ii, fnd] + yerr = tab['ye'][ii, fnd] + merr = tab['me'][ii, fnd] dt = tab['t'][ii, fnd] - tab['t0'][ii] - fitLineX = tab['x0'][ii] + (tab['vx'][ii] * dt) - fitLineY = tab['y0'][ii] + (tab['vy'][ii] * dt) - fitSigX = np.hypot(tab['x0e'][ii], tab['vxe'][ii]*dt) - fitSigY = np.hypot(tab['y0e'][ii], tab['vye'][ii]*dt) + fitLineX = xt_mod_all[ii, fnd] + fitLineY = yt_mod_all[ii, fnd] + + fitSigX = xt_mod_err[ii, fnd] + fitSigY = yt_mod_err[ii, fnd] fitLineM = np.repeat(tab['m0'][ii], len(dt)).reshape(len(dt),1) - fitSigM = np.repeat(tab['m0e'][ii], len(dt)).reshape(len(dt),1) + fitSigM = np.repeat(tab['m0_err'][ii], len(dt)).reshape(len(dt),1) diffX = x - fitLineX diffY = y - fitLineY @@ -2437,21 +3021,23 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), chi2_y = np.sum(sigY**2) chi2_m = np.sum(sigM**2) - dof = len(x) - 2 + dof = (len(tab['x'][ii])-tab['n_params'][ii]).astype(int) dofM = len(m) - 1 chi2_red_x = chi2_x / dof chi2_red_y = chi2_y / dof chi2_red_m = chi2_m / dofM - + print( 'Star: ', starName ) - print( '\tX Chi^2 = %5.2f (%6.2f for %2d dof)' % + print( '\tX Chi^2 = %5.2f (%6.2f for %2d dof)' % (chi2_red_x, chi2_x, dof)) - print( '\tY Chi^2 = %5.2f (%6.2f for %2d dof)' % + print( '\tY Chi^2 = %5.2f (%6.2f for %2d dof)' % (chi2_red_y, chi2_y, dof)) - print( '\tM Chi^2 = %5.2f (%6.2f for %2d dof)' % + print( '\tM Chi^2 = %5.2f (%6.2f for %2d dof)' % (chi2_red_m, chi2_m, dofM)) + if 'motion_model_used' in tab.keys(): + print('\tMotion model:', tab['motion_model_used'][ii]) tmin = time.min() tmax = time.max() @@ -2483,7 +3069,6 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), resTicRng = [-1.1*maxErr, 1.1*maxErr] resTicRngM = [-1.1*maxErrM, 1.1*maxErrM] - from matplotlib.ticker import FormatStrFormatter fmtX = FormatStrFormatter('%5i') fmtY = FormatStrFormatter('%6.3f') fmtM = FormatStrFormatter('%5.2f') @@ -2497,18 +3082,23 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), row = 1 else: col = 1 + 3*(i % (Ncols/3)) - row = 1 + 3*(i//(Ncols/3)) + row = 1 + 3*(i//(Ncols/3)) ind = int((row-1)*Ncols + col) paxes = plt.subplot(Nrows, Ncols, ind) - plt.plot(time, fitLineX, 'b-') - plt.plot(time, fitLineX + fitSigX, 'b--') - plt.plot(time, fitLineX - fitSigX, 'b--') + plt.plot(cont_times, xt_cont_all[ii], 'b-') + plt.plot(cont_times, xt_cont_all[ii] + xt_cont_err[ii], 'b--') + plt.plot(cont_times, xt_cont_all[ii] - xt_cont_err[ii], 'b--') if not color_time: - plt.errorbar(time, x, yerr=xerr.reshape(len(xerr),), fmt='k.') + #print('x:',x) + #print('xerr:',xerr) + #print('xerr_reshaped:', xerr.reshape(len(xerr),)) + #plt.errorbar(time, x, yerr=xerr.reshape(len(xerr)), fmt='k.') + plt.errorbar(rs(time), rs(x), yerr=rs(xerr), fmt='k.') + #plt.errorbar(time, x, yerr=xerr, fmt='k.') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, x, xerr, time_color): @@ -2534,13 +3124,13 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), ind = int((row-1)*Ncols + col) paxes = plt.subplot(Nrows, Ncols, ind) - plt.plot(time, fitLineY, 'b-') - plt.plot(time, fitLineY + fitSigY, 'b--') - plt.plot(time, fitLineY - fitSigY, 'b--') + plt.plot(cont_times, yt_cont_all[ii], 'b-') + plt.plot(cont_times, yt_cont_all[ii] + yt_cont_err[ii], 'b--') + plt.plot(cont_times, yt_cont_all[ii] - yt_cont_err[ii], 'b--') if not color_time: - plt.errorbar(time, y, yerr=yerr.reshape(len(yerr),), fmt='k.') + plt.errorbar(rs(time), rs(y), yerr=rs(yerr), fmt='k.') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, y, yerr, time_color): @@ -2568,9 +3158,9 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), plt.plot(time, fitLineM + fitSigM, 'g--') plt.plot(time, fitLineM - fitSigM, 'g--') if not color_time: - plt.errorbar(time, m, yerr=merr.reshape(len(merr),), fmt='k.') + plt.errorbar(rs(time), rs(m), yerr=rs(merr), fmt='k.') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, m, merr, time_color): @@ -2586,7 +3176,7 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), paxes.xaxis.set_major_formatter(fmtX) paxes.yaxis.set_major_formatter(fmtM) paxes.tick_params(axis='both', which='major', labelsize=12) - + ########## # X residuals vs time @@ -2597,12 +3187,12 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), paxes = plt.subplot(Nrows, Ncols, ind) plt.plot(time, np.zeros(len(time)), 'b-') - plt.plot(time, fitSigX*1e3, 'b--') - plt.plot(time, -fitSigX*1e3, 'b--') + plt.plot(cont_times, xt_cont_err[ii]*1e3, 'b--') + plt.plot(cont_times, -xt_cont_err[ii]*1e3, 'b--') if not color_time: - plt.errorbar(time, (x - fitLineX)*1e3, yerr=xerr.reshape(len(xerr),)*1e3, fmt='k.') + plt.errorbar(rs(time), rs(x - fitLineX)*1e3, yerr=rs(xerr)*1e3, fmt='k.') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, (x - fitLineX)*1e3, xerr*1e3, time_color): @@ -2625,12 +3215,12 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), paxes = plt.subplot(Nrows, Ncols, ind) plt.plot(time, np.zeros(len(time)), 'b-') - plt.plot(time, fitSigY*1e3, 'b--') - plt.plot(time, -fitSigY*1e3, 'b--') + plt.plot(cont_times, yt_cont_err[ii]*1e3, 'b--') + plt.plot(cont_times, -yt_cont_err[ii]*1e3, 'b--') if not color_time: - plt.errorbar(time, (y - fitLineY)*1e3, yerr=yerr.reshape(len(yerr),)*1e3, fmt='k.') + plt.errorbar(rs(time), rs(y - fitLineY)*1e3, yerr=rs(yerr)*1e3, fmt='k.') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, (y - fitLineY)*1e3, yerr*1e3, time_color): @@ -2656,9 +3246,9 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), plt.plot(time, fitSigM*1e3, 'g--') plt.plot(time, -fitSigM*1e3, 'g--') if not color_time: - plt.errorbar(time, (m - fitLineM), yerr=merr.reshape(len(merr),), fmt='k.') + plt.errorbar(rs(time), rs(m - fitLineM), yerr=rs(merr), fmt='k.') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, (m - fitLineM), merr, time_color): @@ -2683,13 +3273,13 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), paxes = plt.subplot(Nrows, Ncols, ind) if not color_time: - plt.errorbar(x,y, xerr=xerr.reshape(len(xerr),), - yerr=yerr.reshape(len(yerr),), fmt='k.') + plt.errorbar(rs(x),rs(y), xerr=rs(xerr), + yerr=rs(yerr), fmt='k.') else: sc = plt.scatter(x, y, s=0, c=dtime, vmin=0, vmax=1, cmap='hsv') clb = plt.colorbar(sc) clb.ax.tick_params(labelsize=fontsize1) - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, eexx, eeyy, color in zip(x, y, xerr, yerr, time_color): @@ -2701,7 +3291,7 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), paxes.xaxis.set_major_formatter(FormatStrFormatter('%.3f')) plt.xlabel('X (asec)', fontsize=fontsize1) plt.ylabel('Y (asec)', fontsize=fontsize1) - plt.plot(fitLineX, fitLineY, 'b-') + plt.plot(xt_cont_all[ii], yt_cont_all[ii], 'b-') ########## # X, Y Histogram of Residuals @@ -2712,7 +3302,7 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), bins = np.arange(-7.5, 7.5, 1) paxes = plt.subplot(Nrows, Ncols, ind) id = np.where(diffY < 0)[0] - sig[id] = -1.*sig[id] + sig[id] = -1.*sig[id] (n, b, p) = plt.hist(sigX, bins, histtype='stepfilled', color='b', label='X') plt.setp(p, 'facecolor', 'b') (n, b, p) = plt.hist(sigY, bins, histtype='step', color='r', label='Y') @@ -2738,26 +3328,24 @@ def plot_stars(tab, star_names, NcolMax=2, epoch_array = None, figsize=(15,25), plt.xlabel('Residuals (sigma)', fontsize=fontsize1) plt.ylabel('Number of Epochs', fontsize=fontsize1) paxes.tick_params(axis='both', which='major', labelsize=fontsize1) - + if Nstars == 1: - plt.subplots_adjust(wspace=0.4, hspace=0.4, left = 0.15, bottom = 0.1, right=0.9, top=0.9) - # plt.savefig(rootDir+'plots/plotStar_' + starName + '.png') + plt.subplots_adjust(wspace=0.4, hspace=0.4, left = 0.15, bottom = 0.1, right=0.9, top=0.9) + # plt.savefig(rootDir+'plots/plotStar_' + starName + '.png', dpi=300) else: plt.subplots_adjust(wspace=0.6, hspace=0.6, left = 0.08, bottom = 0.05, right=0.95, top=0.90) - # plt.savefig(rootDir+'plots/plotStar_all.png') + # plt.savefig(rootDir+'plots/plotStar_all.png', dpi=300) plt.show() plt.show() return - - def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_list = None, - figsize=(15,25), color_time=False, resTicRng=None): + figsize=(15,25), color_time=False, resTicRng=None, save_name=None, boot_err=False): """ - Plot a set of stars positions, flux and residuals over time. + Plot a set of stars positions, flux and residuals over time. epoch_array : None, array Array of the epoch indicies to plot. If None, plots all epochs. @@ -2767,7 +3355,14 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ """ print( 'Creating residuals plots for star(s):' ) print( star_names ) - + def rs(x): + return x.reshape(len(x)) + + # motion_model_dict = motion_model.validate_motion_model_dict(motion_model_dict, tab, None) + i_all_detected = np.where(~np.any(np.isnan(tab['t']),axis=1))[0][0] + # xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.get_star_positions_at_time(tab['t'][i_all_detected], motion_model_dict, allow_alt_models=True) + xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.infer_positions(tab['t'][i_all_detected]) + Nstars = len(star_names) Ncols = 3 * np.min([Nstars, NcolMax]) if Nstars <= Ncols/3: @@ -2782,45 +3377,56 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ x = tab['x0'] y = tab['y0'] r = np.hypot(x, y) - + # motion_model_dict = motion_model.validate_motion_model_dict(motion_model_dict, tab, None) + i_all_detected = np.where(~np.any(np.isnan(tab['t']),axis=1))[0][0] + cont_times = np.arange(np.min(tab['t'][i_all_detected]), np.max(tab['t'][i_all_detected]), 0.01) + # xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.get_star_positions_at_time(tab['t'][i_all_detected], motion_model_dict, allow_alt_models=True) + xt_mod_all, yt_mod_all, xt_mod_err, yt_mod_err = tab.infer_positions(tab['t'][i_all_detected]) + # xt_cont_all, yt_cont_all, xt_cont_err, yt_cont_err = tab.get_star_positions_at_time(cont_times, motion_model_dict, allow_alt_models=True) + xt_cont_all, yt_cont_all, xt_cont_err, yt_cont_err = tab.infer_positions(cont_times) + for i in range(Nstars): for ea, epoch_array in enumerate(epoch_array_list): color=color_list[ea] starName = star_names[i] - + try: ii = np.where(tab['name'] == starName)[0][0] except IndexError: print("!! %s is not in this list"%starName) continue - + # Ignore the NaNs fnd = np.argwhere(~np.isnan(tab['xe'][ii,:])) - + if epoch_array is not None: fnd = np.intersect1d(fnd, epoch_array) fnd = fnd.reshape(len(fnd),1) - + time = tab['t'][ii, fnd] - dtime = time.data % 1 + dtime = time.data % 1 x = tab['x'][ii, fnd] y = tab['y'][ii, fnd] m = tab['m'][ii, fnd] - - xerr = tab['xe'][ii, fnd] - yerr = tab['ye'][ii, fnd] - merr = tab['me'][ii, fnd] - - dt = tab['t'][ii, fnd] - tab['t0'][ii] - fitLineX = tab['x0'][ii] + (tab['vx'][ii] * dt) - fitLineY = tab['y0'][ii] + (tab['vy'][ii] * dt) - - fitSigX = np.hypot(tab['x0e'][ii], tab['vxe'][ii]*dt) - fitSigY = np.hypot(tab['y0e'][ii], tab['vye'][ii]*dt) - - fitLineM = np.repeat(tab['m0'][ii], len(dt)).reshape(len(dt),1) - fitSigM = np.repeat(tab['m0e'][ii], len(dt)).reshape(len(dt),1) - + + if boot_err: + xerr = np.hypot(tab['xe'][ii, fnd], tab['xe_boot'][ii, fnd]) + yerr = np.hypot(tab['ye'][ii, fnd], tab['ye_boot'][ii, fnd]) + merr = np.hypot(tab['me'][ii, fnd], tab['me_boot'][ii, fnd]) + else: + xerr = tab['xe'][ii, fnd] + yerr = tab['ye'][ii, fnd] + merr = tab['me'][ii, fnd] + + fitLineX = xt_mod_all[ii, fnd] + fitLineY = yt_mod_all[ii, fnd] + + fitSigX = xt_mod_err[ii, fnd] + fitSigY = yt_mod_err[ii, fnd] + + fitLineM = np.repeat(tab['m0'][ii], len(time)).reshape(len(time),1) + fitSigM = np.repeat(tab['m0_err'][ii], len(time)).reshape(len(time),1) + diffX = x - fitLineX diffY = y - fitLineY diffM = m - fitLineM @@ -2830,42 +3436,42 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ sigY = diffY / yerr sigM = diffM / merr sig = diff / rerr - + # Determine if there are points that are more than 4 sigma off idxX = np.where(abs(sigX) > 4) idxY = np.where(abs(sigY) > 4) idxM = np.where(abs(sigM) > 4) idx = np.where(abs(sig) > 4) - + # Calculate chi^2 metrics chi2_x = np.sum(sigX**2) chi2_y = np.sum(sigY**2) chi2_m = np.sum(sigM**2) - + dof = len(x) - 2 dofM = len(m) - 1 - + chi2_red_x = chi2_x / dof chi2_red_y = chi2_y / dof chi2_red_m = chi2_m / dofM - - + + print( 'Star: ', starName ) - print( '\tX Chi^2 = %5.2f (%6.2f for %2d dof)' % + print( '\tX Chi^2 = %5.2f (%6.2f for %2d dof)' % (chi2_red_x, chi2_x, dof)) - print( '\tY Chi^2 = %5.2f (%6.2f for %2d dof)' % + print( '\tY Chi^2 = %5.2f (%6.2f for %2d dof)' % (chi2_red_y, chi2_y, dof)) - print( '\tM Chi^2 = %5.2f (%6.2f for %2d dof)' % + print( '\tM Chi^2 = %5.2f (%6.2f for %2d dof)' % (chi2_red_m, chi2_m, dofM)) - + tmin = time.min() tmax = time.max() - + dateTicLoc = plt.MultipleLocator(3) dateTicRng = [np.floor(tmin), np.ceil(tmax)] dateTics = np.arange(np.floor(tmin), np.ceil(tmax)+0.1) DateTicsLabel = dateTics - + # See if we are using MJD instead. if time[0] > 50000: print('MJD') @@ -2875,12 +3481,12 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ dateTicRng = [tmin-200, tmax+200] dateTics = np.arange(dateTicRng[0], dateTicRng[-1]+500, 1000) DateTicsLabel = dateTics - - + + maxErr = np.array([(diffX-xerr)*1e3, (diffX+xerr)*1e3, (diffY-yerr)*1e3, (diffY+yerr)*1e3]).max() maxErrM = np.array([(diffM - merr), (diffM + merr)]).max() - + if maxErr > 2: maxErr = 2.0 if maxErrM > 1.0: @@ -2888,13 +3494,12 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ if resTicRng == None: resTicRng = [-1.1*maxErr, 1.1*maxErr] resTicRngM = [-1.1*maxErrM, 1.1*maxErrM] - - from matplotlib.ticker import FormatStrFormatter + fmtX = FormatStrFormatter('%5i') fmtY = FormatStrFormatter('%6.3f') fmtM = FormatStrFormatter('%5.2f') fontsize1 = 10 - + ########## # X vs time ########## @@ -2903,18 +3508,19 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ row = 1 else: col = 1 + 3*(i % (Ncols/3)) - row = 1 + 3*(i//(Ncols/3)) - + row = 1 + 3*(i//(Ncols/3)) + ind = int((row-1)*Ncols + col) - + paxes = plt.subplot(Nrows, Ncols, ind) - plt.plot(time, fitLineX, 'b-') - plt.plot(time, fitLineX + fitSigX, 'b--') - plt.plot(time, fitLineX - fitSigX, 'b--') + plt.plot(cont_times, xt_cont_all[ii], 'b-') + plt.plot(cont_times, xt_cont_all[ii] + xt_cont_err[ii], 'b--') + plt.plot(cont_times, xt_cont_all[ii] - xt_cont_err[ii], 'b--') + print(np.shape(xerr.reshape(len(xerr),))) if not color_time: - plt.errorbar(time, x, yerr=xerr.reshape(len(xerr),), marker='.', color=color, ls='none') + plt.errorbar(rs(time), rs(x), yerr=rs(xerr), marker='.', color=color, ls='none') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, x, xerr, time_color): @@ -2931,22 +3537,22 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ paxes.yaxis.set_major_formatter(fmtY) paxes.tick_params(axis='both', which='major', labelsize=fontsize1) plt.annotate(starName, xy=(1.0,1.1), xycoords='axes fraction', fontsize=12, color='red') - - + + ########## # Y vs time ########## col = col + 1 ind = int((row-1)*Ncols + col) - + paxes = plt.subplot(Nrows, Ncols, ind) - plt.plot(time, fitLineY, 'b-') - plt.plot(time, fitLineY + fitSigY, 'b--') - plt.plot(time, fitLineY - fitSigY, 'b--') + plt.plot(cont_times, yt_cont_all[ii], 'b-') + plt.plot(cont_times, yt_cont_all[ii] + yt_cont_err[ii], 'b--') + plt.plot(cont_times, yt_cont_all[ii] - yt_cont_err[ii], 'b--') if not color_time: - plt.errorbar(time, y, yerr=yerr.reshape(len(yerr),), marker='.', color=color, ls='none') + plt.errorbar(rs(time), rs(y), yerr=rs(yerr), marker='.', color=color, ls='none') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, y, yerr, time_color): @@ -2962,21 +3568,21 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ paxes.xaxis.set_major_formatter(fmtX) paxes.yaxis.set_major_formatter(fmtY) paxes.tick_params(axis='both', which='major', labelsize=fontsize1) - + ########## # M vs time ########## col = col + 1 ind = int((row - 1)*Ncols + col) - + paxes = plt.subplot(Nrows, Ncols, ind) plt.plot(time, fitLineM, 'g-') plt.plot(time, fitLineM + fitSigM, 'g--') plt.plot(time, fitLineM - fitSigM, 'g--') if not color_time: - plt.errorbar(time, m, yerr=merr.reshape(len(merr),), marker='.', color=color, ls='none') + plt.errorbar(rs(time), rs(m), yerr=rs(merr), marker='.', color=color, ls='none') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, m, merr, time_color): @@ -2992,23 +3598,23 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ paxes.xaxis.set_major_formatter(fmtX) paxes.yaxis.set_major_formatter(fmtM) paxes.tick_params(axis='both', which='major', labelsize=12) - - + + ########## # X residuals vs time ########## row = row + 1 col = col - 2 ind = int((row-1)*Ncols + col) - + paxes = plt.subplot(Nrows, Ncols, ind) plt.plot(time, np.zeros(len(time)), 'b-') - plt.plot(time, fitSigX*1e3, 'b--') - plt.plot(time, -fitSigX*1e3, 'b--') + plt.plot(cont_times, xt_cont_err[ii]*1e3, 'b--') + plt.plot(cont_times, -xt_cont_err[ii]*1e3, 'b--') if not color_time: - plt.errorbar(time, (x - fitLineX)*1e3, yerr=xerr.reshape(len(xerr),)*1e3, marker='.', color=color, ls='none') + plt.errorbar(rs(time), rs(x - fitLineX)*1e3, yerr=rs(xerr)*1e3, marker='.', color=color, ls='none') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, (x - fitLineX)*1e3, xerr*1e3, time_color): @@ -3022,21 +3628,21 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ plt.ylabel('X Residuals (mas)', fontsize=fontsize1) paxes.xaxis.set_major_formatter(fmtX) paxes.tick_params(axis='both', which='major', labelsize=fontsize1) - + ########## # Y residuals vs time ########## col = col + 1 ind = int((row-1)*Ncols + col) - + paxes = plt.subplot(Nrows, Ncols, ind) plt.plot(time, np.zeros(len(time)), 'b-') - plt.plot(time, fitSigY*1e3, 'b--') - plt.plot(time, -fitSigY*1e3, 'b--') + plt.plot(cont_times, yt_cont_err[ii]*1e3, 'b--') + plt.plot(cont_times, -yt_cont_err[ii]*1e3, 'b--') if not color_time: - plt.errorbar(time, (y - fitLineY)*1e3, yerr=yerr.reshape(len(yerr),)*1e3, marker='.', color=color, ls='none') + plt.errorbar(rs(time), rs(y - fitLineY)*1e3, yerr=rs(yerr)*1e3, marker='.', color=color, ls='none') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, (y - fitLineY)*1e3, yerr*1e3, time_color): @@ -3050,21 +3656,21 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ plt.ylabel('Y Residuals (mas)', fontsize=fontsize1) paxes.xaxis.set_major_formatter(fmtX) paxes.tick_params(axis='both', which='major', labelsize=fontsize1) - + ########## # M residuals vs time ########## col = col + 1 ind = int((row-1)*Ncols + col) - + paxes = plt.subplot(Nrows, Ncols, ind) plt.plot(time, np.zeros(len(time)), 'g-') plt.plot(time, fitSigM*1e3, 'g--') plt.plot(time, -fitSigM*1e3, 'g--') if not color_time: - plt.errorbar(time, (m - fitLineM), yerr=merr.reshape(len(merr),), marker='.', color=color, ls='none') + plt.errorbar(rs(time), rs(m - fitLineM), yerr=rs(merr), marker='.', color=color, ls='none') else: - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, ee, color in zip(time, (m - fitLineM), merr, time_color): @@ -3078,24 +3684,24 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ plt.ylabel('m Residuals (mag)', fontsize=fontsize1) paxes.xaxis.set_major_formatter(fmtX) paxes.tick_params(axis='both', which='major', labelsize=fontsize1) - - + + ########## # X vs. Y ########## row = row + 1 col = col - 2 ind = int((row-1)*Ncols + col) - + paxes = plt.subplot(Nrows, Ncols, ind) if not color_time: - plt.errorbar(x,y, xerr=xerr.reshape(len(xerr),), - yerr=yerr.reshape(len(yerr),), marker='.', color=color, ls='none') + plt.errorbar(rs(x),rs(y), xerr=rs(xerr), + yerr=rs(yerr), marker='.', color=color, ls='none') else: sc = plt.scatter(x, y, s=0, c=dtime, vmin=0, vmax=1, cmap='hsv') clb = plt.colorbar(sc) clb.ax.tick_params(labelsize=fontsize1) - norm = colors.Normalize(vmin=0, vmax=1, clip=True) + norm = mcolors.Normalize(vmin=0, vmax=1, clip=True) mapper = cm.ScalarMappable(norm=norm, cmap='hsv') time_color = np.array([(mapper.to_rgba(v)) for v in dtime]) for xx, yy, eexx, eeyy, color in zip(x, y, xerr, yerr, time_color): @@ -3107,18 +3713,18 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ paxes.xaxis.set_major_formatter(FormatStrFormatter('%.3f')) plt.xlabel('X (asec)', fontsize=fontsize1) plt.ylabel('Y (asec)', fontsize=fontsize1) - plt.plot(fitLineX, fitLineY, 'b-') - + plt.plot(fitLineX, fitLineY, 'b-') + ########## # X, Y Histogram of Residuals ########## col = col + 1 ind = int((row-1)*Ncols + col) - + bins = np.arange(-7.5, 7.5, 1) paxes = plt.subplot(Nrows, Ncols, ind) id = np.where(diffY < 0)[0] - sig[id] = -1.*sig[id] + sig[id] = -1.*sig[id] (n, b, p) = plt.hist(sigX, bins, histtype='stepfilled', color='b', label='X') plt.setp(p, 'facecolor', 'b') (n, b, p) = plt.hist(sigY, bins, histtype='step', color='r', label='Y') @@ -3128,13 +3734,13 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ plt.xlabel('Residuals (sigma)', fontsize=fontsize1) plt.ylabel('Number of Epochs', fontsize=fontsize1) paxes.tick_params(axis='both', which='major', labelsize=fontsize1) - + ########## # M Histogram of Residuals ########## col = col + 1 ind = int((row-1)*Ncols + col) - + bins = np.arange(-7.5, 7.5, 1) paxes = plt.subplot(Nrows, Ncols, ind) (n, b, p) = plt.hist(sigM, bins, histtype='stepfilled', color='g', label='m') @@ -3144,16 +3750,17 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ plt.xlabel('Residuals (sigma)', fontsize=fontsize1) plt.ylabel('Number of Epochs', fontsize=fontsize1) paxes.tick_params(axis='both', which='major', labelsize=fontsize1) - + if Nstars == 1: - plt.subplots_adjust(wspace=0.4, hspace=0.4, left = 0.15, bottom = 0.1, right=0.9, top=0.9) - # plt.savefig(rootDir+'plots/plotStar_' + starName + '.png') + plt.subplots_adjust(wspace=0.4, hspace=0.4, left = 0.15, bottom = 0.1, right=0.9, top=0.9) + # plt.savefig(rootDir+'plots/plotStar_' + starName + '.png', dpi=300) else: plt.subplots_adjust(wspace=0.6, hspace=0.6, left = 0.08, bottom = 0.05, right=0.95, top=0.90) - # plt.savefig(rootDir+'plots/plotStar_all.png') + # plt.savefig(rootDir+'plots/plotStar_all.png', dpi=300) plt.show() - + if save_name is not None: + plt.savefig(save_name + '.png', dpi=300) plt.show() return @@ -3161,18 +3768,18 @@ def plot_stars_nfilt(tab, star_names, NcolMax=2, epoch_array_list = None, color_ def plot_errors_vs_r_m(star_tab, vmax_perr=0.75, vmax_pmerr=0.75): """ - Plot the positional errors and the proper motion errors as a function of radius - and magnitude. The positional an proper motion errors will be the mean in the - two axis (as is used in pick_good_ref_stars()). + Plot the positional errors and the proper motion errors as a function of radius + and magnitude. The positional an proper motion errors will be the mean in the + two axis (as is used in pick_good_ref_stars()). """ r = np.hypot(star_tab['x0'], star_tab['y0']) - p_err = np.mean((star_tab['x0e'], star_tab['y0e']), axis=0) * 1e3 - pm_err = np.mean((star_tab['vxe'], star_tab['vye']), axis=0) * 1e3 + p_err = np.mean((star_tab['x0_err'], star_tab['y0_err']), axis=0) * 1e3 + pm_err = np.mean((star_tab['vx_err'], star_tab['vy_err']), axis=0) * 1e3 plt.figure(figsize=(12, 6)) plt.clf() plt.subplots_adjust(wspace=0.4) - + plt.subplot(1, 2, 1) plt.scatter(star_tab['m0'], r, c=p_err, s=8, vmin=0, vmax=vmax_perr) plt.colorbar(label='Pos Err (mas)') @@ -3184,10 +3791,25 @@ def plot_errors_vs_r_m(star_tab, vmax_perr=0.75, vmax_pmerr=0.75): plt.colorbar(label='PM Err (mas/yr)') plt.xlabel('Mag') plt.ylabel('Radius (")') + plt.tight_layout() + plt.show() return - - + +def plot_plxs(star_tab, target_idx=0): + fig,ax = plt.subplots(nrows=1,ncols=2,figsize=(10,5)) + ax[0].errorbar(star_tab['m0'],star_tab['pi']*1e3, yerr=star_tab['pi_err']*1e3,marker='.',linestyle='none') + if target_idx is not None: + ax[0].errorbar(star_tab['m0'][target_idx],star_tab['pi'][target_idx]*1e3, yerr=star_tab['pi_err'][target_idx]*1e3,marker='*',linestyle='none', color='orange', markersize=10) + ax[0].axhline(0, c='gray') + ax[0].set_ylabel('Plx (mas)') + ax[0].set_xlabel('Mag') + ax[1].hist(star_tab['pi']/star_tab['pi_err'], bins=range(-10,10)) + ax[1].set_ylabel('N stars') + ax[1].set_xlabel('Plx/Plx_err') + plt.tight_layout() + ax[0].set_ylim(-5,5) + def plot_sky(stars_tab, plot_errors=False, center_star=None, range=0.4, xcenter=0, ycenter=0, show_names=False, saveplot=False, @@ -3201,8 +3823,8 @@ def plot_sky(stars_tab, Parameters ---------- stars_tab : flystar.startables.StarTable - The StarTable containining 'x', 'y', 't', 'xe', 'ye', columns etc. - for plotting, where each of these columns is a 2D array of + The StarTable containining 'x', 'y', 't', 'xe', 'ye', columns etc. + for plotting, where each of these columns is a 2D array of [star_index, epoch_index]. @@ -3247,11 +3869,11 @@ def plot_sky(stars_tab, good_t = np.isfinite(stars_tab['t']) epochs = np.unique(stars_tab['t'][good_t]) assert len(epochs) == stars_tab['t'].shape[1] - + yearsInt = np.floor(epochs).astype('int') # Set up a color scheme - cnorm = colors.Normalize(stars_tab['t'][0, :].min(), stars_tab['t'][0, :].max() + 1) + cnorm = mcolors.Normalize(stars_tab['t'][0, :].min(), stars_tab['t'][0, :].max() + 1) cmap = plt.cm.gist_ncar colorList = [] @@ -3259,8 +3881,8 @@ def plot_sky(stars_tab, foo = cnorm(yearsInt[ee]) colorList.append( cmap(cnorm(yearsInt[ee])) ) - py.close(2) - fig = py.figure(2, figsize=(13,10)) + plt.close(2) + fig = plt.figure(2, figsize=(13,10)) previousYear = 0.0 @@ -3275,7 +3897,13 @@ def plot_sky(stars_tab, ye = stars_tab['ye'][:, ee] mag = stars_tab['m'][:, ee] - name_epoch = stars_tab['name_in_list'][:, ee] + # Per-epoch identity. Modern tables carry 'idx_in_list' (a row index + # into the starlist); tables written before that change carry + # 'name_in_list' (the name itself). Either is fine to label with. + if 'idx_in_list' in stars_tab.colnames: + name_epoch = stars_tab['idx_in_list'][:, ee] + else: + name_epoch = stars_tab['name_in_list'][:, ee] if mag_range is None: idx = np.where((x > -1000) & (y > -1000))[0] @@ -3298,13 +3926,13 @@ def plot_sky(stars_tab, label = '_nolegend_' if plot_errors: - (line, foo1, foo2) = py.errorbar(x, y, xerr=xe, yerr=ye, + (line, foo1, foo2) = plt.errorbar(x, y, xerr=xe, yerr=ye, color=colorList[ee], fmt='^', markeredgecolor=colorList[ee], markerfacecolor=colorList[ee], label=label, picker=4) else: - (line, foo1, foo2) = py.errorbar(x, y, xerr=None, yerr=None, + (line, foo1, foo2) = plt.errorbar(x, y, xerr=None, yerr=None, color=colorList[ee], fmt='^', markeredgecolor=colorList[ee], markerfacecolor=colorList[ee], @@ -3322,19 +3950,19 @@ def plot_sky(stars_tab, point_labels[line] = points_info foo = PrintSelected(point_labels, fig, stars_tab, mag_range, manual_print=manual_print) - py.connect('pick_event', foo) + plt.connect('pick_event', foo) xlo = xcenter + (range) xhi = xcenter - (range) ylo = ycenter - (range) yhi = ycenter + (range) - py.axis('equal') - py.axis([xlo, xhi, ylo, yhi]) - py.xlabel('R.A. Offset from Sgr A* (arcsec)') - py.ylabel('Dec. Offset from Sgr A* (arcsec)') + plt.axis('equal') + plt.axis([xlo, xhi, ylo, yhi]) + plt.xlabel('R.A. Offset from Sgr A* (arcsec)') + plt.ylabel('Dec. Offset from Sgr A* (arcsec)') - py.legend(handles=epochs_legend, numpoints=1, loc='lower left', fontsize=12) + plt.legend(handles=epochs_legend, numpoints=1, loc='lower left', fontsize=12) if show_names: xpos = stars_tab['x0'] @@ -3342,20 +3970,20 @@ def plot_sky(stars_tab, goodind = np.where((xpos <= xlo) & (xpos >= xhi) & (ypos >= ylo) & (ypos <= yhi))[0] for ind in goodind: - py.text(xpos[ind], ypos[ind], stars_tab['name'][ind], size=10) + plt.text(xpos[ind], ypos[ind], stars_tab['name'][ind], size=10) if saveplot: - py.show(block=0) + plt.show(block=0) if (center_star != None): - py.savefig('plot_sky_' + center_star + '.png') + plt.savefig('plot_sky_' + center_star + '.png', dpi=300) else: - py.savefig('plot_sky.png') + plt.savefig('plot_sky.png', dpi=300) else: - py.show() + plt.show() return - - + + class PrintSelected(object): def __init__(self, points_info, fig, tab, mag_range, manual_print=False): self.points_info = points_info @@ -3376,9 +4004,9 @@ def __call__(self, event): data = self.points_info[event.artist] if self.manual_print: - fmt = 'align_name="{:s}",epoch={:f},align_mag={:4.2f},align_x={:10.4f},align_xerr={:7.4f},align_y={:10.4f},align_yerr={:7.4f},name_epoch="{:s}"' + fmt = 'align_name="{:s}",epoch={:f},align_mag={:4.2f},align_x={:10.4f},align_xerr={:7.4f},align_y={:10.4f},align_yerr={:7.4f},name_epoch="{}"' else: - fmt = '{:15s} t={:10.6f} m={:5.2f} x={:10.4f} +/- {:7.4f} y={:10.4f} +/- {:7.4f} Epoch name: {:15s}' + fmt = '{:15s} t={:10.6f} m={:5.2f} x={:10.4f} +/- {:7.4f} y={:10.4f} +/- {:7.4f} Epoch name: {:>15}' for ii in indices: print(fmt.format(data['name'][ii], data['year'], data['mag'][ii], @@ -3396,9 +4024,9 @@ def __call__(self, event): data = self.points_info[event.artist] if self.manual_print: - fmt = 'align_name="{:s}",epoch={:f},align_mag={:4.2f},align_x={:10.4f},align_xerr={:7.4f},align_y={:10.4f},align_yerr={:7.4f},name_epoch="{:s}"' + fmt = 'align_name="{:s}",epoch={:f},align_mag={:4.2f},align_x={:10.4f},align_xerr={:7.4f},align_y={:10.4f},align_yerr={:7.4f},name_epoch="{}"' else: - fmt = '{:15s} t={:10.6f} m={:5.2f} x={:10.4f} +/- {:7.4f} y={:10.4f} +/- {:7.4f} Epoch name: {:15s}' + fmt = '{:15s} t={:10.6f} m={:5.2f} x={:10.4f} +/- {:7.4f} y={:10.4f} +/- {:7.4f} Epoch name: {:>15}' ii =indices[0] print(fmt.format(data['name'][ii], data['year'], data['mag'][ii], @@ -3423,3 +4051,140 @@ def __call__(self, event): self.fig.canvas.draw() return + + +def plotly_stars(x, y, m=None, xe=None, ye=None, me=None, star_name=None, custom_dict=None, marker_size=3, color=None, alpha=0.7, symbol='circle', label=None, xlabel='x', ylabel='y', fig=None, figsize=(700, 700), show=None): + """Plot stars with plotly in interactive html format + + Parameters + ---------- + x : array-like + x positions + y : array-like + y positions + m : array-like, optional + magnitude to be added in hover label, by default None + xe : array-like, optional + x errors to be added in hover label, by default None + ye : array-like, optional + y errors to be added in hover label, by default None + me : array-like, optional + magnitude errors to be added in hover label, by default None + star_name : array-like, optional + Star names to be added in hover label, by default None + custom_dict : dict, optional + Extra fields to add to the hover label, as {name: array-like}, one + entry per extra line (e.g. {'chi2_x': chi2_x_arr}). Each array must + have the same length as x/y. By default None + marker_size : int, optional + Size of marker, by default 10 + color : array or str, optional + Color of marker, either a string (e.g., 'red') or rgba array, by default None + alpha : float, optional + Opacity of marker color, by default 0.7 + symbol : str, optional + Marker symbol, by default 'circle' + label : str, optional + Label for the star list, by default None + xlabel : str, optional + Label for the x-axis, by default 'x' + ylabel : str, optional + Label for the y-axis, by default 'y' + fig : plotly.graph_objects.Figure object, optional + Figure if the stars are to be added to an exisiting plot, by default None + figsize : tuple, optional + Figure size, by default (700, 700) + show : bool, optional + Show figure or not. By default: True if fig is None, False if fig is not None, by default None + + Returns + ------- + fig : plotly.graph_objects.Figure, optional + Figure object, by default None. + """ + import plotly.graph_objects as go + x = np.asarray(x) + y = np.asarray(y) + hover_template = 'x: %{x:.3f}
y: %{y:.3f}' + + if isinstance(color, str) and color.startswith('C') and color[1:].isdigit(): + color = mcolors.to_rgba(color, alpha=alpha) + color = f'rgba({color[0]*255}, {color[1]*255}, {color[2]*255}, {color[3]:.2f})' + + customdata = [] + customidx = 0 + + if star_name is not None: + customdata.append(star_name) + hover_template = f'name: %{{customdata[{customidx}]}}
' + hover_template + customidx += 1 + + if label is not None: + hover_template = f'{label}
' + hover_template + + if m is not None: + m = np.asarray(m) + customdata.append(m) + hover_template += f'
m: %{{customdata[{customidx}]:.2f}}' + customidx += 1 + + if xe is not None: + xe = np.asarray(xe) + customdata.append(xe) + hover_template += f'
xe: %{{customdata[{customidx}]:.2e}}' + customidx += 1 + + if ye is not None: + ye = np.asarray(ye) + customdata.append(ye) + hover_template += f'
ye: %{{customdata[{customidx}]:.2e}}' + customidx += 1 + + if me is not None: + me = np.asarray(me) + customdata.append(me) + hover_template += f'
me: %{{customdata[{customidx}]:.2e}}' + customidx += 1 + + if custom_dict is not None: + for key, val in custom_dict.items(): + val = np.asarray(val) + customdata.append(val) + hover_template += f'
{key}: %{{customdata[{customidx}]}}' + customidx += 1 + + if customdata: + customdata = np.column_stack(customdata) + hover_template += '' + + fig_data = go.Scattergl( + x=x, + y=y, + mode='markers', + marker=dict( + size=marker_size, + color=color, + symbol=symbol + ), + customdata=customdata, + hovertemplate=hover_template, + name=label + ) + + if fig is None: + fig = go.Figure(data=[fig_data]) + show = True if show is None else show + else: + fig.add_trace(fig_data) + show = False if show is None else show + + fig.update_layout( + xaxis_title=xlabel, + yaxis_title=ylabel, + xaxis=dict(scaleanchor='y', scaleratio=1), # Ensure equal aspect ratio + width=figsize[0], + height=figsize[1] + ) + if show: + fig.show() + return fig \ No newline at end of file diff --git a/flystar/starlists.py b/flystar/starlists.py index be49458..0dc290f 100644 --- a/flystar/starlists.py +++ b/flystar/starlists.py @@ -1,8 +1,7 @@ +import warnings import numpy as np -from astropy.table import Table, Column, MaskedColumn import astropy.table -import warnings -import pdb +from astropy.table import Table, Column, MaskedColumn try: set @@ -31,7 +30,7 @@ def restrict_by_name(table1, table2): name1 = table1['name'] name2 = table2['name'] - + Name = np.intersect1d(name1, name2) # trim out stars begin with 'star' idx = [] @@ -52,31 +51,29 @@ def restrict_by_area(table1, area, exclude=False): indicies of the stars in table1 that fulfill this criteria. Area and table1 positions must be in consistent units in order for this to work. - Parameters: + Parameters ---------- table1: astropy table Starlist to be restricted. Must have standard column headers (i.e. x, y) - area: 2x2 array [[x1, x2], [y1, y2]] X and Y coordinate range to restric the stars too. Only stars with coordinates with x1 < X < x2 and y1 < Y < y2 will be allowed. We assume the area is given in same units as the table1 positions. i.e., x1 = xmin, x2 = xmax; y1 = ymin, y2 = ymax - - exclude: boolean (default=False) + exclude: boolean (default=False), optional If true, *exclude* the stars that fall within the given area. If false, then only return stars that fall within the given area - - Output: + + Output ------ array of indicies corresponding to stars which are within the designated - area. + area, by default False. """ # Extract star coordinates xpos = table1['x'] ypos = table1['y'] - + # Extract desired coordinate ranges x_range = area[0] y_range = area[1] @@ -89,7 +86,7 @@ def restrict_by_area(table1, area, exclude=False): else: good = np.where( ( (xpos < x_range[0]) | (xpos > x_range[1]) ) & ( (ypos < y_range[0]) | (ypos > y_range[1]) ) ) - + return good[0] def restrict_by_use(label_mat, starlist_mat, idx_label, idx_starlist): @@ -114,7 +111,7 @@ def restrict_by_use(label_mat, starlist_mat, idx_label, idx_starlist): idx_starlist: array of indicies Indicies of the matched stars in the starlist. - + Output: ------- idx_label_f: array of indicies in the label catalog that fulfill the restrict @@ -122,15 +119,15 @@ def restrict_by_use(label_mat, starlist_mat, idx_label, idx_starlist): idx_starlist_f: array of indicies in the starlist that fulfill the restrict condition - - + + label_trim: astropy table label table with only use > 2 stars starlist_trim: astropy table reference table with only stars that correspond to use > 2 stars in the label_mat table. - + """ print( 'Restrict option activated') @@ -151,7 +148,7 @@ def restrict_by_use(label_mat, starlist_mat, idx_label, idx_starlist): print( 'Restrict option activated') print(( 'Keeping {0} of {1} stars'.format(len(idx_restrict), len(label_mat)))) - + return idx_label_f, idx_starlist_f @@ -164,7 +161,7 @@ def read_label(labelFile, prop_to_time=None, flipX=True): Update values in columns of position and velocity - Parameters: + Parameters ---------- labelFile: text file. containing col1: name @@ -180,27 +177,25 @@ def read_label(labelFile, prop_to_time=None, flipX=True): col11: t0 col12: use col13: r0 (arcsec) - - prop_to_time: None or float (default = None) - If float, use velocities to propogate positions to defined time. - - flipX: boolean (default = True) + prop_to_time: None or float (default = None), optional + If float, use velocities to propogate positions to defined time, by default None. + flipX: boolean (default = True), optional If true, multiply the x positions and velocities by -1.0. This is useful when label.dat has +x to the east, while reference starlist has +x to the west. - + #OLD# tref: reference epoch that label.dat is converted to. - Output: + Output ------ labelFile: astropy.table. containing name, m, x0, y0, x0e, y0e, vx, vy, vxe, vye, t0, use, r0, (if prop_to_time: x, y, xe, ye, t) - + x and y is in arcsec, converted to tref epoch, *(-1) so it increases to west - + vx, vy, vxe, vye is converted to arcsec/yr """ @@ -209,12 +204,12 @@ def read_label(labelFile, prop_to_time=None, flipX=True): t_label.rename_column('col2', 'm') t_label.rename_column('col3', 'x0') t_label.rename_column('col4', 'y0') - t_label.rename_column('col5', 'x0e') - t_label.rename_column('col6', 'y0e') + t_label.rename_column('col5', 'x0_err') + t_label.rename_column('col6', 'y0_err') t_label.rename_column('col7', 'vx') t_label.rename_column('col8', 'vy') - t_label.rename_column('col9', 'vxe') - t_label.rename_column('col10','vye') + t_label.rename_column('col9', 'vx_err') + t_label.rename_column('col10','vy_err') t_label.rename_column('col11','t0') t_label.rename_column('col12','use') t_label.rename_column('col13','r0') @@ -222,23 +217,23 @@ def read_label(labelFile, prop_to_time=None, flipX=True): # Convert velocities from mas/yr to arcsec/year # t_label['vx'] *= 0.001 # t_label['vy'] *= 0.001 -# t_label['vxe'] *= 0.001 -# t_label['vye'] *= 0.001 +# t_label['vx_err'] *= 0.001 +# t_label['vy_err'] *= 0.001 t_label['vx'] = t_label['vx'] * 0.001 t_label['vy'] = t_label['vy'] * 0.001 - t_label['vxe'] = t_label['vxe'] * 0.001 - t_label['vye'] = t_label['vye'] * 0.001 + t_label['vx_err'] = t_label['vx_err'] * 0.001 + t_label['vy_err'] = t_label['vy_err'] * 0.001 # propogate to prop_to_time if prop_to_time is given if prop_to_time != None: x0 = t_label['x0'] - x0e = t_label['x0e'] + x0e = t_label['x0_err'] vx = t_label['vx'] - vxe = t_label['vxe'] + vxe = t_label['vx_err'] y0 = t_label['y0'] - y0e = t_label['y0e'] + y0e = t_label['y0_err'] vy = t_label['vy'] - vye = t_label['vye'] + vye = t_label['vy_err'] t0 = t_label['t0'] t_label['x'] = x0 + vx*(prop_to_time - t0) t_label['y'] = y0 + vy*(prop_to_time - t0) @@ -248,7 +243,7 @@ def read_label(labelFile, prop_to_time=None, flipX=True): t_label['y'].format = '.5f' t_label['xe'].format = '.5f' t_label['ye'].format = '.5f' - + # flip the x axis if flipX is True if flipX == True: t_label['x0'] = t_label['x0'] * (-1.0) @@ -267,7 +262,7 @@ def read_label_accel(labelFile, prop_to_time=None, flipX=True): Update values in columns of position and velocity - Parameters: + Parameters ---------- labelFile: text file. containing col1: name @@ -287,27 +282,25 @@ def read_label_accel(labelFile, prop_to_time=None, flipX=True): col11: t0 col12: use col13: r0 (arcsec) - - prop_to_time: None or float (default = None) - If float, use velocities to propogate positions to defined time. - - flipX: boolean (default = True) + prop_to_time: None or float (default = None), optional + If float, use velocities to propogate positions to defined time, by default None. + flipX: boolean (default = True), optional If true, multiply the x positions and velocities by -1.0. This is useful when label.dat has +x to the east, while reference starlist has +x to the west. - + #OLD# tref: reference epoch that label.dat is converted to. - Output: + Output ------ labelFile: astropy.table. containing name, m, x0, y0, x0e, y0e, vx, vy, vxe, vye, t0, use, r0, (if prop_to_time: x, y, xe, ye, t) - + x and y is in arcsec, converted to tref epoch, *(-1) so it increases to west - + vx, vy, vxe, vye is converted to arcsec/yr """ @@ -316,12 +309,12 @@ def read_label_accel(labelFile, prop_to_time=None, flipX=True): t_label.rename_column('col2', 'm') t_label.rename_column('col3', 'x0') t_label.rename_column('col4', 'y0') - t_label.rename_column('col5', 'x0e') - t_label.rename_column('col6', 'y0e') + t_label.rename_column('col5', 'x0_err') + t_label.rename_column('col6', 'y0_err') t_label.rename_column('col7', 'vx') t_label.rename_column('col8', 'vy') - t_label.rename_column('col9', 'vxe') - t_label.rename_column('col10','vye') + t_label.rename_column('col9', 'vx_err') + t_label.rename_column('col10','vy_err') t_label.rename_column('col11', 'ax') t_label.rename_column('col12', 'ay') t_label.rename_column('col13', 'axe') @@ -333,12 +326,12 @@ def read_label_accel(labelFile, prop_to_time=None, flipX=True): # Convert velocities from mas/yr to arcsec/year # t_label['vx'] *= 0.001 # t_label['vy'] *= 0.001 -# t_label['vxe'] *= 0.001 -# t_label['vye'] *= 0.001 +# t_label['vx_err'] *= 0.001 +# t_label['vy_err'] *= 0.001 t_label['vx'] = t_label['vx'] * 0.001 t_label['vy'] = t_label['vy'] * 0.001 - t_label['vxe'] = t_label['vxe'] * 0.001 - t_label['vye'] = t_label['vye'] * 0.001 + t_label['vx_err'] = t_label['vx_err'] * 0.001 + t_label['vy_err'] = t_label['vy_err'] * 0.001 t_label['ax'] = t_label['ax'] * 0.001 t_label['ay'] = t_label['ay'] * 0.001 @@ -348,15 +341,15 @@ def read_label_accel(labelFile, prop_to_time=None, flipX=True): # propogate to prop_to_time if prop_to_time is given if prop_to_time != None: x0 = t_label['x0'] - x0e = t_label['x0e'] + x0e = t_label['x0_err'] vx = t_label['vx'] - vxe = t_label['vxe'] + vxe = t_label['vx_err'] ax = t_label['ax'] axe = t_label['axe'] y0 = t_label['y0'] - y0e = t_label['y0e'] + y0e = t_label['y0_err'] vy = t_label['vy'] - vye = t_label['vye'] + vye = t_label['vy_err'] ay = t_label['ay'] aye = t_label['aye'] t0 = t_label['t0'] @@ -391,41 +384,45 @@ def read_starlist(starlistFile, error=True): Assumes the starlist is the reference, so we have time as t and don't try to propogate positions to a different time. - Parameter: + Parameter --------- starlistFile: text file, containing: + col1: name col2: mag col3: t col4: x (pix) col5: y (pix) if error==True: + col6: xerr col7: yerr col8: SNR col9: corr col10: N_frames col11: flux + else: + col6: ? (left as default) col7: corr col8: N_frames col9: ? (left as default) - + error: boolean (default=True) If true, assumes starlist has error columns. This significantly changes the order of the columns. - - Output: + + Output ------ starlist astropy table. containing: name, m, x, y, xe, ye, t """ - t_ref = Table.read(starlistFile, format='ascii', delimiter='\s') + t_ref = Table.read(starlistFile, format='ascii', delimiter=r'\s') # Check if this already has column names: cols = t_ref.colnames - + if cols[0] != 'col1': t_ref['name'] = t_ref['name'].astype(str) return t_ref @@ -436,7 +433,7 @@ def read_starlist(starlistFile, error=True): t_ref.rename_column(cols[2], 't') t_ref.rename_column(cols[3], 'x') t_ref.rename_column(cols[4], 'y') - + if error==True: t_ref.rename_column(cols[5], 'xe') t_ref.rename_column(cols[6], 'ye') @@ -449,120 +446,123 @@ def read_starlist(starlistFile, error=True): t_ref.rename_column(cols[6], 'corr') t_ref.rename_column(cols[7], 'N_frames') t_ref.rename_column(cols[8], 'flux') - + return t_ref class StarList(Table): - """ - A StarList is an astropy.Table with star catalog from a single image. - - Required table columns (input as keywords): - ------------------------- - name : 1D numpy.array with shape = N_stars - List of names of the stars in the table. + def __init__(self, *args, **kwargs): + """ + A StarList is an astropy.Table with star catalog from a single image. - x : 1D numpy.array with shape = N_stars - Positions of N_stars in the x dimension. + Required table columns (input as keywords) + ------------------------------------------ + name : 1D numpy.array with shape = N_stars + List of names of the stars in the table. - y : 1D numpy.array with shape = N_stars - Positions of N_stars in the y dimension. + x : 1D numpy.array with shape = N_stars + Positions of N_stars in the x dimension. - m : 1D numpy.array with shape = N_stars - Magnitudes of N_stars. + y : 1D numpy.array with shape = N_stars + Positions of N_stars in the y dimension. - Optional table columns (input as keywords): - ------------------------- - xe : 1D numpy.array with shape = N_stars - Position uncertainties of N_stars in the x dimension. + m : 1D numpy.array with shape = N_stars + Magnitudes of N_stars. - ye : 1D numpy.array with shape = N_stars - Position uncertainties of N_stars in the y dimension. + Optional table columns (input as keywords) + ------------------------------------------ + xe : 1D numpy.array with shape = N_stars + Position uncertainties of N_stars in the x dimension. - me : 1D numpy.array with shape = N_stars - Magnitude uncertainties of N_stars. - - corr : 1D numpy.array with shape = N_stars - Fitting correlation of N_stars. + ye : 1D numpy.array with shape = N_stars + Position uncertainties of N_stars in the y dimension. - Optional table meta data - ------------------------- - list_name : str - Name of the starlist. + me : 1D numpy.array with shape = N_stars + Magnitude uncertainties of N_stars. - list_time : int or float - Time/date of the starlist. + corr : 1D numpy.array with shape = N_stars + Fitting correlation of N_stars. + Optional table meta data + ------------------------- + list_name : str + Name of the starlist. - """ - - def __init__(self, *args, **kwargs): - """ + list_time : int or float + Time/date of the starlist. """ # Check if the required arguments are present arg_req = ('name', 'x', 'y', 'm') found_all_required = True - + for arg_test in arg_req: if arg_test not in kwargs: found_all_required = False if not found_all_required: - if not ('copy' in kwargs) | ('names' in kwargs.keys()) | \ - ('masked' in kwargs.keys()): # If it's not making a copy of the - # StarList or replacing columns or selecting from slices + # A single positional Table-like argument (another Table/StarList, + # a dict/OrderedDict of Columns, a list of Columns, etc.) can + # already carry name/x/y/m even though they're not in kwargs -- + # e.g. astropy's Table.__setstate__ reconstructs a pickled + # StarList as self.__init__(columns_dict, meta=meta), which is + # exactly this case. Don't warn then. + has_required_positionally = False + if len(args) == 1: + candidate = args[0] + if hasattr(candidate, 'colnames'): + candidate_names = candidate.colnames + elif hasattr(candidate, 'keys'): + candidate_names = list(candidate.keys()) + elif isinstance(candidate, (list, tuple)) and all(hasattr(c, 'name') for c in candidate): + candidate_names = [c.name for c in candidate] + else: + candidate_names = [] + has_required_positionally = all(a in candidate_names for a in arg_req) + + if not has_required_positionally and not any(key in kwargs for key in ['copy', 'names', 'masked']): + # If it's not making a copy of the StarList or replacing columns or selecting from slices err_msg = "The StarList class requires a arguments" + str(arg_req) warnings.warn(err_msg, UserWarning) - Table.__init__(self, *args, **kwargs) + super().__init__(*args, **kwargs) else: # If we have errors, we need them in both dimensions. if ('xe' in kwargs) ^ ('ye' in kwargs): - raise TypeError("The StarList class requires both 'xe' and" + - " 'ye' arguments") + raise TypeError("The StarList class requires both 'xe' and 'ye' arguments") # Figure out the shape + kwargs['x'] = np.array(kwargs['x']) n_stars = kwargs['x'].shape[0] # Check if the type and size of the arguments are correct. # Name checking: type and shape + kwargs['name'] = np.array(kwargs['name']) if (not isinstance(kwargs['name'], np.ndarray)) or ( len(kwargs['name']) != n_stars): - err_msg = "The '{0:s}' argument has to be a numpy array " - err_msg += "with length = {1:d}" - raise TypeError(err_msg.format('name', n_stars)) + raise TypeError(f"The 'name' argument has to be a numpy array with length {n_stars}, but has type {type(kwargs['name'])} and length {len(kwargs['name'])}") # Check all the arrays. arg_tab = ('x', 'y', 'm', 'xe', 'ye', 'me', 'corr') + #print(kwargs) + for arg_test in arg_tab: if arg_test in kwargs: - if not isinstance(kwargs[arg_test], np.ndarray): - err_msg = "The '{0:s}' argument has to be a numpy array" - raise TypeError(err_msg.format(arg_test)) - + kwargs[arg_test] = np.array(kwargs[arg_test]) if kwargs[arg_test].shape != (n_stars,): - err_msg = "The '{0:s}' argument has to have shape = ({1:d},)" - raise TypeError(err_msg.format(arg_test, n_stars)) + raise ValueError(f"The '{arg_test:s}' argument has to match the shape of x ({n_stars:d},), but has shape {kwargs[arg_test].shape}") # We have to have special handling of meta-data meta_tab = ('list_time', 'list_name') meta_type = ((float, int), str) - for mm in range(len(meta_tab)): - meta_test = meta_tab[mm] - meta_type_test = meta_type[mm] - - if meta_test in kwargs: - - if not isinstance(kwargs[meta_test], meta_type_test): - err_msg = "The '{0:s}' argument has to be a {1:s}." - raise TypeError( - err_msg.format(meta_test, str(meta_type_test))) + for mtab, mtype in zip(meta_tab, meta_type): + if (mtab in kwargs) and (not isinstance(kwargs[mtab], mtype)): + raise TypeError(f"The '{mtab:s}' argument has to be a {mtype:s}, but has type {type(kwargs[mtab])}") ##### # Create the starlist ##### - Table.__init__(self, + super().__init__( (kwargs['name'], kwargs['x'], kwargs['y'], kwargs['m']), names=('name', 'x', 'y', 'm')) self.meta = {'n_stars': n_stars} @@ -575,13 +575,53 @@ def __init__(self, *args, **kwargs): if arg in ['name', 'x', 'y', 'm']: continue if arg in kwargs: - # 2022-08-25: Need to explicitly add MaskedColumn if - # data is masked + # 2022-08-25: Need to explicitly add MaskedColumn if data is masked if isinstance(kwargs[arg], MaskedColumn): self.add_column(MaskedColumn(data=kwargs[arg], name=arg)) else: self.add_column(Column(data=kwargs[arg], name=arg)) - + + # Any remaining keyword is treated as an extra column rather than + # being dropped. A caller building, say, a Linear-model reference + # list naturally writes + # StarList(name=.., x=.., y=.., m=.., vx=.., vy=.., t0=..) + # and silently discarding vx/vy/t0 loses real data with no error -- + # the resulting list simply has no velocities, which then shows up + # much later as a reference that refuses to move (see + # align.MosaicToRef propagation). Extra columns must still be 1D + # and one entry per star, so a typo'd or wrongly-shaped argument + # fails loudly here instead of becoming a bogus column. + reserved = ('meta', 'copy', 'masked', 'names', 'dtype', 'rows', + 'units', 'descriptions') + handled = set(arg_req) | set(arg_tab) | set(meta_tab) | set(reserved) + for arg in kwargs: + if arg in handled: + continue + value = kwargs[arg] + # MaskedColumn subclasses np.ma.MaskedArray, so testing the + # base class keeps a plain np.ma.masked_array's mask too + # (np.asarray would silently strip it). + is_masked = isinstance(value, np.ma.MaskedArray) + col_data = value if is_masked else np.asarray(value) + if col_data.shape != (n_stars,): + raise ValueError( + f"The '{arg}' argument has to match the shape of x " + f"({n_stars:d},), but has shape {col_data.shape}. " + f"StarList treats any unrecognized keyword as an " + f"extra column, so this must be a 1D array with one " + f"entry per star." + ) + if is_masked: + self.add_column(MaskedColumn(data=col_data, name=arg)) + else: + self.add_column(Column(data=col_data, name=arg)) + + # 'meta' is the one reserved keyword worth honoring here: this + # branch builds the table itself and then overwrites self.meta, so + # a caller-supplied meta would otherwise vanish too. + if 'meta' in kwargs and kwargs['meta']: + self.meta.update(dict(kwargs['meta'])) + return @classmethod @@ -591,22 +631,26 @@ def from_lis_file(cls, filename, error=True, fvu_file=None): Assumes the starlist is the reference, so we have time as t and don't try to propogate positions to a different time. - Parameter: + Parameter --------- starlistFile: text file, containing: + col1: name col2: mag (name=m) col3: t col4: x (pix) col5: y (pix) if error==True: + col6: xerr (name=xe) col7: yerr (name=ye) col8: SNR (name=snr) col9: corr col10: N_frames col11: flux + else: + col6: ? (left as default) col7: corr col8: N_frames @@ -618,11 +662,11 @@ def from_lis_file(cls, filename, error=True, fvu_file=None): If true, assumes starlist has error columns. This significantly changes the order of the columns. - Output: + Output ------ starlists.StarList() object (subclass of Astropy Table). """ - t_ref = Table.read(filename, format='ascii', delimiter='\s') + t_ref = Table.read(filename, format='ascii', delimiter=r'\s') # Check if this already has column names: cols = t_ref.colnames @@ -639,7 +683,7 @@ def from_lis_file(cls, filename, error=True, fvu_file=None): t_ref.rename_column(cols[2], 't') t_ref.rename_column(cols[3], 'x') t_ref.rename_column(cols[4], 'y') - + if error==True: t_ref.rename_column(cols[5], 'xe') t_ref.rename_column(cols[6], 'ye') @@ -652,7 +696,7 @@ def from_lis_file(cls, filename, error=True, fvu_file=None): t_ref.rename_column(cols[6], 'corr') t_ref.rename_column(cols[7], 'N_frames') t_ref.rename_column(cols[8], 'flux') - + if ('me' not in cols) and ('snr' in cols) and (error == True): t_ref['me'] = 1.0 / t_ref['snr'] @@ -665,16 +709,16 @@ def from_lis_file(cls, filename, error=True, fvu_file=None): msg = 'Star list and metric list have different lengths.\n' msg += '\t len(stars) = {0:d}\n' msg += '\t len(fvu) = {1:d}\n' - + raise RuntimeError(msg.format(len(t_ref), len(t_fvu))) - - t_ref = astropy.table.hstack([t_ref, t_fvu]) + + t_ref = astropy.table.hstack([t_ref, t_fvu]) return cls.from_table(t_ref) def to_lis_file(self, filename): _out = open(filename, 'w') - + hdr = '{name:13s} {mag:>6s} {year:>8s} ' hdr += '{x:>9s} {y:>9s} {xe:>9s} {ye:>9s} ' hdr += '{snr:>20s} {corr:>6s} {nimg:>8s} {flux:>20s}\n' @@ -682,7 +726,7 @@ def to_lis_file(self, filename): _out.write(hdr.format(name='# name', mag='m', year='t', x='x', y='y', xe='xe', ye='ye', snr='snr', corr='corr', nimg='N_frames', flux='flux')) - + fmt = '{name:13s} {mag:6.3f} {year:8.3f} ' fmt += '{x:9.3f} {y:9.3f} {xe:9.3f} {ye:9.3f} ' @@ -695,10 +739,10 @@ def to_lis_file(self, filename): flux=self['flux'][ss])) _out.close() - + return - - + + @classmethod def from_table(cls, table): """ @@ -707,7 +751,7 @@ def from_table(cls, table): will be added to the new StarList object that is returned. """ starlist = cls(name=table['name'], x=table['x'], y=table['y'], m=table['m'], meta=table.meta) - + for col in table.colnames: if col in ['name', 'x', 'y', 'm']: continue @@ -719,10 +763,10 @@ def from_table(cls, table): def fubar(self): print('This is in StarList') return - + def restrict_by_value(self, **kwargs): """ - Restrict a table to any min/max range of column values. For instance, + Restrict a table to any min/max range of column values. For instance, to restrict to only stars between 10 <= m <= 15, use: starlist.restrict_by_value(m_min=10, m_max=15) @@ -730,31 +774,31 @@ def restrict_by_value(self, **kwargs): where 'm' was the column name. This function acts on self, so the rows are removed - forever. + forever. """ # Loop through all conditions and build up - # an array of indicies of rows to remove. + # an array of indicies of rows to remove. remove_flag = np.zeros(len(self), dtype=bool) - - for kwarg in kwargs: - if kwargs[kwarg] is not None: + + for key, value in kwargs.items(): + if value is not None: # Get the name of the column to act on and # whether the condition is min or max. - kwarg_split = kwarg.split('_') + key_split = key.split('_') - # Support column names such as x_0. - col = '_'.join(kwarg_split[:-1]) + # Support column names such as x_0. + col = '_'.join(key_split[:-1]) - if kwarg_split[-1] == 'min': - remove_flag = np.logical_or(remove_flag, self[col] <= kwargs[kwarg]) - - if kwarg_split[-1] == 'max': - remove_flag = np.logical_or(remove_flag, self[col] >= kwargs[kwarg]) + if key_split[-1] == 'min': + remove_flag = np.logical_or(remove_flag, self[col] <= value) + + if key_split[-1] == 'max': + remove_flag = np.logical_or(remove_flag, self[col] >= value) rem_idx = np.where(remove_flag == True)[0] - + self.remove_rows(rem_idx) - + return def transform_xym(self, trans): @@ -767,7 +811,7 @@ def transform_xym(self, trans): self.transform_xy(trans) self.transform_m(trans) - + return def transform_xy(self, trans): @@ -779,7 +823,7 @@ def transform_xy(self, trans): """ if trans == None: return - + x_T, y_T = trans.evaluate(self['x'], self['y']) self['x'] = x_T self['y'] = y_T @@ -790,7 +834,7 @@ def transform_xy(self, trans): self['ye'] = ye_T return - + def transform_m(self, trans): """ Apply a transformation (instance of flystar.transforms.Transform2D) @@ -800,17 +844,78 @@ def transform_m(self, trans): """ if trans == None: return - + m_T = trans.evaluate_mag(self['m']) self['m'] = m_T if 'me' in self.colnames: me_T = trans.evaluate_magerror(self['m'], self['me']) self['me'] = me_T - + return + +def write_region(x, y, save_path, frame='image', colors='magenta', shape='circle', shape_properties={'radius': 10}): + """ + Write a DS9 region file with the given x, y coordinates. + + Parameters + ---------- + x: 1D numpy.array + X coordinates of the stars to write to the region file. + y: 1D numpy.array + Y coordinates of the stars to write to the region file. + frame: str, optional + Frame of reference for the coordinates. Default is 'image'. Other options include 'fk5', 'icrs', 'galactic', 'wcs', etc. + See https://ds9.si.edu/doc/ref/region.html for more details. + save_path: str + Path to the file where the region file will be saved. + colors: str or list of str, optional + Color(s) of the regions. If a single string is given, all regions will be that color. + If a list of strings is given, it must have the same length as x and y, by default 'magenta'. + shape: str, optional + Shape of the regions. Default is 'circle'. Other options include 'box', 'ellipse', etc. + shape_properties: dict, optional + Dictionary of properties for the shape. For example, for circles, you can specify {'radius': 10}. + For boxes, you can specify {'width': 20, 'height': 10}. + + Output + ------ + A DS9 region file will be created at the specified save_path, by default {'radius': 10}. + """ + if isinstance(colors, str): + colors = [colors] * len(x) + + if shape == 'circle': + radius = shape_properties.get('radius', 1) + write_format = f'circle {{x}} {{y}} {radius} # color={{color}}\n' + elif shape == 'box': + width = shape_properties.get('width', 3) + height = shape_properties.get('height', 3) + angle = shape_properties.get('angle', 0) + write_format = f'box {{x}} {{y}} {width} {height} {angle} # color={{color}}\n' + elif shape == 'ellipse': + semimajor = shape_properties.get('semi-major', 6) + semiminor = shape_properties.get('semi-minor', 3) + angle = shape_properties.get('angle', 0) + write_format = f'ellipse {{x}} {{y}} {semimajor} {semiminor} {angle} # color={{color}}\n' + elif shape == 'point': + point = shape_properties.get('point', 'circle') + size = shape_properties.get('size', 3) + write_format = f'point {{x}} {{y}} # point={point} {size} color={{color}}\n' + else: + raise ValueError(f"Unsupported shape: {shape}") + + with open(save_path, 'w') as f: + f.write('# Region file format: DS9 version 4.1\n') + f.write('global color=green dashlist=8 3 width=1 font="helvetica 10 normal" select=1 highlite=1 dash=0 fixed=0 edit=1 move=1 delete=1 include=1 source=1\n') + f.write(f'{frame}\n') + for i in range(len(x)): + f.write(write_format.format(x=x[i], y=y[i], color=colors[i])) + + return + def write_starlist(list, outfile): formats = {'name': '%-13s', @@ -834,4 +939,3 @@ def write_starlist(list, outfile): return outfile - diff --git a/flystar/startables.py b/flystar/startables.py index 84953a9..145a854 100644 --- a/flystar/startables.py +++ b/flystar/startables.py @@ -1,85 +1,104 @@ -from astropy.table import Table, Column, hstack -from astropy.stats import sigma_clipping -from scipy.optimize import curve_fit -from flystar.fit_velocity import linear_fit, calc_chi2, linear, fit_velocity -from tqdm import tqdm -import numpy as np -import warnings -import collections import pdb -import time import copy - +import warnings +import numpy as np +from tqdm import tqdm +from multiprocessing import Pool +from astropy.time import Time +from astropy.stats import sigma_clip +from astropy.table import Table, Column +from pandas.api.types import is_string_dtype +from collections.abc import Iterable +from flystar import motion_model + +# Widest motion-model name that exists, so motion_model_input/motion_model_used +# columns can hold any of them (and be written in place) without a fixed U20. +# Derived rather than hard-coded so a newly added model widens the column. +_MOTION_MODEL_NAME_WIDTH = max( + (len(_n) for _n in motion_model.motion_model_map()), default=20 +) class StarTable(Table): - """ - A StarTable is an astropy.Table with stars matched from multiple starlists. - - Required table columns (input as keywords): - ------------------------- - name : 1D numpy.array with shape = N_stars - List of unique names for each of the stars in the table. - - x : 2D numpy.array with shape = (N_stars, N_lists) - Positions of N_stars in each of N_lists in the x dimension. - - y : 2D numpy.array with shape = (N_stars, N_lists) - Positions of N_stars in each of N_lists in the y dimension. - - m : 2D numpy.array with shape = (N_stars, N_lists) - Magnitudes of N_stars in each of N_lists. - - Optional table columns (input as keywords): - ------------------------- - xe : 2D numpy.array with shape = (N_stars, N_lists) - Position uncertainties of N_stars in each of N_lists in the x dimension. - - ye : 2D numpy.array with shape = (N_stars, N_lists) - Position uncertainties of N_stars in each of N_lists in the y dimension. - - me : 2D numpy.array with shape = (N_stars, N_lists) - Magnitude uncertainties of N_stars in each of N_lists. - - ep_name : 2D numpy.array with shape = (N_stars, N_lists) - Names in each epoch for each of N_stars in each of N_lists. This is - useful for tracking purposes. - - corr : 2D numpy.array with shape = (N_stars, N_lists) - Fitting correlation for each of N_stars in each of N_lists. - - Optional table meta data - ------------------------- - list_names : list of strings - List of names, one for each of the starlists. - - list_times : list of integers or floats - List of times/dates for each starlist. - - ref_list : int - Specify which list is the reference list (if any). - - - Examples - -------------------------- - - t = startables.StarTable(name=name, x=x, y=y, m=m) - - # Access the data: - print(t) - print(t['name'][0:10]) # print the first 10 star names - print(t['x'][0:10, 0]) # print x from the first epoch/list/column for the first 10 stars - """ - def __init__(self, *args, ref_list=0, **kwargs): + def __init__(self, *args, ref_list=0, copy=True, **kwargs): """ + A StarTable is an astropy.Table with stars matched from multiple starlists. + + Required table columns (input as keywords) + ------------------------------------------ + name : 1D numpy.array with shape = N_stars + List of unique names for each of the stars in the table. + + x : 2D numpy.array with shape = (N_stars, N_lists) + Positions of N_stars in each of N_lists in the x dimension. + + y : 2D numpy.array with shape = (N_stars, N_lists) + Positions of N_stars in each of N_lists in the y dimension. + + m : 2D numpy.array with shape = (N_stars, N_lists) + Magnitudes of N_stars in each of N_lists. + + Optional table columns (input as keywords) + ------------------------------------------ + motion_model : 1D numpy.array with shape = N_stars + string indicating motion model type for each star + + xe : 2D numpy.array with shape = (N_stars, N_lists) + Position uncertainties of N_stars in each of N_lists in the x dimension. + + ye : 2D numpy.array with shape = (N_stars, N_lists) + Position uncertainties of N_stars in each of N_lists in the y dimension. + + me : 2D numpy.array with shape = (N_stars, N_lists) + Magnitude uncertainties of N_stars in each of N_lists. + + ep_name : 2D numpy.array with shape = (N_stars, N_lists) + Names in each epoch for each of N_stars in each of N_lists. This is + useful for tracking purposes. + + corr : 2D numpy.array with shape = (N_stars, N_lists) + Fitting correlation for each of N_stars in each of N_lists. + + Optional table meta data + ------------------------- + list_names : list of strings + List of names, one for each of the starlists. + + list_times : list of integers or floats + List of times/dates for each starlist, as decimal years in the + UTC scale (e.g. 2025.0 for the start of 2025) -- i.e. an + observation timestamp expressed as a fraction of a year. Models + that need a uniform timescale convert internally; Parallax, for + instance, converts UTC -> TDB before evaluating the ephemeris. + + ref_list : int + Specify which list is the reference list (if any). + + copy : bool, optional + If True (default), the table makes its own independent copy of + every input array -- safe if the caller might mutate their + arrays afterward. If False, arrays that are already a + compatible ndarray are used directly without copying (they're + still converted/copied if genuinely necessary, e.g. from a + list or an incompatible dtype) -- only pass False when you + know the caller won't touch these arrays again (e.g. they were + just freshly built and not stored anywhere else), since the + table's data would otherwise alias and mutating one would + silently mutate the other. + + Examples + -------------------------- + + t = startables.StarTable(name=name, x=x, y=y, m=m) + + # Access the data: + print(t) + print(t['name'][0:10]) # print the first 10 star names + print(t['x'][0:10, 0]) # print x from the first epoch/list/column for the first 10 stars """ - + # Check if the required arguments are present arg_req = ('name', 'x', 'y', 'm') - - found_all_required = True - for arg_test in arg_req: - if arg_test not in kwargs: - found_all_required = False + found_all_required = all(arg in kwargs for arg in arg_req) if not found_all_required: if len(args) > 1: # If there are no arguments, it's because the @@ -88,12 +107,26 @@ def __init__(self, *args, ref_list=0, **kwargs): # columns selected err_msg = "The StarTable class requires arguments: " + str(arg_req) warnings.warn(err_msg, UserWarning) - Table.__init__(self, *args, **kwargs) + Table.__init__(self, *args, copy=copy, **kwargs) else: # If we have errors, we need them in both dimensions. if ('xe' in kwargs) ^ ('ye' in kwargs): raise TypeError("The StarTable class requires both 'xe' and" + " 'ye' arguments") + # np.array(..., copy=True) (the default) always copies; np.asarray + # only converts/copies when actually necessary (e.g. a list, or an + # incompatible dtype) -- pass a caller-owned, already-correct + # ndarray straight through with copy=False. + array_convert = np.array if copy else np.asarray + kwargs['name'] = array_convert(kwargs['name']) + kwargs['x'] = array_convert(kwargs['x']) + kwargs['y'] = array_convert(kwargs['y']) + kwargs['m'] = array_convert(kwargs['m']) + if ('xe' in kwargs) and ('ye' in kwargs): + kwargs['xe'] = array_convert(kwargs['xe']) + kwargs['ye'] = array_convert(kwargs['ye']) + if 'me' in kwargs: + kwargs['me'] = array_convert(kwargs['me']) # Figure out the shape n_stars = kwargs['x'].shape[0] @@ -101,93 +134,154 @@ def __init__(self, *args, ref_list=0, **kwargs): # Check if the type and size of the arguments are correct. # Name checking: type and shape - if (not isinstance(kwargs['name'], np.ndarray)) or (len(kwargs['name']) != n_stars): - err_msg = "The '{0:s}' argument has to be a numpy array " - err_msg += "with length = {1:d}" - raise TypeError(err_msg.format('name', n_stars)) + if len(kwargs['name']) != n_stars: + err_msg += f"The 'name' argument length should be {n_stars}, but got {len(kwargs['name'])}." + raise TypeError(err_msg) # Check all the 2D arrays. - arg_tab = ('x', 'y', 'm', 'xe', 'ye', 'me', 'name_in_list') + arg_tab = ('x', 'y', 'm', 'xe', 'ye', 'me', 'idx_in_list') for arg_test in arg_tab: if arg_test in kwargs: if not isinstance(kwargs[arg_test], np.ndarray): - err_msg = "The '{0:s}' argument has to be a numpy array" - raise TypeError(err_msg.format(arg_test)) + err_msg = f"The '{arg_test}' argument has to be a numpy array, not {type(kwargs[arg_test])}" + raise TypeError(err_msg) if kwargs[arg_test].shape != (n_stars, n_lists): - err_msg = "The '{0:s}' argument has to have shape = ({1:d}, {2:d})" - raise TypeError(err_msg.format(arg_test, n_stars, n_lists)) + err_msg = f"The '{arg_test}' argument has to have shape = ({n_stars}, {n_lists}), but got {kwargs[arg_test].shape}" + raise TypeError(err_msg) # Check that the reference list is specified. if ref_list not in range(n_lists): - err_msg = "The 'ref_list' argument has to be an integer between 0 and {0:d}" - raise IndexError(err_msg.format(n_lists-1)) + err_msg = f"The 'ref_list' argument has to be an integer between 0 and {n_lists-1}" + raise IndexError(err_msg) # We have to have special handling of meta-data (i.e. info that has # dimensions of n_lists). - meta_tab = ('LIST_TIMES', 'LIST_NAMES') + meta_tab = ('list_times', 'list_names') meta_type = ((float, int), str) - for mm in range(len(meta_tab)): - meta_test = meta_tab[mm] - meta_type_test = meta_type[mm] - - if meta_test in kwargs: - if len(kwargs[meta_test]) != n_lists: - err_msg = "The '{0:s}' argument has to have length = {1:d}" - raise ValueError(err_msg.format(meta_test, n_lists)) - - if not all(isinstance(tt, meta_type_test) for tt in kwargs[meta_test]): - err_msg = "The '{0:s}' argument has to be a list of {1:s}." - raise TypeError(err_msg.format(meta_test, str(meta_type_test))) + for mtab, mtype in zip(meta_tab, meta_type): + if mtab in kwargs: + kwargs[mtab] = list(kwargs[mtab]) # Convert to list, as astropy.Table doesn't like numpy arrays in meta-data. + if len(kwargs[mtab]) != n_lists: + raise ValueError(f"The '{mtab}' argument has to have length = {n_lists}") + if not all(isinstance(tt, mtype) for tt in kwargs[mtab]): + raise TypeError(f"The '{mtab}' argument has to be a list of {str(mtype)}.") ##### # Create the startable ##### - Table.__init__(self, (kwargs['name'], kwargs['x'], kwargs['y'], kwargs['m']), - names=('name', 'x', 'y', 'm')) - self['name'] = self['name'].astype('U20') - self.meta = {'n_stars': n_stars, 'n_lists': n_lists, 'ref_list': ref_list} - + # Pull the special meta-data args out of kwargs first, so the + # column-building loop below doesn't see them. + meta_updates = {} for meta_arg in meta_tab: if meta_arg in kwargs: - self.meta[meta_arg] = kwargs[meta_arg] - del kwargs[meta_arg] - + meta_updates[meta_arg] = kwargs.pop(meta_arg) + elif meta_arg.upper() in kwargs: + meta_updates[meta_arg] = kwargs.pop(meta_arg.upper()) + + # Build every column's (name, data) pair upfront and construct + # the whole table in a single call, instead of constructing the + # 4 required columns and then add_column()-ing the rest one at + # a time. add_column() is dramatically more expensive per call + # than passing every column to the constructor together + # (confirmed empirically: ~1.2s and +4.6GB for ~29 columns + # built via a loop of add_column() calls at ~1.4M rows, vs + # ~0.001s and ~0GB for the exact same columns passed to the + # constructor at once) -- almost certainly because add_column() + # re-validates/re-indexes the whole table on every single call. + all_col_names = ['name', 'x', 'y', 'm'] + all_col_data = [kwargs['name'], kwargs['x'], kwargs['y'], kwargs['m']] for arg in kwargs: - if arg in ['name', 'x', 'y', 'm']: + if arg in ('name', 'x', 'y', 'm'): continue - else: - self.add_column(Column(data=kwargs[arg], name=arg)) - if arg == 'name_in_list': - self['name_in_list'] = self['name_in_list'].astype('U20') + data = kwargs[arg] + if arg in ('motion_model_input', 'motion_model_used'): + # Wide enough for every motion-model name that exists, so + # the in-place writes elsewhere (e.g. fit_motion_models) + # can never truncate -- but not the flat U20 this used to + # be. At 80 bytes per row, re-paid on every rebuild of the + # table, that was several MB of a large mosaic's reference + # table spent on strings of at most 12 characters. + data = np.asarray(data).astype(f'U{_MOTION_MODEL_NAME_WIDTH}', + copy=copy) + all_col_names.append(arg) + all_col_data.append(data) + + super().__init__(tuple(all_col_data), names=tuple(all_col_names), copy=copy) + # Names get written in place elsewhere (analysis.py assigns label + # names into an existing column), so keep the historical U30 of + # headroom -- but widen, rather than truncate, when the incoming + # names are longer than that. Casting straight to U30 silently + # cut off anything longer, which real catalogue identifiers + # (Gaia source ids and the like) routinely are. + name_width = max(30, self['name'].dtype.itemsize // np.dtype('U1').itemsize) + if self['name'].dtype.kind != 'U' or name_width > 30: + self['name'] = self['name'].astype(f'U{name_width}') + elif self['name'].dtype.itemsize != np.dtype('U30').itemsize: + self['name'] = self['name'].astype('U30') + self.meta = {'n_stars': n_stars, 'n_lists': n_lists, 'ref_list': ref_list} + self.meta.update(meta_updates) + #if 'motion_model_input' not in kwargs: + # self['motion_model_input'] = np.repeat(self.default_motion_model, len(self['name'])) return - - def add_starlist(self, **kwargs): + + @classmethod + def read(cls, *args, **kwargs): """ - Add data from a new list to an existing StarTable. + Table.read, with string columns handed back as str rather than bytes. + + 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, which turns a + round-trip through disk into silently different behavior -- a + `motion_model_used == 'Parallax'` test that works on a freshly fit + table stops matching anything once the table has been saved and + reloaded, without raising. Normalizing on read keeps a saved table + behaving like the one that was written. + """ + tab = super().read(*args, **kwargs) + if isinstance(tab, Table): + tab.convert_bytestring_to_unicode() + return tab + + def add_starlist(self, warn_missing_meta=True, **kwargs): + """ + Add data from a new list to an existing StarTable. Note, you can pass in the data via a StarList object or - via a series of keywords with a 1D array on each. + via a series of keywords with a 1D array on each. In either case, the number of stars must already match the existing number of stars in the StarTable. - Example 1: Pass in data via StarList object. - ---------- - print(t['x'].shape) - t.add_starlist(starlist=my_list) - print(t['x'].shape) # Should be 1 column larger than before. + Examples + -------- + Pass in data via a StarList object:: + + print(t['x'].shape) + t.add_starlist(starlist=my_list) + print(t['x'].shape) # Should be 1 column larger than before. - Example 2: Pass in data via keywords and 1D arrays. - t.add_starlist(x=x_new, y=y_new, m=m_new) + Pass in data via keywords and 1D arrays:: + t.add_starlist(x=x_new, y=y_new, m=m_new) + + Parameters + ---------- + warn_missing_meta : bool, optional + Whether to warn when a per-list meta value (e.g. list_times) + already tracked by this table isn't supplied by this call. Set + to False when the caller knows that value will be set some other + way (e.g. rebuilt in full immediately afterward), so the warning + would just be noise about a value that was never meant to be + given here. By default True. """ # Check if we are dealing with a StarList object or a # set of arguments with individual arrays. if 'starlist' in kwargs: self._add_list_data_from_starlist(kwargs['starlist']) else: - self._add_list_data_from_keywords(**kwargs) + self._add_list_data_from_keywords(warn_missing_meta=warn_missing_meta, **kwargs) return @@ -204,16 +298,27 @@ def _add_list_data_from_starlist(self, starlist): old_type = self[col_name].info.dtype new_data = np.empty((old_data.shape[0], old_data.shape[1] + 1), dtype=old_type) new_data[:, :-1] = old_data - + # Save the new data array (with both old and new data in it) to the table. - self[col_name] = new_data - + self[col_name] = new_data + if (col_name in starlist.colnames): # Add data if it was input self[col_name][:, -1] = starlist[col_name] else: # Add junk data it if wasn't input self._set_invalid_list_values(col_name, -1) - - + + # Special case for list_times: Update 't' column in startable + if ('list_time' in starlist.meta): + if 't' not in self.colnames: + self.add_column(Column(data=np.full((len(self), 1), starlist.meta['list_time']), name='t')) + else: + old_data = self['t'].data + old_type = self['t'].info.dtype + new_data = np.empty((old_data.shape[0], old_data.shape[1] + 1), dtype=old_type) + new_data[:, :-1] = old_data + self['t'] = new_data + self['t'][:, -1] = starlist.meta['list_time'] + ########## # Update the table meta-data. Remember that entries are lists not numpy arrays. ########## @@ -222,38 +327,38 @@ def _add_list_data_from_starlist(self, starlist): lis_meta_keys = list(starlist.meta.keys()) # append 's' to the end to pluralize the input starlist. lis_meta_keys_plural = [lis_meta_key + 's' for lis_meta_key in lis_meta_keys] - + for kk in range(len(tab_meta_keys)): tab_key = tab_meta_keys[kk] # Meta table entries with a size that matches the n_lists size are the ones # that need a new value. We have to add something... whatever was passed in or None - if isinstance(self.meta[tab_key], collections.abc.Iterable) and (len(self.meta[tab_key]) == self.meta['n_lists']): - + if isinstance(self.meta[tab_key], Iterable) and (len(self.meta[tab_key]) == self.meta['n_lists']) and (not isinstance(self.meta[tab_key], str)): # If we find the key in the starlists' meta argument, then add the new values. # Otherwise, add "None". - idx = np.where(lis_meta_keys_plural == tab_key)[0] - if len(idx) > 0: - lis_key = lis_meta_keys[idx[0]] - self.meta[tab_key] = np.append(self.meta[tab_key], [starlist.meta[lis_key]]) + self.meta[tab_key] = list(self.meta[tab_key]) # Convert to list, as astropy.Table doesn't like numpy arrays in meta-data. + idx = lis_meta_keys_plural.index(tab_key) if tab_key in lis_meta_keys_plural else None + if idx is not None: + lis_key = lis_meta_keys[idx] + self.meta[tab_key].append(starlist.meta[lis_key]) else: self._append_invalid_meta_values(tab_key) # Update the n_lists meta keyword. self.meta['n_lists'] += 1 - + return - - - def _add_list_data_from_keywords(self, **kwargs): + + + def _add_list_data_from_keywords(self, warn_missing_meta=True, **kwargs): # # Check if the required arguments are present # arg_req = ('x', 'y', 'm') - + # for arg_test in arg_req: # if arg_test not in kwargs: # err_msg = "Added lists require a '{0:s}' argument" # raise TypeError(err_msg.format(arg_test)) - + # # If we have errors, we need them in both dimensions. # if ('xe' in kwargs) ^ ('ye' in kwargs): # raise TypeError("Added lists with errors require both 'xe' and" + @@ -263,7 +368,7 @@ def _add_list_data_from_keywords(self, **kwargs): # If there is no input data for a particular column, then fill it with # zeros and mask it. for col_name in self.colnames: - if (len(self[col_name].data.shape) == 2) and (col_name not in ['detect', 'n_detect']): # Find the 2D columns + if (np.ndim(self[col_name].data) == 2) and (col_name not in ['detect', 'n_detect']): # Find the 2D columns # Make a new 2D array with +1 extra column. Copy over the old data. # This is much faster than hstack or concatenate according to: # https://stackoverflow.com/questions/8486294/how-to-add-an-extra-column-to-an-numpy-array @@ -271,49 +376,71 @@ def _add_list_data_from_keywords(self, **kwargs): old_type = self[col_name].info.dtype new_data = np.empty((old_data.shape[0], old_data.shape[1] + 1), dtype=old_type) new_data[:, :-1] = old_data - + # Save the new data array (with both old and new data in it) to the table. self[col_name] = new_data - + if (col_name in kwargs): # Add data if it was input self[col_name][:, -1] = kwargs[col_name] else: # Add junk data it if wasn't input self._set_invalid_list_values(col_name, -1) - + # Update the table meta-data. Remember that entries are lists not numpy arrays. for key in self.meta.keys(): # Meta table entries with a size that matches the n_lists size are the ones # that need a new value. We have to add something... whatever was passed in or None - if isinstance(self.meta[key], collections.abc.Iterable) and (len(self.meta[key]) == self.meta['n_lists']): + if isinstance(self.meta[key], Iterable) and (len(self.meta[key]) == self.meta['n_lists']) and (not isinstance(self.meta[key], str)): # If we find the key is the passed in meta argument, then add the new values. # Otherwise, add "None". + self.meta[key] = list(self.meta[key]) # Convert to list, as astropy.Table doesn't like numpy arrays in meta-data. if 'meta' in kwargs: new_meta_keys = kwargs['meta'].keys() if key in new_meta_keys: - self.meta[key] = np.append(self.meta[key], [kwargs['meta'][key]]) + self.meta[key].append(kwargs['meta'][key]) else: - self._append_invalid_meta_values(key) + self._append_invalid_meta_values(key, warn=warn_missing_meta) else: - self._append_invalid_meta_values(key) + self._append_invalid_meta_values(key, warn=warn_missing_meta) # Update the n_lists meta keyword. self.meta['n_lists'] += 1 - + return + @staticmethod + def _invalid_float_value(col_name): + """ + The "no data" placeholder for a float column: np.inf for uncertainty + columns (xe, ye, me, or anything ending in '_err'), np.nan for + everything else (x, y, m, t, ...). Matches the convention already + used by add_rows_for_new_stars() for brand-new rows -- without this, + the exact same "never detected in this list" situation ends up as + NaN or inf depending only on whether the row or the column existed + first, not on what the data actually means. + """ + if col_name in ('xe', 'ye', 'me') or col_name.endswith('_err'): + return np.inf + return np.nan + def _set_invalid_list_values(self, col_name, col_idx): """ Set the contents of the specified column (in the 2D column objects) to an invalide value depending on the data type. """ - if np.issubdtype(self[col_name].info.dtype, np.integer): + if col_name == 'n_detect_list': + # Unlike other int columns, 0 (not -1) is the correct "no data" + # value here -- it lets n_detect (the aggregate) be computed as + # a direct sum(n_detect_list, axis=1) instead of a masked sum + # against x/y. + self[col_name][:, col_idx] = 0 + elif np.issubdtype(self[col_name].info.dtype, np.integer): self[col_name][:, col_idx] = -1 elif np.issubdtype(self[col_name].info.dtype, np.floating): - self[col_name][:, col_idx] = np.nan + self[col_name][:, col_idx] = self._invalid_float_value(col_name) else: self[col_name][:, col_idx] = None - + return def _set_invalid_star_values(self, col_name, row_idx): @@ -321,39 +448,41 @@ def _set_invalid_star_values(self, col_name, row_idx): Set the contents of the specified rows (in the 2D column objects) to an invalide value depending on the data type. """ - if np.issubdtype(self[col_name].info.dtype, np.integer): + if col_name == 'n_detect_list': + self[col_name][row_idx] = 0 + elif np.issubdtype(self[col_name].info.dtype, np.integer): self[col_name][row_idx] = -1 elif np.issubdtype(self[col_name].info.dtype, np.floating): - self[col_name][row_idx] = np.nan + self[col_name][row_idx] = self._invalid_float_value(col_name) else: self[col_name][row_idx] = None - + return - - def _append_invalid_meta_values(self, key): + + def _append_invalid_meta_values(self, key, warn=True): """ - For an existing meta keyword that is a list (already known), - add an invalid value depending on the type. + For an existing meta keyword that is a list (already known), + add an invalid value depending on the type. """ if issubclass(type(self.meta[key][0]), np.integer): - self.meta[key] = np.append(self.meta[key], [-1]) + self.meta[key].append(-1) elif issubclass(type(self.meta[key][0]), np.floating): - self.meta[key] = np.append(self.meta[key], [np.nan]) + self.meta[key].append(np.nan) elif issubclass(type(self.meta[key][0]), str): - self.meta[key] = np.append(self.meta[key], ['']) + self.meta[key].append('') else: - self.meta[key] = np.append(self.meta[key], [None]) + self.meta[key].append(None) - # Print a warning message: - err_msg = "StarTable.add_starlist(): Missing meta keyword: {0:s}".format(key) - warnings.warn(err_msg, UserWarning) + if warn: + err_msg = "StarTable.add_starlist(): Missing meta keyword: {0:s}".format(key) + warnings.warn(err_msg, UserWarning) return - - + + def get_starlist(self, list_index): """ - Return a StarList object for the specified list_index or epoch. + Return a StarList object for the specified list_index or epoch. Parameters ---------- @@ -373,28 +502,36 @@ def get_starlist(self, list_index): col_req_dict[col_name] = self[col_name] starlist = StarList(**col_req_dict) - + for col_name in self.colnames: if col_name in col_req_names: pass - + if len(self[col_name].data.shape) == 2: # Find the 2D columns starlist[col_name] = self[col_name][:, list_index] else: starlist[col_name] = self[col_name] - + return starlist - def combine_lists_xym(self, weighted_xy=True, weighted_m=True, mask_lists=False, sigma=3): + def combine_lists_xym(self, weighted_xy=True, weighted_m=True, mask_lists=None, sigma=3, select_stars=None, + absolute_sigma=True): """ For x, y and m columns in the table, collapse along the lists direction. For 'x', 'y' this means calculating the average position with outlier rejection. Optionally, weight by the 'xe' and 'ye' individual uncertainties. Optionally, use sigma clipping. - "mask_lists" is a list with the indices of starlists that are + "mask_lists" is a list with the indices of starlists that are excluded from the combination. Also, count the number of times a star is found in starlists. + + select_stars : array-like of bool or int, optional + If given, only (re)compute x0/y0/m0 (and errors) for these star + rows; see combine_lists() for details. By default None (compute + for all rows, same as before). + absolute_sigma : bool, optional + See combine_lists() for details. By default True. """ # Combine by position @@ -409,15 +546,19 @@ def combine_lists_xym(self, weighted_xy=True, weighted_m=True, mask_lists=False, weights_colm = 'me' else: weights_colm = None - - self.combine_lists('x', weights_col=weights_colx, mask_lists=mask_lists, sigma=sigma) - self.combine_lists('y', weights_col=weights_coly, mask_lists=mask_lists, sigma=sigma) - self.combine_lists('m', weights_col=weights_colm, mask_lists=mask_lists, sigma=sigma, ismag=True) - + + self.combine_lists('x', weights_col=weights_colx, mask_lists=mask_lists, sigma=sigma, select_stars=select_stars, + absolute_sigma=absolute_sigma) + self.combine_lists('y', weights_col=weights_coly, mask_lists=mask_lists, sigma=sigma, select_stars=select_stars, + absolute_sigma=absolute_sigma) + self.combine_lists('m', weights_col=weights_colm, mask_lists=mask_lists, sigma=sigma, ismag=True, select_stars=select_stars, + absolute_sigma=absolute_sigma) + return def combine_lists(self, col_name_in, weights_col=None, mask_val=None, - mask_lists=False, meta_add=True, ismag=False, sigma=3): + mask_lists=None, meta_add=True, ismag=False, sigma=3, + select_stars=None, absolute_sigma=True): """ For the specified column (col_name_in), collapse along the starlists direction and calculated the average value, with outlier rejection. @@ -427,604 +568,1487 @@ def combine_lists(self, col_name_in, weights_col=None, mask_val=None, 0e -- the std (with outlier rejection) Masking of NaN values is also performed. - - "mask_lists" is a list with the indices of starlists that are + + "mask_lists" is a list with the indices of starlists that are excluded from the combination. - + A flag can be stored in the metadata to record if the average was weighted or not. + + select_stars : array-like of bool or int, optional + If given, only (re)compute the averaged columns for these star + rows; every other row is left untouched. Useful when most rows + already hold a valid average from a previous call and only a + subset of rows (e.g. newly matched/added stars) actually need + recomputing -- avoids redoing work for the whole (potentially + very large) table every time. Ignored (falls back to computing + for all rows) if the 0/0_err columns + don't exist yet, since there's nothing to selectively update on + a first pass. By default None (compute for all rows). + absolute_sigma : bool, optional + Same convention as scipy.optimize.curve_fit and every + MotionModel.run_fit. The reported uncertainty is always an + uncertainty OF THE MEAN, never the scatter of the points, in all + four cases below. With S = sum((x - xbar)**2), w = 1/sigma**2, + chi2 = sum(w * (x - xbar)**2) and dof = n_valid - 1 (one parameter, + the mean, estimated from the data): + + ====================== ========================= ============================ + branch absolute_sigma=True absolute_sigma=False + ====================== ========================= ============================ + weighted (weights_col) ``sqrt(1/sum(w))`` ``sqrt(1/sum(w)*chi2/dof)`` + unweighted ``sqrt(S/(n_valid*dof))`` ``sqrt(S/(n_valid*dof))`` + ====================== ========================= ============================ + + True trusts the per-point input errors and propagates them; False + rescales by the epochs' own disagreement, which is more honest when + the input errors are systematically underestimated. absolute_sigma + does not reach the unweighted branch: it chooses between + propagating the input errors and rescaling by the observed scatter, + and that branch runs precisely when there are no input errors to + propagate, leaving only one thing to compute. That single entry is + exactly the weighted False entry with every weight equal to 1. + + dof <= 0 (0 or 1 valid epochs) carries no residual information, so + the uncertainty is inf there in both branches, as in every + MotionModel.run_fit. By default True. """ - # Get the array we are going to combine. Make a copy so we don't mod it. - val_2d = copy.deepcopy( self[col_name_in].data ) + col_name_avg = col_name_in + '0' + col_name_std = col_name_in + '0_err' + if (select_stars is not None) and (col_name_avg not in self.colnames): + select_stars = None + + if mask_lists is not None: + # Extract list of indices that we want to keep (i.e. not mask) + mask_lists = np.atleast_1d(mask_lists) + assert mask_lists.dtype == int, "mask_lists needs to be a list of integers." + list_indices = np.array([i for i in np.arange(self[col_name_in].data.shape[1]) if i not in mask_lists]) + else: + # Use all indices. A plain slice (rather than an arange array) keeps + # the col_data[:, list_indices] indexing below a view instead of a + # forced fancy-index copy -- np.array()/masked_invalid() further down + # already makes the one copy that's actually needed. + list_indices = slice(None) + + if select_stars is not None: + col_data = self[col_name_in].data[select_stars] + else: + col_data = self[col_name_in].data + val_2d = np.array(col_data[:, list_indices], dtype=float) if ismag: # Convert to flux. - val_2d = 10**(-val_2d / 2.5) - # Make a mask of invalid (NaN) values and a user-specified invalid value. - val_2d = np.ma.masked_invalid(val_2d) + val_2d = 10**(-0.4 * val_2d) + + # `valid` tracks, elementwise, whether a value is usable at all -- + # this replaces numpy.ma's masking, but as a plain boolean array so + # the arithmetic below can use ordinary (fast) numpy ops instead of + # numpy.ma's much slower generic dispatch for every operator. + valid = np.isfinite(val_2d) + + # Mask a user-specified invalid value too. if mask_val: - val_2d = np.ma.masked_values(val_2d, mask_val) - - if mask_lists is not False: - # Remove a list - if isinstance(mask_lists, list): - if all(isinstance(item, int) for item in mask_lists): - val_2d.mask[:, mask_lists] = True - - # Throw a warning if mask_lists is not a list - if not isinstance(mask_lists, list): - raise RuntimeError('mask_lists needs to be a list.') - - # Decide if we are going to have weights (before we - # do the expensive sigma clipping routine). Note that - # if we have only 1 column to average, then we can't do weighting. - if (weights_col and weights_col in self.colnames) and (val_2d.shape[1] > 1): - err_2d = self[weights_col].data - + valid &= ~np.isclose(val_2d, mask_val, rtol=1e-05, atol=1e-08) + + # Figure out which ones are outliers. sigma_clip already treats NaN + # (and, via the mask below, our own invalid entries) as excluded, and + # returns a masked array -- pull its mask into `valid` and its data + # into a plain array immediately, rather than keep operating on the + # masked array itself for every subsequent step. + if sigma: + # Pass a masked (not NaN-filled) array in: sigma_clip treats an + # explicit mask as "already known invalid" silently, whereas raw + # NaNs trigger an "invalid values...automatically clipped" + # warning that the original implementation never produced. + val_2d_for_clip = np.ma.masked_array(val_2d, mask=~valid, copy=False) + clipped = sigma_clip(val_2d_for_clip, sigma=sigma, maxiters=5, axis=1) + valid &= ~np.ma.getmaskarray(clipped) + val_2d_clip = np.where(valid, clipped.data, 0.0) + else: + val_2d_clip = np.where(valid, val_2d, 0.0) + + # Decide if we are going to have weights (before we do the expensive sigma clipping routine). + if weights_col in self.colnames: + if select_stars is not None: + weights_data = self[weights_col].data[select_stars] + else: + weights_data = self[weights_col].data + err_2d = np.array(weights_data[:, list_indices], dtype=float) + if ismag: # Convert to flux error - err_2d = err_2d * val_2d * np.log(10) / 2.5 - - np.seterr(divide='ignore') - wgt_2d = np.ma.masked_invalid(1.0 / err_2d**2) - np.seterr(divide='warn') - + err_2d = 0.4 * np.log(10) * val_2d * err_2d + + # Inverse variance weights minimize the propagated uncertainty. + # `err_2d` here is never faked/patched -- it's exactly what was + # measured, so the `wgt_2d`/`wgt_sum` derived from it below are + # an honest record of how much real uncertainty information we + # actually have for each star. weight_from_sigma safely zeroes + # out any epoch where the value isn't valid (post-clipping) or + # the error itself is invalid/zero/overflow-inducing, rather + # than letting a bad error corrupt the weighted sum. + wgt_2d = motion_model.weight_from_sigma(err_2d, valid) + + # Honest weight sum, built only from real, known uncertainties. + # The reported std below is derived directly from this, so a + # star whose every epoch lacks a usable error naturally ends up + # with wgt_sum == 0 -> std = sqrt(1/0) == inf via ordinary + # division -- there's no separate flag to remember to apply + # afterward, and no way for a fabricated finite error to reach std. + wgt_sum = wgt_2d.sum(axis=1) + n_valid = valid.sum(axis=1) + has_data = n_valid > 0 + + with np.errstate(divide='ignore', invalid='ignore'): + avg = (val_2d_clip * wgt_2d).sum(axis=1) / wgt_sum + # Equivalent of avg = np.average(val_2d_clip, weights=wgt_2d, axis=1) + std = np.sqrt(1. / wgt_sum) # Error propagation for weighted mean + if not absolute_sigma: + # Rescale by sqrt(reduced chi2) -- scipy's own + # absolute_sigma=False convention (pcov *= chi2/dof), + # same formula every MotionModel.run_fit uses. chi2 here + # is the weighted sum of squared residuals against the + # weighted mean (the same quantity align.py separately + # computes as chi2_x/chi2_y later in fit(), though not + # reusable from here: that happens afterward in the same + # call, using the raw, non-sigma-clipped data, so it + # isn't numerically identical when sigma clipping is + # active). dof = n_valid - 1 (the weighted mean is + # effectively a 1-parameter fit); dof <= 0 has no + # residual information to estimate scatter from, so it's + # forced to inf exactly like every run_fit's own dof_pos + # check, rather than the 0/0 = nan (or, at dof=0 with a + # perfect single-point "fit", a false 0) this would + # otherwise silently produce. + dof = n_valid - 1 + dof_pos = dof > 0 + chi2 = (wgt_2d * (val_2d_clip - avg[:, np.newaxis])**2).sum(axis=1) + reduced_chi2 = np.where(dof_pos, chi2 / np.where(dof_pos, dof, 1), 1.0) + std = np.where(dof_pos, std * np.sqrt(reduced_chi2), np.inf) + + # A star whose every epoch has an invalid raw uncertainty (e.g. + # missing/invalid me/xe/ye everywhere) but at least one valid + # value still gets an average -- a plain mean of its valid + # epoch(s), same as the unweighted branch below would give -- + # instead of discarding a real measurement as nan just because + # we don't know how to weight it. std is forced to inf here + # regardless of absolute_sigma (True already gives inf naturally + # via 1/0; False would otherwise give 0/0 = nan), so this can't + # accidentally fabricate a finite reported error. + no_usable_err = (wgt_sum == 0) & has_data + if no_usable_err.any(): + avg[no_usable_err] = val_2d_clip[no_usable_err].sum(axis=1) / n_valid[no_usable_err] + std[no_usable_err] = np.inf + + avg[~has_data] = np.nan + if meta_add: self.meta[col_name_in + '0'] = 'weighted' else: - wgt_2d = None + # Calculate the (unweighted) mean and uncertainty + n_valid = valid.sum(axis=1) + has_data = n_valid > 0 + with np.errstate(divide='ignore', invalid='ignore'): + avg = val_2d_clip.sum(axis=1) / n_valid + avg[~has_data] = np.nan + # Uncertainty OF THE MEAN, not the scatter of the points. This + # used to return sqrt(sum(dev**2)/n_valid) -- the population RMS of + # the individual values -- which is larger than the error on their + # average by sqrt(n_valid - 1), and described a different quantity + # from the weighted branch above, though both land in the same + # 0_err column and both feed align's transformation weights. + deviations = np.where(valid, val_2d_clip - avg[:, np.newaxis], 0.0) + # sqrt(S / (n_valid * dof)) with dof = n_valid - 1: the standard + # error of the mean. Identical to the weighted branch above with + # every weight set to 1 -- sqrt(1/sum(w)) * sqrt(chi2/dof) + # collapses to exactly this -- so both branches now report the same + # quantity. absolute_sigma does not enter: it selects between + # propagating the input errors and rescaling by the observed + # scatter, and with no input errors to propagate there is only one + # thing this branch can compute. + # + # dof is substituted with 1 inside the denominator rather than + # dividing by zero and repairing the result afterwards: at + # n_valid <= 1 the honest answer is 0/0, and the std == 0 guard + # further down tests for zero, so a nan would slip past it. + dof = n_valid - 1 + dof_pos = dof > 0 + with np.errstate(divide='ignore', invalid='ignore'): + std = np.sqrt((deviations**2).sum(axis=1) + / (n_valid * np.where(dof_pos, dof, 1))) + std = np.where(dof_pos, std, np.inf) + if meta_add: self.meta[col_name_in + '0'] = 'not_weighted' - # Figure out which ones are outliers. Returns a masked array. - if sigma: - warnings.filterwarnings('ignore', category=RuntimeWarning) - val_2d_clip = sigma_clipping.sigma_clip(val_2d, sigma=sigma, maxiters=5, axis=1) - warnings.filterwarnings('default', category=RuntimeWarning) - else: - val_2d_clip = val_2d - - # Calculate the (weighted) mean and standard deviation along - # the N_lists direction (axis=1). - if wgt_2d is not None: - avg = np.ma.average(val_2d_clip, weights=wgt_2d, axis=1) - std = np.sqrt(np.ma.average((val_2d_clip.T - avg).T**2, weights=wgt_2d, axis=1)) - else: - avg = np.ma.mean(val_2d_clip, axis=1) - std = np.ma.std(val_2d_clip, axis=1) - # To Do: bring the previous uncertainties of stars that are detected - # in only one input frame. - if (weights_col and weights_col in self.colnames) and (val_2d.shape[1] > 1): - mask_for_singles = ((~np.isnan(val_2d_clip)).sum(axis=1)==1) - std[mask_for_singles]=np.nanmean(err_2d[mask_for_singles], axis=1) - - # Save off our new AVG and STD into new columns with shape (N_stars). - col_name_avg = col_name_in + '0' - col_name_std = col_name_in + '0e' + std_invalid = (~has_data) | (std == 0.) # Mask out zero uncertainties + # Save off our new AVG and STD into columns with shape (N_stars) + # (col_name_avg/col_name_std were resolved at the top of this function). if ismag: - std = (2.5 / np.log(10)) * std / avg - avg = -2.5 * np.ma.log10(avg) - if col_name_avg in self.colnames: - self[col_name_avg] = avg.data - self[col_name_std] = std.data + with np.errstate(divide='ignore', invalid='ignore'): + std = 2.5 / np.log(10) * std / avg # Error propagation + avg = -2.5 * np.log10(avg) + + # Fill invalid entries with nan (avg) or inf (std) + std[std_invalid] = np.inf + + if select_stars is not None: + # Columns must already exist -- only the selected rows are updated, + # everything else is left exactly as it was. + self[col_name_avg][select_stars] = avg + self[col_name_std][select_stars] = std + elif col_name_avg in self.colnames: + self[col_name_avg] = avg + self[col_name_std] = std else: - self.add_column(Column(data=avg.data, name=col_name_avg)) - self.add_column(Column(data=std.data, name=col_name_std)) - + self.add_column(Column(data=avg, name=col_name_avg)) + self.add_column(Column(data=std, name=col_name_std)) + return - def detections(self): + def detections(self, weight_col=None): """ Find where stars are detected. - # """ - n_detect = np.sum(~np.isnan(self['x']), axis=1) - + + weight_col : str, optional + If given and present in this table's columns, sum this per-list + column directly instead of counting each valid (x, y) as 1. Its + "no detection this epoch" cells must already be 0 (not -1/nan) + for this to give the right total. Used to inherit a per-list + 'n_detect_list' column from starlists that are themselves the + output of a previous, lower-level align pass, so n_detect + reflects the total number of raw detections a star represents. + By default None (plain count). + """ + if (weight_col is not None) and (weight_col in self.colnames): + n_detect = np.sum(self[weight_col], axis=1) + else: + valid = np.isfinite(self['x']) & np.isfinite(self['y']) + n_detect = np.sum(valid, axis=1) + if 'n_detect' in self.colnames: self['n_detect'] = n_detect else: - self.add_column(Column(n_detect), name='n_detect') - + self.add_column(Column(data=n_detect, name='n_detect')) + return - - def fit_velocities(self, weighting='var', use_scipy=True, absolute_sigma=True, bootstrap=0, fixed_t0=False, verbose=False, - mask_val=None, mask_lists=False, show_progress=True): - """Fit velocities for all stars in the table and add to the columns 'vx', 'vxe', 'vy', 'vye', 'x0', 'x0e', 'y0', 'y0e'. + def fit_motion_models( + self, + motion_models=None, + fixed_params_dict=None, + weighting='var', + absolute_sigma=True, + select_stars=None, + keep_existing=True, + bootstrap=0, + seed=None, + mask_value=None, + mask_lists=None, + fill_value=np.nan, + art_star=False, + processes=1, + chunksize=None, + mp_star_threshold=100_000, + verbose=True + ): + """Fit velocity for star table Parameters ---------- + motion_models : list of MotionModel or str, optional + Motion models to use, by default Empty, Fixed and Linear. + Empty and Fixed models are always added automatically for stars with n_fit = 0 or 1. + The behavior is as follows: + 1. If 'motion_model_input' column is NOT in table: + + - Use the most complex model that has enough parameters to fit the data (n_fit >= n_params). + - If multiple models are supplied, prioritize the model with the most parameters to fit. + - If multiple models have the same number of parameters, raise AssertionError: not sure which to use. + + 2. If 'motion_model_input' column IS in table: + + - Use the model specified in the 'motion_model_input' column. + - If not enough data points to fit the specified model, use the most complex model in any 'motion_model_input' column that has enough parameters to fit the data (n_fit >= n_params) among the provided motion_models and 'motion_model_input'. + + The actual used motion model is stored in the 'motion_model_used' column. The default motion_models are [Empty, Fixed, Linear]. + fixed_params_dict : dict, optional + Dictionary of fixed parameters for motion models, e.g., {'t0': 0., 'ra': np.array([...]), 'dec': np.array([...])}. + - Scalar values are used for all stars, array values should have length = N_stars. + - t0 is automatically calculated as np.average(t, weights=1/np.hypot(xe, ye)) if not provided. + - The keys should match the fixed parameter names in the motion models. See MotionModel class for details. + - Each parameter is resolved in the order fixed_params_dict -> table column -> table metadata, so an entry here outranks a same-named column or metadata entry. + - The values actually used are written back under '': to metadata if uniform and no such column exists, otherwise to the column, with a disagreeing caller's column moved aside to '_orig', by default None weighting : str, optional - Weight by variance 'var' or standard deviation 'std', by default 'var' - use_scipy : bool, optional - Use scipy.curve_fit (recommended for large number of epochs, but may return inf or nan) or analytic fitting from flystar.fit_velocity.linear_fit (recommended for a few epochs), by default True + Uncertainty weighting, 'std' for weight=1/xe(ye) or 'var' for weight=1/xe(ye)**2, by default 'var' absolute_sigma : bool, optional - Absolute sigma or not. See https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.curve_fit.html for details, by default True + Same convention as scipy.optimize.curve_fit. If True (default), + the reported parameter errors are propagated from the input + xe/ye as given. If False, they are rescaled by + sqrt(chi2/dof), so only the relative magnitudes of xe/ye + matter and the errors instead reflect the epochs' own scatter. + + Special case -- no uncertainty information available (the table + has no xe/ye columns, or a given star's xe/ye are invalid in + every epoch): the fit falls back to a substituted unit error + (sigma=1) so a position can still be measured. With + absolute_sigma=True the resulting error would be purely a + function of that fabricated sigma (e.g. exactly 1/sqrt(N_valid) + for Fixed), so it is reported as np.inf instead -- unknown + rather than a finite number that only looks like a real + uncertainty. With absolute_sigma=False the sqrt(chi2/dof) + rescaling cancels the fabricated sigma back out, leaving the + epochs' genuine empirical scatter, which is kept. + By default True. + select_stars : list of int, optional + Indices of stars to fit, by default None (fit all stars) + keep_existing : bool, optional + Keep existing motion model results in the table, or set them to fill_value and Inf for stars not in select_stars, by default True bootstrap : int, optional - Calculate uncertain using bootstraping or not, by default 0 - fixed_t0 : bool or array-like, optional - Fix the t0 in dt = time - t0 if user provides an array with the same length of the table, or automatically calculate t0 = np.average(time, weights=1/np.hypot(xe, ye)) if False, by default False + Number of bootstrap samples for uncertainty resampling, by default 0 + seed : int, optional + Random seed for bootstrap resampling, by default None + mask_value : float, optional + Values to mask in data, by default None + mask_lists : list of int, optional + Indices of lists to mask/exclude from fitting, by default None + fill_value : float, optional + Fill value when there is not enough data points to fit, by default np.nan + art_star : bool, optional + Artifical star table or observed star table. If artificial stars, Use the output coordinates for fitting motion models (x[..., 1], y[..., 1]). + By default False. + processes : int, optional + Number of processes to use for parallel processing, maximum os.cpu_count(), by default 1 (no multiprocessing) + chunksize : int, optional + Chunk size for multiprocessing, by default None (auto) + mp_star_threshold : int, optional + Minimum number of stars needing the per-star fitting path before a + multiprocessing Pool is spun up, even if processes > 1 was + requested. A star needs that path only when bootstrap > 0 (bootstrap + resampling isn't vectorized across stars). Below this + threshold, fitting runs serially in the calling process instead -- + spinning up a Pool has real fixed overhead (worker startup, + pickling the shared data arrays to each worker) that a small + per-star workload doesn't recoup. Measured break-even was between + 20,000 and 100,000 stars on a 10-core machine, so 100,000 + (default) is a conservative choice. By default 100_000. verbose : bool, optional - Output verbose information or not, by default False - mask_val : float, optional - Value that needs to be masked in the data, e.g. -100000, by default None - mask_lists : list, optional - Columns that needs to be masked, by default False - show_progress : bool, optional - Show progress bar or not, by default True + Print verbose messages or not, by default True + Raises ------ ValueError - If weighting is neither 'var' or 'std' + If weighting is not 'var' or 'std'. + KeyError + If time values are not found in the table or meta. KeyError - If there's not time information in the table + If required columns 'x' and 'y' are missing in the table. """ + ########################### + ####### Check Params ###### + ########################### if weighting not in ['var', 'std']: - raise ValueError(f"fit_velocities: Weighting must either be 'var' or 'std', not {weighting}!") - - if ('t' not in self.colnames) and ('LIST_TIMES' not in self.meta): - raise KeyError("fit_velocities: Failed to access time values. No 't' column in table, no 'LIST_TIMES' in meta.") - + raise ValueError(f"fit_motion_models: Weighting must either be 'var' or 'std', not {weighting}!") + + if ('t' not in self.colnames) and ('list_times' not in self.meta): + raise KeyError("fit_motion_models: Failed to access time values. No 't' column in table, no 'list_times' in meta.") + # Check if we have the required columns if not all([_ in self.colnames for _ in ['x', 'y']]): - raise KeyError(f"fit_velocities: Missing required columns in the table: {', '.join(['x', 'y'])}!") - - N_stars = len(self) + raise KeyError(f"fit_motion_models: Missing required columns in the table: {', '.join(['x', 'y'])}!") - if verbose: - start_time = time.time() - msg = 'Starting startable.fit_velocities for {0:d} stars with n={1:d} bootstrap' - print(msg.format(N_stars, bootstrap)) - - # Clean/remove up old arrays. - if 'x0' in self.colnames: self.remove_column('x0') - if 'vx' in self.colnames: self.remove_column('vx') - if 'y0' in self.colnames: self.remove_column('y0') - if 'vy' in self.colnames: self.remove_column('vy') - if 'x0e' in self.colnames: self.remove_column('x0e') - if 'vxe' in self.colnames: self.remove_column('vxe') - if 'y0e' in self.colnames: self.remove_column('y0e') - if 'vye' in self.colnames: self.remove_column('vye') - if 'chi2_vx' in self.colnames: self.remove_column('chi2_vx') - if 'chi2_vy' in self.colnames: self.remove_column('chi2_vy') - if 't0' in self.colnames: self.remove_column('t0') - if 'n_vfit' in self.colnames: self.remove_column('n_vfit') - - # Define output arrays for the best-fit parameters. - self.add_column(Column(data = np.zeros(N_stars, dtype=float), name = 'x0')) - self.add_column(Column(data = np.zeros(N_stars, dtype=float), name = 'vx')) - self.add_column(Column(data = np.zeros(N_stars, dtype=float), name = 'y0')) - self.add_column(Column(data = np.zeros(N_stars, dtype=float), name = 'vy')) - - self.add_column(Column(data = np.zeros(N_stars, dtype=float), name = 'x0e')) - self.add_column(Column(data = np.zeros(N_stars, dtype=float), name = 'vxe')) - self.add_column(Column(data = np.zeros(N_stars, dtype=float), name = 'y0e')) - self.add_column(Column(data = np.zeros(N_stars, dtype=float), name = 'vye')) - - self.add_column(Column(data = np.zeros(N_stars, dtype=float), name = 'chi2_vx')) - self.add_column(Column(data = np.zeros(N_stars, dtype=float), name = 'chi2_vy')) - - self.add_column(Column(data = np.zeros(N_stars, dtype=float), name = 't0')) - self.add_column(Column(data = np.zeros(N_stars, dtype=int), name = 'n_vfit')) - - self.meta['N_VFIT_BOOTSTRAP'] = bootstrap - - # (FIXME: Do we need to catch the case where there's a single *unmasked* epoch?) - # Catch the case when there is only a single epoch. Just return 0 velocity - # and the same input position for the x0/y0. - if len(self['x'].shape) == 1: - self['x0'] = self['x'] - self['y0'] = self['y'] - if 't' in self.colnames: - self['t0'] = self['t'] - else: - self['t0'] = self.meta['LIST_TIMES'][0] - if 'xe' in self.colnames: - self['x0e'] = self['xe'] - self['y0e'] = self['ye'] - self['n_vfit'] = 1 + # Make a copy of fixed_params_dict to avoid modifying the original one outside the function + fixed_params_dict = copy.deepcopy(fixed_params_dict) - return - - if self['x'].shape[1] == 1: - self['x0'] = self['x'][:,0] - self['y0'] = self['y'][:,0] + # Check fixed_params_dict is a dict + if fixed_params_dict is not None: + if not isinstance(fixed_params_dict, dict): + raise ValueError("fit_motion_models: fixed_params_dict must be a dictionary!") - if 't' in self.colnames: - self['t0'] = self['t'][:, 0] + if select_stars is not None: + select_idx = np.asarray(select_stars) + if select_idx.dtype == bool: + select_idx = np.flatnonzero(select_idx) else: - self['t0'] = self.meta['LIST_TIMES'][0] - - if 'xe' in self.colnames: - self['x0e'] = self['xe'][:,0] - self['y0e'] = self['ye'][:,0] + select_idx = np.asarray(select_idx, dtype=int) + if len(select_idx) == 0: + return + else: + select_idx = None - self['n_vfit'] = 1 + N_stars = len(self) + if (select_idx is not None) and (len(select_idx) < N_stars): + # Everything below this point -- the masked-array data prep, + # n_fit/motion-model classification, and per-star fixed-params + # dict construction -- costs O(N_stars) every single call, + # regardless of how few stars select_stars actually asks to + # fit. For a mosaic that's re-fit once per starlist (this + # function called repeatedly as the table keeps growing), that + # made the redundant, unselected majority of the table get + # copied and reprocessed on every single call -- for many + # starlists and a large final table, this dwarfs the actual + # fitting cost. Slice down to just the selected rows (fancy/ + # boolean indexing always copies in numpy, so this bounds cost + # to len(select_stars), not N_stars), run this same function + # unmodified on that much smaller table, then scatter its + # results back into self at the selected positions. (If + # select_stars covers the whole table there's nothing to save + # by slicing -- that would just pay a full-table copy for no + # benefit -- so fall through to the normal path below instead.) + sub_fixed_params_dict = { + k: (v[select_idx] if (np.ndim(v) > 0 and len(v) == N_stars) else v) + for k, v in (fixed_params_dict or {}).items() + } + + sub_table = self[select_idx] + orig_meta_keys = set(self.meta.keys()) + sub_table.fit_motion_models( + motion_models=motion_models, fixed_params_dict=sub_fixed_params_dict, + weighting=weighting, absolute_sigma=absolute_sigma, + select_stars=None, keep_existing=keep_existing, + bootstrap=bootstrap, seed=seed, mask_value=mask_value, mask_lists=mask_lists, + fill_value=fill_value, art_star=art_star, processes=processes, + chunksize=chunksize, mp_star_threshold=mp_star_threshold, verbose=verbose + ) + + for col_name in sub_table.colnames: + if col_name not in self.colnames: + default = np.inf if (col_name.endswith('_err')) else fill_value + dtype = sub_table[col_name].dtype + if dtype.kind in 'US': + default = '' + elif dtype.kind == 'i': + default = -1 + elif dtype.kind == 'b': + default = False + self.add_column(Column(data=np.full(N_stars, default, dtype=dtype), name=col_name)) + self[col_name][select_idx] = sub_table[col_name] + + # Only propagate meta keys fit_motion_models itself newly added + # (e.g. n_bootstrap, or a scalar-valued fixed param) -- not + # table-size-specific ones the smaller sub_table happens to + # carry (n_stars, ref_list, list_times, ...). + for key, value in sub_table.meta.items(): + if key not in orig_meta_keys: + self.meta[key] = value return - # STARS LOOP through the stars and work on them 1 at a time. - # This is slow; but robust. - if show_progress: - for ss in tqdm(range(N_stars)): - self.fit_velocity_for_star(ss, weighting=weighting, use_scipy=use_scipy, absolute_sigma=absolute_sigma, bootstrap=bootstrap, fixed_t0=fixed_t0, - mask_val=mask_val, mask_lists=mask_lists) + all_mm_map = motion_model.motion_model_map() + # Setting the default to None to avoid mutable default argument issue + # See https://stackoverflow.com/questions/15189245/assigning-class-variable-as-default-value-to-class-method-argument + if motion_models is None: + # Linear by default + motion_models = [motion_model.Linear] + motion_models = motion_model.organize_motion_models(motion_models) + mm_names = [mm.name for mm in motion_models] + + # Construct motion models if motion_model_input column exists + if 'motion_model_input' in self.colnames: + input_mm_names = np.unique(self['motion_model_input']) + assert all([name in all_mm_map.keys() for name in input_mm_names]), \ + f"fit_motion_models: Unknown motion model name(s) in 'motion_model_input' column. Available motion models are: {', '.join(all_mm_map.keys())}." + for mm_name in input_mm_names: + if mm_name not in mm_names: + motion_models.append(all_mm_map[mm_name]) + + # Sort motion models by required epochs + motion_models = sorted(motion_models, key=lambda mm: mm.n_params) + + input_mm_map = {mm.name: mm for mm in motion_models} + + mm_n_params = np.sort([mm.n_params for mm in motion_models]) + if 'motion_model_input' not in self.colnames: + # If motion_model_input column is not provided, assert that motion model n_params are unique and sorted + # Otherwise the fitter does not know which motion model to use based on n_obs + assert len(mm_n_params) == len(set(mm_n_params)), \ + f"fit_motion_models: Provided motion model n_params are not unique! Motion Models are: {[_.name for _ in motion_models]}" + '\n' + "Cannot decide which motion model to use based on n_obs. Please provide unique motion_models or a 'motion_model_input' column." + + + ########################### + ####### Prepare Data ###### + ########################### + # Prepare data for fitting + N_stars = len(self) + if art_star: + x = self['x'].data[..., 1] + y = self['y'].data[..., 1] else: - for ss in range(N_stars): - self.fit_velocity_for_star(ss, weighting=weighting, use_scipy=use_scipy, absolute_sigma=absolute_sigma, bootstrap=bootstrap, fixed_t0=fixed_t0, - mask_val=mask_val, mask_lists=mask_lists, ) - if verbose: - stop_time = time.time() - print('startable.fit_velocities runtime = {0:.0f} s for {1:d} stars'.format(stop_time - start_time, N_stars)) - - return + x = self['x'].data + y = self['y'].data - def fit_velocity_for_star(self, ss, weighting='var', use_scipy=True, absolute_sigma=True, bootstrap=False, fixed_t0=False, - mask_val=None, mask_lists=False): + xe = self['xe'].data if 'xe' in self.colnames else None + ye = self['ye'].data if 'ye' in self.colnames else None + with_xe_ye = (xe is not None) and (ye is not None) - # Make a mask of invalid (NaN) values and a user-specified invalid value. - x = np.ma.masked_invalid(self['x'][ss, :].data) - y = np.ma.masked_invalid(self['y'][ss, :].data) - if mask_val: - x = np.ma.masked_values(x, mask_val) - y = np.ma.masked_values(y, mask_val) - # If no mask, convert x.mask to list - if not np.ma.is_masked(x): - x.mask = np.zeros_like(x.data, dtype=bool) - if not np.ma.is_masked(y): - y.mask = np.zeros_like(y.data, dtype=bool) - - - if mask_lists is not False: - # Remove a list - if isinstance(mask_lists, list): - if all(isinstance(item, int) for item in mask_lists): - x.mask[mask_lists] = True - y.mask[mask_lists] = True - - # Throw a warning if mask_lists is not a list - if not isinstance(mask_lists, list): - raise RuntimeError('mask_lists needs to be a list.') - - if 'xe' in self.colnames: - # Make a mask of invalid (NaN) values and a user-specified invalid value. - xe = np.ma.masked_invalid(self['xe'][ss, :].data) - ye = np.ma.masked_invalid(self['ye'][ss, :].data) - - # Catch the case where we have positions but no errors for - # some of the entries... we need to "fill in" reasonable - # weights for these... just use the average weights over - # all the other epochs. - pos_no_err = np.where((np.isfinite(x) & np.isfinite(y)) & - (np.isfinite(xe) == False) & (np.isfinite(ye) == False))[0] - pos_with_err = np.where((np.isfinite(x) & np.isfinite(y)) & - (np.isfinite(xe) & np.isfinite(ye)))[0] - - if len(pos_with_err) > 0: - xe[pos_no_err] = xe[pos_with_err].mean() - ye[pos_no_err] = ye[pos_with_err].mean() - else: - xe[pos_no_err] = 1.0 - ye[pos_no_err] = 1.0 + N_times = x.shape[1] + if mask_lists is not None: + list_indices = np.array([i for i in range(N_times) if i not in mask_lists]) else: - N_epochs = len(x) - xe = np.ones(N_epochs, dtype=float) - ye = np.ones(N_epochs, dtype=float) - xe = np.ma.masked_invalid(xe) - ye = np.ma.masked_invalid(xe) - - if mask_val: - xe = np.ma.masked_values(xe, mask_val) - ye = np.ma.masked_values(ye, mask_val) - # If no mask, convert xe.mask to list - if not np.ma.is_masked(xe): - xe.mask = np.zeros_like(xe.data, dtype=bool) - if not np.ma.is_masked(ye): - ye.mask = np.zeros_like(ye.data, dtype=bool) - - if mask_lists is not False: - # Remove a list - if isinstance(mask_lists, list): - if all(isinstance(item, int) for item in mask_lists): - xe.mask[mask_lists] = True - ye.mask[mask_lists] = True - - # Throw a warning if mask_lists is not a list - if not isinstance(mask_lists, list): - raise RuntimeError('mask_lists needs to be a list.') - - # Make a mask of invalid (NaN) values and a user-specified invalid value. + # A plain slice (rather than an arange array) keeps x[:, list_indices] + # etc. below a view instead of a forced fancy-index copy -- the + # explicit copy=True/deepcopy calls further down already make the one + # copy that's actually needed. At full-table scale this was making two + # full (N_stars, N_times) copies of x, y, xe, ye, and t where one would do. + list_indices = slice(None) + + x_data = np.ma.masked_invalid(x[:, list_indices], copy=True) + y_data = np.ma.masked_invalid(y[:, list_indices], copy=True) + xe_data = np.ma.masked_invalid(xe[:, list_indices], copy=True) if with_xe_ye else None + ye_data = np.ma.masked_invalid(ye[:, list_indices], copy=True) if with_xe_ye else None + + # Mask out close to 0 values to avoid infinite weights + if with_xe_ye: + xe_data.mask[np.isclose(xe_data, 0)] = True + ye_data.mask[np.isclose(ye_data, 0)] = True + + # If all of xe and ye is masked for a star, effectively no uncertainties provided, fill with 1. + # Note that this automatically turn the mask to False for these stars + if with_xe_ye: + fill_with_one = np.all(xe_data.mask, axis=1) & np.all(ye_data.mask, axis=1) + xe_data[fill_with_one] = 1. + ye_data[fill_with_one] = 1. + # motion_model.fit()'s batch path only checks isfinite(x)/isfinite(y) + # for validity -- weight_from_sigma zeroes any weight that comes out + # non-finite, but a merely tiny (not exactly zero/inf/nan) xe/ye from + # the np.isclose masking above wouldn't trip that check on its own. + # Make the epochs that are *still* masked at this point (i.e. not + # rescued by fill_with_one above) genuinely nan, so they're reliably + # zero-weighted regardless of how close to zero they were. + xe_data.data[xe_data.mask] = np.nan + ye_data.data[ye_data.mask] = np.nan + + # Ensure data is 2D for consistent indexing, even if we have only one list/epoch (shape (N_stars, 1) instead of (N_stars,)) + if np.ndim(x_data) == 1: + x_data = x_data[:, np.newaxis] + if np.ndim(y_data) == 1: + y_data = y_data[:, np.newaxis] + if with_xe_ye: + if np.ndim(xe_data) == 1: + xe_data = xe_data[:, np.newaxis] + if np.ndim(ye_data) == 1: + ye_data = ye_data[:, np.newaxis] + + # t_data: 2d array with shape (N_stars, N_epochs) + # t0: 1d array with shape (N_stars,) if 't' in self.colnames: - t = np.ma.masked_invalid(self['t'][ss, :].data) + t_data = copy.deepcopy(self['t'].data[:, list_indices]) + else: + t_data = copy.deepcopy(np.array(self.meta['list_times']))[list_indices] + t_data = np.broadcast_to(t_data, x_data.shape) + + fixed_params_dict = {} if fixed_params_dict is None else fixed_params_dict + # Add default t0 if not provided in fixed_params_dict + if 't0' not in fixed_params_dict: + weights = 1. / np.hypot(xe_data, ye_data) if with_xe_ye else None + # t_data must be masked (not just weights) and np.ma.average (not + # plain np.average) must be used here: for the fill_with_one + # stars above (no usable xe/ye anywhere at all), the substitute + # weight is uniform/unmasked, but t can still be genuinely + # invalid in undetected epochs, or weights can be masked (e.g. + # only some epochs have usable xe/ye) while t_data itself is + # plain. Plain np.average's weight-sum denominator doesn't + # respect either mask in that case, silently producing NaN. + fixed_params_dict['t0'] = np.ma.average(np.ma.masked_invalid(t_data), axis=1, weights=weights).filled(np.nan) + else: + if np.ndim(fixed_params_dict['t0']) == 0: + fixed_params_dict['t0'] = np.full(N_stars, fixed_params_dict['t0']) + + t0 = fixed_params_dict['t0'] + + # Apply mask_value if provided + if mask_value: + x_data = np.ma.masked_values(x_data, mask_value) + y_data = np.ma.masked_values(y_data, mask_value) + if with_xe_ye: + xe_data = np.ma.masked_values(xe_data, mask_value) + ye_data = np.ma.masked_values(ye_data, mask_value) + # motion_model.fit()'s batch path derives validity as + # isfinite(x) & isfinite(y) directly from the data (no + # separate mask array is passed) -- unlike genuine "no + # detection" gaps, mask_value cells aren't already nan, so + # make them so here (once, only when mask_value is actually + # used) rather than leaving a finite-but-meaningless value + # (e.g. mask_value itself) silently treated as real. xe/ye + # need the same treatment: fit()'s weight calculation already + # zeroes out any weight that comes out non-finite, so a nan + # xe/ye is enough to exclude that epoch even though only x/y + # feed the isfinite() mask itself. + x_data.data[x_data.mask] = np.nan + y_data.data[y_data.mask] = np.nan + if with_xe_ye: + xe_data.data[xe_data.mask] = np.nan + ye_data.data[ye_data.mask] = np.nan + + # Calculate mask array + valid_xy = ~ (x_data.mask | y_data.mask) + if with_xe_ye: + valid_xy &= ~ (xe_data.mask | ye_data.mask) + + # Calculate n_fit: unmasked x y values + # This will be used to determine which motion model to use for each star. + # Note that we don't require unique times here + # as scipy.curve_fit and Linear algebra can fit non-unique times. + # self['n_fit'] = np.sum(valid_xy, axis=1) + + # Calculate n_fit: unique times & unmasked x y values. + # Vectorized equivalent of len(set(t_data[i][valid_xy[i]])) per star: + # push each star's invalid entries to +inf (so they sort last and + # never affect the count), sort, then count 1 (for the first valid + # entry, if any) plus the number of adjacent sorted valid entries + # that differ -- mathematically identical to counting unique values, + # but as whole-array numpy ops instead of a per-star Python loop + # building a set() object for each of potentially millions of stars. + N_epochs = t_data.shape[1] + t_for_sort = np.where(valid_xy, t_data, np.inf) + t_sorted = np.sort(t_for_sort, axis=1) + n_valid_per_star = valid_xy.sum(axis=1) + if N_epochs > 1: + with np.errstate(invalid='ignore'): + diffs_differ = np.diff(t_sorted, axis=1) != 0 + col_idx = np.arange(N_epochs - 1) + diff_counts_valid = col_idx[np.newaxis, :] < (n_valid_per_star[:, np.newaxis] - 1) + n_unique_extra = (diffs_differ & diff_counts_valid).sum(axis=1) else: - t = np.ma.masked_invalid(self.meta['LIST_TIMES']) + n_unique_extra = np.zeros(N_stars, dtype=int) + n_fit = np.where(n_valid_per_star > 0, 1 + n_unique_extra, 0) + self['n_fit'] = n_fit - if mask_val: - t = np.ma.masked_values(t, mask_val) - if not np.ma.is_masked(t): - t.mask = np.zeros_like(t.data, dtype=bool) - - if mask_lists is not False: - # Remove a list - if isinstance(mask_lists, list): - if all(isinstance(item, int) for item in mask_lists): - t.mask[mask_lists] = True - - # Throw a warning if mask_lists is not a list - if not isinstance(mask_lists, list): - raise RuntimeError('mask_lists needs to be a list.') - - # For inconsistent masks, mask the star if any of the values are masked. - new_mask = np.logical_or.reduce((t.mask, x.mask, y.mask, xe.mask, ye.mask)) - # Figure out where we have detections (as indicated by error columns) - good = np.where((xe != 0) & (ye != 0) & - np.isfinite(xe) & np.isfinite(ye) & - np.isfinite(x) & np.isfinite(y) & ~new_mask)[0] - - N_good = len(good) - - # Catch the case where there is NO good data. - if N_good == 0: - return - # Everything below has N_good >= 1 - x = x[good] - y = y[good] - t = t[good] - xe = xe[good] - ye = ye[good] - - # slope, intercept - p0x = np.array([0., x.mean()]) - p0y = np.array([0., y.mean()]) - - # Unless t0 is fixed, calculate the t0 for the stars. - if fixed_t0 is False: - t_weight = 1.0 / np.hypot(xe, ye) - t0 = np.average(t, weights=t_weight) + ########################### + ####### Determine MM ###### + ########################### + if 'motion_model_input' in self.colnames: + # Determine which motion model to use based on motion_model_input column + # If n_fit < n_params for the input motion model, use the most complicated motion model with n_fit >= n_params + required_params = np.array([all_mm_map[mm_name].n_params for mm_name in self['motion_model_input']]) + reassign_mm = n_fit < required_params + + mm_digitized = np.digitize( + x=n_fit[reassign_mm], + bins=mm_n_params + ) - 1 # Convert to 0-based index + + # Assign motion models to stars + self['motion_model_used'] = self['motion_model_input'] + self['motion_model_used'][reassign_mm] = np.array([motion_models[d].name for d in mm_digitized], dtype=f'U{_MOTION_MODEL_NAME_WIDTH}') + else: - t0 = fixed_t0[ss] - dt = t - t0 - - self['t0'][ss] = t0 - self['n_vfit'][ss] = N_good - - # Catch the case where all the times are identical - if (dt == dt[0]).all(): - if weighting == 'var': - wgt_x = (1.0/xe)**2 - wgt_y = (1.0/ye)**2 - elif weighting == 'std': - wgt_x = 1./np.abs(xe) - wgt_y = 1./np.abs(ye) - - self['x0'][ss] = np.average(x, weights=wgt_x) - self['y0'][ss] = np.average(y, weights=wgt_y) - self['x0e'][ss] = np.sqrt(np.average((x - self['x0'][ss])**2, weights=wgt_x)) - self['y0e'][ss] = np.sqrt(np.average((y - self['y0'][ss])**2, weights=wgt_x)) - - self['vx'][ss] = 0.0 - self['vy'][ss] = 0.0 - self['vxe'][ss] = 0.0 - self['vye'][ss] = 0.0 + # If motion_model_input column is not provided, use the most complicated model in motion_models with n_fit >= n_params. + mm_digitized = np.digitize( + x=n_fit, + bins=mm_n_params + ) - 1 # Convert to 0-based index + + # Assign motion models to stars + self['motion_model_used'] = np.array([motion_models[d].name for d in mm_digitized], dtype=f'U{_MOTION_MODEL_NAME_WIDTH}') + + ############################ + # Prepare Fixed Parameters # + ############################ + # If required fixed params in self.meta or columns, but not provided in fixed_params_dict, add them to fixed_params_dict + motion_model_used = [all_mm_map[name] for name in np.unique(self['motion_model_used'])] + raise_key_error = False + missing_params = [] + for mm in motion_model_used: + # Check required fixed parameters + for param in mm.required_fixed_param_names: + # Check in the order of fixed_params_dict -> self columns -> self.meta + if param not in fixed_params_dict: + # If not provided in fixed_params_dict, it must be in table columns + if param in self.colnames: + fixed_params_dict[param] = self[param].data + elif param in self.meta: + # Check if the parameter is in self.meta + fixed_params_dict[param] = self.meta[param] + else: + raise_key_error = True + missing_params.append(f"'{param}'") + + # Check optional fixed parameters + # Set to default value if not provided in fixed_params_dict or in self + for param, value in mm.optional_fixed_params.items(): + if param not in fixed_params_dict: + # If param is not provided in fixed_params_dict + if param in self.colnames: + # Set to column value if column exists + fixed_params_dict[param] = self[param].data + elif param in self.meta: + # Check if the parameter is in self.meta + fixed_params_dict[param] = self.meta[param] + else: + # Set to default value if neither in columns nor provided in fixed_params_dict + fixed_params_dict[param] = value + self.meta[param] = value + + if raise_key_error: + raise KeyError(f"fit_motion_models: Missing required fixed parameter(s) for the motion models used: {', '.join(missing_params)}! Please provide them in fixed_params_dict, or as columns in the table, or as table metadata.") + + + # Prepare fixed_params_dict for each star + # This avoids checking types and slicing inside the fitting loop + # Identify array parameters (length N_stars) and scalar parameters + array_params = {k: v for k, v in fixed_params_dict.items() if np.ndim(v) > 0 and len(v) == N_stars} + scalar_params = {k: v for k, v in fixed_params_dict.items() if k not in array_params} + + # Convert any masked-array fixed params (e.g. the default t0, which + # comes out of np.average() as a masked array whenever xe/ye are + # masked) to plain arrays before the per-star dict construction + # below -- indexing a MaskedArray once per star goes through numpy.ma's + # much slower generic machinery vs. plain ndarray indexing. + array_params = {k: (np.ma.filled(v, np.nan) if np.ma.isMaskedArray(v) else v) for k, v in array_params.items()} + + # fixed_params_stars (one dict per star) is only actually needed by + # the per-star/multiprocessing fitting path below (bootstrap > 0) -- + # building it here for all N_stars unconditionally meant allocating a Python dict (plus + # boxed scalar values) per star even for the (often large) fraction + # handled entirely by the batched Fixed-model path, which never even + # looks at it. It's built lazily further down, once we know which + # stars actually need it (same idea as unmasked_idx below). + + + ############################ + ####### Prepare Table ###### + ############################ + # Fill table with all possible motion model parameter names as new columns. + new_col_list = motion_model.motion_model_param_names(motion_model_used, with_errors=True, with_fixed=False) + new_col_list += ['chi2_x', 'chi2_y', 'n_params'] + + if 't0' not in new_col_list: + new_col_list.append('t0') + + # Add new columns if they do not exist + for col in new_col_list: + if col in self.colnames: + # Keep old data if the column already exists + if keep_existing: + continue + else: + self.remove_column(col) - return + if col.endswith('_err'): + self.add_column( + Column(data=np.full(N_stars, np.inf, dtype=float), name=col), + rename_duplicate=True + ) + else: + self.add_column( + Column(data=np.full(N_stars, fill_value, dtype=float), name=col), + rename_duplicate=True + ) + + # Add fixed parameter meta if scalar, column if array. + fixed_param_names = [] + for mm in motion_model_used: + for param in mm.fixed_param_names: + if param not in fixed_param_names: + fixed_param_names.append(param) + # Remove t0 from fixed_param_names as it will be saved during fitting + if 't0' in fixed_param_names: + fixed_param_names.remove('t0') + + + for param in fixed_param_names: + # Equivalent to np.array([fps[param] for fps in fixed_params_stars]) + # from the (no-longer-built-eagerly) per-star dicts, without ever + # materializing them: every param here came from array_params or + # scalar_params above, so it's already exactly this column, or a + # single value to be broadcast to one. + if param in array_params: + coldata = np.asarray(array_params[param]) + else: + coldata = np.full(N_stars, scalar_params[param]) + + # Wherever the used values land, they have to be readable back + # under `param` itself. That is the name infer_positions and + # determine_motion_models search, so putting them anywhere else + # means the propagation runs with parameters this fit never saw -- + # either silently demoting the star to a simpler model (the name is + # missing entirely) or, worse, propagating the requested model with + # somebody else's values. + if param in self.colnames: + existing = self[param] + + # Skip if identical + same = ( + np.array_equal(existing, coldata) + if is_string_dtype(existing) + else np.allclose(existing, coldata, equal_nan=True) + ) + + if same: + continue - # Catch the case where we have enough measurements to actually - # fit a velocity! - if N_good > 2: - if weighting == 'var': - sigma_x = xe - sigma_y = ye - elif weighting == 'std': - sigma_x = np.abs(xe)**0.5 - sigma_y = np.abs(ye)**0.5 - - if use_scipy: - vx_opt, vx_cov = curve_fit(linear, dt, x, p0=p0x, sigma=sigma_x, absolute_sigma=absolute_sigma) - vy_opt, vy_cov = curve_fit(linear, dt, y, p0=p0y, sigma=sigma_y, absolute_sigma=absolute_sigma) - vx = vx_opt[0] - x0 = vx_opt[1] - vy = vy_opt[0] - y0 = vy_opt[1] - chi2_vx = calc_chi2(dt, x, sigma_x, *vx_opt) - chi2_vy = calc_chi2(dt, y, sigma_y, *vy_opt) - + # The column exists and disagrees with what the fit used -- + # fixed_params_dict wins the resolution order, so the fit used + # coldata. Keep the caller's values under `_orig`, the + # same convention align.py uses when it replaces x/y/m with + # transformed values, and write the used ones into `param`. + # + # Only on first write: a second fit with a different + # fixed_params_dict must not overwrite the caller's original + # with the previous fit's substitute. + orig_name = f'{param}_orig' + if orig_name not in self.colnames: + self.add_column(Column(data=np.array(existing), name=orig_name)) + + # replace_column rather than self[param] = coldata: assigning + # into an existing string column can truncate to its current + # itemsize, and obsLocation is a string parameter. + self.replace_column(param, Column(data=coldata, name=param)) + continue + + # No column of this name exists yet. + if len(np.unique(coldata)) == 1: + # Uniform across stars: one entry in meta rather than the same + # number repeated down every row. Safe only in this branch -- + # with a column of the same name present, meta would be + # shadowed by it in the resolution order and the value written + # here could never be read back. + self.meta[param] = coldata[0] else: - result_vx = linear_fit(dt, x, sigma_x, absolute_sigma=absolute_sigma) - result_vy = linear_fit(dt, y, sigma_y, absolute_sigma=absolute_sigma) - vx = result_vx['slope'] - x0 = result_vx['intercept'] - vy = result_vy['slope'] - y0 = result_vy['intercept'] - chi2_vx = result_vx['chi2'] - chi2_vy = result_vy['chi2'] - - self['vx'][ss] = vx - self['x0'][ss] = x0 - self['vy'][ss] = vy - self['y0'][ss] = y0 - self['chi2_vx'][ss] = chi2_vx - self['chi2_vy'][ss] = chi2_vy - - # Run the bootstrap - if bootstrap > 0: - edx = np.arange(N_good, dtype=int) - - vx_b = np.zeros(bootstrap, dtype=float) - x0_b = np.zeros(bootstrap, dtype=float) - vy_b = np.zeros(bootstrap, dtype=float) - y0_b = np.zeros(bootstrap, dtype=float) - - for bb in range(bootstrap): - bdx = np.random.choice(edx, N_good) - if weighting == 'var': - sigma_x_b = xe[bdx] - sigma_y_b = ye[bdx] - elif weighting == 'std': - sigma_x_b = xe[bdx]**0.5 - sigma_y_b = ye[bdx]**0.5 - - if use_scipy: - vx_opt_b, vx_cov_b = curve_fit(linear, dt[bdx], x[bdx], p0=vx_opt, sigma=sigma_x_b, - absolute_sigma=absolute_sigma) - vy_opt_b, vy_cov_b = curve_fit(linear, dt[bdx], y[bdx], p0=vy_opt, sigma=sigma_y_b, - absolute_sigma=absolute_sigma) - vx_b[bb] = vx_opt_b[0] - x0_b[bb] = vx_opt_b[1] - vy_b[bb] = vy_opt_b[0] - y0_b[bb] = vy_opt_b[1] - - else: - result_vx_b = linear_fit(dt[bdx], x[bdx], sigma=sigma_x_b, absolute_sigma=absolute_sigma) - result_vy_b = linear_fit(dt[bdx], y[bdx], sigma=sigma_y_b, absolute_sigma=absolute_sigma) - vx_b[bb] = result_vx_b['slope'] - x0_b[bb] = result_vx_b['intercept'] - vy_b[bb] = result_vy_b['slope'] - y0_b[bb] = result_vy_b['intercept'] - - # Save the errors from the bootstrap - self['vxe'][ss] = vx_b.std() - self['x0e'][ss] = x0_b.std() - self['vye'][ss] = vy_b.std() - self['y0e'][ss] = y0_b.std() - + # Per-star: has to be a column, under the canonical name. + self.add_column(Column(data=coldata, name=param)) + + # Add a column to keep track of the number of points used in a fit and number of bootstrap used. + self.meta['n_bootstrap'] = bootstrap + + # A star whose motion_model_used just changed to a simpler model + # (e.g. Linear -> Fixed, because it now matches fewer epochs than + # it used to) would otherwise keep whatever vx/vy (or other params + # its old, more complex model had) its previous fit wrote -- + # nothing rewrites those columns for this star since they aren't + # in its new model's fit_param_names. Reset any such leftover + # param to fill_value/inf for every star, based on its current + # motion_model_used, before the fitting loop below fills in the + # correct values for the params that DO belong to its model. + # Check against every motion model that could ever exist, not just + # ones assigned to a star this round -- a param column can still + # exist from an earlier call (e.g. 'vx' from a prior Linear fit) + # even if no star is currently classified as that model. + all_possible_params = set() + for mm in all_mm_map.values(): + all_possible_params.update(mm.fit_param_names) + for param_name in all_possible_params: + if param_name not in self.colnames: + continue + models_with_this_param = [mm.name for mm in all_mm_map.values() if param_name in mm.fit_param_names] + belongs = np.isin(self['motion_model_used'], models_with_this_param) + self[param_name][~belongs] = fill_value + err_name = param_name + '_err' + if err_name in self.colnames: + self[err_name][~belongs] = np.inf + + + ########################### + ######### FITTING ######### + ########################### + unique_motion_models, unique_inv_indices = np.unique(self['motion_model_used'], return_inverse=True) + if select_stars is not None: + select_stars = np.asarray(select_stars) + if select_stars.dtype == bool: + select_stars = np.flatnonzero(select_stars) else: - if use_scipy: - vxe, x0e = np.sqrt(vx_cov.diagonal()) - vye, y0e = np.sqrt(vy_cov.diagonal()) - else: - vxe = result_vx['e_slope'] - x0e = result_vx['e_intercept'] - vye = result_vy['e_slope'] - y0e = result_vy['e_intercept'] - - self['vxe'][ss] = vxe - self['x0e'][ss] = x0e - self['vye'][ss] = vye - self['y0e'][ss] = y0e - - elif N_good == 2: - # Not enough epochs to fit a velocity. - dx = np.diff(x)[0] - dy = np.diff(y)[0] - dt_diff = np.diff(dt)[0] - - if weighting == 'var': - sigma_x = 1./xe**2 - sigma_y = 1./ye**2 - elif weighting == 'std': - sigma_x = 1./np.abs(xe) - sigma_y = 1./np.abs(ye) - - self['x0'][ss] = np.average(x, weights=sigma_x) - self['y0'][ss] = np.average(y, weights=sigma_y) - self['x0e'][ss] = np.abs(dx) / 2**0.5 - self['y0e'][ss] = np.abs(dy) / 2**0.5 - self['vx'][ss] = dx / dt_diff - self['vy'][ss] = dy / dt_diff - self['vxe'][ss] = 0.0 - self['vye'][ss] = 0.0 - self['chi2_vx'][ss] = calc_chi2(dt, x, sigma_x, self['vx'][ss], self['x0'][ss]) - self['chi2_vy'][ss] = calc_chi2(dt, y, sigma_y, self['vy'][ss], self['y0'][ss]) - + select_stars = np.asarray(select_stars, dtype=int) + indices_by_motion_model = {key: np.intersect1d(select_stars, np.flatnonzero(unique_inv_indices == k)) for k, key in enumerate(unique_motion_models)} + else: + indices_by_motion_model = {key: np.flatnonzero(unique_inv_indices == k) for k, key in enumerate(unique_motion_models)} + + # Unmasked indices for each star -- but only for stars in groups that + # actually need the generic per-star path below. Every group goes + # through the batch fit() path unless bootstrap > 0 forces the + # per-star fallback (bootstrap resampling isn't vectorized across + # stars), so this is only ever non-empty in that case. Left as None + # for stars that don't need it; those entries are never looked up. + non_batch_star_idxs = list(indices_by_motion_model.values()) if bootstrap > 0 else [] + if non_batch_star_idxs: + non_batch_star_idxs = np.concatenate(non_batch_star_idxs) + unmasked_idx = [None] * N_stars + fixed_params_stars = [None] * N_stars + for i in non_batch_star_idxs: + unmasked_idx[i] = np.flatnonzero(valid_xy[i]) + fixed_params_stars[i] = {**scalar_params, **{k: v[i] for k, v in array_params.items()}} else: - # N_good == 1 case - self['n_vfit'][ss] = 1 - self['x0'][ss] = x - self['y0'][ss] = y - - if 'xe' in self.colnames: - self['x0e'] = xe - self['y0e'] = ye + unmasked_idx = None + fixed_params_stars = None + + # Plain (non-masked) views of the per-star arrays for the per-star + # extraction below. x_data/y_data/xe_data/ye_data need to stay + # numpy.ma arrays up to this point because valid_xy (and thus + # unmasked_idx) is derived from their masks -- but once we have + # unmasked_idx, indexing with it only ever touches already-known- + # valid entries, so the mask itself is no longer needed and plain + # ndarray indexing (via .data, a zero-copy view) is far cheaper than + # numpy.ma's per-element indexing machinery. Doing this extraction + # with the masked arrays directly was previously the single largest + # cost in this function for large tables (confirmed by profiling: + # tens of millions of numpy.ma.core.__getitem__ calls). + t_data_arr = np.asarray(t_data) + x_data_arr = x_data.data if np.ma.isMaskedArray(x_data) else np.asarray(x_data) + y_data_arr = y_data.data if np.ma.isMaskedArray(y_data) else np.asarray(y_data) + xe_data_arr = (xe_data.data if np.ma.isMaskedArray(xe_data) else np.asarray(xe_data)) if with_xe_ye else None + ye_data_arr = (ye_data.data if np.ma.isMaskedArray(ye_data) else np.asarray(ye_data)) if with_xe_ye else None + + # If multiprocessing, spawn ONE pool for the whole function (not one + # per motion-model group below), and hand each worker the shared + # per-star data arrays exactly once via the initializer. Each task + # then only needs to cross the process boundary with a star index + + # its small fixed_params_dict, and does its own (ragged -- stars + # have different numbers of valid epochs) data extraction locally, + # instead of the parent process pre-extracting a t_stars/x_stars/... + # slice for every single star up front and pickling all of it per task. + # Only actually pay for a multiprocessing Pool when there's enough + # per-star work to recoup its fixed cost (worker startup, pickling + # the shared data arrays to each worker) -- below mp_star_threshold, + # run serially in this process even if processes > 1 was requested. + pool = None + if processes > 1 and unmasked_idx is not None and len(non_batch_star_idxs) >= mp_star_threshold: + pool = Pool( + processes, + initializer=_fit_motion_models_init, + initargs=(t_data_arr, x_data_arr, y_data_arr, xe_data_arr, ye_data_arr, + unmasked_idx, input_mm_map, weighting, absolute_sigma, + fill_value, bootstrap, seed, verbose) + ) + + try: + # For each motion model + for unique_motion_model, unique_index in indices_by_motion_model.items(): + # Create motion model instance + motion_model_instance = input_mm_map[unique_motion_model]() + param_names = motion_model_instance.fit_param_names + # Initialize arrays to store results + n_stars_this_model = len(unique_index) + n_params = len(param_names) + + params_array = np.full((n_stars_this_model, n_params), fill_value, dtype=float) + param_errs_array = np.full((n_stars_this_model, n_params), np.inf, dtype=float) + chi2_x_array = np.full(n_stars_this_model, np.nan, dtype=float) + chi2_y_array = np.full(n_stars_this_model, np.nan, dtype=float) + + # For each star + if len(unique_index) > 0: + if bootstrap == 0: + # Every model's fit() accepts a 2D batch (many stars + # packed into one rectangular array) as well as a + # single star, and fits the whole subgroup in one + # vectorized pass instead of star-by-star. This matters + # even when the table isn't ALL Fixed (the align.py-level + # shortcut to combine_lists_xym only fires then): a large + # fraction of stars in a growing mosaic are often still + # Fixed regardless of what other stars need, and that + # fraction only shrinks as more epochs get added -- so + # without this, the (often huge) Fixed subset would + # keep paying the per-star loop/multiprocessing cost. + # Bootstrap resampling isn't vectorized here, so that + # case still falls through to the per-star path below. + if verbose: + print(f"Fitting {unique_motion_model} motion model: vectorized batch fit for {n_stars_this_model} star(s)") + n_epochs = t_data_arr.shape[1] + xe_batch = xe_data_arr[unique_index] if with_xe_ye else np.ones((n_stars_this_model, n_epochs)) + ye_batch = ye_data_arr[unique_index] if with_xe_ye else np.ones((n_stars_this_model, n_epochs)) + # Same {scalar params} + {array params sliced to this + # group} construction as fixed_params_stars above, but + # kept batched (not exploded into one dict per star) + # since fit() takes it once for the whole group. + fixed_params_batch = { + **scalar_params, + **{k: v[unique_index] for k, v in array_params.items()} + } + # No mask is passed -- fit()'s batch path derives + # validity from nan in x/y directly (x_data_arr/ + # y_data_arr already have real nan at every invalid + # cell, including mask_value/near-zero-error cells, + # which were explicitly nan-ed above for exactly this). + params_array, param_errs_array, chi2_x_array, chi2_y_array = motion_model_instance.fit( + t_data_arr[unique_index], x_data_arr[unique_index], y_data_arr[unique_index], + xe_batch, ye_batch, + fixed_params_dict=fixed_params_batch, + weighting=weighting, absolute_sigma=absolute_sigma, fill_value=fill_value, verbose=verbose + ) + + elif pool is not None: + # Use multiprocessing to fit stars in parallel + arguments = [(i_star, unique_motion_model, fixed_params_stars[i_star]) for i_star in unique_index] + + results = pool.starmap( + _fit_motion_models_worker, + tqdm( + arguments, + desc=f"Fitting {unique_motion_model} motion model with {processes} processes", + disable=not verbose + ), + chunksize=chunksize + ) + + for idx, (params, param_errs, chi2_x, chi2_y) in enumerate(results): + params_array[idx] = params + param_errs_array[idx] = param_errs + chi2_x_array[idx] = chi2_x + chi2_y_array[idx] = chi2_y + else: + # Prepare data as lists of arrays for faster access during fitting + t_stars = [t_data_arr[i][unmasked_idx[i]] for i in unique_index] + x_stars = [x_data_arr[i][unmasked_idx[i]] for i in unique_index] + y_stars = [y_data_arr[i][unmasked_idx[i]] for i in unique_index] + xe_stars = [xe_data_arr[i][unmasked_idx[i]] for i in unique_index] if with_xe_ye else [np.ones_like(x_star) for x_star in x_stars] + ye_stars = [ye_data_arr[i][unmasked_idx[i]] for i in unique_index] if with_xe_ye else [np.ones_like(y_star) for y_star in y_stars] + + # Expensive for loop! Prepare everything beforehand to speed up. + for idx, i_star in enumerate(tqdm(unique_index, disable=not verbose, desc=f"Fitting {unique_motion_model} motion model")): + # Fit the star + params, param_errs, chi2_x, chi2_y = motion_model_instance.fit( + t=t_stars[idx], + x=x_stars[idx], + y=y_stars[idx], + xe=xe_stars[idx], + ye=ye_stars[idx], + fixed_params_dict=fixed_params_stars[i_star], + weighting=weighting, + absolute_sigma=absolute_sigma, + fill_value=fill_value, + return_chi2=True, + bootstrap=bootstrap, + seed=seed, + verbose=verbose + ) + params_array[idx] = params + param_errs_array[idx] = param_errs + chi2_x_array[idx] = chi2_x + chi2_y_array[idx] = chi2_y + + # These stars were fit with a substituted unit error (sigma=1) + # rather than a real measurement uncertainty -- either the + # table has no xe/ye columns at all (xe_batch/xe_stars above + # fall back to np.ones), or this particular star's own xe/ye + # were invalid in every epoch (fill_with_one). Both cases are + # the same situation: no uncertainty information exists. + # + # With absolute_sigma=True the reported error is then purely + # propagated from that fabricated sigma -- e.g. exactly + # 1/sqrt(N_valid) for Fixed (0.577... for 3 epochs) -- a + # finite number that looks like a real uncertainty in the + # data's units but carries no measurement information at all, + # so report inf instead. With absolute_sigma=False the errors + # are rescaled by sqrt(chi2/dof), which cancels the fabricated + # sigma back out and leaves the epochs' own empirical scatter: + # a genuine, correctly-scaled uncertainty, so that one is kept. + if absolute_sigma: + if with_xe_ye: + no_real_err = fill_with_one[unique_index] + else: + no_real_err = np.ones(len(unique_index), dtype=bool) + if no_real_err.any(): + param_errs_array[no_real_err] = np.inf + + # Store results back to the table + for j, param_name in enumerate(param_names): + self[param_name][unique_index] = params_array[:, j] + self[param_name + '_err'][unique_index] = param_errs_array[:, j] + self['chi2_x'][unique_index] = chi2_x_array + self['chi2_y'][unique_index] = chi2_y_array + self['t0'][unique_index] = t0[unique_index] + finally: + if pool is not None: + pool.close() + pool.join() + + # Update n_params regardless of selections + for mm in motion_model_used: + self['n_params'][self['motion_model_used'] == mm.name] = mm.n_params return - - - def fit_velocities_all_detected(self, weighting='var', use_scipy=False, absolute_sigma=False, epoch_cols='all', mask_val=None, art_star=False, return_result=False): - """Fit velocities for stars detected in all epochs specified by epoch_cols. - Criterion: xe/ye error > 0 and finite, x/y not masked. + + def infer_positions(self, times, fixed_params_dict=None, fill_value=np.nan): + """Infer star positions at given times using fitted motion models. Parameters ---------- - weighting : str, optional - Variance weighting('var') or standard deviation weighting ('std'), by default 'var' - use_scipy : bool, optional - Use scipy.curve_fit or flystar.fit_velocity.fit_velocity, by default False - absolute_sigma : bool, optional - Absolute sigma or rescaled sigma, by default False - epoch_cols : str or list of intergers, optional - List of epoch column indices used for fitting velocity, by default 'all' - mask_val : float, optional - Values in x, y to be masked - art_star : bool, optional - Artificial star or observation star catalog. If artificial star, use 'det' column to select stars detected in all epochs, by default False - return_result : bool, optional - Return the velocity results or not, by default False - + times : scalar or array_like + Times at which to predict positions. The SHAPE decides whether the + times are shared across stars or are per-star -- nothing is + inferred from ``len(times)`` matching ``N_stars``: + + ====================== ========================================= + ``times`` meaning + ====================== ========================================= + scalar one time, every star + ``(N_times,)`` one shared grid, every star -- always, + even when ``N_times == N_stars`` + ``(1, N_times)`` the same, written explicitly + ``(N_stars, N_times)`` each star has its own times + ====================== ========================================= + + For one time per star, pass a column vector + ``times[:, np.newaxis]`` of shape ``(N_stars, 1)`` -- not a bare + 1D array. Note that propagating the whole table to a single new + epoch does NOT need that: pass the scalar epoch and a per-star + ``t0`` (table column or fixed_params_dict entry), and each star's + ``dt = t - t0[star]`` already differs. Any other shape raises + ``ValueError`` rather than being guessed at. See + :func:`~flystar.motion_model.broadcast_times`. + fixed_params_dict : None or dict, optional + Dictionary of fixed parameters to use for prediction. + Each parameter is resolved in the order fixed_params_dict -> + table column -> table metadata -> the model's default, so an + entry here overrides whatever the table carries rather than + being ignored in its favour. Anything absent from all four + raises KeyError if the model requires it, by default None + fill_value : float, optional + Value to use for missing data, by default np.nan + Returns ------- - vel_result : astropy Table - Astropy Table with velocity results + x, y, xe, ye : ndarray + Arrays of predicted x, y positions and their uncertainties xe, ye, with shape (N_stars, N_times) or (N_stars,) if N_times=1, or (N_times,) if N_stars=1, or scalar. """ - + # Which model moves each star is decided here rather than taken from a + # caller-supplied array: it is an internal detail, not something the + # user should have to compute. A 'motion_model_input' column is honored + # as an explicit per-star request, falling back -- per star -- to the + # most complex model that star's own parameters actually support, both + # when the column is absent and when a request cannot be evaluated + # (e.g. Acceleration requested but ax is nan for too few epochs). + # + # Deliberately NOT 'motion_model_used': that records which model was + # FIT, and a reference star imported from an external catalog can carry + # vx/vy/t0 that were never fit here and must still move with Linear. + # For a star that was fit, the two agree anyway -- the parameters + # outside its own model are nan, so they cannot be selected. + motion_model_used = motion_model.determine_motion_models( + self, motion_models=None, fixed_params_dict=fixed_params_dict + )[0] + N_stars = len(self) - - if epoch_cols == 'all': - epoch_cols = np.arange(np.shape(self['x'])[1]) - - # Artificial Star - if art_star: - detected_in_all_epochs = np.all(self['det'][:, epoch_cols], axis=1) - - # Observation Star + # Normalize to an explicit (N_stars, N_times) grid up front, so the + # shared-grid vs per-star distinction is settled by times' shape + # rather than re-derived (differently) inside each motion model. + # See motion_model.broadcast_times for the accepted shapes. + times_grid = motion_model.broadcast_times(times, N_stars, caller='infer_positions') + N_times = times_grid.shape[1] + + x_pred = np.full((N_stars, N_times), fill_value, dtype=float) + y_pred = np.full((N_stars, N_times), fill_value, dtype=float) + + # Only calculate xe ye if columns exist in table, otherwise fill with np.inf + if 'x0_err' in self.colnames and 'y0_err' in self.colnames: + # 'x0_err' and 'y0_err' are the common uncertainty params for all motion models + with_xe_ye = True + xe_pred = np.full((N_stars, N_times), np.inf, dtype=float) + ye_pred = np.full((N_stars, N_times), np.inf, dtype=float) else: - valid_xe = np.all(self['xe'][:, epoch_cols]!=0, axis=1) & np.all(np.isfinite(self['xe'][:, epoch_cols]), axis=1) - valid_ye = np.all(self['ye'][:, epoch_cols]!=0, axis=1) & np.all(np.isfinite(self['ye'][:, epoch_cols]), axis=1) - - if mask_val: - x = np.ma.masked_values(self['x'][:, epoch_cols], mask_val) - y = np.ma.masked_values(self['y'][:, epoch_cols], mask_val) - - # If no mask, convert x.mask to list - if not np.ma.is_masked(x): - x.mask = np.zeros_like(self['x'][:, epoch_cols].data, dtype=bool) - if not np.ma.is_masked(y): - y.mask = np.zeros_like(self['y'][:, epoch_cols].data, dtype=bool) - - valid_x = ~np.any(x.mask, axis=1) - valid_y = ~np.any(y.mask, axis=1) - detected_in_all_epochs = np.logical_and.reduce(( - valid_x, valid_y, valid_xe, valid_ye - )) + with_xe_ye = False + xe_pred = np.full((N_stars, N_times), np.inf, dtype=float) + ye_pred = np.full((N_stars, N_times), np.inf, dtype=float) + + # Calculate the dictionary of {motion_model: indices of stars with this motion model} for faster access during prediction + unique_motion_models, unique_inv_indices = np.unique(motion_model_used, return_inverse=True) + indices_by_motion_model = {key: np.flatnonzero(unique_inv_indices == k) for k, key in enumerate(unique_motion_models)} + + mm_map = motion_model.motion_model_map() + # Prepare fit_params, fixed_params, fit_param_errs for each star + for unique_motion_model, unique_index in indices_by_motion_model.items(): + # Create motion model instance + motion_model_instance = mm_map[unique_motion_model]() + # Prepare parameters for prediction. Empty has no fit params at + # all, and np.array([]).T would collapse to shape (0,) no matter + # how many stars this model covers -- atleast_2d then reads that + # back as a single star, and the (N_stars_this_model, N_times) + # time grid below no longer matches. Build the empty case with an + # explicit row per star so every model sees the same shape. + if len(motion_model_instance.fit_param_names) > 0: + fit_params = np.array([ + self[param_name][unique_index] for param_name in motion_model_instance.fit_param_names + ]).T # shape (N_stars_this_model, N_params) + + fit_param_errs = np.array([ + self[param_name + '_err'][unique_index] for param_name in motion_model_instance.fit_param_names + ]).T if with_xe_ye else None # shape (N_stars_this_model, N_params) else: - detected_in_all_epochs = np.logical_and(valid_xe, valid_ye) - - - # Fit velocities - vel_result = fit_velocity(self[detected_in_all_epochs], weighting=weighting, use_scipy=use_scipy, absolute_sigma=absolute_sigma, epoch_cols=epoch_cols, art_star=art_star) - vel_result = Table.from_pandas(vel_result) - - - # Add n_vfit - n_vfit = len(epoch_cols) - vel_result['n_vfit'] = n_vfit - - # Clean/remove up old arrays. - columns = [*vel_result.keys(), 'n_vfit'] - for column in columns: - if column in self.colnames: self.remove_column(column) - - # Update self - for column in columns: - column_array = np.ma.zeros(N_stars) - column_array[detected_in_all_epochs] = vel_result[column] - column_array[~detected_in_all_epochs] = np.nan - column_array.mask = ~detected_in_all_epochs - self[column] = column_array - - if return_result: - return vel_result + fit_params = np.empty((len(unique_index), 0)) + fit_param_errs = np.empty((len(unique_index), 0)) if with_xe_ye else None + + # Construct fixed_params: Look for fixed_params_dict -> table columns -> meta data -> default value + fixed_params = fixed_params_dict.copy() if fixed_params_dict is not None else {} + for param in motion_model_instance.required_fixed_param_names: + if param not in fixed_params: + # If required fixed param not provided, find it in the table columns or meta data + if param in self.colnames: + fixed_params[param] = self[param][unique_index] + elif param in self.meta: + fixed_params[param] = self.meta[param] + else: + raise KeyError(f"infer_positions: Required fixed parameter '{param}' not found for motion model '{unique_motion_model}'. Please provide it in fixed_params_dict, or add it as a column in the table, or add it to the meta data.") + else: + fixed_params[param] = fixed_params_dict[param] + + for param, default_value in motion_model_instance.optional_fixed_params.items(): + if param not in fixed_params: + # If optional fixed param not provided, find it in the table columns or meta data, otherwise use default value + if param in self.colnames: + if param == 'obsLocation': + # Special case for obsLocation: no vectorization implemented yet, use the value from the first star + assert np.unique(self[param][unique_index]).size == 1, \ + f"infer_positions: obsLocation fixed parameter has different values ({np.unique(self[param][unique_index])}) for different stars. Vectorized handling not implemented yet." + fixed_params[param] = self[param][unique_index] + elif param in self.meta: + fixed_params[param] = self.meta[param] + else: + fixed_params[param] = default_value + else: + fixed_params[param] = fixed_params_dict[param] + + + # Predict positions + # shape = (N_stars_this_model, N_times) or (N_stars_this_model,) if N_times=1 or (N_times,) if N_stars_this_model=1 or scalar + # Hand this model's stars their own rows of the time grid, so a + # per-star grid stays aligned with the per-star fit_params / + # fixed_params sliced by unique_index just above. + times_this_model = times_grid[unique_index] + if with_xe_ye: + x, y, xe, ye = motion_model_instance.model( + times_this_model, fit_params, fit_param_errs, fixed_params + ) + else: + x, y = motion_model_instance.model( + times_this_model, fit_params, fixed_params=fixed_params + ) + + if N_stars==1 and N_times > 1: + # Reshape (N_times,) to (1, N_times) + x = x[np.newaxis, :] + y = y[np.newaxis, :] + if with_xe_ye: + xe = xe[np.newaxis, :] + ye = ye[np.newaxis, :] + elif N_times==1 and N_stars > 1: + # Reshape (N_stars,) to (N_stars, 1) + x = x[:, np.newaxis] + y = y[:, np.newaxis] + if with_xe_ye: + xe = xe[:, np.newaxis] + ye = ye[:, np.newaxis] + + x_pred[unique_index] = x + y_pred[unique_index] = y + if with_xe_ye: + xe_pred[unique_index] = xe + ye_pred[unique_index] = ye + + if N_stars==1 or N_times==1: + # Reshape back to 1D array or scalar + x_pred = x_pred.flatten() + y_pred = y_pred.flatten() + if with_xe_ye: + xe_pred = xe_pred.flatten() + ye_pred = ye_pred.flatten() + + xe_pred = xe_pred if with_xe_ye else np.full_like(x_pred, np.inf) + ye_pred = ye_pred if with_xe_ye else np.full_like(y_pred, np.inf) + return x_pred, y_pred, xe_pred, ye_pred + + + # New function, to use in align + def get_star_positions_at_time(self, t, motion_model_dict, allow_alt_models=True): + """ Get current x,y positions of each star according to its motion_model + """ + # Start with empty arrays so we can fill them in batches + N_stars = len(self) + if hasattr(t, "__len__"): + x = np.full((N_stars,len(t)), np.nan, dtype=float) + y = np.full((N_stars,len(t)), np.nan, dtype=float) + xe = np.full((N_stars,len(t)), np.nan, dtype=float) + ye = np.full((N_stars,len(t)), np.nan, dtype=float) else: - return \ No newline at end of file + x = np.full(N_stars, np.nan, dtype=float) + y = np.full(N_stars, np.nan, dtype=float) + xe = np.full(N_stars, np.nan, dtype=float) + ye = np.full(N_stars, np.nan, dtype=float) + + # TODO: probably worth some additional testing here + # Check which motion models we need + # use complex_mms to collect models besides Fixed and Linear + unique_mms = np.unique(self['motion_model_input']).tolist() + # Calculate current position in batches by motion model + for mm in unique_mms: + try: + # Identify stars with this model & get class + idx = np.where(self['motion_model_input']==mm)[0] + mod = motion_model_dict[mm] + # Set up parameters + param_dict = {} + for par in mod.fit_param_names + mod.fixed_param_names + [pm+'_err' for pm in mod.fit_param_names]: + param_dict[par] = self[par][idx] + x[idx],y[idx],xe[idx],ye[idx] = mod.get_batch_pos_at_time(t,**param_dict) + except: + pass + if np.isnan(x).any() and allow_alt_models: + re_calc = np.where(np.isnan(x))[0] + unique_mms = np.unique(self['motion_model_used'][re_calc]).tolist() + # Calculate current position in batches by motion model + for mm in unique_mms: + # Identify stars with this model & get class + idx_0 = np.where(self['motion_model_used']==mm)[0] + idx = np.intersect1d(re_calc, idx_0) + mod = motion_model_dict[mm] + # Set up parameters + param_dict = {} + for par in motion_model.get_one_motion_model_param_names(mm,with_errors=True,with_fixed=True): + param_dict[par] = self[par][idx] + x[idx],y[idx],xe[idx],ye[idx] = mod.get_batch_pos_at_time(t,**param_dict) + + return x, y, xe, ye + + + + def shift_reference_frame(self, delta_vx=0.0, delta_vy=0.0, delta_pi=0.0, fixed_params_dict=None): + """ + After completing an alignment, shift from your relative reference frame to + the absolute frame using either Gaia or a Galactic model. This modified the + motion model fit parameters as well as the time series astrometry, assuming + zero error on the shift values. + + Parameters + ---------- + delta_vx : float, optional + velocity shift in x-direction (as/yr), by default 0.0. + delta_vy : float, optional + velocity shift in y-direction (as/yr), by default 0.0. + delta_pi : float, optional + parallax shift (as), by default 0.0. + fixed_params_dict : None or dict, optional + Dictionary of fixed parameters to use for prediction: ra, dec, obsLocation, specifically in this case, by default None. + """ + if delta_vx==0.0 and delta_vy==0.0 and delta_pi==0.0: + print("No shifts input, reference frame unchanged.") + print("Specify delta_vx, delta_vy, and/or delta_pi to perform a reference frame shift.") + return + self['vx'] += delta_vx + self['x'] += delta_vx*(self['t']-self['t0'][:, np.newaxis]) + self['vy'] += delta_vy + self['y'] += delta_vy*(self['t']-self['t0'][:, np.newaxis]) + if delta_pi!=0.0: + fixed_params_dict = {} if fixed_params_dict is None else fixed_params_dict + if 'ra' not in fixed_params_dict or 'dec' not in fixed_params_dict: + raise KeyError("shift_reference_frame: 'ra' and 'dec' must be provided in fixed_params_dict for parallax shift.") + from .motion_model import Parallax + ra = fixed_params_dict['ra'] + dec = fixed_params_dict['dec'] + pa = fixed_params_dict.get('pa', 0.0) + obsLocation = fixed_params_dict.get('obsLocation', 'earth') + t_all = self['t'][np.where(~np.any(np.isnan(self['t']), axis=1))[0][0]] + # Observation epochs come from UTC timestamps, so convert UTC -> TDB + # properly rather than relabelling the number. parallax_in_direction + # declares its input TDB without converting, so handing it a bare UTC + # MJD shifts every epoch by TDB-UTC (69.184 s as of 2026). + # + # UTC conversion consults the leap-second table, and ERFA warns + # ("dubious year") for epochs more than ~5 years past the table in the + # installed pyerfa -- around 2028 for 2.0.1.5. Only future epochs are + # affected; any real observation sits inside the table. + t_mjd = Time(t_all, format='decimalyear', scale='utc').tdb.mjd + pvec = Parallax().calc_parallax_vector(t_mjd, ra=ra, dec=dec, pa=pa, obsLocation=obsLocation) + self['pi'] += delta_pi + self['x'] += delta_pi*pvec[:, 0, :] # Shape (N_stars, N_times) + self['y'] += delta_pi*pvec[:, 1, :] # Shape (N_stars, N_times) + return + +def shift_reference_frame(table, delta_vx=0.0, delta_vy=0.0, delta_pi=0.0, fixed_params_dict=None): + """ + After completing an alignment, shift from your relative reference frame to + the absolute frame using either Gaia or a Galactic model. This modified the + motion model fit parameters as well as the time series astrometry, assuming + zero error on the shift values. + + Parameters + ---------- + delta_vx : float, optional + velocity shift in x-direction (as/yr), by default 0.0. + delta_vy : float, optional + velocity shift in y-direction (as/yr), by default 0.0. + delta_pi : float, optional + parallax shift (as), by default 0.0. + """ + if delta_vx==0.0 and delta_vy==0.0 and delta_pi==0.0: + print("No shifts input, reference frame unchanged.") + print("Specify delta_vx, delta_vy, and/or delta_pi to perform a reference frame shift.") + return + table['vx'] += delta_vx + table['x'] += delta_vx*(table['t']-table['t0'][:, np.newaxis]) + table['vy'] += delta_vy + table['y'] += delta_vy*(table['t']-table['t0'][:, np.newaxis]) + if delta_pi!=0.0: + from .motion_model import Parallax + fixed_params_dict = {} if fixed_params_dict is None else fixed_params_dict + if 'ra' not in fixed_params_dict or 'dec' not in fixed_params_dict: + raise KeyError("shift_reference_frame: 'ra' and 'dec' must be provided in fixed_params_dict for parallax shift.") + ra = fixed_params_dict['ra'] + dec = fixed_params_dict['dec'] + pa = fixed_params_dict.get('pa', 0.0) + obsLocation = fixed_params_dict.get('obsLocation', 'earth') + t_all = table['t'][np.where(~np.any(np.isnan(table['t']), axis=1))[0][0]] + t_mjd = Time(t_all, format='decimalyear', scale='utc').tdb.mjd + pvec = Parallax().calc_parallax_vector(t_mjd, ra=ra, dec=dec, pa=pa, obsLocation=obsLocation) + table['pi'] += delta_pi + table['x'] += delta_pi*pvec[:, 0, :] # Shape (N_stars, N_times) + table['y'] += delta_pi*pvec[:, 1, :] # Shape (N_stars, N_times) + return table + + +# Per-worker state for the fit_motion_models() process pool, set once by +# _fit_motion_models_init() when each worker starts. Using a Pool initializer +# instead of passing this data with every task means the (potentially large) +# shared arrays cross the process boundary once per worker, not once per star. +_fmm_worker_state = {} + + +def _fit_motion_models_init(t_data, x_data, y_data, xe_data, ye_data, unmasked_idx, + input_mm_map, weighting, absolute_sigma, + fill_value, bootstrap, seed, verbose): + """ + Pool initializer for fit_motion_models(). Stashes the per-star data + arrays (shared, read-only across all stars/tasks) as module-level state + in each worker process, so individual tasks only need to send a star + index and its small fixed_params_dict -- not a freshly-extracted slice + of every array -- to get fit. + """ + _fmm_worker_state.update( + t_data=t_data, x_data=x_data, y_data=y_data, xe_data=xe_data, ye_data=ye_data, + unmasked_idx=unmasked_idx, input_mm_map=input_mm_map, weighting=weighting, + absolute_sigma=absolute_sigma, + fill_value=fill_value, bootstrap=bootstrap, seed=seed, verbose=verbose, + ) + + +def _fit_motion_models_worker(i_star, motion_model_name, fixed_params_dict): + """ + Pool worker for fit_motion_models(). Slices out this one star's own + (ragged -- stars have different numbers of valid epochs) data from the + shared arrays stashed by _fit_motion_models_init(), then fits it. + """ + s = _fmm_worker_state + idx = s['unmasked_idx'][i_star] + t = np.array(s['t_data'][i_star][idx]) + x = np.array(s['x_data'][i_star][idx]) + y = np.array(s['y_data'][i_star][idx]) + if s['xe_data'] is not None: + xe = np.array(s['xe_data'][i_star][idx]) + ye = np.array(s['ye_data'][i_star][idx]) + else: + xe = np.ones_like(x) + ye = np.ones_like(y) + + motion_model_instance = s['input_mm_map'][motion_model_name]() + return motion_model_instance.fit( + t=t, x=x, y=y, xe=xe, ye=ye, + fixed_params_dict=fixed_params_dict, + weighting=s['weighting'], + absolute_sigma=s['absolute_sigma'], + fill_value=s['fill_value'], + return_chi2=True, + bootstrap=s['bootstrap'], + seed=s['seed'], + verbose=s['verbose'], + ) \ No newline at end of file diff --git a/flystar/stitch_method2.py b/flystar/stitch_method2.py index 8cab361..612c5bd 100644 --- a/flystar/stitch_method2.py +++ b/flystar/stitch_method2.py @@ -1,7 +1,7 @@ -from flystar import starlists,plots,match,align,analysis, transforms import numpy as np -from astropy.table import vstack, Table import pandas as pd +from astropy.table import Table +from flystar import starlists, match, align, transforms def align_starlists(starlist, ref, transModel=transforms.PolyTransform, order=2, N_loop=2, dr_tol=1.0, briteN=None, weights='both'): @@ -36,13 +36,13 @@ def align_starlists(starlist, ref, transModel=transforms.PolyTransform, order=2, weights: string (default='both') if weights=='both', we use both position error in transformed starlist and - reference starlist as uncertanty. And weights is the reciprocal of this uncertanty. + reference starlist as uncertanty. And weights is the reciprocal of this uncertanty. if weights=='starlist', we only use postion error in transformed starlist. if weights=='reference', we only use position error in reference starlist. if weights==None, we don't use weights. """ - + #-------------------------------------------------- # Initial transformation with brightest briteN stars #-------------------------------------------------- @@ -98,7 +98,7 @@ def weighted_mean(df,x,xe,frames_in_use): # error = xe or ye # all_frames = e.g. ['A', 'B', 'C', ...] - + cols_x=["{0}_{1}".format(x,f) for f in frames_in_use] # columns for x_* e.g. ['x_A', 'x_B', 'x_C', ....] cols_xe=["{0}_{1}".format(xe,f) for f in frames_in_use] # columns for xe_* e.g. ['xe_A', 'xe_B', 'xe_C', ....] @@ -120,11 +120,11 @@ def weighted_mean(df,x,xe,frames_in_use): xe_master.append(array_xe[i][mask][0]) else: rows_to_drop.append(i) - + df=df.drop(rows_to_drop) df[x]=np.array(x_master) df[xe]=np.array(xe_master) - + return df @@ -132,12 +132,12 @@ def normal_mean(df,x,frames_in_use): cols_x=["{0}_{1}".format(x,f) for f in frames_in_use] df[x]=df[cols_x].mean(axis=1) - + return df def stitch(all_starlists, name_initial_ref, N_iter=5, corr_thresh=0.8, outMaster='./master.lis'): - + # all_starslist: the list of the names of all starlists e.g. ['A', 'B', 'C', ... ] # name_initial_ref: the name of the reference that you use in the very first match. # corr_thresh : threshold for correlation values. @@ -149,11 +149,11 @@ def stitch(all_starlists, name_initial_ref, N_iter=5, corr_thresh=0.8, outMaste input_starslists.remove(name_initial_ref) for name_starlist in input_starslists: - + starlist=starlists.read_starlist('{0}.lis'.format(name_starlist)) if 'ref' not in locals(): ref=starlists.read_starlist('{0}.lis'.format(name_initial_ref)) - + #------------ Choose good stars to use for a trans object -------------------- @@ -162,14 +162,14 @@ def stitch(all_starlists, name_initial_ref, N_iter=5, corr_thresh=0.8, outMaste # Select the very first 11 columns (i.e. the master reference) consistent with those of the starlist. # Table -> dataframe -> Table, which lets us avoid the following error: 'MaskedColumn' object has no attribute '_mask' - + ref_for_align=ref_for_align.to_pandas() - + ref_for_align=Table.from_pandas(ref_for_align[starlist_for_align.colnames]) _,_,_,trans=align_starlists(starlist_for_align,ref_for_align,order=2,dr_tol=1,N_loop=15) - + #------------ Transform the whole starlist using the trans object and match with the reference ------------- starlist_transformed=align.transform_from_object(starlist,trans) @@ -183,7 +183,7 @@ def stitch(all_starlists, name_initial_ref, N_iter=5, corr_thresh=0.8, outMaste #-------------Convert the astropy talbes into dataframes --------------------- df_ref=ref.to_pandas() df_starlist_transformed=starlist_transformed.to_pandas() - + #-------------Columns 11-21 contain the measurments for the initial reference-------------- colnames=starlist.colnames @@ -199,11 +199,11 @@ def stitch(all_starlists, name_initial_ref, N_iter=5, corr_thresh=0.8, outMaste for col in colnames: df_ref['{0}_{1}'.format(col,name_starlist)]=np.nan df_ref.loc[idx_ref_matched,'{0}_{1}'.format(col,name_starlist)]= np.array(df_starlist_transformed.loc[idx_starlist_transformed_matched,col]) - + else: for col in colnames: - + df_ref.insert(len(df_ref.columns),'{0}_{1}'.format(col,name_starlist),np.nan) df_ref.loc[idx_ref_matched,'{0}_{1}'.format(col,name_starlist)]= np.array(df_starlist_transformed.loc[idx_starlist_transformed_matched,col]) @@ -218,7 +218,7 @@ def stitch(all_starlists, name_initial_ref, N_iter=5, corr_thresh=0.8, outMaste #-------------- Figure out which frames are currently included in the master frame ----------- frames_in_use=sorted(set([column[-1] for column in columns if (column[-1] in all_starlists)])) - + #-------------- Average the measurements ------------- for col in colnames: if (col!='name') and (col!='x') and (col!='y') and (col!='xe') and (col!='ye') and (col!='N_frames'): @@ -226,7 +226,7 @@ def stitch(all_starlists, name_initial_ref, N_iter=5, corr_thresh=0.8, outMaste df_comb=weighted_mean(df_comb,'x','xe',frames_in_use) df_comb=weighted_mean(df_comb,'y','ye',frames_in_use) - + #-------------Recalculate 'N_frames' for the master frame -> N_frames = the number of input starlists containing the star----------- # N_frames = the number of notnull columns at each row in the master frame divided by the number of columns in an input starlist, then minus one. # The "minus one" at the end accounts for the very first columns, i.e. master columns, that contain the averaged values of all the input starlists. @@ -244,7 +244,7 @@ def stitch(all_starlists, name_initial_ref, N_iter=5, corr_thresh=0.8, outMaste #-------------- Convert the final dataframe back into an astropy table ------ ref=Table.from_pandas(df_comb) - + ref.write(outMaster,format='ascii.commented_header', header_start=-1, overwrite=True) return diff --git a/flystar/template.py b/flystar/template.py index c714f9d..fd20b5e 100644 --- a/flystar/template.py +++ b/flystar/template.py @@ -1,35 +1,30 @@ -from flystar import match -from flystar import align -from flystar import starlists -from flystar import plots -from flystar import transforms -from astropy.table import Table -import numpy as np import pdb +import numpy as np +from flystar import align, starlists, plots, transforms -def align_template(labelFile, reference, transModel=transforms.PolyTransform, order=1, N_loop=2, +def align_template(labelFile, reference, transModel=transforms.PolyTransform, order=1, N_loop=2, dr_tol=1.0, dm_tol=None, briteN=100, weights='both', restrict=False, outFile='outTrans.txt'): """ Base example of how to use the flystar code. Assumes we are transforming a label.dat into a reference starlist. - + Parameters: ----------- labelFile: ascii file Starlist we would like to transform into the reference frame. For this code, we expect a label.dat file - + reference: ascii file Starlist that defines the reference frame - + transModel: transformation class (default: transforms.polyTransform) Defines which transformation model to use. Both the four-parameter and polynomial transformations are supported - + order: int (default=1) Order of the polynomial transformation. Only used for polynomial transform - + N_loop: int (default=2) How many times to iterate on the transformation calculation. Ideally, each iteration adds more stars and thus a better transform, to some @@ -39,14 +34,14 @@ def align_template(labelFile, reference, transModel=transforms.PolyTransform, or the distance tolerance for matching two stars in align.transform_and_match dm_tol: float (defalut=None) - the magnitude tolerance for matching two stars in align.trnasform_and_match + the magnitude tolerance for matching two stars in align.trnasform_and_match briteN: int (default=100) the number of stars used in blind matching - + weights: string (default='both') if weights=='both', we use both position error in transformed starlist and - reference starlist as uncertanty. And weights is the reciprocal of this uncertanty. + reference starlist as uncertanty. And weights is the reciprocal of this uncertanty. if weights=='starlist', we only use postion error in transformed starlist. if weights=='reference', we only use position error in reference starlist. if weights==None, we don't use weights. @@ -66,7 +61,7 @@ def align_template(labelFile, reference, transModel=transforms.PolyTransform, or tref = starlist['t'][0] # label.dat has position & position err and velocity & velocity error label = starlists.read_label(labelFile, prop_to_time=tref, flipX=True) - + #-------------------------------------------------- # Initial transformation with brightest briteN stars @@ -79,21 +74,21 @@ def align_template(labelFile, reference, transModel=transforms.PolyTransform, or # and calculate initial transform label_ini = label[idx_ini_label] starlist_ini = starlist[idx_ini_starlist] - + trans = align.initial_align(label_ini, starlist_ini, briteN=briteN, transformModel=transModel, order=order) - + # apply the initial transform to label.dat # this is used for future weights calculation label_trans_ini = align.transform_from_object(label, trans) - + #------------------------------------------------------------------------ # Use transformation to match starlists, then recalculate transformation. #------------------------------------------------------------------------ # Iterate on this as many times as desired for i in range(N_loop): - # apply the transformation to label.dat and + # apply the transformation to label.dat and # matched the transformed label with starlist. idx_label, idx_starlist = align.transform_and_match(label, starlist, trans, dr_tol=dr_tol, dm_tol=dm_tol) @@ -101,17 +96,17 @@ def align_template(labelFile, reference, transModel=transforms.PolyTransform, or if restrict: label_match = label[idx_label] starlist_match = starlist[idx_starlist] - idx_label, idx_starlist = stalists.restrict_by_use(label_match, starlist_match, + idx_label, idx_starlist = starlists.restrict_by_use(label_match, starlist_match, idx_label, idx_starlist) - + # use the matched stars to calculate new transformation label_match = label[idx_label] starlist_match = starlist[idx_starlist] label_ini_match = label_trans_ini[idx_label] - trans, N_trans = align.find_transform(label_match, label_ini_match, starlist_match, + trans, N_trans = align.find_transform(label_match, label_ini_match, starlist_match, transModel=transModel, order=order, weights = weights) - + #--------------------------------------------- # Write final transform in java align format @@ -121,7 +116,7 @@ def align_template(labelFile, reference, transModel=transforms.PolyTransform, or # write the transformation coefficients to 'outTrans.txt' align.write_transform(trans, labelFile, reference, N_trans, deltaMag=delta_m, restrict=restrict, weights=weights, outFile=outFile) - + #----------------------------------------------------------- # Test transform: apply to label.dat, make diagnostic plots @@ -129,11 +124,11 @@ def align_template(labelFile, reference, transModel=transforms.PolyTransform, or # apply the final transformation to label.dat label_trans = align.transform_from_object(label, trans) label_trans_match = label_trans[idx_label] - + # postion map with every star in starlist and transformed label. # both matched and unmatched stars. plots.trans_positions( starlist, starlist_match, label_trans, label_trans_match) - + # position difference histogram for matched stars. plots.pos_diff_hist( starlist_match, label_trans_match) @@ -146,6 +141,6 @@ def align_template(labelFile, reference, transModel=transforms.PolyTransform, or # quiver plot of postion residules plots.pos_diff_quiver( starlist_match, label_trans_match) - + return - + diff --git a/flystar/tests/compare_branches.py b/flystar/tests/compare_branches.py new file mode 100644 index 0000000..a493ae0 --- /dev/null +++ b/flystar/tests/compare_branches.py @@ -0,0 +1,45 @@ +import pickle +import flystar +import matplotlib.pyplot as plt +from flystar import align, transforms, motion_model +from flystar.plots import plot_stars + +branch = 'mm_rework_lingfeng' # 'mm_rework_lingfeng' or 'mm_rework' + +test_data_path = f'{flystar.__path__[0]}/tests/test_data' + +with open(f'{test_data_path}/my_gaia.pkl', 'rb') as f: + my_gaia = pickle.load(f) +with open(f'{test_data_path}/list_of_starlists.pkl', 'rb') as f: + list_of_starlists = pickle.load(f) +ra_deg, dec_deg = 18.0, -30.0 +my_gaia.remove_column('motion_model_used') +# my_gaia['motion_model_input'] = 'Fixed' +if branch == 'mm_rework_lingfeng': + msc = align.MosaicToRef(my_gaia, list_of_starlists, + dr_tol=[0.2], dm_tol=[5], + outlier_tol=[None], mag_lim=[6, 20], + trans_class=transforms.PolyTransform, + trans_args=[{'order': 1}], + motion_models=['Fixed', 'Parallax'], + fixed_params_dict = {'ra':ra_deg, 'dec':dec_deg, 'pa':0.0, 'obsLocation':'earth'}, + use_ref_new=True, + update_ref_orig=False, + mag_trans=True, + trans_weights='both,std', + init_guess_mode='name', verbose=3) +elif branch == 'mm_rework': + msc = align.MosaicToRef(my_gaia, list_of_starlists, + dr_tol=[0.2], dm_tol=[5], + outlier_tol=[None], mag_lim=[6, 20], + trans_class=transforms.PolyTransform, + trans_args=[{'order': 1}], + default_motion_model='Parallax', + motion_model_dict = {'Parallax': motion_model.Parallax(RA=ra_deg, Dec=dec_deg, PA=0.0, obsLocation='earth')}, + use_ref_new=True, + update_ref_orig=False, + mag_trans=True, + trans_weights='both,std', + init_guess_mode='name', verbose=3) + +msc.fit() \ No newline at end of file diff --git a/flystar/tests/test_align.ipynb b/flystar/tests/test_align.ipynb deleted file mode 100644 index 02442b9..0000000 --- a/flystar/tests/test_align.ipynb +++ /dev/null @@ -1,366 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Notebook for Running Align Tests" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "from flystar.tests import test_align\n", - "from flystar import starlists\n", - "from astropy.table import Table" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Test: make_fake_starlists_poly1_vel\n", - "\n", - "Just make sure the tables look sensible and are in the right units." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " name m0 m0e ... vye t0 \n", - "-------- ----------------- -------------------- ... ------------------- ------\n", - "star_155 9.106905292995506 0.054167528156861204 ... 0.1564397531527286 2019.5\n", - "star_113 9.153031462110043 0.0421090989942197 ... 0.08128628950126615 2019.5\n", - "star_077 9.16547870263162 0.02021147759307802 ... 0.05907352582911862 2019.5\n", - "star_069 9.169817788300977 0.027788213230369625 ... 0.04965351499764548 2019.5\n", - "star_037 9.173200786855755 0.007665400875860144 ... 0.22723357600795704 2019.5\n", - " name m me ... ye t \n", - "-------- ----------------- -------------------- ... -------------------- ------\n", - "star_155 9.198437965086988 0.054167528156861204 ... 0.02649499466969545 2018.5\n", - "star_113 9.257333243243941 0.0421090989942197 ... 0.02606700846524875 2018.5\n", - "star_077 9.252158908537464 0.02021147759307802 ... 0.04250920654497108 2018.5\n", - "star_069 9.267901667333167 0.027788213230369625 ... 0.042689240225924296 2018.5\n", - "star_037 9.276780126418494 0.007665400875860144 ... 0.03592203011554212 2018.5\n", - " name m me ... ye t \n", - "-------- ----------------- -------------------- ... -------------------- ------\n", - "star_155 9.478887659623185 0.054167528156861204 ... 0.02649499466969545 2019.5\n", - "star_113 9.569878576042546 0.0421090989942197 ... 0.02606700846524875 2019.5\n", - "star_077 9.575998150724095 0.02021147759307802 ... 0.04250920654497108 2019.5\n", - "star_069 9.593581807234129 0.027788213230369625 ... 0.042689240225924296 2019.5\n", - "star_037 9.553127108740597 0.007665400875860144 ... 0.03592203011554212 2019.5\n", - "['name', 'm0', 'm0e', 'x0', 'x0e', 'y0', 'y0e', 'vx', 'vxe', 'vy', 'vye', 't0']\n", - "['name', 'm', 'me', 'x', 'xe', 'y', 'ye', 't']\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/jlu/code/python/flystar/flystar/starlists.py:386: UserWarning: The StarList class requires a arguments('name', 'x', 'y', 'm')\n", - " warnings.warn(err_msg, UserWarning)\n" - ] - } - ], - "source": [ - "test_align.make_fake_starlists_poly1_vel()\n", - "\n", - "ref = Table.read('random_vel_ref.fits')\n", - "lis0 = Table.read('random_vel_0.fits')\n", - "lis1 = Table.read('random_vel_1.fits')\n", - "\n", - "print(ref[0:5])\n", - "print(lis0[0:5])\n", - "print(lis1[0:5])\n", - "\n", - "print(ref.colnames)\n", - "print(lis0.colnames)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## test_align_vel\n", - "\n", - "Make sure it runs, make some plots along the way, etc." - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/jlu/code/python/flystar/flystar/starlists.py:386: UserWarning: The StarList class requires a arguments('name', 'x', 'y', 'm')\n", - " warnings.warn(err_msg, UserWarning)\n", - "/Users/jlu/code/python/flystar/flystar/starlists.py:386: UserWarning: The StarList class requires a arguments('name', 'x', 'y', 'm')\n", - " warnings.warn(err_msg, UserWarning)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " \n", - "**********\n", - "**********\n", - "Starting iter 0 with ref_table shape: (200, 1)\n", - "**********\n", - "**********\n", - " \n", - " **********\n", - " Matching catalog 1 / 4 in iteration 0 with 200 stars\n", - " **********\n", - "initial_guess: 50 stars matched between starlist and reference list\n", - "initial_guess: [-6.05144456e+00 1.01098279e+00 -2.50608887e-04] [-1.07161761e+01 4.89226304e-05 1.01096529e+00]\n", - " Found 0 duplicates out of 196 matches\n", - "In Loop 0 found 196 matches\n", - " Found 0 duplicates out of 196 matches\n", - " \n", - " **********\n", - " Matching catalog 2 / 4 in iteration 0 with 200 stars\n", - " **********\n", - "initial_guess: 49 stars matched between starlist and reference list\n", - "initial_guess: [-1.02158015e+02 1.02080743e+00 -1.45081519e-04] [-5.07779471e+01 -2.60729494e-05 9.99423500e-01]\n", - " Found 0 duplicates out of 200 matches\n", - "In Loop 0 found 200 matches\n", - " Found 0 duplicates out of 200 matches\n", - " \n", - " **********\n", - " Matching catalog 3 / 4 in iteration 0 with 200 stars\n", - " **********\n", - "initial_guess: 50 stars matched between starlist and reference list\n", - "initial_guess: [-2.14220566e-10 1.00000000e+00 -2.24089697e-16] [2.50622339e-10 0.00000000e+00 1.00000000e+00]\n", - " Found 0 duplicates out of 200 matches\n", - "In Loop 0 found 200 matches\n", - " Found 0 duplicates out of 200 matches\n", - " \n", - " **********\n", - " Matching catalog 4 / 4 in iteration 0 with 200 stars\n", - " **********\n", - "initial_guess: 50 stars matched between starlist and reference list\n", - "initial_guess: [-2.57803428e+02 1.03052409e+00 -5.28390832e-05] [ 2.49886631e+02 -6.00884405e-05 9.98642952e-01]\n", - " Found 0 duplicates out of 200 matches\n", - "In Loop 0 found 200 matches\n", - " Found 0 duplicates out of 200 matches\n", - " \n", - "**********\n", - "**********\n", - "Starting iter 1 with ref_table shape: (204, 4)\n", - "**********\n", - "**********\n", - " \n", - " **********\n", - " Matching catalog 1 / 4 in iteration 1 with 200 stars\n", - " **********\n", - " Found 0 duplicates out of 199 matches\n", - "In Loop 1 found 199 matches\n", - " Found 0 duplicates out of 199 matches\n", - " \n", - " **********\n", - " Matching catalog 2 / 4 in iteration 1 with 200 stars\n", - " **********\n", - " Found 0 duplicates out of 198 matches\n", - "In Loop 1 found 198 matches\n", - " Found 0 duplicates out of 199 matches\n", - " \n", - " **********\n", - " Matching catalog 3 / 4 in iteration 1 with 200 stars\n", - " **********\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/jlu/code/python/flystar/flystar/starlists.py:386: UserWarning: The StarList class requires a arguments('name', 'x', 'y', 'm')\n", - " warnings.warn(err_msg, UserWarning)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Found 0 duplicates out of 200 matches\n", - "In Loop 1 found 200 matches\n", - " Found 0 duplicates out of 200 matches\n", - " \n", - " **********\n", - " Matching catalog 4 / 4 in iteration 1 with 200 stars\n", - " **********\n", - " Found 0 duplicates out of 200 matches\n", - "In Loop 1 found 200 matches\n", - " Found 0 duplicates out of 200 matches\n", - "**********\n", - "Final Matching\n", - "**********\n", - " Found 0 duplicates out of 199 matches\n", - "Matched 199 out of 200 stars in list 0\n", - " Found 0 duplicates out of 199 matches\n", - "Matched 199 out of 200 stars in list 1\n", - " Found 0 duplicates out of 200 matches\n", - "Matched 200 out of 200 stars in list 2\n", - " Found 0 duplicates out of 199 matches\n", - "Matched 199 out of 200 stars in list 3\n", - "\n", - " Preparing the reference table...\n" - ] - } - ], - "source": [ - "test_align.test_mosaic_lists_vel()" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "> /Users/jlu/code/python/flystar/flystar/align.py(3244)apply_mag_lim()\n", - "-> star_list_T.restrict_by_value(**conditions)\n" - ] - }, - { - "name": "stdin", - "output_type": "stream", - "text": [ - "(Pdb) conditions\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'m0_min': None, 'm0_max': None}\n" - ] - }, - { - "name": "stdin", - "output_type": "stream", - "text": [ - "(Pdb) type(star_list_T)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - }, - { - "name": "stdin", - "output_type": "stream", - "text": [ - "(Pdb) type(ref_list)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "*** NameError: name 'ref_list' is not defined\n" - ] - }, - { - "name": "stdin", - "output_type": "stream", - "text": [ - "(Pdb) ref_list\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "*** NameError: name 'ref_list' is not defined\n" - ] - }, - { - "name": "stdin", - "output_type": "stream", - "text": [ - "(Pdb) u\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "> /Users/jlu/code/python/flystar/flystar/align.py(991)mosaic_lists()\n", - "-> ref_list_T = apply_mag_lim(ref_list, mag_lim[ref_index])\n" - ] - }, - { - "name": "stdin", - "output_type": "stream", - "text": [ - "(Pdb) type(ref_list)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - }, - { - "name": "stdin", - "output_type": "stream", - "text": [ - "(Pdb) q\n" - ] - } - ], - "source": [ - "import pdb\n", - "pdb.pm()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.7" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/flystar/tests/test_align.py b/flystar/tests/test_align.py index 026a8b1..f62d581 100644 --- a/flystar/tests/test_align.py +++ b/flystar/tests/test_align.py @@ -1,246 +1,234 @@ -from flystar import align -from flystar import starlists -from flystar import startables -from flystar import transforms -from flystar import analysis -from astropy.table import Table -import numpy as np -import pylab as plt import pdb -import datetime +import pytest +import flystar +import numpy as np +import matplotlib.pyplot as plt +from astropy.table import Table +from flystar.plots import plot_stars +from flystar import align, starlists, transforms, motion_model +test_data_path = f'{flystar.__path__[0]}/tests/test_data' def test_MosaicSelfRef(): """ Cross-match and align 4 starlists using the OO version of mosaic lists. """ - list_files = ['A.lis', 'B.lis', 'C.lis', 'D.lis'] + list_files = [f'{test_data_path}/{f}' for f in ['A.lis', 'B.lis', 'C.lis', 'D.lis']] lists = [starlists.StarList.from_lis_file(lf) for lf in list_files] ########## # Test instantiation and basic fitting. ########## - msc = align.MosaicSelfRef(lists, ref_index=0, iters=2, + msc = align.MosaicSelfRef(lists, ref_index=0, dr_tol=[3, 3], dm_tol=[1, 1], trans_class=transforms.PolyTransform, verbose=False, trans_args={'order': 2}) msc.fit() - + # Check some of the output quantities on the final table. assert 'x0' in msc.ref_table.colnames - assert 'x0e' in msc.ref_table.colnames + assert 'x0_err' in msc.ref_table.colnames assert 'y0' in msc.ref_table.colnames - assert 'y0e' in msc.ref_table.colnames + assert 'y0_err' in msc.ref_table.colnames assert 'm0' in msc.ref_table.colnames - assert 'm0e' in msc.ref_table.colnames + assert 'm0_err' in msc.ref_table.colnames assert 'use_in_trans' in msc.ref_table.colnames assert 'used_in_trans' in msc.ref_table.colnames assert 'ref_orig' in msc.ref_table.colnames assert msc.ref_table['use_in_trans'].shape == msc.ref_table['x0'].shape assert msc.ref_table['used_in_trans'].shape == msc.ref_table['x'].shape - # Check that we have some matched stars... should be at least 35 stars # that are detected in all 4 starlists. idx = np.where(msc.ref_table['n_detect'] == 4)[0] - assert len(idx) > 35 + assert len(idx) > 35 # Check that the transformation error isn't too big - assert (msc.ref_table['x0e'] < 3.0).all() # less than 1 pix - assert (msc.ref_table['y0e'] < 3.0).all() - #assert (msc.ref_table['m0e'] < 1.0).all() # less than 0.5 mag - assert (msc.ref_table['m0e'] < 1.5).all() # less than 0.5 mag - + valid_err = np.isfinite(msc.ref_table['x0_err']) & np.isfinite(msc.ref_table['y0_err']) & np.isfinite(msc.ref_table['m0_err']) + assert (msc.ref_table['x0_err'][valid_err] < 3.0).all() # less than 1 pix + assert (msc.ref_table['y0_err'][valid_err] < 3.0).all() + #assert (msc.ref_table['m0_err'][valid_err] < 1.0).all() # less than 0.5 mag + assert (msc.ref_table['m0_err'][valid_err] < 1.5).all() # less than 0.5 mag # Check that the transformation lists aren't too wacky for ii in range(4): - np.testing.assert_almost_equal(msc.trans_list[ii].px.c1_0, 1.0, 2) - np.testing.assert_almost_equal(msc.trans_list[ii].py.c0_1, 1.0, 2) - + np.testing.assert_allclose(msc.trans_list[ii].px.c1_0, 1.0, rtol=1e-2) + np.testing.assert_allclose(msc.trans_list[ii].py.c0_1, 1.0, rtol=1e-2) # We didn't do any velocity fitting, so make sure nothing got created. assert 'vx' not in msc.ref_table.colnames assert 'vy' not in msc.ref_table.colnames - assert 'vxe' not in msc.ref_table.colnames - assert 'vye' not in msc.ref_table.colnames + assert 'vx_err' not in msc.ref_table.colnames + assert 'vy_err' not in msc.ref_table.colnames plt.clf() plt.plot(msc.ref_table['x'][:, 0], msc.ref_table['y'][:, 0], - 'k+', color='red', mec='red', mfc='none') + '+', color='red', mec='red', mfc='none') plt.plot(msc.ref_table['x'][:, 1], msc.ref_table['y'][:, 1], - 'kx', color='blue', mec='blue', mfc='none') + 'x', color='blue', mec='blue', mfc='none') plt.plot(msc.ref_table['x'][:, 2], msc.ref_table['y'][:, 2], - 'ko', color='cyan', mec='cyan', mfc='none') + 'o', color='cyan', mec='cyan', mfc='none') plt.plot(msc.ref_table['x'][:, 3], msc.ref_table['y'][:, 3], - 'k^', color='green', mec='green', mfc='none') + '^', color='green', mec='green', mfc='none') plt.plot(msc.ref_table['x0'], msc.ref_table['y0'], - 'k.', color='black', alpha=0.2) - + '.', color='black', alpha=0.2) return def test_MosaicSelfRef_vel_tconst(): """ Cross-match and align 4 starlists using the OO version of mosaic lists. The 4 lists are all taken at the same time (so 0 velocities should result). - + """ - list_files = ['A.lis', 'B.lis', 'C.lis', 'D.lis'] + list_files = [f'{test_data_path}/{f}' for f in ['A.lis', 'B.lis', 'C.lis', 'D.lis']] lists = [starlists.StarList.from_lis_file(lf) for lf in list_files] ########## # Test instantiation and basic fitting. # Note these star lists are ALL at the same date. ########## - msc = align.MosaicSelfRef(lists, ref_index=0, iters=2, + msc = align.MosaicSelfRef(lists, ref_index=0, dr_tol=[3, 3], dm_tol=[1, 1], trans_class=transforms.PolyTransform, - trans_args={'order': 2}, use_vel=True, + trans_args={'order': 2}, + motion_models=['Empty', 'Fixed', 'Linear'], verbose=False) msc.fit() - + # Check some of the output quantities on the final table. assert 'x0' in msc.ref_table.colnames - assert 'x0e' in msc.ref_table.colnames + assert 'x0_err' in msc.ref_table.colnames assert 'y0' in msc.ref_table.colnames - assert 'y0e' in msc.ref_table.colnames + assert 'y0_err' in msc.ref_table.colnames assert 'm0' in msc.ref_table.colnames - assert 'm0e' in msc.ref_table.colnames - assert 'vx' in msc.ref_table.colnames - assert 'vxe' in msc.ref_table.colnames - assert 'vy' in msc.ref_table.colnames - assert 'vye' in msc.ref_table.colnames + assert 'm0_err' in msc.ref_table.colnames + # Since they are in the same epoch, no velocity information can be inferred + # assert 'vx' in msc.ref_table.colnames + # assert 'vx_err' in msc.ref_table.colnames + # assert 'vy' in msc.ref_table.colnames + # assert 'vy_err' in msc.ref_table.colnames assert 't0' in msc.ref_table.colnames # Check that we have some matched stars... should be at least 35 stars # that are detected in all 4 starlists. idx = np.where(msc.ref_table['n_detect'] == 4)[0] - assert len(idx) > 35 + assert len(idx) > 35 # Check that the transformation error isn't too big - assert (msc.ref_table['x0e'] < 3.0).all() # less than 1 pix - assert (msc.ref_table['y0e'] < 3.0).all() - assert (msc.ref_table['m0e'] < 1.0).all() # less than 0.5 mag - + valid_err = np.isfinite(msc.ref_table['x0_err']) & np.isfinite(msc.ref_table['y0_err']) & np.isfinite(msc.ref_table['m0_err']) + assert (msc.ref_table['x0_err'][valid_err] < 3.0).all() # less than 1 pix + assert (msc.ref_table['y0_err'][valid_err] < 3.0).all() + # A star detected in only 1 epoch now correctly gets a finite m0_err + # from that single epoch's own 'me' (weighted average of 1 point) + # instead of being silently excluded via an inf from the unweighted + # fallback -- so its (legitimately large, single-detection) uncertainty + # is included here rather than skipped by the isfinite() filter above. + assert (msc.ref_table['m0_err'][valid_err] < 1.5).all() + # Check that the transformation lists aren't too wacky for ii in range(4): - np.testing.assert_almost_equal(msc.trans_list[ii].px.c1_0, 1.0, 2) - np.testing.assert_almost_equal(msc.trans_list[ii].py.c0_1, 1.0, 2) - - # Check that the velocities aren't crazy... - # they should be zero (since there is no time difference) - np.testing.assert_almost_equal(msc.ref_table['vx'], 0, 1) - np.testing.assert_almost_equal(msc.ref_table['vy'], 0, 1) - - assert (msc.ref_table['vx'] == 0).all() - assert (msc.ref_table['vy'] == 0).all() - assert (msc.ref_table['vxe'] == 0).all() - assert (msc.ref_table['vye'] == 0).all() - + np.testing.assert_allclose(msc.trans_list[ii].px.c1_0, 1.0, rtol=1e-2) + np.testing.assert_allclose(msc.trans_list[ii].py.c0_1, 1.0, rtol=1e-2) return def test_MosaicSelfRef_vel(): """ Cross-match and align 4 starlists using the OO version of mosaic lists. - """ - list_files = ['A.lis', 'B.lis', 'C.lis', 'D.lis'] + list_files = [f'{test_data_path}/{f}' for f in ['A.lis', 'B.lis', 'C.lis', 'D.lis']] lists = [starlists.StarList.from_lis_file(lf) for lf in list_files] # Modify the times so that we get velocities out. - lists[0].meta['list_time'] = 2001.4 + lists[0].meta['list_times'] = 2001.4 lists[0]['t'] = 2001.4 - - lists[1].meta['list_time'] = 2002.4 + + lists[1].meta['list_times'] = 2002.4 lists[1]['t'] = 2002.4 - - lists[2].meta['list_time'] = 2003.4 + + lists[2].meta['list_times'] = 2003.4 lists[2]['t'] = 2003.4 - - lists[3].meta['list_time'] = 2004.4 + + lists[3].meta['list_times'] = 2004.4 lists[3]['t'] = 2004.4 ########## # Test instantiation and basic fitting. ########## - msc = align.MosaicSelfRef(lists, ref_index=0, iters=3, - dr_tol=[5, 3, 3], dm_tol=[1, 1, 0.5], outlier_tol=None, + msc = align.MosaicSelfRef(lists, ref_index=0, + dr_tol=[5, 3, 3], dm_tol=[1, 1, 0.5], outlier_tol=None, briteN=30, trans_class=transforms.PolyTransform, - trans_args={'order': 2}, use_vel=True, + trans_args={'order': 2}, motion_models=['Empty', 'Fixed', 'Linear'], verbose=False) msc.fit() - + # Check some of the output quantities on the final table. assert 'x0' in msc.ref_table.colnames - assert 'x0e' in msc.ref_table.colnames + assert 'x0_err' in msc.ref_table.colnames assert 'y0' in msc.ref_table.colnames - assert 'y0e' in msc.ref_table.colnames + assert 'y0_err' in msc.ref_table.colnames assert 'm0' in msc.ref_table.colnames - assert 'm0e' in msc.ref_table.colnames + assert 'm0_err' in msc.ref_table.colnames assert 'vx' in msc.ref_table.colnames - assert 'vxe' in msc.ref_table.colnames + assert 'vx_err' in msc.ref_table.colnames assert 'vy' in msc.ref_table.colnames - assert 'vye' in msc.ref_table.colnames + assert 'vy_err' in msc.ref_table.colnames assert 't0' in msc.ref_table.colnames # Check that we have some matched stars... should be at least 35 stars # that are detected in all 4 starlists. idx = np.where(msc.ref_table['n_detect'] == 4)[0] - assert len(idx) > 35 + assert len(idx) >= 35, f"Expected at least 35 stars detected in all 4 starlists, but only found {len(idx)}" # Check that the transformation error isn't too big - assert (msc.ref_table['x0e'] < 3.0).all() # less than 1 pix - assert (msc.ref_table['y0e'] < 3.0).all() - assert (msc.ref_table['m0e'] < 1.0).all() # less than 0.5 mag - + valid_err = np.isfinite(msc.ref_table['x0_err']) & np.isfinite(msc.ref_table['y0_err']) & np.isfinite(msc.ref_table['m0_err']) + assert (msc.ref_table['x0_err'][valid_err] < 3.0).all() # less than 1 pix + assert (msc.ref_table['y0_err'][valid_err] < 3.0).all() + # A star detected in only 1 epoch now correctly gets a finite m0_err + # from that single epoch's own 'me' (weighted average of 1 point) + # instead of being silently excluded via an inf from the unweighted + # fallback -- so its (legitimately large, single-detection) uncertainty + # is included here rather than skipped by the isfinite() filter above. + assert (msc.ref_table['m0_err'][valid_err] < 1.5).all() + # Check that the transformation lists aren't too wacky for ii in range(4): - np.testing.assert_almost_equal(msc.trans_list[ii].px.c1_0, 1.0, 2) - np.testing.assert_almost_equal(msc.trans_list[ii].py.c0_1, 1.0, 2) + np.testing.assert_allclose(msc.trans_list[ii].px.c1_0, 1.0, rtol=2e-2) + np.testing.assert_allclose(msc.trans_list[ii].py.c0_1, 1.0, rtol=2e-2) - plt.clf() plt.plot(msc.ref_table['vx'], msc.ref_table['vy'], 'k.', color='black', alpha=0.2) + return def test_MosaicToRef(): - make_fake_starlists_poly1_vel(seed=42) - - ref_file = 'random_vel_ref.fits' - list_files = ['random_vel_0.fits', - 'random_vel_1.fits', - 'random_vel_2.fits', - 'random_vel_3.fits'] + make_fake_starlists_poly1(seed=42) - ref_list = Table.read(ref_file) + ref_file = f'{test_data_path}/random_ref.fits' + list_files = [f'{test_data_path}/random_{i}.fits' for i in range(8)] - # Convert velocities to arcsec/yr - ref_list['vx'] *= 1e-3 - ref_list['vy'] *= 1e-3 - ref_list['vxe'] *= 1e-3 - ref_list['vye'] *= 1e-3 + ref_list = Table.read(ref_file) # Switch our list to a "increasing to the West" list. ref_list['x0'] *= -1.0 - ref_list['vx'] *= -1.0 - + lists = [starlists.StarList.read(lf) for lf in list_files] - msc = align.MosaicToRef(ref_list, lists, iters=2, + msc = align.MosaicToRef(ref_list, lists, dr_tol=[0.2, 0.1], dm_tol=[1, 0.5], trans_class=transforms.PolyTransform, - trans_args={'order': 2}, use_vel=True, + trans_args={'order': 2}, motion_models=['Empty', 'Fixed'], update_ref_orig=False, verbose=False) msc.fit() @@ -252,250 +240,288 @@ def test_MosaicToRef(): assert msc.ref_table['use_in_trans'].shape == msc.ref_table['x0'].shape assert msc.ref_table['used_in_trans'].shape == msc.ref_table['x'].shape - # The velocities should be almost the same as the input + # The velocities should be almost the same as the input # velocities since update_ref_orig == False. - np.testing.assert_almost_equal(msc.ref_table['vx'], ref_list['vx'], 5) - np.testing.assert_almost_equal(msc.ref_table['vy'], ref_list['vy'], 5) - + np.testing.assert_allclose(msc.ref_table['x0'], ref_list['x0'], rtol=1e-5) + np.testing.assert_allclose(msc.ref_table['y0'], ref_list['y0'], rtol=1e-5) ########## - # Align and let velocities be free. + # Align and let velocities be free. ########## - msc.update_ref_orig = True + msc.update_ref_orig = 'periter' msc.fit() # The velocities should be almost the same (but not as close as before) # as the input velocities since update_ref == False. - np.testing.assert_almost_equal(msc.ref_table['vx'], ref_list['vx'], 1) - np.testing.assert_almost_equal(msc.ref_table['vy'], ref_list['vy'], 1) + np.testing.assert_allclose(msc.ref_table['x0'], ref_list['x0'], rtol=1e-1) + np.testing.assert_allclose(msc.ref_table['y0'], ref_list['y0'], rtol=1e-1) # Also double check that they aren't exactly the same for the reference stars. - assert np.any(np.not_equal(msc.ref_table['vx'], ref_list['vx'])) - - return msc - + assert np.not_equal(msc.ref_table['x0'], ref_list['x0']).all() + assert np.not_equal(msc.ref_table['y0'], ref_list['y0']).all() -def make_fake_starlists_shifts(): - N_stars = 200 - x = np.random.rand(N_stars) * 1000 - y = np.random.rand(N_stars) * 1000 - m = (np.random.rand(N_stars) * 8) + 9 - - sdx = np.argsort(m) - x = x[sdx] - y = y[sdx] - m = m[sdx] - - name = ['star_{0:03d}'.format(ii) for ii in range(N_stars)] + return - # Save original positions as reference (1st) list. - fmt = '{0:10s} {1:5.2f} 2015.0 {2:9.4f} {3:9.4f} 0 0 0 0\n' - _out = open('random_0.lis', 'w') - for ii in range(N_stars): - _out.write(fmt.format(name[ii], m[ii], x[ii], y[ii])) - _out.close() +def test_MosaicToRef_p0_vel(): + make_fake_starlists_poly0_vel(seed=42) + + ref_file = f'{test_data_path}/random_vel_ref.fits' + list_files = [f'{test_data_path}/random_vel_p0_{i}.fits' for i in range(4)] + + ref_list = Table.read(ref_file) + + # Convert velocities to arcsec/yr + ref_list['vx'] *= 1e-3 + ref_list['vy'] *= 1e-3 + ref_list['vx_err'] *= 1e-3 + ref_list['vy_err'] *= 1e-3 + + # Switch our list to a "increasing to the West" list. + ref_list['x0'] *= -1.0 + ref_list['vx'] *= -1.0 + + lists = [starlists.StarList.read(lf) for lf in list_files] + + msc = align.MosaicToRef(ref_list, lists, + dr_tol=[0.2, 0.1], dm_tol=[1, 0.5], + outlier_tol=[None, None], + trans_class=transforms.PolyTransform, + trans_args={'order': 1}, motion_models=['Empty', 'Fixed', 'Linear'], + update_ref_orig=False, verbose=False) + msc.fit() + # Check our status columns + assert 'use_in_trans' in msc.ref_table.colnames + assert 'used_in_trans' in msc.ref_table.colnames + assert 'ref_orig' in msc.ref_table.colnames + assert msc.ref_table['use_in_trans'].shape == msc.ref_table['x0'].shape + assert msc.ref_table['used_in_trans'].shape == msc.ref_table['x'].shape + + # The velocities should be almost the same as the input + # velocities since update_ref_orig == False. + assert (msc.ref_table['name']==ref_list['name']).all() + np.testing.assert_allclose(msc.ref_table['vx'], ref_list['vx'], rtol=1e-5) + np.testing.assert_allclose(msc.ref_table['vy'], ref_list['vy'], rtol=1e-5) ########## - # Shifts + # Align and let velocities be free. ########## - # Make 4 new starlists with different shifts. - shifts = [[ 6.5, 10.1], - [100.3, 50.5], - [-30.0,-100.7], - [250.0,-250.0]] + msc.update_ref_orig = 'periter' + msc.fit() - for ss in range(len(shifts)): - xnew = x - shifts[ss][0] - ynew = y - shifts[ss][1] + # The velocities should be almost the same (but not as close as before) + # as the input velocities since update_ref == True. + assert (msc.ref_table['name']==ref_list['name']).all() + np.testing.assert_allclose(msc.ref_table['vx'], ref_list['vx'], rtol=1e-1, atol=3e-4) + np.testing.assert_allclose(msc.ref_table['vy'], ref_list['vy'], rtol=1e-1, atol=3e-4) - # Perturb with small errors (0.1 pix) - xnew += np.random.randn(N_stars) * 0.1 - ynew += np.random.randn(N_stars) * 0.1 + # Also double check that they aren't exactly the same for the reference stars. + #assert np.any(np.not_equal(msc.ref_table['vx'], ref_list['vx'])) + assert np.not_equal(msc.ref_table['vx'], ref_list['vx']).any() - mnew = m + np.random.randn(N_stars) * 0.05 + return - _out = open('random_shift_{0:d}.lis'.format(ss+1), 'w') - for ii in range(N_stars): - _out.write(fmt.format(name[ii], mnew[ii], xnew[ii], ynew[ii])) - _out.close() +def test_MosaicToRef_vel(): + make_fake_starlists_poly1_vel(seed=42) - return shifts + ref_file = f'{test_data_path}/random_vel_ref.fits' + list_files = [f'{test_data_path}/random_vel_{i}.fits' for i in range(4)] -def make_fake_starlists_poly1(seed=-1): - # If seed >=0, then set random seed to that value - if seed >= 0: - np.random.seed(seed=seed) - - N_stars = 200 - x = np.random.rand(N_stars) * 1000 - y = np.random.rand(N_stars) * 1000 - m = (np.random.rand(N_stars) * 8) + 9 - - sdx = np.argsort(m) - x = x[sdx] - y = y[sdx] - m = m[sdx] - - name = ['star_{0:03d}'.format(ii) for ii in range(N_stars)] + ref_list = Table.read(ref_file) - # Save original positions as reference (1st) list. - fmt = '{0:10s} {1:5.2f} 2015.0 {2:9.4f} {3:9.4f} 0 0 0 0\n' - _out = open('random_0.lis', 'w') - for ii in range(N_stars): - _out.write(fmt.format(name[ii], m[ii], x[ii], y[ii])) - _out.close() + # Convert velocities to arcsec/yr + ref_list['vx'] *= 1e-3 + ref_list['vy'] *= 1e-3 + ref_list['vx_err'] *= 1e-3 + ref_list['vy_err'] *= 1e-3 + + # Switch our list to a "increasing to the West" list. + ref_list['x0'] *= -1.0 + ref_list['vx'] *= -1.0 + + lists = [starlists.StarList.read(lf) for lf in list_files] + + msc = align.MosaicToRef(ref_list, lists, + dr_tol=[0.2, 0.1], dm_tol=[1, 0.5], + outlier_tol=[None, None], + trans_class=transforms.PolyTransform, + trans_args={'order': 1}, motion_models=['Empty', 'Fixed', 'Linear'], + update_ref_orig=False, verbose=False) + msc.fit() + + # Check our status columns + assert 'use_in_trans' in msc.ref_table.colnames + assert 'used_in_trans' in msc.ref_table.colnames + assert 'ref_orig' in msc.ref_table.colnames + assert msc.ref_table['use_in_trans'].shape == msc.ref_table['x0'].shape + assert msc.ref_table['used_in_trans'].shape == msc.ref_table['x'].shape + # The velocities should be almost the same as the input + # velocities since update_ref_orig == False. + assert (msc.ref_table['name']==ref_list['name']).all() + np.testing.assert_allclose(msc.ref_table['vx'], ref_list['vx'], rtol=1e-5) + np.testing.assert_allclose(msc.ref_table['vy'], ref_list['vy'], rtol=1e-5) ########## - # Shifts + # Align and let velocities be free. ########## - # Make 4 new starlists with different shifts. - transforms = [[[ 6.5, 0.99, 1e-5], [ 10.1, 1e-5, 0.99]], - [[100.3, 0.98, 1e-5], [ 50.5, 9e-6, 1.001]], - [[-30.0, 1.00, 1e-5], [-100.7, 2e-5, 0.999]], - [[250.0, 0.97, 2e-5], [-250.0, 1e-5, 1.001]]] + msc.update_ref_orig = 'periter' + msc.fit() - for ss in range(len(shifts)): - #transforms.PolyTransform2D(1, transforms[ss]) - xnew = x - shifts[ss][0] - ynew = y - shifts[ss][1] + # The velocities should be almost the same (but not as close as before) + # as the input velocities since update_ref == True. + assert (msc.ref_table['name']==ref_list['name']).all() + np.testing.assert_allclose(msc.ref_table['vx'], ref_list['vx'], rtol=1e-1, atol=3e-4) + np.testing.assert_allclose(msc.ref_table['vy'], ref_list['vy'], rtol=1e-1, atol=3e-4) - # Perturb with small errors (0.1 pix) - xnew += np.random.randn(N_stars) * 0.1 - ynew += np.random.randn(N_stars) * 0.1 + # Also double check that they aren't exactly the same for the reference stars. + #assert np.any(np.not_equal(msc.ref_table['vx'], ref_list['vx'])) + assert np.not_equal(msc.ref_table['vx'], ref_list['vx']).any() - mnew = m + np.random.randn(N_stars) * 0.05 + return - _out = open('random_shift_{0:d}.lis'.format(ss+1), 'w') - for ii in range(N_stars): - _out.write(fmt.format(name[ii], mnew[ii], xnew[ii], ynew[ii])) - _out.close() +def test_MosaicToRef_acc(): + make_fake_starlists_poly1_acc(seed=42) - return shifts + ref_file = f'{test_data_path}/random_acc_ref.fits' + list_files = [f'{test_data_path}/random_acc_{i}.fits' for i in range(8)] + ref_list = Table.read(ref_file) -def make_fake_starlists_poly1_vel(seed=-1): - # If seed >=0, then set random seed to that value - if seed >= 0: - np.random.seed(seed=seed) - - N_stars = 200 + # Convert velocities to arcsec/yr + ref_list['vx0'] *= 1e-3 + ref_list['vy0'] *= 1e-3 + ref_list['vx0_err'] *= 1e-3 + ref_list['vy0_err'] *= 1e-3 - x0 = np.random.rand(N_stars) * 10.0 # arcsec (increasing to East) - y0 = np.random.rand(N_stars) * 10.0 # arcsec - x0e = np.random.randn(N_stars) * 5.0e-4 # arcsec - y0e = np.random.randn(N_stars) * 5.0e-4 # arcsec - vx = np.random.randn(N_stars) * 5.0 # mas / yr - vy = np.random.randn(N_stars) * 5.0 # mas / yr - vxe = np.random.randn(N_stars) * 0.1 # mas / yr - vye = np.random.randn(N_stars) * 0.1 # mas / yr - m0 = (np.random.rand(N_stars) * 8) + 9 # mag - m0e = np.random.randn(N_stars) * 0.05 # mag - t0 = np.ones(N_stars) * 2019.5 + # Convert accelerations to arcsec/yr**2 + ref_list['ax'] *= 1e-3 + ref_list['ay'] *= 1e-3 + ref_list['ax_err'] *= 1e-3 + ref_list['ay_err'] *= 1e-3 - # Make all the errors positive - x0e = np.abs(x0e) - y0e = np.abs(y0e) - m0e = np.abs(m0e) - vxe = np.abs(vxe) - vye = np.abs(vye) - - name = ['star_{0:03d}'.format(ii) for ii in range(N_stars)] + # Switch our list to a "increasing to the West" list. + ref_list['x0'] *= -1.0 + ref_list['vx0'] *= -1.0 + ref_list['ax'] *= -1.0 - # Make an StarList - lis = starlists.StarList([name, m0, m0e, x0, x0e, y0, y0e, vx, vxe, vy, vye, t0], - names = ('name', 'm0', 'm0e', 'x0', 'x0e', 'y0', 'y0e', - 'vx', 'vxe', 'vy', 'vye', 't0')) - - sdx = np.argsort(m0) - lis = lis[sdx] + lists = [starlists.StarList.read(lf) for lf in list_files] - # Save original positions as reference (1st) list - # in a StarList format (with velocities). - lis.write('random_vel_ref.fits', overwrite=True) - - ########## - # Propogate to new times and distort. - ########## - # Make 4 new starlists with different epochs and transformations. - times = [2018.5, 2019.5, 2020.5, 2021.5] - xy_trans = [[[ 6.5, 0.99, 1e-5], [ 10.1, 1e-5, 0.99]], - [[100.3, 0.98, 1e-5], [ 50.5, 9e-6, 1.001]], - [[ 0.0, 1.00, 0.0], [ 0.0, 0.0, 1.0]], - [[250.0, 0.97, 2e-5], [-250.0, 1e-5, 1.001]]] - mag_trans = [0.1, 0.4, 0.0, -0.3] + msc = align.MosaicToRef(ref_list, lists, + dr_tol=[0.4, 0.2], dm_tol=[1, 0.5], + trans_class=transforms.PolyTransform, + trans_args={'order': 2}, + motion_models=['Acceleration'], + update_ref_orig=False, verbose=False) - # Convert into pixels (undistorted) with the following info. - scale = 0.01 # arcsec / pix - shift = [1.0, 1.0] # pix - - for ss in range(len(times)): - dt = times[ss] - lis['t0'] - - x = lis['x0'] + (lis['vx']/1e3) * dt - y = lis['y0'] + (lis['vy']/1e3) * dt - t = np.ones(N_stars) * times[ss] + msc.fit() - # Convert into pixels - xp = (x / -scale) + shift[0] # -1 from switching to increasing to West (right) - yp = (y / scale) + shift[1] - xpe = lis['x0e'] / scale - ype = lis['y0e'] / scale + # Check our status columns + assert 'use_in_trans' in msc.ref_table.colnames + assert 'used_in_trans' in msc.ref_table.colnames + assert 'ref_orig' in msc.ref_table.colnames + assert msc.ref_table['use_in_trans'].shape == msc.ref_table['x0'].shape + assert msc.ref_table['used_in_trans'].shape == msc.ref_table['x'].shape - # Distort the positions - trans = transforms.PolyTransform(1, xy_trans[ss][0], xy_trans[ss][1], mag_offset=mag_trans[ss]) - xd, yd = trans.evaluate(xp, yp) - md = trans.evaluate_mag(lis['m0']) + # The velocities should be almost the same as the input + # velocities since update_ref_orig == False. + i_orig, i_fit = [],[] + for i,star in enumerate(ref_list["name"]): + if star in msc.ref_table["name"]: + i_fit.append(np.where(msc.ref_table["name"]==star)[0][0]) + i_orig.append(i) + np.testing.assert_allclose(msc.ref_table['ax'][i_fit], ref_list['ax'][i_orig], rtol=1e-5) + np.testing.assert_allclose(msc.ref_table['ay'][i_fit], ref_list['ay'][i_orig], rtol=1e-5) - # Perturb with small errors (0.1 pix) - xd += np.random.randn(N_stars) * 0.1 - yd += np.random.randn(N_stars) * 0.1 - md += np.random.randn(N_stars) * 0.02 - xde = xpe - yde = ype - mde = lis['m0e'] + ########## + # Align and let velocities be free. + ########## + msc.update_ref_orig = 'periter' + msc.fit() - # Save the new list as a starlist. - new_lis = starlists.StarList([lis['name'], md, mde, xd, xde, yd, yde, t], - names=('name', 'm', 'me', 'x', 'xe', 'y', 'ye', 't')) + # The velocities should be almost the same (but not as close as before) + # as the input velocities since update_ref == False. + i_orig, i_fit = [],[] + for i,star in enumerate(ref_list["name"]): + if star in msc.ref_table["name"]: + ix_fit = np.where(msc.ref_table["name"]==star)[0][0] + if ~np.isnan(msc.ref_table['ax'][ix_fit]): + i_orig.append(i) + i_fit.append(ix_fit) + # Accelerations all too small, rtol doesn't work well here. atol is + # loosened slightly beyond the fit noise floor (individual ax_err/ay_err + # are themselves ~2-3e-4 for the most weakly-constrained stars) since + # correctly weighting the magnitude combination by 'me' (rather than the + # previous unweighted average) nudges the mag-based transform fit enough + # to shift the most marginal star's acceleration by a comparable amount. + atol = 6e-4 + np.testing.assert_allclose(msc.ref_table['ax'][i_fit], ref_list['ax'][i_orig], atol=atol) + np.testing.assert_allclose(msc.ref_table['ay'][i_fit], ref_list['ay'][i_orig], atol=atol) + + ax_min = np.min(ref_list['ax'][i_orig]) + ax_max = np.max(ref_list['ax'][i_orig]) + ay_min = np.min(ref_list['ay'][i_orig]) + ay_max = np.max(ref_list['ay'][i_orig]) + + plt.clf() + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 5)) + ax1.plot(ref_list['ax'][i_orig], msc.ref_table['ax'][i_fit], '.') + ax1.plot([ax_min, ax_max], [ax_min, ax_max], color='C3') + ax1.plot([ax_min, ax_max], [ax_min - atol, ax_max - atol], ls='--', color='C3') + ax1.plot([ax_min, ax_max], [ax_min + atol, ax_max + atol], ls='--', color='C3') + ax1.set_xlabel('Input ax') + ax1.set_ylabel('Ref Table ax') + ax1.set_title('Acceleration in X') + + ax2.plot(ref_list['ay'][i_orig], msc.ref_table['ay'][i_fit], '.') + ax2.plot([ay_min, ay_max], [ay_min, ay_max], color='C3') + ax2.plot([ay_min, ay_max], [ay_min - atol, ay_max - atol], ls='--', color='C3') + ax2.plot([ay_min, ay_max], [ay_min + atol, ay_max + atol], ls='--', color='C3') + ax2.set_xlabel('Input ay') + ax2.set_ylabel('Ref Table ay') + ax2.set_title('Acceleration in Y') + plt.tight_layout() - new_lis.write('random_vel_{0:d}.fits'.format(ss), overwrite=True) + # Also double check that they aren't exactly the same for the reference stars. + assert np.any(np.not_equal(msc.ref_table['ax'][i_fit], ref_list['ax'][i_orig])) + return - return (xy_trans, mag_trans) - def test_MosaicToRef_hst_me(): """ - Test Casey's issue with 'me' not getting propogated + Test Casey's issue with 'me' not getting propogated from the input starlists to the output table. - Use data from MB10-364 microlensing target for the test. + Use data from MB10-364 microlensing target for the test. """ # Target RA and Dec (MOA data download) - ra = '17:57:05.401' - dec = '-34:27:05.01' - + # ra = '17:57:05.401' + # dec = '-34:27:05.01' + # Load up a Gaia catalog (queried around the RA/Dec above) - my_gaia = Table.read('mb10364_data/my_gaia.fits') + my_gaia = Table.read(f'{test_data_path}/mb10364_data/my_gaia.fits') my_gaia['me'] = 0.01 - + + my_gaia.rename_columns( + ['x0e', 'y0e', 'vxe', 'vye'], + ['x0_err', 'y0_err', 'vx_err', 'vy_err'] + ) # Gather the list of starlists. For first pass, don't modify the starlists. # Loop through the observations and read them in, in prep for alignment with Gaia epochs = [2011.83, 2012.73, 2013.81] - starlist_names = ['mb10364_data/2011_10_31_F606W_MATCHUP_XYMEEE_final.calib', - 'mb10364_data/2012_09_25_F606W_MATCHUP_XYMEEE_final.calib', - 'mb10364_data/2013_10_24_F606W_MATCHUP_XYMEEE_final.calib'] - + starlist_names = [f'{test_data_path}/mb10364_data/2011_10_31_F606W_MATCHUP_XYMEEE_final.calib', + f'{test_data_path}/mb10364_data/2012_09_25_F606W_MATCHUP_XYMEEE_final.calib', + f'{test_data_path}/mb10364_data/2013_10_24_F606W_MATCHUP_XYMEEE_final.calib'] + list_of_starlists = [] - + # Just using the F606W filters first. for ee in range(len(starlist_names)): lis = starlists.StarList.from_lis_file(starlist_names[ee]) - + # # Add additive error term. MAYBE YOU DON'T NEED THIS # lis['xe'] = np.hypot(lis['xe'], 0.01) # Adding 0.01 pix (0.1 mas) in quadrature. # lis['ye'] = np.hypot(lis['ye'], 0.01) - + lis['t'] = epochs[ee] # Lets dump the faint stars. @@ -503,65 +529,66 @@ def test_MosaicToRef_hst_me(): lis = lis[idx] list_of_starlists.append(lis) - - msc = align.MosaicToRef(my_gaia, list_of_starlists, iters=1, - dr_tol=[0.1], dm_tol=[5], - outlier_tol=[None], mag_lim=[13, 21], - trans_class=transforms.PolyTransform, - trans_args=[{'order': 1}], - use_vel=False, - use_ref_new=False, - update_ref_orig=False, - mag_trans=False, - weights='both,std', - init_guess_mode='miracle', verbose=False) - msc.fit() - tab = msc.ref_table - assert 'me' in tab.colnames + msc = align.MosaicToRef( + my_gaia, list_of_starlists, + dr_tol=[0.1], dm_tol=[5], + outlier_tol=[None], mag_lim=[13, 21], + trans_class=transforms.PolyTransform, + trans_args=[{'order': 1}], + motion_models=['Empty', 'Fixed'], + use_ref_new=False, + update_ref_orig=False, + mag_trans=False, + trans_weights='both,std', + init_guess_mode='miracle', + # save_path=f'{test_data_path}/mb10364_data/test_MosaicToRef_hst_me.pkl', + verbose=False + ) + msc.fit() + assert 'me' in msc.ref_table.colnames return def test_bootstrap(): """ - Test to make sure calc_bootstrap_error() call is working + Test to make sure calc_bootstrap_error() call is working properly (e.g., only called when user calls calc_bootstrap_error, n_boot param for calc_bootstrap_error only, boot_epochs_min working, etc.) """ # Read in starlists for MosaicToRef - ref = Table.read('ref_vel.lis', format='ascii') - list1 = Table.read('E.lis', format='ascii') - list2 = Table.read('F.lis', format='ascii') + ref = Table.read(f'{test_data_path}/ref_vel.lis', format='ascii') + list1 = Table.read(f'{test_data_path}/E.lis', format='ascii') + list2 = Table.read(f'{test_data_path}/F.lis', format='ascii') list1 = starlists.StarList.from_table(list1) list2 = starlists.StarList.from_table(list2) - + # Set parameters for alignment transModel = transforms.PolyTransform trans_args = {'order':2} - N_loop = 1 dr_tol = 0.08 dm_tol = 99 outlier_tol = None mag_lim = None ref_mag_lim = None - weights = 'both,var' + trans_weights = 'both,var' mag_trans = False n_boot = 15 boot_epochs_min=-1 # Run FLYSTAR, no bootstraps yet! - match1 = align.MosaicToRef(ref, [list1, list2], iters=N_loop, dr_tol=dr_tol, + match1 = align.MosaicToRef(ref, [list1, list2], dr_tol=dr_tol, dm_tol=dm_tol, outlier_tol=outlier_tol, trans_class=transModel, trans_args=trans_args, mag_trans=mag_trans, mag_lim=mag_lim, ref_mag_lim=ref_mag_lim, - weights=weights, - use_vel=True, + trans_weights=trans_weights, + motion_models=['Linear'], use_ref_new=False, update_ref_orig=False, init_guess_mode='name', @@ -575,28 +602,27 @@ def test_bootstrap(): assert 'vye_boot' not in match1.ref_table.keys() # Run bootstrap: no boot_epochs_min - match1.calc_bootstrap_errors(n_boot=n_boot, boot_epochs_min=boot_epochs_min) - + match1.calc_bootstrap_errors(n_boot=n_boot, boot_epochs_min=boot_epochs_min, seed=42) # Make sure columns exist, and none of them are nan values assert np.sum(np.isnan(match1.ref_table['xe_boot'])) == 0 assert np.sum(np.isnan(match1.ref_table['ye_boot'])) == 0 - assert np.sum(np.isnan(match1.ref_table['vxe_boot'])) == 0 - assert np.sum(np.isnan(match1.ref_table['vye_boot'])) == 0 + assert np.sum(np.isnan(match1.ref_table['vx_err_boot'])) == 0 + assert np.sum(np.isnan(match1.ref_table['vy_err_boot'])) == 0 # Test 2: make sure boot_epochs_min is working # Eliminate some rows to list2, so some stars are only in 1 epoch. # Rerun align. Some stars should only be detected in 1 epoch list3 = list2[0:60] - match2 = align.MosaicToRef(ref, [list1, list3], iters=N_loop, dr_tol=dr_tol, + match2 = align.MosaicToRef(ref, [list1, list3], dr_tol=dr_tol, dm_tol=dm_tol, outlier_tol=outlier_tol, trans_class=transModel, trans_args=trans_args, mag_trans=mag_trans, mag_lim=mag_lim, ref_mag_lim=ref_mag_lim, - weights=weights, - use_vel=True, + trans_weights=trans_weights, + motion_models=['Linear'], use_ref_new=False, update_ref_orig=False, init_guess_mode='name', @@ -605,7 +631,7 @@ def test_bootstrap(): # Now run_calc_bootstrap_error, with boot_epochs_min engaged boot_epochs_min2 = 2 - match2.calc_bootstrap_errors(n_boot=n_boot, boot_epochs_min=boot_epochs_min2) + match2.calc_bootstrap_errors(n_boot=n_boot, boot_epochs_min=boot_epochs_min2, seed=42) # Make sure boot_epochs_min cut worked as intended out = match2.ref_table @@ -618,63 +644,61 @@ def test_bootstrap(): assert len(good[0]) > 0 # For "good" stars: all bootstrap vals should be present - assert np.sum(np.isnan(out['xe_boot'][good])) == 0 - assert np.sum(np.isnan(out['ye_boot'][good])) == 0 - assert np.sum(np.isnan(out['vxe_boot'][good])) == 0 - assert np.sum(np.isnan(out['vye_boot'][good])) == 0 + assert np.sum(~np.isfinite(out['xe_boot'][good])) == 0 + assert np.sum(~np.isfinite(out['ye_boot'][good])) == 0 + assert np.sum(~np.isfinite(out['vx_err_boot'][good])) == 0 + assert np.sum(~np.isfinite(out['vy_err_boot'][good])) == 0 # For "bad" stars, all bootstrap vals should be nans assert np.sum(np.isfinite(out['xe_boot'][bad])) == 0 assert np.sum(np.isfinite(out['ye_boot'][bad])) == 0 - assert np.sum(np.isfinite(out['vxe_boot'][bad])) == 0 - assert np.sum(np.isfinite(out['vye_boot'][bad])) == 0 + assert np.sum(np.isfinite(out['vx_err_boot'][bad])) == 0 + assert np.sum(np.isfinite(out['vy_err_boot'][bad])) == 0 return def test_calc_vel_in_bootstrap(): """ Check calc_vel_in_bootstrap performance in calc_bootstrap_errors() - - Only calculate velocity bootstrap (e.g., bootstrap over epochs and + + Only calculate velocity bootstrap (e.g., bootstrap over epochs and calculating proper motions) if calc_vel_in_bootstrap=True. """ import copy - - # Define match parameters - ref = Table.read('ref_vel.lis', format='ascii') - list1 = Table.read('E.lis', format='ascii') - list2 = Table.read('F.lis', format='ascii') + # Define match parameters + ref = Table.read(f'{test_data_path}/ref_vel.lis', format='ascii') + list1 = Table.read(f'{test_data_path}/E.lis', format='ascii') + list2 = Table.read(f'{test_data_path}/F.lis', format='ascii') list1 = starlists.StarList.from_table(list1) list2 = starlists.StarList.from_table(list2) - + # Set parameters for alignment transModel = transforms.PolyTransform trans_args = {'order':2} - N_loop = 1 dr_tol = 0.08 dm_tol = 99 outlier_tol = None mag_lim = None ref_mag_lim = None - weights = 'both,var' + trans_weights = 'both,var' mag_trans = False n_boot = 15 boot_epochs_min=-1 # Run match - match = align.MosaicToRef(ref, [list1, list2], iters=N_loop, dr_tol=dr_tol, + match = align.MosaicToRef(ref, [list1, list2], dr_tol=dr_tol, dm_tol=dm_tol, outlier_tol=outlier_tol, trans_class=transModel, trans_args=trans_args, mag_trans=mag_trans, mag_lim=mag_lim, ref_mag_lim=ref_mag_lim, - weights=weights, - use_vel=True, + trans_weights=trans_weights, + motion_models=['Linear'], use_ref_new=False, update_ref_orig=False, init_guess_mode='name', @@ -688,69 +712,68 @@ def test_calc_vel_in_bootstrap(): # Run calc_bootstrap_error function with calc_vel_in_bootstrap=True. # Make sure bootstrap velocity errors are calculated and valid n_boot = 50 - match_vel.calc_bootstrap_errors(n_boot=n_boot, calc_vel_in_bootstrap=True) + match_vel.calc_bootstrap_errors(n_boot=n_boot, calc_vel_in_bootstrap=True, seed=42) assert 'xe_boot' in match_vel.ref_table.keys() assert np.sum(np.isnan(match_vel.ref_table['xe_boot'])) == 0 - assert 'vxe_boot' in match_vel.ref_table.keys() - assert np.sum(np.isnan(match_vel.ref_table['vxe_boot'])) == 0 + assert 'vx_err_boot' in match_vel.ref_table.keys() + assert np.sum(np.isnan(match_vel.ref_table['vx_err_boot'])) == 0 # Run without calc_vel_in_bootstrap, make sure velocities are NOT calculated - match.calc_bootstrap_errors(n_boot=n_boot, calc_vel_in_bootstrap=False) + match.calc_bootstrap_errors(n_boot=n_boot, calc_vel_in_bootstrap=False, seed=42) assert 'xe_boot' in match.ref_table.keys() assert np.sum(np.isnan(match.ref_table['xe_boot'])) == 0 - assert 'vxe_boot' not in match.ref_table.keys() - + assert 'vx_err_boot' not in match.ref_table.keys() + return def test_transform_xym(): """ Test to make sure transforms are being done to mags only - if mag_trans = True. This can cause subtle bugs + if mag_trans = True. This can cause subtle bugs otherwise """ #---Align 1: self.mag_Trans = False---# - ref = Table.read('ref_vel.lis', format='ascii') - list1 = Table.read('E.lis', format='ascii') - list2 = Table.read('F.lis', format='ascii') + ref = Table.read(f'{test_data_path}/ref_vel.lis', format='ascii') + list1 = Table.read(f'{test_data_path}/E.lis', format='ascii') + list2 = Table.read(f'{test_data_path}/F.lis', format='ascii') list1 = starlists.StarList.from_table(list1) list2 = starlists.StarList.from_table(list2) - + # Set parameters for alignment transModel = transforms.PolyTransform trans_args = {'order':2} - N_loop = 1 dr_tol = 0.08 dm_tol = 99 outlier_tol = None mag_lim = None ref_mag_lim = None - weights = 'both,var' + trans_weights = 'both,var' n_boot = 15 mag_trans = False # Run FLYSTAR, with bootstraps - match1 = align.MosaicToRef(ref, [list1, list2], iters=N_loop, dr_tol=dr_tol, + match1 = align.MosaicToRef(ref, [list1, list2], dr_tol=dr_tol, dm_tol=dm_tol, outlier_tol=outlier_tol, trans_class=transModel, trans_args=trans_args, mag_trans=mag_trans, mag_lim=mag_lim, ref_mag_lim=ref_mag_lim, - weights=weights, - use_vel=False, + trans_weights=trans_weights, + motion_models=['Fixed'], use_ref_new=False, update_ref_orig=False, init_guess_mode='name', verbose=False) match1.fit() - match1.calc_bootstrap_errors(n_boot=n_boot) + match1.calc_bootstrap_errors(n_boot=n_boot, seed=42) - # Make sure all transformations have mag_offset = 0 + # Make sure all transformations have mag_offset = 0 trans_list = match1.trans_list for ii in trans_list: @@ -759,7 +782,7 @@ def test_transform_xym(): # Check that no mag transformation has been applied to m col in ref_table tab1 = match1.ref_table assert np.all(tab1['m'] == tab1['m_orig']) - + # Check me_boost == 0 or really small (should be the case # since we don't transform mags) assert np.isclose(np.max(tab1['me_boot']), 0, rtol=10**-5) @@ -768,22 +791,22 @@ def test_transform_xym(): #---Align 2: self.mag_Trans = True---# # Repeat, this time with mag_trans = False mag_trans = True - match2 = align.MosaicToRef(ref, [list1, list2], iters=N_loop, dr_tol=dr_tol, + match2 = align.MosaicToRef(ref, [list1, list2], dr_tol=dr_tol, dm_tol=dm_tol, outlier_tol=outlier_tol, trans_class=transModel, trans_args=trans_args, mag_trans=mag_trans, mag_lim=mag_lim, ref_mag_lim=ref_mag_lim, - weights=weights, - use_vel=False, + trans_weights=trans_weights, + motion_models=['Fixed'], use_ref_new=False, update_ref_orig=False, init_guess_mode='name', verbose=False) match2.fit() - match2.calc_bootstrap_errors(n_boot=n_boot) + match2.calc_bootstrap_errors(n_boot=n_boot, seed=42) # Make sure all transformations have correct mag offset @@ -792,34 +815,33 @@ def test_transform_xym(): for ii in trans_list2: assert ii.mag_offset > 20 - # Make sure final table mags have transform applied (i.e, + # Make sure final table mags have transform applied (i.e, tab2 = match2.ref_table assert np.all(tab2['m'] != tab2['m_orig']) - + # Check me_boost > 0 assert np.min(tab2['me_boot']) > 10**-3 print('Done mag_trans = True case') - + return def test_MosaicToRef_mag_bug(): """ Bug found by Tuan Do on 2020-04-12. """ - make_fake_starlists_poly1_vel() + make_fake_starlists_poly1_vel(seed=42) - ref_list = starlists.StarList.from_lis_file('random_0.lis', error=False) + ref_list = starlists.StarList.read(f'{test_data_path}/random_vel_0.fits') lists = [ref_list] - msc = align.MosaicToRef(ref_list, lists, + msc = align.MosaicToRef(ref_list, lists, mag_trans=True, - iters=1, dr_tol=[0.2], dm_tol=[1], outlier_tol=None, trans_class=transforms.PolyTransform, trans_args=[{'order': 1}], - use_vel=False, + motion_models=['Fixed'], use_ref_new=False, update_ref_orig=False, verbose=True) @@ -836,58 +858,1377 @@ def test_MosaicToRef_mag_bug(): return -def test_masked_cols(): - """ - Test to make sure analysis.prepare_gaia_for_flystar - produces an astropy.table.Table, NOT a masked column - table. MosaicToRef cannot handle masked column tables. +def make_fake_starlists_shifts(): + N_stars = 200 + x = np.random.rand(N_stars) * 1000 + y = np.random.rand(N_stars) * 1000 + m = (np.random.rand(N_stars) * 8) + 9 + + sdx = np.argsort(m) + x = x[sdx] + y = y[sdx] + m = m[sdx] + + name = ['star_{0:03d}'.format(ii) for ii in range(N_stars)] + + # Save original positions as reference (1st) list. + fmt = '{0:10s} {1:5.2f} 2015.0 {2:9.4f} {3:9.4f} 0 0 0 0\n' + _out = open(f'{test_data_path}/random_0.lis', 'w') + for ii in range(N_stars): + _out.write(fmt.format(name[ii], m[ii], x[ii], y[ii])) + _out.close() + + + ########## + # Shifts + ########## + # Make 4 new starlists with different shifts. + shifts = [[ 6.5, 10.1], + [100.3, 50.5], + [-30.0,-100.7], + [250.0,-250.0]] + + for ss in range(len(shifts)): + xnew = x - shifts[ss][0] + ynew = y - shifts[ss][1] + + # Perturb with small errors (0.1 pix) + xnew += np.random.randn(N_stars) * 0.1 + ynew += np.random.randn(N_stars) * 0.1 + + mnew = m + np.random.randn(N_stars) * 0.05 + + _out = open(f'{test_data_path}/random_shift_{ss+1}.lis', 'w') + for ii in range(N_stars): + _out.write(fmt.format(name[ii], mnew[ii], xnew[ii], ynew[ii])) + _out.close() + + return shifts + +def make_fake_starlists_poly1(seed=-1): + # If seed >=0, then set random seed to that value + if seed >= 0: + np.random.seed(seed=seed) + + N_stars = 200 + + x0 = np.random.rand(N_stars) * 10.0 # arcsec (increasing to East) + y0 = np.random.rand(N_stars) * 10.0 # arcsec + x0e = np.random.randn(N_stars) * 5.0e-4 # arcsec + y0e = np.random.randn(N_stars) * 5.0e-4 # arcsec + m0 = (np.random.rand(N_stars) * 8) + 9 # mag + m0e = np.random.randn(N_stars) * 0.05 # mag + t0 = np.ones(N_stars) * 2019.5 + + # Make all the errors positive + x0e = np.abs(x0e) + y0e = np.abs(y0e) + m0e = np.abs(m0e) + + name = ['star_{0:03d}'.format(ii) for ii in range(N_stars)] + + # Make an StarList + lis = starlists.StarList([name, m0, m0e, x0, x0e, y0, y0e, t0], + names = ('name', 'm0', 'm0_err', 'x0', 'x0_err', 'y0', 'y0_err', 't0')) + + sdx = np.argsort(m0) + lis = lis[sdx] + + # Save original positions as reference (1st) list + # in a StarList format (with velocities). + lis.write(f'{test_data_path}/random_ref.fits', overwrite=True) + + ########## + # Shifts + ########## + # Make 4 new starlists with different shifts. + times = [2018.5, 2019.0, 2019.5, 2020.0, 2020.5, 2021.0, 2021.5, 2022.0] + xy_trans = [[[ 6.5, 0.99, 1e-5], [ 10.1, 1e-5, 0.99]], + [[100.3, 0.98, 1e-5], [ 50.5, 9e-6, 1.001]], + [[ 0.0, 1.00, 0.0], [ 0.0, 0.0, 1.0]], + [[250.0, 0.97, 2e-5], [-250.0, 1e-5, 1.001]], + [[ 50.0, 1.01, 1e-5], [ -31.0, 1e-5, 1.000]], + [[ 78.0, 0.98, 0.0 ], [ 45.0, 9e-6, 1.001]], + [[-13.0, 0.99, 1e-5], [ 150, 2e-5, 1.002]], + [[ 94.0, 1.00, 9e-6], [-182.0, 0.0, 0.99]]] + mag_trans = [0.1, 0.4, 0.0, -0.3, 0.2, 0.0, -0.1, -0.3] + + # Convert into pixels (undistorted) with the following info. + scale = 0.01 # arcsec / pix + shift = [1.0, 1.0] # pix + + for ss in range(len(times)): + dt = times[ss] - lis['t0'] + + x = lis['x0'] + y = lis['y0'] + t = np.ones(N_stars) * times[ss] + + # Convert into pixels + xp = (x / -scale) + shift[0] # -1 from switching to increasing to West (right) + yp = (y / scale) + shift[1] + xpe = lis['x0_err'] / scale + ype = lis['y0_err'] / scale + + # Distort the positions + trans = transforms.PolyTransform(1, xy_trans[ss][0], xy_trans[ss][1], mag_offset=mag_trans[ss]) + xd, yd = trans.evaluate(xp, yp) + md = trans.evaluate_mag(lis['m0']) + + # Perturb with small errors (0.1 pix) + xd += np.random.randn(N_stars) * 0.1 + yd += np.random.randn(N_stars) * 0.1 + md += np.random.randn(N_stars) * 0.02 + xde = xpe + yde = ype + mde = lis['m0_err'] + + # fig, ax = plt.subplots() + # ax.scatter(x0, y0, s=2, label='Reference') + # ax.scatter(xd, yd, s=2, label='Starlist') + # ax.set_xlabel('X (pix)') + # ax.set_ylabel('Y (pix)') + # ax.legend() + # plt.show() + + # Save the new list as a starlist. + new_lis = starlists.StarList([lis['name'], md, mde, xd, xde, yd, yde, t], + names=('name', 'm', 'me', 'x', 'xe', 'y', 'ye', 't')) + + new_lis.write(f'{test_data_path}/random_{ss}.fits', overwrite=True) + + return (xy_trans,mag_trans) + +def make_fake_starlists_poly0_vel(seed=-1): + # If seed >=0, then set random seed to that value + if seed >= 0: + np.random.seed(seed=seed) + + N_stars = 200 + + x0 = np.random.rand(N_stars) * 10.0 # arcsec (increasing to East) + y0 = np.random.rand(N_stars) * 10.0 # arcsec + x0e = np.ones(N_stars) * 1.0e-4 # arcsec + y0e = np.ones(N_stars) * 1.0e-4 # arcsec + vx = np.random.randn(N_stars) * 5.0 # mas / yr + vy = np.random.randn(N_stars) * 5.0 # mas / yr + vxe = np.ones(N_stars) * 0.05 # mas / yr + vye = np.ones(N_stars) * 0.05 # mas / yr + m0 = (np.random.rand(N_stars) * 8) + 9 # mag + m0e = np.random.randn(N_stars) * 0.05 # mag + t0 = np.ones(N_stars) * 2019.5 + + # Make all the errors positive + x0e = np.abs(x0e) + y0e = np.abs(y0e) + m0e = np.abs(m0e) + vxe = np.abs(vxe) + vye = np.abs(vye) + + name = [f'star_{ii:03d}' for ii in range(N_stars)] + + # Make an StarList + lis = starlists.StarList([name, m0, m0e, x0, x0e, y0, y0e, vx, vxe, vy, vye, t0], + names = ('name', 'm0', 'm0_err', 'x0', 'x0_err', 'y0', 'y0_err', + 'vx', 'vx_err', 'vy', 'vy_err', 't0')) + + sdx = np.argsort(m0) + lis = lis[sdx] + + # Save original positions as reference (1st) list + # in a StarList format (with velocities). + lis.write(f'{test_data_path}/random_vel_ref.fits', overwrite=True) + + ########## + # Propogate to new times and distort. + ########## + # Make 4 new starlists with different epochs and transformations. + times = [2018.5, 2019.0, 2019.5, 2020.0, 2020.5, 2021.0, 2021.5, 2022.0] + xy_trans = [[[ 6.5], [ 10.1]], + [[100.3], [ 50.5]], + [[ 0.0], [ 0.0]], + [[250.0], [-250.0]], + [[ 50.0], [ -31.0]], + [[ 78.0], [ 45.0]], + [[-13.0], [ 150]], + [[ 94.0], [-182.0]]] + mag_trans = [0.1, 0.4, 0.0, -0.3, 0.2, 0.0, -0.1, -0.3] + + # Convert into pixels (undistorted) with the following info. + scale = 0.01 # arcsec / pix + shift = [1.0, 1.0] # pix + + for ss in range(len(times)): + dt = times[ss] - lis['t0'] + + x = lis['x0'] + (lis['vx']/1e3) * dt + y = lis['y0'] + (lis['vy']/1e3) * dt + t = np.ones(N_stars) * times[ss] + + # Convert into pixels + xp = (x / -scale) + shift[0] # -1 from switching to increasing to West (right) + yp = (y / scale) + shift[1] + xpe = lis['x0_err'] / scale + ype = lis['y0_err'] / scale + + # Distort the positions + trans = transforms.PolyTransform(0, xy_trans[ss][0], xy_trans[ss][1], mag_offset=mag_trans[ss]) + xd, yd = trans.evaluate(xp, yp) + md = trans.evaluate_mag(lis['m0']) + + # Perturb with small errors (0.1 pix) + xd += np.random.randn(N_stars) * xpe + yd += np.random.randn(N_stars) * ype + md += np.random.randn(N_stars) * 0.02 + xde = xpe + yde = ype + mde = lis['m0_err'] + + # Save the new list as a starlist. + new_lis = starlists.StarList([lis['name'], md, mde, xd, xde, yd, yde, t], + names=('name', 'm', 'me', 'x', 'xe', 'y', 'ye', 't')) + + new_lis.write(f'{test_data_path}/random_vel_p0_{ss}.fits', overwrite=True) + + return (xy_trans, mag_trans) + + +def make_fake_starlists_poly1_vel(seed=-1): + # If seed >=0, then set random seed to that value + if seed >= 0: + np.random.seed(seed=seed) + + N_stars = 200 + + x0 = np.random.rand(N_stars) * 10.0 # arcsec (increasing to East) + y0 = np.random.rand(N_stars) * 10.0 # arcsec + x0e = np.ones(N_stars) * 1.0e-4 # arcsec + y0e = np.ones(N_stars) * 1.0e-4 # arcsec + vx = np.random.randn(N_stars) * 5.0 # mas / yr + vy = np.random.randn(N_stars) * 5.0 # mas / yr + vxe = np.ones(N_stars) * 0.05 # mas / yr + vye = np.ones(N_stars) * 0.05 # mas / yr + m0 = (np.random.rand(N_stars) * 8) + 9 # mag + m0e = np.random.randn(N_stars) * 0.05 # mag + t0 = np.ones(N_stars) * 2019.5 + + # Make all the errors positive + x0e = np.abs(x0e) + y0e = np.abs(y0e) + m0e = np.abs(m0e) + vxe = np.abs(vxe) + vye = np.abs(vye) + + name = [f'star_{ii:03d}' for ii in range(N_stars)] + + # Make an StarList + lis = starlists.StarList([name, m0, m0e, x0, x0e, y0, y0e, vx, vxe, vy, vye, t0], + names = ('name', 'm0', 'm0_err', 'x0', 'x0_err', 'y0', 'y0_err', + 'vx', 'vx_err', 'vy', 'vy_err', 't0')) + + sdx = np.argsort(m0) + lis = lis[sdx] + + # Save original positions as reference (1st) list + # in a StarList format (with velocities). + lis.write(f'{test_data_path}/random_vel_ref.fits', overwrite=True) + + ########## + # Propogate to new times and distort. + ########## + # Make 4 new starlists with different epochs and transformations. + times = [2018.5, 2019.0, 2019.5, 2020.0, 2020.5, 2021.0, 2021.5, 2022.0] + xy_trans = [[[ 6.5, 0.99, 1e-5], [ 10.1, 1e-5, 0.99]], + [[100.3, 0.98, 1e-5], [ 50.5, 9e-6, 1.001]], + [[ 0.0, 1.00, 0.0], [ 0.0, 0.0, 1.000]], + [[250.0, 1.01, 2e-5], [-250.0, 1e-5, 0.98]], + [[ 50.0, 1.01, 1e-5], [ -31.0, 1e-5, 1.000]], + [[ 78.0, 0.98, 0.0 ], [ 45.0, 9e-6, 1.001]], + [[-13.0, 0.99, 1e-5], [ 150, 2e-5, 1.002]], + [[ 94.0, 1.00, 9e-6], [-182.0, 0.0, 0.99]]] + mag_trans = [0.1, 0.4, 0.0, -0.3, 0.2, 0.0, -0.1, -0.3] - Also make sure this example works, since we use it for the examples - jupyter notebook. + # Convert into pixels (undistorted) with the following info. + scale = 0.01 # arcsec / pix + shift = [1.0, 1.0] # pix + + for ss in range(len(times)): + dt = times[ss] - lis['t0'] + + x = lis['x0'] + (lis['vx']/1e3) * dt + y = lis['y0'] + (lis['vy']/1e3) * dt + t = np.ones(N_stars) * times[ss] + + # Convert into pixels + xp = (x / -scale) + shift[0] # -1 from switching to increasing to West (right) + yp = (y / scale) + shift[1] + xpe = lis['x0_err'] / scale + ype = lis['y0_err'] / scale + + # Distort the positions + trans = transforms.PolyTransform(1, xy_trans[ss][0], xy_trans[ss][1], mag_offset=mag_trans[ss]) + xd, yd = trans.evaluate(xp, yp) + md = trans.evaluate_mag(lis['m0']) + + # Perturb with small errors (0.1 mas) + xd += np.random.randn(N_stars) * xpe + yd += np.random.randn(N_stars) * ype + md += np.random.randn(N_stars) * 0.02 + xde = xpe + yde = ype + mde = lis['m0_err'] + + # Save the new list as a starlist. + new_lis = starlists.StarList([lis['name'], md, mde, xd, xde, yd, yde, t], + names=('name', 'm', 'me', 'x', 'xe', 'y', 'ye', 't')) + + new_lis.write(f'{test_data_path}/random_vel_{ss}.fits', overwrite=True) + + return (xy_trans, mag_trans) + +def make_fake_starlists_poly1_acc(seed=-1): + # If seed >=0, then set random seed to that value + if seed >= 0: + np.random.seed(seed=seed) + + N_stars = 200 + + x0 = np.random.rand(N_stars) * 10.0 # arcsec (increasing to East) + y0 = np.random.rand(N_stars) * 10.0 # arcsec + x0e = np.ones(N_stars) * 1.0e-4 # arcsec + y0e = np.ones(N_stars) * 1.0e-4 # arcsec + vx = np.random.randn(N_stars) * 5.0 # mas / yr + vy = np.random.randn(N_stars) * 5.0 # mas / yr + vxe = np.ones(N_stars) * 0.1 # mas / yr + vye = np.ones(N_stars) * 0.1 # mas / yr + ax = np.random.randn(N_stars) * 0.5 # mas / yr^2 + ay = np.random.randn(N_stars) * 0.5 # mas / yr^2 + axe = np.ones(N_stars) * 0.01 # mas / yr^2 + aye = np.ones(N_stars) * 0.01 # mas / yr^2 + m0 = (np.random.rand(N_stars) * 8) + 9 # mag + m0e = np.random.randn(N_stars) * 0.05 # mag + t0 = np.ones(N_stars) * 2019.5 + + # Make all the errors positive + x0e = np.abs(x0e) + y0e = np.abs(y0e) + m0e = np.abs(m0e) + vxe = np.abs(vxe) + vye = np.abs(vye) + axe = np.abs(axe) + aye = np.abs(aye) + + name = ['star_{0:03d}'.format(ii) for ii in range(N_stars)] + + # Make an StarList + lis = starlists.StarList([name, m0, m0e, + x0, x0e, y0, y0e, + vx, vxe, vy, vye, + ax, axe, ay, aye, + t0], + names = ('name', 'm0', 'm0_err', + 'x0', 'x0_err', 'y0', 'y0_err', + 'vx0', 'vx0_err', 'vy0', 'vy0_err', + 'ax', 'ax_err', 'ay', 'ay_err', + 't0')) + + sdx = np.argsort(m0) + lis = lis[sdx] + + # Save original positions as reference (1st) list + # in a StarList format (with velocities). + lis.write(f'{test_data_path}/random_acc_ref.fits', overwrite=True) + + ########## + # Propogate to new times and distort. + ########## + # Make 4 new starlists with different epochs and transformations. + times = [2018.5, 2019.0, 2019.5, 2020.0, 2020.5, 2021.0, 2021.5, 2022.0] + xy_trans = [[[ 6.5, 0.99, 1e-5], [ 10.1, 1e-5, 0.99]], + [[100.3, 0.98, 1e-5], [ 50.5, 9e-6, 1.001]], + [[ 0.0, 1.00, 0.0], [ 0.0, 0.0, 1.000]], + [[250.0, 0.97, 2e-5], [-250.0, 1e-5, 1.001]], + [[ 50.0, 1.01, 1e-5], [ -31.0, 1e-5, 1.000]], + [[ 78.0, 0.98, 0.0 ], [ 45.0, 9e-6, 1.001]], + [[-13.0, 0.99, 1e-5], [ 150, 2e-5, 1.002]], + [[ 94.0, 1.00, 9e-6], [-182.0, 0.0, 0.99]]] + mag_trans = [0.1, 0.4, 0.0, -0.3, 0.2, 0.0, -0.1, -0.3] + + # Convert into pixels (undistorted) with the following info. + scale = 0.01 # arcsec / pix + shift = [1.0, 1.0] # pix + + for ss in range(len(times)): + dt = times[ss] - lis['t0'] + + x = lis['x0'] + (lis['vx0']/1e3) * dt + 0.5*(lis['ax']/1e3) * dt**2 + y = lis['y0'] + (lis['vy0']/1e3) * dt + 0.5*(lis['ay']/1e3) * dt**2 + t = np.ones(N_stars) * times[ss] + + # Convert into pixels + xp = (x / -scale) + shift[0] # -1 from switching to increasing to West (right) + yp = (y / scale) + shift[1] + xpe = lis['x0_err'] / scale + ype = lis['y0_err'] / scale + + # Distort the positions + trans = transforms.PolyTransform(1, xy_trans[ss][0], xy_trans[ss][1], mag_offset=mag_trans[ss]) + xd, yd = trans.evaluate(xp, yp) + md = trans.evaluate_mag(lis['m0']) + + # Perturb with small errors (0.1 pix) + xd += np.random.randn(N_stars) * xpe + yd += np.random.randn(N_stars) * ype + md += np.random.randn(N_stars) * 0.02 + xde = xpe + yde = ype + mde = lis['m0_err'] + + # Save the new list as a starlist. + new_lis = starlists.StarList([lis['name'], md, mde, xd, xde, yd, yde, t], + names=('name', 'm', 'me', 'x', 'xe', 'y', 'ye', 't')) + + new_lis.write(f'{test_data_path}/random_acc_{ss}.fits', overwrite=True) + + return (xy_trans, mag_trans) + +def make_fake_starlists_poly1_par(seed=-1): + # If seed >=0, then set random seed to that value + if seed >= 0: + np.random.seed(seed=seed) + + N_stars = 200 + + x0 = np.random.rand(N_stars) * 10.0 # arcsec (increasing to East) + y0 = np.random.rand(N_stars) * 10.0 # arcsec + x0e = np.random.randn(N_stars) * 5.0e-4 # arcsec + y0e = np.random.randn(N_stars) * 5.0e-4 # arcsec + vx = np.random.randn(N_stars) * 5.0 # mas / yr + vy = np.random.randn(N_stars) * 5.0 # mas / yr + vxe = np.random.randn(N_stars) * 0.1 # mas / yr + vye = np.random.randn(N_stars) * 0.1 # mas / yr + pi = np.random.randn(N_stars) * 0.5 # mas + pie = np.random.randn(N_stars) * 0.01 # mas + m0 = (np.random.rand(N_stars) * 8) + 9 # mag + m0e = np.random.randn(N_stars) * 0.05 # mag + t0 = np.ones(N_stars) * 2019.5 + + # Make all the errors positive + x0e = np.abs(x0e) + y0e = np.abs(y0e) + m0e = np.abs(m0e) + vxe = np.abs(vxe) + vye = np.abs(vye) + pie = np.abs(pie) + + name = ['star_{0:03d}'.format(ii) for ii in range(N_stars)] + + # Make an StarList + lis = starlists.StarList([name, m0, m0e, + x0, x0e, y0, y0e, + vx, vxe, vy, vye, + pi, pie, + t0], + names = ('name', 'm0', 'm0_err', + 'x0', 'x0_err', 'y0', 'y0_err', + 'vx', 'vx_err', 'vy', 'vy_err', + 'pi', 'pi_err', + 't0')) + + sdx = np.argsort(m0) + lis = lis[sdx] + + # Save original positions as reference (1st) list + # in a StarList format (with velocities). + lis.write(f'{test_data_path}/random_par_ref.fits', overwrite=True) + + ########## + # Propogate to new times and distort. + ########## + # Make 4 new starlists with different epochs and transformations. + '''times = [2018.5, 2019.5, 2020.5, 2021.5] + xy_trans = [[[ 6.5, 0.99, 1e-5], [ 10.1, 1e-5, 0.99]], + [[100.3, 0.98, 1e-5], [ 50.5, 9e-6, 1.001]], + [[ 0.0, 1.00, 0.0], [ 0.0, 0.0, 1.0]], + [[250.0, 0.97, 2e-5], [-250.0, 1e-5, 1.001]]] + mag_trans = [0.1, 0.4, 0.0, -0.3]''' + + times = [2018.5, 2019.0, 2019.5, 2020.0, 2020.5, 2021.0, 2021.5, 2022.0] + xy_trans = [[[ 6.5, 0.99, 1e-5], [ 10.1, 1e-5, 0.99]], + [[100.3, 0.98, 1e-5], [ 50.5, 9e-6, 1.001]], + [[ 0.0, 1.00, 0.0], [ 0.0, 0.0, 1.0]], + [[250.0, 0.97, 2e-5], [-250.0, 1e-5, 1.001]], + [[ 50.0, 1.00, 0.0], [ -31.0, 0.0, 1.000]], + [[ 78.0, 1.00, 0.0 ], [ 45.0, 0.0, 1.00]], + [[-13.0, 1.00, 0.0], [ 150, 0.0, 1.00]], + [[ 94.0, 1.00, 0.0], [-182.0, 0.0, 1.00]]] + mag_trans = [0.1, 0.4, 0.0, -0.3, 0.0, 0.0, 0.0, 0.0] + + # Convert into pixels (undistorted) with the following info. + scale = 0.01 # arcsec / pix + shift = [1.0, 1.0] # pix + + for ss in range(len(times)): + dt = times[ss] - lis['t0'] + + par_mod = motion_model.Parallax(pa=0,ra=18.0, dec=-30.0) + par_mod_dat = par_mod.get_batch_pos_at_time(dt+lis['t0'], x0=lis['x0'],vx=lis['vx']/1e3, pi=lis['pi'], + y0=lis['y0'], vy=lis['vy']/1e3, t0=lis['t0']) + x,y = par_mod_dat[0], par_mod_dat[1] + t = np.ones(N_stars) * times[ss] + + # Convert into pixels + xp = (x / -scale) + shift[0] # -1 from switching to increasing to West (right) + yp = (y / scale) + shift[1] + xpe = lis['x0_err'] / scale + ype = lis['y0_err'] / scale + + # Distort the positions + trans = transforms.PolyTransform(1, xy_trans[ss][0], xy_trans[ss][1], mag_offset=mag_trans[ss]) + xd, yd = trans.evaluate(xp, yp) + md = trans.evaluate_mag(lis['m0']) + + # Perturb with small errors (0.1 pix) + xd += np.random.randn(N_stars) * 0.1 + yd += np.random.randn(N_stars) * 0.1 + md += np.random.randn(N_stars) * 0.02 + xde = xpe + yde = ype + mde = lis['m0_err'] + + # Save the new list as a starlist. + new_lis = starlists.StarList([lis['name'], md, mde, xd, xde, yd, yde, t], + names=('name', 'm', 'me', 'x', 'xe', 'y', 'ye', 't')) + + new_lis.write(f'{test_data_path}/random_par_{ss}.fits', overwrite=True) + + return (xy_trans, mag_trans) + + +def _bruteforce_determine_motion_models(startable, motion_models, fixed_params_dict, verbose=False): + """ + Reference implementation of align.determine_motion_models(), kept here only + as ground truth for test_determine_motion_models_vectorized: a plain, + unambiguous per-star Python loop (the same algorithm the vectorized version + in align.py replaced, for performance, with whole-column numpy ops). """ - # Get gaia reference stars using analysis.py - # around a test location. - target = 'ob150029' - ra = '17:59:46.60' - dec = '-28:38:41.8' + if all(isinstance(mm, str) for mm in motion_models): + mm_map = motion_model.motion_model_map() + motion_models = [mm_map[mm] for mm in motion_models] + + motion_models_possible = [] + for mm in motion_models: + required_columns = mm.fit_param_names + mm.fixed_param_names + req_col_in_table = [col for col in required_columns if (col in startable.colnames)] + req_col_in_dict = [col for col in required_columns if (col in fixed_params_dict.keys())] + req_cols = startable[req_col_in_table] + if all((col in startable.colnames) or (col in fixed_params_dict.keys()) for col in required_columns): + motion_models_possible.append((mm, req_col_in_table, req_cols, req_col_in_dict)) + + motion_model_used = [] + n_params = [] + for k in range(len(startable)): + for mm, req_col_in_table, req_cols, req_col_in_dict in motion_models_possible[::-1]: + if all(np.isfinite(req_cols[col][k]) for col in req_col_in_table if np.issubdtype(req_cols[col].dtype, np.number)) \ + and all(np.isfinite(fixed_params_dict[col]) for col in req_col_in_dict if np.issubdtype(np.array(fixed_params_dict[col]).dtype, np.number)): + motion_model_used.append(mm.name) + n_params.append(mm.n_params) + break + + return motion_model_used, n_params + + +def test_determine_motion_models_vectorized(): + """ + align.determine_motion_models() was rewritten to use whole-column numpy + operations instead of a Python loop over every star (a major bottleneck + for large mosaics). Check the vectorized version against a brute-force + per-star reference on a table that exercises: an always-finite fallback + model (Empty), a model needing table columns to be finite (Fixed), and a + model needing both table columns and a fixed_params_dict entry to be + finite (Linear, gated on 't0'). + """ + rng = np.random.default_rng(42) + n_stars = 200 - # Coordinates are arcsecs offset +x to the East. - targets_dict = {'ob150029': [0.0, 0.0], - 'S11_15_3.9': [ 1.13982, 3.73524], - 'S13_13_4.5': [-4.42878, 0.03100] - } + x0 = rng.uniform(-10, 10, n_stars) + y0 = rng.uniform(-10, 10, n_stars) + vx = rng.uniform(-1, 1, n_stars) + vy = rng.uniform(-1, 1, n_stars) - # Get gaia catalog stars. Note that this produces a masked column table - search_rad = 10.0 # arcsec - gaia = analysis.query_gaia(ra, dec, search_radius=search_rad) - my_gaia = analysis.prepare_gaia_for_flystar(gaia, ra, dec, targets_dict=targets_dict) + # Sprinkle in some non-finite values so all three models get exercised. + x0[::7] = np.nan # these rows can only ever be 'Empty' + vx[::5] = np.inf # these rows (minus the ones above) can only be 'Fixed' + vy[1::11] = np.nan - assert isinstance(my_gaia, Table) + table = Table({'x0': x0, 'y0': y0, 'vx': vx, 'vy': vy}) - # Let's make sure the entire align runs, just to be safe - - # Get starlists to align to gaia - epochs = ['15jun07','16jul14', '17may21'] + for fixed_params_dict in [{'t0': 2020.0}, {'t0': np.inf}, {}]: + motion_models = ['Empty', 'Fixed', 'Linear'] - list_of_starlists = [] + got_used, got_n = align.determine_motion_models( + table, motion_models=motion_models, fixed_params_dict=dict(fixed_params_dict), verbose=False + ) + want_used, want_n = _bruteforce_determine_motion_models( + table, motion_models=motion_models, fixed_params_dict=dict(fixed_params_dict), verbose=False + ) + + assert got_used == want_used + assert got_n == want_n + # Sanity check: with fixed_params_dict containing a finite t0, at least + # some stars should have resolved to each of the three models. + if fixed_params_dict.get('t0') == 2020.0: + assert set(got_used) == {'Empty', 'Fixed', 'Linear'} - for ee in range(len(epochs)): - lis_file = 'mag' + epochs[ee] + '_ob150029_kp_rms_named.lis' - lis = starlists.StarList.from_lis_file(lis_file) - - list_of_starlists.append(lis) - # Run the align - msc = align.MosaicToRef(my_gaia, list_of_starlists, iters=2, - dr_tol=[0.2, 0.1], dm_tol=[1, 1], +def test_update_old_and_new_names(): + """ + align.update_old_and_new_names() reads each new star's per-list name via + the 'idx_in_list' index into the starlist it came from, and widens the + 'name' column's dtype only when the new names need it. Check both the + "no widening needed" and "widening needed" branches against the original + per-row logic. + """ + n_old = 50 + old_names = np.array([f'{i:03d}_star' for i in range(n_old)]) # 8 chars each + list_names = np.array([f'star_{i}' for i in range(n_old)]) # 6-7 chars + idx_in_list = np.arange(n_old, dtype=np.int32).reshape(-1, 1) + + star_list = starlists.StarList(name=list_names, + x=np.zeros(n_old), y=np.zeros(n_old), + m=np.zeros(n_old)) + ref_table = Table({'name': old_names, 'idx_in_list': idx_in_list}) + idx_ref_new = np.array([5, 12, 30]) + list_index = 0 + + def _bruteforce(ref_table, star_list, list_index, idx_ref_new): + names = [star_list['name'][k] + for k in ref_table['idx_in_list'][idx_ref_new, list_index]] + new_names = [f"{list_index:3d}_{name}" for name in names] + new_name_len_max = np.max([len(new_name) for new_name in new_names]) + old_names = ref_table['name'] + old_name_len_max = np.max([len(old_name) for old_name in old_names]) + if new_name_len_max > old_name_len_max: + all_names = old_names.astype('U{0:d}'.format(new_name_len_max)) + else: + all_names = old_names + all_names[idx_ref_new] = new_names + return all_names + + # Case 1: new names are no longer than existing ones -- no widening needed. + got = align.update_old_and_new_names(ref_table.copy(), star_list, list_index, idx_ref_new) + want = _bruteforce(ref_table.copy(), star_list, list_index, idx_ref_new) + assert list(got) == list(want) + + # Case 2: one new name is longer than any existing name -- dtype must widen. + long_names = list_names.astype('U40') + long_names[idx_ref_new[0]] = 'a_much_much_longer_star_name' + star_list2 = starlists.StarList(name=long_names, + x=np.zeros(n_old), y=np.zeros(n_old), + m=np.zeros(n_old)) + got2 = align.update_old_and_new_names(ref_table.copy(), star_list2, list_index, idx_ref_new) + want2 = _bruteforce(ref_table.copy(), star_list2, list_index, idx_ref_new) + assert list(got2) == list(want2) + assert 'a_much_much_longer_star_name' in got2[idx_ref_new[0]] + + +def test_names_in_list_round_trip(): + """ + The per-list identity is stored as an index, so align.names_in_list() has + to hand back exactly the names the starlists carry -- and '' wherever a + star was not detected in that list. + """ + lists = [] + for jj in range(3): + n = 6 + lists.append(starlists.StarList( + name=np.array([f'L{jj}_star{i}' for i in range(n)]), + x=np.arange(n, dtype=float), y=np.arange(n, dtype=float), + m=np.zeros(n))) + + ref_table = Table({'idx_in_list': np.array([[0, 2, -1], + [1, -1, 4], + [-1, 3, 5]], dtype=np.int32)}) + + got = align.names_in_list(ref_table, lists) + assert got.shape == (3, 3) + assert got[0, 0] == 'L0_star0' + assert got[0, 1] == 'L1_star2' + assert got[0, 2] == '' # not detected in list 2 + assert got[2, 0] == '' + assert got[2, 2] == 'L2_star5' + + # Single-list form matches the corresponding column. + for jj in range(3): + assert list(align.names_in_list(ref_table, lists, list_index=jj)) == list(got[:, jj]) + + +if __name__ == '__main__': + import pickle + with open(f'{test_data_path}/my_gaia.pkl', 'rb') as f: + my_gaia = pickle.load(f) + with open(f'{test_data_path}/list_of_starlists.pkl', 'rb') as f: + list_of_starlists = pickle.load(f) + ra_deg, dec_deg = 18.0, -30.0 + my_gaia.remove_column('motion_model_used') + msc = align.MosaicToRef(my_gaia, list_of_starlists, + dr_tol=[0.2, 0.1, 0.08], dm_tol=[5,5,5], + outlier_tol=[None, None, 3], mag_lim=[6, 20], trans_class=transforms.PolyTransform, - trans_args=[{'order': 1}, {'order': 1}], - use_vel=True, - use_ref_new=False, - update_ref_orig=False, + trans_args=[{'order': 1}, {'order': 1}, {'order': 1}], + motion_models=['Linear','Parallax'], + fixed_params_dict = {'ra':ra_deg, 'dec':dec_deg, 'pa':0.0, 'obsLocation':'earth'}, + use_ref_new=True, + update_ref_orig=False, mag_trans=True, - init_guess_mode='name', verbose=True) + trans_weights='both,std', + init_guess_mode='name', verbose=3) + msc.fit() + for i in range(msc.ref_table['x'].shape[1]): + plt.scatter(msc.ref_table['x'][:, i], msc.ref_table['y'][:, i]) + # block=False: with an interactive backend (e.g. macosx) a blocking show() + # hangs the whole test session until the window is closed by hand. + plt.show(block=False) + plot_stars(msc.ref_table, msc.ref_table['name'][:3]) + +def test_ref_velocity_propagation_independent_of_motion_models(): + """ + align's `motion_models` says which models to FIT for the observed stars. + It must NOT limit how a reference star is PROPAGATED to an observed epoch: + a reference imported from an external catalog can carry vx/vy/t0 that were + never fit here, and those have to be used even when motion_models=['Fixed'] + -- otherwise the velocities sit unused in the table and the reference stays + frozen at its catalog epoch. + """ + from flystar.align import MosaicToRef, determine_motion_models + from flystar.starlists import StarList + + n, T0 = 25, 2020.0 + rng = np.random.default_rng(3) + names = [f'r{i:03d}' for i in range(n)] + x0 = rng.uniform(40, 160, n) + y0 = rng.uniform(40, 160, n) + # per-star (not uniform) velocities -- a uniform proper motion is + # degenerate with the per-epoch transformation, which would absorb it and + # make this test pass vacuously + vx = rng.normal(0, 0.8, n) + vy = rng.normal(0, 0.8, n) + m0 = rng.uniform(13, 18, n) + + # NOTE: StarList.__init__ only accepts x/y/m/xe/ye/me/corr as keywords and + # silently drops anything else, so the Linear params go on as columns. + ref = StarList(name=names, x=x0, y=y0, m=m0, xe=np.full(n, .01), + ye=np.full(n, .01), me=np.full(n, .01)) + for col, val in [('vx', vx), ('vy', vy), ('t0', np.full(n, T0)), + ('vx_err', np.full(n, .001)), ('vy_err', np.full(n, .001)), + ('x0_err', np.full(n, .01)), ('y0_err', np.full(n, .01))]: + ref[col] = val + + lists = [] + for e in range(4): + t = T0 + e + sl = StarList(name=names, + x=x0 + vx * (t - T0) + rng.normal(0, .01, n), + y=y0 + vy * (t - T0) + rng.normal(0, .01, n), + m=m0 + rng.normal(0, .01, n), + xe=np.full(n, .01), ye=np.full(n, .01), me=np.full(n, .01)) + sl.meta['list_time'] = t + lists.append(sl) + + for models in (['Fixed'], ['Linear']): + mtr = MosaicToRef(ref, lists, motion_models=models, + update_ref_orig=False, dr_tol=[6.], + dm_tol=[3], outlier_tol=[None], + init_guess_mode='name', verbose=False) + mtr.fit() + tab = mtr.ref_table + + # the reference's Linear params must survive into ref_table + assert 'vx' in tab.colnames, f'{models}: vx column lost' + assert 't0' in tab.colnames, f'{models}: t0 column lost' + + # propagation picks the most complex model each star supports... + mm_prop, _ = determine_motion_models(tab, None, mtr.fixed_params_dict) + assert set(np.asarray(mm_prop)) == {'Linear'}, \ + f'{models}: propagation model should be Linear, got {set(mm_prop)}' + + # ...and align's own propagation path really moves the stars at the + # reference's per-star velocity + r0 = mtr.get_ref_list_from_table(T0) + r5 = mtr.get_ref_list_from_table(T0 + 5.0) + slope = (np.asarray(r5['x']) - np.asarray(r0['x'])) / 5.0 + np.testing.assert_allclose( + slope[:n], vx, rtol=1e-6, atol=1e-8, + err_msg=f'{models}: reference not propagated at its own vx') + + slope_y = (np.asarray(r5['y']) - np.asarray(r0['y'])) / 5.0 + np.testing.assert_allclose( + slope_y[:n], vy, rtol=1e-6, atol=1e-8, + err_msg=f'{models}: reference not propagated at its own vy') + + # motion_model_used says which model the ROW'S PARAMETERS constitute, not + # which one the fitting configuration asked for. These reference stars are + # frozen (update_ref_orig=False) and hold catalog x0/vx/t0, so the row is a + # Linear model and must be labeled as one -- labeling it 'Fixed' because + # motion_models=['Fixed'] would describe a row holding Linear parameters as + # Fixed with n_params=1, which is what this used to do. + mtr_fixed = MosaicToRef(ref, lists, motion_models=['Fixed'], + update_ref_orig=False, dr_tol=[6.], + dm_tol=[3], outlier_tol=[None], + init_guess_mode='name', verbose=False) + mtr_fixed.fit() + tab_f = mtr_fixed.ref_table + ref_rows = np.asarray(tab_f['ref_orig']) + assert set(np.asarray(tab_f['motion_model_used'])[ref_rows]) == {'Linear'}, \ + 'frozen reference rows hold Linear params and must be labeled Linear' + assert set(np.asarray(tab_f['n_params'])[ref_rows]) == {2}, \ + 'n_params must agree with the label' + assert np.isfinite(np.asarray(tab_f['vx'])[ref_rows]).all(), \ + 'the Linear label must point at finite parameters' + + +def test_propagation_honors_motion_model_input(): + """ + determine_motion_models(tab, None) honors a caller-supplied 'motion_model_input' + column as an explicit per-star propagation request, and otherwise falls + back to the most complex model each star's own finite parameters support. + + The subtlety worth pinning: 'motion_model_input' must NOT be auto-filled + by setup_ref_table_from_starlist. It used to be populated with + motion_models[-1].name whenever the input lacked it, which made the column + always present and so indistinguishable from a real request -- tying + propagation back to `motion_models` and re-freezing a reference that + carries velocities, exactly what + test_ref_velocity_propagation_independent_of_motion_models forbids. + """ + from flystar.align import MosaicToRef + from flystar.starlists import StarList + + n, T0 = 25, 2020.0 + rng = np.random.default_rng(3) + names = [f'r{i:03d}' for i in range(n)] + x0 = rng.uniform(40, 160, n) + y0 = rng.uniform(40, 160, n) + vx = rng.normal(0, 0.8, n) + vy = rng.normal(0, 0.8, n) + m0 = rng.uniform(13, 18, n) + + def build(mm_input=None): + ref = StarList(name=names, x=x0, y=y0, m=m0, xe=np.full(n, .01), + ye=np.full(n, .01), me=np.full(n, .01), vx=vx, vy=vy, + t0=np.full(n, T0), vx_err=np.full(n, .001), + vy_err=np.full(n, .001), x0_err=np.full(n, .01), + y0_err=np.full(n, .01)) + if mm_input is not None: + ref['motion_model_input'] = np.array(mm_input, dtype='U20') + lists = [] + for e in range(4): + t = T0 + e + sl = StarList(name=names, x=x0 + vx * (t - T0), y=y0 + vy * (t - T0), + m=m0, xe=np.full(n, .01), ye=np.full(n, .01), + me=np.full(n, .01)) + sl.meta['list_time'] = t + lists.append(sl) + return ref, lists + + def slopes(mm_input): + ref, lists = build(mm_input) + mtr = MosaicToRef(ref, lists, motion_models=['Fixed'], + update_ref_orig=False, dr_tol=[6.], + dm_tol=[3], outlier_tol=[None], + init_guess_mode='name', verbose=False) + mtr.fit() + r0 = mtr.get_ref_list_from_table(T0) + r5 = mtr.get_ref_list_from_table(T0 + 5.0) + return mtr, (np.asarray(r5['x']) - np.asarray(r0['x']))[:n] / 5.0 + + # no column supplied -> it must not be invented, and the velocities stand + mtr, sl_auto = slopes(None) + assert 'motion_model_input' not in mtr.ref_table.colnames, \ + 'motion_model_input was auto-filled; propagation can no longer tell a ' \ + 'real per-star request from the fitting setting restated per row' + np.testing.assert_allclose(sl_auto, vx, rtol=1e-6, atol=1e-8, + err_msg='reference velocities were suppressed') + + # explicit Fixed request -> honored, so the stars do NOT move + mtr, sl_fixed = slopes(['Fixed'] * n) + assert 'motion_model_input' in mtr.ref_table.colnames + np.testing.assert_allclose(sl_fixed, 0.0, atol=1e-10, + err_msg='explicit Fixed request was not honored') + + # explicit Linear request -> honored, stars move at their own vx + mtr, sl_linear = slopes(['Linear'] * n) + np.testing.assert_allclose(sl_linear, vx, rtol=1e-6, atol=1e-8, + err_msg='explicit Linear request was not honored') + + # mixed per-star requests are resolved per star + mixed = ['Fixed'] * 10 + ['Linear'] * 15 + mtr, sl_mixed = slopes(mixed) + np.testing.assert_allclose(sl_mixed[:10], 0.0, atol=1e-10, + err_msg='per-star Fixed rows moved') + np.testing.assert_allclose(sl_mixed[10:], vx[10:], rtol=1e-6, atol=1e-8, + err_msg='per-star Linear rows did not move at vx') + + # A usable per-star request outranks the motion_models list, so + # motion_model_used follows the request rather than being clamped to + # ['Fixed'] -- the same priority fit_motion_models gives the column. These + # reference stars carry real vx/vy from the input catalog, so labeling the + # requested rows 'Linear' points at parameters that genuinely exist. + mmu = np.asarray(mtr.ref_table['motion_model_used'])[:n] + assert set(mmu[:10]) == {'Fixed'}, f'Fixed-requested rows got {set(mmu[:10])}' + assert set(mmu[10:]) == {'Linear'}, f'Linear-requested rows got {set(mmu[10:])}' + + +def test_determine_motion_models_precedence(): + """Unit-level precedence: a usable request wins; an unusable, unrecognized + or absent one falls back to most-complex-available.""" + from astropy.table import Column + from flystar.startables import StarTable + from flystar.align import determine_motion_models + + n = 6 + tab = StarTable(name=[f's{i}' for i in range(n)], + x=np.zeros((n, 2)), y=np.zeros((n, 2)), m=np.zeros((n, 2)), + xe=np.ones((n, 2)) * .01, ye=np.ones((n, 2)) * .01, + me=np.ones((n, 2)) * .01, t=np.tile([2020., 2021.], (n, 1))) + tab['x0'] = np.arange(n, dtype=float) + tab['y0'] = np.arange(n, dtype=float) + tab['vx'] = np.array([1., 2., np.nan, 4., 5., 6.]) + tab['vy'] = np.array([1., 2., np.nan, 4., 5., 6.]) + tab['t0'] = np.full(n, 2020.) + + # no request column -> pure finiteness fallback + got, _ = determine_motion_models(tab, None) + assert list(got) == ['Linear', 'Linear', 'Fixed', 'Linear', 'Linear', 'Linear'] + + tab['motion_model_input'] = Column( + ['Linear', # usable -> honored + 'Fixed', # explicit downgrade despite finite vx -> honored + 'Linear', # vx is nan -> unusable, fallback + 'Bogus', # unrecognized -> fallback + 'Parallax', # needs pi/ra/dec -> absent, fallback + 'Empty'], # explicit -> honored + dtype='U20') + got, _ = determine_motion_models(tab, None) + assert list(got) == ['Linear', 'Fixed', 'Fixed', 'Linear', 'Linear', 'Empty'] + + +def test_outlier_tol_second_pass_redoes_transform(): + """ + match_and_transform rejects outliers twice: once before deriving the + transformation, and again against the derived transformation, re-deriving + it without any star that is still an outlier. + + That second pass was dead code. Its guard read + keepers = self.outlier_rejection_indices(...) # a boolean MASK + if len(keepers) < len(idx2): # len(mask) == len(idx2) + + so it could never fire, and the accompanying message always reported 0 + rejected. Every existing test passes outlier_tol=None, so nothing caught + it. This one asserts the transformation is actually re-derived. + """ + from flystar.align import MosaicSelfRef + from flystar.starlists import StarList + from flystar import transforms + + n, n_ep = 40, 3 + rng = np.random.default_rng(2) + x0 = rng.uniform(20, 180, n) + y0 = rng.uniform(20, 180, n) + m0 = rng.uniform(13, 19, n) + names = [f's{i:03d}' for i in range(n)] + + lists = [] + for e in range(n_ep): + x = x0 + rng.normal(0, .01, n) + y = y0 + rng.normal(0, .01, n) + if e > 0: + x[:3] += 4.0 # three hard outliers, well outside the scatter + sl = StarList(name=names, x=x, y=y, m=m0 + rng.normal(0, .01, n), + xe=np.full(n, .01), ye=np.full(n, .01), me=np.full(n, .01)) + sl.meta['list_time'] = 2020.0 + e + lists.append(sl) + + def run(outlier_tol): + calls = [0] + original = transforms.PolyTransform.derive_transform + + @classmethod + def counting(cls, *args, **kwargs): + calls[0] += 1 + return original.__func__(cls, *args, **kwargs) + + transforms.PolyTransform.derive_transform = counting + try: + msc = MosaicSelfRef(lists, dr_tol=[8.], dm_tol=[3], + outlier_tol=outlier_tol, motion_models=['Fixed'], + init_guess_mode='name', verbose=0) + msc.fit() + finally: + transforms.PolyTransform.derive_transform = original + return calls[0] + + n_with = run([2.0]) + n_without = run([None]) + + # With outliers present and a finite tolerance, at least one starlist must + # trigger the second pass, which derives its transformation a second time. + assert n_with > n_without, ( + f'outlier_tol did not re-derive any transformation: ' + f'{n_with} derive_transform calls with outlier_tol=2.0 vs ' + f'{n_without} with outlier_tol=None -- the second rejection pass is ' + f'not running' + ) + + +def test_outlier_rejection_keeps_identical_residuals(): + """ + Outlier rejection on a set of identical residuals must keep every star. + + The threshold is median + outlier_tol * sigma. With no scatter, sigma is 0 + and the threshold collapses onto the median, so a strict '<' comparison + rejected 100% of the stars. That is not a corner case invented for a test: + MosaicSelfRef matches the reference list's own starlist against a reference + table built from it, under an identity transform, so on the first iteration + every residual is exactly 0. + """ + from flystar.align import MosaicSelfRef + from flystar.starlists import StarList + + n = 25 + rng = np.random.default_rng(7) + x = rng.uniform(0, 100, n) + y = rng.uniform(0, 100, n) + m = rng.uniform(13, 18, n) + sl = StarList(name=[f's{i:03d}' for i in range(n)], x=x, y=y, m=m) + sl.meta['list_time'] = 2020.0 + + # An unbound call: outlier_rejection_indices touches no instance state. + keepers = MosaicSelfRef.outlier_rejection_indices( + None, sl, sl, 5, verbose=False) + assert np.count_nonzero(keepers) == n, ( + f'identical (zero) residuals: kept {np.count_nonzero(keepers)} of {n}, ' + f'expected all of them' + ) + + # A constant nonzero offset is also scatter-free, and equally must survive. + sl_off = StarList(sl, copy=True) + sl_off['x'] = sl['x'] + 0.3 + keepers = MosaicSelfRef.outlier_rejection_indices( + None, sl_off, sl, 5, verbose=False) + assert np.count_nonzero(keepers) == n, ( + f'identical (constant 0.3) residuals: kept ' + f'{np.count_nonzero(keepers)} of {n}, expected all of them' + ) + + # A genuine outlier must still be rejected -- the fix must not disable + # rejection altogether. + sl_bad = StarList(sl, copy=True) + sl_bad['x'] = sl['x'] + rng.normal(0, .01, n) + sl_bad['x'][0] += 10.0 + keepers = MosaicSelfRef.outlier_rejection_indices( + None, sl_bad, sl, 3, verbose=False) + assert not keepers[0], 'a 10-unit outlier at 3 sigma was not rejected' + assert np.count_nonzero(keepers) == n - 1, ( + f'expected exactly 1 rejection, got {n - np.count_nonzero(keepers)}' + ) + + +def test_min_stars_for_transform(): + """Free-parameter counts per transformation order.""" + from flystar.align import min_stars_for_transform + + assert min_stars_for_transform({'order': 0}) == 1 + assert min_stars_for_transform({'order': 1}) == 3 + assert min_stars_for_transform({'order': 2}) == 6 + assert min_stars_for_transform({'order': 3}) == 10 + # No order given, and no trans_args at all, fall back to the linear case. + assert min_stars_for_transform({}) == 3 + assert min_stars_for_transform(None) == 3 + + +def test_outlier_tol_self_ref_transforms_are_finite(): + """ + A MosaicSelfRef fit with a finite outlier_tol must not produce NaN + transformations, positions, or reference table entries. + + This is the end-to-end shape of the failure: on iteration 1 the reference + starlist matched itself with exactly-zero residuals, outlier rejection threw + away all of its matches, derive_transform quietly returned NaN coefficients + for 0 stars, and the NaNs only announced themselves an iteration later as + + ValueError: x1 does not contain any finite values! + + raised from match.match. The same fit with outlier_tol=None worked, which is + what made it look like a tolerance problem rather than a rejection bug. + """ + from flystar.align import MosaicSelfRef + from flystar.starlists import StarList + from flystar import transforms + + n, n_ep = 60, 4 + rng = np.random.default_rng(11) + x0 = rng.uniform(20, 180, n) + y0 = rng.uniform(20, 180, n) + m0 = rng.uniform(13, 19, n) + names = [f's{i:03d}' for i in range(n)] + + lists = [] + for e in range(n_ep): + sl = StarList(name=names, + x=x0 + rng.normal(0, .01, n), + y=y0 + rng.normal(0, .01, n), + m=m0 + rng.normal(0, .01, n), + xe=np.full(n, .01), ye=np.full(n, .01), me=np.full(n, .01)) + sl.meta['list_time'] = 2020.0 + e + lists.append(sl) + + # Two iterations with second-order transformations on the second pass -- + # the configuration that failed, scaled down. + msc = MosaicSelfRef(lists, dr_tol=[1., .5], dm_tol=[.5, .5], + outlier_tol=[5, 5], trans_class=transforms.PolyTransform, + trans_args=[{'order': 1}, {'order': 2}], + trans_input=[transforms.PolyTransform(order=0, px=[0], py=[0]) + for _ in lists], + motion_models=['Fixed'], init_guess_mode='name', + mag_lim=[[(13, 19)] * n_ep] * 2, verbose=0) msc.fit() - return + for ii, trans in enumerate(msc.trans_list): + assert np.isfinite(trans.px.parameters).all(), ( + f'starlist {ii}: NaN in the x transformation parameters ' + f'{trans.px.parameters}' + ) + assert np.isfinite(trans.py.parameters).all(), ( + f'starlist {ii}: NaN in the y transformation parameters ' + f'{trans.py.parameters}' + ) + + for col in ['x0', 'y0']: + assert np.isfinite(msc.ref_table[col]).all(), ( + f'NaN in ref_table["{col}"] after fitting with outlier_tol' + ) + + +def test_chi2_matching_reduces_split_stars(): + """ + matching='chi2' must stop one physical star being recorded as several + reference rows in a crowded field. + + With the legacy rules, a star whose nearest candidate was not also its + nearest in magnitude was left unmatched and added to the reference table as + a new row a few mas from the original. Every later starlist then saw two + nearly coincident reference rows, was ambiguous by construction, and split + again -- so one bad tie-break seeded the next. This builds a field dense + enough to trigger it and asserts on the number of duplicate rows. + """ + from flystar.align import MosaicSelfRef + from flystar.starlists import StarList + from flystar import transforms + from scipy.spatial import cKDTree + + n, n_ep = 400, 4 + rng = np.random.default_rng(17) + # A crowded field: mean separation comparable to dr_tol, so most stars have + # neighbours inside the match radius. + x0 = rng.uniform(0, 4, n) + y0 = rng.uniform(0, 4, n) + m0 = rng.uniform(15, 20, n) + + lists = [] + for e in range(n_ep): + sl = StarList(name=[f'e{e}_{i:04d}' for i in range(n)], + x=x0 + rng.normal(0, .004, n), + y=y0 + rng.normal(0, .004, n), + m=m0 + rng.normal(0, .08, n)) + sl.meta['list_time'] = 2020.0 + e + lists.append(sl) + + def n_split(mode): + msc = MosaicSelfRef( + lists, dr_tol=[.1, .05], dm_tol=[.5, .5], matching=mode, + trans_class=transforms.PolyTransform, + trans_args=[{'order': 1}, {'order': 1}], + trans_input=[transforms.PolyTransform(order=0, px=[0], py=[0]) for _ in lists], + motion_models=['Fixed'], init_guess_mode='name', verbose=0) + msc.fit() + rt = msc.ref_table + det = np.isfinite(np.asarray(rt['x'])) + pairs = cKDTree(np.c_[np.asarray(rt['x0']), np.asarray(rt['y0'])]).query_pairs( + 0.02, output_type='ndarray') + if len(pairs) == 0: + return len(rt), 0 + # Two rows this close that never appear in the same exposure are one + # star split in two, not two stars. + disjoint = ~(det[pairs[:, 0]] & det[pairs[:, 1]]).any(axis=1) + return len(rt), int(disjoint.sum()) + + rows_legacy, split_legacy = n_split('legacy') + rows_chi2, split_chi2 = n_split('chi2') + + assert split_chi2 < split_legacy, ( + f'chi2 matching did not reduce split stars: {split_chi2} split pairs vs ' + f'{split_legacy} with legacy matching' + ) + assert rows_chi2 <= rows_legacy, ( + f'chi2 matching grew the reference table: {rows_chi2} rows vs {rows_legacy}' + ) + # The field really does have only n stars in it. + assert rows_chi2 < n * 1.5, ( + f'{rows_chi2} reference rows for {n} stars -- still splitting badly' + ) + + + +def test_use_in_trans_on_input_starlist(): + """ + Supplying a 'use_in_trans' column on an input starlist used to crash: + copy_over_values writes every column shared with ref_table at + [idx_ref, idx_epoch], but ref_table's 'use_in_trans' is a 1D per-star flag + with no epoch axis, so it raised + + IndexError: too many indices for array: + array is 1-dimensional, but 2 were indexed + + Only 2D (per-list) columns can take a per-epoch write; 1D ones are + aggregates or per-star flags and are left to whoever owns them. + """ + from flystar.align import MosaicSelfRef + from flystar.starlists import StarList + + n, n_ep, n_out = 40, 3, 4 + rng = np.random.default_rng(11) + x0 = rng.uniform(20, 180, n) + y0 = rng.uniform(20, 180, n) + m0 = rng.uniform(13, 19, n) + names = [f's{i:03d}' for i in range(n)] + + lists = [] + for e in range(n_ep): + x = x0 + rng.normal(0, .01, n) + y = y0 + rng.normal(0, .01, n) + if e > 0: + x[:n_out] += 4.0 + sl = StarList(name=names, x=x, y=y, m=m0 + rng.normal(0, .01, n), + xe=np.full(n, .01), ye=np.full(n, .01), me=np.full(n, .01)) + sl['use_in_trans'] = np.ones(n, dtype=bool) + sl['use_in_trans'][:n_out] = False + sl.meta['list_time'] = 2020.0 + e + lists.append(sl) + + msc = MosaicSelfRef(lists, dr_tol=[8., 8.], dm_tol=[3, 3], + outlier_tol=[None, None], motion_models=['Fixed'], + init_guess_mode='name', verbose=0) + msc.fit() # must not raise + + tab = msc.ref_table + assert 'use_in_trans' in tab.colnames + assert np.ndim(tab['use_in_trans']) == 1, \ + "ref_table's use_in_trans must stay a 1D per-star flag" + + # other 1D aggregates shared by name are likewise left alone, not written + # per-epoch -- x0 must remain 1D + assert np.ndim(tab['x0']) == 1 + + +def test_iter_callback_indices_are_distinct(): + """ + iter_callback fires once per iteration and once more after the final + re-matching pass. That last call used to reuse the last iteration's index, + so a callback could not tell the two apart -- and one with side effects + (accumulating outlier rejections, say) silently ran twice for the last + iteration. The final call now reports `iters`, one past the last index. + """ + from flystar.align import MosaicSelfRef + from flystar.starlists import StarList + + n = 30 + rng = np.random.default_rng(4) + x0 = rng.uniform(20, 180, n) + y0 = rng.uniform(20, 180, n) + m0 = rng.uniform(13, 19, n) + names = [f's{i:03d}' for i in range(n)] + + def make_lists(): + out = [] + for e in range(3): + sl = StarList(name=names, x=x0 + rng.normal(0, .01, n), + y=y0 + rng.normal(0, .01, n), m=m0, + xe=np.full(n, .01), ye=np.full(n, .01), + me=np.full(n, .01)) + sl.meta['list_time'] = 2020.0 + e + out.append(sl) + return out + + for iters in (1, 2, 3): + seen = [] + msc = MosaicSelfRef(make_lists(), dr_tol=[8.] * iters, + dm_tol=[3] * iters, outlier_tol=[None] * iters, + motion_models=['Fixed'], init_guess_mode='name', + verbose=0, + iter_callback=lambda tab, it: seen.append(it)) + msc.fit() + + assert len(seen) == len(set(seen)), \ + f'iters={iters}: callback saw a repeated index: {seen}' + assert seen == list(range(iters + 1)), \ + f'iters={iters}: expected indices 0..{iters}, got {seen}' + assert seen[-1] == iters, \ + f'iters={iters}: final call must be marked {iters}, got {seen[-1]}' + + +def test_iters_from_longest_schedule(): + """ + The number of iterations is the length of the longest schedule, not of + dr_tol specifically. Any schedule given as a single value is broadcast up + to that length, and two schedules of differing length are an error. + """ + rng = np.random.default_rng(4) + n = 40 + names = np.array([f's{i:03d}' for i in range(n)]) + x0 = rng.uniform(0, 100, n) + y0 = rng.uniform(0, 100, n) + m0 = rng.uniform(10, 15, n) + + lists = [] + for e in range(2): + sl = starlists.StarList(name=names, x=x0 + rng.normal(0, .01, n), + y=y0 + rng.normal(0, .01, n), m=m0, + xe=np.full(n, .01), ye=np.full(n, .01), + me=np.full(n, .01)) + sl.meta['list_time'] = 2020.0 + e + lists.append(sl) + + def build(**kwargs): + return align.MosaicSelfRef(lists, motion_models=['Fixed'], + init_guess_mode='name', verbose=0, **kwargs) + + # Each schedule in turn sets the count, with the others broadcast up. + for kwargs, iters in (({'dr_tol': 8.}, 1), + ({'dr_tol': [8., 4.]}, 2), + ({'dr_tol': 8., 'dm_tol': [3, 2]}, 2), + ({'dr_tol': 8., 'outlier_tol': [None, 3, 3]}, 3), + ({'dr_tol': 8., + 'trans_args': [{'order': 1}, {'order': 2}]}, 2)): + msc = build(**kwargs) + assert msc.iters == iters, f'{kwargs}: iters={msc.iters} != {iters}' + for name in ('dr_tol', 'dm_tol', 'outlier_tol', 'trans_args'): + assert len(getattr(msc, name)) == iters, \ + f'{kwargs}: {name} was not broadcast to {iters}' + + # A single value really is used for every iteration, not just the first. + msc = build(dr_tol=8., dm_tol=[3, 2]) + assert np.all(msc.dr_tol == 8.), f'dr_tol not broadcast: {msc.dr_tol}' + + # Two schedules that disagree stay an error rather than being guessed at. + for kwargs in ({'dr_tol': [8., 4.], 'dm_tol': [3, 2, 1]}, + {'dr_tol': [8., 4., 2.], 'outlier_tol': [None, 3]}, + {'dr_tol': [8., 4.], + 'trans_args': [{'order': 1}] * 3}): + with pytest.raises(AssertionError): + build(**kwargs) + + # mag_lim does not vote: its [min, max] form is a pair, not a schedule. + msc = build(dr_tol=8., mag_lim=[10, 15]) + assert msc.iters == 1, f'mag_lim set the iteration count: iters={msc.iters}' diff --git a/flystar/tests/A.lis b/flystar/tests/test_data/A.lis similarity index 100% rename from flystar/tests/A.lis rename to flystar/tests/test_data/A.lis diff --git a/flystar/tests/B.lis b/flystar/tests/test_data/B.lis similarity index 100% rename from flystar/tests/B.lis rename to flystar/tests/test_data/B.lis diff --git a/flystar/tests/C.lis b/flystar/tests/test_data/C.lis similarity index 100% rename from flystar/tests/C.lis rename to flystar/tests/test_data/C.lis diff --git a/flystar/tests/D.lis b/flystar/tests/test_data/D.lis similarity index 100% rename from flystar/tests/D.lis rename to flystar/tests/test_data/D.lis diff --git a/flystar/tests/E.lis b/flystar/tests/test_data/E.lis similarity index 100% rename from flystar/tests/E.lis rename to flystar/tests/test_data/E.lis diff --git a/flystar/tests/F.lis b/flystar/tests/test_data/F.lis similarity index 100% rename from flystar/tests/F.lis rename to flystar/tests/test_data/F.lis diff --git a/flystar/tests/coveragerc b/flystar/tests/test_data/coveragerc similarity index 100% rename from flystar/tests/coveragerc rename to flystar/tests/test_data/coveragerc diff --git a/flystar/tests/test_data/list_of_starlists.pkl b/flystar/tests/test_data/list_of_starlists.pkl new file mode 100644 index 0000000..3662f0f Binary files /dev/null and b/flystar/tests/test_data/list_of_starlists.pkl differ diff --git a/flystar/tests/mb10364_data/2011_10_31_F606W_MATCHUP_XYMEEE_final.calib b/flystar/tests/test_data/mb10364_data/2011_10_31_F606W_MATCHUP_XYMEEE_final.calib similarity index 100% rename from flystar/tests/mb10364_data/2011_10_31_F606W_MATCHUP_XYMEEE_final.calib rename to flystar/tests/test_data/mb10364_data/2011_10_31_F606W_MATCHUP_XYMEEE_final.calib diff --git a/flystar/tests/mb10364_data/2012_09_25_F606W_MATCHUP_XYMEEE_final.calib b/flystar/tests/test_data/mb10364_data/2012_09_25_F606W_MATCHUP_XYMEEE_final.calib similarity index 100% rename from flystar/tests/mb10364_data/2012_09_25_F606W_MATCHUP_XYMEEE_final.calib rename to flystar/tests/test_data/mb10364_data/2012_09_25_F606W_MATCHUP_XYMEEE_final.calib diff --git a/flystar/tests/mb10364_data/2013_10_24_F606W_MATCHUP_XYMEEE_final.calib b/flystar/tests/test_data/mb10364_data/2013_10_24_F606W_MATCHUP_XYMEEE_final.calib similarity index 100% rename from flystar/tests/mb10364_data/2013_10_24_F606W_MATCHUP_XYMEEE_final.calib rename to flystar/tests/test_data/mb10364_data/2013_10_24_F606W_MATCHUP_XYMEEE_final.calib diff --git a/flystar/tests/mb10364_data/my_gaia.fits b/flystar/tests/test_data/mb10364_data/my_gaia.fits similarity index 100% rename from flystar/tests/mb10364_data/my_gaia.fits rename to flystar/tests/test_data/mb10364_data/my_gaia.fits diff --git a/flystar/tests/test_data/my_gaia.pkl b/flystar/tests/test_data/my_gaia.pkl new file mode 100644 index 0000000..58fa1c8 Binary files /dev/null and b/flystar/tests/test_data/my_gaia.pkl differ diff --git a/flystar/tests/ref.lis b/flystar/tests/test_data/ref.lis similarity index 100% rename from flystar/tests/ref.lis rename to flystar/tests/test_data/ref.lis diff --git a/flystar/tests/ref_vel.lis b/flystar/tests/test_data/ref_vel.lis similarity index 99% rename from flystar/tests/ref_vel.lis rename to flystar/tests/test_data/ref_vel.lis index 4d223b0..fc191bb 100644 --- a/flystar/tests/ref_vel.lis +++ b/flystar/tests/test_data/ref_vel.lis @@ -1,4 +1,4 @@ -name x y m xe ye me t0 vx vy vxe vye +name x y m xe ye me t0 vx vy vx_err vy_err gaia_1150 -63.98457260029581 -30.67278228118061 13.628200000000001 0.00014609621924194742 0.00014585407086906515 0.0115 2010.5 0.0 0.0 0.1 0.1 gaia_1162 0.47637231898572985 -79.79611824529178 14.6439 0.00011419811781207949 0.00011415029792639667 0.0084 2010.5 0.0 0.0 0.1 0.1 gaia_1166 8.546170748636236 -47.35893234401765 14.696900000000001 0.00013283068515276605 0.00013260913293195234 0.0041 2010.5 0.0 0.0 0.1 0.1 diff --git a/flystar/tests/test_catalog.fits b/flystar/tests/test_data/test_catalog.fits similarity index 100% rename from flystar/tests/test_catalog.fits rename to flystar/tests/test_data/test_catalog.fits diff --git a/flystar/tests/test_match.py b/flystar/tests/test_match.py index 594f0b9..2976a28 100644 --- a/flystar/tests/test_match.py +++ b/flystar/tests/test_match.py @@ -1,4 +1,4 @@ -from flystar import match, starlists, transforms +from flystar import align, match, starlists, transforms import numpy as np import pdb from astropy.table import Table @@ -164,7 +164,7 @@ def test_generic_match(): n2 = np.array(['S11', 'S12', 'S13', 'S14', 'S15', 'S16', 'S17', 'S18', 'S19']) - + list1 = Table([n1, x1, y1, m1], names=('name', 'x', 'y', 'm')) list2 = Table([n2, x2, y2, m2], @@ -173,11 +173,191 @@ def test_generic_match(): starlist1 = starlists.StarList.from_table(list1) starlist2 = starlists.StarList.from_table(list2) - out = match.generic_match(starlist1, starlist2, init_mode='triangle', - model=transforms.PolyTransform, order_dr=(1, 1.0), - dr_final=1.0, + # These two lists are deliberately perturbed copies of each other: after the + # blind-triangle initial align, the six real counterparts sit 1.3-3.2 pixels + # from their partners, and the three sl2 stars with no counterpart sit + # hundreds of pixels away. dr_tol has to straddle that gap -- the original + # 1.0 matched nothing at all, so the refinement fit 3 free parameters to 0 + # stars and returned a NaN transformation. + transf, st = align.generic_match(starlist1, starlist2, init_mode='triangle', + model=transforms.PolyTransform, order_dr=[[1, 4.0]], + dr_final=4.0, xy_match=(None, None, None, None, None, None, None, None), m_match=(None, None, None, None), sigma_match=None, n_bright=8, verbose=True) - + assert np.isfinite(transf.px.parameters).all(), \ + f'NaN in the x transformation parameters: {transf.px.parameters}' + assert np.isfinite(transf.py.parameters).all(), \ + f'NaN in the y transformation parameters: {transf.py.parameters}' + + # The six sl2 stars that have a counterpart in sl1, and only those. + assert len(st) == 6, f'expected 6 matches, got {len(st)}' + matched_sl1 = set(np.asarray(st['ep_name'][:, 0])) + assert matched_sl1 == {'S01', 'S02', 'S04', 'S06', 'S07', 'S10'}, \ + f'matched the wrong sl1 stars: {sorted(matched_sl1)}' + + # Every matched pair lands inside the final search radius. + resid = np.hypot(st['x'][:, 0] - st['x'][:, 1], st['y'][:, 0] - st['y'][:, 1]) + assert (resid < 4.0).all(), f'matched pairs beyond dr_final: {resid}' + + +def test_generic_match_no_matches_raises(): + """ + A refinement pass that matches nothing must say so, not return NaN. + + find_transform runs an underdetermined least-squares solve and hands back + NaN coefficients without raising. Those NaNs used to travel one loop + iteration further and surface as the thoroughly unhelpful + + ValueError: x1 does not contain any finite values! + + from inside match.match. The error should name the fit that failed instead. + """ + import pytest + + n = 12 + rng = np.random.default_rng(3) + x = rng.uniform(0, 2000, n) + y = rng.uniform(0, 2000, n) + m = rng.uniform(-6, -2, n) + names = [f'S{i:02d}' for i in range(n)] + + sl1 = starlists.StarList.from_table( + Table([names, x, y, m], names=('name', 'x', 'y', 'm'))) + # The same stars under a strongly quadratic distortion. Name-matching gives + # an initial guess from all 12 stars, but an order=1 refinement cannot + # absorb the quadratic term, so its residuals are tens of pixels -- nothing + # falls inside dr_tol=0.001 and the refit gets 0 stars. + sl2 = starlists.StarList.from_table( + Table([names, + x + 50.0 + 1e-4 * (x - 1000.0)**2, + y + 50.0 + 1e-4 * (y - 1000.0)**2, + m], names=('name', 'x', 'y', 'm'))) + + with pytest.raises(ValueError, match='non-finite parameters'): + align.generic_match(sl1, sl2, init_mode='match_name', + model=transforms.PolyTransform, + order_dr=[[1, 0.001]], dr_final=0.001, + m_match=(None, None, None, None), + sigma_match=None, verbose=False) + + +def test_chi2_matching_decisive_vs_ambiguous(): + """ + matching='chi2' must keep a decisively closer candidate and still refuse a + genuine positional tie. + + The legacy rule required a star's nearest candidate in position to also be + its nearest in magnitude, with no regard for how lopsided the positional + evidence was. Real case from a NIRCam pointing: a counterpart 3.6 mas away + was thrown out because an unrelated star 82.9 mas away -- 23x farther -- + happened to be 0.06 mag closer in brightness. + """ + import pytest + + # One star, two candidates. Distances in arcsec. + x1, y1, m1 = np.array([0.0]), np.array([0.0]), np.array([20.23]) + + decisive_x, decisive_m = np.array([0.0036, 0.0829]), np.array([19.97, 20.43]) + tie_x, tie_m = np.array([0.011, 0.013]), np.array([20.65, 20.28]) + + def run(x2, m2, mode): + return match.match(x1, y1, m1, x2, np.zeros(2), m2, + dr_tol=0.1, dm_tol=0.5, matching=mode, verbose=0) + + # Decisive: legacy drops it, chi2 takes the near one. + assert len(run(decisive_x, decisive_m, 'legacy')[0]) == 0, \ + 'legacy behaviour changed: the 3.6 mas match is no longer dropped' + i1, i2, dr, dm = run(decisive_x, decisive_m, 'chi2') + assert len(i1) == 1 and i2[0] == 0, \ + f'chi2 did not match the decisively closer candidate: {i2}' + np.testing.assert_allclose(dr[0], 0.0036, rtol=1e-6) + + # Genuine tie, 11 vs 13 mas: neither mode should guess. + assert len(run(tie_x, tie_m, 'legacy')[0]) == 0 + assert len(run(tie_x, tie_m, 'chi2')[0]) == 0, \ + 'chi2 matched a pair separated by less than the scatter from its rival' + + with pytest.raises(ValueError, match="matching must be"): + run(decisive_x, decisive_m, 'nonsense') + + +def test_chi2_matching_is_reciprocal(): + """ + Two stars competing for one reference star: only the pair that both sides + prefer survives, and only if it wins decisively. + + Legacy enforced one-to-one by arbitrating duplicates after the fact with + the same both-must-agree test. Reciprocity is symmetric by construction -- + the result cannot depend on which catalog is passed first. + """ + # Catalog 1: a star right on top of ref 0, and an interloper further away. + x1 = np.array([0.000, 0.030]) + y1 = np.array([0.0, 0.0]) + m1 = np.array([18.00, 18.10]) + # Catalog 2: a single reference star. + x2, y2, m2 = np.array([0.0]), np.array([0.0]), np.array([18.02]) + + i1, i2, dr, dm = match.match(x1, y1, m1, x2, y2, m2, dr_tol=0.05, dm_tol=0.5, + matching='chi2', verbose=0) + assert len(i1) == 1 and i1[0] == 0, \ + f'the contested reference star went to the wrong suitor: {i1}' + + # Symmetry: swapping the catalogs must pair the same two stars. + j1, j2, _, _ = match.match(x2, y2, m2, x1, y1, m1, dr_tol=0.05, dm_tol=0.5, + matching='chi2', verbose=0) + assert len(j1) == 1 and j2[0] == 0, \ + f'match is not symmetric under catalog order: {j2}' + + # Equidistant suitors of equal brightness are unresolvable, so neither wins. + x1_sym = np.array([-0.010, 0.010]) + m1_sym = np.array([18.02, 18.02]) + k1, _, _, _ = match.match(x1_sym, y1, m1_sym, x2, y2, m2, dr_tol=0.05, + dm_tol=0.5, matching='chi2', verbose=0) + assert len(k1) == 0, 'chi2 picked a winner between two identical suitors' + + +def test_chi2_scale_calibration_without_errors(): + """ + The chi^2 scales are measured from the starlists, with no error columns. + + Tier 1 uses unambiguous pairs; the estimate must track the injected + scatter. A catalog matched against itself has zero scatter, which must not + become a zero divisor -- the same class of degeneracy that made outlier + rejection reject everything. + """ + from flystar.match import calibrate_match_scales, robust_sigma + + rng = np.random.default_rng(5) + n = 500 + x = rng.uniform(0, 100, n) + y = rng.uniform(0, 100, n) + m = rng.uniform(14, 20, n) + + for truth in (0.002, 0.010): + x2 = x + rng.normal(0, truth, n) + y2 = y + rng.normal(0, truth, n) + m2 = m + rng.normal(0, 0.03, n) + i1, i2, _, _ = match.match(x2, y2, m2, x, y, m, dr_tol=0.05, dm_tol=0.5, + matching='chi2', verbose=0) + # Recover the scale the matcher would have measured for this pairing. + pair_i = np.arange(len(i1)) + s_pos, s_mag = calibrate_match_scales( + pair_i, x2[i1] - x[i2], y2[i1] - y[i2], m2[i1] - m[i2], + len(x2), 0.05, 0.5, verbose=0) + # sqrt(2) because both catalogs carry the injected scatter. + expect = truth * np.sqrt(2) + assert 0.6 * expect < s_pos < 1.4 * expect, \ + f'measured sigma_pos={s_pos:.5f}, expected about {expect:.5f}' + assert i1.size > 0.9 * n, f'only {i1.size} of {n} matched at scatter {truth}' + + # Zero scatter: a list against itself. Must not divide by zero, and must + # match every star to itself. + i1, i2, _, _ = match.match(x, y, m, x, y, m, dr_tol=0.05, dm_tol=0.5, + matching='chi2', verbose=0) + assert len(i1) > 0, 'self-match produced nothing -- zero scatter divided by zero' + np.testing.assert_array_equal(i1, i2) + + assert np.isnan(robust_sigma([])) + np.testing.assert_allclose(robust_sigma([1., 1., 1.]), 0.0) diff --git a/flystar/tests/test_motion_model.py b/flystar/tests/test_motion_model.py new file mode 100644 index 0000000..5aa2acb --- /dev/null +++ b/flystar/tests/test_motion_model.py @@ -0,0 +1,1047 @@ +from flystar import motion_model +import numpy as np +import matplotlib.pyplot as plt +from scipy.optimize import curve_fit + +def within_error(true_val, fit_val, fit_err, n_sigma=3): + return np.abs(true_val - fit_val) <= n_sigma*fit_err + +def test_Fixed(): + rng = np.random.default_rng(0) + # Test handling of a single star + true_params = {'x0': 1.0, 'y0':0.5, 'x0_err':0.1, 'y0_err':0.1} + mod = motion_model.Fixed() + param_list = mod.fit_param_names + # Confirm return of proper values for single t and array t + x_t, y_t = mod.model( + 0.0, + fit_params=np.array([true_params['x0'], true_params['y0']]).T + ) + assert x_t==true_params['x0'] + assert y_t==true_params['y0'] + x_t, y_t = mod.model( + [0.0,2025.0,10000], + fit_params=np.array([true_params['x0'], true_params['y0']]).T + ) + assert (x_t==true_params['x0']).all() + assert (y_t==true_params['y0']).all() + + # Check behavior of model + x0_batch = np.random.uniform(-2.0,2.0, 50) + y0_batch = np.random.uniform(-2.0,2.0, 50) + x0_err_batch = np.repeat(0.1, 50) + y0_err_batch = np.repeat(0.1, 50) + # Single epoch + t_batch=2020.0 + x_t_batch, y_t_batch, x_err_t_batch, y_err_t_batch = mod.model( + t_batch, + fit_params=np.array([x0_batch, y0_batch]).T, + fit_param_errs=np.array([x0_err_batch, y0_err_batch]).T + ) + assert (x_t_batch==x0_batch).all() + assert (y_t_batch==y0_batch).all() + assert (x_err_t_batch==x0_err_batch).all() + assert (y_err_t_batch==y0_err_batch).all() + # Multiple times + t_batch = np.arange(2015.0,2025.0, 0.5) + x_t_batch, y_t_batch, x_err_t_batch, y_err_t_batch = mod.model( + t_batch, + fit_params=np.array([x0_batch, y0_batch]).T, + fit_param_errs=np.array([x0_err_batch, y0_err_batch]).T + ) + assert (x_t_batch==np.array([np.repeat(x0_batch_i, len(t_batch)) for x0_batch_i in x0_batch])).all() + assert (y_t_batch==np.array([np.repeat(y0_batch_i, len(t_batch)) for y0_batch_i in y0_batch])).all() + assert (x_err_t_batch==np.array([np.repeat(x0_err_batch_i, len(t_batch)) for x0_err_batch_i in x0_err_batch])).all() + assert (y_err_t_batch==np.array([np.repeat(y0_err_batch_i, len(t_batch)) for y0_err_batch_i in y0_err_batch])).all() + + # Test fitter + t = np.arange(2015.0,2025.0, 0.5) + # Get values from model and add scatter + x_true, y_true = mod.model( + t, + fit_params=np.array([true_params['x0'], true_params['y0']]) + ) + x_sim = rng.normal(x_true, true_params['x0_err']) + y_sim = rng.normal(y_true, true_params['y0_err']) + xe = np.ones_like(t)*true_params['x0_err'] + ye = np.ones_like(t)*true_params['y0_err'] + # Run fit + params, param_errs = mod.fit( + t, + x_sim,y_sim, + xe=xe, + ye=ye + ) + + x_wt = 1. / xe**2 + y_wt = 1. / ye**2 + x_wt_norm = x_wt / np.sum(x_wt) + y_wt_norm = y_wt / np.sum(y_wt) + x_mean = np.average(x_sim, weights=x_wt) + y_mean = np.average(y_sim, weights=y_wt) + x_std = (np.sum(x_wt_norm**2 * xe**2))**0.5 + y_std = (np.sum(y_wt_norm**2 * ye**2))**0.5 + + # Confirm true value is within error bar of fit value + assert np.all([within_error(true_params[param_list[i]], params[i], param_errs[i]) for i in range(len(params))]) + np.testing.assert_allclose(params[0], x_mean, atol=1e-5) + np.testing.assert_allclose(params[1], y_mean, atol=1e-5) + np.testing.assert_allclose(param_errs[0], x_std, atol=1e-5) + np.testing.assert_allclose(param_errs[1], y_std, atol=1e-5) + + +def test_Linear(): + rng = np.random.default_rng(1) + # Test handling of a single star + true_params = {'x0': 1.0, 'y0':0.5, 'x0_err':0.1, 'y0_err':0.1, + 'vx':0.2, 'vy':0.5, 'vx_err':0.05, 'vy_err':0.05, + 't0':2025.0} + mod = motion_model.Linear() + param_list = mod.fit_param_names + # Confirm return of proper values for single t=t0 and array t + x_t, y_t = mod.model( + t=true_params['t0'], + fit_params=np.array([true_params[p] for p in param_list]).T, + fixed_params_dict={'t0': true_params['t0']} + ) + assert x_t==true_params['x0'] + assert y_t==true_params['y0'] + t_arr = np.array([2010.0,true_params['t0'],2030.0]) + x_t, y_t = mod.model( + t=t_arr, + fit_params=np.array([true_params[p] for p in param_list]).T, + fixed_params_dict={'t0': true_params['t0']} + ) + assert (x_t==(true_params['x0'] + (t_arr-true_params['t0'])*true_params['vx'])).all() + assert (y_t==(true_params['y0'] + (t_arr-true_params['t0'])*true_params['vy'])).all() + + # Check behavior of model + x0_batch = np.random.uniform(-2.0,2.0, 50) + y0_batch = np.random.uniform(-2.0,2.0, 50) + vx_batch = np.random.uniform(-2.0,2.0, 50) + vy_batch = np.random.uniform(-2.0,2.0, 50) + x0_err_batch = np.repeat(0.1, 50) + y0_err_batch = np.repeat(0.1, 50) + vx_err_batch = np.repeat(0.05, 50) + vy_err_batch = np.repeat(0.05, 50) + t0_batch = np.repeat(2025.0,50) + # Single epoch + t_batch=2020.0 + x_t_batch, y_t_batch, x_err_t_batch, y_err_t_batch = mod.model( + t=t_batch, + fit_params=np.array([x0_batch, vx_batch, y0_batch, vy_batch]).T, + fit_param_errs=np.array([x0_err_batch, vx_err_batch, y0_err_batch, vy_err_batch]).T, + fixed_params_dict={'t0': t0_batch} + ) + + np.testing.assert_allclose(x_t_batch, (x0_batch+(t_batch-t0_batch)*vx_batch), atol=1e-5) + np.testing.assert_allclose(y_t_batch, (y0_batch+(t_batch-t0_batch)*vy_batch), atol=1e-5) + np.testing.assert_allclose(x_err_t_batch, np.hypot(x0_err_batch, (t_batch-t0_batch)*vx_err_batch), atol=1e-5) + np.testing.assert_allclose(y_err_t_batch, np.hypot(y0_err_batch, (t_batch-t0_batch)*vy_err_batch), atol=1e-5) + + # Multiple times + t_batch = np.arange(2015.0,2025.0, 0.5) + x_t_batch, y_t_batch, x_err_t_batch, y_err_t_batch = mod.model( + t=t_batch, + fit_params=np.array([x0_batch, vx_batch, y0_batch, vy_batch]).T, + fit_param_errs=np.array([x0_err_batch, vx_err_batch, y0_err_batch, vy_err_batch]).T, + fixed_params_dict={'t0': t0_batch} + ) + np.testing.assert_allclose(x_t_batch, np.array([x0_batch[i] + (t_batch-t0_batch[i])*vx_batch[i] for i in range(len(x0_batch))]), atol=1e-5) + np.testing.assert_allclose(y_t_batch, np.array([y0_batch[i] + (t_batch-t0_batch[i])*vy_batch[i] for i in range(len(x0_batch))]), atol=1e-5) + np.testing.assert_allclose(x_err_t_batch, np.array([np.hypot(x0_err_batch[i], (t_batch-t0_batch[i])*vx_err_batch[i]) for i in range(len(x0_batch))]), atol=1e-5) + np.testing.assert_allclose(y_err_t_batch, np.array([np.hypot(y0_err_batch[i], (t_batch-t0_batch[i])*vy_err_batch[i]) for i in range(len(x0_batch))]), atol=1e-5) + + # Test fitter + t = np.arange(2015.0,2025.0, 0.5) + # Get values from model and add scatter + x_true, y_true = mod.model( + t=t, + fit_params=np.array([true_params[p] for p in param_list]).T, + fixed_params_dict={'t0': true_params['t0']} + ) + x_sim = rng.normal(x_true, 0.05) + y_sim = rng.normal(y_true, 0.05) + # Run fit + xe = np.ones_like(t)*0.05 + ye = np.ones_like(t)*0.05 + + def linear(t, x0, vx): + return x0 + vx * t + + for absolute_sigma in [True, False]: + for weighting in ['std', 'var']: + params, param_errs = mod.fit( + t=t, + x=x_sim, + y=y_sim, + xe=xe, + ye=ye, + fixed_params_dict={'t0': true_params['t0']}, + weighting=weighting, + absolute_sigma=absolute_sigma + ) + + # Scipy (independent ground truth to check our closed-form fit against) + xe_scipy = xe**0.5 if weighting=='std' else xe + ye_scipy = ye**0.5 if weighting=='std' else ye + x_popt, x_pcov = curve_fit( + linear, + t - true_params['t0'], + x_sim, + sigma=xe_scipy, + absolute_sigma=absolute_sigma, + p0=[np.mean(x_sim), 0.0] + ) + y_popt, y_pcov = curve_fit( + linear, + t - true_params['t0'], + y_sim, + sigma=ye_scipy, + absolute_sigma=absolute_sigma, + p0=[np.mean(y_sim), 0.0] + ) + np.testing.assert_allclose(params[:2], x_popt, atol=1e-5) + np.testing.assert_allclose(param_errs[:2], np.sqrt(np.diag(x_pcov)), atol=1e-5) + np.testing.assert_allclose(params[2:], y_popt, atol=1e-5) + np.testing.assert_allclose(param_errs[2:], np.sqrt(np.diag(y_pcov)), atol=1e-5) + + # Test fitter with bootstrap + t = np.arange(2015.0, 2025.0, 0.5) + # Get values from model and add scatter + x_true, y_true = mod.model( + t=t, + fit_params=np.array([true_params[p] for p in param_list]).T, + fixed_params_dict={'t0': true_params['t0']} + ) + x_true_err, y_true_err = np.ones_like(t)*0.05, np.ones_like(t)*0.05 + x_sim = rng.normal(x_true, x_true_err) + y_sim = rng.normal(y_true, y_true_err) + # Run fit + params, param_errs = mod.fit(t, x_sim, y_sim, x_true_err, y_true_err, fixed_params_dict={'t0': true_params['t0']}, bootstrap=10, seed=42) + # Confirm true value is within error bar of fit value + assert np.all([within_error(true_params[param_list[i]], params[i], param_errs[i]) for i in range(len(params))]) + + +def test_Acceleration(): + rng = np.random.default_rng(2) + # Test handling of a single star + true_params = {'x0': 1.0, 'y0':0.5, 'x0_err':0.1, 'y0_err':0.1, + 'vx0':0.2, 'vy0':0.5, 'vx0_err':0.05, 'vy0_err':0.05, + 'ax':0.1, 'ay':-0.1, 'ax_err':0.02, 'ay_err':0.02, + 't0':2025.0} + mod = motion_model.Acceleration() + param_list = mod.fit_param_names + # Confirm return of proper values for single t=t0 and array t + x_t, y_t = mod.model( + t=true_params['t0'], + fit_params=np.array([true_params[p] for p in param_list]).T, + fixed_params_dict={'t0': true_params['t0']} + ) + np.testing.assert_allclose(x_t, true_params['x0']) + np.testing.assert_allclose(y_t, true_params['y0']) + t_arr = np.array([2010.0, true_params['t0'], 2030.0]) + x_t, y_t = mod.model( + t=t_arr, + fit_params=np.array([true_params[p] for p in param_list]).T, + fixed_params_dict={'t0': true_params['t0']} + ) + np.testing.assert_allclose(x_t, true_params['x0'] + (t_arr-true_params['t0'])*true_params['vx0'] + 0.5*(t_arr-true_params['t0'])**2*true_params['ax']) + np.testing.assert_allclose(y_t, true_params['y0'] + (t_arr-true_params['t0'])*true_params['vy0'] + 0.5*(t_arr-true_params['t0'])**2*true_params['ay']) + + # Check behavior of model + x0_batch = np.random.uniform(-2.0,2.0, 50) + y0_batch = np.random.uniform(-2.0,2.0, 50) + vx0_batch = np.random.uniform(-2.0,2.0, 50) + vy0_batch = np.random.uniform(-2.0,2.0, 50) + ax_batch = np.random.uniform(-1.0,1.0, 50) + ay_batch = np.random.uniform(-1.0,1.0, 50) + x0_err_batch = np.repeat(0.1, 50) + y0_err_batch = np.repeat(0.1, 50) + vx0_err_batch = np.repeat(0.05, 50) + vy0_err_batch = np.repeat(0.05, 50) + ax_err_batch = np.repeat(0.02, 50) + ay_err_batch = np.repeat(0.02, 50) + t0_batch = np.repeat(2025.0,50) + # Single epoch + t_batch=2020.0 + x_t_batch, y_t_batch, x_err_t_batch, y_err_t_batch = mod.model( + t=t_batch, + fit_params=np.array([x0_batch, vx0_batch, ax_batch, y0_batch, vy0_batch, ay_batch]).T, + fit_param_errs=np.array([x0_err_batch, vx0_err_batch, ax_err_batch, y0_err_batch, vy0_err_batch, ay_err_batch]).T, + fixed_params_dict={'t0': t0_batch} + ) + np.testing.assert_allclose(x_t_batch, x0_batch + (t_batch-t0_batch)*vx0_batch + 0.5*(t_batch-t0_batch)**2*ax_batch) + np.testing.assert_allclose(y_t_batch, y0_batch + (t_batch-t0_batch)*vy0_batch + 0.5*(t_batch-t0_batch)**2*ay_batch) + np.testing.assert_allclose(x_err_t_batch, np.sqrt(x0_err_batch**2 + ((t_batch-t0_batch)*vx0_err_batch)**2 + + (0.5*(t_batch-t0_batch)**2*ax_err_batch)**2)) + np.testing.assert_allclose(y_err_t_batch, np.sqrt(y0_err_batch**2 + ((t_batch-t0_batch)*vy0_err_batch)**2 + + (0.5*(t_batch-t0_batch)**2*ay_err_batch)**2)) + + # Multiple times + t_batch = np.arange(2015.0,2025.0, 0.5) + x_t_batch, y_t_batch, x_err_t_batch, y_err_t_batch = mod.model( + t=t_batch, + fit_params=np.array([x0_batch, vx0_batch, ax_batch, y0_batch, vy0_batch, ay_batch]).T, + fit_param_errs=np.array([x0_err_batch, vx0_err_batch, ax_err_batch, y0_err_batch, vy0_err_batch, ay_err_batch]).T, + fixed_params_dict={'t0': t0_batch} + ) + np.testing.assert_allclose(x_t_batch, np.array([x0_batch[i] + (t_batch-t0_batch[i])*vx0_batch[i] + 0.5*(t_batch-t0_batch[i])**2*ax_batch[i] for i in range(len(x0_batch))])) + np.testing.assert_allclose(y_t_batch, np.array([y0_batch[i] + (t_batch-t0_batch[i])*vy0_batch[i] + 0.5*(t_batch-t0_batch[i])**2*ay_batch[i] for i in range(len(x0_batch))])) + np.testing.assert_allclose(x_err_t_batch, np.array([np.sqrt(x0_err_batch[i]**2 + ((t_batch-t0_batch[i])*vx0_err_batch[i])**2 + (0.5*(t_batch-t0_batch[i])**2*ax_err_batch[i])**2) for i in range(len(x0_batch))])) + np.testing.assert_allclose(y_err_t_batch, np.array([np.sqrt(y0_err_batch[i]**2 + ((t_batch-t0_batch[i])*vy0_err_batch[i])**2 + (0.5*(t_batch-t0_batch[i])**2*ay_err_batch[i])**2) for i in range(len(x0_batch))])) + + # Test fitter + t = np.arange(2015.0,2025.0, 0.5) + # Get values from model and add scatter + x_true, y_true = mod.model( + t=t, + fit_params=np.array([true_params[p] for p in param_list]).T, + fixed_params_dict={'t0': true_params['t0']} + ) + x_true_err = np.sqrt(true_params['x0_err']**2 + ((t - true_params['t0']) * true_params['vx0_err'])**2 + + (0.5*(t - true_params['t0'])**2 * true_params['ax_err'])**2) + y_true_err = np.sqrt(true_params['y0_err']**2 + ((t - true_params['t0']) * true_params['vy0_err'])**2 + + (0.5*(t - true_params['t0'])**2 * true_params['ay_err'])**2) + x_sim = rng.normal(x_true, x_true_err) + y_sim = rng.normal(y_true, y_true_err) + # Run fit + mod_fit = motion_model.Acceleration() + params, param_errs = mod_fit.fit( + t=t, + x=x_sim, + y=y_sim, + xe=x_true_err, + ye=y_true_err, + fixed_params_dict={'t0': true_params['t0']} + ) + # Confirm true value is within error bar of fit value + assert np.all([within_error(true_params[param_list[i]], params[i], param_errs[i]) for i in range(len(params))]) + +#@pytest.mark.skip(reason="not written") +def test_Parallax(): + rng = np.random.default_rng(4) + # Test handling of a single star + true_params = {'x0': 1.0, 'y0':-0.5, 'x0_err':0.1, 'y0_err':0.1, + 'vx':-0.2, 'vy':0.5, 'vx_err':0.05, 'vy_err':0.05, + 'pi':0.5, 'ra':17.76, 'dec':-28.933, 'pa':0, + 't0':2020.0, 'obsLocation': 'earth'} + mod = motion_model.Parallax() + param_list = mod.fit_param_names + fixed_params_dict = { + 't0': true_params['t0'], + 'ra': true_params['ra'], + 'dec': true_params['dec'], + 'pa': true_params['pa'], + 'obsLocation': true_params['obsLocation'] + } + + # Test fitter + t = np.arange(2015.0,2025.0, 0.5) + # Get values from model and add scatter + x_true, y_true = mod.model( + t=t, + fit_params=np.array([true_params[p] for p in param_list]).T, + fixed_params_dict=fixed_params_dict + ) + x_true_err, y_true_err = np.ones_like(t)*true_params['x0_err'], np.ones_like(t)*true_params['y0_err'] + x_sim = rng.normal(x_true, x_true_err) + y_sim = rng.normal(y_true, y_true_err) + # Run fit + params, param_errs = mod.fit(t, x_sim,y_sim, x_true_err, y_true_err, fixed_params_dict=fixed_params_dict) + + x_model, y_model = mod.model( + t=t, + fit_params=params, + fixed_params_dict=fixed_params_dict + ) + plt.clf() + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 5)) + ax1.plot(t, x_model, color='C3', lw=2, label='Model x') + ax1.plot(t, x_true, color='C0', ls='--', label='True x') + ax1.errorbar(t, x_sim, yerr=x_true_err, fmt='o', color='C0', label='Sim x') + ax1.set_xlabel('t') + ax1.set_ylabel('x') + ax1.legend() + ax2.plot(t, y_model, color='C3', lw=2, label='Model y') + ax2.plot(t, y_true, color='C0', ls='--', label='True y') + ax2.errorbar(t, y_sim, yerr=y_true_err, fmt='o', color='C0', label='Sim y') + ax2.set_xlabel('t') + ax2.set_ylabel('y') + ax2.legend() + plt.tight_layout() + + # Confirm true value is within error bar of fit value + assert np.all([within_error(true_params[param_list[i]], params[i], param_errs[i]) for i in range(len(params))]) + +def test_Parallax_PA(): + # Set PA=0 model + x0, y0 = 2.0, -1.0 + vx, vy = 0.2, 0.5 + ra, dec = 17.76, -28.933 + pi = 0.5 + mod_pa0 = motion_model.Parallax() + # Set PA=90 model with equivalent parameters in that frame + mod_pa90 = motion_model.Parallax() + + t_set = np.arange(2018, 2024, 0.01) + t0 = 2020.0 + dat_pa0 = mod_pa0.model( + t = t_set, + fit_params = np.array([x0, vx, y0, vy, pi]).T, + fixed_params_dict = {'t0': t0, 'ra': ra, 'dec': dec, 'pa': 0} + ) + dat_pa90 = mod_pa90.model( + t = t_set, + fit_params = np.array([y0, vy, -x0, -vx, pi]).T, + fixed_params_dict = {'t0': t0, 'ra': ra, 'dec': dec, 'pa': 90} + ) + np.testing.assert_allclose(dat_pa0[0], -dat_pa90[1], atol=1e-10) + np.testing.assert_allclose(dat_pa0[1], dat_pa90[0], atol=1e-10) + + +def test_motion_model_param_names_dedup(): + """ + motion_model_param_names() used to re-expand fit_param_names/fixed_param_names + once per input entry even when the same motion model repeated thousands of + times (e.g. align.py passing one entry per star). It now dedups the input + first. Check a heavily-duplicated input still gives the same result as the + plain unique input. + """ + repeated_names = ['Fixed', 'Linear'] * 5000 + got = motion_model.motion_model_param_names(repeated_names, with_errors=True, with_fixed=True) + want = motion_model.motion_model_param_names(['Fixed', 'Linear'], with_errors=True, with_fixed=True) + assert got == want + + # Order of first appearance should still control the output order. + reordered = ['Linear', 'Fixed'] * 3000 + got_reordered = motion_model.motion_model_param_names(reordered, with_errors=True, with_fixed=True) + want_reordered = motion_model.motion_model_param_names(['Linear', 'Fixed'], with_errors=True, with_fixed=True) + assert got_reordered == want_reordered + assert got_reordered != got # different first-seen order -> different param order + + # Mixing model classes with their string names should still be correct. + mixed = [motion_model.Fixed, 'Fixed', motion_model.Linear, 'Linear'] * 100 + got_mixed = motion_model.motion_model_param_names(mixed, with_errors=True, with_fixed=True) + assert got_mixed == want + + # with_errors=False / with_fixed=False should still behave as before. + got_no_extras = motion_model.motion_model_param_names(repeated_names, with_errors=False, with_fixed=False) + want_no_extras = motion_model.motion_model_param_names(['Fixed', 'Linear'], with_errors=False, with_fixed=False) + assert got_no_extras == want_no_extras + + +def test_Fixed_run_fit(): + """ + Fixed.run_fit() fits many stars at once in a single vectorized call + (closed-form weighted average, no iterative optimizer needed). Check it + against a per-star loop calling fit() directly (which wraps a single + star into a batch of one row and calls run_fit() itself), across a + battery of randomized cases: full epochs, ragged (different numbers of + valid epochs per star), a star with exactly one valid epoch + (degree_of_freedom == 0), a star with zero valid epochs (not enough + data), var/std weighting, and absolute_sigma True/False. + """ + rng = np.random.default_rng(3) + n_stars = 40 + n_epochs = 6 + + t = np.tile(np.arange(n_epochs) + 2020.0, (n_stars, 1)) + x = rng.normal(100, 5, size=(n_stars, n_epochs)) + y = rng.normal(-50, 5, size=(n_stars, n_epochs)) + xe = rng.uniform(0.01, 0.5, size=(n_stars, n_epochs)) + ye = rng.uniform(0.01, 0.5, size=(n_stars, n_epochs)) + + valid = rng.random((n_stars, n_epochs)) > 0.3 + valid[0, :] = False # zero valid epochs -- not enough data + valid[1, :] = False + valid[1, 2] = True # exactly one valid epoch -- degree_of_freedom == 0 + valid[2, :] = True # fully detected, for a clean baseline case + + for weighting, absolute_sigma in [('var', True), ('std', True), ('var', False)]: + mod = motion_model.Fixed() + got_params, got_errs, got_chi2x, got_chi2y = mod.run_fit( + t, x, y, xe, ye, valid, weighting=weighting, absolute_sigma=absolute_sigma, + fill_value=np.nan, verbose=False + ) + + want_params = np.full((n_stars, 2), np.nan) + want_errs = np.full((n_stars, 2), np.inf) + want_chi2x = np.full(n_stars, np.nan) + want_chi2y = np.full(n_stars, np.nan) + for i in range(n_stars): + idx = np.flatnonzero(valid[i]) + params, errs, chi2x, chi2y = mod.fit( + t=t[i][idx], x=x[i][idx], y=y[i][idx], xe=xe[i][idx], ye=ye[i][idx], + weighting=weighting, absolute_sigma=absolute_sigma, + fill_value=np.nan, return_chi2=True, bootstrap=0, verbose=False + ) + want_params[i] = params + want_errs[i] = errs + want_chi2x[i] = chi2x + want_chi2y[i] = chi2y + + np.testing.assert_allclose(got_params, want_params, rtol=1e-10, atol=1e-10, equal_nan=True, + err_msg=f"weighting={weighting} absolute_sigma={absolute_sigma}: params mismatch") + np.testing.assert_allclose(got_errs, want_errs, rtol=1e-10, atol=1e-10, equal_nan=True, + err_msg=f"weighting={weighting} absolute_sigma={absolute_sigma}: errs mismatch") + np.testing.assert_allclose(got_chi2x, want_chi2x, rtol=1e-10, atol=1e-10, equal_nan=True, + err_msg=f"weighting={weighting} absolute_sigma={absolute_sigma}: chi2x mismatch") + np.testing.assert_allclose(got_chi2y, want_chi2y, rtol=1e-10, atol=1e-10, equal_nan=True, + err_msg=f"weighting={weighting} absolute_sigma={absolute_sigma}: chi2y mismatch") + +# ---------------------------------------------------------------------- +# scipy.optimize.curve_fit agreement +# +# Every MotionModel.run_fit is a closed-form (vectorized) weighted +# least-squares solve rather than an iterative optimizer call, so nothing +# structurally guarantees it still matches scipy's conventions -- these +# tests pin that down. They caught a real bug: Fixed.run_fit computed chi2 +# as residual**2/xe**2 instead of using the fit's own weights (1/sigma**2). +# Those coincide only for weighting='var'; under weighting='std' the fit +# weight is 1/xe, so chi2 (and, through the absolute_sigma=False +# sqrt(chi2/dof) rescaling, the reported parameter errors) disagreed with +# curve_fit. Keep weighting='std' x absolute_sigma=False in the sweep. +# ---------------------------------------------------------------------- + +_CF_KW = dict(xtol=1e-14, ftol=1e-14, gtol=1e-14, maxfev=200000) + +_FIXED_BASIS = lambda tt, x0: x0 + 0.0 * tt +_LINEAR_BASIS = lambda tt, x0, v: x0 + v * tt +_ACCEL_BASIS = lambda tt, x0, v, a: x0 + v * tt + 0.5 * a * tt ** 2 + +_INDEP_MODELS = [ + ('Fixed', motion_model.Fixed, _FIXED_BASIS, 1), + ('Linear', motion_model.Linear, _LINEAR_BASIS, 2), + ('Acceleration', motion_model.Acceleration, _ACCEL_BASIS, 3), +] + + +def _wchi2(resid, sigma): + return float(np.sum((resid / sigma) ** 2)) + + +def test_scipy_agreement_independent_models(): + """ + Fixed/Linear/Acceleration fit x and y independently -- compare each + direction against its own curve_fit, over both weighting schemes, both + absolute_sigma settings, several epoch counts (including exactly + n_params, where dof == 0), and nan-padded epochs. + """ + for name, cls, basis, n_par in _INDEP_MODELS: + for n_epochs in [n_par, n_par + 1, 8]: + for weighting in ['var', 'std']: + for absolute_sigma in [True, False]: + for nan_count in ([0, 2] if n_epochs == 8 else [0]): + _check_independent(name, cls, basis, n_par, n_epochs, + weighting, absolute_sigma, nan_count) + + +def _check_independent(name, cls, basis, n_par, n_epochs, weighting, + absolute_sigma, nan_count): + seed = abs(hash((name, n_epochs, weighting, absolute_sigma, nan_count))) % (2**31) + rng = np.random.default_rng(seed) + + t = 2020.0 + np.arange(n_epochs, dtype=float) * 0.9 + t0 = float(np.mean(t)) + dt = t - t0 + + tx = rng.normal(50, 2, n_par) + ty = rng.normal(30, 2, n_par) + xe = rng.uniform(0.5, 2.0, n_epochs) * 1e-3 + ye = rng.uniform(0.5, 2.0, n_epochs) * 1e-3 + x = basis(dt, *tx) + rng.normal(0, 1, n_epochs) * xe + y = basis(dt, *ty) + rng.normal(0, 1, n_epochs) * ye + if nan_count: + idx = rng.choice(n_epochs, size=nan_count, replace=False) + x[idx] = np.nan + y[idx] = np.nan + + model = cls() + tag = (f'{name} N={n_epochs} nan={nan_count} weighting={weighting} ' + f'absolute_sigma={absolute_sigma}') + + if nan_count: + # nan padding is only supported on the 2D batch path -- the 1D path's + # contract is data the caller already filtered down to real epochs. + gp, ge, gx2, gy2 = model.fit( + t[None, :], x[None, :], y[None, :], xe[None, :], ye[None, :], + fixed_params_dict={'t0': t0}, weighting=weighting, + absolute_sigma=absolute_sigma, verbose=False) + gp, ge, gx2, gy2 = gp[0], ge[0], gx2[0], gy2[0] + else: + gp, ge, gx2, gy2 = model.fit( + t, x, y, xe, ye, fixed_params_dict={'t0': t0}, weighting=weighting, + absolute_sigma=absolute_sigma, return_chi2=True, verbose=False) + + valid = np.isfinite(x) & np.isfinite(y) + n_valid = int(valid.sum()) + sx, sy = motion_model.sigma_from_error(xe, ye, weighting=weighting) + dtv = dt[valid] + + want_p, want_e = [], [] + for val, sig, truth in ((x[valid], sx[valid], tx), (y[valid], sy[valid], ty)): + popt, pcov = curve_fit(lambda tt, *p: basis(tt, *p), dtv, val, + p0=np.array(truth, dtype=float), sigma=sig, + absolute_sigma=absolute_sigma, **_CF_KW) + want_p.append(popt) + want_e.append(np.sqrt(np.diag(pcov))) + want_p = np.concatenate(want_p) + want_e = np.concatenate(want_e) + + if n_valid == n_par and not absolute_sigma: + # dof == 0: scipy's cov is inf/nan; flystar's contract is inf + want_e = np.full_like(want_e, np.inf) + + np.testing.assert_allclose(gp, want_p, rtol=1e-6, atol=1e-12, + err_msg=f'{tag}: params disagree with curve_fit') + np.testing.assert_allclose(ge, want_e, rtol=1e-5, atol=1e-14, + err_msg=f'{tag}: param errors disagree with curve_fit') + + # chi2 is compared using scipy's definition evaluated at flystar's OWN + # params: for a near-exact (low-dof) fit chi2 is so sensitive to the + # parameters that curve_fit's residual convergence error would dominate, + # even though the params themselves agree to ~1e-11 relative. + want_c2 = (_wchi2(x[valid] - basis(dtv, *gp[:n_par]), sx[valid]), + _wchi2(y[valid] - basis(dtv, *gp[n_par:]), sy[valid])) + np.testing.assert_allclose([gx2, gy2], want_c2, rtol=1e-9, atol=1e-14, + err_msg=f'{tag}: chi2 disagrees with curve_fit definition') + + +def test_scipy_agreement_parallax(): + """ + Parallax is a single joint 5-parameter fit over the stacked [x, y] data + (pi is shared between both directions), so it's compared against one + curve_fit over that same stacked model -- not two independent fits. + """ + from astropy.time import Time + + ra, dec = 266.4, -29.0 + truth = np.array([50.0, 0.4, 30.0, -0.3, 0.05]) + + for n_epochs in [4, 9]: + for weighting in ['var', 'std']: + for absolute_sigma in [True, False]: + seed = abs(hash((n_epochs, weighting, absolute_sigma))) % (2**31) + rng = np.random.default_rng(seed) + + t = 2020.0 + np.arange(n_epochs, dtype=float) * 0.7 + t0 = float(np.mean(t)) + dt = t - t0 + + model = motion_model.Parallax() + pvec = model.calc_parallax_vector( + Time(t, format='decimalyear', scale='utc').tdb.mjd, + np.array([ra]), np.array([dec]), + pa=np.array([0.0]), obsLocation='earth') + Px, Py = pvec[0, 0, :].copy(), pvec[0, 1, :].copy() + model.pvec_cached = None + model.t_mjd_cached = None + + xe = rng.uniform(0.5, 2.0, n_epochs) * 1e-3 + ye = rng.uniform(0.5, 2.0, n_epochs) * 1e-3 + x = truth[0] + truth[1]*dt + truth[4]*Px + rng.normal(0, 1, n_epochs)*xe + y = truth[2] + truth[3]*dt + truth[4]*Py + rng.normal(0, 1, n_epochs)*ye + + tag = (f'Parallax N={n_epochs} weighting={weighting} ' + f'absolute_sigma={absolute_sigma}') + + gp, ge, gx2, gy2 = model.fit( + t, x, y, xe, ye, + fixed_params_dict={'t0': t0, 'ra': ra, 'dec': dec}, + weighting=weighting, absolute_sigma=absolute_sigma, + return_chi2=True, verbose=False) + + sx, sy = motion_model.sigma_from_error(xe, ye, weighting=weighting) + + def f_joint(dummy, x0, vx, y0, vy, pi): + return np.concatenate([x0 + vx*dt + pi*Px, + y0 + vy*dt + pi*Py]) + + data = np.concatenate([x, y]) + sig = np.concatenate([sx, sy]) + dummy = np.arange(2 * n_epochs) + popt, pcov = curve_fit(f_joint, dummy, data, p0=truth, sigma=sig, + absolute_sigma=absolute_sigma, **_CF_KW) + want_e = np.sqrt(np.diag(pcov)) + if 2 * n_epochs - 5 <= 0 and not absolute_sigma: + want_e = np.full(5, np.inf) + + np.testing.assert_allclose(gp, popt, rtol=1e-6, atol=1e-12, + err_msg=f'{tag}: params disagree with curve_fit') + np.testing.assert_allclose(ge, want_e, rtol=1e-5, atol=1e-14, + err_msg=f'{tag}: param errors disagree with curve_fit') + + resid = (data - f_joint(dummy, *gp)) / sig + want_c2 = (float((resid[:n_epochs]**2).sum()), + float((resid[n_epochs:]**2).sum())) + np.testing.assert_allclose([gx2, gy2], want_c2, rtol=1e-9, atol=1e-14, + err_msg=f'{tag}: chi2 disagrees with curve_fit definition') + + +# ---------------------------------------------------------------------- +# Time-argument shape contract (motion_model.broadcast_times) +# +# model() used to infer "one time per star" from len(t) == N_stars, so the +# same 1D array changed meaning based on how many stars the table happened +# to hold -- and a table whose star count equalled its epoch count took the +# per-star branch by accident, then crashed in infer_positions. Shape alone +# decides now; these tests pin that, especially the coincidence case, which +# previously had no coverage anywhere in the suite. +# ---------------------------------------------------------------------- + +def test_broadcast_times_shape_contract(): + n_stars = 3 + bt = motion_model.broadcast_times + + assert bt(2025.0, n_stars).shape == (n_stars, 1) + assert bt(np.array([1., 2., 3., 4.]), n_stars).shape == (n_stars, 4) + # 1D whose length equals n_stars is a SHARED grid, never per-star + assert bt(np.array([1., 2., 3.]), n_stars).shape == (n_stars, 3) + assert bt(np.array([[1., 2., 3., 4.]]), n_stars).shape == (n_stars, 4) + assert bt(np.array([[1.], [2.], [3.]]), n_stars).shape == (n_stars, 1) + assert bt(np.zeros((n_stars, 5)), n_stars).shape == (n_stars, 5) + + # a shared 1D grid really is shared: every row identical + grid = bt(np.array([1., 2., 3.]), n_stars) + assert np.all(grid == np.array([1., 2., 3.])[np.newaxis, :]) + + # unusable shapes raise instead of being guessed at + for bad in [np.zeros((7, 2)), np.zeros((2, 2, 2))]: + try: + bt(bad, n_stars) + except ValueError: + pass + else: + raise AssertionError(f'broadcast_times accepted bad shape {bad.shape}') + + +def test_model_time_shape_contract(): + """Every model honors the same time-shape contract and agrees, row by + row, with independent single-star evaluations.""" + t0 = np.array([2020., 2021., 2022.]) + t_shared = np.array([2025., 2026., 2027., 2028.]) + t_coincide = np.array([2025., 2026., 2027.]) # len == N_stars + t_per_star = np.array([[2025., 2026.], [2030., 2031.], [2035., 2036.]]) + + cases = [ + (motion_model.Fixed, np.array([[1., 2.], [3., 4.], [5., 6.]]), {}), + (motion_model.Linear, + np.array([[10., 1., 20., 2.], [30., -1., 40., .5], [50., 0., 60., -3.]]), + {'t0': t0}), + (motion_model.Acceleration, + np.array([[10., 1., .1, 20., 2., -.1], + [30., -1., .2, 40., .5, .05], + [50., 0., -.3, 60., -3., .01]]), + {'t0': t0}), + (motion_model.Parallax, + np.array([[10., 1., 20., 2., .05], + [30., -1., 40., .5, .04], + [50., 0., 60., -3., .03]]), + {'t0': t0, 'ra': 266.4, 'dec': -29.0}), + ] + + for cls, params, fpd in cases: + n_stars = params.shape[0] + + assert np.shape(cls().model(t_shared, params, fixed_params_dict=fpd)[0]) \ + == (n_stars, len(t_shared)), f'{cls.__name__}: shared grid' + + # the coincidence case: must be a shared grid, NOT per-star + x_co = cls().model(t_coincide, params, fixed_params_dict=fpd)[0] + assert np.shape(x_co) == (n_stars, len(t_coincide)), \ + f'{cls.__name__}: len(t) == N_stars must stay a shared grid' + x_1x = cls().model(t_coincide[np.newaxis, :], params, fixed_params_dict=fpd)[0] + np.testing.assert_allclose( + x_co, x_1x, rtol=1e-12, atol=1e-12, + err_msg=f'{cls.__name__}: (N,) and (1,N) must agree') + + # per-star times: each row equals an independent single-star fit + x_ps, y_ps = cls().model(t_per_star, params, fixed_params_dict=fpd)[:2] + assert np.shape(x_ps) == t_per_star.shape, f'{cls.__name__}: per-star grid' + for i in range(n_stars): + fpd_i = {k: (np.atleast_1d(v)[i] if (not isinstance(v, str) + and np.ndim(v) > 0) else v) + for k, v in fpd.items()} + xi, yi = cls().model(t_per_star[i], params[i], fixed_params_dict=fpd_i)[:2] + np.testing.assert_allclose( + x_ps[i], xi, rtol=1e-10, atol=1e-10, + err_msg=f'{cls.__name__}: per-star row {i} x') + np.testing.assert_allclose( + y_ps[i], yi, rtol=1e-10, atol=1e-10, + err_msg=f'{cls.__name__}: per-star row {i} y') + + +def test_infer_positions_time_shape_contract(): + """StarTable.infer_positions with N_stars == N_times used to raise a + broadcasting ValueError, because model() silently switched to its + per-star interpretation. It is a shared grid now.""" + from flystar.startables import StarTable + + n = 6 # deliberately N_stars == N_times + rng = np.random.default_rng(0) + t = np.tile(2020.0 + np.arange(n, dtype=float), (n, 1)) + tab = StarTable(name=[f's{i}' for i in range(n)], + x=rng.uniform(10, 50, (n, n)), y=rng.uniform(10, 50, (n, n)), + m=np.full((n, n), 15.0), xe=np.full((n, n), 0.01), + ye=np.full((n, n), 0.01), me=np.full((n, n), 0.01), t=t) + tab.fit_motion_models(motion_models=['Linear'], verbose=False) + + times = 2020.0 + np.arange(n, dtype=float) + x, y, xe, ye = tab.infer_positions(times) + assert x.shape == (n, n), f'expected shared grid (6, 6), got {x.shape}' + + # identical to spelling the same grid as (1, N_times) + x2, _, _, _ = tab.infer_positions(times[np.newaxis, :]) + np.testing.assert_allclose(x, x2, rtol=1e-12, atol=1e-12, equal_nan=True) + + # per-star times: give every star its own grid, check against a shared + # call for the subset of stars whose row is that same grid + per_star = np.tile(times, (n, 1)) + x3, _, _, _ = tab.infer_positions(per_star) + np.testing.assert_allclose(x, x3, rtol=1e-12, atol=1e-12, equal_nan=True) + + # one time per star, as a column vector + x4, _, _, _ = tab.infer_positions(times[:, np.newaxis]) + assert x4.shape == (n,), f'expected (6,) for one time per star, got {x4.shape}' + for i in range(n): + assert np.isclose(x4[i], x3[i, i], rtol=1e-10, atol=1e-10) + + +def test_organize_motion_models_accepts_any_case(): + """ + organize_motion_models() matches names case-insensitively, and only the + canonical spelling propagates. + + Every model name is a single word, so str.capitalize() is an exact + normalization. The point of the test is not just that 'linear' is accepted + but that nothing downstream can tell how it was spelled: what comes back is + the class, and the name reaching a ref_table is that class's .name. + """ + import pytest + from flystar.motion_model import organize_motion_models as org + + canonical = ['Empty', 'Fixed', 'Linear'] + + # A bare string, in any casing. + for spelling in ['Linear', 'linear', 'LINEAR', 'lInEaR']: + got = [mm.name for mm in org(spelling)] + assert got == canonical, f'{spelling!r} gave {got}' + + # Inside a list, and mixed with the classes themselves. + assert [mm.name for mm in org(['linear', 'acceleration'])] == \ + ['Empty', 'Fixed', 'Linear', 'Acceleration'] + assert [mm.name for mm in org([motion_model.Linear, 'parallax'])] == \ + ['Empty', 'Fixed', 'Linear', 'Parallax'] + assert [mm.name for mm in org(('linear',))] == canonical + + # Every valid name resolves from its lower-case form. + for name in ['empty', 'fixed', 'linear', 'acceleration', 'parallax']: + assert name.capitalize() in [mm.name for mm in org(name)] + + # A genuine typo still fails -- case-insensitivity must not become + # fuzzy matching. The message quotes what the caller wrote, not the + # capitalized form, so it is recognizable in a traceback. + for bad in ['Quadratic', 'quadratic', 'lineaar', '']: + with pytest.raises(AssertionError) as err: + org(bad) + assert repr(bad).strip("'") in str(err.value) or bad == '' + + +def _mini_table(n_stars=6, n_epochs=8, varying_radec=True): + """A small StarTable with clean Linear motion, for the tests below.""" + from flystar.startables import StarTable + rng = np.random.default_rng(0) + t = np.tile(np.linspace(2015., 2025., n_epochs), (n_stars, 1)) + x = rng.uniform(0, 100, (n_stars, 1)) + rng.normal(0, .05, (n_stars, n_epochs)) + y = rng.uniform(0, 100, (n_stars, 1)) + rng.normal(0, .05, (n_stars, n_epochs)) + e = np.full((n_stars, n_epochs), .05) + m = np.tile(rng.uniform(12, 19, (n_stars, 1)), (1, n_epochs)) + tab = StarTable(name=np.array([f'S{i}' for i in range(n_stars)]), + x=x, y=y, m=m, xe=e, ye=e, me=e, t=t) + off = np.arange(n_stars) * 0.01 if varying_radec else np.zeros(n_stars) + tab['ra'] = 18.0 + off + tab['dec'] = -30.0 + off + return tab + + +def test_determine_motion_models_reads_meta(): + """ + determine_motion_models() must treat table metadata as a source of fixed + parameters, like the lookups it gates do. + + fit_motion_models() stores a fixed parameter that is uniform across stars + in meta -- only a per-star one becomes a column -- so fitting Parallax with + one ra/dec/pa for the whole table leaves 'pa' and 'obsLocation' in meta. + Checking only columns and fixed_params_dict made Parallax un-selectable + afterwards, so infer_positions silently demoted those stars to Linear and + dropped the parallax term: the table said motion_model_used='Parallax' and + carried a fitted pi, yet was propagated as if pi were zero. + """ + from flystar.motion_model import determine_motion_models + + tab = _mini_table() + tab.fit_motion_models(motion_models=['Parallax'], verbose=False) + + # Precondition: this is the storage split that used to break selection. + assert 'pa' in tab.meta and 'pa' not in tab.colnames + assert 'Parallax' in np.unique(np.asarray(tab['motion_model_used']).astype(str)) + + used = np.unique(np.asarray(determine_motion_models(tab)[0]).astype(str)) + assert set(used) == {'Parallax'}, f'expected Parallax from meta, got {used}' + + # And it still round-trips through the public entry point. + x_pred, y_pred, _, _ = tab.infer_positions(np.array([2030.])) + assert np.all(np.isfinite(x_pred)) and np.all(np.isfinite(y_pred)) + + # A non-finite fixed parameter must still disqualify the model, whether it + # sits in meta or anywhere else -- the fix widens where we look, not what + # counts as usable. + tab.meta['pa'] = np.nan + used_nan = np.unique(np.asarray(determine_motion_models(tab)[0]).astype(str)) + assert 'Parallax' not in used_nan, f'nan pa should disqualify Parallax, got {used_nan}' + + +def test_infer_positions_accepts_array_fixed_params(): + """ + A fixed parameter may be an array of length n_stars. + + fit_motion_models documents scalars as applying to every star and arrays as + per-star, but determine_motion_models tested np.isfinite(value) for its + truth value, which raises for any array longer than one element -- so the + per-star form the API invites crashed on the way into infer_positions. + """ + tab = _mini_table() + tab.fit_motion_models(motion_models=['Linear'], verbose=False) + n = len(tab) + + t0 = np.asarray(tab['t0']).copy() if 't0' in tab.colnames else np.full(n, 2020.) + + from_column, _, _, _ = tab.infer_positions(np.array([2030.])) + from_array, _, _, _ = tab.infer_positions(np.array([2030.]), + fixed_params_dict={'t0': t0}) + shifted, _, _, _ = tab.infer_positions(np.array([2030.]), + fixed_params_dict={'t0': t0 + 100.}) + + # Same numbers as reading t0 off the column ... + np.testing.assert_allclose(np.asarray(from_column), np.asarray(from_array), + rtol=1e-10, atol=1e-10) + # ... and the dict is actually consulted, not silently dropped. + assert not np.allclose(np.asarray(from_column), np.asarray(shifted)) + + +def test_fixed_params_written_back_under_canonical_name(): + """ + Whatever the fit used must be readable back under ````. + + That is the name infer_positions and determine_motion_models search, so a + fixed parameter written anywhere else means the propagation runs with + parameters the fit never saw -- either demoting the star to a simpler model + (the name is missing) or propagating the requested model with somebody + else's values. Previously a per-star value went to ``_mm``, and a + uniform value conflicting with an existing column went to meta where that + column shadowed it. + + A caller's own values are not destroyed: on a genuine conflict they move to + ``_orig``, the convention align.py uses when it replaces x/y/m with + transformed values. + """ + from flystar.motion_model import determine_motion_models + + n = 6 + ra_per_star = 18.0 + np.arange(n) * 0.01 + dec_per_star = -30.0 + np.arange(n) * 0.01 + uniform = {'ra': 18.0, 'dec': -30.0, 'pa': 0.0, 'obsLocation': 'earth'} + per_star = {'ra': ra_per_star, 'dec': dec_per_star, + 'pa': 0.0, 'obsLocation': 'earth'} + + def selected(tab): + return set(np.unique(np.asarray(determine_motion_models(tab)[0]).astype(str))) + + # No column yet, per-star values -> canonical column, not _mm. + tab = _mini_table(n_stars=n, varying_radec=False) + del tab['ra'], tab['dec'] + tab.fit_motion_models(motion_models=['Parallax'], + fixed_params_dict=per_star, verbose=False) + assert not any(c.endswith('_mm') for c in tab.colnames) + np.testing.assert_allclose(np.asarray(tab['ra']), ra_per_star) + assert selected(tab) == {'Parallax'} + + # Column conflicts with a UNIFORM value -> column holds what was used, and + # meta must NOT be where it went, since the column would shadow it. + tab = _mini_table(n_stars=n, varying_radec=False) + tab['ra'] = np.full(n, 99.0) + tab['dec'] = np.full(n, -99.0) + tab.fit_motion_models(motion_models=['Parallax'], + fixed_params_dict=uniform, verbose=False) + np.testing.assert_allclose(np.asarray(tab['ra']), 18.0) + np.testing.assert_allclose(np.asarray(tab['ra_orig']), 99.0) + assert 'ra' not in tab.meta + assert selected(tab) == {'Parallax'} + + # Column conflicts with PER-STAR values -> same, per-star this time. + tab = _mini_table(n_stars=n, varying_radec=False) + tab['ra'] = np.full(n, 99.0) + tab['dec'] = np.full(n, -99.0) + tab.fit_motion_models(motion_models=['Parallax'], + fixed_params_dict=per_star, verbose=False) + np.testing.assert_allclose(np.asarray(tab['ra']), ra_per_star) + np.testing.assert_allclose(np.asarray(tab['ra_orig']), 99.0) + assert selected(tab) == {'Parallax'} + + # No conflict -> the column is left completely alone, no _orig churn. + tab = _mini_table(n_stars=n) + tab.fit_motion_models(motion_models=['Parallax'], verbose=False) + assert 'ra_orig' not in tab.colnames + assert selected(tab) == {'Parallax'} + + # A second fit with different values must not overwrite the caller's + # original with the first fit's substitute. + tab = _mini_table(n_stars=n, varying_radec=False) + tab['ra'] = np.full(n, 99.0) + tab['dec'] = np.full(n, -99.0) + tab.fit_motion_models(motion_models=['Parallax'], + fixed_params_dict=uniform, verbose=False) + tab.fit_motion_models(motion_models=['Parallax'], + fixed_params_dict={**uniform, 'ra': 55.0, 'dec': -55.0}, + verbose=False) + np.testing.assert_allclose(np.asarray(tab['ra']), 55.0) + np.testing.assert_allclose(np.asarray(tab['ra_orig']), 99.0) + + # A uniform value with NO column of that name still goes to meta -- one + # entry instead of the same number down every row. + tab = _mini_table(n_stars=n, varying_radec=False) + del tab['ra'], tab['dec'] + tab.fit_motion_models(motion_models=['Parallax'], + fixed_params_dict=uniform, verbose=False) + assert tab.meta['ra'] == 18.0 and 'ra' not in tab.colnames + assert selected(tab) == {'Parallax'} + + +def test_fixed_param_string_column_not_truncated(): + """ + obsLocation is a string fixed parameter, so the replacement has to swap the + column rather than assign into it -- assigning a longer string into an + existing narrower column truncates it to the old itemsize. + + The pre-seeded column is deliberately the SHORT side ('e', itemsize 1) and + the value the fit uses is the longer, valid 'earth'. obsLocation names a JPL + Horizons body that gets queried over the network and cached on disk, so a + test must not invent one: it would fire a live query and write a junk entry + into the shared parallax cache. Truncation is exercised by the widening + direction, which needs no new body. + """ + n = 6 + tab = _mini_table(n_stars=n, varying_radec=False) + tab['obsLocation'] = np.array(['e'] * n) + assert np.asarray(tab['obsLocation']).dtype.itemsize < len('earth') * 4 + + tab.fit_motion_models( + motion_models=['Parallax'], + fixed_params_dict={'ra': 18.0, 'dec': -30.0, 'pa': 0.0, + 'obsLocation': 'earth'}, + verbose=False) + + assert set(np.asarray(tab['obsLocation']).astype(str)) == {'earth'}, \ + 'used value was truncated to the old column width' + assert set(np.asarray(tab['obsLocation_orig']).astype(str)) == {'e'} diff --git a/flystar/tests/test_starlist.py b/flystar/tests/test_starlist.py index 5113c43..a88bad5 100644 --- a/flystar/tests/test_starlist.py +++ b/flystar/tests/test_starlist.py @@ -1,16 +1,17 @@ from astropy.table import Table from flystar.starlists import StarList import os, pdb +import flystar -test_dir = os.path.dirname(__file__) +test_data_path = f'{flystar.__path__[0]}/tests/test_data' def make_star_list(): # User input - cat_file = test_dir + '/A.lis' + cat_file = f'{test_data_path}/A.lis' # Read and arrange the test input - cat_tab = Table.read(cat_file, format='ascii', delimiter='\s') + cat_tab = Table.read(cat_file, format='ascii', delimiter=r'\s') # Copy columns from the input file. # Note that all of these inputs will be numpy arrays. @@ -25,7 +26,7 @@ def make_star_list(): # Name is a unique name for each star and is a 1D array. starlist_time = 2011.1 - starlist_name = 'A.lis' + starlist_name = f'{test_data_path}/A.lis' # Generate the starlist starlist = StarList(name=name_in, x=x_in, y=y_in, m=m_in, xe=xe_in, @@ -64,3 +65,62 @@ def test_restrict_by_value(): assert len(lis) < len(liso) return + + +def test_starlist_keeps_extra_columns(): + """ + StarList's keyword constructor used to accept only + ('x','y','m','xe','ye','me','corr') and silently DROP every other keyword. + Building a reference list for a Linear motion model the obvious way -- + StarList(name=.., x=.., y=.., m=.., vx=.., vy=.., t0=..) -- therefore + produced a list with no velocities at all, and no error to say so. That + surfaced much later as an align reference that refused to propagate. + Extra keywords are kept as columns now. + """ + import numpy as np + from astropy.table import MaskedColumn + from flystar.starlists import StarList + + n = 5 + base = dict(name=[f's{i}' for i in range(n)], + x=np.arange(n, dtype=float), y=np.arange(n, dtype=float), + m=np.full(n, 15.0), xe=np.full(n, .01), ye=np.full(n, .01), + me=np.full(n, .01)) + + sl = StarList(vx=np.full(n, 1.5), vy=np.full(n, -0.5), + t0=np.full(n, 2020.0), vx_err=np.full(n, .001), **base) + for col in ('vx', 'vy', 't0', 'vx_err'): + assert col in sl.colnames, f'extra column {col} was dropped' + np.testing.assert_allclose(np.asarray(sl['vx']), 1.5) + np.testing.assert_allclose(np.asarray(sl['t0']), 2020.0) + + # the recognized columns and meta still behave + assert sl.colnames[:4] == ['name', 'x', 'y', 'm'] + sl_meta = StarList(list_time=2020.5, list_name='ep1', meta={'extra': 'kept'}, **base) + assert sl_meta.meta['list_time'] == 2020.5 + assert sl_meta.meta['list_name'] == 'ep1' + assert sl_meta.meta['extra'] == 'kept' + assert sl_meta.meta['n_stars'] == n + + # a wrongly-shaped extra fails loudly rather than becoming a bad column + for bad in (np.zeros(3), np.zeros((n, 2)), np.ma.masked_array(np.zeros(3))): + try: + StarList(vx=bad, **base) + except ValueError: + pass + else: + raise AssertionError(f'accepted bad extra of shape {np.shape(bad)}') + + # masks on an extra column survive (MaskedColumn subclasses MaskedArray, + # so a plain np.ma.masked_array must be handled too) + for maker in (lambda d, mk: np.ma.masked_array(d, mask=mk), + lambda d, mk: MaskedColumn(data=d, mask=mk)): + mv = maker(np.arange(n, dtype=float), [0, 1, 0, 0, 1]) + sl_masked = StarList(vx=mv, **base) + np.testing.assert_array_equal( + np.ma.getmaskarray(sl_masked['vx']), [False, True, False, False, True]) + + # copy-construction from an existing StarList is a different code path and + # must keep every column, extras included + sl_copy = StarList(sl, copy=True) + assert sl_copy.colnames == sl.colnames diff --git a/flystar/tests/test_startable.py b/flystar/tests/test_startable.py index 9051fb8..2dc4345 100644 --- a/flystar/tests/test_startable.py +++ b/flystar/tests/test_startable.py @@ -1,20 +1,22 @@ +import os +import pdb +import pytest +import flystar +import numpy as np from astropy.table import Table from astropy import table +from flystar import motion_model from flystar.startables import StarTable from flystar.starlists import StarList -import numpy as np -import pytest -import os -import pdb -test_dir = os.path.dirname(__file__) +test_data_path = f'{flystar.__path__[0]}/tests/test_data' def test_StarTable_init1(): """ Test creation of new StarTable. """ # User input - cat_file = test_dir + '/test_catalog.fits' + cat_file = f'{test_data_path}/test_catalog.fits' # Read and arrange the test input cat_tab = Table.read(cat_file) @@ -39,9 +41,14 @@ def test_StarTable_init1(): starlist_names = np.array(['file1', 'file2', 'file3', 'file4', 'file5', 'file6', 'file7', 'file8']) # Generate the startable - startable = StarTable(name=name_in, x=x_in, y=y_in, m=m_in, xe=xe_in, ye=ye_in, me=me_in, - ref_list=1, - list_times=starlist_times, list_names=starlist_names) + startable = StarTable( + name=name_in, + x=x_in, y=y_in, m=m_in, + xe=xe_in, ye=ye_in, me=me_in, + ref_list=1, + list_times=starlist_times, + list_names=starlist_names + ) # Now put in some assertions to make sure all our startable columns # have the right dimensions. @@ -55,7 +62,7 @@ def test_StarTable_init1(): assert len(startable['name']) == N_stars assert startable.meta['list_times'][0] == starlist_times[0] assert type(startable) == StarTable - + return def test_StarTable_init2(): @@ -65,8 +72,8 @@ def test_StarTable_init2(): Also double check that we can add a second list to it using add_starlist and we can get_starlist() as well. """ - list_file1 = 'A.lis' - list_file2 = 'B.lis' + list_file1 = f'{test_data_path}/A.lis' + list_file2 = f'{test_data_path}/B.lis' list1 = StarList.from_lis_file(list_file1) list2 = StarList.from_lis_file(list_file2) @@ -75,7 +82,6 @@ def test_StarTable_init2(): assert len(tab) == len(list1) - return def test_combine_lists(): @@ -93,20 +99,25 @@ def test_combine_lists(): x_avg_0 = t['x'][0, :].mean() t.combine_lists('x', mask_val=-100000) assert t['x0'][0] == x_avg_0 - assert t['x0'][-1] == pytest.approx(2108.855, 0.001) + np.testing.assert_allclose(t['x0'][-1], 2108.855, rtol=1e-3) # Test 3: Trying calling the same thing a second time and make sure the # answers don't change and we didn't break anything. t.combine_lists('x', mask_val=-100000) assert t['x0'][0] == x_avg_0 - assert t['x0'][-1] == pytest.approx(2108.855, 0.001) - + np.testing.assert_allclose(t['x0'][-1], 2108.855, rtol=1e-3) + # Test 4: weighted average of x. x_wgt_0 = 1.0 / t['xe'][0, :]**2 x_avg_0 = np.average(t['x'][0, :], weights=x_wgt_0) t.combine_lists('x', mask_val=-100000, weights_col='xe') - assert t['x0'][0] == x_avg_0 - + # A weighted-mean reduction over a 2D array's axis=1 (as combine_lists + # does internally) doesn't reproduce a 1D np.average() call bit-for-bit + # -- that's a numpy summation-order quirk (also true of the original + # numpy.ma-based implementation for a plain np.average, just not for + # np.ma.average specifically), not a precision issue worth chasing. + np.testing.assert_allclose(t['x0'][0], x_avg_0) + x_wgt_last = 1.0 / t['xe'][-1, :]**2 x_avg_last = np.average(t['x'][-1, [2,7]], weights=x_wgt_last[[2,7]]) assert t['x0'][-1] == pytest.approx(x_avg_last) @@ -115,14 +126,14 @@ def test_combine_lists(): # Test 5: make sure mask_list is working. ########## # Test 5ai: Non-masked, weighted_m=False - tt.combine_lists_xym(weighted_xy=True, weighted_m=False, mask_lists=False) + tt.combine_lists_xym(weighted_xy=True, weighted_m=False, mask_lists=None) assert np.arange(1.8, 38, 4) == pytest.approx(tt['x0'].data) assert np.arange(1.8, 38, 4) == pytest.approx(tt['y0'].data) avg_m = -2.5 * np.log10((4 * 10**-0.4 + 1)/5) assert avg_m * np.ones(10) == pytest.approx(tt['m0'].data) # Test 5aii: Non-masked, weighted_m=True - tt.combine_lists_xym(weighted_xy=True, weighted_m=True, mask_lists=False) + tt.combine_lists_xym(weighted_xy=True, weighted_m=True, mask_lists=None) assert np.arange(1.8, 38, 4) == pytest.approx(tt['x0'].data) assert np.arange(1.8, 38, 4) == pytest.approx(tt['y0'].data) avg_m_weight = 0.9391744564422395 @@ -141,13 +152,415 @@ def test_combine_lists(): assert np.ones(10) == pytest.approx(tt['m0'].data) # Test 5c: Things that should break the code. - with pytest.raises(RuntimeError): - t.combine_lists_xym(weighted_xy=True, weighted_m=True, mask_lists=np.arange(2)) - with pytest.raises(RuntimeError): + # with pytest.raises(RuntimeError): + # This would not break the code anymore + # t.combine_lists_xym(weighted_xy=True, weighted_m=True, mask_lists=np.arange(2)) + with pytest.raises(AssertionError): t.combine_lists_xym(weighted_xy=True, weighted_m=True, mask_lists=True) return +def test_combine_lists_select_stars(): + """ + combine_lists()/combine_lists_xym() gained a select_stars parameter so + align.update_ref_table_aggregates() can recompute averages only for the + rows that changed, instead of the whole (potentially huge, ever-growing) + ref_table every time it's called. Check that: + - computing with select_stars over a subset gives the same numbers as + a full recompute, for that subset. + - rows outside select_stars are left completely untouched, even if + their underlying per-epoch data changed after the last full + recompute. + """ + t = make_star_table() + + # Seed x0/x0_err and m0/m0_err with a full computation first. + t.combine_lists('x', weights_col='xe', mask_val=-100000) + t.combine_lists('m', weights_col='me', mask_val=-100000, ismag=True) + x0_before = t['x0'].copy() + x0_err_before = t['x0_err'].copy() + m0_before = t['m0'].copy() + + # Mutate the underlying per-epoch data for every star... + rng = np.random.default_rng(0) + t['x'] = t['x'] + rng.uniform(-5, 5, t['x'].shape) + t['m'] = t['m'] + rng.uniform(-0.5, 0.5, t['m'].shape) + + # ...but only recompute a subset of rows. + select = np.zeros(len(t), dtype=bool) + select[[1, 3, 5, 7]] = True + + t.combine_lists('x', weights_col='xe', mask_val=-100000, select_stars=select) + t.combine_lists('m', weights_col='me', mask_val=-100000, ismag=True, select_stars=select) + + # A fresh full recompute on the same (mutated) data is ground truth. + t_full = make_star_table() + t_full['x'] = t['x'] + t_full['m'] = t['m'] + t_full.combine_lists('x', weights_col='xe', mask_val=-100000) + t_full.combine_lists('m', weights_col='me', mask_val=-100000, ismag=True) + + # Selected rows should match the fresh full recompute (allowing for + # floating-point reduction-order noise between a sliced vs. full array). + np.testing.assert_allclose(t['x0'][select], t_full['x0'][select], rtol=1e-12) + np.testing.assert_allclose(t['x0_err'][select], t_full['x0_err'][select], rtol=1e-12) + np.testing.assert_allclose(t['m0'][select], t_full['m0'][select], rtol=1e-12) + + # Unselected rows should be untouched -- still equal to the pre-mutation + # values, not the (different) values the new data would produce. + np.testing.assert_array_equal(t['x0'][~select], x0_before[~select]) + np.testing.assert_array_equal(t['x0_err'][~select], x0_err_before[~select]) + np.testing.assert_array_equal(t['m0'][~select], m0_before[~select]) + + # combine_lists_xym should thread select_stars through consistently too. + tt = make_tiny_star_table() + tt.combine_lists_xym(weighted_xy=True, weighted_m=True) + x0_before_tt = tt['x0'].copy() + tt['x'] = tt['x'] + 100.0 # move every star + select_tt = np.array([True, False] * 5) + tt.combine_lists_xym(weighted_xy=True, weighted_m=True, select_stars=select_tt) + assert not np.allclose(tt['x0'][select_tt], x0_before_tt[select_tt]) # these moved + np.testing.assert_array_equal(tt['x0'][~select_tt], x0_before_tt[~select_tt]) # these didn't + + # Edge case: an all-False selection should be a safe no-op. + t2 = make_star_table() + t2.combine_lists('x', weights_col='xe', mask_val=-100000) + x0_snapshot = t2['x0'].copy() + none_selected = np.zeros(len(t2), dtype=bool) + t2.combine_lists('x', weights_col='xe', mask_val=-100000, select_stars=none_selected) + np.testing.assert_array_equal(t2['x0'], x0_snapshot) + + return + + +def _bruteforce_combine_lists(startable, col_name_in, weights_col=None, mask_val=None, + mask_lists=None, ismag=False, sigma=3): + """ + Reference implementation of StarTable.combine_lists(), kept here only as + ground truth for test_combine_lists_vectorized: the original numpy.ma + -based implementation that the vectorized (plain-numpy) version replaced, + for performance (numpy.ma carries heavy per-operation overhead compared + to explicit boolean-mask arithmetic on plain arrays). + """ + from astropy.stats import sigma_clip as _sigma_clip + + if mask_lists is not None: + mask_lists = np.atleast_1d(mask_lists) + list_indices = np.array([i for i in np.arange(startable[col_name_in].data.shape[1]) if i not in mask_lists]) + else: + list_indices = np.arange(startable[col_name_in].data.shape[1]) + + val_2d = np.ma.masked_invalid(startable[col_name_in].data[:, list_indices]) + + if ismag: + val_2d = 10**(-0.4 * val_2d) + + if mask_val: + val_2d = np.ma.masked_values(val_2d, mask_val) + + if sigma: + val_2d_clip = _sigma_clip(val_2d, sigma=sigma, maxiters=5, axis=1) + else: + val_2d_clip = val_2d + + if weights_col in startable.colnames: + err_2d = np.ma.masked_invalid(startable[weights_col].data[:, list_indices]) + if ismag: + err_2d = 0.4 * np.log(10) * val_2d * err_2d + unified_mask = val_2d_clip.mask | err_2d.mask + val_2d_clip.mask = unified_mask + err_2d.mask = unified_mask + wgt_2d = np.ma.masked_invalid(1. / err_2d**2) + avg = np.ma.average(val_2d_clip, weights=wgt_2d, axis=1) + std = np.ma.sqrt(1. / np.ma.sum(wgt_2d, axis=1)) + else: + avg = np.ma.mean(val_2d_clip, axis=1) + # Standard error of the MEAN, matching combine_lists' unweighted + # branch. np.ma.std is the population scatter sqrt(S/n); the error on + # their average is sqrt(S/(n(n-1))), i.e. that divided by sqrt(n-1). + # n <= 1 carries no residual information and is masked -> inf below. + n_val = val_2d_clip.count(axis=1) + with np.errstate(divide='ignore', invalid='ignore'): + std = np.ma.std(val_2d_clip, axis=1) / np.ma.sqrt(n_val - 1) + std = np.ma.masked_where(n_val <= 1, std) + + std = np.ma.masked_where(std == 0., std) + + if ismag: + std = 2.5 / np.log(10) * std / avg + avg = -2.5 * np.ma.log10(avg) + + avg = avg.filled(np.nan) + std = std.filled(np.inf) + return avg, std + + +def test_combine_lists_vectorized(): + """ + StarTable.combine_lists() was rewritten to use plain numpy arithmetic + with explicit boolean masks instead of numpy.ma (which carries heavy + per-operation overhead -- mask bookkeeping and generic dispatch on every + arithmetic op -- and was a measurable chunk of align.py's runtime for + large mosaics). Check the vectorized version against the original + numpy.ma-based reference across a battery of randomized tables that + exercise: weighted/unweighted, magnitude conversion, mask_lists, + mask_val, sigma clipping, all-invalid rows, and rows with exactly one + valid epoch. + """ + rng = np.random.default_rng(7) + + for trial in range(20): + n_stars = 60 + n_epochs = 5 + + x = rng.normal(100, 5, size=(n_stars, n_epochs)) + xe = rng.uniform(0.001, 0.05, size=(n_stars, n_epochs)) + + # Sprinkle in missing epochs (NaN), a sentinel mask value, and some + # gross outliers for sigma clipping to catch. + x[rng.random((n_stars, n_epochs)) < 0.25] = np.nan + xe[np.isnan(x)] = np.nan + sentinel_mask = rng.random((n_stars, n_epochs)) < 0.05 + x[sentinel_mask] = -100000 + outlier_mask = rng.random((n_stars, n_epochs)) < 0.05 + x[outlier_mask] += rng.choice([-1, 1], size=outlier_mask.sum()) * rng.uniform(50, 200, size=outlier_mask.sum()) + + # A few rows with zero, or exactly one, valid epoch -- edge cases for + # "no data" and "std of a single point." + x[0, :] = np.nan + xe[0, :] = np.nan + x[1, 1:] = np.nan + xe[1, 1:] = np.nan + + t_weighted = Table({'x': x.copy(), 'xe': xe.copy()}) + t_unweighted = Table({'x': x.copy()}) + + for use_weights, ismag, mask_lists, sigma in [ + (True, False, None, 3), + (False, False, None, 3), + (True, True, None, 3), + (True, False, [2], 3), + (True, False, None, None), + ]: + t = t_weighted if use_weights else t_unweighted + kwargs = dict(mask_val=-100000, mask_lists=mask_lists, ismag=ismag, sigma=sigma) + if use_weights: + kwargs['weights_col'] = 'xe' + + want_avg, want_std = _bruteforce_combine_lists(t, 'x', **kwargs) + + t_copy = Table({k: t[k].copy() for k in t.colnames}) + t_copy.__class__ = StarTable # combine_lists is a StarTable method + t_copy.combine_lists('x', **kwargs) + got_avg = np.asarray(t_copy['x0']) + got_std = np.asarray(t_copy['x0_err']) + + np.testing.assert_allclose(got_avg, want_avg, rtol=1e-10, atol=1e-10, equal_nan=True, + err_msg=f"trial={trial} use_weights={use_weights} ismag={ismag} mask_lists={mask_lists} sigma={sigma}: avg mismatch") + np.testing.assert_allclose(got_std, want_std, rtol=1e-10, atol=1e-10, equal_nan=True, + err_msg=f"trial={trial} use_weights={use_weights} ismag={ismag} mask_lists={mask_lists} sigma={sigma}: std mismatch") + + +def test_combine_lists_weight_fallback(): + """ + Regression test for StarTable.combine_lists()'s handling of stars whose + weighting column (e.g. 'xe'/'ye'/'me') is entirely invalid (inf) in + every epoch. The contract: + - if a star has at least one epoch with a real, finite weight, only + those epoch(s) are used (epochs with an invalid weight are simply + dropped, even if their raw value is finite) -- the reported error + is a real, finite propagated uncertainty. + - if a star has NO usable weight anywhere but does have at least one + finite raw value, the mean falls back to an (unweighted-in-spirit) + average of the valid value(s) -- but the reported error MUST be + exactly np.inf, never a fabricated finite number, since the true + uncertainty was never actually known. This is the exact bug that + motivated this refactor: a fake weight=1 fallback elsewhere in this + codebase once leaked a finite x0_err=1.0 into real output for + months, undetected. + - if a star has no valid raw value at all, the mean is nan and the + error is inf (nothing to fall back to). + - a column with no weights_col at all (the plain unweighted branch, + untouched by this refactor) still handles inf/nan correctly. + + All expected numbers below were derived by hand (or, for the weighted + cases, via the same textbook inverse-variance formula the production + code implements: wgt = 1/err**2, avg = weighted mean, std = + sqrt(1/sum(wgt))) and cross-checked against the implementation before + being hardcoded here, so a future refactor that silently changes the + fallback's arithmetic (and not just its inf/nan-ness) will also be caught. + """ + nan, inf = np.nan, np.inf + + ########## + # Non-magnitude column ('x'/'xe'), one star per case, sigma clipping + # disabled so every number below is exact (not subject to outlier + # rejection on tiny synthetic rows). + ########## + names = np.array(['case1_baseline', 'case2_partial', 'case3a_fallback_single', + 'case3b_fallback_multi', 'case4_no_data', 'case5_composite']) + + # case1_baseline: every epoch has a valid value AND a valid weight -- + # sanity check that normal weighted averaging is unaffected. + # case2_partial: epoch 1 has a finite raw value (999) but an inf weight + # -- it must be excluded, leaving only epochs 0, 2, 3 to average, with a + # real (finite) propagated error. + # case3a_fallback_single: only epoch 0 has a finite value; every weight + # is inf. Falls back to that single value; error must be exactly inf. + # case3b_fallback_multi: epochs 0, 1 have finite values (5, 9); every + # weight is inf. Falls back to the unweighted mean of the two valid + # values (7.0); error must be exactly inf. + # case4_no_data: no valid value anywhere and no usable weight -- nothing + # to fall back to, so mean is nan and error is inf. + # case5_composite: combines four different conditions in one row -- + # epoch 0 is an invalid (nan) value with a valid-looking weight, epoch 1 + # is a valid value with an inf (unusable) weight, epoch 2 is a valid + # value with a real, usable weight, epoch 3 is an invalid (inf) value + # with a valid-looking weight. Since epoch 2 gives this star a real, + # non-zero weight sum, this is NOT a fallback star -- it should reduce + # to the ordinary weighted case using only epoch 2. + x = np.array([ + [10., 20., 30., 40.], + [10., 999., 20., 30.], + [7., nan, nan, nan], + [5., 9., nan, nan], + [nan, nan, nan, nan], + [nan, 50., 60., inf], + ]) + xe = np.array([ + [1., 2., 3., 4.], + [1., inf, 2., 3.], + [inf, inf, inf, inf], + [inf, inf, inf, inf], + [inf, inf, inf, inf], + [0.5, inf, 1.0, nan], + ]) + + t = StarTable(name=names, x=x.copy(), y=x.copy(), m=np.ones_like(x), + xe=xe.copy(), ye=xe.copy()) + t.combine_lists('x', weights_col='xe', sigma=None) + + i1, i2, i3a, i3b, i4, i5 = range(6) + + # Case 1: baseline, all weights valid -- ordinary weighted average. + wgt1 = 1. / xe[i1]**2 + avg1 = np.average(x[i1], weights=wgt1) + std1 = np.sqrt(1. / wgt1.sum()) + np.testing.assert_allclose(t['x0'][i1], avg1, rtol=1e-12) + np.testing.assert_allclose(t['x0_err'][i1], std1, rtol=1e-12) + assert np.isfinite(t['x0_err'][i1]) + + # Case 2: epoch 1 (value 999, weight inf) must be excluded -- average + # matches using only the epochs with a real, finite weight (0, 2, 3), + # and the error is finite (real weight info exists), not inf. + idx2 = [0, 2, 3] + wgt2 = 1. / xe[i2][idx2]**2 + avg2 = np.average(x[i2][idx2], weights=wgt2) + std2 = np.sqrt(1. / wgt2.sum()) + np.testing.assert_allclose(t['x0'][i2], avg2, rtol=1e-12) + np.testing.assert_allclose(t['x0_err'][i2], std2, rtol=1e-12) + assert np.isfinite(t['x0_err'][i2]) + assert not np.isinf(t['x0_err'][i2]) + + # Case 3a: single valid value, all weights inf -- fallback mean is just + # that one value; error must be EXACTLY inf (not merely large). + assert t['x0'][i3a] == 7.0 + assert t['x0_err'][i3a] == np.inf + assert np.isinf(t['x0_err'][i3a]) + + # Case 3b: two valid values (5, 9), all weights inf -- fallback mean is + # their plain (unweighted) average, 7.0; error must be EXACTLY inf. + # This is the core regression case for today's fix. + assert t['x0'][i3b] == pytest.approx(7.0) + assert t['x0_err'][i3b] == np.inf + assert np.isinf(t['x0_err'][i3b]) + + # Case 4: no valid value anywhere -- mean is nan, error is inf. + assert np.isnan(t['x0'][i4]) + assert t['x0_err'][i4] == np.inf + + # Case 5: composite row -- only epoch 2 (value 60, weight 1.0) carries + # real weight, so the star reduces to an ordinary weighted case using + # only that epoch, exactly as if epochs 0, 1, 3 didn't exist. + assert t['x0'][i5] == pytest.approx(60.0) + assert t['x0_err'][i5] == pytest.approx(1.0) + assert np.isfinite(t['x0_err'][i5]) + + ########## + # Magnitude column ('m'/'me', ismag=True) -- same fallback contract, but + # exercised through the flux-space conversion pipeline. + ########## + m_names = np.array(['mag_baseline', 'mag_fallback_single', 'mag_fallback_multi']) + m_vals = np.array([ + [10., 12., 14.], + [15., nan, nan], + [12.0, 14.0, nan], + ]) + m_errs = np.array([ + [0.05, 0.1, 0.2], + [inf, inf, inf], + [inf, inf, inf], + ]) + tm = StarTable(name=m_names, x=np.ones_like(m_vals), y=np.ones_like(m_vals), + m=m_vals.copy(), me=m_errs.copy()) + tm.combine_lists('m', weights_col='me', ismag=True, sigma=None) + + # mag_baseline: every epoch has a valid value and a valid error -- the + # refactor must not have changed ordinary weighted-in-flux averaging. + val_flux = 10**(-0.4 * m_vals[0]) + err_flux = 0.4 * np.log(10) * val_flux * m_errs[0] + wgt = 1. / err_flux**2 + avg_flux = np.average(val_flux, weights=wgt) + std_flux = np.sqrt(1. / wgt.sum()) + avg_mag = -2.5 * np.log10(avg_flux) + std_mag = 2.5 / np.log(10) * std_flux / avg_flux + np.testing.assert_allclose(tm['m0'][0], avg_mag, rtol=1e-10) + np.testing.assert_allclose(tm['m0_err'][0], std_mag, rtol=1e-10) + assert np.isfinite(tm['m0_err'][0]) + + # mag_fallback_single: one valid magnitude (15.0), every weight inf -- + # fallback mean is that value; error must be EXACTLY inf. + np.testing.assert_allclose(tm['m0'][1], 15.0, rtol=1e-10) + assert tm['m0_err'][1] == np.inf + + # mag_fallback_multi: two DIFFERENT valid magnitudes (12.0, 14.0), every + # weight inf. Averaging magnitudes is physically a flux-space average, + # not a plain arithmetic mean of the mag values themselves -- so + # independently reproduce that here (a plain, equally-weighted mean of + # the *flux* values, since that's the space val_2d is already in when + # ismag=True) and require flystar's result to match it, rather than + # asserting some simpler (and wrong) unweighted-in-mag-space expectation. + flux2 = 10**(-0.4 * m_vals[2, :2]) + avg_flux2 = flux2.mean() + avg_mag2 = -2.5 * np.log10(avg_flux2) + np.testing.assert_allclose(tm['m0'][2], avg_mag2, rtol=1e-10) + assert tm['m0_err'][2] == np.inf + + ########## + # No weights_col at all -- the plain unweighted branch, untouched by + # this refactor, but still deserving direct inf/nan regression coverage. + ########## + names_uw = np.array(['one_nan_two_valid', 'all_nan']) + x_uw = np.array([ + [10., nan, 30.], + [nan, nan, nan], + ]) + t_uw = StarTable(name=names_uw, x=x_uw.copy(), y=x_uw.copy(), m=np.ones_like(x_uw)) + t_uw.combine_lists('x', sigma=None) + + # One nan among three epochs -- mean and std computed from the two + # valid values only (10, 30): mean 20, population std of residuals 10. + assert t_uw['x0'][0] == pytest.approx(20.0) + assert t_uw['x0_err'][0] == pytest.approx(10.0) + assert t_uw.meta['x0'] == 'not_weighted' + + # All epochs nan -- nothing to average; mean nan, error inf. + assert np.isnan(t_uw['x0'][1]) + assert t_uw['x0_err'][1] == np.inf + + return + + def test_add_starlist(): """ Test the startables.combine_lists() functionality. @@ -168,78 +581,79 @@ def test_add_starlist(): t.add_starlist(x=x_new, y=y_new, m=m_new, xe=xe_new, ye=ye_new, me=me_new, meta={'list_times': t_new}) - assert len(t) == len(t_orig) + np.testing.assert_equal(len(t), len(t_orig)) expected_shape = np.array(t_orig['x'].shape) expected_shape[1] += 1 - - assert len(t['x'].shape) == len(expected_shape) - assert t['x'].shape[0] == expected_shape[0] + + np.testing.assert_equal(len(t['x'].shape), len(expected_shape)) + np.testing.assert_equal(t['x'].shape[0], expected_shape[0]) assert t['x'].shape[1] == expected_shape[1] - assert len(t['y'].shape) == len(expected_shape) - assert t['y'].shape[0] == expected_shape[0] + np.testing.assert_equal(len(t['y'].shape), len(expected_shape)) + np.testing.assert_equal(t['y'].shape[0], expected_shape[0]) assert t['y'].shape[1] == expected_shape[1] - assert len(t['m'].shape) == len(expected_shape) - assert t['m'].shape[0] == expected_shape[0] + np.testing.assert_equal(len(t['m'].shape), len(expected_shape)) + np.testing.assert_equal(t['m'].shape[0], expected_shape[0]) assert t['m'].shape[1] == expected_shape[1] - assert len(t['xe'].shape) == len(expected_shape) - assert t['xe'].shape[0] == expected_shape[0] - assert t['xe'].shape[1] == expected_shape[1] - - assert len(t['ye'].shape) == len(expected_shape) - assert t['ye'].shape[0] == expected_shape[0] - assert t['ye'].shape[1] == expected_shape[1] + np.testing.assert_equal(len(t['xe'].shape), len(expected_shape)) + np.testing.assert_equal(t['xe'].shape[0], expected_shape[0]) + np.testing.assert_equal(t['xe'].shape[1], expected_shape[1]) - assert len(t['me'].shape) == len(expected_shape) - assert t['me'].shape[0] == expected_shape[0] - assert t['me'].shape[1] == expected_shape[1] - - assert len(t['name']) == len(t_orig['name']) - assert len(t.meta['list_times']) == expected_shape[1] - assert t.meta['n_lists'] == 9 + np.testing.assert_equal(len(t['ye'].shape), len(expected_shape)) + np.testing.assert_equal(t['ye'].shape[0], expected_shape[0]) + np.testing.assert_equal(t['ye'].shape[1], expected_shape[1]) + np.testing.assert_equal(len(t['me'].shape), len(expected_shape)) + np.testing.assert_equal(t['me'].shape[0], expected_shape[0]) + np.testing.assert_equal(t['me'].shape[1], expected_shape[1]) + np.testing.assert_equal(len(t['name']), len(t_orig['name'])) + np.testing.assert_equal(len(t.meta['list_times']), expected_shape[1]) + np.testing.assert_equal(t.meta['n_lists'], 9) # Test 2: Add as starlist rather than with keywords. - starlist = StarList(name=t_orig['name'], x=x_new, y=y_new, m=m_new, - xe=xe_new, ye=ye_new, me=me_new, list_time=2001.0, list_name='A.lis') + starlist = StarList( + name=t_orig['name'], + x=x_new, y=y_new, m=m_new, + xe=xe_new, ye=ye_new, me=me_new, + list_time=2001.0, list_name='A.lis' + ) t = make_star_table() t.add_starlist(starlist=starlist) - assert len(t) == len(t_orig) + np.testing.assert_equal(len(t), len(t_orig)) expected_shape = np.array(t_orig['x'].shape) expected_shape[1] += 1 - - assert len(t['x'].shape) == len(expected_shape) - assert t['x'].shape[0] == expected_shape[0] - assert t['x'].shape[1] == expected_shape[1] - assert len(t['y'].shape) == len(expected_shape) - assert t['y'].shape[0] == expected_shape[0] - assert t['y'].shape[1] == expected_shape[1] + np.testing.assert_equal(len(t['x'].shape), len(expected_shape)) + np.testing.assert_equal(t['x'].shape[0], expected_shape[0]) + np.testing.assert_equal(t['x'].shape[1], expected_shape[1]) - assert len(t['m'].shape) == len(expected_shape) - assert t['m'].shape[0] == expected_shape[0] - assert t['m'].shape[1] == expected_shape[1] + np.testing.assert_equal(len(t['y'].shape), len(expected_shape)) + np.testing.assert_equal(t['y'].shape[0], expected_shape[0]) + np.testing.assert_equal(t['y'].shape[1], expected_shape[1]) - assert len(t['xe'].shape) == len(expected_shape) - assert t['xe'].shape[0] == expected_shape[0] - assert t['xe'].shape[1] == expected_shape[1] + np.testing.assert_equal(len(t['m'].shape), len(expected_shape)) + np.testing.assert_equal(t['m'].shape[0], expected_shape[0]) + np.testing.assert_equal(t['m'].shape[1], expected_shape[1]) - assert len(t['ye'].shape) == len(expected_shape) - assert t['ye'].shape[0] == expected_shape[0] - assert t['ye'].shape[1] == expected_shape[1] + np.testing.assert_equal(len(t['xe'].shape), len(expected_shape)) + np.testing.assert_equal(t['xe'].shape[0], expected_shape[0]) + np.testing.assert_equal(t['xe'].shape[1], expected_shape[1]) + np.testing.assert_equal(len(t['ye'].shape), len(expected_shape)) + np.testing.assert_equal(t['ye'].shape[0], expected_shape[0]) + np.testing.assert_equal(t['ye'].shape[1], expected_shape[1]) - assert len(t['me'].shape) == len(expected_shape) - assert t['me'].shape[0] == expected_shape[0] - assert t['me'].shape[1] == expected_shape[1] + np.testing.assert_equal(len(t['me'].shape), len(expected_shape)) + np.testing.assert_equal(t['me'].shape[0], expected_shape[0]) + np.testing.assert_equal(t['me'].shape[1], expected_shape[1]) - assert len(t['name']) == len(t_orig['name']) - assert len(t.meta['list_times']) == expected_shape[1] - assert t.meta['n_lists'] == 9 + np.testing.assert_equal(len(t['name']), len(t_orig['name'])) + np.testing.assert_equal(len(t.meta['list_times']), expected_shape[1]) + np.testing.assert_equal(t.meta['n_lists'], 9) return @@ -255,13 +669,13 @@ def test_get_starlist(): assert t['x'][0,2] == t_list['x'][0] assert type(t_list) == StarList assert len(t_list['x'].shape) == 1 - + return def test_combine_1col(): # User input - cat_file = test_dir + '/test_catalog.fits' + cat_file = f'{test_data_path}/test_catalog.fits' # Read and arrange the test input cat_tab = Table.read(cat_file) @@ -287,11 +701,11 @@ def test_combine_1col(): t.combine_lists('x', weights_col='xe') - assert t['x0'][0] == t['x'][0] + np.testing.assert_equal(t['x0'][0], t['x'][0]) return -def test_fit_velocities(): +def test_fit_motion_models(): tab = make_star_table() tt = make_tiny_star_table() @@ -303,155 +717,99 @@ def test_fit_velocities(): tab = table.vstack((tab1, tab2, tab3)) tab.meta = tab1.meta - tab.fit_velocities(verbose=True) + tab.fit_motion_models(verbose=True, mask_value=-100000.) # Test creation of new variables - assert len(tab['vx']) == len(tab) - assert len(tab['vy']) == len(tab) - assert len(tab['vxe']) == len(tab) - assert len(tab['vye']) == len(tab) - assert len(tab['n_vfit']) == len(tab) - assert tab.meta['n_vfit_bootstrap'] == 0 + np.testing.assert_equal(len(tab['vx']), len(tab)) + np.testing.assert_equal(len(tab['vy']), len(tab)) + np.testing.assert_equal(len(tab['vx_err']), len(tab)) + np.testing.assert_equal(len(tab['vy_err']), len(tab)) + np.testing.assert_equal(len(tab['n_fit']), len(tab)) + np.testing.assert_equal(tab.meta['n_bootstrap'], 0) # Test no-fit for stars with N<2 epochs. n_epochs = (tab['x'] >= 0).sum(axis=1) idx = np.where(n_epochs < 2)[0] - assert (tab['vx'][idx] == 0).all() - assert (tab['vxe'][idx] == 0).all() - assert (tab['n_vfit'][idx] == 2).all() + np.testing.assert_equal((tab['vx'][idx] == 0).all(), True) + np.testing.assert_equal((tab['vx_err'][idx] == 0).all(), True) + np.testing.assert_equal((tab['n_fit'][idx] == 2).all(), True) # Test that the velocity errors were calculated. - assert (tab['vxe'][0:100] > 0).all() - assert (tab['x0e'][0:100] > 0).all() - assert (tab['vye'][0:100] > 0).all() - assert (tab['y0e'][0:100] > 0).all() - assert np.isfinite(tab['x0']).all() - assert np.isfinite(tab['vx']).all() - assert np.isfinite(tab['y0']).all() - assert np.isfinite(tab['vy']).all() - assert np.isfinite(tab['x0e']).all() - assert np.isfinite(tab['vxe']).all() - assert np.isfinite(tab['y0e']).all() - assert np.isfinite(tab['vye']).all() + np.testing.assert_equal((~(tab['vx_err'][0:100] < 0)).all(), True) + np.testing.assert_equal((~(tab['x0_err'][0:100] < 0)).all(), True) + np.testing.assert_equal((~(tab['vy_err'][0:100] < 0)).all(), True) + np.testing.assert_equal((~(tab['y0_err'][0:100] < 0)).all(), True) ########## # Test running a second time. We should get the same results. ########## vx_orig = tab['vx'] x0_orig = tab['x0'] - vxe_orig = tab['vxe'] - x0e_orig = tab['x0e'] - tab.fit_velocities(verbose=False) + vxe_orig = tab['vx_err'] + x0e_orig = tab['x0_err'] + tab.fit_motion_models(verbose=False, mask_value=-100000.) - assert (vx_orig == tab['vx']).all() - assert (x0_orig == tab['x0']).all() - assert (vxe_orig == tab['vxe']).all() - assert (x0e_orig == tab['x0e']).all() + np.testing.assert_allclose(tab['vx'], vx_orig) + np.testing.assert_allclose(tab['x0'], x0_orig) + np.testing.assert_allclose(tab['vx_err'], vxe_orig) + np.testing.assert_allclose(tab['x0_err'], x0e_orig) ########## # Test fixed_t0 functionality ########## fixed_t0 = tab['t0'] + np.random.normal(size=len(tab)) - tab.fit_velocities(fixed_t0=fixed_t0) - - assert(np.sum(abs(tab['t0'] - fixed_t0)) == 0) + tab.fit_motion_models(verbose=False, mask_value=-100000., fixed_params_dict={'t0': fixed_t0}) + np.testing.assert_allclose(tab['t0'], fixed_t0) ########## # Test bootstrap ########## tab_b = table.vstack((tab1, tab2, tab3)) tab_b.meta = tab1.meta - tab_b.fit_velocities(verbose=True, bootstrap=50) - - assert tab_b.meta['n_vfit_bootstrap'] == 50 - assert tab_b['x0e'][0] > tab['x0e'][0] - assert tab_b['vxe'][0] > tab['vxe'][0] - assert tab_b['y0e'][0] > tab['y0e'][0] - assert tab_b['vye'][0] > tab['vye'][0] + tab_b.fit_motion_models(verbose=True, bootstrap=50) + + np.testing.assert_equal(tab_b.meta['n_bootstrap'], 50) + np.testing.assert_array_less(tab['x0_err'][0], tab_b['x0_err'][0]) + np.testing.assert_array_less(tab['vx_err'][0], tab_b['vx_err'][0]) + np.testing.assert_array_less(tab['y0_err'][0], tab_b['y0_err'][0]) + np.testing.assert_array_less(tab['vy_err'][0], tab_b['vy_err'][0]) ########## # Test what happens with no velocity errors ########## - tab.remove_columns(['xe', 'ye', 'x0', 'y0', 'x0e', 'y0e', 'vx', 'vy', 'vxe', 'vye', 'n_vfit']) - tab.fit_velocities(verbose=False) - - assert len(tab['vx']) == len(tab) - assert len(tab['vy']) == len(tab) - assert len(tab['vxe']) == len(tab) - assert len(tab['vye']) == len(tab) - assert len(tab['n_vfit']) == len(tab) - assert (tab['vxe'][0:100] > 0).all() - assert (tab['x0e'][0:100] > 0).all() - assert (tab['vye'][0:100] > 0).all() - assert (tab['y0e'][0:100] > 0).all() + tab.remove_columns(['xe', 'ye', 'x0', 'y0', 'x0_err', 'y0_err', 'vx', 'vy', 'vx_err', 'vy_err', 'n_fit']) + tab.fit_motion_models(verbose=False) + + np.testing.assert_equal(len(tab['vx']), len(tab)) + np.testing.assert_equal(len(tab['vy']), len(tab)) + np.testing.assert_equal(len(tab['vx_err']), len(tab)) + np.testing.assert_equal(len(tab['vy_err']), len(tab)) + np.testing.assert_equal(len(tab['n_fit']), len(tab)) + np.testing.assert_equal((~(tab['vx_err'][0:100] < 0)).all(), True) + np.testing.assert_equal((~(tab['x0_err'][0:100] < 0)).all(), True) + np.testing.assert_equal((~(tab['vy_err'][0:100] < 0)).all(), True) + np.testing.assert_equal((~(tab['y0_err'][0:100] < 0)).all(), True) ######### # Test mask_list ######### # Test 5a: Masked - tt.fit_velocities(bootstrap=0, verbose=False, mask_lists=[1]) - assert np.arange(2.25, 48, 5) == pytest.approx(tt['x0'].data) - assert np.arange(2.25, 48, 5) == pytest.approx(tt['y0'].data) - assert np.zeros(10) == pytest.approx(tt['x0e'].data) - assert np.zeros(10) == pytest.approx(tt['y0e'].data) - assert np.ones(10) == pytest.approx(tt['vx'].data) - assert np.ones(10) == pytest.approx(tt['vy'].data) - assert np.zeros(10) == pytest.approx(tt['vxe'].data) - assert np.zeros(10) == pytest.approx(tt['vye'].data) - assert 2017.25 * np.ones(10) == pytest.approx(tt['t0'].data) - - # Test 5b: Things that should break the code. - with pytest.raises(RuntimeError): - tt.fit_velocities(bootstrap=0, verbose=False, mask_lists=np.arange(2)) - with pytest.raises(RuntimeError): - tt.fit_velocities(bootstrap=0, verbose=False, mask_lists=True) + print("Testing Masked List") + tt.fit_motion_models(verbose=False, mask_lists=[1]) + np.testing.assert_allclose(np.arange(2.25, 48, 5), tt['x0'].data) + np.testing.assert_allclose(np.arange(2.25, 48, 5), tt['y0'].data) + np.testing.assert_allclose(np.full(10, 0.05), tt['x0_err'].data) + np.testing.assert_allclose(np.full(10, 0.05), tt['y0_err'].data) + np.testing.assert_allclose(np.ones(10), tt['vx'].data) + np.testing.assert_allclose(np.ones(10), tt['vy'].data) + np.testing.assert_allclose(np.full(10, 0.03380617), tt['vx_err'].data) + np.testing.assert_allclose(np.full(10, 0.03380617), tt['vy_err'].data) + np.testing.assert_allclose(2017.25 * np.ones(10), tt['t0'].data) return -def test_fit_velocities_1epoch(): - ########## - # Test: only 1 epoch - ########## - tab = make_star_table_1epoch() - - # We don't need the entire table... lets just - # pull a small subset for faster testing. - tab1 = tab[0:100] - tab2 = tab[10000:10100] - tab3 = tab[-100:] - tab_1 = table.vstack((tab1, tab2, tab3)) - - tab_1.fit_velocities(verbose=False) - - assert 'n_vfit' in tab_1.colnames - assert 't0' in tab_1.colnames - assert 'x0' in tab_1.colnames - assert 'y0' in tab_1.colnames - assert 'vx' in tab_1.colnames - assert 'vy' in tab_1.colnames - assert 'x0e' in tab_1.colnames - assert 'y0e' in tab_1.colnames - assert 'vxe' in tab_1.colnames - assert 'vye' in tab_1.colnames - - - assert (tab_1['x0'] == tab_1['x'][:,0]).all() - assert (tab_1['y0'] == tab_1['y'][:,0]).all() - assert (tab_1['x0e'] == tab_1['xe'][:,0]).all() - assert (tab_1['y0e'] == tab_1['ye'][:,0]).all() - - assert(tab_1['vx'] == 0).all() - assert(tab_1['vy'] == 0).all() - assert(tab_1['vxe'] == 0).all() - assert(tab_1['vye'] == 0).all() - - assert(tab_1['t0'] == 2001.0).all() - assert(tab_1['n_vfit'] == 1).all() - - return -def test_fit_velocities_2epoch(): - +def test_fit_motion_model_2epoch(): ########## # Test: only 2 epoch2 ########## @@ -463,37 +821,89 @@ def test_fit_velocities_2epoch(): tab2 = tab[10000:10100] tab3 = tab[-100:] tab_2 = table.vstack((tab1, tab2, tab3)) + tab_2.meta=tab1.meta - tab_2.fit_velocities(verbose=False) + tab_2.fit_motion_models(verbose=False, mask_value=-100000.) - assert 'n_vfit' in tab_2.colnames - assert 't0' in tab_2.colnames - assert 'x0' in tab_2.colnames - assert 'y0' in tab_2.colnames - assert 'vx' in tab_2.colnames - assert 'vy' in tab_2.colnames - assert 'x0e' in tab_2.colnames - assert 'y0e' in tab_2.colnames - assert 'vxe' in tab_2.colnames - assert 'vye' in tab_2.colnames + assert all([_ in tab_2.colnames for _ in ['n_fit', 't0', 'x0', 'y0', 'vx', 'vy', 'x0_err', 'y0_err', 'vx_err', 'vy_err']]) # 2 detections + print(tab1.meta) np.testing.assert_almost_equal(tab_2['x0'][0], tab_2['x'][0,0], 1) - assert tab_2['n_vfit'][0] == 2 - + np.testing.assert_equal(tab_2['n_fit'][0], 2) + # 1 detection - assert tab_2['x0'][100] == tab_2['x'][100, 0] - assert tab_2['n_vfit'][100] == 1 - + np.testing.assert_equal(tab_2['x0'][100], tab_2['x'][100, 0]) + np.testing.assert_equal(tab_2['n_fit'][100], 1) + # 0 detections - assert tab_2['x0'][-1] == 0 - assert tab_2['n_vfit'][-1] == 0 - + np.testing.assert_equal(np.isnan(tab_2['x0'][-1]), True) + np.testing.assert_equal(tab_2['n_fit'][-1], 0) + + return + + +def test_multiprocessing(): + rng = np.random.default_rng(42) + N = 10000 + x = rng.random((N, 5)) + y = rng.random((N, 5)) + m = rng.random((N, 5)) + xe = rng.random((N, 5)) + ye = rng.random((N, 5)) + t = np.arange(5) + 2026 + fixed_params_dict = [None for _ in range(N)] + weighting = 'var' + fill_value = np.nan + verbose = True + + st1 = StarTable( + name=np.arange(N), + x=x, + y=y, + m=m, + xe=xe, + ye=ye + ) + st1.meta['list_times'] = t + + st2 = StarTable( + name=np.arange(N), + x=x, + y=y, + m=m, + xe=xe, + ye=ye + ) + st2.meta['list_times'] = t + + st1.fit_motion_models( + motion_models=['Linear'], + weighting=weighting, + absolute_sigma=True, + bootstrap=0, + fill_value=fill_value, + verbose=verbose + ) + + st2.fit_motion_models( + motion_models=['Linear'], + weighting=weighting, + absolute_sigma=True, + bootstrap=0, + fill_value=fill_value, + processes=10, + verbose=verbose + ) + + for key in ['x0', 'x0_err', 'y0', 'y0_err', 'vx', 'vx_err', 'vy', 'vy_err', 'chi2_x', 'chi2_y', 'n_params', 't0']: + np.testing.assert_array_equal(st1[key], st2[key], err_msg=f"Mismatch in {key} between single and multi-processing runs.") return + def make_star_table(): # User input - cat_file = test_dir + '/test_catalog.fits' + cat_file = f'{test_data_path}/test_catalog.fits' # Read and arrange the test input cat_tab = Table.read(cat_file) @@ -514,15 +924,21 @@ def make_star_table(): starlist_names = np.array(['file1', 'file2', 'file3', 'file4', 'file5', 'file6', 'file7', 'file8']) # Generate the startable - startable = StarTable(name=name_in, x=x_in, y=y_in, m=m_in, xe=xe_in, ye=ye_in, me=me_in, n=n_in, - ref_list=1, - list_times=starlist_times, list_names=starlist_names) + startable = StarTable( + name=name_in, + x=x_in, y=y_in, m=m_in, + xe=xe_in, ye=ye_in, me=me_in, + n=n_in, + ref_list=1 + ) + startable.meta['list_times'] = starlist_times + startable.meta['list_names'] = starlist_names return startable def make_star_table_1epoch(): # User input - cat_file = test_dir + '/test_catalog.fits' + cat_file = f'{test_data_path}/test_catalog.fits' # Read and arrange the test input cat_tab = Table.read(cat_file) @@ -550,8 +966,8 @@ def make_star_table_1epoch(): return startable def make_star_table_2epoch(): - # User inpup - cat_file = test_dir + '/test_catalog.fits' + # User input + cat_file = f'{test_data_path}/test_catalog.fits' # Read and arrange the test input cat_tab = Table.read(cat_file) @@ -605,3 +1021,7 @@ def make_tiny_star_table(): xe=xe_in, ye=ye_in, me=me_in) return startable + + +if __name__ == "__main__": + test_combine_lists() \ No newline at end of file diff --git a/flystar/tests/test_transforms.py b/flystar/tests/test_transforms.py index ea7c423..11338ad 100644 --- a/flystar/tests/test_transforms.py +++ b/flystar/tests/test_transforms.py @@ -22,9 +22,9 @@ def compare_evaluate_errors(): xe = np.abs(np.random.randn(100) * 0.1) ye = np.abs(np.random.randn(100) * 0.1) - xe_new1 = foo._evaluate_error2(x, y, xe, ye, foo.px.parameters) + # xe_new1 = foo._evaluate_error2(x, y, xe, ye, foo.px.parameters) - xe_new2, ye_new2 = foo._evaluate_error(x, y, xe, ye) + xe_new2, ye_new2 = foo.evaluate_error(x, y, xe, ye) # BROKEN diff --git a/flystar/transforms.py b/flystar/transforms.py index 0a1885a..5c7b0d4 100755 --- a/flystar/transforms.py +++ b/flystar/transforms.py @@ -1,11 +1,28 @@ -from astropy.modeling import models, fitting +import os +import re +import copy +import datetime import numpy as np -from scipy.interpolate import LSQBivariateSpline as spline -from scipy import stats -from astropy.table import Table import collections -import re -import pdb +from flystar import motion_model +from astropy.table import Table +from astropy.modeling import models, fitting +from scipy import stats +from scipy.interpolate import LSQBivariateSpline as spline + + +def _deriv_times_error(deriv, err): + """ + deriv * err, but treats an exactly-zero derivative as contributing + exactly zero regardless of err -- including when err is inf (an + unknown uncertainty) or nan. A transform with zero sensitivity to an + input genuinely propagates zero uncertainty from it; 0 * inf/nan is + an indeterminate form only in general, not in this specific case, + where the correct limit is well-defined. + """ + with np.errstate(invalid='ignore'): + return np.where(deriv == 0, 0.0, deriv * err) + class Transform2D(object): ''' @@ -112,21 +129,39 @@ def evaluate_starlist(self, star_list): new_list['xe'] = vals[0] new_list['ye'] = vals[1] - # Velocities (if they exist) - if 'vx' in new_list.colnames: + # Velocities (if they exist and no more complex motion model used) + complex_motion_model = ('motion_model_input' in new_list.colnames) + if complex_motion_model: + # If the only motion models used are Fixed and Linear, we can still transform velocities. + motion_models_unique = list(np.unique(starlist_f['motion_model_input'])) + if 'Linear' in motion_models_unique: + motion_models_unique.remove('Linear') + if 'Fixed' in motion_models_unique: + motion_models_unique.remove('Fixed') + if len(motion_models_unique)==0: + complex_motion_model=False + # Cannot transform more complex motion models - set values to nan + if complex_motion_model: + motion_params = motion_model.motion_model_param_names(new_list['motion_model_input'], with_errors=True, with_fixed=False) + for param in motion_params: + if param in new_list.colnames: + new_list[param] = np.nan + + if ('vx' in new_list.colnames) and (not complex_motion_model): + # For velocity only, no problem vals = self.evaluate_vel(star_list['x'], star_list['y'], star_list['vx'], star_list['vy']) new_list['vx'] = vals[0] new_list['vy'] = vals[1] # Velocity errors (if they exist) - if 'vxe' in new_list.colnames: + if 'vx_err' in new_list.colnames: vals = self.evaluate_vel_error(star_list['x'], star_list['y'], star_list['vx'], star_list['vy'], star_list['xe'], star_list['ye'], - star_list['vxe'], star_list['vye']) - new_list['vxe'] = vals[0] - new_list['vye'] = vals[1] + star_list['vx_err'], star_list['vy_err']) + new_list['vx_err'] = vals[0] + new_list['vy_err'] = vals[1] return new_list @@ -201,12 +236,12 @@ def evaluate(self, x, y): yn = self.py[0] + self.py[1]*x + self.py[2]*y return xn, yn - def evaluate_error(self, x, y): + def evaluate_error(self, x, y, xe, ye): """ Transform positional uncertainties. - Parameters: + Parameters ---------- x : numpy array The original x coordinates to be used in the transformation. @@ -226,7 +261,7 @@ def evaluate_error(self, x, y): """ xe_new = np.hypot(self.px[1] * xe, self.px[2] * ye) - xe_new = np.hpyot(self.px[1] * xe, self.px[2] * ye) + ye_new = np.hypot(self.py[1] * xe, self.py[2] * ye) return xe_new, ye_new @@ -254,25 +289,20 @@ def __init__(self, order, px, py, pxerr=None, pyerr=None, mag_offset=0.0): Parameters ---------- - px : list or array [a0, a1, a2, ...] + order : int + The order of the transformation. 0 = 2 free parameters, 1 = 6 free parameters. + px : list or array [a0, a1, a2, ...] coefficients to transform input x coordinates into output x' coordinates. - py : list or array [b0, b1, b2, ...] coefficients to transform input y coordinates into output y' coordinates. - - order : int - The order of the transformation. 0 = 2 free parameters, 1 = 6 free parameters. - - pxerr : array or list + pxerr : array or list, optional array or list of errors of the coefficients to transform input x coordinates - into output x' coordinates. - - pyerr : array or list + into output x' coordinates, by default None. + pyerr : array or list, optional array or list of errors of the coefficients to transform input y coordinates - into output y' coordinates. - - mag_offset : float - magnitude difference with the reference catalog (mag_ref - mag_cat) + into output y' coordinates, by default None. + mag_offset : float, optional + magnitude difference with the reference catalog (mag_ref - mag_cat), by default 0.0. """ self.order = order @@ -288,7 +318,7 @@ def __init__(self, order, px, py, pxerr=None, pyerr=None, mag_offset=0.0): px_dict = PolyTransform.make_param_dict(px, self.poly_order, isY=False) py_dict = PolyTransform.make_param_dict(py, self.poly_order, isY=True) - fixed_params = {'c0_0': False, 'c1_0': True, 'c1_1': True} + fixed_params = {'c0_0': False, 'c1_0': True, 'c0_1': True} #, 'c1_1':True} self.px = models.Polynomial2D(self.poly_order, **px_dict, fixed=fixed_params) self.py = models.Polynomial2D(self.poly_order, **py_dict, fixed=fixed_params) else: @@ -312,7 +342,7 @@ def make_param_dict(initial_param, order, isY=False): a0 + a1*x + a2*y + a3*x^2 + a4*x*y + a5*y^2 + a6*x^3 + a7*x^2*y + a8*x*y^2 + a9*y^3 - and conver this into a dictionary where: + and convert this into a dictionary where: c0_0 = a0 c1_0 = a1 @@ -328,7 +358,9 @@ def make_param_dict(initial_param, order, isY=False): The input/output ordering is set for easy coding using: for i in range(self.order + 1): + for j in range(i + 1): + coeff[i-j, j] for term x**(i-j) * y**(j) But astropy models Polynomial2D has its own special order... we try to @@ -371,7 +403,7 @@ def evaluate(self, x, y): """ Apply the transformation to a starlist. - Parameters: + Parameters ---------- x : numpy array The raw x coordinates to be transformed. @@ -392,7 +424,7 @@ def evaluate_error(self, x, y, xe, ye): """ Transform positional uncertainties. - Parameters: + Parameters ---------- x : numpy array The original x coordinates to be used in the transformation. @@ -435,8 +467,8 @@ def evaluate_error(self, x, y, xe, ye): # Take square root for xe/ye_new - xe_new = np.sqrt((dxnew_dx * xe)**2 + (dxnew_dy * ye)**2) - ye_new = np.sqrt((dynew_dx * xe)**2 + (dynew_dy * ye)**2) + xe_new = np.sqrt(_deriv_times_error(dxnew_dx, xe)**2 + _deriv_times_error(dxnew_dy, ye)**2) + ye_new = np.sqrt(_deriv_times_error(dynew_dx, xe)**2 + _deriv_times_error(dynew_dy, ye)**2) return xe_new, ye_new @@ -444,7 +476,7 @@ def evaluate_vel(self, x, y, vx, vy): """ Transform velocities. - Parameters: + Parameters ---------- x : numpy array The original x coordinates to be used in the transformation. @@ -488,7 +520,7 @@ def evaluate_vel_err(self, x, y, vx, vy, xe, ye, vxe, vye): """ Transform velocities. - Parameters: + Parameters ---------- x : numpy array The original x coordinates to be used in the transformation. @@ -560,10 +592,10 @@ def evaluate_vel_err(self, x, y, vx, vy, xe, ye, vxe, vye): dvxnew_dvy += Xcoeff * (j) * x**(i-j) * y**(j-1) dvynew_dvy += Ycoeff * (j) * x**(i-j) * y**(j-1) - vxe_new = np.sqrt((dvxnew_dx * xe)**2 + (dvxnew_dy * ye)**2 + - (dvxnew_dvx * vxe)**2 + (dvxnew_dvy * vye)**2) - vye_new = np.sqrt((dvynew_dx * xe)**2 + (dvynew_dy * ye)**2 + - (dvynew_dvx * vxe)**2 + (dvynew_dvy * vye)**2) + vxe_new = np.sqrt(_deriv_times_error(dvxnew_dx, xe)**2 + _deriv_times_error(dvxnew_dy, ye)**2 + + _deriv_times_error(dvxnew_dvx, vxe)**2 + _deriv_times_error(dvxnew_dvy, vye)**2) + vye_new = np.sqrt(_deriv_times_error(dvynew_dx, xe)**2 + _deriv_times_error(dvynew_dy, ye)**2 + + _deriv_times_error(dvynew_dvx, vxe)**2 + _deriv_times_error(dvynew_dvy, vye)**2) return vxe_new, vye_new @@ -580,7 +612,7 @@ def derive_transform(cls, x, y, xref, yref, order, m=None, mref=None, init_gx = PolyTransform.make_param_dict(init_gx, poly_order, isY=False) init_gy = PolyTransform.make_param_dict(init_gy, poly_order, isY=True) - fixed_params = {'c0_0': False, 'c1_0': True, 'c1_1': True, 'c0_1': True} + fixed_params = {'c0_0': False, 'c1_0': True, 'c0_1': True} #, 'c1_1':True} p_init_x = models.Polynomial2D(poly_order, **init_gx, fixed=fixed_params) p_init_y = models.Polynomial2D(poly_order, **init_gy, fixed=fixed_params) else: @@ -592,6 +624,7 @@ def derive_transform(cls, x, y, xref, yref, order, m=None, mref=None, fit_p = fitting.LinearLSQFitter() + #pdb.set_trace() px = fit_p(p_init_x, x, y, xref, weights=weights) py = fit_p(p_init_y, x, y, yref, weights=weights) @@ -616,15 +649,18 @@ def derive_transform(cls, x, y, xref, yref, order, m=None, mref=None, @classmethod def from_file(cls, trans_file): - """ + r""" Given a transformation coefficients file, read in the coefficients and create a PolyTransform object. Coefficients in the input file should have the following order: - x' = a0 + a1*x + a2*y + a3*x**2. + a4*x*y + a5*y**2. + ... - y' = b0 + b1*x + b2*y + b3*x**2. + b4*x*y + b5*y**2. + ... + + .. math:: + + x' &= a_0 + a_1 x + a_2 y + a_3 x^2 + a_4 x y + a_5 y^2 + \dots \\ + y' &= b_0 + b_1 x + b_2 y + b_3 x^2 + b_4 x y + b_5 y^2 + \dots - Parameters: + Parameters ---------- trans_file : str The name of the input file to read in. @@ -646,20 +682,23 @@ def from_file(cls, trans_file): return trans_obj - def to_file(self, trans_file): - """ + def to_file(self, transform, outFile): + r""" Given a transformation object, write out the coefficients in a text file (readable by java align). Outfile name is specified by user. Coefficients are output in file in the following way: - x' = a0 + a1*x + a2*y + a3*x**2. + a4*x*y + a5*y**2. + ... - y' = b0 + b1*x + b2*y + b3*x**2. + b4*x*y + b5*y**2. + ... + + .. math:: + + x' &= a_0 + a_1 x + a_2 y + a_3 x^2 + a_4 x y + a_5 y^2 + \dots \\ + y' &= b_0 + b_1 x + b_2 y + b_3 x^2 + b_4 x y + b_5 y^2 + \dots - Parameters: + Parameters ---------- - trans_file : str - The name of the output file to save the coefficients and meta data to. - This file can be read back in with + transform : PolyTransform + The transformation object containing the coefficients and meta data to save. + This object can be recreated with trans_obj = PolyTransfrom.from_file(trans_file). @@ -675,7 +714,7 @@ def to_file(self, trans_file): # Write output _out = open(outFile, 'w') - + # Write the header. DO NOT CHANGE, HARDCODED IN JAVA ALIGN _out.write('## Date: {0}\n'.format(datetime.date.today()) ) _out.write('## File: {0}, Reference: {1}\n'.format(starlist, reference) ) @@ -784,13 +823,10 @@ def __init__(self, order, px, py, x_domain, y_domain, ---------- order : int The order of the transformation. - px : list or array [a0, a1, a2, ...] coefficients to transform input x coordinates into output x' coordinates. - py : list or array [b0, b1, b2, ...] coefficients to transform input y coordinates into output y' coordinates. - x_domain: list or array [xmin, xmax] y_domain: list or array [ymin, ymax] This is the allowable range of input values and it will be conditioned onto @@ -802,21 +838,19 @@ def __init__(self, order, px, py, x_domain, y_domain, Optional Inputs --------------- - pxerr : array or list + pxerr : array or list, optional array or list of errors of the coefficients to transform input x coordinates - into output x' coordinates. - - pyerr : array or list + into output x' coordinates, by default None. + pyerr : array or list, optional array or list of errors of the coefficients to transform input y coordinates - into output y' coordinates. - - mag_offset : float - Magnitude transformation term... only offset applied (offset = mag_out - mag_in) - - astropy_order : boolean + into output y' coordinates, by default None. + mag_offset : float, optional + Magnitude transformation term... only offset applied (offset = mag_out - mag_in). + By default 0.0. + astropy_order : boolean, optional Use our parameter ordering (if False) where going from order=0 --> 1 keeps the lowest order terms in the same order (same for order=1 --> 2). If True, - then use the default astropy.models.Legendre2D paramter ordering scheme. + then use the default astropy.models.Legendre2D paramter ordering scheme, by default False. """ if not astropy_order: px_dict = LegTransform.make_param_dict(px, order, isY=False) @@ -853,8 +887,10 @@ def derive_transform(cls, x, y, xref, yref, order, m=None, mref=None, Legnedre polynomials as the basis. Transforms are independent for x and y and of the form: + x' = c0_0 + c1_0 * L_1(x) + c0_1*L_1(y) + .... y' = d0_0 + d1_0 * L_1(x) + d0_1*L_1(y) + .... + Note that all input coordinates will be renomalized to be on the interval of [-1:1] before fitting. The evaulate function must use the same renormalization procedure. """ @@ -1049,7 +1085,7 @@ def evaluate(self, x, y): """ Apply the transformation to a starlist. - Parameters: + Parameters ---------- x : numpy array The raw x coordinates to be transformed. @@ -1081,7 +1117,7 @@ def evaluate_error(self, x, y, xe, ye): """ Transform positional uncertainties. - Parameters: + Parameters ---------- x : numpy array The original x coordinates to be used in the transformation. @@ -1149,7 +1185,7 @@ def evaluate_vel(self, x, y, vx, vy): """ Transform velocities. - Parameters: + Parameters ---------- x : numpy array The original x coordinates to be used in the transformation. @@ -1203,7 +1239,7 @@ def evaluate_vel_err(self, x, y, vx, vy, xe, ye, vxe, vye): """ Transform velocities. - Parameters: + Parameters ---------- x : numpy array The original x coordinates to be used in the transformation. diff --git a/flystar/version.py b/flystar/version.py deleted file mode 100644 index 8571359..0000000 --- a/flystar/version.py +++ /dev/null @@ -1,8 +0,0 @@ -# Note that we need to fall back to the hard-coded version if either -# setuptools_scm can't be imported or setuptools_scm can't determine the -# version, so we catch the generic 'Exception'. -try: - from setuptools_scm import get_version - version = get_version(root='..', relative_to=__file__) -except Exception: - version = '0.1.dev392+gf99036d.d20201212' diff --git a/pyproject.toml b/pyproject.toml index 513457b..3b0b67b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,73 @@ [project] name = "flystar" -description = "FlyStar Astromeric Analysis Package" +description = "FlyStar Astrometric Analysis Package" readme = "README.rst" authors = [{name="Jessica Lu", email="jlu.astro@berkeley.edu"}, {name="Matt Hosek", email="mwhosek@astro.ucla.edu"}] -license = {text="BSD 3-Clause License"} -dependencies = ["numpy", "astropy>=3.2"] -#dynamic = ["version"] -version = "0.1" +license = "BSD-3-Clause" +license-files = ["licenses/LICENSE.rst"] +requires-python = ">=3.9" +dynamic = ["version"] + +# jplephem is required, not optional: astropy reaches for it whenever a +# solar-system ephemeris is evaluated, which the Parallax motion model does, so +# without it Parallax raises ModuleNotFoundError at runtime. +dependencies = [ + "numpy", + "astropy>=3.2", + "scipy", + "matplotlib", + "tqdm", + "joblib", + "pandas", + "h5py", + "jplephem", +] + +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Astronomy", +] + +[project.optional-dependencies] +docs = ["sphinx-astropy"] +test = ["pytest-astropy"] +# Imported lazily, inside the functions that need them, so the package works +# without these -- but the features that use them do not. +# shapely -- polygon footprints for the initial guess (align, match) +# astroquery -- Gaia and JPL Horizons queries (analysis, parallax) +# plotly -- interactive diagnostic plots (align, plots) +optional = ["shapely", "astroquery", "plotly"] [project.urls] homepage = "https://github.com/MovingUniverseLab/flystar" +repository = "https://github.com/MovingUniverseLab/flystar" +documentation = "https://flystar.readthedocs.io" [build-system] -requires = ["setuptools", +# setuptools 77 is the first release to accept an SPDX `license` string and +# `license-files` in [project]. +requires = ["setuptools>=77", "setuptools_scm", - "wheel", - "extension-helpers", - "oldest-supported-numpy", - "cython==0.29.14"] - + "wheel"] build-backend = 'setuptools.build_meta' + +[tool.setuptools.packages.find] +include = ["flystar*"] +# Tests stay in the repository rather than in the installed package. +# flystar/tests/test_data is 24 MB, 22 MB of it a single FITS catalogue, which +# would otherwise be 96% of the wheel. The suite also locates its fixtures via +# flystar.__path__, so shipping the test modules without that data would leave +# an installed suite that cannot run. +exclude = ["flystar.tests*"] + +[tool.setuptools.package-data] +flystar = ["data/*"] diff --git a/setup.cfg b/setup.cfg index 0bf235e..f5b3998 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,43 +1,14 @@ -[metadata] -name = flystar -author = Jessica Lu -author_email = jlu.astro@berkeley.edu -license = BSD 3-Clause -license_file = licenses/LICENSE.rst -url = https://bitbucket.org/mwhosek/flystar -description = FlyStar -long_description = file: README.rst -long_description_content_type = text/x-rst -edit_on_github = False -github_project = astropy/astropy - -[options] -zip_safe = False -packages = find: -python_requires = >=3.7 -setup_requires = setuptools_scm -install_requires = - astropy - -[options.entry_points] -console_scripts = - astropy-package-template-example = packagename.example_mod:main - -[options.extras_require] -test = - pytest-astropy -docs = - sphinx-astropy - -[options.package_data] -flystar = data/* +# Project metadata and dependencies live in pyproject.toml. +# Only tooling configuration that has no [project] equivalent stays here: +# setuptools reads [project] in preference to [metadata]/[options], so +# anything duplicated below would be silently discarded. [tool:pytest] testpaths = "flystar" "docs" astropy_header = true doctest_plus = enabled text_file_format = rst -addopts = --doctest-rst +#addopts = --doctest-rst [coverage:run] omit =