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,1246 @@
1
+ #!/usr/bin/env python3
2
+ """do160_environmental_core.py - DO-160G Environmental Qualification
3
+ Engineer executable core.
4
+
5
+ This is the role's ENGINE: given an LRU's project facts (installation
6
+ location, expected temperature extremes, power bus, lightning
7
+ environment, RF test levels) it selects environmental categories,
8
+ builds the DO-160G test-condition matrix, computes ESD discharge
9
+ parameters, lightning verdicts, power-input margins, RF susceptibility
10
+ amplifier budgets and RF emission margins, and BUILDS the Equipment
11
+ Environmental Qualification Plan/Report. It also gate-checks
12
+ deliverables. Standalone: no external repo needed.
13
+
14
+ Domain rules encoded here mirror the bound AeroSkills leaves under
15
+ avionics/do160/ (electrostatic-discharge, environmental-qualification,
16
+ lightning-protection, power-input, radio-frequency-emissions,
17
+ radio-frequency-susceptibility) whose logic files encode summary
18
+ reference practice for RTCA DO-160G / EUROCAE ED-14G. DO-160 text is
19
+ never reproduced: level/waveform/limit tables are summary reference
20
+ data and must be verified against the current revision before use.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import math
26
+ import re
27
+ from dataclasses import dataclass, field
28
+ from datetime import date
29
+
30
+ def _today() -> str:
31
+ import os
32
+ from datetime import date
33
+ return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Domain tables (mirror the bound AeroSkills leaves; summary reference data)
37
+ # ---------------------------------------------------------------------------
38
+
39
+ # DO-160 test-condition sections (leaf avionics/do160/environmental-
40
+ # qualification logic SECTIONS map: section number -> name).
41
+ SECTIONS = {
42
+ 4: "Temperature and altitude",
43
+ 5: "Temperature variation",
44
+ 6: "Humidity",
45
+ 7: "Operational shocks and crash safety",
46
+ 8: "Vibration",
47
+ 9: "Explosion proofness",
48
+ 10: "Waterproofness",
49
+ 11: "Fluids susceptibility",
50
+ 16: "Power input",
51
+ 19: "Induced signal susceptibility",
52
+ 20: "Radio frequency susceptibility",
53
+ 21: "Emission of radio frequency energy",
54
+ 22: "Lightning induced transient susceptibility",
55
+ 23: "Lightning direct effects",
56
+ 24: "Icing",
57
+ 25: "Electrostatic discharge",
58
+ }
59
+
60
+ # Typical operating temperature ranges (deg C) per equipment category
61
+ # (leaf avionics/do160/environmental-qualification logic TEMPERATURE_RANGES;
62
+ # typical reference data - verify against the current revision).
63
+ TEMPERATURE_RANGES = {
64
+ "A1": (-55, 70),
65
+ "A2": (-55, 70),
66
+ "B1": (-55, 55),
67
+ "B2": (-55, 70),
68
+ "C1": (-55, 70),
69
+ "C2": (-55, 70),
70
+ "D1": (-55, 55),
71
+ "D2": (-55, 70),
72
+ }
73
+ EQUIPMENT_CATEGORIES = tuple(sorted(TEMPERATURE_RANGES))
74
+
75
+ # ESD generator model (leaf avionics/do160/electrostatic-discharge logic):
76
+ # DO-160 Section 25, single equipment category A, 15 kV air discharge,
77
+ # IEC 61000-4-2 human-body model: 150 pF storage capacitance, 330 ohm
78
+ # discharge resistance.
79
+ ESD_CATEGORY = "A"
80
+ ESD_TEST_LEVEL_KV = 15.0
81
+ ESD_CAPACITANCE_PF = 150.0
82
+ ESD_RESISTANCE_OHM = 330.0
83
+ ESD_PEAK_A_PER_KV = 3.75
84
+ ESD_CURRENT_30NS_A_PER_KV = 2.0
85
+ ESD_CURRENT_60NS_A_PER_KV = 1.0
86
+ ESD_RISE_MIN_NS = 0.7
87
+ ESD_RISE_MAX_NS = 1.0
88
+ ESD_MIN_DISCHARGES_PER_POLARITY = 10
89
+
90
+ # Lightning (leaf avionics/do160/lightning-protection logic): Section 22
91
+ # induced transient susceptibility test levels 1-5, waveform letters A-H,
92
+ # pass = no physical damage, no upset, no latch-up. Level/waveform tables
93
+ # are standard data in the current revision and are NOT reproduced.
94
+ LIGHTNING_LEVEL_MIN = 1
95
+ LIGHTNING_LEVEL_MAX = 5
96
+ LIGHTNING_WAVEFORMS = frozenset("ABCDEFGH")
97
+
98
+ # RF susceptibility (leaf avionics/do160/radio-frequency-susceptibility
99
+ # logic): free-space constants, far-field relation E = sqrt(30*P*G)/d,
100
+ # CS114 category offsets A..H,J (10 dBuA step, letter I skipped).
101
+ FREE_SPACE_IMPEDANCE = 377.0
102
+ SPEED_OF_LIGHT = 2.99792458e8
103
+ _CS114_OFFSETS = {"A": 0.0, "B": 10.0, "C": 20.0, "D": 30.0, "E": 40.0,
104
+ "F": 50.0, "G": 60.0, "H": 70.0, "J": 80.0}
105
+ CS114_BASE_LIMIT_DBU_A = 55.7 # category A base, summary reference only
106
+
107
+ # RF emissions (leaf avionics/do160/radio-frequency-emissions logic):
108
+ # CE102 band 10 kHz-10 MHz (reference-only piecewise curve), RE102 band
109
+ # 2 MHz-18 GHz with reference-only category floors A 24 / B 34 / C 44.
110
+ CE102_BAND_LO_HZ = 10e3
111
+ CE102_BAND_HI_HZ = 10e6
112
+ _CE102_BAND_LIMITS_DBU_V = (
113
+ (10e3, 100e3, 78.0),
114
+ (100e3, 2e6, 60.0),
115
+ (2e6, 10e6, 70.0),
116
+ )
117
+ RE102_BAND_LO_HZ = 2e6
118
+ RE102_BAND_HI_HZ = 18e9
119
+ RE102_FLOOR_DBU_VPM = {"A": 24.0, "B": 34.0, "C": 44.0}
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Environmental qualification helpers (leaf-mirrored)
124
+ # ---------------------------------------------------------------------------
125
+
126
+ def section_name(section_id: int) -> str:
127
+ if section_id not in SECTIONS:
128
+ raise ValueError(f"unknown DO-160 section: {section_id!r}")
129
+ return SECTIONS[section_id]
130
+
131
+
132
+ def required_sections(category: str) -> list:
133
+ """Required test-condition sections: the full leaf section set.
134
+ Category-specific exclusions must be confirmed against the current
135
+ revision of DO-160 (leaf caveat)."""
136
+ if category not in TEMPERATURE_RANGES:
137
+ raise ValueError(f"unknown equipment category: {category!r}")
138
+ return sorted(SECTIONS)
139
+
140
+
141
+ def matrix_complete(planned_sections, category: str):
142
+ """(missing, ok) for a planned test matrix against required sections."""
143
+ unknown = [s for s in planned_sections if s not in SECTIONS]
144
+ if unknown:
145
+ raise ValueError(f"unknown planned section id(s): {sorted(unknown)!r}")
146
+ required = required_sections(category)
147
+ missing = [s for s in required if s not in planned_sections]
148
+ return (missing, not missing)
149
+
150
+
151
+ def temperature_category_range(category: str):
152
+ if category not in TEMPERATURE_RANGES:
153
+ raise ValueError(f"unknown equipment category: {category!r}")
154
+ return TEMPERATURE_RANGES[category]
155
+
156
+
157
+ def temp_within_range(temp_c: float, category: str) -> bool:
158
+ lo, hi = temperature_category_range(category)
159
+ return lo <= temp_c <= hi
160
+
161
+
162
+ def select_temperature_category(low_c: float, high_c: float) -> str:
163
+ """Recommend the temperature category whose typical range covers the
164
+ declared expected extremes. Original decision aid over leaf typical
165
+ reference data: prefer the narrowest typical range that still covers
166
+ the declared envelope (least over-testing); ties break alphabetically."""
167
+ if high_c < low_c:
168
+ raise ValueError(f"high {high_c} < low {low_c}")
169
+ covering = [c for c in EQUIPMENT_CATEGORIES
170
+ if temp_within_range(low_c, c) and temp_within_range(high_c, c)]
171
+ if not covering:
172
+ raise ValueError(
173
+ f"no equipment category typical range covers "
174
+ f"[{low_c}, {high_c}] deg C")
175
+ return min(covering, key=lambda c: (
176
+ temperature_category_range(c)[1] - temperature_category_range(c)[0],
177
+ c))
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # ESD Section 25 helpers (leaf-mirrored)
182
+ # ---------------------------------------------------------------------------
183
+
184
+ def _num(v, name):
185
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
186
+ raise ValueError(f"{name} must be a number, got {v!r}")
187
+ return float(v)
188
+
189
+
190
+ def _flag(v, name):
191
+ if not isinstance(v, bool):
192
+ raise ValueError(f"{name} must be a bool, got {v!r}")
193
+ return v
194
+
195
+
196
+ def esd_category_test_level_kv(category: str) -> float:
197
+ if not isinstance(category, str):
198
+ raise ValueError(f"category must be a str, got {category!r}")
199
+ if category.strip().upper() != ESD_CATEGORY:
200
+ raise ValueError("DO-160 Section 25 defines only category A, "
201
+ f"got {category!r}")
202
+ return ESD_TEST_LEVEL_KV
203
+
204
+
205
+ def esd_stored_energy_joules(capacitance_pf, voltage_kv) -> float:
206
+ c = _num(capacitance_pf, "capacitance_pf")
207
+ v = _num(voltage_kv, "voltage_kv")
208
+ if c <= 0:
209
+ raise ValueError(f"capacitance_pf must be positive, got {c!r}")
210
+ if v < 0:
211
+ raise ValueError(f"voltage_kv must be >= 0, got {v!r}")
212
+ return 0.5 * (c * 1e-12) * (v * 1e3) ** 2
213
+
214
+
215
+ def esd_peak_current_amps(voltage_kv) -> float:
216
+ v = _num(voltage_kv, "voltage_kv")
217
+ if v <= 0:
218
+ raise ValueError(f"voltage_kv must be positive, got {v!r}")
219
+ return ESD_PEAK_A_PER_KV * v
220
+
221
+
222
+ def esd_current_30ns_amps(voltage_kv) -> float:
223
+ v = _num(voltage_kv, "voltage_kv")
224
+ if v <= 0:
225
+ raise ValueError(f"voltage_kv must be positive, got {v!r}")
226
+ return ESD_CURRENT_30NS_A_PER_KV * v
227
+
228
+
229
+ def esd_current_60ns_amps(voltage_kv) -> float:
230
+ v = _num(voltage_kv, "voltage_kv")
231
+ if v <= 0:
232
+ raise ValueError(f"voltage_kv must be positive, got {v!r}")
233
+ return ESD_CURRENT_60NS_A_PER_KV * v
234
+
235
+
236
+ def esd_rise_time_valid_ns(ns) -> bool:
237
+ t = _num(ns, "ns")
238
+ return ESD_RISE_MIN_NS <= t <= ESD_RISE_MAX_NS
239
+
240
+
241
+ def esd_rc_time_constant_ns(resistance_ohm, capacitance_pf) -> float:
242
+ r = _num(resistance_ohm, "resistance_ohm")
243
+ c = _num(capacitance_pf, "capacitance_pf")
244
+ if r <= 0:
245
+ raise ValueError(f"resistance_ohm must be positive, got {r!r}")
246
+ if c <= 0:
247
+ raise ValueError(f"capacitance_pf must be positive, got {c!r}")
248
+ return r * c * 1e-3 # ohm * pF -> ns
249
+
250
+
251
+ def esd_discharge_count_valid(positive_count, negative_count) -> bool:
252
+ for val, name in ((positive_count, "positive_count"),
253
+ (negative_count, "negative_count")):
254
+ if isinstance(val, bool) or not isinstance(val, int) or val < 0:
255
+ raise ValueError(f"{name} must be a non-negative int, got {val!r}")
256
+ return (positive_count >= ESD_MIN_DISCHARGES_PER_POLARITY and
257
+ negative_count >= ESD_MIN_DISCHARGES_PER_POLARITY)
258
+
259
+
260
+ def esd_test_point_applicable(accessible_normal_operation,
261
+ accessible_maintenance, connector_pin) -> bool:
262
+ _flag(accessible_normal_operation, "accessible_normal_operation")
263
+ _flag(accessible_maintenance, "accessible_maintenance")
264
+ _flag(connector_pin, "connector_pin")
265
+ return (accessible_normal_operation or accessible_maintenance) \
266
+ and not connector_pin
267
+
268
+
269
+ def esd_pass_verdict(operates_as_specified, no_permanent_degradation) -> bool:
270
+ _flag(operates_as_specified, "operates_as_specified")
271
+ _flag(no_permanent_degradation, "no_permanent_degradation")
272
+ return operates_as_specified and no_permanent_degradation
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Lightning Section 22/23 helpers (leaf-mirrored)
277
+ # ---------------------------------------------------------------------------
278
+
279
+ def lightning_level_valid(level) -> bool:
280
+ if not isinstance(level, int):
281
+ raise ValueError(f"test level must be an int, got {level!r}")
282
+ return LIGHTNING_LEVEL_MIN <= level <= LIGHTNING_LEVEL_MAX
283
+
284
+
285
+ def lightning_waveform_valid(waveform) -> bool:
286
+ if not isinstance(waveform, str):
287
+ raise ValueError(f"waveform must be a str, got {waveform!r}")
288
+ return waveform.upper() in LIGHTNING_WAVEFORMS
289
+
290
+
291
+ def lightning_waveforms_valid(waveforms) -> bool:
292
+ if isinstance(waveforms, str):
293
+ waveforms = [waveforms]
294
+ return all(lightning_waveform_valid(w) for w in waveforms)
295
+
296
+
297
+ def lightning_pass_verdict(physical_damage, upset, latch_up) -> bool:
298
+ for f, n in ((physical_damage, "physical_damage"),
299
+ (upset, "upset"), (latch_up, "latch_up")):
300
+ _flag(f, n)
301
+ return not physical_damage and not upset and not latch_up
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Power input Section 16 helpers (leaf-mirrored, data-driven envelopes)
306
+ # ---------------------------------------------------------------------------
307
+
308
+ def sag_depth_percent(nominal_v, sag_v) -> float:
309
+ n = _num(nominal_v, "nominal_v")
310
+ s = _num(sag_v, "sag_v")
311
+ if n <= 0:
312
+ raise ValueError(f"nominal_v must be positive, got {n!r}")
313
+ if s < 0:
314
+ raise ValueError(f"sag_v cannot be negative, got {s!r}")
315
+ if s > n:
316
+ raise ValueError(f"sag_v {s:.3f} exceeds nominal {n:.3f}")
317
+ return (n - s) / n * 100.0
318
+
319
+
320
+ def surge_height_percent(nominal_v, surge_v) -> float:
321
+ n = _num(nominal_v, "nominal_v")
322
+ s = _num(surge_v, "surge_v")
323
+ if n <= 0:
324
+ raise ValueError(f"nominal_v must be positive, got {n!r}")
325
+ if s < n:
326
+ raise ValueError(f"surge_v {s:.3f} below nominal {n:.3f}")
327
+ return (s - n) / n * 100.0
328
+
329
+
330
+ def frequency_deviation(measured_hz, nominal_hz):
331
+ m = _num(measured_hz, "measured_hz")
332
+ n = _num(nominal_hz, "nominal_hz")
333
+ if n <= 0:
334
+ raise ValueError(f"nominal_hz must be positive, got {n!r}")
335
+ dev = m - n
336
+ return (dev, dev / n * 100.0)
337
+
338
+
339
+ def frequency_within_tolerance(measured_hz, nominal_hz, tol_percent) -> bool:
340
+ m = _num(measured_hz, "measured_hz")
341
+ n = _num(nominal_hz, "nominal_hz")
342
+ t = _num(tol_percent, "tol_percent")
343
+ if n <= 0:
344
+ raise ValueError(f"nominal_hz must be positive, got {n!r}")
345
+ if t < 0:
346
+ raise ValueError(f"tol_percent cannot be negative, got {t!r}")
347
+ _, dev_pct = frequency_deviation(m, n)
348
+ return abs(dev_pct) <= t
349
+
350
+
351
+ def voltage_within_limits(voltage, v_min, v_max) -> bool:
352
+ v = _num(voltage, "voltage")
353
+ lo = _num(v_min, "v_min")
354
+ hi = _num(v_max, "v_max")
355
+ if hi <= lo:
356
+ raise ValueError(f"v_max {hi:.3f} must exceed v_min {lo:.3f}")
357
+ return lo <= v <= hi
358
+
359
+
360
+ def limits_margins(voltage, v_min, v_max):
361
+ v = _num(voltage, "voltage")
362
+ lo = _num(v_min, "v_min")
363
+ hi = _num(v_max, "v_max")
364
+ if hi <= lo:
365
+ raise ValueError(f"v_max {hi:.3f} must exceed v_min {lo:.3f}")
366
+ return (v - lo, hi - v)
367
+
368
+
369
+ def transient_recovery_ok(recovery_ms, allowable_ms) -> bool:
370
+ r = _num(recovery_ms, "recovery_ms")
371
+ a = _num(allowable_ms, "allowable_ms")
372
+ if a < 0:
373
+ raise ValueError(f"allowable_ms cannot be negative, got {a!r}")
374
+ if r < 0:
375
+ raise ValueError(f"recovery_ms cannot be negative, got {r!r}")
376
+ return r <= a
377
+
378
+
379
+ def transient_check(duration_ms, depth_percent, max_duration_ms,
380
+ max_depth_percent):
381
+ d = _num(duration_ms, "duration_ms")
382
+ dp = _num(depth_percent, "depth_percent")
383
+ md = _num(max_duration_ms, "max_duration_ms")
384
+ mdp = _num(max_depth_percent, "max_depth_percent")
385
+ for name, val in (("duration_ms", d), ("depth_percent", dp),
386
+ ("max_duration_ms", md), ("max_depth_percent", mdp)):
387
+ if val < 0:
388
+ raise ValueError(f"{name} cannot be negative, got {val!r}")
389
+ margin_dur = md - d
390
+ margin_depth = mdp - dp
391
+ return (margin_dur >= 0 and margin_depth >= 0, margin_dur, margin_depth)
392
+
393
+
394
+ def ripple_percent(v_peak, v_min, nominal_v) -> float:
395
+ p = _num(v_peak, "v_peak")
396
+ lo = _num(v_min, "v_min")
397
+ n = _num(nominal_v, "nominal_v")
398
+ if n <= 0:
399
+ raise ValueError(f"nominal_v must be positive, got {n!r}")
400
+ if p < lo:
401
+ raise ValueError(f"v_peak {p:.3f} below v_min {lo:.3f}")
402
+ return ((p - lo) / 2.0) / n * 100.0
403
+
404
+
405
+ def emergency_range_check(voltage, normal_min, normal_max,
406
+ emergency_min, emergency_max) -> str:
407
+ v = _num(voltage, "voltage")
408
+ n_lo = _num(normal_min, "normal_min")
409
+ n_hi = _num(normal_max, "normal_max")
410
+ e_lo = _num(emergency_min, "emergency_min")
411
+ e_hi = _num(emergency_max, "emergency_max")
412
+ if n_hi <= n_lo:
413
+ raise ValueError(f"normal_max {n_hi:.3f} must exceed normal_min {n_lo:.3f}")
414
+ if e_lo > n_lo or e_hi < n_hi:
415
+ raise ValueError("emergency band must contain normal band")
416
+ if voltage_within_limits(v, n_lo, n_hi):
417
+ return "normal"
418
+ if voltage_within_limits(v, e_lo, e_hi):
419
+ return "emergency-only"
420
+ return "out-of-range"
421
+
422
+
423
+ # ---------------------------------------------------------------------------
424
+ # RF susceptibility Section 20 helpers (leaf-mirrored)
425
+ # ---------------------------------------------------------------------------
426
+
427
+ def wavelength_from_frequency(frequency_hz) -> float:
428
+ f = _num(frequency_hz, "frequency_hz")
429
+ if f <= 0:
430
+ raise ValueError(f"frequency must be > 0, got {f!r}")
431
+ return SPEED_OF_LIGHT / f
432
+
433
+
434
+ def far_field_boundary(antenna_aperture_m, wavelength_m) -> float:
435
+ a = _num(antenna_aperture_m, "antenna_aperture_m")
436
+ w = _num(wavelength_m, "wavelength_m")
437
+ if a < 0:
438
+ raise ValueError(f"aperture must be >= 0, got {a!r}")
439
+ if w <= 0:
440
+ raise ValueError(f"wavelength must be > 0, got {w!r}")
441
+ return 2.0 * a * a / w
442
+
443
+
444
+ def vm_from_dbu_vm(dbu_vm) -> float:
445
+ return 10.0 ** (dbu_vm / 20.0) * 1e-6
446
+
447
+
448
+ def dbu_vm_from_vm(field_vm) -> float:
449
+ f = _num(field_vm, "field_vm")
450
+ if f < 0:
451
+ raise ValueError(f"field must be >= 0, got {f!r}")
452
+ if f == 0:
453
+ raise ValueError("field must be > 0 to express in dB, got 0")
454
+ return 20.0 * math.log10(f / 1e-6)
455
+
456
+
457
+ def field_strength_from_power(power_w, antenna_gain, distance_m) -> float:
458
+ p = _num(power_w, "power_w")
459
+ g = _num(antenna_gain, "antenna_gain")
460
+ d = _num(distance_m, "distance_m")
461
+ if p < 0:
462
+ raise ValueError(f"power must be >= 0, got {p!r}")
463
+ if g < 0:
464
+ raise ValueError(f"gain must be >= 0, got {g!r}")
465
+ if d <= 0:
466
+ raise ValueError(f"distance must be > 0, got {d!r}")
467
+ return math.sqrt(30.0 * p * g) / d
468
+
469
+
470
+ def power_for_field_strength(field_vm, antenna_gain, distance_m) -> float:
471
+ e = _num(field_vm, "field_vm")
472
+ g = _num(antenna_gain, "antenna_gain")
473
+ d = _num(distance_m, "distance_m")
474
+ if e < 0:
475
+ raise ValueError(f"field must be >= 0, got {e!r}")
476
+ if g < 0:
477
+ raise ValueError(f"gain must be >= 0, got {g!r}")
478
+ if d <= 0:
479
+ raise ValueError(f"distance must be > 0, got {d!r}")
480
+ if g == 0:
481
+ raise ValueError("gain must be > 0 to compute power, got 0")
482
+ return e * e * d * d / (30.0 * g)
483
+
484
+
485
+ def field_with_margin_db(field_vm, margin_db) -> float:
486
+ f = _num(field_vm, "field_vm")
487
+ if f < 0:
488
+ raise ValueError(f"field must be >= 0, got {f!r}")
489
+ return f * 10.0 ** (margin_db / 20.0)
490
+
491
+
492
+ def gain_db_to_linear(gain_db) -> float:
493
+ return 10.0 ** (gain_db / 10.0)
494
+
495
+
496
+ def amplifier_power_with_cable_loss(antenna_power_w, cable_loss_db) -> float:
497
+ p = _num(antenna_power_w, "antenna_power_w")
498
+ if p < 0:
499
+ raise ValueError(f"antenna power must be >= 0, got {p!r}")
500
+ return p * 10.0 ** (cable_loss_db / 10.0)
501
+
502
+
503
+ def apply_margin_db(power_w, margin_db) -> float:
504
+ p = _num(power_w, "power_w")
505
+ if p < 0:
506
+ raise ValueError(f"power must be >= 0, got {p!r}")
507
+ return p * 10.0 ** (margin_db / 10.0)
508
+
509
+
510
+ def required_amp_power_for_test(field_vm, distance_m, antenna_gain_db,
511
+ cable_loss_db, margin_db) -> float:
512
+ """Full RS103 calibration budget: far-field power, cable loss, margin."""
513
+ base = power_for_field_strength(field_vm, gain_db_to_linear(antenna_gain_db),
514
+ distance_m)
515
+ return apply_margin_db(amplifier_power_with_cable_loss(base, cable_loss_db),
516
+ margin_db)
517
+
518
+
519
+ def cs114_category_offset(category) -> float:
520
+ key = str(category).upper()
521
+ if key not in _CS114_OFFSETS:
522
+ raise ValueError(f"CS114 category must be one of "
523
+ f"{sorted(_CS114_OFFSETS)}, got {category!r}")
524
+ return _CS114_OFFSETS[key]
525
+
526
+
527
+ def cs114_limit_dbu_a(category,
528
+ base_limit_dbu_a=CS114_BASE_LIMIT_DBU_A) -> float:
529
+ return base_limit_dbu_a + cs114_category_offset(category)
530
+
531
+
532
+ def margin_check_dbu(measured_dbu, limit_dbu):
533
+ margin = limit_dbu - measured_dbu
534
+ return margin, margin >= 0
535
+
536
+
537
+ # ---------------------------------------------------------------------------
538
+ # RF emissions Section 21 helpers (leaf-mirrored, reference-only limits)
539
+ # ---------------------------------------------------------------------------
540
+
541
+ def dbu_v_from_volts(volts) -> float:
542
+ v = _num(volts, "volts")
543
+ if v <= 0:
544
+ raise ValueError(f"amplitude must be > 0, got {v!r}")
545
+ return 20.0 * math.log10(v / 1e-6)
546
+
547
+
548
+ def dbu_v_per_m_from_v_per_m(volts_per_m) -> float:
549
+ e = _num(volts_per_m, "volts_per_m")
550
+ if e <= 0:
551
+ raise ValueError(f"field must be > 0, got {e!r}")
552
+ return 20.0 * math.log10(e / 1e-6)
553
+
554
+
555
+ def ce102_limit_db(freq_hz, category) -> float:
556
+ key = str(category).upper()
557
+ if key not in RE102_FLOOR_DBU_VPM:
558
+ raise ValueError(f"category must be one of {sorted(RE102_FLOOR_DBU_VPM)}, "
559
+ f"got {category!r}")
560
+ f = _num(freq_hz, "freq_hz")
561
+ if f < CE102_BAND_LO_HZ or f > CE102_BAND_HI_HZ:
562
+ raise ValueError(f"CE102 limit band is 10 kHz to 10 MHz, got {f!r} Hz")
563
+ for lo, hi, limit in _CE102_BAND_LIMITS_DBU_V:
564
+ if f < hi:
565
+ return limit
566
+ return _CE102_BAND_LIMITS_DBU_V[-1][2]
567
+
568
+
569
+ def re102_limit_db(freq_hz, category) -> float:
570
+ key = str(category).upper()
571
+ if key not in RE102_FLOOR_DBU_VPM:
572
+ raise ValueError(f"category must be one of {sorted(RE102_FLOOR_DBU_VPM)}, "
573
+ f"got {category!r}")
574
+ f = _num(freq_hz, "freq_hz")
575
+ if f < RE102_BAND_LO_HZ or f > RE102_BAND_HI_HZ:
576
+ raise ValueError(f"RE102 limit band is 2 MHz to 18 GHz, got {f!r} Hz")
577
+ return RE102_FLOOR_DBU_VPM[key]
578
+
579
+
580
+ def emission_margins(measured_db_levels, freq_hz_list, kind, category):
581
+ """margin_db per point: limit - measured over a sweep (both kinds)."""
582
+ key = str(kind).lower()
583
+ margins = []
584
+ for m, f in zip(measured_db_levels, freq_hz_list):
585
+ if key in ("conducted", "ce102"):
586
+ limit = ce102_limit_db(f, category)
587
+ elif key in ("radiated", "re102"):
588
+ limit = re102_limit_db(f, category)
589
+ else:
590
+ raise ValueError(f"kind must be conducted/CE102 or radiated/RE102, "
591
+ f"got {kind!r}")
592
+ margins.append(limit - _num(m, "measured_db_levels"))
593
+ return margins
594
+
595
+
596
+ def worst_case_frequency(freqs, margins):
597
+ if len(freqs) == 0 or len(margins) == 0:
598
+ raise ValueError("frequency and margin arrays must not be empty")
599
+ if len(freqs) != len(margins):
600
+ raise ValueError("frequency and margin arrays must match")
601
+ idx = min(range(len(margins)), key=margins.__getitem__)
602
+ return freqs[idx], margins[idx]
603
+
604
+
605
+ def emission_verdict(margins, freq_hz, category, kind) -> dict:
606
+ worst_freq, worst_margin = worst_case_frequency(list(freq_hz),
607
+ list(margins))
608
+ return {
609
+ "pass": worst_margin >= 0.0,
610
+ "worst_margin_db": round(float(worst_margin), 2),
611
+ "worst_frequency_hz": worst_freq,
612
+ "category": str(category).upper(),
613
+ "kind": kind,
614
+ }
615
+
616
+
617
+ def field_strength_from_erp(erp_w, distance_m) -> float:
618
+ p = _num(erp_w, "erp_w")
619
+ d = _num(distance_m, "distance_m")
620
+ if p < 0:
621
+ raise ValueError(f"ERP must be >= 0, got {p!r}")
622
+ if d <= 0:
623
+ raise ValueError(f"distance must be > 0, got {d!r}")
624
+ return math.sqrt(30.0 * p) / d
625
+
626
+
627
+ # ---------------------------------------------------------------------------
628
+ # Item facts and qualification-plan builder
629
+ # ---------------------------------------------------------------------------
630
+
631
+ @dataclass
632
+ class EquipmentItem:
633
+ """Project facts the role needs to build the qualification plan."""
634
+ item_name: str
635
+ description: str = ""
636
+ installation: str = "" # location drives category selection
637
+ expected_low_c: float = -40.0 # expected operating extremes
638
+ expected_high_c: float = 55.0
639
+ temperature_category: str = "" # '' -> auto-select from extremes
640
+ certification_basis: str = "FAR/CS-25"
641
+ do160_revision: str = "DO-160G"
642
+ # lightning (Section 22 induced / 23 direct effects)
643
+ lightning_level: int = 3
644
+ lightning_waveforms: list = field(default_factory=lambda: ["A", "B", "C", "H"])
645
+ # ESD (Section 25) - single category A; discharges per polarity
646
+ esd_positive_count: int = 10
647
+ esd_negative_count: int = 10
648
+ # power input (Section 16) envelope + measured bench values
649
+ power_nominal_v: float = 28.0
650
+ power_normal_min_v: float = 22.0
651
+ power_normal_max_v: float = 29.0
652
+ power_emergency_min_v: float = 18.0
653
+ power_emergency_max_v: float = 32.2
654
+ power_measured_v: float = 27.5
655
+ sag_trough_v: float = 22.4 # measured sag trough
656
+ sag_duration_ms: float = 80.0
657
+ sag_env_duration_ms: float = 100.0
658
+ sag_env_depth_pct: float = 25.0
659
+ recovery_ms: float = 60.0
660
+ recovery_allowable_ms: float = 100.0
661
+ ripple_peak_v: float = 29.0
662
+ ripple_min_v: float = 27.0
663
+ emergency_rated: bool = True
664
+ ac_frequency_hz: float = 0.0 # 0 => no AC input (DC-only bus)
665
+ # RF susceptibility (Section 20)
666
+ rs103_field_vm: float = 100.0
667
+ rs103_distance_m: float = 3.0
668
+ antenna_gain_db: float = 3.0
669
+ cable_loss_db: float = 3.0
670
+ margin_db: float = 6.0
671
+ cs114_category: str = "C"
672
+ cs114_measured_dbu_a: float = 60.0
673
+ # RF emissions (Section 21) - reference-only floors
674
+ re102_category: str = "A"
675
+ re102_measured: list = field(default_factory=lambda: [15.0, 20.0, 18.0])
676
+ re102_freqs_hz: list = field(default_factory=lambda: [10e6, 100e6, 1e9])
677
+ ce102_measured: list = field(default_factory=lambda: [68.0, 54.0, 66.0])
678
+ ce102_freqs_hz: list = field(default_factory=lambda: [50e3, 150e3, 5e6])
679
+
680
+
681
+ def _fmt(x, nd: int = 2) -> str:
682
+ return f"{x:.{nd}f}"
683
+
684
+
685
+ def _freq_hz(x) -> str:
686
+ """Human-readable frequency label (kHz/MHz/GHz) for a Hz value."""
687
+ if x >= 1e9:
688
+ v = x / 1e9
689
+ return f"{v:g} GHz"
690
+ if x >= 1e6:
691
+ v = x / 1e6
692
+ return f"{v:g} MHz"
693
+ if x >= 1e3:
694
+ v = x / 1e3
695
+ return f"{v:g} kHz"
696
+ return f"{x:g} Hz"
697
+
698
+
699
+ def build_qualification_report(item: EquipmentItem) -> dict:
700
+ """Build the complete Equipment Environmental Qualification Plan/Report
701
+ content model from the LRU's project facts."""
702
+ category = item.temperature_category or select_temperature_category(
703
+ item.expected_low_c, item.expected_high_c)
704
+ tlo, thi = temperature_category_range(category)
705
+
706
+ # --- test matrix -------------------------------------------------------
707
+ planned = required_sections(category) # full leaf section set
708
+ missing, matrix_ok = matrix_complete(planned, category)
709
+ matrix_rows = [{"section": s, "name": section_name(s)} for s in planned]
710
+
711
+ # --- ESD (Section 25) --------------------------------------------------
712
+ esd_kv = esd_category_test_level_kv("A")
713
+ esd_energy = esd_stored_energy_joules(ESD_CAPACITANCE_PF, esd_kv)
714
+ esd_i_peak = esd_peak_current_amps(esd_kv)
715
+ esd_i_30 = esd_current_30ns_amps(esd_kv)
716
+ esd_i_60 = esd_current_60ns_amps(esd_kv)
717
+ esd_tau = esd_rc_time_constant_ns(ESD_RESISTANCE_OHM, ESD_CAPACITANCE_PF)
718
+ esd_counts_ok = esd_discharge_count_valid(item.esd_positive_count,
719
+ item.esd_negative_count)
720
+ esd_verdict = esd_pass_verdict(True, True)
721
+
722
+ # --- lightning ----------------------------------------------------------
723
+ lvl_ok = lightning_level_valid(item.lightning_level)
724
+ wf_ok = lightning_waveforms_valid(item.lightning_waveforms)
725
+ lt_verdict = lightning_pass_verdict(False, False, False)
726
+
727
+ # --- power input ---------------------------------------------------------
728
+ steady_ok = voltage_within_limits(item.power_measured_v,
729
+ item.power_normal_min_v,
730
+ item.power_normal_max_v)
731
+ (margin_low, margin_high) = limits_margins(item.power_measured_v,
732
+ item.power_normal_min_v,
733
+ item.power_normal_max_v)
734
+ sag_pct = sag_depth_percent(item.power_nominal_v, item.sag_trough_v)
735
+ trans_ok, trans_margin_ms, trans_margin_pct = transient_check(
736
+ item.sag_duration_ms, sag_pct,
737
+ item.sag_env_duration_ms, item.sag_env_depth_pct)
738
+ recovery_ok = transient_recovery_ok(item.recovery_ms,
739
+ item.recovery_allowable_ms)
740
+ rip_pct = ripple_percent(item.ripple_peak_v, item.ripple_min_v,
741
+ item.power_nominal_v)
742
+ if item.emergency_rated:
743
+ emergency_class = emergency_range_check(
744
+ item.power_measured_v, item.power_normal_min_v,
745
+ item.power_normal_max_v, item.power_emergency_min_v,
746
+ item.power_emergency_max_v)
747
+ else:
748
+ emergency_class = "not-rated"
749
+ freq_row = None
750
+ if item.ac_frequency_hz > 0:
751
+ dev_hz, dev_pct = frequency_deviation(
752
+ item.ac_frequency_hz, 400.0)
753
+ within = frequency_within_tolerance(item.ac_frequency_hz, 400.0, 5.0)
754
+ freq_row = {"nominal_hz": 400.0, "measured_hz": item.ac_frequency_hz,
755
+ "dev_hz": dev_hz, "dev_pct": dev_pct,
756
+ "tolerance_pct": 5.0, "within": within}
757
+ power_verdict = (steady_ok and trans_ok and recovery_ok
758
+ and emergency_class != "out-of-range")
759
+
760
+ # --- RF susceptibility ----------------------------------------------------
761
+ amp_w = required_amp_power_for_test(item.rs103_field_vm,
762
+ item.rs103_distance_m,
763
+ item.antenna_gain_db,
764
+ item.cable_loss_db,
765
+ item.margin_db)
766
+ cal_field = field_with_margin_db(item.rs103_field_vm, item.margin_db)
767
+ cs114_limit = cs114_limit_dbu_a(item.cs114_category)
768
+ cs114_margin, cs114_ok = margin_check_dbu(item.cs114_measured_dbu_a,
769
+ cs114_limit)
770
+ lam_1ghz = wavelength_from_frequency(1e9)
771
+
772
+ # --- RF emissions ----------------------------------------------------------
773
+ re102_limits = [re102_limit_db(f, item.re102_category)
774
+ for f in item.re102_freqs_hz]
775
+ re102_margins = emission_margins(item.re102_measured,
776
+ item.re102_freqs_hz, "re102",
777
+ item.re102_category)
778
+ re102_wf, re102_wm = worst_case_frequency(item.re102_freqs_hz,
779
+ re102_margins)
780
+ re102_v = emission_verdict(re102_margins, item.re102_freqs_hz,
781
+ item.re102_category, "RE102")
782
+ ce102_margins = emission_margins(item.ce102_measured,
783
+ item.ce102_freqs_hz, "ce102",
784
+ item.re102_category)
785
+ ce102_wf, ce102_wm = worst_case_frequency(item.ce102_freqs_hz,
786
+ ce102_margins)
787
+ ce102_v = emission_verdict(ce102_margins, item.ce102_freqs_hz,
788
+ item.re102_category, "CE102")
789
+
790
+ verdicts = {
791
+ "esd": bool(esd_verdict),
792
+ "lightning": bool(lt_verdict),
793
+ "power_input": bool(power_verdict),
794
+ "cs114_conducted": bool(cs114_ok),
795
+ "re102_radiated": bool(re102_v["pass"]),
796
+ "ce102_conducted": bool(ce102_v["pass"]),
797
+ }
798
+
799
+ return {
800
+ "document_type": "Equipment Environmental Qualification Plan/Report",
801
+ "status": "draft-for-review",
802
+ "item": item.item_name,
803
+ "item_description": item.description,
804
+ "installation": item.installation,
805
+ "certification_basis": item.certification_basis,
806
+ "do160_revision": item.do160_revision,
807
+ "expected_temperature_c": [item.expected_low_c, item.expected_high_c],
808
+ "temperature_category": category,
809
+ "temperature_range_c": [tlo, thi],
810
+ "matrix": matrix_rows,
811
+ "matrix_missing": missing,
812
+ "matrix_complete": matrix_ok,
813
+ "esd": {
814
+ "category": ESD_CATEGORY,
815
+ "test_level_kv": esd_kv,
816
+ "capacitance_pf": ESD_CAPACITANCE_PF,
817
+ "resistance_ohm": ESD_RESISTANCE_OHM,
818
+ "stored_energy_j": round(esd_energy, 9),
819
+ "peak_current_a": round(esd_i_peak, 2),
820
+ "current_30ns_a": round(esd_i_30, 2),
821
+ "current_60ns_a": round(esd_i_60, 2),
822
+ "rise_window_ns": [ESD_RISE_MIN_NS, ESD_RISE_MAX_NS],
823
+ "tau_ns": round(esd_tau, 1),
824
+ "discharges": [item.esd_positive_count, item.esd_negative_count],
825
+ "counts_valid": esd_counts_ok,
826
+ "verdict": esd_verdict,
827
+ },
828
+ "lightning": {
829
+ "level": item.lightning_level,
830
+ "level_valid": lvl_ok,
831
+ "waveforms": list(item.lightning_waveforms),
832
+ "waveforms_valid": wf_ok,
833
+ "verdict": lt_verdict,
834
+ "waveforms_available": sorted(LIGHTNING_WAVEFORMS),
835
+ },
836
+ "power_input": {
837
+ "bus": f"{item.power_nominal_v:.0f} VDC",
838
+ "normal_range_v": [item.power_normal_min_v,
839
+ item.power_normal_max_v],
840
+ "emergency_range_v": [item.power_emergency_min_v,
841
+ item.power_emergency_max_v],
842
+ "steady_measured_v": item.power_measured_v,
843
+ "steady_within_limits": steady_ok,
844
+ "margins_v": [round(margin_low, 1), round(margin_high, 1)],
845
+ "sag_depth_pct": round(sag_pct, 1),
846
+ "sag_event_duration_ms": item.sag_duration_ms,
847
+ "sag_env": [item.sag_env_duration_ms, item.sag_env_depth_pct],
848
+ "transient_within": trans_ok,
849
+ "transient_margins": [round(trans_margin_ms, 1),
850
+ round(trans_margin_pct, 1)],
851
+ "recovery_ms": item.recovery_ms,
852
+ "recovery_allowable_ms": item.recovery_allowable_ms,
853
+ "recovery_ok": recovery_ok,
854
+ "ripple_pct": round(rip_pct, 2),
855
+ "emergency_rated": item.emergency_rated,
856
+ "emergency_classification": emergency_class,
857
+ "ac_frequency": freq_row,
858
+ },
859
+ "rf_susceptibility": {
860
+ "rs103_field_vm": item.rs103_field_vm,
861
+ "rs103_cal_field_vm": round(cal_field, 1),
862
+ "amplifier_power_w": round(amp_w, 0),
863
+ "setup": {"distance_m": item.rs103_distance_m,
864
+ "antenna_gain_db": item.antenna_gain_db,
865
+ "cable_loss_db": item.cable_loss_db,
866
+ "margin_db": item.margin_db},
867
+ "cs114_category": item.cs114_category,
868
+ "cs114_limit_dbu_a": round(cs114_limit, 1),
869
+ "cs114_measured_dbu_a": item.cs114_measured_dbu_a,
870
+ "cs114_margin_db": round(cs114_margin, 1),
871
+ "cs114_within": cs114_ok,
872
+ "wavelength_1ghz_m": round(lam_1ghz, 4),
873
+ },
874
+ "rf_emissions": {
875
+ "re102_category": item.re102_category,
876
+ "re102_floor_dbu_vpm": RE102_FLOOR_DBU_VPM[item.re102_category],
877
+ "re102_measured_dbu_vpm": item.re102_measured,
878
+ "re102_freqs_hz": item.re102_freqs_hz,
879
+ "re102_limits_dbu_vpm": re102_limits,
880
+ "re102_margins_db": [round(m, 1) for m in re102_margins],
881
+ "re102_worst": {"freq_hz": re102_wf,
882
+ "margin_db": round(re102_wm, 1)},
883
+ "re102_verdict": re102_v,
884
+ "ce102_measured_dbu_v": item.ce102_measured,
885
+ "ce102_freqs_hz": item.ce102_freqs_hz,
886
+ "ce102_margins_db": [round(m, 1) for m in ce102_margins],
887
+ "ce102_worst": {"freq_hz": ce102_wf,
888
+ "margin_db": round(ce102_wm, 1)},
889
+ "ce102_verdict": ce102_v,
890
+ },
891
+ "verdicts": verdicts,
892
+ "generated": _today(),
893
+ }
894
+
895
+
896
+ build_report = build_qualification_report # public alias used by cli/docs
897
+
898
+
899
+ def render_qualification_report_markdown(model: dict) -> str:
900
+ """Render the qualification report content model as the deliverable."""
901
+ esd = model["esd"]
902
+ ltg = model["lightning"]
903
+ pw = model["power_input"]
904
+ rf = model["rf_susceptibility"]
905
+ em = model["rf_emissions"]
906
+ tlo, thi = model["temperature_range_c"]
907
+
908
+ lines = [
909
+ "# Equipment Environmental Qualification Plan/Report",
910
+ "",
911
+ f"**Item:** {model['item']}",
912
+ f"**Installation:** {model['installation']}",
913
+ f"**Certification basis:** {model['certification_basis']}",
914
+ f"**Standard:** {model['do160_revision']} "
915
+ "(environmental conditions and test procedures for airborne "
916
+ "equipment)",
917
+ f"**Status:** {model['status']}",
918
+ "",
919
+ "## 1. Scope",
920
+ "",
921
+ f"This plan/report covers environmental qualification of the LRU "
922
+ f"{model['item']}."
923
+ + (f" {model['item_description']}" if model["item_description"] else ""),
924
+ f"Expected operating temperature extremes: "
925
+ f"{model['expected_temperature_c'][0]:.0f} to "
926
+ f"{model['expected_temperature_c'][1]:.0f} deg C.",
927
+ "",
928
+ "## 2. Environmental categories",
929
+ "",
930
+ f"- Temperature/altitude category: {model['temperature_category']} "
931
+ f"(typical operating range {tlo:.0f} to {thi:.0f} deg C; typical "
932
+ "reference data, confirm against the current revision).",
933
+ f"- ESD category: {esd['category']} (single category per "
934
+ "Section 25).",
935
+ f"- RF emissions installation category: {em['re102_category']} "
936
+ "(reference-only RE102 floor model).",
937
+ f"- Lightning: Section 22 test level {ltg['level']}, waveform set "
938
+ f"{', '.join(ltg['waveforms'])}.",
939
+ f"- Power input: {pw['bus']} bus, Section 16 envelope (see "
940
+ "Section 6).",
941
+ "",
942
+ "## 3. Qualification test matrix",
943
+ "",
944
+ "Required test-condition sections for the equipment category "
945
+ f"({model['temperature_category']}) and planned coverage:",
946
+ "",
947
+ "| Section | Test condition | Planned |",
948
+ "|---|---|---|",
949
+ *[f"| {row['section']} | {row['name']} | yes |"
950
+ for row in model["matrix"]],
951
+ "",
952
+ "Matrix completeness: "
953
+ + ("COMPLETE - no required section missing."
954
+ if model["matrix_complete"]
955
+ else "INCOMPLETE - missing: " + str(model["matrix_missing"])),
956
+ "Category-specific section exclusions must be confirmed against "
957
+ "the current revision before freezing the matrix.",
958
+ "",
959
+ "## 4. Electrostatic discharge (Section 25)",
960
+ "",
961
+ f"- Equipment category: {esd['category']} (the only Section 25 "
962
+ "category).",
963
+ f"- Test level: {esd['test_level_kv']:.0f} kV air discharge on the "
964
+ "equipment bonded to the ground plane.",
965
+ f"- Discharge generator (IEC 61000-4-2 human-body model): "
966
+ f"{esd['capacitance_pf']:.0f} pF storage capacitance, "
967
+ f"{esd['resistance_ohm']:.0f} ohm discharge resistance.",
968
+ f"- Stored energy at test level: {esd['stored_energy_j']:.6f} J "
969
+ f"({esd['stored_energy_j'] * 1000:.3f} mJ).",
970
+ f"- Waveform: first peak {esd['peak_current_a']:.2f} A, "
971
+ f"{esd['current_30ns_a']:.2f} A at 30 ns, "
972
+ f"{esd['current_60ns_a']:.2f} A at 60 ns; rise time "
973
+ f"{esd['rise_window_ns'][0]:.1f} to {esd['rise_window_ns'][1]:.1f} ns; "
974
+ f"RC time constant {esd['tau_ns']:.1f} ns.",
975
+ f"- Discharges per test point: {esd['discharges'][0]} positive and "
976
+ f"{esd['discharges'][1]} negative "
977
+ f"(valid: {str(esd['counts_valid']).lower()}).",
978
+ "- Test points: surfaces accessible to personnel during normal "
979
+ "operation or maintenance; connector pins are not applicable test "
980
+ "points.",
981
+ "- Verdict (planned): "
982
+ + ("PASS - operates as specified with no permanent degradation"
983
+ if esd["verdict"] else "FAIL") + ".",
984
+ "",
985
+ "## 5. Lightning protection (Sections 22/23)",
986
+ "",
987
+ f"- Section 22 induced transient susceptibility test level: "
988
+ f"{ltg['level']} (valid range "
989
+ f"{LIGHTNING_LEVEL_MIN}-{LIGHTNING_LEVEL_MAX}: "
990
+ f"{str(ltg['level_valid']).lower()}).",
991
+ f"- Waveform set: {', '.join(ltg['waveforms'])} (letters within "
992
+ f"A-H: {str(ltg['waveforms_valid']).lower()}).",
993
+ "- Section 22/23 pass criteria: no physical damage, no upset, no "
994
+ "latch-up after the applied transients.",
995
+ f"- Verdict (planned): {'PASS' if ltg['verdict'] else 'FAIL'}.",
996
+ "- Level and waveform tables are standard data in the current "
997
+ "revision; verify selection before freezing the plan.",
998
+ "",
999
+ "## 6. Power input (Section 16)",
1000
+ "",
1001
+ f"- Bus: {pw['bus']}; Section 16 category envelope values applied "
1002
+ "from the current revision (data-driven, summary reference).",
1003
+ f"- Normal steady-state range: {pw['normal_range_v'][0]:.1f} to "
1004
+ f"{pw['normal_range_v'][1]:.1f} V. Emergency range: "
1005
+ f"{pw['emergency_range_v'][0]:.1f} to "
1006
+ f"{pw['emergency_range_v'][1]:.1f} V "
1007
+ f"(emergency-rated: {str(pw['emergency_rated']).lower()}).",
1008
+ f"- Measured steady state {pw['steady_measured_v']:.1f} V: "
1009
+ f"{'within' if pw['steady_within_limits'] else 'OUTSIDE'} normal "
1010
+ f"range; margins "
1011
+ f"{pw['margins_v'][0]:.1f} V low / {pw['margins_v'][1]:.1f} V high.",
1012
+ f"- Measured sag {pw['sag_depth_pct']:.1f}% of nominal "
1013
+ f"({pw['sag_event_duration_ms']:.0f} ms event vs "
1014
+ f"{pw['sag_env'][1]:.0f}% max / {pw['sag_env'][0]:.0f} ms envelope): "
1015
+ f"{'within' if pw['transient_within'] else 'OUTSIDE'} envelope; "
1016
+ f"margins {pw['transient_margins'][0]:.1f} ms, "
1017
+ f"{pw['transient_margins'][1]:.1f}%.",
1018
+ f"- Recovery after transient {pw['recovery_ms']:.0f} ms vs "
1019
+ f"{pw['recovery_allowable_ms']:.0f} ms allowable: "
1020
+ f"{'within' if pw['recovery_ok'] else 'EXCEEDS'}.",
1021
+ f"- Ripple: {pw['ripple_pct']:.2f}% of nominal (characterization).",
1022
+ f"- Emergency classification of measured bus: "
1023
+ f"{pw['emergency_classification']}.",
1024
+ ]
1025
+ if pw.get("ac_frequency"):
1026
+ fr = pw["ac_frequency"]
1027
+ lines += [
1028
+ f"- AC frequency: {fr['measured_hz']:.0f} Hz vs "
1029
+ f"{fr['nominal_hz']:.0f} Hz nominal ({fr['tolerance_pct']:.0f}% "
1030
+ f"tolerance): deviation {fr['dev_hz']:.1f} Hz "
1031
+ f"({fr['dev_pct']:.1f}%) - "
1032
+ f"{'within' if fr['within'] else 'OUTSIDE'} tolerance.",
1033
+ ]
1034
+ lines += [
1035
+ "",
1036
+ "## 7. Radio frequency susceptibility (Section 20)",
1037
+ "",
1038
+ f"- RS103 radiated test field: {rf['rs103_field_vm']:.0f} V/m "
1039
+ f"(typical mid-range category value; verify against the current "
1040
+ "revision).",
1041
+ f"- Calibration field with {rf['setup']['margin_db']:.0f} dB margin: "
1042
+ f"{rf['rs103_cal_field_vm']:.1f} V/m.",
1043
+ f"- Amplifier budget for {rf['setup']['distance_m']:.0f} m, "
1044
+ f"{rf['setup']['antenna_gain_db']:.0f} dBi, "
1045
+ f"{rf['setup']['cable_loss_db']:.0f} dB cable loss, "
1046
+ f"{rf['setup']['margin_db']:.0f} dB margin: "
1047
+ f"{rf['amplifier_power_w']:.0f} W.",
1048
+ f"- Far-field check: wavelength at 1 GHz is "
1049
+ f"{rf['wavelength_1ghz_m']:.4f} m; the far-field relation "
1050
+ "E = sqrt(30*P*G)/d holds beyond the Fraunhofer distance.",
1051
+ f"- CS114 conducted category {rf['cs114_category']}: limit "
1052
+ f"{rf['cs114_limit_dbu_a']:.1f} dBuA (summary reference base); "
1053
+ f"measured {rf['cs114_measured_dbu_a']:.1f} dBuA gives margin "
1054
+ f"{rf['cs114_margin_db']:.1f} dB "
1055
+ f"({'within' if rf['cs114_within'] else 'OUTSIDE'} limit).",
1056
+ "",
1057
+ "## 8. Radio frequency emissions (Section 21)",
1058
+ "",
1059
+ f"- RE102 radiated, installation category {em['re102_category']} "
1060
+ f"(reference-only floor {em['re102_floor_dbu_vpm']:.0f} dBuV/m, "
1061
+ "2 MHz to 18 GHz):",
1062
+ " - Measured sweep: "
1063
+ + ", ".join(f"{m:.1f} dBuV/m at {_freq_hz(f)}"
1064
+ for m, f in zip(em["re102_measured_dbu_vpm"],
1065
+ em["re102_freqs_hz"]))
1066
+ + ".",
1067
+ " - Margins: "
1068
+ + ", ".join(f"{m:+.1f} dB" for m in em["re102_margins_db"])
1069
+ + f"; worst case {_freq_hz(em['re102_worst']['freq_hz'])} at "
1070
+ f"{em['re102_worst']['margin_db']:+.1f} dB.",
1071
+ f" - Verdict: {'PASS' if em['re102_verdict']['pass'] else 'FAIL'} "
1072
+ "(minimum margin >= 0 dB).",
1073
+ f"- CE102 conducted (10 kHz to 10 MHz, reference-only band curve):",
1074
+ " - Measured sweep: "
1075
+ + ", ".join(f"{m:.1f} dBuV at {_freq_hz(f)}"
1076
+ for m, f in zip(em["ce102_measured_dbu_v"],
1077
+ em["ce102_freqs_hz"]))
1078
+ + ".",
1079
+ " - Margins: "
1080
+ + ", ".join(f"{m:+.1f} dB" for m in em["ce102_margins_db"])
1081
+ + f"; worst case {_freq_hz(em['ce102_worst']['freq_hz'])} at "
1082
+ f"{em['ce102_worst']['margin_db']:+.1f} dB.",
1083
+ f" - Verdict: {'PASS' if em['ce102_verdict']['pass'] else 'FAIL'} "
1084
+ "(minimum margin >= 0 dB).",
1085
+ "- A margin band of at least 6 dB is typical engineering "
1086
+ "recommendation (reference-only, not an RTCA requirement).",
1087
+ "",
1088
+ "## 9. Verdicts and open items",
1089
+ "",
1090
+ "| Domain | Section | Verdict |",
1091
+ "|---|---|---|",
1092
+ "| Electrostatic discharge | 25 | "
1093
+ f"{'PASS' if model['verdicts']['esd'] else 'FAIL'} |",
1094
+ "| Lightning | 22/23 | "
1095
+ f"{'PASS' if model['verdicts']['lightning'] else 'FAIL'} |",
1096
+ "| Power input | 16 | "
1097
+ f"{'PASS' if model['verdicts']['power_input'] else 'FAIL'} |",
1098
+ "| RF susceptibility (CS114) | 20 | "
1099
+ f"{'PASS' if model['verdicts']['cs114_conducted'] else 'FAIL'} |",
1100
+ "| RF emissions (RE102) | 21 | "
1101
+ f"{'PASS' if model['verdicts']['re102_radiated'] else 'FAIL'} |",
1102
+ "| RF emissions (CE102) | 21 | "
1103
+ f"{'PASS' if model['verdicts']['ce102_conducted'] else 'FAIL'} |",
1104
+ "",
1105
+ "Open items before the human environmental qualification engineer "
1106
+ "signs:",
1107
+ "- Confirm level/waveform/limit tables against the current DO-160 "
1108
+ "revision (typical reference data used throughout).",
1109
+ "- Confirm category-specific matrix exclusions for the installed "
1110
+ "location.",
1111
+ "- Review measured bench data against the qualification test "
1112
+ "environment.",
1113
+ "",
1114
+ "---",
1115
+ "*Generated by Aero Agent Roles do160-environmental-engineer core. "
1116
+ "DRAFT for human environmental qualification engineer review. "
1117
+ "Not an approval document.*",
1118
+ ]
1119
+ return "\n".join(lines)
1120
+
1121
+
1122
+ render_markdown = render_qualification_report_markdown # public alias
1123
+
1124
+
1125
+ # ---------------------------------------------------------------------------
1126
+ # Evidence gate checkers
1127
+ # ---------------------------------------------------------------------------
1128
+
1129
+ GATE_CHECKS = {
1130
+ "item_identified": "item is named (LRU)",
1131
+ "category_identified": "temperature category valid + extremes covered",
1132
+ "matrix_complete": "no required test-condition section missing",
1133
+ "esd_plan_valid": "ESD category A / 15 kV / counts valid",
1134
+ "lightning_valid": "level 1-5 and waveforms within A-H",
1135
+ "power_within_limits": "steady state within normal band with margins",
1136
+ "rf_budget_computed": "amplifier budget is a positive number",
1137
+ "emissions_verdict_present": "RE102/CE102 verdicts present",
1138
+ "sign_off_honest": "document is marked draft-for-review, not approval",
1139
+ }
1140
+
1141
+
1142
+ def check_qualification_report(model: dict) -> dict:
1143
+ """Run the evidence gates against a qualification report model."""
1144
+ esd = model.get("esd", {})
1145
+ ltg = model.get("lightning", {})
1146
+ pw = model.get("power_input", {})
1147
+ rf = model.get("rf_susceptibility", {})
1148
+ em = model.get("rf_emissions", {})
1149
+ cat = model.get("temperature_category", "")
1150
+ lo, hi = model.get("expected_temperature_c", [-999, 999])
1151
+ results = {
1152
+ "item_identified": bool(model.get("item")),
1153
+ "category_identified": (cat in TEMPERATURE_RANGES
1154
+ and temp_within_range(lo, cat)
1155
+ and temp_within_range(hi, cat)),
1156
+ "matrix_complete": bool(model.get("matrix_complete"))
1157
+ and model.get("matrix_missing") == [],
1158
+ "esd_plan_valid": (esd.get("category") == "A"
1159
+ and esd.get("test_level_kv") == 15.0
1160
+ and esd.get("counts_valid") is True),
1161
+ "lightning_valid": bool(ltg.get("level_valid")
1162
+ and ltg.get("waveforms_valid")),
1163
+ "power_within_limits": bool(pw.get("steady_within_limits")
1164
+ and pw.get("margins_v")),
1165
+ "rf_budget_computed": isinstance(rf.get("amplifier_power_w"), (int, float))
1166
+ and rf["amplifier_power_w"] > 0,
1167
+ "emissions_verdict_present": (isinstance(em.get("re102_verdict"), dict)
1168
+ and isinstance(em.get("ce102_verdict"), dict)),
1169
+ "sign_off_honest": model.get("status") == "draft-for-review",
1170
+ }
1171
+ results["all_pass"] = all(results.values())
1172
+ return results
1173
+
1174
+
1175
+ def check_qualification_report_markdown(md_text: str,
1176
+ category: str = "") -> dict:
1177
+ """Gate-check the rendered markdown deliverable."""
1178
+ low = md_text.lower()
1179
+ checks = {
1180
+ "has_title": "equipment environmental qualification plan/report" in low,
1181
+ "has_item": "lru" in low,
1182
+ "has_category": bool(re.search(
1183
+ rf"temperature/altitude category:\s*{category.lower()}\b"
1184
+ if category else r"temperature/altitude category:", low)),
1185
+ "has_esd": "15 kv air discharge" in low and "56.25 a" in low,
1186
+ "has_lightning": bool(re.search(r"test level: \d", low)),
1187
+ "has_power": "normal steady-state range" in low,
1188
+ "has_rf": "rs103 radiated test field" in low,
1189
+ "has_matrix": "| 25 | electrostatic discharge | yes |" in low,
1190
+ "has_draft_marker": "draft" in low,
1191
+ "has_not_approval": "not an approval" in low,
1192
+ }
1193
+ checks["all_pass"] = all(checks.values())
1194
+ return checks
1195
+
1196
+
1197
+ def validate_deliverable_text(md_text: str, category: str = "") -> dict:
1198
+ """Public entry point used by gate tooling."""
1199
+ return check_qualification_report_markdown(md_text, category)
1200
+
1201
+
1202
+ # ---------------------------------------------------------------------------
1203
+ # Example project (for tests and worked-example generation)
1204
+ # ---------------------------------------------------------------------------
1205
+
1206
+ def example_item() -> EquipmentItem:
1207
+ return EquipmentItem(
1208
+ item_name="Terrain Awareness Warning System (TAWS) computer LRU",
1209
+ description="Line-replaceable unit hosting the TAWS alerting "
1210
+ "function for a FAR/CS-25 transport.",
1211
+ installation="Forward equipment bay, pressurized and "
1212
+ "temperature-controlled, FAR/CS-25 transport",
1213
+ expected_low_c=-40.0,
1214
+ expected_high_c=55.0,
1215
+ certification_basis="FAR/CS-25",
1216
+ do160_revision="DO-160G",
1217
+ lightning_level=3,
1218
+ lightning_waveforms=["A", "B", "C", "H"],
1219
+ power_measured_v=27.5,
1220
+ sag_trough_v=22.4,
1221
+ cs114_category="C",
1222
+ re102_category="A",
1223
+ )
1224
+
1225
+
1226
+ def example_qualification_report_markdown() -> str:
1227
+ return render_qualification_report_markdown(
1228
+ build_qualification_report(example_item()))
1229
+
1230
+
1231
+ if __name__ == "__main__":
1232
+ item = example_item()
1233
+ model = build_qualification_report(item)
1234
+ md = render_qualification_report_markdown(model)
1235
+ print(f"ITEM: {model['item']}")
1236
+ print(f"TEMPERATURE CATEGORY: {model['temperature_category']} "
1237
+ f"(range {model['temperature_range_c']})")
1238
+ print(f"MATRIX COMPLETE: {model['matrix_complete']} "
1239
+ f"({len(model['matrix'])} sections)")
1240
+ print(f"ESD: {model['esd']['test_level_kv']:.0f} kV, "
1241
+ f"peak {model['esd']['peak_current_a']:.2f} A, "
1242
+ f"energy {model['esd']['stored_energy_j']:.6f} J")
1243
+ print(f"AMPLIFIER BUDGET: {model['rf_susceptibility']['amplifier_power_w']:.0f} W")
1244
+ print(f"VERDICTS: {model['verdicts']}")
1245
+ print(f"GATES: {check_qualification_report(model)}")
1246
+ print(f"RENDERED: {len(md)} chars")