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/review-r9-exports.py
"""Independent R9 Gerber readback. Does not edit source or fabrication geometry."""
import argparse
import importlib.util
import json
import hashlib
import sys
from pathlib import Path
from zipfile import ZipFile
from shapely.geometry import LineString
from shapely.ops import unary_union, polygonize
from gerbonara import GerberFile, graphic_primitives as gp
from r8_board_geometry import board_outline
from r9_board_geometry import antenna_exclusion, rf_intrusions
ROOT=Path(__file__).resolve().parents[1]
def load_module(name, filename):
spec=importlib.util.spec_from_file_location(name,ROOT/'scripts'/filename)
module=importlib.util.module_from_spec(spec)
sys.modules[name]=module
spec.loader.exec_module(module)
return module
PROCESS=load_module('r9_process','r9-filled-via-process.py')
GEOMETRY=PROCESS.GEOMETRY
READER=load_module('r9_readback_primitives','review-r3-exports.py')
def review(args):
circuit=json.loads(args.circuit.read_text())
if any(e['type'].endswith('_error') for e in circuit) or not any(e['type']=='pcb_trace' for e in circuit):
raise ValueError('Fabrication release requires error-free routed source')
process=json.loads(args.process.read_text())
copper,drills,widths,capped_via_ids=PROCESS.classify_process(circuit,process)
intrusions=rf_intrusions({'circuit':circuit,'copper':copper,'epsilon_mm':GEOMETRY.EPS})
if intrusions: raise ValueError(f'Unqualified antenna copper: {intrusions}')
antenna,exclusion=antenna_exclusion(circuit)
antenna_pad_ids={e['pcb_smtpad_id'] for e in circuit if e['type']=='pcb_smtpad' and e['pcb_component_id']==antenna['pcb_component_id']}
rf_net=next(e for e in circuit if e['type']=='source_net' and e['name']=='RF_50')['subcircuit_connectivity_map_key']
audit=GEOMETRY.audit(copper,drills,widths)
args.output.mkdir(parents=True,exist_ok=True)
(args.output/'exact-source-manufacturing.json').write_text(json.dumps(audit,indent=2)+'\n')
if audit['failures']:raise ValueError(f'{len(audit["failures"])} actual exported-geometry manufacturing failures')
report={'layers':{},'drills':{},'source':str(args.circuit),'circuit_sha256':hashlib.sha256(args.circuit.read_bytes()).hexdigest(),'archive_sha256':hashlib.sha256(args.archive.read_bytes()).hexdigest()}
layers={}
with ZipFile(args.archive) as archive:
if set(archive.namelist()) != READER.EXPECTED_FILES:raise ValueError('Gerber/Excellon file coverage differs')
for name in sorted(archive.namelist()):
source=archive.read(name).decode()
if name.endswith('.gbr'):
layer=GerberFile.from_string(source);layers[name]=layer
report['layers'][name]={'objects':len(layer.objects),'bounds_mm':layer.bounding_box()}
(args.output/(Path(name).stem+'.svg')).write_text(str(layer.to_svg(margin=1)))
else:
types=('pcb_hole',) if 'npth' in name else ('pcb_via','pcb_plated_hole')
report['drills'][name]=READER.read_drills(READER.DrillReview(source,[e for e in circuit if e['type'] in types],name))
for side,filename in [('top','F_Cu.gbr'),('bottom','B_Cu.gbr')]:
expected=unary_union([e.geometry for e in copper if e.layer==side]);actual=READER.copper_geometry(layers[filename])
report['layers'][filename]['copper_match']=READER.compare_geometry(READER.GeometryComparison(expected,actual,side))
allowed=unary_union([e.geometry for e in copper if e.layer==side and (e.id in antenna_pad_ids or e.kind=='track' and side=='top' and e.net==rf_net)])
if actual.intersection(exclusion).difference(allowed.buffer(READER.COPPER_COMPARE_MM)).area>GEOMETRY.EPS**2:
raise ValueError('Exported copper enters the antenna exclusion outside actual terminals/feed')
lines=[]
for obj in layers['Edge_Cuts.gbr'].objects:
for primitive in obj.to_primitives():
if not isinstance(primitive,gp.Line):raise ValueError('Unsupported outline primitive')
lines.append(LineString([(primitive.x1,primitive.y1),(primitive.x2,primitive.y2)]))
outlines=list(polygonize(unary_union(lines)))
if len(outlines)!=1:raise ValueError('Board outline mismatch')
report['layers']['Edge_Cuts.gbr']['outline_match']=READER.compare_geometry(
READER.GeometryComparison(board_outline(circuit),outlines[0],'board outline'))
for side,filename in [('top','F_Paste.gbr'),('bottom','B_Paste.gbr')]:
expected=unary_union([GEOMETRY.pad(e) for e in circuit if e['type']=='pcb_solder_paste' and e['layer']==side]);actual=READER.copper_geometry(layers[filename])
report['layers'][filename]['paste_match']=READER.compare_geometry(READER.GeometryComparison(expected,actual,filename))
for side,filename in [('top','F_Mask.gbr'),('bottom','B_Mask.gbr')]:
pads=unary_union([e.geometry for e in copper if e.kind=='smt' and e.layer==side]);mask=READER.copper_geometry(layers[filename])
if not pads.difference(mask.buffer(READER.COPPER_COMPARE_MM)).is_empty:raise ValueError('Covered SMT land')
report['layers'][filename]['all_smt_lands_open']=True
report['manufacturing_failures']=0
report['required_filled_capped_vias']=capped_via_ids
report['supplier_process_confirmed']=process['intentional_via_in_pad']['supplier_processed_preview_confirmed']
report['cost_quote_confirmed']=process['intentional_via_in_pad']['cost_quote_confirmed']
report['release_gate']='BLOCKED_PENDING_SUPPLIER_PROCESS_AND_COST_REVIEW'
(args.output/'readback.json').write_text(json.dumps(report,indent=2)+'\n')
print('All 12 fabrication files independently parsed; exact copper, drills, mask, paste and outline PASS')
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('archive',type=Path);parser.add_argument('circuit',type=Path);parser.add_argument('--output',type=Path,required=True);parser.add_argument('--process',type=Path,default=ROOT/'fabrication/r9-process.json')
review(parser.parse_args())