ouch finger

This commit is contained in:
2026-07-08 22:17:23 +02:00
parent 2fe53d5a8a
commit d1385baa45
11 changed files with 865 additions and 189 deletions

View File

@@ -101,7 +101,7 @@ Verify access: `test -w /dev/ttyUSB0 && echo ok`
Emergency cutoff is fixed at **70°C** — you can autotune at 5055°C with ABS in the chamber while hot corners stay below that. Emergency cutoff is fixed at **70°C** — you can autotune at 5055°C with ABS in the chamber while hot corners stay below that.
5. Dry at your target — fan runs at the stir PWM (default **178**) unless you override with `fan <pwm>`; `fan auto` returns to the default. 5. Dry at your target — fan runs at stir PWM (default **178**) whenever target > 0, including heat-up. Override with `fan <pwm>`; `fan auto` returns to default. Fan auto-off below 40°C applies only in idle (`target 0`).
Send `help` over serial for all commands (`target`, `fanchars`, `fan`, `log on/off`, `status`, `pid`, etc.). Send `help` over serial for all commands (`target`, `fanchars`, `fan`, `log on/off`, `status`, `pid`, etc.).

View File

@@ -42,19 +42,20 @@ static const float PID_KI = HEAT_PI_KI;
static const float PID_KD = 0.0f; static const float PID_KD = 0.0f;
static const float GOOD_SPREAD_C = 5.0f; static const float GOOD_SPREAD_C = 5.0f;
// Tiered heater cap during heat-up // Tiered heater cap during heat-up (100% until near target)
static const float HEATER_MAX_DUTY_COLD = 85.0f; static const float HEATER_MAX_DUTY_COLD = 100.0f;
static const float HEATER_MAX_DUTY_MID = 65.0f; static const float HEATER_MAX_DUTY_MID = 75.0f;
static const float HEATER_MAX_DUTY_NEAR = 45.0f; static const float HEATER_MAX_DUTY_NEAR = 45.0f;
static const float HEATER_COLD_BELOW_C = 10.0f; static const float HEATER_COLD_BELOW_C = 10.0f;
static const float HEATER_WARM_BELOW_C = 3.0f; static const float HEATER_WARM_BELOW_C = 3.0f;
static const float CORNER_LIMIT_BAND_C = 10.0f; // Corner taper only within this band below target (was 10°C — blocked heat-up in uneven chambers)
static const float CORNER_LIMIT_BAND_C = 2.0f;
static const float HEATER_SLEW_UP_PER_S = 18.0f; static const float HEATER_SLEW_UP_PER_S = 18.0f;
static const float MAX_TEMP_HEADROOM_C = 15.0f; static const float MAX_TEMP_HEADROOM_C = 15.0f;
static const uint16_t HEATER_CYCLE_MS = 3000; static const uint16_t HEATER_CYCLE_MS = 3000;
// Fan PWM — FAN_IDLE_PWM ≈ 30%; off only while chamber avg is below 40°C // Fan PWM — stir speed when target > 0; off below 40°C only when idle (target 0)
static const uint8_t FAN_IDLE_PWM = 77; static const uint8_t FAN_IDLE_PWM = 77;
static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f; static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f;
static const uint8_t FAN_MAX_PWM = 255; static const uint8_t FAN_MAX_PWM = 255;

View File

@@ -0,0 +1,75 @@
#pragma once
#include <Arduino.h>
#include "config.h"
class FanCharacterize {
public:
enum class Phase : uint8_t { Idle, Precool, Heat, Hold, Cooldown, Done, Failed };
FanCharacterize();
Phase phase() const { return phase_; }
bool isActive() const;
uint32_t elapsedMs(uint32_t nowMs) const;
uint8_t profileIndex() const { return profileIndex_; }
uint8_t profileCount() const { return FANCHARS_COARSE_COUNT + 1; }
uint8_t currentFanPwm() const;
float heaterPct() const { return heaterPct_; }
uint8_t winnerFanPwm() const { return winnerFanPwm_; }
bool isRefineRun() const { return refineRun_ && profileIndex_ >= FANCHARS_COARSE_COUNT; }
const char *phaseName() const;
bool start(float maxCornerC, float avgTempC);
void abort();
void reset();
bool update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut,
uint8_t &fanPwmOut);
void logIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
float avgTempC, float minTempC, float maxTempC, float spreadC, uint32_t nowMs);
private:
struct ProfileResult {
uint8_t fanPwm;
float meanSpreadC;
};
void resetProfileStats();
void beginProfileHeat(uint32_t nowMs);
void enterHold(uint32_t nowMs);
void finishProfile(uint32_t nowMs);
void skipProfile(uint32_t nowMs, float maxTempC);
void planRefine(uint32_t nowMs);
void finishAll(uint32_t nowMs);
void fail(const __FlashStringHelper *reason);
Phase phase_;
float maxCornerC_;
float coolAvgC_;
float heaterPct_;
uint8_t profileIndex_;
uint8_t refineFanPwm_;
bool refineRun_;
uint32_t sessionStartMs_;
uint32_t phaseStartMs_;
uint32_t lastLogMs_;
float spreadSum_;
uint16_t spreadSamples_;
uint8_t resultCount_;
uint8_t winnerFanPwm_;
ProfileResult results_[FANCHARS_MAX_RESULTS];
};

View File

@@ -456,7 +456,7 @@ private:
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) { void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
float duty = 0.0f; float duty = 0.0f;
uint8_t fan = AUTOTUNE_PREHEAT_FAN_PWM; uint8_t fan = stirFanPwm_;
autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan); autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
heaterDutyPercent_ = duty; heaterDutyPercent_ = duty;
@@ -464,7 +464,7 @@ private:
heaterOn_ = duty >= 50.0f; heaterOn_ = duty >= 50.0f;
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW); digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
heaterBlock_ = duty > 0.0f ? HeaterBlock::None : HeaterBlock::Autotune; heaterBlock_ = duty > 0.0f ? HeaterBlock::None : HeaterBlock::Autotune;
writeFan(fan); writeFan(fanManualActive_ ? fanManualPwm_ : stirFanPwm_);
lastHeaterUpdateMs_ = nowMs; lastHeaterUpdateMs_ = nowMs;
commitAutotuneIfDone(); commitAutotuneIfDone();
} }
@@ -513,15 +513,7 @@ private:
} }
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs); heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
const uint8_t fanPwm = fanManualActive_ ? fanManualPwm_ : stirFanPwm_; regulatingFanPwm_ = fanManualActive_ ? fanManualPwm_ : stirFanPwm_;
regulatingFanPwm_ = fanWithMinStir(avgTempC, fanPwm);
}
uint8_t fanWithMinStir(float avgTempC, uint8_t pwm) const {
if (avgTempC < IDLE_AUTO_FAN_OFF_TEMP_C) {
return 0;
}
return pwm;
} }
static float clampPercent(float value) { static float clampPercent(float value) {
@@ -547,6 +539,11 @@ private:
float allowanceFromMaxCorner(float maxTempC, float avgTempC) const { float allowanceFromMaxCorner(float maxTempC, float avgTempC) const {
if (!shouldLimitMaxCorner(avgTempC)) { if (!shouldLimitMaxCorner(avgTempC)) {
// Heat-up: only taper when a hot corner nears the emergency ceiling
if (maxTempC >= EMERGENCY_MAX_TEMP_C - 3.0f) {
const float headroom = EMERGENCY_MAX_TEMP_C - maxTempC;
return clampPercent((headroom / 3.0f) * 100.0f);
}
return 100.0f; return 100.0f;
} }

View File

@@ -200,7 +200,7 @@ def apply_status(state: DryerState, data: dict) -> None:
fan_raw = data["fan"] fan_raw = data["fan"]
state.fan = format_fan_display(fan_raw) state.fan = format_fan_display(fan_raw)
state.fan_note = "" state.fan_note = ""
if "(off)" in fan_raw or "(cooldown)" in fan_raw or "(off<40C)" in fan_raw: if "(off)" in fan_raw or "(cooldown)" in fan_raw:
state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else "" state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else ""
elif "(manual)" in fan_raw or "(stir)" in fan_raw: elif "(manual)" in fan_raw or "(stir)" in fan_raw:
state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else "" state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else ""

187
scripts/fan_characterize.py Normal file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Run fan characterize sweep and capture fc,... serial log lines to CSV.
Each profile heats from ~35 C avg to max corner (default 60 C) at a fixed fan
PWM, measures spread during a hold, cools, then repeats for the next speed.
Firmware prints the best fan at the end; use fanchars save on the device.
Example:
./fan_characterize.py
./fan_characterize.py -o logs/fanchars.csv
"""
from __future__ import annotations
import argparse
import re
import sys
import time
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from capture_csv import decode_line, open_serial, resolve_port
FC_RE = re.compile(
r"^fc,(?P<ms>\d+),(?P<phase>\w+),(?P<run>\d+/\d+),"
r"(?P<fan>\d+),(?P<heater>\d+),"
r"(?P<avg>[\d.]+),(?P<min>[\d.]+),(?P<max>[\d.]+),(?P<spread>[\d.]+)"
r"(?:,(?P<temps>.*))?$"
)
DONE_RE = re.compile(r"^fanchars: done")
FAIL_RE = re.compile(r"^fanchars: abort")
RUN_SUMMARY_RE = re.compile(r"^fanchars: f=(?P<fan>\d+) spr=(?P<mean>[\d.]+)")
BEST_RE = re.compile(r"^ best (?P<fan>\d+) spr=(?P<mean>[\d.]+)")
HEADER = (
"wall_time,ms,phase,run,fan_pwm,fan_pct,heater_pct,avg_c,min_c,max_c,spread_c,"
"ch2_t,ch3_t,ch4_t,ch5_t"
)
def fan_pct(pwm: int) -> int:
return (pwm * 100) // 255
def send_command(ser, command: str, timeout: float = 3.0) -> list[str]:
ser.write((command.strip() + "\n").encode("utf-8"))
ser.flush()
lines: list[str] = []
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
raw = ser.readline()
if not raw:
continue
line = decode_line(raw)
if not line:
continue
lines.append(line)
if line.startswith("OK ") or line.startswith("ERR "):
break
return lines
def parse_fc_line(line: str) -> dict | None:
match = FC_RE.match(line)
if not match:
return None
data = match.groupdict()
temps = data.pop("temps") or ""
channels = (temps.split(",") + ["", "", "", ""])[:4]
data["ch2_t"], data["ch3_t"], data["ch4_t"], data["ch5_t"] = channels
data["fan_pwm"] = data.pop("fan")
data["heater_pct"] = data.pop("heater")
return data
def main() -> int:
parser = argparse.ArgumentParser(description="Capture fan characterize sweep")
parser.add_argument("-p", "--port", help="Serial port (default: auto-detect)")
parser.add_argument("-b", "--baud", type=int, default=115200)
parser.add_argument("--max", type=float, default=60.0, help="Max corner temp (C, default 60)")
parser.add_argument(
"-o",
"--output",
type=Path,
help="Output CSV (default: logs/fanchars_YYYYMMDD_HHMMSS.csv)",
)
parser.add_argument(
"--timeout",
type=float,
default=5 * 3600,
help="Abort if not done within this many seconds (default: 5 h)",
)
args = parser.parse_args()
out = args.output
if out is None:
out = Path("logs") / f"fanchars_{datetime.now():%Y%m%d_%H%M%S}.csv"
out.parent.mkdir(parents=True, exist_ok=True)
cmd = f"fanchars {args.max:g}" if args.max != 60.0 else "fanchars"
print(f"Command: {cmd}", file=sys.stderr)
print(f"Output: {out}", file=sys.stderr)
print("Expect heat/cool cycles per fan speed. Ctrl+C sends fanchars stop.", file=sys.stderr)
port = resolve_port(args.port)
run_means: dict[int, float] = {}
best_line = ""
try:
with open_serial(port, args.baud) as ser:
time.sleep(0.3)
while ser.in_waiting:
decode_line(ser.readline())
lines = send_command(ser, cmd, timeout=5.0)
for line in lines:
print(line, file=sys.stderr)
if line.startswith("ERR "):
return 1
send_command(ser, "log on", timeout=2.0)
with out.open("w", encoding="utf-8") as fh:
fh.write(HEADER + "\n")
deadline = time.monotonic() + args.timeout
while time.monotonic() < deadline:
raw = ser.readline()
if not raw:
continue
line = decode_line(raw)
if not line:
continue
row = parse_fc_line(line)
if row:
wall = datetime.now(timezone.utc).isoformat()
fh.write(
f"{wall},{row['ms']},{row['phase']},{row['run']},"
f"{row['fan_pwm']},{fan_pct(int(row['fan_pwm']))},"
f"{row['heater_pct']},{row['avg']},{row['min']},{row['max']},"
f"{row['spread']},{row['ch2_t']},{row['ch3_t']},"
f"{row['ch4_t']},{row['ch5_t']}\n"
)
fh.flush()
match = RUN_SUMMARY_RE.match(line)
if match:
run_means[int(match.group("fan"))] = float(match.group("mean"))
print(line, file=sys.stderr)
if BEST_RE.search(line):
best_line = line.strip()
if DONE_RE.search(line) or FAIL_RE.search(line):
print(line, file=sys.stderr)
break
else:
print("Timeout waiting for fanchars to finish", file=sys.stderr)
return 1
if best_line:
print(best_line, file=sys.stderr)
print("Run: fanchars save (on device) to store stir PWM", file=sys.stderr)
elif run_means:
best_fan = min(run_means, key=run_means.get)
print(
f"Best from logs: fan {best_fan} ({fan_pct(best_fan)}%) "
f"mean spread {run_means[best_fan]:.2f} C",
file=sys.stderr,
)
except KeyboardInterrupt:
print("\nStopping…", file=sys.stderr)
try:
with open_serial(port, args.baud) as ser:
send_command(ser, "fanchars stop")
except OSError:
pass
return 130
print(f"Wrote {out}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())

232
scripts/plot_logs.py Normal file
View File

@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Plot dryer CSV logs from capture_csv.py, the TUI, or firmware `log on`.
Example:
python3 scripts/plot_logs.py logs/dryer_20260707_195354.csv
python3 scripts/plot_logs.py logs/ # newest .csv in directory
python3 scripts/plot_logs.py remote # newest .csv on alex@10.81.16.44
python3 scripts/plot_logs.py logs/foo.csv -o plot.png
"""
from __future__ import annotations
import argparse
import csv
import subprocess
import sys
import tempfile
from pathlib import Path
REMOTE_SSH = "alex@10.81.16.44"
REMOTE_LOG_DIRS = (
"~/arduino-filament-dryer/logs",
"~/voron-filament-dryer/logs",
"~/logs",
)
def import_matplotlib():
try:
import matplotlib.pyplot as plt
except ImportError:
print("Install matplotlib: pip install matplotlib", file=sys.stderr)
raise SystemExit(1) from None
return plt
def fetch_remote_csv(remote_dirs: tuple[str, ...] = REMOTE_LOG_DIRS) -> Path:
dir_list = " ".join(remote_dirs)
find_cmd = (
f"for d in {dir_list}; do "
'if [ -d "$d" ]; then ls -t "$d"/*.csv 2>/dev/null; fi; done | head -1'
)
result = subprocess.run(
["ssh", REMOTE_SSH, find_cmd],
capture_output=True,
text=True,
check=False,
)
remote_path = result.stdout.strip()
if result.returncode != 0 or not remote_path:
err = result.stderr.strip()
print(f"No remote CSV found on {REMOTE_SSH}", file=sys.stderr)
if err:
print(err, file=sys.stderr)
raise SystemExit(1)
local_path = Path(tempfile.gettempdir()) / f"dryer_remote_{Path(remote_path).name}"
scp = subprocess.run(
["scp", f"{REMOTE_SSH}:{remote_path}", str(local_path)],
capture_output=True,
text=True,
check=False,
)
if scp.returncode != 0:
print(f"scp failed for {REMOTE_SSH}:{remote_path}", file=sys.stderr)
if scp.stderr.strip():
print(scp.stderr.strip(), file=sys.stderr)
raise SystemExit(1)
print(f"Fetched {REMOTE_SSH}:{remote_path}", file=sys.stderr)
return local_path
def resolve_csv(path: Path) -> Path:
if path.is_dir():
matches = sorted(path.glob("*.csv"), key=lambda p: p.stat().st_mtime, reverse=True)
if not matches:
print(f"No CSV files in {path}", file=sys.stderr)
raise SystemExit(1)
return matches[0]
if not path.is_file():
print(f"Not found: {path}", file=sys.stderr)
raise SystemExit(1)
return path
def load_rows(path: Path) -> tuple[list[str], list[dict[str, str]]]:
with path.open(newline="", encoding="utf-8") as fh:
reader = csv.DictReader(fh)
if reader.fieldnames is None:
print(f"Empty CSV: {path}", file=sys.stderr)
raise SystemExit(1)
rows = list(reader)
return list(reader.fieldnames), rows
def column_float(rows: list[dict[str, str]], name: str) -> list[float | None]:
out: list[float | None] = []
for row in rows:
raw = row.get(name, "").strip()
if not raw:
out.append(None)
continue
try:
out.append(float(raw))
except ValueError:
out.append(None)
return out
def time_axis(rows: list[dict[str, str]], fieldnames: list[str]) -> tuple[list[float], str]:
if "ms" in fieldnames:
ms = column_float(rows, "ms")
if any(v is not None for v in ms):
t0 = next(v for v in ms if v is not None)
return [((v or t0) - t0) / 60000.0 for v in ms], "minutes since start"
if "wall_time" in fieldnames:
from datetime import datetime
times: list[float] = []
parsed: list[datetime] = []
for row in rows:
raw = row.get("wall_time", "").strip()
if not raw:
continue
try:
parsed.append(datetime.fromisoformat(raw))
except ValueError:
continue
if parsed:
t0 = parsed[0]
for dt in parsed:
times.append((dt - t0).total_seconds() / 60.0)
return times, "minutes since start"
return [float(i) for i in range(len(rows))], "sample"
def plot_csv(path: Path, output: Path | None, title: str | None = None) -> None:
plt = import_matplotlib()
fieldnames, rows = load_rows(path)
if not rows:
print(f"No data rows in {path}", file=sys.stderr)
raise SystemExit(1)
x, x_label = time_axis(rows, fieldnames)
if len(x) != len(rows):
x = [float(i) for i in range(len(rows))]
x_label = "sample"
fig, axes = plt.subplots(3, 1, figsize=(11, 8), sharex=True, constrained_layout=True)
fig.suptitle(title or path.name)
temp_ax = axes[0]
for col, label, style in (
("avg_c", "avg", "-"),
("min_c", "min", "--"),
("max_c", "max", "--"),
("target_c", "target", ":"),
):
if col not in fieldnames:
continue
y = column_float(rows, col)
temp_ax.plot(x, y, style, label=label, linewidth=1.5 if col == "avg_c" else 1.0)
for ch in (2, 3, 4, 5):
col = f"ch{ch}_t"
if col in fieldnames:
y = column_float(rows, col)
temp_ax.plot(x, y, "-", alpha=0.35, linewidth=0.8, label=f"ch{ch}")
temp_ax.set_ylabel("°C")
temp_ax.legend(loc="upper left", ncol=4, fontsize=8)
temp_ax.grid(True, alpha=0.3)
duty_ax = axes[1]
if "heater_pct" in fieldnames:
duty_ax.plot(x, column_float(rows, "heater_pct"), "C1-", label="heater %")
if "heatlim_pct" in fieldnames:
duty_ax.plot(x, column_float(rows, "heatlim_pct"), "C1--", alpha=0.6, label="heatlim %")
if "fan_pct" in fieldnames:
duty_ax.plot(x, column_float(rows, "fan_pct"), "C0-", label="fan %")
duty_ax.set_ylabel("%")
duty_ax.legend(loc="upper left", fontsize=8)
duty_ax.grid(True, alpha=0.3)
spread_ax = axes[2]
if "spread_c" in fieldnames:
spread_ax.plot(x, column_float(rows, "spread_c"), "C2-", label="spread")
spread_ax.set_ylabel("°C")
spread_ax.set_xlabel(x_label)
spread_ax.legend(loc="upper left", fontsize=8)
spread_ax.grid(True, alpha=0.3)
if output is not None:
fig.savefig(output, dpi=150)
print(f"Wrote {output}")
else:
plt.show()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Plot dryer CSV temperature logs")
parser.add_argument(
"csv",
help='CSV file, directory (newest .csv), or "remote" for newest on ' + REMOTE_SSH,
)
parser.add_argument("-o", "--output", type=Path, help="Save PNG instead of opening a window")
parser.add_argument(
"--remote-dir",
action="append",
metavar="DIR",
help=f"Remote log directory on {REMOTE_SSH} (repeatable; used with remote)",
)
args = parser.parse_args(argv)
plot_title: str | None = None
if args.csv == "remote":
remote_dirs = tuple(args.remote_dir) if args.remote_dir else REMOTE_LOG_DIRS
path = fetch_remote_csv(remote_dirs)
plot_title = f"{REMOTE_SSH}:{path.name}"
else:
path = resolve_csv(Path(args.csv))
if args.output is None:
print(f"Plotting {plot_title or path}", file=sys.stderr)
plot_csv(path, args.output, title=plot_title)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1 +1,2 @@
pyserial>=3.5 pyserial>=3.5
matplotlib>=3.8

View File

@@ -1,169 +0,0 @@
#!/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())

354
src/fan_characterize.cpp Normal file
View File

@@ -0,0 +1,354 @@
#include "fan_characterize.h"
FanCharacterize::FanCharacterize()
: phase_(Phase::Idle),
maxCornerC_(FANCHARS_MAX_CORNER_C),
coolAvgC_(FANCHARS_COOL_AVG_C),
heaterPct_(FANCHARS_HEATER_PCT),
profileIndex_(0),
refineFanPwm_(0),
refineRun_(false),
sessionStartMs_(0),
phaseStartMs_(0),
lastLogMs_(0),
spreadSum_(0.0f),
spreadSamples_(0),
resultCount_(0),
winnerFanPwm_(0) {}
bool FanCharacterize::isActive() const {
return phase_ == Phase::Precool || phase_ == Phase::Heat || phase_ == Phase::Hold ||
phase_ == Phase::Cooldown;
}
uint32_t FanCharacterize::elapsedMs(uint32_t nowMs) const {
if (sessionStartMs_ == 0) {
return 0;
}
return nowMs - sessionStartMs_;
}
uint8_t FanCharacterize::currentFanPwm() const {
if (profileIndex_ >= FANCHARS_COARSE_COUNT) {
return refineFanPwm_;
}
return FANCHARS_COARSE_PWM[profileIndex_];
}
const char *FanCharacterize::phaseName() const {
if (isRefineRun() && (phase_ == Phase::Heat || phase_ == Phase::Hold)) {
return "refine";
}
switch (phase_) {
case Phase::Precool:
return "precool";
case Phase::Heat:
return "heat";
case Phase::Hold:
return "hold";
case Phase::Cooldown:
return "cool";
default:
return "";
}
}
bool FanCharacterize::start(float maxCornerC, float avgTempC) {
if (maxCornerC < 45.0f || maxCornerC > EMERGENCY_MAX_TEMP_C - 5.0f) {
return false;
}
maxCornerC_ = maxCornerC;
heaterPct_ = FANCHARS_HEATER_PCT;
profileIndex_ = 0;
refineFanPwm_ = 0;
refineRun_ = false;
resultCount_ = 0;
winnerFanPwm_ = 0;
sessionStartMs_ = millis();
phaseStartMs_ = sessionStartMs_;
lastLogMs_ = 0;
resetProfileStats();
phase_ = avgTempC > coolAvgC_ + FANCHARS_PRECOOL_MARGIN_C ? Phase::Precool : Phase::Heat;
Serial.print(F("fanchars: "));
Serial.print(FANCHARS_COARSE_COUNT);
Serial.print(F(" fans + refine h="));
Serial.println(heaterPct_, 0);
return true;
}
void FanCharacterize::abort() {
if (isActive()) {
Serial.println(F("fanchars: stop"));
}
phase_ = Phase::Idle;
sessionStartMs_ = 0;
}
void FanCharacterize::reset() {
phase_ = Phase::Idle;
sessionStartMs_ = 0;
}
bool FanCharacterize::update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs,
float &heaterDutyOut, uint8_t &fanPwmOut) {
heaterDutyOut = 0.0f;
fanPwmOut = 0;
if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) {
return false;
}
if (maxTempC >= EMERGENCY_MAX_TEMP_C) {
fail(F("fanchars: abort limit"));
return false;
}
if (phase_ == Phase::Precool) {
fanPwmOut = FAN_MAX_PWM;
if (nowMs - phaseStartMs_ > FANCHARS_COOLDOWN_TIMEOUT_MS) {
fail(F("fanchars: abort precool"));
return false;
}
if (avgTempC <= coolAvgC_) {
beginProfileHeat(nowMs);
}
return true;
}
if (phase_ == Phase::Heat) {
fanPwmOut = currentFanPwm();
heaterDutyOut = heaterPct_;
if (nowMs - phaseStartMs_ > FANCHARS_HEAT_TIMEOUT_MS) {
skipProfile(nowMs, maxTempC);
return true;
}
if (maxTempC >= maxCornerC_) {
enterHold(nowMs);
}
return true;
}
if (phase_ == Phase::Hold) {
fanPwmOut = currentFanPwm();
spreadSum_ += spreadC;
++spreadSamples_;
if (nowMs - phaseStartMs_ >= FANCHARS_HOLD_MS) {
finishProfile(nowMs);
}
return true;
}
if (phase_ == Phase::Cooldown) {
fanPwmOut = FAN_MAX_PWM;
if (nowMs - phaseStartMs_ > FANCHARS_COOLDOWN_TIMEOUT_MS) {
fail(F("fanchars: abort cool"));
return false;
}
if (avgTempC <= coolAvgC_) {
beginProfileHeat(nowMs);
}
return true;
}
return false;
}
void FanCharacterize::logIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
float avgTempC, float minTempC, float maxTempC, float spreadC,
uint32_t nowMs) {
if (!isActive()) {
return;
}
if (lastLogMs_ != 0 && nowMs - lastLogMs_ < FANCHARS_LOG_INTERVAL_MS) {
return;
}
lastLogMs_ = nowMs;
const uint8_t runNum =
profileIndex_ >= FANCHARS_COARSE_COUNT ? FANCHARS_COARSE_COUNT + 1 : profileIndex_ + 1;
Serial.print(F("fc,"));
Serial.print(nowMs);
Serial.print(',');
Serial.print(phaseName());
Serial.print(',');
Serial.print(runNum);
Serial.print('/');
Serial.print(FANCHARS_COARSE_COUNT + 1);
Serial.print(',');
Serial.print(currentFanPwm());
Serial.print(',');
Serial.print(heaterPct_, 0);
Serial.print(',');
Serial.print(avgTempC, 1);
Serial.print(',');
Serial.print(minTempC, 1);
Serial.print(',');
Serial.print(maxTempC, 1);
Serial.print(',');
Serial.println(spreadC, 1);
(void)sensorTemps;
(void)sensorValid;
(void)sensorCount;
}
void FanCharacterize::resetProfileStats() {
spreadSum_ = 0.0f;
spreadSamples_ = 0;
}
void FanCharacterize::beginProfileHeat(uint32_t nowMs) {
phase_ = Phase::Heat;
phaseStartMs_ = nowMs;
resetProfileStats();
Serial.print(F("fanchars: f="));
Serial.println(currentFanPwm());
}
void FanCharacterize::enterHold(uint32_t nowMs) {
phase_ = Phase::Hold;
phaseStartMs_ = nowMs;
resetProfileStats();
}
void FanCharacterize::finishProfile(uint32_t nowMs) {
if (spreadSamples_ == 0) {
fail(F("fanchars: abort hold"));
return;
}
const float meanSpread = spreadSum_ / static_cast<float>(spreadSamples_);
results_[resultCount_].fanPwm = currentFanPwm();
results_[resultCount_].meanSpreadC = meanSpread;
++resultCount_;
Serial.print(F("fanchars: f="));
Serial.print(currentFanPwm());
Serial.print(F(" spr="));
Serial.println(meanSpread, 2);
if (isRefineRun()) {
finishAll(nowMs);
return;
}
if (profileIndex_ + 1 >= FANCHARS_COARSE_COUNT) {
planRefine(nowMs);
return;
}
++profileIndex_;
phase_ = Phase::Cooldown;
phaseStartMs_ = nowMs;
}
void FanCharacterize::skipProfile(uint32_t nowMs, float maxTempC) {
Serial.print(F("fanchars: skip f="));
Serial.print(currentFanPwm());
Serial.print(F(" max="));
Serial.print(maxTempC, 1);
Serial.println(F("C"));
if (isRefineRun()) {
finishAll(nowMs);
return;
}
if (profileIndex_ + 1 >= FANCHARS_COARSE_COUNT) {
planRefine(nowMs);
return;
}
++profileIndex_;
phase_ = Phase::Cooldown;
phaseStartMs_ = nowMs;
}
void FanCharacterize::planRefine(uint32_t nowMs) {
if (resultCount_ < 2) {
Serial.println(F("fanchars: refine skip"));
finishAll(nowMs);
return;
}
uint8_t bestI = 0;
uint8_t secondI = 1;
if (results_[secondI].meanSpreadC < results_[bestI].meanSpreadC) {
bestI = 1;
secondI = 0;
}
for (uint8_t i = 2; i < resultCount_; ++i) {
if (results_[i].meanSpreadC < results_[bestI].meanSpreadC) {
secondI = bestI;
bestI = i;
} else if (results_[i].meanSpreadC < results_[secondI].meanSpreadC) {
secondI = i;
}
}
const uint8_t bestFan = results_[bestI].fanPwm;
if (bestFan <= FANCHARS_LIMIT_LOW_PWM || bestFan >= FANCHARS_LIMIT_HIGH_PWM) {
Serial.println(F("fanchars: limit"));
finishAll(nowMs);
return;
}
const uint8_t secondFan = results_[secondI].fanPwm;
refineFanPwm_ =
static_cast<uint8_t>((static_cast<uint16_t>(bestFan) + secondFan) / 2);
if (refineFanPwm_ == bestFan || refineFanPwm_ == secondFan) {
finishAll(nowMs);
return;
}
refineRun_ = true;
profileIndex_ = FANCHARS_COARSE_COUNT;
phase_ = Phase::Cooldown;
phaseStartMs_ = nowMs;
Serial.print(F("fanchars: mid f="));
Serial.println(refineFanPwm_);
}
void FanCharacterize::finishAll(uint32_t nowMs) {
phase_ = Phase::Done;
if (resultCount_ == 0) {
winnerFanPwm_ = 0;
Serial.println(F("fanchars: done — no valid runs"));
return;
}
uint8_t bestIndex = 0;
float bestSpread = results_[0].meanSpreadC;
for (uint8_t i = 1; i < resultCount_; ++i) {
if (results_[i].meanSpreadC < bestSpread) {
bestSpread = results_[i].meanSpreadC;
bestIndex = i;
}
}
winnerFanPwm_ = results_[bestIndex].fanPwm;
Serial.print(F("fanchars: done "));
Serial.print((nowMs - sessionStartMs_) / 60000UL);
Serial.println(F("min"));
for (uint8_t i = 0; i < resultCount_; ++i) {
Serial.print(F(" "));
Serial.print(results_[i].fanPwm);
Serial.print(F("="));
Serial.println(results_[i].meanSpreadC, 2);
}
Serial.print(F(" best "));
Serial.print(winnerFanPwm_);
Serial.print(F(" spr="));
Serial.println(bestSpread, 2);
Serial.println(F(" fanchars save"));
}
void FanCharacterize::fail(const __FlashStringHelper *reason) {
Serial.println(reason);
phase_ = Phase::Failed;
sessionStartMs_ = 0;
}

View File

@@ -152,8 +152,6 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) {
Serial.print(F("(cooldown)")); Serial.print(F("(cooldown)"));
} else if (thermal.isFanManualOverride()) { } else if (thermal.isFanManualOverride()) {
Serial.print(F("(manual)")); Serial.print(F("(manual)"));
} else if (!thermal.isIdle() && thermal.fanPwm() == 0) {
Serial.print(F("(off<40C)"));
} else if (!thermal.isIdle()) { } else if (!thermal.isIdle()) {
Serial.print(F("(stir)")); Serial.print(F("(stir)"));
} }