kicad:EasyEDA→KiCad 工程(Board1/Board2)、库、脚本与评审文档(IC 引脚、风扇、热、EDL 供电、布局规则)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-10-04 19:24:42 +08:00
共同撰写人 Claude Opus 5.5
父节点 1d842cf45f
当前提交 bfe094a5d7
共修改 173 个文件,包含 401864 行新增和 0 行删除
+90
查看文件
@@ -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()
+91
查看文件
@@ -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()
+487
查看文件
@@ -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()
+241
查看文件
@@ -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())
+77
查看文件
@@ -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()
+95
查看文件
@@ -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()
+70
查看文件
@@ -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()
+128
查看文件
@@ -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))
+64
查看文件
@@ -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()
+79
查看文件
@@ -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()
+20
查看文件
@@ -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')
+68
查看文件
@@ -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'}
+163
查看文件
@@ -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
+374
查看文件
@@ -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()
+61
查看文件
@@ -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()
+53
查看文件
@@ -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()
+17
查看文件
@@ -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()
+224
查看文件
@@ -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()
+102
查看文件
@@ -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()
+239
查看文件
@@ -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()
+303
查看文件
@@ -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()
+225
查看文件
@@ -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()
+148
查看文件
@@ -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()
+136
查看文件
@@ -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()
+125
查看文件
@@ -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()
+220
查看文件
@@ -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))
+155
查看文件
@@ -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()
+39
查看文件
@@ -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
}
+62
查看文件
@@ -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
+129
查看文件
@@ -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()
+65
查看文件
@@ -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))
+30
查看文件
@@ -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()
+243
查看文件
@@ -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()
+58
查看文件
@@ -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()