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:
wangruiguo
2026-09-15 02:14:55 +08:00
parent 3c4f236cba
commit fc5ee1dd36
19 changed files with 7355 additions and 25 deletions

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
"""统计各系列"参数组合 → 型号"是否一对一 (多处多 vs 一处多)。"""
import json, io, collections
CAT = r'D:/开发/OnebotCatalog/release/web_2026.08/catalog.json'
d = json.load(io.open(CAT, encoding='utf-8-sig'))
print(f'{"系列":8s} {"变体":>6s} {"组合":>6s} {"重复组":>6s} {"最大一对多":>9s} 判定')
print('-' * 58)
tot_codes = 0
bad = []
for s in d['series']:
vs = s.get('variants') or []
if not vs:
continue
params = [p['code'] for p in s['parameters']]
sig = collections.Counter(
tuple(str(v['params'].get(pc)) for pc in params) for v in vs)
dup = {k: n for k, n in sig.items() if n > 1}
mx = max(sig.values()) if sig else 0
verdict = 'OK 一对一' if not dup else f'!! {len(dup)} 组一对多'
if dup:
bad.append((s['code'], len(vs), len(dup), mx))
tot_codes += sum(n - 1 for n in dup.values())
print(f'{s["code"]:8s} {len(vs):6d} {len(sig):6d} {len(dup):6d} {mx:9d} {verdict}')
print(f'\n存在一对多的系列: {len(bad)} 个, 受影响型号 {tot_codes} 个')
if bad:
print('\n明细 (系列 / 变体 / 重复组数 / 一组最多几个型号):')
for c, n, dup, mx in sorted(bad, key=lambda x: -x[3]):
print(f' {c:8s} {n:5d} 变体 {dup:3d} 组 max={mx}')

View 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()

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
"""
生成占位数模 (每系列一个通用的"待补数模"方块)。
用途: 为「参数组合补齐」生成的型号提供占位 STEP —— 构建器要求每个型号必须有数模文件。
真实数模后续由 AI 参照同系列已有型号补出, 覆盖同名文件即可。
产出:
catalog/step/PLACEHOLDER.step 通用的方块 (ISO-10303-21, 标准 B-rep)
catalog/step/_placeholder/<系列>.step 每系列一份 (便于按系列替换/识别)
方块边长 60mm, 居中于原点。
用法: PYTHONUTF8=1 python gen-placeholder-step.py
"""
import os
CAT = r'D:/开发/OnebotCatalog/onebot-data/catalog'
STEP_DIR = os.path.join(CAT, 'step')
# 方块尺寸 (mm)
SX, SY, SZ = 60.0, 60.0, 60.0
HX, HY, HZ = SX / 2, SY / 2, SZ / 2
def build_step(label):
"""生成一个合法的 STEP 文件内容: 单位立方体 B-rep。"""
L = []
L.append('ISO-10303-21;')
L.append('HEADER;')
L.append("FILE_DESCRIPTION(('PLACEHOLDER - real CAD model pending'),'2;1');")
L.append("FILE_NAME('%s','2026-01-01T00:00:00',('ONEBOT'),('ONEBOT'),"
"'gen-placeholder-step.py','ONEBOT Catalog','');" % label)
L.append("FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));")
L.append('ENDSEC;')
L.append('DATA;')
L.append("#1=APPLICATION_CONTEXT('core data for automotive mechanical design processes');")
L.append("#2=APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,#1);")
L.append("#3=PRODUCT_CONTEXT('',#1,'mechanical');")
L.append("#4=PRODUCT_DEFINITION_CONTEXT('part definition',#1,'design');")
L.append("#5=PRODUCT('%s','PLACEHOLDER',$,(#3));" % label)
L.append("#6=PRODUCT_DEFINITION_FORMATION('','',#5);")
L.append("#7=PRODUCT_DEFINITION('design','',#6,#4);")
L.append("#8=PRODUCT_DEFINITION_SHAPE('','',#7);")
L.append("#9=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));")
L.append("#10=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));")
L.append("#11=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT());")
L.append("#12=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.001),#9,"
"'distance_accuracy_value','');")
L.append("#13=(GEOMETRIC_REPRESENTATION_CONTEXT(3)"
"GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#12))"
"GLOBAL_UNIT_ASSIGNED_CONTEXT((#9,#10,#11))REPRESENTATION_CONTEXT('',''));")
# 8 个顶点坐标
corners = [
(-HX, -HY, -HZ), (HX, -HY, -HZ), (HX, HY, -HZ), (-HX, HY, -HZ),
(-HX, -HY, HZ), (HX, -HY, HZ), (HX, HY, HZ), (-HX, HY, HZ),
]
eid = 20
pt_ids, vx_ids = [], []
for (x, y, z) in corners:
L.append('#%d=CARTESIAN_POINT('',(%.3f,%.3f,%.3f));' % (eid, x, y, z))
pt_ids.append(eid); eid += 1
L.append('#%d=DIRECTION('',(0.,0.,1.));' % eid); dz = eid; eid += 1
L.append('#%d=DIRECTION('',(1.,0.,0.));' % eid); dx = eid; eid += 1
for pid in pt_ids:
L.append('#%d=VERTEX_POINT('',#%d);' % (eid, pid))
vx_ids.append(eid); eid += 1
# 12 条棱
edges = [(0, 1), (1, 2), (2, 3), (3, 0),
(4, 5), (5, 6), (6, 7), (7, 4),
(0, 4), (1, 5), (2, 6), (3, 7)]
edge_ids = []
for (a, b) in edges:
L.append('#%d=LINE('',#%d,#%d);' % (eid, pt_ids[a], pt_ids[b]))
ln = eid; eid += 1
L.append('#%d=VECTOR('',#%d,1.0);' % (eid, dx)); vc = eid; eid += 1
L.append('#%d=EDGE_CURVE('',#%d,#%d,#%d,.T.);'
% (eid, vx_ids[a], vx_ids[b], ln))
ec = eid; eid += 1
L.append('#%d=ORIENTED_EDGE('',*,*,#%d,.T.);' % (eid, ec))
edge_ids.append(eid); eid += 1
# 6 个面
faces = [
[0, 1, 2, 3], [4, 5, 6, 7],
[0, 1, 5, 4], [1, 2, 6, 5],
[2, 3, 7, 6], [3, 0, 4, 7],
]
normals = [(0, 0, -1), (0, 0, 1), (0, -1, 0), (1, 0, 0), (0, 1, 0), (-1, 0, 0)]
face_ids = []
for fi, f in enumerate(faces):
loops = []
for k in range(4):
loops.append(edge_ids[f[k]])
L.append('#%d=EDGE_LOOP('',(%s));'
% (eid, ','.join('#%d' % x for x in loops)))
el = eid; eid += 1
nx, ny, nz = normals[fi]
L.append('#%d=DIRECTION('',(%.1f,%.1f,%.1f));' % (eid, nx, ny, nz))
nd = eid; eid += 1
L.append("#%d=AXIS2_PLACEMENT_3D('',#%d,#%d,$);" % (eid, pt_ids[f[0]], nd))
ax = eid; eid += 1
L.append('#%d=PLANE(\'\',#%d);' % (eid, ax))
pl = eid; eid += 1
L.append('#%d=ADVANCED_FACE(\'\',(#%d),#%d,.T.);' % (eid, el, pl))
face_ids.append(eid); eid += 1
L.append('#%d=CLOSED_SHELL(\'\',(%s));'
% (eid, ','.join('#%d' % x for x in face_ids)))
sh = eid; eid += 1
L.append("#%d=MANIFOLD_SOLID_BREP('PLACEHOLDER',#%d);" % (eid, sh))
msb = eid; eid += 1
L.append('#%d=ADVANCED_BREP_SHAPE_REPRESENTATION(\'PLACEHOLDER\',(#%d,#%d),#13);'
% (eid, msb, eid + 1))
abr = eid; eid += 1
L.append("#%d=AXIS2_PLACEMENT_3D('',#%d,#%d,#%d);" % (eid, pt_ids[0], dz, dx))
ax2 = eid; eid += 1
L.append("#%d=SHAPE_DEFINITION_REPRESENTATION(#8,#%d);" % (eid, abr))
L.append('ENDSEC;')
L.append('END-ISO-10303-21;')
return '\n'.join(L) + '\n'
def main():
os.makedirs(STEP_DIR, exist_ok=True)
content = build_step('PLACEHOLDER')
p = os.path.join(STEP_DIR, 'PLACEHOLDER.step')
with open(p, 'w', encoding='ascii', newline='\r\n') as f:
f.write(content)
print(f'写入 {p} ({os.path.getsize(p)} 字节)')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""
编码模板反推 v2 —— 支持零填充与空值占位符。
v1 的 explain() 贪心匹配"非零填充", 把 ULP50040AEK0 的 "040" 拆成 字面量"0" + 值"40",
产生大量假阴性。真实规则是:
- 数值参数常带前导零 (stroke=40 -> "040")
- 枚举参数为空时, 编码里用 '_' 占位 (UAGP075_K045WS0)
本脚本对每个参数尝试 {原值, 各前导零位数, '_'占位} 的所有组合, 取能对齐的;
若某系列的"字面量序列"完全一致 → 可模板化。
用法: PYTHONUTF8=1 python infer-sku-template.py
"""
import json, io, collections, sys
CAT = r'D:/开发/OnebotCatalog/release/web_2026.08/catalog.json'
d = json.load(io.open(CAT, encoding='utf-8-sig'))
MAXPAD = 4
def align_all(code, params, values):
"""返回全部可能的字面量序列 (元组)。每个参数尝试: 原值 / 前导零 / '_'占位。"""
results = []
def dfs(i, pos, lits):
if i == len(params):
results.append(tuple(lits + [code[pos:]]))
return
pc = params[i]
v = str(values.get(pc, ''))
cands = []
if v == '':
cands = ['_', ''] # 空值: 占位符或什么都不写
else:
cands = [v] + ['0' * z + v for z in range(1, MAXPAD + 1)]
seen = set()
for pat in cands:
if pat in seen:
continue
seen.add(pat)
if pat == '':
dfs(i + 1, pos, lits + [''])
continue
idx = code.find(pat, pos)
if idx >= 0:
dfs(i + 1, idx + len(pat), lits + [code[pos:idx]])
dfs(0, 0, [])
return results
def render_template(params, lits):
"""字面量序列 -> 模板串。空值参数渲染为 '_'。"""
out = ''
for i, pc in enumerate(params):
out += lits[i] + '{' + pc + '}'
out += lits[len(params)]
return out
good, bad = [], []
for s in d['series']:
vs = s.get('variants') or []
if not vs:
continue
params = [p['code'] for p in s['parameters']]
lit_sets = []
unaligned = 0
for v in vs:
alts = align_all(v['modelCode'], params, v['params'])
if not alts:
unaligned += 1
continue
lit_sets.append(alts)
if not lit_sets:
bad.append((s['code'], len(vs), '完全无法对齐', [], 0))
continue
# 统计"出现在最多变体里"的字面量序列 (每个变体取任一可对齐解)
cnt = collections.Counter()
for alts in lit_sets:
for a in set(alts):
cnt[a] += 1
top, hit = cnt.most_common(1)[0]
rate = hit / len(vs) * 100
if rate >= 99.5 and unaligned == 0:
good.append((s['code'], len(vs), rate, render_template(params, top), len(cnt)))
else:
bad.append((s['code'], len(vs), f'{rate:.1f}% ({len(cnt)} 种)', cnt.most_common(3), unaligned))
print('=' * 96)
print(f'可模板化的系列 ({len(good)} 个)')
print('=' * 96)
for code, n, rate, tpl, k in sorted(good):
print(f'{code:7s} {n:6d} 变体 {rate:6.1f}% {tpl}')
print()
print('=' * 96)
print(f'不可模板化的系列 ({len(bad)} 个)')
print('=' * 96)
for code, n, why, tops, un in sorted(bad, key=lambda x: -x[1]):
print(f'{code:7s} {n:6d} 变体 {why}' + (f' 未对齐 {un}' if un else ''))
for lits, c in tops[:3]:
print(f' {c:5d}× {lits}')
print(f'\n合计: 可模板化 {len(good)} / 共 {len(good) + len(bad)} 个系列')

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""
量化"参数组合缺口": 合法组合 (域 + 规则内) vs 库中已有变体, 以及补全的规模。
对每个系列:
- 现有变体数
- 笛卡尔积规模 (各参数值域相乘)
- 规则过滤后的合法组合数 (复用 C# Configurator 同语义的规则求值)
- 缺口 = 合法组合 - 已覆盖
这决定"补全组合数据"的可行性与数据量。
"""
import json, io, itertools
CAT = r'D:/开发/OnebotCatalog/release/web_2026.08/catalog.json'
d = json.load(io.open(CAT, encoding='utf-8-sig'))
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, params):
"""规则条件求值 (与 app.js condMatches / C# CondMatches 同语义)。"""
if not c:
return True
val = params.get(c['Param'])
if val is None:
return False
op = c['Op']
cv = 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 rule_violated(rules, params):
for r in rules or []:
if cond_match(r.get('If'), params) and not cond_match(r.get('Then'), params):
return True
return False
print(f'{"系列":7s} {"现有":>6s} {"笛卡尔":>9s} {"规则内":>9s} {"缺口":>9s} {"倍数":>5s}')
print('-' * 62)
tot_exist = tot_gap = 0
rows = []
for s in d['series']:
vs = s.get('variants') or []
if not vs:
continue
params = s['parameters']
doms = []
for p in params:
vals = p.get('values') or []
if not vals:
doms = None
break
doms.append(vals)
if doms is None:
rows.append((s['code'], len(vs), None, None, None))
continue
cart = 1
for vals in doms:
cart *= len(vals)
# 规则过滤 (笛卡尔过大时抽样估算)
legal = 0
if cart <= 400000:
for combo in itertools.product(*doms):
prm = {p['code']: v for p, v in zip(params, combo)}
if not rule_violated(s.get('rules'), prm):
legal += 1
else:
legal = None # 太大, 跳过精确统计
gap = (legal - len(vs)) if legal is not None else None
rows.append((s['code'], len(vs), cart, legal, gap))
if legal is not None:
tot_exist += len(vs)
tot_gap += max(0, gap)
for code, n, cart, legal, gap in sorted(rows, key=lambda x: -(x[4] or -1)):
c = f'{cart:,}' if cart else '—'
l = f'{legal:,}' if legal else '—'
g = f'{gap:,}' if gap is not None else '—'
mult = f'{legal/n:.1f}x' if legal and n else '—'
flag = ' <-- 缺口大' if gap and gap > 50000 else ''
print(f'{code:7s} {n:6d} {c:>9s} {l:>9s} {g:>9s} {mult:>5s}{flag}')
print(f'\n合计: 现有 {tot_exist:,} 变体, 缺口 {tot_gap:,} 组合')
print(f'补全后约 {tot_exist + tot_gap:,} 变体 (是现在的 {(tot_exist+tot_gap)/tot_exist:.1f} 倍)')

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""
验证: 用模板对【已有型号】重新渲染, 必须与库中编码完全一致。
凡不一致 → 模板有误, 绝不能用它生成新编码 (会造出不存在的零件号)。
"""
import json, io, os, csv, sys, importlib.util
HERE = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location('gmc', os.path.join(HERE, 'gen-missing-combos.py'))
gmc = importlib.util.module_from_spec(spec)
sys.argv = ['x'] # 防止执行 main
spec.loader.exec_module(gmc)
CAT = gmc.CAT
print(f'{"系列":7s} {"变体":>6s} {"模板一致":>9s} {"失败":>6s} 反例')
print('-' * 74)
allok = True
for code in sorted(gmc.SAFE):
if code not in gmc.TEMPLATES:
continue
tpl, _ = gmc.TEMPLATES[code]
rows = list(csv.DictReader(io.open(os.path.join(CAT, 'csv', code + '.csv'), encoding='utf-8-sig')))
ok = fail = 0
bad = []
for r in rows:
prm = {k: v for k, v in r.items() if k not in ('model_code', 'step_file')}
got = gmc.render(tpl, prm)
if got == r['model_code']:
ok += 1
else:
fail += 1
if len(bad) < 3:
bad.append(f'{r["model_code"]} -> 渲染"{got}"')
flag = '' if fail == 0 else ' <-- 模板有误!'
if fail:
allok = False
print(f'{code:7s} {len(rows):6d} {ok:9d} {fail:6d} {bad[0] if bad else ""}{flag}')
print()
print('结论:', '所有模板对已有型号 100% 复现 ✅' if allok else '存在模板错误, 不可用于生成新编码 ❌')