rudong_model_run.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""六层链 `model_run` 步 —— 窗索引 + 谱库 → **L6 过闸谱线表 + 层小结**(按口径重建)。
  4. ## 这一步是什么 / 不是什么(先读这段再读代码)
  5. 用户令 2026-09-18「/detail 各页面不许用旧版产出补, 必须基于输入数据重算」之后, CMS 报告里
  6. 唯一还空着的是**融合级**那一列, 它的上游就是本步与 `fusion` 步的两件产物:
  7. ```
  8. configs: 'model_l6' = outputs/<场>/m5_cms_tcm/model_run_l6.parquet ← 从未随包(无标准答案)
  9. 'model_summary' = outputs/<场>/m5_cms_tcm/model_run_summary.json
  10. 'fusion' = outputs/<场>/m5_cms_tcm/fusion_38.csv ← 从未随包(无标准答案)
  11. ```
  12. **不是**复现: 这两件从未随过包, 盘上没有样件可逐值对拍 ⇒ 无法证明"与振动线那套一致"(见
  13. `docs/振动六层链_接口规格与缺口_v0.1.md` §3)。**是**按口径重建: 判据全部取自**包内既有实现**
  14. (不在本器里另写一套阈值), 每条线都留下"过没过闸、按哪条闸定的级"的来路, 来历在
  15. `model_run_summary.json` 的 `口径` 字段与产物台账里写明(`按口径重建, 无标准答案对拍`)。
  16. ## 判据来源(包内单一实现, 本器只做接线)
  17. | 环节 | 包内实现 |
  18. |---|---|
  19. | 候选线(理论频率) | `reference/rudong/oem_scan_plan.json` 的 11 个部件 × {BPFI, BPFO, BSF}(分母已 `--verify-plan` 11/11 逐值验过) |
  20. | 谱前提闸 G1–G8 | `src/sop/discriminators.py::spectral_line_gates`(分辨率/有线/峰稳/非固有/非整阶/选择性/fleet 离群) |
  21. | 六枚举定级 | `src/sop/discriminators.py::vib_verdict_and_writeback`(L0 短路 / 机制未定不命名部件 / 无正样本锚封顶候选) |
  22. | 正样本锚 | `reference/rudong/positive_anchors.json`(人工坐实的实物证据; 本器只读不写) |
  23. | 轴系转频 | `oem_scan_plan.json` 各部件的 `shaft_Hz`(★只传该轴承自己那根轴 —— 传全机会把真线误杀) |
  24. ## 输出(消费者的列名是硬契约)
  25. `model_run_l6.parquet` 的列由 `src/windcms/report_std.py::registry()` 与 `report.py` 决定:
  26. `台 / 测点 / 线 / hz / 域 / 绝对量 / 单位 / xfleet / 选择性 / 占比pct / 绝对锚 / 定级`
  27. (`台` 必须是 `f'{n}#'` 形态: registry 的 `_l6_consumed` 闭环断言会核对, 台号格式漂移当场报错)。
  28. 用法:
  29. python scripts/rudong_model_run.py # 默认取 M5_WINDOWS / 自动发现的窗
  30. python scripts/rudong_model_run.py --window w0316
  31. python scripts/rudong_model_run.py --turbines WTG01,WTG09 # 冒烟
  32. python scripts/rudong_model_run.py --dry-run # 只报计划, 不写产物
  33. """
  34. from __future__ import annotations
  35. import argparse
  36. import json
  37. import pathlib
  38. import sys
  39. import time
  40. import numpy as np
  41. import pandas as pd
  42. ROOT = pathlib.Path(__file__).resolve().parents[1]
  43. sys.path.insert(0, str(ROOT))
  44. sys.path.insert(0, str(ROOT / 'scripts'))
  45. from src import paths as P # noqa: E402
  46. from src.sop import discriminators as D # noqa: E402
  47. import rudong_tcm_oem_scan as OS # noqa: E402
  48. import spectra_lookup as SL # noqa: E402
  49. ANCHORS = ROOT / 'reference' / 'rudong' / 'positive_anchors.json'
  50. COMP_CLASS = { # 部件 → 正样本锚的部件类 (reference/rudong/positive_anchors.json)
  51. 'generator': 'generator_bearing', 'hs': 'gearbox_hs_bearing', 'ims': 'gearbox_ims_bearing',
  52. 'main': 'main_bearing',
  53. }
  54. # ISO 10816 速度当量 (mm/s, 刚性支撑·>15kW 机组的黄/红带) — 唯一"已校准"的绝对锚
  55. ISO = dict(yellow=4.5, red=11.0)
  56. LINES = (('BPFI', 'BPFI_hz', '内圈'), ('BPFO', 'BPFO_hz', '外圈'), ('BSF', 'BSF_hz', '滚动体'))
  57. def comp_class(component: str) -> str:
  58. for k, v in COMP_CLASS.items():
  59. if component.startswith(k):
  60. return v
  61. return 'unknown'
  62. def anchors_of() -> list | None:
  63. if not ANCHORS.is_file():
  64. return None
  65. return json.loads(ANCHORS.read_text(encoding='utf-8')).get('anchors')
  66. def windows_of(explicit: str | None) -> list[str]:
  67. import os
  68. if explicit:
  69. return [explicit]
  70. env = os.environ.get('M5_WINDOWS') or ''
  71. if env.strip():
  72. return [w for w in env.split(',') if w.strip()]
  73. m5 = P.m5()
  74. got = [p.parent.name for p in sorted((m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]/index.parquet'))]
  75. if (m5 / 'tcm_index.parquet').is_file():
  76. got.insert(0, 'w0127')
  77. return got
  78. _AVAIL: dict[str, set] = {}
  79. def available(win: str) -> set:
  80. """该窗谱库里**实有**的 (机组, 测点, 测量) 组合 —— 计划里的测量名不一定每台都有。
  81. ★为什么必须先查: `spectra_lookup.load()` 查不到记录时是 `SystemExit`(它的 CLI 语义), 而
  82. SystemExit 继承 BaseException, 用 `except Exception` 兜不住 —— 首版跑到第一台就整体退出
  83. (实测: w0316 里 WTG01 没有 Env_6000_4000_850_Tr 的谱)。缺谱是**常见情形**, 该跳过并计数, 不是崩。
  84. """
  85. if win not in _AVAIL:
  86. mp = SL.meta_path(win)
  87. s = set()
  88. if mp.is_file():
  89. d = pd.read_parquet(mp, columns=['turbine', 'sensor', 'meas_name'])
  90. s = set(map(tuple, d[['turbine', 'sensor', 'meas_name']].drop_duplicates().values))
  91. _AVAIL[win] = s
  92. return _AVAIL[win]
  93. def fleet_median(sensor: str, meas: str, turbines: list[str], win: str) -> np.ndarray | None:
  94. """同 (测点, 测量) 的**跨台中位谱** —— G4(机型固有) 与 G8(个体离群) 都靠它。"""
  95. got = []
  96. for t in turbines:
  97. if (t, sensor, meas) not in available(win):
  98. continue
  99. try:
  100. r = SL.load(win, t, sensor, meas)
  101. except BaseException:
  102. continue
  103. if not r:
  104. continue
  105. got.append(np.asarray(r['v'], dtype=float))
  106. if len(got) < 5:
  107. return None
  108. n = min(len(v) for v in got)
  109. return np.nanmedian(np.vstack([v[:n] for v in got]), axis=0)
  110. def evaluate(win: str, turbines: list[str], comps: list[dict], anchors, dry=False) -> tuple[list, dict]:
  111. """逐 (机组 × 部件 × 特征频率) 过闸定级 → L6 行。"""
  112. rows, stats = [], dict(considered=0, passed=0, no_spectrum=0, by_gate={}, by_level={})
  113. fmed_cache: dict[tuple, np.ndarray | None] = {}
  114. avail = available(win)
  115. for c in comps:
  116. meas, sensor = c['shard'], c['sensor']
  117. key = (sensor, meas)
  118. if key not in fmed_cache:
  119. fmed_cache[key] = fleet_median(sensor, meas, turbines, win)
  120. fmed = fmed_cache[key]
  121. dx = float(c.get('dx_hz') or 0) or None
  122. shaft = c.get('shaft_Hz') or 0.0
  123. domain = '包络谱' if str(meas).lower().startswith('env') else '原始谱'
  124. cls = comp_class(c['component'])
  125. for t in turbines:
  126. if (t, sensor, meas) not in avail:
  127. stats['no_spectrum'] += 1
  128. continue
  129. try:
  130. r = SL.load(win, t, sensor, meas)
  131. except BaseException:
  132. r = None
  133. if r is None:
  134. stats['no_spectrum'] += 1
  135. continue
  136. y = np.asarray(r['v'], dtype=float)
  137. meta = r['row']
  138. _dx = float(r.get('x_delta') or dx or 0) or dx
  139. if not _dx:
  140. continue
  141. for label, col, cn in LINES:
  142. hz = c.get(col)
  143. if not hz or not np.isfinite(hz) or hz <= 0:
  144. continue
  145. stats['considered'] += 1
  146. # 观测峰: 目标频率 ±2 bin 内取最大 (与谱前提闸 G7 的搜索窗同宽)
  147. tol = 2
  148. k = int(round(hz / _dx))
  149. if k - tol < 1 or k + tol >= len(y):
  150. continue
  151. seg = y[k - tol:k + tol + 1]
  152. if not len(seg) or not np.isfinite(seg).max():
  153. continue
  154. obs = float(np.nanmax(seg))
  155. x_fleet = None
  156. if fmed is not None and len(fmed) > k + tol:
  157. base = float(np.nanmax(fmed[k - tol:k + tol + 1])) or np.nan
  158. if np.isfinite(base) and base > 1e-12:
  159. x_fleet = obs / base
  160. sel = None # 选择性 = 线 ÷ 同谱宽带本底 (同窗, 与 G6 同语义)
  161. lo, hi = max(int(0.25 * len(y)), 1), max(int(0.75 * len(y)), 2)
  162. bg = float(np.nanmedian(np.abs(y[lo:hi]))) if hi > lo else np.nan
  163. if np.isfinite(bg) and bg > 1e-12:
  164. sel = obs / bg
  165. is_vel = str(meta.get('y_unit') or '') in ('m/s',)
  166. g = D.spectral_line_gates(y, _dx, float(hz), fleet_med=fmed, shaft_hz=(shaft,) if shaft else (),
  167. x_fleet=x_fleet, selectivity_samewin=sel, domain=domain,
  168. iso_ref_mms=(ISO['yellow'] if is_vel else None))
  169. gl = g.get('verdict') or '?'
  170. stats['by_gate'][gl] = stats['by_gate'].get(gl, 0) + 1
  171. if not g.get('passed'):
  172. continue
  173. # 绝对量: 速度域取 mm/s 当量 (ISO 可直接比), 其余域取谱值原单位
  174. if is_vel:
  175. abs_v, unit = obs * 1000.0, 'mm/s'
  176. else:
  177. abs_v, unit = obs, str(meta.get('y_unit') or '')
  178. vw = D.vib_verdict_and_writeback(
  179. gate_result=g, abs_value=abs_v, abs_unit=unit, x_fleet=x_fleet,
  180. iso_yellow=ISO['yellow'] if is_vel else None,
  181. iso_red=ISO['red'] if is_vel else None,
  182. component_class=cls, anchors=anchors, n_evidence_types=1,
  183. mechanism_confirmed=False) # 机制未定 ⇒ 不命名部件 (硬规则 2)
  184. lvl = vw.get('level') or 'INSUFFICIENT'
  185. stats['passed'] += 1
  186. stats['by_level'][lvl] = stats['by_level'].get(lvl, 0) + 1
  187. # 解封判据: 锚层给出的最高可达级依据 (无实物锚 ⇒ 写明未解封) —— 消费端 report.py:213 与
  188. # plugins.verdict_gate 都会显示这一列, 缺列会让逐台页整页崩 (AttributeError, 2026-09-19 实逮)
  189. try:
  190. ci = D.anchor_cap(cls, anchors, n_evidence_types=1, mechanism_confirmed=False)
  191. unlock = f"锚 tier={ci.get('tier')}: {str(ci.get('reason'))[:60]}"
  192. except Exception as e:
  193. unlock = f'锚不可判 ({type(e).__name__})'
  194. rows.append(dict(
  195. 台=f'{int(t[3:])}#', 测点=sensor, 线=f'{c["component"]}·{label}({cn})',
  196. 证据族=f'{domain}·{cn}', 解封判据=unlock,
  197. hz=round(float(hz), 3), 域=domain,
  198. 绝对量=round(float(abs_v), 5), 单位=unit,
  199. xfleet=(round(float(x_fleet), 2) if x_fleet is not None and np.isfinite(x_fleet) else None),
  200. 选择性=(round(float(sel), 2) if sel is not None and np.isfinite(sel) else None),
  201. 占比pct=None,
  202. 绝对锚=(f'ISO10816 黄{ISO["yellow"]}/红{ISO["red"]} mm/s' if is_vel else '—'),
  203. 定级=_consumer_level(lvl),
  204. _闸=g.get('verdict'), _依据=' · '.join(vw.get('reasons') or [])[:220],
  205. _写回=vw.get('writeback'), _窗=win))
  206. return rows, stats
  207. def _consumer_level(lvl: str) -> str:
  208. """把 L6 六枚举映射到消费端 `_LINE_STATE` 认得的那一套 (report_std.py 的 line_state 表)。
  209. `候选·查偶发源` / `候选·记基线` 这类细分在消费端表里**没有条目** ⇒ 会被默认成"优秀"
  210. (静默降级成假好消息)。这里显式映射, 细分信息留在 `_依据` 里。
  211. """
  212. s = str(lvl)
  213. if s.startswith('确诊') or s == '定论':
  214. return '定论'
  215. if s.startswith('准定论'):
  216. return '准定论·预警'
  217. if s.startswith('候选·新发'):
  218. return '候选·新发'
  219. if s.startswith('候选·记基线'):
  220. return '候选·记基线'
  221. if s.startswith('候选'):
  222. return '候选'
  223. if s.startswith('参考·上升'):
  224. return '参考·上升'
  225. if s.startswith('参考'):
  226. return '参考'
  227. if s.startswith('撤回'):
  228. return '撤回'
  229. return 'INSUFFICIENT'
  230. def l0_layer(win: str) -> dict:
  231. """L0 数据质量层 (按测点): 有解析错/过载/无记录 ⇒ 该测点结论不可采信 (report_std 会据它判"不可判")。
  232. 只看**索引自己的字段**(parse_error/overload), 不加任何自造阈值 —— 这层的意义是"别把盲台当健康台"。
  233. """
  234. ix = P.m5() / ('tcm_index.parquet' if win == 'w0127' else f'windows/{win}/index.parquet')
  235. if not ix.is_file():
  236. return {}
  237. d = pd.read_parquet(ix, columns=[c for c in ('turbine', 'sensor_name', 'parse_error', 'overload')
  238. if c in pd.read_parquet(ix).columns])
  239. out = {}
  240. for (t, s), g in d.groupby(['turbine', 'sensor_name']):
  241. bad = int(g['parse_error'].notna().sum()) if 'parse_error' in g else 0
  242. ovl = int((g['overload'] == True).sum()) if 'overload' in g else 0 # noqa: E712
  243. if bad and bad == len(g):
  244. out[f'{t}|{s}'] = '不可用: 该测点记录全部解析失败'
  245. elif ovl > 0.5 * len(g):
  246. out[f'{t}|{s}'] = '不可用: 该测点过半记录过载'
  247. elif bad:
  248. out[f'{t}|{s}'] = f'可用(部分): {bad}/{len(g)} 条解析失败'
  249. return out
  250. def main() -> int:
  251. ap = argparse.ArgumentParser(description='六层链 model_run 步: 窗索引+谱库 → L6 过闸谱线 (按口径重建)')
  252. ap.add_argument('--window', default=None)
  253. ap.add_argument('--turbines', default=None)
  254. ap.add_argument('--out', default=None)
  255. ap.add_argument('--dry-run', action='store_true')
  256. a = ap.parse_args()
  257. plan, comps = OS.load_plan()
  258. anchors = anchors_of()
  259. wins = windows_of(a.window)
  260. if not wins:
  261. print('[X] 没有可分析的窗 (m5_cms_tcm/windows/*/index.parquet 与 tcm_index.parquet 都不在位)')
  262. return 2
  263. turbines = ([f'WTG{x.strip().zfill(2)}' for x in a.turbines.split(',')] if a.turbines
  264. else [f'WTG{i:02d}' for i in range(1, 39)])
  265. print(f'model_run: 窗 {wins} · 机组 {len(turbines)} 台 · 部件 {len(comps)} 个 · '
  266. f'候选线 {len(comps) * len(LINES)}/台·窗 · 正样本锚 {len(anchors or [])} 条')
  267. if a.dry_run:
  268. for c in comps:
  269. print(f' {c["component"]:24} {c["sensor"]:24} {c["shard"]:24} dx={c["dx_hz"]:<9} '
  270. f'BPFI={c.get("BPFI_hz")} BPFO={c.get("BPFO_hz")} BSF={c.get("BSF_hz")} '
  271. f'shaft={c.get("shaft_Hz")}')
  272. print('(dry-run, 未写产物)')
  273. return 0
  274. t0 = time.time()
  275. allrows, summary = [], {}
  276. for win in wins:
  277. rows, stats = evaluate(win, turbines, comps, anchors)
  278. allrows += rows
  279. summary[win] = stats
  280. print(f' 窗 {win}: 候选 {stats["considered"]} 条 → 过闸 {stats["passed"]} 条 · '
  281. f'定级 {stats["by_level"]} · 闸分布 {dict(sorted(stats["by_gate"].items(), key=lambda x: -x[1])[:5])}')
  282. m5 = P.m5()
  283. l6 = pd.DataFrame(allrows)
  284. out = pathlib.Path(a.out) if a.out else (m5 / 'model_run_l6.parquet')
  285. if len(l6):
  286. l6 = l6.sort_values(['台', '测点', '线']).reset_index(drop=True)
  287. # 消费端的硬契约 (report_std.registry 的 _l6_consumed 闭环 + report.py 的列单)
  288. # 列 = 消费端硬契约: registry()/report.py:504 的投影 + report.py:213 与 plugins.verdict_gate 的明细表
  289. COLS = ['台', '测点', '线', 'hz', '域', '绝对量', '单位', 'xfleet', '选择性', '占比pct', '绝对锚',
  290. '证据族', '解封判据', '定级', '_闸', '_依据', '_写回', '_窗']
  291. l6 = l6.reindex(columns=COLS) if len(l6) else pd.DataFrame(columns=COLS)
  292. bad = sorted(set(l6['台']) - {f'{i}#' for i in range(1, 39)}) if len(l6) else []
  293. if bad:
  294. print(f'[X] 台号不在 38 台清单里: {bad} —— 消费端 registry 的闭环断言会当场报错, 本器先拦')
  295. return 5
  296. out.parent.mkdir(parents=True, exist_ok=True)
  297. l6.to_parquet(out, index=False)
  298. sm_path = m5 / 'model_run_summary.json'
  299. L0 = l0_layer(wins[0])
  300. summ = dict(
  301. status='ok', built=time.strftime('%Y-%m-%d %H:%M:%S'), windows=wins, seconds=round(time.time() - t0, 1),
  302. n_lines=int(len(l6)), by_gate={w: s['by_gate'] for w, s in summary.items()},
  303. by_level={w: s['by_level'] for w, s in summary.items()},
  304. L0=L0,
  305. 口径='按口径重建(**非**逐值对拍的复现 —— model_run_l6.parquet 从未随过包, 无标准答案, 见 '
  306. 'docs/振动六层链_接口规格与缺口_v0.1.md §3)。候选线=reference/rudong/oem_scan_plan.json 的 11 个'
  307. '部件 × {BPFI,BPFO,BSF}(分母已 11/11 逐值验过); 过闸=src/sop/discriminators.py::spectral_line_gates '
  308. '(G1..G8); 定级=src/sop/discriminators.py::vib_verdict_and_writeback (L0 短路 / 机制未定不命名部件 / '
  309. '无正样本锚封顶候选); 轴系转频只传该轴承自己那根轴; ISO 绝对锚只对速度域生效',
  310. missing_note='峰值拾取仍是**本器自定**: 目标频率 ±2 bin 取最大 (与 G7 搜索窗同宽), 未做阶次跟踪/多记录合并 —— '
  311. '振动线那套的"怎么取观测峰"口径仍缺 (docs §7.3), 故本件不可当"复现"用')
  312. sm_path.write_text(json.dumps(summ, ensure_ascii=False, indent=1), encoding='utf-8')
  313. print(f'已写 {P.rel(out)}: {len(l6)} 行')
  314. print(f'已写 {P.rel(sm_path)}: L0 {len(L0)} 条 · 窗 {len(wins)}')
  315. # 来源自登记 (谁算的谁登记)
  316. try:
  317. from src import derived_manifest as DM
  318. rels = {out.relative_to(P.out_root()).as_posix():
  319. 'scripts/rudong_model_run.py (按口径重建, 无标准答案对拍; 判据=src/sop/discriminators.py)',
  320. sm_path.relative_to(P.out_root()).as_posix():
  321. 'scripts/rudong_model_run.py (L0 层 + 口径说明)'}
  322. DM.record(P.out_root(), rels, by='rudong_model_run')
  323. print(' 已自登记 → _derived_manifest.json')
  324. except Exception as e:
  325. print(f' [i] 自登记跳过: {type(e).__name__}: {e}')
  326. return 0
  327. if __name__ == '__main__':
  328. for _s in (sys.stdout, sys.stderr):
  329. try:
  330. _s.reconfigure(errors='replace')
  331. except Exception:
  332. pass
  333. sys.exit(main())