Skip to content

Waveform

gwmock_signal.waveform

Waveform generation and backend abstractions.

LALSimulationBackend

Bases: WaveformBackend

Time-domain waveform backend implemented with LALSimulation.

Parameters:

Name Type Description Default
f_ref float | None

Reference frequency in Hz. Defaults to minimum_frequency of each call when None.

None
ringdown_fraction float

Fraction of the analysis segment reserved after coalescence. Must be in (0, 1).

DEFAULT_RINGDOWN_FRACTION
segment_duration float | None

Optional fixed analysis-segment length in seconds. When None (default) the length is estimated from the post-Newtonian chirp time so the full inspiral fits without wraparound.

None
Source code in src/gwmock_signal/waveform/backends/lal.py
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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
class LALSimulationBackend(WaveformBackend):
    """Time-domain waveform backend implemented with LALSimulation.

    Args:
        f_ref: Reference frequency in Hz. Defaults to ``minimum_frequency`` of each
            call when ``None``.
        ringdown_fraction: Fraction of the analysis segment reserved after
            coalescence. Must be in ``(0, 1)``.
        segment_duration: Optional fixed analysis-segment length in seconds. When
            ``None`` (default) the length is estimated from the post-Newtonian chirp
            time so the full inspiral fits without wraparound.
    """

    def __init__(
        self,
        *,
        f_ref: float | None = None,
        ringdown_fraction: float = conditioning.DEFAULT_RINGDOWN_FRACTION,
        segment_duration: float | None = None,
    ) -> None:
        """Validate the placement configuration shared with the ripple backend."""
        if not 0.0 < ringdown_fraction < 1.0:
            raise ValueError("ringdown_fraction must be in (0, 1)")
        if segment_duration is not None and segment_duration <= 0:
            raise ValueError("segment_duration must be > 0")
        self._f_ref = f_ref
        self._ringdown_fraction = ringdown_fraction
        self._segment_duration = segment_duration

    def available_approximants(self) -> list[str]:
        """Return every LAL approximant this backend can generate.

        ``generate_td_waveform`` produces FD-native approximants via
        ``SimInspiralChooseFDWaveform`` and time-domain approximants via
        ``SimInspiralFD``, so the advertised set is the union of both. Iterating
        over approximant indices yields each name once, in a stable order.
        """
        return [
            lalsimulation.GetStringFromApproximant(i)
            for i in range(lalsimulation.NumApproximants)
            if lalsimulation.SimInspiralImplementedTDApproximants(i)
            or lalsimulation.SimInspiralImplementedFDApproximants(i)
        ]

    @staticmethod
    def _resolve_waveform_arguments(value: object) -> dict[str, object]:
        """Validate the optional extra-argument mapping for the LAL dictionary."""
        if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
            raise ValueError("waveform_arguments must be a dict with string keys")
        for reserved in ("TidalLambda1", "TidalLambda2"):
            if reserved in value:
                raise ValueError(
                    f"Pass tidal deformabilities as lambda_1/lambda_2, not waveform_arguments[{reserved!r}]"
                )
        return dict(value)

    @staticmethod
    def _resolve_parameters(
        sampling_frequency: float, minimum_frequency: float, **params: object
    ) -> _ResolvedParameters:
        """Validate inputs and translate canonical parameters to backend-native ones."""
        remaining = dict(params)
        waveform_arguments = LALSimulationBackend._resolve_waveform_arguments(
            _pop_alias(remaining, "waveform_arguments", default={})
        )
        resolved = _ResolvedParameters(
            waveform_arguments=waveform_arguments,
            mass1=float(_pop_alias(remaining, "detector_frame_mass_1", "mass1")),
            mass2=float(_pop_alias(remaining, "detector_frame_mass_2", "mass2")),
            distance=float(_pop_alias(remaining, "luminosity_distance", "distance")),
            spin_1x=float(_pop_alias(remaining, "spin_1x", "spin1x", default=0.0)),
            spin_1y=float(_pop_alias(remaining, "spin_1y", "spin1y", default=0.0)),
            spin_1z=float(_pop_alias(remaining, "spin_1z", "spin1z", default=0.0)),
            spin_2x=float(_pop_alias(remaining, "spin_2x", "spin2x", default=0.0)),
            spin_2y=float(_pop_alias(remaining, "spin_2y", "spin2y", default=0.0)),
            spin_2z=float(_pop_alias(remaining, "spin_2z", "spin2z", default=0.0)),
            inclination=float(_pop_alias(remaining, "inclination", default=0.0)),
            coa_phase=float(_pop_alias(remaining, "coa_phase", default=0.0)),
            lambda_1=float(_pop_alias(remaining, "lambda_1", "tidal_1", default=0.0)),
            lambda_2=float(_pop_alias(remaining, "lambda_2", "tidal_2", default=0.0)),
        )
        if remaining:
            extras = ", ".join(sorted(remaining))
            raise ValueError(f"Unsupported LAL waveform parameters: {extras}")
        if sampling_frequency <= 0:
            raise ValueError("sampling_frequency must be > 0")
        if minimum_frequency <= 0:
            raise ValueError("minimum_frequency must be > 0")
        if resolved.lambda_1 < 0:
            raise ValueError("lambda_1 must be >= 0")
        if resolved.lambda_2 < 0:
            raise ValueError("lambda_2 must be >= 0")
        return resolved

    def _evaluate_fd(
        self, approximant: str, p: _ResolvedParameters, grid: _FrequencyGrid
    ) -> tuple[np.ndarray, np.ndarray, float]:
        """Evaluate the one-sided FD polarizations plus the epoch correction.

        Returns the raw plus/cross frequency-series buffers and the time shift
        (in seconds) that re-references them to the frequency-domain phase
        convention. Subclasses may override this to source the same quantities
        from a different generator while sharing the segment sizing and
        conditioning performed by :meth:`generate_td_waveform`.
        """
        approx_enum = lalsimulation.GetApproximantFromString(approximant)
        lal_params = lal.CreateDict()
        lalsimulation.SimInspiralWaveformParamsInsertTidalLambda1(lal_params, p.lambda_1)
        lalsimulation.SimInspiralWaveformParamsInsertTidalLambda2(lal_params, p.lambda_2)
        _apply_waveform_arguments(lal_params, p.waveform_arguments)

        wf_args = (
            p.mass1 * MSUN,
            p.mass2 * MSUN,
            p.spin_1x,
            p.spin_1y,
            p.spin_1z,
            p.spin_2x,
            p.spin_2y,
            p.spin_2z,
            p.distance * MPC,
            p.inclination,
            p.coa_phase,
            0.0,  # longitude of ascending nodes
            0.0,  # eccentricity
            0.0,  # mean periastron anomaly
            grid.delta_f,
            grid.minimum_frequency,
            grid.f_max,
            grid.f_ref,
            lal_params,
            approx_enum,
        )
        # Follow bilby: FD-native approximants come back already referenced to the FD
        # phase; a TD approximant routed through SimInspiralFD carries an epoch from
        # the internal time-domain conditioning, undone by a dt = T + epoch shift.
        if lalsimulation.SimInspiralImplementedFDApproximants(approx_enum):
            hp, hc = lalsimulation.SimInspiralChooseFDWaveform(*wf_args)
            epoch_shift = 0.0
        else:
            hp, hc = lalsimulation.SimInspiralFD(*wf_args)
            epoch_shift = 1.0 / hp.deltaF + (hp.epoch.gpsSeconds + hp.epoch.gpsNanoSeconds * 1e-9)
        return np.asarray(hp.data.data), np.asarray(hc.data.data), epoch_shift

    def pre_coalescence_duration(
        self,
        approximant: str,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> float | None:
        """Return the seconds before ``tc`` this backend's buffer starts.

        Computed from the same two helpers ``generate_td_waveform`` uses --
        :func:`~gwmock_signal.waveform.backends.conditioning.segment_sample_count` for the length
        and :func:`~gwmock_signal.waveform.backends.conditioning.coalescence_placement` for where
        coalescence sits in it -- so the answer cannot drift from what generation actually does.
        Reproducing the arithmetic here instead would be a second implementation of it.
        """
        del approximant
        _, merger_index = self._buffer_shape(sampling_frequency, minimum_frequency, **params)
        return merger_index / sampling_frequency

    def post_coalescence_duration(
        self,
        approximant: str,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> float | None:
        """Return the seconds after ``tc`` this backend's buffer runs.

        The complement of :meth:`pre_coalescence_duration`, from the same two helpers and the same
        single call to them, so the two cannot describe different waveforms: the buffer is
        ``n_samples`` long and coalescence sits at ``merger_index``, so what remains after it is
        the rest. ``ringdown_fraction`` is what sets the split.
        """
        del approximant
        n_samples, merger_index = self._buffer_shape(sampling_frequency, minimum_frequency, **params)
        return (n_samples - merger_index) / sampling_frequency

    def _buffer_shape(self, sampling_frequency: float, minimum_frequency: float, **params: object) -> tuple[int, int]:
        """Return ``(n_samples, merger_index)`` for the buffer generation would produce.

        Shared by both duration queries deliberately. They are two views of one buffer, and
        computing them separately is how a change to one silently stops matching the other --
        which would be indistinguishable, at the call site, from a backend whose generation had
        drifted from its own sizing.

        Args:
            sampling_frequency: Sample rate in Hz.
            minimum_frequency: Low-frequency cutoff in Hz.
            **params: Source parameters, as ``generate_td_waveform`` takes them.

        Returns:
            The buffer's sample count and the index coalescence sits on within it.
        """
        p = self._resolve_parameters(sampling_frequency, minimum_frequency, **params)
        chirp_mass = (p.mass1 * p.mass2) ** 0.6 / (p.mass1 + p.mass2) ** 0.2
        n_samples = conditioning.segment_sample_count(
            chirp_mass,
            minimum_frequency,
            sampling_frequency,
            ringdown_fraction=self._ringdown_fraction,
            segment_duration=self._segment_duration,
        )
        merger_index, _ = conditioning.coalescence_placement(n_samples, sampling_frequency, self._ringdown_fraction)
        return n_samples, merger_index

    def generate_td_waveform(
        self,
        approximant: str,
        tc: float,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> dict[str, TimeSeries]:
        """Generate plus/cross polarizations, conditioned from frequency to time domain."""
        p = self._resolve_parameters(sampling_frequency, minimum_frequency, **params)

        chirp_mass = (p.mass1 * p.mass2) ** 0.6 / (p.mass1 + p.mass2) ** 0.2
        n_samples = conditioning.segment_sample_count(
            chirp_mass,
            minimum_frequency,
            sampling_frequency,
            ringdown_fraction=self._ringdown_fraction,
            segment_duration=self._segment_duration,
        )
        delta_f = sampling_frequency / n_samples
        f_max = sampling_frequency / 2.0
        f_ref = self._f_ref if self._f_ref is not None else minimum_frequency

        grid = _FrequencyGrid(delta_f=delta_f, minimum_frequency=minimum_frequency, f_max=f_max, f_ref=f_ref)
        hp_raw, hc_raw, epoch_shift = self._evaluate_fd(approximant, p, grid)

        n_freq = n_samples // 2 + 1
        freqs = np.arange(n_freq) * delta_f
        hp_f = _to_onesided(hp_raw, n_freq)
        hc_f = _to_onesided(hc_raw, n_freq)
        if epoch_shift:
            time_shift = np.exp(-2j * np.pi * freqs * epoch_shift)
            hp_f = hp_f * time_shift
            hc_f = hc_f * time_shift
        in_band = freqs >= minimum_frequency
        hp_f = np.nan_to_num(np.where(in_band, hp_f, 0.0))
        hc_f = np.nan_to_num(np.where(in_band, hc_f, 0.0))

        hp_t, hc_t, epoch = conditioning.condition_fd_to_td(
            hp_f, hc_f, n_samples, sampling_frequency, self._ringdown_fraction
        )
        dt = 1.0 / sampling_frequency
        t0 = epoch + tc
        return {
            "plus": TimeSeries(hp_t, t0=t0, dt=dt),
            "cross": TimeSeries(hc_t, t0=t0, dt=dt),
        }

__init__(*, f_ref=None, ringdown_fraction=conditioning.DEFAULT_RINGDOWN_FRACTION, segment_duration=None)

Validate the placement configuration shared with the ripple backend.

Source code in src/gwmock_signal/waveform/backends/lal.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def __init__(
    self,
    *,
    f_ref: float | None = None,
    ringdown_fraction: float = conditioning.DEFAULT_RINGDOWN_FRACTION,
    segment_duration: float | None = None,
) -> None:
    """Validate the placement configuration shared with the ripple backend."""
    if not 0.0 < ringdown_fraction < 1.0:
        raise ValueError("ringdown_fraction must be in (0, 1)")
    if segment_duration is not None and segment_duration <= 0:
        raise ValueError("segment_duration must be > 0")
    self._f_ref = f_ref
    self._ringdown_fraction = ringdown_fraction
    self._segment_duration = segment_duration

available_approximants()

Return every LAL approximant this backend can generate.

generate_td_waveform produces FD-native approximants via SimInspiralChooseFDWaveform and time-domain approximants via SimInspiralFD, so the advertised set is the union of both. Iterating over approximant indices yields each name once, in a stable order.

Source code in src/gwmock_signal/waveform/backends/lal.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def available_approximants(self) -> list[str]:
    """Return every LAL approximant this backend can generate.

    ``generate_td_waveform`` produces FD-native approximants via
    ``SimInspiralChooseFDWaveform`` and time-domain approximants via
    ``SimInspiralFD``, so the advertised set is the union of both. Iterating
    over approximant indices yields each name once, in a stable order.
    """
    return [
        lalsimulation.GetStringFromApproximant(i)
        for i in range(lalsimulation.NumApproximants)
        if lalsimulation.SimInspiralImplementedTDApproximants(i)
        or lalsimulation.SimInspiralImplementedFDApproximants(i)
    ]

generate_td_waveform(approximant, tc, sampling_frequency, minimum_frequency, **params)

Generate plus/cross polarizations, conditioned from frequency to time domain.

Source code in src/gwmock_signal/waveform/backends/lal.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
def generate_td_waveform(
    self,
    approximant: str,
    tc: float,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> dict[str, TimeSeries]:
    """Generate plus/cross polarizations, conditioned from frequency to time domain."""
    p = self._resolve_parameters(sampling_frequency, minimum_frequency, **params)

    chirp_mass = (p.mass1 * p.mass2) ** 0.6 / (p.mass1 + p.mass2) ** 0.2
    n_samples = conditioning.segment_sample_count(
        chirp_mass,
        minimum_frequency,
        sampling_frequency,
        ringdown_fraction=self._ringdown_fraction,
        segment_duration=self._segment_duration,
    )
    delta_f = sampling_frequency / n_samples
    f_max = sampling_frequency / 2.0
    f_ref = self._f_ref if self._f_ref is not None else minimum_frequency

    grid = _FrequencyGrid(delta_f=delta_f, minimum_frequency=minimum_frequency, f_max=f_max, f_ref=f_ref)
    hp_raw, hc_raw, epoch_shift = self._evaluate_fd(approximant, p, grid)

    n_freq = n_samples // 2 + 1
    freqs = np.arange(n_freq) * delta_f
    hp_f = _to_onesided(hp_raw, n_freq)
    hc_f = _to_onesided(hc_raw, n_freq)
    if epoch_shift:
        time_shift = np.exp(-2j * np.pi * freqs * epoch_shift)
        hp_f = hp_f * time_shift
        hc_f = hc_f * time_shift
    in_band = freqs >= minimum_frequency
    hp_f = np.nan_to_num(np.where(in_band, hp_f, 0.0))
    hc_f = np.nan_to_num(np.where(in_band, hc_f, 0.0))

    hp_t, hc_t, epoch = conditioning.condition_fd_to_td(
        hp_f, hc_f, n_samples, sampling_frequency, self._ringdown_fraction
    )
    dt = 1.0 / sampling_frequency
    t0 = epoch + tc
    return {
        "plus": TimeSeries(hp_t, t0=t0, dt=dt),
        "cross": TimeSeries(hc_t, t0=t0, dt=dt),
    }

post_coalescence_duration(approximant, sampling_frequency, minimum_frequency, **params)

Return the seconds after tc this backend's buffer runs.

The complement of :meth:pre_coalescence_duration, from the same two helpers and the same single call to them, so the two cannot describe different waveforms: the buffer is n_samples long and coalescence sits at merger_index, so what remains after it is the rest. ringdown_fraction is what sets the split.

Source code in src/gwmock_signal/waveform/backends/lal.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
def post_coalescence_duration(
    self,
    approximant: str,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> float | None:
    """Return the seconds after ``tc`` this backend's buffer runs.

    The complement of :meth:`pre_coalescence_duration`, from the same two helpers and the same
    single call to them, so the two cannot describe different waveforms: the buffer is
    ``n_samples`` long and coalescence sits at ``merger_index``, so what remains after it is
    the rest. ``ringdown_fraction`` is what sets the split.
    """
    del approximant
    n_samples, merger_index = self._buffer_shape(sampling_frequency, minimum_frequency, **params)
    return (n_samples - merger_index) / sampling_frequency

pre_coalescence_duration(approximant, sampling_frequency, minimum_frequency, **params)

Return the seconds before tc this backend's buffer starts.

Computed from the same two helpers generate_td_waveform uses -- :func:~gwmock_signal.waveform.backends.conditioning.segment_sample_count for the length and :func:~gwmock_signal.waveform.backends.conditioning.coalescence_placement for where coalescence sits in it -- so the answer cannot drift from what generation actually does. Reproducing the arithmetic here instead would be a second implementation of it.

Source code in src/gwmock_signal/waveform/backends/lal.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def pre_coalescence_duration(
    self,
    approximant: str,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> float | None:
    """Return the seconds before ``tc`` this backend's buffer starts.

    Computed from the same two helpers ``generate_td_waveform`` uses --
    :func:`~gwmock_signal.waveform.backends.conditioning.segment_sample_count` for the length
    and :func:`~gwmock_signal.waveform.backends.conditioning.coalescence_placement` for where
    coalescence sits in it -- so the answer cannot drift from what generation actually does.
    Reproducing the arithmetic here instead would be a second implementation of it.
    """
    del approximant
    _, merger_index = self._buffer_shape(sampling_frequency, minimum_frequency, **params)
    return merger_index / sampling_frequency

PyCBCBackend

Bases: WaveformBackend

Time-domain waveform backend implemented with PyCBC.

Source code in src/gwmock_signal/waveform/backends/pycbc.py
 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
168
169
170
171
172
173
174
175
176
177
178
179
180
class PyCBCBackend(WaveformBackend):
    """Time-domain waveform backend implemented with PyCBC."""

    def __init__(self) -> None:
        """Require PyCBC only when this backend is instantiated."""
        try:
            self._pycbc_waveform = importlib.import_module("pycbc.waveform")
        except ImportError as exc:
            raise ImportError(_PYCBC_IMPORT_ERROR) from exc

    def available_approximants(self) -> list[str]:
        """Return all PyCBC time-domain approximants."""
        return list(self._pycbc_waveform.td_approximants())

    def pre_coalescence_duration(
        self,
        approximant: str,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> float | None:
        """Return ``None``: this backend cannot say where its buffer starts.

        PyCBC sizes the waveform inside its own library, so there is no conditioning arithmetic here
        to answer from. Declared explicitly rather than inherited so that this is a documented
        property of the PyCBC backend rather than a silent fallthrough -- and so the base class's
        discussion of measured losses, which is about backends that *can* answer, does not appear on
        this page as though these figures came from PyCBC.

        ``None`` means *unknown*, never zero. A caller that reads it as zero concludes the waveform
        starts at coalescence and crops the entire inspiral; see ``WaveformBackend`` for what that
        costs.

        Args:
            approximant: Unused; accepted to match the base signature.
            sampling_frequency: Unused; accepted to match the base signature.
            minimum_frequency: Unused; accepted to match the base signature.
            **params: Unused; accepted to match the base signature.

        Returns:
            Always ``None``.
        """
        return None

    def post_coalescence_duration(
        self,
        approximant: str,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> float | None:
        """Return ``None``: this backend cannot say where its buffer ends either.

        PyCBC conditions inside its own library, so answering would mean reimplementing that
        conditioning here and being wrong whenever it changed. ``None`` means *unknown*, never
        zero -- a caller reading zero concludes an event's content stops at coalescence, and
        would discard events whose tail reaches into the segment it is writing.

        Args:
            approximant: Unused; accepted to match the base signature.
            sampling_frequency: Unused; accepted to match the base signature.
            minimum_frequency: Unused; accepted to match the base signature.
            **params: Unused; accepted to match the base signature.
        """
        del approximant, sampling_frequency, minimum_frequency, params
        return None

    @staticmethod
    def _resolve_waveform_arguments(value: object) -> dict[str, object]:
        """Validate the optional extra-argument mapping forwarded to PyCBC.

        Keys are approximant-specific ``get_td_waveform`` options (e.g.
        ``mode_array``, ``f_ref``, ``numerical_relativity_file``). Reserved keys
        that this backend derives from canonical parameters or manages itself are
        rejected so extras cannot silently override them.
        """
        if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
            raise ValueError("waveform_arguments must be a dict with string keys")
        reserved = sorted(key for key in value if key in _RESERVED_WAVEFORM_ARGUMENTS)
        if reserved:
            joined = ", ".join(reserved)
            raise ValueError(f"Pass these as canonical parameters, not waveform_arguments: {joined}")
        return dict(value)

    def generate_td_waveform(
        self,
        approximant: str,
        tc: float,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> dict[str, TimeSeries]:
        """Generate plus/cross polarizations through ``pycbc_waveform_wrapper``.

        Canonical CBC parameters (masses, spins, distance, orientation, tidal
        deformabilities) are translated to PyCBC's native names. Any other
        ``get_td_waveform`` option must be passed inside a ``waveform_arguments``
        mapping; unrecognised top-level parameters are rejected.
        """
        pycbc_waveform_wrapper = importlib.import_module("gwmock_signal.waveform.pycbc_wrapper").pycbc_waveform_wrapper
        remaining = dict(params)
        waveform_arguments = self._resolve_waveform_arguments(_pop_alias(remaining, "waveform_arguments", default={}))
        translated = {
            "mass1": _pop_alias(remaining, "detector_frame_mass_1", "mass1"),
            "mass2": _pop_alias(remaining, "detector_frame_mass_2", "mass2"),
            "distance": _pop_alias(remaining, "luminosity_distance", "distance"),
            "spin1x": _pop_alias(remaining, "spin_1x", "spin1x", default=0.0),
            "spin1y": _pop_alias(remaining, "spin_1y", "spin1y", default=0.0),
            "spin1z": _pop_alias(remaining, "spin_1z", "spin1z", default=0.0),
            "spin2x": _pop_alias(remaining, "spin_2x", "spin2x", default=0.0),
            "spin2y": _pop_alias(remaining, "spin_2y", "spin2y", default=0.0),
            "spin2z": _pop_alias(remaining, "spin_2z", "spin2z", default=0.0),
            "inclination": _pop_alias(remaining, "inclination", default=0.0),
            "coa_phase": _pop_alias(remaining, "coa_phase", default=0.0),
            "lambda1": _pop_alias(remaining, "lambda_1", "lambda1", "tidal_1", default=0.0),
            "lambda2": _pop_alias(remaining, "lambda_2", "lambda2", "tidal_2", default=0.0),
        }
        if remaining:
            extras = ", ".join(sorted(remaining))
            raise ValueError(f"Unsupported PyCBC waveform parameters: {extras}")
        translated.update(waveform_arguments)
        return pycbc_waveform_wrapper(
            tc=tc,
            sampling_frequency=sampling_frequency,
            minimum_frequency=minimum_frequency,
            waveform_model=approximant,
            **translated,
        )

__init__()

Require PyCBC only when this backend is instantiated.

Source code in src/gwmock_signal/waveform/backends/pycbc.py
56
57
58
59
60
61
def __init__(self) -> None:
    """Require PyCBC only when this backend is instantiated."""
    try:
        self._pycbc_waveform = importlib.import_module("pycbc.waveform")
    except ImportError as exc:
        raise ImportError(_PYCBC_IMPORT_ERROR) from exc

available_approximants()

Return all PyCBC time-domain approximants.

Source code in src/gwmock_signal/waveform/backends/pycbc.py
63
64
65
def available_approximants(self) -> list[str]:
    """Return all PyCBC time-domain approximants."""
    return list(self._pycbc_waveform.td_approximants())

generate_td_waveform(approximant, tc, sampling_frequency, minimum_frequency, **params)

Generate plus/cross polarizations through pycbc_waveform_wrapper.

Canonical CBC parameters (masses, spins, distance, orientation, tidal deformabilities) are translated to PyCBC's native names. Any other get_td_waveform option must be passed inside a waveform_arguments mapping; unrecognised top-level parameters are rejected.

Source code in src/gwmock_signal/waveform/backends/pycbc.py
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
168
169
170
171
172
173
174
175
176
177
178
179
180
def generate_td_waveform(
    self,
    approximant: str,
    tc: float,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> dict[str, TimeSeries]:
    """Generate plus/cross polarizations through ``pycbc_waveform_wrapper``.

    Canonical CBC parameters (masses, spins, distance, orientation, tidal
    deformabilities) are translated to PyCBC's native names. Any other
    ``get_td_waveform`` option must be passed inside a ``waveform_arguments``
    mapping; unrecognised top-level parameters are rejected.
    """
    pycbc_waveform_wrapper = importlib.import_module("gwmock_signal.waveform.pycbc_wrapper").pycbc_waveform_wrapper
    remaining = dict(params)
    waveform_arguments = self._resolve_waveform_arguments(_pop_alias(remaining, "waveform_arguments", default={}))
    translated = {
        "mass1": _pop_alias(remaining, "detector_frame_mass_1", "mass1"),
        "mass2": _pop_alias(remaining, "detector_frame_mass_2", "mass2"),
        "distance": _pop_alias(remaining, "luminosity_distance", "distance"),
        "spin1x": _pop_alias(remaining, "spin_1x", "spin1x", default=0.0),
        "spin1y": _pop_alias(remaining, "spin_1y", "spin1y", default=0.0),
        "spin1z": _pop_alias(remaining, "spin_1z", "spin1z", default=0.0),
        "spin2x": _pop_alias(remaining, "spin_2x", "spin2x", default=0.0),
        "spin2y": _pop_alias(remaining, "spin_2y", "spin2y", default=0.0),
        "spin2z": _pop_alias(remaining, "spin_2z", "spin2z", default=0.0),
        "inclination": _pop_alias(remaining, "inclination", default=0.0),
        "coa_phase": _pop_alias(remaining, "coa_phase", default=0.0),
        "lambda1": _pop_alias(remaining, "lambda_1", "lambda1", "tidal_1", default=0.0),
        "lambda2": _pop_alias(remaining, "lambda_2", "lambda2", "tidal_2", default=0.0),
    }
    if remaining:
        extras = ", ".join(sorted(remaining))
        raise ValueError(f"Unsupported PyCBC waveform parameters: {extras}")
    translated.update(waveform_arguments)
    return pycbc_waveform_wrapper(
        tc=tc,
        sampling_frequency=sampling_frequency,
        minimum_frequency=minimum_frequency,
        waveform_model=approximant,
        **translated,
    )

post_coalescence_duration(approximant, sampling_frequency, minimum_frequency, **params)

Return None: this backend cannot say where its buffer ends either.

PyCBC conditions inside its own library, so answering would mean reimplementing that conditioning here and being wrong whenever it changed. None means unknown, never zero -- a caller reading zero concludes an event's content stops at coalescence, and would discard events whose tail reaches into the segment it is writing.

Parameters:

Name Type Description Default
approximant str

Unused; accepted to match the base signature.

required
sampling_frequency float

Unused; accepted to match the base signature.

required
minimum_frequency float

Unused; accepted to match the base signature.

required
**params object

Unused; accepted to match the base signature.

{}
Source code in src/gwmock_signal/waveform/backends/pycbc.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def post_coalescence_duration(
    self,
    approximant: str,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> float | None:
    """Return ``None``: this backend cannot say where its buffer ends either.

    PyCBC conditions inside its own library, so answering would mean reimplementing that
    conditioning here and being wrong whenever it changed. ``None`` means *unknown*, never
    zero -- a caller reading zero concludes an event's content stops at coalescence, and
    would discard events whose tail reaches into the segment it is writing.

    Args:
        approximant: Unused; accepted to match the base signature.
        sampling_frequency: Unused; accepted to match the base signature.
        minimum_frequency: Unused; accepted to match the base signature.
        **params: Unused; accepted to match the base signature.
    """
    del approximant, sampling_frequency, minimum_frequency, params
    return None

pre_coalescence_duration(approximant, sampling_frequency, minimum_frequency, **params)

Return None: this backend cannot say where its buffer starts.

PyCBC sizes the waveform inside its own library, so there is no conditioning arithmetic here to answer from. Declared explicitly rather than inherited so that this is a documented property of the PyCBC backend rather than a silent fallthrough -- and so the base class's discussion of measured losses, which is about backends that can answer, does not appear on this page as though these figures came from PyCBC.

None means unknown, never zero. A caller that reads it as zero concludes the waveform starts at coalescence and crops the entire inspiral; see WaveformBackend for what that costs.

Parameters:

Name Type Description Default
approximant str

Unused; accepted to match the base signature.

required
sampling_frequency float

Unused; accepted to match the base signature.

required
minimum_frequency float

Unused; accepted to match the base signature.

required
**params object

Unused; accepted to match the base signature.

{}

Returns:

Type Description
float | None

Always None.

Source code in src/gwmock_signal/waveform/backends/pycbc.py
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
def pre_coalescence_duration(
    self,
    approximant: str,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> float | None:
    """Return ``None``: this backend cannot say where its buffer starts.

    PyCBC sizes the waveform inside its own library, so there is no conditioning arithmetic here
    to answer from. Declared explicitly rather than inherited so that this is a documented
    property of the PyCBC backend rather than a silent fallthrough -- and so the base class's
    discussion of measured losses, which is about backends that *can* answer, does not appear on
    this page as though these figures came from PyCBC.

    ``None`` means *unknown*, never zero. A caller that reads it as zero concludes the waveform
    starts at coalescence and crops the entire inspiral; see ``WaveformBackend`` for what that
    costs.

    Args:
        approximant: Unused; accepted to match the base signature.
        sampling_frequency: Unused; accepted to match the base signature.
        minimum_frequency: Unused; accepted to match the base signature.
        **params: Unused; accepted to match the base signature.

    Returns:
        Always ``None``.
    """
    return None

RippleBackend

Bases: WaveformBackend

Time-domain waveform backend implemented with ripple (JAX).

Parameters:

Name Type Description Default
f_ref float | None

Reference frequency in Hz. Defaults to minimum_frequency of each call when None.

None
ringdown_fraction float

Fraction of the analysis segment reserved after coalescence. Must be in (0, 1).

_DEFAULT_RINGDOWN_FRACTION
segment_duration float | None

Optional fixed analysis-segment length in seconds. When None (default) the length is estimated from the post-Newtonian chirp time so the full inspiral fits without wraparound.

None
taper_fraction float

Width of the amplitude taper below minimum_frequency, as a fraction of it. Must be in [0, 1).

This changes what minimum_frequency means. With a non-zero fraction it is the frequency at which the waveform reaches full amplitude, and the generated strain contains real inspiral content from minimum_frequency / (1 + taper_fraction) upward -- see :meth:signal_start_frequency. That is deliberate: the alternative, tapering above the cutoff, removes in-band power instead. Pass 0.0 for the previous hard-cutoff behaviour, at the cost of ringing across the buffer.

_DEFAULT_TAPER_FRACTION
Source code in src/gwmock_signal/waveform/backends/ripple.py
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
class RippleBackend(WaveformBackend):
    """Time-domain waveform backend implemented with ripple (JAX).

    Args:
        f_ref: Reference frequency in Hz. Defaults to ``minimum_frequency`` of each
            call when ``None``.
        ringdown_fraction: Fraction of the analysis segment reserved after
            coalescence. Must be in ``(0, 1)``.
        segment_duration: Optional fixed analysis-segment length in seconds. When
            ``None`` (default) the length is estimated from the post-Newtonian
            chirp time so the full inspiral fits without wraparound.
        taper_fraction: Width of the amplitude taper *below* ``minimum_frequency``, as a
            fraction of it. Must be in ``[0, 1)``.

            **This changes what ``minimum_frequency`` means.** With a non-zero fraction it is
            the frequency at which the waveform reaches full amplitude, and the generated
            strain contains real inspiral content from
            ``minimum_frequency / (1 + taper_fraction)`` upward -- see
            :meth:`signal_start_frequency`. That is deliberate: the alternative, tapering
            above the cutoff, removes in-band power instead. Pass ``0.0`` for the previous
            hard-cutoff behaviour, at the cost of ringing across the buffer.
    """

    def __init__(
        self,
        *,
        f_ref: float | None = None,
        ringdown_fraction: float = _DEFAULT_RINGDOWN_FRACTION,
        segment_duration: float | None = None,
        taper_fraction: float = _DEFAULT_TAPER_FRACTION,
    ) -> None:
        """Require ripple/JAX only when this backend is instantiated."""
        try:
            self._jax = importlib.import_module("jax")
            self._jnp = importlib.import_module("jax.numpy")
            self._ripplegw = importlib.import_module("ripplegw")
        except ImportError as exc:
            raise ImportError(_RIPPLE_IMPORT_ERROR) from exc
        # Not in the try above: a missing submodule means ripple is installed but *different*,
        # which is the guard's message to give, not "ripple is not installed".
        self._conversions = _optional_module("ripplegw.conversions")
        self._constants = _optional_module("ripplegw.constants")
        _require_ripple_interface(
            {
                "ripplegw": self._ripplegw,
                "ripplegw.conversions": self._conversions,
                "ripplegw.constants": self._constants,
            }
        )
        # ripple needs double precision for waveform phase accuracy over long
        # inspirals. Importing ripplegw already enables this globally; set it
        # explicitly so correctness does not depend on import order.
        self._jax.config.update("jax_enable_x64", True)
        if not 0.0 < ringdown_fraction < 1.0:
            raise ValueError("ringdown_fraction must be in (0, 1)")
        if segment_duration is not None and segment_duration <= 0:
            raise ValueError("segment_duration must be > 0")
        # A fraction of 1 would put the ramp's lower edge at zero frequency, and beyond that it is
        # negative; either way the window is meaningless rather than merely aggressive.
        if not 0.0 <= taper_fraction < 1.0:
            raise ValueError(f"taper_fraction must be in [0, 1); got {taper_fraction}")
        self._f_ref = f_ref
        self._ringdown_fraction = ringdown_fraction
        self._segment_duration = segment_duration
        self._taper_fraction = float(taper_fraction)

    def available_approximants(self) -> list[str]:
        """Return the ripple approximants supported by this backend."""
        return list(_SUPPORTED_APPROXIMANTS)

    @property
    def taper_fraction(self) -> float:
        """Width of the amplitude taper below ``minimum_frequency``, as a fraction of it."""
        return self._taper_fraction

    def signal_start_frequency(self, minimum_frequency: float) -> float:
        """Return the lowest frequency the generated strain actually contains.

        ``minimum_frequency`` is where the waveform reaches *full* amplitude. With a taper the
        strain also holds attenuated content below it, down to this frequency, and the analysis
        buffer must be sized from here rather than from the cutoff -- an inspiral that starts lower
        lasts longer. Verified: sizing from ``minimum_frequency`` instead leaves a 1.4+1.35 system
        at 10 Hz with a *negative* margin and a post-ringdown level of 2.9e-3, worse than the hard
        cutoff it was meant to improve on.

        Args:
            minimum_frequency: The requested cutoff in Hz.

        Returns:
            ``minimum_frequency / (1 + taper_fraction)``.

        Raises:
            ValueError: If ``minimum_frequency`` is not positive and finite. Validated here as well
                as on the generation paths, because this is public and can be called on its own --
                and without the check a non-positive cutoff returns a plausible-looking number that
                would go on to size a buffer.
        """
        if not np.isfinite(minimum_frequency) or minimum_frequency <= 0.0:
            raise ValueError(f"minimum_frequency must be positive and finite; got {minimum_frequency}.")
        return minimum_frequency / (1.0 + self._taper_fraction)

    @property
    def segment_duration(self) -> float | None:
        """The fixed analysis-segment length in seconds, or ``None`` if auto-sized."""
        return self._segment_duration

    def with_segment_duration(self, segment_duration: float) -> RippleBackend:
        """Return a copy of this backend pinned to a fixed ``segment_duration``.

        Same ``f_ref``, ``ringdown_fraction`` and ``taper_fraction``; useful for forcing one shared
        grid across several batched calls (e.g. count-chunked catalogue generation). The taper has
        to travel with the copy: a pinned backend that quietly reverted to a hard cutoff would
        change the conditioning of the very chunks this exists to keep identical.
        """
        return RippleBackend(
            f_ref=self._f_ref,
            ringdown_fraction=self._ringdown_fraction,
            segment_duration=segment_duration,
            taper_fraction=self._taper_fraction,
        )

    def segment_duration_for(
        self,
        chirp_mass_solar: float | np.ndarray,
        minimum_frequency: float,
        sampling_frequency: float,
        eta: float | np.ndarray,
    ) -> float:
        """Worst-case segment duration (seconds) the batch path uses for these masses.

        Args:
            chirp_mass_solar: Detector-frame chirp mass(es) in solar masses.
            minimum_frequency: Low-frequency cutoff in Hz.
            sampling_frequency: Sample rate in Hz.
            eta: Symmetric mass ratio(es), aligned with ``chirp_mass_solar``. Required, because an
                equal-mass default would silently underestimate an asymmetric binary's duration.

        Returns:
            Duration in seconds.
        """
        return (
            self._segment_samples(chirp_mass_solar, minimum_frequency, sampling_frequency, eta=eta) / sampling_frequency
        )

    def pre_coalescence_duration(
        self,
        approximant: str,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> float | None:
        """Return the seconds before ``tc`` this backend's buffer starts.

        Built from the same two steps generation uses -- ``_segment_samples`` for the length and
        ``coalescence_placement`` for where coalescence sits in it -- so the answer cannot drift
        from what ``generate_td_waveform`` actually produces. Ripple sizes differently from the
        frequency-domain conditioning shared with the LAL backend (5-smooth lengths rather than
        powers of two, and eta enters its 1PN term), which is exactly why this is asked of the
        backend rather than computed once by the caller.
        """
        _, merger_index = self._buffer_shape(approximant, sampling_frequency, minimum_frequency, **params)
        return merger_index / sampling_frequency

    def post_coalescence_duration(
        self,
        approximant: str,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> float | None:
        """Return the seconds after ``tc`` this backend's buffer runs.

        The complement of :meth:`pre_coalescence_duration`, from the same sizing call, so the two
        cannot drift into describing different buffers. Ripple's 5-smooth sizing and its 1PN eta
        term make this a different number from the LAL backend's for the same source, which is
        why both are asked of the backend rather than computed once by the caller.
        """
        n_samples, merger_index = self._buffer_shape(approximant, sampling_frequency, minimum_frequency, **params)
        return (n_samples - merger_index) / sampling_frequency

    def _buffer_shape(
        self, approximant: str, sampling_frequency: float, minimum_frequency: float, **params: object
    ) -> tuple[int, int]:
        """Return ``(n_samples, merger_index)`` for the buffer generation would produce.

        Shared by both duration queries so they remain two views of one buffer; computing them
        separately is how a change to one silently stops matching the other, which at the call
        site is indistinguishable from generation having drifted from its own sizing.

        Args:
            approximant: The approximant that will be generated.
            sampling_frequency: Sample rate in Hz.
            minimum_frequency: Low-frequency cutoff in Hz.
            **params: Source parameters, as ``generate_td_waveform`` takes them.

        Returns:
            The buffer's sample count and the index coalescence sits on within it.
        """
        resolved = self._resolve_parameters(approximant, sampling_frequency, minimum_frequency, **params)
        chirp_mass, eta = self._jax.vmap(self._conversions.ms_to_Mc_eta)(
            self._jnp.stack([self._jnp.atleast_1d(resolved.mass1), self._jnp.atleast_1d(resolved.mass2)], axis=-1)
        )
        n_samples = self._segment_samples(
            np.asarray(chirp_mass, dtype=float),
            minimum_frequency,
            sampling_frequency,
            eta=np.asarray(eta, dtype=float),
        )
        merger_index, _ = self.coalescence_placement(n_samples, sampling_frequency)
        return n_samples, merger_index

    def generate_td_waveform(
        self,
        approximant: str,
        tc: float,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> dict[str, TimeSeries]:
        """Generate plus/cross polarizations from ripple, conditioned to time domain."""
        fd = self.generate_fd_polarizations(
            approximant,
            sampling_frequency=sampling_frequency,
            minimum_frequency=minimum_frequency,
            **params,
        )
        hp_t, hc_t, epoch = self._to_time_domain(fd)
        t0 = epoch + tc
        dt = 1.0 / sampling_frequency
        return {
            "plus": TimeSeries(hp_t, t0=t0, dt=dt),
            "cross": TimeSeries(hc_t, t0=t0, dt=dt),
        }

    def generate_fd_polarizations(
        self,
        approximant: str,
        *,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> FrequencyDomainPolarizations:
        """Generate ripple's frequency-domain plus/cross polarizations (on-device).

        This is the building block the on-device (GPU) projection path consumes: the
        polarizations stay as JAX arrays and are not conditioned to the time domain.
        ``generate_td_waveform`` calls this and then inverse-FFTs the result.

        Args:
            approximant: A supported ripple approximant name.
            sampling_frequency: Sample rate in Hz; sets the Nyquist frequency.
            minimum_frequency: Low-frequency cutoff in Hz; bins below it are zeroed.
            **params: CBC source parameters (gwmock-pop canonical names or aliases).

        Returns:
            A :class:`FrequencyDomainPolarizations` with coalescence at ``t = 0``.
        """
        resolved = self._resolve_parameters(approximant, sampling_frequency, minimum_frequency, **params)
        return self._evaluate_fd(approximant, resolved, sampling_frequency, minimum_frequency)

    def generate_fd_polarizations_batch(
        self,
        approximant: str,
        *,
        sampling_frequency: float,
        minimum_frequency: float,
        parameters: Mapping[str, object],
        waveform_arguments: Mapping[str, object] | None = None,
    ) -> FrequencyDomainPolarizations:
        """Generate ripple FD polarizations for a batch of events on one shared grid.

        Evaluates ripple under ``jax.vmap`` over the catalogue, so all events share a
        single frequency grid. Because ``vmap`` needs a fixed shape, the grid is sized
        (worst case) for the longest inspiral in the batch: the **maximum over every
        event's** 1PN duration estimate, not the smallest chirp mass, since the mass ratio
        enters the 1PN term and can reorder two events of nearly equal chirp mass. Unless a
        fixed ``segment_duration`` was set on the backend, in which case that wins. This is the on-device entry point
        for catalogue-scale (GPU) generation.

        Args:
            approximant: A supported ripple approximant name.
            sampling_frequency: Sample rate in Hz.
            minimum_frequency: Low-frequency cutoff in Hz; bins below it are zeroed.
            parameters: Mapping of **canonical** gwmock-pop parameter names (no aliases)
                to 1-D arrays of equal length ``n_events`` (e.g. ``detector_frame_mass_1``,
                ``spin_1z``, ``inclination``). Omitted optional parameters default to zero.
            waveform_arguments: Optional extra ripple constructor options applied to the
                whole batch (the waveform is built once). Same whitelist as the per-event
                path — e.g. ``{"no_taper": True}`` for the NRTidal variants. These are
                constructor-level, not per-event, so they take scalars, not arrays.

        Returns:
            A :class:`FrequencyDomainPolarizations` whose ``plus`` and ``cross`` are
            ``(n_events, n_samples // 2 + 1)`` JAX arrays (coalescence at ``t = 0``).
        """
        resolved_arguments = self._resolve_waveform_arguments(
            approximant, {} if waveform_arguments is None else dict(waveform_arguments)
        )
        ripple_params, n_samples = self._resolve_batch(approximant, sampling_frequency, minimum_frequency, parameters)
        jnp = self._jnp
        delta_f = sampling_frequency / n_samples
        freqs = jnp.arange(n_samples // 2 + 1) * delta_f
        window = _cutoff_window(freqs, minimum_frequency, self._taper_fraction, jnp)
        f_ref = self._f_ref if self._f_ref is not None else minimum_frequency

        # Fetched from a cache keyed on everything the kernel depends on, so repeated calls
        # reuse one compiled executable. Building jax.jit around a closure defined here
        # would hand XLA a new callable every call and re-pay tracing, lowering and
        # compilation each time -- about 121 s per call for IMRPhenomXPHM on an A100, which
        # made the batched path slower than the per-event LAL loop it replaces.
        kernel = _batched_polarization_kernel(approximant, f_ref, tuple(sorted(resolved_arguments.items())))
        # The same freqs object that is returned below, so the window cannot drift from it.
        plus, cross = kernel(freqs, window, ripple_params)
        return FrequencyDomainPolarizations(
            frequencies=freqs,
            plus=plus,
            cross=cross,
            sampling_frequency=sampling_frequency,
            n_samples=n_samples,
        )

    def _resolve_batch(
        self,
        approximant: str,
        sampling_frequency: float,
        minimum_frequency: float,
        parameters: Mapping[str, object],
    ) -> tuple[dict, int]:
        """Validate a batch of canonical parameters and build ripple-native arrays.

        Returns ``(ripple_params, n_samples)`` where ``ripple_params`` is a dict of
        equal-length JAX arrays ready for ``vmap`` and ``n_samples`` is the shared,
        worst-case segment length.
        """
        if approximant not in _SUPPORTED_APPROXIMANTS:
            raise ValueError(
                f"RippleBackend does not support approximant {approximant!r}. "
                f"Available: {list(_SUPPORTED_APPROXIMANTS)}."
            )
        if sampling_frequency <= 0:
            raise ValueError("sampling_frequency must be > 0")
        if minimum_frequency <= 0:
            raise ValueError("minimum_frequency must be > 0")
        if "waveform_arguments" in parameters:
            raise ValueError(
                "Pass waveform_arguments as its own keyword argument to "
                "generate_fd_polarizations_batch, not inside parameters"
            )

        jnp = self._jnp
        mass1 = self._batch_array(parameters, "detector_frame_mass_1")
        n_events = mass1.shape[0]
        mass2 = self._batch_array(parameters, "detector_frame_mass_2", n_events)
        distance = self._batch_array(parameters, "luminosity_distance", n_events)
        inclination = self._batch_array(parameters, "inclination", n_events, default=0.0)
        coa_phase = self._batch_array(parameters, "coa_phase", n_events, default=0.0)
        spins = {
            name: self._batch_array(parameters, name, n_events, default=0.0)
            for name in ("spin_1x", "spin_1y", "spin_1z", "spin_2x", "spin_2y", "spin_2z")
        }
        lambda_1 = self._batch_array(parameters, "lambda_1", n_events, default=0.0)
        lambda_2 = self._batch_array(parameters, "lambda_2", n_events, default=0.0)

        is_precessing = approximant in _PRECESSING_MODELS
        if not is_precessing:
            for name in ("spin_1x", "spin_1y", "spin_2x", "spin_2y"):
                if bool(jnp.any(spins[name] != 0.0)):
                    raise ValueError(f"{approximant} is an aligned-spin model; {name} must be zero for all events.")
        is_tidal = approximant in _TIDAL_MODELS
        if not is_tidal and (bool(jnp.any(lambda_1 != 0.0)) or bool(jnp.any(lambda_2 != 0.0))):
            raise ValueError(f"{approximant} does not support tidal parameters; use an NRTidal approximant.")
        if bool(jnp.any(lambda_1 < 0.0)) or bool(jnp.any(lambda_2 < 0.0)):
            raise ValueError("lambda_1 and lambda_2 must be >= 0")

        chirp_mass, eta = self._jax.vmap(self._conversions.ms_to_Mc_eta)(jnp.stack([mass1, mass2], axis=-1))
        # Every event's duration is considered rather than the lightest chirp mass: eta enters the
        # 1PN term, so the longest inspiral is not necessarily the lightest binary.
        n_samples = self._segment_samples(
            np.asarray(chirp_mass, dtype=float),
            minimum_frequency,
            sampling_frequency,
            eta=np.asarray(eta, dtype=float),
        )

        ripple_params = {
            "M_c": chirp_mass,
            "eta": eta,
            "s1_z": spins["spin_1z"],
            "s2_z": spins["spin_2z"],
            "d_L": distance,
            "phase_c": coa_phase,
            "iota": inclination,
        }
        if is_precessing:
            ripple_params["s1_x"] = spins["spin_1x"]
            ripple_params["s1_y"] = spins["spin_1y"]
            ripple_params["s2_x"] = spins["spin_2x"]
            ripple_params["s2_y"] = spins["spin_2y"]
        if is_tidal:
            ripple_params["lambda_1"] = lambda_1
            ripple_params["lambda_2"] = lambda_2
        return ripple_params, n_samples

    def _batch_array(
        self,
        parameters: Mapping[str, object],
        name: str,
        n_events: int | None = None,
        *,
        default: float | None = None,
    ) -> Array:
        """Return one parameter as a 1-D float64 JAX array, validating its length."""
        jnp = self._jnp
        if name not in parameters:
            if default is None:
                raise ValueError(f"Missing required batch parameter: {name!r}")
            return jnp.full(n_events, default, dtype=jnp.float64)
        values = jnp.asarray(parameters[name], dtype=jnp.float64)
        if values.ndim != 1:
            raise ValueError(f"Batch parameter {name!r} must be 1-D; got shape {values.shape}.")
        if n_events is not None and values.shape[0] != n_events:
            raise ValueError(f"Batch parameter {name!r} has length {values.shape[0]}, expected {n_events}.")
        return values

    @staticmethod
    def _resolve_waveform_arguments(approximant: str, value: object) -> dict[str, object]:
        """Validate the optional extra ripple constructor options.

        Only the keys whitelisted in :data:`_ALLOWED_WAVEFORM_ARGUMENTS` for this
        approximant are accepted; backend-owned or contract-breaking keys
        (:data:`_RESERVED_WAVEFORM_ARGUMENTS`) are rejected with a specific reason,
        and any other key fails early rather than reaching ripple as an opaque
        ``TypeError``.
        """
        if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
            raise ValueError("waveform_arguments must be a dict with string keys")
        for key in value:
            if key in _RESERVED_WAVEFORM_ARGUMENTS:
                raise ValueError(_RESERVED_WAVEFORM_ARGUMENTS[key])
        allowed = _ALLOWED_WAVEFORM_ARGUMENTS.get(approximant, frozenset())
        unknown = sorted(key for key in value if key not in allowed)
        if unknown:
            joined = ", ".join(unknown)
            allowed_str = ", ".join(sorted(allowed)) if allowed else "(none)"
            raise ValueError(
                f"{approximant} does not accept waveform_arguments: {joined}. "
                f"Allowed for this approximant: {allowed_str}."
            )
        return dict(value)

    def _resolve_parameters(
        self,
        approximant: str,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> _ResolvedParameters:
        """Validate inputs and translate canonical parameters to backend-native ones."""
        if approximant not in _SUPPORTED_APPROXIMANTS:
            raise ValueError(
                f"RippleBackend does not support approximant {approximant!r}. "
                f"Available: {list(_SUPPORTED_APPROXIMANTS)}."
            )
        if sampling_frequency <= 0:
            raise ValueError("sampling_frequency must be > 0")
        if minimum_frequency <= 0:
            raise ValueError("minimum_frequency must be > 0")

        remaining = dict(params)
        waveform_arguments = self._resolve_waveform_arguments(
            approximant, _pop_alias(remaining, "waveform_arguments", default={})
        )
        mass1 = float(_pop_alias(remaining, "detector_frame_mass_1", "mass1"))
        mass2 = float(_pop_alias(remaining, "detector_frame_mass_2", "mass2"))
        distance = float(_pop_alias(remaining, "luminosity_distance", "distance"))
        spins = {
            "spin_1x": float(_pop_alias(remaining, "spin_1x", "spin1x", default=0.0)),
            "spin_1y": float(_pop_alias(remaining, "spin_1y", "spin1y", default=0.0)),
            "spin_1z": float(_pop_alias(remaining, "spin_1z", "spin1z", default=0.0)),
            "spin_2x": float(_pop_alias(remaining, "spin_2x", "spin2x", default=0.0)),
            "spin_2y": float(_pop_alias(remaining, "spin_2y", "spin2y", default=0.0)),
            "spin_2z": float(_pop_alias(remaining, "spin_2z", "spin2z", default=0.0)),
        }
        inclination = float(_pop_alias(remaining, "inclination", default=0.0))
        coa_phase = float(_pop_alias(remaining, "coa_phase", default=0.0))

        is_precessing = approximant in _PRECESSING_MODELS
        if not is_precessing:
            in_plane = ("spin_1x", "spin_1y", "spin_2x", "spin_2y")
            nonzero_in_plane = sorted(name for name in in_plane if spins[name] != 0.0)
            if nonzero_in_plane:
                raise ValueError(
                    f"{approximant} is an aligned-spin model; "
                    f"in-plane spins must be zero: {', '.join(nonzero_in_plane)}"
                )
        lambda_1 = float(_pop_alias(remaining, "lambda_1", "tidal_1", default=0.0))
        lambda_2 = float(_pop_alias(remaining, "lambda_2", "tidal_2", default=0.0))
        is_tidal = approximant in _TIDAL_MODELS
        if not is_tidal and (lambda_1 or lambda_2):
            raise ValueError(f"{approximant} does not support tidal parameters; use an NRTidal approximant.")
        if lambda_1 < 0:
            raise ValueError("lambda_1 must be >= 0")
        if lambda_2 < 0:
            raise ValueError("lambda_2 must be >= 0")
        if remaining:
            extras = ", ".join(sorted(remaining))
            raise ValueError(f"Unsupported ripple waveform parameters: {extras}")

        return _ResolvedParameters(
            mass1=mass1,
            mass2=mass2,
            spins=spins,
            distance=distance,
            inclination=inclination,
            coa_phase=coa_phase,
            lambda_1=lambda_1,
            lambda_2=lambda_2,
            is_tidal=is_tidal,
            is_precessing=is_precessing,
            f_ref=self._f_ref if self._f_ref is not None else minimum_frequency,
            waveform_arguments=waveform_arguments,
        )

    def _segment_samples(
        self,
        chirp_mass_solar: float | np.ndarray,
        minimum_frequency: float,
        sampling_frequency: float,
        eta: float | np.ndarray,
    ) -> int:
        """Return an even sample count whose duration contains the longest inspiral given.

        Sized from the 1PN chirp time (:func:`_inspiral_seconds`) plus a proportional margin, then
        rounded up to a power of two seconds.

        Previously the estimate was the *0PN* chirp time with a flat 2 s pad, which left the real
        safety margin to be whatever the power-of-two rounding happened to supply -- between 2.8%
        and 256% across ordinary parameters. Where that fell below the 1PN correction the inspiral
        wrapped around the buffer: a 10+1.4 system at 10 Hz had 2.8% of room against a 4.9%
        correction, and 1.8% of peak amplitude appeared in its post-ringdown region. Including the
        1PN term and requiring a proportional margin makes the headroom a property of the estimate
        rather than of where the rounding lands.

        Arrays are accepted and the **longest** duration wins. A caller cannot identify the
        worst-case event from chirp mass alone: at fixed chirp mass a more asymmetric binary is
        heavier and lasts longer, so the lightest event is not necessarily the longest.

        Args:
            chirp_mass_solar: Detector-frame chirp mass(es) in solar masses.
            minimum_frequency: Low-frequency cutoff in Hz.
            sampling_frequency: Sample rate in Hz.
            eta: Symmetric mass ratio(es), aligned with ``chirp_mass_solar``. Required rather than
                defaulted: an equal-mass default silently *underestimates* the duration for an
                asymmetric binary, and a buffer too short by a few percent is exactly the failure
                this sizing exists to prevent.

        Returns:
            An even sample count, a power of two in duration.
        """
        if self._segment_duration is not None:
            seconds = self._segment_duration
        else:
            # From where the signal actually starts, not from the requested cutoff: the taper puts
            # real content below it, which lengthens the inspiral.
            inspiral, relative_correction = _inspiral_seconds(
                chirp_mass_solar,
                eta,
                self.signal_start_frequency(minimum_frequency),
                float(self._constants.MTSUN),
            )
            # Each event gets *its own* margin, and the maximum is taken over the resulting
            # requirements. Taking max(duration) and max(margin) separately would apply one event's
            # 1PN correction to another event's duration -- and since the correction grows with total
            # mass, a heavy short event would inflate the grid chosen for a light long one. That is
            # conservative rather than unsafe, but it makes the batch grid depend on events that do
            # not set it, and it broke the invariant that a batch sizes to the same grid as the
            # single-event call for whichever event dominates.
            required = (
                np.asarray(inspiral, dtype=float) * (1.0 + _inspiral_margin(relative_correction))
                + _SEGMENT_BUFFER_SECONDS
            )
            inspiral_room = 1.0 - self._ringdown_fraction
            seconds = max(float(np.max(required)) / inspiral_room, _MIN_SEGMENT_SECONDS)
        # Rounded up to the next 5-smooth length, not to a power of two.
        #
        # The margin still governs accuracy as well as safety -- ringing at the inspiral onset bleeds
        # circularly into the tail, and how far the onset sits from the buffer edge sets how much, so
        # a longer buffer is a cleaner one. That is why this was a power of two: it bought margin for
        # free. What changed is the absolute scale. With the cutoff tapered, a tight 21% margin leaves
        # 5.7e-6 of peak after the ringdown, roughly 40x cleaner than the 2.3e-4 a hard cutoff left at
        # a comfortable 74.5%. There is headroom to spend, and power-of-two rounding spends far too
        # much of it: the taper lengthens the inspiral by ~14%, which a power of two turns into a
        # *doubling* for every case at f_min = 5 Hz -- the regime this backend exists to serve.
        #
        # 5-smooth is what transform libraries are efficient for; a power of two is one needlessly
        # strict special case of it.
        return _next_smooth_even(int(np.ceil(seconds * sampling_frequency)))

    def _evaluate_fd(
        self,
        approximant: str,
        resolved: _ResolvedParameters,
        sampling_frequency: float,
        minimum_frequency: float,
    ) -> FrequencyDomainPolarizations:
        """Evaluate ripple on the analysis frequency grid (coalescence at t=0)."""
        jnp = self._jnp
        spins = resolved.spins
        chirp_mass, eta = self._conversions.ms_to_Mc_eta(jnp.array([resolved.mass1, resolved.mass2]))

        n_samples = self._segment_samples(float(chirp_mass), minimum_frequency, sampling_frequency, eta=float(eta))
        delta_f = sampling_frequency / n_samples
        freqs = jnp.arange(n_samples // 2 + 1) * delta_f

        # ripple's class interface fixes its internal tc=0; coalescence is placed
        # in the time grid by _to_time_domain.
        ripple_params = {
            "M_c": chirp_mass,
            "eta": eta,
            "s1_z": spins["spin_1z"],
            "s2_z": spins["spin_2z"],
            "d_L": resolved.distance,
            "phase_c": resolved.coa_phase,
            "iota": resolved.inclination,
        }
        if resolved.is_precessing:
            ripple_params["s1_x"] = spins["spin_1x"]
            ripple_params["s1_y"] = spins["spin_1y"]
            ripple_params["s2_x"] = spins["spin_2x"]
            ripple_params["s2_y"] = spins["spin_2y"]
        if resolved.is_tidal:
            ripple_params["lambda_1"] = resolved.lambda_1
            ripple_params["lambda_2"] = resolved.lambda_2
        waveform = _build_ripple_waveform(
            self._ripplegw.waveform,
            approximant,
            f_ref=resolved.f_ref,
            options=resolved.waveform_arguments,
            version=getattr(self._ripplegw, "__version__", "unknown"),
        )
        polarizations = waveform(freqs, ripple_params)

        # Attenuate below the cutoff (including DC, where the amplitude diverges) and guard against
        # any non-finite values, keeping everything on device. A *window* rather than a mask: see
        # _DEFAULT_TAPER_FRACTION for why the hard mask rang across the whole buffer.
        window = _cutoff_window(freqs, minimum_frequency, self._taper_fraction, jnp)
        hp_f = jnp.nan_to_num(polarizations["p"] * window)
        hc_f = jnp.nan_to_num(polarizations["c"] * window)
        return FrequencyDomainPolarizations(
            frequencies=freqs,
            plus=hp_f,
            cross=hc_f,
            sampling_frequency=sampling_frequency,
            n_samples=n_samples,
        )

    def coalescence_placement(self, n_samples: int, sampling_frequency: float) -> tuple[int, float]:
        """Return ``(merger_index, epoch)`` for placing coalescence in a segment.

        ``merger_index`` is the sample at which coalescence sits after the
        time-domain roll (near the segment end, leaving a small ringdown pad), and
        ``epoch`` is the time of the first sample relative to coalescence (negative),
        so a caller places coalescence at ``epoch + tc``. Shared by the time-domain
        backend and the batched device path so both use the same convention.
        """
        merger_index = round((1.0 - self._ringdown_fraction) * n_samples)
        return merger_index, -merger_index / sampling_frequency

    def _to_time_domain(self, fd: FrequencyDomainPolarizations) -> tuple[np.ndarray, np.ndarray, float]:
        """Inverse-FFT frequency-domain polarizations and place coalescence in the segment.

        Returns ``(hp, hc, epoch)`` where ``epoch`` is the time of the first sample
        relative to coalescence (negative), so the caller places coalescence at
        ``epoch + tc``.
        """
        dt = 1.0 / fd.sampling_frequency
        # Inverse real FFT: h(t) = irfft(h(f)) / dt (continuous-transform normalization).
        hp_t = np.fft.irfft(np.asarray(fd.plus), n=fd.n_samples) / dt
        hc_t = np.fft.irfft(np.asarray(fd.cross), n=fd.n_samples) / dt

        # With tc=0 coalescence lands at sample 0 and the inspiral wraps to the tail.
        # Roll it forward so coalescence sits near the segment end, leaving the
        # inspiral contiguous before it and a small ringdown pad after.
        merger_index, epoch = self.coalescence_placement(fd.n_samples, fd.sampling_frequency)
        hp_t = np.roll(hp_t, merger_index)
        hc_t = np.roll(hc_t, merger_index)
        return hp_t, hc_t, epoch

segment_duration property

The fixed analysis-segment length in seconds, or None if auto-sized.

taper_fraction property

Width of the amplitude taper below minimum_frequency, as a fraction of it.

__init__(*, f_ref=None, ringdown_fraction=_DEFAULT_RINGDOWN_FRACTION, segment_duration=None, taper_fraction=_DEFAULT_TAPER_FRACTION)

Require ripple/JAX only when this backend is instantiated.

Source code in src/gwmock_signal/waveform/backends/ripple.py
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
def __init__(
    self,
    *,
    f_ref: float | None = None,
    ringdown_fraction: float = _DEFAULT_RINGDOWN_FRACTION,
    segment_duration: float | None = None,
    taper_fraction: float = _DEFAULT_TAPER_FRACTION,
) -> None:
    """Require ripple/JAX only when this backend is instantiated."""
    try:
        self._jax = importlib.import_module("jax")
        self._jnp = importlib.import_module("jax.numpy")
        self._ripplegw = importlib.import_module("ripplegw")
    except ImportError as exc:
        raise ImportError(_RIPPLE_IMPORT_ERROR) from exc
    # Not in the try above: a missing submodule means ripple is installed but *different*,
    # which is the guard's message to give, not "ripple is not installed".
    self._conversions = _optional_module("ripplegw.conversions")
    self._constants = _optional_module("ripplegw.constants")
    _require_ripple_interface(
        {
            "ripplegw": self._ripplegw,
            "ripplegw.conversions": self._conversions,
            "ripplegw.constants": self._constants,
        }
    )
    # ripple needs double precision for waveform phase accuracy over long
    # inspirals. Importing ripplegw already enables this globally; set it
    # explicitly so correctness does not depend on import order.
    self._jax.config.update("jax_enable_x64", True)
    if not 0.0 < ringdown_fraction < 1.0:
        raise ValueError("ringdown_fraction must be in (0, 1)")
    if segment_duration is not None and segment_duration <= 0:
        raise ValueError("segment_duration must be > 0")
    # A fraction of 1 would put the ramp's lower edge at zero frequency, and beyond that it is
    # negative; either way the window is meaningless rather than merely aggressive.
    if not 0.0 <= taper_fraction < 1.0:
        raise ValueError(f"taper_fraction must be in [0, 1); got {taper_fraction}")
    self._f_ref = f_ref
    self._ringdown_fraction = ringdown_fraction
    self._segment_duration = segment_duration
    self._taper_fraction = float(taper_fraction)

available_approximants()

Return the ripple approximants supported by this backend.

Source code in src/gwmock_signal/waveform/backends/ripple.py
572
573
574
def available_approximants(self) -> list[str]:
    """Return the ripple approximants supported by this backend."""
    return list(_SUPPORTED_APPROXIMANTS)

coalescence_placement(n_samples, sampling_frequency)

Return (merger_index, epoch) for placing coalescence in a segment.

merger_index is the sample at which coalescence sits after the time-domain roll (near the segment end, leaving a small ringdown pad), and epoch is the time of the first sample relative to coalescence (negative), so a caller places coalescence at epoch + tc. Shared by the time-domain backend and the batched device path so both use the same convention.

Source code in src/gwmock_signal/waveform/backends/ripple.py
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
def coalescence_placement(self, n_samples: int, sampling_frequency: float) -> tuple[int, float]:
    """Return ``(merger_index, epoch)`` for placing coalescence in a segment.

    ``merger_index`` is the sample at which coalescence sits after the
    time-domain roll (near the segment end, leaving a small ringdown pad), and
    ``epoch`` is the time of the first sample relative to coalescence (negative),
    so a caller places coalescence at ``epoch + tc``. Shared by the time-domain
    backend and the batched device path so both use the same convention.
    """
    merger_index = round((1.0 - self._ringdown_fraction) * n_samples)
    return merger_index, -merger_index / sampling_frequency

generate_fd_polarizations(approximant, *, sampling_frequency, minimum_frequency, **params)

Generate ripple's frequency-domain plus/cross polarizations (on-device).

This is the building block the on-device (GPU) projection path consumes: the polarizations stay as JAX arrays and are not conditioned to the time domain. generate_td_waveform calls this and then inverse-FFTs the result.

Parameters:

Name Type Description Default
approximant str

A supported ripple approximant name.

required
sampling_frequency float

Sample rate in Hz; sets the Nyquist frequency.

required
minimum_frequency float

Low-frequency cutoff in Hz; bins below it are zeroed.

required
**params object

CBC source parameters (gwmock-pop canonical names or aliases).

{}

Returns:

Name Type Description
A FrequencyDomainPolarizations

class:FrequencyDomainPolarizations with coalescence at t = 0.

Source code in src/gwmock_signal/waveform/backends/ripple.py
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
def generate_fd_polarizations(
    self,
    approximant: str,
    *,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> FrequencyDomainPolarizations:
    """Generate ripple's frequency-domain plus/cross polarizations (on-device).

    This is the building block the on-device (GPU) projection path consumes: the
    polarizations stay as JAX arrays and are not conditioned to the time domain.
    ``generate_td_waveform`` calls this and then inverse-FFTs the result.

    Args:
        approximant: A supported ripple approximant name.
        sampling_frequency: Sample rate in Hz; sets the Nyquist frequency.
        minimum_frequency: Low-frequency cutoff in Hz; bins below it are zeroed.
        **params: CBC source parameters (gwmock-pop canonical names or aliases).

    Returns:
        A :class:`FrequencyDomainPolarizations` with coalescence at ``t = 0``.
    """
    resolved = self._resolve_parameters(approximant, sampling_frequency, minimum_frequency, **params)
    return self._evaluate_fd(approximant, resolved, sampling_frequency, minimum_frequency)

generate_fd_polarizations_batch(approximant, *, sampling_frequency, minimum_frequency, parameters, waveform_arguments=None)

Generate ripple FD polarizations for a batch of events on one shared grid.

Evaluates ripple under jax.vmap over the catalogue, so all events share a single frequency grid. Because vmap needs a fixed shape, the grid is sized (worst case) for the longest inspiral in the batch: the maximum over every event's 1PN duration estimate, not the smallest chirp mass, since the mass ratio enters the 1PN term and can reorder two events of nearly equal chirp mass. Unless a fixed segment_duration was set on the backend, in which case that wins. This is the on-device entry point for catalogue-scale (GPU) generation.

Parameters:

Name Type Description Default
approximant str

A supported ripple approximant name.

required
sampling_frequency float

Sample rate in Hz.

required
minimum_frequency float

Low-frequency cutoff in Hz; bins below it are zeroed.

required
parameters Mapping[str, object]

Mapping of canonical gwmock-pop parameter names (no aliases) to 1-D arrays of equal length n_events (e.g. detector_frame_mass_1, spin_1z, inclination). Omitted optional parameters default to zero.

required
waveform_arguments Mapping[str, object] | None

Optional extra ripple constructor options applied to the whole batch (the waveform is built once). Same whitelist as the per-event path — e.g. {"no_taper": True} for the NRTidal variants. These are constructor-level, not per-event, so they take scalars, not arrays.

None

Returns:

Name Type Description
A FrequencyDomainPolarizations

class:FrequencyDomainPolarizations whose plus and cross are

FrequencyDomainPolarizations

(n_events, n_samples // 2 + 1) JAX arrays (coalescence at t = 0).

Source code in src/gwmock_signal/waveform/backends/ripple.py
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
def generate_fd_polarizations_batch(
    self,
    approximant: str,
    *,
    sampling_frequency: float,
    minimum_frequency: float,
    parameters: Mapping[str, object],
    waveform_arguments: Mapping[str, object] | None = None,
) -> FrequencyDomainPolarizations:
    """Generate ripple FD polarizations for a batch of events on one shared grid.

    Evaluates ripple under ``jax.vmap`` over the catalogue, so all events share a
    single frequency grid. Because ``vmap`` needs a fixed shape, the grid is sized
    (worst case) for the longest inspiral in the batch: the **maximum over every
    event's** 1PN duration estimate, not the smallest chirp mass, since the mass ratio
    enters the 1PN term and can reorder two events of nearly equal chirp mass. Unless a
    fixed ``segment_duration`` was set on the backend, in which case that wins. This is the on-device entry point
    for catalogue-scale (GPU) generation.

    Args:
        approximant: A supported ripple approximant name.
        sampling_frequency: Sample rate in Hz.
        minimum_frequency: Low-frequency cutoff in Hz; bins below it are zeroed.
        parameters: Mapping of **canonical** gwmock-pop parameter names (no aliases)
            to 1-D arrays of equal length ``n_events`` (e.g. ``detector_frame_mass_1``,
            ``spin_1z``, ``inclination``). Omitted optional parameters default to zero.
        waveform_arguments: Optional extra ripple constructor options applied to the
            whole batch (the waveform is built once). Same whitelist as the per-event
            path — e.g. ``{"no_taper": True}`` for the NRTidal variants. These are
            constructor-level, not per-event, so they take scalars, not arrays.

    Returns:
        A :class:`FrequencyDomainPolarizations` whose ``plus`` and ``cross`` are
        ``(n_events, n_samples // 2 + 1)`` JAX arrays (coalescence at ``t = 0``).
    """
    resolved_arguments = self._resolve_waveform_arguments(
        approximant, {} if waveform_arguments is None else dict(waveform_arguments)
    )
    ripple_params, n_samples = self._resolve_batch(approximant, sampling_frequency, minimum_frequency, parameters)
    jnp = self._jnp
    delta_f = sampling_frequency / n_samples
    freqs = jnp.arange(n_samples // 2 + 1) * delta_f
    window = _cutoff_window(freqs, minimum_frequency, self._taper_fraction, jnp)
    f_ref = self._f_ref if self._f_ref is not None else minimum_frequency

    # Fetched from a cache keyed on everything the kernel depends on, so repeated calls
    # reuse one compiled executable. Building jax.jit around a closure defined here
    # would hand XLA a new callable every call and re-pay tracing, lowering and
    # compilation each time -- about 121 s per call for IMRPhenomXPHM on an A100, which
    # made the batched path slower than the per-event LAL loop it replaces.
    kernel = _batched_polarization_kernel(approximant, f_ref, tuple(sorted(resolved_arguments.items())))
    # The same freqs object that is returned below, so the window cannot drift from it.
    plus, cross = kernel(freqs, window, ripple_params)
    return FrequencyDomainPolarizations(
        frequencies=freqs,
        plus=plus,
        cross=cross,
        sampling_frequency=sampling_frequency,
        n_samples=n_samples,
    )

generate_td_waveform(approximant, tc, sampling_frequency, minimum_frequency, **params)

Generate plus/cross polarizations from ripple, conditioned to time domain.

Source code in src/gwmock_signal/waveform/backends/ripple.py
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
def generate_td_waveform(
    self,
    approximant: str,
    tc: float,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> dict[str, TimeSeries]:
    """Generate plus/cross polarizations from ripple, conditioned to time domain."""
    fd = self.generate_fd_polarizations(
        approximant,
        sampling_frequency=sampling_frequency,
        minimum_frequency=minimum_frequency,
        **params,
    )
    hp_t, hc_t, epoch = self._to_time_domain(fd)
    t0 = epoch + tc
    dt = 1.0 / sampling_frequency
    return {
        "plus": TimeSeries(hp_t, t0=t0, dt=dt),
        "cross": TimeSeries(hc_t, t0=t0, dt=dt),
    }

post_coalescence_duration(approximant, sampling_frequency, minimum_frequency, **params)

Return the seconds after tc this backend's buffer runs.

The complement of :meth:pre_coalescence_duration, from the same sizing call, so the two cannot drift into describing different buffers. Ripple's 5-smooth sizing and its 1PN eta term make this a different number from the LAL backend's for the same source, which is why both are asked of the backend rather than computed once by the caller.

Source code in src/gwmock_signal/waveform/backends/ripple.py
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
def post_coalescence_duration(
    self,
    approximant: str,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> float | None:
    """Return the seconds after ``tc`` this backend's buffer runs.

    The complement of :meth:`pre_coalescence_duration`, from the same sizing call, so the two
    cannot drift into describing different buffers. Ripple's 5-smooth sizing and its 1PN eta
    term make this a different number from the LAL backend's for the same source, which is
    why both are asked of the backend rather than computed once by the caller.
    """
    n_samples, merger_index = self._buffer_shape(approximant, sampling_frequency, minimum_frequency, **params)
    return (n_samples - merger_index) / sampling_frequency

pre_coalescence_duration(approximant, sampling_frequency, minimum_frequency, **params)

Return the seconds before tc this backend's buffer starts.

Built from the same two steps generation uses -- _segment_samples for the length and coalescence_placement for where coalescence sits in it -- so the answer cannot drift from what generate_td_waveform actually produces. Ripple sizes differently from the frequency-domain conditioning shared with the LAL backend (5-smooth lengths rather than powers of two, and eta enters its 1PN term), which is exactly why this is asked of the backend rather than computed once by the caller.

Source code in src/gwmock_signal/waveform/backends/ripple.py
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
def pre_coalescence_duration(
    self,
    approximant: str,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> float | None:
    """Return the seconds before ``tc`` this backend's buffer starts.

    Built from the same two steps generation uses -- ``_segment_samples`` for the length and
    ``coalescence_placement`` for where coalescence sits in it -- so the answer cannot drift
    from what ``generate_td_waveform`` actually produces. Ripple sizes differently from the
    frequency-domain conditioning shared with the LAL backend (5-smooth lengths rather than
    powers of two, and eta enters its 1PN term), which is exactly why this is asked of the
    backend rather than computed once by the caller.
    """
    _, merger_index = self._buffer_shape(approximant, sampling_frequency, minimum_frequency, **params)
    return merger_index / sampling_frequency

segment_duration_for(chirp_mass_solar, minimum_frequency, sampling_frequency, eta)

Worst-case segment duration (seconds) the batch path uses for these masses.

Parameters:

Name Type Description Default
chirp_mass_solar float | ndarray

Detector-frame chirp mass(es) in solar masses.

required
minimum_frequency float

Low-frequency cutoff in Hz.

required
sampling_frequency float

Sample rate in Hz.

required
eta float | ndarray

Symmetric mass ratio(es), aligned with chirp_mass_solar. Required, because an equal-mass default would silently underestimate an asymmetric binary's duration.

required

Returns:

Type Description
float

Duration in seconds.

Source code in src/gwmock_signal/waveform/backends/ripple.py
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
def segment_duration_for(
    self,
    chirp_mass_solar: float | np.ndarray,
    minimum_frequency: float,
    sampling_frequency: float,
    eta: float | np.ndarray,
) -> float:
    """Worst-case segment duration (seconds) the batch path uses for these masses.

    Args:
        chirp_mass_solar: Detector-frame chirp mass(es) in solar masses.
        minimum_frequency: Low-frequency cutoff in Hz.
        sampling_frequency: Sample rate in Hz.
        eta: Symmetric mass ratio(es), aligned with ``chirp_mass_solar``. Required, because an
            equal-mass default would silently underestimate an asymmetric binary's duration.

    Returns:
        Duration in seconds.
    """
    return (
        self._segment_samples(chirp_mass_solar, minimum_frequency, sampling_frequency, eta=eta) / sampling_frequency
    )

signal_start_frequency(minimum_frequency)

Return the lowest frequency the generated strain actually contains.

minimum_frequency is where the waveform reaches full amplitude. With a taper the strain also holds attenuated content below it, down to this frequency, and the analysis buffer must be sized from here rather than from the cutoff -- an inspiral that starts lower lasts longer. Verified: sizing from minimum_frequency instead leaves a 1.4+1.35 system at 10 Hz with a negative margin and a post-ringdown level of 2.9e-3, worse than the hard cutoff it was meant to improve on.

Parameters:

Name Type Description Default
minimum_frequency float

The requested cutoff in Hz.

required

Returns:

Type Description
float

minimum_frequency / (1 + taper_fraction).

Raises:

Type Description
ValueError

If minimum_frequency is not positive and finite. Validated here as well as on the generation paths, because this is public and can be called on its own -- and without the check a non-positive cutoff returns a plausible-looking number that would go on to size a buffer.

Source code in src/gwmock_signal/waveform/backends/ripple.py
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
def signal_start_frequency(self, minimum_frequency: float) -> float:
    """Return the lowest frequency the generated strain actually contains.

    ``minimum_frequency`` is where the waveform reaches *full* amplitude. With a taper the
    strain also holds attenuated content below it, down to this frequency, and the analysis
    buffer must be sized from here rather than from the cutoff -- an inspiral that starts lower
    lasts longer. Verified: sizing from ``minimum_frequency`` instead leaves a 1.4+1.35 system
    at 10 Hz with a *negative* margin and a post-ringdown level of 2.9e-3, worse than the hard
    cutoff it was meant to improve on.

    Args:
        minimum_frequency: The requested cutoff in Hz.

    Returns:
        ``minimum_frequency / (1 + taper_fraction)``.

    Raises:
        ValueError: If ``minimum_frequency`` is not positive and finite. Validated here as well
            as on the generation paths, because this is public and can be called on its own --
            and without the check a non-positive cutoff returns a plausible-looking number that
            would go on to size a buffer.
    """
    if not np.isfinite(minimum_frequency) or minimum_frequency <= 0.0:
        raise ValueError(f"minimum_frequency must be positive and finite; got {minimum_frequency}.")
    return minimum_frequency / (1.0 + self._taper_fraction)

with_segment_duration(segment_duration)

Return a copy of this backend pinned to a fixed segment_duration.

Same f_ref, ringdown_fraction and taper_fraction; useful for forcing one shared grid across several batched calls (e.g. count-chunked catalogue generation). The taper has to travel with the copy: a pinned backend that quietly reverted to a hard cutoff would change the conditioning of the very chunks this exists to keep identical.

Source code in src/gwmock_signal/waveform/backends/ripple.py
612
613
614
615
616
617
618
619
620
621
622
623
624
625
def with_segment_duration(self, segment_duration: float) -> RippleBackend:
    """Return a copy of this backend pinned to a fixed ``segment_duration``.

    Same ``f_ref``, ``ringdown_fraction`` and ``taper_fraction``; useful for forcing one shared
    grid across several batched calls (e.g. count-chunked catalogue generation). The taper has
    to travel with the copy: a pinned backend that quietly reverted to a hard cutoff would
    change the conditioning of the very chunks this exists to keep identical.
    """
    return RippleBackend(
        f_ref=self._f_ref,
        ringdown_fraction=self._ringdown_fraction,
        segment_duration=segment_duration,
        taper_fraction=self._taper_fraction,
    )

WaveformBackend

Bases: ABC

Abstract interface for time-domain waveform generators.

Source code in src/gwmock_signal/waveform/backends/base.py
 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
class WaveformBackend(ABC):
    """Abstract interface for time-domain waveform generators."""

    @abstractmethod
    def available_approximants(self) -> list[str]:
        """Return supported time-domain approximant names."""

    @abstractmethod
    def generate_td_waveform(
        self,
        approximant: str,
        tc: float,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> dict[str, TimeSeries]:
        """Generate ``plus`` and ``cross`` GWpy time series."""

    def pre_coalescence_duration(
        self,
        approximant: str,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> float | None:
        """Return how long before ``tc`` the generated waveform starts, in seconds.

        A caller placing a signal in segmented data needs this *before* generating: a compact
        binary's inspiral precedes its coalescence, so a buffer whose ``tc`` sits just past a
        segment boundary begins in an earlier segment. Deciding which segment claims an event
        without knowing this length means cropping the start away.

        **How much that costs is not a single number.** It moves by orders of magnitude with the
        low-frequency cutoff, with how far past ``tc`` lands beyond the boundary, and with the
        backend -- from under 1% to over 99% of a binary's unweighted strain-squared energy, for the
        same source. A percentage quoted without all three is not interpretable, so none is quoted
        here. The measured tables live in the user guide, under *How much signal a segment boundary
        costs*, together with what they are a proxy for and what they are not anchored against.

        Asked of the backend rather than computed by the caller on purpose. The length is a
        property of how each library conditions its output: the LAL backend sizes with
        :func:`~gwmock_signal.waveform.backends.conditioning.segment_sample_count`, the gwsignal
        backend inherits that because it overrides only the frequency-domain evaluation, ripple
        applies its own 5-smooth sizing, and PyCBC delegates to its library. A caller reproducing
        any of that would be a second implementation of a quantity that already exists, wrong
        differently per backend -- and wrong in the direction that silently truncates.

        **This is where the buffer starts, not where audible signal begins.** The buffer carries
        headroom beyond the estimated chirp time and is rounded up, so the first samples are
        near-silent: a 30+25 solar-mass binary reports 3.6 s while carrying roughly 1.1 s of
        inspiral. That is the safe direction for choosing a segment -- placing from this value
        never crops real signal -- but it is not a statement about signal duration.

        Returns:
            Seconds between the first sample and coalescence, always positive. ``None`` when this
            backend cannot say, which callers must treat as "unknown" rather than "zero": the
            default is deliberately unhelpful because a wrong number is worse than none. A caller
            that gets ``None`` should keep whatever conservative behaviour it had.

            Test for ``None`` explicitly. ``if duration:`` is a trap -- it is also false for
            ``0.0``, and treating an unknown length as zero places every event in the segment
            holding its coalescence, which is the behaviour this method exists to avoid.

            Of the backends here, only PyCBC returns ``None``.

        Args:
            approximant: The approximant that will be generated. Accepted because a backend may
                condition differently per family, even though the current ones do not.
            sampling_frequency: Sample rate in Hz, which sets the sample count.
            minimum_frequency: Low-frequency cutoff in Hz; the dominant term in the chirp time.
            **params: The source parameters that will be generated, in the same form
                ``generate_td_waveform`` takes.
        """
        del approximant, sampling_frequency, minimum_frequency, params
        return None

    def post_coalescence_duration(
        self,
        approximant: str,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> float | None:
        """Return how long after ``tc`` the generated waveform runs, in seconds.

        The other half of :meth:`pre_coalescence_duration`, and needed for the decision that one
        cannot make: knowing where a buffer *starts* tells a caller that an event begins before a
        segment, never that it has finished before one. A caller placing signals from a
        population that begins earlier than its run has no way, from the start alone, to tell an
        event whose content is entirely in the past from one whose tail lands in the segment it is
        about to write -- so it must generate both.

        **The tail is a fraction of the buffer, not a fixed ringdown.** It therefore scales with
        everything the buffer scales with: a stellar-mass binary at 20 Hz carries a fraction of a
        second, a binary neutron star at the same cutoff carries tens of seconds, and a lower
        cutoff lengthens both. A caller substituting a constant -- "ringdown is milliseconds" --
        is not approximating this quantity, it is discarding it.

        Asked of the backend for the same reason as the pre side: the length is a property of how
        each library conditions its output, and a caller reproducing that arithmetic would be a
        second implementation of it, wrong differently per backend.

        Returns:
            Seconds between coalescence and one sample past the buffer's end, always positive.
            ``None`` when this backend cannot say, which callers must treat as "unknown" rather
            than "zero".

            Test for ``None`` explicitly. Reading it as ``0.0`` asserts that an event's content
            stops at its coalescence, which would discard every event whose ``tc`` precedes a
            segment -- including the ones whose tail lands inside it, which is worse than the
            behaviour this method exists to enable.

        Args:
            approximant: The approximant that will be generated.
            sampling_frequency: Sample rate in Hz, which sets the sample count.
            minimum_frequency: Low-frequency cutoff in Hz; the dominant term in the buffer length.
            **params: The source parameters that will be generated, in the same form
                ``generate_td_waveform`` takes.
        """
        del approximant, sampling_frequency, minimum_frequency, params
        return None

available_approximants() abstractmethod

Return supported time-domain approximant names.

Source code in src/gwmock_signal/waveform/backends/base.py
47
48
49
@abstractmethod
def available_approximants(self) -> list[str]:
    """Return supported time-domain approximant names."""

generate_td_waveform(approximant, tc, sampling_frequency, minimum_frequency, **params) abstractmethod

Generate plus and cross GWpy time series.

Source code in src/gwmock_signal/waveform/backends/base.py
51
52
53
54
55
56
57
58
59
60
@abstractmethod
def generate_td_waveform(
    self,
    approximant: str,
    tc: float,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> dict[str, TimeSeries]:
    """Generate ``plus`` and ``cross`` GWpy time series."""

post_coalescence_duration(approximant, sampling_frequency, minimum_frequency, **params)

Return how long after tc the generated waveform runs, in seconds.

The other half of :meth:pre_coalescence_duration, and needed for the decision that one cannot make: knowing where a buffer starts tells a caller that an event begins before a segment, never that it has finished before one. A caller placing signals from a population that begins earlier than its run has no way, from the start alone, to tell an event whose content is entirely in the past from one whose tail lands in the segment it is about to write -- so it must generate both.

The tail is a fraction of the buffer, not a fixed ringdown. It therefore scales with everything the buffer scales with: a stellar-mass binary at 20 Hz carries a fraction of a second, a binary neutron star at the same cutoff carries tens of seconds, and a lower cutoff lengthens both. A caller substituting a constant -- "ringdown is milliseconds" -- is not approximating this quantity, it is discarding it.

Asked of the backend for the same reason as the pre side: the length is a property of how each library conditions its output, and a caller reproducing that arithmetic would be a second implementation of it, wrong differently per backend.

Returns:

Type Description
float | None

Seconds between coalescence and one sample past the buffer's end, always positive.

float | None

None when this backend cannot say, which callers must treat as "unknown" rather

float | None

than "zero".

float | None

Test for None explicitly. Reading it as 0.0 asserts that an event's content

float | None

stops at its coalescence, which would discard every event whose tc precedes a

float | None

segment -- including the ones whose tail lands inside it, which is worse than the

float | None

behaviour this method exists to enable.

Parameters:

Name Type Description Default
approximant str

The approximant that will be generated.

required
sampling_frequency float

Sample rate in Hz, which sets the sample count.

required
minimum_frequency float

Low-frequency cutoff in Hz; the dominant term in the buffer length.

required
**params object

The source parameters that will be generated, in the same form generate_td_waveform takes.

{}
Source code in src/gwmock_signal/waveform/backends/base.py
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
def post_coalescence_duration(
    self,
    approximant: str,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> float | None:
    """Return how long after ``tc`` the generated waveform runs, in seconds.

    The other half of :meth:`pre_coalescence_duration`, and needed for the decision that one
    cannot make: knowing where a buffer *starts* tells a caller that an event begins before a
    segment, never that it has finished before one. A caller placing signals from a
    population that begins earlier than its run has no way, from the start alone, to tell an
    event whose content is entirely in the past from one whose tail lands in the segment it is
    about to write -- so it must generate both.

    **The tail is a fraction of the buffer, not a fixed ringdown.** It therefore scales with
    everything the buffer scales with: a stellar-mass binary at 20 Hz carries a fraction of a
    second, a binary neutron star at the same cutoff carries tens of seconds, and a lower
    cutoff lengthens both. A caller substituting a constant -- "ringdown is milliseconds" --
    is not approximating this quantity, it is discarding it.

    Asked of the backend for the same reason as the pre side: the length is a property of how
    each library conditions its output, and a caller reproducing that arithmetic would be a
    second implementation of it, wrong differently per backend.

    Returns:
        Seconds between coalescence and one sample past the buffer's end, always positive.
        ``None`` when this backend cannot say, which callers must treat as "unknown" rather
        than "zero".

        Test for ``None`` explicitly. Reading it as ``0.0`` asserts that an event's content
        stops at its coalescence, which would discard every event whose ``tc`` precedes a
        segment -- including the ones whose tail lands inside it, which is worse than the
        behaviour this method exists to enable.

    Args:
        approximant: The approximant that will be generated.
        sampling_frequency: Sample rate in Hz, which sets the sample count.
        minimum_frequency: Low-frequency cutoff in Hz; the dominant term in the buffer length.
        **params: The source parameters that will be generated, in the same form
            ``generate_td_waveform`` takes.
    """
    del approximant, sampling_frequency, minimum_frequency, params
    return None

pre_coalescence_duration(approximant, sampling_frequency, minimum_frequency, **params)

Return how long before tc the generated waveform starts, in seconds.

A caller placing a signal in segmented data needs this before generating: a compact binary's inspiral precedes its coalescence, so a buffer whose tc sits just past a segment boundary begins in an earlier segment. Deciding which segment claims an event without knowing this length means cropping the start away.

How much that costs is not a single number. It moves by orders of magnitude with the low-frequency cutoff, with how far past tc lands beyond the boundary, and with the backend -- from under 1% to over 99% of a binary's unweighted strain-squared energy, for the same source. A percentage quoted without all three is not interpretable, so none is quoted here. The measured tables live in the user guide, under How much signal a segment boundary costs, together with what they are a proxy for and what they are not anchored against.

Asked of the backend rather than computed by the caller on purpose. The length is a property of how each library conditions its output: the LAL backend sizes with :func:~gwmock_signal.waveform.backends.conditioning.segment_sample_count, the gwsignal backend inherits that because it overrides only the frequency-domain evaluation, ripple applies its own 5-smooth sizing, and PyCBC delegates to its library. A caller reproducing any of that would be a second implementation of a quantity that already exists, wrong differently per backend -- and wrong in the direction that silently truncates.

This is where the buffer starts, not where audible signal begins. The buffer carries headroom beyond the estimated chirp time and is rounded up, so the first samples are near-silent: a 30+25 solar-mass binary reports 3.6 s while carrying roughly 1.1 s of inspiral. That is the safe direction for choosing a segment -- placing from this value never crops real signal -- but it is not a statement about signal duration.

Returns:

Type Description
float | None

Seconds between the first sample and coalescence, always positive. None when this

float | None

backend cannot say, which callers must treat as "unknown" rather than "zero": the

float | None

default is deliberately unhelpful because a wrong number is worse than none. A caller

float | None

that gets None should keep whatever conservative behaviour it had.

float | None

Test for None explicitly. if duration: is a trap -- it is also false for

float | None

0.0, and treating an unknown length as zero places every event in the segment

float | None

holding its coalescence, which is the behaviour this method exists to avoid.

float | None

Of the backends here, only PyCBC returns None.

Parameters:

Name Type Description Default
approximant str

The approximant that will be generated. Accepted because a backend may condition differently per family, even though the current ones do not.

required
sampling_frequency float

Sample rate in Hz, which sets the sample count.

required
minimum_frequency float

Low-frequency cutoff in Hz; the dominant term in the chirp time.

required
**params object

The source parameters that will be generated, in the same form generate_td_waveform takes.

{}
Source code in src/gwmock_signal/waveform/backends/base.py
 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
def pre_coalescence_duration(
    self,
    approximant: str,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> float | None:
    """Return how long before ``tc`` the generated waveform starts, in seconds.

    A caller placing a signal in segmented data needs this *before* generating: a compact
    binary's inspiral precedes its coalescence, so a buffer whose ``tc`` sits just past a
    segment boundary begins in an earlier segment. Deciding which segment claims an event
    without knowing this length means cropping the start away.

    **How much that costs is not a single number.** It moves by orders of magnitude with the
    low-frequency cutoff, with how far past ``tc`` lands beyond the boundary, and with the
    backend -- from under 1% to over 99% of a binary's unweighted strain-squared energy, for the
    same source. A percentage quoted without all three is not interpretable, so none is quoted
    here. The measured tables live in the user guide, under *How much signal a segment boundary
    costs*, together with what they are a proxy for and what they are not anchored against.

    Asked of the backend rather than computed by the caller on purpose. The length is a
    property of how each library conditions its output: the LAL backend sizes with
    :func:`~gwmock_signal.waveform.backends.conditioning.segment_sample_count`, the gwsignal
    backend inherits that because it overrides only the frequency-domain evaluation, ripple
    applies its own 5-smooth sizing, and PyCBC delegates to its library. A caller reproducing
    any of that would be a second implementation of a quantity that already exists, wrong
    differently per backend -- and wrong in the direction that silently truncates.

    **This is where the buffer starts, not where audible signal begins.** The buffer carries
    headroom beyond the estimated chirp time and is rounded up, so the first samples are
    near-silent: a 30+25 solar-mass binary reports 3.6 s while carrying roughly 1.1 s of
    inspiral. That is the safe direction for choosing a segment -- placing from this value
    never crops real signal -- but it is not a statement about signal duration.

    Returns:
        Seconds between the first sample and coalescence, always positive. ``None`` when this
        backend cannot say, which callers must treat as "unknown" rather than "zero": the
        default is deliberately unhelpful because a wrong number is worse than none. A caller
        that gets ``None`` should keep whatever conservative behaviour it had.

        Test for ``None`` explicitly. ``if duration:`` is a trap -- it is also false for
        ``0.0``, and treating an unknown length as zero places every event in the segment
        holding its coalescence, which is the behaviour this method exists to avoid.

        Of the backends here, only PyCBC returns ``None``.

    Args:
        approximant: The approximant that will be generated. Accepted because a backend may
            condition differently per family, even though the current ones do not.
        sampling_frequency: Sample rate in Hz, which sets the sample count.
        minimum_frequency: Low-frequency cutoff in Hz; the dominant term in the chirp time.
        **params: The source parameters that will be generated, in the same form
            ``generate_td_waveform`` takes.
    """
    del approximant, sampling_frequency, minimum_frequency, params
    return None

WaveformFactory

Registry and dispatcher for time-domain waveform generators.

On construction, every name returned by the configured backend is registered and mapped to that backend's generate_td_waveform implementation. You may register additional names pointing at custom callables. See package docs for examples.

Source code in src/gwmock_signal/waveform/factory.py
 30
 31
 32
 33
 34
 35
 36
 37
 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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
class WaveformFactory:
    """Registry and dispatcher for time-domain waveform generators.

    On construction, every name returned by the configured backend is registered
    and mapped to that backend's ``generate_td_waveform`` implementation.
    You may register additional names pointing at custom callables. See package docs for examples.
    """

    def __init__(self, backend: WaveformBackend | None = None) -> None:
        """Build the registry of built-in backend approximants.

        Note:
            Enumerating approximants can be slow; reuse one factory
            instance in tight loops instead of creating many factories.
        """
        self._backend = backend or LALSimulationBackend()
        self._models: dict[str, Callable[..., dict[str, TimeSeries]]] = {
            name: self._wrap_backend_call(name) for name in self._backend.available_approximants()
        }
        # Kept so :meth:`pre_coalescence_duration` can tell a backend approximant from a custom
        # registration. Compared by identity rather than by name, because
        # ``register_waveform_model`` may *shadow* a backend name -- and then the backend's answer
        # would describe a waveform nobody is generating.
        self._backend_models = dict(self._models)

    def _wrap_backend_call(self, default_approximant: str) -> Callable[..., dict[str, TimeSeries]]:
        """Adapt the backend interface to the factory's callable registry contract."""

        def _call_backend(
            *,
            waveform_model: str | None = None,
            approximant: str | None = None,
            tc: float,
            sampling_frequency: float,
            minimum_frequency: float,
            **params: Any,
        ) -> dict[str, TimeSeries]:
            for supplied_name in (waveform_model, approximant):
                if supplied_name is not None and supplied_name != default_approximant:
                    raise ValueError(
                        f"Registered model {default_approximant!r} cannot be called with conflicting "
                        f"approximant {supplied_name!r}."
                    )
            model_name = default_approximant
            return self._backend.generate_td_waveform(
                approximant=model_name,
                tc=tc,
                sampling_frequency=sampling_frequency,
                minimum_frequency=minimum_frequency,
                **params,
            )

        return _call_backend

    def register_model(self, name: str, factory_func: Callable[..., Any] | str) -> None:
        """Register or overwrite a waveform model under ``name``.

        Args:
            name: Key used with ``WaveformFactory.generate`` and ``WaveformFactory.get_model``.
            factory_func: Callable that accepts merged waveform kwargs (including
                ``waveform_model``, ``tc``, ``sampling_frequency``, ``minimum_frequency``)
                and returns a dict of GWpy ``plus``/``cross`` series, **or** an import
                string: either ``module.path:callable`` (colon before the name) or
                ``package.module.callable`` (split on the last ``.`` for attribute lookup).

        Raises:
            ImportError: If a string path does not refer to an importable module.
            AttributeError: If the imported module has no such callable attribute.
            ValueError: If factory_func string is neither 'module.path:callable' nor 'package.module.callable'.
            TypeError: Registered model is not callable.
        """
        if isinstance(factory_func, str):
            if ":" in factory_func:
                module_path, func_name = factory_func.split(":", 1)
            else:
                if "." not in factory_func:
                    raise ValueError("factory_func string must be 'module.path:callable' or 'package.module.callable'")
                module_path, func_name = factory_func.rsplit(".", 1)
            module = importlib.import_module(module_path)
            factory_func = getattr(module, func_name)

        if not callable(factory_func):
            raise TypeError(f"Registered model '{name}' is not callable")

        self._models[name] = factory_func
        logger.info("Registered waveform model: %s", name)

    def get_model(self, name: str) -> Callable[..., dict[str, TimeSeries]]:
        """Look up the generator function registered for ``name``.

        Args:
            name: Registered model name (built-in approximant or custom).

        Returns:
            The callable registered for this name.

        Raises:
            ValueError: If ``name`` is not registered.
        """
        if name in self._models:
            return self._models[name]
        raise ValueError(f"Waveform model '{name}' not found. Available: {list(self._models.keys())}.")

    def pre_coalescence_duration(
        self,
        name: str,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> float | None:
        """Return how long before ``tc`` the buffer for ``name`` starts, or ``None`` if unknown.

        Delegates to the backend, which computes it from the same sizing its own generation uses.
        See :meth:`~gwmock_signal.waveform.backends.base.WaveformBackend.pre_coalescence_duration`
        for what the number means -- in particular that it is where the *buffer* starts, not where
        audible signal begins, and that ``None`` means unknown rather than zero.

        Returns ``None`` for a custom registered model. Those are arbitrary callables that never
        reach the backend, so the backend's sizing would not describe what they produce. The check
        is by identity against the wrappers built at construction, so a registration that shadows a
        backend approximant is also excluded.

        Args:
            name: Waveform model name, as passed to :meth:`get_model`.
            sampling_frequency: Sample rate in Hz.
            minimum_frequency: Low-frequency cutoff in Hz.
            **params: Source parameters, as generation would receive them.

        Raises:
            ValueError: If ``name`` is not registered at all, matching :meth:`get_model`.
        """
        if name not in self._models:
            raise ValueError(f"Waveform model '{name}' not found. Available: {list(self._models.keys())}.")
        if self._models[name] is not self._backend_models.get(name):
            return None
        return self._backend.pre_coalescence_duration(name, sampling_frequency, minimum_frequency, **params)

    def post_coalescence_duration(
        self,
        name: str,
        sampling_frequency: float,
        minimum_frequency: float,
        **params: object,
    ) -> float | None:
        """Return how long after ``tc`` the buffer for ``name`` runs, or ``None`` if unknown.

        The complement of :meth:`pre_coalescence_duration`, delegating the same way and excluding
        custom registrations for the same reason: an arbitrary callable never reaches the backend,
        so the backend's sizing would not describe what it produces.

        Args:
            name: Waveform model name, as passed to :meth:`get_model`.
            sampling_frequency: Sample rate in Hz.
            minimum_frequency: Low-frequency cutoff in Hz.
            **params: Source parameters, as generation would receive them.

        Returns:
            Seconds from coalescence to one sample past the buffer's end, or ``None`` when the
            backend cannot say -- unknown, never zero.

        Raises:
            ValueError: If ``name`` is not registered at all, matching :meth:`get_model`.
        """
        if name not in self._models:
            raise ValueError(f"Waveform model '{name}' not found. Available: {list(self._models.keys())}.")
        if self._models[name] is not self._backend_models.get(name):
            return None
        return self._backend.post_coalescence_duration(name, sampling_frequency, minimum_frequency, **params)

    def list_models(self) -> list[str]:
        """Return every registered waveform model name, in dict iteration order.

        Returns:
            List of keys (backend approximants plus any custom registrations).
        """
        return list(self._models.keys())

    def generate(
        self,
        waveform_model: str,
        parameters: dict[str, Any],
        **extra_params: Any,
    ) -> dict[str, TimeSeries]:
        """Generate polarizations by calling the registered model with merged parameters.

        The callable is invoked with ``waveform_model``, then entries from ``parameters``,
        then ``extra_params`` (later keys override earlier ones).

        Args:
            waveform_model: Name of the registered model to run.
            parameters: Injection parameters (e.g. ``tc``, masses, spins) merged first.
            **extra_params: Additional fixed settings (e.g. ``sampling_frequency``,
                ``minimum_frequency``) merged after ``parameters``; later keys override.

        Returns:
            Dict whose keys are the strings ``plus`` and ``cross``, each mapping to a
            GWpy [`TimeSeries`](https://gwpy.github.io/docs/latest/api/gwpy.timeseries.TimeSeries/).

        Raises:
            ValueError: If ``waveform_model`` is not registered.
            TypeError: If the underlying generator is called with invalid arguments.
        """
        waveform_func = self.get_model(waveform_model)
        if "waveform_model" in parameters or "waveform_model" in extra_params:
            raise ValueError("Do not pass 'waveform_model' in parameters/extra_params.")
        all_params: dict[str, Any] = {**parameters, **extra_params, "waveform_model": waveform_model}
        return waveform_func(**all_params)

__init__(backend=None)

Build the registry of built-in backend approximants.

Note

Enumerating approximants can be slow; reuse one factory instance in tight loops instead of creating many factories.

Source code in src/gwmock_signal/waveform/factory.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def __init__(self, backend: WaveformBackend | None = None) -> None:
    """Build the registry of built-in backend approximants.

    Note:
        Enumerating approximants can be slow; reuse one factory
        instance in tight loops instead of creating many factories.
    """
    self._backend = backend or LALSimulationBackend()
    self._models: dict[str, Callable[..., dict[str, TimeSeries]]] = {
        name: self._wrap_backend_call(name) for name in self._backend.available_approximants()
    }
    # Kept so :meth:`pre_coalescence_duration` can tell a backend approximant from a custom
    # registration. Compared by identity rather than by name, because
    # ``register_waveform_model`` may *shadow* a backend name -- and then the backend's answer
    # would describe a waveform nobody is generating.
    self._backend_models = dict(self._models)

generate(waveform_model, parameters, **extra_params)

Generate polarizations by calling the registered model with merged parameters.

The callable is invoked with waveform_model, then entries from parameters, then extra_params (later keys override earlier ones).

Parameters:

Name Type Description Default
waveform_model str

Name of the registered model to run.

required
parameters dict[str, Any]

Injection parameters (e.g. tc, masses, spins) merged first.

required
**extra_params Any

Additional fixed settings (e.g. sampling_frequency, minimum_frequency) merged after parameters; later keys override.

{}

Returns:

Type Description
dict[str, TimeSeries]

Dict whose keys are the strings plus and cross, each mapping to a

dict[str, TimeSeries]

GWpy TimeSeries.

Raises:

Type Description
ValueError

If waveform_model is not registered.

TypeError

If the underlying generator is called with invalid arguments.

Source code in src/gwmock_signal/waveform/factory.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def generate(
    self,
    waveform_model: str,
    parameters: dict[str, Any],
    **extra_params: Any,
) -> dict[str, TimeSeries]:
    """Generate polarizations by calling the registered model with merged parameters.

    The callable is invoked with ``waveform_model``, then entries from ``parameters``,
    then ``extra_params`` (later keys override earlier ones).

    Args:
        waveform_model: Name of the registered model to run.
        parameters: Injection parameters (e.g. ``tc``, masses, spins) merged first.
        **extra_params: Additional fixed settings (e.g. ``sampling_frequency``,
            ``minimum_frequency``) merged after ``parameters``; later keys override.

    Returns:
        Dict whose keys are the strings ``plus`` and ``cross``, each mapping to a
        GWpy [`TimeSeries`](https://gwpy.github.io/docs/latest/api/gwpy.timeseries.TimeSeries/).

    Raises:
        ValueError: If ``waveform_model`` is not registered.
        TypeError: If the underlying generator is called with invalid arguments.
    """
    waveform_func = self.get_model(waveform_model)
    if "waveform_model" in parameters or "waveform_model" in extra_params:
        raise ValueError("Do not pass 'waveform_model' in parameters/extra_params.")
    all_params: dict[str, Any] = {**parameters, **extra_params, "waveform_model": waveform_model}
    return waveform_func(**all_params)

get_model(name)

Look up the generator function registered for name.

Parameters:

Name Type Description Default
name str

Registered model name (built-in approximant or custom).

required

Returns:

Type Description
Callable[..., dict[str, TimeSeries]]

The callable registered for this name.

Raises:

Type Description
ValueError

If name is not registered.

Source code in src/gwmock_signal/waveform/factory.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def get_model(self, name: str) -> Callable[..., dict[str, TimeSeries]]:
    """Look up the generator function registered for ``name``.

    Args:
        name: Registered model name (built-in approximant or custom).

    Returns:
        The callable registered for this name.

    Raises:
        ValueError: If ``name`` is not registered.
    """
    if name in self._models:
        return self._models[name]
    raise ValueError(f"Waveform model '{name}' not found. Available: {list(self._models.keys())}.")

list_models()

Return every registered waveform model name, in dict iteration order.

Returns:

Type Description
list[str]

List of keys (backend approximants plus any custom registrations).

Source code in src/gwmock_signal/waveform/factory.py
199
200
201
202
203
204
205
def list_models(self) -> list[str]:
    """Return every registered waveform model name, in dict iteration order.

    Returns:
        List of keys (backend approximants plus any custom registrations).
    """
    return list(self._models.keys())

post_coalescence_duration(name, sampling_frequency, minimum_frequency, **params)

Return how long after tc the buffer for name runs, or None if unknown.

The complement of :meth:pre_coalescence_duration, delegating the same way and excluding custom registrations for the same reason: an arbitrary callable never reaches the backend, so the backend's sizing would not describe what it produces.

Parameters:

Name Type Description Default
name str

Waveform model name, as passed to :meth:get_model.

required
sampling_frequency float

Sample rate in Hz.

required
minimum_frequency float

Low-frequency cutoff in Hz.

required
**params object

Source parameters, as generation would receive them.

{}

Returns:

Type Description
float | None

Seconds from coalescence to one sample past the buffer's end, or None when the

float | None

backend cannot say -- unknown, never zero.

Raises:

Type Description
ValueError

If name is not registered at all, matching :meth:get_model.

Source code in src/gwmock_signal/waveform/factory.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def post_coalescence_duration(
    self,
    name: str,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> float | None:
    """Return how long after ``tc`` the buffer for ``name`` runs, or ``None`` if unknown.

    The complement of :meth:`pre_coalescence_duration`, delegating the same way and excluding
    custom registrations for the same reason: an arbitrary callable never reaches the backend,
    so the backend's sizing would not describe what it produces.

    Args:
        name: Waveform model name, as passed to :meth:`get_model`.
        sampling_frequency: Sample rate in Hz.
        minimum_frequency: Low-frequency cutoff in Hz.
        **params: Source parameters, as generation would receive them.

    Returns:
        Seconds from coalescence to one sample past the buffer's end, or ``None`` when the
        backend cannot say -- unknown, never zero.

    Raises:
        ValueError: If ``name`` is not registered at all, matching :meth:`get_model`.
    """
    if name not in self._models:
        raise ValueError(f"Waveform model '{name}' not found. Available: {list(self._models.keys())}.")
    if self._models[name] is not self._backend_models.get(name):
        return None
    return self._backend.post_coalescence_duration(name, sampling_frequency, minimum_frequency, **params)

pre_coalescence_duration(name, sampling_frequency, minimum_frequency, **params)

Return how long before tc the buffer for name starts, or None if unknown.

Delegates to the backend, which computes it from the same sizing its own generation uses. See :meth:~gwmock_signal.waveform.backends.base.WaveformBackend.pre_coalescence_duration for what the number means -- in particular that it is where the buffer starts, not where audible signal begins, and that None means unknown rather than zero.

Returns None for a custom registered model. Those are arbitrary callables that never reach the backend, so the backend's sizing would not describe what they produce. The check is by identity against the wrappers built at construction, so a registration that shadows a backend approximant is also excluded.

Parameters:

Name Type Description Default
name str

Waveform model name, as passed to :meth:get_model.

required
sampling_frequency float

Sample rate in Hz.

required
minimum_frequency float

Low-frequency cutoff in Hz.

required
**params object

Source parameters, as generation would receive them.

{}

Raises:

Type Description
ValueError

If name is not registered at all, matching :meth:get_model.

Source code in src/gwmock_signal/waveform/factory.py
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
def pre_coalescence_duration(
    self,
    name: str,
    sampling_frequency: float,
    minimum_frequency: float,
    **params: object,
) -> float | None:
    """Return how long before ``tc`` the buffer for ``name`` starts, or ``None`` if unknown.

    Delegates to the backend, which computes it from the same sizing its own generation uses.
    See :meth:`~gwmock_signal.waveform.backends.base.WaveformBackend.pre_coalescence_duration`
    for what the number means -- in particular that it is where the *buffer* starts, not where
    audible signal begins, and that ``None`` means unknown rather than zero.

    Returns ``None`` for a custom registered model. Those are arbitrary callables that never
    reach the backend, so the backend's sizing would not describe what they produce. The check
    is by identity against the wrappers built at construction, so a registration that shadows a
    backend approximant is also excluded.

    Args:
        name: Waveform model name, as passed to :meth:`get_model`.
        sampling_frequency: Sample rate in Hz.
        minimum_frequency: Low-frequency cutoff in Hz.
        **params: Source parameters, as generation would receive them.

    Raises:
        ValueError: If ``name`` is not registered at all, matching :meth:`get_model`.
    """
    if name not in self._models:
        raise ValueError(f"Waveform model '{name}' not found. Available: {list(self._models.keys())}.")
    if self._models[name] is not self._backend_models.get(name):
        return None
    return self._backend.pre_coalescence_duration(name, sampling_frequency, minimum_frequency, **params)

register_model(name, factory_func)

Register or overwrite a waveform model under name.

Parameters:

Name Type Description Default
name str

Key used with WaveformFactory.generate and WaveformFactory.get_model.

required
factory_func Callable[..., Any] | str

Callable that accepts merged waveform kwargs (including waveform_model, tc, sampling_frequency, minimum_frequency) and returns a dict of GWpy plus/cross series, or an import string: either module.path:callable (colon before the name) or package.module.callable (split on the last . for attribute lookup).

required

Raises:

Type Description
ImportError

If a string path does not refer to an importable module.

AttributeError

If the imported module has no such callable attribute.

ValueError

If factory_func string is neither 'module.path:callable' nor 'package.module.callable'.

TypeError

Registered model is not callable.

Source code in src/gwmock_signal/waveform/factory.py
 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
def register_model(self, name: str, factory_func: Callable[..., Any] | str) -> None:
    """Register or overwrite a waveform model under ``name``.

    Args:
        name: Key used with ``WaveformFactory.generate`` and ``WaveformFactory.get_model``.
        factory_func: Callable that accepts merged waveform kwargs (including
            ``waveform_model``, ``tc``, ``sampling_frequency``, ``minimum_frequency``)
            and returns a dict of GWpy ``plus``/``cross`` series, **or** an import
            string: either ``module.path:callable`` (colon before the name) or
            ``package.module.callable`` (split on the last ``.`` for attribute lookup).

    Raises:
        ImportError: If a string path does not refer to an importable module.
        AttributeError: If the imported module has no such callable attribute.
        ValueError: If factory_func string is neither 'module.path:callable' nor 'package.module.callable'.
        TypeError: Registered model is not callable.
    """
    if isinstance(factory_func, str):
        if ":" in factory_func:
            module_path, func_name = factory_func.split(":", 1)
        else:
            if "." not in factory_func:
                raise ValueError("factory_func string must be 'module.path:callable' or 'package.module.callable'")
            module_path, func_name = factory_func.rsplit(".", 1)
        module = importlib.import_module(module_path)
        factory_func = getattr(module, func_name)

    if not callable(factory_func):
        raise TypeError(f"Registered model '{name}' is not callable")

    self._models[name] = factory_func
    logger.info("Registered waveform model: %s", name)

__getattr__(name)

Resolve optional waveform helpers lazily.

Source code in src/gwmock_signal/waveform/__init__.py
24
25
26
27
28
29
30
def __getattr__(name: str):
    """Resolve optional waveform helpers lazily."""
    if name == "pycbc_waveform_wrapper":
        value = getattr(import_module("gwmock_signal.waveform.pycbc_wrapper"), name)
        globals()[name] = value
        return value
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

For usage examples, see the User guide — Waveform examples.