Every xmris operation makes a contract about what you get back: the output domain is a pure function of the operation and the input domain — never a surprise. This page proves the two contracts executable-style; the design story lives in The Two Domains.
| You call | on a FID (time) | on a spectrum (frequency/chemical_shift) |
|---|---|---|
apodize_exp(), zero_fill() | FID ✅ | spectrum ✅ (round trip inside) |
autophase(), baseline_als() | spectrum (funnel ⤵) | spectrum |
to_spectrum(), to_fid() | explicit conversion | explicit conversion |
Domain-preserving ops compute in the time domain but hand back what you gave them — same physics either side.
Funnel ops are only meaningful on a spectrum, so their result lands there, whatever you feed them.
import numpy as np
import matplotlib.pyplot as plt
import xarray as xr
import xmris # noqa: F401 (registers the .xmr accessor)
from xmris.fitting.simulation import simulate_fid1. A distorted, time-domain FID¶
fid = simulate_fid(
amplitudes=[100, 70, 45],
chemical_shifts=[2.0, 3.5, 5.0],
reference_frequency=123.2,
carrier_ppm=3.0,
dampings=[25, 25, 30],
phases=np.deg2rad(65), # zero-order phase error, baked into the FID
target_snr=250,
n_points=2048,
)
fid.dims # -> ('time',)('time',)2. Domain-preserving: same physics, either side¶
Multiplying an FID by is convolving its spectrum
with a Lorentzian of width Hz — one operation, two views. So
apodize_exp never changes your representation:
spectrum = fid.xmr.to_spectrum()
fid_smooth = fid.xmr.apodize_exp(lb=3) # FID in -> FID out
spec_smooth = spectrum.xmr.apodize_exp(lb=3) # spectrum in -> spectrum out
print("FID path :", fid.dims, "->", fid_smooth.dims)
print("spectrum path :", spectrum.dims, "->", spec_smooth.dims)FID path : ('time',) -> ('time',)
spectrum path : ('frequency',) -> ('frequency',)
And the two paths are numerically the same operation — transforming the apodized FID reproduces the apodized spectrum to machine precision:
fig, ax = plt.subplots(figsize=(7, 3))
ax.plot(spec_smooth["frequency"], np.real(spec_smooth), lw=2.5, label="spectrum path")
ax.plot(
spec_smooth["frequency"],
np.real(fid_smooth.xmr.to_spectrum()),
lw=1.0,
ls="--",
label="FID path → to_spectrum()",
)
ax.set_xlabel("Frequency (Hz)")
ax.set_ylabel("Re{S}")
ax.legend()
ax.grid(True, alpha=0.3)
ax.set_title("Two entry domains, one operation")
plt.tight_layout()
plt.show()
The same contract holds for zero_fill: zero-padding the FID is interpolating
the spectrum onto a finer grid — so calling it on a spectrum hands back a
spectrum with more points, not a FID:
spec_fine = spectrum.xmr.zero_fill(target_points=4096)
print(spectrum.sizes, "->", spec_fine.sizes)Frozen({'frequency': 2048}) -> Frozen({'frequency': 4096})
3. Funnel: the canonical pipeline, one FFT¶
Phasing and baseline correction exist for the spectrum — their results land there. That makes the classic monotonic pipeline read naturally, with exactly one Fourier transform executing at the funnel boundary:
result = (
fid.xmr.zero_fill(target_points=4096) # time-domain home: no transform
.xmr.apodize_exp(lb=3) # time-domain home: no transform
.xmr.autophase() # funnel: FID -> spectrum, stays
.xmr.baseline_als() # already spectral: no transform
)
print("pipeline result:", result.dims, "| real-valued:", not np.iscomplexobj(result.values))pipeline result: ('frequency',) | real-valued: True
ppm = result.xmr.to_ppm()
fig, ax = plt.subplots(figsize=(7, 3))
ax.plot(ppm["chemical_shift"], ppm.values, lw=1.5)
ax.axhline(0, color="red", ls="--", alpha=0.4)
ax.set_xlabel("Chemical shift (ppm)")
ax.set_ylabel("Re{S}")
ax.invert_xaxis()
ax.grid(True, alpha=0.3)
ax.set_title("zero_fill → apodize → autophase → baseline, straight from the FID")
plt.tight_layout()
plt.show()
4. Guardrails¶
One-way data fails loudly. baseline_als discards the imaginary component,
so no valid FID exists behind its output — a time-domain op on it refuses
rather than inventing one:
try:
result.xmr.apodize_exp(lb=2)
except ValueError as err:
print(err)Cannot transform real-valued spectral data (dim 'frequency') into the time domain: the imaginary component is gone (e.g. discarded by `baseline_als`), so no valid FID exists behind this spectrum.
Apply time-domain operations before the step that discarded the imaginary part, or pass an explicit existing dimension to operate on.
Explicit foreign dims pass through — for domain-preserving ops. A domain-preserving op named on an axis outside its domain skips conversion entirely — naming another axis (k-space, say) leaves the data untouched. (Funnel ops have no passthrough: they always land in their home domain.)
rng = np.random.default_rng(42)
kspace = xr.DataArray(
rng.standard_normal((8, 8)),
dims=["kx", "ky"],
coords={"kx": np.arange(8), "ky": np.arange(8)},
)
kfilled = kspace.xmr.zero_fill(dim="kx", target_points=16, position="symmetric")
print(dict(kspace.sizes), "->", dict(kfilled.sizes)){'kx': 8, 'ky': 8} -> {'kx': 16, 'ky': 8}