AnasSarkiz/magnetic-shutter-remote-r8
A USB‑C rechargeable battery-powered ESP32‑C3 wireless controller with Li‑ion charging, protected 3.3‑V buck‑boost regulation, thermal/safety monitoring, status LEDs, buttons, programming UART, crystal clock, and 2.4‑GHz antenna matching network.
- Version
- 0.4.0-prototype
- License
- unset
- Stars
- 0
scripts/manufacturing-audit.py
"""Classified, fail-closed FR4/1oz/2-layer audit in board XY mm (+Z top).
Input is schema-validated Circuit JSON, not edited converter output. Curves use
256 segments/quadrant, curved radius <=1.8mm. Distances within
0.00002 mm of a limit fail as indeterminate; exact dimensional checks are separate.
No component or same-footprint exemption. Own plated annulus and electrical
trace-to-barrel joins are classified features, rather than clearance violations.
"""
import argparse,json,math,hashlib
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from curved_geometry import brep_polygon, arc_polygon
from dataclasses import dataclass
from shapely.geometry import Point,LineString,Polygon,box
from shapely.affinity import rotate,translate
from shapely.ops import unary_union
EPS=0.00002
RULE_SOURCE='https://jlcpcb.com/capabilities/pcb-capabilities'
NPTH_SOURCE='https://jlcpcb.com/blog/npth-design-guide'
@dataclass
class Copper:
id: str
kind: str
layer: str
net: str | None
geometry: object
owner: str | None = None
@dataclass
class Drill:
id: str
kind: str
layers: tuple
net: str | None
geometry: object
outer: object | None = None
filled_capped_pad_id: str | None = None
def circle(x,y,d):
if d/2>1.8:raise ValueError("Curved radius exceeds audited 1.8mm approximation bound")
return Point(x,y).buffer(d/2,quad_segs=256)
def pill(x,y,w,h,angle=0):
r=min(w,h)/2
if r>1.8:raise ValueError("Curved radius exceeds audited 1.8mm approximation bound")
g=LineString([(-(w/2-r),0),(w/2-r,0)]).buffer(r,quad_segs=256) if w>h else LineString([(0,-(h/2-r)),(0,h/2-r)]).buffer(r,quad_segs=256)
if abs(w-h)<1e-12:g=Point(0,0).buffer(r,quad_segs=256)
return translate(rotate(g,angle,origin=(0,0)),x,y)
def pad(e):
if e['shape']=='polygon':return Polygon([(p['x'],p['y']) for p in e['points']])
x,y=e['x'],e['y'];shape=e['shape']
if shape=='circle':return circle(x,y,e.get('radius',0)*2 or e['diameter'])
if shape in ('pill','rotated_pill'):return pill(x,y,e['width'],e['height'],e.get('ccw_rotation',0))
if shape in ('rect','rotated_rect'):
return translate(rotate(box(-e['width']/2,-e['height']/2,e['width']/2,e['height']/2),e.get('ccw_rotation',0),origin=(0,0)),x,y)
raise ValueError(f'Unsupported SMT shape {shape}')
def make_features(circuit, trace_geometry="conservative"):
if trace_geometry not in ("conservative","gerber"):
raise ValueError("Unknown trace geometry mode")
byid={}
for e in circuit:
if (i:=e.get(e['type']+'_id')):byid[i]=e
# Canonical resolved source ports also attribute explicit one-port vias.
# Their redundant source_trace connectivity key is optional in Circuit JSON.
portnets={e['source_port_id']:e['subcircuit_connectivity_map_key'] for e in circuit
if e['type']=='source_port' and e.get('subcircuit_connectivity_map_key')}
for e in circuit:
if e['type']=='source_trace':
n=e.get('subcircuit_connectivity_map_key')
if n is None:continue
for p in e.get('connected_source_port_ids',[]):
if p in portnets and portnets[p]!=n:raise ValueError('Conflicting source net membership')
portnets[p]=n
# Supplier-imported repeated lands are distinct physical ports. Resolve
# only the canonical model's explicit internal connections, transitively;
# matching names or a shared component alone do not imply connectivity.
internal_groups=[e['source_port_ids'] for e in circuit
if e['type']=='source_component_internal_connection']
changed=True
while changed:
changed=False
for source_port_ids in internal_groups:
memberships={portnets[p] for p in source_port_ids
if portnets.get(p) is not None}
if len(memberships)>1:raise ValueError('Conflicting internally connected source nets')
if memberships:
membership=next(iter(memberships))
for source_port_id in source_port_ids:
if portnets.get(source_port_id)!=membership:
portnets[source_port_id]=membership
changed=True
trace_net_keys={}
for trace in (e for e in circuit if e['type']=='source_trace'):
declared=trace.get('subcircuit_connectivity_map_key')
connected=trace.get('connected_source_port_ids',[])
memberships=[portnets.get(port) for port in connected]
memberships.extend(byid[net_id].get('subcircuit_connectivity_map_key')
for net_id in trace.get('connected_source_net_ids',[]))
keys={key for key in memberships if key is not None}
if declared is not None:keys.add(declared)
if len(keys)>1:raise ValueError('Conflicting source net membership')
trace_net_keys[trace['source_trace_id']]=(declared if declared is not None else
next(iter(keys)) if keys and memberships and all(memberships) else None)
def net(e):
if n:=e.get('subcircuit_connectivity_map_key'):return n
if e.get('pcb_port_id'):
return portnets.get(byid[e['pcb_port_id']]['source_port_id'])
if e.get('source_trace_id'):
membership=trace_net_keys[e['source_trace_id']]
if membership is None and e['type']=='pcb_trace':
raise ValueError(f"Routed trace {e['pcb_trace_id']} has unresolved source-net attribution")
return membership
if e['type']=='pcb_trace':raise ValueError(f"Routed trace {e['pcb_trace_id']} has no verified source-net attribution")
if e.get('pcb_trace_id'):return net(byid[e['pcb_trace_id']])
if e.get('source_net_id'):return byid[e['source_net_id']].get('subcircuit_connectivity_map_key')
return None
copper=[];drills=[];widths=[]
for e in circuit:
t=e['type'];n=net(e)
if t=='pcb_smtpad':copper.append(Copper(e[t+'_id'],'smt',e['layer'],n,pad(e),e.get('pcb_component_id')))
elif t=='pcb_via':
g=circle(e['x'],e['y'],e['hole_diameter']);o=circle(e['x'],e['y'],e['outer_diameter']);layers=tuple(e['layers'])
drills.append(Drill(e[t+'_id'],'via',layers,n,g,o))
for l in layers:copper.append(Copper(e[t+'_id'],'annulus',l,n,o))
elif t=='pcb_hole':
if e['hole_shape']!='circle':raise ValueError('Unsupported NPTH shape')
drills.append(Drill(e[t+'_id'],'npth',('top','bottom'),None,circle(e['x'],e['y'],e['hole_diameter'])))
elif t=='pcb_plated_hole':
if e['shape']=='circle':g=circle(e['x'],e['y'],e['hole_diameter']);o=circle(e['x'],e['y'],e['outer_diameter'])
elif e['shape'] in ('pill','oval'):
g=pill(e['x'],e['y'],e['hole_width'],e['hole_height'],e.get('ccw_rotation',0));o=pill(e['x'],e['y'],e['outer_width'],e['outer_height'],e.get('ccw_rotation',0))
else:raise ValueError(f'Unsupported PTH {e["shape"]}')
layers=tuple(e['layers']);drills.append(Drill(e[t+'_id'],'pth',layers,n,g,o))
for l in layers:copper.append(Copper(e[t+'_id'],'annulus',l,n,o,e.get('pcb_component_id')))
elif t=='pcb_trace':
parts={}
for item in e['route']:
if trace_geometry=='gerber' and item.get('width_interpolation_mode') is not None:
raise ValueError('Gerber exporter does not support interpolated trace widths')
if item['route_type']=='through_pad':
# This transition adds copper on its declared endpoint layers;
# only real plated geometry joins layers in connectivity review.
widths.append((e[t+'_id'],item['width']))
if trace_geometry=='conservative':
geometry=LineString([(item['start']['x'],item['start']['y']),(item['end']['x'],item['end']['y'])]).buffer(item['width']/2,quad_segs=256)
for layer in {item['start_layer'],item['end_layer']}:
parts.setdefault(layer,[]).append(geometry)
elif item['route_type'] not in ('wire','via'):
raise ValueError('Unsupported route element')
for a,b in zip(e['route'],e['route'][1:]):
if trace_geometry=='gerber':
# Match the qualified exporter: the start wire sets aperture
# width; via/through-pad starts use the following wire.
if a['route_type']=='wire':
layer=a['layer'];start=a;segment_width=a['width']
if b['route_type'] in ('wire','via'):end=b
elif b['start_layer']==layer:end=b['start']
elif b['end_layer']==layer:end=b['end']
else:continue
elif b['route_type']=='wire':
layer=b['layer'];end=b;segment_width=b['width']
if a['route_type']=='via':start=a
elif a['end_layer']==layer:start=a['end']
elif a['start_layer']==layer:start=a['start']
else:continue
else:continue
widths.append((e[t+'_id'],segment_width))
if (start['x'],start['y'])!=(end['x'],end['y']):
parts.setdefault(layer,[]).append(LineString([(start['x'],start['y']),(end['x'],end['y'])]).buffer(segment_width/2,quad_segs=256))
else:
start=a['end'] if a['route_type']=='through_pad' else a
end=b['start'] if b['route_type']=='through_pad' else b
start_layer=a['end_layer'] if a['route_type']=='through_pad' else a.get('layer')
end_layer=b['start_layer'] if b['route_type']=='through_pad' else b.get('layer')
if a['route_type'] in ('wire','through_pad') and b['route_type'] in ('wire','through_pad') and start_layer==end_layer:
widths.extend([(e[t+'_id'],a['width']),(e[t+'_id'],b['width'])])
parts.setdefault(start_layer,[]).append(LineString([(start['x'],start['y']),(end['x'],end['y'])]).buffer(max(a['width'],b['width'])/2,quad_segs=256))
for l,segments in parts.items():copper.append(Copper(e[t+'_id'],'track',l,n,unary_union(segments)))
elif t=='pcb_copper_pour':
if e.get('shape')=='brep':g=brep_polygon(e['brep_shape'])
elif e.get('shape')=='polygon':
g=Polygon([(p['x'],p['y']) for p in e['points']])
if e.get('cutouts'):raise ValueError('Polygon cutouts require explicit geometry support')
else:raise ValueError('Unsupported pour shape')
copper.append(Copper(e[t+'_id'],'pour',e['layer'],n,g))
return copper,drills,widths
def drill_copper_rule(d,c):
"""Return (minimum mm, classification); None only for an actual barrel feed.
Matching net/component alone never exempts SMT pads. An explicitly validated
filled/capped process may classify a fully contained same-net via barrel as
intentional; ordinary/unrelated drills retain the original clearance rules.
"""
if c.layer not in d.layers:return None,'layer_outside_drill_span'
if d.id==c.id and c.kind=='annulus':return None,'own_plated_annulus'
if d.filled_capped_pad_id == c.id:
if (d.kind != 'via' or c.kind != 'smt' or d.net is None or
d.net != c.net or d.outer is None or not c.geometry.covers(d.outer)):
raise ValueError('Invalid filled/capped via-in-pad process classification')
return None,'required_filled_capped_barrel_in_pad'
if d.kind=='npth':return .2,'npth_all_copper'
if c.kind in ('track','pour') and d.net is not None and d.net==c.net and d.outer.intersects(c.geometry):
return None,'electrical_barrel_feed'
if c.layer not in ('top','bottom'):return (.2 if d.kind=='via' else .3),'inner_drill_copper'
if c.kind=='track':return (.2 if d.kind=='via' else .28),'outer_drill_track'
# JLC does not give an outer PTH-to-SMT special row. Preserve .2 drill
# clearance as the project constraint, plus the separate copper spacing
# and annulus tests. Do not present this project constraint as a JLC row.
return .2,'project_unrelated_drill_copper'
def lower_bound_distance(a,b):
ax,ay,bx,by=a.bounds;cx,cy,dx,dy=b.bounds
return math.hypot(max(cx-bx,ax-dx,0),max(cy-by,ay-dy,0))
def audit(copper,drills,widths):
failures=[];measurements=[];joins=[]
def record(rule,a,b,gap,minimum):
m={'rule':rule,'a':a,'b':b,'gap_mm':gap,'minimum_mm':minimum}
measurements.append(m)
if gap<minimum-EPS:failures.append(m)
elif abs(gap-minimum)<=EPS and rule!="track_width":
m["status"]="indeterminate_at_geometric_precision"
failures.append(m)
for d in drills:
w=d.geometry.bounds[2]-d.geometry.bounds[0]
# Diameter/slot width taken from minimum rotated bounding dimension is
# unsuitable at arbitrary angles; use area-derived annulus only here.
if d.outer is not None:
gap=d.geometry.boundary.distance(d.outer.boundary) if d.outer.covers(d.geometry) else -1
record('via_annulus' if d.kind=='via' else 'pth_annulus',d.id,d.id,gap,.05 if d.kind=='via' else .18)
for c in copper:
minimum,rule=drill_copper_rule(d,c)
if (minimum is not None and d.outer is not None and
c.kind=='track' and d.net is not None and d.net==c.net):
# A trace and via wholly joined by real local plane copper
# have no separate etched gap. Require the entire local hull;
# matching names or a remote conductive path are insufficient.
local_track=c.geometry.intersection(d.outer.buffer(minimum))
if not local_track.is_empty:
local_hull=d.outer.union(local_track).convex_hull
if any(plane.kind=='pour' and plane.layer==c.layer and
plane.net==c.net and plane.geometry.covers(local_hull)
for plane in copper):
minimum,rule=None,'physical_shared_plane_barrel_feed'
if minimum is None:joins.append({'drill':d.id,'copper':c.id,'layer':c.layer,'classification':rule});continue
if lower_bound_distance(d.geometry,c.geometry)<=minimum+.25:
record(rule,d.id,c.id+':'+c.layer,d.geometry.distance(c.geometry),minimum)
for i,d in enumerate(drills):
for other in drills[i+1:]:
if set(d.layers).intersection(other.layers):record('hole_hole',d.id,other.id,d.geometry.distance(other.geometry),.2 if d.kind==other.kind=='via' else .45)
for i,a in enumerate(copper):
for b in copper[i+1:]:
if a.layer!=b.layer:continue
same=a.net is not None and a.net==b.net
if same and a.geometry.intersects(b.geometry):continue # copper union/intentional join
if same:
if a.kind==b.kind=='track':
# Audit etched gaps outside actual common conductive copper.
# Pads and pours merge covered track sections into a single
# conductive shape; uncovered close parallel tails still fail.
shared=[c for c in copper if c.kind in ('annulus','smt','pour') and
c.layer==a.layer and c.net==a.net and
c.geometry.intersects(a.geometry) and
c.geometry.intersects(b.geometry)]
outside_a,outside_b=a.geometry,b.geometry
if shared:
junction=unary_union([c.geometry for c in shared])
outside_a=a.geometry.difference(junction)
outside_b=b.geometry.difference(junction)
joins.append({'copper_a':a.id,'copper_b':b.id,
'layer':a.layer,'pads':[c.id for c in shared],
'classification':'physical_shared_copper_junction'})
if not outside_a.is_empty and not outside_b.is_empty:
record('same_net_separate_tracks',a.id,b.id,outside_a.distance(outside_b),.25)
continue
minimum=.15 if a.kind==b.kind=='smt' else .1
if lower_bound_distance(a.geometry,b.geometry)<=minimum+.25:
record('different_net_copper',a.id+':'+a.layer,b.id+':'+b.layer,a.geometry.distance(b.geometry),minimum)
for i,w in widths:
m={'rule':'track_width','a':i,'b':i,'gap_mm':w,'minimum_mm':.1}
measurements.append(m)
if w<.1-1e-9:failures.append(m)
return {'failures':failures,'measurements':sorted(measurements,key=lambda m:m['gap_mm']-m['minimum_mm']),'classified_joins':joins,'curve_error_bound_mm':EPS,'sources':[RULE_SOURCE,NPTH_SOURCE],'limitations':['No fabrication tolerance stack is subtracted from nominal capability minima.','Minimum drill sizes/slot aspect ratios, solder mask and silk require separate export review.','PTH/NPTH mixed drill spacing uses conservative project 0.45mm; JLC row labels do not explicitly classify mixed pairs.','Outer drill-to-SMT/pour .2mm is a retained project constraint; JLC has specific track rows.','Unsupported geometry raises an error; no ignored copper features.']}
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('--input',default='dist/index/circuit.json');p.add_argument('--trace-geometry',choices=['conservative','gerber'],default='conservative');p.add_argument('--output',default='evidence/R3/manufacturing-audit.json');a=p.parse_args()
result=audit(*make_features(json.loads(Path(a.input).read_text()),a.trace_geometry));result['trace_geometry']=a.trace_geometry
result['circuit_sha256']=hashlib.sha256(Path(a.input).read_bytes()).hexdigest()
Path(a.output).write_text(json.dumps(result,indent=2)+'\n')
print('Classified manufacturing failures:',len(result['failures']))
for f in result['failures'][:25]:print(f)
raise SystemExit(bool(result['failures']))