diff --git a/include/config.h b/include/config.h index 1b5287f..3658130 100644 --- a/include/config.h +++ b/include/config.h @@ -33,11 +33,13 @@ static const float PID_KI = 0.05f; static const float PID_KD = 6.0f; // Tiered heater cap — more power when cold, gentle near setpoint -static const float HEATER_MAX_DUTY_COLD = 65.0f; // avg >10 °C below target -static const float HEATER_MAX_DUTY_MID = 50.0f; // avg 3–10 °C below target -static const float HEATER_MAX_DUTY_NEAR = 42.0f; // avg <3 °C below target +static const float HEATER_MAX_DUTY_COLD = 85.0f; // avg >=10 °C below target +static const float HEATER_MAX_DUTY_MID = 65.0f; // avg 3–10 °C below target +static const float HEATER_MAX_DUTY_NEAR = 45.0f; // avg <3 °C below target static const float HEATER_COLD_BELOW_C = 10.0f; static const float HEATER_WARM_BELOW_C = 3.0f; +// Below this band from target, only the hard cutoff limits max-corner (full heat-up) +static const float CORNER_LIMIT_BAND_C = 10.0f; // Ramp-up limit (% per second) — still caps sudden jumps static const float HEATER_SLEW_UP_PER_S = 18.0f; @@ -59,8 +61,10 @@ static const uint16_t HEATER_CYCLE_MS = 3000; static const uint8_t FAN_IDLE_PWM = 77; // ~30 % — optional override via "fan on" static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f; // idle: fans off when max corner below this static const uint8_t FAN_MIX_MIN_PWM = 70; // ~27 % — light mixing when spread rises -static const uint8_t FAN_HEAT_MIN_PWM = 100; // ~39 % — floor while heating +static const uint8_t FAN_HEAT_MIN_PWM = 100; // ~39 % — floor while heating (near setpoint) static const uint8_t FAN_HEAT_MAX_PWM = 140; // ~55 % — cap during heat-up +static const float FAN_OFF_BELOW_TARGET_C = 8.0f; // no heat-up fan when avg this far below target +static const float FAN_RAMP_BELOW_TARGET_C = 15.0f; // fan ramps in between this and FAN_OFF_BELOW static const uint8_t FAN_MIX_MAX_PWM = 200; // ~78 % — cap for spread-driven mixing static const uint8_t FAN_MAX_PWM = 255; // failsafe / over-temp only @@ -73,8 +77,7 @@ static const float SPREAD_EMA_ALPHA = 0.45f; static const float AUTOTUNE_HYSTERESIS_C = 0.4f; static const float AUTOTUNE_PREHEAT_BAND_C = 5.0f; static const float AUTOTUNE_PREHEAT_DUTY = 100.0f; -static const uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 70; // light mixing only while preheating -static const float AUTOTUNE_ABORT_ABOVE_C = 15.0f; +static const uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 70; // low fan for entire autotune static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 6; static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL; // 20 min static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL; // 60 min total diff --git a/include/pid_autotuner.h b/include/pid_autotuner.h index b736ab5..c621efe 100644 --- a/include/pid_autotuner.h +++ b/include/pid_autotuner.h @@ -107,18 +107,14 @@ public: return phase_; } - if (maxTempC >= setpointC_ + AUTOTUNE_ABORT_ABOVE_C) { - fail(F("autotune: abort — temperature too high")); + if (maxTempC >= TARGET_MAX_C - 1.0f) { + fail(F("autotune: abort — max sensor at safety limit")); return phase_; } - if (nowMs - sessionStartMs_ > AUTOTUNE_SESSION_TIMEOUT_MS) { - fail(F("autotune: abort — session timeout")); - return phase_; - } + fanPwmOut = AUTOTUNE_PREHEAT_FAN_PWM; if (phase_ == Phase::Preheat) { - fanPwmOut = AUTOTUNE_PREHEAT_FAN_PWM; if (nowMs - phaseStartMs_ > AUTOTUNE_PREHEAT_TIMEOUT_MS) { Serial.print(F("autotune: preheat failed — avg ")); Serial.print(avgTempC, 1); @@ -136,6 +132,11 @@ public: return phase_; } + if (nowMs - sessionStartMs_ > AUTOTUNE_SESSION_TIMEOUT_MS) { + fail(F("autotune: abort — session timeout")); + return phase_; + } + spreadSum_ += spreadC; ++spreadSamples_; diff --git a/include/thermal_controller.h b/include/thermal_controller.h index 1f34a49..f007f05 100644 --- a/include/thermal_controller.h +++ b/include/thermal_controller.h @@ -10,6 +10,8 @@ class ThermalController { public: + enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Allow, Autotune }; + ThermalController() : pid_(PID_KP, PID_KI, PID_KD, 0.0f, 100.0f), autotuner_(), @@ -27,7 +29,12 @@ public: failSafeActive_(true), heaterCycleStartMs_(0), lastHeaterUpdateMs_(0), - heaterOn_(false) {} + heaterOn_(false), + lastAvgTempC_(0.0f), + heaterBlock_(HeaterBlock::None), + fanTestActive_(false), + fanTestPwm_(0), + fanTestEndMs_(0) {} void begin() { pinMode(FAN_PIN, OUTPUT); @@ -47,7 +54,7 @@ public: if (isIdle()) { forceHeaterOff(); } - applyFan(); + applyFan(millis()); TuningData stored; if (tuningLoad(stored)) { @@ -150,7 +157,7 @@ public: forceHeaterOff(); fanIdleOverride_ = false; } - applyFan(); + applyFan(millis()); if (persist && targetC >= TARGET_MIN_C && targetC <= TARGET_MAX_C) { settingsSaveTarget(targetC); } @@ -166,7 +173,7 @@ public: return false; } fanIdleOverride_ = false; - applyFan(); + applyFan(millis()); return true; } @@ -178,7 +185,7 @@ public: return; } fanIdleOverride_ = true; - applyFan(); + applyFan(millis()); } bool isFanOff() const { @@ -214,9 +221,44 @@ public: 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 { return maxHeatStopTemp(avgTempC); } + + const char *heaterBlockReason() const { + switch (heaterBlock_) { + case HeaterBlock::Cutoff: + return "cutoff"; + case HeaterBlock::Corner: + return "corner"; + case HeaterBlock::Allow: + return "allow"; + case HeaterBlock::Autotune: + return "autotune"; + default: + return "none"; + } + } + 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 + @@ -232,7 +274,7 @@ public: cutoffActive_ = false; pid_.reset(); lastHeaterUpdateMs_ = nowMs; - applyFan(); + applyFan(nowMs); return; } @@ -244,14 +286,14 @@ public: lastHeaterUpdateMs_ = nowMs; applyHeaterBurst(nowMs); - applyFan(); + applyFan(nowMs); } void enterFailSafe() { failSafeActive_ = true; cutoffActive_ = false; forceHeaterOff(); - applyFan(); + applyFan(millis()); pid_.reset(); autotuner_.abort(); } @@ -284,6 +326,7 @@ private: 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(); @@ -297,6 +340,7 @@ private: heaterDutyPercent_ = 0.0f; heaterAllowancePercent_ = 0.0f; heaterOn_ = false; + heaterBlock_ = HeaterBlock::Cutoff; pid_.reset(); return; } @@ -307,18 +351,26 @@ private: } if (cutoffActive_) { + heaterBlock_ = HeaterBlock::Cutoff; return; } - const float maxHeatStopC = maxHeatStopTemp(avgTempC); - if (maxTempC >= maxHeatStopC) { + if (shouldLimitMaxCorner(avgTempC) && maxTempC >= maxHeatStopTemp(avgTempC)) { heaterDutyPercent_ = 0.0f; heaterAllowancePercent_ = 0.0f; + heaterBlock_ = HeaterBlock::Corner; pid_.reset(); return; } float duty = pid_.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; @@ -335,6 +387,7 @@ private: heaterDutyPercent_ = 0.0f; heaterAllowancePercent_ = 0.0f; heaterOn_ = false; + heaterBlock_ = HeaterBlock::Cutoff; pid_.reset(); return; } @@ -345,13 +398,14 @@ private: } if (cutoffActive_) { + heaterBlock_ = HeaterBlock::Cutoff; return; } - const float maxHeatStopC = maxHeatStopTemp(avgTempC); - if (maxTempC >= maxHeatStopC) { + if (shouldLimitMaxCorner(avgTempC) && maxTempC >= maxHeatStopTemp(avgTempC)) { heaterDutyPercent_ = 0.0f; heaterAllowancePercent_ = 0.0f; + heaterBlock_ = HeaterBlock::Corner; pid_.reset(); return; } @@ -362,6 +416,9 @@ private: if (duty > heaterAllowancePercent_) { duty = heaterAllowancePercent_; } + if (duty <= 0.0f && heaterAllowancePercent_ <= 0.0f) { + heaterBlock_ = HeaterBlock::Allow; + } heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs); } @@ -377,6 +434,10 @@ private: bool isBalancedChamber() const { return cornerSpreadC_ <= GOOD_SPREAD_C; } + bool shouldLimitMaxCorner(float avgTempC) const { + return avgTempC >= targetTempC_ - CORNER_LIMIT_BAND_C; + } + float maxHeatStopTemp(float avgTempC) const { if (avgTempC >= targetTempC_) { return targetTempC_; @@ -389,14 +450,18 @@ private: stopAt = targetTempC_ + cornerSpreadC_ * SPREAD_HEADROOM_FACTOR + 1.0f; } - const float belowCutoff = cutoffThreshold() - 0.1f; - if (stopAt > belowCutoff) { - stopAt = belowCutoff; + const float cutoff = cutoffThreshold(); + if (stopAt > cutoff) { + stopAt = cutoff; } return stopAt; } float allowanceFromMaxCorner(float maxTempC, float avgTempC) const { + if (!shouldLimitMaxCorner(avgTempC)) { + return 100.0f; + } + if (isBalancedChamber() && avgTempC < targetTempC_) { return 100.0f; } @@ -433,10 +498,10 @@ private: } const float below = targetTempC_ - avgTempC; - if (below > HEATER_COLD_BELOW_C) { + if (below >= HEATER_COLD_BELOW_C) { return HEATER_MAX_DUTY_COLD; } - if (below > HEATER_WARM_BELOW_C) { + if (below >= HEATER_WARM_BELOW_C) { return HEATER_MAX_DUTY_MID; } return HEATER_MAX_DUTY_NEAR; @@ -470,14 +535,35 @@ private: return requestedDuty; } - uint8_t fanPwmForHeaterDemand() const { + uint8_t fanPwmForHeatUp() const { if (heaterDutyPercent_ <= 0.0f) { return 0; } - const uint8_t span = FAN_HEAT_MAX_PWM - FAN_HEAT_MIN_PWM; - return FAN_HEAT_MIN_PWM + - static_cast((heaterDutyPercent_ / 100.0f) * static_cast(span)); + const float below = targetTempC_ - lastAvgTempC_; + uint8_t heatFan = 0; + if (below <= FAN_OFF_BELOW_TARGET_C) { + const uint8_t span = FAN_HEAT_MAX_PWM - FAN_HEAT_MIN_PWM; + heatFan = FAN_HEAT_MIN_PWM + + static_cast((heaterDutyPercent_ / 100.0f) * static_cast(span)); + } else if (below < FAN_RAMP_BELOW_TARGET_C) { + const float spanC = FAN_RAMP_BELOW_TARGET_C - FAN_OFF_BELOW_TARGET_C; + const float t = (FAN_RAMP_BELOW_TARGET_C - below) / spanC; + heatFan = static_cast(t * static_cast(FAN_HEAT_MIN_PWM)); + } + + uint8_t mixFan = 0; + if (below <= FAN_OFF_BELOW_TARGET_C || cornerSpreadC_ > GOOD_SPREAD_C) { + mixFan = fanPwmForCornerSpread(); + } + + uint8_t duty = heatFan > mixFan ? heatFan : mixFan; + + if (lastAvgTempC_ >= targetTempC_ - 2.0f && lastMaxTempC_ > targetTempC_ && + duty < FAN_MAX_PWM) { + duty = FAN_MAX_PWM; + } + return duty; } uint8_t fanPwmForCornerSpread() const { @@ -518,7 +604,15 @@ private: } } - void applyFan() { + 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); @@ -535,16 +629,7 @@ private: return; } - uint8_t duty = fanPwmForHeaterDemand(); - const uint8_t mixFan = fanPwmForCornerSpread(); - if (mixFan > duty) { - duty = mixFan; - } - - if (lastMaxTempC_ > targetTempC_ && duty < FAN_MAX_PWM) { - duty = FAN_MAX_PWM; - } - + uint8_t duty = fanPwmForHeatUp(); writeFan(duty); } @@ -565,4 +650,9 @@ private: uint32_t heaterCycleStartMs_; uint32_t lastHeaterUpdateMs_; bool heaterOn_; + float lastAvgTempC_; + HeaterBlock heaterBlock_; + bool fanTestActive_; + uint8_t fanTestPwm_; + uint32_t fanTestEndMs_; }; diff --git a/src/main.cpp b/src/main.cpp index a9b3186..0a889a0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -125,9 +125,24 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) { Serial.print(thermal.heaterAllowance(), 0); Serial.print(F("% heater=")); Serial.print(thermal.heaterDutyPercent(), 1); - Serial.print(F("% fan=")); + Serial.print(F("% htop=")); + if (thermal.isIdle()) { + Serial.print(F("n/a")); + } else { + Serial.print(thermal.maxHeatStopAt(avgTemp), 1); + } + Serial.print(F("C hblk=")); + Serial.print(thermal.heaterBlockReason()); + Serial.print(F(" ssr=")); + Serial.print(thermal.isHeaterSsrOn() ? F("on") : F("off")); + Serial.print(F(" fan=")); + Serial.print(thermal.fanPwm()); + Serial.print(F("/255(")); Serial.print((thermal.fanPwm() * 100) / 255); - if (thermal.isIdle() && thermal.isFanOff()) { + Serial.print(F("%)")); + if (thermal.isFanTestActive(millis())) { + Serial.print(F(" TEST")); + } else if (thermal.isIdle() && thermal.isFanOff()) { Serial.print(F("(off)")); } else if (thermal.isIdleCooling()) { Serial.print(F("(cooldown)")); @@ -181,6 +196,7 @@ void printHelp() { Serial.println(F(" target set target (0 = idle)")); Serial.println(F(" fan off cancel idle fan override (auto-off below 40C)")); Serial.println(F(" fan on idle fan 30% (optional, auto-off below 40C)")); + Serial.println(F(" fan test N set fan PWM 0-255 for 15s (verify wiring)")); Serial.println(F(" autotune [C] learn PID (default: 40C when idle)")); Serial.println(F(" autotune stop")); Serial.println(F(" pid show PID / adaptive status")); @@ -259,6 +275,20 @@ void processSerialLine(const char *line) { return; } + if (strncmp(line, "fan test ", 9) == 0) { + const int pwm = atoi(line + 9); + if (pwm < 0 || pwm > 255) { + Serial.println(F("ERR fan test PWM must be 0-255")); + return; + } + thermal.stopFanTest(); + thermal.startFanTest(static_cast(pwm), millis()); + Serial.print(F("OK fan PWM=")); + Serial.print(pwm); + Serial.println(F(" for 15s — check speed/noise on D5")); + return; + } + if (strcmp(line, "status") == 0) { const float avgTemp = averageValidTemperature(); const float minTemp = minValidTemperature();