Add aperture macro expression tests
This commit is contained in:
parent
bdd4008ab9
commit
2451b517e8
4 changed files with 728 additions and 23 deletions
|
|
@ -0,0 +1,18 @@
|
|||
|
||||
from .parse import ApertureMacro, GenericMacros
|
||||
from .expression import (Expression,
|
||||
UnitExpression,
|
||||
ConstantExpression,
|
||||
VariableExpression,
|
||||
ParameterExpression,
|
||||
NegatedExpression,
|
||||
OperatorExpression)
|
||||
from .primitive import (Comment,
|
||||
Circle,
|
||||
VectorLine,
|
||||
CenterLine,
|
||||
Outline,
|
||||
Polygon,
|
||||
Moire,
|
||||
Thermal)
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ class Expression:
|
|||
return expr(other) / self
|
||||
|
||||
def __neg__(self):
|
||||
return NegatedExpression(self)
|
||||
return NegatedExpression(self).optimized()
|
||||
|
||||
def __pos__(self):
|
||||
return self
|
||||
|
|
@ -339,6 +339,12 @@ class OperatorExpression(Expression):
|
|||
# -x [*/] -y == x [*/] y
|
||||
case (NegatedExpression(l), (operator.truediv | operator.mul) as op, NegatedExpression(r)):
|
||||
rv = op(l, r)
|
||||
# -x [*/] y == -(x [*/] y)
|
||||
case (NegatedExpression(l), (operator.truediv | operator.mul) as op, r):
|
||||
rv = NegatedExpression(op(l, r))
|
||||
# x [*/] -y == -(x [*/] y)
|
||||
case (l, (operator.truediv | operator.mul) as op, NegatedExpression(r)):
|
||||
rv = NegatedExpression(op(l, r))
|
||||
# x + -y == x - y
|
||||
case (l, operator.add, NegatedExpression(r)):
|
||||
rv = l-r
|
||||
|
|
|
|||
|
|
@ -110,10 +110,15 @@ class ApertureMacro:
|
|||
Use your own programmatically defined aperture macros sparingly. While support is getting better, many
|
||||
tools, including the expensive, commercial tools that PCB manufacturers use, still have bugs when handling
|
||||
aperture macros. When using advanced macros with many primitives or with complex, embedded arithmetic
|
||||
expressions, make sure to carefully check the manufacturing files provided by your PCB fab. If in doubt,
|
||||
consider using :py:meth:`~..apertures.ApertureMacroInstance.calculate_out` to convert an instance of a macro
|
||||
with embedded arithmetic expressions into an instance of a different macro where those expressions were
|
||||
replaced with their actual numeric values.
|
||||
expressions, make sure to carefully check the manufacturing files provided by your PCB fab.
|
||||
|
||||
gerbonara currently handles embedded arithmetic expressions by *always* calculating them out since we have
|
||||
recently seen high-end commercial tooling failing at issues as basic as operator precedence. This increases
|
||||
file sizes very very slightly, but it makes sure that you get correct results.
|
||||
|
||||
This means that you can use gerbonara to calculate out aperture macros and hard-bake their values into the
|
||||
gerber source. This can be useful if you have a file that includes complex macros that some manufacturer's
|
||||
tooling can't handle on its own.
|
||||
"""
|
||||
|
||||
name: str = field(default=None, hash=False, compare=False)
|
||||
|
|
@ -139,7 +144,7 @@ class ApertureMacro:
|
|||
# Construct a mock instance of the dataclass with every field bound to its correpsonding ParameterExpression,
|
||||
# then draw() it to get a list of bound macro primitives.
|
||||
primitives = tuple(dc(*[ParameterExpression(i+1) for i in range(len(fields(dc)))]).draw())
|
||||
name = macro_name if macro_name else f'GNM{inst_kls.__name__}'
|
||||
name = macro_name if macro_name else f'GNM{kls.__name__}'
|
||||
|
||||
# Python allows a lot more unicode in class names than the Gerber spec allows in aperture macro names
|
||||
if not re.fullmatch('[._$a-zA-Z][._$a-zA-Z0-9]{0,126}', name):
|
||||
|
|
@ -269,7 +274,7 @@ class GenericMacros:
|
|||
""" Filled circle macro with an optional round hole
|
||||
|
||||
:param float diameter: Diameter of the circle
|
||||
:param hole_dia: Diameter of the hole
|
||||
:param hole_dia: Diameter of the hole (optional)
|
||||
"""
|
||||
diameter: float
|
||||
hole_dia: float = 0
|
||||
|
|
@ -284,8 +289,8 @@ class GenericMacros:
|
|||
|
||||
:param float w: Width
|
||||
:param float h: Height
|
||||
:param float hole_dia: Diameter of the optional round hole
|
||||
:param float rotation: Rotation in clockwise radians
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float # width
|
||||
h: float # height
|
||||
|
|
@ -303,8 +308,8 @@ class GenericMacros:
|
|||
:param float w: Width
|
||||
:param float h: Height
|
||||
:param float r: Corner radius
|
||||
:param float hole_dia: Diameter of the optional round hole
|
||||
:param float rotation: Rotation in clockwise radians
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float # width
|
||||
h: float # height
|
||||
|
|
@ -328,8 +333,8 @@ class GenericMacros:
|
|||
:param float w: Width of the bottom (wider) edge
|
||||
:param float h: Height
|
||||
:param float d: Length difference between bottom and top edges; top width = w - d
|
||||
:param float hole_dia: Diameter of the optional round hole
|
||||
:param float rotation: Rotation in clockwise radians
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float # width
|
||||
h: float # height
|
||||
|
|
@ -355,15 +360,15 @@ class GenericMacros:
|
|||
:param float h: Height
|
||||
:param float d: Length difference between bottom and top edges; top width = w - d
|
||||
:param float margin: Corner rounding radius
|
||||
:param float hole_dia: Diameter of the optional round hole
|
||||
:param float rotation: Rotation in clockwise radians
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float
|
||||
h: float
|
||||
d: float # length difference between narrow side (top) and wide side (bottom)
|
||||
margin: float
|
||||
hole_dia: float
|
||||
rotation: float
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
rot = self.rotation * -deg_per_rad
|
||||
|
|
@ -378,7 +383,7 @@ class GenericMacros:
|
|||
yield ap.VectorLine('mm', 1, self.margin*2,
|
||||
self.w/-2, self.h/-2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
rot),
|
||||
rot)
|
||||
yield ap.VectorLine('mm', 1, self.margin*2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
self.w/2-self.d/2, self.h/2,
|
||||
|
|
@ -413,8 +418,8 @@ class GenericMacros:
|
|||
|
||||
:param float w: Total width including end caps; must satisfy w >= h
|
||||
:param float h: Height, equal to the end cap diameter
|
||||
:param float hole_dia: Diameter of the optional round hole
|
||||
:param float rotation: Rotation in clockwise radians
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float
|
||||
h: float
|
||||
|
|
@ -434,8 +439,8 @@ class GenericMacros:
|
|||
|
||||
:param int n: Number of sides
|
||||
:param float diameter: Diameter of the circumscribed circle
|
||||
:param float hole_dia: Diameter of the optional round hole
|
||||
:param float rotation: Rotation in clockwise radians
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
n: int
|
||||
diameter: float
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
#
|
||||
|
||||
import math
|
||||
import operator as op
|
||||
from contextlib import contextmanager
|
||||
|
||||
from PIL import Image
|
||||
|
|
@ -27,12 +28,23 @@ import pytest
|
|||
from gerbonara.rs274x import GerberFile
|
||||
from gerbonara.graphic_objects import Line, Arc, Flash, Region
|
||||
from gerbonara.apertures import *
|
||||
from gerbonara import aperture_macros as am
|
||||
from gerbonara.aperture_macros import (
|
||||
ConstantExpression, ParameterExpression, OperatorExpression,
|
||||
NegatedExpression, VariableExpression, UnitExpression,
|
||||
)
|
||||
from gerbonara.aperture_macros.expression import expr
|
||||
from gerbonara.aperture_macros.parse import _parse_expression
|
||||
from gerbonara.cam import FileSettings
|
||||
from gerbonara.utils import MM, Inch
|
||||
from gerbonara.utils import MM, Inch, MILLIMETERS_PER_INCH
|
||||
|
||||
from .image_support import svg_soup
|
||||
from .utils import *
|
||||
|
||||
# Short aliases used throughout expression tests
|
||||
C = ConstantExpression
|
||||
P = ParameterExpression
|
||||
|
||||
|
||||
@contextmanager
|
||||
def run_aperture_macro_test(tmpfile, img_support, inst: ApertureMacroInstance, epsilon=1e-3):
|
||||
|
|
@ -96,3 +108,667 @@ def test_macro_conversions(tmpfile, img_support, aperture_type):
|
|||
run_aperture_macro_test(tmpfile, img_support, inst)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('params', [(10, 0), (7, 0), (10, 5)])
|
||||
def test_generic_macro_circle(tmpfile, img_support, params):
|
||||
ap = am.GenericMacros.circle(*params)
|
||||
# epsilon changed since gerbv approximates circles with cubic splines which ends up pretty wrong at this scale
|
||||
run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2)
|
||||
|
||||
@pytest.mark.parametrize('params', [
|
||||
(10, 10, 0, 0),
|
||||
(10, 5, 0, 0),
|
||||
( 5, 10, 0, 0),
|
||||
(10, 10, 5, 0),
|
||||
(10, 7, 3, 0),
|
||||
(10, 10, 0, math.pi/2),
|
||||
(10, 10, 0, math.pi/3),
|
||||
(10, 5, 0, math.pi/3),
|
||||
( 7, 10, 3, math.pi/3)])
|
||||
def test_generic_macro_rect(tmpfile, img_support, params):
|
||||
ap = am.GenericMacros.rect(*params)
|
||||
run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2)
|
||||
|
||||
@pytest.mark.parametrize('params', [
|
||||
(10, 10, 0, 0, 0),
|
||||
(10, 5, 0, 0, 0),
|
||||
( 5, 10, 0, 0, 0),
|
||||
(10, 10, 0, 5, 0),
|
||||
(10, 7, 0, 3, 0),
|
||||
(10, 10, 0, 0, math.pi/2),
|
||||
(10, 10, 0, 0, math.pi/3),
|
||||
(10, 5, 0, 0, math.pi/3),
|
||||
( 7, 10, 0, 3, math.pi/3),
|
||||
(10, 10, 2, 0, 0),
|
||||
(10, 5, 2, 0, 0),
|
||||
( 5, 10, 2, 0, 0),
|
||||
(10, 10, 2, 5, 0),
|
||||
(10, 7, 2, 3, 0),
|
||||
(10, 10, 2, 0, math.pi/2),
|
||||
(10, 10, 2, 0, math.pi/3),
|
||||
(10, 5, 2, 0, math.pi/3),
|
||||
( 7, 10, 2, 3, math.pi/3),
|
||||
])
|
||||
def test_generic_macro_rounded_rect(tmpfile, img_support, params):
|
||||
ap = am.GenericMacros.rounded_rect(*params)
|
||||
run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2)
|
||||
|
||||
@pytest.mark.parametrize('params', [
|
||||
(10, 8, 2, 0, 0),
|
||||
(10, 8, 4, 0, 0),
|
||||
( 8, 10, 2, 0, 0),
|
||||
(10, 8, 2, 3, 0),
|
||||
(10, 8, 2, 0, math.pi/2),
|
||||
(10, 8, 2, 0, math.pi/3),
|
||||
(10, 8, 2, 3, math.pi/3),
|
||||
(10, 8, 0, 0, 0), # d=0: degenerate case (rectangle)
|
||||
])
|
||||
def test_generic_macro_isosceles_trapezoid(tmpfile, img_support, params):
|
||||
ap = am.GenericMacros.isosceles_trapezoid(*params)
|
||||
run_aperture_macro_test(tmpfile, img_support, ap)
|
||||
|
||||
@pytest.mark.parametrize('params', [
|
||||
# (w, h, d, margin, hole_dia, rotation)
|
||||
(10, 8, 2, 1, 0, 0),
|
||||
(10, 8, 4, 1, 0, 0),
|
||||
(10, 8, 2, 1, 3, 0),
|
||||
(10, 8, 2, 1, 0, math.pi/2),
|
||||
(10, 8, 2, 1, 0, math.pi/3),
|
||||
(10, 8, 2, 1, 3, math.pi/3),
|
||||
])
|
||||
def test_generic_macro_rounded_isosceles_trapezoid(tmpfile, img_support, params):
|
||||
ap = am.GenericMacros.rounded_isosceles_trapezoid(*params)
|
||||
run_aperture_macro_test(tmpfile, img_support, ap)
|
||||
|
||||
@pytest.mark.parametrize('params', [
|
||||
# (w, h, hole_dia, rotation), w >= h required
|
||||
(10, 5, 0, 0),
|
||||
( 8, 4, 0, 0),
|
||||
(10, 5, 2, 0),
|
||||
( 7, 7, 0, 0), # w == h: circle
|
||||
(10, 5, 0, math.pi/2),
|
||||
(10, 5, 0, math.pi/3),
|
||||
(10, 5, 2, math.pi/3),
|
||||
])
|
||||
def test_generic_macro_obround(tmpfile, img_support, params):
|
||||
ap = am.GenericMacros.obround(*params)
|
||||
run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2)
|
||||
|
||||
@pytest.mark.parametrize('params', [
|
||||
# (n, diameter, hole_dia, rotation)
|
||||
(3, 10, 0, 0),
|
||||
(4, 10, 0, 0),
|
||||
(5, 10, 0, 0),
|
||||
(6, 10, 0, 0),
|
||||
(6, 10, 3, 0),
|
||||
(6, 10, 0, math.pi/6),
|
||||
(5, 10, 0, math.pi/4),
|
||||
(5, 10, 3, math.pi/4),
|
||||
(3, 10, 3, math.pi/3),
|
||||
])
|
||||
def test_generic_macro_polygon(tmpfile, img_support, params):
|
||||
ap = am.GenericMacros.polygon(*params)
|
||||
run_aperture_macro_test(tmpfile, img_support, ap)
|
||||
|
||||
@pytest.mark.parametrize('abc', [(2.0, 1.6, 2.3), (2.2, 1.6, 2.3), (2.1, 1.7, 2.4)])
|
||||
def test_macro_formulas(tmpfile, img_support, abc):
|
||||
@am.ApertureMacro.map()
|
||||
class test_macro:
|
||||
a: float
|
||||
b: float
|
||||
c: float
|
||||
|
||||
def draw(self):
|
||||
d = 1.3
|
||||
yield am.Circle('mm', 0, d, 0, 0)
|
||||
yield am.Circle('mm', 0, d, 2, 0)
|
||||
yield am.Circle('mm', 0, d, 4, 0)
|
||||
yield am.Circle('mm', 0, d, 2, self.a)
|
||||
yield am.Circle('mm', 0, d, 2, self.a+self.b)
|
||||
yield am.Circle('mm', 0, d, 2, self.a+self.b+self.c)
|
||||
yield am.Circle('mm', 0, d, 4, self.a * 1.1)
|
||||
yield am.Circle('mm', 0, d, 4, self.b * 1.9)
|
||||
yield am.Circle('mm', 0, d, 4, self.c * 2.2)
|
||||
yield am.Circle('mm', 0, d, 6, 2 * self.a / self.b)
|
||||
yield am.Circle('mm', 0, d, 6, 4 * self.b / self.c)
|
||||
yield am.Circle('mm', 0, d, 6, 6 * self.c / self.a)
|
||||
yield am.Circle('mm', 0, d, 8, self.a - self.b * self.a / self.c)
|
||||
yield am.Circle('mm', 0, d, 8, 2 + self.a - self.b * self.a / self.c)
|
||||
yield am.Circle('mm', 0, d, 8, self.a - 2 * self.b * self.a / self.c)
|
||||
|
||||
inst = test_macro(*abc)
|
||||
run_aperture_macro_test(tmpfile, img_support, inst)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Expression language unit tests
|
||||
# =============================================================================
|
||||
|
||||
class TestConstantExpression:
|
||||
def test_value_stored(self):
|
||||
assert C(5).value == 5
|
||||
|
||||
def test_float_conversion(self):
|
||||
assert float(C(3.14)) == pytest.approx(3.14)
|
||||
|
||||
def test_calculate_no_binding(self):
|
||||
assert C(42.0).calculate() == pytest.approx(42.0)
|
||||
|
||||
def test_calculate_ignores_binding(self):
|
||||
assert C(7.0).calculate({1: 99.0}) == pytest.approx(7.0)
|
||||
|
||||
def test_to_gerber_integer(self):
|
||||
assert C(5).to_gerber() == '5'
|
||||
|
||||
def test_to_gerber_float(self):
|
||||
assert C(1.5).to_gerber() == '1.5'
|
||||
|
||||
def test_to_gerber_trailing_zeros_stripped(self):
|
||||
assert C(1.500000).to_gerber() == '1.5'
|
||||
assert C(2.0).to_gerber() == '2'
|
||||
|
||||
def test_to_gerber_zero(self):
|
||||
assert C(0).to_gerber() == '0'
|
||||
|
||||
def test_to_gerber_negative_zero_avoided(self):
|
||||
# -0.0 must not serialize as '-0'
|
||||
assert C(-0.0).to_gerber() == '0'
|
||||
|
||||
def test_equality_exact(self):
|
||||
assert C(3.0) == C(3.0)
|
||||
|
||||
def test_equality_within_tolerance(self):
|
||||
assert C(1.0) == C(1.0 + 1e-10)
|
||||
|
||||
def test_inequality_outside_tolerance(self):
|
||||
assert not (C(1.0) == C(2.0))
|
||||
|
||||
def test_equality_with_plain_number(self):
|
||||
assert C(0) == 0
|
||||
assert C(1) == 1
|
||||
assert C(-1) == -1
|
||||
|
||||
def test_parameters_empty(self):
|
||||
assert list(C(5).parameters()) == []
|
||||
|
||||
|
||||
class TestParameterExpression:
|
||||
def test_to_gerber(self):
|
||||
assert P(1).to_gerber() == '$1'
|
||||
assert P(3).to_gerber() == '$3'
|
||||
assert P(42).to_gerber() == '$42'
|
||||
|
||||
def test_calculate_with_binding(self):
|
||||
assert P(1).calculate({1: 5.0}) == pytest.approx(5.0)
|
||||
assert P(2).calculate({1: 10.0, 2: 20.0}) == pytest.approx(20.0)
|
||||
|
||||
def test_calculate_unresolved_raises(self):
|
||||
with pytest.raises(IndexError):
|
||||
P(1).calculate({})
|
||||
|
||||
def test_calculate_missing_param_raises(self):
|
||||
with pytest.raises(IndexError):
|
||||
P(2).calculate({1: 5.0})
|
||||
|
||||
def test_parameters_yields_self(self):
|
||||
p = P(1)
|
||||
assert list(p.parameters()) == [p]
|
||||
|
||||
def test_optimized_with_binding_resolves(self):
|
||||
assert P(1).optimized({1: 7.5}) == C(7.5)
|
||||
|
||||
def test_optimized_without_binding_is_identity(self):
|
||||
p = P(1)
|
||||
assert p.optimized({}) is p
|
||||
|
||||
|
||||
class TestArithmeticOperators:
|
||||
@pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (0.0, 5.0)])
|
||||
def test_add(self, a, b):
|
||||
assert (P(1) + P(2)).calculate({1: a, 2: b}) == pytest.approx(a + b)
|
||||
|
||||
@pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (5.0, 5.0)])
|
||||
def test_sub(self, a, b):
|
||||
assert (P(1) - P(2)).calculate({1: a, 2: b}) == pytest.approx(a - b)
|
||||
|
||||
@pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (0.0, 5.0)])
|
||||
def test_mul(self, a, b):
|
||||
assert (P(1) * P(2)).calculate({1: a, 2: b}) == pytest.approx(a * b)
|
||||
|
||||
@pytest.mark.parametrize('a,b', [(6.0, 3.0), (-4.5, 1.5), (1.0, 4.0)])
|
||||
def test_div(self, a, b):
|
||||
assert (P(1) / P(2)).calculate({1: a, 2: b}) == pytest.approx(a / b)
|
||||
|
||||
def test_radd(self):
|
||||
assert (5.0 + P(1)).calculate({1: 3.0}) == pytest.approx(8.0)
|
||||
|
||||
def test_rsub(self):
|
||||
assert (10.0 - P(1)).calculate({1: 3.0}) == pytest.approx(7.0)
|
||||
|
||||
def test_rmul(self):
|
||||
assert (2.0 * P(1)).calculate({1: 4.0}) == pytest.approx(8.0)
|
||||
|
||||
def test_rdiv(self):
|
||||
assert (10.0 / P(1)).calculate({1: 2.0}) == pytest.approx(5.0)
|
||||
|
||||
def test_neg(self):
|
||||
assert (-P(1)).calculate({1: 5.0}) == pytest.approx(-5.0)
|
||||
|
||||
def test_pos_is_identity(self):
|
||||
p = P(1)
|
||||
assert +p is p
|
||||
|
||||
|
||||
# Cross-check expression evaluation against Python's own arithmetic.
|
||||
# A single lambda serves both roles: called with P() objects it builds an expression tree;
|
||||
# called with plain numbers it computes the Python reference value (ints/floats auto-convert).
|
||||
@pytest.mark.parametrize('f,binding', [
|
||||
(lambda p1, p2, p3: p1 + p2, {1: 3, 2: 7, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 - p2, {1: 10, 2: 3, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 * p2, {1: 3, 2: 4, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 / p2, {1: 9, 2: 3, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 + p2 + p3, {1: 1, 2: 2, 3: 3}),
|
||||
(lambda p1, p2, p3: p1 * p2 + p3, {1: 2, 2: 3, 3: 4}),
|
||||
(lambda p1, p2, p3: p1 + p2 * p3, {1: 2, 2: 3, 3: 4}),
|
||||
(lambda p1, p2, p3: (p1 + p2) * p3, {1: 2, 2: 3, 3: 4}),
|
||||
(lambda p1, p2, p3: p1 / p2 + p3, {1: 6, 2: 3, 3: 1}),
|
||||
(lambda p1, p2, p3: p1 - p2 * p3, {1: 10, 2: 2, 3: 3}),
|
||||
(lambda p1, p2, p3: (p1 + p2) / p3, {1: 3, 2: 5, 3: 4}),
|
||||
(lambda p1, p2, p3: p1 * (p2 - p3), {1: 3, 2: 7, 3: 2}),
|
||||
(lambda p1, p2, p3: p1 * 2 + 3, {1: 5, 2: 0, 3: 0}),
|
||||
(lambda p1, p2, p3: 10 - p1 * p2, {1: 2, 2: 3, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 / 2 + p2, {1: 6, 2: 1, 3: 0}),
|
||||
(lambda p1, p2, p3: -p1 + p2, {1: 3, 2: 7, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 + (-p2), {1: 10, 2: 3, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 * (-p2), {1: 3, 2: 4, 3: 0}),
|
||||
(lambda p1, p2, p3: (-p1) * (-p2), {1: 3, 2: 4, 3: 0}),
|
||||
(lambda p1, p2, p3: (-p1) / (-p2), {1: 6, 2: 3, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 - (-p2), {1: 5, 2: 3, 3: 0}),
|
||||
(lambda p1, p2, p3: (p1+p2) * (p1-p3), {1: 5, 2: 3, 3: 2}),
|
||||
(lambda p1, p2, p3: p1 / p2 * p3, {1: 6, 2: 2, 3: 5}),
|
||||
])
|
||||
def test_expression_against_python(f, binding):
|
||||
"""Build a gerbonara expression and compare its result to Python's evaluation."""
|
||||
a, b, c = binding.get(1, 0), binding.get(2, 0), binding.get(3, 0)
|
||||
assert f(P(1), P(2), P(3)).calculate(binding) == pytest.approx(f(a, b, c), rel=1e-9, abs=1e-12)
|
||||
|
||||
|
||||
class TestConstantFolding:
|
||||
"""Operations on two ConstantExpressions must immediately produce a ConstantExpression."""
|
||||
|
||||
def test_add(self):
|
||||
result = C(3) + C(4)
|
||||
assert isinstance(result, ConstantExpression) and result.value == pytest.approx(7)
|
||||
|
||||
def test_sub(self):
|
||||
result = C(10) - C(4)
|
||||
assert isinstance(result, ConstantExpression) and result.value == pytest.approx(6)
|
||||
|
||||
def test_mul(self):
|
||||
result = C(3) * C(4)
|
||||
assert isinstance(result, ConstantExpression) and result.value == pytest.approx(12)
|
||||
|
||||
def test_div(self):
|
||||
result = C(10) / C(4)
|
||||
assert isinstance(result, ConstantExpression) and result.value == pytest.approx(2.5)
|
||||
|
||||
def test_neg_of_constant(self):
|
||||
result = -C(5)
|
||||
assert isinstance(result, ConstantExpression) and result.value == pytest.approx(-5)
|
||||
|
||||
def test_nested(self):
|
||||
result = (C(3) + C(4)) * C(2)
|
||||
assert isinstance(result, ConstantExpression) and result.value == pytest.approx(14)
|
||||
|
||||
def test_deeply_nested(self):
|
||||
result = C(2) * C(3) + C(4) * C(5)
|
||||
assert isinstance(result, ConstantExpression) and result.value == pytest.approx(26)
|
||||
|
||||
|
||||
class TestAlgebraicOptimizations:
|
||||
"""Each algebraic simplification rule in OperatorExpression.optimized()."""
|
||||
|
||||
def test_zero_plus_x(self):
|
||||
assert C(0) + P(1) == P(1)
|
||||
|
||||
def test_x_plus_zero(self):
|
||||
assert P(1) + C(0) == P(1)
|
||||
|
||||
def test_zero_times_x(self):
|
||||
assert C(0) * P(1) == C(0)
|
||||
|
||||
def test_x_times_zero(self):
|
||||
assert P(1) * C(0) == C(0)
|
||||
|
||||
def test_one_times_x(self):
|
||||
assert C(1) * P(1) == P(1)
|
||||
|
||||
def test_x_times_one(self):
|
||||
assert P(1) * C(1) == P(1)
|
||||
|
||||
def test_x_times_neg_one_negates(self):
|
||||
assert (P(1) * C(-1)).calculate({1: 5.0}) == pytest.approx(-5.0)
|
||||
|
||||
def test_neg_one_times_x_negates(self):
|
||||
assert (C(-1) * P(1)).calculate({1: 5.0}) == pytest.approx(-5.0)
|
||||
|
||||
def test_x_minus_zero(self):
|
||||
assert P(1) - C(0) == P(1)
|
||||
|
||||
def test_zero_minus_x_is_neg_x(self):
|
||||
assert (C(0) - P(1)).calculate({1: 5.0}) == pytest.approx(-5.0)
|
||||
|
||||
def test_x_minus_x_is_zero(self):
|
||||
p = P(1)
|
||||
assert p - p == C(0)
|
||||
|
||||
def test_x_minus_neg_y_is_x_plus_y(self):
|
||||
assert (P(1) - (-P(2))).calculate({1: 3.0, 2: 4.0}) == pytest.approx(7.0)
|
||||
|
||||
def test_x_div_one(self):
|
||||
assert P(1) / C(1) == P(1)
|
||||
|
||||
def test_x_div_neg_one_negates(self):
|
||||
assert (P(1) / C(-1)).calculate({1: 5.0}) == pytest.approx(-5.0)
|
||||
|
||||
def test_x_div_x_is_one(self):
|
||||
p = P(1)
|
||||
assert p / p == C(1)
|
||||
|
||||
def test_neg_x_times_neg_y_cancels(self):
|
||||
assert ((-P(1)) * (-P(2))).calculate({1: 3.0, 2: 4.0}) == pytest.approx(12.0)
|
||||
|
||||
def test_neg_x_div_neg_y_cancels(self):
|
||||
assert ((-P(1)) / (-P(2))).calculate({1: 6.0, 2: 3.0}) == pytest.approx(2.0)
|
||||
|
||||
def test_x_plus_neg_y_becomes_subtraction(self):
|
||||
assert (P(1) + (-P(2))).calculate({1: 10.0, 2: 3.0}) == pytest.approx(7.0)
|
||||
|
||||
def test_neg_x_plus_y_reverses_subtraction(self):
|
||||
assert ((-P(1)) + P(2)).calculate({1: 3.0, 2: 10.0}) == pytest.approx(7.0)
|
||||
|
||||
def test_x_mul_neg_y_pulls_negation_out(self):
|
||||
e = P(1) * (-P(2))
|
||||
assert isinstance(e, NegatedExpression)
|
||||
assert e.calculate({1: 3.0, 2: 4.0}) == pytest.approx(-12.0)
|
||||
|
||||
def test_neg_x_mul_y_pulls_negation_out(self):
|
||||
e = (-P(1)) * P(2)
|
||||
assert isinstance(e, NegatedExpression)
|
||||
assert e.calculate({1: 3.0, 2: 4.0}) == pytest.approx(-12.0)
|
||||
|
||||
def test_x_div_neg_y_pulls_negation_out(self):
|
||||
e = P(1) / (-P(2))
|
||||
assert isinstance(e, NegatedExpression)
|
||||
assert e.calculate({1: 6.0, 2: 3.0}) == pytest.approx(-2.0)
|
||||
|
||||
def test_neg_x_div_y_pulls_negation_out(self):
|
||||
e = (-P(1)) / P(2)
|
||||
assert isinstance(e, NegatedExpression)
|
||||
assert e.calculate({1: 6.0, 2: 3.0}) == pytest.approx(-2.0)
|
||||
|
||||
|
||||
class TestNegatedExpression:
|
||||
def test_double_negation(self):
|
||||
p = P(1)
|
||||
assert -(-p) == p
|
||||
|
||||
def test_double_negation_evaluates_correctly(self):
|
||||
assert (-(-P(1))).calculate({1: 5.0}) == pytest.approx(5.0)
|
||||
|
||||
def test_negation_of_constant_folds(self):
|
||||
assert -C(5) == C(-5)
|
||||
|
||||
def test_negation_of_subtraction_flips_operands(self):
|
||||
# -(a - b) == b - a
|
||||
assert (-(P(1) - P(2))).calculate({1: 3.0, 2: 7.0}) == pytest.approx(4.0)
|
||||
|
||||
def test_negation_of_zero_is_zero(self):
|
||||
assert -C(0) == C(0)
|
||||
|
||||
def test_to_gerber_parameter_no_parens(self):
|
||||
assert NegatedExpression(P(1)).to_gerber() == '-$1'
|
||||
|
||||
def test_to_gerber_operator_uses_parens(self):
|
||||
inner = OperatorExpression(op.add, P(1), P(2))
|
||||
assert NegatedExpression(inner).to_gerber() == '-($1+$2)'
|
||||
|
||||
|
||||
class TestToGerber:
|
||||
def test_constant_integer(self):
|
||||
assert C(5).to_gerber() == '5'
|
||||
|
||||
def test_constant_float(self):
|
||||
assert C(1.5).to_gerber() == '1.5'
|
||||
|
||||
def test_parameter(self):
|
||||
assert P(1).to_gerber() == '$1'
|
||||
assert P(99).to_gerber() == '$99'
|
||||
|
||||
def test_add_operator(self):
|
||||
assert OperatorExpression(op.add, P(1), P(2)).to_gerber() == '$1+$2'
|
||||
|
||||
def test_sub_operator(self):
|
||||
assert OperatorExpression(op.sub, P(1), P(2)).to_gerber() == '$1-$2'
|
||||
|
||||
def test_mul_uses_x(self):
|
||||
# Gerber spec uses 'x' for multiplication, not '*'
|
||||
assert OperatorExpression(op.mul, P(1), P(2)).to_gerber() == '$1x$2'
|
||||
|
||||
def test_div_operator(self):
|
||||
assert OperatorExpression(op.truediv, P(1), P(2)).to_gerber() == '$1/$2'
|
||||
|
||||
def test_lhs_operator_gets_parens(self):
|
||||
lhs = OperatorExpression(op.add, P(1), P(2))
|
||||
e = OperatorExpression(op.mul, lhs, P(3))
|
||||
assert e.to_gerber() == '($1+$2)x$3'
|
||||
|
||||
def test_rhs_operator_gets_parens(self):
|
||||
rhs = OperatorExpression(op.add, P(2), P(3))
|
||||
e = OperatorExpression(op.mul, P(1), rhs)
|
||||
assert e.to_gerber() == '$1x($2+$3)'
|
||||
|
||||
def test_nested_lhs_and_rhs_parens(self):
|
||||
lhs = OperatorExpression(op.add, P(1), P(2))
|
||||
outer = OperatorExpression(op.add, lhs, P(3))
|
||||
assert outer.to_gerber() == '($1+$2)+$3'
|
||||
|
||||
def test_negated_mul_to_gerber(self):
|
||||
# P(1) * (-P(2)) optimises to -(P(1)*P(2)); NegatedExpression wraps the product
|
||||
assert (P(1) * (-P(2))).to_gerber() == '-($1x$2)'
|
||||
|
||||
def test_negated_div_to_gerber(self):
|
||||
assert (P(1) / (-P(2))).to_gerber() == '-($1/$2)'
|
||||
|
||||
def test_negative_constant(self):
|
||||
assert C(-5).to_gerber() == '-5'
|
||||
|
||||
|
||||
class TestParsing:
|
||||
def test_constant_integer(self):
|
||||
assert _parse_expression('5', {}, set()) == C(5)
|
||||
|
||||
def test_constant_float(self):
|
||||
assert _parse_expression('1.5', {}, set()) == C(1.5)
|
||||
|
||||
def test_parameter_reference(self):
|
||||
params = set()
|
||||
assert _parse_expression('$1', {}, params) == P(1)
|
||||
assert 1 in params
|
||||
|
||||
def test_multiple_parameters_tracked(self):
|
||||
params = set()
|
||||
_parse_expression('$1+$3', {}, params)
|
||||
assert params == {1, 3}
|
||||
|
||||
def test_add(self):
|
||||
assert _parse_expression('$1+$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(7)
|
||||
|
||||
def test_sub(self):
|
||||
assert _parse_expression('$1-$2', {}, set()).calculate({1: 10, 2: 4}) == pytest.approx(6)
|
||||
|
||||
def test_mul_gerber_x_syntax(self):
|
||||
assert _parse_expression('$1x$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(12)
|
||||
|
||||
def test_mul_uppercase_x(self):
|
||||
assert _parse_expression('$1X$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(12)
|
||||
|
||||
def test_div(self):
|
||||
assert _parse_expression('$1/$2', {}, set()).calculate({1: 10, 2: 4}) == pytest.approx(2.5)
|
||||
|
||||
def test_negation(self):
|
||||
assert _parse_expression('-$1', {}, set()).calculate({1: 5}) == pytest.approx(-5)
|
||||
|
||||
def test_parenthesized(self):
|
||||
assert _parse_expression('($1+$2)x$3', {}, set()).calculate({1: 3, 2: 4, 3: 2}) == pytest.approx(14)
|
||||
|
||||
def test_known_variable_becomes_variable_expression(self):
|
||||
e = _parse_expression('$1', {1: C(10)}, set())
|
||||
assert isinstance(e, VariableExpression)
|
||||
|
||||
@pytest.mark.parametrize('gerber_str,py_str,binding', [
|
||||
('$1+$2', 'a+b', {1: 5, 2: 3 }),
|
||||
('$1-$2', 'a-b', {1: 5, 2: 3 }),
|
||||
('$1x$2', 'a*b', {1: 5, 2: 3 }),
|
||||
('$1/$2', 'a/b', {1: 6, 2: 3 }),
|
||||
('($1+$2)x$3', '(a+b)*c', {1: 2, 2: 3, 3: 4 }),
|
||||
('$1x$2+$3', 'a*b+c', {1: 2, 2: 3, 3: 4 }),
|
||||
('-$1+$2', '-a+b', {1: 2, 2: 7 }),
|
||||
('$1/$2+$1x$2', 'a/b+a*b', {1: 6, 2: 2 }),
|
||||
])
|
||||
def test_parse_and_evaluate(self, gerber_str, py_str, binding):
|
||||
e = _parse_expression(gerber_str, {}, set())
|
||||
a = binding.get(1, 0)
|
||||
b = binding.get(2, 0)
|
||||
c = binding.get(3, 0)
|
||||
expected = eval(py_str) # noqa: S307 – controlled test literals only
|
||||
assert e.calculate(binding) == pytest.approx(expected)
|
||||
|
||||
@pytest.mark.parametrize('make_expr,binding', [
|
||||
(lambda p1, p2, p3: p1 + p2, {1: 3, 2: 7, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 - p2, {1: 10, 2: 3, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 * p2, {1: 3, 2: 4, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 / p2, {1: 9, 2: 3, 3: 0}),
|
||||
(lambda p1, p2, p3: (p1 + p2) * p3, {1: 2, 2: 3, 3: 4}),
|
||||
(lambda p1, p2, p3: p1 * p2 - p3, {1: 5, 2: 2, 3: 3}),
|
||||
(lambda p1, p2, p3: p1 / p2 + p3, {1: 6, 2: 3, 3: 1}),
|
||||
(lambda p1, p2, p3: -p1 + p2, {1: 3, 2: 7, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 * (-p2), {1: 3, 2: 4, 3: 0}),
|
||||
(lambda p1, p2, p3: (-p1) * p2, {1: 3, 2: 4, 3: 0}),
|
||||
(lambda p1, p2, p3: p1 / (-p2), {1: 9, 2: 3, 3: 0}),
|
||||
(lambda p1, p2, p3: (-p1) / p2, {1: 9, 2: 3, 3: 0}),
|
||||
(lambda p1, p2, p3: (p1 + p2) / p3 - p1, {1: 3, 2: 5, 3: 4}),
|
||||
(lambda p1, p2, p3: p1 * p2 + p3 / p1, {1: 3, 2: 4, 3: 6}),
|
||||
(lambda p1, p2, p3: -(p1 + p2) * p3, {1: 2, 2: 3, 3: 4}),
|
||||
])
|
||||
def test_to_gerber_round_trip(self, make_expr, binding):
|
||||
"""to_gerber() followed by _parse_expression() must preserve the evaluated value."""
|
||||
original = make_expr(P(1), P(2), P(3))
|
||||
gerber = original.to_gerber()
|
||||
parsed = _parse_expression(gerber, {}, set())
|
||||
assert parsed.calculate(binding) == pytest.approx(original.calculate(binding))
|
||||
|
||||
|
||||
class TestUnitExpression:
|
||||
def test_mm_to_mm_unchanged(self):
|
||||
assert UnitExpression(C(25.4), MM).calculate(unit=MM) == pytest.approx(25.4)
|
||||
|
||||
def test_inch_to_mm(self):
|
||||
assert UnitExpression(C(1.0), Inch).calculate(unit=MM) == pytest.approx(MILLIMETERS_PER_INCH)
|
||||
|
||||
def test_mm_to_inch(self):
|
||||
assert UnitExpression(C(25.4), MM).calculate(unit=Inch) == pytest.approx(25.4 / MILLIMETERS_PER_INCH)
|
||||
|
||||
def test_inch_to_inch_unchanged(self):
|
||||
assert UnitExpression(C(2.0), Inch).calculate(unit=Inch) == pytest.approx(2.0)
|
||||
|
||||
def test_none_unit_passes_through(self):
|
||||
assert UnitExpression(C(5.0), None).calculate(unit=MM) == pytest.approx(5.0)
|
||||
|
||||
def test_negation_preserves_unit(self):
|
||||
neg = -UnitExpression(C(5.0), MM)
|
||||
assert isinstance(neg, UnitExpression) and neg.unit == MM
|
||||
assert neg.calculate(unit=MM) == pytest.approx(-5.0)
|
||||
|
||||
def test_add_same_unit(self):
|
||||
result = UnitExpression(C(3.0), MM) + UnitExpression(C(4.0), MM)
|
||||
assert isinstance(result, UnitExpression)
|
||||
assert result.calculate(unit=MM) == pytest.approx(7.0)
|
||||
|
||||
def test_add_mixed_units_converts(self):
|
||||
# 1 inch + 1 mm, result held in Inch
|
||||
result = UnitExpression(C(1.0), Inch) + UnitExpression(C(1.0), MM)
|
||||
assert result.calculate(unit=Inch) == pytest.approx(1.0 + 1.0 / MILLIMETERS_PER_INCH)
|
||||
|
||||
def test_add_scalar_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
UnitExpression(C(5.0), MM) + C(3.0)
|
||||
|
||||
def test_radd_scalar_raises(self):
|
||||
# BUG: asymmetric unit safety — C(3.0) + UnitExpression(...) does NOT raise because
|
||||
# Python dispatches to Expression.__add__ first, which has no unit awareness.
|
||||
# Only plain Python scalars (not Expression subclasses) trigger __radd__ on UnitExpression.
|
||||
# There is no really nice fix for this, so we just leave it in for now.
|
||||
with pytest.raises(ValueError):
|
||||
5.0 + UnitExpression(C(5.0), MM)
|
||||
|
||||
def test_mul_by_scalar(self):
|
||||
result = UnitExpression(C(3.0), MM) * C(2)
|
||||
assert isinstance(result, UnitExpression)
|
||||
assert result.calculate(unit=MM) == pytest.approx(6.0)
|
||||
|
||||
def test_div_by_scalar(self):
|
||||
result = UnitExpression(C(6.0), MM) / C(2)
|
||||
assert isinstance(result, UnitExpression)
|
||||
assert result.calculate(unit=MM) == pytest.approx(3.0)
|
||||
|
||||
def test_nested_unit_expression_flattens(self):
|
||||
# Wrapping a UnitExpression in another converts rather than double-wrapping
|
||||
inner = UnitExpression(C(1.0), Inch)
|
||||
outer = UnitExpression(inner, MM)
|
||||
assert not isinstance(outer.expr, UnitExpression)
|
||||
assert outer.calculate(unit=MM) == pytest.approx(MILLIMETERS_PER_INCH)
|
||||
|
||||
def test_parameters_forwarded(self):
|
||||
assert list(UnitExpression(P(1), MM).parameters()) == [P(1)]
|
||||
|
||||
|
||||
class TestExprHelper:
|
||||
def test_passthrough_expression(self):
|
||||
p = P(1)
|
||||
assert expr(p) is p
|
||||
|
||||
def test_wraps_int(self):
|
||||
result = expr(5)
|
||||
assert isinstance(result, ConstantExpression) and result.value == 5
|
||||
|
||||
def test_wraps_float(self):
|
||||
result = expr(3.14)
|
||||
assert isinstance(result, ConstantExpression) and result.value == pytest.approx(3.14)
|
||||
|
||||
|
||||
class TestVariableExpression:
|
||||
def test_optimized_non_operator_unwraps(self):
|
||||
# A VariableExpression wrapping something that simplifies to a non-OperatorExpression
|
||||
# should unwrap and return the simplified value directly.
|
||||
result = VariableExpression(C(5)).optimized()
|
||||
assert result == C(5)
|
||||
|
||||
def test_optimized_keeps_operator_expression(self):
|
||||
ve = VariableExpression(OperatorExpression(op.add, P(1), P(2)))
|
||||
assert isinstance(ve.optimized(), VariableExpression)
|
||||
|
||||
def test_to_gerber_without_register_uses_inner(self):
|
||||
assert VariableExpression(C(42)).to_gerber(register_variable=None) == '42'
|
||||
|
||||
def test_to_gerber_with_register_allocates_dollar_variable(self):
|
||||
allocated = {}
|
||||
|
||||
def register(e):
|
||||
key = e.to_gerber()
|
||||
if key not in allocated:
|
||||
allocated[key] = len(allocated) + 1
|
||||
return allocated[key]
|
||||
|
||||
inner = OperatorExpression(op.add, P(1), P(2))
|
||||
result = VariableExpression(inner).to_gerber(register_variable=register)
|
||||
assert result.startswith('$') and int(result[1:]) >= 1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue