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
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.
+39 -1
View File
@@ -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():
+20
View File
@@ -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,
+123
View File
@@ -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)
+21
View File
@@ -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,
+37 -2
View File
@@ -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;
+21
View File
@@ -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,
+44
View File
@@ -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()
+32
View File
@@ -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(
{
+5
View File
@@ -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):