goem.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. # -*- coding: utf-8 -*-
  2. """goem.py — §1.2 G-OEM 字段语义实证 工具箱 + goem.json builder + 保护码↔通道覆盖矩阵 (P2-1).
  3. Batch2 债 2026-07-02 骨架级抽库。诚实边界: **语义判读 (单位/坐标系/名实/限电方向) 是人工域** —
  4. 本模块只出可复核的证据数字 (range/采样率/哨兵/枚举cross-tab), 判读结论由人写进 per 字段 entry。
  5. 产物契约以实跑件为准 (outputs/liangshuiquan/sop/goem.json): {'fields': {字段名: {证据...}}}。
  6. coverage_matrix = 盲区前置盘点 (SOP §1.2; 方法论SOP"模态可用性"的机器化): 每保护码→SCADA通道
  7. 有/死/无, 盲区按已发损失定价喂"加点建议" (郭家店 80014 入口油温死列=齿箱跳闸#1 恰在线盲,
  8. A24 一台 179MWh 损失落于此盲区 = 加点最强论据)。纯函数无文件 I/O。
  9. """
  10. import numpy as np
  11. import pandas as pd
  12. DEFAULT_SENTINELS = (-99.0, -999.0, 9999.0, -9999.0, 32767.0, -11000.0)
  13. def field_unit_range(s, *, sentinels=DEFAULT_SENTINELS):
  14. """字段 range 实证 (§1.2 单位/哨兵): min/p1/median/p99/max + 各哨兵占比。单位判读留人工
  15. (1000×错=瓦特当千瓦, 阜山戒; 证据=数字, 结论=人)。"""
  16. v = pd.to_numeric(s, errors='coerce')
  17. n = int(v.size)
  18. sent = {str(x): round(float((v == x).mean()), 4) for x in sentinels if bool((v == x).any())}
  19. v = v.dropna()
  20. if v.empty:
  21. return {'n': n, 'n_valid': 0, 'sentinel_shares': sent, 'note': '全 NaN/无有效数'}
  22. return {'n': n, 'n_valid': int(v.size),
  23. 'min': round(float(v.min()), 3), 'p1': round(float(v.quantile(0.01)), 3),
  24. 'median': round(float(v.median()), 3), 'p99': round(float(v.quantile(0.99)), 3),
  25. 'max': round(float(v.max()), 3), 'sentinel_shares': sent}
  26. def field_sampling_per_month(df, *, time_col):
  27. """真采样率 per 月实测 (§1.2; 禁全局 median — 阜山 98% 行来自单月1秒, 全局假报"1秒级全年")。"""
  28. t = pd.to_datetime(df[time_col], errors='coerce').dropna().sort_values()
  29. if t.empty:
  30. return {}
  31. g = t.diff().dt.total_seconds().groupby(t.dt.to_period('M').astype(str))
  32. return {m: round(float(x.median()), 1) for m, x in g if x.notna().any()}
  33. def rated_plateau(p, *, q=0.995):
  34. """rated 从高风 plateau 实测 (§1.2; 单 chunk peek 不代表全场, 新艾里 1500→2000 戒)。"""
  35. v = pd.to_numeric(p, errors='coerce').dropna()
  36. return round(float(v.quantile(q)), 1) if not v.empty else None
  37. def enum_crosstab(df, *, enum_col, agg_cols):
  38. """状态/枚举列 cross-tab (§1.2 语义实证主工具): 每枚举值的 n + 各物理量中位 —
  39. '待机时 rpm=346' 这类矛盾一眼可见 (实跑件即此形)。"""
  40. out = {}
  41. for val, g in df.groupby(enum_col, dropna=False):
  42. row = {'n': int(len(g))}
  43. for c in agg_cols:
  44. if c in g.columns:
  45. row[c] = round(float(pd.to_numeric(g[c], errors='coerce').median()), 1)
  46. out[str(val)] = row
  47. return out
  48. def build_goem(fields, *, note=None):
  49. """组装 goem dict + 非空校验。返回 (goem, issues)。fields = {字段名: {实证方法/证据数字/结论}}。"""
  50. issues = []
  51. if not fields or not isinstance(fields, dict):
  52. issues.append('goem.fields 空 — §1.2 对将用到的每个字段做实证')
  53. goem = {'fields': fields or {}}
  54. if note:
  55. goem['note'] = note
  56. return goem, issues
  57. def coverage_matrix(codes, *, channel_map, fault_counts=None, loss_mwh=None):
  58. """保护码↔SCADA通道覆盖矩阵 + 盲区按已发损失定价 (P2-1, SOP §1.2; 2026-07-02)。
  59. 入: codes = {code: 描述}; channel_map = {code: {'channel': 通道名|None, 'state': 'alive'|'dead'|None}}
  60. (state 由 reference_channel_liveness 逐台/逐列判后汇总给出; 无通道 = channel None);
  61. fault_counts = {code: 窗内跳闸次数}; loss_mwh = {code: 窗内已发损失 MWh} (EL-5 口径)。
  62. 出: {'rows': [...], 'blind_ranked': [...按 loss→fault_n 降序], 'summary': {...}}。
  63. 盲区 = state != 'alive' (死列与无通道同为在线盲); blind_ranked 直接喂报告"加点/读保护系统日志"建议。
  64. DP 锚 (郭家店): 80014 入口油温 SCADA 死列 (跳闸#1 276次) + 80001/02 中间轴无通道 (#3 163次)
  65. = 齿箱跳闸前两位恰在在线盲区; A24 一台 179MWh 在此盲区。
  66. """
  67. fault_counts = fault_counts or {}
  68. loss_mwh = loss_mwh or {}
  69. rows = []
  70. for code, desc in codes.items():
  71. ch = (channel_map or {}).get(code) or {}
  72. state = ch.get('state') if ch.get('channel') else None
  73. state_s = {None: '无通道', 'alive': '活', 'dead': '死列'}.get(state, str(state))
  74. blind = state != 'alive'
  75. rows.append({'code': str(code), 'desc': desc, 'channel': ch.get('channel'),
  76. 'state': state_s, 'blind': blind,
  77. 'fault_n': int(fault_counts.get(code, 0)),
  78. 'loss_mwh': round(float(loss_mwh.get(code, 0.0)), 1)})
  79. blind_ranked = sorted((r for r in rows if r['blind']),
  80. key=lambda r: (-r['loss_mwh'], -r['fault_n']))
  81. return {'rows': rows, 'blind_ranked': blind_ranked,
  82. 'summary': {'n_codes': len(rows), 'n_blind': sum(r['blind'] for r in rows),
  83. 'blind_loss_mwh': round(sum(r['loss_mwh'] for r in rows if r['blind']), 1),
  84. 'blind_fault_n': sum(r['fault_n'] for r in rows if r['blind'])},
  85. 'caveat': ('盲区加点建议按 loss→fault_n 排序; state 须先经 liveness 判 (死列≠无通道但同盲); '
  86. '损失=EL-5 物理估算口径, 禁报表损失列')}