diff --git a/include/config.h b/include/config.h index 3658130..ae068a7 100644 --- a/include/config.h +++ b/include/config.h @@ -75,13 +75,14 @@ static const float SPREAD_EMA_ALPHA = 0.45f; // PID auto-tune (relay method) — run with: autotune 45 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 uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 70; // low fan for entire autotune -static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 6; +static const uint8_t AUTOTUNE_PREHEAT_FAN_PWM = 0; // fan off — maximize heat-up +static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 5; static const uint32_t AUTOTUNE_PREHEAT_TIMEOUT_MS = 1200000UL; // 20 min -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_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_RELAY_PERIOD_MAX_MS = 2400000UL; // --------------------------------------------------------------------------- // Timing diff --git a/include/pid_autotuner.h b/include/pid_autotuner.h index c621efe..46c1262 100644 --- a/include/pid_autotuner.h +++ b/include/pid_autotuner.h @@ -24,6 +24,7 @@ public: spreadSamples_(0), cycleCount_(0), aboveSetpoint_(false), + useMaxSensorPv_(false), sessionStartMs_(0), phaseStartMs_(0), resultKp_(PID_KP), @@ -47,12 +48,14 @@ public: float preheatTargetC() const { return setpointC_ - AUTOTUNE_PREHEAT_BAND_C; } + bool usesMaxSensor() const { return useMaxSensorPv_; } + const char *phaseName() const { switch (phase_) { case Phase::Preheat: return "preheat"; case Phase::Relay: - return "relay"; + return useMaxSensorPv_ ? "relay-max" : "relay-avg"; default: return ""; } @@ -124,8 +127,8 @@ public: fail(F("autotune: abort — preheat timeout")); return phase_; } - if (avgTempC >= preheatTargetC()) { - enterRelay(avgTempC, nowMs); + if (avgTempC >= preheatTargetC() || maxTempC >= setpointC_ - 2.0f) { + enterRelay(avgTempC, maxTempC, spreadC, nowMs); } else { heaterDutyOut = AUTOTUNE_PREHEAT_DUTY; } @@ -137,27 +140,44 @@ public: 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; ++spreadSamples_; - if (avgTempC > peakSinceCross_) { - peakSinceCross_ = avgTempC; + if (pv > peakSinceCross_) { + peakSinceCross_ = pv; } - if (avgTempC < valleySinceCross_) { - valleySinceCross_ = avgTempC; + if (pv < valleySinceCross_) { + valleySinceCross_ = pv; } bool heatOn = false; - if (avgTempC <= relayLow_) { + if (pv <= relayLow_) { heatOn = true; - } else if (avgTempC >= relayHigh_) { + } else if (pv >= relayHigh_) { heatOn = false; } else { heatOn = !aboveSetpoint_; } heaterDutyOut = heatOn ? 100.0f : 0.0f; - const bool nowAbove = avgTempC >= setpointC_; + const bool nowAbove = pv >= setpointC_; if (nowAbove != aboveSetpoint_) { onSetpointCrossing(nowMs); aboveSetpoint_ = nowAbove; @@ -167,14 +187,18 @@ public: } private: - void enterRelay(float avgTempC, uint32_t nowMs) { + void enterRelay(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs) { phase_ = Phase::Relay; phaseStartMs_ = nowMs; - aboveSetpoint_ = avgTempC >= setpointC_; - peakSinceCross_ = avgTempC; - valleySinceCross_ = avgTempC; + useMaxSensorPv_ = spreadC > GOOD_SPREAD_C; + const float pv = useMaxSensorPv_ ? maxTempC : avgTempC; + aboveSetpoint_ = pv >= setpointC_; + peakSinceCross_ = pv; + valleySinceCross_ = pv; 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.println(F("s preheat)")); } @@ -191,6 +215,7 @@ private: spreadSamples_ = 0; cycleCount_ = 0; aboveSetpoint_ = false; + useMaxSensorPv_ = false; } void onSetpointCrossing(uint32_t nowMs) { @@ -295,6 +320,7 @@ private: uint16_t spreadSamples_; uint8_t cycleCount_; bool aboveSetpoint_; + bool useMaxSensorPv_; uint32_t sessionStartMs_; uint32_t phaseStartMs_; float resultKp_; diff --git a/scripts/capture_csv.py b/scripts/capture_csv.py index c6ef151..45a4bb9 100755 --- a/scripts/capture_csv.py +++ b/scripts/capture_csv.py @@ -9,6 +9,7 @@ from __future__ import annotations import argparse +import re import sys import time 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" ) +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: 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) -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: + from dryer_tui import parse_status + port = resolve_port(args.port) out = args.output if out is None: 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) if args.auto_log_on: print("Will send 'log on' after connect", file=sys.stderr) - header_written = False - with open_serial(port, args.baud) as ser, out.open("w", encoding="utf-8") as fh: + session = CsvSession(out) + with open_serial(port, args.baud) as ser: if args.auto_log_on: enable_dryer_logging(ser) @@ -130,32 +227,27 @@ def cmd_log(args: argparse.Namespace) -> int: try: raw = ser.readline() except KeyboardInterrupt: - print("\nStopped.", file=sys.stderr) + print(f"\nStopped ({session.row_count} rows).", file=sys.stderr) + session.close() return 0 if not raw: continue line = decode_line(raw) - if not line.startswith("csv_hdr,") and not line.startswith("csv,"): - if line: - print(line) + parsed = parse_status(line) + if parsed: + session.write_status(parsed) + print(line) continue - if line.startswith("csv_hdr,"): - device_header = line[len("csv_hdr,") :] - fh.write("wall_time," + device_header + "\n") - header_written = True - fh.flush() + if line.startswith("csv_hdr,") or line.startswith("csv,"): + session.write_device_line(line) continue - if not header_written: - fh.write(FALLBACK_HEADER + "\n") - header_written = True - - wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds") - fh.write(wall_time + "," + line[len("csv,") :] + "\n") - fh.flush() + if line: + print(line) + return 0 def cmd_tui(args: argparse.Namespace) -> int: diff --git a/scripts/dryer_tui.py b/scripts/dryer_tui.py index 18eab24..b24cd92 100644 --- a/scripts/dryer_tui.py +++ b/scripts/dryer_tui.py @@ -13,8 +13,8 @@ from datetime import datetime from pathlib import Path from capture_csv import ( + CsvSession, decode_line, - write_csv_row, ) 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") AUTOTUNE_MODE_RE = re.compile( - r"autotune/(?P\w+) (?P\d+)s (?P\d+/\d+)cyc pre>=(?P
\d+)C"
+    r"autotune/(?P[\w-]+) (?P\d+)s (?P\d+/\d+)cyc pre>=(?P
\d+)C"
 )
 
 
@@ -155,8 +155,7 @@ class SerialWorker:
         self.state = state
         self.lock = lock
         self.stop = threading.Event()
-        self._log_fh = None
-        self._header_written = [False]
+        self._csv: CsvSession | None = None
         self._thread: threading.Thread | None = None
 
     def start(self) -> None:
@@ -171,9 +170,9 @@ class SerialWorker:
         self.stop.set()
         if self._thread is not None:
             self._thread.join(timeout=1.5)
-        if self._log_fh is not None:
-            self._log_fh.close()
-            self._log_fh = None
+        if self._csv is not None:
+            self._csv.close()
+            self._csv = None
 
     def send(self, command: str) -> None:
         if self.ser is None:
@@ -186,20 +185,22 @@ class SerialWorker:
             if enabled and not self.state.csv_logging:
                 log_dir.mkdir(parents=True, exist_ok=True)
                 path = log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
-                self._log_fh = path.open("w", encoding="utf-8")
-                self._header_written = [False]
+                self._csv = CsvSession(path)
                 self.state.csv_path = path
                 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")
             elif not enabled and self.state.csv_logging:
                 self.send("log off")
                 self.state.csv_logging = False
                 self.state.csv_path = None
-                if self._log_fh is not None:
-                    self._log_fh.close()
-                    self._log_fh = None
-                self.state.messages.append("CSV logging off")
+                if self._csv is not None:
+                    rows = self._csv.row_count
+                    self._csv.close()
+                    self._csv = None
+                    self.state.messages.append(f"CSV logging off ({rows} rows)")
+                else:
+                    self.state.messages.append("CSV logging off")
 
     def _note(self, line: str) -> None:
         with self.lock:
@@ -221,14 +222,14 @@ class SerialWorker:
                 continue
 
             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
 
             parsed = parse_status(line)
             if parsed:
                 with self.lock:
                     apply_status(self.state, parsed)
+                if self._csv is not None:
+                    self._csv.write_status(parsed)
                 continue
 
             if line.startswith("target="):