| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- r"""三层基线生成端 —— 补上"包内没有生成端"的 `m5_cms_tcm/baseline_38.json` (用户令 2026-09-19)。
- ## 为什么要写它
- 用户令:「**所有的计算 均要形成 观澜的源代码**,确保 观澜 在别的电脑安装后,系统运行正常。」
- `baseline_38.json` 是**随包快照**(族表 kind=shipped、gen=None,见
- `scripts/products_reverse_audit.py::HUMAN_SNAPSHOTS`),旧件的 self/absolute 两层由人工坐实。
- 它是 `src/windcms/report.py::baseline_card_html()` 的**唯一取数处** —— 件不在位, 报告页第 5 张卡
- (三层基线对照)直接返回空串。本脚本把它**从 CMS 窗标量 + 场内实物锚算出来**。
- ## 产物与消费者 (字段契约)
- outputs/<场>/m5_cms_tcm/baseline_38.json
- meta built / min_windows / windows / segments_kind / self_stat / layers / caveat /
- usage_limit / anchor_guard_days / ★数据边界
- absolute_limits iso_rms_vel, Rms_Vel → {unit:'mm/s', yellow:7.1, red:11.0, source:ISO 20816-3 ...}
- ★ 标准常数, 不是测量值, 故写死为模块常量 (改进 ISO 不靠重算)
- fleet_baseline {'<sensor>|<meas>': {段: 全场中位}} ← 页面查 fleet_baseline[f'{sensor}|{meas}'][latest_window]
- self_baseline {台: {测点: {标量: 条目}}}
- 条目字段 (report.py 逐个读): self_baseline, normal_band[2], latest, latest_window, x_self,
- threshold_self, in_normal_band, n_windows_healthy, n_windows_total, unit, insufficient
- 另存 (消费者暂不读, 供人核对): self_baseline_stat, rel_mad, threshold_note, healthy_windows,
- excluded (剔掉的段名) / excluded_detail (剔的理由), recovered_after_anchor, trend_flag, spread, latest_n
- ★ 自基线样本不足 ⇒ `self_baseline: null` + `insufficient: '健康期窗 N < 3, 不给数 (禁用 fleet 值顶替)'`
- —— **绝不**用 fleet 值顶替 (report.py 专门把这条不足列出来, 就是防这个)。
- ## 单位口径 (与随包旧件一致, ★容易踩)
- 页面按 `absolute_limits[meas]['unit'] == 'mm/s'` **乘 1000** 显示。CMS 窗索引 (`y_unit` 列) 实测:
- 页面用的 5 个标量里 `Peak/Rms_HP/rms_200` = `m/s²`、`Rms_Vel/iso_rms_vel` = `m/s` (Kurtosis 页面不展示, y_unit=`1`)
- ⇒ 本器**存原值** (速度存 m/s), 单位标签按 y_unit 分给: `m/s²` / `mm/s`。
- 即 0.00282 m/s 存进文件、页面显示 2.82 mm/s。
- ## 分段 (segment) 口径 —— 与 `scripts/component_history_build.py` **同一套** (两件必须同段)
- len(windows) >= 3 segments_kind='window' 段 = 导出窗 (参考口径: 健康期窗中位, ±锚 120 天护栏)
- len(windows) < 3 segments_kind='week' 段 = 窗内 ISO 周 (本机实况: 只有 w0316 一窗)
- ★ 本机 (2026-09-19 实测) 只有 1 个窗 `w0316` (2026-03-16 17:27 → 2026-04-21 10:18) ⇒ 走周段 (6 段)。
- **周段散布 ≠ 多窗散布**: 周与周之间工况/负荷更接近 ⇒ 散布更小 ⇒ `threshold_self` 更容易被现实超出。
- 这一条写进 `meta.usage_limit`, 不假装等于多窗口径。多窗分支本机**未取数验证**。
- ## 健康期与锚护栏
- 健康期段 = 全部段 **减去** 实物锚 ±`ANCHOR_GUARD_DAYS`(=120) 天覆盖的段。
- 锚来自 `reference/rudong/positive_anchors.json` (场内实物证据: 内窥镜/金相/拆检/闭环), 按
- `component_class` 映射到测点 (`gearbox_hs_bearing`→两个高速轴测点, `generator_bearing`→发电机两测点,
- `main_bearing`→主轴承两测点)。★ 实测有 4 条锚**没有日期** (WTG03/WTG08/WTG28 主轴, WTG05/WTG32 发电机)
- ⇒ 无法做时间护栏, 逐条记进 `meta.锚.无日期` 而不是默认忽略。
- 实测后果: WTG09 (锚 2026-01-23) / WTG27 (2026-01-27) / WTG24 (2025-12-15) 三台的对应测点健康段不足 3
- ⇒ 出 `insufficient`, 不给数; WTG02 (锚 2025-12-04) 只余 3 个健康段 ⇒ 出数但 `excluded` 里写明剔了哪 3 段。
- ## 用法
- python scripts/baseline_38_build.py # 算并写盘 + 自登记
- python scripts/baseline_38_build.py --dry-run # 只算不写, 打印漏斗与不足清单
- python scripts/baseline_38_build.py --farm rudong
- """
- from __future__ import annotations
- import argparse
- import json
- import pathlib
- import sys
- import time
- ROOT = pathlib.Path(__file__).resolve().parents[1]
- sys.path.insert(0, str(ROOT))
- # ★GBK 控制台/日志重定向不再因 '²' '⇒' 这类字符抛 UnicodeEncodeError (2026-09-19 远端实逮:
- # `baseline_38_build.py` 打印 'm/s²' 时在 cp936 下 rc=1, 整条恢复链少一件产物)。
- # 只把**不可编码字符替换掉**, 不改流编码 —— 中文照旧可读。
- try:
- sys.stdout.reconfigure(errors='replace')
- except Exception:
- pass
- import numpy as np # noqa: E402
- import pandas as pd # noqa: E402
- import pyarrow.parquet as pq # noqa: E402
- from src import paths as P # noqa: E402
- from src.derived_manifest import record as dm_record # noqa: E402
- from src.windscada.config import farm as ws_farm # noqa: E402
- from src.windcms import data as CD # noqa: E402
- from src.windcms.config import SENSORS, SENSOR_CN # noqa: E402
- from src.windcms.config import farm as cms_farm # noqa: E402
- # ── 页面要的标量集: 与 src/windcms/report.py::_BL_MEAS **逐字一致** (顺序即报告卡里的行序) ──
- # 有绝对标准的速度量在前 (iso_rms_vel/Rms_Vel), 冲击量次之; Kurtosis/CrestFactor 判别力弱, 页面不展示。
- MEAS_BL = ['iso_rms_vel', 'Rms_Vel', 'Peak', 'Rms_HP', 'rms_200']
- # ── 绝对限值: ISO 20816-3 标准常数 (不是测量值 ⇒ 写死; 改进标准号才需要动这里) ──
- ISO_YELLOW, ISO_RED = 7.1, 11.0
- ISO_SRC = 'ISO 20816-3 (机组 >300kW 刚性安装) 区界 B/C 与 C/D'
- ABSOLUTE_LIMITS = {
- 'iso_rms_vel': dict(unit='mm/s', yellow=ISO_YELLOW, red=ISO_RED, source=ISO_SRC),
- 'Rms_Vel': dict(unit='mm/s', yellow=ISO_YELLOW, red=ISO_RED,
- source='同 iso_rms_vel (同为速度 RMS; 频带口径以厂商配置为准)'),
- }
- # y_unit → 存值单位标签 (见 docstring「单位口径」); 速度类标 mm/s (页面 ×1000 显示), 与旧件同约定
- UNIT_OF_YUNIT = {'m/s': 'mm/s', 'm/s²': 'm/s²', 'm/s2': 'm/s²', '1': '-', '': '-'}
- MIN_WINDOWS = 3 # 健康期段数门 (旧件 min_windows=3)
- ANCHOR_GUARD_DAYS = 120 # 实物锚 ±120 天不进健康期 (旧件 anchor_guard_days=120)
- FLEET_MIN_TURBINES = 5 # fleet 中位至少要有几台贡献 (不足则该段不出 fleet 值)
- RECOVER_FRAC = 0.5 # recovered_after_anchor: 锚后中位 ≤ 锚前中位 × 0.5 判"已恢复"
- CONTAMINATED_FLAG = '自基线可能已污染 (健康期窗单调上升, 基线中心被抬高)' # 旧件同措辞
- THRESHOLD_NOTE = ('max(center×max(1+2.0×relMAD, 1.3), 健康期最大×1.05) — 由该台自身散布定非拍脑袋倍数, '
- '且保证历史正常波动不自触发')
- INS_TMPL = '健康期窗 {n} < 3, 不给数 (禁用 fleet 值顶替)'
- WANT_COLS = ['turbine', 'sensor_name', 'meas_name', 'trigger_time', 'scalar_value', 'ds_size', 'y_unit']
- # 锚的部件类 → 测点 (只映射该部件类**自己的**测点, 不扩大到整机)
- ANCHOR_CLASS_SENSORS = {
- 'gearbox_hs_bearing': ('Gear_HS_rotor_side', 'Gear_HS_generator_side'),
- 'gearbox_planet_bearing': ('Gear_planet',),
- 'gearbox_ims_bearing': ('Gear_IMS',),
- 'generator_bearing': ('Generator_DE', 'Generator_NDE'),
- 'main_bearing': ('Main_bearing_front', 'Main_bearing_rear'),
- }
- def _r(v, nd=6):
- if v is None:
- return None
- v = float(v)
- return float(f'{v:.{nd}g}') if np.isfinite(v) else None
- def _round(v, nd):
- if v is None:
- return None
- v = float(v)
- return round(v, nd) if np.isfinite(v) else None
- def _py(o):
- """numpy 标量 → python; NaN/Inf → None (JSON 严格可解析)。"""
- if isinstance(o, dict):
- return {k: _py(v) for k, v in o.items()}
- if isinstance(o, (list, tuple)):
- return [_py(v) for v in o]
- if isinstance(o, (np.integer,)):
- return int(o)
- if isinstance(o, (np.floating, float)):
- f = float(o)
- return f if np.isfinite(f) else None
- if isinstance(o, (np.bool_,)):
- return bool(o)
- return o
- def load_scalars(cfg, meas):
- """各 CMS 窗的标量子集 (口径同 `src.windcms/data.py`, 但不落缓存); 附带 y_unit 用于定单位标签。"""
- ws = CD.windows(cfg)
- spans = CD.window_spans(cfg)
- parts, used = [], {}
- for w, info in ws.items():
- p = pathlib.Path(info['index'])
- names = set(pq.ParquetFile(p).schema_arrow.names)
- cols = [c for c in WANT_COLS if c in names]
- d = pd.read_parquet(p, columns=cols)
- for c in ('ds_size', 'scalar_value'):
- if c not in d.columns:
- d[c] = np.nan
- if 'y_unit' not in d.columns:
- d['y_unit'] = ''
- d = d[(d.ds_size == 1) & d.meas_name.isin(meas) & d.sensor_name.isin(SENSORS)].copy()
- d['window'] = w
- parts.append(d)
- sp = spans.get(w) or {}
- used[w] = dict(index=P.rel(p), rows_scalar=int(len(d)), rows_all=int(sp.get('rows') or 0),
- span_min=str(sp.get('t_min') or '')[:10], span_max=str(sp.get('t_max') or '')[:10],
- turbines=int(d['turbine'].nunique()))
- if not parts:
- return pd.DataFrame(columns=WANT_COLS + ['window', 't', 'val']), used
- d = pd.concat(parts, ignore_index=True)
- d['t'] = pd.to_datetime(d['trigger_time'], errors='coerce')
- d['val'] = pd.to_numeric(d['scalar_value'], errors='coerce')
- d = d[d['val'].notna() & d['t'].notna()].copy()
- return d, used
- def mark_segments(d, n_win, span_min=None):
- """加 `seg` 列 → (kind, {段: 段日起日}, 段序, {段: 段结束日})。口径见 docstring。"""
- span_min = span_min or {}
- if n_win >= 3:
- d['seg'] = d['window'].astype(str)
- kind = 'window'
- else:
- iso = d['t'].dt.isocalendar()
- d['seg'] = iso['year'].astype(int).astype(str) + '-W' + iso['week'].astype(int).astype(str).str.zfill(2)
- kind = 'week'
- g = d.groupby('seg')['t'].min()
- order = [s for s, _ in sorted(g.items(), key=lambda kv: kv[1])]
- if kind == 'week':
- seg_date = {s: str(pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1).date()) for s in order}
- seg_end = {s: str((pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1) + pd.Timedelta(days=6)).date())
- for s in order}
- else:
- seg_date = {s: str(span_min.get(s) or str(g[s])[:10])[:10] for s in order}
- seg_end = {s: str(d[d.seg == s]['t'].max())[:10] for s in order}
- return kind, seg_date, order, seg_end
- def load_anchors(cfg):
- """场内实物锚 → (逐条锚表, 说明)。缺件/无日期都如实记, 不默认忽略。"""
- p = cfg.get('anchors')
- if not p or not pathlib.Path(p).exists():
- return pd.DataFrame(columns=['turbine', 'date', 'sensors']), dict(path=P.rel(p) if p else None, exists=False,
- note='锚件不在位 ⇒ 无护栏 (健康期=全部段)')
- a = json.loads(pathlib.Path(p).read_text(encoding='utf-8'))
- recs, nodate = [], []
- for x in a.get('anchors', []):
- cls = str(x.get('component_class') or '')
- sens = ANCHOR_CLASS_SENSORS.get(cls)
- if sens is None: # 未知类: 不猜, 记下来
- nodate.append(dict(turbine=x.get('turbine'), component_class=cls, why='部件类未登记 → 未映射测点'))
- continue
- dt_ = x.get('date')
- if not dt_:
- nodate.append(dict(turbine=x.get('turbine'), component_class=cls, why='锚无日期 → 无法做时间护栏'))
- continue
- recs.append(dict(turbine=x.get('turbine'), date=str(dt_)[:10], sensors=list(sens),
- component_class=cls, tier=x.get('tier')))
- info = dict(path=P.rel(p), exists=True, n_anchors=len(a.get('anchors', [])), guard_days=ANCHOR_GUARD_DAYS,
- n_used=len(recs), 无日期=len(nodate), tiers=sorted({str(x.get('tier')) for x in a.get('anchors', [])}),
- 未用锚=nodate)
- return pd.DataFrame(recs), info
- def healthy_segments(order, seg_date, seg_end, anchors, turbine, sensor):
- """该 (台, 测点) 的健康期段 + 被剔段 (锚日 ±guard_days 覆盖到的段)。"""
- ex = []
- ad = anchors[(anchors['turbine'] == turbine) & (anchors['sensors'].map(lambda L: sensor in L))] \
- if len(anchors) else anchors
- for _, a in (ad.iterrows() if len(ad) else []):
- t = pd.Timestamp(a['date'])
- lo = str((t - pd.Timedelta(days=ANCHOR_GUARD_DAYS)).date())
- hi = str((t + pd.Timedelta(days=ANCHOR_GUARD_DAYS)).date())
- for s in order: # 段与 [lo,hi] 有交叠即剔 (段是闭区间)
- if not (seg_end[s] < lo or seg_date[s] > hi):
- ex.append((s, f'{a["date"]}±{ANCHOR_GUARD_DAYS}d({a["component_class"]})'))
- ex_map = dict(ex)
- keep = [s for s in order if s not in ex_map]
- return keep, ex_map
- def build(cfg, farm_name='rudong', dry=False):
- m5 = P.m5(farm_name)
- store = P.store(farm_name)
- t0 = time.time()
- d, used = load_scalars(cfg, MEAS_BL)
- if not len(d):
- print('[X] 一个 CMS 窗索引都没读到 —— 先放数据并跑重算链 (scripts/rebuild_all.py); 本器不写空产物。')
- return None, 4
- kind, seg_date, order, seg_end = mark_segments(d, len(used), {w: v['span_min'] for w, v in used.items()})
- # 单位标签: 按每个 (测点, 标量) 在窗索引里的 y_unit 定 (见 docstring「单位口径」)
- yunit = d.groupby(['sensor_name', 'meas_name'])['y_unit'].agg(lambda s: s.dropna().astype(str).mode().iloc[0]
- if s.notna().any() else '')
- sg = d.groupby(['turbine', 'sensor_name', 'meas_name', 'seg'])['val'].agg(['median', 'size']).reset_index()
- sg = sg.rename(columns={'median': 'med', 'size': 'n'})
- anchors, anchor_info = load_anchors(cfg)
- turbines = [t for t in ws_farm(farm_name).get('turbines', [])] or sorted(d['turbine'].unique())
- # 段跨度用**数据实际覆盖**区间 (周段按日历算出的段末日会到 04-26, 而数据止 04-21 ——
- # 前者只用于"工单落在哪一段"的判定, 报告里说跨度必须说数据真到的日子)。
- span = [min(v['span_min'] for v in used.values()), max(v['span_max'] for v in used.values())]
- # ── fleet: 每个 (测点, 标量) 每段的**全场中位** (先全算一遍; 早期版本按台循环里数段行数,
- # 每段至多 1 行 ⇒ 计数永远 <5 ⇒ fleet 键全空, 页面 ×fleet 恒为 —) ──
- fleet = {}
- for (sen, ms), sub in sg.groupby(['sensor_name', 'meas_name']):
- for s in order:
- v = sub[sub.seg == s]['med'].to_numpy(dtype=float)
- if len(v) >= FLEET_MIN_TURBINES:
- fleet.setdefault(f'{sen}|{ms}', {})[s] = _r(float(np.median(v)))
- print(f'三层基线生成端 · 场={farm_name}')
- print(f' 窗 {len(used)} 个: ' + '; '.join(
- f'{w}({v["span_min"]}→{v["span_max"]}, 全表 {v["rows_all"]}行; 标量 {v["rows_scalar"]}行)'
- for w, v in used.items()))
- print(f' 标量 (ds_size==1 ∧ {len(MEAS_BL)} 标量 ∧ {len(SENSORS)} 测点): {len(d)} 行 · 台 {d.turbine.nunique()}')
- print(f' 分段: segments_kind={kind} · {len(order)} 段 {order[0]}…{order[-1]} ({span[0]}→{span[1]})')
- print(f' 单位 (y_unit→标签): ' + '; '.join(
- f'{s}|{m}:{yunit.get((s, m), "") or "(该测点无此标量)"}→{UNIT_OF_YUNIT.get(str(yunit.get((s, m), "")), "-")}'
- for s, m in [('Main_bearing_front', 'Peak'), ('Main_bearing_front', 'Rms_Vel'),
- ('Main_bearing_front', 'rms_200'), ('Generator_DE', 'iso_rms_vel'),
- ('Gear_planet', 'iso_rms_vel')]))
- print(f' 锚: {anchor_info.get("path")} 共 {anchor_info.get("n_anchors", 0)} 条 · 带日期可用 {anchor_info.get("n_used", 0)} '
- f'· 护栏 ±{ANCHOR_GUARD_DAYS}d · 未用 {anchor_info.get("无日期", 0)} 条')
- for x in anchor_info.get('未用锚', []):
- print(f' [锚未用] {x.get("turbine")} {x.get("component_class")} — {x.get("why")}')
- self_bl, ins_rows = {}, []
- n_entries = n_ok = 0
- for t in turbines:
- self_bl[t] = {}
- for sen in SENSORS:
- self_bl[t][sen] = {}
- for ms in MEAS_BL:
- sub = sg[(sg.turbine == t) & (sg.sensor_name == sen) & (sg.meas_name == ms)]
- if not len(sub):
- continue # 该测点无此标量 (如 齿轮箱无 iso_rms_vel): 整条不出现
- n_entries += 1
- ser = sub.set_index('seg')['med'].reindex(order).dropna()
- nser = sub.set_index('seg')['n'].reindex(order).fillna(0)
- healthy, ex_map = healthy_segments(order, seg_date, seg_end, anchors, t, sen)
- hv = [s for s in healthy if s in ser.index]
- unit = UNIT_OF_YUNIT.get(str(yunit.get((sen, ms), '')), '-')
- base = dict(n_windows_total=int(len(ser)), n_windows_healthy=int(len(hv)),
- excluded=[s for s in order if s in ex_map], # 剔掉的段名 (旧件同字段名)
- excluded_detail=[dict(seg=s, why=ex_map[s]) for s in order if s in ex_map],
- unit=unit, healthy_windows=hv, latest_window=str(ser.index[-1]),
- latest=_r(float(ser.iloc[-1])), latest_n=int(nser.get(ser.index[-1], 0)))
- if len(hv) < MIN_WINDOWS:
- self_bl[t][sen][ms] = _py(dict(base, self_baseline=None, self_baseline_stat=None,
- normal_band=None, rel_mad=None, threshold_self=None,
- threshold_note=None, spread=None, x_self=None,
- in_normal_band=None, recovered_after_anchor=None,
- trend_flag=None,
- insufficient=INS_TMPL.format(n=len(hv))))
- ins_rows.append(dict(turbine=t, sensor=sen, meas=ms, n_healthy=len(hv),
- n_total=int(len(ser)), why='锚护栏后健康段不足 3' if ex_map else '总段数不足 3'))
- continue
- vals = ser.loc[hv].to_numpy(dtype=float)
- center = float(np.median(vals))
- lo, hi = float(vals.min()), float(vals.max())
- mad = float(np.median(np.abs(vals - center)))
- rel_mad = (mad / center) if center else None
- thr = max(center * max(1 + 2.0 * (rel_mad or 0.0), 1.3), hi * 1.05) if center else None
- latest = float(ser.iloc[-1])
- # 锚后是否恢复: 需要该 (台,测点) 有锚且段跨度内锚前/锚后各有段 (本机多窗口径才可能成立)
- rec = None
- ad = anchors[(anchors['turbine'] == t) & (anchors['sensors'].map(lambda L: sen in L))] \
- if len(anchors) else anchors
- for _, a in (ad.iterrows() if len(ad) else []):
- pre = [s for s in ser.index if seg_end[s] < a['date']]
- post = [s for s in ser.index if seg_date[s] > a['date']]
- if pre and post:
- pv, qv = float(np.median(ser.loc[pre])), float(np.median(ser.loc[post]))
- rec = bool(qv <= RECOVER_FRAC * pv) if pv > 0 else None
- break
- trend = CONTAMINATED_FLAG if (len(vals) >= 3 and np.all(np.diff(vals) > 0)) else None
- self_bl[t][sen][ms] = _py(dict(
- base, self_baseline=_r(center), self_baseline_stat=f'中位 of 健康期{"窗" if kind == "window" else "段"}中位',
- normal_band=[_r(lo), _r(hi)], rel_mad=_round(rel_mad, 4), threshold_self=_r(thr),
- threshold_note=THRESHOLD_NOTE, spread=_round(hi / lo, 4) if lo > 0 else None,
- x_self=_round(latest / center, 3) if center else None,
- in_normal_band=bool(lo <= latest <= hi), recovered_after_anchor=rec, trend_flag=trend))
- n_ok += 1
- # (fleet 已在上面按 (测点, 标量, 段) 全场算完 —— 不在这里按台累加, 见那一处注释)
- fleet_bl = {k: dict(sorted(v.items(), key=lambda kv: order.index(kv[0]))) for k, v in sorted(fleet.items())}
- n_fleet_keys = len(fleet_bl)
- missing_fleet = sorted({f'{s}|{m}' for t in self_bl.values() for s, mm in t.items() for m in mm} - set(fleet_bl))
- seg_kind_txt = '导出窗' if kind == 'window' else f'窗内 ISO 周 (本机窗数 {len(used)} < 3 ⇒ 降级; 多窗时自动改回)'
- meta = dict(
- built=time.strftime('%Y-%m-%d'), farm=farm_name, by='scripts/baseline_38_build.py',
- min_windows=MIN_WINDOWS,
- windows={w: v['span_min'] for w, v in used.items()},
- windows_detail={w: dict(rows_all=v['rows_all'], rows_scalar=v['rows_scalar'], span_min=v['span_min'],
- span_max=v['span_max'], turbines=v['turbines']) for w, v in used.items()},
- segments_kind=kind, segments=seg_date, n_segments=len(order),
- anchor_guard_days=ANCHOR_GUARD_DAYS,
- self_stat=(f'中位 of 健康期{"窗" if kind == "window" else "段"}中位 (+正常带[min,max]+relMAD); '
- f'健康期 = 全部段 减去 实物锚 ±{ANCHOR_GUARD_DAYS} 天覆盖的段。'
- f'本机段 = {seg_kind_txt}, 共 {len(order)} 段 ({span[0]}→{span[1]}); '
- f'段中位 = 该段该 (台,测点,标量) 全部标量记录的中位 (ds_size==1, 每时间戳一条)。'),
- layers='self (变了没) / fleet (比别人高没) / abs (严重不严重) — 三者独立, 只报一个会误导',
- caveat=('自基线假设健康期段代表正常; 慢性劣化台该假设失效 → 见 recovered_after_anchor / trend_flag。'
- f'★ 本机用周段 ⇒ 正常带/relMAD 是**周与周之间**的散布 (周间工况更接近, 散布偏小), '
- f'不等于随包旧件那种"跨月导出窗"的散布; 且 `latest` 是最后一个周段的段中位, '
- f'该段样本可能很薄 (逐条给出 latest_n)。'),
- unit_note=('存值单位 = CMS 窗索引原值 (y_unit): Peak/Rms_HP/rms_200 = m/s², Rms_Vel/iso_rms_vel = m/s, '
- 'Kurtosis = 1; 速度类标签写 mm/s 而页面按 absolute_limits.unit=="mm/s" 乘 1000 显示 '
- '—— 与随包旧件同一约定 (0.00282 m/s 存值 ↔ 2.82 mm/s 显示)。'),
- usage_limit=(
- '★ 本基线**不作自动报警闸**: 随包旧件 (多窗口径, 2026-08-26 参数扫描) 记录的 ROC 结论是 '
- '误报率压到 6.3% 时检出率仅 14.3% (6/42 锚单元且集中在 20# 一台), 加持续性要求(连续2窗)检出率归零 '
- '—— 多数锚台只有 1~2 个锚前窗。该回测**未在本机重跑** (本机只有 1 窗, 跑不出多窗 ROC), '
- '此处照录以免被当成"已验证的报警线"。'
- f'★ 本机为周段口径 ⇒ 上列阈值由**周段散布**定, 散布比多窗口径更小, 更容易被现实超出; '
- f'正确用法 = ①量化描述(报 x_self 而非"偏高"这类模糊词) ②人工研判输入 ③趋势跟踪积累段数。'
- '自动报警须等段数够做持续性检验。'),
- fleet_min_turbines=FLEET_MIN_TURBINES,
- inputs=dict(scalars=[v['index'] for v in used.values()], anchors=anchor_info.get('path')),
- 锚=anchor_info,
- 未出fleet键=missing_fleet,
- 自基线不足=ins_rows,
- )
- meta['★数据边界'] = (
- f'① 本机只有 {len(used)} 个 CMS 导出色窗 ('
- + '; '.join(f'{w} {v["span_min"]}→{v["span_max"]}' for w, v in used.items())
- + f') ⇒ 分段降级为窗内 ISO 周 ({len(order)} 段, {span[0]}→{span[1]}); '
- f'多窗口径 (健康期"窗"中位) 不可算, 本件 meta.segments_kind 写明实际用的是哪种。'
- f'② 份数: 自基线条目 {n_entries} 条 (台 × 测点 × 该测点**实际存在**的标量), 出数 {n_ok} 条, '
- f'不给数 {len(ins_rows)} 条 (健康期段 < {MIN_WINDOWS}, 明细见 meta.自基线不足; 一律 null + insufficient, '
- f'**不用 fleet 值顶替**)。★ 页面遍历的 5 个标量里, iso_rms_vel 只有发电机两测点有、rms_200 只有主轴承两测点有 '
- f'(y_unit 实测) ⇒ 其余测点这两条**不存在**, 页面 `if not r: continue` 跳过, 本件在 meta.未覆盖标量 里写明。'
- f'③ 锚护栏: {anchor_info.get("n_used", 0)} 条带日期锚已按 ±{ANCHOR_GUARD_DAYS} 天剔段, '
- f'{anchor_info.get("无日期", 0)} 条锚**无日期/类未登记** ⇒ 未做护栏 (逐条见 meta.锚.未用锚)。'
- f'④ fleet 中位要求该段 ≥{FLEET_MIN_TURBINES} 台贡献, 不足则该段不出值 (页面 ×fleet 显示 —)。'
- f'⑤ 本件全部数字由本脚本从 outputs/<场>/m5_cms_tcm/windows/*/index.parquet (data/raw 重算产物) 与 '
- f'reference/<场>/positive_anchors.json 现算, 不含随包旧件的数值搬运。')
- meta['未覆盖标量'] = {s: [m for m in MEAS_BL if m not in {mm for t in self_bl.values() for mm in t.get(s, {})}]
- for s in SENSORS}
- meta['未覆盖标量'] = {s: v for s, v in meta['未覆盖标量'].items() if v}
- payload = dict(meta=meta, absolute_limits=ABSOLUTE_LIMITS, fleet_baseline=fleet_bl, self_baseline=self_bl)
- txt = json.dumps(payload, ensure_ascii=False, indent=1, allow_nan=False, default=str)
- out = m5 / 'baseline_38.json'
- print(f'\n 条目 {n_entries} 条 · 出数 {n_ok} · 不给数 {len(ins_rows)} ({len({r["turbine"] for r in ins_rows})} 台) '
- f'· fleet 键 {n_fleet_keys} (未出 {len(missing_fleet)})')
- print(f' self_baseline 台 {len([t for t in self_bl if any(self_bl[t][s] for s in self_bl[t])])} 台 × 测点 {len(SENSORS)} '
- f'· 正常带/阈值样例见下')
- for t, sen, ms in (('WTG01', 'Main_bearing_front', 'Peak'), ('WTG01', 'Gear_HS_rotor_side', 'Rms_Vel'),
- ('WTG01', 'Generator_DE', 'iso_rms_vel'), ('WTG02', 'Generator_DE', 'Peak')):
- e = (self_bl.get(t) or {}).get(sen, {}).get(ms)
- if e and e.get('self_baseline'):
- print(f' {t} {SENSOR_CN.get(sen, sen)}|{ms}: 自基线 {e["self_baseline"]} 带 {e["normal_band"]} '
- f'relMAD {e["rel_mad"]} 阈 {e["threshold_self"]} 末段 {e["latest_window"]}(n={e["latest_n"]}) '
- f'×自基线 {e["x_self"]} 带内={e["in_normal_band"]} 健康段 {e["n_windows_healthy"]}/{e["n_windows_total"]}'
- f' 剔 {len(e["excluded"])}')
- elif e:
- print(f' {t} {SENSOR_CN.get(sen, sen)}|{ms}: 不给数 — {e.get("insufficient")} '
- f'(健康段 {e["n_windows_healthy"]}/{e["n_windows_total"]}, 剔 {len(e["excluded"])})')
- if ins_rows:
- print(' 不给数明细: ' + '; '.join(f'{r["turbine"]} {SENSOR_CN.get(r["sensor"], r["sensor"])}|{r["meas"]} '
- f'{r["n_healthy"]}/{r["n_total"]} ({r["why"]})' for r in ins_rows[:8])
- + (' …' if len(ins_rows) > 8 else ''))
- print(f' 产物体积预估 {len(txt.encode("utf-8")) / 1024:.0f} KB ({P.rel(out)}) · 用时 {time.time() - t0:.1f}s')
- if dry:
- print('\n[dry-run] 不写盘、不登记。')
- return payload, 0
- m5.mkdir(parents=True, exist_ok=True)
- out.write_text(txt, encoding='utf-8')
- # ★自登记台账的落点与键 (2026-09-19 实逮): 正本是 `P.out_root(farm)/_derived_manifest.json`,
- # 键必须**相对产物仓根**(其它生成端同一口径)。用 P.store()/P.rel() 会另建一本
- # `windscada/_derived_manifest.json` 且键带 `outputs/rudong/` 前缀 ⇒ 台账读不到、审计报"未归类"。
- dm_record(P.out_root(farm_name), {out.relative_to(P.out_root(farm_name)).as_posix():'scripts/baseline_38_build.py (CMS 窗标量 → 自基线/fleet/ISO 绝对三层; '
- f'段口径 segments_kind={kind}, 实物锚 ±{ANCHOR_GUARD_DAYS}d 护栏, 不足不给数)'},
- by='scripts/baseline_38_build.py')
- print(f'\n[OK] 已写 {P.rel(out)} ({out.stat().st_size / 1024:.0f} KB) · 已自登记')
- return payload, 0
- def main() -> int:
- ap = argparse.ArgumentParser(description='三层基线生成端 (m5_cms_tcm/baseline_38.json)')
- ap.add_argument('--farm', default='rudong')
- ap.add_argument('--dry-run', action='store_true', help='只算不写盘 (打印漏斗与不给数清单)')
- args = ap.parse_args()
- cfg = cms_farm(args.farm)
- _, rc = build(cfg, args.farm, dry=args.dry_run)
- return rc
- if __name__ == '__main__':
- sys.exit(main())
|