Skip to content

Stream simulation progress live to the browser #2

@jaywonchung

Description

@jaywonchung

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions