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-radio.py
"""Review actual R9 copper against the retained Espressif RF/clock requirements.
World XY mm,+Z top. Nominal CAD/geometry checks cannot establish antenna
matching, clock drive/frequency, assembled impedance or phone performance.
"""
import argparse, hashlib, importlib.util, json, sys
from pathlib import Path
from shapely.geometry import box
from shapely.ops import unary_union
from r9_board_geometry import rf_intrusions
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location('r9_radio_geometry', ROOT/'scripts/manufacturing-audit.py')
GEOMETRY = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = GEOMETRY
SPEC.loader.exec_module(GEOMETRY)
PROCESS_SPEC = importlib.util.spec_from_file_location('r9_radio_process', ROOT/'scripts/r9-filled-via-process.py')
PROCESS = importlib.util.module_from_spec(PROCESS_SPEC)
sys.modules[PROCESS_SPEC.name] = PROCESS
PROCESS_SPEC.loader.exec_module(PROCESS)
def review(args):
raw = args.circuit.read_bytes()
circuit = json.loads(raw)
if any(e['type'].endswith('_error') for e in circuit):
raise ValueError('Radio review requires error-free native routing')
copper, drills, widths, capped_ids = PROCESS.classify_process(circuit,json.loads(args.process.read_text()))
nets = {e['name']:e['subcircuit_connectivity_map_key'] for e in circuit if e['type']=='source_net'}
gnd = unary_union([c.geometry for c in copper if c.layer=='bottom' and c.net==nets['GND']])
# Surface reference copper excludes ordinary drill voids. Only the four
# validated, explicitly required copper caps close their drill openings.
gnd = gnd.difference(unary_union([d.geometry for d in drills if d.id not in capped_ids]))
failures = []
reviews = []
membership = {c.id:c.net for c in copper if c.kind=='track'}
trunk_widths = []
for trace in (e for e in circuit if e['type']=='pcb_trace' and membership.get(e['pcb_trace_id'])==nets['RF_50']):
for a,b in zip(trace['route'],trace['route'][1:]):
if a['route_type']=='wire' and b['route_type']=='wire' and a['layer']==b['layer']=='top' and abs(a['x']-11)<1e-6 and abs(b['x']-11)<1e-6 and abs(a['y']-b['y'])>1:
trunk_widths.append(a['width'])
if not trunk_widths or any(abs(width-1.139)>1e-6 for width in trunk_widths):
failures.append('Actual long RF trunk does not retain its nominal1.139mm aperture')
reviews.append({'actual_rf_trunk_widths_mm':trunk_widths})
for name in ['RF_CHIP','RF_50','XTAL_P','XTAL_LOAD_P','XTAL_N']:
tracks = [c for c in copper if c.kind=='track' and c.net==nets[name]]
vias = [d.id for d in drills if d.net==nets[name] and d.kind=='via']
if not tracks or any(c.layer!='top' for c in tracks) or vias:
failures.append(f'{name}: require top-only connected traces and no signal vias')
# The radiator portion deliberately clears all-layer copper. Require the
# reference under the feed up to that manufacturer antenna region only.
reference = unary_union([c.geometry for c in tracks]).buffer(.05)
if name=='RF_50':
reference = reference.intersection(box(-22,-28,22,21))
missing = reference.difference(gnd).area
if missing > GEOMETRY.EPS**2:
failures.append(f'{name}: missing actual bottom reference area {missing}mm2')
reviews.append({'net':name,'trace_ids':[c.id for c in tracks],'layers':sorted({c.layer for c in tracks}),'signal_vias':vias,'missing_bottom_ground_area_mm2':missing})
for reference in ['U1','Y1']:
source = next(e for e in circuit if e['type']=='source_component' and e['name']==reference)
pcb = next(e for e in circuit if e['type']=='pcb_component' and e['source_component_id']==source['source_component_id'])
center = pcb['center']
width,height = (5,5) if reference=='U1' else (2.5,2)
body = box(center['x']-width/2,center['y']-height/2,center['x']+width/2,center['y']+height/2)
missing = body.difference(gnd).area
if missing > GEOMETRY.EPS**2:
failures.append(f'{reference}: actual bottom reference does not cover body')
reviews.append({'reference':reference,'missing_bottom_ground_area_mm2':missing})
rf = rf_intrusions({'circuit':circuit,'copper':copper,'epsilon_mm':GEOMETRY.EPS})
failures.extend('Antenna exclusion: '+intrusion for intrusion in rf)
report = {'circuit_sha256':hashlib.sha256(raw).hexdigest(),'checks':reviews,'antenna_copper_intrusions':rf,'failures':failures,'nominal_rf_trunk_width_mm':1.139,'nominal_coplanar_gap_mm':.3,'limitations':['Actual supplier stackup, soldermask and impedance are unconfirmed.','Matching/harmonics, crystal frequency/drive, enclosure battery/magnet influence and RF range require physical qualification.','No measured hardware performance is claimed.']}
args.output.write_text(json.dumps(report,indent=2)+'\n')
print('R9 RF/clock copper failures:',failures)
return bool(failures)
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('--circuit',type=Path,default=ROOT/'dist/index/circuit.json')
parser.add_argument('--process',type=Path,default=ROOT/'fabrication/r9-process.json')
parser.add_argument('--output',type=Path,required=True)
raise SystemExit(review(parser.parse_args()))