diff --git a/README.md b/README.md index 1713c3d..6090191 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,11 @@ Authentication is implemented with `Flask-HTTPAuth`. The current code checks `BE `mcp_server.py` provides a local stdio-only Model Context Protocol server. It is a thin adapter over the same domain services used by Flask: article lookup, -direct roll calculation, material-weight calculation, and transport analysis. +direct roll calculation, product-length calculation, material-weight calculation, +and transport analysis. The available PoC tools are `get_article`, `search_articles`, -`calculate_material_weight`, `calculate_roll_diameter`, and +`calculate_material_weight`, `calculate_product_length`, +`calculate_roll_diameter`, and `analyze_transport_capacity`. Install the pinned dependencies, including `mcp==1.26.0`, in the existing @@ -132,7 +134,7 @@ environment, then start it with: ``` The PoC has no MCP resources, HTTP/SSE transport, remote authentication, or -dedicated product-length or target-length calculation tools, or extrapolation. +dedicated target-length calculation tool or extrapolation support. Tool calling semantics: @@ -145,6 +147,11 @@ 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_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 + weight return material-weight ranges. It does not infer missing article or + material inputs. - `calculate_roll_diameter` uses `roll_length_m` for the material length on one roll. Diameter and thickness inputs are in mm; `width_m` is in m; `area_weight_g_m2` is in g/m². It requires a known positive core diameter; @@ -382,7 +389,11 @@ The direct calculation tab supports multiple modes via mode buttons: - Product length from core diameter, material thickness, and roll diameter. - Product length for a target diameter. -The direct roll-diameter mode posts raw decision inputs to `/api/calculations/roll`. The authoritative implementation is `calculate_roll()` in `roll_calculation.py`; the template only renders its result. +The direct roll-diameter mode posts raw decision inputs to `/api/calculations/roll`. +The Product Length mode posts to `/api/calculations/product-length`. The +authoritative implementations are `calculate_roll()` and +`calculate_product_length()` in `roll_calculation.py`; the template only renders +their results. The four core preset buttons are rendered from the canonical ordered `CORE_PRESETS` tuple in `core_presets.py`. Headless and conversational core resolution uses the same tuple and its finite German/English aliases. Generic steel and PVC family names remain ambiguous because the current UI contains two presets for each family. diff --git a/app.py b/app.py index c74cbe0..3fb27af 100644 --- a/app.py +++ b/app.py @@ -32,7 +32,11 @@ from pdf_report import ( render_roll_report, safe_report_filename, ) -from roll_calculation import calculate_roll, modify_calculation +from roll_calculation import ( + calculate_product_length, + calculate_roll, + modify_calculation, +) from transport_calculation import analyze_transport, transport_presets app = Flask(__name__) @@ -199,6 +203,40 @@ def create_roll_calculation(): return jsonify(result), 200 +@app.route("/api/calculations/product-length", methods=["POST"]) +@auth.login_required +def create_product_length_calculation(): + """Calculate deterministic product length for a target roll diameter.""" + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + result = { + "status": "invalid_parameter", + "invalid": [{"field": "request", "message": "JSON object required"}], + } + else: + allowed = { + "target_roll_diameter_mm", + "core_diameter_mm", + "thickness_mm", + "thickness_stddev_mm", + "width_m", + "area_weight_g_m2", + } + unknown = sorted(set(payload) - allowed) + if unknown: + result = { + "status": "invalid_parameter", + "invalid": [{ + "field": "request", + "message": "unknown request fields: " + ", ".join(unknown), + }], + } + else: + result = calculate_product_length(**payload) + log_access(auth.current_user(), "/api/calculations/product-length", "POST") + return jsonify(result), 200 + + @app.route("/api/calculations/roll/modify", methods=["POST"]) @auth.login_required def modify_roll_calculation(): diff --git a/mcp_server.py b/mcp_server.py index 9f48b23..f2ada8b 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -7,6 +7,7 @@ from typing import Any from rollcalc_mcp_tools import ( analyze_transport_capacity_result, calculate_material_weight_result, + calculate_product_length_result, calculate_roll_diameter_result, get_article_result, search_articles_result, @@ -46,6 +47,25 @@ def create_server() -> Any: area_weight_g_m2=area_weight_g_m2, ) + @server.tool() + def calculate_product_length( + target_roll_diameter_mm: float, + core_diameter_mm: float, + thickness_mm: float, + thickness_stddev_mm: float | None = None, + width_m: float | None = None, + area_weight_g_m2: float | None = None, + ) -> dict[str, Any]: + """Calculate required product/roll length for a target outer roll diameter. target_roll_diameter_mm, core_diameter_mm, and thickness_mm are required and expressed in mm. Use exact article master-data values obtained through get_article when available; do not infer a missing core diameter, thickness, width, area weight, or article. thickness_stddev_mm returns deterministic minimum, average, and maximum length ranges. When both width_m (m) and area_weight_g_m2 (g/m²) are supplied, the result also includes corresponding material-weight ranges. This tool calculates length from target diameter; use calculate_roll_diameter for the reverse direction.""" + return calculate_product_length_result( + target_roll_diameter_mm=target_roll_diameter_mm, + core_diameter_mm=core_diameter_mm, + thickness_mm=thickness_mm, + thickness_stddev_mm=thickness_stddev_mm, + width_m=width_m, + area_weight_g_m2=area_weight_g_m2, + ) + @server.tool() def calculate_roll_diameter( roll_length_m: float | None = None, diff --git a/roll_calculation.py b/roll_calculation.py index 73bcaf1..b6caded 100644 --- a/roll_calculation.py +++ b/roll_calculation.py @@ -431,6 +431,129 @@ def _material_weight_kg( return area_weight_g_m2 * roll_length_m * width_m / 1000 +def calculate_product_length( + *, + target_roll_diameter_mm: Any = None, + core_diameter_mm: Any = None, + thickness_mm: Any = None, + thickness_stddev_mm: Any = None, + width_m: Any = None, + area_weight_g_m2: Any = None, +) -> dict[str, Any]: + """Calculate product-length ranges for a target outer roll diameter.""" + required = { + "target_roll_diameter_mm": target_roll_diameter_mm, + "core_diameter_mm": core_diameter_mm, + "thickness_mm": thickness_mm, + } + invalid = [] + normalized = {} + for field, value in required.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 thickness_stddev_mm is None: + thickness_stddev = 0.0 + elif isinstance(thickness_stddev_mm, bool) or not isinstance( + thickness_stddev_mm, (int, float) + ): + invalid.append( + {"field": "thickness_stddev_mm", "message": "must be a number"} + ) + thickness_stddev = 0.0 + else: + thickness_stddev = float(thickness_stddev_mm) + if not math.isfinite(thickness_stddev) or thickness_stddev < 0: + invalid.append( + { + "field": "thickness_stddev_mm", + "message": "must be zero or greater", + } + ) + if invalid: + return {"status": "invalid_parameter", "invalid": invalid} + + target = normalized["target_roll_diameter_mm"] + core = normalized["core_diameter_mm"] + thickness = normalized["thickness_mm"] + if target <= core: + return { + "status": "invalid_parameter", + "invalid": [ + { + "field": "target_roll_diameter_mm", + "message": "must be greater than core_diameter_mm", + } + ], + } + minimum_thickness = thickness - (2 * thickness_stddev) + if minimum_thickness <= 0: + return { + "status": "invalid_parameter", + "invalid": [ + { + "field": "thickness_stddev_mm", + "message": "minus two standard deviations must remain positive", + } + ], + } + + length_factor = math.pi * (target**2 - core**2) / 4000 + minimum_length = length_factor / (thickness + (2 * thickness_stddev)) + average_length = length_factor / thickness + maximum_length = length_factor / minimum_thickness + lengths = (minimum_length, average_length, maximum_length) + if not all(math.isfinite(value) and value > 0 for value in lengths): + return { + "status": "invalid_parameter", + "invalid": [ + { + "field": "request", + "message": "values are outside the supported calculation range", + } + ], + } + + normalized["thickness_stddev_mm"] = thickness_stddev + material_weights = None + if width_m is not None and area_weight_g_m2 is not None: + weight_result = calculate_material_weight( + roll_length_m=average_length, + width_m=width_m, + area_weight_g_m2=area_weight_g_m2, + ) + if weight_result["status"] != "success": + return weight_result + width = weight_result["inputs"]["width_m"] + area_weight = weight_result["inputs"]["area_weight_g_m2"] + normalized["width_m"] = width + normalized["area_weight_g_m2"] = area_weight + material_weights = { + "minimum_kg": _material_weight_kg(minimum_length, width, area_weight), + "average_kg": weight_result["material_weight_kg"], + "maximum_kg": _material_weight_kg(maximum_length, width, area_weight), + } + + return { + "status": "success", + "inputs": normalized, + "calculation": { + "minimum_product_length_m": minimum_length, + "average_product_length_m": average_length, + "maximum_product_length_m": maximum_length, + "material_weights_kg": material_weights, + }, + } + + def _canonical_name(value: str) -> str: value = value.casefold().replace("×", " x ") tokens = re.findall(r"\d+(?:[.,]\d+)?|[^\W\d_]+", value) diff --git a/rollcalc_mcp_tools.py b/rollcalc_mcp_tools.py index f0f4dff..5f3d344 100644 --- a/rollcalc_mcp_tools.py +++ b/rollcalc_mcp_tools.py @@ -7,6 +7,7 @@ from typing import Any from roll_calculation import ( ArticleRepository, calculate_material_weight, + calculate_product_length, calculate_roll, get_article, transport_roll_inputs_from_roll_calculation, @@ -41,6 +42,26 @@ def calculate_material_weight_result( ) +def calculate_product_length_result( + *, + target_roll_diameter_mm: float, + core_diameter_mm: float, + thickness_mm: float, + thickness_stddev_mm: float | None = None, + width_m: float | None = None, + area_weight_g_m2: float | None = None, +) -> dict[str, Any]: + """Delegate target-diameter product-length calculation to the domain.""" + return calculate_product_length( + target_roll_diameter_mm=target_roll_diameter_mm, + core_diameter_mm=core_diameter_mm, + thickness_mm=thickness_mm, + thickness_stddev_mm=thickness_stddev_mm, + width_m=width_m, + area_weight_g_m2=area_weight_g_m2, + ) + + def calculate_roll_diameter_result( *, article_number: str | None = None, diff --git a/templates/roll_calculator.html b/templates/roll_calculator.html index 593112e..cd34bdf 100644 --- a/templates/roll_calculator.html +++ b/templates/roll_calculator.html @@ -1080,6 +1080,16 @@ async function requestRollCalculation(calculationRequest) { return response.json(); } +async function requestProductLengthCalculation(calculationRequest) { + const response = await fetch('/api/calculations/product-length', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(calculationRequest) + }); + if (!response.ok) throw new Error('Product-length calculation service failed'); + return response.json(); +} + document.getElementById('d-calc').addEventListener('click', async () => { invalidatePdfReport(); const mode = document.querySelector('.mode-btn.active')?.dataset.mode || 'diameter'; @@ -1135,11 +1145,36 @@ document.getElementById('d-calc').addEventListener('click', async () => { renderAuthoritativeForkliftResult(authoritativeResult); } else if (mode === 'length') { if (!d || !t || !D) { alert('Please enter d, t, and D.'); return; } - const L_calc = (Math.PI / (4 * t)) * (D*D - d*d) / 1000; + try { + authoritativeResult = await requestProductLengthCalculation({ + target_roll_diameter_mm: D, + core_diameter_mm: d, + thickness_mm: t, + thickness_stddev_mm: tol, + width_m: numericInput('d-wi'), + area_weight_g_m2: numericInput('d-aw') + }); + } catch (error) { + alert(error.message); + return; + } + if (authoritativeResult.status !== 'success') { + const issue = authoritativeResult.invalid?.[0]?.message; + alert(issue || 'Product-length calculation failed.'); + return; + } + const calculation = authoritativeResult.calculation; + const L_calc = calculation.average_product_length_m; resLabel.textContent = 'Product Length (L)'; resVal.textContent = L_calc.toFixed(2) + ' m'; - resRange.textContent = ''; + if (tol) { + resRange.textContent = '−2σ: ' + calculation.minimum_product_length_m.toFixed(2) + ' m | +2σ: ' + calculation.maximum_product_length_m.toFixed(2) + ' m'; + } else { + resRange.textContent = ''; + } document.getElementById('d-le').value = L_calc.toFixed(2); + const materialWeight = calculation.material_weights_kg?.average_kg; + document.getElementById('d-wo').value = materialWeight === undefined ? '' : materialWeight.toFixed(1); } else if (mode === 'target') { const wi = parseFloat(document.getElementById('d-wi').value) || 0; const aw = parseFloat(document.getElementById('d-aw').value) || 0; diff --git a/tests/test_calculation_api.py b/tests/test_calculation_api.py index 22d9b4c..5e5051d 100644 --- a/tests/test_calculation_api.py +++ b/tests/test_calculation_api.py @@ -63,6 +63,27 @@ class CalculationApiTests(unittest.TestCase): self.assertEqual(result["state"]["request"]["roll_length_m"], 80.0) self.assertEqual(result["calculation"]["effective_roll_length_m"], 80.0) + def test_product_length_endpoint_uses_the_domain_calculation(self): + response = self.client.post( + "/api/calculations/product-length", + json={ + "target_roll_diameter_mm": 600, + "core_diameter_mm": 140, + "thickness_mm": 4.311611, + "thickness_stddev_mm": 0.249999, + "width_m": 6.0, + "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.assertAlmostEqual( + result["calculation"]["average_product_length_m"], 62.0068774341 + ) + def test_transport_endpoint_delegates_to_shared_domain_service(self): payload = { "length_m": 10, diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py index 4ba595c..b1cfc46 100644 --- a/tests/test_mcp_adapter.py +++ b/tests/test_mcp_adapter.py @@ -8,12 +8,14 @@ import mcp_server from roll_calculation import ( ArticleRepository, calculate_material_weight, + calculate_product_length, calculate_roll, get_article, ) from rollcalc_mcp_tools import ( analyze_transport_capacity_result, calculate_material_weight_result, + calculate_product_length_result, calculate_roll_diameter_result, get_article_result, search_articles_result, @@ -87,6 +89,23 @@ class McpAdapterTests(unittest.TestCase): self.assertEqual(result["weight_scope"], "material_only") self.assertAlmostEqual(result["material_weight_kg"], 1455.0239028) + def test_product_length_adapter_delegates_with_article_218500_values(self): + arguments = { + "target_roll_diameter_mm": 600.0, + "core_diameter_mm": 140.0, + "thickness_mm": 4.311611, + "thickness_stddev_mm": 0.249999, + "width_m": 6.0, + "area_weight_g_m2": 495.395833, + } + + result = calculate_product_length_result(**arguments) + + self.assertEqual(result, calculate_product_length(**arguments)) + self.assertAlmostEqual( + result["calculation"]["average_product_length_m"], 62.0068774341 + ) + def test_calculate_roll_diameter_delegates_for_normal_weighted_and_invalid_cases(self): for arguments in ( { @@ -178,6 +197,7 @@ class McpAdapterTests(unittest.TestCase): "get_article", "search_articles", "calculate_material_weight", + "calculate_product_length", "calculate_roll_diameter", "analyze_transport_capacity", ], @@ -188,6 +208,7 @@ class McpAdapterTests(unittest.TestCase): tools = instance._tool_manager._tools search_tool = tools["search_articles"] material_weight_tool = tools["calculate_material_weight"] + product_length_tool = tools["calculate_product_length"] roll_schema = tools["calculate_roll_diameter"].parameters transport_tool = tools["analyze_transport_capacity"] transport_schema = transport_tool.parameters @@ -202,6 +223,19 @@ class McpAdapterTests(unittest.TestCase): ["roll_length_m", "width_m", "area_weight_g_m2"], ) self.assertNotIn("core_diameter_mm", material_weight_schema["properties"]) + product_length_schema = product_length_tool.parameters + self.assertEqual( + product_length_schema["required"], + [ + "target_roll_diameter_mm", + "core_diameter_mm", + "thickness_mm", + ], + ) + self.assertEqual( + product_length_schema["properties"]["target_roll_diameter_mm"]["type"], + "number", + ) self.assertNotIn("include_roll_weight", roll_schema["properties"]) preset_schema = transport_schema["properties"]["transport_preset"] @@ -294,6 +328,16 @@ class McpAdapterTests(unittest.TestCase): self.assertIn("do not infer or default one", roll_description) self.assertIn("call calculate_material_weight instead", roll_description) + product_length_description = product_length_tool.description + for text in ( + "required product/roll length for a target outer roll diameter", + "do not infer a missing core diameter, thickness, width, area weight, or article", + "minimum, average, and maximum length ranges", + "material-weight ranges", + "use calculate_roll_diameter for the reverse direction", + ): + self.assertIn(text, product_length_description) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_roll_calculation.py b/tests/test_roll_calculation.py index d30b5ad..bb5db71 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_product_length, calculate_roll, get_article, modify_calculation, @@ -212,6 +213,37 @@ class RollCalculationTests(unittest.TestCase): item["field"] for item in result["invalid"] ]) + def test_product_length_characterizes_article_218500_with_ranges_and_weight(self): + result = calculate_product_length( + target_roll_diameter_mm=600, + core_diameter_mm=140, + thickness_mm=4.311611, + thickness_stddev_mm=0.249999, + width_m=6.0, + area_weight_g_m2=495.395833, + ) + + self.assertEqual(result["status"], "success") + calculation = result["calculation"] + self.assertAlmostEqual(calculation["minimum_product_length_m"], 55.5634372661) + self.assertAlmostEqual(calculation["average_product_length_m"], 62.0068774341) + self.assertAlmostEqual(calculation["maximum_product_length_m"], 70.1407868061) + self.assertAlmostEqual( + calculation["material_weights_kg"]["average_kg"], 184.3076921892 + ) + + def test_product_length_rejects_target_diameter_not_larger_than_core(self): + result = calculate_product_length( + target_roll_diameter_mm=140, + core_diameter_mm=140, + thickness_mm=4, + ) + + self.assertEqual(result["status"], "invalid_parameter") + self.assertEqual( + result["invalid"][0]["field"], "target_roll_diameter_mm" + ) + def test_diameter_only_calculation_does_not_require_width(self): result = self.calculate_fixture( { diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index 603b9e0..84e7ede 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -17,6 +17,11 @@ class TemplatePdfIntegrationTests(unittest.TestCase): self.assertIn("fetch('/api/calculations/roll'", self.template) self.assertIn("calculation.average_diameter_mm", self.template) self.assertIn("calculation.effective_roll_length_m", self.template) + + def test_product_length_ui_uses_the_shared_domain_endpoint(self): + self.assertIn("fetch('/api/calculations/product-length'", self.template) + self.assertIn("calculation.average_product_length_m", self.template) + self.assertNotIn("const L_calc = (Math.PI / (4 * t))", self.template) self.assertNotIn("function calculateDiameterResult", self.template) def test_optional_weight_requires_both_weight_inputs(self):