Skip to content

Spectra

Spectrum

Spectrum

Spectrum(data, uc)

A 2D spectrum loaded from a Sparky .ucsf file: the data array plus its per-axis unit-conversion objects (for ppm <-> point conversions).

Attributes:

Name Type Description
data ndarray

The intensity array (axis 0 = indirect/N, axis 1 = direct/H).

uc list

One nmrglue unit-conversion object per axis.

Source code in makeshift/spectra/spectrum.py
def __init__(self, data, uc):
    self.data = data
    self.uc = uc

from_ucsf classmethod

from_ucsf(path)

Read a Sparky .ucsf file into a Spectrum.

Source code in makeshift/spectra/spectrum.py
@classmethod
def from_ucsf(cls, path):
    """Read a Sparky ``.ucsf`` file into a Spectrum."""
    dic, data = ng.sparky.read(str(path))
    data = data.astype(float)
    uc = [ng.sparky.make_uc(dic, data, dim=i) for i in range(data.ndim)]
    return cls(data, uc)

estimate_background

estimate_background(n=1000, seed=42)

Noise-floor estimate: median absolute intensity over n randomly sampled points. Deterministic for a given seed.

Source code in makeshift/spectra/spectrum.py
def estimate_background(self, n=1000, seed=42):
    """
    Noise-floor estimate: median absolute intensity over ``n`` randomly
    sampled points. Deterministic for a given ``seed``.
    """
    return estimate_background(self.data, n=n, seed=seed)

pick_peaks

pick_peaks(baseline=10, algorithm='downward', est_params=True, h_ppm_min=6.0, h_ppm_max=11.0)

Pick peaks in the amide region of this 2D ¹H-¹⁵N spectrum.

Peaks above baseline × the noise floor (estimated within the ¹H window) are picked with nmrglue.

Parameters:

Name Type Description Default
baseline float

Threshold multiple of the noise floor.

10
algorithm str

nmrglue peak-picking algorithm ('downward' or 'connected').

'downward'
est_params bool

Have nmrglue estimate linewidths/volumes.

True
h_ppm_min float

¹H window to search (ppm).

6.0
h_ppm_max float

¹H window to search (ppm).

6.0

Returns:

Type Description
DataFrame

One row per picked peak with columns N_axis, H_axis, N_lw, H_lw, est_vol, cid, N_ppm, H_ppm. est_vol is the picker's estimate — treat as approximate (the lineshape fit refines it).

Source code in makeshift/spectra/spectrum.py
def pick_peaks(self, baseline=10, algorithm="downward", est_params=True,
               h_ppm_min=6.0, h_ppm_max=11.0):
    """
    Pick peaks in the amide region of this 2D ¹H-¹⁵N spectrum.

    Peaks above ``baseline`` × the noise floor (estimated within the ¹H
    window) are picked with nmrglue.

    Parameters
    ----------
    baseline : float
        Threshold multiple of the noise floor.
    algorithm : str
        nmrglue peak-picking algorithm ('downward' or 'connected').
    est_params : bool
        Have nmrglue estimate linewidths/volumes.
    h_ppm_min, h_ppm_max : float
        ¹H window to search (ppm).

    Returns
    -------
    DataFrame
        One row per picked peak with columns N_axis, H_axis, N_lw, H_lw,
        est_vol, cid, N_ppm, H_ppm. ``est_vol`` is the picker's estimate —
        treat as approximate (the lineshape fit refines it).
    """
    data = np.asarray(self.data, dtype=float)
    ppm_h = self.uc[1].ppm_scale()
    cols = np.where((ppm_h >= h_ppm_min) & (ppm_h <= h_ppm_max))[0]
    col_start, col_stop = int(cols[0]), int(cols[-1]) + 1
    data_slice = data[:, col_start:col_stop]

    threshold = baseline * estimate_background(data_slice)
    peaks_table = ng.analysis.peakpick.pick(
        data_slice, pthres=threshold, nthres=None,
        algorithm=algorithm, est_params=est_params,
        diag=False, edge=(0, col_start), table=True,
    )
    return _annotate_ppm(peaks_table, self.uc)

ppm

ppm(axis, point)

Convert a point index on axis to ppm.

Source code in makeshift/spectra/spectrum.py
def ppm(self, axis, point):
    """Convert a point index on ``axis`` to ppm."""
    return self.uc[axis].ppm(point)

Peak-list matching

map_peaklists

map_peaklists(left, right, offset=None, tol=1, label_cols=('assn_label', 'assn_label'), how='inner')

Align two peak lists in (H_ppm, N_ppm) and match them one-to-one.

Align two peak lists in (H_ppm, N_ppm) and match them one-to-one.

right (e.g. a reference assignment list) is shifted by a translation offset — grid-searched if offset is None — then Hungarian-matched to left (treated as fixed) within tolerance. Right-side peaks that lost a close match to a competitor are flagged in a conflict column.

Parameters:

Name Type Description Default
left DataFrame

Peak tables with H_ppm/N_ppm columns (pass a PeakList's .data). left is fixed; right is shifted onto it.

required
right DataFrame

Peak tables with H_ppm/N_ppm columns (pass a PeakList's .data). left is fixed; right is shifted onto it.

required
offset (float, float) or None

(Δ¹H, Δ¹⁵N) applied to right; grid-searched if None.

None
tol float

Multiplier on the default matching tolerances (0.03, 0.3) ppm.

1
label_cols (str, str)

Label columns on (left, right); either may be None.

('assn_label', 'assn_label')
how (inner, left, right, outer)

pandas-merge semantics for left_out; right_mapped is always full.

"inner"

Returns:

Name Type Description
left_out DataFrame

Matched peaks with the right label + conflict, filtered per how.

right_mapped DataFrame

Full right peaklist after the offset (for plotting).

Source code in makeshift/spectra/matching.py
def map_peaklists(left, right, offset=None,
                  tol=1, label_cols=("assn_label", "assn_label"),
                  how="inner"):
    """
    Align two peak lists in (H_ppm, N_ppm) and match them one-to-one.

    Align two peak lists in (H_ppm, N_ppm) and match them one-to-one.

    `right` (e.g. a reference assignment list) is shifted by a translation
    offset — grid-searched if `offset` is None — then Hungarian-matched to
    `left` (treated as fixed) within tolerance. Right-side peaks that lost a
    close match to a competitor are flagged in a `conflict` column.

    Parameters
    ----------
    left, right : DataFrame
        Peak tables with H_ppm/N_ppm columns (pass a PeakList's `.data`).
        `left` is fixed; `right` is shifted onto it.
    offset : (float, float) or None
        (Δ¹H, Δ¹⁵N) applied to `right`; grid-searched if None.
    tol : float
        Multiplier on the default matching tolerances (0.03, 0.3) ppm.
    label_cols : (str, str)
        Label columns on (left, right); either may be None.
    how : {"inner", "left", "right", "outer"}
        pandas-merge semantics for `left_out`; `right_mapped` is always full.

    Returns
    -------
    left_out : DataFrame
        Matched peaks with the right label + `conflict`, filtered per `how`.
    right_mapped : DataFrame
        Full right peaklist after the offset (for plotting).
    """
    left_label, right_label = label_cols
    tol_h, tol_n = 0.03 * tol, 0.3 * tol

    #  offset 
    if offset is not None:
        best_dh, best_dn = float(offset[0]), float(offset[1])
        print(f'  Using provided offset: Δ¹H = {best_dh:+.3f}, '
              f'Δ¹⁵N = {best_dn:+.3f} ppm')
    else:
        best_dh, best_dn = _find_offset(left, right, (tol_h, tol_n))

    #  shift and match 
    right_mapped = right.copy()
    right_mapped['H_ppm'] += best_dh
    right_mapped['N_ppm'] += best_dn

    # use a temporary label column internally if the right side has none
    tmp_label = right_label or '_right_index'
    if right_label is None:
        right_mapped[tmp_label] = right_mapped.index

    matched = _match_hungarian(right_mapped, left,
                               tol_h=tol_h, tol_n=tol_n,
                               h_col='H_ppm', n_col='N_ppm')

    #  conflict detection 
    matched = _detect_conflicts(right_mapped, left, matched, (tol_h, tol_n), tmp_label)

    #  build updated left 
    out, right_mapped = _apply_how(left, right_mapped, matched, how, right_label, tmp_label)

    if right_label is None:
        right_mapped = right_mapped.drop(columns=[tmp_label])

    n_assigned = matched['matched'].sum()
    n_conflict = matched['conflict'].sum()
    print(f'  {n_assigned}/{len(right)} peaks in right peaklist assigned  |  '
          f'{n_conflict} flagged for inspection')

    return out, right_mapped

Plotting

plot_spectrum

plot_spectrum(ref_data, contour_levels=8, cmap='plasma', contour_color=None, linewidth=0.8, vmax_scale=0.5, xlim=None, ylim=None, baseline=10, figsize=(8, 7), ax=None)

Plot 2D spectrum contours.

Use plot_peaklist to overlay peak markers/labels on the returned axes

Source code in makeshift/spectra/plotting.py
def plot_spectrum(ref_data, 
                  contour_levels=8, 
                  cmap="plasma",
                  contour_color=None, 
                  linewidth=0.8, 
                  vmax_scale=0.5,
                  xlim=None, ylim=None, 
                  baseline=10,
                  figsize=(8, 7),
                  ax=None):
    """
    Plot 2D spectrum contours.

    Use `plot_peaklist` to overlay peak markers/labels on the returned axes 
    """
    uc = ref_data.uc
    data = ref_data.data
    ppm_w1 = uc[0].ppm_scale()
    ppm_w2 = uc[1].ppm_scale()
    vmax   = data.max() * vmax_scale
    noise  = estimate_background(data)
    levels = np.logspace(np.log10(noise * baseline), np.log10(vmax),
                         contour_levels)

    if ax is None:
        fig, ax = plt.subplots(figsize=figsize)
    else:
        fig = ax.get_figure()

    if contour_color is not None:
        ax.contour(ppm_w2, ppm_w1, data, levels,
                   colors=contour_color, linewidths=linewidth, alpha=0.6)
    else:
        ax.contour(ppm_w2, ppm_w1, data, levels, cmap=cmap, linewidths=linewidth)

    ax.set_xlabel(r"$^1$H (ppm)", fontsize=14)
    ax.set_ylabel(r"$^{15}$N (ppm)", fontsize=14)

    # fix if axis labels are ordered small->large
    if xlim is not None and xlim[0]<xlim[1]:
        xlim=(xlim[1],xlim[0])
    if ylim is not None and ylim[0]<ylim[1]:
        ylim=(ylim[1],ylim[0])

    ax.set_xlim(xlim if xlim else (ppm_w2.max(), ppm_w2.min()))
    ax.set_ylim(ylim if ylim else (ppm_w1.max(), ppm_w1.min()))
    return fig, ax

plot_peaklist

plot_peaklist(peaks_df=None, ax=None, marker='x', peaks_xcol='H_ppm', peaks_ycol='N_ppm', color='limegreen', markersize=3, text='assn_label', label_fontsize=6, label=None, hue=None, palette=None, figsize=(8, 6))

Plot peak markers (and optional labels), optionally onto an existing axes.

Parameters:

Name Type Description Default
peaks_df DataFrame

Peaks to plot.

None
ax matplotlib Axes or None

Axes to plot onto. If None, a new figure is created with inverted axes (H on x, N on y, both increasing toward origin as in NMR convention).

None
markersize float

Marker size for peak positions.

3
text str or None

Column in peaks_df to use for per-peak text annotations. Pass None to suppress these.

'assn_label'
label str or None

Legend label for this peaklist. When hue is None, passed straight to ax.plot (and a legend is drawn if set). When hue is set, this is ignored — each hue group gets its own legend entry instead.

None
hue str or None

Column in peaks_df to colour peaks by. When set, each unique value gets its own colour from palette, markers are grouped in the legend, and annotation text is coloured to match.

None
palette dict or None

Maps hue values to matplotlib colours. Values absent from the dict fall back to 'gray'. Ignored when hue is None.

None
Source code in makeshift/spectra/plotting.py
def plot_peaklist(peaks_df=None,
                  ax=None,
                  marker="x",
                  peaks_xcol="H_ppm", 
                  peaks_ycol="N_ppm",
                  color="limegreen", 
                  markersize=3,
                  text="assn_label",
                  label_fontsize=6,
                  label=None,
                  hue=None, 
                  palette=None,
                  figsize=(8, 6)):
    """
    Plot peak markers (and optional labels), optionally onto an existing axes.

    Parameters
    ----------
    peaks_df : DataFrame
        Peaks to plot.
    ax : matplotlib Axes or None
        Axes to plot onto. If None, a new figure is created with inverted axes
        (H on x, N on y, both increasing toward origin as in NMR convention).
    markersize : float
        Marker size for peak positions.
    text : str or None
        Column in peaks_df to use for per-peak text annotations. Pass None
        to suppress these.
    label : str or None
        Legend label for this peaklist. When hue is None, passed straight to
        ax.plot (and a legend is drawn if set). When hue is set, this is
        ignored — each hue group gets its own legend entry instead.
    hue : str or None
        Column in peaks_df to colour peaks by. When set, each unique value gets
        its own colour from `palette`, markers are grouped in the legend, and
        annotation text is coloured to match. 
    palette : dict or None
        Maps hue values to matplotlib colours. Values absent from the dict
        fall back to 'gray'. Ignored when hue is None.
    """
    def _label_text(val):
        try:
            return str(int(val))
        except (ValueError, TypeError):
            return str(val)

    standalone = ax is None
    if standalone:
        fig, ax = plt.subplots(figsize=figsize)
        ax.invert_xaxis()
        ax.invert_yaxis()
        ax.set_xlabel("$^1$H (ppm)")
        ax.set_ylabel("$^{15}$N (ppm)")
    else:
        fig = ax.get_figure()

    if peaks_df is None or peaks_xcol not in peaks_df.columns or peaks_ycol not in peaks_df.columns:
        return (fig, ax) if standalone else ax

    if not standalone:
        xlim = ax.get_xlim()
        ylim = ax.get_ylim()
        x_lo, x_hi = min(xlim), max(xlim)
        y_lo, y_hi = min(ylim), max(ylim)
        in_view = (peaks_df[peaks_xcol].between(x_lo, x_hi)
                   & peaks_df[peaks_ycol].between(y_lo, y_hi))
        peaks_df = peaks_df[in_view]

    if hue is not None and hue in peaks_df.columns:
        if palette is None:
            default_colors = sns.color_palette("tab10")
            pal = {val: default_colors[i % len(default_colors)]
                   for i, val in enumerate(peaks_df[hue].unique())}
        else:
            pal = palette
        for group_val, grp in peaks_df.groupby(hue):
            color = pal.get(group_val, "gray")
            ax.plot(grp[peaks_xcol], grp[peaks_ycol],
                    marker, color=color, ms=markersize, mew=1.2, zorder=5,
                    label=f"{hue}: {group_val}")
            if text is not None and text in grp.columns:
                for _, row in grp.iterrows():
                    ax.text(row[peaks_xcol] - 0.02, row[peaks_ycol] - 0.2,
                            _label_text(row[text]), color=color,
                            fontsize=label_fontsize, zorder=6,
                            ha="left", va="bottom")
        ax.legend(loc="upper left", fontsize=11)
    else:
        ax.plot(peaks_df[peaks_xcol], peaks_df[peaks_ycol],
                marker, color=color, ms=markersize, mew=1.2, zorder=5,
                label=label)
        if text is not None and text in peaks_df.columns:
            for _, row in peaks_df.iterrows():
                ax.text(row[peaks_xcol] - 0.02, row[peaks_ycol] - 0.2,
                        _label_text(row[text]), color=color,
                        fontsize=label_fontsize, zorder=6,
                        ha="left", va="bottom")
        if label is not None:
            ax.legend(loc="upper left", fontsize=11)

    return (fig, ax) if standalone else ax

plot_csp

plot_csp(peaks_df1, peaks_df2, on, xcol='H_ppm', ycol='N_ppm', color1='steelblue', color2='tab:orange', line_color='gray', line_alpha=0.5, marker='o', markersize=4, text=None, label_fontsize=6, hue=None, palette=None, ax=None, figsize=(8, 6))

Plot two matched peaklists and draw connecting lines between paired peaks.

Parameters:

Name Type Description Default
peaks_df1 DataFrame

The two peaklists to compare (e.g. apo and holo, or two conditions).

required
peaks_df2 DataFrame

The two peaklists to compare (e.g. apo and holo, or two conditions).

required
on str or list of str

Column(s) to merge on — the shared identifier between the two peaklists (e.g. 'assn_label', 'Seq_ID', or ['chain', 'Seq_ID']).

required
xcol str

Column names for H and N chemical shifts in both DataFrames.

'H_ppm'
ycol str

Column names for H and N chemical shifts in both DataFrames.

'H_ppm'
color1 str

Marker colours for peaks_df1 and peaks_df2 respectively. Ignored when hue is set.

'steelblue'
color2 str

Marker colours for peaks_df1 and peaks_df2 respectively. Ignored when hue is set.

'steelblue'
line_color (str, float)

Style for the connecting lines.

'gray'
line_alpha (str, float)

Style for the connecting lines.

'gray'
marker str

Matplotlib marker string applied to peaks_df1. peaks_df2 is always drawn with a triangle marker ("^").

'o'
markersize float
4
text str or None

Column in peaks_df1 to use for per-peak annotations. Pass None to suppress.

None
label_fontsize int
6
hue str or None

Column in peaks_df1 to colour matched pairs by.

None
palette dict, str, list, or None

Colour mapping for hue values. A dict maps values straight to colours Ignored when hue is None.

None
ax matplotlib Axes or None

Axes to plot onto. Creates a new figure if None.

None
figsize tuple
(8, 6)
Source code in makeshift/spectra/plotting.py
def plot_csp(peaks_df1, peaks_df2, on,
             xcol="H_ppm",
             ycol="N_ppm",
             color1="steelblue",
             color2="tab:orange",
             line_color="gray",
             line_alpha=0.5,
             marker="o",
             markersize=4,
             text=None,
             label_fontsize=6,
             hue=None,
             palette=None,
             ax=None,
             figsize=(8, 6)):
    """
    Plot two matched peaklists and draw connecting lines between paired peaks.

    Parameters
    ----------
    peaks_df1, peaks_df2 : DataFrame
        The two peaklists to compare (e.g. apo and holo, or two conditions).
    on : str or list of str
        Column(s) to merge on — the shared identifier between the two
        peaklists (e.g. 'assn_label', 'Seq_ID', or ['chain', 'Seq_ID']).
    xcol, ycol : str
        Column names for H and N chemical shifts in both DataFrames.
    color1, color2 : str
        Marker colours for peaks_df1 and peaks_df2 respectively. Ignored when
        hue is set.
    line_color, line_alpha : str, float
        Style for the connecting lines.
    marker : str
        Matplotlib marker string applied to peaks_df1. peaks_df2 is always
        drawn with a triangle marker ("^").
    markersize : float
    text : str or None
        Column in peaks_df1 to use for per-peak annotations. Pass None to suppress.
    label_fontsize : int
    hue : str or None
        Column in peaks_df1 to colour matched pairs by.
    palette : dict, str, list, or None
        Colour mapping for hue values. A dict maps values straight to
        colours Ignored when hue is None.
    ax : matplotlib Axes or None
        Axes to plot onto. Creates a new figure if None.
    figsize : tuple

    """
    standalone = ax is None
    if standalone:
        fig, ax = plt.subplots(figsize=figsize)
        ax.invert_xaxis()
        ax.invert_yaxis()
        ax.set_xlabel("$^1$H (ppm)")
        ax.set_ylabel("$^{15}$N (ppm)")
    else:
        fig = ax.get_figure()

    merged = peaks_df1.merge(peaks_df2, on=on, suffixes=("_1", "_2"))
    xcol1 = xcol + "_1" if xcol + "_1" in merged.columns else xcol
    ycol1 = ycol + "_1" if ycol + "_1" in merged.columns else ycol
    xcol2 = xcol + "_2" if xcol + "_2" in merged.columns else xcol
    ycol2 = ycol + "_2" if ycol + "_2" in merged.columns else ycol
    hue_col = None
    if hue is not None:
        hue_col = hue + "_1" if hue + "_1" in merged.columns else hue

    for _, row in merged.iterrows():
        ax.plot([row[xcol1], row[xcol2]], [row[ycol1], row[ycol2]],
                color=line_color, alpha=line_alpha, linewidth=0.8, zorder=2)

    if hue_col is not None and hue_col in merged.columns:
        categories = (peaks_df1[hue] if hue in peaks_df1.columns else merged[hue_col])
        categories = sorted(categories.dropna().unique())
        pal = _resolve_palette(palette, categories)
        for group_val, grp in merged.groupby(hue_col):
            color = pal.get(group_val, "gray")
            ax.plot(grp[xcol1], grp[ycol1],
                    marker, color=color, ms=markersize, mew=1.2, zorder=3,
                    label=f"{hue}: {group_val}")
            ax.plot(grp[xcol2], grp[ycol2],
                    "^", color=color, ms=markersize, mew=1.2, zorder=3)
            if text is not None and text in grp.columns:
                for _, row in grp.iterrows():
                    ax.text(row[xcol1] - 0.02, row[ycol1] - 0.2,
                            str(row[text]), color=color,
                            fontsize=label_fontsize, zorder=4,
                            ha="left", va="bottom")
        ax.legend(loc="upper left", fontsize=11)
    else:
        ax.plot(merged[xcol1], merged[ycol1],
                marker, color=color1, ms=markersize, mew=1.2, zorder=3, label="peaks_df1")
        ax.plot(merged[xcol2], merged[ycol2],
                "^", color=color2, ms=markersize, mew=1.2, zorder=3, label="peaks_df2")

        if text is not None and text in merged.columns:
            for _, row in merged.iterrows():
                ax.text(row[xcol1] - 0.02, row[ycol1] - 0.2,
                        str(row[text]), color=color1,
                        fontsize=label_fontsize, zorder=4,
                        ha="left", va="bottom")

    return fig, ax