| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- r"""六层链 `model_run` 步 —— 窗索引 + 谱库 → **L6 过闸谱线表 + 层小结**(按口径重建)。
- ## 这一步是什么 / 不是什么(先读这段再读代码)
- 用户令 2026-09-18「/detail 各页面不许用旧版产出补, 必须基于输入数据重算」之后, CMS 报告里
- 唯一还空着的是**融合级**那一列, 它的上游就是本步与 `fusion` 步的两件产物:
- ```
- configs: 'model_l6' = outputs/<场>/m5_cms_tcm/model_run_l6.parquet ← 从未随包(无标准答案)
- 'model_summary' = outputs/<场>/m5_cms_tcm/model_run_summary.json
- 'fusion' = outputs/<场>/m5_cms_tcm/fusion_38.csv ← 从未随包(无标准答案)
- ```
- **不是**复现: 这两件从未随过包, 盘上没有样件可逐值对拍 ⇒ 无法证明"与振动线那套一致"(见
- `docs/振动六层链_接口规格与缺口_v0.1.md` §3)。**是**按口径重建: 判据全部取自**包内既有实现**
- (不在本器里另写一套阈值), 每条线都留下"过没过闸、按哪条闸定的级"的来路, 来历在
- `model_run_summary.json` 的 `口径` 字段与产物台账里写明(`按口径重建, 无标准答案对拍`)。
- ## 判据来源(包内单一实现, 本器只做接线)
- | 环节 | 包内实现 |
- |---|---|
- | 候选线(理论频率) | `reference/rudong/oem_scan_plan.json` 的 11 个部件 × {BPFI, BPFO, BSF}(分母已 `--verify-plan` 11/11 逐值验过) |
- | 谱前提闸 G1–G8 | `src/sop/discriminators.py::spectral_line_gates`(分辨率/有线/峰稳/非固有/非整阶/选择性/fleet 离群) |
- | 六枚举定级 | `src/sop/discriminators.py::vib_verdict_and_writeback`(L0 短路 / 机制未定不命名部件 / 无正样本锚封顶候选) |
- | 正样本锚 | `reference/rudong/positive_anchors.json`(人工坐实的实物证据; 本器只读不写) |
- | 轴系转频 | `oem_scan_plan.json` 各部件的 `shaft_Hz`(★只传该轴承自己那根轴 —— 传全机会把真线误杀) |
- ## 输出(消费者的列名是硬契约)
- `model_run_l6.parquet` 的列由 `src/windcms/report_std.py::registry()` 与 `report.py` 决定:
- `台 / 测点 / 线 / hz / 域 / 绝对量 / 单位 / xfleet / 选择性 / 占比pct / 绝对锚 / 定级`
- (`台` 必须是 `f'{n}#'` 形态: registry 的 `_l6_consumed` 闭环断言会核对, 台号格式漂移当场报错)。
- 用法:
- python scripts/rudong_model_run.py # 默认取 M5_WINDOWS / 自动发现的窗
- python scripts/rudong_model_run.py --window w0316
- python scripts/rudong_model_run.py --turbines WTG01,WTG09 # 冒烟
- python scripts/rudong_model_run.py --dry-run # 只报计划, 不写产物
- """
- from __future__ import annotations
- import argparse
- import json
- import pathlib
- import sys
- import time
- import numpy as np
- 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 import discriminators as D # noqa: E402
- import rudong_tcm_oem_scan as OS # noqa: E402
- import spectra_lookup as SL # noqa: E402
- ANCHORS = ROOT / 'reference' / 'rudong' / 'positive_anchors.json'
- COMP_CLASS = { # 部件 → 正样本锚的部件类 (reference/rudong/positive_anchors.json)
- 'generator': 'generator_bearing', 'hs': 'gearbox_hs_bearing', 'ims': 'gearbox_ims_bearing',
- 'main': 'main_bearing',
- }
- # ISO 10816 速度当量 (mm/s, 刚性支撑·>15kW 机组的黄/红带) — 唯一"已校准"的绝对锚
- ISO = dict(yellow=4.5, red=11.0)
- LINES = (('BPFI', 'BPFI_hz', '内圈'), ('BPFO', 'BPFO_hz', '外圈'), ('BSF', 'BSF_hz', '滚动体'))
- def comp_class(component: str) -> str:
- for k, v in COMP_CLASS.items():
- if component.startswith(k):
- return v
- return 'unknown'
- def anchors_of() -> list | None:
- if not ANCHORS.is_file():
- return None
- return json.loads(ANCHORS.read_text(encoding='utf-8')).get('anchors')
- def windows_of(explicit: str | None) -> list[str]:
- import os
- if explicit:
- return [explicit]
- env = os.environ.get('M5_WINDOWS') or ''
- if env.strip():
- return [w for w in env.split(',') if w.strip()]
- m5 = P.m5()
- got = [p.parent.name for p in sorted((m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]/index.parquet'))]
- if (m5 / 'tcm_index.parquet').is_file():
- got.insert(0, 'w0127')
- return got
- _AVAIL: dict[str, set] = {}
- def available(win: str) -> set:
- """该窗谱库里**实有**的 (机组, 测点, 测量) 组合 —— 计划里的测量名不一定每台都有。
- ★为什么必须先查: `spectra_lookup.load()` 查不到记录时是 `SystemExit`(它的 CLI 语义), 而
- SystemExit 继承 BaseException, 用 `except Exception` 兜不住 —— 首版跑到第一台就整体退出
- (实测: w0316 里 WTG01 没有 Env_6000_4000_850_Tr 的谱)。缺谱是**常见情形**, 该跳过并计数, 不是崩。
- """
- if win not in _AVAIL:
- mp = SL.meta_path(win)
- s = set()
- if mp.is_file():
- d = pd.read_parquet(mp, columns=['turbine', 'sensor', 'meas_name'])
- s = set(map(tuple, d[['turbine', 'sensor', 'meas_name']].drop_duplicates().values))
- _AVAIL[win] = s
- return _AVAIL[win]
- def fleet_median(sensor: str, meas: str, turbines: list[str], win: str) -> np.ndarray | None:
- """同 (测点, 测量) 的**跨台中位谱** —— G4(机型固有) 与 G8(个体离群) 都靠它。"""
- got = []
- for t in turbines:
- if (t, sensor, meas) not in available(win):
- continue
- try:
- r = SL.load(win, t, sensor, meas)
- except BaseException:
- continue
- if not r:
- continue
- got.append(np.asarray(r['v'], dtype=float))
- if len(got) < 5:
- return None
- n = min(len(v) for v in got)
- return np.nanmedian(np.vstack([v[:n] for v in got]), axis=0)
- def evaluate(win: str, turbines: list[str], comps: list[dict], anchors, dry=False) -> tuple[list, dict]:
- """逐 (机组 × 部件 × 特征频率) 过闸定级 → L6 行。"""
- rows, stats = [], dict(considered=0, passed=0, no_spectrum=0, by_gate={}, by_level={})
- fmed_cache: dict[tuple, np.ndarray | None] = {}
- avail = available(win)
- for c in comps:
- meas, sensor = c['shard'], c['sensor']
- key = (sensor, meas)
- if key not in fmed_cache:
- fmed_cache[key] = fleet_median(sensor, meas, turbines, win)
- fmed = fmed_cache[key]
- dx = float(c.get('dx_hz') or 0) or None
- shaft = c.get('shaft_Hz') or 0.0
- domain = '包络谱' if str(meas).lower().startswith('env') else '原始谱'
- cls = comp_class(c['component'])
- for t in turbines:
- if (t, sensor, meas) not in avail:
- stats['no_spectrum'] += 1
- continue
- try:
- r = SL.load(win, t, sensor, meas)
- except BaseException:
- r = None
- if r is None:
- stats['no_spectrum'] += 1
- continue
- y = np.asarray(r['v'], dtype=float)
- meta = r['row']
- _dx = float(r.get('x_delta') or dx or 0) or dx
- if not _dx:
- continue
- for label, col, cn in LINES:
- hz = c.get(col)
- if not hz or not np.isfinite(hz) or hz <= 0:
- continue
- stats['considered'] += 1
- # 观测峰: 目标频率 ±2 bin 内取最大 (与谱前提闸 G7 的搜索窗同宽)
- tol = 2
- k = int(round(hz / _dx))
- if k - tol < 1 or k + tol >= len(y):
- continue
- seg = y[k - tol:k + tol + 1]
- if not len(seg) or not np.isfinite(seg).max():
- continue
- obs = float(np.nanmax(seg))
- x_fleet = None
- if fmed is not None and len(fmed) > k + tol:
- base = float(np.nanmax(fmed[k - tol:k + tol + 1])) or np.nan
- if np.isfinite(base) and base > 1e-12:
- x_fleet = obs / base
- sel = None # 选择性 = 线 ÷ 同谱宽带本底 (同窗, 与 G6 同语义)
- lo, hi = max(int(0.25 * len(y)), 1), max(int(0.75 * len(y)), 2)
- bg = float(np.nanmedian(np.abs(y[lo:hi]))) if hi > lo else np.nan
- if np.isfinite(bg) and bg > 1e-12:
- sel = obs / bg
- is_vel = str(meta.get('y_unit') or '') in ('m/s',)
- g = D.spectral_line_gates(y, _dx, float(hz), fleet_med=fmed, shaft_hz=(shaft,) if shaft else (),
- x_fleet=x_fleet, selectivity_samewin=sel, domain=domain,
- iso_ref_mms=(ISO['yellow'] if is_vel else None))
- gl = g.get('verdict') or '?'
- stats['by_gate'][gl] = stats['by_gate'].get(gl, 0) + 1
- if not g.get('passed'):
- continue
- # 绝对量: 速度域取 mm/s 当量 (ISO 可直接比), 其余域取谱值原单位
- if is_vel:
- abs_v, unit = obs * 1000.0, 'mm/s'
- else:
- abs_v, unit = obs, str(meta.get('y_unit') or '')
- vw = D.vib_verdict_and_writeback(
- gate_result=g, abs_value=abs_v, abs_unit=unit, x_fleet=x_fleet,
- iso_yellow=ISO['yellow'] if is_vel else None,
- iso_red=ISO['red'] if is_vel else None,
- component_class=cls, anchors=anchors, n_evidence_types=1,
- mechanism_confirmed=False) # 机制未定 ⇒ 不命名部件 (硬规则 2)
- lvl = vw.get('level') or 'INSUFFICIENT'
- stats['passed'] += 1
- stats['by_level'][lvl] = stats['by_level'].get(lvl, 0) + 1
- # 解封判据: 锚层给出的最高可达级依据 (无实物锚 ⇒ 写明未解封) —— 消费端 report.py:213 与
- # plugins.verdict_gate 都会显示这一列, 缺列会让逐台页整页崩 (AttributeError, 2026-09-19 实逮)
- try:
- ci = D.anchor_cap(cls, anchors, n_evidence_types=1, mechanism_confirmed=False)
- unlock = f"锚 tier={ci.get('tier')}: {str(ci.get('reason'))[:60]}"
- except Exception as e:
- unlock = f'锚不可判 ({type(e).__name__})'
- rows.append(dict(
- 台=f'{int(t[3:])}#', 测点=sensor, 线=f'{c["component"]}·{label}({cn})',
- 证据族=f'{domain}·{cn}', 解封判据=unlock,
- hz=round(float(hz), 3), 域=domain,
- 绝对量=round(float(abs_v), 5), 单位=unit,
- xfleet=(round(float(x_fleet), 2) if x_fleet is not None and np.isfinite(x_fleet) else None),
- 选择性=(round(float(sel), 2) if sel is not None and np.isfinite(sel) else None),
- 占比pct=None,
- 绝对锚=(f'ISO10816 黄{ISO["yellow"]}/红{ISO["red"]} mm/s' if is_vel else '—'),
- 定级=_consumer_level(lvl),
- _闸=g.get('verdict'), _依据=' · '.join(vw.get('reasons') or [])[:220],
- _写回=vw.get('writeback'), _窗=win))
- return rows, stats
- def _consumer_level(lvl: str) -> str:
- """把 L6 六枚举映射到消费端 `_LINE_STATE` 认得的那一套 (report_std.py 的 line_state 表)。
- `候选·查偶发源` / `候选·记基线` 这类细分在消费端表里**没有条目** ⇒ 会被默认成"优秀"
- (静默降级成假好消息)。这里显式映射, 细分信息留在 `_依据` 里。
- """
- s = str(lvl)
- if s.startswith('确诊') or s == '定论':
- return '定论'
- if s.startswith('准定论'):
- return '准定论·预警'
- if s.startswith('候选·新发'):
- return '候选·新发'
- if s.startswith('候选·记基线'):
- return '候选·记基线'
- if s.startswith('候选'):
- return '候选'
- if s.startswith('参考·上升'):
- return '参考·上升'
- if s.startswith('参考'):
- return '参考'
- if s.startswith('撤回'):
- return '撤回'
- return 'INSUFFICIENT'
- def l0_layer(win: str) -> dict:
- """L0 数据质量层 (按测点): 有解析错/过载/无记录 ⇒ 该测点结论不可采信 (report_std 会据它判"不可判")。
- 只看**索引自己的字段**(parse_error/overload), 不加任何自造阈值 —— 这层的意义是"别把盲台当健康台"。
- """
- ix = P.m5() / ('tcm_index.parquet' if win == 'w0127' else f'windows/{win}/index.parquet')
- if not ix.is_file():
- return {}
- d = pd.read_parquet(ix, columns=[c for c in ('turbine', 'sensor_name', 'parse_error', 'overload')
- if c in pd.read_parquet(ix).columns])
- out = {}
- for (t, s), g in d.groupby(['turbine', 'sensor_name']):
- bad = int(g['parse_error'].notna().sum()) if 'parse_error' in g else 0
- ovl = int((g['overload'] == True).sum()) if 'overload' in g else 0 # noqa: E712
- if bad and bad == len(g):
- out[f'{t}|{s}'] = '不可用: 该测点记录全部解析失败'
- elif ovl > 0.5 * len(g):
- out[f'{t}|{s}'] = '不可用: 该测点过半记录过载'
- elif bad:
- out[f'{t}|{s}'] = f'可用(部分): {bad}/{len(g)} 条解析失败'
- return out
- def main() -> int:
- ap = argparse.ArgumentParser(description='六层链 model_run 步: 窗索引+谱库 → L6 过闸谱线 (按口径重建)')
- ap.add_argument('--window', default=None)
- ap.add_argument('--turbines', default=None)
- ap.add_argument('--out', default=None)
- ap.add_argument('--dry-run', action='store_true')
- a = ap.parse_args()
- plan, comps = OS.load_plan()
- anchors = anchors_of()
- wins = windows_of(a.window)
- if not wins:
- print('[X] 没有可分析的窗 (m5_cms_tcm/windows/*/index.parquet 与 tcm_index.parquet 都不在位)')
- return 2
- turbines = ([f'WTG{x.strip().zfill(2)}' for x in a.turbines.split(',')] if a.turbines
- else [f'WTG{i:02d}' for i in range(1, 39)])
- print(f'model_run: 窗 {wins} · 机组 {len(turbines)} 台 · 部件 {len(comps)} 个 · '
- f'候选线 {len(comps) * len(LINES)}/台·窗 · 正样本锚 {len(anchors or [])} 条')
- if a.dry_run:
- for c in comps:
- print(f' {c["component"]:24} {c["sensor"]:24} {c["shard"]:24} dx={c["dx_hz"]:<9} '
- f'BPFI={c.get("BPFI_hz")} BPFO={c.get("BPFO_hz")} BSF={c.get("BSF_hz")} '
- f'shaft={c.get("shaft_Hz")}')
- print('(dry-run, 未写产物)')
- return 0
- t0 = time.time()
- allrows, summary = [], {}
- for win in wins:
- rows, stats = evaluate(win, turbines, comps, anchors)
- allrows += rows
- summary[win] = stats
- print(f' 窗 {win}: 候选 {stats["considered"]} 条 → 过闸 {stats["passed"]} 条 · '
- f'定级 {stats["by_level"]} · 闸分布 {dict(sorted(stats["by_gate"].items(), key=lambda x: -x[1])[:5])}')
- m5 = P.m5()
- l6 = pd.DataFrame(allrows)
- out = pathlib.Path(a.out) if a.out else (m5 / 'model_run_l6.parquet')
- if len(l6):
- l6 = l6.sort_values(['台', '测点', '线']).reset_index(drop=True)
- # 消费端的硬契约 (report_std.registry 的 _l6_consumed 闭环 + report.py 的列单)
- # 列 = 消费端硬契约: registry()/report.py:504 的投影 + report.py:213 与 plugins.verdict_gate 的明细表
- COLS = ['台', '测点', '线', 'hz', '域', '绝对量', '单位', 'xfleet', '选择性', '占比pct', '绝对锚',
- '证据族', '解封判据', '定级', '_闸', '_依据', '_写回', '_窗']
- l6 = l6.reindex(columns=COLS) if len(l6) else pd.DataFrame(columns=COLS)
- bad = sorted(set(l6['台']) - {f'{i}#' for i in range(1, 39)}) if len(l6) else []
- if bad:
- print(f'[X] 台号不在 38 台清单里: {bad} —— 消费端 registry 的闭环断言会当场报错, 本器先拦')
- return 5
- out.parent.mkdir(parents=True, exist_ok=True)
- l6.to_parquet(out, index=False)
- sm_path = m5 / 'model_run_summary.json'
- L0 = l0_layer(wins[0])
- summ = dict(
- status='ok', built=time.strftime('%Y-%m-%d %H:%M:%S'), windows=wins, seconds=round(time.time() - t0, 1),
- n_lines=int(len(l6)), by_gate={w: s['by_gate'] for w, s in summary.items()},
- by_level={w: s['by_level'] for w, s in summary.items()},
- L0=L0,
- 口径='按口径重建(**非**逐值对拍的复现 —— model_run_l6.parquet 从未随过包, 无标准答案, 见 '
- 'docs/振动六层链_接口规格与缺口_v0.1.md §3)。候选线=reference/rudong/oem_scan_plan.json 的 11 个'
- '部件 × {BPFI,BPFO,BSF}(分母已 11/11 逐值验过); 过闸=src/sop/discriminators.py::spectral_line_gates '
- '(G1..G8); 定级=src/sop/discriminators.py::vib_verdict_and_writeback (L0 短路 / 机制未定不命名部件 / '
- '无正样本锚封顶候选); 轴系转频只传该轴承自己那根轴; ISO 绝对锚只对速度域生效',
- missing_note='峰值拾取仍是**本器自定**: 目标频率 ±2 bin 取最大 (与 G7 搜索窗同宽), 未做阶次跟踪/多记录合并 —— '
- '振动线那套的"怎么取观测峰"口径仍缺 (docs §7.3), 故本件不可当"复现"用')
- sm_path.write_text(json.dumps(summ, ensure_ascii=False, indent=1), encoding='utf-8')
- print(f'已写 {P.rel(out)}: {len(l6)} 行')
- print(f'已写 {P.rel(sm_path)}: L0 {len(L0)} 条 · 窗 {len(wins)}')
- # 来源自登记 (谁算的谁登记)
- try:
- from src import derived_manifest as DM
- rels = {out.relative_to(P.out_root()).as_posix():
- 'scripts/rudong_model_run.py (按口径重建, 无标准答案对拍; 判据=src/sop/discriminators.py)',
- sm_path.relative_to(P.out_root()).as_posix():
- 'scripts/rudong_model_run.py (L0 层 + 口径说明)'}
- DM.record(P.out_root(), rels, by='rudong_model_run')
- print(' 已自登记 → _derived_manifest.json')
- except Exception as e:
- print(f' [i] 自登记跳过: {type(e).__name__}: {e}')
- return 0
- if __name__ == '__main__':
- for _s in (sys.stdout, sys.stderr):
- try:
- _s.reconfigure(errors='replace')
- except Exception:
- pass
- sys.exit(main())
|