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-declared-clearances.py

"""Check actual copper against R9's declared rules, independently of the router.

Board XY millimetres, +Z top. This supplements the fabrication-capability audit;
it does not replace process classification, connectivity or native checks.
"""
import argparse
import hashlib
import importlib.util
import json
import sys
from pathlib import Path
from shapely.strtree import STRtree

ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location('r9_declared_process', ROOT/'scripts/r9-filled-via-process.py')
PROCESS = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = PROCESS
SPEC.loader.exec_module(PROCESS)
EPS = PROCESS.GEOMETRY.EPS


def declared_measurements(copper, drills, board):
    limits = {
        'foreign_trace_copper': board['min_trace_to_pad_edge_clearance'],
        'via_smt_copper': board['min_via_edge_to_pad_edge_clearance'],
        'via_hole_spacing': board['min_via_hole_edge_to_via_hole_edge_clearance'],
    }
    measurements = []
    def record(rule, first, second, gap):
        measurements.append({'rule':rule, 'a':first, 'b':second,
                             'gap_mm':gap, 'minimum_mm':limits[rule]})
    geometries = [feature.geometry for feature in copper]
    index = STRtree(geometries)
    for i, first in enumerate(copper):
        for j in index.query(first.geometry.buffer(limits['foreign_trace_copper']+EPS)):
            j = int(j)
            if j <= i:
                continue
            second = copper[j]
            if first.layer != second.layer or 'track' not in (first.kind,second.kind):
                continue
            if first.net is not None and first.net == second.net:
                continue
            record('foreign_trace_copper', first.id, second.id,
                   first.geometry.distance(second.geometry))
    smt = [feature for feature in copper if feature.kind == 'smt']
    pad_index = STRtree([feature.geometry for feature in smt])
    vias = [drill for drill in drills if drill.kind == 'via']
    for via in vias:
        if via.outer is None:
            raise ValueError('Via has no verified copper annulus')
        for j in pad_index.query(via.outer.buffer(limits['via_smt_copper']+EPS)):
            pad = smt[int(j)]
            if pad.layer not in via.layers or via.filled_capped_pad_id == pad.id:
                continue
            # Net identity alone never exempts a via from a component land.
            record('via_smt_copper', via.id, pad.id, via.outer.distance(pad.geometry))
    for i, first in enumerate(vias):
        for second in vias[:i]:
            if not set(first.layers).intersection(second.layers):
                continue
            if first.geometry.equals(second.geometry) and first.net is not None and first.net == second.net:
                continue  # Multiple source routes share one physical same-net barrel.
            gap = first.geometry.distance(second.geometry)
            if gap <= limits['via_hole_spacing']+EPS:
                record('via_hole_spacing', first.id, second.id, gap)
    return measurements


def review(args):
    raw = args.circuit.read_bytes()
    circuit = json.loads(raw)
    if any(element['type'].endswith('_error') for element in circuit):
        raise ValueError('Declared-rule review requires an error-free native routed candidate')
    if not any(element['type']=='pcb_trace' for element in circuit):
        raise ValueError('Declared-rule review requires actual routed copper')
    board = next(element for element in circuit if element['type']=='pcb_board')
    copper, drills, widths, capped = PROCESS.classify_process(circuit,json.loads(args.process.read_text()))
    measurements = declared_measurements(copper,drills,board)
    failures = [row for row in measurements if row['gap_mm'] <= row['minimum_mm']+EPS]
    report = {'circuit_sha256':hashlib.sha256(raw).hexdigest(), 'failures':failures,
              'measurements':measurements,'required_filled_capped_vias':capped,
              'curve_error_bound_mm':EPS,
              'scope':'Actual nominal copper/drill geometry against declared board rules; supplier/physical qualification remains separate.'}
    args.output.write_text(json.dumps(report,indent=2)+'\n')
    print('R9 declared-rule clearance failures:',len(failures))
    for row in failures[:15]:
        print(row)
    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()))