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.

Visual Verification: FID Loader and Processing Pipeline

Goal: Visually verify the strict 1D-to-ND Bruker memory reshaping, physical coordinate calculation, and the newly refactored xmris processing pipeline. We will explicitly use xarray’s native plotting to ensure units and axis names are automatically and correctly resolved.

from pathlib import Path
import xarray as xr

from xmris.vendor.bruker import reshape_bruker_raw, build_fid
from xmris.core.config import DIMS, COORDS

# Configuration
xr.set_options(display_expand_data=False)
<xarray.core.options.set_options at 0x7f71c0353050>

1. Data Loading and Reshaping

Raw Bruker data is read as a continuous 1D array. To make it useful, we must reshape it into an N-dimensional C-contiguous array based on the acquisition parameters, and then wrap it in an xarray.DataArray with the correct physical coordinates.

DATA_DIR = Path("../../../tests/data/")
FILE_PATH = Path(DATA_DIR / "nspect_slab_1H" / "rawdatajob0.nc")

# Load the raw 1D complex data
raw_1d_data = xr.load_dataarray(FILE_PATH).xmr.to_complex()

# Reshape into C-contiguous N-dimensional numpy array
reshaped_nd, valid_dims = reshape_bruker_raw(raw_1d_data.values, raw_1d_data.attrs)

# Construct the FID DataArray
fid_xr = build_fid(reshaped_nd, valid_dims, raw_1d_data.attrs)

print(f"Constructed FID Shape: {fid_xr.shape}")
print(f"Assigned Attributes: {list(fid_xr.attrs.keys())}")
Reshaped Bruker data to dims: [ time | averages ]
Constructed FID Shape: (2048, 5)
Assigned Attributes: ['reference_frequency', 'carrier_ppm', 'bruker_group_delay', 'units']

2. Inspecting the Time Domain (FID)

To simplify our visual inspection, we will select the first repetition, channel, or average if the data is multi-dimensional. We then plot the Free Induction Decay (FID) to verify signal decay shape, complex quadrature, and the absence of truncation artifacts.

# Select a 1D slice for basic visual inspection if multi-dimensional
if fid_xr.ndim > 1:
    fid_inspect = fid_xr.isel({d: 0 for d in fid_xr.dims if d != DIMS.time})
else:
    fid_inspect = fid_xr

fig, ax = plt.subplots(figsize=(10, 4))

# Use xarray's native plotting to verify automatic labels
fid_inspect.real.plot(ax=ax, label="Real", alpha=0.8)
fid_inspect.imag.plot(ax=ax, label="Imaginary", alpha=0.8)

ax.set_title("Time Domain: Raw FID")
ax.legend()
ax.grid(True, alpha=0.3)

plt.show()
<Figure size 1500x600 with 1 Axes>

Removing the Digital Filter

Bruker spectrometers utilize digital filters that introduce a group delay at the beginning of the FID. We use the remove_digital_filter accessor to correct this, relying on the groupDelay attribute from the metadata.

# Remove group delay
fid_corrected = fid_inspect.xmr.remove_digital_filter(
    group_delay=raw_1d_data.attrs["groupDelay"],
    keep_length=False
)

fig, ax = plt.subplots(figsize=(10, 4))

fid_corrected.real.plot(ax=ax, label="Real", alpha=0.8, marker='.')
fid_corrected.imag.plot(ax=ax, label="Imaginary", alpha=0.8, marker='.')

ax.set_title("Time Domain: Corrected FID (Group Delay Removed)")
ax.legend()
ax.grid(True, alpha=0.3)

plt.show()
<Figure size 1500x600 with 1 Axes>

3. The xmris Spectral Pipeline

With the FID corrected, we can pass it through the xmris processing pipeline. Here, we apply an exponential apodization to smooth the signal, transform it to the frequency domain, and automatically correct the phase.

# Apply the strict xmris pipeline
spectrum = (
    fid_corrected
    # .xmr.apodize_exp(lb=2)
    .xmr.to_spectrum()
    .xmr.autophase()
)

spectrum
Loading...

Visualizing the Frequency Domain

We can visualize the resulting spectrum. Note how xarray automatically handles the coordinate labels. We invert the x-axis to adhere to standard NMR/MRI conventions.

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 10))

# --- Subplot 1: Real Component ---
spectrum.real.plot(ax=ax1, color="red", marker='.')
ax1.set_title("Frequency Domain (Real)")
ax1.grid(True, alpha=0.3)
ax1.xaxis.set_inverted(True)

# --- Inlay Plot for Subplot 1 (Top Left) ---
# [x, y, width, height] in normalized axes coordinates (0 to 1)
axins = ax1.inset_axes([0.05, 0.55, 0.35, 0.35])
spectrum.real.plot(ax=axins, color="red", marker='.')

# Set limits for the zoom (x-axis inverted to match the main plot)
axins.set_xlim(100, -100)
axins.set_ylim(-1e8, 0.5e9) # Keeping a slight negative baseline so the peak doesn't float

# Clean up inset labels so it doesn't clutter the main plot
axins.set_title('')
axins.set_xlabel('')
axins.set_yticks([0, 0.5e9])
axins.set_ylabel('')
axins.grid(True, alpha=0.3)

# Draw connecting lines from the inset to the zoomed region
# ax1.indicate_inset_zoom(axins, edgecolor="black", alpha=0.5)

# --- Subplot 2: Imaginary Component ---
spectrum.imag.plot(ax=ax2, color="navy", marker='.')
ax2.set_title("Frequency Domain (Imaginary)")
ax2.grid(True, alpha=0.3)
ax2.xaxis.set_inverted(True)

plt.tight_layout()
plt.show()
<Figure size 1500x1500 with 2 Axes>

4. Chemical Shift Conversion

For chemical analysis, viewing the spectrum in parts per million (ppm) is more useful than raw Hertz. The .xmr.to_ppm() accessor handles this conversion automatically using the spectrometer frequency metadata.

# Convert frequency to chemical shift (ppm)
spectrum_ppm = spectrum.xmr.to_ppm()

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 10))

# --- Subplot 1: Real Component ---
spectrum_ppm.real.plot(ax=ax1, color="red", marker='.')
ax1.set_title("Frequency Domain (Real)")
ax1.grid(True, alpha=0.3)
ax1.xaxis.set_inverted(True)

# --- Inlay Plot for Subplot 1 (Top Left) ---
# [x, y, width, height] in normalized axes coordinates (0 to 1)
axins = ax1.inset_axes([0.05, 0.55, 0.35, 0.35])
spectrum_ppm.real.plot(ax=axins, color="red", marker='.')

# Set limits for the zoom (x-axis inverted to match the main plot)
axins.set_xlim(5.7, 3.7)
axins.set_ylim(-1e8, 0.5e9) # Keeping a slight negative baseline so the peak doesn't float

# Clean up inset labels so it doesn't clutter the main plot
axins.set_title('')
axins.set_xlabel('')
axins.set_yticks([0, 0.5e9])
axins.set_ylabel('')
axins.grid(True, alpha=0.3)

# Draw connecting lines from the inset to the zoomed region
# ax1.indicate_inset_zoom(axins, edgecolor="black", alpha=0.5)

# --- Subplot 2: Imaginary Component ---
spectrum_ppm.imag.plot(ax=ax2, color="navy", marker='.')
ax2.set_title("Frequency Domain (Imaginary)")
ax2.grid(True, alpha=0.3)
ax2.xaxis.set_inverted(True)

plt.tight_layout()
plt.show()
<Figure size 1500x1500 with 2 Axes>

5. Reverse Transformation

The xmris accessors preserve metadata and coordinates perfectly, allowing seamless reverse transformations. We can convert our processed spectrum back into the time domain using .xmr.to_fid().

# Inverse Fourier Transform back to Time Domain
fid_final = spectrum.xmr.to_fid()

fig, ax = plt.subplots(figsize=(10, 4))

fid_final.real.plot(ax=ax, label="Real", alpha=0.8, marker='.')
fid_final.imag.plot(ax=ax, label="Imaginary", alpha=0.8, marker='.')

ax.set_title("Time Domain: Reconstructed FID")
ax.legend()
ax.grid(True, alpha=0.3)

plt.show()
<Figure size 1500x600 with 1 Axes>
# --- Configuration: Easy Adjustments ---
view_cfg = {
    "zoom_percent": 0.05,    # Window size (5% of total FID)
    "padding_percent": 0.05  # 5% padding relative to the local data range
}

# 1. Transform back to Time Domain
fid_final = spectrum.xmr.to_fid()

# 2. Setup the 2x2 grid with shared axes for decluttering
# sharex='col' -> Top and Bottom share the same time window
# sharey='row' -> Left and Right share the same amplitude scale
fig, axes = plt.subplots(2, 2, figsize=(11, 7), sharex='col')
(ax_real_start, ax_real_end), (ax_imag_start, ax_imag_end) = axes

# --- Logic: Calculate Time Windows ---
t_coords = fid_final[fid_final.dims[0]]
t_min, t_max = float(t_coords.min()), float(t_coords.max())
t_total = t_max - t_min
window_len = t_total * view_cfg["zoom_percent"]

# Define the data slices for plotting
start_slice = fid_final.sel({fid_final.dims[0]: slice(t_min, t_min + window_len)})
end_slice = fid_final.sel({fid_final.dims[0]: slice(t_max - window_len, t_max)})

# --- Helper to calculate padded limits ---
def get_padded_limits(data, pad_frac):
    d_min, d_max = float(data.min()), float(data.max())
    d_range = d_max - d_min
    padding = (d_range * pad_frac) if d_range > 0 else 0.1
    return d_min - padding, d_max + padding

# --- Execute Plotting ---
# Row 1: Real
start_slice.real.plot(ax=ax_real_start, color="C0", marker='.', markersize=4, linewidth=0.5)
end_slice.real.plot(ax=ax_real_end, color="C0", marker='.', markersize=4, linewidth=0.5)

# Row 2: Imaginary
start_slice.imag.plot(ax=ax_imag_start, color="C1", marker='.', markersize=4, linewidth=0.5)
end_slice.imag.plot(ax=ax_imag_end, color="C1", marker='.', markersize=4, linewidth=0.5)

# --- Apply Dynamic Padding & Scaling ---
# X-Axis Padding (shared by columns)
ax_real_start.set_xlim(t_min - (window_len * view_cfg["padding_percent"]), t_min + window_len)
ax_real_end.set_xlim(t_max - window_len, t_max + (window_len * view_cfg["padding_percent"]))

# Y-Axis Padding (shared by rows)
# We calculate the padding based on the "Start" slice as it's the dominant signal
ax_real_start.set_ylim(get_padded_limits(start_slice.real, view_cfg["padding_percent"]))
ax_imag_start.set_ylim(get_padded_limits(start_slice.imag, view_cfg["padding_percent"]))

# --- Final Decluttering ---
titles = ["Real (Start)", "Real (End)", "Imaginary (Start)", "Imaginary (End)"]
for i, ax in enumerate(axes.flat):
    ax.set_title(titles[i], fontweight='bold', fontsize=10)
    ax.grid(True, alpha=0.2, linestyle='--')

    # Remove xarray's default auto-labels to keep it clean
    ax.set_xlabel('')
    ax.set_ylabel('')

# Add unified labels for the whole figure
fig.text(0.5, 0.02, f'Time [{fid_final.coords[fid_final.dims[0]].attrs.get("units", "s")}]', ha='center')
fig.text(0.04, 0.5, 'Amplitude [a.u.]', va='center', rotation='vertical')

plt.tight_layout(rect=[0.05, 0.05, 1, 1]) # Make room for unified labels
plt.show()
<Figure size 1650x1050 with 4 Axes>
# --- Configuration: Easy Adjustments ---
view_cfg = {
    "zoom_percent": 0.05,    # Window size (5% of total FID)
    "padding_percent": 0.05  # 5% padding relative to the local data range
}

# 1. Transform back to Time Domain
fid_final_apo = fid_final.xmr.apodize_exp(lb=5)

# 2. Setup the 2x2 grid with shared axes for decluttering
# sharex='col' -> Top and Bottom share the same time window
# sharey='row' -> Left and Right share the same amplitude scale
fig, axes = plt.subplots(2, 2, figsize=(11, 7), sharex='col')
(ax_real_start, ax_real_end), (ax_imag_start, ax_imag_end) = axes

# --- Logic: Calculate Time Windows ---
t_coords = fid_final_apo[fid_final_apo.dims[0]]
t_min, t_max = float(t_coords.min()), float(t_coords.max())
t_total = t_max - t_min
window_len = t_total * view_cfg["zoom_percent"]

# Define the data slices for plotting
start_slice = fid_final_apo.sel({fid_final_apo.dims[0]: slice(t_min, t_min + window_len)})
end_slice = fid_final_apo.sel({fid_final_apo.dims[0]: slice(t_max - window_len, t_max)})

# --- Helper to calculate padded limits ---
def get_padded_limits(data, pad_frac):
    d_min, d_max = float(data.min()), float(data.max())
    d_range = d_max - d_min
    padding = (d_range * pad_frac) if d_range > 0 else 0.1
    return d_min - padding, d_max + padding

# --- Execute Plotting ---
# Row 1: Real
start_slice.real.plot(ax=ax_real_start, color="C0", marker='.', markersize=4, linewidth=0.5)
end_slice.real.plot(ax=ax_real_end, color="C0", marker='.', markersize=4, linewidth=0.5)

# Row 2: Imaginary
start_slice.imag.plot(ax=ax_imag_start, color="C1", marker='.', markersize=4, linewidth=0.5)
end_slice.imag.plot(ax=ax_imag_end, color="C1", marker='.', markersize=4, linewidth=0.5)

# --- Apply Dynamic Padding & Scaling ---
# X-Axis Padding (shared by columns)
ax_real_start.set_xlim(t_min - (window_len * view_cfg["padding_percent"]), t_min + window_len)
ax_real_end.set_xlim(t_max - window_len, t_max + (window_len * view_cfg["padding_percent"]))

# Y-Axis Padding (shared by rows)
# We calculate the padding based on the "Start" slice as it's the dominant signal
ax_real_start.set_ylim(get_padded_limits(start_slice.real, view_cfg["padding_percent"]))
ax_imag_start.set_ylim(get_padded_limits(start_slice.imag, view_cfg["padding_percent"]))

# --- Final Decluttering ---
titles = ["Real (Start)", "Real (End)", "Imaginary (Start)", "Imaginary (End)"]
for i, ax in enumerate(axes.flat):
    ax.set_title(titles[i], fontweight='bold', fontsize=10)
    ax.grid(True, alpha=0.2, linestyle='--')

    # Remove xarray's default auto-labels to keep it clean
    ax.set_xlabel('')
    ax.set_ylabel('')

# Add unified labels for the whole figure
fig.text(0.5, 0.02, f'Time [{fid_final_apo.coords[fid_final_apo.dims[0]].attrs.get("units", "s")}]', ha='center')
fig.text(0.04, 0.5, 'Amplitude [a.u.]', va='center', rotation='vertical')

plt.tight_layout(rect=[0.05, 0.05, 1, 1]) # Make room for unified labels
plt.show()
<Figure size 1650x1050 with 4 Axes>