SudForecastEstimator.estimate() builds a fresh Pot/temperature controller per call and never set its initial theta_soll, leaving it at TempControllerBase's default of 0. For the upfront, full-schedule Forecast this was harmless - the first real step always carries its own 'temperature', overwriting it instantly. But the dynamic RemainingForecast is recomputed from wherever the live run currently is, and since ramping is no longer gated by a 'ramp' key, the remaining schedule's first step often has no 'temperature' of its own (e.g. resuming from a WAIT_USER pause into a hold-only step) - a fresh tc has no persisted target to inherit it from like the real run's tc does, so it span chasing 0 degrees, hit MAX_TICKS, and produced a ~200,000-point result instead of the expected ~10,000. That oversized payload (~6MB) is what was actually tripping websockets' default 1MB max_size and disconnecting clients with "1009 (message too big)" - not the GUI threading issue fixed separately. Verified live: the same remaining-schedule case that previously hit MAX_TICKS (200001 points) now resolves in 8348, and a full driven run (load, start, auto-confirm both WAIT_USER steps, completion) produces no oversized messages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
103 lines
4.0 KiB
Python
103 lines
4.0 KiB
Python
from components.plant import Pot
|
|
from components.pid import PidFactory
|
|
from components.sud import Sud, SudState
|
|
|
|
# Safety cap so a schedule whose target a step can never actually reach
|
|
# (e.g. a "hold" colder than ambient with no active cooling) can't hang the
|
|
# simulation forever - the estimate is simply cut off there.
|
|
MAX_TICKS = 200000
|
|
|
|
|
|
class SudForecastEstimator:
|
|
"""Predicts how long a Sud schedule will actually take by simulating it
|
|
with the same machinery (and params) the real server's brewpi.py wires
|
|
up - a fresh Pot and temperature controller of the configured pid_type,
|
|
driven through the schedule exactly as tasks/sud.py's SudTask would.
|
|
|
|
This is deliberately independent of wall-clock/asyncio time: it just
|
|
iterates dt-sized ticks as fast as the CPU allows (a multi-hour brew
|
|
simulates in well under a second), so it can be run synchronously
|
|
whenever a client needs an estimate - the naive "abs(delta)/rate" model
|
|
the GUI used to compute itself has no way to see the real PID cascade's
|
|
spin-up/settling lag, which is exactly why its estimate drifted so far
|
|
from reality (see README.md's "Forecast vs. actual duration")."""
|
|
|
|
def __init__(self, dt, theta_amb, plant_params, pid_type, tempctrl_params, heater_max_power):
|
|
self.dt = dt
|
|
self.theta_amb = theta_amb
|
|
self.plant_params = plant_params
|
|
self.pid_type = pid_type
|
|
self.tempctrl_params = tempctrl_params
|
|
self.heater_max_power = heater_max_power
|
|
|
|
def set_ambient_temperature(self, theta_amb):
|
|
self.theta_amb = theta_amb
|
|
|
|
def estimate(self, doc, start_theta=None):
|
|
"""Returns (t, theta): parallel lists of elapsed simulated seconds
|
|
and temperature, one point per tick, for the given sud.json
|
|
document. start_theta defaults to the configured ambient
|
|
temperature - i.e. a cold start, same as the GUI's static
|
|
estimate."""
|
|
if start_theta is None:
|
|
start_theta = self.theta_amb
|
|
|
|
sud = Sud()
|
|
if not sud.load(doc) or not sud.schedule:
|
|
return [0.0], [start_theta]
|
|
|
|
pot = Pot(self.dt, self.plant_params, self.theta_amb)
|
|
pot.initial(start_theta)
|
|
tc = PidFactory.create(self.pid_type, self.dt, self.tempctrl_params, self.plant_params, theta_amb=self.theta_amb)
|
|
tc.set_enabled(True)
|
|
tc.set_theta_ist(pot.get_temperature())
|
|
# Seed the target at start_theta - this tc is a fresh, throwaway
|
|
# instance (unlike the real run's persistent one), so without this
|
|
# its theta_soll_set defaults to 0 until a step pushes its own.
|
|
# Steps without their own 'temperature' (common now that ramping
|
|
# isn't gated by a 'ramp' key - see components/sud.py) rely on
|
|
# inheriting whatever target was already running, which for a
|
|
# schedule starting mid-brew (the dynamic remaining forecast) is
|
|
# start_theta, not 0 - without this, such a schedule's first step
|
|
# would have the simulated controller chase 0 degrees indefinitely,
|
|
# hitting MAX_TICKS and producing a needlessly huge result.
|
|
tc.set_theta_soll(start_theta)
|
|
|
|
def on_step_changed(step):
|
|
if step is None:
|
|
return
|
|
params = sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0))
|
|
pot.set_thermal_params(params['M'], params['C'])
|
|
if hasattr(tc, 'set_model_params'):
|
|
tc.set_model_params(params['M'], params['C'])
|
|
if sud.state == SudState.RAMPING and step['temperature'] is not None:
|
|
tc.set_theta_soll(step['temperature'])
|
|
tc.set_heatrate_soll(step['ramp']['rate'])
|
|
sud.set_on_changed('step', on_step_changed)
|
|
|
|
t = [0.0]
|
|
theta = [pot.get_temperature()]
|
|
|
|
sud.start()
|
|
ticks = 0
|
|
while sud.state != SudState.DONE and ticks < MAX_TICKS:
|
|
pot.process()
|
|
tc.set_theta_ist(pot.get_temperature())
|
|
tc.process()
|
|
pot.set_power(max(0, self.heater_max_power * tc.get_power()))
|
|
|
|
if sud.state == SudState.RAMPING:
|
|
if tc.is_holding():
|
|
sud.temp_reached()
|
|
elif sud.state == SudState.WAIT_USER:
|
|
# A real user's confirm time isn't predictable - count it
|
|
# as instant for estimation purposes.
|
|
sud.confirm()
|
|
sud.tick(self.dt)
|
|
|
|
t.append(t[-1] + self.dt)
|
|
theta.append(pot.get_temperature())
|
|
ticks += 1
|
|
|
|
return t, theta
|