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