Validate 2D Bruker reconstruction against TopSpin pdata

This example compares a 2D Bruker raw ser dataset processed in SpectroChemPy with the corresponding TopSpin processed reference stored in pdata/1.

It is primarily intended as a validation/inspection tool for hypercomplex 2D NMR encodings. When a richer local validation dataset is available, it is used automatically. Otherwise the example falls back to the public bundled TopSpin 2D dataset.

Requires the official spectrochempy-nmr plugin. Install with: pip install spectrochempy[nmr].

Import API

from pathlib import Path

import numpy as np

import spectrochempy as scp
from spectrochempy_nmr import Experiment


def _candidate_datasets():
    """Return validation candidates ordered from richest local data to public fallback."""
    home = Path.home()
    return [
        {
            "label": "Local Bruker validation dataset (eddy/151215/2)",
            "base": home / "Dropbox/F.STORAGE/bruker/data/eddy/nmr/151215",
            "expno": 2,
            "lb_f2": 2.0,
            "lb_f1": 2.0,
        },
        {
            "label": "Bundled TopSpin validation dataset",
            "base": scp.preferences.datadir
            / "nmrdata"
            / "bruker"
            / "tests"
            / "nmr"
            / "topspin_2d",
            "expno": 1,
            "lb_f2": 2.0,
            "lb_f1": 5.0,
        },
    ]


def _select_candidate():
    """Pick the first candidate that has both a raw SER and processed pdata."""
    for candidate in _candidate_datasets():
        expdir = candidate["base"] / str(candidate["expno"])
        if (expdir / "ser").exists() and (expdir / "pdata" / "1").exists():
            return candidate
    msg = "No Bruker validation dataset with both ser and pdata/1 was found."
    raise FileNotFoundError(msg)


def _magnitude(dataset):
    """Return magnitude for complex or quaternion datasets."""
    if dataset.dtype.kind == "V":
        import quaternion  # noqa: PLC0415

        return np.sqrt(np.sum(quaternion.as_float_array(dataset.data) ** 2, axis=-1))
    return np.abs(dataset.data)


def _peak_coords(dataset):
    """Return the coordinates of the strongest peak."""
    mag = _magnitude(dataset)
    idx = np.unravel_index(np.argmax(mag), mag.shape)
    return idx, float(dataset.y.data[idx[0]]), float(dataset.x.data[idx[1]])

Select a dataset

candidate = _select_candidate()
print(candidate["label"])
print(candidate["base"] / str(candidate["expno"]))
Bundled TopSpin validation dataset
/home/runner/.spectrochempy/testdata/nmrdata/bruker/tests/nmr/topspin_2d/1

Read the raw SER and the TopSpin processed reference

ser = scp.read_topspin(
    candidate["base"], expno=candidate["expno"], remove_digital_filter=True
)
reference = scp.read_topspin(candidate["base"], expno=candidate["expno"], procno=1)
/home/runner/work/spectrochempy/spectrochempy/plugins/spectrochempy-nmr/src/spectrochempy_nmr/extern/nmrglue/_bruker.py:441: UserWarning: (196608,)cannot be shaped into(147, 1024)
  _, data = read_binary(f, shape=shape, cplex=cplex, big=big, isfloat=isfloat)

Print a short summary

NDDataset [topspin_2d expno:1 procno:1 (SER)] — quaternion, shape: (y:96, x:948), count
name
:
topspin_2d expno:1 procno:1 (SER)
author
:
runner@runnervm3jd5f
created
:
2026-07-19 02:27:08+00:00
history
:
2026-07-19 02:27:08+00:00> Imported from TopSpin dataset
Data
title
:
intensity
values
:
RR[[ -0.2238 -0.1985 ... -0.1662 0.03262]
[-0.006566 0.0282 ... -0.02949 -0.06717]
...
[ 0 0 ... 0 0]
[ 0 0 ... 0 0]] count
RI[[ 0.06219 0.1467 ... 0.04565 0.03068]
[-0.05969 -0.08752 ... -0.05134 -0.05994]
...
[ -0 -0 ... -0 -0]
[ -0 -0 ... -0 -0]] count
IR[[ -0.1623 -0.0563 ... 0.02654 -0.01094]
[ 0.1344 -0.006515 ... -0.08239 0.00516]
...
[ 0 0 ... 0 0]
[ 0 0 ... 0 0]] count
II[[-0.003312 -0.001535 ... 0.02067 -0.08058]
[-0.05685 0.1174 ... 0.05831 -0.003414]
...
[ -0 -0 ... -0 -0]
[ -0 -0 ... -0 -0]] count
shape
:
(y:96(complex), x:948(complex))
Dimension `x`
size
:
948
title
:
F2 acquisition time
coordinates
:
[ 0 48 ... 4.541e+04 4.546e+04] µs
Dimension `y`
size
:
96
title
:
F1 acquisition time
coordinates
:
[ 0 74 ... 6956 7030] µs


NDDataset [topspin_2d expno:1 procno:1 (2D)] — quaternion, shape: (y:1024, x:2048), count
name
:
topspin_2d expno:1 procno:1 (2D)
author
:
runner@runnervm3jd5f
created
:
2026-07-19 02:27:08+00:00
history
:
2026-07-19 02:27:09+00:00> Imported from TopSpin dataset
Data
title
:
intensity
values
:
RR[[ 0.1001 0.1188 ... 0.009276 -0.03083]
[ 0.08574 0.1094 ... 0.02799 -0.01425]
...
[ 0.1134 0.1161 ... -0.03438 -0.06006]
[ 0.1091 0.1206 ... -0.01183 -0.04616]] count
RI[[ -0.1092 -0.07951 ... 0.1269 0.1117]
[ -0.1287 -0.1068 ... 0.1194 0.1155]
...
[-0.06948 -0.03049 ... 0.1292 0.09699]
[-0.08905 -0.05339 ... 0.1305 0.1055]] count
IR[[ 0.0913 0.06793 ... -0.1012 -0.118]
[ 0.08804 0.06219 ... -0.09448 -0.1108]
...
[ 0.09555 0.08065 ... -0.1116 -0.1259]
[ 0.09386 0.07434 ... -0.1068 -0.1229]] count
II[[-0.06389 -0.07123 ... 0.09588 0.09539]
[-0.07548 -0.07779 ... 0.1075 0.1114]
...
[-0.04044 -0.05341 ... 0.0732 0.06366]
[-0.05242 -0.06344 ... 0.08463 0.07955]] count
shape
:
(y:1024(complex), x:2048(complex))
Dimension `x`
size
:
2048
title
:
$\delta\ ^{27}Al$
coordinates
:
[ 96.79 96.7 ... -102.8 -102.9] ppm
Dimension `y`
size
:
1024
title
:
$\delta\ ^{31}P$
coordinates
:
[ 38.79 38.7 ... -44.52 -44.6] ppm


Process the raw SER in two stages

We match the TopSpin digital sizes using the reference spectrum shape.

f2_processed = Experiment(ser).process(
    apodization="em",
    lb=candidate["lb_f2"],
    size=reference.shape[1],
)
reconstructed = (
    f2_processed.zf_size(size=reference.shape[0], dim="y")
    .em(lb=candidate["lb_f1"], dim="y")
    .fft(dim="y")
)
/home/runner/work/spectrochempy/spectrochempy/src/spectrochempy/utils/numutils.py:29: RuntimeWarning: divide by zero encountered in log10
  n_decimals = sigdigits - int(np.floor(np.log10(abs(n)))) - 1
/home/runner/work/spectrochempy/spectrochempy/src/spectrochempy/core/dataset/coord.py:902: RuntimeWarning: divide by zero encountered in scalar divide
  (np.max(spacing) - np.min(spacing))

Compare strongest-peak positions

scp_peak, scp_y, scp_x = _peak_coords(reconstructed)
ref_peak, ref_y, ref_x = _peak_coords(reference)
print(f"SpectroChemPy peak: index={scp_peak}, y={scp_y:.3f}, x={scp_x:.3f}")
print(f"TopSpin peak:       index={ref_peak}, y={ref_y:.3f}, x={ref_x:.3f}")
SpectroChemPy peak: index=(np.int64(810), np.int64(584)), y=-27.241, x=39.814
TopSpin peak:       index=(np.int64(808), np.int64(580)), y=-27.078, x=40.204

Plot the reconstructed spectrum

_ = reconstructed.plot_map()
plot validate bruker 2d against pdata

Plot the TopSpin processed reference

_ = reference.plot_map()
plot validate bruker 2d against pdata

Compare a normalized F2 slice through the strongest peak

slice_y = ref_y
scp_slice = reconstructed[slice_y, :].normalize(method="max", dim="x")
ref_slice = reference[slice_y, :].normalize(method="max", dim="x")
scp_slice.title = "normalized intensity"
ref_slice.title = "normalized intensity"

_ = scp_slice.plot(color="black", ylabel="normalized intensity")
_ = ref_slice.plot(clear=False, color="red", linestyle="--")
slice_xlim = _.axes.get_xlim()
plot validate bruker 2d against pdata

Compare a normalized F1 slice through the strongest peak

scp_column = reconstructed[:, ref_x].squeeze().normalize(method="max", dim="y")
ref_column = reference[:, ref_x].squeeze().normalize(method="max", dim="y")
scp_column.title = "normalized intensity"
ref_column.title = "normalized intensity"

_ = scp_column.plot(color="black", ylabel="normalized intensity")
_ = ref_column.plot(clear=False, color="red", linestyle="--")
plot validate bruker 2d against pdata

Optional: simple manual phase touch-up for a closer visual overlay

The reconstruction above is intentionally shown as produced by the standard pipeline. If we want a closer visual match against the TopSpin reference, we can apply a small manual zero-order phase correction on F2 before comparing slices again. The exact value remains dataset-dependent.

reconstructed_phased = reconstructed.pk(phc0=30.0, phc1=0.0, dim="x", rel=True)

Compare the normalized F2 slice again after the manual phase touch-up

scp_slice_phased = reconstructed_phased[slice_y, :].normalize(method="max", dim="x")
scp_slice_phased.title = "normalized intensity"

_ = scp_slice_phased.plot(color="blue", ylabel="normalized intensity")
_ = ref_slice.plot(clear=False, color="red", linestyle="--", xlim=slice_xlim)
plot validate bruker 2d against pdata

Total running time of the script: (0 minutes 7.843 seconds)