Use rotational discharge for Bento consumption

This commit is contained in:
2026-09-07 07:59:48 +02:00
parent be8d76507a
commit 0210ced9e3
10 changed files with 400 additions and 92 deletions
+33 -11
View File
@@ -48,7 +48,7 @@ class MaterialCalculationConfig:
version: str
machine_ref: str
rate_signal_ref: str
gate_signal_ref: str
gate_signal_ref: str | None
gate_threshold: float
max_sample_gap_seconds: float
output_metric: str
@@ -56,6 +56,8 @@ class MaterialCalculationConfig:
group_by: str
source_mode: str = "direct_mass_rate"
application_signal_refs: tuple[str, ...] = ()
rotational_speed_signal_refs: tuple[str, ...] = ()
specific_discharge_kg_per_rev_m: float | None = None
erp_workplace: str = ""
production_order_format: str = ""
@@ -86,7 +88,11 @@ def load_material_calculation(
if "type" in entry and entry["type"] != "material_consumption":
raise CalculationConfigError(f"{prefix}.type: only 'material_consumption' is supported")
area = entry.get("source_mode", "direct_mass_rate") == "area_application"
if area:
rotational = entry.get("source_mode") == "rotational_discharge"
if rotational:
entry.setdefault("gate_signal_ref", None)
entry.setdefault("gate_threshold", 0.0)
if area or rotational:
entry.setdefault("rate_signal_ref", "unused")
missing = required - entry.keys()
if missing:
@@ -94,6 +100,8 @@ def load_material_calculation(
if entry.keys() - allowed:
raise CalculationConfigError(f"{prefix}: unknown fields (check spelling)")
for name in sorted(required - {"gate_threshold", "max_sample_gap_seconds"}):
if name == "gate_signal_ref" and rotational and entry[name] is None:
continue
if not isinstance(entry[name], str) or not entry[name].strip():
raise CalculationConfigError(f"{prefix}.{name} must be a non-empty string")
for name, expected in (
@@ -108,31 +116,45 @@ def load_material_calculation(
entry["max_sample_gap_seconds"], f"{prefix}.max_sample_gap_seconds", positive=True,
)
mode = entry.get("source_mode", "direct_mass_rate")
if not isinstance(mode, str) or mode not in {"direct_mass_rate", "area_application"}:
if not isinstance(mode, str) or mode not in {
"direct_mass_rate", "area_application", "rotational_discharge",
}:
raise CalculationConfigError("Unsupported material source_mode")
if area:
refs = entry.get("application_signal_refs")
if area or rotational:
refs_name = "rotational_speed_signal_refs" if rotational else "application_signal_refs"
other = "application_signal_refs" if rotational else "rotational_speed_signal_refs"
if other in entry:
raise CalculationConfigError(f"{mode} cannot specify {other}")
refs = entry.get(refs_name)
if (not isinstance(refs, list) or not refs
or any(not isinstance(ref, str) or not ref.strip() for ref in refs)
or len(set(refs)) != len(refs)):
raise CalculationConfigError(
"application_signal_refs must be unique signal strings"
f"{refs_name} must be unique signal strings"
)
if entry["rate_signal_ref"] != "unused":
raise CalculationConfigError("area_application cannot specify rate_signal_ref")
raise CalculationConfigError(f"{mode} cannot specify rate_signal_ref")
for name in ("erp_workplace", "production_order_format"):
if not isinstance(entry.get(name), str) or not entry[name].strip():
raise CalculationConfigError(f"area_application requires {name}")
raise CalculationConfigError(f"{mode} requires {name}")
from production_analytics.context import build_enlyze_production_order
try:
build_enlyze_production_order("0", entry["production_order_format"])
except ValueError as exc:
raise CalculationConfigError("Invalid production_order_format") from exc
entry["application_signal_refs"] = tuple(refs)
entry[refs_name] = tuple(refs)
elif any(name in entry for name in (
"application_signal_refs", "erp_workplace", "production_order_format",
"application_signal_refs", "rotational_speed_signal_refs",
"erp_workplace", "production_order_format",
)):
raise CalculationConfigError("Area context fields require area_application")
raise CalculationConfigError("Width context fields require a width-based source mode")
if rotational:
entry["specific_discharge_kg_per_rev_m"] = finite_number(
entry.get("specific_discharge_kg_per_rev_m"),
"specific_discharge_kg_per_rev_m", positive=True,
)
elif "specific_discharge_kg_per_rev_m" in entry:
raise CalculationConfigError("Specific discharge requires rotational_discharge")
if entry["id"] in instances:
raise CalculationConfigError("Calculation ids must be unique")
instances[entry["id"]] = MaterialCalculationConfig(**entry)
@@ -119,3 +119,21 @@ def area_application_rate_kg_per_hour(
if not isfinite(rate):
raise ValueError("derived material rate must be finite")
return rate
def rotational_discharge_rate_kg_per_hour(
rotational_speeds_rpm: Iterable[float], nominal_width_m: float,
specific_discharge_kg_per_rev_m: float,
) -> float:
"""Convert full-width roll speeds and specific discharge to canonical kg/h."""
speeds = tuple(rotational_speeds_rpm)
if not speeds or not all(isfinite(value) for value in speeds):
raise ValueError("rotational speeds must be non-empty and finite")
if not isfinite(nominal_width_m) or nominal_width_m <= 0:
raise ValueError("nominal width must be finite and positive")
if not isfinite(specific_discharge_kg_per_rev_m) or specific_discharge_kg_per_rev_m <= 0:
raise ValueError("specific discharge must be finite and positive")
rate = sum(speeds) * nominal_width_m * specific_discharge_kg_per_rev_m * 60
if not isfinite(rate):
raise ValueError("derived material rate must be finite")
return rate
+22 -6
View File
@@ -7,6 +7,7 @@ from math import isfinite
from production_analytics.calculations.material_consumption import (
MaterialSample,
area_application_rate_kg_per_hour,
rotational_discharge_rate_kg_per_hour,
)
from production_analytics.enlyze.exploration import ExplorationClient
@@ -89,19 +90,28 @@ class EnlyzeApiGateway:
*,
machine_id: str,
rate_variable_id: str,
gate_variable_id: str,
gate_variable_id: str | None,
start: datetime,
end: datetime,
application_variable_ids: tuple[str, ...] = (),
nominal_width_m: float | None = None,
rotational_speed_variable_ids: tuple[str, ...] = (),
specific_discharge_kg_per_rev_m: float | None = None,
) -> 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")
source_ids = application_variable_ids or (rate_variable_id,)
variable_ids = list(dict.fromkeys((*source_ids, gate_variable_id)))
if application_variable_ids and rotational_speed_variable_ids:
raise ValueError("Material source modes are mutually exclusive")
if gate_variable_id is None and not rotational_speed_variable_ids:
raise ValueError("This source requires a gate signal")
source_ids = (
rotational_speed_variable_ids or application_variable_ids or (rate_variable_id,)
)
gate_ids = (gate_variable_id,) if gate_variable_id else ()
variable_ids = list(dict.fromkeys((*source_ids, *gate_ids)))
request_body = {
"machine": machine_id,
"start": start.astimezone(UTC).isoformat(),
@@ -123,7 +133,7 @@ class EnlyzeApiGateway:
raise ValueError(f"required column {required!r} must occur exactly once")
time_index = columns.index("time")
source_indices = [columns.index(ref) for ref in source_ids]
gate_index = columns.index(gate_variable_id)
gate_index = columns.index(gate_variable_id) if gate_variable_id else None
if not isinstance(data["records"], list):
raise ValueError("records must be a list")
for index, record in enumerate(data["records"]):
@@ -131,13 +141,19 @@ class EnlyzeApiGateway:
if not isinstance(record, list) or len(record) != len(columns):
raise ValueError("record must match columns")
sources = [record[i] for i in source_indices]
gate = record[gate_index]
gate = record[gate_index] if gate_index is not None else 1.0
for value in (*sources, 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")
if application_variable_ids:
if rotational_speed_variable_ids:
if nominal_width_m is None or specific_discharge_kg_per_rev_m is None:
raise ValueError("rotational discharge requires width and factor")
rate = rotational_discharge_rate_kg_per_hour(
sources, nominal_width_m, specific_discharge_kg_per_rev_m,
)
elif application_variable_ids:
if nominal_width_m is None:
raise ValueError("area application requires nominal width")
rate = area_application_rate_kg_per_hour(sources, nominal_width_m, gate)
@@ -14,7 +14,8 @@ class ErpNominalWidthProvider:
def __call__(self, production_order: str) -> float:
status = self.gateway.get_current_workplace_status(self.workplace)
if (status is None or status.workplace != self.workplace
if (status is None
or status.workplace.strip().casefold() != self.workplace.strip().casefold()
or build_enlyze_production_order(status.production_order, self.format_template)
!= production_order):
raise ValueError("ERP context does not match the production order")
@@ -51,23 +51,30 @@ class MaterialPollingService:
state_store: MaterialStateStore,
machine_id: str,
rate_variable_id: str,
gate_variable_id: str,
gate_variable_id: str | None,
gate_threshold: float,
max_sample_gap_seconds: float,
application_variable_ids: tuple[str, ...] = (),
rotational_speed_variable_ids: tuple[str, ...] = (),
specific_discharge_kg_per_rev_m: float | None = None,
nominal_width_provider: Callable[[str], float] | None = None,
) -> None:
if application_variable_ids and rotational_speed_variable_ids:
raise ValueError("Material source modes are mutually exclusive")
self._rotational_speed_variable_ids = rotational_speed_variable_ids
self._specific_discharge = specific_discharge_kg_per_rev_m
self._application_variable_ids = application_variable_ids
self._nominal_width_provider = nominal_width_provider
if application_variable_ids and nominal_width_provider is None:
raise ValueError("area application requires a nominal width provider")
if ((application_variable_ids or rotational_speed_variable_ids)
and nominal_width_provider is None):
raise ValueError("width-based source requires a nominal width provider")
self._gateway = gateway
self._state_store = state_store
self._machine_id = machine_id
self._rate_variable_id = rate_variable_id
self._gate_variable_id = gate_variable_id
self._config = MaterialIntegratorConfig(
gate_threshold=gate_threshold,
gate_threshold=gate_threshold if gate_variable_id is not None else 0.0,
max_sample_gap_seconds=max_sample_gap_seconds,
)
@@ -79,7 +86,7 @@ class MaterialPollingService:
return None
width = (self._nominal_width_provider(run.production_order)
if self._application_variable_ids else None)
if self._application_variable_ids or self._rotational_speed_variable_ids else None)
saved_polling_state = self._state_store.load(
self._machine_id,
run.production_order,
@@ -151,6 +158,11 @@ class MaterialPollingService:
source_options = dict(
application_variable_ids=self._application_variable_ids, nominal_width_m=width,
)
if self._rotational_speed_variable_ids:
source_options = dict(
rotational_speed_variable_ids=self._rotational_speed_variable_ids,
specific_discharge_kg_per_rev_m=self._specific_discharge, nominal_width_m=width,
)
samples = self._gateway.get_material_samples(
machine_id=self._machine_id,
rate_variable_id=self._rate_variable_id,
@@ -73,7 +73,7 @@ def build_material_runner(
) from exc
postgres_settings = PostgresSettings.from_environment(environment)
source_options = {}
if calculation.source_mode == "area_application":
if calculation.source_mode in {"area_application", "rotational_discharge"}:
source_options = dict(
application_variable_ids=calculation.application_signal_refs,
nominal_width_provider=ErpNominalWidthProvider(
@@ -82,6 +82,12 @@ def build_material_runner(
format_template=calculation.production_order_format,
),
)
if calculation.source_mode == "rotational_discharge":
source_options.pop("application_variable_ids")
source_options.update(
rotational_speed_variable_ids=calculation.rotational_speed_signal_refs,
specific_discharge_kg_per_rev_m=calculation.specific_discharge_kg_per_rev_m,
)
service = MaterialPollingService(
gateway=EnlyzeApiGateway(ExplorationClient(settings)),
state_store=_ReportingStateStore(JsonMaterialStateStore(state_directory)),