Merge branch 'main' of tesserakt.pro:alex/arduino-filament-dryer

This commit is contained in:
2026-07-05 19:02:18 +02:00
parent 3ab1b3e8fc
commit f614f75153
4 changed files with 171 additions and 47 deletions

View File

@@ -33,11 +33,13 @@ static const float PID_KI = 0.05f;
static const float PID_KD = 6.0f; static const float PID_KD = 6.0f;
// Tiered heater cap — more power when cold, gentle near setpoint // 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_COLD = 85.0f; // avg >=10 °C below target
static const float HEATER_MAX_DUTY_MID = 50.0f; // avg 310 °C below target static const float HEATER_MAX_DUTY_MID = 65.0f; // avg 310 °C below target
static const float HEATER_MAX_DUTY_NEAR = 42.0f; // avg <3 °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_COLD_BELOW_C = 10.0f;
static const float HEATER_WARM_BELOW_C = 3.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 // Ramp-up limit (% per second) — still caps sudden jumps
static const float HEATER_SLEW_UP_PER_S = 18.0f; 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 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 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_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 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_MIX_MAX_PWM = 200; // ~78 % — cap for spread-driven mixing
static const uint8_t FAN_MAX_PWM = 255; // failsafe / over-temp only 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_HYSTERESIS_C = 0.4f;
static const float AUTOTUNE_PREHEAT_BAND_C = 5.0f; static const float AUTOTUNE_PREHEAT_BAND_C = 5.0f;
static const float AUTOTUNE_PREHEAT_DUTY = 100.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 uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 70; // low fan for entire autotune
static const float AUTOTUNE_ABORT_ABOVE_C = 15.0f;
static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 6; static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 6;
static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL; // 20 min static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL; // 20 min
static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL; // 60 min total static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL; // 60 min total

View File

@@ -107,18 +107,14 @@ public:
return phase_; return phase_;
} }
if (maxTempC >= setpointC_ + AUTOTUNE_ABORT_ABOVE_C) { if (maxTempC >= TARGET_MAX_C - 1.0f) {
fail(F("autotune: abort — temperature too high")); fail(F("autotune: abort — max sensor at safety limit"));
return phase_; return phase_;
} }
if (nowMs - sessionStartMs_ > AUTOTUNE_SESSION_TIMEOUT_MS) { fanPwmOut = AUTOTUNE_PREHEAT_FAN_PWM;
fail(F("autotune: abort — session timeout"));
return phase_;
}
if (phase_ == Phase::Preheat) { if (phase_ == Phase::Preheat) {
fanPwmOut = AUTOTUNE_PREHEAT_FAN_PWM;
if (nowMs - phaseStartMs_ > AUTOTUNE_PREHEAT_TIMEOUT_MS) { if (nowMs - phaseStartMs_ > AUTOTUNE_PREHEAT_TIMEOUT_MS) {
Serial.print(F("autotune: preheat failed — avg ")); Serial.print(F("autotune: preheat failed — avg "));
Serial.print(avgTempC, 1); Serial.print(avgTempC, 1);
@@ -136,6 +132,11 @@ public:
return phase_; return phase_;
} }
if (nowMs - sessionStartMs_ > AUTOTUNE_SESSION_TIMEOUT_MS) {
fail(F("autotune: abort — session timeout"));
return phase_;
}
spreadSum_ += spreadC; spreadSum_ += spreadC;
++spreadSamples_; ++spreadSamples_;

View File

@@ -10,6 +10,8 @@
class ThermalController { class ThermalController {
public: public:
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Allow, Autotune };
ThermalController() ThermalController()
: pid_(PID_KP, PID_KI, PID_KD, 0.0f, 100.0f), : pid_(PID_KP, PID_KI, PID_KD, 0.0f, 100.0f),
autotuner_(), autotuner_(),
@@ -27,7 +29,12 @@ public:
failSafeActive_(true), failSafeActive_(true),
heaterCycleStartMs_(0), heaterCycleStartMs_(0),
lastHeaterUpdateMs_(0), lastHeaterUpdateMs_(0),
heaterOn_(false) {} heaterOn_(false),
lastAvgTempC_(0.0f),
heaterBlock_(HeaterBlock::None),
fanTestActive_(false),
fanTestPwm_(0),
fanTestEndMs_(0) {}
void begin() { void begin() {
pinMode(FAN_PIN, OUTPUT); pinMode(FAN_PIN, OUTPUT);
@@ -47,7 +54,7 @@ public:
if (isIdle()) { if (isIdle()) {
forceHeaterOff(); forceHeaterOff();
} }
applyFan(); applyFan(millis());
TuningData stored; TuningData stored;
if (tuningLoad(stored)) { if (tuningLoad(stored)) {
@@ -150,7 +157,7 @@ public:
forceHeaterOff(); forceHeaterOff();
fanIdleOverride_ = false; fanIdleOverride_ = false;
} }
applyFan(); applyFan(millis());
if (persist && targetC >= TARGET_MIN_C && targetC <= TARGET_MAX_C) { if (persist && targetC >= TARGET_MIN_C && targetC <= TARGET_MAX_C) {
settingsSaveTarget(targetC); settingsSaveTarget(targetC);
} }
@@ -166,7 +173,7 @@ public:
return false; return false;
} }
fanIdleOverride_ = false; fanIdleOverride_ = false;
applyFan(); applyFan(millis());
return true; return true;
} }
@@ -178,7 +185,7 @@ public:
return; return;
} }
fanIdleOverride_ = true; fanIdleOverride_ = true;
applyFan(); applyFan(millis());
} }
bool isFanOff() const { bool isFanOff() const {
@@ -214,9 +221,44 @@ public:
uint8_t fanPwm() const { return fanPwm_; } 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) { void update(float avgTempC, float maxTempC, float cornerSpreadC, uint32_t nowMs) {
failSafeActive_ = false; failSafeActive_ = false;
noteSensorMax(maxTempC); noteSensorMax(maxTempC);
lastAvgTempC_ = avgTempC;
heaterBlock_ = HeaterBlock::None;
cornerSpreadC_ = cornerSpreadC_ =
SPREAD_EMA_ALPHA * cornerSpreadC + SPREAD_EMA_ALPHA * cornerSpreadC +
@@ -232,7 +274,7 @@ public:
cutoffActive_ = false; cutoffActive_ = false;
pid_.reset(); pid_.reset();
lastHeaterUpdateMs_ = nowMs; lastHeaterUpdateMs_ = nowMs;
applyFan(); applyFan(nowMs);
return; return;
} }
@@ -244,14 +286,14 @@ public:
lastHeaterUpdateMs_ = nowMs; lastHeaterUpdateMs_ = nowMs;
applyHeaterBurst(nowMs); applyHeaterBurst(nowMs);
applyFan(); applyFan(nowMs);
} }
void enterFailSafe() { void enterFailSafe() {
failSafeActive_ = true; failSafeActive_ = true;
cutoffActive_ = false; cutoffActive_ = false;
forceHeaterOff(); forceHeaterOff();
applyFan(); applyFan(millis());
pid_.reset(); pid_.reset();
autotuner_.abort(); autotuner_.abort();
} }
@@ -284,6 +326,7 @@ private:
heaterAllowancePercent_ = duty; heaterAllowancePercent_ = duty;
heaterOn_ = duty >= 50.0f; heaterOn_ = duty >= 50.0f;
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW); digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
heaterBlock_ = duty > 0.0f ? HeaterBlock::None : HeaterBlock::Autotune;
writeFan(fan); writeFan(fan);
lastHeaterUpdateMs_ = nowMs; lastHeaterUpdateMs_ = nowMs;
commitAutotuneIfDone(); commitAutotuneIfDone();
@@ -297,6 +340,7 @@ private:
heaterDutyPercent_ = 0.0f; heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f; heaterAllowancePercent_ = 0.0f;
heaterOn_ = false; heaterOn_ = false;
heaterBlock_ = HeaterBlock::Cutoff;
pid_.reset(); pid_.reset();
return; return;
} }
@@ -307,18 +351,26 @@ private:
} }
if (cutoffActive_) { if (cutoffActive_) {
heaterBlock_ = HeaterBlock::Cutoff;
return; return;
} }
const float maxHeatStopC = maxHeatStopTemp(avgTempC); if (shouldLimitMaxCorner(avgTempC) && maxTempC >= maxHeatStopTemp(avgTempC)) {
if (maxTempC >= maxHeatStopC) {
heaterDutyPercent_ = 0.0f; heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f; heaterAllowancePercent_ = 0.0f;
heaterBlock_ = HeaterBlock::Corner;
pid_.reset(); pid_.reset();
return; return;
} }
float duty = pid_.compute(avgTempC, nowMs); 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); const float maxDuty = heaterMaxDuty(avgTempC);
if (duty > maxDuty) { if (duty > maxDuty) {
duty = maxDuty; duty = maxDuty;
@@ -335,6 +387,7 @@ private:
heaterDutyPercent_ = 0.0f; heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f; heaterAllowancePercent_ = 0.0f;
heaterOn_ = false; heaterOn_ = false;
heaterBlock_ = HeaterBlock::Cutoff;
pid_.reset(); pid_.reset();
return; return;
} }
@@ -345,13 +398,14 @@ private:
} }
if (cutoffActive_) { if (cutoffActive_) {
heaterBlock_ = HeaterBlock::Cutoff;
return; return;
} }
const float maxHeatStopC = maxHeatStopTemp(avgTempC); if (shouldLimitMaxCorner(avgTempC) && maxTempC >= maxHeatStopTemp(avgTempC)) {
if (maxTempC >= maxHeatStopC) {
heaterDutyPercent_ = 0.0f; heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f; heaterAllowancePercent_ = 0.0f;
heaterBlock_ = HeaterBlock::Corner;
pid_.reset(); pid_.reset();
return; return;
} }
@@ -362,6 +416,9 @@ private:
if (duty > heaterAllowancePercent_) { if (duty > heaterAllowancePercent_) {
duty = heaterAllowancePercent_; duty = heaterAllowancePercent_;
} }
if (duty <= 0.0f && heaterAllowancePercent_ <= 0.0f) {
heaterBlock_ = HeaterBlock::Allow;
}
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs); heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
} }
@@ -377,6 +434,10 @@ private:
bool isBalancedChamber() const { return cornerSpreadC_ <= GOOD_SPREAD_C; } 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 { float maxHeatStopTemp(float avgTempC) const {
if (avgTempC >= targetTempC_) { if (avgTempC >= targetTempC_) {
return targetTempC_; return targetTempC_;
@@ -389,14 +450,18 @@ private:
stopAt = targetTempC_ + cornerSpreadC_ * SPREAD_HEADROOM_FACTOR + 1.0f; stopAt = targetTempC_ + cornerSpreadC_ * SPREAD_HEADROOM_FACTOR + 1.0f;
} }
const float belowCutoff = cutoffThreshold() - 0.1f; const float cutoff = cutoffThreshold();
if (stopAt > belowCutoff) { if (stopAt > cutoff) {
stopAt = belowCutoff; stopAt = cutoff;
} }
return stopAt; return stopAt;
} }
float allowanceFromMaxCorner(float maxTempC, float avgTempC) const { float allowanceFromMaxCorner(float maxTempC, float avgTempC) const {
if (!shouldLimitMaxCorner(avgTempC)) {
return 100.0f;
}
if (isBalancedChamber() && avgTempC < targetTempC_) { if (isBalancedChamber() && avgTempC < targetTempC_) {
return 100.0f; return 100.0f;
} }
@@ -433,10 +498,10 @@ private:
} }
const float below = targetTempC_ - avgTempC; const float below = targetTempC_ - avgTempC;
if (below > HEATER_COLD_BELOW_C) { if (below >= HEATER_COLD_BELOW_C) {
return HEATER_MAX_DUTY_COLD; 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_MID;
} }
return HEATER_MAX_DUTY_NEAR; return HEATER_MAX_DUTY_NEAR;
@@ -470,14 +535,35 @@ private:
return requestedDuty; return requestedDuty;
} }
uint8_t fanPwmForHeaterDemand() const { uint8_t fanPwmForHeatUp() const {
if (heaterDutyPercent_ <= 0.0f) { if (heaterDutyPercent_ <= 0.0f) {
return 0; return 0;
} }
const uint8_t span = FAN_HEAT_MAX_PWM - FAN_HEAT_MIN_PWM; const float below = targetTempC_ - lastAvgTempC_;
return FAN_HEAT_MIN_PWM + uint8_t heatFan = 0;
static_cast<uint8_t>((heaterDutyPercent_ / 100.0f) * static_cast<float>(span)); 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<uint8_t>((heaterDutyPercent_ / 100.0f) * static_cast<float>(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<uint8_t>(t * static_cast<float>(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 { 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 (isIdle()) {
if (!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) { if (!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
writeFan(FAN_MAX_PWM); writeFan(FAN_MAX_PWM);
@@ -535,16 +629,7 @@ private:
return; return;
} }
uint8_t duty = fanPwmForHeaterDemand(); uint8_t duty = fanPwmForHeatUp();
const uint8_t mixFan = fanPwmForCornerSpread();
if (mixFan > duty) {
duty = mixFan;
}
if (lastMaxTempC_ > targetTempC_ && duty < FAN_MAX_PWM) {
duty = FAN_MAX_PWM;
}
writeFan(duty); writeFan(duty);
} }
@@ -565,4 +650,9 @@ private:
uint32_t heaterCycleStartMs_; uint32_t heaterCycleStartMs_;
uint32_t lastHeaterUpdateMs_; uint32_t lastHeaterUpdateMs_;
bool heaterOn_; bool heaterOn_;
float lastAvgTempC_;
HeaterBlock heaterBlock_;
bool fanTestActive_;
uint8_t fanTestPwm_;
uint32_t fanTestEndMs_;
}; };

View File

@@ -125,9 +125,24 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
Serial.print(thermal.heaterAllowance(), 0); Serial.print(thermal.heaterAllowance(), 0);
Serial.print(F("% heater=")); Serial.print(F("% heater="));
Serial.print(thermal.heaterDutyPercent(), 1); 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); 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)")); Serial.print(F("(off)"));
} else if (thermal.isIdleCooling()) { } else if (thermal.isIdleCooling()) {
Serial.print(F("(cooldown)")); Serial.print(F("(cooldown)"));
@@ -181,6 +196,7 @@ void printHelp() {
Serial.println(F(" target <C> set target (0 = idle)")); Serial.println(F(" target <C> set target (0 = idle)"));
Serial.println(F(" fan off cancel idle fan override (auto-off below 40C)")); 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 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 [C] learn PID (default: 40C when idle)"));
Serial.println(F(" autotune stop")); Serial.println(F(" autotune stop"));
Serial.println(F(" pid show PID / adaptive status")); Serial.println(F(" pid show PID / adaptive status"));
@@ -259,6 +275,20 @@ void processSerialLine(const char *line) {
return; 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<uint8_t>(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) { if (strcmp(line, "status") == 0) {
const float avgTemp = averageValidTemperature(); const float avgTemp = averageValidTemperature();
const float minTemp = minValidTemperature(); const float minTemp = minValidTemperature();