Add Closed-loop/Open-loop heater control mode
Closed-loop (default, checkbox checked): TC has exclusive control over the heater; temp-soll and heatrate-soll sliders are active; direct heater power entry is disabled. TC now stays enabled at server startup and whenever closed-loop mode is active — SudTask no longer disables it on IDLE/DONE, so adjusting temp-soll manually with no sud running immediately drives the heater via the TC. Open-loop (checkbox unchecked, only selectable when paused/stopped): TC is disconnected from the heater; only the heater-power slider is active. Switching back to closed-loop resets power_soll to 0 and re-enables the TC. On play, the mode is automatically reset to closed-loop. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
This commit is contained in:
@@ -143,6 +143,8 @@ if __name__ == '__main__':
|
|||||||
# Assign data flow
|
# Assign data flow
|
||||||
# Assign tc control value to heater
|
# Assign tc control value to heater
|
||||||
tc.set_on_changed("y", heater_task.actor)
|
tc.set_on_changed("y", heater_task.actor)
|
||||||
|
# HeaterTask owns TC enable: enabled in closed-loop mode, disabled in open-loop.
|
||||||
|
heater_task.set_on_closed_loop_changed(tc.set_enabled)
|
||||||
|
|
||||||
# Assign temp. sensor readings to tc
|
# Assign temp. sensor readings to tc
|
||||||
sensor_task.set_on_changed("temp", ChangedFloat(tc.set_theta_ist, prec=2).set)
|
sensor_task.set_on_changed("temp", ChangedFloat(tc.set_theta_ist, prec=2).set)
|
||||||
|
|||||||
+18
-3
@@ -15,10 +15,15 @@ class HeaterTask(ATask):
|
|||||||
msg_handler.set_recv_handler(self.recv)
|
msg_handler.set_recv_handler(self.recv)
|
||||||
self.power_soll = 0
|
self.power_soll = 0
|
||||||
self.power_actor = 0
|
self.power_actor = 0
|
||||||
|
self.closed_loop = True
|
||||||
|
self._on_closed_loop_changed = None
|
||||||
self.pulse_counter = 0
|
self.pulse_counter = 0
|
||||||
device.set_on_changed('power_eff', ChangedInteger(self.on_changed_power).set)
|
device.set_on_changed('power_eff', ChangedInteger(self.on_changed_power).set)
|
||||||
self.power_set_changed = ChangedInteger(self.on_changed_power_set).set
|
self.power_set_changed = ChangedInteger(self.on_changed_power_set).set
|
||||||
|
|
||||||
|
def set_on_closed_loop_changed(self, callback):
|
||||||
|
self._on_closed_loop_changed = callback
|
||||||
|
|
||||||
def actor(self, y):
|
def actor(self, y):
|
||||||
self.power_actor = max(0, self.device.get_power_max() * y)
|
self.power_actor = max(0, self.device.get_power_max() * y)
|
||||||
|
|
||||||
@@ -30,7 +35,14 @@ class HeaterTask(ATask):
|
|||||||
|
|
||||||
async def recv(self, data):
|
async def recv(self, data):
|
||||||
for pair in data.items():
|
for pair in data.items():
|
||||||
if 'Power' in pair[0]:
|
if pair[0] == 'ClosedLoop':
|
||||||
|
self.closed_loop = pair[1]
|
||||||
|
if self.closed_loop:
|
||||||
|
self.power_soll = 0
|
||||||
|
if self._on_closed_loop_changed:
|
||||||
|
self._on_closed_loop_changed(self.closed_loop)
|
||||||
|
await self.send({'ClosedLoop': self.closed_loop})
|
||||||
|
elif 'Power' in pair[0]:
|
||||||
self.power_soll = pair[1]
|
self.power_soll = pair[1]
|
||||||
|
|
||||||
async def send(self, data):
|
async def send(self, data):
|
||||||
@@ -52,14 +64,17 @@ class HeaterTask(ATask):
|
|||||||
|
|
||||||
async def on_process(self):
|
async def on_process(self):
|
||||||
await self.send({'Capabilities': {'Power': {'Min': 0, 'Max': self.device.get_power_max()}}})
|
await self.send({'Capabilities': {'Power': {'Min': 0, 'Max': self.device.get_power_max()}}})
|
||||||
|
await self.send({'ClosedLoop': self.closed_loop})
|
||||||
|
if self._on_closed_loop_changed:
|
||||||
|
self._on_closed_loop_changed(self.closed_loop)
|
||||||
|
|
||||||
pulse_period_s = 10
|
pulse_period_s = 10
|
||||||
pulse_period_count = pulse_period_s/self.interval
|
pulse_period_count = pulse_period_s/self.interval
|
||||||
|
|
||||||
with self.device.open():
|
with self.device.open():
|
||||||
while True:
|
while True:
|
||||||
# Combine power set from different sources
|
# Closed-loop: TC has full control. Open-loop: direct manual power only.
|
||||||
power_soll = max(self.power_soll, self.power_actor)
|
power_soll = self.power_actor if self.closed_loop else self.power_soll
|
||||||
self.power_set_changed(power_soll)
|
self.power_set_changed(power_soll)
|
||||||
|
|
||||||
# Calculate duty cycle
|
# Calculate duty cycle
|
||||||
|
|||||||
@@ -220,14 +220,6 @@ class SudTask(ATask):
|
|||||||
if value in (SudState.DONE, SudState.IDLE):
|
if value in (SudState.DONE, SudState.IDLE):
|
||||||
self.stirrer.set_duty_cycle(1.0)
|
self.stirrer.set_duty_cycle(1.0)
|
||||||
self.stirrer.set_speed(0)
|
self.stirrer.set_speed(0)
|
||||||
# A finished/stopped run no longer owns the controller - hand
|
|
||||||
# control back to manual mode (off by default there too).
|
|
||||||
self.tc.set_enabled(False)
|
|
||||||
else:
|
|
||||||
# Any other state (RAMPING/HOLDING/WAIT_USER/PAUSED) means a
|
|
||||||
# run is in progress and needs the controller actively driving
|
|
||||||
# the heater.
|
|
||||||
self.tc.set_enabled(True)
|
|
||||||
|
|
||||||
def on_user_message_changed(self, value):
|
def on_user_message_changed(self, value):
|
||||||
asyncio.create_task(self.send({'UserMessage': value}))
|
asyncio.create_task(self.send({'UserMessage': value}))
|
||||||
|
|||||||
+29
-5
@@ -37,6 +37,9 @@ let warpFactor = 1.0;
|
|||||||
let heaterPowerIst = 0;
|
let heaterPowerIst = 0;
|
||||||
let heaterPowerSoll = 0;
|
let heaterPowerSoll = 0;
|
||||||
let heaterMaxPower = 100;
|
let heaterMaxPower = 100;
|
||||||
|
// Closed-loop: TC drives heater exclusively. Open-loop: direct power_soll only.
|
||||||
|
// Only switchable when not running; auto-reset to true on play.
|
||||||
|
let closedLoop = true;
|
||||||
|
|
||||||
// Sud state - mirrors the sud_* fields on client/brewpi_gui.py's Window.
|
// Sud state - mirrors the sud_* fields on client/brewpi_gui.py's Window.
|
||||||
let sudSchedule = [];
|
let sudSchedule = [];
|
||||||
@@ -468,6 +471,19 @@ function updateStatusLine() {
|
|||||||
el.textContent = `${text} ${elapsedText} ${energyText}`;
|
el.textContent = `${text} ${elapsedText} ${energyText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enables/disables sliders and the closed-loop checkbox based on mode and sud state.
|
||||||
|
// TC sliders (temp/heatrate): only when closed-loop and not running.
|
||||||
|
// Heater power slider: only when open-loop and not running.
|
||||||
|
// Stirrer: whenever not running. Checkbox: whenever not running.
|
||||||
|
function updateControlsEnabled() {
|
||||||
|
const sudRunning = RUNNING_STATES.has(sudState);
|
||||||
|
document.getElementById('temp-soll').disabled = !closedLoop;
|
||||||
|
document.getElementById('heatrate-soll').disabled = !closedLoop;
|
||||||
|
document.getElementById('heater-power').disabled = !(!closedLoop && !sudRunning);
|
||||||
|
document.getElementById('stirrer-speed').disabled = sudRunning;
|
||||||
|
document.getElementById('closed-loop').disabled = sudRunning;
|
||||||
|
}
|
||||||
|
|
||||||
// Mirrors client/brewpi_gui.py's update_sud_actions(): Start also doubles
|
// Mirrors client/brewpi_gui.py's update_sud_actions(): Start also doubles
|
||||||
// as Resume while paused; Stop can still abort a paused run.
|
// as Resume while paused; Stop can still abort a paused run.
|
||||||
function updateSudActions() {
|
function updateSudActions() {
|
||||||
@@ -555,6 +571,10 @@ function onHeaterChanged(msg) {
|
|||||||
} else if (key === 'Power') {
|
} else if (key === 'Power') {
|
||||||
heaterPowerIst = msg.Power;
|
heaterPowerIst = msg.Power;
|
||||||
document.getElementById('lcd-power-ist').textContent = msg.Power;
|
document.getElementById('lcd-power-ist').textContent = msg.Power;
|
||||||
|
} else if (key === 'ClosedLoop') {
|
||||||
|
closedLoop = msg.ClosedLoop;
|
||||||
|
document.getElementById('closed-loop').checked = closedLoop;
|
||||||
|
updateControlsEnabled();
|
||||||
} else if (key === 'Capabilities') {
|
} else if (key === 'Capabilities') {
|
||||||
const power = msg.Capabilities.Power;
|
const power = msg.Capabilities.Power;
|
||||||
heaterMaxPower = power.Max || 100;
|
heaterMaxPower = power.Max || 100;
|
||||||
@@ -649,16 +669,16 @@ function onSudChanged(msg) {
|
|||||||
const prevState = sudState;
|
const prevState = sudState;
|
||||||
const isRunningNow = RUNNING_STATES.has(newState);
|
const isRunningNow = RUNNING_STATES.has(newState);
|
||||||
if (isRunningNow && !wasRunning) {
|
if (isRunningNow && !wasRunning) {
|
||||||
|
closedLoop = true;
|
||||||
|
document.getElementById('closed-loop').checked = true;
|
||||||
|
sendMsg('Heater', {ClosedLoop: true});
|
||||||
elapsed = 0;
|
elapsed = 0;
|
||||||
energyByStep = {};
|
energyByStep = {};
|
||||||
energyCurrent = 0;
|
energyCurrent = 0;
|
||||||
}
|
}
|
||||||
wasRunning = isRunningNow;
|
wasRunning = isRunningNow;
|
||||||
sudState = newState;
|
sudState = newState;
|
||||||
const sliderDisabled = RUNNING_STATES.has(sudState);
|
updateControlsEnabled();
|
||||||
for (const id of ['temp-soll', 'heatrate-soll', 'heater-power', 'stirrer-speed']) {
|
|
||||||
document.getElementById(id).disabled = sliderDisabled;
|
|
||||||
}
|
|
||||||
if (newState === WAIT_USER_STATE && prevState !== WAIT_USER_STATE && sudUserMessage) {
|
if (newState === WAIT_USER_STATE && prevState !== WAIT_USER_STATE && sudUserMessage) {
|
||||||
showUserMessage(sudUserMessage);
|
showUserMessage(sudUserMessage);
|
||||||
} else if (prevState === WAIT_USER_STATE && newState !== WAIT_USER_STATE) {
|
} else if (prevState === WAIT_USER_STATE && newState !== WAIT_USER_STATE) {
|
||||||
@@ -771,6 +791,7 @@ function connect() {
|
|||||||
initialHeatrateSync = true;
|
initialHeatrateSync = true;
|
||||||
initialTempSollSync = true;
|
initialTempSollSync = true;
|
||||||
initialPotTempSync = true;
|
initialPotTempSync = true;
|
||||||
|
closedLoop = true;
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
for (const channel of CHANNELS) {
|
for (const channel of CHANNELS) {
|
||||||
ws.send(JSON.stringify({'+': channel}));
|
ws.send(JSON.stringify({'+': channel}));
|
||||||
@@ -786,7 +807,7 @@ function connect() {
|
|||||||
statusEl.className = 'status-disconnected';
|
statusEl.className = 'status-disconnected';
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
for (const id of ['temp-soll', 'heatrate-soll', 'heater-power', 'stirrer-speed']) {
|
for (const id of ['temp-soll', 'heatrate-soll', 'heater-power', 'stirrer-speed', 'closed-loop']) {
|
||||||
document.getElementById(id).disabled = false;
|
document.getElementById(id).disabled = false;
|
||||||
}
|
}
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
@@ -820,6 +841,9 @@ document.getElementById('btn-connect').addEventListener('click', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('closed-loop').addEventListener('change', (e) => {
|
||||||
|
sendMsg('Heater', {ClosedLoop: e.target.checked});
|
||||||
|
});
|
||||||
document.getElementById('heater-power').addEventListener('input', (e) => {
|
document.getElementById('heater-power').addEventListener('input', (e) => {
|
||||||
document.getElementById('heater-power-readout').textContent = e.target.value;
|
document.getElementById('heater-power-readout').textContent = e.target.value;
|
||||||
sendMsg('Heater', {Power: Number(e.target.value)});
|
sendMsg('Heater', {Power: Number(e.target.value)});
|
||||||
|
|||||||
@@ -43,6 +43,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>Heater</h3>
|
<h3>Heater</h3>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="closed-loop" checked> Closed-loop
|
||||||
|
</label>
|
||||||
<label>Power [W]
|
<label>Power [W]
|
||||||
<div class="slider-row">
|
<div class="slider-row">
|
||||||
<input type="range" id="heater-power" min="0" max="100" step="1">
|
<input type="range" id="heater-power" min="0" max="100" step="1">
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ header#connection-bar {
|
|||||||
}
|
}
|
||||||
.slider-row input[type="range"] { flex: 1; accent-color: var(--accent); }
|
.slider-row input[type="range"] { flex: 1; accent-color: var(--accent); }
|
||||||
.panel input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; }
|
.panel input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; }
|
||||||
|
.panel label.checkbox-label { flex-direction: row; align-items: center; gap: 0.5em; cursor: pointer; }
|
||||||
.panel input[type="number"] { width: 6em; }
|
.panel input[type="number"] { width: 6em; }
|
||||||
|
|
||||||
#pot-reset-row { display: flex; align-items: center; gap: 0.6em; margin: 0.4em 0; }
|
#pot-reset-row { display: flex; align-items: center; gap: 0.6em; margin: 0.4em 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user