1use std::collections::{BTreeMap, BTreeSet, VecDeque};
10
11use crate::{
12 CalibrationArtifact, ClockCalibration, FrameTransform, StudioSource, ViewerError, ViewerResult,
13};
14
15pub const CALIBRATION_EVIDENCE_STATE_VERSION: u32 = 1;
17
18#[derive(Clone, Debug, PartialEq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
22pub struct CalibrationEvidenceClock {
23 pub source_domain: String,
25 pub target_domain: String,
27 pub method: String,
29 pub calibration: ClockCalibration,
31}
32
33impl CalibrationEvidenceClock {
34 pub fn try_new(
36 source_domain: impl Into<String>,
37 target_domain: impl Into<String>,
38 method: impl Into<String>,
39 calibration: ClockCalibration,
40 ) -> ViewerResult<Self> {
41 let clock = Self {
42 source_domain: source_domain.into(),
43 target_domain: target_domain.into(),
44 method: method.into(),
45 calibration,
46 };
47 clock.validate()?;
48 Ok(clock)
49 }
50
51 pub fn validate(&self) -> ViewerResult<()> {
53 if self.source_domain.trim().is_empty()
54 || self.target_domain.trim().is_empty()
55 || self.method.trim().is_empty()
56 {
57 return Err(ViewerError::InvalidState(
58 "calibration clock evidence requires domains and a method".into(),
59 ));
60 }
61 self.calibration.validate()?;
62 if self.calibration.status == "registered" {
63 if self.source_domain == self.target_domain {
64 return Err(ViewerError::InvalidState(
65 "registered clock evidence requires distinct source and target domains".into(),
66 ));
67 }
68 if !self.calibration.source_bound || self.calibration.sample_count == 0 {
69 return Err(ViewerError::InvalidState(
70 "registered clock evidence requires source binding and samples".into(),
71 ));
72 }
73 for (label, value) in [
74 ("p95 absolute offset", self.calibration.p95_abs_offset_nanos),
75 ("clock uncertainty", self.calibration.uncertainty_nanos),
76 ] {
77 if value.map_or(true, |value| value < 0.0) {
78 return Err(ViewerError::InvalidState(format!(
79 "registered clock evidence requires non-negative {label}"
80 )));
81 }
82 }
83 }
84 Ok(())
85 }
86
87 #[must_use]
89 pub fn registration_ready(&self) -> bool {
90 self.calibration.status == "registered"
91 && self.calibration.source_bound
92 && self.calibration.sample_count > 0
93 && self.calibration.p95_abs_offset_nanos.is_some_and(|value| value >= 0.0)
94 && self.calibration.uncertainty_nanos.is_some_and(|value| value >= 0.0)
95 && self.source_domain != self.target_domain
96 }
97}
98
99#[derive(Clone, Debug, PartialEq)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
103pub struct CalibrationEvidenceFrame {
104 pub method: String,
106 pub root_frame: String,
108 pub required_frames: BTreeMap<String, String>,
110 pub frames: Vec<String>,
112 pub edges: Vec<FrameTransform>,
114 pub graph_ready: bool,
116}
117
118impl CalibrationEvidenceFrame {
119 pub fn try_new(
121 method: impl Into<String>,
122 root_frame: impl Into<String>,
123 required_frames: BTreeMap<String, String>,
124 frames: Vec<String>,
125 edges: Vec<FrameTransform>,
126 ) -> ViewerResult<Self> {
127 let frame = Self {
128 method: method.into(),
129 root_frame: root_frame.into(),
130 required_frames,
131 frames,
132 edges,
133 graph_ready: false,
134 };
135 let graph_ready = frame.calculate_graph_ready()?;
136 let frame = Self { graph_ready, ..frame };
137 frame.validate()?;
138 Ok(frame)
139 }
140
141 pub fn validate(&self) -> ViewerResult<()> {
143 if self.method.trim().is_empty() || self.root_frame.trim().is_empty() {
144 return Err(ViewerError::InvalidState(
145 "calibration frame evidence requires a method and root frame".into(),
146 ));
147 }
148 let mut required_values = BTreeSet::new();
149 for (role, frame) in &self.required_frames {
150 if role.trim().is_empty() || frame.trim().is_empty() || !required_values.insert(frame) {
151 return Err(ViewerError::InvalidState(
152 "required calibration sensor roles and frames must be unique and non-empty"
153 .into(),
154 ));
155 }
156 }
157 if !self.required_frames.contains_key("front") || !self.required_frames.contains_key("rear")
158 {
159 return Err(ViewerError::InvalidState(
160 "calibration frame evidence requires front and rear sensor frames".into(),
161 ));
162 }
163 let mut frame_ids = BTreeSet::new();
164 for frame in &self.frames {
165 if frame.trim().is_empty() || !frame_ids.insert(frame) {
166 return Err(ViewerError::InvalidState(
167 "calibration frame graph IDs must be unique and non-empty".into(),
168 ));
169 }
170 }
171 let mut edges = BTreeSet::new();
172 for edge in &self.edges {
173 edge.validate()?;
174 if !frame_ids.contains(&edge.parent_frame) || !frame_ids.contains(&edge.child_frame) {
175 return Err(ViewerError::InvalidState(
176 "calibration frame edge refers to an unknown frame".into(),
177 ));
178 }
179 if !edge.source_bound || !edge.accepted {
180 return Err(ViewerError::InvalidState(
181 "calibration evidence edges must be source-bound and accepted".into(),
182 ));
183 }
184 if !edges.insert((&edge.parent_frame, &edge.child_frame)) {
185 return Err(ViewerError::InvalidState(
186 "calibration frame graph contains a duplicate edge".into(),
187 ));
188 }
189 }
190 if !graph_is_acyclic(&self.frames, &self.edges) {
191 return Err(ViewerError::InvalidState(
192 "calibration frame graph must be acyclic".into(),
193 ));
194 }
195 let calculated_ready = self.calculate_graph_ready()?;
196 if self.graph_ready != calculated_ready {
197 return Err(ViewerError::InvalidState(
198 "calibration frame graph_ready disagrees with graph topology".into(),
199 ));
200 }
201 Ok(())
202 }
203
204 fn calculate_graph_ready(&self) -> ViewerResult<bool> {
205 let mut frame_ids = BTreeSet::new();
206 for frame in &self.frames {
207 if frame.trim().is_empty() || !frame_ids.insert(frame) {
208 return Err(ViewerError::InvalidState(
209 "calibration frame graph IDs must be unique and non-empty".into(),
210 ));
211 }
212 }
213 if !frame_ids.contains(&self.root_frame)
214 || self.edges.is_empty()
215 || !self.edges.iter().all(|edge| {
216 edge.source_bound
217 && edge.accepted
218 && frame_ids.contains(&edge.parent_frame)
219 && frame_ids.contains(&edge.child_frame)
220 })
221 || !graph_is_acyclic(&self.frames, &self.edges)
222 {
223 return Ok(false);
224 }
225 Ok(self.required_frames.values().all(|target| {
226 target != &self.root_frame
227 && frame_ids.contains(target)
228 && path_exists(&self.edges, &self.root_frame, target)
229 }))
230 }
231
232 #[must_use]
234 pub fn registration_ready(&self) -> bool {
235 self.graph_ready
236 }
237}
238
239#[derive(Clone, Debug, PartialEq)]
241#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
242#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
243pub struct CalibrationEvidenceState {
244 pub version: u32,
246 pub title: String,
248 pub source: StudioSource,
250 pub clock_artifact: CalibrationArtifact,
252 pub frame_artifact: CalibrationArtifact,
254 pub clock: CalibrationEvidenceClock,
256 pub frame: CalibrationEvidenceFrame,
258 pub registration_ready: bool,
260 pub blockers: Vec<String>,
262}
263
264impl CalibrationEvidenceState {
265 pub fn try_new(
267 title: impl Into<String>,
268 source: StudioSource,
269 clock_artifact: CalibrationArtifact,
270 frame_artifact: CalibrationArtifact,
271 clock: CalibrationEvidenceClock,
272 frame: CalibrationEvidenceFrame,
273 blockers: Vec<String>,
274 ) -> ViewerResult<Self> {
275 let registration_ready = source.identity_matches
276 && clock_artifact.status == "registered"
277 && clock_artifact.source_bound
278 && frame_artifact.status == "registered"
279 && frame_artifact.source_bound
280 && clock.registration_ready()
281 && frame.registration_ready()
282 && blockers.is_empty();
283 let state = Self {
284 version: CALIBRATION_EVIDENCE_STATE_VERSION,
285 title: title.into(),
286 source,
287 clock_artifact,
288 frame_artifact,
289 clock,
290 frame,
291 registration_ready,
292 blockers,
293 };
294 state.validate()?;
295 Ok(state)
296 }
297
298 pub fn validate(&self) -> ViewerResult<()> {
300 if self.version != CALIBRATION_EVIDENCE_STATE_VERSION {
301 return Err(ViewerError::InvalidState(format!(
302 "unsupported calibration evidence state version {}",
303 self.version
304 )));
305 }
306 if self.title.trim().is_empty() {
307 return Err(ViewerError::InvalidState(
308 "calibration evidence title must not be empty".into(),
309 ));
310 }
311 self.source.validate()?;
312 self.clock_artifact.validate()?;
313 self.frame_artifact.validate()?;
314 self.clock.validate()?;
315 self.frame.validate()?;
316 let calculated_registration = self.source.identity_matches
317 && self.clock_artifact.status == "registered"
318 && self.clock_artifact.source_bound
319 && self.frame_artifact.status == "registered"
320 && self.frame_artifact.source_bound
321 && self.clock.registration_ready()
322 && self.frame.registration_ready()
323 && self.blockers.is_empty();
324 if self.registration_ready != calculated_registration {
325 return Err(ViewerError::InvalidState(
326 "calibration registration_ready disagrees with evidence gates".into(),
327 ));
328 }
329 if self.registration_ready && !self.blockers.is_empty() {
330 return Err(ViewerError::InvalidState(
331 "admitted calibration evidence cannot contain blockers".into(),
332 ));
333 }
334 if !self.registration_ready && self.blockers.is_empty() {
335 return Err(ViewerError::InvalidState(
336 "blocked calibration evidence must expose at least one blocker".into(),
337 ));
338 }
339 if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
340 return Err(ViewerError::InvalidState(
341 "calibration evidence blockers must not be empty".into(),
342 ));
343 }
344 Ok(())
345 }
346}
347
348fn graph_is_acyclic(frames: &[String], edges: &[FrameTransform]) -> bool {
349 let mut adjacency: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
350 let mut indegree: BTreeMap<&str, usize> = BTreeMap::new();
351 for frame in frames {
352 adjacency.entry(frame.as_str()).or_default();
353 indegree.insert(frame.as_str(), 0);
354 }
355 for edge in edges.iter().filter(|edge| edge.accepted) {
356 let Some(value) = indegree.get_mut(edge.child_frame.as_str()) else {
357 return false;
358 };
359 adjacency.entry(edge.parent_frame.as_str()).or_default().push(edge.child_frame.as_str());
360 *value += 1;
361 }
362 let mut queue = indegree
363 .iter()
364 .filter_map(|(frame, degree)| (*degree == 0).then_some(*frame))
365 .collect::<Vec<_>>();
366 let mut visited = 0_usize;
367 while let Some(frame) = queue.pop() {
368 visited += 1;
369 for child in adjacency.get(frame).into_iter().flatten() {
370 let Some(degree) = indegree.get_mut(child) else {
371 return false;
372 };
373 *degree -= 1;
374 if *degree == 0 {
375 queue.push(child);
376 }
377 }
378 }
379 visited == indegree.len()
380}
381
382fn path_exists(edges: &[FrameTransform], root: &str, target: &str) -> bool {
383 let mut adjacency: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
384 for edge in edges.iter().filter(|edge| edge.accepted) {
385 adjacency.entry(edge.parent_frame.as_str()).or_default().push(edge.child_frame.as_str());
386 }
387 let mut queue = VecDeque::from([root]);
388 let mut visited = BTreeSet::from([root]);
389 while let Some(frame) = queue.pop_front() {
390 if frame == target {
391 return true;
392 }
393 for child in adjacency.get(frame).into_iter().flatten() {
394 if visited.insert(child) {
395 queue.push_back(child);
396 }
397 }
398 }
399 false
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
407
408 fn source(matches: bool) -> StudioSource {
409 let observed = if matches {
410 SHA
411 } else {
412 "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
413 };
414 StudioSource::try_new("canonical bag", "/media/canonical.db3", SHA, observed, matches)
415 .unwrap()
416 }
417
418 fn artifacts(bound: bool) -> (CalibrationArtifact, CalibrationArtifact) {
419 (
420 CalibrationArtifact::try_new(
421 "clock_evidence",
422 "registered",
423 Some("/media/clock.json".into()),
424 Some(SHA.into()),
425 bound,
426 )
427 .unwrap(),
428 CalibrationArtifact::try_new(
429 "frame_evidence",
430 "registered",
431 Some("/media/frame.json".into()),
432 Some(SHA.into()),
433 bound,
434 )
435 .unwrap(),
436 )
437 }
438
439 fn clock(bound: bool) -> CalibrationEvidenceClock {
440 CalibrationEvidenceClock::try_new(
441 "ros2-external",
442 "canonical",
443 "fixture-clock-fit",
444 ClockCalibration::try_new(
445 "registered",
446 "explicit clock model; not applied",
447 12,
448 Some(-2.0),
449 Some(5.0),
450 Some(0.1),
451 Some(10.0),
452 bound,
453 false,
454 )
455 .unwrap(),
456 )
457 .unwrap()
458 }
459
460 fn frame(cycle: bool) -> ViewerResult<CalibrationEvidenceFrame> {
461 let required = BTreeMap::from([
462 ("front".into(), "lidar_front".into()),
463 ("rear".into(), "lidar_rear".into()),
464 ]);
465 let mut edges = vec![
466 FrameTransform::try_new(
467 "base_link",
468 "lidar_front",
469 [1.0, 0.0, 0.0],
470 [0.0, 0.0, 0.0, 1.0],
471 None,
472 true,
473 true,
474 )
475 .unwrap(),
476 FrameTransform::try_new(
477 "base_link",
478 "lidar_rear",
479 [-1.0, 0.0, 0.0],
480 [0.0, 0.0, 0.0, 1.0],
481 None,
482 true,
483 true,
484 )
485 .unwrap(),
486 ];
487 if cycle {
488 edges.push(
489 FrameTransform::try_new(
490 "lidar_front",
491 "base_link",
492 [0.0, 0.0, 0.0],
493 [0.0, 0.0, 0.0, 1.0],
494 None,
495 true,
496 true,
497 )
498 .unwrap(),
499 );
500 }
501 CalibrationEvidenceFrame::try_new(
502 "fixture-extrinsic-fit",
503 "base_link",
504 required,
505 vec!["base_link".into(), "lidar_front".into(), "lidar_rear".into()],
506 edges,
507 )
508 }
509
510 #[test]
511 fn healthy_source_bound_evidence_is_registration_ready() {
512 let (clock_artifact, frame_artifact) = artifacts(true);
513 let state = CalibrationEvidenceState::try_new(
514 "Calibration Evidence",
515 source(true),
516 clock_artifact,
517 frame_artifact,
518 clock(true),
519 frame(false).unwrap(),
520 Vec::new(),
521 )
522 .unwrap();
523 assert!(state.registration_ready);
524 state.validate().unwrap();
525 }
526
527 #[test]
528 fn source_mismatch_withholds_registration() {
529 let (clock_artifact, frame_artifact) = artifacts(true);
530 let state = CalibrationEvidenceState::try_new(
531 "Calibration Evidence",
532 source(false),
533 clock_artifact,
534 frame_artifact,
535 clock(true),
536 frame(false).unwrap(),
537 vec!["input SHA mismatch".into()],
538 )
539 .unwrap();
540 assert!(!state.registration_ready);
541 }
542
543 #[test]
544 fn missing_evidence_is_a_valid_blocked_state() {
545 let required = BTreeMap::from([
546 ("front".into(), "lidar_front".into()),
547 ("rear".into(), "lidar_rear".into()),
548 ]);
549 let (clock_artifact, frame_artifact) = (
550 CalibrationArtifact::try_new("clock_evidence", "not_registered", None, None, false)
551 .unwrap(),
552 CalibrationArtifact::try_new("frame_evidence", "not_registered", None, None, false)
553 .unwrap(),
554 );
555 let state = CalibrationEvidenceState::try_new(
556 "Calibration Evidence",
557 source(true),
558 clock_artifact,
559 frame_artifact,
560 CalibrationEvidenceClock::try_new(
561 "unknown",
562 "uncalibrated",
563 "not_registered",
564 ClockCalibration::try_new(
565 "not_registered",
566 "header stamp",
567 0,
568 None,
569 None,
570 None,
571 None,
572 false,
573 false,
574 )
575 .unwrap(),
576 )
577 .unwrap(),
578 CalibrationEvidenceFrame::try_new(
579 "not_registered",
580 "base_link",
581 required,
582 Vec::new(),
583 Vec::new(),
584 )
585 .unwrap(),
586 vec!["clock evidence is not registered".into()],
587 )
588 .unwrap();
589 assert!(!state.registration_ready);
590 state.validate().unwrap();
591 }
592
593 #[test]
594 fn cyclic_frame_graph_is_rejected() {
595 assert!(frame(true).is_err());
596 }
597}