gwmock-data 0.1.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.
@@ -0,0 +1,13 @@
1
+ """Top-level package for gwmock_data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # PEP 810: defer importlib.metadata (loaded by version.py) on 3.15+.
6
+ # Ignored on 3.13/3.14 where the lazy import machinery does not exist.
7
+ __lazy_modules__ = ["gwmock_data.sampling_grid", "gwmock_data.timeseries", "gwmock_data.version"]
8
+
9
+ from gwmock_data.sampling_grid import SamplingGrid
10
+ from gwmock_data.timeseries import TimeSeries, as_timeseries
11
+ from gwmock_data.version import __version__
12
+
13
+ __all__ = ["SamplingGrid", "TimeSeries", "__version__", "as_timeseries"]
@@ -0,0 +1,35 @@
1
+ """Import an optional package, naming the extra that installs it when it is missing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from types import ModuleType
7
+
8
+
9
+ def require(module: str, extra: str, feature: str) -> ModuleType:
10
+ """Return ``module``, importing it; say which extra installs it if it is missing.
11
+
12
+ Only ``module`` itself missing, or a package it sits in, is reported that way. Any other
13
+ missing module -- one it needs, or a part of an installed package that fails to import -- is
14
+ re-raised unchanged, since installing the extra would not help.
15
+
16
+ Args:
17
+ module: Dotted module name, such as ``"gwpy.timeseries"``.
18
+ extra: The ``gwmock-data`` extra that installs it, such as ``"gwpy"``.
19
+ feature: What needs it, for the message, such as ``"Frame (GWF) output"``.
20
+
21
+ Raises:
22
+ ModuleNotFoundError: If the package is not installed.
23
+ """
24
+ try:
25
+ return importlib.import_module(module)
26
+ except ModuleNotFoundError as exc:
27
+ parts = module.split(".")
28
+ enclosing = {".".join(parts[: index + 1]) for index in range(len(parts))}
29
+ if exc.name not in enclosing:
30
+ raise
31
+ root = parts[0]
32
+ raise ModuleNotFoundError(
33
+ f"{feature} needs {root}, which is not installed. Install it with: pip install 'gwmock-data[{extra}]'",
34
+ name=exc.name,
35
+ ) from exc
@@ -0,0 +1,7 @@
1
+ """Reading and writing time series."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gwmock_data.io.hdf5 import read_hdf5, write_hdf5
6
+
7
+ __all__ = ["read_hdf5", "write_hdf5"]
gwmock_data/io/hdf5.py ADDED
@@ -0,0 +1,88 @@
1
+ """Read and write time series as HDF5, in the layout GWpy's ``TimeSeriesDict`` uses.
2
+
3
+ One dataset per series, named by its key and gzip-compressed, with the attributes GWpy writes and
4
+ reads: ``x0`` (the GPS time of the first sample), ``dx`` (the sample spacing, in seconds),
5
+ ``xunit`` (``"s"``), ``unit`` and, for a series that has one, ``name``. A file written here therefore loads with
6
+ ``gwpy.timeseries.TimeSeriesDict.read(path, format="hdf5")``, and a file GWpy wrote loads here.
7
+
8
+ Only ``h5py`` is needed, imported when a function is called; GWpy is not.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+ from gwmock_data._optional import require
19
+ from gwmock_data.timeseries import TimeSeries, as_timeseries
20
+
21
+ __all__ = ["read_hdf5", "write_hdf5"]
22
+
23
+
24
+ def write_hdf5(series: Mapping[str, TimeSeries], path: str | Path) -> None:
25
+ """Write each series to ``path`` as one HDF5 dataset named by its key, replacing the file.
26
+
27
+ Args:
28
+ series: Mapping of dataset name to a one-dimensional time series (this package's or
29
+ GWpy's).
30
+ path: Output file.
31
+
32
+ Raises:
33
+ ValueError: If a series is not one-dimensional.
34
+ ModuleNotFoundError: If ``h5py`` is not installed.
35
+ """
36
+ h5py = require("h5py", "hdf5", "HDF5 output")
37
+ channels = {key: as_timeseries(value) for key, value in series.items()}
38
+ for key, channel in channels.items():
39
+ if len(channel.data.shape) != 1:
40
+ raise ValueError(f"{key!r} must be one-dimensional to write; it has shape {tuple(channel.data.shape)}.")
41
+ with h5py.File(Path(path), "w") as handle:
42
+ for key, channel in channels.items():
43
+ dataset = handle.create_dataset(key, data=np.asarray(channel.data), compression="gzip")
44
+ dataset.attrs["x0"] = channel.t0
45
+ dataset.attrs["dx"] = channel.dt
46
+ dataset.attrs["xunit"] = "s"
47
+ dataset.attrs["unit"] = channel.unit or ""
48
+ if channel.name is not None:
49
+ dataset.attrs["name"] = channel.name
50
+
51
+
52
+ def read_hdf5(path: str | Path) -> dict[str, TimeSeries]:
53
+ """Read every dataset in ``path`` written in the layout :func:`write_hdf5` writes.
54
+
55
+ Args:
56
+ path: Input file.
57
+
58
+ Returns:
59
+ Mapping of dataset name to :class:`~gwmock_data.timeseries.TimeSeries`, in file order.
60
+
61
+ Raises:
62
+ ValueError: If a dataset lacks the ``x0`` or ``dx`` attribute, or its time unit is not seconds.
63
+ ModuleNotFoundError: If ``h5py`` is not installed.
64
+ """
65
+ h5py = require("h5py", "hdf5", "HDF5 input")
66
+ result = {}
67
+ with h5py.File(Path(path), "r") as handle:
68
+ for key, dataset in handle.items():
69
+ attrs = dataset.attrs
70
+ if "x0" not in attrs or "dx" not in attrs:
71
+ raise ValueError(f"dataset {key!r} has no x0 and dx attributes, so it is not a time series.")
72
+ xunit = _text(attrs.get("xunit", "s"))
73
+ if xunit not in ("s", "second"):
74
+ raise ValueError(f"dataset {key!r} has a time axis in {xunit!r}; only seconds are supported.")
75
+ name = _text(attrs["name"]) if "name" in attrs else None
76
+ result[key] = TimeSeries(
77
+ dataset[...],
78
+ t0=float(attrs["x0"]),
79
+ sample_rate=1.0 / float(attrs["dx"]),
80
+ name=name,
81
+ unit=_text(attrs.get("unit", "")) or None,
82
+ )
83
+ return result
84
+
85
+
86
+ def _text(value: object) -> str:
87
+ """Return an HDF5 string attribute as ``str``, whether h5py read it as bytes or text."""
88
+ return value.decode("utf-8") if isinstance(value, bytes) else str(value)
@@ -0,0 +1,167 @@
1
+ """A uniform sample lattice, and the arithmetic for placing GPS times on it.
2
+
3
+ Superposing one series onto another is exact when the two share a sample lattice: the operation
4
+ is an integer-offset add. When they do not, something has to resample, and the resampling sets the
5
+ accuracy of whatever is built on it. A :class:`SamplingGrid` names the lattice so code can decide,
6
+ exactly, whether a time is on it.
7
+
8
+ GPS times near 1.4e9 s carry float64 rounding of a sizeable fraction of a sample at kilohertz rates,
9
+ so "on the lattice" is judged with a tolerance derived from that resolution, never a fixed one.
10
+
11
+ This module holds only the lattice arithmetic and its validation, and imports nothing but NumPy.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+ import numpy as np
19
+
20
+ #: Timestamp ULPs of slack allowed when deciding whether a time is on the lattice. The
21
+ #: tolerance has to be derived from float64 resolution rather than fixed: one ULP of a GPS
22
+ #: timestamp near 1.4e9 is about 2.4e-7 s, which at 2048 Hz is already 4.9e-4 samples. A fixed
23
+ #: 1e-6-sample tolerance -- the first thing written here -- was therefore some 500x *tighter*
24
+ #: than the timestamps can represent, and would have falsely rejected lattice times produced by
25
+ #: ordinary arithmetic. It survived initial testing only because 64 s at 2048 Hz happens to be
26
+ #: exact in binary; a non-power-of-two segment duration would have broken it.
27
+ _LATTICE_TOLERANCE_ULPS = 8.0
28
+
29
+ #: Smallest tolerance to use, in samples, so that a tiny epoch does not make the check
30
+ #: unreasonably strict.
31
+ _MINIMUM_TOLERANCE_SAMPLES = 1e-9
32
+
33
+ #: Largest tolerance to use, in samples. Well below the half-sample displacements the check
34
+ #: exists to catch, so no plausible ULP growth can make it accept a genuinely off-lattice time.
35
+ _MAXIMUM_TOLERANCE_SAMPLES = 1e-2
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SamplingGrid:
40
+ """A uniform sample lattice: sample ``k`` sits at ``epoch + k / sampling_frequency``.
41
+
42
+ Args:
43
+ epoch: GPS time of sample zero.
44
+ sampling_frequency: Sample rate in Hz.
45
+
46
+ Raises:
47
+ ValueError: If ``sampling_frequency`` is not positive and finite, or ``epoch`` is not
48
+ finite.
49
+ """
50
+
51
+ epoch: float
52
+ sampling_frequency: float
53
+
54
+ def __post_init__(self) -> None:
55
+ """Validate the lattice definition."""
56
+ if not np.isfinite(self.epoch):
57
+ raise ValueError(f"epoch must be finite; got {self.epoch}.")
58
+ if not np.isfinite(self.sampling_frequency) or self.sampling_frequency <= 0.0:
59
+ raise ValueError(f"sampling_frequency must be positive and finite; got {self.sampling_frequency}.")
60
+
61
+ @property
62
+ def sample_spacing(self) -> float:
63
+ """Seconds between samples."""
64
+ return 1.0 / self.sampling_frequency
65
+
66
+ def lattice_tolerance_samples(self, gps_time: np.ndarray | float) -> float:
67
+ """Return the on-lattice tolerance, in samples, appropriate to these timestamps.
68
+
69
+ Scaled by the float64 resolution of the times involved, because at GPS magnitudes that
70
+ resolution is coarser than any fixed tolerance worth writing down: near 1.4e9 one ULP
71
+ is already ~5e-4 samples at 2048 Hz.
72
+
73
+ An empty input is answered from the epoch alone. A chunked or filtered caller can
74
+ legitimately arrive with nothing to check, and reducing over an empty array would raise
75
+ here instead of letting the (vacuously satisfied) lattice check return empty.
76
+ """
77
+ times = np.asarray(gps_time, dtype=float)
78
+ magnitude = abs(float(self.epoch))
79
+ if times.size:
80
+ magnitude = max(magnitude, float(np.max(np.abs(times))))
81
+ ulp_samples = float(np.spacing(magnitude)) * self.sampling_frequency
82
+ return float(
83
+ np.clip(_LATTICE_TOLERANCE_ULPS * ulp_samples, _MINIMUM_TOLERANCE_SAMPLES, _MAXIMUM_TOLERANCE_SAMPLES)
84
+ )
85
+
86
+ def index_of(self, gps_time: np.ndarray | float) -> np.ndarray:
87
+ """Return the (generally fractional) lattice index of *gps_time*.
88
+
89
+ The subtraction is done before the multiplication: GPS times are ~1.4e9 and the
90
+ spacing is ~5e-4 s, so scaling first would lose the sub-sample part entirely.
91
+ """
92
+ return (np.asarray(gps_time, dtype=float) - self.epoch) * self.sampling_frequency
93
+
94
+ def time_of(self, index: np.ndarray | int) -> np.ndarray:
95
+ """Return the GPS time of lattice index *index*."""
96
+ return self.epoch + np.asarray(index, dtype=float) * self.sample_spacing
97
+
98
+ def split_index(self, gps_time: np.ndarray | float) -> tuple[np.ndarray, np.ndarray]:
99
+ """Split *gps_time* into the preceding lattice index and a fractional remainder.
100
+
101
+ Returns:
102
+ ``(index, fraction)`` with ``fraction`` in ``[0, 1)``, such that
103
+ ``gps_time == time_of(index) + fraction * sample_spacing``. The fraction is what
104
+ the device must absorb for the sample to land on the lattice.
105
+ """
106
+ exact = self.index_of(gps_time)
107
+ index = np.floor(exact)
108
+ return index.astype(np.int64), exact - index
109
+
110
+ def is_on_lattice(self, gps_time: np.ndarray | float) -> np.ndarray:
111
+ """Return whether each *gps_time* coincides with a lattice sample."""
112
+ exact = self.index_of(gps_time)
113
+ return np.abs(exact - np.round(exact)) <= self.lattice_tolerance_samples(gps_time)
114
+
115
+ def require_on_lattice(self, gps_time: np.ndarray | float, *, name: str) -> np.ndarray:
116
+ """Return integer lattice indices, rejecting any time that is off-lattice.
117
+
118
+ Rejected rather than silently rounded: rounding would move a signal by up to half a
119
+ sample without telling anyone, and silently accepting an off-lattice time is exactly
120
+ how the off-lattice resampling error described in this module's docstring got in.
121
+
122
+ Args:
123
+ gps_time: Time(s) that must coincide with lattice samples.
124
+ name: What is being checked, for the error message.
125
+
126
+ Returns:
127
+ Integer lattice indices, same shape as the input.
128
+
129
+ Raises:
130
+ ValueError: If any time is not on the lattice.
131
+ """
132
+ times = np.atleast_1d(np.asarray(gps_time, dtype=float))
133
+ exact = self.index_of(times)
134
+ offenders = np.abs(exact - np.round(exact)) > self.lattice_tolerance_samples(times)
135
+ if np.any(offenders):
136
+ first = int(np.argmax(offenders))
137
+ raise ValueError(
138
+ f"{name} must lie on the sampling grid (epoch={self.epoch!r}, "
139
+ f"sampling_frequency={self.sampling_frequency!r}), but entry {first} at "
140
+ f"{times[first]!r} is {exact[first] - round(float(exact[first])):+.6g} samples off. "
141
+ f"Rounding it would displace the data by up to half a sample; choose segment "
142
+ f"starts on the grid, or build the grid from them."
143
+ )
144
+ return np.round(exact).astype(np.int64).reshape(np.shape(gps_time))
145
+
146
+ @classmethod
147
+ def from_segment_starts(cls, segment_start_times: np.ndarray, sampling_frequency: float) -> SamplingGrid:
148
+ """Build a grid anchored on the first segment start.
149
+
150
+ Args:
151
+ segment_start_times: GPS start times, which must themselves be mutually
152
+ consistent with one lattice at this sample rate.
153
+ sampling_frequency: Sample rate in Hz.
154
+
155
+ Returns:
156
+ A grid whose epoch is the first segment start.
157
+
158
+ Raises:
159
+ ValueError: If ``segment_start_times`` is empty, or the starts do not share one
160
+ lattice — which would make a single canonical grid impossible.
161
+ """
162
+ starts = np.atleast_1d(np.asarray(segment_start_times, dtype=float))
163
+ if starts.size == 0:
164
+ raise ValueError("segment_start_times must not be empty.")
165
+ grid = cls(epoch=float(starts[0]), sampling_frequency=sampling_frequency)
166
+ grid.require_on_lattice(starts, name="segment_start_times")
167
+ return grid
@@ -0,0 +1,428 @@
1
+ """A uniformly sampled time series whose samples are a NumPy or a JAX array.
2
+
3
+ :class:`TimeSeries` holds an array and the few numbers that place it in time: the GPS time of the
4
+ first sample and the sample rate. Time runs along the **last** axis; leading axes are allowed and
5
+ share that timing, so the strain of several detectors over one segment can be a single series of
6
+ shape ``(n_detectors, n_samples)``.
7
+
8
+ The array is kept as given. A NumPy array stays on the host, and a JAX array stays where it is,
9
+ on a GPU included, through every method here: each works through the array's own namespace
10
+ (``data.__array_namespace__()``), never by converting to NumPy. :meth:`TimeSeries.to_numpy` and
11
+ :meth:`TimeSeries.to_jax` move data deliberately.
12
+
13
+ The class is deliberately small. It has no arithmetic, spectral or plotting methods: code that
14
+ needs them works on :attr:`TimeSeries.data`, and a caller who wants GWpy's API converts with
15
+ :meth:`TimeSeries.to_gwpy`. Conversions to and from GWpy and PyCBC import those packages only when
16
+ called.
17
+
18
+ Under JAX, :func:`register_pytree` makes a series a pytree, so it passes through ``jax.jit`` and
19
+ ``jax.vmap``. The samples and ``t0`` are leaves; the sample rate, name and unit are static. The
20
+ modules that use JAX register it; importing this module never imports JAX. JAX must be in 64-bit mode
21
+ (``jax.config.update("jax_enable_x64", True)``): GPS times need float64, and in 32-bit mode the
22
+ methods that would lose it raise instead.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import dataclasses
28
+ import functools
29
+ import math
30
+ import sys
31
+ import threading
32
+ from dataclasses import dataclass
33
+ from typing import TYPE_CHECKING, Any
34
+
35
+ import numpy as np
36
+
37
+ from gwmock_data._optional import require
38
+ from gwmock_data.sampling_grid import SamplingGrid
39
+
40
+ if TYPE_CHECKING:
41
+ from types import ModuleType
42
+
43
+ import gwpy.timeseries
44
+ import pycbc.types
45
+
46
+ __all__ = ["TimeSeries", "as_timeseries", "register_pytree"]
47
+
48
+
49
+ def _namespace(data: Any) -> ModuleType:
50
+ """Return the array namespace of ``data``: ``numpy``, ``jax.numpy``, or another array-API library."""
51
+ return data.__array_namespace__()
52
+
53
+
54
+ @dataclass(frozen=True, eq=False)
55
+ class TimeSeries:
56
+ """A uniformly sampled series: sample ``k`` along the last axis is at ``t0 + k / sample_rate``.
57
+
58
+ Args:
59
+ data: The samples, an array with at least one axis; time is the last. A NumPy or a JAX
60
+ array is kept as given. Anything else is converted with :func:`numpy.asarray`.
61
+ t0: GPS time of the first sample, in seconds.
62
+ sample_rate: Samples per second, in Hz.
63
+ name: Optional label, such as a detector or channel name. It labels the whole series.
64
+ unit: Optional unit of the samples, such as ``"strain"``.
65
+
66
+ Raises:
67
+ ValueError: If ``data`` has no axis, or ``t0`` or ``sample_rate`` is not finite, or
68
+ ``sample_rate`` is not positive.
69
+ TypeError: If ``t0`` is not a single number.
70
+
71
+ Two series are equal only if they are the same object: comparing arrays elementwise has no
72
+ single truth value, and a JAX array cannot be compared without a device round trip.
73
+ """
74
+
75
+ data: Any
76
+ t0: float
77
+ sample_rate: float
78
+ name: str | None = None
79
+ unit: str | None = None
80
+
81
+ def __post_init__(self) -> None:
82
+ """Validate the timing and normalise the samples to an array."""
83
+ data = self.data
84
+ if not hasattr(data, "__array_namespace__"):
85
+ data = np.asarray(data)
86
+ object.__setattr__(self, "data", data)
87
+ if len(data.shape) == 0:
88
+ raise ValueError("data must have at least one axis; time is the last.")
89
+ sample_rate = float(self.sample_rate)
90
+ if not (math.isfinite(sample_rate) and sample_rate > 0.0):
91
+ raise ValueError(f"sample_rate must be positive and finite; got {self.sample_rate!r}.")
92
+ object.__setattr__(self, "sample_rate", sample_rate)
93
+ try:
94
+ t0 = float(self.t0)
95
+ except TypeError as exc:
96
+ # A traced value inside a JAX transformation has no number yet to check; it was checked
97
+ # when the series entered the transformation. Matched by name so JAX is not imported.
98
+ if type(exc).__name__ == "ConcretizationTypeError":
99
+ return
100
+ raise TypeError(f"t0 must be a single number; got {self.t0!r}.") from exc
101
+ if not math.isfinite(t0):
102
+ raise ValueError(f"t0 must be finite; got {self.t0!r}.")
103
+ object.__setattr__(self, "t0", t0)
104
+
105
+ # ----------------------------------------------------------------------------------------
106
+ # Derived quantities
107
+
108
+ @property
109
+ def value(self) -> Any:
110
+ """The samples; an alias of :attr:`data`, the name GWpy uses."""
111
+ return self.data
112
+
113
+ @property
114
+ def xp(self) -> ModuleType:
115
+ """The array namespace of :attr:`data`, such as ``numpy`` or ``jax.numpy``."""
116
+ return _namespace(self.data)
117
+
118
+ @property
119
+ def n_samples(self) -> int:
120
+ """Number of samples along the time axis."""
121
+ return int(self.data.shape[-1])
122
+
123
+ @property
124
+ def dt(self) -> float:
125
+ """Seconds between samples."""
126
+ return 1.0 / self.sample_rate
127
+
128
+ @property
129
+ def duration(self) -> float:
130
+ """Seconds spanned by the samples: ``n_samples * dt``."""
131
+ return self.n_samples * self.dt
132
+
133
+ @property
134
+ def end(self) -> float:
135
+ """GPS time of the sample after the last, on the series' lattice: ``t0 + n_samples * dt``."""
136
+ return self._time_of(self.n_samples)
137
+
138
+ @property
139
+ def grid(self) -> SamplingGrid:
140
+ """The sample lattice of this series."""
141
+ return SamplingGrid(epoch=self.t0, sampling_frequency=self.sample_rate)
142
+
143
+ @property
144
+ def times(self) -> Any:
145
+ """GPS time of each sample, in the namespace of :attr:`data`, as float64.
146
+
147
+ Raises:
148
+ RuntimeError: If the namespace cannot hold float64, as JAX cannot outside its 64-bit
149
+ mode. In single precision a GPS time near 1.4e9 s resolves only to 128 s, so every
150
+ sample of a typical series would get the same time.
151
+ """
152
+ xp = self.xp
153
+ times = self.t0 + xp.arange(self.n_samples, dtype=xp.float64) * self.dt
154
+ if times.dtype != xp.float64:
155
+ raise RuntimeError(_NO_FLOAT64)
156
+ return times
157
+
158
+ # ----------------------------------------------------------------------------------------
159
+ # New series from this one
160
+
161
+ def with_data(self, data: Any) -> TimeSeries:
162
+ """Return a series with the same timing, name and unit, holding ``data``."""
163
+ return dataclasses.replace(self, data=data)
164
+
165
+ def copy(self) -> TimeSeries:
166
+ """Return a series whose samples are a copy of these."""
167
+ return self.with_data(self.xp.asarray(self.data, copy=True))
168
+
169
+ def crop(self, start: float | None = None, end: float | None = None) -> TimeSeries:
170
+ """Return the samples at GPS times in ``[start, end)``.
171
+
172
+ The bounds decide the result's length, so ``crop`` needs a concrete ``t0``: it cannot run
173
+ inside a JAX transformation over the series.
174
+
175
+ The result starts at the first sample at or after ``start`` and stops before the first at
176
+ or after ``end``. A bound that falls on a sample, to within the float64 resolution of GPS
177
+ times (:meth:`~gwmock_data.sampling_grid.SamplingGrid.lattice_tolerance_samples`), counts
178
+ as on it, so cropping at a sample's own timestamp keeps that sample however large the GPS
179
+ time. Bounds outside the series are clipped to it.
180
+
181
+ GWpy differs for a bound between samples: it rounds down, keeping the sample before
182
+ ``start``. Here every kept sample is at or after ``start``.
183
+
184
+ Args:
185
+ start: First GPS time to keep; ``None`` keeps from the first sample.
186
+ end: GPS time to stop before; ``None`` keeps to the last sample.
187
+
188
+ Returns:
189
+ The cropped series, possibly with no samples. Its samples are a view where the array
190
+ library makes one.
191
+
192
+ Raises:
193
+ ValueError: If a bound is not finite, or ``end`` is before ``start``.
194
+ """
195
+ for bound_name, bound in (("start", start), ("end", end)):
196
+ if bound is not None and not math.isfinite(bound):
197
+ raise ValueError(f"{bound_name} must be finite; got {bound!r}.")
198
+ if start is not None and end is not None and end < start:
199
+ raise ValueError(f"end ({end}) is before start ({start}).")
200
+ first = 0 if start is None else self._first_index_at_or_after(start)
201
+ stop = self.n_samples if end is None else self._first_index_at_or_after(end)
202
+ first = min(max(first, 0), self.n_samples)
203
+ stop = min(max(stop, first), self.n_samples)
204
+ return dataclasses.replace(self, data=self.data[..., first:stop], t0=self._time_of(first))
205
+
206
+ def pad(self, before: int = 0, after: int = 0) -> TimeSeries:
207
+ """Return the series with ``before`` zeros prepended and ``after`` appended, in samples.
208
+
209
+ ``t0`` moves back by ``before`` samples, so the existing samples keep their times. The
210
+ counts decide the result's length, so inside a JAX transformation they must be Python
211
+ integers, not traced values.
212
+
213
+ Raises:
214
+ ValueError: If either count is negative.
215
+ """
216
+ if before < 0 or after < 0:
217
+ raise ValueError(f"pad counts must not be negative; got before={before}, after={after}.")
218
+ xp = self.xp
219
+ lead = self.data.shape[:-1]
220
+ parts = [
221
+ xp.zeros((*lead, before), dtype=self.data.dtype),
222
+ self.data,
223
+ xp.zeros((*lead, after), dtype=self.data.dtype),
224
+ ]
225
+ return dataclasses.replace(self, data=xp.concatenate(parts, axis=-1), t0=self._time_of(-before))
226
+
227
+ def is_compatible(self, other: TimeSeries) -> bool:
228
+ """Return whether ``other`` has the same sample rate and unit, so the two can be combined."""
229
+ return self.sample_rate == other.sample_rate and self.unit == other.unit
230
+
231
+ def _time_of(self, index: int) -> Any:
232
+ # The arithmetic of SamplingGrid.time_of, written out so it also works on a traced t0.
233
+ return self.t0 + index * self.dt
234
+
235
+ def _first_index_at_or_after(self, gps_time: float) -> int:
236
+ grid = self.grid
237
+ exact = float(grid.index_of(gps_time))
238
+ return math.ceil(exact - grid.lattice_tolerance_samples(gps_time))
239
+
240
+ def __array__(self, dtype: Any = None, copy: bool | None = None) -> np.ndarray:
241
+ """Return the samples as a NumPy array, so ``numpy.asarray(series)`` works as for GWpy.
242
+
243
+ Follows NumPy's protocol: ``copy=True`` always copies, ``None`` copies only when the dtype
244
+ or the array library requires it, and ``False`` raises ``ValueError`` when a copy would be
245
+ required -- a dtype change, or samples that are not a NumPy array.
246
+ """
247
+ needs_copy = not isinstance(self.data, np.ndarray) or (dtype is not None and np.dtype(dtype) != self.data.dtype)
248
+ if copy is False and needs_copy:
249
+ raise ValueError("these samples cannot be returned as a NumPy array without a copy.")
250
+ array = np.asarray(self.data, dtype=dtype)
251
+ return np.array(array, copy=True) if copy else array
252
+
253
+ # ----------------------------------------------------------------------------------------
254
+ # Moving data and converting
255
+
256
+ def to_numpy(self) -> TimeSeries:
257
+ """Return the series with its samples as a NumPy array on the host."""
258
+ return self.with_data(np.asarray(self.data))
259
+
260
+ def to_jax(self, device: Any = None) -> TimeSeries:
261
+ """Return the series with its samples as a JAX array, on ``device`` if given.
262
+
263
+ Raises:
264
+ ModuleNotFoundError: If JAX is not installed.
265
+ RuntimeError: If JAX is not in 64-bit mode, in which it would round double-precision
266
+ samples to single precision.
267
+ """
268
+ jax = require("jax", "jax", "TimeSeries.to_jax")
269
+ _require_x64(jax)
270
+ return self.with_data(jax.device_put(jax.numpy.asarray(self.data), device))
271
+
272
+ def to_gwpy(self) -> gwpy.timeseries.TimeSeries:
273
+ """Return a GWpy ``TimeSeries`` with the same samples, timing, name and unit.
274
+
275
+ NumPy samples are shared, not copied, as :meth:`from_gwpy` shares GWpy's, so a write through
276
+ either object is seen by the other. JAX samples are brought to the host with
277
+ :func:`numpy.asarray`; for a CPU array that is a read-only view of JAX's buffer.
278
+
279
+ Raises:
280
+ ValueError: If the series has more than one axis; GWpy's is one-dimensional.
281
+ ModuleNotFoundError: If GWpy is not installed.
282
+ """
283
+ if len(self.data.shape) != 1:
284
+ raise ValueError(f"a GWpy TimeSeries is one-dimensional; this series has shape {tuple(self.data.shape)}.")
285
+ timeseries = require("gwpy.timeseries", "gwpy", "TimeSeries.to_gwpy")
286
+ return timeseries.TimeSeries(
287
+ np.asarray(self.data),
288
+ t0=self.t0,
289
+ sample_rate=self.sample_rate,
290
+ name=self.name,
291
+ unit=self.unit,
292
+ copy=False,
293
+ )
294
+
295
+ @classmethod
296
+ def from_gwpy(cls, series: gwpy.timeseries.TimeSeries) -> TimeSeries:
297
+ """Return a series with the samples, timing, name and unit of a GWpy ``TimeSeries``.
298
+
299
+ The samples are GWpy's array itself, not a copy. A dimensionless GWpy series gets
300
+ ``unit=None``.
301
+
302
+ Raises:
303
+ ValueError: If the GWpy series is irregularly sampled.
304
+ """
305
+ try:
306
+ sample_rate = series.sample_rate.to_value("Hz")
307
+ except AttributeError as exc:
308
+ # GWpy has no sample rate for a series built from an explicit, irregular times array.
309
+ raise ValueError(
310
+ "a TimeSeries is uniformly sampled; this GWpy series has an irregular time index."
311
+ ) from exc
312
+ unit = series.unit.to_string() if series.unit is not None else ""
313
+ return cls(
314
+ series.value,
315
+ t0=series.t0.to_value("s"),
316
+ sample_rate=sample_rate,
317
+ name=None if series.name is None else str(series.name),
318
+ unit=unit or None,
319
+ )
320
+
321
+ def to_pycbc(self) -> pycbc.types.TimeSeries:
322
+ """Return a PyCBC ``TimeSeries`` with the same samples and timing.
323
+
324
+ PyCBC series carry neither a name nor a unit.
325
+
326
+ Raises:
327
+ ValueError: If the series has more than one axis; PyCBC's is one-dimensional.
328
+ ModuleNotFoundError: If PyCBC is not installed.
329
+ """
330
+ if len(self.data.shape) != 1:
331
+ raise ValueError(f"a PyCBC TimeSeries is one-dimensional; this series has shape {tuple(self.data.shape)}.")
332
+ types = require("pycbc.types", "pycbc", "TimeSeries.to_pycbc")
333
+ return types.TimeSeries(np.asarray(self.data), delta_t=self.dt, epoch=self.t0)
334
+
335
+ @classmethod
336
+ def from_pycbc(
337
+ cls, series: pycbc.types.TimeSeries, *, name: str | None = None, unit: str | None = None
338
+ ) -> TimeSeries:
339
+ """Return a series with the samples and timing of a PyCBC ``TimeSeries``."""
340
+ return cls(
341
+ np.asarray(series.numpy()),
342
+ t0=float(series.start_time),
343
+ sample_rate=float(series.sample_rate),
344
+ name=name,
345
+ unit=unit,
346
+ )
347
+
348
+
349
+ def as_timeseries(series: TimeSeries | gwpy.timeseries.TimeSeries) -> TimeSeries:
350
+ """Return ``series`` as a :class:`TimeSeries`: itself, or converted from a GWpy ``TimeSeries``.
351
+
352
+ Functions that take a time series call this on their input, so they accept either kind.
353
+
354
+ Raises:
355
+ TypeError: If ``series`` is neither.
356
+ """
357
+ if isinstance(series, TimeSeries):
358
+ return series
359
+ # A GWpy series can only exist if GWpy is imported, so it is not imported here to find out.
360
+ gwpy_timeseries = sys.modules.get("gwpy.timeseries")
361
+ if gwpy_timeseries is not None and isinstance(series, gwpy_timeseries.TimeSeries):
362
+ return TimeSeries.from_gwpy(series)
363
+ raise TypeError(f"expected a gwmock_data.TimeSeries or a gwpy.timeseries.TimeSeries; got {type(series).__name__}.")
364
+
365
+
366
+ _NO_FLOAT64 = (
367
+ "TimeSeries needs float64 timing, which this array library is not providing; with JAX, call "
368
+ 'jax.config.update("jax_enable_x64", True) first. '
369
+ "In single precision a GPS time near 1.4e9 s resolves only to 128 s, so every sample of "
370
+ "a typical series would get the same time."
371
+ )
372
+
373
+
374
+ def _require_x64(jax: ModuleType) -> None:
375
+ """Refuse, rather than degrade, when JAX is in 32-bit mode."""
376
+ if not jax.config.jax_enable_x64:
377
+ raise RuntimeError(_NO_FLOAT64)
378
+
379
+
380
+ #: Serialises the first registrations: the cache alone lets concurrent first calls both run.
381
+ _REGISTRATION_LOCK = threading.Lock()
382
+
383
+
384
+ def register_pytree() -> None:
385
+ """Register :class:`TimeSeries` as a JAX pytree; safe to call more than once, from any thread.
386
+
387
+ The samples and ``t0`` are leaves, so a batch of series differing in start time can be mapped
388
+ over without recompiling. The sample rate, name and unit are static: they decide shapes and
389
+ meaning, not values.
390
+
391
+ Unflattening bypasses validation, because inside a transformation the leaves are tracers,
392
+ which cannot be checked as numbers. A series rebuilt with its start time as a JAX array that is
393
+ not float64 -- what ``jit``, ``vmap``, ``grad`` and ``device_put`` produce in JAX's 32-bit mode --
394
+ raises ``RuntimeError``; structural uses that leave it a Python float, such as ``jax.tree.map``
395
+ or ``jax.device_get``, work in either mode.
396
+
397
+ Raises:
398
+ ModuleNotFoundError: If JAX is not installed.
399
+ """
400
+ with _REGISTRATION_LOCK:
401
+ _register_pytree_once()
402
+
403
+
404
+ # Cached, so registration, which JAX keeps per process, happens once.
405
+ @functools.cache
406
+ def _register_pytree_once() -> None:
407
+ jax = require("jax", "jax", "Registering TimeSeries with JAX")
408
+ tree_util = jax.tree_util
409
+
410
+ def flatten(series: TimeSeries) -> tuple[tuple[Any, Any], tuple[float, str | None, str | None]]:
411
+ return (series.data, series.t0), (series.sample_rate, series.name, series.unit)
412
+
413
+ def unflatten(static: tuple[float, str | None, str | None], leaves: tuple[Any, Any]) -> TimeSeries:
414
+ # Checked where it can go wrong: JAX has turned t0 into one of its arrays -- a tracer under
415
+ # jit, vmap or grad, or a device array after device_put -- and, in 32-bit mode, a
416
+ # single-precision one, which cannot tell samples apart. A tree map or device_get that
417
+ # leaves t0 a Python float passes in either mode.
418
+ t0 = leaves[1]
419
+ if isinstance(t0, jax.Array) and t0.dtype != jax.numpy.float64:
420
+ raise RuntimeError(_NO_FLOAT64)
421
+ series = object.__new__(TimeSeries)
422
+ for field_name, value in zip(("data", "t0"), leaves, strict=True):
423
+ object.__setattr__(series, field_name, value)
424
+ for field_name, value in zip(("sample_rate", "name", "unit"), static, strict=True):
425
+ object.__setattr__(series, field_name, value)
426
+ return series
427
+
428
+ tree_util.register_pytree_node(TimeSeries, flatten, unflatten)
gwmock_data/version.py ADDED
@@ -0,0 +1,11 @@
1
+ """A script to infer the version number from the metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("gwmock_data")
9
+ except PackageNotFoundError:
10
+ # Fallback for source checkouts or environments without installed metadata.
11
+ __version__ = "0+unknown"
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.5
2
+ Name: gwmock-data
3
+ Version: 0.1.0
4
+ Summary: Shared data structures for the gwmock packages
5
+ Project-URL: Documentation, https://leuven-gravity-institute.github.io/gwmock-data
6
+ Project-URL: Source, https://github.com/Leuven-Gravity-Institute/gwmock-data
7
+ Project-URL: Tracker, https://github.com/Leuven-Gravity-Institute/gwmock-data/issues
8
+ Project-URL: Home, https://github.com/Leuven-Gravity-Institute/gwmock-data
9
+ Project-URL: Release Notes, https://github.com/Leuven-Gravity-Institute/gwmock-data/releases
10
+ Author: Isaac C. F. Wong
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: BSD License
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Requires-Python: >=3.13
20
+ Requires-Dist: numpy>=2.2
21
+ Provides-Extra: gwpy
22
+ Requires-Dist: gwpy>=4.0.0; extra == 'gwpy'
23
+ Provides-Extra: hdf5
24
+ Requires-Dist: h5py>=3.12; extra == 'hdf5'
25
+ Provides-Extra: jax
26
+ Requires-Dist: jax>=0.5.0; extra == 'jax'
27
+ Provides-Extra: pycbc
28
+ Requires-Dist: pycbc>=2.6.0; extra == 'pycbc'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # gwmock-data
32
+
33
+ [![Python CI](https://github.com/Leuven-Gravity-Institute/gwmock-data/actions/workflows/ci.yml/badge.svg)](https://github.com/Leuven-Gravity-Institute/gwmock-data/actions/workflows/ci.yml)
34
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/Leuven-Gravity-Institute/gwmock-data/main.svg)](https://results.pre-commit.ci/latest/github/Leuven-Gravity-Institute/gwmock-data/main)
35
+ [![Documentation Status](https://github.com/Leuven-Gravity-Institute/gwmock-data/actions/workflows/documentation.yml/badge.svg)](https://leuven-gravity-institute.github.io/gwmock-data/)
36
+ [![codecov](https://codecov.io/gh/Leuven-Gravity-Institute/gwmock-data/graph/badge.svg)](https://codecov.io/gh/Leuven-Gravity-Institute/gwmock-data)
37
+ [![PyPI Version](https://img.shields.io/pypi/v/gwmock-data)](https://pypi.org/project/gwmock-data/)
38
+ [![Python Versions](https://img.shields.io/pypi/pyversions/gwmock-data)](https://pypi.org/project/gwmock-data/)
39
+ [![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](LICENSE)
40
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
41
+ [![SPEC 0 — Minimum Supported Dependencies](https://img.shields.io/badge/SPEC-0-green?labelColor=%23004811&color=%235CA038)](https://scientific-python.org/specs/spec-0000/)
42
+
43
+ Shared data structures for the gwmock packages: the types they pass between each
44
+ other, so every package reads and writes them the same way.
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install gwmock-data
50
+ ```
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ uv sync --group dev
56
+ uv run pytest
57
+ ```
58
+
59
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,11 @@
1
+ gwmock_data/__init__.py,sha256=v0iS9KHxSpL9jjQZbgfSx4UhU7J1Cj_cSHaDMHFYpbo,547
2
+ gwmock_data/_optional.py,sha256=v4k7eNEzYhWQtKUgo5Y9QsTvxLa1-0Hi97tCuFBOLMQ,1395
3
+ gwmock_data/sampling_grid.py,sha256=wZQp1d-u1dJsclZAfdaNK2XXCJkpsJH1NoN08yomYC4,7995
4
+ gwmock_data/timeseries.py,sha256=rCN24Rrh4hanBOibnpUZeTu5LCMZROhfe0fK51O6kbQ,19187
5
+ gwmock_data/version.py,sha256=hv5yYdb-Wkr9lKNCSZSQrMnjNoKnaUBK05xgKDhDoEY,346
6
+ gwmock_data/io/__init__.py,sha256=GD3HMrdLrRg2VYbtKvXQC89YpI1HozWEoqzjqijEwxg,169
7
+ gwmock_data/io/hdf5.py,sha256=HdAjIjMpz6xU6dh7BWuTG7701yUmizQrd3snf3t-IYc,3675
8
+ gwmock_data-0.1.0.dist-info/METADATA,sha256=XsF8cDdtt8_96Epi-pii65H2hN80N6uyg35UDm9sbT0,2938
9
+ gwmock_data-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ gwmock_data-0.1.0.dist-info/licenses/LICENSE,sha256=9k1btU36CFFvEBAdimpM63ozNUIPDwhowqECuviFuLw,1481
11
+ gwmock_data-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,26 @@
1
+ Copyright 2026, Isaac C. F. Wong
2
+
3
+ Redistribution and use in source and binary forms, with or without modification,
4
+ are permitted provided that the following conditions are met:
5
+
6
+ 1. Redistributions of source code must retain the above copyright notice, this
7
+ list of conditions and the following disclaimer.
8
+
9
+ 2. Redistributions in binary form must reproduce the above copyright notice,
10
+ this list of conditions and the following disclaimer in the documentation
11
+ and/or other materials provided with the distribution.
12
+
13
+ 3. Neither the name of the copyright holder nor the names of its contributors
14
+ may be used to endorse or promote products derived from this software without
15
+ specific prior written permission.
16
+
17
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND
18
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
21
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
22
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
23
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
24
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
26
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.