Creating New 1D Analyses
Every 1D analysis lives in a single self-contained file, src/analysis1d/expt-<name>.jl. That file holds everything specific to the analysis — how the data is loaded, what is fitted, what is derived from the fit, and how it is presented — so there is exactly one place to go to change the science of an experiment, and one file to copy to add a new one.
The framework is deliberately the same shape as GUI2D: an abstract experiment type with defaulted hooks, a uniform result container, and a visualisation strategy orthogonal to the experiment hierarchy. If you know one, you know the other.
The core idea
Every 1D-derived analysis is a collection of 1D traces, each tagged with the values of one or more arrayed variables, reduced to quantities over named regions, then fitted against an evolution parameter. An experiment is a thin composition over that shared machinery:
Dataset1D ──integrate──▶ quantity per plane ──group──▶ series ──fit──▶ ─┐
│
postfit! / postfitglobal! ─┴─▶ the region's
parametersThe three stages are the same as in GUI2D; see How an Analysis Works.
Data model
| type | meaning |
|---|---|
Trace(δ, y) | one 1D spectrum: chemical shifts and intensities, plain vectors |
Planes(traces, vars) | one row per spectrum; vars[i] is a NamedTuple of that spectrum's arrayed variables |
Region(label, lo, hi) | a named ppm interval; zero width means a peak height |
Dataset1D(planes, noisecentre, label) | the planes, the noise position, and where the data came from |
SeriesResult(group, x, y, planes, model, coefficients, converged) | one series of a region: the planes sharing a grouping key, and the curve fitted through them |
RegionResult(region, series, parameters, postfitted) | everything reported for one region |
The analysis layer works on plain vectors and never sees an NMRData. The adapters in nmrdata.jl (loadspec, tracesfromspec, datasetfromspec) are the only place NMRData is touched.
The file template
Each expt-*.jl is laid out in a fixed five-section order. expt-tract.jl exercises all five and is the best worked example.
1. Entry point
The exported user-facing function. It resolves whatever the analysis needs to know, builds the experiment, and hands off to run1d:
function myanalysis1d(spec; tau=nothing, regions=nothing, integration=nothing,
prompt::Bool=isinteractive())
spec = loadspec(spec)
times = @something(tau,
annotation(spec, :myanalysis, :duration),
acqusvalue(spec, :vdlist),
askvector("delays", nplanesfromspec(spec); unit="s", prompt))
ds = datasetfromspec(spec, [(; time=Float64(t)) for t in times])
expt = isnothing(regions) ? MyExperiment(ds) : MyExperiment(ds; regions)
return run1d(expt; integration)
endrun1d opens the GUI and returns the results standing when its window closes, or — when an integration = (; peakppm, noiseppm, ppmwidth) triple is supplied — skips it and analyses that region directly, so a chosen region can be replayed from a script. Either way it returns a Vector{RegionResult}.
Any physics needed to interpret the acquisition parameters belongs here, in this file, beside the science that uses it — not in a shared loader.
Resolving a parameter
Every entry point resolves each parameter through the same chain, and @something is what expresses it, because it short-circuits: a question is only ever asked for a value that genuinely could not be found.
| Order | Source | Helper |
|---|---|---|
| 1 | An argument the caller passed | the keyword itself |
| 2 | A pulse-sequence annotation | annotation(spec, …), nothing when absent |
| 3 | A Bruker acquisition parameter | acqusvalue(spec, …), nothing when absent |
| 4 | A question, before the window opens | ask, askchoice, askvector, askpath |
The rule this encodes is that no analysis may depend on an annotation to run. An annotation saves the user from being asked; it is never the only way in. That matters because annotations exist only in pulse sequences written for them, and most of the data these routines will see was recorded on somebody else's sequence.
The prompting helpers live in src/prompts.jl, shared with the 2D analyses, and know nothing about NMR: which parameters an experiment needs, and what they mean, stays in that experiment's own file. Each takes a prompt flag which defaults to isinteractive(), so a script, a test or a Documenter build never blocks on stdin: with prompt=false a question becomes either its stated default or an ArgumentError naming the argument to pass instead.
Prompt only for what an experiment genuinely cannot determine. diffusion1d is the one routine that also offers what it did find for confirmation, because its parameters are both easy to get wrong and silent when they are.
2. Type
struct MyExperiment <: Experiment1D
dataset::Dataset1D
regions::Vector{Region}
model::SeriesModel
end
function MyExperiment(dataset::Dataset1D; regions=[defaultregion(dataset)])
return MyExperiment(dataset, collect(Region, regions), ExponentialModel())
endFields named dataset, regions and model are picked up by the default accessors, so no dataset(e)/regions(e)/seriesmodel(e) methods are needed. Override one only if your experiment computes it rather than storing it.
3. Interface
Only what differs from the defaults:
| hook | default | purpose |
|---|---|---|
dataset(e) | e.dataset | the data |
regions(e) | e.regions | initial integration regions |
integrate(region, e) | integration | region × planes → one quantity per plane |
seriesmodel(e) | e.model | what is fitted |
fitaxis(e) | required | the arrayed variable forming the x-axis |
groupcols(e) | () | variables that split planes into separate series |
postfit!(r, e) | none | quantities derived from one region's series |
postfitglobal!(results, e) | none | quantities spanning every region |
primaryparam(e) | :A | the headline quantity |
visualisationtype(e) | SeriesVisualisation() | how results are drawn |
saveextras!(e, results, folder) | writes nothing | further files to save, e.g. a figure |
A series is the set of planes sharing all grouping variables, differing only in the fit-axis. TRACT groups by :which (TROSY vs anti-TROSY); kinetics groups by :run when more than one is present.
4. Science
Any model used by only this experiment is defined here (RecoveryModel in expt-relaxation.jl, StejskalTannerModel in expt-diffusion.jl); a model shared by two or more lives in seriesmodels.jl, which currently means ExponentialModel alone, fitted by both relaxation and TRACT.
Derived quantities are computed in postfit! (one region) or postfitglobal! (the whole analysis) and recorded with setpost!:
function postfit!(r::RegionResult, ::MyExperiment)
setpost!(r, :halflife, log(2) / param(r, :R))
return nothing
endr is the whole region, its fitted parameters already named, so a quantity combining conditions is an ordinary postfit!: TRACT reads :R_trosy and :R_anti and writes τc beside them. Use postfitglobal! only for something spanning regions, such as a parameter fitted once across the whole analysis.
postfit! is also where an experiment does its own fitting when the series must be transformed first, rather than fitting the measured quantities directly — cest2d does this in GUI2D, normalising against its reference plane before fitting.
Store each derived quantity in the unit its label names (a 90° pulse in µs, τc in ns), so one stored number serves both the summary and results.csv. Keys are unique across all experiments — a symbol means one quantity in one unit everywhere — which is why TRACT's cross-correlated rate is :etaxy and diffusion's viscosity is :viscosity.
5. Presentation
windowtitle(::MyExperiment) = "My analysis"
resultlabels(::MyExperiment) = ("Delay / s", "Integrated intensity (a.u.)")
spectruminfo(::MyExperiment, vars::NamedTuple) = "$(round(vars.time; digits=3)) s delay"Optionally resultxfactor (display scaling for the x-axis) and seriesnames (a fixed legend, where the groups are few and consistently named).
Display names and units for this experiment's parameters belong here too, in a <EXPT>_PARAM_LABELS / <EXPT>_PARAM_UNITS pair with paramlabel / paramunit overridden to check it first. The shared PARAM_LABELS / PARAM_UNITS tables in visualisation.jl hold only what is genuinely true of every experiment: :A is "Amplitude" wherever it is fitted, and a rate is in s⁻¹ whoever fitted it. A symbol that appears in one experiment's file belongs in that file.
Results
Every experiment fills the same container, RegionResult — the 1D analogue of GUI2D's Peak. One region is one result, holding its series and one flat set of parameters: each series' fitted coefficients named apart by seriesname (:R_trosy, :R_anti) alongside whatever postfit! derived from them (:tauc).
There is no separate category of per-series result. A series is how a region was measured, not what is reported about it, so one region is one row of results.csv, and a quantity combining two conditions sits beside the two it came from rather than on either of them.
Because the container is uniform, everything downstream is generic: the summary text, the results panel and results.csv all read parameters without knowing which analysis produced it. analyse(expt) returns a Vector{RegionResult}.
Visualisation strategies
Presentation is a hierarchy orthogonal to Experiment1D, joined by visualisationtype(expt) — the same arrangement as GUI2D's VisualisationStrategy. A strategy implements three methods:
completeresultstate!(state, expt, ::V)— build the Observables the panel readsresultpanel!(gui, state, expt, ::V)— the live GUI panelplotresult!(ax, expt, result, label, i0, ::V)— the static export
Live and export share one data getter, so what is saved is what was on screen. A strategy's getter may return whatever shape suits it: only the three methods paired with it consume it.
SeriesVisualisation (points, error bars, fitted line, one colour per group) is the default and covers every experiment currently implemented. Declare a new strategy in your own expt-*.jl only if that shape genuinely will not do. A strategy can also be held as a field on the experiment, as GUI2D's IntensityExperiment does, if one experiment type needs to be presented two different ways.
Checklist for a new experiment
- Write
src/analysis1d/expt-<name>.jlfollowing the five sections. includeit at the foot ofexperiments.jl.- Export the entry point and the experiment type in
Analysis1D.jl, and re-export the entry point fromsrc/NMRAnalysis.jl. - Give any new parameter symbols display names and units in your own file's
<EXPT>_PARAM_LABELS/<EXPT>_PARAM_UNITS, not in the shared tables. - If it can run from a filename alone, register it in
Analysis1D.__init__soanalysedispatches to it.
Correspondence with GUI2D
| GUI2D | Analysis1D |
|---|---|
Experiment | Experiment1D |
Peak | Region (input) + RegionResult (output) |
SpecData | Planes + Dataset1D |
peak.parameters (per plane) | RegionResult.series (per series) |
peak.postparameters (per peak) | RegionResult.parameters (per region) |
postfit!(peak, expt) / postfitglobal!(expt) | postfit!(r, expt) / postfitglobal!(results, expt) |
primaryparam(expt) | primaryparam(expt) |
preparespecdata | each experiment's entry point + nmrdata.jl |
slicelabel(expt, idx) | spectruminfo(expt, vars) |
peakinfotext(expt, idx) | generic resultstext |
experimentinfo(expt) | experimentinfo(expt) (generic) |
VisualisationStrategy | ResultVisualisation |
visualisationtype / completestate! / makepeakplot! / plot_peak! | visualisationtype / completeresultstate! / resultpanel! / plotresult! |
files.jl (results.csv, peak round-trip) | files.jl (results.csv, region round-trip) |
clustering.jl, mask!, simulate! | — (no lineshape fitting in 1D) |
summaryplot (parameter vs residue) | — (no residue axis in 1D) |
Deliberate differences: 1D has no Parameter type (there is no packed least-squares vector, so a Measurement suffices), no fixed/moving split (regions never track between spectra), and models are values rather than a type hierarchy — which means a 1D model cannot carry its own postfit!, so derived science lives on the experiment.