From 28ae97fa45c15b677ee761efba5eafef3e06eb25 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 6 Jul 2026 20:16:51 +0200 Subject: [PATCH] safety commit --- include/config.h | 22 ++- include/fan_step_response.h | 268 +++++++++++++++++++++++++++++++++++ include/thermal_controller.h | 125 ++++++++++++---- include/tuning_store.h | 10 +- scripts/dryer_tui.py | 32 ++++- scripts/fan_test.py | 147 +++++++++++++++++++ scripts/step_response.py | 169 ++++++++++++++++++++++ src/main.cpp | 67 +++++++++ 8 files changed, 806 insertions(+), 34 deletions(-) create mode 100644 include/fan_step_response.h create mode 100644 scripts/fan_test.py create mode 100644 scripts/step_response.py diff --git a/include/config.h b/include/config.h index ae068a7..598050b 100644 --- a/include/config.h +++ b/include/config.h @@ -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 // --------------------------------------------------------------------------- diff --git a/include/fan_step_response.h b/include/fan_step_response.h new file mode 100644 index 0000000..c6a9f51 --- /dev/null +++ b/include/fan_step_response.h @@ -0,0 +1,268 @@ +#pragma once + +#include + +#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_; +}; diff --git a/include/thermal_controller.h b/include/thermal_controller.h index f007f05..7bbdcf2 100644 --- a/include/thermal_controller.h +++ b/include/thermal_controller.h @@ -3,6 +3,7 @@ #include #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); + pinMode(FAN_PIN, OUTPUT); + + 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(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_; diff --git a/include/tuning_store.h b/include/tuning_store.h index d93c6ab..3a65088 100644 --- a/include/tuning_store.h +++ b/include/tuning_store.h @@ -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) { diff --git a/scripts/dryer_tui.py b/scripts/dryer_tui.py index b24cd92..3a35376 100644 --- a/scripts/dryer_tui.py +++ b/scripts/dryer_tui.py @@ -43,7 +43,7 @@ STATUS_RE = re.compile( r"htop=(?P\S+)\s+" r"hblk=(?P\S+)\s+" r"ssr=(?Pon|off)\s+" - r"fan=(?P\S+)\s+" + r"fan=(?P\d+/255\(\d+%\)(?:\([^)]+\))?(?:\s+TEST)?)\s+" r"cutoff=(?P\S+)\s+" r"failsafe=(?P\S+)\s+" r"mode=(?P.+?)\s+sensors=\[(?P.*)\]" @@ -55,6 +55,10 @@ AUTOTUNE_MODE_RE = re.compile( r"autotune/(?P[\w-]+) (?P\d+)s (?P\d+/\d+)cyc pre>=(?P
\d+)C"
 )
 
+STEPRESP_MODE_RE = re.compile(
+    r"stepresp/(?P[\w-]+) (?P\d+)s step (?P\d+/\d+) heat=(?P\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 != "":
diff --git a/scripts/fan_test.py b/scripts/fan_test.py
new file mode 100644
index 0000000..d2ead19
--- /dev/null
+++ b/scripts/fan_test.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""Cycle fan speeds on the dryer for wiring / PWM verification.
+
+Uses the firmware `fan test ` 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())
diff --git a/scripts/step_response.py b/scripts/step_response.py
new file mode 100644
index 0000000..bff5bc1
--- /dev/null
+++ b/scripts/step_response.py
@@ -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\d+),(?P\w+),(?P\d+/\d+),"
+    r"(?P\d+),(?P\d+),"
+    r"(?P[\d.]+),(?P[\d.]+),(?P[\d.]+),(?P[\d.]+)"
+    r"(?:,(?P.*))?$"
+)
+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())
diff --git a/src/main.cpp b/src/main.cpp
index 0a889a0..80a19fd 100644
--- a/src/main.cpp
+++ b/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"));