STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_flight_propellant_iteration.py
Go to the documentation of this file.
1"""Flight-sim propellant iteration -- regression tests against the shipped default config.
2
3- Tank caps: a fuel tank too small for the requested load is capped by the flight router, a tank
4 that fits the load is not, and an explicit design_requirements capacity wins over geometry.
5- Apogee rises with propellant while the burn is truncated.
6- Excess propellant above the full-burn requirement does not raise apogee.
7- A longer burn with enough propellant raises impulse and apogee.
8
9The previous version of this module read a config out of one developer's ~/Downloads folder and
10asserted a property of that file's tank, so it skipped on every other machine and failed on his
11once the file changed. Everything here is built from configs/default.yaml.
12"""
13
14from __future__ import annotations
15
16import copy
17from pathlib import Path
18
19import numpy as np
20import pytest
21
22pytest.importorskip("rocketpy")
23
24CONFIG = Path(__file__).resolve().parents[1] / "configs" / "default.yaml"
25
26
27@pytest.fixture(scope="module")
28def config():
29 from engine.pipeline.io import load_config
30
31 cfg = load_config(str(CONFIG))
32 # Tanks sized so that no volume cap can bite in the propellant-response tests below; the cap
33 # logic has its own test. (default.yaml's fuel tank holds ~4.7 kg of methane at 100%.)
34 for tank, h in ((cfg.lox_tank, "lox_h"), (cfg.fuel_tank, "rp1_h")):
35 tank.tank_volume_m3 = None
36 setattr(tank, h, 2.0)
37 return cfg
38
39
40def _pressure_profiles(config, duration_s: float, n_points: int = 101):
41 """Exponential blowdown from each tank's configured initial pressure to 70% of it."""
42 from engine.pipeline.time_series import generate_pressure_profile
43
44 P_O0 = float(config.lox_tank.initial_pressure_psi or config.design_requirements.max_lox_tank_pressure_psi)
45 P_F0 = float(config.fuel_tank.initial_pressure_psi or config.design_requirements.max_fuel_tank_pressure_psi)
46 times, lox_psi = generate_pressure_profile("exponential", P_O0, 0.7 * P_O0, duration_s, n_points, decay_constant=3.0)
47 _, fuel_psi = generate_pressure_profile("exponential", P_F0, 0.7 * P_F0, duration_s, n_points, decay_constant=3.0)
48 return times, lox_psi, fuel_psi
49
50
51def _run_timeseries(config, duration_s: float):
52 from engine.core.runner import PintleEngineRunner
53 from backend.routers.timeseries import compute_timeseries_results
54
55 runner = PintleEngineRunner(config)
56 times, lox_psi, fuel_psi = _pressure_profiles(config, duration_s)
57 return compute_timeseries_results(runner, times, lox_psi, fuel_psi, run_copv=False)
58
59
60def _run_flight(config, data, lox_kg: float, fuel_kg: float):
61 from engine.optimizer.copv_flight_helpers import run_flight_simulation
62
63 cfg = copy.deepcopy(config)
64 cfg.lox_tank.mass = lox_kg
65 cfg.fuel_tank.mass = fuel_kg
66 times = np.asarray(data["time"], dtype=float)
67 times = times - times[0]
68 pressure_curves = {
69 "time": times,
70 "thrust": np.asarray(data["thrust_kN"], dtype=float) * 1000.0,
71 "mdot_O": np.asarray(data["mdot_O_kg_s"], dtype=float),
72 "mdot_F": np.asarray(data["mdot_F_kg_s"], dtype=float),
73 }
74 return run_flight_simulation(cfg, pressure_curves, float(times[-1]))
75
76
78 """The flight router caps a load the tank cannot hold, matches the shared resolver, does not
79 touch a load that fits, and honours an explicit design_requirements capacity over geometry."""
80 from backend.routers.flight import _apply_propellant_mass_caps
81 from engine.pipeline.config_schemas import PintleEngineConfig
82 from engine.pipeline.tank_capacity import resolve_fuel_tank_limits
83
84 rho_F = float(config.fluids["fuel"].density)
85 base = config.model_dump()
86 requested = 7.0
87
88 small = copy.deepcopy(base)
89 small["fuel_tank"].update(mass=requested, tank_volume_m3=None, rp1_h=0.3, rp1_radius=0.05)
90 adj, _, fuel_max, fill_factor = _apply_propellant_mass_caps(small, config)
91 expected_max, _, expected_ff, explicit = resolve_fuel_tank_limits(PintleEngineConfig(**small), rho_F)
92 assert not explicit
93 assert fuel_max == pytest.approx(expected_max) and fill_factor == pytest.approx(expected_ff)
94 assert fuel_max < requested, "test premise: this tank must be too small for the load"
95 assert adj["fuel"]["was_capped"] is True
96 assert small["fuel_tank"]["mass"] == pytest.approx(fuel_max), "router must write the capped mass back"
97 assert adj["fuel"]["original"] == pytest.approx(requested) and adj["fuel"]["capped"] == pytest.approx(fuel_max)
98
99 big = copy.deepcopy(base)
100 big["fuel_tank"].update(mass=requested, tank_volume_m3=None, rp1_h=2.0, rp1_radius=0.15)
101 adj, _, fuel_max, _ = _apply_propellant_mass_caps(big, config)
102 assert fuel_max > requested
103 assert adj["fuel"]["was_capped"] is False
104 assert big["fuel_tank"]["mass"] == pytest.approx(requested), "a load that fits must not be touched"
105
106 capped = copy.deepcopy(big)
107 capped["design_requirements"]["fuel_tank_capacity_kg"] = 3.0
108 adj, _, fuel_max, _ = _apply_propellant_mass_caps(capped, config)
109 assert fuel_max == pytest.approx(3.0) and adj["fuel"]["explicit_capacity_kg"] == pytest.approx(3.0)
110 assert adj["fuel"]["was_capped"] is True and capped["fuel_tank"]["mass"] == pytest.approx(3.0)
111
112
114 data, summary = _run_timeseries(config, duration_s=6.8)
115
116 lox_required = float(summary.get("lox_propellant_kg") or 0)
117 fuel_required = float(summary.get("fuel_propellant_kg") or 0)
118 assert lox_required > 0 and fuel_required > 0
119
120 low = _run_flight(config, data, max(0.5, lox_required * 0.45), max(0.3, fuel_required * 0.45))
121 high = _run_flight(config, data, lox_required * 1.05, fuel_required * 1.05)
122
123 assert low.get("success"), low.get("error")
124 assert high.get("success"), high.get("error")
125 assert low["truncation_info"].get("truncated") is True
126 assert high["apogee"] > low["apogee"] + 5.0, (
127 f"Expected higher propellant to raise apogee when truncated: low={low['apogee']:.1f}m high={high['apogee']:.1f}m"
128 )
129
130
132 data, summary = _run_timeseries(config, duration_s=3.5)
133
134 lox_required = float(summary["lox_propellant_kg"])
135 fuel_required = float(summary["fuel_propellant_kg"])
136
137 optimal = _run_flight(config, data, lox_required * 1.02, fuel_required * 1.02)
138 heavy = _run_flight(config, data, lox_required * 1.35, fuel_required * 1.35)
139
140 assert optimal.get("success"), optimal.get("error")
141 assert heavy.get("success"), heavy.get("error")
142 assert optimal["truncation_info"].get("truncated") is False
143 assert heavy["apogee"] <= optimal["apogee"] + 15.0, (
144 f"Excess propellant should not increase apogee: optimal={optimal['apogee']:.1f}m heavy={heavy['apogee']:.1f}m"
145 )
146
147
149 short_data, short_summary = _run_timeseries(config, duration_s=4.0)
150 long_data, long_summary = _run_timeseries(config, duration_s=8.0)
151
152 short_imp = float(short_summary["total_impulse_kNs"])
153 long_imp = float(long_summary["total_impulse_kNs"])
154 assert long_imp > short_imp * 1.15, "Longer burn should deliver materially more impulse"
155
156 short_flight = _run_flight(
157 config, short_data,
158 float(short_summary["lox_propellant_kg"]) * 1.1, float(short_summary["fuel_propellant_kg"]) * 1.1,
159 )
160 long_flight = _run_flight(
161 config, long_data,
162 float(long_summary["lox_propellant_kg"]) * 1.1, float(long_summary["fuel_propellant_kg"]) * 1.1,
163 )
164
165 assert short_flight.get("success"), short_flight.get("error")
166 assert long_flight.get("success"), long_flight.get("error")
167 assert long_flight["apogee"] > short_flight["apogee"] + 20.0, (
168 f"Longer burn + enough propellant should raise apogee: short={short_flight['apogee']:.1f}m "
169 f"long={long_flight['apogee']:.1f}m"
170 )
#define max(a, b)
Definition Arduino.h:43
_run_flight(config, data, float lox_kg, float fuel_kg)
_pressure_profiles(config, float duration_s, int n_points=101)