1use std::collections::{BTreeMap, BTreeSet};
7
8use crate::{StudioSource, ViewerError, ViewerResult};
9
10pub const CALIBRATION_OBSERVATORY_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 CalibrationArtifact {
18 pub kind: String,
20 pub status: String,
22 pub path: Option<String>,
24 pub sha256: Option<String>,
26 pub source_bound: bool,
28}
29
30impl CalibrationArtifact {
31 pub fn try_new(
33 kind: impl Into<String>,
34 status: impl Into<String>,
35 path: Option<String>,
36 sha256: Option<String>,
37 source_bound: bool,
38 ) -> ViewerResult<Self> {
39 let artifact =
40 Self { kind: kind.into(), status: status.into(), path, sha256, source_bound };
41 artifact.validate()?;
42 Ok(artifact)
43 }
44
45 pub fn validate(&self) -> ViewerResult<()> {
47 if self.kind.trim().is_empty() || self.status.trim().is_empty() {
48 return Err(ViewerError::InvalidState(
49 "calibration artifact kind and status must not be empty".into(),
50 ));
51 }
52 if self.status == "registered" {
53 let path = self.path.as_deref().unwrap_or_default();
54 let sha256 = self.sha256.as_deref().unwrap_or_default();
55 if path.trim().is_empty() {
56 return Err(ViewerError::InvalidState(format!(
57 "registered {} artifact has no path",
58 self.kind
59 )));
60 }
61 validate_sha256(&format!("{} artifact", self.kind), sha256)?;
62 } else if self.source_bound {
63 return Err(ViewerError::InvalidState(format!(
64 "unregistered {} artifact cannot be source-bound",
65 self.kind
66 )));
67 }
68 Ok(())
69 }
70}
71
72#[derive(Clone, Debug, PartialEq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
76pub struct ClockCalibration {
77 pub status: String,
79 pub time_basis: String,
81 pub sample_count: u64,
83 pub median_offset_nanos: Option<f64>,
85 pub p95_abs_offset_nanos: Option<f64>,
87 pub drift_ppm: Option<f64>,
89 pub uncertainty_nanos: Option<f64>,
91 pub source_bound: bool,
93 pub applied: bool,
95}
96
97impl ClockCalibration {
98 #[allow(clippy::too_many_arguments)]
100 pub fn try_new(
101 status: impl Into<String>,
102 time_basis: impl Into<String>,
103 sample_count: u64,
104 median_offset_nanos: Option<f64>,
105 p95_abs_offset_nanos: Option<f64>,
106 drift_ppm: Option<f64>,
107 uncertainty_nanos: Option<f64>,
108 source_bound: bool,
109 applied: bool,
110 ) -> ViewerResult<Self> {
111 let clock = Self {
112 status: status.into(),
113 time_basis: time_basis.into(),
114 sample_count,
115 median_offset_nanos,
116 p95_abs_offset_nanos,
117 drift_ppm,
118 uncertainty_nanos,
119 source_bound,
120 applied,
121 };
122 clock.validate()?;
123 Ok(clock)
124 }
125
126 pub fn validate(&self) -> ViewerResult<()> {
128 if self.status.trim().is_empty() || self.time_basis.trim().is_empty() {
129 return Err(ViewerError::InvalidState(
130 "clock status and time basis must not be empty".into(),
131 ));
132 }
133 for (label, value) in [
134 ("median offset", self.median_offset_nanos),
135 ("p95 absolute offset", self.p95_abs_offset_nanos),
136 ("clock drift", self.drift_ppm),
137 ("clock uncertainty", self.uncertainty_nanos),
138 ] {
139 if value.is_some_and(|value| !value.is_finite()) {
140 return Err(ViewerError::InvalidState(format!(
141 "clock {label} must be finite when present"
142 )));
143 }
144 }
145 if self.applied && (!self.source_bound || self.status != "registered") {
146 return Err(ViewerError::InvalidState(
147 "applied clock calibration must be registered and source-bound".into(),
148 ));
149 }
150 Ok(())
151 }
152}
153
154#[derive(Clone, Debug, PartialEq)]
156#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
157#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
158pub struct FrameTransform {
159 pub parent_frame: String,
161 pub child_frame: String,
163 pub translation_m: [f64; 3],
165 pub rotation_xyzw: [f64; 4],
167 pub stamp_nanos: Option<u64>,
169 pub source_bound: bool,
171 pub accepted: bool,
173}
174
175impl FrameTransform {
176 pub fn try_new(
178 parent_frame: impl Into<String>,
179 child_frame: impl Into<String>,
180 translation_m: [f64; 3],
181 rotation_xyzw: [f64; 4],
182 stamp_nanos: Option<u64>,
183 source_bound: bool,
184 accepted: bool,
185 ) -> ViewerResult<Self> {
186 let transform = Self {
187 parent_frame: parent_frame.into(),
188 child_frame: child_frame.into(),
189 translation_m,
190 rotation_xyzw,
191 stamp_nanos,
192 source_bound,
193 accepted,
194 };
195 transform.validate()?;
196 Ok(transform)
197 }
198
199 pub fn validate(&self) -> ViewerResult<()> {
201 if self.parent_frame.trim().is_empty()
202 || self.child_frame.trim().is_empty()
203 || self.parent_frame == self.child_frame
204 {
205 return Err(ViewerError::InvalidState(
206 "TF edge parent and child frames must be distinct and non-empty".into(),
207 ));
208 }
209 if self.translation_m.iter().any(|value| !value.is_finite())
210 || self.rotation_xyzw.iter().any(|value| !value.is_finite())
211 {
212 return Err(ViewerError::InvalidState(
213 "TF edge translation and quaternion must be finite".into(),
214 ));
215 }
216 let norm = self.rotation_xyzw.iter().map(|value| value * value).sum::<f64>().sqrt();
217 if norm <= f64::EPSILON {
218 return Err(ViewerError::InvalidState("TF edge quaternion must be non-zero".into()));
219 }
220 if self.accepted && !self.source_bound {
221 return Err(ViewerError::InvalidState("an unbound TF edge cannot be accepted".into()));
222 }
223 Ok(())
224 }
225}
226
227#[derive(Clone, Debug, PartialEq)]
229#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
230#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
231pub struct CalibrationObservatoryState {
232 pub version: u32,
234 pub title: String,
236 pub source: StudioSource,
238 pub clock_artifact: CalibrationArtifact,
240 pub frame_artifact: CalibrationArtifact,
242 pub clock: ClockCalibration,
244 pub frames: Vec<String>,
246 pub edges: Vec<FrameTransform>,
248 pub rejected_edge_count: u64,
250 pub frame_inventory_source_bound: bool,
252 pub root_frame: Option<String>,
254 pub composed: bool,
256 pub cycle_free: bool,
258 pub blockers: Vec<String>,
260 pub calibration_admitted: bool,
262}
263
264impl CalibrationObservatoryState {
265 #[allow(clippy::too_many_arguments)]
267 pub fn try_new(
268 title: impl Into<String>,
269 source: StudioSource,
270 clock_artifact: CalibrationArtifact,
271 frame_artifact: CalibrationArtifact,
272 clock: ClockCalibration,
273 frames: Vec<String>,
274 edges: Vec<FrameTransform>,
275 rejected_edge_count: u64,
276 frame_inventory_source_bound: bool,
277 root_frame: Option<String>,
278 composed: bool,
279 blockers: Vec<String>,
280 ) -> ViewerResult<Self> {
281 let cycle_free = graph_is_acyclic(&frames, &edges);
282 let calibration_admitted = source.identity_matches
283 && clock_artifact.status == "registered"
284 && clock_artifact.source_bound
285 && frame_artifact.status == "registered"
286 && frame_artifact.source_bound
287 && clock.source_bound
288 && clock.applied
289 && frame_inventory_source_bound
290 && composed
291 && cycle_free
292 && !edges.is_empty()
293 && edges.iter().all(|edge| edge.accepted)
294 && blockers.is_empty();
295 let state = Self {
296 version: CALIBRATION_OBSERVATORY_STATE_VERSION,
297 title: title.into(),
298 source,
299 clock_artifact,
300 frame_artifact,
301 clock,
302 frames,
303 edges,
304 rejected_edge_count,
305 frame_inventory_source_bound,
306 root_frame,
307 composed,
308 cycle_free,
309 blockers,
310 calibration_admitted,
311 };
312 state.validate()?;
313 Ok(state)
314 }
315
316 pub fn validate(&self) -> ViewerResult<()> {
318 if self.version != CALIBRATION_OBSERVATORY_STATE_VERSION {
319 return Err(ViewerError::InvalidState(format!(
320 "unsupported calibration observatory state version {}",
321 self.version
322 )));
323 }
324 if self.title.trim().is_empty() {
325 return Err(ViewerError::InvalidState(
326 "calibration observatory title must not be empty".into(),
327 ));
328 }
329 self.source.validate()?;
330 self.clock_artifact.validate()?;
331 self.frame_artifact.validate()?;
332 self.clock.validate()?;
333
334 let mut frames = BTreeSet::new();
335 for frame in &self.frames {
336 if frame.trim().is_empty() || !frames.insert(frame) {
337 return Err(ViewerError::InvalidState(
338 "observatory frame IDs must be unique and non-empty".into(),
339 ));
340 }
341 }
342 let mut edges = BTreeSet::new();
343 for edge in &self.edges {
344 edge.validate()?;
345 if !frames.contains(&edge.parent_frame) || !frames.contains(&edge.child_frame) {
346 return Err(ViewerError::InvalidState(
347 "observatory edge refers to a frame outside the accepted inventory".into(),
348 ));
349 }
350 if !edges.insert((&edge.parent_frame, &edge.child_frame)) {
351 return Err(ViewerError::InvalidState(
352 "observatory frame graph contains a duplicate edge".into(),
353 ));
354 }
355 if edge.accepted && (!edge.source_bound || !self.source.identity_matches) {
356 return Err(ViewerError::InvalidState(
357 "accepted observatory edges must match the input source identity".into(),
358 ));
359 }
360 }
361 if !self.frame_inventory_source_bound && (!self.frames.is_empty() || !self.edges.is_empty())
362 {
363 return Err(ViewerError::InvalidState(
364 "unbound frame inventory cannot populate observatory graph fields".into(),
365 ));
366 }
367 if let Some(root) = &self.root_frame {
368 if root.trim().is_empty() || !frames.contains(root) {
369 return Err(ViewerError::InvalidState(
370 "observatory root must be one of the accepted frames".into(),
371 ));
372 }
373 }
374 if self.composed && (self.root_frame.is_none() || self.edges.is_empty()) {
375 return Err(ViewerError::InvalidState(
376 "composed observatory graph requires a root and accepted edges".into(),
377 ));
378 }
379 if self.cycle_free != graph_is_acyclic(&self.frames, &self.edges) {
380 return Err(ViewerError::InvalidState(
381 "observatory cycle_free disagrees with graph topology".into(),
382 ));
383 }
384 let calculated_admission = self.source.identity_matches
385 && self.clock_artifact.status == "registered"
386 && self.clock_artifact.source_bound
387 && self.frame_artifact.status == "registered"
388 && self.frame_artifact.source_bound
389 && self.clock.source_bound
390 && self.clock.applied
391 && self.frame_inventory_source_bound
392 && self.composed
393 && self.cycle_free
394 && !self.edges.is_empty()
395 && self.edges.iter().all(|edge| edge.accepted)
396 && self.blockers.is_empty();
397 if self.calibration_admitted != calculated_admission {
398 return Err(ViewerError::InvalidState(
399 "calibration_admitted disagrees with observatory gates".into(),
400 ));
401 }
402 if self.calibration_admitted && !self.blockers.is_empty() {
403 return Err(ViewerError::InvalidState(
404 "admitted calibration cannot contain blockers".into(),
405 ));
406 }
407 if !self.calibration_admitted && self.blockers.is_empty() {
408 return Err(ViewerError::InvalidState(
409 "blocked calibration must expose at least one blocker".into(),
410 ));
411 }
412 if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
413 return Err(ViewerError::InvalidState("observatory blockers must not be empty".into()));
414 }
415 Ok(())
416 }
417}
418
419fn graph_is_acyclic(frames: &[String], edges: &[FrameTransform]) -> bool {
420 let mut adjacency: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
421 let mut indegree: BTreeMap<&str, usize> = BTreeMap::new();
422 for frame in frames {
423 indegree.insert(frame.as_str(), 0);
424 adjacency.entry(frame.as_str()).or_default();
425 }
426 for edge in edges.iter().filter(|edge| edge.accepted) {
427 adjacency.entry(edge.parent_frame.as_str()).or_default().push(edge.child_frame.as_str());
428 let Some(value) = indegree.get_mut(edge.child_frame.as_str()) else {
429 return false;
430 };
431 *value += 1;
432 }
433 let mut queue = indegree
434 .iter()
435 .filter_map(|(frame, degree)| (*degree == 0).then_some(*frame))
436 .collect::<Vec<_>>();
437 let mut visited = 0_usize;
438 while let Some(frame) = queue.pop() {
439 visited += 1;
440 for child in adjacency.get(frame).into_iter().flatten() {
441 let Some(degree) = indegree.get_mut(child) else {
442 return false;
443 };
444 *degree -= 1;
445 if *degree == 0 {
446 queue.push(child);
447 }
448 }
449 }
450 visited == indegree.len()
451}
452
453fn validate_sha256(label: &str, value: &str) -> ViewerResult<()> {
454 if value.len() != 64
455 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
456 || value.bytes().any(|byte| byte.is_ascii_uppercase())
457 {
458 return Err(ViewerError::InvalidState(format!(
459 "{label} SHA-256 must be 64 lowercase hexadecimal characters"
460 )));
461 }
462 Ok(())
463}
464
465#[cfg(test)]
466mod tests {
467 use spatialrust_math::Vec3;
468 use spatialrust_viz::{Camera, Projection};
469
470 use super::*;
471
472 const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
473
474 fn source(identity_matches: bool) -> StudioSource {
475 let observed = if identity_matches {
476 SHA
477 } else {
478 "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
479 };
480 StudioSource::try_new("bag", "/tmp/bag.db3", SHA, observed, identity_matches).unwrap()
481 }
482
483 fn blocked() -> CalibrationObservatoryState {
484 CalibrationObservatoryState::try_new(
485 "TF Observatory",
486 source(true),
487 CalibrationArtifact::try_new("clock", "not_registered", None, None, false).unwrap(),
488 CalibrationArtifact::try_new("frame", "not_registered", None, None, false).unwrap(),
489 ClockCalibration::try_new(
490 "not_registered",
491 "header stamp",
492 0,
493 None,
494 None,
495 None,
496 None,
497 false,
498 false,
499 )
500 .unwrap(),
501 Vec::new(),
502 Vec::new(),
503 0,
504 false,
505 None,
506 false,
507 vec!["clock artifact is missing".into()],
508 )
509 .unwrap()
510 }
511
512 #[test]
513 fn blocked_observatory_state_is_valid() {
514 let state = blocked();
515 assert!(!state.calibration_admitted);
516 assert!(state.validate().is_ok());
517 #[cfg(feature = "serde")]
518 {
519 let json = serde_json::to_string(&state).unwrap();
520 assert_eq!(serde_json::from_str::<CalibrationObservatoryState>(&json).unwrap(), state);
521 }
522 }
523
524 #[test]
525 fn unbound_accepted_edge_is_rejected() {
526 let edge = FrameTransform::try_new(
527 "map",
528 "lidar",
529 [0.0, 0.0, 0.0],
530 [0.0, 0.0, 0.0, 1.0],
531 None,
532 false,
533 true,
534 );
535 assert!(edge.is_err());
536 }
537
538 #[test]
539 fn source_mismatch_cannot_admit_an_accepted_edge() {
540 let edge = FrameTransform::try_new(
541 "map",
542 "lidar",
543 [0.0, 0.0, 0.0],
544 [0.0, 0.0, 0.0, 1.0],
545 None,
546 true,
547 true,
548 )
549 .unwrap();
550 let result = CalibrationObservatoryState::try_new(
551 "TF Observatory",
552 source(false),
553 CalibrationArtifact::try_new("clock", "not_registered", None, None, false).unwrap(),
554 CalibrationArtifact::try_new("frame", "not_registered", None, None, false).unwrap(),
555 ClockCalibration::try_new(
556 "not_registered",
557 "header stamp",
558 0,
559 None,
560 None,
561 None,
562 None,
563 false,
564 false,
565 )
566 .unwrap(),
567 vec!["map".into(), "lidar".into()],
568 vec![edge],
569 0,
570 true,
571 None,
572 false,
573 vec!["source mismatch".into()],
574 );
575 assert!(result.is_err());
576 }
577
578 #[test]
579 fn finite_transform_and_camera_types_are_available_without_unsafe() {
580 let camera = Camera::try_new(
581 Vec3::new(0.0, 0.0, 1.0),
582 Vec3::new(0.0, 0.0, 0.0),
583 Vec3::new(0.0, 1.0, 0.0),
584 Projection::Perspective { vertical_fov_radians: 1.0, near: 0.1, far: 10.0 },
585 )
586 .unwrap();
587 assert!(camera.eye.z.is_finite());
588 }
589}