diff --git a/README.md b/README.md index 65f8c59..2ff6f19 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,22 @@ 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. Tune **heat PI** (stored in EEPROM on autotune complete): +3. Find **stir fan** speed (chamber ~35°C, idle): + + ``` + 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). + +4. Tune **heat PI** (stored in EEPROM on autotune complete): ``` target 0 @@ -92,25 +107,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. -4. Tune **mix PI** (spread → fan) while drying: +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. - ``` - target 55 - log on - ``` - - 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.). +Send `help` over serial for all commands (`target`, `fanchars`, `mixpi`, `fan test`, `log on/off`, `status`, `pid`, etc.). ## Raspberry Pi control diff --git a/include/config.h b/include/config.h index f84a658..1af0396 100644 --- a/include/config.h +++ b/include/config.h @@ -36,7 +36,9 @@ 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; +// 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; // Legacy aliases for autotuner relay math only static const float PID_KP = HEAT_PI_KP; @@ -56,7 +58,7 @@ static const float MAX_TEMP_HEADROOM_C = 15.0f; static const uint16_t HEATER_CYCLE_MS = 3000; -// Fan PWM +// Fan PWM — FAN_IDLE_PWM ≈ 30%; off only while chamber avg is below 40°C static const uint8_t FAN_IDLE_PWM = 77; static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f; static const uint8_t FAN_MAX_PWM = 255; @@ -75,19 +77,22 @@ static const uint32_t AUTOTUNE_RELAY_STALL_MS = 1500000UL; static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL; static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL; -// Fan step-response — open-loop heater, fan PWM steps (command: stepresp) -static const float STEPRESP_DEFAULT_TEMP_C = 45.0f; -static const float STEPRESP_DEFAULT_HEATER_PCT = 35.0f; -static const float STEPRESP_MIN_HEATER_PCT = 10.0f; -static const float STEPRESP_MAX_HEATER_PCT = 70.0f; -static const float STEPRESP_PREHEAT_BAND_C = 2.0f; -static const uint32_t STEPRESP_PREHEAT_TIMEOUT_MS = 1200000UL; -static const uint32_t STEPRESP_BASELINE_MS = 120000UL; -static const uint32_t STEPRESP_STEP_HOLD_MS = 300000UL; -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_STEP_COUNT = - sizeof(STEPRESP_FAN_STEPS) / sizeof(STEPRESP_FAN_STEPS[0]); +// Fan characterize — fixed heater, sweep fan PWMs, pick lowest spread +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; +// 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 = + sizeof(FANCHARS_COARSE_PWM) / sizeof(FANCHARS_COARSE_PWM[0]); +static const uint8_t FANCHARS_LIMIT_LOW_PWM = 77; +static const uint8_t FANCHARS_LIMIT_HIGH_PWM = 255; +static const uint8_t FANCHARS_MAX_RESULTS = FANCHARS_COARSE_COUNT + 1; +static const uint32_t FANCHARS_HOLD_MS = 60000UL; +static const uint32_t FANCHARS_HEAT_TIMEOUT_MS = 2700000UL; +static const uint32_t FANCHARS_COOLDOWN_TIMEOUT_MS = 2700000UL; +static const uint32_t FANCHARS_LOG_INTERVAL_MS = 1000UL; // --------------------------------------------------------------------------- // Timing diff --git a/include/fan_step_response.h b/include/fan_step_response.h deleted file mode 100644 index c6a9f51..0000000 --- a/include/fan_step_response.h +++ /dev/null @@ -1,268 +0,0 @@ -#pragma once - -#include - -#include "config.h" - -class FanStepResponse { -public: - enum class Phase : uint8_t { Idle, Preheat, Baseline, StepHold, Done, Failed }; - - FanStepResponse() - : phase_(Phase::Idle), - targetC_(STEPRESP_DEFAULT_TEMP_C), - heaterPct_(STEPRESP_DEFAULT_HEATER_PCT), - stepIndex_(0), - sessionStartMs_(0), - phaseStartMs_(0), - lastLogMs_(0), - lastAvgC_(0.0f) {} - - Phase phase() const { return phase_; } - - bool isActive() const { - return phase_ == Phase::Preheat || phase_ == Phase::Baseline || phase_ == Phase::StepHold; - } - - uint32_t elapsedMs(uint32_t nowMs) const { - if (sessionStartMs_ == 0) { - return 0; - } - return nowMs - sessionStartMs_; - } - - uint8_t stepIndex() const { return stepIndex_; } - - uint8_t stepCount() const { return STEPRESP_FAN_STEP_COUNT; } - - float targetC() const { return targetC_; } - - float heaterPct() const { return heaterPct_; } - - uint8_t currentFanPwm() const { - if (stepIndex_ >= STEPRESP_FAN_STEP_COUNT) { - return 0; - } - return STEPRESP_FAN_STEPS[stepIndex_]; - } - - const char *phaseName() const { - switch (phase_) { - case Phase::Preheat: - return "preheat"; - case Phase::Baseline: - return "baseline"; - case Phase::StepHold: - return "step"; - default: - return ""; - } - } - - bool start(float targetC, float heaterPct) { - if (targetC < 25.0f || targetC > TARGET_MAX_C) { - return false; - } - if (heaterPct < STEPRESP_MIN_HEATER_PCT || heaterPct > STEPRESP_MAX_HEATER_PCT) { - return false; - } - - targetC_ = targetC; - heaterPct_ = heaterPct; - stepIndex_ = 0; - sessionStartMs_ = millis(); - phaseStartMs_ = sessionStartMs_; - lastLogMs_ = 0; - lastAvgC_ = 0.0f; - phase_ = Phase::Preheat; - - Serial.print(F("stepresp: preheat to ")); - Serial.print(targetC_ - STEPRESP_PREHEAT_BAND_C, 1); - Serial.print(F("-")); - Serial.print(targetC_, 1); - Serial.print(F("C avg, heater=")); - Serial.print(heaterPct_, 0); - Serial.println(F("% fan=0")); - return true; - } - - void abort() { - if (isActive()) { - Serial.println(F("stepresp: cancelled")); - } - phase_ = Phase::Idle; - sessionStartMs_ = 0; - } - - void reset() { - phase_ = Phase::Idle; - sessionStartMs_ = 0; - } - - bool update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut, - uint8_t &fanPwmOut) { - heaterDutyOut = 0.0f; - fanPwmOut = 0; - - if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) { - return false; - } - - if (maxTempC >= EMERGENCY_MAX_TEMP_C) { - fail(F("stepresp: abort — max sensor at safety limit")); - return false; - } - - if (phase_ == Phase::Preheat) { - fanPwmOut = 0; - if (nowMs - phaseStartMs_ > STEPRESP_PREHEAT_TIMEOUT_MS) { - fail(F("stepresp: abort — preheat timeout")); - return false; - } - if (avgTempC >= targetC_ - STEPRESP_PREHEAT_BAND_C) { - enterBaseline(nowMs); - } else { - heaterDutyOut = heaterPct_; - } - return true; - } - - heaterDutyOut = heaterPct_; - fanPwmOut = currentFanPwm(); - - if (phase_ == Phase::Baseline) { - if (nowMs - phaseStartMs_ >= STEPRESP_BASELINE_MS) { - advanceStep(nowMs); - } - return true; - } - - if (phase_ == Phase::StepHold) { - if (nowMs - phaseStartMs_ >= STEPRESP_STEP_HOLD_MS) { - if (stepIndex_ + 1 >= STEPRESP_FAN_STEP_COUNT) { - finish(nowMs, avgTempC, spreadC); - } else { - ++stepIndex_; - enterStepHold(nowMs, true); - } - } - return true; - } - - return false; - } - - void logIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount, - float avgTempC, float minTempC, float maxTempC, float spreadC, uint32_t nowMs) { - if (!isActive()) { - return; - } - if (lastLogMs_ != 0 && nowMs - lastLogMs_ < STEPRESP_LOG_INTERVAL_MS) { - return; - } - lastLogMs_ = nowMs; - lastAvgC_ = avgTempC; - - Serial.print(F("sr,")); - Serial.print(nowMs); - Serial.print(','); - Serial.print(phaseName()); - Serial.print(','); - Serial.print(stepIndex_); - Serial.print('/'); - Serial.print(STEPRESP_FAN_STEP_COUNT); - Serial.print(','); - Serial.print(heaterPct_, 0); - Serial.print(','); - Serial.print(currentFanPwm()); - Serial.print(','); - Serial.print(avgTempC, 2); - Serial.print(','); - Serial.print(minTempC, 2); - Serial.print(','); - Serial.print(maxTempC, 2); - Serial.print(','); - Serial.print(spreadC, 2); - - for (uint8_t i = 0; i < sensorCount; ++i) { - Serial.print(','); - if (sensorValid[i]) { - Serial.print(sensorTemps[i], 2); - } - } - - Serial.println(); - } - -private: - void enterBaseline(uint32_t nowMs) { - phase_ = Phase::Baseline; - phaseStartMs_ = nowMs; - stepIndex_ = 0; - Serial.print(F("stepresp: baseline fan=")); - Serial.print(currentFanPwm()); - Serial.print(F(" for ")); - Serial.print(STEPRESP_BASELINE_MS / 1000UL); - Serial.println(F("s")); - } - - void enterStepHold(uint32_t nowMs, bool isStep) { - phase_ = Phase::StepHold; - phaseStartMs_ = nowMs; - Serial.print(F("stepresp: ")); - if (isStep) { - Serial.print(F("step ")); - } - Serial.print(stepIndex_ + 1); - Serial.print(F("/")); - Serial.print(STEPRESP_FAN_STEP_COUNT); - Serial.print(F(" fan=")); - Serial.print(currentFanPwm()); - Serial.print(F(" (")); - Serial.print((currentFanPwm() * 100) / 255); - Serial.print(F("%) hold ")); - Serial.print(STEPRESP_STEP_HOLD_MS / 1000UL); - Serial.println(F("s")); - } - - void advanceStep(uint32_t nowMs) { - if (STEPRESP_FAN_STEP_COUNT <= 1) { - finish(nowMs, lastAvgC_, 0.0f); - return; - } - stepIndex_ = 1; - enterStepHold(nowMs, true); - } - - void finish(uint32_t nowMs, float avgTempC, float spreadC) { - phase_ = Phase::Done; - Serial.print(F("stepresp: done in ")); - Serial.print((nowMs - sessionStartMs_) / 1000UL); - Serial.println(F("s")); - Serial.print(F(" target=")); - Serial.print(targetC_, 1); - Serial.print(F("C heater=")); - Serial.print(heaterPct_, 0); - Serial.print(F("% final avg=")); - Serial.print(avgTempC, 1); - Serial.print(F("C spread=")); - Serial.print(spreadC, 1); - Serial.println(F("C")); - Serial.println(F(" parse sr,... lines for step response (fan PWM vs temp)")); - } - - void fail(const __FlashStringHelper *reason) { - Serial.println(reason); - phase_ = Phase::Failed; - sessionStartMs_ = 0; - } - - Phase phase_; - float targetC_; - float heaterPct_; - uint8_t stepIndex_; - uint32_t sessionStartMs_; - uint32_t phaseStartMs_; - uint32_t lastLogMs_; - float lastAvgC_; -}; diff --git a/include/settings_store.h b/include/settings_store.h index 19263c2..b5ad3d1 100644 --- a/include/settings_store.h +++ b/include/settings_store.h @@ -6,12 +6,13 @@ #include "config.h" // After TuningData (15 bytes) + checksum (1 byte) at address 0 -static const uint16_t SETTINGS_MAGIC = 0xDA7E; +static const uint16_t SETTINGS_MAGIC = 0xDA7F; 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%) }; inline uint8_t settingsChecksum(const SettingsData &data) { @@ -39,8 +40,24 @@ inline void settingsSave(const SettingsData &data) { inline void settingsSaveTarget(float targetC) { SettingsData data; - data.magic = SETTINGS_MAGIC; - data.targetC = targetC; + if (settingsLoad(data)) { + data.targetC = targetC; + } else { + data.magic = SETTINGS_MAGIC; + data.targetC = targetC; + data.stirFanPwm = 0; + } + settingsSave(data); +} + +inline void settingsSaveStirFan(uint8_t stirFanPwm) { + SettingsData data; + if (settingsLoad(data)) { + data.stirFanPwm = stirFanPwm; + } else { + data.magic = SETTINGS_MAGIC; + data.stirFanPwm = stirFanPwm; + } settingsSave(data); } diff --git a/include/thermal_controller.h b/include/thermal_controller.h index 9d658b0..09419ba 100644 --- a/include/thermal_controller.h +++ b/include/thermal_controller.h @@ -3,7 +3,7 @@ #include #include "config.h" -#include "fan_step_response.h" +#include "fan_characterize.h" #include "pid_autotuner.h" #include "pid_controller.h" #include "settings_store.h" @@ -11,19 +11,20 @@ class ThermalController { public: - enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, StepResp }; + enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, FanChars }; 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_(), - stepresp_(), + fanchars_(), targetTempC_(TARGET_TEMP_C), heaterDutyPercent_(0.0f), heaterAllowancePercent_(100.0f), cornerSpreadC_(0.0f), lastMaxTempC_(0.0f), regulatingFanPwm_(0), + stirFanPwm_(FAN_IDLE_PWM), fanPwm_(0), tuningLoaded_(false), fanIdleOverride_(false), @@ -70,9 +71,13 @@ public: } SettingsData settings; - if (settingsLoad(settings) && settings.targetC >= TARGET_MIN_C && - settings.targetC <= TARGET_MAX_C) { - setTarget(settings.targetC, false); + 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); + } } } @@ -140,7 +145,7 @@ public: bool isTuningLoaded() const { return tuningLoaded_; } bool startAutotune(float setpointC) { - if (autotuner_.isActive() || stepresp_.isActive()) { + if (autotuner_.isActive() || fanchars_.isActive()) { return false; } cutoffActive_ = false; @@ -163,46 +168,65 @@ public: float autotunePreheatTargetC() const { return autotuner_.preheatTargetC(); } - bool startStepResponse(float targetC, float heaterPct) { - if (autotuner_.isActive() || stepresp_.isActive()) { + bool startFanCharacterize(float maxCornerC, float avgTempC) { + if (autotuner_.isActive() || fanchars_.isActive()) { return false; } stopFanTest(); cutoffActive_ = false; heatPi_.reset(); mixPi_.reset(); - setTarget(targetC, false); - if (!stepresp_.start(targetC, heaterPct)) { + 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_; } + + 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; } - writeFan(0); + 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; } - void stopStepResponse() { - stepresp_.abort(); - writeFan(0); - } - - bool isStepResponseActive() const { return stepresp_.isActive(); } - - uint32_t stepResponseElapsedMs(uint32_t nowMs) const { return stepresp_.elapsedMs(nowMs); } - - const char *stepResponsePhaseName() const { return stepresp_.phaseName(); } - - uint8_t stepResponseStepIndex() const { return stepresp_.stepIndex(); } - - uint8_t stepResponseStepCount() const { return stepresp_.stepCount(); } - - float stepResponseHeaterPct() const { return stepresp_.heaterPct(); } - - void logStepResponseIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount, - float avgTempC, float minTempC, float maxTempC, float spreadC, - uint32_t nowMs) { - stepresp_.logIfDue(sensorTemps, sensorValid, sensorCount, avgTempC, minTempC, maxTempC, - spreadC, nowMs); - } - bool commitAutotuneIfDone() { if (autotuner_.phase() != PidAutotuner::Phase::Done) { return false; @@ -331,8 +355,8 @@ public: return "corner"; case HeaterBlock::Autotune: return "autotune"; - case HeaterBlock::StepResp: - return "stepresp"; + case HeaterBlock::FanChars: + return "fanchars"; default: return "none"; } @@ -352,8 +376,8 @@ public: SPREAD_EMA_ALPHA * cornerSpreadC_ + (1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC; - if (stepresp_.isActive()) { - updateStepResponse(avgTempC, maxTempC, nowMs); + if (fanchars_.isActive()) { + updateFanCharacterize(avgTempC, maxTempC, nowMs); return; } @@ -386,7 +410,7 @@ public: heatPi_.reset(); mixPi_.reset(); autotuner_.abort(); - stepresp_.abort(); + fanchars_.abort(); } void forceHeaterOff() { @@ -425,22 +449,17 @@ public: } private: - void updateStepResponse(float avgTempC, float maxTempC, uint32_t nowMs) { + void updateFanCharacterize(float avgTempC, float maxTempC, uint32_t nowMs) { float duty = 0.0f; uint8_t fan = 0; - stepresp_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan); + fanchars_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan); heaterDutyPercent_ = duty; heaterAllowancePercent_ = duty; - heaterBlock_ = duty > 0.0f ? HeaterBlock::StepResp : HeaterBlock::None; + heaterBlock_ = duty > 0.0f ? HeaterBlock::FanChars : HeaterBlock::None; applyHeaterBurst(nowMs); writeFan(fan); lastHeaterUpdateMs_ = nowMs; - - if (stepresp_.phase() == FanStepResponse::Phase::Done || - stepresp_.phase() == FanStepResponse::Phase::Failed) { - stepresp_.reset(); - } } void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) { @@ -504,13 +523,28 @@ private: } heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs); - const float mixInput = SPREAD_TARGET_C - cornerSpreadC_; - float fanOut = mixPi_.compute(mixInput, nowMs); - uint8_t fanPwm = static_cast(fanOut + 0.5f); - if (avgTempC < targetTempC_ - HEAT_UP_BAND_C && fanPwm > FAN_COLD_CAP_PWM) { - fanPwm = FAN_COLD_CAP_PWM; + 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; + } } - regulatingFanPwm_ = fanPwm; + regulatingFanPwm_ = fanWithMinStir(avgTempC, fanPwm); + } + + uint8_t fanWithMinStir(float avgTempC, uint8_t pwm) const { + if (avgTempC < IDLE_AUTO_FAN_OFF_TEMP_C) { + return 0; + } + if (pwm < stirFanPwm_) { + return stirFanPwm_; + } + return pwm; } static float clampPercent(float value) { @@ -633,13 +667,14 @@ private: PidController heatPi_; PidController mixPi_; PidAutotuner autotuner_; - FanStepResponse stepresp_; + FanCharacterize fanchars_; float targetTempC_; float heaterDutyPercent_; float heaterAllowancePercent_; float cornerSpreadC_; float lastMaxTempC_; uint8_t regulatingFanPwm_; + uint8_t stirFanPwm_; uint8_t fanPwm_; bool tuningLoaded_; bool fanIdleOverride_; diff --git a/pi-instructions.md b/pi-instructions.md index 9bc0e03..1b4bd6a 100644 --- a/pi-instructions.md +++ b/pi-instructions.md @@ -26,7 +26,7 @@ python3 scripts/capture_csv.py log # logs/dryer_YYYYMMDD_HHMMSS.csv ``` -**TUI keys:** `0` idle · `t` target · `p` presets · `f` fan on · `F` fan off · `l` CSV log · `a` autotune · `r` stepresp · `:` command · `q` quit +**TUI keys:** `0` idle · `t` target · `p` presets · `f` fan on · `F` fan off · `l` CSV log · `a` autotune · `c` fanchars · `:` command · `q` quit **Always-on logging** diff --git a/platformio.ini b/platformio.ini index b24b7e2..76406c9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -4,6 +4,7 @@ platform = atmelavr board = nanoatmega328 framework = arduino monitor_speed = 115200 +build_flags = -flto lib_deps = adafruit/Adafruit SHT31 Library@^2.2.2 adafruit/Adafruit BusIO@^1.16.1 diff --git a/scripts/dryer_tui.py b/scripts/dryer_tui.py index 3a35376..8a49f0c 100644 --- a/scripts/dryer_tui.py +++ b/scripts/dryer_tui.py @@ -7,6 +7,7 @@ import curses import re import sys import threading +import time from collections import deque from dataclasses import dataclass, field from datetime import datetime @@ -43,7 +44,7 @@ STATUS_RE = re.compile( r"htop=(?P\S+)\s+" r"hblk=(?P\S+)\s+" r"ssr=(?Pon|off)\s+" - r"fan=(?P\d+/255\(\d+%\)(?:\([^)]+\))?(?:\s+TEST)?)\s+" + r"fan=(?P\d+/255\([^)]+\)(?:\([^)]+\))?(?:\s+TEST)?)\s+" r"cutoff=(?P\S+)\s+" r"failsafe=(?P\S+)\s+" r"mode=(?P.+?)\s+sensors=\[(?P.*)\]" @@ -55,27 +56,73 @@ AUTOTUNE_MODE_RE = re.compile( r"autotune/(?P[\w-]+) (?P\d+)s (?P\d+/\d+)cyc pre>=(?P
\d+)C"
 )
 
-STEPRESP_MODE_RE = re.compile(
-    r"stepresp/(?P[\w-]+) (?P\d+)s step (?P\d+/\d+) heat=(?P\d+)%"
+FANCHARS_MODE_RE = re.compile(
+    r"fanchars/(?P[\w-]+) (?P\d+)s run (?P[\w]+)/(?P\d+) "
+    r"fan=(?P\d+) heat=(?P\d+)%"
 )
 
+FANCHARS_PHASE_HELP: dict[str, str] = {
+    "precool": "Cooling chamber to 40 C avg before first fan test (fan at 100% now)",
+    "cool": "Cooling to 40 C avg before next fan test (fan at 100% now)",
+    "heat": "Heating to 60 C max corner at test fan speed",
+    "hold": "Holding at max — measuring temperature spread",
+    "refine": "Refine run — midpoint PWM between two best spreads",
+}
 
-def format_mode_line(mode: str) -> str:
+
+def fan_pct_from_pwm(pwm: int) -> int:
+    return (pwm * 100) // 255
+
+
+def format_fan_display(fan_raw: str) -> str:
+    match = re.match(r"(\d+)/255\((\d+)%\)(.*)$", fan_raw.strip())
+    if not match:
+        return fan_raw
+    suffix = match.group(3).strip()
+    pct = match.group(2)
+    if suffix:
+        return f"{pct}% {suffix}"
+    return f"{pct}%"
+
+
+def format_mode_line(mode: str, avg: str = "") -> tuple[str, str]:
+    """Return (mode summary, activity detail) for the dashboard."""
     match = AUTOTUNE_MODE_RE.match(mode)
     if match:
         d = match.groupdict()
-        return (
-            f"Autotune {d['phase']}: {d['elapsed']}s elapsed, "
+        summary = (
+            f"Autotune {d['phase']}: {d['elapsed']}s, "
             f"{d['cycles']} cycles, preheat avg >= {d['pre']} C"
         )
-    match = STEPRESP_MODE_RE.match(mode)
+        return summary, "Relay tuning heat PI — heater bang-bang around setpoint"
+
+    match = FANCHARS_MODE_RE.match(mode)
     if match:
         d = match.groupdict()
-        return (
-            f"Step response {d['phase']}: {d['elapsed']}s, "
-            f"step {d['step']}, heater {d['heat']}%"
-        )
-    return f"Mode: {mode}"
+        phase = d["phase"]
+        test_pct = fan_pct_from_pwm(int(d["testfan"]))
+        run = d["run"]
+        runs = d["runs"]
+        if run == "pre":
+            run_text = f"preparing (before 1/{runs})"
+        elif run.startswith("n"):
+            run_text = f"before {run[1:]}/{runs} ({test_pct}% fan next)"
+        elif run == "refine":
+            run_text = f"refine ({test_pct}% fan)"
+        else:
+            run_text = f"{run}/{runs} ({test_pct}% fan)"
+        summary = f"Fan chars {phase}: {d['elapsed']}s — {run_text}"
+        detail = FANCHARS_PHASE_HELP.get(phase, "")
+        if phase in ("precool", "cool") and avg not in ("—", ""):
+            try:
+                detail += f" — avg {avg} C"
+            except ValueError:
+                pass
+        return summary, detail
+
+    if mode in ("manual", "regulating"):
+        return f"Mode: {mode}", "Normal temperature control"
+    return f"Mode: {mode}", ""
 
 
 @dataclass
@@ -96,8 +143,9 @@ class DryerState:
     cutoff_active: str = "no"
     failsafe: str = "no"
     mode: str = "—"
+    activity: str = ""
     sensors: list[tuple[str, str, str]] = field(default_factory=list)
-    messages: deque[str] = field(default_factory=lambda: deque(maxlen=12))
+    messages: deque[str] = field(default_factory=lambda: deque(maxlen=24))
     csv_logging: bool = False
     csv_path: Path | None = None
     port: str = ""
@@ -150,13 +198,18 @@ def apply_status(state: DryerState, data: dict) -> None:
     state.hblk = data["hblk"]
     state.ssr = data["ssr"]
     fan_raw = data["fan"]
-    state.fan = fan_raw
+    state.fan = format_fan_display(fan_raw)
     state.fan_note = ""
-    if fan_raw.endswith("(off)") or fan_raw.endswith("(cooldown)") or fan_raw.endswith("(cmd-off)") or " TEST" in fan_raw:
+    if "(off)" in fan_raw or "(cooldown)" in fan_raw or "(cmd-off)" 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-") :]
     state.cutoff_active = data["cutoff_active"]
     state.failsafe = data["failsafe"]
     state.mode = data["mode"]
+    summary, activity = format_mode_line(data["mode"], data["avg"])
+    state.mode = summary
+    state.activity = activity
     state.sensors = data["sensor_list"]
 
 
@@ -232,6 +285,10 @@ class SerialWorker:
             if not line:
                 continue
 
+            if line.startswith("fanchars:"):
+                self._note(line)
+                continue
+
             if line.startswith("csv,") or line.startswith("csv_hdr,"):
                 continue
 
@@ -347,8 +404,8 @@ def _preset_menu(stdscr, worker: SerialWorker) -> None:
 def _draw_dashboard(stdscr, state: DryerState) -> None:
     stdscr.erase()
     height, width = stdscr.getmaxyx()
-    if height < 19 or width < 60:
-        _safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x19).")
+    if height < 20 or width < 60:
+        _safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x20).")
         stdscr.refresh()
         return
 
@@ -361,14 +418,19 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
     _safe_addstr(stdscr, row, 36, f"Trip: {state.cutoff_active}", cutoff_attr)
 
     row += 1
-    mode_text = format_mode_line(state.mode)
+    mode_text = state.mode
     _safe_addstr(stdscr, row, 2, f"{mode_text[: max(0, width - 18)]}  FS: {state.failsafe}")
 
     row += 1
+    if state.activity:
+        _safe_addstr(stdscr, row, 2, state.activity[: max(0, width - 4)], curses.A_DIM)
+        row += 1
     _safe_addstr(stdscr, row, 2, f"Avg: {state.avg} C   Min: {state.min_temp} C   Max: {state.max_temp} C   Spread: {state.spread} C")
 
     row += 1
-    fan_text = state.fan if state.fan_note == "" else state.fan
+    fan_text = state.fan
+    if state.fan_note and "(fanchars-" not in state.fan_note:
+        fan_text = f"{state.fan} {state.fan_note}"
     _safe_addstr(
         stdscr,
         row,
@@ -420,7 +482,7 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
         stdscr,
         help_y,
         1,
-        "0 idle | t target | p presets | f fan | l log | a autotune | r stepresp | : cmd | q quit",
+        "0 idle | t target | p presets | f fan | l log | a autotune | c fanchars | : cmd | q quit",
         curses.A_DIM,
     )
     stdscr.refresh()
@@ -440,6 +502,8 @@ def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
     worker = SerialWorker(ser, state, lock)
     worker.start()
     worker.send("status")
+    time.sleep(0.4)
+    worker.send("status")
     if auto_log_on:
         worker.set_csv_logging(True, log_dir)
 
@@ -463,8 +527,9 @@ def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
                     cutoff_active=state.cutoff_active,
                     failsafe=state.failsafe,
                     mode=state.mode,
+                    activity=state.activity,
                     sensors=list(state.sensors),
-                    messages=deque(state.messages, maxlen=12),
+                    messages=deque(state.messages, maxlen=24),
                     csv_logging=state.csv_logging,
                     csv_path=state.csv_path,
                     port=state.port,
@@ -502,21 +567,9 @@ def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
                 if value is not None:
                     cmd = "autotune" if value == "" else f"autotune {value}"
                     worker.send(cmd)
-            elif key == ord("r"):
-                temp = _prompt(stdscr, "Stepresp temp °C (Enter = 45)")
-                if temp is None:
-                    continue
-                heater = _prompt(stdscr, "Heater % (Enter = 35)")
-                if heater is None:
-                    continue
-                if temp == "" and heater == "":
-                    worker.send("stepresp")
-                elif heater == "":
-                    worker.send(f"stepresp {temp}")
-                elif temp == "":
-                    worker.send(f"stepresp 45 {heater}")
-                else:
-                    worker.send(f"stepresp {temp} {heater}")
+            elif key == ord("c"):
+                worker.send("fanchars")
+                worker._note("Started fanchars — 30/100/60/80% then refine if needed")
             elif key == ord(":"):
                 value = _prompt(stdscr, "Command")
                 if value is not None and value != "":
diff --git a/src/main.cpp b/src/main.cpp
index 8112e5c..184eb54 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -142,6 +142,10 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
   Serial.print(F("%)"));
   if (thermal.isFanTestActive(millis())) {
     Serial.print(F(" TEST"));
+  } else if (thermal.isFanCharacterizeActive()) {
+    Serial.print(F("(fanchars-"));
+    Serial.print(thermal.fanCharacterizePhaseName());
+    Serial.print(F(")"));
   } else if (thermal.isIdle() && thermal.isFanOff()) {
     Serial.print(F("(off)"));
   } else if (thermal.isIdleCooling()) {
@@ -166,17 +170,29 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
     Serial.print(F("cyc pre>="));
     Serial.print(thermal.autotunePreheatTargetC(), 0);
     Serial.print(F("C"));
-  } else if (thermal.isStepResponseActive()) {
-    Serial.print(F("stepresp/"));
-    Serial.print(thermal.stepResponsePhaseName());
+  } else if (thermal.isFanCharacterizeActive()) {
+    Serial.print(F("fanchars/"));
+    Serial.print(thermal.fanCharacterizePhaseName());
     Serial.print(F(" "));
-    Serial.print(thermal.stepResponseElapsedMs(millis()) / 1000UL);
-    Serial.print(F("s step "));
-    Serial.print(thermal.stepResponseStepIndex() + 1);
+    Serial.print(thermal.fanCharacterizeElapsedMs(millis()) / 1000UL);
+    Serial.print(F("s run "));
+    const char *fcPhase = thermal.fanCharacterizePhaseName();
+    if (strcmp(fcPhase, "precool") == 0) {
+      Serial.print(F("pre"));
+    } else if (strcmp(fcPhase, "cool") == 0) {
+      Serial.print(F("n"));
+      Serial.print(thermal.fanCharacterizeProfileIndex() + 1);
+    } else if (thermal.isFanCharacterizeRefineRun()) {
+      Serial.print(F("refine"));
+    } else {
+      Serial.print(thermal.fanCharacterizeProfileIndex() + 1);
+    }
     Serial.print(F("/"));
-    Serial.print(thermal.stepResponseStepCount());
+    Serial.print(thermal.fanCharacterizeProfileCount());
+    Serial.print(F(" fan="));
+    Serial.print(thermal.fanCharacterizeFanPwm());
     Serial.print(F(" heat="));
-    Serial.print(thermal.stepResponseHeaterPct(), 0);
+    Serial.print(thermal.fanCharacterizeHeaterPct(), 0);
     Serial.print(F("%"));
   } else {
     Serial.print(thermal.regulatingModeName());
@@ -211,8 +227,8 @@ void printHelp() {
   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("  stepresp [C] [heater%] fan step response (default: 45C 35%)"));
-  Serial.println(F("  stepresp 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 save    write current PI to EEPROM"));
@@ -346,42 +362,41 @@ void processSerialLine(const char *line) {
     return;
   }
 
-  if (strncmp(line, "stepresp", 8) == 0) {
-    if (strcmp(line, "stepresp stop") == 0) {
-      thermal.stopStepResponse();
-      Serial.println(F("OK stepresp cancelled"));
+  if (strncmp(line, "fanchars", 8) == 0) {
+    if (strcmp(line, "fanchars stop") == 0) {
+      thermal.stopFanCharacterize();
+      Serial.println(F("OK fanchars cancelled"));
       return;
     }
-
-    float tempC = STEPRESP_DEFAULT_TEMP_C;
-    float heaterPct = STEPRESP_DEFAULT_HEATER_PCT;
-    if (line[8] == ' ') {
-      const char *args = line + 9;
-      tempC = atof(args);
-      const char *space = strchr(args, ' ');
-      if (space != nullptr) {
-        heaterPct = atof(space + 1);
+    if (strcmp(line, "fanchars save") == 0) {
+      if (!thermal.saveStirFanFromCharacterize()) {
+        Serial.println(F("ERR fanchars save — no completed run with winner"));
+        return;
       }
-    }
-
-    if (tempC < 25.0f || tempC > TARGET_MAX_C) {
-      Serial.println(F("ERR stepresp temperature must be 25-80 C"));
-      return;
-    }
-    if (heaterPct < STEPRESP_MIN_HEATER_PCT || heaterPct > STEPRESP_MAX_HEATER_PCT) {
-      Serial.print(F("ERR stepresp heater must be "));
-      Serial.print(STEPRESP_MIN_HEATER_PCT, 0);
-      Serial.print(F("-"));
-      Serial.print(STEPRESP_MAX_HEATER_PCT, 0);
-      Serial.println(F("%"));
+      Serial.println(F("OK stir fan saved"));
       return;
     }
 
-    if (!thermal.startStepResponse(tempC, heaterPct)) {
-      Serial.println(F("ERR stepresp already running or autotune active"));
+    float maxC = FANCHARS_MAX_CORNER_C;
+    if (line[8] == ' ') {
+      maxC = atof(line + 9);
+    }
+
+    if (maxC < 45.0f || maxC > EMERGENCY_MAX_TEMP_C - 5.0f) {
+      Serial.println(F("ERR fanchars max 45-65 C"));
       return;
     }
-    Serial.println(F("OK stepresp started — open-loop heater, fan steps, ~25-35 min"));
+
+    const float avgTemp = averageValidTemperature();
+    if (isnan(avgTemp)) {
+      Serial.println(F("ERR fanchars needs sensors"));
+      return;
+    }
+    if (!thermal.startFanCharacterize(maxC, avgTemp)) {
+      Serial.println(F("ERR fanchars busy"));
+      return;
+    }
+    Serial.println(F("OK fanchars started"));
     return;
   }
 
@@ -527,9 +542,9 @@ void loop() {
         sensorValid[i] = sensors[i].valid;
       }
       const float minTemp = minValidTemperature();
-      if (thermal.isStepResponseActive() && !isnan(minTemp)) {
-        thermal.logStepResponseIfDue(sensorTemps, sensorValid, SENSOR_COUNT, avgTemp, minTemp,
-                                     maxTemp, spread, now);
+      if (thermal.isFanCharacterizeActive() && !isnan(minTemp)) {
+        thermal.logFanCharacterizeIfDue(sensorTemps, sensorValid, SENSOR_COUNT, avgTemp, minTemp,
+                                        maxTemp, spread, now);
       }
     } else {
       thermal.enterFailSafe();