Add continuous material polling runner

This commit is contained in:
2026-09-05 07:55:40 +02:00
parent efc563512f
commit 6ddb76fd03
10 changed files with 672 additions and 10 deletions
@@ -0,0 +1,110 @@
"""Strict configuration for executable material-consumption instances."""
from dataclasses import dataclass, fields
from math import isfinite
from pathlib import Path
import yaml
class CalculationConfigError(ValueError):
"""An operator-readable configuration failure, without input values."""
class _UniqueLoader(yaml.SafeLoader):
pass
def _mapping(loader, node):
result = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node)
if not isinstance(key, str) or key in result:
raise CalculationConfigError("YAML mapping keys must be unique strings")
result[key] = loader.construct_object(value_node)
return result
_UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _mapping)
def finite_number(value: object, name: str, *, positive: bool = False) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise CalculationConfigError(f"{name} must be a finite number")
try:
number = float(value)
except (OverflowError, ValueError) as exc:
raise CalculationConfigError(f"{name} must be a finite number") from exc
if not isfinite(number) or (positive and number <= 0):
requirement = "finite and greater than zero" if positive else "finite"
raise CalculationConfigError(f"{name} must be {requirement}")
return number
@dataclass(frozen=True, slots=True)
class MaterialCalculationConfig:
id: str
type: str
version: str
machine_ref: str
rate_signal_ref: str
gate_signal_ref: str
gate_threshold: float
max_sample_gap_seconds: float
output_metric: str
output_unit: str
group_by: str
def load_material_calculation(
path: str | Path, calculation_id: str | None = None,
) -> MaterialCalculationConfig:
try:
with Path(path).open(encoding="utf-8") as stream:
document = yaml.load(stream, Loader=_UniqueLoader)
except (OSError, UnicodeError) as exc:
raise CalculationConfigError("Cannot read calculation configuration file") from exc
except yaml.YAMLError as exc:
raise CalculationConfigError("Invalid YAML in calculation configuration") from exc
if not isinstance(document, dict) or set(document) != {"calculations"}:
raise CalculationConfigError("Configuration must contain only 'calculations'")
entries = document["calculations"]
if not isinstance(entries, list) or not entries:
raise CalculationConfigError("calculations must be a non-empty list")
required = {field.name for field in fields(MaterialCalculationConfig)}
instances = {}
for index, entry in enumerate(entries):
prefix = f"calculations[{index}]"
if not isinstance(entry, dict):
raise CalculationConfigError(f"{prefix} must be a mapping")
if "type" in entry and entry["type"] != "material_consumption":
raise CalculationConfigError(f"{prefix}.type: only 'material_consumption' is supported")
missing = required - entry.keys()
if missing:
raise CalculationConfigError(f"{prefix}: missing fields: {', '.join(sorted(missing))}")
if entry.keys() - required:
raise CalculationConfigError(f"{prefix}: unknown fields (check spelling)")
for name in sorted(required - {"gate_threshold", "max_sample_gap_seconds"}):
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 (
("type", "material_consumption"), ("version", "1"),
("output_metric", "material_consumption"), ("output_unit", "kg"),
("group_by", "production_order"),
):
if entry[name] != expected:
raise CalculationConfigError(f"{prefix}.{name}: only {expected!r} is supported")
entry["gate_threshold"] = finite_number(entry["gate_threshold"], f"{prefix}.gate_threshold")
entry["max_sample_gap_seconds"] = finite_number(
entry["max_sample_gap_seconds"], f"{prefix}.max_sample_gap_seconds", positive=True,
)
if entry["id"] in instances:
raise CalculationConfigError("Calculation ids must be unique")
instances[entry["id"]] = MaterialCalculationConfig(**entry)
if calculation_id is not None:
if calculation_id not in instances:
raise CalculationConfigError("Requested calculation id was not found")
return instances[calculation_id]
if len(instances) != 1:
raise CalculationConfigError("Multiple calculations: specify --calculation-id")
return next(iter(instances.values()))