photom 0.4.0

Rust library for loading, structuring and querying astronomical observation datasets — with trajectory grouping, multi-observer support, and efficient lookups.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
//! Internal index structures for efficient observation look-up.
//!
//! This module is `pub(crate)` and is not part of the public API.  It
//! provides the auxiliary index maps that back the three look-up strategies
//! available on `ObsDataset`:
//!
//! | Type | Look-up key | Description |
//! |------|-------------|-------------|
//! | `ObservationIndexMap` | `ObsId` | Maps each observation identifier to its position in the observations `Vec` |
//! | `NightIndexMap` | `NightId` | Maps each night identifier to the positions of all observations recorded that night |
//! | `TrajIndexMap` | `TrajId` | Maps each trajectory identifier to the positions of all observations belonging to that trajectory |
//! | `TrajAliasMap` | `String` | Maps alternate designation strings to their canonical `TrajId` |
//!
//! All maps are backed by `AHashMap` for fast, non-cryptographic hashing.
//!
//! ## Design note
//!
//! Indices stored in these maps are zero-based positions into the
//! `observations: Vec<Observation>` field of `ObsDataset`.  Storing
//! `usize` positions rather than cloned values keeps memory usage low and
//! allows the maps to be updated or queried without holding references into
//! the main observations vector.

use ahash::AHashMap;
use itertools::Either;

use crate::{NightId, TrajId, observation_dataset::ObsId};

/// Zero-based position of an observation inside the `observations` vector of `ObsDataset`.
pub type ObsIndex = usize;

/// Hash map from observation identifier to its position in the observations vector.
pub type ObservationIndexMap = AHashMap<ObsId, ObsIndex>;

/// List of observation positions for a given night.
///
/// This enum allows for two different internal representations of the night index:
/// - `Contiguous` for nights whose observations occupy a single contiguous block of positions in the main vector,
///   storing only the start and end positions of that block.
/// - `Split` for nights whose observations are scattered across multiple non-contiguous positions.
#[derive(Debug, Clone)]
pub enum ObsMapIndex {
    /// Observations for this night occupy a single contiguous block of positions in the main vector, from `start` (inclusive) to `end` (exclusive).
    // Constructed only by the `polars` ingestion path; matched everywhere.
    #[cfg_attr(not(feature = "polars"), allow(dead_code))]
    Contiguous { start: ObsIndex, end: ObsIndex },
    /// Observations for this night are scattered across multiple non-contiguous positions in the main vector, stored as a list of indices.
    Split(Vec<ObsIndex>),
}

impl ObsMapIndex {
    /// Push an observation index into this entry, assuming it is (or will be) the `Split` variant.
    ///
    /// # Panics
    ///
    /// Panics if called on a `Contiguous` entry (contiguous entries are built by range, not by
    /// individual push).
    #[cfg_attr(not(feature = "polars"), allow(dead_code))]
    pub(crate) fn push_split(&mut self, idx: ObsIndex) {
        match self {
            ObsMapIndex::Split(vec) => vec.push(idx),
            ObsMapIndex::Contiguous { .. } => {
                panic!("push_split called on a Contiguous ObsMapIndex entry")
            }
        }
    }
}

/// Hash map from night identifier to the list of observation positions recorded on that night.
pub type NightIndexMap = AHashMap<NightId, ObsMapIndex>;

/// Hash map from trajectory identifier to the list of observation positions assigned to that trajectory.
pub type TrajIndexMap = AHashMap<TrajId, ObsMapIndex>;

/// Maps alternate designation strings to their canonical [`TrajId`].
///
/// Populated by ingestion backends (e.g. the MPC 80-column reader) when a
/// single file contains multiple designations for the same physical object —
/// for example an asteroid that carries both its provisional designation and
/// its permanent number.  The canonical `TrajId` is the primary key used in
/// [`TrajIndexMap`]; aliases are secondary look-up names that resolve to it.
pub type TrajAliasMap = AHashMap<String, TrajId>;

/// Shift all vector positions stored in an [`ObsMapIndex`] by `offset`,
/// preserving the variant: a [`ObsMapIndex::Contiguous`] entry remains
/// contiguous (only its bounds are shifted), and a [`ObsMapIndex::Split`]
/// entry has every element shifted individually.
fn shift_obs_map_index(idx: ObsMapIndex, offset: usize) -> ObsMapIndex {
    match idx {
        ObsMapIndex::Contiguous { start, end } => ObsMapIndex::Contiguous {
            start: start + offset,
            end: end + offset,
        },
        ObsMapIndex::Split(mut v) => {
            v.iter_mut().for_each(|i| *i += offset);
            ObsMapIndex::Split(v)
        }
    }
}

/// Merge `other_map` into `self_map`, shifting all stored positions by `offset`.
///
/// - New keys (absent from `self_map`) are inserted with their
///   [`ObsMapIndex`] variant preserved via [`shift_obs_map_index`].
/// - Colliding keys are merged: both sides are expanded into their index
///   lists, concatenated, and stored as [`ObsMapIndex::Split`].
fn merge_obs_map<K>(
    self_map: &mut AHashMap<K, ObsMapIndex>,
    other_map: AHashMap<K, ObsMapIndex>,
    offset: usize,
) where
    K: Eq + std::hash::Hash,
{
    for (key, other_idx) in other_map {
        let shifted = shift_obs_map_index(other_idx, offset);
        self_map
            .entry(key)
            .and_modify(|existing| {
                // Collision: expand both sides into a flat Vec, then store as Split.
                let mut merged: Vec<ObsIndex> = match existing {
                    ObsMapIndex::Contiguous { start, end } => (*start..*end).collect(),
                    ObsMapIndex::Split(v) => std::mem::take(v),
                };
                match &shifted {
                    ObsMapIndex::Contiguous { start, end } => merged.extend(*start..*end),
                    ObsMapIndex::Split(v) => merged.extend_from_slice(v),
                }
                *existing = ObsMapIndex::Split(merged);
            })
            .or_insert(shifted);
    }
}

/// Merge an optional index map from `other` into `self_opt`.
///
/// - If `other_opt` is `None`, nothing changes.
/// - If `self_opt` is `None` but `other_opt` is `Some`, the shifted map is
///   adopted directly via [`shift_obs_map_index`], preserving variants.
/// - If both are `Some`, entries are merged via [`merge_obs_map`].
fn merge_optional_obs_map<K>(
    self_opt: &mut Option<AHashMap<K, ObsMapIndex>>,
    other_opt: Option<AHashMap<K, ObsMapIndex>>,
    offset: usize,
) where
    K: Eq + std::hash::Hash,
{
    let Some(other_map) = other_opt else { return };

    match self_opt {
        Some(self_map) => merge_obs_map(self_map, other_map, offset),
        None => {
            // Adopt other's map, shifting all positions.
            let shifted = other_map
                .into_iter()
                .map(|(k, idx)| (k, shift_obs_map_index(idx, offset)))
                .collect();
            *self_opt = Some(shifted);
        }
    }
}

/// Composite index for an `ObsDataset`.
///
/// Bundles three independent look-up maps:
///
/// - A mandatory map from `ObsId` to the observation's vector position,
///   present for every dataset.
/// - An optional map from `NightId` to the vector positions of all
///   observations on that night; absent when the source data contains no
///   `night_id` column.
/// - An optional map from `TrajId` to the vector positions of all
///   observations in that trajectory; absent when the source data contains
///   no `traj_id` column.
/// - An alias map from alternate designation strings to their canonical
///   `TrajId`; empty when no aliases have been registered.
#[derive(Debug, Clone)]
pub struct ObsDatasetIndex {
    /// Mapping from `ObsId` to the index in the `observations` vector, used for look-up by observation identifier.
    pub(crate) obs_index_by_id: ObservationIndexMap,

    /// Mapping from `NightId` to the list of observation indices belonging to that night.
    ///
    /// `None` when the source data contained no `night_id` column.
    pub(crate) obs_index_by_night: Option<NightIndexMap>,

    /// Mapping from `TrajId` to the list of observation indices belonging to that trajectory.
    ///
    /// `None` when the source data contained no `traj_id` column.
    pub(crate) obs_index_by_trajectory: Option<TrajIndexMap>,

    /// Maps alternate designation strings to their canonical `TrajId`.
    ///
    /// Empty for most datasets; populated by the MPC 80-column reader when a
    /// file contains multiple designations for the same physical object.
    pub(crate) traj_aliases: TrajAliasMap,
}

impl ObsDatasetIndex {
    /// Construct a new `ObsDatasetIndex` from the three component maps.
    ///
    /// # Arguments
    ///
    /// - `obs_index_by_id` — mandatory map from `ObsId` to vector position.
    /// - `obs_index_by_night` — optional map from `NightId` to vector positions; pass `None`
    ///   when the source data has no `night_id` column.
    /// - `obs_index_by_trajectory` — optional map from `TrajId` to vector positions; pass
    ///   `None` when the source data has no `traj_id` column.
    ///
    /// # Returns
    ///
    /// A fully initialised `ObsDatasetIndex`.
    #[cfg_attr(not(feature = "polars"), allow(dead_code))]
    pub(crate) fn new(
        obs_index_by_id: ObservationIndexMap,
        obs_index_by_night: Option<NightIndexMap>,
        obs_index_by_trajectory: Option<TrajIndexMap>,
    ) -> Self {
        Self {
            obs_index_by_id,
            obs_index_by_night,
            obs_index_by_trajectory,
            traj_aliases: TrajAliasMap::new(),
        }
    }

    /// Return the number of observations recorded on the given night.
    ///
    /// # Arguments
    ///
    /// - `night_id` — the night whose observation count is requested.
    ///
    /// # Returns
    ///
    /// `Some(count)` if the night index exists and the night is present in it;
    /// `None` if no night index was built or the night identifier is unknown.
    pub(crate) fn len_night(&self, night_id: &NightId) -> Option<usize> {
        self.obs_index_by_night
            .as_ref()?
            .get(night_id)
            .map(|indices| match indices {
                ObsMapIndex::Contiguous { start, end } => end - start,
                ObsMapIndex::Split(vec) => vec.len(),
            })
    }

    /// Return the number of observations assigned to the given trajectory.
    ///
    /// # Arguments
    ///
    /// - `traj_id` — the trajectory whose observation count is requested.
    ///
    /// # Returns
    ///
    /// `Some(count)` if the trajectory index exists and the trajectory is present in it;
    /// `None` if no trajectory index was built or the trajectory identifier is unknown.
    pub(crate) fn len_trajectory(&self, traj_id: impl Into<TrajId>) -> Option<usize> {
        let traj_id = traj_id.into();
        self.obs_index_by_trajectory
            .as_ref()?
            .get(&traj_id)
            .map(|indices| match indices {
                ObsMapIndex::Contiguous { start, end } => end - start,
                ObsMapIndex::Split(vec) => vec.len(),
            })
    }

    /// Return an iterator over all `NightId` keys present in the night index.
    ///
    /// # Returns
    ///
    /// `Some(iterator)` if a night index was built; `None` otherwise.
    /// The iteration order is unspecified (hash map key order).
    pub(crate) fn iter_night_id(&self) -> Option<impl Iterator<Item = &NightId>> {
        self.obs_index_by_night
            .as_ref()
            .map(|night_map| night_map.keys())
    }

    /// Return an iterator over all `TrajId` keys present in the trajectory index.
    ///
    /// # Returns
    ///
    /// `Some(iterator)` if a trajectory index was built; `None` otherwise.
    /// The iteration order is unspecified (hash map key order).
    pub(crate) fn iter_traj_id(&self) -> Option<impl Iterator<Item = &TrajId>> {
        self.obs_index_by_trajectory
            .as_ref()
            .map(|traj_map| traj_map.keys())
    }

    /// Look up the vector position of an observation by its identifier.
    ///
    /// # Arguments
    ///
    /// - `obs_id` — the observation identifier to look up.
    ///
    /// # Returns
    ///
    /// `Some(index)` if the observation exists; `None` otherwise.
    pub(crate) fn get_by_id(&self, obs_id: &ObsId) -> Option<ObsIndex> {
        self.obs_index_by_id.get(obs_id).copied()
    }

    /// Return the list of vector positions for all observations on a given night.
    ///
    /// # Arguments
    ///
    /// - `night_id` — the night identifier to look up.
    ///
    /// # Returns
    ///
    /// `Some(&ObsMapIndex)` if a night index exists and the night is present in it;
    /// `None` otherwise.
    pub(crate) fn get_by_night(&self, night_id: &NightId) -> Option<&ObsMapIndex> {
        self.obs_index_by_night.as_ref()?.get(night_id)
    }

    /// Return an iterator over the vector positions of all observations on a given night.
    ///
    /// # Arguments
    ///
    /// - `night_id` — the night identifier whose observation positions are requested.
    ///
    /// # Returns
    ///
    /// `Some(iterator)` yielding each `ObsIndex` in insertion order if the night index
    /// exists and the night is present; `None` otherwise.
    pub(crate) fn iter_night_obs_index(
        &self,
        night_id: &NightId,
    ) -> Option<impl Iterator<Item = ObsIndex> + '_> {
        self.get_by_night(night_id).map(|indices| match indices {
            ObsMapIndex::Contiguous { start, end } => Either::Left(*start..*end),
            ObsMapIndex::Split(vec) => Either::Right(vec.iter().copied()),
        })
    }

    /// Return an iterator over `(NightId, ObsIndex)` pairs for every observation in the night index.
    ///
    /// Each pair associates a night identifier with the vector position of one of the
    /// observations recorded on that night.  Observations from the same night appear
    /// as consecutive pairs in the iteration, but the order between nights is
    /// unspecified (hash map key order).
    ///
    /// # Returns
    ///
    /// `Some(iterator)` if a night index was built; `None` otherwise.
    pub(crate) fn iter_full_night(&self) -> Option<impl Iterator<Item = (NightId, ObsIndex)> + '_> {
        self.obs_index_by_night.as_ref().map(|night_map| {
            night_map
                .iter()
                .flat_map(|(night_id, indices)| match indices {
                    ObsMapIndex::Contiguous { start, end } => {
                        Either::Left((*start..*end).map(move |idx| (*night_id, idx)))
                    }
                    ObsMapIndex::Split(vec) => {
                        Either::Right(vec.iter().map(move |&idx| (*night_id, idx)))
                    }
                })
        })
    }

    /// Return the list of vector positions for all observations in a given trajectory.
    ///
    /// # Arguments
    ///
    /// - `traj_id` — the trajectory identifier to look up.
    ///
    /// # Returns
    ///
    /// `Some(&ObsMapIndex)` if a trajectory index exists and the trajectory is present in it;
    /// `None` otherwise.
    pub(crate) fn get_by_trajectory(&self, traj_id: impl Into<TrajId>) -> Option<&ObsMapIndex> {
        let traj_id = traj_id.into();
        self.obs_index_by_trajectory.as_ref()?.get(&traj_id)
    }

    /// Return an iterator over the vector positions of all observations in a given trajectory.
    ///
    /// # Arguments
    ///
    /// - `traj_id` — the trajectory identifier whose observation positions are requested.
    ///
    /// # Returns
    ///
    /// `Some(iterator)` yielding each `ObsIndex` in insertion order if the trajectory index
    /// exists and the trajectory is present; `None` otherwise.
    pub(crate) fn iter_traj_obs_index(
        &self,
        traj_id: impl Into<TrajId>,
    ) -> Option<impl Iterator<Item = ObsIndex> + '_> {
        self.get_by_trajectory(traj_id)
            .map(|indices| match indices {
                ObsMapIndex::Contiguous { start, end } => Either::Left(*start..*end),
                ObsMapIndex::Split(vec) => Either::Right(vec.iter().copied()),
            })
    }

    /// Return an iterator over `(TrajId, ObsIndex)` pairs for every observation in the trajectory index.
    ///
    /// Each pair associates a trajectory identifier with the vector position of one of the
    /// observations belonging to that trajectory.  Observations from the same trajectory
    /// appear as consecutive pairs in the iteration, but the order between trajectories is
    /// unspecified (hash map key order).
    ///
    /// # Returns
    ///
    /// `Some(iterator)` if a trajectory index was built; `None` otherwise.
    pub(crate) fn iter_full_trajectory(
        &self,
    ) -> Option<impl Iterator<Item = (TrajId, ObsIndex)> + '_> {
        self.obs_index_by_trajectory.as_ref().map(|traj_map| {
            traj_map
                .iter()
                .flat_map(|(traj_id, indices)| match indices {
                    ObsMapIndex::Contiguous { start, end } => {
                        Either::Left((*start..*end).map(move |idx| (traj_id.clone(), idx)))
                    }
                    ObsMapIndex::Split(vec) => {
                        Either::Right(vec.iter().map(move |&idx| (traj_id.clone(), idx)))
                    }
                })
        })
    }

    /// Register an alternate designation string that resolves to a canonical [`TrajId`].
    ///
    /// After this call, [`ObsDatasetIndex::resolve_alias`] will return `Some(primary)`
    /// for `alias`.  If the alias was already registered it is overwritten.
    ///
    /// # Arguments
    ///
    /// - `alias` — the alternate designation string (e.g. a provisional MPC designation).
    /// - `primary` — the canonical `TrajId` that `alias` maps to.
    #[cfg(feature = "mpc_80_col")]
    pub(crate) fn register_alias(&mut self, alias: String, primary: TrajId) {
        self.traj_aliases.insert(alias, primary);
    }

    /// Look up the canonical [`TrajId`] for an alternate designation string.
    ///
    /// # Arguments
    ///
    /// - `alias` — the alternate designation string to resolve.
    ///
    /// # Returns
    ///
    /// `Some(&TrajId)` if `alias` has been registered via [`ObsDatasetIndex::register_alias`];
    /// `None` otherwise.
    pub(crate) fn resolve_alias(&self, alias: &str) -> Option<&TrajId> {
        self.traj_aliases.get(alias)
    }

    /// Iterate over all registered trajectory aliases.
    ///
    /// Yields `(alias, canonical_traj_id)` pairs in unspecified order.
    /// Used by the serde serialisation path to persist the alias map.
    #[cfg(feature = "serde")]
    pub(crate) fn iter_aliases(&self) -> impl Iterator<Item = (&str, &TrajId)> {
        self.traj_aliases.iter().map(|(k, v)| (k.as_str(), v))
    }

    /// Replace the trajectory alias map with the provided one.
    ///
    /// Used by the serde deserialisation path to restore aliases that were
    /// serialised via [`ObsDatasetIndex::iter_aliases`].
    #[cfg(feature = "serde")]
    pub(crate) fn set_aliases(&mut self, aliases: TrajAliasMap) {
        self.traj_aliases = aliases;
    }

    /// Merge another `ObsDatasetIndex` into this one, applying `offset` to all
    /// stored vector positions.
    ///
    /// Called by [`crate::observation_dataset::ObsDataset::merge_from`] after
    /// the observations from the other dataset have been appended into
    /// `self.observations`.
    ///
    /// # Merge rules
    ///
    /// - `obs_index_by_id`: every `(id, pos)` pair from `other` is inserted as
    ///   `(id, pos + offset)`.  Observation identifiers are never modified.
    /// - `obs_index_by_night` and `obs_index_by_trajectory`: merged via
    ///   [`merge_obs_map`].  New keys preserve the [`ObsMapIndex::Contiguous`]
    ///   variant (with bounds shifted by `offset`); colliding keys are merged
    ///   into a [`ObsMapIndex::Split`].
    /// - `traj_aliases`: merged with `extend`; keys from `other` overwrite
    ///   any same-key entries already present in `self`.
    #[cfg_attr(not(any(feature = "ades", feature = "mpc_80_col")), allow(dead_code))]
    pub(crate) fn merge_from(&mut self, other: ObsDatasetIndex, offset: usize) {
        // ── obs_index_by_id ────────────────────────────────────────────────
        self.obs_index_by_id.reserve(other.obs_index_by_id.len());
        for (id, pos) in other.obs_index_by_id {
            self.obs_index_by_id.insert(id, pos + offset);
        }

        // ── obs_index_by_night ─────────────────────────────────────────────
        merge_optional_obs_map(
            &mut self.obs_index_by_night,
            other.obs_index_by_night,
            offset,
        );

        // ── obs_index_by_trajectory ────────────────────────────────────────
        merge_optional_obs_map(
            &mut self.obs_index_by_trajectory,
            other.obs_index_by_trajectory,
            offset,
        );

        // ── traj_aliases ───────────────────────────────────────────────────
        self.traj_aliases.extend(other.traj_aliases);
    }

    /// Insert or replace the trajectory entry for a given `TrajId`.
    ///
    /// If the trajectory index was not built (i.e. `obs_index_by_trajectory` is `None`),
    /// this method is a no-op.  Otherwise the provided slice of observation positions is
    /// stored (as a new `Vec`) under `traj_id`, replacing any pre-existing entry for that key.
    ///
    /// # Arguments
    ///
    /// - `traj_id` — the trajectory identifier to insert or replace.
    /// - `obs_index` — slice of vector positions for the observations belonging to this trajectory.
    pub(crate) fn push_trajectory(mut self, traj_id: TrajId, obs_index: &[ObsIndex]) -> Self {
        if let Some(traj_map) = self.obs_index_by_trajectory.as_mut() {
            traj_map.insert(traj_id, ObsMapIndex::Split(obs_index.to_vec()));
        }
        self
    }
}

#[cfg(test)]
mod obs_map_index_unit_tests {

    // ── index variant unit tests (pub(crate) internals) ───────────────────────────

    use crate::{
        NightId, TrajId,
        observation_dataset::index::{
            NightIndexMap, ObsDatasetIndex, ObsMapIndex, ObservationIndexMap, TrajIndexMap,
        },
    };

    // ── test A ────────────────────────────────────────────────────────────────

    /// Verify that `ObsMapIndex::push_split` appends to a `Split` entry.
    #[test]
    fn push_split_appends_to_split_entry() {
        let mut entry = ObsMapIndex::Split(vec![0, 1]);
        entry.push_split(2);
        match entry {
            ObsMapIndex::Split(v) => assert_eq!(v, vec![0, 1, 2]),
            ObsMapIndex::Contiguous { .. } => panic!("expected Split"),
        }
    }

    // ── test B ────────────────────────────────────────────────────────────────

    /// Verify that `ObsMapIndex::push_split` panics when called on a
    /// `Contiguous` entry.
    #[test]
    #[should_panic(expected = "push_split called on a Contiguous ObsMapIndex entry")]
    fn push_split_panics_on_contiguous() {
        let mut entry = ObsMapIndex::Contiguous { start: 0, end: 5 };
        entry.push_split(5); // must panic
    }

    // ── test C ────────────────────────────────────────────────────────────────

    /// Verify that `ObsDatasetIndex::len_night` returns the correct count for
    /// both `Contiguous` and `Split` variants.
    #[test]
    fn len_night_both_variants() {
        let mut night_map: NightIndexMap = ahash::AHashMap::new();
        night_map.insert(NightId(1), ObsMapIndex::Contiguous { start: 0, end: 3 });
        night_map.insert(NightId(2), ObsMapIndex::Split(vec![4, 5]));

        let idx = ObsDatasetIndex::new(ObservationIndexMap::new(), Some(night_map), None);

        assert_eq!(
            idx.len_night(&NightId(1)),
            Some(3),
            "Contiguous(0..3) must report len 3"
        );
        assert_eq!(
            idx.len_night(&NightId(2)),
            Some(2),
            "Split([4,5]) must report len 2"
        );
        assert_eq!(
            idx.len_night(&NightId(99)),
            None,
            "unknown night must return None"
        );
    }

    // ── test D ────────────────────────────────────────────────────────────────

    /// Verify that `ObsDatasetIndex::len_trajectory` returns the correct count
    /// for both `Contiguous` and `Split` variants.
    #[test]
    fn len_trajectory_both_variants() {
        let mut traj_map: TrajIndexMap = ahash::AHashMap::new();
        traj_map.insert(
            TrajId::Int(10),
            ObsMapIndex::Contiguous { start: 0, end: 4 },
        );
        traj_map.insert(TrajId::Int(20), ObsMapIndex::Split(vec![0, 2, 4]));

        let idx = ObsDatasetIndex::new(ObservationIndexMap::new(), None, Some(traj_map));

        assert_eq!(
            idx.len_trajectory(TrajId::Int(10)),
            Some(4),
            "Contiguous(0..4) must report len 4"
        );
        assert_eq!(
            idx.len_trajectory(TrajId::Int(20)),
            Some(3),
            "Split([0,2,4]) must report len 3"
        );
        assert_eq!(
            idx.len_trajectory(TrajId::Int(99)),
            None,
            "unknown traj must return None"
        );
    }

    // ── test E ────────────────────────────────────────────────────────────────

    /// Verify that `ObsDatasetIndex::iter_night_obs_index` yields the correct
    /// indices for both `Contiguous` and `Split` entries.
    #[test]
    fn iter_night_obs_index_both_variants() {
        let mut night_map: NightIndexMap = ahash::AHashMap::new();
        night_map.insert(NightId(1), ObsMapIndex::Contiguous { start: 2, end: 5 });
        night_map.insert(NightId(2), ObsMapIndex::Split(vec![0, 7, 9]));

        let idx = ObsDatasetIndex::new(ObservationIndexMap::new(), Some(night_map), None);

        // Contiguous variant: must yield 2, 3, 4 in order.
        let contiguous_indices: Vec<_> = idx
            .iter_night_obs_index(&NightId(1))
            .expect("night 1 must be present")
            .collect();
        assert_eq!(contiguous_indices, vec![2, 3, 4]);

        // Split variant: must yield 0, 7, 9 in insertion order.
        let split_indices: Vec<_> = idx
            .iter_night_obs_index(&NightId(2))
            .expect("night 2 must be present")
            .collect();
        assert_eq!(split_indices, vec![0, 7, 9]);
    }

    // ── test F ────────────────────────────────────────────────────────────────

    /// Verify that `ObsDatasetIndex::iter_traj_obs_index` yields the correct
    /// indices for both `Contiguous` and `Split` entries.
    #[test]
    fn iter_traj_obs_index_both_variants() {
        let mut traj_map: TrajIndexMap = ahash::AHashMap::new();
        traj_map.insert(
            TrajId::Int(10),
            ObsMapIndex::Contiguous { start: 0, end: 3 },
        );
        traj_map.insert(TrajId::Int(20), ObsMapIndex::Split(vec![5, 6]));

        let idx = ObsDatasetIndex::new(ObservationIndexMap::new(), None, Some(traj_map));

        let contiguous_indices: Vec<_> = idx
            .iter_traj_obs_index(&TrajId::Int(10))
            .expect("traj 10 must be present")
            .collect();
        assert_eq!(contiguous_indices, vec![0, 1, 2]);

        let split_indices: Vec<_> = idx
            .iter_traj_obs_index(&TrajId::Int(20))
            .expect("traj 20 must be present")
            .collect();
        assert_eq!(split_indices, vec![5, 6]);
    }

    // ── test G ────────────────────────────────────────────────────────────────

    /// Verify that `ObsDatasetIndex::push_trajectory` stores a `Split` entry
    /// with exactly the provided observation indices, replacing any existing
    /// entry for that key.
    #[test]
    fn push_trajectory_stores_split() {
        let mut traj_map: TrajIndexMap = ahash::AHashMap::new();
        // Pre-seed a Split entry that push_trajectory will overwrite.
        traj_map.insert(TrajId::Int(10), ObsMapIndex::Split(vec![99]));

        let idx = ObsDatasetIndex::new(ObservationIndexMap::new(), None, Some(traj_map));

        // Replace the entry with new indices.
        let idx_with_new_traj = idx.push_trajectory(TrajId::Int(10), &[0, 2, 4]);

        let entry = idx_with_new_traj
            .get_by_trajectory(TrajId::Int(10))
            .expect("traj 10 must exist after push");
        match entry {
            ObsMapIndex::Split(v) => assert_eq!(v, &[0, 2, 4]),
            ObsMapIndex::Contiguous { .. } => panic!("push_trajectory must produce Split"),
        }
    }

    // ── test H ────────────────────────────────────────────────────────────────

    /// Verify that `push_trajectory` is a no-op when there is no trajectory
    /// index (`obs_index_by_trajectory` is `None`).
    #[test]
    fn push_trajectory_noop_when_no_traj_index() {
        let idx = ObsDatasetIndex::new(ObservationIndexMap::new(), None, None);
        // Should not panic; the call is silently ignored.
        let idx_with_new_traj = idx.push_trajectory(TrajId::Int(42), &[0, 1, 2]);
        assert!(
            idx_with_new_traj
                .get_by_trajectory(TrajId::Int(42))
                .is_none(),
            "no traj index → get_by_trajectory must return None"
        );
    }

    // ── merge tests ───────────────────────────────────────────────────────────

    /// Verify that obs_index_by_id keys are not offset during merge (only positions are shifted).
    #[test]
    fn merge_from_obs_id_key_not_offset() {
        let mut id_map_self = ObservationIndexMap::new();
        id_map_self.insert(10, 0);
        let mut self_idx = ObsDatasetIndex::new(id_map_self, None, None);

        let mut id_map_other = ObservationIndexMap::new();
        id_map_other.insert(20, 0); // pos 0 in other → pos 1 in merged (offset=1)
        let other_idx = ObsDatasetIndex::new(id_map_other, None, None);

        self_idx.merge_from(other_idx, 1);

        // Key 20 must remain 20 (not 21).
        assert_eq!(
            self_idx.get_by_id(&20),
            Some(1),
            "id key must not be offset; position must be shifted by 1"
        );
        // Original key 10 must still resolve correctly.
        assert_eq!(self_idx.get_by_id(&10), Some(0));
    }

    /// Verify that a Contiguous traj entry from `other` is preserved as
    /// Contiguous (with shifted bounds) when the key is absent from `self`.
    #[test]
    fn merge_from_traj_contiguous_preserved_for_new_key() {
        let self_idx = ObsDatasetIndex::new(ObservationIndexMap::new(), None, None);

        let mut traj_map = TrajIndexMap::new();
        traj_map.insert(TrajId::Int(1), ObsMapIndex::Contiguous { start: 0, end: 3 });
        let other_idx = ObsDatasetIndex::new(ObservationIndexMap::new(), None, Some(traj_map));
        // Give other_idx a traj index so it gets adopted.
        let mut self_idx = self_idx;
        // self has no traj index → will adopt other's.
        self_idx.merge_from(other_idx, 5);

        match self_idx.get_by_trajectory(TrajId::Int(1)).unwrap() {
            ObsMapIndex::Contiguous { start, end } => {
                assert_eq!(*start, 5, "start must be shifted by offset");
                assert_eq!(*end, 8, "end must be shifted by offset");
            }
            ObsMapIndex::Split(_) => panic!("expected Contiguous, got Split"),
        }
    }

    /// Verify that a Contiguous traj entry inserted into an already-populated
    /// self traj index (new key) is also preserved as Contiguous.
    #[test]
    fn merge_from_traj_contiguous_preserved_when_self_has_other_keys() {
        let mut self_traj = TrajIndexMap::new();
        self_traj.insert(TrajId::Int(99), ObsMapIndex::Split(vec![0]));
        let mut self_idx = ObsDatasetIndex::new(ObservationIndexMap::new(), None, Some(self_traj));

        let mut other_traj = TrajIndexMap::new();
        other_traj.insert(TrajId::Int(1), ObsMapIndex::Contiguous { start: 0, end: 2 });
        let other_idx = ObsDatasetIndex::new(ObservationIndexMap::new(), None, Some(other_traj));

        self_idx.merge_from(other_idx, 4);

        match self_idx.get_by_trajectory(TrajId::Int(1)).unwrap() {
            ObsMapIndex::Contiguous { start, end } => {
                assert_eq!(*start, 4);
                assert_eq!(*end, 6);
            }
            ObsMapIndex::Split(_) => panic!("expected Contiguous, got Split"),
        }
    }

    /// Verify that a colliding traj key produces a correct Split containing
    /// indices from both sides.
    #[test]
    fn merge_from_traj_collision_produces_split() {
        let mut self_traj = TrajIndexMap::new();
        self_traj.insert(
            TrajId::Int(1),
            ObsMapIndex::Contiguous { start: 0, end: 2 }, // indices 0, 1
        );
        let mut self_idx = ObsDatasetIndex::new(ObservationIndexMap::new(), None, Some(self_traj));

        let mut other_traj = TrajIndexMap::new();
        other_traj.insert(
            TrajId::Int(1),
            ObsMapIndex::Contiguous { start: 0, end: 2 }, // indices 0, 1 → shifted to 2, 3
        );
        let other_idx = ObsDatasetIndex::new(ObservationIndexMap::new(), None, Some(other_traj));

        self_idx.merge_from(other_idx, 2);

        match self_idx.get_by_trajectory(TrajId::Int(1)).unwrap() {
            ObsMapIndex::Split(v) => assert_eq!(v, &[0, 1, 2, 3]),
            ObsMapIndex::Contiguous { .. } => panic!("expected Split after collision"),
        }
    }

    /// Verify that obs_index_by_night is merged correctly and Contiguous is
    /// preserved for new night keys.
    #[test]
    fn merge_from_night_contiguous_preserved_for_new_key() {
        let mut self_night = NightIndexMap::new();
        self_night.insert(NightId(1), ObsMapIndex::Contiguous { start: 0, end: 2 });
        let mut self_idx = ObsDatasetIndex::new(ObservationIndexMap::new(), Some(self_night), None);

        let mut other_night = NightIndexMap::new();
        other_night.insert(NightId(2), ObsMapIndex::Contiguous { start: 0, end: 3 });
        let other_idx = ObsDatasetIndex::new(ObservationIndexMap::new(), Some(other_night), None);

        self_idx.merge_from(other_idx, 2);

        // Night 1 unchanged.
        match self_idx.get_by_night(&NightId(1)).unwrap() {
            ObsMapIndex::Contiguous { start, end } => {
                assert_eq!((*start, *end), (0, 2));
            }
            _ => panic!("expected Contiguous for night 1"),
        }
        // Night 2 shifted by offset=2.
        match self_idx.get_by_night(&NightId(2)).unwrap() {
            ObsMapIndex::Contiguous { start, end } => {
                assert_eq!((*start, *end), (2, 5));
            }
            ObsMapIndex::Split(_) => panic!("expected Contiguous for new night key"),
        }
    }

    /// Verify that a colliding night key produces a correct Split.
    #[test]
    fn merge_from_night_collision_produces_split() {
        let mut self_night = NightIndexMap::new();
        self_night.insert(NightId(1), ObsMapIndex::Split(vec![0, 1]));
        let mut self_idx = ObsDatasetIndex::new(ObservationIndexMap::new(), Some(self_night), None);

        let mut other_night = NightIndexMap::new();
        other_night.insert(NightId(1), ObsMapIndex::Split(vec![0, 1])); // shifted to 2, 3
        let other_idx = ObsDatasetIndex::new(ObservationIndexMap::new(), Some(other_night), None);

        self_idx.merge_from(other_idx, 2);

        match self_idx.get_by_night(&NightId(1)).unwrap() {
            ObsMapIndex::Split(v) => assert_eq!(v, &[0, 1, 2, 3]),
            ObsMapIndex::Contiguous { .. } => panic!("expected Split after collision"),
        }
    }
}