126 lines
5.3 KiB
Python
126 lines
5.3 KiB
Python
"""Verified ENLYZE adapter for production-run and timeseries access."""
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from math import isfinite
|
|
|
|
from production_analytics.calculations.material_consumption import MaterialSample
|
|
from production_analytics.enlyze.exploration import ExplorationClient
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class EnlyzeProductionRun:
|
|
uuid: str
|
|
machine_id: str
|
|
product_id: str | None
|
|
production_order: str
|
|
start: datetime
|
|
end: datetime | None
|
|
|
|
|
|
class EnlyzeApiGateway:
|
|
def __init__(self, client: ExplorationClient) -> None:
|
|
self._client = client
|
|
|
|
def get_open_production_run(self, machine_id: str) -> EnlyzeProductionRun | None:
|
|
response = self._client.get(
|
|
"/v2/production-runs",
|
|
{"machine": machine_id},
|
|
)
|
|
|
|
try:
|
|
items = response.body["data"]
|
|
if not isinstance(items, list):
|
|
raise ValueError("data must be a list")
|
|
runs = []
|
|
for item in items:
|
|
if not isinstance(item, dict) or "end" not in item:
|
|
raise ValueError("run must be an object with an end field")
|
|
if item["end"] is not None:
|
|
continue
|
|
for key in ("uuid", "machine", "production_order"):
|
|
if not isinstance(item[key], str) or not item[key]:
|
|
raise ValueError(f"{key} must be a non-empty string")
|
|
if item["machine"] != machine_id:
|
|
raise ValueError("run machine does not match requested machine")
|
|
runs.append(EnlyzeProductionRun(
|
|
uuid=item["uuid"], machine_id=item["machine"],
|
|
product_id=item.get("product"), production_order=item["production_order"],
|
|
start=_parse_timestamp(item["start"]), end=None,
|
|
))
|
|
if len(runs) > 1:
|
|
raise ValueError("multiple open Production Runs returned")
|
|
return runs[0] if runs else None
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise ValueError(f"Invalid production-run response: {exc}") from exc
|
|
|
|
def get_material_samples(
|
|
self,
|
|
*,
|
|
machine_id: str,
|
|
rate_variable_id: str,
|
|
gate_variable_id: str,
|
|
start: datetime,
|
|
end: datetime,
|
|
) -> list[MaterialSample]:
|
|
for name, value in (("start", start), ("end", end)):
|
|
if value.tzinfo is None or value.utcoffset() is None:
|
|
raise ValueError(f"{name} must be timezone-aware")
|
|
if end < start:
|
|
raise ValueError("end must not precede start")
|
|
response = self._client.post_json(
|
|
"/v2/timeseries",
|
|
{
|
|
"machine": machine_id,
|
|
"start": start.astimezone(UTC).isoformat(),
|
|
"end": end.astimezone(UTC).isoformat(),
|
|
"variables": [
|
|
{"uuid": rate_variable_id},
|
|
{"uuid": gate_variable_id},
|
|
],
|
|
},
|
|
)
|
|
|
|
try:
|
|
data = response.body["data"]
|
|
columns = data["columns"]
|
|
if not isinstance(columns, list):
|
|
raise ValueError("columns must be a list")
|
|
for required in ("time", rate_variable_id, gate_variable_id):
|
|
if columns.count(required) != 1:
|
|
raise ValueError(f"required column {required!r} must occur exactly once")
|
|
time_index = columns.index("time")
|
|
rate_index = columns.index(rate_variable_id)
|
|
gate_index = columns.index(gate_variable_id)
|
|
if not isinstance(data["records"], list):
|
|
raise ValueError("records must be a list")
|
|
samples = []
|
|
for index, record in enumerate(data["records"]):
|
|
try:
|
|
if not isinstance(record, list) or len(record) != len(columns):
|
|
raise ValueError("record must match columns")
|
|
rate, gate = record[rate_index], record[gate_index]
|
|
for value in (rate, gate):
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
raise ValueError("rate and gate must be numeric")
|
|
if not isfinite(value):
|
|
raise ValueError("rate and gate must be finite")
|
|
samples.append(MaterialSample(
|
|
timestamp=_parse_timestamp(record[time_index]),
|
|
material_rate_kg_per_hour=float(rate), gate_value=float(gate),
|
|
))
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"malformed record {index}: {exc}") from exc
|
|
return samples
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise ValueError(f"Invalid timeseries response: {exc}") from exc
|
|
|
|
|
|
def _parse_timestamp(value: str) -> datetime:
|
|
if not isinstance(value, str):
|
|
raise ValueError("timestamp must be an ISO string")
|
|
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
if timestamp.tzinfo is None or timestamp.utcoffset() is None:
|
|
raise ValueError("timestamp must be timezone-aware")
|
|
return timestamp.astimezone(UTC)
|