From 90c28fb2f5b883f63c086f3c0aea29277fb94a8f Mon Sep 17 00:00:00 2001 From: Martin Date: Sat, 26 Sep 2026 12:25:04 +0200 Subject: [PATCH] Add machine-aware maximum product length --- README.md | 14 +- app.py | 48 ++++- docs/architecture.md | 13 ++ machine_constraints.py | 316 ++++++++++++++++++++++++++++++ mcp_server.py | 20 ++ roll_calculation.py | 48 +++++ rollcalc_mcp_tools.py | 26 ++- tests/test_calculation_api.py | 47 +++++ tests/test_machine_constraints.py | 164 ++++++++++++++++ tests/test_mcp_adapter.py | 38 +++- tests/test_roll_calculation.py | 36 ++++ 11 files changed, 765 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a27ebac..a291c3d 100644 --- a/README.md +++ b/README.md @@ -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//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 diff --git a/app.py b/app.py index 53c73db..3c4f2a7 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/docs/architecture.md b/docs/architecture.md index 20029d0..907f2e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/machine_constraints.py b/machine_constraints.py index e624f50..cfc0402 100644 --- a/machine_constraints.py +++ b/machine_constraints.py @@ -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": [], + } diff --git a/mcp_server.py b/mcp_server.py index 97f3ca3..3897b95 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -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, diff --git a/roll_calculation.py b/roll_calculation.py index b6caded..ce2c92b 100644 --- a/roll_calculation.py +++ b/roll_calculation.py @@ -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: diff --git a/rollcalc_mcp_tools.py b/rollcalc_mcp_tools.py index 64ad28a..9dcf5dc 100644 --- a/rollcalc_mcp_tools.py +++ b/rollcalc_mcp_tools.py @@ -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, diff --git a/tests/test_calculation_api.py b/tests/test_calculation_api.py index 2f5518f..9e01d30 100644 --- a/tests/test_calculation_api.py +++ b/tests/test_calculation_api.py @@ -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() diff --git a/tests/test_machine_constraints.py b/tests/test_machine_constraints.py index 5bfb059..a2e8ac4 100644 --- a/tests/test_machine_constraints.py +++ b/tests/test_machine_constraints.py @@ -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"]) diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py index 83130b2..df2616d 100644 --- a/tests/test_mcp_adapter.py +++ b/tests/test_mcp_adapter.py @@ -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" ) diff --git a/tests/test_roll_calculation.py b/tests/test_roll_calculation.py index bb5db71..3fd949d 100644 --- a/tests/test_roll_calculation.py +++ b/tests/test_roll_calculation.py @@ -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( {