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):
@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_apodizedapodize_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:
@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.