Expose product length calculation through MCP
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user