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