STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_compute_endpoints_offload.py
Go to the documentation of this file.
1"""The page-load solves must not run on the event loop.
2
3Forward Mode evaluates every design it opens (POST /api/evaluate) and the
4Time-Series tab runs it (POST /api/timeseries/generate, or /from-segments) --
5about 10 s of CPU on a laptop and ~23 s on a CI runner. As ``async def``
6endpoints calling the runner inline, that ran on the asyncio event loop and
7froze every other request for as long: /api/health (a page opened meanwhile sat
8on "Connecting"), the design bar's autosave, the documents API. The Playwright
9suite failed on it on 2026-10-17 (design-persistence "panel state ... survives a
10reload", and "Connected" timing out on connect-no-loop).
11
12Same shape as test_config_endpoints_offload.py: the endpoint coroutines are
13driven directly with asyncio.run, the solve is replaced by a spy that records the
14thread it ran on, and the assertion is that it is NOT the event-loop thread.
15Inline the call again and these fail. The spy raises once it has recorded, so
16nothing here runs the physics.
17"""
18
19from __future__ import annotations
20
21import asyncio
22import threading
23import time
24
25import pytest
26from fastapi import HTTPException
27from starlette.requests import Request
28
29from backend import session as session_mod
30from backend.session import get_session
31from backend.routers import evaluate as evaluate_router
32from backend.routers import timeseries as timeseries_router
33
34
35class _Recorded(Exception):
36 """Raised by a spy once it has recorded its thread: the solve itself is not under test."""
37
38
39def _request(email: str) -> Request:
40 return Request(
41 {
42 "type": "http",
43 "method": "POST",
44 "path": "/",
45 "query_string": b"",
46 "headers": [(b"x-auth-email", email.encode())],
47 }
48 )
49
50
51def _drive(run_endpoint) -> tuple[int, int | None]:
52 """Run `run_endpoint(session)` on a fresh loop; return (loop thread, spy thread)."""
53 captured: dict[str, int] = {}
54
55 def spy(*_args, **_kwargs):
56 captured["tid"] = threading.get_ident()
57 raise _Recorded()
58
59 async def main():
60 captured["loop"] = threading.get_ident()
61 session = get_session(_request("compute-offload-test@berkeley.edu"))
62 assert session.app_state.has_config(), "default config should be loaded"
63 # The endpoints wrap any failure of the solve in a 500; that is the spy's raise.
64 with pytest.raises(HTTPException):
65 await run_endpoint(session, spy)
66
67 asyncio.run(main())
68 return captured["loop"], captured.get("tid")
69
70
72 async def run(session, spy):
73 monkeypatch.setattr(session.app_state.runner, "evaluate", spy)
74 await evaluate_router.evaluate(
75 evaluate_router.EvaluateRequest(lox_pressure_psi=750, fuel_pressure_psi=600),
76 session=session,
77 )
78
79 loop_tid, tid = _drive(run)
80 assert tid is not None, "/api/evaluate did not call runner.evaluate"
81 assert tid != loop_tid, (
82 "runner.evaluate ran on the event-loop thread -- every request waits for it. "
83 "Run it through session.run_compute."
84 )
85
86
88 async def run(session, spy):
89 monkeypatch.setattr(timeseries_router, "compute_timeseries_results", spy)
90 profile = {"profile_type": "linear", "start_pressure_psi": 750, "end_pressure_psi": 525}
91 await timeseries_router.generate_timeseries(
92 timeseries_router.GenerateProfileRequest(
93 duration_s=5.0, n_steps=11, lox_profile=profile, fuel_profile=profile,
94 ),
95 session=session,
96 )
97
98 loop_tid, tid = _drive(run)
99 assert tid is not None, "/api/timeseries/generate did not call compute_timeseries_results"
100 assert tid != loop_tid, (
101 "the Time-Series run went on the event-loop thread -- /api/health and every other "
102 "request wait the whole run (~23 s on CI). Run it through session.run_compute."
103 )
104
105
107 async def run(session, spy):
108 monkeypatch.setattr(timeseries_router, "compute_timeseries_results", spy)
109 seg = {"length_ratio": 1.0, "type": "linear", "start_pressure_psi": 750, "end_pressure_psi": 525}
110 await timeseries_router.generate_from_segments(
111 timeseries_router.SegmentsRequest(
112 duration_s=5.0, n_points=11, lox_segments=[seg], fuel_segments=[seg],
113 ),
114 session=session,
115 )
116
117 loop_tid, tid = _drive(run)
118 assert tid is not None, "/api/timeseries/from-segments did not call compute_timeseries_results"
119 assert tid != loop_tid, "the segments run went on the event-loop thread"
120
121
123 """Off the loop, two solves of one session must not share its runner at once."""
124 active = 0
125 peak = 0
126 guard = threading.Lock()
127
128 def solve():
129 nonlocal active, peak
130 with guard:
131 active += 1
132 peak = max(peak, active)
133 time.sleep(0.05)
134 with guard:
135 active -= 1
136
137 async def main():
138 session = get_session(_request("compute-offload-test@berkeley.edu"))
139 await asyncio.gather(*(session_mod.run_compute(session, solve) for _ in range(3)))
140
141 asyncio.run(main())
142 assert peak == 1, f"{peak} solves of one session ran at once"
143
144
145# --- One design per solve -----------------------------------------------------------------------
146#
147# On the event loop nothing could run while a solve did, so the config a solve read at the end was
148# the one it started with. In the thread pool a PUT /api/config (set_config, itself in the thread
149# pool) can land mid-solve. A solve takes one (config, runner) pair, AppState.snapshot(), and uses
150# only that; set_config swaps the pair together.
151
152
154 from engine.pipeline.io import load_config
155
156 return load_config(str(session_mod._DEFAULT_CONFIG_PATH))
157
158
160 """Forward Mode's report describes the design the numbers came from."""
161 import engine.pipeline.config_switch as config_switch
162 import engine.pipeline.forward_report as forward_report_mod
163 import engine.pipeline.handcheck as handcheck_mod
164
165 described: list = []
166 monkeypatch.setattr(config_switch, "design_staleness", lambda cfg: described.append(cfg))
167 monkeypatch.setattr(
168 handcheck_mod, "handcheck",
169 lambda cfg, *_a, **_k: described.append(cfg) or {"rows": [], "flags": []},
170 )
171 monkeypatch.setattr(
172 forward_report_mod, "forward_report", lambda cfg, *_a, **_k: described.append(cfg) or {},
173 )
174
175 async def main():
176 session = get_session(_request("compute-snapshot-evaluate@berkeley.edu"))
177 solved_on = session.app_state.config
178 other = _other_config()
179
180 def solve(*_args, **_kwargs):
181 session.app_state.set_config(other) # the edit lands while the chamber solves
182 return {"P_ambient": 101325.0, "elevation": 0.0}
183
184 monkeypatch.setattr(session.app_state.runner, "evaluate", solve)
185 await evaluate_router.evaluate(
186 evaluate_router.EvaluateRequest(lox_pressure_psi=750, fuel_pressure_psi=600),
187 session=session,
188 )
189 assert session.app_state.config is other, "the mid-solve write did not land; nothing tested"
190 return solved_on, other
191
192 solved_on, other = asyncio.run(main())
193 assert len(described) == 3, described
194 assert all(cfg is solved_on for cfg in described), (
195 "the report was built from a config written while the solve ran -- one design's numbers "
196 "against another's. Take session.app_state.snapshot() once, before the solve."
197 )
198
199
201 """The blowdown loop calls the engine every step; a mid-run edit must not split the burn."""
202 import copv.blowdown_solver as blowdown
203
204 engines: list[str] = []
205
206 def engine(name):
207 def evaluate(**_kwargs):
208 engines.append(name)
209 return {"mdot_O": 1.0, "mdot_F": 0.8}
210
211 return evaluate
212
213 monkeypatch.setattr(
214 blowdown, "resolve_blowdown_tank_state", lambda *_a, **_k: (0.02, 10.0, None, 0.02, 8.0, None),
215 )
216
217 async def main():
218 session = get_session(_request("compute-snapshot-blowdown@berkeley.edu"))
219 monkeypatch.setattr(session.app_state.runner, "evaluate", engine("started on"))
220 other = _other_config()
221
222 def simulate(*, evaluate_engine_fn, **_kwargs):
223 evaluate_engine_fn(5e6, 4e6)
224 session.app_state.set_config(other) # the edit lands mid-burn
225 monkeypatch.setattr(session.app_state.runner, "evaluate", engine("written mid-run"))
226 evaluate_engine_fn(5e6, 4e6)
227 raise _Recorded()
228
229 monkeypatch.setattr(blowdown, "simulate_coupled_blowdown", simulate)
230 with pytest.raises(HTTPException):
231 await timeseries_router.generate_from_segments(
232 timeseries_router.SegmentsRequest(
233 duration_s=5.0, n_points=11, blowdown_mode=True,
234 lox_initial_pressure_psi=600, fuel_initial_pressure_psi=550,
235 ),
236 session=session,
237 )
238
239 asyncio.run(main())
240 assert engines == ["started on", "started on"], (
241 f"engine calls went to {engines}: the blowdown run switched runners when the config was "
242 "written mid-run. Take the runner once, from session.app_state.snapshot()."
243 )
244
245
247 """While the new runner builds (seconds, for a CEA cache), a solve that starts takes the old pair."""
248 from backend import state as state_mod
249
250 app = state_mod.AppState()
251 old_config, old_runner, new_config = object(), object(), object()
252 app.config, app.runner = old_config, old_runner
253 taken: list = []
254
255 class BuildingRunner:
256 def __init__(self, config):
257 # What a solve starting now, on another thread, would take. Not blocked by the build.
258 t = threading.Thread(target=lambda: taken.append(app.snapshot()))
259 t.start()
260 t.join(timeout=5)
261 assert not t.is_alive(), "snapshot() waited for the runner to build"
262
263 monkeypatch.setattr(state_mod, "PintleEngineRunner", BuildingRunner)
264 app.set_config(new_config)
265
266 assert taken == [(old_config, old_runner)], (
267 "a solve starting while the runner built took the new config beside the old runner"
268 )
269 assert app.config is new_config and isinstance(app.runner, BuildingRunner)
270
271
272# --- Flight and controller ----------------------------------------------------------------------
273#
274# The same freeze, measured on the LE4 (3) stand's engine with /api/health polled every 50 ms:
275# /api/flight/optimize-altitude left health unanswered for 6.3 s, /api/flight/simulate 0.6 s; the
276# controller's /simulate and /simulate-layer2 loop the DDP solve and the engine inline.
277
278from backend.routers import control as control_router # noqa: E402
279from backend.routers import flight as flight_router # noqa: E402
280
281
283 return dict(
284 time_array=[0.0, 1.0, 2.0],
285 thrust_array=[6800.0, 6800.0, 6800.0],
286 mdot_O_array=[1.9, 1.9, 1.9],
287 mdot_F_array=[1.2, 1.2, 1.2],
288 )
289
290
292 pytest.importorskip("rocketpy", reason="the flight endpoints need RocketPy")
293
294 async def run(session, spy):
295 monkeypatch.setattr(flight_router, "_execute_flight_simulation", spy)
296 await flight_router.simulate_flight(flight_router.FlightSimRequest(**_flight_arrays()), session=session)
297
298 loop_tid, tid = _drive(run)
299 assert tid is not None, "/api/flight/simulate did not fly"
300 assert tid != loop_tid, "the flight ran on the event-loop thread"
301
302
304 pytest.importorskip("rocketpy", reason="the flight endpoints need RocketPy")
306
307 async def run(session, spy):
308 monkeypatch.setattr(altitude, "optimize_minimum_fuel_burn_time", spy)
309 await flight_router.optimize_flight_altitude(
310 flight_router.FlightOptimizeRequest(**_flight_arrays(), target_apogee_m=1000.0),
311 session=session,
312 )
313
314 loop_tid, tid = _drive(run)
315 assert tid is not None, "/api/flight/optimize-altitude did not search"
316 assert tid != loop_tid, (
317 "the altitude search (a dozen flights) ran on the event-loop thread -- /api/health waited "
318 "6.3 s for it on the LE4 stand"
319 )
320
321
322def _meas():
323 return control_router.MeasurementRequest(
324 P_copv=18.96e6, P_reg=6.89e6, P_u_fuel=3.5e6, P_u_ox=3.5e6, P_d_fuel=3.4e6, P_d_ox=3.4e6,
325 )
326
327
328def _nav():
329 return control_router.NavStateRequest(h=0.0, vz=0.0, theta=0.0, mass_estimate=100.0)
330
331
333 return control_router.CommandRequest(command_type="thrust_desired", thrust_desired=6800.0)
334
335
336@pytest.mark.parametrize("endpoint", ["init", "simulate", "simulate-layer2"])
338 """Each builds the DDP controller (its engine wrapper) and, but for init, loops it."""
339
340 async def run(session, spy):
341 session.controller.controller = None
342 monkeypatch.setattr(control_router, "RobustDDPController", spy)
343 if endpoint == "init":
344 await control_router.init_controller(control_router.ControllerInitRequest(), session=session)
345 elif endpoint == "simulate":
346 await control_router.simulate_controller(
347 control_router.ControllerSimulateRequest(
348 initial_meas=_meas(), initial_nav=_nav(), cmd=_thrust_cmd(), duration=0.1,
349 ),
350 session=session,
351 )
352 else:
353 await control_router.simulate_layer2_controller(
354 control_router.Layer2ControllerSimulateRequest(
355 thrust_curve_time=[0.0, 0.1], thrust_curve_values=[6800.0, 6800.0],
356 ),
357 session=session,
358 )
359
360 loop_tid, tid = _drive(run)
361 assert tid is not None, f"/api/control/{endpoint} did not build the controller"
362 assert tid != loop_tid, f"/api/control/{endpoint} ran its solve on the event-loop thread"
363
364
366 import types
367
368 async def run(session, spy):
369 session.controller.controller = types.SimpleNamespace(step=spy)
370 await control_router.controller_step(
371 control_router.ControllerStepRequest(meas=_meas(), nav=_nav(), cmd=_thrust_cmd()),
372 session=session,
373 )
374
375 try:
376 loop_tid, tid = _drive(run)
377 finally:
378 session_mod.registry.get("compute-offload-test@berkeley.edu").controller.controller = None
379 assert tid is not None, "/api/control/step did not step the controller"
380 assert tid != loop_tid, "the controller step ran on the event-loop thread"
#define max(a, b)
Definition Arduino.h:43
Definition run.py:1
test_a_config_written_during_a_blowdown_run_does_not_switch_its_runner(monkeypatch)
test_controller_solves_run_off_the_event_loop(monkeypatch, endpoint)
tuple[int, int|None] _drive(run_endpoint)
int main()