|
@@ -1,275 +0,0 @@
|
|
|
-"""CMS/TCM 数据可用性判据 (六层振动诊断模型 L0 层)。
|
|
|
|
|
-
|
|
|
|
|
-★为什么 L0 必须第一层跑、且不通过则短路后续所有层 (如东 27# 戒, 2026-08-21):
|
|
|
|
|
- 一条坏掉的测量链会**同时**制造假阳与假阴 ——
|
|
|
|
|
- 假阳: 27# Generator_DE 速度域 69.7 mm/s (ISO 红线 6.3 倍), 实为低频段测量链问题
|
|
|
|
|
- 经积分放大; 同测点加速度域 `Rms_HP` 反而是**全场最低** (分位 0.0%)。
|
|
|
|
|
- 假阴: 盲区台不进筛查集 ⇒ **永远不会被标异常**, 系统也不会报"我没在测"。
|
|
|
|
|
- 27# 被判"换轴承后谱面清洁·销案"并当作全场唯一正样本锚, 而它的 CMS 侧
|
|
|
|
|
- `iso_rms_vel` 从 2026-01-18 报到 08-19、红 237 次无人确认。**L0 未过而 L1~L5 被当真。**
|
|
|
|
|
-
|
|
|
|
|
-★判据分层原则: 物理量 > 派生量 > 自检标志。
|
|
|
|
|
- IEPE 偏置电压是**电气物理量** (恒流源供电的加速度计静态偏置), 是本层唯一硬锚;
|
|
|
|
|
- `health` 字段是厂商自检标志, **只反映连通性不反映采集在不在跑**, 单独不可作判据
|
|
|
|
|
- (memory cms-health-flag-not-data-liveness: 如东 3 台监测盲区期间 Health 全报 100)。
|
|
|
|
|
-
|
|
|
|
|
-数据源 (如东 CMS 服务器 `msys` 库, 2026-08-21 热拷贝解出):
|
|
|
|
|
- sensor_health 194,623 行 (480 传感器 × 逐周 2016W01–2026W34) / hosts 44 /
|
|
|
|
|
- syslog 9,301 / alarm 7,501 / measurements 1,091。
|
|
|
|
|
-"""
|
|
|
|
|
-from __future__ import annotations
|
|
|
|
|
-
|
|
|
|
|
-import pandas as pd
|
|
|
|
|
-
|
|
|
|
|
-from .analysis_kit import episode_segments, near_miss
|
|
|
|
|
-
|
|
|
|
|
-# ── IEPE 物理门 ────────────────────────────────────────────────────────────
|
|
|
|
|
-# 实测依据 (如东全场 155,728 条振动通道 bias 记录):
|
|
|
|
|
-# 99.83% 落在 12–14 V (中位 12.67), 正常带宽仅 ±1 V;
|
|
|
|
|
-# 故障态呈**双峰**: ≈27.5 V (n=265, 恒流源开路 = 传感器/电缆断) 与 0 V (n=16, 短路)。
|
|
|
|
|
-# 带宽取 [8, 15] 而非 [12, 14]: 留出不同 OEM 标称偏置差异, 只拦真故障态。
|
|
|
|
|
-IEPE_BIAS_MIN = 8.0
|
|
|
|
|
-IEPE_BIAS_MAX = 15.0
|
|
|
|
|
-
|
|
|
|
|
-# 主机离线判定: TCM 调度每 10 分钟拉一次数, last_seen 超过 7 天必属异常
|
|
|
|
|
-HOST_STALE_DAYS = 7
|
|
|
|
|
-
|
|
|
|
|
-# 通讯/登录类系统事件 —— 这些 Active 时该台无 SCADA 工况量, 谱采集触发不了
|
|
|
|
|
-COMM_FAULT_TYPES = (
|
|
|
|
|
- 'DP-Logon error',
|
|
|
|
|
- 'No Communication',
|
|
|
|
|
- 'M-System Invalid Communication',
|
|
|
|
|
- 'Operational Values Missing',
|
|
|
|
|
-)
|
|
|
|
|
-
|
|
|
|
|
-VERDICTS = ('可用', '部分盲区', '全盲', '测量链故障', '需谱层输入')
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def iepe_bias_gate(bias, *, lo=IEPE_BIAS_MIN, hi=IEPE_BIAS_MAX):
|
|
|
|
|
- """IEPE 偏置电压物理门 → (是否通过, 故障型)。
|
|
|
|
|
-
|
|
|
|
|
- 故障型区分对现场处置直接有用:
|
|
|
|
|
- 'open' 偏置 ≥ hi → 恒流源开路 = 传感器断线/接头松脱 (如东 12 台次 Generator_DE,
|
|
|
|
|
- 2023W47–2025W51, bias≈27.5 V, 其中 WTG09 断 39 周 / WTG18 断 35 周)
|
|
|
|
|
- 'short' 偏置 ≤ lo → 短路/接地 (如东 WTG05 四测点同时 0 V, 2017W48–W51 = 采集器侧)
|
|
|
|
|
- """
|
|
|
|
|
- s = pd.Series(bias, dtype='float64').dropna()
|
|
|
|
|
- if s.empty:
|
|
|
|
|
- return False, 'no_data'
|
|
|
|
|
- if (s >= hi).any():
|
|
|
|
|
- return False, 'open'
|
|
|
|
|
- if (s <= lo).any():
|
|
|
|
|
- return False, 'short'
|
|
|
|
|
- return True, None
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def blind_windows(present_weeks, all_weeks, *, min_len=1):
|
|
|
|
|
- """周记录缺失段 → 盲区形态。
|
|
|
|
|
-
|
|
|
|
|
- ★为什么要分段而不是只数缺周数 (承 analysis_kit.episode_segments 的 27# 戒):
|
|
|
|
|
- "缺 4 周"可能是**一次 4 周的连续断线**, 也可能是**4 次单周抖动**, 机制与处置相反。
|
|
|
|
|
- 如东 WTG18 近 6 周缺 2026W29/32/33/34 → 分段后是 1+3 两段 = 反复失败而非一次性断,
|
|
|
|
|
- 与其 `data#WTG18` 表 05-27→08-06 崩溃 **9 次**的形态一致 (每次 1–7 天后自动恢复)。
|
|
|
|
|
- """
|
|
|
|
|
- present = set(int(w) for w in present_weeks)
|
|
|
|
|
- order = sorted(int(w) for w in all_weeks)
|
|
|
|
|
- missing_flag = [w not in present for w in order]
|
|
|
|
|
- segs = episode_segments(missing_flag, min_len=min_len)
|
|
|
|
|
- return {
|
|
|
|
|
- 'n_missing': sum(missing_flag),
|
|
|
|
|
- 'n_segments': len(segs),
|
|
|
|
|
- 'segments': [(order[a], order[b], n) for a, b, n in segs],
|
|
|
|
|
- 'weeks_checked': len(order),
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def alarm_reachability(measurements):
|
|
|
|
|
- """G6 告警可达性: 哪些判据的红报警能写回风机控制器。
|
|
|
|
|
-
|
|
|
|
|
- ★如东实测: 1,091 个测量中**只有 8 个**带 `RedAction=SendWtcCodes`
|
|
|
|
|
- (WTC 码 8455 ← Indicator_1P/1S/2P/2S; 8459 ← Indicator_HSP/HSW),
|
|
|
|
|
- 且 `YellowAction` **全部为空** ⇒ 黄级一律不写回。
|
|
|
|
|
- 后果: 2025-01 起未绑码判据红计数 493 (iso_rms_vel 241 + rms_200 206) 全部传不出去,
|
|
|
|
|
- 而主轴承与发电机轴承**恰好只有这两个无码判据**。
|
|
|
|
|
- ⇒ "现场没收到告警"不等于"CMS 没检出", 归因前必先查本表。
|
|
|
|
|
- """
|
|
|
|
|
- m = measurements.copy()
|
|
|
|
|
- red = m.get('WTCCodeRed')
|
|
|
|
|
- if red is None:
|
|
|
|
|
- m['reachable'] = False
|
|
|
|
|
- else:
|
|
|
|
|
- m['reachable'] = red.notna() & (red.astype(str).str.strip() != '') & (red.astype(str) != '0')
|
|
|
|
|
- return (m.groupby('MeasurementName')['reachable'].any()
|
|
|
|
|
- .rename('writes_back_to_wtc').reset_index())
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# 告警"持续"的时长锚 —— **不是拍的**: 取自厂商配置 `TrendEstimationTimeDays`,
|
|
|
|
|
-# 全库 175 个测量一致为 60 天, 是 Gram & Juhl 定义的"估计一条趋势所需的时间窗"。
|
|
|
|
|
-# 一条告警若持续超过厂商认为足以判断趋势的时长, 它已不是"事件"而是"状态"。
|
|
|
|
|
-TREND_ESTIMATION_DAYS = 60.0
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def alarm_burst_pattern(alarm, *, asof, months=12, sustained_days=TREND_ESTIMATION_DAYS):
|
|
|
|
|
- """G6 告警发作形态 → 是否"持续"(单一判据), 附条数等描述量。
|
|
|
|
|
-
|
|
|
|
|
- ★这条判据是为补 L0 的**已知假阴**而加的 (如东 27# 戒):
|
|
|
|
|
- 27# Generator_DE 的 IEPE 偏置正常、周记录连续、主机在线、health=100 ——
|
|
|
|
|
- 前五道门**全过**, L0 判"可用"。但 alarm 表里它 2026-01-18 → 08-19 报了
|
|
|
|
|
- **40 条、红计数 237、大量 UnAck, 而每一条都自动关闭** (未关闭 0 条)。
|
|
|
|
|
- 这个"反复发作但每次自愈"的形态正是测量链问题的签名, 与真劣化相反。
|
|
|
|
|
-
|
|
|
|
|
- 判据只有一条: **存在单条告警持续 > sustained_days** (默认 60, 锚厂商 TrendEstimationTimeDays)。
|
|
|
|
|
-
|
|
|
|
|
- ★★★ 为什么只剩一条 —— "告警条数多"这个判据经检验**不成立, 已删除** (2026-08-21):
|
|
|
|
|
- 首版还有 `burst_n=10` (条数) 与 `burst_median_days=14` (中位时长) 两个参数, 都是拍的。
|
|
|
|
|
- 按外部建议 (振动线: 别用本场检出效果反调阈值) 做独立检验后发现:
|
|
|
|
|
- · 泊松本底不可用 —— 近 12 月 per(台,测点) 告警条数 **方差/均值 = 33.7**
|
|
|
|
|
- (泊松应≈1), 告警强烈成簇非独立事件, 参数化尾概率无意义。
|
|
|
|
|
- · 稳健离群检验 —— 对 log1p(条数) 取 mad_z, **全 63 个通道对 z ∈ [−0.67, 1.79]**,
|
|
|
|
|
- z>2.0 命中 **0 个**, 口径 2.0/2.5/3.0/3.5 全稳健 (sensitivity_sweep)。
|
|
|
|
|
- ⇒ **没有任何通道的告警条数算异常多**; 分布本身就是重尾的。
|
|
|
|
|
- ⇒ 原 `burst_n=10` 选出 21 个通道, 统计上站得住的判据选出 **0 个** —— 那 21 个
|
|
|
|
|
- 完全是拍的阈值造出来的。用泊松给它披统计外衣只会更糟 (先验伪装成实测)。
|
|
|
|
|
- 删掉它不损失检出: 27# 最长 106.5 天 > 60, 靠"持续"这一条照样被抓。
|
|
|
|
|
-
|
|
|
|
|
- 条数 / 中位时长 / 未确认数仍然照报, 但**只作描述量供人看, 不进判据**。
|
|
|
|
|
- """
|
|
|
|
|
- asof = pd.Timestamp(asof)
|
|
|
|
|
- a = alarm.copy()
|
|
|
|
|
- a['FromTime'] = pd.to_datetime(a['FromTime'], errors='coerce')
|
|
|
|
|
- a['ToTime'] = pd.to_datetime(a['ToTime'], errors='coerce')
|
|
|
|
|
- a = a[a['FromTime'] >= asof - pd.DateOffset(months=months)]
|
|
|
|
|
- a['dur_d'] = ((a['ToTime'].fillna(asof) - a['FromTime']).dt.total_seconds() / 86400)
|
|
|
|
|
- rows = []
|
|
|
|
|
- for (loc, sensor), g in a.groupby(['LocationName', 'SensorName']):
|
|
|
|
|
- n = len(g)
|
|
|
|
|
- med = float(g['dur_d'].median())
|
|
|
|
|
- mx = float(g['dur_d'].max())
|
|
|
|
|
- red = int(g['CountRed'].fillna(0).sum())
|
|
|
|
|
- sustained = mx > sustained_days
|
|
|
|
|
- rows.append({'location': loc, 'sensor': sensor,
|
|
|
|
|
- 'alarm_pattern': '持续' if sustained else '零星',
|
|
|
|
|
- 'is_sustained': sustained,
|
|
|
|
|
- # 以下四列为描述量, 不进判据 (见 docstring: 条数判据经检验不成立)
|
|
|
|
|
- 'alarm_n': n, 'alarm_red': red, 'alarm_dur_median_d': round(med, 2),
|
|
|
|
|
- 'alarm_dur_max_d': round(mx, 2),
|
|
|
|
|
- 'alarm_unacked': int((g['AckStatus'] == 'UnAck').sum())})
|
|
|
|
|
- return pd.DataFrame(rows)
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def cms_l0_availability(sensor_health, hosts, syslog, *, asof, alarm=None,
|
|
|
|
|
- recent_weeks=8, host_stale_days=HOST_STALE_DAYS):
|
|
|
|
|
- """L0 数据可用性裁决 → per (台 × 测点) 一行。
|
|
|
|
|
-
|
|
|
|
|
- 判据顺序即短路顺序 —— 先判"整台还在不在", 再判"这个测点的链路好不好"::
|
|
|
|
|
-
|
|
|
|
|
- G1 主机在线 hosts.last_seen 距 asof ≤ host_stale_days
|
|
|
|
|
- G2 通讯无故障 syslog 无 Active 的 COMM_FAULT_TYPES 事件
|
|
|
|
|
- G3 周记录连续 近 recent_weeks 周无缺周
|
|
|
|
|
- G4 IEPE 偏置 bias ∈ [8, 15] V ← 唯一物理硬锚
|
|
|
|
|
- G5 health 标志 == 100 ← 仅提示, 不单独定罪
|
|
|
|
|
- G6 告警形态 无单条告警持续 > 60 天 (锚厂商 TrendEstimationTimeDays; 传 alarm= 才启用)
|
|
|
|
|
-
|
|
|
|
|
- verdict: 全盲 (G1/G2 断) > 测量链故障 (G4 断) > 部分盲区 (G3 断)
|
|
|
|
|
- > 需谱层输入 (G6 断) > 可用。
|
|
|
|
|
-
|
|
|
|
|
- ⚠ **本层有已知假阴, 不可单独使用**: 振动线那两条需标量/谱输入的检验 ——
|
|
|
|
|
- 双态检验 (同测点逐日在极高与极低间跳变) 与域间矛盾检验 (加速度域正常而速度域极端)
|
|
|
|
|
- —— 本层做不了。实测: 不传 alarm 时 27# 十个通道 G1–G5 全过、判"可用",
|
|
|
|
|
- 而它是全场最严重的测量链故障。传 alarm 启用 G6 后才被降级标出。
|
|
|
|
|
- 即便如此, G6 也只提示"分不开", 定性仍须谱层。
|
|
|
|
|
- """
|
|
|
|
|
- asof = pd.Timestamp(asof)
|
|
|
|
|
- sh = sensor_health.copy()
|
|
|
|
|
- sh['yw3'] = sh['yw3'].astype(int)
|
|
|
|
|
- all_weeks = sorted(sh['yw3'].unique())[-recent_weeks:]
|
|
|
|
|
- recent = sh[sh['yw3'].isin(all_weeks)]
|
|
|
|
|
-
|
|
|
|
|
- # G1 主机在线 —— hosts 表一台可能有多条历史记录 (换过机箱), 取最新的
|
|
|
|
|
- h = hosts.copy()
|
|
|
|
|
- h['last_seen'] = pd.to_datetime(h['last_seen'], errors='coerce')
|
|
|
|
|
- h['tid'] = h['hostname'].astype(str).str.rsplit('.', n=1).str[-1]
|
|
|
|
|
- host_last = h.groupby('tid')['last_seen'].max()
|
|
|
|
|
-
|
|
|
|
|
- # G2 当前 Active 的通讯类故障
|
|
|
|
|
- # ★ dropna 不可省 (memory arrow-str-backend-astype-nan-footgun): pandas≥3 下
|
|
|
|
|
- # astype(str) 会把缺失值变成字面 'nan' 混进集合。如东 syslog 有 9 行 LocationName 为空。
|
|
|
|
|
- sl = syslog.dropna(subset=['LocationName']).copy()
|
|
|
|
|
- sl['tid_name'] = sl['LocationName'].astype(str).str.replace('CGN Rudong/', '', regex=False)
|
|
|
|
|
- active_comm = set(sl[(sl['State'] == 'Active')
|
|
|
|
|
- & (sl['Type'].isin(COMM_FAULT_TYPES))]['tid_name'].str.split('/').str[0])
|
|
|
|
|
-
|
|
|
|
|
- # G6 告警形态 (可选) —— 不传 alarm 则本门恒过, 且已知会放走 27# 型假阴
|
|
|
|
|
- pat = {}
|
|
|
|
|
- if alarm is not None:
|
|
|
|
|
- bp = alarm_burst_pattern(alarm, asof=asof)
|
|
|
|
|
- pat = {(r['location'], r['sensor']): r for _, r in bp.iterrows()}
|
|
|
|
|
-
|
|
|
|
|
- rows = []
|
|
|
|
|
- for (loc, sensor), g in recent.groupby(['locationname', 'sensor_name']):
|
|
|
|
|
- tid_num = ''.join(ch for ch in str(loc) if ch.isdigit()).lstrip('0') or '0'
|
|
|
|
|
- seen = host_last.get(tid_num)
|
|
|
|
|
- host_days = (asof - seen).days if pd.notna(seen) else None
|
|
|
|
|
- g1 = host_days is not None and host_days <= host_stale_days
|
|
|
|
|
- g2 = loc not in active_comm
|
|
|
|
|
- bw = blind_windows(g['yw3'], all_weeks)
|
|
|
|
|
- g3 = bw['n_missing'] == 0
|
|
|
|
|
- g4, fault = iepe_bias_gate(g['bias']) if sensor not in ('WPS', 'System Monitor') else (True, None)
|
|
|
|
|
- g5 = bool((g['health'] == 100).all())
|
|
|
|
|
- p = pat.get((loc, sensor))
|
|
|
|
|
- g6 = True if p is None else not p['is_sustained']
|
|
|
|
|
-
|
|
|
|
|
- if not g1:
|
|
|
|
|
- verdict, reason = '全盲', f'主机离线 {host_days} 天'
|
|
|
|
|
- elif not g2:
|
|
|
|
|
- verdict, reason = '全盲', '通讯故障 Active'
|
|
|
|
|
- elif not g4:
|
|
|
|
|
- verdict, reason = '测量链故障', f'IEPE 偏置异常 ({fault})'
|
|
|
|
|
- elif not g3:
|
|
|
|
|
- verdict, reason = '部分盲区', f"近 {len(all_weeks)} 周缺 {bw['n_missing']} 周 / {bw['n_segments']} 段"
|
|
|
|
|
- elif not g6:
|
|
|
|
|
- verdict, reason = ('需谱层输入',
|
|
|
|
|
- f"告警{p['alarm_pattern']}: {p['alarm_n']}条/红{p['alarm_red']}/"
|
|
|
|
|
- f"中位{p['alarm_dur_median_d']}d/未确认{p['alarm_unacked']}")
|
|
|
|
|
- else:
|
|
|
|
|
- verdict, reason = '可用', ''
|
|
|
|
|
-
|
|
|
|
|
- rows.append({
|
|
|
|
|
- 'location': loc, 'sensor': sensor, 'verdict': verdict, 'reason': reason,
|
|
|
|
|
- 'G1_host_online': g1, 'G2_no_comm_fault': g2, 'G3_weeks_continuous': g3,
|
|
|
|
|
- 'G4_iepe_bias_ok': g4, 'G5_health_flag_100': g5, 'G6_alarm_pattern_ok': g6,
|
|
|
|
|
- 'host_stale_days': host_days, 'bias_min': float(g['bias'].min()),
|
|
|
|
|
- 'bias_max': float(g['bias'].max()), 'bias_fault_mode': fault,
|
|
|
|
|
- 'missing_weeks': bw['n_missing'], 'blind_segments': bw['n_segments'],
|
|
|
|
|
- 'weeks_checked': bw['weeks_checked'],
|
|
|
|
|
- 'alarm_pattern': (p['alarm_pattern'] if p is not None else None),
|
|
|
|
|
- 'alarm_n': (int(p['alarm_n']) if p is not None else 0),
|
|
|
|
|
- 'alarm_red': (int(p['alarm_red']) if p is not None else 0),
|
|
|
|
|
- 'spectral_checks_pending': '双态检验+域间矛盾检验(需标量/谱输入)',
|
|
|
|
|
- })
|
|
|
|
|
- out = pd.DataFrame(rows).sort_values(['verdict', 'location', 'sensor'])
|
|
|
|
|
- return out.reset_index(drop=True)
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def l0_summary(avail):
|
|
|
|
|
- """裁决汇总 + 门边披露。
|
|
|
|
|
-
|
|
|
|
|
- ★带 near_miss 是硬要求 (memory exact-set-assertion-self-review-blind):
|
|
|
|
|
- "全场只有 N 台不可用"这类精确集断言若来自硬 cutoff, 交付前必须同时报刀刃带,
|
|
|
|
|
- 否则被阈值刚好挡住的台在报告里**完全不可见**。
|
|
|
|
|
- """
|
|
|
|
|
- by_v = avail['verdict'].value_counts().to_dict()
|
|
|
|
|
- bad = avail[avail['verdict'] != '可用']
|
|
|
|
|
- # 门边: 主机 last_seen 落在 [门-3, 门] 天内的台 —— 再过几天就要翻面
|
|
|
|
|
- stale = avail.dropna(subset=['host_stale_days']).set_index(
|
|
|
|
|
- avail.dropna(subset=['host_stale_days'])['location'] + '/' + avail.dropna(subset=['host_stale_days'])['sensor']
|
|
|
|
|
- )['host_stale_days']
|
|
|
|
|
- return {
|
|
|
|
|
- 'verdict_counts': by_v,
|
|
|
|
|
- 'n_units': int(avail['location'].nunique()),
|
|
|
|
|
- 'n_channels': int(len(avail)),
|
|
|
|
|
- 'unavailable_units': sorted(bad['location'].unique().tolist()),
|
|
|
|
|
- 'unavailable_detail': bad[['location', 'sensor', 'verdict', 'reason']].to_dict('records'),
|
|
|
|
|
- 'host_days_near_miss': near_miss(stale, gate=HOST_STALE_DAYS,
|
|
|
|
|
- band=(HOST_STALE_DAYS - 3, HOST_STALE_DAYS)),
|
|
|
|
|
- }
|
|
|