tcm_mask_thresholds_build.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""TCM 掩码阈值件(`tcm_compatible_replay/model/mask_thresholds.tsv`)的**观澜自算**生成端(源代码化清单第 ③ 件)。
  4. ## 消费者与格式(不能改的契约)
  5. `src/windcms/data.py::load_masks(cfg)` 读 `cfg['mask_thresholds']` 这个 TSV,列固定:
  6. state · measurement_key · condition_key · mask_time · value_median · yellow_scalar · red_scalar
  7. (只收 state=='Active',按 (measurement_key, condition_key) 取 mask_time 最新一条)
  8. `measurement_key` 由 `data.mask_key(farm_prefix, turbine, sensor, meas)` 拼成
  9. (`<CMS SiteName>/<台>/<测点>/<测量>`, 与 `src/windcms/tcm.py::status_table(..., 'CGN Rudong')` 同前缀)。
  10. 消费它的页面:逐台页的阈值线、`status_table` 的 mask 状态列。
  11. ## 口径(★必读:这是"观澜自算",不是厂商阈值)
  12. 现场导出件里**没有**掩码定义 —— 实测 25,679 个 `*_decode.json` 里只有 `NumberOfMasks: 175`,
  13. 掩码本体(黄/红标量)在 CMS 库内、未随导出 ⇒ **厂商阈值无法从给定原始件复原**。
  14. 用户令「所有的计算均要形成观澜的源代码」⇒ 本器按**观澜自己的自适应基线**给阈值:
  15. value_median / yellow / red = src/sop/discriminators.py::adaptive_baseline_threshold(...)
  16. (per (台, 测点, 测量, 功率工况):窗内中位 + k×MAD,k_yellow=3 / k_red=6,基线先净化;样本门 min_n=30)
  17. 来历写进同目录 `mask_thresholds.说明.json`(TSV 不能带注释行,否则 `read_csv(sep='\t')` 会被带偏),
  18. 页面据此把线标成"观澜自算阈值"而不是"厂商黄/红线"(见 src/windcms/workbench.py 的措辞分支)。
  19. 用法: python scripts/tcm_mask_thresholds_build.py [--status] [--window w0316]
  20. """
  21. from __future__ import annotations
  22. import argparse
  23. import json
  24. import pathlib
  25. import sys
  26. import time
  27. import pandas as pd
  28. ROOT = pathlib.Path(__file__).resolve().parents[1]
  29. sys.path.insert(0, str(ROOT))
  30. sys.path.insert(0, str(ROOT / 'scripts'))
  31. from src import paths as P # noqa: E402
  32. from src.sop.discriminators import adaptive_baseline_threshold as _ABT # noqa: E402 ← 单一实现
  33. SITE_PREFIX = 'CGN Rudong' # 与 tcm.status_table(s, masks, 'CGN Rudong') 同一前缀
  34. SETUP = 'mask_thresholds.说明.json'
  35. def _target(farm: str | None = None) -> pathlib.Path:
  36. return P.tcm_replay(farm) / 'model' / 'mask_thresholds.tsv'
  37. def status(farm: str | None = None) -> int:
  38. p = _target(farm)
  39. note = p.parent / SETUP
  40. print(json.dumps(dict(path=P.rel(p), exists=p.is_file(),
  41. rows=(sum(1 for _ in p.open(encoding='utf-8')) - 1) if p.is_file() else 0,
  42. caliber=('观澜自算(k×MAD 自适应基线)' if note.is_file() else
  43. ('厂商阈值(随包件)' if p.is_file() else None)),
  44. note='不在位 ⇒ 逐台页不画阈值线(文案"厂商未设阈值"); 跑本器可由窗索引自算'),
  45. ensure_ascii=False))
  46. return 0
  47. def build(farm: str | None = None, window: str | None = None) -> int:
  48. m5 = P.m5(farm)
  49. wins = [window] if window else [d.name for d in sorted((m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]'))
  50. if (d / 'index.parquet').is_file()]
  51. if not wins:
  52. print('[X] 没有可用的窗索引(m5_cms_tcm/windows/*/index.parquet)—— 先跑振动摄入')
  53. return 2
  54. cols = ['turbine', 'sensor_name', 'meas_name', 'condition_key', 'scalar_value', 'ds_size', 'trigger_time']
  55. rows, n_skip = [], 0
  56. for w in wins:
  57. ix = m5 / 'windows' / w / 'index.parquet'
  58. d = pd.read_parquet(ix, columns=[c for c in cols if c in pd.read_parquet(ix).columns])
  59. if 'ds_size' in d.columns: # 只收标量(ds_size==1); 谱记录没有标量
  60. d = d[pd.to_numeric(d['ds_size'], errors='coerce') == 1]
  61. d = d[pd.to_numeric(d['scalar_value'], errors='coerce').notna()]
  62. for (t, sen, meas, ck), g in d.groupby(['turbine', 'sensor_name', 'meas_name', 'condition_key']):
  63. v = pd.to_numeric(g['scalar_value'], errors='coerce').dropna()
  64. if len(v) < 30: # 样本门: 与 discriminators 的 min_n 一致, 不足则不给阈值
  65. n_skip += 1
  66. continue
  67. r = _ABT(list(v))
  68. med = r.get('baseline_med')
  69. yel, red = r.get('yellow'), r.get('red')
  70. if med is None or yel is None or red is None:
  71. n_skip += 1
  72. continue
  73. rows.append(dict(state='Active',
  74. measurement_key=f'{SITE_PREFIX}/{t}/{sen}/{meas}',
  75. condition_key=str(ck), mask_time=time.strftime('%Y-%m-%d'),
  76. value_median=round(float(med), 6), yellow_scalar=round(float(yel), 6),
  77. red_scalar=round(float(red), 6)))
  78. if not rows:
  79. print(f'[X] 一条阈值也没算出来(可行组 {n_skip} 组都卡在样本门 n≥30)—— 不写空文件')
  80. return 5
  81. out = _target(farm)
  82. out.parent.mkdir(parents=True, exist_ok=True)
  83. pd.DataFrame(rows).to_csv(out, sep='\t', index=False, encoding='utf-8')
  84. note = dict(
  85. schema='guanlan-mask-thresholds/v1', built=time.strftime('%Y-%m-%d %H:%M:%S'),
  86. generator='scripts/tcm_mask_thresholds_build.py',
  87. caliber='观澜自算(不是厂商阈值)',
  88. why='现场导出件里没有掩码定义(25,679 个 *_decode.json 里只有 NumberOfMasks: 175, 掩码本体在 CMS 库内未导出)'
  89. '⇒ 厂商黄/红线无法从原始件复原; 本件按观澜自适应基线出阈值。',
  90. formula='src/sop/discriminators.py::adaptive_baseline_threshold(per (台,测点,测量,工况) 窗内中位 + '
  91. 'k×MAD, k_yellow=3 / k_red=6, 基线先净化, 样本门 n≥30)',
  92. rows=len(rows), skipped_groups=n_skip, site_prefix=SITE_PREFIX,
  93. window=','.join(wins),
  94. usage='src/windcms/data.py::load_masks 读本 TSV; 页面按"观澜自算阈值"标注(见 workbench.py)')
  95. (out.parent / SETUP).write_text(json.dumps(note, ensure_ascii=False, indent=1), encoding='utf-8')
  96. print(f'已写 {P.rel(out)}: {len(rows)} 条阈值(组样本门不足跳过 {n_skip} 组)· 口径见 {SETUP}')
  97. try:
  98. from src import derived_manifest as DM
  99. DM.record(P.out_root(farm), {
  100. f'tcm_compatible_replay/model/mask_thresholds.tsv':
  101. 'scripts/tcm_mask_thresholds_build.py (观澜自算: 中位 + 3/6×MAD 自适应基线)',
  102. f'tcm_compatible_replay/model/{SETUP}':
  103. 'scripts/tcm_mask_thresholds_build.py (口径说明: 非厂商阈值)'}, by='tcm_mask_thresholds_build')
  104. print(' 已自登记 → _derived_manifest.json')
  105. except Exception as e:
  106. print(f' [i] 自登记跳过: {type(e).__name__}: {e}')
  107. return 0
  108. def main() -> int:
  109. ap = argparse.ArgumentParser(description='TCM 掩码阈值件(观澜自算: 自适应基线)')
  110. ap.add_argument('--farm', default=None)
  111. ap.add_argument('--window', default=None)
  112. ap.add_argument('--status', action='store_true')
  113. a = ap.parse_args()
  114. return status(a.farm) if a.status else build(a.farm, a.window)
  115. if __name__ == '__main__':
  116. for _s in (sys.stdout, sys.stderr):
  117. try:
  118. _s.reconfigure(errors='replace')
  119. except Exception:
  120. pass
  121. sys.exit(main())