pi without the d

This commit is contained in:
2026-07-06 20:24:14 +02:00
parent 28ae97fa45
commit 55cf03c015
6 changed files with 231 additions and 298 deletions

View File

@@ -82,20 +82,35 @@ Verify access: `test -w /dev/ttyUSB0 && echo ok`
1. Flash firmware and open the serial monitor at 115200 baud. 1. Flash firmware and open the serial monitor at 115200 baud.
2. Confirm `TCA9548A detected` and four valid sensor channels (`ch2``ch5`). 2. Confirm `TCA9548A detected` and four valid sensor channels (`ch2``ch5`).
3. Run PID autotune once per physical unit (values are stored in EEPROM): 3. Tune **heat PI** (stored in EEPROM on autotune complete):
``` ```
target 0 target 0
autotune 45 pid default
autotune 50
``` ```
4. Start drying: Emergency cutoff is fixed at **70°C** — you can autotune at 5055°C with ABS in the chamber while hot corners stay below that.
4. Tune **mix PI** (spread → fan) while drying:
``` ```
target 55 target 55
log on
``` ```
Send `help` over serial for all commands (`target`, `fan on/off`, `log on/off`, `status`, `pid`, etc.). Watch `spread` in status. Adjust mix gains without reflash:
```
mixpi 40 2
pid save
```
Defaults: heat Kp/Ki from autotune; mix Kp=40 Ki=2 in firmware until tuned.
5. Optional: `stepresp 45 35` logs open-loop fan steps if you want to estimate mix gain before live tuning.
Send `help` over serial for all commands (`target`, `mixpi`, `fan test`, `log on/off`, `status`, `pid`, etc.).
## Raspberry Pi control ## Raspberry Pi control

View File

@@ -21,73 +21,58 @@ static const uint8_t HEATER_PIN = A2; // heater via solid-state relay
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Temperature control // Temperature control
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static const float TARGET_TEMP_C = 0.0f; // power-on default: idle (heater off) static const float TARGET_TEMP_C = 0.0f;
static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f; // autotune when no temp given and idle static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f;
static const float TARGET_MIN_C = 0.0f; // 0 = idle (heater off, fan at idle speed) static const float TARGET_MIN_C = 0.0f;
static const float TARGET_MAX_C = 80.0f; static const float TARGET_MAX_C = 80.0f;
// Absolute max corner temp — heater off + full fan. Decoupled from PID target so you can // Absolute max corner — heater off + full fan (decoupled from PID target).
// run target 5055 while tuning with headroom for hot corners (ABS in chamber).
static const float EMERGENCY_MAX_TEMP_C = 70.0f; static const float EMERGENCY_MAX_TEMP_C = 70.0f;
static const float CORNER_STOP_MARGIN_C = 3.0f; // taper heater when max within this of emergency static const float CORNER_STOP_MARGIN_C = 3.0f;
// PID on chamber average // Dual PI — heat on avg, mix on spread (no D term)
static const float PID_KP = 4.0f; static const float HEAT_PI_KP = 4.0f;
static const float PID_KI = 0.05f; static const float HEAT_PI_KI = 0.05f;
static const float PID_KD = 6.0f; static const float MIX_PI_KP = 40.0f;
static const float MIX_PI_KI = 2.0f;
static const float SPREAD_TARGET_C = 0.5f;
static const float HEAT_UP_BAND_C = 8.0f;
static const uint8_t FAN_COLD_CAP_PWM = 0;
// Tiered heater cap — more power when cold, gentle near setpoint // Legacy aliases for autotuner relay math only
static const float HEATER_MAX_DUTY_COLD = 85.0f; // avg >=10 °C below target static const float PID_KP = HEAT_PI_KP;
static const float HEATER_MAX_DUTY_MID = 65.0f; // avg 310 °C below target static const float PID_KI = HEAT_PI_KI;
static const float HEATER_MAX_DUTY_NEAR = 45.0f; // avg <3 °C below target static const float PID_KD = 0.0f;
static const float GOOD_SPREAD_C = 5.0f;
// Tiered heater cap during heat-up
static const float HEATER_MAX_DUTY_COLD = 85.0f;
static const float HEATER_MAX_DUTY_MID = 65.0f;
static const float HEATER_MAX_DUTY_NEAR = 45.0f;
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; 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; static const float HEATER_SLEW_UP_PER_S = 18.0f;
// Hot-corner limiter: taper heater as max corner approaches stop temperature
static const float MAX_TEMP_HEADROOM_C = 15.0f; static const float MAX_TEMP_HEADROOM_C = 15.0f;
// Average-temp approach: taper only in the last few °C before setpoint
static const float APPROACH_BAND_C = 4.0f;
// When spread is good, allow hottest corner slightly above target so avg can reach setpoint
static const float GOOD_SPREAD_C = 5.0f;
static const float BALANCED_MAX_ABOVE_TARGET_C = 2.0f;
static const float SPREAD_HEADROOM_FACTOR = 0.5f; // extra max-corner °C per °C of spread
static const uint16_t HEATER_CYCLE_MS = 3000; static const uint16_t HEATER_CYCLE_MS = 3000;
// Fan PWM (0255) // Fan PWM
static const uint8_t FAN_IDLE_PWM = 77; // ~30 % — optional override via "fan on" static const uint8_t FAN_IDLE_PWM = 77;
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;
static const uint8_t FAN_MIX_MIN_PWM = 70; // ~27 % — light mixing when spread rises static const uint8_t FAN_MAX_PWM = 255;
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
// Most 24 V MOSFET modules are active-low (pin LOW = fan on). If off/speed seem wrong,
// try flipping this and reflash. Test: `fan test 0` (off) vs `fan test 200` vs `fan test 255`.
static const bool FAN_PWM_INVERT = true; static const bool FAN_PWM_INVERT = true;
// Corner mixing — moderate airflow; full speed reserved for safety
static const float SPREAD_DEADBAND_C = 0.5f;
static const float SPREAD_FULL_MIX_C = 8.0f;
static const float SPREAD_EMA_ALPHA = 0.45f; static const float SPREAD_EMA_ALPHA = 0.45f;
// PID auto-tune (relay method) — run with: autotune 45 // PID auto-tune (relay method) — heat PI only
static const float AUTOTUNE_HYSTERESIS_C = 0.4f; static const float AUTOTUNE_HYSTERESIS_C = 0.4f;
static const float AUTOTUNE_PREHEAT_BAND_C = 3.0f; static const float AUTOTUNE_PREHEAT_BAND_C = 3.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 = 0; // fan off — maximize heat-up static const uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 0;
static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 5; static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 5;
static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL; // 20 min static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL;
static const uint32_t AUTOTUNE_RELAY_STALL_MS = 1500000UL; // 25 min in relay, 0 cycles static const uint32_t AUTOTUNE_RELAY_STALL_MS = 1500000UL;
static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL; // 60 min total static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL;
static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL; static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL;
// Fan step-response — open-loop heater, fan PWM steps (command: stepresp) // Fan step-response — open-loop heater, fan PWM steps (command: stepresp)
@@ -96,9 +81,9 @@ static const float STEPRESP_DEFAULT_HEATER_PCT = 35.0f;
static const float STEPRESP_MIN_HEATER_PCT = 10.0f; static const float STEPRESP_MIN_HEATER_PCT = 10.0f;
static const float STEPRESP_MAX_HEATER_PCT = 70.0f; static const float STEPRESP_MAX_HEATER_PCT = 70.0f;
static const float STEPRESP_PREHEAT_BAND_C = 2.0f; static const float STEPRESP_PREHEAT_BAND_C = 2.0f;
static const uint32_t STEPRESP_PREHEAT_TIMEOUT_MS = 1200000UL; // 20 min static const uint32_t STEPRESP_PREHEAT_TIMEOUT_MS = 1200000UL;
static const uint32_t STEPRESP_BASELINE_MS = 120000UL; // 2 min fan-off baseline static const uint32_t STEPRESP_BASELINE_MS = 120000UL;
static const uint32_t STEPRESP_STEP_HOLD_MS = 300000UL; // 5 min per fan level static const uint32_t STEPRESP_STEP_HOLD_MS = 300000UL;
static const uint32_t STEPRESP_LOG_INTERVAL_MS = 1000UL; static const uint32_t STEPRESP_LOG_INTERVAL_MS = 1000UL;
static const uint8_t STEPRESP_FAN_STEPS[] = {0, 77, 140, 200, 255}; static const uint8_t STEPRESP_FAN_STEPS[] = {0, 77, 140, 200, 255};
static const uint8_t STEPRESP_FAN_STEP_COUNT = static const uint8_t STEPRESP_FAN_STEP_COUNT =
@@ -110,4 +95,4 @@ static const uint8_t STEPRESP_FAN_STEP_COUNT =
static const uint32_t SENSOR_READ_INTERVAL_MS = 1000; static const uint32_t SENSOR_READ_INTERVAL_MS = 1000;
static const uint32_t CONTROL_INTERVAL_MS = 500; static const uint32_t CONTROL_INTERVAL_MS = 500;
static const uint32_t SERIAL_REPORT_INTERVAL_MS = 2000; static const uint32_t SERIAL_REPORT_INTERVAL_MS = 2000;
static const bool LOG_CSV_DEFAULT = false; // enable with serial command: log on static const bool LOG_CSV_DEFAULT = false;

View File

@@ -27,10 +27,8 @@ public:
useMaxSensorPv_(false), useMaxSensorPv_(false),
sessionStartMs_(0), sessionStartMs_(0),
phaseStartMs_(0), phaseStartMs_(0),
resultKp_(PID_KP), resultKp_(HEAT_PI_KP),
resultKi_(PID_KI), resultKi_(HEAT_PI_KI) {}
resultKd_(PID_KD),
resultFanMixMax_(FAN_MIX_MAX_PWM) {}
Phase phase() const { return phase_; } Phase phase() const { return phase_; }
@@ -77,7 +75,7 @@ public:
Serial.print(preheatTargetC(), 1); Serial.print(preheatTargetC(), 1);
Serial.print(F("-")); Serial.print(F("-"));
Serial.print(setpointC_, 1); Serial.print(setpointC_, 1);
Serial.println(F("C avg")); Serial.println(F("C avg (heat PI only)"));
return true; return true;
} }
@@ -98,20 +96,18 @@ public:
float resultKp() const { return resultKp_; } float resultKp() const { return resultKp_; }
float resultKi() const { return resultKi_; } float resultKi() const { return resultKi_; }
float resultKd() const { return resultKd_; }
uint8_t resultFanMixMax() const { return resultFanMixMax_; }
Phase update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut, Phase update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut,
uint8_t &fanPwmOut) { uint8_t &fanPwmOut) {
heaterDutyOut = 0.0f; heaterDutyOut = 0.0f;
fanPwmOut = FAN_HEAT_MIN_PWM; fanPwmOut = AUTOTUNE_PREHEAT_FAN_PWM;
if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) { if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) {
return phase_; return phase_;
} }
if (maxTempC >= TARGET_MAX_C - 1.0f) { if (maxTempC >= EMERGENCY_MAX_TEMP_C) {
fail(F("autotune: abort — max sensor at safety limit")); fail(F("autotune: abort — max sensor at emergency limit"));
return phase_; return phase_;
} }
@@ -140,11 +136,6 @@ public:
return phase_; return phase_;
} }
if (nowMs - sessionStartMs_ > AUTOTUNE_SESSION_TIMEOUT_MS) {
fail(F("autotune: abort — session timeout"));
return phase_;
}
if (cycleCount_ == 0 && nowMs - phaseStartMs_ > AUTOTUNE_RELAY_STALL_MS) { if (cycleCount_ == 0 && nowMs - phaseStartMs_ > AUTOTUNE_RELAY_STALL_MS) {
Serial.print(F("autotune: relay stalled — avg ")); Serial.print(F("autotune: relay stalled — avg "));
Serial.print(avgTempC, 1); Serial.print(avgTempC, 1);
@@ -264,7 +255,6 @@ private:
const float ku = (4.0f * 100.0f) / (PI * avgAmplitude); const float ku = (4.0f * 100.0f) / (PI * avgAmplitude);
resultKp_ = 0.45f * ku; resultKp_ = 0.45f * ku;
resultKi_ = resultKp_ / (2.2f * avgPeriodSec); resultKi_ = resultKp_ / (2.2f * avgPeriodSec);
resultKd_ = resultKp_ * avgPeriodSec / 6.3f;
if (resultKp_ < 0.5f) { if (resultKp_ < 0.5f) {
resultKp_ = 0.5f; resultKp_ = 0.5f;
@@ -273,30 +263,15 @@ private:
resultKi_ = resultKp_ / 3.0f; resultKi_ = resultKp_ / 3.0f;
} }
resultFanMixMax_ = FAN_MIX_MAX_PWM;
const float spreadAvg =
spreadSamples_ > 0 ? spreadSum_ / static_cast<float>(spreadSamples_) : GOOD_SPREAD_C;
if (spreadAvg > GOOD_SPREAD_C) {
const float boost = 1.0f + ((spreadAvg - GOOD_SPREAD_C) / 10.0f);
int boosted = static_cast<int>(static_cast<float>(FAN_MIX_MAX_PWM) * boost);
if (boosted > static_cast<int>(FAN_MAX_PWM) - 20) {
boosted = FAN_MAX_PWM - 20;
}
resultFanMixMax_ = static_cast<uint8_t>(boosted);
}
phase_ = Phase::Done; phase_ = Phase::Done;
Serial.print(F("autotune: done in ")); Serial.print(F("autotune: done in "));
Serial.print((nowMs - sessionStartMs_) / 1000UL); Serial.print((nowMs - sessionStartMs_) / 1000UL);
Serial.println(F("s")); Serial.println(F("s"));
Serial.print(F(" Kp=")); Serial.print(F(" heat Kp="));
Serial.print(resultKp_, 3); Serial.print(resultKp_, 3);
Serial.print(F(" Ki=")); Serial.print(F(" Ki="));
Serial.print(resultKi_, 4); Serial.println(resultKi_, 4);
Serial.print(F(" Kd=")); Serial.println(F(" tune mix PI separately: mixpi <Kp> <Ki>"));
Serial.print(resultKd_, 3);
Serial.print(F(" fanMixMax="));
Serial.println(resultFanMixMax_);
} }
void fail(const __FlashStringHelper *reason) { void fail(const __FlashStringHelper *reason) {
@@ -325,6 +300,4 @@ private:
uint32_t phaseStartMs_; uint32_t phaseStartMs_;
float resultKp_; float resultKp_;
float resultKi_; float resultKi_;
float resultKd_;
uint8_t resultFanMixMax_;
}; };

View File

@@ -11,10 +11,11 @@
class ThermalController { class ThermalController {
public: public:
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Allow, Autotune, StepResp }; enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, StepResp };
ThermalController() ThermalController()
: pid_(PID_KP, PID_KI, PID_KD, 0.0f, 100.0f), : heatPi_(HEAT_PI_KP, HEAT_PI_KI, 0.0f, 0.0f, 100.0f),
mixPi_(MIX_PI_KP, MIX_PI_KI, 0.0f, 0.0f, 255.0f),
autotuner_(), autotuner_(),
stepresp_(), stepresp_(),
targetTempC_(TARGET_TEMP_C), targetTempC_(TARGET_TEMP_C),
@@ -22,9 +23,9 @@ public:
heaterAllowancePercent_(100.0f), heaterAllowancePercent_(100.0f),
cornerSpreadC_(0.0f), cornerSpreadC_(0.0f),
lastMaxTempC_(0.0f), lastMaxTempC_(0.0f),
fanPwm_(FAN_MAX_PWM), regulatingFanPwm_(0),
fanMixMax_(FAN_MIX_MAX_PWM), fanPwm_(0),
adaptiveEnabled_(false), tuningLoaded_(false),
fanIdleOverride_(false), fanIdleOverride_(false),
sensorWarmValid_(false), sensorWarmValid_(false),
cutoffActive_(false), cutoffActive_(false),
@@ -36,7 +37,9 @@ public:
heaterBlock_(HeaterBlock::None), heaterBlock_(HeaterBlock::None),
fanTestActive_(false), fanTestActive_(false),
fanTestPwm_(0), fanTestPwm_(0),
fanTestEndMs_(0) {} fanTestEndMs_(0) {
mixPi_.setSetpoint(0.0f);
}
void begin() { void begin() {
pinMode(FAN_PIN, OUTPUT); pinMode(FAN_PIN, OUTPUT);
@@ -44,12 +47,12 @@ public:
digitalWrite(HEATER_PIN, LOW); digitalWrite(HEATER_PIN, LOW);
writeFan(0); writeFan(0);
pid_.setSetpoint(targetTempC_); heatPi_.setSetpoint(targetTempC_);
pid_.reset(); heatPi_.reset();
mixPi_.reset();
heaterCycleStartMs_ = millis(); heaterCycleStartMs_ = millis();
lastHeaterUpdateMs_ = 0; lastHeaterUpdateMs_ = 0;
failSafeActive_ = true; failSafeActive_ = true;
fanPwm_ = FAN_MAX_PWM;
cornerSpreadC_ = 0.0f; cornerSpreadC_ = 0.0f;
lastMaxTempC_ = 0.0f; lastMaxTempC_ = 0.0f;
sensorWarmValid_ = false; sensorWarmValid_ = false;
@@ -62,7 +65,7 @@ public:
TuningData stored; TuningData stored;
if (tuningLoad(stored)) { if (tuningLoad(stored)) {
applyTuning(stored); applyTuning(stored);
Serial.println(F("Loaded learned PID from EEPROM")); Serial.println(F("Loaded learned PI from EEPROM"));
printTuning(); printTuning();
} }
@@ -74,46 +77,75 @@ public:
} }
void applyTuning(const TuningData &data) { void applyTuning(const TuningData &data) {
pid_.setTunings(data.kp, data.ki, data.kd); heatPi_.setTunings(data.heatKp, data.heatKi, 0.0f);
fanMixMax_ = data.fanMixMax; mixPi_.setTunings(data.mixKp, data.mixKi, 0.0f);
adaptiveEnabled_ = true; tuningLoaded_ = true;
} }
void clearTuning() { void clearTuning() {
adaptiveEnabled_ = false; tuningLoaded_ = false;
fanMixMax_ = FAN_MIX_MAX_PWM; heatPi_.setTunings(HEAT_PI_KP, HEAT_PI_KI, 0.0f);
pid_.setTunings(PID_KP, PID_KI, PID_KD); mixPi_.setTunings(MIX_PI_KP, MIX_PI_KI, 0.0f);
tuningClear(); tuningClear();
pid_.reset(); heatPi_.reset();
Serial.println(F("PID reset to defaults")); mixPi_.reset();
Serial.println(F("PI reset to defaults"));
}
void setMixTunings(float kp, float ki) {
mixPi_.setTunings(kp, ki, 0.0f);
mixPi_.reset();
Serial.print(F("Mix PI Kp="));
Serial.print(kp, 3);
Serial.print(F(" Ki="));
Serial.println(ki, 4);
}
void resetMixTunings() {
mixPi_.setTunings(MIX_PI_KP, MIX_PI_KI, 0.0f);
mixPi_.reset();
Serial.println(F("Mix PI reset to defaults"));
} }
void printTuning() const { void printTuning() const {
Serial.print(F("PID Kp=")); Serial.print(F("Heat PI Kp="));
Serial.print(pidKp(), 3); Serial.print(heatPi_.kp(), 3);
Serial.print(F(" Ki=")); Serial.print(F(" Ki="));
Serial.print(pidKi(), 4); Serial.print(heatPi_.ki(), 4);
Serial.print(F(" Kd=")); Serial.print(F(" Mix PI Kp="));
Serial.print(pidKd(), 3); Serial.print(mixPi_.kp(), 3);
Serial.print(F(" fanMixMax=")); Serial.print(F(" Ki="));
Serial.print(fanMixMax_); Serial.print(mixPi_.ki(), 4);
Serial.print(F(" adaptive=")); Serial.print(F(" tuned="));
Serial.println(adaptiveEnabled_ ? F("yes") : F("no")); Serial.println(tuningLoaded_ ? F("yes") : F("no"));
} }
float pidKp() const { return pid_.kp(); } void saveTuningToEeprom() {
float pidKi() const { return pid_.ki(); } TuningData data;
float pidKd() const { return pid_.kd(); } data.magic = TUNING_MAGIC;
data.heatKp = heatPi_.kp();
data.heatKi = heatPi_.ki();
data.mixKp = mixPi_.kp();
data.mixKi = mixPi_.ki();
tuningSave(data);
tuningLoaded_ = true;
Serial.println(F("Saved PI to EEPROM"));
}
bool isAdaptive() const { return adaptiveEnabled_; } float heatKp() const { return heatPi_.kp(); }
float heatKi() const { return heatPi_.ki(); }
float mixKp() const { return mixPi_.kp(); }
float mixKi() const { return mixPi_.ki(); }
bool isTuningLoaded() const { return tuningLoaded_; }
bool startAutotune(float setpointC) { bool startAutotune(float setpointC) {
if (autotuner_.isActive() || stepresp_.isActive()) { if (autotuner_.isActive() || stepresp_.isActive()) {
return false; return false;
} }
adaptiveEnabled_ = false;
cutoffActive_ = false; cutoffActive_ = false;
pid_.reset(); heatPi_.reset();
mixPi_.reset();
return autotuner_.start(setpointC); return autotuner_.start(setpointC);
} }
@@ -136,9 +168,9 @@ public:
return false; return false;
} }
stopFanTest(); stopFanTest();
adaptiveEnabled_ = false;
cutoffActive_ = false; cutoffActive_ = false;
pid_.reset(); heatPi_.reset();
mixPi_.reset();
setTarget(targetC, false); setTarget(targetC, false);
if (!stepresp_.start(targetC, heaterPct)) { if (!stepresp_.start(targetC, heaterPct)) {
return false; return false;
@@ -176,23 +208,27 @@ public:
return false; return false;
} }
heatPi_.setTunings(autotuner_.resultKp(), autotuner_.resultKi(), 0.0f);
heatPi_.reset();
TuningData data; TuningData data;
data.magic = TUNING_MAGIC; data.magic = TUNING_MAGIC;
data.kp = autotuner_.resultKp(); data.heatKp = autotuner_.resultKp();
data.ki = autotuner_.resultKi(); data.heatKi = autotuner_.resultKi();
data.kd = autotuner_.resultKd(); data.mixKp = mixPi_.kp();
data.fanMixMax = autotuner_.resultFanMixMax(); data.mixKi = mixPi_.ki();
tuningSave(data); tuningSave(data);
applyTuning(data); tuningLoaded_ = true;
autotuner_.reset(); autotuner_.reset();
Serial.println(F("Saved learned PID to EEPROM")); Serial.println(F("Saved heat PI to EEPROM (mix PI unchanged)"));
return true; return true;
} }
void setTarget(float targetC, bool persist = true) { void setTarget(float targetC, bool persist = true) {
targetTempC_ = targetC; targetTempC_ = targetC;
pid_.setSetpoint(targetC); heatPi_.setSetpoint(targetC);
pid_.reset(); heatPi_.reset();
mixPi_.reset();
cutoffActive_ = false; cutoffActive_ = false;
if (targetC > 0.0f) { if (targetC > 0.0f) {
fanIdleOverride_ = false; fanIdleOverride_ = false;
@@ -280,7 +316,12 @@ public:
void stopFanTest() { fanTestActive_ = false; } void stopFanTest() { fanTestActive_ = false; }
float maxHeatStopAt(float avgTempC) const { return maxHeatStopTemp(avgTempC); } float maxHeatStopAt(float avgTempC) const {
if (isIdle()) {
return INFINITY;
}
return maxHeatStopTemp(avgTempC);
}
const char *heaterBlockReason() const { const char *heaterBlockReason() const {
switch (heaterBlock_) { switch (heaterBlock_) {
@@ -288,8 +329,6 @@ public:
return "cutoff"; return "cutoff";
case HeaterBlock::Corner: case HeaterBlock::Corner:
return "corner"; return "corner";
case HeaterBlock::Allow:
return "allow";
case HeaterBlock::Autotune: case HeaterBlock::Autotune:
return "autotune"; return "autotune";
case HeaterBlock::StepResp: case HeaterBlock::StepResp:
@@ -299,6 +338,10 @@ public:
} }
} }
const char *regulatingModeName() const {
return tuningLoaded_ ? "regulating" : "manual";
}
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);
@@ -306,8 +349,8 @@ public:
heaterBlock_ = HeaterBlock::None; heaterBlock_ = HeaterBlock::None;
cornerSpreadC_ = cornerSpreadC_ =
SPREAD_EMA_ALPHA * cornerSpreadC + SPREAD_EMA_ALPHA * cornerSpreadC_ +
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC_; (1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC;
if (stepresp_.isActive()) { if (stepresp_.isActive()) {
updateStepResponse(avgTempC, maxTempC, nowMs); updateStepResponse(avgTempC, maxTempC, nowMs);
@@ -322,21 +365,17 @@ public:
if (isIdle()) { if (isIdle()) {
forceHeaterOff(); forceHeaterOff();
cutoffActive_ = false; cutoffActive_ = false;
pid_.reset(); heatPi_.reset();
mixPi_.reset();
lastHeaterUpdateMs_ = nowMs; lastHeaterUpdateMs_ = nowMs;
applyFan(nowMs); applyFan(nowMs);
return; return;
} }
if (adaptiveEnabled_) { updateRegulating(avgTempC, maxTempC, nowMs);
updateAdaptive(avgTempC, maxTempC, nowMs);
} else {
updateLegacy(avgTempC, maxTempC, nowMs);
}
lastHeaterUpdateMs_ = nowMs; lastHeaterUpdateMs_ = nowMs;
applyHeaterBurst(nowMs); applyHeaterBurst(nowMs);
applyFan(nowMs); writeFan(regulatingFanPwm_);
} }
void enterFailSafe() { void enterFailSafe() {
@@ -344,7 +383,8 @@ public:
cutoffActive_ = false; cutoffActive_ = false;
forceHeaterOff(); forceHeaterOff();
applyFan(millis()); applyFan(millis());
pid_.reset(); heatPi_.reset();
mixPi_.reset();
autotuner_.abort(); autotuner_.abort();
stepresp_.abort(); stepresp_.abort();
} }
@@ -405,7 +445,7 @@ private:
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) { void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
float duty = 0.0f; float duty = 0.0f;
uint8_t fan = FAN_HEAT_MIN_PWM; uint8_t fan = AUTOTUNE_PREHEAT_FAN_PWM;
autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan); autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
heaterDutyPercent_ = duty; heaterDutyPercent_ = duty;
@@ -418,38 +458,33 @@ private:
commitAutotuneIfDone(); commitAutotuneIfDone();
} }
void updateAdaptive(float avgTempC, float maxTempC, uint32_t nowMs) { void updateRegulating(float avgTempC, float maxTempC, uint32_t nowMs) {
const float cutoff = cutoffThreshold(); if (maxTempC >= EMERGENCY_MAX_TEMP_C) {
if (maxTempC >= cutoff) {
cutoffActive_ = true; cutoffActive_ = true;
heaterDutyPercent_ = 0.0f; heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f; heaterAllowancePercent_ = 0.0f;
heaterOn_ = false; regulatingFanPwm_ = FAN_MAX_PWM;
heaterBlock_ = HeaterBlock::Cutoff; heaterBlock_ = HeaterBlock::Cutoff;
pid_.reset(); heatPi_.reset();
mixPi_.reset();
return; return;
} }
if (cutoffActive_ && maxTempC <= targetTempC_) { if (cutoffActive_ && maxTempC < EMERGENCY_MAX_TEMP_C - 5.0f) {
cutoffActive_ = false; cutoffActive_ = false;
pid_.reset(); heatPi_.reset();
mixPi_.reset();
} }
if (cutoffActive_) { if (cutoffActive_) {
heaterBlock_ = HeaterBlock::Cutoff; heaterBlock_ = HeaterBlock::Cutoff;
regulatingFanPwm_ = FAN_MAX_PWM;
return; return;
} }
if (shouldLimitMaxCorner(avgTempC) && maxTempC >= maxHeatStopTemp(avgTempC)) { heaterAllowancePercent_ = allowanceFromMaxCorner(maxTempC, avgTempC);
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
heaterBlock_ = HeaterBlock::Corner;
pid_.reset();
return;
}
float duty = pid_.compute(avgTempC, nowMs); float duty = heatPi_.compute(avgTempC, nowMs);
const float below = targetTempC_ - avgTempC; const float below = targetTempC_ - avgTempC;
if (below > 8.0f) { if (below > 8.0f) {
const float floor = below > 15.0f ? 75.0f : 60.0f; const float floor = below > 15.0f ? 75.0f : 60.0f;
@@ -461,51 +496,21 @@ private:
if (duty > maxDuty) { if (duty > maxDuty) {
duty = maxDuty; duty = maxDuty;
} }
heaterAllowancePercent_ = maxDuty;
heaterDutyPercent_ = duty;
}
void updateLegacy(float avgTempC, float maxTempC, uint32_t nowMs) {
const float cutoff = cutoffThreshold();
if (maxTempC >= cutoff) {
cutoffActive_ = true;
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
heaterOn_ = false;
heaterBlock_ = HeaterBlock::Cutoff;
pid_.reset();
return;
}
if (cutoffActive_ && maxTempC <= targetTempC_) {
cutoffActive_ = false;
pid_.reset();
}
if (cutoffActive_) {
heaterBlock_ = HeaterBlock::Cutoff;
return;
}
if (shouldLimitMaxCorner(avgTempC) && maxTempC >= maxHeatStopTemp(avgTempC)) {
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
heaterBlock_ = HeaterBlock::Corner;
pid_.reset();
return;
}
const float pidOut = pid_.compute(avgTempC, nowMs);
heaterAllowancePercent_ = heaterAllowancePercent(avgTempC, maxTempC);
float duty = pidOut;
if (duty > heaterAllowancePercent_) { if (duty > heaterAllowancePercent_) {
duty = heaterAllowancePercent_; duty = heaterAllowancePercent_;
if (heaterAllowancePercent_ < 100.0f) {
heaterBlock_ = HeaterBlock::Corner;
} }
if (duty <= 0.0f && heaterAllowancePercent_ <= 0.0f) {
heaterBlock_ = HeaterBlock::Allow;
} }
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs); heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
const float mixInput = SPREAD_TARGET_C - cornerSpreadC_;
float fanOut = mixPi_.compute(mixInput, nowMs);
uint8_t fanPwm = static_cast<uint8_t>(fanOut + 0.5f);
if (avgTempC < targetTempC_ - HEAT_UP_BAND_C && fanPwm > FAN_COLD_CAP_PWM) {
fanPwm = FAN_COLD_CAP_PWM;
}
regulatingFanPwm_ = fanPwm;
} }
static float clampPercent(float value) { static float clampPercent(float value) {
@@ -518,8 +523,6 @@ private:
return value; return value;
} }
bool isBalancedChamber() const { return cornerSpreadC_ <= GOOD_SPREAD_C; }
bool shouldLimitMaxCorner(float avgTempC) const { bool shouldLimitMaxCorner(float avgTempC) const {
return avgTempC >= targetTempC_ - CORNER_LIMIT_BAND_C; return avgTempC >= targetTempC_ - CORNER_LIMIT_BAND_C;
} }
@@ -536,10 +539,6 @@ private:
return 100.0f; return 100.0f;
} }
if (isBalancedChamber() && avgTempC < targetTempC_) {
return 100.0f;
}
const float stopAt = maxHeatStopTemp(avgTempC); const float stopAt = maxHeatStopTemp(avgTempC);
if (maxTempC >= stopAt) { if (maxTempC >= stopAt) {
return 0.0f; return 0.0f;
@@ -553,19 +552,6 @@ private:
return clampPercent((headroom / MAX_TEMP_HEADROOM_C) * 100.0f); return clampPercent((headroom / MAX_TEMP_HEADROOM_C) * 100.0f);
} }
float allowanceFromAverage(float avgTempC) const {
if (avgTempC >= targetTempC_) {
return 0.0f;
}
const float below = targetTempC_ - avgTempC;
if (below >= APPROACH_BAND_C) {
return 100.0f;
}
return clampPercent((below / APPROACH_BAND_C) * 100.0f);
}
float heaterMaxDuty(float avgTempC) const { float heaterMaxDuty(float avgTempC) const {
if (avgTempC >= targetTempC_) { if (avgTempC >= targetTempC_) {
return HEATER_MAX_DUTY_NEAR; return HEATER_MAX_DUTY_NEAR;
@@ -581,17 +567,6 @@ private:
return HEATER_MAX_DUTY_NEAR; return HEATER_MAX_DUTY_NEAR;
} }
float heaterAllowancePercent(float avgTempC, float maxTempC) const {
const float fromMax = allowanceFromMaxCorner(maxTempC, avgTempC);
const float fromAvg = allowanceFromAverage(avgTempC);
float allowance = fromMax < fromAvg ? fromMax : fromAvg;
const float maxDuty = heaterMaxDuty(avgTempC);
if (allowance > maxDuty) {
allowance = maxDuty;
}
return allowance;
}
float applyHeaterRamp(float requestedDuty, float avgTempC, uint32_t nowMs) { float applyHeaterRamp(float requestedDuty, float avgTempC, uint32_t nowMs) {
const float maxDuty = heaterMaxDuty(avgTempC); const float maxDuty = heaterMaxDuty(avgTempC);
if (requestedDuty > maxDuty) { if (requestedDuty > maxDuty) {
@@ -609,55 +584,6 @@ private:
return requestedDuty; return requestedDuty;
} }
uint8_t fanPwmForHeatUp() const {
if (heaterDutyPercent_ <= 0.0f) {
return 0;
}
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<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 {
if (cornerSpreadC_ <= SPREAD_DEADBAND_C) {
return 0;
}
float spread = cornerSpreadC_;
if (spread > SPREAD_FULL_MIX_C) {
spread = SPREAD_FULL_MIX_C;
}
const float t =
(spread - SPREAD_DEADBAND_C) / (SPREAD_FULL_MIX_C - SPREAD_DEADBAND_C);
const uint8_t mixMax = fanMixMax_;
const uint8_t mixMin = FAN_MIX_MIN_PWM;
const uint8_t span = mixMax > mixMin ? mixMax - mixMin : 0;
return mixMin + static_cast<uint8_t>(t * static_cast<float>(span));
}
void applyHeaterBurst(uint32_t nowMs) { void applyHeaterBurst(uint32_t nowMs) {
if (heaterDutyPercent_ <= 0.0f) { if (heaterDutyPercent_ <= 0.0f) {
forceHeaterOff(); forceHeaterOff();
@@ -702,12 +628,10 @@ private:
writeFan(FAN_MAX_PWM); writeFan(FAN_MAX_PWM);
return; return;
} }
uint8_t duty = fanPwmForHeatUp();
writeFan(duty);
} }
PidController pid_; PidController heatPi_;
PidController mixPi_;
PidAutotuner autotuner_; PidAutotuner autotuner_;
FanStepResponse stepresp_; FanStepResponse stepresp_;
float targetTempC_; float targetTempC_;
@@ -715,9 +639,9 @@ private:
float heaterAllowancePercent_; float heaterAllowancePercent_;
float cornerSpreadC_; float cornerSpreadC_;
float lastMaxTempC_; float lastMaxTempC_;
uint8_t regulatingFanPwm_;
uint8_t fanPwm_; uint8_t fanPwm_;
uint8_t fanMixMax_; bool tuningLoaded_;
bool adaptiveEnabled_;
bool fanIdleOverride_; bool fanIdleOverride_;
bool sensorWarmValid_; bool sensorWarmValid_;
bool cutoffActive_; bool cutoffActive_;

View File

@@ -26,7 +26,7 @@ python3 scripts/capture_csv.py log
# logs/dryer_YYYYMMDD_HHMMSS.csv # logs/dryer_YYYYMMDD_HHMMSS.csv
``` ```
**TUI keys:** `0` idle · `t` target · `p` presets · `f` fan on · `F` fan off · `l` toggle CSV log · `a` autotune · `:` raw command · `q` quit **TUI keys:** `0` idle · `t` target · `p` presets · `f` fan on · `F` fan off · `l` CSV log · `a` autotune · `r` stepresp · `:` command · `q` quit
**Always-on logging** **Always-on logging**

View File

@@ -178,10 +178,8 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
Serial.print(F(" heat=")); Serial.print(F(" heat="));
Serial.print(thermal.stepResponseHeaterPct(), 0); Serial.print(thermal.stepResponseHeaterPct(), 0);
Serial.print(F("%")); Serial.print(F("%"));
} else if (thermal.isAdaptive()) {
Serial.print(F("learned"));
} else { } else {
Serial.print(F("manual")); Serial.print(thermal.regulatingModeName());
} }
Serial.print(F(" sensors=[")); Serial.print(F(" sensors=["));
@@ -211,12 +209,16 @@ void printHelp() {
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(" 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 heat PI (default: 40C when idle)"));
Serial.println(F(" autotune stop")); Serial.println(F(" autotune stop"));
Serial.println(F(" stepresp [C] [heater%] fan step response (default: 45C 35%)")); Serial.println(F(" stepresp [C] [heater%] fan step response (default: 45C 35%)"));
Serial.println(F(" stepresp stop")); Serial.println(F(" stepresp stop"));
Serial.println(F(" pid show PID / adaptive status")); Serial.println(F(" pid show heat + mix PI gains"));
Serial.println(F(" pid default reset to factory PID")); Serial.println(F(" pid default reset all PI to factory"));
Serial.println(F(" pid save write current PI to EEPROM"));
Serial.println(F(" mixpi show mix PI gains"));
Serial.println(F(" mixpi <Kp> <Ki> set mix PI (spread -> fan)"));
Serial.println(F(" mixpi default reset mix PI to factory"));
Serial.println(F(" status print current readings")); Serial.println(F(" status print current readings"));
Serial.println(F(" log on|off CSV data stream")); Serial.println(F(" log on|off CSV data stream"));
Serial.println(F(" help show this message")); Serial.println(F(" help show this message"));
@@ -393,6 +395,40 @@ void processSerialLine(const char *line) {
return; return;
} }
if (strcmp(line, "pid save") == 0) {
thermal.saveTuningToEeprom();
return;
}
if (strcmp(line, "mixpi") == 0 || strcmp(line, "mixpi show") == 0) {
Serial.print(F("Mix PI Kp="));
Serial.print(thermal.mixKp(), 3);
Serial.print(F(" Ki="));
Serial.println(thermal.mixKi(), 4);
return;
}
if (strcmp(line, "mixpi default") == 0) {
thermal.resetMixTunings();
return;
}
if (strncmp(line, "mixpi ", 6) == 0) {
const float kp = atof(line + 6);
const char *space = strchr(line + 6, ' ');
if (space == nullptr) {
Serial.println(F("ERR mixpi requires: mixpi <Kp> <Ki>"));
return;
}
const float ki = atof(space + 1);
if (kp <= 0.0f || ki < 0.0f) {
Serial.println(F("ERR mixpi Kp must be > 0, Ki >= 0"));
return;
}
thermal.setMixTunings(kp, ki);
return;
}
if (strcmp(line, "help") == 0) { if (strcmp(line, "help") == 0) {
printHelp(); printHelp();
return; return;