Add machine-aware maximum product length
This commit is contained in:
@@ -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": [],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user