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-power.py
"""R9 bare-chip nominal routed power-path measurements, not a hardware current test."""
import importlib.util
import json
import argparse
import heapq
import math
import hashlib
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location('power_geometry', ROOT/'scripts/manufacturing-audit.py')
GEOMETRY = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = GEOMETRY
SPEC.loader.exec_module(GEOMETRY)
def switching_current_budget(params):
# Sunlord SWPA3015S1R5NT table: 1.5uH +/-30%, not +/-20%.
# TI SLVS696D equations 2/3 and 2.2MHz minimum. Triangular ripple RMS.
minimum_inductance_h = 1.5e-6*(1-0.30)
boost_duty = 1 - params['input_floor_v']/3.533
boost_ripple = params['input_floor_v']*boost_duty/(2_200_000*minimum_inductance_h)
buck_duty = 3.169/4.1205
buck_ripple = (4.1205-3.169)*buck_duty/(2_200_000*minimum_inductance_h)
return {'minimum_inductance_h': minimum_inductance_h, 'minimum_frequency_hz': 2_200_000,
'nominal_inductance_h': 1.5e-6, 'inductance_tolerance_fraction': 0.30,
'boost_ripple_peak_to_peak_amps': boost_ripple,
'buck_ripple_peak_to_peak_amps': buck_ripple,
'rms_design_amps': max(math.hypot(params['input_amps'],boost_ripple/math.sqrt(12)),
math.hypot(0.5,buck_ripple/math.sqrt(12))),
'peak_design_amps': max(params['input_amps']+boost_ripple/2,0.5+buck_ripple/2)}
def inductor_rating_failures(switching):
failures = []
if switching['rms_design_amps'] >= 1.7:
failures.append('Normal switching-current estimate exceeds qualified inductor RMS rating')
if switching['peak_design_amps']*1.2 >= 2.3:
failures.append('Inductor saturation rating does not retain TI recommended 20% peak-current headroom')
return failures
def width_budget_failures(paths, budgets):
return [f'{name}: nominal power neck is insufficient for its current budget'
for name,current in budgets.items()
if paths[name]['ipc2221_external_estimate_10c_amps'] < current]
def load_path(params):
features = [f for f in params['copper'] if f.net == params['net']]
circuit = params['circuit']
owners = {e['pcb_component_id']: e['source_component_id'] for e in circuit if e['type'] == 'pcb_component'}
components = {e['source_component_id']: e['name'] for e in circuit if e['type'] == 'source_component'}
pads = {e['pcb_smtpad_id']: e for e in circuit if e['type'] == 'pcb_smtpad'}
endpoints = []
for reference, pin in params['terminals']:
matches = [i for i,f in enumerate(features) if f.kind == 'smt' and components[owners[f.owner]] == reference and pin in pads[f.id]['port_hints']]
if len(matches) != 1:
raise ValueError(f'Power terminal is not unique: {reference}.{pin}')
endpoints.append(matches[0])
adjacent = [[] for _ in features]
for i, first in enumerate(features):
for k, second in enumerate(features[:i]):
if (first.layer == second.layer and first.geometry.intersects(second.geometry)) or (first.id == second.id and first.kind == second.kind == 'annulus'):
adjacent[i].append(k)
adjacent[k].append(i)
queue = [(0, endpoints[0], [endpoints[0]])]
visited = set()
while queue:
resistance, i, route = heapq.heappop(queue)
if i in visited:
continue
visited.add(i)
if i == endpoints[1]:
tracks = [features[k] for k in route if features[k].kind == 'track']
minimum = min(params['track_widths'][t.id] for t in tracks)
return {'terminals': params['terminals'], 'features': [{'id':features[k].id,'layer':features[k].layer} for k in route], 'trace_only_resistance_upper_bound_20c_ohm': resistance, 'minimum_trace_width_mm': minimum, 'ipc2221_external_estimate_10c_amps': 0.048*10**0.44*((minimum/0.0254)*(0.035/0.0254))**0.725}
for k in adjacent[i]:
weight = params['track_resistances'][features[k].id] if features[k].kind == 'track' else 0
heapq.heappush(queue, (resistance+weight, k, route+[k]))
raise ValueError('No physically connected power path')
def review(output=ROOT/'evidence/R9-routing-2026-10-13/power-path-measurements.json'):
source = ROOT/'dist/index/circuit.json'
circuit = json.loads(source.read_text())
if any(e['type'].endswith('_error') for e in circuit):
raise ValueError('Power review requires an error-free routed circuit')
boot_pullup = next(e for e in circuit if e['type']=='source_component' and e['name']=='R16')
if boot_pullup.get('resistance') != 10000 or boot_pullup['manufacturer_part_number'] != '0603WAF1002T5E' or boot_pullup['supplier_part_numbers']['jlcpcb'] != ['C25804']:
raise ValueError('Thin boot pull-up feed requires the exact reviewed10k/1% R16')
boot_pullup_current = 3.533/(10000*.99)
charge_feed = next(e for e in circuit if e['type']=='source_component' and e['name']=='R4')
if charge_feed.get('resistance') != 1000 or charge_feed['manufacturer_part_number'] != '0603WAF1001T5E' or charge_feed['supplier_part_numbers']['jlcpcb'] != ['C21190']:
raise ValueError('Thin charge LED feed requires the exact reviewed1k/1% R4')
charge_feed_current = 4.1205/(1000*.99) # Conservative even for a shorted LED.
copper, _, _ = GEOMETRY.make_features(circuit, 'gerber')
membership = {e.id: e.net for e in copper if e.kind == 'track'}
names = {e['subcircuit_connectivity_map_key']: e['name'] for e in circuit if e['type'] == 'source_net'}
segments = []
for trace in (e for e in circuit if e['type'] == 'pcb_trace'):
if trace['pcb_trace_id'] not in membership:
if len(trace['route']) == 1 and trace['route'][0]['route_type'] == 'wire':
continue # Two existing breakouts may already be the same point.
raise ValueError('Unattributed nonzero routed trace')
name = names[membership[trace['pcb_trace_id']]]
if name not in ['USB5V', 'VBAT', 'V3', 'RADIO_3V3', 'INDUCTOR_L1', 'INDUCTOR_L2']:
continue
for a, b in zip(trace['route'], trace['route'][1:]):
# Match the qualified converter's discrete start-aperture semantics.
if a['route_type'] == 'wire':
start, width = a, a['width']
end = b['start'] if b['route_type'] == 'through_pad' else b
elif b['route_type'] == 'wire':
start = a['end'] if a['route_type'] == 'through_pad' else a
end, width = b, b['width']
else:
continue
length = math.hypot(end['x']-start['x'], end['y']-start['y'])
if length:
segments.append({'net': name, 'trace': trace['pcb_trace_id'],
'length_mm': length, 'width_mm': width,
'resistance_20c_ohm': 0.00001724*length/(width*0.035)})
summaries = {}
for name in ['USB5V', 'VBAT', 'V3', 'RADIO_3V3', 'INDUCTOR_L1', 'INDUCTOR_L2']:
subset = [s for s in segments if s['net'] == name]
if not subset:
raise ValueError(f'No measured segments for {name}')
width = min(s['width_mm'] for s in subset)
summaries[name] = {'minimum_width_mm': width,
'summed_branch_length_mm': sum(s['length_mm'] for s in subset),
'all_segments_series_resistance_20c_ohm': sum(s['resistance_20c_ohm'] for s in subset),
'ipc2221_external_estimate_10c_amps': 0.048*10**0.44*((width/0.0254)*(0.035/0.0254))**0.725}
# Every branch in series overestimates the actual route resistance. Assume
# 50C copper and a conservative -3% regulator budget; do not imply testing.
resistance_factor = 1 + 0.00393*30
track_resistances = {s['trace']: sum(row['resistance_20c_ohm'] for row in segments if row['trace'] == s['trace']) for s in segments}
track_widths = {s['trace']: min(row['width_mm'] for row in segments if row['trace'] == s['trace']) for s in segments}
paths = {}
for path_name, name, terminals in [
('V3', 'V3', [('U3','pin1'),('L2','pin1')]),
('BOOT_PULLUP_FEED', 'V3', [('R15','pin1'),('R16','pin1')]),
('CHARGE_LED_FEED', 'VBAT', [('U2','pin2'),('R4','pin1')]),
*[(f'V3_PIN{pin}', 'V3', [('U3','pin1'),('U1',f'pin{pin}')]) for pin in [11,17,31,32]],
*[(f'RF_PIN{pin}', 'RADIO_3V3', [('L2','pin2'),('U1',f'pin{pin}')]) for pin in [2,3]],
('VBAT', 'VBAT', [('J2','pin2'),('U3','pin5')]),
('USB_VBUS1', 'USB5V', [('J1','pin15'),('U2','pin10')]),
('USB_VBUS2', 'USB5V', [('J1','pin16'),('U2','pin10')]),
('BATTERY_CHARGE', 'VBAT', [('U2','pin2'),('J2','pin2')]),
('INDUCTOR_L1', 'INDUCTOR_L1', [('U3','pin4'),('L1','pin2')]),
('INDUCTOR_L2', 'INDUCTOR_L2', [('U3','pin2'),('L1','pin1')])]:
net = next(key for key,n in names.items() if n == name)
paths[path_name] = load_path({'copper':copper,'circuit':circuit,'net':net,'terminals':terminals,'track_resistances':track_resistances,'track_widths':track_widths})
supply_resistances = [paths[f'V3_PIN{pin}']['trace_only_resistance_upper_bound_20c_ohm'] for pin in [11,17,31,32]]
supply_resistances.extend(paths['V3']['trace_only_resistance_upper_bound_20c_ohm'] + 0.09 + paths[f'RF_PIN{pin}']['trace_only_resistance_upper_bound_20c_ohm'] for pin in [2,3])
chip_floor = 3.169 - 0.5*resistance_factor*max(supply_resistances)
# Retain the earlier static tolerance envelope and use 80% efficiency.
# Solve I*(Vbattery-I*R)=P/efficiency, including the actual copper drop.
input_resistance = resistance_factor*paths['VBAT']['trace_only_resistance_upper_bound_20c_ohm']
input_power = 3.533*0.5/0.8
discriminant = 3.003**2-4*input_resistance*input_power
if discriminant <= 0:
raise ValueError('No valid trace-only battery operating point')
battery_input_current = 2*input_power/(3.003+math.sqrt(discriminant))
regulator_input_floor = 3.003 - battery_input_current*resistance_factor*paths['VBAT']['trace_only_resistance_upper_bound_20c_ohm']
switching = switching_current_budget({'input_floor_v':regulator_input_floor,
'input_amps':battery_input_current})
budgets = {**{f'V3_PIN{pin}':0.5 for pin in [11,17,31,32]}, **{f'RF_PIN{pin}':0.5 for pin in [2,3]}, 'V3':0.5, 'VBAT':battery_input_current, 'USB_VBUS1':0.4,
'USB_VBUS2':0.4, 'BATTERY_CHARGE':0.3333, 'BOOT_PULLUP_FEED':boot_pullup_current, 'CHARGE_LED_FEED':charge_feed_current,
'INDUCTOR_L1':switching['rms_design_amps'],
'INDUCTOR_L2':switching['rms_design_amps']}
report = {'circuit_sha256': hashlib.sha256(source.read_bytes()).hexdigest(),
'nominal_copper_thickness_mm': 0.035, 'segments': segments, 'nets': summaries, 'physical_load_paths': paths,
'charge_led_feed': {'reference':'R4','nominal_resistance_ohm':1000,'resistance_tolerance_fraction':.01,'maximum_reviewed_amps_including_shorted_led':charge_feed_current,'role':'Charge indicator only; battery discharge and charge trunks are reviewed independently'},
'boot_pullup_feed': {'reference':'R16','nominal_resistance_ohm':10000,'resistance_tolerance_fraction':.01,'maximum_reviewed_amps':boot_pullup_current,'role':'Boot pull-up only; chip supply trunks are reviewed independently'},
'chip_design_supply_amps': 0.5, 'chip_voltage_floor_budget_v': chip_floor,
'battery_input_design_amps_at_assumed_80pct_efficiency': battery_input_current,
'regulator_input_voltage_floor_budget_v': regulator_input_floor,
'switching_current_budget': switching, 'load_path_current_budgets_amps': budgets,
'inductor_rating_budget': {'rated_rms_amps': 1.7, 'rated_saturation_amps': 2.3,
'required_saturation_amps_with_20pct_headroom': switching['peak_design_amps']*1.2},
'references': ['Espressif ESP32-C3 hardware design guidelines: supply>=500mA; chip3.0-3.6V; Murata LQG15HN2N0B02D900mA,0.09ohm max DCR', 'TI SLVS696D: up to500mA boost at VIN>2.4V; section9.2.2.2 recommends20% saturation headroom', 'Sunlord SWPA3015S1R5NT:1.5uH+/-30%,1.7A RMS/2.3A saturation; evidence/R8-routing-2026-10-05/sunlord-page5.png', 'IPC-2221 external empirical estimate'],
'limitations': ['Nominal trace-only resistance; excludes connector, via, battery and regulator transient drops.', 'Whole-track series resistance overestimates the selected path, but does not establish actual loaded voltage.', '35um copper, 50C copper, 3.169-3.533V static tolerance envelope and80% efficiency are design assumptions.', 'USB current budget400mA includes333.3mA worst charge current and control overhead; USB disables radio supply.', 'Switching estimate uses ideal steady-state triangular ripple; startup, faults, core losses and thermal spreading require hardware verification.', 'Ground return uses independently verified continuous EP planes; IPC trace estimates do not model plane bottlenecks or via/contact losses.', 'No current, temperature, transient, battery runtime or RF measurement is claimed.']}
failures = []
if chip_floor <= 3.0 or regulator_input_floor <= 2.4:
failures.append('Trace-only voltage budget does not retain chip/regulator operating margin')
failures.extend(width_budget_failures(paths,budgets))
failures.extend(inductor_rating_failures(switching))
if battery_input_current >= 1.0:
failures.append('Input exceeds selected protected cell maximum1A discharge')
report['failures'] = failures
output.write_text(json.dumps(report, indent=2)+'\n')
print('Nominal chip voltage budget:', chip_floor, 'regulator input budget:', regulator_input_floor)
print('Measured power net minima:', {name: row['minimum_width_mm'] for name,row in summaries.items()})
if failures:
raise ValueError('; '.join(failures))
if __name__ == '__main__':
parser=argparse.ArgumentParser()
parser.add_argument('--output',type=Path,default=ROOT/'evidence/R9-routing-2026-10-13/power-path-measurements.json')
review(parser.parse_args().output)