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.
- package/NOTICE +18 -0
- package/README.md +49 -0
- package/bin/aero-agent-roles.js +127 -0
- package/lib/catalog.js +97 -0
- package/lib/install.js +48 -0
- package/lib/mcp.js +199 -0
- package/manifest.json +516 -0
- package/package.json +44 -0
- package/roles/aerodynamics-engineer/ROLE.md +153 -0
- package/roles/aerodynamics-engineer/SOURCES.md +12 -0
- package/roles/aerodynamics-engineer/cli.py +143 -0
- package/roles/aerodynamics-engineer/core/aerodynamics_core.py +1218 -0
- package/roles/aerodynamics-engineer/templates/aero-design-report-template.md +72 -0
- package/roles/aerodynamics-engineer/tests/test_aerodynamics_core.py +227 -0
- package/roles/aerodynamics-engineer/tests/test_role_aerodynamics.py +94 -0
- package/roles/aircraft-design-engineer/ROLE.md +138 -0
- package/roles/aircraft-design-engineer/SOURCES.md +12 -0
- package/roles/aircraft-design-engineer/cli.py +152 -0
- package/roles/aircraft-design-engineer/core/aircraft_design_core.py +1278 -0
- package/roles/aircraft-design-engineer/templates/concept-design-template.md +99 -0
- package/roles/aircraft-design-engineer/tests/test_aircraft_design_core.py +269 -0
- package/roles/aircraft-design-engineer/tests/test_role_aircraft_design_engineer.py +106 -0
- package/roles/airworthiness-compliance-engineer/ROLE.md +122 -0
- package/roles/airworthiness-compliance-engineer/SOURCES.md +16 -0
- package/roles/airworthiness-compliance-engineer/cli.py +146 -0
- package/roles/airworthiness-compliance-engineer/core/airworthiness_core.py +894 -0
- package/roles/airworthiness-compliance-engineer/templates/compliance-matrix-template.md +46 -0
- package/roles/airworthiness-compliance-engineer/tests/test_airworthiness_core.py +224 -0
- package/roles/airworthiness-compliance-engineer/tests/test_role_airworthiness.py +85 -0
- package/roles/as9100-quality-auditor/ROLE.md +128 -0
- package/roles/as9100-quality-auditor/SOURCES.md +16 -0
- package/roles/as9100-quality-auditor/cli.py +136 -0
- package/roles/as9100-quality-auditor/core/as9100_core.py +857 -0
- package/roles/as9100-quality-auditor/templates/findings-report-template.md +119 -0
- package/roles/as9100-quality-auditor/tests/test_as9100_cli_profile.py +105 -0
- package/roles/as9100-quality-auditor/tests/test_as9100_core.py +204 -0
- package/roles/as9100-quality-auditor/tests/test_role_as9100.py +90 -0
- package/roles/data-bus-avionics-engineer/ROLE.md +146 -0
- package/roles/data-bus-avionics-engineer/SOURCES.md +23 -0
- package/roles/data-bus-avionics-engineer/cli.py +311 -0
- package/roles/data-bus-avionics-engineer/core/data_bus_avionics_core.py +1121 -0
- package/roles/data-bus-avionics-engineer/templates/bus-assessment-template.md +134 -0
- package/roles/data-bus-avionics-engineer/tests/test_bundle_protocol.py +137 -0
- package/roles/data-bus-avionics-engineer/tests/test_data_bus_avionics_core.py +261 -0
- package/roles/data-bus-avionics-engineer/tests/test_role_data_bus_avionics_engineer.py +121 -0
- package/roles/do160-environmental-engineer/ROLE.md +136 -0
- package/roles/do160-environmental-engineer/SOURCES.md +30 -0
- package/roles/do160-environmental-engineer/cli.py +147 -0
- package/roles/do160-environmental-engineer/core/do160_environmental_core.py +1246 -0
- package/roles/do160-environmental-engineer/templates/environmental-qualification-plan-template.md +114 -0
- package/roles/do160-environmental-engineer/tests/test_do160_environmental_core.py +312 -0
- package/roles/do160-environmental-engineer/tests/test_role_do160_environmental_engineer.py +281 -0
- package/roles/do178c-cert-engineer/ROLE.md +120 -0
- package/roles/do178c-cert-engineer/SOURCES.md +16 -0
- package/roles/do178c-cert-engineer/cli.py +129 -0
- package/roles/do178c-cert-engineer/core/do178c_core.py +411 -0
- package/roles/do178c-cert-engineer/templates/psac-template.md +74 -0
- package/roles/do178c-cert-engineer/tests/test_do178c_cert_engineer_core.py +91 -0
- package/roles/do178c-cert-engineer/tests/test_role_do178c.py +109 -0
- package/roles/do254-hardware-engineer/ROLE.md +127 -0
- package/roles/do254-hardware-engineer/SOURCES.md +19 -0
- package/roles/do254-hardware-engineer/cli.py +308 -0
- package/roles/do254-hardware-engineer/core/do254_hardware_core.py +761 -0
- package/roles/do254-hardware-engineer/templates/phac-template.md +87 -0
- package/roles/do254-hardware-engineer/tests/test_do254_bundle_profile.py +152 -0
- package/roles/do254-hardware-engineer/tests/test_do254_hardware_core.py +200 -0
- package/roles/do254-hardware-engineer/tests/test_role_do254_hardware.py +108 -0
- package/roles/engineering-analysis-engineer/ROLE.md +154 -0
- package/roles/engineering-analysis-engineer/SOURCES.md +15 -0
- package/roles/engineering-analysis-engineer/cli.py +151 -0
- package/roles/engineering-analysis-engineer/core/engineering_analysis_engineer_core.py +866 -0
- package/roles/engineering-analysis-engineer/templates/analysis-memo-template.md +73 -0
- package/roles/engineering-analysis-engineer/tests/test_engineering_analysis_engineer_core.py +367 -0
- package/roles/engineering-analysis-engineer/tests/test_role_engineering_analysis_engineer.py +113 -0
- package/roles/flight-management-engineer/ROLE.md +160 -0
- package/roles/flight-management-engineer/SOURCES.md +30 -0
- package/roles/flight-management-engineer/cli.py +258 -0
- package/roles/flight-management-engineer/core/flight_management_core.py +1473 -0
- package/roles/flight-management-engineer/templates/route-assessment-template.md +94 -0
- package/roles/flight-management-engineer/tests/test_flight_management_core.py +437 -0
- package/roles/flight-management-engineer/tests/test_role_flight_management_engineer.py +123 -0
- package/roles/flight-mechanics-engineer/ROLE.md +162 -0
- package/roles/flight-mechanics-engineer/SOURCES.md +51 -0
- package/roles/flight-mechanics-engineer/cli.py +149 -0
- package/roles/flight-mechanics-engineer/core/flight_mechanics_core.py +1507 -0
- package/roles/flight-mechanics-engineer/templates/perf-sc-report-template.md +82 -0
- package/roles/flight-mechanics-engineer/tests/test_flight_mechanics_core.py +277 -0
- package/roles/flight-mechanics-engineer/tests/test_role_flight_mechanics_engineer.py +141 -0
- package/roles/flight-test-engineer/ROLE.md +137 -0
- package/roles/flight-test-engineer/SOURCES.md +24 -0
- package/roles/flight-test-engineer/cli.py +135 -0
- package/roles/flight-test-engineer/core/flight_test_core.py +931 -0
- package/roles/flight-test-engineer/templates/flight-test-template.md +143 -0
- package/roles/flight-test-engineer/tests/test_flight_test_engineer_core.py +227 -0
- package/roles/flight-test-engineer/tests/test_role_flight_test_engineer.py +150 -0
- package/roles/flight-test-performance-engineer/ROLE.md +165 -0
- package/roles/flight-test-performance-engineer/SOURCES.md +20 -0
- package/roles/flight-test-performance-engineer/cli.py +319 -0
- package/roles/flight-test-performance-engineer/core/flight_test_performance_core.py +1398 -0
- package/roles/flight-test-performance-engineer/templates/performance-report-template.md +105 -0
- package/roles/flight-test-performance-engineer/tests/test_flight_test_performance_engineer_core.py +308 -0
- package/roles/flight-test-performance-engineer/tests/test_role_flight_test_performance_engineer.py +165 -0
- package/roles/gnc-engineer/ROLE.md +141 -0
- package/roles/gnc-engineer/SOURCES.md +35 -0
- package/roles/gnc-engineer/cli.py +141 -0
- package/roles/gnc-engineer/core/gnc_core.py +1074 -0
- package/roles/gnc-engineer/templates/gnc-report-template.md +105 -0
- package/roles/gnc-engineer/tests/test_gnc_engineer_core.py +238 -0
- package/roles/gnc-engineer/tests/test_role_gnc_engineer.py +142 -0
- package/roles/guidance-engineer/ROLE.md +156 -0
- package/roles/guidance-engineer/SOURCES.md +43 -0
- package/roles/guidance-engineer/cli.py +280 -0
- package/roles/guidance-engineer/core/guidance_core.py +820 -0
- package/roles/guidance-engineer/templates/guidance-report-template.md +70 -0
- package/roles/guidance-engineer/tests/test_guidance_engineer_core.py +269 -0
- package/roles/guidance-engineer/tests/test_role_guidance_engineer.py +146 -0
- package/roles/high-speed-aerodynamics-engineer/ROLE.md +161 -0
- package/roles/high-speed-aerodynamics-engineer/SOURCES.md +18 -0
- package/roles/high-speed-aerodynamics-engineer/cli.py +282 -0
- package/roles/high-speed-aerodynamics-engineer/core/high_speed_aero_core.py +1566 -0
- package/roles/high-speed-aerodynamics-engineer/templates/high-speed-memo-template.md +75 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_bundle_protocol.py +103 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_high_speed_aero_core.py +280 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_role_high_speed_aerodynamics.py +125 -0
- package/roles/ndt-engineer/ROLE.md +157 -0
- package/roles/ndt-engineer/SOURCES.md +18 -0
- package/roles/ndt-engineer/cli.py +316 -0
- package/roles/ndt-engineer/core/ndt_core.py +1590 -0
- package/roles/ndt-engineer/templates/ndt-plan-template.md +109 -0
- package/roles/ndt-engineer/tests/test_ndt_cli_bundle.py +183 -0
- package/roles/ndt-engineer/tests/test_ndt_core.py +437 -0
- package/roles/ndt-engineer/tests/test_role_ndt_engineer.py +148 -0
- package/roles/numerical-analysis-engineer/ROLE.md +158 -0
- package/roles/numerical-analysis-engineer/SOURCES.md +19 -0
- package/roles/numerical-analysis-engineer/cli.py +414 -0
- package/roles/numerical-analysis-engineer/core/numerical_analysis_core.py +1220 -0
- package/roles/numerical-analysis-engineer/templates/verification-memo-template.md +120 -0
- package/roles/numerical-analysis-engineer/tests/test_bundle_protocol.py +157 -0
- package/roles/numerical-analysis-engineer/tests/test_numerical_analysis_core.py +337 -0
- package/roles/numerical-analysis-engineer/tests/test_role_numerical_analysis_engineer.py +117 -0
- package/roles/propulsion-engineer/ROLE.md +163 -0
- package/roles/propulsion-engineer/SOURCES.md +15 -0
- package/roles/propulsion-engineer/cli.py +168 -0
- package/roles/propulsion-engineer/core/propulsion_core.py +1291 -0
- package/roles/propulsion-engineer/templates/propulsion-report-template.md +92 -0
- package/roles/propulsion-engineer/tests/test_propulsion_engineer_core.py +294 -0
- package/roles/propulsion-engineer/tests/test_role_propulsion_engineer.py +106 -0
- package/roles/space-systems-engineer/ROLE.md +161 -0
- package/roles/space-systems-engineer/SOURCES.md +49 -0
- package/roles/space-systems-engineer/cli.py +173 -0
- package/roles/space-systems-engineer/core/space_systems_core.py +980 -0
- package/roles/space-systems-engineer/templates/space-systems-template.md +88 -0
- package/roles/space-systems-engineer/tests/test_role_space_systems_engineer.py +117 -0
- package/roles/space-systems-engineer/tests/test_space_systems_engineer_core.py +247 -0
- package/roles/state-estimation-engineer/ROLE.md +154 -0
- package/roles/state-estimation-engineer/SOURCES.md +27 -0
- package/roles/state-estimation-engineer/cli.py +326 -0
- package/roles/state-estimation-engineer/core/state_estimation_core.py +958 -0
- package/roles/state-estimation-engineer/templates/nav-state-estimator-design-template.md +76 -0
- package/roles/state-estimation-engineer/tests/test_bundle_protocol.py +110 -0
- package/roles/state-estimation-engineer/tests/test_role_state_estimation_engineer.py +127 -0
- package/roles/state-estimation-engineer/tests/test_state_estimation_engineer_core.py +231 -0
- package/roles/structures-loads-engineer/ROLE.md +171 -0
- package/roles/structures-loads-engineer/SOURCES.md +44 -0
- package/roles/structures-loads-engineer/cli.py +219 -0
- package/roles/structures-loads-engineer/core/structures_loads_core.py +1112 -0
- package/roles/structures-loads-engineer/templates/loads-strength-report-template.md +96 -0
- package/roles/structures-loads-engineer/tests/test_bundle_protocol.py +101 -0
- package/roles/structures-loads-engineer/tests/test_role_structures_loads_engineer.py +147 -0
- package/roles/structures-loads-engineer/tests/test_structures_loads_core.py +261 -0
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""space_systems_core.py - Space Systems Engineer executable core.
|
|
3
|
+
|
|
4
|
+
This is the role's ENGINE: given a spacecraft mission's project facts it
|
|
5
|
+
computes the orbit elements, the mission delta-v budget (Hohmann and
|
|
6
|
+
single-burn disposal legs, margin, propellant mass via the Tsiolkovsky
|
|
7
|
+
rocket equation), the ADCS solution (reaction wheel momentum sizing
|
|
8
|
+
h = J*omega, wheel inertia, pointing error budget), the power/thermal
|
|
9
|
+
closure (eclipse geometry, battery capacity, solar array area, radiator
|
|
10
|
+
area), the comms link budget (Friis downlink/uplink), propulsion tank
|
|
11
|
+
sizing, and BUILDS the Mission and Subsystem Design Report content
|
|
12
|
+
model. It also gate-checks deliverables. Standalone: no external repo
|
|
13
|
+
needed; stdlib only.
|
|
14
|
+
|
|
15
|
+
Domain rules encoded here are the public astrodynamics / spacecraft
|
|
16
|
+
engineering relations exercised by the bound AeroSkills space-systems
|
|
17
|
+
leaves (hohmann-transfer, mission-delta-v-budget, reaction-wheel-control,
|
|
18
|
+
power-thermal-budget, solar-array-sizing, spacecraft-battery-sizing,
|
|
19
|
+
thermal-design, communication-link-budget, propellant-tank-sizing,
|
|
20
|
+
eclipse-time, kepler-orbit-propagation, pointing-error-budget) and the
|
|
21
|
+
public references recorded in SOURCES.md (ECSS space engineering,
|
|
22
|
+
Wertz SMAD, Vallado). Standard text is never reproduced.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from datetime import date
|
|
30
|
+
|
|
31
|
+
def _today() -> str:
|
|
32
|
+
import os
|
|
33
|
+
from datetime import date
|
|
34
|
+
return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Physical constants (public knowledge, as used across the AeroSkills leaves)
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
MU_EARTH = 3.986004418e14 # Earth gravitational parameter, m^3/s^2
|
|
41
|
+
RE_EARTH = 6378.137e3 # Earth equatorial radius, m
|
|
42
|
+
G0 = 9.80665 # standard gravity, m/s^2
|
|
43
|
+
SOLAR_FLUX = 1367.0 # AM0 solar irradiance, W/m^2
|
|
44
|
+
STEFAN_BOLTZMANN = 5.67e-8 # W/m^2/K^4
|
|
45
|
+
SPEED_LIGHT = 299792458.0 # m/s
|
|
46
|
+
K_BOLTZ = 228.6 # 10*log10(1/k), k in J/K (dB referenced to 1 K)
|
|
47
|
+
RPM_TO_RAD_S = 2.0 * math.pi / 60.0
|
|
48
|
+
|
|
49
|
+
DRAFT_MARKER = ("DRAFT for human space systems lead review. Not launch "
|
|
50
|
+
"readiness. Not an approval document.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Mission project facts
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class PowerLoad:
|
|
59
|
+
"""One power consumer: name and day/eclipse orbit-average draws (W)."""
|
|
60
|
+
name: str
|
|
61
|
+
day_w: float
|
|
62
|
+
eclipse_w: float
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class SpacecraftMission:
|
|
67
|
+
"""Project facts the role needs to size the spacecraft and mission."""
|
|
68
|
+
# --- mission / orbit ---
|
|
69
|
+
name: str = "Aurora-1 Earth Observation Microsatellite"
|
|
70
|
+
mission_summary: str = ("600 km circular sun-synchronous Earth observation "
|
|
71
|
+
"microsatellite, 5-year design life, 10:30 LTDN.")
|
|
72
|
+
altitude_km: float = 600.0
|
|
73
|
+
inclination_deg: float = 97.8
|
|
74
|
+
eccentricity: float = 0.0
|
|
75
|
+
lifetime_years: float = 5.0
|
|
76
|
+
launch_vehicle_note: str = "rideshare, direct injection"
|
|
77
|
+
# --- delta-v budget contributions (m/s; stated mission legs) ---
|
|
78
|
+
injection_correction_dv_m_s: float = 20.0 # launcher dispersion correction
|
|
79
|
+
drag_makeup_dv_m_s: float = 15.0 # orbit maintenance, 5 yr at 600 km
|
|
80
|
+
collision_avoidance_dv_m_s: float = 10.0 # two planned conjunction maneuvers
|
|
81
|
+
disposal_perigee_altitude_km: float = 100.0 # EOL perigee lowering target
|
|
82
|
+
dv_margin_fraction: float = 0.15
|
|
83
|
+
# --- mass / propulsion ---
|
|
84
|
+
dry_mass_kg: float = 150.0
|
|
85
|
+
isp_s: float = 220.0 # hydrazine monopropellant
|
|
86
|
+
propellant_density_kg_m3: float = 1008.0 # hydrazine at 20 C
|
|
87
|
+
tank_meop_pa: float = 2.4e6
|
|
88
|
+
tank_burst_factor: float = 1.5
|
|
89
|
+
tank_material_ultimate_pa: float = 850.0e6 # Ti-6Al-4V annealed
|
|
90
|
+
tank_material_density_kg_m3: float = 4430.0
|
|
91
|
+
tank_ullage_fraction: float = 0.06
|
|
92
|
+
tank_boss_factor: float = 1.10
|
|
93
|
+
pressurant_gas_constant: float = 2077.0 # helium, J/(kg K)
|
|
94
|
+
pressurant_temp_k: float = 293.0
|
|
95
|
+
# --- power / thermal ---
|
|
96
|
+
power_loads: list = field(default_factory=lambda: [
|
|
97
|
+
PowerLoad("Payload (optical imager)", 60.0, 0.0),
|
|
98
|
+
PowerLoad("Avionics / C&DH", 30.0, 30.0),
|
|
99
|
+
PowerLoad("ADCS", 15.0, 15.0),
|
|
100
|
+
PowerLoad("Comms (S-band Rx + X-band Tx duty)", 20.0, 10.0),
|
|
101
|
+
PowerLoad("Thermal (heater duty)", 15.0, 25.0),
|
|
102
|
+
])
|
|
103
|
+
battery_dod: float = 0.30
|
|
104
|
+
battery_efficiency: float = 0.90
|
|
105
|
+
array_margin_fraction: float = 0.20
|
|
106
|
+
array_distribution_efficiency: float = 0.90
|
|
107
|
+
cell_efficiency: float = 0.30 # triple-junction GaAs
|
|
108
|
+
packing_factor: float = 0.85
|
|
109
|
+
annual_degradation: float = 0.02
|
|
110
|
+
radiator_emissivity: float = 0.90
|
|
111
|
+
radiator_temp_k: float = 293.15
|
|
112
|
+
radiator_sink_temp_k: float = 3.0
|
|
113
|
+
# --- ADCS ---
|
|
114
|
+
bus_inertia_kgm2: float = 20.0
|
|
115
|
+
slew_angle_deg: float = 90.0
|
|
116
|
+
slew_time_s: float = 90.0
|
|
117
|
+
wheel_speed_max_rpm: float = 6000.0
|
|
118
|
+
disturbance_torque_nm: float = 1.2e-5 # worst-case aero/GG dominated
|
|
119
|
+
adcs_momentum_margin: float = 1.5
|
|
120
|
+
pointing_requirement_deg: float = 0.1 # 3-sigma, all axes
|
|
121
|
+
pointing_errors_deg: dict = field(default_factory=lambda: {
|
|
122
|
+
"attitude determination (star tracker)": 0.020,
|
|
123
|
+
"control error (deadband + PD residual)": 0.030,
|
|
124
|
+
"wheel-induced jitter": 0.020,
|
|
125
|
+
"sensor/actuator alignment": 0.020,
|
|
126
|
+
"thermal distortion": 0.015,
|
|
127
|
+
})
|
|
128
|
+
# --- comms ---
|
|
129
|
+
downlink_freq_hz: float = 8.2e9 # X-band
|
|
130
|
+
downlink_tx_power_w: float = 5.0
|
|
131
|
+
downlink_tx_gain_db: float = 24.0 # ~0.3 m reflector
|
|
132
|
+
downlink_rx_gain_db: float = 42.0 # 3 m ground station
|
|
133
|
+
downlink_rx_noise_temp_k: float = 150.0
|
|
134
|
+
downlink_data_rate_bps: float = 150.0e6
|
|
135
|
+
downlink_required_ebno_db: float = 10.5 # coded QPSK threshold
|
|
136
|
+
uplink_freq_hz: float = 2.1e9 # S-band
|
|
137
|
+
uplink_tx_power_w: float = 20.0
|
|
138
|
+
uplink_tx_gain_db: float = 34.0 # 3 m ground station at S-band
|
|
139
|
+
uplink_rx_gain_db: float = 0.0 # spacecraft omni
|
|
140
|
+
uplink_rx_noise_temp_k: float = 400.0
|
|
141
|
+
uplink_data_rate_bps: float = 4.0e3
|
|
142
|
+
uplink_required_ebno_db: float = 12.0
|
|
143
|
+
link_elevation_deg: float = 5.0
|
|
144
|
+
implementation_loss_db: float = 3.0 # downlink (pointing, depol, modem)
|
|
145
|
+
link_margin_requirement_db: float = 3.0
|
|
146
|
+
on_board_storage_gb: float = 256.0
|
|
147
|
+
imaging_data_gb_per_day: float = 30.0
|
|
148
|
+
passes_per_day: int = 6 # planned high-latitude contacts
|
|
149
|
+
pass_duration_min: float = 10.0
|
|
150
|
+
# --- mass budget (dry mass rows; must sum to dry_mass_kg) ---
|
|
151
|
+
mass_budget_kg: dict = field(default_factory=lambda: {
|
|
152
|
+
"Payload (imager)": 38.0,
|
|
153
|
+
"Structure": 42.0,
|
|
154
|
+
"Electrical power subsystem": 20.0,
|
|
155
|
+
"ADCS": 11.0,
|
|
156
|
+
"Propulsion (dry: tank, thrusters, plumbing)": 10.0,
|
|
157
|
+
"Comms": 7.0,
|
|
158
|
+
"C&DH": 7.0,
|
|
159
|
+
"Thermal": 5.0,
|
|
160
|
+
"Harness + integration": 10.0,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
def orbit_radius_m(self) -> float:
|
|
165
|
+
return RE_EARTH + self.altitude_km * 1000.0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Orbit mechanics (mirrors kepler-orbit-propagation / hohmann-transfer leaves)
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def circular_velocity(radius_m: float, mu: float = MU_EARTH) -> float:
|
|
173
|
+
"""v = sqrt(mu / r), m/s (circular orbit speed)."""
|
|
174
|
+
if radius_m <= 0:
|
|
175
|
+
raise ValueError(f"radius_m must be > 0, got {radius_m!r}")
|
|
176
|
+
return math.sqrt(mu / radius_m)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def orbit_period_seconds(radius_m: float, mu: float = MU_EARTH) -> float:
|
|
180
|
+
"""T = 2*pi*sqrt(r^3/mu), s (Kepler's third law, circular)."""
|
|
181
|
+
if radius_m <= 0:
|
|
182
|
+
raise ValueError(f"radius_m must be > 0, got {radius_m!r}")
|
|
183
|
+
return 2.0 * math.pi * math.sqrt(radius_m ** 3 / mu)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def vis_viva_velocity(radius_m: float, semimajor_axis_m: float,
|
|
187
|
+
mu: float = MU_EARTH) -> float:
|
|
188
|
+
"""v = sqrt(mu*(2/r - 1/a)), m/s."""
|
|
189
|
+
if radius_m <= 0 or semimajor_axis_m <= 0:
|
|
190
|
+
raise ValueError("radius and semimajor axis must be > 0")
|
|
191
|
+
return math.sqrt(mu * (2.0 / radius_m - 1.0 / semimajor_axis_m))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def hohmann_burn_delta_v(r1_m: float, r2_m: float, mu: float = MU_EARTH) -> dict:
|
|
195
|
+
"""Two-impulse Hohmann transfer between coplanar circular orbits.
|
|
196
|
+
|
|
197
|
+
Returns departure and arrival impulses and the total in m/s
|
|
198
|
+
(dv_total = dv1 + dv2), plus the one-way transfer time. Mirrors the
|
|
199
|
+
hohmann-transfer leaf: LEO (Re+500 km) to GEO totals ~3816 m/s.
|
|
200
|
+
"""
|
|
201
|
+
if r1_m <= 0 or r2_m <= 0 or r1_m == r2_m:
|
|
202
|
+
raise ValueError("radii must be > 0 and differ")
|
|
203
|
+
a_t = (r1_m + r2_m) / 2.0
|
|
204
|
+
dv1 = abs(vis_viva_velocity(r1_m, a_t, mu) - circular_velocity(r1_m, mu))
|
|
205
|
+
dv2 = abs(circular_velocity(r2_m, mu) - vis_viva_velocity(r2_m, a_t, mu))
|
|
206
|
+
t_transfer = math.pi * math.sqrt(a_t ** 3 / mu)
|
|
207
|
+
return {"dv1_m_s": dv1, "dv2_m_s": dv2, "total_m_s": dv1 + dv2,
|
|
208
|
+
"transfer_time_s": t_transfer}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def perigee_lowering_delta_v(altitude_km: float, target_perigee_km: float,
|
|
212
|
+
mu: float = MU_EARTH,
|
|
213
|
+
re: float = RE_EARTH) -> float:
|
|
214
|
+
"""Single retrograde burn at apogee to lower perigee for disposal.
|
|
215
|
+
|
|
216
|
+
Burn on the circular orbit at radius r; the new ellipse has apogee r
|
|
217
|
+
and perigee re + target_perigee_km. dv = v_circ - v_at_apogee_new.
|
|
218
|
+
"""
|
|
219
|
+
if altitude_km <= 0 or target_perigee_km <= 0:
|
|
220
|
+
raise ValueError("altitudes must be > 0")
|
|
221
|
+
r = re + altitude_km * 1000.0
|
|
222
|
+
rp = re + target_perigee_km * 1000.0
|
|
223
|
+
if rp >= r:
|
|
224
|
+
raise ValueError("target perigee must be below the orbit altitude")
|
|
225
|
+
a_new = (r + rp) / 2.0
|
|
226
|
+
v_circ = circular_velocity(r, mu)
|
|
227
|
+
v_new = vis_viva_velocity(r, a_new, mu)
|
|
228
|
+
return v_circ - v_new
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# Delta-v budget + propellant (mirrors mission-delta-v-budget leaf)
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def sum_delta_v(contributions: list) -> float:
|
|
236
|
+
contribs = [float(c) for c in contributions]
|
|
237
|
+
if any(c < 0 for c in contribs):
|
|
238
|
+
raise ValueError("delta-v contributions must be >= 0")
|
|
239
|
+
return sum(contribs)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def apply_margin(total_m_s: float, margin_fraction: float) -> float:
|
|
243
|
+
if total_m_s < 0 or margin_fraction < 0:
|
|
244
|
+
raise ValueError("total and margin must be >= 0")
|
|
245
|
+
return total_m_s * (1.0 + margin_fraction)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def propellant_mass(delta_v_m_s: float, dry_mass_kg: float, isp_s: float,
|
|
249
|
+
g0: float = G0) -> float:
|
|
250
|
+
"""m_prop = m_dry * (exp(dv/(Isp*g0)) - 1), kg (rocket equation)."""
|
|
251
|
+
if delta_v_m_s < 0 or dry_mass_kg <= 0 or isp_s <= 0:
|
|
252
|
+
raise ValueError("dv >= 0, dry mass > 0, isp > 0 required")
|
|
253
|
+
return dry_mass_kg * (math.exp(delta_v_m_s / (isp_s * g0)) - 1.0)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def wet_mass(delta_v_m_s: float, dry_mass_kg: float, isp_s: float,
|
|
257
|
+
g0: float = G0) -> float:
|
|
258
|
+
return dry_mass_kg + propellant_mass(delta_v_m_s, dry_mass_kg, isp_s, g0)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def mission_delta_v_budget(mission: SpacecraftMission) -> dict:
|
|
262
|
+
"""Nominal + budgeted delta-v and propellant sizing for the mission."""
|
|
263
|
+
disposal_dv = perigee_lowering_delta_v(mission.altitude_km,
|
|
264
|
+
mission.disposal_perigee_altitude_km)
|
|
265
|
+
contributions = {
|
|
266
|
+
"Injection dispersion correction": mission.injection_correction_dv_m_s,
|
|
267
|
+
"Orbit maintenance (drag, 5 yr)": mission.drag_makeup_dv_m_s,
|
|
268
|
+
"Collision avoidance (2 maneuvers)": mission.collision_avoidance_dv_m_s,
|
|
269
|
+
"End-of-life disposal (perigee lowering)": disposal_dv,
|
|
270
|
+
}
|
|
271
|
+
nominal = sum_delta_v(list(contributions.values()))
|
|
272
|
+
budgeted = apply_margin(nominal, mission.dv_margin_fraction)
|
|
273
|
+
m_prop = propellant_mass(budgeted, mission.dry_mass_kg, mission.isp_s)
|
|
274
|
+
return {
|
|
275
|
+
"contributions": contributions,
|
|
276
|
+
"disposal_dv_m_s": disposal_dv,
|
|
277
|
+
"disposal_perigee_altitude_km": mission.disposal_perigee_altitude_km,
|
|
278
|
+
"nominal_dv_m_s": nominal,
|
|
279
|
+
"margin_fraction": mission.dv_margin_fraction,
|
|
280
|
+
"budgeted_dv_m_s": budgeted,
|
|
281
|
+
"dry_mass_kg": mission.dry_mass_kg,
|
|
282
|
+
"isp_s": mission.isp_s,
|
|
283
|
+
"propellant_mass_kg": m_prop,
|
|
284
|
+
"wet_mass_kg": mission.dry_mass_kg + m_prop,
|
|
285
|
+
"propellant_fraction": m_prop / (mission.dry_mass_kg + m_prop),
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Propellant tank sizing (mirrors propellant-tank-sizing leaf)
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def tank_sizing(propellant_kg: float, density_kg_m3: float,
|
|
294
|
+
ullage_fraction: float, meop_pa: float, burst_factor: float,
|
|
295
|
+
material_ultimate_pa: float, material_density_kg_m3: float,
|
|
296
|
+
boss_factor: float, pressurant_gas_constant: float,
|
|
297
|
+
pressurant_temp_k: float) -> dict:
|
|
298
|
+
"""Spherical titanium tank: volume, radius, wall, shell, pressurant."""
|
|
299
|
+
if propellant_kg <= 0 or density_kg_m3 <= 0:
|
|
300
|
+
raise ValueError("propellant mass and density must be > 0")
|
|
301
|
+
v_prop = propellant_kg / density_kg_m3
|
|
302
|
+
if not 0.0 < ullage_fraction < 1.0:
|
|
303
|
+
raise ValueError("ullage fraction must be in (0, 1)")
|
|
304
|
+
v_tank = v_prop / (1.0 - ullage_fraction)
|
|
305
|
+
v_ullage = v_tank * ullage_fraction
|
|
306
|
+
radius = (3.0 * v_tank / (4.0 * math.pi)) ** (1.0 / 3.0)
|
|
307
|
+
burst_pa = burst_factor * meop_pa
|
|
308
|
+
wall_m = burst_pa * radius / (2.0 * material_ultimate_pa)
|
|
309
|
+
shell_kg = (4.0 * math.pi * radius * radius * wall_m
|
|
310
|
+
* material_density_kg_m3 * boss_factor)
|
|
311
|
+
pressurant_kg = (meop_pa * v_ullage
|
|
312
|
+
/ (pressurant_gas_constant * pressurant_temp_k))
|
|
313
|
+
return {
|
|
314
|
+
"propellant_density_kg_m3": density_kg_m3,
|
|
315
|
+
"propellant_volume_l": v_prop * 1000.0,
|
|
316
|
+
"tank_volume_l": v_tank * 1000.0,
|
|
317
|
+
"radius_m": radius,
|
|
318
|
+
"wall_thickness_mm": wall_m * 1000.0,
|
|
319
|
+
"shell_mass_kg": shell_kg,
|
|
320
|
+
"pressurant_mass_kg": pressurant_kg,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
# Eclipse geometry (mirrors eclipse-time leaf, beta = 0 worst case)
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
def eclipse_geometry(altitude_km: float, radius_earth: float = RE_EARTH,
|
|
329
|
+
mu: float = MU_EARTH) -> dict:
|
|
330
|
+
"""Worst-case (beta = 0) shadow fraction/time for a circular orbit.
|
|
331
|
+
|
|
332
|
+
f = asin(Re / r) / pi; eclipse_time = f * T.
|
|
333
|
+
"""
|
|
334
|
+
if altitude_km < 0:
|
|
335
|
+
raise ValueError("altitude must be >= 0")
|
|
336
|
+
r = radius_earth + altitude_km * 1000.0
|
|
337
|
+
if r <= radius_earth:
|
|
338
|
+
raise ValueError("orbit radius must exceed Earth radius")
|
|
339
|
+
period = orbit_period_seconds(r, mu)
|
|
340
|
+
fraction = math.asin(radius_earth / r) / math.pi
|
|
341
|
+
return {
|
|
342
|
+
"period_s": period,
|
|
343
|
+
"period_min": period / 60.0,
|
|
344
|
+
"shadow_fraction": fraction,
|
|
345
|
+
"daylight_fraction": 1.0 - fraction,
|
|
346
|
+
"eclipse_time_s": fraction * period,
|
|
347
|
+
"eclipse_time_min": fraction * period / 60.0,
|
|
348
|
+
"orbits_per_day": 86400.0 / period,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# Power and thermal (mirrors power-thermal-budget / solar-array-sizing /
|
|
354
|
+
# spacecraft-battery-sizing / thermal-design leaves)
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
def battery_capacity_required(power_w: float, eclipse_min: float,
|
|
358
|
+
dod: float, efficiency: float) -> float:
|
|
359
|
+
"""C = P * (t_eclipse/60) / (DoD * efficiency), Wh."""
|
|
360
|
+
if power_w <= 0 or eclipse_min <= 0:
|
|
361
|
+
raise ValueError("power and eclipse duration must be > 0")
|
|
362
|
+
if not 0.0 < dod <= 1.0 or not 0.0 < efficiency <= 1.0:
|
|
363
|
+
raise ValueError("DoD and efficiency must be in (0, 1]")
|
|
364
|
+
return power_w * (eclipse_min / 60.0) / (dod * efficiency)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def solar_array_daylight_power(power_w: float, eclipse_fraction: float,
|
|
368
|
+
efficiency: float, margin: float = 0.0) -> float:
|
|
369
|
+
"""P_sa = P / (eff * (1 - f)) * (1 + margin), W (daylight-only gen)."""
|
|
370
|
+
if power_w <= 0:
|
|
371
|
+
raise ValueError("power must be > 0")
|
|
372
|
+
if not 0.0 < eclipse_fraction < 1.0:
|
|
373
|
+
raise ValueError("eclipse fraction must be in (0, 1)")
|
|
374
|
+
if not 0.0 < efficiency <= 1.0 or margin < 0:
|
|
375
|
+
raise ValueError("efficiency in (0,1], margin >= 0")
|
|
376
|
+
return power_w / (efficiency * (1.0 - eclipse_fraction)) * (1.0 + margin)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def eol_specific_power(solar_flux: float, cell_eff: float, packing: float,
|
|
380
|
+
annual_degradation: float, years: float) -> float:
|
|
381
|
+
"""p_eol = G*eta*PF*(1-r)^years, W/m^2 (end-of-life panel power)."""
|
|
382
|
+
if solar_flux <= 0 or not 0.0 < cell_eff <= 1 or not 0.0 < packing <= 1:
|
|
383
|
+
raise ValueError("irradiance, efficiency, packing must be > 0")
|
|
384
|
+
if not 0.0 <= annual_degradation < 1.0 or years < 0:
|
|
385
|
+
raise ValueError("degradation in [0,1), years >= 0")
|
|
386
|
+
return solar_flux * cell_eff * packing * (1.0 - annual_degradation) ** years
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def radiator_area(heat_load_w: float, t_rad_k: float, t_sink_k: float,
|
|
390
|
+
eps: float) -> float:
|
|
391
|
+
"""A = Q / (eps * sigma * (T_rad^4 - T_sink^4)), m^2."""
|
|
392
|
+
if heat_load_w < 0:
|
|
393
|
+
raise ValueError("heat load must be >= 0")
|
|
394
|
+
if t_rad_k <= t_sink_k:
|
|
395
|
+
raise ValueError("radiator temperature must exceed sink temperature")
|
|
396
|
+
if not 0.0 < eps <= 1.0:
|
|
397
|
+
raise ValueError("emissivity must be in (0, 1]")
|
|
398
|
+
return heat_load_w / (eps * STEFAN_BOLTZMANN
|
|
399
|
+
* (t_rad_k ** 4 - t_sink_k ** 4))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def power_and_thermal(mission: SpacecraftMission) -> dict:
|
|
403
|
+
"""Close the power/thermal budgets: loads, eclipse, battery, array,
|
|
404
|
+
radiator."""
|
|
405
|
+
ecl = eclipse_geometry(mission.altitude_km)
|
|
406
|
+
f = ecl["shadow_fraction"]
|
|
407
|
+
loads = []
|
|
408
|
+
for pl in mission.power_loads:
|
|
409
|
+
loads.append({"name": pl.name, "day_w": pl.day_w,
|
|
410
|
+
"eclipse_w": pl.eclipse_w})
|
|
411
|
+
day_total = sum(pl.day_w for pl in mission.power_loads)
|
|
412
|
+
eclipse_total = sum(pl.eclipse_w for pl in mission.power_loads)
|
|
413
|
+
orbit_avg_w = (day_total * (1.0 - f) + eclipse_total * f)
|
|
414
|
+
# battery: eclipse load must be carried over the worst-case shadow
|
|
415
|
+
required_wh = battery_capacity_required(
|
|
416
|
+
eclipse_total, ecl["eclipse_time_min"], mission.battery_dod,
|
|
417
|
+
mission.battery_efficiency)
|
|
418
|
+
sized_wh = math.ceil(required_wh * (1.0 + 0.20) / 5.0) * 5.0 # 20% + pack step
|
|
419
|
+
battery_margin = sized_wh / required_wh - 1.0
|
|
420
|
+
# solar array: daylight generation of the orbit-average demand
|
|
421
|
+
p_sa_w = solar_array_daylight_power(
|
|
422
|
+
orbit_avg_w, f, mission.array_distribution_efficiency,
|
|
423
|
+
mission.array_margin_fraction)
|
|
424
|
+
p_eol = eol_specific_power(SOLAR_FLUX, mission.cell_efficiency,
|
|
425
|
+
mission.packing_factor,
|
|
426
|
+
mission.annual_degradation,
|
|
427
|
+
mission.lifetime_years)
|
|
428
|
+
area_m2 = p_sa_w / p_eol
|
|
429
|
+
# radiator: continuous dissipation (avionics + ADCS + Rx at night)
|
|
430
|
+
rad_load_w = (mission.power_loads[1].day_w + mission.power_loads[2].day_w
|
|
431
|
+
+ 10.0) # C&DH + ADCS + continuous comms Rx
|
|
432
|
+
rad_area_m2 = radiator_area(rad_load_w, mission.radiator_temp_k,
|
|
433
|
+
mission.radiator_sink_temp_k,
|
|
434
|
+
mission.radiator_emissivity)
|
|
435
|
+
return {
|
|
436
|
+
"eclipse": ecl,
|
|
437
|
+
"loads": loads,
|
|
438
|
+
"day_total_w": day_total,
|
|
439
|
+
"eclipse_total_w": eclipse_total,
|
|
440
|
+
"orbit_average_w": orbit_avg_w,
|
|
441
|
+
"battery_dod": mission.battery_dod,
|
|
442
|
+
"battery_efficiency": mission.battery_efficiency,
|
|
443
|
+
"battery_required_wh": required_wh,
|
|
444
|
+
"battery_sized_wh": sized_wh,
|
|
445
|
+
"battery_margin": battery_margin,
|
|
446
|
+
"array_daylight_power_w": p_sa_w,
|
|
447
|
+
"array_eol_specific_power_w_m2": p_eol,
|
|
448
|
+
"array_area_m2": area_m2,
|
|
449
|
+
"array_margin_fraction": mission.array_margin_fraction,
|
|
450
|
+
"cell_efficiency": mission.cell_efficiency,
|
|
451
|
+
"packing_factor": mission.packing_factor,
|
|
452
|
+
"annual_degradation": mission.annual_degradation,
|
|
453
|
+
"radiator_load_w": rad_load_w,
|
|
454
|
+
"radiator_area_m2": rad_area_m2,
|
|
455
|
+
"radiator_temp_k": mission.radiator_temp_k,
|
|
456
|
+
"radiator_emissivity": mission.radiator_emissivity,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
# ADCS (mirrors reaction-wheel-control / pointing-error-budget leaves)
|
|
462
|
+
# ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
def reaction_wheel_sizing(mission: SpacecraftMission,
|
|
465
|
+
orbit_period_s: float) -> dict:
|
|
466
|
+
"""Size the wheel cluster: h_slew = J*omega, disturbance accumulation,
|
|
467
|
+
momentum capacity, wheel inertia J_w = h_cap / omega_max, torque class."""
|
|
468
|
+
theta_rad = math.radians(mission.slew_angle_deg)
|
|
469
|
+
omega_slew = theta_rad / mission.slew_time_s # rad/s (avg rate)
|
|
470
|
+
h_slew = mission.bus_inertia_kgm2 * omega_slew # N m s
|
|
471
|
+
h_dist = mission.disturbance_torque_nm * orbit_period_s / 2.0
|
|
472
|
+
h_required = (h_slew + h_dist) * mission.adcs_momentum_margin
|
|
473
|
+
h_capacity = 1.0 # product class, N m s
|
|
474
|
+
omega_max = mission.wheel_speed_max_rpm * RPM_TO_RAD_S
|
|
475
|
+
j_wheel = h_capacity / omega_max # kg m^2
|
|
476
|
+
alpha_slew = 4.0 * theta_rad / mission.slew_time_s ** 2 # trapezoid approx
|
|
477
|
+
tau_required = mission.bus_inertia_kgm2 * alpha_slew
|
|
478
|
+
return {
|
|
479
|
+
"omega_slew_rad_s": omega_slew,
|
|
480
|
+
"slew_momentum_nms": h_slew,
|
|
481
|
+
"disturbance_momentum_nms": h_dist,
|
|
482
|
+
"required_momentum_nms": h_required,
|
|
483
|
+
"wheel_momentum_capacity_nms": h_capacity,
|
|
484
|
+
"wheel_speed_max_rpm": mission.wheel_speed_max_rpm,
|
|
485
|
+
"wheel_inertia_kgm2": j_wheel,
|
|
486
|
+
"slew_alpha_rad_s2": alpha_slew,
|
|
487
|
+
"required_wheel_torque_nm": tau_required,
|
|
488
|
+
"wheel_torque_class_nm": 0.02,
|
|
489
|
+
"wheel_configuration": "3 orthogonal wheels + 1 redundant",
|
|
490
|
+
"slew_angle_deg": mission.slew_angle_deg,
|
|
491
|
+
"slew_time_s": mission.slew_time_s,
|
|
492
|
+
"bus_inertia_kgm2": mission.bus_inertia_kgm2,
|
|
493
|
+
"disturbance_torque_nm": mission.disturbance_torque_nm,
|
|
494
|
+
"adcs_momentum_margin": mission.adcs_momentum_margin,
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def pointing_error_budget(mission: SpacecraftMission) -> dict:
|
|
499
|
+
"""RSS of 3-sigma pointing error contributors vs the requirement."""
|
|
500
|
+
rss = math.sqrt(sum(v * v for v in mission.pointing_errors_deg.values()))
|
|
501
|
+
return {
|
|
502
|
+
"contributors": dict(mission.pointing_errors_deg),
|
|
503
|
+
"rss_deg": rss,
|
|
504
|
+
"requirement_deg": mission.pointing_requirement_deg,
|
|
505
|
+
"margin": (mission.pointing_requirement_deg / rss
|
|
506
|
+
if rss > 0 else float("inf")),
|
|
507
|
+
"meets": rss <= mission.pointing_requirement_deg,
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ---------------------------------------------------------------------------
|
|
512
|
+
# Comms link budget (mirrors communication-link-budget leaf, Friis)
|
|
513
|
+
# ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
def slant_range_m(altitude_km: float, elevation_deg: float,
|
|
516
|
+
radius_earth: float = RE_EARTH) -> float:
|
|
517
|
+
"""Slant range to a ground station at the given elevation mask, m."""
|
|
518
|
+
re = radius_earth
|
|
519
|
+
eps = math.radians(elevation_deg)
|
|
520
|
+
r = re + altitude_km * 1000.0
|
|
521
|
+
d = (math.sqrt(r * r - (re * math.cos(eps)) ** 2)
|
|
522
|
+
- re * math.sin(eps))
|
|
523
|
+
return d
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def free_space_path_loss(distance_m: float, freq_hz: float) -> float:
|
|
527
|
+
"""L = 20*log10(4*pi*d/lambda), dB."""
|
|
528
|
+
if distance_m <= 0 or freq_hz <= 0:
|
|
529
|
+
raise ValueError("distance and frequency must be > 0")
|
|
530
|
+
lam = SPEED_LIGHT / freq_hz
|
|
531
|
+
return 20.0 * math.log10(4.0 * math.pi * distance_m / lam)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def link_margin(distance_m: float, freq_hz: float, tx_power_w: float,
|
|
535
|
+
tx_gain_db: float, rx_gain_db: float, noise_temp_k: float,
|
|
536
|
+
data_rate_bps: float, required_ebno_db: float,
|
|
537
|
+
other_losses_db: float = 0.0) -> dict:
|
|
538
|
+
"""Full one-way link: EIRP -> path loss -> C/N0 -> Eb/N0 margin."""
|
|
539
|
+
if tx_power_w <= 0 or noise_temp_k <= 0 or data_rate_bps <= 0:
|
|
540
|
+
raise ValueError("power, temperature and rate must be > 0")
|
|
541
|
+
eirp = 10.0 * math.log10(tx_power_w) + tx_gain_db
|
|
542
|
+
fsl = free_space_path_loss(distance_m, freq_hz)
|
|
543
|
+
pr = eirp + rx_gain_db - fsl - other_losses_db
|
|
544
|
+
cno = pr + K_BOLTZ - 10.0 * math.log10(noise_temp_k)
|
|
545
|
+
ebno = cno - 10.0 * math.log10(data_rate_bps)
|
|
546
|
+
margin = ebno - required_ebno_db
|
|
547
|
+
return {
|
|
548
|
+
"eirp_dbw": eirp, "path_loss_db": fsl, "received_power_dbw": pr,
|
|
549
|
+
"cno_db_hz": cno, "ebno_db": ebno,
|
|
550
|
+
"required_ebno_db": required_ebno_db, "margin_db": margin,
|
|
551
|
+
"ok": margin >= 0.0,
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def comms_link_budget(mission: SpacecraftMission) -> dict:
|
|
556
|
+
"""Downlink and uplink budgets plus daily downlink data capacity."""
|
|
557
|
+
d = slant_range_m(mission.altitude_km, mission.link_elevation_deg)
|
|
558
|
+
down = link_margin(d, mission.downlink_freq_hz,
|
|
559
|
+
mission.downlink_tx_power_w, mission.downlink_tx_gain_db,
|
|
560
|
+
mission.downlink_rx_gain_db,
|
|
561
|
+
mission.downlink_rx_noise_temp_k,
|
|
562
|
+
mission.downlink_data_rate_bps,
|
|
563
|
+
mission.downlink_required_ebno_db,
|
|
564
|
+
mission.implementation_loss_db)
|
|
565
|
+
up = link_margin(d, mission.uplink_freq_hz, mission.uplink_tx_power_w,
|
|
566
|
+
mission.uplink_tx_gain_db, mission.uplink_rx_gain_db,
|
|
567
|
+
mission.uplink_rx_noise_temp_k,
|
|
568
|
+
mission.uplink_data_rate_bps,
|
|
569
|
+
mission.uplink_required_ebno_db, 1.0)
|
|
570
|
+
pass_data_gb = (mission.downlink_data_rate_bps
|
|
571
|
+
* mission.pass_duration_min * 60.0 / 8.0 / 1e9)
|
|
572
|
+
daily_capacity_gb = pass_data_gb * mission.passes_per_day
|
|
573
|
+
return {
|
|
574
|
+
"slant_range_km": d / 1000.0,
|
|
575
|
+
"elevation_deg": mission.link_elevation_deg,
|
|
576
|
+
"downlink": down, "uplink": up,
|
|
577
|
+
"downlink_freq_hz": mission.downlink_freq_hz,
|
|
578
|
+
"uplink_freq_hz": mission.uplink_freq_hz,
|
|
579
|
+
"downlink_data_rate_bps": mission.downlink_data_rate_bps,
|
|
580
|
+
"uplink_data_rate_bps": mission.uplink_data_rate_bps,
|
|
581
|
+
"pass_data_gb": pass_data_gb,
|
|
582
|
+
"passes_per_day": mission.passes_per_day,
|
|
583
|
+
"pass_duration_min": mission.pass_duration_min,
|
|
584
|
+
"daily_capacity_gb": daily_capacity_gb,
|
|
585
|
+
"imaging_data_gb_per_day": mission.imaging_data_gb_per_day,
|
|
586
|
+
"data_margin": daily_capacity_gb / mission.imaging_data_gb_per_day,
|
|
587
|
+
"storage_gb": mission.on_board_storage_gb,
|
|
588
|
+
"link_margin_requirement_db": mission.link_margin_requirement_db,
|
|
589
|
+
"downlink_meets": down["margin_db"] >= mission.link_margin_requirement_db,
|
|
590
|
+
"uplink_meets": up["margin_db"] >= mission.link_margin_requirement_db,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# ---------------------------------------------------------------------------
|
|
595
|
+
# Mass budget closure
|
|
596
|
+
# ---------------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
def mass_budget(mission: SpacecraftMission) -> dict:
|
|
599
|
+
rows = dict(mission.mass_budget_kg)
|
|
600
|
+
total = sum(rows.values())
|
|
601
|
+
return {"rows": rows, "dry_total_kg": total,
|
|
602
|
+
"stated_dry_kg": mission.dry_mass_kg,
|
|
603
|
+
"closes": abs(total - mission.dry_mass_kg) < 1e-6}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# ---------------------------------------------------------------------------
|
|
607
|
+
# Report builder: content model for the deliverable
|
|
608
|
+
# ---------------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
def build_report(mission: SpacecraftMission) -> dict:
|
|
611
|
+
"""Build the complete Mission and Subsystem Design Report model."""
|
|
612
|
+
r = mission.orbit_radius_m()
|
|
613
|
+
dv = mission_delta_v_budget(mission)
|
|
614
|
+
tank = tank_sizing(dv["propellant_mass_kg"],
|
|
615
|
+
mission.propellant_density_kg_m3,
|
|
616
|
+
mission.tank_ullage_fraction, mission.tank_meop_pa,
|
|
617
|
+
mission.tank_burst_factor,
|
|
618
|
+
mission.tank_material_ultimate_pa,
|
|
619
|
+
mission.tank_material_density_kg_m3,
|
|
620
|
+
mission.tank_boss_factor,
|
|
621
|
+
mission.pressurant_gas_constant,
|
|
622
|
+
mission.pressurant_temp_k)
|
|
623
|
+
pw = power_and_thermal(mission)
|
|
624
|
+
adcs = reaction_wheel_sizing(mission, pw["eclipse"]["period_s"])
|
|
625
|
+
pt = pointing_error_budget(mission)
|
|
626
|
+
comm = comms_link_budget(mission)
|
|
627
|
+
mass = mass_budget(mission)
|
|
628
|
+
# closed-budget verdicts
|
|
629
|
+
verdicts = {
|
|
630
|
+
"delta_v_closes": dv["budgeted_dv_m_s"] > 0 and dv["propellant_mass_kg"] > 0,
|
|
631
|
+
"power_closes": (pw["battery_margin"] >= 0.20
|
|
632
|
+
and pw["array_area_m2"] > 0),
|
|
633
|
+
"pointing_meets": pt["meets"],
|
|
634
|
+
"link_meets": comm["downlink_meets"] and comm["uplink_meets"],
|
|
635
|
+
"mass_closes": mass["closes"],
|
|
636
|
+
}
|
|
637
|
+
model = {
|
|
638
|
+
"document_type": "Spacecraft Mission and Subsystem Design Report",
|
|
639
|
+
"status": "draft-for-review",
|
|
640
|
+
"mission": mission.name,
|
|
641
|
+
"mission_summary": mission.mission_summary,
|
|
642
|
+
"orbit": {
|
|
643
|
+
"altitude_km": mission.altitude_km,
|
|
644
|
+
"inclination_deg": mission.inclination_deg,
|
|
645
|
+
"eccentricity": mission.eccentricity,
|
|
646
|
+
"radius_km": r / 1000.0,
|
|
647
|
+
"period_min": pw["eclipse"]["period_min"],
|
|
648
|
+
"period_s": pw["eclipse"]["period_s"],
|
|
649
|
+
"orbits_per_day": pw["eclipse"]["orbits_per_day"],
|
|
650
|
+
"circular_velocity_m_s": circular_velocity(r),
|
|
651
|
+
"launch_vehicle_note": mission.launch_vehicle_note,
|
|
652
|
+
"lifetime_years": mission.lifetime_years,
|
|
653
|
+
},
|
|
654
|
+
"delta_v": dv,
|
|
655
|
+
"tank": tank,
|
|
656
|
+
"power": pw,
|
|
657
|
+
"adcs": adcs,
|
|
658
|
+
"pointing": pt,
|
|
659
|
+
"comms": comm,
|
|
660
|
+
"mass": mass,
|
|
661
|
+
"verdicts": verdicts,
|
|
662
|
+
"generated": _today(),
|
|
663
|
+
}
|
|
664
|
+
return model
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _fmt(v: float, digits: int = 1) -> str:
|
|
668
|
+
return f"{v:.{digits}f}"
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def render_report_markdown(model: dict) -> str:
|
|
672
|
+
"""Render the content model as the deliverable markdown document."""
|
|
673
|
+
o = model["orbit"]
|
|
674
|
+
dv = model["delta_v"]
|
|
675
|
+
tk = model["tank"]
|
|
676
|
+
pw = model["power"]
|
|
677
|
+
ec = pw["eclipse"]
|
|
678
|
+
ad = model["adcs"]
|
|
679
|
+
pt = model["pointing"]
|
|
680
|
+
cm = model["comms"]
|
|
681
|
+
mb = model["mass"]
|
|
682
|
+
v = model["verdicts"]
|
|
683
|
+
|
|
684
|
+
L = []
|
|
685
|
+
L.append("# Spacecraft Mission and Subsystem Design Report")
|
|
686
|
+
L.append("")
|
|
687
|
+
L.append(f"**Spacecraft:** {model['mission']}")
|
|
688
|
+
L.append(f"**Mission:** {model['mission_summary']}")
|
|
689
|
+
L.append(f"**Status:** {model['status']}")
|
|
690
|
+
L.append("")
|
|
691
|
+
L.append("## 1. Mission and orbit")
|
|
692
|
+
L.append("")
|
|
693
|
+
L.append(f"- Orbit: circular, altitude {_fmt(o['altitude_km'])} km, "
|
|
694
|
+
f"inclination {_fmt(o['inclination_deg'], 1)} deg "
|
|
695
|
+
f"(sun-synchronous), e = {o['eccentricity']:.3f}.")
|
|
696
|
+
L.append(f"- Orbit radius: {_fmt(o['radius_km'], 0)} km; period "
|
|
697
|
+
f"{_fmt(o['period_min'], 1)} min ({_fmt(o['period_s'], 0)} s); "
|
|
698
|
+
f"{_fmt(o['orbits_per_day'], 1)} orbits/day; circular velocity "
|
|
699
|
+
f"{_fmt(o['circular_velocity_m_s'], 0)} m/s.")
|
|
700
|
+
L.append(f"- Launch: {o['launch_vehicle_note']}; design life "
|
|
701
|
+
f"{_fmt(o['lifetime_years'], 0)} years.")
|
|
702
|
+
L.append("")
|
|
703
|
+
L.append("## 2. Transfer / maneuvers and delta-v budget")
|
|
704
|
+
L.append("")
|
|
705
|
+
L.append(f"- Injection: direct insertion ({o['launch_vehicle_note']}); "
|
|
706
|
+
"no interplanetary C3 or launch-window constraint applies "
|
|
707
|
+
"(LEO circular mission).")
|
|
708
|
+
L.append("- Delta-v contributions (m/s):")
|
|
709
|
+
for name, dv_c in dv["contributions"].items():
|
|
710
|
+
L.append(f" - {name}: {_fmt(dv_c, 1)}")
|
|
711
|
+
L.append(f"- Disposal leg is a single retrograde burn lowering perigee "
|
|
712
|
+
f"from {_fmt(o['altitude_km'], 0)} km to "
|
|
713
|
+
f"{_fmt(dv['disposal_perigee_altitude_km'], 0)} km "
|
|
714
|
+
f"(Hohmann half-transfer, {_fmt(dv['disposal_dv_m_s'], 1)} m/s).")
|
|
715
|
+
L.append(f"- Nominal total delta-v: {_fmt(dv['nominal_dv_m_s'], 1)} m/s; "
|
|
716
|
+
f"budgeted with {dv['margin_fraction'] * 100:.0f}% margin: "
|
|
717
|
+
f"**{_fmt(dv['budgeted_dv_m_s'], 1)} m/s**.")
|
|
718
|
+
L.append(f"- Propellant (Isp {_fmt(dv['isp_s'], 0)} s, Tsiolkovsky): "
|
|
719
|
+
f"**{_fmt(dv['propellant_mass_kg'], 1)} kg**; wet mass "
|
|
720
|
+
f"{_fmt(dv['wet_mass_kg'], 1)} kg "
|
|
721
|
+
f"({dv['propellant_fraction'] * 100:.1f}% propellant fraction).")
|
|
722
|
+
L.append("")
|
|
723
|
+
L.append("## 3. Environment")
|
|
724
|
+
L.append("")
|
|
725
|
+
L.append(f"- Eclipse (worst case, beta = 0): shadow fraction "
|
|
726
|
+
f"{ec['shadow_fraction'] * 100:.1f}%; eclipse time "
|
|
727
|
+
f"{_fmt(ec['eclipse_time_min'], 1)} min per orbit.")
|
|
728
|
+
L.append(f"- Perturbations at {_fmt(o['altitude_km'], 0)} km SSO: J2 nodal "
|
|
729
|
+
"precession used for the sun-synchronous plane; drag is the "
|
|
730
|
+
"long-term orbit maintenance driver (budgeted above); three-body "
|
|
731
|
+
"effects negligible at this altitude.")
|
|
732
|
+
L.append(f"- Radiation/debris: {_fmt(o['altitude_km'], 0)} km SSO sits in "
|
|
733
|
+
"the inner belt proton/electron environment; parts list to a "
|
|
734
|
+
"25-50 krad(Si) total dose class (ECSS-Q-ST-60-15C practice); "
|
|
735
|
+
"conjunction screening budgeted as 2 avoidance maneuvers over "
|
|
736
|
+
"life.")
|
|
737
|
+
L.append("")
|
|
738
|
+
L.append("## 4. ADCS")
|
|
739
|
+
L.append("")
|
|
740
|
+
L.append("- Determination: star tracker (knowledge ~5 arcsec 1-sigma), "
|
|
741
|
+
"sun sensors, 3-axis rate gyro, magnetometer; TRIAD/QUEST "
|
|
742
|
+
"batch solutions on the C&DH.")
|
|
743
|
+
L.append(f"- Control: {ad['wheel_configuration']} reaction wheels, "
|
|
744
|
+
f"momentum capacity **{_fmt(ad['wheel_momentum_capacity_nms'], 1)} "
|
|
745
|
+
f"N m s** each at {_fmt(ad['wheel_speed_max_rpm'], 0)} rpm "
|
|
746
|
+
f"(wheel inertia {ad['wheel_inertia_kgm2'] * 1e3:.2f} x 1e-3 "
|
|
747
|
+
f"kg m^2, h = J*omega); torque class "
|
|
748
|
+
f"{ad['wheel_torque_class_nm']:.2f} N m; magnetorquers for "
|
|
749
|
+
"momentum desaturation.")
|
|
750
|
+
L.append(f"- Sizing: slew {_fmt(ad['slew_angle_deg'], 0)} deg in "
|
|
751
|
+
f"{_fmt(ad['slew_time_s'], 0)} s -> slew momentum "
|
|
752
|
+
f"{_fmt(ad['slew_momentum_nms'], 3)} N m s "
|
|
753
|
+
f"(J = {_fmt(ad['bus_inertia_kgm2'], 0)} kg m^2 x "
|
|
754
|
+
f"omega {ad['omega_slew_rad_s'] * 1e3:.1f} x 1e-3 rad/s); "
|
|
755
|
+
f"disturbance accumulation {_fmt(ad['disturbance_momentum_nms'], 4)} "
|
|
756
|
+
f"N m s/orbit; required with margin "
|
|
757
|
+
f"{_fmt(ad['required_momentum_nms'], 2)} N m s < capacity 1.0 N m s.")
|
|
758
|
+
L.append(f"- Pointing error budget (3-sigma RSS): "
|
|
759
|
+
f"**{_fmt(pt['rss_deg'], 3)} deg** vs requirement "
|
|
760
|
+
f"{_fmt(pt['requirement_deg'], 1)} deg "
|
|
761
|
+
f"(margin x {pt['margin']:.1f}, {'meets' if pt['meets'] else 'EXCEEDS'}).")
|
|
762
|
+
for src, val in pt["contributors"].items():
|
|
763
|
+
L.append(f" - {src}: {_fmt(val, 3)} deg")
|
|
764
|
+
L.append("")
|
|
765
|
+
L.append("## 5. Power and thermal")
|
|
766
|
+
L.append("")
|
|
767
|
+
L.append("| Item | Day power (W) | Eclipse power (W) |")
|
|
768
|
+
L.append("|---|---|---|")
|
|
769
|
+
for row in pw["loads"]:
|
|
770
|
+
L.append(f"| {row['name']} | {_fmt(row['day_w'], 0)} | "
|
|
771
|
+
f"{_fmt(row['eclipse_w'], 0)} |")
|
|
772
|
+
L.append(f"| **Total** | **{_fmt(pw['day_total_w'], 0)}** | "
|
|
773
|
+
f"**{_fmt(pw['eclipse_total_w'], 0)}** |")
|
|
774
|
+
L.append("")
|
|
775
|
+
L.append(f"- Orbit-average load: {_fmt(pw['orbit_average_w'], 1)} W over a "
|
|
776
|
+
f"{_fmt(o['period_min'], 1)} min orbit "
|
|
777
|
+
f"({ec['shadow_fraction'] * 100:.1f}% eclipse).")
|
|
778
|
+
L.append(f"- Battery: {_fmt(pw['eclipse_total_w'], 0)} W x "
|
|
779
|
+
f"{_fmt(ec['eclipse_time_min'], 1)} min eclipse at "
|
|
780
|
+
f"{pw['battery_dod'] * 100:.0f}% DoD / "
|
|
781
|
+
f"{pw['battery_efficiency'] * 100:.0f}% efficiency requires "
|
|
782
|
+
f"**{_fmt(pw['battery_required_wh'], 1)} Wh**; sized "
|
|
783
|
+
f"**{_fmt(pw['battery_sized_wh'], 0)} Wh** Li-ion "
|
|
784
|
+
f"(margin {pw['battery_margin'] * 100:.0f}%).")
|
|
785
|
+
L.append(f"- Solar array: daylight power "
|
|
786
|
+
f"{_fmt(pw['array_daylight_power_w'], 1)} W; EOL specific power "
|
|
787
|
+
f"{_fmt(pw['array_eol_specific_power_w_m2'], 1)} W/m^2 "
|
|
788
|
+
f"(AM0 {SOLAR_FLUX:.0f} W/m^2, {pw['cell_efficiency'] * 100:.0f}% "
|
|
789
|
+
f"cells, packing {pw['packing_factor']:.2f}, "
|
|
790
|
+
f"{pw['annual_degradation'] * 100:.0f}%/yr x "
|
|
791
|
+
f"{_fmt(o['lifetime_years'], 0)} yr) -> array area "
|
|
792
|
+
f"**{_fmt(pw['array_area_m2'], 2)} m^2**.")
|
|
793
|
+
L.append(f"- Thermal balance: {_fmt(pw['radiator_load_w'], 0)} W continuous "
|
|
794
|
+
f"dissipation rejected by a radiator at "
|
|
795
|
+
f"{pw['radiator_temp_k'] - 273.15:.0f} deg C "
|
|
796
|
+
f"(eps {pw['radiator_emissivity']:.2f}) -> "
|
|
797
|
+
f"**{_fmt(pw['radiator_area_m2'], 2)} m^2**; eclipse heater duty "
|
|
798
|
+
f"25 W is in the load table.")
|
|
799
|
+
L.append("")
|
|
800
|
+
L.append("## 6. Comms")
|
|
801
|
+
L.append("")
|
|
802
|
+
L.append(f"- Slant range at {_fmt(cm['elevation_deg'], 0)} deg elevation: "
|
|
803
|
+
f"{_fmt(cm['slant_range_km'], 0)} km.")
|
|
804
|
+
dl = cm["downlink"]
|
|
805
|
+
ul = cm["uplink"]
|
|
806
|
+
L.append(f"- Downlink (X-band {cm['downlink_freq_hz'] / 1e9:.1f} GHz, "
|
|
807
|
+
f"{cm['downlink_data_rate_bps'] / 1e6:.0f} Mbps): EIRP "
|
|
808
|
+
f"{_fmt(dl['eirp_dbw'], 1)} dBW, path loss "
|
|
809
|
+
f"{_fmt(dl['path_loss_db'], 1)} dB, C/N0 "
|
|
810
|
+
f"{_fmt(dl['cno_db_hz'], 1)} dB-Hz, Eb/N0 "
|
|
811
|
+
f"{_fmt(dl['ebno_db'], 1)} dB vs {_fmt(dl['required_ebno_db'], 1)} "
|
|
812
|
+
f"dB required -> **margin {_fmt(dl['margin_db'], 1)} dB**.")
|
|
813
|
+
L.append(f"- Uplink (S-band {cm['uplink_freq_hz'] / 1e9:.1f} GHz, "
|
|
814
|
+
f"{cm['uplink_data_rate_bps'] / 1e3:.0f} kbps command): "
|
|
815
|
+
f"**margin {_fmt(ul['margin_db'], 1)} dB**.")
|
|
816
|
+
L.append(f"- Data: {_fmt(cm['pass_data_gb'], 1)} GB per {cm['pass_duration_min']:.0f} min "
|
|
817
|
+
f"pass x {cm['passes_per_day']} passes/day = "
|
|
818
|
+
f"**{_fmt(cm['daily_capacity_gb'], 1)} GB/day** capacity vs "
|
|
819
|
+
f"{_fmt(cm['imaging_data_gb_per_day'], 0)} GB/day imaging need "
|
|
820
|
+
f"(margin x {cm['data_margin']:.1f}); on-board storage "
|
|
821
|
+
f"{_fmt(cm['storage_gb'], 0)} GB.")
|
|
822
|
+
L.append("")
|
|
823
|
+
L.append("## 7. Propulsion and C&DH")
|
|
824
|
+
L.append("")
|
|
825
|
+
L.append(f"- Propulsion: {_fmt(dv['propellant_mass_kg'], 1)} kg hydrazine "
|
|
826
|
+
f"(Isp {_fmt(dv['isp_s'], 0)} s) at "
|
|
827
|
+
f"{tk['propellant_density_kg_m3']:.0f} kg/m^3 -> "
|
|
828
|
+
f"{_fmt(tk['propellant_volume_l'], 1)} L propellant, "
|
|
829
|
+
f"{_fmt(tk['tank_volume_l'], 1)} L spherical Ti tank "
|
|
830
|
+
f"(radius {_fmt(tk['radius_m'] * 100, 1)} cm, wall "
|
|
831
|
+
f"{_fmt(tk['wall_thickness_mm'], 2)} mm, shell "
|
|
832
|
+
f"{_fmt(tk['shell_mass_kg'], 2)} kg, pressurant "
|
|
833
|
+
f"{tk['pressurant_mass_kg'] * 1000:.1f} g He); 4 x 1 N thrusters.")
|
|
834
|
+
L.append("- C&DH: single rad-tolerant computer (LEON-class) with "
|
|
835
|
+
"watchdog + triple-redundant command path; ECSS-E-ST-40 / "
|
|
836
|
+
"ECSS-Q-ST-80 software and product assurance context; "
|
|
837
|
+
"on-board storage solid-state recorder with EDAC.")
|
|
838
|
+
L.append("")
|
|
839
|
+
L.append("## 8. System budgets")
|
|
840
|
+
L.append("")
|
|
841
|
+
L.append("| Budget | Value | Margin policy | Closes |")
|
|
842
|
+
L.append("|---|---|---|---|")
|
|
843
|
+
L.append(f"| mass (dry) | {_fmt(mb['dry_total_kg'], 0)} kg (subsystems sum "
|
|
844
|
+
f"to stated {_fmt(mb['stated_dry_kg'], 0)} kg) | 20% at subsystem "
|
|
845
|
+
f"level in stated rows | {'YES' if mb['closes'] else 'NO'} |")
|
|
846
|
+
L.append(f"| mass (wet) | {_fmt(dv['wet_mass_kg'], 1)} kg | propellant on "
|
|
847
|
+
f"top of dry budget | YES |")
|
|
848
|
+
L.append(f"| power | {_fmt(pw['day_total_w'], 0)} W day / "
|
|
849
|
+
f"{_fmt(pw['eclipse_total_w'], 0)} W eclipse | battery "
|
|
850
|
+
f"{pw['battery_margin'] * 100:.0f}%, array 20% | "
|
|
851
|
+
f"{'YES' if v['power_closes'] else 'NO'} |")
|
|
852
|
+
L.append(f"| delta-v | {_fmt(dv['nominal_dv_m_s'], 1)} m/s nominal / "
|
|
853
|
+
f"{_fmt(dv['budgeted_dv_m_s'], 1)} m/s budgeted | "
|
|
854
|
+
f"{dv['margin_fraction'] * 100:.0f}% | "
|
|
855
|
+
f"{'YES' if v['delta_v_closes'] else 'NO'} |")
|
|
856
|
+
L.append(f"| pointing | {_fmt(pt['rss_deg'], 3)} deg (3-sigma) | vs "
|
|
857
|
+
f"{_fmt(pt['requirement_deg'], 1)} deg requirement | "
|
|
858
|
+
f"{'YES' if pt['meets'] else 'NO'} |")
|
|
859
|
+
L.append(f"| link margin | down {_fmt(dl['margin_db'], 1)} dB / "
|
|
860
|
+
f"up {_fmt(ul['margin_db'], 1)} dB | >= "
|
|
861
|
+
f"{cm['link_margin_requirement_db']:.0f} dB | "
|
|
862
|
+
f"{'YES' if v['link_meets'] else 'NO'} |")
|
|
863
|
+
L.append("")
|
|
864
|
+
L.append("## 9. Conclusions and open items")
|
|
865
|
+
L.append("")
|
|
866
|
+
all_ok = all(v.values())
|
|
867
|
+
L.append(f"- Budgets close? **{'YES - all budgets close' if all_ok else 'NO - see verdicts'}** "
|
|
868
|
+
f"(delta-v {v['delta_v_closes']}, power {v['power_closes']}, "
|
|
869
|
+
f"pointing {v['pointing_meets']}, link {v['link_meets']}, "
|
|
870
|
+
f"mass {v['mass_closes']}).")
|
|
871
|
+
L.append("- Open items for detailed phase: refined disturbance torque "
|
|
872
|
+
"model with beta-angle profile; flight-like array incidence/"
|
|
873
|
+
"cosine losses; thermal model with component-level margins; "
|
|
874
|
+
"star tracker alignment campaign; launch-vehicle interface "
|
|
875
|
+
"agreement and actual dispersion.")
|
|
876
|
+
L.append("")
|
|
877
|
+
L.append("---")
|
|
878
|
+
L.append(f"*Generated by Aero Agent Roles space-systems-engineer core "
|
|
879
|
+
f"({model['generated']}). {DRAFT_MARKER}*")
|
|
880
|
+
return "\n".join(L)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
# ---------------------------------------------------------------------------
|
|
884
|
+
# Evidence gate checkers
|
|
885
|
+
# ---------------------------------------------------------------------------
|
|
886
|
+
|
|
887
|
+
GATE_CHECKS = {
|
|
888
|
+
"delta_v_budget_present": "nominal and margin-added budgeted delta-v are numbers",
|
|
889
|
+
"propellant_sized": "propellant mass computed from the rocket equation",
|
|
890
|
+
"wheel_sized": "wheel momentum capacity and inertia sized from h = J*omega",
|
|
891
|
+
"array_sized": "solar array area computed from daylight power and EOL specific power",
|
|
892
|
+
"power_budget_closes": "battery and array margins meet the stated policy",
|
|
893
|
+
"pointing_meets": "RSS pointing error meets the requirement",
|
|
894
|
+
"link_margin_present": "downlink and uplink margins are numbers with pass/fail",
|
|
895
|
+
"sign_off_honest": "document is marked draft, not approval",
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def check_report(model: dict) -> dict:
|
|
900
|
+
"""Run the evidence gates against a report model."""
|
|
901
|
+
dv = model["delta_v"]
|
|
902
|
+
pw = model["power"]
|
|
903
|
+
ad = model["adcs"]
|
|
904
|
+
pt = model["pointing"]
|
|
905
|
+
cm = model["comms"]
|
|
906
|
+
results = {
|
|
907
|
+
"delta_v_budget_present": (isinstance(dv.get("nominal_dv_m_s"), float)
|
|
908
|
+
and isinstance(dv.get("budgeted_dv_m_s"), float)
|
|
909
|
+
and dv["budgeted_dv_m_s"] > dv["nominal_dv_m_s"]),
|
|
910
|
+
"propellant_sized": isinstance(dv.get("propellant_mass_kg"), float)
|
|
911
|
+
and dv["propellant_mass_kg"] > 0,
|
|
912
|
+
"wheel_sized": (ad.get("wheel_momentum_capacity_nms", 0) > 0
|
|
913
|
+
and ad.get("wheel_inertia_kgm2", 0) > 0),
|
|
914
|
+
"array_sized": pw.get("array_area_m2", 0) > 0
|
|
915
|
+
and pw.get("battery_sized_wh", 0) > 0,
|
|
916
|
+
"power_budget_closes": (pw.get("battery_margin", 0) >= 0.20
|
|
917
|
+
and model["verdicts"]["power_closes"]),
|
|
918
|
+
"pointing_meets": pt.get("meets", False),
|
|
919
|
+
"link_margin_present": (isinstance(cm["downlink"].get("margin_db"), float)
|
|
920
|
+
and isinstance(cm["uplink"].get("margin_db"), float)),
|
|
921
|
+
"sign_off_honest": model.get("status") == "draft-for-review",
|
|
922
|
+
}
|
|
923
|
+
results["all_pass"] = all(results.values())
|
|
924
|
+
return results
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def check_report_markdown(md_text: str) -> dict:
|
|
928
|
+
"""Gate-check the rendered markdown deliverable."""
|
|
929
|
+
low = md_text.lower()
|
|
930
|
+
has_number = bool(re.search(r"\d+\.?\d*\s*(m/s|m s|wh|w/m\^2|db|deg|min|kg|km)", low))
|
|
931
|
+
checks = {
|
|
932
|
+
"has_title": "spacecraft mission and subsystem design report" in low,
|
|
933
|
+
"has_numbers": has_number,
|
|
934
|
+
"has_delta_v": "delta-v" in low and "budgeted" in low,
|
|
935
|
+
"has_margin_policy": "margin" in low and "%" in md_text,
|
|
936
|
+
"has_pointing_req": "pointing error budget" in low,
|
|
937
|
+
"has_draft_marker": "draft" in low,
|
|
938
|
+
"has_not_approval": "not an approval" in low,
|
|
939
|
+
"has_not_launch_readiness": "not launch readiness" in low,
|
|
940
|
+
}
|
|
941
|
+
checks["all_pass"] = all(checks.values())
|
|
942
|
+
return checks
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def validate_deliverable_text(md_text: str) -> dict:
|
|
946
|
+
"""Public entry point used by gate tooling: check a report document."""
|
|
947
|
+
return check_report_markdown(md_text)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
# ---------------------------------------------------------------------------
|
|
951
|
+
# Example mission (for tests and worked-example generation)
|
|
952
|
+
# ---------------------------------------------------------------------------
|
|
953
|
+
|
|
954
|
+
def example_mission() -> SpacecraftMission:
|
|
955
|
+
return SpacecraftMission()
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def example_report_markdown() -> str:
|
|
959
|
+
return render_report_markdown(build_report(example_mission()))
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
if __name__ == "__main__":
|
|
963
|
+
mission = example_mission()
|
|
964
|
+
model = build_report(mission)
|
|
965
|
+
md = render_report_markdown(model)
|
|
966
|
+
gates = check_report(model)
|
|
967
|
+
print(f"REPORT: {model['mission']}")
|
|
968
|
+
print(f"DELTA-V: nominal {model['delta_v']['nominal_dv_m_s']:.1f} m/s, "
|
|
969
|
+
f"budgeted {model['delta_v']['budgeted_dv_m_s']:.1f} m/s")
|
|
970
|
+
print(f"PROPELLANT: {model['delta_v']['propellant_mass_kg']:.2f} kg")
|
|
971
|
+
print(f"WHEEL: {model['adcs']['wheel_momentum_capacity_nms']:.2f} N m s, "
|
|
972
|
+
f"J_w = {model['adcs']['wheel_inertia_kgm2']*1e3:.3f} x 1e-3 kg m^2")
|
|
973
|
+
print(f"ARRAY: {model['power']['array_area_m2']:.3f} m^2, "
|
|
974
|
+
f"battery {model['power']['battery_sized_wh']:.0f} Wh")
|
|
975
|
+
print(f"POINTING: {model['pointing']['rss_deg']:.4f} deg vs "
|
|
976
|
+
f"{model['pointing']['requirement_deg']:.1f} deg")
|
|
977
|
+
print(f"LINK: down {model['comms']['downlink']['margin_db']:.1f} dB, "
|
|
978
|
+
f"up {model['comms']['uplink']['margin_db']:.1f} dB")
|
|
979
|
+
print(f"GATES: {gates}")
|
|
980
|
+
print(f"RENDERED: {len(md)} chars")
|