|
1 | 1 | #!/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. |
10 | 11 | """ |
11 | 12 | from __future__ import annotations |
12 | 13 |
|
|
32 | 33 | S_OLD = """ Matrix3& S_mat = S_scratch_;\n S_mat = Rmag;""" |
33 | 34 | S_NEW = r''' Matrix3 Rmag_eff = Rmag; |
34 | 35 | #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); |
38 | 39 | 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); |
40 | 41 | }(); |
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); |
44 | 45 | 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); |
46 | 47 | }(); |
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; |
52 | 60 | Vector3 down_b = R_wb() * Vector3(T(0), T(0), T(1)); |
53 | 61 | const T dn = down_b.norm(); |
54 | 62 | if (std::isfinite(dn) && dn > T(1e-8)) { |
55 | 63 | 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. |
58 | 64 | Vector3 t_yaw = b.cross(down_b); |
59 | 65 | const T yn = t_yaw.norm(); |
60 | 66 | if (std::isfinite(yn) && yn > T(1e-6)) { |
|
63 | 69 | const T tn = t_tilt.norm(); |
64 | 70 | if (std::isfinite(tn) && tn > T(1e-6)) { |
65 | 71 | 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. |
70 | 80 | const Matrix3 M = b * b.transpose() |
71 | | - + sy * (t_yaw * t_yaw.transpose()) |
| 81 | + + t_yaw * t_yaw.transpose() |
72 | 82 | + st * (t_tilt * t_tilt.transpose()); |
73 | 83 | Rmag_eff = M * Rmag * M.transpose(); |
74 | 84 | Rmag_eff = T(0.5) * (Rmag_eff + Rmag_eff.transpose()); |
|
86 | 96 |
|
87 | 97 |
|
88 | 98 | 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`. |
93 | 101 | subprocess.run(["make", "kalman_ou_iii-sim"], cwd=TEST, check=True) |
94 | 102 |
|
95 | 103 |
|
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): |
97 | 105 | env = os.environ.copy() |
98 | 106 | env["W3D_WRITE_TIMESERIES"] = "0" |
99 | 107 | 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) |
102 | 111 | p = subprocess.run([str(BIN)], cwd=TEST, env=env, text=True, |
103 | 112 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
104 | 113 | ds = DS_RE.findall(p.stdout) |
@@ -146,37 +155,36 @@ def main() -> None: |
146 | 155 | HEADER.write_text(h) |
147 | 156 | build() |
148 | 157 |
|
149 | | - base = run(1.0, 1.0) |
| 158 | + base = run(0.0, 0.0, 1.15) |
150 | 159 | print("BASELINE", flush=True) |
151 | 160 |
|
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))) |
165 | 170 |
|
166 | 171 | 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) |
169 | 174 | 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) |
171 | 177 | for r, d, m, b, v in rows[:8]: |
172 | 178 | 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)) |
174 | 180 |
|
175 | 181 | 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) |
178 | 185 | 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) |
180 | 188 | print("STRICT_DOMINANCE=PASS" if best[0] < 1.0 and -best[2] == 56 |
181 | 189 | else "STRICT_DOMINANCE=FAIL", flush=True) |
182 | 190 | finally: |
|
0 commit comments