Add a comprehensive mash-schedule demo

Drives a real Smith-predictor TempController, Pot, HeaterSim, and
StirrerSim through sude/sud_0010.json end-to-end (mirroring brewpi.py's
wiring, including the model/plant power-discretization mismatch and
tasks/sud.py's stirrer-switching rules), auto-confirming waitForUser
pauses so it completes unattended. Plots theta/heatrate (actual vs.
target), heater power_set/power_eff, Sud state, rest index, and
stirrer speed/duty cycle over time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 18:11:13 +02:00
co-authored by Claude Sonnet 4.6
parent 7e131df4ca
commit 395eda5eb4
2 changed files with 172 additions and 0 deletions
View File
+172
View File
@@ -0,0 +1,172 @@
import numpy as np
from matplotlib.pyplot import plot, step, figure, subplot, grid, show, legend, yticks
from components.sud import Sud, SudState
from components.pid.temp_controller_smith import TempController
from components.plant.pot import Pot
from components.actor.stirrersim import StirrerSim
from components.actor.heater_sim import HeaterSim
# Mirrors tasks/sud.py's SudTask, but driven synchronously for a demo run.
TEMP_REACHED_TOLERANCE = 0.2
# A real user would click "Confirm" in the GUI; auto-confirm after this many
# simulated seconds so the demo can run to completion unattended.
WAIT_USER_AUTO_CONFIRM_S = 30.0
if __name__ == '__main__':
dt = 1.0
theta_amb = 20
ctrl_params = {
"Hold": {
"kp": 0.4,
"ki": 0.0,
"kd": 0.0,
"kt": 0.0
},
"Heat": {
"kp": 0.08,
"ki": 0.008,
"kd": 0.0,
"kt": 1.5
}
}
plant_params = {
"theta" : 20,
"C" : 4190,
"M" : 20,
"L" : 0.2,
"Td" : 30,
"gain" : 1.0
}
sud = Sud("sude/sud_0010.json")
ctrl = TempController(dt, ctrl_params, plant_params, theta_amb)
plant = Pot(dt, plant_params, theta_amb)
stirrer = StirrerSim(dt)
heater = HeaterSim({})
# Mirror brewpi.py's data flow: the controller's internal Smith-predictor
# model sees the (continuous) requested power, the real plant sees the
# heater's actual (discretized) output power.
heater.set_on_changed('power_set', ctrl.set_model_power)
heater.set_on_changed('power_eff', plant.set_power)
def on_rast_changed(rast):
if rast is not None:
ctrl.set_theta_soll(rast['temp'])
ctrl.set_heatrate_soll(rast['heatRate'])
def on_state_changed(state):
if state == SudState.RAMPING:
stirrer.set_duty_cycle(1.0)
stirrer.set_speed(sud.stirr_speed_heat)
elif state == SudState.HOLDING:
stirrer.set_cycle_time(sud.stirr_cycle_time)
stirrer.set_duty_cycle(sud.stirr_duty_rast)
stirrer.set_speed(sud.stirr_speed_rast)
elif state in (SudState.WAIT_USER, SudState.DONE):
stirrer.set_duty_cycle(1.0)
stirrer.set_speed(0)
sud.set_on_changed('rast', on_rast_changed)
sud.set_on_changed('state', on_state_changed)
sud.start()
_t = []
_temp_ist = []
_temp_soll = []
_heatrate_ist = []
_heatrate_soll = []
_sud_state = []
_rast_index = []
_stirrer_speed = []
_stirrer_duty = []
_heater_power_set = []
_heater_power_eff = []
wait_user_elapsed = 0.0
t = 0.0
steps = 0
max_steps = 30000
while sud.state != SudState.DONE and steps < max_steps:
plant.process()
ctrl.set_theta_ist(plant.get_temperature())
ctrl.process()
y = ctrl.get_power()
heater.set_power(max(0, 3500*y))
heater.process()
stirrer.process()
if sud.state == SudState.RAMPING:
if abs(ctrl.get_theta_ist() - ctrl.get_theta_soll_set()) < TEMP_REACHED_TOLERANCE:
sud.temp_reached()
if sud.state == SudState.WAIT_USER:
wait_user_elapsed += dt
if wait_user_elapsed >= WAIT_USER_AUTO_CONFIRM_S:
sud.confirm()
wait_user_elapsed = 0.0
else:
wait_user_elapsed = 0.0
sud.tick(dt)
_t.append(t)
_temp_ist.append(ctrl.get_theta_ist())
_temp_soll.append(ctrl.get_theta_soll_set())
_heatrate_ist.append(ctrl.get_heatrate_ist())
_heatrate_soll.append(ctrl.get_heatrate_soll())
_sud_state.append(sud.state.value)
_rast_index.append(sud.index)
_stirrer_speed.append(stirrer.speed * stirrer.isOn)
_stirrer_duty.append(stirrer.dutyCycle * 100)
_heater_power_set.append(heater.power_set)
_heater_power_eff.append(heater.power_eff)
t += dt
steps += 1
if sud.state != SudState.DONE:
print("Sud did not finish within {} steps (stuck in {})".format(max_steps, sud.state))
print("Sud finished after {:.0f} s ({:.1f} min), final state = {}".format(t, t/60.0, sud.state))
_t_min = np.array(_t) / 60.0
figure(1)
subplot(3, 1, 1)
plot(_t_min, _temp_ist, '-b', _t_min, _temp_soll, '-r', linewidth=1)
legend(["theta_ist", "theta_soll"])
grid(True)
subplot(3, 1, 2)
plot(_t_min, _heatrate_ist, '-b', _t_min, _heatrate_soll, '-r', linewidth=1)
legend(["heatrate_ist", "heatrate_soll"])
grid(True)
subplot(3, 1, 3)
plot(_t_min, _heater_power_set, '-r', _t_min, _heater_power_eff, '-b', linewidth=1)
legend(["heater power_set", "heater power_eff"])
grid(True)
figure(2)
subplot(3, 1, 1)
step(_t_min, _sud_state, where='post', color='m')
yticks([0, 1, 2, 3, 4], ["IDLE", "RAMPING", "HOLDING", "WAIT_USER", "DONE"])
legend(["Sud state"])
grid(True)
subplot(3, 1, 2)
step(_t_min, _rast_index, where='post', color='k')
legend(["Rast index"])
grid(True)
subplot(3, 1, 3)
plot(_t_min, _stirrer_speed, '-g', _t_min, _stirrer_duty, '-c', linewidth=1)
legend(["stirrer speed (effective)", "stirrer duty cycle [%]"])
grid(True)
show()
print("End of program")