v2.1: DESTACO 风格选型界面 + 参数组合补全(缺组合也能出零件号)
网页版选型改版 (仅网页版, 桌面版待同步)
- 新增零件号实时条: 边选边拼, 已确定片段实心高亮, 未定片段虚化, 进度条随选推进;
浅色配色 (初版深色块与页面不协调, 已改); 选满且唯一命中时整块转青色实心
- 技术方案: 40 系列中仅 KC 有编码模板 → 改用「当前候选型号的最长公共前缀」,
已验证 40/40 系列前缀单调增长(不回退)
- 参数选择由下拉菜单改为可视化选项块: 悬停抬升+青色投影, 选中青色填充+✓,
不可选取值置灰(虚线+划线, 不消失, 让用户看到选项存在但当前组合不适用),
再点一次取消; 有说明的显示代号+说明, 纯数值用紧凑文字块
- 参数联动仍复用 allowedOptions 规则引擎, 语义未变, 只改渲染
参数组合补全
- 原逻辑: 必须有真实数模才能生成型号 → 库里没有的组合选不出来也出不了号
- infer-sku-template.py 从已有型号反推编码模板, 得 23 个系列 100% 可复现
- verify-templates.py 作为门禁: 用模板重渲染每个已有型号必须逐字一致 ——
该检查抓出 ULM/ULP 行程补零位数写错(2位写3位), 否则会生成 3000 个不存在的零件号
- gen-missing-combos.py 补全 8 个系列共 3157 个型号 (全库 54136 → 57293)
- 占位数模: 3157 个型号共用 1 个 PLACEHOLDER.step(4.5KB), 构建器按路径打包只存一份,
数据包仅 2.10GB → 2.14GB (中途用硬链接方案踩坑: NTFS 上限 1024, 超出退化为拷贝白占 500MB)
- _pending_models.csv 输出待补数模清单, 供 AI 补完后替换 step_file
新增脚本: infer-sku-template / verify-templates / quantify-combo-gap /
gen-missing-combos / gen-placeholder-step / check-combo-unique
新增文档: docs/更改说明-v2.1.md
发现未处理 (已记录在更改说明):
- 夹紧缸 9 系列一组参数对应多型号 (310 个型号, 编码含参数外尾缀, 旧码/新码混用)
- UAGP/LAGP 参数顺序与官方编码结构不符 (传感器应在转角之前)
- 17 个系列因模板不干净未补全 (宁可少补, 不生成可能不存在的零件号)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
225
onebot-data/tools/gen-missing-combos.py
Normal file
225
onebot-data/tools/gen-missing-combos.py
Normal file
@@ -0,0 +1,225 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
补齐参数组合空间: 为"规则内合法但库中缺失"的组合生成零件号, 并指向系列级占位数模。
|
||||
|
||||
背景: 当前型号表由真实 STEP 文件扫描而来 —— 没有数模就没有型号, 用户选完也拿不到代号。
|
||||
本脚本按规则反向生成缺失组合, 使其可选、可出号; 数模用每系列一个占位件, 后续 AI 补真实数模。
|
||||
|
||||
用法:
|
||||
PYTHONUTF8=1 python gen-missing-combos.py --report # 只统计, 不写文件
|
||||
PYTHONUTF8=1 python gen-missing-combos.py --write # 写入 CSV + meta 模板 + 占位清单
|
||||
"""
|
||||
import json, io, os, csv, itertools, sys, collections
|
||||
|
||||
CAT = r'D:/开发/OnebotCatalog/onebot-data/catalog'
|
||||
REPORT = '--report' in sys.argv
|
||||
WRITE = '--write' in sys.argv
|
||||
|
||||
# 只有"模板能 100% 对齐"的系列才补 (由 infer-sku-template.py 验证)。
|
||||
# 其余系列 (旧码混用/编码结构特殊) 保持现状, 不生成可能不存在的零件号。
|
||||
SAFE = {
|
||||
'KC', 'KCB', 'KCS', 'KS', 'KSB', 'KSU', 'M', 'MS', 'MSC', 'MAL', 'MALC',
|
||||
'ULA', 'ULL', 'ULM', 'ULO', 'ULP', 'ULT', 'ULX', 'ULY', 'ULZ', 'USL', 'USSL',
|
||||
}
|
||||
|
||||
# 各系列的编码模板 (由 infer-sku-template.py 反推, 100% 对齐)
|
||||
# {参数:补零位数}; 空值渲染为空串
|
||||
TEMPLATES = {
|
||||
'KC': ('KC{type}{bore}-{stroke}{magnet}{mount}', {}),
|
||||
'KCB': ('KCB{bore}-{stroke}{magnet}{mount}', {}),
|
||||
'KCS': ('KCS{type}{bore}-{stroke}{magnet}{mount}', {}),
|
||||
'KS': ('KS{type}{bore}-{stroke}{magnet}{mount}', {}),
|
||||
'KSB': ('KSB{bore}-{stroke1}x{stroke2}{magnet}{mount}', {}),
|
||||
'KSU': ('KSU{type}{bore}-{stroke}{magnet}{mount}', {}),
|
||||
'M': ('{action}{bore}-{stroke}{magnet}{rod}{mount}{cap}', {}),
|
||||
'MS': ('{action}{bore}-{stroke}{magnet}{rod}{mount}{cap}', {}),
|
||||
'MSC': ('{action}{bore}-{stroke}{magnet}{rod}{mount}{cap}', {}),
|
||||
'MAL': ('{type}{bore}-{stroke}{magnet}{rod}{mount}{cap}', {}),
|
||||
'MALC': ('{type}{bore}-{stroke}{magnet}{rod}{mount}{cap}', {}),
|
||||
'ULA': ('ULA{size}{stroke:pad3}{rod}{mount}{sensor}0', {}),
|
||||
'ULL': ('ULL{size}{stroke:pad3}{rod}{orient}{sensor}0', {}),
|
||||
'ULM': ('ULM{size}{stroke:pad3}{rod}{orient}{sensor}0', {}),
|
||||
'ULO': ('ULO{size}{stroke:pad3}{rod}{sensor}0', {}),
|
||||
'ULP': ('ULP{size}{stroke:pad3}{rod}{orient}{sensor}0', {}),
|
||||
'ULT': ('ULT{size}{stroke:pad3}{rod}{sensor}0', {}),
|
||||
'ULX': ('ULX{size}{stroke:pad3}{rod}{sensor}0', {}),
|
||||
'ULY': ('ULY{size}{stroke:pad3}{rod}{orient}{sensor}0', {}),
|
||||
'ULZ': ('ULZ{size}{stroke:pad3}{rod}{orient}{sensor}0', {}),
|
||||
'USL': ('USL{size}{pcmodel}{pin_dia}{locating}{prog}{sensor}0', {}),
|
||||
'USSL': ('USSL{size}{pcmodel}{hook_pos}{pin_dia}{locating}{prog}{sensor}0', {}),
|
||||
}
|
||||
|
||||
PLACEHOLDER_STEP = 'PLACEHOLDER.step' # 每系列共用
|
||||
|
||||
|
||||
def S(v):
|
||||
return '' if v is None else str(v)
|
||||
|
||||
|
||||
def eq(a, b):
|
||||
return S(a).lower() == S(b).lower()
|
||||
|
||||
|
||||
def cond_match(c, prm):
|
||||
if not c:
|
||||
return True
|
||||
val = prm.get(c['Param'])
|
||||
if val is None:
|
||||
return False
|
||||
op, cv = c['Op'], c['Value']
|
||||
if op == 'eq':
|
||||
return eq(val, cv)
|
||||
if op == 'ne':
|
||||
return not eq(val, cv)
|
||||
if op == 'in':
|
||||
return isinstance(cv, list) and any(eq(val, x) for x in cv)
|
||||
try:
|
||||
a, b = float(S(val)), float(S(cv))
|
||||
except ValueError:
|
||||
return False
|
||||
return {'gt': a > b, 'ge': a >= b, 'lt': a < b, 'le': a <= b}.get(op, False)
|
||||
|
||||
|
||||
def violated(rules, prm):
|
||||
for r in rules or []:
|
||||
if cond_match(r.get('If'), prm) and not cond_match(r.get('Then'), prm):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def render(tpl, prm):
|
||||
"""模板渲染: {field} / {field:padN}; 空值渲染为空串。"""
|
||||
out, i = '', 0
|
||||
while i < len(tpl):
|
||||
if tpl[i] == '{':
|
||||
j = tpl.index('}', i)
|
||||
tok = tpl[i + 1:j]
|
||||
name, _, pad = tok.partition(':')
|
||||
name = name.strip()
|
||||
v = S(prm.get(name, ''))
|
||||
if pad.startswith('pad') and v != '':
|
||||
try:
|
||||
v = v.rjust(int(pad[3:]), '0')
|
||||
except ValueError:
|
||||
return None
|
||||
out += v
|
||||
i = j + 1
|
||||
else:
|
||||
out += tpl[i]
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def load_meta(code):
|
||||
p = os.path.join(CAT, 'series', code + '.meta.json')
|
||||
return json.load(io.open(p, encoding='utf-8'))
|
||||
|
||||
|
||||
def link_or_copy(src, dst):
|
||||
"""建硬链接; 不支持则回退复制。返回 True 表示已创建。
|
||||
注: 必须先建链接再考虑失败回退, 绝不能先删目标再建 (删了又建失败 = 文件丢失)。"""
|
||||
if os.path.exists(dst):
|
||||
return False
|
||||
try:
|
||||
os.link(src, dst)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(src, dst)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
summary = []
|
||||
plan = {}
|
||||
for code in sorted(SAFE):
|
||||
meta = load_meta(code)
|
||||
s = meta['series']
|
||||
params = s['parameters']
|
||||
csvp = os.path.join(CAT, 'csv', code + '.csv')
|
||||
if not os.path.exists(csvp):
|
||||
print(f'[{code}] 无 CSV, 跳过')
|
||||
continue
|
||||
rows = list(csv.DictReader(io.open(csvp, encoding='utf-8-sig')))
|
||||
existing = {r['model_code'] for r in rows}
|
||||
|
||||
# 值域: 数值参数取 CSV 实际出现过的值 (不用 meta.values, 避免含非法/未出现值)
|
||||
doms = collections.OrderedDict()
|
||||
for p in params:
|
||||
pc = p['code']
|
||||
if p['type'] == 'range':
|
||||
doms[pc] = None # 连续域不枚举
|
||||
continue
|
||||
vals = list(dict.fromkeys(r.get(pc, '') for r in rows))
|
||||
vals = [v for v in vals if v != '' or True]
|
||||
doms[pc] = vals
|
||||
if any(v is None for v in doms.values()):
|
||||
print(f'[{code}] 含 range 参数, 跳过 (连续域无法枚举)')
|
||||
continue
|
||||
|
||||
tpl, _ = TEMPLATES[code]
|
||||
codes = list(doms.keys())
|
||||
combos = list(itertools.product(*[doms[k] for k in codes]))
|
||||
added = []
|
||||
for combo in combos:
|
||||
prm = dict(zip(codes, combo))
|
||||
if violated(s.get('rules'), prm):
|
||||
continue
|
||||
mc = render(tpl, prm)
|
||||
if not mc or mc in existing:
|
||||
continue
|
||||
existing.add(mc)
|
||||
added.append((mc, prm))
|
||||
|
||||
summary.append((code, len(rows), len(combos), len(added)))
|
||||
if added:
|
||||
plan[code] = (tpl, params, added, rows)
|
||||
|
||||
# 输出
|
||||
print(f'{"系列":7s} {"现有":>7s} {"笛卡尔":>9s} {"待补":>7s}')
|
||||
print('-' * 36)
|
||||
tot_new = 0
|
||||
for code, n, cart, add in sorted(summary, key=lambda x: -x[3]):
|
||||
tot_new += add
|
||||
print(f'{code:7s} {n:7d} {cart:9,d} {add:7,d}')
|
||||
print(f'\n合计待补 {tot_new:,} 个型号')
|
||||
|
||||
if not WRITE:
|
||||
print('\n(--report 模式, 未写文件)')
|
||||
return
|
||||
|
||||
# ---------- 写入 ----------
|
||||
step_dir = os.path.join(CAT, 'step')
|
||||
placeholder = os.path.join(step_dir, PLACEHOLDER_STEP)
|
||||
manifest = [] # 待 AI 补真实数模的清单
|
||||
n_csv = n_ph = 0
|
||||
for code, (tpl, params, added, rows) in plan.items():
|
||||
csvp = os.path.join(CAT, 'csv', code + '.csv')
|
||||
header = list(rows[0].keys())
|
||||
dims = [h for h in header if h not in ('model_code', 'step_file')]
|
||||
# 新增行: step_file 统一指向 PLACEHOLDER.step ——
|
||||
# 构建器按 v.step 的路径复制 (同路径只存一份), 3157 个型号共用一个占位件,
|
||||
# 既不占磁盘也不撑大数据包。AI 补真实数模后把该行改成 "<型号>.step" 即可。
|
||||
with io.open(csvp, 'a', encoding='utf-8-sig', newline='') as fp:
|
||||
w = csv.writer(fp)
|
||||
for mc, prm in added:
|
||||
w.writerow([mc] + [S(prm.get(d, '')) for d in dims] + [PLACEHOLDER_STEP])
|
||||
manifest.append(f'{code},{mc},{",".join(d + "=" + S(prm.get(d, "")) for d in dims)}')
|
||||
n_csv += 1
|
||||
print(f'[{code}] CSV +{len(added)}')
|
||||
|
||||
# 待补清单 (给 AI 补数模用)
|
||||
mf = os.path.join(CAT, '_pending_models.csv')
|
||||
with io.open(mf, 'w', encoding='utf-8-sig', newline='') as fp:
|
||||
fp.write('series,model_code,params\n')
|
||||
fp.write('\n'.join(manifest) + '\n')
|
||||
print(f'\n写入完成: CSV 新增 {n_csv} 行, 占位 STEP {n_ph} 个')
|
||||
print(f'待补数模清单: {mf} ({len(manifest)} 个型号)')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user