safety commit
This commit is contained in:
@@ -25,7 +25,10 @@ static const float TARGET_TEMP_C = 0.0f; // power-on default: idle (heater off)
|
||||
static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f; // autotune when no temp given and idle
|
||||
static const float TARGET_MIN_C = 0.0f; // 0 = idle (heater off, fan at idle speed)
|
||||
static const float TARGET_MAX_C = 80.0f;
|
||||
static const float OVERTEMP_FRACTION = 0.05f; // hard cutoff at target * 1.05
|
||||
// Absolute max corner temp — heater off + full fan. Decoupled from PID target so you can
|
||||
// run target 50–55 while tuning with headroom for hot corners (ABS in chamber).
|
||||
static const float EMERGENCY_MAX_TEMP_C = 70.0f;
|
||||
static const float CORNER_STOP_MARGIN_C = 3.0f; // taper heater when max within this of emergency
|
||||
|
||||
// PID on chamber average
|
||||
static const float PID_KP = 4.0f;
|
||||
@@ -67,6 +70,9 @@ static const float FAN_OFF_BELOW_TARGET_C = 8.0f; // no heat-up fan when avg thi
|
||||
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
|
||||
// Most 24 V MOSFET modules are active-low (pin LOW = fan on). If off/speed seem wrong,
|
||||
// try flipping this and reflash. Test: `fan test 0` (off) vs `fan test 200` vs `fan test 255`.
|
||||
static const bool FAN_PWM_INVERT = true;
|
||||
|
||||
// Corner mixing — moderate airflow; full speed reserved for safety
|
||||
static const float SPREAD_DEADBAND_C = 0.5f;
|
||||
@@ -84,6 +90,20 @@ static const uint32_t AUTOTUNE_RELAY_STALL_MS = 1500000UL; // 25 min in rel
|
||||
static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL; // 60 min total
|
||||
static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL;
|
||||
|
||||
// Fan step-response — open-loop heater, fan PWM steps (command: stepresp)
|
||||
static const float STEPRESP_DEFAULT_TEMP_C = 45.0f;
|
||||
static const float STEPRESP_DEFAULT_HEATER_PCT = 35.0f;
|
||||
static const float STEPRESP_MIN_HEATER_PCT = 10.0f;
|
||||
static const float STEPRESP_MAX_HEATER_PCT = 70.0f;
|
||||
static const float STEPRESP_PREHEAT_BAND_C = 2.0f;
|
||||
static const uint32_t STEPRESP_PREHEAT_TIMEOUT_MS = 1200000UL; // 20 min
|
||||
static const uint32_t STEPRESP_BASELINE_MS = 120000UL; // 2 min fan-off baseline
|
||||
static const uint32_t STEPRESP_STEP_HOLD_MS = 300000UL; // 5 min per fan level
|
||||
static const uint32_t STEPRESP_LOG_INTERVAL_MS = 1000UL;
|
||||
static const uint8_t STEPRESP_FAN_STEPS[] = {0, 77, 140, 200, 255};
|
||||
static const uint8_t STEPRESP_FAN_STEP_COUNT =
|
||||
sizeof(STEPRESP_FAN_STEPS) / sizeof(STEPRESP_FAN_STEPS[0]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
268
include/fan_step_response.h
Normal file
268
include/fan_step_response.h
Normal file
@@ -0,0 +1,268 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
class FanStepResponse {
|
||||
public:
|
||||
enum class Phase : uint8_t { Idle, Preheat, Baseline, StepHold, Done, Failed };
|
||||
|
||||
FanStepResponse()
|
||||
: phase_(Phase::Idle),
|
||||
targetC_(STEPRESP_DEFAULT_TEMP_C),
|
||||
heaterPct_(STEPRESP_DEFAULT_HEATER_PCT),
|
||||
stepIndex_(0),
|
||||
sessionStartMs_(0),
|
||||
phaseStartMs_(0),
|
||||
lastLogMs_(0),
|
||||
lastAvgC_(0.0f) {}
|
||||
|
||||
Phase phase() const { return phase_; }
|
||||
|
||||
bool isActive() const {
|
||||
return phase_ == Phase::Preheat || phase_ == Phase::Baseline || phase_ == Phase::StepHold;
|
||||
}
|
||||
|
||||
uint32_t elapsedMs(uint32_t nowMs) const {
|
||||
if (sessionStartMs_ == 0) {
|
||||
return 0;
|
||||
}
|
||||
return nowMs - sessionStartMs_;
|
||||
}
|
||||
|
||||
uint8_t stepIndex() const { return stepIndex_; }
|
||||
|
||||
uint8_t stepCount() const { return STEPRESP_FAN_STEP_COUNT; }
|
||||
|
||||
float targetC() const { return targetC_; }
|
||||
|
||||
float heaterPct() const { return heaterPct_; }
|
||||
|
||||
uint8_t currentFanPwm() const {
|
||||
if (stepIndex_ >= STEPRESP_FAN_STEP_COUNT) {
|
||||
return 0;
|
||||
}
|
||||
return STEPRESP_FAN_STEPS[stepIndex_];
|
||||
}
|
||||
|
||||
const char *phaseName() const {
|
||||
switch (phase_) {
|
||||
case Phase::Preheat:
|
||||
return "preheat";
|
||||
case Phase::Baseline:
|
||||
return "baseline";
|
||||
case Phase::StepHold:
|
||||
return "step";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
bool start(float targetC, float heaterPct) {
|
||||
if (targetC < 25.0f || targetC > TARGET_MAX_C) {
|
||||
return false;
|
||||
}
|
||||
if (heaterPct < STEPRESP_MIN_HEATER_PCT || heaterPct > STEPRESP_MAX_HEATER_PCT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
targetC_ = targetC;
|
||||
heaterPct_ = heaterPct;
|
||||
stepIndex_ = 0;
|
||||
sessionStartMs_ = millis();
|
||||
phaseStartMs_ = sessionStartMs_;
|
||||
lastLogMs_ = 0;
|
||||
lastAvgC_ = 0.0f;
|
||||
phase_ = Phase::Preheat;
|
||||
|
||||
Serial.print(F("stepresp: preheat to "));
|
||||
Serial.print(targetC_ - STEPRESP_PREHEAT_BAND_C, 1);
|
||||
Serial.print(F("-"));
|
||||
Serial.print(targetC_, 1);
|
||||
Serial.print(F("C avg, heater="));
|
||||
Serial.print(heaterPct_, 0);
|
||||
Serial.println(F("% fan=0"));
|
||||
return true;
|
||||
}
|
||||
|
||||
void abort() {
|
||||
if (isActive()) {
|
||||
Serial.println(F("stepresp: cancelled"));
|
||||
}
|
||||
phase_ = Phase::Idle;
|
||||
sessionStartMs_ = 0;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
phase_ = Phase::Idle;
|
||||
sessionStartMs_ = 0;
|
||||
}
|
||||
|
||||
bool update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut,
|
||||
uint8_t &fanPwmOut) {
|
||||
heaterDutyOut = 0.0f;
|
||||
fanPwmOut = 0;
|
||||
|
||||
if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (maxTempC >= EMERGENCY_MAX_TEMP_C) {
|
||||
fail(F("stepresp: abort — max sensor at safety limit"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (phase_ == Phase::Preheat) {
|
||||
fanPwmOut = 0;
|
||||
if (nowMs - phaseStartMs_ > STEPRESP_PREHEAT_TIMEOUT_MS) {
|
||||
fail(F("stepresp: abort — preheat timeout"));
|
||||
return false;
|
||||
}
|
||||
if (avgTempC >= targetC_ - STEPRESP_PREHEAT_BAND_C) {
|
||||
enterBaseline(nowMs);
|
||||
} else {
|
||||
heaterDutyOut = heaterPct_;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
heaterDutyOut = heaterPct_;
|
||||
fanPwmOut = currentFanPwm();
|
||||
|
||||
if (phase_ == Phase::Baseline) {
|
||||
if (nowMs - phaseStartMs_ >= STEPRESP_BASELINE_MS) {
|
||||
advanceStep(nowMs);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (phase_ == Phase::StepHold) {
|
||||
if (nowMs - phaseStartMs_ >= STEPRESP_STEP_HOLD_MS) {
|
||||
if (stepIndex_ + 1 >= STEPRESP_FAN_STEP_COUNT) {
|
||||
finish(nowMs, avgTempC, spreadC);
|
||||
} else {
|
||||
++stepIndex_;
|
||||
enterStepHold(nowMs, true);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void logIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
|
||||
float avgTempC, float minTempC, float maxTempC, float spreadC, uint32_t nowMs) {
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
if (lastLogMs_ != 0 && nowMs - lastLogMs_ < STEPRESP_LOG_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastLogMs_ = nowMs;
|
||||
lastAvgC_ = avgTempC;
|
||||
|
||||
Serial.print(F("sr,"));
|
||||
Serial.print(nowMs);
|
||||
Serial.print(',');
|
||||
Serial.print(phaseName());
|
||||
Serial.print(',');
|
||||
Serial.print(stepIndex_);
|
||||
Serial.print('/');
|
||||
Serial.print(STEPRESP_FAN_STEP_COUNT);
|
||||
Serial.print(',');
|
||||
Serial.print(heaterPct_, 0);
|
||||
Serial.print(',');
|
||||
Serial.print(currentFanPwm());
|
||||
Serial.print(',');
|
||||
Serial.print(avgTempC, 2);
|
||||
Serial.print(',');
|
||||
Serial.print(minTempC, 2);
|
||||
Serial.print(',');
|
||||
Serial.print(maxTempC, 2);
|
||||
Serial.print(',');
|
||||
Serial.print(spreadC, 2);
|
||||
|
||||
for (uint8_t i = 0; i < sensorCount; ++i) {
|
||||
Serial.print(',');
|
||||
if (sensorValid[i]) {
|
||||
Serial.print(sensorTemps[i], 2);
|
||||
}
|
||||
}
|
||||
|
||||
Serial.println();
|
||||
}
|
||||
|
||||
private:
|
||||
void enterBaseline(uint32_t nowMs) {
|
||||
phase_ = Phase::Baseline;
|
||||
phaseStartMs_ = nowMs;
|
||||
stepIndex_ = 0;
|
||||
Serial.print(F("stepresp: baseline fan="));
|
||||
Serial.print(currentFanPwm());
|
||||
Serial.print(F(" for "));
|
||||
Serial.print(STEPRESP_BASELINE_MS / 1000UL);
|
||||
Serial.println(F("s"));
|
||||
}
|
||||
|
||||
void enterStepHold(uint32_t nowMs, bool isStep) {
|
||||
phase_ = Phase::StepHold;
|
||||
phaseStartMs_ = nowMs;
|
||||
Serial.print(F("stepresp: "));
|
||||
if (isStep) {
|
||||
Serial.print(F("step "));
|
||||
}
|
||||
Serial.print(stepIndex_ + 1);
|
||||
Serial.print(F("/"));
|
||||
Serial.print(STEPRESP_FAN_STEP_COUNT);
|
||||
Serial.print(F(" fan="));
|
||||
Serial.print(currentFanPwm());
|
||||
Serial.print(F(" ("));
|
||||
Serial.print((currentFanPwm() * 100) / 255);
|
||||
Serial.print(F("%) hold "));
|
||||
Serial.print(STEPRESP_STEP_HOLD_MS / 1000UL);
|
||||
Serial.println(F("s"));
|
||||
}
|
||||
|
||||
void advanceStep(uint32_t nowMs) {
|
||||
if (STEPRESP_FAN_STEP_COUNT <= 1) {
|
||||
finish(nowMs, lastAvgC_, 0.0f);
|
||||
return;
|
||||
}
|
||||
stepIndex_ = 1;
|
||||
enterStepHold(nowMs, true);
|
||||
}
|
||||
|
||||
void finish(uint32_t nowMs, float avgTempC, float spreadC) {
|
||||
phase_ = Phase::Done;
|
||||
Serial.print(F("stepresp: done in "));
|
||||
Serial.print((nowMs - sessionStartMs_) / 1000UL);
|
||||
Serial.println(F("s"));
|
||||
Serial.print(F(" target="));
|
||||
Serial.print(targetC_, 1);
|
||||
Serial.print(F("C heater="));
|
||||
Serial.print(heaterPct_, 0);
|
||||
Serial.print(F("% final avg="));
|
||||
Serial.print(avgTempC, 1);
|
||||
Serial.print(F("C spread="));
|
||||
Serial.print(spreadC, 1);
|
||||
Serial.println(F("C"));
|
||||
Serial.println(F(" parse sr,... lines for step response (fan PWM vs temp)"));
|
||||
}
|
||||
|
||||
void fail(const __FlashStringHelper *reason) {
|
||||
Serial.println(reason);
|
||||
phase_ = Phase::Failed;
|
||||
sessionStartMs_ = 0;
|
||||
}
|
||||
|
||||
Phase phase_;
|
||||
float targetC_;
|
||||
float heaterPct_;
|
||||
uint8_t stepIndex_;
|
||||
uint32_t sessionStartMs_;
|
||||
uint32_t phaseStartMs_;
|
||||
uint32_t lastLogMs_;
|
||||
float lastAvgC_;
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "fan_step_response.h"
|
||||
#include "pid_autotuner.h"
|
||||
#include "pid_controller.h"
|
||||
#include "settings_store.h"
|
||||
@@ -10,11 +11,12 @@
|
||||
|
||||
class ThermalController {
|
||||
public:
|
||||
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Allow, Autotune };
|
||||
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Allow, Autotune, StepResp };
|
||||
|
||||
ThermalController()
|
||||
: pid_(PID_KP, PID_KI, PID_KD, 0.0f, 100.0f),
|
||||
autotuner_(),
|
||||
stepresp_(),
|
||||
targetTempC_(TARGET_TEMP_C),
|
||||
heaterDutyPercent_(0.0f),
|
||||
heaterAllowancePercent_(100.0f),
|
||||
@@ -40,6 +42,7 @@ public:
|
||||
pinMode(FAN_PIN, OUTPUT);
|
||||
pinMode(HEATER_PIN, OUTPUT);
|
||||
digitalWrite(HEATER_PIN, LOW);
|
||||
writeFan(0);
|
||||
|
||||
pid_.setSetpoint(targetTempC_);
|
||||
pid_.reset();
|
||||
@@ -105,7 +108,7 @@ public:
|
||||
bool isAdaptive() const { return adaptiveEnabled_; }
|
||||
|
||||
bool startAutotune(float setpointC) {
|
||||
if (autotuner_.isActive()) {
|
||||
if (autotuner_.isActive() || stepresp_.isActive()) {
|
||||
return false;
|
||||
}
|
||||
adaptiveEnabled_ = false;
|
||||
@@ -128,6 +131,46 @@ public:
|
||||
|
||||
float autotunePreheatTargetC() const { return autotuner_.preheatTargetC(); }
|
||||
|
||||
bool startStepResponse(float targetC, float heaterPct) {
|
||||
if (autotuner_.isActive() || stepresp_.isActive()) {
|
||||
return false;
|
||||
}
|
||||
stopFanTest();
|
||||
adaptiveEnabled_ = false;
|
||||
cutoffActive_ = false;
|
||||
pid_.reset();
|
||||
setTarget(targetC, false);
|
||||
if (!stepresp_.start(targetC, heaterPct)) {
|
||||
return false;
|
||||
}
|
||||
writeFan(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
void stopStepResponse() {
|
||||
stepresp_.abort();
|
||||
writeFan(0);
|
||||
}
|
||||
|
||||
bool isStepResponseActive() const { return stepresp_.isActive(); }
|
||||
|
||||
uint32_t stepResponseElapsedMs(uint32_t nowMs) const { return stepresp_.elapsedMs(nowMs); }
|
||||
|
||||
const char *stepResponsePhaseName() const { return stepresp_.phaseName(); }
|
||||
|
||||
uint8_t stepResponseStepIndex() const { return stepresp_.stepIndex(); }
|
||||
|
||||
uint8_t stepResponseStepCount() const { return stepresp_.stepCount(); }
|
||||
|
||||
float stepResponseHeaterPct() const { return stepresp_.heaterPct(); }
|
||||
|
||||
void logStepResponseIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
|
||||
float avgTempC, float minTempC, float maxTempC, float spreadC,
|
||||
uint32_t nowMs) {
|
||||
stepresp_.logIfDue(sensorTemps, sensorValid, sensorCount, avgTempC, minTempC, maxTempC,
|
||||
spreadC, nowMs);
|
||||
}
|
||||
|
||||
bool commitAutotuneIfDone() {
|
||||
if (autotuner_.phase() != PidAutotuner::Phase::Done) {
|
||||
return false;
|
||||
@@ -206,7 +249,7 @@ public:
|
||||
if (isIdle()) {
|
||||
return INFINITY;
|
||||
}
|
||||
return targetTempC_ * (1.0f + OVERTEMP_FRACTION);
|
||||
return EMERGENCY_MAX_TEMP_C;
|
||||
}
|
||||
|
||||
bool isCutoffActive() const { return cutoffActive_; }
|
||||
@@ -249,6 +292,8 @@ public:
|
||||
return "allow";
|
||||
case HeaterBlock::Autotune:
|
||||
return "autotune";
|
||||
case HeaterBlock::StepResp:
|
||||
return "stepresp";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
@@ -264,6 +309,11 @@ public:
|
||||
SPREAD_EMA_ALPHA * cornerSpreadC +
|
||||
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC_;
|
||||
|
||||
if (stepresp_.isActive()) {
|
||||
updateStepResponse(avgTempC, maxTempC, nowMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (autotuner_.isActive()) {
|
||||
updateAutotune(avgTempC, maxTempC, nowMs);
|
||||
return;
|
||||
@@ -296,6 +346,7 @@ public:
|
||||
applyFan(millis());
|
||||
pid_.reset();
|
||||
autotuner_.abort();
|
||||
stepresp_.abort();
|
||||
}
|
||||
|
||||
void forceHeaterOff() {
|
||||
@@ -307,16 +358,51 @@ public:
|
||||
|
||||
void writeFan(uint8_t pwm) {
|
||||
fanPwm_ = pwm;
|
||||
if (pwm == 0) {
|
||||
// Re-assert output and stop Timer0 PWM on D5 — analogWrite(0) can leave the pin driving
|
||||
pinMode(FAN_PIN, OUTPUT);
|
||||
digitalWrite(FAN_PIN, LOW);
|
||||
} else {
|
||||
analogWrite(FAN_PIN, pwm);
|
||||
|
||||
if (FAN_PWM_INVERT) {
|
||||
if (pwm == 0) {
|
||||
digitalWrite(FAN_PIN, HIGH);
|
||||
return;
|
||||
}
|
||||
if (pwm >= 254) {
|
||||
digitalWrite(FAN_PIN, LOW);
|
||||
return;
|
||||
}
|
||||
analogWrite(FAN_PIN, static_cast<uint8_t>(255 - pwm));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pwm == 0) {
|
||||
digitalWrite(FAN_PIN, LOW);
|
||||
return;
|
||||
}
|
||||
if (pwm >= 254) {
|
||||
digitalWrite(FAN_PIN, HIGH);
|
||||
return;
|
||||
}
|
||||
analogWrite(FAN_PIN, pwm);
|
||||
}
|
||||
|
||||
private:
|
||||
void updateStepResponse(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||
float duty = 0.0f;
|
||||
uint8_t fan = 0;
|
||||
stepresp_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
|
||||
|
||||
heaterDutyPercent_ = duty;
|
||||
heaterAllowancePercent_ = duty;
|
||||
heaterBlock_ = duty > 0.0f ? HeaterBlock::StepResp : HeaterBlock::None;
|
||||
applyHeaterBurst(nowMs);
|
||||
writeFan(fan);
|
||||
lastHeaterUpdateMs_ = nowMs;
|
||||
|
||||
if (stepresp_.phase() == FanStepResponse::Phase::Done ||
|
||||
stepresp_.phase() == FanStepResponse::Phase::Failed) {
|
||||
stepresp_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
|
||||
float duty = 0.0f;
|
||||
uint8_t fan = FAN_HEAT_MIN_PWM;
|
||||
@@ -439,22 +525,10 @@ private:
|
||||
}
|
||||
|
||||
float maxHeatStopTemp(float avgTempC) const {
|
||||
if (avgTempC >= targetTempC_) {
|
||||
return targetTempC_;
|
||||
if (!shouldLimitMaxCorner(avgTempC)) {
|
||||
return EMERGENCY_MAX_TEMP_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;
|
||||
return EMERGENCY_MAX_TEMP_C - CORNER_STOP_MARGIN_C;
|
||||
}
|
||||
|
||||
float allowanceFromMaxCorner(float maxTempC, float avgTempC) const {
|
||||
@@ -614,7 +688,7 @@ private:
|
||||
}
|
||||
|
||||
if (isIdle()) {
|
||||
if (!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
|
||||
if (sensorWarmValid_ && lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
|
||||
writeFan(FAN_MAX_PWM);
|
||||
} else if (fanIdleOverride_) {
|
||||
writeFan(FAN_IDLE_PWM);
|
||||
@@ -635,6 +709,7 @@ private:
|
||||
|
||||
PidController pid_;
|
||||
PidAutotuner autotuner_;
|
||||
FanStepResponse stepresp_;
|
||||
float targetTempC_;
|
||||
float heaterDutyPercent_;
|
||||
float heaterAllowancePercent_;
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
static const uint16_t TUNING_MAGIC = 0xDA7A;
|
||||
static const uint16_t TUNING_MAGIC = 0xDA7B;
|
||||
static const int TUNING_EEPROM_ADDR = 0;
|
||||
|
||||
struct TuningData {
|
||||
uint16_t magic = 0;
|
||||
float kp = PID_KP;
|
||||
float ki = PID_KI;
|
||||
float kd = PID_KD;
|
||||
uint8_t fanMixMax = FAN_MIX_MAX_PWM;
|
||||
float heatKp = HEAT_PI_KP;
|
||||
float heatKi = HEAT_PI_KI;
|
||||
float mixKp = MIX_PI_KP;
|
||||
float mixKi = MIX_PI_KI;
|
||||
};
|
||||
|
||||
inline uint8_t tuningChecksum(const TuningData &data) {
|
||||
|
||||
@@ -43,7 +43,7 @@ STATUS_RE = re.compile(
|
||||
r"htop=(?P<htop>\S+)\s+"
|
||||
r"hblk=(?P<hblk>\S+)\s+"
|
||||
r"ssr=(?P<ssr>on|off)\s+"
|
||||
r"fan=(?P<fan>\S+)\s+"
|
||||
r"fan=(?P<fan>\d+/255\(\d+%\)(?:\([^)]+\))?(?:\s+TEST)?)\s+"
|
||||
r"cutoff=(?P<cutoff_active>\S+)\s+"
|
||||
r"failsafe=(?P<failsafe>\S+)\s+"
|
||||
r"mode=(?P<mode>.+?)\s+sensors=\[(?P<sensors>.*)\]"
|
||||
@@ -55,6 +55,10 @@ AUTOTUNE_MODE_RE = re.compile(
|
||||
r"autotune/(?P<phase>[\w-]+) (?P<elapsed>\d+)s (?P<cycles>\d+/\d+)cyc pre>=(?P<pre>\d+)C"
|
||||
)
|
||||
|
||||
STEPRESP_MODE_RE = re.compile(
|
||||
r"stepresp/(?P<phase>[\w-]+) (?P<elapsed>\d+)s step (?P<step>\d+/\d+) heat=(?P<heat>\d+)%"
|
||||
)
|
||||
|
||||
|
||||
def format_mode_line(mode: str) -> str:
|
||||
match = AUTOTUNE_MODE_RE.match(mode)
|
||||
@@ -64,6 +68,13 @@ def format_mode_line(mode: str) -> str:
|
||||
f"Autotune {d['phase']}: {d['elapsed']}s elapsed, "
|
||||
f"{d['cycles']} cycles, preheat avg >= {d['pre']} C"
|
||||
)
|
||||
match = STEPRESP_MODE_RE.match(mode)
|
||||
if match:
|
||||
d = match.groupdict()
|
||||
return (
|
||||
f"Step response {d['phase']}: {d['elapsed']}s, "
|
||||
f"step {d['step']}, heater {d['heat']}%"
|
||||
)
|
||||
return f"Mode: {mode}"
|
||||
|
||||
|
||||
@@ -141,7 +152,7 @@ def apply_status(state: DryerState, data: dict) -> None:
|
||||
fan_raw = data["fan"]
|
||||
state.fan = fan_raw
|
||||
state.fan_note = ""
|
||||
if fan_raw.endswith("(off)") or fan_raw.endswith("(cooldown)") or " TEST" in fan_raw:
|
||||
if fan_raw.endswith("(off)") or fan_raw.endswith("(cooldown)") or fan_raw.endswith("(cmd-off)") or " TEST" in fan_raw:
|
||||
state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else ""
|
||||
state.cutoff_active = data["cutoff_active"]
|
||||
state.failsafe = data["failsafe"]
|
||||
@@ -409,7 +420,7 @@ def _draw_dashboard(stdscr, state: DryerState) -> None:
|
||||
stdscr,
|
||||
help_y,
|
||||
1,
|
||||
"0 idle | t target | p presets | f fan | l log | a autotune | : cmd | q quit",
|
||||
"0 idle | t target | p presets | f fan | l log | a autotune | r stepresp | : cmd | q quit",
|
||||
curses.A_DIM,
|
||||
)
|
||||
stdscr.refresh()
|
||||
@@ -491,6 +502,21 @@ def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
|
||||
if value is not None:
|
||||
cmd = "autotune" if value == "" else f"autotune {value}"
|
||||
worker.send(cmd)
|
||||
elif key == ord("r"):
|
||||
temp = _prompt(stdscr, "Stepresp temp °C (Enter = 45)")
|
||||
if temp is None:
|
||||
continue
|
||||
heater = _prompt(stdscr, "Heater % (Enter = 35)")
|
||||
if heater is None:
|
||||
continue
|
||||
if temp == "" and heater == "":
|
||||
worker.send("stepresp")
|
||||
elif heater == "":
|
||||
worker.send(f"stepresp {temp}")
|
||||
elif temp == "":
|
||||
worker.send(f"stepresp 45 {heater}")
|
||||
else:
|
||||
worker.send(f"stepresp {temp} {heater}")
|
||||
elif key == ord(":"):
|
||||
value = _prompt(stdscr, "Command")
|
||||
if value is not None and value != "":
|
||||
|
||||
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())
|
||||
169
scripts/step_response.py
Normal file
169
scripts/step_response.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run fan step-response test and capture sr,... serial log lines to CSV.
|
||||
|
||||
The firmware holds heater duty fixed, steps fan PWM, and logs temperature
|
||||
every second. Use the output to see how chamber temp responds to fan changes.
|
||||
|
||||
Example:
|
||||
./step_response.py
|
||||
./step_response.py --temp 45 --heater 35 -o logs/stepresp.csv
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from capture_csv import decode_line, open_serial, resolve_port
|
||||
|
||||
SR_RE = re.compile(
|
||||
r"^sr,(?P<ms>\d+),(?P<phase>\w+),(?P<step>\d+/\d+),"
|
||||
r"(?P<heater>\d+),(?P<fan>\d+),"
|
||||
r"(?P<avg>[\d.]+),(?P<min>[\d.]+),(?P<max>[\d.]+),(?P<spread>[\d.]+)"
|
||||
r"(?:,(?P<temps>.*))?$"
|
||||
)
|
||||
DONE_RE = re.compile(r"^stepresp: done")
|
||||
FAIL_RE = re.compile(r"^stepresp: abort")
|
||||
|
||||
HEADER = (
|
||||
"wall_time,ms,phase,step,heater_pct,fan_pwm,fan_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_sr_line(line: str) -> dict | None:
|
||||
match = SR_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 step-response data")
|
||||
parser.add_argument("-p", "--port", help="Serial port (default: auto-detect)")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200)
|
||||
parser.add_argument("--temp", type=float, default=45.0, help="Target temperature (C)")
|
||||
parser.add_argument("--heater", type=float, default=35.0, help="Fixed heater duty (%%)")
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
type=Path,
|
||||
help="Output CSV (default: logs/stepresp_YYYYMMDD_HHMMSS.csv)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
port = resolve_port(args.port)
|
||||
out = args.output
|
||||
if out is None:
|
||||
out = Path("logs") / f"stepresp_{datetime.now():%Y%m%d_%H%M%S}.csv"
|
||||
|
||||
print(f"Port: {port}", file=sys.stderr)
|
||||
print(f"Output: {out}", file=sys.stderr)
|
||||
print(f"Command: stepresp {args.temp:g} {args.heater:g}", file=sys.stderr)
|
||||
print("Ctrl+C to stop\n", file=sys.stderr)
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
row_count = 0
|
||||
|
||||
with open_serial(port, args.baud) as ser, out.open("w", encoding="utf-8") as fh:
|
||||
fh.write(HEADER + "\n")
|
||||
ser.reset_input_buffer()
|
||||
|
||||
lines = send_command(ser, f"stepresp {args.temp:g} {args.heater:g}")
|
||||
for line in lines:
|
||||
print(line, flush=True)
|
||||
if any(line.startswith("ERR ") for line in lines):
|
||||
return 1
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = ser.readline()
|
||||
if not raw:
|
||||
continue
|
||||
line = decode_line(raw)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("sr,"):
|
||||
data = parse_sr_line(line)
|
||||
if data is None:
|
||||
print(f"WARN: bad sr line: {line}", file=sys.stderr)
|
||||
continue
|
||||
pwm = int(data["fan_pwm"])
|
||||
wall = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
row = [
|
||||
wall,
|
||||
data["ms"],
|
||||
data["phase"],
|
||||
data["step"],
|
||||
data["heater_pct"],
|
||||
str(pwm),
|
||||
str(fan_pct(pwm)),
|
||||
data["avg"],
|
||||
data["min"],
|
||||
data["max"],
|
||||
data["spread"],
|
||||
data["ch2_t"],
|
||||
data["ch3_t"],
|
||||
data["ch4_t"],
|
||||
data["ch5_t"],
|
||||
]
|
||||
fh.write(",".join(row) + "\n")
|
||||
fh.flush()
|
||||
row_count += 1
|
||||
if row_count % 30 == 0:
|
||||
print(
|
||||
f" {data['phase']} step {data['step']} "
|
||||
f"fan={pwm} avg={data['avg']}C spread={data['spread']}C",
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
|
||||
if DONE_RE.match(line) or FAIL_RE.match(line):
|
||||
print(line, flush=True)
|
||||
break
|
||||
|
||||
if line.startswith("stepresp:"):
|
||||
print(line, flush=True)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping…", file=sys.stderr)
|
||||
send_command(ser, "stepresp stop")
|
||||
|
||||
print(f"Wrote {row_count} rows to {out}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
67
src/main.cpp
67
src/main.cpp
@@ -146,6 +146,8 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
|
||||
Serial.print(F("(off)"));
|
||||
} else if (thermal.isIdleCooling()) {
|
||||
Serial.print(F("(cooldown)"));
|
||||
} else if (thermal.fanPwm() == 0) {
|
||||
Serial.print(F("(cmd-off)"));
|
||||
}
|
||||
Serial.print(F(" cutoff="));
|
||||
Serial.print(thermal.isCutoffActive() ? F("YES") : F("no"));
|
||||
@@ -164,6 +166,18 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
|
||||
Serial.print(F("cyc pre>="));
|
||||
Serial.print(thermal.autotunePreheatTargetC(), 0);
|
||||
Serial.print(F("C"));
|
||||
} else if (thermal.isStepResponseActive()) {
|
||||
Serial.print(F("stepresp/"));
|
||||
Serial.print(thermal.stepResponsePhaseName());
|
||||
Serial.print(F(" "));
|
||||
Serial.print(thermal.stepResponseElapsedMs(millis()) / 1000UL);
|
||||
Serial.print(F("s step "));
|
||||
Serial.print(thermal.stepResponseStepIndex() + 1);
|
||||
Serial.print(F("/"));
|
||||
Serial.print(thermal.stepResponseStepCount());
|
||||
Serial.print(F(" heat="));
|
||||
Serial.print(thermal.stepResponseHeaterPct(), 0);
|
||||
Serial.print(F("%"));
|
||||
} else if (thermal.isAdaptive()) {
|
||||
Serial.print(F("learned"));
|
||||
} else {
|
||||
@@ -199,6 +213,8 @@ void printHelp() {
|
||||
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 stop"));
|
||||
Serial.println(F(" stepresp [C] [heater%] fan step response (default: 45C 35%)"));
|
||||
Serial.println(F(" stepresp stop"));
|
||||
Serial.println(F(" pid show PID / adaptive status"));
|
||||
Serial.println(F(" pid default reset to factory PID"));
|
||||
Serial.println(F(" status print current readings"));
|
||||
@@ -328,6 +344,45 @@ void processSerialLine(const char *line) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strncmp(line, "stepresp", 8) == 0) {
|
||||
if (strcmp(line, "stepresp stop") == 0) {
|
||||
thermal.stopStepResponse();
|
||||
Serial.println(F("OK stepresp cancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
float tempC = STEPRESP_DEFAULT_TEMP_C;
|
||||
float heaterPct = STEPRESP_DEFAULT_HEATER_PCT;
|
||||
if (line[8] == ' ') {
|
||||
const char *args = line + 9;
|
||||
tempC = atof(args);
|
||||
const char *space = strchr(args, ' ');
|
||||
if (space != nullptr) {
|
||||
heaterPct = atof(space + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (tempC < 25.0f || tempC > TARGET_MAX_C) {
|
||||
Serial.println(F("ERR stepresp temperature must be 25-80 C"));
|
||||
return;
|
||||
}
|
||||
if (heaterPct < STEPRESP_MIN_HEATER_PCT || heaterPct > STEPRESP_MAX_HEATER_PCT) {
|
||||
Serial.print(F("ERR stepresp heater must be "));
|
||||
Serial.print(STEPRESP_MIN_HEATER_PCT, 0);
|
||||
Serial.print(F("-"));
|
||||
Serial.print(STEPRESP_MAX_HEATER_PCT, 0);
|
||||
Serial.println(F("%"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!thermal.startStepResponse(tempC, heaterPct)) {
|
||||
Serial.println(F("ERR stepresp already running or autotune active"));
|
||||
return;
|
||||
}
|
||||
Serial.println(F("OK stepresp started — open-loop heater, fan steps, ~25-35 min"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(line, "pid") == 0 || strcmp(line, "pid show") == 0) {
|
||||
thermal.printTuning();
|
||||
return;
|
||||
@@ -428,6 +483,18 @@ void loop() {
|
||||
|
||||
if (!isnan(avgTemp) && !isnan(maxTemp) && !isnan(spread)) {
|
||||
thermal.update(avgTemp, maxTemp, spread, now);
|
||||
|
||||
float sensorTemps[SENSOR_COUNT];
|
||||
bool sensorValid[SENSOR_COUNT];
|
||||
for (uint8_t i = 0; i < SENSOR_COUNT; ++i) {
|
||||
sensorTemps[i] = sensors[i].temperatureC;
|
||||
sensorValid[i] = sensors[i].valid;
|
||||
}
|
||||
const float minTemp = minValidTemperature();
|
||||
if (thermal.isStepResponseActive() && !isnan(minTemp)) {
|
||||
thermal.logStepResponseIfDue(sensorTemps, sensorValid, SENSOR_COUNT, avgTemp, minTemp,
|
||||
maxTemp, spread, now);
|
||||
}
|
||||
} else {
|
||||
thermal.enterFailSafe();
|
||||
Serial.println(F("WARN: no valid sensor readings — heater off"));
|
||||
|
||||
Reference in New Issue
Block a user