fix: embed dt and startup plant params in server log; open-loop replay_sim

server/brewpi.py: pass physics DT to ServerLogTask (not wall-clock DT_TASK)
so logs carry the correct integration timestep; log startup plant params as
the first PlantParams event so replay_sim can reconstruct them even when no
Sud is ever loaded (previously fell back to hardcoded defaults, causing ~3°C
temperature offset in replay).

tasks/server_log.py: accept and embed dt in log JSON; switch
log_plant_params() to wall-clock monotonic time (same base as sample t),
since the elapsed argument was simulated time and not comparable.

utils/replay_sim.py: switch to open-loop replay (plant driven by logged
power_eff, not sim controller output); use PidFactory so Smith vs Normal
controller type is honoured; set Td=0 in replay plant for Smith mode (logged
temp_ist is already Smith-corrected); feed Smith predictor's internal model
with real power; apply embedded PlantParams mid-run; prefer embedded dt over
sample-based fallback; clamp sim power ≥ 0; replace meaningless sim-power
trace in power subplot with power_set vs power_eff from the log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 00:31:46 +02:00
co-authored by Claude Sonnet 4.6
parent 4a1c969ad0
commit 57c23f293a
3 changed files with 72 additions and 32 deletions
+4 -1
View File
@@ -110,6 +110,7 @@ if __name__ == '__main__':
# the kettle at startup (e.g. strike water pre-filled). A loaded sud's # the kettle at startup (e.g. strike water pre-filled). A loaded sud's
# own apply_plant_params() will override this on the first step. # own apply_plant_params() will override this on the first step.
startup_water = config.get('Pot', {}).get('water_mass', 0) startup_water = config.get('Pot', {}).get('water_mass', 0)
startup_params = None
if startup_water > 0: if startup_water > 0:
startup_params = sud.derive_plant_params(0, startup_water) startup_params = sud.derive_plant_params(0, startup_water)
pot.set_plant_params(startup_params) pot.set_plant_params(startup_params)
@@ -131,8 +132,10 @@ if __name__ == '__main__':
# Continuous server-session log - records from startup to shutdown # Continuous server-session log - records from startup to shutdown
# regardless of Sud state; written to logs/log_{date_time}.json on exit. # regardless of Sud state; written to logs/log_{date_time}.json on exit.
server_log_task = ServerLogTask(tc, heater, heater_task, DT_TASK, args.logdir, config=config) server_log_task = ServerLogTask(tc, heater, heater_task, DT_TASK, args.logdir, config=config, dt=DT)
sud_task.set_on_plant_params(server_log_task.log_plant_params) sud_task.set_on_plant_params(server_log_task.log_plant_params)
if startup_params is not None:
server_log_task.log_plant_params(0, startup_params)
taskmgr.add(server_log_task) taskmgr.add(server_log_task)
# Assign data flow # Assign data flow
+5 -2
View File
@@ -16,20 +16,21 @@ class ServerLogTask(ATask):
Sud state — useful for verifying controller and heater behaviour Sud state — useful for verifying controller and heater behaviour
outside of a scheduled brew.""" outside of a scheduled brew."""
def __init__(self, tc: APid, heater: AHeater, heater_task, interval, path='./logs', config=None): def __init__(self, tc: APid, heater: AHeater, heater_task, interval, path='./logs', config=None, dt=None):
ATask.__init__(self, interval) ATask.__init__(self, interval)
self.tc = tc self.tc = tc
self.heater = heater self.heater = heater
self.heater_task = heater_task self.heater_task = heater_task
self.path = path self.path = path
self.config = config self.config = config
self.dt = dt
self._samples = [] self._samples = []
self._param_events = [] self._param_events = []
self._t0 = time.monotonic() self._t0 = time.monotonic()
self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime()) self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime())
def log_plant_params(self, elapsed, params): def log_plant_params(self, elapsed, params):
self._param_events.append({'t': elapsed, 'params': params}) self._param_events.append({'t': time.monotonic() - self._t0, 'params': params})
async def on_process(self): async def on_process(self):
while True: while True:
@@ -53,6 +54,8 @@ class ServerLogTask(ATask):
filename = 'log_{}.json'.format(self._run_id) filename = 'log_{}.json'.format(self._run_id)
path = os.path.join(self.path, filename) path = os.path.join(self.path, filename)
log_data = {'Name': ''} log_data = {'Name': ''}
if self.dt is not None:
log_data['dt'] = self.dt
if self.config is not None: if self.config is not None:
log_data['Config'] = self.config log_data['Config'] = self.config
if self._param_events: if self._param_events:
+63 -29
View File
@@ -41,7 +41,7 @@ from matplotlib.ticker import AutoMinorLocator
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from components.pid.temp_controller import TempController from components.pid import PidFactory
from components.plant.pot import Pot from components.plant.pot import Pot
@@ -70,37 +70,65 @@ def _infer_heatrate_soll_set(samples):
return max(candidates, default=1.0) or 1.0 return max(candidates, default=1.0) or 1.0
def run_replay(samples, pid_params, heatrate_soll_set, plant_params, ambient, max_power, heater_efficiency, dt): def run_replay(samples, pid_type, pid_params, heatrate_soll_set, plant_params, param_events,
"""Closed-loop simulation: controller drives plant, plant feeds temp back. ambient, dt):
"""Open-loop plant replay with parallel controller observation.
The plant is seeded at the real starting temperature from the log so the The plant is driven by the real logged power_eff each tick so that
simulation begins at a realistic operating point. Setpoints follow the sim_temp_ist tracks real_temp_ist when the plant model is accurate.
real run's temp_soll schedule sample by sample.""" The controller runs in parallel (observing sim_temp) to produce a
tc = TempController(dt) comparable power signal — useful to spot gain issues — but its output
does NOT feed back into the plant.
param_events is a list of {t, params} dicts (from the log's PlantParams
section) applied at their logged wall-clock t so the simulated plant
tracks the same M/C changes (grain added, water boiling off) as the
real run."""
tc = PidFactory.create(pid_type, dt)
tc.set_params(pid_params) tc.set_params(pid_params)
tc.set_ambient_temperature(ambient)
tc.set_model_plant_params(plant_params)
tc.set_enabled(True) tc.set_enabled(True)
# The logged temp_ist is Smith-corrected (transport delay already removed
# by the predictor), so the replay plant must not add another Td on top.
# For Normal mode the raw sensor reading is logged so keep Td as-is.
replay_plant_params = dict(plant_params, Td=0) if pid_type == 'Smith' else plant_params
plant = Pot(dt) plant = Pot(dt)
plant.set_plant_params(plant_params) plant.set_plant_params(replay_plant_params)
plant.set_ambient_temperature(ambient) plant.set_ambient_temperature(ambient)
plant.initial(samples[0]['temp_ist']) plant.initial(samples[0]['temp_ist'])
# Walk param_events in order, applying each when the sample t passes it.
param_queue = list(param_events)
param_idx = 0
out = [] out = []
for s in samples: for s in samples:
# Apply any plant param changes that fall at or before this sample.
while param_idx < len(param_queue) and param_queue[param_idx]['t'] <= s['t']:
p = param_queue[param_idx]['params']
plant.set_plant_params(dict(p, Td=0) if pid_type == 'Smith' else p)
tc.set_model_plant_params(p)
param_idx += 1
plant.process() plant.process()
sim_temp = plant.get_temperature() sim_temp = plant.get_temperature()
# Controller observes sim_temp but its output doesn't drive the plant.
tc.set_theta_ist(sim_temp) tc.set_theta_ist(sim_temp)
tc.set_theta_soll(s['temp_soll']) tc.set_theta_soll(s['temp_soll'])
tc.set_heatrate_soll(heatrate_soll_set) tc.set_heatrate_soll(heatrate_soll_set)
tc.process() tc.process()
power_watts = max(0.0, tc.get_power() * max_power * heater_efficiency) # Drive plant and Smith model with the real logged power.
plant.set_power(power_watts) real_power_watts = s['power_eff']
tc.set_model_power(real_power_watts)
plant.set_power(real_power_watts)
out.append({ out.append({
'sim_temp_ist': sim_temp, 'sim_temp_ist': sim_temp,
'power': tc.get_power(), 'power': max(0.0, tc.get_power()),
'state': tc.state.name, 'state': tc.state.name,
'heatrate_ist': tc.get_heatrate_ist(), 'heatrate_ist': tc.get_heatrate_ist(),
'heatrate_soll': tc.get_heatrate_soll(), 'heatrate_soll': tc.get_heatrate_soll(),
@@ -140,11 +168,13 @@ def plot_replay(samples, replay, name, max_power, params_label):
ax_rate.set_ylabel('Rate [K/min]') ax_rate.set_ylabel('Rate [K/min]')
ax_rate.legend(loc='upper left', fontsize='small') ax_rate.legend(loc='upper left', fontsize='small')
# Power: log (normalised) vs sim controller output # Power: power_set (what the controller commanded) and power_eff (what
log_norm = [s['power_set'] / max_power for s in samples] # the heater actually delivered, quantised to its discrete steps).
replay_pwr = [r['power'] for r in replay] # The gap between the two is the heater's rounding/quantisation.
ax_power.plot(t, log_norm, '-b', linewidth=1, label='power (real, norm.)') pwr_set = [s['power_set'] / max_power for s in samples]
ax_power.plot(t, replay_pwr, '-', color='C1', linewidth=1, label='power (sim)') pwr_eff = [s['power_eff'] / max_power for s in samples]
ax_power.plot(t, pwr_set, '-b', linewidth=1, label='power_set (norm.)')
ax_power.plot(t, pwr_eff, '-', color='C1', linewidth=1, alpha=0.7, label='power_eff (norm.)')
ax_power.set_ylabel('Power [01]') ax_power.set_ylabel('Power [01]')
ax_power.set_xlabel('t [s]') ax_power.set_xlabel('t [s]')
ax_power.legend(loc='upper left', fontsize='small') ax_power.legend(loc='upper left', fontsize='small')
@@ -180,8 +210,6 @@ def main():
parser.add_argument('--plant-C', type=float, default=None, metavar='J/kgK', help='Specific heat [J/(kg·K)]') parser.add_argument('--plant-C', type=float, default=None, metavar='J/kgK', help='Specific heat [J/(kg·K)]')
parser.add_argument('--plant-L', type=float, default=None, metavar='W/kgK', help='Heat loss coeff [W/(kg·K)]') parser.add_argument('--plant-L', type=float, default=None, metavar='W/kgK', help='Heat loss coeff [W/(kg·K)]')
parser.add_argument('--plant-Td', type=float, default=None, metavar='s', help='Transport delay [s]') parser.add_argument('--plant-Td', type=float, default=None, metavar='s', help='Transport delay [s]')
parser.add_argument('--heater-efficiency', type=float, default=1.0, metavar='0-1',
help='Fraction of rated heater power delivered to the plant (default: %(default)s)')
for section, prefix in (('Hold', 'hold'), ('Heat', 'heat'), ('Cool', 'cool')): for section, prefix in (('Hold', 'hold'), ('Heat', 'heat'), ('Cool', 'cool')):
for gain in ('kp', 'ki', 'kd', 'kt'): for gain in ('kp', 'ki', 'kd', 'kt'):
@@ -224,13 +252,17 @@ def main():
'Td': args.plant_Td if args.plant_Td is not None else (log_plant['Td'] if log_plant else 30.0), 'Td': args.plant_Td if args.plant_Td is not None else (log_plant['Td'] if log_plant else 30.0),
} }
samples = log['Samples'] samples = log['Samples']
name = log.get('Name', log_path.stem) param_events = log.get('PlantParams', [])
pid_params = _apply_gain_overrides(config['TempCtrl'], args) name = log.get('Name', log_path.stem)
dt = (samples[1]['t'] - samples[0]['t']) if len(samples) > 1 else 1.0 pid_type = config['TempCtrl'].get('pid_type', 'Normal')
max_power = _infer_max_power(samples) pid_params = _apply_gain_overrides(config['TempCtrl'], args)
rate_soll = args.rate_soll if args.rate_soll is not None else _infer_heatrate_soll_set(samples) # 'dt' is the physics timestep the controller/plant were created with —
ambient = args.ambient if args.ambient is not None else config.get('ambient_temperature', 20.0) # distinct from the sample interval (wall-clock) in server logs.
dt = log.get('dt') or ((samples[1]['t'] - samples[0]['t']) if len(samples) > 1 else 1.0)
max_power = _infer_max_power(samples)
rate_soll = args.rate_soll if args.rate_soll is not None else _infer_heatrate_soll_set(samples)
ambient = args.ambient if args.ambient is not None else config.get('ambient_temperature', 20.0)
any_override = any( any_override = any(
getattr(args, '{}_{}'.format(p, g)) is not None getattr(args, '{}_{}'.format(p, g)) is not None
@@ -240,11 +272,12 @@ def main():
config_source = 'log' if log.get('Config') else 'file' config_source = 'log' if log.get('Config') else 'file'
params_label = 'candidate' if any_override else config_source params_label = 'candidate' if any_override else config_source
print('Log: {} ({} samples, dt={:.1f} s)'.format(name, len(samples), dt)) print('Log: {} ({} samples, dt={} s)'.format(name, len(samples), round(dt, 4)))
print('Power: max={:.0f} W'.format(max_power)) print('Power: max={:.0f} W'.format(max_power))
print('Rate: heatrate_soll_set={:.2f} K/min'.format(rate_soll)) print('Rate: heatrate_soll_set={:.2f} K/min'.format(rate_soll))
print('Plant: M={M} kg C={C:.0f} J/kgK L={L} W/kgK Td={Td} s ambient={amb} °C heater_efficiency={eff}'.format( print('Plant: M={M} kg C={C:.0f} J/kgK L={L} W/kgK Td={Td} s ambient={amb} °C'.format(
amb=ambient, eff=args.heater_efficiency, **plant_params)) amb=ambient, **plant_params))
print('Controller: {}'.format(pid_type))
print('Params: {} ({})'.format('overridden' if any_override else 'from {}'.format(config_source), print('Params: {} ({})'.format('overridden' if any_override else 'from {}'.format(config_source),
'log' if log_plant else 'defaults')) 'log' if log_plant else 'defaults'))
print() print()
@@ -252,7 +285,8 @@ def main():
p = pid_params[section] p = pid_params[section]
print(' {}: kp={kp} ki={ki} kd={kd} kt={kt}'.format(section, **p)) print(' {}: kp={kp} ki={ki} kd={kd} kt={kt}'.format(section, **p))
replay = run_replay(samples, pid_params, rate_soll, plant_params, ambient, max_power, args.heater_efficiency, dt) replay = run_replay(samples, pid_type, pid_params, rate_soll, plant_params, param_events,
ambient, dt)
plot_replay(samples, replay, name, max_power, params_label) plot_replay(samples, replay, name, max_power, params_label)
plt.show() plt.show()