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.
@@ -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
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .doric import read_doric
4
+ from .excel import read_excel
5
+
6
+ __all__ = ["read_doric", "read_excel"]
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
+ )