Bootstrap material state across production runs
This commit is contained in:
@@ -23,36 +23,63 @@ class EnlyzeApiGateway:
|
||||
self._client = client
|
||||
|
||||
def get_open_production_run(self, machine_id: str) -> EnlyzeProductionRun | None:
|
||||
response = self._client.get(
|
||||
"/v2/production-runs",
|
||||
{"machine": machine_id},
|
||||
)
|
||||
runs = [run for run in self.get_production_runs(machine_id) if run.end is None]
|
||||
if len(runs) > 1:
|
||||
raise ValueError(
|
||||
"Invalid production-run response: multiple open Production Runs returned"
|
||||
)
|
||||
return runs[0] if runs else 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_production_runs(self, machine_id: str) -> list[EnlyzeProductionRun]:
|
||||
"""Retrieve all machine runs, following validated cursor pagination."""
|
||||
params = {"machine": machine_id}
|
||||
runs = []
|
||||
followed_cursors: set[str] = set()
|
||||
page = 1
|
||||
while True:
|
||||
response = self._client.get("/v2/production-runs", params)
|
||||
try:
|
||||
if not isinstance(response.body, dict):
|
||||
raise ValueError("body must be an object")
|
||||
items = response.body["data"]
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("data must be a list")
|
||||
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")
|
||||
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")
|
||||
start = _parse_timestamp(item["start"])
|
||||
end = _parse_timestamp(item["end"]) if item["end"] is not None else None
|
||||
if end is not None and end < start:
|
||||
raise ValueError("run end must not precede start")
|
||||
runs.append(EnlyzeProductionRun(
|
||||
uuid=item["uuid"], machine_id=item["machine"],
|
||||
product_id=item.get("product"), production_order=item["production_order"],
|
||||
start=start, end=end,
|
||||
))
|
||||
next_cursor = None
|
||||
if "metadata" in response.body:
|
||||
metadata = response.body["metadata"]
|
||||
if not isinstance(metadata, dict) or "next_cursor" not in metadata:
|
||||
raise ValueError("metadata must be an object with a next_cursor field")
|
||||
next_cursor = metadata["next_cursor"]
|
||||
if next_cursor is not None and (
|
||||
not isinstance(next_cursor, str) or not next_cursor
|
||||
):
|
||||
raise ValueError("next_cursor must be null or a non-empty string")
|
||||
if next_cursor is None:
|
||||
return runs
|
||||
if next_cursor in followed_cursors:
|
||||
raise ValueError("next_cursor has already been followed")
|
||||
followed_cursors.add(next_cursor)
|
||||
params = {**params, "cursor": next_cursor}
|
||||
page += 1
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Invalid production-run response on page {page}: {exc}") from exc
|
||||
|
||||
def get_material_samples(
|
||||
self,
|
||||
|
||||
@@ -77,7 +77,26 @@ class MaterialPollingService:
|
||||
)
|
||||
|
||||
if saved_polling_state is None:
|
||||
initial_state = None
|
||||
initial_state = MaterialIntegrationState()
|
||||
historical_runs = sorted(
|
||||
(
|
||||
previous for previous in self._gateway.get_production_runs(self._machine_id)
|
||||
if previous.production_order == run.production_order
|
||||
and previous.uuid != run.uuid
|
||||
and previous.start <= run.start
|
||||
),
|
||||
key=lambda previous: previous.start,
|
||||
)
|
||||
for previous in historical_runs:
|
||||
if previous.end is None or previous.end > run.start:
|
||||
raise ValueError("Historical production run overlaps the current open run")
|
||||
integrated = self._integrate(
|
||||
initial_state, start=previous.start, end=previous.end,
|
||||
)
|
||||
initial_state = MaterialIntegrationState(
|
||||
cumulative_consumption_kg=integrated.cumulative_consumption_kg,
|
||||
integrated_running_seconds=integrated.integrated_running_seconds,
|
||||
)
|
||||
start = run.start
|
||||
elif saved_polling_state.run_id == run.uuid:
|
||||
initial_state = saved_polling_state.integration_state
|
||||
@@ -97,31 +116,31 @@ class MaterialPollingService:
|
||||
if now < start:
|
||||
return None
|
||||
|
||||
integrator = MaterialConsumptionIntegrator(
|
||||
self._config,
|
||||
initial_state=initial_state,
|
||||
)
|
||||
|
||||
samples = self._gateway.get_material_samples(
|
||||
machine_id=self._machine_id,
|
||||
rate_variable_id=self._rate_variable_id,
|
||||
gate_variable_id=self._gate_variable_id,
|
||||
start=start,
|
||||
end=now,
|
||||
)
|
||||
|
||||
integrator.process_many(samples)
|
||||
state = self._integrate(initial_state, start=start, end=now)
|
||||
|
||||
self._state_store.save(
|
||||
self._machine_id,
|
||||
run.production_order,
|
||||
MaterialPollingState(
|
||||
run_id=run.uuid,
|
||||
integration_state=integrator.state,
|
||||
integration_state=state,
|
||||
),
|
||||
)
|
||||
|
||||
return MaterialPollResult(
|
||||
run=run,
|
||||
state=integrator.state,
|
||||
state=state,
|
||||
)
|
||||
|
||||
def _integrate(
|
||||
self, initial_state: MaterialIntegrationState, *, start: datetime, end: datetime,
|
||||
) -> MaterialIntegrationState:
|
||||
integrator = MaterialConsumptionIntegrator(self._config, initial_state=initial_state)
|
||||
samples = self._gateway.get_material_samples(
|
||||
machine_id=self._machine_id,
|
||||
rate_variable_id=self._rate_variable_id,
|
||||
gate_variable_id=self._gate_variable_id,
|
||||
start=start,
|
||||
end=end,
|
||||
)
|
||||
return integrator.process_many(samples)
|
||||
|
||||
Reference in New Issue
Block a user