Skip to content

Commit 49bdfcd

Browse files
committed
fix sorting issue for concatinating backends
1 parent 004ea94 commit 49bdfcd

4 files changed

Lines changed: 186 additions & 8 deletions

File tree

meson.build

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ project(
88
'themachinethatgoesping_pingprocessing',
99
'cpp',
1010
license: 'MPL-2.0',
11-
version: '0.14.0',
11+
version: '0.14.1',
1212
default_options: ['warning_level=0', 'buildtype=release', 'cpp_std=c++23'],
1313
meson_version: '>=1.8.1' #there is a problem with meson 1.8.0 so just use a higher version
1414
)

python/tests/watercolumn/echograms/test_concat_combine_params.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,75 @@ def test_depth_only_builder_has_res_ranges_none_not_attribute_error():
152152
# attribute (None) rather than raising AttributeError.
153153
b = EchogramBuilder.from_backend(_make_backend([100.0, 101.0, 102.0]))
154154
assert b.coord_system.res_ranges is None
155+
156+
157+
# ---------------------------------------------------------------------------
158+
# concat(sort_by_time=True) must produce a strictly increasing timeline even
159+
# when backends overlap in time. Sorting inputs by start time alone leaves the
160+
# concatenated ping times non-monotonic, which the coordinate system's time
161+
# feature (a strictly-increasing interpolator) rejects. These reproduce that
162+
# crash and verify the per-ping time ordering keeps data access consistent.
163+
# ---------------------------------------------------------------------------
164+
165+
166+
def _make_valued_backend(times, row_values, n_samples=8):
167+
"""Backend whose every sample in ping i equals row_values[i] (so a column
168+
can be traced back to the ping it came from)."""
169+
times = np.asarray(times, dtype=np.float64)
170+
rows = np.asarray(row_values, dtype=np.float32).reshape(len(times), 1)
171+
image = np.repeat(rows, n_samples, axis=1)
172+
return ImageBackend.from_image(
173+
image, times, y_min=0.0, y_max=20.0, y_axis="depth"
174+
)
175+
176+
177+
def test_concat_sort_by_time_interleaves_overlapping_backends():
178+
# Even vs odd timestamps -> the two backends must interleave ping-by-ping.
179+
a = _make_valued_backend([0.0, 2.0, 4.0], [0.0, 2.0, 4.0])
180+
b = _make_valued_backend([1.0, 3.0, 5.0], [1.0, 3.0, 5.0])
181+
182+
combined = EchogramBuilder.concat([a, b], sort_by_time=True)
183+
backend = combined.backend
184+
185+
times = np.asarray(backend.ping_times, dtype=np.float64)
186+
assert np.all(np.diff(times) > 0) # strictly increasing
187+
np.testing.assert_array_equal(times, [0.0, 1.0, 2.0, 3.0, 4.0, 5.0])
188+
# Each public column must carry the value of the ping at that time.
189+
for g in range(6):
190+
assert backend.get_column(g)[0] == times[g]
191+
192+
193+
def test_concat_sort_by_time_handles_fully_duplicate_backends():
194+
# Real-world trigger: the same recording exported under two paths, so both
195+
# backends carry identical timestamps. Previously raised
196+
# "X list is not sorted in ascending order!" from set_ping_times.
197+
times = [100.0, 101.0, 102.0]
198+
a = _make_valued_backend(times, [10.0, 11.0, 12.0])
199+
b = _make_valued_backend(times, [10.0, 11.0, 12.0])
200+
201+
combined = EchogramBuilder.concat([a, b], sort_by_time=True)
202+
pt = np.asarray(combined.backend.ping_times, dtype=np.float64)
203+
204+
assert len(pt) == 6 # no pings dropped
205+
assert np.all(np.diff(pt) > 0) # duplicates nudged to strictly increasing
206+
207+
# The full display path (time feature + image) must not raise.
208+
combined.set_x_axis_date_time(max_steps=64)
209+
combined.set_y_axis_depth(max_steps=64)
210+
image, _ = combined.build_image()
211+
assert np.isfinite(image).any()
212+
213+
214+
def test_concat_sort_by_time_leaves_sorted_timeline_untouched():
215+
# Non-overlapping, already-ordered inputs must not be permuted or nudged.
216+
a = _make_valued_backend([100.0, 101.0, 102.0], [1.0, 2.0, 3.0])
217+
b = _make_valued_backend([200.0, 201.0, 202.0], [4.0, 5.0, 6.0])
218+
219+
combined = EchogramBuilder.concat([a, b], sort_by_time=True)
220+
backend = combined.backend
221+
222+
assert backend._order is None # fast path, no permutation
223+
np.testing.assert_array_equal(
224+
np.asarray(backend.ping_times, dtype=np.float64),
225+
[100.0, 101.0, 102.0, 200.0, 201.0, 202.0],
226+
)

python/themachinethatgoesping/pingprocessing/watercolumn/echograms/backends/concat_backend.py

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,27 @@
1414
from ..indexers import EchogramImageRequest
1515

1616

17+
def _make_strictly_increasing(times: np.ndarray, eps: float = 1e-4) -> np.ndarray:
18+
"""Return a copy of ``times`` nudged to be strictly increasing.
19+
20+
Assumes ``times`` is already non-decreasing. Equal / zero-gap timestamps
21+
(common when overlapping echograms are concatenated) are pushed forward by
22+
``eps`` seconds so the downstream strictly-increasing time interpolator
23+
accepts them.
24+
"""
25+
t = np.asarray(times, dtype=np.float64).copy()
26+
if t.size < 2:
27+
return t
28+
bad = np.where(np.diff(t) <= 0)[0]
29+
# Each pass fixes the first collision in every run of equal values; a run of
30+
# N equal timestamps needs at most N-1 passes, which is tiny for real ping
31+
# timelines (typically just duplicated pairs).
32+
while bad.size > 0:
33+
t[bad + 1] = t[bad] + eps
34+
bad = np.where(np.diff(t) <= 0)[0]
35+
return t
36+
37+
1738
class ConcatBackend(EchogramDataBackend):
1839
"""Virtual backend that concatenates multiple backends along X (ping) axis.
1940
@@ -36,6 +57,7 @@ def __init__(
3657
self,
3758
backends: List[EchogramDataBackend],
3859
gap_handling: str = "preserve",
60+
sort_by_time: bool = False,
3961
):
4062
"""Initialize ConcatBackend.
4163
@@ -45,6 +67,10 @@ def __init__(
4567
gap_handling: How to handle gaps between backends:
4668
- "preserve": Keep real time gaps (x-axis shows true times)
4769
- "continuous": Virtual continuous (ignore gaps between files)
70+
sort_by_time: If True, reorder all pings into a single strictly
71+
increasing timeline. Needed when backends overlap in time
72+
(sorting inputs by start time alone leaves the concatenated
73+
times non-monotonic, which the coordinate system rejects).
4874
4975
Raises:
5076
ValueError: If backends list is empty or has incompatible metadata.
@@ -57,7 +83,15 @@ def __init__(
5783

5884
self._backends = backends
5985
self._gap_handling = gap_handling
60-
86+
self._sort_by_time = sort_by_time
87+
88+
# Per-ping time ordering. ``_order`` maps a public (sorted) ping index
89+
# to its physical position in the backend concatenation; ``_inv_order``
90+
# is the inverse. ``None`` means the public order already equals the
91+
# physical concatenation order (fast path).
92+
self._order = None
93+
self._inv_order = None
94+
6195
# Validate and collect metadata
6296
self._validate_backends()
6397

@@ -67,6 +101,10 @@ def __init__(
67101
# Combine metadata from all backends
68102
self._compute_combined_metadata()
69103

104+
# Reorder pings into a strictly increasing timeline when requested.
105+
if self._sort_by_time:
106+
self._apply_time_ordering()
107+
70108
def _validate_backends(self):
71109
"""Validate that backends are compatible for concatenation."""
72110
first = self._backends[0]
@@ -193,23 +231,85 @@ def _concat_ping_params(self) -> Dict[str, Tuple[str, Tuple[np.ndarray, np.ndarr
193231

194232
return result
195233

234+
def _apply_time_ordering(self):
235+
"""Reorder pings so the combined timeline is strictly increasing.
236+
237+
Sorting inputs by their start time (as done by ``concat``) is not
238+
enough when backends overlap in time (e.g. the same recording exported
239+
under two paths): the concatenated ping times then step backwards at a
240+
backend boundary, which the coordinate system's time feature (a
241+
strictly-increasing interpolator) rejects.
242+
243+
This performs a stable global sort of all pings by time and applies the
244+
resulting permutation to every per-ping array. Exact-duplicate / zero
245+
gap timestamps are nudged apart so the timeline is strictly increasing.
246+
The permutation is routed through ``_global_to_local`` so data access
247+
stays correct; it is left as ``None`` (fast path) when the concatenated
248+
timeline is already sorted.
249+
"""
250+
times = np.asarray(self._ping_times, dtype=np.float64)
251+
if times.size < 2:
252+
return
253+
254+
if np.any(np.diff(times) < 0):
255+
order = np.argsort(times, kind="stable")
256+
times = times[order]
257+
else:
258+
order = None
259+
260+
# The time feature interpolator forbids equal (zero-gap) x values.
261+
if np.any(np.diff(times) <= 0):
262+
times = _make_strictly_increasing(times)
263+
264+
if order is None:
265+
# Physical order already time-sorted; publish the (possibly nudged)
266+
# times and keep the identity mapping.
267+
self._ping_times = times
268+
return
269+
270+
# Apply the permutation to every per-ping array so ping_times, extents
271+
# and data access all refer to the same ping.
272+
self._ping_times = times
273+
self._max_sample_counts = self._max_sample_counts[order]
274+
self._sample_nr_min = self._sample_nr_min[order]
275+
self._sample_nr_max = self._sample_nr_max[order]
276+
if self._range_min is not None:
277+
self._range_min = self._range_min[order]
278+
self._range_max = self._range_max[order]
279+
if self._depth_min is not None:
280+
self._depth_min = self._depth_min[order]
281+
self._depth_max = self._depth_max[order]
282+
if self._latitudes is not None:
283+
self._latitudes = self._latitudes[order]
284+
self._longitudes = self._longitudes[order]
285+
286+
self._order = order
287+
self._inv_order = np.empty(order.size, dtype=np.int64)
288+
self._inv_order[order] = np.arange(order.size, dtype=np.int64)
289+
196290
def _global_to_local(self, global_ping: int) -> Tuple[int, int]:
197291
"""Convert global ping index to (backend_index, local_ping_index).
198292
199293
Uses binary search for O(log n) lookup.
200294
"""
201295
if global_ping < 0 or global_ping >= self._n_pings:
202296
raise IndexError(f"Ping index {global_ping} out of range [0, {self._n_pings})")
203-
297+
298+
# Map the public (sorted) index back to its physical concat position.
299+
physical = global_ping if self._order is None else int(self._order[global_ping])
300+
204301
# Binary search: find which backend contains this ping
205-
backend_idx = bisect_right(self._cumulative_pings, global_ping) - 1
206-
local_ping = global_ping - self._cumulative_pings[backend_idx]
302+
backend_idx = bisect_right(self._cumulative_pings, physical) - 1
303+
local_ping = physical - self._cumulative_pings[backend_idx]
207304

208305
return backend_idx, local_ping
209306

210307
def _local_to_global(self, backend_idx: int, local_ping: int) -> int:
211-
"""Convert (backend_index, local_ping_index) to global ping index."""
212-
return self._cumulative_pings[backend_idx] + local_ping
308+
"""Convert (backend_index, local_ping_index) to global (public) ping index."""
309+
physical = self._cumulative_pings[backend_idx] + local_ping
310+
if self._inv_order is None:
311+
return physical
312+
return int(self._inv_order[physical])
213313

214314
# =========================================================================
215315
# Metadata properties
@@ -333,6 +433,12 @@ def get_raw_column(self, ping_index: int) -> np.ndarray:
333433

334434
def get_chunk(self, start_ping: int, end_ping: int) -> np.ndarray:
335435
"""Get a chunk of WCI data spanning potentially multiple backends."""
436+
# When pings were reordered by time, a contiguous public range no longer
437+
# maps to contiguous per-backend ranges; fall back to the per-ping
438+
# gather (which honours the permutation via get_column).
439+
if self._order is not None:
440+
return super().get_chunk(start_ping, end_ping)
441+
336442
# Find which backends are involved
337443
start_backend, start_local = self._global_to_local(start_ping)
338444
end_backend, end_local = self._global_to_local(end_ping - 1)

python/themachinethatgoesping/pingprocessing/watercolumn/echograms/echogrambuilder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ def _start_time(backend: "EchogramDataBackend"):
465465

466466
backends.sort(key=_start_time)
467467

468-
concat_backend = ConcatBackend(backends, gap_handling=gap_handling)
468+
concat_backend = ConcatBackend(backends, gap_handling=gap_handling, sort_by_time=sort_by_time)
469469
result = cls(concat_backend)
470470
# Coordinate-system params added after construction (e.g. detect_bottom
471471
# results referenced by 'Ping index') are not part of the backend and

0 commit comments

Comments
 (0)