Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

The Two Domains

An MRS measurement is one signal seen from two sides. The scanner records a free induction decay (FID) — a complex signal over time. Its Fourier transform is the spectrum — the same information over frequency, where peaks become visible and integrable. Nothing is gained or lost moving between them: xmris uses unitary transforms (norm="ortho"), so the round trip is exact to floating-point precision.

In xmris, the dimension name is the domain. A DataArray with a time dim is time-domain along that axis; one with a frequency (Hz) or chemical_shift (ppm) dim is spectral. There is no hidden flag to drift out of sync — the domain is readable, per axis, right in the repr.

Why per-axis domain state matters

A CSI dataset can be image-space along (x, y, z) while still time-domain along t. A single object-level “is this a spectrum?” flag cannot represent that; per-dimension naming can. This is the classic per-dimension domain bookkeeping of NMR processing software, expressed natively in xarray.

The problem

Every processing operation has a home domain where its math lives. Phasing and baseline work are spectral-domain jobs; apodization is a time-domain multiplication. But your data is wherever your pipeline currently holds it — so what should happen when you call a spectral operation on a FID, or a time-domain operation on a spectrum?

Left unmanaged, this ends one of two ways: either you pepper pipelines with manual conversions and remember each function’s home domain yourself, or the library converts silently and you stop being able to predict what a call returns. Both are failure modes. The second is worse.

The goal

One sentence:

To honor that sentence, operations cannot all follow the same rule — because they do not all have the same relationship to their home domain. xmris distinguishes four kinds.

Four kinds of operations

Funnel operations — the result lands in the home domain

autophase, baseline_als. These are only meaningful on a spectrum, and their result is consumed as a spectrum — you phase in order to look at peaks. So a FID input is transformed on the way in and stays there:

fid.xmr.autophase()        # → phased spectrum, ready to inspect
spec.xmr.autophase()       # → phased spectrum (already home — no transform)

For baseline_als there is no alternative even in principle: it discards the imaginary component, and a real-valued spectrum has no valid FID behind it anymore.

Domain-preserving operations — same physics, your representation kept

apodize_exp, apodize_lg, zero_fill. Their math is identical seen from either side — multiplying an FID by eπlbte^{-\pi\,\mathrm{lb}\,t} is convolving its spectrum with a Lorentzian of width lb\mathrm{lb} Hz; zero-filling an FID is interpolating its spectrum onto a finer grid. So these never change what you hold: internally the values take a round trip through the time domain, and the output comes back in the input’s representation, original coordinates intact.

fid.xmr.apodize_exp(lb=2)    # → FID   (home domain — no transform)
spec.xmr.apodize_exp(lb=2)   # → spectrum, smoothed (round trip inside)

Fitting (fit_amares) is domain-preserving too, with a twist. It models the FID, but you may hand it a spectrum: it round-trips through the time domain to fit and returns its signal variables (data/fit/residuals) in the representation you passed — ppm in, ppm out — while the quantified parameters are domain-independent. It is the one such operation that carries no decorator: it returns a Dataset, and the round trip must transform only the signals, never the parameter table, so it hand-rolls the same converter routing the decorators use.

Converters — the only functions that change representation on purpose

to_spectrum, to_fid, to_ppm, to_hz. They own every convention: FFT centering, physical time-axis reconstruction, ppm referencing. All automatic conversion inside xmris routes through them — an inserted transform is bit-identical to one you write yourself. They are deliberately strict about their input (to_spectrum on data without a time dim is an error, not a no-op): that strictness is what makes “accidentally Fourier-transformed twice” impossible.

Explicit operations — no magic, by design

phase (the low-level primitive under autophase) and the raw fft/ifft family never convert for you: they are the sharp tools the converters are built from, so they act exactly where you point them.

What you get, at a glance

You callon a FID (time)on a spectrum (frequency/chemical_shift)
autophase()spectrum (phased)spectrum (phased)
baseline_als()real spectrumreal spectrum
apodize_exp()FIDspectrum
zero_fill()FID (longer)spectrum (finer grid)
to_spectrum()spectrumerror — no time dim
to_fid()error — no spectral dimFID
fit_amares()fit Dataset (time-domain)fit Dataset (spectral)

Spectral outputs keep their input’s labeling: ppm in, ppm out.

The canonical pipeline

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
)

Exactly one Fourier transform executes — at the funnel boundary, where you would have written .xmr.to_spectrum() by hand. Writing the converter explicitly remains equivalent and equally cheap; the contracts only remove the bookkeeping, not the option.

Guardrails

One-way data fails loudly. Downstream of baseline_als the spectrum is real-valued and cannot be taken back to the time domain. Domain-preserving operations check this and raise a clear error instead of inventing an FID:

spec.xmr.baseline_als().xmr.apodize_exp(lb=2)
# ValueError: 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. …

The ppm leg is metadata-gated. Converting chemical_shift data through the time domain needs reference_frequency and carrier_ppm in attrs; if they are missing you get the standard copy-pasteable assign_attrs fix, not a wrong axis.

Explicit foreign dims pass through — for domain-preserving ops. A domain-preserving (@computes_in) operation called on an axis outside its domain skips conversion entirely: kspace.xmr.zero_fill(dim="kx", position="symmetric") names a different axis, so the data passes through untouched. Funnel (@ensures_domain) operations have no such passthrough — they always land in their home domain, converting (or raising) even when an explicit foreign dim is named.

Strict mode. Prefer zero magic — e.g. for quantitative work?

with xmris.set_options(auto_convert=False):
    fid.xmr.autophase()   # ValueError with the explicit fix: .xmr.to_spectrum()

For contributors: declaring a function’s domain

The contracts above are not conventions to remember — they are declared, one line per function, with two decorators sharing one engine (see the validation API):

@ensures_domain(SPECTRAL_DIMS)          # funnel: coerce in, leave there
def autophase(da, dim=None, ...): ...

@computes_in(TIME_DIMS)                 # preserve: round trip, restore representation
def apodize_exp(da, dim=DIMS.time, lb=1.0): ...

Both decorators also resolve the axis: when a spectral function’s dim is left as None, the unique spectral dim present (frequency or chemical_shift) is filled in — an explicitly passed dim is never overridden. That yields the package-wide signature rule, enforced by an architecture test: dim defaults to None iff the function is domain-decorated with a multi-label domain; otherwise it defaults to the config constant.

fit_amares is the exception the tree can’t draw: it is domain-preserving (the computes_in branch) but returns a Dataset, so it hand-rolls the round trip instead of wearing the decorator — see domain-preserving operations above.

Two hard rules keep the system honest: decorator-inserted transforms must route through the converters (never inline fft/ifft — the converters own the conventions), and only converters may change representation. Everything else follows from the one-sentence goal at the top.