Skip to content

Commit 24ae8f3

Browse files
committed
Test norm-aware magnetic tilt weighting
1 parent ac3ac05 commit 24ae8f3

1 file changed

Lines changed: 64 additions & 56 deletions

File tree

tools/ou_iii_mag_tangent_sweep.py

Lines changed: 64 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
#!/usr/bin/env python3
2-
"""Branch-only sweep of tangent-plane magnetic measurement covariance.
3-
4-
The 3-D magnetometer residual has one radial direction parallel to the
5-
predicted field and two attitude-informative tangent directions. This study
6-
scales the measurement sigma independently in the tangent direction excited by
7-
a yaw error (B x down) and in the orthogonal tangent direction. The radial
8-
direction is unchanged. At yaw_scale=tilt_scale=1 the production update is
9-
bit-for-bit unchanged.
2+
"""Branch-only sweep of norm-aware tangent-plane magnetic covariance.
3+
4+
The magnetometer Jacobian J=-[B]x has no first-order attitude information in
5+
the radial field direction. The two useful tangent directions are separated
6+
into the yaw-sensitive direction B x down and the complementary tilt-sensitive
7+
direction. This experiment leaves yaw and radial confidence unchanged and
8+
inflates only tilt-sensitive covariance when the measured field norm disagrees
9+
with the predicted reference norm -- a direct signature of residual soft-iron
10+
scale/cross-axis distortion that a pure attitude rotation cannot create.
1011
"""
1112
from __future__ import annotations
1213

@@ -32,29 +33,34 @@
3233
S_OLD = """ Matrix3& S_mat = S_scratch_;\n S_mat = Rmag;"""
3334
S_NEW = r''' Matrix3 Rmag_eff = Rmag;
3435
#ifdef EIGEN_NON_ARDUINO
35-
const T yaw_sigma_scale = []() -> T {
36-
const char* s = std::getenv("OU3_MAG_YAW_SIGMA_SCALE");
37-
if (!s) return T(1);
36+
const T tilt_norm_gain = []() -> T {
37+
const char* s = std::getenv("OU3_MAG_TILT_NORM_GAIN");
38+
if (!s) return T(0);
3839
const T v = static_cast<T>(std::strtod(s, nullptr));
39-
return (std::isfinite(v) && v > T(0)) ? v : T(1);
40+
return (std::isfinite(v) && v >= T(0)) ? v : T(0);
4041
}();
41-
const T tilt_sigma_scale = []() -> T {
42-
const char* s = std::getenv("OU3_MAG_TILT_SIGMA_SCALE");
43-
if (!s) return T(1);
42+
const T tilt_norm_deadband = []() -> T {
43+
const char* s = std::getenv("OU3_MAG_TILT_NORM_DEADBAND");
44+
if (!s) return T(0);
4445
const T v = static_cast<T>(std::strtod(s, nullptr));
45-
return (std::isfinite(v) && v > T(0)) ? v : T(1);
46+
return (std::isfinite(v) && v >= T(0)) ? v : T(0);
4647
}();
47-
if (std::abs(yaw_sigma_scale - T(1)) > T(1e-7) ||
48-
std::abs(tilt_sigma_scale - T(1)) > T(1e-7)) {
49-
const T bn = v2hat.norm();
50-
if (std::isfinite(bn) && bn > T(1e-8)) {
51-
const Vector3 b = v2hat / bn;
48+
const T tilt_norm_cap = []() -> T {
49+
const char* s = std::getenv("OU3_MAG_TILT_NORM_CAP");
50+
if (!s) return T(1.15);
51+
const T v = static_cast<T>(std::strtod(s, nullptr));
52+
return (std::isfinite(v) && v >= T(1)) ? v : T(1.15);
53+
}();
54+
if (tilt_norm_gain > T(0)) {
55+
const T bhat_norm = v2hat.norm();
56+
const T bmeas_norm = v2.norm();
57+
if (std::isfinite(bhat_norm) && std::isfinite(bmeas_norm) &&
58+
bhat_norm > T(1e-8) && bmeas_norm > T(1e-8)) {
59+
const Vector3 b = v2hat / bhat_norm;
5260
Vector3 down_b = R_wb() * Vector3(T(0), T(0), T(1));
5361
const T dn = down_b.norm();
5462
if (std::isfinite(dn) && dn > T(1e-8)) {
5563
down_b /= dn;
56-
// A small yaw error is a rotation about local down. Through
57-
// J=-[B]x, its measurement residual lies along B x down.
5864
Vector3 t_yaw = b.cross(down_b);
5965
const T yn = t_yaw.norm();
6066
if (std::isfinite(yn) && yn > T(1e-6)) {
@@ -63,12 +69,16 @@
6369
const T tn = t_tilt.norm();
6470
if (std::isfinite(tn) && tn > T(1e-6)) {
6571
t_tilt /= tn;
66-
const T sy = std::clamp(yaw_sigma_scale, T(0.5), T(4));
67-
const T st = std::clamp(tilt_sigma_scale, T(0.5), T(4));
68-
// Congruence scaling preserves the exact configured
69-
// Rmag at sy=st=1, including a future non-isotropic Rmag.
72+
const T rel_norm_error = std::abs(bmeas_norm / bhat_norm - T(1));
73+
const T excess = std::max(T(0), rel_norm_error - tilt_norm_deadband);
74+
const T st = std::clamp(T(1) + tilt_norm_gain * excess,
75+
T(1), tilt_norm_cap);
76+
// Radial and yaw-sensitive information are unchanged.
77+
// Only the tangent direction that primarily informs
78+
// roll/pitch is softened when |B| says the sample is
79+
// inconsistent with a rigid attitude rotation.
7080
const Matrix3 M = b * b.transpose()
71-
+ sy * (t_yaw * t_yaw.transpose())
81+
+ t_yaw * t_yaw.transpose()
7282
+ st * (t_tilt * t_tilt.transpose());
7383
Rmag_eff = M * Rmag * M.transpose();
7484
Rmag_eff = T(0.5) * (Rmag_eff + Rmag_eff.transpose());
@@ -86,19 +96,18 @@
8696

8797

8898
def build() -> None:
89-
# Do not run `make clean` here: the OU-III clean target also removes the
90-
# downloaded wave_data_*.csv fixtures. The checkout is clean and this
91-
# script modifies a header, so ordinary dependency tracking rebuilds the
92-
# simulator while preserving the eight downloaded records.
99+
# The OU-III clean target removes the downloaded wave_data_*.csv fixtures,
100+
# so rely on header dependency tracking instead of `make clean`.
93101
subprocess.run(["make", "kalman_ou_iii-sim"], cwd=TEST, check=True)
94102

95103

96-
def run(yaw_scale: float = 1.0, tilt_scale: float = 1.0):
104+
def run(gain: float = 0.0, deadband: float = 0.0, cap: float = 1.15):
97105
env = os.environ.copy()
98106
env["W3D_WRITE_TIMESERIES"] = "0"
99107
env["W3D_COLLECT_ALL_GATES"] = "1"
100-
env["OU3_MAG_YAW_SIGMA_SCALE"] = str(yaw_scale)
101-
env["OU3_MAG_TILT_SIGMA_SCALE"] = str(tilt_scale)
108+
env["OU3_MAG_TILT_NORM_GAIN"] = str(gain)
109+
env["OU3_MAG_TILT_NORM_DEADBAND"] = str(deadband)
110+
env["OU3_MAG_TILT_NORM_CAP"] = str(cap)
102111
p = subprocess.run([str(BIN)], cwd=TEST, env=env, text=True,
103112
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
104113
ds = DS_RE.findall(p.stdout)
@@ -146,37 +155,36 @@ def main() -> None:
146155
HEADER.write_text(h)
147156
build()
148157

149-
base = run(1.0, 1.0)
158+
base = run(0.0, 0.0, 1.15)
150159
print("BASELINE", flush=True)
151160

152-
# First sweep showed that weakening the yaw-sensitive tangent direction
153-
# moves the stubborn horizontal-X metrics in the wrong direction, while
154-
# weakening the orthogonal (tilt-sensitive) tangent direction produces
155-
# most of the broad gains. Search the complementary quadrant: slightly
156-
# *more* yaw information with slightly *less* tilt information.
157-
candidates = [
158-
(0.88, 1.10),
159-
(0.92, 1.06), (0.92, 1.10), (0.92, 1.14),
160-
(0.96, 1.06), (0.96, 1.10), (0.96, 1.14),
161-
(1.00, 1.06), (1.00, 1.10), (1.00, 1.14),
162-
(1.04, 1.06), (1.04, 1.10), (1.04, 1.14),
163-
(0.96, 1.18), (0.96, 1.22),
164-
]
161+
# A 1--2% norm inconsistency is the expected order of residual soft-iron
162+
# scale/cross-axis distortion. Gains 2--8 therefore span roughly the
163+
# static 1.02--1.16 tilt-sigma range, but only on inconsistent samples.
164+
candidates = []
165+
for deadband in (0.0, 0.003, 0.006, 0.010):
166+
for gain in (2.0, 4.0, 6.0, 8.0):
167+
candidates.append((gain, deadband, 1.15))
168+
candidates.extend(((4.0, 0.003, 1.10), (6.0, 0.003, 1.10),
169+
(4.0, 0.006, 1.10), (6.0, 0.006, 1.10)))
165170

166171
results = []
167-
for sy, st in candidates:
168-
cand = run(sy, st)
172+
for gain, deadband, cap in candidates:
173+
cand = run(gain, deadband, cap)
169174
mx, n, geo, rows = compare(base, cand)
170-
print(f"RESULT yaw={sy:.3f} tilt={st:.3f} max={mx:.9f} geo={geo:.9f} improved={n}/56", flush=True)
175+
print(f"RESULT gain={gain:.2f} deadband={deadband:.3f} cap={cap:.2f} "
176+
f"max={mx:.9f} geo={geo:.9f} improved={n}/56", flush=True)
171177
for r, d, m, b, v in rows[:8]:
172178
print(f" {r:.9f} {m:5s} {d}: {b:.9g} -> {v:.9g}", flush=True)
173-
results.append((mx, geo, -n, sy, st, rows))
179+
results.append((mx, geo, -n, gain, deadband, cap, rows))
174180

175181
print("=== RANKING ===", flush=True)
176-
for mx, geo, nn, sy, st, rows in sorted(results)[:15]:
177-
print(f"yaw={sy:.3f} tilt={st:.3f} max={mx:.9f} geo={geo:.9f} improved={-nn}/56", flush=True)
182+
for mx, geo, nn, gain, db, cap, rows in sorted(results)[:15]:
183+
print(f"gain={gain:.2f} deadband={db:.3f} cap={cap:.2f} "
184+
f"max={mx:.9f} geo={geo:.9f} improved={-nn}/56", flush=True)
178185
best = sorted(results)[0]
179-
print(f"BEST yaw={best[3]:g} tilt={best[4]:g} max={best[0]:.9f} improved={-best[2]}/56", flush=True)
186+
print(f"BEST gain={best[3]:g} deadband={best[4]:g} cap={best[5]:g} "
187+
f"max={best[0]:.9f} improved={-best[2]}/56", flush=True)
180188
print("STRICT_DOMINANCE=PASS" if best[0] < 1.0 and -best[2] == 56
181189
else "STRICT_DOMINANCE=FAIL", flush=True)
182190
finally:

0 commit comments

Comments
 (0)