Add machine-aware maximum product length
This commit is contained in:
@@ -139,6 +139,53 @@ class CalculationApiTests(unittest.TestCase):
|
||||
self.assertEqual(result["feasibility"], "feasible_with_warnings")
|
||||
self.assertEqual(result["warning_constraints"], ["roll_diameter_mm"])
|
||||
|
||||
def test_machine_maximum_length_endpoint_is_authenticated_and_structured(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={
|
||||
"machine": "K7",
|
||||
"core_diameter_mm": 133,
|
||||
"thickness_mm": 4.311611,
|
||||
"thickness_stddev_mm": 0.249999,
|
||||
"product_width_m": 6,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
result = response.get_json()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(
|
||||
result["production_maximums"]["nominal_governing_constraints"],
|
||||
["roll_diameter_nominal"],
|
||||
)
|
||||
|
||||
def test_machine_maximum_length_endpoint_preserves_domain_outcomes(self):
|
||||
incompatible = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={"machine": "K7", "core_diameter_mm": 194, "thickness_mm": 4},
|
||||
headers=self.headers,
|
||||
)
|
||||
missing = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={"machine": "K7", "core_diameter_mm": 133, "thickness_mm": 4},
|
||||
headers=self.headers,
|
||||
)
|
||||
invalid = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={"machine": "K7", "unexpected": 1},
|
||||
headers=self.headers,
|
||||
)
|
||||
missing_required = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={"machine": "K7", "core_diameter_mm": 133},
|
||||
headers=self.headers,
|
||||
)
|
||||
self.assertEqual(incompatible.get_json()["status"], "incompatible")
|
||||
self.assertEqual(missing.get_json()["status"], "needs_clarification")
|
||||
self.assertEqual(invalid.get_json()["status"], "invalid_parameter")
|
||||
self.assertEqual(missing_required.get_json()["status"], "invalid_parameter")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,8 +4,14 @@ from pathlib import Path
|
||||
|
||||
from machine_constraints import (
|
||||
MACHINE_CONFIG_FILE,
|
||||
MAX_LENGTH_TIE_ABS_TOLERANCE_M,
|
||||
Machine,
|
||||
MachineConfigurationError,
|
||||
MachineConstraints,
|
||||
MachineRepository,
|
||||
NumericRange,
|
||||
_governing_maximum,
|
||||
calculate_machine_max_product_length,
|
||||
check_production_feasibility,
|
||||
get_machine,
|
||||
)
|
||||
@@ -24,6 +30,38 @@ def request(**changes):
|
||||
return payload
|
||||
|
||||
|
||||
def machine_repository(
|
||||
*,
|
||||
diameter_maximum=1100.0,
|
||||
diameter_minimum=200.0,
|
||||
weight_maximum=700.0,
|
||||
width_limits=(4.0, 6.1),
|
||||
allowed_cores=(133.0,),
|
||||
):
|
||||
diameter = (
|
||||
NumericRange(minimum=diameter_minimum, maximum=diameter_maximum)
|
||||
if diameter_maximum is not None else None
|
||||
)
|
||||
weight = NumericRange(maximum=weight_maximum) if weight_maximum is not None else None
|
||||
width = (
|
||||
NumericRange(minimum=width_limits[0], maximum=width_limits[1])
|
||||
if width_limits is not None else None
|
||||
)
|
||||
return MachineRepository((Machine(
|
||||
id="test_machine",
|
||||
name="Test Machine",
|
||||
aliases=(),
|
||||
location="Test",
|
||||
constraints=MachineConstraints(
|
||||
roll_weight_kg=weight,
|
||||
roll_diameter_mm=diameter,
|
||||
allowed_core_diameters_mm=allowed_cores,
|
||||
product_width_m=width,
|
||||
line_speed_m_per_min=None,
|
||||
),
|
||||
),))
|
||||
|
||||
|
||||
class MachineRepositoryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = MachineRepository.load()
|
||||
@@ -163,3 +201,129 @@ class MachineFeasibilityTests(unittest.TestCase):
|
||||
self.assertEqual(check_production_feasibility({"machine": "B1", "unknown": 1})["status"], "invalid_parameter")
|
||||
self.assertEqual(check_production_feasibility({"machine": "B1", "core_weight_kg": 10})["status"], "invalid_parameter")
|
||||
self.assertEqual(self.check(machine="not a machine")["status"], "machine_not_found")
|
||||
|
||||
|
||||
class MachineMaximumProductLengthTests(unittest.TestCase):
|
||||
def calculate(self, repository=None, **changes):
|
||||
values = {
|
||||
"machine": "test_machine",
|
||||
"core_diameter_mm": 133,
|
||||
"thickness_mm": 4.311611,
|
||||
"thickness_stddev_mm": 0.249999,
|
||||
"product_width_m": 6,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
"repository": repository or machine_repository(),
|
||||
}
|
||||
values.update(changes)
|
||||
return calculate_machine_max_product_length(**values)
|
||||
|
||||
def test_characterizes_article_218500_on_k7(self):
|
||||
result = calculate_machine_max_product_length(
|
||||
machine="K7",
|
||||
core_diameter_mm=133,
|
||||
thickness_mm=4.311611,
|
||||
thickness_stddev_mm=0.249999,
|
||||
product_width_m=6,
|
||||
area_weight_g_m2=495.395833,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertAlmostEqual(result["limits"]["nominal_diameter_length_m"], 217.1900177448)
|
||||
self.assertAlmostEqual(result["limits"]["conservative_diameter_length_m"], 194.6207328149)
|
||||
self.assertAlmostEqual(result["limits"]["material_weight_length_m"], 235.5019136115)
|
||||
self.assertEqual(result["production_maximums"]["nominal_governing_constraints"], ["roll_diameter_nominal"])
|
||||
self.assertEqual(result["production_maximums"]["conservative_governing_constraints"], ["roll_diameter_conservative"])
|
||||
|
||||
def test_stddev_controls_nominal_and_conservative_diameter_limits(self):
|
||||
zero = self.calculate(thickness_stddev_mm=0)
|
||||
varied = self.calculate()
|
||||
self.assertEqual(
|
||||
zero["limits"]["nominal_diameter_length_m"],
|
||||
zero["limits"]["conservative_diameter_length_m"],
|
||||
)
|
||||
self.assertLess(
|
||||
varied["limits"]["conservative_diameter_length_m"],
|
||||
varied["limits"]["nominal_diameter_length_m"],
|
||||
)
|
||||
invalid = self.calculate(thickness_stddev_mm=3)
|
||||
self.assertEqual(invalid["status"], "invalid_parameter")
|
||||
|
||||
def test_weight_can_govern_one_or_both_production_maximums(self):
|
||||
one = self.calculate(repository=machine_repository(weight_maximum=600))
|
||||
both = self.calculate(repository=machine_repository(weight_maximum=500))
|
||||
self.assertEqual(one["production_maximums"]["nominal_governing_constraints"], ["material_weight"])
|
||||
self.assertEqual(one["production_maximums"]["conservative_governing_constraints"], ["roll_diameter_conservative"])
|
||||
self.assertEqual(both["production_maximums"]["nominal_governing_constraints"], ["material_weight"])
|
||||
self.assertEqual(both["production_maximums"]["conservative_governing_constraints"], ["material_weight"])
|
||||
|
||||
def test_ties_use_documented_tolerance_and_canonical_order(self):
|
||||
value, constraints = _governing_maximum((
|
||||
("roll_diameter_nominal", 100.0),
|
||||
("material_weight", 100.0 + MAX_LENGTH_TIE_ABS_TOLERANCE_M / 2),
|
||||
))
|
||||
self.assertEqual(value, 100.0)
|
||||
self.assertEqual(constraints, ["roll_diameter_nominal", "material_weight"])
|
||||
_, constraints = _governing_maximum((
|
||||
("roll_diameter_nominal", 100.0),
|
||||
("material_weight", 100.0 + MAX_LENGTH_TIE_ABS_TOLERANCE_M * 2),
|
||||
))
|
||||
self.assertEqual(constraints, ["roll_diameter_nominal"])
|
||||
|
||||
def test_core_and_width_incompatibilities_never_claim_a_length(self):
|
||||
core = self.calculate(core_diameter_mm=130)
|
||||
width = self.calculate(product_width_m=3.9)
|
||||
self.assertEqual(core["status"], "incompatible")
|
||||
self.assertEqual(core["production_maximums"], None)
|
||||
self.assertEqual(core["failed_compatibility_constraints"], ["allowed_core_diameters_mm"])
|
||||
self.assertEqual(width["status"], "incompatible")
|
||||
self.assertEqual(width["production_maximums"], None)
|
||||
self.assertEqual(width["failed_compatibility_constraints"], ["product_width_m"])
|
||||
|
||||
def test_width_boundaries_and_missing_inputs_are_structured(self):
|
||||
repository = machine_repository(width_limits=(4, 6))
|
||||
self.assertEqual(self.calculate(repository=repository, product_width_m=4)["status"], "success")
|
||||
self.assertEqual(self.calculate(repository=repository, product_width_m=6)["status"], "success")
|
||||
missing = self.calculate(product_width_m=None, area_weight_g_m2=None)
|
||||
self.assertEqual(missing["status"], "needs_clarification")
|
||||
self.assertEqual(missing["missing_required_inputs"], ["product_width_m", "area_weight_g_m2"])
|
||||
self.assertIsNotNone(missing["partial_limits"])
|
||||
self.assertIsNone(missing["production_maximums"])
|
||||
|
||||
def test_exact_width_machine_is_checked_before_length_calculation(self):
|
||||
values = {
|
||||
"machine": "E1",
|
||||
"core_diameter_mm": 150,
|
||||
"thickness_mm": 4.311611,
|
||||
"product_width_m": 7.5,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
}
|
||||
self.assertEqual(calculate_machine_max_product_length(**values)["status"], "success")
|
||||
values["product_width_m"] = 7.5001
|
||||
result = calculate_machine_max_product_length(**values)
|
||||
self.assertEqual(result["status"], "incompatible")
|
||||
self.assertIsNone(result["production_maximums"])
|
||||
|
||||
def test_unconfigured_limiters_and_minimum_diameter_verification(self):
|
||||
no_weight = self.calculate(repository=machine_repository(weight_maximum=None))
|
||||
self.assertEqual(no_weight["limits"]["material_weight_maximum_state"], "not_configured")
|
||||
no_diameter = self.calculate(repository=machine_repository(diameter_maximum=None))
|
||||
self.assertEqual(no_diameter["limits"]["roll_diameter_maximum_state"], "not_configured")
|
||||
self.assertEqual(
|
||||
no_diameter["production_maximums"]["nominal_governing_constraints"],
|
||||
["material_weight"],
|
||||
)
|
||||
no_limits = self.calculate(repository=machine_repository(diameter_maximum=None, weight_maximum=None, width_limits=None))
|
||||
self.assertEqual(no_limits["status"], "no_maximum_length_constraint")
|
||||
no_limits_without_article_size = self.calculate(
|
||||
repository=machine_repository(
|
||||
diameter_maximum=None, weight_maximum=None, width_limits=None
|
||||
),
|
||||
product_width_m=None,
|
||||
area_weight_g_m2=None,
|
||||
)
|
||||
self.assertEqual(
|
||||
no_limits_without_article_size["status"], "no_maximum_length_constraint"
|
||||
)
|
||||
low_weight = self.calculate(repository=machine_repository(weight_maximum=10, diameter_minimum=800))
|
||||
self.assertEqual(low_weight["status"], "incompatible")
|
||||
self.assertIsNone(low_weight["production_maximums"])
|
||||
|
||||
@@ -14,6 +14,7 @@ from roll_calculation import (
|
||||
)
|
||||
from rollcalc_mcp_tools import (
|
||||
analyze_transport_capacity_result,
|
||||
calculate_machine_max_product_length_result,
|
||||
check_production_feasibility_result,
|
||||
calculate_material_weight_result,
|
||||
calculate_product_length_result,
|
||||
@@ -22,7 +23,11 @@ from rollcalc_mcp_tools import (
|
||||
get_machine_result,
|
||||
search_articles_result,
|
||||
)
|
||||
from machine_constraints import check_production_feasibility, get_machine
|
||||
from machine_constraints import (
|
||||
calculate_machine_max_product_length,
|
||||
check_production_feasibility,
|
||||
get_machine,
|
||||
)
|
||||
from transport_calculation import analyze_transport, transport_presets
|
||||
|
||||
|
||||
@@ -98,6 +103,20 @@ class McpAdapterTests(unittest.TestCase):
|
||||
"feasible_with_warnings",
|
||||
)
|
||||
|
||||
def test_machine_maximum_length_adapter_delegates_to_shared_domain(self):
|
||||
arguments = {
|
||||
"machine": "K7",
|
||||
"core_diameter_mm": 133.0,
|
||||
"thickness_mm": 4.311611,
|
||||
"thickness_stddev_mm": 0.249999,
|
||||
"product_width_m": 6.0,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
}
|
||||
self.assertEqual(
|
||||
calculate_machine_max_product_length_result(**arguments),
|
||||
calculate_machine_max_product_length(**arguments),
|
||||
)
|
||||
|
||||
def test_material_weight_adapter_delegates_without_a_core_diameter(self):
|
||||
arguments = {
|
||||
"roll_length_m": 60.0,
|
||||
@@ -220,6 +239,7 @@ class McpAdapterTests(unittest.TestCase):
|
||||
"search_articles",
|
||||
"get_machine",
|
||||
"check_production_feasibility",
|
||||
"calculate_machine_max_product_length",
|
||||
"calculate_material_weight",
|
||||
"calculate_product_length",
|
||||
"calculate_roll_diameter",
|
||||
@@ -233,6 +253,7 @@ class McpAdapterTests(unittest.TestCase):
|
||||
search_tool = tools["search_articles"]
|
||||
machine_tool = tools["get_machine"]
|
||||
feasibility_tool = tools["check_production_feasibility"]
|
||||
maximum_tool = tools["calculate_machine_max_product_length"]
|
||||
material_weight_tool = tools["calculate_material_weight"]
|
||||
product_length_tool = tools["calculate_product_length"]
|
||||
roll_schema = tools["calculate_roll_diameter"].parameters
|
||||
@@ -250,6 +271,21 @@ class McpAdapterTests(unittest.TestCase):
|
||||
self.assertIn("never override failed constraints", feasibility_tool.description)
|
||||
self.assertIn("warnings", feasibility_tool.description)
|
||||
self.assertIn("Missing configured V1 roll inputs", feasibility_tool.description)
|
||||
self.assertEqual(
|
||||
maximum_tool.parameters["required"],
|
||||
["machine", "core_diameter_mm", "thickness_mm"],
|
||||
)
|
||||
self.assertIn("product_width_m", maximum_tool.parameters["properties"])
|
||||
self.assertIn("area_weight_g_m2", maximum_tool.parameters["properties"])
|
||||
for text in (
|
||||
"Resolve the article first with search_articles/get_article",
|
||||
"Do not supply or invent machine limits",
|
||||
"rearrange roll or weight formulas",
|
||||
"compare candidate limits yourself",
|
||||
"nominal and conservative/no-warning production maxima",
|
||||
"incompatibility rather than substituting another core or width",
|
||||
):
|
||||
self.assertIn(text, maximum_tool.description)
|
||||
self.assertEqual(
|
||||
search_tool.parameters["properties"]["query"]["type"], "string"
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from roll_calculation import (
|
||||
_canonical_name,
|
||||
article_production_site,
|
||||
calculate_material_weight,
|
||||
calculate_material_length_for_weight,
|
||||
calculate_product_length,
|
||||
calculate_roll,
|
||||
get_article,
|
||||
@@ -196,6 +197,41 @@ class RollCalculationTests(unittest.TestCase):
|
||||
self.assertAlmostEqual(result["material_weight_kg"], 1455.0239028)
|
||||
self.assertNotIn("core_diameter_mm", result["inputs"])
|
||||
|
||||
def test_material_length_for_weight_is_material_only_and_round_trips(self):
|
||||
inverse = calculate_material_length_for_weight(
|
||||
target_material_weight_kg=700,
|
||||
width_m=6,
|
||||
area_weight_g_m2=495.395833,
|
||||
)
|
||||
|
||||
self.assertEqual(inverse["status"], "success")
|
||||
self.assertEqual(inverse["weight_scope"], "material_only")
|
||||
self.assertNotIn("core_diameter_mm", inverse["inputs"])
|
||||
round_trip = calculate_material_weight(
|
||||
roll_length_m=inverse["material_length_m"],
|
||||
width_m=6,
|
||||
area_weight_g_m2=495.395833,
|
||||
)
|
||||
self.assertAlmostEqual(round_trip["material_weight_kg"], 700)
|
||||
|
||||
def test_material_length_for_weight_rejects_non_positive_inputs(self):
|
||||
for changes in (
|
||||
{"target_material_weight_kg": 0},
|
||||
{"width_m": 0},
|
||||
{"area_weight_g_m2": 0},
|
||||
):
|
||||
with self.subTest(changes=changes):
|
||||
values = {
|
||||
"target_material_weight_kg": 700,
|
||||
"width_m": 6,
|
||||
"area_weight_g_m2": 495,
|
||||
}
|
||||
values.update(changes)
|
||||
self.assertEqual(
|
||||
calculate_material_length_for_weight(**values)["status"],
|
||||
"invalid_parameter",
|
||||
)
|
||||
|
||||
def test_roll_diameter_still_rejects_a_zero_core_diameter(self):
|
||||
result = calculate_roll(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user