binary-galactica 0.5.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 (37) hide show
  1. binary_galactica/__init__.py +139 -0
  2. binary_galactica/constants.py +18 -0
  3. binary_galactica/detector.py +142 -0
  4. binary_galactica/glass/__init__.py +118 -0
  5. binary_galactica/glass/_precision.py +49 -0
  6. binary_galactica/glass/_twofloat.py +117 -0
  7. binary_galactica/glass/_validation.py +94 -0
  8. binary_galactica/glass/alignment.py +170 -0
  9. binary_galactica/glass/bandwidth.py +168 -0
  10. binary_galactica/glass/compat.py +146 -0
  11. binary_galactica/glass/constants.py +83 -0
  12. binary_galactica/glass/convert.py +91 -0
  13. binary_galactica/glass/fourier.py +387 -0
  14. binary_galactica/glass/interpolation.py +255 -0
  15. binary_galactica/glass/onfly.py +195 -0
  16. binary_galactica/glass/orbit.py +163 -0
  17. binary_galactica/glass/prior.py +306 -0
  18. binary_galactica/glass/proposals.py +488 -0
  19. binary_galactica/glass/response.py +146 -0
  20. binary_galactica/glass/single.py +455 -0
  21. binary_galactica/glass/sparse.py +113 -0
  22. binary_galactica/glass/tdi.py +159 -0
  23. binary_galactica/glass/types.py +24 -0
  24. binary_galactica/glass/waveform.py +360 -0
  25. binary_galactica/parameters.py +221 -0
  26. binary_galactica/py.typed +0 -0
  27. binary_galactica/single.py +462 -0
  28. binary_galactica/waveform.py +196 -0
  29. binary_galactica-0.5.0.dist-info/METADATA +614 -0
  30. binary_galactica-0.5.0.dist-info/RECORD +37 -0
  31. binary_galactica-0.5.0.dist-info/WHEEL +4 -0
  32. binary_galactica-0.5.0.dist-info/licenses/LICENSE +203 -0
  33. binary_galactica-0.5.0.dist-info/licenses/NOTICE +42 -0
  34. binary_galactica-0.5.0.dist-info/licenses/tools/glass_reference/LICENSE-GLASS +201 -0
  35. binary_galactica-0.5.0.dist-info/licenses/tools/glass_reference/vendor/kissfft/COPYING +11 -0
  36. binary_galactica-0.5.0.dist-info/licenses/tools/glass_reference/vendor/kissfft/LICENSES/BSD-3-Clause +35 -0
  37. binary_galactica-0.5.0.dist-info/licenses/tools/glass_reference/vendor/kissfft/LICENSES/Unlicense +30 -0
@@ -0,0 +1,139 @@
1
+ """Galactic-binary LISA TDI waveforms in JAX.
2
+
3
+ ``ucb_waveform`` uses GLASS-native parameter vectors; ``tdi_waveform`` accepts
4
+ physical ``GBParams``. Both return frequency-domain LISA detector channels.
5
+ The original ``polarizations`` function remains a source-only interface.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # Importing the package leaves JAX's process-global configuration alone.
11
+ #
12
+ # Over a four-year observation the phase of a millihertz binary accumulates to
13
+ # ~1e6 radians. Held in float32 that carries ~0.06 rad of error -- more than
14
+ # enough to destroy a matched filter, yet far too small to notice in a plot.
15
+ # The float64 functions therefore raise RuntimeError unless the application has
16
+ # enabled double precision with jax.config.update("jax_enable_x64", True); they
17
+ # never fall back to float32 silently. binary_galactica.single provides float32
18
+ # waveforms, with the frequency split into a reference bin and a small offset,
19
+ # for processes that keep x64 disabled.
20
+ from binary_galactica import single
21
+ from binary_galactica.constants import DAY, YEAR
22
+ from binary_galactica.detector import tdi_waveform
23
+ from binary_galactica.glass import (
24
+ BACKENDS,
25
+ DEFAULT_RESPONSE_SAMPLES,
26
+ FORMATS,
27
+ FSTAR,
28
+ GLASS_TO_CONTINUOUS,
29
+ LARM,
30
+ SNR_MIN,
31
+ UCB_MODEL_NP,
32
+ Alignment,
33
+ ExtrinsicPriorDraw,
34
+ FisherCache,
35
+ FisherProposal,
36
+ PriorDraw,
37
+ ProposalMix,
38
+ UCBPrior,
39
+ WaveformResult,
40
+ ae_noise,
41
+ analytic_orbits,
42
+ analytic_snr,
43
+ arm_vectors,
44
+ detector_tensor,
45
+ draw_from_fisher,
46
+ fm_shift,
47
+ orbit_cache,
48
+ params_from_native,
49
+ polarization_tensor,
50
+ psi_phi_jump,
51
+ single_source_mix,
52
+ slow_time_grid,
53
+ snr_cut,
54
+ synthesize,
55
+ tdi_response,
56
+ template_on_window,
57
+ to_continuous_ft,
58
+ ucb_alignment,
59
+ ucb_bandwidth,
60
+ ucb_bw,
61
+ ucb_fdot,
62
+ ucb_prior,
63
+ ucb_waveform,
64
+ xyz_to_ae,
65
+ xyz_to_aet,
66
+ )
67
+ from binary_galactica.parameters import (
68
+ NUM_PARAMS,
69
+ PARAMETER_NAMES,
70
+ GBParams,
71
+ from_glass_vector,
72
+ to_glass_vector,
73
+ )
74
+ from binary_galactica.waveform import (
75
+ amplitudes,
76
+ frequency,
77
+ phase,
78
+ polarizations,
79
+ )
80
+
81
+ __version__ = "0.5.0"
82
+
83
+ __all__ = [
84
+ "BACKENDS",
85
+ "DEFAULT_RESPONSE_SAMPLES",
86
+ "tdi_response",
87
+ "DAY",
88
+ "NUM_PARAMS",
89
+ "PARAMETER_NAMES",
90
+ "YEAR",
91
+ "GBParams",
92
+ "amplitudes",
93
+ "frequency",
94
+ "phase",
95
+ "polarizations",
96
+ "FORMATS",
97
+ "FSTAR",
98
+ "GLASS_TO_CONTINUOUS",
99
+ "LARM",
100
+ "SNR_MIN",
101
+ "UCB_MODEL_NP",
102
+ "Alignment",
103
+ "ExtrinsicPriorDraw",
104
+ "FisherCache",
105
+ "FisherProposal",
106
+ "PriorDraw",
107
+ "ProposalMix",
108
+ "UCBPrior",
109
+ "WaveformResult",
110
+ "ae_noise",
111
+ "analytic_orbits",
112
+ "analytic_snr",
113
+ "arm_vectors",
114
+ "detector_tensor",
115
+ "draw_from_fisher",
116
+ "fm_shift",
117
+ "orbit_cache",
118
+ "params_from_native",
119
+ "polarization_tensor",
120
+ "psi_phi_jump",
121
+ "single_source_mix",
122
+ "slow_time_grid",
123
+ "snr_cut",
124
+ "synthesize",
125
+ "template_on_window",
126
+ "to_continuous_ft",
127
+ "ucb_alignment",
128
+ "ucb_bandwidth",
129
+ "ucb_bw",
130
+ "ucb_fdot",
131
+ "ucb_prior",
132
+ "ucb_waveform",
133
+ "xyz_to_ae",
134
+ "xyz_to_aet",
135
+ "tdi_waveform",
136
+ "to_glass_vector",
137
+ "from_glass_vector",
138
+ "single",
139
+ ]
@@ -0,0 +1,18 @@
1
+ """Physical constants and time units, in SI.
2
+
3
+ Values follow the LISA Data Challenge conventions. The GLASS backend keeps
4
+ its own pinned literals in :mod:`binary_galactica.glass.constants`; its
5
+ sidereal-year literal differs slightly from the physical unit exposed here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ #: Sidereal year in seconds (LDC ``YRSID_SI``). LISA observation times are
11
+ #: conventionally quoted in sidereal years because the constellation's orbit is
12
+ #: tied to Earth's, so this -- not the Julian year -- is the natural unit.
13
+ YEAR: float = 31_558_149.763_545_603
14
+
15
+ #: Solar day in seconds.
16
+ DAY: float = 86_400.0
17
+
18
+ __all__ = ["DAY", "YEAR"]
@@ -0,0 +1,142 @@
1
+ """LISA detector waveforms from the package's physical source parameters."""
2
+
3
+ from functools import partial
4
+
5
+ import jax
6
+ import jax.numpy as jnp
7
+ import numpy as np
8
+ from jax import Array
9
+ from jax.typing import ArrayLike
10
+
11
+ from binary_galactica.glass import _validation
12
+ from binary_galactica.glass.types import WaveformResult
13
+ from binary_galactica.glass.waveform import (
14
+ DEFAULT_RESPONSE_SAMPLES,
15
+ _options,
16
+ ucb_waveform,
17
+ )
18
+ from binary_galactica.parameters import GBParams, to_glass_vector
19
+
20
+
21
+ def tdi_waveform( # noqa: N803 -- names follow the GLASS waveform API
22
+ theta: ArrayLike | GBParams,
23
+ *,
24
+ T: ArrayLike,
25
+ BW: int,
26
+ t0: ArrayLike = 0.0,
27
+ fmt: str = "sangria",
28
+ positions: ArrayLike | None = None,
29
+ backend: str = "onfly",
30
+ response_samples: int | None = DEFAULT_RESPONSE_SAMPLES,
31
+ ) -> WaveformResult:
32
+ """Generate the raw GLASS TDI spectrum for physical ``GBParams``.
33
+
34
+ This is the physical-coordinate counterpart of :func:`ucb_waveform`,
35
+ whose input array instead uses GLASS-native coordinates. The adapter
36
+ converts amplitude, phase, sky angles, inclination and time scales using
37
+ :func:`to_glass_vector`. It includes GLASS's first-order amplitude
38
+ evolution, which the original source-only ``polarizations`` omits.
39
+
40
+ Args:
41
+ theta: One physical ``GBParams`` or ``(8,)`` physical array. Use
42
+ ``jax.vmap`` to evaluate multiple sources. All fields must be
43
+ finite, ``amp`` and ``f0`` positive, ``iota`` in ``[0, pi]``,
44
+ and ``beta`` in ``[-pi/2, pi/2]``.
45
+ T: Positive observation duration in seconds. May be traced or
46
+ captured as a constant in surrounding JIT functions.
47
+ BW: Positive even number of slow samples and output bins. Static;
48
+ choose it by convergence for the intended analysis.
49
+ t0: Time of the first observation, in seconds from the source's
50
+ reference epoch. The parameters remain defined at time zero.
51
+ fmt: ``"sangria"``, ``"frequency"``, or ``"phase"`` TDI convention.
52
+ positions: Optional ``(3, BW, 3)`` spacecraft positions on the exact
53
+ ``(T, t0, BW)`` slow grid, as returned by ``orbit_cache``.
54
+ On the on-fly path this cache is interpolated; its sampling limits
55
+ geometry accuracy. Omit it to evaluate analytic geometry directly.
56
+ backend: ``"onfly"`` (default) or the legacy ``"fourier"`` path.
57
+ response_samples: ``None`` selects a duration-dependent power-of-two
58
+ grid of 32–2048 samples, targeting 128 samples per year. An explicit
59
+ static integer of at least four fixes the count independently of
60
+ ``BW``. Unused by the legacy backend. Pass an explicit count when
61
+ differentiating ``T`` across automatic grid thresholds: automatic
62
+ selection is piecewise and changes the interpolation grid.
63
+
64
+ Returns:
65
+ A dictionary with complex ``X/Y/Z/A/E`` arrays, ``qmin``, and absolute
66
+ ``freq_bin_indices``. Channels have length ``BW`` and raw GLASS
67
+ normalization. ``to_continuous_ft(result, T)`` multiplies by sqrt(T).
68
+
69
+ Notes:
70
+ Derivatives treat the discrete carrier and bandwidth choices as fixed.
71
+ The backend regularizes the sky-pole coordinate singularity. Those
72
+ coordinate-boundary derivatives are not exact physical derivatives.
73
+ Invalid eager inputs raise ``ValueError``. Invalid values supplied
74
+ through JAX tracers yield NaN in every output; shapes are checked
75
+ during tracing as well. See :func:`ucb_waveform` for grid validation.
76
+
77
+ """
78
+ _validation.require_x64()
79
+ response_samples = _options(backend, response_samples)
80
+ duration, t0, positions = _validation.observation(T, BW, t0, positions)
81
+ # Host scalar fields can be packed once by validation. Keep to_array's
82
+ # broadcasting and traced packing for array-valued fields.
83
+ if isinstance(theta, GBParams) and not all(np.isscalar(field) for field in theta):
84
+ theta = theta.to_array()
85
+ theta = _validation.finite_array(theta, "theta")
86
+ if theta.shape != (8,):
87
+ raise ValueError(f"theta must have shape (8,); got {theta.shape}")
88
+ if not isinstance(theta, jax.core.Tracer):
89
+ p = np.asarray(theta)
90
+ if p[0] <= 0 or p[1] <= 0:
91
+ raise ValueError("amp and f0 must be positive")
92
+ if not 0 <= p[4] <= np.pi:
93
+ raise ValueError("iota must lie in [0, pi]")
94
+ if not -np.pi / 2 <= p[7] <= np.pi / 2:
95
+ raise ValueError("beta must lie in [-pi/2, pi/2]")
96
+ return _tdi_waveform(
97
+ theta,
98
+ T=duration,
99
+ BW=int(BW),
100
+ t0=t0,
101
+ fmt=fmt,
102
+ positions=positions,
103
+ backend=backend,
104
+ response_samples=response_samples,
105
+ )
106
+
107
+
108
+ @partial(jax.jit, static_argnames=("BW", "fmt", "backend", "response_samples"))
109
+ def _tdi_waveform(
110
+ theta: Array,
111
+ *,
112
+ T: Array,
113
+ BW: int,
114
+ t0: Array,
115
+ fmt: str,
116
+ positions: Array | None,
117
+ backend: str,
118
+ response_samples: int | None,
119
+ ) -> WaveformResult:
120
+ """Convert physical coordinates inside JIT and preserve invalid domains."""
121
+ valid = (
122
+ jnp.all(jnp.isfinite(theta))
123
+ & (theta[0] > 0)
124
+ & (theta[1] > 0)
125
+ & (theta[4] >= 0)
126
+ & (theta[4] <= jnp.pi)
127
+ & (jnp.abs(theta[7]) <= jnp.pi / 2)
128
+ )
129
+ native = jnp.where(valid, to_glass_vector(theta, T), jnp.nan)
130
+ return ucb_waveform(
131
+ native,
132
+ T=T,
133
+ BW=BW,
134
+ t0=t0,
135
+ fmt=fmt,
136
+ positions=positions,
137
+ backend=backend,
138
+ response_samples=response_samples,
139
+ )
140
+
141
+
142
+ __all__ = ["tdi_waveform"]
@@ -0,0 +1,118 @@
1
+ # Copyright 2019 Tyson B. Littenberg & Neil J. Cornish
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """GLASS-compatible galactic-binary TDI waveforms in JAX.
15
+
16
+ The default waveform constructs first-generation frozen-geometry XYZ directly
17
+ at sparse reception times, interpolates its demodulated analytic response, and
18
+ then Fourier transforms it. It follows the pinned GLASS
19
+ ``LISA_spline_response`` / ``LISA_TDI_spline`` path from commit
20
+ ``079c6a3a6fcf380a7c107f39640fff7fc720d0f1``. The explicit
21
+ ``backend="fourier"`` option retains GLASS's earlier fast-slow / reduced-DFT
22
+ link-response construction for legacy reference comparisons.
23
+
24
+ Quick start
25
+ -----------
26
+ >>> import jax
27
+ >>> jax.config.update("jax_enable_x64", True)
28
+ >>> import binary_galactica.glass
29
+ >>> T, BW = 7884000.0, 64
30
+ >>> p = binary_galactica.glass.params_from_native(
31
+ ... f0=1e-3, fdot=1e-18, amp=7.16e-22, phi0=0.5,
32
+ ... colat=1.78, lon=0.0, psi=0.7, cos_iota=0.3, T=T)
33
+ >>> w = binary_galactica.glass.ucb_waveform(p, T=T, BW=BW)
34
+ >>> w["A"].shape
35
+ (64,)
36
+
37
+ The returned spectrum is in GLASS's amplitude convention and occupies
38
+ absolute frequency bins ``qmin ... qmin + BW - 1``. Use
39
+ :func:`binary_galactica.glass.convert.to_continuous_ft` for the continuous-Fourier
40
+ convention used elsewhere in this repository.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from .alignment import Alignment, ucb_alignment
46
+ from .bandwidth import ae_noise, analytic_snr, ucb_bandwidth, ucb_bw
47
+ from .compat import template_on_window
48
+ from .constants import FSTAR, LARM, UCB_MODEL_NP
49
+ from .convert import GLASS_TO_CONTINUOUS, to_continuous_ft
50
+ from .orbit import analytic_orbits, arm_vectors, slow_time_grid
51
+ from .prior import SNR_MIN, UCBPrior, snr_cut, ucb_fdot, ucb_prior
52
+ from .proposals import (
53
+ ExtrinsicPriorDraw,
54
+ FisherCache,
55
+ FisherProposal,
56
+ PriorDraw,
57
+ ProposalMix,
58
+ draw_from_fisher,
59
+ fm_shift,
60
+ psi_phi_jump,
61
+ single_source_mix,
62
+ )
63
+ from .response import detector_tensor, polarization_tensor
64
+ from .tdi import FORMATS, synthesize, xyz_to_ae, xyz_to_aet
65
+ from .types import WaveformResult
66
+ from .waveform import (
67
+ BACKENDS,
68
+ DEFAULT_RESPONSE_SAMPLES,
69
+ orbit_cache,
70
+ params_from_native,
71
+ tdi_response,
72
+ ucb_waveform,
73
+ )
74
+
75
+ __all__ = [
76
+ "BACKENDS",
77
+ "DEFAULT_RESPONSE_SAMPLES",
78
+ "tdi_response",
79
+ "Alignment",
80
+ "FORMATS",
81
+ "FSTAR",
82
+ "GLASS_TO_CONTINUOUS",
83
+ "LARM",
84
+ "UCB_MODEL_NP",
85
+ "WaveformResult",
86
+ "ae_noise",
87
+ "analytic_orbits",
88
+ "analytic_snr",
89
+ "arm_vectors",
90
+ "detector_tensor",
91
+ "orbit_cache",
92
+ "params_from_native",
93
+ "ExtrinsicPriorDraw",
94
+ "FisherCache",
95
+ "FisherProposal",
96
+ "PriorDraw",
97
+ "ProposalMix",
98
+ "SNR_MIN",
99
+ "UCBPrior",
100
+ "draw_from_fisher",
101
+ "fm_shift",
102
+ "polarization_tensor",
103
+ "psi_phi_jump",
104
+ "single_source_mix",
105
+ "slow_time_grid",
106
+ "snr_cut",
107
+ "synthesize",
108
+ "ucb_fdot",
109
+ "ucb_prior",
110
+ "template_on_window",
111
+ "to_continuous_ft",
112
+ "ucb_alignment",
113
+ "ucb_bandwidth",
114
+ "ucb_bw",
115
+ "ucb_waveform",
116
+ "xyz_to_ae",
117
+ "xyz_to_aet",
118
+ ]
@@ -0,0 +1,49 @@
1
+ """Working precision for kernels that follow the dtype of their inputs.
2
+
3
+ Float64 inputs keep float64 arithmetic and results; float32 inputs compute
4
+ float32 and complex64 values throughout, even with x64 enabled. Python scalars
5
+ are weakly typed in JAX: they adopt the working precision without promoting
6
+ it. NumPy float64 values and default-dtype constructors are not, so kernels
7
+ cast them to the working dtype explicitly.
8
+ """
9
+
10
+ import jax
11
+ import jax.numpy as jnp
12
+ import numpy as np
13
+
14
+ #: Precision of every contraction. The default lets float32 contractions use
15
+ #: TF32 on NVIDIA GPUs and reduced-precision passes on TPUs. CPU float64
16
+ #: ignores the setting, so float64 results do not change.
17
+ CONTRACTION = jax.lax.Precision.HIGHEST
18
+
19
+
20
+ def real_dtype(*values) -> np.dtype:
21
+ """Return the real working dtype of array-like ``values``, at least float32.
22
+
23
+ Complex values give their component dtype. Integer and boolean inputs, and
24
+ Python scalars on their own, give JAX's default floating dtype: float64
25
+ only when x64 is enabled.
26
+ """
27
+ # Nested sequences have no dtype of their own; their elements decide it.
28
+ leaves = jax.tree_util.tree_leaves(values)
29
+ dtype = jnp.result_type(*leaves) if leaves else jnp.result_type(float)
30
+ if not jnp.issubdtype(dtype, jnp.inexact):
31
+ dtype = jnp.result_type(float)
32
+ return jnp.promote_types(jnp.finfo(dtype).dtype, jnp.float32)
33
+
34
+
35
+ def complex_dtype(*values) -> np.dtype:
36
+ """Return the complex dtype with the precision of :func:`real_dtype`."""
37
+ return jnp.promote_types(real_dtype(*values), jnp.complex64)
38
+
39
+
40
+ def cast(value, dtype):
41
+ """Return ``value`` in ``dtype``, leaving Python scalars weakly typed.
42
+
43
+ A Python ``int`` or ``float`` already adopts the precision of the arrays it
44
+ meets, so it is returned unchanged; arithmetic on it, such as ``T**3``,
45
+ stays in Python double precision.
46
+ """
47
+ if type(value) in (int, float):
48
+ return value
49
+ return jnp.asarray(value, dtype=dtype)
@@ -0,0 +1,117 @@
1
+ """Float32 pairs that carry about twice float32's precision.
2
+
3
+ A value is an unevaluated sum ``(hi, lo)`` of float32 arrays, as in
4
+ double-single arithmetic: products and quotients keep about 2**-46 relative
5
+ precision. Plain float32 resolves ``t/YEAR`` at multi-year times, or a
6
+ phase of hundreds of cycles, only to about 1e-6 cycles.
7
+
8
+ The textbook error-free product subtracts a rounded product from the exact
9
+ one. XLA may contract ``a*b + c`` into a fused multiply-add, as measured on
10
+ an arm64 CPU, which silently changes that correction. Products here are
11
+ instead formed from operands split into halves of at most 12 significant
12
+ bits, so every partial product is exact in float32 and fusing it into an
13
+ addition rounds the same exact sum. Additions use Knuth's two-sum, which has
14
+ no products. Splitting masks the low mantissa bits, so it involves no
15
+ arithmetic.
16
+
17
+ Derivatives pass through: split halves carry no tangent, a two-sum's error
18
+ term carries none, and each pair's derivative is that of the exact
19
+ expression, evaluated in float32.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import jax
25
+ import jax.numpy as jnp
26
+ import numpy as np
27
+ from jax import Array
28
+
29
+ #: An unevaluated sum ``hi + lo`` of float32 values.
30
+ Pair = tuple[Array, Array]
31
+
32
+ #: Clears the low 12 of float32's 23 stored mantissa bits.
33
+ _HIGH_BITS = np.int32(-(1 << 12))
34
+
35
+
36
+ def split(x) -> Pair:
37
+ """Return ``(hi, lo)`` with ``hi + lo == x`` and at most 12 bits in each."""
38
+ x = jnp.asarray(x, dtype=jnp.float32)
39
+ bits = jax.lax.bitcast_convert_type(x, jnp.int32)
40
+ hi = jax.lax.bitcast_convert_type(bits & _HIGH_BITS, jnp.float32)
41
+ return hi, x - hi
42
+
43
+
44
+ def two_sum(a, b) -> Pair:
45
+ """Return the rounded sum and its exact rounding error (Knuth)."""
46
+ s = a + b
47
+ bb = s - a
48
+ return s, (a - (s - bb)) + (b - bb)
49
+
50
+
51
+ def pair(x) -> Pair:
52
+ """Return float32 ``x`` as a pair with a zero low part."""
53
+ x = jnp.asarray(x, dtype=jnp.float32)
54
+ return x, jnp.zeros_like(x)
55
+
56
+
57
+ def constant(value) -> tuple[np.ndarray, np.ndarray]:
58
+ """Round a host float64 constant or array to a float32 pair."""
59
+ value = np.asarray(value, dtype=np.float64)
60
+ hi = value.astype(np.float32)
61
+ return hi, (value - hi).astype(np.float32)
62
+
63
+
64
+ def product(x, y) -> Pair:
65
+ """Return float32 ``x*y`` as a pair, from exact partial products."""
66
+ x1, x2 = split(x)
67
+ y1, y2 = split(y)
68
+ s, e1 = two_sum(x1 * y1, x1 * y2)
69
+ s, e2 = two_sum(s, x2 * y1)
70
+ return two_sum(s, (e1 + e2) + x2 * y2)
71
+
72
+
73
+ def add(a: Pair, b: Pair) -> Pair:
74
+ """Return the pair sum ``a + b``."""
75
+ s, e = two_sum(a[0], b[0])
76
+ return two_sum(s, e + (a[1] + b[1]))
77
+
78
+
79
+ def multiply(a: Pair, b: Pair) -> Pair:
80
+ """Return the pair product ``a*b``."""
81
+ hi, lo = product(a[0], b[0])
82
+ # The cross terms are about 2**-24 of the product; rounding them is harmless.
83
+ return two_sum(hi, lo + (a[0] * b[1] + a[1] * b[0]))
84
+
85
+
86
+ def divide(a, b) -> Pair:
87
+ """Return float32 ``a/b`` as a pair, correcting the quotient's remainder."""
88
+ quotient = a / b
89
+ hi, lo = product(quotient, b)
90
+ # a - hi is exact because hi is within a rounding of a.
91
+ return two_sum(quotient, ((a - hi) - lo) / b)
92
+
93
+
94
+ def fraction(a: Pair) -> Pair:
95
+ """Subtract the integer nearest ``a``, leaving a pair near ``[-0.5, 0.5]``."""
96
+ hi = a[0] - jnp.round(a[0])
97
+ return two_sum(hi, a[1])
98
+
99
+
100
+ def value(a: Pair) -> Array:
101
+ """Round a pair to float32."""
102
+ return a[0] + a[1]
103
+
104
+
105
+ __all__ = [
106
+ "Pair",
107
+ "add",
108
+ "constant",
109
+ "divide",
110
+ "fraction",
111
+ "multiply",
112
+ "pair",
113
+ "product",
114
+ "split",
115
+ "two_sum",
116
+ "value",
117
+ ]
@@ -0,0 +1,94 @@
1
+ """Eager boundary checks shared by the native and physical waveform APIs."""
2
+
3
+ import jax
4
+ import jax.numpy as jnp
5
+ import numpy as np
6
+
7
+
8
+ def require_x64():
9
+ """Refuse to run a float64 entry point while JAX would compute in float32."""
10
+ # read() gives the effective value, including jax.experimental.enable_x64
11
+ # scopes, on every supported JAX; it never changes the setting.
12
+ if not jax.config.read("jax_enable_x64"):
13
+ raise RuntimeError(
14
+ "binary_galactica's float64 functions need JAX double precision, "
15
+ "which is disabled. Call "
16
+ 'jax.config.update("jax_enable_x64", True) at startup, before '
17
+ "creating JAX arrays, or use binary_galactica.single for float32 "
18
+ "waveforms."
19
+ )
20
+
21
+
22
+ def integer(value, name, *, minimum=None):
23
+ """Require a concrete integer index/count without truncating or accepting bools."""
24
+ requirement = {
25
+ None: "an integer",
26
+ 0: "a non-negative integer",
27
+ 1: "a positive integer",
28
+ }[minimum]
29
+ if (
30
+ isinstance(value, (bool, np.bool_))
31
+ or not isinstance(value, (int, np.integer))
32
+ or (minimum is not None and value < minimum)
33
+ ):
34
+ raise ValueError(f"{name} must be {requirement}; got {value!r}")
35
+ return int(value)
36
+
37
+
38
+ def _contains_jax(value):
39
+ """Detect JAX values and conversion protocols before packing sequences."""
40
+ if isinstance(value, (jax.Array, jax.core.Tracer)) or callable(
41
+ getattr(value, "__jax_array__", None)
42
+ ):
43
+ return True
44
+ return isinstance(value, (list, tuple)) and any(_contains_jax(x) for x in value)
45
+
46
+
47
+ def finite_array(value, name, *, dtype=np.float64):
48
+ """Validate floating inputs, keeping host values off device until the JIT call.
49
+
50
+ Values are cast to ``dtype``: float64 for the double-precision API and
51
+ float32 for :mod:`binary_galactica.single`. A host value too large for
52
+ float32 becomes infinite and is rejected like any other nonfinite input.
53
+ """
54
+ # Packing host scalars with jnp dispatches before the compiled waveform.
55
+ # JAX arrays and nested tracers must stay in JAX to preserve placement/AD.
56
+ if _contains_jax(value):
57
+ value = jnp.asarray(value, dtype=dtype)
58
+ else:
59
+ with np.errstate(over="ignore"):
60
+ value = np.asarray(value, dtype=dtype)
61
+ if not isinstance(value, jax.core.Tracer) and not np.all(np.isfinite(value)):
62
+ raise ValueError(f"{name} must be finite")
63
+ return value
64
+
65
+
66
+ def scalar(value, name, *, positive=False, dtype=np.float64):
67
+ """Validate scalar shape during tracing, and concrete value eagerly."""
68
+ value = finite_array(value, name, dtype=dtype)
69
+ if value.ndim != 0:
70
+ raise ValueError(f"{name} must be a scalar; got shape {value.shape}")
71
+ if positive and not isinstance(value, jax.core.Tracer) and np.asarray(value) <= 0:
72
+ raise ValueError(f"{name} must be positive")
73
+ return value
74
+
75
+
76
+ def observation(T, BW, t0, positions, *, dtype=np.float64):
77
+ """Check the shared observation arguments at the public boundary."""
78
+ if (
79
+ isinstance(BW, bool)
80
+ or not isinstance(BW, (int, np.integer))
81
+ or BW <= 0
82
+ or BW % 2
83
+ ):
84
+ raise ValueError(f"BW must be a positive even integer; got {BW!r}")
85
+ T = scalar(T, "T", positive=True, dtype=dtype)
86
+ t0 = scalar(t0, "t0", dtype=dtype)
87
+ if positions is not None:
88
+ positions = finite_array(positions, "positions", dtype=dtype)
89
+ if positions.shape != (3, BW, 3):
90
+ raise ValueError(
91
+ "positions must have shape (3, BW, 3); "
92
+ f"got {positions.shape} for BW={BW}"
93
+ )
94
+ return T, t0, positions