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.
- PyAETNA/__init__.py +9 -0
- PyAETNA/__main__.py +5 -0
- PyAETNA/_device_config.py +57 -0
- PyAETNA/_xla_flags.py +50 -0
- PyAETNA/body_models.py +2336 -0
- PyAETNA/cli.py +52 -0
- PyAETNA/gp.py +791 -0
- PyAETNA/gp_parallel.py +172 -0
- PyAETNA/kernels/factory.py +823 -0
- PyAETNA/kernels/multichannel.py +417 -0
- PyAETNA/logger.py +73 -0
- PyAETNA/model_utils.py +282 -0
- PyAETNA/outputs.py +260 -0
- PyAETNA/physics/__init__.py +6 -0
- PyAETNA/physics/orbital.py +479 -0
- PyAETNA/physics/plotting.py +767 -0
- PyAETNA/physics/priors.py +89 -0
- PyAETNA/physics/spectra.py +483 -0
- PyAETNA/physics/stellar.py +369 -0
- PyAETNA/planet_phys.py +1414 -0
- PyAETNA/provenance.py +163 -0
- PyAETNA/py.typed +0 -0
- PyAETNA/reporting/__init__.py +64 -0
- PyAETNA/reporting/confidence_bands.py +1238 -0
- PyAETNA/reporting/corner_plot.py +1204 -0
- PyAETNA/reporting/data_validation.py +116 -0
- PyAETNA/reporting/diagnostics.py +580 -0
- PyAETNA/reporting/gp_plotting.py +942 -0
- PyAETNA/reporting/grid_builder.py +1257 -0
- PyAETNA/reporting/model_components.py +894 -0
- PyAETNA/reporting/model_math.py +1096 -0
- PyAETNA/reporting/multigp_plots.py +1681 -0
- PyAETNA/reporting/noise_plots.py +406 -0
- PyAETNA/reporting/panel_detrended.py +188 -0
- PyAETNA/reporting/panel_nontime_residuals.py +467 -0
- PyAETNA/reporting/panel_overlay.py +745 -0
- PyAETNA/reporting/panel_residuals.py +109 -0
- PyAETNA/reporting/param_sorting.py +155 -0
- PyAETNA/reporting/pdf_report.py +2119 -0
- PyAETNA/reporting/periodogram_plots.py +1467 -0
- PyAETNA/reporting/phase_plots.py +1529 -0
- PyAETNA/reporting/plot_data_prep.py +1123 -0
- PyAETNA/reporting/plot_models.py +177 -0
- PyAETNA/reporting/plot_styling.py +468 -0
- PyAETNA/reporting/residual_plotting.py +419 -0
- PyAETNA/reporting/trend_detection.py +128 -0
- PyAETNA/reporting/ttv_plots.py +1226 -0
- PyAETNA/run.py +813 -0
- PyAETNA/sampling/__init__.py +19 -0
- PyAETNA/sampling/centering.py +331 -0
- PyAETNA/sampling/config/parser/__init__.py +602 -0
- PyAETNA/sampling/config/parser/_constants.py +14 -0
- PyAETNA/sampling/config/parser/detrending.py +825 -0
- PyAETNA/sampling/config/parser/gp.py +386 -0
- PyAETNA/sampling/config/parser/multigp.py +697 -0
- PyAETNA/sampling/config/parser/ordering.py +154 -0
- PyAETNA/sampling/config/parser/planets.py +699 -0
- PyAETNA/sampling/config/parser/prior_refs.py +157 -0
- PyAETNA/sampling/config/parser/scoping.py +465 -0
- PyAETNA/sampling/config/parser/star.py +647 -0
- PyAETNA/sampling/config/priors.py +266 -0
- PyAETNA/sampling/config/section.py +51 -0
- PyAETNA/sampling/config/validation.py +231 -0
- PyAETNA/sampling/data.py +243 -0
- PyAETNA/sampling/eccentricity.py +65 -0
- PyAETNA/sampling/energy.py +431 -0
- PyAETNA/sampling/engine/checkpoint.py +461 -0
- PyAETNA/sampling/engine/context.py +24 -0
- PyAETNA/sampling/engine/convergence.py +126 -0
- PyAETNA/sampling/engine/data_loader.py +335 -0
- PyAETNA/sampling/engine/eiv.py +458 -0
- PyAETNA/sampling/engine/ensemble_kernels.py +238 -0
- PyAETNA/sampling/engine/exceptions.py +47 -0
- PyAETNA/sampling/engine/execution.py +500 -0
- PyAETNA/sampling/engine/numpyro_model.py +1031 -0
- PyAETNA/sampling/engine/runner.py +150 -0
- PyAETNA/sampling/engine/sampler_settings.py +192 -0
- PyAETNA/sampling/engine/sampler_utils.py +404 -0
- PyAETNA/sampling/engine/sampling_logging.py +292 -0
- PyAETNA/sampling/engine/sampling_utils.py +707 -0
- PyAETNA/sampling/model/core.py +774 -0
- PyAETNA/sampling/model/detrending.py +315 -0
- PyAETNA/sampling/nested_sampling.py +1457 -0
- PyAETNA/sampling/peaks.py +1225 -0
- PyAETNA/sampling/precision.py +393 -0
- PyAETNA/sampling/protocols.py +140 -0
- PyAETNA/sampling/utils.py +299 -0
- PyAETNA/utils/__init__.py +4 -0
- PyAETNA/utils/constants.py +26 -0
- PyAETNA/version.py +21 -0
- pyaetna-4.0.0.dist-info/METADATA +269 -0
- pyaetna-4.0.0.dist-info/RECORD +96 -0
- pyaetna-4.0.0.dist-info/WHEEL +5 -0
- pyaetna-4.0.0.dist-info/entry_points.txt +2 -0
- pyaetna-4.0.0.dist-info/licenses/LICENSE +21 -0
- 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)")
|