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