Add configurable production machine constraints

This commit is contained in:
2026-09-26 10:47:53 +02:00
parent e6a7d4da72
commit 3e13d24405
12 changed files with 904 additions and 3 deletions
+19
View File
@@ -120,6 +120,25 @@ class CalculationApiTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()["status"], "invalid_parameter")
def test_production_feasibility_endpoint_uses_shared_domain(self):
response = self.client.post(
"/api/calculations/production-feasibility",
json={
"machine": "B1",
"roll_weight_kg": 2500,
"average_diameter_mm": 876,
"maximum_diameter_mm": 877,
"core_diameter_mm": 168,
"product_width_m": 5,
},
headers=self.headers,
)
result = response.get_json()
self.assertEqual(response.status_code, 200)
self.assertEqual(result["feasibility"], "feasible_with_warnings")
self.assertEqual(result["warning_constraints"], ["roll_diameter_mm"])
if __name__ == "__main__":
unittest.main()
+165
View File
@@ -0,0 +1,165 @@
import tempfile
import unittest
from pathlib import Path
from machine_constraints import (
MACHINE_CONFIG_FILE,
MachineConfigurationError,
MachineRepository,
check_production_feasibility,
get_machine,
)
def request(**changes):
payload = {
"machine": "bento_1",
"roll_weight_kg": 2500.0,
"average_diameter_mm": 800.0,
"maximum_diameter_mm": 850.0,
"core_diameter_mm": 168.0,
"product_width_m": 4.8,
}
payload.update(changes)
return payload
class MachineRepositoryTests(unittest.TestCase):
def setUp(self):
self.repository = MachineRepository.load()
def test_yaml_load_represents_all_source_machines_and_bento_transformation(self):
self.assertTrue(MACHINE_CONFIG_FILE.exists())
self.assertEqual(len(self.repository.machines), 15)
self.assertEqual(
{machine.name for machine in self.repository.machines},
{
"Bento 1", "Bento 2", "K1", "K3/4", "K5", "K6", "K7",
"E1", "E4", "Gitter 1", "Gitter 2", "Gitter 3", "Gitter 4",
"Secudrain 1/2", "Secudrain 3",
},
)
for identifier in ("bento_1", "bento_2"):
machine = self.repository.resolve(identifier)["resolved_machine"]
self.assertEqual(machine.constraints.roll_weight_kg.maximum, 2500.0)
def test_blank_source_line_speed_is_explicitly_not_configured(self):
machine = self.repository.resolve("Gitter 1")["machine"]
self.assertIsNone(machine["constraints"]["line_speed_m_per_min"])
def test_lookup_uses_ids_names_aliases_and_harmless_normalization(self):
for value, matched_by in (
("bento_1", "id"), (" BENTO 1 ", "name"), (" b1 ", "alias"),
):
with self.subTest(value=value):
result = self.repository.resolve(value)
self.assertEqual(result["status"], "resolved")
self.assertEqual(result["matched_by"], matched_by)
self.assertEqual(result["machine"]["id"], "bento_1")
self.assertEqual(self.repository.resolve("bento") ["status"], "machine_not_found")
def test_get_machine_returns_public_configured_data(self):
result = get_machine("K 3/4", repository=self.repository)
self.assertEqual(result["status"], "resolved")
self.assertNotIn("resolved_machine", result)
self.assertEqual(result["machine"]["id"], "k_3_4")
def config_error(self, body):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "machines.yaml"
path.write_text(body, encoding="utf-8")
with self.assertRaises(MachineConfigurationError):
MachineRepository.load(path)
def test_configuration_validation_rejects_malformed_and_unsupported_versions(self):
self.config_error("machines: [")
self.config_error("schema_version: 2\nmachines: []\n")
self.config_error("schema_version: 1\nunknown: true\nmachines: []\n")
def test_configuration_validation_rejects_invalid_machine_data(self):
valid = """
schema_version: 1
machines:
- id: alpha
name: Alpha
aliases: [A]
location: Here
constraints:
roll_weight_kg: {maximum: 3}
roll_diameter_mm: {minimum: 1, maximum: 2}
allowed_core_diameters_mm: [150]
product_width_m: {minimum: 1, maximum: 2}
line_speed_m_per_min: null
"""
self.config_error(valid.replace("minimum: 1, maximum: 2", "minimum: 2, maximum: 1"))
self.config_error(valid.replace("[150]", "[0]"))
self.config_error(valid.replace("aliases: [A]", "aliases: bad"))
self.config_error(valid.replace("line_speed_m_per_min: null", "unsupported: true"))
self.config_error(valid.replace("[150]", "[not-a-number]"))
duplicate_row = valid.split(" - id: alpha", 1)[1].replace(
"name: Alpha", "name: Beta", 1
)
duplicate = valid + "\n - id: beta" + duplicate_row
self.config_error(duplicate)
class MachineFeasibilityTests(unittest.TestCase):
def check(self, **changes):
return check_production_feasibility(request(**changes))
def test_inclusive_weight_and_range_boundaries(self):
self.assertEqual(self.check()["feasibility"], "feasible")
self.assertEqual(self.check(roll_weight_kg=2500.0001)["feasibility"], "not_feasible")
self.assertEqual(self.check(average_diameter_mm=300, maximum_diameter_mm=300)["feasibility"], "feasible")
self.assertEqual(self.check(average_diameter_mm=876, maximum_diameter_mm=876)["feasibility"], "feasible")
self.assertEqual(self.check(average_diameter_mm=299, maximum_diameter_mm=299)["feasibility"], "not_feasible")
def test_exact_width_machines_do_not_have_an_implicit_tolerance(self):
exact = self.check(machine="e1", product_width_m=7.5, average_diameter_mm=900, maximum_diameter_mm=900)
low = self.check(machine="e1", product_width_m=7.4999, average_diameter_mm=900, maximum_diameter_mm=900)
high = self.check(machine="e1", product_width_m=7.5001, average_diameter_mm=900, maximum_diameter_mm=900)
self.assertEqual(exact["feasibility"], "feasible")
self.assertIn("product_width_m", low["failed_constraints"])
self.assertIn("product_width_m", high["failed_constraints"])
def test_configured_core_equivalents_are_exact_membership_only(self):
for core in (130, 133, 150, 168, 170):
with self.subTest(core=core):
self.assertEqual(self.check(machine="k1", roll_weight_kg=800, core_diameter_mm=core)["feasibility"], "feasible")
failed = self.check(machine="k1", roll_weight_kg=800, core_diameter_mm=169.999)
self.assertIn("allowed_core_diameters_mm", failed["failed_constraints"])
def test_diameter_distinguishes_pass_warning_and_failure(self):
passed = self.check(average_diameter_mm=800, maximum_diameter_mm=850)
warning = self.check(average_diameter_mm=876, maximum_diameter_mm=876.1)
failed = self.check(average_diameter_mm=876.1, maximum_diameter_mm=900)
self.assertEqual(passed["feasibility"], "feasible")
self.assertEqual(warning["feasibility"], "feasible_with_warnings")
self.assertEqual(warning["warning_constraints"], ["roll_diameter_mm"])
self.assertEqual(failed["feasibility"], "not_feasible")
self.assertIn("roll_diameter_mm", failed["failed_constraints"])
def test_failures_and_warnings_are_all_retained(self):
result = self.check(roll_weight_kg=3000, average_diameter_mm=800, maximum_diameter_mm=900, core_diameter_mm=151, product_width_m=4.0)
self.assertEqual(result["feasibility"], "not_feasible")
self.assertEqual(result["warning_constraints"], ["roll_diameter_mm"])
self.assertEqual(set(result["failed_constraints"]), {"roll_weight_kg", "allowed_core_diameters_mm", "product_width_m"})
def test_missing_values_keep_evaluable_checks_but_never_claim_feasible(self):
result = self.check(roll_weight_kg=None, maximum_diameter_mm=None, core_diameter_mm=None)
self.assertEqual(result["status"], "needs_clarification")
self.assertIsNone(result["feasible"])
self.assertEqual(set(result["missing_required_inputs"]), {"roll_weight_kg", "maximum_diameter_mm", "core_diameter_mm"})
self.assertIn("product_width_m", result["passed_constraints"])
def test_line_speed_is_not_required_or_defaulted_in_v1(self):
result = self.check(machine="gitter_1", roll_weight_kg=400, average_diameter_mm=600, maximum_diameter_mm=600, core_diameter_mm=140, product_width_m=4.0)
line_speed = result["checks"][-1]
self.assertEqual(result["feasibility"], "feasible")
self.assertEqual(line_speed["state"], "not_evaluated")
self.assertIsNone(line_speed["limits"])
def test_invalid_and_unknown_requests_are_structured(self):
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")
+36
View File
@@ -14,12 +14,15 @@ from roll_calculation import (
)
from rollcalc_mcp_tools import (
analyze_transport_capacity_result,
check_production_feasibility_result,
calculate_material_weight_result,
calculate_product_length_result,
calculate_roll_diameter_result,
get_article_result,
get_machine_result,
search_articles_result,
)
from machine_constraints import check_production_feasibility, get_machine
from transport_calculation import analyze_transport, transport_presets
@@ -76,6 +79,25 @@ class McpAdapterTests(unittest.TestCase):
"candidates": [],
})
def test_machine_adapters_delegate_to_the_shared_domain(self):
arguments = {
"machine": "B1",
"roll_weight_kg": 2500.0,
"average_diameter_mm": 876.0,
"maximum_diameter_mm": 876.1,
"core_diameter_mm": 168.0,
"product_width_m": 5.0,
}
self.assertEqual(get_machine_result("B1"), get_machine("B1"))
self.assertEqual(
check_production_feasibility_result(**arguments),
check_production_feasibility(arguments),
)
self.assertEqual(
check_production_feasibility_result(**arguments)["feasibility"],
"feasible_with_warnings",
)
def test_material_weight_adapter_delegates_without_a_core_diameter(self):
arguments = {
"roll_length_m": 60.0,
@@ -196,6 +218,8 @@ class McpAdapterTests(unittest.TestCase):
[
"get_article",
"search_articles",
"get_machine",
"check_production_feasibility",
"calculate_material_weight",
"calculate_product_length",
"calculate_roll_diameter",
@@ -207,6 +231,8 @@ class McpAdapterTests(unittest.TestCase):
instance = mcp_server.create_server()
tools = instance._tool_manager._tools
search_tool = tools["search_articles"]
machine_tool = tools["get_machine"]
feasibility_tool = tools["check_production_feasibility"]
material_weight_tool = tools["calculate_material_weight"]
product_length_tool = tools["calculate_product_length"]
roll_schema = tools["calculate_roll_diameter"].parameters
@@ -214,6 +240,16 @@ class McpAdapterTests(unittest.TestCase):
transport_schema = transport_tool.parameters
self.assertEqual(search_tool.parameters["required"], ["query"])
self.assertEqual(machine_tool.parameters["required"], ["machine"])
self.assertEqual(
feasibility_tool.parameters["required"], ["machine"]
)
self.assertIn("average_diameter_mm", feasibility_tool.parameters["properties"])
self.assertIn("maximum_diameter_mm", feasibility_tool.parameters["properties"])
self.assertIn("tool, not the LLM, decides", feasibility_tool.description)
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(
search_tool.parameters["properties"]["query"]["type"], "string"
)