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

@@ -200,7 +200,7 @@ def apply_status(state: DryerState, data: dict) -> None:
fan_raw = data["fan"]
state.fan = format_fan_display(fan_raw)
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 ""
elif "(manual)" in fan_raw or "(stir)" in fan_raw:
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
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())