orblet 0.1.0__py3-none-any.whl

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.
orblet/__init__.py ADDED
@@ -0,0 +1,207 @@
1
+ """orblet — a standalone orbit fitter: arrays in, orbital elements out.
2
+
3
+ No mission, no data files, no network. orblet is a CONSUMER of data and never
4
+ a producer: you hand it epochs, measurements and uncertainties, and it hands
5
+ back a model, a likelihood, a solution. Where those arrays came from, and
6
+ whether you are allowed to have them, is not orblet's business.
7
+
8
+ Work the open way
9
+ -----------------
10
+ orblet ships ATOMS, not a black box. A Kepler solver, forward models, design
11
+ matrices, the two linear cores, proposed likelihoods, default priors, a
12
+ frequency search, the element conversions, the plots. You assemble the
13
+ optimiser or sampler you actually want and plug your own target function into
14
+ it; the default recipes are written out in the manuals and the example
15
+ notebooks rather than hidden inside a wrapper.
16
+
17
+ That is a deliberate trade. A one-line `fit()` is quicker to call and harder
18
+ to trust: when it disagrees with your expectation you cannot see which step
19
+ disagreed. Here every step is a function you can call, print and plot on its
20
+ own.
21
+
22
+ The shape of the thing
23
+ ----------------------
24
+ model the forward models: elements and epochs -> observables
25
+ design the design matrices, one column per linear parameter
26
+ solve the linear cores — fix the geometry, solve the rest exactly
27
+ search the frequency scan and the periodograms
28
+ likelihood proposed likelihoods (Gaussian with jitter), not mandates
29
+ priors prior classes, defaults, and the spec parser
30
+ prepare epoch and time-scale handling
31
+ sampling MCMC atoms: chains, stacking, R-hat, ESS, dead chains
32
+ elements conversions between parameter conventions
33
+ residuals the 5-parameter residual routine
34
+ parallax observer position -> on-sky parallax factors
35
+ interpret what a fitted orbit implies: masses, limits, flux ratios
36
+ plotting the orbit, the corner, the sky plane
37
+ simulate synthetic data with known truth, for testing and teaching
38
+
39
+ Why the split matters: the geometry of a Keplerian orbit is non-linear, but
40
+ once it is fixed the amplitudes enter LINEARLY and have a closed-form best
41
+ fit. `solve` is that closed form. A sampler over a handful of non-linear
42
+ parameters, with `solve` as its inner step, beats one over all of them.
43
+
44
+ Import cost
45
+ -----------
46
+ Nothing heavy is imported when you `import orblet`. Every name below is
47
+ resolved on first attribute access (PEP 562), so scipy, emcee and matplotlib
48
+ arrive only if you touch something that needs them.
49
+
50
+ **This package is under construction.** It is being assembled in place inside
51
+ the gaia-orbits monorepo, one dependency layer at a time, before being
52
+ extracted to its own repository. The modules arrive by relocation, unchanged.
53
+
54
+ State of the move: `docs/plans/2026-09-23-orblet-b1-chunks.md`
55
+ Plan of record: `docs/plans/2026-09-23-orblet-extraction-plan.md`
56
+ Machine-checked: `tests/_orblet_move_map.py`
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import importlib
62
+
63
+ # public name -> (module, attribute). One mechanism, one table, grouped by
64
+ # what the name is FOR rather than by which file it happens to live in.
65
+ #: The package version. The ONLY place it is written: the build reads it from
66
+ #: here (``[tool.setuptools.dynamic]`` in orblet's pyproject), and the packaging
67
+ #: test pins that the installed metadata agrees. A plain literal on purpose —
68
+ #: setuptools parses it from the source without importing the package.
69
+ __version__ = "0.1.0"
70
+
71
+ _LAZY_EXPORTS: dict[str, tuple[str, str]] = {
72
+ # ── Design matrices: one column per linear parameter ───────────────
73
+ "rv_design_matrix": ("orblet.design.columns", "rv_design_matrix"),
74
+ "ti_design_matrix": ("orblet.design.columns", "ti_design_matrix"),
75
+ "acceleration_columns": ("orblet.design.columns", "acceleration_columns"),
76
+ "astrometric_5param_design_matrix": (
77
+ "orblet.solve.astrometry",
78
+ "astrometric_5param_design_matrix",
79
+ ),
80
+
81
+ # ── Linear cores: geometry fixed, amplitudes solved exactly ────────
82
+ "linear_solve_rv": ("orblet.solve.rv", "linear_solve_rv"),
83
+ "linear_solve_ti": ("orblet.solve.astrometry", "linear_solve_ti"),
84
+ "best_linear_params_rv": ("orblet.solve.rv", "best_linear_params_rv"),
85
+ "best_linear_params_ti": (
86
+ "orblet.solve.astrometry",
87
+ "best_linear_params_ti",
88
+ ),
89
+ "fit_astrometric_5param": (
90
+ "orblet.solve.astrometry",
91
+ "fit_astrometric_5param",
92
+ ),
93
+ "recover_K": ("orblet.solve.rv", "recover_K"),
94
+ "recover_omega": ("orblet.solve.rv", "recover_omega"),
95
+ "semi_amplitude_kms": ("orblet.model", "semi_amplitude_kms"),
96
+
97
+ # ── Searching for the period ───────────────────────────────────────
98
+ "scan_ti_frequency": ("orblet.search", "scan_ti_frequency"),
99
+
100
+ # ── Seeds: turning one solution into a starting point for the next ─
101
+ "compose_rv_seed": ("orblet.solve.rv", "compose_rv_seed"),
102
+ "compose_ti_seed": ("orblet.search", "compose_ti_seed"),
103
+ "ti_amplitude_chains": ("orblet.solve.astrometry", "ti_amplitude_chains"),
104
+ "mirror_inclination": ("orblet.elements", "mirror_inclination"),
105
+
106
+ # ── Epochs and time scales ─────────────────────────────────────────
107
+ "prepare_rv_for_orbit": ("orblet.prepare", "prepare_rv_for_orbit"),
108
+ "resolve_epochs_mjd": ("orblet.prepare", "resolve_epochs_mjd"),
109
+
110
+ # ── Where the observer is, as on-sky parallax factors ──────────────
111
+ "per_direction_parallax_factors": (
112
+ "orblet.parallax",
113
+ "per_direction_parallax_factors",
114
+ ),
115
+ "OBSERVER_GEOCENTRE": ("orblet.parallax", "OBSERVER_GEOCENTRE"),
116
+ "OBSERVER_L2": ("orblet.parallax", "OBSERVER_L2"),
117
+ "L2_OFFSET_AU": ("orblet.parallax", "L2_OFFSET_AU"),
118
+
119
+ # ── Element conversions and derived curves ─────────────────────────
120
+ "extract_orbital_elements": (
121
+ "orblet.elements",
122
+ "extract_orbital_elements",
123
+ ),
124
+ "ti_to_kepler": ("orblet.elements", "ti_to_kepler"),
125
+ "to_nss_convention": ("orblet.elements", "to_nss_convention"),
126
+ "compute_rv_model_curve": ("orblet.elements", "compute_rv_model_curve"),
127
+ "compute_residuals": ("orblet.elements", "compute_residuals"),
128
+
129
+ # ── Carrying one channel's chain into the other's prior ────────────
130
+ "a1_sini_from_rv_chain": ("orblet.rv_chain", "a1_sini_from_rv_chain"),
131
+ "astrometric_priors_from_rv_chain": (
132
+ "orblet.rv_chain",
133
+ "astrometric_priors_from_rv_chain",
134
+ ),
135
+ "predicted_k_kms_from_astrometric_chain": (
136
+ "orblet.rv_chain",
137
+ "predicted_k_kms_from_astrometric_chain",
138
+ ),
139
+
140
+ # ── Plots (matplotlib arrives here, not before) ────────────────────
141
+ "plot_orbit_fit": ("orblet.plotting", "plot_orbit_fit"),
142
+ "plot_orbit_corner": ("orblet.plotting", "plot_orbit_corner"),
143
+ "plot_astrometric_orbit": ("orblet.plotting", "plot_astrometric_orbit"),
144
+ "plot_astrometric_sky_overlay": (
145
+ "orblet.plotting",
146
+ "plot_astrometric_sky_overlay",
147
+ ),
148
+ }
149
+
150
+
151
+ def __getattr__(name: str):
152
+ """Resolve a public name on first access (PEP 562).
153
+
154
+ Keeps ``import orblet`` cheap: nothing below the front door is executed
155
+ until something is actually asked for.
156
+ """
157
+ target = _LAZY_EXPORTS.get(name)
158
+ if target is None:
159
+ raise AttributeError(
160
+ f"module {__name__!r} has no attribute {name!r}"
161
+ )
162
+ module_name, attr = target
163
+ return getattr(importlib.import_module(module_name), attr)
164
+
165
+
166
+ def __dir__() -> list[str]:
167
+ return sorted(__all__)
168
+
169
+
170
+ # Kept as a literal, same names as the table above.
171
+ __all__ = [
172
+ "rv_design_matrix",
173
+ "ti_design_matrix",
174
+ "acceleration_columns",
175
+ "astrometric_5param_design_matrix",
176
+ "linear_solve_rv",
177
+ "linear_solve_ti",
178
+ "best_linear_params_rv",
179
+ "best_linear_params_ti",
180
+ "fit_astrometric_5param",
181
+ "recover_K",
182
+ "recover_omega",
183
+ "semi_amplitude_kms",
184
+ "scan_ti_frequency",
185
+ "compose_rv_seed",
186
+ "compose_ti_seed",
187
+ "ti_amplitude_chains",
188
+ "mirror_inclination",
189
+ "prepare_rv_for_orbit",
190
+ "resolve_epochs_mjd",
191
+ "per_direction_parallax_factors",
192
+ "OBSERVER_GEOCENTRE",
193
+ "OBSERVER_L2",
194
+ "L2_OFFSET_AU",
195
+ "extract_orbital_elements",
196
+ "ti_to_kepler",
197
+ "to_nss_convention",
198
+ "compute_rv_model_curve",
199
+ "compute_residuals",
200
+ "a1_sini_from_rv_chain",
201
+ "astrometric_priors_from_rv_chain",
202
+ "predicted_k_kms_from_astrometric_chain",
203
+ "plot_orbit_fit",
204
+ "plot_orbit_corner",
205
+ "plot_astrometric_orbit",
206
+ "plot_astrometric_sky_overlay",
207
+ ]
orblet/chain_stats.py ADDED
@@ -0,0 +1,194 @@
1
+ """
2
+ Chain-summary helpers for posterior samples.
3
+
4
+ Linear-quantity helpers (:func:`chain_quantiles`,
5
+ :func:`chain_credible_interval`, :func:`chain_summary_table`) are
6
+ straightforward wrappers over :func:`numpy.quantile` with strict
7
+ input validation.
8
+
9
+ Angular-quantity helper (:func:`chain_circular_summary`) computes the
10
+ circular mean and circular standard deviation of an angle chain. Two
11
+ implementation notes worth preserving:
12
+
13
+ - Inputs are wrapped to ``[0, 2π)`` before the circular-std call
14
+ because :func:`scipy.stats.circstd` does NOT internally fold values
15
+ outside ``[low, high]``.
16
+ - For known-multimodal posteriors (e.g., the ``(ω + π, Ω + π)`` 180°
17
+ degeneracy in retrograde-inclination astrometric fits), the circular
18
+ mean collapses to a meaningless midpoint between modes; the docstring
19
+ flags this so the helper is not used naively for such cases.
20
+
21
+ *Dependencies.* Three of the four helpers need only numpy and scipy.
22
+ :func:`chain_summary_table` returns a :class:`pandas.DataFrame`, and pandas is
23
+ an OPTIONAL dependency (``pip install orblet[tables]``) imported inside that
24
+ function rather than at module scope — otherwise one unused table helper would
25
+ make the whole module unimportable for anyone without pandas. The numbers are
26
+ available without it: :func:`chain_quantiles` returns the same values as a
27
+ plain dict, and :func:`chain_summary_table` only arranges them.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import TYPE_CHECKING
33
+
34
+ import numpy as np
35
+ from scipy.stats import circstd
36
+
37
+ if TYPE_CHECKING: # pragma: no cover - for type checkers only
38
+ import pandas as pd
39
+
40
+
41
+ def _quantile_key(level: float) -> str:
42
+ """Encode a quantile level as a 3-digit-millis key (e.g., ``q025``)."""
43
+ return f"q{int(round(level * 1000)):03d}"
44
+
45
+
46
+ def chain_quantiles(
47
+ samples: np.ndarray,
48
+ levels: tuple[float, ...] = (0.025, 0.5, 0.975),
49
+ ) -> dict[str, float]:
50
+ """
51
+ Compute quantiles of a 1-D chain.
52
+
53
+ Parameters
54
+ ----------
55
+ samples : np.ndarray, shape (n,)
56
+ 1-D array of posterior samples. Empty arrays raise
57
+ :class:`ValueError`. NaN samples raise :class:`ValueError`.
58
+ levels : tuple of floats in (0, 1), default ``(0.025, 0.5, 0.975)``
59
+ Quantile levels. Returned dict keys are integer-encoded:
60
+ ``f"q{int(round(lvl*1000)):03d}"`` → ``"q025"``, ``"q500"``,
61
+ ``"q975"``.
62
+
63
+ Returns
64
+ -------
65
+ dict[str, float]
66
+ Mapping of quantile-key to value.
67
+ """
68
+ samples = np.asarray(samples, dtype=np.float64)
69
+ if samples.size == 0:
70
+ raise ValueError("samples is empty")
71
+ if np.any(np.isnan(samples)):
72
+ raise ValueError("samples contains NaN")
73
+ qs = np.quantile(samples, list(levels))
74
+ return {_quantile_key(lvl): float(qs[i]) for i, lvl in enumerate(levels)}
75
+
76
+
77
+ def chain_credible_interval(
78
+ samples: np.ndarray,
79
+ level: float = 0.99,
80
+ ) -> dict[str, float]:
81
+ """
82
+ Compute the central credible interval at the given level.
83
+
84
+ Parameters
85
+ ----------
86
+ samples : np.ndarray, shape (n,)
87
+ 1-D array of posterior samples. Empty arrays raise
88
+ :class:`ValueError`.
89
+ level : float in (0, 1), default 0.99
90
+ Credible-interval fraction (e.g., 0.68, 0.95, 0.99).
91
+ Computes quantiles at ``(1-level)/2`` and ``(1+level)/2``.
92
+
93
+ Returns
94
+ -------
95
+ dict[str, float]
96
+ ``{"low": float, "high": float, "level": float}``.
97
+ """
98
+ samples = np.asarray(samples, dtype=np.float64)
99
+ if samples.size == 0:
100
+ raise ValueError("samples is empty")
101
+ if np.any(np.isnan(samples)):
102
+ raise ValueError("samples contains NaN")
103
+ lo_q = (1.0 - level) / 2.0
104
+ hi_q = (1.0 + level) / 2.0
105
+ lo, hi = np.quantile(samples, [lo_q, hi_q])
106
+ return {"low": float(lo), "high": float(hi), "level": float(level)}
107
+
108
+
109
+ def chain_circular_summary(samples: np.ndarray) -> dict[str, float]:
110
+ """
111
+ Compute circular-mean and circular-std of an angular chain.
112
+
113
+ Parameters
114
+ ----------
115
+ samples : np.ndarray, shape (n,)
116
+ 1-D array of angle samples in radians. Convention may be
117
+ ``[0, 2π)`` or ``[-π, π)``; the function pre-wraps to
118
+ ``[0, 2π)`` before computing.
119
+
120
+ Returns
121
+ -------
122
+ dict[str, float]
123
+ ``{"circmean_rad": float, "circstd_rad": float}``. Boundary
124
+ convention: ``circmean_rad`` is in ``[0, 2π)``.
125
+
126
+ Notes
127
+ -----
128
+ Pre-wrap to ``[0, 2π)`` is required because
129
+ :func:`scipy.stats.circstd` does NOT internally re-wrap; values
130
+ outside ``[low, high]`` are not folded.
131
+
132
+ For posteriors known to be multi-modal (e.g., ω with the
133
+ ``(ω + π, Ω + π)`` 180° degeneracy in retrograde-inclination
134
+ astrometric fits), the circular mean collapses bimodal
135
+ posteriors to a meaningless midpoint. Report per-mode summaries
136
+ instead in those cases.
137
+ """
138
+ samples = np.asarray(samples, dtype=np.float64)
139
+ if samples.size == 0:
140
+ raise ValueError("samples is empty")
141
+ if np.any(np.isnan(samples)):
142
+ raise ValueError("samples contains NaN")
143
+ # Pre-wrap to [0, 2π) — scipy.stats.circstd does not re-wrap.
144
+ samples = samples % (2.0 * np.pi)
145
+ # Circular mean via complex-exponential trick: arg(mean(exp(i·θ)))
146
+ # is the canonical wrap-aware estimator and ignores additive 2π
147
+ # shifts in any individual sample.
148
+ circmean = float(np.angle(np.mean(np.exp(1j * samples))) % (2.0 * np.pi))
149
+ circstd_val = float(circstd(samples, low=0.0, high=2.0 * np.pi))
150
+ return {"circmean_rad": circmean, "circstd_rad": circstd_val}
151
+
152
+
153
+ def chain_summary_table(
154
+ chain_dict: dict[str, np.ndarray],
155
+ levels: tuple[float, ...] = (0.025, 0.5, 0.975),
156
+ ) -> pd.DataFrame:
157
+ """
158
+ Tabular summary across multiple chain keys.
159
+
160
+ Parameters
161
+ ----------
162
+ chain_dict : dict[str, np.ndarray]
163
+ Mapping of parameter name → 1-D sample array.
164
+ levels : tuple of floats, default ``(0.025, 0.5, 0.975)``
165
+ Same as :func:`chain_quantiles`.
166
+
167
+ Returns
168
+ -------
169
+ pd.DataFrame
170
+ Columns: ``parameter``, plus one column per level (integer-
171
+ encoded keys: ``q025``, ``q500``, ``q975``).
172
+ """
173
+ # pandas is imported HERE, not at module scope, so that the three helpers
174
+ # above — which need only numpy and scipy — stay usable when pandas is not
175
+ # installed. A top-of-file import would take all four down over this one
176
+ # call. See the module docstring for the install story.
177
+ try:
178
+ import pandas as pd
179
+ except ImportError as exc: # pragma: no cover - depends on the install
180
+ raise ImportError(
181
+ "chain_summary_table returns a pandas DataFrame, and orblet does "
182
+ "not install pandas by default. Either `pip install orblet[tables]`, "
183
+ "or call chain_quantiles per parameter — it returns the same "
184
+ "numbers as a plain dict and needs only numpy."
185
+ ) from exc
186
+
187
+ rows = []
188
+ for name, samples in chain_dict.items():
189
+ qs = chain_quantiles(samples, levels=levels)
190
+ row = {"parameter": name}
191
+ row.update(qs)
192
+ rows.append(row)
193
+ columns = ["parameter"] + [_quantile_key(lvl) for lvl in levels]
194
+ return pd.DataFrame(rows, columns=columns)