safety commit

This commit is contained in:
2026-07-06 20:16:51 +02:00
parent 02e51717e8
commit 28ae97fa45
8 changed files with 806 additions and 34 deletions

View File

@@ -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 5055 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
View 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_;
};

View File

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

View File

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

View File

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

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

169
scripts/step_response.py Normal file
View 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())

View File

@@ -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"));