1use crate::{CalibrationEvidenceState, ReplayArtifact, StudioSource, ViewerError, ViewerResult};
9
10pub const FULL_BAG_MAPPING_STATE_VERSION: u32 = 1;
12
13#[derive(Clone, Debug, PartialEq, Eq)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
17pub struct MappingSourceSummary {
18 pub front_topic: String,
20 pub rear_topic: String,
22 pub front_bag_message_count: u64,
24 pub rear_bag_message_count: u64,
26 pub front_chunk_count: u64,
28 pub rear_chunk_count: u64,
30 pub front_record_count: u64,
32 pub rear_record_count: u64,
34 pub total_record_count: u64,
36 pub total_point_count: u64,
38 pub retained_bytes: u64,
40 pub peak_source_bytes: u64,
42 pub start_nanos: Option<u64>,
44 pub end_nanos: Option<u64>,
46 pub full_bag_processed: bool,
48 pub truncated: bool,
50}
51
52impl MappingSourceSummary {
53 pub fn validate(&self) -> ViewerResult<()> {
55 let expected_total =
56 self.front_record_count.checked_add(self.rear_record_count).ok_or_else(|| {
57 ViewerError::InvalidState("mapping source record total overflow".into())
58 })?;
59 if self.front_topic.trim().is_empty()
60 || self.rear_topic.trim().is_empty()
61 || self.front_topic == self.rear_topic
62 || self.front_bag_message_count < self.front_record_count
63 || self.rear_bag_message_count < self.rear_record_count
64 || self.front_chunk_count < self.front_record_count
65 || self.rear_chunk_count < self.rear_record_count
66 || self.total_record_count != expected_total
67 || (self.total_record_count == 0 && self.total_point_count != 0)
68 || self.truncated && self.full_bag_processed
69 {
70 return Err(ViewerError::InvalidState(
71 "mapping source summary has invalid topic or bounded totals".into(),
72 ));
73 }
74 if let (Some(start), Some(end)) = (self.start_nanos, self.end_nanos) {
75 if start > end {
76 return Err(ViewerError::InvalidState(
77 "mapping source timestamp bounds are reversed".into(),
78 ));
79 }
80 } else if self.start_nanos.is_some() != self.end_nanos.is_some() {
81 return Err(ViewerError::InvalidState(
82 "mapping source timestamp bounds must be complete".into(),
83 ));
84 }
85 if self.full_bag_processed
86 && (self.total_record_count == 0
87 || self.total_point_count == 0
88 || self.retained_bytes == 0
89 || self.peak_source_bytes == 0)
90 {
91 return Err(ViewerError::InvalidState(
92 "a completed full-bag mapping run must retain source records and points".into(),
93 ));
94 }
95 Ok(())
96 }
97}
98
99#[derive(Clone, Debug, PartialEq, Eq)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
103pub struct MappingOdometrySummary {
104 pub topic: String,
106 pub source_frame: String,
108 pub root_frame: String,
110 pub clock_id: String,
112 pub clock_domain: String,
114 pub matcher: String,
116 pub scan_count: u64,
118 pub motion_count: u64,
120 pub pose_graph_node_count: u64,
122 pub pose_graph_edge_count: u64,
124 pub complete: bool,
126 pub truncated: bool,
128}
129
130impl MappingOdometrySummary {
131 pub fn validate(&self) -> ViewerResult<()> {
133 if self.topic.trim().is_empty()
134 || self.source_frame.trim().is_empty()
135 || self.root_frame.trim().is_empty()
136 || self.clock_id.trim().is_empty()
137 || self.clock_domain.trim().is_empty()
138 || self.matcher.trim().is_empty()
139 || self.motion_count > self.scan_count
140 || self.pose_graph_node_count != self.scan_count
141 || self.pose_graph_edge_count != self.motion_count
142 || self.truncated && self.complete
143 {
144 return Err(ViewerError::InvalidState(
145 "mapping odometry summary has invalid identity or graph totals".into(),
146 ));
147 }
148 if self.complete
149 && (self.scan_count < 2 || self.motion_count.checked_add(1) != Some(self.scan_count))
150 {
151 return Err(ViewerError::InvalidState(
152 "completed mapping odometry requires a connected scan trajectory".into(),
153 ));
154 }
155 Ok(())
156 }
157}
158
159#[derive(Clone, Debug, PartialEq)]
161#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
162#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
163pub struct MappingTsdfSummary {
164 pub frame_id: String,
166 pub origin: [f32; 3],
168 pub voxel_size: f32,
170 pub dims: [usize; 3],
172 pub truncation: f32,
174 pub integrated_record_count: u64,
176 pub integrated_point_count: u64,
178 pub mesh_vertex_count: u64,
180 pub mesh_triangle_count: u64,
182 pub complete: bool,
184}
185
186impl MappingTsdfSummary {
187 pub fn validate(&self) -> ViewerResult<()> {
189 if self.frame_id.trim().is_empty()
190 || self.origin.iter().any(|value| !value.is_finite())
191 || !self.voxel_size.is_finite()
192 || self.voxel_size <= 0.0
193 || self.dims.contains(&0)
194 || !self.truncation.is_finite()
195 || self.truncation <= 0.0
196 || self.mesh_vertex_count == 0 && self.mesh_triangle_count != 0
197 {
198 return Err(ViewerError::InvalidState(
199 "mapping TSDF summary has invalid volume or mesh values".into(),
200 ));
201 }
202 if self.complete && (self.integrated_record_count == 0 || self.integrated_point_count == 0)
203 {
204 return Err(ViewerError::InvalidState(
205 "completed mapping TSDF requires integrated records and points".into(),
206 ));
207 }
208 Ok(())
209 }
210}
211
212#[derive(Clone, Debug, PartialEq, Eq)]
214#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
215#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
216pub struct MappingGateSummary {
217 pub calibration_registered: bool,
219 pub clock_applied: bool,
221 pub frame_graph_applied: bool,
223 pub full_bag_processed: bool,
225 pub odometry_complete: bool,
227 pub tsdf_complete: bool,
229 pub mapping_admitted: bool,
231}
232
233#[derive(Clone, Debug, PartialEq)]
235#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
236#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
237pub struct FullBagMappingState {
238 pub version: u32,
240 pub title: String,
242 pub source: StudioSource,
244 pub calibration: Option<CalibrationEvidenceState>,
246 pub source_summary: MappingSourceSummary,
248 pub odometry: Option<MappingOdometrySummary>,
250 pub tsdf: Option<MappingTsdfSummary>,
252 pub artifacts: Vec<ReplayArtifact>,
254 pub summary: MappingGateSummary,
256 pub blockers: Vec<String>,
258}
259
260impl FullBagMappingState {
261 #[allow(clippy::too_many_arguments)]
263 pub fn try_new(
264 title: impl Into<String>,
265 source: StudioSource,
266 calibration: Option<CalibrationEvidenceState>,
267 source_summary: MappingSourceSummary,
268 odometry: Option<MappingOdometrySummary>,
269 tsdf: Option<MappingTsdfSummary>,
270 artifacts: Vec<ReplayArtifact>,
271 clock_applied: bool,
272 frame_graph_applied: bool,
273 mut blockers: Vec<String>,
274 ) -> ViewerResult<Self> {
275 let calibration_registered = calibration.as_ref().is_some_and(|calibration| {
276 calibration.registration_ready
277 && calibration.source.identity_matches
278 && calibration.source.path == source.path
279 && calibration.source.observed_sha256 == source.observed_sha256
280 });
281 let full_bag_processed = source_summary.full_bag_processed && !source_summary.truncated;
282 let odometry_complete =
283 odometry.as_ref().is_some_and(|odometry| odometry.complete && !odometry.truncated);
284 let tsdf_complete = tsdf.as_ref().is_some_and(|tsdf| tsdf.complete);
285
286 if !source.identity_matches {
287 push_blocker(
288 &mut blockers,
289 "mapping input source identity does not match expected SHA-256",
290 );
291 }
292 if calibration.is_none() {
293 push_blocker(&mut blockers, "source-bound calibration evidence was not supplied");
294 } else if !calibration_registered {
295 push_blocker(
296 &mut blockers,
297 "source-bound calibration evidence registration is incomplete",
298 );
299 }
300 if !full_bag_processed {
301 push_blocker(
302 &mut blockers,
303 "full-bag ingest was not completed because an admission gate blocked execution or a configured bound was reached",
304 );
305 }
306 if !clock_applied {
307 push_blocker(
308 &mut blockers,
309 "registered clock model was not applied to the mapping timeline",
310 );
311 }
312 if !frame_graph_applied {
313 push_blocker(
314 &mut blockers,
315 "registered frame graph was not applied to sensor geometry",
316 );
317 }
318 if !odometry_complete {
319 push_blocker(&mut blockers, "full-bag frame-aware odometry did not complete");
320 }
321 if !tsdf_complete {
322 push_blocker(&mut blockers, "full-bag TSDF integration did not complete");
323 }
324
325 let summary = MappingGateSummary {
326 calibration_registered,
327 clock_applied,
328 frame_graph_applied,
329 full_bag_processed,
330 odometry_complete,
331 tsdf_complete,
332 mapping_admitted: source.identity_matches
333 && calibration_registered
334 && clock_applied
335 && frame_graph_applied
336 && full_bag_processed
337 && odometry_complete
338 && tsdf_complete
339 && blockers.is_empty(),
340 };
341 let state = Self {
342 version: FULL_BAG_MAPPING_STATE_VERSION,
343 title: title.into(),
344 source,
345 calibration,
346 source_summary,
347 odometry,
348 tsdf,
349 artifacts,
350 summary,
351 blockers,
352 };
353 state.validate()?;
354 Ok(state)
355 }
356
357 pub fn validate(&self) -> ViewerResult<()> {
359 if self.version != FULL_BAG_MAPPING_STATE_VERSION {
360 return Err(ViewerError::InvalidState(format!(
361 "unsupported full-bag mapping state version {}",
362 self.version
363 )));
364 }
365 if self.title.trim().is_empty() {
366 return Err(ViewerError::InvalidState(
367 "full-bag mapping title must not be empty".into(),
368 ));
369 }
370 self.source.validate()?;
371 self.source_summary.validate()?;
372 if let Some(calibration) = &self.calibration {
373 calibration.validate()?;
374 }
375 if let Some(odometry) = &self.odometry {
376 odometry.validate()?;
377 }
378 if let Some(tsdf) = &self.tsdf {
379 tsdf.validate()?;
380 }
381 let mut roles = std::collections::BTreeSet::new();
382 let mut paths = std::collections::BTreeSet::new();
383 for artifact in &self.artifacts {
384 artifact.validate()?;
385 if !roles.insert(&artifact.role) || !paths.insert(&artifact.path) {
386 return Err(ViewerError::InvalidState(
387 "full-bag mapping artifacts must have unique roles and paths".into(),
388 ));
389 }
390 }
391
392 let calculated_calibration = self.calibration.as_ref().is_some_and(|calibration| {
393 calibration.registration_ready
394 && calibration.source.identity_matches
395 && calibration.source.path == self.source.path
396 && calibration.source.observed_sha256 == self.source.observed_sha256
397 });
398 let calculated_full =
399 self.source_summary.full_bag_processed && !self.source_summary.truncated;
400 let calculated_odometry =
401 self.odometry.as_ref().is_some_and(|odometry| odometry.complete && !odometry.truncated);
402 let calculated_tsdf = self.tsdf.as_ref().is_some_and(|tsdf| tsdf.complete);
403 if self.summary.calibration_registered != calculated_calibration
404 || self.summary.full_bag_processed != calculated_full
405 || self.summary.odometry_complete != calculated_odometry
406 || self.summary.tsdf_complete != calculated_tsdf
407 || (self.summary.clock_applied && !calculated_calibration)
408 || (self.summary.frame_graph_applied && !calculated_calibration)
409 {
410 return Err(ViewerError::InvalidState(
411 "full-bag mapping summary disagrees with source or stage receipts".into(),
412 ));
413 }
414 let calculated_mapping = self.source.identity_matches
415 && calculated_calibration
416 && self.summary.clock_applied
417 && self.summary.frame_graph_applied
418 && calculated_full
419 && calculated_odometry
420 && calculated_tsdf
421 && self.blockers.is_empty();
422 if self.summary.mapping_admitted != calculated_mapping {
423 return Err(ViewerError::InvalidState(
424 "mapping_admitted disagrees with full-bag mapping gates".into(),
425 ));
426 }
427 if self.summary.mapping_admitted && !self.blockers.is_empty() {
428 return Err(ViewerError::InvalidState(
429 "admitted full-bag mapping cannot contain blockers".into(),
430 ));
431 }
432 if !self.summary.mapping_admitted && self.blockers.is_empty() {
433 return Err(ViewerError::InvalidState(
434 "blocked full-bag mapping must expose at least one blocker".into(),
435 ));
436 }
437 if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
438 return Err(ViewerError::InvalidState(
439 "full-bag mapping blockers must not be empty".into(),
440 ));
441 }
442 Ok(())
443 }
444}
445
446fn push_blocker(blockers: &mut Vec<String>, blocker: impl Into<String>) {
447 let blocker = blocker.into();
448 if !blockers.iter().any(|existing| existing == &blocker) {
449 blockers.push(blocker);
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::{
457 CalibrationArtifact, CalibrationEvidenceClock, CalibrationEvidenceFrame, ClockCalibration,
458 FrameTransform,
459 };
460
461 const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
462
463 fn source() -> StudioSource {
464 StudioSource::try_new("fixture", "/media/fixture.db3", SHA, SHA, true).unwrap()
465 }
466
467 fn source_summary() -> MappingSourceSummary {
468 MappingSourceSummary {
469 front_topic: "/front".into(),
470 rear_topic: "/rear".into(),
471 front_bag_message_count: 0,
472 rear_bag_message_count: 0,
473 front_chunk_count: 0,
474 rear_chunk_count: 0,
475 front_record_count: 0,
476 rear_record_count: 0,
477 total_record_count: 0,
478 total_point_count: 0,
479 retained_bytes: 0,
480 peak_source_bytes: 0,
481 start_nanos: None,
482 end_nanos: None,
483 full_bag_processed: false,
484 truncated: false,
485 }
486 }
487
488 #[test]
489 fn missing_calibration_is_a_valid_blocked_state() {
490 let state = FullBagMappingState::try_new(
491 "fixture mapping",
492 source(),
493 None,
494 source_summary(),
495 None,
496 None,
497 Vec::new(),
498 false,
499 false,
500 Vec::new(),
501 )
502 .unwrap();
503 assert!(!state.summary.mapping_admitted);
504 assert!(state.blockers.iter().any(|blocker| blocker.contains("calibration")));
505 state.validate().unwrap();
506 }
507
508 #[test]
509 fn complete_source_bound_stages_are_admitted() {
510 let calibration = CalibrationEvidenceState::try_new(
511 "fixture calibration",
512 source(),
513 CalibrationArtifact::try_new(
514 "clock_evidence",
515 "registered",
516 Some("/media/clock.json".into()),
517 Some(SHA.into()),
518 true,
519 )
520 .unwrap(),
521 CalibrationArtifact::try_new(
522 "frame_evidence",
523 "registered",
524 Some("/media/frame.json".into()),
525 Some(SHA.into()),
526 true,
527 )
528 .unwrap(),
529 CalibrationEvidenceClock::try_new(
530 "sensor",
531 "external",
532 "fixture clock fit",
533 ClockCalibration::try_new(
534 "registered",
535 "anchored external clock",
536 2,
537 Some(1.0),
538 Some(2.0),
539 Some(0.0),
540 Some(3.0),
541 true,
542 false,
543 )
544 .unwrap(),
545 )
546 .unwrap(),
547 CalibrationEvidenceFrame::try_new(
548 "fixture extrinsic fit",
549 "base_link",
550 std::collections::BTreeMap::from([
551 ("front".into(), "front_frame".into()),
552 ("rear".into(), "rear_frame".into()),
553 ]),
554 vec!["base_link".into(), "front_frame".into(), "rear_frame".into()],
555 vec![
556 FrameTransform::try_new(
557 "base_link",
558 "front_frame",
559 [0.0, 0.0, 0.0],
560 [0.0, 0.0, 0.0, 1.0],
561 None,
562 true,
563 true,
564 )
565 .unwrap(),
566 FrameTransform::try_new(
567 "base_link",
568 "rear_frame",
569 [0.0, 0.0, 0.0],
570 [0.0, 0.0, 0.0, 1.0],
571 None,
572 true,
573 true,
574 )
575 .unwrap(),
576 ],
577 )
578 .unwrap(),
579 Vec::new(),
580 )
581 .unwrap();
582 let state = FullBagMappingState::try_new(
583 "fixture mapping",
584 source(),
585 Some(calibration),
586 MappingSourceSummary {
587 front_topic: "/front".into(),
588 rear_topic: "/rear".into(),
589 front_bag_message_count: 2,
590 rear_bag_message_count: 2,
591 front_chunk_count: 2,
592 rear_chunk_count: 2,
593 front_record_count: 2,
594 rear_record_count: 2,
595 total_record_count: 4,
596 total_point_count: 12,
597 retained_bytes: 48,
598 peak_source_bytes: 256,
599 start_nanos: Some(1),
600 end_nanos: Some(2),
601 full_bag_processed: true,
602 truncated: false,
603 },
604 Some(MappingOdometrySummary {
605 topic: "/front".into(),
606 source_frame: "front_frame".into(),
607 root_frame: "base_link".into(),
608 clock_id: "external".into(),
609 clock_domain: "external-calibrated".into(),
610 matcher: "fixture".into(),
611 scan_count: 2,
612 motion_count: 1,
613 pose_graph_node_count: 2,
614 pose_graph_edge_count: 1,
615 complete: true,
616 truncated: false,
617 }),
618 Some(MappingTsdfSummary {
619 frame_id: "base_link".into(),
620 origin: [0.0, 0.0, 0.0],
621 voxel_size: 0.5,
622 dims: [8, 8, 8],
623 truncation: 1.0,
624 integrated_record_count: 4,
625 integrated_point_count: 12,
626 mesh_vertex_count: 3,
627 mesh_triangle_count: 1,
628 complete: true,
629 }),
630 vec![ReplayArtifact::try_new("mesh", "/media/mesh.gltf", 1, SHA).unwrap()],
631 true,
632 true,
633 Vec::new(),
634 )
635 .unwrap();
636 assert!(state.summary.mapping_admitted);
637 state.validate().unwrap();
638 }
639
640 #[test]
641 fn summary_rejects_reversed_source_bounds() {
642 let mut summary = source_summary();
643 summary.start_nanos = Some(20);
644 summary.end_nanos = Some(10);
645 assert!(summary.validate().is_err());
646 }
647}