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,1220 @@
1
+ #!/usr/bin/env python3
2
+ """numerical_analysis_core.py - Numerical Analysis Engineer executable core.
3
+
4
+ This is the role's ENGINE: given an engineering code's numerical-methods
5
+ claims (reported integral, reported root, reported linear-solve result)
6
+ it selects reference methods, computes independent reference values,
7
+ quantifies discrepancies against the code's claimed tolerances, runs a
8
+ three-mesh convergence study (observed order, Richardson extrapolation,
9
+ grid convergence index), assesses linear-solve conditioning, and BUILDS
10
+ the Numerical Methods Verification Memo. It also gate-checks
11
+ deliverables. Standalone: stdlib only, no external repo needed.
12
+
13
+ Every formula mirrors the REAL logic in the bound AeroSkills leaves
14
+ (cross-cutting/numerics/*, paraphrased classical numerical-analysis
15
+ methodology):
16
+ - numerical-integration: composite trapezoid (O(h^2)), composite
17
+ Simpson (O(h^4), exact for degree <= 3), Richardson error estimate
18
+ error(2n) = |I_2n - I_n| / 3 (leaf-identical)
19
+ - root-finding: bisection (linear, bracket must straddle zero),
20
+ Newton-Raphson (quadratic for simple roots, derivative guard),
21
+ area-Mach relation A/A* = (1/M)((2/(g+1))(1+(g-1)/2 M^2))^((g+1)/(2(g-1)))
22
+ - convergence-verification: observed order p = ln((f3-f2)/(f2-f1))/ln(r),
23
+ Richardson extrapolation f_exact = f1 + (f1-f2)/(r^p-1),
24
+ GCI = Fs*|(f1-f2)/f1|/(r^p-1) with Fs = 1.25 for three-grid studies
25
+ - matrix-operations: dense solve by Gaussian elimination with partial
26
+ pivoting
27
+ - singular-value-decomposition: 2-norm condition number kappa = s_max/s_min
28
+ The classical public-domain anchors (Richardson 1910/1927, Euler-Maclaurin
29
+ error analysis, Newton/Simpson/Gauss quadrature) are summarised in
30
+ SOURCES.md; nothing proprietary is reproduced.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import math
35
+ import re
36
+ from dataclasses import dataclass, field
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Domain constants (classical numerical analysis / public practice)
40
+ # ---------------------------------------------------------------------------
41
+
42
+ EPS_MACH = 2.220446049250313e-16 # double-precision unit roundoff
43
+ ONE_OVER_EPS = 1.0 / EPS_MACH # ~4.5e15: kappa >= 1/eps leaves no digits
44
+ GCI_FS = 1.25 # three-grid safety factor (two-grid: 2.0)
45
+ GAMMA_AIR = 1.4 # specific-heat ratio used in the anchor
46
+ DEFAULT_RICH_TOL = 1e-10 # function tolerance for reference iterations
47
+
48
+ # Composite-rule error orders (classical Euler-Maclaurin theory): the
49
+ # trapezoid rule is O(h^2) and exact for linears; Simpson is O(h^4) and
50
+ # exact for polynomials of degree <= 3.
51
+ RULE_ORDER = {"trapezoid": 2, "simpson": 4, "gauss-legendre": None}
52
+
53
+ # Method-selection guidance per integrand class (mirrors the
54
+ # numerical-integration leaf: arbitrary/noisy -> trapezoid, smooth with
55
+ # moderate n -> Simpson, smooth and cheap with high degree of exactness
56
+ # wanted -> Gauss-Legendre n in {2..5}, exact to degree 2n-1).
57
+ RULE_SELECTION = {
58
+ "arbitrary-or-noisy": {
59
+ "rule": "composite trapezoid",
60
+ "order": 2,
61
+ "rationale": "cheap, robust, error scales with h^2; the right "
62
+ "default for an arbitrary or noisy integrand.",
63
+ },
64
+ "smooth": {
65
+ "rule": "composite Simpson",
66
+ "order": 4,
67
+ "rationale": "error scales with h^4 and the rule is exact for "
68
+ "polynomials of degree <= 3, beating trapezoid on "
69
+ "smooth integrands at moderate panel counts.",
70
+ },
71
+ "smooth-high-degree": {
72
+ "rule": "Gauss-Legendre quadrature",
73
+ "order": None,
74
+ "rationale": "n-point rule exact for polynomials of degree <= "
75
+ "2n-1 (n in {2,3,4,5}); one call resolves a smooth "
76
+ "integrand a composite rule would need many panels "
77
+ "for.",
78
+ },
79
+ "tabulated": {
80
+ "rule": "composite trapezoid on tabulated data",
81
+ "order": 2,
82
+ "rationale": "no rule higher than the data's implied smoothness "
83
+ "is justified for tabulated samples; trapezoid is "
84
+ "the conservative choice.",
85
+ },
86
+ }
87
+
88
+ # Classical conditioning language (public error-analysis practice,
89
+ # e.g. Golub & Van Loan / Trefethen & Bau summaries): forward error is
90
+ # bounded by kappa times the backward error, and each factor of 10 in
91
+ # kappa can cost one decimal digit.
92
+ def _cond_text(kappa: float) -> str:
93
+ if kappa >= ONE_OVER_EPS:
94
+ return ("kappa >= 1/eps: the matrix is singular to working "
95
+ "precision - no reliable digits remain")
96
+ return ("kappa = %.3g: up to %.2f decimal digits of input "
97
+ "uncertainty can be amplified by the solve"
98
+ % (kappa, math.log10(kappa)))
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Dataclasses: item under verification
103
+ # ---------------------------------------------------------------------------
104
+
105
+ @dataclass
106
+ class VerificationItem:
107
+ """Project facts: an engineering code's numerical-methods claims."""
108
+ code_name: str
109
+ code_version: str
110
+ description: str = ""
111
+ # --- integration operation facts ---
112
+ integrand_label: str = ""
113
+ interval: list = field(default_factory=lambda: [0.0, 18.0]) # [a, b]
114
+ integrand_w0: float = 4200.0 # N/m peak of the spanwise load
115
+ integrand_span: float = 36.0 # m reference span
116
+ code_method_label: str = "composite trapezoid, n=16"
117
+ code_integral: float = 0.0 # N, value reported by the code
118
+ code_integral_tol: float = 1.0 # N, absolute accuracy the code claims
119
+ code_integral_n: int = 16 # panels the code used
120
+ # --- root-finding operation facts ---
121
+ area_ratio: float = 1.2 # A/A* of the isentropic relation
122
+ gamma: float = GAMMA_AIR
123
+ bracket: list = field(default_factory=lambda: [0.2, 0.99]) # [a, b]
124
+ newton_guess: float = 0.3
125
+ code_mach: float = 0.0 # subsonic Mach reported by the code
126
+ code_mach_tol: float = 1e-8 # |residual| tolerance the code claims
127
+ # --- linear-solve operation facts ---
128
+ system_A: list = field(default_factory=lambda: [[1.0, 0.75],
129
+ [0.75, 1.0]])
130
+ system_b: list = field(default_factory=lambda: [0.5, 0.5])
131
+ code_solution: list = field(default_factory=lambda: [0.0, 0.0])
132
+ code_solve_tol: float = 1e-6 # max component error the code claims
133
+ # --- mesh study facts (code outputs on three meshes, r = 2) ---
134
+ mesh_n: list = field(default_factory=lambda: [64, 32, 16]) # f1..f3
135
+ # --- context ---
136
+ verification_basis: str = (
137
+ "classical numerical analysis (Richardson 1910/1927; "
138
+ "Euler-Maclaurin error analysis; Newton/Simpson/Gauss); "
139
+ "ASME V&V 20-2009 vocabulary for discretization error")
140
+ export_classification: str = "uncontrolled technical data (example)"
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Reference integration engine (mirrors numerical-integration leaf logic)
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def lift_intensity(y: float, w0: float = 4200.0, span: float = 36.0) -> float:
148
+ """Spanwise load intensity w(y) = w0*(1 - (2y/span)^2) [N/m].
149
+
150
+ Smooth quadratic over the half-span [0, span/2], zero at the tip.
151
+ Closed form: integral over [0, span/2] is w0*span/3 exactly, which
152
+ anchors every quadrature check in this role.
153
+ """
154
+ t = 2.0 * y / span
155
+ return w0 * (1.0 - t * t)
156
+
157
+
158
+ def trapezoid(f, a: float, b: float, n: int) -> float:
159
+ """Composite trapezoid rule over n subintervals of [a, b].
160
+
161
+ I = (b-a)/(2n) * (f(a) + f(b) + 2*sum_{i=1}^{n-1} f(a + i h)).
162
+ Error scales with h^2. Raises ValueError when n < 1.
163
+ """
164
+ if not isinstance(n, int) or n < 1:
165
+ raise ValueError("n must be a positive integer")
166
+ h = (b - a) / n
167
+ total = f(a) + f(b)
168
+ for i in range(1, n):
169
+ total += 2.0 * f(a + i * h)
170
+ return h * total / 2.0
171
+
172
+
173
+ def simpson(f, a: float, b: float, n: int) -> float:
174
+ """Composite Simpson rule over n subintervals of [a, b], n even.
175
+
176
+ I = (b-a)/(3n) * (f(a) + f(b) + 4*sum_odd + 2*sum_even). Error
177
+ scales with h^4; exact for polynomials of degree <= 3. Raises
178
+ ValueError when n is odd or n < 2.
179
+ """
180
+ if not isinstance(n, int) or n < 2 or n % 2 != 0:
181
+ raise ValueError("n must be an even integer >= 2")
182
+ h = (b - a) / n
183
+ total = f(a) + f(b)
184
+ for i in range(1, n):
185
+ total += (4.0 if i % 2 == 1 else 2.0) * f(a + i * h)
186
+ return h * total / 3.0
187
+
188
+
189
+ def richardson_error_trapezoid(f, a: float, b: float, n: int) -> float:
190
+ """Richardson error estimate for the composite trapezoid rule.
191
+
192
+ The trapezoid error scales with h^2, so combining the n and 2n
193
+ estimates gives, to leading order, error(2n) = |I_2n - I_n| / 3.
194
+ Raises ValueError when n < 1.
195
+ """
196
+ if not isinstance(n, int) or n < 1:
197
+ raise ValueError("n must be a positive integer")
198
+ i_n = trapezoid(f, a, b, n)
199
+ i_2n = trapezoid(f, a, b, 2 * n)
200
+ return abs(i_2n - i_n) / 3.0
201
+
202
+
203
+ def richardson_error_n(f_n: float, f_2n: float, order: int = 2) -> float:
204
+ """Leading-order error of the n-panel estimate from two runs.
205
+
206
+ For an order-p rule, E_n = |f_n - f_2n| / (1 - r^-p) with r = 2:
207
+ the trapezoid (p=2) case gives E_n = (4/3)|f_n - f_2n|.
208
+ """
209
+ return abs(f_n - f_2n) / (1.0 - 2.0 ** (-order))
210
+
211
+
212
+ def select_integration_rule(integrand_class: str) -> dict:
213
+ """Select the reference quadrature rule for an integrand class.
214
+
215
+ integrand_class in RULE_SELECTION. Returns the rule choice with its
216
+ order and rationale (real selection practice from the
217
+ numerical-integration leaf). Raises ValueError on an unknown class.
218
+ """
219
+ if integrand_class not in RULE_SELECTION:
220
+ raise ValueError("unknown integrand class %r" % (integrand_class,))
221
+ return dict(RULE_SELECTION[integrand_class])
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Reference root-finding engine (mirrors root-finding leaf logic)
226
+ # ---------------------------------------------------------------------------
227
+
228
+ def area_mach_ratio(M: float, gamma: float = GAMMA_AIR) -> float:
229
+ """Isentropic area-Mach relation A/A* as a function of M.
230
+
231
+ A/A* = (1/M) * ((2/(gamma+1)) * (1 + (gamma-1)/2 * M^2))
232
+ ^((gamma+1)/(2*(gamma-1)))
233
+ """
234
+ term = 1.0 + (gamma - 1.0) / 2.0 * M * M
235
+ return ((2.0 / (gamma + 1.0)) * term) ** ((gamma + 1.0) /
236
+ (2.0 * (gamma - 1.0))) / M
237
+
238
+
239
+ def area_mach_residual(M: float, area_ratio: float,
240
+ gamma: float = GAMMA_AIR) -> float:
241
+ """Residual f(M) = A/A*(M) - area_ratio of the inversion problem."""
242
+ return area_mach_ratio(M, gamma) - area_ratio
243
+
244
+
245
+ def area_mach_derivative(M: float, gamma: float = GAMMA_AIR) -> float:
246
+ """Analytic derivative d/dM of the area-Mach residual.
247
+
248
+ d/dM ln(A/A*) = -1/M + (gamma+1)*M / (2*(1 + (gamma-1)/2*M^2)),
249
+ so f'(M) = (A/A*) * that sum.
250
+ """
251
+ term = 1.0 + (gamma - 1.0) / 2.0 * M * M
252
+ g = area_mach_ratio(M, gamma)
253
+ return g * (-1.0 / M + (gamma + 1.0) * M / (2.0 * term))
254
+
255
+
256
+ def bisection(f, a: float, b: float, tol: float = DEFAULT_RICH_TOL,
257
+ max_iter: int = 200) -> float:
258
+ """Root of f(x) = 0 on [a, b] by the bisection method.
259
+
260
+ Requires f(a) and f(b) to straddle zero; each step halves the
261
+ interval (linear convergence). Returns the midpoint once the
262
+ interval half-width is below tol. Raises ValueError when the
263
+ bracket does not straddle zero or convergence is not reached
264
+ within max_iter steps (leaf-identical behavior).
265
+ """
266
+ if not callable(f):
267
+ raise ValueError("f must be a callable f(x)")
268
+ a, b = float(a), float(b)
269
+ if a > b:
270
+ a, b = b, a
271
+ fa, fb = f(a), f(b)
272
+ if fa == 0.0:
273
+ return a
274
+ if fb == 0.0:
275
+ return b
276
+ if fa * fb > 0.0:
277
+ raise ValueError(
278
+ "bracket [a, b] does not straddle zero: f(a) and f(b) have "
279
+ "the same sign")
280
+ for _ in range(max_iter):
281
+ c = 0.5 * (a + b)
282
+ fc = f(c)
283
+ if fc == 0.0:
284
+ return c
285
+ if 0.5 * (b - a) < tol:
286
+ return c
287
+ if fa * fc < 0.0:
288
+ b, fb = c, fc
289
+ else:
290
+ a, fa = c, fc
291
+ raise ValueError("bisection did not converge to tolerance %g within "
292
+ "%d iterations" % (tol, max_iter))
293
+
294
+
295
+ def newton_raphson(f, df, x0: float, tol: float = DEFAULT_RICH_TOL,
296
+ max_iter: int = 100) -> float:
297
+ """Root of f(x) = 0 by Newton-Raphson from the initial guess x0.
298
+
299
+ Iterates x_{k+1} = x_k - f(x_k)/df(x_k): quadratic convergence for
300
+ a simple root (df != 0). Convergence on |f(x)| < tol. Raises
301
+ ValueError when the derivative is zero or non-finite at a step or
302
+ the iteration does not converge (leaf-identical behavior).
303
+ """
304
+ if not callable(f) or not callable(df):
305
+ raise ValueError("f and df must be callables")
306
+ x = float(x0)
307
+ for _ in range(max_iter):
308
+ fx = f(x)
309
+ if abs(fx) < tol:
310
+ return x
311
+ dfx = df(x)
312
+ if dfx == 0.0:
313
+ raise ValueError("derivative is zero at x = %g; Newton-Raphson "
314
+ "step is undefined" % x)
315
+ if not math.isfinite(dfx):
316
+ raise ValueError("derivative is not finite at x = %g; the "
317
+ "function is ill-conditioned" % x)
318
+ x = x - fx / dfx
319
+ if not math.isfinite(x):
320
+ raise ValueError("Newton-Raphson produced a non-finite iterate; "
321
+ "the iteration diverged")
322
+ raise ValueError("newton-raphson did not converge within %d iterations"
323
+ % max_iter)
324
+
325
+
326
+ def bracket_straddles(f, a: float, b: float) -> dict:
327
+ """Check that [a, b] brackets a root (f(a)*f(b) < 0)."""
328
+ fa, fb = float(f(a)), float(f(b))
329
+ return {"a": float(a), "b": float(b), "fa": fa, "fb": fb,
330
+ "straddles": fa * fb < 0.0}
331
+
332
+
333
+ def newton_iterations(f, df, x0: float, tol: float = DEFAULT_RICH_TOL,
334
+ max_iter: int = 100) -> dict:
335
+ """Run Newton-Raphson and report root, iterations and final residual."""
336
+ x = float(x0)
337
+ for it in range(1, max_iter + 1):
338
+ fx = f(x)
339
+ if abs(fx) < tol:
340
+ return {"root": x, "iterations": it - 1, "residual": abs(fx)}
341
+ dfx = df(x)
342
+ if dfx == 0.0 or not math.isfinite(dfx):
343
+ return {"root": x, "iterations": it - 1, "residual": abs(fx),
344
+ "aborted": True}
345
+ x = x - fx / dfx
346
+ if not math.isfinite(x):
347
+ return {"root": x, "iterations": it, "residual": abs(fx),
348
+ "aborted": True}
349
+ fx = f(x)
350
+ return {"root": x, "iterations": max_iter, "residual": abs(fx)}
351
+
352
+
353
+ def root_sensitivity(dfdx: float) -> float:
354
+ """Root displacement per unit residual change: |dx/df| = 1/|f'(x*)|.
355
+
356
+ A near-zero derivative (multiple root) makes the root hypersensitive
357
+ to residual error; Newton then degenerates from quadratic to linear
358
+ convergence.
359
+ """
360
+ if dfdx == 0.0:
361
+ return float("inf")
362
+ return 1.0 / abs(dfdx)
363
+
364
+
365
+ # ---------------------------------------------------------------------------
366
+ # Reference linear-solve engine (mirrors matrix-operations leaf logic)
367
+ # ---------------------------------------------------------------------------
368
+
369
+ def solve(A, b) -> list:
370
+ """Solve the dense square system A x = b by Gaussian elimination
371
+ with partial pivoting.
372
+
373
+ Pivot search picks the largest-magnitude entry in the current
374
+ column at or below the diagonal. Raises ValueError on a singular
375
+ matrix (pivot at or below 1e-12 * scale) or invalid input.
376
+ """
377
+ if not isinstance(A, (list, tuple)) or not A:
378
+ raise ValueError("matrix must be a non-empty list of rows")
379
+ n = len(A)
380
+ M = []
381
+ for row in A:
382
+ if not isinstance(row, (list, tuple)) or len(row) != n:
383
+ raise ValueError("matrix must be square")
384
+ M.append([float(v) for v in row])
385
+ if not isinstance(b, (list, tuple)) or len(b) != n:
386
+ raise ValueError("rhs must be a sequence of length %d" % n)
387
+ rhs = [float(v) for v in b]
388
+ aug = [M[i] + [rhs[i]] for i in range(n)]
389
+ scale = max(abs(v) for row in M for v in row)
390
+ tol = 1e-12 * max(1.0, scale)
391
+ for k in range(n):
392
+ p = max(range(k, n), key=lambda i: abs(aug[i][k]))
393
+ if abs(aug[p][k]) <= tol:
394
+ raise ValueError("matrix is singular: zero pivot at column %d"
395
+ % k)
396
+ if p != k:
397
+ aug[k], aug[p] = aug[p], aug[k]
398
+ pivot = aug[k][k]
399
+ for i in range(k + 1, n):
400
+ factor = aug[i][k] / pivot
401
+ if factor != 0.0:
402
+ for j in range(k, n + 1):
403
+ aug[i][j] -= factor * aug[k][j]
404
+ x = [0.0] * n
405
+ for i in range(n - 1, -1, -1):
406
+ x[i] = (aug[i][n] - sum(aug[i][j] * x[j] for j in range(i + 1, n))) \
407
+ / aug[i][i]
408
+ return x
409
+
410
+
411
+ def max_abs_component_diff(x, y) -> float:
412
+ """Max-absolute component difference between two vectors."""
413
+ return max(abs(float(a) - float(b)) for a, b in zip(x, y))
414
+
415
+
416
+ def condition_number(singular_values) -> float:
417
+ """2-norm condition number kappa = s_max / s_min.
418
+
419
+ Returns inf when the smallest singular value is zero (mirrors the
420
+ singular-value-decomposition leaf). Raises ValueError on empty or
421
+ negative input.
422
+ """
423
+ if not isinstance(singular_values, (list, tuple)) or not singular_values:
424
+ raise ValueError("singular values list must not be empty")
425
+ if any(sj < 0.0 for sj in singular_values):
426
+ raise ValueError("singular values must be non-negative")
427
+ s_min = min(float(sj) for sj in singular_values)
428
+ if s_min == 0.0:
429
+ return float("inf")
430
+ return max(float(sj) for sj in singular_values) / s_min
431
+
432
+
433
+ def condition_report(singular_values) -> dict:
434
+ """Conditioning assessment of a linear solve from its singular values."""
435
+ kappa = condition_number(singular_values)
436
+ digits = None if kappa == float("inf") else math.log10(kappa)
437
+ return {
438
+ "singular_values": [round(float(s), 12) for s in singular_values],
439
+ "kappa": (None if kappa == float("inf") else round(kappa, 12)),
440
+ "digits_lost_max": (None if digits is None else round(digits, 3)),
441
+ "assessment": _cond_text(kappa) if kappa != float("inf") else
442
+ "kappa is infinite: singular system (zero singular value)",
443
+ "at_precision_limit": kappa >= ONE_OVER_EPS,
444
+ }
445
+
446
+
447
+ def forward_error_bound(kappa: float, rel_backward_error: float) -> float:
448
+ """Classical forward-error bound: ||dx||/||x|| <= kappa * backward."""
449
+ return kappa * rel_backward_error
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # Convergence engine (mirrors convergence-verification leaf logic)
454
+ # ---------------------------------------------------------------------------
455
+
456
+ def observed_order(f1: float, f2: float, f3: float, r: float) -> float:
457
+ """Observed order of accuracy p = ln((f3-f2)/(f2-f1)) / ln(r).
458
+
459
+ f1 finest, f2 medium, f3 coarse; r = h_coarse/h_fine > 1. Raises
460
+ ValueError when r <= 1, f2 == f1, or the sequence is non-monotone
461
+ ((f3-f2)/(f2-f1) <= 0).
462
+ """
463
+ if r <= 1.0:
464
+ raise ValueError("refinement ratio must be > 1: got r=%r" % (r,))
465
+ diff = f2 - f1
466
+ if diff == 0.0:
467
+ raise ValueError("degenerate: f2 == f1 (%r), ratio undefined" % (f1,))
468
+ ratio = (f3 - f2) / diff
469
+ if ratio <= 0.0:
470
+ raise ValueError("non-monotone sequence: ratio (f3-f2)/(f2-f1) = "
471
+ "%r <= 0" % (ratio,))
472
+ return math.log(ratio) / math.log(r)
473
+
474
+
475
+ def richardson_extrapolation(f1: float, f2: float, r: float, p: float) -> float:
476
+ """Richardson extrapolated solution f_exact = f1 + (f1-f2)/(r^p - 1)."""
477
+ return f1 + (f1 - f2) / (r ** p - 1.0)
478
+
479
+
480
+ def grid_convergence_index(f1: float, f2: float, r: float, p: float,
481
+ fs: float = GCI_FS) -> float:
482
+ """Grid convergence index as a fraction: Fs*|(f1-f2)/f1|/(r^p - 1)."""
483
+ return fs * abs((f1 - f2) / f1) / (r ** p - 1.0)
484
+
485
+
486
+ def convergence_verdict(f1: float, f2: float, f3: float, r: float) -> dict:
487
+ """Classify the three-solution grid study (leaf-identical semantics).
488
+
489
+ Verdict from the ratio (f3-f2)/(f2-f1): 'monotone converged' when
490
+ the ratio > 0 (order p, Richardson extrapolate and GCI returned),
491
+ 'oscillatory' when the ratio < 0 (or == 0), 'diverging' when the
492
+ ratio < -1. Non-monotone sequences carry order/extrapolated/gci
493
+ as None. Raises ValueError when r <= 1 or f2 == f1.
494
+ """
495
+ if r <= 1.0:
496
+ raise ValueError("refinement ratio must be > 1: got r=%r" % (r,))
497
+ diff = f2 - f1
498
+ if diff == 0.0:
499
+ raise ValueError("degenerate: f2 == f1 (%r), ratio undefined" % (f1,))
500
+ ratio = (f3 - f2) / diff
501
+ if ratio > 0.0:
502
+ p = math.log(ratio) / math.log(r)
503
+ denom = r ** p - 1.0
504
+ if abs(denom) < 1e-12:
505
+ return {"order": p, "extrapolated": None, "gci": None,
506
+ "verdict": "monotone converged"}
507
+ f_exact = f1 + (f1 - f2) / denom
508
+ gci = GCI_FS * abs((f1 - f2) / f1) / denom
509
+ return {"order": p, "extrapolated": f_exact, "gci": gci,
510
+ "verdict": "monotone converged"}
511
+ if ratio < 0.0:
512
+ verdict = "diverging" if abs(ratio) > 1.0 else "oscillatory"
513
+ else:
514
+ verdict = "oscillatory"
515
+ return {"order": None, "extrapolated": None, "gci": None,
516
+ "verdict": verdict}
517
+
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # Report builder: the Numerical Methods Verification Memo
521
+ # ---------------------------------------------------------------------------
522
+
523
+ def _verdict(discrepancy: float, tolerance: float) -> str:
524
+ return "PASS" if discrepancy <= tolerance else "FAIL"
525
+
526
+
527
+ def build_report(item: VerificationItem) -> dict:
528
+ """Build the complete Numerical Methods Verification Memo model.
529
+
530
+ Computes reference integral (composite Simpson at 2x the code's
531
+ finest mesh, n_ref), reference root (Newton from item.newton_guess
532
+ with a bisection bracket cross-check), reference linear solve, the
533
+ three-mesh convergence study of the code's own outputs, the
534
+ conditioning report, and the per-operation discrepancy verdicts.
535
+ """
536
+ a, b = float(item.interval[0]), float(item.interval[1])
537
+
538
+ # --- 1. integration: reference value, error estimate, fingerprint ---
539
+ f = lambda y: lift_intensity(y, item.integrand_w0, item.integrand_span)
540
+ n_ref = max(2 * item.code_integral_n, 64)
541
+ if n_ref % 2 != 0:
542
+ n_ref += 1
543
+ i_ref = simpson(f, a, b, n_ref)
544
+ i_code = float(item.code_integral)
545
+ # the engine reproduces the code's own panel count to fingerprint the
546
+ # method the code actually used
547
+ fingerprint = trapezoid(f, a, b, item.code_integral_n)
548
+ # Richardson estimate of the code's n-panel error from n and 2n runs
549
+ err_n = richardson_error_n(
550
+ trapezoid(f, a, b, item.code_integral_n),
551
+ trapezoid(f, a, b, 2 * item.code_integral_n))
552
+ rich_2n = richardson_error_trapezoid(f, a, b, item.code_integral_n)
553
+ disc_int = abs(i_code - i_ref)
554
+ int_check = {
555
+ "operation": "spanwise load integral",
556
+ "quantity": "half-span lift, N",
557
+ "code_method": item.code_method_label,
558
+ "reference_method": "composite Simpson, n=%d (exact for degree "
559
+ "<= 3) + Richardson error estimate" % n_ref,
560
+ "code_value": round(i_code, 9),
561
+ "reference_value": round(i_ref, 9),
562
+ "discrepancy": round(disc_int, 9),
563
+ "code_tolerance": item.code_integral_tol,
564
+ "verdict": _verdict(disc_int, item.code_integral_tol),
565
+ "method_fingerprint": {
566
+ "expected_trapezoid_n%d" % item.code_integral_n:
567
+ round(fingerprint, 9),
568
+ "matches_code_output":
569
+ abs(fingerprint - i_code) <= 1e-9 * max(1.0, abs(i_code)),
570
+ },
571
+ "richardson_estimate_of_code_error_n%d" % item.code_integral_n:
572
+ round(err_n, 9),
573
+ "richardson_error_estimate_2n": round(rich_2n, 9),
574
+ "closed_form_check": "w0*span/3 = %r (exact anchor)"
575
+ % (item.integrand_w0 * item.integrand_span / 3.0,),
576
+ }
577
+
578
+ # --- 2. root finding: Newton reference + bisection cross-check ---
579
+ ar = item.area_ratio
580
+ resid = lambda M: area_mach_residual(M, ar, item.gamma)
581
+ deriv = lambda M: area_mach_derivative(M, item.gamma)
582
+ nr = newton_iterations(resid, deriv, float(item.newton_guess))
583
+ x_ref = nr["root"]
584
+ brk = bracket_straddles(resid, float(item.bracket[0]),
585
+ float(item.bracket[1]))
586
+ x_bisect = bisection(resid, float(item.bracket[0]),
587
+ float(item.bracket[1])) if brk["straddles"] else None
588
+ dfdx_root = deriv(x_ref)
589
+ sens = root_sensitivity(dfdx_root)
590
+ disc_root = abs(float(item.code_mach) - x_ref)
591
+ root_check = {
592
+ "operation": "isentropic area-Mach inversion (A/A* = %g, gamma "
593
+ "= %g), subsonic branch" % (ar, item.gamma),
594
+ "quantity": "Mach number (dimensionless)",
595
+ "code_method": "code-internal Newton solver",
596
+ "reference_method": "reference Newton-Raphson from x0 = %g + "
597
+ "bisection bracket cross-check" %
598
+ (item.newton_guess,),
599
+ "reference_root": round(x_ref, 12),
600
+ "reference_residual": float(nr["residual"]),
601
+ "newton_iterations": nr["iterations"],
602
+ "bracket": brk,
603
+ "bisection_root": (None if x_bisect is None else
604
+ round(x_bisect, 12)),
605
+ "newton_bisection_agreement":
606
+ (None if x_bisect is None else
607
+ float(abs(x_ref - x_bisect))),
608
+ "derivative_at_root": round(dfdx_root, 9),
609
+ "simple_root_quadratic_convergence": dfdx_root != 0.0,
610
+ "root_sensitivity_per_unit_residual": round(sens, 9),
611
+ "code_value": round(float(item.code_mach), 12),
612
+ "discrepancy": float(disc_root),
613
+ "code_tolerance": item.code_mach_tol,
614
+ "verdict": _verdict(disc_root, item.code_mach_tol),
615
+ }
616
+
617
+ # --- 3. linear solve: reference solve + conditioning ---
618
+ A = [[float(v) for v in row] for row in item.system_A]
619
+ bb = [float(v) for v in item.system_b]
620
+ x_ref_solve = solve(A, bb)
621
+ disc_solve = max_abs_component_diff(item.code_solution, x_ref_solve)
622
+ solve_check = {
623
+ "operation": "loads coefficient solve A c = b",
624
+ "quantity": "solution vector components (dimensionless)",
625
+ "code_method": "code-internal dense solver",
626
+ "reference_method": "Gaussian elimination with partial pivoting "
627
+ "(reference engine)",
628
+ "system_A": A,
629
+ "system_b": bb,
630
+ "reference_solution": [round(v, 12) for v in x_ref_solve],
631
+ "code_solution": [round(float(v), 12) for v in item.code_solution],
632
+ "discrepancy_max_component": round(disc_solve, 15),
633
+ "discrepancy": round(disc_solve, 15),
634
+ "code_tolerance": item.code_solve_tol,
635
+ "verdict": _verdict(disc_solve, item.code_solve_tol),
636
+ }
637
+ # conditioning of A from its singular values (2x2 closed form via
638
+ # eigenvalues of A^T A)
639
+ sv = _singular_values_2x2(A)
640
+ solve_check["conditioning"] = condition_report(sv)
641
+
642
+ # --- 4. three-mesh convergence study of the code's outputs ---
643
+ n1, n2, n3 = item.mesh_n # f1 finest ... f3 coarsest
644
+ f1 = trapezoid(f, a, b, n1)
645
+ f2 = trapezoid(f, a, b, n2)
646
+ f3 = trapezoid(f, a, b, n3)
647
+ r = n2 / float(n3) # uniform refinement ratio > 1
648
+ try:
649
+ cv = convergence_verdict(f1, f2, f3, r)
650
+ except ValueError:
651
+ cv = {"order": None, "extrapolated": None, "gci": None,
652
+ "verdict": "non-monotone (no observed order)"}
653
+ code_err = abs(i_code - f1) # code's n16 output vs finest-mesh value
654
+ conv = {
655
+ "meshes": [{"n": n1, "f": round(f1, 9)},
656
+ {"n": n2, "f": round(f2, 9)},
657
+ {"n": n3, "f": round(f3, 9)}],
658
+ "refinement_ratio": r,
659
+ "observed_order": (None if cv["order"] is None else
660
+ round(cv["order"], 6)),
661
+ "richardson_extrapolated": (None if cv["extrapolated"] is None else
662
+ round(cv["extrapolated"], 9)),
663
+ "gci_fraction": (None if cv["gci"] is None else
664
+ round(cv["gci"], 12)),
665
+ "verdict": cv["verdict"],
666
+ "code_output_vs_finest_mesh": round(code_err, 9),
667
+ "order_consistent_with": "composite trapezoid (order 2)" if (
668
+ cv["order"] is not None and abs(cv["order"] - 2.0) < 0.05)
669
+ else "not trapezoid-consistent",
670
+ }
671
+
672
+ checks = [int_check, root_check, solve_check]
673
+ findings = _findings(int_check, root_check, solve_check, conv)
674
+ method_rows = _method_selection_rows(item)
675
+
676
+ return {
677
+ "document_type": "Numerical Methods Verification Memo",
678
+ "status": "draft-for-review",
679
+ "item": item.code_name,
680
+ "item_version": item.code_version,
681
+ "item_description": item.description,
682
+ "verification_basis": item.verification_basis,
683
+ "export_classification": item.export_classification,
684
+ "interval": [a, b],
685
+ "integrand": {"label": item.integrand_label,
686
+ "w0_N_per_m": item.integrand_w0,
687
+ "span_m": item.integrand_span},
688
+ "method_selection": method_rows,
689
+ "checks": checks,
690
+ "integration": int_check,
691
+ "root_finding": root_check,
692
+ "linear_solve": solve_check,
693
+ "convergence": conv,
694
+ "findings": findings,
695
+ "generated": _today(),
696
+ }
697
+
698
+
699
+ def _singular_values_2x2(A: list) -> list:
700
+ """Singular values of a 2x2 matrix from eigenvalues of A^T A."""
701
+ a11, a12 = A[0]
702
+ a21, a22 = A[1]
703
+ g11 = a11 * a11 + a21 * a21
704
+ g12 = a11 * a12 + a21 * a22
705
+ g22 = a12 * a12 + a22 * a22
706
+ tr = g11 + g22
707
+ det = g11 * g22 - g12 * g12
708
+ disc = math.sqrt(max(0.0, tr * tr - 4.0 * det))
709
+ lam_max = (tr + disc) / 2.0
710
+ lam_min = (tr - disc) / 2.0
711
+ return [math.sqrt(lam_max), math.sqrt(lam_min)]
712
+
713
+
714
+ def _method_selection_rows(item: VerificationItem) -> list:
715
+ """Per-operation reference method choices with real rationale."""
716
+ sel = select_integration_rule("smooth")
717
+ rows = [
718
+ {
719
+ "operation": item.integrand_label or "spanwise load integral",
720
+ "code_method": item.code_method_label,
721
+ "reference_method": sel["rule"],
722
+ "rationale": "%s %s" % (sel["rule"].title(),
723
+ sel["rationale"]),
724
+ },
725
+ {
726
+ "operation": "area-Mach inversion (A/A* = %g, subsonic)"
727
+ % (item.area_ratio,),
728
+ "code_method": "code-internal Newton solver",
729
+ "reference_method": "Newton-Raphson (quadratic, simple root) "
730
+ "with bisection bracket cross-check",
731
+ "rationale": "Bisection guarantees convergence when the "
732
+ "bracket straddles zero (linear); Newton gives "
733
+ "quadratic convergence from a nearby guess when "
734
+ "the derivative is non-zero; agreement of the "
735
+ "two independent iterations cross-validates the "
736
+ "root.",
737
+ },
738
+ {
739
+ "operation": "loads coefficient solve A c = b",
740
+ "code_method": "code-internal dense solver",
741
+ "reference_method": "Gaussian elimination with partial "
742
+ "pivoting",
743
+ "rationale": "Partial pivoting bounds element growth and "
744
+ "avoids zero pivots; the reference residual "
745
+ "A x - b is checked at machine precision, and "
746
+ "the conditioning report bounds how much "
747
+ "backward error the solve amplifies.",
748
+ },
749
+ ]
750
+ return rows
751
+
752
+
753
+ def _findings(int_check, root_check, solve_check, conv) -> list:
754
+ """Evidence-backed findings: PASS rows and actionable FAIL rows."""
755
+ out = []
756
+ if int_check["verdict"] == "FAIL":
757
+ out.append({
758
+ "id": "F-1",
759
+ "severity": "discrepancy",
760
+ "summary": ("%s reports %.6f %s while the reference "
761
+ "integration gives %.6f %s: discrepancy %.6f %s "
762
+ "exceeds the code's claimed tolerance of %g %s."
763
+ % (int_check["operation"], int_check["code_value"],
764
+ "N", int_check["reference_value"], "N",
765
+ int_check["discrepancy"], "N",
766
+ int_check["code_tolerance"], "N")),
767
+ "evidence": ("Richardson error estimate of the code's n-panel "
768
+ "output is %.6f N (measured discrepancy %.6f N); "
769
+ "the three-mesh study shows observed order %.2f, "
770
+ "so the code's panel count cannot support the "
771
+ "claimed tolerance."
772
+ % (int_check.get(
773
+ "richardson_estimate_of_code_error_n16", 0.0),
774
+ int_check["discrepancy"],
775
+ conv.get("observed_order") or 0.0)),
776
+ "recommendation": "Refine to n >= 64 panels or switch to a "
777
+ "composite Simpson rule, and gate the "
778
+ "output with a Richardson error estimate "
779
+ "before release.",
780
+ })
781
+ else:
782
+ out.append({
783
+ "id": "O-1",
784
+ "severity": "observation",
785
+ "summary": "%s is within the code's claimed tolerance "
786
+ "(discrepancy %g <= %g)."
787
+ % (int_check["operation"], int_check["discrepancy"],
788
+ int_check["code_tolerance"]),
789
+ })
790
+ for label, chk in (("root-finding", root_check),
791
+ ("linear solve", solve_check)):
792
+ if chk["verdict"] == "PASS":
793
+ out.append({
794
+ "id": "O-%d" % len(out),
795
+ "severity": "observation",
796
+ "summary": "%s (%s) verified within the code's claimed "
797
+ "tolerance (discrepancy %g <= %g)."
798
+ % (label, chk["operation"], chk["discrepancy"],
799
+ chk["code_tolerance"]),
800
+ })
801
+ else:
802
+ out.append({
803
+ "id": "F-%d" % len(out),
804
+ "severity": "discrepancy",
805
+ "summary": "%s (%s) deviates from the reference by %g, "
806
+ "exceeding the claimed %g tolerance."
807
+ % (label, chk["operation"], chk["discrepancy"],
808
+ chk["code_tolerance"]),
809
+ "recommendation": "Re-run the reference engine inputs and "
810
+ "re-verify before release.",
811
+ })
812
+ return out
813
+
814
+
815
+ def _today() -> str:
816
+ import os
817
+ from datetime import date
818
+ return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
819
+
820
+
821
+ # ---------------------------------------------------------------------------
822
+ # Markdown renderer (the deliverable is a rendering of the model)
823
+ # ---------------------------------------------------------------------------
824
+
825
+ def _fmt(v, digits: int = 6) -> str:
826
+ if v is None:
827
+ return "n/a"
828
+ if isinstance(v, float):
829
+ if v != 0.0 and abs(v) < 1e-4:
830
+ return "%.2e" % v
831
+ return ("%%.%df" % digits) % v
832
+ return str(v)
833
+
834
+
835
+ def _fp_lines(fp: dict) -> list:
836
+ """Lines for the integration method-fingerprint block."""
837
+ out = []
838
+ if not fp or not fp.get("matches_code_output"):
839
+ return out
840
+ for k in sorted(fp):
841
+ if k.startswith("expected_trapezoid_n"):
842
+ out.append("- Method fingerprint: the reference engine's "
843
+ "trapezoid at the code's panel count reproduces "
844
+ "the code output (%s) - the code's method was "
845
+ "identified correctly." % _fmt(fp[k], 9))
846
+ return out
847
+
848
+
849
+ def _int_section_lines(chk: dict) -> list:
850
+ lines = [
851
+ "- Operation: %s" % chk["operation"],
852
+ "- Code method: %s" % chk["code_method"],
853
+ "- Reference method: %s" % chk["reference_method"],
854
+ "- Code-reported value: %s N" % _fmt(chk["code_value"], 9),
855
+ "- Reference value (composite Simpson, n=%d): %s N"
856
+ % (int(chk["reference_method"].split("n=")[1].split(" ")[0]),
857
+ _fmt(chk["reference_value"], 9)),
858
+ "- Discrepancy: %s N" % _fmt(chk["discrepancy"], 9),
859
+ "- Code-claimed tolerance: %s N" % _fmt(chk["code_tolerance"], 3),
860
+ "- Richardson estimate of the code's n-panel error: %s N "
861
+ "(from the code's own n and 2n outputs)" % _fmt(
862
+ chk.get("richardson_estimate_of_code_error_n%d" % 16), 6),
863
+ "- **Verdict: %s**" % chk["verdict"],
864
+ ]
865
+ lines += _fp_lines(chk.get("method_fingerprint"))
866
+ return lines
867
+
868
+
869
+ def _root_section_lines(chk: dict) -> list:
870
+ lines = [
871
+ "- Operation: %s" % chk["operation"],
872
+ "- Code method: %s" % chk["code_method"],
873
+ "- Reference method: %s" % chk["reference_method"],
874
+ "- Code-reported value: M = %s" % _fmt(chk["code_value"], 9),
875
+ "- Reference value: M* = %s (residual |f| = %s)"
876
+ % (_fmt(chk["reference_root"], 12), _fmt(chk["reference_residual"], 3)),
877
+ "- Bisection cross-check: M = %s (agreement with Newton %s)"
878
+ % (_fmt(chk["bisection_root"], 9),
879
+ _fmt(chk["newton_bisection_agreement"], 3)),
880
+ "- Discrepancy: %s" % _fmt(chk["discrepancy"], 12),
881
+ "- Code-claimed tolerance: %s" % _fmt(chk["code_tolerance"], 3),
882
+ "- **Verdict: %s**" % chk["verdict"],
883
+ ]
884
+ return lines
885
+
886
+
887
+ def _solve_section_lines(chk: dict) -> list:
888
+ cond = chk.get("conditioning") or {}
889
+ lines = [
890
+ "- Operation: %s" % chk["operation"],
891
+ "- Code method: %s" % chk["code_method"],
892
+ "- Reference method: %s" % chk["reference_method"],
893
+ "- Code-reported solution: [%s]" % ", ".join(
894
+ _fmt(v, 9) for v in chk["code_solution"]),
895
+ "- Reference solution: [%s]" % ", ".join(
896
+ _fmt(v, 9) for v in chk["reference_solution"]),
897
+ "- Discrepancy (max component): %s" % _fmt(chk["discrepancy"], 12),
898
+ "- Code-claimed tolerance: %s" % _fmt(chk["code_tolerance"], 3),
899
+ "- **Verdict: %s**" % chk["verdict"],
900
+ ]
901
+ if cond:
902
+ lines += [
903
+ "- Condition number kappa = s_max/s_min: %s"
904
+ % _fmt(cond.get("kappa"), 4),
905
+ "- Conditioning: %s" % cond.get("assessment", ""),
906
+ ]
907
+ return lines
908
+
909
+
910
+ def render_report_markdown(model: dict) -> str:
911
+ """Render the content model as the deliverable markdown document."""
912
+ int_chk = model["integration"]
913
+ root_chk = model["root_finding"]
914
+ solve_chk = model["linear_solve"]
915
+ conv = model["convergence"]
916
+ lines = [
917
+ "# Numerical Methods Verification Memo",
918
+ "",
919
+ "**Code under verification:** %s (version %s)"
920
+ % (model["item"], model["item_version"]),
921
+ "**Verification basis:** %s" % model["verification_basis"],
922
+ "**Status:** %s" % model["status"],
923
+ "",
924
+ "## 1. Scope and method selection",
925
+ "",
926
+ (model["item_description"] + " " if model["item_description"] else "")
927
+ + "This memo independently verifies the numerical methods of the "
928
+ "item above: the spanwise-load integral, the area-Mach root "
929
+ "inversion, and the loads coefficient solve are each recomputed "
930
+ "with reference methods and compared against the code's reported "
931
+ "values and claimed tolerances. A three-mesh convergence study "
932
+ "and a conditioning report bound the discretization and "
933
+ "rounding error of the results.",
934
+ "",
935
+ "| Operation | Code method | Reference method | Why |",
936
+ "|---|---|---|---|",
937
+ ]
938
+ for row in model["method_selection"]:
939
+ lines.append("| %s | %s | %s | %s |"
940
+ % (row["operation"], row["code_method"],
941
+ row["reference_method"], row["rationale"]))
942
+ lines += [
943
+ "",
944
+ "## 2. Reference computations",
945
+ "",
946
+ "### 2.1 Reference integral",
947
+ "",
948
+ "Integrand: %s on [%s, %s] m (peak load %g N/m, span %g m)."
949
+ % (model["integrand"]["label"], _fmt(model["interval"][0]),
950
+ _fmt(model["interval"][1]), model["integrand"]["w0_N_per_m"],
951
+ model["integrand"]["span_m"]),
952
+ "- Reference value (composite Simpson): %s N" % _fmt(
953
+ int_chk["reference_value"], 6),
954
+ "- Closed-form anchor w0*span/3: %s N (the integrand is a "
955
+ "quadratic, so Simpson is exact here)" % _fmt(
956
+ model["integrand"]["w0_N_per_m"]
957
+ * model["integrand"]["span_m"] / 3.0, 6),
958
+ "- Richardson error estimate at the code's panel count: the "
959
+ "trapezoid error scales with h^2, giving error(2n) = "
960
+ "|I_2n - I_n|/3 = %s N." % _fmt(
961
+ int_chk.get("richardson_error_estimate_2n"), 6),
962
+ "",
963
+ "### 2.2 Reference root",
964
+ "",
965
+ "- %s: M* = %s"
966
+ % (root_chk["operation"].replace(" (subsonic branch)", ""),
967
+ _fmt(root_chk["reference_root"], 9)),
968
+ "- Reference method: %s - converged in %d iterations, "
969
+ "residual |f| = %s" % (root_chk["reference_method"],
970
+ root_chk["newton_iterations"],
971
+ _fmt(root_chk["reference_residual"], 3)),
972
+ "- Derivative at the root f'(M*) = %s (non-zero: simple root, "
973
+ "quadratic convergence expected); root sensitivity per unit "
974
+ "residual 1/|f'| = %s" % (_fmt(root_chk["derivative_at_root"], 6),
975
+ _fmt(root_chk[
976
+ "root_sensitivity_per_unit_residual"],
977
+ 6)),
978
+ "- Bracket [%s, %s] straddles zero: %s" % (
979
+ _fmt(root_chk["bracket"]["a"], 3),
980
+ _fmt(root_chk["bracket"]["b"], 3),
981
+ root_chk["bracket"]["straddles"]),
982
+ "- Bisection cross-check: M = %s, agreement with Newton %s"
983
+ % (_fmt(root_chk["bisection_root"], 9),
984
+ _fmt(root_chk["newton_bisection_agreement"], 3)),
985
+ "",
986
+ "### 2.3 Reference linear solve",
987
+ "",
988
+ "- Reference solution c = A^-1 b: %s" % ", ".join(
989
+ _fmt(v, 9) for v in solve_chk["reference_solution"]),
990
+ "- Condition number kappa = s_max/s_min: %s"
991
+ % _fmt(solve_chk["conditioning"]["kappa"], 4),
992
+ "- Conditioning assessment: %s" %
993
+ solve_chk["conditioning"]["assessment"],
994
+ "",
995
+ "## 3. Cross-check against the engineering code",
996
+ "",
997
+ "Each operation: code-reported value vs reference value; "
998
+ "verdict PASS when the discrepancy is within the code's own "
999
+ "claimed tolerance.",
1000
+ "",
1001
+ "### 3.1 Integration",
1002
+ "",
1003
+ *_int_section_lines(int_chk),
1004
+ "",
1005
+ "### 3.2 Root finding",
1006
+ "",
1007
+ *_root_section_lines(root_chk),
1008
+ "",
1009
+ "### 3.3 Linear solve",
1010
+ "",
1011
+ *_solve_section_lines(solve_chk),
1012
+ "",
1013
+ "## 4. Convergence and error analysis",
1014
+ "",
1015
+ "Three-mesh study of the code's own outputs (refinement ratio "
1016
+ "r = %g, finest f1 to coarsest f3):" % conv["refinement_ratio"],
1017
+ "",
1018
+ *["- %s: %s" % (m["n"], _fmt(m["f"], 6)) for m in conv["meshes"]],
1019
+ "- Observed order p = %s (consistent with %s)"
1020
+ % (_fmt(conv["observed_order"], 3), conv["order_consistent_with"]),
1021
+ "- Richardson extrapolated value: %s"
1022
+ % _fmt(conv["richardson_extrapolated"], 6),
1023
+ "- Grid convergence index (GCI, Fs = 1.25): %s fraction (%.4f%%)"
1024
+ % (_fmt(conv["gci_fraction"], 8),
1025
+ 0.0 if conv["gci_fraction"] is None
1026
+ else 100.0 * conv["gci_fraction"]),
1027
+ "- Verdict: %s" % conv["verdict"],
1028
+ "- Code-reported value vs finest mesh: %s %s"
1029
+ % (_fmt(conv["code_output_vs_finest_mesh"], 6), "N"),
1030
+ "",
1031
+ "## 5. Findings and recommendations",
1032
+ "",
1033
+ ]
1034
+ for fd in model["findings"]:
1035
+ lines.append("### %s (%s)" % (fd["id"], fd["severity"].title()))
1036
+ lines.append("")
1037
+ lines.append(fd["summary"])
1038
+ if fd.get("evidence"):
1039
+ lines.append("")
1040
+ lines.append("Evidence: %s" % fd["evidence"])
1041
+ if fd.get("recommendation"):
1042
+ lines.append("")
1043
+ lines.append("Recommendation: %s" % fd["recommendation"])
1044
+ lines.append("")
1045
+ lines += [
1046
+ "## 6. Verification limits",
1047
+ "",
1048
+ "- Scope: the three numerical operations named above, on the "
1049
+ "example item's input set. Other code paths (ODE integration, "
1050
+ "interpolation, least-squares fitting, finite-difference "
1051
+ "derivatives, uncertainty propagation) follow the same "
1052
+ "reference-engine workflow when exercised.",
1053
+ "- The code's claimed tolerances are inputs to this memo, not "
1054
+ "endorsements; a PASS verdict means the claim is supported by "
1055
+ "the reference computation on this input set.",
1056
+ "- Export classification: %s." % model["export_classification"],
1057
+ "",
1058
+ "---",
1059
+ "*Generated by Aero Agent Roles numerical-analysis-engineer core "
1060
+ "(%s). DRAFT for human numerical-analysis review. Not an "
1061
+ "approval document.*" % model["generated"],
1062
+ ]
1063
+ return "\n".join(lines)
1064
+
1065
+
1066
+ # ---------------------------------------------------------------------------
1067
+ # Evidence gate checkers
1068
+ # ---------------------------------------------------------------------------
1069
+
1070
+ GATE_CHECKS = {
1071
+ "deliverable_identified": "document type is the Numerical Methods "
1072
+ "Verification Memo",
1073
+ "item_present": "code under verification named with version",
1074
+ "methods_selected": "every operation has a reference method with "
1075
+ "rationale",
1076
+ "reference_numbers_present": "reference integral, root and solve "
1077
+ "values are real numbers",
1078
+ "discrepancies_quantified": "every check row carries discrepancy, "
1079
+ "tolerance and PASS/FAIL verdict",
1080
+ "convergence_study_present": "three-mesh study states observed "
1081
+ "order and GCI (or explicit verdict)",
1082
+ "sign_off_honest": "document is marked draft-for-review, not approval",
1083
+ }
1084
+
1085
+
1086
+ def _is_num(v) -> bool:
1087
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
1088
+
1089
+
1090
+ def check_report(model: dict) -> dict:
1091
+ """Run the evidence gates against a memo content model."""
1092
+ checks = model.get("checks") or []
1093
+ conv = model.get("convergence") or {}
1094
+ results = {
1095
+ "deliverable_identified":
1096
+ model.get("document_type") == "Numerical Methods Verification Memo",
1097
+ "item_present": bool(model.get("item")) and bool(
1098
+ model.get("item_version")),
1099
+ "methods_selected": bool(model.get("method_selection")) and all(
1100
+ r.get("reference_method") and r.get("rationale")
1101
+ for r in model.get("method_selection") or []),
1102
+ "reference_numbers_present": (
1103
+ _is_num(model.get("integration", {}).get("reference_value"))
1104
+ and _is_num(model.get("root_finding", {}).get("reference_root"))
1105
+ and bool(model.get("linear_solve", {}).get("reference_solution"))
1106
+ and all(_is_num(v) for v in model.get("linear_solve", {})
1107
+ .get("reference_solution", []))),
1108
+ "discrepancies_quantified": bool(checks) and all(
1109
+ _is_num(c.get("discrepancy")) and _is_num(c.get("code_tolerance"))
1110
+ and c.get("verdict") in ("PASS", "FAIL") for c in checks),
1111
+ "convergence_study_present": bool(conv.get("meshes")) and (
1112
+ _is_num(conv.get("observed_order"))
1113
+ or conv.get("verdict") in ("oscillatory", "diverging")),
1114
+ "sign_off_honest": model.get("status") == "draft-for-review",
1115
+ }
1116
+ results["all_pass"] = all(results.values())
1117
+ return results
1118
+
1119
+
1120
+ def check_report_markdown(md_text: str) -> dict:
1121
+ """Gate-check the rendered markdown deliverable."""
1122
+ low = md_text.lower()
1123
+ checks = {
1124
+ "has_title": "numerical methods verification memo" in low,
1125
+ "has_item": "code under verification" in low,
1126
+ "has_reference_numbers": bool(re.search(r"50\d\d\d?\.\d", md_text))
1127
+ and "0.590" in md_text,
1128
+ "has_discrepancy": "discrepancy" in low,
1129
+ "has_verdicts": bool(re.search(r"verdict[:\s]*\*?\*?(pass|fail)",
1130
+ low))
1131
+ or ("pass" in low and "fail" in low),
1132
+ "has_convergence": "observed order" in low and "gci" in low,
1133
+ "has_draft_marker": "draft" in low,
1134
+ "has_not_approval": "not an approval" in low,
1135
+ }
1136
+ checks["all_pass"] = all(checks.values())
1137
+ return checks
1138
+
1139
+
1140
+ def validate_deliverable_text(md_text: str) -> dict:
1141
+ """Public entry point used by gate tooling: check a memo document."""
1142
+ return check_report_markdown(md_text)
1143
+
1144
+
1145
+ # ---------------------------------------------------------------------------
1146
+ # Example item (for tests and the filled template)
1147
+ # ---------------------------------------------------------------------------
1148
+
1149
+ def example_item() -> VerificationItem:
1150
+ """The reference verification item used for the worked-example memo.
1151
+
1152
+ All reported code values are the deterministic outputs the reference
1153
+ engine reproduces for the code's own methods: the loads
1154
+ post-processor integrates the smooth quadratic spanwise load with a
1155
+ composite trapezoid at n = 16 (error ~49.2 N on a 50400 N integral),
1156
+ solves the area-Mach subsonic root to |f| < 1e-10, and solves the
1157
+ coefficient system A c = b with a well-conditioned A (kappa = 7).
1158
+ """
1159
+ w0, span = 4200.0, 36.0
1160
+ half = span / 2.0
1161
+ f = lambda y: lift_intensity(y, w0, span)
1162
+ code_integral = trapezoid(f, 0.0, half, 16)
1163
+ ar, gam = 1.2, GAMMA_AIR
1164
+ resid = lambda M: area_mach_residual(M, ar, gam)
1165
+ deriv = lambda M: area_mach_derivative(M, gam)
1166
+ x_ref = newton_raphson(resid, deriv, 0.3)
1167
+ A = [[1.0, 0.75], [0.75, 1.0]]
1168
+ b = [0.5, 0.5]
1169
+ c_ref = solve(A, b)
1170
+ return VerificationItem(
1171
+ code_name="Loads Post-Processor (LPP)",
1172
+ code_version="v2.3.1",
1173
+ description="The LPP loads post-processor turns the spanwise "
1174
+ "load distribution into the half-span resultant "
1175
+ "used by the loads report, inverts the isentropic "
1176
+ "area-Mach relation for duct sizing, and solves the "
1177
+ "2x2 aerodynamic coefficient system A c = b.",
1178
+ integrand_label="spanwise load intensity w(y) = w0*(1 - "
1179
+ "(2y/span)^2), half-span integral",
1180
+ code_method_label="composite trapezoid, n=16 panels",
1181
+ code_integral=code_integral,
1182
+ code_integral_tol=1.0,
1183
+ code_integral_n=16,
1184
+ area_ratio=ar,
1185
+ gamma=gam,
1186
+ code_mach=round(x_ref, 9),
1187
+ code_mach_tol=1e-8,
1188
+ system_A=A,
1189
+ system_b=b,
1190
+ code_solution=[round(c_ref[0], 6), round(c_ref[1], 6)],
1191
+ code_solve_tol=1e-6,
1192
+ mesh_n=[64, 32, 16],
1193
+ verification_basis=(
1194
+ "classical numerical analysis (Richardson 1910/1927; "
1195
+ "Euler-Maclaurin error analysis; Newton/Simpson/Gauss); "
1196
+ "ASME V&V 20-2009 vocabulary for discretization error"),
1197
+ )
1198
+
1199
+
1200
+ def example_report_markdown() -> str:
1201
+ return render_report_markdown(build_report(example_item()))
1202
+
1203
+
1204
+ if __name__ == "__main__":
1205
+ import sys
1206
+ item = example_item()
1207
+ model = build_report(item)
1208
+ md = render_report_markdown(model)
1209
+ print("DOCUMENT: %s" % model["document_type"])
1210
+ print("ITEM: %s %s" % (model["item"], model["item_version"]))
1211
+ for c in model["checks"]:
1212
+ print("CHECK: %-14s disc=%-12g tol=%-10g verdict=%s"
1213
+ % (c["operation"][:14], c["discrepancy"],
1214
+ c["code_tolerance"], c["verdict"]))
1215
+ conv = model["convergence"]
1216
+ print("GRID: p=%s GCI=%s verdict=%s"
1217
+ % (conv["observed_order"], conv["gci_fraction"], conv["verdict"]))
1218
+ print("GATES: %s" % check_report(model))
1219
+ print("MD-GATES: %s" % check_report_markdown(md))
1220
+ print("RENDERED: %d chars" % len(md))