|
1 | | -"""Pure processing functions for Createc spectroscopy data. |
| 1 | +"""Backward-compat re-exports — spectroscopy kernels moved to ``spectroscopy/_kernels.py``. |
2 | 2 |
|
3 | | -All functions operate on raw numpy arrays (physical SI units). |
4 | | -No GUI or file-I/O dependency; safe to call from worker threads. |
| 3 | +The numerical kernels (``smooth_spectrum``, ``numeric_derivative``, |
| 4 | +``normalize``, ``crop``, ``average_spectra``, ``current_histogram``) live in |
| 5 | +:mod:`probeflow.spectroscopy._kernels` (review arch-backend #8). This shim |
| 6 | +preserves the historical ``probeflow.processing.spectroscopy.*`` import path |
| 7 | +that CLI commands, tests, and `analysis/spec_plot.py` already use. |
| 8 | +
|
| 9 | +New code should import directly from :mod:`probeflow.spectroscopy._kernels` |
| 10 | +(or rely on the convenience exports in :mod:`probeflow.spectroscopy`). |
5 | 11 | """ |
6 | 12 |
|
7 | 13 | from __future__ import annotations |
8 | 14 |
|
9 | | -import warnings |
10 | | - |
11 | | -import numpy as np |
12 | | -from scipy.signal import savgol_filter |
13 | | - |
14 | | - |
15 | | -def smooth_spectrum( |
16 | | - data: np.ndarray, |
17 | | - method: str = "savgol", |
18 | | - **kwargs, |
19 | | -) -> np.ndarray: |
20 | | - """Smooth a 1-D spectrum. |
21 | | -
|
22 | | - Parameters |
23 | | - ---------- |
24 | | - data : np.ndarray |
25 | | - 1-D array of spectral values. |
26 | | - method : str |
27 | | - 'savgol' (Savitzky-Golay), 'gaussian', or 'boxcar'. |
28 | | - **kwargs |
29 | | - savgol: window_length (int, default 11), polyorder (int, default 3) |
30 | | - gaussian: sigma (float, default 2.0) |
31 | | - boxcar: n (int, default 5) |
32 | | -
|
33 | | - Returns |
34 | | - ------- |
35 | | - np.ndarray |
36 | | - Smoothed array, same length as input. |
37 | | - """ |
38 | | - data = np.asarray(data, dtype=np.float64) |
39 | | - if method == "savgol": |
40 | | - window = int(kwargs.get("window_length", 11)) |
41 | | - polyorder = int(kwargs.get("polyorder", 3)) |
42 | | - n = len(data) |
43 | | - # Return unchanged if the array is too short to filter meaningfully. |
44 | | - if n < polyorder + 2: |
45 | | - return data.copy() |
46 | | - # window must be odd and strictly greater than polyorder. |
47 | | - window = max(polyorder + 2 if polyorder % 2 == 0 else polyorder + 1, window) |
48 | | - if window % 2 == 0: |
49 | | - window += 1 |
50 | | - max_win = n if n % 2 == 1 else n - 1 |
51 | | - window = min(window, max_win) |
52 | | - # Clamp to minimum valid window after the size cap. |
53 | | - if window < polyorder + 1: |
54 | | - window = polyorder + 1 if (polyorder + 1) % 2 == 1 else polyorder + 2 |
55 | | - return savgol_filter(data, window_length=window, polyorder=polyorder) |
56 | | - elif method == "gaussian": |
57 | | - from scipy.ndimage import gaussian_filter1d |
58 | | - sigma = float(kwargs.get("sigma", 2.0)) |
59 | | - return gaussian_filter1d(data, sigma=sigma) |
60 | | - elif method == "boxcar": |
61 | | - from scipy.ndimage import uniform_filter1d |
62 | | - n = int(kwargs.get("n", 5)) |
63 | | - n = max(1, n) |
64 | | - # mode="nearest" reflects edge values instead of zero-padding. |
65 | | - return uniform_filter1d(data, size=n, mode="nearest") |
66 | | - else: |
67 | | - raise ValueError( |
68 | | - f"Unknown smoothing method: {method!r}. Choose savgol, gaussian, or boxcar." |
69 | | - ) |
70 | | - |
71 | | - |
72 | | -def numeric_derivative(x: np.ndarray, y: np.ndarray) -> np.ndarray: |
73 | | - """Compute dy/dx via central finite differences. |
74 | | -
|
75 | | - x must be strictly monotonic (no duplicate values). Non-monotonic inputs |
76 | | - — such as a forward+backward bias sweep stored in a single array — will |
77 | | - produce incorrect derivatives; split the sweep first. |
78 | | -
|
79 | | - Parameters |
80 | | - ---------- |
81 | | - x : np.ndarray |
82 | | - Independent variable (e.g. bias in V or time in s). Must be monotonic. |
83 | | - y : np.ndarray |
84 | | - Dependent variable (e.g. current in A). |
85 | | -
|
86 | | - Returns |
87 | | - ------- |
88 | | - np.ndarray |
89 | | - Derivative dy/dx, same length as x and y. |
90 | | - """ |
91 | | - x = np.asarray(x, dtype=np.float64) |
92 | | - y = np.asarray(y, dtype=np.float64) |
93 | | - if x.shape != y.shape: |
94 | | - raise ValueError("numeric_derivative: x and y must have matching shape.") |
95 | | - if x.ndim != 1: |
96 | | - raise ValueError("numeric_derivative: x and y must be 1-D arrays.") |
97 | | - if x.size < 2: |
98 | | - raise ValueError("numeric_derivative: at least two samples are required.") |
99 | | - diffs = np.diff(x) |
100 | | - if not (np.all(diffs > 0) or np.all(diffs < 0)): |
101 | | - raise ValueError( |
102 | | - "numeric_derivative: x is not strictly monotonic. " |
103 | | - "If this is a forward+backward sweep, split it before differentiating." |
104 | | - ) |
105 | | - return np.gradient(y, x) |
106 | | - |
107 | | - |
108 | | -def normalize(data: np.ndarray, method: str = "max") -> np.ndarray: |
109 | | - """Normalize a 1-D array. |
110 | | -
|
111 | | - Parameters |
112 | | - ---------- |
113 | | - data : np.ndarray |
114 | | - Input array. |
115 | | - method : str |
116 | | - 'max' — divide by max absolute value. |
117 | | - 'minmax' — rescale to [0, 1]. |
118 | | - 'zscore' — subtract mean, divide by std. |
119 | | - 'setpoint' — divide by the first finite non-zero value. |
120 | | -
|
121 | | - Returns |
122 | | - ------- |
123 | | - np.ndarray |
124 | | - Normalized array, same length as input. |
125 | | - """ |
126 | | - data = np.asarray(data, dtype=np.float64) |
127 | | - if method == "max": |
128 | | - m = float(np.nanmax(np.abs(data))) |
129 | | - if m == 0.0: |
130 | | - warnings.warn("normalize: all-zero input; returning zeros unchanged", stacklevel=2) |
131 | | - return data.copy() |
132 | | - return data / m |
133 | | - elif method == "minmax": |
134 | | - lo, hi = float(np.nanmin(data)), float(np.nanmax(data)) |
135 | | - return (data - lo) / (hi - lo) if hi != lo else np.zeros_like(data) |
136 | | - elif method == "zscore": |
137 | | - mu = float(np.nanmean(data)) |
138 | | - sigma = float(np.nanstd(data)) |
139 | | - return (data - mu) / sigma if sigma != 0.0 else np.zeros_like(data) |
140 | | - elif method == "setpoint": |
141 | | - finite = data[np.isfinite(data) & (data != 0)] |
142 | | - if finite.size == 0: |
143 | | - warnings.warn( |
144 | | - "normalize: no finite non-zero setpoint; returning input unchanged", |
145 | | - stacklevel=2, |
146 | | - ) |
147 | | - return data.copy() |
148 | | - return data / float(finite[0]) |
149 | | - else: |
150 | | - raise ValueError( |
151 | | - f"Unknown normalization method: {method!r}. " |
152 | | - "Choose max, minmax, zscore, or setpoint." |
153 | | - ) |
154 | | - |
155 | | - |
156 | | -def crop( |
157 | | - x: np.ndarray, |
158 | | - y: np.ndarray, |
159 | | - x_min: float, |
160 | | - x_max: float, |
161 | | -) -> tuple[np.ndarray, np.ndarray]: |
162 | | - """Return the subset of (x, y) where x_min ≤ x ≤ x_max. |
163 | | -
|
164 | | - If x_min > x_max the bounds are silently swapped. |
165 | | -
|
166 | | - Parameters |
167 | | - ---------- |
168 | | - x, y : np.ndarray |
169 | | - Paired 1-D arrays of the same length. |
170 | | - x_min, x_max : float |
171 | | - Inclusive bounds on the x range to keep. |
172 | | -
|
173 | | - Returns |
174 | | - ------- |
175 | | - tuple[np.ndarray, np.ndarray] |
176 | | - Cropped (x, y) pair. |
177 | | - """ |
178 | | - x = np.asarray(x, dtype=np.float64) |
179 | | - y = np.asarray(y, dtype=np.float64) |
180 | | - if x_min > x_max: |
181 | | - x_min, x_max = x_max, x_min |
182 | | - mask = (x >= x_min) & (x <= x_max) |
183 | | - return x[mask], y[mask] |
184 | | - |
185 | | - |
186 | | -def average_spectra(spectra: list[np.ndarray]) -> np.ndarray: |
187 | | - """Element-wise mean of a list of equal-length 1-D arrays. |
188 | | -
|
189 | | - Parameters |
190 | | - ---------- |
191 | | - spectra : list[np.ndarray] |
192 | | - List of 1-D arrays, all the same length. All spectra must share the |
193 | | - same x-axis grid (i.e. identical x values at every index), not merely |
194 | | - the same number of points. Raises ValueError if lengths differ — |
195 | | - interpolate to a common x grid before calling if needed. |
196 | | -
|
197 | | - Returns |
198 | | - ------- |
199 | | - np.ndarray |
200 | | - Mean array. |
201 | | - """ |
202 | | - if not spectra: |
203 | | - raise ValueError("spectra list is empty") |
204 | | - arrs = [np.asarray(s, dtype=np.float64) for s in spectra] |
205 | | - lengths = [a.size for a in arrs] |
206 | | - if len(set(lengths)) > 1: |
207 | | - raise ValueError( |
208 | | - f"average_spectra: all spectra must have the same length, " |
209 | | - f"got {lengths}. Interpolate to a common x grid first." |
210 | | - ) |
211 | | - return np.mean(np.stack(arrs, axis=0), axis=0) |
212 | | - |
213 | | - |
214 | | -def current_histogram( |
215 | | - data: np.ndarray, |
216 | | - bins: int = 100, |
217 | | -) -> tuple[np.ndarray, np.ndarray]: |
218 | | - """Histogram of current values for telegraph-noise analysis. |
219 | | -
|
220 | | - Return order matches numpy: (counts, bin_edges). |
221 | | -
|
222 | | - Parameters |
223 | | - ---------- |
224 | | - data : np.ndarray |
225 | | - 1-D array of current values (A). |
226 | | - bins : int |
227 | | - Number of histogram bins. |
228 | | -
|
229 | | - Returns |
230 | | - ------- |
231 | | - tuple[np.ndarray, np.ndarray] |
232 | | - (counts, bin_edges) — counts has length bins, bin_edges has length bins+1. |
233 | | - """ |
234 | | - data = np.asarray(data, dtype=np.float64) |
235 | | - finite = data[np.isfinite(data)] |
236 | | - return np.histogram(finite, bins=bins) |
| 15 | +from probeflow.spectroscopy._kernels import ( # noqa: F401 |
| 16 | + average_spectra, |
| 17 | + crop, |
| 18 | + current_histogram, |
| 19 | + normalize, |
| 20 | + numeric_derivative, |
| 21 | + smooth_spectrum, |
| 22 | +) |
| 23 | + |
| 24 | +__all__ = [ |
| 25 | + "average_spectra", |
| 26 | + "crop", |
| 27 | + "current_histogram", |
| 28 | + "normalize", |
| 29 | + "numeric_derivative", |
| 30 | + "smooth_spectrum", |
| 31 | +] |
0 commit comments