Files
arduino-filament-dryer/include/thermal_controller.h
2026-07-08 16:11:57 +02:00

671 lines
17 KiB
C++

#pragma once
#include <Arduino.h>
#include "config.h"
#include "fan_characterize.h"
#include "pid_autotuner.h"
#include "pid_controller.h"
#include "settings_store.h"
#include "tuning_store.h"
class ThermalController {
public:
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, FanChars };
ThermalController()
: heatPi_(HEAT_PI_KP, HEAT_PI_KI, 0.0f, 0.0f, 100.0f),
autotuner_(),
fanchars_(),
targetTempC_(TARGET_TEMP_C),
heaterDutyPercent_(0.0f),
heaterAllowancePercent_(100.0f),
cornerSpreadC_(0.0f),
lastMaxTempC_(0.0f),
regulatingFanPwm_(0),
stirFanPwm_(FAN_STIR_PWM),
fanManualPwm_(0),
fanPwm_(0),
tuningLoaded_(false),
fanIdleOverride_(false),
fanManualActive_(false),
sensorWarmValid_(false),
cutoffActive_(false),
failSafeActive_(true),
heaterCycleStartMs_(0),
lastHeaterUpdateMs_(0),
heaterOn_(false),
lastAvgTempC_(0.0f),
heaterBlock_(HeaterBlock::None),
fanTestActive_(false),
fanTestPwm_(0),
fanTestEndMs_(0) {}
void begin() {
pinMode(FAN_PIN, OUTPUT);
pinMode(HEATER_PIN, OUTPUT);
digitalWrite(HEATER_PIN, LOW);
writeFan(0);
heatPi_.setSetpoint(targetTempC_);
heatPi_.reset();
heaterCycleStartMs_ = millis();
lastHeaterUpdateMs_ = 0;
failSafeActive_ = true;
cornerSpreadC_ = 0.0f;
lastMaxTempC_ = 0.0f;
sensorWarmValid_ = false;
heaterAllowancePercent_ = 100.0f;
if (isIdle()) {
forceHeaterOff();
}
applyFan(millis());
TuningData stored;
if (tuningLoad(stored)) {
applyTuning(stored);
Serial.println(F("Loaded learned PI from EEPROM"));
printTuning();
}
SettingsData settings;
if (settingsLoad(settings)) {
if (settings.stirFanPwm > 0) {
stirFanPwm_ = settings.stirFanPwm;
}
if (settings.targetC >= TARGET_MIN_C && settings.targetC <= TARGET_MAX_C) {
setTarget(settings.targetC, false);
}
}
}
void applyTuning(const TuningData &data) {
heatPi_.setTunings(data.heatKp, data.heatKi, 0.0f);
tuningLoaded_ = true;
}
void clearTuning() {
tuningLoaded_ = false;
heatPi_.setTunings(HEAT_PI_KP, HEAT_PI_KI, 0.0f);
tuningClear();
heatPi_.reset();
Serial.println(F("PI reset to defaults"));
}
void printTuning() const {
Serial.print(F("Heat PI Kp="));
Serial.print(heatPi_.kp(), 3);
Serial.print(F(" Ki="));
Serial.print(heatPi_.ki(), 4);
Serial.print(F(" tuned="));
Serial.println(tuningLoaded_ ? F("yes") : F("no"));
}
void saveTuningToEeprom() {
TuningData data;
data.magic = TUNING_MAGIC;
data.heatKp = heatPi_.kp();
data.heatKi = heatPi_.ki();
tuningSave(data);
tuningLoaded_ = true;
Serial.println(F("Saved PI to EEPROM"));
}
float heatKp() const { return heatPi_.kp(); }
float heatKi() const { return heatPi_.ki(); }
bool isTuningLoaded() const { return tuningLoaded_; }
bool startAutotune(float setpointC) {
if (autotuner_.isActive() || fanchars_.isActive()) {
return false;
}
cutoffActive_ = false;
heatPi_.reset();
return autotuner_.start(setpointC);
}
void stopAutotune() { autotuner_.abort(); }
bool isAutotuning() const { return autotuner_.isActive(); }
uint32_t autotuneElapsedMs(uint32_t nowMs) const { return autotuner_.elapsedMs(nowMs); }
const char *autotunePhaseName() const { return autotuner_.phaseName(); }
uint8_t autotuneCycleCount() const { return autotuner_.cycleCount(); }
uint8_t autotunePeriodCount() const { return autotuner_.periodCount(); }
float autotunePreheatTargetC() const { return autotuner_.preheatTargetC(); }
bool startFanCharacterize(float maxCornerC, float avgTempC) {
if (autotuner_.isActive() || fanchars_.isActive()) {
return false;
}
stopFanTest();
cutoffActive_ = false;
heatPi_.reset();
setTarget(0.0f, false);
return fanchars_.start(maxCornerC, avgTempC);
}
void stopFanCharacterize() {
fanchars_.abort();
forceHeaterOff();
writeFan(0);
}
bool isFanCharacterizeActive() const { return fanchars_.isActive(); }
uint32_t fanCharacterizeElapsedMs(uint32_t nowMs) const { return fanchars_.elapsedMs(nowMs); }
const char *fanCharacterizePhaseName() const { return fanchars_.phaseName(); }
uint8_t fanCharacterizeProfileIndex() const { return fanchars_.profileIndex(); }
uint8_t fanCharacterizeProfileCount() const { return fanchars_.profileCount(); }
uint8_t fanCharacterizeFanPwm() const { return fanchars_.currentFanPwm(); }
bool isFanCharacterizeRefineRun() const { return fanchars_.isRefineRun(); }
float fanCharacterizeHeaterPct() const { return fanchars_.heaterPct(); }
uint8_t stirFanPwm() const { return stirFanPwm_; }
bool isFanManualOverride() const { return fanManualActive_ && !isIdle(); }
bool setRegulatingFanManual(uint8_t pwm) {
if (isIdle()) {
return false;
}
fanManualPwm_ = pwm;
fanManualActive_ = true;
return true;
}
void clearRegulatingFanManual() {
fanManualActive_ = false;
}
bool setStirFanPwm(uint8_t pwm, bool persist = true) {
if (pwm == 0) {
return false;
}
stirFanPwm_ = pwm;
if (persist) {
settingsSaveStirFan(pwm);
}
return true;
}
void logFanCharacterizeIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
float avgTempC, float minTempC, float maxTempC, float spreadC,
uint32_t nowMs) {
fanchars_.logIfDue(sensorTemps, sensorValid, sensorCount, avgTempC, minTempC, maxTempC,
spreadC, nowMs);
}
bool saveStirFanFromCharacterize() {
if (fanchars_.phase() != FanCharacterize::Phase::Done) {
return false;
}
const uint8_t winner = fanchars_.winnerFanPwm();
if (winner == 0) {
return false;
}
stirFanPwm_ = winner;
settingsSaveStirFan(winner);
Serial.print(F("stir fan "));
Serial.println(winner);
fanchars_.reset();
return true;
}
bool commitAutotuneIfDone() {
if (autotuner_.phase() != PidAutotuner::Phase::Done) {
return false;
}
heatPi_.setTunings(autotuner_.resultKp(), autotuner_.resultKi(), 0.0f);
heatPi_.reset();
TuningData data;
data.magic = TUNING_MAGIC;
data.heatKp = autotuner_.resultKp();
data.heatKi = autotuner_.resultKi();
tuningSave(data);
tuningLoaded_ = true;
autotuner_.reset();
Serial.println(F("Saved heat PI to EEPROM"));
return true;
}
void setTarget(float targetC, bool persist = true) {
targetTempC_ = targetC;
heatPi_.setSetpoint(targetC);
heatPi_.reset();
fanManualActive_ = false;
cutoffActive_ = false;
if (targetC > 0.0f) {
fanIdleOverride_ = false;
} else {
forceHeaterOff();
fanIdleOverride_ = false;
}
applyFan(millis());
if (persist && targetC >= TARGET_MIN_C && targetC <= TARGET_MAX_C) {
settingsSaveTarget(targetC);
}
}
void noteSensorMax(float maxTempC) {
lastMaxTempC_ = maxTempC;
sensorWarmValid_ = true;
}
bool setFanOff() {
if (!isIdle()) {
return false;
}
fanIdleOverride_ = false;
applyFan(millis());
return true;
}
void setFanIdle() {
if (!isIdle()) {
return;
}
if (!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
return;
}
fanIdleOverride_ = true;
applyFan(millis());
}
bool isFanOff() const {
return isIdle() && sensorWarmValid_ && lastMaxTempC_ < IDLE_AUTO_FAN_OFF_TEMP_C &&
!fanIdleOverride_;
}
bool isIdleCooling() const {
return isIdle() &&
(!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C);
}
float target() const { return targetTempC_; }
bool isIdle() const { return targetTempC_ <= 0.0f; }
float cutoffThreshold() const {
if (isIdle()) {
return INFINITY;
}
return EMERGENCY_MAX_TEMP_C;
}
bool isCutoffActive() const { return cutoffActive_; }
bool isFailSafeActive() const { return failSafeActive_; }
float heaterDutyPercent() const { return heaterDutyPercent_; }
float heaterAllowance() const { return heaterAllowancePercent_; }
float cornerSpread() const { return cornerSpreadC_; }
uint8_t fanPwm() const { return fanPwm_; }
bool isHeaterSsrOn() const { return heaterOn_; }
bool isFanTestActive(uint32_t nowMs) const {
return fanTestActive_ && nowMs < fanTestEndMs_;
}
bool startFanTest(uint8_t pwm, uint32_t nowMs, uint32_t durationMs = 15000) {
fanTestPwm_ = pwm;
fanTestEndMs_ = nowMs + durationMs;
fanTestActive_ = true;
writeFan(pwm);
return true;
}
void stopFanTest() { fanTestActive_ = false; }
float maxHeatStopAt(float avgTempC) const {
if (isIdle()) {
return INFINITY;
}
return maxHeatStopTemp(avgTempC);
}
const char *heaterBlockReason() const {
switch (heaterBlock_) {
case HeaterBlock::Cutoff:
return "cutoff";
case HeaterBlock::Corner:
return "corner";
case HeaterBlock::Autotune:
return "autotune";
case HeaterBlock::FanChars:
return "fanchars";
default:
return "none";
}
}
const char *regulatingModeName() const {
return tuningLoaded_ ? "regulating" : "manual";
}
void update(float avgTempC, float maxTempC, float cornerSpreadC, uint32_t nowMs) {
failSafeActive_ = false;
noteSensorMax(maxTempC);
lastAvgTempC_ = avgTempC;
heaterBlock_ = HeaterBlock::None;
cornerSpreadC_ =
SPREAD_EMA_ALPHA * cornerSpreadC_ +
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC;
if (fanchars_.isActive()) {
updateFanCharacterize(avgTempC, maxTempC, nowMs);
return;
}
if (autotuner_.isActive()) {
updateAutotune(avgTempC, maxTempC, nowMs);
return;
}
if (isIdle()) {
forceHeaterOff();
cutoffActive_ = false;
heatPi_.reset();
lastHeaterUpdateMs_ = nowMs;
applyFan(nowMs);
return;
}
updateRegulating(avgTempC, maxTempC, nowMs);
lastHeaterUpdateMs_ = nowMs;
applyHeaterBurst(nowMs);
writeFan(regulatingFanPwm_);
}
void enterFailSafe() {
failSafeActive_ = true;
cutoffActive_ = false;
forceHeaterOff();
applyFan(millis());
heatPi_.reset();
autotuner_.abort();
fanchars_.abort();
}
void forceHeaterOff() {
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
heaterOn_ = false;
digitalWrite(HEATER_PIN, LOW);
}
void writeFan(uint8_t pwm) {
fanPwm_ = pwm;
pinMode(FAN_PIN, OUTPUT);
if (FAN_PWM_INVERT) {
if (pwm == 0) {
digitalWrite(FAN_PIN, HIGH);
return;
}
if (pwm >= 254) {
digitalWrite(FAN_PIN, LOW);
return;
}
analogWrite(FAN_PIN, static_cast<uint8_t>(255 - pwm));
return;
}
if (pwm == 0) {
digitalWrite(FAN_PIN, LOW);
return;
}
if (pwm >= 254) {
digitalWrite(FAN_PIN, HIGH);
return;
}
analogWrite(FAN_PIN, pwm);
}
private:
void updateFanCharacterize(float avgTempC, float maxTempC, uint32_t nowMs) {
float duty = 0.0f;
uint8_t fan = 0;
fanchars_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
heaterDutyPercent_ = duty;
heaterAllowancePercent_ = duty;
heaterBlock_ = duty > 0.0f ? HeaterBlock::FanChars : HeaterBlock::None;
applyHeaterBurst(nowMs);
writeFan(fan);
lastHeaterUpdateMs_ = nowMs;
}
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
float duty = 0.0f;
uint8_t fan = AUTOTUNE_PREHEAT_FAN_PWM;
autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
heaterDutyPercent_ = duty;
heaterAllowancePercent_ = duty;
heaterOn_ = duty >= 50.0f;
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
heaterBlock_ = duty > 0.0f ? HeaterBlock::None : HeaterBlock::Autotune;
writeFan(fan);
lastHeaterUpdateMs_ = nowMs;
commitAutotuneIfDone();
}
void updateRegulating(float avgTempC, float maxTempC, uint32_t nowMs) {
if (maxTempC >= EMERGENCY_MAX_TEMP_C) {
cutoffActive_ = true;
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
regulatingFanPwm_ = FAN_MAX_PWM;
heaterBlock_ = HeaterBlock::Cutoff;
heatPi_.reset();
return;
}
if (cutoffActive_ && maxTempC < EMERGENCY_MAX_TEMP_C - 5.0f) {
cutoffActive_ = false;
heatPi_.reset();
}
if (cutoffActive_) {
heaterBlock_ = HeaterBlock::Cutoff;
regulatingFanPwm_ = FAN_MAX_PWM;
return;
}
heaterAllowancePercent_ = allowanceFromMaxCorner(maxTempC, avgTempC);
float duty = heatPi_.compute(avgTempC, nowMs);
const float below = targetTempC_ - avgTempC;
if (below > 8.0f) {
const float floor = below > 15.0f ? 75.0f : 60.0f;
if (duty < floor) {
duty = floor;
}
}
const float maxDuty = heaterMaxDuty(avgTempC);
if (duty > maxDuty) {
duty = maxDuty;
}
if (duty > heaterAllowancePercent_) {
duty = heaterAllowancePercent_;
if (heaterAllowancePercent_ < 100.0f) {
heaterBlock_ = HeaterBlock::Corner;
}
}
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
const uint8_t fanPwm = fanManualActive_ ? fanManualPwm_ : stirFanPwm_;
regulatingFanPwm_ = fanWithMinStir(avgTempC, fanPwm);
}
uint8_t fanWithMinStir(float avgTempC, uint8_t pwm) const {
if (avgTempC < IDLE_AUTO_FAN_OFF_TEMP_C) {
return 0;
}
return pwm;
}
static float clampPercent(float value) {
if (value < 0.0f) {
return 0.0f;
}
if (value > 100.0f) {
return 100.0f;
}
return value;
}
bool shouldLimitMaxCorner(float avgTempC) const {
return avgTempC >= targetTempC_ - CORNER_LIMIT_BAND_C;
}
float maxHeatStopTemp(float avgTempC) const {
if (!shouldLimitMaxCorner(avgTempC)) {
return EMERGENCY_MAX_TEMP_C;
}
return EMERGENCY_MAX_TEMP_C - CORNER_STOP_MARGIN_C;
}
float allowanceFromMaxCorner(float maxTempC, float avgTempC) const {
if (!shouldLimitMaxCorner(avgTempC)) {
return 100.0f;
}
const float stopAt = maxHeatStopTemp(avgTempC);
if (maxTempC >= stopAt) {
return 0.0f;
}
const float headroom = stopAt - maxTempC;
if (headroom >= MAX_TEMP_HEADROOM_C) {
return 100.0f;
}
return clampPercent((headroom / MAX_TEMP_HEADROOM_C) * 100.0f);
}
float heaterMaxDuty(float avgTempC) const {
if (avgTempC >= targetTempC_) {
return HEATER_MAX_DUTY_NEAR;
}
const float below = targetTempC_ - avgTempC;
if (below >= HEATER_COLD_BELOW_C) {
return HEATER_MAX_DUTY_COLD;
}
if (below >= HEATER_WARM_BELOW_C) {
return HEATER_MAX_DUTY_MID;
}
return HEATER_MAX_DUTY_NEAR;
}
float applyHeaterRamp(float requestedDuty, float avgTempC, uint32_t nowMs) {
const float maxDuty = heaterMaxDuty(avgTempC);
if (requestedDuty > maxDuty) {
requestedDuty = maxDuty;
}
if (lastHeaterUpdateMs_ > 0 && requestedDuty > heaterDutyPercent_) {
const float dt = static_cast<float>(nowMs - lastHeaterUpdateMs_) / 1000.0f;
const float maxUp = heaterDutyPercent_ + HEATER_SLEW_UP_PER_S * dt;
if (requestedDuty > maxUp) {
requestedDuty = maxUp;
}
}
return requestedDuty;
}
void applyHeaterBurst(uint32_t nowMs) {
if (heaterDutyPercent_ <= 0.0f) {
forceHeaterOff();
return;
}
if (nowMs - heaterCycleStartMs_ >= HEATER_CYCLE_MS) {
heaterCycleStartMs_ = nowMs;
}
const float onFraction = heaterDutyPercent_ / 100.0f;
const uint32_t onTimeMs = static_cast<uint32_t>(HEATER_CYCLE_MS * onFraction);
const bool shouldHeat = (nowMs - heaterCycleStartMs_) < onTimeMs;
if (shouldHeat != heaterOn_) {
heaterOn_ = shouldHeat;
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
}
}
void applyFan(uint32_t nowMs) {
if (fanTestActive_) {
if (nowMs < fanTestEndMs_) {
writeFan(fanTestPwm_);
return;
}
fanTestActive_ = false;
}
if (isIdle()) {
if (sensorWarmValid_ && lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
writeFan(FAN_MAX_PWM);
} else if (fanIdleOverride_) {
writeFan(FAN_IDLE_PWM);
} else {
writeFan(0);
}
return;
}
if (failSafeActive_ || cutoffActive_) {
writeFan(FAN_MAX_PWM);
return;
}
}
PidController heatPi_;
PidAutotuner autotuner_;
FanCharacterize fanchars_;
float targetTempC_;
float heaterDutyPercent_;
float heaterAllowancePercent_;
float cornerSpreadC_;
float lastMaxTempC_;
uint8_t regulatingFanPwm_;
uint8_t stirFanPwm_;
uint8_t fanManualPwm_;
uint8_t fanPwm_;
bool tuningLoaded_;
bool fanIdleOverride_;
bool fanManualActive_;
bool sensorWarmValid_;
bool cutoffActive_;
bool failSafeActive_;
uint32_t heaterCycleStartMs_;
uint32_t lastHeaterUpdateMs_;
bool heaterOn_;
float lastAvgTempC_;
HeaterBlock heaterBlock_;
bool fanTestActive_;
uint8_t fanTestPwm_;
uint32_t fanTestEndMs_;
};