Skip to content

Sampling grid

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

GPS time of sample zero.

required
sampling_frequency float

Sample rate in Hz.

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

sample_spacing property

Seconds between samples.

__post_init__()

Validate the lattice definition.

Source code in src/gwmock_data/sampling_grid.py
54
55
56
57
58
59
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

Sample rate in Hz.

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
86
87
88
89
90
91
92
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
110
111
112
113
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
94
95
96
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