Files
cadSet/SimpleCADAPI/test/test_units.py

535 lines
23 KiB
Python

"""Complete coverage for physical units and dimensional expression inference."""
from __future__ import annotations
import json
import math
import unittest
import simplecadapi as scad
from simplecadapi.units import _infer, unit_to_payload
class TestDimensionsAndUnits(unittest.TestCase):
def test_named_dimensions_and_dimension_algebra(self):
self.assertEqual(scad.DIMENSIONLESS.name, "Dimensionless")
self.assertEqual(scad.LENGTH.name, "Length")
self.assertEqual(scad.AREA.name, "Area")
self.assertEqual(scad.VOLUME.name, "Volume")
self.assertEqual(scad.ANGLE.name, "Angle")
self.assertEqual(scad.LENGTH.multiply(scad.LENGTH), scad.AREA)
self.assertEqual(scad.VOLUME.divide(scad.LENGTH), scad.AREA)
self.assertEqual(scad.LENGTH.power(3), scad.VOLUME)
self.assertEqual(scad.AREA.square_root(), scad.LENGTH)
self.assertEqual(scad.Dimension(length=-1, angle=2).symbol, "L^-1 A^2")
self.assertTrue(scad.DIMENSIONLESS.is_dimensionless)
self.assertTrue(scad.LENGTH.is_design_dimension)
self.assertTrue(scad.ANGLE.is_design_dimension)
self.assertFalse(scad.AREA.is_design_dimension)
def test_dimension_serialization_and_validation(self):
self.assertEqual(
scad.Dimension.from_dict(scad.VOLUME.to_dict()), scad.VOLUME
)
invalid = [
None,
{},
{"length": 1},
{"length": True, "angle": 0},
{"length": 1, "angle": 0.5},
]
for payload in invalid:
with self.subTest(payload=payload), self.assertRaises(
(TypeError, ValueError)
):
scad.Dimension.from_dict(payload)
with self.assertRaises(TypeError):
scad.LENGTH.power(0.5)
with self.assertRaises(scad.UnitValidationError):
scad.LENGTH.square_root()
def test_every_registered_unit_converts_to_canonical_units(self):
conversions = [
(scad.MM, 1.0),
(scad.CM, 10.0),
(scad.M, 1000.0),
(scad.INCH, 25.4),
(scad.FOOT, 304.8),
(scad.DEGREE, 1.0),
(scad.RADIAN, 180.0 / math.pi),
(scad.ONE, 1.0),
(scad.PERCENT, 0.01),
(scad.SQUARE_MM, 1.0),
(scad.SQUARE_CM, 100.0),
(scad.SQUARE_M, 1_000_000.0),
(scad.SQUARE_INCH, 25.4**2),
(scad.SQUARE_FOOT, 304.8**2),
(scad.CUBIC_MM, 1.0),
(scad.CUBIC_CM, 1000.0),
(scad.CUBIC_M, 1_000_000_000.0),
(scad.CUBIC_INCH, 25.4**3),
(scad.CUBIC_FOOT, 304.8**3),
]
for unit, expected in conversions:
with self.subTest(unit=unit.symbol):
self.assertAlmostEqual(unit.to_canonical(1.0), expected)
self.assertAlmostEqual(unit.from_canonical(expected), 1.0)
self.assertIs(scad.get_unit(unit), unit)
self.assertEqual(scad.get_unit(unit.symbol), unit)
self.assertEqual(
scad.canonical_unit_for_dimension(unit.dimension).dimension,
unit.dimension,
)
def test_unit_aliases_and_conversion(self):
aliases = {
"millimeters": scad.MM,
"centimeter": scad.CM,
"meters": scad.M,
"inches": scad.INCH,
"feet": scad.FOOT,
"degrees": scad.DEGREE,
"radians": scad.RADIAN,
"percent": scad.PERCENT,
"square feet": scad.SQUARE_FOOT,
"cubic inches": scad.CUBIC_INCH,
"ml": scad.CUBIC_CM,
}
for alias, unit in aliases.items():
with self.subTest(alias=alias):
self.assertEqual(scad.get_unit(alias), unit)
self.assertAlmostEqual(scad.convert_value(1.0, "in", "mm"), 25.4)
self.assertAlmostEqual(scad.convert_value(180.0, "deg", "rad"), math.pi)
self.assertAlmostEqual(scad.convert_value(1.0, "ft^2", "in^2"), 144.0)
with self.assertRaises(scad.UnitValidationError):
scad.convert_value(1.0, "mm", "deg")
with self.assertRaisesRegex(ValueError, "Unknown unit"):
scad.get_unit("parsec")
def test_unit_validation_and_custom_unit_roundtrip(self):
invalid_calls = [
lambda: scad.Unit("", scad.LENGTH, 1.0),
lambda: scad.Unit("bad", "Length", 1.0),
lambda: scad.Unit("bad", scad.LENGTH, True),
lambda: scad.Unit("bad", scad.LENGTH, 0.0),
lambda: scad.Unit("bad", scad.LENGTH, math.inf),
lambda: scad.MM.to_canonical(math.inf),
lambda: scad.MM.to_canonical(True),
lambda: scad.M.to_canonical(1e308),
lambda: scad.Unit("tiny", scad.LENGTH, 5e-324).to_canonical(0.5),
lambda: scad.Unit("tiny", scad.LENGTH, 5e-324).from_canonical(1.0),
lambda: scad.Unit("huge", scad.LENGTH, 1e308).from_canonical(5e-324),
lambda: scad.MM.to_canonical(10**10000),
lambda: scad.get_unit(None),
lambda: scad.get_unit(""),
lambda: scad.Unit.from_dict(None),
lambda: unit_to_payload(None),
]
for call in invalid_calls:
with self.subTest(call=call), self.assertRaises((TypeError, ValueError)):
call()
thou = scad.Unit("thou", scad.LENGTH, 0.0254)
width = scad.var(
"width", 1000.0, unit=thou, tolerance=2.0, tolerance_unit=thou
)
graph = scad.ExpressionGraph()
graph.register(width)
payload = graph.to_dict()
self.assertIsInstance(payload["nodes"][0]["unit"], dict)
rebuilt = scad.ExpressionGraph.from_dict(payload).get(width.expr_id)
self.assertIsInstance(rebuilt, scad.Var)
self.assertEqual(rebuilt.unit, thou)
self.assertEqual(rebuilt.tolerance_unit, thou)
self.assertAlmostEqual(rebuilt.evaluate(), 25.4)
self.assertAlmostEqual(rebuilt.canonical_tolerance.upper_deviation, 0.0508)
def test_unknown_compound_dimension_uses_a_canonical_scale_one_unit(self):
dimension = scad.Dimension(length=-1, angle=2)
unit = scad.canonical_unit_for_dimension(dimension)
self.assertEqual(unit.symbol, "L^-1 A^2")
self.assertEqual(unit.dimension, dimension)
self.assertEqual(unit.scale_to_canonical, 1.0)
def test_malformed_custom_unit_payloads_are_rejected(self):
base = {
"nodes": [
{
"expr_id": "var_x",
"kind": "var",
"name": "x",
"default": 1.0,
"unit": {
"symbol": "custom",
"dimension": {"length": 1, "angle": 0},
"scale_to_canonical": 2.0,
},
}
]
}
for field in ("symbol", "dimension", "scale_to_canonical"):
payload = json.loads(json.dumps(base))
del payload["nodes"][0]["unit"][field]
with self.subTest(field=field), self.assertRaises(ValueError):
scad.ExpressionGraph.from_dict(payload)
class TestUnitAwareVariables(unittest.TestCase):
def test_nominal_and_tolerance_units_are_canonicalized_independently(self):
width = scad.var(
"width",
1.0,
unit="in",
tolerance=(-0.1, 0.2),
tolerance_unit="mm",
)
inherited = scad.var("inherited", 1.0, unit="in", tolerance=0.01)
self.assertEqual(width.default, 1.0)
self.assertEqual(width.tolerance, scad.DimensionTolerance(-0.1, 0.2))
self.assertEqual(width.unit, scad.INCH)
self.assertEqual(width.tolerance_unit, scad.MM)
self.assertAlmostEqual(width.canonical_default, 25.4)
self.assertEqual(
width.canonical_tolerance, scad.DimensionTolerance(-0.1, 0.2)
)
self.assertEqual(inherited.tolerance_unit, scad.INCH)
self.assertAlmostEqual(
inherited.canonical_tolerance.upper_deviation, 0.254
)
def test_bindings_use_the_variable_declaration_unit(self):
width = scad.var("width", 1.0, unit="in")
self.assertAlmostEqual(width.evaluate({"width": 2.0}), 50.8)
self.assertAlmostEqual((width + 1.0).evaluate({"width": 2.0}), 51.8)
def test_invalid_variable_unit_combinations_are_rejected(self):
invalid_calls = [
lambda: scad.var("x", 1.0, tolerance=0.1, tolerance_unit="mm"),
lambda: scad.var("x", 1.0, unit="mm", tolerance_unit="mm"),
lambda: scad.var(
"x", 1.0, unit="mm", tolerance=0.1, tolerance_unit="deg"
),
lambda: scad.var("x", 1.0, unit="unknown"),
lambda: scad.var("x", 1e308, unit="m"),
lambda: scad.var("x", 1.0, unit="mm", tolerance=1e308, tolerance_unit="m"),
]
for call in invalid_calls:
with self.subTest(call=call), self.assertRaises((TypeError, ValueError)):
call()
def test_expression_graph_roundtrip_preserves_registered_units(self):
angle = scad.var(
"angle", math.pi / 2.0, unit="rad", tolerance=0.5, tolerance_unit="deg"
)
expression = scad.sin(angle)
graph = scad.ExpressionGraph()
graph.register(expression)
payload = graph.to_dict()
rebuilt = scad.ExpressionGraph.from_dict(payload)
rebuilt_angle = rebuilt.get(angle.expr_id)
self.assertEqual(rebuilt_angle.unit, scad.RADIAN)
self.assertEqual(rebuilt_angle.tolerance_unit, scad.DEGREE)
self.assertAlmostEqual(rebuilt.get(expression.expr_id).evaluate(), 1.0)
def test_geometry_parameters_use_canonical_cad_values(self):
width = scad.var("width", 1.0, unit="in")
with scad.GraphSession() as session:
scad.make_box_rsolid(width, 2.0, 3.0)
node = session.graph.topological_order()[0]
self.assertAlmostEqual(node.params["width"], 25.4)
self.assertEqual(node.param_exprs["width"]["expr_id"], width.expr_id)
class TestDimensionInference(unittest.TestCase):
def setUp(self):
self.length = scad.var("length", 3.0, unit="mm")
self.other_length = scad.var("other_length", 4.0, unit="cm")
self.angle = scad.var("angle", 90.0, unit="deg")
self.scalar = scad.var("scalar", 50.0, unit="%")
def test_addition_subtraction_and_contextual_constants(self):
self.assertEqual(scad.infer_dimension(self.length + self.other_length), scad.LENGTH)
self.assertEqual(scad.infer_dimension(self.length - 1.0), scad.LENGTH)
self.assertEqual(scad.infer_dimension(1.0 + self.angle), scad.ANGLE)
self.assertAlmostEqual((self.length + self.other_length).evaluate(), 43.0)
def test_multiplication_division_and_powers(self):
area = self.length * self.other_length
volume = area * self.length
ratio = self.length / self.other_length
inverse = 1.0 / self.length
self.assertEqual(scad.infer_dimension(area), scad.AREA)
self.assertEqual(scad.infer_dimension(volume), scad.VOLUME)
self.assertEqual(scad.infer_dimension(ratio), scad.DIMENSIONLESS)
self.assertEqual(scad.infer_dimension(inverse), scad.Dimension(length=-1))
self.assertEqual(scad.infer_dimension(self.length**3), scad.VOLUME)
self.assertEqual(scad.infer_dimension(self.length**0), scad.DIMENSIONLESS)
self.assertEqual(
scad.infer_dimension(self.scalar ** scad.var("power", 2.0, unit="1")),
scad.DIMENSIONLESS,
)
def test_square_root_requires_even_dimension_exponents(self):
diagonal = scad.sqrt(self.length**2 + self.other_length**2)
self.assertEqual(scad.infer_dimension(diagonal), scad.LENGTH)
self.assertAlmostEqual(diagonal.evaluate(), math.sqrt(1609.0))
self.assertEqual(scad.infer_dimension(scad.sqrt(self.scalar)), scad.DIMENSIONLESS)
with self.assertRaises(scad.UnitValidationError):
scad.infer_dimension(scad.sqrt(self.length))
with self.assertRaises(scad.UnitValidationError):
scad.infer_dimension(self.length**0.5)
def test_unary_and_trigonometric_dimensions(self):
self.assertEqual(scad.infer_dimension(-self.length), scad.LENGTH)
self.assertEqual(scad.infer_dimension(abs(self.length)), scad.LENGTH)
for expression in (
scad.sin(self.angle),
scad.cos(self.angle),
scad.tan(self.angle),
):
self.assertEqual(scad.infer_dimension(expression), scad.DIMENSIONLESS)
for expression in (
scad.asin(self.scalar),
scad.acos(self.scalar),
scad.atan(self.scalar),
scad.atan2(self.length, self.other_length),
):
self.assertEqual(scad.infer_dimension(expression), scad.ANGLE)
def test_invalid_physical_expressions_are_rejected(self):
legacy = scad.var("legacy", 1.0)
invalid = [
self.length + self.angle,
self.length + self.scalar,
self.length + legacy,
self.length * legacy,
self.length ** self.scalar,
self.length ** self.angle,
self.length ** legacy,
self.length**0.25,
scad.sin(self.length),
scad.asin(self.length),
scad.atan2(self.length, self.angle),
]
for expression in invalid:
with self.subTest(op=expression.op), self.assertRaises(
scad.UnitValidationError
):
scad.infer_dimension(expression)
with self.assertRaises(scad.UnitValidationError):
(self.length + self.angle).evaluate()
def test_inference_cache_does_not_hide_conflicting_duplicate_ids(self):
length = scad.Var("length", 1.0, expr_id="same", unit=scad.MM)
angle = scad.Var("angle", 1.0, expr_id="same", unit=scad.DEGREE)
expression = scad.Expr("add", (length, angle))
with self.assertRaises(scad.UnitValidationError):
scad.infer_dimension(expression)
with self.assertRaisesRegex(ValueError, "different node"):
scad.ExpressionGraph().register(expression)
def test_inference_rejects_mutated_cycles_ops_and_node_types(self):
cycle = scad.Expr("neg", (scad.const(1.0),), expr_id="cycle")
object.__setattr__(cycle, "args", (cycle,))
with self.assertRaisesRegex(scad.UnitValidationError, "cycle"):
scad.infer_dimension(cycle)
unsupported = scad.Expr("neg", (scad.const(1.0),))
object.__setattr__(unsupported, "op", "unsupported")
with self.assertRaisesRegex(scad.UnitValidationError, "Unsupported"):
scad.infer_dimension(unsupported)
with self.assertRaisesRegex(TypeError, "Unsupported expression node"):
_infer(object(), {}, set())
def test_legacy_expressions_keep_radian_math_and_unknown_dimension(self):
value = scad.var("legacy", 0.5)
expression = value ** scad.var("legacy_power", 2.0)
self.assertIsNone(scad.infer_dimension(expression))
self.assertFalse(scad.expression_uses_units(expression))
self.assertAlmostEqual(scad.sin(value).evaluate(), math.sin(0.5))
self.assertAlmostEqual(scad.asin(value).evaluate(), math.asin(0.5))
self.assertIsNone(scad.infer_dimension(scad.sin(value)))
self.assertIsNone(scad.infer_dimension(value + value))
self.assertIsNone(scad.infer_dimension(scad.atan2(value, value)))
class TestUnitAwareMathAndTolerance(unittest.TestCase):
def test_trigonometric_evaluation_uses_degrees_canonically(self):
degrees = scad.var("degrees", 90.0, unit="deg")
radians = scad.var("radians", math.pi / 2.0, unit="rad")
ratio = scad.var("ratio", 50.0, unit="%")
y = scad.var("y", 1.0, unit="in")
x = scad.var("x", 25.4, unit="mm")
self.assertAlmostEqual(scad.sin(degrees).evaluate(), 1.0)
self.assertAlmostEqual(scad.sin(radians).evaluate(), 1.0)
self.assertAlmostEqual(scad.asin(ratio).evaluate(), 30.0)
self.assertAlmostEqual(scad.atan2(y, x).evaluate(), 45.0)
def test_diagonal_tolerance_chain_reports_length_in_mm(self):
width = scad.var(
"width", 3.0, unit="cm", tolerance=0.1, tolerance_unit="mm"
)
height = scad.var("height", 40.0, unit="mm", tolerance=0.2)
diagonal = scad.sqrt(width**2 + height**2)
worst_case = scad.analyze_tolerance(diagonal)
rss = scad.analyze_tolerance(diagonal, method="rss")
self.assertEqual(worst_case.dimension, scad.LENGTH)
self.assertEqual(worst_case.unit, scad.MM)
self.assertAlmostEqual(worst_case.nominal, 50.0)
self.assertLess(worst_case.lower_deviation, 0.0)
self.assertGreater(worst_case.upper_deviation, 0.0)
expected_rss = math.hypot(0.6 * 0.1, 0.8 * 0.2)
self.assertAlmostEqual(rss.upper_deviation, expected_rss, places=12)
self.assertTrue(all(item.source_unit == scad.MM for item in rss.contributions))
def test_angle_tolerance_propagation_converts_derivatives(self):
angle = scad.var("angle", 30.0, unit="deg", tolerance=1.0)
result = scad.analyze_tolerance(scad.sin(angle), method="rss")
expected = math.cos(math.radians(30.0)) * math.pi / 180.0
self.assertEqual(result.dimension, scad.DIMENSIONLESS)
self.assertEqual(result.unit, scad.ONE)
self.assertAlmostEqual(result.nominal, 0.5)
self.assertAlmostEqual(result.upper_deviation, expected, places=12)
def test_inverse_trig_and_atan2_tolerances_report_degrees(self):
ratio = scad.var("ratio", 50.0, unit="%", tolerance=1.0)
y = scad.var("y", 10.0, unit="mm", tolerance=0.1)
x = scad.var("x", 10.0, unit="mm", tolerance=0.1)
asin_result = scad.analyze_tolerance(scad.asin(ratio), method="rss")
atan2_result = scad.analyze_tolerance(scad.atan2(y, x), method="rss")
self.assertEqual(asin_result.unit, scad.DEGREE)
self.assertAlmostEqual(asin_result.nominal, 30.0)
self.assertEqual(atan2_result.unit, scad.DEGREE)
self.assertAlmostEqual(atan2_result.nominal, 45.0)
def test_area_and_volume_analysis_are_inferred_but_not_requirements(self):
width = scad.var("width", 2.0, unit="mm", tolerance=0.1)
area = width**2
volume = width**3
self.assertEqual(scad.analyze_tolerance(area).dimension, scad.AREA)
self.assertEqual(scad.analyze_tolerance(volume).dimension, scad.VOLUME)
with self.assertRaisesRegex(ValueError, "Length or Angle"):
scad.check_tolerance(area, 0.5, tolerance_unit="mm^2")
class TestUnitAwareRequirementsAndPersistence(unittest.TestCase):
def test_requirement_tolerance_units_are_converted_before_comparison(self):
width = scad.var(
"width", 1.0, unit="in", tolerance=0.1, tolerance_unit="mm"
)
passing = scad.check_tolerance(width, 0.004, tolerance_unit="in")
failing = scad.check_tolerance(width, 0.003, tolerance_unit="in")
self.assertTrue(passing.passed)
self.assertFalse(failing.passed)
self.assertEqual(passing.requirement.tolerance_unit, scad.INCH)
self.assertAlmostEqual(
passing.requirement.canonical_tolerance.upper_deviation, 0.1016
)
def test_requirement_unit_validation(self):
length = scad.var("length", 10.0, unit="mm", tolerance=0.1)
legacy = scad.var("legacy", 10.0, tolerance=0.1)
scalar = scad.var("scalar", 1.0, unit="1", tolerance=0.1)
with self.assertRaisesRegex(ValueError, "incompatible"):
scad.check_tolerance(length, 1.0, tolerance_unit="deg")
with self.assertRaisesRegex(ValueError, "unit-aware"):
scad.check_tolerance(legacy, 1.0, tolerance_unit="mm")
with self.assertRaisesRegex(ValueError, "Length or Angle"):
scad.check_tolerance(scalar, 0.1)
def test_session_model_and_tolerance_graph_roundtrip_units(self):
width = scad.var(
"width", 1.0, unit="in", tolerance=0.1, tolerance_unit="mm"
)
with scad.GraphSession() as session:
scad.make_box_rsolid(width, 2.0, 3.0)
requirement = session.require_tolerance(
width, 0.004, tolerance_unit="in", name="width"
)
raw = json.loads(scad.export_model_json(session))
self.assertEqual(raw["expression_graph"]["nodes"][0]["unit"], "in")
self.assertEqual(
raw["expression_graph"]["nodes"][0]["tolerance_unit"], "mm"
)
self.assertEqual(
raw["tolerance_graph"]["requirements"][0]["tolerance_unit"], "in"
)
self.assertEqual(
raw["tolerance_graph"]["requirements"][0]["target_dimension"],
scad.LENGTH.to_dict(),
)
imported = scad.import_model_json(json.dumps(raw))
rebuilt = imported["tolerance_graph"].requirements[0]
self.assertEqual(rebuilt, requirement)
self.assertTrue(imported["tolerance_graph"].validate().passed)
self.assertEqual(len(scad.replay_model_json(json.dumps(raw))), 1)
def test_import_rejects_tampered_requirement_units_and_dimensions(self):
width = scad.var("width", 10.0, unit="mm", tolerance=0.1)
expressions = scad.ExpressionGraph()
tolerances = scad.ToleranceGraph(expressions)
tolerances.require(width, 0.1, tolerance_unit="mm", requirement_id="req")
expression_payload = expressions.to_dict()
wrong_unit = tolerances.to_dict()
wrong_unit["requirements"][0]["tolerance_unit"] = "deg"
with self.assertRaisesRegex(ValueError, "incompatible"):
scad.ToleranceGraph.from_dict(
wrong_unit, scad.ExpressionGraph.from_dict(expression_payload)
)
wrong_dimension = tolerances.to_dict()
wrong_dimension["requirements"][0]["target_dimension"] = scad.ANGLE.to_dict()
with self.assertRaisesRegex(ValueError, "does not match"):
scad.ToleranceGraph.from_dict(
wrong_dimension, scad.ExpressionGraph.from_dict(expression_payload)
)
missing_dimension = tolerances.to_dict()
missing_dimension["requirements"][0]["target_dimension"] = None
with self.assertRaisesRegex(ValueError, "does not match"):
scad.ToleranceGraph.from_dict(
missing_dimension, scad.ExpressionGraph.from_dict(expression_payload)
)
def test_legacy_payloads_and_requirements_remain_compatible(self):
width = scad.var("width", 10.0, tolerance=0.1)
graph = scad.ExpressionGraph()
tolerance_graph = scad.ToleranceGraph(graph)
tolerance_graph.require(width, 0.1, requirement_id="legacy")
payload = tolerance_graph.to_dict()
payload["requirements"][0].pop("tolerance_unit")
payload["requirements"][0].pop("target_dimension")
rebuilt = scad.ToleranceGraph.from_dict(payload, graph)
requirement = rebuilt.requirements[0]
self.assertIsNone(requirement.tolerance_unit)
self.assertIsNone(requirement.target_dimension)
self.assertTrue(rebuilt.validate().passed)
if __name__ == "__main__":
unittest.main()