Compare commits

..

10 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
20 changed files with 1821 additions and 447 deletions

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ compile_commands.json
.vscode/c_cpp_properties.json .vscode/c_cpp_properties.json
.vscode/launch.json .vscode/launch.json
.vscode/ipch .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. 1. Flash firmware and open the serial monitor at 115200 baud.
2. Confirm `TCA9548A detected` and four valid sensor channels (`ch2``ch5`). 2. Confirm `TCA9548A detected` and four valid sensor channels (`ch2``ch5`).
3. Run PID autotune once per physical unit (values are stored in EEPROM): 3. Find **stir fan** speed (optional — default is **PWM 178**, ~70%):
``` ```
target 0 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 ## 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) // SHT31 I2C address (ADDR pin low → 0x44, high → 0x45)
static const uint8_t SHT31_ADDRESS = 0x44; 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) // Outputs — D5 has hardware PWM; heater on A2 uses burst control (SSR-friendly)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -21,67 +25,81 @@ static const uint8_t HEATER_PIN = A2; // heater via solid-state relay
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Temperature control // Temperature control
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static const float TARGET_TEMP_C = 0.0f; // power-on default: idle (heater off) static const float TARGET_TEMP_C = 0.0f;
static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f; // autotune when no temp given and idle static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f;
static const float TARGET_MIN_C = 0.0f; // 0 = idle (heater off, fan at idle speed) static const float TARGET_MIN_C = 0.0f;
static const float TARGET_MAX_C = 80.0f; static const float TARGET_MAX_C = 80.0f;
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 inline float emergencyCutoffForTarget(float targetC) {
static const float PID_KP = 4.0f; if (targetC <= 0.0f) {
static const float PID_KI = 0.05f; return EMERGENCY_ABSOLUTE_MAX_C;
static const float PID_KD = 6.0f; }
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 // Heat PI on average temp (no D term)
static const float HEATER_MAX_DUTY_COLD = 85.0f; // avg >=10 °C below target static const float HEAT_PI_KP = 4.0f;
static const float HEATER_MAX_DUTY_MID = 65.0f; // avg 310 °C below target static const float HEAT_PI_KI = 0.05f;
static const float HEATER_MAX_DUTY_NEAR = 45.0f; // avg <3 °C below target
static const float HEATER_COLD_BELOW_C = 10.0f;
static const float HEATER_WARM_BELOW_C = 3.0f;
// Below this band from target, only the hard cutoff limits max-corner (full heat-up)
static const float CORNER_LIMIT_BAND_C = 10.0f;
// Ramp-up limit (% per second) — still caps sudden jumps // Fixed circulation fan (~70%, fanchars winner); override with "fan <pwm>" when regulating
static const float HEATER_SLEW_UP_PER_S = 18.0f; static const uint8_t FAN_STIR_PWM = 178;
// Hot-corner limiter: taper heater as max corner approaches stop temperature // Legacy aliases for autotuner relay math only
static const float MAX_TEMP_HEADROOM_C = 15.0f; static const float PID_KP = HEAT_PI_KP;
static const float PID_KI = HEAT_PI_KI;
// Average-temp approach: taper only in the last few °C before setpoint static const float PID_KD = 0.0f;
static const float APPROACH_BAND_C = 4.0f;
// When spread is good, allow hottest corner slightly above target so avg can reach setpoint
static const float GOOD_SPREAD_C = 5.0f; static const float GOOD_SPREAD_C = 5.0f;
static const float BALANCED_MAX_ABOVE_TARGET_C = 2.0f;
static const float SPREAD_HEADROOM_FACTOR = 0.5f; // extra max-corner °C per °C of spread // 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; static const uint16_t HEATER_CYCLE_MS = 3000;
// Fan PWM (0255) // Fan PWM — stir speed when target > 0; off below 40°C only when idle (target 0)
static const uint8_t FAN_IDLE_PWM = 77; // ~30 % — optional override via "fan on" static const uint8_t FAN_IDLE_PWM = 77;
static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f; // idle: fans off when max corner below this static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f;
static const uint8_t FAN_MIX_MIN_PWM = 70; // ~27 % — light mixing when spread rises static const uint8_t FAN_MAX_PWM = 255;
static const uint8_t FAN_HEAT_MIN_PWM = 100; // ~39 % — floor while heating (near setpoint) static const bool FAN_PWM_INVERT = true;
static const uint8_t FAN_HEAT_MAX_PWM = 140; // ~55 % — cap during heat-up
static const float FAN_OFF_BELOW_TARGET_C = 8.0f; // no heat-up fan when avg this far below target
static const float FAN_RAMP_BELOW_TARGET_C = 15.0f; // fan ramps in between this and FAN_OFF_BELOW
static const uint8_t FAN_MIX_MAX_PWM = 200; // ~78 % — cap for spread-driven mixing
static const uint8_t FAN_MAX_PWM = 255; // failsafe / over-temp only
// Corner mixing — moderate airflow; full speed reserved for safety
static const float SPREAD_DEADBAND_C = 0.5f;
static const float SPREAD_FULL_MIX_C = 8.0f;
static const float SPREAD_EMA_ALPHA = 0.45f; static const float SPREAD_EMA_ALPHA = 0.45f;
// PID auto-tune (relay method) — run with: autotune 45 // PID auto-tune (relay method) — heat PI only
static const float AUTOTUNE_HYSTERESIS_C = 0.4f; static const float AUTOTUNE_HYSTERESIS_C = 0.4f;
static const float AUTOTUNE_PREHEAT_BAND_C = 5.0f; static const float AUTOTUNE_PREHEAT_BAND_C = 3.0f;
static const float AUTOTUNE_PREHEAT_DUTY = 100.0f; static const float AUTOTUNE_PREHEAT_DUTY = 100.0f;
static const uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 70; // low fan for entire autotune static const uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 0;
static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 6; static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 5;
static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL; // 20 min static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL;
static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL; // 60 min total static const uint32_t AUTOTUNE_RELAY_STALL_MS = 1500000UL;
static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL; // count periods up to 40 min 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 // Timing
@@ -89,4 +107,4 @@ static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL; // count perio
static const uint32_t SENSOR_READ_INTERVAL_MS = 1000; static const uint32_t SENSOR_READ_INTERVAL_MS = 1000;
static const uint32_t CONTROL_INTERVAL_MS = 500; static const uint32_t CONTROL_INTERVAL_MS = 500;
static const uint32_t SERIAL_REPORT_INTERVAL_MS = 2000; static const uint32_t SERIAL_REPORT_INTERVAL_MS = 2000;
static const bool LOG_CSV_DEFAULT = false; // enable with serial command: log on static const bool LOG_CSV_DEFAULT = false;

View File

@@ -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,12 +24,11 @@ public:
spreadSamples_(0), spreadSamples_(0),
cycleCount_(0), cycleCount_(0),
aboveSetpoint_(false), aboveSetpoint_(false),
useMaxSensorPv_(false),
sessionStartMs_(0), sessionStartMs_(0),
phaseStartMs_(0), phaseStartMs_(0),
resultKp_(PID_KP), resultKp_(HEAT_PI_KP),
resultKi_(PID_KI), resultKi_(HEAT_PI_KI) {}
resultKd_(PID_KD),
resultFanMixMax_(FAN_MIX_MAX_PWM) {}
Phase phase() const { return phase_; } Phase phase() const { return phase_; }
@@ -47,12 +46,14 @@ public:
float preheatTargetC() const { return setpointC_ - AUTOTUNE_PREHEAT_BAND_C; } float preheatTargetC() const { return setpointC_ - AUTOTUNE_PREHEAT_BAND_C; }
bool usesMaxSensor() const { return useMaxSensorPv_; }
const char *phaseName() const { const char *phaseName() const {
switch (phase_) { switch (phase_) {
case Phase::Preheat: case Phase::Preheat:
return "preheat"; return "preheat";
case Phase::Relay: case Phase::Relay:
return "relay"; return useMaxSensorPv_ ? "relay-max" : "relay-avg";
default: default:
return ""; return "";
} }
@@ -74,7 +75,7 @@ public:
Serial.print(preheatTargetC(), 1); Serial.print(preheatTargetC(), 1);
Serial.print(F("-")); Serial.print(F("-"));
Serial.print(setpointC_, 1); Serial.print(setpointC_, 1);
Serial.println(F("C avg")); Serial.println(F("C avg (heat PI only)"));
return true; return true;
} }
@@ -95,20 +96,18 @@ public:
float resultKp() const { return resultKp_; } float resultKp() const { return resultKp_; }
float resultKi() const { return resultKi_; } float resultKi() const { return resultKi_; }
float resultKd() const { return resultKd_; }
uint8_t resultFanMixMax() const { return resultFanMixMax_; }
Phase update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut, Phase update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut,
uint8_t &fanPwmOut) { uint8_t &fanPwmOut) {
heaterDutyOut = 0.0f; heaterDutyOut = 0.0f;
fanPwmOut = FAN_HEAT_MIN_PWM; fanPwmOut = AUTOTUNE_PREHEAT_FAN_PWM;
if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) { if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) {
return phase_; return phase_;
} }
if (maxTempC >= TARGET_MAX_C - 1.0f) { if (maxTempC >= emergencyCutoffForTarget(setpointC_)) {
fail(F("autotune: abort — max sensor at safety limit")); fail(F("autotune: abort — max sensor at emergency limit"));
return phase_; return phase_;
} }
@@ -124,8 +123,8 @@ public:
fail(F("autotune: abort — preheat timeout")); fail(F("autotune: abort — preheat timeout"));
return phase_; return phase_;
} }
if (avgTempC >= preheatTargetC()) { if (avgTempC >= preheatTargetC() || maxTempC >= setpointC_ - 2.0f) {
enterRelay(avgTempC, nowMs); enterRelay(avgTempC, maxTempC, spreadC, nowMs);
} else { } else {
heaterDutyOut = AUTOTUNE_PREHEAT_DUTY; heaterDutyOut = AUTOTUNE_PREHEAT_DUTY;
} }
@@ -137,27 +136,39 @@ public:
return phase_; 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; spreadSum_ += spreadC;
++spreadSamples_; ++spreadSamples_;
if (avgTempC > peakSinceCross_) { if (pv > peakSinceCross_) {
peakSinceCross_ = avgTempC; peakSinceCross_ = pv;
} }
if (avgTempC < valleySinceCross_) { if (pv < valleySinceCross_) {
valleySinceCross_ = avgTempC; valleySinceCross_ = pv;
} }
bool heatOn = false; bool heatOn = false;
if (avgTempC <= relayLow_) { if (pv <= relayLow_) {
heatOn = true; heatOn = true;
} else if (avgTempC >= relayHigh_) { } else if (pv >= relayHigh_) {
heatOn = false; heatOn = false;
} else { } else {
heatOn = !aboveSetpoint_; heatOn = !aboveSetpoint_;
} }
heaterDutyOut = heatOn ? 100.0f : 0.0f; heaterDutyOut = heatOn ? 100.0f : 0.0f;
const bool nowAbove = avgTempC >= setpointC_; const bool nowAbove = pv >= setpointC_;
if (nowAbove != aboveSetpoint_) { if (nowAbove != aboveSetpoint_) {
onSetpointCrossing(nowMs); onSetpointCrossing(nowMs);
aboveSetpoint_ = nowAbove; aboveSetpoint_ = nowAbove;
@@ -167,14 +178,18 @@ public:
} }
private: private:
void enterRelay(float avgTempC, uint32_t nowMs) { void enterRelay(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs) {
phase_ = Phase::Relay; phase_ = Phase::Relay;
phaseStartMs_ = nowMs; phaseStartMs_ = nowMs;
aboveSetpoint_ = avgTempC >= setpointC_; useMaxSensorPv_ = spreadC > GOOD_SPREAD_C;
peakSinceCross_ = avgTempC; const float pv = useMaxSensorPv_ ? maxTempC : avgTempC;
valleySinceCross_ = avgTempC; aboveSetpoint_ = pv >= setpointC_;
peakSinceCross_ = pv;
valleySinceCross_ = pv;
lastCrossMs_ = 0; lastCrossMs_ = 0;
Serial.print(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.print((nowMs - sessionStartMs_) / 1000UL);
Serial.println(F("s preheat)")); Serial.println(F("s preheat)"));
} }
@@ -191,6 +206,7 @@ private:
spreadSamples_ = 0; spreadSamples_ = 0;
cycleCount_ = 0; cycleCount_ = 0;
aboveSetpoint_ = false; aboveSetpoint_ = false;
useMaxSensorPv_ = false;
} }
void onSetpointCrossing(uint32_t nowMs) { void onSetpointCrossing(uint32_t nowMs) {
@@ -239,7 +255,6 @@ private:
const float ku = (4.0f * 100.0f) / (PI * avgAmplitude); const float ku = (4.0f * 100.0f) / (PI * avgAmplitude);
resultKp_ = 0.45f * ku; resultKp_ = 0.45f * ku;
resultKi_ = resultKp_ / (2.2f * avgPeriodSec); resultKi_ = resultKp_ / (2.2f * avgPeriodSec);
resultKd_ = resultKp_ * avgPeriodSec / 6.3f;
if (resultKp_ < 0.5f) { if (resultKp_ < 0.5f) {
resultKp_ = 0.5f; resultKp_ = 0.5f;
@@ -248,30 +263,15 @@ private:
resultKi_ = resultKp_ / 3.0f; resultKi_ = resultKp_ / 3.0f;
} }
resultFanMixMax_ = FAN_MIX_MAX_PWM;
const float spreadAvg =
spreadSamples_ > 0 ? spreadSum_ / static_cast<float>(spreadSamples_) : GOOD_SPREAD_C;
if (spreadAvg > GOOD_SPREAD_C) {
const float boost = 1.0f + ((spreadAvg - GOOD_SPREAD_C) / 10.0f);
int boosted = static_cast<int>(static_cast<float>(FAN_MIX_MAX_PWM) * boost);
if (boosted > static_cast<int>(FAN_MAX_PWM) - 20) {
boosted = FAN_MAX_PWM - 20;
}
resultFanMixMax_ = static_cast<uint8_t>(boosted);
}
phase_ = Phase::Done; phase_ = Phase::Done;
Serial.print(F("autotune: done in ")); Serial.print(F("autotune: done in "));
Serial.print((nowMs - sessionStartMs_) / 1000UL); Serial.print((nowMs - sessionStartMs_) / 1000UL);
Serial.println(F("s")); Serial.println(F("s"));
Serial.print(F(" Kp=")); Serial.print(F(" heat Kp="));
Serial.print(resultKp_, 3); Serial.print(resultKp_, 3);
Serial.print(F(" Ki=")); Serial.print(F(" Ki="));
Serial.print(resultKi_, 4); Serial.println(resultKi_, 4);
Serial.print(F(" Kd=")); Serial.println(F(" tune heat PI: pid save after autotune"));
Serial.print(resultKd_, 3);
Serial.print(F(" fanMixMax="));
Serial.println(resultFanMixMax_);
} }
void fail(const __FlashStringHelper *reason) { void fail(const __FlashStringHelper *reason) {
@@ -295,10 +295,9 @@ private:
uint16_t spreadSamples_; uint16_t spreadSamples_;
uint8_t cycleCount_; uint8_t cycleCount_;
bool aboveSetpoint_; bool aboveSetpoint_;
bool useMaxSensorPv_;
uint32_t sessionStartMs_; uint32_t sessionStartMs_;
uint32_t phaseStartMs_; uint32_t phaseStartMs_;
float resultKp_; float resultKp_;
float resultKi_; float resultKi_;
float resultKd_;
uint8_t resultFanMixMax_;
}; };

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_STIR_PWM from config
}; };
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;
data.magic = SETTINGS_MAGIC; if (settingsLoad(data)) {
data.targetC = targetC; 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); settingsSave(data);
} }

View File

@@ -3,6 +3,7 @@
#include <Arduino.h> #include <Arduino.h>
#include "config.h" #include "config.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"
@@ -10,20 +11,24 @@
class ThermalController { class ThermalController {
public: public:
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Allow, Autotune }; enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, FanChars };
ThermalController() ThermalController()
: pid_(PID_KP, PID_KI, PID_KD, 0.0f, 100.0f), : heatPi_(HEAT_PI_KP, HEAT_PI_KI, 0.0f, 0.0f, 100.0f),
autotuner_(), autotuner_(),
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),
fanPwm_(FAN_MAX_PWM), regulatingFanPwm_(0),
fanMixMax_(FAN_MIX_MAX_PWM), stirFanPwm_(FAN_STIR_PWM),
adaptiveEnabled_(false), fanManualPwm_(0),
fanPwm_(0),
tuningLoaded_(false),
fanIdleOverride_(false), fanIdleOverride_(false),
fanManualActive_(false),
sensorWarmValid_(false), sensorWarmValid_(false),
cutoffActive_(false), cutoffActive_(false),
failSafeActive_(true), failSafeActive_(true),
@@ -40,13 +45,13 @@ public:
pinMode(FAN_PIN, OUTPUT); pinMode(FAN_PIN, OUTPUT);
pinMode(HEATER_PIN, OUTPUT); pinMode(HEATER_PIN, OUTPUT);
digitalWrite(HEATER_PIN, LOW); digitalWrite(HEATER_PIN, LOW);
writeFan(0);
pid_.setSetpoint(targetTempC_); heatPi_.setSetpoint(targetTempC_);
pid_.reset(); heatPi_.reset();
heaterCycleStartMs_ = millis(); heaterCycleStartMs_ = millis();
lastHeaterUpdateMs_ = 0; lastHeaterUpdateMs_ = 0;
failSafeActive_ = true; failSafeActive_ = true;
fanPwm_ = FAN_MAX_PWM;
cornerSpreadC_ = 0.0f; cornerSpreadC_ = 0.0f;
lastMaxTempC_ = 0.0f; lastMaxTempC_ = 0.0f;
sensorWarmValid_ = false; sensorWarmValid_ = false;
@@ -59,58 +64,64 @@ public:
TuningData stored; TuningData stored;
if (tuningLoad(stored)) { if (tuningLoad(stored)) {
applyTuning(stored); applyTuning(stored);
Serial.println(F("Loaded learned PID from EEPROM")); Serial.println(F("Loaded learned PI from EEPROM"));
printTuning(); printTuning();
} }
SettingsData settings; SettingsData settings;
if (settingsLoad(settings) && settings.targetC >= TARGET_MIN_C && if (settingsLoad(settings)) {
settings.targetC <= TARGET_MAX_C) { if (settings.stirFanPwm > 0) {
setTarget(settings.targetC, false); stirFanPwm_ = settings.stirFanPwm;
}
if (settings.targetC >= TARGET_MIN_C && settings.targetC <= TARGET_MAX_C) {
setTarget(settings.targetC, false);
}
} }
} }
void applyTuning(const TuningData &data) { void applyTuning(const TuningData &data) {
pid_.setTunings(data.kp, data.ki, data.kd); heatPi_.setTunings(data.heatKp, data.heatKi, 0.0f);
fanMixMax_ = data.fanMixMax; tuningLoaded_ = true;
adaptiveEnabled_ = true;
} }
void clearTuning() { void clearTuning() {
adaptiveEnabled_ = false; tuningLoaded_ = false;
fanMixMax_ = FAN_MIX_MAX_PWM; heatPi_.setTunings(HEAT_PI_KP, HEAT_PI_KI, 0.0f);
pid_.setTunings(PID_KP, PID_KI, PID_KD);
tuningClear(); tuningClear();
pid_.reset(); heatPi_.reset();
Serial.println(F("PID reset to defaults")); Serial.println(F("PI reset to defaults"));
} }
void printTuning() const { void printTuning() const {
Serial.print(F("PID Kp=")); Serial.print(F("Heat PI Kp="));
Serial.print(pidKp(), 3); Serial.print(heatPi_.kp(), 3);
Serial.print(F(" Ki=")); Serial.print(F(" Ki="));
Serial.print(pidKi(), 4); Serial.print(heatPi_.ki(), 4);
Serial.print(F(" Kd=")); Serial.print(F(" tuned="));
Serial.print(pidKd(), 3); Serial.println(tuningLoaded_ ? F("yes") : F("no"));
Serial.print(F(" fanMixMax="));
Serial.print(fanMixMax_);
Serial.print(F(" adaptive="));
Serial.println(adaptiveEnabled_ ? F("yes") : F("no"));
} }
float pidKp() const { return pid_.kp(); } void saveTuningToEeprom() {
float pidKi() const { return pid_.ki(); } TuningData data;
float pidKd() const { return pid_.kd(); } data.magic = TUNING_MAGIC;
data.heatKp = heatPi_.kp();
data.heatKi = heatPi_.ki();
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) { bool startAutotune(float setpointC) {
if (autotuner_.isActive()) { if (autotuner_.isActive() || fanchars_.isActive()) {
return false; return false;
} }
adaptiveEnabled_ = false;
cutoffActive_ = false; cutoffActive_ = false;
pid_.reset(); heatPi_.reset();
return autotuner_.start(setpointC); return autotuner_.start(setpointC);
} }
@@ -128,28 +139,114 @@ public:
float autotunePreheatTargetC() const { return autotuner_.preheatTargetC(); } 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() { bool commitAutotuneIfDone() {
if (autotuner_.phase() != PidAutotuner::Phase::Done) { if (autotuner_.phase() != PidAutotuner::Phase::Done) {
return false; return false;
} }
heatPi_.setTunings(autotuner_.resultKp(), autotuner_.resultKi(), 0.0f);
heatPi_.reset();
TuningData data; TuningData data;
data.magic = TUNING_MAGIC; data.magic = TUNING_MAGIC;
data.kp = autotuner_.resultKp(); data.heatKp = autotuner_.resultKp();
data.ki = autotuner_.resultKi(); data.heatKi = autotuner_.resultKi();
data.kd = autotuner_.resultKd();
data.fanMixMax = autotuner_.resultFanMixMax();
tuningSave(data); tuningSave(data);
applyTuning(data); tuningLoaded_ = true;
autotuner_.reset(); autotuner_.reset();
Serial.println(F("Saved learned PID to EEPROM")); Serial.println(F("Saved heat PI to EEPROM"));
return true; return true;
} }
void setTarget(float targetC, bool persist = true) { void setTarget(float targetC, bool persist = true) {
targetTempC_ = targetC; targetTempC_ = targetC;
pid_.setSetpoint(targetC); heatPi_.setSetpoint(targetC);
pid_.reset(); heatPi_.reset();
fanManualActive_ = false;
cutoffActive_ = false; cutoffActive_ = false;
if (targetC > 0.0f) { if (targetC > 0.0f) {
fanIdleOverride_ = false; fanIdleOverride_ = false;
@@ -202,11 +299,13 @@ public:
bool isIdle() const { return targetTempC_ <= 0.0f; } bool isIdle() const { return targetTempC_ <= 0.0f; }
float emergencyCutoffC() const { return emergencyCutoffForTarget(targetTempC_); }
float cutoffThreshold() const { float cutoffThreshold() const {
if (isIdle()) { if (isIdle()) {
return INFINITY; return INFINITY;
} }
return targetTempC_ * (1.0f + OVERTEMP_FRACTION); return emergencyCutoffC();
} }
bool isCutoffActive() const { return cutoffActive_; } bool isCutoffActive() const { return cutoffActive_; }
@@ -237,7 +336,12 @@ public:
void stopFanTest() { fanTestActive_ = false; } void stopFanTest() { fanTestActive_ = false; }
float maxHeatStopAt(float avgTempC) const { return maxHeatStopTemp(avgTempC); } float maxHeatStopAt(float avgTempC) const {
if (isIdle()) {
return INFINITY;
}
return maxHeatStopTemp(avgTempC);
}
const char *heaterBlockReason() const { const char *heaterBlockReason() const {
switch (heaterBlock_) { switch (heaterBlock_) {
@@ -245,15 +349,19 @@ public:
return "cutoff"; return "cutoff";
case HeaterBlock::Corner: case HeaterBlock::Corner:
return "corner"; return "corner";
case HeaterBlock::Allow:
return "allow";
case HeaterBlock::Autotune: case HeaterBlock::Autotune:
return "autotune"; return "autotune";
case HeaterBlock::FanChars:
return "fanchars";
default: default:
return "none"; return "none";
} }
} }
const char *regulatingModeName() const {
return tuningLoaded_ ? "regulating" : "manual";
}
void update(float avgTempC, float maxTempC, float cornerSpreadC, uint32_t nowMs) { void update(float avgTempC, float maxTempC, float cornerSpreadC, uint32_t nowMs) {
failSafeActive_ = false; failSafeActive_ = false;
noteSensorMax(maxTempC); noteSensorMax(maxTempC);
@@ -261,8 +369,13 @@ public:
heaterBlock_ = HeaterBlock::None; heaterBlock_ = HeaterBlock::None;
cornerSpreadC_ = cornerSpreadC_ =
SPREAD_EMA_ALPHA * cornerSpreadC + SPREAD_EMA_ALPHA * cornerSpreadC_ +
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC_; (1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC;
if (fanchars_.isActive()) {
updateFanCharacterize(avgTempC, maxTempC, nowMs);
return;
}
if (autotuner_.isActive()) { if (autotuner_.isActive()) {
updateAutotune(avgTempC, maxTempC, nowMs); updateAutotune(avgTempC, maxTempC, nowMs);
@@ -272,21 +385,16 @@ public:
if (isIdle()) { if (isIdle()) {
forceHeaterOff(); forceHeaterOff();
cutoffActive_ = false; cutoffActive_ = false;
pid_.reset(); heatPi_.reset();
lastHeaterUpdateMs_ = nowMs; lastHeaterUpdateMs_ = nowMs;
applyFan(nowMs); applyFan(nowMs);
return; return;
} }
if (adaptiveEnabled_) { updateRegulating(avgTempC, maxTempC, nowMs);
updateAdaptive(avgTempC, maxTempC, nowMs);
} else {
updateLegacy(avgTempC, maxTempC, nowMs);
}
lastHeaterUpdateMs_ = nowMs; lastHeaterUpdateMs_ = nowMs;
applyHeaterBurst(nowMs); applyHeaterBurst(nowMs);
applyFan(nowMs); writeFan(regulatingFanPwm_);
} }
void enterFailSafe() { void enterFailSafe() {
@@ -294,8 +402,9 @@ public:
cutoffActive_ = false; cutoffActive_ = false;
forceHeaterOff(); forceHeaterOff();
applyFan(millis()); applyFan(millis());
pid_.reset(); heatPi_.reset();
autotuner_.abort(); autotuner_.abort();
fanchars_.abort();
} }
void forceHeaterOff() { void forceHeaterOff() {
@@ -307,19 +416,49 @@ public:
void writeFan(uint8_t pwm) { void writeFan(uint8_t pwm) {
fanPwm_ = pwm; fanPwm_ = pwm;
if (pwm == 0) { pinMode(FAN_PIN, OUTPUT);
// Re-assert output and stop Timer0 PWM on D5 — analogWrite(0) can leave the pin driving
pinMode(FAN_PIN, OUTPUT); if (FAN_PWM_INVERT) {
digitalWrite(FAN_PIN, LOW); if (pwm == 0) {
} else { digitalWrite(FAN_PIN, HIGH);
analogWrite(FAN_PIN, pwm); 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: 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) { void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
float duty = 0.0f; float duty = 0.0f;
uint8_t fan = FAN_HEAT_MIN_PWM; uint8_t fan = stirFanPwm_;
autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan); autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
heaterDutyPercent_ = duty; heaterDutyPercent_ = duty;
@@ -327,99 +466,47 @@ private:
heaterOn_ = duty >= 50.0f; heaterOn_ = duty >= 50.0f;
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW); digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
heaterBlock_ = duty > 0.0f ? HeaterBlock::None : HeaterBlock::Autotune; heaterBlock_ = duty > 0.0f ? HeaterBlock::None : HeaterBlock::Autotune;
writeFan(fan); writeFan(fanManualActive_ ? fanManualPwm_ : stirFanPwm_);
lastHeaterUpdateMs_ = nowMs; lastHeaterUpdateMs_ = nowMs;
commitAutotuneIfDone(); commitAutotuneIfDone();
} }
void updateAdaptive(float avgTempC, float maxTempC, uint32_t nowMs) { void updateRegulating(float avgTempC, float maxTempC, uint32_t nowMs) {
const float cutoff = cutoffThreshold(); const float cutoffC = emergencyCutoffC();
if (maxTempC >= cutoffC) {
if (maxTempC >= cutoff) {
cutoffActive_ = true; cutoffActive_ = true;
heaterDutyPercent_ = 0.0f; heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f; heaterAllowancePercent_ = 0.0f;
heaterOn_ = false; regulatingFanPwm_ = FAN_MAX_PWM;
heaterBlock_ = HeaterBlock::Cutoff; heaterBlock_ = HeaterBlock::Cutoff;
pid_.reset(); heatPi_.reset();
return; return;
} }
if (cutoffActive_ && maxTempC <= targetTempC_) { if (cutoffActive_ && maxTempC < cutoffC - CUTOFF_RECOVERY_BAND_C) {
cutoffActive_ = false; cutoffActive_ = false;
pid_.reset(); heatPi_.reset();
} }
if (cutoffActive_) { if (cutoffActive_) {
heaterBlock_ = HeaterBlock::Cutoff; heaterBlock_ = HeaterBlock::Cutoff;
regulatingFanPwm_ = FAN_MAX_PWM;
return; return;
} }
if (shouldLimitMaxCorner(avgTempC) && maxTempC >= maxHeatStopTemp(avgTempC)) { heaterAllowancePercent_ = allowanceFromMaxCorner(maxTempC, avgTempC);
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
heaterBlock_ = HeaterBlock::Corner;
pid_.reset();
return;
}
float duty = pid_.compute(avgTempC, nowMs); float duty = heatPi_.compute(avgTempC, nowMs);
const float below = targetTempC_ - avgTempC; duty = clampPercent(duty);
if (below > 8.0f) {
const float floor = below > 15.0f ? 75.0f : 60.0f;
if (duty < floor) {
duty = floor;
}
}
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;
heaterBlock_ = HeaterBlock::Cutoff;
pid_.reset();
return;
}
if (cutoffActive_ && maxTempC <= targetTempC_) {
cutoffActive_ = false;
pid_.reset();
}
if (cutoffActive_) {
heaterBlock_ = HeaterBlock::Cutoff;
return;
}
if (shouldLimitMaxCorner(avgTempC) && maxTempC >= maxHeatStopTemp(avgTempC)) {
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
heaterBlock_ = HeaterBlock::Corner;
pid_.reset();
return;
}
const float pidOut = pid_.compute(avgTempC, nowMs);
heaterAllowancePercent_ = heaterAllowancePercent(avgTempC, maxTempC);
float duty = pidOut;
if (duty > heaterAllowancePercent_) { if (duty > heaterAllowancePercent_) {
duty = heaterAllowancePercent_; duty = heaterAllowancePercent_;
if (heaterAllowancePercent_ < 100.0f) {
heaterBlock_ = HeaterBlock::Corner;
}
} }
if (duty <= 0.0f && heaterAllowancePercent_ <= 0.0f) { heaterDutyPercent_ = duty;
heaterBlock_ = HeaterBlock::Allow;
} regulatingFanPwm_ = fanManualActive_ ? fanManualPwm_ : stirFanPwm_;
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
} }
static float clampPercent(float value) { static float clampPercent(float value) {
@@ -432,37 +519,25 @@ private:
return value; return value;
} }
bool isBalancedChamber() const { return cornerSpreadC_ <= GOOD_SPREAD_C; }
bool shouldLimitMaxCorner(float avgTempC) const { bool shouldLimitMaxCorner(float avgTempC) const {
return avgTempC >= targetTempC_ - CORNER_LIMIT_BAND_C; return avgTempC >= targetTempC_ - CORNER_LIMIT_BAND_C;
} }
float maxHeatStopTemp(float avgTempC) const { float maxHeatStopTemp(float avgTempC) const {
if (avgTempC >= targetTempC_) { if (!shouldLimitMaxCorner(avgTempC)) {
return targetTempC_; return emergencyCutoffC();
} }
return emergencyCutoffC() - CORNER_STOP_MARGIN_C;
float stopAt = targetTempC_;
if (isBalancedChamber()) {
stopAt = targetTempC_ + BALANCED_MAX_ABOVE_TARGET_C;
} else {
stopAt = targetTempC_ + cornerSpreadC_ * SPREAD_HEADROOM_FACTOR + 1.0f;
}
const float cutoff = cutoffThreshold();
if (stopAt > cutoff) {
stopAt = cutoff;
}
return stopAt;
} }
float allowanceFromMaxCorner(float maxTempC, float avgTempC) const { float allowanceFromMaxCorner(float maxTempC, float avgTempC) const {
const float cutoffC = emergencyCutoffC();
if (!shouldLimitMaxCorner(avgTempC)) { if (!shouldLimitMaxCorner(avgTempC)) {
return 100.0f; // Heat-up: only taper when a hot corner nears the dynamic cutoff
} if (maxTempC >= cutoffC - 3.0f) {
const float headroom = cutoffC - maxTempC;
if (isBalancedChamber() && avgTempC < targetTempC_) { return clampPercent((headroom / 3.0f) * 100.0f);
}
return 100.0f; return 100.0f;
} }
@@ -479,111 +554,6 @@ private:
return clampPercent((headroom / MAX_TEMP_HEADROOM_C) * 100.0f); return clampPercent((headroom / MAX_TEMP_HEADROOM_C) * 100.0f);
} }
float allowanceFromAverage(float avgTempC) const {
if (avgTempC >= targetTempC_) {
return 0.0f;
}
const float below = targetTempC_ - avgTempC;
if (below >= APPROACH_BAND_C) {
return 100.0f;
}
return clampPercent((below / APPROACH_BAND_C) * 100.0f);
}
float heaterMaxDuty(float avgTempC) const {
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 fanPwmForHeatUp() const {
if (heaterDutyPercent_ <= 0.0f) {
return 0;
}
const float below = targetTempC_ - lastAvgTempC_;
uint8_t heatFan = 0;
if (below <= FAN_OFF_BELOW_TARGET_C) {
const uint8_t span = FAN_HEAT_MAX_PWM - FAN_HEAT_MIN_PWM;
heatFan = FAN_HEAT_MIN_PWM +
static_cast<uint8_t>((heaterDutyPercent_ / 100.0f) * static_cast<float>(span));
} else if (below < FAN_RAMP_BELOW_TARGET_C) {
const float spanC = FAN_RAMP_BELOW_TARGET_C - FAN_OFF_BELOW_TARGET_C;
const float t = (FAN_RAMP_BELOW_TARGET_C - below) / spanC;
heatFan = static_cast<uint8_t>(t * static_cast<float>(FAN_HEAT_MIN_PWM));
}
uint8_t mixFan = 0;
if (below <= FAN_OFF_BELOW_TARGET_C || cornerSpreadC_ > GOOD_SPREAD_C) {
mixFan = fanPwmForCornerSpread();
}
uint8_t duty = heatFan > mixFan ? heatFan : mixFan;
if (lastAvgTempC_ >= targetTempC_ - 2.0f && lastMaxTempC_ > targetTempC_ &&
duty < FAN_MAX_PWM) {
duty = FAN_MAX_PWM;
}
return duty;
}
uint8_t fanPwmForCornerSpread() const {
if (cornerSpreadC_ <= SPREAD_DEADBAND_C) {
return 0;
}
float spread = cornerSpreadC_;
if (spread > SPREAD_FULL_MIX_C) {
spread = SPREAD_FULL_MIX_C;
}
const float t =
(spread - SPREAD_DEADBAND_C) / (SPREAD_FULL_MIX_C - SPREAD_DEADBAND_C);
const uint8_t mixMax = fanMixMax_;
const uint8_t mixMin = FAN_MIX_MIN_PWM;
const uint8_t span = mixMax > mixMin ? mixMax - mixMin : 0;
return mixMin + static_cast<uint8_t>(t * static_cast<float>(span));
}
void applyHeaterBurst(uint32_t nowMs) { void applyHeaterBurst(uint32_t nowMs) {
if (heaterDutyPercent_ <= 0.0f) { if (heaterDutyPercent_ <= 0.0f) {
forceHeaterOff(); forceHeaterOff();
@@ -614,7 +584,7 @@ private:
} }
if (isIdle()) { if (isIdle()) {
if (!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) { if (sensorWarmValid_ && lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
writeFan(FAN_MAX_PWM); writeFan(FAN_MAX_PWM);
} else if (fanIdleOverride_) { } else if (fanIdleOverride_) {
writeFan(FAN_IDLE_PWM); writeFan(FAN_IDLE_PWM);
@@ -628,22 +598,23 @@ private:
writeFan(FAN_MAX_PWM); writeFan(FAN_MAX_PWM);
return; return;
} }
uint8_t duty = fanPwmForHeatUp();
writeFan(duty);
} }
PidController pid_; PidController heatPi_;
PidAutotuner autotuner_; PidAutotuner autotuner_;
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 stirFanPwm_;
uint8_t fanManualPwm_;
uint8_t fanPwm_; uint8_t fanPwm_;
uint8_t fanMixMax_; bool tuningLoaded_;
bool adaptiveEnabled_;
bool fanIdleOverride_; bool fanIdleOverride_;
bool fanManualActive_;
bool sensorWarmValid_; bool sensorWarmValid_;
bool cutoffActive_; bool cutoffActive_;
bool failSafeActive_; bool failSafeActive_;

View File

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

View File

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

@@ -9,6 +9,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import re
import sys import sys
import time import time
from datetime import datetime, timezone 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" "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: def detect_serial_port() -> str | None:
by_id = Path("/dev/serial/by-id") 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) 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: def log_notice(message: str) -> None:
if line.startswith("csv_hdr,"): stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
device_header = line[len("csv_hdr,") :] print(f"{stamp} {message}", file=sys.stderr)
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 cmd_log(args: argparse.Namespace) -> int: def cmd_log(args: argparse.Namespace) -> int:
from dryer_tui import parse_status
port = resolve_port(args.port) port = resolve_port(args.port)
out = args.output out = args.output
if out is None: if out is None:
out = args.log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv" 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) print(f"Logging {port} -> {out}", file=sys.stderr)
if args.auto_log_on: if args.auto_log_on:
print("Will send 'log on' after connect", file=sys.stderr) print("Will send 'log on' after connect", file=sys.stderr)
header_written = False session = CsvSession(out)
with open_serial(port, args.baud) as ser, out.open("w", encoding="utf-8") as fh: with open_serial(port, args.baud) as ser:
if args.auto_log_on: if args.auto_log_on:
enable_dryer_logging(ser) enable_dryer_logging(ser)
last_activity = time.monotonic()
while True: while True:
try: try:
raw = ser.readline() raw = ser.readline()
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nStopped.", file=sys.stderr) print(f"\nStopped ({session.row_count} rows).", file=sys.stderr)
session.close()
return 0 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 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 continue
last_activity = time.monotonic()
line = decode_line(raw) line = decode_line(raw)
if not line.startswith("csv_hdr,") and not line.startswith("csv,"): parsed = parse_status(line)
if line: if parsed:
print(line) session.write_status(parsed)
print(line)
continue continue
if line.startswith("csv_hdr,"): if line.startswith("csv_hdr,") or line.startswith("csv,"):
device_header = line[len("csv_hdr,") :] session.write_device_line(line)
fh.write("wall_time," + device_header + "\n")
header_written = True
fh.flush()
continue continue
if not header_written: if line:
fh.write(FALLBACK_HEADER + "\n") print(line)
header_written = True return 0
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
fh.write(wall_time + "," + line[len("csv,") :] + "\n")
fh.flush()
def cmd_tui(args: argparse.Namespace) -> int: def cmd_tui(args: argparse.Namespace) -> int:
@@ -185,6 +304,15 @@ def build_parser() -> argparse.ArgumentParser:
default=True, default=True,
help="Send 'log on' after connect in log mode (default: on)", 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") subparsers = parser.add_subparsers(dest="action")
log_p = subparsers.add_parser("log", help="Headless CSV capture", add_help=False) 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("-o", "--output", type=Path)
log_p.add_argument("--log-dir", type=Path, default=Path("logs")) 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("--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 = subparsers.add_parser("tui", help="Interactive curses dashboard")
tui_p.add_argument("-p", "--port") tui_p.add_argument("-p", "--port")

View File

@@ -7,14 +7,15 @@ 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
from pathlib import Path from pathlib import Path
from capture_csv import ( from capture_csv import (
CsvSession,
decode_line, decode_line,
write_csv_row,
) )
PRESETS: list[tuple[str, float]] = [ PRESETS: list[tuple[str, float]] = [
@@ -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>\S+)\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>.*)\]"
@@ -52,19 +53,76 @@ STATUS_RE = re.compile(
SENSOR_RE = re.compile(r"ch(\d+):([\d.]+)C/(\d+)%|ch(\d+):ERR") SENSOR_RE = re.compile(r"ch(\d+):([\d.]+)C/(\d+)%|ch(\d+):ERR")
AUTOTUNE_MODE_RE = re.compile( 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"
) )
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+)%"
)
def format_mode_line(mode: str) -> str: 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) 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"
) )
return f"Mode: {mode}" 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 @dataclass
@@ -85,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 = ""
@@ -139,13 +198,20 @@ 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 " TEST" in fan_raw: if "(off)" in fan_raw or "(cooldown)" 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 "(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.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"]
@@ -155,8 +221,7 @@ class SerialWorker:
self.state = state self.state = state
self.lock = lock self.lock = lock
self.stop = threading.Event() self.stop = threading.Event()
self._log_fh = None self._csv: CsvSession | None = None
self._header_written = [False]
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
def start(self) -> None: def start(self) -> None:
@@ -171,9 +236,9 @@ class SerialWorker:
self.stop.set() self.stop.set()
if self._thread is not None: if self._thread is not None:
self._thread.join(timeout=1.5) self._thread.join(timeout=1.5)
if self._log_fh is not None: if self._csv is not None:
self._log_fh.close() self._csv.close()
self._log_fh = None self._csv = None
def send(self, command: str) -> None: def send(self, command: str) -> None:
if self.ser is None: if self.ser is None:
@@ -186,20 +251,22 @@ class SerialWorker:
if enabled and not self.state.csv_logging: if enabled and not self.state.csv_logging:
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
path = log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv" path = log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
self._log_fh = path.open("w", encoding="utf-8") self._csv = CsvSession(path)
self._header_written = [False]
self.state.csv_path = path self.state.csv_path = path
self.state.csv_logging = True 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") self.send("log on")
elif not enabled and self.state.csv_logging: elif not enabled and self.state.csv_logging:
self.send("log off") self.send("log off")
self.state.csv_logging = False self.state.csv_logging = False
self.state.csv_path = None self.state.csv_path = None
if self._log_fh is not None: if self._csv is not None:
self._log_fh.close() rows = self._csv.row_count
self._log_fh = None self._csv.close()
self.state.messages.append("CSV logging off") 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: def _note(self, line: str) -> None:
with self.lock: with self.lock:
@@ -220,15 +287,19 @@ 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,"):
if self._log_fh is not None:
write_csv_row(self._log_fh, line, self._header_written)
continue continue
parsed = parse_status(line) parsed = parse_status(line)
if parsed: if parsed:
with self.lock: with self.lock:
apply_status(self.state, parsed) apply_status(self.state, parsed)
if self._csv is not None:
self._csv.write_status(parsed)
continue continue
if line.startswith("target="): if line.startswith("target="):
@@ -335,8 +406,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
@@ -349,14 +420,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,
@@ -408,7 +484,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 | : 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()
@@ -428,6 +504,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)
@@ -451,8 +529,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,
@@ -490,6 +569,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("c"):
worker.send("fanchars")
worker._note("Started fanchars — 30/100/60/80% then refine if needed")
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 != "":

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 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]); readSensorOnChannel(SENSOR_CHANNELS[i], sensors[i]);
} }
mux.disableAll(); mux.disableAll();
if (Wire.getWireTimeoutFlag()) {
Wire.clearWireTimeoutFlag();
}
} }
float averageValidTemperature() { float averageValidTemperature() {
@@ -142,10 +145,18 @@ 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()) {
Serial.print(F("(cooldown)")); 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(F(" cutoff="));
Serial.print(thermal.isCutoffActive() ? F("YES") : F("no")); Serial.print(thermal.isCutoffActive() ? F("YES") : F("no"));
@@ -164,10 +175,32 @@ 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.isAdaptive()) { } else if (thermal.isFanCharacterizeActive()) {
Serial.print(F("learned")); 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(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 { } else {
Serial.print(F("manual")); Serial.print(thermal.regulatingModeName());
} }
Serial.print(F(" sensors=[")); Serial.print(F(" sensors=["));
@@ -196,11 +229,18 @@ void printHelp() {
Serial.println(F(" target <C> set target (0 = idle)")); 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 off cancel idle fan override (auto-off below 40C)"));
Serial.println(F(" fan on idle fan 30% (optional, auto-off below 40C)")); Serial.println(F(" fan on idle fan 30% (optional, auto-off below 40C)"));
Serial.println(F(" fan 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(" fan test N set fan PWM 0-255 for 15s (verify wiring)"));
Serial.println(F(" autotune [C] learn PID (default: 40C when idle)")); Serial.println(F(" autotune [C] learn heat PI (default: 40C when idle)"));
Serial.println(F(" autotune stop")); Serial.println(F(" autotune stop"));
Serial.println(F(" pid show PID / adaptive status")); Serial.println(F(" fanchars learn stir fan (30/100/60/80%% + refine)"));
Serial.println(F(" pid default reset to factory PID")); 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(" status print current readings"));
Serial.println(F(" log on|off CSV data stream")); Serial.println(F(" log on|off CSV data stream"));
Serial.println(F(" help show this message")); Serial.println(F(" help show this message"));
@@ -289,6 +329,58 @@ void processSerialLine(const char *line) {
return; 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) { if (strcmp(line, "status") == 0) {
const float avgTemp = averageValidTemperature(); const float avgTemp = averageValidTemperature();
const float minTemp = minValidTemperature(); const float minTemp = minValidTemperature();
@@ -328,6 +420,44 @@ void processSerialLine(const char *line) {
return; 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;
}
if (strcmp(line, "pid") == 0 || strcmp(line, "pid show") == 0) { if (strcmp(line, "pid") == 0 || strcmp(line, "pid show") == 0) {
thermal.printTuning(); thermal.printTuning();
return; return;
@@ -338,6 +468,11 @@ void processSerialLine(const char *line) {
return; return;
} }
if (strcmp(line, "pid save") == 0) {
thermal.saveTuningToEeprom();
return;
}
if (strcmp(line, "help") == 0) { if (strcmp(line, "help") == 0) {
printHelp(); printHelp();
return; return;
@@ -389,6 +524,11 @@ void setup() {
} }
Wire.begin(); 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()) { if (!mux.begin()) {
Serial.println(F("ERROR: TCA9548A not found on I2C bus")); Serial.println(F("ERROR: TCA9548A not found on I2C bus"));
@@ -428,6 +568,18 @@ void loop() {
if (!isnan(avgTemp) && !isnan(maxTemp) && !isnan(spread)) { if (!isnan(avgTemp) && !isnan(maxTemp) && !isnan(spread)) {
thermal.update(avgTemp, maxTemp, spread, now); 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 { } else {
thermal.enterFailSafe(); thermal.enterFailSafe();
Serial.println(F("WARN: no valid sensor readings — heater off")); Serial.println(F("WARN: no valid sensor readings — heater off"));