Waveform evaluation

The public surface for generating a waveform: which method to call, which units it speaks, what shape comes back, and which arguments it will refuse. Written 2026-09-02, when the surface was reshaped to the LAL and pycbc spelling.

The four methods

Two axes, product and unit system. The unsuffixed name is PHYSICAL units and the _geometric suffix is geometric ones.

method returns units
get_td_waveform h_plus, h_cross solar masses, seconds, Hz, Mpc
get_td_waveform_geometric times, h_plus, h_cross M, M f
get_td_waveform_modes times, {(l, m): h_lm} solar masses, seconds, Hz, Mpc
get_td_waveform_modes_geometric times, {(l, m): r h_lm / M} M, M f

Each carries ONLY the arguments that mean something in its own units, so no argument is ever accepted and ignored. There is no distance on a geometric method, because a geometric strain has nothing for it to scale, and no time_step in M on a physical one, because delta_t in seconds is what a LAL caller has. nrhjsurrogate/driver/aa_api.py is where all four live.

get_td_waveform returning two arrays and no time axis is deliberate and is the pycbc convention the name comes from, where the returned series carry their own epoch and spacing. Ours do not, so the axis comes from get_td_waveform_modes at the same arguments: delta_t-spaced, peak aligned.

The superseded spellings are retained and forward. model(...) and model.physical(...) return the same content as one dictionary and call the same implementation the new names call, so they cannot drift apart. MEASURED bitwise over 5 parameter points and all 7 modes, 430 comparisons and 0 failures.

Units, verified rather than asserted

Measured 2026-09-02 on the released NRHJSur3dq8_AA_v3 artifact, consumer route, at mass_ratio = 4, spins (0.3, -0.2), total mass 100 solar masses:

At mass_ratio = 4, spins (0.3, -0.2), the geometric face returns 15775 samples from -3819.1 M to +124.4 M with |h_22| peaking at 2.4156e-01. At 50 + 12.5 solar masses from 30 Hz at 400 Mpc, the physical face returns 0.4241 s from -0.385883 s to +0.038190 s with |h_22| peaking at 1.8062e-21.

One flag whose value changes the arity, and where it lives

with_derivatives on get_td_waveform_modes_geometric:

times, modes = model.get_td_waveform_modes_geometric(...)
times, modes, derivatives = model.get_td_waveform_modes_geometric(
    ..., with_derivatives=True)

That is the ONLY place on this surface where a return shape depends on an argument's value, and it is confined to the wrapper. Behind it each product is its own method with one fixed shape and one owner:

backend shape
_modes_only (times, modes)
_modes_and_derivatives (times, modes, derivatives)
_derivatives_only (times, derivatives)
_polarizations_only (times, h_plus, h_cross)
_physical_modes_only (times, modes)
_physical_polarizations_only (h_plus, h_cross)

None of them is layered on another, and that is a measurement rather than a preference. At mass_ratio = 4, spins (0.3, -0.2), 8192 samples, inspiral only: the waveform kernel is 84.10 ms and the fused gradient kernel is 406.43 ms, and the fused kernel ALREADY returns the modes. So a _modes_and_derivatives built out of _modes_only plus a gradient call would pay 84.10 ms, 21 percent on top of the fused call, for a waveform it already had. _derivatives_only runs that same 406.43 ms kernel and discards the waveform, so it is not a cheap path and its docstring says so; the derivative is built from the two quantities whose product IS the waveform (nrhjsurrogate/driver/aa_gradients.py:1161-1165), so forming it is one complex multiply per mode per sample.

No with_derivatives on the physical faces, deliberately. The derivatives are with respect to mass_ratio, spin1z and spin2z, and the physical face rotates the modes to a reference frequency using the waveform itself, so a physical-units derivative carries a chain-rule term the geometric one does not. Returning something that looks like a derivative and is missing a term is worse than not offering it.

Full derivative coverage, seam behaviour and batching are in parameter-derivatives.md.

Starting earlier than the artifact reaches

f_low (geometric, dimensionless M f of the (2, 2) mode) and f_min (physical, Hz) select where the waveform starts. None or exactly 0 is the full span; in reach it trims, bit for bit; below reach the inspiral is extended with a post-Newtonian description joined onto the artifact content.

hybridization is the policy, with three named states:

state what it does
"extend_if_below_reach" the default: trim in reach, extend below reach
"never" never extend; a below-reach request raises, so the content is numerical-relativity based throughout
"require" raise unless an extension is actually delivered

They replace hybridize="auto", hybridize=False and hybridize=True, which put a string and two booleans on one keyword and where True did not mean "yes please" but "raise unless you actually extend". The old spellings are accepted through either keyword and warn with FutureWarning (nrhjsurrogate/driver/hybridization_states.py; the module says why DeprecationWarning was rejected).

MEASURED at mass_ratio = 4, spins (0.3, -0.2), whose own content starts at M f = 0.006311:

f_low samples starts at
0.004 55881 -13845.5 M
0.002 367642 -91785.9 M
0.0015 796583 -199021.0 M

Keys that always exist

"dynamics" and "hybrid_report" are ALWAYS present in the dictionary that model(...) and model.physical(...) return, and are None when the call did not produce one (aa_api._with_always_present_keys). They used to appear only when the extension engaged, which is the same defect as a return whose arity varies: the reader cannot write result["hybrid_report"] without first writing the membership test, and the test is the part that gets forgotten.

Its cost, recorded because it was real: five in-tree callers iterated the result and skipped only "t", and all five had to change to isinstance(key, tuple). That is exactly the code that was relying on a key set that varied.

What each method will refuse

Refusals, not silent narrowing, because an array that looks right and answers a different question is the failure this surface exists to prevent.

Limits, next to the capability

Other products, which are other methods

method returns
get_orbital_dynamics the orbital quantities, not a waveform
get_remnant_properties final mass in solar masses and final spin
get_remnant_properties_geometric final mass in units of M and final spin
get_waveform_error_estimate the model's own 1-sigma error, see model-error-estimate.md

dynamics, remnant and remnant_physical are the superseded names and forward to these.

Where the code is

what where
the four methods and their backends nrhjsurrogate/driver/aa_api.py
named hybridisation states nrhjsurrogate/driver/hybridization_states.py
one loader argument name nrhjsurrogate/driver/loader_arguments.py
contract tests tests/test_public_api_spelling.py
user documentation docs/USAGE.md sections 2, 3, 4; examples/usage/01, 02, 03