fibphot 0.1.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.
- fibphot/__init__.py +6 -0
- fibphot/analysis/__init__.py +0 -0
- fibphot/analysis/aggregate.py +257 -0
- fibphot/analysis/auc.py +354 -0
- fibphot/analysis/irls.py +350 -0
- fibphot/analysis/peaks.py +1163 -0
- fibphot/analysis/photobleaching.py +290 -0
- fibphot/analysis/plotting.py +105 -0
- fibphot/analysis/report.py +56 -0
- fibphot/collection.py +207 -0
- fibphot/fit/__init__.py +0 -0
- fibphot/fit/regression.py +269 -0
- fibphot/io/__init__.py +6 -0
- fibphot/io/doric.py +435 -0
- fibphot/io/excel.py +76 -0
- fibphot/io/h5.py +321 -0
- fibphot/misc.py +11 -0
- fibphot/peaks.py +628 -0
- fibphot/pipeline.py +14 -0
- fibphot/plotting.py +594 -0
- fibphot/stages/__init__.py +22 -0
- fibphot/stages/base.py +101 -0
- fibphot/stages/baseline.py +354 -0
- fibphot/stages/control_dff.py +214 -0
- fibphot/stages/filters.py +273 -0
- fibphot/stages/normalisation.py +260 -0
- fibphot/stages/regression.py +139 -0
- fibphot/stages/smooth.py +442 -0
- fibphot/stages/trim.py +141 -0
- fibphot/state.py +309 -0
- fibphot/tags.py +130 -0
- fibphot/types.py +6 -0
- fibphot-0.1.0.dist-info/METADATA +63 -0
- fibphot-0.1.0.dist-info/RECORD +37 -0
- fibphot-0.1.0.dist-info/WHEEL +5 -0
- fibphot-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- fibphot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ..types import FloatArray
|
|
9
|
+
|
|
10
|
+
RegressionMethod = Literal["ols", "irls_tukey", "irls_huber"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class LinearFit:
|
|
15
|
+
"""
|
|
16
|
+
Fit of y ≈ intercept + slope * x (or slope-only if include_intercept=False).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
intercept: float
|
|
20
|
+
slope: float
|
|
21
|
+
fitted: FloatArray
|
|
22
|
+
residuals: FloatArray
|
|
23
|
+
r2: float
|
|
24
|
+
method: RegressionMethod
|
|
25
|
+
n_iter: int | None = None
|
|
26
|
+
tuning_constant: float | None = None
|
|
27
|
+
scale: float | None = None
|
|
28
|
+
weights: FloatArray | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _r2_score(y: FloatArray, yhat: FloatArray) -> float:
|
|
32
|
+
y = np.asarray(y, dtype=float)
|
|
33
|
+
yhat = np.asarray(yhat, dtype=float)
|
|
34
|
+
ss_res = float(np.sum((y - yhat) ** 2))
|
|
35
|
+
ss_tot = float(np.sum((y - float(np.mean(y))) ** 2))
|
|
36
|
+
if ss_tot <= 1e-20:
|
|
37
|
+
return float("nan")
|
|
38
|
+
return 1.0 - ss_res / ss_tot
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _mad_sigma(x: FloatArray) -> float:
|
|
42
|
+
"""Robust scale estimate using MAD, scaled for Normal data."""
|
|
43
|
+
x = np.asarray(x, dtype=float)
|
|
44
|
+
med = float(np.median(x))
|
|
45
|
+
mad = float(np.median(np.abs(x - med)))
|
|
46
|
+
return 1.4826 * mad
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _design_matrix(x: FloatArray, include_intercept: bool) -> FloatArray:
|
|
50
|
+
x = np.asarray(x, dtype=float)
|
|
51
|
+
if include_intercept:
|
|
52
|
+
return np.column_stack([np.ones_like(x), x])
|
|
53
|
+
return x[:, None]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fit_ols(
|
|
57
|
+
x: FloatArray,
|
|
58
|
+
y: FloatArray,
|
|
59
|
+
*,
|
|
60
|
+
include_intercept: bool = True,
|
|
61
|
+
) -> LinearFit:
|
|
62
|
+
"""
|
|
63
|
+
Ordinary least squares fit of y on x.
|
|
64
|
+
|
|
65
|
+
Context
|
|
66
|
+
-------
|
|
67
|
+
Applied to motion corrections in fiber photometry, x is the control signal
|
|
68
|
+
(e.g., isosbestic channel) and y is the signal to be corrected. Hence, the
|
|
69
|
+
estimated motion is given by:
|
|
70
|
+
|
|
71
|
+
yhat = intercept + slope * x
|
|
72
|
+
|
|
73
|
+
and the corrected signal is given by the residuals:
|
|
74
|
+
|
|
75
|
+
corrected = y - yhat.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
x = np.asarray(x, dtype=float)
|
|
79
|
+
y = np.asarray(y, dtype=float)
|
|
80
|
+
|
|
81
|
+
mask = np.isfinite(x) & np.isfinite(y)
|
|
82
|
+
x0 = x[mask]
|
|
83
|
+
y0 = y[mask]
|
|
84
|
+
|
|
85
|
+
X = _design_matrix(x0, include_intercept)
|
|
86
|
+
beta, *_ = np.linalg.lstsq(X, y0, rcond=None)
|
|
87
|
+
|
|
88
|
+
if include_intercept:
|
|
89
|
+
intercept = float(beta[0])
|
|
90
|
+
slope = float(beta[1])
|
|
91
|
+
yhat0 = intercept + slope * x0
|
|
92
|
+
else:
|
|
93
|
+
intercept = 0.0
|
|
94
|
+
slope = float(beta[0])
|
|
95
|
+
yhat0 = slope * x0
|
|
96
|
+
|
|
97
|
+
fitted = np.full_like(y, np.nan, dtype=float)
|
|
98
|
+
fitted[mask] = yhat0
|
|
99
|
+
residuals = y - fitted
|
|
100
|
+
|
|
101
|
+
return LinearFit(
|
|
102
|
+
intercept=intercept,
|
|
103
|
+
slope=slope,
|
|
104
|
+
fitted=fitted,
|
|
105
|
+
residuals=residuals,
|
|
106
|
+
r2=_r2_score(y0, yhat0),
|
|
107
|
+
method="ols",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _weights_tukey(u: FloatArray) -> FloatArray:
|
|
112
|
+
"""Tukey's bisquare weights: w = (1 - u^2)^2 for |u|<1 else 0."""
|
|
113
|
+
u = np.asarray(u, dtype=float)
|
|
114
|
+
w = np.zeros_like(u)
|
|
115
|
+
inside = np.abs(u) < 1.0
|
|
116
|
+
w[inside] = (1.0 - u[inside] ** 2) ** 2
|
|
117
|
+
return w
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _weights_huber(u: FloatArray) -> FloatArray:
|
|
121
|
+
"""Huber weights: w = 1 for |u|<=1 else 1/|u|."""
|
|
122
|
+
u = np.asarray(u, dtype=float)
|
|
123
|
+
au = np.abs(u)
|
|
124
|
+
w = np.ones_like(u)
|
|
125
|
+
outside = au > 1.0
|
|
126
|
+
w[outside] = 1.0 / au[outside]
|
|
127
|
+
return w
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _wls_line(
|
|
131
|
+
x: FloatArray,
|
|
132
|
+
y: FloatArray,
|
|
133
|
+
w: FloatArray,
|
|
134
|
+
*,
|
|
135
|
+
include_intercept: bool,
|
|
136
|
+
) -> tuple[float, float]:
|
|
137
|
+
x = np.asarray(x, dtype=float)
|
|
138
|
+
y = np.asarray(y, dtype=float)
|
|
139
|
+
w = np.asarray(w, dtype=float)
|
|
140
|
+
|
|
141
|
+
w = np.clip(w, 0.0, np.inf)
|
|
142
|
+
sw = float(np.sum(w))
|
|
143
|
+
if not np.isfinite(sw) or sw <= 1e-15:
|
|
144
|
+
return float("nan"), float("nan")
|
|
145
|
+
|
|
146
|
+
if include_intercept:
|
|
147
|
+
sx = float(np.sum(w * x))
|
|
148
|
+
sy = float(np.sum(w * y))
|
|
149
|
+
sxx = float(np.sum(w * x * x))
|
|
150
|
+
sxy = float(np.sum(w * x * y))
|
|
151
|
+
|
|
152
|
+
denom = sw * sxx - sx * sx
|
|
153
|
+
if not np.isfinite(denom) or abs(denom) <= 1e-20:
|
|
154
|
+
return float("nan"), float("nan")
|
|
155
|
+
|
|
156
|
+
slope = (sw * sxy - sx * sy) / denom
|
|
157
|
+
intercept = (sy - slope * sx) / sw
|
|
158
|
+
return float(intercept), float(slope)
|
|
159
|
+
|
|
160
|
+
# slope-only
|
|
161
|
+
sxx = float(np.sum(w * x * x))
|
|
162
|
+
if not np.isfinite(sxx) or sxx <= 1e-20:
|
|
163
|
+
return 0.0, float("nan")
|
|
164
|
+
|
|
165
|
+
sxy = float(np.sum(w * x * y))
|
|
166
|
+
slope = sxy / sxx
|
|
167
|
+
return 0.0, float(slope)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def fit_irls(
|
|
171
|
+
x: FloatArray,
|
|
172
|
+
y: FloatArray,
|
|
173
|
+
*,
|
|
174
|
+
include_intercept: bool = True,
|
|
175
|
+
loss: Literal["tukey", "huber"] = "tukey",
|
|
176
|
+
tuning_constant: float = 4.685,
|
|
177
|
+
max_iter: int = 50,
|
|
178
|
+
tol: float = 1e-10,
|
|
179
|
+
store_weights: bool = False,
|
|
180
|
+
) -> LinearFit:
|
|
181
|
+
x = np.asarray(x, dtype=float)
|
|
182
|
+
y = np.asarray(y, dtype=float)
|
|
183
|
+
|
|
184
|
+
mask = np.isfinite(x) & np.isfinite(y)
|
|
185
|
+
x0 = x[mask]
|
|
186
|
+
y0 = y[mask]
|
|
187
|
+
|
|
188
|
+
# initial OLS.
|
|
189
|
+
if include_intercept:
|
|
190
|
+
X = np.column_stack([np.ones_like(x0), x0])
|
|
191
|
+
beta, *_ = np.linalg.lstsq(X, y0, rcond=None)
|
|
192
|
+
intercept = float(beta[0])
|
|
193
|
+
slope = float(beta[1])
|
|
194
|
+
else:
|
|
195
|
+
slope = float(np.dot(x0, y0) / (np.dot(x0, x0) + 1e-18))
|
|
196
|
+
intercept = 0.0
|
|
197
|
+
|
|
198
|
+
weight_fn = _weights_tukey if loss == "tukey" else _weights_huber
|
|
199
|
+
|
|
200
|
+
w = np.ones_like(y0, dtype=float)
|
|
201
|
+
scale: float | None = None
|
|
202
|
+
|
|
203
|
+
n_iter = 0
|
|
204
|
+
last_intercept = intercept
|
|
205
|
+
last_slope = slope
|
|
206
|
+
|
|
207
|
+
for _ in range(max_iter):
|
|
208
|
+
n_iter += 1
|
|
209
|
+
|
|
210
|
+
yhat = intercept + slope * x0 if include_intercept else slope * x0
|
|
211
|
+
r = y0 - yhat
|
|
212
|
+
|
|
213
|
+
scale = _mad_sigma(r)
|
|
214
|
+
if not np.isfinite(scale) or scale <= 1e-15:
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
u = r / (tuning_constant * scale)
|
|
218
|
+
w = weight_fn(u)
|
|
219
|
+
|
|
220
|
+
if float(np.sum(w)) <= 1e-12:
|
|
221
|
+
break
|
|
222
|
+
|
|
223
|
+
intercept, slope = _wls_line(
|
|
224
|
+
x0, y0, w, include_intercept=include_intercept
|
|
225
|
+
)
|
|
226
|
+
if not np.isfinite(slope) or (
|
|
227
|
+
include_intercept and not np.isfinite(intercept)
|
|
228
|
+
):
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
# convergence
|
|
232
|
+
di = abs(intercept - last_intercept) if include_intercept else 0.0
|
|
233
|
+
ds = abs(slope - last_slope)
|
|
234
|
+
denom = (
|
|
235
|
+
abs(last_slope)
|
|
236
|
+
+ (abs(last_intercept) if include_intercept else 0.0)
|
|
237
|
+
+ 1e-18
|
|
238
|
+
)
|
|
239
|
+
if (di + ds) / denom < tol:
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
last_intercept = intercept
|
|
243
|
+
last_slope = slope
|
|
244
|
+
|
|
245
|
+
yhat0 = intercept + slope * x0 if include_intercept else slope * x0
|
|
246
|
+
|
|
247
|
+
fitted = np.full_like(y, np.nan, dtype=float)
|
|
248
|
+
fitted[mask] = yhat0
|
|
249
|
+
residuals = y - fitted
|
|
250
|
+
|
|
251
|
+
weights_out = None
|
|
252
|
+
if store_weights:
|
|
253
|
+
weights_out = np.full_like(y, np.nan, dtype=float)
|
|
254
|
+
weights_out[mask] = w
|
|
255
|
+
|
|
256
|
+
method: RegressionMethod = "irls_tukey" if loss == "tukey" else "irls_huber"
|
|
257
|
+
|
|
258
|
+
return LinearFit(
|
|
259
|
+
intercept=float(intercept),
|
|
260
|
+
slope=float(slope),
|
|
261
|
+
fitted=fitted,
|
|
262
|
+
residuals=residuals,
|
|
263
|
+
r2=_r2_score(y0, yhat0),
|
|
264
|
+
method=method,
|
|
265
|
+
n_iter=n_iter,
|
|
266
|
+
tuning_constant=float(tuning_constant),
|
|
267
|
+
scale=float(scale) if scale is not None else None,
|
|
268
|
+
weights=weights_out,
|
|
269
|
+
)
|
fibphot/io/__init__.py
ADDED
fibphot/io/doric.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import h5py
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ..state import PhotometryState
|
|
12
|
+
from ..types import FloatArray
|
|
13
|
+
|
|
14
|
+
AlignMode = Literal["truncate", "interp"]
|
|
15
|
+
Source = Literal[
|
|
16
|
+
"lockin", # demodulated photometry channels (GCaMP, Iso, etc.)
|
|
17
|
+
"analog_in", # raw detector voltages
|
|
18
|
+
"analog_out", # LED modulation outputs
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class DoricChannel:
|
|
24
|
+
name: str
|
|
25
|
+
signal_path: str
|
|
26
|
+
time_path: str
|
|
27
|
+
attrs: dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _decode_attr(value: Any) -> Any:
|
|
31
|
+
"""Best-effort decoding for HDF5 attributes."""
|
|
32
|
+
|
|
33
|
+
if isinstance(value, bytes):
|
|
34
|
+
return value.decode(errors="replace")
|
|
35
|
+
|
|
36
|
+
if isinstance(value, np.ndarray):
|
|
37
|
+
if value.dtype.kind in {"S", "O"}:
|
|
38
|
+
out: list[Any] = []
|
|
39
|
+
for v in value.ravel().tolist():
|
|
40
|
+
out.append(_decode_attr(v))
|
|
41
|
+
return np.array(out, dtype=object).reshape(value.shape)
|
|
42
|
+
if value.size == 1:
|
|
43
|
+
return _decode_attr(value.item())
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
if isinstance(value, np.generic):
|
|
47
|
+
return value.item()
|
|
48
|
+
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_attrs(obj: h5py.Dataset | h5py.Group) -> dict[str, Any]:
|
|
53
|
+
"""Extract all attributes from an HDF5 group/dataset and decode them."""
|
|
54
|
+
return {k: _decode_attr(v) for k, v in obj.attrs.items()}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalise_name(name: str) -> str:
|
|
58
|
+
"""Normalise channel names so they’re consistent keys."""
|
|
59
|
+
return name.strip().lower().replace(" ", "_").replace("-", "_")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_1d(dataset: h5py.Dataset) -> FloatArray:
|
|
63
|
+
"""Read a dataset and ensure it’s a 1D float array."""
|
|
64
|
+
arr = np.asarray(dataset[()], dtype=float)
|
|
65
|
+
if arr.ndim != 1:
|
|
66
|
+
raise ValueError(f"Expected 1D dataset, got shape {arr.shape}.")
|
|
67
|
+
return arr
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _discover_series_names(f: h5py.File, fpconsole: str) -> list[str]:
|
|
71
|
+
"""Find which Doric “SeriesXXXX” groups exist in the file."""
|
|
72
|
+
base = f"DataAcquisition/{fpconsole}/Signals"
|
|
73
|
+
if base not in f:
|
|
74
|
+
raise KeyError(f"Missing signals root: {base!r}")
|
|
75
|
+
|
|
76
|
+
grp = f[base]
|
|
77
|
+
series = [k for k in grp if str(k).lower().startswith("series")]
|
|
78
|
+
if not series:
|
|
79
|
+
raise ValueError(f"No series groups found under {base!r}")
|
|
80
|
+
return sorted(series)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _series_sort_key(name: str) -> tuple[int, str]:
|
|
84
|
+
"""Sort key for series names like 'Series0001'."""
|
|
85
|
+
digits = "".join(ch for ch in name if ch.isdigit())
|
|
86
|
+
return (int(digits) if digits else -1, name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _choose_series(series: str | None, available: list[str]) -> str:
|
|
90
|
+
"""Decide which series to use."""
|
|
91
|
+
if series is not None:
|
|
92
|
+
if series not in available:
|
|
93
|
+
raise KeyError(
|
|
94
|
+
f"Series {series!r} not found. Available: {available}"
|
|
95
|
+
)
|
|
96
|
+
return series
|
|
97
|
+
|
|
98
|
+
if len(available) == 1:
|
|
99
|
+
return available[0]
|
|
100
|
+
|
|
101
|
+
# Choose highest numbered series by default.
|
|
102
|
+
return sorted(available, key=_series_sort_key)[-1]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _find_series_root(f: h5py.File, fpconsole: str, series: str) -> str:
|
|
106
|
+
"""Construct and validate the HDF5 path to the chosen series."""
|
|
107
|
+
root = f"DataAcquisition/{fpconsole}/Signals/{series}"
|
|
108
|
+
if root not in f:
|
|
109
|
+
raise KeyError(f"Could not find series root: {root!r}")
|
|
110
|
+
return root
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _available_sources(f: h5py.File, series_root: str) -> list[Source]:
|
|
114
|
+
"""Discover which source types are available in the series."""
|
|
115
|
+
out: list[Source] = []
|
|
116
|
+
|
|
117
|
+
if f"{series_root}/AnalogIn" in f:
|
|
118
|
+
out.append("analog_in")
|
|
119
|
+
if f"{series_root}/AnalogOut" in f:
|
|
120
|
+
out.append("analog_out")
|
|
121
|
+
|
|
122
|
+
grp = f[series_root]
|
|
123
|
+
if any(str(k).startswith("LockIn") for k in grp):
|
|
124
|
+
out.append("lockin")
|
|
125
|
+
|
|
126
|
+
order: list[Source] = ["lockin", "analog_in", "analog_out"]
|
|
127
|
+
return [s for s in order if s in out]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _choose_source(source: Source | None, available: list[Source]) -> Source:
|
|
131
|
+
"""
|
|
132
|
+
Decide which source to use.
|
|
133
|
+
|
|
134
|
+
Priority: lockin > analog_in > analog_out. For photometry, lockin is
|
|
135
|
+
preferred as it contains demodulated signals.
|
|
136
|
+
"""
|
|
137
|
+
if source is not None:
|
|
138
|
+
if source not in available:
|
|
139
|
+
raise KeyError(
|
|
140
|
+
f"Source {source!r} not available. Available: {available}"
|
|
141
|
+
)
|
|
142
|
+
return source
|
|
143
|
+
|
|
144
|
+
# Priority: lockin > analog_in > analog_out
|
|
145
|
+
for preferred in ("lockin", "analog_in", "analog_out"):
|
|
146
|
+
if preferred in available:
|
|
147
|
+
return preferred # type: ignore[return-value]
|
|
148
|
+
|
|
149
|
+
raise ValueError("No supported sources found in file.")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _discover_lockin_channels(
|
|
153
|
+
f: h5py.File, series_root: str
|
|
154
|
+
) -> list[DoricChannel]:
|
|
155
|
+
"""Discover LockIn (demodulated) channels under the series root."""
|
|
156
|
+
|
|
157
|
+
channels: list[DoricChannel] = []
|
|
158
|
+
|
|
159
|
+
grp = f[series_root]
|
|
160
|
+
lockin_keys = [k for k in grp if str(k).startswith("LockIn")]
|
|
161
|
+
for key in lockin_keys:
|
|
162
|
+
grp_path = f"{series_root}/{key}"
|
|
163
|
+
lock_grp = f[grp_path]
|
|
164
|
+
|
|
165
|
+
time_path = f"{grp_path}/Time"
|
|
166
|
+
if time_path not in f:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Pick first non-Time dataset as signal (AIN01 is typical).
|
|
170
|
+
dset_names = [k for k in lock_grp if str(k).lower() != "time"]
|
|
171
|
+
if not dset_names:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
signal_path = (
|
|
175
|
+
f"{grp_path}/AIN01"
|
|
176
|
+
if f"{grp_path}/AIN01" in f
|
|
177
|
+
else f"{grp_path}/{dset_names[0]}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
ds = f[signal_path]
|
|
181
|
+
attrs = _get_attrs(ds)
|
|
182
|
+
|
|
183
|
+
username = attrs.get("Username")
|
|
184
|
+
name = (
|
|
185
|
+
str(username)
|
|
186
|
+
if username not in (None, "", "0")
|
|
187
|
+
else ds.name.split("/")[-1]
|
|
188
|
+
)
|
|
189
|
+
channels.append(
|
|
190
|
+
DoricChannel(
|
|
191
|
+
name=_normalise_name(name),
|
|
192
|
+
signal_path=signal_path,
|
|
193
|
+
time_path=time_path,
|
|
194
|
+
attrs=attrs,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if not channels:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
"No LockIn channels found. "
|
|
201
|
+
f"Expected groups like 'LockInAOUT02' under {series_root!r}."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
dedup: dict[str, DoricChannel] = {}
|
|
205
|
+
for ch in channels:
|
|
206
|
+
dedup.setdefault(ch.name, ch)
|
|
207
|
+
|
|
208
|
+
return list(dedup.values())
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _discover_analog_in(f: h5py.File, series_root: str) -> list[DoricChannel]:
|
|
212
|
+
"""Discover analogue input channels."""
|
|
213
|
+
|
|
214
|
+
grp_path = f"{series_root}/AnalogIn"
|
|
215
|
+
if grp_path not in f:
|
|
216
|
+
raise KeyError(f"Missing group: {grp_path!r}")
|
|
217
|
+
|
|
218
|
+
time_path = f"{grp_path}/Time"
|
|
219
|
+
if time_path not in f:
|
|
220
|
+
raise KeyError(f"Missing time dataset: {time_path!r}")
|
|
221
|
+
|
|
222
|
+
grp = f[grp_path]
|
|
223
|
+
channels: list[DoricChannel] = []
|
|
224
|
+
for k in grp:
|
|
225
|
+
if str(k).lower() == "time":
|
|
226
|
+
continue
|
|
227
|
+
ds_path = f"{grp_path}/{k}"
|
|
228
|
+
ds = f[ds_path]
|
|
229
|
+
attrs = _get_attrs(ds)
|
|
230
|
+
username = attrs.get("Username")
|
|
231
|
+
name = str(username) if username not in (None, "", "0") else str(k)
|
|
232
|
+
channels.append(
|
|
233
|
+
DoricChannel(
|
|
234
|
+
name=_normalise_name(name),
|
|
235
|
+
signal_path=ds_path,
|
|
236
|
+
time_path=time_path,
|
|
237
|
+
attrs=attrs,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if not channels:
|
|
242
|
+
raise ValueError(f"No analogue-in datasets found under {grp_path!r}")
|
|
243
|
+
|
|
244
|
+
return channels
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _discover_analog_out(f: h5py.File, series_root: str) -> list[DoricChannel]:
|
|
248
|
+
"""Discover analogue output channels (LED modulation outputs)."""
|
|
249
|
+
|
|
250
|
+
grp_path = f"{series_root}/AnalogOut"
|
|
251
|
+
if grp_path not in f:
|
|
252
|
+
raise KeyError(f"Missing group: {grp_path!r}")
|
|
253
|
+
|
|
254
|
+
time_path = f"{grp_path}/Time"
|
|
255
|
+
if time_path not in f:
|
|
256
|
+
raise KeyError(f"Missing time dataset: {time_path!r}")
|
|
257
|
+
|
|
258
|
+
grp = f[grp_path]
|
|
259
|
+
channels: list[DoricChannel] = []
|
|
260
|
+
for k in grp:
|
|
261
|
+
if str(k).lower() == "time":
|
|
262
|
+
continue
|
|
263
|
+
ds_path = f"{grp_path}/{k}"
|
|
264
|
+
ds = f[ds_path]
|
|
265
|
+
attrs = _get_attrs(ds)
|
|
266
|
+
username = attrs.get("Username")
|
|
267
|
+
name = str(username) if username not in (None, "", "0") else str(k)
|
|
268
|
+
channels.append(
|
|
269
|
+
DoricChannel(
|
|
270
|
+
name=_normalise_name(name),
|
|
271
|
+
signal_path=ds_path,
|
|
272
|
+
time_path=time_path,
|
|
273
|
+
attrs=attrs,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if not channels:
|
|
278
|
+
raise ValueError(f"No analogue-out datasets found under {grp_path!r}")
|
|
279
|
+
|
|
280
|
+
return channels
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _align_to_reference(
|
|
284
|
+
x_ref: FloatArray,
|
|
285
|
+
x: FloatArray,
|
|
286
|
+
y: FloatArray,
|
|
287
|
+
mode: AlignMode,
|
|
288
|
+
) -> FloatArray:
|
|
289
|
+
"""
|
|
290
|
+
Align y(x) to the reference timebase x_ref.
|
|
291
|
+
|
|
292
|
+
- truncate: cut to the shortest length (no resampling)
|
|
293
|
+
- interp: interpolate y onto x_ref
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
if mode == "truncate":
|
|
297
|
+
n = min(x_ref.shape[0], x.shape[0], y.shape[0])
|
|
298
|
+
return np.asarray(y[:n], dtype=float)
|
|
299
|
+
|
|
300
|
+
if mode == "interp":
|
|
301
|
+
n = min(x.shape[0], y.shape[0])
|
|
302
|
+
return np.interp(x_ref, x[:n], y[:n]).astype(float)
|
|
303
|
+
|
|
304
|
+
raise ValueError(f"Unknown align mode: {mode!r}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def read_doric(
|
|
308
|
+
filename: Path | str,
|
|
309
|
+
*,
|
|
310
|
+
fpconsole: str = "FPConsole",
|
|
311
|
+
series: str | None = None,
|
|
312
|
+
source: Source | None = None,
|
|
313
|
+
channels: Sequence[str] | Mapping[str, str] | None = None,
|
|
314
|
+
align: AlignMode = "truncate",
|
|
315
|
+
) -> PhotometryState:
|
|
316
|
+
"""
|
|
317
|
+
Read a Doric .doric (HDF5) file into a PhotometryState.
|
|
318
|
+
|
|
319
|
+
Automatic behaviour
|
|
320
|
+
-------------------
|
|
321
|
+
- If series is None: choose only series if there is one, otherwise choose
|
|
322
|
+
the highest-numbered series.
|
|
323
|
+
- If source is None: prefer lockin > analog_in > analog_out.
|
|
324
|
+
- Channel naming uses dataset attribute 'Username' when available.
|
|
325
|
+
|
|
326
|
+
Parameters
|
|
327
|
+
----------
|
|
328
|
+
fpconsole:
|
|
329
|
+
Device group under DataAcquisition (usually 'FPConsole').
|
|
330
|
+
series:
|
|
331
|
+
Series group name like 'Series0001'. If None, auto-selected.
|
|
332
|
+
source:
|
|
333
|
+
'lockin', 'analog_in', or 'analog_out'. If None, auto-selected.
|
|
334
|
+
channels:
|
|
335
|
+
- None: load all discovered channels for the selected source
|
|
336
|
+
- Sequence[str]: select by discovered channel names (normalised)
|
|
337
|
+
- Mapping[str, str]: rename channels: {new_name: existing_name}
|
|
338
|
+
align:
|
|
339
|
+
- 'truncate': truncate all channels to the shortest timebase length
|
|
340
|
+
- 'interp': interpolate all channels onto a reference timebase
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
path = Path(filename)
|
|
344
|
+
if not path.exists():
|
|
345
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
346
|
+
|
|
347
|
+
with h5py.File(path, "r") as f:
|
|
348
|
+
series_names = _discover_series_names(f, fpconsole)
|
|
349
|
+
chosen_series = _choose_series(series, series_names)
|
|
350
|
+
series_root = _find_series_root(f, fpconsole, chosen_series)
|
|
351
|
+
|
|
352
|
+
available = _available_sources(f, series_root)
|
|
353
|
+
chosen_source = _choose_source(source, available)
|
|
354
|
+
|
|
355
|
+
if chosen_source == "lockin":
|
|
356
|
+
discovered = _discover_lockin_channels(f, series_root)
|
|
357
|
+
elif chosen_source == "analog_in":
|
|
358
|
+
discovered = _discover_analog_in(f, series_root)
|
|
359
|
+
else:
|
|
360
|
+
discovered = _discover_analog_out(f, series_root)
|
|
361
|
+
|
|
362
|
+
by_name = {c.name: c for c in discovered}
|
|
363
|
+
|
|
364
|
+
if channels is None:
|
|
365
|
+
selected = discovered
|
|
366
|
+
out_names = [c.name for c in selected]
|
|
367
|
+
elif isinstance(channels, Mapping):
|
|
368
|
+
selected = []
|
|
369
|
+
out_names = []
|
|
370
|
+
for new_name, old_name in channels.items():
|
|
371
|
+
old_key = _normalise_name(str(old_name))
|
|
372
|
+
if old_key not in by_name:
|
|
373
|
+
raise KeyError(
|
|
374
|
+
f"Unknown channel {old_name!r}. "
|
|
375
|
+
f"Available: {sorted(by_name)}"
|
|
376
|
+
)
|
|
377
|
+
selected.append(by_name[old_key])
|
|
378
|
+
out_names.append(_normalise_name(str(new_name)))
|
|
379
|
+
else:
|
|
380
|
+
wanted = [_normalise_name(str(c)) for c in channels]
|
|
381
|
+
missing = [c for c in wanted if c not in by_name]
|
|
382
|
+
if missing:
|
|
383
|
+
raise KeyError(
|
|
384
|
+
f"Unknown channel(s): {missing}. "
|
|
385
|
+
f"Available: {sorted(by_name)}"
|
|
386
|
+
)
|
|
387
|
+
selected = [by_name[c] for c in wanted]
|
|
388
|
+
out_names = wanted
|
|
389
|
+
|
|
390
|
+
if not selected:
|
|
391
|
+
raise ValueError("No channels selected to load.")
|
|
392
|
+
|
|
393
|
+
# Reference timebase: first selected channel.
|
|
394
|
+
t_ref = _read_1d(f[selected[0].time_path])
|
|
395
|
+
|
|
396
|
+
signals: list[FloatArray] = []
|
|
397
|
+
for ch in selected:
|
|
398
|
+
t = _read_1d(f[ch.time_path])
|
|
399
|
+
y = _read_1d(f[ch.signal_path])
|
|
400
|
+
signals.append(_align_to_reference(t_ref, t, y, mode=align))
|
|
401
|
+
|
|
402
|
+
if align == "truncate":
|
|
403
|
+
n = min([t_ref.shape[0], *[s.shape[0] for s in signals]])
|
|
404
|
+
t_ref = t_ref[:n]
|
|
405
|
+
signals = [s[:n] for s in signals]
|
|
406
|
+
|
|
407
|
+
stacked = np.stack(signals, axis=0)
|
|
408
|
+
|
|
409
|
+
meta: dict[str, Any] = {
|
|
410
|
+
"file": str(path),
|
|
411
|
+
"fpconsole": fpconsole,
|
|
412
|
+
"series": chosen_series,
|
|
413
|
+
"available_series": series_names,
|
|
414
|
+
"source": chosen_source,
|
|
415
|
+
"available_sources": available,
|
|
416
|
+
"align": align,
|
|
417
|
+
"channels": {
|
|
418
|
+
name: by_name.get(name).attrs if name in by_name else {}
|
|
419
|
+
for name in out_names
|
|
420
|
+
},
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
gs_path = f"Configurations/{fpconsole}/GlobalSettings"
|
|
424
|
+
ss_path = f"Configurations/{fpconsole}/SavingSettings"
|
|
425
|
+
if gs_path in f:
|
|
426
|
+
meta["global_settings"] = _get_attrs(f[gs_path])
|
|
427
|
+
if ss_path in f:
|
|
428
|
+
meta["saving_settings"] = _get_attrs(f[ss_path])
|
|
429
|
+
|
|
430
|
+
return PhotometryState(
|
|
431
|
+
time_seconds=t_ref,
|
|
432
|
+
signals=stacked,
|
|
433
|
+
channel_names=tuple(out_names),
|
|
434
|
+
metadata=meta,
|
|
435
|
+
)
|