233 lines
7.3 KiB
Python
233 lines
7.3 KiB
Python
#!/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())
|