92 lines
3.6 KiB
Python
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
|