| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- r"""融合面 handoff 的**观澜自算**生成端(用户令 2026-09-19「所有的计算均要形成观澜的源代码」)。
- ## 为什么需要它
- `/detail/v2` 的「需要关注」与「全场状态」两块,取数走 `/api/fleet` 的 `fus`,而 `fus` 的
- 链盘行(需要关注)与逐台矩阵(全场状态)都来自 `src/windscada/subsys/fusion.py` 读的
- `outputs/<场>/m5_cms_tcm/handoff_vibration_v2.json`。那件此前是**振动线人工交证件**:
- 全库 0 处写入方 ⇒ 清过产物、只从 data/raw 重算的机器上必然不在位 ⇒ 两块页面**空白**
- (不是报错,也不是没数据 —— 是那件没有生成端)。
- 用户令把口径定死了:**所有计算都要落到观澜自己的源代码里**,别的电脑装完(放上原始件、重算)
- 系统就要正常跑。所以本器把这份 handoff **按同一份结构自己算出来**:
- 输入(全部是观澜从 data/raw 重算出来的产物)
- · m5_cms_tcm/model_run_l6.parquet 六层链 L6 过闸谱线(scripts/rudong_model_run.py)
- · m5_cms_tcm/fusion_38.csv 逐台融合裁决(scripts/rudong_fusion_run.py)
- · windcms/报告_CMS振动状态评估报告_*.md 逐台逐部件状态等级 + 证据状态(scripts/windcms.py report)
- · m5_cms_tcm/windows/<窗>/index.parquet 窗内真实日期区间(claim_window 用实算值)
- 输出
- · m5_cms_tcm/handoff_vibration_v2.json 与消费者同结构(meta / detectability_prior / per_turbine / open_items)
- ## 与"正本"的关系(必须说清,不许冒充)
- 原 handoff 是**人对人的交证件**(含人工裁决、校准更新、现场动作),本器产出的是**同结构的观澜自算件**:
- · `meta.from` = 观澜自算(不是振动线),`meta.version` = `guanlan-selfcomputed-v1`;
- · 每条 verdict 由观澜自己的判据给出(L6 过闸线 + 融合裁决 + 报告状态等级),**不含**任何人工裁决;
- · `open_items` 首条如实声明"本件非振动线正本,正本到场应以正本为准";
- · 若盘上已有**正本**(`meta.from` 里不含"观澜自算"),本器**不覆盖**(除非显式 `--force`)。
- 用法:
- python scripts/rudong_fusion_handoff.py # 有正本则跳过; 没有则生成
- python scripts/rudong_fusion_handoff.py --status # 只报现状
- python scripts/rudong_fusion_handoff.py --force # 覆盖(自算件之间可覆盖)
- """
- from __future__ import annotations
- import argparse
- import json
- import pathlib
- import sys
- import time
- import pandas as pd
- ROOT = pathlib.Path(__file__).resolve().parents[1]
- sys.path.insert(0, str(ROOT))
- sys.path.insert(0, str(ROOT / 'scripts'))
- from src import paths as P # noqa: E402
- HANDOFF = None # 运行时按场站解析 (P.m5())
- VERSION = 'guanlan-selfcomputed-v1'
- # 状态等级 → verdict 文本(词表必须与 fusion.VERDICT_CLASSES 对得上, 否则整列落成 unknown):
- # 危险 → '定论' (bad) · 报警 → '准定论·预警' (warn) · 良好/优秀 → '无新证' (ok) · 不可判 → '测量链不可判' (meta)
- VERDICT_OF = {'危险': '定论', '报警': '准定论·预警', '良好': '无新证(本轮未见异常)',
- '优秀': '无新证(本轮未见异常)', '不可判': '测量链不可判'}
- COMP_OF = {'发电机': '发电机', '齿轮箱': '齿轮箱', '主轴承前': '主轴承', '主轴承后': '主轴承'}
- def status(farm: str | None = None) -> int:
- p = P.m5(farm) / 'handoff_vibration_v2.json'
- if not p.is_file():
- print(json.dumps(dict(path=P.rel(p), exists=False, kind=None,
- note='不在位 —— /detail/v2 的「需要关注/全场状态」会如实为空; '
- '跑本器可从观澜自算产物生成'), ensure_ascii=False))
- return 0
- try:
- h = json.loads(p.read_text(encoding='utf-8'))
- except Exception as e:
- print(json.dumps(dict(path=P.rel(p), exists=True, kind='unreadable', err=str(e)[:120])))
- return 1
- meta = h.get('meta') or {}
- kind = 'self' if '观澜自算' in str(meta.get('from', '')) else 'primary'
- n = sum(len(v or []) for v in (h.get('per_turbine') or {}).values())
- print(json.dumps(dict(path=P.rel(p), exists=True, kind=kind, version=meta.get('version'),
- date=meta.get('date'), from_=meta.get('from'), per_turbine=n,
- note='正本(振动线交证件)' if kind == 'primary' else '观澜自算件(同结构)'),
- ensure_ascii=False))
- return 0
- def _report_levels(farm: str | None) -> tuple:
- """逐台逐部件的状态等级/证据状态 + 报告日期 + 数据窗日期 —— 直接取重算出的 CMS 报告(不重复判级)。"""
- import re
- cms = P.cms(farm)
- reps = sorted(cms.glob('报告_CMS振动状态评估报告_*.md'))
- if not reps:
- return {}, None, None
- rp = reps[-1]
- md = rp.read_text(encoding='utf-8')
- date = rp.stem.rsplit('_', 1)[-1]
- # 表头: | 机组号 | 主轴承前 | 主轴承后 | 齿轮箱 | 发电机 | 综合 | 融合级(模型) | CMS 红/黄 | 行动 |
- out = {}
- if '## 附录 A' in md:
- for l in md.split('## 附录 A')[1].splitlines():
- if not l.startswith('| WTG'):
- continue
- c = [x.strip() for x in l.strip('|').split('|')]
- if len(c) < 6:
- continue
- out[c[0]] = dict(主轴承前=c[1], 主轴承后=c[2], 齿轮箱=c[3], 发电机=c[4], 综合=c[5])
- win = None
- m = re.search(r'数据窗\s+(\S+)\s+(\d{4}-\d{2}-\d{2})\s*→\s*(\d{4}-\d{2}-\d{2})', md)
- if m:
- win = (m.group(2), m.group(3))
- return out, date, win
- def build(farm: str | None = None, force: bool = False) -> int:
- m5 = P.m5(farm)
- dest = m5 / 'handoff_vibration_v2.json'
- if dest.is_file() and not force:
- try:
- cur = json.loads(dest.read_text(encoding='utf-8'))
- if '观澜自算' not in str((cur.get('meta') or {}).get('from', '')):
- print(f'[跳过] {P.rel(dest)} 是**振动线正本**(非观澜自算) —— 不覆盖。'
- f'要强制覆盖用 --force(不建议:正本含人工裁决)')
- return 0
- except Exception:
- pass
- levels, rep_date, win = _report_levels(farm)
- if not levels:
- print('[X] 没有重算出的 CMS 报告(windcms/报告_CMS振动状态评估报告_*.md)—— '
- '先跑 scripts/windcms.py report(或在重算链 ④b 里跑)')
- return 2
- f38 = m5 / 'fusion_38.csv'
- fus = pd.read_csv(f38, dtype=str) if f38.is_file() else pd.DataFrame()
- ts = pd.Timestamp.now()
- w_lo, w_hi = (win or ('', ''))
- if not w_lo:
- from src.windcms import config as _wc, data as _wd
- sp = _wd.window_spans(_wc.farm(str(farm or 'rudong')))
- rows = [v for v in sp.values() if v.get('rows')]
- if rows:
- w_lo, w_hi = str(rows[0]['t_min'])[:10], str(rows[0]['t_max'])[:10]
- window_txt = f'{w_lo}~{w_hi}' if w_lo else '未标'
- per = {'v1_generator': [], 'v1_gearbox': [], 'new_2026_08_26': []}
- by_comp = {'发电机': 'v1_generator', '齿轮箱': 'v1_gearbox', '主轴承': 'new_2026_08_26'}
- n_bad = n_warn = 0
- for tid, lv in sorted(levels.items()):
- fu = fus[fus['台'] == tid].iloc[0].to_dict() if len(fus) and (fus['台'] == tid).any() else {}
- for comp_cn, state in (('主轴承前', lv.get('主轴承前')), ('主轴承后', lv.get('主轴承后')),
- ('齿轮箱', lv.get('齿轮箱')), ('发电机', lv.get('发电机'))):
- comp = COMP_OF.get(comp_cn, comp_cn)
- verdict = VERDICT_OF.get(str(state), '测量链不可判')
- if not str(state) or str(state) == '优秀':
- continue # 优秀不必逐条登记(矩阵按"未列≠正常"渲染, 但全列 38×4 太吵)
- if verdict == '定论':
- n_bad += 1
- elif verdict.startswith('准定论'):
- n_warn += 1
- per[by_comp.get(comp, 'new_2026_08_26')].append(dict(
- id=f'{tid}-{comp_cn}', turbine=tid, component=comp,
- title=f'{comp_cn} 状态等级 {state}',
- verdict=verdict,
- claim_window=f'{window_txt}(观澜自算, 窗内实算区间)',
- source='观澜自算: m5_cms_tcm/model_run_l6.parquet + fusion_38.csv + windcms 报告',
- fusion_grade=fu.get('融合'), cms=f"{fu.get('CMS', '')}", report_date=rep_date))
- from src.windcms.data import span_text as _span # 单一实现: 窗区间口径
- from src.windcms import config as _wc2
- try:
- span = _span(_wc2.farm(str(farm or 'rudong')))
- except Exception:
- span = window_txt
- doc = dict(
- meta=dict(version=VERSION, date=ts.strftime('%Y-%m-%d'),
- from_='观澜自算 (scripts/rudong_fusion_handoff.py)',
- to='windscada 融合面 (/api/fleet 的 fus: 需要关注 / 全场状态 / 振动融合)',
- supersedes='(无)', principle='判级全部来自观澜自己的确定性判据, 不含人工裁决; 与振动线正本同结构',
- reading_discipline='列内状态=判级转录; 「未列」≠正常; 相对判据必配同域绝对量; '
- '缺件时如实为空, 不从交付包补齐(用户令 2026-09-17)',
- derived_from=['m5_cms_tcm/model_run_l6.parquet', 'm5_cms_tcm/fusion_38.csv',
- 'windcms/报告_CMS振动状态评估报告_*.md', 'm5_cms_tcm/windows/*/index.parquet'],
- data_window=span, n_verdicts=sum(len(v) for v in per.values()),
- n_bad=n_bad, n_warn=n_warn),
- detectability_prior=dict(
- purpose='振动面能力边界与盲区(观澜自算口径, 与振动线正本的可能不同)',
- classes=dict(
- spectral_line=dict(note='特征频率族: 六道前提闸(G1–G8) + 同域绝对锚 + 证据族; '
- '不过闸的 x_fleet/选择性/趋势一律不进下游'),
- broadband_level=dict(note='宽带层: CMS rms_200/Indicator; 未越限不能作正常依据'),
- impact_time_domain=dict(note='冲击轴(时域六窗): 与谱线层互补, 盲区不同'),
- iso_velocity=dict(note='ISO 速度当量: 唯一已校准的绝对锚(黄 4.5 / 红 11.0 mm/s)'),
- data_quality=dict(note='L0 数据质量: 不过门则该测点任何结论不可采信')),
- meas_crossing=dict(note='同一物理量的不同测量(原始谱/包络谱)不可跨域比绝对量'),
- **{'known_blind_spots': [
- '包络域无 ISO 绝对锚 ⇒ 该域结论封顶「候选」',
- '主轴承前后 8–13 kHz 存在同路电气调幅成分, 该带不用于机械源定位',
- '无正样本锚(实物/内窥)时不得出「定论」; 本件为**观澜自算**, 不具备跨线裁决权',
- '单窗数据时"逐窗趋势"信息量有限(按日聚合后可见)']},
- 端到端闭环=dict(note='判级→行动→复测闭环在综合报告线, 本面只出证据与判级')),
- per_turbine=per,
- field_actions_v1=[],
- calibration_updates_v1=[],
- open_items=['★本件是**观澜自算**的融合面(非振动线 handoff 正本): 由 scripts/rudong_fusion_handoff.py '
- '从重算产物生成; 振动线正本到场时应以正本为准(把正本放回该路径即可, 本器不再覆盖正本)。',
- '峰值拾取仍为本器口径(目标频率 ±2 bin 取最大), 见 docs/振动六层链_接口规格与缺口_v0.1.md §3.1',
- f'数据窗 {span}'] if True else [])
- dest.parent.mkdir(parents=True, exist_ok=True)
- dest.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding='utf-8')
- print(f'已写 {P.rel(dest)}: 逐台条目 {doc["meta"]["n_verdicts"]} 条 '
- f'(危险/报警 {n_bad} · 预警 {n_warn}) · 数据窗 {span}')
- try:
- from src import derived_manifest as DM
- DM.record(P.out_root(farm), {dest.relative_to(P.out_root(farm)).as_posix():
- 'scripts/rudong_fusion_handoff.py (观澜自算融合面: L6 过闸线+融合裁决+报告状态等级; 同结构于振动线手交件)'},
- by='rudong_fusion_handoff')
- print(' 已自登记 → _derived_manifest.json')
- except Exception as e:
- print(f' [i] 自登记跳过: {type(e).__name__}: {e}')
- return 0
- def main() -> int:
- ap = argparse.ArgumentParser(description='融合面 handoff 的观澜自算生成端(同结构于振动线手交件)')
- ap.add_argument('--farm', default=None)
- ap.add_argument('--status', action='store_true', help='只报现状(正本/自算件/不在位)')
- ap.add_argument('--force', action='store_true', help='覆盖已有自算件(正本仍不覆盖)')
- a = ap.parse_args()
- if a.status:
- return status(a.farm)
- return build(a.farm, a.force)
- if __name__ == '__main__':
- for _s in (sys.stdout, sys.stderr):
- try:
- _s.reconfigure(errors='replace')
- except Exception:
- pass
- sys.exit(main())
|