vib_confidence.py 20 KB

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