fix(mat): real dimensionless GDOP = sqrt(trace((HtH)^-1)), not ad-hoc angle factor (ADR-158 §5)

estimate_gdop returned an average-pair-angle factor merely labelled GDOP (the same
class of defect ADR-156 §2.3 fixed). Replaced with the genuine Geometric Dilution
of Precision computed from the range-measurement Jacobian H (unit target->sensor
bearings): GDOP = sqrt(trace((HtH)^-1)), dimensionless, returning None for singular
(collinear) geometry which the caller treats as factor 1.0. Tests assert a
well-spread array yields lower GDOP than a near-collinear one, cross-check the
closed form, and confirm singular geometry returns None.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-06-11 21:37:10 -04:00
parent c9a8ca758a
commit 982994ca3c
1 changed files with 132 additions and 36 deletions

View File

@ -239,8 +239,13 @@ impl Triangulator {
let rmse = (sum_sq_error / distances.len() as f64).sqrt();
// GDOP (Geometric Dilution of Precision) approximation
let gdop = self.estimate_gdop(position, distances);
// Real, dimensionless GDOP (Geometric Dilution of Precision). Falls back
// to a unit factor for a degenerate (collinear) geometry where (HᵀH) is
// singular — that geometry already produces a large residual RMSE.
let gdop = self
.compute_gdop(position, distances)
.unwrap_or(1.0)
.max(1.0);
LocationUncertainty {
horizontal_error: rmse * gdop,
@ -249,45 +254,59 @@ impl Triangulator {
}
}
/// Estimate Geometric Dilution of Precision
fn estimate_gdop(&self, position: &[f64], distances: &[(SensorPosition, f64)]) -> f64 {
// Simplified GDOP based on sensor geometry
let mut sum_angle = 0.0;
let n = distances.len();
/// Compute the real Geometric Dilution of Precision (GDOP).
///
/// GDOP is the dimensionless factor by which measurement (range) noise is
/// amplified into position error by the sensor geometry. For range-based 2D
/// positioning the measurement Jacobian `H` has one row per sensor equal to
/// the unit bearing vector from the target to that sensor,
/// `[ (xₛ-xₜ)/d , (yₛ-yₜ)/d ]`. The position covariance (per unit measurement
/// variance) is `(HᵀH)⁻¹`, and
///
/// ```text
/// GDOP = sqrt( trace( (HᵀH)⁻¹ ) )
/// ```
///
/// This is the same quantity ADR-156 §2.3 corrected elsewhere — a genuine
/// dimensionless dilution, not the previous ad-hoc average-angle factor that
/// was merely *labelled* GDOP. Returns `None` when `HᵀH` is singular
/// (collinear / coincident geometry), which the caller treats as no
/// dilution information (factor 1.0).
fn compute_gdop(&self, position: &[f64], distances: &[(SensorPosition, f64)]) -> Option<f64> {
let (tx, ty) = (position[0], position[1]);
for i in 0..n {
for j in (i + 1)..n {
let dx1 = distances[i].0.x - position[0];
let dy1 = distances[i].0.y - position[1];
let dx2 = distances[j].0.x - position[0];
let dy2 = distances[j].0.y - position[1];
let dot = dx1 * dx2 + dy1 * dy2;
let mag1 = (dx1 * dx1 + dy1 * dy1).sqrt();
let mag2 = (dx2 * dx2 + dy2 * dy2).sqrt();
if mag1 > 0.0 && mag2 > 0.0 {
let cos_angle = (dot / (mag1 * mag2)).clamp(-1.0, 1.0);
let angle = cos_angle.acos();
sum_angle += angle;
}
// Accumulate HᵀH (2×2, symmetric) from unit bearing vectors.
let (mut hxx, mut hxy, mut hyy) = (0.0_f64, 0.0_f64, 0.0_f64);
let mut rows = 0usize;
for (sensor, _dist) in distances {
let dx = sensor.x - tx;
let dy = sensor.y - ty;
let d = (dx * dx + dy * dy).sqrt();
if d <= f64::EPSILON {
continue; // target coincident with sensor: undefined bearing
}
let ux = dx / d;
let uy = dy / d;
hxx += ux * ux;
hxy += ux * uy;
hyy += uy * uy;
rows += 1;
}
// Average angle between sensor pairs
let num_pairs = (n * (n - 1)) as f64 / 2.0;
let avg_angle = if num_pairs > 0.0 {
sum_angle / num_pairs
} else {
std::f64::consts::PI / 4.0
};
if rows < 2 {
return None;
}
// GDOP is better when sensors are spread out (angle closer to 90 degrees)
// GDOP gets worse as sensors are collinear
let optimal_angle = std::f64::consts::PI / 2.0;
let angle_factor = (avg_angle / optimal_angle - 1.0).abs() + 1.0;
angle_factor.max(1.0)
// Invert the 2×2 HᵀH. trace((HᵀH)⁻¹) = (hxx + hyy) / det.
let det = hxx * hyy - hxy * hxy;
if det.abs() < 1e-12 {
return None; // singular: collinear geometry
}
let trace_inv = (hxx + hyy) / det;
if trace_inv <= 0.0 {
return None;
}
Some(trace_inv.sqrt())
}
}
@ -390,6 +409,83 @@ mod tests {
let result = triangulator.estimate_position(&sensors, &rssi_values);
assert!(result.is_none());
}
fn sensor_at(id: &str, x: f64, y: f64) -> SensorPosition {
SensorPosition {
id: id.to_string(),
x,
y,
z: 1.5,
sensor_type: SensorType::Transceiver,
is_operational: true,
last_rssi: None,
}
}
/// Real GDOP: dimensionless, geometry-dependent, and matches the closed-form
/// sqrt(trace((HᵀH)⁻¹)). A well-spread (near-orthogonal) array must give a
/// LOWER GDOP than a near-collinear one. (The old ad-hoc angle factor was not
/// a true dilution and is replaced.)
#[test]
fn test_gdop_is_real_dilution() {
let t = Triangulator::with_defaults();
let target = [5.0_f64, 5.0_f64];
// Well-distributed: an equilateral-ish triangle around the target.
let good = vec![
(sensor_at("a", 5.0, 15.0), 10.0),
(sensor_at("b", -3.66, 0.0), 10.0),
(sensor_at("c", 13.66, 0.0), 10.0),
];
let gdop_good = t.compute_gdop(&target, &good).expect("good geometry");
// Near-collinear: bearings nearly all along ±y with a tiny x-spread, so
// HᵀH is ill-conditioned (invertible but with a small eigenvalue) and the
// GDOP is large but finite.
let bad = vec![
(sensor_at("a", 5.3, 15.0), 10.0),
(sensor_at("b", 4.7, 15.0), 10.0),
(sensor_at("c", 5.1, -5.0), 10.0),
];
let gdop_bad = t.compute_gdop(&target, &bad).expect("bad geometry");
assert!(gdop_good >= 1.0, "GDOP must be >= 1 (dilution, dimensionless)");
assert!(
gdop_good < gdop_bad,
"well-spread GDOP {gdop_good} must be < near-collinear GDOP {gdop_bad}"
);
// Closed-form cross-check for the well-spread case: each unit bearing
// vector contributes to HᵀH; verify trace((HᵀH)⁻¹) explicitly.
let (mut hxx, mut hxy, mut hyy) = (0.0, 0.0, 0.0);
for (s, _d) in &good {
let dx = s.x - target[0];
let dy = s.y - target[1];
let d = (dx * dx + dy * dy).sqrt();
let (ux, uy) = (dx / d, dy / d);
hxx += ux * ux;
hxy += ux * uy;
hyy += uy * uy;
}
let det = hxx * hyy - hxy * hxy;
let expected = ((hxx + hyy) / det).sqrt();
assert!((gdop_good - expected).abs() < 1e-9);
}
/// Collinear geometry makes HᵀH singular -> compute_gdop returns None,
/// and the uncertainty path falls back to a unit factor (no fabrication).
#[test]
fn test_gdop_singular_collinear_is_none() {
let t = Triangulator::with_defaults();
let target = [0.0_f64, 0.0_f64];
// All sensors on the +x axis through the target: bearings all ±x -> rank 1.
let collinear = vec![
(sensor_at("a", 1.0, 0.0), 1.0),
(sensor_at("b", 2.0, 0.0), 2.0),
(sensor_at("c", 3.0, 0.0), 3.0),
];
assert!(t.compute_gdop(&target, &collinear).is_none());
}
}
// ---------------------------------------------------------------------------