feat(S3): Feed-Forward-Boost bei Bezug für den Wasserkessel (5.2.0) #10

Merged
thomas merged 1 commits from feat/feed-forward-bei-bezug into main 2026-07-18 04:11:40 +02:00
2 changed files with 145 additions and 12 deletions
Showing only changes of commit 4f840173a9 - Show all commits
+14
View File
@@ -1,3 +1,17 @@
Version 5.2.0:
- Feed-Forward bei Bezug (Störgrößenaufschaltung) für den Wasserkessel: Während eines Bezugs wird der
Heizung sofort ein einstellbarer Leistungs-Boost (0100 % der Fenstergröße, 0 % = aus) aufgeschaltet,
statt auf den vom Sensor gemeldeten Temperaturabfall zu warten. Die PID regelt nur noch den Restfehler;
der Temperatur-Dip während des Shots wird deutlich kleiner. Einstellung auf der /PID-Seite
(„Feed-Forward bei Bezug"), gespeichert im EEPROM (bewusst kein Profilfeld, da maschinenabhängig).
- Optional: I-Anteil der Wasser-PID während des Bezugs einfrieren. Verhindert das Aufladen des
Integral-Anteils während des Shots und damit das Überschwingen nach Bezugsende.
- Der Boost wirkt nur bei echten Bezügen (Hardware-Schalter oder Software-Start), nicht bei
Spülen/Wartungsmodus/AutoTune. Sicherheitslogik unverändert: Sensorfehler, Übertemperatur-Abschaltung
und „Heizen oberhalb Setpoint verhindern" haben weiterhin Vorrang; Deckelung auf die Fenstergröße.
- Die Heizleistungs-Telemetrie (dutyW, u. a. für das Verlaufs-Chart des Touch-Displays) zeigt den
Boost mit an - damit ist die Wirkung direkt im Chart kalibrierbar.
Version 5.1.2:
- Profil-Seite (Web-UI): Die neue „Aktives Profil"-Anzeige stand linksbündig außerhalb des
Karten-Layouts. Sie nutzt jetzt den status-message-Stil der Seite (zentriert, max. 600 px,
+131 -12
View File
@@ -369,7 +369,7 @@ Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire);
* Firmware-Informationen
************************************************************************************/
String version = "5.1.2";
String version = "5.2.0";
String versionHersteller = "Thomas Müller";
String versionHerstellerMail =
"<a href='mailto:thomas@mueller.black' class='info-link'>thomas@mueller.black</a>";
@@ -656,6 +656,17 @@ unsigned long windowStartTimeDampf; // Startzeit des aktuellen Fensters für D
PID pidDampf(&InputDampf, &OutputDampf, &SetpointDampf, KpDampf, KiDampf, KdDampf, DIRECT);
PID pidWasser(&InputWasser, &OutputWasser, &SetpointWasser, KpWasser, KiWasser, KdWasser, DIRECT);
// Feed-Forward (Störgrößenaufschaltung) bei Bezug: der Bezug ist eine bekannte Störung
// (kaltes Wasser strömt in den Kessel). Statt auf den Temperaturabfall zu warten, wird
// der Heizleistung während des Bezugs sofort ein fester Boost aufgeschaltet; die PID
// regelt nur noch den Restfehler. Optional wird der I-Anteil während des Bezugs
// eingefroren, damit er sich nicht auflädt (verhindert Überschwingen nach dem Shot).
uint8_t ffBoostPercentWasser = 0; // Zusatz-Heizleistung während Bezug in % der Fenstergröße (0 = deaktiviert)
bool ffIFreezeWasser = false; // I-Anteil der Wasser-PID während des Bezugs einfrieren
bool ffPidIFrozenWasser = false; // Laufzeitzustand: Ki ist aktuell auf 0 gesetzt
const uint8_t defaultFfBoostPercentWasser = 0;
const bool defaultFfIFreezeWasser = false;
// Variablen für Sicherheitsabschaltung
double maxTempWasser = 110.0; // Standardwert, wie bisher hardcoded
double maxTempDampf = 180.0; // Standardwert, wie bisher hardcoded
@@ -944,7 +955,9 @@ const int EEPROM_ADDR_POWERON_STANDBY = 910; // 1 Byte (bool) -> Ende 9
const int EEPROM_ADDR_DISPLAY_TYPE = 911; // 1 Byte (uint8_t) -> Ende 911
const int EEPROM_ADDR_ACTIVE_PROFILE_NAME = 912; // 31 Bytes (char[31], nullterminiert) -> Ende 942
const int EEPROM_ADDR_ACTIVE_PROFILE_DIRTY = 943; // 1 Byte (bool) -> Ende 943
// Naechste freie Adresse: 944 (Innerhalb EEPROM_SIZE=1024)
const int EEPROM_ADDR_FF_BOOST_PERCENT_WASSER = 944; // 1 Byte (uint8_t) -> Ende 944
const int EEPROM_ADDR_FF_IFREEZE_WASSER = 945; // 1 Byte (bool) -> Ende 945
// Naechste freie Adresse: 946 (Innerhalb EEPROM_SIZE=1024)
/************************************************************************************
* Eco-Mode Variablen
@@ -2871,6 +2884,8 @@ void resetToDefaults() {
EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, defaultBoostDampfActive);
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, defaultPreventHeatAboveSetpointWasser);
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, defaultPreventHeatAboveSetpointDampf);
EEPROM.put(EEPROM_ADDR_FF_BOOST_PERCENT_WASSER, defaultFfBoostPercentWasser);
EEPROM.put(EEPROM_ADDR_FF_IFREEZE_WASSER, (uint8_t)(defaultFfIFreezeWasser ? 1 : 0));
EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser); // Sollte ggf. eigene Defaults haben
EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf); // Sollte ggf. eigene Defaults haben
@@ -3861,6 +3876,46 @@ void applyFlowGuardPumpControl(unsigned long currentShotTimeMillis, float netWei
digitalWrite(PUMP_PIN, pumpOn ? HIGH : LOW);
}
/************************************************************************************
* Feed-Forward bei Bezug (Wasserkessel)
************************************************************************************/
// Setzt die Wasser-PID-Tunings und berücksichtigt dabei einen eingefrorenen I-Anteil.
// Alle Stellen, die die Wasser-Tunings anwenden, müssen diese Funktion nutzen, damit
// der Freeze-Zustand bei Änderungen während eines Bezugs erhalten bleibt.
void applyPidWasserTunings() {
pidWasser.SetTunings(KpWasser, ffPidIFrozenWasser ? 0.0 : KiWasser, KdWasser);
}
// Friert den I-Anteil der Wasser-PID ein bzw. gibt ihn wieder frei (nur bei Wechsel).
// Bei Ki=0 bleibt die aufsummierte I-Komponente in der PID-Library erhalten und wird
// nach dem Auftauen unverändert weiterverwendet.
void setPidWasserIntegralFrozen(bool frozen) {
if (frozen == ffPidIFrozenWasser) return;
ffPidIFrozenWasser = frozen;
applyPidWasserTunings();
}
// Feed-Forward nur während eines echten Bezugs (Hardware- oder Software-Shot),
// nicht im Wartungsmodus und nicht während AutoTune.
bool ffBoostWasserActiveNow() {
return ffBoostPercentWasser > 0 && (shotActive || shotSoftwareActive) &&
!wartungsModusAktiv && !autoTuneWasserActive;
}
// Effektive Einschaltzeit im aktuellen Fenster: PID-Output + Feed-Forward-Boost,
// gedeckelt auf die Fenstergröße. Zustandslos, damit SSR-Ansteuerung und
// Duty-Telemetrie (Chart!) denselben Wert sehen.
unsigned long effectiveOutputWasserMs() {
double out = OutputWasser;
if (ffBoostWasserActiveNow()) {
out += ((double)ffBoostPercentWasser / 100.0) * (double)windowSizeWasser;
}
if (out > (double)windowSizeWasser) out = (double)windowSizeWasser;
if (out < 0.0) out = 0.0;
return (unsigned long)out;
}
/************************************************************************************
* Stoppt den Brühvorgang (Pumpe/Ventil) und verarbeitet Shot-Daten.
* Wird von updateShotTimer() oder aus loop() (BrewByTime/Weight) aufgerufen.
@@ -4178,6 +4233,13 @@ void setup() {
EEPROM.get(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive);
EEPROM.get(EEPROM_ADDR_PREVENTHEAT_WASSER, preventHeatAboveSetpointWasser);
EEPROM.get(EEPROM_ADDR_PREVENTHEAT_DAMPF, preventHeatAboveSetpointDampf);
uint8_t storedFfBoostPercent = defaultFfBoostPercentWasser;
EEPROM.get(EEPROM_ADDR_FF_BOOST_PERCENT_WASSER, storedFfBoostPercent);
if (storedFfBoostPercent > 100) storedFfBoostPercent = defaultFfBoostPercentWasser; // u. a. unprogrammierte 0xFF
ffBoostPercentWasser = storedFfBoostPercent;
uint8_t storedFfIFreeze = 0;
EEPROM.get(EEPROM_ADDR_FF_IFREEZE_WASSER, storedFfIFreeze);
ffIFreezeWasser = (storedFfIFreeze == 1);
EEPROM.get(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser);
EEPROM.get(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf);
uint8_t storedOffsetCompensationEnabled = 0;
@@ -4504,7 +4566,7 @@ void setup() {
}
pidWasser.SetOutputLimits(0, windowSizeWasser);
pidDampf.SetOutputLimits(0, windowSizeDampf);
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
applyPidWasserTunings();
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
pidWasser.SetMode(AUTOMATIC);
pidDampf.SetMode(AUTOMATIC);
@@ -4866,6 +4928,11 @@ void loop() {
}
yield();
// Feed-Forward: I-Anteil der Wasser-PID während des Bezugs einfrieren/freigeben.
// Zentral hier verwaltet (statt an Start-/Stopp-Stellen), damit der Zustand auch
// nach Software-Shots, Einstellungsänderungen mid-shot etc. immer konsistent ist.
setPidWasserIntegralFrozen(ffIFreezeWasser && (shotActive || shotSoftwareActive) && !wartungsModusAktiv);
// --- Angepasste Sicherheitsueberpruefung Wasser ---
if (InputWasser < 0.0 || isnan(InputWasser)) {
wasserSensorError = true;
@@ -4964,7 +5031,8 @@ void loop() {
// Wasser SSR
if (wasserSensorError || wasserSafetyShutdown || standbyModeActive || displayOtaActive) { digitalWrite(SSR_WASSER_PIN, LOW); } else {
if (now - windowStartTimeWasser > windowSizeWasser) { windowStartTimeWasser = now; }
bool pidWantsHeat = (OutputWasser > 0) && (now < (windowStartTimeWasser + (unsigned long)OutputWasser));
unsigned long effOutWasser = effectiveOutputWasserMs(); // PID-Output + Feed-Forward-Boost bei Bezug
bool pidWantsHeat = (effOutWasser > 0) && (now < (windowStartTimeWasser + effOutWasser));
bool preventOverheatActive = (preventHeatAboveSetpointWasser && (InputWasser > SetpointWasser)) || (ecoModeAktiv && (InputWasser > SetpointWasser));
if (pidWantsHeat && !preventOverheatActive) { digitalWrite(SSR_WASSER_PIN, HIGH); } else { digitalWrite(SSR_WASSER_PIN, LOW); }
}
@@ -6406,7 +6474,7 @@ void handleAutoTune(AsyncWebServerRequest *request) {
KdWasser = newKd;
// WICHTIG: PID-Tunings sofort anwenden
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
applyPidWasserTunings();
// Neue Werte im EEPROM speichern
EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser);
@@ -8717,8 +8785,32 @@ static const char rootChunk_PIDWasser_Window[] PROGMEM = R"rawliteral('>
<label for='windowSizeWasser'>Zeitfenster (ms):</label>
<input type='number' step='1' id='windowSizeWasser' name='windowSizeWasser' value=')rawliteral"; // Step auf 1 geändert für ms, Endet vor Window Size Wert
// --- Feed-Forward bei Bezug (Wasser) Sektion ---
static const char rootChunk_FFWasser_Start[] PROGMEM = R"rawliteral('>
<br><br> <h3>Feed-Forward bei Bezug (Wasser)</h3>
<p style="font-size:0.9em; color: #ccc; margin-bottom:15px;">
W&auml;hrend des Bezugs str&ouml;mt kaltes Wasser in den Kessel. Der Boost schaltet der Heizung<br>
sofort zus&auml;tzliche Leistung auf, statt auf den Temperaturabfall zu warten (Feed-Forward).<br>
Richtwert: ben&ouml;tigte Leistung [W] = Flow [g/s] &middot; 4,2 &middot; Temperaturhub [K] &mdash; daraus Prozent<br>
der Heizleistung bestimmen. <b>0&nbsp;% = deaktiviert.</b> Wirkt nur w&auml;hrend eines Bezugs,<br>
nicht bei Sp&uuml;len/Wartung. &bdquo;Heizen oberhalb Setpoint verhindern&ldquo; hat weiterhin Vorrang.
</p>
<label for='ffBoostWasser'>Boost bei Bezug (% Heizleistung):</label>
<input type='number' step='1' min='0' max='100' id='ffBoostWasser' name='ffBoostWasser' value=')rawliteral"; // Endet vor Boost-Prozent-Wert
static const char rootChunk_FFWasser_IFreeze_Start[] PROGMEM = R"rawliteral('>
<div class="toggle-switch-container">
<span class="toggle-switch-label-text">I-Anteil w&auml;hrend Bezug einfrieren:</span>
<label class="toggle-switch">
<input type="checkbox" id="ffIFreezeWasser" name="ffIFreezeWasser" value="1")rawliteral"; // Endet VOR checked
static const char rootChunk_FFWasser_IFreeze_End[] PROGMEM = R"rawliteral(>
<span class="toggle-slider"></span>
</label>
</div>
<small class="toggle-description">Verhindert das Aufladen des I-Anteils w&auml;hrend des Bezugs (kein &Uuml;berschwingen nach dem Shot)</small>
)rawliteral";
// --- PID Dampf Sektion ---
static const char rootChunk_PIDDampf_Start[] PROGMEM = R"rawliteral('>
static const char rootChunk_PIDDampf_Start[] PROGMEM = R"rawliteral(
<br><br> <h3>PID-Parameter: Dampf / Thermoblock</h3>
<label for='kpDampf'>Kp:</label>
<input type='number' step='0.01' id='kpDampf' name='kpDampf' value=')rawliteral"; // Endet vor Kp Dampf Wert
@@ -8842,6 +8934,14 @@ void handlePidSettings(AsyncWebServerRequest *request) {
response->print(buffer);
yield();
response->write(rootChunk_FFWasser_Start, strlen_P(rootChunk_FFWasser_Start));
snprintf(buffer, sizeof(buffer), "%u", (unsigned int)ffBoostPercentWasser);
response->print(buffer);
response->write(rootChunk_FFWasser_IFreeze_Start, strlen_P(rootChunk_FFWasser_IFreeze_Start));
if (ffIFreezeWasser) response->print(F(" checked"));
response->write(rootChunk_FFWasser_IFreeze_End, strlen_P(rootChunk_FFWasser_IFreeze_End));
yield();
response->write(rootChunk_PIDDampf_Start, strlen_P(rootChunk_PIDDampf_Start));
snprintf(buffer, sizeof(buffer), "%.2f", KpDampf);
response->print(buffer);
@@ -9067,15 +9167,34 @@ void handleSettingsUpdate(AsyncWebServerRequest *request) {
}
// Feed-Forward bei Bezug (kein Profilfeld: maschinenabhängig, nicht rezeptabhängig)
bool ffChanged = false;
if (request->hasArg(F("ffBoostWasser"))) {
long val = request->arg(F("ffBoostWasser")).toInt();
if (val >= 0 && val <= 100 && (uint8_t)val != ffBoostPercentWasser) {
ffBoostPercentWasser = (uint8_t)val;
EEPROM.put(EEPROM_ADDR_FF_BOOST_PERCENT_WASSER, ffBoostPercentWasser);
ffChanged = true;
}
}
bool newFfIFreeze = request->hasArg(F("ffIFreezeWasser"));
if (newFfIFreeze != ffIFreezeWasser) {
ffIFreezeWasser = newFfIFreeze;
EEPROM.put(EEPROM_ADDR_FF_IFREEZE_WASSER, (uint8_t)(ffIFreezeWasser ? 1 : 0));
ffChanged = true;
}
// --- EEPROM speichern und PID aktualisieren ---
if (valueChanged) { // Nur speichern, wenn sich mindestens ein Wert geändert hat
if (valueChanged || ffChanged) { // Nur speichern, wenn sich mindestens ein Wert geändert hat
EEPROM.commit();
}
if (valueChanged) {
markProfileDirty(); // Setpoints/Offsets/PID/Boost/MaxTemp/Window sind Profilfelder
}
// PID-Parameter an die Regler übergeben
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
applyPidWasserTunings();
// Zurück zur Hauptseite (bereits vorhanden)
AsyncWebServerResponse *resp = request->beginResponse(303);
@@ -11489,7 +11608,7 @@ void applyProfileSettings(const TemperatureProfile& profileData) {
EEPROM.commit();
// --- PID-Regler sofort aktualisieren ---
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
applyPidWasserTunings(); pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
pidWasser.SetOutputLimits(0, windowSizeWasser); pidDampf.SetOutputLimits(0, windowSizeDampf);
// Serial.println(" PID controllers reconfigured with loaded settings.");
@@ -14865,7 +14984,7 @@ bool executeDashboardAction(const String& action, const String& value,
}
}
if (pidNeedsUpdate) {
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
applyPidWasserTunings();
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
// Serial.println("PID-Parameter nach Dashboard-Aktion aktualisiert.");
}
@@ -15558,7 +15677,7 @@ static void buildTouchUartStateJson(char *buffer, size_t bufferSize) {
offset += snprintf(buffer + offset, bufferSize - offset, "\"xSwitchLongAction\":%u,", (unsigned int)xSwitchLongAction);
// --- v2: Heizleistung (Duty in %) aus PID-Output / Fenstergroesse ---
double dutyW = (windowSizeWasser > 0) ? (OutputWasser / (double)windowSizeWasser) * 100.0 : 0.0;
double dutyW = (windowSizeWasser > 0) ? ((double)effectiveOutputWasserMs() / (double)windowSizeWasser) * 100.0 : 0.0; // inkl. Feed-Forward-Boost
double dutyD = (windowSizeDampf > 0) ? (OutputDampf / (double)windowSizeDampf) * 100.0 : 0.0;
dtostrf(dutyW, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"dutyW\":%s,", floatBuf);
dtostrf(dutyD, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"dutyD\":%s,", floatBuf);
@@ -16400,7 +16519,7 @@ static bool touchUartHandleCommandLine(const String& line) {
}
EEPROM.commit();
markProfileDirty(); // PID-Werte sind Profilfelder
if (changedW) { pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); }
if (changedW) { applyPidWasserTunings(); }
if (changedD) { pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); }
touchUartSendAck(id, true, "PID gespeichert");
touchUartSendState();