Skip to content

Commit dece10a

Browse files
Move spectroscopy kernels into spectroscopy/_kernels.py (review arch-backend #8)
`probeflow/processing/spectroscopy.py` held the raw numerical kernels (smooth_spectrum, numeric_derivative, normalize, crop, average_spectra, current_histogram) for spectroscopy data, but the spectroscopy package itself had display-side helpers in a sibling layout, and two files inside `spectroscopy/` had to reach back into `processing/` for kernels they wrap. Future contributors couldn't tell where to add new transforms. - Moved kernels to `probeflow/spectroscopy/_kernels.py` (file rename via `git mv` preserves history). - `probeflow/processing/spectroscopy.py` is now a thin re-export shim so the historical import path (CLI, tests, analysis/spec_plot.py) keeps working unchanged. - `spectroscopy/transforms.py` and `spectroscopy/smoothing.py` now import `_numeric_derivative` and `smooth_spectrum` from the local `_kernels` module directly instead of the round-trip through `processing/`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7432305 commit dece10a

4 files changed

Lines changed: 270 additions & 233 deletions

File tree

Lines changed: 26 additions & 231 deletions
Original file line numberDiff line numberDiff line change
@@ -1,236 +1,31 @@
1-
"""Pure processing functions for Createc spectroscopy data.
1+
"""Backward-compat re-exports — spectroscopy kernels moved to ``spectroscopy/_kernels.py``.
22
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`).
511
"""
612

713
from __future__ import annotations
814

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

Comments
 (0)