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:
+4
-1
@@ -110,6 +110,7 @@ if __name__ == '__main__':
|
||||
# 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.
|
||||
startup_water = config.get('Pot', {}).get('water_mass', 0)
|
||||
startup_params = None
|
||||
if startup_water > 0:
|
||||
startup_params = sud.derive_plant_params(0, startup_water)
|
||||
pot.set_plant_params(startup_params)
|
||||
@@ -131,8 +132,10 @@ if __name__ == '__main__':
|
||||
|
||||
# Continuous server-session log - records from startup to shutdown
|
||||
# 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)
|
||||
if startup_params is not None:
|
||||
server_log_task.log_plant_params(0, startup_params)
|
||||
taskmgr.add(server_log_task)
|
||||
|
||||
# Assign data flow
|
||||
|
||||
+5
-2
@@ -16,20 +16,21 @@ class ServerLogTask(ATask):
|
||||
Sud state — useful for verifying controller and heater behaviour
|
||||
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)
|
||||
self.tc = tc
|
||||
self.heater = heater
|
||||
self.heater_task = heater_task
|
||||
self.path = path
|
||||
self.config = config
|
||||
self.dt = dt
|
||||
self._samples = []
|
||||
self._param_events = []
|
||||
self._t0 = time.monotonic()
|
||||
self._run_id = time.strftime("%Y%m%dT%H%M%S", time.localtime())
|
||||
|
||||
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):
|
||||
while True:
|
||||
@@ -53,6 +54,8 @@ class ServerLogTask(ATask):
|
||||
filename = 'log_{}.json'.format(self._run_id)
|
||||
path = os.path.join(self.path, filename)
|
||||
log_data = {'Name': ''}
|
||||
if self.dt is not None:
|
||||
log_data['dt'] = self.dt
|
||||
if self.config is not None:
|
||||
log_data['Config'] = self.config
|
||||
if self._param_events:
|
||||
|
||||
+57
-23
@@ -41,7 +41,7 @@ from matplotlib.ticker import AutoMinorLocator
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -70,37 +70,65 @@ def _infer_heatrate_soll_set(samples):
|
||||
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):
|
||||
"""Closed-loop simulation: controller drives plant, plant feeds temp back.
|
||||
def run_replay(samples, pid_type, pid_params, heatrate_soll_set, plant_params, param_events,
|
||||
ambient, dt):
|
||||
"""Open-loop plant replay with parallel controller observation.
|
||||
|
||||
The plant is seeded at the real starting temperature from the log so the
|
||||
simulation begins at a realistic operating point. Setpoints follow the
|
||||
real run's temp_soll schedule sample by sample."""
|
||||
tc = TempController(dt)
|
||||
The plant is driven by the real logged power_eff each tick so that
|
||||
sim_temp_ist tracks real_temp_ist when the plant model is accurate.
|
||||
The controller runs in parallel (observing sim_temp) to produce a
|
||||
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_ambient_temperature(ambient)
|
||||
tc.set_model_plant_params(plant_params)
|
||||
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.set_plant_params(plant_params)
|
||||
plant.set_plant_params(replay_plant_params)
|
||||
plant.set_ambient_temperature(ambient)
|
||||
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 = []
|
||||
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()
|
||||
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_soll(s['temp_soll'])
|
||||
tc.set_heatrate_soll(heatrate_soll_set)
|
||||
tc.process()
|
||||
|
||||
power_watts = max(0.0, tc.get_power() * max_power * heater_efficiency)
|
||||
plant.set_power(power_watts)
|
||||
# Drive plant and Smith model with the real logged power.
|
||||
real_power_watts = s['power_eff']
|
||||
tc.set_model_power(real_power_watts)
|
||||
plant.set_power(real_power_watts)
|
||||
|
||||
out.append({
|
||||
'sim_temp_ist': sim_temp,
|
||||
'power': tc.get_power(),
|
||||
'power': max(0.0, tc.get_power()),
|
||||
'state': tc.state.name,
|
||||
'heatrate_ist': tc.get_heatrate_ist(),
|
||||
'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.legend(loc='upper left', fontsize='small')
|
||||
|
||||
# Power: log (normalised) vs sim controller output
|
||||
log_norm = [s['power_set'] / max_power for s in samples]
|
||||
replay_pwr = [r['power'] for r in replay]
|
||||
ax_power.plot(t, log_norm, '-b', linewidth=1, label='power (real, norm.)')
|
||||
ax_power.plot(t, replay_pwr, '-', color='C1', linewidth=1, label='power (sim)')
|
||||
# Power: power_set (what the controller commanded) and power_eff (what
|
||||
# the heater actually delivered, quantised to its discrete steps).
|
||||
# The gap between the two is the heater's rounding/quantisation.
|
||||
pwr_set = [s['power_set'] / max_power for s in samples]
|
||||
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 [0–1]')
|
||||
ax_power.set_xlabel('t [s]')
|
||||
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-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('--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 gain in ('kp', 'ki', 'kd', 'kt'):
|
||||
@@ -225,9 +253,13 @@ def main():
|
||||
}
|
||||
|
||||
samples = log['Samples']
|
||||
param_events = log.get('PlantParams', [])
|
||||
name = log.get('Name', log_path.stem)
|
||||
pid_type = config['TempCtrl'].get('pid_type', 'Normal')
|
||||
pid_params = _apply_gain_overrides(config['TempCtrl'], args)
|
||||
dt = (samples[1]['t'] - samples[0]['t']) if len(samples) > 1 else 1.0
|
||||
# 'dt' is the physics timestep the controller/plant were created with —
|
||||
# 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)
|
||||
@@ -240,11 +272,12 @@ def main():
|
||||
config_source = 'log' if log.get('Config') else 'file'
|
||||
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('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(
|
||||
amb=ambient, eff=args.heater_efficiency, **plant_params))
|
||||
print('Plant: M={M} kg C={C:.0f} J/kgK L={L} W/kgK Td={Td} s ambient={amb} °C'.format(
|
||||
amb=ambient, **plant_params))
|
||||
print('Controller: {}'.format(pid_type))
|
||||
print('Params: {} ({})'.format('overridden' if any_override else 'from {}'.format(config_source),
|
||||
'log' if log_plant else 'defaults'))
|
||||
print()
|
||||
@@ -252,7 +285,8 @@ def main():
|
||||
p = pid_params[section]
|
||||
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)
|
||||
plt.show()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user