1use std::collections::{BTreeMap, HashMap};
4
5use crate::{StampedRecord, SyncResult};
6
7#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
9pub struct TopicId(pub String);
10
11impl TopicId {
12 #[must_use]
14 pub fn new(value: impl Into<String>) -> Self {
15 Self(value.into())
16 }
17
18 #[must_use]
20 pub fn as_str(&self) -> &str {
21 &self.0
22 }
23}
24
25impl From<&str> for TopicId {
26 fn from(value: &str) -> Self {
27 Self(value.to_owned())
28 }
29}
30
31impl From<String> for TopicId {
32 fn from(value: String) -> Self {
33 Self(value)
34 }
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
39pub struct SyncWindow {
40 pub max_delta_ns: u64,
42 pub max_uncertainty_ns: u64,
44}
45
46impl Default for SyncWindow {
47 fn default() -> Self {
48 Self { max_delta_ns: 10_000_000, max_uncertainty_ns: 5_000_000 }
49 }
50}
51
52#[derive(Clone, Debug, Default, PartialEq)]
54pub struct EpisodeIndex {
55 entries: BTreeMap<(u64, String, u64), usize>,
57}
58
59impl EpisodeIndex {
60 pub fn build(records: &[StampedRecord]) -> Self {
62 let mut entries = BTreeMap::new();
63 for (ordinal, record) in records.iter().enumerate() {
64 entries
65 .insert((record.stamp.as_nanos(), record.topic.0.clone(), ordinal as u64), ordinal);
66 }
67 Self { entries }
68 }
69
70 pub fn ordered_indices(&self) -> impl Iterator<Item = usize> + '_ {
72 self.entries.values().copied()
73 }
74}
75
76#[derive(Clone, Debug, Default, PartialEq)]
78pub struct MemoryEpisode {
79 records: Vec<StampedRecord>,
80 index: EpisodeIndex,
81}
82
83impl MemoryEpisode {
84 #[must_use]
86 pub fn from_records(mut records: Vec<StampedRecord>) -> Self {
87 records.sort_by(|a, b| {
89 (a.stamp.as_nanos(), a.topic.as_str()).cmp(&(b.stamp.as_nanos(), b.topic.as_str()))
90 });
91 let index = EpisodeIndex::build(&records);
92 Self { records, index }
93 }
94
95 #[must_use]
97 pub fn records(&self) -> &[StampedRecord] {
98 &self.records
99 }
100
101 #[must_use]
103 pub fn index(&self) -> &EpisodeIndex {
104 &self.index
105 }
106}
107
108pub struct DeterministicReplayer<'a> {
110 episode: &'a MemoryEpisode,
111 cursor: usize,
112 order: Vec<usize>,
113}
114
115impl<'a> DeterministicReplayer<'a> {
116 #[must_use]
118 pub fn new(episode: &'a MemoryEpisode) -> Self {
119 let order = episode.index.ordered_indices().collect();
120 Self { episode, cursor: 0, order }
121 }
122
123 pub fn next_record(&mut self) -> Option<&'a StampedRecord> {
125 let index = *self.order.get(self.cursor)?;
126 self.cursor += 1;
127 self.episode.records.get(index)
128 }
129
130 pub fn next_bundle(
133 &mut self,
134 primary: &TopicId,
135 others: &[TopicId],
136 window: SyncWindow,
137 ) -> SyncResult<Option<HashMap<TopicId, &'a StampedRecord>>> {
138 while let Some(candidate) = self.next_record() {
139 if &candidate.topic != primary {
140 continue;
141 }
142 if !candidate.stamp.quality.is_tight(window.max_uncertainty_ns) {
143 continue;
144 }
145 let mut bundle = HashMap::new();
146 bundle.insert(primary.clone(), candidate);
147 let mut ok = true;
148 for topic in others {
149 match nearest_match(self.episode, topic, candidate.stamp.as_nanos(), window) {
150 Some(matched) => {
151 bundle.insert(topic.clone(), matched);
152 }
153 None => {
154 ok = false;
155 break;
156 }
157 }
158 }
159 if ok {
160 return Ok(Some(bundle));
161 }
162 }
163 Ok(None)
164 }
165}
166
167fn nearest_match<'a>(
168 episode: &'a MemoryEpisode,
169 topic: &TopicId,
170 center_ns: u64,
171 window: SyncWindow,
172) -> Option<&'a StampedRecord> {
173 episode
174 .records
175 .iter()
176 .filter(|record| {
177 &record.topic == topic
178 && record.stamp.quality.is_tight(window.max_uncertainty_ns)
179 && record.stamp.as_nanos().abs_diff(center_ns) <= window.max_delta_ns
180 })
181 .min_by_key(|record| record.stamp.as_nanos().abs_diff(center_ns))
182}
183
184#[cfg(test)]
185mod tests {
186 use super::{DeterministicReplayer, MemoryEpisode, SyncWindow, TopicId};
187 use crate::{ClockDomain, StampedRecord, StampedTime};
188 use spatialrust_core::{
189 PointBuffer, PointBufferSet, PointCloud, SpatialMetadata, StandardSchemas, Timestamp,
190 };
191 use spatialrust_records::{SchemaVersion, SpatialRecord};
192
193 fn sample(topic: &str, nanos: u64, x: f32) -> StampedRecord {
194 let mut buffers = PointBufferSet::new();
195 buffers.insert("x", PointBuffer::from_f32(vec![x]));
196 buffers.insert("y", PointBuffer::from_f32(vec![0.0]));
197 buffers.insert("z", PointBuffer::from_f32(vec![0.0]));
198 let cloud = PointCloud::try_from_parts(
199 StandardSchemas::point_xyz(),
200 buffers,
201 SpatialMetadata::default(),
202 )
203 .unwrap();
204 let record =
205 SpatialRecord::try_from_cloud("point", SchemaVersion::new(1, 0), cloud).unwrap();
206 StampedRecord::new(
207 topic,
208 StampedTime::exact("host", ClockDomain::HostSteady, Timestamp::from_nanos(nanos)),
209 record,
210 )
211 }
212
213 #[test]
214 fn replays_in_timestamp_order_and_bundles_topics() {
215 let episode = MemoryEpisode::from_records(vec![
216 sample("lidar", 30, 3.0),
217 sample("camera", 10, 1.0),
218 sample("lidar", 12, 2.0),
219 sample("camera", 40, 4.0),
220 ]);
221 let mut replayer = DeterministicReplayer::new(&episode);
222 let first = replayer.next_record().unwrap();
223 assert_eq!(first.topic.as_str(), "camera");
224 assert_eq!(first.stamp.as_nanos(), 10);
225
226 let mut replayer = DeterministicReplayer::new(&episode);
227 let bundle = replayer
228 .next_bundle(
229 &TopicId::new("camera"),
230 &[TopicId::new("lidar")],
231 SyncWindow { max_delta_ns: 5, max_uncertainty_ns: 0 },
232 )
233 .unwrap()
234 .unwrap();
235 assert_eq!(bundle.get(&TopicId::new("camera")).unwrap().stamp.as_nanos(), 10);
236 assert_eq!(bundle.get(&TopicId::new("lidar")).unwrap().stamp.as_nanos(), 12);
237 }
238}