Harden live material polling state

This commit is contained in:
2026-09-05 05:50:01 +02:00
parent 98a92c3739
commit efc563512f
9 changed files with 513 additions and 40 deletions
+70 -34
View File
@@ -2,6 +2,7 @@
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
@@ -27,24 +28,31 @@ class EnlyzeApiGateway:
{"machine": machine_id},
)
for item in response.body.get("data", []):
if item.get("end") is not None:
continue
return EnlyzeProductionRun(
uuid=str(item["uuid"]),
machine_id=str(item["machine"]),
product_id=(
str(item["product"])
if item.get("product") is not None
else None
),
production_order=str(item["production_order"]),
start=_parse_timestamp(item["start"]),
end=None,
)
return None
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,
@@ -55,6 +63,11 @@ class EnlyzeApiGateway:
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",
{
@@ -68,22 +81,45 @@ class EnlyzeApiGateway:
},
)
data = response.body["data"]
columns = data["columns"]
time_index = columns.index("time")
rate_index = columns.index(rate_variable_id)
gate_index = columns.index(gate_variable_id)
return [
MaterialSample(
timestamp=_parse_timestamp(record[time_index]),
material_rate_kg_per_hour=float(record[rate_index]),
gate_value=float(record[gate_index]),
)
for record in data["records"]
]
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:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
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)