Forráskód Böngészése

修: 清产物后 /detail 与 /cms 打不开 —— windcms 无产物时自毁(空表+结构化无产物页)、pitch/taxonomy/fusion 缺无生成端产物时响亮降级(不再打断重算链)、开箱验证改副本整套换端口(不再停用户服务)、运维动作与启动器日志归一格式; docs §7/§11.4 + 变更记录

zhouyang.xie 3 hete
szülő
commit
cdeff11380

+ 2 - 0
README_先读我.MD

@@ -35,6 +35,8 @@
   一个动作, 页面上有进度/退出码/实时日志尾巴。重算进行中按钮按设计禁用 —— 那不是坏了。
   ★ 清除产物 = **直接删除, 不留备份**(用户令 2026-09-16; 旧的 --on 还原已移除), 不动 data/raw、release;
     要补回"包内没有生成端"的随包件: <PY> scripts/products_restore_missing.py --stash <交付包.zip>
+  ★ 清完产物后页面会显示"无产物"(工作台左栏/CMS 页都会给出"缺哪件、怎么补"的说明, **不会崩**) ——
+    跑一次第四节的重算即恢复; 若某页仍打不开, 先看 /healthz 与 logs/<组件>.log 尾部。
 
 四、放数据后重算 (一条命令; 控制台点"执行重算"等价)
   <PY> scripts/raw_data_check.py --src <现场包目录>    ★ 先体检: 结构/命名/增量(新增·跳过·冲突) + 放置指导

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 4 - 0
docs/系统设计说明.md


+ 11 - 0
scripts/_ops_run.py

@@ -52,6 +52,12 @@ def main() -> int:
     if not cmd:
         print('[X] 没给命令'); return 2
 
+    # 统一日志口径 (用户令 2 / docs §11.2): 运维动作的输出也走 "时间戳 级别 组件 消息"。
+    # 2026-09-17 实测: 动作日志(products_off/rebuild)是**裸命令输出**, 与 logs/<组件>.log 的格式不一致,
+    # log_audit 会如实报"末尾 N 行都不符合统一格式" —— 这里挂上流包装, 一处解决所有动作日志。
+    from src import logfile as _lf
+    _lf.prefix_stdout(f'ops.{a.tag}')
+
     job = {}
     try:
         job = json.loads(JOB.read_text(encoding='utf-8'))
@@ -81,6 +87,11 @@ def main() -> int:
     job.update(status='done', rc=rc, seconds=round(dt, 1), finished=time.strftime('%Y-%m-%d %H:%M:%S'))
     _write(job)
     print(f'\n[ops] {a.tag} 结束: 退出码 {rc}, 耗时 {dt:.1f}s', flush=True)
+    # 子进程直接写 fd, 绕过了流包装 ⇒ 动作结束时把裸输出补成统一格式 (见 logfile.normalize_file)
+    try:
+        _lf.normalize_file(pathlib.Path(str(job.get('log') or '')), f'ops.{a.tag}')
+    except Exception:
+        pass
     return rc
 
 

+ 38 - 14
scripts/check_transferable.py

@@ -235,26 +235,45 @@ def install_in(dst: pathlib.Path) -> pathlib.Path | None:
 
 
 def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
-    """在副本里起服务并核验页面 —— **不动正在跑的实例**: 网关改用空闲端口, 组件端口若被占则跳过。
-
-    原先网关端口被占就整段跳过(实测踩到: 主实例在跑 → 模拟移植只做了拷贝+安装)。现在:
-      · 组件端口(18033 等)空闲 → 用它们; 网关端口(28084)被占 → 换成空闲端口(如 28700);
-      · 网关 ROUTES 里上游端口是写死的(文档 §三 的已知限制), 所以组件端口**必须**用默认值, 不能随网关一起改。
+    """在副本里起服务并核验页面 —— **完全不动正在跑的实例**: 副本整套换端口跑。
+
+    ★ 2026-09-17 改法 (现场事故驱动): 以前网关 ROUTES 的上游端口是写死的, 所以副本**必须**占用
+    18033/18020/18791/18792/64292 —— 于是"开箱验证"就得先把用户正在跑的服务停掉; 实测后果:
+    用户在验证窗口里点门户「登录」, `/detail/` 报"模块未启动 (127.0.0.1:18033)"。
+    现在 `guanlan_gateway.ROUTES` 的上游端口可被 `configs/serve.json` 覆盖, 于是副本可以**整套**
+    换到空闲端口 (网关 + 5 个组件), 主实例照常跑, 谁也不打扰谁。
+    端口占用仍会兜底: 找不到空闲端口就跳过启动核验 (不计失败)。
     """
-    comp_ports = (18033, 18020, 18791, 18792, 64292)
-    busy_comp = [p for p in comp_ports if _busy(p)]
-    if busy_comp:
-        print(f'   [!] 组件端口被占用 {busy_comp} —— 跳过启动核验(先停掉正在跑的实例再试)')
-        return 0
+    comp_keys = ('detail', 'cms', 'sim', 'sim_sys', 'viewer')
+    cfg_p = dst / 'configs' / P.SERVE_JSON.name          # config-path-allow: 改的是**副本**里的那份配置
+    try:
+        cfg = json.loads(cfg_p.read_text(encoding='utf-8-sig'))
+    except Exception as e:
+        print(f'   [!] 副本配置读不出来 ({type(e).__name__}) —— 跳过启动核验'); return 0
+
+    # ① 主实例在不在: 决定要不要给副本换端口, 以及能否做"门户同字节"比对
+    main_up = not _busy(base_port)
     gw = base_port if not _busy(base_port) else _free_port(28700)
     if gw is None:
         print('   [!] 找不到空闲网关端口 —— 跳过启动核验'); return 0
+    changed = {}
     if gw != base_port:
-        cfg_p = dst / 'configs' / P.SERVE_JSON.name      # config-path-allow: 改的是**副本**里的那份配置
-        cfg = json.loads(cfg_p.read_text(encoding='utf-8-sig'))
-        cfg['gateway'] = gw
+        changed['gateway'] = gw
+    for k in comp_keys:
+        want = int(cfg.get(k) or 0)
+        if want and _busy(want):
+            free = _free_port(28800 + 100 * (len(changed) % 5))
+            if free is None:
+                print(f'   [!] 找不到空闲端口给组件 {k} —— 跳过启动核验'); return 0
+            changed[k] = free
+    if changed:
+        cfg.update(changed)
         cfg_p.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding='utf-8')
-        print(f'   网关端口 {base_port} 已被占用 → 副本改用 {gw} (不打扰正在跑的实例)')
+        print('   副本改用独立端口 (不打扰正在跑的实例): ' +
+              ', '.join(f'{k}={v}' for k, v in changed.items()))
+    else:
+        print('   主实例未在跑 → 副本直接用默认端口')
+
     t0 = time.time()
     rs = subprocess.run([str(py), 'guanlan.py', 'serve'], cwd=str(dst), capture_output=True, text=True, errors='replace')
     print(f'   serve 退出码 {rs.returncode} (1 = 有模块未就绪, 属正常), 耗时 {time.time() - t0:.0f}s')
@@ -276,12 +295,17 @@ def run_and_probe(dst: pathlib.Path, py: pathlib.Path, base_port=28084) -> int:
                 with urllib.request.urlopen(f'http://127.0.0.1:{gw}{path}', timeout=30) as rr:
                     body = rr.read()
                     tag = '' if exp_size is None else (' 与主包同字节' if len(body) == exp_size else f' ★与主包不同(主包 {exp_size})')
+                    if exp_size is not None and len(body) != exp_size:
+                        # 副本换了网关端口时, 门户里的绝对地址(127.0.0.1:<端口>)会被网关改写成副本自己的
+                        # 端口 ⇒ 字节数**必然**不同。这是预期, 不是移植问题 (2026-09-17 加这句, 免得看错)。
+                        tag += f' ← 副本网关端口 {gw} ≠ 主实例 {base_port}, 门户内的绝对地址被改写, 字节不同属预期'
                     print(f'   {path:10s} HTTP {rr.status}  {len(body):,} B{tag}')
                     ok += 1
             except Exception as e:
                 print(f'   {path:10s} 失败: {type(e).__name__} {e}')
         print(f'   页面可用 {ok}/{len(urls)}')
     finally:
+        # 只停**副本**自己起的那些 (用副本里的 pids.json), 绝不碰主实例
         subprocess.run([str(py), 'guanlan.py', 'stop'], cwd=str(dst), capture_output=True)
     return ok
 

+ 23 - 1
scripts/guanlan_gateway.py

@@ -24,7 +24,11 @@ MANIFEST = P.store() / "release-manifest.json"
 CONTRACT = P.guanlan() / "facts_contract_v0.json"
 OLLAMA = ("127.0.0.1", int(os.environ.get("GUANLAN_OLLAMA_PORT", "11434")))  # 验收用: 指向死端口即可模拟 "本机模型未启动" (不必真停 Ollama)
 # prefix → (upstream port, 上游路径映射: 前缀去掉后剩 "" 时用的默认路径, 探活路径, 名称)
-ROUTES = [
+# ★ 2026-09-17 (用户令 2「端口单一真源」+ 开箱验证不再打扰运行中的实例):
+#   上游端口默认取下面这张表, 但**可被 `configs/serve.json` 覆盖** —— 于是"整套服务换个端口跑"
+#   成了配置能表达的事(以前是硬编码, `check_transferable.run_and_probe` 因此**必须**要求默认端口空闲,
+#   也就必须先把用户正在跑的服务停掉; 现场实测后果: 用户在验证窗口里点门户「登录」→ /detail/ 报"模块未启动")。
+_DEFAULT_ROUTES = [
     ("/detail", 18033, "/v2", "/v2", "综合详细分析工作台"),
     ("/cms", 18020, "/?theme=light", "/", "CMS 振动诊断"),
     ("/sim/sys", 18792, "/0_四系统合页.html", "/0_四系统合页.html", "仿真·四系统合页"),
@@ -32,6 +36,24 @@ ROUTES = [
     ("/viewer", 64292, "/unit-workbench.html", "/unit-workbench.html", "三维拆装工作台"),
     ("/local-ai", OLLAMA[1], "/api/tags", "/api/tags", "本机模型 Ollama"),
 ]
+_PORT_KEY = {'/detail': 'detail', '/cms': 'cms', '/sim/sys': 'sim_sys', '/sim': 'sim',
+             '/viewer': 'viewer', '/local-ai': 'ollama'}
+
+
+def _upstream_ports() -> dict:
+    """上游端口: `configs/serve.json` 里同名键覆盖内置默认 (文件读不到就用默认)。"""
+    import json as _json
+    import pathlib as _pl
+    got = {}
+    try:
+        cfg_p = _pl.Path(__file__).resolve().parents[1] / 'configs' / 'serve.json'
+        got = _json.loads(cfg_p.read_text(encoding='utf-8-sig'))
+    except Exception:
+        got = {}
+    return {pre: int(got.get(_PORT_KEY[pre]) or port) for pre, port, *_ in _DEFAULT_ROUTES}
+
+
+ROUTES = [(pre, _upstream_ports()[pre], a, b, nm) for pre, _, a, b, nm in _DEFAULT_ROUTES]
 PORT2PREFIX = {p: pre for pre, p, *_ in ROUTES}
 PORT2PREFIX[28084] = ""  # v2 入口绝对链接 → 根
 for _old, _new in ((8033, 18033), (8020, 18020), (8792, 18792), (8791, 18791), (54292, 64292), (18084, 28084)): PORT2PREFIX[_old] = PORT2PREFIX.get(_new, "")  # 旧端口绝对链接同样归到 v2 前缀

+ 8 - 3
scripts/guanlan_start_hidden.py

@@ -47,10 +47,15 @@ LOG = P.LOGS / 'start_hidden.log'          # 启动器日志: 服务日志命名
 
 
 def log(msg: str) -> None:
+    """写启动器日志 —— **走统一行格式** `时间戳 级别 组件 消息` (用户令 2 / docs §11.2)。
+
+    2026-09-17 实测: 这里原来是自己的 `时间戳 消息`(没有级别与组件), log_audit 会报"不符合统一格式";
+    统一后与 6 个服务、运维动作日志一套格式, 排障时一眼能对上。
+    """
+    from src import logfile as _lf
     P.LOGS.mkdir(parents=True, exist_ok=True)
-    line = f'{dt.datetime.now():%Y-%m-%d %H:%M:%S} {msg}'
-    with open(LOG, 'a', encoding='utf-8') as f:
-        f.write(line + '\n')
+    with open(LOG, 'a', encoding='utf-8', newline='\n') as f:
+        f.write(_lf.line('INFO', 'start_hidden', msg) + '\n')
 
 
 def port_up(host: str, port: int, t: float = 0.4) -> bool:

+ 28 - 0
src/logfile.py

@@ -141,6 +141,34 @@ def prune_actions(keep: int = ACTION_KEEP, days: int = ACTION_DAYS, dry: bool =
     return gone
 
 
+def normalize_file(path: pathlib.Path, comp: str, when: float | None = None) -> int:
+    """把一份**裸输出**日志就地补成统一格式 → 补了几行。
+
+    为什么需要: 运维动作的日志是"子进程直接写 fd"的产物 (`_ops_run.py` 把本进程的 stdout 句柄交给子命令,
+    见那里的注释 —— 用管道收输出曾经导致日志空白/任务看起来卡住), 所以子命令的 print **绕过**了 Python
+    层的流包装。这里在动作结束时补一次前缀: 已经是规范行的原样保留, 其余行补 `时间戳 级别 组件.raw`,
+    并在开头插一行说明"以下为原样输出、前缀是事后补的" —— 不假装是原始时刻写的。
+    """
+    if not path or not pathlib.Path(path).is_file():
+        return 0
+    p = pathlib.Path(path)
+    ts = dt.datetime.fromtimestamp(when if when is not None else p.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S')
+    raw = p.read_text(encoding='utf-8', errors='replace').replace('\r\n', '\n').split('\n')
+    fixed, n = [], 0
+    for ln in raw:
+        if not ln.strip():
+            continue
+        if LINE_RE.match(ln):
+            fixed.append(ln)
+            continue
+        fixed.append(f'{ts} INFO {comp}.raw {ln}')
+        n += 1
+    if n:
+        fixed.insert(0, f'{ts} INFO {comp} === 以下 {n} 行为子进程原样输出 (前缀由 logfile.normalize_file 事后补齐) ===')
+        p.write_text('\n'.join(fixed) + '\n', encoding='utf-8', newline='\n')
+    return n
+
+
 # ── 服务侧: 让 print 出来的每一行都符合格式 ─────────────────────────────────────────────
 class _Prefixed:
     """把写到 stdout/stderr 的每一行加上 `时间戳 级别 组件` 前缀。

+ 22 - 1
src/windcms/data.py

@@ -35,7 +35,13 @@ def _read_index(p):
 
 
 def load_scalars(cfg, meas=KEY_SCALARS):
-    """各窗标量 (ds_size==1) 拼接; 缓存按窗集合命名."""
+    """各窗标量 (ds_size==1) 拼接; 缓存按窗集合命名。
+
+    ★ 2026-09-17 修: 产物被"清除产物"删掉后 `windows()` 返回空集 ⇒ 原来 `pd.concat([])` 直接抛
+    `ValueError: No objects to concatenate`, **把整个 CMS 服务进程带走**(现场实测: 清产物后点"登录"
+    → /detail/ 与 /cms/ 全挂)。现在空集返回**空表**(列齐、dtype 对), 页面照常起来显示"无产物";
+    空结果不写缓存 (否则下次放了产物还会读到这份空缓存)。
+    """
     ws = windows(cfg)
     key = hashlib.md5(('|'.join(ws) + '|' + ','.join(meas)).encode()).hexdigest()[:10]
     cache = cfg['out'] / 'cache' / f'scalars_{key}.parquet'
@@ -47,6 +53,8 @@ def load_scalars(cfg, meas=KEY_SCALARS):
         d = d[(d.ds_size == 1) & d.meas_name.isin(meas) & d.sensor_name.isin(SENSORS)].copy()
         d['window'] = w
         parts.append(d)
+    if not parts:
+        return _empty_scalars()
     s = pd.concat(parts, ignore_index=True)
     s['trigger_time'] = pd.to_datetime(s.trigger_time)
     s['scalar_value'] = pd.to_numeric(s.scalar_value, errors='coerce')
@@ -55,13 +63,26 @@ def load_scalars(cfg, meas=KEY_SCALARS):
     return s
 
 
+def _empty_scalars() -> pd.DataFrame:
+    """无窗(无产物)时的空标量表 —— 列名与 _read_index 下的标量子集一致, 供下游直接 groupby/取值。"""
+    cols = ['turbine', 'sensor_name', 'meas_name', 'trigger_time', 'rpm', 'condition_key',
+            'alarm_type', 'ds_size', 'scalar_value', 'overload', 'window']
+    d = pd.DataFrame({c: pd.Series(dtype='float64' if c in ('rpm', 'ds_size', 'scalar_value') else 'object')
+                      for c in cols})
+    d['trigger_time'] = pd.to_datetime(d['trigger_time'])
+    return d
+
+
 def load_alarm_counts(cfg):
+    """各窗报警计数 —— 无窗时返回空表而不是 `pd.concat([])` 崩掉 (同上, 2026-09-17)。"""
     rows = []
     for w, info in windows(cfg).items():
         d = pd.read_parquet(info['index'], columns=['turbine', 'alarm_type'])
         c = d.groupby(['turbine', 'alarm_type']).size().unstack(fill_value=0)
         c['window'] = w
         rows.append(c.reset_index())
+    if not rows:
+        return pd.DataFrame(columns=['turbine', 'window'])
     return pd.concat(rows, ignore_index=True).fillna(0)
 
 

+ 70 - 1
src/windcms/serve.py

@@ -235,6 +235,30 @@ def review_css_scoped():
     return _REVIEW_CSS['v']
 
 
+def _err_page(path: str, title: str, detail: str) -> str:
+    """兜底页 (不依赖 ui.shell, 免得兜底本身再抛异常): 一句人话 + 回门户/控制台的入口。
+
+    2026-09-17 加: 之前产物缺失会让请求**连接被掐断**(页面白屏/网关报"模块未启动"),
+    现在无论缺产物还是服务端异常, 用户都能看到发生了什么、该去哪儿。
+    """
+    import html as _h
+    return ('<!doctype html><meta charset="utf-8"><title>' + _h.escape(title) + '</title>'
+            '<style>body{font:14px/1.7 system-ui,"Microsoft YaHei",sans-serif;color:#183548;background:#f2f6f8;margin:0;'
+            'display:flex;align-items:center;justify-content:center;min-height:100vh}'
+            '.b{background:#fff;border:1px solid #d4e3e7;border-radius:10px;padding:22px 26px;max-width:760px;'
+            'box-shadow:0 6px 20px rgba(14,74,87,.08)}h1{font-size:18px;margin:0 0 10px}'
+            'code{background:#eef4f6;border-radius:4px;padding:1px 5px}.s{color:#667f8e;font-size:13px}'
+            'a{color:#087f89}</style>'
+            f'<div class="b"><h1>{_h.escape(title)}</h1><p class="s">请求路径 <code>{_h.escape(path)}</code>'
+            + (f' · {_h.escape(detail)}' if detail else '') + '</p>'
+            '<p>这一页的数来自 <code>outputs/&lt;场站&gt;/</code> 下的产物。请先放数据再重算:</p>'
+            '<p><code>scripts/place_raw_data.py --src &lt;现场包目录&gt;</code> → '
+            '<code>scripts/rebuild_all.py</code> (或运维控制台点「执行重算」)</p>'
+            '<p><a href="http://127.0.0.1:28084/#cms" target="_top">← 返回观澜门户</a> · '
+            '<a href="http://127.0.0.1:28084/ops" target="_top">运维控制台</a> · '
+            '<a href="http://127.0.0.1:28084/healthz" target="_top">全部模块状态</a></p></div>')
+
+
 def run(cfg, port=8030, model=None):
     out = Path(cfg['out'])
     c = orchestrator.ctx(cfg)
@@ -335,7 +359,52 @@ def run(cfg, port=8030, model=None):
                     import traceback as _tb
                     print('[err] /api/agent_stream\n' + _tb.format_exc()[-400:], flush=True)
                 return
-            return self._do_GET_pages()
+            return self._do_GET_pages_or_gap()
+
+        def _do_GET_pages_or_gap(self):
+            """页面路由的兜底 (2026-09-17 用户令"重算后登录报模块不可用"后的加固)。
+
+            现场症状: 执行「清除产物」后点门户「登录」→ `/detail/`·`/cms/` 打不开(连接被直接掐断),
+            日志里是 `FileNotFoundError: outputs/rudong/windcms/index.html` —— 因为 CMS 的页面本身
+            是**产物**, 产物没了就直接抛异常, 连一句人话都没有。
+            现在改成: 缺产物 → 返回**结构化"无产物"页**(HTTP 200, 与 workbench 侧 err=no_products 同口径);
+            其它异常 → 500 页 + 日志, 不再让连接凭空断掉。
+            """
+            try:
+                if not (out / 'index.html').is_file():
+                    return self._send_no_products()
+                return self._do_GET_pages()
+            except FileNotFoundError as e:
+                print(f'[gap] {self.path} 缺产物: {e}', flush=True)
+                return self._send_no_products(str(e))
+            except Exception:
+                import traceback as _tb
+                print('[err] GET ' + self.path + '\n' + _tb.format_exc()[-600:], flush=True)
+                return self._send(_err_page(self.path, '500 服务端异常', '见 logs/cms.log 的 [err] 段'),
+                                  'text/html; charset=utf-8', code=500)
+
+        def _send_no_products(self, detail: str = ''):
+            """无产物页: 说清"哪件产物缺了、怎么补", 并给回门户/去重算的入口。"""
+            body = (
+                '<div class="card"><h3>无产物: 这份 CMS 页面还没有生成</h3>'
+                f'<p class="small">找不到 <code>{ui.esc(str(out / "index.html"))}</code>'
+                + (f' —— {ui.esc(detail)}' if detail else '') + '</p>'
+                '<p>页面的数来自 <code>outputs/&lt;场站&gt;/windcms/</code> 下的产物, 产物由 <code>data/raw</code> '
+                '里的原始件重算出来。当前这台机器上<b>要么还没放数据, 要么刚执行过「清除产物」</b>。</p>'
+                '<p>下一步:</p>'
+                '<div class="kv"><span><b>① 放数据</b><code>scripts/place_raw_data.py --src &lt;现场包目录&gt; --scope vib</code></span>'
+                '<span><b>② 重算</b><code>scripts/rebuild_all.py</code> 或在运维控制台点「执行重算」</span>'
+                '<span><b>③ 核验</b><code>scripts/raw_data_check.py --deep</code> 与 <code>/healthz</code></span></div>'
+                '<p class="small">补齐"包内没有生成端"的那批随包件也可以: '
+                '<code>scripts/products_restore_missing.py --stash &lt;交付包.zip&gt;</code></p>'
+                '<p><a href="http://127.0.0.1:28084/#cms" target="_top">← 返回观澜门户</a> · '
+                '<a href="http://127.0.0.1:28084/ops" target="_top">运维控制台 / 执行重算</a></p></div>')
+            try:
+                return self._send(ui.shell('无产物', body, farm_name=cfg['name'], machine=cfg['machine'],
+                                           active='/', subtitle='CMS 振动诊断 · 产物未生成'), 'text/html; charset=utf-8')
+            except Exception:
+                return self._send(_err_page(self.path, '无产物', '产物未生成 (outputs/<场站>/windcms/)'),
+                                  'text/html; charset=utf-8')
 
         def _do_GET_pages(self):
             p = self.path.split('?')[0]

+ 19 - 2
src/windscada/subsys/fusion.py

@@ -27,10 +27,23 @@ OIL_COMP_NORM = {
 }
 
 
+GAP_HANDOFF = [None]      # 缺 handoff 时记一句(供上层/页面显示); 见 load_handoff 的 2026-09-17 说明
+
+
 def load_handoff():
-    """振动线接口 v2; 缺失响亮 (判据必需输入禁容错取值)."""
+    """振动线接口 v2。
+
+    ★ 2026-09-17 改口径 (用户令"打包不随产物"之后): 原来"缺失就 raise" —— 理由正当
+    ("融合面不可静默降级"), 但它把**整条重算链**卡死: 交付包不再随产物 ⇒ 目标机上这件
+    handoff 必然缺, 于是 `populate`(⑦) 直接 rc=1, 后面的本体审计全不执行。
+    现在改成**响亮降级**: 缺件返回空结构 + 在 `GAP_HANDOFF[0]` 写明缺什么、从哪儿补,
+    由上层写进结果与日志(页面/审计能看见), 而不是把链打断。判级本身不造数: 缺件时融合面
+    的状态为"不可判", 依据写明缺件。
+    """
     if not HANDOFF.exists():
-        raise FileNotFoundError(f'振动 handoff 接口缺失: {HANDOFF} — 融合面不可静默降级')
+        GAP_HANDOFF[0] = (f'融合面缺件: {HANDOFF} (属"包内无生成端"的件, docs §7) —— '
+                          f'从旧交付包补齐: scripts/products_restore_missing.py --stash <包.zip>')
+        return {}
     return json.loads(HANDOFF.read_text(encoding='utf-8'))   # 中文 JSON: 缺 encoding 会按系统 locale(cp936) 读
 
 
@@ -75,6 +88,10 @@ def fusion_table(cfg=None, all_turbines=False):
     行 = 振动线 handoff 里有 verdict 的台; 列 = 各证据轴 + 各源 claim 窗."""
     cfg = cfg or farm()
     h = load_handoff()
+    if not h or not (h.get('per_turbine') or {}):
+        # 缺 handoff (交付包不随产物 ⇒ 目标机必然缺): 返回**空表但列齐**, 上层把
+        # GAP_HANDOFF[0] 那句"缺什么/怎么补"写进结果 —— 判级不造数, 页面显示缺件 (2026-09-17)。
+        return (pd.DataFrame(columns=['turbine', '部件', '振动结论', '温度', '柱塞', '油样窗', '依据']), {})
     oil = oil_window(cfg)
     from . import temp_nbm
     wp = pathlib.Path(cfg['store']) / 'watch_channels_monthly.parquet'

+ 19 - 2
src/windscada/subsys/pitch.py

@@ -16,9 +16,21 @@ def _cur_win(d, days=60):
     return d[d['date'] >= (pd.Timestamp(end) - pd.Timedelta(days=days)).date()]
 
 def load():
-    daily = pd.read_parquet(PITCH_DIR / 'pitch_daily.parquet')
+    """读变桨面产物 —— **缺件时返回空表而不是抛异常** (2026-09-17, 见下)。
+
+    为什么必须容缺: `pitch_daily.parquet` 属于"**包内没有生成端**"的那批产物(docs §7) ——
+    交付包按用户令**不再随产物**, 目标机(或执行过「清除产物」的机器)上它必然缺失。
+    原先这里直接 `pd.read_parquet` ⇒ `FileNotFoundError` 一路上抛到本体层 `taxonomy.system_matrix()`,
+    把"整条重算链"卡在第 ⑦ 步 (2026-09-17 实测: 清产物后重算, 2267 s 后 rc=1 断在这里)。
+    现在缺件返回空表, 下游按"该面无数据"降级(页面显示缺件, 不静默造数)。
+    """
+    p = PITCH_DIR / 'pitch_daily.parquet'
+    if p.is_file():
+        daily = pd.read_parquet(p)
+    else:
+        daily = pd.DataFrame(columns=['date', 'turbine', 'n', 'hydlevel_timeon', 'hydfilt_timeon'])
     zp = PITCH_DIR / 'pitch_zero_monthly.parquet'
-    zero = pd.read_parquet(zp) if zp.exists() else pd.DataFrame()
+    zero = pd.read_parquet(zp) if zp.is_file() else pd.DataFrame()
     return daily, zero
 
 ALARM_FAM = {  # M4b 报警轴 (windscada alarms.parquet; 分册监测量对齐)
@@ -49,6 +61,11 @@ def _alarm_counts(win_start, win_end, store=None):
 def registry(cfg=None):
     """变桨面登记表。`cfg` 给定时, 其 `store` 决定读哪个场的 alarms (见 _alarm_counts 的注释)。"""
     daily, zero = load()
+    if not len(daily):
+        # 缺"无生成端"的 pitch_daily (交付包不随产物 ⇒ 目标机上必然缺): 该面整体**不可判**,
+        # 但**结构要在**(列齐、零行), 否则下游 `reg.set_index('机组')` 会 KeyError, 又把整条链卡住。
+        # 页面据此显示"变桨面缺件: outputs/<场>/pitch/pitch_daily.parquet (docs §7)", 不静默造数。
+        return pd.DataFrame(columns=['机组', '系统级', *DIMS]), {}
     daily['date'] = pd.to_datetime(daily['date']).dt.date
     cur = _cur_win(daily).copy()
     # 语义修正 (2026-08-24 数据实逮): HydLevel/HydrFilt 开关 1=正常 → 报警秒 = 当日应测秒(n×600) − timeon;

+ 77 - 15
src/windscada/taxonomy.py

@@ -63,6 +63,52 @@ def system_matrix(cfg=None):
     """38台 × 7系统 状态 + 依据. 轴: 分系统模块(变桨/偏航/蓄能) + 温度通道归系统 + 润滑柱塞×fleet + 测风/通讯."""
     cfg = cfg or farm()
     TC = _load_tc(cfg)
+
+    # ★ 2026-09-17 缺件容错: 交付包不随产物 ⇒ 这批"无生成端"的件(pitch_daily/yaw_daily/system_aux/
+    #   thermal_chain…)在目标机上必然缺。这里逐个兜住: 缺件 = 该面无数据 (记 GAPS, 页面显示缺件),
+    #   绝不让 KeyError/FileNotFoundError 把本体层整步打断 (实测清产物后重算就死在这里)。
+    GAPS: list[str] = []
+
+    def _missing(what: str, why: str = ''):
+        tag = f'{what} 缺件 ({why})' if why else f'{what} 缺件'
+        if tag not in GAPS:
+            GAPS.append(tag)
+
+    def _load_reg(fn, what, idx_col):
+        try:
+            reg = fn()
+            reg = reg[0] if isinstance(reg, tuple) else reg
+            return reg.set_index(idx_col)
+        except FileNotFoundError as e:
+            _missing(what, str(e).split("'")[-2] if "'" in str(e) else str(e)[:60])
+        except Exception as e:
+            _missing(what, f'{type(e).__name__}')
+        return pd.DataFrame()
+
+    def _load_parquet(name):
+        f = pathlib.Path(cfg['store']) / name
+        if not f.exists():
+            _missing(name)
+            return pd.DataFrame()
+        try:
+            return pd.read_parquet(f).set_index('turbine')
+        except Exception as e:
+            _missing(name, f'{type(e).__name__}')
+            return pd.DataFrame()
+
+    def _cell(i, t_, col, default='不可判'):
+        """按台取一格 —— 表空/行缺/列缺都返回 default (缺件时该轴不可判, 不是崩溃)。"""
+        try:
+            v = i.loc[t_, col]
+        except Exception:
+            return default
+        try:
+            if v != v:          # NaN
+                return default
+        except Exception:
+            pass
+        return v
+
     try:
         from .subsys import structure as stxmod
         _stx, STXD = stxmod.registry(cfg)
@@ -70,14 +116,15 @@ def system_matrix(cfg=None):
     except Exception:
         STX, STXD = None, {}
     from .subsys import pitch as pitchmod, yaw as yawmod, hydraulic, temp_nbm
-    preg, _ = pitchmod.registry()
-    yreg, ydet = yawmod.registry()
-    hreg, _ = hydraulic.registry()
+    p_i = _load_reg(lambda: pitchmod.registry(), '变桨面 pitch_daily.parquet', '机组')
+    y_i = _load_reg(lambda: yawmod.registry(), '偏航面 yaw_daily.parquet', '机组')
+    h_i = _load_reg(lambda: hydraulic.registry(), '蓄能面 (液压深挖件)', 'turbine')
     treg = temp_nbm.registry()
     tdf = treg[0] if isinstance(treg, tuple) else treg
-    aux = pd.read_parquet(pathlib.Path(cfg['store']) / 'system_aux.parquet').set_index('turbine')
-    ydf = pd.read_parquet(pathlib.Path(cfg['store']) / 'yaw_daily.parquet').set_index('turbine')
-    p_i = preg.set_index('机组'); y_i = yreg.set_index('机组'); h_i = hreg.set_index('turbine')
+    if tdf is None or not len(tdf):
+        tdf = pd.DataFrame(columns=['turbine', 'channel', 'dev_K', '判'])
+    aux = _load_parquet('system_aux.parquet')
+    ydf = _load_parquet('yaw_daily.parquet')
     pairs = temp_nbm.pairs_table()
     try:
         pcd = pd.read_parquet(pathlib.Path(cfg['store']) / 'powercurve_dev.parquet')
@@ -93,14 +140,22 @@ def system_matrix(cfg=None):
         # 每系统: (states, 依据 list)
         acc = {s: ([], []) for s in SYSTEMS}
         # 变桨 = 分系统四维 + 蓄能深挖
-        acc['变桨'][0].append(p_i.loc[t, '系统级'])
-        acc['变桨'][1].append('四维:' + '/'.join(f"{d}{p_i.loc[t, d][0]}" for d in pitchmod.DIMS))
-        hv = h_i.loc[t, '判']
+        acc['变桨'][0].append(_cell(p_i, t, '系统级'))
+        _pd_ = [_cell(p_i, t, d, '') for d in pitchmod.DIMS]
+        if any(_pd_):
+            acc['变桨'][1].append('四维:' + '/'.join(f'{d}{v}' for d, v in zip(pitchmod.DIMS, _pd_) if v))
+        else:
+            acc['变桨'][1].append('四维: 缺件, 不可判')
+        hv = _cell(h_i, t, '判', '—')
         acc['变桨'][0].append('报警' if '候选' in hv else ('良好' if '记基线' in hv else '优秀'))
         if hv != '—': acc['变桨'][1].append(f"蓄能: {h_i.loc[t, '依据']}")
         # 偏航
-        acc['偏航'][0].append(y_i.loc[t, '系统级'])
-        acc['偏航'][1].append('四维:' + '/'.join(f"{d[:2]}{y_i.loc[t, d][0]}" for d in yawmod.DIMS))
+        acc['偏航'][0].append(_cell(y_i, t, '系统级'))
+        _yd_ = [_cell(y_i, t, d, '') for d in yawmod.DIMS]
+        if any(_yd_):
+            acc['偏航'][1].append('四维:' + '/'.join(f'{d[:2]}{v}' for d, v in zip(yawmod.DIMS, _yd_) if v))
+        else:
+            acc['偏航'][1].append('四维: 缺件, 不可判')
         # 温度通道归系统
         for _, r in tdf[tdf['turbine'] == t].iterrows():
             ch = r['channel'].replace('tmp_wtc_', '').replace('_mean', '').replace('_max', '')
@@ -118,7 +173,7 @@ def system_matrix(cfg=None):
                 acc[sysname][1].append(f"配对差{pr['pair']} {pr['pair_diff']:+.1f}K(偏{pr['pair_dev']:+.1f})")
         # 润滑柱塞 ×fleet (筛查级; 单窗相对门弱, 只报不定级除非极端)
         for s in ('主轴承', '齿轮箱', '发电机'):
-            v = aux.loc[t, f'lub_{s}']
+            v = _cell(aux, t, f'lub_{s}', float('nan'))
             x = v / max(lub_med[s], 1e-9) if v == v else np.nan
             if x == x and (x >= 2.5 or x <= 0.25):
                 acc[s][0].append('良好')
@@ -158,11 +213,11 @@ def system_matrix(cfg=None):
         if pc_a.get(t):
             acc['主控与传感网'][0].append('报警')
             acc['主控与传感网'][1].append(f"曲线判风速计偏置A类 ({pc_a[t]})")
-        wf = float((ydf.loc[t, 'windfau_s'] or 0) + (ydf.loc[t, 'windfau_t'] or 0))
-        yfa = int(ydf.loc[t, 'D4风传感'])
+        wf = float((_cell(ydf, t, 'windfau_s', 0) or 0) + (_cell(ydf, t, 'windfau_t', 0) or 0))
+        yfa = int(_cell(ydf, t, 'D4风传感', 0) or 0)
         if wf >= 100 or yfa >= 30: acc['主控与传感网'][0].append('报警'); acc['主控与传感网'][1].append(f'测风故障计数{wf:.0f}+报警{yfa}')
         elif wf >= 30 or yfa >= 5: acc['主控与传感网'][0].append('良好'); acc['主控与传感网'][1].append(f'测风计数{wf:.0f}/报警{yfa}')
-        sc = aux.loc[t, 'scomerr']
+        sc = _cell(aux, t, 'scomerr', float('nan'))
         scx = sc / max(sc_med, 1e-9) if sc == sc else np.nan
         if scx == scx and scx >= 3 and sc >= 50:
             acc['主控与传感网'][0].append('良好'); acc['主控与传感网'][1].append(f'通讯错误增量{sc:.0f} ({scx:.1f}×fleet)')
@@ -172,6 +227,13 @@ def system_matrix(cfg=None):
             sysd[s] = dict(状态=base, 依据='; '.join(why) if why else '各轴无非常态')
         result[t] = sysd
 
+    if GAPS:
+        # 整表挂一条缺件说明 (页面/维护页据此显示"哪些面因缺件不可判"), 不静默造数
+        result['_缺件说明'] = dict(GAPS=list(GAPS),
+                                注='这些是"包内没有生成端"的产物 (docs §7); 交付包按用户令不随产物, '
+                                   '目标机上要么从旧交付包补齐 (products_restore_missing.py --stash <包.zip>), '
+                                   '要么由研发补生成端。')
+
     # 振动映射进分系统状态 (2026-08-28 用户令): handoff 判级按 component 归系统, 取严合并+依据可溯.
     # 定论/报警/预警级 → 该系统升'报警'; 候选/监视/观察级 → 状态不动只追加依据 (筛查级展示态,
     # 振动判级本身已过双模审, 非本层产判级 — 转录取严)

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott