Skip to main content

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}