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
@@ -0,0 +1,419 @@
1
+ """
2
+ residual_plotting.py
3
+ ~~~~~~~~~~~~~~~~~~~
4
+ Residual panel plotting and KDE visualization.
5
+
6
+ Responsibilities:
7
+ - Plot residuals with error bars
8
+ - Build and render KDE (kernel density estimation)
9
+ - Format residual panels with proper labeling and references
10
+ """
11
+
12
+ from typing import Any
13
+
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np
16
+ from scipy.stats import gaussian_kde
17
+
18
+
19
+ def plot_residuals_with_kde(
20
+ ax_residual: Any,
21
+ ax_kde: Any,
22
+ residuals: np.ndarray,
23
+ time: np.ndarray,
24
+ dy: np.ndarray,
25
+ visit_sorted: np.ndarray | None = None,
26
+ visit_colors: dict[Any, str] | None = None,
27
+ data_type: str = "LC",
28
+ clusters: list[tuple[int, int]] | None = None,
29
+ cluster_idx: int = 0,
30
+ show_reference_bars: bool = False,
31
+ med_raw: float | None = None,
32
+ med_tot: float | None = None,
33
+ ) -> dict[str, Any]:
34
+ """
35
+ Plot residuals (time-series) and optional KDE on dual-axis panel.
36
+
37
+ Args:
38
+ ax_residual: Matplotlib axis for time-series
39
+ ax_kde: Matplotlib axis for KDE (right inset)
40
+ residuals: Residual values (data - model)
41
+ time: Time array
42
+ dy: Error bars
43
+ visit_sorted: Visits (optional, for per-visit coloring)
44
+ visit_colors: Dict {visit_id: color} for per-visit plots
45
+ data_type: 'RV' or 'LC'
46
+ clusters: List of (start, end) tuples for per-cluster plotting
47
+ cluster_idx: Current cluster index (0 for first)
48
+ show_reference_bars: Whether to add reference uncertainty bars
49
+ med_raw: Median raw uncertainty (for reference)
50
+ med_tot: Median total uncertainty (for reference)
51
+
52
+ Returns:
53
+ dict: {'rms': float, 'kde_visible': bool}
54
+ """
55
+ is_per_visit = visit_colors is not None
56
+
57
+ # Plot time-series residuals
58
+ if is_per_visit and visit_sorted is not None:
59
+ unique_visits = np.unique(visit_sorted)
60
+ for visit_id in unique_visits:
61
+ sel = visit_sorted == visit_id
62
+ color = (
63
+ visit_colors.get(visit_id, "gray")
64
+ if visit_colors is not None
65
+ else "gray"
66
+ )
67
+ label = f"Visit {visit_id}" if cluster_idx == 0 else "_nolegend_"
68
+ ax_residual.errorbar(
69
+ time[sel],
70
+ residuals[sel],
71
+ yerr=dy[sel],
72
+ fmt=".",
73
+ markersize=3,
74
+ elinewidth=0.5,
75
+ alpha=0.6,
76
+ color=color,
77
+ label=label,
78
+ zorder=1,
79
+ )
80
+ else:
81
+ label = "Data" if cluster_idx == 0 else "_nolegend_"
82
+ ax_residual.errorbar(
83
+ time,
84
+ residuals,
85
+ yerr=dy,
86
+ fmt=".",
87
+ markersize=3,
88
+ elinewidth=0.5,
89
+ alpha=0.6,
90
+ color="gray",
91
+ label=label,
92
+ zorder=1,
93
+ )
94
+
95
+ ax_residual.axhline(0, color="k", linestyle="--", linewidth=0.5, zorder=2)
96
+
97
+ # RMS text (first cluster only)
98
+ rms = None
99
+ if cluster_idx == 0:
100
+ rms = np.std(residuals)
101
+ if data_type == "RV":
102
+ rms_str = f"RMS = {rms:.3f} m/s"
103
+ else:
104
+ rms_str = f"RMS = {rms * 1e6:.2f} ppm"
105
+ ax_residual.text(
106
+ 0.02,
107
+ 0.95,
108
+ rms_str,
109
+ transform=ax_residual.transAxes,
110
+ fontsize=8,
111
+ verticalalignment="top",
112
+ bbox=dict(boxstyle="round", facecolor="white", alpha=0.8),
113
+ )
114
+
115
+ # Reference uncertainty bars (first cluster only)
116
+ if show_reference_bars and cluster_idx == 0 and med_raw is not None:
117
+ from .plot_utils import _add_unc_ref_bars
118
+
119
+ x_ref = time[len(time) // 2] if len(time) > 0 else 0.0
120
+ fmt_unc = "m/s" if data_type == "RV" else "ppm"
121
+ _add_unc_ref_bars(ax_residual, x_ref, 0.0, med_raw, med_tot, fmt_unc)
122
+
123
+ # KDE visualization
124
+ kde_visible = _plot_kde(ax_kde, residuals, visit_sorted, visit_colors, is_per_visit)
125
+
126
+ return {"rms": rms, "kde_visible": kde_visible}
127
+
128
+
129
+ def _plot_kde(
130
+ ax_kde: Any,
131
+ residuals: np.ndarray,
132
+ visit_sorted: np.ndarray | None = None,
133
+ visit_colors: dict[Any, str] | None = None,
134
+ is_per_visit: bool = False,
135
+ ) -> bool:
136
+ """
137
+ Render KDE panel (right inset).
138
+
139
+ Args:
140
+ ax_kde: Matplotlib axis
141
+ residuals: Residual values
142
+ visit_sorted: Visits (optional)
143
+ visit_colors: Visit color dict (optional)
144
+ is_per_visit: Bool
145
+
146
+ Returns:
147
+ bool: True if KDE was plotted, False otherwise
148
+ """
149
+ if len(residuals) < 4:
150
+ return False
151
+
152
+ try:
153
+ # Determine y-range for KDE sampling
154
+ res_span = float(residuals.max() - residuals.min())
155
+ margin = 0.15 * res_span if res_span > 0 else 1e-6
156
+ y_k = np.linspace(
157
+ float(residuals.min()) - margin, float(residuals.max()) + margin, 300
158
+ )
159
+
160
+ if is_per_visit and visit_sorted is not None:
161
+ # Overall combined KDE
162
+ kde_all = gaussian_kde(residuals)
163
+ ax_kde.plot(
164
+ kde_all(y_k), y_k, color="black", linewidth=1.2, label="All data"
165
+ )
166
+
167
+ # Reference Gaussian
168
+ mu = float(np.mean(residuals))
169
+ sig = float(np.std(residuals))
170
+ if sig > 0:
171
+ gauss = np.exp(-0.5 * ((y_k - mu) / sig) ** 2) / (
172
+ sig * np.sqrt(2.0 * np.pi)
173
+ )
174
+ ax_kde.plot(
175
+ gauss,
176
+ y_k,
177
+ color="C3",
178
+ linewidth=1.0,
179
+ linestyle="--",
180
+ alpha=0.8,
181
+ label="Gaussian ref.",
182
+ )
183
+
184
+ # Per-visit KDEs
185
+ unique_visits = np.unique(visit_sorted)
186
+ if len(unique_visits) > 1:
187
+ for visit_id in unique_visits:
188
+ sel = visit_sorted == visit_id
189
+ res_visit = residuals[sel]
190
+ if len(res_visit) > 3:
191
+ kde_visit = gaussian_kde(res_visit)
192
+ color = (
193
+ visit_colors.get(visit_id, "gray")
194
+ if visit_colors is not None
195
+ else "gray"
196
+ )
197
+ ax_kde.plot(
198
+ kde_visit(y_k),
199
+ y_k,
200
+ color=color,
201
+ linewidth=0.5,
202
+ alpha=0.8,
203
+ label=f"Visit {visit_id}",
204
+ )
205
+
206
+ ax_kde.legend(fontsize=6, loc="upper right")
207
+ else:
208
+ # Single KDE
209
+ kde_all = gaussian_kde(residuals)
210
+ ax_kde.plot(kde_all(y_k), y_k, color="black", linewidth=1.2)
211
+
212
+ # Reference Gaussian
213
+ mu = float(np.mean(residuals))
214
+ sig = float(np.std(residuals))
215
+ if sig > 0:
216
+ gauss = np.exp(-0.5 * ((y_k - mu) / sig) ** 2) / (
217
+ sig * np.sqrt(2.0 * np.pi)
218
+ )
219
+ ax_kde.plot(
220
+ gauss,
221
+ y_k,
222
+ color="C3",
223
+ linewidth=1.0,
224
+ linestyle="--",
225
+ alpha=0.8,
226
+ label="Gaussian ref.",
227
+ )
228
+
229
+ ax_kde.axhline(0, color="k", linestyle="--", linewidth=0.5)
230
+ ax_kde.set_xlim(left=0)
231
+
232
+ return True
233
+
234
+ except (ImportError, Exception):
235
+ return False
236
+
237
+
238
+ def style_residual_axes(
239
+ axes: list[Any], clusters: list[tuple[int, int]] | None = None
240
+ ) -> None:
241
+ """
242
+ Apply consistent styling to residual axes.
243
+
244
+ Args:
245
+ axes: List of residual axes
246
+ clusters: List of (start, end) tuples for multi-panel layout
247
+ """
248
+ for idx, ax in enumerate(axes):
249
+ ax.set_xlabel("Time [d]")
250
+ ax.set_ylabel("Residuals (σ_orig error bars)")
251
+
252
+ # Hide y-labels on non-leftmost columns
253
+ if idx > 0:
254
+ plt.setp(ax.get_yticklabels(), visible=False)
255
+ plt.setp(ax.get_yaxis().get_label(), visible=False)
256
+
257
+
258
+ def style_kde_axis(ax_kde: Any, kde_visible: bool) -> None:
259
+ """
260
+ Apply consistent styling to KDE axis.
261
+
262
+ Args:
263
+ ax_kde: KDE axis
264
+ kde_visible: Bool
265
+ """
266
+ if kde_visible:
267
+ ax_kde.tick_params(labelleft=False, labelsize=7)
268
+ ax_kde.set_xlabel("KDE", fontsize=7)
269
+ ax_kde.xaxis.set_major_locator(plt.MaxNLocator(2))
270
+ else:
271
+ ax_kde.set_visible(False)
272
+
273
+
274
+ # ──────────────────────────────────────────────────────────────────────────────
275
+ # Self-contained KDE inset renderer used by plot_models
276
+ # ──────────────────────────────────────────────────────────────────────────────
277
+
278
+
279
+ def render_kde_inset(
280
+ ax_kde: Any,
281
+ residuals: np.ndarray,
282
+ vis_sorted: np.ndarray,
283
+ unique_vis: np.ndarray,
284
+ visit_colors: dict[Any, str] | None,
285
+ is_per_inst_plot: bool,
286
+ ref_sigma: float | None = None,
287
+ ) -> bool:
288
+ """Render the KDE residual distribution inset.
289
+
290
+ The KDE panel shares its y-axis with the residuals time-series so the
291
+ residual-value scale is identical on both sides. The x-axis shows the
292
+ probability density (arbitrary units, increasing to the right), making
293
+ it easy to see at a glance whether the residuals are Gaussian, skewed,
294
+ or have heavy tails, without leaving the time-series context.
295
+
296
+ Parameters
297
+ ----------
298
+ ax_kde : matplotlib.axes.Axes
299
+ The right-inset axis (y-axis shared with residuals panel).
300
+ residuals : array
301
+ Residual values (data − total model).
302
+ vis_sorted, unique_vis : arrays
303
+ Per-point visit labels and unique visit IDs (used for per-visit KDEs).
304
+ visit_colors : dict or None
305
+ ``{visit_id: color}`` mapping; ``None`` disables per-visit KDEs.
306
+ is_per_inst_plot : bool
307
+ When True and ``visit_colors`` is set, draws one KDE per visit.
308
+ ref_sigma : float, optional
309
+ Width of the dashed "Gaussian ref." curve, in residual units. This
310
+ should be the fitted **total** uncertainty (raw ⊕ jitter) — e.g.
311
+ ``ctx.med_tot`` or a channel's ``med_tot`` — so the curve answers
312
+ "does the residual scatter match what the noise model (including the
313
+ fitted jitter) predicts?", not merely "what Gaussian best fits the
314
+ residuals themselves". When several rows in view have different
315
+ total uncertainties (heterogeneous raw errors, or a per-visit jitter
316
+ on an ``is_per_inst_plot`` page), pass a representative value (the
317
+ median, as the noise-budget panels and the reference bars do) — the
318
+ legend label says so. If omitted or non-positive, falls back to the
319
+ empirical standard deviation of the residuals, matching the curve's
320
+ pre-existing behaviour.
321
+
322
+ Returns
323
+ -------
324
+ kde_visible : bool
325
+ ``True`` if KDE was rendered, ``False`` if suppressed (too few points
326
+ or scipy not available).
327
+ """
328
+ kde_visible = False
329
+ try:
330
+ from scipy.stats import gaussian_kde as _gkde
331
+
332
+ if len(residuals) >= 4:
333
+ kde_visible = True
334
+ _res_span = float(residuals.max() - residuals.min())
335
+ _margin = 0.15 * _res_span if _res_span > 0 else 1e-6
336
+ _y_k = np.linspace(
337
+ float(residuals.min()) - _margin, float(residuals.max()) + _margin, 300
338
+ )
339
+
340
+ def _draw_gaussian_ref(label: str) -> None:
341
+ _mu = float(np.mean(residuals))
342
+ _sig = (
343
+ float(ref_sigma)
344
+ if ref_sigma is not None and ref_sigma > 0
345
+ else float(np.std(residuals))
346
+ )
347
+ if _sig <= 0:
348
+ return
349
+ _gauss = np.exp(-0.5 * ((_y_k - _mu) / _sig) ** 2) / (
350
+ _sig * np.sqrt(2.0 * np.pi)
351
+ )
352
+ ax_kde.plot(
353
+ _gauss,
354
+ _y_k,
355
+ color="C3",
356
+ linewidth=1.0,
357
+ linestyle="--",
358
+ alpha=0.8,
359
+ label=label,
360
+ )
361
+
362
+ _gauss_label = (
363
+ "Gaussian ref. (median σ_tot)"
364
+ if ref_sigma is not None and ref_sigma > 0
365
+ else "Gaussian ref."
366
+ )
367
+
368
+ if is_per_inst_plot and visit_colors is not None:
369
+ try:
370
+ _kde_v = _gkde(residuals)(_y_k)
371
+ ax_kde.fill_betweenx(_y_k, 0, _kde_v, color="gray", alpha=0.30)
372
+ ax_kde.plot(
373
+ _kde_v, _y_k, color="gray", linewidth=1.2, label="All visits"
374
+ )
375
+ except Exception:
376
+ pass
377
+
378
+ _draw_gaussian_ref(_gauss_label)
379
+
380
+ for uv in unique_vis:
381
+ sel = vis_sorted == uv
382
+ res_uv = residuals[sel]
383
+ if len(res_uv) < 4:
384
+ continue
385
+ try:
386
+ kde = _gkde(res_uv)
387
+ ax_kde.plot(
388
+ kde(_y_k),
389
+ _y_k,
390
+ color=visit_colors[uv],
391
+ linewidth=1.2,
392
+ alpha=0.8,
393
+ label=f"Visit {uv}",
394
+ )
395
+ except Exception:
396
+ pass
397
+ else:
398
+ _kde_v = _gkde(residuals)(_y_k)
399
+ ax_kde.fill_betweenx(_y_k, 0, _kde_v, color="gray", alpha=0.30)
400
+ ax_kde.plot(_kde_v, _y_k, color="gray", linewidth=1.2, label="Data")
401
+
402
+ _draw_gaussian_ref(_gauss_label)
403
+
404
+ ax_kde.axhline(0, color="k", linestyle="--", linewidth=0.5)
405
+ ax_kde.set_xlim(left=0)
406
+ ax_kde.legend(fontsize=6, loc="upper right")
407
+ except ImportError:
408
+ pass
409
+ except Exception:
410
+ kde_visible = False
411
+
412
+ if kde_visible:
413
+ ax_kde.tick_params(labelleft=False, labelsize=7)
414
+ ax_kde.set_xlabel("KDE", fontsize=7)
415
+ ax_kde.xaxis.set_major_locator(plt.MaxNLocator(2))
416
+ else:
417
+ ax_kde.set_visible(False)
418
+
419
+ return kde_visible
@@ -0,0 +1,128 @@
1
+ """
2
+ trend_detection.py
3
+ ~~~~~~~~~~~~~~~~~
4
+ Detect and compute non-time detrending components.
5
+
6
+ Responsibilities:
7
+ - Identify whether polynomial/Fourier/GP use non-time variables
8
+ - Build non-time trend arrays
9
+ - Handle per-instrument GP contributions
10
+ """
11
+
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+
16
+ from .model_math import _compute_nontime_trend
17
+
18
+
19
+ def detect_nontime_xvariables(
20
+ poly_cfgs: list[dict[str, Any]],
21
+ four_cfgs: list[dict[str, Any]],
22
+ gp_cfgs: dict[Any, dict[str, Any]],
23
+ eiv_cfgs: list[dict[str, Any]] | None = None,
24
+ ) -> tuple[bool, bool]:
25
+ """
26
+ Check if any detrending component uses a non-time variable.
27
+
28
+ Args:
29
+ poly_cfgs: List of polynomial configs
30
+ four_cfgs: List of Fourier configs
31
+ gp_cfgs: Dict of {inst_id: gp_config}
32
+ eiv_cfgs: List of EIV configs (optional)
33
+
34
+ Returns:
35
+ tuple: (has_poly_fourier, has_gp_nontime) booleans
36
+ """
37
+ if eiv_cfgs is None:
38
+ eiv_cfgs = []
39
+ has_poly_fourier = (
40
+ any(cfg.get("x_variable", "TIME") != "TIME" for cfg in poly_cfgs)
41
+ or any(cfg.get("x_variable", "TIME") != "TIME" for cfg in four_cfgs)
42
+ or any(cfg.get("x_variable", "TIME") != "TIME" for cfg in eiv_cfgs)
43
+ )
44
+
45
+ has_gp_nontime = any(_gp_uses_nontime_xvar(cfg) for cfg in gp_cfgs.values())
46
+
47
+ return has_poly_fourier, has_gp_nontime
48
+
49
+
50
+ def _gp_uses_nontime_xvar(gp_cfg: dict[str, Any]) -> bool:
51
+ """Check if a single GP config uses non-time variable."""
52
+ xvars = gp_cfg.get("x_variables", gp_cfg.get("x_variable", ["TIME"]))
53
+ if isinstance(xvars, str):
54
+ xvars = [xvars]
55
+ return any(xv != "TIME" for xv in xvars)
56
+
57
+
58
+ def build_nontime_trend_at_data_points(
59
+ model_comp: dict[str, Any],
60
+ plData: Any,
61
+ param_config: dict[str, Any],
62
+ poly_cfgs: list[dict[str, Any]],
63
+ four_cfgs: list[dict[str, Any]],
64
+ gp_cfgs: dict[Any, dict[str, Any]],
65
+ ) -> np.ndarray | None:
66
+ """
67
+ Pre-compute non-time trend component at every data point.
68
+
69
+ This combines polynomial/Fourier non-time terms and any GP predictions
70
+ on non-time variables. Used for Panel 0 (raw data + model) overlay.
71
+
72
+ Args:
73
+ model_comp: Best-fit model components
74
+ plData: Data object
75
+ param_config: Configuration
76
+ poly_cfgs, four_cfgs, gp_cfgs: Configuration dicts
77
+
78
+ Returns:
79
+ ndarray: Non-time trend at each data point, or None if no non-time trends
80
+ """
81
+ has_poly_fourier, has_gp = detect_nontime_xvariables(poly_cfgs, four_cfgs, gp_cfgs)
82
+
83
+ if not (has_poly_fourier or has_gp):
84
+ return None
85
+
86
+ # Start with polynomial/Fourier/EIV non-time contributions
87
+ trend = _compute_nontime_trend(
88
+ model_comp["poly_terms"],
89
+ model_comp["fourier_terms"],
90
+ model_comp.get("eiv_terms", {}),
91
+ poly_cfgs,
92
+ four_cfgs,
93
+ model_comp.get("eiv_configs", []),
94
+ len(plData.time),
95
+ )
96
+
97
+ # Add non-time GP contributions
98
+ if has_gp:
99
+ inst_arr = np.array(plData.instrument)
100
+ for gp_inst_id, gp_cfg in gp_cfgs.items():
101
+ if _gp_uses_nontime_xvar(gp_cfg):
102
+ mask = inst_arr == gp_inst_id
103
+ # model_comp['gp_mean'] contains the full GP prediction
104
+ # for this instrument; add it where it applies
105
+ trend[mask] += model_comp["gp_mean"][mask]
106
+
107
+ return trend
108
+
109
+
110
+ def apply_nontime_detrending(
111
+ y: np.ndarray, nontime_trend: np.ndarray | None, mask: np.ndarray | None = None
112
+ ) -> np.ndarray:
113
+ """
114
+ Remove non-time trend from data.
115
+
116
+ Args:
117
+ y: Data array
118
+ nontime_trend: Non-time trend to subtract
119
+ mask: Optional subset mask
120
+
121
+ Returns:
122
+ ndarray: Detrended data
123
+ """
124
+ if nontime_trend is None:
125
+ return y
126
+
127
+ trend_to_use = nontime_trend if mask is None else nontime_trend[mask]
128
+ return y - trend_to_use