#pragma once #include #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 emergencyCutoffC() const { return emergencyCutoffForTarget(targetTempC_); } float cutoffThreshold() const { if (isIdle()) { return INFINITY; } return emergencyCutoffC(); } 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(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 = stirFanPwm_; 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(fanManualActive_ ? fanManualPwm_ : stirFanPwm_); lastHeaterUpdateMs_ = nowMs; commitAutotuneIfDone(); } void updateRegulating(float avgTempC, float maxTempC, uint32_t nowMs) { const float cutoffC = emergencyCutoffC(); if (maxTempC >= cutoffC) { cutoffActive_ = true; heaterDutyPercent_ = 0.0f; heaterAllowancePercent_ = 0.0f; regulatingFanPwm_ = FAN_MAX_PWM; heaterBlock_ = HeaterBlock::Cutoff; heatPi_.reset(); return; } if (cutoffActive_ && maxTempC < cutoffC - CUTOFF_RECOVERY_BAND_C) { cutoffActive_ = false; heatPi_.reset(); } if (cutoffActive_) { heaterBlock_ = HeaterBlock::Cutoff; regulatingFanPwm_ = FAN_MAX_PWM; return; } heaterAllowancePercent_ = allowanceFromMaxCorner(maxTempC, avgTempC); float duty = heatPi_.compute(avgTempC, nowMs); duty = clampPercent(duty); if (duty > heaterAllowancePercent_) { duty = heaterAllowancePercent_; if (heaterAllowancePercent_ < 100.0f) { heaterBlock_ = HeaterBlock::Corner; } } heaterDutyPercent_ = duty; regulatingFanPwm_ = fanManualActive_ ? fanManualPwm_ : stirFanPwm_; } 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 emergencyCutoffC(); } return emergencyCutoffC() - CORNER_STOP_MARGIN_C; } float allowanceFromMaxCorner(float maxTempC, float avgTempC) const { const float cutoffC = emergencyCutoffC(); if (!shouldLimitMaxCorner(avgTempC)) { // Heat-up: only taper when a hot corner nears the dynamic cutoff if (maxTempC >= cutoffC - 3.0f) { const float headroom = cutoffC - maxTempC; return clampPercent((headroom / 3.0f) * 100.0f); } 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); } 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(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_; };