populate.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. # -*- coding: utf-8 -*-
  2. """P1 本体铺开: 38台×九系统判级 + 振动handoff + 工单 + 油样 + 机制库 批量入库.
  3. 转录纪律: 判级只转录 L1 正本 (taxonomy.system_matrix / fusion_table), 本层零判级权;
  4. 每条 Verdict 带审级与 claim_window (C2), 其 Evidence 指回 L1 产物 (C1);
  5. 同一部件不同源判级并存不合并 (如 29# 主轴承: L1温度轴'优秀'lite vs TCM-16'定论'双模审 —
  6. NBM变化型对稳定高位失明的活例, 冲突显式可见, 审级高者主导但都保留).
  7. 跑法: .venv/bin/python -m src.ontology.populate"""
  8. from src import paths as P
  9. import json, pathlib, sys
  10. import pandas as pd
  11. from .store import Store
  12. from .schema import ConstraintError
  13. ST = P.store()
  14. ONT = P.ont()
  15. # 九系统 → Evidence 观测面 (粗映射, 转录用; 机制级证据另有精确面)
  16. SYS_FACET = {'变桨': '因侧执行链', '偏航': '因侧执行链', '主轴承': '果侧温度', '齿轮箱': '果侧温度',
  17. '发电机': '果侧电气', '变流器': '果侧电气', '主控与传感网': '测量链面',
  18. '叶片与叶根': '结构面', '塔架与基础': '结构面'}
  19. L1_WINDOW = '近90日固定窗 (至2026-07-07; 曲线轴=2025H2)'
  20. def _tid(t):
  21. return f'turbine/{t}'
  22. # ── 部件对象 (惰性建; 工单 resolves / 油样 about 的落点) ──
  23. COMP_NAMES = {
  24. 'mb_lub_pump': '主轴承集中润滑泵', 'gen_lub_pump': '发电机润滑泵', 'yaw_lub_pump': '偏航润滑泵',
  25. 'main_bearing': '主轴承(未分端)', 'main_bearing_fore': '前主轴承', 'main_bearing_aft': '后主轴承(齿轮箱侧)',
  26. 'gearbox': '主齿轮箱', 'generator': '发电机',
  27. 'gen_bearing_fore': '发电机前轴承', 'gen_bearing_aft': '发电机后轴承',
  28. 'pitch_system': '变桨系统', 'pitch_bearing_a': '变桨轴承A', 'pitch_bearing_b': '变桨轴承B',
  29. 'pitch_bearing_c': '变桨轴承C',
  30. 'yaw_system': '偏航系统', 'yaw_gearbox': '偏航减速器', 'yaw_bearing': '偏航轴承', 'yaw_hydraulic': '偏航液压站',
  31. 'hydraulic_station': '液压站',
  32. }
  33. def _ensure_component(s, t, key):
  34. """component/{t}/{key} 不存在则建 (链回台); 已存在 (如 seed 的 WTG29 两件) 直接复用不覆盖."""
  35. cid = f'component/{t}/{key}'
  36. if cid not in s.objects:
  37. s.put(dict(id=cid, type='Component', props=dict(name=COMP_NAMES[key]),
  38. links=dict(of=[_tid(t)])))
  39. return cid
  40. def _wo_component_key(txt):
  41. """工单文本 → 部件 key. 只做确定性强的关键词映射, 含糊不建 (宁缺勿滥).
  42. 顺序 = 具体优先: 润滑泵必须先归属主体 (偏航/发电机/主轴承各有润滑泵, 单写'润滑泵'不知道哪个)."""
  43. if ('润滑' in txt) or ('油脂' in txt):
  44. if '主轴' in txt:
  45. return 'mb_lub_pump'
  46. if '发电机' in txt:
  47. return 'gen_lub_pump'
  48. if '偏航' in txt:
  49. return 'yaw_lub_pump'
  50. return None # 归属不明的润滑工单: 不建 (宁缺勿滥)
  51. if '主轴承' in txt:
  52. return 'main_bearing'
  53. if '齿轮箱' in txt:
  54. return 'gearbox'
  55. if '变桨' in txt:
  56. return 'pitch_system'
  57. if '偏航' in txt:
  58. return 'yaw_system'
  59. if '发电机' in txt: # '偏航电机/变桨电机' 不含'发电机'三连字, 已被上两支接走
  60. return 'generator'
  61. if '液压' in txt:
  62. return 'hydraulic_station'
  63. return None
  64. # 油样 comp 字段 (结构化, 18 种取值全部确定) → 部件 key; 同义归一
  65. OIL_COMP_KEY = {
  66. '主轴前': 'main_bearing_fore', '主轴前轴承': 'main_bearing_fore',
  67. '主轴后': 'main_bearing_aft', '主轴后轴承': 'main_bearing_aft',
  68. '主齿轮箱': 'gearbox',
  69. '发电机前': 'gen_bearing_fore', '发电机前轴承': 'gen_bearing_fore', '发电机轴承前': 'gen_bearing_fore',
  70. '发电机后': 'gen_bearing_aft', '发电机后轴承': 'gen_bearing_aft', '发电机轴承后': 'gen_bearing_aft',
  71. '液压站': 'hydraulic_station',
  72. '偏航减速器': 'yaw_gearbox', '偏航液压站': 'yaw_hydraulic', '偏航轴承': 'yaw_bearing',
  73. '变桨轴承A': 'pitch_bearing_a', '变桨轴承B': 'pitch_bearing_b', '变桨轴承C': 'pitch_bearing_c',
  74. }
  75. def populate():
  76. sys.path.insert(0, '.')
  77. from src.windscada import taxonomy
  78. from src.windscada.subsys import fusion
  79. s = Store(ONT / 'objects.json')
  80. n0 = len(s.objects)
  81. # ── Turbine 38 台 ──
  82. from src.windscada.config import farm
  83. cfg = farm()
  84. for t in cfg['turbines']:
  85. if _tid(t) not in s.objects:
  86. s.put(dict(id=_tid(t), type='Turbine', props=dict(name=t.replace('WTG', '') + '#',
  87. model='SWT-4.0-130', farm='rudong')))
  88. # F7: Decision 显式分型在 store.put (写入闸层, 所有模块统一) — 见 store._decision_kind
  89. # ── 九系统判级 (L1 正本 = taxonomy.system_matrix; 全状态含优秀/良好 = 全景转录) ──
  90. mx = taxonomy.system_matrix()
  91. for t, row in mx.items():
  92. for sysn, v in row.items():
  93. eid = f'evidence/L1/{t}/{sysn}'
  94. # C4 三分判读: L1 依据是多轴拼接串 (";" 分隔), 交叉性 = 有几个独立轴同时非常态。
  95. # 单轴 → 不可判 (单源不构成交叉印证, C3 同理); 多轴同向 → 支持;
  96. # ★"各轴无非常态" 也是"不可判"而非"排除" — 判据面没测到 ≠ 该部件被证明无缺陷
  97. # (监测面覆盖有限, 见 detectability_prior 盲区)。
  98. _basis = str(v.get('依据', ''))
  99. _axes = [x for x in _basis.split(';') if x.strip() and '无非常态' not in x]
  100. if len(_axes) >= 2:
  101. _cc, _ccn = '支持', f'{len(_axes)} 个轴同时非常态 (轴: {"; ".join(a.strip()[:26] for a in _axes[:3])})'
  102. elif len(_axes) == 1:
  103. _cc, _ccn = '不可判', '仅单轴非常态 — 单源不构成交叉印证 (需第二个独立观测面)'
  104. else:
  105. _cc, _ccn = '不可判', '本层各轴无非常态 — 判据面未测到 ≠ 已排除 (监测面覆盖有限, 见盲区先验)'
  106. s.put(dict(id=eid, type='Evidence',
  107. props=dict(facet=SYS_FACET.get(sysn, '外部记录'),
  108. source=f'src/windscada taxonomy.system_matrix[{sysn}] (L1 判据层)',
  109. claim_window=L1_WINDOW, summary=_basis[:200],
  110. crosscheck=_cc, crosscheck_note=_ccn,
  111. crosscheck_facet='L1 多轴合成 (同层多轴, 非第三方独立源)'),
  112. links=dict(about=[_tid(t)])))
  113. s.put(dict(id=f'verdict/L1/{t}/{sysn}', type='Verdict',
  114. props=dict(level=v['状态'], system=sysn, review_tier='lite',
  115. review_note='L1 自动判级 (筛查级/候选, 非定谳; SOP 审级=lite)',
  116. claim_window=L1_WINDOW),
  117. links=dict(evidencedBy=[eid], about=[_tid(t)])))
  118. # ── 振动 handoff 判级 (28 台次, fusion_table 已带 component/claim_window/verdict 原文) ──
  119. ftab, _ = fusion.fusion_table()
  120. # F6 修 (Critic 逮): 原 id 用 DataFrame 位置索引 i — handoff 增删一行, 所有后续行的 id 整体错位,
  121. # 已落库的 decision/links 会静默指向别的台次。改内容派生 id (台+部件槽), 与行序无关。
  122. def _slot(comp):
  123. import re as _re
  124. return _re.sub(r'[^0-9A-Za-z\u4e00-\u9fa5]+', '', str(comp))[:16] or 'na'
  125. for i, r in ftab.iterrows():
  126. eid = f'evidence/vib/{r.turbine}/{_slot(r.部件)}'
  127. # C4 三分判读 (审计 ④ 修): 交叉面 = 温度轴 (fusion 表已给该部件的 dev 与判).
  128. # ★"无信号" 必须写"不可判"不能写"排除" — 温度对早期轴承缺陷本就不敏感 (fleet σ 2.7~3.4K
  129. # 只能检出 >3K 的严重热负荷), 写"排除"会让下游误以为假设已被否定而放弃现场复查
  130. # (memory: 不支持≠无信号)。
  131. _vlv = fusion.verdict_class(str(r.振动结论))[1]
  132. _tdev = r.温度dev if '温度dev' in ftab.columns else None
  133. _tvd = str(r.温度判 or '') if '温度判' in ftab.columns else ''
  134. if _vlv in ('bad', 'warn'):
  135. if _tdev is not None and _tdev == _tdev and abs(float(_tdev)) >= 3.0 and '候选' in _tvd:
  136. _cc, _ccn = '支持', f'温度轴同向: {r.温度通道} {_tdev:+.1f}K [{_tvd}] — 双面同向'
  137. elif _tdev is not None and _tdev == _tdev and float(_tdev) <= -3.0:
  138. _cc, _ccn = '不可判', f'温度轴反向 ({r.温度通道} {_tdev:+.1f}K) 但温度对早期缺陷分辨力不足 (fleet σ 2.7~3.4K) — 不构成排除'
  139. else:
  140. _cc, _ccn = '不可判', ('温度轴无信号' + (f' ({r.温度通道} {_tdev:+.1f}K [{_tvd or "—"}])' if _tdev == _tdev else '')
  141. + ' — 分辨力不足非阴性; 早期轴承缺陷在温度面本就不显')
  142. else:
  143. _cc, _ccn = '不可判', '振动结论非报警级, 交叉面不适用 (单源不构成交叉印证)'
  144. s.put(dict(id=eid, type='Evidence',
  145. props=dict(facet='果侧振动稳态',
  146. source='振动线 handoff_vibration_v2.json (正本=m5_cms_tcm findings; 转录粗分面, 精确面见机制对象)',
  147. claim_window=str(r.claim_window), summary=str(r.振动信号)[:200],
  148. crosscheck=_cc, crosscheck_note=_ccn,
  149. crosscheck_facet='果侧温度 (fusion 表同部件通道)'),
  150. links=dict(about=[_tid(r.turbine)])))
  151. # F3 修 (独立 Critic 逮): 振动线 v2.1 显式标注"引用同族/谱线级/测量链问题/未映射"的行
  152. # **不作部件级结论消费** — 原实现一律提成 Verdict, 等于把"这条不能当部件结论"的行
  153. # 变成了 28 条里 15 条的部件级判级, 下游(排程/风险/矩阵)会把它们当真判级用。
  154. # 改: 这类行仍转录为 Evidence(证据在, 不丢), 但降为 props.consumable=False 的 Observation 级 Verdict,
  155. # 并显式记 not_component_level 原因; 判级类查询(ont_maint_plan/ont_risk)只取 consumable 行。
  156. _comp = str(r.部件)
  157. _nc = next((k for k in ('引用同族', '谱线级', '测量链', '未映射') if k in _comp), None)
  158. s.put(dict(id=f'verdict/vib/{r.turbine}/{_slot(r.部件)}', type='Verdict',
  159. props=dict(level=str(r.振动结论), system='振动', component=_comp,
  160. review_tier='双模审',
  161. review_note='振动线历审 (Gemini+DeepSeek + 双线互复现), 本层转录',
  162. consumable=(_nc is None),
  163. not_component_level=_nc,
  164. consume_note=(None if _nc is None else
  165. f'振动线标注"{_nc}" ⇒ 不作部件级结论消费 (证据保留, 判级不下游)'),
  166. claim_window=str(r.claim_window)),
  167. links=dict(evidencedBy=[eid], about=[_tid(r.turbine)])))
  168. # ── 工单 1651 条 → WorkOrder (检修事件语义; 原始行留 L0) ──
  169. # resolves 链 (审计缺链①): 工单文本含确定性部件关键词 → links.resolves→component/;
  170. # 匹配面 = 故障名称+位置二级+元器件+维修对象, 含糊不建 (宁缺勿滥)
  171. w = pd.read_parquet(ST / 'workorders.parquet')
  172. w = w[w.t_report > '2019']
  173. n_dirty_loss, n_resolves = 0, 0
  174. for i, r in w.iterrows():
  175. raw_loss = r['发电量损失(kWh)']
  176. loss_kwh, loss_note = None, None
  177. if pd.notna(raw_loss):
  178. try:
  179. loss_kwh = float(raw_loss)
  180. except (ValueError, TypeError): # 脏文本混数值列 (如"损失电量统计在不间断电源故障内") — 原文保留不静默丢
  181. loss_note = str(raw_loss)[:60]; n_dirty_loss += 1
  182. t = str(r.turbine)
  183. wo_txt = '|'.join(str(r[c]) for c in ('故障名称', '故障位置二级', '元器件名称', '维修对象')
  184. if pd.notna(r[c]))
  185. links = dict(about=[_tid(t)])
  186. ckey = _wo_component_key(wo_txt)
  187. if ckey:
  188. links['resolves'] = [_ensure_component(s, t, ckey)]
  189. n_resolves += 1
  190. s.put(dict(id=f'workorder/{i}', type='WorkOrder',
  191. props=dict(turbine=t, date=str(r.t_report)[:10],
  192. name=str(r['故障名称'])[:60], loc2=str(r['故障位置二级']),
  193. action=str(r['维修动作']) if pd.notna(r['维修动作']) else None,
  194. part=str(r['元器件名称']) if pd.notna(r['元器件名称']) else None,
  195. loss_kwh=loss_kwh, loss_note=loss_note),
  196. links=links))
  197. if n_dirty_loss:
  198. print(f' ⚠ 工单损失列脏文本 {n_dirty_loss} 条 → loss_note 保留原文')
  199. print(f' 工单 resolves 链 {n_resolves}/{len(w)} 条 (确定性关键词映射, 含糊不建)')
  200. # ── 油样 506 份索引 → OilSample (审计缺链②: comp 字段结构化确定 → about 补 component/ 链) ──
  201. oil = pd.read_parquet(ST / 'oil_samples_index.parquet')
  202. n_oil_comp = 0
  203. for _, r in oil.iterrows():
  204. t = str(r.turbine)
  205. about = [_tid(t)]
  206. okey = OIL_COMP_KEY.get(str(r.comp))
  207. if okey:
  208. about.append(_ensure_component(s, t, okey))
  209. n_oil_comp += 1
  210. s.put(dict(id=f'oilsample/{r.sample_id}', type='OilSample',
  211. props=dict(turbine=t, comp=str(r.comp), report_date=str(r.date),
  212. note='文件名=报告日期, 取样日≈报告日−1~2周; 数值按需逐份解析'),
  213. links=dict(about=about)))
  214. print(f' 油样→部件链 {n_oil_comp}/{len(oil)} 份')
  215. # ── 机制库 (findings/注记提炼; 29# 润滑断供已入 P0, 此处补 8 族) ──
  216. # F9: fleet 级机制的 affects 从数据实算台清单 (mblub_alarm_cross 的 "2026窗78条/21台" 口径),
  217. # 不留空链接 (空 affects = 查询游走不可达); 台号 data-driven 非硬编码
  218. a = pd.read_parquet(ST / 'alarms.parquet')
  219. fleet_64001 = sorted(a[(a.code == '64001') & (a.t_on >= '2026-01-01')].turbine.unique())
  220. MECHS = [
  221. dict(id='mechanism/rudong/dew_heater_stuck_on', name='防凝露加热器卡开 (温控执行器卡死家族)',
  222. instances=['WTG31'], status='候选 (跨场家族 n=3+1)',
  223. facets=['果侧温度', '因侧执行链'],
  224. source='memory thermal-regulation-stuck-family; 夏duty高×低湿窗×整窗运行三证'),
  225. dict(id='mechanism/rudong/gende_bpfi_135', name='发电机DE内圈缺陷族 (BPFI 135Hz, OEM表锚定)',
  226. instances=['WTG16', 'WTG33'], status='预警 (16#处置规划/33#预警; 谱线过六闸)',
  227. facets=['果侧振动稳态'],
  228. source='振动线 handoff v1_generator; 包络域显著+原始谱不显著=早期轴承缺陷预期签名'),
  229. dict(id='mechanism/rudong/accum_fail_recharge_heat', name='变桨蓄能器失效→频繁充压→液压油温高',
  230. instances=['WTG20', 'WTG08', 'WTG03'], status='候选 (三向重合: 油温高簇×蓄能报警×带宽)',
  231. facets=['因侧执行链', '果侧温度'],
  232. source='temp_overview 互证注记 2026-08-25 订正版; 19#油温高但蓄能三轴净=单列待查不并入'),
  233. dict(id='mechanism/rudong/gen_de_hot_24', name='24# 发电机DE轴承高温 (NDE−DE=−20K)',
  234. instances=['WTG24'], status='候选 (与2025-12换双轴承史并读)',
  235. facets=['果侧温度'],
  236. source='temp_nbm pairs_table; 端别按西门子IO表定案 R=DE'),
  237. dict(id='mechanism/rudong/gbx_bypass_filter_dp', name='齿轮箱旁路过滤器压力高 (码4312)',
  238. instances=['WTG27'], status='候选 (27#累计1082h 全场集中; 机制未定: 滤芯堵/油温粘度/传感)',
  239. facets=['因侧执行链'],
  240. source='alarms.parquet 码4312; 油路压力必须油温匹配 (memory oil-circuit-pressure)'),
  241. dict(id='mechanism/rudong/mb_grease_low_systemic', name='主轴承油脂水平低系统性背景 (码64001)',
  242. instances=fleet_64001, status='系统性背景 (非个体; 报警条数排序无意义)',
  243. facets=['因侧执行链'], scope_note=f'2026窗报警台清单 (alarms.parquet 实算, {len(fleet_64001)}台)',
  244. source='mblub_alarm_cross.json: 2026窗78条/21台, 与柱塞无显著关联(19/45 p=0.41), 个体升级须走自身纵向机制链'),
  245. dict(id='mechanism/rudong/ims_broadband_31', name='31# 齿轮箱中速轴宽带高位 (不命名滚道)',
  246. instances=['WTG31'], status='预警 (宽带持续高位, 离散谱线无独立选择性 → 只判区域不命名部件)',
  247. facets=['果侧振动稳态'],
  248. source='振动线 v1_gearbox; 拒绝命名=限制错误传播半径的防污染闸'),
  249. dict(id='mechanism/rudong/yaw_drive_alarm_family', name='偏航驱动报警族',
  250. instances=['WTG29'], status='报警 (L1偏航轴; 29#偏航判报警的驱动维)',
  251. facets=['因侧执行链'],
  252. source='taxonomy.system_matrix 偏航轴; 与主轴承润滑机制独立'),
  253. ]
  254. for m in MECHS:
  255. affects = [_tid(t) for t in m['instances'] if t.startswith('WTG')]
  256. if not affects:
  257. raise ValueError(f"F9: 机制 {m['id']} affects 为空 — 空链接=游走不可达, 转录处必须给台/系统级落点")
  258. props = dict(name=m['name'], status=m['status'], facets=m['facets'],
  259. independent_source_count=len(set(m['facets'])), # C3: 源数=facet 去重数
  260. source=m['source'])
  261. if 'scope_note' in m:
  262. props['scope_note'] = m['scope_note']
  263. s.put(dict(id=m['id'], type='FailureMechanism', props=props,
  264. links=dict(affects=affects)))
  265. p = s.save()
  266. return p, n0, len(s.objects)
  267. if __name__ == '__main__':
  268. # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
  269. # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
  270. # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
  271. import sys as _sys
  272. for _s in (_sys.stdout, _sys.stderr):
  273. try: _s.reconfigure(errors='replace')
  274. except Exception: pass
  275. p, n0, n1 = populate()
  276. print(f'✔ P1 铺开: {n0} → {n1} 对象 ({p})')