Skip to content

Commit 3ce148d

Browse files
authored
Merge pull request #27 from Arcascope/grid-every-window
senpy 4.2.0: transform every window with at least min_samples samples
2 parents 27bd0b4 + 6ccc215 commit 3ce148d

10 files changed

Lines changed: 116 additions & 67 deletions

‎README.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ window grid, and they all put the same samples in the same windows:
4040

4141
* Window `k` spans `[origin + k*hop, origin + k*hop + window_s)`, with `hop = window_s - overlap_s`.
4242
Starts are `k*hop`, never an accumulated sum. Samples before the origin belong to no window.
43-
* The grid runs while a window ends no later than one median sample period past the last sample.
43+
* The grid runs through the last window holding at least `min_samples` samples, so the trailing
44+
windows the recording stops partway through are transformed like any other window with data.
4445
* `times` are window centres, `k*hop + window_s/2`, measured from the origin.
4546

4647
`senpy.window_grid(timestamps, window_s, overlap_s, origin_s=..., min_samples=...)` returns that
@@ -246,11 +247,10 @@ band at 30 s windows costs about 150 000 multiply-accumulates per second of stre
246247
subwindow straddles a window edge; one that did could not be shared by the windows either side.
247248
* **Sample rate.** Supplied rather than measured: it sets the magnitude scale and the grid size.
248249
`compute_nustft` takes the median spacing over the whole recording, which a stream cannot see.
249-
* **The trailing window.** `push` reports only windows the stream has passed the end of, which is
250-
all a live stream can honestly say. `compute_nustft` knows where the recording stops and also
251-
emits a final window ending within one sample period of the last timestamp; that one comes out
252-
of `flush()`. `compute_nustft_streaming` applies this rule for you and is the function to compare
253-
the two paths with.
250+
* **The trailing windows.** `push` reports only windows the stream has passed the end of, which is
251+
all a live stream can honestly say. The windows the data stops partway through come out of
252+
`flush()`, as `compute_nustft` reports them. `compute_nustft_streaming` does both and is the
253+
function to compare the two paths with.
254254
* **The Nyquist bin** (present only when `fmax` is unset) is the true $+N/2$ coefficient.
255255
`compute_nustft` reports its conjugate there, an artifact of reading that bin out of the aliased
256256
FINUFFT mode. Magnitudes are identical.

‎senpy/_grid.py‎

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
on the timestamps' own clock, in seconds. Its start is computed by
55
multiplication, never by accumulating ``hop``, so every backend -- the C++
66
batch transform, the streaming transform, the JAX transform and the JAX packer
7-
-- puts the same sample in the same window. The grid runs while a window ends
8-
no later than one median sample period past the last timestamp, the bound the
9-
batch transforms have always used.
7+
-- puts the same sample in the same window. The grid runs through the last
8+
window holding at least ``min_samples`` samples: every window with enough data
9+
is transformed, at the end of a recording as anywhere else.
1010
1111
Timestamps are measured from the origin once, by :func:`relative_seconds`,
1212
and every backend is handed those relative times. That is what makes the
@@ -137,22 +137,38 @@ def median_spacing(t_s: NDArray[np.float64]) -> float:
137137
return float(positive[positive.size // 2])
138138

139139

140-
def window_count(last_relative_s: float, dt_median_s: float, window_s: float, hop_s: float) -> int:
141-
"""How many windows fit: those with ``k * hop + window <= last + dt_median``.
140+
def last_window_start(last_relative_s: float, hop_s: float) -> int:
141+
"""Index of the last window starting at or before the last sample; -1 if none.
142142
143-
``last_relative_s`` is the last timestamp minus the origin. The estimate is
144-
corrected against the exact comparison, so the count agrees with a loop
145-
that tests each window in turn.
143+
Windows starting later hold no samples. The estimate is corrected against
144+
the exact comparison, so it agrees with a loop that tests each start.
146145
"""
147-
limit = last_relative_s + dt_median_s
148-
if not math.isfinite(limit) or window_s > limit:
149-
return 0
150-
n = int(math.floor((limit - window_s) / hop_s)) + 1
151-
while n > 0 and (n - 1) * hop_s + window_s > limit:
152-
n -= 1
153-
while n * hop_s + window_s <= limit:
154-
n += 1
155-
return n
146+
if not math.isfinite(last_relative_s) or last_relative_s < 0.0:
147+
return -1
148+
k = int(math.floor(last_relative_s / hop_s))
149+
while k >= 0 and k * hop_s > last_relative_s:
150+
k -= 1
151+
while (k + 1) * hop_s <= last_relative_s:
152+
k += 1
153+
return k
154+
155+
156+
def window_count(
157+
t_relative_s: NDArray[np.float64], window_s: float, hop_s: float, min_samples: int
158+
) -> int:
159+
"""How many windows the grid has: through the last one with ``min_samples`` samples.
160+
161+
``t_relative_s`` are sorted timestamps minus the origin. Windows before
162+
that last one stay on the grid whatever they hold; with
163+
``empty_windows="keep"`` those without enough samples are NaN rows.
164+
"""
165+
t = np.asarray(t_relative_s, dtype=np.float64)
166+
starts = np.arange(last_window_start(float(t[-1]), hop_s) + 1, dtype=np.int64) * hop_s
167+
counts = np.searchsorted(t, starts + window_s, side="left") - np.searchsorted(
168+
t, starts, side="left"
169+
)
170+
enough = np.flatnonzero(counts >= min_samples)
171+
return int(enough[-1]) + 1 if enough.size else 0
156172

157173

158174
@dataclass(frozen=True)
@@ -164,7 +180,7 @@ class WindowGrid:
164180
seconds. A result's ``times`` are measured from it.
165181
window_s: Window duration in seconds.
166182
hop_s: Window hop in seconds (``window_s - overlap_s``).
167-
dt_median_s: Upper median sample spacing, which sets the grid's end.
183+
dt_median_s: Upper median sample spacing, which sets the transform size.
168184
first: Index of each window's first sample, shape ``(n_windows,)``.
169185
stop: One past each window's last sample, shape ``(n_windows,)``.
170186
min_samples: Fewest samples a window needs to be transformed.
@@ -263,7 +279,7 @@ def grid_from_relative(
263279
min_samples: int,
264280
) -> WindowGrid:
265281
"""Build the grid from timestamps already expressed relative to the origin."""
266-
n = window_count(float(t_relative_s[-1]), dt_median_s, window_s, hop_s)
282+
n = window_count(t_relative_s, window_s, hop_s, min_samples)
267283
starts = np.arange(n, dtype=np.int64) * hop_s
268284
first = np.searchsorted(t_relative_s, starts, side="left").astype(np.int64)
269285
stop = np.searchsorted(t_relative_s, starts + window_s, side="left").astype(np.int64)

‎senpy/_version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "4.1.0"
1+
__version__ = "4.2.0"

‎senpy/api.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,11 +1075,9 @@ def compute_nustft_streaming(
10751075
windows: List[StreamingWindow] = []
10761076
for start in range(0, len(t), chunk):
10771077
windows.extend(transform.push(t[start : start + chunk], values[start : start + chunk]))
1078-
# push() only reports windows the stream has passed the end of. compute_nustft, which sees
1079-
# where the recording stops, also emits a final window that ends within one sample period
1080-
# of the last timestamp; take that one out of the flush and drop anything past the grid.
1078+
# push() only reports windows the stream has passed the end of; the trailing windows the
1079+
# data stops partway through come out of the flush.
10811080
windows.extend(transform.flush())
1082-
windows = [w for w in windows if w.index < grid.n_windows]
10831081

10841082
if grid.n_windows == 0 or (empty_windows == "drop" and not windows):
10851083
raise ValueError("compute_nustft_streaming requires enough data for at least one window")

‎senpy/jax_backend.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@
3434
from ._grid import (
3535
DEFAULT_MIN_SAMPLES,
3636
OriginSpec,
37+
last_window_start,
3738
median_spacing,
3839
relative_seconds,
3940
resolve_empty_windows,
4041
resolve_origin,
4142
timestamp_scale,
4243
validate_min_samples,
43-
window_count,
4444
)
4545

4646

@@ -794,10 +794,9 @@ def _compute_nustft(
794794
n_pos_freqs = nfft_padded // 2 + 1
795795

796796
# The same grid senpy._grid.window_grid builds on the host: window k starts
797-
# at k * hop after the origin.
797+
# at k * hop after the origin, through the last window with min_samples.
798798
t_end = float(host_t[-1]) if host_t is not None else float(jax.device_get(t[-1]))
799-
n_windows = window_count(t_end, dt_median, window_s, hop_s)
800-
starts = np.arange(n_windows, dtype=np.int64) * hop_s
799+
starts = np.arange(last_window_start(t_end, hop_s) + 1, dtype=np.int64) * hop_s
801800

802801
complex_dtype = jnp.result_type(s, 1j)
803802
frequencies = jnp.arange(n_pos_freqs, dtype=t.dtype) / window_s
@@ -817,7 +816,7 @@ def empty_result() -> JaxNUSTFTResult:
817816
origin_s=origin,
818817
)
819818

820-
if n_windows == 0:
819+
if starts.size == 0:
821820
return empty_result()
822821

823822
if host_t is not None:
@@ -829,6 +828,13 @@ def empty_result() -> JaxNUSTFTResult:
829828
end_indices = np.asarray(
830829
jax.device_get(jnp.searchsorted(t, starts_device + window_s, side="left"))
831830
)
831+
enough = np.flatnonzero(end_indices - start_indices >= min_samples)
832+
n_windows = int(enough[-1]) + 1 if enough.size else 0
833+
if n_windows == 0:
834+
return empty_result()
835+
starts = starts[:n_windows]
836+
start_indices = start_indices[:n_windows]
837+
end_indices = end_indices[:n_windows]
832838
grid_counts = (end_indices - start_indices).astype(np.int64)
833839
mode_indices = jnp.concatenate(
834840
(jnp.arange(nfft_padded // 2, nfft_padded), jnp.array([0]))

‎senpy/tests/test_jax_api.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ def test_compute_nustft_returns_device_arrays_with_known_tone():
6464
)
6565

6666
assert result.axis_order == "time_frequency"
67-
assert result.coefficients.shape == (3, 33)
67+
# 8 s of data, 4 s windows every 2 s: the one starting at 6 s holds the last 2 s.
68+
assert result.coefficients.shape == (4, 33)
6869
assert result.frequencies.shape == (33,)
69-
assert result.times.shape == (3,)
70+
assert result.times.shape == (4,)
7071
peak = result.frequencies[jnp.argmax(jnp.mean(result.power, axis=0))]
7172
assert float(peak) == pytest.approx(3.0)
7273

@@ -82,7 +83,7 @@ def test_compute_nufft_spectrogram_and_welch_stay_jax_backed():
8283
timestamps, signal, window_s=4.0, overlap_s=2.0
8384
)
8485

85-
assert spectrogram.Sxx.shape == (3, 129)
86+
assert spectrogram.Sxx.shape == (4, 129)
8687
assert frequencies.shape == welch.shape == (129,)
8788

8889

‎senpy/tests/test_spectral_api.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,4 +276,4 @@ def test_removed_nufft_alias_and_uniform_compatibility_alias_warns():
276276

277277

278278
def test_version_is_consolidated():
279-
assert senpy.__version__ == sp.__version__ == "4.1.0"
279+
assert senpy.__version__ == sp.__version__ == "4.2.0"

‎senpy/tests/test_streaming_nustft.py‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,10 @@ def batch_equivalent(t, signal, chunk=137, **kwargs):
5757
"""Streams the whole array and returns the windows ``compute_nustft`` would report.
5858
5959
``push`` only reports windows the stream has passed the end of, which is all a live stream
60-
can honestly say. ``compute_nustft`` knows where the recording stops, so it also emits a
61-
final window ending within one sample period of the last timestamp; that one comes out of
62-
the flush.
60+
can honestly say. The windows the data stops partway through come out of the flush.
6361
"""
6462
transform, windows = stream_through(t, signal, chunk=chunk, **kwargs)
65-
limit = float(t[-1]) + 1.0 / FS
66-
windows = windows + [w for w in transform.flush() if w.start + WINDOW_S <= limit]
67-
return transform, windows
63+
return transform, windows + transform.flush()
6864

6965

7066
def relative_deviation(actual, expected):
@@ -144,15 +140,17 @@ def test_dropouts_leave_holes_rather_than_shifting_windows():
144140
assert windows[indices.index(3)].sample_count < 3000
145141

146142

147-
def test_flush_reports_the_trailing_window_compute_nustft_stops_short_of():
143+
def test_flush_reports_the_trailing_windows_push_cannot():
148144
t, signal = strap_stream(seconds=125, gaps=())
149145
transform, windows = stream_through(t, signal)
150146
reference = senpy.compute_nustft(t, signal, window_s=WINDOW_S, overlap_s=0.0)
151147

152-
assert len(windows) == len(reference.times)
148+
# Windows 0-3 are complete; window 4 (120-150 s) holds the last 5 s.
149+
assert [w.index for w in windows] == [0, 1, 2, 3]
153150
tail = transform.flush()
154151
assert [w.index for w in tail] == [4]
155152
assert tail[0].sample_count == 500
153+
assert len(reference.times) == 5
156154

157155

158156
def test_convenience_wrapper_reproduces_compute_nustft():

‎senpy/tests/test_window_grid.py‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,41 @@ def test_grid_starts_are_whole_hops_after_the_origin():
5656
assert inside.max() < grid.starts[k] + WINDOW_S
5757

5858

59-
def test_grid_ends_where_the_batch_transforms_always_have():
60-
# Evenly spaced samples: the last window may end at most one sample past the data.
59+
def test_grid_runs_through_the_last_window_with_enough_samples():
60+
# Evenly spaced samples every 0.5 s up to 59.5 s: the window starting at 58 s
61+
# holds 4 samples (58.0-59.5), the one at 60 s none.
6162
t = np.arange(0.0, 60.0, 0.5)
6263
grid = senpy.window_grid(t, WINDOW_S, OVERLAP_S)
63-
last_end = grid.starts[-1] + WINDOW_S
6464

65-
assert last_end <= t[-1] + 0.5
66-
assert last_end + HOP_S > t[-1] + 0.5
65+
assert grid.starts[-1] == 58.0
66+
assert grid.sample_count[-1] == 4 and grid.valid[-1]
67+
# The windows the data stops partway through are on the grid and valid.
68+
assert grid.valid[grid.starts + WINDOW_S > t[-1]].all()
69+
assert senpy.window_grid(t, WINDOW_S, OVERLAP_S, min_samples=5).starts[-1] == 56.0
70+
71+
72+
def test_trailing_sparse_windows_end_the_grid_but_interior_ones_stay():
73+
t = np.concatenate([np.arange(0.0, 20.0, 0.5), [40.0, 59.0]])
74+
grid = senpy.window_grid(t, WINDOW_S, OVERLAP_S)
75+
76+
# The last window with 4 samples starts at 18 s; windows up to it that hold
77+
# fewer stay on the grid as invalid, the sparse ones after it are not on it.
78+
assert grid.starts[-1] == 18.0
79+
assert grid.valid.all()
80+
kept = senpy.window_grid(np.concatenate([t, np.arange(80.0, 82.0, 0.5)]), WINDOW_S, OVERLAP_S)
81+
assert kept.starts[-1] == 80.0
82+
assert not kept.valid[(kept.starts > 18.0) & (kept.starts < 72.0)].any()
83+
84+
85+
def test_the_end_of_the_grid_does_not_hinge_on_rounding():
86+
# np.arange drift leaves the last sample ~2e-10 s short of 239.98 s. The
87+
# window 230-240 s is full either way, and the grid runs on to the window
88+
# starting at 238 s, which holds the last 2 s.
89+
t = np.arange(7.0, 240.0, 1 / 50)
90+
grid = senpy.window_grid(t, WINDOW_S, OVERLAP_S, origin_s=0.0)
91+
92+
assert grid.starts[-1] == 238.0
93+
assert grid.sample_count[grid.starts == 230.0][0] >= 499
6794

6895

6996
def test_dropout_windows_are_counted_not_lost():
@@ -347,13 +374,12 @@ def test_jax_returns_zero_rows_when_there_is_nothing_to_report(kwargs):
347374
assert result.window_index.size == result.sample_count.size == result.valid.size == 0
348375

349376

350-
def test_keep_mode_with_every_window_too_sparse_is_all_nan_not_an_error():
377+
def test_keep_mode_with_every_window_too_sparse_has_nothing_to_report():
351378
t, x = recording()
352-
result = nustft(t, x, empty_windows="keep", min_samples=100_000)
353379

354-
assert result.valid.size == senpy.window_grid(t, WINDOW_S, OVERLAP_S).n_windows
355-
assert not result.valid.any()
356-
assert np.isnan(result.coefficients).all()
380+
assert senpy.window_grid(t, WINDOW_S, OVERLAP_S, min_samples=100_000).n_windows == 0
381+
with pytest.raises(ValueError, match="at least one window"):
382+
nustft(t, x, empty_windows="keep", min_samples=100_000)
357383

358384

359385
# ── the 5.0 default change ──────────────────────────────────────────

‎src/sensor_processing_native.cpp‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,9 @@ class SensorProcessor
317317
// Window k spans [origin + k*hop, origin + k*hop + secperseg). origin defaults (NaN) to the
318318
// first timestamp; samples before it belong to no window. Starts are k*hop, never an
319319
// accumulated sum, so senpy/_grid.py, the streaming transform and the JAX backend place every
320-
// sample in the same window. Windows with fewer than min_samples samples are not transformed;
321-
// grid_sample_counts still reports them, so callers can rebuild the full grid.
320+
// sample in the same window. The grid runs through the last window with at least min_samples
321+
// samples. Windows before it with fewer are not transformed; grid_sample_counts still reports
322+
// them, so callers can rebuild the full grid.
322323
static NUSTFTResult computeNUSTFT(
323324
const std::vector<double> &timestamps,
324325
const std::vector<double> &signal,
@@ -406,7 +407,9 @@ class SensorProcessor
406407
std::vector<double> t_rel(n);
407408
for (size_t i = 0; i < n; ++i)
408409
t_rel[i] = timestamps[i] - origin;
409-
const double limit = t_rel[n - 1] + dt_median;
410+
// Windows starting after the last sample hold none; the grid then ends at the last
411+
// window with min_samples samples (trimmed below).
412+
const double t_last = t_rel[n - 1];
410413

411414
// Use two-pointer indices to avoid rescanning timestamps for every window
412415
size_t start_idx = 0;
@@ -415,7 +418,7 @@ class SensorProcessor
415418
{
416419
const double win_start = static_cast<double>(w) * hop_dur;
417420
const double win_end = win_start + win_dur;
418-
if (!(win_end <= limit))
421+
if (!(win_start <= t_last))
419422
break;
420423

421424
// Advance start_idx to the first sample >= win_start
@@ -533,6 +536,9 @@ class SensorProcessor
533536
window_indices.push_back(w);
534537
}
535538

539+
// The grid ends at the last window with enough data to transform.
540+
grid_sample_counts.resize(window_indices.empty() ? 0 : static_cast<size_t>(window_indices.back()) + 1);
541+
536542
// Build result
537543
NUSTFTResult result;
538544
result.freqs = freqs;
@@ -1668,8 +1674,6 @@ class StreamingNUSTFT
16681674
}
16691675

16701676
// Emits every window the stream has passed the end of, oldest first, and forgets it.
1671-
// The readiness test matches computeNUSTFT's loop bound, which admits a final window whose
1672-
// end is within one sample period of the last timestamp.
16731677
std::vector<StreamingNUSTFTWindow> drain()
16741678
{
16751679
std::vector<StreamingNUSTFTWindow> ready;
@@ -1680,8 +1684,8 @@ class StreamingNUSTFT
16801684
// input, no sample belonging to it can arrive after that. Reporting a window even one
16811685
// sample period early would strand the samples that a packet whose clock drifted
16821686
// backwards still owes it -- and computeNUSTFT, which sees the whole recording, would
1683-
// have counted them. The trailing window computeNUSTFT admits when it is within one
1684-
// sample period of complete comes out of flush() instead.
1687+
// have counted them. The trailing windows the data stops partway through come out of
1688+
// flush() instead.
16851689
const double reach = newest_ - origin_ - win_dur_;
16861690
if (reach < 0.0)
16871691
return ready;
@@ -1703,8 +1707,8 @@ class StreamingNUSTFT
17031707
return ready;
17041708
}
17051709

1706-
// Emits everything still open, however partial, and resets to empty. Unlike drain(), this
1707-
// reports the trailing window computeNUSTFT would have stopped short of.
1710+
// Emits everything still open, however partial, and resets to empty: the trailing windows
1711+
// the data stops partway through, which computeNUSTFT also reports.
17081712
std::vector<StreamingNUSTFTWindow> flush()
17091713
{
17101714
std::vector<StreamingNUSTFTWindow> ready;

0 commit comments

Comments
 (0)