Cap the forecast sent over the wire to avoid exceeding WebSocket max_size

A fine enough dt over a multi-hour brew can produce a single Forecast
message several MB in size - large enough to exceed the websockets
library's default 1 MiB max_size and get the connection closed outright
(code 1009) right after Load. Confirmed by reproducing with dt=0.1: the
276KB message at dt=1 was safe, but the equivalent ~2.7MB at dt=0.1
reliably disconnected the client.

SudTask._send_forecast() now thins T/Theta to at most MAX_FORECAST_POINTS
(1000) via simple decimation before sending - self.forecast_t/
forecast_theta themselves stay at full simulated resolution, since
_continue_forecast_after_confirm()'s bisect-based truncation needs exact
t-value matches, and SudLogTask's logs/forecast_*.json wants full
fidelity. Only the copy actually sent to clients is thinned, which the
GUI's few-hundred-pixel-wide plot can't show the difference from anyway.

Verified against a live server+client over the real WebSocket connection
with the dt=0.1 config that originally triggered the disconnect: 4/4
clean runs, forecast arrives capped under 1000 points, and the rendered
plot is visually identical to the un-downsampled version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
This commit is contained in:
2026-06-22 21:28:30 +02:00
co-authored by Claude Sonnet 4.6
parent 4d98dacb49
commit 442b032117
+31 -2
View File
@@ -7,6 +7,34 @@ from components import APid, AStirrer
from components.plant import APlant
from components.sud import Sud, SudState
# Upper bound on how many (t, theta) points the forecast is thinned to
# before going out over the wire (see _send_forecast()) - a fine enough
# dt over a multi-hour brew can otherwise produce a single JSON message
# of several MB, large enough to exceed the websockets library's default
# 1 MiB max_size and get the connection closed outright (code 1009). The
# GUI's forecast plot is a few hundred pixels wide, so this many points
# is already far more resolution than it can show; self.forecast_t/
# forecast_theta themselves stay at full simulated resolution - this
# only thins the copy actually sent to clients.
MAX_FORECAST_POINTS = 1000
def _downsample(t, theta, max_points=MAX_FORECAST_POINTS):
"""Returns (t, theta) thinned to at most max_points entries by simple
decimation, always keeping the first and last point - losing a few
intermediate samples doesn't matter for a plot this size, but losing
the endpoints would visibly truncate the curve or its final value."""
n = len(t)
if n <= max_points:
return t, theta
step = -(-n // max_points) # ceil(n / max_points)
t_ds = t[::step]
theta_ds = theta[::step]
if t_ds[-1] != t[-1]:
t_ds = t_ds + [t[-1]]
theta_ds = theta_ds + [theta[-1]]
return t_ds, theta_ds
class SudTask(ATask):
def __init__(self, sud: Sud, tc: APid, stirrer: AStirrer, pot: APlant, dt, interval, msg_handler: MsgIo,
@@ -166,9 +194,10 @@ class SudTask(ATask):
await self._send_forecast()
async def _send_forecast(self):
t, theta = _downsample(self.forecast_t, self.forecast_theta)
await self.send({'Forecast': {
'T': self.forecast_t,
'Theta': self.forecast_theta,
'T': t,
'Theta': theta,
'Finished': self.forecast_finished,
}})