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))