Expose product length calculation through MCP

This commit is contained in:
2026-09-25 21:21:20 +02:00
parent 580e9caa5e
commit e6a7d4da72
10 changed files with 357 additions and 7 deletions
+15 -4
View File
@@ -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 `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, 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`, 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`. `analyze_transport_capacity`.
Install the pinned dependencies, including `mcp==1.26.0`, in the existing 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 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: Tool calling semantics:
@@ -145,6 +147,11 @@ 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_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 - `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; 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; `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 from core diameter, material thickness, and roll diameter.
- Product length for a target 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. 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.
+39 -1
View File
@@ -32,7 +32,11 @@ from pdf_report import (
render_roll_report, render_roll_report,
safe_report_filename, 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 from transport_calculation import analyze_transport, transport_presets
app = Flask(__name__) app = Flask(__name__)
@@ -199,6 +203,40 @@ def create_roll_calculation():
return jsonify(result), 200 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"]) @app.route("/api/calculations/roll/modify", methods=["POST"])
@auth.login_required @auth.login_required
def modify_roll_calculation(): def modify_roll_calculation():
+20
View File
@@ -7,6 +7,7 @@ from typing import Any
from rollcalc_mcp_tools import ( from rollcalc_mcp_tools import (
analyze_transport_capacity_result, analyze_transport_capacity_result,
calculate_material_weight_result, calculate_material_weight_result,
calculate_product_length_result,
calculate_roll_diameter_result, calculate_roll_diameter_result,
get_article_result, get_article_result,
search_articles_result, search_articles_result,
@@ -46,6 +47,25 @@ def create_server() -> Any:
area_weight_g_m2=area_weight_g_m2, 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() @server.tool()
def calculate_roll_diameter( def calculate_roll_diameter(
roll_length_m: float | None = None, roll_length_m: float | None = None,
+123
View File
@@ -431,6 +431,129 @@ def _material_weight_kg(
return area_weight_g_m2 * roll_length_m * width_m / 1000 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: def _canonical_name(value: str) -> str:
value = value.casefold().replace("×", " x ") value = value.casefold().replace("×", " x ")
tokens = re.findall(r"\d+(?:[.,]\d+)?|[^\W\d_]+", value) tokens = re.findall(r"\d+(?:[.,]\d+)?|[^\W\d_]+", value)
+21
View File
@@ -7,6 +7,7 @@ from typing import Any
from roll_calculation import ( from roll_calculation import (
ArticleRepository, ArticleRepository,
calculate_material_weight, calculate_material_weight,
calculate_product_length,
calculate_roll, calculate_roll,
get_article, get_article,
transport_roll_inputs_from_roll_calculation, 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( def calculate_roll_diameter_result(
*, *,
article_number: str | None = None, article_number: str | None = None,
+36 -1
View File
@@ -1080,6 +1080,16 @@ async function requestRollCalculation(calculationRequest) {
return response.json(); 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 () => { document.getElementById('d-calc').addEventListener('click', async () => {
invalidatePdfReport(); invalidatePdfReport();
const mode = document.querySelector('.mode-btn.active')?.dataset.mode || 'diameter'; const mode = document.querySelector('.mode-btn.active')?.dataset.mode || 'diameter';
@@ -1135,11 +1145,36 @@ document.getElementById('d-calc').addEventListener('click', async () => {
renderAuthoritativeForkliftResult(authoritativeResult); renderAuthoritativeForkliftResult(authoritativeResult);
} else if (mode === 'length') { } else if (mode === 'length') {
if (!d || !t || !D) { alert('Please enter d, t, and D.'); return; } 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)'; resLabel.textContent = 'Product Length (L)';
resVal.textContent = L_calc.toFixed(2) + ' m'; resVal.textContent = L_calc.toFixed(2) + ' m';
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 = ''; resRange.textContent = '';
}
document.getElementById('d-le').value = L_calc.toFixed(2); 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') { } else if (mode === 'target') {
const wi = parseFloat(document.getElementById('d-wi').value) || 0; const wi = parseFloat(document.getElementById('d-wi').value) || 0;
const aw = parseFloat(document.getElementById('d-aw').value) || 0; const aw = parseFloat(document.getElementById('d-aw').value) || 0;
+21
View File
@@ -63,6 +63,27 @@ class CalculationApiTests(unittest.TestCase):
self.assertEqual(result["state"]["request"]["roll_length_m"], 80.0) self.assertEqual(result["state"]["request"]["roll_length_m"], 80.0)
self.assertEqual(result["calculation"]["effective_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): def test_transport_endpoint_delegates_to_shared_domain_service(self):
payload = { payload = {
"length_m": 10, "length_m": 10,
+44
View File
@@ -8,12 +8,14 @@ import mcp_server
from roll_calculation import ( from roll_calculation import (
ArticleRepository, ArticleRepository,
calculate_material_weight, calculate_material_weight,
calculate_product_length,
calculate_roll, calculate_roll,
get_article, get_article,
) )
from rollcalc_mcp_tools import ( from rollcalc_mcp_tools import (
analyze_transport_capacity_result, analyze_transport_capacity_result,
calculate_material_weight_result, calculate_material_weight_result,
calculate_product_length_result,
calculate_roll_diameter_result, calculate_roll_diameter_result,
get_article_result, get_article_result,
search_articles_result, search_articles_result,
@@ -87,6 +89,23 @@ class McpAdapterTests(unittest.TestCase):
self.assertEqual(result["weight_scope"], "material_only") self.assertEqual(result["weight_scope"], "material_only")
self.assertAlmostEqual(result["material_weight_kg"], 1455.0239028) 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): def test_calculate_roll_diameter_delegates_for_normal_weighted_and_invalid_cases(self):
for arguments in ( for arguments in (
{ {
@@ -178,6 +197,7 @@ class McpAdapterTests(unittest.TestCase):
"get_article", "get_article",
"search_articles", "search_articles",
"calculate_material_weight", "calculate_material_weight",
"calculate_product_length",
"calculate_roll_diameter", "calculate_roll_diameter",
"analyze_transport_capacity", "analyze_transport_capacity",
], ],
@@ -188,6 +208,7 @@ class McpAdapterTests(unittest.TestCase):
tools = instance._tool_manager._tools tools = instance._tool_manager._tools
search_tool = tools["search_articles"] search_tool = tools["search_articles"]
material_weight_tool = tools["calculate_material_weight"] material_weight_tool = tools["calculate_material_weight"]
product_length_tool = tools["calculate_product_length"]
roll_schema = tools["calculate_roll_diameter"].parameters roll_schema = tools["calculate_roll_diameter"].parameters
transport_tool = tools["analyze_transport_capacity"] transport_tool = tools["analyze_transport_capacity"]
transport_schema = transport_tool.parameters transport_schema = transport_tool.parameters
@@ -202,6 +223,19 @@ class McpAdapterTests(unittest.TestCase):
["roll_length_m", "width_m", "area_weight_g_m2"], ["roll_length_m", "width_m", "area_weight_g_m2"],
) )
self.assertNotIn("core_diameter_mm", material_weight_schema["properties"]) 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"]) self.assertNotIn("include_roll_weight", roll_schema["properties"])
preset_schema = transport_schema["properties"]["transport_preset"] 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("do not infer or default one", roll_description)
self.assertIn("call calculate_material_weight instead", 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+32
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_product_length,
calculate_roll, calculate_roll,
get_article, get_article,
modify_calculation, modify_calculation,
@@ -212,6 +213,37 @@ class RollCalculationTests(unittest.TestCase):
item["field"] for item in result["invalid"] 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): def test_diameter_only_calculation_does_not_require_width(self):
result = self.calculate_fixture( result = self.calculate_fixture(
{ {
+5
View File
@@ -17,6 +17,11 @@ class TemplatePdfIntegrationTests(unittest.TestCase):
self.assertIn("fetch('/api/calculations/roll'", self.template) self.assertIn("fetch('/api/calculations/roll'", self.template)
self.assertIn("calculation.average_diameter_mm", self.template) self.assertIn("calculation.average_diameter_mm", self.template)
self.assertIn("calculation.effective_roll_length_m", 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) self.assertNotIn("function calculateDiameterResult", self.template)
def test_optional_weight_requires_both_weight_inputs(self): def test_optional_weight_requires_both_weight_inputs(self):