Add feedback-aligned material efficiency KPI

This commit is contained in:
2026-09-06 05:45:11 +02:00
parent 0856629e77
commit c411581a1a
5 changed files with 524 additions and 4 deletions
@@ -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