rudong_tcm_oem_scan.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""六层链 `oem_scan` 步 —— **逆向工程实现(分母已就位/分子待口径)**,2026-09-17。
  4. ## 现状:一半已经落地并逐值验过,另一半明确挡住
  5. 壳 `src/windcms/pipeline.py::analyze` 会调本脚本(无参数,读 `M5_WINDOWS` / `SCAN_BANDS` 环境变量),
  6. 产出特征频率扫描件(`bearing_freq_scan` / `gear_freq_scan` / `cage_slip_scan` / `oem_frequency_scan`)。
  7. **已落地(分母 + 配置,逐值对拍通过)**:
  8. 1. **扫描配置**:每个部件用哪条谱、什么分辨率 —— `reference/rudong/oem_scan_plan.json`
  9. (11 个部件:`shard` 取 `Env_6000_4000_850_Tr` / `FFT_6000_Tr` / `FFT_2000_fast_Tr` / `FFT_62_Tr`,
  10. `dx_hz` 1.0625 / 0.9375 / 0.625 / 0.03875)。
  11. 2. **理论频率(分母)**:`option` 串 → `reference/rudong/oem_bearing_freqs.json` 条目,规则
  12. = 位置 token(`Pos.150R`→`150r`、`139.1/1060 f-576853.PRL`→`1391/1060`、`6338 M/C3`→`6338m/c3`)
  13. + 同位置多候选时按型号 token 定夺(NSK 284 vs FAG 273;NSK 137 vs FAG 151)。
  14. **实测:11/11 个部件的 `BPFI_hz`/`BPFO_hz`/`BSF_hz` 与随包样件逐值一致**(`--verify-plan`)。
  15. **未落地(分子)**:`*_fleet_ratio` / `*_local_ratio` 要的是**观测频率 ÷ 理论频率**,而"在谱里怎么取观测峰"
  16. (带宽 / 取什么量最大 / 是否插值 / 是否按 rpm 阶次跟踪 / 多记录怎么合并)是缺的口径
  17. (见 `docs/向振动线取料单_v0.1.md` 第 1 项、`docs/振动六层链_接口规格与缺口_v0.1.md` §7)。
  18. **因此本脚本默认行为是"响亮失败"**(rc=3),绝不写半成品:
  19. 在口径到位前,`windcms.py analyze --steps oem_scan` 会明确告诉你缺什么、去哪儿要,而不是产出一堆
  20. 只有一半是真的扫描件让人误以为重算成功了。
  21. ## 用法
  22. python scripts/rudong_tcm_oem_scan.py --verify-plan # 分母+配置 与随包样件逐值对拍(不写盘)
  23. python scripts/rudong_tcm_oem_scan.py --plan # 打印扫描计划(部件/测点/谱/分辨率/理论频率)
  24. python scripts/rudong_tcm_oem_scan.py --status # 机器可读状态(供 chain_gap_check 用)
  25. python scripts/rudong_tcm_oem_scan.py # 真跑(口径未到位 ⇒ rc=3, 不写盘)
  26. 退出码: 0 成功/对拍通过 · 2 缺参考件 · 3 **峰值拾取口径未到位**(不写盘) · 5 对拍不一致
  27. """
  28. from __future__ import annotations
  29. import argparse
  30. import json
  31. import pathlib
  32. import re
  33. import sys
  34. import pandas as pd
  35. ROOT = pathlib.Path(__file__).resolve().parents[1]
  36. sys.path.insert(0, str(ROOT))
  37. from src import paths as P # noqa: E402
  38. PLAN = ROOT / 'reference' / 'rudong' / 'oem_scan_plan.json'
  39. THEORY = ROOT / 'reference' / 'rudong' / 'oem_bearing_freqs.json'
  40. # 口径闸: 峰值拾取口径到位后把它翻成 True, 本脚本才会真的写产物
  41. PEAK_PICKING_READY = False
  42. MISSING = ('峰值拾取口径: 理论频率附近**怎么取观测峰**(带宽 / 取什么量最大 / 是否插值 / '
  43. '是否按 rpm 阶次跟踪 / 多记录怎么合并)',) # ★ 末尾逗号: 这是"缺失项清单", 不是一整句话
  44. def _norm(s: str) -> str:
  45. return re.sub(r'[^0-9a-z/]', '', str(s).lower())
  46. def pos_token(option: str) -> str:
  47. """option 串 → 位置 token(见模块头第 2 条的实测规则)。"""
  48. s = str(option)
  49. m = re.search(r'pos\.?\s*([0-9]+[LR]?|[0-9.]+/[0-9]+)', s, re.I)
  50. if m:
  51. return _norm(m.group(1))
  52. m = re.search(r'([0-9.]+/[0-9]+)', s)
  53. if m:
  54. return _norm(m.group(1))
  55. m = re.search(r'([0-9]{3,4}\s*[A-Z]?\s*/\s*[A-Z0-9]+)', s)
  56. if m:
  57. return _norm(m.group(1))
  58. return _norm(s)
  59. def theory_of(option: str, bearings: dict):
  60. tok = pos_token(option)
  61. cands = [k for k, v in bearings.items() if pos_token(v.get('pos', '')) == tok and tok]
  62. if not cands:
  63. return None, tok, []
  64. if len(cands) == 1:
  65. return cands[0], tok, cands
  66. no = _norm(option)
  67. for k in cands:
  68. mo = _norm(bearings[k].get('model', ''))
  69. if mo and (mo[:10] in no or mo[:8] in no):
  70. return k, tok, cands
  71. for k in cands:
  72. mk = str(bearings[k].get('model', '')).lower()
  73. for v in ('nsk', 'fag', 'ina'):
  74. if v in mk and v in no:
  75. return k, tok, cands
  76. return cands[0], tok, cands
  77. def load_plan():
  78. if not PLAN.is_file():
  79. raise SystemExit(f'[X] 缺扫描配置 {P.rel(PLAN)}')
  80. if not THEORY.is_file():
  81. raise SystemExit(f'[X] 缺理论频率表 {P.rel(THEORY)}')
  82. plan = json.loads(PLAN.read_text(encoding='utf-8'))
  83. th = json.loads(THEORY.read_text(encoding='utf-8'))['bearings']
  84. out = []
  85. for c in plan['components']:
  86. k, tok, cands = theory_of(c['option'], th)
  87. e = th.get(k) or {}
  88. out.append(dict(**c, theory_key=k, pos_token=tok, n_cands=len(cands),
  89. BPFI_hz=e.get('BPFI_Hz'), BPFO_hz=e.get('BPFO_Hz'), BSF_hz=e.get('BSF_Hz'),
  90. shaft_Hz=e.get('shaft_Hz'), model=e.get('model', '')))
  91. return plan, out
  92. def verify_plan(farm: str | None = None) -> int:
  93. """分母 + 配置 与随包样件逐值对拍(样件 = outputs/<场>/m5_cms_tcm/oem_frequency_scan.parquet)。"""
  94. farm = farm or P.farm()
  95. sample = P.m5(farm) / 'oem_frequency_scan.parquet'
  96. if not sample.is_file():
  97. print(f'[X] 没有样件可对拍: {P.rel(sample)}')
  98. return 2
  99. _plan, rows = load_plan()
  100. s = pd.read_parquet(sample)
  101. got = pd.DataFrame(rows)
  102. m = s[['component', 'sensor', 'shard', 'option', 'dx_hz', 'BPFI_hz', 'BPFO_hz', 'BSF_hz']].drop_duplicates()
  103. j = m.merge(got, on=['component', 'sensor', 'shard', 'option'], how='outer', suffixes=('_样', '_本'),
  104. indicator=True)
  105. both = j[j['_merge'] == 'both']
  106. ok = 0
  107. bad = []
  108. for _, r in both.iterrows():
  109. good = all(abs(float(r[f'{c}_样']) - float(r[f'{c}_本'])) < 1e-9 for c in ('BPFI_hz', 'BPFO_hz', 'BSF_hz')) \
  110. and abs(float(r['dx_hz_样']) - float(r['dx_hz_本'])) < 1e-9
  111. ok += good
  112. if not good:
  113. bad.append(r['component'])
  114. print(f'== oem_scan 分母+配置 逐值对拍 · 场站 {farm} ==')
  115. print(f' 样件组件 {len(m)} 个 · 本器 {len(got)} 个 · 同键 {len(both)} 个'
  116. f' · 仅样件 {int((j["_merge"] == "left_only").sum())} · 仅本器 {int((j["_merge"] == "right_only").sum())}')
  117. print(f' BPFI_hz/BPFO_hz/BSF_hz + dx_hz 逐值一致: {ok}/{len(both)}' + (f' 不一致: {bad}' if bad else ''))
  118. for _, r in got.iterrows():
  119. print(f' {r["component"]:24s} {r["sensor"]:22s} {r["shard"]:22s} dx={r["dx_hz"]:<8} '
  120. f'BPFI={r["BPFI_hz"]:<6} BPFO={r["BPFO_hz"]:<6} BSF={r["BSF_hz"]:<6} ← {str(r["theory_key"])[:34]}')
  121. rc = 0 if (ok == len(both) and len(both) == len(m) == len(got)) else 5
  122. print(f' 结论: {"分母映射成立(理论频率 + 扫描配置逐值一致)" if rc == 0 else "有不一致, 见上"} rc={rc}')
  123. return rc
  124. def status(farm: str | None = None) -> int:
  125. """机器可读状态(JSON)—— 供 scripts/chain_gap_check.py 汇总。"""
  126. _plan, rows = load_plan()
  127. st = dict(step='oem_scan', complete=bool(PEAK_PICKING_READY), components=len(rows),
  128. denominator='ok(理论频率 + 扫描配置已落地, --verify-plan 逐值对拍)',
  129. missing=list(MISSING) if not PEAK_PICKING_READY else [],
  130. request_sheet='docs/向振动线取料单_v0.1.md')
  131. print(json.dumps(st, ensure_ascii=False))
  132. return 0 if PEAK_PICKING_READY else 3
  133. def skeleton_from_index(win: str = 'w0127'):
  134. r"""**从包内索引真推行骨架**(不是从样件抄):窗 × 机组 × 部件/测点 × 分箱。
  135. 骨架分两种成分, 必须分开说清楚(2026-09-17):
  136. · **可从数据推**:某窗里"哪些机组有哪个测点的记录""某机组在该窗出现过哪些功率分箱" —— 索引里
  137. 的 `turbine` / `sensor_name` / `condition_key` 直接给出;w0127 的索引在包内(`m5/tcm_index.parquet`)。
  138. · **只能从样件复刻**:**窗集合**与"哪些窗有哪几台"(w07xx/w08xx 的索引与谱库都不在本机)。
  139. 本函数把前者算出来交给 `--skeleton-verify` 与样件对拍;后者在 `oem_scan_plan.json` 里记为"复刻自样件"。
  140. """
  141. ix = P.m5() / ('tcm_index.parquet' if win == 'w0127' else f'windows/{win}/index.parquet')
  142. if not ix.is_file():
  143. raise SystemExit(f'[X] 该窗索引不在位: {P.rel(ix)}(只有 w0127 与 w0316 的索引在包内)')
  144. d = pd.read_parquet(ix, columns=['turbine', 'sensor_name', 'condition_key'])
  145. out = {}
  146. for c in plan_components():
  147. sub = d[(d['sensor_name'] == c['sensor'])]
  148. turbine_sensor = set(map(tuple, sub[['turbine', 'sensor_name']].drop_duplicates().values))
  149. bins = (sub.groupby(['turbine', 'sensor_name', 'condition_key']).size().reset_index()[['turbine', 'sensor_name', 'condition_key']])
  150. out[c['component']] = dict(
  151. turbine_sensor=turbine_sensor,
  152. turbine_sensor_bin=set(map(tuple, bins.values)))
  153. return out
  154. def plan_components():
  155. if not PLAN.is_file():
  156. raise SystemExit(f'[X] 缺扫描配置 {P.rel(PLAN)}')
  157. return json.loads(PLAN.read_text(encoding='utf-8'))['components']
  158. def verify_skeleton(win: str = 'w0127', farm: str | None = None) -> int:
  159. """把"从索引推出来的骨架"与样件该窗的键对拍(w0127 是样件与索引都在的那一窗)。"""
  160. farm = farm or P.farm()
  161. idx_sk = skeleton_from_index(win)
  162. # 样件侧键
  163. out = {}
  164. b = pd.read_parquet(P.m5(farm) / 'bearing_freq_scan.parquet')
  165. b = b[b['window'] == win]
  166. bs = {}
  167. for comp, sens in (('GEN_bearing', ('Generator_DE', 'Generator_NDE')),
  168. ('HS_bearing', ('Gear_HS_generator_side', 'Gear_HS_rotor_side'))):
  169. bs[comp] = set(map(tuple, b[b['sensor'].isin(sens)][['turbine', 'sensor']].drop_duplicates().values))
  170. out['bearing_freq_scan'] = bs
  171. c = pd.read_parquet(P.m5(farm) / 'cage_slip_scan.parquet')
  172. c = c[c['window'] == win]
  173. cs = {}
  174. for comp, sens in (('发电机DE', 'Generator_DE'), ('发电机NDE', 'Generator_NDE'), ('高速轴', 'Gear_HS_generator_side')):
  175. cs[comp] = set(map(tuple, c[c['sensor'] == sens][['turbine', 'sensor', 'bin']].drop_duplicates().values))
  176. out['cage_slip_scan'] = cs
  177. print(f'== 行骨架 从索引推 vs 样件 · 窗 {win} ==')
  178. rc = 0
  179. rows = []
  180. for comp, sens_set in (('GEN_bearing', ('Generator_DE', 'Generator_NDE')),
  181. ('HS_bearing', ('Gear_HS_generator_side', 'Gear_HS_rotor_side'))):
  182. want = out['bearing_freq_scan'][comp]
  183. got = set()
  184. for c in plan_components():
  185. if c['sensor'] in sens_set:
  186. got |= {(t, s) for (t, s) in idx_sk[c['component']]['turbine_sensor']}
  187. rows.append((f'bearing_freq_scan/{comp}', len(want), len(got), len(want & got)))
  188. for comp, sens in (('发电机DE', 'Generator_DE'), ('发电机NDE', 'Generator_NDE'), ('高速轴', 'Gear_HS_generator_side')):
  189. want = out['cage_slip_scan'][comp]
  190. res = {}
  191. for c in plan_components():
  192. if c['sensor'] == sens:
  193. res[c['component']] = idx_sk[c['component']]['turbine_sensor_bin']
  194. got = set().union(*res.values()) if res else set()
  195. rows.append((f'cage_slip_scan/{comp}(机组×分箱)', len(want), len(got), len(want & got)))
  196. print(' %-38s %6s %6s %6s' % ('键集合', '样件', '索引推', '交集'))
  197. for name, w, g, i in rows:
  198. exact = (w == g == i)
  199. subset = name.startswith('cage_slip') and w <= g and i == w # 样件是索引的子集
  200. rc = rc or (0 if (exact or subset) else 5)
  201. tag = '一致' if exact else ('样件是索引的子集(缺稳态筛选口径)' if subset else '有差异')
  202. print(' %-38s %6d %6d %6d %s' % (name, w, g, i, tag))
  203. print(' 结论: bearing/gear 的行骨架**可由包内索引直接推出**(74/74 逐键一致);'
  204. 'cage_slip 的键是索引的**子集**(样件 83 / 索引 282),少掉的那些由"稳态段筛选"决定 —— '
  205. '口径未知(n_rec 只有 2~7,与索引里的组记录数 47~1426 完全不是一回事)')
  206. return rc
  207. def main() -> int:
  208. ap = argparse.ArgumentParser(description='六层链 oem_scan 步(逆向实现: 分母已就位/分子待口径)')
  209. ap.add_argument('--farm', default=None)
  210. ap.add_argument('--verify-plan', action='store_true', help='分母+配置 与随包样件逐值对拍(不写盘)')
  211. ap.add_argument('--plan', action='store_true', help='打印扫描计划')
  212. ap.add_argument('--status', action='store_true', help='机器可读状态(JSON)')
  213. ap.add_argument('--skeleton-verify', action='store_true', help='行骨架: 由索引推 vs 样件逐键对拍(w0127)')
  214. a = ap.parse_args()
  215. if a.status:
  216. return status(a.farm)
  217. if a.verify_plan:
  218. return verify_plan(a.farm)
  219. if a.skeleton_verify:
  220. return verify_skeleton('w0127', a.farm)
  221. _plan, rows = load_plan()
  222. if a.plan:
  223. print(f'== 扫描计划({len(rows)} 个部件)==')
  224. for r in rows:
  225. print(f' {r["component"]:24s} {r["sensor"]:22s} {r["shard"]:22s} dx={r["dx_hz"]:<8} '
  226. f'BPFI={r["BPFI_hz"]:<6} BPFO={r["BPFO_hz"]:<6} BSF={r["BSF_hz"]:<6} shaft={r["shaft_Hz"]}')
  227. return 0
  228. if not PEAK_PICKING_READY:
  229. print('[X] 峰值拾取口径未到位 —— 本步**拒绝**产出半成品(只写分母会很危险: 那些件看着像扫描结果, 实则没有分子)')
  230. print(f' 缺的是: {MISSING[0]}')
  231. print(' 要料: docs/向振动线取料单_v0.1.md 第 1 项(源码最佳, 一句话亦可)')
  232. print(' 已就位: 扫描配置 + 理论频率 —— 用 --verify-plan 可逐值对拍(11/11 通过)')
  233. return 3
  234. raise SystemExit('[!] PEAK_PICKING_READY=True 但实现未完成 —— 请先补上分子侧实现再翻这个开关')
  235. if __name__ == '__main__':
  236. for _s in (sys.stdout, sys.stderr):
  237. try:
  238. _s.reconfigure(errors='replace')
  239. except Exception:
  240. pass
  241. sys.exit(main())