diff --git a/README.md b/README.md index 2ff6f19..4fc5817 100644 --- a/README.md +++ b/README.md @@ -82,20 +82,14 @@ Verify access: `test -w /dev/ttyUSB0 && echo ok` 1. Flash firmware and open the serial monitor at 115200 baud. 2. Confirm `TCA9548A detected` and four valid sensor channels (`ch2`–`ch5`). -3. Find **stir fan** speed (chamber ~35°C, idle): +3. Find **stir fan** speed (optional — default is **PWM 178**, ~70%): ``` target 0 fanchars ``` - Heats to max **60°C** at **30%, 100%, 60%, 80%** fan (cool to **40°C** avg between each). If the best is not at 30% or 100%, runs once at the midpoint of the two lowest spreads (~1–3 h). Or: `python3 scripts/fan_characterize.py`. When done: - - ``` - fanchars save - ``` - - Stored PWM is the minimum-stir floor while regulating (≥40°C avg). + Sweeps **30%, 100%, 60%, 80%** fan — heats to max **60°C** corner at **100%** heater. Cools to **40°C** avg between runs. Skips a fan speed if 60°C isn't reached in time. Optional midpoint refine if best isn't at 30% or 100%. Or: `python3 scripts/fan_characterize.py`. When done, `fanchars save` writes the winner to EEPROM (or skip and keep the 178 default). 4. Tune **heat PI** (stored in EEPROM on autotune complete): @@ -107,9 +101,9 @@ Verify access: `test -w /dev/ttyUSB0 && echo ok` Emergency cutoff is fixed at **70°C** — you can autotune at 50–55°C with ABS in the chamber while hot corners stay below that. -5. Optional: adjust **mix PI** at target if spread still drifts (`mixpi`, `pid save`). Stir fan from step 3 is usually enough to start drying. +5. Dry at your target — fan runs at the stir PWM (default **178**) unless you override with `fan `; `fan auto` returns to the default. -Send `help` over serial for all commands (`target`, `fanchars`, `mixpi`, `fan test`, `log on/off`, `status`, `pid`, etc.). +Send `help` over serial for all commands (`target`, `fanchars`, `fan`, `log on/off`, `status`, `pid`, etc.). ## Raspberry Pi control diff --git a/include/config.h b/include/config.h index 1af0396..b5a8e74 100644 --- a/include/config.h +++ b/include/config.h @@ -29,16 +29,12 @@ static const float TARGET_MAX_C = 80.0f; static const float EMERGENCY_MAX_TEMP_C = 70.0f; static const float CORNER_STOP_MARGIN_C = 3.0f; -// Dual PI — heat on avg, mix on spread (no D term) +// Heat PI on average temp (no D term) static const float HEAT_PI_KP = 4.0f; static const float HEAT_PI_KI = 0.05f; -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; -// Mix PI only at/above target; below target use minimum stir only (no mix PI). -// Cap mix fan — high airflow often increases spread / heat loss rather than fixing it. -static const uint8_t FAN_MIX_MAX_PWM = 140; + +// Fixed circulation fan (~70%, fanchars winner); override with "fan " when regulating +static const uint8_t FAN_STIR_PWM = 178; // Legacy aliases for autotuner relay math only static const float PID_KP = HEAT_PI_KP; @@ -81,7 +77,7 @@ static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL; static const float FANCHARS_MAX_CORNER_C = 60.0f; static const float FANCHARS_COOL_AVG_C = 40.0f; static const float FANCHARS_PRECOOL_MARGIN_C = 2.0f; -static const float FANCHARS_HEATER_PCT = 85.0f; +static const float FANCHARS_HEATER_PCT = 100.0f; // Coarse sweep order: 30%, 100%, 60%, 80% fan static const uint8_t FANCHARS_COARSE_PWM[] = {77, 255, 153, 204}; static const uint8_t FANCHARS_COARSE_COUNT = diff --git a/include/pid_autotuner.h b/include/pid_autotuner.h index 5ab2207..76e812e 100644 --- a/include/pid_autotuner.h +++ b/include/pid_autotuner.h @@ -271,7 +271,7 @@ private: Serial.print(resultKp_, 3); Serial.print(F(" Ki=")); Serial.println(resultKi_, 4); - Serial.println(F(" tune mix PI separately: mixpi ")); + Serial.println(F(" tune heat PI: pid save after autotune")); } void fail(const __FlashStringHelper *reason) { diff --git a/include/settings_store.h b/include/settings_store.h index b5ad3d1..b46cd3c 100644 --- a/include/settings_store.h +++ b/include/settings_store.h @@ -12,7 +12,7 @@ static const int SETTINGS_EEPROM_ADDR = 16; struct SettingsData { uint16_t magic = 0; float targetC = TARGET_TEMP_C; - uint8_t stirFanPwm = 0; // 0 = use FAN_IDLE_PWM (~30%) + uint8_t stirFanPwm = 0; // 0 = use FAN_STIR_PWM from config }; inline uint8_t settingsChecksum(const SettingsData &data) { diff --git a/include/thermal_controller.h b/include/thermal_controller.h index 09419ba..751e41a 100644 --- a/include/thermal_controller.h +++ b/include/thermal_controller.h @@ -15,7 +15,6 @@ public: ThermalController() : 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_(), fanchars_(), targetTempC_(TARGET_TEMP_C), @@ -24,10 +23,12 @@ public: cornerSpreadC_(0.0f), lastMaxTempC_(0.0f), regulatingFanPwm_(0), - stirFanPwm_(FAN_IDLE_PWM), + stirFanPwm_(FAN_STIR_PWM), + fanManualPwm_(0), fanPwm_(0), tuningLoaded_(false), fanIdleOverride_(false), + fanManualActive_(false), sensorWarmValid_(false), cutoffActive_(false), failSafeActive_(true), @@ -38,9 +39,7 @@ public: heaterBlock_(HeaterBlock::None), fanTestActive_(false), fanTestPwm_(0), - fanTestEndMs_(0) { - mixPi_.setSetpoint(0.0f); - } + fanTestEndMs_(0) {} void begin() { pinMode(FAN_PIN, OUTPUT); @@ -50,7 +49,6 @@ public: heatPi_.setSetpoint(targetTempC_); heatPi_.reset(); - mixPi_.reset(); heaterCycleStartMs_ = millis(); lastHeaterUpdateMs_ = 0; failSafeActive_ = true; @@ -83,44 +81,22 @@ public: void applyTuning(const TuningData &data) { heatPi_.setTunings(data.heatKp, data.heatKi, 0.0f); - mixPi_.setTunings(data.mixKp, data.mixKi, 0.0f); tuningLoaded_ = true; } void clearTuning() { tuningLoaded_ = false; heatPi_.setTunings(HEAT_PI_KP, HEAT_PI_KI, 0.0f); - mixPi_.setTunings(MIX_PI_KP, MIX_PI_KI, 0.0f); tuningClear(); heatPi_.reset(); - 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 { Serial.print(F("Heat PI Kp=")); Serial.print(heatPi_.kp(), 3); Serial.print(F(" Ki=")); Serial.print(heatPi_.ki(), 4); - Serial.print(F(" Mix PI Kp=")); - Serial.print(mixPi_.kp(), 3); - Serial.print(F(" Ki=")); - Serial.print(mixPi_.ki(), 4); Serial.print(F(" tuned=")); Serial.println(tuningLoaded_ ? F("yes") : F("no")); } @@ -130,8 +106,6 @@ public: 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")); @@ -139,8 +113,6 @@ public: 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_; } @@ -150,7 +122,6 @@ public: } cutoffActive_ = false; heatPi_.reset(); - mixPi_.reset(); return autotuner_.start(setpointC); } @@ -175,7 +146,6 @@ public: stopFanTest(); cutoffActive_ = false; heatPi_.reset(); - mixPi_.reset(); setTarget(0.0f, false); return fanchars_.start(maxCornerC, avgTempC); } @@ -204,6 +174,32 @@ public: 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) { @@ -239,12 +235,10 @@ public: data.magic = TUNING_MAGIC; data.heatKp = autotuner_.resultKp(); data.heatKi = autotuner_.resultKi(); - data.mixKp = mixPi_.kp(); - data.mixKi = mixPi_.ki(); tuningSave(data); tuningLoaded_ = true; autotuner_.reset(); - Serial.println(F("Saved heat PI to EEPROM (mix PI unchanged)")); + Serial.println(F("Saved heat PI to EEPROM")); return true; } @@ -252,7 +246,7 @@ public: targetTempC_ = targetC; heatPi_.setSetpoint(targetC); heatPi_.reset(); - mixPi_.reset(); + fanManualActive_ = false; cutoffActive_ = false; if (targetC > 0.0f) { fanIdleOverride_ = false; @@ -390,7 +384,6 @@ public: forceHeaterOff(); cutoffActive_ = false; heatPi_.reset(); - mixPi_.reset(); lastHeaterUpdateMs_ = nowMs; applyFan(nowMs); return; @@ -408,7 +401,6 @@ public: forceHeaterOff(); applyFan(millis()); heatPi_.reset(); - mixPi_.reset(); autotuner_.abort(); fanchars_.abort(); } @@ -485,14 +477,12 @@ private: regulatingFanPwm_ = FAN_MAX_PWM; heaterBlock_ = HeaterBlock::Cutoff; heatPi_.reset(); - mixPi_.reset(); return; } if (cutoffActive_ && maxTempC < EMERGENCY_MAX_TEMP_C - 5.0f) { cutoffActive_ = false; heatPi_.reset(); - mixPi_.reset(); } if (cutoffActive_) { @@ -523,17 +513,7 @@ private: } heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs); - uint8_t fanPwm = stirFanPwm_; - if (avgTempC < targetTempC_) { - mixPi_.reset(); - } else { - const float mixInput = SPREAD_TARGET_C - cornerSpreadC_; - float fanOut = mixPi_.compute(mixInput, nowMs); - fanPwm = static_cast(fanOut + 0.5f); - if (fanPwm > FAN_MIX_MAX_PWM) { - fanPwm = FAN_MIX_MAX_PWM; - } - } + const uint8_t fanPwm = fanManualActive_ ? fanManualPwm_ : stirFanPwm_; regulatingFanPwm_ = fanWithMinStir(avgTempC, fanPwm); } @@ -541,9 +521,6 @@ private: if (avgTempC < IDLE_AUTO_FAN_OFF_TEMP_C) { return 0; } - if (pwm < stirFanPwm_) { - return stirFanPwm_; - } return pwm; } @@ -665,7 +642,6 @@ private: } PidController heatPi_; - PidController mixPi_; PidAutotuner autotuner_; FanCharacterize fanchars_; float targetTempC_; @@ -675,9 +651,11 @@ private: 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_; diff --git a/include/tuning_store.h b/include/tuning_store.h index 3a65088..38e967e 100644 --- a/include/tuning_store.h +++ b/include/tuning_store.h @@ -5,15 +5,13 @@ #include "config.h" -static const uint16_t TUNING_MAGIC = 0xDA7B; +static const uint16_t TUNING_MAGIC = 0xDA7C; static const int TUNING_EEPROM_ADDR = 0; struct TuningData { uint16_t magic = 0; float heatKp = HEAT_PI_KP; float heatKi = HEAT_PI_KI; - float mixKp = MIX_PI_KP; - float mixKi = MIX_PI_KI; }; inline uint8_t tuningChecksum(const TuningData &data) { diff --git a/scripts/dryer_tui.py b/scripts/dryer_tui.py index 8a49f0c..b0f7488 100644 --- a/scripts/dryer_tui.py +++ b/scripts/dryer_tui.py @@ -200,7 +200,9 @@ def apply_status(state: DryerState, data: dict) -> None: fan_raw = data["fan"] state.fan = format_fan_display(fan_raw) state.fan_note = "" - if "(off)" in fan_raw or "(cooldown)" in fan_raw or "(cmd-off)" in fan_raw: + if "(off)" in fan_raw or "(cooldown)" in fan_raw or "(off<40C)" in fan_raw: + state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else "" + elif "(manual)" in fan_raw or "(stir)" in fan_raw: state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else "" elif "(fanchars-" in fan_raw: state.fan_note = fan_raw[fan_raw.find("(fanchars-") :] diff --git a/src/main.cpp b/src/main.cpp index 184eb54..d4117f4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -150,8 +150,12 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) { Serial.print(F("(off)")); } else if (thermal.isIdleCooling()) { Serial.print(F("(cooldown)")); - } else if (thermal.fanPwm() == 0) { - Serial.print(F("(cmd-off)")); + } else if (thermal.isFanManualOverride()) { + Serial.print(F("(manual)")); + } else if (!thermal.isIdle() && thermal.fanPwm() == 0) { + Serial.print(F("(off<40C)")); + } else if (!thermal.isIdle()) { + Serial.print(F("(stir)")); } Serial.print(F(" cutoff=")); Serial.print(thermal.isCutoffActive() ? F("YES") : F("no")); @@ -224,17 +228,18 @@ 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 auto regulating: back to default stir fan")); + Serial.println(F(" fan regulating: manual fan override (0-255)")); + Serial.println(F(" fan stir show default stir fan PWM")); + Serial.println(F(" fan stir N set default stir fan + EEPROM")); Serial.println(F(" fan test N set fan PWM 0-255 for 15s (verify wiring)")); Serial.println(F(" autotune [C] learn heat PI (default: 40C when idle)")); Serial.println(F(" autotune stop")); Serial.println(F(" fanchars learn stir fan (30/100/60/80%% + refine)")); Serial.println(F(" fanchars stop | fanchars save")); - Serial.println(F(" pid show heat + mix PI gains")); - Serial.println(F(" pid default reset all PI to factory")); + Serial.println(F(" pid show heat PI gains")); + Serial.println(F(" pid default reset heat 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 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(" log on|off CSV data stream")); Serial.println(F(" help show this message")); @@ -323,6 +328,58 @@ void processSerialLine(const char *line) { return; } + if (strcmp(line, "fan auto") == 0) { + if (thermal.isIdle()) { + Serial.println(F("ERR fan auto only when regulating (set target > 0)")); + return; + } + thermal.clearRegulatingFanManual(); + Serial.print(F("OK fan stir ")); + Serial.println(thermal.stirFanPwm()); + return; + } + + if (strncmp(line, "fan stir", 8) == 0) { + if (line[8] == '\0') { + Serial.print(F("stir fan PWM=")); + Serial.println(thermal.stirFanPwm()); + return; + } + if (line[8] == ' ') { + const int pwm = atoi(line + 9); + if (pwm < 1 || pwm > 255) { + Serial.println(F("ERR fan stir PWM must be 1-255")); + return; + } + thermal.setStirFanPwm(static_cast(pwm)); + if (!thermal.isIdle()) { + thermal.clearRegulatingFanManual(); + } + Serial.print(F("OK stir fan ")); + Serial.println(pwm); + return; + } + } + + if (strncmp(line, "fan ", 4) == 0) { + const char *arg = line + 4; + if (*arg >= '0' && *arg <= '9') { + const int pwm = atoi(arg); + if (pwm < 0 || pwm > 255) { + Serial.println(F("ERR fan PWM must be 0-255")); + return; + } + if (thermal.isIdle()) { + Serial.println(F("ERR fan only when regulating (or use fan test)")); + return; + } + thermal.setRegulatingFanManual(static_cast(pwm)); + Serial.print(F("OK fan manual ")); + Serial.println(pwm); + return; + } + } + if (strcmp(line, "status") == 0) { const float avgTemp = averageValidTemperature(); const float minTemp = minValidTemperature(); @@ -415,35 +472,6 @@ void processSerialLine(const char *line) { 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 ")); - 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) { printHelp(); return;