Files
tapo-pump-monitor/app/detector.py
T

92 lines
3.6 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from .storage import Storage
@dataclass(frozen=True)
class PowerSample:
sampled_at: datetime
watts: float
class CycleDetector:
"""Detect pump runs using two thresholds and consecutive-sample confirmation."""
def __init__(self, storage: Storage, start_watts: float, stop_watts: float, confirm_samples: int):
self.storage = storage
self.start_watts = start_watts
self.stop_watts = stop_watts
self.confirm_samples = confirm_samples
self.running = False
self.cycle_id: int | None = None
self.pending_start: list[PowerSample] = []
self.pending_stop: list[PowerSample] = []
self.first_observation = True
self.pending_incomplete_start = False
self.last_sample_at: datetime | None = None
def process(self, sample: PowerSample) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
self.last_sample_at = sample.sampled_at
if not self.running:
if sample.watts >= self.start_watts:
if not self.pending_start:
self.pending_incomplete_start = self.first_observation
self.pending_start.append(sample)
if len(self.pending_start) >= self.confirm_samples:
started_at = self.pending_start[0].sampled_at
self.cycle_id = self.storage.start_cycle(started_at, self.pending_incomplete_start)
self.running = True
events.append(
{
"type": "started",
"cycle_id": self.cycle_id,
"at": started_at,
"incomplete_start": self.pending_incomplete_start,
}
)
self.pending_start.clear()
self.pending_incomplete_start = False
else:
self.pending_start.clear()
self.pending_incomplete_start = False
else:
if sample.watts <= self.stop_watts:
self.pending_stop.append(sample)
if len(self.pending_stop) >= self.confirm_samples:
ended_at = self.pending_stop[0].sampled_at
cycle_id = self.cycle_id
if cycle_id is None:
raise RuntimeError("Running detector has no cycle id")
cycle = self.storage.finish_cycle(cycle_id, ended_at)
events.append({"type": "stopped", "cycle": cycle, "at": ended_at})
self.running = False
self.cycle_id = None
self.pending_stop.clear()
else:
self.pending_stop.clear()
self.first_observation = False
return events
def interrupt(self, ended_at: datetime | None = None) -> dict[str, Any] | None:
event = None
if self.running and self.cycle_id is not None:
final_time = ended_at or self.last_sample_at
if final_time is not None:
cycle = self.storage.finish_cycle(self.cycle_id, final_time, incomplete_end=True)
event = {"type": "interrupted", "cycle": cycle, "at": final_time}
self.running = False
self.cycle_id = None
self.pending_start.clear()
self.pending_stop.clear()
self.pending_incomplete_start = False
self.first_observation = True
self.last_sample_at = None
return event