vib_confidence.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. # -*- coding: utf-8 -*-
  2. """振动诊断 置信度评分 + 多源交叉核对 (2026-08-26, 用户令"提到最高层面并给置信度, 以事实和评分收敛").
  3. **为什么不是把现有判据再加权一遍**: 六层模型已给"这台有没有信号"(定级), 缺的是"这个结论我们多信"。
  4. 两者是不同的量 — 一条谱线可以判据全过 (定级=准定论) 而置信度低 (无独立源共时确认、无实物锚、样本薄)。
  5. ## 五轴评分 (每轴 0~20, 总 0~100)
  6. | 轴 | 问的问题 | 满分条件 |
  7. |---|---|---|
  8. | A 内部证据强度 | 算法自身闸过得多硬 | 五闸全过 + 绝对锚达标 (占比≥1%∧≥10×p90) + 持续窗数 ≥ `max(4, ceil(2N/3))`(N≤6 恒 4;N=9 ⇒ 6,见下方「窗数口径」) |
  9. | B 独立源共时确认 | **同窗** 有无非振动源同向 | 油液/工单/告警/温度 ≥2 类独立源共时同向 |
  10. | C 实物锚 | 有无拆检/内窥实证 | physical_in_window (窗内实物) |
  11. | D 样本充分性 | 数据够不够说话 | 有效样本 ≥30 且 覆盖窗数 ≥ `max(4, ceil(2N/3))` 且非盲区 |
  12. | E 反证搜索 | 有没有认真找过反例 | 已跑 fleet 基率 + 共模/固有线核 + 替代解释检验且全部未推翻 |
  13. **扣分项 (硬扣, 不与加分相抵)**: 时窗错配 (证据窗 ≠ claim 窗) −15; 单源多统计量冒充多源 −20;
  14. 量测域跨越 (包络当原始/相对当绝对) −20; 盲区台却给个体结论 −25。
  15. ## 窗数口径与单窗拒判 (2026-10-06 P1 明文; 门的形式见 `score_internal`/`score_sample`)
  16. **窗数口径(2026-10-06 P1 明文)**:持续性门与窗覆盖门的分母 = `max(4, ceil(2N/3))`(N=窗数):N≤6 恒为 4(与历史口径逐位一致);N=7/8 ⇒ 5/6;**N=9 ⇒ 6**。分母只增不减,故**任何窗数下都不会比历史口径更宽松**。
  17. **单窗/少窗拒判(硬条款)**:N<3 时**不得**用「持续性」「逐窗趋势」类判据出个体结论;此类信号只能标注为「**窗内周段趋势(单窗补充口径,仅参考不作判据)**」(与 `app_ETL/.../component_history_build.py` 的既有降级口径同源)。
  18. **不得静默丢窗**:谱窗与"仅标量/原厂状态"窗在 pipeline 中**分列**呈现,缺窗必须显式进入盲区,不得被埋进健康结论。
  19. ## 时窗纪律 (本场实测约束, 2026-08-26 盘点)
  20. - 振动现役窗: 2026-07-07 → 08-11 (w0127 孤窗 2026-01)
  21. - 工单: 2021-01 → **2024-11** (距振动窗 ~20 个月)
  22. - 油液: 2024-11 → **2025-08** (距振动窗 ~11 个月)
  23. - 实物锚: 2024-01 → 2026-01-23 (仅 09# 一条在窗内)
  24. ⇒ 本场 B 轴 (共时确认) 对绝大多数台**结构性拿不到满分** — 这不是算法弱, 是数据可得性边界。
  25. 报告必须显式写"独立源为历史面, 不构成共时确认", 禁把历史一致性冒充共时印证
  26. (memory stale-lab-samples-historical-not-current: 动作紧急 ≠ 证据强度)。
  27. ## 置信度分级 (评分 → 可做什么)
  28. - **≥80 高**: 可进客户交付件等级栏, 可驱动 P1 派工
  29. - **60~79 中**: 可进报告候选栏, 派工需现场先验证 (登机复核)
  30. - **40~59 低**: 只进观察清单, 不派工, 下窗复核
  31. - **<40 不足**: 不进任何交付件, 只作内部基线留档
  32. """
  33. from __future__ import annotations
  34. import math
  35. SCORE_AXES = ('A_internal', 'B_independent', 'C_physical', 'D_sample', 'E_falsification')
  36. PENALTY_KINDS = {
  37. 'window_mismatch': (-15, '证据窗 ≠ claim 窗 (历史源冒充共时确认)'),
  38. 'same_source_multi_stat': (-20, '同一路信号的多种统计量当多源 (memory scada-residual-claim-discipline)'),
  39. 'domain_crossing': (-20, '量测域跨越 (包络当原始 / 相对当绝对 / 阶次轴当频率轴)'),
  40. 'blind_individual_claim': (-25, '盲区台却给个体结论'),
  41. }
  42. BANDS = ((80, '高', '可进交付件等级栏, 可驱动 P1 派工'),
  43. (60, '中', '可进报告候选栏, 派工需现场先验证'),
  44. (40, '低', '只进观察清单, 不派工, 下窗复核'),
  45. (0, '不足', '不进交付件, 仅内部基线留档'))
  46. # ── 失效模式 × 振动可检出性 先验 (2026-08-26 历史锚回测习得; 分模式标注成熟度, 不一刀切) ──
  47. # 回测: hist2024_w0831 / w1226 / hist2025_w0314 三窗 × 12 实物锚 (scripts/rudong_backtest_anchors.py)
  48. # **规律**: 可检出性由失效模式决定, 不由部件决定 —
  49. # · 渐进磨损型 (点蚀/剥落): 22# HS 点蚀 Peak 7.5×fleet 提前 121 天可见; 20# 2.4→3.5× 提前 96 天且在升 ⇒ 可检出
  50. # · 急性热失效型 (高温跑圈/轴损): 23#/17# 发电机轴承拆检前 11/19 天 Peak 仅 1.03/1.02×fleet, 排 13/16 of 37
  51. # = 完全在分布中间 ⇒ **冲击量**对该模式无检出力
  52. # · ★温度轴**不是更早的指示** (2026-08-26 windscada 线协作 + data-first 复核): 2024 故障窗 SCADA 无数据
  53. # (两层皆自 2025-01 起) ⇒ 本场无法用温度给本先验升级, 维持 [暂行] n<3; 而在两轴都有数据的 2025 年,
  54. # 17# 振动已双端排 1/38 而温度仍负偏 31-34/38, 温度 2026-02 才翻正 ⇒ **温度滞后振动约 11 个月**。
  55. # 与"高温跑圈"这个失效模式名给人的直觉相反: 该模式**振动速度量在先, 温度是后验佐证**。
  56. # ★端别 (2026-08-26 官方 IO 表定案, 西门子 WTC-3 IO_V30_VS_170809_R1.00 第2页 IOHandle1 逐字):
  57. # GenBeRTm = "Generator bearing temperature, DE" (X6-TI6) · GenBeGTm = "...NDE" (X8-TI8) ⇒ R=DE, G=NDE。
  58. # 17# 是**两端各有信号**: 温度爬升在 DE (R端 +2.29K 位次7/38), 振动最高在 NDE (4.29 vs DE 3.19 mm/s)
  59. # — 与"轴损"叙事相容 (轴贯穿两端), 不是同端互证, 引用时勿合并成一端。
  60. # · ★**润滑失效族** (2026-08-26 anchors×台账对拍发现, 补登记 05#/32#): 台账原因栏把根因写全了 —
  61. # 「**油脂排油不畅 → 油脂板结 → 轴承过温膨胀 → 与轴过盈失效 → 轴承跑圈 → 轴损伤**」(05#/32# 逐字,
  62. # 32# 另注"前期维护间隔较长")。17#/23# 的"轴承高温, 振动增加, 跑圈, 轴损"是同一条链的后段。
  63. # ⇒ **thermal_acute 的上游是润滑失效**, 两者不是并列的两种模式, 是一条链的两段。
  64. # ★对 29# 的预后意义: 该台现处链条**第一步**(润滑断供·泵本体, findings TCM-16), 台账里已有 **4 台**
  65. # 走完全程到"换发电机/换轴"; 这是 29# 紧迫度的**独立支持** (不是振动/温度的重复计数, 是同族台的历史结局)。
  66. # ⚠ 但 05#/32# **台账维修时间栏为空** ⇒ 只能作模式归类, **不能做前瞻回测** (不知哪些窗是锚前) —
  67. # 故 thermal_acute 的"可检出性" 仍是 n=2 的结论, 不因归类 n=4 而升格。
  68. # · ★但**速度量看得见** (2026-08-26 谱线层回测后更正): 17# iso_rms_vel 故障前 19 天 1.79×fleet 排 2/37,
  69. # 时序 1.29(2024-05) → 3.08(故障前) → 5.20(2025-03 峰) → 3.83 mm/s(2026-08 仍排 1) ⇒ 见 detectability(meas=)
  70. # ★ 本表约束**标量层**; 谱线层已回测 (hist2024_w0504/w0831 两窗 38 台全部 OEM 频率过闸数 0) —
  71. # 谱线层是**确诊工具非早期预警**: 四个锚台故障前 1~4 个月全部零输出, 其零输出不可作"无缺陷"证据。
  72. DETECTABILITY = {
  73. 'wear_progressive': (0.86, '渐进磨损 (点蚀/剥落/磨损): 标量冲击量可检出率 **6/7** (23#/19#/05#/22#/20# 排名路径 + 09# 持续路径; 唯一漏 28#)。★**端到端闭环已验**: 22#/05#/20# 三台 换件后振动降至换前的 **0.13/0.19/0.25×** — 这三台正是检出台 ⇒ 该模式上算法**故障前能看见、换件后能看见恢复**; 另 11 组"工单后振动持平"是负面对照 (那些工单为油位/传感器/加注类, 非轴承更换, 振动本不该变 = 判据未乱报)。见 outputs/rudong/m5_cms_tcm/component_history.json'),
  74. 'thermal_acute': (0.4, '急性热失效 (高温跑圈/轴损/烧蚀): **冲击量**实证不可见 (23#/17# Peak 仅 1.02-1.03×); 改看速度量或温度轴。模式归类 n=4 (17#/23#/05#/32#) 但**可检出性评估仍 n=2** — 05#/32# 台账无日期不能做前瞻回测'),
  75. 'unknown': (0.7, '失效模式未分类: 不加不减地按中性折扣, 待归类'),
  76. }
  77. # ★ wear_progressive 可检出率 = **6/7 不是 100%** (2026-08-26; 系数 0.86 = 6/7 而非 1.0):
  78. # ★★ 系数在同一天被自己的数据改了三次, 每次都是判据缺陷暴露后重算 — 过程留档以免后人再走一遍:
  79. # v1 = 1.0 "n=5 全部进 top5" → **幸存者偏差** (只数检出的 5 台, 漏掉同为 HS 点蚀锚的 28#/09#)
  80. # v2 = 0.7 (5/7) → 修 best 选法 bug 后发现 **09# 不是无信号**: 旧版 best 只按 rank 最小选, 把它显示成
  81. # Kurtosis 1.09×(rank5), 而其 Peak@MID 实为 **1.61~1.74×fleet 跨四窗稳定**(位次 6-9)
  82. # v3 = 0.86 (6/7) ← 现值。09# 由**持续型路径**召回 (x>=1.5 ∧ 跨>=3 窗), 与其锚描述 "HS 轴承外圈
  83. # **轻微**损伤" 的程度相称: 轻微损伤 → 中等持续信号(1.6×), 而非爆发式高倍数。
  84. # **唯一真漏 = 28#, 且有现场记录独立佐证不是判据缺陷**: 锚前三窗 1.226~1.253× (未达绝对量门 1.5),
  85. # 锚后降至 0.84~0.93×(位次 22-25)。查大部件台账原文 (rudong.ops.maint_major_parts, 2025/8/23-8/28,
  86. # 更换项目=高速轴, 轴承 F-581491.01): 原因栏逐字写着「TCM析高速轴轴承告警, **振动分析时确认未高速轴
  87. # 轴承告警**, 开展内窥镜检查发现为高速轴轴承点蚀」⇒ **当时现场的振动分析同样没看出来**, 靠内窥镜发现;
  88. # 我方回测判"漏"与现场记录独立吻合 = 忠实复现历史, 而非算法缺陷。锚后回落 = 换件成功 (更换项目已写明)。
  89. # ⇒ 28# 是**物理检出边界**的实证 (点蚀程度不足以产生振动信号), 不是判据可修的漏检。
  90. # 与 09# 的"持续中等"(内窥后**运行中未换**, tier=physical_in_window) 是不同情形, 前一版归为一类是错的。
  91. # ⇒ 可检出性还依赖**损伤程度**, 不只失效模式: 轻微损伤给中等持续信号, 排名类判据天然筛不出。
  92. # 检出台的形态还分两类 (逐台看, 分组统计会骗人):
  93. # · **长期高位型** 22# (121天 7.53× · 199天 7.37× · **316天 12.64×**) / 20# (96天 2.42 · 213天 2.35 · 332天 2.06)
  94. # — 任何时候都高, 与提前期无关; 报警成立但**不是"新发展"**, 判"恶化"须另证 (memory change-detector-blind-to-steady-bias)
  95. # · **短期爆发型** 23# (提前 8 天 11.50×) / 19# (41 天 6.95×) / 05# (27 天 4.53×) — 只在故障前 1~2 月起来
  96. # ⇒ **提前期与信号强度无单调关系**: 分组中位 5.74(≤60天) → 2.39(61-250) → 1.09(>250) 看似递减,
  97. # 实为混合了上述三类台的**辛普森式假象** (聚合抹掉了"台的类型"这一维), 不可据此推"越近越强"。
  98. # ★ 筛查口径缺陷 (同次实逮): **只用 HI 满发档会漏台** — 23# 在 w0504 窗 HI 档 Peak 记录数 = 0
  99. # (该窗 1324/1832 条落在 0-1600 档, 低载运行), 若只筛 HI 档, 这个"提前 9 天全场第一"的最强样本
  100. # **完全不可见**。⇒ 标量层 fleet 筛查必须 MID 与 HI **分档各扫一遍**, 任一档进 top5 即入候选;
  101. # 两档不可混比 (skill cms-tcm-swt4000 §1: 混档只作初筛), 但可各自出榜再取并集。
  102. _MODE_KEYWORDS = (
  103. (('跑圈', '高温', '轴损', '烧', '过热'), 'thermal_acute'),
  104. (('点蚀', '剥落', '磨损', '疲劳', '损伤', '裂'), 'wear_progressive'),
  105. )
  106. def failure_mode_class(failure_mode_text):
  107. """失效模式文本 → 类别。热关键词优先 (高温跑圈常同时写"轴损", 但主因是热)。"""
  108. s = str(failure_mode_text or '')
  109. for kws, cls in _MODE_KEYWORDS:
  110. if any(k in s for k in kws):
  111. return cls
  112. return 'unknown'
  113. # 量类型 × 失效模式 的交叉可检出性 (2026-08-26 谱线层回测后精确化):
  114. # thermal_acute 不是"振动看不见", 是"冲击量看不见而速度量看得见" — 17# iso_rms_vel 故障前 19 天
  115. # 已 1.79×fleet 排 2/37 (同期 Peak 仅 1.02×)。物理: 跑圈=配合面松动=低频大位移 ⇒ 速度敏感/加速度峰值不敏感。
  116. VELOCITY_MEAS = {'iso_rms_vel', 'Rms_Vel'}
  117. IMPACT_MEAS = {'Peak', 'Rms_HP', 'Kurtosis', 'CrestFactor'}
  118. def detectability(failure_mode_text=None, mode_class=None, meas=None):
  119. """返回 (折扣系数, 依据)。调用侧把系数乘到 A 轴 — 算法在该失效模式上没证明过自己时不该拿满分。
  120. meas: 给出标量名时按"量 × 模式"交叉判 — thermal_acute 的速度量不折扣 (实证可见, n=1/2 [暂行])。"""
  121. cls = mode_class or failure_mode_class(failure_mode_text)
  122. if cls not in DETECTABILITY:
  123. raise ValueError(f'未知失效模式类 {cls!r}: 词表漂移必响亮')
  124. coef, why = DETECTABILITY[cls]
  125. if cls == 'thermal_acute' and meas in VELOCITY_MEAS:
  126. return 1.0, '急性热失效 × 速度量: 17# 实证故障前 19 天 1.79×fleet 可见 (n=1/2, [暂行])'
  127. return coef, why
  128. def score_internal(gates_passed, n_gates=5, anchor_ok=False, persist_windows=0, n_windows=6,
  129. failure_mode=None, layer='spectral', meas=None):
  130. """A 轴 0~20: 五闸 (12) + 绝对锚 (5) + 持续性 (3)。
  131. failure_mode: 若给出且 layer='scalar', 按 DETECTABILITY 折扣 (标量层对急性热失效实证无检出力)。"""
  132. g = 12.0 * (gates_passed / max(n_gates, 1))
  133. a = 5.0 if anchor_ok else 0.0
  134. # ★P0(用户令 2026-10-06):持续性门随窗数伸缩 —— 分母 = **max(4, ceil(2N/3))**。
  135. # · N<=6:max(4, <=4) = 4 ⇒ 与原硬编码 4 **逐位一致(零回归)**
  136. # · N=9 :ceil(18/3)=6 ⇒ 自动升级为 6,避免"四窗当九窗同等证据"
  137. # (注:单纯 ceil(2N/3) 在 N<6 时会把门放宽——与"部分覆盖被埋进健康"的告诫相反,故加 4 下限。)
  138. _den = max(4, math.ceil(2 * int(n_windows) / 3)) if (n_windows or 0) > 0 else 4
  139. p = 3.0 * min(persist_windows / _den, 1.0)
  140. raw = min(g + a + p, 20.0)
  141. if failure_mode is not None and layer == 'scalar':
  142. raw *= detectability(failure_mode, meas=meas)[0]
  143. return round(raw, 1)
  144. def score_independent(sources_concurrent, sources_historical=()):
  145. """B 轴 0~20: 共时独立源每类 8 分 (封顶 16) + 历史一致源每类 2 分 (封顶 4)。
  146. sources_* = 类别名集合, 如 {'oil','workorder','alarm','temperature','vendor_report'}。
  147. ★同一路信号的派生量不算两类 — 调用侧负责去重 (见 PENALTY same_source_multi_stat)。"""
  148. c = min(8.0 * len(set(sources_concurrent)), 16.0)
  149. h = min(2.0 * len(set(sources_historical)), 4.0)
  150. return round(min(c + h, 20.0), 1)
  151. def score_physical(tier=None):
  152. """C 轴 0~20: 窗内实物 20 / 闭环换件 12 / 历史实物 8 / 无 0。
  153. 历史实物只给 8: 已修复的旧损伤不证明现况 (memory degradation-must-use-timewindow-not-wholewindow)。"""
  154. return {'physical_in_window': 20.0, 'closed_loop': 12.0, 'physical_historical': 8.0}.get(tier, 0.0)
  155. def score_sample(n_records=0, n_windows_covered=0, is_blind=False, n_windows=6):
  156. """D 轴 0~20: 记录数 (10) + 窗覆盖 (10); 盲区台直接 0。"""
  157. if is_blind:
  158. return 0.0
  159. r = 10.0 * min(n_records / 30.0, 1.0)
  160. # ★P0:窗覆盖门同样取 max(4, ceil(2N/3))(默认 n_windows=6 ⇒ 分母 4,与旧行为一致;N=9 ⇒ 6)
  161. _den = max(4, math.ceil(2 * int(n_windows) / 3)) if (n_windows or 0) > 0 else 4
  162. w = 10.0 * min(n_windows_covered / float(_den), 1.0)
  163. return round(r + w, 1)
  164. def score_falsification(checks_done=(), checks_total=('fleet_base_rate', 'common_mode', 'intrinsic_line', 'alternative_explanation')):
  165. """E 轴 0~20: 已跑且未推翻的反证检验比例。**没做过反证搜索 = 0 分**, 不是默认满分。"""
  166. d = len(set(checks_done) & set(checks_total))
  167. return round(20.0 * d / max(len(checks_total), 1), 1)
  168. def confidence(axes: dict, penalties=()):
  169. """总分与分级。axes: {轴名: 分}; penalties: PENALTY_KINDS 的键序列。
  170. 返回 dict(total, band, action, axes, penalty_detail)。总分钳 [0,100]。
  171. ★window_mismatch 与窗内实物锚互斥 (2026-08-26 实跑逮): 该扣分惩罚的是"拿历史源冒充共时确认",
  172. 而 C 轴满分 (physical_in_window) 本身就是窗内直接证据 — 此时历史源只是附带背景, 不构成冒充。
  173. 否则确诊台 (09# 内窥实证) 会被扣到"低·不派工", 与事实冲突。"""
  174. missing = [k for k in SCORE_AXES if k not in axes]
  175. if missing:
  176. raise ValueError(f'置信度五轴不全, 缺 {missing} — 禁默认填分 (缺轴=没评, 不是满分也不是零分)')
  177. bad = [p for p in penalties if p not in PENALTY_KINDS]
  178. if bad:
  179. raise ValueError(f'未知扣分项 {bad}: 词表漂移必响亮')
  180. base = sum(float(axes[k]) for k in SCORE_AXES)
  181. penalties = [p for p in penalties
  182. if not (p == 'window_mismatch' and float(axes['C_physical']) >= score_physical('physical_in_window'))]
  183. pen = sum(PENALTY_KINDS[p][0] for p in penalties)
  184. total = max(0.0, min(100.0, base + pen))
  185. for cut, band, action in BANDS:
  186. if total >= cut:
  187. break
  188. return dict(total=round(total, 1), band=band, action=action, axes={k: round(float(axes[k]), 1) for k in SCORE_AXES},
  189. penalty=pen, penalty_detail=[PENALTY_KINDS[p][1] for p in penalties])
  190. def cross_check(vib_verdict, oil=None, workorder=None, alarm=None, vendor=None, *, vib_window=None):
  191. """多源交叉核对 → (一致性标签, 明细)。**只判方向一致性, 不做加权投票**
  192. (投票会让 3 个弱源压过 1 个强源; 强弱由 confidence 五轴表达, 不在这里混)。
  193. 每源取值: 'positive' (指向异常) / 'negative' (指向正常) / None (无数据/不可判)。
  194. 返回标签: CONFIRMED (≥1 源同向且无反向) / MIXED (有同向也有反向) /
  195. CONTRADICTED (仅反向) / UNCORROBORATED (全无数据)。
  196. ★ 'negative' 源不推翻振动结论 — 不同源测不同物理量, 阴性只降置信度不翻结论
  197. (memory zero-alarm-plus-physical-failure-proves-blind: 没测到 ≠ 没有)。"""
  198. srcs = dict(oil=oil, workorder=workorder, alarm=alarm, vendor=vendor)
  199. pos = [k for k, v in srcs.items() if v == 'positive']
  200. neg = [k for k, v in srcs.items() if v == 'negative']
  201. if not pos and not neg:
  202. label = 'UNCORROBORATED'
  203. elif pos and neg:
  204. label = 'MIXED'
  205. elif pos:
  206. label = 'CONFIRMED'
  207. else:
  208. label = 'CONTRADICTED'
  209. return label, dict(concurring=pos, dissenting=neg, no_data=[k for k, v in srcs.items() if v is None],
  210. vib_verdict=vib_verdict, vib_window=vib_window,
  211. note='阴性源不翻转振动结论, 只降置信度 (不同源测不同物理量)')