Depends on #1, specifically the submit/poll -> blocking-endpoint conversion in #1's last cleanup. Streaming on top of polling+SQLite would be an architectural overhaul; streaming on top of a clean blocking endpoint is a small refactor.
Today the server runs the simulation to completion and returns the result in one shot. Replace that with per-tick streaming: the frontend opens a WebSocket, the backend run_iter()s the coordinator and pushes each TickOutput as it produces it. The producer runs as fast as the CPU allows; the frontend paints each tick as it arrives. OpenG2G 0.2.0 (just released) adds the method run_iter, so it needs to be updated.
This is a required UX fix. Today, no one has the patience to wait multiple seconds after pressing "Run." If nothing moves nearly immediately; they will assume it's broken.
This also sets up #4 (interactive controls); user-issued commands ride back over the same WebSocket.
Backend
from fastapi import WebSocket
from fastapi.concurrency import iterate_in_threadpool
from openg2g.coordinator import Coordinator
@app.websocket("/sim/stream")
async def sim_stream(ws: WebSocket, params: SimParams = ...):
await ws.accept()
coord = build_coordinator(params)
def producer():
for tick in coord.run_iter():
yield serialize_tick(tick)
async for payload in iterate_in_threadpool(producer()):
await ws.send_json(payload)
Three things to nail down:
serialize_tick(tick). TickOutput has t_s, dc_states, grid_state, commands, sim_events. Ship the subset the UI paints, not the whole object. Aim for a few KB per tick.
- Concurrency. Each session holds a worker for the full simulation duration. With the current
ProcessPoolExecutor(max_workers=2) that's a 2-concurrent-user ceiling. Options: bump max_workers; spawn a fresh multiprocessing.Process per session; or keep the producer on the asyncio loop via loop.run_in_executor. Pick one and document the per-session cost.
- Disconnect cleanup.
run_iter()'s try / finally calls coord.stop() when the consumer disconnects. Test that closing the tab actually releases the worker.
Frontend
const ws = new WebSocket("ws://.../sim/stream?config=...");
ws.onmessage = (evt) => {
const tick = JSON.parse(evt.data);
updateVoltageChart(tick.grid_state.voltages);
updateBatchSizes(tick.dc_states);
appendEventLog(tick.sim_events);
};
Same state updates the existing code already does, driven by an event stream instead of a one-shot fetch.
If you ever want a real time mode that paces the simulation to wall-clock time (one simulated second per real second: useful for interactive demos in #4), pass live=True to the Coordinator constructor. Off by default; this only makes sense when the user explicitly wants to watch unfold at human pace.
Depends on #1, specifically the submit/poll -> blocking-endpoint conversion in #1's last cleanup. Streaming on top of polling+SQLite would be an architectural overhaul; streaming on top of a clean blocking endpoint is a small refactor.
Today the server runs the simulation to completion and returns the result in one shot. Replace that with per-tick streaming: the frontend opens a WebSocket, the backend
run_iter()s the coordinator and pushes eachTickOutputas it produces it. The producer runs as fast as the CPU allows; the frontend paints each tick as it arrives. OpenG2G 0.2.0 (just released) adds the methodrun_iter, so it needs to be updated.This is a required UX fix. Today, no one has the patience to wait multiple seconds after pressing "Run." If nothing moves nearly immediately; they will assume it's broken.
This also sets up #4 (interactive controls); user-issued commands ride back over the same WebSocket.
Backend
Three things to nail down:
serialize_tick(tick).TickOutputhast_s,dc_states,grid_state,commands,sim_events. Ship the subset the UI paints, not the whole object. Aim for a few KB per tick.ProcessPoolExecutor(max_workers=2)that's a 2-concurrent-user ceiling. Options: bumpmax_workers; spawn a freshmultiprocessing.Processper session; or keep the producer on the asyncio loop vialoop.run_in_executor. Pick one and document the per-session cost.run_iter()'stry / finallycallscoord.stop()when the consumer disconnects. Test that closing the tab actually releases the worker.Frontend
Same state updates the existing code already does, driven by an event stream instead of a one-shot fetch.
If you ever want a real time mode that paces the simulation to wall-clock time (one simulated second per real second: useful for interactive demos in #4), pass
live=Trueto theCoordinatorconstructor. Off by default; this only makes sense when the user explicitly wants to watch unfold at human pace.