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
+12 -2
View File
@@ -108,6 +108,7 @@ Implemented routes:
| `/api/calculations/roll/modify` | `POST` | Basic Auth | Applies explicit changes to structured calculation state and recalculates. | | `/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/transport` | `POST` | Basic Auth | Executes the shared transport-capacity analysis. |
| `/api/calculations/production-feasibility` | `POST` | Basic Auth | Checks configured production-machine constraints. | | `/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/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` | `POST` | Basic Auth | Creates an in-memory conversation session. |
| `/api/conversations/<id>/messages` | `POST` | Basic Auth | Interprets one utterance and executes the deterministic workflow. | | `/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. transport analysis, and configured production-machine feasibility.
The available PoC tools are `get_article`, `search_articles`, The available PoC tools are `get_article`, `search_articles`,
`calculate_material_weight`, `calculate_product_length`, `calculate_material_weight`, `calculate_product_length`,
`calculate_roll_diameter`, `analyze_transport_capacity`, `get_machine`, and `calculate_roll_diameter`, `analyze_transport_capacity`, `get_machine`,
`check_production_feasibility`. `check_production_feasibility`, and `calculate_machine_max_product_length`.
Install the pinned dependencies, including `mcp==1.26.0`, in the existing Install the pinned dependencies, including `mcp==1.26.0`, in the existing
environment, then start it with: environment, then start it with:
@@ -148,6 +149,15 @@ Tool calling semantics:
- `calculate_material_weight` calculates material-only weight from roll length, - `calculate_material_weight` calculates material-only weight from roll length,
width, and area weight. It requires no core diameter, does not infer one, and 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. 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 - `calculate_product_length` calculates required roll length from a target outer
diameter, core diameter, and material thickness. Its optional thickness diameter, core diameter, and material thickness. Its optional thickness
stddev returns minimum/average/maximum length ranges; optional width and area stddev returns minimum/average/maximum length ranges; optional width and area
+47 -1
View File
@@ -20,7 +20,10 @@ from flask_httpauth import HTTPBasicAuth
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from core_presets import CORE_PRESETS 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 ( from conversation_service import (
ConversationNotFoundError, ConversationNotFoundError,
ConversationReportNotFoundError, ConversationReportNotFoundError,
@@ -278,6 +281,49 @@ def create_production_feasibility_calculation():
return jsonify(result), 200 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): def _create_pdf(calculation_request):
result = calculate_roll(calculation_request, build_info=BUILD_INFO) result = calculate_roll(calculation_request, build_info=BUILD_INFO)
report = report_from_calculation_result(result) report = report_from_calculation_result(result)
+13
View File
@@ -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 scope. Flask and MCP are thin adapters; no machine constraints are duplicated
in those layers or in browser code. 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 ## Constrained Natural-Language Flow
```text ```text
+316
View File
@@ -10,6 +10,13 @@ from typing import Any
import yaml 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" MACHINE_CONFIG_FILE = Path(__file__).parent / "config" / "machines.yaml"
SCHEMA_VERSION = 1 SCHEMA_VERSION = 1
@@ -20,6 +27,8 @@ CONSTRAINT_KEYS = {
"product_width_m", "product_width_m",
"line_speed_m_per_min", "line_speed_m_per_min",
} }
MAX_LENGTH_TIE_REL_TOLERANCE = 1e-12
MAX_LENGTH_TIE_ABS_TOLERANCE_M = 1e-9
class MachineConfigurationError(ValueError): class MachineConfigurationError(ValueError):
@@ -400,3 +409,310 @@ def check_production_feasibility(
"failed_constraints": failed, "failed_constraints": failed,
"missing_required_inputs": missing, "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": [],
}
+20
View File
@@ -6,6 +6,7 @@ from typing import Any
from rollcalc_mcp_tools import ( from rollcalc_mcp_tools import (
analyze_transport_capacity_result, analyze_transport_capacity_result,
calculate_machine_max_product_length_result,
calculate_material_weight_result, calculate_material_weight_result,
calculate_product_length_result, calculate_product_length_result,
calculate_roll_diameter_result, calculate_roll_diameter_result,
@@ -62,6 +63,25 @@ def create_server() -> Any:
line_speed_m_per_min=line_speed_m_per_min, 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() @server.tool()
def calculate_material_weight( def calculate_material_weight(
roll_length_m: float, roll_length_m: float,
+48
View File
@@ -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( def _material_weight_kg(
roll_length_m: float, width_m: float, area_weight_g_m2: float roll_length_m: float, width_m: float, area_weight_g_m2: float
) -> float: ) -> float:
+25 -1
View File
@@ -4,7 +4,11 @@ from __future__ import annotations
from typing import Any 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 ( from roll_calculation import (
ArticleRepository, ArticleRepository,
calculate_material_weight, 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( def calculate_material_weight_result(
*, *,
roll_length_m: float, roll_length_m: float,
+47
View File
@@ -139,6 +139,53 @@ class CalculationApiTests(unittest.TestCase):
self.assertEqual(result["feasibility"], "feasible_with_warnings") self.assertEqual(result["feasibility"], "feasible_with_warnings")
self.assertEqual(result["warning_constraints"], ["roll_diameter_mm"]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+164
View File
@@ -4,8 +4,14 @@ from pathlib import Path
from machine_constraints import ( from machine_constraints import (
MACHINE_CONFIG_FILE, MACHINE_CONFIG_FILE,
MAX_LENGTH_TIE_ABS_TOLERANCE_M,
Machine,
MachineConfigurationError, MachineConfigurationError,
MachineConstraints,
MachineRepository, MachineRepository,
NumericRange,
_governing_maximum,
calculate_machine_max_product_length,
check_production_feasibility, check_production_feasibility,
get_machine, get_machine,
) )
@@ -24,6 +30,38 @@ def request(**changes):
return payload 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): class MachineRepositoryTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.repository = MachineRepository.load() 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", "unknown": 1})["status"], "invalid_parameter")
self.assertEqual(check_production_feasibility({"machine": "B1", "core_weight_kg": 10})["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") 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"])
+37 -1
View File
@@ -14,6 +14,7 @@ from roll_calculation import (
) )
from rollcalc_mcp_tools import ( from rollcalc_mcp_tools import (
analyze_transport_capacity_result, analyze_transport_capacity_result,
calculate_machine_max_product_length_result,
check_production_feasibility_result, check_production_feasibility_result,
calculate_material_weight_result, calculate_material_weight_result,
calculate_product_length_result, calculate_product_length_result,
@@ -22,7 +23,11 @@ from rollcalc_mcp_tools import (
get_machine_result, get_machine_result,
search_articles_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 from transport_calculation import analyze_transport, transport_presets
@@ -98,6 +103,20 @@ class McpAdapterTests(unittest.TestCase):
"feasible_with_warnings", "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): def test_material_weight_adapter_delegates_without_a_core_diameter(self):
arguments = { arguments = {
"roll_length_m": 60.0, "roll_length_m": 60.0,
@@ -220,6 +239,7 @@ class McpAdapterTests(unittest.TestCase):
"search_articles", "search_articles",
"get_machine", "get_machine",
"check_production_feasibility", "check_production_feasibility",
"calculate_machine_max_product_length",
"calculate_material_weight", "calculate_material_weight",
"calculate_product_length", "calculate_product_length",
"calculate_roll_diameter", "calculate_roll_diameter",
@@ -233,6 +253,7 @@ class McpAdapterTests(unittest.TestCase):
search_tool = tools["search_articles"] search_tool = tools["search_articles"]
machine_tool = tools["get_machine"] machine_tool = tools["get_machine"]
feasibility_tool = tools["check_production_feasibility"] feasibility_tool = tools["check_production_feasibility"]
maximum_tool = tools["calculate_machine_max_product_length"]
material_weight_tool = tools["calculate_material_weight"] material_weight_tool = tools["calculate_material_weight"]
product_length_tool = tools["calculate_product_length"] product_length_tool = tools["calculate_product_length"]
roll_schema = tools["calculate_roll_diameter"].parameters 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("never override failed constraints", feasibility_tool.description)
self.assertIn("warnings", feasibility_tool.description) self.assertIn("warnings", feasibility_tool.description)
self.assertIn("Missing configured V1 roll inputs", 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( self.assertEqual(
search_tool.parameters["properties"]["query"]["type"], "string" search_tool.parameters["properties"]["query"]["type"], "string"
) )
+36
View File
@@ -9,6 +9,7 @@ from roll_calculation import (
_canonical_name, _canonical_name,
article_production_site, article_production_site,
calculate_material_weight, calculate_material_weight,
calculate_material_length_for_weight,
calculate_product_length, calculate_product_length,
calculate_roll, calculate_roll,
get_article, get_article,
@@ -196,6 +197,41 @@ class RollCalculationTests(unittest.TestCase):
self.assertAlmostEqual(result["material_weight_kg"], 1455.0239028) self.assertAlmostEqual(result["material_weight_kg"], 1455.0239028)
self.assertNotIn("core_diameter_mm", result["inputs"]) 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): def test_roll_diameter_still_rejects_a_zero_core_diameter(self):
result = calculate_roll( result = calculate_roll(
{ {