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
|
@@ -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
|