diff --git a/src/production_analytics/enlyze/gateway.py b/src/production_analytics/enlyze/gateway.py new file mode 100644 index 0000000..d20bcfe --- /dev/null +++ b/src/production_analytics/enlyze/gateway.py @@ -0,0 +1,89 @@ +"""Verified ENLYZE adapter for production-run and timeseries access.""" + +from dataclasses import dataclass +from datetime import UTC, datetime + +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}, + ) + + 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 + + def get_material_samples( + self, + *, + machine_id: str, + rate_variable_id: str, + gate_variable_id: str, + start: datetime, + end: datetime, + ) -> list[MaterialSample]: + 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}, + ], + }, + ) + + 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"] + ] + + +def _parse_timestamp(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) diff --git a/tests/test_enlyze_gateway.py b/tests/test_enlyze_gateway.py new file mode 100644 index 0000000..f96fe25 --- /dev/null +++ b/tests/test_enlyze_gateway.py @@ -0,0 +1,133 @@ +from datetime import UTC, datetime +from unittest.mock import Mock + +from production_analytics.enlyze.gateway import EnlyzeApiGateway + + +def test_get_open_production_run_returns_current_run() -> None: + client = Mock() + client.get.return_value.body = { + "data": [ + { + "uuid": "closed-run", + "machine": "machine-1", + "product": "product-a", + "production_order": "ORDER-1", + "start": "2026-09-03T04:00:00Z", + "end": "2026-09-03T05:00:00Z", + }, + { + "uuid": "open-run", + "machine": "machine-1", + "product": "product-b", + "production_order": "ORDER-2", + "start": "2026-09-03T05:07:47Z", + "end": None, + }, + ] + } + + gateway = EnlyzeApiGateway(client) + + run = gateway.get_open_production_run("machine-1") + + assert run is not None + assert run.uuid == "open-run" + assert run.machine_id == "machine-1" + assert run.product_id == "product-b" + assert run.production_order == "ORDER-2" + assert run.start == datetime(2026, 9, 3, 5, 7, 47, tzinfo=UTC) + assert run.end is None + + client.get.assert_called_once_with( + "/v2/production-runs", + {"machine": "machine-1"}, + ) + + +def test_get_open_production_run_returns_none_without_open_run() -> None: + client = Mock() + client.get.return_value.body = { + "data": [ + { + "uuid": "closed-run", + "machine": "machine-1", + "product": "product-a", + "production_order": "ORDER-1", + "start": "2026-09-03T04:00:00Z", + "end": "2026-09-03T05:00:00Z", + } + ] + } + + gateway = EnlyzeApiGateway(client) + + assert gateway.get_open_production_run("machine-1") is None + + +def test_get_material_samples_requests_rate_and_gate_together() -> None: + client = Mock() + client.post_json.return_value.body = { + "data": { + "columns": ["time", "rate-variable", "gate-variable"], + "records": [ + ["2026-09-03T05:09:50Z", 1028.5, 2.1], + ["2026-09-03T05:10:00Z", 1029.0, 2.2], + ], + } + } + + gateway = EnlyzeApiGateway(client) + + samples = gateway.get_material_samples( + machine_id="machine-1", + rate_variable_id="rate-variable", + gate_variable_id="gate-variable", + start=datetime(2026, 9, 3, 5, 9, 50, tzinfo=UTC), + end=datetime(2026, 9, 3, 5, 10, 10, tzinfo=UTC), + ) + + assert len(samples) == 2 + assert samples[0].timestamp == datetime(2026, 9, 3, 5, 9, 50, tzinfo=UTC) + assert samples[0].material_rate_kg_per_hour == 1028.5 + assert samples[0].gate_value == 2.1 + assert samples[1].material_rate_kg_per_hour == 1029.0 + assert samples[1].gate_value == 2.2 + + client.post_json.assert_called_once_with( + "/v2/timeseries", + { + "machine": "machine-1", + "start": "2026-09-03T05:09:50+00:00", + "end": "2026-09-03T05:10:10+00:00", + "variables": [ + {"uuid": "rate-variable"}, + {"uuid": "gate-variable"}, + ], + }, + ) + + +def test_get_material_samples_uses_column_names_not_fixed_positions() -> None: + client = Mock() + client.post_json.return_value.body = { + "data": { + "columns": ["time", "gate-variable", "rate-variable"], + "records": [ + ["2026-09-03T05:09:50Z", 2.1, 1028.5], + ], + } + } + + gateway = EnlyzeApiGateway(client) + + samples = gateway.get_material_samples( + machine_id="machine-1", + rate_variable_id="rate-variable", + gate_variable_id="gate-variable", + start=datetime(2026, 9, 3, 5, 9, 50, tzinfo=UTC), + end=datetime(2026, 9, 3, 5, 10, 0, tzinfo=UTC), + ) + + assert samples[0].material_rate_kg_per_hour == 1028.5 + assert samples[0].gate_value == 2.1