aero-agent-roles 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/NOTICE +18 -0
  2. package/README.md +49 -0
  3. package/bin/aero-agent-roles.js +127 -0
  4. package/lib/catalog.js +97 -0
  5. package/lib/install.js +48 -0
  6. package/lib/mcp.js +199 -0
  7. package/manifest.json +516 -0
  8. package/package.json +44 -0
  9. package/roles/aerodynamics-engineer/ROLE.md +153 -0
  10. package/roles/aerodynamics-engineer/SOURCES.md +12 -0
  11. package/roles/aerodynamics-engineer/cli.py +143 -0
  12. package/roles/aerodynamics-engineer/core/aerodynamics_core.py +1218 -0
  13. package/roles/aerodynamics-engineer/templates/aero-design-report-template.md +72 -0
  14. package/roles/aerodynamics-engineer/tests/test_aerodynamics_core.py +227 -0
  15. package/roles/aerodynamics-engineer/tests/test_role_aerodynamics.py +94 -0
  16. package/roles/aircraft-design-engineer/ROLE.md +138 -0
  17. package/roles/aircraft-design-engineer/SOURCES.md +12 -0
  18. package/roles/aircraft-design-engineer/cli.py +152 -0
  19. package/roles/aircraft-design-engineer/core/aircraft_design_core.py +1278 -0
  20. package/roles/aircraft-design-engineer/templates/concept-design-template.md +99 -0
  21. package/roles/aircraft-design-engineer/tests/test_aircraft_design_core.py +269 -0
  22. package/roles/aircraft-design-engineer/tests/test_role_aircraft_design_engineer.py +106 -0
  23. package/roles/airworthiness-compliance-engineer/ROLE.md +122 -0
  24. package/roles/airworthiness-compliance-engineer/SOURCES.md +16 -0
  25. package/roles/airworthiness-compliance-engineer/cli.py +146 -0
  26. package/roles/airworthiness-compliance-engineer/core/airworthiness_core.py +894 -0
  27. package/roles/airworthiness-compliance-engineer/templates/compliance-matrix-template.md +46 -0
  28. package/roles/airworthiness-compliance-engineer/tests/test_airworthiness_core.py +224 -0
  29. package/roles/airworthiness-compliance-engineer/tests/test_role_airworthiness.py +85 -0
  30. package/roles/as9100-quality-auditor/ROLE.md +128 -0
  31. package/roles/as9100-quality-auditor/SOURCES.md +16 -0
  32. package/roles/as9100-quality-auditor/cli.py +136 -0
  33. package/roles/as9100-quality-auditor/core/as9100_core.py +857 -0
  34. package/roles/as9100-quality-auditor/templates/findings-report-template.md +119 -0
  35. package/roles/as9100-quality-auditor/tests/test_as9100_cli_profile.py +105 -0
  36. package/roles/as9100-quality-auditor/tests/test_as9100_core.py +204 -0
  37. package/roles/as9100-quality-auditor/tests/test_role_as9100.py +90 -0
  38. package/roles/data-bus-avionics-engineer/ROLE.md +146 -0
  39. package/roles/data-bus-avionics-engineer/SOURCES.md +23 -0
  40. package/roles/data-bus-avionics-engineer/cli.py +311 -0
  41. package/roles/data-bus-avionics-engineer/core/data_bus_avionics_core.py +1121 -0
  42. package/roles/data-bus-avionics-engineer/templates/bus-assessment-template.md +134 -0
  43. package/roles/data-bus-avionics-engineer/tests/test_bundle_protocol.py +137 -0
  44. package/roles/data-bus-avionics-engineer/tests/test_data_bus_avionics_core.py +261 -0
  45. package/roles/data-bus-avionics-engineer/tests/test_role_data_bus_avionics_engineer.py +121 -0
  46. package/roles/do160-environmental-engineer/ROLE.md +136 -0
  47. package/roles/do160-environmental-engineer/SOURCES.md +30 -0
  48. package/roles/do160-environmental-engineer/cli.py +147 -0
  49. package/roles/do160-environmental-engineer/core/do160_environmental_core.py +1246 -0
  50. package/roles/do160-environmental-engineer/templates/environmental-qualification-plan-template.md +114 -0
  51. package/roles/do160-environmental-engineer/tests/test_do160_environmental_core.py +312 -0
  52. package/roles/do160-environmental-engineer/tests/test_role_do160_environmental_engineer.py +281 -0
  53. package/roles/do178c-cert-engineer/ROLE.md +120 -0
  54. package/roles/do178c-cert-engineer/SOURCES.md +16 -0
  55. package/roles/do178c-cert-engineer/cli.py +129 -0
  56. package/roles/do178c-cert-engineer/core/do178c_core.py +411 -0
  57. package/roles/do178c-cert-engineer/templates/psac-template.md +74 -0
  58. package/roles/do178c-cert-engineer/tests/test_do178c_cert_engineer_core.py +91 -0
  59. package/roles/do178c-cert-engineer/tests/test_role_do178c.py +109 -0
  60. package/roles/do254-hardware-engineer/ROLE.md +127 -0
  61. package/roles/do254-hardware-engineer/SOURCES.md +19 -0
  62. package/roles/do254-hardware-engineer/cli.py +308 -0
  63. package/roles/do254-hardware-engineer/core/do254_hardware_core.py +761 -0
  64. package/roles/do254-hardware-engineer/templates/phac-template.md +87 -0
  65. package/roles/do254-hardware-engineer/tests/test_do254_bundle_profile.py +152 -0
  66. package/roles/do254-hardware-engineer/tests/test_do254_hardware_core.py +200 -0
  67. package/roles/do254-hardware-engineer/tests/test_role_do254_hardware.py +108 -0
  68. package/roles/engineering-analysis-engineer/ROLE.md +154 -0
  69. package/roles/engineering-analysis-engineer/SOURCES.md +15 -0
  70. package/roles/engineering-analysis-engineer/cli.py +151 -0
  71. package/roles/engineering-analysis-engineer/core/engineering_analysis_engineer_core.py +866 -0
  72. package/roles/engineering-analysis-engineer/templates/analysis-memo-template.md +73 -0
  73. package/roles/engineering-analysis-engineer/tests/test_engineering_analysis_engineer_core.py +367 -0
  74. package/roles/engineering-analysis-engineer/tests/test_role_engineering_analysis_engineer.py +113 -0
  75. package/roles/flight-management-engineer/ROLE.md +160 -0
  76. package/roles/flight-management-engineer/SOURCES.md +30 -0
  77. package/roles/flight-management-engineer/cli.py +258 -0
  78. package/roles/flight-management-engineer/core/flight_management_core.py +1473 -0
  79. package/roles/flight-management-engineer/templates/route-assessment-template.md +94 -0
  80. package/roles/flight-management-engineer/tests/test_flight_management_core.py +437 -0
  81. package/roles/flight-management-engineer/tests/test_role_flight_management_engineer.py +123 -0
  82. package/roles/flight-mechanics-engineer/ROLE.md +162 -0
  83. package/roles/flight-mechanics-engineer/SOURCES.md +51 -0
  84. package/roles/flight-mechanics-engineer/cli.py +149 -0
  85. package/roles/flight-mechanics-engineer/core/flight_mechanics_core.py +1507 -0
  86. package/roles/flight-mechanics-engineer/templates/perf-sc-report-template.md +82 -0
  87. package/roles/flight-mechanics-engineer/tests/test_flight_mechanics_core.py +277 -0
  88. package/roles/flight-mechanics-engineer/tests/test_role_flight_mechanics_engineer.py +141 -0
  89. package/roles/flight-test-engineer/ROLE.md +137 -0
  90. package/roles/flight-test-engineer/SOURCES.md +24 -0
  91. package/roles/flight-test-engineer/cli.py +135 -0
  92. package/roles/flight-test-engineer/core/flight_test_core.py +931 -0
  93. package/roles/flight-test-engineer/templates/flight-test-template.md +143 -0
  94. package/roles/flight-test-engineer/tests/test_flight_test_engineer_core.py +227 -0
  95. package/roles/flight-test-engineer/tests/test_role_flight_test_engineer.py +150 -0
  96. package/roles/flight-test-performance-engineer/ROLE.md +165 -0
  97. package/roles/flight-test-performance-engineer/SOURCES.md +20 -0
  98. package/roles/flight-test-performance-engineer/cli.py +319 -0
  99. package/roles/flight-test-performance-engineer/core/flight_test_performance_core.py +1398 -0
  100. package/roles/flight-test-performance-engineer/templates/performance-report-template.md +105 -0
  101. package/roles/flight-test-performance-engineer/tests/test_flight_test_performance_engineer_core.py +308 -0
  102. package/roles/flight-test-performance-engineer/tests/test_role_flight_test_performance_engineer.py +165 -0
  103. package/roles/gnc-engineer/ROLE.md +141 -0
  104. package/roles/gnc-engineer/SOURCES.md +35 -0
  105. package/roles/gnc-engineer/cli.py +141 -0
  106. package/roles/gnc-engineer/core/gnc_core.py +1074 -0
  107. package/roles/gnc-engineer/templates/gnc-report-template.md +105 -0
  108. package/roles/gnc-engineer/tests/test_gnc_engineer_core.py +238 -0
  109. package/roles/gnc-engineer/tests/test_role_gnc_engineer.py +142 -0
  110. package/roles/guidance-engineer/ROLE.md +156 -0
  111. package/roles/guidance-engineer/SOURCES.md +43 -0
  112. package/roles/guidance-engineer/cli.py +280 -0
  113. package/roles/guidance-engineer/core/guidance_core.py +820 -0
  114. package/roles/guidance-engineer/templates/guidance-report-template.md +70 -0
  115. package/roles/guidance-engineer/tests/test_guidance_engineer_core.py +269 -0
  116. package/roles/guidance-engineer/tests/test_role_guidance_engineer.py +146 -0
  117. package/roles/high-speed-aerodynamics-engineer/ROLE.md +161 -0
  118. package/roles/high-speed-aerodynamics-engineer/SOURCES.md +18 -0
  119. package/roles/high-speed-aerodynamics-engineer/cli.py +282 -0
  120. package/roles/high-speed-aerodynamics-engineer/core/high_speed_aero_core.py +1566 -0
  121. package/roles/high-speed-aerodynamics-engineer/templates/high-speed-memo-template.md +75 -0
  122. package/roles/high-speed-aerodynamics-engineer/tests/test_bundle_protocol.py +103 -0
  123. package/roles/high-speed-aerodynamics-engineer/tests/test_high_speed_aero_core.py +280 -0
  124. package/roles/high-speed-aerodynamics-engineer/tests/test_role_high_speed_aerodynamics.py +125 -0
  125. package/roles/ndt-engineer/ROLE.md +157 -0
  126. package/roles/ndt-engineer/SOURCES.md +18 -0
  127. package/roles/ndt-engineer/cli.py +316 -0
  128. package/roles/ndt-engineer/core/ndt_core.py +1590 -0
  129. package/roles/ndt-engineer/templates/ndt-plan-template.md +109 -0
  130. package/roles/ndt-engineer/tests/test_ndt_cli_bundle.py +183 -0
  131. package/roles/ndt-engineer/tests/test_ndt_core.py +437 -0
  132. package/roles/ndt-engineer/tests/test_role_ndt_engineer.py +148 -0
  133. package/roles/numerical-analysis-engineer/ROLE.md +158 -0
  134. package/roles/numerical-analysis-engineer/SOURCES.md +19 -0
  135. package/roles/numerical-analysis-engineer/cli.py +414 -0
  136. package/roles/numerical-analysis-engineer/core/numerical_analysis_core.py +1220 -0
  137. package/roles/numerical-analysis-engineer/templates/verification-memo-template.md +120 -0
  138. package/roles/numerical-analysis-engineer/tests/test_bundle_protocol.py +157 -0
  139. package/roles/numerical-analysis-engineer/tests/test_numerical_analysis_core.py +337 -0
  140. package/roles/numerical-analysis-engineer/tests/test_role_numerical_analysis_engineer.py +117 -0
  141. package/roles/propulsion-engineer/ROLE.md +163 -0
  142. package/roles/propulsion-engineer/SOURCES.md +15 -0
  143. package/roles/propulsion-engineer/cli.py +168 -0
  144. package/roles/propulsion-engineer/core/propulsion_core.py +1291 -0
  145. package/roles/propulsion-engineer/templates/propulsion-report-template.md +92 -0
  146. package/roles/propulsion-engineer/tests/test_propulsion_engineer_core.py +294 -0
  147. package/roles/propulsion-engineer/tests/test_role_propulsion_engineer.py +106 -0
  148. package/roles/space-systems-engineer/ROLE.md +161 -0
  149. package/roles/space-systems-engineer/SOURCES.md +49 -0
  150. package/roles/space-systems-engineer/cli.py +173 -0
  151. package/roles/space-systems-engineer/core/space_systems_core.py +980 -0
  152. package/roles/space-systems-engineer/templates/space-systems-template.md +88 -0
  153. package/roles/space-systems-engineer/tests/test_role_space_systems_engineer.py +117 -0
  154. package/roles/space-systems-engineer/tests/test_space_systems_engineer_core.py +247 -0
  155. package/roles/state-estimation-engineer/ROLE.md +154 -0
  156. package/roles/state-estimation-engineer/SOURCES.md +27 -0
  157. package/roles/state-estimation-engineer/cli.py +326 -0
  158. package/roles/state-estimation-engineer/core/state_estimation_core.py +958 -0
  159. package/roles/state-estimation-engineer/templates/nav-state-estimator-design-template.md +76 -0
  160. package/roles/state-estimation-engineer/tests/test_bundle_protocol.py +110 -0
  161. package/roles/state-estimation-engineer/tests/test_role_state_estimation_engineer.py +127 -0
  162. package/roles/state-estimation-engineer/tests/test_state_estimation_engineer_core.py +231 -0
  163. package/roles/structures-loads-engineer/ROLE.md +171 -0
  164. package/roles/structures-loads-engineer/SOURCES.md +44 -0
  165. package/roles/structures-loads-engineer/cli.py +219 -0
  166. package/roles/structures-loads-engineer/core/structures_loads_core.py +1112 -0
  167. package/roles/structures-loads-engineer/templates/loads-strength-report-template.md +96 -0
  168. package/roles/structures-loads-engineer/tests/test_bundle_protocol.py +101 -0
  169. package/roles/structures-loads-engineer/tests/test_role_structures_loads_engineer.py +147 -0
  170. package/roles/structures-loads-engineer/tests/test_structures_loads_core.py +261 -0
@@ -0,0 +1,958 @@
1
+ #!/usr/bin/env python3
2
+ """state_estimation_core.py - State Estimation Engineer executable core.
3
+
4
+ This is the role's ENGINE: given a navigation state estimation item's
5
+ project facts (vehicle, sensor set, sample rates, noise-model inputs) it
6
+ computes the estimator design numbers and BUILDS the Navigation State
7
+ Estimator Design Report content:
8
+
9
+ - complementary-filter attitude channel: Mahony-style explicit
10
+ complementary filter gains (kp, ki), gyro-bias drift anchor |b|*t,
11
+ discrete-integration lag |omega|*dt, innovation tolerance
12
+ (bound leaf: gnc-autonomy/estimation-filtering/complementary-filter)
13
+ - navigation (position/velocity) channel: discrete constant-velocity
14
+ Kalman recursion with the standard discretized process-noise
15
+ covariance Q = q*[[dt^4/4, dt^3/2], [dt^3/2, dt^2]] (bound leaves:
16
+ rts-smoother forward_kalman model, interacting-multiple-model-filter
17
+ kalman recursion)
18
+ - alpha-beta design companion: Benedict-Bordner critical-damping beta
19
+ and Kalata gains from the maneuverability/tracking index
20
+ lambda = sigma_w*dt^2/sigma_v (bound leaf: alpha-beta-filter)
21
+ - nonlinear update design: EKF linearization by central finite
22
+ differences of a bearing/range measurement, innovation S, gain K,
23
+ corrected state, and the NEES consistency metric (bound leaves:
24
+ extended-kalman-filter, unscented-kalman-filter)
25
+ - fallback/verification methods: IMM two-mode bank, SIR particle
26
+ filter effective-sample-size rule ESS < n/2, fixed-interval RTS
27
+ smoothing with covariance-reduction numbers (bound leaves:
28
+ interacting-multiple-model-filter, particle-filter, rts-smoother)
29
+ - observability analysis: discrete observability matrix rank/determinant
30
+ for the measured position channel
31
+ - evidence gates check_* that verify the deliverable
32
+
33
+ Standalone: stdlib only, deterministic, no AeroSkills checkout required.
34
+ Every formula below is grounded in the equations shipped by the bound
35
+ AeroSkills leaf logic files (their *_logic.py) or in the leaf SKILL.md
36
+ documentation; see SOURCES.md. No number is invented.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import math
41
+ import re
42
+ from dataclasses import dataclass, field
43
+ from datetime import date
44
+
45
+ def _today() -> str:
46
+ import os
47
+ from datetime import date
48
+ return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Domain anchors (from the bound estimation-filtering leaves)
52
+ # ---------------------------------------------------------------------------
53
+
54
+ # Complementary-filter module defaults documented by the leaf (SKILL.md):
55
+ # proportional gain k_p in 1/s, integral gain k_i in 1/s^2.
56
+ CF_KP_DEFAULT = 2.0 # 1/s
57
+ CF_KI_DEFAULT = 0.4 # 1/s^2
58
+ # Steady-state innovation-norm tolerance (rad) used by the leaf's
59
+ # steady_state_verdict (trailing <=10 steps below tolerance => converged).
60
+ CF_INNOVATION_TOL = 1e-3 # rad
61
+
62
+ # SIR particle-filter degeneracy rule documented by the leaf: resample
63
+ # when the effective sample size drops below half the particle count.
64
+ PARTICLE_ESS_FRACTION = 0.5
65
+
66
+ # Fixed-interval smoothing semantics (leaf rts-smoother): the smoothed
67
+ # covariance must sit at or below the filtered covariance at every step
68
+ # and match it at the final (boundary) step.
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Small list-matrix helpers (stdlib; 2x2/4x4 sized use)
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def _vec(n, v):
76
+ return [float(v[i]) for i in range(n)]
77
+
78
+
79
+ def mat_mul(a, b):
80
+ bt = list(zip(*b))
81
+ return [[sum(ra[j] * cb[j] for j in range(len(ra))) for cb in bt]
82
+ for ra in a]
83
+
84
+
85
+ def mat_vec(a, v):
86
+ return [sum(a[i][j] * v[j] for j in range(len(v))) for i in range(len(a))]
87
+
88
+
89
+ def mat_transpose(a):
90
+ return [list(r) for r in zip(*a)]
91
+
92
+
93
+ def mat_add(a, b):
94
+ return [[a[i][j] + b[i][j] for j in range(len(a[0]))]
95
+ for i in range(len(a))]
96
+
97
+
98
+ def mat_sub(a, b):
99
+ return [[a[i][j] - b[i][j] for j in range(len(a[0]))]
100
+ for i in range(len(a))]
101
+
102
+
103
+ def mat_scale(a, s):
104
+ return [[a[i][j] * s for j in range(len(a[0]))] for i in range(len(a))]
105
+
106
+
107
+ def mat_identity(n):
108
+ return [[1.0 if i == j else 0.0 for j in range(n)] for i in range(n)]
109
+
110
+
111
+ def vec_sub(a, b):
112
+ return [a[i] - b[i] for i in range(len(a))]
113
+
114
+
115
+ def vec_dot(a, b):
116
+ return sum(a[i] * b[i] for i in range(len(a)))
117
+
118
+
119
+ def mat_inverse_2x2(a):
120
+ """Inverse of a 2x2 matrix (RTS-smoother leaf inv_2x2 formula)."""
121
+ det = a[0][0] * a[1][1] - a[0][1] * a[1][0]
122
+ if abs(det) < 1e-300:
123
+ raise ValueError("singular 2x2 matrix")
124
+ return [[a[1][1] / det, -a[0][1] / det],
125
+ [-a[1][0] / det, a[0][0] / det]]
126
+
127
+
128
+ def mat_inverse(m):
129
+ """Gauss-Jordan inverse (extended-kalman-filter leaf mat_inverse)."""
130
+ n = len(m)
131
+ aug = [row[:] + [1.0 if i == j else 0.0 for j in range(n)]
132
+ for i, row in enumerate(m)]
133
+ for col in range(n):
134
+ pivot = max(range(col, n), key=lambda r: abs(aug[r][col]))
135
+ if abs(aug[pivot][col]) < 1e-300:
136
+ raise ValueError("singular matrix")
137
+ aug[col], aug[pivot] = aug[pivot], aug[col]
138
+ pv = aug[col][col]
139
+ aug[col] = [v / pv for v in aug[col]]
140
+ for r in range(n):
141
+ if r == col:
142
+ continue
143
+ f = aug[r][col]
144
+ if f != 0.0:
145
+ aug[r] = [a - f * b for a, b in zip(aug[r], aug[col])]
146
+ return [row[n:] for row in aug]
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Domain rules: alpha-beta gains (alpha-beta-filter leaf formulas)
151
+ # ---------------------------------------------------------------------------
152
+
153
+ def steady_state_beta(alpha: float) -> float:
154
+ """Benedict-Bordner critical-damping beta = alpha^2/(2 - alpha).
155
+
156
+ Grounded in alpha-beta-filter leaf steady_state_gains() (valid for
157
+ alpha in (0, 2)).
158
+ """
159
+ aa = float(alpha)
160
+ if not 0.0 < aa < 2.0:
161
+ raise ValueError("smoothing factor alpha must be in (0, 2)")
162
+ return aa * aa / (2.0 - aa)
163
+
164
+
165
+ def kalata_gains(tracking_index: float) -> dict:
166
+ """Kalata critical-damping (alpha, beta) from the tracking index.
167
+
168
+ lambda = sigma_w*dt^2/sigma_v (ratio of position uncertainty
169
+ accumulated by the process noise over one step to the measurement
170
+ standard deviation). Grounded in the alpha-beta-filter leaf
171
+ gains_from_tracking_index() closed forms.
172
+ """
173
+ lam = float(tracking_index)
174
+ if lam < 0.0:
175
+ raise ValueError("tracking index must be >= 0")
176
+ if lam == 0.0:
177
+ return {"alpha": 0.0, "beta": 0.0}
178
+ s = math.sqrt(lam * lam + 8.0 * lam)
179
+ alpha = -(lam * lam + 8.0 * lam - (lam + 4.0) * s) / 8.0
180
+ beta = (lam * lam + 4.0 * lam - lam * s) / 4.0
181
+ return {"alpha": alpha, "beta": beta}
182
+
183
+
184
+ def tracking_index_from_noise(sigma_w: float, dt: float,
185
+ sigma_v: float) -> float:
186
+ """lambda = sigma_w*dt^2/sigma_v (alpha-beta leaf definition)."""
187
+ if sigma_v <= 0.0:
188
+ raise ValueError("measurement sigma must be > 0")
189
+ if dt <= 0.0:
190
+ raise ValueError("dt must be > 0")
191
+ return sigma_w * dt * dt / sigma_v
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Domain rules: discrete constant-velocity Kalman recursion
196
+ # (rts-smoother forward_kalman + IMM kalman_predict/kalman_update models)
197
+ # ---------------------------------------------------------------------------
198
+
199
+ def cv_matrices(dt: float, q: float):
200
+ """(F, Q) for the discrete constant-velocity model.
201
+
202
+ F = [[1, dt], [0, 1]] and the discretized process-noise covariance
203
+ Q = q*[[dt^4/4, dt^3/2], [dt^3/2, dt^2]] - identical to the
204
+ rts-smoother leaf _model_matrices().
205
+ """
206
+ return ([[1.0, dt], [0.0, 1.0]],
207
+ [[q * dt ** 4 / 4.0, q * dt ** 3 / 2.0],
208
+ [q * dt ** 3 / 2.0, q * dt ** 2]])
209
+
210
+
211
+ def run_cv_kalman(measurements, dt: float, q: float, r: float,
212
+ x0=None, p0=None) -> list:
213
+ """Run the linear constant-velocity Kalman recursion (per axis).
214
+
215
+ Every step: predict x_pred = F x, P_pred = F P F^T + Q; update with
216
+ innovation y = z - H x_pred (H = [1, 0]), innovation variance
217
+ S = H P_pred H^T + r, gain K = P_pred H^T / S, and the filtered
218
+ state/covariance (Joseph-form, as the rts-smoother leaf). Returns
219
+ one record per measurement with x_pred, P_pred, innovation, S, gain,
220
+ x_filt, P_filt. Deterministic: the covariance/gain recursion does
221
+ not depend on the measurement values.
222
+ """
223
+ if dt <= 0.0:
224
+ raise ValueError("dt must be > 0")
225
+ if q < 0.0:
226
+ raise ValueError("process noise intensity q must be >= 0")
227
+ if r <= 0.0:
228
+ raise ValueError("measurement variance r must be > 0")
229
+ if not measurements:
230
+ raise ValueError("measurements must be non-empty")
231
+ f, q_disc = cv_matrices(dt, q)
232
+ x = _vec(2, x0 if x0 is not None else [0.0, 0.0])
233
+ p = [[float(v) for v in row] for row in
234
+ (p0 if p0 is not None else [[100.0, 0.0], [0.0, 25.0]])]
235
+ records = []
236
+ for z in measurements:
237
+ x_pred = mat_vec(f, x)
238
+ p_pred = mat_add(mat_mul(mat_mul(f, p), mat_transpose(f)), q_disc)
239
+ innov = float(z) - x_pred[0]
240
+ s = p_pred[0][0] + r
241
+ gain = [p_pred[0][0] / s, p_pred[1][0] / s]
242
+ x_filt = [x_pred[0] + gain[0] * innov,
243
+ x_pred[1] + gain[1] * innov]
244
+ kh = [[gain[0], 0.0], [gain[1], 0.0]]
245
+ eye_kh = [[1.0 - kh[0][0], -kh[0][1]],
246
+ [-kh[1][0], 1.0 - kh[1][1]]]
247
+ p_filt = mat_add(
248
+ mat_mul(mat_mul(eye_kh, p_pred), mat_transpose(eye_kh)),
249
+ mat_scale(mat_mul([[gain[0]], [gain[1]]],
250
+ [[gain[0], gain[1]]]), r))
251
+ records.append({
252
+ "x_pred": x_pred, "P_pred": p_pred,
253
+ "innovation": innov, "S": s,
254
+ "gain": gain, "x_filt": x_filt, "P_filt": p_filt,
255
+ })
256
+ x = x_filt
257
+ p = p_filt
258
+ return records
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Domain rules: fixed-interval RTS smoothing over the forward records
263
+ # (rts-smoother leaf rts_smooth + smoother_reduction semantics)
264
+ # ---------------------------------------------------------------------------
265
+
266
+ def rts_smooth(records: list) -> dict:
267
+ """Backward Rauch-Tung-Striebel pass over CV forward records.
268
+
269
+ Smoother gain G_k = P_filt[k] F^T (P_pred[k+1])^-1; smoothed state
270
+ x_s[k] = x_filt[k] + G_k (x_s[k+1] - x_pred[k+1]); smoothed
271
+ covariance P_s[k] = P_filt[k] + G_k (P_s[k+1] - P_pred[k+1]) G_k^T.
272
+ Returns smoothed states/covariances plus the leaf's reduction
273
+ verdict: max_reduction (largest relative drop of the position
274
+ variance), all_reduced, boundary_matches.
275
+ """
276
+ n = len(records)
277
+ if n < 2:
278
+ raise ValueError("at least two forward records required")
279
+ dt = records[0].get("dt", None)
280
+ f = [[1.0, dt], [0.0, 1.0]] if dt else None
281
+ if f is None:
282
+ raise ValueError("forward records carry no dt")
283
+ f_t = mat_transpose(f)
284
+ x_s = [None] * n
285
+ p_s = [None] * n
286
+ gains = [None] * n
287
+ x_s[n - 1] = list(records[n - 1]["x_filt"])
288
+ p_s[n - 1] = [row[:] for row in records[n - 1]["P_filt"]]
289
+ gains[n - 1] = [[0.0, 0.0], [0.0, 0.0]]
290
+ for k in range(n - 2, -1, -1):
291
+ fwd, nxt = records[k], records[k + 1]
292
+ g = mat_mul(mat_mul(fwd["P_filt"], f_t),
293
+ mat_inverse_2x2(nxt["P_pred"]))
294
+ diff = mat_vec(g, vec_sub(x_s[k + 1], nxt["x_pred"]))
295
+ x_s[k] = vec_add_list(fwd["x_filt"], diff)
296
+ p_corr = mat_sub(p_s[k + 1], nxt["P_pred"])
297
+ p_s[k] = mat_add(fwd["P_filt"],
298
+ mat_mul(mat_mul(g, p_corr), mat_transpose(g)))
299
+ gains[k] = g
300
+ reductions = []
301
+ all_reduced = True
302
+ for k in range(n):
303
+ p_f = records[k]["P_filt"][0][0]
304
+ p_sk = p_s[k][0][0]
305
+ reductions.append((p_f - p_sk) / p_f)
306
+ if p_sk > p_f + 1e-12:
307
+ all_reduced = False
308
+ p_f_last = records[-1]["P_filt"]
309
+ p_s_last = p_s[-1]
310
+ boundary_matches = all(
311
+ abs(p_s_last[i][j] - p_f_last[i][j]) <= 1e-12
312
+ for i in range(2) for j in range(2))
313
+ return {
314
+ "smoothed_states": x_s,
315
+ "smoothed_covs": p_s,
316
+ "gains": gains,
317
+ "max_reduction": max(reductions) if reductions else 0.0,
318
+ "all_reduced": all_reduced,
319
+ "boundary_matches": boundary_matches,
320
+ }
321
+
322
+
323
+ def vec_add_list(a, b):
324
+ return [a[i] + b[i] for i in range(len(a))]
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # Domain rules: EKF linearization and one nonlinear update
329
+ # (extended-kalman-filter leaf: central-difference Jacobians)
330
+ # ---------------------------------------------------------------------------
331
+
332
+ def bearing_range_measurement(x):
333
+ """Nonlinear measurement [range, bearing] of a 4-state CV target.
334
+
335
+ Same model as the extended-kalman-filter leaf
336
+ bearing_range_measurement(): range = hypot(px, py), bearing =
337
+ atan2(py, px) (rad).
338
+ """
339
+ px, py = x[0], x[1]
340
+ return [math.hypot(px, py), math.atan2(py, px)]
341
+
342
+
343
+ def numeric_jacobian(f, x, eps: float = 1e-6):
344
+ """Central-finite-difference Jacobian (leaf jacobian())."""
345
+ n = len(x)
346
+ fx = f(x)
347
+ scalar_out = isinstance(fx, (int, float))
348
+ m = 1 if scalar_out else len(fx)
349
+ j = [[0.0] * n for _ in range(m)]
350
+ for col in range(n):
351
+ xp = list(x)
352
+ xm = list(x)
353
+ xp[col] += eps
354
+ xm[col] -= eps
355
+ fp = f(xp)
356
+ fm = f(xm)
357
+ if scalar_out:
358
+ j[0][col] = (fp - fm) / (2.0 * eps)
359
+ else:
360
+ for row in range(m):
361
+ j[row][col] = (fp[row] - fm[row]) / (2.0 * eps)
362
+ return j
363
+
364
+
365
+ def ekf_correct(x_pred, p_pred, z, h, r_mat, eps: float = 1e-6) -> dict:
366
+ """One EKF correction at the predicted state (leaf ekf_update)."""
367
+ h_jac = numeric_jacobian(h, x_pred, eps)
368
+ hx = h(x_pred)
369
+ z = [float(v) for v in z]
370
+ innov = vec_sub(z, hx)
371
+ s = mat_add(mat_mul(mat_mul(h_jac, p_pred), mat_transpose(h_jac)),
372
+ r_mat)
373
+ s_inv = mat_inverse(s)
374
+ k = mat_mul(mat_mul(p_pred, mat_transpose(h_jac)), s_inv)
375
+ x_new = vec_add_list(x_pred, mat_vec(k, innov))
376
+ p_new = mat_mul(mat_sub(mat_identity(len(x_pred)),
377
+ mat_mul(k, h_jac)), p_pred)
378
+ return {"x": x_new, "P": p_new, "y": innov, "S": s, "K": k, "H": h_jac}
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Domain rules: consistency metrics (UKF + particle-filter leaves)
383
+ # ---------------------------------------------------------------------------
384
+
385
+ def nees(x_est, p_est, x_true) -> float:
386
+ """Normalized estimation error squared (UKF leaf nees()).
387
+
388
+ NEES = (x_est - x_true)^T P^-1 (x_est - x_true); for a consistent
389
+ filter the expected value is the state dimension n.
390
+ """
391
+ d = vec_sub([float(v) for v in x_est], [float(v) for v in x_true])
392
+ return vec_dot(d, mat_vec(mat_inverse(p_est), d))
393
+
394
+
395
+ def effective_sample_size(weights) -> float:
396
+ """ESS = (sum w)^2 / sum(w^2) (particle-filter leaf formula)."""
397
+ if not weights:
398
+ raise ValueError("weights must be non-empty")
399
+ total = sum(float(w) for w in weights)
400
+ if total <= 0.0:
401
+ raise ValueError("total weight must be positive")
402
+ sq = sum(w * w for w in weights)
403
+ if sq <= 0.0:
404
+ raise ValueError("sum of squared weights must be positive")
405
+ return total * total / sq
406
+
407
+
408
+ def steady_state_verdict(innovation_norms, tolerance) -> bool:
409
+ """Complementary-filter steady-state acceptance verdict.
410
+
411
+ Mirrors the complementary-filter leaf steady_state_verdict(): True
412
+ when the mean of the last min(10, n) per-step innovation norms is
413
+ below the tolerance (rad). Raises ValueError on an empty sequence,
414
+ non-finite norms, or a non-positive tolerance.
415
+ """
416
+ if not innovation_norms:
417
+ raise ValueError("innovation_norms must be non-empty")
418
+ norms = [float(x) for x in innovation_norms]
419
+ for x in norms:
420
+ if not math.isfinite(x):
421
+ raise ValueError("innovation norms must be finite")
422
+ tol = float(tolerance)
423
+ if tol <= 0.0:
424
+ raise ValueError("tolerance must be > 0")
425
+ window = norms[-min(10, len(norms)):]
426
+ return sum(window) / len(window) < tol
427
+
428
+
429
+ # ---------------------------------------------------------------------------
430
+ # Domain rules: observability (discrete linear systems practice)
431
+ # ---------------------------------------------------------------------------
432
+
433
+ def observability_rank(dt: float) -> dict:
434
+ """Discrete observability of the CV pair [x, vx] with H = [1, 0].
435
+
436
+ Observability matrix rows [H; H F] = [[1, 0], [1, dt]]; the
437
+ determinant is dt, so the pair is fully observable (rank 2) for any
438
+ dt > 0: velocity is observable through the dynamics between
439
+ position samples. Returns the determinant, the rank, and the
440
+ verdict.
441
+ """
442
+ if dt <= 0.0:
443
+ raise ValueError("dt must be > 0")
444
+ det = dt
445
+ rank = 2 if abs(det) > 1e-12 else 1
446
+ return {"det": det, "rank": rank, "observable": rank == 2}
447
+
448
+
449
+ # ---------------------------------------------------------------------------
450
+ # Item facts (inputs; noise-model values are design inputs from the
451
+ # sensor datasheets, never invented rules)
452
+ # ---------------------------------------------------------------------------
453
+
454
+ @dataclass
455
+ class NavEstimatorItem:
456
+ """Project facts the role needs to build the design report."""
457
+ item_name: str
458
+ description: str = ""
459
+ estimator_class: str = "attitude (complementary filter) + loosely-coupled GNSS/INS"
460
+ vehicle: str = ""
461
+ intended_use: str = ""
462
+ dt: float = 1.0 # GNSS/nav update interval, s
463
+ horizon_steps: int = 60 # design evaluation horizon
464
+ process_noise_intensity: float = 0.01 # q, m^2/s^3 (accel PSD input)
465
+ meas_variance: float = 4.0 # r, m^2 (GNSS position variance input)
466
+ initial_pos_var: float = 100.0 # P0[0][0], m^2
467
+ initial_vel_var: float = 25.0 # P0[1][1], (m/s)^2
468
+ alpha: float = 0.5 # alpha-beta smoothing factor
469
+ gyro_bias_vec: list = field(default_factory=lambda:
470
+ [0.001, -0.0008, 0.0006]) # rad/s, per axis
471
+ gyro_rate_mag: float = 0.0229 # rad/s (example body-rate magnitude)
472
+ imu_dt: float = 0.01 # IMU/attitude update interval, s
473
+ attitude_horizon_s: float = 100.0 # drift evaluation horizon, s
474
+ cf_kp: float = CF_KP_DEFAULT
475
+ cf_ki: float = CF_KI_DEFAULT
476
+ ekf_op_point: list = field(default_factory=lambda:
477
+ [300.0, 40.0, 12.0, 0.0]) # px,py,vx,vy
478
+ ekf_range_r: float = 9.0 # m^2 (range-aid measurement variance)
479
+ ekf_range_error: float = 3.0 # m (deterministic range error input)
480
+ particle_count: int = 1000
481
+ measurement_drift: float = 0.5 # m per step (example target motion)
482
+
483
+ @property
484
+ def sigma_w(self) -> float:
485
+ return math.sqrt(self.process_noise_intensity)
486
+
487
+ @property
488
+ def sigma_v(self) -> float:
489
+ return math.sqrt(self.meas_variance)
490
+
491
+ @property
492
+ def bias_norm(self) -> float:
493
+ return math.sqrt(sum(b * b for b in self.gyro_bias_vec))
494
+
495
+ def tracking_index(self) -> float:
496
+ return tracking_index_from_noise(self.sigma_w, self.dt,
497
+ self.sigma_v)
498
+
499
+ def measurement_sequence(self):
500
+ """Deterministic ramp measurements (m) for the design run."""
501
+ return [self.measurement_drift * k for k in range(1,
502
+ self.horizon_steps + 1)]
503
+
504
+ def imu_measurement_sequence(self):
505
+ """Gyro samples: constant rate about the body z axis."""
506
+ return [[0.0, 0.0, self.gyro_rate_mag] for _ in range(
507
+ int(self.attitude_horizon_s / self.imu_dt))]
508
+
509
+
510
+ # ---------------------------------------------------------------------------
511
+ # Report builder
512
+ # ---------------------------------------------------------------------------
513
+
514
+ def build_report(item: NavEstimatorItem) -> dict:
515
+ """Build the complete Navigation State Estimator Design Report model."""
516
+ dt = item.dt
517
+ # --- navigation channel: linear CV Kalman recursion ---------------
518
+ x0 = [0.0, 0.0]
519
+ p0 = [[item.initial_pos_var, 0.0], [0.0, item.initial_vel_var]]
520
+ records = run_cv_kalman(item.measurement_sequence(), dt,
521
+ item.process_noise_intensity,
522
+ item.meas_variance, x0, p0)
523
+ kf = [{
524
+ "step": k + 1,
525
+ "gain": records[k]["gain"],
526
+ "S": records[k]["S"],
527
+ "innovation": records[k]["innovation"],
528
+ "P_filt": records[k]["P_filt"],
529
+ "x_filt": records[k]["x_filt"],
530
+ } for k in range(len(records))]
531
+
532
+ # --- RTS smoothing over the same run ------------------------------
533
+ recs_dt = [dict(r, dt=dt) for r in records]
534
+ smooth = rts_smooth(recs_dt)
535
+
536
+ # --- alpha-beta design companion ----------------------------------
537
+ beta_bb = steady_state_beta(item.alpha)
538
+ lam = item.tracking_index()
539
+ kal = kalata_gains(lam)
540
+
541
+ # --- EKF nonlinear (range/bearing) update design ------------------
542
+ op = [float(v) for v in item.ekf_op_point]
543
+ h_model = bearing_range_measurement(op) # true measurement
544
+ z = [h_model[0] + item.ekf_range_error, h_model[1]] # deterministic meas
545
+ p_op = [[item.initial_pos_var, 0.0, 0.0, 0.0],
546
+ [0.0, item.initial_pos_var, 0.0, 0.0],
547
+ [0.0, 0.0, item.initial_vel_var, 0.0],
548
+ [0.0, 0.0, 0.0, item.initial_vel_var]]
549
+ ekf = ekf_correct(op, p_op, z, bearing_range_measurement,
550
+ [[item.ekf_range_r, 0.0], [0.0, (math.pi / 180.0) ** 2]])
551
+ ekf_nees = nees(ekf["x"][:2], [[ekf["P"][0][0], ekf["P"][0][1]],
552
+ [ekf["P"][1][0], ekf["P"][1][1]]],
553
+ op[:2])
554
+
555
+ # --- attitude channel (complementary filter design numbers) -------
556
+ bias_norm = item.bias_norm
557
+ drift_100 = bias_norm * item.attitude_horizon_s
558
+ drift_10 = bias_norm * 10.0
559
+ lag = item.gyro_rate_mag * item.imu_dt
560
+
561
+ # --- consistency metrics (SIR rule + NEES) ------------------------
562
+ ess = effective_sample_size([1.0 / item.particle_count]
563
+ * item.particle_count)
564
+
565
+ # --- observability -------------------------------------------------
566
+ obs = observability_rank(dt)
567
+
568
+ return {
569
+ "document_type": "Navigation State Estimator Design Report",
570
+ "status": "draft-for-review",
571
+ "item": item.item_name,
572
+ "item_description": item.description,
573
+ "estimator_class": item.estimator_class,
574
+ "vehicle": item.vehicle,
575
+ "intended_use": item.intended_use,
576
+ "design_inputs": {
577
+ "dt_s": dt,
578
+ "horizon_steps": item.horizon_steps,
579
+ "process_noise_intensity": item.process_noise_intensity,
580
+ "meas_variance": item.meas_variance,
581
+ "sigma_w_m_per_s2": round(item.sigma_w, 6),
582
+ "sigma_v_m": round(item.sigma_v, 6),
583
+ "initial_pos_var": item.initial_pos_var,
584
+ "initial_vel_var": item.initial_vel_var,
585
+ "imu_dt_s": item.imu_dt,
586
+ "attitude_horizon_s": item.attitude_horizon_s,
587
+ "particle_count": item.particle_count,
588
+ },
589
+ "attitude": {
590
+ "filter": "Mahony-style explicit complementary filter (SO(3))",
591
+ "kp_1_per_s": item.cf_kp,
592
+ "ki_1_per_s2": item.cf_ki,
593
+ "innovation_tolerance_rad": CF_INNOVATION_TOL,
594
+ "gyro_bias_vec": item.gyro_bias_vec,
595
+ "bias_norm": round(bias_norm, 6),
596
+ "drift_10s_rad": round(drift_10, 6),
597
+ "drift_100s_rad": round(drift_100, 6),
598
+ "drift_100s_deg": round(math.degrees(drift_100), 3),
599
+ "integration_lag_rad_per_step": round(lag, 9),
600
+ "integration_lag_deg_per_step": round(math.degrees(lag), 6),
601
+ "bias_observability_note": (
602
+ "gyro bias is observable when at least two non-parallel "
603
+ "reference vectors (accelerometer + magnetometer) are "
604
+ "fused; a single reference leaves the bias along its "
605
+ "axis unobservable (Mahony et al. 2008 nonlinear "
606
+ "complementary filter on SO(3))"),
607
+ },
608
+ "alpha_beta": {
609
+ "alpha": item.alpha,
610
+ "beta_benedict_bordner": round(beta_bb, 6),
611
+ "tracking_index_lambda": round(lam, 6),
612
+ "kalata_alpha": round(kal["alpha"], 6),
613
+ "kalata_beta": round(kal["beta"], 6),
614
+ },
615
+ "kalman": {
616
+ "model": "discrete constant-velocity, H = [1 0], per axis",
617
+ "F": [[1.0, dt], [0.0, 1.0]],
618
+ "Q_discrete": [[round(item.process_noise_intensity * dt ** 4 / 4.0, 9),
619
+ round(item.process_noise_intensity * dt ** 3 / 2.0, 9)],
620
+ [round(item.process_noise_intensity * dt ** 3 / 2.0, 9),
621
+ round(item.process_noise_intensity * dt ** 2, 9)]],
622
+ "first": {
623
+ "gain": [round(kf[0]["gain"][0], 9), round(kf[0]["gain"][1], 9)],
624
+ "S": round(kf[0]["S"], 9),
625
+ "pos_var_after": round(kf[0]["P_filt"][0][0], 9),
626
+ },
627
+ "mid": {
628
+ "gain": [round(kf[len(kf) // 2]["gain"][0], 9),
629
+ round(kf[len(kf) // 2]["gain"][1], 9)],
630
+ "S": round(kf[len(kf) // 2]["S"], 9),
631
+ "pos_var_after": round(kf[len(kf) // 2]["P_filt"][0][0], 9),
632
+ },
633
+ "final": {
634
+ "step": len(kf),
635
+ "gain": [round(kf[-1]["gain"][0], 9), round(kf[-1]["gain"][1], 9)],
636
+ "S": round(kf[-1]["S"], 9),
637
+ "pos_var_after": round(kf[-1]["P_filt"][0][0], 9),
638
+ "vel_var_after": round(kf[-1]["P_filt"][1][1], 9),
639
+ },
640
+ "residual_rms": round(math.sqrt(
641
+ sum(r["innovation"] ** 2 for r in records) / len(records)), 6),
642
+ },
643
+ "ekf": {
644
+ "measurement_model": "range + bearing (nonlinear)",
645
+ "operating_point": [round(v, 4) for v in op],
646
+ "true_measurement": [round(h_model[0], 4), round(h_model[1], 6)],
647
+ "measurement_z": [round(z[0], 4), round(z[1], 6)],
648
+ "H": [[round(v, 9) for v in row] for row in ekf["H"]],
649
+ "innovation": [round(v, 6) for v in ekf["y"]],
650
+ "S_diag": [round(ekf["S"][0][0], 9), round(ekf["S"][1][1], 9)],
651
+ "gain_row0": [round(v, 9) for v in ekf["K"][0]],
652
+ "x_corrected": [round(v, 6) for v in ekf["x"]],
653
+ "nees_2d": round(ekf_nees, 4),
654
+ "nees_dimension": 2,
655
+ },
656
+ "verification_metrics": {
657
+ "nees_expected_dim": 2,
658
+ "nees_2d": round(ekf_nees, 4),
659
+ "ess_uniform": round(ess, 4),
660
+ "ess_rule": "resample when ESS < n/2 (SIR, particle-filter leaf)",
661
+ "ess_threshold": item.particle_count * PARTICLE_ESS_FRACTION,
662
+ },
663
+ "smoothing": {
664
+ "method": "fixed-interval RTS smoother over stored forward run",
665
+ "max_reduction": round(smooth["max_reduction"], 9),
666
+ "max_reduction_pct": round(100.0 * smooth["max_reduction"], 3),
667
+ "all_reduced": smooth["all_reduced"],
668
+ "boundary_matches": smooth["boundary_matches"],
669
+ "smoothed_final_pos_var": round(
670
+ smooth["smoothed_covs"][-1][0][0], 9),
671
+ },
672
+ "observability": {
673
+ "matrix_rows": [[1.0, 0.0], [1.0, dt]],
674
+ "det": round(obs["det"], 9),
675
+ "rank": obs["rank"],
676
+ "observable": obs["observable"],
677
+ },
678
+ "fallback_methods": {
679
+ "imm": "two-mode CV/CA IMM bank with Markov mode mixing for "
680
+ "maneuvering flight",
681
+ "particle": "SIR bootstrap filter (non-Gaussian fallback), "
682
+ "systematic resampling when ESS < n/2",
683
+ "ukf": "scaled-unscented-transform alternative to EKF "
684
+ "linearization for strongly nonlinear updates",
685
+ "rts": "fixed-interval smoothing for offline trajectory "
686
+ "reconstruction",
687
+ },
688
+ "generated": _today(),
689
+ }
690
+
691
+
692
+ def render_report_markdown(model: dict) -> str:
693
+ """Render the report content model as the deliverable markdown."""
694
+ di = model["design_inputs"]
695
+ at = model["attitude"]
696
+ ab = model["alpha_beta"]
697
+ km = model["kalman"]
698
+ ek = model["ekf"]
699
+ vm = model["verification_metrics"]
700
+ sm = model["smoothing"]
701
+ ob = model["observability"]
702
+ lines = [
703
+ "# Navigation State Estimator Design Report",
704
+ "",
705
+ f"**Item:** {model['item']}",
706
+ f"**Estimator class:** {model['estimator_class']}",
707
+ f"**Vehicle / platform:** {model['vehicle']}",
708
+ f"**Intended use:** {model['intended_use']}",
709
+ f"**Status:** {model['status']}",
710
+ "",
711
+ f"## 1. Scope",
712
+ "",
713
+ f"This report produces the navigation state estimator design for "
714
+ f"{model['item']}." +
715
+ (f" {model['item_description']}" if model["item_description"] else "") +
716
+ " The estimator architecture is " + model["estimator_class"] +
717
+ ": an attitude channel (complementary filter) plus a "
718
+ "loosely-coupled GNSS position/velocity channel (linear "
719
+ "constant-velocity Kalman recursion with an EKF nonlinear-update "
720
+ "option). Outputs are DRAFT design numbers for a human navigation "
721
+ "engineer to review before implementation on the target computer.",
722
+ "",
723
+ "## 2. Design inputs (noise model)",
724
+ "",
725
+ f"- Navigation update interval dt = {di['dt_s']:g} s over a "
726
+ f"{di['horizon_steps']}-step design horizon.",
727
+ f"- Process noise intensity q = {di['process_noise_intensity']:g} "
728
+ f"m^2/s^3 -> sigma_w = {di['sigma_w_m_per_s2']} m/s^2.",
729
+ f"- Measurement variance r = {di['meas_variance']:g} m^2 (GNSS "
730
+ f"position, sigma_v = {di['sigma_v_m']} m).",
731
+ f"- Initial covariance P0 = diag({di['initial_pos_var']:g} m^2, "
732
+ f"{di['initial_vel_var']:g} (m/s)^2).",
733
+ f"- IMU/attitude rate dt = {di['imu_dt_s']:g} s; attitude design "
734
+ f"horizon {di['attitude_horizon_s']:g} s.",
735
+ f"- Particle fallback count n = {di['particle_count']:g}.",
736
+ "",
737
+ "## 3. Attitude channel (complementary filter)",
738
+ "",
739
+ f"- Filter: {at['filter']}.",
740
+ f"- Gains: k_p = {at['kp_1_per_s']:g} 1/s, k_i = "
741
+ f"{at['ki_1_per_s2']:g} 1/s^2 (leaf module defaults); corrected "
742
+ "rate omega_c = omega_m - b + k_p*e with bias step "
743
+ "b <- b - k_i*e*dt.",
744
+ f"- Gyro bias input b = {at['gyro_bias_vec']} rad/s per axis, "
745
+ f"|b| = {at['bias_norm']} rad/s.",
746
+ f"- Drift anchor: uncompensated bias integrates to |b|*t = "
747
+ f"{at['drift_10s_rad']} rad after 10 s and "
748
+ f"{at['drift_100s_rad']} rad ({at['drift_100s_deg']} deg) after "
749
+ f"{di['attitude_horizon_s']:g} s - the error the bias estimate "
750
+ "removes.",
751
+ f"- Discrete-integration lag bound |omega|*dt = "
752
+ f"{at['integration_lag_rad_per_step']} rad/step "
753
+ f"({at['integration_lag_deg_per_step']} deg/step) at the example "
754
+ "body rate.",
755
+ f"- Steady-state acceptance: trailing-window innovation norm "
756
+ f"below {at['innovation_tolerance_rad']:g} rad "
757
+ "(leaf steady_state_verdict).",
758
+ f"- Observability note: {at['bias_observability_note']}.",
759
+ "",
760
+ "## 4. Navigation channel (Kalman recursion)",
761
+ "",
762
+ f"- Model: {km['model']}.",
763
+ f"- Transition F = {km['F'][0]} / {km['F'][1]}; discretized "
764
+ f"process covariance Q = {km['Q_discrete'][0]} / "
765
+ f"{km['Q_discrete'][1]}.",
766
+ f"- Alpha-beta design companion (fixed-gain alternative for the "
767
+ f"smoothing stage): alpha = {ab['alpha']:g}, "
768
+ f"Benedict-Bordner critical-damping beta = alpha^2/(2-alpha) = "
769
+ f"{ab['beta_benedict_bordner']}; tracking index "
770
+ f"lambda = sigma_w*dt^2/sigma_v = {ab['tracking_index_lambda']} "
771
+ f"gives Kalata gains alpha = {ab['kalata_alpha']}, "
772
+ f"beta = {ab['kalata_beta']}.",
773
+ f"- First update: gain K = {km['first']['gain']}, innovation "
774
+ f"variance S = {km['first']['S']}, position variance after "
775
+ f"{km['first']['pos_var_after']} m^2.",
776
+ f"- Mid-horizon (step {di['horizon_steps'] // 2}): "
777
+ f"gain K = {km['mid']['gain']}, S = {km['mid']['S']}, position "
778
+ f"variance {km['mid']['pos_var_after']} m^2.",
779
+ f"- Final (step {km['final']['step']}): gain K = "
780
+ f"{km['final']['gain']}, S = {km['final']['S']}, position "
781
+ f"variance {km['final']['pos_var_after']} m^2, velocity variance "
782
+ f"variance {km['final']['vel_var_after']} (m/s)^2 - the steady-state "
783
+ "covariance the design reports. The converged gain equals the "
784
+ "Kalata alpha-beta pair above within the recursion tolerance, "
785
+ "confirming the fixed-gain design is a faithful steady-state "
786
+ "approximation of the full recursion.",
787
+ f"- Residual RMS over the deterministic design run: "
788
+ f"{km['residual_rms']} m.",
789
+ "",
790
+ "## 5. Nonlinear update design (EKF linearization)",
791
+ "",
792
+ f"- Measurement model: {ek['measurement_model']} "
793
+ f"h(x) = [hypot(px,py), atan2(py,px)].",
794
+ f"- Operating point x = {ek['operating_point']} "
795
+ f"(true measurement {ek['true_measurement']}).",
796
+ f"- Linearized measurement Jacobian (central differences) "
797
+ f"H = {ek['H'][0]} / {ek['H'][1]}.",
798
+ f"- Deterministic measurement z = {ek['measurement_z']} "
799
+ "(range biased +3 m input).",
800
+ f"- Innovation y = {ek['innovation']}; innovation covariance "
801
+ f"diag(S) = {ek['S_diag']}.",
802
+ f"- Gain first row K = {ek['gain_row0']}.",
803
+ f"- Corrected state x+ = {ek['x_corrected']}.",
804
+ "",
805
+ "## 6. Consistency and fallback verification",
806
+ "",
807
+ f"- NEES = {ek['nees_2d']} against the {ek['nees_dimension']}-state "
808
+ "position subspace (expected value n = "
809
+ f"{vm['nees_expected_dim']} for a consistent filter).",
810
+ f"- SIR particle fallback: uniform ensemble of "
811
+ f"{di['particle_count']:g} particles gives ESS = "
812
+ f"{vm['ess_uniform']:g}; {vm['ess_rule']} (threshold "
813
+ f"{vm['ess_threshold']:g}).",
814
+ f"- IMM two-mode bank ({model['fallback_methods']['imm']}).",
815
+ f"- UKF option ({model['fallback_methods']['ukf']}).",
816
+ "",
817
+ "## 7. Offline post-processing (RTS smoothing)",
818
+ "",
819
+ f"- Method: {sm['method']}.",
820
+ f"- Smoothed position variance at the final boundary equals the "
821
+ f"filtered value ({sm['boundary_matches']}); every interior "
822
+ f"smoothed variance at or below the filtered "
823
+ f"({sm['all_reduced']}).",
824
+ f"- Maximum covariance reduction: {sm['max_reduction_pct']}% "
825
+ f"(smoothed final position variance "
826
+ f"{sm['smoothed_final_pos_var']} m^2).",
827
+ "",
828
+ "## 8. Observability analysis",
829
+ "",
830
+ f"- Discrete observability matrix rows {ob['matrix_rows'][0]} / "
831
+ f"{ob['matrix_rows'][1]} with determinant {ob['det']}.",
832
+ f"- Verdict: rank {ob['rank']}, "
833
+ f"{'fully observable' if ob['observable'] else 'NOT fully observable'} "
834
+ "for dt > 0: the velocity state is observable through the "
835
+ "dynamics between position samples.",
836
+ "",
837
+ "## 9. Verification evidence and open items",
838
+ "",
839
+ "- Evidence gates (see cli.py check): architecture stated, "
840
+ "attitude + navigation channels designated, alpha-beta and "
841
+ "Kalman/EKF numbers present, observability checked, consistency "
842
+ "metrics present.",
843
+ "- Open items for the human navigation engineer: final sensor "
844
+ "datasheet noise values, lever-arm and time-tagging alignment, "
845
+ "flight-computer numerical format, and the in-flight "
846
+ "consistency monitoring policy.",
847
+ "",
848
+ "---",
849
+ f"*Generated by Aero Agent Roles state-estimation-engineer core "
850
+ f"({model['generated']}). DRAFT for human navigation engineer "
851
+ "review. Not an approval document. Not flight software release.*",
852
+ ]
853
+ return "\n".join(lines)
854
+
855
+
856
+ def example_item() -> NavEstimatorItem:
857
+ return NavEstimatorItem(
858
+ item_name="UAV navigation state estimator (INS/GNSS + AHRS)",
859
+ description="Loosely-coupled GNSS/INS navigation filter with a "
860
+ "complementary-filter attitude channel for a small "
861
+ "fixed-wing UAV autopilot.",
862
+ estimator_class="attitude (complementary filter) + loosely-coupled GNSS/INS",
863
+ vehicle="small fixed-wing UAV (autopilot reference design)",
864
+ intended_use="design analysis for the navigation state estimator "
865
+ "before implementation on the flight computer",
866
+ )
867
+
868
+
869
+ def example_report_markdown() -> str:
870
+ return render_report_markdown(build_report(example_item()))
871
+
872
+
873
+ # ---------------------------------------------------------------------------
874
+ # Evidence gate checkers
875
+ # ---------------------------------------------------------------------------
876
+
877
+ GATE_CHECKS = {
878
+ "architecture_stated": "estimator class / architecture is named",
879
+ "channels_designated": "attitude and navigation filter designs are present",
880
+ "gains_computed": "alpha-beta and complementary-filter gains are numbers",
881
+ "kalman_numbers_present": "Kalman gain + covariance at the final step",
882
+ "linearization_present": "EKF Jacobian H is a numeric matrix",
883
+ "observability_checked": "observability rank/determinant computed",
884
+ "consistency_metrics_present": "NEES + ESS figures computed",
885
+ "sign_off_honest": "report is marked draft-for-review, not approval",
886
+ }
887
+
888
+
889
+ def check_report(model: dict) -> dict:
890
+ """Run the evidence gates against a report content model."""
891
+ ab = model.get("alpha_beta", {})
892
+ km = model.get("kalman", {})
893
+ ek = model.get("ekf", {})
894
+ ob = model.get("observability", {})
895
+ results = {
896
+ "architecture_stated": bool(model.get("estimator_class")),
897
+ "channels_designated": bool(model.get("attitude"))
898
+ and bool(model.get("kalman")),
899
+ "gains_computed": isinstance(ab.get("kalata_beta"), (int, float))
900
+ and isinstance(ab.get("beta_benedict_bordner"), (int, float)),
901
+ "kalman_numbers_present": isinstance(
902
+ km.get("final", {}).get("gain"), list)
903
+ and isinstance(km.get("final", {}).get("pos_var_after"),
904
+ (int, float)),
905
+ "linearization_present": isinstance(ek.get("H"), list)
906
+ and bool(ek.get("H")),
907
+ "observability_checked": ob.get("observable") is not None,
908
+ "consistency_metrics_present": isinstance(
909
+ model.get("verification_metrics", {}).get("nees_2d"),
910
+ (int, float)),
911
+ "sign_off_honest": model.get("status") == "draft-for-review",
912
+ }
913
+ results["all_pass"] = all(results.values())
914
+ return results
915
+
916
+
917
+ def check_report_markdown(md_text: str) -> dict:
918
+ """Gate-check the rendered markdown deliverable."""
919
+ low = md_text.lower()
920
+ checks = {
921
+ "has_title": "navigation state estimator design report" in low,
922
+ "has_architecture": "estimator class" in low,
923
+ "has_attitude_channel": "complementary filter" in low
924
+ and "bias" in low,
925
+ "has_nav_channel": "kalman" in low and "innovation" in low,
926
+ "has_alpha_beta": "kalata" in low and "benedict" in low,
927
+ "has_numbers": "gain" in low and "variance" in low,
928
+ "has_observability": "observability" in low and "rank" in low,
929
+ "has_draft_marker": "draft" in low,
930
+ "has_not_approval": "not an approval" in low,
931
+ }
932
+ checks["all_pass"] = all(checks.values())
933
+ return checks
934
+
935
+
936
+ def validate_deliverable_text(md_text: str) -> dict:
937
+ """Public entry point used by gate tooling: check a report document."""
938
+ return check_report_markdown(md_text)
939
+
940
+
941
+ if __name__ == "__main__":
942
+ model = build_report(example_item())
943
+ md = render_report_markdown(model)
944
+ print("ESTIMATOR CLASS:", model["estimator_class"])
945
+ print("KALATA GAINS (lambda=%.4f): alpha=%.6f beta=%.6f"
946
+ % (model["alpha_beta"]["tracking_index_lambda"],
947
+ model["alpha_beta"]["kalata_alpha"],
948
+ model["alpha_beta"]["kalata_beta"]))
949
+ print("KF FINAL: gain=%s pos_var=%.6f"
950
+ % (model["kalman"]["final"]["gain"],
951
+ model["kalman"]["final"]["pos_var_after"]))
952
+ print("EKF NEES: %.4f OBSERVABLE: %s"
953
+ % (model["ekf"]["nees_2d"],
954
+ model["observability"]["observable"]))
955
+ print("SMOOTH MAX REDUCTION: %.4f%%"
956
+ % model["smoothing"]["max_reduction_pct"])
957
+ print("GATES:", check_report(model))
958
+ print("RENDERED: %d chars" % len(md))