#!/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\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"^fanchars: done") FAIL_RE = re.compile(r"^fanchars: abort") RUN_SUMMARY_RE = re.compile(r"^fanchars: f=(?P\d+) spr=(?P[\d.]+)") BEST_RE = re.compile(r"^ best (?P\d+) spr=(?P[\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())