main
Ray Publish 0.2.7 db87ed5 1d ago
"""Every gate fails on purpose: a small generated power stage passes them all, and each test breaks one rule.

Run: python3 tests/run.py   (standard library only)
"""
import sys, tempfile, unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
import kicad_board as K   # noqa: E402
import routing_gates as R  # noqa: E402

SPEC = {
    'powerStage': {'ic': 'U1', 'vinPin': '3', 'gndPin': '1', 'inputCaps': ['C2', 'C3'], 'outputCaps': ['C6', 'C7', 'C8'],
                   'inductor': 'L1', 'inductorOutPin': '2', 'planeLayer': 'B.Cu', 'planeNet': 'GND'},
    'powerPaths': [{'net': 'SW', 'from': 'U1.2', 'to': 'L1.1'}],
    'decoupling': [{'pin': 'U1.3', 'cap': 'C2'}],
}


def pad(n, x, y, w, h, net, layers='"F.Cu"'):
    return f'(pad "{n}" smd rect (at {x} {y}) (size {w} {h}) (layers {layers}) (net "{net}"))'


def fp(ref, pads):
    return f'(footprint "x:{ref}" (layer "F.Cu") (at 0 0) (property "Reference" "{ref}") {" ".join(pads)})'


def seg(a, b, w, net, layer='F.Cu'):
    return f'(segment (start {a[0]} {a[1]}) (end {b[0]} {b[1]}) (width {w}) (layer "{layer}") (net "{net}"))'


def via(x, y, net):
    return f'(via (at {x} {y}) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "{net}"))'


def fill(net, layer, pts):
    xy = ' '.join(f'(xy {x} {y})' for x, y in pts)
    return f'(zone (net "{net}") (layer "{layer}") (filled_polygon (layer "{layer}") (pts {xy})))'


def base():
    """A buck power stage in KiCad's frame (y down): U1's pins 1-3 face +x, the input cap straddles SW, L1 beyond."""
    parts = {
        'U1': fp('U1', [pad(1, 111.14, 110.95, 1.32, 0.6, 'GND'), pad(2, 111.14, 110.0, 1.32, 0.6, 'SW'), pad(3, 111.14, 109.05, 1.32, 0.6, 'VIN')]),
        'C2': fp('C2', [pad(1, 113.2, 108.52, 1.8, 1.15, 'VIN'), pad(2, 113.2, 111.48, 1.8, 1.15, 'GND')]),
        'C3': fp('C3', [pad(1, 115.3, 108.52, 1.8, 1.15, 'VIN'), pad(2, 115.3, 111.48, 1.8, 1.15, 'GND')]),
        'L1': fp('L1', [pad(1, 118.0, 110.0, 2.15, 3.5, 'SW'), pad(2, 122.4, 110.0, 2.15, 3.5, 'VOUT')]),
        'C6': fp('C6', [pad(1, 121.0, 106.4, 1.45, 1.0, 'VOUT'), pad(2, 121.0, 104.5, 1.45, 1.0, 'GND')]),
        'C7': fp('C7', [pad(1, 122.8, 106.4, 1.45, 1.0, 'VOUT'), pad(2, 122.8, 104.5, 1.45, 1.0, 'GND')]),
        'C8': fp('C8', [pad(1, 124.6, 106.4, 1.45, 1.0, 'VOUT'), pad(2, 124.6, 104.5, 1.45, 1.0, 'GND')]),
    }
    tracks = [seg((111.14, 109.05), (113.2, 109.05), 0.5, 'VIN'), seg((111.14, 110.95), (113.2, 110.95), 0.5, 'GND'),
              seg((113.2, 111.48), (113.2, 112.6), 0.4, 'GND'), seg((111.14, 110.0), (118.0, 110.0), 1.0, 'SW')]
    vias = [via(113.2, 112.6, 'GND')]
    zones = [fill('GND', 'B.Cu', [(100, 100), (130, 100), (130, 120), (100, 120)])]
    return parts, tracks, vias, zones


def write(d, parts, tracks, vias, zones):
    body = ' '.join(list(parts.values()) + tracks + vias + zones)
    p = Path(d) / 'b.kicad_pcb'
    p.write_text(f'(kicad_pcb (version 20250114) (setup (aux_axis_origin 100 100)) {body})')
    return p


class RoutingGates(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory(); self.d = self.tmp.name

    def tearDown(self):
        self.tmp.cleanup()

    def gates(self, parts, tracks, vias, zones, spec=SPEC):
        return R.Gates(K.Board(write(self.d, parts, tracks, vias, zones)), spec).run()

    def assertFailsWith(self, res, text):
        self.assertFalse(res['passed'], res['failures'])
        self.assertTrue(any(text in f for f in res['failures']), f'{text!r} not in {res["failures"]}')

    def test_the_base_board_passes_every_gate(self):
        res = self.gates(*base())
        self.assertTrue(res['passed'], res['failures'])
        self.assertEqual(res['power_paths'][0]['minTrackMm'], 1.0)
        self.assertGreaterEqual(res['power_paths'][0]['copperNeckMm'], 0.95)

    def test_off_grid_segment(self):
        p, t, v, z = base(); t.append(seg((101, 101), (102, 101.6), 0.2, 'X'))
        self.assertFailsWith(self.gates(p, t, v, z), 'off the 0/45/90 grid')

    def test_redundant_vertex(self):
        p, t, v, z = base(); t += [seg((101, 115), (102, 115), 0.2, 'X'), seg((102, 115), (103, 115), 0.2, 'X')]
        self.assertFailsWith(self.gates(p, t, v, z), 'redundant vertex')

    def test_short_jog(self):
        p, t, v, z = base(); t += [seg((101, 117), (102, 117), 0.2, 'X'), seg((102, 117), (102.2, 117.2), 0.2, 'X'), seg((102.2, 117.2), (103.2, 117.2), 0.2, 'X')]
        self.assertFailsWith(self.gates(p, t, v, z), 'short jog')

    def test_via_inside_a_trace(self):
        p, t, v, z = base(); t += [seg((104, 102), (105, 102), 0.2, 'X'), seg((105, 102), (106, 102), 0.2, 'X')]; v.append(via(105, 102, 'X'))
        self.assertFailsWith(self.gates(p, t, v, z), 'inside a trace')

    def test_junction_before_the_cap(self):
        p, t, v, z = base()
        t[0] = seg((111.14, 109.05), (112.2, 109.05), 0.5, 'VIN'); t += [seg((112.2, 109.05), (113.2, 109.05), 0.5, 'VIN'), seg((112.2, 109.05), (112.2, 104.0), 0.5, 'VIN')]
        self.assertFailsWith(self.gates(p, t, v, z), 'before the cap')

    def test_rail_pour_before_the_cap(self):
        p, t, v, z = base(); del t[0]; z.append(fill('VIN', 'F.Cu', [(110.6, 108.0), (114.2, 108.0), (114.2, 109.3), (110.6, 109.3)]))
        self.assertFailsWith(self.gates(p, t, v, z), 'a pour')

    def test_untraced_loop_is_a_failure_not_a_none(self):
        p, t, v, z = base(); del t[0]
        self.assertFailsWith(self.gates(p, t, v, z), 'supply path not traced')

    def test_input_cap_5mm_away(self):
        p, t, v, z = base()
        p['C2'] = fp('C2', [pad(1, 118.2, 103.52, 1.8, 1.15, 'VIN'), pad(2, 118.2, 106.48, 1.8, 1.15, 'GND')])
        p['C3'] = fp('C3', [pad(1, 120.3, 103.52, 1.8, 1.15, 'VIN'), pad(2, 120.3, 106.48, 1.8, 1.15, 'GND')])
        self.assertFailsWith(self.gates(p, t, v, z), 'input loop: C2 pads')

    def test_output_cap_scattered(self):
        p, t, v, z = base(); p['C8'] = fp('C8', [pad(1, 112.0, 118.0, 1.45, 1.0, 'VOUT'), pad(2, 112.0, 119.9, 1.45, 1.0, 'GND')])
        self.assertFailsWith(self.gates(p, t, v, z), 'output caps spread')

    def test_plane_cut_under_the_hot_loop(self):
        p, t, v, z = base(); t.append(seg((112.0, 109.0), (113.0, 109.0), 0.2, 'VOUT', 'B.Cu'))
        self.assertFailsWith(self.gates(p, t, v, z), 'under the hot loop')

    def test_power_trace_necks_below_its_pin(self):
        p, t, v, z = base(); t[3] = seg((111.14, 110.0), (118.0, 110.0), 0.3, 'SW')
        res = self.gates(p, t, v, z)
        self.assertFailsWith(res, 'the trace necks to 0.3 mm')
        self.assertFailsWith(res, 'the filled copper necks')          # with no pour beneath, the copper necks too

    def test_wide_pour_carries_the_current_but_the_thin_trace_still_fails(self):
        # John's buck: a 0.3 mm trace drawn over a wide SW patch. The copper is wide enough; the trace is still flagged
        p, t, v, z = base(); t[3] = seg((111.14, 110.0), (118.0, 110.0), 0.3, 'SW')
        z.append(fill('SW', 'F.Cu', [(111.7, 109.45), (117.0, 109.45), (117.0, 110.55), (111.7, 110.55)]))
        res = self.gates(p, t, v, z)
        self.assertGreaterEqual(res['power_paths'][0]['copperNeckMm'], 0.6)
        self.assertFailsWith(res, 'the trace necks to 0.3 mm')
        self.assertFalse(any('filled copper necks' in f for f in res['failures']))

    def test_power_trace_wanders(self):
        p, t, v, z = base()
        t[3:4] = [seg((111.14, 110.0), (113.8, 110.0), 1.0, 'SW'), seg((113.8, 110.0), (114.8, 111.0), 1.0, 'SW'), seg((114.8, 111.0), (114.8, 113.0), 1.0, 'SW'),
                  seg((114.8, 113.0), (115.8, 113.0), 1.0, 'SW'), seg((115.8, 113.0), (115.8, 111.0), 1.0, 'SW'), seg((115.8, 111.0), (116.8, 110.0), 1.0, 'SW'),
                  seg((116.8, 110.0), (118.0, 110.0), 1.0, 'SW')]
        res = self.gates(p, t, v, z)
        self.assertFailsWith(res, 'the trace wanders')
        self.assertFailsWith(res, 'bends between U1.2 and L1.1')

    def test_no_copper_between_the_pins_is_a_failure(self):
        p, t, v, z = base(); del t[3]
        self.assertFailsWith(self.gates(p, t, v, z), 'no filled-copper path')


if __name__ == '__main__':
    unittest.main()