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 Architecture Contract

This page is the law for everything under src/xmris/: eleven numbered rules — the Commandments — that every library change must obey. Cite them by number; ordinals 1–8 are stable (code and tooling reference them), and 9–11 codify patterns the test suite was already enforcing. The why lives elsewhere: the architecture tour motivates the xarray-first design, The Two Domains derives the domain decorators, and The Controlled Vocabulary explains the config singletons in xmris.core.config (which sits beside validation.py and utils.py under src/xmris/core/). The authoring skills and CLAUDE.md route here; where another contributor page differs from this one, this page wins.

Each rule closes with its enforcement — the test class in tests/test_core.py that guards it, or an honest reviewer checks where none does.

The Commandments

1. Xarray in, xarray out

Every public function takes and returns xr.DataArray or xr.Dataset. Private numeric kernels (e.g. _simulate_fid_ndarray) may drop to NumPy internally — the boundary stays xarray. Reviewer checks.

2. Functional purity

Never modify the input in place. Copy, transform, return a new object. Reviewer checks; TestAttrsPreservation guards the metadata half.

3. Lineage: preserve, append the parameters applied, never flag

Preserve inbound coordinates and attributes, then append what the function actually applied under ATTRS keys — a scalar (phase_p0=15.0), a config-blessed string (baseline_method="als", zero_fill_position="end"), or a list (simulate_fid’s sim_amplitudes). Banned: state flags (phase_applied=True) — the applied parameter’s presence is the record. Enforced: TestAttrsPreservation. The wider attrs strategy — preservation guarantees and structured provenance (xmr_history) — is an open design decision (#64, with #21 and #23); do not build ahead of it.

4. No magic strings — the vocabulary is law

Inside src/xmris/, dimension, coordinate, attribute and variable names come from the ATTRS/DIMS/COORDS/VARS singletons in xmris.core.config, never a bare "time". (User code and reader-facing examples use plain strings deliberately — the low entrance barrier is a feature.) A missing term is added to config.py — lowercase keys, singular dim names — and every new term is called out explicitly in the change. The legacy xmris.config.DEFAULTS shim is deprecated: never in new code. The one sanctioned exception is Commandment 11. Enforced: TestConfigNamingConventions, TestConfigMetadata, TestVocabularyUniqueness; the no-bare-strings half — reviewer greps, since an XmrisTerm equals its string.

5. The dim-default biconditional

A dim argument defaults to its config constant (dim: str = DIMS.time) — except it defaults to None iff the function carries a multi-label domain decorator (today only SPECTRAL_DIMS), whose merged resolution fills it at call time. Enforced: TestDomainDimRule. Why: The Two Domains.

6. Declare the contract at the door

Gate hidden state with @requires_attrs(...). Declare a domain-sensitive function’s working domain with @ensures_domain (funnel: the result stays there) or @computes_in (domain-preserving: the representation is restored). Converters, FFT primitives and vendor loaders stay undecorated by design — their transforms are explicit. Fitting (fit_amares) is domain-preserving but carries no decorator — it returns a Dataset, so it hand-rolls the converter round trip (restoring only the signal variables, not the parameter table). Never inline fft/ifft for domain handling, route through the converters. Validate dimensions with _check_dims(da, dim, "func_name"). Enforced: TestDomainRollout pins every function’s contract; the semantics under TestEnsuresDomain/TestComputesIn. Which decorator: the decision tree in The Two Domains.

7. Coordinates are built by as_variable

Never hand-assemble a {"units": ..., "long_name": ...} dict. as_variable(COORDS.term, dim, data) bundles data and term metadata into a fully formed xr.Variable for .assign_coords(). Reviewer greps src/ for literal "long_name".

8. Explicit MyST targets in docs

Every docs header carries an explicit (kebab-target)= and is linked via [text](#target), never an auto-generated slug — which mystmd numbers by document position, so inserting one section silently renumbers every anchor below it. The target is kebab-case and prefixed with the page topic (baseline-visualizing-the-results, not visualizing-the-results), since targets resolve page-globally. The full documentation law lives with the docs-page workflow. Enforced: check_docs.py, run over the whole tree by the Docs style job in ci-fast.yml.

9. The accessor method is a thin delegator

An .xmr method contains no logic: return free_func(self._obj, ...). Defaults are copied verbatim from the free function, every parameter is forwarded explicitly (a keyword reachable only through **kwargs makes the docstring lie), and the docstring documents the method — it takes self, not da. Enforced in part: TestAccessorDefaults pins dim defaults for its listed methods; full signature parity is open (#102).

10. Errors end with the fix

Every raise a user can hit ends with a copy-pasteable recovery line — >>> obj = obj.rename({...}), >>> obj = obj.assign_attrs({...}). _check_dims and requires_attrs (in core/utils.py and core/validation.py) are the house exemplars. Enforced in part: TestCheckDims pins the rename fix; new messages — reviewer checks.

11. Deliberately-local axes carry the marker

A diagnostic output axis deliberately kept out of the vocabulary is tagged # xmris-diagnostic-dim at its definition site, so every escape hatch stays greppable and revocable. Exemplar: estimate_group_delay’s trial_delay axis in vendor/bruker.py. The marker is the enforcement.

The rules in real code

There is no hand-written template: the exemplars below are quoted from the live source at build time, so they cannot drift. Read apodize_exp top to bottom as the walkthrough — the domain is declared at the door (6), the dimension validated (_check_dims, 6), the math pure (2), and the applied parameter appended to .attrs (3):

fid.py
@computes_in(TIME_DIMS)
def apodize_exp(da: xr.DataArray, dim: str = DIMS.time, lb: float = 1.0) -> xr.DataArray:
    """
    Apply an exponential weighting filter function for line broadening.

    During apodization, the time-domain FID signal $f(t)$ is multiplied with a filter
    function $f_{filter}(t) = e^{-t/T_L}$. This improves the Signal-to-Noise Ratio (SNR)
    because data points at the end of the FID, which primarily contain noise, are
    attenuated. The time constant $T_L$ is calculated from the desired line broadening
    in Hz.


    Parameters
    ----------
    da : xr.DataArray
        The input time-domain data.
    dim : str, optional
        The dimension corresponding to time, by default `DIMS.time`.
    lb : float, optional
        The desired line broadening factor in Hz, by default 1.0.

    Returns
    -------
    xr.DataArray
        A new apodized DataArray, preserving coordinates and attributes.
    """
    _check_dims(da, dim, "apodize_exp")

    t = da.coords[dim]

    # Calculate exponential filter: exp(-t / T_L) where T_L = 1 / (pi * lb)
    # This simplifies to: exp(-pi * lb * t)
    weight = np.exp(-np.pi * lb * t)

    # Functional application (transpose ensures broadcasting doesn't scramble axis order)
    da_apodized = (da * weight).transpose(*da.dims).assign_attrs(da.attrs)

    # Record lineage
    da_apodized.attrs[ATTRS.apodization_lb] = lb

    return da_apodized

apodize_exp — quoted from src/xmris/processing/fid.py at build time

to_ppm shows the other half: an attribute gate (@requires_attrs, 6) on a deliberately undecorated converter, and a coordinate built by as_variable (7) before the swap_dims:

referencing.py
@requires_attrs(ATTRS.reference_frequency, ATTRS.carrier_ppm)
def to_ppm(da: xr.DataArray, dim: str = DIMS.frequency) -> xr.DataArray:
    """
    Convert a relative frequency axis [Hz] to an absolute chemical shift axis [ppm].

    Computes ``carrier_ppm + hz / reference_frequency`` for every point of the
    frequency coordinate, assigns the result as a new ``chemical_shift``
    coordinate, and swaps the indexing dimension to it. The original Hz
    coordinate is kept alongside, so both views remain available.

    Parameters
    ----------
    da : xr.DataArray
        The input frequency-domain data with a coordinate on ``dim``.
    dim : str, optional
        The relative frequency dimension to convert, by default `DIMS.frequency`.

    Returns
    -------
    xr.DataArray
        The same data indexed by an absolute ``chemical_shift`` [ppm] dimension.
    """
    _check_dims(da, dim, "to_ppm")

    mhz = da.attrs[ATTRS.reference_frequency]
    carrier_ppm = da.attrs[ATTRS.carrier_ppm]
    hz_coords = da.coords[dim].values

    # 1. Calculate the math
    ppm_coords = carrier_ppm + (hz_coords / mhz)

    # 2. Build the fully-formed xarray Variable (data + metadata). Use the
    #    COORDS term (carries unit="ppm") so the coordinate's lineage is
    #    complete — mirrors `to_hz` using COORDS.frequency (unit="Hz").
    shift_var = as_variable(COORDS.chemical_shift, dim, ppm_coords)

    # 3. Assign and swap in one clean sweep
    obj = da.assign_coords({DIMS.chemical_shift: shift_var})
    return obj.swap_dims({dim: DIMS.chemical_shift})

to_ppm — quoted from src/xmris/processing/referencing.py at build time

Which decorator stack a new function copies — including the case neither exemplar shows — is the xmr-method skill’s job (Add a processing method).

The contract, executed

The rules above are claims; this cell runs them. It executes on every PR build, together with hidden asserts that hold the page to Commandments 2, 3 and 7 — and pin the two quotes above, since a silently truncated literalinclude would otherwise only warn.

import xmris

fid = xmris.simulate_fid(
    amplitudes=[1.0, 0.6],
    chemical_shifts=[0.0, 5.2],
    reference_frequency=120.66,  # MHz — enables ppm referencing
    n_points=1024,
)
fid_before = fid.copy(deep=True)

spectrum = fid.xmr.apodize_exp(lb=5.0).xmr.to_spectrum().xmr.to_ppm()
spectrum.attrs
{'spectral_width': 10000.0, 'dead_time': 0.0, 'sim_amplitudes': [1.0, 0.6], 'sim_dampings': [50.0], 'carrier_ppm': 0.0, 'units': 'a.u.', 'reference_frequency': 120.66, 'sim_chemical_shifts_ppm': [0.0, 5.2], 'apodization_lb': 5.0}

Open questions

The contract has one moving edge: the attrs strategy. #64 decides whether lineage stays flat per-parameter keys (today’s law) or becomes a structured xmr_history log. Commandment 3 states today’s law; when #64 resolves, it and this page change together.