网表脚本,后面可以提前检查硬件的错误

This commit is contained in:
2026-09-16 13:47:59 +08:00
parent e7348c48ba
commit 8d331031d6
4 changed files with 2797 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""netlist_sem.py - KiCad Pcbnew 导出网表 (.tel) 语义化工具
将精简导出网表($PACKAGES/$NETS,无料号、无引脚功能名)结合引脚知识库
(pinout_kb.json) 与规则引擎,生成紧凑的结构化语义图(Markdown),
供 LLM / 工程师快速理解板级连接。
用法(仓库根目录,统一 venv 解释器):
& '.venv\Scripts\python.exe' tools/netlist_semantics/netlist_sem.py dump-pins docs/硬件平台/V0.0.tel
& '.venv\Scripts\python.exe' tools/netlist_semantics/netlist_sem.py semantic docs/硬件平台/V0.0.tel -o docs/硬件平台/V0.0_网表语义图.md
纯标准库实现,无第三方依赖。
"""
import argparse
import json
import os
import re
import sys
from collections import OrderedDict, defaultdict
# ---------------------------------------------------------------- 解析
REF_RE = re.compile(r'^[A-Z]{1,4}\d+$')
def _group_entries(lines, is_start=None):
"""按条目分组。is_start(ln) 判断是否新条目起始行;缺省:非空白起始行=新条目。"""
if is_start is None:
def is_start(ln):
return ln[0] not in ' \t'
entries = []
cur = []
for ln in lines:
if ln.strip() == '':
continue
if is_start(ln):
if cur:
entries.append('\n'.join(cur))
cur = [ln]
else:
if not cur:
cur = [ln.strip()]
else:
cur.append(ln.strip())
if cur:
entries.append('\n'.join(cur))
return entries
def _tokens_after(text, sep=';'):
head, _, tail = text.partition(sep)
tail = tail.replace(',', ' ')
return head.strip(), [t for t in tail.split() if t]
def _parse_package(entry):
"""解析单个 PACKAGE 条目,返回 (fp, value, [refs])。
兼容两种行格式:
单行: FP ! FP ! '{Value}' ; REF1 REF2 ...
多行: FP ! FP ! '{Value}' , 然后续行列 REF
字段(封装/值)取首行按 ! 分割;refs 取首行 ';' 之后 + 所有续行。
"""
lines = entry.split('\n')
first = lines[0].strip()
head = first.split(';', 1)[0] if ';' in first else first
fields = [x.strip() for x in head.split('!')]
fp = fields[0] if fields else ''
value = fields[2].strip().rstrip(',') if len(fields) > 2 else ''
refpart = first.split(';', 1)[1] if ';' in first else ''
if len(lines) > 1:
refpart = (refpart + '\n' if refpart else '') + '\n'.join(lines[1:])
toks = re.split(r'[\s,]+', refpart.replace(',', ' '))
refs = [t for t in toks if t and REF_RE.match(t)]
return fp, value, refs
def parse_netlist(path):
"""解析 .tel。返回 (packages, nets)
packages: {ref: {'fp': 封装, 'value': 值}}
nets: {net: [(ref, pin), ...]}
"""
with open(path, encoding='utf-8', errors='replace') as f:
text = f.read()
sections = {}
name, buf = None, []
for ln in text.splitlines():
if ln.startswith('$'):
if name:
sections[name] = buf
name = ln.strip().lstrip('$')
buf = []
elif name:
buf.append(ln)
if name:
sections[name] = buf
packages = {}
for entry in _group_entries(sections.get('PACKAGES', []),
lambda ln: ' ! ' in ln):
fp, value, refs = _parse_package(entry)
if not fp:
continue
for r in refs:
packages[r] = {'fp': fp, 'value': value}
nets = {}
for entry in _group_entries(sections.get('NETS', [])):
head, toks = _tokens_after(entry)
name = head.strip().strip("'\"")
if not name:
continue
pins = []
for t in toks:
ref, _, pin = t.rpartition('.')
if REF_RE.match(ref) and pin:
pins.append((ref, pin))
if pins:
nets[name] = pins
return packages, nets
def build_pin_index(packages, nets):
"""pin_index[ref][pin] = [net, ...];unused_refs = 有封装无网络的位号"""
pin_index = defaultdict(lambda: defaultdict(list))
seen_refs = set()
for net, pins in nets.items():
for ref, pin in pins:
pin_index[ref][pin].append(net)
seen_refs.add(ref)
for ref in list(pin_index):
if ref not in packages:
pass # 网络中出现但 PACKAGES 缺失(如被 DNP 隐藏),仍保留
unused = [r for r in packages if r not in seen_refs]
return pin_index, sorted(unused)
# ---------------------------------------------------------------- 知识库
def load_kb(path):
"""返回 (instances, nets)
instances: {ref: {mpn, pkg, desc}}
nets: {net: {func, gpio, note}}
说明:本工程 KiCad 符号引脚号为任意编号(非数据手册脚号,如 U17.133=PB3),
故语义标注落在【网络名】与【位号】两级,不做引脚级断言。
"""
with open(path, encoding='utf-8') as f:
kb = json.load(f)
return kb.get('instances', {}), kb.get('nets', {})
# ---------------------------------------------------------------- 分类规则
GROUND_NETS = {'GND', 'AGND', 'PGND', 'CGND'}
RAIL_NETS = {'3.3V', '5V', 'C3.3V', 'G3.3V', 'AVCC', 'AVDDL', 'STM_VDDA', 'VDD50'}
CLOCK_NETS = {'XI', 'XO', 'RXI', 'RXO', 'CLOCK'}
def classify_net(name, pins=None, pin_index=None):
"""返回 (类别, 说明)。类别: GND / POWER / CLOCK / SIGNAL
串行时钟 (SCK/SCL/SCLK) 归 SIGNAL(接口信号),仅系统/晶振时钟归 CLOCK。"""
if name in GROUND_NETS or name.endswith('_GND'):
return 'GND', '地'
if name in RAIL_NETS or re.fullmatch(r'\d+\.\d+V', name) or name.startswith('VDD'):
return 'POWER', '电源轨'
if name in CLOCK_NETS:
return 'CLOCK', '时钟'
return 'SIGNAL', '信号'
# 接口分组规则:按网名匹配(顺序敏感,先匹配先归组)
INTERFACE_RULES = [
('以太网 MDI', 'U2 RTL8305 双口 MDI → T1 网络变压器 + J1/J2 RJ45(端口A/B);U1 CH395F 侧为无名网',
lambda n: n in ('RX_P', 'RX_N', 'TX_P', 'TX_N') or
re.fullmatch(r'RX[AB]_[PN]|TX[AB]_[PN]', n) is not None),
('SPI-NAND (SPI1)', 'U17 SPI1 → U18 74LVC16245 → U19 GD5F2GQ5UE NAND(双向缓冲)',
lambda n: n.startswith('GD_') or n in ('SPI1_SOMI', 'SPI1_SIMO')),
('SPI-ETH (SPI2)', 'U17 SPI2 → U1 CH395F(SPI 以太网)',
lambda n: n.startswith('CH395F_')),
('I2C-时钟', 'U17 I2C → U16 SD2506 时钟/RTC 芯片',
lambda n: n in ('SD_SDA', 'SD_SCL')),
('UART-ST', 'U17 USART → U9/U10/U11 隔离器 → RS232/RS485 通道',
lambda n: re.fullmatch(r'ST_(TX|RX)\d', n) is not None or n == 'ST_DIR4'),
('RS-485', 'U11 信号隔离 + U8 光耦(DIR) → U15 TP8485E → U12 ESD → CN1 485 总线',
lambda n: n in ('RS485_A', 'RS485_B', 'TX4', 'RX4', 'DIR4')),
('RS-232', 'U9/U10 隔离 → U13 双通道 232 收发器 → RS232 总线(CN1 / U14)',
lambda n: re.fullmatch(r'RS232_(TX|RX)\d', n) is not None or
re.fullmatch(r'[TR]X\d', n) is not None),
('LCD 8080 并口', 'U17 FSMC → H1 16P 排针 → 板外 LCD(16bit 8080 并口)',
lambda n: n.startswith('LCD_')),
('ADC 并口', 'U17 FSMC 读 ↔ U3 TPAFE5160(16bit + 控制线)',
lambda n: n.startswith('TP_') or re.fullmatch(r'DB\d+', n) is not None),
('ADC 输入', 'U3 TPAFE5160 8 通道输入(经 R 分压/保护至 AIN)',
lambda n: n.startswith('AIN') or n.startswith('ADC')),
('按键', 'U17 GPIO 按键(上拉 3.3V,低有效)',
lambda n: re.fullmatch(r'KEY\d+', n) is not None),
('子板接口 (U18)', 'U17 ↔ U18 74LVC16245 ↔ U5 WINGTAT 子板(继电器/告警/状态/外键)',
lambda n: n.startswith('EXTK') or n.startswith('HT_') or n.startswith('ST_IN_') or
n.endswith('_LED') or n in ('RELAY_STO', 'H16', 'V15', 'J3', 'F3', 'K3', 'RAS',
'DQM0', 'DQM1', 'WAIT1', 'SDCKE', 'CF_P', 'CF_Q',
'V_ZL1', 'V_ZL2', 'R16')),
('SWD/JTAG 调试', 'U17 调试口 → H2 3P 排针(SWCLK/SWDIO/GND)',
lambda n: n in ('SWCLK', 'SWDIO', 'NRST')),
('状态指示', '板级 LED / PHY 中断 / 显示控制',
lambda n: re.fullmatch(r'LED\d+', n) is not None or
n in ('PBLED', 'DIS_EEE', 'INT')),
]
def group_interfaces(signals):
groups = OrderedDict()
ungrouped = []
for net in signals:
placed = False
for gname, gdesc, pat in INTERFACE_RULES:
if pat(net):
groups.setdefault(gname, {'desc': gdesc, 'nets': []})['nets'].append(net)
placed = True
break
if not placed:
ungrouped.append(net)
return groups, ungrouped
# ---------------------------------------------------------------- 输出
def render_semantic(packages, nets, pin_index, instances, nets_kb):
L = []
ap = L.append
def net_ann(net):
"""网络语义注释(来自知识库 nets 表);无名网($..)返回空。"""
if net.startswith('$'):
return ''
ann = nets_kb.get(net)
if not ann:
return ''
bits = []
if ann.get('func'):
bits.append(ann['func'])
if ann.get('gpio'):
bits.append(ann['gpio'])
if ann.get('note'):
bits.append(ann['note'])
return ' '.join(bits)
ics = sorted((r for r in pin_index if r.startswith('U')),
key=lambda r: int(re.sub(r'\D', '', r) or 0))
oths = sorted((r for r in pin_index if not r.startswith('U')))
ap('# V0.0 网表语义图(自动生成)')
ap('')
ap('> 由 `tools/netlist_semantics/netlist_sem.py` 从 `V0.0.tel` + `pinout_kb.json` 生成。')
ap('> 网表仅含位号/引脚号/网名;MPN 与信号语义由知识库补全。')
ap('> 注意:KiCad 符号引脚号为任意编号(非数据手册脚号,如 U17.133=PB3),引脚功能以【网名+知识库】为准。')
ap('')
ap('## 1. 概览')
ap('')
ap(f'- 元件位号: {len(packages)} 组 / 网络: {len(nets)} 条')
n_gnd = sum(1 for n in nets if n in GROUND_NETS or n.endswith('_GND'))
n_rail = sum(1 for n in nets if n in RAIL_NETS)
n_unnamed = sum(1 for n in nets if n.startswith('$'))
ap(f'- 地网络: {n_gnd} / 电源轨: {n_rail} / 未命名(内部)网: {n_unnamed}')
ap('')
# --- 电源域
ap('## 2. 电源与地')
ap('')
ap('| 网络 | 类别 | 连接引脚数 | 主要去向 |')
ap('|---|---|---|---|')
for net in sorted(nets, key=lambda n: (n not in RAIL_NETS, n not in GROUND_NETS, n)):
cat, _ = classify_net(net, nets[net], pin_index)
if cat not in ('GND', 'POWER'):
continue
pins = nets[net]
ics_hit = sorted(set(r for r, _ in pins if r.startswith('U')))
others = sorted(set(r for r, _ in pins if not r.startswith('U')))
dest = ', '.join(ics_hit[:4] + (['…'] if len(ics_hit) > 4 else []))
if others:
dest += f" / {len(others)}×被动件"
ap(f'| {net} | {cat} | {len(pins)} | {dest} |')
ap('')
# --- 接口
groups, ungrouped = group_interfaces(
[n for n in nets if not n.startswith('$') and
classify_net(n, nets[n], pin_index)[0] == 'SIGNAL'])
ap('## 3. 接口与总线')
ap('')
for gname, g in groups.items():
ap(f'### {gname}')
ap('')
ap(f'{g["desc"]}')
ap('')
ap('| 网络 | 语义(知识库) | 连接 |')
ap('|---|---|---|')
for net in g['nets']:
pins = nets[net]
desc = ', '.join(f'{r}.{p}' for r, p in pins[:8]) + (' …' if len(pins) > 8 else '')
ap(f'| {net} | {net_ann(net) or "—"} | {desc} |')
ap('')
if ungrouped:
ap('### 未归类信号')
ap('')
ap(', '.join(ungrouped))
ap('')
# --- 芯片引脚
ap('## 4. 芯片引脚')
ap('')
for ref in ics:
info = instances.get(ref, {})
mpn = info.get('mpn', '?')
desc = info.get('desc', '')
fp = packages.get(ref, {}).get('fp', '?')
pins = pin_index[ref]
ap(f'### {ref} — {mpn}')
ap('')
if desc:
ap(f'{desc}')
ap('')
ap(f'封装: `{fp}`({len(pins)} 脚入网)')
ap('')
ap('| Pin | 网络 | 语义(知识库) |')
ap('|---|---|---|')
def _key(kv):
p = kv[0]
m = re.fullmatch(r'(\d+)', p)
return (0, int(m.group(1))) if m else (1, p)
for pin, nets_h in sorted(pins.items(), key=_key):
uniq = sorted(set(nets_h))
ann = ';'.join(a for a in (net_ann(n) for n in uniq) if a) or '—'
ap(f'| {pin} | {", ".join(uniq)} | {ann} |')
ap('')
# --- 其余器件
ap('## 5. 连接件/晶振/变压器/无源/分立')
ap('')
ap('| 位号 | 器件(知识库 MPN) | 引脚→网络 |')
ap('|---|---|---|')
for ref in oths:
info = instances.get(ref, {})
mpn = info.get('mpn', '')
fp = packages.get(ref, {}).get('fp', '?')
what = f'{mpn}({fp})' if mpn else fp
pins = pin_index[ref]
desc = '; '.join(f'{p}→{", ".join(sorted(set(ns)))}'
for p, ns in sorted(pins.items(), key=lambda kv: kv[0]))
ap(f'| {ref} | {what} | {desc} |')
ap('')
return '\n'.join(L) + '\n'
def dump_pins(packages, nets):
pin_index, unused = build_pin_index(packages, nets)
print(f'== PACKAGES: {len(packages)} refs; NETS: {len(nets)} ==')
print(f'== refs in packages but on no net: {unused} ==')
ics = sorted((r for r in pin_index if r.startswith('U')),
key=lambda r: int(re.sub(r'\D', '', r) or 0))
oths = sorted(r for r in pin_index if not r.startswith('U'))
for ref in ics + oths:
fp = packages.get(ref, {}).get('fp', '?')
pins = pin_index[ref]
print(f'\n### {ref} [{fp}] ({len(pins)} pins)')
def _key(kv):
p = kv[0]
m = re.fullmatch(r'(\d+)', p)
return (0, int(m.group(1))) if m else (1, p)
for pin, nets_h in sorted(pins.items(), key=_key):
print(f' {pin:>4} {", ".join(sorted(set(nets_h)))}')
# ---------------------------------------------------------------- main
def main():
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser(
description='KiCad Pcbnew .tel 网表语义化(解析 + 引脚知识库 + 规则引擎)')
sub = ap.add_subparsers(dest='cmd', required=True)
p1 = sub.add_parser('dump-pins', help='按位号列出 引脚→网络(用于编制知识库)')
p1.add_argument('netlist')
p2 = sub.add_parser('semantic', help='生成语义图 Markdown')
p2.add_argument('netlist')
p2.add_argument('-o', '--out', default=None,
help='输出文件(缺省打印到 stdout)')
p2.add_argument('--kb', default=os.path.join(here, 'pinout_kb.json'))
a = ap.parse_args()
packages, nets = parse_netlist(a.netlist)
if a.cmd == 'dump-pins':
dump_pins(packages, nets)
else:
pin_index, unused = build_pin_index(packages, nets)
instances, nets_kb = load_kb(a.kb)
doc = render_semantic(packages, nets, pin_index, instances, nets_kb)
if a.out:
with open(a.out, 'w', encoding='utf-8', newline='\n') as f:
f.write(doc)
print(f'written: {a.out} ({len(doc)} chars)')
else:
sys.stdout.write(doc)
print(f'[stats] packages={len(packages)} nets={len(nets)} '
f'kb_instances={len(instances)} kb_nets={len(nets_kb)} unused={unused}',
file=sys.stderr)
if __name__ == '__main__':
main()