Compare commits

..

15 Commits

Author SHA1 Message Date
d80fbdfc57 remove logs 2026-08-07 17:36:37 +02:00
31a6764484 freitag 2026-08-07 17:36:15 +02:00
d1385baa45 ouch finger 2026-07-08 22:17:23 +02:00
2fe53d5a8a remove fan-controls 2026-07-08 16:11:57 +02:00
8182b9efd2 add fanchars 2026-07-06 22:19:43 +02:00
55cf03c015 pi without the d 2026-07-06 20:24:14 +02:00
28ae97fa45 safety commit 2026-07-06 20:16:51 +02:00
02e51717e8 fix tui 2026-07-05 20:15:29 +02:00
960831529e fix ignore 2026-07-05 19:15:12 +02:00
f6a9f62029 remove pycache 2026-07-05 19:14:51 +02:00
e1c24d0552 fix py 2026-07-05 19:14:26 +02:00
134004fce9 fix py 2026-07-05 19:12:54 +02:00
f614f75153 Merge branch 'main' of tesserakt.pro:alex/arduino-filament-dryer 2026-07-05 19:02:18 +02:00
3ab1b3e8fc Merge branch 'main' of tesserakt.pro:alex/arduino-filament-dryer 2026-07-05 16:10:04 +02:00
0e4a30adf7 refine logging 2026-07-05 16:09:42 +02:00
20 changed files with 2050 additions and 421 deletions

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ compile_commands.json
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
scripts/__pycache__/

View File

@@ -82,20 +82,28 @@ 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. Run PID autotune once per physical unit (values are stored in EEPROM):
3. Find **stir fan** speed (optional — default is **PWM 178**, ~70%):
```
target 0
autotune 45
fanchars
```
4. Start drying:
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):
```
target 55
target 0
pid default
autotune 50
```
Send `help` over serial for all commands (`target`, `fan on/off`, `log on/off`, `status`, `pid`, etc.).
Cutoff follows target (`target + 12°C`, max 95°C) — e.g. ABS at 55°C trips at 67°C corner, nylon at 80°C at 92°C.
5. Dry at your target — fan runs at stir PWM (default **178**) whenever target > 0, including heat-up. Override with `fan <pwm>`; `fan auto` returns to default. Fan auto-off below 40°C applies only in idle (`target 0`).
Send `help` over serial for all commands (`target`, `fanchars`, `fan`, `log on/off`, `status`, `pid`, etc.).
## Raspberry Pi control

View File

@@ -12,6 +12,10 @@ static const uint8_t SENSOR_COUNT = sizeof(SENSOR_CHANNELS) / sizeof(SENSOR_CHAN
// SHT31 I2C address (ADDR pin low → 0x44, high → 0x45)
static const uint8_t SHT31_ADDRESS = 0x44;
// Bus timeout (Wire.setWireTimeout) — bounds a stuck I2C transaction so a
// glitch resets the TWI hardware instead of hanging the whole sketch.
static const uint32_t I2C_TIMEOUT_US = 25000UL;
// ---------------------------------------------------------------------------
// Outputs — D5 has hardware PWM; heater on A2 uses burst control (SSR-friendly)
// ---------------------------------------------------------------------------
@@ -21,60 +25,81 @@ static const uint8_t HEATER_PIN = A2; // heater via solid-state relay
// ---------------------------------------------------------------------------
// Temperature control
// ---------------------------------------------------------------------------
static const float TARGET_TEMP_C = 0.0f; // power-on default: idle (heater off)
static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f; // autotune when no temp given and idle
static const float TARGET_MIN_C = 0.0f; // 0 = idle (heater off, fan at idle speed)
static const float TARGET_TEMP_C = 0.0f;
static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f;
static const float TARGET_MIN_C = 0.0f;
static const float TARGET_MAX_C = 80.0f;
static const float OVERTEMP_FRACTION = 0.05f; // hard cutoff at target * 1.05
// Hard ceiling (sensor / enclosure limit). Cutoff when regulating = target + CUTOFF_ABOVE_TARGET_C.
static const float EMERGENCY_ABSOLUTE_MAX_C = 95.0f;
static const float CUTOFF_ABOVE_TARGET_C = 12.0f;
static const float CUTOFF_RECOVERY_BAND_C = 5.0f;
static const float CORNER_STOP_MARGIN_C = 5.0f;
// PID on chamber average
static const float PID_KP = 4.0f;
static const float PID_KI = 0.05f;
static const float PID_KD = 6.0f;
inline float emergencyCutoffForTarget(float targetC) {
if (targetC <= 0.0f) {
return EMERGENCY_ABSOLUTE_MAX_C;
}
float cutoff = targetC + CUTOFF_ABOVE_TARGET_C;
if (cutoff > EMERGENCY_ABSOLUTE_MAX_C) {
cutoff = EMERGENCY_ABSOLUTE_MAX_C;
}
return cutoff;
}
// Tiered heater cap — more power when cold, gentle near setpoint
static const float HEATER_MAX_DUTY_COLD = 65.0f; // avg >10 °C below target
static const float HEATER_MAX_DUTY_MID = 50.0f; // avg 310 °C below target
static const float HEATER_MAX_DUTY_NEAR = 42.0f; // avg <3 °C below target
static const float HEATER_COLD_BELOW_C = 10.0f;
static const float HEATER_WARM_BELOW_C = 3.0f;
// Heat PI on average temp (no D term)
static const float HEAT_PI_KP = 4.0f;
static const float HEAT_PI_KI = 0.05f;
// Ramp-up limit (% per second) — still caps sudden jumps
static const float HEATER_SLEW_UP_PER_S = 18.0f;
// Fixed circulation fan (~70%, fanchars winner); override with "fan <pwm>" when regulating
static const uint8_t FAN_STIR_PWM = 178;
// Hot-corner limiter: taper heater as max corner approaches stop temperature
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
// Legacy aliases for autotuner relay math only
static const float PID_KP = HEAT_PI_KP;
static const float PID_KI = HEAT_PI_KI;
static const float PID_KD = 0.0f;
static const float GOOD_SPREAD_C = 5.0f;
static const float BALANCED_MAX_ABOVE_TARGET_C = 2.0f;
// Corner taper when avg is near target — keeps hottest sensor below emergency
static const float CORNER_LIMIT_BAND_C = 2.0f;
static const float MAX_TEMP_HEADROOM_C = 15.0f;
static const uint16_t HEATER_CYCLE_MS = 3000;
// Fan PWM (0255)
static const uint8_t FAN_IDLE_PWM = 77; // ~30 % — optional override via "fan on"
static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f; // idle: fans off when max corner below this
static const uint8_t FAN_MIX_MIN_PWM = 70; // ~27 % — light mixing when spread rises
static const uint8_t FAN_HEAT_MIN_PWM = 100; // ~39 % — floor while heating
static const uint8_t FAN_HEAT_MAX_PWM = 140; // ~55 % — cap during heat-up
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
// Fan PWM — stir speed when target > 0; off below 40°C only when idle (target 0)
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;
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;
// 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_PREHEAT_BAND_C = 5.0f;
static const float AUTOTUNE_PREHEAT_DUTY = 80.0f;
static const float AUTOTUNE_ABORT_ABOVE_C = 15.0f;
static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 6;
static const uint32_t AUTOTUNE_TIMEOUT_MS = 1800000UL;
static const float AUTOTUNE_PREHEAT_BAND_C = 3.0f;
static const float AUTOTUNE_PREHEAT_DUTY = 100.0f;
static const uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 0;
static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 5;
static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL;
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 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 = 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 =
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
@@ -82,4 +107,4 @@ static const uint32_t AUTOTUNE_TIMEOUT_MS = 1800000UL;
static const uint32_t SENSOR_READ_INTERVAL_MS = 1000;
static const uint32_t CONTROL_INTERVAL_MS = 500;
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

@@ -0,0 +1,75 @@
#pragma once
#include <Arduino.h>
#include "config.h"
class FanCharacterize {
public:
enum class Phase : uint8_t { Idle, Precool, Heat, Hold, Cooldown, Done, Failed };
FanCharacterize();
Phase phase() const { return phase_; }
bool isActive() const;
uint32_t elapsedMs(uint32_t nowMs) const;
uint8_t profileIndex() const { return profileIndex_; }
uint8_t profileCount() const { return FANCHARS_COARSE_COUNT + 1; }
uint8_t currentFanPwm() const;
float heaterPct() const { return heaterPct_; }
uint8_t winnerFanPwm() const { return winnerFanPwm_; }
bool isRefineRun() const { return refineRun_ && profileIndex_ >= FANCHARS_COARSE_COUNT; }
const char *phaseName() const;
bool start(float maxCornerC, float avgTempC);
void abort();
void reset();
bool update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut,
uint8_t &fanPwmOut);
void logIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
float avgTempC, float minTempC, float maxTempC, float spreadC, uint32_t nowMs);
private:
struct ProfileResult {
uint8_t fanPwm;
float meanSpreadC;
};
void resetProfileStats();
void beginProfileHeat(uint32_t nowMs);
void enterHold(uint32_t nowMs);
void finishProfile(uint32_t nowMs);
void skipProfile(uint32_t nowMs, float maxTempC);
void planRefine(uint32_t nowMs);
void finishAll(uint32_t nowMs);
void fail(const __FlashStringHelper *reason);
Phase phase_;
float maxCornerC_;
float coolAvgC_;
float heaterPct_;
uint8_t profileIndex_;
uint8_t refineFanPwm_;
bool refineRun_;
uint32_t sessionStartMs_;
uint32_t phaseStartMs_;
uint32_t lastLogMs_;
float spreadSum_;
uint16_t spreadSamples_;
uint8_t resultCount_;
uint8_t winnerFanPwm_;
ProfileResult results_[FANCHARS_MAX_RESULTS];
};

View File

@@ -24,16 +24,41 @@ public:
spreadSamples_(0),
cycleCount_(0),
aboveSetpoint_(false),
useMaxSensorPv_(false),
sessionStartMs_(0),
phaseStartMs_(0),
resultKp_(PID_KP),
resultKi_(PID_KI),
resultKd_(PID_KD),
resultFanMixMax_(FAN_MIX_MAX_PWM) {}
resultKp_(HEAT_PI_KP),
resultKi_(HEAT_PI_KI) {}
Phase phase() const { return phase_; }
bool isActive() const { return phase_ == Phase::Preheat || phase_ == Phase::Relay; }
uint32_t elapsedMs(uint32_t nowMs) const {
if (sessionStartMs_ == 0) {
return 0;
}
return nowMs - sessionStartMs_;
}
uint8_t cycleCount() const { return cycleCount_; }
uint8_t periodCount() const { return periodCount_; }
float preheatTargetC() const { return setpointC_ - AUTOTUNE_PREHEAT_BAND_C; }
bool usesMaxSensor() const { return useMaxSensorPv_; }
const char *phaseName() const {
switch (phase_) {
case Phase::Preheat:
return "preheat";
case Phase::Relay:
return useMaxSensorPv_ ? "relay-max" : "relay-avg";
default:
return "";
}
}
bool start(float setpointC) {
if (setpointC < 25.0f || setpointC > TARGET_MAX_C) {
return false;
@@ -44,10 +69,13 @@ public:
relayLow_ = setpointC - AUTOTUNE_HYSTERESIS_C;
resetMeasurements();
phase_ = Phase::Preheat;
phaseStartMs_ = millis();
sessionStartMs_ = millis();
phaseStartMs_ = sessionStartMs_;
Serial.print(F("autotune: preheat to "));
Serial.print(preheatTargetC(), 1);
Serial.print(F("-"));
Serial.print(setpointC_, 1);
Serial.println(F("C"));
Serial.println(F("C avg (heat PI only)"));
return true;
}
@@ -56,66 +84,91 @@ public:
Serial.println(F("autotune: cancelled"));
}
phase_ = Phase::Idle;
sessionStartMs_ = 0;
}
void reset() { phase_ = Phase::Idle; }
void reset() {
phase_ = Phase::Idle;
sessionStartMs_ = 0;
}
float setpoint() const { return setpointC_; }
float resultKp() const { return resultKp_; }
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,
uint8_t &fanPwmOut) {
heaterDutyOut = 0.0f;
fanPwmOut = FAN_HEAT_MIN_PWM;
fanPwmOut = AUTOTUNE_PREHEAT_FAN_PWM;
if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) {
return phase_;
}
if (maxTempC >= setpointC_ + AUTOTUNE_ABORT_ABOVE_C) {
fail(F("autotune: abort — temperature too high"));
if (maxTempC >= emergencyCutoffForTarget(setpointC_)) {
fail(F("autotune: abort — max sensor at emergency limit"));
return phase_;
}
if (nowMs - phaseStartMs_ > AUTOTUNE_TIMEOUT_MS) {
fail(F("autotune: abort — timeout"));
return phase_;
}
fanPwmOut = AUTOTUNE_PREHEAT_FAN_PWM;
if (phase_ == Phase::Preheat) {
if (avgTempC >= setpointC_ - AUTOTUNE_PREHEAT_BAND_C) {
enterRelay(avgTempC, nowMs);
if (nowMs - phaseStartMs_ > AUTOTUNE_PREHEAT_TIMEOUT_MS) {
Serial.print(F("autotune: preheat failed — avg "));
Serial.print(avgTempC, 1);
Serial.print(F("C after "));
Serial.print((nowMs - sessionStartMs_) / 60000UL);
Serial.println(F(" min"));
fail(F("autotune: abort — preheat timeout"));
return phase_;
}
if (avgTempC >= preheatTargetC() || maxTempC >= setpointC_ - 2.0f) {
enterRelay(avgTempC, maxTempC, spreadC, nowMs);
} else {
heaterDutyOut = AUTOTUNE_PREHEAT_DUTY;
}
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) {
Serial.print(F("autotune: relay stalled — avg "));
Serial.print(avgTempC, 1);
Serial.print(F("C max "));
Serial.print(maxTempC, 1);
Serial.println(F("C (spread too large for avg to cross setpoint?)"));
fail(F("autotune: abort — no oscillation"));
return phase_;
}
const float pv = useMaxSensorPv_ ? maxTempC : avgTempC;
spreadSum_ += spreadC;
++spreadSamples_;
if (avgTempC > peakSinceCross_) {
peakSinceCross_ = avgTempC;
if (pv > peakSinceCross_) {
peakSinceCross_ = pv;
}
if (avgTempC < valleySinceCross_) {
valleySinceCross_ = avgTempC;
if (pv < valleySinceCross_) {
valleySinceCross_ = pv;
}
bool heatOn = false;
if (avgTempC <= relayLow_) {
if (pv <= relayLow_) {
heatOn = true;
} else if (avgTempC >= relayHigh_) {
} else if (pv >= relayHigh_) {
heatOn = false;
} else {
heatOn = !aboveSetpoint_;
}
heaterDutyOut = heatOn ? 100.0f : 0.0f;
const bool nowAbove = avgTempC >= setpointC_;
const bool nowAbove = pv >= setpointC_;
if (nowAbove != aboveSetpoint_) {
onSetpointCrossing(nowMs);
aboveSetpoint_ = nowAbove;
@@ -125,14 +178,20 @@ public:
}
private:
void enterRelay(float avgTempC, uint32_t nowMs) {
void enterRelay(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs) {
phase_ = Phase::Relay;
phaseStartMs_ = nowMs;
aboveSetpoint_ = avgTempC >= setpointC_;
peakSinceCross_ = avgTempC;
valleySinceCross_ = avgTempC;
useMaxSensorPv_ = spreadC > GOOD_SPREAD_C;
const float pv = useMaxSensorPv_ ? maxTempC : avgTempC;
aboveSetpoint_ = pv >= setpointC_;
peakSinceCross_ = pv;
valleySinceCross_ = pv;
lastCrossMs_ = 0;
Serial.println(F("autotune: relay test started"));
Serial.print(F("autotune: relay "));
Serial.print(useMaxSensorPv_ ? F("max-sensor") : F("avg"));
Serial.print(F(" ("));
Serial.print((nowMs - sessionStartMs_) / 1000UL);
Serial.println(F("s preheat)"));
}
void resetMeasurements() {
@@ -147,6 +206,7 @@ private:
spreadSamples_ = 0;
cycleCount_ = 0;
aboveSetpoint_ = false;
useMaxSensorPv_ = false;
}
void onSetpointCrossing(uint32_t nowMs) {
@@ -158,13 +218,18 @@ private:
Serial.print(F("autotune: cycle "));
Serial.print(cycleCount_);
Serial.print(F("/"));
Serial.print(AUTOTUNE_CYCLES_REQUIRED);
Serial.print(F(" amp="));
Serial.println(amplitude, 2);
Serial.print(amplitude, 2);
Serial.print(F("C elapsed="));
Serial.print((nowMs - sessionStartMs_) / 1000UL);
Serial.println(F("s"));
}
if (lastCrossMs_ > 0) {
const uint32_t period = nowMs - lastCrossMs_;
if (period > 8000 && period < 900000) {
if (period > 8000 && period < AUTOTUNE_RELAY_PERIOD_MAX_MS) {
periodSumMs_ += period;
++periodCount_;
}
@@ -173,11 +238,11 @@ private:
peakSinceCross_ = valleySinceCross_;
if (cycleCount_ >= AUTOTUNE_CYCLES_REQUIRED && periodCount_ >= 3 && amplitudeCount_ >= 3) {
finish();
finish(nowMs);
}
}
void finish() {
void finish(uint32_t nowMs) {
const float avgPeriodSec =
static_cast<float>(periodSumMs_ / periodCount_) / 1000.0f;
const float avgAmplitude = amplitudeSum_ / static_cast<float>(amplitudeCount_);
@@ -190,7 +255,6 @@ private:
const float ku = (4.0f * 100.0f) / (PI * avgAmplitude);
resultKp_ = 0.45f * ku;
resultKi_ = resultKp_ / (2.2f * avgPeriodSec);
resultKd_ = resultKp_ * avgPeriodSec / 6.3f;
if (resultKp_ < 0.5f) {
resultKp_ = 0.5f;
@@ -199,33 +263,21 @@ private:
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;
Serial.println(F("autotune: done"));
Serial.print(F(" Kp="));
Serial.print(F("autotune: done in "));
Serial.print((nowMs - sessionStartMs_) / 1000UL);
Serial.println(F("s"));
Serial.print(F(" heat Kp="));
Serial.print(resultKp_, 3);
Serial.print(F(" Ki="));
Serial.print(resultKi_, 4);
Serial.print(F(" Kd="));
Serial.print(resultKd_, 3);
Serial.print(F(" fanMixMax="));
Serial.println(resultFanMixMax_);
Serial.println(resultKi_, 4);
Serial.println(F(" tune heat PI: pid save after autotune"));
}
void fail(const __FlashStringHelper *reason) {
Serial.println(reason);
phase_ = Phase::Failed;
sessionStartMs_ = 0;
}
Phase phase_;
@@ -243,9 +295,9 @@ private:
uint16_t spreadSamples_;
uint8_t cycleCount_;
bool aboveSetpoint_;
bool useMaxSensorPv_;
uint32_t sessionStartMs_;
uint32_t phaseStartMs_;
float resultKp_;
float resultKi_;
float resultKd_;
uint8_t resultFanMixMax_;
};

View File

@@ -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_STIR_PWM from config
};
inline uint8_t settingsChecksum(const SettingsData &data) {
@@ -39,8 +40,24 @@ inline void settingsSave(const SettingsData &data) {
inline void settingsSaveTarget(float targetC) {
SettingsData data;
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);
}

View File

@@ -3,6 +3,7 @@
#include <Arduino.h>
#include "config.h"
#include "fan_characterize.h"
#include "pid_autotuner.h"
#include "pid_controller.h"
#include "settings_store.h"
@@ -10,36 +11,47 @@
class ThermalController {
public:
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, FanChars };
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),
autotuner_(),
fanchars_(),
targetTempC_(TARGET_TEMP_C),
heaterDutyPercent_(0.0f),
heaterAllowancePercent_(100.0f),
cornerSpreadC_(0.0f),
lastMaxTempC_(0.0f),
fanPwm_(FAN_MAX_PWM),
fanMixMax_(FAN_MIX_MAX_PWM),
adaptiveEnabled_(false),
regulatingFanPwm_(0),
stirFanPwm_(FAN_STIR_PWM),
fanManualPwm_(0),
fanPwm_(0),
tuningLoaded_(false),
fanIdleOverride_(false),
fanManualActive_(false),
sensorWarmValid_(false),
cutoffActive_(false),
failSafeActive_(true),
heaterCycleStartMs_(0),
lastHeaterUpdateMs_(0),
heaterOn_(false) {}
heaterOn_(false),
lastAvgTempC_(0.0f),
heaterBlock_(HeaterBlock::None),
fanTestActive_(false),
fanTestPwm_(0),
fanTestEndMs_(0) {}
void begin() {
pinMode(FAN_PIN, OUTPUT);
pinMode(HEATER_PIN, OUTPUT);
digitalWrite(HEATER_PIN, LOW);
writeFan(0);
pid_.setSetpoint(targetTempC_);
pid_.reset();
heatPi_.setSetpoint(targetTempC_);
heatPi_.reset();
heaterCycleStartMs_ = millis();
lastHeaterUpdateMs_ = 0;
failSafeActive_ = true;
fanPwm_ = FAN_MAX_PWM;
cornerSpreadC_ = 0.0f;
lastMaxTempC_ = 0.0f;
sensorWarmValid_ = false;
@@ -47,63 +59,69 @@ public:
if (isIdle()) {
forceHeaterOff();
}
applyFan();
applyFan(millis());
TuningData stored;
if (tuningLoad(stored)) {
applyTuning(stored);
Serial.println(F("Loaded learned PID from EEPROM"));
Serial.println(F("Loaded learned PI from EEPROM"));
printTuning();
}
SettingsData settings;
if (settingsLoad(settings) && settings.targetC >= TARGET_MIN_C &&
settings.targetC <= TARGET_MAX_C) {
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);
}
}
}
void applyTuning(const TuningData &data) {
pid_.setTunings(data.kp, data.ki, data.kd);
fanMixMax_ = data.fanMixMax;
adaptiveEnabled_ = true;
heatPi_.setTunings(data.heatKp, data.heatKi, 0.0f);
tuningLoaded_ = true;
}
void clearTuning() {
adaptiveEnabled_ = false;
fanMixMax_ = FAN_MIX_MAX_PWM;
pid_.setTunings(PID_KP, PID_KI, PID_KD);
tuningLoaded_ = false;
heatPi_.setTunings(HEAT_PI_KP, HEAT_PI_KI, 0.0f);
tuningClear();
pid_.reset();
Serial.println(F("PID reset to defaults"));
heatPi_.reset();
Serial.println(F("PI reset to defaults"));
}
void printTuning() const {
Serial.print(F("PID Kp="));
Serial.print(pidKp(), 3);
Serial.print(F("Heat PI Kp="));
Serial.print(heatPi_.kp(), 3);
Serial.print(F(" Ki="));
Serial.print(pidKi(), 4);
Serial.print(F(" Kd="));
Serial.print(pidKd(), 3);
Serial.print(F(" fanMixMax="));
Serial.print(fanMixMax_);
Serial.print(F(" adaptive="));
Serial.println(adaptiveEnabled_ ? F("yes") : F("no"));
Serial.print(heatPi_.ki(), 4);
Serial.print(F(" tuned="));
Serial.println(tuningLoaded_ ? F("yes") : F("no"));
}
float pidKp() const { return pid_.kp(); }
float pidKi() const { return pid_.ki(); }
float pidKd() const { return pid_.kd(); }
void saveTuningToEeprom() {
TuningData data;
data.magic = TUNING_MAGIC;
data.heatKp = heatPi_.kp();
data.heatKi = heatPi_.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(); }
bool isTuningLoaded() const { return tuningLoaded_; }
bool startAutotune(float setpointC) {
if (autotuner_.isActive()) {
if (autotuner_.isActive() || fanchars_.isActive()) {
return false;
}
adaptiveEnabled_ = false;
cutoffActive_ = false;
pid_.reset();
heatPi_.reset();
return autotuner_.start(setpointC);
}
@@ -111,28 +129,124 @@ public:
bool isAutotuning() const { return autotuner_.isActive(); }
uint32_t autotuneElapsedMs(uint32_t nowMs) const { return autotuner_.elapsedMs(nowMs); }
const char *autotunePhaseName() const { return autotuner_.phaseName(); }
uint8_t autotuneCycleCount() const { return autotuner_.cycleCount(); }
uint8_t autotunePeriodCount() const { return autotuner_.periodCount(); }
float autotunePreheatTargetC() const { return autotuner_.preheatTargetC(); }
bool startFanCharacterize(float maxCornerC, float avgTempC) {
if (autotuner_.isActive() || fanchars_.isActive()) {
return false;
}
stopFanTest();
cutoffActive_ = false;
heatPi_.reset();
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_; }
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) {
fanchars_.logIfDue(sensorTemps, sensorValid, sensorCount, avgTempC, minTempC, maxTempC,
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() {
if (autotuner_.phase() != PidAutotuner::Phase::Done) {
return false;
}
heatPi_.setTunings(autotuner_.resultKp(), autotuner_.resultKi(), 0.0f);
heatPi_.reset();
TuningData data;
data.magic = TUNING_MAGIC;
data.kp = autotuner_.resultKp();
data.ki = autotuner_.resultKi();
data.kd = autotuner_.resultKd();
data.fanMixMax = autotuner_.resultFanMixMax();
data.heatKp = autotuner_.resultKp();
data.heatKi = autotuner_.resultKi();
tuningSave(data);
applyTuning(data);
tuningLoaded_ = true;
autotuner_.reset();
Serial.println(F("Saved learned PID to EEPROM"));
Serial.println(F("Saved heat PI to EEPROM"));
return true;
}
void setTarget(float targetC, bool persist = true) {
targetTempC_ = targetC;
pid_.setSetpoint(targetC);
pid_.reset();
heatPi_.setSetpoint(targetC);
heatPi_.reset();
fanManualActive_ = false;
cutoffActive_ = false;
if (targetC > 0.0f) {
fanIdleOverride_ = false;
@@ -140,7 +254,7 @@ public:
forceHeaterOff();
fanIdleOverride_ = false;
}
applyFan();
applyFan(millis());
if (persist && targetC >= TARGET_MIN_C && targetC <= TARGET_MAX_C) {
settingsSaveTarget(targetC);
}
@@ -156,7 +270,7 @@ public:
return false;
}
fanIdleOverride_ = false;
applyFan();
applyFan(millis());
return true;
}
@@ -168,7 +282,7 @@ public:
return;
}
fanIdleOverride_ = true;
applyFan();
applyFan(millis());
}
bool isFanOff() const {
@@ -185,11 +299,13 @@ public:
bool isIdle() const { return targetTempC_ <= 0.0f; }
float emergencyCutoffC() const { return emergencyCutoffForTarget(targetTempC_); }
float cutoffThreshold() const {
if (isIdle()) {
return INFINITY;
}
return targetTempC_ * (1.0f + OVERTEMP_FRACTION);
return emergencyCutoffC();
}
bool isCutoffActive() const { return cutoffActive_; }
@@ -204,13 +320,62 @@ public:
uint8_t fanPwm() const { return fanPwm_; }
bool isHeaterSsrOn() const { return heaterOn_; }
bool isFanTestActive(uint32_t nowMs) const {
return fanTestActive_ && nowMs < fanTestEndMs_;
}
bool startFanTest(uint8_t pwm, uint32_t nowMs, uint32_t durationMs = 15000) {
fanTestPwm_ = pwm;
fanTestEndMs_ = nowMs + durationMs;
fanTestActive_ = true;
writeFan(pwm);
return true;
}
void stopFanTest() { fanTestActive_ = false; }
float maxHeatStopAt(float avgTempC) const {
if (isIdle()) {
return INFINITY;
}
return maxHeatStopTemp(avgTempC);
}
const char *heaterBlockReason() const {
switch (heaterBlock_) {
case HeaterBlock::Cutoff:
return "cutoff";
case HeaterBlock::Corner:
return "corner";
case HeaterBlock::Autotune:
return "autotune";
case HeaterBlock::FanChars:
return "fanchars";
default:
return "none";
}
}
const char *regulatingModeName() const {
return tuningLoaded_ ? "regulating" : "manual";
}
void update(float avgTempC, float maxTempC, float cornerSpreadC, uint32_t nowMs) {
failSafeActive_ = false;
noteSensorMax(maxTempC);
lastAvgTempC_ = avgTempC;
heaterBlock_ = HeaterBlock::None;
cornerSpreadC_ =
SPREAD_EMA_ALPHA * cornerSpreadC +
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC_;
SPREAD_EMA_ALPHA * cornerSpreadC_ +
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC;
if (fanchars_.isActive()) {
updateFanCharacterize(avgTempC, maxTempC, nowMs);
return;
}
if (autotuner_.isActive()) {
updateAutotune(avgTempC, maxTempC, nowMs);
@@ -220,30 +385,26 @@ public:
if (isIdle()) {
forceHeaterOff();
cutoffActive_ = false;
pid_.reset();
heatPi_.reset();
lastHeaterUpdateMs_ = nowMs;
applyFan();
applyFan(nowMs);
return;
}
if (adaptiveEnabled_) {
updateAdaptive(avgTempC, maxTempC, nowMs);
} else {
updateLegacy(avgTempC, maxTempC, nowMs);
}
updateRegulating(avgTempC, maxTempC, nowMs);
lastHeaterUpdateMs_ = nowMs;
applyHeaterBurst(nowMs);
applyFan();
writeFan(regulatingFanPwm_);
}
void enterFailSafe() {
failSafeActive_ = true;
cutoffActive_ = false;
forceHeaterOff();
applyFan();
pid_.reset();
applyFan(millis());
heatPi_.reset();
autotuner_.abort();
fanchars_.abort();
}
void forceHeaterOff() {
@@ -255,104 +416,97 @@ public:
void writeFan(uint8_t pwm) {
fanPwm_ = pwm;
if (pwm == 0) {
// Re-assert output and stop Timer0 PWM on D5 — analogWrite(0) can leave the pin driving
pinMode(FAN_PIN, OUTPUT);
digitalWrite(FAN_PIN, LOW);
} else {
analogWrite(FAN_PIN, pwm);
if (FAN_PWM_INVERT) {
if (pwm == 0) {
digitalWrite(FAN_PIN, HIGH);
return;
}
if (pwm >= 254) {
digitalWrite(FAN_PIN, LOW);
return;
}
analogWrite(FAN_PIN, static_cast<uint8_t>(255 - pwm));
return;
}
if (pwm == 0) {
digitalWrite(FAN_PIN, LOW);
return;
}
if (pwm >= 254) {
digitalWrite(FAN_PIN, HIGH);
return;
}
analogWrite(FAN_PIN, pwm);
}
private:
void updateFanCharacterize(float avgTempC, float maxTempC, uint32_t nowMs) {
float duty = 0.0f;
uint8_t fan = 0;
fanchars_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
heaterDutyPercent_ = duty;
heaterAllowancePercent_ = duty;
heaterBlock_ = duty > 0.0f ? HeaterBlock::FanChars : HeaterBlock::None;
applyHeaterBurst(nowMs);
writeFan(fan);
lastHeaterUpdateMs_ = nowMs;
}
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
float duty = 0.0f;
uint8_t fan = FAN_HEAT_MIN_PWM;
uint8_t fan = stirFanPwm_;
autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
heaterDutyPercent_ = duty;
heaterAllowancePercent_ = duty;
heaterOn_ = duty >= 50.0f;
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
writeFan(fan);
heaterBlock_ = duty > 0.0f ? HeaterBlock::None : HeaterBlock::Autotune;
writeFan(fanManualActive_ ? fanManualPwm_ : stirFanPwm_);
lastHeaterUpdateMs_ = nowMs;
commitAutotuneIfDone();
}
void updateAdaptive(float avgTempC, float maxTempC, uint32_t nowMs) {
const float cutoff = cutoffThreshold();
if (maxTempC >= cutoff) {
void updateRegulating(float avgTempC, float maxTempC, uint32_t nowMs) {
const float cutoffC = emergencyCutoffC();
if (maxTempC >= cutoffC) {
cutoffActive_ = true;
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
heaterOn_ = false;
pid_.reset();
regulatingFanPwm_ = FAN_MAX_PWM;
heaterBlock_ = HeaterBlock::Cutoff;
heatPi_.reset();
return;
}
if (cutoffActive_ && maxTempC <= targetTempC_) {
if (cutoffActive_ && maxTempC < cutoffC - CUTOFF_RECOVERY_BAND_C) {
cutoffActive_ = false;
pid_.reset();
heatPi_.reset();
}
if (cutoffActive_) {
heaterBlock_ = HeaterBlock::Cutoff;
regulatingFanPwm_ = FAN_MAX_PWM;
return;
}
const float maxHeatStopC = maxHeatStopTemp(avgTempC);
if (maxTempC >= maxHeatStopC) {
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
pid_.reset();
return;
}
heaterAllowancePercent_ = allowanceFromMaxCorner(maxTempC, avgTempC);
float duty = pid_.compute(avgTempC, nowMs);
const float maxDuty = heaterMaxDuty(avgTempC);
if (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;
pid_.reset();
return;
}
if (cutoffActive_ && maxTempC <= targetTempC_) {
cutoffActive_ = false;
pid_.reset();
}
if (cutoffActive_) {
return;
}
const float maxHeatStopC = maxHeatStopTemp(avgTempC);
if (maxTempC >= maxHeatStopC) {
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
pid_.reset();
return;
}
const float pidOut = pid_.compute(avgTempC, nowMs);
heaterAllowancePercent_ = heaterAllowancePercent(avgTempC, maxTempC);
float duty = pidOut;
float duty = heatPi_.compute(avgTempC, nowMs);
duty = clampPercent(duty);
if (duty > heaterAllowancePercent_) {
duty = heaterAllowancePercent_;
if (heaterAllowancePercent_ < 100.0f) {
heaterBlock_ = HeaterBlock::Corner;
}
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
}
heaterDutyPercent_ = duty;
regulatingFanPwm_ = fanManualActive_ ? fanManualPwm_ : stirFanPwm_;
}
static float clampPercent(float value) {
@@ -365,17 +519,25 @@ private:
return value;
}
bool isBalancedChamber() const { return cornerSpreadC_ <= GOOD_SPREAD_C; }
bool shouldLimitMaxCorner(float avgTempC) const {
return avgTempC >= targetTempC_ - CORNER_LIMIT_BAND_C;
}
float maxHeatStopTemp(float avgTempC) const {
if (isBalancedChamber() && avgTempC < targetTempC_) {
return targetTempC_ + BALANCED_MAX_ABOVE_TARGET_C;
if (!shouldLimitMaxCorner(avgTempC)) {
return emergencyCutoffC();
}
return targetTempC_;
return emergencyCutoffC() - CORNER_STOP_MARGIN_C;
}
float allowanceFromMaxCorner(float maxTempC, float avgTempC) const {
if (isBalancedChamber() && avgTempC < targetTempC_) {
const float cutoffC = emergencyCutoffC();
if (!shouldLimitMaxCorner(avgTempC)) {
// Heat-up: only taper when a hot corner nears the dynamic cutoff
if (maxTempC >= cutoffC - 3.0f) {
const float headroom = cutoffC - maxTempC;
return clampPercent((headroom / 3.0f) * 100.0f);
}
return 100.0f;
}
@@ -392,90 +554,6 @@ private:
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 {
if (avgTempC >= targetTempC_) {
return HEATER_MAX_DUTY_NEAR;
}
const float below = targetTempC_ - avgTempC;
if (below > HEATER_COLD_BELOW_C) {
return HEATER_MAX_DUTY_COLD;
}
if (below > HEATER_WARM_BELOW_C) {
return HEATER_MAX_DUTY_MID;
}
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) {
const float maxDuty = heaterMaxDuty(avgTempC);
if (requestedDuty > maxDuty) {
requestedDuty = maxDuty;
}
if (lastHeaterUpdateMs_ > 0 && requestedDuty > heaterDutyPercent_) {
const float dt = static_cast<float>(nowMs - lastHeaterUpdateMs_) / 1000.0f;
const float maxUp = heaterDutyPercent_ + HEATER_SLEW_UP_PER_S * dt;
if (requestedDuty > maxUp) {
requestedDuty = maxUp;
}
}
return requestedDuty;
}
uint8_t fanPwmForHeaterDemand() const {
if (heaterDutyPercent_ <= 0.0f) {
return 0;
}
const uint8_t span = FAN_HEAT_MAX_PWM - FAN_HEAT_MIN_PWM;
return FAN_HEAT_MIN_PWM +
static_cast<uint8_t>((heaterDutyPercent_ / 100.0f) * static_cast<float>(span));
}
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) {
if (heaterDutyPercent_ <= 0.0f) {
forceHeaterOff();
@@ -496,9 +574,17 @@ private:
}
}
void applyFan() {
void applyFan(uint32_t nowMs) {
if (fanTestActive_) {
if (nowMs < fanTestEndMs_) {
writeFan(fanTestPwm_);
return;
}
fanTestActive_ = false;
}
if (isIdle()) {
if (!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
if (sensorWarmValid_ && lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
writeFan(FAN_MAX_PWM);
} else if (fanIdleOverride_) {
writeFan(FAN_IDLE_PWM);
@@ -512,35 +598,32 @@ private:
writeFan(FAN_MAX_PWM);
return;
}
uint8_t duty = fanPwmForHeaterDemand();
const uint8_t mixFan = fanPwmForCornerSpread();
if (mixFan > duty) {
duty = mixFan;
}
if (lastMaxTempC_ > targetTempC_ && duty < FAN_MAX_PWM) {
duty = FAN_MAX_PWM;
}
writeFan(duty);
}
PidController pid_;
PidController heatPi_;
PidAutotuner autotuner_;
FanCharacterize fanchars_;
float targetTempC_;
float heaterDutyPercent_;
float heaterAllowancePercent_;
float cornerSpreadC_;
float lastMaxTempC_;
uint8_t regulatingFanPwm_;
uint8_t stirFanPwm_;
uint8_t fanManualPwm_;
uint8_t fanPwm_;
uint8_t fanMixMax_;
bool adaptiveEnabled_;
bool tuningLoaded_;
bool fanIdleOverride_;
bool fanManualActive_;
bool sensorWarmValid_;
bool cutoffActive_;
bool failSafeActive_;
uint32_t heaterCycleStartMs_;
uint32_t lastHeaterUpdateMs_;
bool heaterOn_;
float lastAvgTempC_;
HeaterBlock heaterBlock_;
bool fanTestActive_;
uint8_t fanTestPwm_;
uint32_t fanTestEndMs_;
};

View File

@@ -5,15 +5,13 @@
#include "config.h"
static const uint16_t TUNING_MAGIC = 0xDA7A;
static const uint16_t TUNING_MAGIC = 0xDA7C;
static const int TUNING_EEPROM_ADDR = 0;
struct TuningData {
uint16_t magic = 0;
float kp = PID_KP;
float ki = PID_KI;
float kd = PID_KD;
uint8_t fanMixMax = FAN_MIX_MAX_PWM;
float heatKp = HEAT_PI_KP;
float heatKi = HEAT_PI_KI;
};
inline uint8_t tuningChecksum(const TuningData &data) {

View File

@@ -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` 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 · `c` fanchars · `:` command · `q` quit
**Always-on logging**

View File

@@ -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

View File

@@ -9,6 +9,7 @@
from __future__ import annotations
import argparse
import re
import sys
import time
from datetime import datetime, timezone
@@ -19,6 +20,122 @@ FALLBACK_HEADER = (
"fan_pct,cutoff,failsafe,ch2_t,ch2_h,ch3_t,ch3_h,ch4_t,ch4_h,ch5_t,ch5_h"
)
SENSOR_CHANNELS = [2, 3, 4, 5]
# If no bytes at all arrive for this long, treat the device as hung (e.g. an
# I2C bus lockup freezing the Arduino) rather than looping forever in silence.
DEFAULT_STALL_TIMEOUT_S = 20.0
_FAN_PCT_RE = re.compile(r"\((\d+)%\)")
_FAN_PWM_RE = re.compile(r"^(\d+)/")
def fan_pct_from_status(fan: str) -> int:
match = _FAN_PCT_RE.search(fan)
if match:
return int(match.group(1))
match = _FAN_PWM_RE.match(fan)
if match:
return (int(match.group(1)) * 100) // 255
return 0
def target_c_from_status(target: str) -> str:
if target.startswith("idle"):
return "0.0"
if target.endswith("C"):
return target[:-1]
return target
def build_csv_payload_from_status(data: dict, ms: int | None = None) -> str:
if ms is None:
ms = int(time.time() * 1000)
sensors = {ch: (temp, hum) for ch, temp, hum in data.get("sensor_list", [])}
parts = [
str(ms),
target_c_from_status(data["target"]),
data["avg"],
data["min"],
data["max"],
data["spread"],
data["heatlim"],
data["heater"],
str(fan_pct_from_status(data.get("fan", "0"))),
"1" if data.get("cutoff_active") == "YES" else "0",
"1" if data.get("failsafe") == "YES" else "0",
]
for ch in SENSOR_CHANNELS:
if str(ch) in sensors:
temp, hum = sensors[str(ch)]
if temp == "ERR":
parts.extend(["", ""])
else:
parts.extend([temp, hum])
else:
parts.extend(["", ""])
return ",".join(parts)
class CsvSession:
"""Deferred CSV writer — no empty file until the first row lands."""
def __init__(self, path: Path):
self.path = path
self._fh = None
self._header_written = False
self.row_count = 0
def _ensure_open(self) -> None:
if self._fh is None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self._fh = self.path.open("w", encoding="utf-8")
def _write_header(self) -> None:
if not self._header_written:
self._ensure_open()
assert self._fh is not None
self._fh.write(FALLBACK_HEADER + "\n")
self._header_written = True
def write_device_line(self, line: str) -> None:
if line.startswith("csv_hdr,"):
self._ensure_open()
assert self._fh is not None
device_header = line[len("csv_hdr,") :]
self._fh.write("wall_time," + device_header + "\n")
self._header_written = True
self._fh.flush()
return
if not line.startswith("csv,"):
return
self._write_header()
assert self._fh is not None
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
self._fh.write(wall_time + "," + line[len("csv,") :] + "\n")
self._fh.flush()
self.row_count += 1
def write_status(self, data: dict) -> None:
self._write_header()
assert self._fh is not None
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
payload = build_csv_payload_from_status(data)
self._fh.write(wall_time + "," + payload + "\n")
self._fh.flush()
self.row_count += 1
def close(self) -> None:
if self._fh is not None:
self._fh.close()
self._fh = None
if self.row_count == 0 and self.path.exists():
try:
self.path.unlink()
except OSError:
pass
def detect_serial_port() -> str | None:
by_id = Path("/dev/serial/by-id")
@@ -93,69 +210,71 @@ def enable_dryer_logging(ser, retries: int = 3) -> None:
print("WARN: did not see 'OK csv logging on' — continuing anyway", file=sys.stderr)
def write_csv_row(fh, line: str, header_written: list[bool]) -> None:
if line.startswith("csv_hdr,"):
device_header = line[len("csv_hdr,") :]
fh.write("wall_time," + device_header + "\n")
header_written[0] = True
fh.flush()
return
if not line.startswith("csv,"):
return
if not header_written[0]:
fh.write(FALLBACK_HEADER + "\n")
header_written[0] = True
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
fh.write(wall_time + "," + line[len("csv,") :] + "\n")
fh.flush()
def log_notice(message: str) -> None:
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
print(f"{stamp} {message}", file=sys.stderr)
def cmd_log(args: argparse.Namespace) -> int:
from dryer_tui import parse_status
port = resolve_port(args.port)
out = args.output
if out is None:
out = args.log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
out.parent.mkdir(parents=True, exist_ok=True)
stall_timeout = args.stall_timeout
print(f"Logging {port} -> {out}", file=sys.stderr)
if args.auto_log_on:
print("Will send 'log on' after connect", file=sys.stderr)
header_written = False
with open_serial(port, args.baud) as ser, out.open("w", encoding="utf-8") as fh:
session = CsvSession(out)
with open_serial(port, args.baud) as ser:
if args.auto_log_on:
enable_dryer_logging(ser)
last_activity = time.monotonic()
while True:
try:
raw = ser.readline()
except KeyboardInterrupt:
print("\nStopped.", file=sys.stderr)
print(f"\nStopped ({session.row_count} rows).", file=sys.stderr)
session.close()
return 0
except Exception as exc:
log_notice(
f"ERROR: serial read failed ({exc}) — closing after "
f"{session.row_count} rows"
)
session.close()
return 1
if not raw:
if time.monotonic() - last_activity >= stall_timeout:
log_notice(
f"WARN: no data from {port} for {stall_timeout:.0f}s — "
f"device likely hung (e.g. I2C bus lockup on the Arduino) "
f"— closing after {session.row_count} rows"
)
session.close()
return 1
continue
last_activity = time.monotonic()
line = decode_line(raw)
if not line.startswith("csv_hdr,") and not line.startswith("csv,"):
if line:
parsed = parse_status(line)
if parsed:
session.write_status(parsed)
print(line)
continue
if line.startswith("csv_hdr,"):
device_header = line[len("csv_hdr,") :]
fh.write("wall_time," + device_header + "\n")
header_written = True
fh.flush()
if line.startswith("csv_hdr,") or line.startswith("csv,"):
session.write_device_line(line)
continue
if not header_written:
fh.write(FALLBACK_HEADER + "\n")
header_written = True
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
fh.write(wall_time + "," + line[len("csv,") :] + "\n")
fh.flush()
if line:
print(line)
return 0
def cmd_tui(args: argparse.Namespace) -> int:
@@ -185,6 +304,15 @@ def build_parser() -> argparse.ArgumentParser:
default=True,
help="Send 'log on' after connect in log mode (default: on)",
)
parser.add_argument(
"--stall-timeout",
type=float,
default=DEFAULT_STALL_TIMEOUT_S,
help=(
"log mode: seconds without any data before treating the device as "
f"hung and exiting (default: {DEFAULT_STALL_TIMEOUT_S:.0f})"
),
)
subparsers = parser.add_subparsers(dest="action")
log_p = subparsers.add_parser("log", help="Headless CSV capture", add_help=False)
@@ -193,6 +321,7 @@ def build_parser() -> argparse.ArgumentParser:
log_p.add_argument("-o", "--output", type=Path)
log_p.add_argument("--log-dir", type=Path, default=Path("logs"))
log_p.add_argument("--auto-log-on", action=argparse.BooleanOptionalAction, default=True)
log_p.add_argument("--stall-timeout", type=float, default=DEFAULT_STALL_TIMEOUT_S)
tui_p = subparsers.add_parser("tui", help="Interactive curses dashboard")
tui_p.add_argument("-p", "--port")

View File

@@ -7,14 +7,15 @@ import curses
import re
import sys
import threading
import time
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from capture_csv import (
CsvSession,
decode_line,
write_csv_row,
)
PRESETS: list[tuple[str, float]] = [
@@ -40,15 +41,89 @@ STATUS_RE = re.compile(
r"spread=(?P<spread>[\d.]+)C\s+"
r"heatlim=(?P<heatlim>\d+)%\s+"
r"heater=(?P<heater>[\d.]+)%\s+"
r"fan=(?P<fan>\d+)(?P<fan_note>\([^)]*\))?\s+"
r"htop=(?P<htop>\S+)\s+"
r"hblk=(?P<hblk>\S+)\s+"
r"ssr=(?P<ssr>on|off)\s+"
r"fan=(?P<fan>\d+/255\([^)]+\)(?:\([^)]+\))?(?:\s+TEST)?)\s+"
r"cutoff=(?P<cutoff_active>\S+)\s+"
r"failsafe=(?P<failsafe>\S+)\s+"
r"mode=(?P<mode>\S+)\s+"
r"sensors=\[(?P<sensors>.*)\]"
r"mode=(?P<mode>.+?)\s+sensors=\[(?P<sensors>.*)\]"
)
SENSOR_RE = re.compile(r"ch(\d+):([\d.]+)C/(\d+)%|ch(\d+):ERR")
AUTOTUNE_MODE_RE = re.compile(
r"autotune/(?P<phase>[\w-]+) (?P<elapsed>\d+)s (?P<cycles>\d+/\d+)cyc pre>=(?P<pre>\d+)C"
)
FANCHARS_MODE_RE = re.compile(
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 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()
summary = (
f"Autotune {d['phase']}: {d['elapsed']}s, "
f"{d['cycles']} cycles, preheat avg >= {d['pre']} C"
)
return summary, "Relay tuning heat PI — heater bang-bang around setpoint"
match = FANCHARS_MODE_RE.match(mode)
if match:
d = match.groupdict()
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
class DryerState:
@@ -60,13 +135,17 @@ class DryerState:
spread: str = ""
heatlim: str = ""
heater: str = ""
htop: str = ""
hblk: str = ""
ssr: str = ""
fan: str = ""
fan_note: str = ""
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 = ""
@@ -91,7 +170,6 @@ def parse_status(line: str) -> dict | None:
else:
sensors.append((m.group(1), m.group(2), m.group(3)))
data["sensor_list"] = sensors
data["fan_note"] = data.get("fan_note") or ""
return data
@@ -116,11 +194,24 @@ def apply_status(state: DryerState, data: dict) -> None:
state.spread = data["spread"]
state.heatlim = data["heatlim"]
state.heater = data["heater"]
state.fan = data["fan"]
state.fan_note = data["fan_note"]
state.htop = data["htop"].removesuffix("C") if data["htop"].endswith("C") else data["htop"]
state.hblk = data["hblk"]
state.ssr = data["ssr"]
fan_raw = data["fan"]
state.fan = format_fan_display(fan_raw)
state.fan_note = ""
if "(off)" in fan_raw or "(cooldown)" 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-") :]
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"]
@@ -130,8 +221,7 @@ class SerialWorker:
self.state = state
self.lock = lock
self.stop = threading.Event()
self._log_fh = None
self._header_written = [False]
self._csv: CsvSession | None = None
self._thread: threading.Thread | None = None
def start(self) -> None:
@@ -146,9 +236,9 @@ class SerialWorker:
self.stop.set()
if self._thread is not None:
self._thread.join(timeout=1.5)
if self._log_fh is not None:
self._log_fh.close()
self._log_fh = None
if self._csv is not None:
self._csv.close()
self._csv = None
def send(self, command: str) -> None:
if self.ser is None:
@@ -161,19 +251,21 @@ class SerialWorker:
if enabled and not self.state.csv_logging:
log_dir.mkdir(parents=True, exist_ok=True)
path = log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
self._log_fh = path.open("w", encoding="utf-8")
self._header_written = [False]
self._csv = CsvSession(path)
self.state.csv_path = path
self.state.csv_logging = True
self.state.messages.append(f"CSV -> {path.name}")
self.state.messages.append(f"CSV -> {path.name} (on status)")
self.send("log on")
elif not enabled and self.state.csv_logging:
self.send("log off")
self.state.csv_logging = False
self.state.csv_path = None
if self._log_fh is not None:
self._log_fh.close()
self._log_fh = None
if self._csv is not None:
rows = self._csv.row_count
self._csv.close()
self._csv = None
self.state.messages.append(f"CSV logging off ({rows} rows)")
else:
self.state.messages.append("CSV logging off")
def _note(self, line: str) -> None:
@@ -195,15 +287,23 @@ class SerialWorker:
if not line:
continue
if line.startswith("fanchars:"):
self._note(line)
continue
if line.startswith("csv,") or line.startswith("csv_hdr,"):
if self._log_fh is not None:
write_csv_row(self._log_fh, line, self._header_written)
continue
parsed = parse_status(line)
if parsed:
with self.lock:
apply_status(self.state, parsed)
if self._csv is not None:
self._csv.write_status(parsed)
continue
if line.startswith("target="):
self._note("WARN: could not parse status line")
continue
if line.startswith("OK") or line.startswith("ERR") or line.startswith("WARN"):
@@ -306,8 +406,8 @@ def _preset_menu(stdscr, worker: SerialWorker) -> None:
def _draw_dashboard(stdscr, state: DryerState) -> None:
stdscr.erase()
height, width = stdscr.getmaxyx()
if height < 18 or width < 60:
_safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x18).")
if height < 20 or width < 60:
_safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x20).")
stdscr.refresh()
return
@@ -315,17 +415,33 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
_safe_addstr(stdscr, 0, 1, title, curses.A_BOLD)
row = 2
_safe_addstr(stdscr, row, 2, f"Target: {state.target:>8} °C", curses.A_BOLD)
_safe_addstr(stdscr, row, 28, f"Mode: {state.mode}")
_safe_addstr(stdscr, row, 2, f"Target: {state.target:>6} C Limit: {state.cutoff} C", curses.A_BOLD)
cutoff_attr = curses.A_BOLD | curses.color_pair(1) if state.cutoff_active == "YES" else 0
_safe_addstr(stdscr, row, 48, f"Cutoff: {state.cutoff_active}", cutoff_attr)
_safe_addstr(stdscr, row, 36, f"Trip: {state.cutoff_active}", cutoff_attr)
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")
mode_text = state.mode
_safe_addstr(stdscr, row, 2, f"{mode_text[: max(0, width - 18)]} FS: {state.failsafe}")
row += 1
fan_text = f"{state.fan} %{state.fan_note}"
_safe_addstr(stdscr, row, 2, f"Heater: {state.heater} % Fan: {fan_text} Limit: {state.heatlim} %")
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 and "(fanchars-" not in state.fan_note:
fan_text = f"{state.fan} {state.fan_note}"
_safe_addstr(
stdscr,
row,
2,
f"Heater: {state.heater} % SSR: {state.ssr} Fan: {fan_text} Limit: {state.heatlim} %",
)
row += 1
_safe_addstr(stdscr, row, 2, f"Heat stop: {state.htop} C Block: {state.hblk}")
row += 2
_draw_box(stdscr, row, 1, 5, width - 2, "Sensors")
@@ -368,7 +484,7 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
stdscr,
help_y,
1,
"0 idle | t target | p presets | f fan | l log | a autotune | : 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()
@@ -388,6 +504,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)
@@ -403,13 +521,17 @@ def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
spread=state.spread,
heatlim=state.heatlim,
heater=state.heater,
htop=state.htop,
hblk=state.hblk,
ssr=state.ssr,
fan=state.fan,
fan_note=state.fan_note,
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,
@@ -447,6 +569,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("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 != "":

187
scripts/fan_characterize.py Normal file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Run fan characterize sweep and capture fc,... serial log lines to CSV.
Each profile heats from ~35 C avg to max corner (default 60 C) at a fixed fan
PWM, measures spread during a hold, cools, then repeats for the next speed.
Firmware prints the best fan at the end; use fanchars save on the device.
Example:
./fan_characterize.py
./fan_characterize.py -o logs/fanchars.csv
"""
from __future__ import annotations
import argparse
import re
import sys
import time
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from capture_csv import decode_line, open_serial, resolve_port
FC_RE = re.compile(
r"^fc,(?P<ms>\d+),(?P<phase>\w+),(?P<run>\d+/\d+),"
r"(?P<fan>\d+),(?P<heater>\d+),"
r"(?P<avg>[\d.]+),(?P<min>[\d.]+),(?P<max>[\d.]+),(?P<spread>[\d.]+)"
r"(?:,(?P<temps>.*))?$"
)
DONE_RE = re.compile(r"^fanchars: done")
FAIL_RE = re.compile(r"^fanchars: abort")
RUN_SUMMARY_RE = re.compile(r"^fanchars: f=(?P<fan>\d+) spr=(?P<mean>[\d.]+)")
BEST_RE = re.compile(r"^ best (?P<fan>\d+) spr=(?P<mean>[\d.]+)")
HEADER = (
"wall_time,ms,phase,run,fan_pwm,fan_pct,heater_pct,avg_c,min_c,max_c,spread_c,"
"ch2_t,ch3_t,ch4_t,ch5_t"
)
def fan_pct(pwm: int) -> int:
return (pwm * 100) // 255
def send_command(ser, command: str, timeout: float = 3.0) -> list[str]:
ser.write((command.strip() + "\n").encode("utf-8"))
ser.flush()
lines: list[str] = []
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
raw = ser.readline()
if not raw:
continue
line = decode_line(raw)
if not line:
continue
lines.append(line)
if line.startswith("OK ") or line.startswith("ERR "):
break
return lines
def parse_fc_line(line: str) -> dict | None:
match = FC_RE.match(line)
if not match:
return None
data = match.groupdict()
temps = data.pop("temps") or ""
channels = (temps.split(",") + ["", "", "", ""])[:4]
data["ch2_t"], data["ch3_t"], data["ch4_t"], data["ch5_t"] = channels
data["fan_pwm"] = data.pop("fan")
data["heater_pct"] = data.pop("heater")
return data
def main() -> int:
parser = argparse.ArgumentParser(description="Capture fan characterize sweep")
parser.add_argument("-p", "--port", help="Serial port (default: auto-detect)")
parser.add_argument("-b", "--baud", type=int, default=115200)
parser.add_argument("--max", type=float, default=60.0, help="Max corner temp (C, default 60)")
parser.add_argument(
"-o",
"--output",
type=Path,
help="Output CSV (default: logs/fanchars_YYYYMMDD_HHMMSS.csv)",
)
parser.add_argument(
"--timeout",
type=float,
default=5 * 3600,
help="Abort if not done within this many seconds (default: 5 h)",
)
args = parser.parse_args()
out = args.output
if out is None:
out = Path("logs") / f"fanchars_{datetime.now():%Y%m%d_%H%M%S}.csv"
out.parent.mkdir(parents=True, exist_ok=True)
cmd = f"fanchars {args.max:g}" if args.max != 60.0 else "fanchars"
print(f"Command: {cmd}", file=sys.stderr)
print(f"Output: {out}", file=sys.stderr)
print("Expect heat/cool cycles per fan speed. Ctrl+C sends fanchars stop.", file=sys.stderr)
port = resolve_port(args.port)
run_means: dict[int, float] = {}
best_line = ""
try:
with open_serial(port, args.baud) as ser:
time.sleep(0.3)
while ser.in_waiting:
decode_line(ser.readline())
lines = send_command(ser, cmd, timeout=5.0)
for line in lines:
print(line, file=sys.stderr)
if line.startswith("ERR "):
return 1
send_command(ser, "log on", timeout=2.0)
with out.open("w", encoding="utf-8") as fh:
fh.write(HEADER + "\n")
deadline = time.monotonic() + args.timeout
while time.monotonic() < deadline:
raw = ser.readline()
if not raw:
continue
line = decode_line(raw)
if not line:
continue
row = parse_fc_line(line)
if row:
wall = datetime.now(timezone.utc).isoformat()
fh.write(
f"{wall},{row['ms']},{row['phase']},{row['run']},"
f"{row['fan_pwm']},{fan_pct(int(row['fan_pwm']))},"
f"{row['heater_pct']},{row['avg']},{row['min']},{row['max']},"
f"{row['spread']},{row['ch2_t']},{row['ch3_t']},"
f"{row['ch4_t']},{row['ch5_t']}\n"
)
fh.flush()
match = RUN_SUMMARY_RE.match(line)
if match:
run_means[int(match.group("fan"))] = float(match.group("mean"))
print(line, file=sys.stderr)
if BEST_RE.search(line):
best_line = line.strip()
if DONE_RE.search(line) or FAIL_RE.search(line):
print(line, file=sys.stderr)
break
else:
print("Timeout waiting for fanchars to finish", file=sys.stderr)
return 1
if best_line:
print(best_line, file=sys.stderr)
print("Run: fanchars save (on device) to store stir PWM", file=sys.stderr)
elif run_means:
best_fan = min(run_means, key=run_means.get)
print(
f"Best from logs: fan {best_fan} ({fan_pct(best_fan)}%) "
f"mean spread {run_means[best_fan]:.2f} C",
file=sys.stderr,
)
except KeyboardInterrupt:
print("\nStopping…", file=sys.stderr)
try:
with open_serial(port, args.baud) as ser:
send_command(ser, "fanchars stop")
except OSError:
pass
return 130
print(f"Wrote {out}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())

147
scripts/fan_test.py Normal file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Cycle fan speeds on the dryer for wiring / PWM verification.
Uses the firmware `fan test <pwm>` command (heater stays off). Sends `target 0`
first so the thermal loop is idle.
Example:
./fan_test.py
./fan_test.py --pct 30 50 100 --interval 3 --loop
"""
from __future__ import annotations
import argparse
import re
import sys
import time
from capture_csv import decode_line, open_serial, resolve_port
OK_RE = re.compile(r"^OK ")
ERR_RE = re.compile(r"^ERR ")
FAN_STATUS_RE = re.compile(r"fan=(\d+)/255\((\d+)%\)")
def pct_to_pwm(pct: int) -> int:
if pct < 0 or pct > 100:
raise ValueError(f"fan percent must be 0-100, got {pct}")
return round(pct * 255 / 100)
def send_command(ser, command: str, timeout: float = 2.0) -> list[str]:
ser.write((command.strip() + "\n").encode("utf-8"))
ser.flush()
lines: list[str] = []
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
raw = ser.readline()
if not raw:
continue
line = decode_line(raw)
if not line:
continue
lines.append(line)
if OK_RE.match(line) or ERR_RE.match(line):
break
return lines
def drain_status(ser, duration: float) -> str | None:
"""Read serial for `duration` seconds; return last status fan field if seen."""
fan_field: str | None = None
deadline = time.monotonic() + duration
while time.monotonic() < deadline:
raw = ser.readline()
if not raw:
continue
line = decode_line(raw)
if not line or line.startswith("csv"):
continue
match = FAN_STATUS_RE.search(line)
if match:
fan_field = f"{match.group(1)}/255 ({match.group(2)}%)"
elif line.startswith("target="):
print(f" status: {line}", flush=True)
return fan_field
def main() -> int:
parser = argparse.ArgumentParser(description="Cycle fan PWM to verify fan control")
parser.add_argument(
"-p",
"--port",
help="Serial port (default: auto-detect)",
)
parser.add_argument("-b", "--baud", type=int, default=115200)
parser.add_argument(
"--pct",
type=int,
nargs="+",
default=[0, 30, 100, 200, 255],
metavar="PCT",
help="Fan speeds in percent (default: 0 30 100 200 255)",
)
parser.add_argument(
"--interval",
type=float,
default=5.0,
metavar="SEC",
help="Seconds to hold each step (default: 5)",
)
parser.add_argument(
"--loop",
action="store_true",
help="Repeat the sequence until Ctrl+C",
)
args = parser.parse_args()
port = resolve_port(args.port)
sequence = [(pct, pct_to_pwm(pct)) for pct in args.pct]
print(f"Port: {port}", file=sys.stderr)
print(
f"Sequence: {' -> '.join(str(p) + '%' for p, _ in sequence)} "
f"every {args.interval:g}s (heater off)",
file=sys.stderr,
)
print("Ctrl+C to stop\n", file=sys.stderr)
interrupted = False
with open_serial(port, args.baud) as ser:
ser.reset_input_buffer()
lines = send_command(ser, "target 0")
for line in lines:
print(line, flush=True)
if any(ERR_RE.match(line) for line in lines):
return 1
try:
while True:
for pct, pwm in sequence:
print(f">>> fan test {pwm} ({pct}%)", flush=True)
lines = send_command(ser, f"fan test {pwm}")
for line in lines:
print(f" {line}", flush=True)
if any(ERR_RE.match(line) for line in lines):
return 1
reported = drain_status(ser, args.interval)
if reported:
print(f" reported fan={reported}", flush=True)
if not args.loop:
break
except KeyboardInterrupt:
interrupted = True
print("\nInterrupted", file=sys.stderr)
finally:
print(">>> fan test 0 (stop)", flush=True)
send_command(ser, "fan test 0")
return 130 if interrupted else 0
if __name__ == "__main__":
raise SystemExit(main())

232
scripts/plot_logs.py Normal file
View File

@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Plot dryer CSV logs from capture_csv.py, the TUI, or firmware `log on`.
Example:
python3 scripts/plot_logs.py logs/dryer_20260707_195354.csv
python3 scripts/plot_logs.py logs/ # newest .csv in directory
python3 scripts/plot_logs.py remote # newest .csv on alex@10.81.16.44
python3 scripts/plot_logs.py logs/foo.csv -o plot.png
"""
from __future__ import annotations
import argparse
import csv
import subprocess
import sys
import tempfile
from pathlib import Path
REMOTE_SSH = "alex@10.81.16.44"
REMOTE_LOG_DIRS = (
"~/arduino-filament-dryer/logs",
"~/voron-filament-dryer/logs",
"~/logs",
)
def import_matplotlib():
try:
import matplotlib.pyplot as plt
except ImportError:
print("Install matplotlib: pip install matplotlib", file=sys.stderr)
raise SystemExit(1) from None
return plt
def fetch_remote_csv(remote_dirs: tuple[str, ...] = REMOTE_LOG_DIRS) -> Path:
dir_list = " ".join(remote_dirs)
find_cmd = (
f"for d in {dir_list}; do "
'if [ -d "$d" ]; then ls -t "$d"/*.csv 2>/dev/null; fi; done | head -1'
)
result = subprocess.run(
["ssh", REMOTE_SSH, find_cmd],
capture_output=True,
text=True,
check=False,
)
remote_path = result.stdout.strip()
if result.returncode != 0 or not remote_path:
err = result.stderr.strip()
print(f"No remote CSV found on {REMOTE_SSH}", file=sys.stderr)
if err:
print(err, file=sys.stderr)
raise SystemExit(1)
local_path = Path(tempfile.gettempdir()) / f"dryer_remote_{Path(remote_path).name}"
scp = subprocess.run(
["scp", f"{REMOTE_SSH}:{remote_path}", str(local_path)],
capture_output=True,
text=True,
check=False,
)
if scp.returncode != 0:
print(f"scp failed for {REMOTE_SSH}:{remote_path}", file=sys.stderr)
if scp.stderr.strip():
print(scp.stderr.strip(), file=sys.stderr)
raise SystemExit(1)
print(f"Fetched {REMOTE_SSH}:{remote_path}", file=sys.stderr)
return local_path
def resolve_csv(path: Path) -> Path:
if path.is_dir():
matches = sorted(path.glob("*.csv"), key=lambda p: p.stat().st_mtime, reverse=True)
if not matches:
print(f"No CSV files in {path}", file=sys.stderr)
raise SystemExit(1)
return matches[0]
if not path.is_file():
print(f"Not found: {path}", file=sys.stderr)
raise SystemExit(1)
return path
def load_rows(path: Path) -> tuple[list[str], list[dict[str, str]]]:
with path.open(newline="", encoding="utf-8") as fh:
reader = csv.DictReader(fh)
if reader.fieldnames is None:
print(f"Empty CSV: {path}", file=sys.stderr)
raise SystemExit(1)
rows = list(reader)
return list(reader.fieldnames), rows
def column_float(rows: list[dict[str, str]], name: str) -> list[float | None]:
out: list[float | None] = []
for row in rows:
raw = row.get(name, "").strip()
if not raw:
out.append(None)
continue
try:
out.append(float(raw))
except ValueError:
out.append(None)
return out
def time_axis(rows: list[dict[str, str]], fieldnames: list[str]) -> tuple[list[float], str]:
if "ms" in fieldnames:
ms = column_float(rows, "ms")
if any(v is not None for v in ms):
t0 = next(v for v in ms if v is not None)
return [((v or t0) - t0) / 60000.0 for v in ms], "minutes since start"
if "wall_time" in fieldnames:
from datetime import datetime
times: list[float] = []
parsed: list[datetime] = []
for row in rows:
raw = row.get("wall_time", "").strip()
if not raw:
continue
try:
parsed.append(datetime.fromisoformat(raw))
except ValueError:
continue
if parsed:
t0 = parsed[0]
for dt in parsed:
times.append((dt - t0).total_seconds() / 60.0)
return times, "minutes since start"
return [float(i) for i in range(len(rows))], "sample"
def plot_csv(path: Path, output: Path | None, title: str | None = None) -> None:
plt = import_matplotlib()
fieldnames, rows = load_rows(path)
if not rows:
print(f"No data rows in {path}", file=sys.stderr)
raise SystemExit(1)
x, x_label = time_axis(rows, fieldnames)
if len(x) != len(rows):
x = [float(i) for i in range(len(rows))]
x_label = "sample"
fig, axes = plt.subplots(3, 1, figsize=(11, 8), sharex=True, constrained_layout=True)
fig.suptitle(title or path.name)
temp_ax = axes[0]
for col, label, style in (
("avg_c", "avg", "-"),
("min_c", "min", "--"),
("max_c", "max", "--"),
("target_c", "target", ":"),
):
if col not in fieldnames:
continue
y = column_float(rows, col)
temp_ax.plot(x, y, style, label=label, linewidth=1.5 if col == "avg_c" else 1.0)
for ch in (2, 3, 4, 5):
col = f"ch{ch}_t"
if col in fieldnames:
y = column_float(rows, col)
temp_ax.plot(x, y, "-", alpha=0.35, linewidth=0.8, label=f"ch{ch}")
temp_ax.set_ylabel("°C")
temp_ax.legend(loc="upper left", ncol=4, fontsize=8)
temp_ax.grid(True, alpha=0.3)
duty_ax = axes[1]
if "heater_pct" in fieldnames:
duty_ax.plot(x, column_float(rows, "heater_pct"), "C1-", label="heater %")
if "heatlim_pct" in fieldnames:
duty_ax.plot(x, column_float(rows, "heatlim_pct"), "C1--", alpha=0.6, label="heatlim %")
if "fan_pct" in fieldnames:
duty_ax.plot(x, column_float(rows, "fan_pct"), "C0-", label="fan %")
duty_ax.set_ylabel("%")
duty_ax.legend(loc="upper left", fontsize=8)
duty_ax.grid(True, alpha=0.3)
spread_ax = axes[2]
if "spread_c" in fieldnames:
spread_ax.plot(x, column_float(rows, "spread_c"), "C2-", label="spread")
spread_ax.set_ylabel("°C")
spread_ax.set_xlabel(x_label)
spread_ax.legend(loc="upper left", fontsize=8)
spread_ax.grid(True, alpha=0.3)
if output is not None:
fig.savefig(output, dpi=150)
print(f"Wrote {output}")
else:
plt.show()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Plot dryer CSV temperature logs")
parser.add_argument(
"csv",
help='CSV file, directory (newest .csv), or "remote" for newest on ' + REMOTE_SSH,
)
parser.add_argument("-o", "--output", type=Path, help="Save PNG instead of opening a window")
parser.add_argument(
"--remote-dir",
action="append",
metavar="DIR",
help=f"Remote log directory on {REMOTE_SSH} (repeatable; used with remote)",
)
args = parser.parse_args(argv)
plot_title: str | None = None
if args.csv == "remote":
remote_dirs = tuple(args.remote_dir) if args.remote_dir else REMOTE_LOG_DIRS
path = fetch_remote_csv(remote_dirs)
plot_title = f"{REMOTE_SSH}:{path.name}"
else:
path = resolve_csv(Path(args.csv))
if args.output is None:
print(f"Plotting {plot_title or path}", file=sys.stderr)
plot_csv(path, args.output, title=plot_title)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1 +1,2 @@
pyserial>=3.5
matplotlib>=3.8

354
src/fan_characterize.cpp Normal file
View File

@@ -0,0 +1,354 @@
#include "fan_characterize.h"
FanCharacterize::FanCharacterize()
: phase_(Phase::Idle),
maxCornerC_(FANCHARS_MAX_CORNER_C),
coolAvgC_(FANCHARS_COOL_AVG_C),
heaterPct_(FANCHARS_HEATER_PCT),
profileIndex_(0),
refineFanPwm_(0),
refineRun_(false),
sessionStartMs_(0),
phaseStartMs_(0),
lastLogMs_(0),
spreadSum_(0.0f),
spreadSamples_(0),
resultCount_(0),
winnerFanPwm_(0) {}
bool FanCharacterize::isActive() const {
return phase_ == Phase::Precool || phase_ == Phase::Heat || phase_ == Phase::Hold ||
phase_ == Phase::Cooldown;
}
uint32_t FanCharacterize::elapsedMs(uint32_t nowMs) const {
if (sessionStartMs_ == 0) {
return 0;
}
return nowMs - sessionStartMs_;
}
uint8_t FanCharacterize::currentFanPwm() const {
if (profileIndex_ >= FANCHARS_COARSE_COUNT) {
return refineFanPwm_;
}
return FANCHARS_COARSE_PWM[profileIndex_];
}
const char *FanCharacterize::phaseName() const {
if (isRefineRun() && (phase_ == Phase::Heat || phase_ == Phase::Hold)) {
return "refine";
}
switch (phase_) {
case Phase::Precool:
return "precool";
case Phase::Heat:
return "heat";
case Phase::Hold:
return "hold";
case Phase::Cooldown:
return "cool";
default:
return "";
}
}
bool FanCharacterize::start(float maxCornerC, float avgTempC) {
if (maxCornerC < 45.0f || maxCornerC > EMERGENCY_ABSOLUTE_MAX_C - 5.0f) {
return false;
}
maxCornerC_ = maxCornerC;
heaterPct_ = FANCHARS_HEATER_PCT;
profileIndex_ = 0;
refineFanPwm_ = 0;
refineRun_ = false;
resultCount_ = 0;
winnerFanPwm_ = 0;
sessionStartMs_ = millis();
phaseStartMs_ = sessionStartMs_;
lastLogMs_ = 0;
resetProfileStats();
phase_ = avgTempC > coolAvgC_ + FANCHARS_PRECOOL_MARGIN_C ? Phase::Precool : Phase::Heat;
Serial.print(F("fanchars: "));
Serial.print(FANCHARS_COARSE_COUNT);
Serial.print(F(" fans + refine h="));
Serial.println(heaterPct_, 0);
return true;
}
void FanCharacterize::abort() {
if (isActive()) {
Serial.println(F("fanchars: stop"));
}
phase_ = Phase::Idle;
sessionStartMs_ = 0;
}
void FanCharacterize::reset() {
phase_ = Phase::Idle;
sessionStartMs_ = 0;
}
bool FanCharacterize::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_ABSOLUTE_MAX_C) {
fail(F("fanchars: abort limit"));
return false;
}
if (phase_ == Phase::Precool) {
fanPwmOut = FAN_MAX_PWM;
if (nowMs - phaseStartMs_ > FANCHARS_COOLDOWN_TIMEOUT_MS) {
fail(F("fanchars: abort precool"));
return false;
}
if (avgTempC <= coolAvgC_) {
beginProfileHeat(nowMs);
}
return true;
}
if (phase_ == Phase::Heat) {
fanPwmOut = currentFanPwm();
heaterDutyOut = heaterPct_;
if (nowMs - phaseStartMs_ > FANCHARS_HEAT_TIMEOUT_MS) {
skipProfile(nowMs, maxTempC);
return true;
}
if (maxTempC >= maxCornerC_) {
enterHold(nowMs);
}
return true;
}
if (phase_ == Phase::Hold) {
fanPwmOut = currentFanPwm();
spreadSum_ += spreadC;
++spreadSamples_;
if (nowMs - phaseStartMs_ >= FANCHARS_HOLD_MS) {
finishProfile(nowMs);
}
return true;
}
if (phase_ == Phase::Cooldown) {
fanPwmOut = FAN_MAX_PWM;
if (nowMs - phaseStartMs_ > FANCHARS_COOLDOWN_TIMEOUT_MS) {
fail(F("fanchars: abort cool"));
return false;
}
if (avgTempC <= coolAvgC_) {
beginProfileHeat(nowMs);
}
return true;
}
return false;
}
void FanCharacterize::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_ < FANCHARS_LOG_INTERVAL_MS) {
return;
}
lastLogMs_ = nowMs;
const uint8_t runNum =
profileIndex_ >= FANCHARS_COARSE_COUNT ? FANCHARS_COARSE_COUNT + 1 : profileIndex_ + 1;
Serial.print(F("fc,"));
Serial.print(nowMs);
Serial.print(',');
Serial.print(phaseName());
Serial.print(',');
Serial.print(runNum);
Serial.print('/');
Serial.print(FANCHARS_COARSE_COUNT + 1);
Serial.print(',');
Serial.print(currentFanPwm());
Serial.print(',');
Serial.print(heaterPct_, 0);
Serial.print(',');
Serial.print(avgTempC, 1);
Serial.print(',');
Serial.print(minTempC, 1);
Serial.print(',');
Serial.print(maxTempC, 1);
Serial.print(',');
Serial.println(spreadC, 1);
(void)sensorTemps;
(void)sensorValid;
(void)sensorCount;
}
void FanCharacterize::resetProfileStats() {
spreadSum_ = 0.0f;
spreadSamples_ = 0;
}
void FanCharacterize::beginProfileHeat(uint32_t nowMs) {
phase_ = Phase::Heat;
phaseStartMs_ = nowMs;
resetProfileStats();
Serial.print(F("fanchars: f="));
Serial.println(currentFanPwm());
}
void FanCharacterize::enterHold(uint32_t nowMs) {
phase_ = Phase::Hold;
phaseStartMs_ = nowMs;
resetProfileStats();
}
void FanCharacterize::finishProfile(uint32_t nowMs) {
if (spreadSamples_ == 0) {
fail(F("fanchars: abort hold"));
return;
}
const float meanSpread = spreadSum_ / static_cast<float>(spreadSamples_);
results_[resultCount_].fanPwm = currentFanPwm();
results_[resultCount_].meanSpreadC = meanSpread;
++resultCount_;
Serial.print(F("fanchars: f="));
Serial.print(currentFanPwm());
Serial.print(F(" spr="));
Serial.println(meanSpread, 2);
if (isRefineRun()) {
finishAll(nowMs);
return;
}
if (profileIndex_ + 1 >= FANCHARS_COARSE_COUNT) {
planRefine(nowMs);
return;
}
++profileIndex_;
phase_ = Phase::Cooldown;
phaseStartMs_ = nowMs;
}
void FanCharacterize::skipProfile(uint32_t nowMs, float maxTempC) {
Serial.print(F("fanchars: skip f="));
Serial.print(currentFanPwm());
Serial.print(F(" max="));
Serial.print(maxTempC, 1);
Serial.println(F("C"));
if (isRefineRun()) {
finishAll(nowMs);
return;
}
if (profileIndex_ + 1 >= FANCHARS_COARSE_COUNT) {
planRefine(nowMs);
return;
}
++profileIndex_;
phase_ = Phase::Cooldown;
phaseStartMs_ = nowMs;
}
void FanCharacterize::planRefine(uint32_t nowMs) {
if (resultCount_ < 2) {
Serial.println(F("fanchars: refine skip"));
finishAll(nowMs);
return;
}
uint8_t bestI = 0;
uint8_t secondI = 1;
if (results_[secondI].meanSpreadC < results_[bestI].meanSpreadC) {
bestI = 1;
secondI = 0;
}
for (uint8_t i = 2; i < resultCount_; ++i) {
if (results_[i].meanSpreadC < results_[bestI].meanSpreadC) {
secondI = bestI;
bestI = i;
} else if (results_[i].meanSpreadC < results_[secondI].meanSpreadC) {
secondI = i;
}
}
const uint8_t bestFan = results_[bestI].fanPwm;
if (bestFan <= FANCHARS_LIMIT_LOW_PWM || bestFan >= FANCHARS_LIMIT_HIGH_PWM) {
Serial.println(F("fanchars: limit"));
finishAll(nowMs);
return;
}
const uint8_t secondFan = results_[secondI].fanPwm;
refineFanPwm_ =
static_cast<uint8_t>((static_cast<uint16_t>(bestFan) + secondFan) / 2);
if (refineFanPwm_ == bestFan || refineFanPwm_ == secondFan) {
finishAll(nowMs);
return;
}
refineRun_ = true;
profileIndex_ = FANCHARS_COARSE_COUNT;
phase_ = Phase::Cooldown;
phaseStartMs_ = nowMs;
Serial.print(F("fanchars: mid f="));
Serial.println(refineFanPwm_);
}
void FanCharacterize::finishAll(uint32_t nowMs) {
phase_ = Phase::Done;
if (resultCount_ == 0) {
winnerFanPwm_ = 0;
Serial.println(F("fanchars: done — no valid runs"));
return;
}
uint8_t bestIndex = 0;
float bestSpread = results_[0].meanSpreadC;
for (uint8_t i = 1; i < resultCount_; ++i) {
if (results_[i].meanSpreadC < bestSpread) {
bestSpread = results_[i].meanSpreadC;
bestIndex = i;
}
}
winnerFanPwm_ = results_[bestIndex].fanPwm;
Serial.print(F("fanchars: done "));
Serial.print((nowMs - sessionStartMs_) / 60000UL);
Serial.println(F("min"));
for (uint8_t i = 0; i < resultCount_; ++i) {
Serial.print(F(" "));
Serial.print(results_[i].fanPwm);
Serial.print(F("="));
Serial.println(results_[i].meanSpreadC, 2);
}
Serial.print(F(" best "));
Serial.print(winnerFanPwm_);
Serial.print(F(" spr="));
Serial.println(bestSpread, 2);
Serial.println(F(" fanchars save"));
}
void FanCharacterize::fail(const __FlashStringHelper *reason) {
Serial.println(reason);
phase_ = Phase::Failed;
sessionStartMs_ = 0;
}

View File

@@ -51,6 +51,9 @@ void readAllSensors() {
readSensorOnChannel(SENSOR_CHANNELS[i], sensors[i]);
}
mux.disableAll();
if (Wire.getWireTimeoutFlag()) {
Wire.clearWireTimeoutFlag();
}
}
float averageValidTemperature() {
@@ -125,12 +128,35 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
Serial.print(thermal.heaterAllowance(), 0);
Serial.print(F("% heater="));
Serial.print(thermal.heaterDutyPercent(), 1);
Serial.print(F("% fan="));
Serial.print(F("% htop="));
if (thermal.isIdle()) {
Serial.print(F("n/a"));
} else {
Serial.print(thermal.maxHeatStopAt(avgTemp), 1);
}
Serial.print(F("C hblk="));
Serial.print(thermal.heaterBlockReason());
Serial.print(F(" ssr="));
Serial.print(thermal.isHeaterSsrOn() ? F("on") : F("off"));
Serial.print(F(" fan="));
Serial.print(thermal.fanPwm());
Serial.print(F("/255("));
Serial.print((thermal.fanPwm() * 100) / 255);
if (thermal.isIdle() && thermal.isFanOff()) {
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()) {
Serial.print(F("(cooldown)"));
} else if (thermal.isFanManualOverride()) {
Serial.print(F("(manual)"));
} else if (!thermal.isIdle()) {
Serial.print(F("(stir)"));
}
Serial.print(F(" cutoff="));
Serial.print(thermal.isCutoffActive() ? F("YES") : F("no"));
@@ -138,11 +164,43 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
Serial.print(thermal.isFailSafeActive() ? F("YES") : F("no"));
Serial.print(F(" mode="));
if (thermal.isAutotuning()) {
Serial.print(F("autotune"));
} else if (thermal.isAdaptive()) {
Serial.print(F("learned"));
Serial.print(F("autotune/"));
Serial.print(thermal.autotunePhaseName());
Serial.print(F(" "));
Serial.print(thermal.autotuneElapsedMs(millis()) / 1000UL);
Serial.print(F("s "));
Serial.print(thermal.autotuneCycleCount());
Serial.print(F("/"));
Serial.print(AUTOTUNE_CYCLES_REQUIRED);
Serial.print(F("cyc pre>="));
Serial.print(thermal.autotunePreheatTargetC(), 0);
Serial.print(F("C"));
} else if (thermal.isFanCharacterizeActive()) {
Serial.print(F("fanchars/"));
Serial.print(thermal.fanCharacterizePhaseName());
Serial.print(F(" "));
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(F("manual"));
Serial.print(thermal.fanCharacterizeProfileIndex() + 1);
}
Serial.print(F("/"));
Serial.print(thermal.fanCharacterizeProfileCount());
Serial.print(F(" fan="));
Serial.print(thermal.fanCharacterizeFanPwm());
Serial.print(F(" heat="));
Serial.print(thermal.fanCharacterizeHeaterPct(), 0);
Serial.print(F("%"));
} else {
Serial.print(thermal.regulatingModeName());
}
Serial.print(F(" sensors=["));
@@ -171,10 +229,18 @@ void printHelp() {
Serial.println(F(" target <C> set target (0 = idle)"));
Serial.println(F(" fan off cancel idle fan override (auto-off below 40C)"));
Serial.println(F(" fan on idle fan 30% (optional, auto-off below 40C)"));
Serial.println(F(" autotune [C] learn PID (default: 40C when idle)"));
Serial.println(F(" fan auto regulating: back to default stir fan"));
Serial.println(F(" fan <pwm> 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(" pid show PID / adaptive status"));
Serial.println(F(" pid default reset to factory PID"));
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 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(" status print current readings"));
Serial.println(F(" log on|off CSV data stream"));
Serial.println(F(" help show this message"));
@@ -249,6 +315,72 @@ void processSerialLine(const char *line) {
return;
}
if (strncmp(line, "fan test ", 9) == 0) {
const int pwm = atoi(line + 9);
if (pwm < 0 || pwm > 255) {
Serial.println(F("ERR fan test PWM must be 0-255"));
return;
}
thermal.stopFanTest();
thermal.startFanTest(static_cast<uint8_t>(pwm), millis());
Serial.print(F("OK fan PWM="));
Serial.print(pwm);
Serial.println(F(" for 15s — check speed/noise on D5"));
return;
}
if (strcmp(line, "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<uint8_t>(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 <pwm> only when regulating (or use fan test)"));
return;
}
thermal.setRegulatingFanManual(static_cast<uint8_t>(pwm));
Serial.print(F("OK fan manual "));
Serial.println(pwm);
return;
}
}
if (strcmp(line, "status") == 0) {
const float avgTemp = averageValidTemperature();
const float minTemp = minValidTemperature();
@@ -284,7 +416,45 @@ void processSerialLine(const char *line) {
Serial.println(F("ERR autotune already running"));
return;
}
Serial.println(F("OK autotune started — keep chamber closed, wait ~10-20 min"));
Serial.println(F("OK autotune started — preheat then relay, typically 15-40 min"));
return;
}
if (strncmp(line, "fanchars", 8) == 0) {
if (strcmp(line, "fanchars stop") == 0) {
thermal.stopFanCharacterize();
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;
}
float maxC = FANCHARS_MAX_CORNER_C;
if (line[8] == ' ') {
maxC = atof(line + 9);
}
if (maxC < 45.0f || maxC > EMERGENCY_ABSOLUTE_MAX_C - 5.0f) {
Serial.println(F("ERR fanchars max 45-65 C"));
return;
}
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;
}
@@ -298,6 +468,11 @@ void processSerialLine(const char *line) {
return;
}
if (strcmp(line, "pid save") == 0) {
thermal.saveTuningToEeprom();
return;
}
if (strcmp(line, "help") == 0) {
printHelp();
return;
@@ -349,6 +524,11 @@ void setup() {
}
Wire.begin();
// Without a timeout, a glitched I2C transaction (electrical noise, a
// momentary bad connection) can hang the AVR's Wire library forever,
// freezing the whole sketch. This bounds any transaction and resets the
// TWI hardware so the loop keeps running instead of locking up silently.
Wire.setWireTimeout(I2C_TIMEOUT_US, true);
if (!mux.begin()) {
Serial.println(F("ERROR: TCA9548A not found on I2C bus"));
@@ -388,6 +568,18 @@ void loop() {
if (!isnan(avgTemp) && !isnan(maxTemp) && !isnan(spread)) {
thermal.update(avgTemp, maxTemp, spread, now);
float sensorTemps[SENSOR_COUNT];
bool sensorValid[SENSOR_COUNT];
for (uint8_t i = 0; i < SENSOR_COUNT; ++i) {
sensorTemps[i] = sensors[i].temperatureC;
sensorValid[i] = sensors[i].valid;
}
const float minTemp = minValidTemperature();
if (thermal.isFanCharacterizeActive() && !isnan(minTemp)) {
thermal.logFanCharacterizeIfDue(sensorTemps, sensorValid, SENSOR_COUNT, avgTemp, minTemp,
maxTemp, spread, now);
}
} else {
thermal.enterFailSafe();
Serial.println(F("WARN: no valid sensor readings — heater off"));