diff --git a/README.md b/README.md index 4fc5817..cad6676 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Verify access: `test -w /dev/ttyUSB0 && echo ok` Emergency cutoff is fixed at **70°C** — you can autotune at 50–55°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 `; `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 `; `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.). diff --git a/include/config.h b/include/config.h index b5a8e74..5a9044d 100644 --- a/include/config.h +++ b/include/config.h @@ -42,19 +42,20 @@ static const float PID_KI = HEAT_PI_KI; static const float PID_KD = 0.0f; static const float GOOD_SPREAD_C = 5.0f; -// Tiered heater cap during heat-up -static const float HEATER_MAX_DUTY_COLD = 85.0f; -static const float HEATER_MAX_DUTY_MID = 65.0f; +// Tiered heater cap during heat-up (100% until near target) +static const float HEATER_MAX_DUTY_COLD = 100.0f; +static const float HEATER_MAX_DUTY_MID = 75.0f; static const float HEATER_MAX_DUTY_NEAR = 45.0f; static const float HEATER_COLD_BELOW_C = 10.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 MAX_TEMP_HEADROOM_C = 15.0f; 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 float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f; static const uint8_t FAN_MAX_PWM = 255; diff --git a/include/fan_characterize.h b/include/fan_characterize.h new file mode 100644 index 0000000..10714ec --- /dev/null +++ b/include/fan_characterize.h @@ -0,0 +1,75 @@ +#pragma once + +#include + +#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]; +}; diff --git a/include/thermal_controller.h b/include/thermal_controller.h index 751e41a..5e46a0a 100644 --- a/include/thermal_controller.h +++ b/include/thermal_controller.h @@ -456,7 +456,7 @@ private: void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) { float duty = 0.0f; - uint8_t fan = AUTOTUNE_PREHEAT_FAN_PWM; + uint8_t fan = stirFanPwm_; autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan); heaterDutyPercent_ = duty; @@ -464,7 +464,7 @@ private: heaterOn_ = duty >= 50.0f; digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW); heaterBlock_ = duty > 0.0f ? HeaterBlock::None : HeaterBlock::Autotune; - writeFan(fan); + writeFan(fanManualActive_ ? fanManualPwm_ : stirFanPwm_); lastHeaterUpdateMs_ = nowMs; commitAutotuneIfDone(); } @@ -513,15 +513,7 @@ private: } heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs); - const uint8_t fanPwm = 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; + regulatingFanPwm_ = fanManualActive_ ? fanManualPwm_ : stirFanPwm_; } static float clampPercent(float value) { @@ -547,6 +539,11 @@ private: float allowanceFromMaxCorner(float maxTempC, float avgTempC) const { 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; } diff --git a/scripts/dryer_tui.py b/scripts/dryer_tui.py index b0f7488..199b501 100644 --- a/scripts/dryer_tui.py +++ b/scripts/dryer_tui.py @@ -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 "" diff --git a/scripts/fan_characterize.py b/scripts/fan_characterize.py new file mode 100644 index 0000000..77ff759 --- /dev/null +++ b/scripts/fan_characterize.py @@ -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\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()) diff --git a/scripts/plot_logs.py b/scripts/plot_logs.py new file mode 100644 index 0000000..0470d8e --- /dev/null +++ b/scripts/plot_logs.py @@ -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()) diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 7ad05ef..b4fe362 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1 +1,2 @@ pyserial>=3.5 +matplotlib>=3.8 diff --git a/scripts/step_response.py b/scripts/step_response.py deleted file mode 100644 index bff5bc1..0000000 --- a/scripts/step_response.py +++ /dev/null @@ -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\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/fan_characterize.cpp b/src/fan_characterize.cpp new file mode 100644 index 0000000..989306e --- /dev/null +++ b/src/fan_characterize.cpp @@ -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(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((static_cast(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; +} diff --git a/src/main.cpp b/src/main.cpp index d4117f4..c070d18 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -152,8 +152,6 @@ void printStatus(float avgTemp, float minTemp, float maxTemp) { Serial.print(F("(cooldown)")); } else if (thermal.isFanManualOverride()) { Serial.print(F("(manual)")); - } else if (!thermal.isIdle() && thermal.fanPwm() == 0) { - Serial.print(F("(off<40C)")); } else if (!thermal.isIdle()) { Serial.print(F("(stir)")); }