Compare commits
10 Commits
e1c24d0552
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d80fbdfc57 | |||
| 31a6764484 | |||
| d1385baa45 | |||
| 2fe53d5a8a | |||
| 8182b9efd2 | |||
| 55cf03c015 | |||
| 28ae97fa45 | |||
| 02e51717e8 | |||
| 960831529e | |||
| f6a9f62029 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,3 +5,6 @@ compile_commands.json
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
|
||||
|
||||
scripts/__pycache__/
|
||||
18
README.md
18
README.md
@@ -82,20 +82,28 @@ Verify access: `test -w /dev/ttyUSB0 && echo ok`
|
||||
|
||||
1. Flash firmware and open the serial monitor at 115200 baud.
|
||||
2. Confirm `TCA9548A detected` and four valid sensor channels (`ch2`–`ch5`).
|
||||
3. Run PID autotune once per physical unit (values are stored in EEPROM):
|
||||
3. Find **stir fan** speed (optional — default is **PWM 178**, ~70%):
|
||||
|
||||
```
|
||||
target 0
|
||||
autotune 45
|
||||
fanchars
|
||||
```
|
||||
|
||||
4. Start drying:
|
||||
Sweeps **30%, 100%, 60%, 80%** fan — heats to max **60°C** corner at **100%** heater. Cools to **40°C** avg between runs. Skips a fan speed if 60°C isn't reached in time. Optional midpoint refine if best isn't at 30% or 100%. Or: `python3 scripts/fan_characterize.py`. When done, `fanchars save` writes the winner to EEPROM (or skip and keep the 178 default).
|
||||
|
||||
4. Tune **heat PI** (stored in EEPROM on autotune complete):
|
||||
|
||||
```
|
||||
target 55
|
||||
target 0
|
||||
pid default
|
||||
autotune 50
|
||||
```
|
||||
|
||||
Send `help` over serial for all commands (`target`, `fan on/off`, `log on/off`, `status`, `pid`, etc.).
|
||||
Cutoff follows target (`target + 12°C`, max 95°C) — e.g. ABS at 55°C trips at 67°C corner, nylon at 80°C at 92°C.
|
||||
|
||||
5. Dry at your target — fan runs at stir PWM (default **178**) whenever target > 0, including heat-up. Override with `fan <pwm>`; `fan auto` returns to default. Fan auto-off below 40°C applies only in idle (`target 0`).
|
||||
|
||||
Send `help` over serial for all commands (`target`, `fanchars`, `fan`, `log on/off`, `status`, `pid`, etc.).
|
||||
|
||||
## Raspberry Pi control
|
||||
|
||||
|
||||
114
include/config.h
114
include/config.h
@@ -12,6 +12,10 @@ static const uint8_t SENSOR_COUNT = sizeof(SENSOR_CHANNELS) / sizeof(SENSOR_CHAN
|
||||
// SHT31 I2C address (ADDR pin low → 0x44, high → 0x45)
|
||||
static const uint8_t SHT31_ADDRESS = 0x44;
|
||||
|
||||
// Bus timeout (Wire.setWireTimeout) — bounds a stuck I2C transaction so a
|
||||
// glitch resets the TWI hardware instead of hanging the whole sketch.
|
||||
static const uint32_t I2C_TIMEOUT_US = 25000UL;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outputs — D5 has hardware PWM; heater on A2 uses burst control (SSR-friendly)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -21,67 +25,81 @@ static const uint8_t HEATER_PIN = A2; // heater via solid-state relay
|
||||
// ---------------------------------------------------------------------------
|
||||
// Temperature control
|
||||
// ---------------------------------------------------------------------------
|
||||
static const float TARGET_TEMP_C = 0.0f; // power-on default: idle (heater off)
|
||||
static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f; // autotune when no temp given and idle
|
||||
static const float TARGET_MIN_C = 0.0f; // 0 = idle (heater off, fan at idle speed)
|
||||
static const float TARGET_TEMP_C = 0.0f;
|
||||
static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f;
|
||||
static const float TARGET_MIN_C = 0.0f;
|
||||
static const float TARGET_MAX_C = 80.0f;
|
||||
static const float OVERTEMP_FRACTION = 0.05f; // hard cutoff at target * 1.05
|
||||
// Hard ceiling (sensor / enclosure limit). Cutoff when regulating = target + CUTOFF_ABOVE_TARGET_C.
|
||||
static const float EMERGENCY_ABSOLUTE_MAX_C = 95.0f;
|
||||
static const float CUTOFF_ABOVE_TARGET_C = 12.0f;
|
||||
static const float CUTOFF_RECOVERY_BAND_C = 5.0f;
|
||||
static const float CORNER_STOP_MARGIN_C = 5.0f;
|
||||
|
||||
// PID on chamber average
|
||||
static const float PID_KP = 4.0f;
|
||||
static const float PID_KI = 0.05f;
|
||||
static const float PID_KD = 6.0f;
|
||||
inline float emergencyCutoffForTarget(float targetC) {
|
||||
if (targetC <= 0.0f) {
|
||||
return EMERGENCY_ABSOLUTE_MAX_C;
|
||||
}
|
||||
float cutoff = targetC + CUTOFF_ABOVE_TARGET_C;
|
||||
if (cutoff > EMERGENCY_ABSOLUTE_MAX_C) {
|
||||
cutoff = EMERGENCY_ABSOLUTE_MAX_C;
|
||||
}
|
||||
return cutoff;
|
||||
}
|
||||
|
||||
// Tiered heater cap — more power when cold, gentle near setpoint
|
||||
static const float HEATER_MAX_DUTY_COLD = 85.0f; // avg >=10 °C below target
|
||||
static const float HEATER_MAX_DUTY_MID = 65.0f; // avg 3–10 °C below target
|
||||
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;
|
||||
// Heat PI on average temp (no D term)
|
||||
static const float HEAT_PI_KP = 4.0f;
|
||||
static const float HEAT_PI_KI = 0.05f;
|
||||
|
||||
// Ramp-up limit (% per second) — still caps sudden jumps
|
||||
static const float HEATER_SLEW_UP_PER_S = 18.0f;
|
||||
// Fixed circulation fan (~70%, fanchars winner); override with "fan <pwm>" when regulating
|
||||
static const uint8_t FAN_STIR_PWM = 178;
|
||||
|
||||
// Hot-corner limiter: taper heater as max corner approaches stop temperature
|
||||
static const float MAX_TEMP_HEADROOM_C = 15.0f;
|
||||
|
||||
// Average-temp approach: taper only in the last few °C before setpoint
|
||||
static const float APPROACH_BAND_C = 4.0f;
|
||||
|
||||
// When spread is good, allow hottest corner slightly above target so avg can reach setpoint
|
||||
// Legacy aliases for autotuner relay math only
|
||||
static const float PID_KP = HEAT_PI_KP;
|
||||
static const float PID_KI = HEAT_PI_KI;
|
||||
static const float PID_KD = 0.0f;
|
||||
static const float GOOD_SPREAD_C = 5.0f;
|
||||
static const float BALANCED_MAX_ABOVE_TARGET_C = 2.0f;
|
||||
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;
|
||||
|
||||
// Fan PWM (0–255)
|
||||
static const uint8_t FAN_IDLE_PWM = 77; // ~30 % — optional override via "fan on"
|
||||
static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f; // idle: fans off when max corner below this
|
||||
static const uint8_t FAN_MIX_MIN_PWM = 70; // ~27 % — light mixing when spread rises
|
||||
static const uint8_t FAN_HEAT_MIN_PWM = 100; // ~39 % — floor while heating (near setpoint)
|
||||
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
|
||||
// Fan PWM — stir speed when target > 0; off below 40°C only when idle (target 0)
|
||||
static const uint8_t FAN_IDLE_PWM = 77;
|
||||
static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f;
|
||||
static const uint8_t FAN_MAX_PWM = 255;
|
||||
static const bool FAN_PWM_INVERT = true;
|
||||
|
||||
// Corner mixing — moderate airflow; full speed reserved for safety
|
||||
static const float SPREAD_DEADBAND_C = 0.5f;
|
||||
static const float SPREAD_FULL_MIX_C = 8.0f;
|
||||
static const float SPREAD_EMA_ALPHA = 0.45f;
|
||||
|
||||
// PID auto-tune (relay method) — run with: autotune 45
|
||||
// PID auto-tune (relay method) — heat PI only
|
||||
static const float AUTOTUNE_HYSTERESIS_C = 0.4f;
|
||||
static const float AUTOTUNE_PREHEAT_BAND_C = 5.0f;
|
||||
static const float AUTOTUNE_PREHEAT_BAND_C = 3.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_CYCLES_REQUIRED = 6;
|
||||
static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL; // 20 min
|
||||
static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL; // 60 min total
|
||||
static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL; // count periods up to 40 min
|
||||
static const uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 0;
|
||||
static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 5;
|
||||
static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL;
|
||||
static const uint32_t AUTOTUNE_RELAY_STALL_MS = 1500000UL;
|
||||
static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL;
|
||||
static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL;
|
||||
|
||||
// Fan characterize — fixed heater, sweep fan PWMs, pick lowest spread
|
||||
static const float FANCHARS_MAX_CORNER_C = 60.0f;
|
||||
static const float FANCHARS_COOL_AVG_C = 40.0f;
|
||||
static const float FANCHARS_PRECOOL_MARGIN_C = 2.0f;
|
||||
static const float FANCHARS_HEATER_PCT = 100.0f;
|
||||
// Coarse sweep order: 30%, 100%, 60%, 80% fan
|
||||
static const uint8_t FANCHARS_COARSE_PWM[] = {77, 255, 153, 204};
|
||||
static const uint8_t FANCHARS_COARSE_COUNT =
|
||||
sizeof(FANCHARS_COARSE_PWM) / sizeof(FANCHARS_COARSE_PWM[0]);
|
||||
static const uint8_t FANCHARS_LIMIT_LOW_PWM = 77;
|
||||
static const uint8_t FANCHARS_LIMIT_HIGH_PWM = 255;
|
||||
static const uint8_t FANCHARS_MAX_RESULTS = FANCHARS_COARSE_COUNT + 1;
|
||||
static const uint32_t FANCHARS_HOLD_MS = 60000UL;
|
||||
static const uint32_t FANCHARS_HEAT_TIMEOUT_MS = 2700000UL;
|
||||
static const uint32_t FANCHARS_COOLDOWN_TIMEOUT_MS = 2700000UL;
|
||||
static const uint32_t FANCHARS_LOG_INTERVAL_MS = 1000UL;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timing
|
||||
@@ -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 CONTROL_INTERVAL_MS = 500;
|
||||
static const uint32_t SERIAL_REPORT_INTERVAL_MS = 2000;
|
||||
static const bool LOG_CSV_DEFAULT = false; // enable with serial command: log on
|
||||
static const bool LOG_CSV_DEFAULT = false;
|
||||
|
||||
75
include/fan_characterize.h
Normal file
75
include/fan_characterize.h
Normal 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];
|
||||
};
|
||||
@@ -24,12 +24,11 @@ public:
|
||||
spreadSamples_(0),
|
||||
cycleCount_(0),
|
||||
aboveSetpoint_(false),
|
||||
useMaxSensorPv_(false),
|
||||
sessionStartMs_(0),
|
||||
phaseStartMs_(0),
|
||||
resultKp_(PID_KP),
|
||||
resultKi_(PID_KI),
|
||||
resultKd_(PID_KD),
|
||||
resultFanMixMax_(FAN_MIX_MAX_PWM) {}
|
||||
resultKp_(HEAT_PI_KP),
|
||||
resultKi_(HEAT_PI_KI) {}
|
||||
|
||||
Phase phase() const { return phase_; }
|
||||
|
||||
@@ -47,12 +46,14 @@ public:
|
||||
|
||||
float preheatTargetC() const { return setpointC_ - AUTOTUNE_PREHEAT_BAND_C; }
|
||||
|
||||
bool usesMaxSensor() const { return useMaxSensorPv_; }
|
||||
|
||||
const char *phaseName() const {
|
||||
switch (phase_) {
|
||||
case Phase::Preheat:
|
||||
return "preheat";
|
||||
case Phase::Relay:
|
||||
return "relay";
|
||||
return useMaxSensorPv_ ? "relay-max" : "relay-avg";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -74,7 +75,7 @@ public:
|
||||
Serial.print(preheatTargetC(), 1);
|
||||
Serial.print(F("-"));
|
||||
Serial.print(setpointC_, 1);
|
||||
Serial.println(F("C avg"));
|
||||
Serial.println(F("C avg (heat PI only)"));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -95,20 +96,18 @@ public:
|
||||
|
||||
float resultKp() const { return resultKp_; }
|
||||
float resultKi() const { return resultKi_; }
|
||||
float resultKd() const { return resultKd_; }
|
||||
uint8_t resultFanMixMax() const { return resultFanMixMax_; }
|
||||
|
||||
Phase update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut,
|
||||
uint8_t &fanPwmOut) {
|
||||
heaterDutyOut = 0.0f;
|
||||
fanPwmOut = FAN_HEAT_MIN_PWM;
|
||||
fanPwmOut = AUTOTUNE_PREHEAT_FAN_PWM;
|
||||
|
||||
if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) {
|
||||
return phase_;
|
||||
}
|
||||
|
||||
if (maxTempC >= TARGET_MAX_C - 1.0f) {
|
||||
fail(F("autotune: abort — max sensor at safety limit"));
|
||||
if (maxTempC >= emergencyCutoffForTarget(setpointC_)) {
|
||||
fail(F("autotune: abort — max sensor at emergency limit"));
|
||||
return phase_;
|
||||
}
|
||||
|
||||
@@ -124,8 +123,8 @@ public:
|
||||
fail(F("autotune: abort — preheat timeout"));
|
||||
return phase_;
|
||||
}
|
||||
if (avgTempC >= preheatTargetC()) {
|
||||
enterRelay(avgTempC, nowMs);
|
||||
if (avgTempC >= preheatTargetC() || maxTempC >= setpointC_ - 2.0f) {
|
||||
enterRelay(avgTempC, maxTempC, spreadC, nowMs);
|
||||
} else {
|
||||
heaterDutyOut = AUTOTUNE_PREHEAT_DUTY;
|
||||
}
|
||||
@@ -137,27 +136,39 @@ public:
|
||||
return phase_;
|
||||
}
|
||||
|
||||
if (cycleCount_ == 0 && nowMs - phaseStartMs_ > AUTOTUNE_RELAY_STALL_MS) {
|
||||
Serial.print(F("autotune: relay stalled — avg "));
|
||||
Serial.print(avgTempC, 1);
|
||||
Serial.print(F("C max "));
|
||||
Serial.print(maxTempC, 1);
|
||||
Serial.println(F("C (spread too large for avg to cross setpoint?)"));
|
||||
fail(F("autotune: abort — no oscillation"));
|
||||
return phase_;
|
||||
}
|
||||
|
||||
const float pv = useMaxSensorPv_ ? maxTempC : avgTempC;
|
||||
|
||||
spreadSum_ += spreadC;
|
||||
++spreadSamples_;
|
||||
|
||||
if (avgTempC > peakSinceCross_) {
|
||||
peakSinceCross_ = avgTempC;
|
||||
if (pv > peakSinceCross_) {
|
||||
peakSinceCross_ = pv;
|
||||
}
|
||||
if (avgTempC < valleySinceCross_) {
|
||||
valleySinceCross_ = avgTempC;
|
||||
if (pv < valleySinceCross_) {
|
||||
valleySinceCross_ = pv;
|
||||
}
|
||||
|
||||
bool heatOn = false;
|
||||
if (avgTempC <= relayLow_) {
|
||||
if (pv <= relayLow_) {
|
||||
heatOn = true;
|
||||
} else if (avgTempC >= relayHigh_) {
|
||||
} else if (pv >= relayHigh_) {
|
||||
heatOn = false;
|
||||
} else {
|
||||
heatOn = !aboveSetpoint_;
|
||||
}
|
||||
heaterDutyOut = heatOn ? 100.0f : 0.0f;
|
||||
|
||||
const bool nowAbove = avgTempC >= setpointC_;
|
||||
const bool nowAbove = pv >= setpointC_;
|
||||
if (nowAbove != aboveSetpoint_) {
|
||||
onSetpointCrossing(nowMs);
|
||||
aboveSetpoint_ = nowAbove;
|
||||
@@ -167,14 +178,18 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
void enterRelay(float avgTempC, uint32_t nowMs) {
|
||||
void enterRelay(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs) {
|
||||
phase_ = Phase::Relay;
|
||||
phaseStartMs_ = nowMs;
|
||||
aboveSetpoint_ = avgTempC >= setpointC_;
|
||||
peakSinceCross_ = avgTempC;
|
||||
valleySinceCross_ = avgTempC;
|
||||
useMaxSensorPv_ = spreadC > GOOD_SPREAD_C;
|
||||
const float pv = useMaxSensorPv_ ? maxTempC : avgTempC;
|
||||
aboveSetpoint_ = pv >= setpointC_;
|
||||
peakSinceCross_ = pv;
|
||||
valleySinceCross_ = pv;
|
||||
lastCrossMs_ = 0;
|
||||
Serial.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.println(F("s preheat)"));
|
||||
}
|
||||
@@ -191,6 +206,7 @@ private:
|
||||
spreadSamples_ = 0;
|
||||
cycleCount_ = 0;
|
||||
aboveSetpoint_ = false;
|
||||
useMaxSensorPv_ = false;
|
||||
}
|
||||
|
||||
void onSetpointCrossing(uint32_t nowMs) {
|
||||
@@ -239,7 +255,6 @@ private:
|
||||
const float ku = (4.0f * 100.0f) / (PI * avgAmplitude);
|
||||
resultKp_ = 0.45f * ku;
|
||||
resultKi_ = resultKp_ / (2.2f * avgPeriodSec);
|
||||
resultKd_ = resultKp_ * avgPeriodSec / 6.3f;
|
||||
|
||||
if (resultKp_ < 0.5f) {
|
||||
resultKp_ = 0.5f;
|
||||
@@ -248,30 +263,15 @@ private:
|
||||
resultKi_ = resultKp_ / 3.0f;
|
||||
}
|
||||
|
||||
resultFanMixMax_ = FAN_MIX_MAX_PWM;
|
||||
const float spreadAvg =
|
||||
spreadSamples_ > 0 ? spreadSum_ / static_cast<float>(spreadSamples_) : GOOD_SPREAD_C;
|
||||
if (spreadAvg > GOOD_SPREAD_C) {
|
||||
const float boost = 1.0f + ((spreadAvg - GOOD_SPREAD_C) / 10.0f);
|
||||
int boosted = static_cast<int>(static_cast<float>(FAN_MIX_MAX_PWM) * boost);
|
||||
if (boosted > static_cast<int>(FAN_MAX_PWM) - 20) {
|
||||
boosted = FAN_MAX_PWM - 20;
|
||||
}
|
||||
resultFanMixMax_ = static_cast<uint8_t>(boosted);
|
||||
}
|
||||
|
||||
phase_ = Phase::Done;
|
||||
Serial.print(F("autotune: done in "));
|
||||
Serial.print((nowMs - sessionStartMs_) / 1000UL);
|
||||
Serial.println(F("s"));
|
||||
Serial.print(F(" Kp="));
|
||||
Serial.print(F(" heat Kp="));
|
||||
Serial.print(resultKp_, 3);
|
||||
Serial.print(F(" Ki="));
|
||||
Serial.print(resultKi_, 4);
|
||||
Serial.print(F(" Kd="));
|
||||
Serial.print(resultKd_, 3);
|
||||
Serial.print(F(" fanMixMax="));
|
||||
Serial.println(resultFanMixMax_);
|
||||
Serial.println(resultKi_, 4);
|
||||
Serial.println(F(" tune heat PI: pid save after autotune"));
|
||||
}
|
||||
|
||||
void fail(const __FlashStringHelper *reason) {
|
||||
@@ -295,10 +295,9 @@ private:
|
||||
uint16_t spreadSamples_;
|
||||
uint8_t cycleCount_;
|
||||
bool aboveSetpoint_;
|
||||
bool useMaxSensorPv_;
|
||||
uint32_t sessionStartMs_;
|
||||
uint32_t phaseStartMs_;
|
||||
float resultKp_;
|
||||
float resultKi_;
|
||||
float resultKd_;
|
||||
uint8_t resultFanMixMax_;
|
||||
};
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
#include "config.h"
|
||||
|
||||
// After TuningData (15 bytes) + checksum (1 byte) at address 0
|
||||
static const uint16_t SETTINGS_MAGIC = 0xDA7E;
|
||||
static const uint16_t SETTINGS_MAGIC = 0xDA7F;
|
||||
static const int SETTINGS_EEPROM_ADDR = 16;
|
||||
|
||||
struct SettingsData {
|
||||
uint16_t magic = 0;
|
||||
float targetC = TARGET_TEMP_C;
|
||||
uint8_t stirFanPwm = 0; // 0 = use FAN_STIR_PWM from config
|
||||
};
|
||||
|
||||
inline uint8_t settingsChecksum(const SettingsData &data) {
|
||||
@@ -39,8 +40,24 @@ inline void settingsSave(const SettingsData &data) {
|
||||
|
||||
inline void settingsSaveTarget(float targetC) {
|
||||
SettingsData data;
|
||||
if (settingsLoad(data)) {
|
||||
data.targetC = targetC;
|
||||
} else {
|
||||
data.magic = SETTINGS_MAGIC;
|
||||
data.targetC = targetC;
|
||||
data.stirFanPwm = 0;
|
||||
}
|
||||
settingsSave(data);
|
||||
}
|
||||
|
||||
inline void settingsSaveStirFan(uint8_t stirFanPwm) {
|
||||
SettingsData data;
|
||||
if (settingsLoad(data)) {
|
||||
data.stirFanPwm = stirFanPwm;
|
||||
} else {
|
||||
data.magic = SETTINGS_MAGIC;
|
||||
data.stirFanPwm = stirFanPwm;
|
||||
}
|
||||
settingsSave(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "fan_characterize.h"
|
||||
#include "pid_autotuner.h"
|
||||
#include "pid_controller.h"
|
||||
#include "settings_store.h"
|
||||
@@ -10,20 +11,24 @@
|
||||
|
||||
class ThermalController {
|
||||
public:
|
||||
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Allow, Autotune };
|
||||
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, FanChars };
|
||||
|
||||
ThermalController()
|
||||
: pid_(PID_KP, PID_KI, PID_KD, 0.0f, 100.0f),
|
||||
: heatPi_(HEAT_PI_KP, HEAT_PI_KI, 0.0f, 0.0f, 100.0f),
|
||||
autotuner_(),
|
||||
fanchars_(),
|
||||
targetTempC_(TARGET_TEMP_C),
|
||||
heaterDutyPercent_(0.0f),
|
||||
heaterAllowancePercent_(100.0f),
|
||||
cornerSpreadC_(0.0f),
|
||||
lastMaxTempC_(0.0f),
|
||||
fanPwm_(FAN_MAX_PWM),
|
||||
fanMixMax_(FAN_MIX_MAX_PWM),
|
||||
adaptiveEnabled_(false),
|
||||
regulatingFanPwm_(0),
|
||||
stirFanPwm_(FAN_STIR_PWM),
|
||||
fanManualPwm_(0),
|
||||
fanPwm_(0),
|
||||
tuningLoaded_(false),
|
||||
fanIdleOverride_(false),
|
||||
fanManualActive_(false),
|
||||
sensorWarmValid_(false),
|
||||
cutoffActive_(false),
|
||||
failSafeActive_(true),
|
||||
@@ -40,13 +45,13 @@ public:
|
||||
pinMode(FAN_PIN, OUTPUT);
|
||||
pinMode(HEATER_PIN, OUTPUT);
|
||||
digitalWrite(HEATER_PIN, LOW);
|
||||
writeFan(0);
|
||||
|
||||
pid_.setSetpoint(targetTempC_);
|
||||
pid_.reset();
|
||||
heatPi_.setSetpoint(targetTempC_);
|
||||
heatPi_.reset();
|
||||
heaterCycleStartMs_ = millis();
|
||||
lastHeaterUpdateMs_ = 0;
|
||||
failSafeActive_ = true;
|
||||
fanPwm_ = FAN_MAX_PWM;
|
||||
cornerSpreadC_ = 0.0f;
|
||||
lastMaxTempC_ = 0.0f;
|
||||
sensorWarmValid_ = false;
|
||||
@@ -59,58 +64,64 @@ public:
|
||||
TuningData stored;
|
||||
if (tuningLoad(stored)) {
|
||||
applyTuning(stored);
|
||||
Serial.println(F("Loaded learned PID from EEPROM"));
|
||||
Serial.println(F("Loaded learned PI from EEPROM"));
|
||||
printTuning();
|
||||
}
|
||||
|
||||
SettingsData settings;
|
||||
if (settingsLoad(settings) && settings.targetC >= TARGET_MIN_C &&
|
||||
settings.targetC <= TARGET_MAX_C) {
|
||||
if (settingsLoad(settings)) {
|
||||
if (settings.stirFanPwm > 0) {
|
||||
stirFanPwm_ = settings.stirFanPwm;
|
||||
}
|
||||
if (settings.targetC >= TARGET_MIN_C && settings.targetC <= TARGET_MAX_C) {
|
||||
setTarget(settings.targetC, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void applyTuning(const TuningData &data) {
|
||||
pid_.setTunings(data.kp, data.ki, data.kd);
|
||||
fanMixMax_ = data.fanMixMax;
|
||||
adaptiveEnabled_ = true;
|
||||
heatPi_.setTunings(data.heatKp, data.heatKi, 0.0f);
|
||||
tuningLoaded_ = true;
|
||||
}
|
||||
|
||||
void clearTuning() {
|
||||
adaptiveEnabled_ = false;
|
||||
fanMixMax_ = FAN_MIX_MAX_PWM;
|
||||
pid_.setTunings(PID_KP, PID_KI, PID_KD);
|
||||
tuningLoaded_ = false;
|
||||
heatPi_.setTunings(HEAT_PI_KP, HEAT_PI_KI, 0.0f);
|
||||
tuningClear();
|
||||
pid_.reset();
|
||||
Serial.println(F("PID reset to defaults"));
|
||||
heatPi_.reset();
|
||||
Serial.println(F("PI reset to defaults"));
|
||||
}
|
||||
|
||||
void printTuning() const {
|
||||
Serial.print(F("PID Kp="));
|
||||
Serial.print(pidKp(), 3);
|
||||
Serial.print(F("Heat PI Kp="));
|
||||
Serial.print(heatPi_.kp(), 3);
|
||||
Serial.print(F(" Ki="));
|
||||
Serial.print(pidKi(), 4);
|
||||
Serial.print(F(" Kd="));
|
||||
Serial.print(pidKd(), 3);
|
||||
Serial.print(F(" fanMixMax="));
|
||||
Serial.print(fanMixMax_);
|
||||
Serial.print(F(" adaptive="));
|
||||
Serial.println(adaptiveEnabled_ ? F("yes") : F("no"));
|
||||
Serial.print(heatPi_.ki(), 4);
|
||||
Serial.print(F(" tuned="));
|
||||
Serial.println(tuningLoaded_ ? F("yes") : F("no"));
|
||||
}
|
||||
|
||||
float pidKp() const { return pid_.kp(); }
|
||||
float pidKi() const { return pid_.ki(); }
|
||||
float pidKd() const { return pid_.kd(); }
|
||||
void saveTuningToEeprom() {
|
||||
TuningData data;
|
||||
data.magic = TUNING_MAGIC;
|
||||
data.heatKp = heatPi_.kp();
|
||||
data.heatKi = heatPi_.ki();
|
||||
tuningSave(data);
|
||||
tuningLoaded_ = true;
|
||||
Serial.println(F("Saved PI to EEPROM"));
|
||||
}
|
||||
|
||||
bool isAdaptive() const { return adaptiveEnabled_; }
|
||||
float heatKp() const { return heatPi_.kp(); }
|
||||
float heatKi() const { return heatPi_.ki(); }
|
||||
|
||||
bool isTuningLoaded() const { return tuningLoaded_; }
|
||||
|
||||
bool startAutotune(float setpointC) {
|
||||
if (autotuner_.isActive()) {
|
||||
if (autotuner_.isActive() || fanchars_.isActive()) {
|
||||
return false;
|
||||
}
|
||||
adaptiveEnabled_ = false;
|
||||
cutoffActive_ = false;
|
||||
pid_.reset();
|
||||
heatPi_.reset();
|
||||
return autotuner_.start(setpointC);
|
||||
}
|
||||
|
||||
@@ -128,28 +139,114 @@ public:
|
||||
|
||||
float autotunePreheatTargetC() const { return autotuner_.preheatTargetC(); }
|
||||
|
||||
bool startFanCharacterize(float maxCornerC, float avgTempC) {
|
||||
if (autotuner_.isActive() || fanchars_.isActive()) {
|
||||
return false;
|
||||
}
|
||||
stopFanTest();
|
||||
cutoffActive_ = false;
|
||||
heatPi_.reset();
|
||||
setTarget(0.0f, false);
|
||||
return fanchars_.start(maxCornerC, avgTempC);
|
||||
}
|
||||
|
||||
void stopFanCharacterize() {
|
||||
fanchars_.abort();
|
||||
forceHeaterOff();
|
||||
writeFan(0);
|
||||
}
|
||||
|
||||
bool isFanCharacterizeActive() const { return fanchars_.isActive(); }
|
||||
|
||||
uint32_t fanCharacterizeElapsedMs(uint32_t nowMs) const { return fanchars_.elapsedMs(nowMs); }
|
||||
|
||||
const char *fanCharacterizePhaseName() const { return fanchars_.phaseName(); }
|
||||
|
||||
uint8_t fanCharacterizeProfileIndex() const { return fanchars_.profileIndex(); }
|
||||
|
||||
uint8_t fanCharacterizeProfileCount() const { return fanchars_.profileCount(); }
|
||||
|
||||
uint8_t fanCharacterizeFanPwm() const { return fanchars_.currentFanPwm(); }
|
||||
|
||||
bool isFanCharacterizeRefineRun() const { return fanchars_.isRefineRun(); }
|
||||
|
||||
float fanCharacterizeHeaterPct() const { return fanchars_.heaterPct(); }
|
||||
|
||||
uint8_t stirFanPwm() const { return stirFanPwm_; }
|
||||
|
||||
bool isFanManualOverride() const { return fanManualActive_ && !isIdle(); }
|
||||
|
||||
bool setRegulatingFanManual(uint8_t pwm) {
|
||||
if (isIdle()) {
|
||||
return false;
|
||||
}
|
||||
fanManualPwm_ = pwm;
|
||||
fanManualActive_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void clearRegulatingFanManual() {
|
||||
fanManualActive_ = false;
|
||||
}
|
||||
|
||||
bool setStirFanPwm(uint8_t pwm, bool persist = true) {
|
||||
if (pwm == 0) {
|
||||
return false;
|
||||
}
|
||||
stirFanPwm_ = pwm;
|
||||
if (persist) {
|
||||
settingsSaveStirFan(pwm);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void logFanCharacterizeIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
|
||||
float avgTempC, float minTempC, float maxTempC, float spreadC,
|
||||
uint32_t nowMs) {
|
||||
fanchars_.logIfDue(sensorTemps, sensorValid, sensorCount, avgTempC, minTempC, maxTempC,
|
||||
spreadC, nowMs);
|
||||
}
|
||||
|
||||
bool saveStirFanFromCharacterize() {
|
||||
if (fanchars_.phase() != FanCharacterize::Phase::Done) {
|
||||
return false;
|
||||
}
|
||||
const uint8_t winner = fanchars_.winnerFanPwm();
|
||||
if (winner == 0) {
|
||||
return false;
|
||||
}
|
||||
stirFanPwm_ = winner;
|
||||
settingsSaveStirFan(winner);
|
||||
Serial.print(F("stir fan "));
|
||||
Serial.println(winner);
|
||||
fanchars_.reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool commitAutotuneIfDone() {
|
||||
if (autotuner_.phase() != PidAutotuner::Phase::Done) {
|
||||
return false;
|
||||
}
|
||||
|
||||
heatPi_.setTunings(autotuner_.resultKp(), autotuner_.resultKi(), 0.0f);
|
||||
heatPi_.reset();
|
||||
|
||||
TuningData data;
|
||||
data.magic = TUNING_MAGIC;
|
||||
data.kp = autotuner_.resultKp();
|
||||
data.ki = autotuner_.resultKi();
|
||||
data.kd = autotuner_.resultKd();
|
||||
data.fanMixMax = autotuner_.resultFanMixMax();
|
||||
data.heatKp = autotuner_.resultKp();
|
||||
data.heatKi = autotuner_.resultKi();
|
||||
tuningSave(data);
|
||||
applyTuning(data);
|
||||
tuningLoaded_ = true;
|
||||
autotuner_.reset();
|
||||
Serial.println(F("Saved learned PID to EEPROM"));
|
||||
Serial.println(F("Saved heat PI to EEPROM"));
|
||||
return true;
|
||||
}
|
||||
|
||||
void setTarget(float targetC, bool persist = true) {
|
||||
targetTempC_ = targetC;
|
||||
pid_.setSetpoint(targetC);
|
||||
pid_.reset();
|
||||
heatPi_.setSetpoint(targetC);
|
||||
heatPi_.reset();
|
||||
fanManualActive_ = false;
|
||||
cutoffActive_ = false;
|
||||
if (targetC > 0.0f) {
|
||||
fanIdleOverride_ = false;
|
||||
@@ -202,11 +299,13 @@ public:
|
||||
|
||||
bool isIdle() const { return targetTempC_ <= 0.0f; }
|
||||
|
||||
float emergencyCutoffC() const { return emergencyCutoffForTarget(targetTempC_); }
|
||||
|
||||
float cutoffThreshold() const {
|
||||
if (isIdle()) {
|
||||
return INFINITY;
|
||||
}
|
||||
return targetTempC_ * (1.0f + OVERTEMP_FRACTION);
|
||||
return emergencyCutoffC();
|
||||
}
|
||||
|
||||
bool isCutoffActive() const { return cutoffActive_; }
|
||||
@@ -237,7 +336,12 @@ public:
|
||||
|
||||
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 {
|
||||
switch (heaterBlock_) {
|
||||
@@ -245,15 +349,19 @@ public:
|
||||
return "cutoff";
|
||||
case HeaterBlock::Corner:
|
||||
return "corner";
|
||||
case HeaterBlock::Allow:
|
||||
return "allow";
|
||||
case HeaterBlock::Autotune:
|
||||
return "autotune";
|
||||
case HeaterBlock::FanChars:
|
||||
return "fanchars";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
const char *regulatingModeName() const {
|
||||
return tuningLoaded_ ? "regulating" : "manual";
|
||||
}
|
||||
|
||||
void update(float avgTempC, float maxTempC, float cornerSpreadC, uint32_t nowMs) {
|
||||
failSafeActive_ = false;
|
||||
noteSensorMax(maxTempC);
|
||||
@@ -261,8 +369,13 @@ public:
|
||||
heaterBlock_ = HeaterBlock::None;
|
||||
|
||||
cornerSpreadC_ =
|
||||
SPREAD_EMA_ALPHA * cornerSpreadC +
|
||||
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC_;
|
||||
SPREAD_EMA_ALPHA * cornerSpreadC_ +
|
||||
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC;
|
||||
|
||||
if (fanchars_.isActive()) {
|
||||
updateFanCharacterize(avgTempC, maxTempC, nowMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (autotuner_.isActive()) {
|
||||
updateAutotune(avgTempC, maxTempC, nowMs);
|
||||
@@ -272,21 +385,16 @@ public:
|
||||
if (isIdle()) {
|
||||
forceHeaterOff();
|
||||
cutoffActive_ = false;
|
||||
pid_.reset();
|
||||
heatPi_.reset();
|
||||
lastHeaterUpdateMs_ = nowMs;
|
||||
applyFan(nowMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (adaptiveEnabled_) {
|
||||
updateAdaptive(avgTempC, maxTempC, nowMs);
|
||||
} else {
|
||||
updateLegacy(avgTempC, maxTempC, nowMs);
|
||||
}
|
||||
|
||||
updateRegulating(avgTempC, maxTempC, nowMs);
|
||||
lastHeaterUpdateMs_ = nowMs;
|
||||
applyHeaterBurst(nowMs);
|
||||
applyFan(nowMs);
|
||||
writeFan(regulatingFanPwm_);
|
||||
}
|
||||
|
||||
void enterFailSafe() {
|
||||
@@ -294,8 +402,9 @@ public:
|
||||
cutoffActive_ = false;
|
||||
forceHeaterOff();
|
||||
applyFan(millis());
|
||||
pid_.reset();
|
||||
heatPi_.reset();
|
||||
autotuner_.abort();
|
||||
fanchars_.abort();
|
||||
}
|
||||
|
||||
void forceHeaterOff() {
|
||||
@@ -307,19 +416,49 @@ public:
|
||||
|
||||
void writeFan(uint8_t pwm) {
|
||||
fanPwm_ = pwm;
|
||||
if (pwm == 0) {
|
||||
// Re-assert output and stop Timer0 PWM on D5 — analogWrite(0) can leave the pin driving
|
||||
pinMode(FAN_PIN, OUTPUT);
|
||||
digitalWrite(FAN_PIN, LOW);
|
||||
} else {
|
||||
analogWrite(FAN_PIN, pwm);
|
||||
|
||||
if (FAN_PWM_INVERT) {
|
||||
if (pwm == 0) {
|
||||
digitalWrite(FAN_PIN, HIGH);
|
||||
return;
|
||||
}
|
||||
if (pwm >= 254) {
|
||||
digitalWrite(FAN_PIN, LOW);
|
||||
return;
|
||||
}
|
||||
analogWrite(FAN_PIN, static_cast<uint8_t>(255 - pwm));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pwm == 0) {
|
||||
digitalWrite(FAN_PIN, LOW);
|
||||
return;
|
||||
}
|
||||
if (pwm >= 254) {
|
||||
digitalWrite(FAN_PIN, HIGH);
|
||||
return;
|
||||
}
|
||||
analogWrite(FAN_PIN, pwm);
|
||||
}
|
||||
|
||||
private:
|
||||
void updateFanCharacterize(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||
float duty = 0.0f;
|
||||
uint8_t fan = 0;
|
||||
fanchars_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
|
||||
|
||||
heaterDutyPercent_ = duty;
|
||||
heaterAllowancePercent_ = duty;
|
||||
heaterBlock_ = duty > 0.0f ? HeaterBlock::FanChars : HeaterBlock::None;
|
||||
applyHeaterBurst(nowMs);
|
||||
writeFan(fan);
|
||||
lastHeaterUpdateMs_ = nowMs;
|
||||
}
|
||||
|
||||
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||
float duty = 0.0f;
|
||||
uint8_t fan = FAN_HEAT_MIN_PWM;
|
||||
uint8_t fan = stirFanPwm_;
|
||||
autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
|
||||
|
||||
heaterDutyPercent_ = duty;
|
||||
@@ -327,99 +466,47 @@ private:
|
||||
heaterOn_ = duty >= 50.0f;
|
||||
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
|
||||
heaterBlock_ = duty > 0.0f ? HeaterBlock::None : HeaterBlock::Autotune;
|
||||
writeFan(fan);
|
||||
writeFan(fanManualActive_ ? fanManualPwm_ : stirFanPwm_);
|
||||
lastHeaterUpdateMs_ = nowMs;
|
||||
commitAutotuneIfDone();
|
||||
}
|
||||
|
||||
void updateAdaptive(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||
const float cutoff = cutoffThreshold();
|
||||
|
||||
if (maxTempC >= cutoff) {
|
||||
void updateRegulating(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||
const float cutoffC = emergencyCutoffC();
|
||||
if (maxTempC >= cutoffC) {
|
||||
cutoffActive_ = true;
|
||||
heaterDutyPercent_ = 0.0f;
|
||||
heaterAllowancePercent_ = 0.0f;
|
||||
heaterOn_ = false;
|
||||
regulatingFanPwm_ = FAN_MAX_PWM;
|
||||
heaterBlock_ = HeaterBlock::Cutoff;
|
||||
pid_.reset();
|
||||
heatPi_.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cutoffActive_ && maxTempC <= targetTempC_) {
|
||||
if (cutoffActive_ && maxTempC < cutoffC - CUTOFF_RECOVERY_BAND_C) {
|
||||
cutoffActive_ = false;
|
||||
pid_.reset();
|
||||
heatPi_.reset();
|
||||
}
|
||||
|
||||
if (cutoffActive_) {
|
||||
heaterBlock_ = HeaterBlock::Cutoff;
|
||||
regulatingFanPwm_ = FAN_MAX_PWM;
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldLimitMaxCorner(avgTempC) && maxTempC >= maxHeatStopTemp(avgTempC)) {
|
||||
heaterDutyPercent_ = 0.0f;
|
||||
heaterAllowancePercent_ = 0.0f;
|
||||
heaterBlock_ = HeaterBlock::Corner;
|
||||
pid_.reset();
|
||||
return;
|
||||
}
|
||||
heaterAllowancePercent_ = allowanceFromMaxCorner(maxTempC, avgTempC);
|
||||
|
||||
float duty = pid_.compute(avgTempC, nowMs);
|
||||
const float below = targetTempC_ - avgTempC;
|
||||
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;
|
||||
float duty = heatPi_.compute(avgTempC, nowMs);
|
||||
duty = clampPercent(duty);
|
||||
if (duty > heaterAllowancePercent_) {
|
||||
duty = heaterAllowancePercent_;
|
||||
if (heaterAllowancePercent_ < 100.0f) {
|
||||
heaterBlock_ = HeaterBlock::Corner;
|
||||
}
|
||||
if (duty <= 0.0f && heaterAllowancePercent_ <= 0.0f) {
|
||||
heaterBlock_ = HeaterBlock::Allow;
|
||||
}
|
||||
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
|
||||
heaterDutyPercent_ = duty;
|
||||
|
||||
regulatingFanPwm_ = fanManualActive_ ? fanManualPwm_ : stirFanPwm_;
|
||||
}
|
||||
|
||||
static float clampPercent(float value) {
|
||||
@@ -432,37 +519,25 @@ private:
|
||||
return value;
|
||||
}
|
||||
|
||||
bool isBalancedChamber() const { return cornerSpreadC_ <= GOOD_SPREAD_C; }
|
||||
|
||||
bool shouldLimitMaxCorner(float avgTempC) const {
|
||||
return avgTempC >= targetTempC_ - CORNER_LIMIT_BAND_C;
|
||||
}
|
||||
|
||||
float maxHeatStopTemp(float avgTempC) const {
|
||||
if (avgTempC >= targetTempC_) {
|
||||
return targetTempC_;
|
||||
if (!shouldLimitMaxCorner(avgTempC)) {
|
||||
return emergencyCutoffC();
|
||||
}
|
||||
|
||||
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;
|
||||
return emergencyCutoffC() - CORNER_STOP_MARGIN_C;
|
||||
}
|
||||
|
||||
float allowanceFromMaxCorner(float maxTempC, float avgTempC) const {
|
||||
const float cutoffC = emergencyCutoffC();
|
||||
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;
|
||||
return clampPercent((headroom / 3.0f) * 100.0f);
|
||||
}
|
||||
|
||||
if (isBalancedChamber() && avgTempC < targetTempC_) {
|
||||
return 100.0f;
|
||||
}
|
||||
|
||||
@@ -479,111 +554,6 @@ private:
|
||||
return clampPercent((headroom / MAX_TEMP_HEADROOM_C) * 100.0f);
|
||||
}
|
||||
|
||||
float allowanceFromAverage(float avgTempC) const {
|
||||
if (avgTempC >= targetTempC_) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const float below = targetTempC_ - avgTempC;
|
||||
if (below >= APPROACH_BAND_C) {
|
||||
return 100.0f;
|
||||
}
|
||||
|
||||
return clampPercent((below / APPROACH_BAND_C) * 100.0f);
|
||||
}
|
||||
|
||||
float heaterMaxDuty(float avgTempC) const {
|
||||
if (avgTempC >= targetTempC_) {
|
||||
return HEATER_MAX_DUTY_NEAR;
|
||||
}
|
||||
|
||||
const float below = targetTempC_ - avgTempC;
|
||||
if (below >= HEATER_COLD_BELOW_C) {
|
||||
return HEATER_MAX_DUTY_COLD;
|
||||
}
|
||||
if (below >= HEATER_WARM_BELOW_C) {
|
||||
return HEATER_MAX_DUTY_MID;
|
||||
}
|
||||
return HEATER_MAX_DUTY_NEAR;
|
||||
}
|
||||
|
||||
float heaterAllowancePercent(float avgTempC, float maxTempC) const {
|
||||
const float fromMax = allowanceFromMaxCorner(maxTempC, avgTempC);
|
||||
const float fromAvg = allowanceFromAverage(avgTempC);
|
||||
float allowance = fromMax < fromAvg ? fromMax : fromAvg;
|
||||
const float maxDuty = heaterMaxDuty(avgTempC);
|
||||
if (allowance > maxDuty) {
|
||||
allowance = maxDuty;
|
||||
}
|
||||
return allowance;
|
||||
}
|
||||
|
||||
float applyHeaterRamp(float requestedDuty, float avgTempC, uint32_t nowMs) {
|
||||
const float maxDuty = heaterMaxDuty(avgTempC);
|
||||
if (requestedDuty > maxDuty) {
|
||||
requestedDuty = maxDuty;
|
||||
}
|
||||
|
||||
if (lastHeaterUpdateMs_ > 0 && requestedDuty > heaterDutyPercent_) {
|
||||
const float dt = static_cast<float>(nowMs - lastHeaterUpdateMs_) / 1000.0f;
|
||||
const float maxUp = heaterDutyPercent_ + HEATER_SLEW_UP_PER_S * dt;
|
||||
if (requestedDuty > maxUp) {
|
||||
requestedDuty = maxUp;
|
||||
}
|
||||
}
|
||||
|
||||
return requestedDuty;
|
||||
}
|
||||
|
||||
uint8_t 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) {
|
||||
if (heaterDutyPercent_ <= 0.0f) {
|
||||
forceHeaterOff();
|
||||
@@ -614,7 +584,7 @@ private:
|
||||
}
|
||||
|
||||
if (isIdle()) {
|
||||
if (!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
|
||||
if (sensorWarmValid_ && lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
|
||||
writeFan(FAN_MAX_PWM);
|
||||
} else if (fanIdleOverride_) {
|
||||
writeFan(FAN_IDLE_PWM);
|
||||
@@ -628,22 +598,23 @@ private:
|
||||
writeFan(FAN_MAX_PWM);
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t duty = fanPwmForHeatUp();
|
||||
writeFan(duty);
|
||||
}
|
||||
|
||||
PidController pid_;
|
||||
PidController heatPi_;
|
||||
PidAutotuner autotuner_;
|
||||
FanCharacterize fanchars_;
|
||||
float targetTempC_;
|
||||
float heaterDutyPercent_;
|
||||
float heaterAllowancePercent_;
|
||||
float cornerSpreadC_;
|
||||
float lastMaxTempC_;
|
||||
uint8_t regulatingFanPwm_;
|
||||
uint8_t stirFanPwm_;
|
||||
uint8_t fanManualPwm_;
|
||||
uint8_t fanPwm_;
|
||||
uint8_t fanMixMax_;
|
||||
bool adaptiveEnabled_;
|
||||
bool tuningLoaded_;
|
||||
bool fanIdleOverride_;
|
||||
bool fanManualActive_;
|
||||
bool sensorWarmValid_;
|
||||
bool cutoffActive_;
|
||||
bool failSafeActive_;
|
||||
|
||||
@@ -5,15 +5,13 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
static const uint16_t TUNING_MAGIC = 0xDA7A;
|
||||
static const uint16_t TUNING_MAGIC = 0xDA7C;
|
||||
static const int TUNING_EEPROM_ADDR = 0;
|
||||
|
||||
struct TuningData {
|
||||
uint16_t magic = 0;
|
||||
float kp = PID_KP;
|
||||
float ki = PID_KI;
|
||||
float kd = PID_KD;
|
||||
uint8_t fanMixMax = FAN_MIX_MAX_PWM;
|
||||
float heatKp = HEAT_PI_KP;
|
||||
float heatKi = HEAT_PI_KI;
|
||||
};
|
||||
|
||||
inline uint8_t tuningChecksum(const TuningData &data) {
|
||||
|
||||
@@ -26,7 +26,7 @@ python3 scripts/capture_csv.py log
|
||||
# logs/dryer_YYYYMMDD_HHMMSS.csv
|
||||
```
|
||||
|
||||
**TUI keys:** `0` idle · `t` target · `p` presets · `f` fan on · `F` fan off · `l` toggle CSV log · `a` autotune · `:` raw command · `q` quit
|
||||
**TUI keys:** `0` idle · `t` target · `p` presets · `f` fan on · `F` fan off · `l` CSV log · `a` autotune · `c` fanchars · `:` command · `q` quit
|
||||
|
||||
**Always-on logging**
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ platform = atmelavr
|
||||
board = nanoatmega328
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
build_flags = -flto
|
||||
lib_deps =
|
||||
adafruit/Adafruit SHT31 Library@^2.2.2
|
||||
adafruit/Adafruit BusIO@^1.16.1
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -9,6 +9,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
@@ -19,6 +20,122 @@ FALLBACK_HEADER = (
|
||||
"fan_pct,cutoff,failsafe,ch2_t,ch2_h,ch3_t,ch3_h,ch4_t,ch4_h,ch5_t,ch5_h"
|
||||
)
|
||||
|
||||
SENSOR_CHANNELS = [2, 3, 4, 5]
|
||||
|
||||
# If no bytes at all arrive for this long, treat the device as hung (e.g. an
|
||||
# I2C bus lockup freezing the Arduino) rather than looping forever in silence.
|
||||
DEFAULT_STALL_TIMEOUT_S = 20.0
|
||||
|
||||
_FAN_PCT_RE = re.compile(r"\((\d+)%\)")
|
||||
_FAN_PWM_RE = re.compile(r"^(\d+)/")
|
||||
|
||||
|
||||
def fan_pct_from_status(fan: str) -> int:
|
||||
match = _FAN_PCT_RE.search(fan)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
match = _FAN_PWM_RE.match(fan)
|
||||
if match:
|
||||
return (int(match.group(1)) * 100) // 255
|
||||
return 0
|
||||
|
||||
|
||||
def target_c_from_status(target: str) -> str:
|
||||
if target.startswith("idle"):
|
||||
return "0.0"
|
||||
if target.endswith("C"):
|
||||
return target[:-1]
|
||||
return target
|
||||
|
||||
|
||||
def build_csv_payload_from_status(data: dict, ms: int | None = None) -> str:
|
||||
if ms is None:
|
||||
ms = int(time.time() * 1000)
|
||||
|
||||
sensors = {ch: (temp, hum) for ch, temp, hum in data.get("sensor_list", [])}
|
||||
parts = [
|
||||
str(ms),
|
||||
target_c_from_status(data["target"]),
|
||||
data["avg"],
|
||||
data["min"],
|
||||
data["max"],
|
||||
data["spread"],
|
||||
data["heatlim"],
|
||||
data["heater"],
|
||||
str(fan_pct_from_status(data.get("fan", "0"))),
|
||||
"1" if data.get("cutoff_active") == "YES" else "0",
|
||||
"1" if data.get("failsafe") == "YES" else "0",
|
||||
]
|
||||
for ch in SENSOR_CHANNELS:
|
||||
if str(ch) in sensors:
|
||||
temp, hum = sensors[str(ch)]
|
||||
if temp == "ERR":
|
||||
parts.extend(["", ""])
|
||||
else:
|
||||
parts.extend([temp, hum])
|
||||
else:
|
||||
parts.extend(["", ""])
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
class CsvSession:
|
||||
"""Deferred CSV writer — no empty file until the first row lands."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self._fh = None
|
||||
self._header_written = False
|
||||
self.row_count = 0
|
||||
|
||||
def _ensure_open(self) -> None:
|
||||
if self._fh is None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._fh = self.path.open("w", encoding="utf-8")
|
||||
|
||||
def _write_header(self) -> None:
|
||||
if not self._header_written:
|
||||
self._ensure_open()
|
||||
assert self._fh is not None
|
||||
self._fh.write(FALLBACK_HEADER + "\n")
|
||||
self._header_written = True
|
||||
|
||||
def write_device_line(self, line: str) -> None:
|
||||
if line.startswith("csv_hdr,"):
|
||||
self._ensure_open()
|
||||
assert self._fh is not None
|
||||
device_header = line[len("csv_hdr,") :]
|
||||
self._fh.write("wall_time," + device_header + "\n")
|
||||
self._header_written = True
|
||||
self._fh.flush()
|
||||
return
|
||||
if not line.startswith("csv,"):
|
||||
return
|
||||
self._write_header()
|
||||
assert self._fh is not None
|
||||
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
self._fh.write(wall_time + "," + line[len("csv,") :] + "\n")
|
||||
self._fh.flush()
|
||||
self.row_count += 1
|
||||
|
||||
def write_status(self, data: dict) -> None:
|
||||
self._write_header()
|
||||
assert self._fh is not None
|
||||
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
payload = build_csv_payload_from_status(data)
|
||||
self._fh.write(wall_time + "," + payload + "\n")
|
||||
self._fh.flush()
|
||||
self.row_count += 1
|
||||
|
||||
def close(self) -> None:
|
||||
if self._fh is not None:
|
||||
self._fh.close()
|
||||
self._fh = None
|
||||
if self.row_count == 0 and self.path.exists():
|
||||
try:
|
||||
self.path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def detect_serial_port() -> str | None:
|
||||
by_id = Path("/dev/serial/by-id")
|
||||
@@ -93,69 +210,71 @@ def enable_dryer_logging(ser, retries: int = 3) -> None:
|
||||
print("WARN: did not see 'OK csv logging on' — continuing anyway", file=sys.stderr)
|
||||
|
||||
|
||||
def write_csv_row(fh, line: str, header_written: list[bool]) -> None:
|
||||
if line.startswith("csv_hdr,"):
|
||||
device_header = line[len("csv_hdr,") :]
|
||||
fh.write("wall_time," + device_header + "\n")
|
||||
header_written[0] = True
|
||||
fh.flush()
|
||||
return
|
||||
if not line.startswith("csv,"):
|
||||
return
|
||||
if not header_written[0]:
|
||||
fh.write(FALLBACK_HEADER + "\n")
|
||||
header_written[0] = True
|
||||
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
fh.write(wall_time + "," + line[len("csv,") :] + "\n")
|
||||
fh.flush()
|
||||
def log_notice(message: str) -> None:
|
||||
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
print(f"{stamp} {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_log(args: argparse.Namespace) -> int:
|
||||
from dryer_tui import parse_status
|
||||
|
||||
port = resolve_port(args.port)
|
||||
out = args.output
|
||||
if out is None:
|
||||
out = args.log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stall_timeout = args.stall_timeout
|
||||
print(f"Logging {port} -> {out}", file=sys.stderr)
|
||||
if args.auto_log_on:
|
||||
print("Will send 'log on' after connect", file=sys.stderr)
|
||||
|
||||
header_written = False
|
||||
with open_serial(port, args.baud) as ser, out.open("w", encoding="utf-8") as fh:
|
||||
session = CsvSession(out)
|
||||
with open_serial(port, args.baud) as ser:
|
||||
if args.auto_log_on:
|
||||
enable_dryer_logging(ser)
|
||||
|
||||
last_activity = time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
raw = ser.readline()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped.", file=sys.stderr)
|
||||
print(f"\nStopped ({session.row_count} rows).", file=sys.stderr)
|
||||
session.close()
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log_notice(
|
||||
f"ERROR: serial read failed ({exc}) — closing after "
|
||||
f"{session.row_count} rows"
|
||||
)
|
||||
session.close()
|
||||
return 1
|
||||
|
||||
if not raw:
|
||||
if time.monotonic() - last_activity >= stall_timeout:
|
||||
log_notice(
|
||||
f"WARN: no data from {port} for {stall_timeout:.0f}s — "
|
||||
f"device likely hung (e.g. I2C bus lockup on the Arduino) "
|
||||
f"— closing after {session.row_count} rows"
|
||||
)
|
||||
session.close()
|
||||
return 1
|
||||
continue
|
||||
|
||||
last_activity = time.monotonic()
|
||||
line = decode_line(raw)
|
||||
if not line.startswith("csv_hdr,") and not line.startswith("csv,"):
|
||||
if line:
|
||||
parsed = parse_status(line)
|
||||
if parsed:
|
||||
session.write_status(parsed)
|
||||
print(line)
|
||||
continue
|
||||
|
||||
if line.startswith("csv_hdr,"):
|
||||
device_header = line[len("csv_hdr,") :]
|
||||
fh.write("wall_time," + device_header + "\n")
|
||||
header_written = True
|
||||
fh.flush()
|
||||
if line.startswith("csv_hdr,") or line.startswith("csv,"):
|
||||
session.write_device_line(line)
|
||||
continue
|
||||
|
||||
if not header_written:
|
||||
fh.write(FALLBACK_HEADER + "\n")
|
||||
header_written = True
|
||||
|
||||
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
fh.write(wall_time + "," + line[len("csv,") :] + "\n")
|
||||
fh.flush()
|
||||
if line:
|
||||
print(line)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_tui(args: argparse.Namespace) -> int:
|
||||
@@ -185,6 +304,15 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
default=True,
|
||||
help="Send 'log on' after connect in log mode (default: on)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stall-timeout",
|
||||
type=float,
|
||||
default=DEFAULT_STALL_TIMEOUT_S,
|
||||
help=(
|
||||
"log mode: seconds without any data before treating the device as "
|
||||
f"hung and exiting (default: {DEFAULT_STALL_TIMEOUT_S:.0f})"
|
||||
),
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="action")
|
||||
log_p = subparsers.add_parser("log", help="Headless CSV capture", add_help=False)
|
||||
@@ -193,6 +321,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
log_p.add_argument("-o", "--output", type=Path)
|
||||
log_p.add_argument("--log-dir", type=Path, default=Path("logs"))
|
||||
log_p.add_argument("--auto-log-on", action=argparse.BooleanOptionalAction, default=True)
|
||||
log_p.add_argument("--stall-timeout", type=float, default=DEFAULT_STALL_TIMEOUT_S)
|
||||
|
||||
tui_p = subparsers.add_parser("tui", help="Interactive curses dashboard")
|
||||
tui_p.add_argument("-p", "--port")
|
||||
|
||||
@@ -7,14 +7,15 @@ import curses
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from capture_csv import (
|
||||
CsvSession,
|
||||
decode_line,
|
||||
write_csv_row,
|
||||
)
|
||||
|
||||
PRESETS: list[tuple[str, float]] = [
|
||||
@@ -43,7 +44,7 @@ STATUS_RE = re.compile(
|
||||
r"htop=(?P<htop>\S+)\s+"
|
||||
r"hblk=(?P<hblk>\S+)\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"failsafe=(?P<failsafe>\S+)\s+"
|
||||
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")
|
||||
|
||||
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)
|
||||
if match:
|
||||
d = match.groupdict()
|
||||
return (
|
||||
f"Autotune {d['phase']}: {d['elapsed']}s elapsed, "
|
||||
summary = (
|
||||
f"Autotune {d['phase']}: {d['elapsed']}s, "
|
||||
f"{d['cycles']} cycles, preheat avg >= {d['pre']} C"
|
||||
)
|
||||
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
|
||||
@@ -85,8 +143,9 @@ class DryerState:
|
||||
cutoff_active: str = "no"
|
||||
failsafe: str = "no"
|
||||
mode: str = "—"
|
||||
activity: str = ""
|
||||
sensors: list[tuple[str, str, str]] = field(default_factory=list)
|
||||
messages: deque[str] = field(default_factory=lambda: deque(maxlen=12))
|
||||
messages: deque[str] = field(default_factory=lambda: deque(maxlen=24))
|
||||
csv_logging: bool = False
|
||||
csv_path: Path | None = None
|
||||
port: str = ""
|
||||
@@ -139,13 +198,20 @@ def apply_status(state: DryerState, data: dict) -> None:
|
||||
state.hblk = data["hblk"]
|
||||
state.ssr = data["ssr"]
|
||||
fan_raw = data["fan"]
|
||||
state.fan = fan_raw
|
||||
state.fan = format_fan_display(fan_raw)
|
||||
state.fan_note = ""
|
||||
if fan_raw.endswith("(off)") or fan_raw.endswith("(cooldown)") or " 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 ""
|
||||
elif "(manual)" in fan_raw or "(stir)" in fan_raw:
|
||||
state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else ""
|
||||
elif "(fanchars-" in fan_raw:
|
||||
state.fan_note = fan_raw[fan_raw.find("(fanchars-") :]
|
||||
state.cutoff_active = data["cutoff_active"]
|
||||
state.failsafe = data["failsafe"]
|
||||
state.mode = data["mode"]
|
||||
summary, activity = format_mode_line(data["mode"], data["avg"])
|
||||
state.mode = summary
|
||||
state.activity = activity
|
||||
state.sensors = data["sensor_list"]
|
||||
|
||||
|
||||
@@ -155,8 +221,7 @@ class SerialWorker:
|
||||
self.state = state
|
||||
self.lock = lock
|
||||
self.stop = threading.Event()
|
||||
self._log_fh = None
|
||||
self._header_written = [False]
|
||||
self._csv: CsvSession | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -171,9 +236,9 @@ class SerialWorker:
|
||||
self.stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=1.5)
|
||||
if self._log_fh is not None:
|
||||
self._log_fh.close()
|
||||
self._log_fh = None
|
||||
if self._csv is not None:
|
||||
self._csv.close()
|
||||
self._csv = None
|
||||
|
||||
def send(self, command: str) -> None:
|
||||
if self.ser is None:
|
||||
@@ -186,19 +251,21 @@ class SerialWorker:
|
||||
if enabled and not self.state.csv_logging:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
|
||||
self._log_fh = path.open("w", encoding="utf-8")
|
||||
self._header_written = [False]
|
||||
self._csv = CsvSession(path)
|
||||
self.state.csv_path = path
|
||||
self.state.csv_logging = True
|
||||
self.state.messages.append(f"CSV -> {path.name}")
|
||||
self.state.messages.append(f"CSV -> {path.name} (on status)")
|
||||
self.send("log on")
|
||||
elif not enabled and self.state.csv_logging:
|
||||
self.send("log off")
|
||||
self.state.csv_logging = False
|
||||
self.state.csv_path = None
|
||||
if self._log_fh is not None:
|
||||
self._log_fh.close()
|
||||
self._log_fh = None
|
||||
if self._csv is not None:
|
||||
rows = self._csv.row_count
|
||||
self._csv.close()
|
||||
self._csv = None
|
||||
self.state.messages.append(f"CSV logging off ({rows} rows)")
|
||||
else:
|
||||
self.state.messages.append("CSV logging off")
|
||||
|
||||
def _note(self, line: str) -> None:
|
||||
@@ -220,15 +287,19 @@ class SerialWorker:
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("fanchars:"):
|
||||
self._note(line)
|
||||
continue
|
||||
|
||||
if line.startswith("csv,") or line.startswith("csv_hdr,"):
|
||||
if self._log_fh is not None:
|
||||
write_csv_row(self._log_fh, line, self._header_written)
|
||||
continue
|
||||
|
||||
parsed = parse_status(line)
|
||||
if parsed:
|
||||
with self.lock:
|
||||
apply_status(self.state, parsed)
|
||||
if self._csv is not None:
|
||||
self._csv.write_status(parsed)
|
||||
continue
|
||||
|
||||
if line.startswith("target="):
|
||||
@@ -335,8 +406,8 @@ def _preset_menu(stdscr, worker: SerialWorker) -> None:
|
||||
def _draw_dashboard(stdscr, state: DryerState) -> None:
|
||||
stdscr.erase()
|
||||
height, width = stdscr.getmaxyx()
|
||||
if height < 19 or width < 60:
|
||||
_safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x19).")
|
||||
if height < 20 or width < 60:
|
||||
_safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x20).")
|
||||
stdscr.refresh()
|
||||
return
|
||||
|
||||
@@ -349,14 +420,19 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
|
||||
_safe_addstr(stdscr, row, 36, f"Trip: {state.cutoff_active}", cutoff_attr)
|
||||
|
||||
row += 1
|
||||
mode_text = format_mode_line(state.mode)
|
||||
mode_text = state.mode
|
||||
_safe_addstr(stdscr, row, 2, f"{mode_text[: max(0, width - 18)]} FS: {state.failsafe}")
|
||||
|
||||
row += 1
|
||||
if state.activity:
|
||||
_safe_addstr(stdscr, row, 2, state.activity[: max(0, width - 4)], curses.A_DIM)
|
||||
row += 1
|
||||
_safe_addstr(stdscr, row, 2, f"Avg: {state.avg} C Min: {state.min_temp} C Max: {state.max_temp} C Spread: {state.spread} C")
|
||||
|
||||
row += 1
|
||||
fan_text = state.fan if state.fan_note == "" else state.fan
|
||||
fan_text = state.fan
|
||||
if state.fan_note and "(fanchars-" not in state.fan_note:
|
||||
fan_text = f"{state.fan} {state.fan_note}"
|
||||
_safe_addstr(
|
||||
stdscr,
|
||||
row,
|
||||
@@ -408,7 +484,7 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
|
||||
stdscr,
|
||||
help_y,
|
||||
1,
|
||||
"0 idle | t target | p presets | f fan | l log | a autotune | : cmd | q quit",
|
||||
"0 idle | t target | p presets | f fan | l log | a autotune | c fanchars | : cmd | q quit",
|
||||
curses.A_DIM,
|
||||
)
|
||||
stdscr.refresh()
|
||||
@@ -428,6 +504,8 @@ def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
|
||||
worker = SerialWorker(ser, state, lock)
|
||||
worker.start()
|
||||
worker.send("status")
|
||||
time.sleep(0.4)
|
||||
worker.send("status")
|
||||
if auto_log_on:
|
||||
worker.set_csv_logging(True, log_dir)
|
||||
|
||||
@@ -451,8 +529,9 @@ def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
|
||||
cutoff_active=state.cutoff_active,
|
||||
failsafe=state.failsafe,
|
||||
mode=state.mode,
|
||||
activity=state.activity,
|
||||
sensors=list(state.sensors),
|
||||
messages=deque(state.messages, maxlen=12),
|
||||
messages=deque(state.messages, maxlen=24),
|
||||
csv_logging=state.csv_logging,
|
||||
csv_path=state.csv_path,
|
||||
port=state.port,
|
||||
@@ -490,6 +569,9 @@ def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
|
||||
if value is not None:
|
||||
cmd = "autotune" if value == "" else f"autotune {value}"
|
||||
worker.send(cmd)
|
||||
elif key == ord("c"):
|
||||
worker.send("fanchars")
|
||||
worker._note("Started fanchars — 30/100/60/80% then refine if needed")
|
||||
elif key == ord(":"):
|
||||
value = _prompt(stdscr, "Command")
|
||||
if value is not None and value != "":
|
||||
|
||||
187
scripts/fan_characterize.py
Normal file
187
scripts/fan_characterize.py
Normal 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
147
scripts/fan_test.py
Normal 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
232
scripts/plot_logs.py
Normal 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())
|
||||
@@ -1 +1,2 @@
|
||||
pyserial>=3.5
|
||||
matplotlib>=3.8
|
||||
|
||||
354
src/fan_characterize.cpp
Normal file
354
src/fan_characterize.cpp
Normal 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;
|
||||
}
|
||||
164
src/main.cpp
164
src/main.cpp
@@ -51,6 +51,9 @@ void readAllSensors() {
|
||||
readSensorOnChannel(SENSOR_CHANNELS[i], sensors[i]);
|
||||
}
|
||||
mux.disableAll();
|
||||
if (Wire.getWireTimeoutFlag()) {
|
||||
Wire.clearWireTimeoutFlag();
|
||||
}
|
||||
}
|
||||
|
||||
float averageValidTemperature() {
|
||||
@@ -142,10 +145,18 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
|
||||
Serial.print(F("%)"));
|
||||
if (thermal.isFanTestActive(millis())) {
|
||||
Serial.print(F(" TEST"));
|
||||
} else if (thermal.isFanCharacterizeActive()) {
|
||||
Serial.print(F("(fanchars-"));
|
||||
Serial.print(thermal.fanCharacterizePhaseName());
|
||||
Serial.print(F(")"));
|
||||
} else if (thermal.isIdle() && thermal.isFanOff()) {
|
||||
Serial.print(F("(off)"));
|
||||
} else if (thermal.isIdleCooling()) {
|
||||
Serial.print(F("(cooldown)"));
|
||||
} else if (thermal.isFanManualOverride()) {
|
||||
Serial.print(F("(manual)"));
|
||||
} else if (!thermal.isIdle()) {
|
||||
Serial.print(F("(stir)"));
|
||||
}
|
||||
Serial.print(F(" cutoff="));
|
||||
Serial.print(thermal.isCutoffActive() ? F("YES") : F("no"));
|
||||
@@ -164,10 +175,32 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
|
||||
Serial.print(F("cyc pre>="));
|
||||
Serial.print(thermal.autotunePreheatTargetC(), 0);
|
||||
Serial.print(F("C"));
|
||||
} else if (thermal.isAdaptive()) {
|
||||
Serial.print(F("learned"));
|
||||
} else if (thermal.isFanCharacterizeActive()) {
|
||||
Serial.print(F("fanchars/"));
|
||||
Serial.print(thermal.fanCharacterizePhaseName());
|
||||
Serial.print(F(" "));
|
||||
Serial.print(thermal.fanCharacterizeElapsedMs(millis()) / 1000UL);
|
||||
Serial.print(F("s run "));
|
||||
const char *fcPhase = thermal.fanCharacterizePhaseName();
|
||||
if (strcmp(fcPhase, "precool") == 0) {
|
||||
Serial.print(F("pre"));
|
||||
} else if (strcmp(fcPhase, "cool") == 0) {
|
||||
Serial.print(F("n"));
|
||||
Serial.print(thermal.fanCharacterizeProfileIndex() + 1);
|
||||
} else if (thermal.isFanCharacterizeRefineRun()) {
|
||||
Serial.print(F("refine"));
|
||||
} else {
|
||||
Serial.print(F("manual"));
|
||||
Serial.print(thermal.fanCharacterizeProfileIndex() + 1);
|
||||
}
|
||||
Serial.print(F("/"));
|
||||
Serial.print(thermal.fanCharacterizeProfileCount());
|
||||
Serial.print(F(" fan="));
|
||||
Serial.print(thermal.fanCharacterizeFanPwm());
|
||||
Serial.print(F(" heat="));
|
||||
Serial.print(thermal.fanCharacterizeHeaterPct(), 0);
|
||||
Serial.print(F("%"));
|
||||
} else {
|
||||
Serial.print(thermal.regulatingModeName());
|
||||
}
|
||||
Serial.print(F(" sensors=["));
|
||||
|
||||
@@ -196,11 +229,18 @@ void printHelp() {
|
||||
Serial.println(F(" target <C> set target (0 = idle)"));
|
||||
Serial.println(F(" fan off cancel idle fan override (auto-off below 40C)"));
|
||||
Serial.println(F(" fan on idle fan 30% (optional, auto-off below 40C)"));
|
||||
Serial.println(F(" fan auto regulating: back to default stir fan"));
|
||||
Serial.println(F(" fan <pwm> regulating: manual fan override (0-255)"));
|
||||
Serial.println(F(" fan stir show default stir fan PWM"));
|
||||
Serial.println(F(" fan stir N set default stir fan + EEPROM"));
|
||||
Serial.println(F(" fan test N set fan PWM 0-255 for 15s (verify wiring)"));
|
||||
Serial.println(F(" autotune [C] learn 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(" pid show PID / adaptive status"));
|
||||
Serial.println(F(" pid default reset to factory PID"));
|
||||
Serial.println(F(" fanchars learn stir fan (30/100/60/80%% + refine)"));
|
||||
Serial.println(F(" fanchars stop | fanchars save"));
|
||||
Serial.println(F(" pid show heat PI gains"));
|
||||
Serial.println(F(" pid default reset heat PI to factory"));
|
||||
Serial.println(F(" pid save write current PI to EEPROM"));
|
||||
Serial.println(F(" status print current readings"));
|
||||
Serial.println(F(" log on|off CSV data stream"));
|
||||
Serial.println(F(" help show this message"));
|
||||
@@ -289,6 +329,58 @@ void processSerialLine(const char *line) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(line, "fan auto") == 0) {
|
||||
if (thermal.isIdle()) {
|
||||
Serial.println(F("ERR fan auto only when regulating (set target > 0)"));
|
||||
return;
|
||||
}
|
||||
thermal.clearRegulatingFanManual();
|
||||
Serial.print(F("OK fan stir "));
|
||||
Serial.println(thermal.stirFanPwm());
|
||||
return;
|
||||
}
|
||||
|
||||
if (strncmp(line, "fan stir", 8) == 0) {
|
||||
if (line[8] == '\0') {
|
||||
Serial.print(F("stir fan PWM="));
|
||||
Serial.println(thermal.stirFanPwm());
|
||||
return;
|
||||
}
|
||||
if (line[8] == ' ') {
|
||||
const int pwm = atoi(line + 9);
|
||||
if (pwm < 1 || pwm > 255) {
|
||||
Serial.println(F("ERR fan stir PWM must be 1-255"));
|
||||
return;
|
||||
}
|
||||
thermal.setStirFanPwm(static_cast<uint8_t>(pwm));
|
||||
if (!thermal.isIdle()) {
|
||||
thermal.clearRegulatingFanManual();
|
||||
}
|
||||
Serial.print(F("OK stir fan "));
|
||||
Serial.println(pwm);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (strncmp(line, "fan ", 4) == 0) {
|
||||
const char *arg = line + 4;
|
||||
if (*arg >= '0' && *arg <= '9') {
|
||||
const int pwm = atoi(arg);
|
||||
if (pwm < 0 || pwm > 255) {
|
||||
Serial.println(F("ERR fan PWM must be 0-255"));
|
||||
return;
|
||||
}
|
||||
if (thermal.isIdle()) {
|
||||
Serial.println(F("ERR fan <pwm> only when regulating (or use fan test)"));
|
||||
return;
|
||||
}
|
||||
thermal.setRegulatingFanManual(static_cast<uint8_t>(pwm));
|
||||
Serial.print(F("OK fan manual "));
|
||||
Serial.println(pwm);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (strcmp(line, "status") == 0) {
|
||||
const float avgTemp = averageValidTemperature();
|
||||
const float minTemp = minValidTemperature();
|
||||
@@ -328,6 +420,44 @@ void processSerialLine(const char *line) {
|
||||
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) {
|
||||
thermal.printTuning();
|
||||
return;
|
||||
@@ -338,6 +468,11 @@ void processSerialLine(const char *line) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(line, "pid save") == 0) {
|
||||
thermal.saveTuningToEeprom();
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(line, "help") == 0) {
|
||||
printHelp();
|
||||
return;
|
||||
@@ -389,6 +524,11 @@ void setup() {
|
||||
}
|
||||
|
||||
Wire.begin();
|
||||
// Without a timeout, a glitched I2C transaction (electrical noise, a
|
||||
// momentary bad connection) can hang the AVR's Wire library forever,
|
||||
// freezing the whole sketch. This bounds any transaction and resets the
|
||||
// TWI hardware so the loop keeps running instead of locking up silently.
|
||||
Wire.setWireTimeout(I2C_TIMEOUT_US, true);
|
||||
|
||||
if (!mux.begin()) {
|
||||
Serial.println(F("ERROR: TCA9548A not found on I2C bus"));
|
||||
@@ -428,6 +568,18 @@ void loop() {
|
||||
|
||||
if (!isnan(avgTemp) && !isnan(maxTemp) && !isnan(spread)) {
|
||||
thermal.update(avgTemp, maxTemp, spread, now);
|
||||
|
||||
float sensorTemps[SENSOR_COUNT];
|
||||
bool sensorValid[SENSOR_COUNT];
|
||||
for (uint8_t i = 0; i < SENSOR_COUNT; ++i) {
|
||||
sensorTemps[i] = sensors[i].temperatureC;
|
||||
sensorValid[i] = sensors[i].valid;
|
||||
}
|
||||
const float minTemp = minValidTemperature();
|
||||
if (thermal.isFanCharacterizeActive() && !isnan(minTemp)) {
|
||||
thermal.logFanCharacterizeIfDue(sensorTemps, sensorValid, SENSOR_COUNT, avgTemp, minTemp,
|
||||
maxTemp, spread, now);
|
||||
}
|
||||
} else {
|
||||
thermal.enterFailSafe();
|
||||
Serial.println(F("WARN: no valid sensor readings — heater off"));
|
||||
|
||||
Reference in New Issue
Block a user