baseline_38_build.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""三层基线生成端 —— 补上"包内没有生成端"的 `m5_cms_tcm/baseline_38.json` (用户令 2026-09-19)。
  4. ## 为什么要写它
  5. 用户令:「**所有的计算 均要形成 观澜的源代码**,确保 观澜 在别的电脑安装后,系统运行正常。」
  6. `baseline_38.json` 是**随包快照**(族表 kind=shipped、gen=None,见
  7. `scripts/products_reverse_audit.py::HUMAN_SNAPSHOTS`),旧件的 self/absolute 两层由人工坐实。
  8. 它是 `src/windcms/report.py::baseline_card_html()` 的**唯一取数处** —— 件不在位, 报告页第 5 张卡
  9. (三层基线对照)直接返回空串。本脚本把它**从 CMS 窗标量 + 场内实物锚算出来**。
  10. ## 产物与消费者 (字段契约)
  11. outputs/<场>/m5_cms_tcm/baseline_38.json
  12. meta built / min_windows / windows / segments_kind / self_stat / layers / caveat /
  13. usage_limit / anchor_guard_days / ★数据边界
  14. absolute_limits iso_rms_vel, Rms_Vel → {unit:'mm/s', yellow:7.1, red:11.0, source:ISO 20816-3 ...}
  15. ★ 标准常数, 不是测量值, 故写死为模块常量 (改进 ISO 不靠重算)
  16. fleet_baseline {'<sensor>|<meas>': {段: 全场中位}} ← 页面查 fleet_baseline[f'{sensor}|{meas}'][latest_window]
  17. self_baseline {台: {测点: {标量: 条目}}}
  18. 条目字段 (report.py 逐个读): self_baseline, normal_band[2], latest, latest_window, x_self,
  19. threshold_self, in_normal_band, n_windows_healthy, n_windows_total, unit, insufficient
  20. 另存 (消费者暂不读, 供人核对): self_baseline_stat, rel_mad, threshold_note, healthy_windows,
  21. excluded (剔掉的段名) / excluded_detail (剔的理由), recovered_after_anchor, trend_flag, spread, latest_n
  22. ★ 自基线样本不足 ⇒ `self_baseline: null` + `insufficient: '健康期窗 N < 3, 不给数 (禁用 fleet 值顶替)'`
  23. —— **绝不**用 fleet 值顶替 (report.py 专门把这条不足列出来, 就是防这个)。
  24. ## 单位口径 (与随包旧件一致, ★容易踩)
  25. 页面按 `absolute_limits[meas]['unit'] == 'mm/s'` **乘 1000** 显示。CMS 窗索引 (`y_unit` 列) 实测:
  26. 页面用的 5 个标量里 `Peak/Rms_HP/rms_200` = `m/s²`、`Rms_Vel/iso_rms_vel` = `m/s` (Kurtosis 页面不展示, y_unit=`1`)
  27. ⇒ 本器**存原值** (速度存 m/s), 单位标签按 y_unit 分给: `m/s²` / `mm/s`。
  28. 即 0.00282 m/s 存进文件、页面显示 2.82 mm/s。
  29. ## 分段 (segment) 口径 —— 与 `scripts/component_history_build.py` **同一套** (两件必须同段)
  30. len(windows) >= 3 segments_kind='window' 段 = 导出窗 (参考口径: 健康期窗中位, ±锚 120 天护栏)
  31. len(windows) < 3 segments_kind='week' 段 = 窗内 ISO 周 (本机实况: 只有 w0316 一窗)
  32. ★ 本机 (2026-09-19 实测) 只有 1 个窗 `w0316` (2026-03-16 17:27 → 2026-04-21 10:18) ⇒ 走周段 (6 段)。
  33. **周段散布 ≠ 多窗散布**: 周与周之间工况/负荷更接近 ⇒ 散布更小 ⇒ `threshold_self` 更容易被现实超出。
  34. 这一条写进 `meta.usage_limit`, 不假装等于多窗口径。多窗分支本机**未取数验证**。
  35. ## 健康期与锚护栏
  36. 健康期段 = 全部段 **减去** 实物锚 ±`ANCHOR_GUARD_DAYS`(=120) 天覆盖的段。
  37. 锚来自 `reference/rudong/positive_anchors.json` (场内实物证据: 内窥镜/金相/拆检/闭环), 按
  38. `component_class` 映射到测点 (`gearbox_hs_bearing`→两个高速轴测点, `generator_bearing`→发电机两测点,
  39. `main_bearing`→主轴承两测点)。★ 实测有 4 条锚**没有日期** (WTG03/WTG08/WTG28 主轴, WTG05/WTG32 发电机)
  40. ⇒ 无法做时间护栏, 逐条记进 `meta.锚.无日期` 而不是默认忽略。
  41. 实测后果: WTG09 (锚 2026-01-23) / WTG27 (2026-01-27) / WTG24 (2025-12-15) 三台的对应测点健康段不足 3
  42. ⇒ 出 `insufficient`, 不给数; WTG02 (锚 2025-12-04) 只余 3 个健康段 ⇒ 出数但 `excluded` 里写明剔了哪 3 段。
  43. ## 用法
  44. python scripts/baseline_38_build.py # 算并写盘 + 自登记
  45. python scripts/baseline_38_build.py --dry-run # 只算不写, 打印漏斗与不足清单
  46. python scripts/baseline_38_build.py --farm rudong
  47. """
  48. from __future__ import annotations
  49. try:
  50. from app_common.app_common_guanlan.api import install_root as _install_root
  51. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  52. from pathlib import Path as _P
  53. def _install_root(_f): return _P(_f).resolve().parents[3]
  54. import argparse
  55. import json
  56. import pathlib
  57. import sys
  58. import time
  59. ROOT = _install_root(__file__) # 模块化后按标记找安装根(原 parents[1] 已不成立)
  60. sys.path.insert(0, str(ROOT))
  61. # ★GBK 控制台/日志重定向不再因 '²' '⇒' 这类字符抛 UnicodeEncodeError (2026-09-19 远端实逮:
  62. # `baseline_38_build.py` 打印 'm/s²' 时在 cp936 下 rc=1, 整条恢复链少一件产物)。
  63. # 只把**不可编码字符替换掉**, 不改流编码 —— 中文照旧可读。
  64. try:
  65. sys.stdout.reconfigure(errors='replace')
  66. except Exception:
  67. pass
  68. import numpy as np # noqa: E402
  69. import pandas as pd # noqa: E402
  70. import pyarrow.parquet as pq # noqa: E402
  71. from src import paths as P # noqa: E402
  72. from src.derived_manifest import record as dm_record # noqa: E402
  73. from src.windscada.config import farm as ws_farm # noqa: E402
  74. from src.windcms import data as CD # noqa: E402
  75. from src.windcms.config import SENSORS, SENSOR_CN # noqa: E402
  76. from src.windcms.config import farm as cms_farm # noqa: E402
  77. # ── 页面要的标量集: 与 src/windcms/report.py::_BL_MEAS **逐字一致** (顺序即报告卡里的行序) ──
  78. # 有绝对标准的速度量在前 (iso_rms_vel/Rms_Vel), 冲击量次之; Kurtosis/CrestFactor 判别力弱, 页面不展示。
  79. MEAS_BL = ['iso_rms_vel', 'Rms_Vel', 'Peak', 'Rms_HP', 'rms_200']
  80. # ── 绝对限值: ISO 20816-3 标准常数 (不是测量值 ⇒ 写死; 改进标准号才需要动这里) ──
  81. ISO_YELLOW, ISO_RED = 7.1, 11.0
  82. ISO_SRC = 'ISO 20816-3 (机组 >300kW 刚性安装) 区界 B/C 与 C/D'
  83. ABSOLUTE_LIMITS = {
  84. 'iso_rms_vel': dict(unit='mm/s', yellow=ISO_YELLOW, red=ISO_RED, source=ISO_SRC),
  85. 'Rms_Vel': dict(unit='mm/s', yellow=ISO_YELLOW, red=ISO_RED,
  86. source='同 iso_rms_vel (同为速度 RMS; 频带口径以厂商配置为准)'),
  87. }
  88. # y_unit → 存值单位标签 (见 docstring「单位口径」); 速度类标 mm/s (页面 ×1000 显示), 与旧件同约定
  89. UNIT_OF_YUNIT = {'m/s': 'mm/s', 'm/s²': 'm/s²', 'm/s2': 'm/s²', '1': '-', '': '-'}
  90. MIN_WINDOWS = 3 # 健康期段数门 (旧件 min_windows=3)
  91. ANCHOR_GUARD_DAYS = 120 # 实物锚 ±120 天不进健康期 (旧件 anchor_guard_days=120)
  92. FLEET_MIN_TURBINES = 5 # fleet 中位至少要有几台贡献 (不足则该段不出 fleet 值)
  93. RECOVER_FRAC = 0.5 # recovered_after_anchor: 锚后中位 ≤ 锚前中位 × 0.5 判"已恢复"
  94. CONTAMINATED_FLAG = '自基线可能已污染 (健康期窗单调上升, 基线中心被抬高)' # 旧件同措辞
  95. THRESHOLD_NOTE = ('max(center×max(1+2.0×relMAD, 1.3), 健康期最大×1.05) — 由该台自身散布定非拍脑袋倍数, '
  96. '且保证历史正常波动不自触发')
  97. INS_TMPL = '健康期窗 {n} < 3, 不给数 (禁用 fleet 值顶替)'
  98. WANT_COLS = ['turbine', 'sensor_name', 'meas_name', 'trigger_time', 'scalar_value', 'ds_size', 'y_unit']
  99. # 锚的部件类 → 测点 (只映射该部件类**自己的**测点, 不扩大到整机)
  100. ANCHOR_CLASS_SENSORS = {
  101. 'gearbox_hs_bearing': ('Gear_HS_rotor_side', 'Gear_HS_generator_side'),
  102. 'gearbox_planet_bearing': ('Gear_planet',),
  103. 'gearbox_ims_bearing': ('Gear_IMS',),
  104. 'generator_bearing': ('Generator_DE', 'Generator_NDE'),
  105. 'main_bearing': ('Main_bearing_front', 'Main_bearing_rear'),
  106. }
  107. def _r(v, nd=6):
  108. if v is None:
  109. return None
  110. v = float(v)
  111. return float(f'{v:.{nd}g}') if np.isfinite(v) else None
  112. def _round(v, nd):
  113. if v is None:
  114. return None
  115. v = float(v)
  116. return round(v, nd) if np.isfinite(v) else None
  117. def _py(o):
  118. """numpy 标量 → python; NaN/Inf → None (JSON 严格可解析)。"""
  119. if isinstance(o, dict):
  120. return {k: _py(v) for k, v in o.items()}
  121. if isinstance(o, (list, tuple)):
  122. return [_py(v) for v in o]
  123. if isinstance(o, (np.integer,)):
  124. return int(o)
  125. if isinstance(o, (np.floating, float)):
  126. f = float(o)
  127. return f if np.isfinite(f) else None
  128. if isinstance(o, (np.bool_,)):
  129. return bool(o)
  130. return o
  131. def load_scalars(cfg, meas):
  132. """各 CMS 窗的标量子集 (口径同 `src.windcms/data.py`, 但不落缓存); 附带 y_unit 用于定单位标签。"""
  133. ws = CD.windows(cfg)
  134. spans = CD.window_spans(cfg)
  135. parts, used = [], {}
  136. for w, info in ws.items():
  137. p = pathlib.Path(info['index'])
  138. names = set(pq.ParquetFile(p).schema_arrow.names)
  139. cols = [c for c in WANT_COLS if c in names]
  140. d = pd.read_parquet(p, columns=cols)
  141. for c in ('ds_size', 'scalar_value'):
  142. if c not in d.columns:
  143. d[c] = np.nan
  144. if 'y_unit' not in d.columns:
  145. d['y_unit'] = ''
  146. d = d[(d.ds_size == 1) & d.meas_name.isin(meas) & d.sensor_name.isin(SENSORS)].copy()
  147. d['window'] = w
  148. parts.append(d)
  149. sp = spans.get(w) or {}
  150. used[w] = dict(index=P.rel(p), rows_scalar=int(len(d)), rows_all=int(sp.get('rows') or 0),
  151. span_min=str(sp.get('t_min') or '')[:10], span_max=str(sp.get('t_max') or '')[:10],
  152. turbines=int(d['turbine'].nunique()))
  153. if not parts:
  154. return pd.DataFrame(columns=WANT_COLS + ['window', 't', 'val']), used
  155. d = pd.concat(parts, ignore_index=True)
  156. d['t'] = pd.to_datetime(d['trigger_time'], errors='coerce')
  157. d['val'] = pd.to_numeric(d['scalar_value'], errors='coerce')
  158. d = d[d['val'].notna() & d['t'].notna()].copy()
  159. return d, used
  160. def mark_segments(d, n_win, span_min=None):
  161. """加 `seg` 列 → (kind, {段: 段日起日}, 段序, {段: 段结束日})。口径见 docstring。"""
  162. span_min = span_min or {}
  163. if n_win >= 3:
  164. d['seg'] = d['window'].astype(str)
  165. kind = 'window'
  166. else:
  167. iso = d['t'].dt.isocalendar()
  168. d['seg'] = iso['year'].astype(int).astype(str) + '-W' + iso['week'].astype(int).astype(str).str.zfill(2)
  169. kind = 'week'
  170. g = d.groupby('seg')['t'].min()
  171. order = [s for s, _ in sorted(g.items(), key=lambda kv: kv[1])]
  172. if kind == 'week':
  173. seg_date = {s: str(pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1).date()) for s in order}
  174. seg_end = {s: str((pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1) + pd.Timedelta(days=6)).date())
  175. for s in order}
  176. else:
  177. seg_date = {s: str(span_min.get(s) or str(g[s])[:10])[:10] for s in order}
  178. seg_end = {s: str(d[d.seg == s]['t'].max())[:10] for s in order}
  179. return kind, seg_date, order, seg_end
  180. def load_anchors(cfg):
  181. """场内实物锚 → (逐条锚表, 说明)。缺件/无日期都如实记, 不默认忽略。"""
  182. p = cfg.get('anchors')
  183. if not p or not pathlib.Path(p).exists():
  184. return pd.DataFrame(columns=['turbine', 'date', 'sensors']), dict(path=P.rel(p) if p else None, exists=False,
  185. note='锚件不在位 ⇒ 无护栏 (健康期=全部段)')
  186. a = json.loads(pathlib.Path(p).read_text(encoding='utf-8'))
  187. recs, nodate = [], []
  188. for x in a.get('anchors', []):
  189. cls = str(x.get('component_class') or '')
  190. sens = ANCHOR_CLASS_SENSORS.get(cls)
  191. if sens is None: # 未知类: 不猜, 记下来
  192. nodate.append(dict(turbine=x.get('turbine'), component_class=cls, why='部件类未登记 → 未映射测点'))
  193. continue
  194. dt_ = x.get('date')
  195. if not dt_:
  196. nodate.append(dict(turbine=x.get('turbine'), component_class=cls, why='锚无日期 → 无法做时间护栏'))
  197. continue
  198. recs.append(dict(turbine=x.get('turbine'), date=str(dt_)[:10], sensors=list(sens),
  199. component_class=cls, tier=x.get('tier')))
  200. info = dict(path=P.rel(p), exists=True, n_anchors=len(a.get('anchors', [])), guard_days=ANCHOR_GUARD_DAYS,
  201. n_used=len(recs), 无日期=len(nodate), tiers=sorted({str(x.get('tier')) for x in a.get('anchors', [])}),
  202. 未用锚=nodate)
  203. return pd.DataFrame(recs), info
  204. def healthy_segments(order, seg_date, seg_end, anchors, turbine, sensor):
  205. """该 (台, 测点) 的健康期段 + 被剔段 (锚日 ±guard_days 覆盖到的段)。"""
  206. ex = []
  207. ad = anchors[(anchors['turbine'] == turbine) & (anchors['sensors'].map(lambda L: sensor in L))] \
  208. if len(anchors) else anchors
  209. for _, a in (ad.iterrows() if len(ad) else []):
  210. t = pd.Timestamp(a['date'])
  211. lo = str((t - pd.Timedelta(days=ANCHOR_GUARD_DAYS)).date())
  212. hi = str((t + pd.Timedelta(days=ANCHOR_GUARD_DAYS)).date())
  213. for s in order: # 段与 [lo,hi] 有交叠即剔 (段是闭区间)
  214. if not (seg_end[s] < lo or seg_date[s] > hi):
  215. ex.append((s, f'{a["date"]}±{ANCHOR_GUARD_DAYS}d({a["component_class"]})'))
  216. ex_map = dict(ex)
  217. keep = [s for s in order if s not in ex_map]
  218. return keep, ex_map
  219. def build(cfg, farm_name='rudong', dry=False):
  220. m5 = P.m5(farm_name)
  221. store = P.store(farm_name)
  222. t0 = time.time()
  223. d, used = load_scalars(cfg, MEAS_BL)
  224. if not len(d):
  225. print('[X] 一个 CMS 窗索引都没读到 —— 先放数据并跑重算链 (scripts/rebuild_all.py); 本器不写空产物。')
  226. return None, 4
  227. kind, seg_date, order, seg_end = mark_segments(d, len(used), {w: v['span_min'] for w, v in used.items()})
  228. # 单位标签: 按每个 (测点, 标量) 在窗索引里的 y_unit 定 (见 docstring「单位口径」)
  229. yunit = d.groupby(['sensor_name', 'meas_name'])['y_unit'].agg(lambda s: s.dropna().astype(str).mode().iloc[0]
  230. if s.notna().any() else '')
  231. sg = d.groupby(['turbine', 'sensor_name', 'meas_name', 'seg'])['val'].agg(['median', 'size']).reset_index()
  232. sg = sg.rename(columns={'median': 'med', 'size': 'n'})
  233. anchors, anchor_info = load_anchors(cfg)
  234. turbines = [t for t in ws_farm(farm_name).get('turbines', [])] or sorted(d['turbine'].unique())
  235. # 段跨度用**数据实际覆盖**区间 (周段按日历算出的段末日会到 04-26, 而数据止 04-21 ——
  236. # 前者只用于"工单落在哪一段"的判定, 报告里说跨度必须说数据真到的日子)。
  237. span = [min(v['span_min'] for v in used.values()), max(v['span_max'] for v in used.values())]
  238. # ── fleet: 每个 (测点, 标量) 每段的**全场中位** (先全算一遍; 早期版本按台循环里数段行数,
  239. # 每段至多 1 行 ⇒ 计数永远 <5 ⇒ fleet 键全空, 页面 ×fleet 恒为 —) ──
  240. fleet = {}
  241. for (sen, ms), sub in sg.groupby(['sensor_name', 'meas_name']):
  242. for s in order:
  243. v = sub[sub.seg == s]['med'].to_numpy(dtype=float)
  244. if len(v) >= FLEET_MIN_TURBINES:
  245. fleet.setdefault(f'{sen}|{ms}', {})[s] = _r(float(np.median(v)))
  246. print(f'三层基线生成端 · 场={farm_name}')
  247. print(f' 窗 {len(used)} 个: ' + '; '.join(
  248. f'{w}({v["span_min"]}→{v["span_max"]}, 全表 {v["rows_all"]}行; 标量 {v["rows_scalar"]}行)'
  249. for w, v in used.items()))
  250. print(f' 标量 (ds_size==1 ∧ {len(MEAS_BL)} 标量 ∧ {len(SENSORS)} 测点): {len(d)} 行 · 台 {d.turbine.nunique()}')
  251. print(f' 分段: segments_kind={kind} · {len(order)} 段 {order[0]}…{order[-1]} ({span[0]}→{span[1]})')
  252. print(f' 单位 (y_unit→标签): ' + '; '.join(
  253. f'{s}|{m}:{yunit.get((s, m), "") or "(该测点无此标量)"}→{UNIT_OF_YUNIT.get(str(yunit.get((s, m), "")), "-")}'
  254. for s, m in [('Main_bearing_front', 'Peak'), ('Main_bearing_front', 'Rms_Vel'),
  255. ('Main_bearing_front', 'rms_200'), ('Generator_DE', 'iso_rms_vel'),
  256. ('Gear_planet', 'iso_rms_vel')]))
  257. print(f' 锚: {anchor_info.get("path")} 共 {anchor_info.get("n_anchors", 0)} 条 · 带日期可用 {anchor_info.get("n_used", 0)} '
  258. f'· 护栏 ±{ANCHOR_GUARD_DAYS}d · 未用 {anchor_info.get("无日期", 0)} 条')
  259. for x in anchor_info.get('未用锚', []):
  260. print(f' [锚未用] {x.get("turbine")} {x.get("component_class")} — {x.get("why")}')
  261. self_bl, ins_rows = {}, []
  262. n_entries = n_ok = 0
  263. for t in turbines:
  264. self_bl[t] = {}
  265. for sen in SENSORS:
  266. self_bl[t][sen] = {}
  267. for ms in MEAS_BL:
  268. sub = sg[(sg.turbine == t) & (sg.sensor_name == sen) & (sg.meas_name == ms)]
  269. if not len(sub):
  270. continue # 该测点无此标量 (如 齿轮箱无 iso_rms_vel): 整条不出现
  271. n_entries += 1
  272. ser = sub.set_index('seg')['med'].reindex(order).dropna()
  273. nser = sub.set_index('seg')['n'].reindex(order).fillna(0)
  274. healthy, ex_map = healthy_segments(order, seg_date, seg_end, anchors, t, sen)
  275. hv = [s for s in healthy if s in ser.index]
  276. unit = UNIT_OF_YUNIT.get(str(yunit.get((sen, ms), '')), '-')
  277. base = dict(n_windows_total=int(len(ser)), n_windows_healthy=int(len(hv)),
  278. excluded=[s for s in order if s in ex_map], # 剔掉的段名 (旧件同字段名)
  279. excluded_detail=[dict(seg=s, why=ex_map[s]) for s in order if s in ex_map],
  280. unit=unit, healthy_windows=hv, latest_window=str(ser.index[-1]),
  281. latest=_r(float(ser.iloc[-1])), latest_n=int(nser.get(ser.index[-1], 0)))
  282. if len(hv) < MIN_WINDOWS:
  283. self_bl[t][sen][ms] = _py(dict(base, self_baseline=None, self_baseline_stat=None,
  284. normal_band=None, rel_mad=None, threshold_self=None,
  285. threshold_note=None, spread=None, x_self=None,
  286. in_normal_band=None, recovered_after_anchor=None,
  287. trend_flag=None,
  288. insufficient=INS_TMPL.format(n=len(hv))))
  289. ins_rows.append(dict(turbine=t, sensor=sen, meas=ms, n_healthy=len(hv),
  290. n_total=int(len(ser)), why='锚护栏后健康段不足 3' if ex_map else '总段数不足 3'))
  291. continue
  292. vals = ser.loc[hv].to_numpy(dtype=float)
  293. center = float(np.median(vals))
  294. lo, hi = float(vals.min()), float(vals.max())
  295. mad = float(np.median(np.abs(vals - center)))
  296. rel_mad = (mad / center) if center else None
  297. thr = max(center * max(1 + 2.0 * (rel_mad or 0.0), 1.3), hi * 1.05) if center else None
  298. latest = float(ser.iloc[-1])
  299. # 锚后是否恢复: 需要该 (台,测点) 有锚且段跨度内锚前/锚后各有段 (本机多窗口径才可能成立)
  300. rec = None
  301. ad = anchors[(anchors['turbine'] == t) & (anchors['sensors'].map(lambda L: sen in L))] \
  302. if len(anchors) else anchors
  303. for _, a in (ad.iterrows() if len(ad) else []):
  304. pre = [s for s in ser.index if seg_end[s] < a['date']]
  305. post = [s for s in ser.index if seg_date[s] > a['date']]
  306. if pre and post:
  307. pv, qv = float(np.median(ser.loc[pre])), float(np.median(ser.loc[post]))
  308. rec = bool(qv <= RECOVER_FRAC * pv) if pv > 0 else None
  309. break
  310. trend = CONTAMINATED_FLAG if (len(vals) >= 3 and np.all(np.diff(vals) > 0)) else None
  311. self_bl[t][sen][ms] = _py(dict(
  312. base, self_baseline=_r(center), self_baseline_stat=f'中位 of 健康期{"窗" if kind == "window" else "段"}中位',
  313. normal_band=[_r(lo), _r(hi)], rel_mad=_round(rel_mad, 4), threshold_self=_r(thr),
  314. threshold_note=THRESHOLD_NOTE, spread=_round(hi / lo, 4) if lo > 0 else None,
  315. x_self=_round(latest / center, 3) if center else None,
  316. in_normal_band=bool(lo <= latest <= hi), recovered_after_anchor=rec, trend_flag=trend))
  317. n_ok += 1
  318. # (fleet 已在上面按 (测点, 标量, 段) 全场算完 —— 不在这里按台累加, 见那一处注释)
  319. fleet_bl = {k: dict(sorted(v.items(), key=lambda kv: order.index(kv[0]))) for k, v in sorted(fleet.items())}
  320. n_fleet_keys = len(fleet_bl)
  321. 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))
  322. seg_kind_txt = '导出窗' if kind == 'window' else f'窗内 ISO 周 (本机窗数 {len(used)} < 3 ⇒ 降级; 多窗时自动改回)'
  323. meta = dict(
  324. built=time.strftime('%Y-%m-%d'), farm=farm_name, by='scripts/baseline_38_build.py',
  325. min_windows=MIN_WINDOWS,
  326. windows={w: v['span_min'] for w, v in used.items()},
  327. windows_detail={w: dict(rows_all=v['rows_all'], rows_scalar=v['rows_scalar'], span_min=v['span_min'],
  328. span_max=v['span_max'], turbines=v['turbines']) for w, v in used.items()},
  329. segments_kind=kind, segments=seg_date, n_segments=len(order),
  330. anchor_guard_days=ANCHOR_GUARD_DAYS,
  331. self_stat=(f'中位 of 健康期{"窗" if kind == "window" else "段"}中位 (+正常带[min,max]+relMAD); '
  332. f'健康期 = 全部段 减去 实物锚 ±{ANCHOR_GUARD_DAYS} 天覆盖的段。'
  333. f'本机段 = {seg_kind_txt}, 共 {len(order)} 段 ({span[0]}→{span[1]}); '
  334. f'段中位 = 该段该 (台,测点,标量) 全部标量记录的中位 (ds_size==1, 每时间戳一条)。'),
  335. layers='self (变了没) / fleet (比别人高没) / abs (严重不严重) — 三者独立, 只报一个会误导',
  336. caveat=('自基线假设健康期段代表正常; 慢性劣化台该假设失效 → 见 recovered_after_anchor / trend_flag。'
  337. f'★ 本机用周段 ⇒ 正常带/relMAD 是**周与周之间**的散布 (周间工况更接近, 散布偏小), '
  338. f'不等于随包旧件那种"跨月导出窗"的散布; 且 `latest` 是最后一个周段的段中位, '
  339. f'该段样本可能很薄 (逐条给出 latest_n)。'),
  340. unit_note=('存值单位 = CMS 窗索引原值 (y_unit): Peak/Rms_HP/rms_200 = m/s², Rms_Vel/iso_rms_vel = m/s, '
  341. 'Kurtosis = 1; 速度类标签写 mm/s 而页面按 absolute_limits.unit=="mm/s" 乘 1000 显示 '
  342. '—— 与随包旧件同一约定 (0.00282 m/s 存值 ↔ 2.82 mm/s 显示)。'),
  343. usage_limit=(
  344. '★ 本基线**不作自动报警闸**: 随包旧件 (多窗口径, 2026-08-26 参数扫描) 记录的 ROC 结论是 '
  345. '误报率压到 6.3% 时检出率仅 14.3% (6/42 锚单元且集中在 20# 一台), 加持续性要求(连续2窗)检出率归零 '
  346. '—— 多数锚台只有 1~2 个锚前窗。该回测**未在本机重跑** (本机只有 1 窗, 跑不出多窗 ROC), '
  347. '此处照录以免被当成"已验证的报警线"。'
  348. f'★ 本机为周段口径 ⇒ 上列阈值由**周段散布**定, 散布比多窗口径更小, 更容易被现实超出; '
  349. f'正确用法 = ①量化描述(报 x_self 而非"偏高"这类模糊词) ②人工研判输入 ③趋势跟踪积累段数。'
  350. '自动报警须等段数够做持续性检验。'),
  351. fleet_min_turbines=FLEET_MIN_TURBINES,
  352. inputs=dict(scalars=[v['index'] for v in used.values()], anchors=anchor_info.get('path')),
  353. 锚=anchor_info,
  354. 未出fleet键=missing_fleet,
  355. 自基线不足=ins_rows,
  356. )
  357. meta['★数据边界'] = (
  358. f'① 本机只有 {len(used)} 个 CMS 导出色窗 ('
  359. + '; '.join(f'{w} {v["span_min"]}→{v["span_max"]}' for w, v in used.items())
  360. + f') ⇒ 分段降级为窗内 ISO 周 ({len(order)} 段, {span[0]}→{span[1]}); '
  361. f'多窗口径 (健康期"窗"中位) 不可算, 本件 meta.segments_kind 写明实际用的是哪种。'
  362. f'② 份数: 自基线条目 {n_entries} 条 (台 × 测点 × 该测点**实际存在**的标量), 出数 {n_ok} 条, '
  363. f'不给数 {len(ins_rows)} 条 (健康期段 < {MIN_WINDOWS}, 明细见 meta.自基线不足; 一律 null + insufficient, '
  364. f'**不用 fleet 值顶替**)。★ 页面遍历的 5 个标量里, iso_rms_vel 只有发电机两测点有、rms_200 只有主轴承两测点有 '
  365. f'(y_unit 实测) ⇒ 其余测点这两条**不存在**, 页面 `if not r: continue` 跳过, 本件在 meta.未覆盖标量 里写明。'
  366. f'③ 锚护栏: {anchor_info.get("n_used", 0)} 条带日期锚已按 ±{ANCHOR_GUARD_DAYS} 天剔段, '
  367. f'{anchor_info.get("无日期", 0)} 条锚**无日期/类未登记** ⇒ 未做护栏 (逐条见 meta.锚.未用锚)。'
  368. f'④ fleet 中位要求该段 ≥{FLEET_MIN_TURBINES} 台贡献, 不足则该段不出值 (页面 ×fleet 显示 —)。'
  369. f'⑤ 本件全部数字由本脚本从 outputs/<场>/m5_cms_tcm/windows/*/index.parquet (data/raw 重算产物) 与 '
  370. f'reference/<场>/positive_anchors.json 现算, 不含随包旧件的数值搬运。')
  371. 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, {})}]
  372. for s in SENSORS}
  373. meta['未覆盖标量'] = {s: v for s, v in meta['未覆盖标量'].items() if v}
  374. payload = dict(meta=meta, absolute_limits=ABSOLUTE_LIMITS, fleet_baseline=fleet_bl, self_baseline=self_bl)
  375. txt = json.dumps(payload, ensure_ascii=False, indent=1, allow_nan=False, default=str)
  376. out = m5 / 'baseline_38.json'
  377. print(f'\n 条目 {n_entries} 条 · 出数 {n_ok} · 不给数 {len(ins_rows)} ({len({r["turbine"] for r in ins_rows})} 台) '
  378. f'· fleet 键 {n_fleet_keys} (未出 {len(missing_fleet)})')
  379. 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)} '
  380. f'· 正常带/阈值样例见下')
  381. for t, sen, ms in (('WTG01', 'Main_bearing_front', 'Peak'), ('WTG01', 'Gear_HS_rotor_side', 'Rms_Vel'),
  382. ('WTG01', 'Generator_DE', 'iso_rms_vel'), ('WTG02', 'Generator_DE', 'Peak')):
  383. e = (self_bl.get(t) or {}).get(sen, {}).get(ms)
  384. if e and e.get('self_baseline'):
  385. print(f' {t} {SENSOR_CN.get(sen, sen)}|{ms}: 自基线 {e["self_baseline"]} 带 {e["normal_band"]} '
  386. f'relMAD {e["rel_mad"]} 阈 {e["threshold_self"]} 末段 {e["latest_window"]}(n={e["latest_n"]}) '
  387. f'×自基线 {e["x_self"]} 带内={e["in_normal_band"]} 健康段 {e["n_windows_healthy"]}/{e["n_windows_total"]}'
  388. f' 剔 {len(e["excluded"])}')
  389. elif e:
  390. print(f' {t} {SENSOR_CN.get(sen, sen)}|{ms}: 不给数 — {e.get("insufficient")} '
  391. f'(健康段 {e["n_windows_healthy"]}/{e["n_windows_total"]}, 剔 {len(e["excluded"])})')
  392. if ins_rows:
  393. print(' 不给数明细: ' + '; '.join(f'{r["turbine"]} {SENSOR_CN.get(r["sensor"], r["sensor"])}|{r["meas"]} '
  394. f'{r["n_healthy"]}/{r["n_total"]} ({r["why"]})' for r in ins_rows[:8])
  395. + (' …' if len(ins_rows) > 8 else ''))
  396. print(f' 产物体积预估 {len(txt.encode("utf-8")) / 1024:.0f} KB ({P.rel(out)}) · 用时 {time.time() - t0:.1f}s')
  397. if dry:
  398. print('\n[dry-run] 不写盘、不登记。')
  399. return payload, 0
  400. m5.mkdir(parents=True, exist_ok=True)
  401. out.write_text(txt, encoding='utf-8')
  402. # ★自登记台账的落点与键 (2026-09-19 实逮): 正本是 `P.out_root(farm)/_derived_manifest.json`,
  403. # 键必须**相对产物仓根**(其它生成端同一口径)。用 P.store()/P.rel() 会另建一本
  404. # `windscada/_derived_manifest.json` 且键带 `outputs/rudong/` 前缀 ⇒ 台账读不到、审计报"未归类"。
  405. 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 绝对三层; '
  406. f'段口径 segments_kind={kind}, 实物锚 ±{ANCHOR_GUARD_DAYS}d 护栏, 不足不给数)'},
  407. by='scripts/baseline_38_build.py')
  408. print(f'\n[OK] 已写 {P.rel(out)} ({out.stat().st_size / 1024:.0f} KB) · 已自登记')
  409. return payload, 0
  410. def main() -> int:
  411. ap = argparse.ArgumentParser(description='三层基线生成端 (m5_cms_tcm/baseline_38.json)')
  412. ap.add_argument('--farm', default='rudong')
  413. ap.add_argument('--dry-run', action='store_true', help='只算不写盘 (打印漏斗与不给数清单)')
  414. args = ap.parse_args()
  415. cfg = cms_farm(args.farm)
  416. _, rc = build(cfg, args.farm, dry=args.dry_run)
  417. return rc
  418. if __name__ == '__main__':
  419. sys.exit(main())