Add machine-aware maximum product length
This commit is contained in:
@@ -108,6 +108,7 @@ Implemented routes:
|
||||
| `/api/calculations/roll/modify` | `POST` | Basic Auth | Applies explicit changes to structured calculation state and recalculates. |
|
||||
| `/api/calculations/transport` | `POST` | Basic Auth | Executes the shared transport-capacity analysis. |
|
||||
| `/api/calculations/production-feasibility` | `POST` | Basic Auth | Checks configured production-machine constraints. |
|
||||
| `/api/calculations/machine-max-product-length` | `POST` | Basic Auth | Calculates deterministic machine-aware maximum product lengths. |
|
||||
| `/api/reports/roll-calculation.pdf` | `POST` | Basic Auth | Recalculates a request authoritatively and returns a one-page PDF. |
|
||||
| `/api/conversations` | `POST` | Basic Auth | Creates an in-memory conversation session. |
|
||||
| `/api/conversations/<id>/messages` | `POST` | Basic Auth | Interprets one utterance and executes the deterministic workflow. |
|
||||
@@ -123,8 +124,8 @@ direct roll calculation, product-length calculation, material-weight calculation
|
||||
transport analysis, and configured production-machine feasibility.
|
||||
The available PoC tools are `get_article`, `search_articles`,
|
||||
`calculate_material_weight`, `calculate_product_length`,
|
||||
`calculate_roll_diameter`, `analyze_transport_capacity`, `get_machine`, and
|
||||
`check_production_feasibility`.
|
||||
`calculate_roll_diameter`, `analyze_transport_capacity`, `get_machine`,
|
||||
`check_production_feasibility`, and `calculate_machine_max_product_length`.
|
||||
|
||||
Install the pinned dependencies, including `mcp==1.26.0`, in the existing
|
||||
environment, then start it with:
|
||||
@@ -148,6 +149,15 @@ Tool calling semantics:
|
||||
- `calculate_material_weight` calculates material-only weight from roll length,
|
||||
width, and area weight. It requires no core diameter, does not infer one, and
|
||||
cannot return a roll diameter or a core-inclusive total weight.
|
||||
- `calculate_machine_max_product_length` composes configured machine limits
|
||||
with the shared calculation domain. Resolve an article first, then pass its
|
||||
properties unchanged. Its nominal maximum uses the nominal diameter limit;
|
||||
its conservative/no-warning maximum uses RollCalc's existing maximum-diameter
|
||||
thickness variation. Both also respect configured material-only maximum roll
|
||||
weight, and the domain returns the governing constraint(s), unrounded.
|
||||
Core and width incompatibility cannot be corrected by shortening and is
|
||||
returned structurally. No manufacturing increment, core/packaging/gross
|
||||
weight, or conditional production rule is applied in V1.1.
|
||||
- `calculate_product_length` calculates required roll length from a target outer
|
||||
diameter, core diameter, and material thickness. Its optional thickness
|
||||
stddev returns minimum/average/maximum length ranges; optional width and area
|
||||
|
||||
@@ -20,7 +20,10 @@ from flask_httpauth import HTTPBasicAuth
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from core_presets import CORE_PRESETS
|
||||
from machine_constraints import check_production_feasibility
|
||||
from machine_constraints import (
|
||||
calculate_machine_max_product_length,
|
||||
check_production_feasibility,
|
||||
)
|
||||
from conversation_service import (
|
||||
ConversationNotFoundError,
|
||||
ConversationReportNotFoundError,
|
||||
@@ -278,6 +281,49 @@ def create_production_feasibility_calculation():
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/machine-max-product-length", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_machine_max_product_length_calculation():
|
||||
"""Calculate machine-limited product length through the shared domain."""
|
||||
payload = request.get_json(silent=True)
|
||||
allowed = {
|
||||
"machine",
|
||||
"core_diameter_mm",
|
||||
"thickness_mm",
|
||||
"thickness_stddev_mm",
|
||||
"product_width_m",
|
||||
"area_weight_g_m2",
|
||||
}
|
||||
if not isinstance(payload, dict):
|
||||
result = {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{"field": "request", "message": "JSON object required"}],
|
||||
}
|
||||
else:
|
||||
unknown = sorted(set(payload) - allowed)
|
||||
if unknown:
|
||||
result = {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{
|
||||
"field": "request",
|
||||
"message": "unknown request fields: " + ", ".join(unknown),
|
||||
}],
|
||||
}
|
||||
else:
|
||||
result = calculate_machine_max_product_length(
|
||||
machine=payload.get("machine"),
|
||||
core_diameter_mm=payload.get("core_diameter_mm"),
|
||||
thickness_mm=payload.get("thickness_mm"),
|
||||
thickness_stddev_mm=payload.get("thickness_stddev_mm"),
|
||||
product_width_m=payload.get("product_width_m"),
|
||||
area_weight_g_m2=payload.get("area_weight_g_m2"),
|
||||
)
|
||||
log_access(
|
||||
auth.current_user(), "/api/calculations/machine-max-product-length", "POST"
|
||||
)
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
def _create_pdf(calculation_request):
|
||||
result = calculate_roll(calculation_request, build_info=BUILD_INFO)
|
||||
report = report_from_calculation_result(result)
|
||||
|
||||
@@ -48,6 +48,19 @@ warning. Line speed remains configured but is outside the initial roll-only
|
||||
scope. Flask and MCP are thin adapters; no machine constraints are duplicated
|
||||
in those layers or in browser code.
|
||||
|
||||
`calculate_machine_max_product_length()` composes the same domains for target
|
||||
length questions. It resolves the machine only through `MachineRepository`,
|
||||
uses `calculate_product_length()` with the configured maximum diameter, and
|
||||
maps its average length to the nominal maximum and its minimum length to the
|
||||
conservative/no-warning maximum. Material-weight maxima use the shared
|
||||
`calculate_material_length_for_weight()` inverse; final material weights use
|
||||
`calculate_material_weight()`. The domain chooses governing constraints,
|
||||
including deterministic ties, and verifies selected lengths through the
|
||||
existing direct roll and feasibility services. It never resolves articles,
|
||||
reads YAML in adapters, duplicates roll mathematics, applies a manufacturing
|
||||
increment, or implements conditional production rules. Flask and MCP expose
|
||||
thin adapters; the browser UI remains unchanged.
|
||||
|
||||
## Constrained Natural-Language Flow
|
||||
|
||||
```text
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from rollcalc_mcp_tools import (
|
||||
analyze_transport_capacity_result,
|
||||
calculate_machine_max_product_length_result,
|
||||
calculate_material_weight_result,
|
||||
calculate_product_length_result,
|
||||
calculate_roll_diameter_result,
|
||||
@@ -62,6 +63,25 @@ def create_server() -> Any:
|
||||
line_speed_m_per_min=line_speed_m_per_min,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def calculate_machine_max_product_length(
|
||||
machine: str,
|
||||
core_diameter_mm: float,
|
||||
thickness_mm: float,
|
||||
thickness_stddev_mm: float | None = None,
|
||||
product_width_m: float | None = None,
|
||||
area_weight_g_m2: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate deterministic maximum product length for a configured production machine. Use this for maximum roll/product length, how much product fits on a machine roll, or maximum length without a diameter warning. Resolve the article first with search_articles/get_article when needed, then pass its thickness, thickness standard deviation, width, and area weight exactly. Do not supply or invent machine limits, rearrange roll or weight formulas, calculate a weight-limited length, or compare candidate limits yourself: this tool returns nominal and conservative/no-warning production maxima and their governing constraints. Report incompatibility rather than substituting another core or width, and ask for returned missing inputs rather than inventing them."""
|
||||
return calculate_machine_max_product_length_result(
|
||||
machine=machine,
|
||||
core_diameter_mm=core_diameter_mm,
|
||||
thickness_mm=thickness_mm,
|
||||
thickness_stddev_mm=thickness_stddev_mm,
|
||||
product_width_m=product_width_m,
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def calculate_material_weight(
|
||||
roll_length_m: float,
|
||||
|
||||
@@ -425,6 +425,54 @@ def calculate_material_weight(
|
||||
}
|
||||
|
||||
|
||||
def calculate_material_length_for_weight(
|
||||
*,
|
||||
target_material_weight_kg: Any,
|
||||
width_m: Any,
|
||||
area_weight_g_m2: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate material length for a target material-only roll weight."""
|
||||
values = {
|
||||
"target_material_weight_kg": target_material_weight_kg,
|
||||
"width_m": width_m,
|
||||
"area_weight_g_m2": area_weight_g_m2,
|
||||
}
|
||||
invalid = []
|
||||
normalized = {}
|
||||
for field, value in values.items():
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
invalid.append({"field": field, "message": "must be a number"})
|
||||
continue
|
||||
number = float(value)
|
||||
if not math.isfinite(number) or number <= 0:
|
||||
invalid.append({"field": field, "message": "must be greater than zero"})
|
||||
continue
|
||||
normalized[field] = number
|
||||
if invalid:
|
||||
return {"status": "invalid_parameter", "invalid": invalid}
|
||||
|
||||
length_m = (
|
||||
normalized["target_material_weight_kg"]
|
||||
* 1000
|
||||
/ normalized["width_m"]
|
||||
/ normalized["area_weight_g_m2"]
|
||||
)
|
||||
if not math.isfinite(length_m) or length_m <= 0:
|
||||
return {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{
|
||||
"field": "request",
|
||||
"message": "values are outside the supported calculation range",
|
||||
}],
|
||||
}
|
||||
return {
|
||||
"status": "success",
|
||||
"weight_scope": "material_only",
|
||||
"inputs": normalized,
|
||||
"material_length_m": length_m,
|
||||
}
|
||||
|
||||
|
||||
def _material_weight_kg(
|
||||
roll_length_m: float, width_m: float, area_weight_g_m2: float
|
||||
) -> float:
|
||||
|
||||
+25
-1
@@ -4,7 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from machine_constraints import check_production_feasibility, get_machine
|
||||
from machine_constraints import (
|
||||
calculate_machine_max_product_length,
|
||||
check_production_feasibility,
|
||||
get_machine,
|
||||
)
|
||||
from roll_calculation import (
|
||||
ArticleRepository,
|
||||
calculate_material_weight,
|
||||
@@ -56,6 +60,26 @@ def check_production_feasibility_result(
|
||||
})
|
||||
|
||||
|
||||
def calculate_machine_max_product_length_result(
|
||||
*,
|
||||
machine: str,
|
||||
core_diameter_mm: float,
|
||||
thickness_mm: float,
|
||||
thickness_stddev_mm: float | None = None,
|
||||
product_width_m: float | None = None,
|
||||
area_weight_g_m2: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate machine-aware maximum product length to the shared domain."""
|
||||
return calculate_machine_max_product_length(
|
||||
machine=machine,
|
||||
core_diameter_mm=core_diameter_mm,
|
||||
thickness_mm=thickness_mm,
|
||||
thickness_stddev_mm=thickness_stddev_mm,
|
||||
product_width_m=product_width_m,
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
)
|
||||
|
||||
|
||||
def calculate_material_weight_result(
|
||||
*,
|
||||
roll_length_m: float,
|
||||
|
||||
@@ -139,6 +139,53 @@ class CalculationApiTests(unittest.TestCase):
|
||||
self.assertEqual(result["feasibility"], "feasible_with_warnings")
|
||||
self.assertEqual(result["warning_constraints"], ["roll_diameter_mm"])
|
||||
|
||||
def test_machine_maximum_length_endpoint_is_authenticated_and_structured(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={
|
||||
"machine": "K7",
|
||||
"core_diameter_mm": 133,
|
||||
"thickness_mm": 4.311611,
|
||||
"thickness_stddev_mm": 0.249999,
|
||||
"product_width_m": 6,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
result = response.get_json()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(
|
||||
result["production_maximums"]["nominal_governing_constraints"],
|
||||
["roll_diameter_nominal"],
|
||||
)
|
||||
|
||||
def test_machine_maximum_length_endpoint_preserves_domain_outcomes(self):
|
||||
incompatible = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={"machine": "K7", "core_diameter_mm": 194, "thickness_mm": 4},
|
||||
headers=self.headers,
|
||||
)
|
||||
missing = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={"machine": "K7", "core_diameter_mm": 133, "thickness_mm": 4},
|
||||
headers=self.headers,
|
||||
)
|
||||
invalid = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={"machine": "K7", "unexpected": 1},
|
||||
headers=self.headers,
|
||||
)
|
||||
missing_required = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={"machine": "K7", "core_diameter_mm": 133},
|
||||
headers=self.headers,
|
||||
)
|
||||
self.assertEqual(incompatible.get_json()["status"], "incompatible")
|
||||
self.assertEqual(missing.get_json()["status"], "needs_clarification")
|
||||
self.assertEqual(invalid.get_json()["status"], "invalid_parameter")
|
||||
self.assertEqual(missing_required.get_json()["status"], "invalid_parameter")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,8 +4,14 @@ from pathlib import Path
|
||||
|
||||
from machine_constraints import (
|
||||
MACHINE_CONFIG_FILE,
|
||||
MAX_LENGTH_TIE_ABS_TOLERANCE_M,
|
||||
Machine,
|
||||
MachineConfigurationError,
|
||||
MachineConstraints,
|
||||
MachineRepository,
|
||||
NumericRange,
|
||||
_governing_maximum,
|
||||
calculate_machine_max_product_length,
|
||||
check_production_feasibility,
|
||||
get_machine,
|
||||
)
|
||||
@@ -24,6 +30,38 @@ def request(**changes):
|
||||
return payload
|
||||
|
||||
|
||||
def machine_repository(
|
||||
*,
|
||||
diameter_maximum=1100.0,
|
||||
diameter_minimum=200.0,
|
||||
weight_maximum=700.0,
|
||||
width_limits=(4.0, 6.1),
|
||||
allowed_cores=(133.0,),
|
||||
):
|
||||
diameter = (
|
||||
NumericRange(minimum=diameter_minimum, maximum=diameter_maximum)
|
||||
if diameter_maximum is not None else None
|
||||
)
|
||||
weight = NumericRange(maximum=weight_maximum) if weight_maximum is not None else None
|
||||
width = (
|
||||
NumericRange(minimum=width_limits[0], maximum=width_limits[1])
|
||||
if width_limits is not None else None
|
||||
)
|
||||
return MachineRepository((Machine(
|
||||
id="test_machine",
|
||||
name="Test Machine",
|
||||
aliases=(),
|
||||
location="Test",
|
||||
constraints=MachineConstraints(
|
||||
roll_weight_kg=weight,
|
||||
roll_diameter_mm=diameter,
|
||||
allowed_core_diameters_mm=allowed_cores,
|
||||
product_width_m=width,
|
||||
line_speed_m_per_min=None,
|
||||
),
|
||||
),))
|
||||
|
||||
|
||||
class MachineRepositoryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = MachineRepository.load()
|
||||
@@ -163,3 +201,129 @@ class MachineFeasibilityTests(unittest.TestCase):
|
||||
self.assertEqual(check_production_feasibility({"machine": "B1", "unknown": 1})["status"], "invalid_parameter")
|
||||
self.assertEqual(check_production_feasibility({"machine": "B1", "core_weight_kg": 10})["status"], "invalid_parameter")
|
||||
self.assertEqual(self.check(machine="not a machine")["status"], "machine_not_found")
|
||||
|
||||
|
||||
class MachineMaximumProductLengthTests(unittest.TestCase):
|
||||
def calculate(self, repository=None, **changes):
|
||||
values = {
|
||||
"machine": "test_machine",
|
||||
"core_diameter_mm": 133,
|
||||
"thickness_mm": 4.311611,
|
||||
"thickness_stddev_mm": 0.249999,
|
||||
"product_width_m": 6,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
"repository": repository or machine_repository(),
|
||||
}
|
||||
values.update(changes)
|
||||
return calculate_machine_max_product_length(**values)
|
||||
|
||||
def test_characterizes_article_218500_on_k7(self):
|
||||
result = calculate_machine_max_product_length(
|
||||
machine="K7",
|
||||
core_diameter_mm=133,
|
||||
thickness_mm=4.311611,
|
||||
thickness_stddev_mm=0.249999,
|
||||
product_width_m=6,
|
||||
area_weight_g_m2=495.395833,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertAlmostEqual(result["limits"]["nominal_diameter_length_m"], 217.1900177448)
|
||||
self.assertAlmostEqual(result["limits"]["conservative_diameter_length_m"], 194.6207328149)
|
||||
self.assertAlmostEqual(result["limits"]["material_weight_length_m"], 235.5019136115)
|
||||
self.assertEqual(result["production_maximums"]["nominal_governing_constraints"], ["roll_diameter_nominal"])
|
||||
self.assertEqual(result["production_maximums"]["conservative_governing_constraints"], ["roll_diameter_conservative"])
|
||||
|
||||
def test_stddev_controls_nominal_and_conservative_diameter_limits(self):
|
||||
zero = self.calculate(thickness_stddev_mm=0)
|
||||
varied = self.calculate()
|
||||
self.assertEqual(
|
||||
zero["limits"]["nominal_diameter_length_m"],
|
||||
zero["limits"]["conservative_diameter_length_m"],
|
||||
)
|
||||
self.assertLess(
|
||||
varied["limits"]["conservative_diameter_length_m"],
|
||||
varied["limits"]["nominal_diameter_length_m"],
|
||||
)
|
||||
invalid = self.calculate(thickness_stddev_mm=3)
|
||||
self.assertEqual(invalid["status"], "invalid_parameter")
|
||||
|
||||
def test_weight_can_govern_one_or_both_production_maximums(self):
|
||||
one = self.calculate(repository=machine_repository(weight_maximum=600))
|
||||
both = self.calculate(repository=machine_repository(weight_maximum=500))
|
||||
self.assertEqual(one["production_maximums"]["nominal_governing_constraints"], ["material_weight"])
|
||||
self.assertEqual(one["production_maximums"]["conservative_governing_constraints"], ["roll_diameter_conservative"])
|
||||
self.assertEqual(both["production_maximums"]["nominal_governing_constraints"], ["material_weight"])
|
||||
self.assertEqual(both["production_maximums"]["conservative_governing_constraints"], ["material_weight"])
|
||||
|
||||
def test_ties_use_documented_tolerance_and_canonical_order(self):
|
||||
value, constraints = _governing_maximum((
|
||||
("roll_diameter_nominal", 100.0),
|
||||
("material_weight", 100.0 + MAX_LENGTH_TIE_ABS_TOLERANCE_M / 2),
|
||||
))
|
||||
self.assertEqual(value, 100.0)
|
||||
self.assertEqual(constraints, ["roll_diameter_nominal", "material_weight"])
|
||||
_, constraints = _governing_maximum((
|
||||
("roll_diameter_nominal", 100.0),
|
||||
("material_weight", 100.0 + MAX_LENGTH_TIE_ABS_TOLERANCE_M * 2),
|
||||
))
|
||||
self.assertEqual(constraints, ["roll_diameter_nominal"])
|
||||
|
||||
def test_core_and_width_incompatibilities_never_claim_a_length(self):
|
||||
core = self.calculate(core_diameter_mm=130)
|
||||
width = self.calculate(product_width_m=3.9)
|
||||
self.assertEqual(core["status"], "incompatible")
|
||||
self.assertEqual(core["production_maximums"], None)
|
||||
self.assertEqual(core["failed_compatibility_constraints"], ["allowed_core_diameters_mm"])
|
||||
self.assertEqual(width["status"], "incompatible")
|
||||
self.assertEqual(width["production_maximums"], None)
|
||||
self.assertEqual(width["failed_compatibility_constraints"], ["product_width_m"])
|
||||
|
||||
def test_width_boundaries_and_missing_inputs_are_structured(self):
|
||||
repository = machine_repository(width_limits=(4, 6))
|
||||
self.assertEqual(self.calculate(repository=repository, product_width_m=4)["status"], "success")
|
||||
self.assertEqual(self.calculate(repository=repository, product_width_m=6)["status"], "success")
|
||||
missing = self.calculate(product_width_m=None, area_weight_g_m2=None)
|
||||
self.assertEqual(missing["status"], "needs_clarification")
|
||||
self.assertEqual(missing["missing_required_inputs"], ["product_width_m", "area_weight_g_m2"])
|
||||
self.assertIsNotNone(missing["partial_limits"])
|
||||
self.assertIsNone(missing["production_maximums"])
|
||||
|
||||
def test_exact_width_machine_is_checked_before_length_calculation(self):
|
||||
values = {
|
||||
"machine": "E1",
|
||||
"core_diameter_mm": 150,
|
||||
"thickness_mm": 4.311611,
|
||||
"product_width_m": 7.5,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
}
|
||||
self.assertEqual(calculate_machine_max_product_length(**values)["status"], "success")
|
||||
values["product_width_m"] = 7.5001
|
||||
result = calculate_machine_max_product_length(**values)
|
||||
self.assertEqual(result["status"], "incompatible")
|
||||
self.assertIsNone(result["production_maximums"])
|
||||
|
||||
def test_unconfigured_limiters_and_minimum_diameter_verification(self):
|
||||
no_weight = self.calculate(repository=machine_repository(weight_maximum=None))
|
||||
self.assertEqual(no_weight["limits"]["material_weight_maximum_state"], "not_configured")
|
||||
no_diameter = self.calculate(repository=machine_repository(diameter_maximum=None))
|
||||
self.assertEqual(no_diameter["limits"]["roll_diameter_maximum_state"], "not_configured")
|
||||
self.assertEqual(
|
||||
no_diameter["production_maximums"]["nominal_governing_constraints"],
|
||||
["material_weight"],
|
||||
)
|
||||
no_limits = self.calculate(repository=machine_repository(diameter_maximum=None, weight_maximum=None, width_limits=None))
|
||||
self.assertEqual(no_limits["status"], "no_maximum_length_constraint")
|
||||
no_limits_without_article_size = self.calculate(
|
||||
repository=machine_repository(
|
||||
diameter_maximum=None, weight_maximum=None, width_limits=None
|
||||
),
|
||||
product_width_m=None,
|
||||
area_weight_g_m2=None,
|
||||
)
|
||||
self.assertEqual(
|
||||
no_limits_without_article_size["status"], "no_maximum_length_constraint"
|
||||
)
|
||||
low_weight = self.calculate(repository=machine_repository(weight_maximum=10, diameter_minimum=800))
|
||||
self.assertEqual(low_weight["status"], "incompatible")
|
||||
self.assertIsNone(low_weight["production_maximums"])
|
||||
|
||||
@@ -14,6 +14,7 @@ from roll_calculation import (
|
||||
)
|
||||
from rollcalc_mcp_tools import (
|
||||
analyze_transport_capacity_result,
|
||||
calculate_machine_max_product_length_result,
|
||||
check_production_feasibility_result,
|
||||
calculate_material_weight_result,
|
||||
calculate_product_length_result,
|
||||
@@ -22,7 +23,11 @@ from rollcalc_mcp_tools import (
|
||||
get_machine_result,
|
||||
search_articles_result,
|
||||
)
|
||||
from machine_constraints import check_production_feasibility, get_machine
|
||||
from machine_constraints import (
|
||||
calculate_machine_max_product_length,
|
||||
check_production_feasibility,
|
||||
get_machine,
|
||||
)
|
||||
from transport_calculation import analyze_transport, transport_presets
|
||||
|
||||
|
||||
@@ -98,6 +103,20 @@ class McpAdapterTests(unittest.TestCase):
|
||||
"feasible_with_warnings",
|
||||
)
|
||||
|
||||
def test_machine_maximum_length_adapter_delegates_to_shared_domain(self):
|
||||
arguments = {
|
||||
"machine": "K7",
|
||||
"core_diameter_mm": 133.0,
|
||||
"thickness_mm": 4.311611,
|
||||
"thickness_stddev_mm": 0.249999,
|
||||
"product_width_m": 6.0,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
}
|
||||
self.assertEqual(
|
||||
calculate_machine_max_product_length_result(**arguments),
|
||||
calculate_machine_max_product_length(**arguments),
|
||||
)
|
||||
|
||||
def test_material_weight_adapter_delegates_without_a_core_diameter(self):
|
||||
arguments = {
|
||||
"roll_length_m": 60.0,
|
||||
@@ -220,6 +239,7 @@ class McpAdapterTests(unittest.TestCase):
|
||||
"search_articles",
|
||||
"get_machine",
|
||||
"check_production_feasibility",
|
||||
"calculate_machine_max_product_length",
|
||||
"calculate_material_weight",
|
||||
"calculate_product_length",
|
||||
"calculate_roll_diameter",
|
||||
@@ -233,6 +253,7 @@ class McpAdapterTests(unittest.TestCase):
|
||||
search_tool = tools["search_articles"]
|
||||
machine_tool = tools["get_machine"]
|
||||
feasibility_tool = tools["check_production_feasibility"]
|
||||
maximum_tool = tools["calculate_machine_max_product_length"]
|
||||
material_weight_tool = tools["calculate_material_weight"]
|
||||
product_length_tool = tools["calculate_product_length"]
|
||||
roll_schema = tools["calculate_roll_diameter"].parameters
|
||||
@@ -250,6 +271,21 @@ class McpAdapterTests(unittest.TestCase):
|
||||
self.assertIn("never override failed constraints", feasibility_tool.description)
|
||||
self.assertIn("warnings", feasibility_tool.description)
|
||||
self.assertIn("Missing configured V1 roll inputs", feasibility_tool.description)
|
||||
self.assertEqual(
|
||||
maximum_tool.parameters["required"],
|
||||
["machine", "core_diameter_mm", "thickness_mm"],
|
||||
)
|
||||
self.assertIn("product_width_m", maximum_tool.parameters["properties"])
|
||||
self.assertIn("area_weight_g_m2", maximum_tool.parameters["properties"])
|
||||
for text in (
|
||||
"Resolve the article first with search_articles/get_article",
|
||||
"Do not supply or invent machine limits",
|
||||
"rearrange roll or weight formulas",
|
||||
"compare candidate limits yourself",
|
||||
"nominal and conservative/no-warning production maxima",
|
||||
"incompatibility rather than substituting another core or width",
|
||||
):
|
||||
self.assertIn(text, maximum_tool.description)
|
||||
self.assertEqual(
|
||||
search_tool.parameters["properties"]["query"]["type"], "string"
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from roll_calculation import (
|
||||
_canonical_name,
|
||||
article_production_site,
|
||||
calculate_material_weight,
|
||||
calculate_material_length_for_weight,
|
||||
calculate_product_length,
|
||||
calculate_roll,
|
||||
get_article,
|
||||
@@ -196,6 +197,41 @@ class RollCalculationTests(unittest.TestCase):
|
||||
self.assertAlmostEqual(result["material_weight_kg"], 1455.0239028)
|
||||
self.assertNotIn("core_diameter_mm", result["inputs"])
|
||||
|
||||
def test_material_length_for_weight_is_material_only_and_round_trips(self):
|
||||
inverse = calculate_material_length_for_weight(
|
||||
target_material_weight_kg=700,
|
||||
width_m=6,
|
||||
area_weight_g_m2=495.395833,
|
||||
)
|
||||
|
||||
self.assertEqual(inverse["status"], "success")
|
||||
self.assertEqual(inverse["weight_scope"], "material_only")
|
||||
self.assertNotIn("core_diameter_mm", inverse["inputs"])
|
||||
round_trip = calculate_material_weight(
|
||||
roll_length_m=inverse["material_length_m"],
|
||||
width_m=6,
|
||||
area_weight_g_m2=495.395833,
|
||||
)
|
||||
self.assertAlmostEqual(round_trip["material_weight_kg"], 700)
|
||||
|
||||
def test_material_length_for_weight_rejects_non_positive_inputs(self):
|
||||
for changes in (
|
||||
{"target_material_weight_kg": 0},
|
||||
{"width_m": 0},
|
||||
{"area_weight_g_m2": 0},
|
||||
):
|
||||
with self.subTest(changes=changes):
|
||||
values = {
|
||||
"target_material_weight_kg": 700,
|
||||
"width_m": 6,
|
||||
"area_weight_g_m2": 495,
|
||||
}
|
||||
values.update(changes)
|
||||
self.assertEqual(
|
||||
calculate_material_length_for_weight(**values)["status"],
|
||||
"invalid_parameter",
|
||||
)
|
||||
|
||||
def test_roll_diameter_still_rejects_a_zero_core_diameter(self):
|
||||
result = calculate_roll(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user