shibosoftwaredev/f1c100s-linux-nema17-stepper-controller

A four-layer NEMA 17 motor-controller PCB combining an Allwinner F1C100S Linux computer, USB‑PD/buck power system, W5500 Ethernet, STM32 real-time motion control, DRV8825 stepper driver, protection/temperature/current telemetry, USB interfaces, and local LED/buzzer diagnostics.

Version
0.4.56
License
unset
Stars
0

scripts/plan-round3-fanout.py

"""Candidate-only coordinated SoC fanout, with exact local metal checks.

Uses explicit engineering edits, not an autorouter. Never installs candidates;
the rebuilt native DRC, Gerber shorts, and independent connectivity gates remain
mandatory. Unchanged inherited violations are reported, not accepted as fixed.
"""
from route_geometry import *
from shapely.strtree import STRtree
from shapely.ops import unary_union
import copy,sys
import hashlib
from collections import Counter

c=load_circuit("dist/index/circuit.json");root=connectivity(c)
sources={e["source_trace_id"]:e for e in c if e["type"]=="source_trace"}
ports={e["pcb_port_id"]:e for e in c if e["type"]=="pcb_port"}
traces={e["pcb_trace_id"]:e for e in c if e["type"]=="pcb_trace"}
def net(t):
    s=sources[t["source_trace_id"]]
    return root((s.get("connected_source_port_ids",[])+s.get("connected_source_net_ids",[]))[0])
def xy(p):return round(p["x"],6),round(p["y"],6)
routes=json.loads(Path("routing/complete-routes.json").read_text())
vias=[dict(xy=xy(e),net=net({"source_trace_id":e.get("source_trace_id",traces.get(e.get("pcb_trace_id"),{}).get("source_trace_id"))})) for e in c if e["type"]=="pcb_via"]
pads=[]
for e in c:
    if e["type"] not in ["pcb_smtpad","pcb_plated_hole"]:continue
    p=ports.get(e.get("pcb_port_id"));n=root(p["source_port_id"]) if p else None
    pads.append(dict(net=n,part=e.get("pcb_component_id"),layers=e.get("layers",[e.get("layer")]),shape=geometry(e,"outer_" if e["type"]=="pcb_plated_hole" else "")))
changed_pads=[]
holes=[geometry(e,"hole_") for e in c if e["type"]=="pcb_hole"]
def collect(rr):
    out={}
    for t in rr:
        n=net(t)
        for a,b in zip(t["route"],t["route"][1:]):
            if a["route_type"]!=b["route_type"] or a["route_type"]!="wire" or a["layer"]!=b["layer"] or math.dist(xy(a),xy(b))<1e-7:continue
            k=n,a["layer"],max(a["width"],b["width"]),tuple(sorted([xy(a),xy(b)]))
            out[k]=dict(net=n,layers=[a["layer"]],shape=LineString([xy(a),xy(b)]).buffer(k[2]/2,quad_segs=32))
    return out
old=collect(routes)
def validate(rr,moves):
    segments=collect(rr);changed=[m for k,m in segments.items() if k not in old]
    vv=[dict(net=v["net"],xy=moves.get(v["xy"],v["xy"])) for v in vias]
    metal=list(segments.values())+pads
    tree=STRtree([m["shape"] for m in metal])
    for pad in changed_pads:
        for j in tree.query(pad["shape"].buffer(.151)):
            other=metal[int(j)]
            if pad["net"]==other["net"] or not set(pad["layers"])&set(other["layers"]):continue
            required=.15 if "part" in other else .1
            if pad["shape"].distance(other["shape"])<required-.0001:return "moved pad to metal"
        for v in vv:
            if v["net"]!=pad["net"] and Point(v["xy"]).distance(pad["shape"])<.352:return f"moved pad {pad['net']} {pad['shape'].bounds} to drill {v['xy']} distance {Point(v['xy']).distance(pad['shape']):.6f}"
    for v in vv:
        p=Point(v["xy"])
        if v["xy"] not in moves.values():continue
        for j in tree.query(p.buffer(.354)):
            m=metal[int(j)]
            if m["net"]!=v["net"] and p.distance(m["shape"])<.352:return f"moved drill {v['xy']} to {m['net']} {m['layers']} bounds {m['shape'].bounds} distance {p.distance(m['shape']):.6f}"
        if any(q is not v and math.dist(v["xy"],q["xy"])<.553 for q in vv):return "via to via"
        # Retain incidental original pad contacts, in addition to endpoints.
        oldv=next(q for q in vias if q["net"]==v["net"] and moves.get(q["xy"],q["xy"])==v["xy"])
        for pad in pads:
            if pad["net"]==v["net"] and Point(oldv["xy"]).buffer(.225).intersects(pad["shape"]) and not p.buffer(.225).intersects(pad["shape"]):
                for layer in pad["layers"]:
                    joined=unary_union([pad["shape"],p.buffer(.225)]+[s["shape"] for s in segments.values() if s["net"]==v["net"] and layer in s["layers"]]).buffer(.000001)
                    islands=list(joined.geoms) if hasattr(joined,"geoms") else [joined]
                    if not any(g.intersects(p) and g.intersects(pad["shape"]) for g in islands):return "lost pad contact"
    for m in changed:
        g=m["shape"]
        if max(abs(b) for b in g.bounds)>20.95 or any(g.distance(h)<.2 for h in holes):return "edge/hole"
        for j in tree.query(g.buffer(.101)):
            q=metal[int(j)]
            if m["net"]!=q["net"] and set(m["layers"])&set(q["layers"]) and g.distance(q["shape"])<.10005:return f"copper {m['net']} {m['layers']} {g.bounds} to {q['net']} {q['shape'].bounds} gap {g.distance(q['shape']):.6f}"
        for v in vv:
            p=Point(v["xy"])
            if v["net"]==m["net"] or p.distance(g)>=.35-.0002:continue
            original=[z["shape"] for z in old.values() if z["net"]==m["net"] and z["layers"]==m["layers"] and z["shape"].distance(p)<.36]
            if g.intersection(p.buffer(.35-.0002)).difference(unary_union(original).buffer(.00002)).area>1e-8:return f"new wire {m['net']} {m['layers']} {g.bounds} to drill {v['xy']} distance {p.distance(g):.6f}"
    return None

site=sys.argv[1] if len(sys.argv)>1 else "lower"
assert site in ["lower","upper","pd"]
if site=="pd":
    src=next(e for e in c if e["type"]=="source_component" and e["name"]=="C_PD_ADC")
    pc=next(e for e in c if e["type"]=="pcb_component" and e["source_component_id"]==src["source_component_id"])
    assert xy(pc["center"])==(18,8) and pc["rotation"]==0,"PD plan requires its documented pre-fix placement"
    for pad in pads:
        if pad["part"]==pc["pcb_component_id"]:
            pad["shape"]=translate(rotate(pad["shape"],90,origin=(18,8)),xoff=.2,yoff=.18);changed_pads.append(pad)
reasons=Counter();accepted=None
for center_dy in [.15]:
    for flank_dy in [.03]:
        for spread in ([.022] if site=="lower" else [.014,.016,.018,.02]):
            moves={(4.199894,1.60001):(4.199894,round(1.60001+center_dy,6)),
                   (3.385858,1.614142):(3.365858,round(1.614142+flank_dy,6)),
                   (5.014136,1.614152):(5.034136,round(1.614152+flank_dy,6))}
            if site=="upper":moves={(5.400044,10.39999):(5.400044,10.24999)}
            if site=="pd":moves={(17.24,7.35):(17.25,7.43),(18.51,8.6731):(19.15,8.6731),(18.5122,9.2818):(18.5122,9.3318)}
            assert all(point in {v["xy"] for v in vias} for point in moves),"Plan source geometry no longer matches its baseline"
            rr=copy.deepcopy(routes)
            for t in rr:
                points=[]
                ground_bypass=site=="pd" and any(xy(p)==(18.5122,9.2818) for p in t["route"]) and any(xy(p)==(17.49,8) for p in t["route"])
                for p in t["route"]:
                    point=xy(p)
                    if point in moves:p["x"],p["y"]=moves[point]
                    elif site=="pd" and point==(17.49,8):p["x"],p["y"]=18.2,7.67
                    elif site=="pd" and point==(18.51,8):p["x"],p["y"]=18.2,8.69
                    elif ground_bypass and point==(17.9317,8.7213):p["x"],p["y"]=19.6,9.2818
                    elif ground_bypass and point==(17.9317,8):p["x"],p["y"]=19.6,8.4
                    points.append(p)
                t["route"]=points
                out=[]
                for a,b in zip(points,points[1:]):
                    out.append(a)
                    if site=="pd" and a.get("layer")==b.get("layer")=="inner1" and a["route_type"]==b["route_type"]=="wire":
                        ends={xy(a),xy(b)}
                        if ends=={(19.15,8.6731),(17.3409,9.8422)}:
                            out.append(dict(route_type="wire",x=18.3,y=8.6731,layer="inner1",width=a["width"]))
                    if site=="upper":
                        if a["route_type"]!=b["route_type"] or a["route_type"]!="wire" or a["layer"]!=b["layer"]:continue
                        def match(p,q):return math.dist(xy(p),q)<1e-5
                        pairs=[((4.999994,10.99999),(4.999994,9.79999),4.999994-spread,"top"),
                               ((5.800094,10.99999),(5.800094,9.79999),5.800094+spread,"top"),
                               ((5.8001,10.5737),(5.800094,9.79999),5.800094+spread,"inner2")]
                        for p,q,x,layer in pairs:
                            if a["layer"]==layer and ((match(a,p) and match(b,q)) or (match(b,p) and match(a,q))):
                                bends=[(p[0],10.37999),(round(x,6),10.24999),(q[0],10.11999)]
                                if match(b,p):bends.reverse()
                                out.extend(dict(route_type="wire",x=xx,y=yy,layer=layer,width=a["width"]) for xx,yy in bends)
                        continue
                    if a.get("layer")!="top" or b.get("layer")!="top" or a["route_type"]!=b["route_type"] or a["route_type"]!="wire":continue
                    left=math.dist(xy(a),(3.800098,1.00001))<1e-5 and math.dist(xy(b),(3.8001,2.2))<1e-5
                    right=math.dist(xy(a),(4.599944,1.00001))<1e-5 and math.dist(xy(b),(4.599944,2.20001))<1e-5
                    reverse=math.dist(xy(b),(3.800098,1.00001))<1e-5 and math.dist(xy(a),(3.8001,2.2))<1e-5
                    reverse_right=math.dist(xy(b),(4.599944,1.00001))<1e-5 and math.dist(xy(a),(4.599944,2.20001))<1e-5
                    if not any([left,right,reverse,reverse_right]):continue
                    x=(3.800098-spread) if left or reverse else (4.599944+spread)
                    out.append(dict(route_type="wire",x=round(x,6),y=moves[(4.199894,1.60001)][1],layer="top",width=a["width"]))
                out.append(points[-1]);t["route"]=out
            reason=validate(rr,moves)
            if reason:reasons[reason]+=1;continue
            accepted=dict(moves=[dict(before=a,after=b) for a,b in moves.items()],spread=spread)
            Path("routing/round3-fanout-candidate.json").write_text(json.dumps(rr,indent=2)+"\n")
            break
        if accepted:break
    if accepted:break
report=dict(site=site,accepted=accepted,rejections=dict(reasons),notInstalled=True,
            baselineCircuitSha256=hashlib.sha256(Path("dist/index/circuit.json").read_bytes()).hexdigest(),
            baselineReplaySha256=hashlib.sha256(Path("routing/complete-routes.json").read_bytes()).hexdigest())
Path(f"reports/round3-{site}-fanout-candidate.json").write_text(json.dumps(report,indent=2)+"\n")
print(json.dumps(report))