PyAETNA 4.0.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.
Files changed (96) hide show
  1. PyAETNA/__init__.py +9 -0
  2. PyAETNA/__main__.py +5 -0
  3. PyAETNA/_device_config.py +57 -0
  4. PyAETNA/_xla_flags.py +50 -0
  5. PyAETNA/body_models.py +2336 -0
  6. PyAETNA/cli.py +52 -0
  7. PyAETNA/gp.py +791 -0
  8. PyAETNA/gp_parallel.py +172 -0
  9. PyAETNA/kernels/factory.py +823 -0
  10. PyAETNA/kernels/multichannel.py +417 -0
  11. PyAETNA/logger.py +73 -0
  12. PyAETNA/model_utils.py +282 -0
  13. PyAETNA/outputs.py +260 -0
  14. PyAETNA/physics/__init__.py +6 -0
  15. PyAETNA/physics/orbital.py +479 -0
  16. PyAETNA/physics/plotting.py +767 -0
  17. PyAETNA/physics/priors.py +89 -0
  18. PyAETNA/physics/spectra.py +483 -0
  19. PyAETNA/physics/stellar.py +369 -0
  20. PyAETNA/planet_phys.py +1414 -0
  21. PyAETNA/provenance.py +163 -0
  22. PyAETNA/py.typed +0 -0
  23. PyAETNA/reporting/__init__.py +64 -0
  24. PyAETNA/reporting/confidence_bands.py +1238 -0
  25. PyAETNA/reporting/corner_plot.py +1204 -0
  26. PyAETNA/reporting/data_validation.py +116 -0
  27. PyAETNA/reporting/diagnostics.py +580 -0
  28. PyAETNA/reporting/gp_plotting.py +942 -0
  29. PyAETNA/reporting/grid_builder.py +1257 -0
  30. PyAETNA/reporting/model_components.py +894 -0
  31. PyAETNA/reporting/model_math.py +1096 -0
  32. PyAETNA/reporting/multigp_plots.py +1681 -0
  33. PyAETNA/reporting/noise_plots.py +406 -0
  34. PyAETNA/reporting/panel_detrended.py +188 -0
  35. PyAETNA/reporting/panel_nontime_residuals.py +467 -0
  36. PyAETNA/reporting/panel_overlay.py +745 -0
  37. PyAETNA/reporting/panel_residuals.py +109 -0
  38. PyAETNA/reporting/param_sorting.py +155 -0
  39. PyAETNA/reporting/pdf_report.py +2119 -0
  40. PyAETNA/reporting/periodogram_plots.py +1467 -0
  41. PyAETNA/reporting/phase_plots.py +1529 -0
  42. PyAETNA/reporting/plot_data_prep.py +1123 -0
  43. PyAETNA/reporting/plot_models.py +177 -0
  44. PyAETNA/reporting/plot_styling.py +468 -0
  45. PyAETNA/reporting/residual_plotting.py +419 -0
  46. PyAETNA/reporting/trend_detection.py +128 -0
  47. PyAETNA/reporting/ttv_plots.py +1226 -0
  48. PyAETNA/run.py +813 -0
  49. PyAETNA/sampling/__init__.py +19 -0
  50. PyAETNA/sampling/centering.py +331 -0
  51. PyAETNA/sampling/config/parser/__init__.py +602 -0
  52. PyAETNA/sampling/config/parser/_constants.py +14 -0
  53. PyAETNA/sampling/config/parser/detrending.py +825 -0
  54. PyAETNA/sampling/config/parser/gp.py +386 -0
  55. PyAETNA/sampling/config/parser/multigp.py +697 -0
  56. PyAETNA/sampling/config/parser/ordering.py +154 -0
  57. PyAETNA/sampling/config/parser/planets.py +699 -0
  58. PyAETNA/sampling/config/parser/prior_refs.py +157 -0
  59. PyAETNA/sampling/config/parser/scoping.py +465 -0
  60. PyAETNA/sampling/config/parser/star.py +647 -0
  61. PyAETNA/sampling/config/priors.py +266 -0
  62. PyAETNA/sampling/config/section.py +51 -0
  63. PyAETNA/sampling/config/validation.py +231 -0
  64. PyAETNA/sampling/data.py +243 -0
  65. PyAETNA/sampling/eccentricity.py +65 -0
  66. PyAETNA/sampling/energy.py +431 -0
  67. PyAETNA/sampling/engine/checkpoint.py +461 -0
  68. PyAETNA/sampling/engine/context.py +24 -0
  69. PyAETNA/sampling/engine/convergence.py +126 -0
  70. PyAETNA/sampling/engine/data_loader.py +335 -0
  71. PyAETNA/sampling/engine/eiv.py +458 -0
  72. PyAETNA/sampling/engine/ensemble_kernels.py +238 -0
  73. PyAETNA/sampling/engine/exceptions.py +47 -0
  74. PyAETNA/sampling/engine/execution.py +500 -0
  75. PyAETNA/sampling/engine/numpyro_model.py +1031 -0
  76. PyAETNA/sampling/engine/runner.py +150 -0
  77. PyAETNA/sampling/engine/sampler_settings.py +192 -0
  78. PyAETNA/sampling/engine/sampler_utils.py +404 -0
  79. PyAETNA/sampling/engine/sampling_logging.py +292 -0
  80. PyAETNA/sampling/engine/sampling_utils.py +707 -0
  81. PyAETNA/sampling/model/core.py +774 -0
  82. PyAETNA/sampling/model/detrending.py +315 -0
  83. PyAETNA/sampling/nested_sampling.py +1457 -0
  84. PyAETNA/sampling/peaks.py +1225 -0
  85. PyAETNA/sampling/precision.py +393 -0
  86. PyAETNA/sampling/protocols.py +140 -0
  87. PyAETNA/sampling/utils.py +299 -0
  88. PyAETNA/utils/__init__.py +4 -0
  89. PyAETNA/utils/constants.py +26 -0
  90. PyAETNA/version.py +21 -0
  91. pyaetna-4.0.0.dist-info/METADATA +269 -0
  92. pyaetna-4.0.0.dist-info/RECORD +96 -0
  93. pyaetna-4.0.0.dist-info/WHEEL +5 -0
  94. pyaetna-4.0.0.dist-info/entry_points.txt +2 -0
  95. pyaetna-4.0.0.dist-info/licenses/LICENSE +21 -0
  96. pyaetna-4.0.0.dist-info/top_level.txt +1 -0
PyAETNA/body_models.py ADDED
@@ -0,0 +1,2336 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import partial
4
+ from typing import Any
5
+
6
+ import jax
7
+ import jax.numpy as jnp
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ from astropy.constants import R_sun, c
11
+ from jax import jit
12
+ from jaxoplanet.light_curves import limb_dark_light_curve
13
+ from jaxoplanet.light_curves.emission import light_curve as emission_lc
14
+ from jaxoplanet.orbits.keplerian import Body, Central, System
15
+ from jaxoplanet.orbits.ttv import TTVOrbit
16
+ from jaxoplanet.starry.core.basis import A1 as _jxp_A1
17
+ from jaxoplanet.starry.core.basis import A2_inv as _jxp_A2_inv
18
+ from jaxoplanet.starry.core.basis import U as _jxp_U
19
+ from jaxoplanet.starry.core.polynomials import Pijk as _jxp_Pijk
20
+ from jaxoplanet.starry.core.rotation import dot_rz as _jxp_dot_rz
21
+ from jaxoplanet.starry.core.solution import rT as _jxp_rT
22
+ from jaxoplanet.starry.core.solution import solution_vector as _jxp_solution_vector
23
+ from jaxoplanet.starry.doppler import rv_map_expansion as _jxp_rv_map_expansion
24
+ from jaxoplanet.starry.orbit import SurfaceSystem
25
+ from jaxoplanet.starry.surface import Surface
26
+
27
+ # Import physical constants from centralized location
28
+ from .utils.constants import G_CGS, RHO_SUN_CGS, SECONDS_PER_DAY
29
+
30
+ # Speed of light in R_sun per day.
31
+ # Use astropy constants for the speed of light and solar radius so that
32
+ # the value is not hard-coded. c.cgs.value is in cm/s and
33
+ # R_sun.cgs.value is in cm.
34
+ # Used for light-travel-time correction in phase curves.
35
+ _C_RSUN_PER_DAY = (c.cgs.value / R_sun.cgs.value) * SECONDS_PER_DAY # ≈ 37228 R_sun/day
36
+
37
+ # Conversion factor: 1 R_sun / day → m/s
38
+ # R_sun.cgs.value [cm] / SECONDS_PER_DAY [s] / 100 [cm→m]
39
+ _RSUN_DAY_TO_MS = float(R_sun.cgs.value / SECONDS_PER_DAY / 100.0) # ≈ 8052.08 m/s
40
+
41
+ # ──────────────────────────────────────────────────────────────────────────────
42
+ # Pre-computed constants for the Rossiter-McLaughlin (RM) model
43
+ # ──────────────────────────────────────────────────────────────────────────────
44
+ # These constants are evaluated ONCE at module import time from scipy/jaxoplanet
45
+ # basis matrices and stored as float32 JAX arrays. They encode the polynomial
46
+ # change-of-basis and occultation geometry for our fixed configuration:
47
+ # ydeg = 0 (no explicit stellar surface map — uniform disk)
48
+ # udeg = 2 (quadratic limb darkening, 2 free coefficients)
49
+ # vdeg = 1 (solid-body rotation velocity map, no differential rotation)
50
+ # deg = max(ydeg, udeg) = 2 → n_deg = (deg+1)^2 = 9 coefficients
51
+ # total_deg = deg + vdeg = 3 → n_total = (total_deg+1)^2 = 16 coefficients
52
+ #
53
+ # Background:
54
+ # jaxoplanet.starry uses the Pijk polynomial basis from Luger et al. (2019).
55
+ # The change-of-basis matrix A2 converts from the occultation (sT) basis to the
56
+ # Pijk polynomial basis. A1(deg) converts from the spherical-harmonic (Ylm) basis.
57
+ # These matrices depend only on integer degrees, so they are Python-level constants.
58
+ #
59
+ # WHY we precompute here (not inside the function):
60
+ # surface_radial_velocity calls scipy.sparse.linalg.inv(A2_inv(deg)) at TRACE
61
+ # TIME every time JAX compiles the model. Pre-computing avoids repeated expensive
62
+ # scipy calls and produces a compile-time constant that XLA can fold into the
63
+ # computation, yielding faster and more predictable JIT compilation.
64
+ import scipy.sparse as _scipy_sparse # imported here to keep the namespace clean
65
+
66
+ _RM_YDEG = 0 # stellar surface map degree (uniform)
67
+ _RM_UDEG = 2 # limb-darkening degree (quadratic)
68
+ _RM_VDEG = 1 # velocity map degree (solid-body rotation)
69
+ _RM_DEG = max(_RM_YDEG, _RM_UDEG) # = 2
70
+ _RM_TOTAL_DEG = _RM_DEG + _RM_VDEG # = 3
71
+ _RM_N_DEG = (_RM_DEG + 1) ** 2 # = 9 (surface + LD polynomial size)
72
+ _RM_N_TOTAL = (_RM_TOTAL_DEG + 1) ** 2 # = 16 (surface + LD + velocity size)
73
+
74
+ # A2 change-of-basis matrix: converts occultation solution vector sT to polynomial
75
+ # coefficients. Shape: (_RM_N_TOTAL, _RM_N_TOTAL) = (16, 16).
76
+ _RM_A2 = np.array(
77
+ _scipy_sparse.linalg.inv(_jxp_A2_inv(_RM_TOTAL_DEG)).todense(),
78
+ dtype=np.float32,
79
+ )
80
+
81
+ # A1 basis matrix for the velocity map (vdeg=1).
82
+ # Converts velocity-map spherical harmonics to polynomial coefficients.
83
+ # Shape: (_RM_VDEG_N, _RM_VDEG_N) = (4, 4).
84
+ _RM_A1_VDEG = np.array(_jxp_A1(_RM_VDEG).todense(), dtype=np.float32)
85
+
86
+ # A1(0) scalar: the degree-0 surface-map basis factor (= 1/π).
87
+ # Used to scale the ydeg=0 surface amplitude into polynomial units.
88
+ _RM_A1_YDEG_SCALAR = float(np.array(_jxp_A1(_RM_YDEG).todense()).flatten()[0]) # ≈ 1/π
89
+
90
+ # Limb-darkening basis matrix U(2): maps [1, u1, u2] → 9 polynomial coefficients.
91
+ # Shape: (3, 9).
92
+ _RM_U = np.array(_jxp_U(_RM_UDEG), dtype=np.float32)
93
+
94
+ # Rotation vectors rT(n): used for normalisation (total flux integrals).
95
+ # rT(2) shape (9,) — for limb-darkening normalisation (udeg=2).
96
+ # rT(3) shape (16,) — for out-of-transit design matrix (total_deg=3).
97
+ _RM_rT_UDEG = np.array(_jxp_rT(_RM_UDEG), dtype=np.float32) # (9,)
98
+ _RM_rT_TOTAL = np.array(_jxp_rT(_RM_TOTAL_DEG), dtype=np.float32) # (16,)
99
+
100
+
101
+ @jit
102
+ def reparametrize_LD(
103
+ q1: float | jax.Array, q2: float | jax.Array
104
+ ) -> tuple[jax.Array, jax.Array]:
105
+ """Convert (q1, q2) to (u1, u2) limb darkening coefficients. JIT-compiled."""
106
+ u1 = 2.0 * jnp.sqrt(q1) * q2
107
+ u2 = jnp.sqrt(q1) * (1 - 2 * q2)
108
+ return (u1, u2)
109
+
110
+
111
+ @jit
112
+ def reparametrize_LD_inverse(
113
+ u1: float | jax.Array, u2: float | jax.Array
114
+ ) -> tuple[float | jax.Array, float | jax.Array]:
115
+ """Convert (u1, u2) to (q1, q2) limb darkening parameters. JIT-compiled."""
116
+ q1 = jnp.square(u1 + u2)
117
+ q2 = 0.5 * u1 / (u1 + u2)
118
+ return (q1, q2)
119
+
120
+
121
+ @jit
122
+ def reparametrize_ecc_omega(
123
+ ec: float | jax.Array, es: float | jax.Array
124
+ ) -> tuple[float | jax.Array, float | jax.Array]:
125
+ """
126
+ Convert Cartesian eccentricity to (e, ω).
127
+
128
+ ec = e * cos(ω)
129
+ es = e * sin(ω)
130
+
131
+ ec and es are each sampled from a uniform prior on [-1, 1] (the widest
132
+ range consistent with e <= 1), so points outside the unit disk
133
+ (ec**2 + es**2 >= 1) do not correspond to any physical (e, ω) and must be
134
+ excluded upstream by an explicit validity gate -- see
135
+ ``ecc_physical_<planet>`` in
136
+ ``PyAETNA.sampling.engine.numpyro_model``, which rejects exactly those
137
+ points via ``numpyro.factor``.
138
+
139
+ Returns: (ecc, omega) where ecc ∈ [0, 1], omega ∈ [-π, π]
140
+
141
+ JIT-compiled for performance.
142
+ """
143
+ ecc = jnp.sqrt(ec**2 + es**2)
144
+ omega = jnp.arctan2(es, ec)
145
+ return (ecc, omega)
146
+
147
+
148
+ @jit
149
+ def compute_semimajor_axis_scaled(
150
+ rho_star: float | jax.Array, period: float | jax.Array
151
+ ) -> jax.Array:
152
+ """
153
+ Compute scaled semi-major axis (a/R_star) from stellar density and orbital period.
154
+
155
+ Uses Kepler's third law with dimensionless stellar parameters (M_star = R_star = 1.0):
156
+ a/R_star = [(G * ρ_star * P²) / (3π)]^(1/3)
157
+
158
+ Parameters:
159
+ -----------
160
+ rho_star : float
161
+ Stellar density [g/cm³]
162
+ period : float or array
163
+ Orbital period [days]
164
+
165
+ Returns:
166
+ --------
167
+ a_over_rstar : float or array
168
+ Scaled semi-major axis [dimensionless]
169
+ """
170
+ # Convert period to seconds
171
+ P_sec = period * SECONDS_PER_DAY
172
+
173
+ # Compute a/R_star using Kepler's third law
174
+ a_over_rstar = jnp.power((G_CGS * rho_star * P_sec**2) / (3.0 * jnp.pi), 1.0 / 3.0)
175
+
176
+ return a_over_rstar
177
+
178
+
179
+ @jit
180
+ def compute_inclination(
181
+ b_param: float | jax.Array,
182
+ a_over_rstar: float | jax.Array,
183
+ ecc: float | jax.Array = 0.0,
184
+ omega: float | jax.Array = 0.0,
185
+ ) -> jax.Array:
186
+ """
187
+ Compute orbital inclination from transit impact parameter.
188
+
189
+ Inverts the impact-parameter definition
190
+ (Winn 2010, Eq. 7, https://arxiv.org/pdf/1001.2010.pdf):
191
+
192
+ b ≡ (a/R*) · cos(i) · (1 − e²) / (1 + e · sin ω)
193
+
194
+ to obtain
195
+
196
+ i = arccos[ b / ((a/R*) · ρ_c) ]
197
+
198
+ where ρ_c = (1 − e²) / (1 + e · sin ω).
199
+
200
+ Parameters
201
+ ----------
202
+ b_param : float or array
203
+ Transit impact parameter [dimensionless].
204
+ a_over_rstar : float or array
205
+ Scaled semi-major axis a/R* [dimensionless].
206
+ ecc : float or array, optional
207
+ Orbital eccentricity [0, 1).
208
+ omega : float or array, optional
209
+ Argument of periastron [radians].
210
+
211
+ Returns
212
+ -------
213
+ incl : float or array
214
+ Orbital inclination [radians].
215
+ """
216
+
217
+ # NOTE: We use jnp.where to branch between circular (e=0) and
218
+ # eccentric orbits. This handles both scalar and array inputs.
219
+ # (Previously used jax.lax.cond which failed for array inputs).
220
+ incl_circ = jnp.arccos(jnp.clip(b_param / a_over_rstar, 0.0, 1.0 - 1e-10))
221
+ rho_c = (1.0 - ecc**2) / (1.0 + ecc * jnp.sin(omega))
222
+ incl_ecc = jnp.arccos(jnp.clip(b_param / (a_over_rstar * rho_c), 0.0, 1.0 - 1e-10))
223
+
224
+ return jnp.where(ecc == 0, incl_circ, incl_ecc)
225
+
226
+
227
+ @jit
228
+ def compute_transit_duration(
229
+ period: float | jax.Array,
230
+ rho_star: float | jax.Array,
231
+ b_param: float | jax.Array,
232
+ rp_by_rs: float | jax.Array,
233
+ ecc: float | jax.Array = 0.0,
234
+ omega: float | jax.Array = 0.0,
235
+ T23: bool = False,
236
+ ) -> jax.Array:
237
+ """
238
+ Compute transit duration (T14 or T23) from orbital parameters and stellar density.
239
+
240
+ Implements Eqs. 14–16 of Winn (2010, https://arxiv.org/pdf/1001.2010.pdf):
241
+
242
+ T = (P/π) · arcsin[ √(k_eff² − b²) / ((a/R*) · sin i) ]
243
+ · √(1 − e²) / (1 + e · sin ω)
244
+
245
+ where k_eff = (1 + k) for the total (first-to-fourth contact, T14)
246
+ duration, and k_eff = (1 − k) for the full (second-to-third contact,
247
+ T23) duration, with k = Rp/R*.
248
+
249
+ Parameters
250
+ ----------
251
+ period : float or array
252
+ Orbital period [days].
253
+ rho_star : float
254
+ Stellar density [g/cm³].
255
+ b_param : float or array
256
+ Transit impact parameter [dimensionless].
257
+ rp_by_rs : float or array
258
+ Planet-to-star radius ratio k = Rp/R* [dimensionless].
259
+ ecc : float or array, optional
260
+ Orbital eccentricity [0, 1).
261
+ omega : float or array, optional
262
+ Argument of periastron [radians].
263
+ T23 : bool, optional
264
+ If True, compute T23 (full duration, second-to-third contact)
265
+ using (1 − k) instead of (1 + k). Default is False (T14).
266
+
267
+ Returns
268
+ -------
269
+ duration : float or array
270
+ Transit duration [days]. T14 by default, T23 if ``T23=True``.
271
+ """
272
+ a_over_rstar = compute_semimajor_axis_scaled(rho_star, period)
273
+ incl = compute_inclination(b_param, a_over_rstar, ecc, omega)
274
+ k = rp_by_rs
275
+
276
+ # T14 → (1+k), T23 → (1−k)
277
+ k_eff = jnp.where(T23, 1.0 - k, 1.0 + k)
278
+
279
+ transit_occurs = b_param < k_eff
280
+ b_safe = jnp.where(transit_occurs, b_param, 0.0)
281
+
282
+ numerator = jnp.sqrt(jnp.maximum(k_eff**2 - b_safe**2, 0.0))
283
+ denominator = a_over_rstar * jnp.sin(incl)
284
+
285
+ duration = (period / jnp.pi) * jnp.arcsin(numerator / denominator)
286
+
287
+ # Eccentricity correction (Eq. 16 in Winn 2010)
288
+ ecc_factor = jnp.sqrt(1.0 - ecc**2) / (1.0 + ecc * jnp.sin(omega))
289
+ duration_ecc = duration * ecc_factor
290
+
291
+ duration = jnp.where(ecc == 0, duration, duration_ecc)
292
+
293
+ return jnp.where(transit_occurs, duration, 0.0)
294
+
295
+
296
+ @jit
297
+ def compute_eclipse_duration(
298
+ period: float | jax.Array,
299
+ rho_star: float | jax.Array,
300
+ b_param: float | jax.Array,
301
+ rp_by_rs: float | jax.Array,
302
+ ecc: float | jax.Array = 0.0,
303
+ omega: float | jax.Array = 0.0,
304
+ T23: bool = False,
305
+ ) -> jax.Array:
306
+ """
307
+ Compute secondary eclipse duration (total T14 or full T23).
308
+
309
+ Same geometry as :func:`compute_transit_duration` but the eccentricity
310
+ correction uses the eclipse-phase factor:
311
+
312
+ √(1 − e²) / (1 − e · sin ω)
313
+
314
+ (note the sign flip compared to the transit formula which has 1 + e · sin ω).
315
+
316
+ Parameters
317
+ ----------
318
+ period : float or array
319
+ Orbital period [days].
320
+ rho_star : float
321
+ Stellar density [g/cm³].
322
+ b_param : float or array
323
+ Impact parameter [dimensionless] — approximate; eclipse impact
324
+ parameter differs slightly for eccentric orbits, but this is
325
+ standard practice for moderate eccentricities.
326
+ rp_by_rs : float or array
327
+ Planet-to-star radius ratio [dimensionless].
328
+ ecc : float or array, optional
329
+ Orbital eccentricity [0, 1).
330
+ omega : float or array, optional
331
+ Argument of periastron [radians].
332
+ T23 : bool, optional
333
+ If True, compute T23 (full duration, second-to-third contact)
334
+ using (1 − k) instead of (1 + k). Default is False (T14).
335
+
336
+ Returns
337
+ -------
338
+ duration : float or array
339
+ Secondary eclipse duration [days].
340
+ """
341
+ a_over_rstar = compute_semimajor_axis_scaled(rho_star, period)
342
+ incl = compute_inclination(b_param, a_over_rstar, ecc, omega)
343
+ k = rp_by_rs
344
+
345
+ # T14 → (1+k), T23 → (1−k)
346
+ k_eff = jnp.where(T23, 1.0 - k, 1.0 + k)
347
+
348
+ eclipse_occurs = b_param < k_eff
349
+ b_safe = jnp.where(eclipse_occurs, b_param, 0.0)
350
+
351
+ numerator = jnp.sqrt(jnp.maximum(k_eff**2 - b_safe**2, 0.0))
352
+ denominator = a_over_rstar * jnp.sin(incl)
353
+
354
+ duration = (period / jnp.pi) * jnp.arcsin(numerator / denominator)
355
+
356
+ # Eccentricity correction at eclipse phase: sign flip on e·sin(ω)
357
+ ecc_factor = jnp.sqrt(1.0 - ecc**2) / (1.0 - ecc * jnp.sin(omega))
358
+ duration_ecc = duration * ecc_factor
359
+
360
+ duration = jnp.where(ecc == 0, duration, duration_ecc)
361
+
362
+ return jnp.where(eclipse_occurs, duration, 0.0)
363
+
364
+
365
+ @jit
366
+ def compute_eclipse_phase(
367
+ ecc: float | jax.Array, omega: float | jax.Array
368
+ ) -> jax.Array:
369
+ """
370
+ Compute orbital phase of secondary eclipse for eccentric orbits.
371
+
372
+ For circular orbits, the eclipse occurs at phase 0.5 (half period after transit).
373
+ For eccentric orbits, the eclipse phase shifts due to Kepler's equation.
374
+
375
+ The computation uses:
376
+ - Transit occurs at true anomaly f_transit = π/2 - ω
377
+ - Eclipse occurs at true anomaly f_eclipse = 3π/2 - ω
378
+ - Convert both to mean anomaly via Kepler's equation
379
+ - Phase difference = (M_eclipse - M_transit) / (2π)
380
+
381
+ Parameters:
382
+ -----------
383
+ ecc : float or array
384
+ Orbital eccentricity [0, 1)
385
+ omega : float or array
386
+ Argument of periastron [radians]
387
+
388
+ Returns:
389
+ --------
390
+ eclipse_phase : float or array
391
+ Orbital phase at eclipse [0, 1], where phase 0 = transit
392
+ For e=0: returns 0.5
393
+ For e>0: returns value shifted from 0.5
394
+ """
395
+
396
+ # NOTE: We use jnp.where to branch between circular (e=0) and
397
+ # eccentric orbits. This handles both scalar and array inputs.
398
+ # (Previously used jax.lax.cond which failed for array inputs).
399
+ phase_circ = jnp.array(0.5)
400
+
401
+ # True anomaly at transit and eclipse
402
+ f_transit = jnp.pi / 2.0 - omega
403
+ f_eclipse = 3.0 * jnp.pi / 2.0 - omega
404
+
405
+ # Convert true anomaly to eccentric anomaly using:
406
+ # tan(E/2) = sqrt((1-e)/(1+e)) * tan(f/2)
407
+ # Rearranged: E = 2 * arctan(sqrt((1-e)/(1+e)) * tan(f/2))
408
+ sqrt_ecc_factor = jnp.sqrt((1.0 - ecc) / (1.0 + ecc))
409
+
410
+ E_transit = 2.0 * jnp.arctan(sqrt_ecc_factor * jnp.tan(f_transit / 2.0))
411
+ E_eclipse = 2.0 * jnp.arctan(sqrt_ecc_factor * jnp.tan(f_eclipse / 2.0))
412
+
413
+ # Convert eccentric anomaly to mean anomaly: M = E - e*sin(E)
414
+ M_transit = E_transit - ecc * jnp.sin(E_transit)
415
+ M_eclipse = E_eclipse - ecc * jnp.sin(E_eclipse)
416
+
417
+ # Phase difference (handle wrapping: eclipse is ~π ahead of transit)
418
+ # For small e, this is close to π, so phase ≈ 0.5
419
+ phase_ecc = ((M_eclipse - M_transit) / (2.0 * jnp.pi)) % 1.0
420
+
421
+ return jnp.where(ecc == 0, phase_circ, phase_ecc)
422
+
423
+
424
+ @jit
425
+ def compute_phase_angle_fast(x: jax.Array, y: jax.Array, z: jax.Array) -> jax.Array:
426
+ """
427
+ Calculate phase angle for jaxoplanet coordinate system.
428
+
429
+ In jaxoplanet's coordinate system:
430
+ - +Z points towards the observer
431
+ - X is the north axis
432
+ - Y is the east axis
433
+
434
+ Phase angle: angle at the planet between star and observer directions.
435
+
436
+ Phase angle convention:
437
+ - α = 0: Eclipse (planet behind star, z > 0, fully illuminated)
438
+ - α = π/2: Quadrature (planet at side)
439
+ - α = π: Transit (planet in front of star, z < 0, dark side toward observer)
440
+
441
+ NOTE: This returns UNSIGNED phase angle [0, π]. For phase curves with
442
+ hotspot offsets, use compute_orbital_phase_signed() instead.
443
+
444
+ Parameters:
445
+ -----------
446
+ x, y, z : array, shape [n_planets, n_timestamps]
447
+ Planet coordinates relative to the star from system.relative_position()
448
+
449
+ Returns:
450
+ --------
451
+ phase_angle : array, shape [n_planets, n_timestamps]
452
+ Phase angle in radians [0, π]
453
+ """
454
+ # Vector from planet to star: -r_planet = (-x, -y, -z)
455
+ # Vector from planet to observer: observer is at +Z direction = (0, 0, 1)
456
+
457
+ # Dot product between (-x, -y, -z) and (0, 0, 1):
458
+ # dot = (-x)*(0) + (-y)*(0) + (-z)*(1) = -z
459
+
460
+ # Magnitude of planet position vector
461
+ r_norm = jnp.sqrt(x * x + y * y + z * z)
462
+
463
+ # cos(phase_angle) = -z / r_norm
464
+ # When z > 0 (planet away from observer): cos > 0, phase < π/2 (eclipse, α→0)
465
+ # When z < 0 (planet toward observer): cos < 0, phase > π/2 (transit, α→π)
466
+
467
+ cos_phase = jnp.clip(-z / r_norm, -1.0, 1.0)
468
+
469
+ return jnp.arccos(cos_phase)
470
+
471
+
472
+ @jit
473
+ def compute_orbital_phase_signed(x: jax.Array, y: jax.Array, z: jax.Array) -> jax.Array:
474
+ """
475
+ Calculate SIGNED orbital phase for phase curve modeling with hotspot offsets.
476
+
477
+ In jaxoplanet's coordinate system (same as exoplanet package):
478
+ - +Z points towards the observer
479
+ - z > 0: Planet is in FRONT of star (primary transit)
480
+ - z < 0: Planet is BEHIND star (secondary eclipse)
481
+ - X is in the orbital plane (north/south direction of orbital motion)
482
+ - Y is perpendicular to the orbital plane (impact parameter direction)
483
+
484
+ The orbit is primarily in the X-Z plane (Y is nearly constant = impact parameter).
485
+
486
+ IMPORTANT: For phase curve modeling:
487
+ - At primary transit (z > 0): We see the planet's NIGHTSIDE (facing away from star)
488
+ - At secondary eclipse (z < 0): We see the planet's DAYSIDE (facing the star)
489
+
490
+ Returns a SIGNED phase that distinguishes pre-eclipse from post-eclipse:
491
+ - φ = 0: Secondary eclipse (planet behind star, z < 0 min, x = 0) - DAYSIDE visible
492
+ - φ = +π/2: First quadrature (x > 0, z = 0)
493
+ - φ = ±π: Primary transit (planet in front of star, z > 0 max, x = 0) - NIGHTSIDE visible
494
+ - φ = -π/2: Second quadrature (x < 0, z = 0)
495
+
496
+ This is essential for modeling asymmetric phase curves due to hotspot offsets.
497
+
498
+ Parameters:
499
+ -----------
500
+ x, y, z : array, shape [n_planets, n_timestamps]
501
+ Planet coordinates relative to the star from system.relative_position()
502
+
503
+ Returns:
504
+ --------
505
+ orbital_phase : array, shape [n_planets, n_timestamps]
506
+ Signed orbital phase in radians [-π, π], 0 at secondary eclipse
507
+ """
508
+ # Use atan2 to get signed angle. The orbit is in the x-z plane.
509
+ # With jaxoplanet convention (z > 0 = transit, z < 0 = eclipse):
510
+ #
511
+ # We want phase = 0 at secondary eclipse (z minimum < 0).
512
+ # Use atan2(-x, -z) so that:
513
+ # - At secondary eclipse: z < 0 (min), x = 0 → atan2(0, positive) = 0
514
+ # - Approaching eclipse (x < 0): phase > 0 (positive)
515
+ # - Receding from eclipse (x > 0): phase < 0 (negative)
516
+ #
517
+ # This convention means:
518
+ # - phase_offset > 0: hotspot shifted AHEAD in orbital motion (eastward, evening)
519
+ # → We see it BEFORE reaching secondary eclipse
520
+ # → But it's brightest AFTER eclipse (when moving away, phase < 0)
521
+ # - phase_offset < 0: hotspot shifted BEHIND in orbital motion (westward, morning)
522
+ # → We see it AFTER passing secondary eclipse
523
+ # → But it's brightest BEFORE eclipse (when approaching, phase > 0)
524
+
525
+ return -jnp.arctan2(x, -z)
526
+
527
+
528
+ @jit
529
+ def getLambertPhaseCurve(alpha: jax.Array) -> jax.Array:
530
+ """
531
+ Exact phase function for a reflective Lambertian sphere.
532
+
533
+ Formula: (sin(α) + (π - α)cos(α)) / π
534
+
535
+ Valid for α ∈ [0, π]. JIT-compiled.
536
+
537
+ Parameters:
538
+ -----------
539
+ alpha : array
540
+ Phase angle in radians [0, π]
541
+
542
+ Returns:
543
+ --------
544
+ phase_modulation : array
545
+ Modulation factor [0, 1], where 1 = fully illuminated, 0 = fully dark
546
+ """
547
+ return (jnp.sin(alpha) + (jnp.pi - alpha) * jnp.cos(alpha)) / jnp.pi
548
+
549
+
550
+ @jit
551
+ def getIsotropicPhaseCurve(alpha: jax.Array) -> jax.Array:
552
+ """
553
+ Phase function for isotropic scattering.
554
+
555
+ Formula: (1 + cos(α)) / 2
556
+
557
+ Simpler than Lambert, peaks at α=0 (full illumination). JIT-compiled.
558
+
559
+ Parameters:
560
+ -----------
561
+ alpha : array
562
+ Phase angle in radians [0, π]
563
+
564
+ Returns:
565
+ --------
566
+ phase_modulation : array
567
+ Modulation factor [0, 1]
568
+ """
569
+ return 0.5 * (1 + jnp.cos(alpha))
570
+
571
+
572
+ def _compute_ltt_positions(
573
+ system: Any, times: jax.Array, z_obs: jax.Array
574
+ ) -> tuple[jax.Array, jax.Array, jax.Array]:
575
+ """Re-evaluate planet positions at light-travel-time corrected epochs.
576
+
577
+ Photons observed at *t_obs* were emitted at t_em = t_obs + z/c where
578
+ z is the planet's line-of-sight displacement from the star (+z towards
579
+ observer, in R_sun) and c is in R_sun/day.
580
+
581
+ A single iteration is sufficient: the residual
582
+ |delta_t_2 / delta_t_1| ~ v_z * a / c**2 < 1e-4
583
+ for all known exoplanet systems.
584
+
585
+ Parameters
586
+ ----------
587
+ system : jaxoplanet.orbits.keplerian.System
588
+ Built orbital system.
589
+ times : array (n_times,)
590
+ Observed timestamps [days].
591
+ z_obs : array (n_planets, n_times)
592
+ Planet z-coordinates at *observed* times [R_sun].
593
+
594
+ Returns
595
+ -------
596
+ x, y, z : arrays (n_planets, n_times)
597
+ Positions evaluated at emission times [R_sun].
598
+ """
599
+ # Light-travel-time offset [days] (+z closer → emission later)
600
+ ltt = z_obs / _C_RSUN_PER_DAY
601
+
602
+ # Per-planet corrected positions (loop unrolled at JIT trace time)
603
+ corrected = [
604
+ body.relative_position(times + ltt[i]) for i, body in enumerate(system.bodies)
605
+ ]
606
+ return (
607
+ jnp.stack([pos[0] for pos in corrected]),
608
+ jnp.stack([pos[1] for pos in corrected]),
609
+ jnp.stack([pos[2] for pos in corrected]),
610
+ )
611
+
612
+
613
+ @jit
614
+ def compute_radial_velocity(
615
+ times: jax.Array,
616
+ t0s: jax.Array,
617
+ periods: jax.Array,
618
+ ecosomega: jax.Array,
619
+ esinomega: jax.Array,
620
+ K_rv: jax.Array,
621
+ ) -> tuple[jax.Array, jax.Array]:
622
+ """
623
+ Compute Keplerian radial velocity model for multiple planets.
624
+
625
+ Builds a jaxoplanet orbital system and returns the stellar reflex RV
626
+ time series. The returned values represent the *stellar* radial velocity
627
+ (reflex motion) — positive values mean the star moves **away** from the
628
+ observer.
629
+
630
+ When the observed (inclination-projected) semi-amplitude K is provided,
631
+ jaxoplanet does NOT need stellar mass/density, radius or inclination —
632
+ only the orbital ephemeris (period, τ) and eccentricity.
633
+
634
+ Parameters
635
+ ----------
636
+ times : array, shape (n_times,)
637
+ Observation times [days], centred on ``timeCenter``
638
+ t0s : array, shape (n_planets,)
639
+ Mid-transit reference epochs [days]
640
+ periods : array, shape (n_planets,)
641
+ Orbital periods [days]
642
+ ecosomega, esinomega : arrays, shape (n_planets,)
643
+ Cartesian eccentricity parameterisation
644
+ K_rv : array, shape (n_planets,)
645
+ Observed (projected) radial-velocity semi-amplitudes [m/s].
646
+ Since this is the *observed* (inclination-projected) K, neither
647
+ stellar mass nor orbital inclination are required.
648
+
649
+ Returns
650
+ -------
651
+ rv_total : array, shape (n_times,)
652
+ Total stellar radial velocity [m/s] (sum over all planets, **not**
653
+ including systemic velocity γ).
654
+ rv_per_planet : array, shape (n_planets, n_times)
655
+ Per-planet radial-velocity contribution [m/s].
656
+ """
657
+ eccentricities, omegas = reparametrize_ecc_omega(ecosomega, esinomega)
658
+ n_planets = len(t0s)
659
+
660
+ # Empty Central — mass/radius not needed when K is provided directly
661
+ star = Central()
662
+
663
+ system = System(star)
664
+ for i in range(n_planets):
665
+ system = system.add_body(
666
+ period=periods[i],
667
+ time_transit=t0s[i],
668
+ eccentricity=eccentricities[i],
669
+ omega_peri=omegas[i],
670
+ radial_velocity_semiamplitude=K_rv[i],
671
+ )
672
+
673
+ # jaxoplanet returns shape (n_planets, n_times)
674
+ rv_per_planet = system.radial_velocity(times)
675
+
676
+ # Sum over planets
677
+ rv_total = jnp.sum(rv_per_planet, axis=0)
678
+
679
+ return rv_total, rv_per_planet
680
+
681
+
682
+ def _rm_disc_terms(
683
+ times: jax.Array,
684
+ rp_by_rs: jax.Array,
685
+ b_param: jax.Array,
686
+ ecosomega: jax.Array,
687
+ esinomega: jax.Array,
688
+ period: jax.Array,
689
+ rho_star: jax.Array,
690
+ v_sin_i: jax.Array,
691
+ lambda_spin: jax.Array,
692
+ inc_star: jax.Array,
693
+ q1: jax.Array,
694
+ q2: jax.Array,
695
+ ) -> tuple[jax.Array, jax.Array, jax.Array]:
696
+ """Disc integrals of the RM observables, with full-precision matrix products.
697
+
698
+ On GPUs, JAX's default precision for float32 matrix products is TF32,
699
+ which keeps only 10 bits of mantissa. The occultation integrals below are
700
+ differences of matrix products, and with TF32 the occulted flux fraction
701
+ was wrong by up to 3e-4 (3 % of a 1 % transit) and the CCF anomaly by up
702
+ to 2 m/s near ingress and egress, while the CPU was exact. The products
703
+ are few and small, so ``highest`` precision costs nothing measurable.
704
+ See :func:`_rm_disc_terms_tf32_unsafe` for the physics.
705
+ """
706
+ with jax.default_matmul_precision("highest"):
707
+ return _rm_disc_terms_tf32_unsafe(
708
+ times, rp_by_rs, b_param, ecosomega, esinomega, period, rho_star,
709
+ v_sin_i, lambda_spin, inc_star, q1, q2,
710
+ ) # fmt: skip
711
+
712
+
713
+ def _rm_disc_terms_tf32_unsafe(
714
+ times: jax.Array,
715
+ rp_by_rs: jax.Array,
716
+ b_param: jax.Array,
717
+ ecosomega: jax.Array,
718
+ esinomega: jax.Array,
719
+ period: jax.Array,
720
+ rho_star: jax.Array,
721
+ v_sin_i: jax.Array,
722
+ lambda_spin: jax.Array,
723
+ inc_star: jax.Array,
724
+ q1: jax.Array,
725
+ q2: jax.Array,
726
+ ) -> tuple[jax.Array, jax.Array, jax.Array]:
727
+ """Disc integrals behind both RM observables (flux-weighted and CCF).
728
+
729
+ Returns ``(rv_fw, visible, r_sky)`` per time step: the flux-weighted RV
730
+ anomaly of the visible disc [m/s] (the classic RM model, below), the visible
731
+ fraction of the stellar flux (1 - f, with f the occulted fraction), and the
732
+ sky-plane distance of the planet centre from the disc centre [R_star].
733
+ All args must be JAX arrays.
734
+ """
735
+ """
736
+ Compute the Rossiter-McLaughlin (RM) radial-velocity anomaly for a single planet.
737
+
738
+ The RM effect is the apparent RV shift that arises during a planetary transit
739
+ because the planet's disk occults different Doppler-shifted strips of the rotating
740
+ stellar surface. The anomaly is zero outside transit and follows the rotation
741
+ velocity profile of the occulted chord.
742
+
743
+ This implementation reproduces the physics of
744
+ ``jaxoplanet.starry.doppler.surface_radial_velocity``
745
+ (Luger et al. 2019, AJ 157 64) but avoids that function's use of BCOO sparse
746
+ matrix multiplications inside ``jax.vmap``. Those sparse ops fail in the
747
+ backward pass (VJP) required by NumPyro's NUTS sampler: the batch rule for
748
+ ``bcoo_dot_general`` does not support a batched LHS, causing the error::
749
+
750
+ ValueError: Input primal JAX type float32[1,9].
751
+ Expected cotangent float32[1,9] but got float32[N,9].
752
+
753
+ Our fix pre-computes the change-of-basis matrices (A2, A1_vdeg) as dense
754
+ float32 constants at module import time, replaces the problematic
755
+ ``p_yu.tosparse() @`` with ``jnp.dot(p_yu_dense, dm[:n])`` (dense dot), and
756
+ uses ``Pijk.todense()`` (scatter-to-dense, vmap-compatible) instead of
757
+ ``Pijk.tosparse() @`` (matrix-multiply-with-BCOO, NOT vmap-compatible).
758
+ The result is functionally identical but compiles quickly (≈70 s for 117
759
+ data points) and produces correct gradients through ``jax.vmap``.
760
+
761
+ Unit system
762
+ -----------
763
+ jaxoplanet uses R_star = 1 R_sun. All orbital positions are in solar radii.
764
+ The function returns values in R_sun/day, which are converted to m/s via
765
+ ``_RSUN_DAY_TO_MS`` (≈ 8052.08 m/s per R_sun/day).
766
+
767
+ Outside transit the function automatically returns 0 via the ``b_rot`` flag
768
+ (planet behind star → z≤0, or outside disk → b≥1+r), so no explicit mask is
769
+ needed.
770
+
771
+ Multi-planet RM
772
+ ---------------
773
+ Call once per planet and sum the results. Simultaneous mutual transits are
774
+ not modelled (additive approximation).
775
+
776
+ Parameters
777
+ ----------
778
+ times : array_like, shape (n_times,)
779
+ Observation times [days], **folded so that mid-transit is at t = 0**.
780
+ rp_by_rs : float
781
+ Planet-to-star radius ratio (dimensionless).
782
+ b_param : float
783
+ Transit impact parameter b = (a/R*)·cos(i_orb) (dimensionless).
784
+ ecosomega : float
785
+ Cartesian eccentricity component e·cos(ω).
786
+ esinomega : float
787
+ Cartesian eccentricity component e·sin(ω).
788
+ period : float
789
+ Orbital period [days].
790
+ rho_star : float
791
+ Mean stellar density [g/cm³].
792
+ v_sin_i : float
793
+ Sky-projected stellar rotation velocity [m/s].
794
+ lambda_spin : float
795
+ Sky-projected spin-orbit angle λ [rad], in the convention of Ohta et al.
796
+ (2005): the angle from the sky projection of the stellar spin axis to
797
+ that of the orbit normal, as in their Eqs. (9)-(10), with the impact
798
+ parameter b >= 0. λ=0 → aligned; |λ|=π → retrograde; for λ > 0 the
799
+ planet covers more of the receding (redshifted) hemisphere.
800
+ inc_star : float
801
+ Stellar inclination [rad]. π/2 → stellar pole in plane of sky.
802
+ q1 : float, optional
803
+ Quadratic limb-darkening parameter q1 (Kipping 2013). Default: 0.5.
804
+ q2 : float, optional
805
+ Quadratic limb-darkening parameter q2 (Kipping 2013). Default: 0.1.
806
+
807
+ Returns
808
+ -------
809
+ rv_rm : array, shape (n_times,)
810
+ Rossiter-McLaughlin radial-velocity anomaly [m/s]. Positive values mean
811
+ the star appears to recede (planet occuiting the blue-shifted hemisphere).
812
+
813
+ Notes
814
+ -----
815
+ The stellar rotation period is derived as::
816
+
817
+ v_eq = v_sin_i / (sin(inc_star) * _RSUN_DAY_TO_MS) [R_sun/day]
818
+ P_rot = 2π / v_eq [days]
819
+
820
+ Polynomial degrees: ydeg=0, udeg=2, vdeg=1 (solid-body rotation, no alpha).
821
+ The module-level ``_RM_*`` constants encode the pre-computed basis matrices.
822
+
823
+ References
824
+ ----------
825
+ * Luger et al. (2019), AJ 157 64 — starry polynomial framework
826
+ * Ohta et al. (2005), ApJ 622 1118 — analytic RM formula
827
+ * Kipping (2013), MNRAS 435 2152 — q1/q2 LD parameterisation
828
+ """
829
+ # ------------------------------------------------------------------
830
+ # 1. Derive stellar rotation period from the observable v_sin_i
831
+ # ------------------------------------------------------------------
832
+ # jaxoplanet's Surface uses rotation period [days] as its argument.
833
+ # The relationship is:
834
+ # v_eq [R_sun/day] = v_sin_i [m/s] / (_RSUN_DAY_TO_MS × sin(inc_star))
835
+ # P_rot [days] = 2π / v_eq [R_sun/day]
836
+ # (since jaxoplanet sets R_star = 1 R_sun, so v_eq = 2π R_star / P_rot
837
+ # is in R_sun/day and 1 R_sun/day = _RSUN_DAY_TO_MS m/s)
838
+ v_eq_rsun_per_day = v_sin_i / (_RSUN_DAY_TO_MS * jnp.sin(inc_star))
839
+ p_rot_star = 2.0 * jnp.pi / v_eq_rsun_per_day # [days]
840
+
841
+ # ------------------------------------------------------------------
842
+ # 2. Compute polynomial representations of the stellar surface
843
+ # (all depend on surface params only, NOT on planet position)
844
+ # ------------------------------------------------------------------
845
+ # These are computed ONCE per model call and closed over in the per-step
846
+ # vmap lambda below. They do NOT use Pijk.tosparse() because that creates
847
+ # a BCOO sparse matrix whose VJP (backward pass) fails inside jax.vmap.
848
+ # Instead we use:
849
+ # - Pijk.todense() for the polynomial→dense-array conversion (works in vmap)
850
+ # - jnp.dot() for all dot products (standard dense, fully vmappable)
851
+ # The module-level constants (_RM_U, _RM_A1_VDEG, …) are pre-computed scipy
852
+ # matrices stored as float32 numpy arrays; converting them to JAX arrays here
853
+ # makes them compile-time constants.
854
+
855
+ # (a) Limb-darkening (LD) polynomial coefficients.
856
+ # u_vec = [1, u1, u2]; p_u_coeffs = u_vec @ U(2) → shape (9,)
857
+ u1, u2 = reparametrize_LD(q1, q2)
858
+ u_vec = jnp.array([1.0, u1, u2])
859
+ _U_jax = jnp.array(_RM_U) # (3, 9) constant
860
+ p_u_coeffs = u_vec @ _U_jax # (9,) depends on u1, u2 (traced)
861
+
862
+ # (b) Surface map polynomial (ydeg=0 → uniform disk = constant 1/π scaled).
863
+ # A1(0) = [[1/π]]; p_y_coeff = [1/π].
864
+ p_y_coeff = jnp.array([_RM_A1_YDEG_SCALAR]) # [1/π], shape (1,)
865
+
866
+ # (c) Combined LD × surface polynomial (p_yu = p_y * p_u).
867
+ # Since p_y = [1/π] (degree-0 scalar), multiplication by p_u just scales:
868
+ # p_yu_coeffs = (1/π) * p_u_coeffs → shape (9,)
869
+ p_yu_coeffs = p_y_coeff[0] * p_u_coeffs # (9,)
870
+
871
+ # (d) Normalization scalar: norm = π / (p_u @ rT_2).
872
+ # This is the reciprocal of the total flux integral over the stellar disk.
873
+ _rT_udeg_jax = jnp.array(_RM_rT_UDEG) # (9,) constant
874
+ norm = jnp.pi / jnp.dot(p_u_coeffs, _rT_udeg_jax)
875
+
876
+ # (e) RV map expansion coefficients (solid-body rotation, no differential rotation).
877
+ # y_rv = rv_map_expansion(inc, obl, veq, alpha=None)
878
+ # Returns 4 coefficients: [0, veq*√3*B/3, 0, veq*√3*A/3] * π
879
+ # where A = sin(i)*cos(λ), B = sin(i)*sin(λ).
880
+ # We use veq = v_eq_rsun_per_day (derived in step 1 above).
881
+ # lambda follows Ohta et al. (2005, Eqs. 9-10), the convention of most codes
882
+ # (e.g. PyAstronomy's RmcL, PyORBIT). starry's map obliquity is oriented the
883
+ # other way, so it is -lambda (tests/unit/core/test_rm_convention.py).
884
+ y_rv_jax = jnp.array(
885
+ _jxp_rv_map_expansion(
886
+ inc=inc_star, obl=-lambda_spin, veq=v_eq_rsun_per_day, alpha=None
887
+ ),
888
+ dtype=jnp.float32,
889
+ ) # shape (4,)
890
+
891
+ # (f) Pre-cast the basis matrices to JAX arrays for use inside the vmap.
892
+ _A2_jax = jnp.array(_RM_A2) # (16, 16) constant
893
+ _A1_vdeg_jax = jnp.array(_RM_A1_VDEG) # (4, 4) constant
894
+ _rT_total_jax = jnp.array(_RM_rT_TOTAL) # (16,) constant
895
+
896
+ # (g) Pijk objects for the polynomial product inside the vmap.
897
+ # These carry the traced LD and surface map coefficients.
898
+ p_u_pk = _jxp_Pijk.from_dense(p_u_coeffs, degree=_RM_UDEG) # degree-2 poly
899
+ p_y_pk = _jxp_Pijk.from_dense(p_y_coeff, degree=_RM_YDEG) # degree-0 poly
900
+
901
+ # ------------------------------------------------------------------
902
+ # 3. Build Keplerian orbit to obtain planet (x, y, z) positions
903
+ # ------------------------------------------------------------------
904
+ # We use the same Central setup as in compute_lightcurve:
905
+ # R_star = 1 R_sun → positions are in R_sun = R_star units.
906
+ # M_star [M_sun] = rho_star [g/cm³] / rho_sun [g/cm³]
907
+ # (Kepler's 3rd law then gives the correct a/R_star from the period.)
908
+ eccentricity, omega = reparametrize_ecc_omega(ecosomega, esinomega)
909
+ star_mass = rho_star / RHO_SUN_CGS # [M_sun], with R_star = 1 R_sun
910
+ star = Central(mass=star_mass, radius=1.0)
911
+
912
+ # Clip impact parameter to avoid degenerate geometry inside jaxoplanet.
913
+ b_safe = jnp.clip(b_param, 0.0, 0.99)
914
+
915
+ # Build a single-planet system. We only need orbital positions here,
916
+ # not the Keplerian RV amplitude. times are folded to t0 = 0.
917
+ system = System(star).add_body(
918
+ period=period,
919
+ time_transit=0.0, # folded: mid-transit at t = 0
920
+ radius=rp_by_rs,
921
+ impact_param=b_safe,
922
+ eccentricity=eccentricity,
923
+ omega_peri=omega,
924
+ )
925
+
926
+ # relative_position returns (x, y, z) each shape (n_planets, n_times).
927
+ # Coordinate convention (jaxoplanet):
928
+ # +x: transverse direction (parallel to stellar equator for aligned orbit)
929
+ # +y: along the impact parameter direction
930
+ # +z: toward the observer (> 0 during transit)
931
+ x_all, y_all, z_all = system.relative_position(times)
932
+ x_p = x_all[0] # (n_times,)
933
+ y_p = y_all[0]
934
+ z_p = z_all[0]
935
+
936
+ # ------------------------------------------------------------------
937
+ # 4. Evaluate RM anomaly for each time step via jax.vmap
938
+ # ------------------------------------------------------------------
939
+ # WHY vmap WORKS HERE (despite jaxoplanet's sparse-op issues):
940
+ # The original jaxoplanet surface_radial_velocity computes:
941
+ # intensity = p_yu.tosparse() @ design_matrix[:n_deg]
942
+ # This FAILS inside vmap during the backward pass (VJP) because the
943
+ # bcoo_dot_general VJP rule does not support batched LHS (BCOO matrices
944
+ # can't have a batch dimension in the backward rule of JAX ≤0.4.x).
945
+ # The error manifests as:
946
+ # "Input primal float32[1,9]: expected cotangent float32[1,9] got float32[N,9]"
947
+ #
948
+ # FIX: We replace the two problematic operations:
949
+ # OLD: intensity = (p_yu.tosparse() @ dm[:9]) * norm # BCOO @ dense → FAILS in vmap
950
+ # NEW: intensity = jnp.dot(p_yu_coeffs, dm[:9]) * norm # dense @ dense → OK
951
+ # AND:
952
+ # OLD: design_matrix = sT @ A2_bcoo # BCOO matmul → may also FAIL in vmap
953
+ # NEW: design_matrix = sT @ _A2_jax # dense matmul → OK
954
+ #
955
+ # The Pijk polynomial product (p_y * p_rv * p_u).todense() also uses
956
+ # BCOO internally, but ONLY for BCOO.todense() (scatter-to-dense), which
957
+ # DOES support vmap (it's a scatter with static indices and dynamic values).
958
+
959
+ def _rm_per_step(
960
+ xi: jax.Array, yi: jax.Array, zi: jax.Array
961
+ ) -> tuple[jax.Array, jax.Array, jax.Array]:
962
+ """RM RV [R_sun/day], visible flux fraction and sky distance for one time step.
963
+
964
+ All ops are dense.
965
+ """
966
+ # Angular position of the planet on the stellar disk (azimuth in sky plane).
967
+ # This determines which part of the rotating stellar surface is occulted.
968
+ theta_z = jnp.arctan2(xi, yi)
969
+
970
+ # Sky-plane separation from the stellar disk centre.
971
+ b_dist = jnp.sqrt(xi**2 + yi**2)
972
+
973
+ # Out-of-transit flag: planet behind star (z≤0) or outside disk (b≥1+r).
974
+ # When True the RM signal is zero (we use safe dummy values to avoid NaN
975
+ # inside solution_vector / the jnp.where below).
976
+ b_rot = jnp.logical_or(b_dist >= 1.0 + rp_by_rs, zi <= 0.0)
977
+
978
+ # Clamp to safe non-degenerate values for the occultation solver.
979
+ b_safe_step = jnp.where(b_rot, jnp.array(1.0), b_dist)
980
+ r_safe_step = jnp.where(b_rot, jnp.array(1.0), rp_by_rs)
981
+
982
+ # Occultation solution vector sT (shape (16,)).
983
+ # solution_vector evaluates the integral over the occulted crescent.
984
+ sT = _jxp_solution_vector(_RM_TOTAL_DEG)(b_safe_step, r_safe_step)
985
+
986
+ # Design matrix: converts sT to polynomial-basis coefficients.
987
+ # Uses the pre-computed dense A2 matrix to avoid BCOO inside vmap.
988
+ # Out-of-transit: use rT_total (full-disk rotation vector).
989
+ design_matrix = jnp.where(b_rot, _rT_total_jax, sT @ _A2_jax) # (16,)
990
+
991
+ # Rotate the RV map by the planet's azimuthal angle theta_z.
992
+ # dot_rz(vdeg=1, angle) returns a function that rotates a degree-1
993
+ # polynomial vector by the given angle.
994
+ rotated_rv_y = _jxp_dot_rz(_RM_VDEG, -theta_z)(y_rv_jax) # (4,)
995
+
996
+ # Apply the A1 basis transform to get the velocity polynomial in the
997
+ # Pijk polynomial basis (degree 1, 4 coefficients).
998
+ p_rv_coeffs = _A1_vdeg_jax @ rotated_rv_y # (4,)
999
+ p_rv_pk = _jxp_Pijk.from_dense(p_rv_coeffs, degree=_RM_VDEG) # degree-1 Pijk
1000
+
1001
+ # Combined polynomial product (p_y × p_rv × p_u) in the Pijk basis.
1002
+ # This is the velocity-weighted LD-attenuated surface brightness.
1003
+ # .todense() calls .tosparse() then BCOO.todense() — the BCOO.todense()
1004
+ # (scatter-to-dense) DOES work inside vmap, unlike .tosparse() @.
1005
+ rv_coeffs = (p_y_pk * p_rv_pk * p_u_pk).todense() # (16,)
1006
+
1007
+ # RV numerator: dot product of the combined polynomial with the design matrix.
1008
+ rv_val = jnp.dot(rv_coeffs, design_matrix) # scalar
1009
+
1010
+ # Intensity denominator: dot product of the LD+surface polynomial with the
1011
+ # first n_deg elements of the design matrix. Uses the pre-computed dense
1012
+ # p_yu_coeffs (= p_y_coeff * p_u_coeffs) to avoid tosparse() @.
1013
+ intensity = jnp.dot(p_yu_coeffs, design_matrix[:_RM_N_DEG]) * norm # scalar
1014
+
1015
+ # RM RV in R_sun/day (surface amplitude = 1.0 for a uniform disk).
1016
+ # Matches the return value of surface_radial_velocity(surface, x,y,z,r).
1017
+ # ``intensity`` is the visible fraction of the stellar flux (1 out of
1018
+ # transit); the CCF observable also needs it and the planet's distance.
1019
+ return rv_val * norm / intensity, intensity, b_dist
1020
+
1021
+ # Vectorise over all time steps in parallel (vmap is safe because
1022
+ # all matrix operations inside _rm_per_step are dense).
1023
+ rv_rm_rsun_per_day, visible, r_sky = jax.vmap(_rm_per_step)(x_p, y_p, z_p)
1024
+
1025
+ # ------------------------------------------------------------------
1026
+ # 5. Convert R_sun/day → m/s and return
1027
+ # ------------------------------------------------------------------
1028
+ # 1 R_sun/day = _RSUN_DAY_TO_MS m/s ≈ 8052.08 m/s
1029
+ return rv_rm_rsun_per_day * _RSUN_DAY_TO_MS, visible, r_sky
1030
+
1031
+
1032
+ @jax.jit
1033
+ def _compute_rossiter_mclaughlin_jit(
1034
+ times: jax.Array,
1035
+ rp_by_rs: jax.Array,
1036
+ b_param: jax.Array,
1037
+ ecosomega: jax.Array,
1038
+ esinomega: jax.Array,
1039
+ period: jax.Array,
1040
+ rho_star: jax.Array,
1041
+ v_sin_i: jax.Array,
1042
+ lambda_spin: jax.Array,
1043
+ inc_star: jax.Array,
1044
+ q1: jax.Array,
1045
+ q2: jax.Array,
1046
+ ) -> jax.Array:
1047
+ """JIT-compiled flux-weighted RM anomaly [m/s] (see :func:`_rm_disc_terms`)."""
1048
+ return _rm_disc_terms(
1049
+ times, rp_by_rs, b_param, ecosomega, esinomega, period, rho_star,
1050
+ v_sin_i, lambda_spin, inc_star, q1, q2,
1051
+ )[0] # fmt: skip
1052
+
1053
+
1054
+ def compute_rossiter_mclaughlin(
1055
+ times: Any,
1056
+ rp_by_rs: Any,
1057
+ b_param: Any,
1058
+ ecosomega: Any,
1059
+ esinomega: Any,
1060
+ period: Any,
1061
+ rho_star: Any,
1062
+ v_sin_i: Any,
1063
+ lambda_spin: Any,
1064
+ inc_star: Any,
1065
+ q1: Any = 0.5,
1066
+ q2: Any = 0.1,
1067
+ ) -> jax.Array:
1068
+ """Public wrapper: converts scalar inputs to JAX float32 arrays, then calls the JIT core."""
1069
+
1070
+ def _f(x: Any) -> jax.Array:
1071
+ return jnp.asarray(x, dtype=jnp.float32)
1072
+
1073
+ return _compute_rossiter_mclaughlin_jit(
1074
+ jnp.asarray(times, dtype=jnp.float32),
1075
+ _f(rp_by_rs),
1076
+ _f(b_param),
1077
+ _f(ecosomega),
1078
+ _f(esinomega),
1079
+ _f(period),
1080
+ _f(rho_star),
1081
+ _f(v_sin_i),
1082
+ _f(lambda_spin),
1083
+ _f(inc_star),
1084
+ _f(q1),
1085
+ _f(q2),
1086
+ )
1087
+
1088
+
1089
+ # ──────────────────────────────────────────────────────────────────────────────
1090
+ # Rossiter-McLaughlin effect as measured by cross-correlation (CCF observable)
1091
+ # ──────────────────────────────────────────────────────────────────────────────
1092
+ # The flux-weighted anomaly above is the first moment of the distorted line.
1093
+ # RVs measured by cross-correlating the spectrum with a template (or by fitting
1094
+ # the CCF with a Gaussian) respond differently when v sin i is comparable to the
1095
+ # line width (Hirano et al. 2010). Hirano et al. (2011, ApJ 742, 69, Eq. 16)
1096
+ # give the anomaly measured by maximising the cross-correlation with a template
1097
+ # (the iodine-cell and template-matching techniques; Boue et al. 2013), for a Voigt
1098
+ # intrinsic line (Gaussian width beta, Lorentzian width gamma), the exact
1099
+ # rotational kernel with quadratic limb darkening, and radial-tangential
1100
+ # macroturbulence (zeta):
1101
+ #
1102
+ # dv = -(f / 2 pi) N / D
1103
+ # N = int E(s) M(s) T(s) sin(2 pi s v_p) s ds
1104
+ # D = int E(s) M(s) [M(s) - f T(s) cos(2 pi s v_p)] s^2 ds
1105
+ # E = exp(-2 pi^2 beta^2 s^2 - 4 pi gamma s)
1106
+ #
1107
+ # where s is the Fourier frequency [s/km], f the occulted flux fraction, v_p
1108
+ # the intensity-weighted rotational velocity of the occulted region, M(s) the
1109
+ # Fourier transform of the disc-integrated rotation+macroturbulence kernel
1110
+ # (their Eq. 17) and T(s) that of the macroturbulence at the planet (Eq. 18).
1111
+ # Hirano's widths follow exp(-v^2/beta^2): beta is sqrt(2) times a standard
1112
+ # deviation. f and v_p come from _rm_disc_terms, so the geometry, the limb
1113
+ # darkening and the lambda convention are those of the flux-weighted model.
1114
+
1115
+ _HIRANO_N_FREQ = 1025 # Simpson's rule: odd number of points
1116
+ _HIRANO_N_MU = 128 # Gauss-Legendre nodes for the disc integral of M(s)
1117
+ _HIRANO_S_MAX_BETA = 1.2 # s_max = 1.2 / beta: exp(-2 pi^2 beta^2 s^2) < 1e-12
1118
+ _mu_nodes, _mu_weights = np.polynomial.legendre.leggauss(_HIRANO_N_MU)
1119
+ _HIRANO_MU = 0.5 * (_mu_nodes + 1.0) # on [0, 1]
1120
+ _HIRANO_MU_W = 0.5 * _mu_weights
1121
+ _simpson = np.ones(_HIRANO_N_FREQ)
1122
+ _simpson[1:-1:2], _simpson[2:-1:2] = 4.0, 2.0
1123
+ _HIRANO_SIMPSON = _simpson / 3.0
1124
+
1125
+
1126
+ def _bessel_j0(x: jax.Array) -> jax.Array:
1127
+ """Bessel function J0, Abramowitz & Stegun 9.4.1 and 9.4.3 (|error| < 1e-7)."""
1128
+ ax = jnp.abs(x)
1129
+ y = (ax / 3.0) ** 2
1130
+ small = 1.0 + y * (
1131
+ -2.2499997
1132
+ + y * (1.2656208 + y * (-0.3163866 + y * (0.0444479 + y * (-0.0039444 + y * 0.0002100))))
1133
+ ) # fmt: skip
1134
+ big = jnp.maximum(ax, 3.0)
1135
+ z = 3.0 / big
1136
+ f0 = 0.79788456 + z * (
1137
+ -0.00000077
1138
+ + z * (-0.00552740 + z * (-0.00009512 + z * (0.00137237 + z * (-0.00072805 + z * 0.00014476))))
1139
+ ) # fmt: skip
1140
+ theta0 = big - 0.78539816 + z * (
1141
+ -0.04166397
1142
+ + z * (-0.00003954 + z * (0.00262573 + z * (-0.00054125 + z * (-0.00029333 + z * 0.00013558))))
1143
+ ) # fmt: skip
1144
+ return jnp.where(ax <= 3.0, small, f0 * jnp.cos(theta0) / jnp.sqrt(big))
1145
+
1146
+
1147
+ def _hirano2011_anomaly(
1148
+ f: jax.Array,
1149
+ v_p: jax.Array,
1150
+ cos_theta: jax.Array,
1151
+ v_sin_i: Any,
1152
+ beta: Any,
1153
+ gamma: Any,
1154
+ zeta: Any,
1155
+ u1: Any,
1156
+ u2: Any,
1157
+ ) -> jax.Array:
1158
+ """Hirano et al. (2011) Eq. (16): CCF RM anomaly [km/s].
1159
+
1160
+ ``f``, ``v_p`` [km/s] and ``cos_theta`` (of the occulted region) have shape
1161
+ (n_times,); the other arguments are scalars, velocities in km/s.
1162
+ """
1163
+ s_max = _HIRANO_S_MAX_BETA / jnp.maximum(beta, 0.05)
1164
+ s = jnp.linspace(0.0, 1.0, _HIRANO_N_FREQ) * s_max # (N,)
1165
+ ds_w = jnp.asarray(_HIRANO_SIMPSON) * s_max / (_HIRANO_N_FREQ - 1)
1166
+ mu = jnp.asarray(_HIRANO_MU)
1167
+ mu_w = jnp.asarray(_HIRANO_MU_W)
1168
+ t = jnp.sqrt(jnp.clip(1.0 - mu**2, 0.0, 1.0))
1169
+ limb = (1.0 - u1 * (1.0 - mu) - u2 * (1.0 - mu) ** 2) / (1.0 - u1 / 3.0 - u2 / 6.0)
1170
+ pz2 = (jnp.pi * zeta) ** 2
1171
+ s2 = s[:, None] ** 2
1172
+ macro = jnp.exp(-pz2 * s2 * mu**2) + jnp.exp(-pz2 * s2 * t**2)
1173
+ # M(s), Eq. (17), written in mu = cos(theta): t dt = -mu dmu
1174
+ m_s = jnp.sum(
1175
+ mu_w * limb * macro * _bessel_j0(2.0 * jnp.pi * s[:, None] * v_sin_i * t) * mu,
1176
+ axis=1,
1177
+ ) # (N,)
1178
+ envelope = jnp.exp(-2.0 * jnp.pi**2 * beta**2 * s**2 - 4.0 * jnp.pi * gamma * s)
1179
+ sin2 = jnp.clip(1.0 - cos_theta**2, 0.0, 1.0)
1180
+ theta_s = 0.5 * (
1181
+ jnp.exp(-pz2 * cos_theta[:, None] ** 2 * s**2)
1182
+ + jnp.exp(-pz2 * sin2[:, None] * s**2)
1183
+ ) # (n_times, N), Eq. (18) at the planet position
1184
+ phase = 2.0 * jnp.pi * s * v_p[:, None]
1185
+ num = jnp.sum(ds_w * envelope * m_s * theta_s * jnp.sin(phase) * s, axis=1)
1186
+ den = jnp.sum(
1187
+ ds_w * envelope * m_s * (m_s - f[:, None] * theta_s * jnp.cos(phase)) * s**2,
1188
+ axis=1,
1189
+ )
1190
+ return -f / (2.0 * jnp.pi) * num / den
1191
+
1192
+
1193
+ @jax.jit
1194
+ def _compute_rossiter_mclaughlin_hirano2011_jit(
1195
+ times: jax.Array,
1196
+ rp_by_rs: jax.Array,
1197
+ b_param: jax.Array,
1198
+ ecosomega: jax.Array,
1199
+ esinomega: jax.Array,
1200
+ period: jax.Array,
1201
+ rho_star: jax.Array,
1202
+ v_sin_i: jax.Array,
1203
+ lambda_spin: jax.Array,
1204
+ inc_star: jax.Array,
1205
+ q1: jax.Array,
1206
+ q2: jax.Array,
1207
+ line_beta: jax.Array,
1208
+ line_gamma: jax.Array,
1209
+ line_zeta: jax.Array,
1210
+ ) -> jax.Array:
1211
+ """JIT-compiled CCF RM anomaly [m/s]; velocity inputs in m/s."""
1212
+ rv_fw, visible, r_sky = _rm_disc_terms(
1213
+ times, rp_by_rs, b_param, ecosomega, esinomega, period, rho_star,
1214
+ v_sin_i, lambda_spin, inc_star, q1, q2,
1215
+ ) # fmt: skip
1216
+ f = jnp.clip(1.0 - visible, 0.0, 1.0)
1217
+ in_transit = f > 1e-9
1218
+ f_safe = jnp.where(in_transit, f, 1.0)
1219
+ v_sin_i_kms = v_sin_i / 1000.0
1220
+ # flux-weighted anomaly = -(occulted flux x v_p) / visible flux, since the
1221
+ # whole rotating disc integrates to zero velocity
1222
+ v_p = -(rv_fw / 1000.0) * (1.0 - f) / f_safe
1223
+ v_p = jnp.where(in_transit, jnp.clip(v_p, -v_sin_i_kms, v_sin_i_kms), 0.0)
1224
+ cos_theta = jnp.sqrt(jnp.clip(1.0 - r_sky**2, 0.0, 1.0))
1225
+ u1, u2 = reparametrize_LD(q1, q2)
1226
+ dv = _hirano2011_anomaly(
1227
+ jnp.where(in_transit, f, 0.0), v_p, cos_theta, v_sin_i_kms,
1228
+ line_beta / 1000.0, line_gamma / 1000.0, line_zeta / 1000.0, u1, u2,
1229
+ ) # fmt: skip
1230
+ return jnp.where(in_transit, dv * 1000.0, 0.0)
1231
+
1232
+
1233
+ def compute_rossiter_mclaughlin_hirano2011(
1234
+ times: Any,
1235
+ rp_by_rs: Any,
1236
+ b_param: Any,
1237
+ ecosomega: Any,
1238
+ esinomega: Any,
1239
+ period: Any,
1240
+ rho_star: Any,
1241
+ v_sin_i: Any,
1242
+ lambda_spin: Any,
1243
+ inc_star: Any,
1244
+ q1: Any = 0.5,
1245
+ q2: Any = 0.1,
1246
+ line_beta: Any = 2000.0,
1247
+ line_gamma: Any = 0.0,
1248
+ line_zeta: Any = 0.0,
1249
+ ) -> jax.Array:
1250
+ """RM anomaly [m/s] as measured by cross-correlation with a template (Hirano et al. 2011).
1251
+
1252
+ The RV is the maximum of the cross-correlation of the spectrum with the
1253
+ out-of-transit template (their Eq. 16): the iodine-cell and template-matching
1254
+ techniques, ``rm_observable: template``.
1255
+
1256
+ Arguments as :func:`compute_rossiter_mclaughlin`, plus the intrinsic line
1257
+ widths in **m/s** (like ``v_sin_i``), in Hirano's convention
1258
+ exp(-v^2/beta^2): ``line_beta`` (Gaussian: thermal, microturbulent and
1259
+ instrumental broadening, sqrt(2) x their standard deviation added in
1260
+ quadrature), ``line_gamma`` (Lorentzian: pressure and natural broadening) and
1261
+ ``line_zeta`` (radial-tangential macroturbulence).
1262
+ """
1263
+
1264
+ def _f(x: Any) -> jax.Array:
1265
+ return jnp.asarray(x, dtype=jnp.float32)
1266
+
1267
+ return _compute_rossiter_mclaughlin_hirano2011_jit(
1268
+ jnp.asarray(times, dtype=jnp.float32),
1269
+ _f(rp_by_rs), _f(b_param), _f(ecosomega), _f(esinomega), _f(period),
1270
+ _f(rho_star), _f(v_sin_i), _f(lambda_spin), _f(inc_star), _f(q1), _f(q2),
1271
+ _f(line_beta), _f(line_gamma), _f(line_zeta),
1272
+ ) # fmt: skip
1273
+
1274
+
1275
+ # ──────────────────────────────────────────────────────────────────────────────
1276
+ # CCF pipelines (HARPS, HARPS-N, ESPRESSO) measure the RV as the centre of a
1277
+ # Gaussian fitted to the CCF, which is not the maximum of the cross-correlation
1278
+ # modelled above. Boué et al. (2013, A&A 550, A53, Eq. 14) give the shift of
1279
+ # that centre for a Gaussian line of standard deviation beta_0 (thermal,
1280
+ # microturbulent and instrumental broadening):
1281
+ #
1282
+ # dv = -(1 / a0) [2 sigma0^2 / (sigma0^2 + beta_p^2)]^(3/2)
1283
+ # f v_p exp(-v_p^2 / (2 (sigma0^2 + beta_p^2)))
1284
+ #
1285
+ # sigma0 and a0 are the width and area of the best Gaussian fit to the
1286
+ # out-of-transit line (the rotationally broadened, limb-darkened profile); they
1287
+ # depend on v sin i, beta_0 and the limb darkening, so they are refitted at
1288
+ # every call. beta_p^2 = beta_0^2 + (v sin i k / 2)^2 is the width of the line
1289
+ # emitted behind the planet, broadened by the rotation across the planet disc
1290
+ # (their Sect. 4.3, for a fully occulting planet). The linear response of the
1291
+ # centre is exact to first order in f: at the best-fit width the residual term
1292
+ # of the Hessian vanishes (a Gaussian satisfies the heat equation), so the
1293
+ # Gauss-Newton expression of Boué et al. needs no correction.
1294
+
1295
+ _BOUE_N_U = 256 # Gauss-Legendre nodes on the rotation kernel
1296
+ _BOUE_NEWTON = 8 # Newton steps for the fitted width (4 suffice)
1297
+ _u_nodes, _u_weights = np.polynomial.legendre.leggauss(_BOUE_N_U)
1298
+ _BOUE_X = _u_nodes # x = v / v sin i, on [-1, 1]
1299
+ _BOUE_W = _u_weights
1300
+
1301
+
1302
+ def _boue2013_fit(v_sin_i: Any, beta0: Any, u1: Any, u2: Any) -> tuple[Any, Any]:
1303
+ """Width sigma0 and area a0 of the least-squares Gaussian fit to the stellar line.
1304
+
1305
+ The line is a Gaussian of standard deviation ``beta0`` convolved with the
1306
+ rotation kernel of a quadratically limb-darkened disc; velocities in km/s.
1307
+ """
1308
+ x = jnp.asarray(_BOUE_X)
1309
+ root = jnp.sqrt(jnp.clip(1.0 - x**2, 0.0, 1.0))
1310
+ # rotation kernel: the intensity integrated along each strip of constant v
1311
+ kernel = (
1312
+ (1.0 - u1 - u2) * 2.0 * root
1313
+ + (u1 + 2.0 * u2) * 0.5 * jnp.pi * root**2
1314
+ - u2 * 4.0 / 3.0 * root**3
1315
+ ) * jnp.asarray(_BOUE_W)
1316
+ kernel = kernel / jnp.sum(kernel)
1317
+ v2 = (x * v_sin_i) ** 2
1318
+
1319
+ def overlap(log_sigma: Any) -> Any:
1320
+ """sigma * int line(v) exp(-v^2 / 2 sigma^2) dv / sqrt(2 pi) sigma."""
1321
+ t2 = jnp.exp(2.0 * log_sigma) + beta0**2
1322
+ return (
1323
+ jnp.exp(log_sigma)
1324
+ * jnp.sum(kernel * jnp.exp(-0.5 * v2 / t2))
1325
+ / jnp.sqrt(t2)
1326
+ )
1327
+
1328
+ def objective(log_sigma: Any) -> Any:
1329
+ # least squares with the amplitude profiled out: maximise overlap^2 / sigma
1330
+ return 2.0 * jnp.log(overlap(log_sigma)) - log_sigma
1331
+
1332
+ grad = jax.grad(objective)
1333
+ hess = jax.grad(grad)
1334
+
1335
+ def newton(log_sigma: Any) -> Any:
1336
+ step = grad(log_sigma) / (jnp.abs(hess(log_sigma)) + 1e-6) # ascent
1337
+ return log_sigma + jnp.clip(step, -0.5, 0.5)
1338
+
1339
+ # start from the second moment of the line; iterate without gradients, then
1340
+ # one differentiable step, which carries the implicit-function derivative
1341
+ log_sigma = 0.5 * jnp.log(beta0**2 + jnp.sum(kernel * v2))
1342
+ log_sigma = jax.lax.fori_loop(0, _BOUE_NEWTON, lambda _, s: newton(s), log_sigma)
1343
+ log_sigma = newton(jax.lax.stop_gradient(log_sigma))
1344
+ sigma0 = jnp.exp(log_sigma)
1345
+ # area of the fitted Gaussian relative to the unit-area line (Boué Eq. 13)
1346
+ a0 = jnp.sqrt(2.0) * overlap(log_sigma)
1347
+ return sigma0, a0
1348
+
1349
+
1350
+ def _boue2013_anomaly(
1351
+ f: jax.Array, v_p: jax.Array, v_sin_i: Any, beta0: Any, k: Any, u1: Any, u2: Any
1352
+ ) -> jax.Array:
1353
+ """Boué et al. (2013) Eq. (14): Gaussian-fit RM anomaly [km/s].
1354
+
1355
+ ``f`` and ``v_p`` [km/s] have shape (n_times,); ``beta0`` is the standard
1356
+ deviation of the non-rotating line [km/s], ``k`` the radius ratio.
1357
+ """
1358
+ sigma0, a0 = _boue2013_fit(v_sin_i, beta0, u1, u2)
1359
+ s2 = sigma0**2 + beta0**2 + (0.5 * v_sin_i * k) ** 2 # sigma0^2 + beta_p^2
1360
+ return (
1361
+ -(1.0 / a0)
1362
+ * (2.0 * sigma0**2 / s2) ** 1.5
1363
+ * f
1364
+ * v_p
1365
+ * jnp.exp(-0.5 * v_p**2 / s2)
1366
+ )
1367
+
1368
+
1369
+ @jax.jit
1370
+ def _compute_rossiter_mclaughlin_boue2013_jit(
1371
+ times: jax.Array,
1372
+ rp_by_rs: jax.Array,
1373
+ b_param: jax.Array,
1374
+ ecosomega: jax.Array,
1375
+ esinomega: jax.Array,
1376
+ period: jax.Array,
1377
+ rho_star: jax.Array,
1378
+ v_sin_i: jax.Array,
1379
+ lambda_spin: jax.Array,
1380
+ inc_star: jax.Array,
1381
+ q1: jax.Array,
1382
+ q2: jax.Array,
1383
+ line_beta: jax.Array,
1384
+ ) -> jax.Array:
1385
+ """JIT-compiled Gaussian-fit RM anomaly [m/s]; velocity inputs in m/s."""
1386
+ rv_fw, visible, _ = _rm_disc_terms(
1387
+ times, rp_by_rs, b_param, ecosomega, esinomega, period, rho_star,
1388
+ v_sin_i, lambda_spin, inc_star, q1, q2,
1389
+ ) # fmt: skip
1390
+ f = jnp.clip(1.0 - visible, 0.0, 1.0)
1391
+ in_transit = f > 1e-9
1392
+ f_safe = jnp.where(in_transit, f, 1.0)
1393
+ v_sin_i_kms = v_sin_i / 1000.0
1394
+ v_p = -(rv_fw / 1000.0) * (1.0 - f) / f_safe
1395
+ v_p = jnp.where(in_transit, jnp.clip(v_p, -v_sin_i_kms, v_sin_i_kms), 0.0)
1396
+ u1, u2 = reparametrize_LD(q1, q2)
1397
+ # the width is given in Hirano's convention, exp(-v^2/beta^2)
1398
+ beta0 = line_beta / 1000.0 / jnp.sqrt(2.0)
1399
+ dv = _boue2013_anomaly(
1400
+ jnp.where(in_transit, f, 0.0), v_p, v_sin_i_kms, beta0, rp_by_rs, u1, u2
1401
+ )
1402
+ return jnp.where(in_transit, dv * 1000.0, 0.0)
1403
+
1404
+
1405
+ def compute_rossiter_mclaughlin_boue2013(
1406
+ times: Any,
1407
+ rp_by_rs: Any,
1408
+ b_param: Any,
1409
+ ecosomega: Any,
1410
+ esinomega: Any,
1411
+ period: Any,
1412
+ rho_star: Any,
1413
+ v_sin_i: Any,
1414
+ lambda_spin: Any,
1415
+ inc_star: Any,
1416
+ q1: Any = 0.5,
1417
+ q2: Any = 0.1,
1418
+ line_beta: Any = 2000.0,
1419
+ ) -> jax.Array:
1420
+ """RM anomaly [m/s] as the centre of a Gaussian fit to the CCF (Boué et al. 2013).
1421
+
1422
+ The RV of HARPS-like pipelines, ``rm_observable: ccf``.
1423
+
1424
+ Arguments as :func:`compute_rossiter_mclaughlin_hirano2011`: ``line_beta`` [m/s] is
1425
+ the width of the non-rotating line in Hirano's convention exp(-v^2/beta^2),
1426
+ i.e. sqrt(2) times its standard deviation.
1427
+ """
1428
+
1429
+ def _f(x: Any) -> jax.Array:
1430
+ return jnp.asarray(x, dtype=jnp.float32)
1431
+
1432
+ return _compute_rossiter_mclaughlin_boue2013_jit(
1433
+ jnp.asarray(times, dtype=jnp.float32),
1434
+ _f(rp_by_rs), _f(b_param), _f(ecosomega), _f(esinomega), _f(period),
1435
+ _f(rho_star), _f(v_sin_i), _f(lambda_spin), _f(inc_star), _f(q1), _f(q2),
1436
+ _f(line_beta),
1437
+ ) # fmt: skip
1438
+
1439
+
1440
+ # ──────────────────────────────────────────────────────────────────────────────
1441
+ # Transit Timing Variation (TTV) lightcurve model
1442
+ # ──────────────────────────────────────────────────────────────────────────────
1443
+
1444
+
1445
+ def compute_transit_ttv(
1446
+ times: jax.Array,
1447
+ transit_times: jax.Array,
1448
+ duration: jax.Array,
1449
+ impact_param: jax.Array,
1450
+ radius_ratio: jax.Array,
1451
+ u_limb_dark: jax.Array,
1452
+ ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]:
1453
+ """Compute the transit light curve for a single planet using TTV parameterization.
1454
+
1455
+ Unlike :func:`compute_lightcurve`, which is parameterized by a global
1456
+ period + reference epoch (suitable for systems with a strictly linear
1457
+ ephemeris), this function accepts an *explicit array of individual
1458
+ mid-transit times*. Each transit epoch can deviate from a linear
1459
+ ephemeris by an arbitrary amount (the TTV signal), and
1460
+ :class:`jaxoplanet.orbits.ttv.TTVOrbit` computes the correct transit
1461
+ geometry for every epoch independently.
1462
+
1463
+ **What is modeled here:**
1464
+ * The primary transit depth and shape at the correct TTV-corrected time
1465
+ for every observed transit. The transit profile is described by a
1466
+ quadratic limb-darkening law (triangular parameterization via
1467
+ ``u_limb_dark``).
1468
+ * No secondary eclipse, no phase curve, no beaming, no ellipsoidal
1469
+ variations. Those components should be computed separately using the
1470
+ *linear* ephemeris (``linear_t0``, ``linear_period``) returned by this
1471
+ function, and then summed with the transit offset.
1472
+
1473
+ **When to use this instead of compute_lightcurve:**
1474
+ Use this function when the planet's transit times are free parameters
1475
+ (one per observed transit epoch) rather than a single (t0, period) pair.
1476
+ This is the correct model whenever TTVs are large enough to be
1477
+ measurable (amplitude ≳ 2 minutes for typical photometric precision).
1478
+
1479
+ **Precision notes:**
1480
+ The function works in whatever precision JAX is currently configured
1481
+ for (float32 or float64). In PyAETNA's default float32 mode the transit
1482
+ times should be in the *centered* frame (i.e., subtracted by
1483
+ ``timeCenter``) to keep their magnitude well within the float32 range.
1484
+ A centered time of ≲ 15 d has float32 precision of ≲ 1.8e-6 d ≈ 0.15 s,
1485
+ which is sufficient for typical photometric TTV measurements.
1486
+
1487
+ **JIT compatibility:**
1488
+ The number of transit epochs ``n_transits = len(transit_times)`` is
1489
+ fixed at compile time (it is a Python-level shape, not a traced value).
1490
+ The *values* of ``transit_times`` are dynamic JAX tracers. This
1491
+ design is fully compatible with ``jax.jit`` and ``numpyro.MCMC``.
1492
+
1493
+ Parameters
1494
+ ----------
1495
+ times : jax.Array, shape (N,)
1496
+ Observation times in the centered frame [days].
1497
+ Must be the same reference frame as ``transit_times``
1498
+ (typically ``TIME - timeCenter``).
1499
+ transit_times : jax.Array, shape (n_transits,)
1500
+ Mid-transit time for each observed transit epoch [days, centered frame].
1501
+ Element ``i`` is the time of the *i*-th transit that falls within the
1502
+ observation window, ordered chronologically. Typically constructed as:
1503
+ ``expected_t + ttv_offset``
1504
+ where ``expected_t = tau_ref_centered + epoch_index * period_ref`` is
1505
+ the linear-ephemeris prediction and ``ttv_offset`` is the free
1506
+ TTV parameter for that epoch.
1507
+ duration : jax.Array, shape (1,)
1508
+ Transit duration T14 [days] — from first to last contact.
1509
+ Fixed (not TTV-perturbed) across all epochs for this implementation.
1510
+ impact_param : jax.Array, shape (1,)
1511
+ Impact parameter b = a cos(i) / R_star (dimensionless, 0 = central).
1512
+ radius_ratio : jax.Array, shape (1,)
1513
+ Planet-to-star radius ratio Rp/Rs (dimensionless).
1514
+ u_limb_dark : jax.Array, shape (2,)
1515
+ Limb-darkening coefficients [u1, u2] in the triangular (q1, q2)
1516
+ reparameterization *already converted* to the standard Kipping (2013)
1517
+ coefficients u1, u2 by :func:`reparametrize_LD`.
1518
+
1519
+ Returns
1520
+ -------
1521
+ transit_offset : jax.Array, shape (N,)
1522
+ Additive flux offset from stellar baseline due to the transit.
1523
+ The value is ≤ 0 (flux deficit during transit), and exactly 0
1524
+ out of transit. Units: same as ``y`` in the data file (normalized
1525
+ flux, so the transit depth is ≈ (Rp/Rs)²).
1526
+ linear_t0 : jax.Array, shape (1,)
1527
+ Best-fit linear reference epoch from the TTVOrbit internal least-squares
1528
+ fit through all ``transit_times`` [days, centered frame]. This is the
1529
+ constant term of the linear trend T_i = linear_t0 + i * linear_period.
1530
+ Use this to fold the RV data or to compute the phase curve with the
1531
+ linear ephemeris.
1532
+ linear_period : jax.Array, shape (1,)
1533
+ Best-fit linear period from the TTVOrbit internal least-squares fit
1534
+ [days]. Use this together with ``linear_t0`` for any computation
1535
+ that requires a global period (phase curves, RV, corner-plot labels).
1536
+ ttvs : jax.Array, shape (n_transits,)
1537
+ Observed minus Computed (O-C) residuals for every transit epoch
1538
+ [days]. These are the TTV signals:
1539
+ ttvs[i] = transit_times[i] - (linear_t0 + i * linear_period)
1540
+ where ``i`` is the epoch index inferred from the linear fit.
1541
+ Positive = transit arrived late; negative = transit arrived early.
1542
+
1543
+ Notes
1544
+ -----
1545
+ TTVOrbit attributes used:
1546
+ orbit.linear_t0[0] → linear_t0 (1-element array)
1547
+ orbit.linear_period[0] → linear_period (1-element array)
1548
+ orbit.ttvs[0] → ttvs (n_transits-element array)
1549
+
1550
+ Example
1551
+ -------
1552
+ >>> # Assume tau_ref=2459000.0, period_ref=3.5, timeCenter=2459015.0
1553
+ >>> tau_ref_c = 2459000.0 - 2459015.0 # = -15.0 (centered)
1554
+ >>> epoch_indices = jnp.array([0, 1, 2, 3, 4])
1555
+ >>> expected_times = tau_ref_c + epoch_indices * 3.5 # linear ephem
1556
+ >>> ttv_offsets = jnp.array([0.01, -0.02, 0.015, -0.01, 0.005]) # TTV
1557
+ >>> transit_times = expected_times + ttv_offsets
1558
+ >>> offset, lt0, lP, ttvs = compute_transit_ttv(
1559
+ ... times, transit_times,
1560
+ ... duration=jnp.array([0.12]),
1561
+ ... impact_param=jnp.array([0.3]),
1562
+ ... radius_ratio=jnp.array([0.1]),
1563
+ ... u_limb_dark=jnp.array([0.4, 0.2]),
1564
+ ... )
1565
+ """
1566
+ # TTVOrbit accepts transit_times as a *tuple* of 1-D arrays, one per planet.
1567
+ # For a single-planet call (PyAETNA evaluates one planet at a time), we
1568
+ # wrap the array in a length-1 tuple.
1569
+ orbit = TTVOrbit(
1570
+ transit_times=(transit_times,), # 1-tuple: 1 planet, n_transits epochs
1571
+ duration=duration, # shape (1,) — one planet
1572
+ impact_param=impact_param, # shape (1,) — one planet
1573
+ radius_ratio=radius_ratio, # shape (1,) — one planet
1574
+ )
1575
+
1576
+ # limb_dark_light_curve returns a function f(times) → array of shape (N, n_planets)
1577
+ # For n_planets=1 the shape is (N, 1); we squeeze the planet axis.
1578
+ lc_raw = limb_dark_light_curve(orbit, u_limb_dark)(times) # (N, 1)
1579
+ transit_offset = lc_raw[:, 0] # (N,) — additive offset, ≤ 0 at transit
1580
+
1581
+ # Extract derived linear ephemeris from the internal least-squares fit.
1582
+ # TTVOrbit fits T_i = linear_t0 + i * linear_period + ttvs[i] to the
1583
+ # supplied transit_times, so:
1584
+ # orbit.linear_t0[0] : reference epoch (first transit in the fit)
1585
+ # orbit.linear_period[0]: mean orbital period
1586
+ # orbit.ttvs[0] : O-C residuals array, shape (n_transits,)
1587
+ linear_t0 = orbit.linear_t0[0] # scalar (float32 or float64)
1588
+ linear_period = orbit.linear_period[0] # scalar
1589
+ ttvs = orbit.ttvs[0] # shape (n_transits,)
1590
+
1591
+ return transit_offset, linear_t0, linear_period, ttvs
1592
+
1593
+
1594
+ @partial(jit, static_argnames=("transit_only", "circular"))
1595
+ def compute_lightcurve(
1596
+ times: jax.Array,
1597
+ q_limb_dark: jax.Array,
1598
+ rho_star: float | jax.Array,
1599
+ t0s: jax.Array,
1600
+ periods: jax.Array,
1601
+ b_params: jax.Array,
1602
+ rp_by_rs: jax.Array,
1603
+ ecosomega: jax.Array,
1604
+ esinomega: jax.Array,
1605
+ amp_night: jax.Array,
1606
+ amp_day_minus_amp_night: jax.Array,
1607
+ amp_beam: jax.Array,
1608
+ amp_ellip: jax.Array,
1609
+ phase_offsets: jax.Array,
1610
+ tidal_lag: jax.Array,
1611
+ K_amp: jax.Array,
1612
+ transit_only: bool = False,
1613
+ circular: bool = False,
1614
+ ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array, jax.Array, jax.Array]:
1615
+ """
1616
+ Compute multi-planet lightcurve with phase curves, beaming, and ellipsoidal variations.
1617
+
1618
+ ``transit_only=True`` (static) returns the transits alone, with zero
1619
+ phase-curve, beaming, ellipsoidal and RV components: the caller sets it
1620
+ when every phase-curve amplitude is fixed to zero. The secondary
1621
+ eclipse, light-travel-time positions, velocities and RVs are then never
1622
+ evaluated (about 1.5x faster on GPU, 1.7x on CPU at 1e5 points).
1623
+
1624
+ ``circular=True`` (static) builds circular orbits, ignoring *ecosomega*
1625
+ and *esinomega*: the caller sets it when both are fixed to zero for every
1626
+ planet. jaxoplanet then computes positions without solving Kepler's
1627
+ equation, which with an eccentricity array of zeros takes about a third of
1628
+ a transit evaluation (K2-140, CPU); the result is the same.
1629
+
1630
+ Uses dimensionless stellar units (M_star = R_star = 1.0) with stellar density
1631
+ to set the orbital scale via Kepler's third law.
1632
+
1633
+ The key observable is a/R_star = (G*M_star/(4π²))^(1/3) * P^(2/3) / R_star
1634
+ which can be rewritten as: a/R_star ∝ (ρ_star * P²)^(1/3)
1635
+
1636
+ PERFORMANCE NOTES:
1637
+ - JIT-compiled for speed
1638
+ - Fully vectorized over time and planets
1639
+ - System initialization is efficient
1640
+ - Phase offset interpretation: shifts RADIATING hemisphere relative to ILLUMINATED hemisphere
1641
+
1642
+ LIGHT TRAVEL TIME (LTT):
1643
+ Photons observed at t_obs were emitted at t_em = t_obs + z(t_obs)/c, where
1644
+ z is the planet's line-of-sight displacement in R_sun (+z towards observer).
1645
+ For typical hot Jupiters (a ~ 10 R_sun), |Δt| ~ 25 s.
1646
+ The LTT correction is applied to the PHASE CURVE
1647
+ orbital phase only; transit/eclipse geometry, beaming, and ellipsoidal
1648
+ variations use observed-time positions (physically correct).
1649
+ One Newton iteration suffices (residual < 1e-4 for all known systems).
1650
+ Eccentric orbits are handled exactly via re-solving Kepler's equation.
1651
+
1652
+ Phase Offset Physics:
1653
+ - orbital_phase_angle = 0 at secondary eclipse (planet fully illuminated from observer's view)
1654
+ - orbital_phase_angle = π at primary transit (planet's night side toward observer)
1655
+ - phase_offset > 0: hotspot shifted EAST (evening terminator, trailing limb)
1656
+ - phase_offset < 0: hotspot shifted WEST (morning terminator, leading limb)
1657
+ - shifted_angle = orbital_phase_angle + phase_offset defines where peak emission occurs
1658
+
1659
+ The modulation uses the shifted angle, so:
1660
+ - If phase_offset = 0: peak brightness at secondary eclipse (α=0)
1661
+ - If phase_offset = π/4: peak brightness happens at α=π/4 (after secondary eclipse)
1662
+
1663
+ Parameters:
1664
+ -----------
1665
+ times : array
1666
+ Times at which to evaluate the model [days]
1667
+ q_limb_dark : array, shape (2,)
1668
+ Limb darkening parameters [q1, q2] for reparametrization
1669
+ rho_star : float
1670
+ Stellar density [g/cm³]. Used to set stellar mass (M = 4/3 π R³ ρ) so that
1671
+ jaxoplanet derives the orbital scale from period and stellar mass.
1672
+ Typical values: 0.5-3.0 g/cm³ for main-sequence stars. Solar value: 1.4 g/cm³
1673
+ t0s : array, shape (n_planets,)
1674
+ Mid-transit times [days]
1675
+ periods : array, shape (n_planets,)
1676
+ Orbital periods [days]
1677
+ b_params : array, shape (n_planets,)
1678
+ Impact parameters (dimensionless, b = a*cos(i)/R_star)
1679
+ rp_by_rs : array, shape (n_planets,)
1680
+ Planet-to-star radius ratios (dimensionless)
1681
+ ecosomega, esinomega : arrays, shape (n_planets,)
1682
+ Eccentricity parameterization (dimensionless)
1683
+ amp_night, amp_day_minus_amp_night, amp_beam, amp_ellip : arrays, shape (n_planets,)
1684
+ Phase curve amplitudes [ppm]
1685
+ phase_offsets : array, shape (n_planets,)
1686
+ Phase offset for each planet's emission peak [radians]
1687
+ Positive = hotspot shifted toward trailing hemisphere (evening)
1688
+ Negative = hotspot shifted toward leading hemisphere (morning)
1689
+ tidal_lag : array, shape (n_planets,)
1690
+ Tidal lag angle for ellipsoidal variations [radians]
1691
+ The stellar tidal bulge lags behind the planet due to:
1692
+ - Finite response time of stellar material (viscous dissipation)
1693
+ - Stellar rotation relative to orbital period
1694
+ Positive = bulge lags behind planet (typical for slow stellar rotation)
1695
+ Negative = bulge leads planet (rare, for super-synchronous rotation)
1696
+ Typical values: 0-10 degrees for hot Jupiter systems
1697
+ K_amp : array, shape (n_planets,)
1698
+ RV semi-amplitudes [m/s] (for beaming calculation)
1699
+
1700
+ Returns:
1701
+ --------
1702
+ planet_offset : array
1703
+ Normalized planetary light curve offset from baseline (total_flux - 1.0)
1704
+ This is the additive contribution: flux_obs = 1.0 + planet_offset + detrending
1705
+ lc_phase : array, shape (n_planets, n_times)
1706
+ Phase curve component per planet [ppm]
1707
+ lc_beam : array, shape (n_planets, n_times)
1708
+ Doppler beaming component per planet [ppm]
1709
+ lc_ellip : array, shape (n_planets, n_times)
1710
+ Ellipsoidal variation component per planet [ppm]
1711
+ lc_transit : array
1712
+ Transit light curve component (relative flux)
1713
+ rvs : array, shape (n_planets, n_times)
1714
+ Radial velocities per planet [m/s]
1715
+ """
1716
+ # ==========================================
1717
+ # 1. Create System with Stellar Density
1718
+ # ==========================================
1719
+
1720
+ # Convert eccentricity parameterization
1721
+ eccentricities, omegas = reparametrize_ecc_omega(ecosomega, esinomega)
1722
+ n_planets = len(t0s)
1723
+
1724
+ # Create star with given density.
1725
+ # rho_star is in g/cm³; jaxoplanet uses solar units (M_sun, R_sun, day).
1726
+ # For R_star = 1 R_sun: M_star [M_sun] = ρ_star [g/cm³] / ρ_sun [g/cm³]
1727
+ star_radius = 1.0
1728
+ star_mass = rho_star / RHO_SUN_CGS # Stellar mass in M_sun for R_star = 1 R_sun
1729
+
1730
+ star = Central(mass=star_mass, radius=star_radius)
1731
+
1732
+ # ------------------------------------------------------------------
1733
+ # Physics guard for b >= 1 (no transit / eclipse)
1734
+ # ------------------------------------------------------------------
1735
+ # When b >= 1 + rp_by_rs the planet does not cross the stellar disc,
1736
+ # so no transit or eclipse occurs. However jaxoplanet's internal
1737
+ # geometry (limb_dark_light_curve, emission_lc) can produce NaN for
1738
+ # such configurations because it takes sqrt(1 - b²) etc.
1739
+ #
1740
+ # Strategy (fully JIT-compatible via jnp.where):
1741
+ # 1. Build the orbital system with b_safe = min(b, 0.99) so that
1742
+ # jaxoplanet geometry never sees b >= 1. This keeps positions,
1743
+ # velocities AND transit/eclipse calculations finite.
1744
+ # 2. After computing transit & eclipse light-curves, multiply them
1745
+ # by a per-planet mask that zeros them out when the real b
1746
+ # implies no transit (b >= 1 + k).
1747
+ # 3. Phase curve, beaming, and ellipsoidal variations depend only
1748
+ # on orbital phase (not on b) and are always valid.
1749
+ # ------------------------------------------------------------------
1750
+ transit_occurs = b_params < (1.0 + rp_by_rs) # (n_planets,)
1751
+ b_safe = jnp.where(transit_occurs, b_params, 0.99)
1752
+
1753
+ system = System(star)
1754
+
1755
+ # Add planets using period directly (jaxoplanet computes a/R_star internally)
1756
+ for i in range(n_planets):
1757
+ system = system.add_body(
1758
+ period=periods[i], # Use period directly
1759
+ time_transit=t0s[i],
1760
+ radius=rp_by_rs[i],
1761
+ impact_param=b_safe[i],
1762
+ eccentricity=None if circular else eccentricities[i],
1763
+ omega_peri=None if circular else omegas[i],
1764
+ radial_velocity_semiamplitude=K_amp[i],
1765
+ )
1766
+
1767
+ # ==========================================
1768
+ # 2. Calculate Geometry & Kinematics (Vectorized)
1769
+ # ==========================================
1770
+
1771
+ # Get relative position and velocity
1772
+ # Shape: (3, n_planets, n_times) - vectorized by jaxoplanet
1773
+ pos = system.relative_position(times)
1774
+ vel = system.relative_velocity(times)
1775
+
1776
+ x, y, z = pos[0], pos[1], pos[2]
1777
+ vx, vy, vz = vel[0], vel[1], vel[2]
1778
+
1779
+ # --- Light Travel Time Correction (phase curve only) ---
1780
+ # Photons we observe at t_obs were emitted at t_em = t_obs + z/c.
1781
+ # Re-evaluate planet positions at emission times so the Lambert phase
1782
+ # function sees the correct orbital geometry. Transit, eclipse-mask,
1783
+ # beaming, and ellipsoidal use *observed* positions (no LTT).
1784
+ x_ltt, y_ltt, z_ltt = _compute_ltt_positions(system, times, z)
1785
+
1786
+ # ==========================================
1787
+ # 3. Compute Flux Components (Fully Vectorized)
1788
+ # ==========================================
1789
+
1790
+ # --- Component 1: Primary Transits ---
1791
+ u_limb_dark = reparametrize_LD(q_limb_dark[0], q_limb_dark[1])
1792
+ # Raw transit computed with b_safe — always finite
1793
+ lc_transit_raw = limb_dark_light_curve(system, u_limb_dark)(
1794
+ times
1795
+ ) # (n_times, n_planets)
1796
+ # Zero out planets that have no transit (b >= 1 + k)
1797
+ lc_transit_raw = lc_transit_raw * transit_occurs[None, :] # broadcast mask
1798
+ lc_transit = jnp.sum(lc_transit_raw, axis=-1)
1799
+
1800
+ if transit_only:
1801
+ # Positions, velocities and LTT positions computed above do not reach
1802
+ # these outputs, so XLA removes them from the compiled graph.
1803
+ zeros = jnp.zeros((t0s.shape[0], times.shape[0]), dtype=lc_transit.dtype)
1804
+ return lc_transit, zeros, zeros, zeros, lc_transit, zeros
1805
+
1806
+ # --- Component 2: Phase Curve (Vectorized over planets and time) ---
1807
+ #
1808
+ # HOTSPOT OFFSET PHYSICS:
1809
+ # -----------------------
1810
+ # The substellar point is where the star is directly overhead on the planet.
1811
+ # For a tidally locked planet, this point is fixed on the planet surface.
1812
+ #
1813
+ # The hotspot (peak thermal emission) may be OFFSET from the substellar point
1814
+ # due to atmospheric circulation (winds carry heat eastward/westward).
1815
+ #
1816
+ # phase_offset defines WHERE on the planet the hotspot is located:
1817
+ # - phase_offset = 0: Hotspot at substellar point (no offset)
1818
+ # - phase_offset > 0: Hotspot shifted EAST of substellar (evening terminator)
1819
+ # Peak brightness occurs AFTER secondary eclipse
1820
+ # - phase_offset < 0: Hotspot shifted WEST of substellar (morning terminator)
1821
+ # Peak brightness occurs BEFORE secondary eclipse
1822
+ #
1823
+ # The key insight: we need a SIGNED orbital phase to distinguish pre/post eclipse.
1824
+ # The orbital_phase_signed gives us the planet's position in its orbit:
1825
+ # - φ_orb = 0 at secondary eclipse (substellar point faces us)
1826
+ # - φ_orb > 0 after secondary eclipse (planet moved east)
1827
+ # - φ_orb < 0 before secondary eclipse (planet approaching from west)
1828
+ #
1829
+ # To see maximum brightness from the hotspot, we need:
1830
+ # φ_orb = phase_offset
1831
+ # i.e., the planet must have moved past secondary eclipse by angle phase_offset
1832
+ # for the hotspot to directly face us.
1833
+ #
1834
+ # The "viewing angle" to the hotspot is: |φ_orb - phase_offset|
1835
+ # This is the angle between our line of sight and the hotspot direction.
1836
+
1837
+ # OBSERVED orbital phase — used for ellipsoidal variations (tidal
1838
+ # bulge follows the planet's instantaneous position, no LTT needed)
1839
+ # Shape: (n_planets, n_times)
1840
+ orbital_phase_signed = compute_orbital_phase_signed(x, y, z)
1841
+
1842
+ # LTT-CORRECTED orbital phase — used for thermal phase curve.
1843
+ # Evaluates where the planet *was* when it emitted the photons we
1844
+ # receive at t_obs. This shifts the peak by ~a/c ≈ 25 s for a
1845
+ # typical hot Jupiter, detectable with JWST-class precision.
1846
+ orbital_phase_ltt = compute_orbital_phase_signed(x_ltt, y_ltt, z_ltt)
1847
+
1848
+ # Compute viewing angle to hotspot with PROPER ANGLE WRAPPING
1849
+ # This is the angle between our line of sight and the hotspot on the planet
1850
+ # phase_offsets shape: (n_planets,) -> broadcast to (n_planets, 1)
1851
+ #
1852
+ # The raw difference can be in [-2π, 2π], which causes discontinuities
1853
+ # when orbital_phase_signed crosses ±π at transit.
1854
+ # We use atan2(sin, cos) to properly wrap to [-π, π] before taking abs.
1855
+ angle_diff = orbital_phase_ltt - phase_offsets[:, None]
1856
+
1857
+ # Wrap angle difference to [-π, π] using atan2 (handles all edge cases)
1858
+ # This ensures smooth transitions across the transit (where φ jumps from +π to -π)
1859
+ angle_diff_wrapped = jnp.arctan2(jnp.sin(angle_diff), jnp.cos(angle_diff))
1860
+
1861
+ # Convert to [0, π] for the Lambert phase function
1862
+ # The Lambert function expects:
1863
+ # α = 0: hotspot directly facing us (maximum brightness)
1864
+ # α = π: hotspot on far side (minimum brightness)
1865
+ hotspot_viewing_angle = jnp.abs(angle_diff_wrapped)
1866
+
1867
+ # Compute phase curve modulation using Lambert law
1868
+ # Shape: (n_planets, n_times)
1869
+ modulation = getLambertPhaseCurve(hotspot_viewing_angle)
1870
+
1871
+ # Vectorized computation of phase curve flux
1872
+ # amp_day calculated once, broadcast properly
1873
+ amp_day = amp_day_minus_amp_night + amp_night
1874
+ lc_phase = amp_night[:, None] + (amp_day[:, None] - amp_night[:, None]) * modulation
1875
+
1876
+ # --- Component 2a: Smooth Secondary Eclipse Masking ---
1877
+ # During secondary eclipse, the STAR occults the PLANET'S thermal emission.
1878
+ # We use jaxoplanet's emission model which computes smooth ingress/egress
1879
+ # profiles based on geometric occultation.
1880
+ #
1881
+ # The emission API calculates limb geometry, producing continuous and
1882
+ # differentiable eclipse profiles suitable for gradient-based inference.
1883
+
1884
+ # Build emission system with planet surface properties
1885
+ star_surface = Surface(u=u_limb_dark)
1886
+
1887
+ # Create planet bodies and surfaces for emission calculation
1888
+ # Uses b_safe to keep jaxoplanet geometry finite; masking applied below
1889
+ planet_bodies_with_surfaces = []
1890
+ for i in range(n_planets):
1891
+ # Define planet body with orbital parameters
1892
+ planet_body = Body(
1893
+ period=periods[i],
1894
+ time_transit=t0s[i],
1895
+ radius=rp_by_rs[i],
1896
+ impact_param=b_safe[i],
1897
+ eccentricity=None if circular else eccentricities[i],
1898
+ omega_peri=None if circular else omegas[i],
1899
+ )
1900
+ # A planet surface of unit emission: its light curve is the visible
1901
+ # fraction of the planetary disc (1 out of occultation, 0 when hidden),
1902
+ # the eclipse mask itself. The day-side amplitude multiplies lc_phase
1903
+ # above, so no division by it is needed: dividing by amp_day * 1e-6 gave
1904
+ # 0/0 = NaN in float32 when the sampler drove amp_day to ~1e-35, whose
1905
+ # product with 1e-6 is a denormal that the hardware flushes to zero.
1906
+ planet_surface = Surface(
1907
+ amplitude=1.0,
1908
+ u=(), # No limb darkening for planet emission
1909
+ normalize=False, # keep the unit amplitude
1910
+ )
1911
+ planet_bodies_with_surfaces.append((planet_body, planet_surface))
1912
+
1913
+ # Build emission system for smooth eclipse calculation
1914
+ emission_system = SurfaceSystem(
1915
+ central=star, central_surface=star_surface, bodies=planet_bodies_with_surfaces
1916
+ )
1917
+
1918
+ # Compute emission light curves with smooth secondary eclipse profiles
1919
+ # Output shape: (n_times, n_planets+1) where:
1920
+ # - Column 0: stellar baseline (not used)
1921
+ # - Column i+1: planet i's emission * smooth_eclipse_mask
1922
+ emission_flux = emission_lc(emission_system)(times)
1923
+
1924
+ # Eclipse masks, shape (n_planets, n_times): 0 = fully eclipsed, 1 = fully visible
1925
+ eclipse_masks = emission_flux[:, 1:].T
1926
+
1927
+ # Zero out eclipse masks for planets with no transit/eclipse (b >= 1+k)
1928
+ # When transit_occurs is False, the eclipse mask should be 1.0 (no occultation)
1929
+ eclipse_masks = jnp.where(transit_occurs[:, None], eclipse_masks, 1.0)
1930
+
1931
+ # Apply smooth secondary eclipse masks to phase curves (element-wise multiplication)
1932
+ lc_phase = lc_phase * eclipse_masks
1933
+
1934
+ # --- Component 3: Doppler Beaming ---
1935
+ # The star brightens when its reflex motion is TOWARD the observer
1936
+ # (negative stellar RV), so beaming = -amp_beam * RV_star / K. With
1937
+ # jaxoplanet's conventions the planet's relative vz equals -RV_star (both
1938
+ # scaled by K): vz / K = -RV_star / K, i.e. +1 at phase 0.25 after transit,
1939
+ # where the star approaches.
1940
+ # K_amp may be zero for LC-only cases; avoid division-by-zero by using a
1941
+ # safe denominator and masking the ratio to zero when K_amp == 0.
1942
+ K_amp_safe = jnp.where(K_amp[:, None] != 0.0, K_amp[:, None], 1.0)
1943
+ ratio = vz / K_amp_safe
1944
+ ratio = jnp.where(K_amp[:, None] != 0.0, ratio, 0.0)
1945
+ lc_beam = amp_beam[:, None] * ratio
1946
+
1947
+ # --- Component 4: Ellipsoidal Variations (Fully Vectorized) ---
1948
+ # The planet's gravity deforms the star into an ellipsoid, with brightness maxima
1949
+ # at quadrature (twice per orbit, when viewing the elongated axis).
1950
+ #
1951
+ # TIDAL LAG PHYSICS:
1952
+ # The tidal bulge follows the planet's orbital motion, but lags behind due to:
1953
+ # - Finite response time of stellar material (viscous dissipation)
1954
+ # - Stellar rotation (if rotating faster/slower than orbital period)
1955
+ #
1956
+ # The lag is typically small (few degrees) and follows the planet's motion
1957
+ # (not a uniform rotation) because the tidal force is always directed
1958
+ # along the star-planet line.
1959
+ #
1960
+ # IMPORTANT: Must use SIGNED orbital phase (orbital_phase_signed) to properly
1961
+ # account for the directional lag. Using unsigned phase would make the lag
1962
+ # symmetric (same before/after eclipse), which is physically incorrect.
1963
+ #
1964
+ # Formula: EV ~ cos(2 * (φ - δ_lag))
1965
+ # where φ = SIGNED orbital phase, δ_lag = tidal lag angle
1966
+ # We SUBTRACT the lag because the bulge lags BEHIND the planet's position
1967
+ # Factor of 2: two maxima per orbit (both quadratures)
1968
+ lc_ellip = -amp_ellip[:, None] * jnp.cos(
1969
+ 2 * (orbital_phase_signed - tidal_lag[:, None])
1970
+ )
1971
+
1972
+ # ==========================================
1973
+ # 4. Combine and Return (Vectorized Summation)
1974
+ # ==========================================
1975
+
1976
+ # Sum over all planets (axis=0)
1977
+ total_phase_effects = jnp.sum(lc_phase + lc_beam + lc_ellip, axis=0)
1978
+
1979
+ # Return NORMALIZED offset (flux - 1.0, i.e. normalized stellar baseline removed)
1980
+ # for consistent additive model structure
1981
+ # This allows uniform treatment in the MC sampler: observed_flux = 1.0 + planet_offset + poly + fourier + GP
1982
+
1983
+ planet_offset = lc_transit + total_phase_effects * 1e-6
1984
+
1985
+ # Radial velocities (vectorized)
1986
+ rvs = system.radial_velocity(times)
1987
+
1988
+ return planet_offset, lc_phase, lc_beam, lc_ellip, lc_transit, rvs
1989
+
1990
+
1991
+ # ==============================================================================
1992
+ # Exposure-time integration (per-point supersampling)
1993
+ # ==============================================================================
1994
+
1995
+ # Default number of uniformly-spaced sub-samples per exposure for the Kipping
1996
+ # (2010) resampling scheme. Must be odd; 7 is the standard
1997
+ # (used by batman, exoplanet, jaxoplanet's built-in scalar integrator).
1998
+ SUPERSAMPLE_NUM = 7
1999
+
2000
+
2001
+ def exposure_average(
2002
+ fn: Any, times: jax.Array, exptime_seconds: Any, **kwargs: Any
2003
+ ) -> jax.Array:
2004
+ """Average ``fn(times=..., **kwargs)`` over each exposure (Kipping 2010).
2005
+
2006
+ Each time is replaced by ``SUPERSAMPLE_NUM`` equally spaced sub-samples over
2007
+ ``[t - exptime/2, t + exptime/2]`` and the results are averaged, as for the
2008
+ light curves (:func:`compute_lightcurve_supersampled`). Used for the RM
2009
+ anomaly of RV instruments with ``EXPTIME > 0``: through ingress and egress
2010
+ the anomaly changes within a 10-minute exposure. Points with zero exposure
2011
+ time get the instantaneous value.
2012
+ """
2013
+ ns = SUPERSAMPLE_NUM
2014
+ dt = jnp.linspace(-0.5, 0.5, ns, dtype=times.dtype)
2015
+ exp_days = jnp.asarray(exptime_seconds, dtype=times.dtype) / SECONDS_PER_DAY
2016
+ t_sub = times[:, None] + dt[None, :] * exp_days[:, None]
2017
+ out = fn(times=t_sub.reshape(-1), **kwargs)
2018
+ return jnp.mean(out.reshape(times.shape[0], ns), axis=1)
2019
+
2020
+
2021
+ @partial(jit, static_argnames=("transit_only", "circular"))
2022
+ def compute_lightcurve_supersampled(
2023
+ times: jax.Array,
2024
+ exptime_seconds: jax.Array,
2025
+ q_limb_dark: jax.Array,
2026
+ rho_star: float | jax.Array,
2027
+ t0s: jax.Array,
2028
+ periods: jax.Array,
2029
+ b_params: jax.Array,
2030
+ rp_by_rs: jax.Array,
2031
+ ecosomega: jax.Array,
2032
+ esinomega: jax.Array,
2033
+ amp_night: jax.Array,
2034
+ amp_day_minus_amp_night: jax.Array,
2035
+ amp_beam: jax.Array,
2036
+ amp_ellip: jax.Array,
2037
+ phase_offsets: jax.Array,
2038
+ tidal_lag: jax.Array,
2039
+ K_amp: jax.Array,
2040
+ transit_only: bool = False,
2041
+ circular: bool = False,
2042
+ ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array, jax.Array, jax.Array]:
2043
+ """
2044
+ Compute multi-planet lightcurve with **per-point** exposure-time integration.
2045
+
2046
+ Wraps :func:`compute_lightcurve` with the Kipping (2010) resampling scheme:
2047
+ each data point is evaluated at ``SUPERSAMPLE_NUM`` equally-spaced sub-sample
2048
+ times spanning ``[t − exptime/2, t + exptime/2]``, then averaged (rectangle
2049
+ rule). This convolves the model with a top-hat kernel of width ``exptime``.
2050
+
2051
+ **Pre-condition (enforced by the caller):** all ``N`` rows passed here must
2052
+ have ``exptime_seconds > 0``. Filtering out zero-exptime rows before
2053
+ calling this function is the responsibility of :func:`_compute_planet_model`
2054
+ in ``PyAETNA/sampling/engine/runner.py``, which pre-computes static per-instrument per-point split
2055
+ indices and routes zero-exptime rows to :func:`compute_lightcurve` instead.
2056
+ Calling this function with ``exptime == 0`` rows wastes
2057
+ ``SUPERSAMPLE_NUM − 1`` redundant evaluations per such point — that
2058
+ inefficiency has been eliminated at the call site.
2059
+
2060
+ Because the integration is vectorised over all ``N`` data points
2061
+ simultaneously (single call to ``compute_lightcurve`` with
2062
+ ``N × SUPERSAMPLE_NUM`` times), the function is fully JIT-compatible and
2063
+ GPU-friendly.
2064
+
2065
+ .. note:: **Why per-point rather than per-visit?**
2066
+
2067
+ In practice, all points within a visit usually share the same
2068
+ exposure time, so one might expect a scalar-per-visit approach to be
2069
+ faster. However, for JAX the difference is negligible: the
2070
+ ``compute_lightcurve`` call at ``N × 7`` points dominates the budget,
2071
+ and the per-point broadcast that builds the sub-sample grid is
2072
+ essentially free. A per-visit loop would *reduce* vectorisation
2073
+ (more, smaller calls), and expanding a scalar back to an ``(N,)``
2074
+ array would be identical to the current design. Keeping a single
2075
+ ``(N,)`` shape also avoids shape-dependent JIT recompilations.
2076
+
2077
+ Parameters
2078
+ ----------
2079
+ times : array, shape (N,)
2080
+ Mid-exposure times [days], same reference frame as orbital elements.
2081
+ All rows must have a corresponding positive ``exptime_seconds`` value.
2082
+ exptime_seconds : array, shape (N,)
2083
+ Per-point exposure duration [seconds] — must be **> 0** for every row
2084
+ (caller must filter out zero-exptime points before calling this
2085
+ function).
2086
+ q_limb_dark … K_amp :
2087
+ Same parameters as :func:`compute_lightcurve`.
2088
+
2089
+ Returns
2090
+ -------
2091
+ Identical 6-tuple as :func:`compute_lightcurve`, with each output averaged
2092
+ over the exposure window:
2093
+
2094
+ * ``planet_offset`` — shape ``(N,)``
2095
+ * ``lc_phase`` — shape ``(n_planets, N)``
2096
+ * ``lc_beam`` — shape ``(n_planets, N)``
2097
+ * ``lc_ellip`` — shape ``(n_planets, N)``
2098
+ * ``lc_transit`` — shape ``(N,)``
2099
+ * ``rvs`` — shape ``(n_planets, N)``
2100
+ """
2101
+ n_times = times.shape[0]
2102
+ ns = SUPERSAMPLE_NUM
2103
+ exptime_days = exptime_seconds / SECONDS_PER_DAY
2104
+
2105
+ # Sub-sample offsets: equally spaced in [-0.5, 0.5] × exptime_days.
2106
+ # The dtype must follow ``times``: a default-dtype linspace is float64
2107
+ # whenever x64 is enabled, and would silently promote the whole transit
2108
+ # model (Kepler solver included) out of the float32 firewall, making it
2109
+ # ~20x slower on consumer GPUs.
2110
+ dt = jnp.linspace(-0.5, 0.5, ns, dtype=times.dtype) # (ns,)
2111
+ t_sub = times[:, None] + dt[None, :] * exptime_days[:, None] # (N, ns)
2112
+ t_flat = t_sub.reshape(-1) # (N * ns,)
2113
+
2114
+ # Evaluate model at all sub-sample times in a single vectorised call
2115
+ (
2116
+ planet_offset_flat,
2117
+ lc_phase_flat,
2118
+ lc_beam_flat,
2119
+ lc_ellip_flat,
2120
+ lc_transit_flat,
2121
+ rvs_flat,
2122
+ ) = compute_lightcurve(
2123
+ t_flat,
2124
+ q_limb_dark,
2125
+ rho_star,
2126
+ t0s,
2127
+ periods,
2128
+ b_params,
2129
+ rp_by_rs,
2130
+ ecosomega,
2131
+ esinomega,
2132
+ amp_night,
2133
+ amp_day_minus_amp_night,
2134
+ amp_beam,
2135
+ amp_ellip,
2136
+ phase_offsets,
2137
+ tidal_lag,
2138
+ K_amp,
2139
+ transit_only=transit_only,
2140
+ circular=circular,
2141
+ )
2142
+
2143
+ # Average over sub-samples — 1-D arrays (n_times * ns,) → (n_times,)
2144
+ planet_offset = jnp.mean(planet_offset_flat.reshape(n_times, ns), axis=1)
2145
+ lc_transit = jnp.mean(lc_transit_flat.reshape(n_times, ns), axis=1)
2146
+
2147
+ # 2-D per-planet arrays: (n_planets, n_times * ns) → (n_planets, n_times)
2148
+ n_planets = lc_phase_flat.shape[0]
2149
+ lc_phase = jnp.mean(lc_phase_flat.reshape(n_planets, n_times, ns), axis=2)
2150
+ lc_beam = jnp.mean(lc_beam_flat.reshape(n_planets, n_times, ns), axis=2)
2151
+ lc_ellip = jnp.mean(lc_ellip_flat.reshape(n_planets, n_times, ns), axis=2)
2152
+ rvs = jnp.mean(rvs_flat.reshape(n_planets, n_times, ns), axis=2)
2153
+
2154
+ return planet_offset, lc_phase, lc_beam, lc_ellip, lc_transit, rvs
2155
+
2156
+
2157
+ if __name__ == "__main__":
2158
+ q_limb_dark = jnp.array([0.1, 0.3]) # Limb darkening
2159
+ t0s = jnp.array([0.0, 0.5])
2160
+ periods = jnp.array([1.5, 4.0]) # Orbital periods [days]
2161
+ b_params = jnp.array([0.1, 0.0])
2162
+ rp_by_rs = jnp.array([0.01, 0.1])
2163
+ ecosomega = jnp.array([0.0, 0.0]) # between -1 and 1
2164
+ esinomega = jnp.array([0.0, 0.0]) # between -1 and 1
2165
+ amp_night = jnp.array([0, 0]) # in ppm
2166
+ amp_day_minus_amp_night = jnp.array([0, 0]) # in ppm
2167
+ amp_beam = jnp.array([0, 1]) # in ppm
2168
+ amp_ellip = jnp.array([0, 5]) # in ppm
2169
+ phase_offsets = jnp.array([0.0, jnp.pi / 4]) # in radians
2170
+ tidal_lag = jnp.array([0.0, jnp.pi / 4]) # in radians
2171
+ K_amp = jnp.array([1e-10, 100]) # in m/s
2172
+
2173
+ # ==========================================
2174
+ # Run and Visualize - Demonstrate Flexibility
2175
+ # ==========================================
2176
+ # Time grid: 4 days (covers 1 orbit of Planet c)
2177
+ t_grid = jnp.linspace(0, 4.0, 1000)
2178
+
2179
+ # Method 1: Use stellar density (can be sampled in MCMC)
2180
+ rho_star = 1.4 # g/cm³ (solar-like)
2181
+ planet_offset1, phases1, beams1, ellips1, transits1, rvs1 = compute_lightcurve(
2182
+ t_grid,
2183
+ q_limb_dark,
2184
+ rho_star,
2185
+ t0s,
2186
+ periods,
2187
+ b_params,
2188
+ rp_by_rs,
2189
+ ecosomega,
2190
+ esinomega,
2191
+ amp_night,
2192
+ amp_day_minus_amp_night,
2193
+ amp_beam,
2194
+ amp_ellip,
2195
+ phase_offsets,
2196
+ tidal_lag,
2197
+ K_amp,
2198
+ )
2199
+ flux1 = 1.0 * 0 + planet_offset1
2200
+
2201
+ # Setup Plot
2202
+ fig, axes = plt.subplots(6, 1, figsize=(10, 12), sharex=True)
2203
+
2204
+ # 1. Total Lightcurve (with flexible stellar density)
2205
+ axes[0].plot(t_grid, flux1, "k", lw=1.5)
2206
+ axes[0].set_title(
2207
+ f"Total Lightcurve (ρ_star = {rho_star:.2f} g/cm³, Dimensionless & Flexible)",
2208
+ fontsize=12,
2209
+ weight="bold",
2210
+ )
2211
+ axes[0].set_ylabel("Relative Flux")
2212
+
2213
+ # 2. Phase Curves (Thermal/Reflected) - use first model's components
2214
+ # Sum over planets to show total signal for that component
2215
+ axes[1].plot(t_grid, jnp.sum(phases1, axis=0), "C1")
2216
+ axes[1].set_ylabel("Phase Curve\n(ppm)")
2217
+ axes[1].text(
2218
+ 0.02,
2219
+ 0.85,
2220
+ "Note: Cuts to 0 during Secondary Eclipse",
2221
+ transform=axes[1].transAxes,
2222
+ fontsize=9,
2223
+ )
2224
+
2225
+ # 3. Doppler Beaming
2226
+ axes[2].plot(t_grid, jnp.sum(beams1, axis=0), "C2")
2227
+ axes[2].set_ylabel("Doppler Beaming\n(ppm)")
2228
+
2229
+ # 4. Ellipsoidal Variations
2230
+ axes[3].plot(t_grid, jnp.sum(ellips1, axis=0), "C3")
2231
+ axes[3].set_ylabel("Ellipsoidal\n(ppm)")
2232
+
2233
+ # 5. Transits
2234
+ axes[4].plot(t_grid, transits1 * 1e6, "C0")
2235
+ axes[4].set_ylabel("Transits\n(ppm)")
2236
+ axes[4].set_xlabel("Time [days]")
2237
+
2238
+ # 6. RVs
2239
+ axes[5].plot(t_grid, jnp.sum(rvs1, axis=0), "C4")
2240
+ axes[5].set_ylabel("Radial Velocity\n(m/s)")
2241
+ axes[5].set_xlabel("Time [days]")
2242
+
2243
+ for ax in axes:
2244
+ ax.grid(True, alpha=0.3)
2245
+
2246
+ plt.tight_layout()
2247
+ plt.show()
2248
+
2249
+ # ==========================================
2250
+ # Additional Test: Phase Offset Comparison
2251
+ # ==========================================
2252
+ # print("\n=== Phase Offset Test ===")
2253
+
2254
+ # Single planet test with different offsets
2255
+ t_test = jnp.linspace(0, 4.0, 500) # One orbit of 4-day planet
2256
+
2257
+ fig2, axes2 = plt.subplots(2, 1, figsize=(12, 8), sharex=True)
2258
+
2259
+ # Test 1: Circular orbit, different phase offsets
2260
+ offsets_to_test = [0.0, jnp.pi / 6, jnp.pi / 4, jnp.pi / 3]
2261
+ colors = ["C0", "C1", "C2", "C3"]
2262
+
2263
+ for offset, color in zip(offsets_to_test, colors):
2264
+ _, phases_test, _, _, _, _ = compute_lightcurve(
2265
+ t_test,
2266
+ q_limb_dark,
2267
+ rho_star,
2268
+ jnp.array([0.0]),
2269
+ jnp.array([4.0]),
2270
+ jnp.array([0.1]),
2271
+ jnp.array([0.1]),
2272
+ jnp.array([0.0]),
2273
+ jnp.array([0.0]), # Circular
2274
+ jnp.array([100.0]),
2275
+ jnp.array([800.0]),
2276
+ jnp.array([0.0]),
2277
+ jnp.array([0.0]),
2278
+ jnp.array([offset]),
2279
+ jnp.array([0.0]),
2280
+ jnp.array([100.0]),
2281
+ )
2282
+ label = f"offset = {offset * 180 / jnp.pi:.0f}°"
2283
+ axes2[0].plot(t_test, phases_test[0], color=color, label=label, lw=1.5)
2284
+
2285
+ axes2[0].axvline(2.0, color="k", ls="--", alpha=0.5, label="Secondary Eclipse")
2286
+ axes2[0].set_ylabel("Phase Curve (ppm)")
2287
+ axes2[0].set_title(
2288
+ "Circular Orbit: Phase Offset Comparison", fontsize=12, weight="bold"
2289
+ )
2290
+ axes2[0].legend(loc="upper right")
2291
+ axes2[0].grid(True, alpha=0.3)
2292
+
2293
+ # Test 2: Eccentric orbit (e=0.3) with same offsets
2294
+ ecc = 0.3
2295
+ omega = jnp.pi / 4 # Argument of periastron
2296
+ ec_test = jnp.sqrt(2 * ecc) * jnp.cos(omega)
2297
+ es_test = jnp.sqrt(2 * ecc) * jnp.sin(omega)
2298
+
2299
+ for offset, color in zip(offsets_to_test, colors):
2300
+ _, phases_test, _, _, _, _ = compute_lightcurve(
2301
+ t_test,
2302
+ q_limb_dark,
2303
+ rho_star,
2304
+ jnp.array([0.0]),
2305
+ jnp.array([4.0]),
2306
+ jnp.array([0.1]),
2307
+ jnp.array([0.1]),
2308
+ jnp.array([ec_test]),
2309
+ jnp.array([es_test]), # Eccentric!
2310
+ jnp.array([100.0]),
2311
+ jnp.array([800.0]),
2312
+ jnp.array([0.0]),
2313
+ jnp.array([0.0]),
2314
+ jnp.array([offset]),
2315
+ jnp.array([0.0]),
2316
+ jnp.array([100.0]),
2317
+ )
2318
+ label = f"offset = {offset * 180 / jnp.pi:.0f}°"
2319
+ axes2[1].plot(t_test, phases_test[0], color=color, label=label, lw=1.5)
2320
+
2321
+ axes2[1].set_ylabel("Phase Curve (ppm)")
2322
+ axes2[1].set_xlabel("Time [days]")
2323
+ axes2[1].set_title(
2324
+ f"Eccentric Orbit (e={ecc:.1f}, ω={omega * 180 / jnp.pi:.0f}°): Phase Offset Comparison",
2325
+ fontsize=12,
2326
+ weight="bold",
2327
+ )
2328
+ axes2[1].legend(loc="upper right")
2329
+ axes2[1].grid(True, alpha=0.3)
2330
+
2331
+ plt.tight_layout()
2332
+ plt.show()
2333
+
2334
+ print("Phase offset = 0°: Peak at secondary eclipse")
2335
+ print("Phase offset > 0°: Peak shifted AFTER secondary eclipse (eastward hotspot)")
2336
+ print("Phase offset < 0°: Peak shifted BEFORE secondary eclipse (westward hotspot)")