72 lines
3.1 KiB
Python
72 lines
3.1 KiB
Python
"""Sequential foreground polling with injectable time and output."""
|
|
|
|
import sys
|
|
import time
|
|
from collections.abc import Callable
|
|
from datetime import UTC, datetime
|
|
from typing import TextIO
|
|
|
|
from production_analytics.calculations.config import finite_number
|
|
from production_analytics.enlyze.exploration import ConfigurationError, ExplorationError
|
|
from production_analytics.service.material_polling import MaterialPollingService
|
|
|
|
|
|
class MaterialStateError(RuntimeError):
|
|
"""State operation failed; message contains only the operation and error class."""
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
return datetime.now(UTC)
|
|
|
|
|
|
class MaterialPollingRunner:
|
|
def __init__(
|
|
self, service: MaterialPollingService, *, machine_id: str,
|
|
poll_interval_seconds: float, clock: Callable[[], datetime] = utc_now,
|
|
sleep: Callable[[float], None] = time.sleep,
|
|
stdout: TextIO | None = None, stderr: TextIO | None = None,
|
|
) -> None:
|
|
self.service = service
|
|
self.machine_id = machine_id
|
|
self.interval = finite_number(poll_interval_seconds, "poll interval", positive=True)
|
|
self.clock = clock
|
|
self.sleep = sleep
|
|
self.stdout = stdout if stdout is not None else sys.stdout
|
|
self.stderr = stderr if stderr is not None else sys.stderr
|
|
|
|
def run(self) -> None:
|
|
try:
|
|
while True:
|
|
now = self.clock()
|
|
if now.tzinfo is None or now.utcoffset() is None:
|
|
raise ConfigurationError("Runner clock must return timezone-aware timestamps")
|
|
now = now.astimezone(UTC)
|
|
prefix = f"{now.isoformat()} machine={self.machine_id!r}"
|
|
try:
|
|
result = self.service.poll_once(now=now)
|
|
except ConfigurationError:
|
|
raise
|
|
except Exception as exc:
|
|
if isinstance(exc, MaterialStateError):
|
|
detail = str(exc)
|
|
elif isinstance(exc, ExplorationError):
|
|
detail = f"ENLYZE/gateway: {type(exc).__name__}"
|
|
else:
|
|
detail = f"gateway/poll cycle: {type(exc).__name__}"
|
|
# Do not echo arbitrary exception messages or response bodies.
|
|
print(f"{prefix} error {detail}", file=self.stderr, flush=True)
|
|
else:
|
|
if result is None:
|
|
detail = "no open Production Run / no eligible polling window"
|
|
else:
|
|
detail = (
|
|
f"production_order={result.run.production_order!r} "
|
|
f"consumption_kg={result.state.cumulative_consumption_kg:.9f} "
|
|
f"running_seconds={result.state.integrated_running_seconds:.3f}"
|
|
)
|
|
print(f"{prefix} {detail}", file=self.stdout, flush=True)
|
|
# Fixed delay after completion, including errors; never catch up or overlap.
|
|
self.sleep(self.interval)
|
|
except KeyboardInterrupt:
|
|
print("Material polling stopped", file=self.stdout, flush=True)
|