330 lines
16 KiB
Python
330 lines
16 KiB
Python
import tempfile
|
|
import unittest
|
|
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,
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
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()
|
|
|
|
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")
|
|
|
|
|
|
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"])
|