Add continuous material polling runner
This commit is contained in:
@@ -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()))
|
||||
Reference in New Issue
Block a user