orbix 1.2.0__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {orbix-1.2.0 → orbix-2.0.0}/.github/workflows/docs.yml +1 -1
  2. {orbix-1.2.0 → orbix-2.0.0}/.gitignore +1 -0
  3. {orbix-1.2.0 → orbix-2.0.0}/.pre-commit-config.yaml +1 -1
  4. {orbix-1.2.0 → orbix-2.0.0}/.zenodo.json +1 -1
  5. {orbix-1.2.0 → orbix-2.0.0}/CHANGELOG.md +25 -0
  6. {orbix-1.2.0 → orbix-2.0.0}/CITATION.cff +1 -1
  7. {orbix-1.2.0 → orbix-2.0.0}/PKG-INFO +16 -2
  8. {orbix-1.2.0 → orbix-2.0.0}/README.md +14 -1
  9. {orbix-1.2.0 → orbix-2.0.0}/docs/conf.py +1 -1
  10. {orbix-1.2.0 → orbix-2.0.0}/docs/examples/orbits.md +19 -9
  11. orbix-2.0.0/docs/examples/prepared.md +304 -0
  12. {orbix-1.2.0 → orbix-2.0.0}/docs/gallery.md +73 -12
  13. {orbix-1.2.0 → orbix-2.0.0}/docs/index.md +4 -1
  14. {orbix-1.2.0 → orbix-2.0.0}/pyproject.toml +1 -1
  15. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/_version.py +2 -2
  16. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/kepler/__init__.py +8 -1
  17. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/kepler/core.py +24 -9
  18. orbix-2.0.0/src/orbix/kepler/markley.py +126 -0
  19. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/viz/__init__.py +6 -0
  20. orbix-2.0.0/src/orbix/viz/_prepare.py +521 -0
  21. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/viz/anim.py +227 -184
  22. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/viz/orbit.py +129 -115
  23. {orbix-1.2.0 → orbix-2.0.0}/tests/test_kepler_core.py +36 -1
  24. orbix-2.0.0/tests/test_kepler_markley.py +123 -0
  25. {orbix-1.2.0 → orbix-2.0.0}/tests/test_viz_anim.py +62 -11
  26. {orbix-1.2.0 → orbix-2.0.0}/tests/test_viz_orbit.py +125 -36
  27. orbix-2.0.0/tests/test_viz_package.py +140 -0
  28. orbix-2.0.0/tests/test_viz_prepared.py +818 -0
  29. orbix-1.2.0/tests/test_viz_package.py +0 -71
  30. {orbix-1.2.0 → orbix-2.0.0}/.github/dependabot.yml +0 -0
  31. {orbix-1.2.0 → orbix-2.0.0}/.github/workflows/publish-to-pypi.yml +0 -0
  32. {orbix-1.2.0 → orbix-2.0.0}/.github/workflows/release-please.yml +0 -0
  33. {orbix-1.2.0 → orbix-2.0.0}/.github/workflows/tests.yml +0 -0
  34. {orbix-1.2.0 → orbix-2.0.0}/.python-version +0 -0
  35. {orbix-1.2.0 → orbix-2.0.0}/.readthedocs.yaml +0 -0
  36. {orbix-1.2.0 → orbix-2.0.0}/LICENSE +0 -0
  37. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/__init__.py +0 -0
  38. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/equations/__init__.py +0 -0
  39. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/equations/lambert.py +0 -0
  40. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/equations/orbit.py +0 -0
  41. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/equations/phase.py +0 -0
  42. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/equations/propagation.py +0 -0
  43. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/kepler/shortcuts/__init__.py +0 -0
  44. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/kepler/shortcuts/fixed_e.py +0 -0
  45. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/kepler/shortcuts/grid.py +0 -0
  46. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/kepler/shortcuts/guess.py +0 -0
  47. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/observatory/__init__.py +0 -0
  48. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/observatory/data/L2_halo_orbit_six_month.npz +0 -0
  49. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/observatory/keepout.py +0 -0
  50. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/observatory/observatory.py +0 -0
  51. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/observatory/orbit.py +0 -0
  52. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/observatory/solar_system.py +0 -0
  53. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/orbit.py +0 -0
  54. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/py.typed +0 -0
  55. {orbix-1.2.0 → orbix-2.0.0}/src/orbix/viz/_require.py +0 -0
  56. {orbix-1.2.0 → orbix-2.0.0}/tests/conftest.py +0 -0
  57. {orbix-1.2.0 → orbix-2.0.0}/tests/test_equations_grads.py +0 -0
  58. {orbix-1.2.0 → orbix-2.0.0}/tests/test_fixed_e_solvers.py +0 -0
  59. {orbix-1.2.0 → orbix-2.0.0}/tests/test_grid_solvers.py +0 -0
  60. {orbix-1.2.0 → orbix-2.0.0}/tests/test_internal_refs_guard.py +0 -0
  61. {orbix-1.2.0 → orbix-2.0.0}/tests/test_kepler_grads.py +0 -0
  62. {orbix-1.2.0 → orbix-2.0.0}/tests/test_lambert.py +0 -0
  63. {orbix-1.2.0 → orbix-2.0.0}/tests/test_observatory.py +0 -0
  64. {orbix-1.2.0 → orbix-2.0.0}/tests/test_observatory_wrapper.py +0 -0
  65. {orbix-1.2.0 → orbix-2.0.0}/tests/test_orbit.py +0 -0
  66. {orbix-1.2.0 → orbix-2.0.0}/tests/test_vs_exosims.py +0 -0
  67. {orbix-1.2.0 → orbix-2.0.0}/tools/check_internal_refs.py +0 -0
@@ -12,7 +12,7 @@ jobs:
12
12
  name: Build docs
13
13
  runs-on: ubuntu-latest
14
14
  steps:
15
- - uses: actions/checkout@v6
15
+ - uses: actions/checkout@v7
16
16
 
17
17
  - name: Install uv
18
18
  uses: astral-sh/setup-uv@v7
@@ -70,6 +70,7 @@ instance/
70
70
 
71
71
  # Sphinx documentation
72
72
  docs/_build/
73
+ docs/.build/
73
74
 
74
75
  # PyBuilder
75
76
  .pybuilder/
@@ -15,7 +15,7 @@ repos:
15
15
  args: [--pytest-test-first]
16
16
  - id: end-of-file-fixer
17
17
  - repo: https://github.com/astral-sh/ruff-pre-commit
18
- rev: v0.16.4
18
+ rev: v0.16.10
19
19
  hooks:
20
20
  - id: ruff-check
21
21
  args: [--fix]
@@ -3,7 +3,7 @@
3
3
  "creators": [
4
4
  {
5
5
  "orcid": "0000-0002-3138-0240",
6
- "affiliation": "NASA Goddard Space Flight Center, Greenbelt, MD",
6
+ "affiliation": "University of Maryland, Baltimore County; NASA Goddard Space Flight Center; CRESST II",
7
7
  "name": "Spohn, Corey"
8
8
  }
9
9
  ],
@@ -11,6 +11,31 @@
11
11
  * The broken predict/pdet functions and the `discrete_e` factory were removed
12
12
  (they did not work correctly and had no working callers).
13
13
 
14
+ ## [2.0.0](https://github.com/CoreySpohn/orbix/compare/v1.2.0...v2.0.0) (2026-10-12)
15
+
16
+
17
+ ### ⚠ BREAKING CHANGES
18
+
19
+ * **viz:** prepare sky tracks for shared renderers
20
+
21
+ ### Features
22
+
23
+ * **kepler:** add a table-free Markley solver with implicit derivatives ([886bec0](https://github.com/CoreySpohn/orbix/commit/886bec02b6dfda444cf78a84710e1aadc2a2f150))
24
+ * **viz:** prepare sky tracks for shared renderers ([84fd1ef](https://github.com/CoreySpohn/orbix/commit/84fd1ef7dd7fdb51fd0751cef4afd7f75f80dabd))
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * **viz:** accept a quarter-step clock rounding when no precision is exact ([67a3822](https://github.com/CoreySpohn/orbix/commit/67a3822eee0ed7b8474768a7b3b91acf3d048861))
30
+ * **viz:** keep per-track colors on sky tracks ([693e5da](https://github.com/CoreySpohn/orbix/commit/693e5da6be5b55372bd7ae8b7a4d0b63733ee3af))
31
+ * **viz:** print exact clock offsets, take clock_fmt, and weight heads ([022f04a](https://github.com/CoreySpohn/orbix/commit/022f04ac45b6d121f95a42a309b250fd44f94674))
32
+ * **viz:** weight the star and observations at the top track weight so heads fade with their paths ([b190d40](https://github.com/CoreySpohn/orbix/commit/b190d40e2ec876ca5fafd3975485c3268e294cd7))
33
+
34
+
35
+ ### Performance Improvements
36
+
37
+ * **kepler:** select the reduced angle before the trig polynomial and gather RPP coefficients once ([02d71da](https://github.com/CoreySpohn/orbix/commit/02d71dae0984e3e36f58ae70d502ab7703257362))
38
+
14
39
  ## [1.2.0](https://github.com/CoreySpohn/orbix/compare/v1.1.0...v1.2.0) (2026-08-28)
15
40
 
16
41
 
@@ -7,7 +7,7 @@ authors:
7
7
  - family-names: Spohn
8
8
  given-names: Corey
9
9
  orcid: "https://orcid.org/0000-0002-3138-0240"
10
- affiliation: "NASA Goddard Space Flight Center"
10
+ affiliation: "University of Maryland, Baltimore County; NASA Goddard Space Flight Center; CRESST II"
11
11
  repository-code: "https://github.com/CoreySpohn/orbix"
12
12
  license: MIT
13
13
  doi: "10.5281/zenodo.20819376"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: orbix
3
- Version: 1.2.0
3
+ Version: 2.0.0
4
4
  Summary: A JAX library of functions useful for exoplanet simulations
5
5
  Project-URL: Homepage, https://github.com/CoreySpohn/orbix
6
6
  Project-URL: Issues, https://github.com/CoreySpohn/orbix/issues
@@ -44,6 +44,7 @@ Requires-Dist: sphinx-autodoc-typehints; extra == 'docs'
44
44
  Requires-Dist: sphinx-book-theme; extra == 'docs'
45
45
  Provides-Extra: test
46
46
  Requires-Dist: eyepiece>=0.4.0; extra == 'test'
47
+ Requires-Dist: mpmath>=1.3; extra == 'test'
47
48
  Requires-Dist: pytest; extra == 'test'
48
49
  Provides-Extra: viz
49
50
  Requires-Dist: eyepiece>=0.4.0; extra == 'viz'
@@ -71,7 +72,13 @@ from orbix.orbit import KeplerianOrbit
71
72
  from orbix.kepler.shortcuts.grid import get_grid_solver
72
73
 
73
74
  orbit = KeplerianOrbit(
74
- a_AU=1.0, e=0.0167, W_rad=0.0, i_rad=0.0, w_rad=0.0, M0_rad=0.0, t0_d=0.0,
75
+ a_AU=1.0,
76
+ e=0.0167,
77
+ W_rad=0.0,
78
+ i_rad=0.0,
79
+ w_rad=0.0,
80
+ M0_rad=0.0,
81
+ t0_d=0.0,
75
82
  )
76
83
  trig_solver = get_grid_solver(level="scalar", E=False, trig=True)
77
84
  t_jd = jnp.linspace(2451545.0, 2451545.0 + 365.25, 5)
@@ -84,6 +91,13 @@ contract `trig_solver(M, e) -> (sinE, cosE)`; `KeplerianOrbit.propagate` uses
84
91
  it to convert orbital elements and times into position vectors, phase angle,
85
92
  and star-planet distance.
86
93
 
94
+ For an alternative without interpolation tables, use
95
+ `from orbix.kepler import markley_solve_trig` and pass `markley_solve_trig` as the
96
+ orbit's `trig_solver`. This Markley-based solver supports scalar inputs,
97
+ broadcasting, and both forward and reverse derivatives. Enable
98
+ `jax.config.update("jax_enable_x64", True)` for double-precision accuracy.
99
+ The RPP/orvara solver remains available in `orbix.kepler.core`.
100
+
87
101
  ## Scope
88
102
 
89
103
  orbix provides Keplerian orbit geometry and Kepler-equation solvers; scene
@@ -20,7 +20,13 @@ from orbix.orbit import KeplerianOrbit
20
20
  from orbix.kepler.shortcuts.grid import get_grid_solver
21
21
 
22
22
  orbit = KeplerianOrbit(
23
- a_AU=1.0, e=0.0167, W_rad=0.0, i_rad=0.0, w_rad=0.0, M0_rad=0.0, t0_d=0.0,
23
+ a_AU=1.0,
24
+ e=0.0167,
25
+ W_rad=0.0,
26
+ i_rad=0.0,
27
+ w_rad=0.0,
28
+ M0_rad=0.0,
29
+ t0_d=0.0,
24
30
  )
25
31
  trig_solver = get_grid_solver(level="scalar", E=False, trig=True)
26
32
  t_jd = jnp.linspace(2451545.0, 2451545.0 + 365.25, 5)
@@ -33,6 +39,13 @@ contract `trig_solver(M, e) -> (sinE, cosE)`; `KeplerianOrbit.propagate` uses
33
39
  it to convert orbital elements and times into position vectors, phase angle,
34
40
  and star-planet distance.
35
41
 
42
+ For an alternative without interpolation tables, use
43
+ `from orbix.kepler import markley_solve_trig` and pass `markley_solve_trig` as the
44
+ orbit's `trig_solver`. This Markley-based solver supports scalar inputs,
45
+ broadcasting, and both forward and reverse derivatives. Enable
46
+ `jax.config.update("jax_enable_x64", True)` for double-precision accuracy.
47
+ The RPP/orvara solver remains available in `orbix.kepler.core`.
48
+
36
49
  ## Scope
37
50
 
38
51
  orbix provides Keplerian orbit geometry and Kepler-equation solvers; scene
@@ -31,7 +31,7 @@ intersphinx_mapping = {
31
31
  "jax": ("https://docs.jax.dev/en/latest/", None),
32
32
  }
33
33
 
34
- exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
34
+ exclude_patterns = ["_build", ".build", "Thumbs.db", ".DS_Store"]
35
35
 
36
36
  autoapi_dirs = ["../src"]
37
37
  autoapi_ignore = ["**/_version.py"]
@@ -14,8 +14,9 @@ kernelspec:
14
14
  `orbix.viz` turns an orbit into a figure in one call. It is installed with
15
15
  the `viz` extra (`pip install 'orbix[viz]'`) and is built on eyepiece, so
16
16
  every figure here follows the same conventions as any other figure in the
17
- eyepiece fleet: stateless ax-first functions, a `PlotResult` back, and
18
- style resolved from hwostyle at call time.
17
+ eyepiece fleet: stateless functions that draw into a given axes or a new
18
+ figure, a result back that holds every artist drawn, and style resolved from
19
+ hwostyle at call time.
19
20
 
20
21
  Every page starts the same way: activate a style mode, import the plotting
21
22
  module, and declare the document's cast.
@@ -50,7 +51,10 @@ styles = ep.SourceStyles(["planet b", "planet c", "planet d"])
50
51
  `plot_sky_track` propagates an orbit and draws its sky-plane track in
51
52
  arcseconds: RA offset on the x axis (increasing to the left, per the
52
53
  astronomical convention), Dec offset on the y axis, the star at the
53
- origin, and an equal aspect so the ellipse is not distorted.
54
+ origin, and an equal aspect so the ellipse is not distorted. The sky-plane
55
+ functions share one preparation step that propagates the orbit once; the
56
+ {doc}`prepared` page uses it directly for a still, a strip, and a movie from
57
+ one set of tracks.
54
58
 
55
59
  ```{code-cell} ipython3
56
60
  MSUN_KG = 1.988409870698051e30
@@ -108,7 +112,7 @@ t_system = jnp.linspace(T0_D, T0_D + OUTER_PERIOD_D, 240)
108
112
 
109
113
  result = viz.plot_sky_track(
110
114
  orbit, t_jd, Ms_kg=MSUN_KG, dist_pc=10.0,
111
- style=styles["planet b"], iwa=0.06,
115
+ style=styles["planet b"], iwa_arcsec=0.06,
112
116
  )
113
117
  ```
114
118
 
@@ -132,9 +136,14 @@ t_obs = T0_D + np.array([70.0, 250.0, 430.0])
132
136
  ra_true, dec_true = orbit.position_arcsec(
133
137
  t_jd=jnp.asarray(t_obs), Ms_kg=MSUN_KG, dist_pc=10.0
134
138
  )
139
+ # Observations are (xy, xerr, yerr): positions shaped (P, 2) and one-sigma
140
+ # errors along RA and Dec.
135
141
  epochs = (
136
- np.asarray(ra_true)[0] + SIGMA_ARCSEC * rng.standard_normal(3),
137
- np.asarray(dec_true)[0] + SIGMA_ARCSEC * rng.standard_normal(3),
142
+ np.column_stack([
143
+ np.asarray(ra_true)[0] + SIGMA_ARCSEC * rng.standard_normal(3),
144
+ np.asarray(dec_true)[0] + SIGMA_ARCSEC * rng.standard_normal(3),
145
+ ]),
146
+ np.full(3, SIGMA_ARCSEC),
138
147
  np.full(3, SIGMA_ARCSEC),
139
148
  )
140
149
 
@@ -151,7 +160,7 @@ draws = dict(
151
160
  fan = KeplerianOrbit.from_period(**draws, Ms_kg=MSUN_KG)
152
161
  result = viz.plot_sky_track(
153
162
  fan, t_jd, Ms_kg=MSUN_KG, dist_pc=10.0,
154
- style=styles["planet b"], iwa=0.06, data=epochs,
163
+ style=styles["planet b"], iwa_arcsec=0.06, observations=epochs,
155
164
  )
156
165
  ```
157
166
 
@@ -213,7 +222,7 @@ ax3d.set_title("plot_orbit -- star-centric, AU")
213
222
 
214
223
  viz.plot_sky_track(
215
224
  system, t_system, Ms_kg=MSUN_KG, dist_pc=10.0, ax=axsky,
216
- colors=[styles[name]["color"] for name in PLANETS], iwa=0.06,
225
+ colors=[styles[name]["color"] for name in PLANETS], iwa_arcsec=0.06,
217
226
  fan_kw={"lw": 1.8},
218
227
  )
219
228
  axsky.set_title("plot_sky_track -- as seen from Earth")
@@ -250,7 +259,8 @@ keeps a trailing window, `"none"` moves the head markers alone. The base
250
259
  marker size is the anchor the depth cue swells around, so it is where
251
260
  physical meaning lives: `size_by_radius` maps planet radii onto marker
252
261
  diameters. The same call with `kind="sky"` animates the sky-plane track
253
- instead.
262
+ instead, played in physical time from the prepared sequence described in
263
+ {doc}`prepared`.
254
264
 
255
265
  ```{code-cell} ipython3
256
266
  from IPython.display import HTML
@@ -0,0 +1,304 @@
1
+ ---
2
+ jupytext:
3
+ text_representation:
4
+ extension: .md
5
+ format_name: myst
6
+ format_version: 0.13
7
+ kernelspec:
8
+ display_name: Python 3
9
+ language: python
10
+ name: python3
11
+ ---
12
+
13
+ # Sky tracks, prepared once
14
+
15
+ A sky-plane figure of a planet's orbit is needed in several forms: a paper
16
+ still of a posterior fan, a strip of chosen epochs, a movie, and a clip in a
17
+ talk. Each form needs the same science: the orbits propagated over the time
18
+ grid, projected to arcseconds, and moved from the device to the host. The
19
+ preparation functions in `orbix.viz` do that work once and return
20
+ [eyepiece](https://eyepiece.readthedocs.io) prepared records, which
21
+ eyepiece's renderers draw. None of the outputs below propagates an orbit
22
+ again, so they cannot disagree about where the planet is.
23
+
24
+ - `prepare_sky_view` returns a static `TrackView` of full tracks, with no
25
+ time axis.
26
+ - `prepare_sky_track` returns a `Sequence` whose samples are the propagated
27
+ epochs, with a head per track, an optional trail, and a clock.
28
+
29
+ Their module imports NumPy, JAX, the orbit types, and `eyepiece.prepared`,
30
+ and no renderer. Drawing belongs to
31
+ `eyepiece.mpl` for figures and movies and `eyepiece.manim` for native Manim
32
+ clips. The records, scales, and sample-and-hold time rules are described in
33
+ eyepiece's
34
+ [prepared views guide](https://eyepiece.readthedocs.io/en/latest/prepared-views.html).
35
+
36
+ ```{code-cell} ipython3
37
+ import jax
38
+
39
+ # Julian days near 2.46 million need double precision to resolve a fraction
40
+ # of a day, and the clock below prints the sampled times.
41
+ jax.config.update("jax_enable_x64", True)
42
+
43
+ import hwostyle
44
+ import matplotlib
45
+ import matplotlib.pyplot as plt
46
+ import numpy as np
47
+ from hwoutils.constants import Msun2kg
48
+ from IPython.display import HTML
49
+
50
+ import eyepiece.mpl as mpl
51
+ from eyepiece.prepared import find_element
52
+ from eyepiece.style import SourceCast, snapshot_profile
53
+ from orbix import KeplerianOrbit
54
+ from orbix.viz import prepare_sky_track, prepare_sky_view
55
+
56
+ hwostyle.use("dark")
57
+
58
+ # Docs-builder concerns, not lines to copy into your own scripts: a fallback
59
+ # face for builders without the style's fonts, and small page images.
60
+ matplotlib.rcParams["font.sans-serif"] = list(
61
+ matplotlib.rcParams["font.sans-serif"]
62
+ ) + ["DejaVu Sans"]
63
+ matplotlib.rcParams["savefig.dpi"] = 120
64
+
65
+ # One cast and one profile for every output on this page, sized from the
66
+ # active style's rc settings.
67
+ CAST = SourceCast(["planet b"])
68
+ PROFILE = snapshot_profile(
69
+ text_size_pt=plt.rcParams["font.size"],
70
+ stroke_width_pt=plt.rcParams["lines.linewidth"],
71
+ )
72
+ ```
73
+
74
+ ## A posterior and its data
75
+
76
+ The planet below is synthetic: an eccentric, inclined orbit around a solar
77
+ mass star at 10 pc, three measured positions drawn from it with 8 mas
78
+ errors, and forty posterior draws scattered around it, each with a weight.
79
+
80
+ ```{code-cell} ipython3
81
+ DIST_PC = 10.0
82
+ A_AU, ECC, INC_RAD = 1.3, 0.31, 1.05
83
+ BIG_OMEGA_RAD, SMALL_OMEGA_RAD, M0_RAD = 2.3, -0.7, 0.4
84
+ T0_D = 2460000.0
85
+ PERIOD_D = A_AU**1.5 * 365.25
86
+ PERIAPSIS_D = T0_D - M0_RAD * PERIOD_D / (2.0 * np.pi)
87
+
88
+ orbit = KeplerianOrbit(
89
+ a_AU=A_AU, e=ECC, W_rad=BIG_OMEGA_RAD, i_rad=INC_RAD,
90
+ w_rad=SMALL_OMEGA_RAD, M0_rad=M0_RAD, t0_d=T0_D,
91
+ )
92
+ rng = np.random.default_rng(0)
93
+
94
+ # Observed positions: the true orbit at three epochs, perturbed by the error.
95
+ SIGMA_ARCSEC = 0.008
96
+ t_obs = T0_D + np.array([70.0, 250.0, 430.0])
97
+ ra, dec = orbit.position_arcsec(t_jd=t_obs, Ms_kg=Msun2kg, dist_pc=DIST_PC)
98
+ xy_obs = np.column_stack([np.asarray(ra)[0], np.asarray(dec)[0]])
99
+ xy_obs = xy_obs + SIGMA_ARCSEC * rng.standard_normal(xy_obs.shape)
100
+ sigma = np.full(len(t_obs), SIGMA_ARCSEC)
101
+ observations = (xy_obs, sigma, sigma)
102
+
103
+ # Forty posterior draws around the same orbit, and a weight for each.
104
+ K = 40
105
+ fan = KeplerianOrbit.from_period(
106
+ T_d=PERIOD_D * (1.0 + 0.04 * rng.standard_normal(K)),
107
+ e=np.clip(ECC + 0.04 * rng.standard_normal(K), 0.0, 0.9),
108
+ cos_i=np.clip(np.cos(INC_RAD) + 0.06 * rng.standard_normal(K), -1.0, 1.0),
109
+ W_rad=BIG_OMEGA_RAD + 0.08 * rng.standard_normal(K),
110
+ cos_w=np.cos(SMALL_OMEGA_RAD + 0.15 * rng.standard_normal(K)),
111
+ sin_w=np.sin(SMALL_OMEGA_RAD + 0.15 * rng.standard_normal(K)),
112
+ tp_d=PERIAPSIS_D + 12.0 * rng.standard_normal(K),
113
+ Ms_kg=Msun2kg,
114
+ )
115
+ weights = rng.dirichlet(np.full(K, 3.0))
116
+
117
+ # One orbital period in 180 steps of about three days.
118
+ t_jd = np.linspace(T0_D, T0_D + PERIOD_D, 181)
119
+ ```
120
+
121
+ ## A still of the fan
122
+
123
+ `prepare_sky_view` propagates every draw over `t_jd` and returns one
124
+ `TrackView`. Each draw is its own `Path` (`path/0` to `path/39`), so every
125
+ draw stays addressable, while `source_ids="planet b"` names the one planet
126
+ they all describe, so they share one color from the cast. The weights become
127
+ relative opacity. The observations carry their error bars, and
128
+ `iwa_arcsec` adds the inner working angle as a shaded disk about the star.
129
+ RA offset increases to the left and the two axes share one scale; both are
130
+ part of the view, not options.
131
+
132
+ ```{code-cell} ipython3
133
+ view = prepare_sky_view(
134
+ fan,
135
+ t_jd=t_jd,
136
+ Ms_kg=Msun2kg,
137
+ dist_pc=DIST_PC,
138
+ source_ids="planet b",
139
+ weights=weights,
140
+ observations=observations,
141
+ iwa_arcsec=0.06,
142
+ )
143
+ fig, ax = plt.subplots(figsize=(4.2, 4.0), layout="constrained")
144
+ fan_still = mpl.render(view, ax=ax, cast=CAST, profile=PROFILE)
145
+ print(len(fan_still.parts), "parts, including", sorted(fan_still.parts)[:4])
146
+ ```
147
+
148
+ `fan_still.parts` maps each element ID to its Matplotlib artist, so one
149
+ draw, the star, or the IWA disk is adjusted through its handle. The
150
+ Matplotlib convenience `plot_sky_track` makes this same view and render in
151
+ one call.
152
+
153
+ ## A sequence of epochs
154
+
155
+ `prepare_sky_track` takes the same arguments and returns a `Sequence` over
156
+ the propagated epochs. Its template adds a head per draw (`head/<k>`), which
157
+ holds the propagated position at the current sample, and a clock. `history`
158
+ adds a trail per draw: `"all"` grows from the first sample, and an integer
159
+ keeps a sliding window of that many samples, here about ninety days.
160
+
161
+ ```{code-cell} ipython3
162
+ sequence = prepare_sky_track(
163
+ fan,
164
+ t_jd=t_jd,
165
+ Ms_kg=Msun2kg,
166
+ dist_pc=DIST_PC,
167
+ source_ids="planet b",
168
+ weights=weights,
169
+ observations=observations,
170
+ iwa_arcsec=0.06,
171
+ history=30,
172
+ )
173
+ print(f"{len(sequence.times)} samples in {sequence.time_unit!r} after JD {sequence.epoch}")
174
+ ```
175
+
176
+ Times are days since the first sample, and the first sample's Julian day is
177
+ the sequence `epoch`. A head never moves along the chord between two
178
+ samples. Asked for a time between samples, the sequence holds the earlier
179
+ one, so a head is always a propagated position. On a sparsely sampled orbit
180
+ an interpolated head could cut across the inner working angle while the
181
+ orbit itself never enters it; a held head cannot.
182
+
183
+ ```{code-cell} ipython3
184
+ between = 0.5 * (sequence.times[60] + sequence.times[61])
185
+ sample = sequence.at(between)
186
+ held = find_element(sample.view, "head/0").xy[0]
187
+ propagated = find_element(sequence.template, "path/0").xy[60]
188
+ print(f"t = {between:.1f} d holds sample {sample.index}:", np.array_equal(held, propagated))
189
+ ```
190
+
191
+ ## A still and a strip
192
+
193
+ `sequence.frame(i)` is the complete state at sample `i`, and
194
+ `sequence.strip(indices)` lays chosen samples side by side. Each strip slot's
195
+ IDs are prefixed with its position, and each slot's clock is its time label,
196
+ restamped with that slot's time. The heads show where the planet could be at each epoch: the spread of
197
+ the forty heads is the posterior uncertainty in its position at that time.
198
+
199
+ ```{code-cell} ipython3
200
+ picks = [0, 60, 120]
201
+ fig, axes = plt.subplots(1, 3, figsize=(10.0, 3.6), layout="constrained")
202
+ strip = mpl.render(sequence.strip(picks), axes=axes, cast=CAST, profile=PROFILE)
203
+ ```
204
+
205
+ ## A movie
206
+
207
+ `eyepiece.mpl.animate` plays the sequence in physical time: three seconds at
208
+ ten frames per second is thirty output frames, spaced evenly from the first
209
+ sample to the last. Each output frame holds the latest sample acquired at or
210
+ before its time, so the heads move at their projected speed on the sky:
211
+ quickly near periapsis and slowly near apoapsis.
212
+
213
+ ```{code-cell} ipython3
214
+ fig, ax = plt.subplots(figsize=(3.6, 3.4), layout="constrained")
215
+ movie = mpl.animate(
216
+ sequence, run_time=3.0, fps=10, ax=ax, cast=CAST, profile=PROFILE
217
+ )
218
+ html = movie.jshtml(dpi=100)
219
+ plt.close(fig)
220
+ print("output frames:", movie.n_frames)
221
+ HTML(html)
222
+ ```
223
+
224
+ `animate_orbit(..., kind="sky")` wraps preparation and this call in one step,
225
+ for a movie that needs no other output.
226
+
227
+ ## Bare tracks
228
+
229
+ Tracks that were propagated elsewhere, or loaded from a file, enter through
230
+ the same functions: an array of arcsecond offsets shaped `(K, T, 2)`, or an
231
+ `(ra, dec)` pair of `(T,)` or `(K, T)` arrays. A bare array is borrowed as
232
+ given, without a copy. `prepare_sky_view` draws bare tracks with no times at
233
+ all, and `prepare_sky_track` takes the times they were sampled at. A row with
234
+ a nonfinite coordinate is a gap in the track.
235
+
236
+ ```{code-cell} ipython3
237
+ tracks = np.stack(
238
+ [find_element(view, f"path/{k}").xy for k in range(K)]
239
+ ) # (K, T, 2) arcsec
240
+ bare = prepare_sky_view(tracks, source_ids="planet b", iwa_arcsec=0.06)
241
+ print(np.shares_memory(find_element(bare, "path/0").xy, tracks))
242
+ ```
243
+
244
+ ## In a talk
245
+
246
+ The same sequence plays as a native Manim clip through `eyepiece.manim`,
247
+ with a profile sized for slides and the same cast, so the planet keeps its
248
+ color from paper to talk. The code below is shown rather than run, because
249
+ rendering Manim media needs system libraries a documentation builder does
250
+ not have.
251
+
252
+ ```python
253
+ import manim
254
+ import eyepiece.manim as em
255
+ from matplotlib.colors import to_hex
256
+
257
+ TALK = snapshot_profile() # 24 pt text and 1.5 pt strokes
258
+
259
+
260
+ class Orbit(manim.Scene):
261
+ def construct(self):
262
+ self.camera.background_color = manim.ManimColor(to_hex(TALK.background_color))
263
+ clip = em.animate(sequence, cast=CAST, profile=TALK)
264
+ self.add(clip.mobject)
265
+ self.play(clip.playback(run_time=8))
266
+ ```
267
+
268
+ Render it with Manim's caching disabled (`manim --disable_caching`). The
269
+ playback follows the same physical-time schedule as the Matplotlib movie.
270
+ Eyepiece's [Manim guide](https://eyepiece.readthedocs.io/en/latest/manim.html)
271
+ covers the parts, reveals, and Manim Slides.
272
+
273
+ ## Migrating from the earlier sky-track functions
274
+
275
+ Earlier releases drew sky tracks directly through `eyepiece.sky_fan`.
276
+ `plot_sky_track` and `animate_orbit(kind="sky")` now prepare the tracks
277
+ first and render them through eyepiece, which changes the following
278
+ behavior.
279
+
280
+ - Bare tracks are shaped `(K, T, 2)`, (RA, Dec) last, or given as an
281
+ `(ra, dec)` pair of `(T,)` or `(K, T)` arrays. The earlier `(2, T)` and
282
+ `(K, 2, T)` arrays are no longer read: pass a `(2, T)` array as the pair
283
+ `tuple(tracks)`, and a `(K, 2, T)` array as `np.moveaxis(tracks, 1, 2)`.
284
+ - `invert_ra=` is removed. RA always increases to the left.
285
+ - `plot_sky_track` returns an `eyepiece.mpl.MplResult`, not a `PlotResult`.
286
+ Track `k`'s line is `result.parts["path/k"]`, and the IWA disk and the
287
+ observations are `result.parts["iwa"]` and `result.parts["observations"]`.
288
+ - Bare tracks are in arcsec and get the same RA and Dec offset axis labels
289
+ as propagated orbits.
290
+ - A lone track is drawn at opacity 0.80, the top of the shared weight
291
+ mapping, rather than 0.75. Heads carry their track's weight and fade with
292
+ it; the star and the observations carry the top track weight, so they
293
+ stay at full opacity.
294
+ - An `"alpha"` in `fan_kw=` is set last, so it replaces the weight-derived
295
+ opacity instead of scaling it.
296
+ - `plot_sky_track` still accepts `data=(ra, dec, err)` and `iwa=` and
297
+ converts them to `observations=` and `iwa_arcsec=`. `animate_orbit` with
298
+ `kind="sky"` takes only `observations=(xy, xerr, yerr)` and `iwa_arcsec=`,
299
+ so convert there by hand: `observations=(np.column_stack([ra, dec]), err,
300
+ err)`.
301
+ - `animate_orbit(kind="sky")` refuses `style=`, `base_ms=`, and `marks=`,
302
+ which apply to `kind="3d"` only; sky colors come from the cast. It needs
303
+ `t_jd=` for bare tracks too, because the sample times drive its clock and
304
+ its physical-time playback.