#!/usr/bin/env python3 # -*- coding: utf-8 -*- r"""TCM 掩码阈值件(`tcm_compatible_replay/model/mask_thresholds.tsv`)的**观澜自算**生成端(源代码化清单第 ③ 件)。 ## 消费者与格式(不能改的契约) `src/windcms/data.py::load_masks(cfg)` 读 `cfg['mask_thresholds']` 这个 TSV,列固定: state · measurement_key · condition_key · mask_time · value_median · yellow_scalar · red_scalar (只收 state=='Active',按 (measurement_key, condition_key) 取 mask_time 最新一条) `measurement_key` 由 `data.mask_key(farm_prefix, turbine, sensor, meas)` 拼成 (`/<台>/<测点>/<测量>`, 与 `src/windcms/tcm.py::status_table(..., 'CGN Rudong')` 同前缀)。 消费它的页面:逐台页的阈值线、`status_table` 的 mask 状态列。 ## 口径(★必读:这是"观澜自算",不是厂商阈值) 现场导出件里**没有**掩码定义 —— 实测 25,679 个 `*_decode.json` 里只有 `NumberOfMasks: 175`, 掩码本体(黄/红标量)在 CMS 库内、未随导出 ⇒ **厂商阈值无法从给定原始件复原**。 用户令「所有的计算均要形成观澜的源代码」⇒ 本器按**观澜自己的自适应基线**给阈值: value_median / yellow / red = src/sop/discriminators.py::adaptive_baseline_threshold(...) (per (台, 测点, 测量, 功率工况):窗内中位 + k×MAD,k_yellow=3 / k_red=6,基线先净化;样本门 min_n=30) 来历写进同目录 `mask_thresholds.说明.json`(TSV 不能带注释行,否则 `read_csv(sep='\t')` 会被带偏), 页面据此把线标成"观澜自算阈值"而不是"厂商黄/红线"(见 src/windcms/workbench.py 的措辞分支)。 用法: python scripts/tcm_mask_thresholds_build.py [--status] [--window w0316] """ 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 from src.sop.discriminators import adaptive_baseline_threshold as _ABT # noqa: E402 ← 单一实现 SITE_PREFIX = 'CGN Rudong' # 与 tcm.status_table(s, masks, 'CGN Rudong') 同一前缀 SETUP = 'mask_thresholds.说明.json' def _target(farm: str | None = None) -> pathlib.Path: return P.tcm_replay(farm) / 'model' / 'mask_thresholds.tsv' def status(farm: str | None = None) -> int: p = _target(farm) note = p.parent / SETUP print(json.dumps(dict(path=P.rel(p), exists=p.is_file(), rows=(sum(1 for _ in p.open(encoding='utf-8')) - 1) if p.is_file() else 0, caliber=('观澜自算(k×MAD 自适应基线)' if note.is_file() else ('厂商阈值(随包件)' if p.is_file() else None)), note='不在位 ⇒ 逐台页不画阈值线(文案"厂商未设阈值"); 跑本器可由窗索引自算'), ensure_ascii=False)) return 0 def build(farm: str | None = None, window: str | None = None) -> int: m5 = P.m5(farm) wins = [window] if window else [d.name for d in sorted((m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]')) if (d / 'index.parquet').is_file()] if not wins: print('[X] 没有可用的窗索引(m5_cms_tcm/windows/*/index.parquet)—— 先跑振动摄入') return 2 cols = ['turbine', 'sensor_name', 'meas_name', 'condition_key', 'scalar_value', 'ds_size', 'trigger_time'] rows, n_skip = [], 0 for w in wins: ix = m5 / 'windows' / w / 'index.parquet' d = pd.read_parquet(ix, columns=[c for c in cols if c in pd.read_parquet(ix).columns]) if 'ds_size' in d.columns: # 只收标量(ds_size==1); 谱记录没有标量 d = d[pd.to_numeric(d['ds_size'], errors='coerce') == 1] d = d[pd.to_numeric(d['scalar_value'], errors='coerce').notna()] for (t, sen, meas, ck), g in d.groupby(['turbine', 'sensor_name', 'meas_name', 'condition_key']): v = pd.to_numeric(g['scalar_value'], errors='coerce').dropna() if len(v) < 30: # 样本门: 与 discriminators 的 min_n 一致, 不足则不给阈值 n_skip += 1 continue r = _ABT(list(v)) med = r.get('baseline_med') yel, red = r.get('yellow'), r.get('red') if med is None or yel is None or red is None: n_skip += 1 continue rows.append(dict(state='Active', measurement_key=f'{SITE_PREFIX}/{t}/{sen}/{meas}', condition_key=str(ck), mask_time=time.strftime('%Y-%m-%d'), value_median=round(float(med), 6), yellow_scalar=round(float(yel), 6), red_scalar=round(float(red), 6))) if not rows: print(f'[X] 一条阈值也没算出来(可行组 {n_skip} 组都卡在样本门 n≥30)—— 不写空文件') return 5 out = _target(farm) out.parent.mkdir(parents=True, exist_ok=True) pd.DataFrame(rows).to_csv(out, sep='\t', index=False, encoding='utf-8') note = dict( schema='guanlan-mask-thresholds/v1', built=time.strftime('%Y-%m-%d %H:%M:%S'), generator='scripts/tcm_mask_thresholds_build.py', caliber='观澜自算(不是厂商阈值)', why='现场导出件里没有掩码定义(25,679 个 *_decode.json 里只有 NumberOfMasks: 175, 掩码本体在 CMS 库内未导出)' '⇒ 厂商黄/红线无法从原始件复原; 本件按观澜自适应基线出阈值。', formula='src/sop/discriminators.py::adaptive_baseline_threshold(per (台,测点,测量,工况) 窗内中位 + ' 'k×MAD, k_yellow=3 / k_red=6, 基线先净化, 样本门 n≥30)', rows=len(rows), skipped_groups=n_skip, site_prefix=SITE_PREFIX, window=','.join(wins), usage='src/windcms/data.py::load_masks 读本 TSV; 页面按"观澜自算阈值"标注(见 workbench.py)') (out.parent / SETUP).write_text(json.dumps(note, ensure_ascii=False, indent=1), encoding='utf-8') print(f'已写 {P.rel(out)}: {len(rows)} 条阈值(组样本门不足跳过 {n_skip} 组)· 口径见 {SETUP}') try: from src import derived_manifest as DM DM.record(P.out_root(farm), { f'tcm_compatible_replay/model/mask_thresholds.tsv': 'scripts/tcm_mask_thresholds_build.py (观澜自算: 中位 + 3/6×MAD 自适应基线)', f'tcm_compatible_replay/model/{SETUP}': 'scripts/tcm_mask_thresholds_build.py (口径说明: 非厂商阈值)'}, by='tcm_mask_thresholds_build') print(' 已自登记 → _derived_manifest.json') except Exception as e: print(f' [i] 自登记跳过: {type(e).__name__}: {e}') return 0 def main() -> int: ap = argparse.ArgumentParser(description='TCM 掩码阈值件(观澜自算: 自适应基线)') ap.add_argument('--farm', default=None) ap.add_argument('--window', default=None) ap.add_argument('--status', action='store_true') a = ap.parse_args() return status(a.farm) if a.status else build(a.farm, a.window) if __name__ == '__main__': for _s in (sys.stdout, sys.stderr): try: _s.reconfigure(errors='replace') except Exception: pass sys.exit(main())