rudong_line_energy_share.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""六层链 `energy_share` 步 —— **谱线能量占比**(最后补上的那一步脚本)。
  4. ## 它在链里的位置与口径
  5. `src/windcms/pipeline.py::analyze` 的步序是 `oem_scan → **energy_share** → model_run → fusion → report`:
  6. 扫线(哪些特征频率有线)之后,本步回答"**这条线占了多少能量**"。此前这一步脚本没随包
  7. (`chain_gap_check` 报"1 个步骤脚本未随包"),于是链上少一环、也没有任何件回答这个问题。
  8. 口径(写死在这里,供复核):
  9. band = 目标特征频率 ±tol_bins·dx(默认 ±2 bin,与 model_run 的取峰窗同宽)
  10. 峰能量 = Σ y[i]² (i ∈ band)
  11. 总能量 = Σ y[j]² (整条谱,去掉 DC 前 3 bin)
  12. energy_pct = 峰能量 ÷ 总能量 × 100
  13. ★只报**占比**,不定级:占比小不等于没问题(宽带抬升会摊薄单线占比),
  14. 故消费端(model_run 的 `vib_verdict_and_writeback`)把 energy_pct<0.01% 的结论降为「参考」。
  15. 输入: `m5_cms_tcm/windows/<窗>/index.parquet` + `spectra/*`(振动摄入的产物)
  16. 候选线来自 `reference/rudong/oem_scan_plan.json`(与 `rudong_tcm_oem_scan.py` 同一份配置)
  17. 输出: `m5_cms_tcm/line_energy_share.parquet`
  18. 列: window, turbine, sensor, meas, component, line, hz, band_hz, energy_pct, peak, total, n_bins
  19. 用法: python scripts/rudong_line_energy_share.py [--window w0316] [--limit-turbines N]
  20. """
  21. from __future__ import annotations
  22. import argparse
  23. import pathlib
  24. import sys
  25. import time
  26. import numpy as np
  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. import rudong_tcm_oem_scan as OS # noqa: E402 候选线(分母)同一实现
  33. import spectra_lookup as SL # noqa: E402
  34. LINES = (('BPFI', 'BPFI_hz'), ('BPFO', 'BPFO_hz'), ('BSF', 'BSF_hz'))
  35. def windows_of(explicit=None) -> list[str]:
  36. m5 = P.m5()
  37. got = sorted(d.name for d in (m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]')
  38. if (d / 'index.parquet').is_file())
  39. return [explicit] if explicit else got
  40. def main() -> int:
  41. ap = argparse.ArgumentParser(description='六层链 energy_share 步: 谱线能量占比')
  42. ap.add_argument('--window', default=None)
  43. ap.add_argument('--tol-bins', type=int, default=2)
  44. ap.add_argument('--limit-turbines', type=int, default=0)
  45. ap.add_argument('--out', default=None)
  46. a = ap.parse_args()
  47. _plan, comps = OS.load_plan()
  48. wins = windows_of(a.window)
  49. if not wins:
  50. print('[跳过] 没有窗索引(先跑振动摄入 scripts/vib_raw_build.py)')
  51. return 0
  52. rows = []
  53. t0 = time.time()
  54. for w in wins:
  55. mp = SL.meta_path(w)
  56. if not mp.is_file():
  57. continue
  58. meta = pd.read_parquet(mp, columns=['turbine', 'sensor', 'meas_name'])
  59. avail = set(map(tuple, meta.drop_duplicates().values))
  60. turbines = sorted({t for t, _s, _m in avail})
  61. if a.limit_turbines:
  62. turbines = turbines[:a.limit_turbines]
  63. for c in comps:
  64. sen, meas = c['sensor'], c['shard']
  65. for t in turbines:
  66. if (t, sen, meas) not in avail:
  67. continue
  68. try:
  69. rec = SL.load(w, t, sen, meas)
  70. except BaseException:
  71. continue
  72. if not rec:
  73. continue
  74. y = np.asarray(rec['v'], dtype=float)
  75. dx = float(rec.get('x_delta') or c.get('dx_hz') or 0)
  76. if not dx or y.size < 8:
  77. continue
  78. y2 = y[3:] ** 2 # 去前 3 bin(DC/趋势), 与"总能量"口径一致
  79. total = float(np.nansum(y2))
  80. if not np.isfinite(total) or total <= 0:
  81. continue
  82. for label, col in LINES:
  83. hz = c.get(col)
  84. if not hz or not np.isfinite(hz) or hz <= 0:
  85. continue
  86. k = int(round(float(hz) / dx))
  87. lo, hi = max(k - a.tol_bins - 3, 0), min(k + a.tol_bins - 3, y2.size)
  88. if hi <= lo:
  89. continue
  90. band = y2[lo:hi]
  91. peak = float(np.nansum(band))
  92. rows.append(dict(window=w, turbine=t, sensor=sen, meas=meas,
  93. component=c['component'], line=label, hz=round(float(hz), 3),
  94. band_hz=round(2 * a.tol_bins * dx, 3),
  95. energy_pct=round(100.0 * peak / total, 6),
  96. peak=peak, total=total, n_bins=int(hi - lo)))
  97. if not rows:
  98. print(f'[X] 一条占比也没算出来(窗 {wins} 的谱库不可用?)—— 不写空文件')
  99. return 2
  100. df = pd.DataFrame(rows).sort_values(['window', 'turbine', 'component', 'line'])
  101. out = pathlib.Path(a.out) if a.out else (P.m5() / 'line_energy_share.parquet')
  102. out.parent.mkdir(parents=True, exist_ok=True)
  103. df.to_parquet(out, index=False)
  104. top = df.nlargest(3, 'energy_pct')[['turbine', 'component', 'line', 'energy_pct']].to_dict('records')
  105. print(f'已写 {P.rel(out)}: {len(df)} 行 × {len(df.columns)} 列(窗 {",".join(wins)},{time.time()-t0:.0f}s)· '
  106. f'占比最高: {top}')
  107. try:
  108. from src import derived_manifest as DM
  109. DM.record(P.out_root(), {out.relative_to(P.out_root()).as_posix():
  110. 'scripts/rudong_line_energy_share.py (线能量占比 = ±2bin 带内能量 ÷ 全谱能量; 只报占比不定级)'},
  111. by='rudong_line_energy_share')
  112. print(' 已自登记 → _derived_manifest.json')
  113. except Exception as e:
  114. print(f' [i] 自登记跳过: {type(e).__name__}: {e}')
  115. return 0
  116. if __name__ == '__main__':
  117. for _s in (sys.stdout, sys.stderr):
  118. try:
  119. _s.reconfigure(errors='replace')
  120. except Exception:
  121. pass
  122. sys.exit(main())