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,697 @@
1
+ """multigp.py — Multi-dimensional GP configuration parser for PyAETNA.
2
+
3
+ This module provides :func:`parse_multi_gp`, which reads the
4
+ ``detrending.multi_GP`` subsection of the YAML configuration and builds the
5
+ joint multi-channel GP blocks consumed by the sampling engine.
6
+
7
+ How this differs from :func:`PyAETNA.sampling.config.parser.gp.parse_gp`
8
+ -----------------------------------------------------------------------
9
+ ``parse_gp`` returns a dict keyed by **instrument id**: each instrument gets
10
+ its own independent GP and its own likelihood factor. A multi-GP block is
11
+ keyed by **block name** instead, because one block deliberately spans several
12
+ observables (and possibly several instruments) under a single joint
13
+ covariance. That is the whole point of the Rajpaul/Barragán construction and
14
+ is the one thing the per-instrument structure cannot express.
15
+
16
+ Channel semantics
17
+ -----------------
18
+ ``Y`` is a reserved channel name meaning *the observable already being fitted*
19
+ for these instruments: its residual is ``y - mean_function`` (Keplerians,
20
+ systemic velocity, polynomials, Fourier, EIV — all unchanged) and its variance
21
+ is the existing ``dy_total**2``. Nothing is duplicated; the multi-GP replaces
22
+ only the likelihood term, never the mean model.
23
+
24
+ Any other channel name is a column of the detrending basis (``plData.dBasis``),
25
+ paired with its ``<NAME>_ERR`` uncertainty column following the convention
26
+ already established by the EIV block. Such channels carry their own constant
27
+ ``offset``, an optional ``log_jitter`` and an optional polynomial trend
28
+ (``c1``, ``c2``, …, centred on the midpoint of each coefficient's scope domain
29
+ over the channel's usable rows). They receive no Keplerian and no other
30
+ detrending by construction.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from collections.abc import Callable, Sequence
36
+ from typing import Any
37
+
38
+ import numpy as np
39
+
40
+ from ....kernels.multichannel import (
41
+ MULTIGP_SUPPORTED_KERNELS,
42
+ get_multigp_param_order,
43
+ )
44
+ from ...centering import TREND_KEY_RE, group_centers, usable_channel_rows
45
+ from .prior_refs import is_resolved_prior_reference, resolve_config_value
46
+ from .scoping import _SCOPE_KEYWORDS, _parse_scoped_param
47
+
48
+ # The channel name meaning "the observable already being fitted".
49
+ RESERVED_CHANNEL = "Y"
50
+
51
+ # Per-channel configuration keys, split by how they enter the likelihood.
52
+ #
53
+ # ``A``/``B`` multiply the latent process inside the kernel, indexed by
54
+ # channel, so giving them a per-instrument or per-visit value would need one
55
+ # kernel channel per (channel, group) pair -- the joint covariance itself
56
+ # changes shape. They stay global.
57
+ #
58
+ # ``offset``/``log_jitter`` never touch the kernel: the first is a constant
59
+ # subtracted from the channel's residual vector, the second is added to the
60
+ # covariance *diagonal*. Both are per-point quantities, so varying them by
61
+ # instrument or visit is a gather over rows and nothing more.
62
+ #
63
+ # Trend coefficients ``c1``, ``c2``, … (matched by ``TREND_KEY_RE``) are
64
+ # pointwise like ``offset``: together they form the channel mean
65
+ # ``offset + sum_k c_k * (x - x_c)**k`` subtracted from the residual, and never
66
+ # enter the kernel, so they accept the same scopes.
67
+ _KERNEL_CHANNEL_PARAMS = ("A", "B")
68
+ _POINTWISE_CHANNEL_PARAMS = ("offset", "log_jitter")
69
+ _CHANNEL_PARAMS = _KERNEL_CHANNEL_PARAMS + _POINTWISE_CHANNEL_PARAMS
70
+
71
+ # Total joint-matrix size beyond which a dense Cholesky becomes uncomfortable.
72
+ # The joint matrix is N_total x N_total with N_total summed over ALL channels,
73
+ # so these are reached faster than for a single-instrument GP.
74
+ _SIZE_WARN = 2000
75
+ _SIZE_ERROR = 6000
76
+
77
+
78
+ def _normalise_scoped(value: Any) -> Any:
79
+ """Prepend the implicit ``global`` scope to a bare distribution list."""
80
+ if isinstance(value, list) and len(value) > 0:
81
+ first = value[0]
82
+ if not isinstance(first, str) or first.lower() not in _SCOPE_KEYWORDS:
83
+ return ["global", *value]
84
+ return value
85
+
86
+
87
+ def _refuse_grouped_kernel_param(
88
+ value: Any,
89
+ label: str,
90
+ block_name: str,
91
+ channel: str,
92
+ pname: str,
93
+ instruments: Sequence[str],
94
+ ) -> None:
95
+ """Reject a grouped scope on ``A``/``B`` with a message that says what to do.
96
+
97
+ ``A`` and ``B`` are the linear map from the latent process to the channel,
98
+ so they sit inside every cross-covariance block of the joint matrix. A
99
+ per-group value would mean the channel is no longer one channel and the
100
+ matrix would have to be rebuilt with it split -- which is exactly what one
101
+ ``multi_GP`` block per group already does, with its own latent process.
102
+ """
103
+ if not isinstance(value, list) or not value:
104
+ return
105
+ scope = value[0]
106
+ if not isinstance(scope, str) or scope.lower() == "global":
107
+ return
108
+
109
+ first = instruments[0] if instruments else "INST_A"
110
+ second = instruments[1] if len(instruments) > 1 else "INST_B"
111
+ raise ValueError(
112
+ f"Parameter '{label}.channels.{channel}.{pname}': scope '{scope}' is not "
113
+ f"allowed. 'A' and 'B' are part of the covariance itself, so they cannot "
114
+ f"vary within one multi_GP block -- a channel with two projections is "
115
+ f"two channels.\n"
116
+ f" If the projection genuinely differs between groups (e.g. two "
117
+ f"spectrographs with different {channel} calibrations), split the block "
118
+ f"instead, giving each group its own latent process:\n"
119
+ f" multi_GP:\n"
120
+ f" {block_name}_{str(first).lower()}:\n"
121
+ f" instruments: [{first}]\n"
122
+ f" ...\n"
123
+ f" {block_name}_{str(second).lower()}:\n"
124
+ f" instruments: [{second}]\n"
125
+ f" ...\n"
126
+ f" If instead only the zero point or the excess scatter differs, scope "
127
+ f"'offset' or 'log_jitter' -- those accept per_instrument and per_visit."
128
+ )
129
+
130
+
131
+ def _validate_channel_keys(
132
+ label: str,
133
+ channel: str,
134
+ ch_cfg: dict[str, Any],
135
+ log: Callable[[str], None],
136
+ ) -> None:
137
+ """Reject channel keys the parser would otherwise silently ignore."""
138
+ allowed = ", ".join((*_CHANNEL_PARAMS, "c1", "c2", "..."))
139
+ for key in ch_cfg:
140
+ skey = str(key)
141
+ if skey in _CHANNEL_PARAMS or TREND_KEY_RE.match(skey):
142
+ continue
143
+ if skey == "c0":
144
+ msg = (
145
+ f"{label}: channel '{channel}' sets 'c0'. The constant term of a "
146
+ f"channel trend is its 'offset'; start trends from c1."
147
+ )
148
+ else:
149
+ msg = (
150
+ f"{label}: channel '{channel}' has unknown key '{skey}'. "
151
+ f"Allowed keys: {allowed}."
152
+ )
153
+ log(f"ERROR: {msg}")
154
+ raise ValueError(msg)
155
+
156
+
157
+ def _prior_lower_bound(prior_spec: Any) -> float | None:
158
+ """Best-effort lower bound of a prior spec, or ``None`` if not determinable."""
159
+ if isinstance(prior_spec, (int, float)):
160
+ return float(prior_spec)
161
+ if isinstance(prior_spec, (list, tuple)) and len(prior_spec) >= 2:
162
+ dist = str(prior_spec[0]).lower()
163
+ try:
164
+ if dist in ("uniform", "truncnorm"):
165
+ return float(prior_spec[1])
166
+ except (TypeError, ValueError):
167
+ return None
168
+ return None
169
+
170
+
171
+ def parse_multi_gp(
172
+ detrending: dict[str, Any],
173
+ plModel: Any,
174
+ plData: Any,
175
+ inst_names: list[str],
176
+ inst_name_to_id: dict[str, int],
177
+ free_params: dict[str, Any],
178
+ fixed_params: dict[str, Any],
179
+ param_scopes: dict[str, Any],
180
+ named_priors: dict[str, Any] | None = None,
181
+ log_func: Callable[..., None] | None = None,
182
+ ) -> dict[str, Any]:
183
+ """Parse ``detrending.multi_GP`` into joint multi-channel GP blocks.
184
+
185
+ Args:
186
+ detrending: The ``detrending`` config section.
187
+ plModel: PlModel object.
188
+ plData: PlData object (used to validate channel columns against
189
+ ``dBasis``).
190
+ inst_names: All instrument names, in config order.
191
+ inst_name_to_id: Map from instrument name to internal instrument id.
192
+ free_params: Mutated in place with sampled parameters.
193
+ fixed_params: Mutated in place with fixed parameters.
194
+ param_scopes: Mutated in place with each block's scope summary.
195
+ named_priors: Top-level ``priors:`` registry for ``@name`` references.
196
+ log_func: Logging callable, called as ``log_func(plModel, msg)``.
197
+
198
+ Returns:
199
+ Dict keyed by block name. Each value holds ``kernel_type``,
200
+ ``x_variable``, ``instruments`` (ids), ``instrument_names``,
201
+ ``channels`` (ordered list of names), ``channel_index``,
202
+ ``latent_params`` (name lookup per hyperparameter), ``blocking`` and
203
+ the per-channel parameter name lookups.
204
+
205
+ Raises:
206
+ ValueError: On any invalid configuration; every gate explains what to
207
+ change.
208
+ """
209
+
210
+ def _log(msg: str) -> None:
211
+ if log_func is not None:
212
+ log_func(plModel, msg)
213
+
214
+ section = detrending.get("multi_GP") or {}
215
+ if not section:
216
+ return {}
217
+
218
+ configs: dict[str, Any] = {}
219
+ d_basis = getattr(plData, "dBasis", {}) or {}
220
+
221
+ _log("Multi-GP configuration:")
222
+
223
+ for block_name in sorted(section.keys()):
224
+ cfg = section[block_name]
225
+ if not isinstance(cfg, dict):
226
+ continue
227
+ label = f"multi_GP '{block_name}'"
228
+
229
+ # ---- kernel ---------------------------------------------------
230
+ kernel_type = cfg.get("kernel")
231
+ if kernel_type is None:
232
+ raise ValueError(f"{label}: 'kernel' is mandatory.")
233
+ supported_lower = {k.lower(): k for k in MULTIGP_SUPPORTED_KERNELS}
234
+ if str(kernel_type).lower() not in supported_lower:
235
+ msg = (
236
+ f"{label}: kernel '{kernel_type}' cannot be used as a multi-GP "
237
+ f"latent kernel. The latent process must be twice differentiable "
238
+ f"so that its derivative Gdot exists. Supported: "
239
+ f"{', '.join(MULTIGP_SUPPORTED_KERNELS)}."
240
+ )
241
+ _log(f"ERROR: {msg}")
242
+ raise ValueError(msg)
243
+ kernel_type = supported_lower[str(kernel_type).lower()]
244
+
245
+ # ---- unit-variance latent process -----------------------------
246
+ if "log_gp_sigma" in cfg:
247
+ _log(
248
+ f" WARNING: {label} specifies 'log_gp_sigma'; it is ignored and "
249
+ f"forced to 0 (unit variance). The latent amplitude is carried "
250
+ f"entirely by the per-channel A/B coefficients — a free sigma "
251
+ f"would make (G, A, B) and (kG, A/k, B/k) indistinguishable."
252
+ )
253
+
254
+ # ---- x_variable -----------------------------------------------
255
+ x_variable = cfg.get("x_variable", "TIME")
256
+ if x_variable != "TIME" and x_variable not in d_basis:
257
+ msg = (
258
+ f"{label}: x_variable '{x_variable}' is not a column in the data "
259
+ f"file. Available extra columns: {sorted(d_basis)}."
260
+ )
261
+ _log(f"ERROR: {msg}")
262
+ raise ValueError(msg)
263
+ if x_variable != "TIME":
264
+ _log(
265
+ f" WARNING: {label} sets x_variable='{x_variable}' instead of "
266
+ f"'TIME'. The Ġ term is the time-derivative of the latent process, "
267
+ f"so the multi-GP construction is only physically meaningful when "
268
+ f"the GP runs against time. Using a non-time axis (e.g. an orbital "
269
+ f"phase or another basis column) makes B_c's interpretation "
270
+ f"(a rate, in 1/time) undefined, and the derivative kernel blocks "
271
+ f"were derived and tested against time only. This is untested and "
272
+ f"not recommended; proceed only if you understand what Ġ means "
273
+ f"against your chosen axis."
274
+ )
275
+
276
+ # ---- instruments ----------------------------------------------
277
+ instruments = cfg.get("instruments")
278
+ if instruments is None:
279
+ msg = (
280
+ f"{label}: 'instruments' is mandatory. Specify which instruments "
281
+ f"supply the epochs, e.g.:\n instruments: [{inst_names[0] if inst_names else 'HARPS'}]"
282
+ )
283
+ _log(f"ERROR: {msg}")
284
+ raise ValueError(msg)
285
+ if isinstance(instruments, str):
286
+ instruments = [instruments]
287
+ for name in instruments:
288
+ if name not in inst_name_to_id:
289
+ msg = (
290
+ f"{label}: unknown instrument '{name}' in 'instruments'. "
291
+ f"Available: {inst_names}."
292
+ )
293
+ _log(f"ERROR: {msg}")
294
+ raise ValueError(msg)
295
+ inst_ids = [inst_name_to_id[n] for n in instruments]
296
+ n_block_insts = len(instruments)
297
+
298
+ # Visits covered by this block, needed when a channel asks for
299
+ # per_visit scope on its offset or jitter.
300
+ block_visits: list[Any] = []
301
+ try:
302
+ _inst_arr = np.asarray(plData.instrument)
303
+ _visit_arr = np.asarray(plData.visit)
304
+ _blk_ids = [inst_name_to_id[n] for n in instruments]
305
+ block_visits = sorted(
306
+ {
307
+ v.item() if hasattr(v, "item") else v
308
+ for v in np.unique(_visit_arr[np.isin(_inst_arr, _blk_ids)])
309
+ }
310
+ )
311
+ except Exception:
312
+ block_visits = []
313
+
314
+ # True once any channel parameter is resolved to a grouped scope, so
315
+ # the block's scope summary reflects reality.
316
+ any_grouped = False
317
+
318
+ # ---- channels --------------------------------------------------
319
+ channels_cfg = cfg.get("channels")
320
+ if not isinstance(channels_cfg, dict) or not channels_cfg:
321
+ msg = (
322
+ f"{label}: a 'channels' mapping is mandatory, with at least two "
323
+ f"entries. Use the reserved name '{RESERVED_CHANNEL}' for the "
324
+ f"fitted observable, and a data column name for each indicator."
325
+ )
326
+ _log(f"ERROR: {msg}")
327
+ raise ValueError(msg)
328
+
329
+ # Reserved channel first, then the rest in config order: the first
330
+ # channel is the reference whose A sign fixes the latent sign.
331
+ channel_names = list(channels_cfg.keys())
332
+ if RESERVED_CHANNEL in channel_names:
333
+ channel_names.remove(RESERVED_CHANNEL)
334
+ channel_names.insert(0, RESERVED_CHANNEL)
335
+
336
+ if len(channel_names) < 2:
337
+ msg = (
338
+ f"{label}: a multi-GP needs at least two channels — with one it "
339
+ f"is just an ordinary GP, so use 'detrending.GP' instead."
340
+ )
341
+ _log(f"ERROR: {msg}")
342
+ raise ValueError(msg)
343
+
344
+ for channel in channel_names:
345
+ if channel == RESERVED_CHANNEL:
346
+ continue
347
+ if channel not in d_basis:
348
+ msg = (
349
+ f"{label}: channel '{channel}' is not a column in the data "
350
+ f"file. Available extra columns: {sorted(d_basis)}."
351
+ )
352
+ _log(f"ERROR: {msg}")
353
+ raise ValueError(msg)
354
+ err_col = f"{channel}_ERR"
355
+ if err_col not in d_basis:
356
+ msg = (
357
+ f"{label}: channel '{channel}' has no uncertainty column "
358
+ f"'{err_col}'. Every multi-GP channel needs one, following "
359
+ f"the same convention as detrending.eiv_linear."
360
+ )
361
+ _log(f"ERROR: {msg}")
362
+ raise ValueError(msg)
363
+
364
+ # ---- blocking ---------------------------------------------------
365
+ blocking = cfg.get("blocking", "none")
366
+ if isinstance(blocking, str) and blocking.lower() in ("none", "false"):
367
+ blocking = None
368
+ elif blocking is True or blocking == "per_visit":
369
+ blocking = "per_visit"
370
+ elif isinstance(blocking, dict) and "gap" in blocking:
371
+ blocking = {"gap": float(blocking["gap"])}
372
+ elif blocking is not None:
373
+ msg = (
374
+ f"{label}: 'blocking' must be 'none', 'per_visit', or "
375
+ f"{{gap: <days>}}. Got: {blocking!r}."
376
+ )
377
+ _log(f"ERROR: {msg}")
378
+ raise ValueError(msg)
379
+
380
+ if blocking is not None and kernel_type == "ExpSineSquared":
381
+ msg = (
382
+ f"{label}: 'blocking' cannot be used with an ExpSineSquared "
383
+ f"latent kernel. Its covariance never decays — gamma(nP) == 1 "
384
+ f"for any integer n — so dropping the between-block covariance "
385
+ f"is mathematically wrong, not merely approximate."
386
+ )
387
+ _log(f"ERROR: {msg}")
388
+ raise ValueError(msg)
389
+
390
+ # ---- per-channel parameter validation (fail fast, before assembly) --
391
+ for channel in channel_names:
392
+ ch_cfg = channels_cfg[channel] or {}
393
+ if not isinstance(ch_cfg, dict):
394
+ msg = f"{label}: channel '{channel}' must be a mapping of parameters."
395
+ _log(f"ERROR: {msg}")
396
+ raise ValueError(msg)
397
+
398
+ for required in ("A", "B"):
399
+ if required not in ch_cfg:
400
+ msg = (
401
+ f"{label}: channel '{channel}' is missing '{required}'. "
402
+ f"Both projection coefficients are required; set "
403
+ f"'B: 0.0' for an amplitude-only channel (Rajpaul's L_c)."
404
+ )
405
+ _log(f"ERROR: {msg}")
406
+ raise ValueError(msg)
407
+
408
+ _validate_channel_keys(label, channel, ch_cfg, _log)
409
+
410
+ # 'Y' already has its offset (systemic velocity / normalization) and
411
+ # its jitter from the main model; duplicating them here would create
412
+ # an exactly degenerate pair of parameters.
413
+ if channel == RESERVED_CHANNEL:
414
+ for forbidden in ("offset", "log_jitter"):
415
+ if forbidden in ch_cfg:
416
+ msg = (
417
+ f"{label}: channel '{RESERVED_CHANNEL}' must not set "
418
+ f"'{forbidden}'. The fitted observable already takes "
419
+ f"its offset from detrending.normalization and its "
420
+ f"jitter from log_jitter; a second copy would be "
421
+ f"exactly degenerate with the first."
422
+ )
423
+ _log(f"ERROR: {msg}")
424
+ raise ValueError(msg)
425
+ trend_keys = [k for k in ch_cfg if TREND_KEY_RE.match(str(k))]
426
+ if trend_keys:
427
+ msg = (
428
+ f"{label}: channel '{RESERVED_CHANNEL}' must not set trend "
429
+ f"coefficients ({', '.join(trend_keys)}). Trends of the "
430
+ f"fitted observable belong in detrending.polynomials; a "
431
+ f"second copy here would be exactly degenerate with it."
432
+ )
433
+ _log(f"ERROR: {msg}")
434
+ raise ValueError(msg)
435
+
436
+ # ---- per-channel parameter parsing -----------------------------------
437
+ # The insertion order here is the free-parameter order everywhere
438
+ # downstream (log file, corner plot, MCMC summary table): grouped by
439
+ # *kind of parameter* across channels, not by channel. For a block
440
+ # with several channels this reads far better than interleaving four
441
+ # different kinds of parameter once per channel:
442
+ # jitter (one per non-reference channel) -> offset (one per
443
+ # non-reference channel) -> trend coefficients (per non-reference
444
+ # channel, degrees ascending) -> GP hyperparameters -> A, B (one
445
+ # pair per channel, reference channel included).
446
+ channel_param_names: dict[str, dict[str, Any]] = {
447
+ channel: {} for channel in channel_names
448
+ }
449
+
450
+ def _parse_channel_param(channel: str, pname: str) -> None:
451
+ nonlocal any_grouped
452
+ ch_cfg = channels_cfg[channel] or {}
453
+ if pname not in ch_cfg:
454
+ return
455
+ raw = resolve_config_value(
456
+ ch_cfg[pname],
457
+ named_priors,
458
+ f"detrending.multi_GP.{block_name}.channels.{channel}.{pname}",
459
+ )
460
+ tied = raw.canonical_name if is_resolved_prior_reference(raw) else None
461
+ is_kernel_param = pname in _KERNEL_CHANNEL_PARAMS
462
+ allowed = (
463
+ {"global"}
464
+ if is_kernel_param
465
+ else {"global", "per_instrument", "per_visit"}
466
+ )
467
+ normalised = _normalise_scoped(raw)
468
+ if is_kernel_param:
469
+ _refuse_grouped_kernel_param(
470
+ normalised, label, block_name, channel, pname, instruments
471
+ )
472
+ scope, entries = _parse_scoped_param(
473
+ normalised,
474
+ allowed,
475
+ n_block_insts,
476
+ f"{label}.channels.{channel}.{pname}",
477
+ )
478
+
479
+ if scope == "global":
480
+ spec = entries[0]
481
+ full = tied or f"{pname}_{block_name}_{channel}"
482
+ if isinstance(spec, (int, float)):
483
+ fixed_params[full] = spec
484
+ elif spec is not None:
485
+ free_params.setdefault(full, spec)
486
+ channel_param_names[channel][pname] = {
487
+ "scope": "global",
488
+ "names": [full],
489
+ "groups": [None],
490
+ }
491
+ elif is_kernel_param:
492
+ # Unreachable via _parse_scoped_param (which only allows
493
+ # 'global' for these), but kept explicit so the reason
494
+ # survives if the allowed set is ever widened by mistake.
495
+ msg = (
496
+ f"{label}: '{scope}' scope is not supported for "
497
+ f"'{channel}.{pname}'. A/B set how the latent process "
498
+ f"projects into the channel, so varying them by group "
499
+ f"would need one kernel channel per (channel, group) "
500
+ f"pair. Use 'global', or define one multi_GP block per "
501
+ f"instrument. Note that 'offset' and 'log_jitter' DO "
502
+ f"accept per_instrument and per_visit scope."
503
+ )
504
+ _log(f"ERROR: {msg}")
505
+ raise ValueError(msg)
506
+ elif scope == "per_instrument":
507
+ per_names, groups = [], []
508
+ for i, iname in enumerate(instruments):
509
+ spec = entries[i]
510
+ full = f"{pname}_{block_name}_{channel}_{iname}"
511
+ if isinstance(spec, (int, float)):
512
+ fixed_params[full] = spec
513
+ elif spec is not None:
514
+ free_params[full] = spec
515
+ per_names.append(full)
516
+ groups.append(inst_name_to_id[iname])
517
+ channel_param_names[channel][pname] = {
518
+ "scope": "per_instrument",
519
+ "names": per_names,
520
+ "groups": groups,
521
+ }
522
+ any_grouped = True
523
+ else: # per_visit
524
+ if not block_visits:
525
+ msg = (
526
+ f"{label}: 'per_visit' scope requested for "
527
+ f"'{channel}.{pname}', but no VISIT information is "
528
+ f"available for this block's instruments."
529
+ )
530
+ _log(f"ERROR: {msg}")
531
+ raise ValueError(msg)
532
+ spec = entries[0] # one prior, shared by every visit
533
+ per_names, groups = [], []
534
+ for v in block_visits:
535
+ full = f"{pname}_{block_name}_{channel}_v{v}"
536
+ if isinstance(spec, (int, float)):
537
+ fixed_params[full] = spec
538
+ elif spec is not None:
539
+ free_params[full] = spec
540
+ per_names.append(full)
541
+ groups.append(v)
542
+ channel_param_names[channel][pname] = {
543
+ "scope": "per_visit",
544
+ "names": per_names,
545
+ "groups": groups,
546
+ }
547
+ any_grouped = True
548
+
549
+ # Sign degeneracy: (G, A, B) -> (-G, -A, -B) is unobservable and
550
+ # produces a bimodal posterior that reads as a convergence
551
+ # failure. Pinning the reference channel's A to be
552
+ # non-negative removes the mirror mode.
553
+ if (
554
+ pname == "A"
555
+ and channel == channel_names[0]
556
+ and scope == "global"
557
+ and not isinstance(entries[0], (int, float))
558
+ ):
559
+ low = _prior_lower_bound(entries[0])
560
+ if low is None or low < 0.0:
561
+ _log(
562
+ f" WARNING: {label} reference channel "
563
+ f"'{channel}': the prior on A is not bounded below "
564
+ f"at 0. The latent sign is then unidentifiable "
565
+ f"((G, A, B) and (-G, -A, -B) fit equally well) and "
566
+ f"the posterior will be bimodal, which looks like a "
567
+ f"convergence failure. Use e.g. A: [uniform, 0, ...]."
568
+ )
569
+
570
+ # 1) jitter, one per non-reference channel, in channel order
571
+ for channel in channel_names:
572
+ if channel != RESERVED_CHANNEL:
573
+ _parse_channel_param(channel, "log_jitter")
574
+
575
+ # 2) offset, one per non-reference channel, in channel order
576
+ for channel in channel_names:
577
+ if channel != RESERVED_CHANNEL:
578
+ _parse_channel_param(channel, "offset")
579
+
580
+ # 3) trend coefficients, per non-reference channel, degrees ascending.
581
+ # Each gets one centre per scope group, computed here from the real
582
+ # data over the channel's usable rows, and never recomputed later.
583
+ for channel in channel_names:
584
+ if channel == RESERVED_CHANNEL:
585
+ continue
586
+ trend_keys = sorted(
587
+ (k for k in (channels_cfg[channel] or {}) if TREND_KEY_RE.match(k)),
588
+ key=lambda k: int(k[1:]),
589
+ )
590
+ if not trend_keys:
591
+ continue
592
+ ch_rows = usable_channel_rows(
593
+ np.where(np.isin(np.asarray(plData.instrument), inst_ids))[0],
594
+ d_basis[channel],
595
+ d_basis[f"{channel}_ERR"],
596
+ )
597
+ x_all = np.asarray(
598
+ plData.time if x_variable == "TIME" else d_basis[x_variable],
599
+ dtype=float,
600
+ )
601
+ for key in trend_keys:
602
+ _parse_channel_param(channel, key)
603
+ entry = channel_param_names[channel][key]
604
+ entry["degree"] = int(key[1:])
605
+ entry["centers"] = group_centers(
606
+ x_all,
607
+ ch_rows,
608
+ entry["scope"],
609
+ entry["groups"],
610
+ np.asarray(plData.instrument),
611
+ np.asarray(plData.visit),
612
+ )
613
+ empty = [
614
+ g for g, c in zip(entry["groups"], entry["centers"]) if np.isnan(c)
615
+ ]
616
+ if empty:
617
+ _log(
618
+ f" WARNING: {label} channel '{channel}' {key}: groups "
619
+ f"{empty} have no usable '{channel}' rows; their "
620
+ f"coefficients are constrained by the prior only."
621
+ )
622
+
623
+ # 4) GP latent hyperparameters
624
+ latent_param_names: dict[str, str] = {}
625
+ param_order = get_multigp_param_order(kernel_type)
626
+ for pname in param_order:
627
+ if pname not in cfg:
628
+ msg = (
629
+ f"{label}: kernel '{kernel_type}' requires '{pname}', which "
630
+ f"is missing. Required: {param_order}."
631
+ )
632
+ _log(f"ERROR: {msg}")
633
+ raise ValueError(msg)
634
+ raw = resolve_config_value(
635
+ cfg[pname], named_priors, f"detrending.multi_GP.{block_name}.{pname}"
636
+ )
637
+ tied = raw.canonical_name if is_resolved_prior_reference(raw) else None
638
+ _scope, entries = _parse_scoped_param(
639
+ _normalise_scoped(raw), {"global"}, 1, f"{label}.{pname}"
640
+ )
641
+ spec = entries[0]
642
+ full_name = tied or f"{pname}_{block_name}"
643
+ if isinstance(spec, (int, float)):
644
+ fixed_params[full_name] = spec
645
+ elif spec is not None:
646
+ free_params.setdefault(full_name, spec)
647
+ latent_param_names[pname] = full_name
648
+
649
+ # 5) A, B per channel (reference channel included), in channel order
650
+ for channel in channel_names:
651
+ _parse_channel_param(channel, "A")
652
+ _parse_channel_param(channel, "B")
653
+
654
+ configs[block_name] = {
655
+ "name": block_name,
656
+ "kernel_type": kernel_type,
657
+ "x_variable": x_variable,
658
+ "instruments": inst_ids,
659
+ "instrument_names": list(instruments),
660
+ "channels": channel_names,
661
+ "channel_index": {c: i for i, c in enumerate(channel_names)},
662
+ "latent_params": latent_param_names,
663
+ "channel_params": channel_param_names,
664
+ "blocking": blocking,
665
+ }
666
+ param_scopes[block_name] = "per_group" if any_grouped else "global"
667
+
668
+ # ---- logging ------------------------------------------------------
669
+ # Kept deliberately short: one line of setup, one of channels. Anything
670
+ # a user needs to *decide* something (warnings, errors) is logged above;
671
+ # this is only the "what was configured" record.
672
+ described = []
673
+ for channel in channel_names:
674
+ suffix = ""
675
+ b_spec = (channels_cfg[channel] or {}).get("B")
676
+ if isinstance(b_spec, (int, float)) and float(b_spec) == 0.0:
677
+ suffix = ", B=0"
678
+ if channel == RESERVED_CHANNEL:
679
+ described.append(f"{channel} (fitted observable{suffix})")
680
+ else:
681
+ described.append(f"{channel}{suffix}")
682
+ _log(
683
+ f" {block_name}: kernel={kernel_type}, x={x_variable}, "
684
+ f"instruments={list(instruments)}, "
685
+ f"blocking={blocking if blocking is not None else 'none'}"
686
+ )
687
+ _log(f" channels (reference first): {', '.join(described)}")
688
+ for channel in channel_names:
689
+ for key, entry in channel_param_names[channel].items():
690
+ if "centers" not in entry:
691
+ continue
692
+ where = ", ".join(
693
+ f"{x_variable}={c:.6g}" for c in entry["centers"] if np.isfinite(c)
694
+ )
695
+ _log(f" {channel}: {key} ({entry['scope']}) about {where}")
696
+
697
+ return configs