Modelling MRS Spectra

A small language for writing down a spectrum, and an interpreter that draws what you wrote — on 1H, 2H, 13C, 19F, 23Na, 31P and 129Xe

“The spectrum is not the tissue. It is the shortest description of the tissue that a Fourier transform will accept.”

— what the fitting program is really telling you

Almost everything an in vivo spectrum does can be written as one line of arithmetic. The free induction decay is a sum of complex exponentials, each one a resonance: an amplitude, a frequency, a starting phase, and a decay. The spectrum is its Fourier transform. Line broadening, zero filling, phase correction, echo time, decoupling, the difference between a doublet and a triplet — all of them are operations on that sum, applied before the transform or after it.

The honest version of that sentence is that the sum is what survives the physics. Getting from a spin Hamiltonian to a list of frequencies and amplitudes is the hard part, and for strongly coupled spin systems it cannot be done on the back of an envelope. What this page models is the easy part, which happens to be the part that describes most of what an in vivo spectrum looks like.Writing the sum out by hand is tedious in a way that hides the structure. So this page contains a small domain specific language for declaring it — nucleus, field, a list of resonances with their shifts and widths and couplings, an acquisition, a processing chain — together with a complete interpreter for that language: lexer, parser, compiler, and a simulator that synthesises the time-domain signal and transforms it. Nothing is fetched, nothing is uploaded, and the program you write is the whole of the model.

The reason to do this across nuclei rather than for protons alone is that the interesting differences between 31P, 13C and 129Xe spectroscopy are almost entirely differences in three numbers, and a language makes that visible in a way a paragraph does not. The gyromagnetic ratio sets how many hertz a part per million is worth. The chemical shift range sets how many parts per million you have to spend. The coupling constants set whether a resonance arrives as one line or seven. Change those three things in the program below and a phosphorus spectrum becomes a carbon spectrum.

The workbench

Program ⌘/Ctrl + Enter to build
Spectrum drag across a region to integrate it
Free induction decay real and imaginary channels, with the apodisation envelope
Report everything below is measured from the simulated spectrum, not read back from the program
The time domain is the honest one. Truncation, apodisation and the dead time before the first sample are all obvious in the FID and all disguised in the spectrum, where they arrive as wiggles at the foot of a peak, as a linewidth you did not ask for, and as a baseline roll that looks like biology.

The examples in the menu

The language in one page

A program is a sequence of statements in any order. Every statement has a fixed number of arguments, so several may share a line — which is what makes a one-line pool declaration legible — and an expression can never run past the end of a line. Comments run from # to the end of the line, except where a # is followed immediately by three or six hexadecimal digits, which is a colour.

The one place the grammar is stricter than it looks: a statement taking two numbers, such as couple, line and axis, reads each of them as a single term rather than as arithmetic, so that line -0.03 0.5 is two arguments rather than one subtraction. Put brackets around anything more complicated than a name or a number.Numbers may carry a unit — ppm, Hz, kHz, ms, us, s, T, deg — and the unit decides how the number is read, so width 8 Hz and width 0.15 ppm are both linewidths and mean different things at different fields. Times default to seconds, shifts to ppm, and linewidths and couplings to hertz, but it costs nothing to write the unit and the examples always do.

nucleus 31P
field 3 T
sweep 40 ppm
center -5 ppm
points 2048
noise 0.08

param pH = 7.05 [6.2, 7.6]
param Pi_ppm = 3.29 + 2.39 / (1 + pow(10, 6.75 - pH))

pool PCr  { shift 0        amp 1.00  width 10 Hz }
pool Pi   { shift Pi_ppm   amp 0.33  width 10 Hz }
pool bATP { shift -16.26   amp 0.85  width 10 Hz  couple 2 16.5 Hz }

process { lb 2 Hz  zerofill 4 }

report {
  "PCr/ATP" = area(PCr) / area(bATP)
  SNR       = snr(PCr)
}

The β-ATP triplet is the cleanest demonstration in the language that a “peak” and a “resonance” are different objects. One pool, one amplitude, one chemical shift — and three lines, in a 1:2:1 ratio, because the β phosphate is coupled to two others. Its integral is unchanged by the splitting; only its height falls.Every pool is a chemical species or, more precisely, a set of chemically equivalent nuclei. It has a shift, an amplitude, a width, and optionally a list of couplings, each of which splits every line the pool has so far into a first-order multiplet with binomial intensities. The amplitude is the integral of the whole multiplet and is conserved by splitting, which is the point.

Acquisition

These statements stand on their own at the top level, or may be gathered inside an acquire { … } block, which changes nothing but the tidiness.

StatementMeaning
nucleus 31PThe observed nucleus: 1H 2H 13C 19F 23Na 31P 129Xe, or the names proton deuterium carbon fluorine sodium phosphorus xenon. Sets the gyromagnetic ratio and therefore the hertz-per-ppm of everything else.
field 3 TB0 in tesla.
center 4.7 ppmThe centre of the ppm axis, which is also the transmitter frequency and the zero of the hertz axis.
sweep 40 ppmSpectral width, in ppm, Hz or kHz. It sets the dwell time, so a resonance outside it does not vanish — it aliases, exactly as it would on the scanner.
points 2048Complex points acquired, rounded up to a power of two.
delay 0.5 msDead time between excitation and the first sample. The source of first-order phase and of rolling baselines.
te 144 msEcho time. Applies e−TE/T2 to pools that declare a t2, and modulates homonuclear multiplets.
tr 2 sRepetition time. With flip and a pool's t1 it applies the steady-state saturation factor.
flip 90 degExcitation flip angle, used only by the saturation factor.
noise 0.002Standard deviation of the complex Gaussian noise added to each point of the FID, in units of the amplitude scale. Zero for a noiseless model.
seed 11Seeds the noise, so a spectrum is reproducible to the pixel.
decouple 1H noe 1.8 if dCollapse every coupling to that nucleus and multiply pools by their NOE factor. The noe and if clauses are optional; if takes any expression, so a parameter can switch the decoupler.
jmod offSuppress J modulation at TE, leaving only the T2 weighting. On by default.
axis 10 -20 ppmThe displayed window, left edge first. Defaults to the whole sweep.
ymax 0.02Clip the vertical scale, in spectrum units. Indispensable when one peak is two orders of magnitude taller than the ones you care about.
param p = v [lo, hi]A named constant with a slider; the bounds are optional. A parameter whose value is a plain number is a knob. One computed from other parameters or from the acquisition — param lw = 5 + 1.8 * B0 — is derived: it gets no slider of its own and is recomputed every time anything it depends on moves.

Pools

A pool is a set of chemically equivalent nuclei — a metabolite, or one carbon of one. Everything inside is optional; an empty pool is a unit singlet at 0 ppm.

StatementMeaning
shift 4.85 ppmChemical shift. Hz is accepted and is read as an offset from the centre of the axis.
amp 0.22The integral of the whole pool, in arbitrary units. Splitting redistributes it; it never creates or destroys it.
width 9 HzFull width at half maximum of the Lorentzian part, which is to say an apparent T2* of 1/πΔ. ppm is accepted.
gauss 4 HzFWHM of a Gaussian part, multiplied into the same decay. With both, the line is a Voigt profile: Lorentzian in the wings, Gaussian in the shoulders, which is what shim imperfection actually produces.
t2 90 msThe true T2, used only for the e−TE/T2 weighting. Declaring it does not change the linewidth, because in vivo the linewidth is T2* and the two are not the same number.
t1 4 sUsed only by the tr/flip saturation factor.
phase 0 degA starting phase for this pool alone. Useful for the 129Xe membrane resonance, which is really two components with nearly opposite phase.
noe 1.8The factor applied when the relevant decoupler is on. Carbons with no attached proton get very little; this is why carbonyls are underestimated.
couple 2 16 HzFirst-order splitting by two equivalent partners: three lines at 1:2:1, spaced 16 Hz. Repeatable, so a doublet of doublets is two couple lines. Add to 1H to mark a coupling heteronuclear and therefore removable by a decoupler.
line -0.03 0.5 ppmAn explicit line at an offset from the pool centre with a relative amplitude, for multiplets that are not binomial. The first line replaces the implicit singlet; later couplings split each one.
label "…"The name used in the legend and on the peak label. Defaults to the pool's identifier.
color #a8402cIts colour in the component trace.

Processing

Everything in the process block happens after the signal exists and before it is drawn, which is the whole of the distinction between acquisition and post-processing.

StatementMeaning
lb 2 HzExponential apodisation. Adds exactly 2 Hz to every Lorentzian linewidth and improves SNR; the two facts are the same fact.
gb 3 HzGaussian apodisation. With a negative lb it is the Lorentz-to-Gauss resolution enhancement, which buys narrower lines with noise and wiggles.
window none|sine|hammingAn additional window over the acquisition, applied on top of lb and gb.
truncate 256Keep only the first n points, or, with ms, the first t milliseconds. The rest of the FID is set to zero, which is not the same as never having acquired it.
zerofill 2Pad the FID to this multiple of its length before transforming. Interpolates the lineshape; adds no information.
phase0 0 degZero-order phase correction, applied to every frequency equally.
phase1 0 degFirst-order phase correction, in degrees across the full sweep, applied linearly in frequency. What you use to undo a delay.
firstpoint 0.5The weight given to the first point of the FID. One half is the correct value and removes the constant baseline offset; set it to 1 to see the offset you are removing.
mode real|magnitude|imaginaryWhat is plotted. Magnitude mode needs no phasing and costs a factor near √2 in SNR and a good deal of resolution.

Report

The distinction between area() and integral() is the point of the whole block. The first asks the model what it put there; the second measures the drawn spectrum between two ppm values, the way a person with a cursor does. Where peaks overlap the two disagree, and the size of the disagreement is the quantification bias you would have had.A report block is a list of named expressions, evaluated against the finished spectrum and printed under the FID. An optional string after the expression is a unit label.

report {
  pH        = 6.75 + log10((shiftmax(3.5, 6.5) - 3.29) / (5.68 - shiftmax(3.5, 6.5)))
  "PCr/ATP" = area(PCr) / area(bATP)
  linewidth = fwhm(PCr) "Hz"
  SNR       = height(PCr) / rms(-25, -20)
}
FunctionValue
area(P)The integral of pool P's own contribution over the whole axis. With no processing losses it equals the declared amplitude.
height(P)The tallest point of that pool's contribution.
shift(P)Its declared centre, in ppm.
fwhm(P)Its linewidth in hertz, measured from the simulated lineshape after apodisation, not read back from width.
snr(P)Its height divided by the analytic noise standard deviation of the spectrum.
integral(a, b)The integral of the drawn spectrum between two ppm values, noise, neighbours, baseline and all.
peak(a, b)The largest value in that ppm range.
shiftmax(a, b)Where that largest value is, in ppm, refined to sub-bin precision by a parabolic fit. This is how you measure a shift rather than assume it.
rms(a, b)Root mean square of the spectrum in that range. Over a signal-free stretch it is an honest noise estimate.
noise()The analytic noise standard deviation, computed from the input noise and the apodisation.

Expressions

Guards, shifts, amplitudes and report lines are all ordinary arithmetic over parameters and a few read-only quantities. Comparisons yield 1 and 0; and, or and not work on those.

NameValue
b0, f0The field in tesla, and the Larmor frequency in MHz.
hzppmHertz per ppm, which is f0. The number that makes a carbon spectrum hard and a xenon spectrum easy.
sw, np, dt, tacqSweep width in Hz, points acquired, dwell time in seconds, total acquisition time in seconds.
df, nfft, centerHertz per point of the finished spectrum, the transform length after zero filling, and the centre of the ppm axis.
te, tr, piEcho and repetition time in seconds, and 3.14159…
min max abs floor ceil round sqrt exp log log10 sin cos tan pow clamp mod sgn ifThe usual arithmetic. if(c, a, b) evaluates both branches.

What one part per million is worth

Gyromagnetic ratios in MHz/T, with the sign dropped; 129Xe is negative, and the gas-phase value 11.777 is used rather than the bare-nucleus 11.860, because in vivo xenon work references itself to the gas peak in the same breath. Natural abundance is irrelevant for the hyperpolarised nuclei, where the polarisation is five orders of magnitude from thermal equilibrium and the isotope is enriched anyway.The chemical shift scale is defined in ppm precisely so that it does not depend on the field. The linewidth is not, and neither is the shim, and neither is anything else that happens in hertz. That mismatch is the whole of multinuclear spectroscopy's difficulty, and it is arithmetic:

NucleusSpinMHz/TAbundanceLarmor at 3 THz per ppm at 3 TRange in use
1H1/242.577599.99 %127.7 MHz127.7about 5 ppm, and water sits in the middle of it
2H16.53590.0115 %19.6 MHz19.63.5 ppm, which is 69 Hz — DMI is a shimming problem
13C1/210.70841.07 %32.1 MHz32.1170 ppm, but a 130 Hz one-bond coupling is 4 ppm of it
19F1/240.0775100 %120.2 MHz120.2hundreds of ppm, and no background signal at all
23Na3/211.2688100 %33.8 MHz33.8one line; the interest is in biexponential T2, not shift
31P1/217.235100 %51.7 MHz51.723 ppm from PE to β-ATP, or 1.2 kHz at 3 T
129Xe1/211.77726.4 %35.3 MHz35.3218 ppm of it, which is why the compartments resolve

Read the last two columns together. A 10 Hz line is 0.08 ppm on protons and 0.5 ppm on deuterium: the same shim, the same magnet, the same voxel, and a sixfold difference in what counts as resolved. 129Xe has the opposite luck. Its gyromagnetic ratio is poor, so a dissolved-phase line 150 Hz wide is a perfectly ordinary 4 ppm — and the red cell and membrane resonances are 20.6 ppm apart, which is 730 Hz at 3 T and separable by a one-point Dixon with no fitting at all. The nucleus with almost the worst sensitivity has almost the best-resolved spectrum, because its electron cloud is enormous and polarisable and its chemical shift range runs to thousands of ppm.

Which is also why decouple 1H is not a convenience in 13C work but a precondition. Without it, the 30.2 ppm lipid methylene arrives as a triplet 125 Hz wide and every carbon in the spectrum overlaps its neighbour's multiplet.Carbon is the awkward case in the other direction. Its 200 ppm range looks generous until you notice that the one-bond 13C–1H coupling is 125 to 170 Hz and does not shrink with field: at 3 T that splitting is 4 to 5 ppm wide, comparable to the entire separation between glutamate C4 and glutamine C4. Couplings are in hertz, shifts are in ppm, and going to a higher field is the only operation that improves the ratio between them.

Things worth trying

The examples are starting points rather than results. A few ways of pulling on them:

What this does not model

If you need a basis set to fit real data with, this is not the tool: use a density-matrix simulation of the actual pulse sequence — GAMMA, FID-A, Vespa, or the vendor basis sets built with them. This page is for understanding and for teaching, and for sketching what an experiment might look like before you book the magnet.The model is first-order throughout, and it is worth being explicit about what falls outside it.

The chemical shifts and couplings in the examples are literature central values, gathered for the periodic chart for MR on this site and reused here. They are the middle of a reported range, not a measurement, and several of them move with pH, temperature, oxygenation and tissue.

How the interpreter works

Roughly twelve hundred lines of ordinary JavaScript in the source of this page, with no dependencies and no build step. View source and read from function lex.The pipeline is the textbook one. A lexer turns the source into tokens, keeping line and column so that errors can point at something, and emitting newlines as tokens, because one statement per line is what lets couple 2 16 Hz be a statement rather than a four-argument ambiguity. It also recognises nucleus names, which is the one place the grammar has to cope with an identifier that starts with a digit. A recursive-descent parser reads the statements and hands arithmetic to a precedence climber, producing a syntax tree.

The tree is then compiled to closures: every expression becomes a JavaScript function of one argument, the evaluation context. Identifier resolution happens once, at compile time, which is where an undefined parameter or a misspelled pool name is caught. Pool names inside area() and its relatives are resolved to indices at the same moment.

The simulator then does the only physics in the page. It evaluates the acquisition in dependency order, expands every pool into a list of lines by applying each couple in turn, and synthesises the complex FID one pool at a time by direct summation of decaying exponentials. Noise is added to the sum alone, so each component trace stays clean and the total does not. Apodisation, truncation, the first-point weight and zero filling are applied to all of them identically, then each is transformed by an iterative radix-2 FFT and phased. Keeping a separate spectrum per pool costs a little memory and buys the component traces, the measured linewidths, and an honest answer to the question of how much of that peak was actually the metabolite you named.