necksense 1.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.
- necksense/__init__.py +37 -0
- necksense/__main__.py +6 -0
- necksense/antennal.py +336 -0
- necksense/cli.py +399 -0
- necksense/kenyon.py +487 -0
- necksense/mushroom.py +390 -0
- necksense/py.typed +1 -0
- necksense-1.1.0.dist-info/METADATA +299 -0
- necksense-1.1.0.dist-info/RECORD +13 -0
- necksense-1.1.0.dist-info/WHEEL +5 -0
- necksense-1.1.0.dist-info/entry_points.txt +2 -0
- necksense-1.1.0.dist-info/licenses/LICENSE +21 -0
- necksense-1.1.0.dist-info/top_level.txt +1 -0
necksense/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""NeckSense: Drosophila Antennal Lobe + Mushroom Body edge sensory intelligence.
|
|
2
|
+
|
|
3
|
+
Product #2 under the NeckLink fruit-fly brain suite:
|
|
4
|
+
- :class:`AntennalNormalizer` (dual-timescale drift adaptation + Olsen-Wilson LN gain)
|
|
5
|
+
- :class:`KenyonHasher` & :class:`KenyonIndex` (zero-multiply sparse FlyHash + Popcount search)
|
|
6
|
+
- :class:`MushroomNoveltyDetector` (1-shot MBON-alpha'3 anti-Hebbian anomaly detector)
|
|
7
|
+
- :class:`MushroomValenceClassifier` (1-shot / few-shot DAN->KC->MBON classifier)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .antennal import AntennalNormalizer, Q16_ONE, float_to_q16, q16_to_float
|
|
11
|
+
from .kenyon import (
|
|
12
|
+
KenyonHasher,
|
|
13
|
+
KenyonIndex,
|
|
14
|
+
KenyonMatch,
|
|
15
|
+
build_projection_indices,
|
|
16
|
+
popcount_overlap,
|
|
17
|
+
popcount_u64,
|
|
18
|
+
)
|
|
19
|
+
from .mushroom import MushroomNoveltyDetector, MushroomValenceClassifier
|
|
20
|
+
|
|
21
|
+
__version__ = "1.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"Q16_ONE",
|
|
26
|
+
"float_to_q16",
|
|
27
|
+
"q16_to_float",
|
|
28
|
+
"AntennalNormalizer",
|
|
29
|
+
"KenyonHasher",
|
|
30
|
+
"KenyonIndex",
|
|
31
|
+
"KenyonMatch",
|
|
32
|
+
"build_projection_indices",
|
|
33
|
+
"popcount_overlap",
|
|
34
|
+
"popcount_u64",
|
|
35
|
+
"MushroomNoveltyDetector",
|
|
36
|
+
"MushroomValenceClassifier",
|
|
37
|
+
]
|
necksense/__main__.py
ADDED
necksense/antennal.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Antennal Lobe sensory conditioning and divisive lateral inhibition.
|
|
2
|
+
|
|
3
|
+
Implements dual-timescale olfactory receptor neuron (ORN) baseline adaptation
|
|
4
|
+
followed by Olsen-Wilson GABAergic local interneuron (LN) divisive gain
|
|
5
|
+
normalization:
|
|
6
|
+
|
|
7
|
+
r_i = max(0, u_i)^n / (sigma^n + (gamma * mean_pool)^n + max(0, u_i)^n)
|
|
8
|
+
|
|
9
|
+
References:
|
|
10
|
+
- Olsen, S. R. & Wilson, R. I. (2008). Lateral presynaptic inhibition
|
|
11
|
+
mediates gain control in an olfactory circuit. Nature, 452, 956-960.
|
|
12
|
+
- Nagel, K. I. & Wilson, R. I. (2011). Biophysical mechanisms underlying
|
|
13
|
+
olfactory receptor neuron dynamics. Nature Neuroscience, 14, 208-216.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
Q16_ONE: int = 1 << 16
|
|
21
|
+
_INT32_MIN: int = -(1 << 31)
|
|
22
|
+
_INT32_MAX: int = (1 << 31) - 1
|
|
23
|
+
_UINT64_SHIFT_GUARD: int = 1 << 47
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def float_to_q16(values: np.ndarray | float) -> np.ndarray:
|
|
27
|
+
"""Convert floating-point values into clamped Q16.16 signed int32 integers."""
|
|
28
|
+
arr = np.asarray(values, dtype=np.float64)
|
|
29
|
+
scaled = np.rint(arr * float(Q16_ONE))
|
|
30
|
+
clamped = np.clip(scaled, _INT32_MIN, _INT32_MAX)
|
|
31
|
+
return clamped.astype(np.int32)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def q16_to_float(values: np.ndarray) -> np.ndarray:
|
|
35
|
+
"""Convert Q16.16 integer values back into float64."""
|
|
36
|
+
return np.asarray(values, dtype=np.float64) / float(Q16_ONE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AntennalNormalizer:
|
|
40
|
+
"""Dual-timescale receptor drift removal + Olsen-Wilson LN normalization.
|
|
41
|
+
|
|
42
|
+
Subtracts slow tonic baseline drift (thermal, humidity, or sensor aging)
|
|
43
|
+
while preserving fast phasic plume/event transients, then applies lateral
|
|
44
|
+
GABAergic divisive normalization so combinatorial channel patterns remain
|
|
45
|
+
invariant across 100x+ concentration swings without amplifying zero-input
|
|
46
|
+
sensor noise.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
n_channels : int
|
|
51
|
+
Number of sensory input channels (`D >= 1`).
|
|
52
|
+
sigma : float, default=0.01
|
|
53
|
+
Semisaturation constant (`sigma > 0`) that suppresses low-level
|
|
54
|
+
electronic noise when no plume/event is present.
|
|
55
|
+
gamma : float, default=1.0
|
|
56
|
+
Lateral `LN` divisive inhibition strength multiplying `mean_pool`.
|
|
57
|
+
hill_n : float, default=2.0
|
|
58
|
+
Hill exponent `n` (`n > 0`). In `c_parity=True` mode, `hill_n` must be
|
|
59
|
+
`1` or `2`.
|
|
60
|
+
tonic_alpha : float, default=0.01
|
|
61
|
+
Slow tonic baseline drift adaptation rate in `[0, 1]`.
|
|
62
|
+
tonic_down_alpha : float | None, default=None
|
|
63
|
+
Baseline recovery rate when signal drops below tonic estimate. Defaults
|
|
64
|
+
to `min(1.0, tonic_alpha * 5.0)` when `tonic_alpha > 0`, else `0.0`.
|
|
65
|
+
tonic_slew_limit : float | None, default=0.02
|
|
66
|
+
Maximum upward baseline increment per step so high-concentration plumes
|
|
67
|
+
do not pull up the baseline and blind subsequent weak plumes. Set `None`
|
|
68
|
+
to disable slew clamping.
|
|
69
|
+
phasic_gain : float, default=0.5
|
|
70
|
+
Gain applied to fast phasic transients (`x - fast_state`).
|
|
71
|
+
fast_alpha : float, default=0.35
|
|
72
|
+
Adaptation rate of the fast receptor state in `[0, 1]`.
|
|
73
|
+
c_parity : bool, default=False
|
|
74
|
+
If `True`, executes Q16.16 fixed-point integer arithmetic matching
|
|
75
|
+
`csrc/necksense.c` (`ns_antennal_step` / `ns_antennal_normalize`).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
n_channels: int,
|
|
81
|
+
*,
|
|
82
|
+
sigma: float = 0.01,
|
|
83
|
+
gamma: float = 1.0,
|
|
84
|
+
hill_n: float = 2.0,
|
|
85
|
+
tonic_alpha: float = 0.01,
|
|
86
|
+
tonic_down_alpha: float | None = None,
|
|
87
|
+
tonic_slew_limit: float | None = 0.02,
|
|
88
|
+
phasic_gain: float = 0.5,
|
|
89
|
+
fast_alpha: float = 0.35,
|
|
90
|
+
c_parity: bool = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
if n_channels < 1:
|
|
93
|
+
raise ValueError("n_channels must be >= 1")
|
|
94
|
+
if sigma <= 0.0:
|
|
95
|
+
raise ValueError("sigma must be > 0")
|
|
96
|
+
if gamma < 0.0:
|
|
97
|
+
raise ValueError("gamma must be >= 0")
|
|
98
|
+
if hill_n <= 0.0:
|
|
99
|
+
raise ValueError("hill_n must be > 0")
|
|
100
|
+
if not (0.0 <= tonic_alpha <= 1.0):
|
|
101
|
+
raise ValueError("tonic_alpha must be in [0, 1]")
|
|
102
|
+
if phasic_gain < 0.0:
|
|
103
|
+
raise ValueError("phasic_gain must be >= 0")
|
|
104
|
+
if not (0.0 <= fast_alpha <= 1.0):
|
|
105
|
+
raise ValueError("fast_alpha must be in [0, 1]")
|
|
106
|
+
if c_parity and int(hill_n) not in (1, 2):
|
|
107
|
+
raise ValueError("c_parity=True supports integer hill_n in {1, 2}")
|
|
108
|
+
|
|
109
|
+
self.n_channels = int(n_channels)
|
|
110
|
+
self.sigma = float(sigma)
|
|
111
|
+
self.gamma = float(gamma)
|
|
112
|
+
self.hill_n = float(int(hill_n) if c_parity else hill_n)
|
|
113
|
+
self.tonic_alpha = float(tonic_alpha)
|
|
114
|
+
if tonic_down_alpha is None:
|
|
115
|
+
self.tonic_down_alpha = min(1.0, self.tonic_alpha * 5.0) if self.tonic_alpha > 0.0 else 0.0
|
|
116
|
+
else:
|
|
117
|
+
if not (0.0 <= tonic_down_alpha <= 1.0):
|
|
118
|
+
raise ValueError("tonic_down_alpha must be in [0, 1]")
|
|
119
|
+
self.tonic_down_alpha = float(tonic_down_alpha)
|
|
120
|
+
if tonic_slew_limit is not None and tonic_slew_limit < 0.0:
|
|
121
|
+
raise ValueError("tonic_slew_limit must be >= 0 or None")
|
|
122
|
+
self.tonic_slew_limit = None if tonic_slew_limit is None else float(tonic_slew_limit)
|
|
123
|
+
self.phasic_gain = float(phasic_gain)
|
|
124
|
+
self.fast_alpha = float(fast_alpha)
|
|
125
|
+
self.c_parity = bool(c_parity)
|
|
126
|
+
|
|
127
|
+
# Q16.16 fixed-point parameters for exact C99 parity
|
|
128
|
+
self._sigma_q16 = max(1, int(round(self.sigma * Q16_ONE)))
|
|
129
|
+
self._gamma_q16 = int(round(self.gamma * Q16_ONE))
|
|
130
|
+
self._tonic_alpha_q16 = int(round(self.tonic_alpha * Q16_ONE))
|
|
131
|
+
self._tonic_down_q16 = int(round(self.tonic_down_alpha * Q16_ONE))
|
|
132
|
+
self._tonic_slew_q16 = (
|
|
133
|
+
0 if self.tonic_slew_limit is None else max(1, int(round(self.tonic_slew_limit * Q16_ONE)))
|
|
134
|
+
)
|
|
135
|
+
self._phasic_gain_q16 = int(round(self.phasic_gain * Q16_ONE))
|
|
136
|
+
self._fast_alpha_q16 = int(round(self.fast_alpha * Q16_ONE))
|
|
137
|
+
|
|
138
|
+
self.tonic_baseline = np.zeros(self.n_channels, dtype=np.float64)
|
|
139
|
+
self.fast_state = np.zeros(self.n_channels, dtype=np.float64)
|
|
140
|
+
self._tonic_q16 = np.zeros(self.n_channels, dtype=np.int32)
|
|
141
|
+
self._fast_q16 = np.zeros(self.n_channels, dtype=np.int32)
|
|
142
|
+
|
|
143
|
+
def reset(self, baseline: np.ndarray | float | None = None) -> None:
|
|
144
|
+
"""Reset tonic and fast receptor states to zero or an explicit baseline."""
|
|
145
|
+
if baseline is None:
|
|
146
|
+
self.tonic_baseline.fill(0.0)
|
|
147
|
+
self.fast_state.fill(0.0)
|
|
148
|
+
self._tonic_q16.fill(0)
|
|
149
|
+
self._fast_q16.fill(0)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
b = np.broadcast_to(np.asarray(baseline, dtype=np.float64), (self.n_channels,)).copy()
|
|
153
|
+
self.tonic_baseline[:] = b
|
|
154
|
+
self.fast_state[:] = b
|
|
155
|
+
b_q16 = float_to_q16(b)
|
|
156
|
+
self._tonic_q16[:] = b_q16
|
|
157
|
+
self._fast_q16[:] = b_q16
|
|
158
|
+
|
|
159
|
+
def calibrate(self, ambient_samples: np.ndarray) -> np.ndarray:
|
|
160
|
+
"""Lock tonic and fast receptor baselines to clean-air / ambient data."""
|
|
161
|
+
arr = np.asarray(ambient_samples, dtype=np.float64)
|
|
162
|
+
if arr.ndim == 1:
|
|
163
|
+
if arr.shape[0] != self.n_channels:
|
|
164
|
+
raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[0]}")
|
|
165
|
+
mean_vec = arr.copy()
|
|
166
|
+
elif arr.ndim == 2:
|
|
167
|
+
if arr.shape[1] != self.n_channels:
|
|
168
|
+
raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[1]}")
|
|
169
|
+
mean_vec = arr.mean(axis=0)
|
|
170
|
+
else:
|
|
171
|
+
raise ValueError("ambient_samples must be 1D (D,) or 2D (T, D)")
|
|
172
|
+
self.reset(mean_vec)
|
|
173
|
+
return mean_vec
|
|
174
|
+
|
|
175
|
+
def _ln_normalize_q16_vec(self, pos_q16: np.ndarray) -> np.ndarray:
|
|
176
|
+
"""Exact Q16.16 integer Olsen-Wilson LN normalization for a 1D vector."""
|
|
177
|
+
d = self.n_channels
|
|
178
|
+
p_u64 = pos_q16.astype(np.uint64)
|
|
179
|
+
mean_pool = int(np.sum(p_u64, dtype=np.uint64)) // d
|
|
180
|
+
lat_q16 = (mean_pool * self._gamma_q16) >> 16
|
|
181
|
+
out = np.zeros(d, dtype=np.int32)
|
|
182
|
+
hill_n_int = int(self.hill_n)
|
|
183
|
+
|
|
184
|
+
if hill_n_int == 1:
|
|
185
|
+
base_den = self._sigma_q16 + lat_q16
|
|
186
|
+
for i in range(d):
|
|
187
|
+
num = int(p_u64[i])
|
|
188
|
+
den = base_den + num
|
|
189
|
+
out[i] = 0 if den == 0 else int((num << 16) // den)
|
|
190
|
+
else:
|
|
191
|
+
sig2 = self._sigma_q16 * self._sigma_q16
|
|
192
|
+
lat2 = lat_q16 * lat_q16
|
|
193
|
+
base_den = sig2 + lat2
|
|
194
|
+
for i in range(d):
|
|
195
|
+
p_val = int(p_u64[i])
|
|
196
|
+
num = p_val * p_val
|
|
197
|
+
den = base_den + num
|
|
198
|
+
if den == 0:
|
|
199
|
+
out[i] = 0
|
|
200
|
+
else:
|
|
201
|
+
n_s = num
|
|
202
|
+
d_s = den
|
|
203
|
+
while n_s > _UINT64_SHIFT_GUARD:
|
|
204
|
+
n_s >>= 2
|
|
205
|
+
d_s >>= 2
|
|
206
|
+
out[i] = Q16_ONE if d_s == 0 else int((n_s << 16) // d_s)
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
def normalize_q16(self, x_q16: np.ndarray) -> np.ndarray:
|
|
210
|
+
"""Stateless Q16.16 Olsen-Wilson LN normalization returning int32 Q16.16."""
|
|
211
|
+
arr = np.asarray(x_q16, dtype=np.int32)
|
|
212
|
+
if arr.ndim == 1:
|
|
213
|
+
if arr.shape[0] != self.n_channels:
|
|
214
|
+
raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[0]}")
|
|
215
|
+
pos = np.maximum(arr, 0)
|
|
216
|
+
return self._ln_normalize_q16_vec(pos)
|
|
217
|
+
if arr.ndim == 2:
|
|
218
|
+
if arr.shape[1] != self.n_channels:
|
|
219
|
+
raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[1]}")
|
|
220
|
+
out = np.empty_like(arr, dtype=np.int32)
|
|
221
|
+
for t in range(arr.shape[0]):
|
|
222
|
+
out[t] = self._ln_normalize_q16_vec(np.maximum(arr[t], 0))
|
|
223
|
+
return out
|
|
224
|
+
raise ValueError("x_q16 must be 1D (D,) or 2D (N, D)")
|
|
225
|
+
|
|
226
|
+
def normalize(self, x: np.ndarray) -> np.ndarray:
|
|
227
|
+
"""Stateless Olsen-Wilson LN divisive gain normalization (no state update).
|
|
228
|
+
|
|
229
|
+
Accepts a 1D vector `(D,)` or 2D batch `(N, D)` and returns normalized
|
|
230
|
+
responses in `[0, 1)`.
|
|
231
|
+
"""
|
|
232
|
+
arr = np.asarray(x, dtype=np.float64)
|
|
233
|
+
if arr.ndim not in (1, 2):
|
|
234
|
+
raise ValueError("x must be 1D (D,) or 2D (N, D)")
|
|
235
|
+
if arr.shape[-1] != self.n_channels:
|
|
236
|
+
raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[-1]}")
|
|
237
|
+
|
|
238
|
+
if self.c_parity:
|
|
239
|
+
q_in = float_to_q16(arr)
|
|
240
|
+
q_out = self.normalize_q16(q_in)
|
|
241
|
+
return q16_to_float(q_out)
|
|
242
|
+
|
|
243
|
+
pos = np.maximum(arr, 0.0)
|
|
244
|
+
mean_pool = np.mean(pos, axis=-1, keepdims=True)
|
|
245
|
+
if self.hill_n == 2.0:
|
|
246
|
+
num = pos * pos
|
|
247
|
+
lat = self.gamma * mean_pool
|
|
248
|
+
den = (self.sigma * self.sigma) + (lat * lat) + num
|
|
249
|
+
elif self.hill_n == 1.0:
|
|
250
|
+
num = pos
|
|
251
|
+
den = self.sigma + (self.gamma * mean_pool) + num
|
|
252
|
+
else:
|
|
253
|
+
num = np.power(pos, self.hill_n)
|
|
254
|
+
den = (self.sigma ** self.hill_n) + np.power(self.gamma * mean_pool, self.hill_n) + num
|
|
255
|
+
return num / den
|
|
256
|
+
|
|
257
|
+
def step_q16(self, x_q16: np.ndarray) -> np.ndarray:
|
|
258
|
+
"""Single-step stateful Q16.16 receptor adaptation + LN normalization."""
|
|
259
|
+
arr = np.asarray(x_q16, dtype=np.int32)
|
|
260
|
+
if arr.ndim != 1 or arr.shape[0] != self.n_channels:
|
|
261
|
+
raise ValueError(f"Expected 1D vector of length {self.n_channels}")
|
|
262
|
+
|
|
263
|
+
pos = np.zeros(self.n_channels, dtype=np.int32)
|
|
264
|
+
for i in range(self.n_channels):
|
|
265
|
+
xv = int(arr[i])
|
|
266
|
+
bv = int(self._tonic_q16[i])
|
|
267
|
+
fv = int(self._fast_q16[i])
|
|
268
|
+
|
|
269
|
+
diff_tonic = xv - bv
|
|
270
|
+
diff_fast = xv - fv
|
|
271
|
+
phasic_term = (diff_fast * self._phasic_gain_q16) >> 16
|
|
272
|
+
drive = diff_tonic + phasic_term
|
|
273
|
+
if drive > 0:
|
|
274
|
+
pos[i] = _INT32_MAX if drive > _INT32_MAX else int(drive)
|
|
275
|
+
|
|
276
|
+
fv_next = fv + ((diff_fast * self._fast_alpha_q16) >> 16)
|
|
277
|
+
self._fast_q16[i] = int(np.clip(fv_next, _INT32_MIN, _INT32_MAX))
|
|
278
|
+
|
|
279
|
+
if diff_tonic >= 0:
|
|
280
|
+
step_b = (diff_tonic * self._tonic_alpha_q16) >> 16
|
|
281
|
+
if self._tonic_slew_q16 > 0 and step_b > self._tonic_slew_q16:
|
|
282
|
+
step_b = self._tonic_slew_q16
|
|
283
|
+
bv_next = bv + step_b
|
|
284
|
+
else:
|
|
285
|
+
step_b = (diff_tonic * self._tonic_down_q16) >> 16
|
|
286
|
+
bv_next = bv + step_b
|
|
287
|
+
self._tonic_q16[i] = int(np.clip(bv_next, _INT32_MIN, _INT32_MAX))
|
|
288
|
+
|
|
289
|
+
self.tonic_baseline[:] = q16_to_float(self._tonic_q16)
|
|
290
|
+
self.fast_state[:] = q16_to_float(self._fast_q16)
|
|
291
|
+
return self._ln_normalize_q16_vec(pos)
|
|
292
|
+
|
|
293
|
+
def step(self, x: np.ndarray) -> np.ndarray:
|
|
294
|
+
"""Process one sensory sample `(D,)` online with drift adaptation + LN gain."""
|
|
295
|
+
arr = np.asarray(x, dtype=np.float64)
|
|
296
|
+
if arr.ndim != 1 or arr.shape[0] != self.n_channels:
|
|
297
|
+
raise ValueError(f"Expected 1D vector of length {self.n_channels}, got {arr.shape}")
|
|
298
|
+
|
|
299
|
+
if self.c_parity:
|
|
300
|
+
q_out = self.step_q16(float_to_q16(arr))
|
|
301
|
+
return q16_to_float(q_out)
|
|
302
|
+
|
|
303
|
+
diff_tonic = arr - self.tonic_baseline
|
|
304
|
+
diff_fast = arr - self.fast_state
|
|
305
|
+
drive = diff_tonic + self.phasic_gain * diff_fast
|
|
306
|
+
|
|
307
|
+
# Update fast phasic tracker
|
|
308
|
+
self.fast_state += self.fast_alpha * diff_fast
|
|
309
|
+
|
|
310
|
+
# Update slow tonic baseline with asymmetric recovery and optional upward slew limit
|
|
311
|
+
up_step = self.tonic_alpha * diff_tonic
|
|
312
|
+
if self.tonic_slew_limit is not None:
|
|
313
|
+
up_step = np.minimum(up_step, self.tonic_slew_limit)
|
|
314
|
+
down_step = self.tonic_down_alpha * diff_tonic
|
|
315
|
+
self.tonic_baseline += np.where(diff_tonic >= 0.0, up_step, down_step)
|
|
316
|
+
|
|
317
|
+
return self.normalize(drive)
|
|
318
|
+
|
|
319
|
+
def transform(self, X: np.ndarray, *, reset_state: bool = False) -> np.ndarray:
|
|
320
|
+
"""Process a 1D sample `(D,)` or sequential 2D stream `(T, D)`."""
|
|
321
|
+
if reset_state:
|
|
322
|
+
self.reset()
|
|
323
|
+
arr = np.asarray(X, dtype=np.float64)
|
|
324
|
+
if arr.ndim == 1:
|
|
325
|
+
return self.step(arr)
|
|
326
|
+
if arr.ndim == 2:
|
|
327
|
+
if arr.shape[1] != self.n_channels:
|
|
328
|
+
raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[1]}")
|
|
329
|
+
out = np.empty_like(arr, dtype=np.float64)
|
|
330
|
+
for t in range(arr.shape[0]):
|
|
331
|
+
out[t] = self.step(arr[t])
|
|
332
|
+
return out
|
|
333
|
+
raise ValueError("X must be 1D (D,) or 2D (T, D)")
|
|
334
|
+
|
|
335
|
+
def __call__(self, x: np.ndarray) -> np.ndarray:
|
|
336
|
+
return self.transform(x)
|