main
TylerK Replace discrete-analog board with MCU/IC redesign: STM32G031 P&O MPPT + CC/CV firmware, LM5175 buck-boost, INA180 curr… e48ba30 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
#!/usr/bin/env python3
"""Generator core for the MCU/IC-based solar MPPT power system schematic.

Unlike the old discrete-analog board's generator (schgen.py, which connects
pins purely with net-name labels), this module draws REAL wire segments
between pins -- the schematic is meant to be traceable by eye, the way a
hand-drawn schematic is. Net labels/power symbols are only used where that is
itself standard practice (global power/GND symbols with a short real wire
stub to the pin -- not a bare text label standing in for a wire).
"""
import math
import uuid as U
from symlib import SymLib

GRID = 1.27
def snap(v): return round(v / GRID) * GRID
def uid(): return str(U.uuid4())


def rot_pt(px, py, rot):
    """Rotate a symbol-local pin offset (y-up) by `rot` degrees CCW, then
    flip to sheet space (y-down). Consistent with KiCad's own placement of
    rotated symbol instances."""
    r = math.radians(rot)
    dx = px * math.cos(r) - py * math.sin(r)
    dy = -(px * math.sin(r) + py * math.cos(r))
    return dx, dy


def rot_dir(angle_deg, rot):
    """Outward direction unit vector (sheet space) of a pin whose local
    KiCad pin angle is `angle_deg`, after the symbol is placed at `rot`."""
    eff = math.radians(angle_deg + rot)
    return math.cos(eff), -math.sin(eff)


class Sch:
    def __init__(self, lib, project):
        self.lib = lib
        self.project = project
        self.items = []
        self.used = {}
        self.root = uid()
        self.pins_of = {}   # ref -> {pin_key: (x, y, angle_deg)}
        self.refs_seen = set()
        self.pin_num_of = {}      # (ref, any_key) -> canonical pin number
        self._intent_parent = {}  # (ref, num) -> parent, for intended-net union-find
        self._named_net_repr = {}  # net_name -> representative (ref, num) node
        self._pending_wires = []  # deferred wire() calls, processed by finalize_wires()
        self._emitted_segments = []

    # ---- intended-connectivity tracking (verified against real geometry
    # by verify_intent() below -- this is what caught the shared-stub-column
    # short-circuit class of bug: two pins this code never asked to be tied
    # together, but that ended up on the same net because their auto-routed
    # wires happened to overlap) ------------------------------------------
    def _canon(self, ref, key):
        return (ref, self.pin_num_of[(ref, str(key))])

    def _ifind(self, node):
        self._intent_parent.setdefault(node, node)
        while self._intent_parent[node] != node:
            self._intent_parent[node] = self._intent_parent[self._intent_parent[node]]
            node = self._intent_parent[node]
        return node

    def _iunion(self, a, b):
        ra, rb = self._ifind(a), self._ifind(b)
        if ra != rb:
            self._intent_parent[ra] = rb

    def intend(self, ref, pin, ref2, pin2):
        self._iunion(self._canon(ref, pin), self._canon(ref2, pin2))

    def intend_named(self, ref, pin, net_name):
        node = self._canon(ref, pin)
        rep = self._named_net_repr.setdefault(net_name, node)
        self._iunion(node, rep)

    def need(self, l, n):
        self.used.setdefault((l, n), self.lib.lib_symbol_entry(l, n))

    def place(self, l, n, unit, ref, value, x, y, rot=0, footprint='',
              extra_props=None, hide_val=False, dnp=False):
        assert ref not in self.refs_seen, f"duplicate reference {ref}"
        self.refs_seen.add(ref)
        self.need(l, n)
        x, y = snap(x), snap(y)
        pu = uid()
        props = [('Reference', ref, False), ('Value', value, hide_val),
                 ('Footprint', footprint, True), ('Datasheet', '', True)]
        if extra_props:
            props += [(k, v, True) for k, v in extra_props.items()]
        s = ['\t(symbol', f'\t\t(lib_id "{l}:{n}")', f'\t\t(at {x:.2f} {y:.2f} {rot})']
        s += [f'\t\t(unit {unit})', '\t\t(body_style 1)', '\t\t(exclude_from_sim no)',
              '\t\t(in_bom yes)', '\t\t(on_board yes)', '\t\t(in_pos_files yes)',
              f'\t\t(dnp {"yes" if dnp else "no"})', f'\t\t(uuid "{pu}")']
        py = y - 10.0
        for name, v, hide in props:
            esc = v.replace('\\', '\\\\').replace('"', '\\"')
            s.append(f'\t\t(property "{name}" "{esc}"')
            s.append(f'\t\t\t(at {x:.2f} {py:.2f} 0)')
            if hide: s.append('\t\t\t(hide yes)')
            s.append('\t\t\t(show_name no)\n\t\t\t(do_not_autoplace no)')
            s.append('\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.0 1.0)\n\t\t\t\t)\n\t\t\t)')
            s.append('\t\t)')
            py += 1.8
        pins = self.lib.pins(l, n).get(unit, [])
        for num, pname, *_ in pins:
            s.append(f'\t\t(pin "{num}"\n\t\t\t(uuid "{uid()}")\n\t\t)')
        s.append('\t\t(instances')
        s.append(f'\t\t\t(project "{self.project}"')
        s.append(f'\t\t\t\t(path "/{self.root}"')
        s.append(f'\t\t\t\t\t(reference "{ref}")\n\t\t\t\t\t(unit {unit})\n\t\t\t\t)')
        s.append('\t\t\t)\n\t\t)\n\t)')
        self.items.append('\n'.join(s))

        pmap = {}
        for num, pname, et, px, py2, ang in pins:
            dx, dy = rot_pt(px, py2, rot)
            ddx, ddy = rot_dir(ang, rot)
            abs_x, abs_y = snap(x + dx), snap(y + dy)
            pmap[num] = (abs_x, abs_y, ddx, ddy)
            self.pin_num_of[(ref, num)] = num
            if pname:
                # first-wins, matching pmap.setdefault() above: several real
                # parts (e.g. TPS62130) reuse one name like "GND" or "VIN" on
                # several distinct physical pins, so a later assignment here
                # must NOT silently repoint the name at a different pin than
                # the coordinate lookup resolves to (that mismatch made
                # verify_intent() think a power()/connect() call landed on a
                # different physical pin than the wire it actually drew).
                if (ref, pname) not in self.pin_num_of:
                    self.pin_num_of[(ref, pname)] = num
                pmap.setdefault(pname, (abs_x, abs_y, ddx, ddy))
        self.pins_of[ref] = pmap
        return pmap

    def pin_xy(self, ref, key):
        try:
            return self.pins_of[ref][str(key)]
        except KeyError:
            return self.pins_of[ref][key]

    def _obstacles_on(self, x1, y1, x2, y2, skip):
        """Every placed pin coordinate lying strictly between (x1,y1) and
        (x2,y2) on an axis-aligned segment, excluding the segment's own
        endpoints (`skip`, a set of (x,y) tuples). Used to catch a wire
        silently passing through -- and shorting into -- some other
        component's pin that just happens to sit on the same row/column
        (this bit repeatedly: a straight or rail-drop wire to one pin of a
        part routed right through that SAME part's other pin, or an
        unrelated part's, because grid-snapped layouts make collinear pins
        common)."""
        eps = 0.02
        hits = []
        if abs(x1 - x2) < eps:
            lo, hi = sorted([y1, y2])
            for (px, py) in self._all_pin_coords():
                if (px, py) in skip:
                    continue
                if abs(px - x1) <= eps and lo + eps < py < hi - eps:
                    hits.append((px, py))
        elif abs(y1 - y2) < eps:
            lo, hi = sorted([x1, x2])
            for (px, py) in self._all_pin_coords():
                if (px, py) in skip:
                    continue
                if abs(py - y1) <= eps and lo + eps < px < hi - eps:
                    hits.append((px, py))
        return hits

    def _all_pin_coords(self):
        if not hasattr(self, '_pin_coord_cache') or self._pin_coord_cache_n != len(self.pins_of):
            coords = set()
            for pmap in self.pins_of.values():
                for (x, y, dx, dy) in pmap.values():
                    coords.add((round(x, 2), round(y, 2)))
            self._pin_coord_cache = coords
            self._pin_coord_cache_n = len(self.pins_of)
        return self._pin_coord_cache

    # ---- drawing primitives ----------------------------------------
    def wire(self, points):
        """Record a wire request. Emission is DEFERRED to finalize_wires()
        (called once, after the whole schematic is placed) so the
        obstacle check below sees every component's pins -- not just the
        ones placed so far -- regardless of the order blocks were built in.
        A component placed later in the script (e.g. an output cap added
        after the switch-node wire it happens to sit under) is just as
        real an obstacle as one placed earlier."""
        self._pending_wires.append([(x, y) for x, y in points])

    def finalize_wires(self):
        """Process every deferred wire() call: split into 2-point segments,
        check each for obstacles (see _obstacles_on) against the COMPLETE
        final pin set, and detour around any with a small perpendicular
        jog -- otherwise the segment would electrically short into
        whatever pin it silently passed over. Also checks each segment
        against every OTHER call's segments for a collinear/overlapping
        run belonging to a different intended net (two unrelated
        connect()/net() calls choosing the same row or column for an
        intermediate hop -- e.g. two different "route through a shared
        clear lane at y=10" choices -- which shorts them together without
        ever passing through a named pin at all). Call once, after the
        last place()/connect()/etc., before render()."""
        pending, self._pending_wires = self._pending_wires, []
        for points in pending:
            self._emit_wire_checked(points, tag=None)

    def _seg_overlap(self, x1, y1, x2, y2, ox1, oy1, ox2, oy2, eps=0.02):
        """Do two axis-aligned segments overlap over a real span (not just
        touch at a point)? Both must be on the same line (same X if both
        vertical, same Y if both horizontal) with overlapping ranges."""
        vert_a = abs(x1 - x2) < eps
        vert_b = abs(ox1 - ox2) < eps
        if vert_a != vert_b:
            return False
        if vert_a:
            if abs(x1 - ox1) > eps:
                return False
            lo1, hi1 = sorted([y1, y2])
            lo2, hi2 = sorted([oy1, oy2])
        else:
            if abs(y1 - oy1) > eps:
                return False
            lo1, hi1 = sorted([x1, x2])
            lo2, hi2 = sorted([ox1, ox2])
        lo, hi = max(lo1, lo2), min(hi1, hi2)
        return hi - lo > eps   # real overlap, not just a shared endpoint

    def _emit_wire_checked(self, points, tag=None):
        pts = [(snap(x), snap(y)) for x, y in points]
        JOG = 1 * GRID   # NOT 0.5*GRID: that lands exactly on a snap() rounding
                         # tie and gets rounded straight back to 0, silently
                         # cancelling the whole detour (found the hard way)
        segs = [(p1, p2) for p1, p2 in zip(pts, pts[1:]) if p1 != p2]
        for (x1, y1), (x2, y2) in segs:
            hits = self._obstacles_on(x1, y1, x2, y2, skip={(x1, y1), (x2, y2)})
            cross_wire_hit = False
            if not hits and tag is not None:
                for (ox1, oy1, ox2, oy2, otag) in self._emitted_segments:
                    if otag != tag and self._seg_overlap(x1, y1, x2, y2, ox1, oy1, ox2, oy2):
                        cross_wire_hit = True
                        break
            if not hits and not cross_wire_hit:
                self.items.append(
                    f'\t(wire\n\t\t(pts (xy {x1:.2f} {y1:.2f}) (xy {x2:.2f} {y2:.2f}))\n'
                    f'\t\t(stroke\n\t\t\t(width 0)\n\t\t\t(type default)\n\t\t)\n'
                    f'\t\t(uuid "{uid()}")\n\t)')
                self._emitted_segments.append((x1, y1, x2, y2, tag))
                continue
            # Detour: step off the line by JOG for the whole span, clearing
            # every obstacle on it regardless of where it sits, then return.
            vertical = abs(x1 - x2) < 0.02
            if vertical:
                jx1, jy1 = snap(x1 + JOG), y1
                jx2, jy2 = snap(x2 + JOG), y2
            else:
                jx1, jy1 = x1, snap(y1 + JOG)
                jx2, jy2 = x2, snap(y2 + JOG)
            detour = [(x1, y1), (jx1, jy1), (jx2, jy2), (x2, y2)]
            for (ax, ay), (bx, by) in zip(detour, detour[1:]):
                if (ax, ay) == (bx, by):
                    continue
                self.items.append(
                    f'\t(wire\n\t\t(pts (xy {ax:.2f} {ay:.2f}) (xy {bx:.2f} {by:.2f}))\n'
                    f'\t\t(stroke\n\t\t\t(width 0)\n\t\t\t(type default)\n\t\t)\n'
                    f'\t\t(uuid "{uid()}")\n\t)')
                self._emitted_segments.append((ax, ay, bx, by, tag))

    def junction(self, x, y):
        self.items.append(
            f'\t(junction\n\t\t(at {snap(x):.2f} {snap(y):.2f})\n\t\t(diameter 0)\n'
            f'\t\t(color 0 0 0 0)\n\t\t(uuid "{uid()}")\n\t)')

    def label(self, text, x, y, rot=0, just='left bottom', kind='local'):
        x, y = snap(x), snap(y)
        esc = text.replace('\\', '\\\\').replace('"', '\\"')
        tag = {'local': 'label', 'global': 'global_label', 'hier': 'hierarchical_label'}[kind]
        extra = ''
        if tag != 'label':
            extra = '\n\t\t(shape input)'
        self.items.append(
            f'\t({tag} "{esc}"\n\t\t(at {x:.2f} {y:.2f} {rot}){extra}\n'
            f'\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.27 1.27)\n\t\t\t)\n'
            f'\t\t\t(justify {just})\n\t\t)\n\t\t(uuid "{uid()}")\n\t)')

    def noconn(self, ref, pin_key):
        x, y, *_ = self.pin_xy(ref, pin_key)
        self.items.append(f'\t(no_connect\n\t\t(at {x:.2f} {y:.2f})\n\t\t(uuid "{uid()}")\n\t)')

    def text(self, t, x, y, size=2.5, bold=True):
        esc = t.replace('"', '\\"')
        b = '\n\t\t\t\t(bold yes)' if bold else ''
        self.items.append(
            f'\t(text "{esc}"\n\t\t(exclude_from_sim no)\n\t\t(at {x:.2f} {y:.2f} 0)\n'
            f'\t\t(effects\n\t\t\t(font\n\t\t\t\t(size {size} {size}){b}\n\t\t\t)\n'
            f'\t\t\t(justify left bottom)\n\t\t)\n\t\t(uuid "{uid()}")\n\t)')

    def rect_box(self, x0, y0, x1, y1):
        self.items.append(
            f'\t(rectangle\n\t\t(start {x0:.2f} {y0:.2f})\n\t\t(end {x1:.2f} {y1:.2f})\n'
            f'\t\t(stroke\n\t\t\t(width 0.25)\n\t\t\t(type dash)\n\t\t)\n'
            f'\t\t(fill\n\t\t\t(type none)\n\t\t)\n\t\t(uuid "{uid()}")\n\t)')

    # ---- real-wire connection helpers -------------------------------
    def connect(self, a, apin, b, bpin, bend='auto', stub=2 * GRID, via=None):
        """Draw a real wire between two placed pins. For a connection that
        must cross a lot of empty distance (e.g. between blocks), pass
        explicit `via=[(x,y), ...]` waypoints -- the naive 1-2 segment
        auto-bend below is only safe for short, local, same-block hops: a
        long automatic bend has a real chance of sweeping directly through
        an unrelated component's pin that happens to share the bend's X or
        Y coordinate, which is a silent short (found the hard way)."""
        self.intend(a, apin, b, bpin)
        ax, ay, adx, ady = self.pin_xy(a, apin)
        bx, by, bdx, bdy = self.pin_xy(b, bpin)
        if via:
            # Stub out from each pin along its own outward direction first,
            # then visit the explicit waypoints. Any consecutive pair that
            # shares neither X nor Y (including a badly-chosen via point)
            # gets an automatic L-corner inserted -- a straight pin->via
            # jump is NOT guaranteed to be axis-aligned and previously
            # produced silent diagonal "wires" (KiCad still draws them, but
            # they are not a real orthogonal schematic connection).
            a2 = (snap(ax + adx * stub), snap(ay + ady * stub))
            b2 = (snap(bx + bdx * stub), snap(by + bdy * stub))
            raw = [(ax, ay)]
            if a2 != (ax, ay):
                raw.append(a2)
            raw += list(via)
            raw.append(b2)
            raw.append((bx, by))
            pts = [raw[0]]
            for p in raw[1:]:
                x1, y1 = pts[-1]
                x2, y2 = p
                if abs(x1 - x2) > 1e-6 and abs(y1 - y2) > 1e-6:
                    pts.append((x2, y1))   # horizontal-then-vertical corner
                if p != pts[-1]:
                    pts.append(p)
            self.wire(pts)
            return
        if abs(ax - bx) < 1e-6 or abs(ay - by) < 1e-6:
            self.wire([(ax, ay), (bx, by)])
            return
        # short outward stubs from each pin before the bend, for a cleaner look
        a2 = (snap(ax + adx * stub), snap(ay + ady * stub))
        b2 = (snap(bx + bdx * stub), snap(by + bdy * stub))
        if bend == 'auto':
            bend = 'h' if abs(a2[0] - b2[0]) >= abs(a2[1] - b2[1]) else 'v'
        corner = (b2[0], a2[1]) if bend == 'h' else (a2[0], b2[1])
        pts = [(ax, ay)]
        if a2 != (ax, ay):
            pts.append(a2)
        if corner != pts[-1]:
            pts.append(corner)
        if b2 != corner:
            pts.append(b2)
        pts.append((bx, by))
        # dedupe consecutive duplicate points
        clean = [pts[0]]
        for p in pts[1:]:
            if p != clean[-1]:
                clean.append(p)
        self.wire(clean)

    def net(self, pins):
        """Connect 3+ pins with a rail (anchored at the first pin's axis) +
        junctions, or fall back to connect() for exactly 2."""
        if len(pins) == 2:
            (a, ap), (b, bp) = pins
            self.connect(a, ap, b, bp)
            return
        for ref, key in pins[1:]:
            self.intend(pins[0][0], pins[0][1], ref, key)
        anchor = self.pin_xy(*pins[0])
        rail_y = anchor[1]
        for ref, key in pins[1:]:
            x, y, *_ = self.pin_xy(ref, key)
            if abs(y - rail_y) < 1e-6:
                continue
        # horizontal rail at rail_y spanning all pin x's
        xs = [self.pin_xy(r, k)[0] for r, k in pins]
        x0, x1 = min(xs), max(xs)
        self.wire([(x0, rail_y), (x1, rail_y)])
        for ref, key in pins:
            x, y, *_ = self.pin_xy(ref, key)
            if abs(y - rail_y) > 1e-6:
                self.wire([(x, rail_y), (x, y)])
            self.junction(x, rail_y)

    def power(self, ref, pin_key, net_name, stub=1 * GRID):
        """Place a stock power symbol (power:GND, power:+5V, ...) near
        ref.pin_key and wire a real stub to it. These are power_in-typed
        and globally merge by name -- exactly what's wanted for GND and
        other universally-shared rails. For a custom local rail that needs
        an ERC-satisfying driver, use drive_rail() instead (a non-global
        flag -- see its docstring for why)."""
        self.intend_named(ref, pin_key, net_name)
        x, y, dx, dy = self.pin_xy(ref, pin_key)
        px, py = snap(x + dx * stub), snap(y + dy * stub)
        pwr_rot = {(1, 0): 180, (-1, 0): 0, (0, -1): 90, (0, 1): 270}
        key = (round(dx), round(dy))
        rot = pwr_rot.get(key, 0)
        sref = f'#PWR{len(self.refs_seen) + 1:03d}'
        self.refs_seen.add(sref)
        self.need('power', net_name)
        pu = uid()
        s = ['\t(symbol', f'\t\t(lib_id "power:{net_name}")', f'\t\t(at {px:.2f} {py:.2f} {rot})',
             '\t\t(unit 1)', '\t\t(body_style 1)', '\t\t(exclude_from_sim no)',
             '\t\t(in_bom yes)', '\t\t(on_board yes)', '\t\t(in_pos_files yes)',
             '\t\t(dnp no)', f'\t\t(uuid "{pu}")']
        for name, v, hide in [('Reference', sref, True), ('Value', net_name, False),
                               ('Footprint', '', True), ('Datasheet', '', True)]:
            s.append(f'\t\t(property "{name}" "{v}"')
            s.append(f'\t\t\t(at {px:.2f} {py - 2.5:.2f} 0)')
            if hide: s.append('\t\t\t(hide yes)')
            s.append('\t\t\t(show_name no)\n\t\t\t(do_not_autoplace no)')
            s.append('\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.0 1.0)\n\t\t\t\t)\n\t\t\t)')
            s.append('\t\t)')
        for num, *_ in self.lib.pins('power', net_name).get(1, []):
            s.append(f'\t\t(pin "{num}"\n\t\t\t(uuid "{uid()}")\n\t\t)')
        s.append('\t\t(instances')
        s.append(f'\t\t\t(project "{self.project}"')
        s.append(f'\t\t\t\t(path "/{self.root}"')
        s.append(f'\t\t\t\t\t(reference "{sref}")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)')
        s.append('\t\t\t)\n\t\t)\n\t)')
        self.items.append('\n'.join(s))
        self.wire([(x, y), (px, py)])

    def drive_rail(self, ref, pin_key, net_name, stub=1 * GRID):
        """Mark `net_name` as ERC-driven at ref.pin_key with a LOCAL
        (non-global) power_out flag -- deliberately NOT `(power global)`,
        because KiCad 10's ERC treats every `(power global)` symbol as one
        shared pool for the 'two power outputs on a net' check regardless
        of name/value, so a global flag on one rail can falsely conflict
        with a genuine regulator output pin on a totally unrelated rail
        elsewhere in the design (confirmed empirically). A plain, per-net
        local symbol avoids that: it only ties into the real net it's
        physically wired to, via the normal wire/connect() graph."""
        x, y, dx, dy = self.pin_xy(ref, pin_key)
        px, py = snap(x + dx * stub), snap(y + dy * stub)
        libname = f'DRV_{net_name}'
        if ('power', libname) not in self.used:
            body = (
                f'\t(symbol "power:{libname}"\n'
                f'\t\t(pin_numbers\n\t\t\t(hide yes)\n\t\t)\n'
                f'\t\t(pin_names\n\t\t\t(offset 0)\n\t\t\t(hide yes)\n\t\t)\n'
                f'\t\t(exclude_from_sim no)\n\t\t(in_bom yes)\n\t\t(on_board yes)\n'
                f'\t\t(in_pos_files yes)\n\t\t(duplicate_pin_numbers_are_jumpers no)\n'
                f'\t\t(property "Reference" "#DRV"\n\t\t\t(at 0 -3.81 0)\n\t\t\t(show_name no)\n'
                f'\t\t\t(do_not_autoplace no)\n\t\t\t(hide yes)\n'
                f'\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.27 1.27)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n'
                f'\t\t(property "Value" "{net_name} (driven)"\n\t\t\t(at 0 3.556 0)\n\t\t\t(show_name no)\n'
                f'\t\t\t(do_not_autoplace no)\n'
                f'\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.0 1.0)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n'
                f'\t\t(property "Footprint" ""\n\t\t\t(at 0 0 0)\n\t\t\t(show_name no)\n'
                f'\t\t\t(do_not_autoplace no)\n\t\t\t(hide yes)\n'
                f'\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.27 1.27)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n'
                f'\t\t(symbol "{libname}_0_1"\n'
                f'\t\t\t(polyline\n\t\t\t\t(pts (xy -0.762 1.27) (xy 0 2.54))\n'
                f'\t\t\t\t(stroke\n\t\t\t\t\t(width 0)\n\t\t\t\t\t(type default)\n\t\t\t\t)\n'
                f'\t\t\t\t(fill\n\t\t\t\t\t(type none)\n\t\t\t\t)\n\t\t\t)\n'
                f'\t\t\t(polyline\n\t\t\t\t(pts (xy 0 2.54) (xy 0.762 1.27))\n'
                f'\t\t\t\t(stroke\n\t\t\t\t\t(width 0)\n\t\t\t\t\t(type default)\n\t\t\t\t)\n'
                f'\t\t\t\t(fill\n\t\t\t\t\t(type none)\n\t\t\t\t)\n\t\t\t)\n'
                f'\t\t\t(polyline\n\t\t\t\t(pts (xy 0 0) (xy 0 2.54))\n'
                f'\t\t\t\t(stroke\n\t\t\t\t\t(width 0)\n\t\t\t\t\t(type default)\n\t\t\t\t)\n'
                f'\t\t\t\t(fill\n\t\t\t\t\t(type none)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n'
                f'\t\t(symbol "{libname}_1_1"\n'
                f'\t\t\t(pin power_out line\n\t\t\t\t(at 0 0 90)\n\t\t\t\t(length 0)\n'
                f'\t\t\t\t(name "{libname}"\n\t\t\t\t\t(effects\n\t\t\t\t\t\t(font\n\t\t\t\t\t\t\t(size 1.27 1.27)\n'
                f'\t\t\t\t\t\t)\n\t\t\t\t\t)\n\t\t\t\t)\n'
                f'\t\t\t\t(number "1"\n\t\t\t\t\t(effects\n\t\t\t\t\t\t(font\n\t\t\t\t\t\t\t(size 1.27 1.27)\n'
                f'\t\t\t\t\t\t)\n\t\t\t\t\t)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n'
                f'\t\t(embedded_fonts no)\n\t)')
            self.used[('power', libname)] = body
        sref = f'#DRV{len(self.refs_seen) + 1:03d}'
        self.refs_seen.add(sref)
        pu = uid()
        s = ['\t(symbol', f'\t\t(lib_id "power:{libname}")', f'\t\t(at {px:.2f} {py:.2f} 0)',
             '\t\t(unit 1)', '\t\t(body_style 1)', '\t\t(exclude_from_sim no)',
             '\t\t(in_bom yes)', '\t\t(on_board yes)', '\t\t(in_pos_files yes)',
             '\t\t(dnp no)', f'\t\t(uuid "{pu}")']
        for name, v, hide in [('Reference', sref, True), ('Value', f'{net_name} (driven)', True),
                               ('Footprint', '', True), ('Datasheet', '', True)]:
            s.append(f'\t\t(property "{name}" "{v}"')
            s.append(f'\t\t\t(at {px:.2f} {py - 2.5:.2f} 0)')
            if hide: s.append('\t\t\t(hide yes)')
            s.append('\t\t\t(show_name no)\n\t\t\t(do_not_autoplace no)')
            s.append('\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.0 1.0)\n\t\t\t\t)\n\t\t\t)')
            s.append('\t\t)')
        s.append('\t\t(pin "1"\n\t\t\t(uuid "' + uid() + '")\n\t\t)')
        s.append('\t\t(instances')
        s.append(f'\t\t\t(project "{self.project}"')
        s.append(f'\t\t\t\t(path "/{self.root}"')
        s.append(f'\t\t\t\t\t(reference "{sref}")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)')
        s.append('\t\t\t)\n\t\t)\n\t)')
        self.items.append('\n'.join(s))
        self.wire([(x, y), (px, py)])

    def pwr_flag(self, ref, pin_key, stub=2 * GRID):
        """Place a power:PWR_FLAG (power_out) on the same net as ref.pin_key.
        Standard KiCad fix for the 'input power pin not driven' ERC rule on
        nets whose only power symbols (GND, named rails) are themselves
        power_in -- a real, deliberate ERC-satisfying marker, not a
        connectivity shortcut (the real wire to the pin already exists)."""
        x, y, dx, dy = self.pin_xy(ref, pin_key)
        fx, fy = snap(x + dx * stub * 2), snap(y + dy * stub * 2)
        sref = f'#DRV{len(self.refs_seen) + 1:03d}'
        self.refs_seen.add(sref)
        self.need('power', 'PWR_FLAG')
        pu = uid()
        s = ['\t(symbol', '\t\t(lib_id "power:PWR_FLAG")', f'\t\t(at {fx:.2f} {fy:.2f} 0)',
             '\t\t(unit 1)', '\t\t(body_style 1)', '\t\t(exclude_from_sim no)',
             '\t\t(in_bom yes)', '\t\t(on_board yes)', '\t\t(in_pos_files yes)',
             '\t\t(dnp no)', f'\t\t(uuid "{pu}")']
        for name, v, hide in [('Reference', sref, True), ('Value', 'PWR_FLAG', True),
                               ('Footprint', '', True), ('Datasheet', '', True)]:
            s.append(f'\t\t(property "{name}" "{v}"')
            s.append(f'\t\t\t(at {fx:.2f} {fy - 2.5:.2f} 0)')
            if hide: s.append('\t\t\t(hide yes)')
            s.append('\t\t\t(show_name no)\n\t\t\t(do_not_autoplace no)')
            s.append('\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.0 1.0)\n\t\t\t\t)\n\t\t\t)')
            s.append('\t\t)')
        for num, *_ in self.lib.pins('power', 'PWR_FLAG').get(1, []):
            s.append(f'\t\t(pin "{num}"\n\t\t\t(uuid "{uid()}")\n\t\t)')
        s.append('\t\t(instances')
        s.append(f'\t\t\t(project "{self.project}"')
        s.append(f'\t\t\t\t(path "/{self.root}"')
        s.append(f'\t\t\t\t\t(reference "{sref}")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)')
        s.append('\t\t\t)\n\t\t)\n\t)')
        self.items.append('\n'.join(s))
        self.wire([(x, y), (fx, fy)])

    def verify_intent(self, raise_on_error=True):
        """Compare INTENDED connectivity (built up by connect/net/power calls
        via self._intent_parent) against ACTUAL geometric connectivity (every
        wire endpoint + any pin that lies ON a wire's segment, not just at
        its ends). Reports every pair of pins that ended up on the same real
        net without this code ever asking for that -- the class of bug this
        whole method exists to catch: two unrelated pins whose auto-routed
        stubs/bends happened to be collinear and overlapping, an accidental
        dead short that plain ERC does not flag (pin-type rules don't catch
        a passive-to-passive short) and that a screenshot only shows if you
        happen to zoom into exactly the right spot."""
        import re as _re
        wires = []
        for it in self.items:
            m = _re.search(r'\(wire\s*\n\s*\(pts \(xy ([\d.-]+) ([\d.-]+)\) \(xy ([\d.-]+) ([\d.-]+)\)\)', it)
            if m:
                x1, y1, x2, y2 = map(float, m.groups())
                wires.append(((round(x1, 2), round(y1, 2)), (round(x2, 2), round(y2, 2))))

        def on_segment(px, py, x1, y1, x2, y2, eps=0.02):
            if abs(x1 - x2) < eps:
                if abs(px - x1) > eps:
                    return False
                lo, hi = sorted([y1, y2])
                return lo - eps <= py <= hi + eps
            if abs(y1 - y2) < eps:
                if abs(py - y1) > eps:
                    return False
                lo, hi = sorted([x1, x2])
                return lo - eps <= px <= hi + eps
            return False

        node_coord = {}   # (ref, num) -> coord
        for ref, pmap in self.pins_of.items():
            for key, (x, y, dx, dy) in pmap.items():
                num = self.pin_num_of.get((ref, key))
                if num is not None:
                    node_coord[(ref, num)] = (round(x, 2), round(y, 2))
        coord_nodes = {}
        for node, c in node_coord.items():
            coord_nodes.setdefault(c, []).append(node)

        gparent = {}
        cause = {}   # coord -> (seg_a, seg_b) that first merged it into its group
        def gfind(p):
            gparent.setdefault(p, p)
            while gparent[p] != p:
                gparent[p] = gparent[gparent[p]]
                p = gparent[p]
            return p
        def gunion(a, b, why=None):
            ra, rb = gfind(a), gfind(b)
            if ra != rb:
                gparent[ra] = rb
                if why:
                    cause[ra] = why

        all_coords = list(coord_nodes.keys())
        for a, b in wires:
            gunion(a, b, (a, b))
            for c in all_coords:
                if c != a and c != b and on_segment(c[0], c[1], a[0], a[1], b[0], b[1]):
                    gunion(c, a, (a, b))

        violations = []
        seen_pairs = set()
        nodes = list(node_coord.keys())
        # group nodes by their geometric root, then check intent within each group
        geo_groups = {}
        for n in nodes:
            c = node_coord[n]
            geo_groups.setdefault(gfind(c), []).append(n)
        for root, members in geo_groups.items():
            if len(members) < 2:
                continue
            by_intent = {}
            for n in members:
                by_intent.setdefault(self._ifind(n), []).append(n)
            if len(by_intent) > 1:
                groups_desc = [sorted(f"{r}.{num}" for r, num in grp) for grp in by_intent.values()]
                key = tuple(sorted(tuple(g) for g in groups_desc))
                if key in seen_pairs:
                    continue
                seen_pairs.add(key)
                violations.append(groups_desc)

        if violations:
            msg = ["verify_intent: found unintended net merges (real shorts):"]
            for groups_desc in violations:
                msg.append("  MERGED (not asked for): " + "  <->  ".join(" + ".join(g) for g in groups_desc))
            report = "\n".join(msg)
            if raise_on_error:
                raise AssertionError(report)
            print(report)
        return violations

    def render(self, w, h, title, rev, company, date):
        out = ['(kicad_sch', '\t(version 20260306)', '\t(generator "eeschema")',
               '\t(generator_version "10.0")', f'\t(uuid "{self.root}")',
               f'\t(paper "User" {w} {h})', '\t(title_block',
               f'\t\t(title "{title}")', f'\t\t(date "{date}")', f'\t\t(rev "{rev}")',
               f'\t\t(company "{company}")', '\t)', '\t(lib_symbols']
        out += list(self.used.values()); out.append('\t)')
        out += self.items
        out.append('\t(sheet_instances\n\t\t(path "/"\n\t\t\t(page "1")\n\t\t)\n\t)')
        out.append('\t(embedded_fonts no)\n)')
        return '\n'.join(out) + '\n'


def check_balance(text):
    d, ins, esc = 0, False, False
    for ch in text:
        if esc: esc = False; continue
        if ch == '\\': esc = True; continue
        if ch == '"': ins = not ins; continue
        if ins: continue
        d += (ch == '(') - (ch == ')')
    return d == 0