"""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)