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,417 @@
1
+ """Multi-dimensional GP kernel (Rajpaul+2015 / Barragán+2022) for PyAETNA.
2
+
3
+ This module implements the *joint* cross-output covariance that couples several
4
+ observables to one latent stellar-activity process — as opposed to the
5
+ block-diagonal, one-GP-per-instrument likelihood built by
6
+ :mod:`PyAETNA.kernels.factory`.
7
+
8
+ The model
9
+ ---------
10
+ A single latent, **unit-variance** process :math:`G(t)` with covariance
11
+ :math:`\\gamma(t_i, t_j)`. Each observable channel :math:`c` is a linear
12
+ projection of that process and its time derivative:
13
+
14
+ .. math::
15
+
16
+ \\Delta_c(t) = A_c\\, G(t) + B_c\\, \\dot{G}(t)
17
+
18
+ This is Barragán et al. (2022) Eq. 1, the N-channel generalisation of Rajpaul
19
+ et al. (2015) Eqs. 14-16. The paper's three observables are the special case
20
+ ``RV -> (V_c, V_r)``, ``BIS -> (B_c, B_r)``, ``log R'HK -> (L_c, 0)``.
21
+
22
+ The covariance between channels :math:`c` and :math:`d` (Rajpaul Eq. 23) is
23
+
24
+ .. math::
25
+
26
+ \\mathrm{cov}(\\Delta_c(t_i), \\Delta_d(t_j)) =
27
+ A_c A_d\\, \\gamma
28
+ + A_c B_d\\, \\partial\\gamma/\\partial t_j
29
+ + B_c A_d\\, \\partial\\gamma/\\partial t_i
30
+ + B_c B_d\\, \\partial^2\\gamma/\\partial t_i \\partial t_j
31
+
32
+ The two middle terms are antisymmetric: they vanish at zero lag but not
33
+ otherwise, and they encode the phase relationship between observables. They
34
+ are exactly what a block-diagonal likelihood discards.
35
+
36
+ Implementation notes
37
+ --------------------
38
+ * The derivative blocks are obtained by **nested** :func:`jax.grad` on the
39
+ scalar latent function, so any twice-differentiable latent kernel works with
40
+ no hand-derived algebra.
41
+ * The kernel operates on ``tinygp`` pytree inputs ``X = (t, channel_index)``.
42
+ * The latent kernel carries **no amplitude**: :math:`\\gamma` is normalised to
43
+ unit variance and all amplitude information lives in :math:`A_c`/:math:`B_c`.
44
+ Otherwise ``(G, A, B) -> (kG, A/k, B/k)`` would be unidentifiable.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from collections.abc import Callable
50
+ from typing import Any
51
+
52
+ import equinox as eqx
53
+ import jax
54
+ import jax.numpy as jnp
55
+ from tinygp import kernels
56
+
57
+ __all__ = [
58
+ "MULTIGP_PARAM_ORDERS",
59
+ "MULTIGP_SUPPORTED_KERNELS",
60
+ "ChannelAutoKernel",
61
+ "MultiChannelKernel",
62
+ "build_latent_kernel_fn",
63
+ "get_multigp_param_order",
64
+ "multigp_log_probability",
65
+ "multigp_log_probability_blocked",
66
+ ]
67
+
68
+ # Latent kernels admissible for a multi-GP block.
69
+ #
70
+ # ``Gdot`` only exists if ``gamma`` is twice differentiable in the mean-square
71
+ # sense. Matern32 is deliberately EXCLUDED: the Matern-3/2 process is only
72
+ # *once* mean-square differentiable, so its derivative process is not defined.
73
+ # It cannot be left to fail at runtime — autodiff happily returns a finite,
74
+ # plausible-looking second derivative (JAX takes the subgradient of |tau| at
75
+ # zero) and the sampled matrix stays numerically positive semi-definite, so the
76
+ # misspecification is completely silent. Hence an explicit whitelist.
77
+ #
78
+ # The ``_quasisep`` kernels and SHO are excluded for a different reason: the
79
+ # quasiseparable solver cannot represent the cross-derivative blocks, so they
80
+ # would silently fall back to a dense solve anyway.
81
+ MULTIGP_SUPPORTED_KERNELS = (
82
+ "ExpSquared",
83
+ "RBF",
84
+ "QuasiPeriodic",
85
+ "ExpSineSquared",
86
+ "Cosine",
87
+ "Matern52",
88
+ "RationalQuadratic",
89
+ )
90
+
91
+ # Hyperparameter names per latent kernel. These mirror
92
+ # ``PyAETNA.sampling.utils._get_default_param_order`` but **omit
93
+ # ``log_gp_sigma``**: the latent process is unit-variance by construction (see
94
+ # the module docstring). The naming convention is unchanged — everything is
95
+ # sampled in log scale except ``gp_freq``, and the kernel converts
96
+ # ``period = 1 / freq`` internally.
97
+ MULTIGP_PARAM_ORDERS: dict[str, list[str]] = {
98
+ "ExpSquared": ["log_gp_scale"],
99
+ "RBF": ["log_gp_scale"],
100
+ "Matern52": ["log_gp_scale"],
101
+ "RationalQuadratic": ["log_gp_scale", "log_gp_alpha"],
102
+ "Cosine": ["gp_freq"],
103
+ "ExpSineSquared": ["gp_freq", "log_gp_gamma"],
104
+ "QuasiPeriodic": ["log_gp_se_scale", "gp_freq", "log_gp_gamma"],
105
+ }
106
+
107
+
108
+ def get_multigp_param_order(kernel_type: str) -> list[str]:
109
+ """Return the latent hyperparameter names for a multi-GP kernel type.
110
+
111
+ Args:
112
+ kernel_type: Kernel identifier (case-insensitive).
113
+
114
+ Returns:
115
+ Ordered list of hyperparameter names, or ``[]`` if unknown.
116
+ """
117
+ for name, order in MULTIGP_PARAM_ORDERS.items():
118
+ if name.lower() == kernel_type.lower():
119
+ return list(order)
120
+ return []
121
+
122
+
123
+ def build_latent_kernel_fn(
124
+ kernel_type: str,
125
+ ) -> Callable[[dict[str, Any], Any, Any], Any]:
126
+ """Build the scalar, unit-variance latent covariance ``gamma(params, t1, t2)``.
127
+
128
+ The hyperparameters are an *argument*, not a closure capture, so that
129
+ :class:`MultiChannelKernel` can hold the function as a **static** equinox
130
+ field while the hyperparameters stay traced — which is what lets gradients
131
+ reach them under NUTS.
132
+
133
+ The returned function is differentiable in ``t1`` and ``t2``, so the kernel
134
+ can form its derivative blocks with :func:`jax.grad`.
135
+
136
+ Args:
137
+ kernel_type: One of :data:`MULTIGP_SUPPORTED_KERNELS`.
138
+
139
+ Returns:
140
+ ``gamma(params, t1, t2)`` returning a scalar. ``params`` holds
141
+ hyperparameters in **linear** scale (already exponentiated from their
142
+ log-sampled values), keyed by the names in
143
+ :data:`MULTIGP_PARAM_ORDERS` with any ``log_`` prefix stripped — e.g.
144
+ ``{"gp_scale": 12.0}``.
145
+
146
+ Raises:
147
+ ValueError: If ``kernel_type`` is not admissible for a multi-GP block.
148
+ """
149
+ kt = kernel_type.lower()
150
+
151
+ if kt not in {k.lower() for k in MULTIGP_SUPPORTED_KERNELS}:
152
+ raise ValueError(
153
+ f"Kernel '{kernel_type}' cannot be used as a multi-GP latent kernel. "
154
+ f"The latent process must be twice differentiable so that its "
155
+ f"derivative Gdot exists. Supported: "
156
+ f"{', '.join(MULTIGP_SUPPORTED_KERNELS)}."
157
+ )
158
+
159
+ if kt in ("expsquared", "rbf"):
160
+
161
+ def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
162
+ return jnp.exp(-0.5 * ((t1 - t2) / p["gp_scale"]) ** 2)
163
+
164
+ elif kt == "matern52":
165
+
166
+ def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
167
+ r = jnp.sqrt(5.0) * jnp.abs(t1 - t2) / p["gp_scale"]
168
+ return (1.0 + r + r**2 / 3.0) * jnp.exp(-r)
169
+
170
+ elif kt == "rationalquadratic":
171
+
172
+ def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
173
+ alpha = p["gp_alpha"]
174
+ return (1.0 + 0.5 * ((t1 - t2) / p["gp_scale"]) ** 2 / alpha) ** (-alpha)
175
+
176
+ elif kt == "cosine":
177
+
178
+ def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
179
+ return jnp.cos(2.0 * jnp.pi * (t1 - t2) * p["gp_freq"])
180
+
181
+ elif kt == "expsinesquared":
182
+
183
+ def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
184
+ return jnp.exp(
185
+ -p["gp_gamma"] * jnp.sin(jnp.pi * (t1 - t2) * p["gp_freq"]) ** 2
186
+ )
187
+
188
+ else: # quasiperiodic
189
+
190
+ def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
191
+ tau = t1 - t2
192
+ return jnp.exp(
193
+ -0.5 * (tau / p["gp_se_scale"]) ** 2
194
+ - p["gp_gamma"] * jnp.sin(jnp.pi * tau * p["gp_freq"]) ** 2
195
+ )
196
+
197
+ return gamma
198
+
199
+
200
+ class MultiChannelKernel(kernels.Kernel):
201
+ """Rajpaul/Barragán cross-output covariance over ``X = (t, channel_index)``.
202
+
203
+ Attributes:
204
+ latent: Scalar unit-variance covariance ``gamma(params, t1, t2)``
205
+ from :func:`build_latent_kernel_fn`; a **static** field.
206
+ params: Latent hyperparameters in linear scale; a traced field, so
207
+ gradients reach them.
208
+ A: Shape ``(n_channels,)``. Projection onto ``G``; signed.
209
+ B: Shape ``(n_channels,)``. Projection onto ``Gdot``; signed.
210
+
211
+ ``A`` and ``B`` are deliberately **linear, signed** quantities rather than
212
+ log-sampled: in Rajpaul et al. the relative signs of ``(V_c, V_r)`` and
213
+ ``(B_c, B_r)`` are physically meaningful (BIS typically anti-correlates
214
+ with RV), and the sign structure is normally what the fit should measure.
215
+
216
+ The residual ``(G, A, B) -> (-G, -A, -B)`` sign degeneracy is broken by the
217
+ parser, which requires the reference channel's ``A`` prior to be bounded
218
+ below at zero.
219
+ """
220
+
221
+ latent: Callable[[dict[str, Any], Any, Any], Any] = eqx.field(static=True)
222
+ params: dict[str, Any]
223
+ A: jnp.ndarray
224
+ B: jnp.ndarray
225
+
226
+ def evaluate(self, X1: Any, X2: Any) -> Any:
227
+ """Covariance between one pair of (time, channel) points."""
228
+ t1, c1 = X1
229
+ t2, c2 = X2
230
+
231
+ # `latent` is a static field (a plain function) while `params` is a
232
+ # traced field, so gradients reach the latent hyperparameters.
233
+ def k(a: Any, b: Any) -> Any:
234
+ return self.latent(self.params, a, b)
235
+
236
+ dk_dt1 = jax.grad(k, argnums=0)
237
+ dk_dt2 = jax.grad(k, argnums=1)
238
+ d2k = jax.grad(dk_dt1, argnums=1)
239
+
240
+ a1, b1 = self.A[c1], self.B[c1]
241
+ a2, b2 = self.A[c2], self.B[c2]
242
+
243
+ return (
244
+ a1 * a2 * k(t1, t2)
245
+ + a1 * b2 * dk_dt2(t1, t2)
246
+ + b1 * a2 * dk_dt1(t1, t2)
247
+ + b1 * b2 * d2k(t1, t2)
248
+ )
249
+
250
+
251
+ def multigp_log_probability(
252
+ kernel: MultiChannelKernel,
253
+ t: jnp.ndarray,
254
+ channel: jnp.ndarray,
255
+ res: jnp.ndarray,
256
+ diag: jnp.ndarray,
257
+ ) -> jnp.ndarray:
258
+ """Marginal log-probability of the full joint multi-channel covariance.
259
+
260
+ Args:
261
+ kernel: The :class:`MultiChannelKernel`.
262
+ t: Times of every point, all channels concatenated. Shape ``(N,)``.
263
+ channel: Channel index of every point, same order. Shape ``(N,)``.
264
+ res: Residuals (observable minus its mean model). Shape ``(N,)``.
265
+ diag: Per-point variance (measurement error squared plus jitter).
266
+ Shape ``(N,)``.
267
+
268
+ Returns:
269
+ Scalar log-probability. Cost is ``O(N^3)`` with ``N`` the total across
270
+ **all** channels.
271
+ """
272
+ from tinygp import GaussianProcess
273
+
274
+ # tinygp types X as an Array, but a pytree input is the documented way to
275
+ # carry structured inputs (its "Derivative Observations & Pytree Data"
276
+ # pattern). The tuple is what MultiChannelKernel.evaluate unpacks.
277
+ gp = GaussianProcess(kernel, (t, channel), diag=diag) # type: ignore[arg-type]
278
+ return gp.log_probability(res)
279
+
280
+
281
+ def multigp_log_probability_blocked(
282
+ kernel: MultiChannelKernel,
283
+ t: jnp.ndarray,
284
+ channel: jnp.ndarray,
285
+ res: jnp.ndarray,
286
+ diag: jnp.ndarray,
287
+ block_indices: list[Any],
288
+ ) -> jnp.ndarray:
289
+ """Block-diagonal version of :func:`multigp_log_probability`.
290
+
291
+ Each block holds **all channels** for the epochs it covers, so the
292
+ cross-channel information — the whole point of the multi-GP — is preserved
293
+ *within* a block. Only the covariance *between* blocks is dropped.
294
+
295
+ .. math::
296
+
297
+ \\log p(\\mathbf{y} \\mid \\theta) = \\sum_b \\log p(\\mathbf{y}_b \\mid \\theta)
298
+
299
+ Cost drops from ``N^3`` to ``sum_b n_b^3``, i.e. roughly ``N^3 / n_blocks^2``.
300
+
301
+ .. warning::
302
+
303
+ Valid only when the between-block gap greatly exceeds the latent
304
+ kernel's decay scale, so the dropped off-block covariance really is
305
+ negligible. As with
306
+ :func:`PyAETNA.gp.gp_log_probability_per_visit`, this is **never**
307
+ valid for a pure ``ExpSineSquared`` latent kernel, whose covariance
308
+ does not decay at all: ``gamma(nP) == 1`` for any integer ``n``.
309
+ For ``QuasiPeriodic`` it is conditionally valid, when the gap greatly
310
+ exceeds ``exp(log_gp_se_scale)``.
311
+
312
+ Padding follows the convention of
313
+ :func:`PyAETNA.gp.gp_log_probability_per_visit`: padded points get
314
+ ``diag = 1e30`` (effectively infinite noise, so they cannot affect the
315
+ real-data contribution), zero residuals, and times pushed far beyond the
316
+ last real point so their covariance with real data vanishes.
317
+
318
+ Args:
319
+ kernel: The :class:`MultiChannelKernel`.
320
+ t: Times of every point, all channels concatenated.
321
+ channel: Channel index of every point, same order.
322
+ res: Residuals.
323
+ diag: Per-point variance.
324
+ block_indices: One index array per block, giving positions within the
325
+ concatenated arrays. Each must already contain every channel's
326
+ points for its epochs.
327
+
328
+ Returns:
329
+ Scalar log-probability summed over blocks.
330
+ """
331
+ n_blocks = len(block_indices)
332
+ if n_blocks == 0:
333
+ return jnp.zeros(())
334
+ if n_blocks == 1:
335
+ idx = block_indices[0]
336
+ return multigp_log_probability(
337
+ kernel, t[idx], channel[idx], res[idx], diag[idx]
338
+ )
339
+
340
+ max_len = max(len(idx) for idx in block_indices)
341
+
342
+ t_pad = jnp.zeros((n_blocks, max_len), dtype=t.dtype)
343
+ c_pad = jnp.zeros((n_blocks, max_len), dtype=channel.dtype)
344
+ r_pad = jnp.zeros((n_blocks, max_len), dtype=res.dtype)
345
+ d_pad = jnp.full((n_blocks, max_len), 1e30, dtype=diag.dtype)
346
+
347
+ for i, idx in enumerate(block_indices):
348
+ n_b = len(idx) # static at trace time
349
+ t_pad = t_pad.at[i, :n_b].set(t[idx])
350
+ c_pad = c_pad.at[i, :n_b].set(channel[idx])
351
+ r_pad = r_pad.at[i, :n_b].set(res[idx])
352
+ d_pad = d_pad.at[i, :n_b].set(diag[idx])
353
+ if n_b < max_len:
354
+ n_extra = max_len - n_b
355
+ last_t = t[idx[-1]]
356
+ pad_start = last_t + jnp.asarray(1e4, dtype=t.dtype)
357
+ t_pad = t_pad.at[i, n_b:].set(
358
+ pad_start + jnp.arange(n_extra, dtype=t.dtype) * 0.001
359
+ )
360
+
361
+ def _block_logprob(
362
+ t_b: jnp.ndarray, c_b: jnp.ndarray, r_b: jnp.ndarray, d_b: jnp.ndarray
363
+ ) -> jnp.ndarray:
364
+ return multigp_log_probability(kernel, t_b, c_b, r_b, d_b)
365
+
366
+ return jnp.sum(jax.vmap(_block_logprob)(t_pad, c_pad, r_pad, d_pad))
367
+
368
+
369
+ class ChannelAutoKernel(kernels.Kernel):
370
+ """One multi-GP channel's own covariance, as a function of scalar time.
371
+
372
+ :class:`MultiChannelKernel` needs ``(time, channel_index)`` pairs, which
373
+ makes it unusable with the report's PSD helpers — those call
374
+ ``kernel(t, t)`` on a plain time array. This wrapper exposes a single
375
+ channel's auto-covariance in that ordinary form:
376
+
377
+ .. math::
378
+
379
+ k(t_1,t_2) = A^2\\gamma
380
+ + A B\\,\\partial\\gamma/\\partial t_2
381
+ + B A\\,\\partial\\gamma/\\partial t_1
382
+ + B^2\\,\\partial^2\\gamma/\\partial t_1\\partial t_2
383
+
384
+ which is the diagonal block of the joint covariance — exactly the kernel
385
+ that channel would have had if it were fitted on its own. It is what a
386
+ periodogram PSD overlay should be compared against, since the periodogram
387
+ is likewise computed from that channel alone.
388
+
389
+ Attributes:
390
+ latent: Scalar unit-variance covariance ``gamma(params, t1, t2)``;
391
+ a **static** field.
392
+ params: Latent hyperparameters in linear scale; a traced field.
393
+ A: Projection onto ``G`` for this channel.
394
+ B: Projection onto ``Gdot`` for this channel.
395
+ """
396
+
397
+ latent: Callable[[dict[str, Any], Any, Any], Any] = eqx.field(static=True)
398
+ params: dict[str, Any]
399
+ A: Any
400
+ B: Any
401
+
402
+ def evaluate(self, X1: Any, X2: Any) -> Any:
403
+ """Covariance between two scalar times."""
404
+
405
+ def k(a: Any, b: Any) -> Any:
406
+ return self.latent(self.params, a, b)
407
+
408
+ dk_dt1 = jax.grad(k, argnums=0)
409
+ dk_dt2 = jax.grad(k, argnums=1)
410
+ d2k = jax.grad(dk_dt1, argnums=1)
411
+
412
+ return (
413
+ self.A * self.A * k(X1, X2)
414
+ + self.A * self.B * dk_dt2(X1, X2)
415
+ + self.B * self.A * dk_dt1(X1, X2)
416
+ + self.B * self.B * d2k(X1, X2)
417
+ )
PyAETNA/logger.py ADDED
@@ -0,0 +1,73 @@
1
+ """
2
+ Canonical repository-wide logging module for PyAETNA.
3
+
4
+ Provides a unified logger `_log` supporting both implicit module-level log file
5
+ destinations (configured via `set_log_file`) and explicit model/file target parameters.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time as ttt
11
+ from typing import Any
12
+
13
+ _LOG_FILE: str | None = None
14
+ _EIV_TRIGGERED = False
15
+
16
+
17
+ def set_log_file(path: str | None) -> None:
18
+ """Set the module-wide default log file path.
19
+
20
+ All subsequent calls to `_log` without an explicit target will write to `path`.
21
+ Pass `None` to log to stdout by default.
22
+ """
23
+ global _LOG_FILE
24
+ _LOG_FILE = path
25
+
26
+
27
+ def get_log_file() -> str | None:
28
+ """Return the current default log file path."""
29
+ return _LOG_FILE
30
+
31
+
32
+ def _log(target_or_msg: Any, msg: str | None = None, level: str | None = None) -> None:
33
+ """Write a timestamped message to a log file or stdout.
34
+
35
+ Signatures supported:
36
+ _log("Message", level="WARNING") # Default target (_LOG_FILE or stdout)
37
+ _log(plModel, "Message") # Explicit model or path target
38
+ _log(log_file_path, "Message") # Explicit string path target
39
+
40
+ Args:
41
+ target_or_msg: Target object (with `.logFile` attribute), path (str), or message string.
42
+ msg (str, optional): Message string if target_or_msg is a target object/path.
43
+ level (str, optional): Severity level (e.g. 'WARNING', 'ERROR'). Standard 'INFO' level
44
+ is omitted from the output string as messages are informative by nature.
45
+ """
46
+ if msg is not None:
47
+ target = target_or_msg
48
+ message = msg
49
+ else:
50
+ target = None
51
+ message = target_or_msg
52
+
53
+ # Extract path from target object if present, otherwise use target path or module default
54
+ log_file = target.logFile if hasattr(target, "logFile") else (target or _LOG_FILE)
55
+
56
+ # Omit 'INFO' tag from output line; include non-INFO severity levels (WARNING, ERROR, etc.)
57
+ level_tag = f" {level.upper()}:" if level and level.upper() != "INFO" else ""
58
+ timestamp = ttt.strftime("%Y-%m-%d %H:%M:%S", ttt.gmtime())
59
+ formatted = f"{timestamp}{level_tag} {message}"
60
+
61
+ if not log_file:
62
+ print(formatted)
63
+ return
64
+
65
+ try:
66
+ with open(log_file, "a") as f:
67
+ f.write(formatted + "\n")
68
+ except OSError as e:
69
+ print(f"{formatted} (warning: failed to write to log file {log_file!r}: {e})")
70
+
71
+
72
+ # Default log file is stdout (None)
73
+ set_log_file(None)