1414from ..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+
1738class 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 )
0 commit comments