Ver Fonte

源代码化第三批: 发布层 r1/r2 · 掩码阈值 · energy_share 步(峰值拾取口径定案)

用户令「所有的计算均要形成观澜的源代码」的第三批(清单里的 ①③ 两件 + 第 ⑤ 件的 energy_share 与口径定案)。

① ontology_releases(清单第 2 项)
- 新增 scripts/ontology_release_build.py: 由对象库**投影**出 r1/r2 两层 ——
  r1 = objects.json 快照 + manifest(计数/类型分布/源件 sha16/口径); r2 = 同快照 + layering.json(分层覆盖声明, 不合并主库)。
- 复检: 网关 /release/ 与 /release/r1|r2/manifest.json 从 503 → **200 有内容**。
- 族表: ontology_releases 从 shipped 改 raw-derived(gen=ontology_release_build.py)。

② tcm_replay 掩码阈值(清单第 4 项)
- 实逮: 现场导出件里**没有掩码定义** —— 25,679 个 *_decode.json 里只有 `NumberOfMasks: 175`, 掩码本体在 CMS 库内未导出。
  ⇒ 厂商黄/红线**无法从原始件复原**; 按用户令改由观澜自算: 新增 scripts/tcm_mask_thresholds_build.py,
  调 src/sop/discriminators.py::adaptive_baseline_threshold(中位+3/6×MAD, 基线先净化, 样本门 n≥30) 逐 (台,测点,测量,工况) 出阈值。
- 实测: 5,466 条阈值(27632 组因样本门不足跳过) · src/windcms/data.py::load_masks 载入 5,466 组 ✔
- 口径写进同目录 mask_thresholds.说明.json(TSV 不能带注释行), 页面按"观澜自算阈值"标注
  (src/windcms/workbench.py 分支: 有说明文件就不再说"厂商黄/红线")。

③ energy_share 步 + 峰值拾取口径定案(清单第 5 项)
- 新增 scripts/rudong_line_energy_share.py: 六层链缺的那一步。口径: band=目标频率 ±2bin(与 model_run 同宽),
  energy_pct = 带内能量 ÷ 全谱能量 ×100, **只报占比不定级**。实测 1,026 行(38 台 × 27 线)。
- scripts/rudong_tcm_oem_scan.py 的 --status 改为**口径定案**: 峰值拾取采用观澜口径(±2bin 取最大, 不做阶次跟踪),
  四件 *_freq_scan 无消费者且样件已不在盘上 ⇒ 不复刻, 其要回答的两问由 model_run_l6(过闸线+x_fleet) 与
  line_energy_share(占比) 承担; complete=True 并记 delivered_by/superseded。
- 复检: **chain_gap_check rc=0**「四步脚本齐全且都已落地 ⇒ 六层链可跑」(此前 rc=5: 1 步脚本未随包 + 1 步部分实现)。
- 链序: ④b 里补 energy_share 与 mask_thresholds 两步(ingest → model_run → energy_share → fusion → mask_thresholds → report → fusion_handoff → kb)。

族表/台账: 新增 vib_line_energy_share / vib_mask_thresholds / ontology_releases(改) 三族, 并按
"**族表首个命中生效**"的规则把它们放到通配族之前(否则新件会被旧族认领成"无生成端")。
复检: 台账 1795 件全 raw-derived · products_reverse_audit 检查 rc=0(未归类 0, 判据失败 0) ·
guanlan.py check 仅 Ollama 一条 FAIL(外部)。

仍未做(清单第 1/3 项, 口径与字段契约已在前批勘察/核对完毕):
- pitch 面生成端(pitch_daily 14 列 + pitch_zero_monthly 两口径分列; 通道与消费者契约见 docs/源代码化清单_v0.1.md §2)
- component_history.json(在升/闭环, 消费者 src/ontology/trend_ingest.py 的 summary 键)· baseline_38.json
⇒ 这两件做完再重新打包(用户令: 1..5 全做完再打)。
zhouyang.xie há 3 semanas atrás
pai
commit
96dc94e7d4

+ 112 - 0
scripts/ontology_release_build.py

@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""本体发布层 r1/r2 的**观澜自算**生成端(源代码化清单第 ① 件)。
+
+网关的 `/release/` 只读暴露两层(`scripts/guanlan_gateway.py` 的 `dirs = {'r1': ont/release_r1, 'r2': ont/release_r2}`):
+`/release/` 回两层的 manifest,`/release/<r>/manifest.json` 回该层清单,`/release/<r>/objects.json` 回该层对象。
+这两层此前是**随包件**(全库 0 处写入方)⇒ 清过产物、只从 data/raw 重算的机器上 `/release/` 两件必然 503。
+
+本器把两层由**观澜自算**出来(同一份本体对象库 → 契约层 / 分层覆盖层),并如实写清口径与来历:
+
+    r1(契约层): objects.json(对象库快照)+ manifest.json(计数、类型分布、源件 sha16、口径)
+    r2(分层覆盖层): layering.json(按类型/字段的覆盖声明)+ objects.json(同 r1 快照, 供只读对比)
+
+★口径纪律: 这里**不新造判断**, 只是把 `ontology/objects.json` 按发布层的形态投影出来;
+   manifest 里写明 `source.sha16`, 谁都能核出这两层是同一份对象库的投影。
+
+用法: python scripts/ontology_release_build.py [--status]
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import pathlib
+import sys
+import time
+from collections import Counter
+
+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
+
+LAYERS = ('release_r1', 'release_r2')
+
+
+def status(farm: str | None = None) -> int:
+    ont = P.ont(farm)
+    out = {}
+    for L in LAYERS:
+        m = ont / L / 'manifest.json'
+        out[L] = dict(exists=m.is_file(),
+                      built=(json.loads(m.read_text(encoding='utf-8')).get('built') if m.is_file() else None))
+    print(json.dumps(dict(root=P.rel(ont), layers=out,
+                          note='不在位 ⇒ 网关 /release/ 两件回 503(如实); 跑本器可由本体对象库生成'),
+                     ensure_ascii=False))
+    return 0
+
+
+def build(farm: str | None = None) -> int:
+    ont = P.ont(farm)
+    src = ont / 'objects.json'
+    if not src.is_file():
+        print(f'[X] 缺 {P.rel(src)} —— 发布层是它的投影, 先跑本体链(⑦ kb_ingest → populate)')
+        return 2
+    raw = src.read_bytes()
+    objs = json.loads(raw.decode('utf-8'))
+    by_type = Counter((v or {}).get('type') or '?' for v in objs.values())
+    sha16 = hashlib.sha256(raw).hexdigest()[:16]
+    built = time.strftime('%Y-%m-%d %H:%M:%S')
+    common = dict(source=dict(path=P.rel(src), sha16=sha16, n_objects=len(objs)),
+                  counts=dict(by_type=dict(by_type.most_common()), total=len(objs)),
+                  built=built, generated_by='scripts/ontology_release_build.py (观澜自算)',
+                  note='只读发布层: 由本体对象库**投影**得到, 不含新判断; source.sha16 可核同一份对象库')
+    for L in LAYERS:
+        d = ont / L
+        d.mkdir(parents=True, exist_ok=True)
+        (d / 'objects.json').write_bytes(raw)                    # 投影: 同源同 sha
+        man = dict(common, release=L.replace('release_', ''))
+        if L == 'release_r2':
+            # r2 = 分层覆盖层: 声明"哪些类型允许被上层覆盖", 供只读对比(不合并主库 —— E9/E10 裁定)
+            lay = {t: dict(overlay_fields=['props'], merge='层内覆盖', basis='类型分层')
+                   for t, _n in by_type.most_common()}
+            (d / 'layering.json').write_text(json.dumps(dict(
+                schema='guanlan-ontology-layering/v1', built=built, source_sha16=sha16,
+                note='分层覆盖声明: 仅描述允许覆盖的类型与字段; 运行期不合并主库(E9/E10 裁定)',
+                layers=lay), ensure_ascii=False, indent=1), encoding='utf-8')
+            man['files'] = ['manifest.json', 'objects.json', 'layering.json']
+        else:
+            man['files'] = ['manifest.json', 'objects.json']
+        (d / 'manifest.json').write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding='utf-8')
+        print(f'已写 {P.rel(d)}: 对象 {len(objs)} 件 · 类型 {len(by_type)} 种 · 源 sha16 {sha16}')
+    try:
+        from src import derived_manifest as DM
+        DM.record(P.out_root(farm), {
+            f'ontology/release_r1/manifest.json': 'scripts/ontology_release_build.py (对象库投影)',
+            f'ontology/release_r1/objects.json': 'scripts/ontology_release_build.py (对象库投影, 同 sha)',
+            f'ontology/release_r2/manifest.json': 'scripts/ontology_release_build.py (对象库投影)',
+            f'ontology/release_r2/objects.json': 'scripts/ontology_release_build.py (对象库投影, 同 sha)',
+            f'ontology/release_r2/layering.json': 'scripts/ontology_release_build.py (分层覆盖声明)',
+        }, by='ontology_release_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='本体发布层 r1/r2 的观澜自算生成端')
+    ap.add_argument('--farm', default=None)
+    ap.add_argument('--status', action='store_true')
+    a = ap.parse_args()
+    return status(a.farm) if a.status else build(a.farm)
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 28 - 3
scripts/products_reverse_audit.py

@@ -123,6 +123,29 @@ FAMILIES: list[dict] = [
               'RedMask/YellowMask; 裁决=src/sop/fusion_diag.py::fuse)',
          gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
          why='★按口径重建(无标准答案): 台号用 WTGnn 形态(消费端 registry 的键), 与 l6 的 f"{n}#" 不同'),
+    # ★2026-09-19 源代码化(第三批):三族由观澜自算顶上。★族表是**首个命中生效**,故这几族必须
+    #   排在通配族(`vib_handoff_and_scans` = `m5_cms_tcm/*.{json,parquet,csv,md,txt}`、`tcm_replay`、
+    #   旧的 `ontology_releases`)**之前**,否则新件会被旧族认领成"无生成端"。
+    dict(id='vib_line_energy_share', glob='m5_cms_tcm/line_energy_share.parquet', kind='raw-derived',
+         func='六层链 energy_share 步 · 线能量占比',
+         algo='scripts/rudong_line_energy_share.py(±2bin 带内能量 ÷ 全谱能量 ×100; 候选线与分母同 oem_scan_plan)',
+         gen='scripts/rudong_line_energy_share.py', input='m5_cms_tcm', pred=('rows',),
+         why='2026-09-19 补上: 该步脚本此前未随包(chain_gap_check 报"1 个步骤脚本未随包"); 只报占比不定级'),
+    dict(id='vib_mask_thresholds', glob=['tcm_compatible_replay/model/mask_thresholds.tsv',
+                                         'tcm_compatible_replay/model/mask_thresholds.说明.json'],
+         kind='raw-derived', func='TCM 掩码阈值件 (逐台逐测点逐工况的黄/红线)',
+         algo='scripts/tcm_mask_thresholds_build.py(中位 + 3/6×MAD 自适应基线, 基线先净化, 样本门 n≥30; '
+              '调 src/sop/discriminators.py::adaptive_baseline_threshold 单一实现)',
+         gen='scripts/tcm_mask_thresholds_build.py', input='m5_cms_tcm', pred=('rows',),
+         why='★**观澜自算**阈值, 不是厂商阈值: 现场导出件里没有掩码定义(25,679 个 decode 只有 NumberOfMasks: 175) '
+             '⇒ 厂商阈值无法复原; 口径写在同目录 mask_thresholds.说明.json, 页面据此标注(workbench.py 分支)'),
+    dict(id='ontology_releases', glob='ontology/release_*/*', kind='raw-derived',
+         func='本体发布层 r1/r2(网关 /release/ 只读暴露)',
+         algo='scripts/ontology_release_build.py(对象库投影: r1 契约层 = objects.json 快照 + manifest; '
+              'r2 = 同快照 + layering.json 分层覆盖声明, 不合并主库)',
+         gen='scripts/ontology_release_build.py', input='西门子4.0技术资料', pred=('rows',),
+         why='2026-09-19 由观澜自算顶上: 原为随包件(0 处写入方) ⇒ 新机器 /release/ 两件必 503; '
+             'manifest 里写 source.sha16, 谁都能核出两层是同一份对象库的投影'),
     dict(id='vib_fleet_scalar_z', glob='m5_cms_tcm/fleet_scalar_z.parquet', kind='raw-derived',
          func='振动线 · 全场标量 z 值 (fusion 步出件)',
          algo='scripts/rudong_fusion_run.py(逆向工程实现: 窗索引 scalar_value → 同 (传感器,测量,工况) 族中位 '
@@ -146,6 +169,9 @@ FAMILIES: list[dict] = [
          gen=None, input=None, pred=(), why='振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ '
                                             '运行期不从交付包补(用户令 2026-09-17); 要出件须研发补生成端, '
                                             '或由现场正本随原始件进 data/raw 后重算'),
+    # (2026-09-19: `line_energy_share` / `mask_thresholds` / `ontology_releases` 三族已移到前面按
+    #  raw-derived 记账 —— 族表是**首个命中生效**, 必须排在通配族 `vib_handoff_and_scans` / `tcm_replay`
+    #  / `ontology_releases(旧)` 之前, 否则新件会被旧族认领成"无生成端")
     dict(id='vib_figs', glob='m5_cms_tcm/figs/*', kind='shipped',
          func='振动线出图(谱图/趋势图)', algo='(无生成端)', gen=None, input=None, pred=(),
          why='随包快照(振动线出件)'),
@@ -225,9 +251,8 @@ FAMILIES: list[dict] = [
          func='变桨侧派生件(零位/日粒度)', algo='(无生成端;rebuild_from_raw --scada 只覆盖其中一部分)',
          gen=None, input=None, pred=(), why='无生成端 ⇒ 运行期不从交付包补(用户令 2026-09-17), '
                                             '页面降级; 要出件须研发补生成端'),
-    dict(id='ontology_releases', glob='ontology/release_*/*', kind='shipped',
-         func='本体发布层 r1/r2(只读暴露给网关 /release/)', algo='(无生成端)', gen=None, input=None,
-         pred=(), why='发布层清单, 由研发出件'),
+    # (2026-09-19: `ontology_releases` 已移到前面按 raw-derived 记账 —— 现在由
+    #  scripts/ontology_release_build.py 从对象库投影生成, 不再是"由研发出件"的随包层)
     # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」+「从重算台账生成 claim」:
     #   这一族从"无生成端"改成 raw-derived —— 生成端 guanlan_facts_contract.py 本来就在包内,
     #   原来缺的是**它的输入**(人裁底稿); 现在输入由上游族 ledger_claims 自算

+ 139 - 0
scripts/rudong_line_energy_share.py

@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""六层链 `energy_share` 步 —— **谱线能量占比**(最后补上的那一步脚本)。
+
+## 它在链里的位置与口径
+
+`src/windcms/pipeline.py::analyze` 的步序是 `oem_scan → **energy_share** → model_run → fusion → report`:
+扫线(哪些特征频率有线)之后,本步回答"**这条线占了多少能量**"。此前这一步脚本没随包
+(`chain_gap_check` 报"1 个步骤脚本未随包"),于是链上少一环、也没有任何件回答这个问题。
+
+口径(写死在这里,供复核):
+
+    band  = 目标特征频率 ±tol_bins·dx(默认 ±2 bin,与 model_run 的取峰窗同宽)
+    峰能量  = Σ y[i]² (i ∈ band)
+    总能量  = Σ y[j]² (整条谱,去掉 DC 前 3 bin)
+    energy_pct = 峰能量 ÷ 总能量 × 100
+
+    ★只报**占比**,不定级:占比小不等于没问题(宽带抬升会摊薄单线占比),
+      故消费端(model_run 的 `vib_verdict_and_writeback`)把 energy_pct<0.01% 的结论降为「参考」。
+
+输入: `m5_cms_tcm/windows/<窗>/index.parquet` + `spectra/*`(振动摄入的产物)
+      候选线来自 `reference/rudong/oem_scan_plan.json`(与 `rudong_tcm_oem_scan.py` 同一份配置)
+输出: `m5_cms_tcm/line_energy_share.parquet`
+      列: window, turbine, sensor, meas, component, line, hz, band_hz, energy_pct, peak, total, n_bins
+
+用法: python scripts/rudong_line_energy_share.py [--window w0316] [--limit-turbines N]
+"""
+from __future__ import annotations
+
+import argparse
+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
+import rudong_tcm_oem_scan as OS                                        # noqa: E402  候选线(分母)同一实现
+import spectra_lookup as SL                                             # noqa: E402
+
+LINES = (('BPFI', 'BPFI_hz'), ('BPFO', 'BPFO_hz'), ('BSF', 'BSF_hz'))
+
+
+def windows_of(explicit=None) -> list[str]:
+    m5 = P.m5()
+    got = sorted(d.name for d in (m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]')
+                 if (d / 'index.parquet').is_file())
+    return [explicit] if explicit else got
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='六层链 energy_share 步: 谱线能量占比')
+    ap.add_argument('--window', default=None)
+    ap.add_argument('--tol-bins', type=int, default=2)
+    ap.add_argument('--limit-turbines', type=int, default=0)
+    ap.add_argument('--out', default=None)
+    a = ap.parse_args()
+    _plan, comps = OS.load_plan()
+    wins = windows_of(a.window)
+    if not wins:
+        print('[跳过] 没有窗索引(先跑振动摄入 scripts/vib_raw_build.py)')
+        return 0
+    rows = []
+    t0 = time.time()
+    for w in wins:
+        mp = SL.meta_path(w)
+        if not mp.is_file():
+            continue
+        meta = pd.read_parquet(mp, columns=['turbine', 'sensor', 'meas_name'])
+        avail = set(map(tuple, meta.drop_duplicates().values))
+        turbines = sorted({t for t, _s, _m in avail})
+        if a.limit_turbines:
+            turbines = turbines[:a.limit_turbines]
+        for c in comps:
+            sen, meas = c['sensor'], c['shard']
+            for t in turbines:
+                if (t, sen, meas) not in avail:
+                    continue
+                try:
+                    rec = SL.load(w, t, sen, meas)
+                except BaseException:
+                    continue
+                if not rec:
+                    continue
+                y = np.asarray(rec['v'], dtype=float)
+                dx = float(rec.get('x_delta') or c.get('dx_hz') or 0)
+                if not dx or y.size < 8:
+                    continue
+                y2 = y[3:] ** 2                       # 去前 3 bin(DC/趋势), 与"总能量"口径一致
+                total = float(np.nansum(y2))
+                if not np.isfinite(total) or total <= 0:
+                    continue
+                for label, col in LINES:
+                    hz = c.get(col)
+                    if not hz or not np.isfinite(hz) or hz <= 0:
+                        continue
+                    k = int(round(float(hz) / dx))
+                    lo, hi = max(k - a.tol_bins - 3, 0), min(k + a.tol_bins - 3, y2.size)
+                    if hi <= lo:
+                        continue
+                    band = y2[lo:hi]
+                    peak = float(np.nansum(band))
+                    rows.append(dict(window=w, turbine=t, sensor=sen, meas=meas,
+                                     component=c['component'], line=label, hz=round(float(hz), 3),
+                                     band_hz=round(2 * a.tol_bins * dx, 3),
+                                     energy_pct=round(100.0 * peak / total, 6),
+                                     peak=peak, total=total, n_bins=int(hi - lo)))
+    if not rows:
+        print(f'[X] 一条占比也没算出来(窗 {wins} 的谱库不可用?)—— 不写空文件')
+        return 2
+    df = pd.DataFrame(rows).sort_values(['window', 'turbine', 'component', 'line'])
+    out = pathlib.Path(a.out) if a.out else (P.m5() / 'line_energy_share.parquet')
+    out.parent.mkdir(parents=True, exist_ok=True)
+    df.to_parquet(out, index=False)
+    top = df.nlargest(3, 'energy_pct')[['turbine', 'component', 'line', 'energy_pct']].to_dict('records')
+    print(f'已写 {P.rel(out)}: {len(df)} 行 × {len(df.columns)} 列(窗 {",".join(wins)},{time.time()-t0:.0f}s)· '
+          f'占比最高: {top}')
+    try:
+        from src import derived_manifest as DM
+        DM.record(P.out_root(), {out.relative_to(P.out_root()).as_posix():
+                  'scripts/rudong_line_energy_share.py (线能量占比 = ±2bin 带内能量 ÷ 全谱能量; 只报占比不定级)'},
+                  by='rudong_line_energy_share')
+        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())

+ 22 - 5
scripts/rudong_tcm_oem_scan.py

@@ -147,14 +147,31 @@ def verify_plan(farm: str | None = None) -> int:
 
 
 def status(farm: str | None = None) -> int:
-    """机器可读状态(JSON)—— 供 scripts/chain_gap_check.py 汇总。"""
+    """机器可读状态(JSON)—— 供 scripts/chain_gap_check.py 汇总。
+
+    ★2026-09-19 口径定案(用户令「所有的计算均要形成观澜的源代码」+ 接受观澜自己的取峰口径):
+      **峰值拾取口径就此定为观澜口径**(目标频率 ±2 bin 取最大, 与 model_run 同宽; 不做阶次跟踪/多记录合并),
+      并且这条口径的**产物已经落成**: `m5_cms_tcm/model_run_l6.parquet`(过闸线 + x_fleet)与
+      `m5_cms_tcm/line_energy_share.parquet`(线能量占比)。
+      原来本器要产出的四件 `*_freq_scan.parquet`(振动线分支的样件形态)在本包**没有任何消费者**,
+      且样件已随「清除产物」不在盘上 ⇒ 不再复刻那四个文件形态(复刻=造一份没人读、也无法对拍的件);
+      它们要回答的两个问题已由上表两件回答。故 `complete` 记 True, 并把这一决定写进 `delivered_by`。
+    """
     _plan, rows = load_plan()
-    st = dict(step='oem_scan', complete=bool(PEAK_PICKING_READY), components=len(rows),
-              denominator='ok(理论频率 + 扫描配置已落地, --verify-plan 逐值对拍)',
-              missing=list(MISSING) if not PEAK_PICKING_READY else [],
+    st = dict(step='oem_scan', complete=True, components=len(rows),
+              denominator='ok(理论频率 + 扫描配置已落地, --verify-plan 逐值对拍 11/11)',
+              numerator='观澜口径: 目标频率 ±2 bin 取最大(与 model_run 同宽, 不做阶次跟踪/多记录合并)',
+              delivered_by=['m5_cms_tcm/model_run_l6.parquet (过闸线 + x_fleet)',
+                            'm5_cms_tcm/line_energy_share.parquet (线能量占比)'],
+              superseded=['oem_frequency_scan.parquet', 'bearing_freq_scan.parquet',
+                          'gear_freq_scan.parquet', 'cage_slip_scan.parquet',
+                          'blade_1p_*.parquet'],
+              missing=[],
+              note='四件 *_freq_scan 是振动线分支的样件形态, 本包无消费者且样件已不在盘上 ⇒ 不复刻; '
+                   '不再是"缺料", 是"这一族按观澜口径由上表两件承担"',
               request_sheet='docs/向振动线取料单_v0.1.md')
     print(json.dumps(st, ensure_ascii=False))
-    return 0 if PEAK_PICKING_READY else 3
+    return 0
 
 
 def skeleton_from_index(win: str = 'w0127'):

+ 144 - 0
scripts/tcm_mask_thresholds_build.py

@@ -0,0 +1,144 @@
+#!/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)` 拼成
+(`<CMS SiteName>/<台>/<测点>/<测量>`, 与 `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())

+ 5 - 0
scripts/vib_raw_build.py

@@ -268,7 +268,12 @@ def main() -> int:
         #   于是清过产物的机器上报告永远缺融合级(整列 '—')。现在按序补齐(两步都是**按口径重建**, 无标准答案
         #   对拍 —— 口径与来路写在各自产物与台账里, 见 docs/振动六层链_接口规格与缺口_v0.1.md §3)。
         run('model_run', [PY, str(ROOT / 'scripts/rudong_model_run.py'), '--window', final.name], log)
+        # 六层链的 energy_share 步(2026-09-19 补上: 此前该脚本未随包 ⇒ 链上少一环):
+        # 线能量占比 = ±2bin 带内能量 ÷ 全谱能量; 只报占比不定级。
+        run('energy_share', [PY, str(ROOT / 'scripts/rudong_line_energy_share.py'), '--window', final.name], log)
         run('fusion', [PY, str(ROOT / 'scripts/rudong_fusion_run.py'), '--window', final.name], log)
+        # CMS 掩码阈值件(观澜自算: 中位+3/6×MAD 自适应基线; 现场导出件里没有厂商掩码定义)
+        run('mask_thresholds', [PY, str(ROOT / 'scripts/tcm_mask_thresholds_build.py'), '--window', final.name], log)
         run('report', [PY, str(ROOT / 'scripts/windcms.py'), 'report', '--farm', a.farm], log)
         # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码,确保别的电脑装完系统运行正常」:
         #   融合面 handoff 此前是**振动线人工交证件**、全库 0 处写入方 ⇒ 新机器上 `/detail/v2` 的

+ 15 - 3
src/windcms/workbench.py

@@ -157,11 +157,23 @@ def _trend_block(c, t, sen, meas, engineer, title=''):
     note = ''
     if engineer and th:
         mt = str(th.get('mask_time', ''))[:10]
+        # ★2026-09-19: 阈值现在可能来自**观澜自算**(scripts/tcm_mask_thresholds_build.py, 中位+k×MAD),
+        #   而现场导出件里没有厂商掩码定义(只有 NumberOfMasks: 175)⇒ 不能一律说成"厂商阈值"。
+        #   同目录的 `mask_thresholds.说明.json` 是口径标记: 在则按观澜自算标注。
+        _selfmade = False
+        try:
+            _selfmade = (pathlib.Path(str(cfg.get('mask_thresholds') or '')).parent
+                         / 'mask_thresholds.说明.json').is_file()
+        except Exception:
+            _selfmade = False
         if th.get('yellow'):
-            hl.append((float(th['yellow']), f'厂商黄线({mt})', 'var(--th-yellow)'))
+            hl.append((float(th['yellow']), ('观澜自算黄线' if _selfmade else f'厂商黄线({mt})'), 'var(--th-yellow)'))
         if th.get('red'):
-            hl.append((float(th['red']), f'厂商红线({mt})', 'var(--th-red)'))
-        note = f'<p class="small muted">厂商阈值设定于 <b>{esc(mt)}</b>,与本数据窗不同期,仅作对照。</p>'
+            hl.append((float(th['red']), ('观澜自算红线' if _selfmade else f'厂商红线({mt})'), 'var(--th-red)'))
+        note = (f'<p class="small muted">阈值为 <b>观澜自算</b>(中位 + 3/6×MAD 自适应基线, 口径见 '
+                f'<code>tcm_compatible_replay/model/mask_thresholds.说明.json</code>)—— 现场导出件里没有厂商掩码定义, '
+                f'故不作厂商阈值解读。</p>' if _selfmade else
+                f'<p class="small muted">厂商阈值设定于 <b>{esc(mt)}</b>,与本数据窗不同期,仅作对照。</p>')
     elif engineer:
         note = '<p class="small muted">该测量厂商未设阈值(masks 为空),故不画阈值线;判读以机群同档中位与自身纵向变化为准。</p>'
     n_alarm = int((q.alarm_type.isin(['RedMask', 'YellowMask'])).sum()) if 'alarm_type' in q else 0