Sfoglia il codice sorgente

逆向工程进展: fleet_scalar_z 口径反推(val 逐值 100% 一致) + rudong_fusion_run.py(--verify 机器判据)

用户令 #2「能否逆向工程推导」的实质推进 —— 挑有标准答案的那一件先攻:
· 新增 scripts/rudong_fusion_run.py(六层链 fusion 步的第一件产物; 包内原先只有壳在调它):
    val       = 窗内 median(scalar_value), 按 (turbine, sensor_name, meas_name, condition_key) 分组
    n         = 组内记录数(样件口径 n>=3)
    fleet_med = 跨机组 median(val)
    z         = (val - fleet_med) / (1.4826 × MAD)     ← 稳健 z
  尺度是从四条候选里数出来的: std=1.44859→z=-0.1725 · MAD=0.80209→z=-0.3116 ·
  IQR/1.349=1.46899→z=-0.1701 · 1.4826×MAD=1.18918→z=-0.2102 命中样件 -0.210156。
· 逐值对拍(样件 8,887 行 · 窗 w0127 · 8 个 sensor / 7 个 bin / 37 台):
  val 8887/8887 = 100% · fleet_med 94.1% · z 92.0% · n 96.8%; 本器 9,505 行(只多不缺, 多 618)。
· 纪律: 对拍未全过 ⇒ 本器拒绝声明复现(--verify 退出码 5, 打印"不要拿本器产物替换样件"),
  产物不替换, 该件继续留 ✗ —— 宁可留着缺口, 也不拿 92% 冒充标准答案。
· 剩余差异已定位(docs §6.3): ① meas 口径(样件 28 个全是标量指标类, 排掉 80 余个 FFT_/Time_/Env_/
  Cep_FFT_ 名与 Disk/Memory/Health/RpmProfile; 但 Indicator_*/iso_rms/rms_200 的 meas_type 为空
  ⇒ 筛选字段还没找准) ② 每键机组子集(样件 14~37 台, 中位 28; 本器 34~37) ③ 基线随之差 5~8%。
· docs/振动六层链_接口规格与缺口_v0.1.md 增 §6: 口径、尺度候选表、对拍表、剩余差异定位与下一步。
zhouyang.xie 3 settimane fa
parent
commit
f2675154f9

+ 55 - 0
docs/振动六层链_接口规格与缺口_v0.1.md

@@ -74,3 +74,58 @@ python.exe: can't open file '…\scripts\rudong_model_run.py': [Errno 2] No such
    页面缺件时给结构化说明(不静默、不造数、不从交付包补)。
 
 > 台账现状(2026-09-17):✓ 呼应成立 1742 件 · ✗ 无生成端 285 件 · ◆ 人工件 209 件 · 无法验证 0 · 未归类 0。
+
+---
+
+## 6. 逆向工程进展:`fleet_scalar_z.parquet` 的口径已反推(可对拍)
+
+`fusion` 步的产物里,**只有 `fleet_scalar_z.parquet` 有标准答案**(随包件在盘上),因此先攻它。
+新增 `scripts/rudong_fusion_run.py`(`--verify` 与样件逐值对拍,**不改任何件**)。
+
+### 6.1 反推出来的口径
+
+```
+val       = 窗内 median(scalar_value),按 (turbine, sensor_name, meas_name, condition_key) 分组
+n         = 组内记录数(样件口径:n ≥ 3)
+fleet_med = 跨机组 median(val)(同一 sensor/meas/bin 下)
+z         = (val - fleet_med) / (1.4826 × MAD(val))     ← 稳健 z 分数
+```
+
+**尺度是怎么定下来的**(四条候选逐个数对):std(ddof=0)=1.44859 → z=−0.1725 ·
+MAD=0.80209 → z=−0.3116 · IQR/1.349=1.46899 → z=−0.1701 ·
+**1.4826×MAD=1.18918 → z=−0.2102 ← 与样件 −0.210156 命中**。
+
+### 6.2 逐值对拍结果(样件 8,887 行 · 窗 w0127)
+
+| 列 | 一致 | 比率 |
+|---|---:|---:|
+| `val` | **8887 / 8887** | **100%** |
+| `fleet_med` | 8359 / 8887 | 94.1% |
+| `z` | 8180 / 8887 | 92.0% |
+| `n` | 8606 / 8887 | 96.8% |
+
+行数:本器 9,505 行 vs 样件 8,887 行(多 618 行,全部键都在样件里 ⇒ **只多不缺**)。
+
+### 6.3 剩下的差异定位(还差最后几步)
+
+1. **meas 口径**:样件的 28 个 meas 全是**标量指标类**(`CrestFactor` `Kurtosis` `Peak` `Rms_*`
+   `iso_rms*` `Indicator_*` `Tooth_damage_indicator_*` `rms_200` `rms_Env_6000_Tr`),
+   而索引里另有 80 余个 `FFT_*` / `Time_*` / `Env_*` / `Cep_FFT_*` 名(波形/谱名)与
+   `Disk Usage` / `Memory Usage` / `Health` / `RpmProfile`(运维项)被排除。
+   ⇒ 规则大概是"只留 meas_type ∈ {Kurtosis, CrestFactor, Peak, RmsOverAll} 的标量",
+   但 `Indicator_*` / `iso_rms` / `rms_200` 这几类在索引里 `meas_type` 为空 ⇒ **筛选字段还没找准**。
+2. **每键机组子集**:样件每个 (sensor,meas,bin) 只含 14–37 台(中位 28),本器是 34–37 台 ⇒
+   样件多了一层"机组级有效性"过滤(猜测与 `parse_error` / `overload` / `alarm_type` 或组内一致性有关)。
+3. `fleet_med`/`z` 那 5–8% 的差异,很可能就是**被上面两条筛选改变后的基线**导致的 —— 一旦筛选口径对齐,
+   基线应当自动落回(`val` 100% 一致已说明取值链是对的)。
+
+### 6.4 结论
+
+- **能推**:这条链上"内容是窗内标量函数"的那部分**确实可以逆向工程**,且已经推到
+  `val` 逐值 100%、`z` 92% 的程度;`scripts/rudong_fusion_run.py --verify` 把这件事做成了可重复的机器判据。
+- **判据纪律**:对拍未通过时本器**拒绝**声明复现(退出码 5、打印"不要拿本器产物替换样件"),
+  产物也**不替换** —— 宁可继续留 ✗,也不拿 92% 的东西冒充标准答案。
+- **下一步**(按代价排序):① 找准"哪 4 类 meas 该留"的筛选字段(读一次索引就能定);
+  ② 找准机组级有效性过滤;③ 对齐后 `fleet_scalar_z` 转 raw-derived,`fusion` 步再补 `fusion_38.csv`
+  (那件无标准答案,需要振动线给一份历史产出)。
+

+ 151 - 0
scripts/rudong_fusion_run.py

@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""六层链 · `fusion` 步(**逆向工程实现**,2026-09-17 用户令 #2)。
+
+## 这个文件是怎么来的
+
+包里的编排壳 `src/windcms/pipeline.py::analyze` 会调 `scripts/rudong_fusion_run.py`,而该脚本没随包
+(六层链四步脚本都缺)。用户令 #2 问"能不能根据旧包产物逆向工程推导出来"——本文件是**可对拍的那部分**
+的答案:`fleet_scalar_z.parquet` 的标准答案在盘上(随包件),于是口径可以**反推 + 逐值对拍**。
+
+## 反推出来的口径(三条都逐值验过)
+
+    val        = 窗内 median(scalar_value),按 (turbine, sensor_name, meas_name, condition_key) 分组
+    n          = 该组记录数
+    fleet_med  = 跨机组的 median(val)(同一 sensor/meas/bin 下)
+    z          = (val - fleet_med) / (1.4826 × MAD(val))      ← 稳健 z 分数(MAD 抗离群)
+
+验证(`--verify`,样件 outputs/<场>/m5_cms_tcm/fleet_scalar_z.parquet,8,887 行):
+    样例 WTG01 / Gear_HS_generator_side / CrestFactor / WPS-ActivePower 0-1600,
+      样件: val=5.66453  fleet_med=5.91444  z=-0.210156  n=7
+      本器: val=5.66453  fleet_med=5.91444  z=-0.210153  n=7
+    尺度候选实测: std=1.44859(z=-0.1725) · MAD=0.80209(z=-0.3116) · IQR/1.349=1.46899(z=-0.1701)
+                  **1.4826×MAD=1.18918(z=-0.2102) ← 命中**
+
+## 用法
+
+    python scripts/rudong_fusion_run.py                    # 算并落盘 (默认窗口 = 配置里的首窗)
+    python scripts/rudong_fusion_run.py --verify           # 与盘上样件逐值对拍 (不改任何件)
+    python scripts/rudong_fusion_run.py --out <路径>       # 写到别处 (对拍/试验用)
+
+★ 本器**只实现已被标准答案验证过的那一件**。`fusion_38.csv` 与 `model_run_l6.parquet` 从未随过包,
+没有标准答案 ⇒ 不在本器里猜着写(本项目硬规矩: 允许响亮降级, 不许造数; 见
+`docs/振动六层链_接口规格与缺口_v0.1.md`)。
+
+退出码: 0 成功/对拍通过 · 5 对拍不一致 · 2 找不到输入
+"""
+from __future__ import annotations
+
+import argparse
+import pathlib
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                              # noqa: E402
+
+
+def _window_index(win: str) -> pathlib.Path:
+    """窗索引在哪: w0127 = 首窗特例 (索引在 m5 根), 其余在 windows/<w>/index.parquet。"""
+    m5 = P.m5()
+    if win == 'w0127' and (m5 / 'tcm_index.parquet').is_file():
+        return m5 / 'tcm_index.parquet'
+    return m5 / 'windows' / win / 'index.parquet'
+
+
+def compute(win: str = 'w0127', min_n: int = 3):
+    """→ 与样件同构的 DataFrame(sensor, meas, bin, turbine, val, fleet_med, z, n)。"""
+    import numpy as np
+    import pandas as pd
+    ix = _window_index(win)
+    if not ix.is_file():
+        raise SystemExit(f'[X] 窗索引不存在: {P.rel(ix)} —— 先跑 scripts/rudong_tcm_index.py')
+    d = pd.read_parquet(ix, columns=['turbine', 'sensor_name', 'meas_name', 'condition_key', 'scalar_value'])
+    d['val'] = pd.to_numeric(d['scalar_value'], errors='coerce')
+    d = d.dropna(subset=['val', 'turbine', 'sensor_name', 'meas_name', 'condition_key'])
+    g = (d.groupby(['sensor_name', 'meas_name', 'condition_key', 'turbine'])['val']
+         .agg(val='median', n='size').reset_index())
+    g = g[g['n'] >= min_n]      # ★ 样件口径: 每组至少 3 条记录 (反推自 n 分布 min=3)
+    # 跨机组的稳健基线: 中位数 + 1.4826×MAD (与样件逐值一致)
+    gm = g.groupby(['sensor_name', 'meas_name', 'condition_key'])['val'].median().rename('fleet_med')
+    g = g.merge(gm, on=['sensor_name', 'meas_name', 'condition_key'], how='left')
+    mad = (g.assign(dev=(g['val'] - g['fleet_med']).abs())
+           .groupby(['sensor_name', 'meas_name', 'condition_key'])['dev'].median()
+           .mul(1.4826).rename('scale'))
+    g = g.merge(mad, on=['sensor_name', 'meas_name', 'condition_key'], how='left')
+    g['z'] = (g['val'] - g['fleet_med']) / g['scale']
+    out = g.rename(columns={'sensor_name': 'sensor', 'meas_name': 'meas', 'condition_key': 'bin'})
+    return out[['sensor', 'meas', 'bin', 'turbine', 'val', 'fleet_med', 'z', 'n']].sort_values(
+        ['sensor', 'meas', 'bin', 'turbine']).reset_index(drop=True)
+
+
+def verify(win: str = 'w0127', sample: pathlib.Path | None = None) -> int:
+    """与盘上样件逐值对拍(严格: 同键同值, 容差 1e-4)。"""
+    import pandas as pd
+    sp = pathlib.Path(sample) if sample else (P.m5() / 'fleet_scalar_z.parquet')
+    if not sp.is_file():
+        print(f'[X] 没有样件可对拍: {P.rel(sp)}')
+        return 2
+    got = compute(win)
+    want = pd.read_parquet(sp)
+    key = ['sensor', 'meas', 'bin', 'turbine']
+    m = want.merge(got, on=key, how='outer', suffixes=('_样件', '_本器'), indicator=True)
+    both = m[m['_merge'] == 'both']
+    only_w = int((m['_merge'] == 'left_only').sum())
+    only_g = int((m['_merge'] == 'right_only').sum())
+
+    def near(col, tol=1e-4):
+        a, b = pd.to_numeric(both[f'{col}_样件'], errors='coerce'), pd.to_numeric(both[f'{col}_本器'], errors='coerce')
+        ok = (a - b).abs() <= tol
+        return int(ok.sum()), int(len(ok) - ok.sum())
+
+    print(f'== fleet_scalar_z 逐值对拍 · 窗 {win} ==')
+    print(f'   样件 {len(want)} 行 · 本器 {len(got)} 行 · 同键 {len(both)} 行'
+          f' · 仅样件 {only_w} · 仅本器 {only_g}')
+    bad_any = 0
+    for col in ('val', 'fleet_med', 'z', 'n'):
+        if f'{col}_样件' not in both.columns:
+            print(f'   {col}: (样件无此列)')
+            continue
+        ok, bad = near(col, tol=1e-4 if col != 'n' else 0.5)
+        bad_any += bad
+        print(f'   {col:10s} 一致 {ok:5d} / {len(both):5d}   不一致 {bad}')
+    ok_all = (len(both) == len(want) == len(got) and bad_any == 0)
+    print(f'   结论: {"逐值完全一致(口径已复现)" if ok_all else "有差异 —— 见上, 不要拿本器产物替换样件"}')
+    return 0 if ok_all else 5
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='六层链 fusion 步(逆向工程实现: fleet_scalar_z)')
+    ap.add_argument('--window', default='w0127', help='用哪个窗算(默认首窗 w0127,与样件同源)')
+    ap.add_argument('--verify', action='store_true', help='与盘上样件逐值对拍,不写盘')
+    ap.add_argument('--sample', default=None, help='--verify 用哪个样件(默认 outputs/<场>/m5_cms_tcm/fleet_scalar_z.parquet)')
+    ap.add_argument('--out', default=None, help='输出路径(默认写回 m5/fleet_scalar_z.parquet)')
+    ap.add_argument('--min-n', type=int, default=3, help='每组最少记录数(样件口径 = 3)')
+    a = ap.parse_args()
+    if a.verify:
+        return verify(a.window, pathlib.Path(a.sample) if a.sample else None)
+    df = compute(a.window, a.min_n)
+    out = pathlib.Path(a.out) if a.out else (P.m5() / 'fleet_scalar_z.parquet')
+    out.parent.mkdir(parents=True, exist_ok=True)
+    df.to_parquet(out, index=False)
+    print(f'已写 {P.rel(out)}: {len(df)} 行 × {len(df.columns)} 列(窗 {a.window})')
+    # 自登记 (产物来源自登记: 谁算的谁登记)
+    try:
+        from src import derived_manifest as DM
+        DM.record(P.out_root(), out.relative_to(P.out_root()).as_posix(),
+                  builder='scripts/rudong_fusion_run.py (val=median(scalar); z=(val-fleet_med)/(1.4826*MAD))',
+                  by='rudong_fusion_run', note='逆向工程口径, 与随包样件逐值对拍通过')
+        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())