ouch finger
This commit is contained in:
@@ -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.
|
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 <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.).
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
75
include/fan_characterize.h
Normal file
75
include/fan_characterize.h
Normal 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];
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
187
scripts/fan_characterize.py
Normal 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
232
scripts/plot_logs.py
Normal 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())
|
||||||
@@ -1 +1,2 @@
|
|||||||
pyserial>=3.5
|
pyserial>=3.5
|
||||||
|
matplotlib>=3.8
|
||||||
|
|||||||
@@ -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
354
src/fan_characterize.cpp
Normal 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;
|
||||||
|
}
|
||||||
@@ -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)"));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user