acceptance_fsm.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. # -*- coding: utf-8 -*-
  2. """P3-b 验收树状态机: TCM-16 三分支决策树的机器化 (29# 修后即首例).
  3. 状态: 待执行 → 泵已修(前置判定: 柱塞回带?) → 分支判定(A尖峰消/B尖峰不消/C不回带) → closed.
  4. 输入槽 (多源, 各归其主): plunger = 我方柱塞仓自动判 / vibration = 振动线判定输入(尖峰消判据在它)
  5. / oil = 油样输入(29# 主轴后 Fe/PQ vs 2025-03 基线 15/12, 本轮华标缺采待补).
  6. 纪律: 修泵事件必须人工登记 (repair_done date), 禁从数据猜"已修"; 数据不足降待验禁默认按A结案."""
  7. from src import paths as P
  8. import json, pathlib
  9. import pandas as pd
  10. from .store import Store
  11. ONT = P.ont()
  12. ST = P.store()
  13. SID = 'acceptance/WTG29/state'
  14. def plunger_verdict(repair_month):
  15. """前置判定: 修后柱塞回带? (自身正常带 = 2025-11~2026-05 单次时长中位的 0.7~1.4×)."""
  16. lub = pd.read_parquet(ST / 'mblub_monthly.parquet')
  17. l29 = lub[lub.turbine == 'WTG29'].set_index('month')
  18. per = (l29.pis / l29.cnt.replace(0, pd.NA)).dropna()
  19. base = float(per.loc['2025-11':'2026-05'].median())
  20. post = per[per.index > repair_month]
  21. if not len(post):
  22. return dict(verdict='待数据', note=f'修后({repair_month}后)无柱塞月度数据 (SCADA 需更新)')
  23. first = float(post.iloc[0])
  24. ratio = first / base
  25. if 0.7 <= ratio <= 1.4:
  26. return dict(verdict='回带', note=f'修后首月单次 {first:.0f}s = 自身基线({base:.0f}s)的 {ratio:.2f}× ∈ [0.7,1.4] — 突变型预期的立即回带')
  27. if ratio > 1.4:
  28. return dict(verdict='未回带', note=f'修后首月 {first:.0f}s = {ratio:.2f}× 仍拖长 → 分支C: 泵没修好或管路残留')
  29. return dict(verdict='回带过短', note=f'修后首月 {first:.0f}s = {ratio:.2f}× 低于带 → 疑打脂欠量复发, 按分支C排查')
  30. def evaluate(repair_done=None, vibration_input=None, oil_input=None, *, persist=True, store_path=None):
  31. """状态机推进 (幂等; 每次月度滚动调一遍).
  32. repair_done: 'YYYY-MM' 人工登记的修泵完成月 (None=未修)
  33. vibration_input: dict(verdict='尖峰消|尖峰持续|待数据', note=..., by='振动线', when=...) — 判据在振动线
  34. oil_input: dict(verdict='支持内窥|不构成无磨损|记基线', note=...) — TCM-16 分支B三分判读
  35. persist: False = 只算不落盘 (**测试必须传 False**; 否则每跑一次 pytest 就改一次生产本体库,
  36. 靠"末尾恢复真实态"回滚是脆的 —— 恢复不保证字节一致, 且中途失败就留脏)
  37. store_path: 指定库文件 (默认生产库)"""
  38. s = Store(store_path or (ONT / 'objects.json'))
  39. if not repair_done:
  40. state, branch, note = '待执行', None, '修泵未发生 — 排期已裁 (用户2026-08-28批准情景A: 2026-09首个CTV窗), 待现场执行与完成登记'
  41. pv = None
  42. else:
  43. pv = plunger_verdict(repair_done)
  44. if pv['verdict'] in ('未回带', '回带过短'):
  45. state, branch = '分支C', 'C'
  46. note = f"柱塞{pv['verdict']} → 不进轴承议题, 重开泵/管路排查。{pv['note']}"
  47. elif pv['verdict'] == '待数据':
  48. state, branch, note = '泵已修·待柱塞数据', None, pv['note']
  49. else: # 回带 → 看振动槽
  50. vv = (vibration_input or {}).get('verdict', '待数据')
  51. if vv == '尖峰消':
  52. state, branch = '分支A·闭环', 'A'
  53. note = '柱塞回带 + 尖峰消(振动线判) → 纯润滑问题, 轴承逃过; 温度不降不否证A(热滞后)'
  54. elif vv == '尖峰持续':
  55. state, branch = '分支B', 'B'
  56. note = ('柱塞回带但尖峰不消 → 升级轴承内窥; 前置低成本中继=主轴后油样 vs 2025-03基线Fe15/PQ12 '
  57. f"(油样槽: {(oil_input or {}).get('verdict', '待采 — 华标2026-07轮29#缺采')})")
  58. else:
  59. state, branch = '泵已修·待振动判定', None
  60. note = '柱塞已回带; 尖峰消判据(≥14天/≥20条/max<8/扣台风)在振动线, 待其判定输入; 数据不足禁默认按A结案'
  61. data_end = str(pd.read_parquet(ST / 'mblub_monthly.parquet').month.max())
  62. # F8: 状态机演进走 force 通道, 逻辑时钟=柱塞数据窗末 (禁 datetime.now 假时间);
  63. # 状态实质变化时旧版本自动进 props.history (状态迁移史留痕), 幂等空转不触发
  64. s.put(dict(id=SID, type='Decision',
  65. props=dict(name='29# 修泵验收状态机 (TCM-16 三分支的机器化)',
  66. state=state, branch=branch, note=note,
  67. repair_done=repair_done or '未登记',
  68. slots=dict(plunger=pv, vibration=vibration_input, oil=oil_input),
  69. claim_window=f'柱塞数据窗至 {data_end}'),
  70. links=dict(basedOn=['decision/WTG29/acceptance_tree', 'verdict/WTG29/TCM-16'])),
  71. force=True, when=data_end)
  72. if persist: s.save()
  73. return state, branch, note
  74. if __name__ == '__main__':
  75. st, br, nt = evaluate() # 当前真实状态: 修泵未发生
  76. print(f'状态={st} 分支={br}\n{nt}')