PyAETNA 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. PyAETNA/__init__.py +9 -0
  2. PyAETNA/__main__.py +5 -0
  3. PyAETNA/_device_config.py +57 -0
  4. PyAETNA/_xla_flags.py +50 -0
  5. PyAETNA/body_models.py +2336 -0
  6. PyAETNA/cli.py +52 -0
  7. PyAETNA/gp.py +791 -0
  8. PyAETNA/gp_parallel.py +172 -0
  9. PyAETNA/kernels/factory.py +823 -0
  10. PyAETNA/kernels/multichannel.py +417 -0
  11. PyAETNA/logger.py +73 -0
  12. PyAETNA/model_utils.py +282 -0
  13. PyAETNA/outputs.py +260 -0
  14. PyAETNA/physics/__init__.py +6 -0
  15. PyAETNA/physics/orbital.py +479 -0
  16. PyAETNA/physics/plotting.py +767 -0
  17. PyAETNA/physics/priors.py +89 -0
  18. PyAETNA/physics/spectra.py +483 -0
  19. PyAETNA/physics/stellar.py +369 -0
  20. PyAETNA/planet_phys.py +1414 -0
  21. PyAETNA/provenance.py +163 -0
  22. PyAETNA/py.typed +0 -0
  23. PyAETNA/reporting/__init__.py +64 -0
  24. PyAETNA/reporting/confidence_bands.py +1238 -0
  25. PyAETNA/reporting/corner_plot.py +1204 -0
  26. PyAETNA/reporting/data_validation.py +116 -0
  27. PyAETNA/reporting/diagnostics.py +580 -0
  28. PyAETNA/reporting/gp_plotting.py +942 -0
  29. PyAETNA/reporting/grid_builder.py +1257 -0
  30. PyAETNA/reporting/model_components.py +894 -0
  31. PyAETNA/reporting/model_math.py +1096 -0
  32. PyAETNA/reporting/multigp_plots.py +1681 -0
  33. PyAETNA/reporting/noise_plots.py +406 -0
  34. PyAETNA/reporting/panel_detrended.py +188 -0
  35. PyAETNA/reporting/panel_nontime_residuals.py +467 -0
  36. PyAETNA/reporting/panel_overlay.py +745 -0
  37. PyAETNA/reporting/panel_residuals.py +109 -0
  38. PyAETNA/reporting/param_sorting.py +155 -0
  39. PyAETNA/reporting/pdf_report.py +2119 -0
  40. PyAETNA/reporting/periodogram_plots.py +1467 -0
  41. PyAETNA/reporting/phase_plots.py +1529 -0
  42. PyAETNA/reporting/plot_data_prep.py +1123 -0
  43. PyAETNA/reporting/plot_models.py +177 -0
  44. PyAETNA/reporting/plot_styling.py +468 -0
  45. PyAETNA/reporting/residual_plotting.py +419 -0
  46. PyAETNA/reporting/trend_detection.py +128 -0
  47. PyAETNA/reporting/ttv_plots.py +1226 -0
  48. PyAETNA/run.py +813 -0
  49. PyAETNA/sampling/__init__.py +19 -0
  50. PyAETNA/sampling/centering.py +331 -0
  51. PyAETNA/sampling/config/parser/__init__.py +602 -0
  52. PyAETNA/sampling/config/parser/_constants.py +14 -0
  53. PyAETNA/sampling/config/parser/detrending.py +825 -0
  54. PyAETNA/sampling/config/parser/gp.py +386 -0
  55. PyAETNA/sampling/config/parser/multigp.py +697 -0
  56. PyAETNA/sampling/config/parser/ordering.py +154 -0
  57. PyAETNA/sampling/config/parser/planets.py +699 -0
  58. PyAETNA/sampling/config/parser/prior_refs.py +157 -0
  59. PyAETNA/sampling/config/parser/scoping.py +465 -0
  60. PyAETNA/sampling/config/parser/star.py +647 -0
  61. PyAETNA/sampling/config/priors.py +266 -0
  62. PyAETNA/sampling/config/section.py +51 -0
  63. PyAETNA/sampling/config/validation.py +231 -0
  64. PyAETNA/sampling/data.py +243 -0
  65. PyAETNA/sampling/eccentricity.py +65 -0
  66. PyAETNA/sampling/energy.py +431 -0
  67. PyAETNA/sampling/engine/checkpoint.py +461 -0
  68. PyAETNA/sampling/engine/context.py +24 -0
  69. PyAETNA/sampling/engine/convergence.py +126 -0
  70. PyAETNA/sampling/engine/data_loader.py +335 -0
  71. PyAETNA/sampling/engine/eiv.py +458 -0
  72. PyAETNA/sampling/engine/ensemble_kernels.py +238 -0
  73. PyAETNA/sampling/engine/exceptions.py +47 -0
  74. PyAETNA/sampling/engine/execution.py +500 -0
  75. PyAETNA/sampling/engine/numpyro_model.py +1031 -0
  76. PyAETNA/sampling/engine/runner.py +150 -0
  77. PyAETNA/sampling/engine/sampler_settings.py +192 -0
  78. PyAETNA/sampling/engine/sampler_utils.py +404 -0
  79. PyAETNA/sampling/engine/sampling_logging.py +292 -0
  80. PyAETNA/sampling/engine/sampling_utils.py +707 -0
  81. PyAETNA/sampling/model/core.py +774 -0
  82. PyAETNA/sampling/model/detrending.py +315 -0
  83. PyAETNA/sampling/nested_sampling.py +1457 -0
  84. PyAETNA/sampling/peaks.py +1225 -0
  85. PyAETNA/sampling/precision.py +393 -0
  86. PyAETNA/sampling/protocols.py +140 -0
  87. PyAETNA/sampling/utils.py +299 -0
  88. PyAETNA/utils/__init__.py +4 -0
  89. PyAETNA/utils/constants.py +26 -0
  90. PyAETNA/version.py +21 -0
  91. pyaetna-4.0.0.dist-info/METADATA +269 -0
  92. pyaetna-4.0.0.dist-info/RECORD +96 -0
  93. pyaetna-4.0.0.dist-info/WHEEL +5 -0
  94. pyaetna-4.0.0.dist-info/entry_points.txt +2 -0
  95. pyaetna-4.0.0.dist-info/licenses/LICENSE +21 -0
  96. pyaetna-4.0.0.dist-info/top_level.txt +1 -0
PyAETNA/gp.py ADDED
@@ -0,0 +1,791 @@
1
+ """
2
+ PyAETNA GP Module - Shared Gaussian Process utilities
3
+
4
+ This module provides GP kernel building and prediction functions used by both
5
+ the sampler (PyAETNA/sampling/engine/runner.py) and reporting modules.
6
+
7
+ Centralizing GP code here ensures consistency between sampling and visualization.
8
+
9
+ Per-visit block-diagonal GP
10
+ ----------------------------
11
+ When ``per_visit: true`` is set in a GP section, the covariance matrix is
12
+ factorised into independent blocks—one per visit—while **sharing** the same
13
+ kernel hyperparameters across all visits. This is a *computational strategy*,
14
+ not a parameter-scope expansion:
15
+
16
+ .. math::
17
+
18
+ \\log p(\\mathbf{y} \\mid \\theta)
19
+ = \\sum_v \\log p(\\mathbf{y}_v \\mid \\theta)
20
+
21
+ This factorisation is valid **only when the off-diagonal inter-visit blocks of
22
+ the covariance matrix are negligible**, i.e. when the kernel decays to zero over
23
+ the inter-visit gap. Kernels that violate this condition make the factorisation
24
+ mathematically incorrect:
25
+
26
+ * **``ExpSineSquared`` (strictly periodic kernel)** — covariance never decays;
27
+ $k(nP) = \\sigma^2$ at any integer multiple of the period. Per-visit is
28
+ **contraindicated** for this kernel.
29
+ * **``SHO_Quality`` / ``SHO`` with very high quality factor** — damping is slow;
30
+ cross-visit covariances remain significant when the inter-visit gap is much
31
+ shorter than the damping time $Q / \\omega_0$. Use with caution and verify
32
+ that the fitted Q implies fast damping relative to the gap.
33
+ * **``QuasiPeriodic``** — the SE envelope makes it conditionally valid when the
34
+ inter-visit gap $\\gg \\ell_\\text{SE}$. Safe for typical multi-visit
35
+ photometry (gaps of days, SE scales of hours).
36
+
37
+ For all monotonically decaying kernels (Matérn32, Matérn52, ExpSquared, SHO
38
+ with moderate Q) the benefits are:
39
+
40
+ * **O(N³) dense kernels** — wall-clock speedup proportional to V² (number of
41
+ visits squared), since each block is ``(N/V)³`` instead of ``N³``.
42
+ * **SHO quasisep kernels** — prevents the ``-inf`` numerical catastrophe that
43
+ occurs when Kalman transition-matrix elements ``exp(-α Δt)`` underflow at
44
+ large inter-visit gaps.
45
+ * **O(N) Matérn quasisep** — no benefit (already numerically robust and
46
+ linear); a small overhead from the Python loop is incurred.
47
+
48
+ See ``docs/gp_per_visit_analysis/GP_PER_VISIT_ANALYSIS.md`` for benchmarks.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ from collections.abc import Callable
54
+ from typing import Any
55
+
56
+ import jax
57
+ import jax.numpy as jnp
58
+ import numpy as np
59
+ from tinygp import GaussianProcess
60
+
61
+ # Import kernel functions from the new dedicated kernels/factory.py module
62
+ from PyAETNA.kernels.factory import (
63
+ build_gp_kernel_for_plotting,
64
+ build_gp_kernel_for_sampling,
65
+ build_gp_kernel_from_specs,
66
+ )
67
+ from PyAETNA.logger import _log
68
+
69
+ __all__ = [
70
+ "build_gp_kernel_for_plotting",
71
+ "build_gp_kernel_for_sampling",
72
+ "build_gp_kernel_from_specs",
73
+ "compute_kernel_theoretical_psd",
74
+ "compute_windowed_psd",
75
+ "get_gp_x_variable",
76
+ "gp_log_probability_per_visit",
77
+ "gp_predict",
78
+ "gp_predict_per_visit",
79
+ ]
80
+
81
+ # =============================================================================
82
+ # GP Prediction (JIT-compiled for speed)
83
+ # =============================================================================
84
+
85
+
86
+ def gp_predict(
87
+ kernel: Any,
88
+ x: Any,
89
+ y: Any,
90
+ dy_sq: Any,
91
+ x_test: Any,
92
+ log_func: Callable[..., None] | None = None,
93
+ log_target: Any = None,
94
+ ) -> tuple[jax.Array, jax.Array]:
95
+ """
96
+ GP prediction wrapper. Handles shape validation and logging before
97
+ calling the JIT-compiled core.
98
+
99
+ Args:
100
+ kernel: tinygp kernel object
101
+ x: Training inputs (1D or 2D array)
102
+ y: Training outputs (residuals)
103
+ dy_sq: Squared uncertainties (diagonal noise)
104
+ x_test: Test inputs (usually same as x for interpolation)
105
+ log_func: Logging callable (expected signature: log_func(target, msg))
106
+ log_target: Target for logging (e.g. log file path or PlModel object)
107
+
108
+ Returns:
109
+ mean, std: Predicted mean and standard deviation at x_test
110
+ """
111
+ # Convert to arrays for shape checking
112
+ x = jnp.asarray(x)
113
+ y = jnp.asarray(y)
114
+ dy_sq = jnp.asarray(dy_sq)
115
+ x_test = jnp.asarray(x_test)
116
+
117
+ # Shape repair and validation
118
+ if dy_sq.ndim > 1:
119
+ flat = dy_sq.reshape(-1)
120
+ if flat.shape[0] == y.shape[0]:
121
+ dy_sq = flat
122
+ else:
123
+ msg = f"ERROR: dy_sq shape {dy_sq.shape} cannot be flattened to match y length {y.shape[0]}"
124
+ if log_func:
125
+ log_func(log_target, msg)
126
+ raise ValueError(msg)
127
+
128
+ if dy_sq.shape[0] != y.shape[0]:
129
+ msg = f"ERROR: dy_sq length {dy_sq.shape[0]} must match y length {y.shape[0]}"
130
+ if log_func:
131
+ log_func(log_target, msg)
132
+ raise ValueError(msg)
133
+
134
+ return _gp_predict_jit(kernel, x, y, dy_sq, x_test)
135
+
136
+
137
+ @jax.jit
138
+ def _gp_predict_jit(
139
+ kernel: Any, x: jax.Array, y: jax.Array, dy_sq: jax.Array, x_test: jax.Array
140
+ ) -> tuple[jax.Array, jax.Array]:
141
+ """Core JIT-compiled GP prediction logic."""
142
+ gp = GaussianProcess(kernel, x, diag=dy_sq)
143
+ gp_cond = gp.condition(y, x_test)
144
+ return gp_cond.gp.loc, jnp.sqrt(gp_cond.gp.variance)
145
+
146
+
147
+ # =============================================================================
148
+ # GP x-variable extraction helper
149
+ # =============================================================================
150
+
151
+
152
+ def get_gp_x_variable(
153
+ plData: Any, gp_cfg: dict[str, Any], inst_mask: Any, use_jax: bool = True
154
+ ) -> jax.Array | np.ndarray:
155
+ """
156
+ Extract GP input variable(s) for an instrument.
157
+
158
+ Args:
159
+ plData: PlData object with time and dBasis
160
+ gp_cfg: GP configuration dict with 'x_variable' or 'x_variables'
161
+ inst_mask: Boolean mask or index array for the instrument
162
+ use_jax: If True, return jax arrays. If False, return numpy arrays.
163
+
164
+ Returns:
165
+ x_inst: Input array for GP (1D or 2D depending on number of variables)
166
+ """
167
+ xp = jnp if use_jax else np
168
+
169
+ # Support both x_variable (string) and x_variables (list)
170
+ x_variables = gp_cfg.get("x_variables", gp_cfg.get("x_variable", ["TIME"]))
171
+ if isinstance(x_variables, str):
172
+ x_variables = [x_variables]
173
+
174
+ if len(x_variables) == 1:
175
+ # Single variable - 1D input
176
+ xv = x_variables[0]
177
+ if xv == "TIME":
178
+ x_var = xp.array(plData.time) if use_jax else np.array(plData.time)
179
+ else:
180
+ x_var = (
181
+ xp.array(plData.dBasis[xv]) if use_jax else np.array(plData.dBasis[xv])
182
+ )
183
+ x_inst = x_var[inst_mask]
184
+ else:
185
+ # Multiple variables - stack into 2D input
186
+ x_arrays = []
187
+ for xv in x_variables:
188
+ if xv == "TIME":
189
+ x_arrays.append(np.array(plData.time))
190
+ else:
191
+ x_arrays.append(np.array(plData.dBasis[xv]))
192
+ x_var = np.column_stack(x_arrays)
193
+ x_inst = x_var[inst_mask]
194
+ if use_jax:
195
+ x_inst = jnp.array(x_inst)
196
+
197
+ return x_inst
198
+
199
+
200
+ # =============================================================================
201
+ # Per-visit block-diagonal GP helpers
202
+ # =============================================================================
203
+
204
+
205
+ # Variance of the fake points that pad per-visit GP blocks to a common length,
206
+ # and the log-probability each of them contributes (see
207
+ # gp_log_probability_per_visit).
208
+ _PAD_VARIANCE = 1e30
209
+ _PAD_LOGPROB_CORRECTION = 0.5 * float(np.log(2.0 * np.pi * _PAD_VARIANCE))
210
+
211
+
212
+ def make_gp(
213
+ kernel: Any, x: Any, diag: Any, *, assume_sorted: bool = False
214
+ ) -> GaussianProcess:
215
+ """Build a tinygp ``GaussianProcess``, optionally skipping the sortedness check.
216
+
217
+ For quasiseparable kernels tinygp (0.3.1) checks, on every evaluation,
218
+ that the inputs are sorted, through a ``jax.debug.callback``: each likelihood call then
219
+ waits for a device-to-host transfer, which is costly on a GPU (and fails
220
+ when no CPU device is visible). PyAETNA's inputs are fixed data, so the
221
+ caller verifies the ordering once, before tracing, and passes
222
+ ``assume_sorted=True``. Dense kernels have no such check.
223
+ """
224
+ from tinygp.kernels.quasisep import Quasisep
225
+
226
+ if assume_sorted and isinstance(kernel, Quasisep):
227
+ return GaussianProcess(kernel, x, diag=diag, assume_sorted=True)
228
+ return GaussianProcess(kernel, x, diag=diag)
229
+
230
+
231
+ def is_quasiseparable(kernel: Any) -> bool:
232
+ from tinygp.kernels.quasisep import Quasisep
233
+
234
+ return isinstance(kernel, Quasisep)
235
+
236
+
237
+ def gp_log_probability(
238
+ kernel: Any,
239
+ x: Any,
240
+ diag: Any,
241
+ y: Any,
242
+ *,
243
+ assume_sorted: bool = False,
244
+ parallel: bool = False,
245
+ ) -> Any:
246
+ """GP marginal log-likelihood of residuals ``y``.
247
+
248
+ With ``parallel=True``, a quasiseparable kernel on sorted 1-D inputs is
249
+ evaluated with the parallel-in-time Kalman filter
250
+ (:mod:`PyAETNA.gp_parallel`), which gives the same value as tinygp but in
251
+ ~2 log2 N sequential steps instead of N: much faster on GPUs, slower on
252
+ CPUs. Any other case uses tinygp.
253
+ """
254
+ if parallel and assume_sorted and is_quasiseparable(kernel) and jnp.ndim(x) == 1:
255
+ from PyAETNA.gp_parallel import log_probability_parallel
256
+
257
+ return log_probability_parallel(kernel, x, diag, y)
258
+ return make_gp(kernel, x, diag, assume_sorted=assume_sorted).log_probability(y)
259
+
260
+
261
+ def resolve_gp_solver(solver: str) -> bool:
262
+ """True if the parallel quasiseparable solver should be used.
263
+
264
+ ``auto`` selects it on GPUs, where it is 40-150x faster than tinygp's
265
+ sequential scan, and not on CPUs, where it is 2-4x slower
266
+ (``paper_benchmarks/gp_solver``).
267
+ """
268
+ solver = str(solver).lower()
269
+ if solver not in ("auto", "sequential", "parallel"):
270
+ raise ValueError(
271
+ f"GP solver must be 'auto', 'sequential' or 'parallel', got {solver!r}"
272
+ )
273
+ if solver == "auto":
274
+ return jax.default_backend() == "gpu"
275
+ return solver == "parallel"
276
+
277
+
278
+ def inputs_sorted(x: Any) -> bool:
279
+ """True if a 1-D input array is non-decreasing (checked on concrete data)."""
280
+ arr = np.asarray(x)
281
+ return arr.ndim == 1 and bool(np.all(np.diff(arr) >= 0))
282
+
283
+
284
+ def gp_log_probability_per_visit(
285
+ kernel: Any,
286
+ x_inst: jax.Array,
287
+ res_inst: jax.Array,
288
+ diag_inst: jax.Array,
289
+ visit_indices: list[Any],
290
+ assume_sorted: bool = False,
291
+ parallel: bool = False,
292
+ ) -> jax.Array:
293
+ """
294
+ Compute the GP marginal log-probability using a per-visit block-diagonal
295
+ factorisation. Hyperparameters are **shared** across all visits.
296
+
297
+ This is mathematically equivalent to the full-matrix computation when
298
+ inter-visit covariance is negligible (gap >> kernel length scale), which
299
+ is the expected regime for space-based photometry (e.g. CHEOPS visits
300
+ separated by days, kernel length scales of minutes).
301
+
302
+ .. math::
303
+
304
+ \\log p(\\mathbf{y} \\mid \\theta)
305
+ = \\sum_v \\log p(\\mathbf{y}_v \\mid \\theta)
306
+
307
+ Each visit contributes an independent block of size ``n_v``, so the
308
+ total cost for O(N³) dense kernels drops from ``N³`` to
309
+ ``\\sum_v n_v^3 \\approx V \\cdot (N/V)^3 = N^3 / V^2``.
310
+
311
+ For SHO quasisep kernels this factorisation is a **numerical necessity**:
312
+ the Kalman state-transition matrix contains ``exp(-\\alpha \\Delta t)``
313
+ terms that underflow for large inter-visit gaps, producing ``-inf``.
314
+
315
+ **Compilation optimization** — All visits are padded to the same length
316
+ and evaluated via a single ``jax.vmap``-ed GP call. This replaces the
317
+ previous Python for-loop (which JAX unrolled into *n_visits* separate GP
318
+ subgraphs) with one compiled GP kernel batched across visits,
319
+ dramatically reducing XLA graph size and compilation time. Padded
320
+ positions use ``diag = 1e30`` (effectively infinite observation noise):
321
+ the Kalman gain is driven to zero, so padding cannot corrupt the
322
+ real-data likelihood contribution—it only adds a constant independent
323
+ of the kernel hyperparameters.
324
+
325
+ .. warning::
326
+
327
+ This factorisation is only valid when the kernel decays sufficiently
328
+ over the inter-visit gap, i.e. the off-diagonal cross-visit covariance
329
+ blocks must be negligible. Three specific cases break this assumption:
330
+
331
+ * **``ExpSineSquared``** (strictly periodic): covariance never decays;
332
+ ``k(n*P) == sigma^2`` for any integer ``n``. Using per-visit with
333
+ this kernel is **mathematically incorrect** and will produce biased
334
+ hyperparameter estimates. Do not combine ``ExpSineSquared`` with
335
+ ``per_visit: true``.
336
+ * **``SHO_Quality`` / ``SHO`` with high Q factor**: decay is slow;
337
+ cross-visit covariances are significant when the inter-visit gap is
338
+ much less than the damping time ``Q / omega_0``. Verify that the
339
+ posterior Q implies fast damping relative to the gap.
340
+ * **``QuasiPeriodic``**: conditionally valid when the inter-visit gap
341
+ is much larger than ``exp(log_gp_se_scale)`` (the SE length scale
342
+ that provides the decaying envelope).
343
+
344
+ Args:
345
+ kernel: tinygp kernel object (built from shared hyperparameters).
346
+ x_inst: GP input array for the instrument (1-D, sorted within visits).
347
+ res_inst: Residuals for the instrument.
348
+ diag_inst: Diagonal variance (data error² + jitter²) for the instrument.
349
+ visit_indices: List of JAX index arrays, one per visit, giving the
350
+ positions within the *instrument* arrays (not the global data array).
351
+
352
+ Returns:
353
+ Scalar GP marginal log-probability (sum over visits).
354
+ """
355
+ n_visits = len(visit_indices)
356
+
357
+ if n_visits == 0:
358
+ return jnp.zeros(())
359
+
360
+ # Single visit: no padding or vmap overhead needed.
361
+ if n_visits == 1:
362
+ v_idx = visit_indices[0]
363
+ return gp_log_probability(
364
+ kernel,
365
+ x_inst[v_idx],
366
+ diag_inst[v_idx],
367
+ res_inst[v_idx],
368
+ assume_sorted=assume_sorted,
369
+ parallel=parallel,
370
+ )
371
+
372
+ # --- Pad all visits to uniform length for a single vmap-ed GP call ------
373
+ # Instead of a Python for-loop (which JAX unrolls into n_visits separate
374
+ # GP subgraphs), we build (n_visits, max_len) arrays and vmap a single
375
+ # GP evaluation. This compiles ONE GP solve and batches it, reducing
376
+ # XLA graph size by ~n_visits×.
377
+ #
378
+ # Padding conventions:
379
+ # x: sorted values placed far after the last real point (gap = 1e4)
380
+ # so that exp(-α*Δt) → 0 and the Kalman state resets.
381
+ # r: zero residuals (no signal contribution).
382
+ # diag: 1e30 (effectively infinite noise → Kalman gain ≈ 0 → no
383
+ # state update; log-probability contribution is a constant
384
+ # that does not depend on kernel hyperparameters).
385
+ max_len = max(len(v_idx) for v_idx in visit_indices)
386
+
387
+ x_padded = jnp.zeros((n_visits, max_len), dtype=x_inst.dtype)
388
+ r_padded = jnp.zeros((n_visits, max_len), dtype=res_inst.dtype)
389
+ d_padded = jnp.full((n_visits, max_len), _PAD_VARIANCE, dtype=diag_inst.dtype)
390
+
391
+ for i, v_idx in enumerate(visit_indices):
392
+ n_v = len(v_idx) # static (concrete) at trace time
393
+ x_padded = x_padded.at[i, :n_v].set(x_inst[v_idx])
394
+ r_padded = r_padded.at[i, :n_v].set(res_inst[v_idx])
395
+ d_padded = d_padded.at[i, :n_v].set(diag_inst[v_idx])
396
+ # Padding x-values: sorted, placed far after the last real point.
397
+ # The large gap (1e4) ensures exp(-α*Δt) → 0, resetting the
398
+ # Kalman state so padding cannot corrupt the real-data block.
399
+ if n_v < max_len:
400
+ n_pad = max_len - n_v
401
+ last_x = x_inst[v_idx[-1]]
402
+ pad_start = last_x + jnp.asarray(1e4, dtype=x_inst.dtype)
403
+ pad_x = pad_start + jnp.arange(n_pad, dtype=x_inst.dtype) * 0.001
404
+ x_padded = x_padded.at[i, n_v:].set(pad_x)
405
+
406
+ def _visit_logprob(
407
+ x_v: jnp.ndarray, r_v: jnp.ndarray, d_v: jnp.ndarray
408
+ ) -> jnp.ndarray:
409
+ # Padding appends points after the last real one, so a sorted visit
410
+ # stays sorted.
411
+ return gp_log_probability(
412
+ kernel, x_v, d_v, r_v, assume_sorted=assume_sorted, parallel=parallel
413
+ )
414
+
415
+ # Single compiled GP kernel, vmapped over the visit dimension.
416
+ # The kernel object (captured from outer scope) is shared—hyperparameters
417
+ # are NOT batched, only x/r/d are.
418
+ logprobs = jax.vmap(_visit_logprob)(x_padded, r_padded, d_padded)
419
+ # Each padded point (zero residual, variance _PAD_VARIANCE, uncorrelated
420
+ # with the data) adds -0.5 ln(2 pi _PAD_VARIANCE) to the log-probability.
421
+ # The term is constant, so posteriors are unaffected, but it would shift
422
+ # the Bayesian evidence (by ~35.5 nats per padded point): remove it.
423
+ n_padded = sum(max_len - len(v_idx) for v_idx in visit_indices)
424
+ return jnp.sum(logprobs) + n_padded * _PAD_LOGPROB_CORRECTION
425
+
426
+
427
+ def gp_predict_per_visit(
428
+ kernel: Any,
429
+ x_inst: Any,
430
+ res_inst: Any,
431
+ diag_inst: Any,
432
+ visit_indices: list[Any],
433
+ x_pred_inst: Any = None,
434
+ ) -> tuple[jax.Array, jax.Array]:
435
+ """
436
+ Compute GP posterior predictions using per-visit block-diagonal
437
+ conditioning, with **shared** kernel hyperparameters.
438
+
439
+ Each visit is conditioned independently (no cross-visit covariance is used
440
+ for the conditioning), then results are concatenated back into the
441
+ instrument-level arrays. The same validity conditions as
442
+ :func:`gp_log_probability_per_visit` apply: the kernel must decay over
443
+ the inter-visit gap. In particular, ``ExpSineSquared`` (strictly
444
+ periodic, non-decaying) must **not** be combined with this function, as
445
+ ignoring cross-visit covariance will corrupt the posterior mean and
446
+ variance estimates.
447
+
448
+ Args:
449
+ kernel: tinygp kernel object.
450
+ x_inst: Training inputs for the instrument.
451
+ res_inst: Training residuals.
452
+ diag_inst: Diagonal variance (data error² + jitter²).
453
+ visit_indices: List of JAX index arrays (per-visit positions within
454
+ the instrument arrays).
455
+ x_pred_inst: (optional) Prediction inputs. If ``None``, predicts at
456
+ the training locations.
457
+
458
+ Returns:
459
+ (mean, std) — predicted GP posterior mean and standard deviation,
460
+ same length as ``x_inst`` (or ``x_pred_inst`` if provided).
461
+ """
462
+ n_out = len(x_inst) if x_pred_inst is None else len(x_pred_inst)
463
+ mean_out = jnp.zeros(n_out)
464
+ std_out = jnp.zeros(n_out)
465
+
466
+ for v_idx in visit_indices:
467
+ # Convert to JAX arrays — dtype follows the global x64 setting,
468
+ # keeping data and kernel params consistently float32 or float64.
469
+ x_v = jnp.asarray(x_inst[v_idx])
470
+ r_v = jnp.asarray(res_inst[v_idx])
471
+ d_v = jnp.asarray(diag_inst[v_idx])
472
+ gp_v = GaussianProcess(kernel, x_v, diag=d_v)
473
+
474
+ if x_pred_inst is None:
475
+ cond = gp_v.condition(r_v, x_v)
476
+ else:
477
+ x_p_v = jnp.asarray(x_pred_inst[v_idx])
478
+ cond = gp_v.condition(r_v, x_p_v)
479
+
480
+ mean_out = mean_out.at[v_idx].set(cond.gp.loc)
481
+ std_out = std_out.at[v_idx].set(jnp.sqrt(cond.gp.variance))
482
+
483
+ return mean_out, std_out
484
+
485
+
486
+ # =============================================================================
487
+ # Kernel theoretical PSD computation (for periodogram comparison)
488
+ # =============================================================================
489
+
490
+
491
+ def compute_kernel_theoretical_psd(
492
+ kernel: Any,
493
+ freqs: np.ndarray,
494
+ n_acf_lags: int = 8192,
495
+ oversample: float = 20.0,
496
+ max_refine: int = 6,
497
+ rtol: float = 1e-3,
498
+ max_n_lags: int = 2**20,
499
+ alias_rtol: float = 1e-4,
500
+ ) -> np.ndarray:
501
+ """
502
+ Compute the one-sided power spectral density (PSD) of a tinygp kernel
503
+ at a set of requested cyclic frequencies via numerical FFT of the
504
+ autocovariance function.
505
+
506
+ The approach is kernel-agnostic: it builds a regularly-spaced lag grid,
507
+ evaluates the autocovariance k(0, tau) at each lag via ``jax.vmap``, then
508
+ computes the PSD via a real FFT. Works for dense kernels (ExpSquared,
509
+ ExpSineSquared, Matern, ...), quasisep kernels, and arbitrary sums/
510
+ products thereof.
511
+
512
+ **Aliasing safeguard**: the ACF lag spacing ``dt_lag`` must be fine
513
+ enough to resolve the *kernel's own* frequency content, which for
514
+ periodic/quasi-periodic kernels (ExpSineSquared and friends) can extend
515
+ to harmonics far above the highest frequency you actually care about in
516
+ ``freqs``. Coupling ``dt_lag`` directly to ``max(freqs)`` (as a naive
517
+ Nyquist argument would suggest) undersamples that structure and folds
518
+ high harmonics back into the requested band, producing spurious/
519
+ intermittent extra peaks. Instead, this function starts from a lag
520
+ spacing oversampled by ``oversample`` beyond ``max(freqs)`` and
521
+ adaptively halves it (doubling the number of lags to keep the total lag
522
+ span, and hence frequency resolution, fixed) until **both** of the
523
+ following hold:
524
+
525
+ 1. **There is no power left to fold**: the PSD in the top octave of the
526
+ lag grid's own Nyquist band,
527
+ :math:`[f_{\\text{nyq}}/2,\\, f_{\\text{nyq}}]` with
528
+ :math:`f_{\\text{nyq}} = 1/(2\\,\\Delta\\tau)`, has dropped below
529
+ ``alias_rtol`` times the peak PSD *in the requested band*. Whatever
530
+ lies above :math:`f_{\\text{nyq}}` and folds back down is bounded by
531
+ that level, so it cannot contaminate the result. Being measured on
532
+ the spectrum actually computed, this detects kernel-specific
533
+ high-frequency content empirically rather than assuming it is
534
+ bounded by ``max(freqs)``.
535
+ 2. **Refining no longer changes the answer**: the in-band PSD moved by
536
+ less than ``rtol`` of its own peak between successive refinements.
537
+ This catches the *other* dt-dependent error, the quadrature error of
538
+ the discrete transform, which shows up on the steep flanks of sharp
539
+ kernel peaks and is not aliasing at all — criterion 1 is blind to it.
540
+
541
+ Both criteria are scaled by the in-band **peak** PSD rather than applied
542
+ pointwise-relative. A pointwise-relative test is meaningless in the far
543
+ wings, where the PSD of a smooth kernel sits 10-12 orders of magnitude
544
+ below the peak, on the FFT's own round-off floor: that floor reshuffles
545
+ at O(1) relative level on every refinement and never settles, so a
546
+ pointwise test reports "did not converge" for even the most benign
547
+ kernel while the physically meaningful part of the spectrum is already
548
+ accurate to far better than ``rtol``.
549
+
550
+ A pure ``Constant`` kernel is the one genuinely non-convergent case: all
551
+ its power sits at DC, so it has no meaningful in-band PSD and the
552
+ warning below is the honest answer. In any sum with a real kernel its
553
+ contribution is stable and the warning does not fire.
554
+
555
+ See `localDocs/2026_08_20_ExpSineSquared_PSD_Aliasing_Analysis.pdf` for
556
+ a detailed analysis of the underlying aliasing issue.
557
+
558
+ **Normalisation convention** (matches Lomb-Scargle PSD in reports.py):
559
+
560
+ .. math::
561
+
562
+ S_{\\text{1-sided}}(f) = 2 \\,|\\tilde{k}(f)|\\,\\Delta\\tau
563
+ \\qquad \\text{for } f > 0
564
+
565
+ so that :math:`\\int_0^\\infty S(f)\\,df \\approx k(0)` (the kernel
566
+ variance).
567
+
568
+ Parameters
569
+ ----------
570
+ kernel : tinygp kernel object
571
+ Any tinygp kernel (dense or quasisep) exposing ``.evaluate(x1, x2)``.
572
+ freqs : 1-D array of float
573
+ Cyclic frequencies [d⁻¹] at which to evaluate the PSD. Must be
574
+ positive.
575
+ n_acf_lags : int, optional
576
+ Minimum number of lags used for the FFT at the *starting*
577
+ resolution (before adaptive refinement). Default 8192.
578
+ oversample : float, optional
579
+ Factor by which the initial lag-spacing Nyquist frequency exceeds
580
+ ``max(freqs)``. Larger values are safer for sharply peaked/high-Γ
581
+ periodic kernels but cost more compute on the first pass. Default
582
+ 20.
583
+ max_refine : int, optional
584
+ Maximum number of dt_lag-halving refinement iterations. Default 6
585
+ (i.e. up to 64x finer than the initial spacing).
586
+ rtol : float, optional
587
+ Stability tolerance: the PSD at the requested ``freqs`` counts as
588
+ converged once successive refinements move it by less than ``rtol``
589
+ times its own in-band peak. Default 1e-3.
590
+ max_n_lags : int, optional
591
+ Hard cap on the number of lags, to bound memory/compute if
592
+ convergence is never reached. A warning is emitted if this cap is
593
+ hit without convergence. Default 2**20.
594
+ alias_rtol : float, optional
595
+ Aliasing tolerance: the PSD near the lag grid's Nyquist frequency
596
+ must fall below this fraction of the in-band peak for the lag
597
+ spacing to be declared fine enough. Default 1e-4, i.e. an order of
598
+ magnitude stricter than ``rtol``, so residual folded power stays
599
+ well below the level at which the result is declared converged —
600
+ while remaining orders of magnitude above the FFT's own round-off
601
+ floor (~1e-12 of the peak), so the test responds to real spectral
602
+ content rather than to numerical noise.
603
+
604
+ Returns
605
+ -------
606
+ psd : 1-D array of float
607
+ One-sided PSD evaluated at ``freqs``, in units of
608
+ ``[data_units]² · [x_units]`` (e.g. flux² · day when x is TIME).
609
+ """
610
+ _prev_x64 = jax.config.jax_enable_x64
611
+ jax.config.update("jax_enable_x64", True)
612
+ try:
613
+ freqs = np.asarray(freqs, dtype=np.float64)
614
+ f_max = float(np.max(freqs))
615
+ f_min = float(np.min(freqs[freqs > 0]))
616
+
617
+ def _eval_psd(dt_lag: float, n_lags: int) -> tuple[np.ndarray, np.ndarray]:
618
+ """FFT-based one-sided PSD at a given lag resolution."""
619
+ x_grid = jnp.arange(n_lags, dtype=jnp.float64) * dt_lag
620
+ origin = x_grid[0] # = 0.0
621
+ k_tau = np.asarray(
622
+ jax.vmap(lambda tau: kernel.evaluate(origin, tau))(x_grid)
623
+ )
624
+
625
+ # Cosine taper to suppress Gibbs ringing from truncating the ACF.
626
+ window = 0.5 * (1.0 + np.cos(np.pi * np.arange(n_lags) / n_lags))
627
+ k_tau_w = k_tau * window
628
+
629
+ # Two-sided ACF: k(-tau) = k(tau)
630
+ k_sym = np.concatenate([k_tau_w[::-1][:-1], k_tau_w])
631
+ fft_vals = np.fft.fft(k_sym)
632
+ psd_twosided = np.abs(fft_vals) * dt_lag
633
+ fft_freqs = np.fft.fftfreq(len(k_sym), d=dt_lag)
634
+
635
+ pos_mask = fft_freqs > 0
636
+ f_pos = fft_freqs[pos_mask]
637
+ psd_onesided = 2.0 * psd_twosided[pos_mask]
638
+
639
+ sort_idx = np.argsort(f_pos)
640
+ return f_pos[sort_idx], psd_onesided[sort_idx]
641
+
642
+ def _alias_level(
643
+ f_grid: np.ndarray, psd_grid: np.ndarray, peak: float
644
+ ) -> float:
645
+ """Power near the lag grid's Nyquist, as a fraction of the
646
+ in-band peak.
647
+
648
+ Aliasing folds the spectrum above ``f_nyq = 1/(2 dt_lag)`` back
649
+ down into the computed band. That folded-in power is bounded by
650
+ the level the PSD has already reached as it approaches
651
+ ``f_nyq``, so probing the top octave of the computed grid gives
652
+ a direct, single-pass measure of how much contamination is still
653
+ possible — no iteration-to-iteration differencing needed.
654
+ """
655
+ if peak <= 0.0:
656
+ return 0.0
657
+ f_nyq = float(f_grid[-1])
658
+ # Keep the probe band clear of the requested band itself, which
659
+ # it would otherwise overlap for ``oversample`` near 1.
660
+ tail = psd_grid[f_grid >= max(0.5 * f_nyq, f_max)]
661
+ if tail.size == 0:
662
+ return 0.0
663
+ return float(np.max(tail)) / peak
664
+
665
+ # ── Initial resolution: oversampled well beyond max(freqs) ──────────
666
+ f_nyq_resolve = oversample * f_max
667
+ dt_lag = 1.0 / (2.0 * f_nyq_resolve)
668
+ # Ensure total lag span covers at least one cycle of the lowest
669
+ # requested frequency (needed for low-frequency resolution).
670
+ min_lags_needed = int(np.ceil(1.0 / (f_min * dt_lag))) + 1
671
+ n_lags = max(n_acf_lags, min_lags_needed)
672
+
673
+ # Initial evaluation
674
+ f_grid, psd_grid = _eval_psd(dt_lag, n_lags)
675
+ psd_prev = np.interp(freqs, f_grid, psd_grid, left=0.0, right=0.0)
676
+ peak = float(np.max(psd_prev))
677
+
678
+ alias_level = _alias_level(f_grid, psd_grid, peak)
679
+ stab_level = np.inf
680
+
681
+ # ``peak <= 0`` means the kernel has no power anywhere in the
682
+ # requested band (e.g. a multi-GP channel whose projections are both
683
+ # zero): trivially converged, since there is no scale to test
684
+ # anything against.
685
+ converged = peak <= 0.0
686
+
687
+ # ── Adaptive refinement: halve dt_lag, double n_lags (span fixed) ───
688
+ # At least one refinement has to run before the result can be called
689
+ # converged: ``stab_level`` is a difference between successive
690
+ # resolutions, so the first pass alone cannot establish it.
691
+ n_refined = 0
692
+ if not converged:
693
+ for _ in range(max_refine):
694
+ if n_lags * 2 > max_n_lags:
695
+ break
696
+ dt_lag /= 2.0
697
+ n_lags *= 2
698
+ n_refined += 1
699
+
700
+ f_grid, psd_grid = _eval_psd(dt_lag, n_lags)
701
+ psd_cur = np.interp(freqs, f_grid, psd_grid, left=0.0, right=0.0)
702
+ peak = float(np.max(psd_cur))
703
+
704
+ if peak > 0.0:
705
+ alias_level = _alias_level(f_grid, psd_grid, peak)
706
+ stab_level = float(np.max(np.abs(psd_cur - psd_prev))) / peak
707
+ psd_prev = psd_cur
708
+
709
+ if peak <= 0.0 or (alias_level <= alias_rtol and stab_level <= rtol):
710
+ converged = True
711
+ break
712
+
713
+ if n_refined == 0:
714
+ # Refinement was disabled (``max_refine <= 0``) or blocked
715
+ # outright by ``max_n_lags``, so stability could never be
716
+ # measured; the aliasing test is then all there is to go on.
717
+ converged = alias_level <= alias_rtol
718
+
719
+ if not converged:
720
+ _log(
721
+ f"compute_kernel_theoretical_psd: PSD not converged after "
722
+ f"{n_refined} of max_refine={max_refine} refinements (final "
723
+ f"dt_lag={dt_lag:.3g}, n_lags={n_lags}): power near the "
724
+ f"lag-grid Nyquist is still {alias_level:.2e} of the in-band "
725
+ f"peak (target {alias_rtol:.0e}), and the last refinement "
726
+ f"still moved the in-band PSD by {stab_level:.2e} of that "
727
+ f"peak (target {rtol:.0e}). Result may still contain aliasing "
728
+ f"from unresolved kernel structure (e.g. very high Γ in a "
729
+ f"quasi-periodic term). Consider raising oversample, "
730
+ f"max_refine, or max_n_lags.",
731
+ level="WARNING",
732
+ )
733
+
734
+ return psd_prev
735
+ finally:
736
+ jax.config.update("jax_enable_x64", _prev_x64)
737
+
738
+
739
+ # =============================================================================
740
+ # Kernel realistic PSD computation taking into account the observational time window (for periodogram comparison)
741
+ # =============================================================================
742
+
743
+
744
+ def compute_windowed_psd(kernel: Any, freqs: Any, t: Any) -> jax.Array:
745
+ """
746
+ Compute the discrete one-sided PSD approximation of a tinygp kernel,
747
+ fully JAX-compatible and differentiable.
748
+ """
749
+ _prev_x64 = jax.config.jax_enable_x64
750
+ jax.config.update("jax_enable_x64", True)
751
+
752
+ try:
753
+ t = jnp.asarray(t, dtype=jnp.float64).reshape(-1)
754
+ freqs = jnp.asarray(freqs, dtype=jnp.float64)
755
+
756
+ # 1. Build the Covariance Matrix
757
+ K = kernel(t, t)
758
+
759
+ # # Safe JAX-compatible dimensionality check
760
+ # K = jnp.atleast_2d(K)
761
+ # if K.shape == (1, 1) and t.shape[0] > 1:
762
+ # K = jnp.full((len(t), len(t)), K[0, 0])
763
+
764
+ # 2. Get Intrinsic Kernel Variance
765
+ kernel_variance = jnp.mean(jnp.diagonal(K))
766
+
767
+ # 3. Project into frequency space
768
+ # Using a more memory-efficient/readable outer product for the exponent
769
+ exponent = -2j * jnp.pi * jnp.outer(t, freqs)
770
+ projection_matrix = jnp.exp(exponent)
771
+
772
+ # Matrix quadratic form: raw_power = diag(P^H @ K @ P)
773
+ raw_power = jnp.real(
774
+ jnp.sum(jnp.conj(projection_matrix) * (K @ projection_matrix), axis=0)
775
+ )
776
+
777
+ # 4. Pure JAX Trapezoidal Integration
778
+ # ∫ P(f) df ≈ ∑ 0.5 * (P_i + P_{i+1}) * Δf
779
+ df = jnp.diff(freqs)
780
+ zero_padded_power = 0.5 * (raw_power[:-1] + raw_power[1:])
781
+ raw_integral = jnp.sum(zero_padded_power * df)
782
+
783
+ # 5. Normalize
784
+ psd_normalized = jnp.where(
785
+ raw_integral > 0, raw_power * (kernel_variance / raw_integral), raw_power
786
+ )
787
+
788
+ return psd_normalized
789
+
790
+ finally:
791
+ jax.config.update("jax_enable_x64", _prev_x64)