add charts gui

This commit is contained in:
osiu97 2026-01-02 11:02:13 +01:00
parent 814c41098f
commit 5231f262e9
4 changed files with 127 additions and 3 deletions

23
Hopi.py
View File

@ -3,6 +3,7 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from hopi.data_handler import PowerMeterDataHandler from hopi.data_handler import PowerMeterDataHandler
from hopi.gui_plotter import PlotConfig, RealtimePlotter
from hopi.serial_client import ModbusRtuSerialClient, SerialConfig from hopi.serial_client import ModbusRtuSerialClient, SerialConfig
@ -20,6 +21,8 @@ def build_arg_parser() -> argparse.ArgumentParser:
parser.add_argument("--daemon", action="store_true", help="Run continuously until stopped (Ctrl+C)") parser.add_argument("--daemon", action="store_true", help="Run continuously until stopped (Ctrl+C)")
parser.add_argument("--csv", default="hopi_readings.csv", help="CSV output path for daemon mode") parser.add_argument("--csv", default="hopi_readings.csv", help="CSV output path for daemon mode")
parser.add_argument("--interval", type=float, default=1.0, help="Seconds between reads in daemon mode") parser.add_argument("--interval", type=float, default=1.0, help="Seconds between reads in daemon mode")
parser.add_argument("--gui", action="store_true", help="Show realtime plot in daemon mode")
parser.add_argument("--max-points", type=int, default=300, help="Max points to keep in the plot")
return parser return parser
@ -38,10 +41,21 @@ def main() -> None:
if args.interval <= args.timeout: if args.interval <= args.timeout:
raise ValueError("--interval must be > --timeout") raise ValueError("--interval must be > --timeout")
next_deadline = time.monotonic() t0 = time.monotonic()
next_deadline = t0
plotter = None
if args.gui:
series_names = handler.flat_readings_keys() # stable order
plotter = RealtimePlotter(series_names, config=PlotConfig(max_points=max(10, args.max_points)))
plotter.open()
with client, handler.open_csv_logger(args.csv) as logger: with client, handler.open_csv_logger(args.csv) as logger:
try: try:
while True: while True:
if plotter is not None and not plotter.is_open():
return
response = client.read_holding_registers( response = client.read_holding_registers(
slave_id=SLAVE_ID, slave_id=SLAVE_ID,
start_register=START_REG, start_register=START_REG,
@ -53,9 +67,14 @@ def main() -> None:
readings = handler.decode_readings(registers) readings = handler.decode_readings(registers)
row = {"timestamp": datetime.now(timezone.utc).isoformat()} row = {"timestamp": datetime.now(timezone.utc).isoformat()}
row.update(handler.readings_to_flat_dict(readings)) flat = handler.readings_to_flat_dict(readings)
row.update(flat)
logger.log(row) logger.log(row)
if plotter is not None:
plotter.update(time.monotonic() - t0, flat)
plotter.process_events()
next_deadline += args.interval next_deadline += args.interval
sleep_for = next_deadline - time.monotonic() sleep_for = next_deadline - time.monotonic()
if sleep_for > 0: if sleep_for > 0:

View File

@ -125,9 +125,12 @@ class PowerMeterDataHandler:
print(self.readings_to_json(readings, ensure_ascii=False)) print(self.readings_to_json(readings, ensure_ascii=False))
def open_csv_logger(self, file_path: str) -> ReadingsCsvLogger: def open_csv_logger(self, file_path: str) -> ReadingsCsvLogger:
fieldnames = ["timestamp"] + self._flat_readings_keys() fieldnames = ["timestamp"] + self.flat_readings_keys()
return ReadingsCsvLogger(file_path=file_path, fieldnames=fieldnames) return ReadingsCsvLogger(file_path=file_path, fieldnames=fieldnames)
def flat_readings_keys(self) -> List[str]:
return self._flat_readings_keys()
@staticmethod @staticmethod
def _flat_readings_keys() -> List[str]: def _flat_readings_keys() -> List[str]:
# Keep order stable for CSV columns. # Keep order stable for CSV columns.

101
hopi/gui_plotter.py Normal file
View File

@ -0,0 +1,101 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional
@dataclass
class PlotConfig:
max_points: int = 300
class RealtimePlotter:
"""Very small matplotlib-based realtime plotter with per-series toggles."""
def __init__(self, series_names: List[str], *, config: Optional[PlotConfig] = None):
self._series_names = list(series_names)
self._config = config or PlotConfig()
self._t: List[float] = []
self._y: Dict[str, List[float]] = {name: [] for name in self._series_names}
self._plt = None
self._fig = None
self._ax = None
self._lines = {}
self._check = None
def open(self) -> None:
try:
import matplotlib.pyplot as plt
from matplotlib.widgets import CheckButtons
except Exception as exc: # pragma: no cover
raise RuntimeError(
"GUI requires matplotlib. Install it (pip install matplotlib) or run without --gui."
) from exc
self._plt = plt
self._fig, self._ax = plt.subplots()
self._fig.canvas.manager.set_window_title("Hopi Realtime Readings")
# Leave space on the right for checkboxes.
self._fig.subplots_adjust(right=0.78)
for name in self._series_names:
(line,) = self._ax.plot([], [], label=name, linewidth=1)
self._lines[name] = line
self._ax.set_xlabel("t (s)")
self._ax.set_ylabel("value")
self._ax.grid(True, which="both", linewidth=0.5)
# Checkboxes
rax = self._fig.add_axes([0.80, 0.10, 0.19, 0.85])
visibility = [True] * len(self._series_names)
self._check = CheckButtons(rax, self._series_names, visibility)
def _toggle(label: str) -> None:
line = self._lines.get(label)
if line is None:
return
line.set_visible(not line.get_visible())
self._fig.canvas.draw_idle()
self._check.on_clicked(_toggle)
# Show legend for currently visible lines only; keep it minimal.
self._ax.legend(loc="upper left", fontsize="small")
plt.show(block=False)
def is_open(self) -> bool:
if self._plt is None or self._fig is None:
return False
return bool(self._plt.fignum_exists(self._fig.number))
def update(self, t_seconds: float, readings: Dict[str, float]) -> None:
if not self.is_open() or self._ax is None:
return
self._t.append(t_seconds)
for name in self._series_names:
self._y[name].append(float(readings.get(name, 0.0)))
# Rolling window
if len(self._t) > self._config.max_points:
self._t = self._t[-self._config.max_points :]
for name in self._series_names:
self._y[name] = self._y[name][-self._config.max_points :]
for name in self._series_names:
line = self._lines.get(name)
if line is not None:
line.set_data(self._t, self._y[name])
self._ax.relim()
self._ax.autoscale_view()
def process_events(self) -> None:
if self._plt is None:
return
# Allow the GUI event loop to run.
self._plt.pause(0.001)

View File

@ -1 +1,2 @@
pyserial>=3.5 pyserial>=3.5
matplotlib>=3.5