A uniform sample lattice, and the arithmetic for placing GPS times on it.
Superposing one series onto another is exact when the two share a sample lattice: the operation
is an integer-offset add. When they do not, something has to resample, and the resampling sets the
accuracy of whatever is built on it. A :class:SamplingGrid names the lattice so code can decide,
exactly, whether a time is on it.
GPS times near 1.4e9 s carry float64 rounding of a sizeable fraction of a sample at kilohertz rates,
so "on the lattice" is judged with a tolerance derived from that resolution, never a fixed one.
This module holds only the lattice arithmetic and its validation, and imports nothing but NumPy.
SamplingGrid
dataclass
A uniform sample lattice: sample k sits at epoch + k / sampling_frequency.
Parameters:
| Name |
Type |
Description |
Default |
epoch
|
float
|
|
required
|
sampling_frequency
|
float
|
|
required
|
Raises:
| Type |
Description |
ValueError
|
If sampling_frequency is not positive and finite, or epoch is not
finite.
|
Source code in src/gwmock_data/sampling_grid.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167 | @dataclass(frozen=True)
class SamplingGrid:
"""A uniform sample lattice: sample ``k`` sits at ``epoch + k / sampling_frequency``.
Args:
epoch: GPS time of sample zero.
sampling_frequency: Sample rate in Hz.
Raises:
ValueError: If ``sampling_frequency`` is not positive and finite, or ``epoch`` is not
finite.
"""
epoch: float
sampling_frequency: float
def __post_init__(self) -> None:
"""Validate the lattice definition."""
if not np.isfinite(self.epoch):
raise ValueError(f"epoch must be finite; got {self.epoch}.")
if not np.isfinite(self.sampling_frequency) or self.sampling_frequency <= 0.0:
raise ValueError(f"sampling_frequency must be positive and finite; got {self.sampling_frequency}.")
@property
def sample_spacing(self) -> float:
"""Seconds between samples."""
return 1.0 / self.sampling_frequency
def lattice_tolerance_samples(self, gps_time: np.ndarray | float) -> float:
"""Return the on-lattice tolerance, in samples, appropriate to these timestamps.
Scaled by the float64 resolution of the times involved, because at GPS magnitudes that
resolution is coarser than any fixed tolerance worth writing down: near 1.4e9 one ULP
is already ~5e-4 samples at 2048 Hz.
An empty input is answered from the epoch alone. A chunked or filtered caller can
legitimately arrive with nothing to check, and reducing over an empty array would raise
here instead of letting the (vacuously satisfied) lattice check return empty.
"""
times = np.asarray(gps_time, dtype=float)
magnitude = abs(float(self.epoch))
if times.size:
magnitude = max(magnitude, float(np.max(np.abs(times))))
ulp_samples = float(np.spacing(magnitude)) * self.sampling_frequency
return float(
np.clip(_LATTICE_TOLERANCE_ULPS * ulp_samples, _MINIMUM_TOLERANCE_SAMPLES, _MAXIMUM_TOLERANCE_SAMPLES)
)
def index_of(self, gps_time: np.ndarray | float) -> np.ndarray:
"""Return the (generally fractional) lattice index of *gps_time*.
The subtraction is done before the multiplication: GPS times are ~1.4e9 and the
spacing is ~5e-4 s, so scaling first would lose the sub-sample part entirely.
"""
return (np.asarray(gps_time, dtype=float) - self.epoch) * self.sampling_frequency
def time_of(self, index: np.ndarray | int) -> np.ndarray:
"""Return the GPS time of lattice index *index*."""
return self.epoch + np.asarray(index, dtype=float) * self.sample_spacing
def split_index(self, gps_time: np.ndarray | float) -> tuple[np.ndarray, np.ndarray]:
"""Split *gps_time* into the preceding lattice index and a fractional remainder.
Returns:
``(index, fraction)`` with ``fraction`` in ``[0, 1)``, such that
``gps_time == time_of(index) + fraction * sample_spacing``. The fraction is what
the device must absorb for the sample to land on the lattice.
"""
exact = self.index_of(gps_time)
index = np.floor(exact)
return index.astype(np.int64), exact - index
def is_on_lattice(self, gps_time: np.ndarray | float) -> np.ndarray:
"""Return whether each *gps_time* coincides with a lattice sample."""
exact = self.index_of(gps_time)
return np.abs(exact - np.round(exact)) <= self.lattice_tolerance_samples(gps_time)
def require_on_lattice(self, gps_time: np.ndarray | float, *, name: str) -> np.ndarray:
"""Return integer lattice indices, rejecting any time that is off-lattice.
Rejected rather than silently rounded: rounding would move a signal by up to half a
sample without telling anyone, and silently accepting an off-lattice time is exactly
how the off-lattice resampling error described in this module's docstring got in.
Args:
gps_time: Time(s) that must coincide with lattice samples.
name: What is being checked, for the error message.
Returns:
Integer lattice indices, same shape as the input.
Raises:
ValueError: If any time is not on the lattice.
"""
times = np.atleast_1d(np.asarray(gps_time, dtype=float))
exact = self.index_of(times)
offenders = np.abs(exact - np.round(exact)) > self.lattice_tolerance_samples(times)
if np.any(offenders):
first = int(np.argmax(offenders))
raise ValueError(
f"{name} must lie on the sampling grid (epoch={self.epoch!r}, "
f"sampling_frequency={self.sampling_frequency!r}), but entry {first} at "
f"{times[first]!r} is {exact[first] - round(float(exact[first])):+.6g} samples off. "
f"Rounding it would displace the data by up to half a sample; choose segment "
f"starts on the grid, or build the grid from them."
)
return np.round(exact).astype(np.int64).reshape(np.shape(gps_time))
@classmethod
def from_segment_starts(cls, segment_start_times: np.ndarray, sampling_frequency: float) -> SamplingGrid:
"""Build a grid anchored on the first segment start.
Args:
segment_start_times: GPS start times, which must themselves be mutually
consistent with one lattice at this sample rate.
sampling_frequency: Sample rate in Hz.
Returns:
A grid whose epoch is the first segment start.
Raises:
ValueError: If ``segment_start_times`` is empty, or the starts do not share one
lattice — which would make a single canonical grid impossible.
"""
starts = np.atleast_1d(np.asarray(segment_start_times, dtype=float))
if starts.size == 0:
raise ValueError("segment_start_times must not be empty.")
grid = cls(epoch=float(starts[0]), sampling_frequency=sampling_frequency)
grid.require_on_lattice(starts, name="segment_start_times")
return grid
|
__post_init__()
Validate the lattice definition.
Source code in src/gwmock_data/sampling_grid.py
| def __post_init__(self) -> None:
"""Validate the lattice definition."""
if not np.isfinite(self.epoch):
raise ValueError(f"epoch must be finite; got {self.epoch}.")
if not np.isfinite(self.sampling_frequency) or self.sampling_frequency <= 0.0:
raise ValueError(f"sampling_frequency must be positive and finite; got {self.sampling_frequency}.")
|
from_segment_starts(segment_start_times, sampling_frequency)
classmethod
Build a grid anchored on the first segment start.
Parameters:
| Name |
Type |
Description |
Default |
segment_start_times
|
ndarray
|
GPS start times, which must themselves be mutually
consistent with one lattice at this sample rate.
|
required
|
sampling_frequency
|
float
|
|
required
|
Returns:
| Type |
Description |
SamplingGrid
|
A grid whose epoch is the first segment start.
|
Raises:
| Type |
Description |
ValueError
|
If segment_start_times is empty, or the starts do not share one
lattice — which would make a single canonical grid impossible.
|
Source code in src/gwmock_data/sampling_grid.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167 | @classmethod
def from_segment_starts(cls, segment_start_times: np.ndarray, sampling_frequency: float) -> SamplingGrid:
"""Build a grid anchored on the first segment start.
Args:
segment_start_times: GPS start times, which must themselves be mutually
consistent with one lattice at this sample rate.
sampling_frequency: Sample rate in Hz.
Returns:
A grid whose epoch is the first segment start.
Raises:
ValueError: If ``segment_start_times`` is empty, or the starts do not share one
lattice — which would make a single canonical grid impossible.
"""
starts = np.atleast_1d(np.asarray(segment_start_times, dtype=float))
if starts.size == 0:
raise ValueError("segment_start_times must not be empty.")
grid = cls(epoch=float(starts[0]), sampling_frequency=sampling_frequency)
grid.require_on_lattice(starts, name="segment_start_times")
return grid
|
index_of(gps_time)
Return the (generally fractional) lattice index of gps_time.
The subtraction is done before the multiplication: GPS times are ~1.4e9 and the
spacing is ~5e-4 s, so scaling first would lose the sub-sample part entirely.
Source code in src/gwmock_data/sampling_grid.py
| def index_of(self, gps_time: np.ndarray | float) -> np.ndarray:
"""Return the (generally fractional) lattice index of *gps_time*.
The subtraction is done before the multiplication: GPS times are ~1.4e9 and the
spacing is ~5e-4 s, so scaling first would lose the sub-sample part entirely.
"""
return (np.asarray(gps_time, dtype=float) - self.epoch) * self.sampling_frequency
|
is_on_lattice(gps_time)
Return whether each gps_time coincides with a lattice sample.
Source code in src/gwmock_data/sampling_grid.py
| def is_on_lattice(self, gps_time: np.ndarray | float) -> np.ndarray:
"""Return whether each *gps_time* coincides with a lattice sample."""
exact = self.index_of(gps_time)
return np.abs(exact - np.round(exact)) <= self.lattice_tolerance_samples(gps_time)
|
lattice_tolerance_samples(gps_time)
Return the on-lattice tolerance, in samples, appropriate to these timestamps.
Scaled by the float64 resolution of the times involved, because at GPS magnitudes that
resolution is coarser than any fixed tolerance worth writing down: near 1.4e9 one ULP
is already ~5e-4 samples at 2048 Hz.
An empty input is answered from the epoch alone. A chunked or filtered caller can
legitimately arrive with nothing to check, and reducing over an empty array would raise
here instead of letting the (vacuously satisfied) lattice check return empty.
Source code in src/gwmock_data/sampling_grid.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84 | def lattice_tolerance_samples(self, gps_time: np.ndarray | float) -> float:
"""Return the on-lattice tolerance, in samples, appropriate to these timestamps.
Scaled by the float64 resolution of the times involved, because at GPS magnitudes that
resolution is coarser than any fixed tolerance worth writing down: near 1.4e9 one ULP
is already ~5e-4 samples at 2048 Hz.
An empty input is answered from the epoch alone. A chunked or filtered caller can
legitimately arrive with nothing to check, and reducing over an empty array would raise
here instead of letting the (vacuously satisfied) lattice check return empty.
"""
times = np.asarray(gps_time, dtype=float)
magnitude = abs(float(self.epoch))
if times.size:
magnitude = max(magnitude, float(np.max(np.abs(times))))
ulp_samples = float(np.spacing(magnitude)) * self.sampling_frequency
return float(
np.clip(_LATTICE_TOLERANCE_ULPS * ulp_samples, _MINIMUM_TOLERANCE_SAMPLES, _MAXIMUM_TOLERANCE_SAMPLES)
)
|
require_on_lattice(gps_time, *, name)
Return integer lattice indices, rejecting any time that is off-lattice.
Rejected rather than silently rounded: rounding would move a signal by up to half a
sample without telling anyone, and silently accepting an off-lattice time is exactly
how the off-lattice resampling error described in this module's docstring got in.
Parameters:
| Name |
Type |
Description |
Default |
gps_time
|
ndarray | float
|
Time(s) that must coincide with lattice samples.
|
required
|
name
|
str
|
What is being checked, for the error message.
|
required
|
Returns:
| Type |
Description |
ndarray
|
Integer lattice indices, same shape as the input.
|
Raises:
| Type |
Description |
ValueError
|
If any time is not on the lattice.
|
Source code in src/gwmock_data/sampling_grid.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144 | def require_on_lattice(self, gps_time: np.ndarray | float, *, name: str) -> np.ndarray:
"""Return integer lattice indices, rejecting any time that is off-lattice.
Rejected rather than silently rounded: rounding would move a signal by up to half a
sample without telling anyone, and silently accepting an off-lattice time is exactly
how the off-lattice resampling error described in this module's docstring got in.
Args:
gps_time: Time(s) that must coincide with lattice samples.
name: What is being checked, for the error message.
Returns:
Integer lattice indices, same shape as the input.
Raises:
ValueError: If any time is not on the lattice.
"""
times = np.atleast_1d(np.asarray(gps_time, dtype=float))
exact = self.index_of(times)
offenders = np.abs(exact - np.round(exact)) > self.lattice_tolerance_samples(times)
if np.any(offenders):
first = int(np.argmax(offenders))
raise ValueError(
f"{name} must lie on the sampling grid (epoch={self.epoch!r}, "
f"sampling_frequency={self.sampling_frequency!r}), but entry {first} at "
f"{times[first]!r} is {exact[first] - round(float(exact[first])):+.6g} samples off. "
f"Rounding it would displace the data by up to half a sample; choose segment "
f"starts on the grid, or build the grid from them."
)
return np.round(exact).astype(np.int64).reshape(np.shape(gps_time))
|
split_index(gps_time)
Split gps_time into the preceding lattice index and a fractional remainder.
Returns:
| Type |
Description |
ndarray
|
(index, fraction) with fraction in [0, 1), such that
|
ndarray
|
gps_time == time_of(index) + fraction * sample_spacing. The fraction is what
|
tuple[ndarray, ndarray]
|
the device must absorb for the sample to land on the lattice.
|
Source code in src/gwmock_data/sampling_grid.py
98
99
100
101
102
103
104
105
106
107
108 | def split_index(self, gps_time: np.ndarray | float) -> tuple[np.ndarray, np.ndarray]:
"""Split *gps_time* into the preceding lattice index and a fractional remainder.
Returns:
``(index, fraction)`` with ``fraction`` in ``[0, 1)``, such that
``gps_time == time_of(index) + fraction * sample_spacing``. The fraction is what
the device must absorb for the sample to land on the lattice.
"""
exact = self.index_of(gps_time)
index = np.floor(exact)
return index.astype(np.int64), exact - index
|
time_of(index)
Return the GPS time of lattice index index.
Source code in src/gwmock_data/sampling_grid.py
| def time_of(self, index: np.ndarray | int) -> np.ndarray:
"""Return the GPS time of lattice index *index*."""
return self.epoch + np.asarray(index, dtype=float) * self.sample_spacing
|