fusion_diag.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. """FUSION 振动诊断框架 v2 — 厂商经验值 × 谱线模型 的结构化融合.
  2. ═══ v1 自查出五处错, v2 全修 (每处都有如东实证) ═══
  3. ★错1 覆盖面凭声明不凭证据 (v1 最严重的错, 且是它自己该逮的那类)
  4. v1 在 SOURCE_SPEC 里写死 MODEL covers broadband_level, 于是 12# 那次
  5. 模型报"正常"被当成"查过宽带认为正常"; 真相是**模型当时根本没接 rms_200 判据**。
  6. ⇒ v2: **"正常" 不是可默认的状态**。报正常必须同时登记 checked=[...检查了什么]。
  7. 登记为空 ⇒ 该结论是 INSUFFICIENT 不是 正常。
  8. (同族 memory: verdict-field-exists-but-unused-in-criteria / parse-failure-read-as-positive-result)
  9. ★错2 真分歧一律升级 ⇒ 告警洪水
  10. 如东四方对照实测: 38台里四源一致的**只有09#** ⇒ **分歧是常态不是例外**。
  11. v1 规则会把约30台升级为"需现场" = 等于没排序。
  12. ⇒ v2: 升级须同时满足 (a)跨度>=2级 (b)判据**独立**(不同信号链) (c)双方都有登记检查。
  13. ★错3 同源判据当独立分歧
  14. CMS 的 rms_200 与我 L4b 的宽带层**是同一个量**。二者不一致是方法差异不是独立冲突。
  15. ⇒ v2: signal_id=(测点,判据) 重叠 ⇒ 标 METHOD_DIFF 不升级。
  16. (memory control-logic-derived-channels-not-independent / alarm-same-sensor-not-independent)
  17. ★错4 路由器自身的批次盲
  18. R3(台间离散<1.35 ⇒ 只能用趋势) 遇批次退化时: 离散同样小、趋势同样一起涨 ⇒ **两条路都盲**。
  19. 而 Indicator_* **没有绝对锚** ⇒ 分不出"紧且健康"与"紧且全坏"。
  20. ⇒ v2: R3 且无绝对锚 ⇒ 显式挂 UNRESOLVABLE_BATCH_BLIND, 不静默路由。
  21. (Gemini 双模审第②硬伤在路由器里的复现)
  22. ★错5 缺厂商的运维设计 — 这部分是厂商经验值真正值钱的地方, 我原模型完全没有
  23. Hysteresis=3 (防告警抖动; 我的模型逐窗裁决会翻来翻去)
  24. TargetCount=15 (最小证据量门; 我没有"样本不够就不判")
  25. Red/Yellow≈1.4~2.1 (分级响应非二值)
  26. WTCCode 绑定 (**送达闸** — 报了要有人收到; 如东 631/639 无码 = 报了传不回)
  27. """
  28. from __future__ import annotations
  29. import numpy as np
  30. EVIDENCE_TYPES = ('spectral_line', 'broadband_level', 'cepstral_indicator',
  31. 'iso_velocity', 'impact_time_domain', 'data_quality')
  32. LEVELS = ('INSUFFICIENT', '正常', '参考', '候选·记基线', '候选', '准定论·预警', '确诊')
  33. _LI = {v: i for i, v in enumerate(LEVELS)}
  34. _DEFECT_LEVELS = {'候选', '准定论·预警', '确诊'}
  35. # ══════════════════════════════════════════════════════════════════
  36. # ① 判据路由器 — 判据用 level 还是趋势由 fleet 离散度定, 不是固定的
  37. # ══════════════════════════════════════════════════════════════════
  38. def route_criterion(per_turbine_values, *, rpm_values=None, iso_anchor=None,
  39. spread_min=1.35, dim_corr_max=0.99, min_turbines=10):
  40. v = np.asarray(list(per_turbine_values.values() if hasattr(per_turbine_values, 'values')
  41. else per_turbine_values), dtype=float)
  42. v = v[np.isfinite(v) & (v > 0)]
  43. out = dict(route=None, spread=np.nan, reason='', flags=[])
  44. if len(v) < min_turbines:
  45. return {**out, 'route': 'R0_INSUFFICIENT', 'reason': f'有效台数 {len(v)} < {min_turbines}'}
  46. if rpm_values is not None:
  47. r = np.asarray(rpm_values, dtype=float); n = min(len(r), len(v))
  48. if n >= min_turbines:
  49. c = np.corrcoef(v[:n], r[:n])[0, 1]
  50. if np.isfinite(c) and abs(c) > dim_corr_max:
  51. return {**out, 'route': 'R2_DISABLE',
  52. 'reason': f'与转速 corr={c:.4f} ⇒ 该量就是转速本身, 阈值与量纲无关'}
  53. sp = float(np.percentile(v, 90) / np.median(v))
  54. # ★2026-08-22 双模审 (Gemini③) 采纳: spread_min=1.35 是连续分布上的任意切点 —
  55. # 如东 14 判据×6 窗 p90/p50 分布 p25 1.11 / p50 1.27 / p75 1.38, 1.35 附近无自然断点 (最大相邻比仅 1.13×)。
  56. # 改为**连续量**输出: σ_log = ln(p90/p50)/1.2816 (对数正态假设), min_detectable_x = exp(3σ_log)
  57. # = 一台要被判"3σ 离群"至少得高出中位多少倍。R3/R4 的切点保留为**[暂行, 单场经验]**参数, 不再当结论。
  58. # ★与切点无关的硬结论只有一条: 无绝对锚的相对判据对"批次退化"盲 (UNRESOLVABLE_BATCH_BLIND), 任何 spread 都挂。
  59. sig = float(np.log(sp) / 1.2816) if sp > 1 else 0.0
  60. mdx = float(np.exp(3 * sig))
  61. out.update(spread=sp, sigma_log=sig, min_detectable_x=mdx,
  62. spread_min_note='[暂行 n=1 场] spread_min 切点为经验值, 无自然断点; 用 min_detectable_x 连续判读')
  63. if iso_anchor:
  64. return {**out, 'route': 'R1_ABSOLUTE',
  65. 'reason': f'有国际标准 {iso_anchor[0]} ⇒ 绝对锚**双向**不可被统计值覆盖'}
  66. flags = ['UNRESOLVABLE_BATCH_BLIND'] # 无绝对锚 ⇒ 批次盲, 与 spread 无关
  67. if sp < spread_min:
  68. return {**out, 'route': 'R3_TREND_ONLY', 'flags': flags,
  69. 'reason': (f'台间 p90/p50={sp:.2f} (σ_log={sig:.3f}, 3σ 离群需 ≥{mdx:.2f}× 中位) < 暂行切点 {spread_min}; '
  70. f'无绝对锚 ⇒ 分不出"紧且健康"与"紧且全坏"')}
  71. return {**out, 'route': 'R4_LEVEL_TREND', 'flags': flags,
  72. 'reason': f'台间 p90/p50={sp:.2f} (σ_log={sig:.3f}, 3σ 离群需 ≥{mdx:.2f}× 中位) ≥ 暂行切点 {spread_min}'}
  73. # ══════════════════════════════════════════════════════════════════
  74. # ② 厂商运维设计: 迟滞 + 最小证据量
  75. # ══════════════════════════════════════════════════════════════════
  76. def apply_hysteresis(window_levels, *, h=3):
  77. """厂商 Hysteresis=3 的通用化: 连续 h 窗同向才改级, 否则保持.
  78. 我原模型逐窗独立裁决 ⇒ 会翻来翻去。这是厂商经验值里真正值钱的一条。
  79. """
  80. if not window_levels: return None, []
  81. cur = window_levels[0]; run = 1; trail = [cur]
  82. for lv in window_levels[1:]:
  83. if lv == cur: run += 1
  84. else:
  85. run = 1 if not (trail and lv == trail[-1]) else run + 1
  86. if run >= h: cur = lv; run = 0
  87. trail.append(cur)
  88. return cur, trail
  89. def min_evidence_gate(n, *, n_min=30, vendor_n=15):
  90. """厂商 TargetCount=15。我取 30: 3σ 用 15 样本估 σ 的相对标准误 = 1/sqrt(2(N-1)) = 19%
  91. ⇒ 阈值本身带 ±57% 抖动。n_min=30 把它压到 ±39%。"""
  92. if n >= n_min: return True, f'n={n} >= {n_min}'
  93. if n >= vendor_n: return False, f'n={n} 达厂商 TargetCount={vendor_n} 但 < {n_min} (3σ估计不稳) ⇒ 只记基线不定级'
  94. return False, f'n={n} < 厂商 TargetCount={vendor_n} ⇒ 证据量不足, 不判'
  95. # ══════════════════════════════════════════════════════════════════
  96. # ③ 跨体系融合 — 覆盖凭证据 / 排同源 / 按跨度升级
  97. # ══════════════════════════════════════════════════════════════════
  98. def _checked_types(v):
  99. return {c.get('evidence_type') for c in v.get('checked', []) if c.get('evidence_type')}
  100. def _signals(v):
  101. """物理信号身份 — 用显式 signal 字段, **不用判据名**。
  102. CMS 的 'rms_200' 与我的 'L1b_rms200_同档分位' 名字不同但**是同一个量**;
  103. 按名字比会把同源判据当独立分歧 (v2 首跑在 12# 上实逮)。"""
  104. return {c.get('signal') or f"{c.get('sensor')}/{c.get('criterion')}"
  105. for c in v.get('checked', [])}
  106. def _driver_ev(v):
  107. """驱动该体系定级的证据类型。盲区检验必须用它, 不能用 checked 全集之差 ——
  108. 12# 实逮: CMS 判红靠 rms_200(宽带) 而全集之差算出 cepstral_indicator,
  109. 于是报'模型盲在倒谱'——可 CMS 的红根本不是倒谱给的。"""
  110. d = v.get('driver')
  111. if d: return {d} if isinstance(d, str) else set(d)
  112. return _checked_types(v)
  113. def normalize_verdict(v):
  114. """★错1 修: '正常' 不是可默认的状态。没登记检查项 ⇒ 降为 INSUFFICIENT。"""
  115. v = dict(v)
  116. if not v.get('checked'):
  117. if v.get('level') in ('正常', '参考'):
  118. v['_downgraded'] = f"原报「{v['level']}」但未登记任何检查项 ⇒ 降为 INSUFFICIENT"
  119. v['level'] = 'INSUFFICIENT'
  120. return v
  121. def fuse(verdicts, *, escalate_span=2, positive_anchor=False, anchors=None, component_class=None):
  122. """多判据体系裁决。返回覆盖限定的结论, 不是一个裸等级。"""
  123. vs = [normalize_verdict(v) for v in verdicts]
  124. vs = [v for v in vs if v.get('level') in _LI]
  125. if not vs:
  126. return dict(level='INSUFFICIENT', mechanism='NO_VALID_VERDICT', rationale='无有效裁决输入',
  127. covered=[], uncovered=list(EVIDENCE_TYPES), dissent=[], downgraded=[])
  128. covered = set().union(*[_checked_types(v) for v in vs]) if vs else set()
  129. down = [f"{v['source']}: {v['_downgraded']}" for v in vs if v.get('_downgraded')]
  130. real = [v for v in vs if v['level'] != 'INSUFFICIENT']
  131. if not real:
  132. return dict(level='INSUFFICIENT', mechanism='ALL_INSUFFICIENT',
  133. rationale='所有体系都无登记检查项', covered=sorted(covered),
  134. uncovered=sorted(set(EVIDENCE_TYPES) - covered), dissent=[], downgraded=down)
  135. hi = max(real, key=lambda v: _LI[v['level']]); lo = min(real, key=lambda v: _LI[v['level']])
  136. span = _LI[hi['level']] - _LI[lo['level']]
  137. base = dict(covered=sorted(covered), uncovered=sorted(set(EVIDENCE_TYPES) - covered),
  138. dissent=[f"{v['source']}:{v['level']}" for v in real], downgraded=down)
  139. def cap(lv, mech, why, driver=None):
  140. # ★封顶只封**自定门限**的结论 (ISO/厂商已校准门限不封)。
  141. # ★2026-08-23: 全局 positive_anchor → 按部件类 anchor_cap (登记表 reference/<场>/positive_anchors.json)
  142. # ★2026-08-23 修: 校准与否只看**驱动该定级的证据类型**的登记项 (告警证据力). 原写法 any(全部登记项) 被 ISO 项 (每台都登记) 豁免,
  143. # CMS 由未整定 rms_200/Indicator 判红的台永远不封顶 (12# 实逮: 红告警 rms_200 → 准定论·预警, 而裁决为 候选).
  144. _dev = _driver_ev(driver) if driver else set()
  145. _drv_items = [c for c in (driver or {}).get('checked', []) if not _dev or c.get('evidence_type') in _dev]
  146. cal = any(c.get('calibrated') for c in _drv_items)
  147. unrepro = [c.get('criterion') for c in _drv_items if c.get('reproducible') is False]
  148. if unrepro:
  149. why += f" (驱动判据定义不可复现: {sorted(set(unrepro))})"
  150. if lv in _DEFECT_LEVELS and _LI[lv] > _LI['候选'] and not cal and not positive_anchor:
  151. if anchors is not None:
  152. from sop.discriminators import anchor_cap as _ac
  153. ntypes = len({c.get('evidence_type') for c in (driver or {}).get('checked', [])
  154. if c.get('evidence_type') and c.get('value') is not None})
  155. ci = _ac(component_class, anchors, n_evidence_types=ntypes)
  156. if ci['cap'] in ('准定论·预警', '定论'):
  157. why += f" (部件类锚 tier={ci['tier']}: {ci['reason']} ⇒ 不封顶)"
  158. return dict(level=lv, mechanism=mech + '+ANCHOR_OK', rationale=why, **base)
  159. return dict(level='候选', mechanism=mech + '+ANCHOR_CAP',
  160. rationale=why + f" ★部件类锚 tier={ci['tier']} ({ci['reason']}) ⇒ 封顶「候选」", **base)
  161. return dict(level='候选', mechanism=mech + '+ANCHOR_CAP',
  162. rationale=why + ' ★结论由**未校准的自定相对门限**驱动且无正样本锚 ⇒ 封顶「候选」', **base)
  163. if not cal and lv in _DEFECT_LEVELS and _LI[lv] > _LI['候选']:
  164. pass # (anchors/positive_anchor 分支已处理)
  165. if cal and lv in _DEFECT_LEVELS:
  166. why += ' (门限已校准: ISO/厂商定值 ⇒ 不受无正样本锚封顶约束)'
  167. return dict(level=lv, mechanism=mech, rationale=why, **base)
  168. if span == 0:
  169. return cap(hi['level'], 'AGREE',
  170. f'{len(real)} 个体系一致'+(f" (另 {len(down)} 个因未登记检查项降为 INSUFFICIENT)" if down else ''), hi)
  171. # 盲区检验: 高者的证据类型, 低者查过没有
  172. hi_ev = _driver_ev(hi) - _checked_types(lo)
  173. if hi_ev:
  174. return cap(hi['level'], 'LOW_IS_BLIND',
  175. (f"{lo['source']}判「{lo['level']}」但它**未检查**驱动证据类型 {sorted(hi_ev)} "
  176. f"(它查的是 {sorted(_checked_types(lo))}) ⇒ 其'正常'在此无信息、不得用于销案 "
  177. f"⇒ 按 {hi['source']}"), hi)
  178. # ★错3: 同源判据 ⇒ 方法差异不升级
  179. if _signals(hi) & _signals(lo):
  180. return cap(hi['level'], 'METHOD_DIFF_SAME_SIGNAL',
  181. (f"两者算的是**同一个量** {sorted(_signals(hi) & _signals(lo))} ⇒ 非独立, "
  182. f"分歧属**方法差异**(阈值口径不同)不属独立冲突 ⇒ 不升级, 按较高者, "
  183. f"并挂『需复核口径』"), hi)
  184. # ★错2: 按跨度升级
  185. if span >= escalate_span:
  186. up = LEVELS[min(_LI[hi['level']] + 1, len(LEVELS) - 1)]
  187. return cap(up, 'TRUE_DISAGREEMENT_ESCALATE',
  188. (f"{hi['source']}「{hi['level']}」vs {lo['source']}「{lo['level']}」跨度{span}级, "
  189. f"信号链独立且双方都有登记检查 ⇒ 真分歧, 不取任一, 升级 "
  190. f"(分歧点信息量 > 任一单侧结论)"), hi)
  191. return cap(hi['level'], 'MINOR_DISAGREEMENT',
  192. f"跨度仅{span}级 < {escalate_span} ⇒ 不升级, 按较高者", hi)