数据 (CLM 气动夹紧缸分类): - 1 系列 demo -> 11 个官方系列, 6 型号 -> 859 个真实型号 - 参数化升级: 仅缸径 -> 最多 5 维 (缸径/臂位置/臂类型/臂样式/传感器) - 11 系列各含官方图册附图 (84 张) + datasheet PDF - 修复: 枚举维度空串必须为合法值 (否则级联过滤后下拉全空) - 修复: catalog.json 分类 series 数组未更新导致新系列不进包 - 修复: _step 副本后缀归一化错误 (产生假型号) 工具: - gen-clamp-series.py 数据清洗 (X盘数模 -> CSV/meta/STEP) - copy-clamp-step.py 8线程并行复制 (网络盘 I/O 优化) - gen-clamp-manual.py 官方图册 PDF -> 附图 - update-clamp-meta-attach.py / fix-clamp-meta-empty-enum.py 仓库瘦身: - .gitignore 排除数模(5GB)/.opc(1.2GB)/编译产物/发布产物 - 移出索引: release/ bin产物 STEP数模 .opc 大型PDF 等 - 提交体积 17MB (原 15.97GB) 其他: SelfTest.cs 可解析选型逻辑放宽 (稀疏数据用真实变体回验) Co-Authored-By: Claude Code <noreply@anthropic.com>
249 lines
12 KiB
Python
249 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
夹紧缸数据清洗脚本 v2(Univer 真实数模 -> OnebotCatalog 目录数据)
|
||
最终方案:11 个系列 + 5 维参数(缸径/臂位置/臂类型/臂样式/传感器)
|
||
用法:
|
||
PYTHONUTF8=1 python gen-clamp-series.py --dry-run # 只分析打印,不写文件
|
||
PYTHONUTF8=1 python gen-clamp-series.py # 正式生成
|
||
"""
|
||
import os, re, sys, shutil, collections, json
|
||
|
||
SRC = r'X:/新开发产品-Research and Development/1_产品3D数模(发客户仿真)/1-夹紧缸'
|
||
DST = r'D:/开发/OnebotCatalog/onebot-data/catalog'
|
||
BORES = ['25缸', '32缸', '40缸', '50缸', '63缸', '80缸']
|
||
DRY = '--dry-run' in sys.argv
|
||
|
||
LEGAL_BORES = [80, 63, 50, 40, 32, 25]
|
||
|
||
# 系列前缀映射(旧码 -> 官方 UC 前缀码;UFL/UHL 单独系列)
|
||
PREFIX_MAP = [
|
||
('UCBP', 'UCBP'), ('UBP', 'UCBP'),
|
||
('UCBM', 'UCBM'), ('UBM', 'UCBM'),
|
||
('UCBT', 'UCBT'), ('UBT', 'UCBT'),
|
||
('UCBQ', 'UCBQ'), ('UBQ', 'UCBQ'),
|
||
('UCBH', 'UCBH'), ('UBH', 'UCBH'),
|
||
('UCDP', 'UCDP'), ('UDP', 'UCDP'),
|
||
('UCIM', 'UCIM'), ('IBM', 'UCIM'),
|
||
('UCIP', 'UCIP'), ('IBP', 'UCIP'),
|
||
('UCNM', 'UCNM'), ('UNM', 'UCNM'),
|
||
('UCNP', 'UCNP'), ('UNP', 'UCNP'),
|
||
('UFL', 'UFL'), ('UHL', 'UFL'),
|
||
]
|
||
|
||
SERIES_NAME = {
|
||
'UCBP': ('UCBP 标准气动夹紧缸', 'UCBP Standard Pneumatic Power Clamp', 'ucbp jiajin clamping standard'),
|
||
'UCBM': ('UCBM 带手杆气动夹紧缸', 'UCBM Pneumatic Power Clamp with Hand Lever', 'ucbm jiajin lever'),
|
||
'UCBT': ('UCBT 带中央手杆气动夹紧缸', 'UCBT Pneumatic Power Clamp with Central Hand Lever', 'ucbt jiajin central lever'),
|
||
'UCBQ': ('UCBQ 带手杆补偿夹紧缸', 'UCBQ Power Clamp with Hand Lever & Compensation', 'ucbq jiajin compensation'),
|
||
'UCBH': ('UCBH 夹紧缸 Ø63/Ø22', 'UCBH Power Clamp Ø63/Ø22', 'ucbh jiajin'),
|
||
'UCDP': ('UCDP 双夹臂气动夹紧缸', 'UCDP Double Arm Pneumatic Power Clamp', 'ucdp jiajin double arm'),
|
||
'UCIM': ('UCIM 可互换带手杆夹紧缸', 'UCIM Interchangeable Power Clamp with Hand Lever', 'ucim jiajin interchangeable'),
|
||
'UCIP': ('UCIP 可互换气动夹紧缸', 'UCIP Interchangeable Pneumatic Power Clamp', 'ucip jiajin interchangeable'),
|
||
'UCNM': ('UCNM NAAMS 带手杆夹紧缸', 'UCNM NAAMS Power Clamp with Hand Lever', 'ucnm naams jiajin'),
|
||
'UCNP': ('UCNP NAAMS 气动夹紧缸', 'UCNP NAAMS Pneumatic Power Clamp', 'ucnp naams jiajin'),
|
||
'UFL': ('UFL/UHL 停产气动夹紧缸', 'UFL/UHL Legacy Pneumatic Power Clamp (Discontinued)', 'ufl uhl jiajin legacy'),
|
||
}
|
||
|
||
POS_DISPLAY = {'V': '90°', 'O': '180°', 'N': '无臂', '': '不适用'}
|
||
TYPE_DISPLAY = {'C': '中央 15mm', 'D': '右 15mm', 'S': '左 15mm', 'E': '中央 45mm',
|
||
'F': '右 45mm', 'L': '左 45mm', 'N': '无臂', '': '不适用'}
|
||
STYLE_DISPLAY = {'A': '铝臂', 'S': '钢臂', 'N': '无臂', '': '不适用'}
|
||
SENSOR_DISPLAY = {'K': '电子 PNP M12', 'N': '无传感器', 'Y': '电子 PNP 白LED', 'D': '气动传感器', '': '不适用'}
|
||
|
||
# 杂音标记(这些出现说明文件是测试/副本,归一化时优先丢弃)
|
||
NOISE = re.compile(r'20\d{12,14}|PROVA|_ALLCATPART|ALLCATPART|-\d{2}-\d{2}-\d{4}')
|
||
|
||
|
||
def _extract_bore(s):
|
||
for b in LEGAL_BORES:
|
||
bs = str(b)
|
||
if s.startswith(bs):
|
||
return b, s[len(bs):]
|
||
return None
|
||
|
||
|
||
def normalize(name):
|
||
"""归一化 -> (model_code, series, bore, pos, type, style, sensor) 或 None"""
|
||
raw = os.path.splitext(name)[0]
|
||
# 排除含非 ASCII(中文夹臂/大孔等)
|
||
if not all(ord(c) < 128 for c in raw):
|
||
return None
|
||
base = raw.upper()
|
||
# 先剥离 "_step"/"_1" 副本后缀(必须在去下划线之前,否则 _ 已被抹掉匹配不到)
|
||
base = re.sub(r'[_\s]*_?STEP$', '', base) # UCBM40OESK0_step -> UCBM40OESK0
|
||
base = re.sub(r'_1$', '', base)
|
||
base = base.replace(' ', '').replace('_', '')
|
||
# 记录是否含杂音(用于去重优先级)
|
||
has_noise = bool(NOISE.search(base))
|
||
base = NOISE.sub('', base)
|
||
base = base.replace('-S', '\x01') # 钢臂标记占位(保护 -S 不被后续清理)
|
||
base = base.replace('-', '') # 去连字符
|
||
base = base.replace('\x01', '-S') # 恢复 -S
|
||
if not base:
|
||
return None
|
||
# 系列识别
|
||
series = rest = None
|
||
for pfx, code in PREFIX_MAP:
|
||
if base.startswith(pfx):
|
||
series, rest = code, base[len(pfx):]
|
||
break
|
||
if series is None or rest == '':
|
||
return None
|
||
# UFL/UHL 特殊缸径:UFL450->50, UHL463->63
|
||
if series == 'UFL':
|
||
if rest.startswith('450'):
|
||
bore, tail = 50, rest[3:]
|
||
elif rest.startswith('463'):
|
||
bore, tail = 63, rest[3:]
|
||
else:
|
||
return None
|
||
else:
|
||
r = _extract_bore(rest)
|
||
if r is None:
|
||
return None
|
||
bore, tail = r
|
||
# 解析 pos/type/style/sensor
|
||
pos = type_ = style = sensor = ''
|
||
i = 0
|
||
if i < len(tail) and tail[i] in 'VON':
|
||
pos, i = tail[i], i + 1
|
||
if i < len(tail) and tail[i] in 'CDSEFLN':
|
||
type_, i = tail[i], i + 1
|
||
if i < len(tail) and tail[i] in 'ASN':
|
||
style, i = tail[i], i + 1
|
||
if i < len(tail) and tail[i] in 'KNYD':
|
||
sensor, i = tail[i], i + 1
|
||
# 旧命名 -S 后缀 => 钢臂
|
||
if tail.endswith('-S') and style == '':
|
||
style = 'S'
|
||
return (base, series, bore, pos, type_, style, sensor, has_noise)
|
||
|
||
|
||
def collect():
|
||
search_roots = [os.path.join(SRC, b) for b in BORES] + [SRC]
|
||
found = collections.defaultdict(list)
|
||
for root in search_roots:
|
||
for dirpath, dirs, files in os.walk(root):
|
||
for f in files:
|
||
if f.lower().endswith(('.stp', '.step')):
|
||
found[f].append(os.path.join(dirpath, f))
|
||
# 候选:model -> 最佳源文件
|
||
best = {}
|
||
for fname, paths in found.items():
|
||
r = normalize(fname)
|
||
if r is None:
|
||
continue
|
||
model, series, bore, pos, type_, style, sensor, has_noise = r
|
||
cur = best.get(model)
|
||
# 优先级:干净名 > 杂音名;同优先级取 mtime 新
|
||
better = (cur is None
|
||
or (cur['has_noise'] and not has_noise)
|
||
or (cur['has_noise'] == has_noise
|
||
and os.path.getmtime(paths[0]) > os.path.getmtime(cur['src'])))
|
||
if better:
|
||
best[model] = dict(model=model, series=series, bore=bore, pos=pos,
|
||
type=type_, style=style, sensor=sensor,
|
||
src=paths[0], has_noise=has_noise)
|
||
return best
|
||
|
||
|
||
def main():
|
||
models = collect()
|
||
by_series = collections.defaultdict(list)
|
||
for m in models.values():
|
||
by_series[m['series']].append(m)
|
||
|
||
print('=== 归一化去重后型号总数:', len(models), '===')
|
||
for series in sorted(by_series, key=lambda s: -len(by_series[s])):
|
||
ms = by_series[series]
|
||
bs = sorted(set(m['bore'] for m in ms))
|
||
print(f'{series:6s} 型号 {len(ms):4d} 缸径 {bs}')
|
||
|
||
if DRY:
|
||
print()
|
||
for series in sorted(by_series):
|
||
ms = sorted(by_series[series], key=lambda m: (m['bore'], m['model']))
|
||
print(f'\n## {series} ({len(ms)} 个)')
|
||
for m in ms:
|
||
code = f"pos={m['pos'] or '-'} type={m['type'] or '-'} style={m['style'] or '-'} sensor={m['sensor'] or '-'}"
|
||
print(f" {m['model']:30s} bore={m['bore']:<3} {code}")
|
||
return
|
||
|
||
# 1) CSV
|
||
csv_dir = os.path.join(DST, 'csv')
|
||
for series, ms in by_series.items():
|
||
ms = sorted(ms, key=lambda m: (m['bore'], m['model']))
|
||
rows = ['model_code,bore,pos,type,style,sensor,step_file']
|
||
for m in ms:
|
||
rows.append(f"{m['model']},{m['bore']},{m['pos']},{m['type']},{m['style']},{m['sensor']},{m['model']}.step")
|
||
with open(os.path.join(csv_dir, series + '.csv'), 'w', encoding='utf-8-sig', newline='') as fp:
|
||
fp.write('\r\n'.join(rows) + '\r\n')
|
||
print(f'写 CSV: {series}.csv ({len(ms)} 行)')
|
||
|
||
# 2) meta.json
|
||
series_dir = os.path.join(DST, 'series')
|
||
for series, ms in by_series.items():
|
||
bores = sorted(set(m['bore'] for m in ms))
|
||
# 枚举维度: 空串 '' 是合法值(表示该维度"无/未指定"), 必须保留在 values 里
|
||
# (参考 KC 的 magnet values=['', 'M']) —— 否则 DomainValid 会把 "" 判非法,
|
||
# AllowedOptions 过滤后下拉全空。仅当除空串外无真值时, 才剔除该维度。
|
||
poss = [''] + sorted({m['pos'] for m in ms if m['pos']})
|
||
types = [''] + sorted({m['type'] for m in ms if m['type']})
|
||
styles = [''] + sorted({m['style'] for m in ms if m['style']})
|
||
sensors = [''] + sorted({m['sensor'] for m in ms if m['sensor']})
|
||
poss = poss if len(poss) > 1 else []
|
||
types = types if len(types) > 1 else []
|
||
styles = styles if len(styles) > 1 else []
|
||
sensors = sensors if len(sensors) > 1 else []
|
||
name_zh, name_en, kw = SERIES_NAME[series]
|
||
params = [
|
||
{"code": "bore", "nameZh": "缸径", "nameEn": "Bore", "keywords": "bore gangjing", "unit": "mm", "type": "number", "values": bores, "display": {}},
|
||
{"code": "pos", "nameZh": "臂位置", "nameEn": "Arm Position", "keywords": "pos arm position weizhi", "unit": "", "type": "enum", "values": poss, "display": {k: POS_DISPLAY.get(k, k) for k in poss}},
|
||
{"code": "type", "nameZh": "臂类型", "nameEn": "Arm Type", "keywords": "type arm leixing", "unit": "", "type": "enum", "values": types, "display": {k: TYPE_DISPLAY.get(k, k) for k in types}},
|
||
{"code": "style", "nameZh": "臂样式", "nameEn": "Arm Style", "keywords": "style arm yangshi", "unit": "", "type": "enum", "values": styles, "display": {k: STYLE_DISPLAY.get(k, k) for k in styles}},
|
||
{"code": "sensor", "nameZh": "传感器", "nameEn": "Sensor", "keywords": "sensor chuanganqi", "unit": "", "type": "enum", "values": sensors, "display": {k: SENSOR_DISPLAY.get(k, k) for k in sensors}},
|
||
]
|
||
# 值域为空的参数维度直接剔除(避免 UI 出现无值下拉;该维度在本系列数据里不存在)
|
||
params = [p for p in params if p['values']]
|
||
meta = {
|
||
"series": {
|
||
"code": series, "nameZh": name_zh, "nameEn": name_en, "keywords": kw,
|
||
"parameters": params,
|
||
"rules": [], "naming": {"stepNameTemplate": "{model}.step"},
|
||
"attachments": [], "variants": []
|
||
},
|
||
"attachments": [
|
||
{"kind": "manual", "lang": "", "source": f"manual/{series}/p01.png", "model": None},
|
||
{"kind": "datasheet", "lang": "", "source": f"manual/{series}/{series}_manual.pdf", "model": None},
|
||
]
|
||
}
|
||
with open(os.path.join(series_dir, series + '.meta.json'), 'w', encoding='utf-8') as fp:
|
||
json.dump(meta, fp, ensure_ascii=False, indent=1)
|
||
print(f'写 meta: {series}.meta.json bore={bores} pos={poss} type={types} style={styles} sensor={sensors}')
|
||
|
||
# 3) 复制 STEP
|
||
step_dir = os.path.join(DST, 'step')
|
||
copied = 0
|
||
for m in models.values():
|
||
dst_file = os.path.join(step_dir, m['model'] + '.step')
|
||
if not os.path.exists(dst_file):
|
||
shutil.copy2(m['src'], dst_file)
|
||
copied += 1
|
||
print(f'复制 STEP: {copied} 个(共 {len(models)} 型号)')
|
||
|
||
# 4) 更新 catalog.json:CLM 分类 series 数组
|
||
cat_path = os.path.join(DST, 'catalog.json')
|
||
with open(cat_path, 'r', encoding='utf-8-sig') as fp:
|
||
cat = json.load(fp)
|
||
series_order = ['UCBP', 'UCBM', 'UCBT', 'UCBQ', 'UCBH', 'UCDP', 'UCIM', 'UCIP', 'UCNM', 'UCNP', 'UFL']
|
||
for c in cat['categories']:
|
||
if c['code'] == 'CLM':
|
||
c['series'] = series_order
|
||
with open(cat_path, 'w', encoding='utf-8-sig') as fp:
|
||
json.dump(cat, fp, ensure_ascii=False, indent=1)
|
||
print('更新 catalog.json: CLM.series =', series_order)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|