Add machine-aware maximum product length

This commit is contained in:
2026-09-26 12:25:04 +02:00
parent 3e13d24405
commit 90c28fb2f5
11 changed files with 765 additions and 5 deletions
+316
View File
@@ -10,6 +10,13 @@ from typing import Any
import yaml
from roll_calculation import (
calculate_material_length_for_weight,
calculate_material_weight,
calculate_product_length,
calculate_roll,
)
MACHINE_CONFIG_FILE = Path(__file__).parent / "config" / "machines.yaml"
SCHEMA_VERSION = 1
@@ -20,6 +27,8 @@ CONSTRAINT_KEYS = {
"product_width_m",
"line_speed_m_per_min",
}
MAX_LENGTH_TIE_REL_TOLERANCE = 1e-12
MAX_LENGTH_TIE_ABS_TOLERANCE_M = 1e-9
class MachineConfigurationError(ValueError):
@@ -400,3 +409,310 @@ def check_production_feasibility(
"failed_constraints": failed,
"missing_required_inputs": missing,
}
def _machine_max_input_error(field: str, value: Any, *, allow_zero: bool = False) -> dict[str, str] | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return {"field": field, "message": "must be a number"}
if not math.isfinite(float(value)) or (float(value) < 0 if allow_zero else float(value) <= 0):
return {
"field": field,
"message": "must be zero or greater" if allow_zero else "must be greater than zero",
}
return None
def _maximum_limit_state(value: NumericRange | None) -> str:
if value is None or value.maximum is None:
return "not_configured"
return "configured"
def _governing_maximum(
candidates: tuple[tuple[str, float], ...],
) -> tuple[float, list[str]]:
"""Choose all co-governing limits with a fixed, calculation-only tolerance."""
maximum = min(value for _, value in candidates)
governing = [
name
for name, value in candidates
if math.isclose(
value,
maximum,
rel_tol=MAX_LENGTH_TIE_REL_TOLERANCE,
abs_tol=MAX_LENGTH_TIE_ABS_TOLERANCE_M,
)
]
return maximum, governing
def _verification_boundary_value(value: float | None, limits: NumericRange | None) -> float | None:
"""Normalize only inverse-calculation floating-point noise at a hard bound."""
if value is None or limits is None:
return value
if (
limits.minimum is not None
and value < limits.minimum
and math.isclose(
value,
limits.minimum,
rel_tol=MAX_LENGTH_TIE_REL_TOLERANCE,
abs_tol=MAX_LENGTH_TIE_ABS_TOLERANCE_M,
)
):
return limits.minimum
if (
limits.maximum is not None
and value > limits.maximum
and math.isclose(
value,
limits.maximum,
rel_tol=MAX_LENGTH_TIE_REL_TOLERANCE,
abs_tol=MAX_LENGTH_TIE_ABS_TOLERANCE_M,
)
):
return limits.maximum
return value
def calculate_machine_max_product_length(
*,
machine: str,
core_diameter_mm: Any,
thickness_mm: Any,
thickness_stddev_mm: Any = None,
product_width_m: Any = None,
area_weight_g_m2: Any = None,
repository: MachineRepository | None = None,
) -> dict[str, Any]:
"""Calculate deterministic machine-limited product lengths from shared domains.
Diameter limits are delegated to ``calculate_product_length`` and material
weight limits to ``calculate_material_length_for_weight``. This function
only composes configured machine constraints; it contains no roll formula.
"""
resolved = (repository or MachineRepository.load()).resolve(machine)
if resolved["status"] != "resolved":
return {key: value for key, value in resolved.items() if key != "resolved_machine"}
invalid = []
for field, value in (("core_diameter_mm", core_diameter_mm), ("thickness_mm", thickness_mm)):
error = _machine_max_input_error(field, value)
if error:
invalid.append(error)
if thickness_stddev_mm is None:
thickness_stddev = 0.0
else:
error = _machine_max_input_error(
"thickness_stddev_mm", thickness_stddev_mm, allow_zero=True
)
if error:
invalid.append(error)
thickness_stddev = 0.0
else:
thickness_stddev = float(thickness_stddev_mm)
for field, value in (("product_width_m", product_width_m), ("area_weight_g_m2", area_weight_g_m2)):
if value is not None:
error = _machine_max_input_error(field, value)
if error:
invalid.append(error)
if invalid:
return {"status": "invalid_parameter", "invalid": invalid}
core = float(core_diameter_mm)
thickness = float(thickness_mm)
width = float(product_width_m) if product_width_m is not None else None
area_weight = float(area_weight_g_m2) if area_weight_g_m2 is not None else None
machine_model: Machine = resolved["resolved_machine"]
constraints = machine_model.constraints
compatibility_checks = [
_core_check(core, constraints.allowed_core_diameters_mm),
_range_check("product_width_m", width, constraints.product_width_m, "m"),
]
base = {
"machine": resolved["machine"],
"resolution": {
"matched_by": resolved["matched_by"],
"matched_value": resolved["matched_value"],
},
"weight_scope": "material_only",
"inputs": {
"core_diameter_mm": core,
"thickness_mm": thickness,
"thickness_stddev_mm": thickness_stddev,
"product_width_m": width,
"area_weight_g_m2": area_weight,
},
"compatibility_checks": compatibility_checks,
"units": {"length_m": "m", "diameter_mm": "mm", "weight_kg": "kg"},
}
failed_compatibility = [
check["constraint"] for check in compatibility_checks if check["state"] == "failed"
]
if failed_compatibility:
return {
"status": "incompatible",
**base,
"failed_compatibility_constraints": failed_compatibility,
"production_maximums": None,
"missing_required_inputs": [],
}
missing = []
if constraints.product_width_m is not None and width is None:
missing.append("product_width_m")
if constraints.roll_weight_kg is not None:
if width is None and "product_width_m" not in missing:
missing.append("product_width_m")
if area_weight is None:
missing.append("area_weight_g_m2")
diameter_limits: dict[str, float | None] = {
"nominal_diameter_length_m": None,
"conservative_diameter_length_m": None,
}
if constraints.roll_diameter_mm is not None and constraints.roll_diameter_mm.maximum is not None:
diameter_result = calculate_product_length(
target_roll_diameter_mm=constraints.roll_diameter_mm.maximum,
core_diameter_mm=core,
thickness_mm=thickness,
thickness_stddev_mm=thickness_stddev,
)
if diameter_result["status"] != "success":
return {**base, **diameter_result, "production_maximums": None}
calculation = diameter_result["calculation"]
diameter_limits = {
"nominal_diameter_length_m": calculation["average_product_length_m"],
"conservative_diameter_length_m": calculation["minimum_product_length_m"],
}
weight_limit: float | None = None
if constraints.roll_weight_kg is not None and constraints.roll_weight_kg.maximum is not None and not missing:
weight_result = calculate_material_length_for_weight(
target_material_weight_kg=constraints.roll_weight_kg.maximum,
width_m=width,
area_weight_g_m2=area_weight,
)
if weight_result["status"] != "success":
return {**base, **weight_result, "production_maximums": None}
weight_limit = weight_result["material_length_m"]
limits = {
**diameter_limits,
"material_weight_length_m": weight_limit,
"roll_diameter_maximum_state": _maximum_limit_state(constraints.roll_diameter_mm),
"material_weight_maximum_state": _maximum_limit_state(constraints.roll_weight_kg),
}
if missing:
partial_limits = {
key: value for key, value in diameter_limits.items() if value is not None
}
return {
"status": "needs_clarification",
**base,
"limits": limits,
"partial_limits": partial_limits or None,
"production_maximums": None,
"missing_required_inputs": missing,
}
nominal_candidates: list[tuple[str, float]] = []
conservative_candidates: list[tuple[str, float]] = []
if diameter_limits["nominal_diameter_length_m"] is not None:
nominal_candidates.append(("roll_diameter_nominal", diameter_limits["nominal_diameter_length_m"]))
conservative_candidates.append(("roll_diameter_conservative", diameter_limits["conservative_diameter_length_m"]))
if weight_limit is not None:
nominal_candidates.append(("material_weight", weight_limit))
conservative_candidates.append(("material_weight", weight_limit))
if not nominal_candidates:
return {
"status": "no_maximum_length_constraint",
**base,
"limits": limits,
"production_maximums": None,
"missing_required_inputs": [],
}
nominal_length, nominal_governing = _governing_maximum(tuple(nominal_candidates))
conservative_length, conservative_governing = _governing_maximum(
tuple(conservative_candidates)
)
production_maximums = {
"nominal_length_m": nominal_length,
"nominal_governing_constraints": nominal_governing,
"conservative_length_m": conservative_length,
"conservative_governing_constraints": conservative_governing,
}
material_weights = None
if width is not None and area_weight is not None:
nominal_weight = calculate_material_weight(
roll_length_m=nominal_length, width_m=width, area_weight_g_m2=area_weight
)
conservative_weight = calculate_material_weight(
roll_length_m=conservative_length, width_m=width, area_weight_g_m2=area_weight
)
material_weights = {
"at_nominal_production_maximum": nominal_weight["material_weight_kg"],
"at_conservative_production_maximum": conservative_weight["material_weight_kg"],
}
final_feasibility = {}
for label, length in (("nominal", nominal_length), ("conservative", conservative_length)):
roll_result = calculate_roll({
"roll_length_m": length,
"core_diameter_mm": core,
"thickness_mm": thickness,
"thickness_stddev_mm": thickness_stddev,
"width_m": width,
"area_weight_g_m2": area_weight,
"include_roll_weight": constraints.roll_weight_kg is not None,
})
if roll_result["status"] != "success":
return {
"status": "incompatible",
**base,
"limits": limits,
"production_maximums": None,
"final_feasibility": {label: roll_result},
"missing_required_inputs": [],
}
calculated = roll_result["calculation"]
feasibility = check_production_feasibility({
"machine": machine_model.id,
"roll_weight_kg": _verification_boundary_value(
calculated["roll_weight_kg"], constraints.roll_weight_kg
),
"average_diameter_mm": _verification_boundary_value(
calculated["average_diameter_mm"], constraints.roll_diameter_mm
),
"maximum_diameter_mm": _verification_boundary_value(
calculated["maximum_diameter_mm"], constraints.roll_diameter_mm
),
"core_diameter_mm": core,
"product_width_m": width,
}, repository=repository)
final_feasibility[label] = feasibility
if feasibility.get("feasible") is not True:
return {
"status": "incompatible",
**base,
"limits": limits,
"production_maximums": None,
"final_feasibility": final_feasibility,
"missing_required_inputs": [],
}
return {
"status": "success",
**base,
"limits": limits,
"production_maximums": production_maximums,
"material_weights_kg": material_weights,
"final_feasibility": final_feasibility,
"warnings": {
label: value["warning_constraints"]
for label, value in final_feasibility.items()
if value["warning_constraints"]
},
"missing_required_inputs": [],
}