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.
@@ -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
+ ]
@@ -0,0 +1,5 @@
1
+ """Entry point for `python -m nmrmetaproc`."""
2
+ from nmrmetaproc.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -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]