This commit is contained in:
2026-07-05 20:15:29 +02:00
parent 960831529e
commit 02e51717e8
4 changed files with 192 additions and 72 deletions

View File

@@ -75,13 +75,14 @@ static const float SPREAD_EMA_ALPHA = 0.45f;
// PID auto-tune (relay method) — run with: autotune 45 // PID auto-tune (relay method) — run with: autotune 45
static const float AUTOTUNE_HYSTERESIS_C = 0.4f; static const float AUTOTUNE_HYSTERESIS_C = 0.4f;
static const float AUTOTUNE_PREHEAT_BAND_C = 5.0f; static const float AUTOTUNE_PREHEAT_BAND_C = 3.0f;
static const float AUTOTUNE_PREHEAT_DUTY = 100.0f; static const float AUTOTUNE_PREHEAT_DUTY = 100.0f;
static const uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 70; // low fan for entire autotune static const uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 0; // fan off — maximize heat-up
static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 6; static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 5;
static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL; // 20 min static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL; // 20 min
static const uint32_t AUTOTUNE_RELAY_STALL_MS = 1500000UL; // 25 min in relay, 0 cycles
static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL; // 60 min total static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL; // 60 min total
static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL; // count periods up to 40 min static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Timing // Timing

View File

@@ -24,6 +24,7 @@ public:
spreadSamples_(0), spreadSamples_(0),
cycleCount_(0), cycleCount_(0),
aboveSetpoint_(false), aboveSetpoint_(false),
useMaxSensorPv_(false),
sessionStartMs_(0), sessionStartMs_(0),
phaseStartMs_(0), phaseStartMs_(0),
resultKp_(PID_KP), resultKp_(PID_KP),
@@ -47,12 +48,14 @@ public:
float preheatTargetC() const { return setpointC_ - AUTOTUNE_PREHEAT_BAND_C; } float preheatTargetC() const { return setpointC_ - AUTOTUNE_PREHEAT_BAND_C; }
bool usesMaxSensor() const { return useMaxSensorPv_; }
const char *phaseName() const { const char *phaseName() const {
switch (phase_) { switch (phase_) {
case Phase::Preheat: case Phase::Preheat:
return "preheat"; return "preheat";
case Phase::Relay: case Phase::Relay:
return "relay"; return useMaxSensorPv_ ? "relay-max" : "relay-avg";
default: default:
return ""; return "";
} }
@@ -124,8 +127,8 @@ public:
fail(F("autotune: abort — preheat timeout")); fail(F("autotune: abort — preheat timeout"));
return phase_; return phase_;
} }
if (avgTempC >= preheatTargetC()) { if (avgTempC >= preheatTargetC() || maxTempC >= setpointC_ - 2.0f) {
enterRelay(avgTempC, nowMs); enterRelay(avgTempC, maxTempC, spreadC, nowMs);
} else { } else {
heaterDutyOut = AUTOTUNE_PREHEAT_DUTY; heaterDutyOut = AUTOTUNE_PREHEAT_DUTY;
} }
@@ -137,27 +140,44 @@ public:
return phase_; return phase_;
} }
if (nowMs - sessionStartMs_ > AUTOTUNE_SESSION_TIMEOUT_MS) {
fail(F("autotune: abort — session timeout"));
return phase_;
}
if (cycleCount_ == 0 && nowMs - phaseStartMs_ > AUTOTUNE_RELAY_STALL_MS) {
Serial.print(F("autotune: relay stalled — avg "));
Serial.print(avgTempC, 1);
Serial.print(F("C max "));
Serial.print(maxTempC, 1);
Serial.println(F("C (spread too large for avg to cross setpoint?)"));
fail(F("autotune: abort — no oscillation"));
return phase_;
}
const float pv = useMaxSensorPv_ ? maxTempC : avgTempC;
spreadSum_ += spreadC; spreadSum_ += spreadC;
++spreadSamples_; ++spreadSamples_;
if (avgTempC > peakSinceCross_) { if (pv > peakSinceCross_) {
peakSinceCross_ = avgTempC; peakSinceCross_ = pv;
} }
if (avgTempC < valleySinceCross_) { if (pv < valleySinceCross_) {
valleySinceCross_ = avgTempC; valleySinceCross_ = pv;
} }
bool heatOn = false; bool heatOn = false;
if (avgTempC <= relayLow_) { if (pv <= relayLow_) {
heatOn = true; heatOn = true;
} else if (avgTempC >= relayHigh_) { } else if (pv >= relayHigh_) {
heatOn = false; heatOn = false;
} else { } else {
heatOn = !aboveSetpoint_; heatOn = !aboveSetpoint_;
} }
heaterDutyOut = heatOn ? 100.0f : 0.0f; heaterDutyOut = heatOn ? 100.0f : 0.0f;
const bool nowAbove = avgTempC >= setpointC_; const bool nowAbove = pv >= setpointC_;
if (nowAbove != aboveSetpoint_) { if (nowAbove != aboveSetpoint_) {
onSetpointCrossing(nowMs); onSetpointCrossing(nowMs);
aboveSetpoint_ = nowAbove; aboveSetpoint_ = nowAbove;
@@ -167,14 +187,18 @@ public:
} }
private: private:
void enterRelay(float avgTempC, uint32_t nowMs) { void enterRelay(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs) {
phase_ = Phase::Relay; phase_ = Phase::Relay;
phaseStartMs_ = nowMs; phaseStartMs_ = nowMs;
aboveSetpoint_ = avgTempC >= setpointC_; useMaxSensorPv_ = spreadC > GOOD_SPREAD_C;
peakSinceCross_ = avgTempC; const float pv = useMaxSensorPv_ ? maxTempC : avgTempC;
valleySinceCross_ = avgTempC; aboveSetpoint_ = pv >= setpointC_;
peakSinceCross_ = pv;
valleySinceCross_ = pv;
lastCrossMs_ = 0; lastCrossMs_ = 0;
Serial.print(F("autotune: relay test started (")); Serial.print(F("autotune: relay "));
Serial.print(useMaxSensorPv_ ? F("max-sensor") : F("avg"));
Serial.print(F(" ("));
Serial.print((nowMs - sessionStartMs_) / 1000UL); Serial.print((nowMs - sessionStartMs_) / 1000UL);
Serial.println(F("s preheat)")); Serial.println(F("s preheat)"));
} }
@@ -191,6 +215,7 @@ private:
spreadSamples_ = 0; spreadSamples_ = 0;
cycleCount_ = 0; cycleCount_ = 0;
aboveSetpoint_ = false; aboveSetpoint_ = false;
useMaxSensorPv_ = false;
} }
void onSetpointCrossing(uint32_t nowMs) { void onSetpointCrossing(uint32_t nowMs) {
@@ -295,6 +320,7 @@ private:
uint16_t spreadSamples_; uint16_t spreadSamples_;
uint8_t cycleCount_; uint8_t cycleCount_;
bool aboveSetpoint_; bool aboveSetpoint_;
bool useMaxSensorPv_;
uint32_t sessionStartMs_; uint32_t sessionStartMs_;
uint32_t phaseStartMs_; uint32_t phaseStartMs_;
float resultKp_; float resultKp_;

View File

@@ -9,6 +9,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import re
import sys import sys
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -19,6 +20,118 @@ FALLBACK_HEADER = (
"fan_pct,cutoff,failsafe,ch2_t,ch2_h,ch3_t,ch3_h,ch4_t,ch4_h,ch5_t,ch5_h" "fan_pct,cutoff,failsafe,ch2_t,ch2_h,ch3_t,ch3_h,ch4_t,ch4_h,ch5_t,ch5_h"
) )
SENSOR_CHANNELS = [2, 3, 4, 5]
_FAN_PCT_RE = re.compile(r"\((\d+)%\)")
_FAN_PWM_RE = re.compile(r"^(\d+)/")
def fan_pct_from_status(fan: str) -> int:
match = _FAN_PCT_RE.search(fan)
if match:
return int(match.group(1))
match = _FAN_PWM_RE.match(fan)
if match:
return (int(match.group(1)) * 100) // 255
return 0
def target_c_from_status(target: str) -> str:
if target.startswith("idle"):
return "0.0"
if target.endswith("C"):
return target[:-1]
return target
def build_csv_payload_from_status(data: dict, ms: int | None = None) -> str:
if ms is None:
ms = int(time.time() * 1000)
sensors = {ch: (temp, hum) for ch, temp, hum in data.get("sensor_list", [])}
parts = [
str(ms),
target_c_from_status(data["target"]),
data["avg"],
data["min"],
data["max"],
data["spread"],
data["heatlim"],
data["heater"],
str(fan_pct_from_status(data.get("fan", "0"))),
"1" if data.get("cutoff_active") == "YES" else "0",
"1" if data.get("failsafe") == "YES" else "0",
]
for ch in SENSOR_CHANNELS:
if str(ch) in sensors:
temp, hum = sensors[str(ch)]
if temp == "ERR":
parts.extend(["", ""])
else:
parts.extend([temp, hum])
else:
parts.extend(["", ""])
return ",".join(parts)
class CsvSession:
"""Deferred CSV writer — no empty file until the first row lands."""
def __init__(self, path: Path):
self.path = path
self._fh = None
self._header_written = False
self.row_count = 0
def _ensure_open(self) -> None:
if self._fh is None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self._fh = self.path.open("w", encoding="utf-8")
def _write_header(self) -> None:
if not self._header_written:
self._ensure_open()
assert self._fh is not None
self._fh.write(FALLBACK_HEADER + "\n")
self._header_written = True
def write_device_line(self, line: str) -> None:
if line.startswith("csv_hdr,"):
self._ensure_open()
assert self._fh is not None
device_header = line[len("csv_hdr,") :]
self._fh.write("wall_time," + device_header + "\n")
self._header_written = True
self._fh.flush()
return
if not line.startswith("csv,"):
return
self._write_header()
assert self._fh is not None
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
self._fh.write(wall_time + "," + line[len("csv,") :] + "\n")
self._fh.flush()
self.row_count += 1
def write_status(self, data: dict) -> None:
self._write_header()
assert self._fh is not None
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
payload = build_csv_payload_from_status(data)
self._fh.write(wall_time + "," + payload + "\n")
self._fh.flush()
self.row_count += 1
def close(self) -> None:
if self._fh is not None:
self._fh.close()
self._fh = None
if self.row_count == 0 and self.path.exists():
try:
self.path.unlink()
except OSError:
pass
def detect_serial_port() -> str | None: def detect_serial_port() -> str | None:
by_id = Path("/dev/serial/by-id") by_id = Path("/dev/serial/by-id")
@@ -93,36 +206,20 @@ def enable_dryer_logging(ser, retries: int = 3) -> None:
print("WARN: did not see 'OK csv logging on' — continuing anyway", file=sys.stderr) print("WARN: did not see 'OK csv logging on' — continuing anyway", file=sys.stderr)
def write_csv_row(fh, line: str, header_written: list[bool]) -> None:
if line.startswith("csv_hdr,"):
device_header = line[len("csv_hdr,") :]
fh.write("wall_time," + device_header + "\n")
header_written[0] = True
fh.flush()
return
if not line.startswith("csv,"):
return
if not header_written[0]:
fh.write(FALLBACK_HEADER + "\n")
header_written[0] = True
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
fh.write(wall_time + "," + line[len("csv,") :] + "\n")
fh.flush()
def cmd_log(args: argparse.Namespace) -> int: def cmd_log(args: argparse.Namespace) -> int:
from dryer_tui import parse_status
port = resolve_port(args.port) port = resolve_port(args.port)
out = args.output out = args.output
if out is None: if out is None:
out = args.log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv" out = args.log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
out.parent.mkdir(parents=True, exist_ok=True)
print(f"Logging {port} -> {out}", file=sys.stderr) print(f"Logging {port} -> {out}", file=sys.stderr)
if args.auto_log_on: if args.auto_log_on:
print("Will send 'log on' after connect", file=sys.stderr) print("Will send 'log on' after connect", file=sys.stderr)
header_written = False session = CsvSession(out)
with open_serial(port, args.baud) as ser, out.open("w", encoding="utf-8") as fh: with open_serial(port, args.baud) as ser:
if args.auto_log_on: if args.auto_log_on:
enable_dryer_logging(ser) enable_dryer_logging(ser)
@@ -130,32 +227,27 @@ def cmd_log(args: argparse.Namespace) -> int:
try: try:
raw = ser.readline() raw = ser.readline()
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nStopped.", file=sys.stderr) print(f"\nStopped ({session.row_count} rows).", file=sys.stderr)
session.close()
return 0 return 0
if not raw: if not raw:
continue continue
line = decode_line(raw) line = decode_line(raw)
if not line.startswith("csv_hdr,") and not line.startswith("csv,"): parsed = parse_status(line)
if line: if parsed:
session.write_status(parsed)
print(line) print(line)
continue continue
if line.startswith("csv_hdr,"): if line.startswith("csv_hdr,") or line.startswith("csv,"):
device_header = line[len("csv_hdr,") :] session.write_device_line(line)
fh.write("wall_time," + device_header + "\n")
header_written = True
fh.flush()
continue continue
if not header_written: if line:
fh.write(FALLBACK_HEADER + "\n") print(line)
header_written = True return 0
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
fh.write(wall_time + "," + line[len("csv,") :] + "\n")
fh.flush()
def cmd_tui(args: argparse.Namespace) -> int: def cmd_tui(args: argparse.Namespace) -> int:

View File

@@ -13,8 +13,8 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from capture_csv import ( from capture_csv import (
CsvSession,
decode_line, decode_line,
write_csv_row,
) )
PRESETS: list[tuple[str, float]] = [ PRESETS: list[tuple[str, float]] = [
@@ -52,7 +52,7 @@ STATUS_RE = re.compile(
SENSOR_RE = re.compile(r"ch(\d+):([\d.]+)C/(\d+)%|ch(\d+):ERR") SENSOR_RE = re.compile(r"ch(\d+):([\d.]+)C/(\d+)%|ch(\d+):ERR")
AUTOTUNE_MODE_RE = re.compile( AUTOTUNE_MODE_RE = re.compile(
r"autotune/(?P<phase>\w+) (?P<elapsed>\d+)s (?P<cycles>\d+/\d+)cyc pre>=(?P<pre>\d+)C" r"autotune/(?P<phase>[\w-]+) (?P<elapsed>\d+)s (?P<cycles>\d+/\d+)cyc pre>=(?P<pre>\d+)C"
) )
@@ -155,8 +155,7 @@ class SerialWorker:
self.state = state self.state = state
self.lock = lock self.lock = lock
self.stop = threading.Event() self.stop = threading.Event()
self._log_fh = None self._csv: CsvSession | None = None
self._header_written = [False]
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
def start(self) -> None: def start(self) -> None:
@@ -171,9 +170,9 @@ class SerialWorker:
self.stop.set() self.stop.set()
if self._thread is not None: if self._thread is not None:
self._thread.join(timeout=1.5) self._thread.join(timeout=1.5)
if self._log_fh is not None: if self._csv is not None:
self._log_fh.close() self._csv.close()
self._log_fh = None self._csv = None
def send(self, command: str) -> None: def send(self, command: str) -> None:
if self.ser is None: if self.ser is None:
@@ -186,19 +185,21 @@ class SerialWorker:
if enabled and not self.state.csv_logging: if enabled and not self.state.csv_logging:
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
path = log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv" path = log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
self._log_fh = path.open("w", encoding="utf-8") self._csv = CsvSession(path)
self._header_written = [False]
self.state.csv_path = path self.state.csv_path = path
self.state.csv_logging = True self.state.csv_logging = True
self.state.messages.append(f"CSV -> {path.name}") self.state.messages.append(f"CSV -> {path.name} (on status)")
self.send("log on") self.send("log on")
elif not enabled and self.state.csv_logging: elif not enabled and self.state.csv_logging:
self.send("log off") self.send("log off")
self.state.csv_logging = False self.state.csv_logging = False
self.state.csv_path = None self.state.csv_path = None
if self._log_fh is not None: if self._csv is not None:
self._log_fh.close() rows = self._csv.row_count
self._log_fh = None self._csv.close()
self._csv = None
self.state.messages.append(f"CSV logging off ({rows} rows)")
else:
self.state.messages.append("CSV logging off") self.state.messages.append("CSV logging off")
def _note(self, line: str) -> None: def _note(self, line: str) -> None:
@@ -221,14 +222,14 @@ class SerialWorker:
continue continue
if line.startswith("csv,") or line.startswith("csv_hdr,"): if line.startswith("csv,") or line.startswith("csv_hdr,"):
if self._log_fh is not None:
write_csv_row(self._log_fh, line, self._header_written)
continue continue
parsed = parse_status(line) parsed = parse_status(line)
if parsed: if parsed:
with self.lock: with self.lock:
apply_status(self.state, parsed) apply_status(self.state, parsed)
if self._csv is not None:
self._csv.write_status(parsed)
continue continue
if line.startswith("target="): if line.startswith("target="):