main
TylerK Update 51 file(s) 5951069 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824
#!/usr/bin/env python3
"""PCB layout for the MCU/IC-based solar MPPT power system.
4-layer board (F.Cu / In1.Cu GND plane / In2.Cu signal / B.Cu), compact and
mostly SMD -- the direct comparison point against the old discrete-analog
board's 250x160mm THT-heavy layout. Critical switching loops (LM5175 4-switch
stage, both POL buck switch nodes) and Kelvin current-sense pairs are hand
placed; everything else is grid-clustered per schematic block.
"""
import os, re, json, math, uuid as U

from boardsize import BOARD_W, BOARD_H   # single source of truth
from fplib import FpLib

def uid(): return str(U.uuid4())

HERE = os.path.dirname(__file__)
NET = json.load(open(os.path.join(HERE, "mppt_mcu_netlist.json")))
COMP = NET["components"]
REF_PIN_NET = NET["ref_pin_nets"]

FP_ROOT = "/mnt/c/Program Files/KiCad/10.0/share/kicad/footprints"

_fp_cache = {}
def fp_path(lib_footprint):
    if lib_footprint not in _fp_cache:
        lib, name = lib_footprint.split(':', 1)
        _fp_cache[lib_footprint] = os.path.join(FP_ROOT, f"{lib}.pretty", f"{name}.kicad_mod")
    return _fp_cache[lib_footprint]

ALL_FOOTPRINTS = {fp: fp_path(fp) for fp in {c["footprint"] for c in COMP.values() if c["footprint"]}}
fp = FpLib(ALL_FOOTPRINTS)

_pads_geom_cache = {}


def _pads_geom(lib_id):
    """(number, x, y, angle, w, h) for every pad, parsed per pad BLOCK.

    Keyed positionally, not by pad number, because pad numbers are not unique:
    SOT-223-3_TabPin2 has two pads numbered "2" -- the 2.0x1.5mm signal pin and
    the 2.0x3.8mm tab. A number-keyed size lookup gave the small pin the tab's
    height, which made it appear to overlap its neighbour by 0.35mm and very
    nearly earned U3 a spurious clearance override for a defect that does not
    exist.
    """
    if lib_id in _pads_geom_cache:
        return _pads_geom_cache[lib_id]
    import geomcheck as _G
    out = []
    txt = open(fp_path(lib_id), errors='replace').read()
    for pblk in _G._blocks(txt, 'pad'):
        mn = re.match(r'\(pad "(\w*)"', pblk)
        ma = re.search(r'\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', pblk)
        ms = re.search(r'\(size ([-\d.]+) ([-\d.]+)\)', pblk)
        if not (mn and ma and ms and mn.group(1)):
            continue
        out.append((mn.group(1), float(ma.group(1)), float(ma.group(2)),
                    float(ma.group(3) or 0),
                    float(ms.group(1)), float(ms.group(2))))
    _pads_geom_cache[lib_id] = out
    return out


def V(ref): return COMP[ref]["value"]
def FP(ref): return COMP[ref]["footprint"]

def straight_nets(ref, count):
    out = {}
    nets = REF_PIN_NET.get(ref, {})
    for i in range(1, count + 1):
        k = str(i)
        if k in nets:
            out[k] = nets[k]
    return out

footprints_out = []
placed = {}   # ref -> (x, y, rot) for reporting

def place(ref, x, y, rot=0, renumber=None):
    n = len(fp.pads(FP(ref)))
    nets_map = straight_nets(ref, n)
    footprints_out.append(fp.instance(FP(ref), ref, V(ref), x, y, rot, nets_map, renumber=renumber))
    placed[ref] = (x, y, rot)

def grid_place(refs, x0, y0, cols, pitch_x, pitch_y):
    """Record a cluster of passives for net-driven placement (see resolve()).

    This used to place them literally on the grid, in list order. That is how
    the LM5175's compensation network (R11/R12/C13) ended up ~28mm from the
    LM5175 and the LM5165's feedback/soft-start parts ~31mm from the LM5165:
    the grid slot a part got was decided by its position in a Python list, not
    by the pin it serves. Two consequences, one of them serious:

      * routing -- every one of the 10 links the router could not close was a
        small-signal pin reaching across the board to its own passive;
      * circuit behaviour -- COMP, SS, SLOPE and FB are high-impedance nodes
        on a switching controller. A 28mm trace from a switcher's error
        amplifier to its compensation network is an antenna running past the
        switch node. That is a real design defect, and it would have survived
        a perfectly clean DRC.

    The grid arguments are kept as the cluster's home region, which is still
    used to break ties and to keep each schematic block spatially coherent.
    """
    for i, ref in enumerate(refs):
        if ref not in COMP:
            continue
        r, c = divmod(i, cols)
        deferred.append((ref, x0 + c * pitch_x, y0 + r * pitch_y))


deferred = []      # (ref, home_x, home_y) -- placed after the anchors, by net

# A net that reaches many parts is a rail, not a connection: averaging its pad
# positions yields the centre of the board rather than anything useful, so
# rails must not attract. Derived from the netlist rather than hand-listed --
# the hand-listed version missed Net-(U3-VI), the bias rail, which feeds five
# ICs and duly dragged R4 (a U2 current-limit resistor) 44mm across the board
# toward the LM5175 instead of toward the LM5165 it belongs to.
_net_refs = {}
for _r, _pins in REF_PIN_NET.items():
    for _n in _pins.values():
        _net_refs.setdefault(_n, set()).add(_r)
DIFFUSE_NETS = {n for n, rs in _net_refs.items() if len(rs) > 4} | {"GND", ""}


# --------------------------------------------------------------------------
# Bypass capacitors belong to a PIN, not to a net
# --------------------------------------------------------------------------
# A decoupling capacitor's two nets are a supply rail and GND. Both are
# "diffuse" by the rule above -- a rail reaches more than four parts and GND
# reaches everything -- so a bypass cap has NO attractor at all and falls back
# to wherever its block's home region happens to be. That is how the LM5175's
# 100nF VCC bypass ended up 62.5mm from the VCC pin it bypasses, which is not a
# decoupling capacitor in any useful sense: at that distance the trace
# inductance is the circuit and the capacitor is decoration. Every IC on the
# board had the same problem to some degree.
#
# So a bypass cap is assigned to one specific supply PIN and placed against it.
# Assignment is greedy and deterministic: ICs in descending pin count (the
# big controllers get first pick, because they are the ones that misbehave),
# and for each supply pin the smallest unclaimed capacitor on that rail --
# small parts are the local bypass, bulk electrolytics are not.
def _cap_value_rank(ref):
    """Sort key that puts 100n before 1u before 10u before 100u."""
    v = (COMP.get(ref, {}).get("value") or "").strip().lower()
    m = re.match(r'([\d.]+)\s*([pnum]?)', v)
    if not m:
        return 1e9
    mult = {"p": 1e-12, "n": 1e-9, "u": 1e-6, "m": 1e-3, "": 1.0}
    try:
        return float(m.group(1)) * mult.get(m.group(2), 1.0)
    except ValueError:
        return 1e9


def _assign_bypass_caps():
    """{cap_ref: (ic_ref, pin)} -- which pin each bypass cap is there to serve.

    Round-robin, so every IC gets ONE capacitor before any IC gets a second.
    Assigning greedily by pin count instead gave the big controllers two and
    three caps each and left the INA180s with their nearest capacitor 78mm
    away, which is worse than the problem being solved: an IC with no local
    capacitor at all is the failure mode, not an IC whose second capacitor is
    a few millimetres further out than ideal.

    Within one IC the pins are tried in order and the smallest unclaimed
    capacitor on that net wins -- a 100n is a bypass, a 100u is bulk storage
    and does not need to be against the pin.
    """
    on_rail = {}
    for ref, pins in REF_PIN_NET.items():
        if not ref.startswith("C") or len(pins) != 2:
            continue
        nets = list(pins.values())
        if "GND" not in nets:
            continue
        other = [n for n in nets if n != "GND"]
        if other:
            on_rail.setdefault(other[0], []).append(ref)
    for rail in on_rail:
        on_rail[rail].sort(key=_cap_value_rank)

    ics = sorted((r for r, p in REF_PIN_NET.items()
                  if r.startswith("U") and len(p) >= 3),
                 key=lambda r: -len(REF_PIN_NET[r]))
    claimed, assign, got = set(), {}, {ic: 0 for ic in ics}

    for _round in range(3):
        for ic in ics:
            if got[ic] > _round:
                continue
            for pin, net in sorted(REF_PIN_NET[ic].items()):
                if net == "GND" or net.startswith("unconnected-"):
                    continue
                pick = next((c for c in on_rail.get(net, []) if c not in claimed),
                            None)
                if pick:
                    claimed.add(pick)
                    assign[pick] = (ic, pin)
                    got[ic] += 1
                    break
    return assign


def _assign_pin_partners():
    """{passive_ref: (ic_ref, pin)} for point-to-point IC-pin-to-passive nets.

    A net that reaches exactly two pins, one of them an IC pin and the other a
    two-pin discrete, means that discrete exists FOR that pin: a compensation
    resistor, a soft-start capacitor, a switch-node inductor. It belongs
    against the pin, and everywhere else is wrong twice over.

    Electrically, because COMP/SS/SLOPE/FB are high-impedance nodes on a
    switching controller and a centimetre of trace past a switch node is an
    antenna. And practically, because the router cannot close a link from a
    walled-in fine-pitch pin to a part on the far side of a crowded region:
    the last three unroutable nets on this board were exactly this pattern,
    with their partners 11 to 13mm away.
    """
    out = {}
    for net, pins in _net_refs.items():
        if net == "GND" or net.startswith("unconnected-") or not (2 <= len(pins) <= 3):
            continue
        ends = []
        for ref in pins:
            for pin, n2 in REF_PIN_NET.get(ref, {}).items():
                if n2 == net:
                    ends.append((ref, pin)); break
        if len(ends) != len(pins):
            continue
        ics = [e for e in ends if e[0].startswith("U") and len(REF_PIN_NET[e[0]]) >= 3]
        pas = [e for e in ends if len(REF_PIN_NET.get(e[0], {})) == 2]
        # One IC pin plus one or two two-pin discretes. The three-pin case is a
        # DIVIDER TAP -- an FB pin and the two resistors that set it -- and it
        # was missed entirely while the rule demanded exactly two pins. That is
        # the single case with the strongest claim to being at the pin: it is
        # the highest-impedance node on a switching regulator, and on revision 6
        # it was also the last link the router could not close, because the
        # LM5165's feedback divider had been left 9.3mm from a 0.5mm-pitch pin
        # that has no room to reach it.
        if len(ics) == 1 and len(pas) == len(ends) - 1 and pas:
            for pref, _ppin in pas:
                if pref != ics[0][0]:
                    out[pref] = ics[0]
    return out


PIN_PARTNER = _assign_pin_partners()

BYPASS_OF = _assign_bypass_caps()
# a point-to-point partner is the more specific relationship, so it wins
BYPASS_OF.update(PIN_PARTNER)


_crtyd_cache = {}


def _courtyard(lib_id):
    """Half-extent of the footprint's real F.CrtYd outline.

    Sizing keep-outs from the pad span plus a guess was close but not the same
    thing, and KiCad duly reported 5 courtyard overlaps: the courtyard is the
    manufacturer's keep-out for the body and the placement machine, and on a
    part like L1 (a large shielded inductor) it is much bigger than its pads.
    Fall back to the pad span only if a footprint has no courtyard drawn.
    """
    if lib_id in _crtyd_cache:
        return _crtyd_cache[lib_id]
    xs, ys = [], []
    try:
        import geomcheck as _G
        txt = open(fp_path(lib_id), errors='replace').read()
        for tag in ("fp_line", "fp_rect", "fp_poly", "fp_circle", "fp_arc"):
            for blk in _G._blocks(txt, tag):
                if '"F.CrtYd"' not in blk:
                    continue
                for mm in re.finditer(r'\((?:start|end|center|mid|xy) '
                                      r'([-\d.]+) ([-\d.]+)\)', blk):
                    xs.append(float(mm.group(1))); ys.append(float(mm.group(2)))
    except OSError:
        pass
    if not xs:
        pads = fp.pads_full(lib_id)
        if not pads:
            _crtyd_cache[lib_id] = (1.5, 1.5); return _crtyd_cache[lib_id]
        xs = [p[1] for p in pads]; ys = [p[2] for p in pads]
        _crtyd_cache[lib_id] = (max(xs) - min(xs)) / 2 + 1.1, (max(ys) - min(ys)) / 2 + 1.1
        return _crtyd_cache[lib_id]
    ew = max(abs(min(xs)), abs(max(xs)))
    eh = max(abs(min(ys)), abs(max(ys)))

    # A keep-out has to cover the COPPER, not just the body. On a TO-220 the
    # leads reach well past the F.CrtYd outline, so two parts can have clear
    # courtyards and still place their pads inside the clearance rule -- which
    # is exactly what happened on the first revision-6 placement: Q5's tab pin
    # landed 0.125mm from RS2's pad against a 0.2mm rule, and neither the
    # courtyard check nor DRC-on-the-placement could see it coming, because
    # nothing had been routed yet. Take the larger of the courtyard and the
    # pad span, and add half the clearance rule so two abutting keep-outs
    # leave a legal gap between their pads.
    half_clear = 0.11
    for _n, px, py, pang, pw, ph in _pads_geom(lib_id):
        a = math.radians(pang or 0.0)
        hw = abs(pw * math.cos(a)) / 2 + abs(ph * math.sin(a)) / 2
        hh = abs(pw * math.sin(a)) / 2 + abs(ph * math.cos(a)) / 2
        ew = max(ew, abs(px) + hw + half_clear)
        eh = max(eh, abs(py) + hh + half_clear)
    _crtyd_cache[lib_id] = (ew, eh)
    return _crtyd_cache[lib_id]


def _extent(ref):
    """Half-width/half-height of a footprint's keep-out box."""
    ew, eh = _courtyard(FP(ref))
    if round((placed.get(ref, (0, 0, 0))[2] or 0) / 90) % 2:
        ew, eh = eh, ew          # a 90/270 part occupies the transposed box
    return ew, eh


def _pad_world(ref, num):
    """World position of one pad of an already-placed footprint."""
    if ref not in placed:
        return None
    px, py, rot = placed[ref]
    th = math.radians(rot or 0)
    ct, st = math.cos(th), math.sin(th)
    for n, lx, ly, _ang, _w, _h in _pads_geom(FP(ref)):
        if str(n) == str(num):
            return (px + lx * ct + ly * st, py + ly * ct - lx * st)
    return (px, py)


def _attractors(ref):
    """Where the pins this part connects to actually are.

    Aimed at the footprint, not at the individual pad. Aiming at the exact pad
    was tried -- it is the more obviously "correct" model and it did shorten
    the average pin-to-partner distance slightly (168mm to 163mm across the
    difficult links) -- but it made the board measurably harder to route:
    fine-pitch escapes that closed went from 6 of 13 to 4 of 13. Pulling each
    part toward its own pin packs parts unevenly around a package and eats the
    fanout room on the busy side. Distance was never the binding constraint
    here; room next to the pin field is.
    """
    # A bypass cap is pulled to its own pin, hard, and to nothing else. Its
    # nets are a rail and GND, so the general rule below gives it no attractor
    # whatsoever -- see BYPASS_OF.
    if ref in BYPASS_OF:
        ic, pin = BYPASS_OF[ref]
        w = _pad_world(ic, pin)
        if w:
            return [(w[0], w[1], 50)]

    nets = {n for n in REF_PIN_NET.get(ref, {}).values() if n not in DIFFUSE_NETS}
    pts = []
    for other, pins in REF_PIN_NET.items():
        if other == ref or other not in placed:
            continue
        shared = len(nets & set(pins.values()))
        if shared:
            ox, oy, _ = placed[other]
            # an IC pulls harder than another passive: it is the fixed thing
            weight = shared * (3 if len(pins) > 4 else 1)
            pts.append((ox, oy, weight))
    return pts


def resolve():
    """Place every deferred passive next to what it connects to.

    Order matters: a part with a strong attachment to an already-fixed anchor
    is placed first, so it becomes an anchor for the next one. That lets an
    RC network assemble itself outward from the IC pin rather than each part
    independently piling onto the same spot.
    """
    todo = list(deferred)
    while todo:
        scored = []
        for ref, hx, hy in todo:
            pts = _attractors(ref)
            # bypass caps go down first: they are the only parts whose whole
            # value depends on being within a few millimetres of one pad, and
            # everything else can be placed around them
            first = 0 if ref in BYPASS_OF else 1
            scored.append((first, -sum(w for _, _, w in pts), ref, hx, hy, pts))
        scored.sort()
        _, _, ref, hx, hy, pts = scored[0]
        todo = [t for t in todo if t[0] != ref]
        if pts:
            tw = sum(w for _, _, w in pts)
            tx = sum(x * w for x, _, w in pts) / tw
            ty = sum(y * w for _, y, w in pts) / tw
            if ref not in BYPASS_OF:
                # keep a light pull toward the block's home region so a part
                # shared between two blocks does not drift into the gap between
                # them. A bypass cap gets no such pull: its home region is
                # exactly the thing that put it 62mm from its pin.
                tx = 0.85 * tx + 0.15 * hx
                ty = 0.85 * ty + 0.15 * hy
        else:
            tx, ty = hx, hy
        x, y = _free_slot(ref, tx, ty)
        place(ref, x, y)


def _free_slot(ref, tx, ty, step=1.0, max_r=45.0):
    """Nearest position to (tx,ty) whose courtyard hits nothing already placed."""
    ew, eh = _extent(ref)
    margin = 0.6
    r = 0.0
    while r <= max_r:
        n = max(1, int(2 * math.pi * r / step)) if r > 0 else 1
        for k in range(n):
            a = 2 * math.pi * k / n
            x = tx + r * math.cos(a)
            y = ty + r * math.sin(a)
            if not (6 + ew < x < BOARD_W - 6 - ew and 6 + eh < y < BOARD_H - 6 - eh):
                continue
            ok = True
            for oref, (ox, oy, _rot) in placed.items():
                ow, oh = _extent(oref)
                # Fanout keep-out. A fine-pitch IC needs a ring of clear board
                # around its pins for the escape traces to spread out in before
                # they can turn or take a via -- a 0.65mm pin pitch has no room
                # for a via between the pins themselves. Without this, pulling
                # the passives in close (which is right for the circuit) parks
                # them on top of the fanout: C15 landed 0.4mm off U4's pin
                # tips and sealed the escape for that whole side of the part.
                keep = margin + (2.6 if len(REF_PIN_NET.get(oref, {})) > 8 else 0.0)
                if (abs(x - ox) < ew + ow + keep
                        and abs(y - oy) < eh + oh + keep):
                    ok = False; break
            if ok:
                return x, y
        r += step
    return tx, ty

# ============================================================================
# PLACEMENT
#
# The anchors below are the parts whose position is a DESIGN decision rather
# than a consequence of one: connectors that must reach a board edge, the
# power-path parts whose order IS the power path, and the ICs everything else
# clusters around. Everything else is deferred to the net-driven placer.
#
# They live in a table rather than in a sequence of calls so that a placement
# can be generated, scored and compared without editing code -- see
# placescore.py for what "better" means and placeopt.py for the search. A
# placement is the one input to this board that a DC simulation cannot
# recover from: copper can be widened and paths can be added in parallel
# afterwards, but a part in the wrong place stays in the wrong place.
#
# This table is revision 6, produced by that search rather than by hand. Its
# starting point was one structural correction the search could never have
# found on its own: revision 5 placed RS2, the 2.5A charge-current shunt, in
# the "sensing" block beside the other INA180 instead of in the power path it
# measures, which put 157mm of the 338mm power chain into two hops. Each
# INA180 now sits beside its OWN shunt. Against revision 5, measured by
# placescore.py: current cost -26.9%, peak board temperature -6.8C, power
# chain -41%, and total switching-loop perimeter -58%.
#
# Revision 7 keeps that and fixes what it cost. Revision 6's objective weighted
# every net by I^2, which is proportional to loss and therefore correct -- and
# which duly spent the low-current output rails to buy the 5A charge path,
# leaving each POL's filter on the far side of the board from the connector it
# feeds (161mm and 165mm of output chain). Those chains are now scored
# directly, and the board frame shrank with them: 189x129 -> 173x118, a 17%
# smaller panel for about 1.5C of temperature.
#
# The table is then legalised against a keep-out box that unions the
# courtyard with the PADS. The courtyard alone is not enough: a TO-220's
# origin sits on pad 1 and its other leads reach 5mm past the drawn
# courtyard, so a courtyard-clean placement put Q5's third pin 0.125mm
# from RS2's pad against a 0.2mm rule -- which only surfaced after the
# board had been routed and the exact-geometry checker looked at it.
# ============================================================================

ANCHORS = {
    # -- power path, in the order current flows through it
    'J1'    : (  10.0,   20.0,   0),
    'F1'    : (  35.9,   33.5,  90),
    'Q1'    : (  55.7,   40.7,   0),
    'U1'    : (  69.9,   39.8,  90),
    'D1'    : (  54.7,   34.9,   0),
    'D2'    : (  83.1,   35.7,   0),
    'RS1'   : (  69.4,   46.1,  90),
    'U5'    : (  74.6,   40.3,  90),
    'Q2'    : (  72.2,   27.2,   0),
    'Q3'    : (  71.6,   21.2,   0),
    'C16'   : ( 100.8,   46.2,  90),
    'D3'    : (  37.7,   81.8,   0),
    'U4'    : (  84.5,   26.8,   0),
    'L2'    : ( 103.0,   31.0,   0),
    'C17'   : ( 119.4,   67.4,  90),
    'D4'    : (  80.2,   10.9,  90),
    'Q4'    : ( 119.2,   43.3,  90),
    'Q5'    : ( 123.8,   36.8,   0),
    'RS2'   : ( 138.5,   31.6,  90),
    'U6'    : ( 133.4,   36.6,  90),
    'J2'    : ( 166.0,   20.0,   0),
    # -- housekeeping bias
    'U2'    : (  57.4,   28.0,  90),
    'L1'    : (  84.9,   53.7,  90),
    'U3'    : (  81.6,  101.0,   0),
    # -- 5V output rail, regulator to connector
    'U8'    : ( 124.5,   83.7,  90),
    'FB1'   : ( 152.7,   80.7,   0),
    'C34'   : ( 140.0,   77.2,  90),
    'J3'    : ( 166.0,   76.0,   0),
    # -- 3V3 output rail, regulator to connector
    'U9'    : ( 153.3,   84.5,   0),
    'FB2'   : ( 161.7,  101.4,  90),
    'C35'   : ( 134.1,   80.6,  90),
    'J4'    : ( 166.0,   99.0,   0),
    # -- MCU, programming and telemetry
    'U7'    : ( 122.4,   53.0,  90),
    'J5'    : (  10.0,   96.0,   0),
    'J6'    : ( 166.0,  112.0,   0),
}

GRID_CLUSTERS = [
    (['C1', 'R1', 'C2', 'C3', 'C4'], 26.0, 40.0, 5, 12.0, 12.0),
    (['C5', 'C6', 'C7', 'R2', 'R3', 'C8', 'R6', 'R4', 'R5'], 16.0, 70.0, 5, 9.0, 9.0),
    (['C9', 'C10'], 46.0, 70.0, 2, 9.0, 9.0),
    (['C11', 'C12', 'C15', 'C18', 'C19', 'C20'], 86.0, 52.0, 6, 12.0, 12.0),
    (['R7', 'R8', 'R9', 'R10', 'R11', 'R12', 'R13', 'R14', 'R15', 'R16', 'R17', 'R18', 'C13', 'C14'], 106.0, 56.0, 7, 8.0, 8.0),
    (['C21', 'C22', 'C23', 'R19', 'R20'], 140.0, 44.0, 5, 12.0, 12.0),
    (['L3'], 130.0, 76.0, 1, 8.0, 8.0),
    (['C27', 'C29', 'C30', 'R25', 'R26', 'R27', 'C28'], 120.0, 88.0, 7, 8.0, 8.0),
    (['L4'], 130.0, 99.0, 1, 8.0, 8.0),
    (['C31', 'C32', 'C33', 'R28', 'R29'], 120.0, 110.0, 5, 8.0, 8.0),
    (['C24', 'C25', 'R22', 'C26', 'R23', 'LED1', 'R24', 'LED2'], 14.0, 110.0, 4, 7.0, 7.0),
    (['TP1', 'TP2', 'TP3', 'TP4'], 70.0, 104.0, 4, 8.0, 8.0),
]


def layout(anchors=None, clusters=None):
    """Place the anchors, defer the clusters, then run the net-driven placer."""
    global footprints_out, placed, deferred
    footprints_out = []
    placed = {}
    deferred = []
    _sync_globals()
    for ref, (x, y, rot) in (anchors or ANCHORS).items():
        if ref in COMP:
            place(ref, x, y, rot)
    for refs, x0, y0, cols, px, py in (clusters or GRID_CLUSTERS):
        grid_place(refs, x0, y0, cols, px, py)
    resolve()
    return placed


def _sync_globals():
    """place()/grid_place()/resolve() append to module-level lists; rebinding
    those names in layout() must be visible to them too."""
    g = globals()
    g["footprints_out"] = footprints_out
    g["placed"] = placed
    g["deferred"] = deferred


layout()
print(f"placement: {len(placed)} parts ({len(ANCHORS)} anchors, {len(deferred)} net-driven)")


def relax_fine_pitch_footprints():
    """Let a package's own land pattern be as tight as the package requires.

    KiCad's stock SOT-563 (U9) puts adjacent pins 0.150mm apart and its
    QFN-16-P0.5mm (U8) 0.177mm apart. Those gaps are the manufacturer's,
    fixed by the package pitch, and they sit below this board's 0.2mm netclass
    clearance -- so DRC reports the vendor footprint as an error before a
    single track is drawn. The wrong fixes are editing vendor land patterns
    (they are correct) or dropping the board's clearance to 0.15mm everywhere
    (which would loosen every real track on the board).

    The right one is a local clearance override on just those footprints,
    which is what the feature exists for. The router is unaffected -- it keeps
    its own 0.2mm rule regardless -- so this only stops DRC reporting a
    package's pitch as a layout defect.
    """
    import geomcheck as G
    n = 0
    for i, blk in enumerate(footprints_out):
        m = re.search(r'\(property "Reference" "([^"]*)"', blk)
        if not m: continue
        ref = m.group(1)
        objs = []
        for num, px, py, ang, w, h in _pads_geom(FP(ref)):
            net = REF_PIN_NET.get(ref, {}).get(str(num))
            objs.append(G.Obj("pad", net or f"~{num}", ("F.Cu",),
                              tag=f"{ref}.{num}", ax=px, ay=py, bx=px, by=py,
                              rect=G.ORect(px, py, w, h, ang)))
        worst = min((a.copper_gap(b) for i2, a in enumerate(objs)
                     for b in objs[i2 + 1:] if a.net != b.net), default=9.9)
        if worst < 0.2:
            local = max(0.05, math.floor(worst * 100) / 100)
            footprints_out[i] = blk.replace('\n\t\t(layer "F.Cu")',
                                            f'\n\t\t(layer "F.Cu")\n\t\t(clearance {local})', 1)
            print(f"   {ref}: package pitch gives {worst:.3f}mm pad-to-pad "
                  f"-> local clearance {local}mm")
            n += 1
    return n


print("fine-pitch packages given a local clearance override:")
relax_fine_pitch_footprints()
print(f"total footprints placed: {len(footprints_out)} / {len(COMP)} components in netlist")
missing = set(COMP) - set(placed)
if missing:
    print("NOT PLACED:", sorted(missing))

# ============================================================================
# Board outline, mounting holes, silkscreen, 4-layer stackup
# ============================================================================


def edge_rect(x0, y0, x1, y1):
    out = []
    for (ax, ay), (bx, by) in [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)),
                               ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]:
        out.append(f'\t(gr_line\n\t\t(start {ax} {ay})\n\t\t(end {bx} {by})\n'
                    f'\t\t(stroke\n\t\t\t(width 0.15)\n\t\t\t(type default)\n\t\t)\n'
                    f'\t\t(layer "Edge.Cuts")\n\t\t(uuid "{uid()}")\n\t)')
    return '\n'.join(out)

def mounting_hole(x, y, ref):
    return (f'\t(footprint "MountingHole:MountingHole_3.2mm_M3"\n'
            f'\t\t(layer "F.Cu")\n\t\t(uuid "{uid()}")\n\t\t(at {x} {y})\n'
            f'\t\t(property "Reference" "{ref}"\n\t\t\t(at 0 -3 0)\n\t\t\t(layer "F.SilkS")\n'
            f'\t\t\t(hide yes)\n\t\t\t(uuid "{uid()}")\n\t\t\t(effects (font (size 1 1)))\n\t\t)\n'
            f'\t\t(property "Value" "MountingHole"\n\t\t\t(at 0 3 0)\n\t\t\t(layer "F.Fab")\n'
            f'\t\t\t(hide yes)\n\t\t\t(uuid "{uid()}")\n\t\t\t(effects (font (size 1 1)))\n\t\t)\n'
            f'\t\t(attr exclude_from_pos_files exclude_from_bom)\n'
            f'\t\t(pad "1" np_thru_hole circle\n\t\t\t(at 0 0)\n\t\t\t(size 3.2 3.2)\n'
            f'\t\t\t(drill 3.2)\n\t\t\t(layers "*.Cu" "*.Mask")\n\t\t\t(uuid "{uid()}")\n\t\t)\n\t)')

def _label_at(ref, dx=0.0, dy=0.0, fallback=(10.0, 10.0)):
    """A silkscreen label position derived from where a part actually ended up."""
    if ref in placed:
        x, y, _ = placed[ref]
        return (round(min(max(x + dx, 6.0), BOARD_W - 40.0), 1),
                round(min(max(y + dy, 8.0), BOARD_H - 6.0), 1))
    return fallback


def silk_text(text, x, y, size=2.0):
    return (f'\t(gr_text "{text}"\n\t\t(at {x} {y} 0)\n\t\t(layer "F.SilkS")\n'
            f'\t\t(uuid "{uid()}")\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size {size} {size})\n'
            f'\t\t\t\t(thickness {size*0.15:.2f})\n\t\t\t)\n\t\t\t(justify left)\n\t\t)\n\t)')

silk = [
    # Block labels are positioned FROM the anchor table, not from constants.
    # Hand-written coordinates survive a re-placement looking perfectly valid
    # while pointing at empty board -- revision 5's said "5V/3.3V POL BUCKS"
    # over what became the charge path.
    silk_text("SOLAR MPPT POWER SYSTEM (MCU+ICs) - 4-layer - rev 7", 8, 6, 2.2),
    silk_text("J1: SOLAR IN", *_label_at("J1", dy=-9.0), 1.2),
    silk_text("MPPT / CHARGE STAGE (LM5175)", *_label_at("U4", dy=-13.0), 1.4),
    silk_text("PANEL I SENSE", *_label_at("RS1", dy=-8.0), 1.1),
    silk_text("CHARGE I SENSE", *_label_at("RS2", dy=-8.0), 1.1),
    silk_text("5V POL", *_label_at("U8", dx=-9.0, dy=-9.5), 1.2),
    silk_text("3V3 POL", *_label_at("U9", dy=-8.0), 1.2),
    silk_text("OUTPUT FILTER", *_label_at("FB1", dy=-7.0), 1.2),
    silk_text("MCU (STM32G031)", *_label_at("U7", dy=-9.0), 1.4),
    silk_text("J2 BATT / J3 5V / J4 3V3 / J6 UART", 120, 128, 1.2),
]

mounts = [
    mounting_hole(6, 6, "MH1"),
    mounting_hole(BOARD_W - 6, 6, "MH2"),
    mounting_hole(6, BOARD_H - 6, "MH3"),
    mounting_hole(BOARD_W - 6, BOARD_H - 6, "MH4"),
]

HEADER = f'''(kicad_pcb
\t(version 20260206)
\t(generator "pcbnew")
\t(generator_version "10.0")
\t(general
\t\t(thickness 1.6)
\t\t(legacy_teardrops no)
\t)
\t(paper "User" {BOARD_W} {BOARD_H})
\t(title_block
\t\t(title "MPPT Power System (MCU/IC)")
\t\t(date "2026-09-16")
\t\t(rev "v1 - 4-layer, STM32G031 + LM5175 + INA180 + TPS62130/TLV62569")
\t\t(company "Adom")
\t)
\t(layers
\t\t(0 "F.Cu" signal)
\t\t(4 "In1.Cu" signal)
\t\t(6 "In2.Cu" signal)
\t\t(2 "B.Cu" signal)
\t\t(9 "F.Adhes" user "F.Adhesive")
\t\t(11 "B.Adhes" user "B.Adhesive")
\t\t(13 "F.Paste" user)
\t\t(15 "B.Paste" user)
\t\t(5 "F.SilkS" user "F.Silkscreen")
\t\t(7 "B.SilkS" user "B.Silkscreen")
\t\t(1 "F.Mask" user)
\t\t(3 "B.Mask" user)
\t\t(17 "Dwgs.User" user "User.Drawings")
\t\t(19 "Cmts.User" user "User.Comments")
\t\t(21 "Eco1.User" user "User.Eco1")
\t\t(23 "Eco2.User" user "User.Eco2")
\t\t(25 "Edge.Cuts" user)
\t\t(27 "Margin" user)
\t\t(31 "F.CrtYd" user "F.Courtyard")
\t\t(29 "B.CrtYd" user "B.Courtyard")
\t\t(35 "F.Fab" user)
\t\t(33 "B.Fab" user)
\t)
\t(setup
\t\t(stackup
\t\t\t(layer "F.SilkS" (type "Top Silk Screen"))
\t\t\t(layer "F.Paste" (type "Top Solder Paste"))
\t\t\t(layer "F.Mask" (type "Top Solder Mask") (thickness 0.01))
\t\t\t(layer "F.Cu" (type "copper") (thickness 0.035))
\t\t\t(layer "dielectric 1" (type "core") (thickness 0.2) (material "FR4") (epsilon_r 4.5))
\t\t\t(layer "In1.Cu" (type "copper") (thickness 0.035))
\t\t\t(layer "dielectric 2" (type "prepreg") (thickness 0.7) (material "FR4") (epsilon_r 4.5))
\t\t\t(layer "In2.Cu" (type "copper") (thickness 0.035))
\t\t\t(layer "dielectric 3" (type "core") (thickness 0.2) (material "FR4") (epsilon_r 4.5))
\t\t\t(layer "B.Cu" (type "copper") (thickness 0.035))
\t\t\t(layer "B.Mask" (type "Bottom Solder Mask") (thickness 0.01))
\t\t\t(layer "B.Paste" (type "Bottom Solder Paste"))
\t\t\t(layer "B.SilkS" (type "Bottom Silk Screen"))
\t\t\t(copper_finish "HASL")
\t\t)
\t\t(pad_to_mask_clearance 0.05)
\t\t(allow_soldermask_bridges_in_footprints no)
\t\t(pcbplotparams
\t\t\t(layerselection 0x00010fc_ffffffff)
\t\t\t(plot_on_all_layers_selection 0x0000000_00000000)
\t\t\t(disableapertmacros no)
\t\t\t(usegerberextensions no)
\t\t\t(usegerberattributes yes)
\t\t\t(usegerberadvancedattributes yes)
\t\t\t(creategerberjobfile yes)
\t\t\t(dashed_line_dash_ratio 12.000000)
\t\t\t(dashed_line_gap_ratio 3.000000)
\t\t\t(svgprecision 4)
\t\t\t(plotframeref no)
\t\t\t(mode 1)
\t\t\t(useauxorigin no)
\t\t\t(hpglpennumber 1)
\t\t\t(hpglpenspeed 20)
\t\t\t(hpglpendiameter 15.000000)
\t\t\t(pdf_front_fp_property_popups yes)
\t\t\t(pdf_back_fp_property_popups yes)
\t\t\t(dxfpolygonmode yes)
\t\t\t(dxfimperialunits yes)
\t\t\t(dxfusepcbnewfont yes)
\t\t\t(psnegative no)
\t\t\t(psa4output no)
\t\t\t(plot_black_and_white yes)
\t\t\t(plotinvisibletext no)
\t\t\t(sketchpadsonfab no)
\t\t\t(plotpadnumbers no)
\t\t\t(hidednponfab no)
\t\t\t(sketchdnponfab yes)
\t\t\t(crossoutdnponfab yes)
\t\t\t(subtractmaskfromsilk no)
\t\t\t(outputformat 1)
\t\t\t(mirror no)
\t\t\t(drillshape 0)
\t\t\t(scaleselection 1)
\t\t\t(outputdirectory "")
\t\t)
\t)
'''

def gnd_zone(layer):
    x0, y0, x1, y1 = 3, 3, BOARD_W - 3, BOARD_H - 3
    gnd_num = fp.net_id("GND")
    return (f'\t(zone\n\t\t(net {gnd_num})\n\t\t(net_name "GND")\n\t\t(layers "{layer}")\n'
            f'\t\t(uuid "{uid()}")\n\t\t(hatch edge 0.5)\n'
            # Solid pad connection, not thermal relief: this is a power board
            # whose GND return quality matters more than hand-soldering ease,
            # and relief spokes on the fine-pitch parts were coming out
            # "starved" (too few spokes able to form in the space available).
            f'\t\t(connect_pads yes\n\t\t\t(clearance 0.2)\n\t\t)\n'
            f'\t\t(min_thickness 0.25)\n\t\t(filled_areas_thickness no)\n'
            # island_removal_mode 1 means NEVER remove islands, which is what
            # left 19 stranded copper islands in the new F.Cu pour; 0 removes
            # any fill area that ends up with no connection to the net.
            f'\t\t(fill yes\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n'
            f'\t\t\t(island_removal_mode 0)\n\t\t)\n'
            f'\t\t(polygon\n\t\t\t(pts\n'
            f'\t\t\t\t(xy {x0} {y0}) (xy {x1} {y0}) (xy {x1} {y1}) (xy {x0} {y1})\n'
            f'\t\t\t)\n\t\t)\n\t)')

# F.Cu carries a GND pour as well as In1.Cu/B.Cu. Without it, every SMD GND
# pad needs its own stitching via punched straight through the pad to reach an
# inner plane -- which is physically impossible on the 0.5mm-pitch SOT-563 and
# 0.65mm-pitch HTSSOP parts, and was the single largest source of DRC
# clearance/shorting defects on this board (245 of 255 findings involved such
# a via). With a top pour, GND pads connect directly to copper on their own
# layer and stitching vias only have to tie the pours together, so they can
# sit in open space where they actually fit.
zones = [gnd_zone("F.Cu"), gnd_zone("In1.Cu"), gnd_zone("B.Cu")]

body = HEADER
body += fp.net_table() + '\n'
body += edge_rect(3, 3, BOARD_W - 3, BOARD_H - 3) + '\n'
body += '\n'.join(silk) + '\n'
body += '\n'.join(mounts) + '\n'
body += '\n'.join(footprints_out) + '\n'
body += '\n'.join(zones) + '\n'
body += ')\n'
OUT = "/mnt/c/Users/kirkt/Documents/mppt-power-system-mcu/mppt-power-system-mcu.kicad_pcb"
# Importing this module to reuse its placement (placeopt.py does exactly that,
# thousands of times) must not overwrite a routed board. Writing is what this
# script DOES, so it stays the default; set PCBGEN_NO_WRITE to borrow the
# placement without the side effect.
if not os.environ.get("PCBGEN_NO_WRITE"):
    open(OUT, "w").write(body)
    print(f"wrote {OUT}  ({len(body)/1024:.0f} KB)  board {BOARD_W}x{BOARD_H}mm")
else:
    print("PCBGEN_NO_WRITE set -- placement computed, board file left alone")
print(f"nets registered: {len(fp.net_num)}")