rigidity_graph/lib.rs
1//! Pose-graph optimisation with each edge's information in calibrated
2//! units.
3//!
4//! Nodes, edges, Gauss–Newton on `SE(3)`, an anchor — and
5//! [`calibrated_information`], which is where this crate differs from the
6//! ordinary machinery, though by much less than it once claimed.
7//!
8//! # What this crate set out to do, and what measurement left of it
9//!
10//! Every pairwise registration produces `JᵀWJ` at its solution, and every
11//! pose-graph package in this space takes that matrix at face value. Until
12//! 0.1.1 this crate said that was wrong — that an edge down a corridor
13//! should carry *no* weight along the axis the geometry never determined,
14//! not a small one — and thresholded the spectrum accordingly. On scenes
15//! generated here that wins by 120×.
16//!
17//! It does not survive real data. Against theodolite ground truth on the
18//! ETH ASL surveys the threshold never once beat plain `JᵀWJ` and lost by
19//! as much as 3.4×, and four further ways of reshaping the matrix lost
20//! too. The reason is that an edge's error is a *bias* rather than
21//! scatter, and that the bias lies away from the best-determined
22//! direction — which is what the anisotropy of `JᵀWJ` already says. The
23//! shape was right; only the scale was wrong, and a scale common to every
24//! edge does not move a survey.
25//!
26//! So [`calibrated_information`] no longer thresholds. It puts the
27//! project's calibration into the matrix and leaves out a direction that
28//! is genuinely blind, and is otherwise `JᵀWJ/σ²`. Its documentation
29//! carries the numbers; `degenerate_leg` in this crate's tests carries the
30//! assertions, including the old comparison kept as an equality so that
31//! reintroducing a threshold quietly would move a number somebody has to
32//! argue for.
33//!
34//! What survived is the diagnosis rather than the weight: *which*
35//! directions are weak is worth reporting, and
36//! [`Conditioning::classify`](rigidity_core::observability::Conditioning::classify)
37//! still reports it for a single edge while [`PoseGraph::diagnose`] reports
38//! it for the survey. What conditioning cannot report at all is whether a
39//! registration landed in the right minimum; for that, see
40//! `rigidity_pipeline::median_absolute_residual`.
41//!
42//! # Frames, and the one thing that will go wrong if they are misread
43//!
44//! A node's pose is *world-from-scan*: it carries that scan's own
45//! coordinates into the survey. An edge from `i` to `j` measures
46//! `Z ≈ T_i⁻¹·T_j` — scan `j`'s coordinates expressed in scan `i`'s — which
47//! is exactly what [`rigidity_core::icp`] returns when `j` is the source and
48//! `i` is the target.
49//!
50//! The edge's information matrix lives in the tangent at `Z`, under a
51//! **left** perturbation, in scan `i`'s frame. That is not a choice made
52//! here: the ICP updates its pose as `T ← exp(Δξ)·T` and builds its
53//! Jacobian rows from points in the target's frame, so `IcpResult::
54//! information` is already in those coordinates and
55//! [`calibrated_information`] restates it in the same ones. An
56//! information matrix in the wrong frame does not fail loudly — it
57//! converges to a slightly wrong answer, which is the failure mode this
58//! paragraph is here to prevent.
59//!
60//! Nodes are perturbed on the **right**, `T ← T·exp(δ)`, because that keeps
61//! each increment in the body frame of its own scan, where the
62//! measurements were taken.
63//!
64//! # Determinism
65//!
66//! Single-threaded, and the dense solve is a fixed sequence of operations
67//! on a fixed matrix, so the result does not depend on a thread count that
68//! does not exist. A few hundred poses is a 1200×1200 Cholesky, which is
69//! milliseconds; sparse storage waits until a survey asks for it.
70
71use rigidity_core::lie::{Se3, inverse_right_jacobian_se3};
72use rigidity_core::nalgebra::{DMatrix, DVector, Matrix3, Matrix6, Vector3, Vector6};
73use rigidity_core::observability::Conditioning;
74
75/// One measured relative pose, and how much of it to believe.
76#[derive(Debug, Clone, Copy)]
77pub struct Edge {
78 /// The node the measurement is expressed in.
79 pub from: usize,
80 /// The node it measures.
81 pub to: usize,
82 /// `Z`: scan `to`'s coordinates in scan `from`'s frame.
83 pub measurement: Se3,
84 /// The inverse covariance of `Z`, in scan `from`'s frame.
85 ///
86 /// Either [`calibrated_information`], which puts the project's calibration
87 /// into it and leaves out a direction the geometry is blind to, or
88 /// `IcpResult::information` as it comes.
89 pub information: Matrix6<f64>,
90}
91
92/// What can be wrong with a graph.
93#[derive(Debug, thiserror::Error, PartialEq)]
94pub enum GraphError {
95 /// An edge names a node that is not there.
96 #[error("edge {edge} names node {node}, and the graph has {nodes}")]
97 NoSuchNode {
98 /// Which edge.
99 edge: usize,
100 /// The index it named.
101 node: usize,
102 /// How many there are.
103 nodes: usize,
104 },
105 /// An edge joins a node to itself.
106 ///
107 /// Not merely useless: its two Jacobian blocks land in the same place
108 /// and cancel, so it contributes a row of zeros and makes the system
109 /// harder to solve while measuring nothing.
110 #[error("edge {edge} joins node {node} to itself")]
111 SelfLoop {
112 /// Which edge.
113 edge: usize,
114 /// The node on both ends.
115 node: usize,
116 },
117 /// The anchor is not a node.
118 #[error("the anchor is node {anchor}, and the graph has {nodes}")]
119 NoSuchAnchor {
120 /// The index given.
121 anchor: usize,
122 /// How many there are.
123 nodes: usize,
124 },
125 /// The normal equations could not be factorised at any damping.
126 ///
127 /// Reached only when the system is degenerate in a way damping cannot
128 /// repair, which in practice means a graph whose edges leave part of it
129 /// unconnected to the anchor.
130 #[error("the normal equations are singular at damping {damping:e}")]
131 Singular {
132 /// The largest damping that was tried.
133 damping: f64,
134 },
135}
136
137/// How hard to try.
138#[derive(Debug, Clone, Copy, PartialEq)]
139pub struct OptimiseParams {
140 /// Which node is held fixed.
141 ///
142 /// A pose graph determines its nodes only up to a common rigid motion:
143 /// six directions of the system are free no matter how many edges there
144 /// are. Holding one node still is how that gauge freedom is removed,
145 /// and the survey's coordinates then mean "relative to this scan".
146 pub anchor: usize,
147 /// Stop after this many accepted steps.
148 pub max_iterations: usize,
149 /// Stop when a step moves every node less than this, in the units of
150 /// the algebra — metres, and radians at one metre.
151 pub step_tolerance: f64,
152 /// Where the Levenberg damping starts.
153 ///
154 /// Damping is not a luxury here. Zeroing the unobservable directions of
155 /// an edge is the entire point of the crate, and it can leave the whole
156 /// system rank-deficient — a survey where nothing at all constrains one
157 /// direction is a survey this crate should still return an answer for,
158 /// rather than a factorisation error.
159 pub initial_damping: f64,
160}
161
162impl Default for OptimiseParams {
163 fn default() -> Self {
164 Self {
165 anchor: 0,
166 max_iterations: 100,
167 step_tolerance: 1e-10,
168 initial_damping: 1e-9,
169 }
170 }
171}
172
173/// What an optimisation did.
174#[derive(Debug, Clone, Copy, PartialEq)]
175pub struct Report {
176 /// How many steps were accepted.
177 pub iterations: usize,
178 /// Whether it stopped because the step became small rather than
179 /// because it ran out of iterations.
180 pub converged: bool,
181 /// The cost before and after.
182 pub cost: [f64; 2],
183}
184
185/// What the edges add up to.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct Shape {
188 /// How many nodes at least one edge touches.
189 pub joined: usize,
190 /// How many independent loops the edges form.
191 ///
192 /// Zero is the number that matters. A survey with no closure is a tree:
193 /// its residual is zero at whatever answer it gives, because no two
194 /// measurements are ever compared, and every error made along the way is
195 /// still in the answer.
196 pub closures: usize,
197 /// How many joined nodes no chain of edges connects to the anchor.
198 ///
199 /// Anything above zero makes the normal equations singular, and the
200 /// survey has more than one piece.
201 pub adrift: usize,
202}
203
204/// Nodes, edges, and the optimisation over them.
205#[derive(Debug, Clone, Default)]
206pub struct PoseGraph {
207 poses: Vec<Se3>,
208 edges: Vec<Edge>,
209}
210
211impl PoseGraph {
212 /// A graph with these nodes and no edges.
213 pub fn new(poses: Vec<Se3>) -> Self {
214 Self {
215 poses,
216 edges: Vec::new(),
217 }
218 }
219
220 /// The nodes, in order.
221 pub fn poses(&self) -> &[Se3] {
222 &self.poses
223 }
224
225 /// The edges, in the order they were added.
226 pub fn edges(&self) -> &[Edge] {
227 &self.edges
228 }
229
230 /// Adds an edge, refusing one that names a node that is not there.
231 pub fn push(&mut self, edge: Edge) -> Result<(), GraphError> {
232 let nodes = self.poses.len();
233 let index = self.edges.len();
234 for node in [edge.from, edge.to] {
235 if node >= nodes {
236 return Err(GraphError::NoSuchNode {
237 edge: index,
238 node,
239 nodes,
240 });
241 }
242 }
243 if edge.from == edge.to {
244 return Err(GraphError::SelfLoop {
245 edge: index,
246 node: edge.from,
247 });
248 }
249 self.edges.push(edge);
250 Ok(())
251 }
252
253 /// What shape the edges make, which decides what a solve can do.
254 ///
255 /// Reported rather than discovered by failing: a survey with a node
256 /// nothing joins to the anchor is one the normal equations cannot
257 /// factorise, and finding that out from `GraphError::Singular` after the
258 /// solve is a worse way to learn it than being told before.
259 pub fn shape(&self, anchor: usize) -> Shape {
260 let nodes = self.poses.len();
261 let mut neighbours: Vec<Vec<usize>> = vec![Vec::new(); nodes];
262 for edge in &self.edges {
263 neighbours[edge.from].push(edge.to);
264 neighbours[edge.to].push(edge.from);
265 }
266 let touched: Vec<bool> = neighbours.iter().map(|list| !list.is_empty()).collect();
267
268 // Components over the nodes an edge touches, by breadth-first walk.
269 // Sorted work list rather than a hash set, so the traversal order —
270 // and therefore nothing at all — depends on a hasher.
271 let mut seen = vec![false; nodes];
272 let mut components = 0;
273 for start in 0..nodes {
274 if !touched[start] || seen[start] {
275 continue;
276 }
277 components += 1;
278 let mut queue = vec![start];
279 seen[start] = true;
280 while let Some(node) = queue.pop() {
281 for next in &neighbours[node] {
282 if !seen[*next] {
283 seen[*next] = true;
284 queue.push(*next);
285 }
286 }
287 }
288 }
289
290 // Which of them the anchor can be reached from. The anchor itself
291 // may be untouched — a survey of edges that all avoid it — and then
292 // nothing is anchored and everything is adrift.
293 let mut reachable = vec![false; nodes];
294 if anchor < nodes {
295 reachable[anchor] = true;
296 let mut queue = vec![anchor];
297 while let Some(node) = queue.pop() {
298 for next in &neighbours[node] {
299 if !reachable[*next] {
300 reachable[*next] = true;
301 queue.push(*next);
302 }
303 }
304 }
305 }
306
307 let joined = touched.iter().filter(|t| **t).count();
308 Shape {
309 joined,
310 // The cyclomatic number: how many edges could be removed before
311 // the graph stops being connected the way it is. Zero means
312 // every measurement is believed exactly because nothing
313 // contradicts it — which is what a survey walked as a chain is,
314 // and why it drifts.
315 closures: (self.edges.len() + components).saturating_sub(joined),
316 adrift: (0..nodes)
317 .filter(|node| touched[*node] && !reachable[*node])
318 .count(),
319 }
320 }
321
322 /// The disagreement on one edge: `log(T_i⁻¹·T_j·Z⁻¹)`.
323 ///
324 /// Zero when the two nodes sit exactly as the measurement says. The
325 /// ordering — the measurement inverted on the *right* — is what puts
326 /// the residual in the same frame and the same left-perturbation
327 /// convention as the information matrix beside it.
328 pub fn residual(&self, edge: &Edge) -> Vector6<f64> {
329 self.error(edge).log()
330 }
331
332 fn error(&self, edge: &Edge) -> Se3 {
333 self.poses[edge.from].inverse() * self.poses[edge.to] * edge.measurement.inverse()
334 }
335
336 /// `Σ rᵀΛr` over the edges.
337 pub fn cost(&self) -> f64 {
338 self.edges
339 .iter()
340 .map(|edge| {
341 let r = self.residual(edge);
342 (r.transpose() * edge.information * r)[(0, 0)]
343 })
344 .sum()
345 }
346
347 /// Levenberg-damped Gauss–Newton until the steps stop mattering.
348 pub fn optimise(&mut self, params: &OptimiseParams) -> Result<Report, GraphError> {
349 let nodes = self.poses.len();
350 if params.anchor >= nodes {
351 return Err(GraphError::NoSuchAnchor {
352 anchor: params.anchor,
353 nodes,
354 });
355 }
356
357 let before = self.cost();
358 let mut damping = params.initial_damping;
359 let mut iterations = 0;
360 let mut converged = false;
361
362 while iterations < params.max_iterations {
363 let (hessian, gradient) = self.normal_equations(params.anchor);
364 let mut step = None;
365 // Ten increases of the damping, each by a factor of ten: from
366 // 1e-9 that reaches 1e1, which is far past the point where the
367 // system is dominated by the damping and the step is a tiny
368 // gradient descent. Failing beyond that is a graph problem, not
369 // a conditioning one.
370 for _ in 0..12 {
371 let mut damped = hessian.clone();
372 for index in 0..damped.nrows() {
373 damped[(index, index)] += damping;
374 }
375 if let Some(cholesky) = damped.cholesky() {
376 step = Some(cholesky.solve(&(-&gradient)));
377 break;
378 }
379 damping *= 10.0;
380 }
381 let Some(step) = step else {
382 return Err(GraphError::Singular { damping });
383 };
384
385 let previous = self.poses.clone();
386 self.apply(&step, params.anchor);
387 let after = self.cost();
388 if after.is_finite() && after < self.cost_of(&previous) {
389 iterations += 1;
390 damping = (damping * 0.1).max(f64::MIN_POSITIVE);
391 if step.amax() < params.step_tolerance {
392 converged = true;
393 break;
394 }
395 } else {
396 // Rejected: the linearisation was not good enough at this
397 // damping, so put the poses back and lean harder on the
398 // gradient.
399 self.poses = previous;
400 damping *= 10.0;
401 if damping > 1e12 {
402 converged = true;
403 break;
404 }
405 }
406 }
407
408 Ok(Report {
409 iterations,
410 converged,
411 cost: [before, self.cost()],
412 })
413 }
414
415 fn cost_of(&self, poses: &[Se3]) -> f64 {
416 let mut probe = self.clone();
417 probe.poses = poses.to_vec();
418 probe.cost()
419 }
420
421 /// `H = ΣJᵀΛJ` and `b = ΣJᵀΛr`, over the nodes that are free to move.
422 fn normal_equations(&self, anchor: usize) -> (DMatrix<f64>, DVector<f64>) {
423 let free = self.poses.len() - 1;
424 let mut hessian = DMatrix::zeros(6 * free, 6 * free);
425 let mut gradient = DVector::zeros(6 * free);
426 // The anchor has no block, so every node after it shifts down one.
427 let slot = |node: usize| -> Option<usize> {
428 match node.cmp(&anchor) {
429 std::cmp::Ordering::Less => Some(node),
430 std::cmp::Ordering::Equal => None,
431 std::cmp::Ordering::Greater => Some(node - 1),
432 }
433 };
434
435 for edge in &self.edges {
436 let error = self.error(edge);
437 let residual = error.log();
438 // The right Jacobian's inverse is what turns a perturbation of
439 // the group element into a perturbation of its logarithm.
440 // Approximating it by the identity — which plenty of
441 // implementations do — is exact only where the residual is
442 // already zero, which is the one place the answer does not
443 // matter.
444 let lift = inverse_right_jacobian_se3(&residual);
445 let jacobian_from = -lift * error.inverse().adjoint();
446 let jacobian_to = lift * edge.measurement.adjoint();
447
448 let blocks = [(edge.from, jacobian_from), (edge.to, jacobian_to)];
449 for (node, jacobian) in blocks {
450 let Some(row) = slot(node) else { continue };
451 let weighted = jacobian.transpose() * edge.information;
452 let contribution = weighted * residual;
453 for axis in 0..6 {
454 gradient[6 * row + axis] += contribution[axis];
455 }
456 for (other, other_jacobian) in blocks {
457 let Some(column) = slot(other) else { continue };
458 let block = weighted * other_jacobian;
459 for r in 0..6 {
460 for c in 0..6 {
461 hessian[(6 * row + r, 6 * column + c)] += block[(r, c)];
462 }
463 }
464 }
465 }
466 }
467 (hessian, gradient)
468 }
469
470 fn apply(&mut self, step: &DVector<f64>, anchor: usize) {
471 let mut row = 0;
472 for (index, pose) in self.poses.iter_mut().enumerate() {
473 if index == anchor {
474 continue;
475 }
476 let delta = Vector6::new(
477 step[6 * row],
478 step[6 * row + 1],
479 step[6 * row + 2],
480 step[6 * row + 3],
481 step[6 * row + 4],
482 step[6 * row + 5],
483 );
484 *pose = *pose * Se3::exp(&delta);
485 row += 1;
486 }
487 }
488}
489
490/// `JᵀWJ` in calibrated units, with the directions the geometry cannot see
491/// at all left out.
492///
493/// ```text
494/// Λ = Σ vᵢ vᵢᵀ / spreadᵢ² over the directions with a finite spread
495/// ```
496///
497/// A direction the geometry does not constrain has `σ' = 0`, an infinite
498/// spread, and is absent from the sum rather than given a small weight. An
499/// arbitrary number with a small weight still pulls a survey towards
500/// itself; absent, it is what it is, which is no information. The rest of
501/// the spectrum is carried in full.
502///
503/// # What this is, stated plainly, because it used to claim more
504///
505/// Everywhere except an exactly blind direction this **equals `JᵀWJ/σ²`**.
506/// It is that matrix rebuilt from its own spectrum in calibrated units, not
507/// a different matrix. Nothing here is cleverer than what a pose-graph
508/// package is already handed; what it adds is the calibration in `σ` and a
509/// null direction that stays null instead of being damped into a number.
510/// The name says so: through 0.1.1 this was `weighted_information`, which
511/// promised a weighting by conditioning that has since been measured and
512/// withdrawn. Same function, honest name.
513///
514/// Until S6 this function dropped every direction whose predicted spread
515/// exceeded the survey's required accuracy — a threshold, and the crate's
516/// claim to exist. That was measured and did not survive. On the ETH ASL
517/// surveys, across two scenes, five fields of view and six tolerances, the
518/// thresholded version never once beat `JᵀWJ` and lost by up to 3.4×; and
519/// the reason is that on real scans the six spreads of an edge lie within
520/// one order of magnitude of each other — `σ_min/σ_max` measured between
521/// 0.46 and 0.039 over everything tried — so a threshold either keeps them
522/// all or drops them all. The gap the synthetic gates relied on, four
523/// orders wide, is a property of geometry that has been given exactly, not
524/// of geometry that has been scanned.
525///
526/// Five attempts have since been measured against theodolite truth — this
527/// threshold, an additive floor, a probabilistic attenuation from a noise
528/// model, a floor tied to the measured bias, and discarding the spectrum
529/// altogether — and all five lose. The reason is that an edge's error is a
530/// *bias* rather than scatter, fourteen to thirty times larger than the
531/// scatter the closed form correctly predicts, and that the bias lies away
532/// from the best-determined direction on every scene tried. `JᵀWJ` says
533/// exactly that: most uncertainty where the geometry is weakest. Its shape
534/// is right and only its scale is wrong, and a scale common to every edge
535/// does not move a survey. Each of the five changed the shape.
536///
537/// What survived that measurement is the diagnosis: *which* directions are
538/// weak is worth reporting, and
539/// [`Conditioning::classify`](rigidity_core::observability::Conditioning::classify)
540/// still reports it. Turning that report into a binary weight is the part
541/// that did not.
542///
543/// The calibration is the caller's business, and it belongs in
544/// `noise_sigma`. On real data the predicted spread is optimistic — the
545/// project measured about seventeenfold — and passing an uncorrected sigma
546/// gives a survey that states an accuracy seventeen times better than it
547/// has.
548pub fn calibrated_information(conditioning: &Conditioning, noise_sigma: f64) -> Matrix6<f64> {
549 // The spectrum lives in normalised coordinates and an edge lives in
550 // world ones. `to_normalised` is the linear map between them; it is
551 // applied to the six basis vectors rather than rebuilt from the centre
552 // and the radius of gyration, so this cannot drift away from the
553 // transform the report itself uses.
554 let mut to_normalised = Matrix6::zeros();
555 for axis in 0..6 {
556 let mut basis = Vector6::zeros();
557 basis[axis] = 1.0;
558 to_normalised.set_column(axis, &conditioning.to_normalised(basis));
559 }
560
561 let spreads = conditioning.uncertainty(noise_sigma);
562 let mut normalised = Matrix6::zeros();
563 for (index, spread) in spreads.iter().enumerate() {
564 if !(spread.is_finite() && *spread > 0.0) {
565 continue;
566 }
567 let direction = conditioning.direction(index);
568 normalised += direction * direction.transpose() / (spread * spread);
569 }
570
571 to_normalised.transpose() * normalised * to_normalised
572}
573
574/// What one edge is doing after a solve.
575#[derive(Debug, Clone, Copy, PartialEq)]
576pub struct EdgeReport {
577 /// Its position in [`PoseGraph::edges`].
578 pub edge: usize,
579 /// How far apart the two ends are, metres.
580 pub translation: f64,
581 /// And by what angle, radians.
582 pub rotation: f64,
583 /// `rᵀΛr`: how hard this edge is pulling against the rest.
584 ///
585 /// The pair of numbers is the diagnosis, not either alone. An edge that
586 /// disagrees by half a metre and costs nothing is an edge whose weight
587 /// along that direction was removed — it could not see along there, the
588 /// survey settled by some other path, and nothing is wrong. An edge that
589 /// disagrees by a millimetre and costs a great deal is a measurement in
590 /// a fight it should be winning.
591 pub cost: f64,
592}
593
594/// How well the survey determines one station.
595///
596/// The covariance is reported and the readings are derived from it, rather
597/// than the other way round, because the frame it is in is the caller's
598/// business. A graph built in coordinates conjugated onto the survey — which
599/// is what anything georeferenced has to do — gets marginals in those
600/// coordinates, and only the caller knows how to carry them back.
601#[derive(Debug, Clone, Copy, PartialEq)]
602pub struct NodeReport {
603 /// Its position in [`PoseGraph::poses`].
604 pub node: usize,
605 /// The marginal covariance of this station's pose, `ξ = [ρ; φ]`,
606 /// relative to the anchor.
607 pub covariance: Matrix6<f64>,
608 /// Whether the survey determines this station at all.
609 ///
610 /// False when some direction of it lies in the null space of the normal
611 /// equations — nothing joins it to the anchor, or nothing measures one
612 /// of its degrees of freedom. The covariance is then meaningless rather
613 /// than large, and the readings below say so by returning infinity.
614 pub determined: bool,
615}
616
617impl NodeReport {
618 /// The 3×3 marginal covariance of position.
619 pub fn position_covariance(&self) -> Matrix3<f64> {
620 if !self.determined {
621 return Matrix3::from_diagonal_element(f64::INFINITY);
622 }
623 self.covariance.fixed_view::<3, 3>(0, 0).into()
624 }
625
626 /// Standard deviation along the worst-determined direction of position,
627 /// metres, and the direction itself.
628 pub fn position(&self) -> (f64, Vector3<f64>) {
629 if !self.determined {
630 return (f64::INFINITY, Vector3::new(1.0, 0.0, 0.0));
631 }
632 let eigen = self.position_covariance().symmetric_eigen();
633 let (index, value) = eigen.eigenvalues.iter().enumerate().fold(
634 (0usize, f64::NEG_INFINITY),
635 |best, (index, value)| {
636 if *value > best.1 {
637 (index, *value)
638 } else {
639 best
640 }
641 },
642 );
643 (
644 value.max(0.0).sqrt(),
645 eigen.eigenvectors.column(index).into(),
646 )
647 }
648
649 /// Standard deviation about the worst-determined axis, radians.
650 pub fn orientation(&self) -> f64 {
651 if !self.determined {
652 return f64::INFINITY;
653 }
654 self.covariance
655 .fixed_view::<3, 3>(3, 3)
656 .symmetric_eigen()
657 .eigenvalues
658 .iter()
659 .fold(0.0f64, |best, value| best.max(*value))
660 .max(0.0)
661 .sqrt()
662 }
663}
664
665/// The survey, seen whole.
666#[derive(Debug, Clone, PartialEq)]
667pub struct Diagnosis {
668 /// One per edge, in graph order.
669 pub edges: Vec<EdgeReport>,
670 /// One per node, in graph order. The anchor is absent: it is where the
671 /// survey is measured *from*, so its spread is zero by definition and a
672 /// row saying so would be a row about the definition.
673 pub nodes: Vec<NodeReport>,
674}
675
676impl PoseGraph {
677 /// What the survey looks like from above, at the poses it currently has.
678 ///
679 /// Node spreads come from the pseudo-inverse of `H = ΣJᵀΛJ`, whose
680 /// diagonal blocks are each station's marginal covariance relative to
681 /// the anchor. Pseudo- rather than plain inverse, and that is the whole
682 /// point: a survey with a direction nothing constrains has a singular
683 /// `H`, which is not an error to be damped away but the finding. A
684 /// damped inverse would answer "this station is known to a centimetre"
685 /// where the truth is that nothing in the survey knows where it is.
686 ///
687 /// The null space is left out of the sum rather than inverted into
688 /// infinities. Building `V·diag(∞)·Vᵀ` and reading blocks out of it does
689 /// not give infinity where it should — an eigenvector component that is
690 /// exactly zero turns `∞·0` into `NaN`, and the `NaN`s spread into the
691 /// blocks of stations the survey determines perfectly well. Which
692 /// coordinates are unconstrained is asked separately, of how much of
693 /// each lies in the null space.
694 ///
695 /// The cutoff is relative, at `1e-12` of the largest eigenvalue — the
696 /// same shape of judgement the conditioning report makes about a single
697 /// registration, one level up.
698 pub fn diagnose(&self, anchor: usize) -> Result<Diagnosis, GraphError> {
699 let nodes = self.poses.len();
700 if anchor >= nodes {
701 return Err(GraphError::NoSuchAnchor { anchor, nodes });
702 }
703
704 let edges = self
705 .edges
706 .iter()
707 .enumerate()
708 .map(|(index, edge)| {
709 let residual = self.residual(edge);
710 EdgeReport {
711 edge: index,
712 translation: residual.fixed_rows::<3>(0).norm(),
713 rotation: residual.fixed_rows::<3>(3).norm(),
714 cost: (residual.transpose() * edge.information * residual)[(0, 0)],
715 }
716 })
717 .collect();
718
719 let (hessian, _) = self.normal_equations(anchor);
720 let width = hessian.nrows();
721 let eigen = hessian.symmetric_eigen();
722 let largest = eigen
723 .eigenvalues
724 .iter()
725 .fold(0.0f64, |best, value| best.max(*value));
726 let cutoff = largest * 1e-12;
727
728 let mut covariance = DMatrix::zeros(width, width);
729 // How much of each coordinate lies in the null space. Anything
730 // above a rounding error there means the survey does not determine
731 // that coordinate at all.
732 let mut unconstrained: DVector<f64> = DVector::zeros(width);
733 for (index, value) in eigen.eigenvalues.iter().enumerate() {
734 let vector = eigen.eigenvectors.column(index);
735 if *value > cutoff && largest > 0.0 {
736 covariance += (vector * vector.transpose()) / *value;
737 } else {
738 for row in 0..width {
739 unconstrained[row] += vector[row] * vector[row];
740 }
741 }
742 }
743
744 let mut reports = Vec::with_capacity(nodes.saturating_sub(1));
745 for node in 0..nodes {
746 if node == anchor {
747 continue;
748 }
749 let row = 6 * if node < anchor { node } else { node - 1 };
750 let determined = largest > 0.0 && (0..6).all(|axis| unconstrained[row + axis] <= 1e-9);
751 let mut block = Matrix6::zeros();
752 if determined {
753 for r in 0..6 {
754 for c in 0..6 {
755 block[(r, c)] = covariance[(row + r, row + c)];
756 }
757 }
758 }
759 reports.push(NodeReport {
760 node,
761 covariance: block,
762 determined,
763 });
764 }
765
766 Ok(Diagnosis {
767 edges,
768 nodes: reports,
769 })
770 }
771}