rudong_fusion_handoff.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""融合面 handoff 的**观澜自算**生成端(用户令 2026-09-19「所有的计算均要形成观澜的源代码」)。
  4. ## 为什么需要它
  5. `/detail/v2` 的「需要关注」与「全场状态」两块,取数走 `/api/fleet` 的 `fus`,而 `fus` 的
  6. 链盘行(需要关注)与逐台矩阵(全场状态)都来自 `src/windscada/subsys/fusion.py` 读的
  7. `outputs/<场>/m5_cms_tcm/handoff_vibration_v2.json`。那件此前是**振动线人工交证件**:
  8. 全库 0 处写入方 ⇒ 清过产物、只从 data/raw 重算的机器上必然不在位 ⇒ 两块页面**空白**
  9. (不是报错,也不是没数据 —— 是那件没有生成端)。
  10. 用户令把口径定死了:**所有计算都要落到观澜自己的源代码里**,别的电脑装完(放上原始件、重算)
  11. 系统就要正常跑。所以本器把这份 handoff **按同一份结构自己算出来**:
  12. 输入(全部是观澜从 data/raw 重算出来的产物)
  13. · m5_cms_tcm/model_run_l6.parquet 六层链 L6 过闸谱线(scripts/rudong_model_run.py)
  14. · m5_cms_tcm/fusion_38.csv 逐台融合裁决(scripts/rudong_fusion_run.py)
  15. · windcms/报告_CMS振动状态评估报告_*.md 逐台逐部件状态等级 + 证据状态(scripts/windcms.py report)
  16. · m5_cms_tcm/windows/<窗>/index.parquet 窗内真实日期区间(claim_window 用实算值)
  17. 输出
  18. · m5_cms_tcm/handoff_vibration_v2.json 与消费者同结构(meta / detectability_prior / per_turbine / open_items)
  19. ## 与"正本"的关系(必须说清,不许冒充)
  20. 原 handoff 是**人对人的交证件**(含人工裁决、校准更新、现场动作),本器产出的是**同结构的观澜自算件**:
  21. · `meta.from` = 观澜自算(不是振动线),`meta.version` = `guanlan-selfcomputed-v1`;
  22. · 每条 verdict 由观澜自己的判据给出(L6 过闸线 + 融合裁决 + 报告状态等级),**不含**任何人工裁决;
  23. · `open_items` 首条如实声明"本件非振动线正本,正本到场应以正本为准";
  24. · 若盘上已有**正本**(`meta.from` 里不含"观澜自算"),本器**不覆盖**(除非显式 `--force`)。
  25. 用法:
  26. python scripts/rudong_fusion_handoff.py # 有正本则跳过; 没有则生成
  27. python scripts/rudong_fusion_handoff.py --status # 只报现状
  28. python scripts/rudong_fusion_handoff.py --force # 覆盖(自算件之间可覆盖)
  29. """
  30. from __future__ import annotations
  31. import argparse
  32. import json
  33. import pathlib
  34. import sys
  35. import time
  36. import pandas as pd
  37. ROOT = pathlib.Path(__file__).resolve().parents[1]
  38. sys.path.insert(0, str(ROOT))
  39. sys.path.insert(0, str(ROOT / 'scripts'))
  40. from src import paths as P # noqa: E402
  41. HANDOFF = None # 运行时按场站解析 (P.m5())
  42. VERSION = 'guanlan-selfcomputed-v1'
  43. # 状态等级 → verdict 文本(词表必须与 fusion.VERDICT_CLASSES 对得上, 否则整列落成 unknown):
  44. # 危险 → '定论' (bad) · 报警 → '准定论·预警' (warn) · 良好/优秀 → '无新证' (ok) · 不可判 → '测量链不可判' (meta)
  45. VERDICT_OF = {'危险': '定论', '报警': '准定论·预警', '良好': '无新证(本轮未见异常)',
  46. '优秀': '无新证(本轮未见异常)', '不可判': '测量链不可判'}
  47. COMP_OF = {'发电机': '发电机', '齿轮箱': '齿轮箱', '主轴承前': '主轴承', '主轴承后': '主轴承'}
  48. def status(farm: str | None = None) -> int:
  49. p = P.m5(farm) / 'handoff_vibration_v2.json'
  50. if not p.is_file():
  51. print(json.dumps(dict(path=P.rel(p), exists=False, kind=None,
  52. note='不在位 —— /detail/v2 的「需要关注/全场状态」会如实为空; '
  53. '跑本器可从观澜自算产物生成'), ensure_ascii=False))
  54. return 0
  55. try:
  56. h = json.loads(p.read_text(encoding='utf-8'))
  57. except Exception as e:
  58. print(json.dumps(dict(path=P.rel(p), exists=True, kind='unreadable', err=str(e)[:120])))
  59. return 1
  60. meta = h.get('meta') or {}
  61. kind = 'self' if '观澜自算' in str(meta.get('from', '')) else 'primary'
  62. n = sum(len(v or []) for v in (h.get('per_turbine') or {}).values())
  63. print(json.dumps(dict(path=P.rel(p), exists=True, kind=kind, version=meta.get('version'),
  64. date=meta.get('date'), from_=meta.get('from'), per_turbine=n,
  65. note='正本(振动线交证件)' if kind == 'primary' else '观澜自算件(同结构)'),
  66. ensure_ascii=False))
  67. return 0
  68. def _report_levels(farm: str | None) -> tuple:
  69. """逐台逐部件的状态等级/证据状态 + 报告日期 + 数据窗日期 —— 直接取重算出的 CMS 报告(不重复判级)。"""
  70. import re
  71. cms = P.cms(farm)
  72. reps = sorted(cms.glob('报告_CMS振动状态评估报告_*.md'))
  73. if not reps:
  74. return {}, None, None
  75. rp = reps[-1]
  76. md = rp.read_text(encoding='utf-8')
  77. date = rp.stem.rsplit('_', 1)[-1]
  78. # 表头: | 机组号 | 主轴承前 | 主轴承后 | 齿轮箱 | 发电机 | 综合 | 融合级(模型) | CMS 红/黄 | 行动 |
  79. out = {}
  80. if '## 附录 A' in md:
  81. for l in md.split('## 附录 A')[1].splitlines():
  82. if not l.startswith('| WTG'):
  83. continue
  84. c = [x.strip() for x in l.strip('|').split('|')]
  85. if len(c) < 6:
  86. continue
  87. out[c[0]] = dict(主轴承前=c[1], 主轴承后=c[2], 齿轮箱=c[3], 发电机=c[4], 综合=c[5])
  88. win = None
  89. m = re.search(r'数据窗\s+(\S+)\s+(\d{4}-\d{2}-\d{2})\s*→\s*(\d{4}-\d{2}-\d{2})', md)
  90. if m:
  91. win = (m.group(2), m.group(3))
  92. return out, date, win
  93. def build(farm: str | None = None, force: bool = False) -> int:
  94. m5 = P.m5(farm)
  95. dest = m5 / 'handoff_vibration_v2.json'
  96. if dest.is_file() and not force:
  97. try:
  98. cur = json.loads(dest.read_text(encoding='utf-8'))
  99. if '观澜自算' not in str((cur.get('meta') or {}).get('from', '')):
  100. print(f'[跳过] {P.rel(dest)} 是**振动线正本**(非观澜自算) —— 不覆盖。'
  101. f'要强制覆盖用 --force(不建议:正本含人工裁决)')
  102. return 0
  103. except Exception:
  104. pass
  105. levels, rep_date, win = _report_levels(farm)
  106. if not levels:
  107. print('[X] 没有重算出的 CMS 报告(windcms/报告_CMS振动状态评估报告_*.md)—— '
  108. '先跑 scripts/windcms.py report(或在重算链 ④b 里跑)')
  109. return 2
  110. f38 = m5 / 'fusion_38.csv'
  111. fus = pd.read_csv(f38, dtype=str) if f38.is_file() else pd.DataFrame()
  112. ts = pd.Timestamp.now()
  113. w_lo, w_hi = (win or ('', ''))
  114. if not w_lo:
  115. from src.windcms import config as _wc, data as _wd
  116. sp = _wd.window_spans(_wc.farm(str(farm or 'rudong')))
  117. rows = [v for v in sp.values() if v.get('rows')]
  118. if rows:
  119. w_lo, w_hi = str(rows[0]['t_min'])[:10], str(rows[0]['t_max'])[:10]
  120. window_txt = f'{w_lo}~{w_hi}' if w_lo else '未标'
  121. per = {'v1_generator': [], 'v1_gearbox': [], 'new_2026_08_26': []}
  122. by_comp = {'发电机': 'v1_generator', '齿轮箱': 'v1_gearbox', '主轴承': 'new_2026_08_26'}
  123. n_bad = n_warn = 0
  124. for tid, lv in sorted(levels.items()):
  125. fu = fus[fus['台'] == tid].iloc[0].to_dict() if len(fus) and (fus['台'] == tid).any() else {}
  126. for comp_cn, state in (('主轴承前', lv.get('主轴承前')), ('主轴承后', lv.get('主轴承后')),
  127. ('齿轮箱', lv.get('齿轮箱')), ('发电机', lv.get('发电机'))):
  128. comp = COMP_OF.get(comp_cn, comp_cn)
  129. verdict = VERDICT_OF.get(str(state), '测量链不可判')
  130. if not str(state) or str(state) == '优秀':
  131. continue # 优秀不必逐条登记(矩阵按"未列≠正常"渲染, 但全列 38×4 太吵)
  132. if verdict == '定论':
  133. n_bad += 1
  134. elif verdict.startswith('准定论'):
  135. n_warn += 1
  136. per[by_comp.get(comp, 'new_2026_08_26')].append(dict(
  137. id=f'{tid}-{comp_cn}', turbine=tid, component=comp,
  138. title=f'{comp_cn} 状态等级 {state}',
  139. verdict=verdict,
  140. claim_window=f'{window_txt}(观澜自算, 窗内实算区间)',
  141. source='观澜自算: m5_cms_tcm/model_run_l6.parquet + fusion_38.csv + windcms 报告',
  142. fusion_grade=fu.get('融合'), cms=f"{fu.get('CMS', '')}", report_date=rep_date))
  143. from src.windcms.data import span_text as _span # 单一实现: 窗区间口径
  144. from src.windcms import config as _wc2
  145. try:
  146. span = _span(_wc2.farm(str(farm or 'rudong')))
  147. except Exception:
  148. span = window_txt
  149. doc = dict(
  150. meta=dict(version=VERSION, date=ts.strftime('%Y-%m-%d'),
  151. from_='观澜自算 (scripts/rudong_fusion_handoff.py)',
  152. to='windscada 融合面 (/api/fleet 的 fus: 需要关注 / 全场状态 / 振动融合)',
  153. supersedes='(无)', principle='判级全部来自观澜自己的确定性判据, 不含人工裁决; 与振动线正本同结构',
  154. reading_discipline='列内状态=判级转录; 「未列」≠正常; 相对判据必配同域绝对量; '
  155. '缺件时如实为空, 不从交付包补齐(用户令 2026-09-17)',
  156. derived_from=['m5_cms_tcm/model_run_l6.parquet', 'm5_cms_tcm/fusion_38.csv',
  157. 'windcms/报告_CMS振动状态评估报告_*.md', 'm5_cms_tcm/windows/*/index.parquet'],
  158. data_window=span, n_verdicts=sum(len(v) for v in per.values()),
  159. n_bad=n_bad, n_warn=n_warn),
  160. detectability_prior=dict(
  161. purpose='振动面能力边界与盲区(观澜自算口径, 与振动线正本的可能不同)',
  162. classes=dict(
  163. spectral_line=dict(note='特征频率族: 六道前提闸(G1–G8) + 同域绝对锚 + 证据族; '
  164. '不过闸的 x_fleet/选择性/趋势一律不进下游'),
  165. broadband_level=dict(note='宽带层: CMS rms_200/Indicator; 未越限不能作正常依据'),
  166. impact_time_domain=dict(note='冲击轴(时域六窗): 与谱线层互补, 盲区不同'),
  167. iso_velocity=dict(note='ISO 速度当量: 唯一已校准的绝对锚(黄 4.5 / 红 11.0 mm/s)'),
  168. data_quality=dict(note='L0 数据质量: 不过门则该测点任何结论不可采信')),
  169. meas_crossing=dict(note='同一物理量的不同测量(原始谱/包络谱)不可跨域比绝对量'),
  170. **{'known_blind_spots': [
  171. '包络域无 ISO 绝对锚 ⇒ 该域结论封顶「候选」',
  172. '主轴承前后 8–13 kHz 存在同路电气调幅成分, 该带不用于机械源定位',
  173. '无正样本锚(实物/内窥)时不得出「定论」; 本件为**观澜自算**, 不具备跨线裁决权',
  174. '单窗数据时"逐窗趋势"信息量有限(按日聚合后可见)']},
  175. 端到端闭环=dict(note='判级→行动→复测闭环在综合报告线, 本面只出证据与判级')),
  176. per_turbine=per,
  177. field_actions_v1=[],
  178. calibration_updates_v1=[],
  179. open_items=['★本件是**观澜自算**的融合面(非振动线 handoff 正本): 由 scripts/rudong_fusion_handoff.py '
  180. '从重算产物生成; 振动线正本到场时应以正本为准(把正本放回该路径即可, 本器不再覆盖正本)。',
  181. '峰值拾取仍为本器口径(目标频率 ±2 bin 取最大), 见 docs/振动六层链_接口规格与缺口_v0.1.md §3.1',
  182. f'数据窗 {span}'] if True else [])
  183. dest.parent.mkdir(parents=True, exist_ok=True)
  184. dest.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding='utf-8')
  185. print(f'已写 {P.rel(dest)}: 逐台条目 {doc["meta"]["n_verdicts"]} 条 '
  186. f'(危险/报警 {n_bad} · 预警 {n_warn}) · 数据窗 {span}')
  187. try:
  188. from src import derived_manifest as DM
  189. DM.record(P.out_root(farm), {dest.relative_to(P.out_root(farm)).as_posix():
  190. 'scripts/rudong_fusion_handoff.py (观澜自算融合面: L6 过闸线+融合裁决+报告状态等级; 同结构于振动线手交件)'},
  191. by='rudong_fusion_handoff')
  192. print(' 已自登记 → _derived_manifest.json')
  193. except Exception as e:
  194. print(f' [i] 自登记跳过: {type(e).__name__}: {e}')
  195. return 0
  196. def main() -> int:
  197. ap = argparse.ArgumentParser(description='融合面 handoff 的观澜自算生成端(同结构于振动线手交件)')
  198. ap.add_argument('--farm', default=None)
  199. ap.add_argument('--status', action='store_true', help='只报现状(正本/自算件/不在位)')
  200. ap.add_argument('--force', action='store_true', help='覆盖已有自算件(正本仍不覆盖)')
  201. a = ap.parse_args()
  202. if a.status:
  203. return status(a.farm)
  204. return build(a.farm, a.force)
  205. if __name__ == '__main__':
  206. for _s in (sys.stdout, sys.stderr):
  207. try:
  208. _s.reconfigure(errors='replace')
  209. except Exception:
  210. pass
  211. sys.exit(main())