kicad:EasyEDA→KiCad 工程(Board1/Board2)、库、脚本与评审文档(IC 引脚、风扇、热、EDL 供电、布局规则)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -0,0 +1,90 @@
|
||||
"""Apply the catalog-backed FAN-R2 part selection to the existing R1 schematic."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from schematic_maintenance import ROOT, parse, children, child, properties
|
||||
from revise_four_wire_fans import prop, quote, uid
|
||||
|
||||
|
||||
def main():
|
||||
selection = json.loads((ROOT/'fan-selection.json').read_text(encoding='utf-8'))
|
||||
path = ROOT/'PhoneRail-Board1/P6-FAN-EDL.kicad_sch'
|
||||
source = path.read_text(encoding='utf-8')
|
||||
if 'SANYO FAN SELECTION R2' in source:
|
||||
print('FAN-R2 already applied; skipped')
|
||||
return
|
||||
assert 'FOUR-WIRE FAN PROTOTYPE R1' in source
|
||||
root = parse(source)
|
||||
edits = []
|
||||
for symbol in children(root, 'symbol'):
|
||||
p = properties(symbol)
|
||||
ref = p['Reference'][2]
|
||||
values = {}
|
||||
if ref in {'J26', 'J27'}:
|
||||
values = {'Value': '1725672', 'MPN': '1725672', 'Manufacturer': 'Phoenix Contact',
|
||||
'LCSC': 'C3816889', 'Footprint': selection['connector']['footprint'],
|
||||
'Datasheet': 'https://item.szlcsc.com/datasheet/1725672/4384563.html',
|
||||
'Fan_MPN': selection['mpn'], 'Fan_LCSC': selection['lcsc'],
|
||||
'Fan_Device_UUID': selection['easyeda_device_uuid'],
|
||||
'Fan_Datasheet': selection['datasheet_url'],
|
||||
'Harness': '1 Black GND; 2 Red +12V_SW; 3 Yellow TACH; 4 Brown PWM',
|
||||
'Description': 'Four-way screw terminal for off-board SANYO fan; fan is in fan-assembly-bom.csv'}
|
||||
if ref in {'R745', 'R749'}:
|
||||
values = {'Value': '15k', 'MPN': '0402WGF1502TCE', 'LCSC': 'C25756'}
|
||||
# C25756 cross-checked through the live catalog on 2026-09-07.
|
||||
x, y = map(float, child(symbol, 'at')[1:3])
|
||||
extra = []
|
||||
for key, value in values.items():
|
||||
if key in p:
|
||||
node = p[key]
|
||||
raw = source[node.start:node.end]
|
||||
old = quote(node[2])
|
||||
prefix = '(property '+quote(key)+' '
|
||||
assert raw.startswith(prefix+old)
|
||||
edits.append((node.start, node.end, prefix+quote(value)+raw[len(prefix+old):]))
|
||||
else:
|
||||
extra.append(prop(key, value, x, y, True))
|
||||
if extra:
|
||||
edits.append((symbol.end-1, symbol.end-1, '\n'.join(extra)))
|
||||
if ref in {'C738', 'D2'}:
|
||||
for key, dy in [('Reference', -2.54), ('Value', 0)]:
|
||||
node = child(p[key], 'at')
|
||||
edits.append((node.start, node.end, f'(at {x+22.86:g} {y+dy:g} 270)'))
|
||||
replacements = {
|
||||
'FOUR-WIRE FAN PROTOTYPE R1': 'SANYO FAN SELECTION R2 / 2x 9G0612P4S001 / C2920592',
|
||||
'2x NF-A6x25 PWM / J26 intake / J27 exhaust. Vendor limits and prototype tests OPEN.':
|
||||
'J26 intake / J27 exhaust. 12V, 60x60x25mm, 25kHz PWM. Startup/thermal verification OPEN.',
|
||||
'Keyed PC fan header: 1 GND / 2 +12V / 3 TACH / 4 PWM.':
|
||||
'Screw terminals: 1 BLACK GND / 2 RED +12V / 3 YELLOW TACH / 4 BROWN PWM.',
|
||||
'Confirm header drawing, key orientation and cable fit before layout.':
|
||||
'Bare AWG26 leads, 300-350mm. No PC-fan plug. 0% PWM still runs at about 3300RPM.',
|
||||
'25kHz target; invert MCU duty: D_gpio = 1 - D_fan. Confirm vendor limits.':
|
||||
'25kHz nominal; invert MCU duty: D_gpio = 1 - D_fan. Family PWM limits: see FAN_CIRCUIT.md.',
|
||||
'RAW high: Vgate ~ Vfan/3. RAW low: Vgate ~ Vtach_low/2.':
|
||||
'R745/R749 = 15k: Vgate high ~ Vfan/3.5; low <=0.24V nominal for RAW <=0.6V.',
|
||||
}
|
||||
for note in children(root, 'text'):
|
||||
if note[1] in replacements:
|
||||
raw = source[note.start:note.end]
|
||||
edits.append((note.start, note.end, raw.replace(quote(note[1]), quote(replacements[note[1]]), 1)))
|
||||
rev = child(child(root, 'title_block'), 'rev')
|
||||
edits.append((rev.start, rev.end, '(rev "FAN-R2")'))
|
||||
for start, end, value in sorted(edits, reverse=True):
|
||||
source = source[:start]+value+source[end:]
|
||||
source = source.replace('FanHeader', 'FanTerminal')
|
||||
parse(source)
|
||||
backup = ROOT/'out/before-sanyo-selection'
|
||||
backup.mkdir(exist_ok=False)
|
||||
shutil.copy2(path, backup/path.name)
|
||||
path.write_text(source, encoding='utf-8', newline='\n')
|
||||
lib = ROOT/'lib/PhoneRail-Fan.kicad_sym'
|
||||
lib.write_text(lib.read_text(encoding='utf-8').replace('FanHeader', 'FanTerminal'), encoding='utf-8', newline='\n')
|
||||
name = selection['connector']['footprint'].split(':')[1]
|
||||
fp = Path('D:/KiCad/share/kicad/footprints/TerminalBlock_Phoenix.pretty')/(name+'.kicad_mod')
|
||||
shutil.copy2(fp, ROOT/'lib/PhoneRail-Fan.pretty'/fp.name)
|
||||
print('Applied SANYO fan metadata, Phoenix terminals and 15k TACH gate resistors')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Download a catalog-linked datasheet, retaining its source URL and SHA256."""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import fitz
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class Links(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pdfs = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
for key, value in attrs:
|
||||
if key in {'src', 'href', 'data'} and value and urlsplit(value).path.lower().endswith('.pdf'):
|
||||
self.pdfs.append(value.split('#')[0])
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('name')
|
||||
ap.add_argument('url')
|
||||
ap.add_argument('--html', action='store_true')
|
||||
ap.add_argument('--out-dir', type=Path, default=ROOT/'datasheets/fans')
|
||||
args = ap.parse_args()
|
||||
if not all(c.isalnum() or c in '-_.' for c in args.name):
|
||||
raise ValueError('Filename must be a simple part name')
|
||||
output = args.out_dir.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
session = requests.Session()
|
||||
session.trust_env = False
|
||||
response = session.get(args.url, timeout=25)
|
||||
response.raise_for_status()
|
||||
page = response
|
||||
if args.html:
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
path = output/(args.name+'.html')
|
||||
path.write_bytes(response.content)
|
||||
text = soup.get_text('\n', strip=True)
|
||||
path.with_suffix('.txt').write_text(text, encoding='utf-8')
|
||||
images = []
|
||||
for tag in soup.find_all('img', src=True):
|
||||
if any(key in tag['src'] for key in ['pwm_02_en', 'sensor_01_en', 'sensor_02_en']):
|
||||
image_url = urljoin(response.url, tag['src'])
|
||||
image = session.get(image_url, timeout=20)
|
||||
image.raise_for_status()
|
||||
image_path = output/Path(urlsplit(image_url).path).name
|
||||
image_path.write_bytes(image.content)
|
||||
images.append({'url': image_url, 'file': image_path.name,
|
||||
'sha256': hashlib.sha256(image.content).hexdigest()})
|
||||
metadata = {'retrieved_at_utc': datetime.now(timezone.utc).isoformat(),
|
||||
'requested_url': args.url, 'source_page_url': response.url,
|
||||
'sha256': hashlib.sha256(response.content).hexdigest(), 'images': images}
|
||||
path.with_suffix('.json').write_text(json.dumps(metadata, indent=2)+'\n', encoding='utf-8')
|
||||
print(json.dumps(metadata, indent=2))
|
||||
return
|
||||
if not response.content.startswith(b'%PDF'):
|
||||
parser = Links()
|
||||
parser.feed(response.text)
|
||||
urls = list(dict.fromkeys(urljoin(response.url, p) for p in parser.pdfs))
|
||||
if len(urls) != 1:
|
||||
raise ValueError(f'Expected a unique linked PDF; found {urls}')
|
||||
(output/(args.name+'-source.html')).write_text(response.text, encoding='utf-8')
|
||||
response = session.get(urls[0], timeout=25)
|
||||
response.raise_for_status()
|
||||
if not response.content.startswith(b'%PDF'):
|
||||
raise ValueError('Response is not a PDF')
|
||||
document = fitz.open(stream=response.content, filetype='pdf')
|
||||
pdf = output/(args.name+'.pdf')
|
||||
pdf.write_bytes(response.content)
|
||||
text = '\n'.join(f'\n--- PDF PAGE {p.number+1} ---\n'+p.get_text() for p in document)
|
||||
pdf.with_suffix('.txt').write_text(text, encoding='utf-8')
|
||||
metadata = {'retrieved_at_utc': datetime.now(timezone.utc).isoformat(), 'requested_url': args.url,
|
||||
'source_page_url': page.url, 'pdf_url': response.url, 'pages': len(document),
|
||||
'sha256': hashlib.sha256(response.content).hexdigest(), 'bytes': len(response.content)}
|
||||
pdf.with_suffix('.json').write_text(json.dumps(metadata, indent=2)+'\n', encoding='utf-8')
|
||||
print(json.dumps(metadata, indent=2))
|
||||
print(text[:1600])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,487 @@
|
||||
"""Create unrouted KiCad boards with verified net import and mechanical anchors.
|
||||
|
||||
Run with the Python shipped with KiCad. Refuse to overwrite edited boards.
|
||||
Electrical parts awaiting layout are grouped outside the board, not silently
|
||||
presented as a completed placement. No EasyEDA tracks or copper are imported.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
from check_hardware import Board
|
||||
from schematic_maintenance import ROOT, parse, child, children
|
||||
|
||||
CONFIG = json.loads((ROOT / 'pcb-mechanical.json').read_text(encoding='utf-8'))
|
||||
OUT = ROOT / 'out/pcb-start'
|
||||
MECH_LIB = ROOT / 'lib/PhoneRail-Mechanical.pretty'
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def xy(x, y):
|
||||
return pcb.VECTOR2I(pcb.FromMM(x), pcb.FromMM(y))
|
||||
|
||||
|
||||
def top(x, y):
|
||||
ox, oy = CONFIG['origin_kicad_mm']
|
||||
return ox + x, oy + CONFIG['size_mm'][1] - y
|
||||
|
||||
|
||||
def site(n, u, v):
|
||||
x, y, angle = CONFIG['phone_sites'][str(n)]
|
||||
if angle == 180:
|
||||
u, v = CONFIG['phone_size_mm'][0] - u, CONFIG['phone_size_mm'][1] - v
|
||||
return top(x + u, y + v)
|
||||
|
||||
|
||||
def layer_set(*layers):
|
||||
result = pcb.LSET()
|
||||
for layer in layers:
|
||||
result.AddLayer(layer)
|
||||
return result
|
||||
|
||||
|
||||
def draw_line(board, a, b, layer=pcb.Edge_Cuts, width=.05):
|
||||
item = pcb.PCB_SHAPE()
|
||||
item.SetShape(pcb.SHAPE_T_SEGMENT)
|
||||
item.SetStart(xy(*a))
|
||||
item.SetEnd(xy(*b))
|
||||
item.SetLayer(layer)
|
||||
item.SetWidth(pcb.FromMM(width))
|
||||
board.Add(item)
|
||||
|
||||
|
||||
def rounded_rect(board, x, y, w, h, r, layer=pcb.Edge_Cuts):
|
||||
points = [(x+r,y), (x+w-r,y), (x+w,y+r), (x+w,y+h-r),
|
||||
(x+w-r,y+h), (x+r,y+h), (x,y+h-r), (x,y+r)]
|
||||
for i in (0, 2, 4, 6):
|
||||
draw_line(board, points[i], points[i+1], layer)
|
||||
for i, center, mid_angle in [(1,(x+w-r,y+r),-45), (3,(x+w-r,y+h-r),45),
|
||||
(5,(x+r,y+h-r),135), (7,(x+r,y+r),225)]:
|
||||
mid = (center[0]+r*math.cos(math.radians(mid_angle)),
|
||||
center[1]+r*math.sin(math.radians(mid_angle)))
|
||||
arc = pcb.PCB_SHAPE()
|
||||
arc.SetShape(pcb.SHAPE_T_ARC)
|
||||
arc.SetArcGeometry(xy(*points[i]), xy(*mid), xy(*points[(i+1) % 8]))
|
||||
arc.SetLayer(layer)
|
||||
arc.SetWidth(pcb.FromMM(.05))
|
||||
board.Add(arc)
|
||||
|
||||
|
||||
def label(board, text, x, y, size=1, layer=pcb.Dwgs_User):
|
||||
item = pcb.PCB_TEXT(board)
|
||||
item.SetText(text)
|
||||
item.SetPosition(xy(x, y))
|
||||
item.SetTextSize(xy(size, size))
|
||||
item.SetTextThickness(pcb.FromMM(size / 7))
|
||||
item.SetLayer(layer)
|
||||
board.Add(item)
|
||||
|
||||
|
||||
def keepout(board, name, rect, layers, copper=False):
|
||||
zone = pcb.ZONE(board)
|
||||
zone.SetIsRuleArea(True)
|
||||
zone.SetZoneName(name)
|
||||
zone.SetLayerSet(layer_set(*layers))
|
||||
zone.SetDoNotAllowFootprints(True)
|
||||
zone.SetDoNotAllowTracks(copper)
|
||||
zone.SetDoNotAllowVias(copper)
|
||||
zone.SetDoNotAllowPads(copper)
|
||||
zone.SetDoNotAllowZoneFills(copper)
|
||||
x0, y0, x1, y1 = rect
|
||||
outline = zone.Outline()
|
||||
outline.NewOutline()
|
||||
for x, y in [(x0,y0), (x1,y0), (x1,y1), (x0,y1)]:
|
||||
outline.Append(pcb.FromMM(x), pcb.FromMM(y))
|
||||
board.Add(zone)
|
||||
|
||||
|
||||
def mechanical_library():
|
||||
MECH_LIB.mkdir(exist_ok=True)
|
||||
for name, drill, pad, radius, plated in [
|
||||
('M3_GND_D3.2', 3.2, 6, 3.5, True),
|
||||
('M1.6_NPTH_D1.8', 1.8, 1.8, CONFIG['phone_hole_courtyard_radius_mm'], False)]:
|
||||
fp = pcb.FOOTPRINT(None)
|
||||
fp.SetFPIDAsString('PhoneRail-Mechanical:' + name)
|
||||
fp.SetReference('H**')
|
||||
fp.SetValue(name)
|
||||
fp.SetAttributes(pcb.FP_BOARD_ONLY | pcb.FP_EXCLUDE_FROM_BOM | pcb.FP_EXCLUDE_FROM_POS_FILES)
|
||||
hole = pcb.PAD(fp)
|
||||
hole.SetNumber('1' if plated else '')
|
||||
hole.SetAttribute(pcb.PAD_ATTRIB_PTH if plated else pcb.PAD_ATTRIB_NPTH)
|
||||
hole.SetShape(pcb.PAD_SHAPE_CIRCLE)
|
||||
hole.SetSize(xy(pad, pad))
|
||||
hole.SetDrillSize(xy(drill, drill))
|
||||
layers = pcb.LSET.AllCuMask()
|
||||
layers.AddLayer(pcb.F_Mask)
|
||||
layers.AddLayer(pcb.B_Mask)
|
||||
hole.SetLayerSet(layers)
|
||||
fp.Add(hole)
|
||||
for layer in (pcb.F_CrtYd, pcb.B_CrtYd):
|
||||
shape = pcb.PCB_SHAPE(fp)
|
||||
shape.SetShape(pcb.SHAPE_T_CIRCLE)
|
||||
shape.SetCenter(xy(0, 0))
|
||||
shape.SetEnd(xy(radius, 0))
|
||||
shape.SetWidth(pcb.FromMM(.05))
|
||||
shape.SetLayer(layer)
|
||||
fp.Add(shape)
|
||||
fp.Reference().SetVisible(False)
|
||||
fp.Value().SetVisible(False)
|
||||
pcb.PCB_IO_MGR.FindPlugin(pcb.PCB_IO_MGR.KICAD_SEXP).FootprintSave(str(MECH_LIB), fp)
|
||||
path = MECH_LIB/(name+'.kicad_mod')
|
||||
path.write_text(pth_apertures(path.read_text(encoding='utf-8')),encoding='utf-8')
|
||||
|
||||
|
||||
def mount(board, name, ref, position, gnd=None):
|
||||
fp = pcb.FootprintLoad(str(MECH_LIB), name)
|
||||
fp.SetReference(ref)
|
||||
fp.SetPosition(xy(*position))
|
||||
fp.SetLocked(True)
|
||||
if gnd:
|
||||
for pad in fp.Pads():
|
||||
pad.SetNet(gnd)
|
||||
board.Add(fp)
|
||||
|
||||
|
||||
def place(fp, position, rotation=0, bottom=False, locked=True):
|
||||
fp.SetPosition(xy(*position))
|
||||
if bottom:
|
||||
fp.Flip(fp.GetPosition(), pcb.FLIP_DIRECTION_TOP_BOTTOM)
|
||||
fp.SetOrientationDegrees(rotation)
|
||||
fp.SetLocked(locked)
|
||||
fp.SetField('Placement', 'MECHANICAL_ANCHOR' if locked else 'STAGED_NOT_PLACED')
|
||||
for field in fp.GetFields():
|
||||
if field.GetName() not in {'Reference', 'Value'}:
|
||||
field.SetVisible(False)
|
||||
field.SetLayer(pcb.B_Fab if bottom else pcb.F_Fab)
|
||||
field.SetMirrored(bottom)
|
||||
fp.Value().SetVisible(False)
|
||||
fp.Reference().SetTextSize(xy(.8, .8))
|
||||
fp.Reference().SetTextThickness(pcb.FromMM(.12))
|
||||
box = fp.GetBoundingBox(False, False)
|
||||
fp.Reference().SetPosition(pcb.VECTOR2I(fp.GetPosition().x, box.GetTop()-pcb.FromMM(1)))
|
||||
|
||||
|
||||
def stage(board, parts, source, number):
|
||||
groups = {}
|
||||
for ref, fp in parts.items():
|
||||
if fp.IsLocked():
|
||||
continue
|
||||
groups.setdefault(source.parts[ref]['page'], []).append(fp)
|
||||
xstart = CONFIG['origin_kicad_mm'][0] + CONFIG['size_mm'][0] + 15
|
||||
ystart = CONFIG['origin_kicad_mm'][1]
|
||||
column_y = [ystart] * 3
|
||||
for index, (page, fps) in enumerate(sorted(groups.items())):
|
||||
col = index % 3
|
||||
left, y = xstart + col*77, column_y[col] + 6
|
||||
label(board, page.removesuffix('.kicad_sch'), left+32, y-4, 1.2)
|
||||
cursor, row_height = left, 0
|
||||
for fp in sorted(fps, key=lambda f: f.GetReference()):
|
||||
place(fp, (0, 0), bottom=number == 2, locked=False)
|
||||
box = fp.GetBoundingBox(False, False)
|
||||
w, h = pcb.ToMM(box.GetWidth()), pcb.ToMM(box.GetHeight())
|
||||
if cursor+w > left+70 and cursor > left:
|
||||
cursor, y, row_height = left, y+row_height+4, 0
|
||||
offset = (cursor-pcb.ToMM(box.GetLeft()), y-pcb.ToMM(box.GetTop()))
|
||||
fp.Move(xy(*offset))
|
||||
cursor += w+3
|
||||
row_height = max(row_height, h)
|
||||
column_y[col] = y+row_height+7
|
||||
|
||||
|
||||
def add_stackup(path):
|
||||
source = path.read_text(encoding='utf-8')
|
||||
setup = child(parse(source), 'setup')
|
||||
layers = ['F.Cu','In1.Cu','In2.Cu','B.Cu']
|
||||
items = ['(stackup']
|
||||
for i, layer in enumerate(layers):
|
||||
items.append(f'(layer "{layer}" (type "copper") (thickness {CONFIG["copper_mm"][i]}))')
|
||||
if i < 3:
|
||||
kind = 'core' if i == 1 else 'prepreg'
|
||||
items.append(f'(layer "dielectric {i+1}" (type "{kind}") '
|
||||
f'(thickness {CONFIG["dielectric_mm"][i]}) (material "FR4") '
|
||||
'(epsilon_r 4.3) (loss_tangent 0.02))')
|
||||
items.append('(copper_finish "ENIG") (dielectric_constraints no))')
|
||||
old = child(setup, 'stackup')
|
||||
start, end = (old.start, old.end) if old else (setup.end-1, setup.end-1)
|
||||
source = source[:start]+'\n'.join(items)+'\n'+source[end:]
|
||||
path.write_text(pth_apertures(source), encoding='utf-8')
|
||||
|
||||
|
||||
def pth_apertures(source):
|
||||
# Explicit no-tenting survives a native load/save cycle in KiCad 10.
|
||||
edits = []
|
||||
root = parse(source)
|
||||
footprints = [root] if root[0] == 'footprint' else children(root,'footprint')
|
||||
for footprint in footprints:
|
||||
for pad in children(footprint,'pad'):
|
||||
if pad[2] in {'thru_hole','np_thru_hole'}:
|
||||
layers = child(pad,'layers')
|
||||
edits.append((layers.start,layers.end,'(layers "*.Cu" "*.Mask")'))
|
||||
if not child(pad,'tenting'):
|
||||
edits.append((pad.end-1,pad.end-1,' (tenting (front no) (back no))\n'))
|
||||
for start,end,replacement in sorted(edits,reverse=True):
|
||||
source = source[:start]+replacement+source[end:]
|
||||
return source
|
||||
|
||||
|
||||
def native_net_names(source):
|
||||
if hasattr(source, 'pcb_net_names'):
|
||||
return source.pcb_net_names
|
||||
path = OUT/f'Board{source.number}.net'
|
||||
result = subprocess.run(['D:/KiCad/bin/kicad-cli.exe','sch','export','netlist',
|
||||
'--format','kicadsexpr','-o',str(path),str(source.path)],
|
||||
capture_output=True,encoding='utf-8',errors='replace',timeout=60)
|
||||
path.with_suffix('.net.log').write_text(result.stdout+result.stderr,encoding='utf-8')
|
||||
if result.returncode:
|
||||
raise RuntimeError(f'Native netlist failed: {path}')
|
||||
nets = child(parse(path.read_text(encoding='utf-8')),'nets')
|
||||
by_members = {frozenset((child(node,'ref')[1],child(node,'pin')[1]) for node in children(net,'node')):
|
||||
child(net,'name')[1] for net in children(nets,'net')}
|
||||
# XML is human-readable; the native netlist retains KiCad's escaped names.
|
||||
source.pcb_net_names = {name:by_members[frozenset(members)] for name,members in source.net_members.items()}
|
||||
return source.pcb_net_names
|
||||
|
||||
|
||||
def project_settings(number):
|
||||
path = ROOT / f'PhoneRail-Board{number}/PhoneRail-Board{number}.kicad_pro'
|
||||
project = json.loads(path.read_text(encoding='utf-8'))
|
||||
net = project.setdefault('net_settings', {})
|
||||
base = dict(net['classes'][0])
|
||||
base.update(clearance=.15, track_width=.25, via_diameter=.6, via_drill=.3,
|
||||
diff_pair_width=.28, diff_pair_gap=.18, diff_pair_via_gap=.25)
|
||||
classes = [dict(base, name='Default')]
|
||||
for name, width, clearance in [('USB90',.28,.15), ('PWR4A',1.2,.2),
|
||||
('PWR5V',.8,.2), ('PWR12V',2,.2)]:
|
||||
classes.append(dict(base, name=name, track_width=width, clearance=clearance))
|
||||
net['classes'] = classes
|
||||
patterns = [(n, 'PWR4A') for n in ['VO*_P', *[f'VO{i}' for i in range(1,6)],
|
||||
*[f'Net-(U{i}-VIN+)' for i in range(8,13)]]]
|
||||
patterns += [(n, 'PWR12V') for n in ['+12V', '*/FAN_12V_SW', 'Net-(J1-+)', 'Net-(D1-C)']]
|
||||
patterns += [(n, 'PWR5V') for n in ['+5V', *[f'VBUS{i}' for i in range(1,6)]]]
|
||||
patterns += [(n, 'USB90') for n in ['USB*_DP','USB*_DM','USB*_DPJ','USB*_DMJ','UP_DP','UP_DM']]
|
||||
net['netclass_patterns'] = [{'pattern': p, 'netclass': c} for p, c in patterns]
|
||||
settings = project['board'].setdefault('design_settings', {})
|
||||
settings.setdefault('rules', {}).update(min_clearance=.127, min_track_width=.127,
|
||||
min_via_diameter=.6, min_through_hole_diameter=.3, min_hole_to_hole=.25,
|
||||
min_copper_edge_clearance=.3, solder_mask_clearance=.05, solder_mask_min_width=.1)
|
||||
return path, json.dumps(project, indent=2, ensure_ascii=False)+'\n'
|
||||
|
||||
|
||||
def build(number, source):
|
||||
board = pcb.BOARD()
|
||||
board.SetCopperLayerCount(4)
|
||||
board.GetDesignSettings().SetBoardThickness(pcb.FromMM(CONFIG['thickness_mm']))
|
||||
board.GetTitleBlock().SetTitle(f'PhoneRail Board{number} R1 - placement start')
|
||||
board.GetTitleBlock().SetRevision('R1-PLACEMENT-START')
|
||||
origin = CONFIG['origin_kicad_mm']
|
||||
rounded_rect(board, *origin, *CONFIG['size_mm'], CONFIG['corner_radius_mm'])
|
||||
nets = {}
|
||||
names = native_net_names(source)
|
||||
for name in source.net_members:
|
||||
net = pcb.NETINFO_ITEM(board, names[name])
|
||||
board.Add(net)
|
||||
nets[name] = net
|
||||
tree = ET.parse(OUT / f'Board{number}.xml')
|
||||
root_uuid = child(parse(source.path.read_text(encoding='utf-8')), 'uuid')[1]
|
||||
parts = {}
|
||||
for c in tree.findall('components/comp'):
|
||||
ref, fpname = c.get('ref'), c.findtext('footprint')
|
||||
if not fpname:
|
||||
raise ValueError(f'{ref}: no footprint')
|
||||
lib, name = fpname.split(':')
|
||||
if lib == 'PhoneRail':
|
||||
assert 'phonerail-y-axis-corrected-r1' in (ROOT/f'lib/{lib}.pretty/{name}.kicad_mod').read_text(encoding='utf-8'), 'Run repair_footprint_handedness.py before importing the migrated library'
|
||||
fp = pcb.FootprintLoad(str(ROOT / f'lib/{lib}.pretty'), name)
|
||||
if fp is None:
|
||||
raise ValueError(f'{ref}: missing {fpname}')
|
||||
fp.SetFPIDAsString(fpname)
|
||||
fp.SetReference(ref)
|
||||
fp.SetValue(c.findtext('value'))
|
||||
fp.SetSheetname(source.parts[ref]['page'].removesuffix('.kicad_sch'))
|
||||
fp.SetSheetfile(source.parts[ref]['page'])
|
||||
fp.SetExcludedFromBOM(source.parts[ref]['in_bom'] != 'yes')
|
||||
fp.SetExcludedFromPosFiles(source.parts[ref]['in_bom'] != 'yes')
|
||||
fp.SetDNP(source.parts[ref]['dnp'] == 'yes')
|
||||
for field in c.findall('fields/field'):
|
||||
if field.get('name') not in {'Reference','Value','Footprint'}:
|
||||
fp.SetField(field.get('name'), field.text or '')
|
||||
path = pcb.KIID_PATH()
|
||||
ids = [root_uuid, *c.find('sheetpath').get('tstamps').strip('/').split('/'), c.findtext('tstamps')]
|
||||
for uid in filter(None, ids):
|
||||
path.push_back(pcb.KIID(uid))
|
||||
fp.SetPath(path)
|
||||
board.Add(fp)
|
||||
for pad in fp.Pads():
|
||||
net_name = source.pins.get((ref, pad.GetNumber()))
|
||||
if net_name:
|
||||
pad.SetNet(nets[net_name])
|
||||
parts[ref] = fp
|
||||
for side, position in CONFIG['headers_source_mm'].items():
|
||||
ref = {1: {'left':'J24','right':'J25'}, 2: {'left':'J31','right':'J32'}}[number][side]
|
||||
place(parts[ref], top(*position), bottom=number == 2)
|
||||
holes = list(CONFIG['mounting_holes_common_mm'])
|
||||
if number == 2:
|
||||
holes += CONFIG['mounting_holes_carrier_only_mm']
|
||||
for i, point in enumerate(holes, 1):
|
||||
mount(board, 'M3_GND_D3.2', f'MH{i}', top(*point), nets['GND'])
|
||||
slot_rectangles = []
|
||||
if number == 2:
|
||||
place(parts['J23'], top(*CONFIG['usb_upstream_source_mm']), rotation=270, bottom=True)
|
||||
for n in range(1, 6):
|
||||
x, y, angle = CONFIG['phone_sites'][str(n)]
|
||||
w, h = CONFIG['phone_size_mm']
|
||||
lx, ly = top(x, y+h)
|
||||
rounded_rect(board, lx, ly, w, h, .5, pcb.Dwgs_User)
|
||||
label(board, f'CH{n} PHONE - 1mm GAP', lx+w/2, ly+h/2, 1.2)
|
||||
for name, pos in CONFIG['phone_holes_mm'].items():
|
||||
if name not in CONFIG['phone_holes_skipped']:
|
||||
mount(board, 'M1.6_NPTH_D1.8', f'P{n}{name}', site(n, *pos))
|
||||
for index, pos in enumerate(CONFIG['phone_key_contacts_mm']):
|
||||
ref = f'J{41+(n-1)*4+index}'
|
||||
place(parts[ref], site(n, *pos), rotation=angle)
|
||||
# A separate label outside the contact cluster prevents ink on contacts.
|
||||
parts[ref].Reference().SetVisible(False)
|
||||
for prefix, key in [('J8','battery_connector_local_mm'), ('J7','tail_connector_local_mm')]:
|
||||
place(parts[f'{prefix}{n}'], site(n, *CONFIG[key]), rotation=angle, bottom=True)
|
||||
for name, rect in CONFIG['slots_local_mm'].items():
|
||||
a, b = site(n, *rect[:2]), site(n, *rect[2:])
|
||||
x0,y0,x1,y1 = min(a[0],b[0]),min(a[1],b[1]),max(a[0],b[0]),max(a[1],b[1])
|
||||
rounded_rect(board, x0,y0,x1-x0,y1-y0,CONFIG['slot_corner_radius_mm'])
|
||||
margin = CONFIG['slot_clearance_mm']
|
||||
keepout(board, f'CH{n}_{name}_CUTOUT', (x0-margin,y0-margin,x1+margin,y1+margin),
|
||||
[pcb.F_Cu,pcb.In1_Cu,pcb.In2_Cu,pcb.B_Cu], copper=True)
|
||||
slot_rectangles.append([x0,y0,x1,y1])
|
||||
for name, rect in CONFIG['ribbon_lanes_local_mm'].items():
|
||||
a,b = site(n,*rect[:2]),site(n,*rect[2:])
|
||||
keepout(board, f'CH{n}_{name}_RIBBON',
|
||||
(min(a[0],b[0]),min(a[1],b[1]),max(a[0],b[0]),max(a[1],b[1])), [pcb.B_Cu])
|
||||
label(board, f'BOARD {number} R1 - UNROUTED', origin[0]+110, origin[1]-5, 1.8)
|
||||
stage(board, parts, source, number)
|
||||
destination = OUT / f'PhoneRail-Board{number}.kicad_pcb'
|
||||
pcb.SaveBoard(str(destination), board)
|
||||
add_stackup(destination)
|
||||
reloaded = pcb.LoadBoard(str(destination))
|
||||
report = verify_import(reloaded, source, number)
|
||||
report['internal_slots'] = len(slot_rectangles)
|
||||
return destination, reloaded, report
|
||||
|
||||
|
||||
def verify_import(board, source, number, allow_routes=False):
|
||||
parts = {f.GetReference(): f for f in board.GetFootprints()}
|
||||
electrical = {r: f for r, f in parts.items() if r in source.parts}
|
||||
assert set(electrical) == set(source.parts), 'Component import mismatch'
|
||||
checked = 0
|
||||
names = native_net_names(source)
|
||||
for ref, fp in electrical.items():
|
||||
expected = {pin: net for (r, pin), net in source.pins.items() if r == ref}
|
||||
pads = {}
|
||||
for pad in fp.Pads():
|
||||
pads.setdefault(pad.GetNumber(), []).append(pad)
|
||||
assert set(expected) <= set(pads), (ref, 'Missing pads', set(expected)-set(pads))
|
||||
for pin, net in expected.items():
|
||||
assert all(p.GetNetname() == names[net] for p in pads[pin]), (ref, pin, net)
|
||||
checked += len(pads[pin])
|
||||
for pad in fp.Pads():
|
||||
if pad.GetAttribute() in {pcb.PAD_ATTRIB_PTH,pcb.PAD_ATTRIB_NPTH}:
|
||||
assert pad.GetLayerSet().Contains(pcb.F_Mask) and pad.GetLayerSet().Contains(pcb.B_Mask), (ref,'missing PTH mask openings')
|
||||
assert fp.GetPath().size() >= 2, (ref, 'Missing schematic UUID path')
|
||||
assert fp.GetFPIDAsString() == source.parts[ref]['Footprint']
|
||||
if number == 2 and ref.startswith('J') and ref[1:].isdigit() and 41 <= int(ref[1:]) <= 60:
|
||||
assert all(not p.GetLayerSet().Contains(pcb.F_Paste) for p in fp.Pads()), ref
|
||||
assert fp.IsExcludedFromBOM() and fp.IsExcludedFromPosFiles(), ref
|
||||
assert board.GetCopperLayerCount() == 4
|
||||
assert abs(pcb.ToMM(board.GetDesignSettings().GetBoardThickness())-1.6) < .001
|
||||
if not allow_routes:
|
||||
assert len(list(board.GetTracks())) == 0
|
||||
if number == 2:
|
||||
usb = electrical['J23']
|
||||
assert all(p.GetPosition().x < usb.GetPosition().x for p in usb.Pads()
|
||||
if p.GetNumber() in {'A5','B5','A6','B6','A7','B7'}), 'USB mouth faces inward'
|
||||
for n in range(1,6):
|
||||
fp = electrical[f'J7{n}']
|
||||
pads = {p.GetNumber():p for p in fp.Pads()}
|
||||
# In local phone coordinates, VBUS anchor is -u, even row is +v.
|
||||
sign = -1 if CONFIG['phone_sites'][str(n)][2] == 180 else 1
|
||||
assert (pads['51'].GetPosition().x-fp.GetPosition().x)*sign < 0
|
||||
assert (pads['2'].GetPosition().y-fp.GetPosition().y)*sign < 0
|
||||
return {'electrical_footprints':len(electrical), 'mechanical_footprints':len(parts)-len(electrical),
|
||||
'pad_net_assignments_checked':checked, 'net_count':len(source.net_members),
|
||||
'anchored_electrical_footprints':sum(f.IsLocked() for f in electrical.values()),
|
||||
'staged_electrical_footprints':sum(any(field.GetName() == 'Placement' and field.GetText() == 'STAGED_NOT_PLACED'
|
||||
for field in f.GetFields()) for f in electrical.values()),
|
||||
'tracks':len(list(board.GetTracks())), 'copper_layers':4, 'thickness_mm':1.6}
|
||||
|
||||
|
||||
def mating_check(lower, upper):
|
||||
boards = [{f.GetReference():f for f in b.GetFootprints()} for b in (lower, upper)]
|
||||
count = 0
|
||||
for lref, uref in [('J24','J31'),('J25','J32')]:
|
||||
lp = {p.GetNumber():p for p in boards[0][lref].Pads()}
|
||||
up = {p.GetNumber():p for p in boards[1][uref].Pads()}
|
||||
for pin in range(1,41):
|
||||
a, b = lp[str(pin)], up[str(pin+1 if pin % 2 else pin-1)]
|
||||
assert (a.GetPosition()-b.GetPosition()).EuclideanNorm() < pcb.FromMM(.001), (lref,pin,'misaligned')
|
||||
assert a.GetNetname() == b.GetNetname(), (lref,pin,'wrong net')
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(exist_ok=True)
|
||||
manifest_path = OUT / 'pcb-start.json'
|
||||
old = json.loads(manifest_path.read_text(encoding='utf-8')) if manifest_path.exists() else {}
|
||||
for n in (1,2):
|
||||
name = f'PhoneRail-Board{n}'
|
||||
for suffix in ('.kicad_pcb', '.kicad_dru'):
|
||||
target = ROOT / name / (name+suffix)
|
||||
if target.exists() and digest(target) != old.get('generated_files', {}).get(str(target.relative_to(ROOT))):
|
||||
raise RuntimeError(f'Refusing to overwrite an edited or untracked file: {target}')
|
||||
mechanical_library()
|
||||
results, boards, artifacts = {}, [], []
|
||||
for n in (1,2):
|
||||
source = Board(n)
|
||||
source.compile(r'D:\KiCad\bin\kicad-cli.exe', OUT)
|
||||
temporary, board, report = build(n, source)
|
||||
results[f'Board{n}'] = report
|
||||
boards.append(board)
|
||||
artifacts.append((temporary, ROOT/source.name/(source.name+'.kicad_pcb')))
|
||||
results['mated_pins_checked'] = mating_check(*boards)
|
||||
results['generated_files'] = {}
|
||||
rules = '''(version 1)
|
||||
(rule "USB90 geometry target"
|
||||
(condition "A.NetClass == 'USB90'")
|
||||
(constraint diff_pair_gap (min 0.15) (opt 0.18) (max 0.21))
|
||||
(constraint diff_pair_uncoupled (max 5)))
|
||||
'''
|
||||
for temporary, target in artifacts:
|
||||
shutil.copyfile(temporary, target)
|
||||
n = 1 if 'Board1' in target.stem else 2
|
||||
project_path, content = project_settings(n)
|
||||
project_path.write_text(content, encoding='utf-8')
|
||||
rule_path = target.with_suffix('.kicad_dru')
|
||||
rule_path.write_text(rules, encoding='utf-8')
|
||||
table_path = target.parent / 'fp-lib-table'
|
||||
table = table_path.read_text(encoding='utf-8')
|
||||
if 'PhoneRail-Mechanical' not in table:
|
||||
node = parse(table)
|
||||
entry = '(lib (name "PhoneRail-Mechanical") (type "KiCad") (uri "${KIPRJMOD}/../lib/PhoneRail-Mechanical.pretty") (options "") (descr "R1 mechanical anchors"))\n'
|
||||
table_path.write_text(table[:node.end-1]+entry+table[node.end-1:], encoding='utf-8')
|
||||
for path in (target, rule_path):
|
||||
results['generated_files'][str(path.relative_to(ROOT))] = digest(path)
|
||||
results['status'] = 'PLACEMENT_START_NOT_ROUTED_NOT_RELEASED'
|
||||
results['mechanical_config_sha256'] = digest(ROOT/'pcb-mechanical.json')
|
||||
manifest_path.write_text(json.dumps(results,indent=2)+'\n',encoding='utf-8')
|
||||
print(json.dumps(results, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Compile KiCad netlists and check selected PhoneRail hardware requirements.
|
||||
|
||||
Exit 1 means a hardware check failed, including known open design issues.
|
||||
ERC is reported independently; a passing contract is not a production release.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from schematic_maintenance import ROOT, parse, children, child, properties, HC595_PINS
|
||||
from fan_revision_checks import verify_fans, verify_unrelated
|
||||
|
||||
|
||||
class Board:
|
||||
def __init__(self, number):
|
||||
self.number = number
|
||||
self.name = f'PhoneRail-Board{number}'
|
||||
self.path = ROOT / self.name / f'{self.name}.kicad_sch'
|
||||
self.parts = {}
|
||||
for path in sorted(self.path.parent.glob('*.kicad_sch')):
|
||||
for symbol in children(parse(path.read_text(encoding='utf-8')), 'symbol'):
|
||||
props = {k: v[2] for k, v in properties(symbol).items()}
|
||||
ref = props['Reference']
|
||||
if ref.startswith('#PWR'):
|
||||
continue
|
||||
if ref in self.parts:
|
||||
raise ValueError(f'Duplicate reference {ref}')
|
||||
props['page'] = path.name
|
||||
props['in_bom'] = child(symbol, 'in_bom')[1]
|
||||
props['dnp'] = child(symbol, 'dnp')[1]
|
||||
self.parts[ref] = props
|
||||
self.pins = {}
|
||||
self.functions = {}
|
||||
self.pin_types = {}
|
||||
self.net_members = {}
|
||||
|
||||
def compile(self, cli, out):
|
||||
def run(args, destination):
|
||||
destination.unlink(missing_ok=True)
|
||||
result = subprocess.run([cli, *args], capture_output=True, encoding='utf-8', errors='replace')
|
||||
destination.with_suffix(destination.suffix + '.log').write_text(result.stdout + result.stderr, encoding='utf-8')
|
||||
if result.returncode or not destination.exists():
|
||||
raise RuntimeError(f'KiCad failed: {destination.name}; see its log')
|
||||
netfile = out / f'Board{self.number}.xml'
|
||||
run(['sch', 'export', 'netlist', '--format', 'kicadxml', '-o', str(netfile), str(self.path)], netfile)
|
||||
root = ET.parse(netfile).getroot()
|
||||
for net in root.findall('nets/net'):
|
||||
self.net_members[net.attrib['name']] = {(p.attrib['ref'], p.attrib['pin']) for p in net.findall('node')}
|
||||
for pin in net.findall('node'):
|
||||
key = (pin.attrib['ref'], pin.attrib['pin'])
|
||||
if key in self.pins:
|
||||
raise ValueError(f'Pin appears on multiple nets: {key}')
|
||||
self.pins[key] = net.attrib['name']
|
||||
self.functions[key] = pin.attrib.get('pinfunction', '')
|
||||
self.pin_types[key] = pin.attrib.get('pintype', '')
|
||||
erc = out / f'Board{self.number}-erc.json'
|
||||
run(['sch', 'erc', '--format', 'json', '-o', str(erc), str(self.path)], erc)
|
||||
report = json.loads(erc.read_text(encoding='utf-8'))
|
||||
violations = [v for sheet in report['sheets'] for v in sheet['violations']]
|
||||
self.erc = dict(Counter(v['type'] for v in violations))
|
||||
with (out / f'Board{self.number}-bom.csv').open('w', newline='', encoding='utf-8-sig') as stream:
|
||||
writer = csv.writer(stream)
|
||||
writer.writerow(['Reference', 'Value', 'Footprint', 'MPN', 'LCSC', 'DNP', 'Sheet'])
|
||||
for ref, props in sorted(self.parts.items()):
|
||||
if props['in_bom'] == 'yes':
|
||||
writer.writerow([ref, *[props.get(k, '') for k in ['Value', 'Footprint', 'MPN', 'LCSC', 'dnp', 'page']]])
|
||||
|
||||
def net(self, ref, pin):
|
||||
return self.pins[(ref, str(pin))]
|
||||
|
||||
def resistor(self, ref):
|
||||
props = self.parts[ref]
|
||||
code = re.fullmatch(r'0402WGF(\d{4})TCE', props.get('MPN', ''))
|
||||
if code:
|
||||
digits = code[1]
|
||||
return int(digits[:3]) * 10 ** int(digits[3])
|
||||
fixed_parts = {'FRC2512F10R0TS': 10, 'RHC2512FT2R00': 2}
|
||||
if props.get('MPN') in fixed_parts:
|
||||
return fixed_parts[props['MPN']]
|
||||
value = re.fullmatch(r'([\d.]+)([km]?)Ω', props['Value'])
|
||||
if not value:
|
||||
raise ValueError(f'Unknown resistance {ref}: {props["Value"]}')
|
||||
return float(value[1]) * {'': 1, 'k': 1000, 'm': .001}[value[2]]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--cli', default=shutil.which('kicad-cli') or r'D:\KiCad\bin\kicad-cli.exe')
|
||||
parser.add_argument('--firmware-dir', type=Path, default=ROOT.parent / 'firmware',
|
||||
help='Firmware source root; default remains the sibling firmware')
|
||||
parser.add_argument('--out', type=Path, default=ROOT / 'out',
|
||||
help='Keep candidate reports separate from the integrated baseline')
|
||||
parser.add_argument('--strict', action='store_true',
|
||||
help='Include checks explicitly deferred from the R1 prototype scope')
|
||||
args = parser.parse_args()
|
||||
out = args.out.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
boards = [Board(1), Board(2)]
|
||||
for board in boards:
|
||||
board.compile(args.cli, out)
|
||||
a, b = boards
|
||||
checks = []
|
||||
|
||||
def check(name, good, detail=''):
|
||||
checks.append({'check': name, 'pass': bool(good), 'detail': detail})
|
||||
|
||||
def same(board, label, *pins):
|
||||
nets = [board.net(*p) for p in pins]
|
||||
check(label, len(set(nets)) == 1 and not nets[0].startswith('unconnected-'), str(nets))
|
||||
|
||||
check('Board1 component count', len(a.parts) == 301, 'Four-wire fan prototype R2: 24 PCB parts on P6; external fans in assembly BOM')
|
||||
check('Board2 component count', len(b.parts) == 228, 'Hub strap ECO adds ten 100k pulldowns')
|
||||
from hub_strap_checks import verify_hub_straps
|
||||
verify_hub_straps(b, check)
|
||||
# Opposite rows mate: design section 26.5, confirmed connector numbering.
|
||||
for lower, upper in [('J24', 'J31'), ('J25', 'J32')]:
|
||||
for pin in range(1, 41):
|
||||
mate = pin + 1 if pin % 2 else pin - 1
|
||||
check(f'IF-7 {lower}.{pin} <-> {upper}.{mate}', a.net(lower, pin) == b.net(upper, mate), f'{a.net(lower, pin)} / {b.net(upper, mate)}')
|
||||
for ch in range(1, 6):
|
||||
buck, efuse, ina = f'U{ch}', f'U{ch + 13}', f'U{ch + 7}'
|
||||
r = lambda suffix: f'R{ch}{suffix:02}'
|
||||
for suffix, resistance in {1:30100, 2:5490, 3:100000, 7:150000, 8:1000, 9:68100, 10:20000, 11:20000, 14:100000, 18:100000, 19:10}.items():
|
||||
check(f'CH{ch} {r(suffix)} value', a.resistor(r(suffix)) == resistance)
|
||||
same(a, f'CH{ch} feedback node', (buck, 7), (r(1), 2), (r(2), 1), (r(7), 1))
|
||||
same(a, f'CH{ch} feedback bottom GND', (r(2), 2), (buck, 2))
|
||||
same(a, f'CH{ch} OVP senses pre-eFuse output', (r(9), 1), (efuse, 9), (r(1), 1))
|
||||
same(a, f'CH{ch} OVP divider', (r(9), 2), (r(10), 1), (efuse, 15))
|
||||
check(f'CH{ch} OVP bottom GND', a.net(r(10), 2) == 'GND')
|
||||
check(f'CH{ch} ILIM', a.net(r(11), 2) == 'GND' and a.net(r(11), 1) == a.net(efuse, 17))
|
||||
same(a, f'CH{ch} disable interlock', (efuse, 14), (r(14), 1), (f'Q{ch}01', 1))
|
||||
check(f'CH{ch} EN pulldown', a.net(r(14), 2) == 'GND')
|
||||
same(a, f'CH{ch} discharge gate', (f'Q{ch}01', 3), (f'Q{ch}02', 1), (r(18), 2))
|
||||
check(f'CH{ch} discharge gate pullup', a.net(r(18), 1) == '+3V3')
|
||||
same(a, f'CH{ch} discharge load', (r(19), 2), (f'Q{ch}02', 3))
|
||||
check(f'CH{ch} discharge output', a.net(r(19), 1) == f'VO{ch}_P')
|
||||
check(f'CH{ch} discharge source GND', a.net(f'Q{ch}02', 2) == 'GND' and a.net(f'Q{ch}01', 2) == 'GND')
|
||||
same(a, f'CH{ch} shunt upstream', (f'Rs{ch}01', 2), (ina, 10), (efuse, 4))
|
||||
same(a, f'CH{ch} shunt downstream', (f'Rs{ch}01', 1), (ina, 9), (ina, 8))
|
||||
check(f'CH{ch} battery pads', b.net(f'J8{ch}', 1) == f'VO{ch}_P' and b.net(f'J8{ch}', 2) == 'GND')
|
||||
check(f'CH{ch} BM24 supply anchors', b.net(f'J7{ch}', 51) == f'VBUS{ch}' and b.net(f'J7{ch}', 52) == 'GND')
|
||||
check(f'CH{ch} VBUS pulldown', b.resistor(f'R70{ch}') == 100000 and {b.net(f'R70{ch}', 1), b.net(f'R70{ch}', 2)} == {'GND', f'VBUS_EN{ch}'})
|
||||
check(f'CH{ch} EDL pulldown', b.resistor(f'R74{ch}') == 100000 and {b.net(f'R74{ch}', 1), b.net(f'R74{ch}', 2)} == {'GND', f'EDL_EN{ch}'})
|
||||
|
||||
rt, rb, ri = a.resistor('R101'), a.resistor('R102'), a.resistor('R107') + a.resistor('R108')
|
||||
maximum = .6 * (1 + rt / rb + rt / ri)
|
||||
minimum = maximum - 2.424 * rt / ri
|
||||
check('PR-4 nominal DAC output range', 3.51 < minimum < 3.53 and 4.00 < maximum < 4.02, f'{minimum:.6f} .. {maximum:.6f} V; includes R108 series resistance')
|
||||
ovp = .99 * (1 + a.resistor('R109') / a.resistor('R110'))
|
||||
check('SR-3 nominal OVP setpoint', 4.35 < ovp < 4.37, f'{ovp:.6f} V; not a transient or tolerance certification')
|
||||
for index in range(41, 61):
|
||||
p = b.parts[f'J{index}']
|
||||
check(f'Contact J{index} BOM', p['in_bom'] == 'no' and not p.get('MPN') and not p.get('LCSC'))
|
||||
|
||||
fw = args.firmware_dir.resolve() / 'main'
|
||||
for ref, cap in [('U21', 'C604'), ('U22', 'C605'), ('U33', 'C739')]:
|
||||
for pin, (_, kind) in HC595_PINS.items():
|
||||
check(f'595 {ref}.{pin} electrical type', b.pin_types[(ref, pin)].split('+')[0] == kind)
|
||||
for pin, net in [(8, 'GND'), (10, '+3V3_HUB'), (16, '+3V3_HUB'), (11, 'KEY_SRCLK'), (12, 'KEY_RCLK'), (13, 'KEY_OE#')]:
|
||||
check(f'595 {ref}.{pin} {net}', b.net(ref, pin) == net)
|
||||
check(f'595 {ref} decoupling connection', {b.net(cap, 1), b.net(cap, 2)} == {'+3V3_HUB', 'GND'} and b.parts[cap]['Value'] == '100nF')
|
||||
check('595 OE pullup', b.resistor('R609') == 100000 and {b.net('R609', 1), b.net('R609', 2)} == {'KEY_OE#', '+3V3_HUB'})
|
||||
check('595 serial input', b.net('U21', 14) == 'KEY_SER')
|
||||
for upstream, downstream in [('U21', 'U22'), ('U22', 'U33')]:
|
||||
check(f'595 cascade {upstream} -> {downstream}', b.net_members[b.net(upstream, 9)] == {(upstream, '9'), (downstream, '14')})
|
||||
check('595 HUB reset output', b.net('U33', 7) == b.net('U32', 43) == 'HUB_RST')
|
||||
for ref, pin in [('U21', '4'), ('U22', '4'), ('U22', '15'), ('U33', '9')]:
|
||||
check(f'595 reviewed unused output {ref}.{pin}', b.net_members[b.net(ref, pin)] == {(ref, pin)} and 'no_connect' in b.pin_types[(ref, pin)].split('+'))
|
||||
if fw.exists():
|
||||
header = (fw / 'board_pins.h').read_text(encoding='utf-8')
|
||||
config = (fw / 'rail_cfg.c').read_text(encoding='utf-8')
|
||||
gpio = {net: int(a.functions[key].split('_')[0][2:]) for key, net in a.pins.items() if key[0] == 'U20' and re.fullmatch(r'IO\d+(?:_\d+)?', a.functions[key])}
|
||||
sr_header = (fw.parent / 'components/drv/include/sr595.h').read_text(encoding='utf-8')
|
||||
for define, net in {'SER': 'KEY_SER', 'SRCLK': 'KEY_SRCLK', 'RCLK': 'KEY_RCLK', 'OE_N': 'KEY_OE#'}.items():
|
||||
match = re.search(r'#define\s+SR595_PIN_' + define + r'\s+(\d+)', sr_header)
|
||||
check(f'Firmware 595 GPIO {net}', bool(match) and int(match[1]) == gpio.get(net))
|
||||
for define, net in {'I2C_SDA':'SDA', 'I2C_SCL':'SCL', 'FAN_EN':'FAN_EN', 'FAN_TACH_IN':'FAN_TACH_IN', 'FAN_TACH_OUT':'FAN_TACH_OUT', 'FAN_PWM':'FAN_PWM', 'UP_VBUS_DET':'UP_VBUS_DET', 'NTC1':'NTC1', 'NTC2':'NTC2', 'USB_DM':'USB_DM', 'USB_DP':'USB_DP'}.items():
|
||||
match = re.search(r'#define\s+BOARD_PIN_' + define + r'\s+(\d+)', header)
|
||||
check(f'Firmware GPIO {net}', bool(match) and int(match[1]) == gpio.get(net))
|
||||
rows = re.findall(r'/\* CH(\d) \*/\s*\{\s*(0x\w+),\s*(0x\w+),\s*(\d+),\s*(\d+),\s*(\d+),\s*\{\s*(\d+),\s*(\d+),\s*(\d+)\s*\},\s*(\d+)', config)
|
||||
check('Firmware rail table parsed', len(rows) == 5)
|
||||
for row in rows:
|
||||
ch, ina_addr, dac_addr, dac_ch, en, vbus, pwr, up, down, edl = [int(x, 0) for x in row]
|
||||
check(f'Firmware CH{ch} enables', gpio[f'EFUSE_EN{ch}'] == en and gpio[f'VBUS_EN{ch}'] == vbus)
|
||||
straps = {'GND':0, '+3V3':1, 'SDA':2, 'SCL':3}
|
||||
address = 0x40 + 4 * straps[a.net(f'U{ch+7}', 1)] + straps[a.net(f'U{ch+7}', 2)]
|
||||
check(f'Firmware CH{ch} INA address', address == ina_addr)
|
||||
dac = {0x4a:'U6', 0x4b:'U7'}[dac_addr]
|
||||
check(f'Firmware CH{ch} DAC output', a.net(dac, {0:11, 1:10, 2:3, 3:2}[dac_ch]) == f'VDAC{ch}')
|
||||
for bit, net in [(pwr, f'K{ch}_PWR'), (up, f'K{ch}_VUP'), (down, f'K{ch}_VDN'), (edl, f'EDL_EN{ch}')]:
|
||||
chip, q = ['U21', 'U22', 'U33'][bit // 8], bit % 8
|
||||
check(f'Firmware shift bit {bit} {net}', b.net(chip, 15 if q == 0 else q) == net)
|
||||
else:
|
||||
check('Firmware sources available', False, 'Sibling firmware/main required to verify GPIO mapping')
|
||||
|
||||
check('FR-10 two fan connectors only', all(r in a.parts for r in ('J26', 'J27')) and
|
||||
not any(r in a.parts for r in ('J28', 'J29', 'J30')))
|
||||
check('FR-10 selected fan four-wire interface', all((r, '4') in a.pins for r in ('J26', 'J27')),
|
||||
'SANYO 9G0612P4S001 / C2920592: four bare leads, screw-terminal interface')
|
||||
check('FR-10 obsolete divider removed', not any(r in a.parts for r in ('R730', 'R731', 'R732')))
|
||||
check('FR-10 independent raw TACH nets', a.net('J26', 3) != a.net('J27', 3))
|
||||
check('FR-10 reset pulldown', a.resistor('R734') == 100000 and
|
||||
{a.net('R734', 1), a.net('R734', 2)} == {'GND', a.net('Q6', 1)})
|
||||
for ref, pin, name in [('J26', 9, 'FAN_TACH_IN'), ('J27', 4, 'FAN_TACH_OUT')]:
|
||||
check(f'FR-10 reserved input {name}', a.net('U20', pin) == name)
|
||||
check(f'FR-10 raw TACH separated from MCU {ref}',
|
||||
not any(r == 'U20' for r, _ in a.net_members[a.net(ref, 3)]))
|
||||
check(f'FR-10 receiver populated {name}', len(a.net_members[a.net('U20', pin)]) > 1,
|
||||
'Discrete MOS receiver fitted; electrical assumptions checked separately')
|
||||
verify_fans(a, check)
|
||||
verify_unrelated(check, out)
|
||||
scope = json.loads((ROOT / 'prototype-scope.json').read_text(encoding='utf-8'))
|
||||
deferred = {} if args.strict else scope['deferred_checks']
|
||||
for result in checks:
|
||||
result['status'] = 'passed' if result['pass'] else 'deferred' if result['check'] in deferred else 'failed'
|
||||
if result['status'] == 'deferred':
|
||||
result['scope_reason'] = deferred[result['check']]
|
||||
report = {'firmware_dir': str(fw.parent), 'scope': 'strict' if args.strict else scope['revision'],
|
||||
'checks': checks, 'passed': sum(x['pass'] for x in checks),
|
||||
'failed': sum(x['status'] == 'failed' for x in checks),
|
||||
'deferred': sum(x['status'] == 'deferred' for x in checks),
|
||||
'erc': {b.name:b.erc for b in boards}}
|
||||
(out / 'hardware-checks.json').write_text(json.dumps(report, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
print(f'Hardware contracts: {report["passed"]} passed, {report["failed"]} failed, {report["deferred"]} deferred ({report["scope"]})')
|
||||
print(f'Firmware source: {fw.parent}')
|
||||
for check_result in checks:
|
||||
if not check_result['pass']:
|
||||
print(check_result['status'].upper(), check_result['check'], check_result['detail'])
|
||||
for board in boards:
|
||||
print(board.name, 'ERC', board.erc)
|
||||
return 1 if report['failed'] else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Trace reviewed supply paths from fresh KiCad netlists; not an electrical rating check."""
|
||||
import json
|
||||
|
||||
from check_hardware import Board
|
||||
from schematic_maintenance import ROOT, children, parse, properties
|
||||
|
||||
OUT = ROOT / 'out/power-path-review'
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
boards = {n: Board(n) for n in (1, 2)}
|
||||
for b in boards.values():
|
||||
b.compile('D:/KiCad/bin/kicad-cli.exe', OUT)
|
||||
a, b = boards[1], boards[2]
|
||||
checks = []
|
||||
|
||||
def same(number, *endpoints, net=None):
|
||||
board = boards[number]
|
||||
names = {board.net(*p) for p in endpoints}
|
||||
assert len(names) == 1 and (net is None or names == {net}), (number, endpoints, names)
|
||||
checks.append({'board': number, 'endpoints': endpoints, 'net': names.pop()})
|
||||
|
||||
same(1, ('J1', 1), net='GND')
|
||||
same(1, ('J1', 2), ('F1', 1))
|
||||
same(1, ('F1', 2), ('Q1', 1), ('Q1', 2), ('Q1', 3))
|
||||
same(1, ('Q1', 5), ('Q1', 6), ('Q1', 7), ('Q1', 8), ('Rs6', 1))
|
||||
same(1, ('Rs6', 2), ('U19', 3), ('U30', 3), net='+12V')
|
||||
same(1, ('U19', 2), ('L2', 1))
|
||||
same(1, ('L2', 2), ('U20', 2), net='+3V3')
|
||||
same(1, ('U30', 2), ('L3', 1))
|
||||
same(1, ('L3', 2), ('J24', 8), net='+5V')
|
||||
for ch in range(1, 6):
|
||||
same(1, (f'U{ch}', 4), (f'U{ch}', 5), (f'U{ch}', 6), net='+12V')
|
||||
same(1, (f'U{ch}', 11), (f'U{ch}', 12), (f'L{ch}01', 1))
|
||||
same(1, (f'L{ch}01', 2), *[(f'U{13+ch}', p) for p in range(9, 14)], net=f'VO{ch}')
|
||||
same(2, (f'U{23+ch}', 1), ('U31', 3), net='+5V')
|
||||
# The mechanical contract swaps odd/even physical pin numbers across the mating pair.
|
||||
for lower, upper in [('J24', 'J31'), ('J25', 'J32')]:
|
||||
for pin in range(1, 41):
|
||||
mate = pin + 1 if pin % 2 else pin - 1
|
||||
net = a.net(lower, pin)
|
||||
assert net == b.net(upper, mate), (lower, pin, upper, mate)
|
||||
if net in ('GND', '+5V'):
|
||||
checks.append({'mating': [[1, lower, pin], [2, upper, mate]], 'net': net})
|
||||
expected = {1: {'GND', '+3V3', '+12V', *[f'VO{ch}' for ch in range(1, 6)]},
|
||||
2: {'GND', '+5V'}}
|
||||
reported = {}
|
||||
for number, board in boards.items():
|
||||
report = json.loads((OUT / f'Board{number}-erc.json').read_text())
|
||||
names = []
|
||||
for sheet in report['sheets']:
|
||||
for v in sheet['violations']:
|
||||
if v['type'] != 'power_pin_not_driven':
|
||||
continue
|
||||
description = v['items'][0]['description'].split()
|
||||
ref = description[1]
|
||||
if ref.startswith('#PWR'):
|
||||
path = board.path.parent / (sheet['path'].strip('/') + '.kicad_sch')
|
||||
symbol = next(s for s in children(parse(path.read_text(encoding='utf-8')), 'symbol')
|
||||
if properties(s)['Reference'][2] == ref)
|
||||
net = properties(symbol)['Value'][2]
|
||||
else:
|
||||
net = board.net(ref, description[3])
|
||||
names.append(net)
|
||||
assert set(names) == expected[number] and len(names) == len(expected[number])
|
||||
reported[number] = names
|
||||
result = {'status': 'PASS', 'scope': 'Netlist topology and coverage of all 10 power ERC reports',
|
||||
'power_erc_nets': reported, 'checks': checks,
|
||||
'limits': ['No PWR_FLAG added; ERC remains visible',
|
||||
'MOS gate operation, component ratings, startup and physical copper are not validated']}
|
||||
(OUT / 'verification.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(json.dumps({'status': 'PASS', 'checks': len(checks), 'power_erc_nets': reported}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Read the live EasyEDA component catalog through the installed API bridge.
|
||||
|
||||
Archive exact device-search and device-detail responses for fan selection.
|
||||
This does not edit the EasyEDA project or claim that catalog entries are stocked.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def bridge(session):
|
||||
for port in range(49620, 49630):
|
||||
base = f'http://127.0.0.1:{port}'
|
||||
try:
|
||||
response = session.get(base + '/health', timeout=.5)
|
||||
health = response.json()
|
||||
except (requests.RequestException, ValueError):
|
||||
continue
|
||||
if health.get('service') == 'easyeda-bridge':
|
||||
return base, health
|
||||
raise RuntimeError('Installed EasyEDA API bridge not reachable on 49620-49629')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='action', required=True)
|
||||
sub.add_parser('probe')
|
||||
search = sub.add_parser('search')
|
||||
search.add_argument('keywords', nargs='+')
|
||||
search.add_argument('--page', type=int, default=1)
|
||||
search.add_argument('--limit', type=int, default=100)
|
||||
search.add_argument('--category', nargs=2, metavar=('PRIMARY_UUID', 'SECONDARY_UUID'))
|
||||
detail = sub.add_parser('detail')
|
||||
detail.add_argument('uuid')
|
||||
detail.add_argument('--library')
|
||||
args = parser.parse_args()
|
||||
session = requests.Session()
|
||||
session.trust_env = False
|
||||
base, health = bridge(session)
|
||||
if args.action == 'probe':
|
||||
print(json.dumps(health, ensure_ascii=False, indent=2))
|
||||
return 0 if health.get('edaConnected') else 2
|
||||
if not health.get('edaConnected'):
|
||||
raise RuntimeError('Bridge is healthy but no EasyEDA API Gateway window is connected')
|
||||
# Reading the public catalog does not require selecting or editing a project.
|
||||
window = health.get('activeWindowId')
|
||||
if not window:
|
||||
raise RuntimeError('No active EasyEDA window; select a client window first')
|
||||
if args.action == 'search':
|
||||
assert 1 <= args.limit <= 100 and args.page >= 1
|
||||
category = json.dumps({'libraryUuid': '0819f05c4eef4c71ace90d822a990e87', 'libraryType': '3',
|
||||
'primaryClassificationUuid': args.category[0],
|
||||
'secondaryClassificationUuid': args.category[1]}) if args.category else 'undefined'
|
||||
queries = [(key, 'return await eda.lib_Device.search(' + json.dumps('' if key == '*' else key) +
|
||||
f', undefined, {category}, undefined, {args.limit}, {args.page});') for key in args.keywords]
|
||||
else:
|
||||
queries = [(args.uuid, 'return await eda.lib_Device.get(' + json.dumps(args.uuid) +
|
||||
', ' + (json.dumps(args.library) if args.library else 'undefined') + ');')]
|
||||
output = ROOT/'out/fan-catalog'
|
||||
output.mkdir(exist_ok=True)
|
||||
for query, code in queries:
|
||||
response = session.post(base+'/execute', json={'code': code, 'windowId': window}, timeout=40)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if result.get('success') is False or result.get('error'):
|
||||
raise RuntimeError(str(result))
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
record = {'retrieved_at_utc': timestamp.isoformat(), 'source': 'Live EasyEDA lib_Device API',
|
||||
'window_id': window, 'operation': args.action, 'query': query,
|
||||
'page': getattr(args, 'page', None), 'category': getattr(args, 'category', None), 'response': result}
|
||||
path = output/(timestamp.strftime('%Y%m%dT%H%M%S%f')+'-'+args.action+'.json')
|
||||
path.write_text(json.dumps(record, indent=2, ensure_ascii=False)+'\n', encoding='utf-8')
|
||||
print(path.relative_to(ROOT))
|
||||
data = result.get('result')
|
||||
if isinstance(data, list):
|
||||
print('Results:', len(data), flush=True)
|
||||
for part in data:
|
||||
print(json.dumps({k: part.get(k) for k in ['name', 'uuid', 'supplierId', 'description']}, ensure_ascii=False), flush=True)
|
||||
else:
|
||||
print(json.dumps(data, ensure_ascii=False), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (RuntimeError, requests.RequestException) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Export the reviewed FAN-R2 firmware changes without writing sibling firmware."""
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FILES = ('main/board_pins.h', 'main/rail_cfg.c', 'test/test_rail_cfg.c')
|
||||
|
||||
|
||||
def main():
|
||||
source = ROOT.parent / 'firmware'
|
||||
candidate = ROOT / 'out/firmware-fan-r2'
|
||||
output = ROOT / 'firmware-sync'
|
||||
output.mkdir(exist_ok=True)
|
||||
patch = []
|
||||
manifest = {'source': str(source), 'candidate': str(candidate), 'files': []}
|
||||
for name in FILES:
|
||||
before = (source / name).read_bytes()
|
||||
after = (candidate / name).read_bytes()
|
||||
if before == after:
|
||||
raise ValueError(f'Expected reviewed change missing: {name}')
|
||||
patch.extend(difflib.unified_diff(
|
||||
before.decode('utf-8').splitlines(keepends=True),
|
||||
after.decode('utf-8').splitlines(keepends=True),
|
||||
fromfile='a/firmware/' + name, tofile='b/firmware/' + name))
|
||||
manifest['files'].append({
|
||||
'path': name,
|
||||
'source_sha256': hashlib.sha256(before).hexdigest(),
|
||||
'candidate_sha256': hashlib.sha256(after).hexdigest(),
|
||||
})
|
||||
# Normalize line endings so git can apply the patch to the CRLF source tree.
|
||||
patch_text = ''.join(patch).replace('\r\n', '\n')
|
||||
with (output / 'fan-r2.patch').open('w', encoding='utf-8', newline='\n') as stream:
|
||||
stream.write(patch_text)
|
||||
(output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8')
|
||||
print(f'Exported {len(FILES)} files to {output / "fan-r2.patch"}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Reproducible FAN-R2 power estimates, not startup or protection certification."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def calculate(selection):
|
||||
fans_w = selection['quantity'] * selection['rated_at_pwm_100']['input_w']
|
||||
fans_a = selection['quantity'] * selection['rated_at_pwm_100']['current_a']
|
||||
rail_output_w = 5 * 4.01 * 4
|
||||
buck_efficiency = 0.92
|
||||
scenarios = []
|
||||
# Auxiliary currents are example operating cases, not guaranteed maxima.
|
||||
for name, aux_w in [('fans_and_rails_only', 0),
|
||||
('aux_5v_0p5a_3v3_0p5a', (5 * .5 + 3.3 * .5) / .9),
|
||||
('aux_5v_2p8a_3v3_0p5a', (5 * 2.8 + 3.3 * .5) / .9)]:
|
||||
power_w = rail_output_w / buck_efficiency + fans_w + aux_w
|
||||
for bus_v in (10.8, 12.0, 13.2):
|
||||
scenarios.append({
|
||||
'case': name, 'bus_v': bus_v, 'input_w_estimate': power_w,
|
||||
'input_a_estimate': power_w / bus_v,
|
||||
'headroom_to_10a_w': 10 * bus_v - power_w,
|
||||
'rail_output_budget_at_8a_w': max(0, (8 * bus_v - fans_w - aux_w) * buck_efficiency),
|
||||
})
|
||||
return {
|
||||
'mpn': selection['mpn'],
|
||||
'certified': False,
|
||||
'assumptions': {
|
||||
'rail_output_w': rail_output_w,
|
||||
'rail_conversion_efficiency': buck_efficiency,
|
||||
'aux_conversion_efficiency': .9,
|
||||
'adapter_current_limit_a': 10,
|
||||
'provisional_continuous_current_target_a': 8,
|
||||
'fan_input_model': 'Constant 12V-rated power for sensitivity only; voltage dependence is unknown',
|
||||
'exclusions': ['input-path loss', 'startup peak', 'locked-rotor retries',
|
||||
'maximum fan current', 'adapter temperature derating',
|
||||
'efficiency tolerance', 'fan switch/receiver losses'],
|
||||
},
|
||||
'fan_rated_power_w': fans_w,
|
||||
'fan_rated_current_a': fans_a,
|
||||
'q7_conduction_at_25c_60mohm': {
|
||||
'drop_v': fans_a * .06, 'loss_w': fans_a ** 2 * .06,
|
||||
'limitation': '25C on-state only; no startup SOA, hot Rds(on), or short-circuit guarantee',
|
||||
},
|
||||
'c738_nominal_energy_at_13p2v_j': .5 * 22e-6 * 13.2 ** 2,
|
||||
'c738_energy_limitation': 'Excludes both fan input capacitors and ceramic tolerance; does not bound peak current',
|
||||
'scenarios': scenarios,
|
||||
'thermal': {
|
||||
'total_heat_target_w': 40,
|
||||
'remaining_heat_budget_at_fan_rated_full_speed_w': 40 - fans_w,
|
||||
'rise_k_at_6cfm_40w': 40 / (1200 * .00047195 * 6),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
selection = json.loads((ROOT / 'fan-selection.json').read_text(encoding='utf-8'))
|
||||
report = calculate(selection)
|
||||
destination = ROOT / 'out/fan-power-budget.json'
|
||||
destination.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
|
||||
for row in report['scenarios']:
|
||||
print(f'{row["case"]}: {row["bus_v"]:.1f}V, '
|
||||
f'{row["input_w_estimate"]:.2f}W, {row["input_a_estimate"]:.3f}A, '
|
||||
f'10A headroom {row["headroom_to_10a_w"]:.2f}W')
|
||||
print(f'Conditional estimates only: {destination}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Connectivity and electrical assumptions for the four-wire fan prototype."""
|
||||
import itertools
|
||||
import hashlib
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from schematic_maintenance import ROOT, parse, children, child, properties
|
||||
|
||||
|
||||
def verify_fans(board, check):
|
||||
a = board
|
||||
selection = json.loads((ROOT/'fan-selection.json').read_text(encoding='utf-8'))
|
||||
|
||||
def exact(name, *pins):
|
||||
expected = {(r, str(p)) for r, p in pins}
|
||||
actual = a.net_members.get(a.pins.get(next(iter(expected)), ''), set())
|
||||
check('Fan ' + name, actual == expected, str(sorted(actual)))
|
||||
|
||||
for ref in ['J26', 'J27']:
|
||||
check(f'Fan {ref} pin 1 fixed GND', a.net(ref, 1) == 'GND')
|
||||
check(f'Fan {ref} four-position screw terminal',
|
||||
a.parts[ref]['Footprint'] == selection['connector']['footprint']
|
||||
and a.parts[ref]['MPN'] == selection['connector']['mpn']
|
||||
and a.parts[ref]['LCSC'] == selection['connector']['lcsc'])
|
||||
check(f'Fan {ref} selected external fan', a.parts[ref].get('Fan_MPN') == selection['mpn']
|
||||
and a.parts[ref].get('Fan_LCSC') == selection['lcsc']
|
||||
and a.parts[ref].get('Fan_Device_UUID') == selection['easyeda_device_uuid'])
|
||||
exact('switched power', ('Q7', 3), ('J26', 2), ('J27', 2), ('C738', 1), ('D2', 1), ('R744', 2), ('R748', 2))
|
||||
exact('PWM open drain', ('Q6', 3), ('J26', 4), ('J27', 4))
|
||||
exact('PWM gate', ('Q6', 1), ('R733', 2), ('R734', 2))
|
||||
exact('PWM GPIO', ('R733', 1), ('U20', 11))
|
||||
exact('enable GPIO', ('R742', 1), ('U20', 5))
|
||||
exact('enable gate', ('Q8', 1), ('R742', 2), ('R743', 2))
|
||||
exact('high-side gate clamp', ('Q7', 1), ('DZ3', 2), ('R740', 1), ('R741', 2))
|
||||
exact('high-side gate sink', ('Q8', 3), ('R741', 1))
|
||||
for ref, pin in [('Q7', 2), ('DZ3', 1), ('R740', 2)]:
|
||||
check(f'Fan {ref}.{pin} +12V', a.net(ref, pin) == '+12V')
|
||||
for ref, pin in [('Q6', 2), ('Q8', 2), ('Q9', 2), ('Q10', 2), ('D2', 2),
|
||||
('C738', 2), ('R734', 1), ('R743', 1), ('R746', 1), ('R750', 1)]:
|
||||
check(f'Fan {ref}.{pin} GND', a.net(ref, pin) == 'GND')
|
||||
check('Fan enable GPIO function', a.functions[('U20', '5')] in {'IO5', 'IO5_5'} and a.net('U20', 5) == 'FAN_EN')
|
||||
for ref, resistance in {'R733':1000, 'R734':100000, 'R740':100000,
|
||||
'R741':1000, 'R742':1000, 'R743':100000,
|
||||
**{f'R{n}':15000 if n in [745, 749] else 10000 for n in range(744, 752)}}.items():
|
||||
check(f'Fan {ref} resistance', a.resistor(ref) == resistance)
|
||||
check('Fan PMOS part', a.parts['Q7']['MPN'] == 'AO3401A')
|
||||
check('Fan gate clamp part', a.parts['DZ3']['MPN'] == 'BZT52C8V2-7-F')
|
||||
check('Fan bulk capacitance part', a.parts['C738']['MPN'] == 'CL31A226KAHNNNE')
|
||||
check('Fan rail clamp part', a.parts['D2']['MPN'] == 'SS34')
|
||||
for q in ['Q6', 'Q8', 'Q9', 'Q10']:
|
||||
check(f'Fan {q} NMOS part', a.parts[q]['MPN'] == 'AO3400A')
|
||||
for idx, (q, j, mcu) in enumerate([('Q9', 'J26', 9), ('Q10', 'J27', 4)]):
|
||||
rp, rs, rg, ro = [f'R{744 + idx*4 + n}' for n in range(4)]
|
||||
exact(j + ' TACH raw', (j, 3), (rp, 1), (rs, 1))
|
||||
exact(j + ' TACH gate divider', (rs, 2), (rg, 2), (q, 1))
|
||||
exact(j + ' TACH MCU output', (ro, 1), (q, 3), ('U20', mcu))
|
||||
check(f'Fan {j} MCU pullup +3V3', a.net(ro, 2) == '+3V3')
|
||||
# 0.6V is the vendor's family example, not exact-model certification.
|
||||
# High-state leakage <=1uA and a 0.2V supply-path drop are design assumptions.
|
||||
high, low, sink = [], [], []
|
||||
for supply, p, s, g in itertools.product([10.6, 13.2], *[[v*.99, v*1.01] for v in [a.resistor(rp), a.resistor(rs), a.resistor(rg)]]):
|
||||
high.append((supply - 1e-6*p)*g/(p+s+g))
|
||||
low.append(.6*g/(s+g))
|
||||
sink.append(supply/p)
|
||||
check(f'Fan {j} conditional gate high', min(high) > 2.5,
|
||||
f'{min(high):.4f}..{max(high):.4f}V; assumes TACH leakage <=1uA')
|
||||
check(f'Fan {j} conditional gate low', max(low) < .30,
|
||||
f'<= {max(low):.4f}V; family example TACH low <=0.6V; exact model and hot NMOS off-state still require verification')
|
||||
check(f'Fan {j} conditional TACH sink', max(sink) < .005,
|
||||
f'<= {1000*max(sink):.4f}mA; must fit vendor sink rating')
|
||||
for ref in ['Q6', 'Q7', 'Q8', 'Q9', 'Q10']:
|
||||
for p, function, kind in [('1', 'G', 'input'), ('2', 'S', 'passive'), ('3', 'D', 'passive')]:
|
||||
check(f'Fan {ref}.{p} pin identity', a.functions[(ref, p)] in {function, function+'_'+p} and a.pin_types[(ref, p)] == kind)
|
||||
|
||||
# Local footprint files are part of this project, including the two copied
|
||||
# standard footprints. Match every placed fan symbol to actual pad numbers.
|
||||
src = (ROOT/'PhoneRail-Board1/P6-FAN-EDL.kicad_sch').read_text(encoding='utf-8')
|
||||
root = parse(src)
|
||||
libs = {n[1]:n for n in children(child(root, 'lib_symbols'), 'symbol')}
|
||||
for sym in children(root, 'symbol'):
|
||||
p = properties(sym)
|
||||
ref = p['Reference'][2]
|
||||
lib, name = p['Footprint'][2].split(':')
|
||||
path = ROOT / f'lib/{lib}.pretty/{name}.kicad_mod'
|
||||
fp = parse(path.read_text(encoding='utf-8'))
|
||||
pads = {n[1] for n in children(fp, 'pad')}
|
||||
expected = {child(n, 'number')[1] for u in children(libs[child(sym, 'lib_id')[1]], 'symbol') for n in children(u, 'pin')}
|
||||
check(f'Fan {ref} footprint pad numbers', expected <= pads)
|
||||
|
||||
check('Fan archived model datasheet', hashlib.sha256((ROOT/selection['datasheet']).read_bytes()).hexdigest() == selection['datasheet_sha256'])
|
||||
check('Fan archived connector datasheet', hashlib.sha256((ROOT/selection['connector']['datasheet']).read_bytes()).hexdigest() == selection['connector']['datasheet_sha256'])
|
||||
device = json.loads((ROOT/selection['catalog_record']).read_text(encoding='utf-8'))['response']['result']
|
||||
check('Fan live EasyEDA selection provenance', device['property']['manufacturerId'] == selection['mpn']
|
||||
and device['property']['supplierId'] == selection['lcsc'])
|
||||
check('Fan documented voltage covers system bus', selection['operating_voltage_v'][0] <= 10.6
|
||||
and selection['operating_voltage_v'][1] >= 13.2,
|
||||
'Includes a provisional 0.2V low-side-of-range path-drop budget; actual drop needs validation')
|
||||
check('Fan documented PWM and stop behavior', selection['pwm_frequency_hz'] == 25000
|
||||
and selection['pwm_zero_stops'] is False and selection['rated_at_pwm_0']['rpm'] == 3300)
|
||||
connector_fp = parse((ROOT/'lib/PhoneRail-Fan.pretty'/
|
||||
(selection['connector']['footprint'].split(':')[1]+'.kicad_mod')).read_text(encoding='utf-8'))
|
||||
pads = {p[1]:p for p in children(connector_fp, 'pad')}
|
||||
check('Fan terminal pitch and drill match datasheet',
|
||||
all(abs(float(child(pads[str(n)], 'at')[1]) - 2.54*(n-1)) < .001
|
||||
and abs(float(child(pads[str(n)], 'drill')[1])-1.1) < .001 for n in range(1,5)))
|
||||
# The supplier's family examples explicitly say that specifications vary.
|
||||
check('Fan exact-model PWM TACH limits verified',
|
||||
selection['family_interface_examples']['applicability_to_exact_model_confirmed'],
|
||||
'Family PWM/TACH examples archived; exact-model limits, leakage and startup/locked-rotor current not published in model sheet')
|
||||
check('Fan assembly and startup protection verified', False,
|
||||
'Connector drawing checked; Q7 SOA/inrush, fuse coordination, power budget and installed harness/hot-plug remain open; see FAN_CIRCUIT.md')
|
||||
|
||||
|
||||
def verify_unrelated(check, out=ROOT/'out'):
|
||||
def groups(path, refs=(), pins=()):
|
||||
return {frozenset((p.get('ref'), p.get('pin')) for p in n.findall('node')
|
||||
if p.get('ref') not in refs and (p.get('ref'), p.get('pin')) not in pins)
|
||||
for n in ET.parse(path).findall('nets/net')} - {frozenset()}
|
||||
|
||||
old = ROOT/'out/before-four-wire'
|
||||
fan_refs = {'Q6','Q7','Q8','Q9','Q10','DZ3','D2','C738','J26','J27','R733','R734',
|
||||
*{f'R{n}' for n in range(740, 752)}}
|
||||
pins = {('U20', '4'), ('U20', '5'), ('U20', '9'), ('U20', '11')}
|
||||
check('Four-wire revision: unrelated Board1 networks preserved',
|
||||
groups(old/'Board1.xml', fan_refs, pins) == groups(out/'Board1.xml', fan_refs, pins))
|
||||
from hub_strap_checks import REFS
|
||||
check('Four-wire revision: Board2 networks preserved apart from verified Hub strap additions',
|
||||
groups(old/'Board2.xml') == groups(out/'Board2.xml', REFS))
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Apply the reviewed R1 annotation layout without changing electrical geometry."""
|
||||
from schematic_maintenance import ROOT, parse, child, children, properties
|
||||
from revise_four_wire_fans import uid, quote
|
||||
|
||||
|
||||
def apply_edits(path, source, edits):
|
||||
for start, end, replacement in sorted(edits, reverse=True):
|
||||
source = source[:start] + replacement + source[end:]
|
||||
path.write_text(source, encoding='utf-8', newline='\n')
|
||||
|
||||
|
||||
def main():
|
||||
path = ROOT/'PhoneRail-Board1/P6-FAN-EDL.kicad_sch'
|
||||
source = path.read_text(encoding='utf-8')
|
||||
root = parse(source)
|
||||
edits = []
|
||||
for sym in children(root, 'symbol'):
|
||||
x, y, angle = map(float, child(sym, 'at')[1:])
|
||||
if angle not in [90, 270]:
|
||||
continue
|
||||
for name, dy in [('Reference', -2.54), ('Value', 0)]:
|
||||
p = properties(sym)[name]
|
||||
edits.append((p.start, p.end,
|
||||
f'(property "{name}" {quote(p[2])} (at {x+(22.86 if properties(sym)["Reference"][2] in {"C738", "D2"} else 10.16):g} {y+dy:g} {360-angle:g}) '
|
||||
'(effects (font (size 1.27 1.27))))'))
|
||||
left_nets = {'FAN_EN', 'FAN_PWM', 'FAN_IN_TACH_RAW', 'FAN_OUT_TACH_RAW', 'FAN_12V_SW', 'FAN_PWM_OD', 'GND'}
|
||||
for n in children(root, 'label') + children(root, 'global_label'):
|
||||
x, y, _ = map(float, child(n, 'at')[1:])
|
||||
# Only the left-facing connector/input stubs; receiver labels stay put.
|
||||
if n[1] not in left_nets or not (x in [271.78, 365.76, 58.42]):
|
||||
continue
|
||||
raw = source[n.start:n.end].replace(f'(at {x:g} {y:g} 0)', f'(at {x:g} {y:g} 180)').replace('(justify left bottom)', '(justify right bottom)')
|
||||
edits.append((n.start, n.end, raw))
|
||||
for n in children(root, 'text'):
|
||||
if n[1].startswith('RAW high:'):
|
||||
at = child(n, 'at')
|
||||
edits.append((at.start, at.end, '(at 223.52 177.8 0)'))
|
||||
if n[1].startswith('Electrical limits, hot-plug/ESD'):
|
||||
edits.append((n.start, n.end, ''))
|
||||
apply_edits(path, source, edits)
|
||||
|
||||
path = ROOT/'PhoneRail-Board1/P3-CONTROL.kicad_sch'
|
||||
source = path.read_text(encoding='utf-8')
|
||||
root = parse(source)
|
||||
edits = [(n.start, n.end, '') for n in children(root, 'text')]
|
||||
notes = [
|
||||
(15.24, 15.24, 'U20 / ESP32-S3'),
|
||||
(15.24, 21.59, '3V3 / EN RC reset'),
|
||||
(15.24, 118.11, 'J22 / USB debug'),
|
||||
(165.1, 40.64, 'I2C 4.7k / open-drain pullups'),
|
||||
(165.1, 99.06, 'NTC: 10k B3380 / 10k / 100nF'),
|
||||
(12.7, 168.91, 'FAN: EN=GPIO5, PWM=GPIO18 (inverted), TACH_IN=GPIO16, TACH_OUT=GPIO4.'),
|
||||
(12.7, 175.26, 'IO4/IO5 assigned; IO7 spare. Other unconnected pins remain under review.'),
|
||||
(12.7, 181.61, 'IO39-42/47/48 assigned; historical spare/JTAG map is obsolete.'),
|
||||
(12.7, 187.96, 'USB debug requires main 12V. IO0/UART recovery test points are not yet fitted.'),
|
||||
(12.7, 194.31, 'IO35-37 reserved for R8 PSRAM. Strap pins: IO0/3/45/46. See FAN_CIRCUIT.md.'),
|
||||
]
|
||||
text = '\n'.join(f'(text {quote(t)} (at {x:g} {y:g} 0) (effects (font (size 1.27 1.27)) (justify left bottom)) (uuid "{uid(t)}"))' for x, y, t in notes)
|
||||
edits.append((root.end-1, root.end-1, text+'\n'))
|
||||
apply_edits(path, source, edits)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Rotate J1 by 180 degrees and place its mating face at the left board edge."""
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
import route_local_r1 as base
|
||||
from schematic_maintenance import ROOT
|
||||
|
||||
OUT = ROOT / 'out/input-connector-r1'
|
||||
|
||||
|
||||
def snapshot(board):
|
||||
return {f.GetReference(): (f.GetPosition().x, f.GetPosition().y, f.GetOrientationDegrees(),
|
||||
f.GetLayer(), [(p.GetNumber(), p.GetPosition().x, p.GetPosition().y,
|
||||
p.GetNetname()) for p in f.Pads()])
|
||||
for f in board.GetFootprints()}
|
||||
|
||||
|
||||
def copper(board):
|
||||
return {t.m_Uuid.AsString(): (t.GetStart().x, t.GetStart().y, t.GetEnd().x, t.GetEnd().y,
|
||||
t.GetLayer(), t.GetWidth(), t.GetNetname()) for t in board.GetTracks()}
|
||||
|
||||
|
||||
def main():
|
||||
assert not (OUT / 'verification.json').exists(), 'Completed checkpoint exists'
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
base.OUT = OUT
|
||||
path = base.BOARD
|
||||
start_hash = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
backup = OUT / ('before-' + path.name)
|
||||
if backup.exists():
|
||||
assert hashlib.sha256(backup.read_bytes()).hexdigest() == start_hash, 'Source changed since failed attempt'
|
||||
else:
|
||||
shutil.copy2(path, backup)
|
||||
board = pcb.LoadBoard(str(path))
|
||||
before, tracks = snapshot(board), copper(board)
|
||||
initial = base.unconnected_count(board)
|
||||
part = next(f for f in board.GetFootprints() if f.GetReference() == 'J1')
|
||||
assert part.GetOrientationDegrees() == 90
|
||||
assert not any(t.GetNetname() in {p.GetNetname() for p in part.Pads()} for t in board.GetTracks()), 'Review existing input copper first'
|
||||
part.SetOrientationDegrees(270)
|
||||
part.SetPosition(pcb.VECTOR2I(pcb.FromMM(58.4), pcb.FromMM(67)))
|
||||
part.Reference().SetTextAngle(pcb.EDA_ANGLE(0, pcb.DEGREES_T))
|
||||
part.Reference().SetPosition(pcb.VECTOR2I(pcb.FromMM(58.4), pcb.FromMM(59)))
|
||||
candidate = path.with_name('PhoneRail-Board1-input-candidate.kicad_pcb')
|
||||
temporary = [candidate.with_suffix(s) for s in ('.kicad_pcb', '.kicad_pro', '.kicad_dru', '.kicad_prl')]
|
||||
assert all(not p.exists() for p in temporary)
|
||||
try:
|
||||
for suffix in ('.kicad_pro', '.kicad_dru'):
|
||||
shutil.copy2(path.with_suffix(suffix), candidate.with_suffix(suffix))
|
||||
pcb.SaveBoard(str(candidate), board)
|
||||
report = base.drc(candidate, 'drc')
|
||||
assert not report['violations'], report['violations']
|
||||
reloaded = pcb.LoadBoard(str(candidate))
|
||||
after = snapshot(reloaded)
|
||||
assert all(value == after[ref] for ref, value in before.items() if ref != 'J1')
|
||||
assert copper(reloaded) == tracks
|
||||
assert base.unconnected_count(reloaded) == initial
|
||||
assert sorted((p[0], p[3]) for p in before['J1'][4]) == sorted((p[0], p[3]) for p in after['J1'][4])
|
||||
assert hashlib.sha256(path.read_bytes()).hexdigest() == start_hash
|
||||
shutil.copy2(candidate, path)
|
||||
result = {'status': 'PLACEMENT_CORRECTED_NOT_RELEASED', 'reference': 'J1',
|
||||
'before_position_mm': [63, 67], 'after_position_mm': [58.4, 67],
|
||||
'before_angle_deg': 90, 'after_angle_deg': 270,
|
||||
'mating_face': 'Left/outward; nominal outline x=50.25041mm, board edge x=50mm',
|
||||
'native_drc_violations': 0, 'other_footprints_preserved': len(before)-1,
|
||||
'existing_track_segments_preserved': len(tracks), 'unconnected_items': initial,
|
||||
'board_sha256': hashlib.sha256(path.read_bytes()).hexdigest()}
|
||||
(OUT / 'verification.json').write_text(json.dumps(result, indent=2)+'\n', encoding='utf-8')
|
||||
print(json.dumps(result))
|
||||
finally:
|
||||
for p in temporary:
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Electrical contracts for the ten explicit USB2517 strap pulldowns."""
|
||||
STRAPS = dict(zip(range(900, 910), (51, 49, 47, 33, 31, 17, 15, 50, 48, 34)))
|
||||
REFS = {f'R{n}' for n in STRAPS}
|
||||
|
||||
|
||||
def verify_hub_straps(board, check):
|
||||
for refnum, pin in STRAPS.items():
|
||||
ref = f'R{refnum}'
|
||||
check(f'Hub strap {ref} approved 100k part',
|
||||
board.parts[ref]['MPN'] == '0402WGF1003TCE'
|
||||
and board.parts[ref]['LCSC'] == 'C25741' and board.resistor(ref) == 100000)
|
||||
check(f'Hub strap U32.{pin} dedicated pull-down',
|
||||
board.net_members[board.net('U32', pin)] == {('U32', str(pin)), (ref, '2')}
|
||||
and board.net(ref, 1) == 'GND')
|
||||
for pin, ref in ((41, 'R711'), (42, 'R712')):
|
||||
check(f'Hub configuration select {pin} low',
|
||||
board.resistor(ref) == 10000 and
|
||||
{board.net(ref, 1), board.net(ref, 2)} == {'GND', board.net('U32', pin)})
|
||||
check('Hub configuration select 13 low', board.net('U32', 13) == 'GND')
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Check registered sense copper against native physical connectivity."""
|
||||
import pcbnew as pcb
|
||||
|
||||
|
||||
def check_measurement_branches(board, branches):
|
||||
if not branches:
|
||||
return {'branches': [], 'errors': []}
|
||||
board.BuildConnectivity()
|
||||
connection = board.GetConnectivity()
|
||||
tracks = {t.m_Uuid.AsString(): t for t in board.GetTracks()}
|
||||
pads = {(f.GetReference(), p.GetNumber()): p for f in board.GetFootprints() for p in f.Pads()}
|
||||
results, errors = [], []
|
||||
for branch in branches:
|
||||
name = branch['connection']
|
||||
ids = set(branch['track_uuids'])
|
||||
source_key, target_key = tuple(branch['from']), tuple(branch['to'])
|
||||
source, target = pads.get(source_key), pads.get(target_key)
|
||||
issues = []
|
||||
if source is None or target is None or not ids <= tracks.keys():
|
||||
errors.append({'kind': 'kelvin_missing_items', 'connection': name})
|
||||
continue
|
||||
if source.GetNetname() != target.GetNetname() or source.GetNetname() != branch['net']:
|
||||
issues.append('net_changed')
|
||||
if source_key[1] not in ('9', '10') or not target_key[0].startswith('Rs'):
|
||||
issues.append('unexpected_measurement_endpoints')
|
||||
seen = set()
|
||||
pending = {t.m_Uuid.AsString() for t in connection.GetConnectedTracks(source)} & ids
|
||||
while pending:
|
||||
uid = pending.pop()
|
||||
if uid in seen:
|
||||
continue
|
||||
seen.add(uid)
|
||||
pending.update({t.m_Uuid.AsString() for t in connection.GetConnectedTracks(tracks[uid])} & ids - seen)
|
||||
if seen != ids:
|
||||
issues.append('disconnected_registered_segments')
|
||||
touched = set()
|
||||
external = set()
|
||||
for uid in ids:
|
||||
track = tracks[uid]
|
||||
if isinstance(track, pcb.PCB_VIA) or track.GetLayer() != pcb.F_Cu:
|
||||
issues.append('measurement_changed_layer')
|
||||
if track.GetWidth() != pcb.FromMM(branch['width_mm']) or track.GetNetname() != branch['net']:
|
||||
issues.append('measurement_geometry_or_net_changed')
|
||||
touched.update((p.GetParentFootprint().GetReference(), p.GetNumber()) for p in connection.GetConnectedPads(track))
|
||||
for other in connection.GetConnectedTracks(track):
|
||||
other_id = other.m_Uuid.AsString()
|
||||
if other_id in ids:
|
||||
continue
|
||||
# Future load copper may join at the shunt land, never on the sense run.
|
||||
shared = [q for q in (track.GetStart(), track.GetEnd())
|
||||
if q == other.GetStart() or q == other.GetEnd()]
|
||||
if not shared or not all(target.HitTest(q) for q in shared):
|
||||
external.add(other_id)
|
||||
if touched != {source_key, target_key}:
|
||||
issues.append('unexpected_or_missing_pad_contact')
|
||||
if external:
|
||||
issues.append('branch_join_outside_shunt_land')
|
||||
length = sum(pcb.ToMM(tracks[uid].GetLength()) for uid in ids)
|
||||
if abs(length - branch['length_mm']) > .001:
|
||||
issues.append('measurement_length_changed_requires_review')
|
||||
result = {'connection': name, 'from': source_key, 'to': target_key,
|
||||
'physically_touched_pads': sorted(touched), 'length_mm': round(length, 5),
|
||||
'external_track_joins': sorted(external), 'passed': not issues}
|
||||
results.append(result)
|
||||
if issues:
|
||||
errors.append({'kind': 'kelvin_branch', 'connection': name, 'issues': sorted(set(issues))})
|
||||
return {'branches': results, 'errors': errors,
|
||||
'limits': 'Zones and final shunt load-current entry geometry require review after power copper is added'}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Reviewed TI pin tables and ERC modeling decisions; see IC_PIN_REVIEW_R2.md."""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from schematic_maintenance import ROOT
|
||||
|
||||
|
||||
def numbered(entries):
|
||||
return {str(i): pair for i, pair in enumerate(entries, 1)}
|
||||
|
||||
|
||||
TABLES = {
|
||||
'TPS54622RHLR': numbered([
|
||||
('RT/CLK', 'input'), ('GND', 'power_in'), ('GND', 'power_in'),
|
||||
('PVIN', 'power_in'), ('PVIN', 'power_in'), ('VIN', 'power_in'),
|
||||
('VSENSE', 'input'), ('COMP', 'output'), ('SS/TR', 'bidirectional'),
|
||||
('EN', 'input'), ('PH', 'power_out'), ('PH', 'passive'),
|
||||
('BOOT', 'passive'), ('PWRGD', 'open_collector'), ('EXP', 'power_in'),
|
||||
]),
|
||||
'TPS25940ARVCR': numbered([
|
||||
('DEVSLP', 'input'), ('PGOOD', 'open_collector'), ('PGTH', 'input'),
|
||||
('OUT', 'power_out'), *[('OUT', 'passive')] * 4,
|
||||
*[('IN', 'power_in')] * 5, ('EN', 'input'), ('OVP', 'input'),
|
||||
('GND', 'power_in'), ('ILIM', 'passive'), ('dVdT', 'passive'),
|
||||
('IMON', 'output'), ('FLT#', 'open_collector'), ('EP', 'power_in'),
|
||||
]),
|
||||
'DAC63204RTER': numbered([
|
||||
('FB3', 'input'), ('OUT3', 'output'), ('OUT2', 'output'), ('FB2', 'input'),
|
||||
('GPIO/SDO', 'bidirectional'), ('SCL/SYNC#', 'bidirectional'),
|
||||
('A0/SDI', 'input'), ('SDA/SCLK', 'bidirectional'), ('FB1', 'input'),
|
||||
('OUT1', 'output'), ('OUT0', 'output'), ('FB0', 'input'),
|
||||
('CAP', 'power_out'), ('AGND', 'power_in'), ('VDD', 'power_in'),
|
||||
('VREF', 'input'), ('EP', 'power_in'),
|
||||
]),
|
||||
'TPS22918DBVR': numbered([
|
||||
('VIN', 'power_in'), ('GND', 'power_in'), ('ON', 'input'),
|
||||
('CT', 'output'), ('QOD', 'passive'), ('VOUT', 'power_out'),
|
||||
]),
|
||||
'TS5A3157DBVR': numbered([
|
||||
('NO', 'passive'), ('GND', 'power_in'), ('NC', 'passive'),
|
||||
('COM', 'passive'), ('V+', 'power_in'), ('IN', 'input'),
|
||||
]),
|
||||
}
|
||||
TABLES['TPS22918DBVR_MIRRORED'] = TABLES['TPS22918DBVR']
|
||||
SOURCES = {
|
||||
'TPS54622RHLR': ('TPS54622', 4, 'ca33ca93ec3849011f07573bf5b6c91dbb92f5f33e53977b5849e97d279bbcfd'),
|
||||
'TPS25940ARVCR': ('TPS25940', 3, '9db4dc76b1040251f56d994041d2e6f02f1307d687aecbc5218f137a5c830adc'),
|
||||
'DAC63204RTER': ('DAC63204', 3, '83e228e2cbd5f0b60b00bce6e7c3c9461e8430249a5d7ce7ef2ccddb0f30655b'),
|
||||
'TPS22918DBVR': ('TPS22918', 3, '1b28eb58003d28b18bf919a15734056f296a499bebee2bd493a1e4358c4dfb1c'),
|
||||
'TS5A3157DBVR': ('TS5A3157', 3, '29d243a798f52820fce6c11e42bcc07aebf939ff8a75aaf6be523af714b512a0'),
|
||||
}
|
||||
SOURCES['TPS22918DBVR_MIRRORED'] = SOURCES['TPS22918DBVR']
|
||||
|
||||
HUB_TABLES = {'USB2517I-JZX': numbered([
|
||||
('USBDN1_DM/PRT_DIS_M1', 'bidirectional'), ('USBDN1_DP/PRT_DIS_P1', 'bidirectional'),
|
||||
('USBDN2_DM/PRT_DIS_M2', 'bidirectional'), ('USBDN2_DP/PRT_DIS_P2', 'bidirectional'),
|
||||
('VDDA33', 'power_in'),
|
||||
('USBDN3_DM/PRT_DIS_M3', 'bidirectional'), ('USBDN3_DP/PRT_DIS_P3', 'bidirectional'),
|
||||
('USBDN4_DM/PRT_DIS_M4', 'bidirectional'), ('USBDN4_DP/PRT_DIS_P4', 'bidirectional'),
|
||||
('VDDA33', 'power_in'),
|
||||
('USBDN5_DM/PRT_DIS_M5', 'bidirectional'), ('USBDN5_DP/PRT_DIS_P5', 'bidirectional'),
|
||||
('CFG_SEL2', 'input'), ('LED_B7_N', 'bidirectional'), ('LED_A7_N/PRT_SWP7', 'bidirectional'),
|
||||
('LED_B6_N', 'bidirectional'), ('LED_A6_N/PRT_SWP6', 'bidirectional'),
|
||||
('LED_B5_N', 'bidirectional'), ('TEST', 'input'), ('PRTPWR4', 'output'),
|
||||
('OCS4_N', 'input'), ('OCS3_N', 'input'), ('PRTPWR3', 'output'),
|
||||
('VDD33CR', 'power_in'), ('VDD18', 'power_out'), ('PRTPWR2', 'output'),
|
||||
('OCS2_N', 'input'), ('OCS1_N', 'input'), ('PRTPWR1', 'output'), ('PRTPWR5', 'output'),
|
||||
('LED_A5_N/PRT_SWP5', 'bidirectional'), ('LED_B4_N', 'bidirectional'),
|
||||
('LED_A4_N/PRT_SWP4', 'bidirectional'), ('LED_B3_N/GANG_EN', 'bidirectional'),
|
||||
('OCS5_N', 'input'), ('PRTPWR7', 'output'), ('OCS7_N', 'input'), ('OCS6_N', 'input'),
|
||||
('PRTPWR6', 'output'), ('SDA/SMBDATA/NON_REM1', 'bidirectional'),
|
||||
('SCL/SMBCLK/CFG_SEL0', 'bidirectional'), ('HS_IND/CFG_SEL1', 'bidirectional'),
|
||||
('RESET_N', 'input'), ('VBUS_DET', 'bidirectional'),
|
||||
('SUSP_IND/LOCAL_PWR/NON_REM0', 'bidirectional'), ('VDD33', 'power_in'),
|
||||
('LED_A3_N/PRT_SWP3', 'bidirectional'), ('LED_B2_N/BOOST1', 'bidirectional'),
|
||||
('LED_A2_N/PRT_SWP2', 'bidirectional'), ('LED_B1_N/BOOST0', 'bidirectional'),
|
||||
('LED_A1_N/PRT_SWP1', 'bidirectional'), ('VDDA33', 'power_in'),
|
||||
('USBDN6_DM/PRT_DIS_M6', 'bidirectional'), ('USBDN6_DP/PRT_DIS_P6', 'bidirectional'),
|
||||
('USBDN7_DM/PRT_DIS_M7', 'bidirectional'), ('USBDN7_DP/PRT_DIS_P7', 'bidirectional'),
|
||||
('VDDA33', 'power_in'), ('USBUP_DM', 'bidirectional'), ('USBUP_DP', 'bidirectional'),
|
||||
('XTAL2', 'output'), ('XTAL1/CLKIN', 'input'), ('VDD18PLL', 'power_out'),
|
||||
('RBIAS', 'passive'), ('VDD33PLL', 'power_in'), ('VSS', 'power_in'),
|
||||
])}
|
||||
HUB_SOURCES = {'USB2517I-JZX': ('USB2517', '7, 10-16', 'fd9caf7754e5656052e73435efc339dfd459f65f1e77439ed2dfc74a14177b75')}
|
||||
|
||||
DISCRETE_TABLES = {
|
||||
'2N7002': numbered([('G', 'input'), ('S', 'passive'), ('D', 'passive')]),
|
||||
'AO3400A': numbered([('G', 'input'), ('S', 'passive'), ('D', 'passive')]),
|
||||
'AO4407': numbered([*[('S', 'passive')] * 3, ('G', 'input'), *[('D', 'passive')] * 4]),
|
||||
'AMS1117-3': numbered([('GND', 'power_in'), ('VOUT', 'power_out'), ('VIN', 'power_in'), ('VOUT', 'passive')]),
|
||||
'TL431_C2887585': numbered([('REF', 'input'), ('CATHODE', 'passive'), ('ANODE', 'passive')]),
|
||||
}
|
||||
DISCRETE_SOURCES = {
|
||||
'2N7002': ('2N7002-CJ', 1, '7941fb423af7c6c6c8979063a7e8819bb19217ece275efcce18948950a41d9f6'),
|
||||
'AO3400A': ('../fans/AO3400A', 1, '9c60d0b6c1ddc7609a4b788a91181468d8ffe6c3e9ba425c3d8ffc5ba88c5033'),
|
||||
'AO4407': ('AO4407-KEXIN', 1, '1d0a7da91cb84b4aeef53e375dbbb4bd7f805cf93ba58579582b5b39f0edee1e'),
|
||||
'AMS1117-3': ('AMS1117', 1, '189a2651878a87d590b768eaa9b44217a3fdf460352ce6ecaff127221282a3f0'),
|
||||
'TL431_C2887585': ('TL431-YFSEMI', 1, 'bf2b255bd231944cc92b425ac58dc18b387324b44fe7a7a77bc7125d397f43d2'),
|
||||
}
|
||||
|
||||
|
||||
def references(hub=False, discrete=False):
|
||||
result = {}
|
||||
tables, sources = (HUB_TABLES, HUB_SOURCES) if hub else (TABLES, SOURCES)
|
||||
if discrete:
|
||||
tables, sources = DISCRETE_TABLES, DISCRETE_SOURCES
|
||||
for name, (part, page, expected) in sources.items():
|
||||
path = (ROOT / 'datasheets/ic' / (part + '.pdf')).resolve()
|
||||
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if actual != expected:
|
||||
raise ValueError(f'Reviewed datasheet changed: {path}')
|
||||
meta = json.loads(path.with_suffix('.json').read_text(encoding='utf-8'))
|
||||
result[name] = {'pdf': str(path.relative_to(ROOT)), 'page': page,
|
||||
'sha256': actual, 'url': meta['pdf_url'], 'pins': tables[name]}
|
||||
return result
|
||||
|
||||
|
||||
def check_common_pins(board):
|
||||
checks = []
|
||||
for ref, props in board.parts.items():
|
||||
if props.get('MPN') == 'TPS54622RHLR':
|
||||
groups = [('11', '12'), ('2', '3', '15'), ('4', '5')]
|
||||
elif props.get('MPN') == 'TPS25940ARVCR':
|
||||
groups = [('4', '5', '6', '7', '8'), ('9', '10', '11', '12', '13'), ('16', '21')]
|
||||
elif props.get('MPN') == 'DAC63204RTER':
|
||||
groups = [('14', '17')]
|
||||
elif props.get('MPN') == 'AO4407':
|
||||
groups = [('1', '2', '3'), ('5', '6', '7', '8')]
|
||||
elif props.get('MPN') == 'AMS1117-3.3':
|
||||
groups = [('2', '4')]
|
||||
else:
|
||||
continue
|
||||
for group in groups:
|
||||
nets = [board.net(ref, pin) for pin in group]
|
||||
good = len(set(nets)) == 1 and not nets[0].startswith('unconnected-')
|
||||
checks.append({'reference': ref, 'pins': group, 'nets': nets, 'pass': good})
|
||||
if not good:
|
||||
raise ValueError(f'{ref}: internally common pins split across nets: {group}')
|
||||
return checks
|
||||
|
||||
|
||||
def check_hub_supply(board):
|
||||
if board.number != 2:
|
||||
return []
|
||||
checks = []
|
||||
|
||||
def record(label, good):
|
||||
checks.append({'check': label, 'pass': bool(good)})
|
||||
if not good:
|
||||
raise ValueError(label)
|
||||
|
||||
for pin in (5, 10, 24, 46, 52, 57, 64):
|
||||
record(f'Hub 3V3 supply pin {pin}', board.net('U32', pin) == '+3V3_HUB')
|
||||
record('Hub exposed ground', board.net('U32', 65) == 'GND')
|
||||
for pin, ref in ((25, 'C742'), (62, 'C743')):
|
||||
expected = {('U32', str(pin)), (ref, '1')}
|
||||
record(f'Hub internal regulator {pin} only feeds its bypass',
|
||||
board.net_members[board.net('U32', pin)] == expected)
|
||||
record(f'Hub bypass {ref} ground', board.net(ref, 2) == 'GND')
|
||||
record(f'Hub bypass {ref} selected 1uF part', board.parts[ref]['MPN'] == 'CL05A105KA5NQNC')
|
||||
record('Hub internal regulators are separate', board.net('U32', 25) != board.net('U32', 62))
|
||||
record('Hub TEST grounded', board.net('U32', 19) == 'GND')
|
||||
return checks
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Create R2 functional placement from schematic connectivity and mechanical anchors.
|
||||
|
||||
Run with KiCad Python. The source snapshot is the unrouted native KiCad board;
|
||||
no coordinates, copper, or routes are read from the legacy layout.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
from bootstrap_pcb import ROOT, CONFIG, xy, keepout, label, pth_apertures
|
||||
from schematic_maintenance import parse, children, child
|
||||
|
||||
OUT = ROOT / 'out/pcb-layout-r2'
|
||||
REVISION = 'R2-FUNCTIONAL-PLACEMENT'
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def box_mm(item):
|
||||
b = item.GetBoundingBox(False, False) if isinstance(item, pcb.FOOTPRINT) else item.GetBoundingBox()
|
||||
return tuple(pcb.ToMM(v) for v in (b.GetLeft(), b.GetTop(), b.GetRight(), b.GetBottom()))
|
||||
|
||||
|
||||
def expand(box, d):
|
||||
a, b, c, e = box
|
||||
return a-d, b-d, c+d, e+d
|
||||
|
||||
|
||||
def intersects(a, b):
|
||||
return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
|
||||
|
||||
|
||||
def point(item):
|
||||
q = item.GetPosition()
|
||||
return pcb.ToMM(q.x), pcb.ToMM(q.y)
|
||||
|
||||
|
||||
class Layout:
|
||||
def __init__(self, number):
|
||||
self.number = number
|
||||
self.name = f'PhoneRail-Board{number}'
|
||||
self.path = ROOT / self.name / (self.name + '.kicad_pcb')
|
||||
text = (OUT / 'before' / self.path.name).read_text(encoding='utf-8')
|
||||
edits = [(node.start,node.end) for node in children(parse(text),'gr_text')
|
||||
if float(child(node,'at')[1]) > 270 or 'UNROUTED' in node[1]]
|
||||
for start,end in sorted(edits,reverse=True):
|
||||
text = text[:start]+text[end:]
|
||||
source = OUT / ('input-'+self.path.name)
|
||||
source.write_text(text,encoding='utf-8')
|
||||
self.board = pcb.LoadBoard(str(source))
|
||||
assert not list(self.board.GetTracks()), 'This placement tool requires an unrouted source'
|
||||
self.parts = {f.GetReference(): f for f in self.board.GetFootprints()}
|
||||
self.done = {r for r, f in self.parts.items() if f.IsLocked()}
|
||||
self.obstacles = []
|
||||
self.placements = {}
|
||||
for r in self.done:
|
||||
f = self.parts[r]
|
||||
both = any(p.GetAttribute() in (pcb.PAD_ATTRIB_PTH, pcb.PAD_ATTRIB_NPTH) for p in f.Pads())
|
||||
self.obstacles.append((r, expand(box_mm(f), .35), -1 if both else f.GetLayer()))
|
||||
for z in self.board.Zones():
|
||||
if z.GetIsRuleArea() and z.GetDoNotAllowFootprints():
|
||||
self.obstacles.append((z.GetZoneName(), expand(box_mm(z), .25),
|
||||
-1 if z.GetLayerSet().Contains(pcb.F_Cu) else pcb.B_Cu))
|
||||
|
||||
def reserve(self, name, rect, layers, copper=False, footprints=True):
|
||||
keepout(self.board, name, rect, layers, copper)
|
||||
z = list(self.board.Zones())[-1]
|
||||
z.SetDoNotAllowFootprints(footprints)
|
||||
if footprints:
|
||||
self.obstacles.append((name, rect, -1 if len(layers) > 1 else layers[0]))
|
||||
|
||||
def put(self, ref, target, rotation=0, radius=8, step=.5, bottom=None, reason='functional placement'):
|
||||
assert ref not in self.done, (ref, 'already placed')
|
||||
f = self.parts[ref]
|
||||
bottom = self.number == 2 if bottom is None else bottom
|
||||
if f.IsFlipped() != bottom:
|
||||
f.Flip(f.GetPosition(), pcb.FLIP_DIRECTION_TOP_BOTTOM)
|
||||
rotations = rotation if isinstance(rotation, tuple) else (rotation,)
|
||||
layer = pcb.B_Cu if bottom else pcb.F_Cu
|
||||
candidates = [(0, 0)]
|
||||
for k in range(1, int(radius / step)+1):
|
||||
candidates.extend((dx*step, dy*step) for dx in range(-k, k+1) for dy in (-k, k))
|
||||
candidates.extend((dx*step, dy*step) for dy in range(-k+1, k) for dx in (-k, k))
|
||||
candidates.sort(key=lambda q: q[0]**2+q[1]**2)
|
||||
winner = None
|
||||
for dx, dy in candidates:
|
||||
for rot in rotations:
|
||||
f.SetPosition(xy(target[0]+dx, target[1]+dy))
|
||||
f.SetOrientationDegrees(rot)
|
||||
b = box_mm(f)
|
||||
if b[0] < 51 or b[1] < 51 or b[2] > (271 if ref == 'J22' else 269) or b[3] > 184:
|
||||
continue
|
||||
if any((ol == -1 or ol == layer) and intersects(b, ob) for _, ob, ol in self.obstacles):
|
||||
continue
|
||||
winner = (b, rot)
|
||||
break
|
||||
if winner:
|
||||
break
|
||||
if winner is None:
|
||||
raise RuntimeError(f'No legal location for {ref} near {target}')
|
||||
b, rot = winner
|
||||
f.SetLocked(False)
|
||||
f.SetField('Placement', REVISION)
|
||||
f.SetField('LayoutIntent', reason)
|
||||
for field in f.GetFields():
|
||||
if field.GetName() not in {'Reference', 'Value'}:
|
||||
field.SetVisible(False)
|
||||
f.Value().SetVisible(False)
|
||||
f.Reference().SetLayer(pcb.B_SilkS if bottom else pcb.F_SilkS)
|
||||
f.Reference().SetMirrored(bottom)
|
||||
f.Reference().SetTextAngle(pcb.EDA_ANGLE(0, pcb.DEGREES_T))
|
||||
f.Reference().SetTextSize(xy(.8, .8))
|
||||
f.Reference().SetTextThickness(pcb.FromMM(.12))
|
||||
f.Reference().SetPosition(xy((b[0]+b[2])/2, b[1]-.7))
|
||||
self.done.add(ref)
|
||||
both = any(p.GetAttribute() == pcb.PAD_ATTRIB_PTH for p in f.Pads())
|
||||
self.obstacles.append((ref, expand(b, .45), -1 if both else layer))
|
||||
self.placements[ref] = {'xy_mm': point(f), 'rotation': rot, 'layer': self.board.GetLayerName(layer),
|
||||
'target_displacement_mm': round(math.hypot(dx,dy),3), 'intent': reason}
|
||||
return f
|
||||
|
||||
def near(self, ref, anchor, pin=None, offset=(0,0), **kwargs):
|
||||
f = self.parts[anchor]
|
||||
obj = next(p for p in f.Pads() if p.GetNumber() == str(pin)) if pin else f
|
||||
x, y = point(obj)
|
||||
return self.put(ref, (x+offset[0], y+offset[1]), rotation=(0,90,180,270), **kwargs)
|
||||
|
||||
def finish(self):
|
||||
missing = set(self.parts) - self.done
|
||||
assert not missing, sorted(missing)
|
||||
self.board.GetTitleBlock().SetRevision(REVISION)
|
||||
self.board.GetTitleBlock().SetTitle(f'PhoneRail Board {self.number} - functional placement / unrouted')
|
||||
label(self.board, f'BOARD {self.number} R2 - PLACED / UNROUTED', 160, 46, 1.5)
|
||||
label(self.board, 'F.Cu: horizontal | B.Cu: vertical | local escapes require review', 160, 189, 1)
|
||||
place_silkscreen(self.board)
|
||||
candidate = OUT / self.path.name
|
||||
pcb.SaveBoard(str(candidate), self.board)
|
||||
candidate.write_text(pth_apertures(candidate.read_text(encoding='utf-8')), encoding='utf-8')
|
||||
return self.board
|
||||
|
||||
|
||||
def place_silkscreen(board, refs=None):
|
||||
# Fit references in actual free board space without suppressing DRC severities.
|
||||
occupied = defaultdict(list)
|
||||
for f in board.GetFootprints():
|
||||
for p in f.Pads():
|
||||
for copper, silk in ((pcb.F_Cu,pcb.F_SilkS),(pcb.B_Cu,pcb.B_SilkS)):
|
||||
if p.GetLayerSet().Contains(copper):
|
||||
occupied[silk].append(expand(box_mm(p), .2))
|
||||
for shape in f.GraphicalItems():
|
||||
if shape.GetLayer() in (pcb.F_SilkS, pcb.B_SilkS):
|
||||
occupied[shape.GetLayer()].append(expand(box_mm(shape), .12))
|
||||
if refs is not None and f.GetReference() not in refs and f.Reference().IsVisible():
|
||||
occupied[f.Reference().GetLayer()].append(expand(box_mm(f.Reference()), .12))
|
||||
for f in sorted(board.GetFootprints(), key=lambda x:x.GetReference()):
|
||||
t = f.Reference()
|
||||
if not t.IsVisible() or (refs is not None and f.GetReference() not in refs):
|
||||
continue
|
||||
layer = t.GetLayer()
|
||||
b = box_mm(f)
|
||||
cx, cy = (b[0]+b[2])/2, (b[1]+b[3])/2
|
||||
t.SetTextAngle(pcb.EDA_ANGLE(0,pcb.DEGREES_T))
|
||||
found = False
|
||||
for d in (.8,1.3,1.8,2.5,3.5,5,7):
|
||||
positions = [(cx,b[1]-d),(cx,b[3]+d),(b[0]-d-1.5,cy),(b[2]+d+1.5,cy)]
|
||||
positions += [(cx+dx,b[1]-d) for dx in (-3,-2,-1,1,2,3)]
|
||||
positions += [(cx+dx,b[3]+d) for dx in (-3,-2,-1,1,2,3)]
|
||||
for x,y in positions:
|
||||
t.SetPosition(xy(x,y))
|
||||
tb = box_mm(t)
|
||||
if tb[0]<51 or tb[2]>269 or tb[1]<51 or tb[3]>184:
|
||||
continue
|
||||
if not any(intersects(tb, q) for q in occupied[layer]):
|
||||
occupied[layer].append(expand(tb,.12))
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
break
|
||||
if not found:
|
||||
t.SetLayer(pcb.B_Fab if f.IsFlipped() else pcb.F_Fab)
|
||||
t.SetPosition(xy(cx,cy))
|
||||
f.SetField('ReferenceOnFabOnly','Dense area: refer to assembly drawing')
|
||||
for field in f.GetFields():
|
||||
if field.GetName()=='ReferenceOnFabOnly':
|
||||
field.SetVisible(False)
|
||||
field.SetMirrored(f.IsFlipped())
|
||||
|
||||
|
||||
def carrier():
|
||||
s = Layout(2)
|
||||
# Bottom-side electronics stay clear of the lower board's outward-facing antenna.
|
||||
s.reserve('R2_ESP32_ANTENNA_PROJECTION', (232,175,258,184), [pcb.F_Cu,pcb.In1_Cu,pcb.In2_Cu,pcb.B_Cu], True)
|
||||
s.put('U32',(174,122),90,reason='Hub centered between five ports; upstream exit faces east')
|
||||
s.near('C749','U32',57,offset=(2,-2),radius=3,reason='Prioritize hub bypass beside upstream PHY supply pin')
|
||||
s.put('U31',(152,122),0,reason='Hub LDO between BTB supply and hub')
|
||||
s.put('U21',(203,130),0,reason='595 serial input adjacent to J32 control pins')
|
||||
s.put('U22',(184,135),0,reason='595 chain toward lower phone sites')
|
||||
s.put('U33',(164,137),0,reason='595 chain end and hub reset')
|
||||
s.put('U34',(239,154),0,reason='Carrier temperature sensor away from LDO and bulk capacitors')
|
||||
for ch in range(1,6):
|
||||
# Rotate this functional cell with the mechanical phone orientation.
|
||||
sign = 1 if ch <= 3 else -1
|
||||
bat, tail = f'J8{ch}',f'J7{ch}'
|
||||
bx,by = point(s.parts[bat])
|
||||
tx,ty = point(s.parts[tail])
|
||||
base = 800+(ch-1)*10
|
||||
s.put(f'C{base+1}',(bx+sign*12,by+sign*2),0, radius=12,
|
||||
reason='10.5mm bulk capacitor beside battery; lower-board projection reserved')
|
||||
for k, offset in [(2,(7,-5)),(3,(7,-2)),(4,(7,1)),(5,(7,4)),(6,(5,-6))]:
|
||||
s.put(f'C{base+k}',(bx+sign*offset[0],by+sign*offset[1]),(0,90,180,270),radius=10,
|
||||
reason='Battery ceramic bank outside ribbon lane')
|
||||
for ref,pin in [(f'R{780+ch}',3),(f'R{790+ch}',4)]:
|
||||
s.near(ref,bat,pin,reason='Battery identification resistor at connector')
|
||||
s.put(f'U{50+ch}',(tx+sign*3,ty-sign*5),0,radius=8,
|
||||
reason='USB ESD flow-through device adjacent to tail connector')
|
||||
s.put(f'U{23+ch}',(tx+sign*10,ty-sign*7),0,radius=10,
|
||||
reason='Per-port VBUS switch near tail; bulk current separated from USB pair')
|
||||
for prefix,pin in [('C70',5),('C71',1),('C72',4),('R70',6)]:
|
||||
ref = f'{prefix}{ch}'
|
||||
if ref in s.parts:
|
||||
s.near(ref,f'U{23+ch}',pin,reason='VBUS switch local decoupling/timing/default state')
|
||||
for ref,pin in [(f'R{760+ch}',22),(f'R{770+ch}',14)]:
|
||||
s.near(ref,tail,pin,reason='Tail accessory termination')
|
||||
# Keep EDL service connector reachable from the side of each site.
|
||||
s.put(f'J6{ch}',(bx-sign*10,by-sign*9),0,radius=10,reason='EDL service connector beside battery site')
|
||||
s.near(f'U4{ch}',f'J6{ch}',offset=(0,-sign*6),radius=10,reason='EDL analog switch at service connector')
|
||||
s.near(f'C{760+ch}',f'U4{ch}',5,reason='EDL switch supply bypass')
|
||||
s.near(f'R{740+ch}',f'U4{ch}',6,reason='EDL enable defaults low')
|
||||
for k in range(3):
|
||||
q = f'Q{ch}{k+3:02}'
|
||||
contact = f'J{41+(ch-1)*4+k}'
|
||||
s.near(q,contact,offset=(-sign*3,sign*7),radius=14,reason='Key MOS close to its phone contact')
|
||||
for suffix in (20+2*k,21+2*k):
|
||||
s.near(f'R{ch}{suffix}',q,1,reason='Key gate resistor/pulldown at MOS')
|
||||
for ref,anchor,pin in [('C736','U31',3),('C737','U31',2),('C760','U34',8),
|
||||
('C604','U21',16),('C605','U22',16),('C739','U33',16),('R609','U21',13)]:
|
||||
s.near(ref,anchor,pin,reason='Supply bypass / control bias at IC pin')
|
||||
s.near('Y1','U32',61,offset=(0,-4),radius=8,reason='24MHz crystal immediately at hub oscillator pins')
|
||||
for ref,pin in [('C740',1),('C741',3)]:
|
||||
s.near(ref,'Y1',pin,reason='Crystal load capacitor; isolated ground return')
|
||||
hub_pins = {'C742':25,'C743':62,'C744':5,'C745':10,'C746':24,'C747':46,'C748':52,
|
||||
'C750':64,'C751':64,'R708':63,'R709':44,'R710':44,'R711':41,
|
||||
'R712':42,'R714':54,'R715':56,'R716':43,'R719':40,'R720':45}
|
||||
for ref,pin in hub_pins.items():
|
||||
s.near(ref,'U32',pin,radius=12,reason='Hub pin-local bypass or configuration resistor')
|
||||
s.near('R717','J23','A5',offset=(-5,0),reason='USB-C CC termination')
|
||||
s.near('R718','J23','B5',offset=(-5,0),reason='USB-C CC termination')
|
||||
s.near('R721','J23','A4B9',offset=(-8,0),reason='Upstream VBUS detection divider')
|
||||
s.near('R722','R721',2,reason='Upstream VBUS detection divider return')
|
||||
return s
|
||||
|
||||
|
||||
def power_cell(s,ch,origin):
|
||||
x,y = origin
|
||||
buck, efuse, ina = f'U{ch}',f'U{ch+13}',f'U{ch+7}'
|
||||
s.put(buck,(x,y),0,radius=0,reason=f'CH{ch} buck; VIN west, SW east, feedback south')
|
||||
s.put(f'L{ch}01',(x+7,y),0,radius=1,reason='Inductor directly at SW; horizontal hot path')
|
||||
s.put(f'C{ch}06',(x+14,y-2),90,radius=2,reason='First buck output capacitor immediately after inductor')
|
||||
s.put(f'C{ch}07',(x+17,y-2),90,radius=2,reason='Parallel buck output capacitor')
|
||||
s.put(efuse,(x+20,y+5),90,radius=3,reason='Output protection between buck capacitor and shunt')
|
||||
s.put(f'Rs{ch}01',(x+26,y+9),180,radius=3,reason='4A output shunt; Kelvin pickup on inner pad edges')
|
||||
s.put(ina,(x+26,y+15),0,radius=3,reason='INA inputs face shunt; digital bus exits away from power path')
|
||||
for k,dy in [(1,-2),(2,1),(3,4),(4,-4)]:
|
||||
s.put(f'C{ch}{k:02}',(x-5,y+dy),(0,180,90,270),radius=5,reason='Local VIN ceramic hot-loop bank')
|
||||
s.near(f'C{ch}05',buck,13,offset=(2,-2),radius=5,reason='Bootstrap capacitor directly between BOOT and SW')
|
||||
s.near(f'C{ch}08',efuse,11,reason='eFuse input bypass')
|
||||
for ref,pin in [(f'R{ch}01',7),(f'R{ch}02',7),(f'R{ch}03',1),(f'R{ch}04',10),
|
||||
(f'R{ch}05',10),(f'R{ch}06',8),(f'C{ch}09',9),(f'C{ch}10',8),
|
||||
(f'C{ch}11',8),(f'R{ch}07',7)]:
|
||||
s.near(ref,buck,pin,radius=9,reason='Buck quiet feedback/compensation/soft-start component')
|
||||
s.near(f'C{ch}12',f'R{ch}07',2,reason='DAC injection low-pass at feedback branch')
|
||||
s.near(f'R{ch}08',f'C{ch}12',1,reason='DAC injection series resistor')
|
||||
for k,pin in [(9,15),(10,15),(11,17),(12,19),(13,20),(14,14),(15,2),(16,3),(17,3)]:
|
||||
s.near(f'R{ch}{k:02}',efuse,pin,radius=10,reason='eFuse OVP/ILIM/PG threshold or default bias')
|
||||
s.near(f'C{ch}14',efuse,18,reason='eFuse ramp timing capacitor')
|
||||
s.near(f'C{ch}13',ina,6,reason='INA226 local supply bypass')
|
||||
s.near(f'Q{ch}01',efuse,14,offset=(-4,4),reason='Discharge interlock local to eFuse enable')
|
||||
s.near(f'Q{ch}02',f'Q{ch}01',3,offset=(0,5),reason='Discharge MOS isolated from quiet feedback')
|
||||
s.near(f'R{ch}18',f'Q{ch}01',3,reason='Discharge gate bias')
|
||||
s.near(f'R{ch}19',f'Q{ch}02',3,offset=(5,4),radius=10,reason='Pulse discharge resistor with open thermal surroundings')
|
||||
|
||||
|
||||
def lower(upper):
|
||||
s = Layout(1)
|
||||
for ch in range(1,6):
|
||||
ref = f'C{801+(ch-1)*10}'
|
||||
s.reserve(f'R2_HEIGHT_{ref}_10.5mm',expand(box_mm(upper.parts[ref]),1),[pcb.F_Cu])
|
||||
s.reserve('R2_RF_COPPER_VOID',(232,175,258,184),[pcb.F_Cu,pcb.In1_Cu,pcb.In2_Cu,pcb.B_Cu],True,False)
|
||||
s.obstacles.append(('RF_NEIGHBOUR_CLEARANCE',(232,175,258,184),pcb.F_Cu))
|
||||
# The module itself extends into the copper-only antenna area.
|
||||
rf = s.obstacles.pop()
|
||||
s.put('U20',(247,165),180,radius=0,reason='MCU at board edge; antenna toward free south-east corner')
|
||||
s.obstacles.append(rf)
|
||||
s.put('J22',(264.866,151),90,radius=0,reason='USB debug connector opens at right edge')
|
||||
s.put('J1',(63,67),90,radius=0,reason='12V input at left edge; fuse and TVS directly inboard')
|
||||
s.put('J26',(59,148),270,radius=0,reason='Intake fan cable entry at left edge')
|
||||
s.put('J27',(260,99),90,radius=0,reason='Exhaust fan cable entry at right edge')
|
||||
# Three upper and two lower cells follow connector destinations, not old coordinates.
|
||||
for ch,origin in [(1,(81,91)),(2,(137,86)),(3,(213,89)),(4,(102,141)),(5,(179,141))]:
|
||||
power_cell(s,ch,origin)
|
||||
for ref,position,rot in [('F1',(74,65),0),('Q1',(82,65),90),('D1',(87,57),0),
|
||||
('C2',(98,59),0),('C3',(108,59),0),('Rs6',(118,62),0),
|
||||
('U13',(119,69),0),('U19',(142,62),0),('L2',(149,62),0),
|
||||
('U30',(169,62),0),('L3',(177,62),0),('Q3',(189,58),90),
|
||||
('U29',(190,67),0),('U23',(150,157),0),('U6',(135,170),0),
|
||||
('U7',(209,169),0),('Q7',(74,153),0),('Q8',(79,160),0),
|
||||
('Q6',(85,159),0),('Q9',(72,166),0),('Q10',(251,111),0)]:
|
||||
s.put(ref,position,rot,radius=6,reason='Functional input / auxiliary supply / control / fan zone')
|
||||
for i,ref in enumerate(('R9A','R9B','R9C','R9D','R9E')):
|
||||
s.put(ref,(204+i*10,58),0,radius=2,reason='Series bus discharge resistors spaced along ventilated north edge')
|
||||
assignments = {
|
||||
'C1':('Q1',4),'DZ1':('Q1',4),'R1':('Q1',4),'C4':('Q1',5),'C5':('Rs6',1),'C6':('Rs6',1),
|
||||
'C7':('U19',3),'C8':('U19',6),'C9':('L2',2),'C10':('L2',2),'C11':('U13',6),
|
||||
'R2':('U19',4),'R3':('U19',4),'R4':('U29',1),'R5':('U29',1),'R6':('Q3',4),
|
||||
'C731':('U30',3),'C732':('U30',3),'C733':('U30',6),'C734':('L3',2),'C735':('L3',2),
|
||||
'R706':('U30',4),'R707':('U30',4),
|
||||
'C601':('U20',2),'C602':('U20',2),'C603':('U20',3),'R601':('U20',3),
|
||||
'R602':('J22','A5'),'R603':('J22','B5'),'R604':('U20',22),'R605':('U20',8),
|
||||
'R606':('U23',9),'R607':('U23',10),'R608':('U23',1),
|
||||
'C606':('U23',24),'C607':('U6',15),'C608':('U6',13),'C609':('U6',16),
|
||||
'C610':('U7',15),'C611':('U7',13),'C612':('U7',16),'R614':('U6',16),
|
||||
'R615':('U6',5),'R616':('U7',16),'R617':('U7',5),
|
||||
'C738':('Q7',3),'D2':('Q7',3),'DZ3':('Q7',1),'R740':('Q7',1),'R741':('Q8',3),
|
||||
'R742':('Q8',1),'R743':('Q8',1),'R733':('Q6',1),'R734':('Q6',1),
|
||||
'R744':('J26',3),'R745':('Q9',1),'R746':('Q9',1),'R747':('Q9',3),
|
||||
'R748':('J27',3),'R749':('Q10',1),'R750':('Q10',1),'R751':('Q10',3)}
|
||||
for ref,(anchor,pin) in assignments.items():
|
||||
s.near(ref,anchor,pin,radius=12,reason='Pin-local support circuitry')
|
||||
s.put('RT601',(92,102),0,radius=8,reason='Thermistor near CH1 hot area; avoid direct copper heat spreading')
|
||||
s.put('RT602',(220,99),0,radius=8,reason='Thermistor near CH3 hot area; avoid direct copper heat spreading')
|
||||
for ref,pin in [('R618',39),('C613',39),('R619',38),('C614',38)]:
|
||||
s.near(ref,'U20',pin,radius=10,reason='ADC thermistor filter at MCU input')
|
||||
return s
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True,exist_ok=True)
|
||||
before = OUT / 'before'
|
||||
manifest = OUT / 'placement.json'
|
||||
previous = json.loads(manifest.read_text(encoding='utf-8')) if manifest.exists() else {}
|
||||
for n in (1,2):
|
||||
name = f'PhoneRail-Board{n}'
|
||||
for ext in ('.kicad_pcb','.kicad_pro','.kicad_dru'):
|
||||
path = ROOT/name/(name+ext)
|
||||
expected = previous.get('generated_files',{}).get(str(path.relative_to(ROOT)))
|
||||
if expected and digest(path) != expected:
|
||||
raise RuntimeError(f'Refusing to overwrite subsequent edits: {path}')
|
||||
before.mkdir(exist_ok=True)
|
||||
backup = before/path.name
|
||||
if not backup.exists():
|
||||
shutil.copy2(path,backup)
|
||||
upper = carrier()
|
||||
lower_board = lower(upper)
|
||||
reports = {}
|
||||
for s in (lower_board,upper):
|
||||
s.finish()
|
||||
reports[s.name] = {'moved':len(s.placements),'placements':s.placements}
|
||||
print(s.name,'placed',len(s.placements),flush=True)
|
||||
for s in (lower_board,upper):
|
||||
shutil.copy2(OUT/s.path.name,s.path)
|
||||
reports['status'] = REVISION + '_UNROUTED_NOT_RELEASED'
|
||||
reports['generated_files'] = {str(s.path.relative_to(ROOT)):digest(s.path) for s in (lower_board,upper)}
|
||||
manifest.write_text(json.dumps(reports,indent=2)+'\n',encoding='utf-8')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Inject measurement-route faults in memory without saving the production PCB."""
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
from kelvin_checks import check_measurement_branches
|
||||
from schematic_maintenance import ROOT
|
||||
|
||||
|
||||
def run_case(case):
|
||||
path = ROOT / 'PhoneRail-Board1/PhoneRail-Board1.kicad_pcb'
|
||||
branches = json.loads((ROOT / 'pcb-routing-rules.json').read_text(encoding='utf-8'))['measurement_branches']
|
||||
board = pcb.LoadBoard(str(path))
|
||||
tracks = {t.m_Uuid.AsString(): t for t in board.GetTracks()}
|
||||
track = tracks[branches[0]['track_uuids'][0]]
|
||||
if case == 'missing_segment':
|
||||
board.Remove(track)
|
||||
elif case == 'load_branch_on_sense_trace':
|
||||
a, b = track.GetStart(), track.GetEnd()
|
||||
mid = pcb.VECTOR2I((a.x+b.x)//2, (a.y+b.y)//2)
|
||||
branch = pcb.PCB_TRACK(board)
|
||||
branch.SetStart(mid)
|
||||
branch.SetEnd(pcb.VECTOR2I(mid.x+pcb.FromMM(1), mid.y))
|
||||
branch.SetWidth(pcb.FromMM(.5))
|
||||
branch.SetLayer(track.GetLayer())
|
||||
branch.SetNet(track.GetNet())
|
||||
board.Add(branch)
|
||||
result = check_measurement_branches(board, branches)
|
||||
if case == 'baseline':
|
||||
assert not result['errors'] and len(result['branches']) == 10, result
|
||||
elif case == 'missing_segment':
|
||||
assert any(e['kind'] == 'kelvin_missing_items' for e in result['errors']), result
|
||||
else:
|
||||
assert any('branch_join_outside_shunt_land' in e.get('issues', []) for e in result['errors']), result
|
||||
return {'case': case, 'passed': True, 'detected_errors': result['errors']}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--case', choices=('baseline', 'missing_segment', 'load_branch_on_sense_trace'))
|
||||
args = parser.parse_args()
|
||||
folder = ROOT / 'out/local-routing-r3'
|
||||
if args.case:
|
||||
result = run_case(args.case)
|
||||
(folder / f'kelvin-probe-{args.case}.json').write_text(json.dumps(result, indent=2)+'\n', encoding='utf-8')
|
||||
return
|
||||
results = []
|
||||
# Isolate native KiCad object ownership for each destructive in-memory probe.
|
||||
for case in ('baseline', 'missing_segment', 'load_branch_on_sense_trace'):
|
||||
subprocess.run([sys.executable, __file__, '--case', case], check=True, timeout=60)
|
||||
results.append(json.loads((folder / f'kelvin-probe-{case}.json').read_text(encoding='utf-8')))
|
||||
out = folder / 'kelvin-probe.json'
|
||||
out.write_text(json.dumps({'passed': True, 'cases': results}, indent=2)+'\n', encoding='utf-8')
|
||||
print(json.dumps(results))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Inject violations into a disposable board to check R2 rules actually fire."""
|
||||
import json
|
||||
import shutil
|
||||
|
||||
import pcbnew as pcb
|
||||
from bootstrap_pcb import xy, rounded_rect
|
||||
from optimize_pcb_layout import ROOT, OUT
|
||||
from verify_pcb_layout import run, routing
|
||||
|
||||
|
||||
def main():
|
||||
folder=OUT/'rule-probe'
|
||||
folder.mkdir(exist_ok=True)
|
||||
path=folder/'RuleProbe.kicad_pcb'
|
||||
b=pcb.BOARD(); b.SetCopperLayerCount(4)
|
||||
rounded_rect(b,50,50,220,135,5)
|
||||
def net(name):
|
||||
item=pcb.NETINFO_ITEM(b,name); b.Add(item); return item
|
||||
usb,sw,control=net('USB_DP'),net('Net-(C105-Pad2)'),net('SDA')
|
||||
def track(a,z,layer,n,width=.25):
|
||||
t=pcb.PCB_TRACK(b); t.SetStart(xy(*a));t.SetEnd(xy(*z));t.SetLayer(layer)
|
||||
t.SetWidth(pcb.FromMM(width));t.SetNet(n);b.Add(t)
|
||||
track((60,60),(65,60),pcb.F_Cu,usb,.8)
|
||||
track((70,70),(80,70),pcb.In1_Cu,control)
|
||||
track((90,80),(90,81.5),pcb.F_Cu,control)
|
||||
track((90,81.5),(90,83),pcb.F_Cu,control)
|
||||
track((100,90),(101.5,90),pcb.B_Cu,control)
|
||||
track((101.5,90),(103,90),pcb.B_Cu,control)
|
||||
v=pcb.PCB_VIA(b);v.SetPosition(xy(120,100));v.SetWidth(pcb.FromMM(.6))
|
||||
v.SetDrill(pcb.FromMM(.3));v.SetLayerPair(pcb.F_Cu,pcb.B_Cu);v.SetNet(sw);b.Add(v)
|
||||
pcb.SaveBoard(str(path),b)
|
||||
for ext in ('.kicad_pro','.kicad_dru'):
|
||||
shutil.copy2(ROOT/'PhoneRail-Board1'/('PhoneRail-Board1'+ext),path.with_suffix(ext))
|
||||
drc=folder/'drc.json'
|
||||
run(['pcb','drc','--format','json','-o',str(drc),str(path)],drc)
|
||||
data=json.loads(drc.read_text(encoding='utf-8'))
|
||||
descriptions='\n'.join(v['description'] for v in data['violations'])
|
||||
expected=['R2 USB differential target','R2 inner layers reserved','R2 switch copper stays']
|
||||
checks={s:s in descriptions for s in expected}
|
||||
contract=json.loads((ROOT/'pcb-routing-rules.json').read_text(encoding='utf-8'))
|
||||
audit=routing(b,contract,1)
|
||||
direction=[v for v in audit['errors'] if v['kind']=='direction']
|
||||
checks['Split transverse tracks rejected on both outer layers']=len(direction)==2
|
||||
checks['Inner signal track rejected']=any(v['kind']=='inner_signal_track' for v in audit['errors'])
|
||||
checks['Explicit nonstandard USB pair skew checked']=any(v['kind']=='usb_skew' for v in audit['errors'])
|
||||
result={'checks':checks,'all_passed':all(checks.values()),'routing':audit}
|
||||
(folder/'verification.json').write_text(json.dumps(result,indent=2)+'\n',encoding='utf-8')
|
||||
print(checks)
|
||||
assert result['all_passed'],descriptions
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$mcpPython = 'D:\kicad-mcp\.venv\Scripts\python.exe'
|
||||
$kicadCli = 'D:\KiCad\bin\kicad-cli.exe'
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
foreach ($requiredPath in @($mcpPython, $kicadCli)) {
|
||||
if (-not (Test-Path -LiteralPath $requiredPath)) {
|
||||
throw "Required executable missing: $requiredPath"
|
||||
}
|
||||
}
|
||||
Get-Command codex -ErrorAction Stop | Out-Null
|
||||
& $mcpPython -B (Join-Path $PSScriptRoot 'verify_kicad_mcp.py')
|
||||
if ($LASTEXITCODE -ne 0) { throw 'KiCad MCP verification failed.' }
|
||||
& codex mcp add kicad --env "KICAD_CLI=$kicadCli" --env "KICAD_PROJECT_PATHS=$projectRoot" --env 'PYTHONIOENCODING=utf-8' --env 'PYTHONDONTWRITEBYTECODE=1' -- $mcpPython -B -m kicad_mcp_server
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Could not register KiCad MCP in Codex.' }
|
||||
& codex mcp get kicad
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Could not read the registered MCP configuration.' }
|
||||
Write-Host 'KiCad MCP registered. Restart Codex and open a new conversation.'
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Correct the migration's missing Y reflection, preserving source spans.
|
||||
|
||||
The EasyEDA footprint coordinates are Y-up; KiCad footprint coordinates are
|
||||
Y-down. Reflect geometry and negate angles, not pad numbers or copper sides.
|
||||
Verify every pad against archived efoo coordinates and IC pin chirality
|
||||
against standard KiCad footprints. The marker prevents a second reflection.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
from schematic_maintenance import ROOT, Node, parse, child, children
|
||||
|
||||
MARKER = 'phonerail-y-axis-corrected-r1'
|
||||
LIB = ROOT / 'lib/PhoneRail.pretty'
|
||||
OUT = ROOT / 'out/footprint-orientation'
|
||||
|
||||
|
||||
def source_footprints():
|
||||
found = {}
|
||||
for path in sorted((ROOT.parent / '.claude/eda_src/epro/FOOTPRINT').glob('*.efoo')):
|
||||
rows = [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()]
|
||||
head = next(row[1] for row in rows if row[0] == 'HEAD')
|
||||
name = re.sub(r'[<>:"/\\|?*\r\n]', '_', head.get('title') or path.stem)
|
||||
found.setdefault(name, (path, [r for r in rows if r[0] == 'PAD']))
|
||||
return found
|
||||
|
||||
|
||||
def reflect(source):
|
||||
root = parse(source)
|
||||
assert not children(root, 'model'), '3D model reflection needs a separate review'
|
||||
edits = []
|
||||
|
||||
def visit(node):
|
||||
if node[0] in {'at', 'start', 'mid', 'end', 'center', 'xy', 'offset', 'rect_delta'}:
|
||||
assert len(node) in (3,4) and all(isinstance(v,str) for v in node)
|
||||
values = list(node[1:])
|
||||
values[1] = f'{-float(values[1]):g}'
|
||||
if len(values) == 3:
|
||||
values[2] = f'{-float(values[2]):g}'
|
||||
edits.append((node.start,node.end,'('+node[0]+' '+' '.join(values)+')'))
|
||||
else:
|
||||
for item in node:
|
||||
if isinstance(item, Node):
|
||||
visit(item)
|
||||
|
||||
visit(root)
|
||||
edits.append((root.end-1,root.end-1,f'\t(tags "{MARKER}")\n'))
|
||||
for start,end,replacement in sorted(edits,reverse=True):
|
||||
source = source[:start]+replacement+source[end:]
|
||||
return source
|
||||
|
||||
|
||||
def verify_pads(path, rows, reflected):
|
||||
root = parse(path.read_text(encoding='utf-8'))
|
||||
pads = children(root,'pad')
|
||||
assert len(pads) == len(rows), (path.name,'pad count')
|
||||
for pad,row in zip(pads,rows):
|
||||
assert pad[1] == str(row[5]), (path.name,'pad number')
|
||||
at = child(pad,'at')
|
||||
x,y = float(row[6])*.0254,float(row[7])*.0254
|
||||
if reflected:
|
||||
y = -y
|
||||
assert abs(float(at[1])-x) < 1e-4 and abs(float(at[2])-y) < 1e-4, (path.name,pad[1],'pad coordinate')
|
||||
angle = float(at[3]) if len(at) == 4 else 0
|
||||
expected = (row[8] or 0)*(-1 if reflected else 1)
|
||||
assert abs((angle-expected+180) % 360-180) < .001, (path.name,pad[1],'pad rotation')
|
||||
return len(pads)
|
||||
|
||||
|
||||
def repair_slot_drills(source, rows):
|
||||
root = parse(source)
|
||||
edits = []
|
||||
count = 0
|
||||
for pad,row in zip(children(root,'pad'),rows):
|
||||
if not isinstance(row[9],list) or row[9][0] != 'SLOT':
|
||||
continue
|
||||
# efoo column 14 is drill rotation, independent of pad rotation.
|
||||
angle = ((row[14] or 0)-(row[8] or 0)) % 180
|
||||
assert angle in (0,90), 'Nonorthogonal drill needs a dedicated pad transform'
|
||||
w,h = [round(float(v)*.0254,5) for v in row[9][1:3]]
|
||||
if angle == 90:
|
||||
w,h = h,w
|
||||
drill = child(pad,'drill')
|
||||
assert drill[1] == 'oval'
|
||||
edits.append((drill.start,drill.end,f'(drill oval {w:g} {h:g})'))
|
||||
count += 1
|
||||
for start,end,replacement in sorted(edits,reverse=True):
|
||||
source = source[:start]+replacement+source[end:]
|
||||
return source,count
|
||||
|
||||
|
||||
def explicit_pth_masks(source):
|
||||
edits = []
|
||||
for pad in children(parse(source),'pad'):
|
||||
if pad[2] in {'thru_hole','np_thru_hole'} and not child(pad,'tenting'):
|
||||
layers = child(pad,'layers')
|
||||
assert '*.Mask' in layers or ('F.Mask' in layers and 'B.Mask' in layers)
|
||||
edits.append((pad.end-1,' (tenting (front no) (back no))\n'))
|
||||
for at,text in sorted(edits,reverse=True):
|
||||
source = source[:at]+text+source[at:]
|
||||
return source
|
||||
|
||||
|
||||
def chirality(lib, name, pins):
|
||||
fp = pcb.FootprintLoad(str(lib), name)
|
||||
if fp is None:
|
||||
raise ValueError(name)
|
||||
pads = {p.GetNumber():p.GetPosition() for p in fp.Pads()}
|
||||
a,b,c = [pads[n] for n in pins]
|
||||
area = (b.x-a.x)*(c.y-a.y)-(b.y-a.y)*(c.x-a.x)
|
||||
assert area != 0
|
||||
return 1 if area > 0 else -1
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--apply', action='store_true')
|
||||
args = ap.parse_args()
|
||||
target = OUT / 'corrected.pretty'
|
||||
target.mkdir(parents=True,exist_ok=True)
|
||||
backup = OUT / 'before.pretty'
|
||||
backup.mkdir(exist_ok=True)
|
||||
originals = source_footprints()
|
||||
report = {'files':[], 'pad_coordinates_checked':0, 'standard_chirality_checks':[],
|
||||
'slot_drill_rotations_checked':0}
|
||||
for path in sorted(LIB.glob('*.kicad_mod')):
|
||||
original, rows = originals[path.stem]
|
||||
source = path.read_text(encoding='utf-8')
|
||||
done = MARKER in source
|
||||
verify_pads(path, rows, done)
|
||||
if not done:
|
||||
if not (backup/path.name).exists():
|
||||
shutil.copyfile(path, backup/path.name)
|
||||
source = reflect(source)
|
||||
source, slots = repair_slot_drills(source, rows)
|
||||
source = explicit_pth_masks(source)
|
||||
report['slot_drill_rotations_checked'] += slots
|
||||
candidate = target/path.name
|
||||
candidate.write_text(source,encoding='utf-8')
|
||||
report['pad_coordinates_checked'] += verify_pads(candidate,rows,True)
|
||||
report['files'].append({'file':path.name,'source_efoo':str(original),
|
||||
'source_sha256':hashlib.sha256(original.read_bytes()).hexdigest(),
|
||||
'corrected_sha256':hashlib.sha256(candidate.read_bytes()).hexdigest()})
|
||||
for custom, standard, pins in [
|
||||
('SOIC-16_L9.9-W3.9-P1.27-LS6.0-BL', 'Package_SO/SOIC-16_3.9x9.9mm_P1.27mm', ('1','2','9')),
|
||||
('SOIC-8_L5.0-W4.0-P1.27-LS6.0-BL', 'Package_SO/SOIC-8_3.9x4.9mm_P1.27mm', ('1','2','5')),
|
||||
('SOT-23-3_L2.9-W1.3-P1.90-LS2.4-BR', 'Package_TO_SOT_SMD/SOT-23', ('1','2','3'))]:
|
||||
lib,name = standard.split('/')
|
||||
reference = chirality(Path('D:/KiCad/share/kicad/footprints')/(lib+'.pretty'),name,pins)
|
||||
assert chirality(target,custom,pins) == reference, (custom,'mirrored relative to KiCad standard')
|
||||
report['standard_chirality_checks'].append({'custom':custom,'standard':standard,'pass':True})
|
||||
if args.apply:
|
||||
for path in target.glob('*.kicad_mod'):
|
||||
shutil.copyfile(path,LIB/path.name)
|
||||
# Preserve the standard fan connector geometry; only make mask intent explicit.
|
||||
for path in (ROOT/'lib/PhoneRail-Fan.pretty').glob('*.kicad_mod'):
|
||||
source = path.read_text(encoding='utf-8')
|
||||
revised = explicit_pth_masks(source)
|
||||
if revised != source:
|
||||
path.write_text(revised,encoding='utf-8')
|
||||
report['applied'] = args.apply
|
||||
(OUT/'audit.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
|
||||
print(f'{len(report["files"])} footprints; {report["pad_coordinates_checked"]} pad coordinates; '
|
||||
f'{len(report["standard_chirality_checks"])} standard IC chirality checks; applied={args.apply}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Apply the reviewed C737 solid-tantalum substitution, preserving pin 2 as positive."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
from check_hardware import Board
|
||||
from schematic_maintenance import ROOT, child, children, parse, properties
|
||||
|
||||
OUT = ROOT / 'out/c737-tantalum'
|
||||
SHEET = ROOT / 'PhoneRail-Board2/C1-CONN.kicad_sch'
|
||||
BOARD = ROOT / 'PhoneRail-Board2/PhoneRail-Board2.kicad_pcb'
|
||||
LIB = ROOT / 'lib/PhoneRail.kicad_sym'
|
||||
FP_TABLE = ROOT / 'PhoneRail-Board2/fp-lib-table'
|
||||
FP_DIR = ROOT / 'lib/PhoneRail-Qualified.pretty'
|
||||
FP_NAME = 'CP_EIA-3216-18_Kemet-A_Pos2'
|
||||
SYMBOL = 'T491A226K010AT_Pos2'
|
||||
META = {'Value': '22uF / 10V Ta', 'Footprint': 'PhoneRail-Qualified:' + FP_NAME,
|
||||
'Datasheet': 'https://item.szlcsc.com/datasheet/T491A226K010AT/117829.html',
|
||||
'Description': '22uF +/-10% 10V solid tantalum MnO2; pin 2 positive, pin 1 negative; KEMET A 3216-18',
|
||||
'Manufacturer': 'KEMET', 'MPN': 'T491A226K010AT', 'LCSC': 'C116575'}
|
||||
CLI = 'D:/KiCad/bin/kicad-cli.exe'
|
||||
|
||||
|
||||
def update_spans(source, edits):
|
||||
for start, end, text in sorted(edits, reverse=True):
|
||||
source = source[:start] + text + source[end:]
|
||||
return source
|
||||
|
||||
|
||||
def net_signature(root):
|
||||
return {n.attrib['name']: {(p.attrib['ref'], p.attrib['pin']) for p in n.findall('node')}
|
||||
for n in root.findall('nets/net')}
|
||||
|
||||
|
||||
def approved_metadata_transition(before, after):
|
||||
old, new = ET.fromstring(before), ET.fromstring(after)
|
||||
if old.attrib.get('ref') != 'C737' or old.findtext('footprint') != 'PhoneRail:C1206':
|
||||
return False
|
||||
if old.find('libsource').attrib.get('part') != 'CL31A226KAHNNNE':
|
||||
return False
|
||||
for tag, key in (('value', 'Value'), ('footprint', 'Footprint'), ('datasheet', 'Datasheet'), ('description', 'Description')):
|
||||
old.find(tag).text = META[key]
|
||||
for field in old.findall('fields/field'):
|
||||
if field.attrib['name'] in META:
|
||||
field.text = META[field.attrib['name']]
|
||||
for prop in old.findall('property'):
|
||||
if prop.attrib['name'] in META:
|
||||
prop.attrib['value'] = META[prop.attrib['name']]
|
||||
old.find('libsource').attrib['part'] = SYMBOL
|
||||
return ET.tostring(old) == ET.tostring(new)
|
||||
|
||||
|
||||
def field_style(footprint, template):
|
||||
for field in footprint.GetFields():
|
||||
if field.GetName() not in ('Reference', 'Value'):
|
||||
field.SetVisible(False)
|
||||
field.SetMirrored(footprint.IsFlipped())
|
||||
for current, previous in ((footprint.Reference(), template.Reference()), (footprint.Value(), template.Value())):
|
||||
current.SetPosition(previous.GetPosition())
|
||||
current.SetTextAngle(previous.GetTextAngle())
|
||||
current.SetTextSize(previous.GetTextSize())
|
||||
current.SetVisible(previous.IsVisible())
|
||||
current.SetMirrored(previous.IsMirrored())
|
||||
|
||||
|
||||
def verify():
|
||||
after = OUT / 'after'
|
||||
after.mkdir(exist_ok=True)
|
||||
b = Board(2)
|
||||
b.compile(CLI, after)
|
||||
assert all(b.parts['C737'][k] == v for k, v in META.items())
|
||||
assert b.net('C737', 1) == 'GND' and b.net('C737', 2) == '+3V3_HUB'
|
||||
old = ET.parse(OUT / 'before/Board2.xml').getroot()
|
||||
new = ET.parse(after / 'Board2.xml').getroot()
|
||||
assert net_signature(old) == net_signature(new), 'Unexpected connectivity change'
|
||||
comps = lambda r: {c.attrib['ref']: ET.tostring(c) for c in r.findall('components/comp') if c.attrib['ref'] != 'C737'}
|
||||
assert comps(old) == comps(new), 'Unrelated component metadata changed'
|
||||
board = pcb.LoadBoard(str(BOARD))
|
||||
before = pcb.LoadBoard(str(OUT / 'before' / BOARD.name))
|
||||
fps = {f.GetReference(): f for f in board.GetFootprints()}
|
||||
for f in before.GetFootprints():
|
||||
if f.GetReference() == 'C737':
|
||||
continue
|
||||
current = fps[f.GetReference()]
|
||||
assert current.GetPosition() == f.GetPosition() and current.GetLayer() == f.GetLayer()
|
||||
assert current.GetOrientationDegrees() == f.GetOrientationDegrees()
|
||||
assert current.GetFPIDAsString() == f.GetFPIDAsString()
|
||||
assert [(p.GetNumber(), p.GetPosition(), p.GetNetname()) for p in current.Pads()] == [
|
||||
(p.GetNumber(), p.GetPosition(), p.GetNetname()) for p in f.Pads()]
|
||||
f = fps['C737']
|
||||
assert f.GetFPIDAsString() == META['Footprint'] and f.GetValue() == META['Value']
|
||||
pads = {p.GetNumber(): p for p in f.Pads()}
|
||||
assert pads['2'].GetNetname() == '+3V3_HUB' and pads['1'].GetNetname() == 'GND'
|
||||
assert f.GetLayer() == pcb.B_Cu and pads['2'].GetPosition().y < pads['1'].GetPosition().y
|
||||
assert len(list(board.GetTracks())) == len(list(before.GetTracks()))
|
||||
drc_path = OUT / 'Board2-drc.json'
|
||||
drc_path.unlink(missing_ok=True)
|
||||
result = subprocess.run([CLI, 'pcb', 'drc', '--format', 'json', '--schematic-parity',
|
||||
'-o', str(drc_path), str(BOARD)], capture_output=True,
|
||||
encoding='utf-8', errors='replace', timeout=180)
|
||||
drc_path.with_suffix('.log').write_text(result.stdout + result.stderr, encoding='utf-8')
|
||||
if result.returncode or not drc_path.exists():
|
||||
raise RuntimeError('Native DRC failed to run')
|
||||
drc = json.loads(drc_path.read_text(encoding='utf-8'))
|
||||
assert not drc.get('violations') and not drc.get('schematic_parity'), 'Native DRC or parity failed'
|
||||
report = {'status': 'SUBSTITUTION_VERIFIED_NOT_PRODUCTION_RELEASE', 'reference': 'C737', **META,
|
||||
'positive_pin': '2', 'positive_net': '+3V3_HUB', 'negative_pin': '1', 'negative_net': 'GND',
|
||||
'unchanged_networks': len(net_signature(new)), 'unrelated_components_unchanged': len(comps(new)),
|
||||
'position_mm': [pcb.ToMM(f.GetPosition().x), pcb.ToMM(f.GetPosition().y)],
|
||||
'orientation_degrees': f.GetOrientationDegrees(), 'erc': b.erc,
|
||||
'body_max_height_mm': 1.8, 'voltage_rating_v': 10, 'nominal_rail_v': 3.3,
|
||||
'native_geometry_drc': 0, 'native_schematic_parity': 0,
|
||||
'unconnected_pcb_items': len(drc.get('unconnected_items', []))}
|
||||
(OUT / 'verification.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps(report))
|
||||
|
||||
|
||||
def apply():
|
||||
backup = OUT / 'before'
|
||||
if backup.exists():
|
||||
for path in (SHEET, BOARD, LIB, FP_TABLE):
|
||||
if path.read_bytes() != (backup / path.name).read_bytes():
|
||||
raise RuntimeError('C737 baseline differs; use verification, do not overwrite later work')
|
||||
pdf = ROOT / 'datasheets/ic/KEMET-T491.pdf'
|
||||
assert hashlib.sha256(pdf.read_bytes()).hexdigest() == '47bd290c07415015937bb42a5998eb0c6ccf5760c539ab9d180233ed37dd6649'
|
||||
source = SHEET.read_text(encoding='utf-8')
|
||||
root = parse(source)
|
||||
instance = next(s for s in children(root, 'symbol') if properties(s)['Reference'][2] == 'C737')
|
||||
props = properties(instance)
|
||||
assert props['MPN'][2] == 'CL31A226KAHNNNE' and props['Footprint'][2] == 'PhoneRail:C1206'
|
||||
old_lib_id = child(instance, 'lib_id')[1]
|
||||
template = next(s for s in children(child(root, 'lib_symbols'), 'symbol') if s[1] == old_lib_id)
|
||||
symbol = source[template.start:template.end].replace('CL31A226KAHNNNE', SYMBOL)
|
||||
symbol_root = parse(symbol)
|
||||
graphics = children(symbol_root, 'symbol')[0]
|
||||
plus = '\n(text "+" (at 2 2.8 0) (effects (font (size 1.27 1.27))))\n'
|
||||
symbol = symbol[:graphics.end-1] + plus + symbol[graphics.end-1:]
|
||||
edits = [(child(instance, 'lib_id').start, child(instance, 'lib_id').end, f'(lib_id "PhoneRail:{SYMBOL}")')]
|
||||
for key, value in META.items():
|
||||
p = props[key]
|
||||
raw = source[p.start:p.end]
|
||||
updated = re.sub(r'^(\(property\s+"[^\"]*"\s+)"(?:\\.|[^\"\\])*"',
|
||||
lambda m: m[1] + json.dumps(value), raw)
|
||||
edits.append((p.start, p.end, updated))
|
||||
pos = child(root, 'lib_symbols').end - 1
|
||||
edits.append((pos, pos, '\n' + symbol + '\n'))
|
||||
updated_sheet = update_spans(source, edits)
|
||||
library = LIB.read_text(encoding='utf-8')
|
||||
lib_root = parse(library)
|
||||
updated_lib = library[:lib_root.end-1] + '\n' + symbol.replace('"PhoneRail:' + SYMBOL + '"', '"' + SYMBOL + '"', 1) + '\n' + library[lib_root.end-1:]
|
||||
standard = Path('D:/KiCad/share/kicad/footprints/Capacitor_Tantalum_SMD.pretty/CP_EIA-3216-18_Kemet-A.kicad_mod')
|
||||
footprint = standard.read_text(encoding='utf-8')
|
||||
fp_root = parse(footprint)
|
||||
fp_edits = []
|
||||
for pad in children(fp_root, 'pad'):
|
||||
raw = footprint[pad.start:pad.end]
|
||||
fp_edits.append((pad.start, pad.end, raw.replace('(pad "' + pad[1] + '"', '(pad "' + {'1': '2', '2': '1'}[pad[1]] + '"', 1)))
|
||||
footprint = update_spans(footprint, fp_edits).replace('(footprint "CP_EIA-3216-18_Kemet-A"', '(footprint "' + FP_NAME + '"', 1)
|
||||
table = FP_TABLE.read_text(encoding='utf-8')
|
||||
table_root = parse(table)
|
||||
entry = '\n(lib (name "PhoneRail-Qualified") (type "KiCad") (uri "${KIPRJMOD}/../lib/PhoneRail-Qualified.pretty") (options "") (descr "Datasheet-qualified replacements"))\n'
|
||||
updated_table = table[:table_root.end-1] + entry + table[table_root.end-1:]
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
if not backup.exists():
|
||||
backup.mkdir()
|
||||
Board(2).compile(CLI, backup)
|
||||
for path in (SHEET, BOARD, LIB, FP_TABLE):
|
||||
shutil.copy2(path, backup / path.name)
|
||||
FP_DIR.mkdir(exist_ok=True)
|
||||
(FP_DIR / (FP_NAME + '.kicad_mod')).write_text(footprint, encoding='utf-8')
|
||||
board = pcb.LoadBoard(str(BOARD))
|
||||
old = next(f for f in board.GetFootprints() if f.GetReference() == 'C737')
|
||||
original_pads = {p.GetNumber(): p for p in old.Pads()}
|
||||
f = pcb.FootprintLoad(str(FP_DIR), FP_NAME)
|
||||
board.Add(f)
|
||||
f.SetPosition(old.GetPosition())
|
||||
if old.GetLayer() == pcb.B_Cu:
|
||||
f.Flip(f.GetPosition(), False)
|
||||
scores = []
|
||||
for angle in (0, 90, 180, 270):
|
||||
f.SetOrientationDegrees(angle)
|
||||
scores.append((sum((p.GetPosition() - original_pads[p.GetNumber()].GetPosition()).EuclideanNorm() for p in f.Pads()), angle))
|
||||
f.SetOrientationDegrees(min(scores)[1])
|
||||
f.SetFPIDAsString(META['Footprint'])
|
||||
f.SetReference('C737')
|
||||
f.SetValue(META['Value'])
|
||||
f.SetPath(old.GetPath())
|
||||
f.SetSheetfile(old.GetSheetfile())
|
||||
f.SetSheetname(old.GetSheetname())
|
||||
board.Remove(old)
|
||||
f.SetUuid(pcb.KIID(old.m_Uuid.AsString()))
|
||||
for field in old.GetFields():
|
||||
if field.GetName() not in ('Reference', 'Value', 'Footprint'):
|
||||
f.SetField(field.GetName(), field.GetText())
|
||||
for key, value in META.items():
|
||||
if key not in ('Value', 'Footprint'):
|
||||
f.SetField(key, value)
|
||||
field_style(f, old)
|
||||
for p in f.Pads():
|
||||
p.SetNet(original_pads[p.GetNumber()].GetNet())
|
||||
pcb.SaveBoard(str(BOARD), board)
|
||||
SHEET.write_text(updated_sheet, encoding='utf-8')
|
||||
LIB.write_text(updated_lib, encoding='utf-8')
|
||||
FP_TABLE.write_text(updated_table, encoding='utf-8')
|
||||
(OUT / 'selection.json').write_text(json.dumps({**META, 'symbol': SYMBOL,
|
||||
'positive_pin': '2', 'negative_pin': '1', 'footprint_source': str(standard),
|
||||
'footprint_changes': 'Only root name and pad numbers swapped; standard positive marking remains on positive terminal',
|
||||
'catalog_record': 'out/fan-catalog/20260907T070741439087-detail.json'}, indent=2) + '\n', encoding='utf-8')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.apply:
|
||||
apply()
|
||||
verify()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Verify the five reviewed EDL NC markers against a compiled pre-change baseline."""
|
||||
import argparse
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from check_hardware import Board
|
||||
from schematic_maintenance import ROOT
|
||||
from unconnected_inventory import inventory, write_inventory
|
||||
|
||||
OUT = ROOT / 'out/edl-nc-review'
|
||||
CLI = 'D:/KiCad/bin/kicad-cli.exe'
|
||||
|
||||
|
||||
def snapshot(path):
|
||||
root = ET.parse(path).getroot()
|
||||
return ({c.attrib['ref']: ET.tostring(c) for c in root.findall('components/comp')},
|
||||
{n.attrib['name']: {(p.attrib['ref'], p.attrib['pin']): dict(p.attrib)
|
||||
for p in n.findall('node')} for n in root.findall('nets/net')})
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--baseline', action='store_true')
|
||||
args = ap.parse_args()
|
||||
out = OUT / ('before' if args.baseline else 'after')
|
||||
if args.baseline and out.exists():
|
||||
raise RuntimeError('Refusing to overwrite the baseline')
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
results, rows = {}, []
|
||||
for number in (1, 2):
|
||||
b = Board(number)
|
||||
b.compile(CLI, out)
|
||||
if args.baseline:
|
||||
continue
|
||||
old_parts, old_nets = snapshot(OUT / 'before' / f'Board{number}.xml')
|
||||
new_parts, new_nets = snapshot(out / f'Board{number}.xml')
|
||||
assert old_parts == new_parts, 'Component metadata changed'
|
||||
approved = {(f'U4{ch}', '3') for ch in range(1, 6)} if number == 2 else set()
|
||||
changed = set()
|
||||
assert old_nets.keys() == new_nets.keys(), 'Net names changed'
|
||||
for name, endpoints in old_nets.items():
|
||||
assert endpoints.keys() == new_nets[name].keys(), 'Connectivity changed'
|
||||
for endpoint, attrs in endpoints.items():
|
||||
expected = dict(attrs)
|
||||
if endpoint in approved:
|
||||
assert len(endpoints) == 1 and name.startswith('unconnected-')
|
||||
assert attrs['pintype'] == 'passive'
|
||||
expected['pintype'] = 'passive+no_connect'
|
||||
actual = new_nets[name][endpoint]
|
||||
assert actual == expected, (endpoint, actual, expected)
|
||||
if actual != attrs:
|
||||
changed.add(endpoint)
|
||||
assert changed == approved
|
||||
if number == 2:
|
||||
for ch in range(1, 6):
|
||||
u, j, r = f'U4{ch}', f'J6{ch}', f'R74{ch}'
|
||||
assert b.net_members[b.net(u, 1)] == {(u, '1'), (j, '1')}
|
||||
assert b.net_members[b.net(u, 4)] == {(u, '4'), (j, '2')}
|
||||
assert b.net(u, 2) == 'GND' and b.net(u, 5) == '+3V3_HUB'
|
||||
assert b.net(u, 6) == f'EDL_EN{ch}'
|
||||
assert b.resistor(r) == 100000
|
||||
assert {b.net(r, 1), b.net(r, 2)} == {'GND', b.net(u, 6)}
|
||||
old_erc = json.loads((OUT / 'before' / f'Board{number}-erc.json').read_text())
|
||||
new_erc = json.loads((out / f'Board{number}-erc.json').read_text())
|
||||
for key in ('ignored_checks', 'included_severities'):
|
||||
assert old_erc[key] == new_erc[key]
|
||||
def violations(report):
|
||||
# KiCad regenerates ERC pin UUIDs on each CLI load.
|
||||
return sorted(json.dumps([s['path'], v['type'], v['severity'], v['description'],
|
||||
[{k: value for k, value in item.items() if k != 'uuid'}
|
||||
for item in v['items']]], sort_keys=True)
|
||||
for s in report['sheets'] for v in s['violations'])
|
||||
expected_erc = json.loads(json.dumps(old_erc))
|
||||
removed = 0
|
||||
for sheet in expected_erc['sheets']:
|
||||
kept = []
|
||||
for v in sheet['violations']:
|
||||
is_target = (number == 2 and v['type'] == 'pin_not_connected'
|
||||
and any(f'Symbol U4{ch} ' in v['items'][0]['description']
|
||||
for ch in range(1, 6)))
|
||||
if is_target:
|
||||
removed += 1
|
||||
else:
|
||||
kept.append(v)
|
||||
sheet['violations'] = kept
|
||||
assert removed == len(approved)
|
||||
assert violations(expected_erc) == violations(new_erc), 'Unexpected ERC change'
|
||||
rows.extend(inventory(b))
|
||||
results[number] = {'components_unchanged': len(new_parts),
|
||||
'nets_unchanged': len(new_nets), 'reviewed_nc': sorted(approved),
|
||||
'erc': b.erc}
|
||||
if not args.baseline:
|
||||
result = {'status': 'PASS', 'boards': results,
|
||||
'inventory': write_inventory(OUT, rows),
|
||||
'evidence': 'datasheets/ic/TS5A3157.pdf pages 3 and 16',
|
||||
'scope': 'Unused switch port and powered default selection only; not power-off isolation qualification'}
|
||||
(OUT / 'verification.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Review IC pin types; --apply performs a guarded, mechanical type-only update."""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from schematic_maintenance import ROOT, child, children, parse, properties
|
||||
|
||||
LIB = Path('D:/KiCad/share/kicad/symbols')
|
||||
TARGETS = {
|
||||
'INA226AIDGSR': ('Sensor_Energy', 'INA226'),
|
||||
'LM75BD,118': ('Sensor_Temperature', 'LM75B'),
|
||||
'PCA9555PW,112': ('Interface_Expansion', 'PCA9555PW'),
|
||||
'ESP32-S3-WROOM-1': ('RF_Module', 'ESP32-S3-WROOM-1'),
|
||||
'LMR51430YDDCR': ('Regulator_Switching', 'LMR51430'),
|
||||
'USBLC6-2SC6_C2687116': ('Power_Protection', 'USBLC6-2SC6'),
|
||||
}
|
||||
OUT = ROOT / 'out/ic-pin-review'
|
||||
# Exact name differences checked during review, indexed by physical pin number.
|
||||
ALIASES = {
|
||||
'INA226AIDGSR': {'3': ('Alert', '~{Alert}'), '6': ('VS+', 'VS'),
|
||||
'8': ('VBUS', 'Vbus'), '9': ('VIN-', 'Vin-'), '10': ('VIN+', 'Vin+')},
|
||||
'LM75BD,118': {'3': ('OS', 'O.S.'), '8': ('VCC', '+Vs')},
|
||||
'PCA9555PW,112': {'1': ('INT#', '~{INT}')},
|
||||
'ESP32-S3-WROOM-1': {'13': ('IO19', 'USB_D-'), '14': ('IO20', 'USB_D+'),
|
||||
'41': ('EPAD', 'GND')},
|
||||
}
|
||||
|
||||
|
||||
def digest(source):
|
||||
return hashlib.sha256(source.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def pins(symbol):
|
||||
return {child(p, 'number')[1]: (child(p, 'name')[1], p[1])
|
||||
for unit in children(symbol, 'symbol') for p in children(unit, 'pin')}
|
||||
|
||||
|
||||
def reference_pins(library, name):
|
||||
source = (LIB / (library + '.kicad_sym')).read_text(encoding='utf-8')
|
||||
symbols = {s[1]: s for s in children(parse(source), 'symbol')}
|
||||
symbol = symbols[name]
|
||||
while child(symbol, 'extends'):
|
||||
symbol = symbols[child(symbol, 'extends')[1]]
|
||||
return pins(symbol)
|
||||
|
||||
|
||||
def verify_netlists(standards):
|
||||
from check_hardware import Board
|
||||
from manufacturer_ic_pins import check_common_pins, check_hub_supply
|
||||
from unconnected_inventory import inventory, write_inventory
|
||||
after = OUT / 'after'
|
||||
after.mkdir(exist_ok=True)
|
||||
result = {'status': 'PASS', 'boards': {}}
|
||||
queue = []
|
||||
unconnected = []
|
||||
for number in (1, 2):
|
||||
board = Board(number)
|
||||
board.compile('D:/KiCad/bin/kicad-cli.exe', after)
|
||||
unconnected.extend(inventory(board))
|
||||
old = ET.parse(OUT / 'before' / f'Board{number}.xml').getroot()
|
||||
new = ET.parse(after / f'Board{number}.xml').getroot()
|
||||
components = lambda root: {c.attrib['ref']: ET.tostring(c) for c in root.findall('components/comp')}
|
||||
old_components, new_components = components(old), components(new)
|
||||
changed_components = [ref for ref in old_components.keys() | new_components.keys()
|
||||
if old_components.get(ref) != new_components.get(ref)]
|
||||
if changed_components:
|
||||
from replace_c737_tantalum import approved_metadata_transition
|
||||
if number != 2 or changed_components != ['C737'] or not approved_metadata_transition(
|
||||
old_components['C737'], new_components['C737']):
|
||||
raise ValueError(f'Board{number}: unapproved component metadata change')
|
||||
|
||||
def nets(root):
|
||||
return {n.attrib['name']: { (p.attrib['ref'], p.attrib['pin']): dict(p.attrib)
|
||||
for p in n.findall('node')} for n in root.findall('nets/net')}
|
||||
|
||||
old_nets, new_nets = nets(old), nets(new)
|
||||
if set(old_nets) != set(new_nets):
|
||||
raise ValueError(f'Board{number}: network names changed')
|
||||
types = {c.attrib['ref']: c.find('libsource').attrib['part'] for c in new.findall('components/comp')}
|
||||
changed = 0
|
||||
for net, old_pins in old_nets.items():
|
||||
new_pins = new_nets[net]
|
||||
if set(old_pins) != set(new_pins):
|
||||
raise ValueError(f'Board{number}: {net} endpoints changed')
|
||||
for endpoint, old_attrs in old_pins.items():
|
||||
new_attrs = new_pins[endpoint]
|
||||
expected = dict(old_attrs)
|
||||
name = types[endpoint[0]]
|
||||
if name in standards:
|
||||
expected['pintype'] = standards[name][endpoint[1]][1]
|
||||
if '+no_connect' in old_attrs.get('pintype', ''):
|
||||
expected['pintype'] += '+no_connect'
|
||||
if expected != new_attrs:
|
||||
raise ValueError(f'Board{number}: unapproved pin change: {endpoint}')
|
||||
changed += old_attrs != new_attrs
|
||||
old_erc = json.loads((OUT / 'before' / f'Board{number}-erc.json').read_text(encoding='utf-8'))
|
||||
new_erc = json.loads((after / f'Board{number}-erc.json').read_text(encoding='utf-8'))
|
||||
if old_erc['ignored_checks'] != new_erc['ignored_checks'] or old_erc['included_severities'] != new_erc['included_severities']:
|
||||
raise ValueError('ERC check configuration changed')
|
||||
erc_counts = lambda report: dict(Counter(v['type'] for s in report['sheets'] for v in s['violations']))
|
||||
for sheet in new_erc['sheets']:
|
||||
for violation in sheet['violations']:
|
||||
queue.append({'board': number, 'sheet': sheet['path'], **violation})
|
||||
result['boards'][board.name] = {
|
||||
'components_unchanged': len(types) - len(changed_components),
|
||||
'approved_component_metadata_changes': changed_components,
|
||||
'net_names_and_endpoints_unchanged': len(new_nets),
|
||||
'approved_instance_pin_type_changes': changed,
|
||||
'erc_before': erc_counts(old_erc), 'erc_after': erc_counts(new_erc),
|
||||
'erc_configuration_unchanged': True,
|
||||
'common_pad_checks': check_common_pins(board),
|
||||
'hub_supply_checks': check_hub_supply(board),
|
||||
}
|
||||
result['unconnected_inventory'] = write_inventory(OUT, unconnected)
|
||||
(OUT / 'verification.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8')
|
||||
(OUT / 'remaining-erc.json').write_text(json.dumps(queue, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
print(json.dumps({'status': result['status'], 'boards': {
|
||||
name: {k: len(v) if k.endswith('_checks') else v for k, v in entry.items()}
|
||||
for name, entry in result['boards'].items()}}))
|
||||
|
||||
|
||||
def main():
|
||||
global OUT
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--apply', action='store_true')
|
||||
ap.add_argument('--verify-netlist', action='store_true', help='Recompile and compare against the saved pre-change baseline')
|
||||
ap.add_argument('--batch', choices=('standard', 'manufacturer', 'hub', 'discrete'), default='standard')
|
||||
args = ap.parse_args()
|
||||
if args.batch != 'standard':
|
||||
from manufacturer_ic_pins import TABLES, HUB_TABLES, DISCRETE_TABLES, references
|
||||
standards = {'manufacturer': TABLES, 'hub': HUB_TABLES, 'discrete': DISCRETE_TABLES}[args.batch]
|
||||
evidence = references(hub=args.batch == 'hub', discrete=args.batch == 'discrete')
|
||||
OUT = ROOT / 'out' / {'manufacturer': 'ic-pin-review-r2', 'hub': 'ic-pin-review-hub',
|
||||
'discrete': 'ic-pin-review-discrete'}[args.batch]
|
||||
else:
|
||||
standards = {name: reference_pins(*spec) for name, spec in TARGETS.items()}
|
||||
evidence = {n: {'library': spec[0], 'symbol': spec[1],
|
||||
'library_sha256': digest((LIB / (spec[0] + '.kicad_sym')).read_text(encoding='utf-8')),
|
||||
'pins': standards[n]} for n, spec in TARGETS.items()}
|
||||
paths = [ROOT / 'lib/PhoneRail.kicad_sym',
|
||||
*sorted(ROOT.glob('PhoneRail-Board*/*.kicad_sch'))]
|
||||
pending = []
|
||||
rows = []
|
||||
instances = Counter()
|
||||
for path in paths:
|
||||
source = path.read_text(encoding='utf-8')
|
||||
root = parse(source)
|
||||
if root[0] == 'kicad_sch':
|
||||
for instance in children(root, 'symbol'):
|
||||
name = child(instance, 'lib_id')[1].removeprefix('PhoneRail:')
|
||||
if name in standards:
|
||||
instances[name] += 1
|
||||
symbols = children(child(root, 'lib_symbols'), 'symbol')
|
||||
else:
|
||||
symbols = children(root, 'symbol')
|
||||
edits = []
|
||||
for symbol in symbols:
|
||||
name = symbol[1].removeprefix('PhoneRail:')
|
||||
if name not in standards:
|
||||
continue
|
||||
standard = standards[name]
|
||||
local = pins(symbol)
|
||||
if set(local) != set(standard):
|
||||
raise ValueError(f'{path}: {name} pin-number mismatch')
|
||||
for unit in children(symbol, 'symbol'):
|
||||
for pin in children(unit, 'pin'):
|
||||
number = child(pin, 'number')[1]
|
||||
actual_name, actual_type = local[number]
|
||||
expected_name, expected_type = standard[number]
|
||||
if actual_name != expected_name and ALIASES.get(name, {}).get(number) != (actual_name, expected_name):
|
||||
raise ValueError(f'{path}: {name}.{number} unreviewed name difference')
|
||||
if actual_type not in ('unspecified', expected_type):
|
||||
raise ValueError(f'{path}: {name}.{number} unexpected existing type {actual_type}')
|
||||
rows.append({'file': str(path.relative_to(ROOT)), 'symbol': name, 'pin': number,
|
||||
'name': actual_name, 'expected_name': expected_name,
|
||||
'actual_type': actual_type, 'expected_type': expected_type})
|
||||
if actual_type != expected_type:
|
||||
raw = source[pin.start:pin.end]
|
||||
edits.append((pin.start, pin.end, re.sub(r'^\(pin\s+\S+', '(pin ' + expected_type, raw)))
|
||||
updated = source
|
||||
for start, end, replacement in sorted(edits, reverse=True):
|
||||
updated = updated[:start] + replacement + updated[end:]
|
||||
if edits:
|
||||
# Prove the complete document differs only in electrical-type tokens.
|
||||
strip = lambda text: re.sub(r'\(pin\s+(?:unspecified|power_in|power_out|input|output|passive|bidirectional|open_collector)\s', '(pin TYPE ', text)
|
||||
if strip(updated) != strip(source):
|
||||
raise ValueError(f'Non-type change in {path}')
|
||||
pending.append((path, source, updated, len(edits)))
|
||||
if not all(instances[name] or name == 'TPS22918DBVR' for name in standards):
|
||||
raise ValueError('Some reviewed symbols have no instances')
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
if args.apply and pending:
|
||||
from check_hardware import Board
|
||||
before = OUT / 'before'
|
||||
if before.exists():
|
||||
raise RuntimeError('Review baseline already exists; inspect changes before another apply')
|
||||
before.mkdir()
|
||||
for number in (1, 2):
|
||||
Board(number).compile('D:/KiCad/bin/kicad-cli.exe', before)
|
||||
for path, source, _, _ in pending:
|
||||
backup = before / path.relative_to(ROOT)
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
backup.write_text(source, encoding='utf-8')
|
||||
for path, source, _, _ in pending:
|
||||
if path.read_text(encoding='utf-8') != source:
|
||||
raise RuntimeError(f'Concurrent edit: {path}')
|
||||
for path, _, updated, _ in pending:
|
||||
path.write_text(updated, encoding='utf-8')
|
||||
(OUT / 'applied.json').write_text(json.dumps({
|
||||
'pin_declarations_changed': sum(p[3] for p in pending),
|
||||
'files': [{'file': str(p.relative_to(ROOT)), 'before_sha256': digest(s),
|
||||
'after_sha256': digest(u), 'changed_pins': n} for p, s, u, n in pending],
|
||||
'pins': rows,
|
||||
}, indent=2) + '\n', encoding='utf-8')
|
||||
report = {'status': 'APPLIED_REQUIRES_ERC_AND_PARITY' if args.apply and pending else
|
||||
'PASS' if not pending else 'TYPE_UPDATES_PENDING',
|
||||
'instances': dict(instances), 'instance_pins': sum(instances[n] * len(standards[n]) for n in standards),
|
||||
'declarations_checked': len(rows), 'pending_updates': sum(p[3] for p in pending),
|
||||
'references': evidence,
|
||||
'pins': rows}
|
||||
(OUT / 'pin-types.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps({k: v for k, v in report.items() if k not in ('pins', 'references')}))
|
||||
if pending and not args.apply:
|
||||
raise SystemExit(1)
|
||||
if args.verify_netlist:
|
||||
from manufacturer_ic_pins import TABLES, HUB_TABLES, DISCRETE_TABLES, references
|
||||
references()
|
||||
references(hub=True)
|
||||
references(discrete=True)
|
||||
standards = {**{n: reference_pins(*spec) for n, spec in TARGETS.items()}, **TABLES, **HUB_TABLES, **DISCRETE_TABLES}
|
||||
verify_netlists(standards)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Generate the scoped four-wire prototype revision; retain a pre-change snapshot.
|
||||
|
||||
Only P6 and the unused U20.IO5 connection on P3 are changed. Existing migration
|
||||
helpers are deliberately not used to regenerate the rest of the project.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from schematic_maintenance import ROOT, parse, child, children, properties
|
||||
|
||||
LIB = 'PhoneRail-Fan'
|
||||
KICAD = Path('D:/KiCad/share/kicad')
|
||||
PAGE = ROOT / 'PhoneRail-Board1/P6-FAN-EDL.kicad_sch'
|
||||
MARKER = 'FOUR-WIRE FAN PROTOTYPE R1'
|
||||
|
||||
|
||||
def uid(key):
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, 'phonerail/fan-r1/' + key))
|
||||
|
||||
|
||||
def quote(value):
|
||||
return json.dumps(str(value), ensure_ascii=False)
|
||||
|
||||
|
||||
def symbol_from(path, name, new_name):
|
||||
source = path.read_text(encoding='utf-8')
|
||||
node = next(n for n in children(parse(source), 'symbol') if n[1] == name)
|
||||
assert child(node, 'extends') is None
|
||||
raw = source[node.start:node.end]
|
||||
return raw.replace('"' + name, '"' + new_name)
|
||||
|
||||
|
||||
def prop(name, value, x, y, hidden=False):
|
||||
return (f'(property {quote(name)} {quote(value)} (at {x:g} {y:g} 0) '
|
||||
f'(effects (font (size 1.27 1.27)) {"(hide yes)" if hidden else ""}))')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--apply', action='store_true')
|
||||
args = ap.parse_args()
|
||||
old = PAGE.read_text(encoding='utf-8')
|
||||
if MARKER in old or 'SANYO FAN SELECTION R2' in old:
|
||||
print('Already revised; generation skipped. Edit the current schematic directly.')
|
||||
return
|
||||
assert 'LEGACY THREE-PIN CIRCUIT' in old
|
||||
root = parse(old)
|
||||
original = {properties(n)['Reference'][2]: n for n in children(root, 'symbol')}
|
||||
board = ROOT / 'PhoneRail-Board1'
|
||||
top_id = child(parse((board / 'PhoneRail-Board1.kicad_sch').read_text(encoding='utf-8')), 'uuid')[1]
|
||||
page_id = child(root, 'uuid')[1]
|
||||
sheet_path = child(child(child(original['Q6'], 'instances'), 'project'), 'path')[1]
|
||||
assert sheet_path.startswith('/' + top_id)
|
||||
shared = ROOT / 'lib/PhoneRail.kicad_sym'
|
||||
definitions = {
|
||||
'R': symbol_from(shared, '0402WGF1002TCE', 'R'),
|
||||
'NMOS': symbol_from(shared, 'AO3400A', 'NMOS'),
|
||||
'PMOS': symbol_from(KICAD / 'symbols/Transistor_FET.kicad_sym', 'TP0610T', 'PMOS'),
|
||||
'Zener': symbol_from(KICAD / 'symbols/Device.kicad_sym', 'D_Zener', 'Zener'),
|
||||
}
|
||||
# Gate/input and channel/passive follow KiCad's standard discrete MOS symbols.
|
||||
raw = definitions['NMOS']
|
||||
for unit in children(parse(raw), 'symbol'):
|
||||
for pin in sorted(children(unit, 'pin'), key=lambda p: p.start, reverse=True):
|
||||
kind = 'input' if child(pin, 'number')[1] == '1' else 'passive'
|
||||
raw = raw[:pin.start] + raw[pin.start:pin.end].replace('pin unspecified', 'pin ' + kind) + raw[pin.end:]
|
||||
definitions['NMOS'] = raw
|
||||
for ref, key in [('D2', 'Schottky'), ('C738', 'C')]:
|
||||
libid = child(original[ref], 'lib_id')[1].split(':')[1]
|
||||
definitions[key] = symbol_from(shared, libid, key)
|
||||
definitions['FanHeader'] = '''(symbol "FanHeader"
|
||||
(pin_names (offset 1.016)) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "J" (at 0 5.08 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Value" "47053-1000" (at 0 -10.16 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Footprint" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))
|
||||
(property "Datasheet" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))
|
||||
(symbol "FanHeader_0_1" (rectangle (start 0 2.54) (end 17.78 -10.16)
|
||||
(stroke (width 0.254) (type default)) (fill (type background))))
|
||||
(symbol "FanHeader_1_1"
|
||||
''' + ''.join(f'''(pin passive line (at -5.08 {-2.54*(i-1):g} 0) (length 5.08)
|
||||
(name "{name}" (effects (font (size 1.27 1.27))))
|
||||
(number "{i}" (effects (font (size 1.27 1.27)))))'''
|
||||
for i, name in enumerate(['GND', '+12V', 'TACH', 'PWM'], 1)) + '))'
|
||||
# These are generic drawing primitives. Do not retain the source part's
|
||||
# voltage rating, datasheet or footprint as inherited library metadata.
|
||||
for key, raw in definitions.items():
|
||||
edits = []
|
||||
for name, value in [('Value', key), ('Footprint', ''), ('Datasheet', ''),
|
||||
('Description', 'Four-wire prototype: see instance MPN and FAN_CIRCUIT.md')]:
|
||||
p = properties(parse(raw)).get(name)
|
||||
if p:
|
||||
edits.append((p.start, p.end, prop(name, value, 0, 0, name != 'Value')))
|
||||
for start, end, replacement in sorted(edits, reverse=True):
|
||||
raw = raw[:start] + replacement + raw[end:]
|
||||
definitions[key] = raw
|
||||
parts, pins, items = {}, {}, []
|
||||
|
||||
def add(ref, key, x, y, value, footprint, mpn, angle=0, manufacturer='', datasheet='', lcsc=''):
|
||||
assert ref not in parts
|
||||
sx = child(original[ref], 'uuid')[1] if ref in original else uid(ref)
|
||||
parts[ref] = sx
|
||||
node = parse(definitions[key])
|
||||
coords = {}
|
||||
for unit in children(node, 'symbol'):
|
||||
for pin in children(unit, 'pin'):
|
||||
px, py = map(float, child(pin, 'at')[1:3])
|
||||
t = math.radians(angle)
|
||||
coords[child(pin, 'number')[1]] = (round(x + px*math.cos(t) - py*math.sin(t), 4),
|
||||
round(y - px*math.sin(t) - py*math.cos(t), 4))
|
||||
pins[ref] = coords
|
||||
if key in {'NMOS', 'PMOS'}:
|
||||
tx, ty = x + (10.16 if angle == 0 else -12.7), y
|
||||
else:
|
||||
tx, ty = x, y - (5.08 if angle == 0 else 8.89)
|
||||
metadata = {'Footprint': footprint, 'MPN': mpn, 'Manufacturer': manufacturer,
|
||||
'Datasheet': datasheet, 'LCSC': lcsc}
|
||||
items.append(f'(symbol (lib_id "{LIB}:{key}") (at {x:g} {y:g} {angle}) (unit 1) '
|
||||
f'(in_bom yes) (on_board yes) (dnp no) (uuid "{sx}") '
|
||||
+ prop('Reference', ref, tx, ty - 2.54)
|
||||
+ prop('Value', value, tx, ty)
|
||||
+ ''.join(prop(k, v, x, y, True) for k, v in metadata.items())
|
||||
+ ''.join(f'(pin "{p}" (uuid "{uid(ref + "." + p)}"))' for p in coords)
|
||||
+ f'(instances (project "PhoneRail-Board1" (path "{sheet_path}" (reference "{ref}") (unit 1)))))')
|
||||
|
||||
def wire(a, b):
|
||||
assert a != b
|
||||
assert a[0] == b[0] or a[1] == b[1], (a, b)
|
||||
items.append(f'(wire (pts (xy {a[0]:g} {a[1]:g}) (xy {b[0]:g} {b[1]:g})) '
|
||||
f'(stroke (width 0) (type default)) (uuid "{uid(str((a,b)))}"))')
|
||||
|
||||
def join(*points):
|
||||
for a, b in zip(points, points[1:]):
|
||||
wire(a, b)
|
||||
|
||||
def label(net, pos, global_net=False):
|
||||
typ = 'global_label' if global_net or net in {'+12V', '+3V3', 'GND', 'FAN_EN', 'FAN_PWM', 'FAN_TACH_IN', 'FAN_TACH_OUT'} else 'label'
|
||||
extra = '(shape bidirectional)' if typ == 'global_label' else ''
|
||||
items.append(f'({typ} {quote(net)} {extra} (at {pos[0]:g} {pos[1]:g} 0) '
|
||||
f'(effects (font (size 1.27 1.27)) (justify left bottom)) (uuid "{uid(net+str(pos))}"))')
|
||||
|
||||
def pin(ref, number):
|
||||
return pins[ref][str(number)]
|
||||
|
||||
def terminal(ref, number, net, dx=0, dy=5.08):
|
||||
p = pin(ref, number)
|
||||
q = (round(p[0]+dx, 4), round(p[1]+dy, 4))
|
||||
wire(p, q)
|
||||
label(net, q)
|
||||
|
||||
def resistor(ref, value, x, y, angle=0):
|
||||
code = {1000: '1001', 10000: '1002', 100000: '1003'}[value]
|
||||
add(ref, 'R', x, y, f'{value//1000}k', 'PhoneRail:R0402', '0402WGF'+code+'TCE', angle, 'UNI-ROYAL')
|
||||
|
||||
def mos(ref, x, y, pm=False):
|
||||
mpn = 'AO3401A' if pm else 'AO3400A'
|
||||
add(ref, 'PMOS' if pm else 'NMOS', x, y, mpn,
|
||||
'PhoneRail:SOT-23-3_L2.9-W1.3-P1.90-LS2.4-BR', mpn,
|
||||
180 if pm else 0, 'Alpha & Omega Semiconductor',
|
||||
f'https://www.aosmd.com/sites/default/files/res/datasheets/{mpn}.pdf')
|
||||
|
||||
def note(x, y, text, size=1.27):
|
||||
items.append(f'(text {quote(text)} (at {x:g} {y:g} 0) '
|
||||
f'(effects (font (size {size} {size})) (justify left bottom)) (uuid "{uid(text)}"))')
|
||||
|
||||
note(15.24, 15.24, MARKER, 2.0)
|
||||
note(15.24, 22.86, '2x NF-A6x25 PWM / J26 intake / J27 exhaust. Vendor limits and prototype tests OPEN.')
|
||||
note(15.24, 30.48, 'HIGH-SIDE POWER: GPIO5 high = ON; hardware reset = OFF', 1.5)
|
||||
mos('Q7', 76.2, 55.88, True)
|
||||
terminal('Q7', 2, '+12V', dy=-15.24)
|
||||
terminal('Q7', 3, 'FAN_12V_SW')
|
||||
resistor('R740', 100000, 109.22, 40.64, 90)
|
||||
terminal('R740', 2, '+12V', dy=-5.08)
|
||||
join(pin('Q7', 1), (109.22, 55.88), pin('R740', 1))
|
||||
add('DZ3', 'Zener', 137.16, 40.64, 'BZT52C8V2-7-F', f'{LIB}:D_SOD-123', 'BZT52C8V2-7-F', 270, 'Diodes Incorporated', 'https://www.diodes.com/assets/Datasheets/ds18004.pdf')
|
||||
terminal('DZ3', 1, '+12V', dy=-5.08)
|
||||
join(pin('DZ3', 2), (137.16, 55.88), (109.22, 55.88))
|
||||
resistor('R741', 1000, 109.22, 76.2, 90)
|
||||
wire((109.22, 55.88), pin('R741', 2))
|
||||
mos('Q8', 106.68, 101.6)
|
||||
wire(pin('R741', 1), pin('Q8', 3))
|
||||
terminal('Q8', 2, 'GND')
|
||||
resistor('R742', 1000, 71.12, 101.6)
|
||||
terminal('R742', 1, 'FAN_EN', dx=-7.62, dy=0)
|
||||
join(pin('R742', 2), (91.44, 101.6), pin('Q8', 1))
|
||||
resistor('R743', 100000, 91.44, 119.38, 90)
|
||||
wire((91.44, 101.6), pin('R743', 2))
|
||||
terminal('R743', 1, 'GND')
|
||||
for ref, key, x, y in [('D2', 'Schottky', 27.94, 76.2), ('C738', 'C', 27.94, 114.3)]:
|
||||
p = properties(original[ref])
|
||||
add(ref, key, x, y, p['Value'][2], p['Footprint'][2], p['MPN'][2], 90,
|
||||
p['Manufacturer'][2], p['Datasheet'][2], p['LCSC'][2])
|
||||
# Imported diode: pin 1 cathode. Imported capacitor: pin 1 left.
|
||||
terminal(ref, 1, 'FAN_12V_SW', dx=-7.62, dy=0)
|
||||
terminal(ref, 2, 'GND', dx=7.62, dy=0)
|
||||
note(15.24, 142.24, 'Q7 body diode blocks +12V -> fan when OFF. No reverse isolation/current limiting.')
|
||||
note(15.24, 149.86, 'DZ3 clamps |Vgs|; validate clamp tolerance, startup SOA and short-circuit protection.')
|
||||
|
||||
note(15.24, 162.56, 'OPEN-DRAIN PWM: GPIO18 high pulls fan pin 4 LOW', 1.5)
|
||||
mos('Q6', 106.68, 193.04)
|
||||
resistor('R733', 1000, 71.12, 193.04)
|
||||
terminal('R733', 1, 'FAN_PWM', dx=-7.62, dy=0)
|
||||
join(pin('R733', 2), (91.44, 193.04), pin('Q6', 1))
|
||||
resistor('R734', 100000, 91.44, 213.36, 90)
|
||||
wire((91.44, 193.04), pin('R734', 2))
|
||||
terminal('R734', 1, 'GND')
|
||||
terminal('Q6', 2, 'GND')
|
||||
terminal('Q6', 3, 'FAN_PWM_OD', dy=-7.62)
|
||||
note(15.24, 238.76, '25kHz target; invert MCU duty: D_gpio = 1 - D_fan. Confirm vendor limits.')
|
||||
note(15.24, 246.38, 'Stop/reset: FAN_EN=0. PWM alone does not guarantee a stopped fan.')
|
||||
note(15.24, 254, 'Firmware: two counters, startup grace, per-fan stall fault and commanded-OFF mask.')
|
||||
note(15.24, 266.7, 'PROTOTYPE ONLY: see FAN_CIRCUIT.md. Not released for PCB fabrication.', 1.5)
|
||||
|
||||
for ref, x, raw in [('J26', 284.48, 'FAN_IN_TACH_RAW'), ('J27', 378.46, 'FAN_OUT_TACH_RAW')]:
|
||||
add(ref, 'FanHeader', x, 48.26, '47053-1000', f'{LIB}:FanPinHeader_1x04_P2.54mm_Vertical', '470531000', manufacturer='Molex')
|
||||
for p, net in enumerate(['GND', 'FAN_12V_SW', raw, 'FAN_PWM_OD'], 1):
|
||||
terminal(ref, p, net, dx=-7.62, dy=0)
|
||||
note(223.52, 76.2, 'Keyed PC fan header: 1 GND / 2 +12V / 3 TACH / 4 PWM.')
|
||||
note(223.52, 83.82, 'Confirm header drawing, key orientation and cable fit before layout.')
|
||||
note(223.52, 96.52, 'INDEPENDENT TACH: switched pullups avoid a 3V3 path into an unpowered fan', 1.5)
|
||||
for idx, y in enumerate([132.08, 215.9]):
|
||||
ref = ['Q9', 'Q10'][idx]
|
||||
raw = ['FAN_IN_TACH_RAW', 'FAN_OUT_TACH_RAW'][idx]
|
||||
output = ['FAN_TACH_IN', 'FAN_TACH_OUT'][idx]
|
||||
a, b, c, d = [f'R{744 + idx*4 + j}' for j in range(4)]
|
||||
mos(ref, 332.74, y)
|
||||
resistor(a, 10000, 246.38, y-17.78, 90)
|
||||
terminal(a, 2, 'FAN_12V_SW', dy=-5.08)
|
||||
label(raw, (246.38, y))
|
||||
wire(pin(a, 1), (246.38, y))
|
||||
resistor(b, 10000, 279.4, y)
|
||||
wire((246.38, y), pin(b, 1))
|
||||
join(pin(b, 2), (307.34, y), pin(ref, 1))
|
||||
resistor(c, 10000, 307.34, y+17.78, 90)
|
||||
wire((307.34, y), pin(c, 2))
|
||||
terminal(c, 1, 'GND')
|
||||
terminal(ref, 2, 'GND')
|
||||
resistor(d, 10000, 363.22, y-17.78, 90)
|
||||
terminal(d, 2, '+3V3', dy=-5.08)
|
||||
join(pin(ref, 3), (335.28, y-7.62), (363.22, y-7.62), pin(d, 1))
|
||||
terminal(d, 1, output, dx=7.62, dy=0)
|
||||
note(223.52, y+35.56, f'{output}: inverted RAW; MCU GPIO{16 if idx == 0 else 4}. Count one edge per pulse.')
|
||||
note(223.52, 264.16, 'RAW high: Vgate ~ Vfan/3. RAW low: Vgate ~ Vtach_low/2.')
|
||||
note(223.52, 271.78, 'Electrical limits, hot-plug/ESD, power sequencing and thermal validation remain open.')
|
||||
|
||||
# Explicit junctions for every drawn T connection, independent of label semantics.
|
||||
for x, y in [(109.22,55.88), (91.44,101.6), (91.44,193.04),
|
||||
(307.34,132.08), (307.34,215.9), (363.22,119.38), (363.22,203.2)]:
|
||||
items.append(f'(junction (at {x:g} {y:g}) (diameter 0) (color 0 0 0 0) (uuid "{uid("junction"+str((x,y)))}"))')
|
||||
|
||||
embedded = '\n'.join(raw.replace('(symbol "'+key+'"', '(symbol "'+LIB+':'+key+'"', 1) for key, raw in definitions.items())
|
||||
page = (f'(kicad_sch (version 20250610) (generator "eeschema") (uuid "{page_id}") (paper "A3") '
|
||||
'(title_block (title "P6-FAN-EDL / Four-wire fan prototype") (rev "FAN-R1") (date "2026-09-07")) '
|
||||
f'(lib_symbols {embedded})\n' + '\n'.join(items) + '\n(embedded_fonts no))\n')
|
||||
parse(page)
|
||||
existing_refs = set()
|
||||
for path in board.glob('*.kicad_sch'):
|
||||
if path != PAGE:
|
||||
existing_refs.update(properties(n)['Reference'][2] for n in children(parse(path.read_text(encoding='utf-8')), 'symbol'))
|
||||
assert not existing_refs.intersection(parts), existing_refs.intersection(parts)
|
||||
control = board / 'P3-CONTROL.kicad_sch'
|
||||
source = control.read_text(encoding='utf-8')
|
||||
control_root = parse(source)
|
||||
mcu = next(n for n in children(control_root, 'symbol') if properties(n)['Reference'][2] == 'U20')
|
||||
assert child(mcu, 'at')[1:] == ['64.77', '66.04', '0']
|
||||
io5 = (76.2, 27.94)
|
||||
assert 'FAN_EN' not in source
|
||||
for n in children(control_root, 'wire'):
|
||||
assert io5 not in [tuple(map(float, p[1:])) for p in children(child(n, 'pts'), 'xy')]
|
||||
extra = f'''(wire (pts (xy 76.2 27.94) (xy 91.44 27.94)) (stroke (width 0) (type default)) (uuid "{uid('io5-wire')}"))
|
||||
(global_label "FAN_EN" (shape input) (at 91.44 27.94 0) (effects (font (size 1.27 1.27)) (justify left)) (uuid "{uid('io5-label')}"))'''
|
||||
source = source[:control_root.end-1] + extra + source[control_root.end-1:]
|
||||
outputs = {PAGE: page, control: source,
|
||||
ROOT / f'lib/{LIB}.kicad_sym': '(kicad_symbol_lib (version 20241209) (generator "kicad_symbol_editor")\n' + '\n'.join(definitions.values()) + '\n)\n'}
|
||||
for name, category in [('FanPinHeader_1x04_P2.54mm_Vertical', 'Connector'), ('D_SOD-123', 'Diode_SMD')]:
|
||||
outputs[ROOT / f'lib/{LIB}.pretty/{name}.kicad_mod'] = (KICAD / f'footprints/{category}.pretty/{name}.kicad_mod').read_text(encoding='utf-8')
|
||||
for table, kind, uri in [('sym-lib-table', 'KiCad', f'${{KIPRJMOD}}/../lib/{LIB}.kicad_sym'),
|
||||
('fp-lib-table', 'KiCad', f'${{KIPRJMOD}}/../lib/{LIB}.pretty')]:
|
||||
path = board / table
|
||||
src = path.read_text(encoding='utf-8')
|
||||
end = parse(src).end - 1
|
||||
assert LIB not in src
|
||||
outputs[path] = src[:end] + f'(lib (name "{LIB}") (type "{kind}") (uri "{uri}") (options "") (descr "Four-wire prototype additions"))\n' + src[end:]
|
||||
print(f'P6: {len(parts)} parts; Board1 expected total {284 - 7 + len(parts)}')
|
||||
for path in outputs:
|
||||
print(path.relative_to(ROOT))
|
||||
if not args.apply:
|
||||
return
|
||||
backup = ROOT / 'out/before-four-wire'
|
||||
backup.mkdir(exist_ok=False)
|
||||
for path in [PAGE, control, board/'sym-lib-table', board/'fp-lib-table', ROOT/'out/Board1.xml', ROOT/'out/Board2.xml']:
|
||||
shutil.copy2(path, backup / path.name)
|
||||
for path, content in outputs.items():
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
path.write_text(content, encoding='utf-8', newline='\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Guarded Hub strap pulldown ECO and reviewed USB/LED NC markers."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
from bootstrap_pcb import native_net_names, xy
|
||||
from check_hardware import Board
|
||||
from optimize_pcb_layout import Layout, box_mm, expand, point, place_silkscreen
|
||||
from schematic_maintenance import ROOT, child, children, parse, properties
|
||||
from hub_strap_checks import STRAPS
|
||||
|
||||
OUT = ROOT / 'out/hub-straps-r1'
|
||||
CLI = 'D:/KiCad/bin/kicad-cli.exe'
|
||||
NC = {1: {'J22': ['A8', 'B8', 'A4B9', 'B4A9']},
|
||||
2: {'J23': ['A8', 'B8'], 'U32': ['14', '16', '18', '32']}}
|
||||
SHEETS = [ROOT / 'PhoneRail-Board1/P3-CONTROL.kicad_sch',
|
||||
ROOT / 'PhoneRail-Board2/C2-HUB.kicad_sch']
|
||||
PCB = ROOT / 'PhoneRail-Board2/PhoneRail-Board2.kicad_pcb'
|
||||
|
||||
|
||||
def uid(key):
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, 'PhoneRail/hub-straps-r1/' + key))
|
||||
|
||||
|
||||
def pin_position(root, instance, number):
|
||||
assert child(instance, 'mirror') is None, 'Mirror requires explicit geometry review'
|
||||
lib = next(s for s in children(child(root, 'lib_symbols'), 'symbol')
|
||||
if s[1] == child(instance, 'lib_id')[1])
|
||||
pin = next(p for u in children(lib, 'symbol') for p in children(u, 'pin')
|
||||
if child(p, 'number')[1] == str(number))
|
||||
x, y, angle = map(float, child(instance, 'at')[1:])
|
||||
px, py = map(float, child(pin, 'at')[1:3])
|
||||
a = math.radians(angle)
|
||||
return round(x + px*math.cos(a)-py*math.sin(a), 5), round(y-px*math.sin(a)-py*math.cos(a), 5)
|
||||
|
||||
|
||||
def label(name, x, y, key, global_label=False, angle=0):
|
||||
kind = 'global_label' if global_label else 'label'
|
||||
shape = '(shape input)' if global_label else ''
|
||||
return (f'({kind} "{name}" {shape} (at {x} {y} {angle}) '
|
||||
f'(effects (font (size 1 1)) (justify {"left" if angle == 0 else "right"} bottom)) (uuid "{uid(key)}"))')
|
||||
|
||||
|
||||
def layout_notes(source):
|
||||
root = parse(source)
|
||||
for note in sorted(children(root, 'text'), key=lambda n: n.start, reverse=True):
|
||||
source = source[:note.start] + source[note.end:]
|
||||
notes = [
|
||||
'CONFIG: CFG_SEL[2:0] = 000; internal defaults + straps.',
|
||||
'ROM 06h = 9Bh: self-powered operation.',
|
||||
'PRTPWR / OCS open by existing architecture.',
|
||||
'5V short-circuit / port OC response: REVIEW OPEN.',
|
||||
'R900-R909: 100k explicit strap-low (Fig 7-3).',
|
||||
'PRT_SWP[7:1] = 0: normal D+/D- polarity.',
|
||||
'BOOST[1:0] = 00; GANG_EN = 0.',
|
||||
'Ports 6/7: R714/R715 10k to +3V3_HUB.',
|
||||
'Port-disable / enumeration: verify on prototype.',
|
||||
'RBIAS: 12.0k 1%; Y1: 24MHz.',
|
||||
'VDD18/VDD18PLL: >=1uF, ESR <0.1 ohm.',
|
||||
'VBUS_DET: R709/R710 = 100k/100k.',
|
||||
'UP_VBUS_DET: R721/R722 = 10k/20k.',
|
||||
'NC: USB2-only SBU and four unused LED outputs.',
|
||||
]
|
||||
additions = []
|
||||
for index, text in enumerate(notes):
|
||||
additions.append(f'(text "{text}" (at 310 {45+index*7} 0) '
|
||||
'(effects (font (size 1.27 1.27)) (justify left bottom)) '
|
||||
f'(uuid "{uid("review-note-"+str(index))}"))')
|
||||
additions.append('(text "R900-R909: USB2517 strap-low pulldowns, 100k each" (at 25.4 216 0) '
|
||||
'(effects (font (size 1.27 1.27)) (justify left bottom)) '
|
||||
f'(uuid "{uid("bank-note")}"))')
|
||||
root = parse(source)
|
||||
return source[:root.end-1] + '\n'.join(additions) + '\n' + source[root.end-1:]
|
||||
|
||||
|
||||
def edit_sheets():
|
||||
for number, path in enumerate(SHEETS, 1):
|
||||
source = path.read_text(encoding='utf-8')
|
||||
root = parse(source)
|
||||
instances = {properties(s)['Reference'][2]: s for s in children(root, 'symbol')}
|
||||
additions = []
|
||||
for ref, pins in NC[number].items():
|
||||
for pin in pins:
|
||||
x, y = pin_position(root, instances[ref], pin)
|
||||
additions.append(f'(no_connect (at {x} {y}) (uuid "{uid(ref+pin)}"))')
|
||||
if number == 2:
|
||||
template_source = (ROOT / 'PhoneRail-Board2/C4-SITE1.kicad_sch').read_text(encoding='utf-8')
|
||||
template_root = parse(template_source)
|
||||
template = next(s for s in children(template_root, 'symbol') if properties(s)['Reference'][2] == 'R741')
|
||||
props = {k: p[2] for k, p in properties(template).items()}
|
||||
lib_id = child(template, 'lib_id')[1]
|
||||
library = child(root, 'lib_symbols')
|
||||
if not any(s[1] == lib_id for s in children(library, 'symbol')):
|
||||
lib = next(s for s in children(child(template_root, 'lib_symbols'), 'symbol') if s[1] == lib_id)
|
||||
source = source[:library.end-1] + template_source[lib.start:lib.end] + '\n' + source[library.end-1:]
|
||||
root = parse(source)
|
||||
sheet_path = child(child(child(instances['R711'], 'instances'), 'project'), 'path')[1]
|
||||
for index, (refnum, pin) in enumerate(STRAPS.items()):
|
||||
ref, name = f'R{refnum}', f'STRAP_{pin}'
|
||||
x, y = 25.4 + index*30.48, 236.22
|
||||
fields = []
|
||||
for key, value in {**props, 'Reference': ref}.items():
|
||||
visible = key in ('Reference', 'Value')
|
||||
fx, fy = (x+2.54, y+(2.54 if key == 'Value' else 0)) if visible else (x, y)
|
||||
fields.append(f'(property {json.dumps(key)} {json.dumps(value, ensure_ascii=False)} '
|
||||
f'(at {fx} {fy} 270) (effects (font (size 1.27 1.27)) '
|
||||
f'(justify left bottom) {"" if visible else "(hide yes)"}))')
|
||||
additions.append(f'(symbol (lib_id "{lib_id}") (at {x} {y} 90) (unit 1) '
|
||||
'(exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) '
|
||||
f'(uuid "{uid(ref)}") ' + ' '.join(fields) +
|
||||
f' (instances (project "PhoneRail-Board2" (path "{sheet_path}" '
|
||||
f'(reference "{ref}") (unit 1)))))')
|
||||
# The existing resistor symbol has pins at local x = +/-5.08.
|
||||
new_instance = parse(additions[-1])
|
||||
rx1, ry1 = pin_position(root, new_instance, 1)
|
||||
rx2, ry2 = pin_position(root, new_instance, 2)
|
||||
additions += [label('GND', rx1, ry1, ref+'gnd', True),
|
||||
label(name, rx2, ry2, ref+'net')]
|
||||
hx, hy = pin_position(root, instances['U32'], pin)
|
||||
additions.append(label(name, hx, hy, 'hub'+str(pin), angle=180 if hx < 128 else 0))
|
||||
additions.append('(text "R900-R909: 100k strap-low; USB2517 DS00001598C Fig 7-3." '
|
||||
'(at 25.4 216 0) (effects (font (size 1.27 1.27)) (justify left bottom)) '
|
||||
f'(uuid "{uid("note")}"))')
|
||||
source = source[:root.end-1] + '\n' + '\n'.join(additions) + '\n' + source[root.end-1:]
|
||||
if number == 2:
|
||||
source = layout_notes(source)
|
||||
path.write_text(source, encoding='utf-8')
|
||||
|
||||
|
||||
def sync_pcb(source):
|
||||
board = pcb.LoadBoard(str(PCB))
|
||||
names = native_net_names(source)
|
||||
nets = {n.GetNetname(): n for n in board.GetNetsByName().values()}
|
||||
for name in names.values():
|
||||
if name not in nets:
|
||||
net = pcb.NETINFO_ITEM(board, name)
|
||||
board.Add(net)
|
||||
nets[name] = net
|
||||
existing = {f.GetReference(): f for f in board.GetFootprints()}
|
||||
components = {c.attrib['ref']: c for c in ET.parse(OUT / 'after/Board2.xml').findall('components/comp')}
|
||||
root_uuid = child(parse(source.path.read_text(encoding='utf-8')), 'uuid')[1]
|
||||
layout = Layout.__new__(Layout)
|
||||
layout.number, layout.board, layout.parts = 2, board, existing
|
||||
layout.done, layout.placements = set(existing), {}
|
||||
layout.obstacles = [(r, expand(box_mm(f), .35),
|
||||
-1 if any(p.GetAttribute() in (pcb.PAD_ATTRIB_PTH, pcb.PAD_ATTRIB_NPTH)
|
||||
for p in f.Pads()) else f.GetLayer()) for r, f in existing.items()]
|
||||
for z in board.Zones():
|
||||
if z.GetIsRuleArea() and z.GetDoNotAllowFootprints():
|
||||
layout.obstacles.append((z.GetZoneName(), expand(box_mm(z), .25),
|
||||
-1 if z.GetLayerSet().Contains(pcb.F_Cu) else pcb.B_Cu))
|
||||
center = point(existing['U32'])
|
||||
for refnum, pin in STRAPS.items():
|
||||
ref = f'R{refnum}'
|
||||
c = components[ref]
|
||||
fp = pcb.FootprintLoad(str(ROOT / 'lib/PhoneRail.pretty'), 'R0402')
|
||||
fp.SetFPIDAsString('PhoneRail:R0402')
|
||||
fp.SetReference(ref)
|
||||
fp.SetValue(c.findtext('value'))
|
||||
fp.SetSheetname('C2-HUB')
|
||||
fp.SetSheetfile('C2-HUB.kicad_sch')
|
||||
for field in c.findall('fields/field'):
|
||||
if field.get('name') not in {'Reference', 'Value', 'Footprint'}:
|
||||
fp.SetField(field.get('name'), field.text or '')
|
||||
path = pcb.KIID_PATH()
|
||||
for key in [root_uuid, *c.find('sheetpath').get('tstamps').strip('/').split('/'), c.findtext('tstamps')]:
|
||||
path.push_back(pcb.KIID(key))
|
||||
fp.SetPath(path)
|
||||
board.Add(fp)
|
||||
existing[ref] = fp
|
||||
pad = next(p for p in existing['U32'].Pads() if p.GetNumber() == str(pin))
|
||||
px, py = point(pad)
|
||||
dx, dy = px-center[0], py-center[1]
|
||||
norm = math.hypot(dx, dy)
|
||||
layout.put(ref, (px+dx/norm*2, py+dy/norm*2), rotation=(0,90,180,270), radius=12,
|
||||
reason=f'100k pull-down for U32.{pin}; DS00001598C Fig 7-3')
|
||||
fp.Reference().SetLayer(pcb.B_Fab)
|
||||
fp.SetField('ReferenceOnFabOnly', 'yes')
|
||||
for field in fp.GetFields():
|
||||
field.SetVisible(field.GetName() == 'Reference')
|
||||
field.SetMirrored(True)
|
||||
for ref, fp in existing.items():
|
||||
for pad in fp.Pads():
|
||||
endpoint = (ref, pad.GetNumber())
|
||||
if endpoint in source.pins:
|
||||
pad.SetNet(nets[names[source.pins[endpoint]]])
|
||||
place_silkscreen(board, {'C745', 'C747', 'C748', 'R709', 'R714', 'R719'})
|
||||
pcb.SaveBoard(str(PCB), board)
|
||||
(OUT / 'placement.json').write_text(json.dumps(layout.placements, indent=2) + '\n')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--apply', action='store_true')
|
||||
args = ap.parse_args()
|
||||
if not args.apply:
|
||||
raise SystemExit('Use --apply once; validate with verify_erc_disposition.py afterwards')
|
||||
before = OUT / 'before'
|
||||
if before.exists():
|
||||
raise RuntimeError('Existing ECO baseline; refusing to repeat mutations')
|
||||
before.mkdir(parents=True)
|
||||
for path in [*SHEETS, PCB]:
|
||||
shutil.copy2(path, before / path.name)
|
||||
for number in (1, 2):
|
||||
Board(number).compile(CLI, before)
|
||||
edit_sheets()
|
||||
after = OUT / 'after'
|
||||
after.mkdir()
|
||||
for number in (1, 2):
|
||||
board = Board(number)
|
||||
board.compile(CLI, after)
|
||||
if number == 2:
|
||||
sync_pcb(board)
|
||||
hashes = {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in [*SHEETS, PCB]}
|
||||
(OUT / 'applied.json').write_text(json.dumps(hashes, indent=2) + '\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Route ten explicitly reviewed Buck local connections using native KiCad DRC."""
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
from schematic_maintenance import ROOT
|
||||
from verify_pcb_layout import routing
|
||||
|
||||
OUT = ROOT / 'out/local-routing-r1'
|
||||
BOARD = ROOT / 'PhoneRail-Board1/PhoneRail-Board1.kicad_pcb'
|
||||
CLI = 'D:/KiCad/bin/kicad-cli.exe'
|
||||
|
||||
|
||||
def drc(candidate, name):
|
||||
report = OUT / f'{name}.json'
|
||||
result = subprocess.run([CLI, 'pcb', 'drc', '--format', 'json', '-o', str(report), str(candidate)],
|
||||
capture_output=True, encoding='utf-8', errors='replace', timeout=90)
|
||||
report.with_suffix('.log').write_text(result.stdout+result.stderr, encoding='utf-8')
|
||||
assert result.returncode == 0 and report.exists(), result.stderr
|
||||
return json.loads(report.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def unconnected_count(board):
|
||||
board.BuildConnectivity()
|
||||
board.GetConnectivity().RecalculateRatsnest()
|
||||
return board.GetConnectivity().GetUnconnectedCount(False)
|
||||
|
||||
|
||||
def main():
|
||||
if (OUT / 'verification.json').exists():
|
||||
raise RuntimeError('Routing checkpoint already exists; do not overwrite later copper')
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
board = pcb.LoadBoard(str(BOARD))
|
||||
assert not list(board.GetTracks()), 'This first local routing pass requires an unrouted board'
|
||||
initial_count = unconnected_count(board)
|
||||
start_hash = hashlib.sha256(BOARD.read_bytes()).hexdigest()
|
||||
backup = OUT / 'before'
|
||||
backup.mkdir(exist_ok=True)
|
||||
shutil.copy2(BOARD, backup / BOARD.name)
|
||||
contract = json.loads((ROOT / 'pcb-routing-rules.json').read_text(encoding='utf-8'))
|
||||
candidate = BOARD.parent / 'PhoneRail-Board1-local-candidate.kicad_pcb'
|
||||
temporary = [candidate, candidate.with_suffix('.kicad_prl')]
|
||||
assert all(not p.exists() for p in temporary), 'Candidate files already exist'
|
||||
for suffix in ('.kicad_pro', '.kicad_dru'):
|
||||
dest = candidate.with_suffix(suffix)
|
||||
assert not dest.exists(), dest
|
||||
shutil.copy2(BOARD.with_suffix(suffix), dest)
|
||||
temporary.append(dest)
|
||||
parts = {f.GetReference(): f for f in board.GetFootprints()}
|
||||
accepted, blocked, exceptions = [], [], []
|
||||
try:
|
||||
pcb.SaveBoard(str(candidate), board)
|
||||
initial = drc(candidate, 'baseline-drc')
|
||||
assert not initial.get('violations'), 'Baseline geometry is not clean'
|
||||
for ch in range(1, 6):
|
||||
for function, pin, ref, other_pin in [('frequency', '1', f'R{ch}03', '2'),
|
||||
('feedback_top', '7', f'R{ch}01', '2')]:
|
||||
source = next(p for p in parts[f'U{ch}'].Pads() if p.GetNumber() == pin)
|
||||
target = next(p for p in parts[ref].Pads() if p.GetNumber() == other_pin)
|
||||
assert source.GetNetCode() == target.GetNetCode() and source.GetNetCode() > 0
|
||||
assert source.IsOnLayer(pcb.F_Cu) and target.IsOnLayer(pcb.F_Cu)
|
||||
a, b = source.GetPosition(), target.GetPosition()
|
||||
key = f'CH{ch}-{function}'
|
||||
success = False
|
||||
dx, dy = b.x-a.x, b.y-a.y
|
||||
assert abs(dy) > abs(dx)
|
||||
sx, sy = (1 if dx > 0 else -1), (1 if dy > 0 else -1)
|
||||
for variant, escape in enumerate((.5, .7, .9)):
|
||||
offset = pcb.FromMM(escape)
|
||||
assert offset + abs(dx) < abs(dy)
|
||||
c = pcb.VECTOR2I(a.x, a.y+sy*offset)
|
||||
d = pcb.VECTOR2I(b.x, c.y+sy*abs(dx))
|
||||
tracks = []
|
||||
for start, end in ((a, c), (c, d), (d, b)):
|
||||
if start == end:
|
||||
continue
|
||||
t = pcb.PCB_TRACK(board)
|
||||
t.SetStart(start)
|
||||
t.SetEnd(end)
|
||||
t.SetLayer(pcb.F_Cu)
|
||||
t.SetWidth(pcb.FromMM(.2))
|
||||
t.SetNet(source.GetNet())
|
||||
board.Add(t)
|
||||
tracks.append(t)
|
||||
ids = [t.m_Uuid.AsString() for t in tracks]
|
||||
length = sum(pcb.ToMM(t.GetLength()) for t in tracks)
|
||||
exception = function == 'frequency' and length <= 3
|
||||
if exception:
|
||||
contract['direction']['exceptions'].extend(ids)
|
||||
direction = routing(board, contract, 1)
|
||||
if not direction['errors']:
|
||||
pcb.SaveBoard(str(candidate), board)
|
||||
report = drc(candidate, f'{key}-v{variant}')
|
||||
if not report.get('violations'):
|
||||
accepted.append({'connection': key, 'from': [f'U{ch}', pin], 'to': [ref, other_pin],
|
||||
'net': source.GetNetname(), 'width_mm': .2, 'layer': 'F.Cu',
|
||||
'length_mm': round(sum(pcb.ToMM(t.GetLength()) for t in tracks), 5),
|
||||
'track_uuids': [t.m_Uuid.AsString() for t in tracks]})
|
||||
success = True
|
||||
if exception:
|
||||
exceptions.append({'connection': key, 'uuids': ids, 'length_mm': round(length, 5),
|
||||
'reason': 'Reviewed local RT/CLK resistor link, 45-degree bend and no via; 2mm axial separation plus pad offset'})
|
||||
print('ROUTED', key, flush=True)
|
||||
break
|
||||
for t in tracks:
|
||||
board.Remove(t)
|
||||
if exception:
|
||||
contract['direction']['exceptions'] = [u for u in contract['direction']['exceptions'] if u not in ids]
|
||||
if not success:
|
||||
blocked.append(key)
|
||||
board.GetTitleBlock().SetTitle('PhoneRail Board 1 - partial local routing')
|
||||
board.GetTitleBlock().SetRevision('R3-LOCAL-ROUTING')
|
||||
for drawing in board.GetDrawings():
|
||||
if isinstance(drawing, pcb.PCB_TEXT) and drawing.GetText() == 'BOARD 1 R2 - PLACED / UNROUTED':
|
||||
drawing.SetText('BOARD 1 R3 - PARTIAL LOCAL ROUTING')
|
||||
pcb.SaveBoard(str(candidate), board)
|
||||
final = drc(candidate, 'final-drc')
|
||||
assert not final.get('violations') and not routing(board, contract, 1)['errors']
|
||||
reloaded = pcb.LoadBoard(str(candidate))
|
||||
final_count = unconnected_count(reloaded)
|
||||
reduction = initial_count - final_count
|
||||
assert reduction == len(accepted), (reduction, len(accepted))
|
||||
assert hashlib.sha256(BOARD.read_bytes()).hexdigest() == start_hash, 'Source changed during candidate review'
|
||||
assert accepted, 'No reviewed local connection could be routed'
|
||||
shutil.copy2(candidate, BOARD)
|
||||
contract['direction'].setdefault('exception_reviews', []).extend(exceptions)
|
||||
(ROOT / 'pcb-routing-rules.json').write_text(json.dumps(contract, indent=2, ensure_ascii=False)+'\n', encoding='utf-8')
|
||||
result = {'status': 'PARTIAL_LOCAL_ROUTING_NOT_RELEASED', 'accepted': accepted, 'blocked': blocked,
|
||||
'native_drc_violations': 0, 'direction_errors': 0, 'reviewed_direction_exceptions': exceptions,
|
||||
'unconnected_before': initial_count,
|
||||
'unconnected_after': final_count,
|
||||
'drc_report_unconnected_items': len(final['unconnected_items']),
|
||||
'count_source': 'Native CONNECTIVITY_DATA, all nets including hidden ratsnest; DRC report list is incomplete',
|
||||
'track_segments': len(list(board.GetTracks())), 'vias': 0,
|
||||
'limits': 'Only frequency setting and feedback top resistor links; complete feedback return, Kelvin, power and reference copper remain open',
|
||||
'board_sha256': hashlib.sha256(BOARD.read_bytes()).hexdigest()}
|
||||
(OUT / 'verification.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(json.dumps({k: v for k, v in result.items() if k != 'accepted'}))
|
||||
finally:
|
||||
for path in temporary:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Extend existing Buck local copper through DRC-gated candidate routing."""
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
import route_local_r1 as base
|
||||
from schematic_maintenance import ROOT
|
||||
from verify_pcb_layout import routing
|
||||
|
||||
OUT = ROOT / 'out/local-routing-r2'
|
||||
BOARD = base.BOARD
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def paths(a, b, axis):
|
||||
# Escape the IC away from its adjacent pins, then use a 45-degree bend.
|
||||
for escape in (.7, .9, 1.1, 1.4):
|
||||
sx = 1 if b.x >= a.x else -1
|
||||
sy = 1 if b.y >= a.y else -1
|
||||
c = pcb.VECTOR2I(a.x + (sx * pcb.FromMM(escape) if axis == 'x' else 0),
|
||||
a.y + (sy * pcb.FromMM(escape) if axis == 'y' else 0))
|
||||
dx, dy = b.x-c.x, b.y-c.y
|
||||
step = min(abs(dx), abs(dy))
|
||||
for diagonal_first in (True, False):
|
||||
d = (pcb.VECTOR2I(c.x+(step if dx >= 0 else -step), c.y+(step if dy >= 0 else -step))
|
||||
if diagonal_first else
|
||||
pcb.VECTOR2I(b.x-(step if dx >= 0 else -step), b.y-(step if dy >= 0 else -step)))
|
||||
yield [a, c, d, b]
|
||||
|
||||
|
||||
def main():
|
||||
assert not (OUT / 'verification.json').exists(), 'Completed checkpoint exists'
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
base.OUT = OUT
|
||||
rules_path = ROOT / 'pcb-routing-rules.json'
|
||||
hashes = {p: digest(p) for p in (BOARD, rules_path)}
|
||||
backup = OUT / 'before'
|
||||
assert not backup.exists(), 'Existing attempt requires review before retry'
|
||||
backup.mkdir()
|
||||
for p in hashes:
|
||||
shutil.copy2(p, backup / p.name)
|
||||
contract = json.loads(rules_path.read_text(encoding='utf-8'))
|
||||
board = pcb.LoadBoard(str(BOARD))
|
||||
initial = base.unconnected_count(board)
|
||||
original_tracks = {t.m_Uuid.AsString() for t in board.GetTracks()}
|
||||
parts = {f.GetReference(): f for f in board.GetFootprints()}
|
||||
candidate = BOARD.with_name('PhoneRail-Board1-local-r2-candidate.kicad_pcb')
|
||||
temporary = [candidate.with_suffix(s) for s in ('.kicad_pcb', '.kicad_pro', '.kicad_dru', '.kicad_prl')]
|
||||
assert all(not p.exists() for p in temporary)
|
||||
accepted, blocked, reviews = [], [], []
|
||||
try:
|
||||
for suffix in ('.kicad_pro', '.kicad_dru'):
|
||||
shutil.copy2(BOARD.with_suffix(suffix), candidate.with_suffix(suffix))
|
||||
pcb.SaveBoard(str(candidate), board)
|
||||
assert not base.drc(candidate, 'baseline-drc')['violations']
|
||||
for ch in range(1, 6):
|
||||
links = [
|
||||
('comp_series', f'R{ch}06', '2', f'C{ch}10', '1', 'y'),
|
||||
('comp_drive', f'U{ch}', '8', f'R{ch}06', '1', 'y'),
|
||||
('comp_hf', f'U{ch}', '8', f'C{ch}11', '1', 'y'),
|
||||
('soft_start', f'U{ch}', '9', f'C{ch}09', '1', 'x'),
|
||||
('feedback_divider', f'R{ch}01', '2', f'R{ch}02', '1', 'x'),
|
||||
]
|
||||
for function, ref, pin, dest, dest_pin, axis in links:
|
||||
source = next(p for p in parts[ref].Pads() if p.GetNumber() == pin)
|
||||
target = next(p for p in parts[dest].Pads() if p.GetNumber() == dest_pin)
|
||||
assert source.GetNetCode() > 0 and source.GetNetCode() == target.GetNetCode()
|
||||
assert source.IsOnLayer(pcb.F_Cu) and target.IsOnLayer(pcb.F_Cu)
|
||||
key = f'CH{ch}-{function}'
|
||||
failures = []
|
||||
for variant, points in enumerate(paths(source.GetPosition(), target.GetPosition(), axis)):
|
||||
tracks = []
|
||||
for a, b in zip(points, points[1:]):
|
||||
if a == b:
|
||||
continue
|
||||
t = pcb.PCB_TRACK(board)
|
||||
t.SetStart(a)
|
||||
t.SetEnd(b)
|
||||
t.SetLayer(pcb.F_Cu)
|
||||
t.SetWidth(pcb.FromMM(.2))
|
||||
t.SetNet(source.GetNet())
|
||||
board.Add(t)
|
||||
tracks.append(t)
|
||||
ids = [t.m_Uuid.AsString() for t in tracks]
|
||||
length = sum(pcb.ToMM(t.GetLength()) for t in tracks)
|
||||
assert length < 8, (key, length)
|
||||
contract['direction']['exceptions'].extend(ids)
|
||||
pcb.SaveBoard(str(candidate), board)
|
||||
report = base.drc(candidate, f'{key}-v{variant}')
|
||||
if not report['violations'] and not routing(board, contract, 1)['errors']:
|
||||
accepted.append({'connection': key, 'from': [ref, pin], 'to': [dest, dest_pin],
|
||||
'net': source.GetNetname(), 'width_mm': .2, 'layer': 'F.Cu',
|
||||
'length_mm': round(length, 5), 'track_uuids': ids})
|
||||
reviews.append({'connection': key, 'uuids': ids, 'length_mm': round(length, 5),
|
||||
'reason': 'Buck local compensation, soft-start or divider connection; same-face short path avoids signal vias; ground return remains pending'})
|
||||
print('ROUTED', key, flush=True)
|
||||
break
|
||||
failures.append(sorted({v['type'] for v in report['violations']}))
|
||||
for t in tracks:
|
||||
board.Remove(t)
|
||||
contract['direction']['exceptions'] = [u for u in contract['direction']['exceptions'] if u not in ids]
|
||||
else:
|
||||
blocked.append({'connection': key, 'candidate_violations': failures})
|
||||
print('BLOCKED', key, flush=True)
|
||||
assert accepted, 'No acceptable routes'
|
||||
pcb.SaveBoard(str(candidate), board)
|
||||
final = base.drc(candidate, 'final-drc')
|
||||
reloaded = pcb.LoadBoard(str(candidate))
|
||||
remaining = base.unconnected_count(reloaded)
|
||||
assert initial - remaining == len(accepted), (initial, remaining, len(accepted))
|
||||
assert original_tracks <= {t.m_Uuid.AsString() for t in reloaded.GetTracks()}
|
||||
assert not final['violations'] and not routing(reloaded, contract, 1)['errors']
|
||||
assert all(digest(p) == h for p, h in hashes.items()), 'Source changed during review'
|
||||
contract['direction'].setdefault('exception_reviews', []).extend(reviews)
|
||||
shutil.copy2(candidate, BOARD)
|
||||
rules_path.write_text(json.dumps(contract, indent=2, ensure_ascii=False)+'\n', encoding='utf-8')
|
||||
result = {'status': 'PARTIAL_LOCAL_ROUTING_NOT_RELEASED', 'accepted': accepted, 'blocked': blocked,
|
||||
'unconnected_before': initial, 'unconnected_after': remaining,
|
||||
'native_drc_violations': 0, 'direction_errors': 0,
|
||||
'track_segments': len(list(reloaded.GetTracks())), 'vias': 0,
|
||||
'reviewed_direction_exceptions': reviews, 'board_sha256': digest(BOARD),
|
||||
'limits': 'Local signal copper only; quiet ground, power loops, Kelvin and reference planes remain incomplete'}
|
||||
(OUT / 'verification.json').write_text(json.dumps(result, indent=2)+'\n', encoding='utf-8')
|
||||
print('COMPLETE', len(accepted), 'connections;', remaining, 'remaining', flush=True)
|
||||
finally:
|
||||
for path in temporary:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Route independent shunt sense branches and reviewed local INA/eFuse signals."""
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
import route_local_r1 as base
|
||||
from fix_input_connector import copper, snapshot
|
||||
from route_local_r2 import paths
|
||||
from schematic_maintenance import ROOT
|
||||
from verify_pcb_layout import routing
|
||||
|
||||
OUT = ROOT / 'out/local-routing-r3'
|
||||
BOARD = base.BOARD
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
assert not OUT.exists(), 'Existing attempt or checkpoint requires review'
|
||||
OUT.mkdir(parents=True)
|
||||
base.OUT = OUT
|
||||
rules = ROOT / 'pcb-routing-rules.json'
|
||||
hashes = {p: digest(p) for p in (BOARD, rules)}
|
||||
backup = OUT / 'before'
|
||||
backup.mkdir()
|
||||
for p in hashes:
|
||||
shutil.copy2(p, backup / p.name)
|
||||
contract = json.loads(rules.read_text(encoding='utf-8'))
|
||||
board = pcb.LoadBoard(str(BOARD))
|
||||
original_parts, original_copper = snapshot(board), copper(board)
|
||||
initial = base.unconnected_count(board)
|
||||
parts = {f.GetReference(): f for f in board.GetFootprints()}
|
||||
candidate = BOARD.with_name('PhoneRail-Board1-local-r3-candidate.kicad_pcb')
|
||||
temporary = [candidate.with_suffix(s) for s in ('.kicad_pcb', '.kicad_pro', '.kicad_dru', '.kicad_prl')]
|
||||
assert all(not p.exists() for p in temporary)
|
||||
accepted, blocked, reviews = [], [], []
|
||||
try:
|
||||
for suffix in ('.kicad_pro', '.kicad_dru'):
|
||||
shutil.copy2(BOARD.with_suffix(suffix), candidate.with_suffix(suffix))
|
||||
pcb.SaveBoard(str(candidate), board)
|
||||
assert not base.drc(candidate, 'baseline-drc')['violations']
|
||||
for ch in range(1, 6):
|
||||
links = [
|
||||
('kelvin_positive', f'U{ch+7}', '10', f'Rs{ch}01', '2', 'y', .2),
|
||||
('kelvin_negative', f'U{ch+7}', '9', f'Rs{ch}01', '1', 'y', .2),
|
||||
('ina_bypass_supply', f'U{ch+7}', '6', f'C{ch}13', '1', 'x', .25),
|
||||
('efuse_enable_bias', f'U{ch+13}', '14', f'R{ch}14', '1', 'x', .2),
|
||||
]
|
||||
for function, ref, pin, dest, dest_pin, axis, width in links:
|
||||
source = next(p for p in parts[ref].Pads() if p.GetNumber() == pin)
|
||||
target = next(p for p in parts[dest].Pads() if p.GetNumber() == dest_pin)
|
||||
assert source.GetNetCode() > 0 and source.GetNetCode() == target.GetNetCode()
|
||||
assert source.IsOnLayer(pcb.F_Cu) and target.IsOnLayer(pcb.F_Cu)
|
||||
key = f'CH{ch}-{function}'
|
||||
failures = []
|
||||
for variant, points in enumerate(paths(source.GetPosition(), target.GetPosition(), axis)):
|
||||
tracks = []
|
||||
for a, b in zip(points, points[1:]):
|
||||
if a == b:
|
||||
continue
|
||||
t = pcb.PCB_TRACK(board)
|
||||
t.SetStart(a)
|
||||
t.SetEnd(b)
|
||||
t.SetLayer(pcb.F_Cu)
|
||||
t.SetWidth(pcb.FromMM(width))
|
||||
t.SetNet(source.GetNet())
|
||||
board.Add(t)
|
||||
tracks.append(t)
|
||||
ids = [t.m_Uuid.AsString() for t in tracks]
|
||||
length = sum(pcb.ToMM(t.GetLength()) for t in tracks)
|
||||
assert length < 8, (key, length)
|
||||
contract['direction']['exceptions'].extend(ids)
|
||||
pcb.SaveBoard(str(candidate), board)
|
||||
report = base.drc(candidate, f'{key}-v{variant}')
|
||||
if not report['violations'] and not routing(board, contract, 1)['errors']:
|
||||
accepted.append({'connection': key, 'from': [ref, pin], 'to': [dest, dest_pin],
|
||||
'net': source.GetNetname(), 'width_mm': width, 'layer': 'F.Cu',
|
||||
'length_mm': round(length, 5), 'track_uuids': ids,
|
||||
'role': 'MEASUREMENT_BRANCH_NOT_LOAD_CURRENT' if function.startswith('kelvin') else 'LOCAL_SIGNAL_OR_BYPASS'})
|
||||
reviews.append({'connection': key, 'uuids': ids, 'length_mm': round(length, 5),
|
||||
'reason': 'Short same-face shunt-to-INA measurement or local bias/bypass link; no vias; power-current copper and ground remain pending'})
|
||||
print('ROUTED', key, flush=True)
|
||||
break
|
||||
failures.append(sorted({v['type'] for v in report['violations']}))
|
||||
for t in tracks:
|
||||
board.Remove(t)
|
||||
contract['direction']['exceptions'] = [u for u in contract['direction']['exceptions'] if u not in ids]
|
||||
else:
|
||||
blocked.append({'connection': key, 'candidate_violations': failures})
|
||||
print('BLOCKED', key, flush=True)
|
||||
assert accepted, 'No acceptable routes'
|
||||
pcb.SaveBoard(str(candidate), board)
|
||||
final = base.drc(candidate, 'final-drc')
|
||||
reloaded = pcb.LoadBoard(str(candidate))
|
||||
remaining = base.unconnected_count(reloaded)
|
||||
assert initial - remaining == len(accepted), (initial, remaining, len(accepted))
|
||||
assert snapshot(reloaded) == original_parts
|
||||
final_copper = copper(reloaded)
|
||||
assert all(final_copper[k] == v for k, v in original_copper.items())
|
||||
assert not final['violations'] and not routing(reloaded, contract, 1)['errors']
|
||||
assert all(digest(p) == h for p, h in hashes.items()), 'Source changed during review'
|
||||
contract['direction'].setdefault('exception_reviews', []).extend(reviews)
|
||||
contract.setdefault('measurement_branches', []).extend(v for v in accepted if v['role'] == 'MEASUREMENT_BRANCH_NOT_LOAD_CURRENT')
|
||||
shutil.copy2(candidate, BOARD)
|
||||
rules.write_text(json.dumps(contract, indent=2, ensure_ascii=False)+'\n', encoding='utf-8')
|
||||
result = {'status': 'PARTIAL_LOCAL_ROUTING_NOT_RELEASED', 'accepted': accepted, 'blocked': blocked,
|
||||
'unconnected_before': initial, 'unconnected_after': remaining,
|
||||
'native_drc_violations': 0, 'direction_errors': 0,
|
||||
'track_segments': len(list(reloaded.GetTracks())), 'vias': 0,
|
||||
'original_tracks_preserved': len(original_copper), 'unchanged_footprints': len(original_parts),
|
||||
'reviewed_direction_exceptions': reviews, 'board_sha256': digest(BOARD),
|
||||
'limits': 'Sense branches do not carry load current. Current-entry geometry of the two-terminal shunt lands, ground returns and complete power routing remain pending'}
|
||||
(OUT / 'verification.json').write_text(json.dumps(result, indent=2)+'\n', encoding='utf-8')
|
||||
print('COMPLETE', len(accepted), 'connections;', remaining, 'remaining', flush=True)
|
||||
finally:
|
||||
for path in temporary:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Scoped migration cleanup; preserve all untouched S-expression source spans."""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TOKEN = re.compile(r'"(?:\\.|[^"\\])*"|[()]|[^\s()]+')
|
||||
# Pin numbers/types cross-checked against KiCad 74xx:74HC595.
|
||||
HC595_PINS = {
|
||||
**{str(i): (f'Q{i}', 'tri_state') for i in range(1, 8)},
|
||||
'8': ('GND', 'power_in'), '9': ('Q7S', 'output'),
|
||||
'10': ('MR#', 'input'), '11': ('SHCP', 'input'),
|
||||
'12': ('STCP', 'input'), '13': ('OE#', 'input'),
|
||||
'14': ('DS', 'input'), '15': ('Q0', 'tri_state'),
|
||||
'16': ('VCC', 'power_in'),
|
||||
}
|
||||
# Reviewed dangling extensions from the migration baseline, not arbitrary ERC errors.
|
||||
WIRE_STUBS = {
|
||||
'a65a7700-a243-55e0-8007-fd0b3d962b61', '0a207484-be28-5acb-b11a-f92435941381',
|
||||
'6cd4551d-b945-53d0-80d0-21d019ad50b8', '503168b2-0b79-59b7-bc16-76fc375faae5',
|
||||
'441e621d-54a9-5730-ba9d-e935bb1fb902', 'b8b87b0a-b722-5042-99c4-f48791a7a13a',
|
||||
'f0891bf2-0ec0-5b8f-a2e7-8aae61bc25c3', '543b8815-4995-5fb6-808d-969700a6774a',
|
||||
'8a9d3b65-768d-59a5-a13f-19fae717ebc4', '32a92a88-d05a-56aa-9132-fde8e08df99b',
|
||||
'5c2de7b9-896a-5a8b-9cfa-054a4be9c2c4', '33ec1b87-42b3-5371-95f4-c14859255644',
|
||||
'15fea150-9f17-5ddd-97f0-f3618ed282ae', '56ba2d24-026a-56c4-a8b9-f2fe385f63aa',
|
||||
'0a7ffeba-9293-5255-8d6d-e59c113ecf11', '4d8be1e0-5a90-519d-bd30-3b2092db6667',
|
||||
'5939369d-292c-5284-aba7-35b9e8c034eb',
|
||||
}
|
||||
|
||||
|
||||
class Node(list):
|
||||
def __init__(self, start):
|
||||
super().__init__()
|
||||
self.start = start
|
||||
self.end = None
|
||||
|
||||
|
||||
def parse(source):
|
||||
stack = []
|
||||
root = None
|
||||
for match in TOKEN.finditer(source):
|
||||
token = match.group()
|
||||
if token == '(':
|
||||
node = Node(match.start())
|
||||
if stack:
|
||||
stack[-1].append(node)
|
||||
else:
|
||||
if root is not None:
|
||||
raise ValueError('Multiple roots')
|
||||
root = node
|
||||
stack.append(node)
|
||||
elif token == ')':
|
||||
stack.pop().end = match.end()
|
||||
else:
|
||||
stack[-1].append(json.loads(token) if token.startswith('"') else token)
|
||||
if stack or root is None:
|
||||
raise ValueError('Unbalanced S-expression')
|
||||
return root
|
||||
|
||||
|
||||
def children(node, key):
|
||||
return [x for x in node if isinstance(x, Node) and x[0] == key]
|
||||
|
||||
|
||||
def child(node, key):
|
||||
return next(iter(children(node, key)), None)
|
||||
|
||||
|
||||
def properties(node):
|
||||
return {x[1]: x for x in children(node, 'property')}
|
||||
|
||||
|
||||
def cleanup(path, apply=False):
|
||||
source = path.read_text(encoding='utf-8')
|
||||
root = parse(source)
|
||||
edits = []
|
||||
counts = Counter()
|
||||
replace_fan_notes = (path.name == 'P6-FAN-EDL.kicad_sch'
|
||||
and '风扇电路审查:FR-10 尚未满足' not in source
|
||||
and 'TWO-FAN PROTOTYPE: INTAKE + EXHAUST' not in source
|
||||
and 'FOUR-WIRE FAN PROTOTYPE R1' not in source
|
||||
and 'SANYO FAN SELECTION R2' not in source)
|
||||
replace_site_notes = path.name.startswith('C4-SITE') and '侧键采用裸铜压接,非采购元件' not in source
|
||||
replace_keys_notes = path.name == 'C6-KEYS.kicad_sch' and '控制链核查:四个未用输出已逐脚标 NC' not in source
|
||||
|
||||
def replace(node, replacement, reason):
|
||||
old = source[node.start:node.end]
|
||||
if replacement != old:
|
||||
edits.append((node.start, node.end, replacement))
|
||||
counts[reason] += 1
|
||||
|
||||
libraries = children(root, 'symbol') if root[0] == 'kicad_symbol_lib' else children(child(root, 'lib_symbols'), 'symbol')
|
||||
for symbol in libraries:
|
||||
props = properties(symbol)
|
||||
ref = props['Reference'][2]
|
||||
if symbol[1].removeprefix('PhoneRail:') == '74HC595D,118':
|
||||
pins = [p for unit in children(symbol, 'symbol') for p in children(unit, 'pin')]
|
||||
assert {child(p, 'number')[1] for p in pins} == set(HC595_PINS)
|
||||
for pin in pins:
|
||||
name, kind = HC595_PINS[child(pin, 'number')[1]]
|
||||
assert child(pin, 'name')[1] == name
|
||||
replace(pin, re.sub(r'^\(pin\s+\S+', '(pin ' + kind, source[pin.start:pin.end]), 'hc595_pin_types')
|
||||
if ref not in {'R', 'Rs', 'RT', 'C', 'L', 'F', 'Y', 'J', 'D', 'DZ'}:
|
||||
continue
|
||||
for unit in children(symbol, 'symbol'):
|
||||
for pin in children(unit, 'pin'):
|
||||
raw = source[pin.start:pin.end]
|
||||
replace(pin, re.sub(r'^\(pin\s+\S+', '(pin passive', raw), 'passive_pins')
|
||||
|
||||
if root[0] == 'kicad_sch':
|
||||
for wire in children(root, 'wire'):
|
||||
if child(wire, 'uuid')[1] in WIRE_STUBS:
|
||||
replace(wire, '', 'reviewed_wire_stubs')
|
||||
for symbol in children(root, 'symbol'):
|
||||
props = properties(symbol)
|
||||
ref = props['Reference'][2]
|
||||
if replace_site_notes and props.get('Value', ['', '', ''])[2].startswith('6.3TPV2200'):
|
||||
p = props['Value']
|
||||
replace(p, re.sub(r'^(\(property "Value"\s+)"[^"]*"', r'\1"2200uF / 6.3V"', source[p.start:p.end]), 'capacitor_label')
|
||||
if ref.startswith('#PWR'):
|
||||
for key in ('in_bom', 'on_board'):
|
||||
replace(child(symbol, key), f'({key} no)', 'power_metadata')
|
||||
if re.fullmatch(r'J(?:4[1-9]|5[0-9]|60)', ref) and 'Board2' in str(path):
|
||||
assert props['Footprint'][2] == 'PhoneRail:PAD-D1.5_MI8-KEY'
|
||||
replace(child(symbol, 'in_bom'), '(in_bom no)', 'contact_bom')
|
||||
for key, value in {'Value': 'KEY_PAD_D1.5', 'MPN': '', 'LCSC': '', 'Manufacturer': '', 'Datasheet': '', 'Description': 'ENIG contact, no paste; PCB feature, not a purchased part'}.items():
|
||||
if key in props:
|
||||
p = props[key]
|
||||
raw = source[p.start:p.end]
|
||||
updated = re.sub(r'^(\(property\s+"(?:\\.|[^"\\])*"\s+)"(?:\\.|[^"\\])*"', lambda m: m[1] + json.dumps(value), raw)
|
||||
replace(p, updated, 'contact_metadata')
|
||||
|
||||
# Only identical text at identical coordinates is redundant. Different revisions
|
||||
# are retained except the fan page, whose full annotation is replaced below.
|
||||
seen = set()
|
||||
for text in children(root, 'text'):
|
||||
signature = (text[1], tuple(child(text, 'at')[1:]))
|
||||
if replace_fan_notes or replace_site_notes or replace_keys_notes:
|
||||
replace(text, '', 'keys_stale_notes' if replace_keys_notes else 'fan_stale_notes' if replace_fan_notes else 'site_stale_notes')
|
||||
elif signature in seen:
|
||||
replace(text, '', 'duplicate_notes')
|
||||
else:
|
||||
seen.add(signature)
|
||||
if path.name.startswith('C4-SITE'):
|
||||
value = text[1]
|
||||
if 'YTC1P-2510-01' in value:
|
||||
value = ' 侧键为 PCB 顶层 Ø1.5 ENIG 裸铜触点;不开钢网、不装顶针。'
|
||||
elif '冠径只有' in value:
|
||||
value = ' 主板弹片压接触点,装配间隙 1.0 mm;触点位置须实物复测。'
|
||||
else:
|
||||
value = value.replace('四根顶针顶主板反面的', '四个裸铜触点连接主板的').replace('侧键顶针', '侧键裸铜触点')
|
||||
raw = source[text.start:text.end]
|
||||
replace(text, re.sub(r'^\(text\s+"(?:\\.|[^"\\])*"', lambda m: '(text ' + json.dumps(value, ensure_ascii=False), raw), 'contact_notes')
|
||||
|
||||
if replace_fan_notes or replace_site_notes or replace_keys_notes:
|
||||
import uuid
|
||||
notes = [
|
||||
(12.7, 13.716, '风扇电路审查:FR-10 尚未满足,禁止按本页直接投板。'),
|
||||
(12.7, 19.05, 'FAN_PWM = GPIO18 / U20.11;FAN_TACH = GPIO16 / U20.9。'),
|
||||
(12.7, 86.36, 'Q6 低边调速,D2 续流;五个风扇共用供电回路。'),
|
||||
(12.7, 92.456, 'R730/R731/R732 = 10k/10k/3.3k:开漏释放时高电平仅 1.70V。'),
|
||||
(12.7, 98.552, '计算:12 x 3.3 / (10 + 10 + 3.3),低于 GPIO 保证高阈值 2.475V。'),
|
||||
(12.7, 104.648, '五路 TACH 并联不能保证识别单风扇停转;不得按频率下降 1/5 判定。'),
|
||||
(12.7, 110.744, '测速需独立采集或故障汇总电路;低边关断时须校核浮地和 GPIO 钳位。'),
|
||||
(12.7, 170.18, 'J26~J30:1=+12V,2=风扇回流/Q6.D,3=TACH(当前并联,待整改)。'),
|
||||
(12.7, 176.276, '型号、电平接口与装配高度待实物确认。详见 HARDWARE_REVIEW.md。'),
|
||||
]
|
||||
if replace_site_notes:
|
||||
ch = int(path.stem[-1])
|
||||
notes = [
|
||||
(15, 17, f'CH{ch}:电池直供、电容组、侧键触点与 EDL'),
|
||||
(15, 23, f'J8{ch}:1=P+,2=P-,3=TEMP,4=ID;旁路电池保护板,保留排线 BTB。'),
|
||||
(15, 29, f'R78{ch}=10k 模拟温度;R79{ch}=68k 模拟 ID。2200uF 使用指定普通铝电解,勿换低 ESR 聚合物。'),
|
||||
(15, 86, '侧键采用裸铜压接,非采购元件:Ø1.5 ENIG,钢网不开口,不安装顶针。'),
|
||||
(15, 92, '主板弹片直压触点,间隙 1.0mm;接触位置、压力及绝缘须实物确认。'),
|
||||
(15, 98, f'Q{ch}03~Q{ch}05:栅极 1k 串阻、100k 下拉;595 高阻期间保持按键释放。'),
|
||||
(15, 167, f'J6{ch} 连接主板 TP1204/TP1205,U4{ch} 导通时短接两点进入 EDL。'),
|
||||
(15, 173, f'R74{ch}=100k 下拉:复位或连接器未插时 EDL 不触发。'),
|
||||
(15, 179, '装配与未完成检查见 HARDWARE_REVIEW.md。'),
|
||||
]
|
||||
if replace_keys_notes:
|
||||
notes = [
|
||||
(15, 16, 'C6-KEYS:三片 74HC595,15 路侧键 + 5 路 EDL + HUB_RST'),
|
||||
(15, 22, '控制链核查:四个未用输出已逐脚标 NC;电气参数与电源时序仍待复核。'),
|
||||
(15, 28, '级联 U21 → U22 → U33;固件发送 U33 → U22 → U21,每字节高位先发。'),
|
||||
(185, 44, '三片 VCC / MR# 接 +3V3_HUB。'),
|
||||
(185, 51, 'R609 100k 将 OE# 拉高,复位时输出高阻。'),
|
||||
(185, 58, '先锁存安全值,再拉低 OE# 使能输出。'),
|
||||
(185, 65, 'HUB_RST 低有效;安全值为 1 << 23。'),
|
||||
(185, 72, 'U33.Q7 → U32.43;R716 为复位上拉。'),
|
||||
(185, 82, 'NC:U21.4、U22.4、U22.15、U33.9。'),
|
||||
(185, 89, '前三脚为预留并行输出;末脚为链尾输出。'),
|
||||
(185, 99, 'KEY_SER / SRCLK / RCLK / OE# 经板间连接。'),
|
||||
(185, 106, '跨电源域上掉电、注入电流及阈值待审。'),
|
||||
(185, 116, 'K*_PWR / VUP / VDN 驱动 C4 页按键 MOS。'),
|
||||
(185, 123, 'EDL_EN1~5 控制各路 TS5A3157。'),
|
||||
]
|
||||
new = ''.join('\n\t(text ' + json.dumps(value, ensure_ascii=False) + f' (at {x} {y} 0) (effects (font (size 1.587 1.587)) (justify left bottom)) (uuid "{uuid.uuid5(uuid.NAMESPACE_URL, value)}"))' for x, y, value in notes)
|
||||
edits.append((root.end - 1, root.end - 1, new + '\n'))
|
||||
|
||||
for start, end, replacement in sorted(edits, reverse=True):
|
||||
source = source[:start] + replacement + source[end:]
|
||||
if apply and edits:
|
||||
path.write_text(source, encoding='utf-8', newline='\n')
|
||||
return counts
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
args = parser.parse_args()
|
||||
total = Counter()
|
||||
for path in [ROOT / 'lib/PhoneRail.kicad_sym', *sorted(ROOT.glob('PhoneRail-*/*.kicad_sch'))]:
|
||||
counts = cleanup(path, args.apply)
|
||||
if counts:
|
||||
print(path.name, dict(counts))
|
||||
total.update(counts)
|
||||
print('TOTAL', dict(total))
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Install explicit net classes and the R2 routing contract without altering nets."""
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
from optimize_pcb_layout import ROOT, OUT, digest
|
||||
|
||||
|
||||
def main():
|
||||
contract = {
|
||||
'revision':'R2',
|
||||
'direction':{'F.Cu':'horizontal','B.Cu':'vertical',
|
||||
'max_transverse_escape_mm':2.0,
|
||||
'exceptions':[],
|
||||
'policy':'Long transverse/diagonal tracks require explicit reviewed UUID exceptions; copper zones are exempt'},
|
||||
'stackup_status':'PRELIMINARY_FABRICATOR_CONFIRMATION_REQUIRED',
|
||||
'usb':{'target_ohms':90,'tolerance_percent':10,'width_mm':.28,'gap_mm':.18,
|
||||
'max_pair_skew_mm':.5,'max_uncoupled_mm':5,'max_vias_per_leg':2,
|
||||
'pair_names_native_recognized':False,
|
||||
'reference_policy':'F.Cu requires uninterrupted In1.Cu GND; B.Cu requires uninterrupted In2.Cu GND. Reserve local GND corridors in the nominal power layer before changing signal layers',
|
||||
'max_return_via_distance_mm':1.0},
|
||||
'power':{'input_design_continuous_A':10,'input_fuse_A':12,'phone_channel_A':4,
|
||||
'main_bus_preferred_width_mm':6,'main_bus_min_trunk_mm':4,
|
||||
'phone_preferred_width_mm':2,'phone_min_trunk_mm':1.5,
|
||||
'max_narrow_escape_mm':2,'via_drill_mm':.3,'via_diameter_mm':.6,
|
||||
'phone_parallel_vias_min':6,'input_parallel_vias_min':12,
|
||||
'note':'Starting geometry only: qualify current, voltage drop, thermal rise and via plating with board house'},
|
||||
'placement':{'min_body_spacing_mm':.35,'battery_bulk_lower_projection_margin_mm':1,
|
||||
'max_buck_vin_cap_pin_distance_mm':5,'max_bootstrap_pin_distance_mm':5,
|
||||
'max_feedback_pin_distance_mm':5,'max_shunt_ina_pin_distance_mm':10,
|
||||
'max_crystal_pin_distance_mm':7,'max_hub_bypass_pin_distance_mm':5,
|
||||
'height_gap_mm':13,'height_status':'Bulk-cap projections reserved; remaining component heights unverified'},
|
||||
'boards':{},
|
||||
}
|
||||
for n in (1,2):
|
||||
name = f'PhoneRail-Board{n}'
|
||||
path = ROOT/name/(name+'.kicad_pro')
|
||||
project = json.loads(path.read_text(encoding='utf-8'))
|
||||
source = OUT / ('rules-input-'+name+'.kicad_pcb')
|
||||
shutil.copy2(path.with_suffix('.kicad_pcb'),source)
|
||||
b = pcb.LoadBoard(str(source))
|
||||
parts = {f.GetReference():f for f in b.GetFootprints()}
|
||||
def net(ref,pin):
|
||||
return next(p.GetNetname() for p in parts[ref].Pads() if p.GetNumber()==str(pin))
|
||||
nets = {p.GetNetname() for f in b.GetFootprints() for p in f.Pads() if p.GetNetname()}
|
||||
mapping = {s:'Default' for s in nets}
|
||||
usb_pairs = [('USB_DP','USB_DM')] if n == 1 else [('UP_DP','UP_DM')]+[
|
||||
(f'USB{ch}_DP{suffix}',f'USB{ch}_DM{suffix}') for ch in range(1,6) for suffix in ('','J')]
|
||||
for plus,minus in usb_pairs:
|
||||
assert plus in nets and minus in nets
|
||||
mapping[plus] = mapping[minus] = 'USB90'
|
||||
for s in nets:
|
||||
if s in {'SCL','SDA','INA_ALERT'}:
|
||||
mapping[s] = 'I2C'
|
||||
elif s.startswith('VDAC') or s.startswith('NTC'):
|
||||
mapping[s] = 'ANALOG'
|
||||
elif re.fullmatch(r'VO[1-5](_P)?',s):
|
||||
mapping[s] = 'PWR4A'
|
||||
elif s == '+5V' or re.fullmatch(r'VBUS[1-5]',s):
|
||||
mapping[s] = 'PWR5V'
|
||||
elif s.startswith('+3V3') or s in {'VDD18','VDD18PLL'}:
|
||||
mapping[s] = 'PWR3V3'
|
||||
elif s == '+12V':
|
||||
mapping[s] = 'PWR12V'
|
||||
elif 'FAN_12V_SW' in s:
|
||||
mapping[s] = 'FAN_PWR'
|
||||
if n == 1:
|
||||
for ref,pin in [('J1',2),('F1',2),('Q1',5)]:
|
||||
mapping[net(ref,pin)] = 'PWR12V'
|
||||
for ch in range(1,6):
|
||||
mapping[net(f'U{ch}',11)] = 'SWITCH'
|
||||
mapping[net(f'U{ch}',7)] = 'FEEDBACK'
|
||||
mapping[net(f'U{ch}',8)] = 'FEEDBACK'
|
||||
mapping[net(f'U{ch+13}',4)] = 'PWR4A'
|
||||
for pin in (15,17,18,19):
|
||||
mapping[net(f'U{ch+13}',pin)] = 'ANALOG'
|
||||
for ref in ('U19','U30'):
|
||||
mapping[net(ref,2)] = 'SWITCH'
|
||||
mapping[net(ref,4)] = 'FEEDBACK'
|
||||
else:
|
||||
for s in ('XTAL1','XTAL2','RBIAS'):
|
||||
mapping[s] = 'CLOCK_LOCAL'
|
||||
net_settings = project['net_settings']
|
||||
base = dict(next(c for c in net_settings['classes'] if c['name']=='Default'))
|
||||
base.update(clearance=.15,track_width=.25,via_diameter=.6,via_drill=.3,
|
||||
diff_pair_width=.28,diff_pair_gap=.18,diff_pair_via_gap=.25)
|
||||
classes = [('Default',.25,.15),('USB90',.28,.15),('I2C',.25,.15),('ANALOG',.2,.15),
|
||||
('FEEDBACK',.2,.15),('CLOCK_LOCAL',.2,.15),('SWITCH',1,.15),
|
||||
('PWR4A',2,.2),('PWR5V',1,.2),('PWR12V',4,.2),('FAN_PWR',1.2,.2),('PWR3V3',.5,.15)]
|
||||
net_settings['classes'] = [dict(base,name=c,track_width=w,clearance=gap) for c,w,gap in classes]
|
||||
# Use exact escaped net names; patterns cannot accidentally capture BOOT/FB.
|
||||
net_settings['netclass_assignments'] = {k:[v] for k,v in sorted(mapping.items()) if v!='Default'}
|
||||
net_settings['netclass_patterns'] = [{'pattern':k,'netclass':v} for k,v in sorted(mapping.items()) if v!='Default']
|
||||
ds = project['board']['design_settings']
|
||||
ds['rules'].update(min_clearance=.127,min_track_width=.127,min_via_diameter=.6,
|
||||
min_through_hole_diameter=.3,min_hole_to_hole=.25,
|
||||
min_via_annular_width=.15,min_copper_edge_clearance=.3,
|
||||
min_connection=.2,solder_mask_clearance=.05,solder_mask_min_width=.1)
|
||||
ds['track_widths'] = [0,.15,.2,.25,.28,.5,1,1.2,1.5,2,3,4,6]
|
||||
ds['via_dimensions'] = [{'diameter':0,'drill':0},{'diameter':.6,'drill':.3},{'diameter':.8,'drill':.4}]
|
||||
ds['diff_pair_dimensions'] = [{'width':0,'gap':0,'via_gap':0},{'width':.28,'gap':.18,'via_gap':.25}]
|
||||
ds['defaults']['zones'].update(min_clearance=.2,min_thickness=.2,thermal_relief_gap=.25,
|
||||
thermal_relief_spoke_width=.4,min_island_area=5)
|
||||
path.write_text(json.dumps(project,indent=2,ensure_ascii=False)+'\n',encoding='utf-8')
|
||||
rules = '''(version 1)
|
||||
# F.Cu horizontal, B.Cu vertical. Direction and explicit pair checks:
|
||||
# scripts/verify_pcb_layout.py + pcb-routing-rules.json.
|
||||
# Net names are unchanged; DP/DM suffixes are not presumed native pairs.
|
||||
(rule "R2 inner layers reserved for reference and power zones"
|
||||
(condition "A.Layer == 'In1.Cu' || A.Layer == 'In2.Cu'")
|
||||
(constraint disallow track))
|
||||
(rule "R2 USB differential target - requires recognized pair"
|
||||
(condition "A.hasNetclass('USB90')")
|
||||
(constraint track_width (min 0.15mm) (opt 0.28mm) (max 0.30mm))
|
||||
(constraint diff_pair_gap (min 0.15mm) (opt 0.18mm) (max 0.21mm))
|
||||
(constraint diff_pair_uncoupled (max 5mm))
|
||||
(constraint skew (max 0.5mm)))
|
||||
(rule "R2 switch copper stays on component side with no vias"
|
||||
(condition "A.hasNetclass('SWITCH')")
|
||||
(constraint disallow via)
|
||||
(constraint length (max 12mm)))
|
||||
(rule "R2 feedback and oscillator do not change layers"
|
||||
(condition "A.hasNetclass('FEEDBACK') || A.hasNetclass('CLOCK_LOCAL')")
|
||||
(constraint disallow via)
|
||||
(constraint length (max 15mm)))
|
||||
(rule "R2 switch tracks isolated from sensitive nets"
|
||||
(condition "A.Type == 'Track' && A.hasNetclass('SWITCH') && (B.hasNetclass('FEEDBACK') || B.hasNetclass('ANALOG') || B.hasNetclass('USB90') || B.hasNetclass('CLOCK_LOCAL'))")
|
||||
(constraint clearance (min 0.5mm)))
|
||||
(rule "R2 ordinary vias stay outside SMD lands"
|
||||
(condition "A.Type == 'Via' && B.Type == 'Pad' && B.Pad_Type == 'SMD'")
|
||||
(constraint physical_clearance (min 0.1mm)))
|
||||
'''
|
||||
path.with_suffix('.kicad_dru').write_text(rules,encoding='utf-8')
|
||||
contract['boards'][name] = {'ground_reference_layer':'In1.Cu' if n==1 else 'In2.Cu',
|
||||
'power_zone_layer':'In2.Cu' if n==1 else 'In1.Cu',
|
||||
'usb_pairs':[{'positive':p,'negative':m} for p,m in usb_pairs], 'net_classes':mapping}
|
||||
(ROOT/'pcb-routing-rules.json').write_text(json.dumps(contract,indent=2)+'\n',encoding='utf-8')
|
||||
manifest = OUT/'placement.json'
|
||||
data = json.loads(manifest.read_text(encoding='utf-8'))
|
||||
# KiCad normalizes project JSON during schematic/DRC operations. Placement
|
||||
# protects the board and DRU files it can overwrite, not that mutable cache.
|
||||
data['generated_files'] = {k:v for k,v in data['generated_files'].items() if not k.endswith('.kicad_pro')}
|
||||
for n in (1,2):
|
||||
name = f'PhoneRail-Board{n}'
|
||||
for ext in ('.kicad_dru',):
|
||||
p = ROOT/name/(name+ext)
|
||||
data['generated_files'][str(p.relative_to(ROOT))] = digest(p)
|
||||
manifest.write_text(json.dumps(data,indent=2)+'\n',encoding='utf-8')
|
||||
print('R2 classes, native rules, explicit pair/direction contract installed')
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
param(
|
||||
[string]$Firmware = "$PSScriptRoot/../out/firmware-fan-r2",
|
||||
[string]$Msvc = 'C:/Program Files (x86)/Microsoft Visual Studio/18/BuildTools/VC/Tools/MSVC/14.50.35717',
|
||||
[string]$Sdk = 'C:/Program Files (x86)/Windows Kits/10',
|
||||
[string]$SdkVersion = '10.0.26100.0'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$fw = (Resolve-Path -LiteralPath $Firmware).Path
|
||||
$build = [IO.Path]::GetFullPath("$PSScriptRoot/../out/fan-pin-tests")
|
||||
New-Item -ItemType Directory -Path $build -Force | Out-Null
|
||||
$oldInclude = $env:INCLUDE
|
||||
$oldLib = $env:LIB
|
||||
$oldPath = $env:PATH
|
||||
try {
|
||||
$env:INCLUDE = "$Msvc/include;$Sdk/Include/$SdkVersion/ucrt;$Sdk/Include/$SdkVersion/shared;$Sdk/Include/$SdkVersion/um"
|
||||
$env:LIB = "$Msvc/lib/x64;$Sdk/Lib/$SdkVersion/ucrt/x64;$Sdk/Lib/$SdkVersion/um/x64"
|
||||
$env:PATH = "$Msvc/bin/Hostx64/x64;$env:PATH"
|
||||
$hal = "$fw/components/phonerail_hal"
|
||||
$drv = "$fw/components/drv"
|
||||
$sources = @("$fw/main/rail_cfg.c", "$fw/test/test_rail_cfg.c", "$fw/third_party/unity/unity.c")
|
||||
$sources += (Get-ChildItem "$hal/*.c", "$hal/sim/*.c", "$drv/*.c").FullName
|
||||
$includes = @("$fw/main", "$fw/third_party/unity", "$hal/include", "$hal/sim", "$drv/include")
|
||||
$flags = @('/nologo', '/std:c11', '/utf-8', '/W3', '/WX')
|
||||
$flags += $includes | ForEach-Object { "/I$_" }
|
||||
Push-Location $build
|
||||
try {
|
||||
& "$Msvc/bin/Hostx64/x64/cl.exe" @flags @sources /Fe:test_rail_cfg.exe 2>&1 |
|
||||
Tee-Object -FilePath compile.log
|
||||
if ($LASTEXITCODE -ne 0) { throw "Pin test compilation failed: $LASTEXITCODE" }
|
||||
& ./test_rail_cfg.exe 2>&1 | Tee-Object -FilePath tests.log
|
||||
if ($LASTEXITCODE -ne 0) { throw "Pin tests failed: $LASTEXITCODE" }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} finally {
|
||||
$env:INCLUDE = $oldInclude
|
||||
$env:LIB = $oldLib
|
||||
$env:PATH = $oldPath
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Classify singleton unconnected nets without declaring them electrically safe."""
|
||||
from collections import Counter
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def classify(board, ref, pin):
|
||||
if board == 2 and re.fullmatch(r'J7[1-5]', ref):
|
||||
return 'phone_tail_contact', 'Check Xiaomi J1401 signal assignment and retained phone functions before NC'
|
||||
if board == 1 and ref == 'U20':
|
||||
return 'mcu_recovery' if pin in ('27', '36', '37') else 'mcu_spare_or_strap', 'Review recovery interface, boot straps and module memory variant'
|
||||
if board == 1 and ref in ('U6', 'U7'):
|
||||
return 'unused_dac_channel', 'Unused by current rail map; verify Hi-Z at reset, NVM restore and faults before NC'
|
||||
if board == 2 and ref == 'U32':
|
||||
if pin in ('21', '22', '27', '28', '35', '37', '38'):
|
||||
return 'hub_overcurrent', 'Internal pull-up documented; review port power control and system overcurrent responsibility'
|
||||
if pin in ('20', '23', '26', '29', '30', '36', '39'):
|
||||
return 'hub_port_power', 'Review firmware-controlled TPS22918 sequencing versus hub port power requests'
|
||||
if pin in ('53', '55'):
|
||||
return 'hub_unused_usb_port', 'Verify port 6/7 disable straps and USB enumeration'
|
||||
return 'hub_led_or_strap', 'Review sampled strap default and enabled LED function before NC'
|
||||
if board == 2 and re.fullmatch(r'U4[1-5]', ref) and pin == '3':
|
||||
return 'edl_normally_closed', 'NC means normally closed switch port; confirm COM/NO wiring and default-open EDL behavior'
|
||||
if board == 2 and ref == 'U34':
|
||||
return 'temperature_alert', 'Firmware polls temperature; review fault coverage without OS interrupt'
|
||||
if ref in ('J22', 'J23'):
|
||||
return 'usb_unused_contact', 'Confirm USB2-only SBU policy or externally powered debug VBUS policy'
|
||||
if board == 1 and ref == 'J1':
|
||||
return 'input_connector_mechanical', 'Confirm connector pad 3/4 are mechanical anchors and soldering requirements'
|
||||
return 'unclassified', 'Manual schematic and datasheet review required'
|
||||
|
||||
|
||||
def inventory(board):
|
||||
rows = []
|
||||
for net, endpoints in sorted(board.net_members.items()):
|
||||
if len(endpoints) != 1 or not net.startswith('unconnected-'):
|
||||
continue
|
||||
ref, pin = next(iter(endpoints))
|
||||
kind = board.pin_types[(ref, pin)]
|
||||
if 'no_connect' in kind.split('+'):
|
||||
continue
|
||||
category, action = classify(board.number, ref, pin)
|
||||
rows.append({'board': board.number, 'sheet': board.parts[ref]['page'], 'reference': ref,
|
||||
'pin': pin, 'function': board.functions[(ref, pin)], 'type': kind,
|
||||
'net': net, 'category': category, 'status': 'REVIEW_REQUIRED', 'next_action': action})
|
||||
if len(rows) != board.erc.get('pin_not_connected', 0):
|
||||
raise ValueError('Singleton inventory does not account for every unconnected-pin ERC item')
|
||||
return rows
|
||||
|
||||
|
||||
def write_inventory(out, rows):
|
||||
with (out / 'unconnected-pins.csv').open('w', encoding='utf-8-sig', newline='') as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=['board', 'sheet', 'reference', 'pin', 'function',
|
||||
'type', 'net', 'category', 'status', 'next_action'])
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
summary = {'total': len(rows), 'by_category': dict(Counter(r['category'] for r in rows)),
|
||||
'by_board': dict(Counter(r['board'] for r in rows)),
|
||||
'note': 'Every row remains open; classification is not an NC approval or hardware acceptance'}
|
||||
(out / 'unconnected-summary.json').write_text(json.dumps(summary, indent=2) + '\n', encoding='utf-8')
|
||||
return summary
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Verify the Hub ECO and produce a complete, explicit disposition of remaining ERC."""
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
import pcbnew as pcb
|
||||
|
||||
from check_hardware import Board
|
||||
from hub_strap_checks import REFS, STRAPS, verify_hub_straps
|
||||
from revise_hub_straps import OUT, NC, CLI, PCB
|
||||
from schematic_maintenance import child, children, parse, properties
|
||||
from unconnected_inventory import classify, inventory, write_inventory
|
||||
|
||||
|
||||
def snapshot(path, omit=()):
|
||||
root = ET.parse(path).getroot()
|
||||
parts = {c.attrib['ref']: ET.tostring(c) for c in root.findall('components/comp') if c.attrib['ref'] not in omit}
|
||||
nets = {}
|
||||
for net in root.findall('nets/net'):
|
||||
pins = {(p.attrib['ref'], p.attrib['pin']): dict(p.attrib)
|
||||
for p in net.findall('node') if p.attrib['ref'] not in omit}
|
||||
if pins:
|
||||
nets[frozenset(pins)] = pins
|
||||
return parts, nets
|
||||
|
||||
|
||||
def main():
|
||||
from check_power_paths import main as verify_power_paths
|
||||
verify_power_paths()
|
||||
rows, unconnected, boards = [], [], {}
|
||||
after = OUT / 'verified'
|
||||
after.mkdir(exist_ok=True)
|
||||
for number in (1, 2):
|
||||
b = Board(number)
|
||||
b.compile(CLI, after)
|
||||
added = REFS if number == 2 else set()
|
||||
old_parts, old_nets = snapshot(OUT / 'before' / f'Board{number}.xml')
|
||||
new_parts, new_nets = snapshot(after / f'Board{number}.xml', added)
|
||||
assert old_parts == new_parts, 'Unrelated component metadata changed'
|
||||
assert old_nets.keys() == new_nets.keys(), 'Unapproved connectivity change'
|
||||
assert set(b.parts) - set(old_parts) == added
|
||||
expected_nc = {(ref, pin) for ref, pins in NC[number].items() for pin in pins}
|
||||
changed = set()
|
||||
for members, pins in old_nets.items():
|
||||
for endpoint, attrs in pins.items():
|
||||
expected = dict(attrs)
|
||||
if endpoint in expected_nc:
|
||||
assert len(members) == 1 and 'no_connect' not in attrs['pintype']
|
||||
expected['pintype'] += '+no_connect'
|
||||
assert expected == new_nets[members][endpoint], endpoint
|
||||
if attrs != expected:
|
||||
changed.add(endpoint)
|
||||
assert changed == expected_nc
|
||||
if number == 2:
|
||||
def check(label, good):
|
||||
assert good, label
|
||||
verify_hub_straps(b, check)
|
||||
current_pcb = pcb.LoadBoard(str(PCB))
|
||||
previous_pcb = pcb.LoadBoard(str(OUT / 'before' / PCB.name))
|
||||
footprints = {f.GetReference(): f for f in current_pcb.GetFootprints()}
|
||||
changed_pins = {str(pin) for pin in STRAPS.values()}
|
||||
for old_fp in previous_pcb.GetFootprints():
|
||||
ref = old_fp.GetReference()
|
||||
new_fp = footprints[ref]
|
||||
assert old_fp.GetPosition() == new_fp.GetPosition() and old_fp.GetLayer() == new_fp.GetLayer(), ref
|
||||
assert old_fp.GetOrientationDegrees() == new_fp.GetOrientationDegrees(), ref
|
||||
new_pads = {p.GetNumber(): p for p in new_fp.Pads()}
|
||||
for old_pad in old_fp.Pads():
|
||||
new_pad = new_pads[old_pad.GetNumber()]
|
||||
assert old_pad.GetPosition() == new_pad.GetPosition(), ref
|
||||
if ref != 'U32' or old_pad.GetNumber() not in changed_pins:
|
||||
assert old_pad.GetNetname() == new_pad.GetNetname(), (ref, old_pad.GetNumber())
|
||||
report = json.loads((after / f'Board{number}-erc.json').read_text())
|
||||
before = json.loads((OUT / 'before' / f'Board{number}-erc.json').read_text())
|
||||
for key in ('ignored_checks', 'included_severities'):
|
||||
assert report[key] == before[key]
|
||||
for sheet in report['sheets']:
|
||||
for violation in sheet['violations']:
|
||||
description = violation['items'][0]['description']
|
||||
match = re.search(r'^Symbol (\S+) .*? (\S+) \[', description)
|
||||
assert match, description
|
||||
ref, pin = match.groups()
|
||||
kind = violation['type']
|
||||
net = b.pins.get((ref, pin), '')
|
||||
if ref.startswith('#PWR'):
|
||||
path = b.path.parent / (sheet['path'].strip('/') + '.kicad_sch')
|
||||
instance = next(s for s in children(parse(path.read_text(encoding='utf-8')), 'symbol')
|
||||
if properties(s)['Reference'][2] == ref)
|
||||
net = properties(instance)['Value'][2]
|
||||
status, blocking = 'REVIEW_REQUIRED', True
|
||||
category, action = classify(number, ref, pin)
|
||||
evidence = ''
|
||||
if kind == 'power_pin_not_driven':
|
||||
category = 'supply_model'
|
||||
status, blocking = 'TEMPORARILY_ACCEPTED_FOR_ROUTING', False
|
||||
evidence = 'out/power-path-review/verification.json'
|
||||
action = 'Topology traced; retain ERC until source operation/ratings are reviewed, then add scoped PWR_FLAG'
|
||||
elif category in ('hub_port_power', 'hub_overcurrent'):
|
||||
category = 'hub_power_architecture'
|
||||
evidence = '../五路直供电源板设计.md sections 15.2,21.6; USB2517.pdf pages 10,12'
|
||||
action = 'Intentional external MCU control; verify 5V short-circuit protection and USB port power/OC reporting before release'
|
||||
elif category == 'unused_dac_channel':
|
||||
evidence = 'DAC63204.pdf pin table and power-down/NVM sections; ../firmware/components/drv/dac63204.c'
|
||||
action = 'Runtime keeps unused channels Hi-Z; verify manufactured NVM image and recall/fault state before NC'
|
||||
elif category == 'mcu_recovery':
|
||||
action = 'Add accessible IO0/UART recovery test points; current schematic explicitly says they are not fitted'
|
||||
rows.append({'board': number, 'sheet': sheet['path'], 'reference': ref, 'pin': pin,
|
||||
'erc_type': kind, 'severity': violation['severity'], 'net': net,
|
||||
'category': category, 'disposition': status,
|
||||
'temporary_routing_acceptance': not blocking,
|
||||
'release_blocking': True, 'evidence': evidence, 'next_action': action})
|
||||
unconnected.extend(inventory(b))
|
||||
boards[number] = {'original_components_unchanged': len(old_parts), 'added_components': sorted(added),
|
||||
'original_endpoint_groups_preserved': len(new_nets), 'approved_nc': sorted(changed), 'erc': b.erc}
|
||||
with (OUT / 'erc-disposition.csv').open('w', encoding='utf-8-sig', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=list(rows[0]))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
(OUT / 'erc-disposition.json').write_text(json.dumps(rows, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
result = {'status': 'ECO_VERIFIED_NOT_RELEASED', 'boards': boards,
|
||||
'remaining_erc': len(rows), 'temporary_routing_acceptances': sum(r['temporary_routing_acceptance'] for r in rows),
|
||||
'release_review_required': sum(r['release_blocking'] for r in rows),
|
||||
'unconnected': write_inventory(OUT, unconnected)}
|
||||
(OUT / 'verification.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Check the installed server over stdio without modifying design files."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pcbnew
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PYTHON = Path('D:/kicad-mcp/.venv/Scripts/python.exe')
|
||||
|
||||
|
||||
async def verify():
|
||||
env = dict(os.environ)
|
||||
env.update(
|
||||
KICAD_CLI='D:/KiCad/bin/kicad-cli.exe',
|
||||
KICAD_PROJECT_PATHS=str(ROOT),
|
||||
PYTHONIOENCODING='utf-8',
|
||||
PYTHONDONTWRITEBYTECODE='1',
|
||||
)
|
||||
parameters = StdioServerParameters(
|
||||
command=str(PYTHON), args=['-B', '-m', 'kicad_mcp_server'],
|
||||
cwd=str(ROOT), env=env,
|
||||
)
|
||||
async with stdio_client(parameters) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
info = await session.initialize()
|
||||
available = []
|
||||
cursor = None
|
||||
while True:
|
||||
page = await session.list_tools(cursor=cursor)
|
||||
available.extend(tool.name for tool in page.tools)
|
||||
cursor = page.nextCursor
|
||||
if not cursor:
|
||||
break
|
||||
required = {'list_schematic_components', 'run_erc', 'run_drc'}
|
||||
if not required.issubset(available):
|
||||
raise RuntimeError(f'Missing tools: {required - set(available)}')
|
||||
resource = await session.read_resource('kicad://config')
|
||||
configuration = json.loads(resource.contents[0].text)
|
||||
if configuration['project_paths'] != [str(ROOT)]:
|
||||
raise RuntimeError('Project search path was not applied')
|
||||
schematic = ROOT / 'PhoneRail-Board1' / 'P1-INPUT.kicad_sch'
|
||||
result = await session.call_tool(
|
||||
'list_schematic_components', {'file_path': str(schematic)}
|
||||
)
|
||||
output = '\n'.join(getattr(part, 'text', '') for part in result.content)
|
||||
if result.isError or 'Total:' not in output:
|
||||
raise RuntimeError(f'Schematic read failed: {output}')
|
||||
print(json.dumps({
|
||||
'server': info.serverInfo.name,
|
||||
'pcbnew': pcbnew.Version(),
|
||||
'tool_count': len(available),
|
||||
'project_paths': configuration['project_paths'],
|
||||
'schematic': str(schematic),
|
||||
'components': next(line for line in output.splitlines() if line.startswith('Total:')),
|
||||
'status': 'PASS',
|
||||
}, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(asyncio.wait_for(verify(), timeout=60))
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Validate the declared Board2 Hub/USB topology before routing."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pcbnew
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BOARD_PATH = ROOT / "PhoneRail-Board2" / "PhoneRail-Board2.kicad_pcb"
|
||||
TOPOLOGY = ROOT / "pcb2-usb-topology.json"
|
||||
|
||||
def main():
|
||||
board = pcbnew.LoadBoard(str(BOARD_PATH))
|
||||
topo = json.loads(TOPOLOGY.read_text(encoding="utf-8"))
|
||||
refs = {f.GetReference(): f for f in board.GetFootprints()}
|
||||
required = ["J23", "U32", "J31", "J32"]
|
||||
missing = [r for r in required if r not in refs]
|
||||
assert not missing, f"missing required footprints: {missing}"
|
||||
report = {"status": "PASS", "required": required, "missing": missing,
|
||||
"tracks": len(board.GetTracks()), "nets": {}}
|
||||
for ref in required:
|
||||
nets = sorted({p.GetNetname() for p in refs[ref].Pads() if p.GetNetCode() > 0})
|
||||
report["nets"][ref] = nets
|
||||
assert report["nets"]["J23"], "J23 has no assigned nets"
|
||||
assert report["nets"]["U32"], "U32 has no assigned nets"
|
||||
out = ROOT / "out" / "pcb2-topology"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / "verification.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(report, indent=2))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Native DRC/parity plus placement, direction, explicit USB pair and height audits.
|
||||
|
||||
Exit 0 means this placement checkpoint passes. --release additionally requires
|
||||
completed copper connectivity, resolved ERC, impedance and height qualification.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
|
||||
import pcbnew as pcb
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from bootstrap_pcb import mating_check, verify_import
|
||||
from check_hardware import Board
|
||||
from kelvin_checks import check_measurement_branches
|
||||
from optimize_pcb_layout import ROOT, OUT, box_mm, expand, intersects, point
|
||||
|
||||
CLI = 'D:/KiCad/bin/kicad-cli.exe'
|
||||
|
||||
|
||||
def run(args,output):
|
||||
result = subprocess.run([CLI,*args],capture_output=True,encoding='utf-8',errors='replace',timeout=180)
|
||||
output.with_suffix(output.suffix+'.log').write_text(result.stdout+result.stderr,encoding='utf-8')
|
||||
if result.returncode or not output.exists():
|
||||
raise RuntimeError(f'KiCad failed ({result.returncode}): {output}; {result.stdout[-600:]}')
|
||||
|
||||
|
||||
def preview(board,number):
|
||||
scale=9
|
||||
im=Image.new('RGB',(228*scale,147*scale),'#f6f8fa')
|
||||
d=ImageDraw.Draw(im)
|
||||
def pos(x,y): return ((x-46)*scale,(y-43)*scale)
|
||||
def rect(b): return (*pos(b[0],b[1]),*pos(b[2],b[3]))
|
||||
d.rounded_rectangle(rect((50,50,270,185)),radius=5*scale,fill='#e7f0ec',outline='#34483c',width=2)
|
||||
font=ImageFont.truetype('C:/Windows/Fonts/arial.ttf',9)
|
||||
titlefont=ImageFont.truetype('C:/Windows/Fonts/arial.ttf',19)
|
||||
stage = 'PARTIAL LOCAL ROUTING' if list(board.GetTracks()) else 'UNROUTED'
|
||||
d.text(pos(51,45),f'PhoneRail Board {number} | {stage} | common top-view coordinates',font=titlefont,fill='#19272a')
|
||||
for z in board.Zones():
|
||||
if z.GetIsRuleArea():
|
||||
b=box_mm(z)
|
||||
d.rectangle(rect(b),outline='#bf4d70',width=2)
|
||||
if 'HEIGHT' in z.GetZoneName() or 'RF_' in z.GetZoneName():
|
||||
d.text(pos(b[0],b[1]),z.GetZoneName().replace('R2_',''),font=font,fill='#a23151')
|
||||
for t in board.GetTracks():
|
||||
if isinstance(t, pcb.PCB_VIA):
|
||||
continue
|
||||
a, b = t.GetStart(), t.GetEnd()
|
||||
d.line([pos(pcb.ToMM(a.x),pcb.ToMM(a.y)),pos(pcb.ToMM(b.x),pcb.ToMM(b.y))],
|
||||
fill='#d22e32' if t.GetLayer() == pcb.F_Cu else '#2358b0',
|
||||
width=max(1,round(pcb.ToMM(t.GetWidth())*scale)))
|
||||
for f in board.GetFootprints():
|
||||
b=box_mm(f)
|
||||
if f.GetSheetfile():
|
||||
color = '#c8d8ed' if f.GetReference().startswith('U') else '#dcd5bb'
|
||||
if f.GetReference().startswith('J'): color='#bfc6cc'
|
||||
if f.GetReference().startswith(('L','Rs')): color='#e5c7ab'
|
||||
d.rectangle(rect(b),fill=color,outline='#596474',width=1)
|
||||
for p in f.Pads():
|
||||
b=box_mm(p)
|
||||
fill='#b0692c' if p.GetNetname()=='GND' else '#c64343'
|
||||
if p.GetShape()==pcb.PAD_SHAPE_CIRCLE: d.ellipse(rect(b),fill=fill)
|
||||
else: d.rectangle(rect(b),fill=fill)
|
||||
drill=p.GetDrillSize()
|
||||
if drill.x:
|
||||
x,y=point(p); radius=pcb.ToMM(drill.x)/2
|
||||
d.ellipse(rect((x-radius,y-radius,x+radius,y+radius)),fill='#f6f8fa')
|
||||
if f.Reference().IsVisible():
|
||||
x,y=point(f.Reference())
|
||||
d.text(pos(x,y),f.GetReference(),font=font,fill='#172329',anchor='mm')
|
||||
im.save(OUT/f'Board{number}-placement.png')
|
||||
|
||||
|
||||
def distances(parts,n):
|
||||
checks=[]
|
||||
def distance(label,ref,pin,other,max_mm,other_pin=None):
|
||||
a=next(p for p in parts[ref].Pads() if p.GetNumber()==str(pin))
|
||||
pads=[p for p in parts[other].Pads() if (p.GetNumber()==str(other_pin) if other_pin else p.GetNetname()==a.GetNetname())]
|
||||
assert pads, (label,ref,pin,other)
|
||||
actual=min((a.GetPosition()-p.GetPosition()).EuclideanNorm()/1e6 for p in pads)
|
||||
checks.append({'check':label,'from':f'{ref}.{pin}','to':other,'distance_mm':round(actual,3),
|
||||
'max_mm':max_mm,'pass':actual<=max_mm})
|
||||
if n==1:
|
||||
for ch in range(1,6):
|
||||
distance(f'CH{ch} VIN bypass',f'U{ch}',4,f'C{ch}01',5)
|
||||
distance(f'CH{ch} bootstrap',f'U{ch}',13,f'C{ch}05',5)
|
||||
distance(f'CH{ch} switch to inductor',f'U{ch}',11,f'L{ch}01',5)
|
||||
distance(f'CH{ch} feedback top',f'U{ch}',7,f'R{ch}01',5)
|
||||
distance(f'CH{ch} feedback bottom',f'U{ch}',7,f'R{ch}02',5)
|
||||
distance(f'CH{ch} Kelvin positive',f'U{ch+7}',10,f'Rs{ch}01',10)
|
||||
distance(f'CH{ch} Kelvin negative',f'U{ch+7}',9,f'Rs{ch}01',10)
|
||||
distance(f'CH{ch} INA bypass',f'U{ch+7}',6,f'C{ch}13',5)
|
||||
else:
|
||||
for ref,pin in [('C742',25),('C743',62),('C744',5),('C745',10),('C746',24),
|
||||
('C747',46),('C748',52),('C749',57),('C750',64)]:
|
||||
distance(f'Hub bypass {ref}','U32',pin,ref,5)
|
||||
distance('Hub crystal XTAL1','U32',61,'Y1',7)
|
||||
distance('Hub crystal XTAL2','U32',60,'Y1',7)
|
||||
for ch in range(1,6):
|
||||
distance(f'CH{ch} tail ESD DM',f'J7{ch}',4,f'U5{ch}',10)
|
||||
distance(f'CH{ch} battery ceramic',f'J8{ch}',1,f'C{802+(ch-1)*10}',12)
|
||||
return checks
|
||||
|
||||
|
||||
def routing(board,contract,n):
|
||||
errors=[]
|
||||
tracks={}
|
||||
transverse=[]
|
||||
for t in board.GetTracks():
|
||||
tracks.setdefault(t.GetNetname(),[]).append(t)
|
||||
if isinstance(t,pcb.PCB_VIA): continue
|
||||
a,b=t.GetStart(),t.GetEnd()
|
||||
dx,dy=abs(pcb.ToMM(a.x-b.x)),abs(pcb.ToMM(a.y-b.y))
|
||||
if t.GetLayer() not in (pcb.F_Cu,pcb.B_Cu):
|
||||
errors.append({'kind':'inner_signal_track','uuid':t.m_Uuid.AsString()})
|
||||
continue
|
||||
wrong=dy if t.GetLayer()==pcb.F_Cu else dx
|
||||
if wrong>.001 and t.m_Uuid.AsString() not in contract['direction']['exceptions']:
|
||||
transverse.append(t)
|
||||
# Join contiguous nonpreferred segments so splitting a long wrong-way route
|
||||
# into short pieces does not bypass the escape-length limit.
|
||||
pending=set(range(len(transverse)))
|
||||
while pending:
|
||||
group={pending.pop()}
|
||||
changed=True
|
||||
while changed:
|
||||
changed=False
|
||||
endpoints={(transverse[i].GetNetname(),transverse[i].GetLayer(),q.x,q.y)
|
||||
for i in group for q in (transverse[i].GetStart(),transverse[i].GetEnd())}
|
||||
for i in list(pending):
|
||||
t=transverse[i]
|
||||
if any((t.GetNetname(),t.GetLayer(),q.x,q.y) in endpoints for q in (t.GetStart(),t.GetEnd())):
|
||||
pending.remove(i);group.add(i);changed=True
|
||||
length=sum(pcb.ToMM(transverse[i].GetLength()) for i in group)
|
||||
if length>contract['direction']['max_transverse_escape_mm']:
|
||||
errors.append({'kind':'direction','uuids':[transverse[i].m_Uuid.AsString() for i in group],
|
||||
'net':transverse[next(iter(group))].GetNetname(),'length_mm':round(length,3)})
|
||||
pairs=[]
|
||||
for pair in contract['boards'][f'PhoneRail-Board{n}']['usb_pairs']:
|
||||
info=dict(pair)
|
||||
lengths=[]
|
||||
via_counts=[]
|
||||
for net in pair.values():
|
||||
lengths.append(sum(pcb.ToMM(t.GetLength()) for t in tracks.get(net,[]) if not isinstance(t,pcb.PCB_VIA)))
|
||||
via_counts.append(sum(isinstance(t,pcb.PCB_VIA) for t in tracks.get(net,[])))
|
||||
info.update(copper_length_mm=lengths,via_counts=via_counts,
|
||||
status='UNROUTED' if not any(lengths) else 'PARTIAL_OR_ROUTED_REQUIRES_CONNECTIVITY_CHECK')
|
||||
if abs(lengths[0]-lengths[1])>contract['usb']['max_pair_skew_mm']:
|
||||
errors.append({'kind':'usb_skew','pair':pair,'lengths':lengths})
|
||||
if max(via_counts)>contract['usb']['max_vias_per_leg'] or via_counts[0]!=via_counts[1]:
|
||||
errors.append({'kind':'usb_vias','pair':pair,'counts':via_counts})
|
||||
pairs.append(info)
|
||||
measurements=check_measurement_branches(board,contract.get('measurement_branches',[]) if n==1 else [])
|
||||
errors.extend(measurements['errors'])
|
||||
return {'errors':errors,'explicit_usb_pairs':pairs,'measurement_branches':measurements,
|
||||
'note':'Track-only lengths exclude package/connector delay and vertical via delay; impedance and coupling require routed review'}
|
||||
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--release',action='store_true')
|
||||
args=ap.parse_args()
|
||||
contract=json.loads((ROOT/'pcb-routing-rules.json').read_text(encoding='utf-8'))
|
||||
result={'status':'PLACEMENT_CHECKPOINT_NOT_PRODUCTION_RELEASE','boards':{}}
|
||||
boards=[]
|
||||
errors=[]
|
||||
for n in (1,2):
|
||||
name=f'PhoneRail-Board{n}'
|
||||
path=ROOT/name/(name+'.kicad_pcb')
|
||||
project=json.loads(path.with_suffix('.kicad_pro').read_text(encoding='utf-8'))
|
||||
patterns={v['pattern']:v['netclass'] for v in project['net_settings']['netclass_patterns']}
|
||||
expected=contract['boards'][name]['net_classes']
|
||||
assert all(patterns.get(k)==v for k,v in expected.items() if v!='Default'),'Net-class rules have drifted'
|
||||
b=pcb.LoadBoard(str(path)); boards.append(b)
|
||||
source=Board(n); source.compile(CLI,OUT)
|
||||
parity=verify_import(b,source,n,allow_routes=True)
|
||||
parts={f.GetReference():f for f in b.GetFootprints()}
|
||||
if n == 1:
|
||||
mechanical=json.loads((ROOT/'pcb-mechanical.json').read_text(encoding='utf-8'))
|
||||
input_spec=mechanical['input_connector_kicad']
|
||||
connector=parts[input_spec['reference']]
|
||||
assert list(point(connector)) == input_spec['position_mm'], 'Input connector moved from reviewed board-edge position'
|
||||
assert connector.GetOrientationDegrees() % 360 == input_spec['angle_deg'] % 360, 'Input connector must open toward the left edge'
|
||||
before=pcb.LoadBoard(str(OUT/'before'/path.name))
|
||||
anchors={f.GetReference():f for f in before.GetFootprints() if f.IsLocked()}
|
||||
for ref,f in anchors.items():
|
||||
current=parts[ref]
|
||||
assert point(f)==point(current) and f.GetOrientationDegrees()==current.GetOrientationDegrees() and f.GetLayer()==current.GetLayer(),ref
|
||||
assert [(p.GetNumber(),point(p),p.GetNetname()) for p in f.Pads()]==[(p.GetNumber(),point(p),p.GetNetname()) for p in current.Pads()],ref
|
||||
out_of_board=[r for r,f in parts.items() if not (50<=point(f)[0]<=270 and 50<=point(f)[1]<=185)]
|
||||
assert not out_of_board,out_of_board
|
||||
# Native class evaluation is checked after loading the associated project.
|
||||
drc=OUT/f'Board{n}-drc.json'
|
||||
run(['pcb','drc','--format','json','--schematic-parity','-o',str(drc),str(path)],drc)
|
||||
data=json.loads(drc.read_text(encoding='utf-8'))
|
||||
metrics=distances(parts,n)
|
||||
route=routing(b,contract,n)
|
||||
for face,layers in [('top','F.Cu,F.Silkscreen,Edge.Cuts'),('bottom','B.Cu,B.Silkscreen,Edge.Cuts')]:
|
||||
svg=OUT/f'Board{n}-{face}.svg'
|
||||
run(['pcb','export','svg','--mode-single','--layers',layers,'--page-size-mode','2',
|
||||
'--exclude-drawing-sheet','-o',str(svg),str(path)],svg)
|
||||
preview(b,n)
|
||||
entry={'electrical_footprints':len(source.parts),'all_inside_board':True,'unchanged_anchors':len(anchors),
|
||||
'pad_net_assignments_checked':parity['pad_net_assignments_checked'],
|
||||
'drc_violations':dict(Counter(v['type'] for v in data.get('violations',[]))),
|
||||
'schematic_parity':data.get('schematic_parity',[]),
|
||||
'drc_report_unconnected_items':len(data.get('unconnected_items',[])),
|
||||
'critical_distances':metrics,'routing':route,'erc':source.erc,'tracks':len(list(b.GetTracks()))}
|
||||
b.BuildConnectivity()
|
||||
b.GetConnectivity().RecalculateRatsnest()
|
||||
entry['unconnected_items']=b.GetConnectivity().GetUnconnectedCount(False)
|
||||
entry['unconnected_count_source']='Native connectivity engine; DRC report list may be incomplete'
|
||||
result['boards'][name]=entry
|
||||
if data.get('violations') or data.get('schematic_parity') or route['errors'] or any(not m['pass'] for m in metrics):
|
||||
errors.append(name)
|
||||
print(name,'DRC',entry['drc_violations'],'unconnected',entry['unconnected_items'],
|
||||
'distance failures',[m for m in metrics if not m['pass']],flush=True)
|
||||
result['mated_pins_checked']=mating_check(*boards)
|
||||
lower={f.GetReference():f for f in boards[0].GetFootprints()}
|
||||
upper={f.GetReference():f for f in boards[1].GetFootprints()}
|
||||
collisions=[]
|
||||
for ch in range(1,6):
|
||||
ref=f'C{801+(ch-1)*10}'
|
||||
rect=expand(box_mm(upper[ref]),1)
|
||||
collisions.extend((ref,r) for r,f in lower.items() if f.GetLayer()==pcb.F_Cu and intersects(rect,box_mm(f)))
|
||||
result['tall_cap_projection_collisions']=collisions
|
||||
if collisions:errors.append('height projection')
|
||||
result['checkpoint_passed']=not errors
|
||||
if any(v['tracks'] for v in result['boards'].values()):
|
||||
result['status']='PARTIAL_ROUTING_CHECKPOINT_NOT_PRODUCTION_RELEASE'
|
||||
total_airwires=sum(v['unconnected_items'] for v in result['boards'].values())
|
||||
result['release_blockers']=[f'{total_airwires} current unconnected items',
|
||||
'Full routing, reference planes and thermal copper not implemented',
|
||||
'ERC pin review remains open','Controlled impedance stackup unconfirmed',
|
||||
'Complete height/antenna/connector assembly and worst-case thermal qualification remain open']
|
||||
(OUT/'verification.json').write_text(json.dumps(result,indent=2,ensure_ascii=False)+'\n',encoding='utf-8')
|
||||
if errors or args.release:raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Run native DRC, schematic parity, and render the two unrouted PCB boards."""
|
||||
from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pcbnew as pcb
|
||||
|
||||
from bootstrap_pcb import ROOT, OUT, mating_check, verify_import
|
||||
from check_hardware import Board
|
||||
|
||||
CLI = 'D:/KiCad/bin/kicad-cli.exe'
|
||||
|
||||
|
||||
def run(args, output):
|
||||
result = subprocess.run([CLI, *args], capture_output=True, encoding='utf-8',
|
||||
errors='replace', timeout=120)
|
||||
output.with_suffix(output.suffix+'.log').write_text(result.stdout+result.stderr,encoding='utf-8')
|
||||
if result.returncode or not output.exists():
|
||||
raise RuntimeError(f'KiCad failed ({result.returncode}); see {output}.log')
|
||||
|
||||
|
||||
def main():
|
||||
reports, boards = {}, []
|
||||
for n in (1,2):
|
||||
name = f'PhoneRail-Board{n}'
|
||||
path = ROOT/name/(name+'.kicad_pcb')
|
||||
source = Board(n)
|
||||
source.compile(CLI,OUT)
|
||||
board = pcb.LoadBoard(str(path))
|
||||
checks = verify_import(board,source,n)
|
||||
boards.append(board)
|
||||
drc = OUT/f'Board{n}-drc.json'
|
||||
run(['pcb','drc','--format','json','--schematic-parity','-o',str(drc),str(path)],drc)
|
||||
data = json.loads(drc.read_text(encoding='utf-8'))
|
||||
checks['drc_violations'] = dict(Counter(v['type'] for v in data.get('violations',[])))
|
||||
checks['unconnected_items'] = len(data.get('unconnected_items',[]))
|
||||
checks['schematic_parity'] = data.get('schematic_parity',[])
|
||||
assert not checks['schematic_parity'], f'{name}: schematic parity mismatch'
|
||||
assert not data.get('violations'), f'{name}: DRC geometry/library violation; see {drc}'
|
||||
roundtrip = OUT/f'{name}-roundtrip.kicad_pcb'
|
||||
pcb.SaveBoard(str(roundtrip),board)
|
||||
verify_import(pcb.LoadBoard(str(roundtrip)),source,n)
|
||||
checks['native_save_roundtrip'] = 'passed'
|
||||
for face,layers in [('top','F.Cu,Edge.Cuts,Dwgs.User'),('bottom','B.Cu,Edge.Cuts,Dwgs.User')]:
|
||||
svg = OUT/f'Board{n}-{face}.svg'
|
||||
run(['pcb','export','svg','--mode-single','--layers',layers,'--page-size-mode','2',
|
||||
'--exclude-drawing-sheet','-o',str(svg),str(path)],svg)
|
||||
reports[name] = checks
|
||||
print(name, 'DRC', checks['drc_violations'], 'unconnected',checks['unconnected_items'],
|
||||
'schematic parity',len(checks['schematic_parity']),flush=True)
|
||||
reports['mated_pins_checked'] = mating_check(*boards)
|
||||
reports['status'] = 'UNROUTED_PLACEMENT_START'
|
||||
(OUT/'verification.json').write_text(json.dumps(reports,indent=2,ensure_ascii=False)+'\n',encoding='utf-8')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
在新工单中引用
屏蔽一个用户