add charts gui
This commit is contained in:
parent
814c41098f
commit
5231f262e9
23
Hopi.py
23
Hopi.py
|
|
@ -3,6 +3,7 @@ import time
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from hopi.data_handler import PowerMeterDataHandler
|
||||
from hopi.gui_plotter import PlotConfig, RealtimePlotter
|
||||
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("--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("--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
|
||||
|
||||
|
||||
|
|
@ -38,10 +41,21 @@ def main() -> None:
|
|||
if args.interval <= args.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:
|
||||
try:
|
||||
while True:
|
||||
if plotter is not None and not plotter.is_open():
|
||||
return
|
||||
|
||||
response = client.read_holding_registers(
|
||||
slave_id=SLAVE_ID,
|
||||
start_register=START_REG,
|
||||
|
|
@ -53,9 +67,14 @@ def main() -> None:
|
|||
readings = handler.decode_readings(registers)
|
||||
|
||||
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)
|
||||
|
||||
if plotter is not None:
|
||||
plotter.update(time.monotonic() - t0, flat)
|
||||
plotter.process_events()
|
||||
|
||||
next_deadline += args.interval
|
||||
sleep_for = next_deadline - time.monotonic()
|
||||
if sleep_for > 0:
|
||||
|
|
|
|||
|
|
@ -125,9 +125,12 @@ class PowerMeterDataHandler:
|
|||
print(self.readings_to_json(readings, ensure_ascii=False))
|
||||
|
||||
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)
|
||||
|
||||
def flat_readings_keys(self) -> List[str]:
|
||||
return self._flat_readings_keys()
|
||||
|
||||
@staticmethod
|
||||
def _flat_readings_keys() -> List[str]:
|
||||
# Keep order stable for CSV columns.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1 +1,2 @@
|
|||
pyserial>=3.5
|
||||
matplotlib>=3.5
|
||||
|
|
|
|||
Loading…
Reference in New Issue