Add continuous material polling runner
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Compose the configured live material polling application."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from production_analytics.calculations.config import MaterialCalculationConfig, finite_number
|
||||
from production_analytics.enlyze.exploration import (
|
||||
ConfigurationError,
|
||||
ExplorationClient,
|
||||
ExplorationSettings,
|
||||
load_secret_file,
|
||||
)
|
||||
from production_analytics.enlyze.gateway import EnlyzeApiGateway
|
||||
from production_analytics.service.material_polling import (
|
||||
MaterialPollingService,
|
||||
MaterialPollingState,
|
||||
)
|
||||
from production_analytics.service.material_runner import MaterialPollingRunner, MaterialStateError
|
||||
from production_analytics.service.material_state_store import JsonMaterialStateStore
|
||||
|
||||
|
||||
class _ReportingStateStore:
|
||||
def __init__(self, store: JsonMaterialStateStore) -> None:
|
||||
self.store = store
|
||||
|
||||
def load(self, machine_id: str, production_order: str) -> MaterialPollingState | None:
|
||||
try:
|
||||
return self.store.load(machine_id, production_order)
|
||||
except Exception as exc:
|
||||
raise MaterialStateError(f"state loading: {type(exc).__name__}") from exc
|
||||
|
||||
def save(
|
||||
self, machine_id: str, production_order: str, state: MaterialPollingState,
|
||||
) -> None:
|
||||
try:
|
||||
self.store.save(machine_id, production_order, state)
|
||||
except Exception as exc:
|
||||
raise MaterialStateError(f"state saving: {type(exc).__name__}") from exc
|
||||
|
||||
|
||||
def build_material_runner(
|
||||
calculation: MaterialCalculationConfig, *, poll_interval_seconds: float,
|
||||
state_directory: Path, secrets_file: Path,
|
||||
) -> MaterialPollingRunner:
|
||||
finite_number(poll_interval_seconds, "poll interval", positive=True)
|
||||
environment = dict(os.environ)
|
||||
environment.update(load_secret_file(secrets_file))
|
||||
settings = ExplorationSettings.from_environment(environment)
|
||||
parsed = urlsplit(settings.base_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username:
|
||||
raise ConfigurationError(
|
||||
"ENLYZE_BASE_URL must be an HTTP(S) server URL without credentials"
|
||||
)
|
||||
if not math.isfinite(settings.timeout_seconds):
|
||||
raise ConfigurationError("ENLYZE_HTTP_TIMEOUT_SECONDS must be finite")
|
||||
try:
|
||||
state_directory.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryFile(dir=state_directory):
|
||||
pass
|
||||
except OSError as exc:
|
||||
raise ConfigurationError(
|
||||
f"State directory startup check failed: {type(exc).__name__}"
|
||||
) from exc
|
||||
service = MaterialPollingService(
|
||||
gateway=EnlyzeApiGateway(ExplorationClient(settings)),
|
||||
state_store=_ReportingStateStore(JsonMaterialStateStore(state_directory)),
|
||||
machine_id=calculation.machine_ref,
|
||||
rate_variable_id=calculation.rate_signal_ref,
|
||||
gate_variable_id=calculation.gate_signal_ref,
|
||||
gate_threshold=calculation.gate_threshold,
|
||||
max_sample_gap_seconds=calculation.max_sample_gap_seconds,
|
||||
)
|
||||
return MaterialPollingRunner(
|
||||
service, machine_id=calculation.machine_ref, poll_interval_seconds=poll_interval_seconds,
|
||||
)
|
||||
Reference in New Issue
Block a user