Add feedback-aligned material efficiency KPI
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""Feedback-aligned, machine-independent production-order material efficiency."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from math import isfinite
|
||||
from typing import Protocol
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from production_analytics.context import build_enlyze_production_order, extract_nominal_width_m
|
||||
from production_analytics.erp import CurrentWorkplaceStatus
|
||||
from production_analytics.service.postgres_material import MaterialSnapshotRepository
|
||||
|
||||
|
||||
class WorkplaceStatusReader(Protocol):
|
||||
def get_current_workplace_status(self, workplace: str) -> CurrentWorkplaceStatus | None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MaterialEfficiencySnapshot:
|
||||
workplace: str
|
||||
machine_id: str
|
||||
calculation_id: str
|
||||
erp_production_order: str
|
||||
enlyze_production_order: str
|
||||
article_number: str | None
|
||||
article_description: str | None
|
||||
nominal_width_m: float | None
|
||||
erp_feedback_timestamp: datetime
|
||||
material_snapshot_timestamp: datetime
|
||||
good_quantity_m2: float
|
||||
material_consumption_kg: float
|
||||
material_consumption_kg_per_m2: float
|
||||
material_consumption_g_per_m2: float
|
||||
|
||||
|
||||
class MaterialEfficiencyService:
|
||||
def __init__(
|
||||
self, erp: WorkplaceStatusReader, materials: MaterialSnapshotRepository, *,
|
||||
workplace: str, machine_id: str, calculation_id: str, format_template: str,
|
||||
erp_timezone: ZoneInfo | None = None,
|
||||
) -> None:
|
||||
self.erp = erp
|
||||
self.materials = materials
|
||||
self.workplace = workplace
|
||||
self.machine_id = machine_id
|
||||
self.calculation_id = calculation_id
|
||||
self.format_template = format_template
|
||||
self.erp_timezone = erp_timezone
|
||||
|
||||
def evaluate_current(self) -> MaterialEfficiencySnapshot | None:
|
||||
"""Read current ERP feedback once; no scheduling or mutable KPI history."""
|
||||
status = self.erp.get_current_workplace_status(self.workplace)
|
||||
return None if status is None else self.evaluate(status)
|
||||
|
||||
def evaluate(self, status: CurrentWorkplaceStatus) -> MaterialEfficiencySnapshot | None:
|
||||
"""Evaluate supplied feedback; missing/invalid numeric inputs yield None.
|
||||
|
||||
Configuration, timestamp and adapter contract errors raise ValueError.
|
||||
Database failures propagate, rather than being treated as missing data.
|
||||
"""
|
||||
if status.workplace != self.workplace:
|
||||
raise ValueError("ERP workplace does not match configured workplace")
|
||||
order = build_enlyze_production_order(status.production_order, self.format_template)
|
||||
quantity = status.good_quantity_m2
|
||||
if quantity is None or not isfinite(quantity) or quantity <= 0:
|
||||
return None
|
||||
cutoff = _feedback_instant(status.feedback_timestamp, self.erp_timezone)
|
||||
material = self.materials.latest_at_or_before(
|
||||
calculation_id=self.calculation_id, machine_id=self.machine_id,
|
||||
production_order=order, timestamp=cutoff,
|
||||
)
|
||||
if material is None:
|
||||
return None
|
||||
if (
|
||||
material.calculation_id != self.calculation_id
|
||||
or material.machine_id != self.machine_id
|
||||
or material.production_order != order
|
||||
or material.timestamp.utcoffset() is None
|
||||
or material.timestamp > cutoff
|
||||
):
|
||||
raise ValueError("Material repository returned an unaligned snapshot")
|
||||
consumption = material.consumption_kg
|
||||
if not isfinite(consumption) or consumption < 0:
|
||||
return None
|
||||
kg_per_m2 = consumption / quantity
|
||||
g_per_m2 = kg_per_m2 * 1000
|
||||
if not isfinite(kg_per_m2) or not isfinite(g_per_m2):
|
||||
return None
|
||||
return MaterialEfficiencySnapshot(
|
||||
workplace=status.workplace, machine_id=self.machine_id,
|
||||
calculation_id=self.calculation_id, erp_production_order=status.production_order,
|
||||
enlyze_production_order=order, article_number=status.article_number,
|
||||
article_description=status.article_description,
|
||||
nominal_width_m=extract_nominal_width_m(status.article_description),
|
||||
erp_feedback_timestamp=status.feedback_timestamp,
|
||||
material_snapshot_timestamp=material.timestamp,
|
||||
good_quantity_m2=quantity, material_consumption_kg=consumption,
|
||||
material_consumption_kg_per_m2=kg_per_m2,
|
||||
material_consumption_g_per_m2=g_per_m2,
|
||||
)
|
||||
|
||||
|
||||
def _feedback_instant(timestamp: datetime, timezone: ZoneInfo | None) -> datetime:
|
||||
if timestamp.utcoffset() is not None:
|
||||
return timestamp.astimezone(UTC)
|
||||
if timezone is None:
|
||||
raise ValueError("Naive ERP feedback timestamp requires explicit erp_timezone")
|
||||
# Round trips reject DST gaps; two distinct instants indicate a DST overlap.
|
||||
instants = set()
|
||||
for fold in (0, 1):
|
||||
instant = timestamp.replace(tzinfo=timezone, fold=fold).astimezone(UTC)
|
||||
if instant.astimezone(timezone).replace(tzinfo=None) == timestamp:
|
||||
instants.add(instant)
|
||||
if len(instants) != 1:
|
||||
raise ValueError("ERP feedback timestamp is ambiguous or nonexistent in erp_timezone")
|
||||
return instants.pop()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""PostgreSQL settings and derived material snapshot inserts."""
|
||||
"""PostgreSQL settings and derived material snapshot reads/writes."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
@@ -41,6 +41,54 @@ class MaterialSnapshotWriter(Protocol):
|
||||
) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MaterialConsumptionSnapshot:
|
||||
timestamp: datetime
|
||||
calculation_id: str
|
||||
machine_id: str
|
||||
production_order: str
|
||||
run_id: str
|
||||
consumption_kg: float
|
||||
|
||||
|
||||
class MaterialSnapshotRepository(Protocol):
|
||||
def latest_at_or_before(
|
||||
self, *, calculation_id: str, machine_id: str, production_order: str,
|
||||
timestamp: datetime,
|
||||
) -> MaterialConsumptionSnapshot | None: ...
|
||||
|
||||
|
||||
class PostgresMaterialSnapshotRepository:
|
||||
def __init__(self, settings: PostgresSettings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
def latest_at_or_before(
|
||||
self, *, calculation_id: str, machine_id: str, production_order: str,
|
||||
timestamp: datetime,
|
||||
) -> MaterialConsumptionSnapshot | None:
|
||||
"""Read one exact-order cumulative snapshot, never later than the aware cutoff."""
|
||||
if timestamp.utcoffset() is None:
|
||||
raise ValueError("Material snapshot cutoff must be timezone-aware")
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(
|
||||
host=self.settings.host, port=self.settings.port, dbname=self.settings.dbname,
|
||||
user=self.settings.user, password=self.settings.password, connect_timeout=10,
|
||||
options='-c statement_timeout=10000',
|
||||
) as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT timestamp, calculation_id, machine_id, production_order,
|
||||
run_id, consumption_kg
|
||||
FROM material_consumption_snapshots
|
||||
WHERE calculation_id = %s AND machine_id = %s
|
||||
AND production_order = %s AND timestamp <= %s
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1""",
|
||||
(calculation_id, machine_id, production_order, timestamp),
|
||||
).fetchone()
|
||||
return None if row is None else MaterialConsumptionSnapshot(*row)
|
||||
|
||||
|
||||
class PostgresMaterialSnapshotWriter:
|
||||
def __init__(self, settings: PostgresSettings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
Reference in New Issue
Block a user