nmrmetaproc 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nmrmetaproc/__init__.py +32 -0
- nmrmetaproc/__main__.py +5 -0
- nmrmetaproc/alignment.py +137 -0
- nmrmetaproc/cli.py +199 -0
- nmrmetaproc/io.py +185 -0
- nmrmetaproc/normalization.py +123 -0
- nmrmetaproc/processing.py +387 -0
- nmrmetaproc/processor.py +422 -0
- nmrmetaproc/qc.py +218 -0
- nmrmetaproc/utils.py +86 -0
- nmrmetaproc/version.py +4 -0
- nmrmetaproc-1.0.0.dist-info/METADATA +202 -0
- nmrmetaproc-1.0.0.dist-info/RECORD +17 -0
- nmrmetaproc-1.0.0.dist-info/WHEEL +5 -0
- nmrmetaproc-1.0.0.dist-info/entry_points.txt +2 -0
- nmrmetaproc-1.0.0.dist-info/licenses/LICENSE +21 -0
- nmrmetaproc-1.0.0.dist-info/top_level.txt +1 -0
nmrmetaproc/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Silence scipy.optimize.fmin convergence output used internally by nmrglue
|
|
2
|
+
import scipy.optimize as _scipy_opt
|
|
3
|
+
_scipy_opt_fmin_orig = _scipy_opt.fmin
|
|
4
|
+
def _fmin_silent(fn, x0, *args, **kwargs):
|
|
5
|
+
kwargs.setdefault("disp", False)
|
|
6
|
+
return _scipy_opt_fmin_orig(fn, x0, *args, **kwargs)
|
|
7
|
+
_scipy_opt.fmin = _fmin_silent
|
|
8
|
+
del _scipy_opt # clean up namespace
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
nmrmetaproc: NMR Metabolomics Spectral Processor
|
|
12
|
+
=================================================
|
|
13
|
+
A Python package for processing raw Bruker NMR FID files into
|
|
14
|
+
analysis-ready spectral matrices for metabolomics studies.
|
|
15
|
+
|
|
16
|
+
Author: Folorunsho Bright Omage
|
|
17
|
+
ORCID: https://orcid.org/0000-0002-9750-5034
|
|
18
|
+
Email: omagefolorunsho@gmail.com
|
|
19
|
+
License: MIT
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from nmrmetaproc.version import __version__, __author__, __email__, __orcid__
|
|
23
|
+
from nmrmetaproc.processor import NMRProcessor, ProcessingResults
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"__version__",
|
|
27
|
+
"__author__",
|
|
28
|
+
"__email__",
|
|
29
|
+
"__orcid__",
|
|
30
|
+
"NMRProcessor",
|
|
31
|
+
"ProcessingResults",
|
|
32
|
+
]
|
nmrmetaproc/__main__.py
ADDED
nmrmetaproc/alignment.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spectral alignment algorithms.
|
|
3
|
+
|
|
4
|
+
Two strategies are available:
|
|
5
|
+
1. Reference-peak alignment: shift each spectrum so a chosen reference
|
|
6
|
+
peak (e.g. TSP) aligns exactly. Fast and interpretable.
|
|
7
|
+
2. Icoshift-style correlation-optimized shifting: maximise cross-
|
|
8
|
+
correlation with a reference (or mean) spectrum over a defined
|
|
9
|
+
shifting window. More robust for complex metabolite regions.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Maximum shift window (in number of bins) for icoshift-style alignment
|
|
22
|
+
DEFAULT_MAX_SHIFT: int = 50
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Reference-peak alignment
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def align_to_reference_peak(
|
|
30
|
+
spectra: np.ndarray,
|
|
31
|
+
ppm_axis: np.ndarray,
|
|
32
|
+
ref_ppm: float,
|
|
33
|
+
search_width: float = 0.1,
|
|
34
|
+
) -> Tuple[np.ndarray, List[int]]:
|
|
35
|
+
"""Shift each spectrum so the tallest peak near *ref_ppm* lands exactly there.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
spectra:
|
|
40
|
+
2-D array (n_samples x n_points).
|
|
41
|
+
ppm_axis:
|
|
42
|
+
1-D ppm axis.
|
|
43
|
+
ref_ppm:
|
|
44
|
+
Target chemical shift for the reference peak.
|
|
45
|
+
search_width:
|
|
46
|
+
Half-width of the search window in ppm.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
aligned:
|
|
51
|
+
Shifted spectra (same shape as input).
|
|
52
|
+
shifts:
|
|
53
|
+
Integer shift applied to each spectrum (in data-point units).
|
|
54
|
+
"""
|
|
55
|
+
from nmrmetaproc.utils import ppm_range_to_slice, ppm_to_index
|
|
56
|
+
|
|
57
|
+
lo = ref_ppm - search_width
|
|
58
|
+
hi = ref_ppm + search_width
|
|
59
|
+
start, stop = ppm_range_to_slice(ppm_axis, lo, hi)
|
|
60
|
+
target_idx = ppm_to_index(ppm_axis, ref_ppm)
|
|
61
|
+
|
|
62
|
+
aligned = np.zeros_like(spectra)
|
|
63
|
+
shifts: List[int] = []
|
|
64
|
+
|
|
65
|
+
for i, sp in enumerate(spectra):
|
|
66
|
+
region = sp[start:stop]
|
|
67
|
+
if region.size == 0:
|
|
68
|
+
aligned[i] = sp
|
|
69
|
+
shifts.append(0)
|
|
70
|
+
continue
|
|
71
|
+
peak_local = int(np.argmax(region))
|
|
72
|
+
peak_global = start + peak_local
|
|
73
|
+
shift = target_idx - peak_global
|
|
74
|
+
aligned[i] = np.roll(sp, shift)
|
|
75
|
+
shifts.append(int(shift))
|
|
76
|
+
|
|
77
|
+
return aligned, shifts
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Icoshift-style cross-correlation alignment
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def icoshift_align(
|
|
85
|
+
spectra: np.ndarray,
|
|
86
|
+
reference: Optional[np.ndarray] = None,
|
|
87
|
+
max_shift: int = DEFAULT_MAX_SHIFT,
|
|
88
|
+
) -> Tuple[np.ndarray, List[int]]:
|
|
89
|
+
"""Correlation-optimized spectral alignment inspired by icoshift.
|
|
90
|
+
|
|
91
|
+
Aligns each spectrum to *reference* (or the column-mean if None) by
|
|
92
|
+
finding the integer shift that maximises normalised cross-correlation,
|
|
93
|
+
within a window of [-max_shift, +max_shift] data points.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
spectra:
|
|
98
|
+
2-D array (n_samples x n_points).
|
|
99
|
+
reference:
|
|
100
|
+
Target spectrum. If None, the column-mean is used.
|
|
101
|
+
max_shift:
|
|
102
|
+
Maximum allowed shift in data points.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
aligned:
|
|
107
|
+
Aligned spectra.
|
|
108
|
+
shifts:
|
|
109
|
+
Integer shift applied to each spectrum.
|
|
110
|
+
"""
|
|
111
|
+
if reference is None:
|
|
112
|
+
reference = np.mean(spectra, axis=0)
|
|
113
|
+
|
|
114
|
+
ref_norm = _normalise_for_xcorr(reference)
|
|
115
|
+
aligned = np.zeros_like(spectra)
|
|
116
|
+
shifts: List[int] = []
|
|
117
|
+
|
|
118
|
+
for i, sp in enumerate(spectra):
|
|
119
|
+
sp_norm = _normalise_for_xcorr(sp)
|
|
120
|
+
xcorr = np.correlate(sp_norm, ref_norm, mode="full")
|
|
121
|
+
n = len(sp)
|
|
122
|
+
lags = np.arange(-(n - 1), n)
|
|
123
|
+
|
|
124
|
+
# Restrict to allowed window
|
|
125
|
+
mask = np.abs(lags) <= max_shift
|
|
126
|
+
best_lag = lags[mask][int(np.argmax(xcorr[mask]))]
|
|
127
|
+
aligned[i] = np.roll(sp, -int(best_lag))
|
|
128
|
+
shifts.append(int(-best_lag))
|
|
129
|
+
|
|
130
|
+
return aligned, shifts
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _normalise_for_xcorr(arr: np.ndarray) -> np.ndarray:
|
|
134
|
+
"""Zero-mean, unit-norm normalisation."""
|
|
135
|
+
a = arr - arr.mean()
|
|
136
|
+
norm = np.linalg.norm(a)
|
|
137
|
+
return a / norm if norm > 0 else a
|
nmrmetaproc/cli.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for nmrmetaproc.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, module="scipy")
|
|
14
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, module="nmrglue")
|
|
15
|
+
|
|
16
|
+
from nmrmetaproc import __version__, __author__, __orcid__
|
|
17
|
+
from nmrmetaproc.io import find_sample_dirs
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_regions(regions_str: str) -> List[Tuple[float, float]]:
|
|
21
|
+
"""Parse comma-separated ppm range string like '4.5-5.0,0.0-0.5'."""
|
|
22
|
+
result = []
|
|
23
|
+
for part in regions_str.split(","):
|
|
24
|
+
part = part.strip()
|
|
25
|
+
if not part:
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
lo_str, hi_str = part.split("-", 1)
|
|
29
|
+
result.append((float(lo_str), float(hi_str)))
|
|
30
|
+
except ValueError:
|
|
31
|
+
print(f" Warning: could not parse region '{part}', skipping.", file=sys.stderr)
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_process(args: argparse.Namespace) -> int:
|
|
36
|
+
"""Run the full processing pipeline."""
|
|
37
|
+
from nmrmetaproc.processor import NMRProcessor
|
|
38
|
+
|
|
39
|
+
extra_regions = []
|
|
40
|
+
if args.exclude_regions:
|
|
41
|
+
extra_regions = _parse_regions(args.exclude_regions)
|
|
42
|
+
|
|
43
|
+
processor = NMRProcessor(
|
|
44
|
+
lb=args.lb,
|
|
45
|
+
bin_width=args.bin_width,
|
|
46
|
+
normalization=args.normalization,
|
|
47
|
+
ppm_range=(args.ppm_min, args.ppm_max),
|
|
48
|
+
exclude_regions_extra=extra_regions if extra_regions else None,
|
|
49
|
+
snr_threshold=args.snr_threshold,
|
|
50
|
+
linewidth_threshold=args.linewidth_threshold,
|
|
51
|
+
align=args.align,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
results = processor.process(args.data_dir)
|
|
55
|
+
|
|
56
|
+
output_dir = Path(args.output)
|
|
57
|
+
results.save(output_dir)
|
|
58
|
+
|
|
59
|
+
print(f"\nOutput:")
|
|
60
|
+
if not results.spectral_matrix.empty:
|
|
61
|
+
nr, nc = results.spectral_matrix.shape
|
|
62
|
+
print(f" ? {output_dir}/spectral_matrix.csv ({nr} samples × {nc} bins)")
|
|
63
|
+
print(f" ? {output_dir}/qc_report.csv")
|
|
64
|
+
print(f" ? {output_dir}/acquisition_parameters.csv")
|
|
65
|
+
print(f" ? {output_dir}/processing_log.txt")
|
|
66
|
+
|
|
67
|
+
return 0 if results.n_passed > 0 else 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_qc(args: argparse.Namespace) -> int:
|
|
71
|
+
"""Quick QC-only scan (no full processing)."""
|
|
72
|
+
from nmrmetaproc.io import read_fid
|
|
73
|
+
from nmrmetaproc.processing import (
|
|
74
|
+
apodize_exponential, auto_phase, baseline_als,
|
|
75
|
+
build_ppm_axis, fourier_transform, zero_fill,
|
|
76
|
+
)
|
|
77
|
+
from nmrmetaproc.qc import evaluate_sample
|
|
78
|
+
import pandas as pd
|
|
79
|
+
|
|
80
|
+
data_dir = Path(args.data_dir)
|
|
81
|
+
output_dir = Path(args.output)
|
|
82
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
|
|
84
|
+
sample_dirs = find_sample_dirs(data_dir)
|
|
85
|
+
if not sample_dirs:
|
|
86
|
+
print("No valid FID directories found.")
|
|
87
|
+
return 1
|
|
88
|
+
|
|
89
|
+
print(f"nmrmetaproc v{__version__} — QC scan")
|
|
90
|
+
print(f"Found {len(sample_dirs)} samples\n")
|
|
91
|
+
|
|
92
|
+
rows = []
|
|
93
|
+
for sdir in sample_dirs:
|
|
94
|
+
sid = sdir.name
|
|
95
|
+
try:
|
|
96
|
+
fid, params = read_fid(sdir)
|
|
97
|
+
sw_hz = float(params.get("SW_h", params.get("SW", 20.0) * float(params.get("SFO1", 600.0))))
|
|
98
|
+
fid = apodize_exponential(fid, lb=0.3, sw=sw_hz)
|
|
99
|
+
fid = zero_fill(fid)
|
|
100
|
+
spec = fourier_transform(fid)
|
|
101
|
+
ppm = build_ppm_axis(params, len(spec))
|
|
102
|
+
spec_r = auto_phase(spec)
|
|
103
|
+
spec_r = baseline_als(spec_r)
|
|
104
|
+
qc = evaluate_sample(spec_r, ppm, sid, sw_hz)
|
|
105
|
+
status = "PASS" if qc.passed else "FAIL"
|
|
106
|
+
warn = "; ".join(qc.warnings)
|
|
107
|
+
print(f" {sid:<30s} {status} SNR={qc.snr:.1f} LW={qc.linewidth_hz:.2f}Hz {warn}")
|
|
108
|
+
rows.append({
|
|
109
|
+
"sample_id": sid,
|
|
110
|
+
"snr": qc.snr,
|
|
111
|
+
"linewidth_hz": qc.linewidth_hz,
|
|
112
|
+
"water_suppression_score": qc.water_suppression_score,
|
|
113
|
+
"passed": qc.passed,
|
|
114
|
+
"warnings": warn,
|
|
115
|
+
})
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
print(f" {sid:<30s} ERROR: {exc}")
|
|
118
|
+
rows.append({"sample_id": sid, "passed": False, "warnings": str(exc)})
|
|
119
|
+
|
|
120
|
+
pd.DataFrame(rows).to_csv(output_dir / "qc_report.csv", index=False)
|
|
121
|
+
print(f"\nQC report saved to {output_dir}/qc_report.csv")
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_info(args: argparse.Namespace) -> int:
|
|
126
|
+
"""Show available samples."""
|
|
127
|
+
data_dir = Path(args.data_dir)
|
|
128
|
+
sample_dirs = find_sample_dirs(data_dir)
|
|
129
|
+
|
|
130
|
+
print(f"nmrmetaproc v{__version__} — Data Info")
|
|
131
|
+
print(f"Root: {data_dir}\n")
|
|
132
|
+
|
|
133
|
+
if not sample_dirs:
|
|
134
|
+
print("No valid FID directories found.")
|
|
135
|
+
return 1
|
|
136
|
+
|
|
137
|
+
print(f"Found {len(sample_dirs)} samples:")
|
|
138
|
+
for sdir in sample_dirs:
|
|
139
|
+
size = (sdir / "fid").stat().st_size
|
|
140
|
+
print(f" {sdir.name:<40s} FID size: {size:>10,} bytes")
|
|
141
|
+
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
146
|
+
parser = argparse.ArgumentParser(
|
|
147
|
+
prog="nmrmetaproc",
|
|
148
|
+
description=(
|
|
149
|
+
f"nmrmetaproc v{__version__} — NMR Metabolomics Spectral Processor\n"
|
|
150
|
+
f"Author: {__author__} (ORCID: {__orcid__})"
|
|
151
|
+
),
|
|
152
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
153
|
+
)
|
|
154
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
155
|
+
|
|
156
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
157
|
+
sub.required = True
|
|
158
|
+
|
|
159
|
+
# --- process ---
|
|
160
|
+
p_proc = sub.add_parser("process", help="Full spectral processing pipeline")
|
|
161
|
+
p_proc.add_argument("data_dir", metavar="DATA_DIR", help="Root directory with Bruker FID data")
|
|
162
|
+
p_proc.add_argument("--output", "-o", default="./results", metavar="DIR", help="Output directory (default: ./results)")
|
|
163
|
+
p_proc.add_argument("--lb", type=float, default=0.3, metavar="HZ", help="Line broadening in Hz (default: 0.3)")
|
|
164
|
+
p_proc.add_argument("--bin-width", type=float, default=0.01, metavar="PPM", dest="bin_width", help="Bin width in ppm (default: 0.01)")
|
|
165
|
+
p_proc.add_argument("--normalization", choices=["pqn", "total", "tsp", "none"], default="pqn", help="Normalisation method (default: pqn)")
|
|
166
|
+
p_proc.add_argument("--ppm-min", type=float, default=0.5, dest="ppm_min", help="Lower ppm limit (default: 0.5)")
|
|
167
|
+
p_proc.add_argument("--ppm-max", type=float, default=9.5, dest="ppm_max", help="Upper ppm limit (default: 9.5)")
|
|
168
|
+
p_proc.add_argument("--snr-threshold", type=float, default=10.0, dest="snr_threshold", help="Minimum SNR to pass QC (default: 10)")
|
|
169
|
+
p_proc.add_argument("--linewidth-threshold", type=float, default=2.5, dest="linewidth_threshold", help="Max TSP linewidth Hz to pass QC (default: 2.5)")
|
|
170
|
+
p_proc.add_argument("--exclude-regions", metavar="RANGES", dest="exclude_regions", help="Additional ppm regions to exclude, e.g. '4.5-5.0,0.0-0.5'")
|
|
171
|
+
p_proc.add_argument("--align", choices=["icoshift", "reference", "none"], default="icoshift", help="Spectral alignment method (default: icoshift)")
|
|
172
|
+
p_proc.set_defaults(func=cmd_process)
|
|
173
|
+
|
|
174
|
+
# --- qc ---
|
|
175
|
+
p_qc = sub.add_parser("qc", help="QC scan only (no full processing)")
|
|
176
|
+
p_qc.add_argument("data_dir", metavar="DATA_DIR")
|
|
177
|
+
p_qc.add_argument("--output", "-o", default="./qc_results", metavar="DIR")
|
|
178
|
+
p_qc.set_defaults(func=cmd_qc)
|
|
179
|
+
|
|
180
|
+
# --- info ---
|
|
181
|
+
p_info = sub.add_parser("info", help="List available samples")
|
|
182
|
+
p_info.add_argument("data_dir", metavar="DATA_DIR")
|
|
183
|
+
p_info.set_defaults(func=cmd_info)
|
|
184
|
+
|
|
185
|
+
return parser
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def main() -> None:
|
|
189
|
+
parser = build_parser()
|
|
190
|
+
args = parser.parse_args()
|
|
191
|
+
|
|
192
|
+
if hasattr(args, "align") and args.align == "none":
|
|
193
|
+
args.align = None
|
|
194
|
+
|
|
195
|
+
sys.exit(args.func(args))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|
nmrmetaproc/io.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
I/O module: reading Bruker FID data and writing output files.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import struct
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Bruker FID reader
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
def find_sample_dirs(root: Path) -> List[Path]:
|
|
23
|
+
"""Walk *root* and return directories that contain both ``fid`` and ``acqus``.
|
|
24
|
+
|
|
25
|
+
Bruker raw data typically lives in numbered experiment directories
|
|
26
|
+
(e.g. ``sample01/1/``, ``sample01/10/``). We accept any layout as long
|
|
27
|
+
as both files are present.
|
|
28
|
+
"""
|
|
29
|
+
hits: List[Path] = []
|
|
30
|
+
for p in sorted(root.rglob("fid")):
|
|
31
|
+
if (p.parent / "acqus").exists():
|
|
32
|
+
hits.append(p.parent)
|
|
33
|
+
return hits
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_acqus(acqus_path: Path) -> Dict[str, Any]:
|
|
37
|
+
"""Parse a Bruker ``acqus`` parameter file.
|
|
38
|
+
|
|
39
|
+
Returns a flat dict with parameter names (without leading ``$``) as keys.
|
|
40
|
+
Multiline arrays are stored as lists.
|
|
41
|
+
"""
|
|
42
|
+
params: Dict[str, Any] = {}
|
|
43
|
+
current_key: Optional[str] = None
|
|
44
|
+
array_buf: List[str] = []
|
|
45
|
+
|
|
46
|
+
with open(acqus_path, "r", encoding="latin-1") as fh:
|
|
47
|
+
for raw_line in fh:
|
|
48
|
+
line = raw_line.strip()
|
|
49
|
+
if not line or line.startswith("$$"):
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if line.startswith("##$"):
|
|
53
|
+
# flush previous array if any
|
|
54
|
+
if current_key and array_buf:
|
|
55
|
+
params[current_key] = _parse_value(" ".join(array_buf))
|
|
56
|
+
array_buf = []
|
|
57
|
+
# parse new key
|
|
58
|
+
rest = line[3:]
|
|
59
|
+
if "=" in rest:
|
|
60
|
+
key, val = rest.split("=", 1)
|
|
61
|
+
current_key = key.strip()
|
|
62
|
+
val = val.strip()
|
|
63
|
+
if val.startswith("("):
|
|
64
|
+
# array opener - may continue on next lines
|
|
65
|
+
array_buf = [val]
|
|
66
|
+
else:
|
|
67
|
+
params[current_key] = _parse_value(val)
|
|
68
|
+
current_key = None
|
|
69
|
+
elif current_key:
|
|
70
|
+
array_buf.append(line)
|
|
71
|
+
|
|
72
|
+
# flush any trailing array
|
|
73
|
+
if current_key and array_buf:
|
|
74
|
+
params[current_key] = _parse_value(" ".join(array_buf))
|
|
75
|
+
|
|
76
|
+
return params
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_value(val: str) -> Any:
|
|
80
|
+
"""Try to cast a string value to int, float, or leave as str."""
|
|
81
|
+
val = val.strip()
|
|
82
|
+
try:
|
|
83
|
+
return int(val)
|
|
84
|
+
except ValueError:
|
|
85
|
+
pass
|
|
86
|
+
try:
|
|
87
|
+
return float(val)
|
|
88
|
+
except ValueError:
|
|
89
|
+
pass
|
|
90
|
+
# strip angle-bracket strings like <PROTON>
|
|
91
|
+
if val.startswith("<") and val.endswith(">"):
|
|
92
|
+
return val[1:-1]
|
|
93
|
+
return val
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_fid(sample_dir: Path) -> Tuple[np.ndarray, Dict[str, Any]]:
|
|
97
|
+
"""Read a Bruker FID file and return the complex time-domain signal.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
sample_dir:
|
|
102
|
+
Directory containing ``fid`` and ``acqus``.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
fid_data:
|
|
107
|
+
Complex 1-D numpy array (time-domain FID).
|
|
108
|
+
params:
|
|
109
|
+
Parsed acquisition parameters from ``acqus``.
|
|
110
|
+
|
|
111
|
+
Raises
|
|
112
|
+
------
|
|
113
|
+
FileNotFoundError
|
|
114
|
+
If expected files are missing.
|
|
115
|
+
ValueError
|
|
116
|
+
If byte order or data type cannot be determined.
|
|
117
|
+
"""
|
|
118
|
+
fid_path = sample_dir / "fid"
|
|
119
|
+
acqus_path = sample_dir / "acqus"
|
|
120
|
+
|
|
121
|
+
if not fid_path.exists():
|
|
122
|
+
raise FileNotFoundError(f"fid not found: {fid_path}")
|
|
123
|
+
if not acqus_path.exists():
|
|
124
|
+
raise FileNotFoundError(f"acqus not found: {acqus_path}")
|
|
125
|
+
|
|
126
|
+
params = read_acqus(acqus_path)
|
|
127
|
+
|
|
128
|
+
# Determine byte order
|
|
129
|
+
dtypa = params.get("DTYPA", 0) # 0=int32, 2=float64
|
|
130
|
+
bytorda = params.get("BYTORDA", 0) # 0=little, 1=big
|
|
131
|
+
|
|
132
|
+
if bytorda == 0:
|
|
133
|
+
endian = "<"
|
|
134
|
+
else:
|
|
135
|
+
endian = ">"
|
|
136
|
+
|
|
137
|
+
if dtypa == 2:
|
|
138
|
+
dtype = np.dtype(f"{endian}f8") # float64
|
|
139
|
+
else:
|
|
140
|
+
dtype = np.dtype(f"{endian}i4") # int32
|
|
141
|
+
|
|
142
|
+
raw = np.frombuffer(fid_path.read_bytes(), dtype=dtype).astype(np.float64)
|
|
143
|
+
|
|
144
|
+
# Interleaved real/imag
|
|
145
|
+
if len(raw) % 2 != 0:
|
|
146
|
+
raw = raw[:-1]
|
|
147
|
+
fid = raw[0::2] + 1j * raw[1::2]
|
|
148
|
+
|
|
149
|
+
return fid, params
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Output writers
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def save_results(
|
|
157
|
+
output_dir: Path,
|
|
158
|
+
spectral_matrix: pd.DataFrame,
|
|
159
|
+
qc_report: pd.DataFrame,
|
|
160
|
+
acq_params: pd.DataFrame,
|
|
161
|
+
log_text: str,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Write all output files to *output_dir*.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
output_dir:
|
|
168
|
+
Destination directory (created if absent).
|
|
169
|
+
spectral_matrix:
|
|
170
|
+
Rows = samples, columns = ppm bins.
|
|
171
|
+
qc_report:
|
|
172
|
+
One row per sample with QC metrics.
|
|
173
|
+
acq_params:
|
|
174
|
+
Acquisition parameters table.
|
|
175
|
+
log_text:
|
|
176
|
+
Full processing log as a single string.
|
|
177
|
+
"""
|
|
178
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
|
|
180
|
+
spectral_matrix.to_csv(output_dir / "spectral_matrix.csv")
|
|
181
|
+
qc_report.to_csv(output_dir / "qc_report.csv", index=False)
|
|
182
|
+
acq_params.to_csv(output_dir / "acquisition_parameters.csv", index=False)
|
|
183
|
+
|
|
184
|
+
log_path = output_dir / "processing_log.txt"
|
|
185
|
+
log_path.write_text(log_text, encoding="utf-8")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Normalization strategies for NMR spectral matrices.
|
|
3
|
+
|
|
4
|
+
Supported methods:
|
|
5
|
+
pqn — Probabilistic Quotient Normalization (Dieterle et al. 2006)
|
|
6
|
+
total — Total-area (sum) normalization
|
|
7
|
+
tsp — TSP-peak reference normalization
|
|
8
|
+
none — No normalization (return as-is)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Literal, Optional
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
NormMethod = Literal["pqn", "total", "tsp", "none"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize(
|
|
25
|
+
matrix: np.ndarray,
|
|
26
|
+
method: NormMethod = "pqn",
|
|
27
|
+
tsp_bin_index: Optional[int] = None,
|
|
28
|
+
) -> np.ndarray:
|
|
29
|
+
"""Normalise a spectral matrix.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
matrix:
|
|
34
|
+
2-D array (n_samples x n_bins). All values should be >= 0.
|
|
35
|
+
method:
|
|
36
|
+
Normalisation method ('pqn', 'total', 'tsp', 'none').
|
|
37
|
+
tsp_bin_index:
|
|
38
|
+
Column index of the TSP reference peak bin (required for method='tsp').
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
np.ndarray
|
|
43
|
+
Normalised matrix (same shape).
|
|
44
|
+
|
|
45
|
+
Raises
|
|
46
|
+
------
|
|
47
|
+
ValueError
|
|
48
|
+
If method is unknown or tsp_bin_index is missing when method='tsp'.
|
|
49
|
+
"""
|
|
50
|
+
if method == "none":
|
|
51
|
+
return matrix.copy()
|
|
52
|
+
if method == "total":
|
|
53
|
+
return _total_area(matrix)
|
|
54
|
+
if method == "pqn":
|
|
55
|
+
return _pqn(matrix)
|
|
56
|
+
if method == "tsp":
|
|
57
|
+
if tsp_bin_index is None:
|
|
58
|
+
raise ValueError("tsp_bin_index must be provided for method='tsp'")
|
|
59
|
+
return _tsp_reference(matrix, tsp_bin_index)
|
|
60
|
+
raise ValueError(f"Unknown normalisation method: {method!r}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _total_area(matrix: np.ndarray) -> np.ndarray:
|
|
64
|
+
"""Divide each sample by its total spectral area."""
|
|
65
|
+
totals = matrix.sum(axis=1, keepdims=True)
|
|
66
|
+
totals[totals == 0] = 1.0 # avoid divide-by-zero for empty spectra
|
|
67
|
+
return matrix / totals
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _pqn(matrix: np.ndarray) -> np.ndarray:
|
|
71
|
+
"""Probabilistic Quotient Normalization (PQN).
|
|
72
|
+
|
|
73
|
+
Algorithm (Dieterle et al. 2006, Anal. Chem.):
|
|
74
|
+
1. Total-area normalize all spectra.
|
|
75
|
+
2. Compute a reference spectrum (column-wise median).
|
|
76
|
+
3. For each sample, compute quotients (sample / reference) for all bins
|
|
77
|
+
where the reference > 0.
|
|
78
|
+
4. The normalisation factor is the median of those quotients.
|
|
79
|
+
5. Divide the original (not total-area) spectrum by that factor.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
matrix:
|
|
84
|
+
2-D array (n_samples x n_bins), values >= 0.
|
|
85
|
+
|
|
86
|
+
Returns
|
|
87
|
+
-------
|
|
88
|
+
np.ndarray
|
|
89
|
+
PQN-normalised matrix.
|
|
90
|
+
"""
|
|
91
|
+
# Step 1: total-area normalise for reference construction only
|
|
92
|
+
ta = _total_area(matrix)
|
|
93
|
+
|
|
94
|
+
# Step 2: reference spectrum
|
|
95
|
+
reference = np.median(ta, axis=0)
|
|
96
|
+
|
|
97
|
+
# Steps 3-5: compute PQN factor per sample
|
|
98
|
+
normed = np.zeros_like(matrix)
|
|
99
|
+
nonzero_ref = reference > 0
|
|
100
|
+
|
|
101
|
+
for i, sample in enumerate(ta):
|
|
102
|
+
if not nonzero_ref.any():
|
|
103
|
+
normed[i] = matrix[i]
|
|
104
|
+
continue
|
|
105
|
+
quotients = sample[nonzero_ref] / reference[nonzero_ref]
|
|
106
|
+
pqn_factor = float(np.median(quotients))
|
|
107
|
+
if pqn_factor <= 0:
|
|
108
|
+
logger.warning(
|
|
109
|
+
"Sample %d: PQN factor <= 0 (%.4g), skipping normalisation.", i, pqn_factor
|
|
110
|
+
)
|
|
111
|
+
normed[i] = matrix[i]
|
|
112
|
+
else:
|
|
113
|
+
# Divide the original (un-normalised) sample by the PQN factor
|
|
114
|
+
normed[i] = matrix[i] / pqn_factor
|
|
115
|
+
|
|
116
|
+
return normed
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _tsp_reference(matrix: np.ndarray, tsp_bin_index: int) -> np.ndarray:
|
|
120
|
+
"""Normalise each sample to the intensity of the TSP reference peak."""
|
|
121
|
+
tsp_intensities = matrix[:, tsp_bin_index].copy()
|
|
122
|
+
tsp_intensities[tsp_intensities == 0] = 1.0
|
|
123
|
+
return matrix / tsp_intensities[:, np.newaxis]
|