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:
delta_t = 1/4096 sgives a returned spacing of1/4096 sto within 1e-15.- Doubling
distancefrom 400 to 800 Mpc halves the strain, and BITWISE, since the factor is a power of two. distance=Nonereturnsr h_lm / M: scaling it byM SOLAR_MASS_METERS / (400 MEGAPARSEC_METERS)reproduces the 400 Mpc strain to 7.755e-37 absolute on a peak of order 1e-21. Not bitwise, and it cannot be, because the evaluator formsh * (scale * rotation)while the check forms(h * rotation) * scale.f_min = 25 Hzstarts the (2, 2) at 25 Hz, within the 0.5 Hz accuracy of the finite-differenced phase the check measures it with.
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.
with_derivatives=Truewith a mode subset: the fused kernel produces all seven modes in one pass. Select from the returned dictionary.with_derivatives=Truewithtime_step: the derivative arm builds its own grid at the model's load-time spacing. Pass explicittimes=instead.hybridization="never"with a below-reachf_low: raises, naming that point's own start frequency.hybridization="require"with an in-reach or absentf_low: raises, because the arm would not engage.- A below-reach
f_lowwithwith_derivatives=Trueontiming_mode="aa": there is no derivative path;hybrid_gradients.hybrid_modes_gradis named in the message. - Both
hybridization=andhybridize=, or bothmerger_ringdown_path=andmr=/mr_path=: one argument under two names, and guessing which was meant is the failure the rename removes.
Limits, next to the capability
- The parameter derivatives cover the inspiral plus, with the merger-ringdown arm attached, that arm. They are scalar-parameter only and take no mode subset. A derivative obtained on the merger-ringdown arm is an exact derivative OF THIS MODEL and inherits that arm's own error, which is the model's largest. Those are two different claims and neither stands in for the other.
"dynamics"and"hybrid_report"are filled in only for scalar input on the NumPy backend, withdynamics=True. On the kokkos backenddynamics=Trueraises rather than dropping the keys.- The derived routes (
timing_mode="aa-int","aa-int-pn") are inspiral-only and scalar-only, and carry their own extension gate, sohybridizationis honoured ontiming_mode="aa"only. - Outside the training range the model clamps to the boundary and returns
a confident-looking extrapolation with an
OutOfHullWarning.model.in_hull(mass_ratio, spin1z, spin2z)is the authority.
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 |