add fanchars

This commit is contained in:
2026-07-06 22:19:43 +02:00
parent 55cf03c015
commit 8182b9efd2
9 changed files with 296 additions and 439 deletions

View File

@@ -82,7 +82,22 @@ 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. 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 (~13 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 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 5055°C with ABS in the chamber while hot corners stay below that. 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: 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.
``` Send `help` over serial for all commands (`target`, `fanchars`, `mixpi`, `fan test`, `log on/off`, `status`, `pid`, etc.).
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.).
## Raspberry Pi control ## Raspberry Pi control

View File

@@ -36,7 +36,9 @@ static const float MIX_PI_KP = 40.0f;
static const float MIX_PI_KI = 2.0f; static const float MIX_PI_KI = 2.0f;
static const float SPREAD_TARGET_C = 0.5f; static const float SPREAD_TARGET_C = 0.5f;
static const float HEAT_UP_BAND_C = 8.0f; 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 // Legacy aliases for autotuner relay math only
static const float PID_KP = HEAT_PI_KP; 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; 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 uint8_t FAN_IDLE_PWM = 77;
static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f; static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f;
static const uint8_t FAN_MAX_PWM = 255; 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_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 characterize — fixed heater, sweep fan PWMs, pick lowest spread
static const float STEPRESP_DEFAULT_TEMP_C = 45.0f; static const float FANCHARS_MAX_CORNER_C = 60.0f;
static const float STEPRESP_DEFAULT_HEATER_PCT = 35.0f; static const float FANCHARS_COOL_AVG_C = 40.0f;
static const float STEPRESP_MIN_HEATER_PCT = 10.0f; static const float FANCHARS_PRECOOL_MARGIN_C = 2.0f;
static const float STEPRESP_MAX_HEATER_PCT = 70.0f; static const float FANCHARS_HEATER_PCT = 85.0f;
static const float STEPRESP_PREHEAT_BAND_C = 2.0f; // Coarse sweep order: 30%, 100%, 60%, 80% fan
static const uint32_t STEPRESP_PREHEAT_TIMEOUT_MS = 1200000UL; static const uint8_t FANCHARS_COARSE_PWM[] = {77, 255, 153, 204};
static const uint32_t STEPRESP_BASELINE_MS = 120000UL; static const uint8_t FANCHARS_COARSE_COUNT =
static const uint32_t STEPRESP_STEP_HOLD_MS = 300000UL; sizeof(FANCHARS_COARSE_PWM) / sizeof(FANCHARS_COARSE_PWM[0]);
static const uint32_t STEPRESP_LOG_INTERVAL_MS = 1000UL; static const uint8_t FANCHARS_LIMIT_LOW_PWM = 77;
static const uint8_t STEPRESP_FAN_STEPS[] = {0, 77, 140, 200, 255}; static const uint8_t FANCHARS_LIMIT_HIGH_PWM = 255;
static const uint8_t STEPRESP_FAN_STEP_COUNT = static const uint8_t FANCHARS_MAX_RESULTS = FANCHARS_COARSE_COUNT + 1;
sizeof(STEPRESP_FAN_STEPS) / sizeof(STEPRESP_FAN_STEPS[0]); 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 // Timing

View File

@@ -1,268 +0,0 @@
#pragma once
#include <Arduino.h>
#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_;
};

View File

@@ -6,12 +6,13 @@
#include "config.h" #include "config.h"
// After TuningData (15 bytes) + checksum (1 byte) at address 0 // 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; static const int SETTINGS_EEPROM_ADDR = 16;
struct SettingsData { struct SettingsData {
uint16_t magic = 0; uint16_t magic = 0;
float targetC = TARGET_TEMP_C; float targetC = TARGET_TEMP_C;
uint8_t stirFanPwm = 0; // 0 = use FAN_IDLE_PWM (~30%)
}; };
inline uint8_t settingsChecksum(const SettingsData &data) { inline uint8_t settingsChecksum(const SettingsData &data) {
@@ -39,8 +40,24 @@ inline void settingsSave(const SettingsData &data) {
inline void settingsSaveTarget(float targetC) { inline void settingsSaveTarget(float targetC) {
SettingsData data; SettingsData data;
if (settingsLoad(data)) {
data.targetC = targetC;
} else {
data.magic = SETTINGS_MAGIC; data.magic = SETTINGS_MAGIC;
data.targetC = targetC; 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); settingsSave(data);
} }

View File

@@ -3,7 +3,7 @@
#include <Arduino.h> #include <Arduino.h>
#include "config.h" #include "config.h"
#include "fan_step_response.h" #include "fan_characterize.h"
#include "pid_autotuner.h" #include "pid_autotuner.h"
#include "pid_controller.h" #include "pid_controller.h"
#include "settings_store.h" #include "settings_store.h"
@@ -11,19 +11,20 @@
class ThermalController { class ThermalController {
public: public:
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, StepResp }; enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, FanChars };
ThermalController() ThermalController()
: heatPi_(HEAT_PI_KP, HEAT_PI_KI, 0.0f, 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), mixPi_(MIX_PI_KP, MIX_PI_KI, 0.0f, 0.0f, 255.0f),
autotuner_(), autotuner_(),
stepresp_(), fanchars_(),
targetTempC_(TARGET_TEMP_C), targetTempC_(TARGET_TEMP_C),
heaterDutyPercent_(0.0f), heaterDutyPercent_(0.0f),
heaterAllowancePercent_(100.0f), heaterAllowancePercent_(100.0f),
cornerSpreadC_(0.0f), cornerSpreadC_(0.0f),
lastMaxTempC_(0.0f), lastMaxTempC_(0.0f),
regulatingFanPwm_(0), regulatingFanPwm_(0),
stirFanPwm_(FAN_IDLE_PWM),
fanPwm_(0), fanPwm_(0),
tuningLoaded_(false), tuningLoaded_(false),
fanIdleOverride_(false), fanIdleOverride_(false),
@@ -70,11 +71,15 @@ public:
} }
SettingsData settings; SettingsData settings;
if (settingsLoad(settings) && settings.targetC >= TARGET_MIN_C && if (settingsLoad(settings)) {
settings.targetC <= TARGET_MAX_C) { if (settings.stirFanPwm > 0) {
stirFanPwm_ = settings.stirFanPwm;
}
if (settings.targetC >= TARGET_MIN_C && settings.targetC <= TARGET_MAX_C) {
setTarget(settings.targetC, false); setTarget(settings.targetC, false);
} }
} }
}
void applyTuning(const TuningData &data) { void applyTuning(const TuningData &data) {
heatPi_.setTunings(data.heatKp, data.heatKi, 0.0f); heatPi_.setTunings(data.heatKp, data.heatKi, 0.0f);
@@ -140,7 +145,7 @@ public:
bool isTuningLoaded() const { return tuningLoaded_; } bool isTuningLoaded() const { return tuningLoaded_; }
bool startAutotune(float setpointC) { bool startAutotune(float setpointC) {
if (autotuner_.isActive() || stepresp_.isActive()) { if (autotuner_.isActive() || fanchars_.isActive()) {
return false; return false;
} }
cutoffActive_ = false; cutoffActive_ = false;
@@ -163,46 +168,65 @@ public:
float autotunePreheatTargetC() const { return autotuner_.preheatTargetC(); } float autotunePreheatTargetC() const { return autotuner_.preheatTargetC(); }
bool startStepResponse(float targetC, float heaterPct) { bool startFanCharacterize(float maxCornerC, float avgTempC) {
if (autotuner_.isActive() || stepresp_.isActive()) { if (autotuner_.isActive() || fanchars_.isActive()) {
return false; return false;
} }
stopFanTest(); stopFanTest();
cutoffActive_ = false; cutoffActive_ = false;
heatPi_.reset(); heatPi_.reset();
mixPi_.reset(); mixPi_.reset();
setTarget(targetC, false); setTarget(0.0f, false);
if (!stepresp_.start(targetC, heaterPct)) { return fanchars_.start(maxCornerC, avgTempC);
return false;
}
writeFan(0);
return true;
} }
void stopStepResponse() { void stopFanCharacterize() {
stepresp_.abort(); fanchars_.abort();
forceHeaterOff();
writeFan(0); writeFan(0);
} }
bool isStepResponseActive() const { return stepresp_.isActive(); } bool isFanCharacterizeActive() const { return fanchars_.isActive(); }
uint32_t stepResponseElapsedMs(uint32_t nowMs) const { return stepresp_.elapsedMs(nowMs); } uint32_t fanCharacterizeElapsedMs(uint32_t nowMs) const { return fanchars_.elapsedMs(nowMs); }
const char *stepResponsePhaseName() const { return stepresp_.phaseName(); } const char *fanCharacterizePhaseName() const { return fanchars_.phaseName(); }
uint8_t stepResponseStepIndex() const { return stepresp_.stepIndex(); } uint8_t fanCharacterizeProfileIndex() const { return fanchars_.profileIndex(); }
uint8_t stepResponseStepCount() const { return stepresp_.stepCount(); } uint8_t fanCharacterizeProfileCount() const { return fanchars_.profileCount(); }
float stepResponseHeaterPct() const { return stepresp_.heaterPct(); } uint8_t fanCharacterizeFanPwm() const { return fanchars_.currentFanPwm(); }
void logStepResponseIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount, 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, float avgTempC, float minTempC, float maxTempC, float spreadC,
uint32_t nowMs) { uint32_t nowMs) {
stepresp_.logIfDue(sensorTemps, sensorValid, sensorCount, avgTempC, minTempC, maxTempC, fanchars_.logIfDue(sensorTemps, sensorValid, sensorCount, avgTempC, minTempC, maxTempC,
spreadC, nowMs); spreadC, nowMs);
} }
bool saveStirFanFromCharacterize() {
if (fanchars_.phase() != FanCharacterize::Phase::Done) {
return false;
}
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;
}
bool commitAutotuneIfDone() { bool commitAutotuneIfDone() {
if (autotuner_.phase() != PidAutotuner::Phase::Done) { if (autotuner_.phase() != PidAutotuner::Phase::Done) {
return false; return false;
@@ -331,8 +355,8 @@ public:
return "corner"; return "corner";
case HeaterBlock::Autotune: case HeaterBlock::Autotune:
return "autotune"; return "autotune";
case HeaterBlock::StepResp: case HeaterBlock::FanChars:
return "stepresp"; return "fanchars";
default: default:
return "none"; return "none";
} }
@@ -352,8 +376,8 @@ public:
SPREAD_EMA_ALPHA * cornerSpreadC_ + SPREAD_EMA_ALPHA * cornerSpreadC_ +
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC; (1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC;
if (stepresp_.isActive()) { if (fanchars_.isActive()) {
updateStepResponse(avgTempC, maxTempC, nowMs); updateFanCharacterize(avgTempC, maxTempC, nowMs);
return; return;
} }
@@ -386,7 +410,7 @@ public:
heatPi_.reset(); heatPi_.reset();
mixPi_.reset(); mixPi_.reset();
autotuner_.abort(); autotuner_.abort();
stepresp_.abort(); fanchars_.abort();
} }
void forceHeaterOff() { void forceHeaterOff() {
@@ -425,22 +449,17 @@ public:
} }
private: private:
void updateStepResponse(float avgTempC, float maxTempC, uint32_t nowMs) { void updateFanCharacterize(float avgTempC, float maxTempC, uint32_t nowMs) {
float duty = 0.0f; float duty = 0.0f;
uint8_t fan = 0; uint8_t fan = 0;
stepresp_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan); fanchars_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
heaterDutyPercent_ = duty; heaterDutyPercent_ = duty;
heaterAllowancePercent_ = duty; heaterAllowancePercent_ = duty;
heaterBlock_ = duty > 0.0f ? HeaterBlock::StepResp : HeaterBlock::None; heaterBlock_ = duty > 0.0f ? HeaterBlock::FanChars : HeaterBlock::None;
applyHeaterBurst(nowMs); applyHeaterBurst(nowMs);
writeFan(fan); writeFan(fan);
lastHeaterUpdateMs_ = nowMs; lastHeaterUpdateMs_ = nowMs;
if (stepresp_.phase() == FanStepResponse::Phase::Done ||
stepresp_.phase() == FanStepResponse::Phase::Failed) {
stepresp_.reset();
}
} }
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) { void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
@@ -504,13 +523,28 @@ private:
} }
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs); heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
uint8_t fanPwm = stirFanPwm_;
if (avgTempC < targetTempC_) {
mixPi_.reset();
} else {
const float mixInput = SPREAD_TARGET_C - cornerSpreadC_; const float mixInput = SPREAD_TARGET_C - cornerSpreadC_;
float fanOut = mixPi_.compute(mixInput, nowMs); float fanOut = mixPi_.compute(mixInput, nowMs);
uint8_t fanPwm = static_cast<uint8_t>(fanOut + 0.5f); fanPwm = static_cast<uint8_t>(fanOut + 0.5f);
if (avgTempC < targetTempC_ - HEAT_UP_BAND_C && fanPwm > FAN_COLD_CAP_PWM) { if (fanPwm > FAN_MIX_MAX_PWM) {
fanPwm = FAN_COLD_CAP_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) { static float clampPercent(float value) {
@@ -633,13 +667,14 @@ private:
PidController heatPi_; PidController heatPi_;
PidController mixPi_; PidController mixPi_;
PidAutotuner autotuner_; PidAutotuner autotuner_;
FanStepResponse stepresp_; FanCharacterize fanchars_;
float targetTempC_; float targetTempC_;
float heaterDutyPercent_; float heaterDutyPercent_;
float heaterAllowancePercent_; float heaterAllowancePercent_;
float cornerSpreadC_; float cornerSpreadC_;
float lastMaxTempC_; float lastMaxTempC_;
uint8_t regulatingFanPwm_; uint8_t regulatingFanPwm_;
uint8_t stirFanPwm_;
uint8_t fanPwm_; uint8_t fanPwm_;
bool tuningLoaded_; bool tuningLoaded_;
bool fanIdleOverride_; bool fanIdleOverride_;

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` 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** **Always-on logging**

View File

@@ -4,6 +4,7 @@ platform = atmelavr
board = nanoatmega328 board = nanoatmega328
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
build_flags = -flto
lib_deps = lib_deps =
adafruit/Adafruit SHT31 Library@^2.2.2 adafruit/Adafruit SHT31 Library@^2.2.2
adafruit/Adafruit BusIO@^1.16.1 adafruit/Adafruit BusIO@^1.16.1

View File

@@ -7,6 +7,7 @@ import curses
import re import re
import sys import sys
import threading import threading
import time
from collections import deque from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -43,7 +44,7 @@ STATUS_RE = re.compile(
r"htop=(?P<htop>\S+)\s+" r"htop=(?P<htop>\S+)\s+"
r"hblk=(?P<hblk>\S+)\s+" r"hblk=(?P<hblk>\S+)\s+"
r"ssr=(?P<ssr>on|off)\s+" r"ssr=(?P<ssr>on|off)\s+"
r"fan=(?P<fan>\d+/255\(\d+%\)(?:\([^)]+\))?(?:\s+TEST)?)\s+" r"fan=(?P<fan>\d+/255\([^)]+\)(?:\([^)]+\))?(?:\s+TEST)?)\s+"
r"cutoff=(?P<cutoff_active>\S+)\s+" r"cutoff=(?P<cutoff_active>\S+)\s+"
r"failsafe=(?P<failsafe>\S+)\s+" r"failsafe=(?P<failsafe>\S+)\s+"
r"mode=(?P<mode>.+?)\s+sensors=\[(?P<sensors>.*)\]" r"mode=(?P<mode>.+?)\s+sensors=\[(?P<sensors>.*)\]"
@@ -55,27 +56,73 @@ AUTOTUNE_MODE_RE = re.compile(
r"autotune/(?P<phase>[\w-]+) (?P<elapsed>\d+)s (?P<cycles>\d+/\d+)cyc pre>=(?P<pre>\d+)C" r"autotune/(?P<phase>[\w-]+) (?P<elapsed>\d+)s (?P<cycles>\d+/\d+)cyc pre>=(?P<pre>\d+)C"
) )
STEPRESP_MODE_RE = re.compile( FANCHARS_MODE_RE = re.compile(
r"stepresp/(?P<phase>[\w-]+) (?P<elapsed>\d+)s step (?P<step>\d+/\d+) heat=(?P<heat>\d+)%" r"fanchars/(?P<phase>[\w-]+) (?P<elapsed>\d+)s run (?P<run>[\w]+)/(?P<runs>\d+) "
r"fan=(?P<testfan>\d+) heat=(?P<heat>\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) match = AUTOTUNE_MODE_RE.match(mode)
if match: if match:
d = match.groupdict() d = match.groupdict()
return ( summary = (
f"Autotune {d['phase']}: {d['elapsed']}s elapsed, " f"Autotune {d['phase']}: {d['elapsed']}s, "
f"{d['cycles']} cycles, preheat avg >= {d['pre']} C" 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: if match:
d = match.groupdict() d = match.groupdict()
return ( phase = d["phase"]
f"Step response {d['phase']}: {d['elapsed']}s, " test_pct = fan_pct_from_pwm(int(d["testfan"]))
f"step {d['step']}, heater {d['heat']}%" run = d["run"]
) runs = d["runs"]
return f"Mode: {mode}" 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 @dataclass
@@ -96,8 +143,9 @@ class DryerState:
cutoff_active: str = "no" cutoff_active: str = "no"
failsafe: str = "no" failsafe: str = "no"
mode: str = "" mode: str = ""
activity: str = ""
sensors: list[tuple[str, str, str]] = field(default_factory=list) 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_logging: bool = False
csv_path: Path | None = None csv_path: Path | None = None
port: str = "" port: str = ""
@@ -150,13 +198,18 @@ def apply_status(state: DryerState, data: dict) -> None:
state.hblk = data["hblk"] state.hblk = data["hblk"]
state.ssr = data["ssr"] state.ssr = data["ssr"]
fan_raw = data["fan"] fan_raw = data["fan"]
state.fan = fan_raw state.fan = format_fan_display(fan_raw)
state.fan_note = "" 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 "" 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.cutoff_active = data["cutoff_active"]
state.failsafe = data["failsafe"] state.failsafe = data["failsafe"]
state.mode = data["mode"] state.mode = data["mode"]
summary, activity = format_mode_line(data["mode"], data["avg"])
state.mode = summary
state.activity = activity
state.sensors = data["sensor_list"] state.sensors = data["sensor_list"]
@@ -232,6 +285,10 @@ class SerialWorker:
if not line: if not line:
continue continue
if line.startswith("fanchars:"):
self._note(line)
continue
if line.startswith("csv,") or line.startswith("csv_hdr,"): if line.startswith("csv,") or line.startswith("csv_hdr,"):
continue continue
@@ -347,8 +404,8 @@ def _preset_menu(stdscr, worker: SerialWorker) -> None:
def _draw_dashboard(stdscr, state: DryerState) -> None: def _draw_dashboard(stdscr, state: DryerState) -> None:
stdscr.erase() stdscr.erase()
height, width = stdscr.getmaxyx() height, width = stdscr.getmaxyx()
if height < 19 or width < 60: if height < 20 or width < 60:
_safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x19).") _safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x20).")
stdscr.refresh() stdscr.refresh()
return return
@@ -361,14 +418,19 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
_safe_addstr(stdscr, row, 36, f"Trip: {state.cutoff_active}", cutoff_attr) _safe_addstr(stdscr, row, 36, f"Trip: {state.cutoff_active}", cutoff_attr)
row += 1 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}") _safe_addstr(stdscr, row, 2, f"{mode_text[: max(0, width - 18)]} FS: {state.failsafe}")
row += 1 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") _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 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( _safe_addstr(
stdscr, stdscr,
row, row,
@@ -420,7 +482,7 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
stdscr, stdscr,
help_y, help_y,
1, 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, curses.A_DIM,
) )
stdscr.refresh() 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 = SerialWorker(ser, state, lock)
worker.start() worker.start()
worker.send("status") worker.send("status")
time.sleep(0.4)
worker.send("status")
if auto_log_on: if auto_log_on:
worker.set_csv_logging(True, log_dir) 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, cutoff_active=state.cutoff_active,
failsafe=state.failsafe, failsafe=state.failsafe,
mode=state.mode, mode=state.mode,
activity=state.activity,
sensors=list(state.sensors), sensors=list(state.sensors),
messages=deque(state.messages, maxlen=12), messages=deque(state.messages, maxlen=24),
csv_logging=state.csv_logging, csv_logging=state.csv_logging,
csv_path=state.csv_path, csv_path=state.csv_path,
port=state.port, 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: if value is not None:
cmd = "autotune" if value == "" else f"autotune {value}" cmd = "autotune" if value == "" else f"autotune {value}"
worker.send(cmd) worker.send(cmd)
elif key == ord("r"): elif key == ord("c"):
temp = _prompt(stdscr, "Stepresp temp °C (Enter = 45)") worker.send("fanchars")
if temp is None: worker._note("Started fanchars — 30/100/60/80% then refine if needed")
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(":"): elif key == ord(":"):
value = _prompt(stdscr, "Command") value = _prompt(stdscr, "Command")
if value is not None and value != "": if value is not None and value != "":

View File

@@ -142,6 +142,10 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
Serial.print(F("%)")); Serial.print(F("%)"));
if (thermal.isFanTestActive(millis())) { if (thermal.isFanTestActive(millis())) {
Serial.print(F(" TEST")); 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()) { } else if (thermal.isIdle() && thermal.isFanOff()) {
Serial.print(F("(off)")); Serial.print(F("(off)"));
} else if (thermal.isIdleCooling()) { } else if (thermal.isIdleCooling()) {
@@ -166,17 +170,29 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
Serial.print(F("cyc pre>=")); Serial.print(F("cyc pre>="));
Serial.print(thermal.autotunePreheatTargetC(), 0); Serial.print(thermal.autotunePreheatTargetC(), 0);
Serial.print(F("C")); Serial.print(F("C"));
} else if (thermal.isStepResponseActive()) { } else if (thermal.isFanCharacterizeActive()) {
Serial.print(F("stepresp/")); Serial.print(F("fanchars/"));
Serial.print(thermal.stepResponsePhaseName()); Serial.print(thermal.fanCharacterizePhaseName());
Serial.print(F(" ")); Serial.print(F(" "));
Serial.print(thermal.stepResponseElapsedMs(millis()) / 1000UL); Serial.print(thermal.fanCharacterizeElapsedMs(millis()) / 1000UL);
Serial.print(F("s step ")); Serial.print(F("s run "));
Serial.print(thermal.stepResponseStepIndex() + 1); 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(F("/"));
Serial.print(thermal.stepResponseStepCount()); Serial.print(thermal.fanCharacterizeProfileCount());
Serial.print(F(" fan="));
Serial.print(thermal.fanCharacterizeFanPwm());
Serial.print(F(" heat=")); Serial.print(F(" heat="));
Serial.print(thermal.stepResponseHeaterPct(), 0); Serial.print(thermal.fanCharacterizeHeaterPct(), 0);
Serial.print(F("%")); Serial.print(F("%"));
} else { } else {
Serial.print(thermal.regulatingModeName()); 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(" 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 [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(" fanchars learn stir fan (30/100/60/80%% + refine)"));
Serial.println(F(" stepresp stop")); Serial.println(F(" fanchars stop | fanchars save"));
Serial.println(F(" pid show heat + mix PI gains")); Serial.println(F(" pid show heat + mix PI gains"));
Serial.println(F(" pid default reset all PI to factory")); Serial.println(F(" pid default reset all PI to factory"));
Serial.println(F(" pid save write current PI to EEPROM")); Serial.println(F(" pid save write current PI to EEPROM"));
@@ -346,42 +362,41 @@ void processSerialLine(const char *line) {
return; return;
} }
if (strncmp(line, "stepresp", 8) == 0) { if (strncmp(line, "fanchars", 8) == 0) {
if (strcmp(line, "stepresp stop") == 0) { if (strcmp(line, "fanchars stop") == 0) {
thermal.stopStepResponse(); thermal.stopFanCharacterize();
Serial.println(F("OK stepresp cancelled")); Serial.println(F("OK fanchars cancelled"));
return;
}
if (strcmp(line, "fanchars save") == 0) {
if (!thermal.saveStirFanFromCharacterize()) {
Serial.println(F("ERR fanchars save — no completed run with winner"));
return;
}
Serial.println(F("OK stir fan saved"));
return; return;
} }
float tempC = STEPRESP_DEFAULT_TEMP_C; float maxC = FANCHARS_MAX_CORNER_C;
float heaterPct = STEPRESP_DEFAULT_HEATER_PCT;
if (line[8] == ' ') { if (line[8] == ' ') {
const char *args = line + 9; maxC = atof(line + 9);
tempC = atof(args);
const char *space = strchr(args, ' ');
if (space != nullptr) {
heaterPct = atof(space + 1);
}
} }
if (tempC < 25.0f || tempC > TARGET_MAX_C) { if (maxC < 45.0f || maxC > EMERGENCY_MAX_TEMP_C - 5.0f) {
Serial.println(F("ERR stepresp temperature must be 25-80 C")); Serial.println(F("ERR fanchars max 45-65 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("%"));
return; return;
} }
if (!thermal.startStepResponse(tempC, heaterPct)) { const float avgTemp = averageValidTemperature();
Serial.println(F("ERR stepresp already running or autotune active")); if (isnan(avgTemp)) {
Serial.println(F("ERR fanchars needs sensors"));
return; return;
} }
Serial.println(F("OK stepresp started — open-loop heater, fan steps, ~25-35 min")); if (!thermal.startFanCharacterize(maxC, avgTemp)) {
Serial.println(F("ERR fanchars busy"));
return;
}
Serial.println(F("OK fanchars started"));
return; return;
} }
@@ -527,8 +542,8 @@ void loop() {
sensorValid[i] = sensors[i].valid; sensorValid[i] = sensors[i].valid;
} }
const float minTemp = minValidTemperature(); const float minTemp = minValidTemperature();
if (thermal.isStepResponseActive() && !isnan(minTemp)) { if (thermal.isFanCharacterizeActive() && !isnan(minTemp)) {
thermal.logStepResponseIfDue(sensorTemps, sensorValid, SENSOR_COUNT, avgTemp, minTemp, thermal.logFanCharacterizeIfDue(sensorTemps, sensorValid, SENSOR_COUNT, avgTemp, minTemp,
maxTemp, spread, now); maxTemp, spread, now);
} }
} else { } else {