فهرست منبع

2.36.1 fleet 搬运完成(AST 传递闭包 591 行: 13 函数 + 12 全局)但未计一致: 自检顶层 18 键仅 kpi.关注台 一处差异(重算时点/缓存), 下轮同点复比再判定

zhouyang.xie 1 هفته پیش
والد
کامیت
74eedb6810

+ 1 - 1
VERSION

@@ -1 +1 @@
-2.36.0
+2.36.1

+ 591 - 0
app_algorithmModel/app_algorithmModel_guanlan/service/fleet_views.py

@@ -0,0 +1,591 @@
+# -*- coding: utf-8 -*-
+r"""整场取数视图(P12):`fleet_view` 及其**传递闭包**内的全部本地函数与模块级全局,机械搬到算法服务。
+
+★来源:`app_backEnd/app_backEnd_guanlan/serve.py`(原样搬运,未改写逻辑)。
+搬运范围由 AST 计算(种子 `fleet_view` → 递归展开被调本地函数 → 带上被引用的模块级全局),避免漏项。
+"""
+from __future__ import annotations
+
+import collections
+import datetime as _dt
+import json
+import pathlib
+import re
+import threading
+import time
+from typing import Any
+
+from app_common.app_common_guanlan.api import paths as _P
+from src.windscada.config import farm
+
+from src import paths as _P                                              # 路径唯一真源 (与 cwd 无关)
+from src.windscada import i18n
+from src.windscada import taxonomy
+from src.windscada.subsys import temp_nbm, hydraulic, yaw as yawmod, pitch as pitchmod
+import hashlib, time
+import numpy as np, pandas as pd
+import sys, json, pathlib, re, threading, urllib.parse, collections
+import threading as _thr
+
+# ── 原 serve.py 的模块级全局(逐字搬) ──
+CFG = farm(); ST = pathlib.Path(CFG['store'])
+
+LOCK = threading.Lock()
+
+CFG = farm(); ST = pathlib.Path(CFG['store'])
+
+TS = {'报警': 0, '危险': 0, '良好': 1, '不可判': 2, '优秀': 3}
+
+_CACHE = {}
+
+_HEAVY_LOCK = _thr.Lock()
+
+_STAMP = {'val': None, 'at': 0.0}
+
+_STAMP_TTL = 2.0          # 秒: 指纹有效期 (stat ~150 个文件 ≈ 1~3 ms, 不值得每请求都做)
+
+_WIN_BUSY: dict = {'sysmx': set(), 'curves': set(), 'm9': set()}
+
+_WIN_CACHE: dict = {'sysmx': {}, 'curves': {}, 'm9': {}}
+
+_WIN_ERR: dict = {'sysmx': {}, 'curves': {}, 'm9': {}}
+
+_WIN_LOCK = _thr.Lock()
+
+
+def _product_files():
+    """页面取数依赖的产物文件清单 (顺序稳定: 供指纹; 只列**产物**, 不含 reference/ 随包契约)。
+
+    覆盖 _load() 直读的件 + 它经 taxonomy/temp_nbm/hydraulic/fusion 间接读的件:
+      windscada/*.parquet|csv|json  · ontology/*.json  · pitch/*.parquet
+      m5_cms_tcm/{handoff_vibration_v2,component_history,baseline_38}.json  · windcms/报告_CMS*.md
+    """
+    try:
+        from src.windcms.config import cms_out as _cms_out     # 与写侧同一解析口 (WINDCMS_OUT)
+        _cms_dir = _cms_out()
+    except Exception:
+        _cms_dir = _P.cms()
+    pats = ((ST, ('*.parquet', '*.csv', '*.json')),
+            (_P.ont(), ('*.json',)),
+            (_P.pitch(), ('*.parquet',)),
+            (_P.m5(), ('handoff_vibration_v2.json', 'component_history.json', 'baseline_38.json')),
+            (_cms_dir, ('报告_CMS振动状态评估报告_*.md',)))
+    files = []
+    for d, ps in pats:
+        for pat in ps:
+            files.extend(sorted(d.glob(pat)))
+    return files
+
+
+def products_stamp(force=False):
+    """产物指纹 (sha1 前 16 位)。force=True 时忽略 TTL 立即重算。"""
+    now = time.time()
+    if not force and _STAMP['val'] is not None and (now - _STAMP['at']) < _STAMP_TTL:
+        return _STAMP['val']
+    h = hashlib.sha1()
+    for p in _product_files():
+        try:
+            st = p.stat()
+            h.update(f'{_P.rel(p)}|{st.st_mtime_ns}|{st.st_size}\n'.encode('utf-8'))
+        except OSError:
+            h.update(f'{_P.rel(p)}|MISSING\n'.encode('utf-8'))
+    _STAMP.update(val=h.hexdigest()[:16], at=now)
+    return _STAMP['val']
+
+
+def _load():
+    with LOCK:
+        stamp = products_stamp()
+        if _CACHE.get('__loaded'):
+            if _CACHE.get('__stamp') == stamp:
+                return
+            # 产物变了 (典型: 刚跑完重算) → 重载。**先建后换**: 下面任何一步抛异常都不会破坏旧缓存,
+            # 请求照旧能用旧数 (降级但不空白), 同时日志留痕。
+            print(f'[reload] 产物指纹变化 {_CACHE.get("__stamp")} → {stamp}, 重载', flush=True)
+        tmp = {}
+        try:
+            for key, name in (('tm', 'temp_monthly.parquet'), ('al', 'alarms.parquet'),
+                              ('lm', 'loss_monthly.parquet'), ('bins', 'powercurve_bins.parquet'),
+                              ('pcd', 'powercurve_dev.parquet')):
+                f = ST / name
+                if not f.exists():
+                    raise ProductsMissing(name, f)
+                tmp[key] = pd.read_parquet(f)
+            tmp['al']['month'] = tmp['al']['t_on'].dt.to_period('M').astype(str)
+            tmp['pcd'] = tmp['pcd'].set_index('turbine')
+            for key, name in (('duty', 'duty_monthly.parquet'),):
+                f = ST / name
+                tmp[key] = pd.read_parquet(f) if f.exists() else None
+            try:
+                tmp['sysmx'] = taxonomy.system_matrix()
+                tmp['treg'] = temp_nbm.registry()
+                tmp['treg'] = tmp['treg'][0] if isinstance(tmp['treg'], tuple) else tmp['treg']
+                tmp['hyd'], _ = hydraulic.registry()
+                tmp['hyd'] = tmp['hyd'].set_index('turbine')
+            except FileNotFoundError as e:            # 这些派生件同样在产物仓里; 缺了就按"无产物"处理
+                raise ProductsMissing(getattr(e, 'filename', '派生产物'), getattr(e, 'filename', ST))
+            zp = _P.pitch() / 'pitch_zero_monthly.parquet'
+            tmp['zero'] = pd.read_parquet(zp) if zp.exists() else None
+            tmp['__stamp'] = stamp
+            tmp['__loaded'] = True
+            _CACHE.update(tmp)                        # 原子提交: 失败时不留下半截缓存
+        except Exception:
+            if _CACHE.get('__loaded'):
+                print('[reload] 重载失败 → 继续用上一份缓存 (页面不会空白, 但数是旧的; 看上面的异常)', flush=True)
+            raise
+
+
+def _month_end(m):
+    """'YYYY-MM' → 该月最后一天 'YYYY-MM-DD'。"""
+    import calendar
+    y, mo = int(m[:4]), int(m[-2:])
+    return f'{m}-{calendar.monthrange(y, mo)[1]:02d}'
+
+
+def months_of(win):
+    """窗 → 覆盖到的月份列表(月度类数据按此过滤)。"""
+    _load()
+    all_m = sorted(_CACHE['tm'].month.unique())
+    if win == '全程': return all_m
+    if win == '2025H2': return [m for m in all_m if '2025-07' <= m <= '2025-12']
+    if win == '2026H1': return [m for m in all_m if '2026-01' <= m <= '2026-06']
+    if win == '2026年': return [m for m in all_m if m >= '2026-01']
+    import re as _re
+    if _re.fullmatch(r'\d{4}-\d{2}', win):        # 单月窗 (2026-08-27 用户令: 按月份过滤和选择)
+        return [m for m in all_m if m == win]
+    m2 = _re.fullmatch(r'(\d{4}-\d{2})~(\d{4}-\d{2})', win)
+    if m2:                                          # 月区间窗 "2025-04~2025-10"
+        return [m for m in all_m if m2.group(1) <= m <= m2.group(2)]
+    m3 = _re.fullmatch(r'(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})', win)
+    if m3:                                          # ★自定义起止(含)→ 相交的月 (用户令 2026-09-21)
+        a, b = sorted((m3.group(1), m3.group(2)))
+        return [m for m in all_m if _month_end(m) >= a and f'{m}-01' <= b]
+    n = 1 if win == '近30日' else 3
+    return all_m[-n:]
+
+
+def win_range(win):
+    """窗 → (起, 止) 日期串,**含两端**(用户令 2026-09-21:时间窗支持自定义起止日期)。
+
+    预设/逐月/月区间窗一律落到"该时间窗覆盖月的月首/月末",于是与 `months_of()` 同口径;
+    日区间窗原样返回。日粒度件(停机事件/报警/日粒度判据)用它做**含端**过滤。
+    """
+    import re as _re
+    if win:
+        if _re.fullmatch(r'\d{4}-\d{2}-\d{2}~\d{4}-\d{2}-\d{2}', win):
+            a, b = win.split('~')
+            return (a, b) if a <= b else (b, a)
+        if _re.fullmatch(r'\d{4}-\d{2}', win):
+            return (f'{win}-01', _month_end(win))
+        m2 = _re.fullmatch(r'(\d{4}-\d{2})~(\d{4}-\d{2})', win)
+        if m2:
+            return (f'{m2.group(1)}-01', _month_end(m2.group(2)))
+        if win == '2025H2':
+            return ('2025-07-01', '2025-12-31')
+        if win == '2026H1':
+            return ('2026-01-01', '2026-06-30')
+    ms = months_of(win)
+    if not ms:
+        return ('0001-01-01', '9999-12-31')
+    return (f'{ms[0]}-01', _month_end(ms[-1]))
+
+
+def span_of(win):
+    """窗 → 计算层用的 (起, 止) 元组(含);判级/曲线等按窗重算的口子都吃这个。"""
+    return win_range(win)
+
+
+def _mem_mb():
+    """本进程可用内存(MB);拿不到就返回 None(不因为这些诊断信息把重算搞挂)。"""
+    try:
+        import ctypes
+
+        class _MS(ctypes.Structure):
+            _fields_ = [('dwLength', ctypes.c_ulong), ('dwMemoryLoad', ctypes.c_ulong),
+                        ('ullTotalPhys', ctypes.c_ulonglong), ('ullAvailPhys', ctypes.c_ulonglong),
+                        ('ullTotalPageFile', ctypes.c_ulonglong), ('ullAvailPageFile', ctypes.c_ulonglong),
+                        ('ullTotalVirtual', ctypes.c_ulonglong), ('ullAvailVirtual', ctypes.c_ulonglong),
+                        ('ullAvailExtendedVirtual', ctypes.c_ulonglong)]
+        st = _MS()
+        st.dwLength = ctypes.sizeof(_MS)
+        ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(st))
+        return int(st.ullAvailPhys // (1024 * 1024))
+    except Exception:
+        return None
+
+
+def _win_key(win):
+    a, b = span_of(win)
+    return f'{a}~{b}'
+
+
+def _win_get(kind, win, fn):
+    """→ 值 或 None(None = 正在算/刚起算)。失败**记名**(`_WIN_ERR`)而不是静默当"没数据"。"""
+    key = _win_key(win)
+    with _WIN_LOCK:
+        if key in _WIN_CACHE[kind]:
+            return _WIN_CACHE[kind][key]
+        if key not in _WIN_BUSY[kind]:
+            _WIN_BUSY[kind].add(key)
+
+            def _run():
+                try:
+                    with _HEAVY_LOCK:                       # 重活串行: 同一时刻只算一份
+                        m0 = _mem_mb()
+                        print(f'[win] {kind} 按窗重算开算 {key}(可用内存 {m0} MB)', flush=True)
+                        v = fn()
+                        print(f'[win] {kind} 按窗重算完成 {key}(可用内存 {_mem_mb()} MB)', flush=True)
+                except Exception as e:                      # 守护失败必响亮
+                    _WIN_ERR[kind][key] = f'{type(e).__name__}: {e}'[:200]
+                    print(f'[win] {kind} 按窗重算失败 {key}: {_WIN_ERR[kind][key]}', flush=True)
+                    v = None
+                with _WIN_LOCK:
+                    if v is not None:
+                        _WIN_CACHE[kind][key] = v
+                    _WIN_BUSY[kind].discard(key)
+            _thr.Thread(target=_run, name=f'win-{kind}-{key}', daemon=True).start()
+            print(f'[win] {kind} 按窗重算启动 {key}(首次约数十秒,页面会先出 pending)', flush=True)
+        return None
+
+
+def sysmx_of(win):
+    """→ (判级矩阵, pending)。`pending=True` 时返回的是"另一口径"的旧矩阵,页面必须如实标注。"""
+    from src.windscada import taxonomy
+    m = _win_get('sysmx', win, lambda: taxonomy.system_matrix(CFG, span=span_of(win)))
+    if m is None:
+        return _CACHE['sysmx'], True
+    return m, False
+
+
+def m9_of(win):
+    """→ (控制参数一致性, pending)。按窗重算(走窄仓 ≈5 s/窗);干净窗直接用正式产物。"""
+    from src.windscada.perf import control as _cm
+    a, b = span_of(win)
+    if (a, b) == (_cm.WIN[0], '2025-12-31'):
+        return _cm.registry(CFG), False
+    v = _win_get('m9', win, lambda: _cm.registry(CFG, span=(a, b)))
+    if v is None:
+        return _cm.registry(CFG), True          # 未就位: 先给上一口径, 页面按 pending 标注
+    return v, False
+
+
+def fleet_view(win):
+    _load()
+    ms = months_of(win)
+    # ★2026-09-21 用户令「判级也按所选时间窗重算」: 判级矩阵按窗算(后台+缓存,首次 pending)。
+    sysmx, sysmx_pending = sysmx_of(win)
+    # ① 系统分类问题 (全系统)
+    systems, sysdist = {}, {}
+    for s in taxonomy.SYSTEMS:
+        rows = [dict(t=t, st=sysmx[t][s]['状态'], why=i18n.humanize(sysmx[t][s]['依据']))
+                for t in CFG['turbines'] if sysmx[t][s]['状态'] in ('报警', '不可判')]   # 良好不出 (用户令: 只显示有问题的)
+        rows.sort(key=lambda r: TS.get(r['st'], 9))
+        systems[s] = rows
+        # 全场分布 (系统入口卡用: rows 只含问题台, 不能当全场分母 — 会显示"共12台")
+        from collections import Counter as _C
+        sysdist[s] = dict(_C(sysmx[t][s]['状态'] for t in CFG['turbines']))
+        # 良好台清单单列 (系统详情页第三档)
+        systems[s + '·良好'] = [dict(t=t, st='良好', why=i18n.humanize(sysmx[t][s]['依据']))
+                                for t in CFG['turbines'] if sysmx[t][s]['状态'] == '良好']
+    # ①b 关注清单: ≥2 系统报警 (逐台独立台页)
+    watch = []
+    for t in CFG['turbines']:
+        al_sys = [x for x in taxonomy.SYSTEMS if sysmx[t][x]['状态'] == '报警']
+        if len(al_sys) >= 2:
+            watch.append(dict(t=t, n=len(al_sys), syss=al_sys,
+                              # ★不截断: 截到 70 字会把依据切成半句, 而模板反解要完整形态
+                              #   ⇒ 英文侧整条落回中文 (2026-09-03 实逮, 总览 7 卡 4 张中文)。
+                              #   显示长度归前端 CSS 管。
+                              why=';'.join(f"{x}: {i18n.humanize(sysmx[t][x]['依据'])}" for x in al_sys)))
+    watch.sort(key=lambda r: -r['n'])
+    n_alarm_t = sum(1 for t in CFG['turbines'] if any(sysmx[t][x]['状态'] == '报警' for x in taxonomy.SYSTEMS))
+    # ② 故障统计梳理 (真时间窗)
+    al = _CACHE['al']; a = al[al.month.isin(ms)]
+    top_txt = a.groupby(['code', 'text']).agg(n=('code', 'size'), dur_h=('dur_s', lambda x: x.sum() / 3600)).reset_index()
+    pareto_n = top_txt.sort_values('n', ascending=False).head(12)
+    pareto_d = top_txt.sort_values('dur_h', ascending=False).head(12)
+    monthly = a.groupby('month').size().reindex(ms).fillna(0)
+    per_t = a.groupby('turbine').size().sort_values(ascending=False).head(10)
+    # 经典故障分析: 条数与时长必须配对看 (2026-08-28)。两个独立排行榜分不出
+    # "高频短时(信号抖动或重复触发)" 与 "低频长时(硬故障)" — 处置方向相反, 混在一起会误派工。
+    # 第三维=影响台数, 分离"单台刷屏"与"全场批次共性"。
+    # 残月识别 (2026-08-28 审核逮): 2026-07 只有 6 天数据(覆盖 19.4%), 却被当整月
+    # 并进"2026年"的占比与月度趋势 → 末柱"下降"是假象。占比类分母也因此不完整。
+    import calendar as _cal
+    _lmc = _CACHE['lm']; _lmc = _lmc[_lmc.month.astype(str).isin(ms)]
+    _cov = {}
+    for _mm in ms:
+        _sub = _lmc[_lmc.month.astype(str) == _mm]
+        if not len(_sub):
+            _cov[_mm] = 0.0; continue
+        _y, _mo = int(_mm[:4]), int(_mm[-2:])
+        _cal_h = _cal.monthrange(_y, _mo)[1] * 24
+        _cov[_mm] = round(float(_sub['rows_'].sum() / 6 / max(_sub['turbine'].nunique(), 1) / _cal_h), 3)
+    _nt = a.groupby(['code', 'text'])['turbine'].nunique().rename('nt')
+    _med = a.groupby(['code', 'text'])['dur_s'].median().rename('med_s')
+    qd = top_txt.set_index(['code', 'text']).join([_nt, _med]).reset_index()
+    qd = qd.sort_values('n', ascending=False).head(30)
+    faults = dict(
+        pareto_n=[dict(k=f"{r.code} {i18n.alarm_label(r.code, r.text)[:18]}", v=int(r.n)) for _, r in pareto_n.iterrows()],
+        pareto_d=[dict(k=f"{r.code} {i18n.alarm_label(r.code, r.text)[:18]}", v=round(float(r.dur_h), 1)) for _, r in pareto_d.iterrows()],
+        monthly=dict(months=ms, vals=[int(v) for v in monthly],
+                     cov=[_cov.get(x, 0.0) for x in ms],
+                     per_day=[round(float(monthly[x]) / max(_cov.get(x, 0.0) * _cal.monthrange(int(x[:4]), int(x[-2:]))[1], 1e-9), 1)
+                              if _cov.get(x, 0) > 0.02 else None for x in ms]),
+        cov=_cov, cov_min=min(_cov.values()) if _cov else 1.0,
+        partial=[x for x in ms if _cov.get(x, 1) < 0.5],
+        quad=[dict(code=str(r.code), k=i18n.alarm_label(r.code, r.text)[:20], n=int(r.n),
+                   h=round(float(r.dur_h), 1), nt=int(r.nt), med=round(float(r.med_s), 1))
+              for _, r in qd.iterrows()],
+        n_codes=int(len(top_txt)),
+        per_t=[dict(k=k, v=int(v)) for k, v in per_t.items()], total=int(len(a)))
+    # ③ SOP 控制策略
+    lm = _CACHE['lm']; l = lm[lm.month.astype(str).isin(ms)]
+    hrs = l.groupby('state')['rows_'].sum() / 6
+    loss = l.groupby('state')['loss'].sum() / 1000
+    hrs_tot = max(hrs.sum(), 1)
+    stop_h = hrs.get('停机', 0) + hrs.get('停机(调度令)', 0)
+    disp_h = hrs.get('停机(调度令)', 0)
+    lw_h = hrs.get('低风待机', 0)
+    # 口径与 availability.summary 单源一致: 1 − 停机/(总 − 调度令 − 低风待机)
+    avail = 1 - (stop_h - disp_h) / max(hrs_tot - disp_h - lw_h, 1)
+    pcd = _CACHE['pcd']
+    # 能量账闭合 (2026-08-28 经典分析): 四行工况表看不出"电量流向"。
+    # 理论可发 = 实际上网 + Σ各态损失; 过物理上限核 (不可超 38台×4.0MW×窗时长)。
+    _act = l.groupby('state')['act'].sum() / 1000
+    _wf = [dict(k=str(k), loss=round(float(loss.get(k, 0)), 0), act=round(float(_act.get(k, 0)), 0),
+                h=round(float(hrs.get(k, 0)), 0)) for k in hrs.index]
+    _A = float(_act.sum()); _L = float(loss.sum())
+    _cap = len(CFG['turbines']) * 4.0 * float(hrs_tot) / max(len(CFG['turbines']), 1)
+    # 逐月能量 (2026-08-28 报表端口): 汇报要环比与趋势, 全时间窗聚合给不出。
+    # 残月覆盖率一并带出 — 报表里拿残月和整月比环比会读反 (故障月度图已踩过一次)。
+    _em = []
+    for _mm in ms:
+        _sub = l[l.month.astype(str) == _mm]
+        if not len(_sub):
+            continue
+        _a1 = float(_sub['act'].sum()) / 1000
+        _l1 = float(_sub['loss'].sum()) / 1000
+        _em.append(dict(m=_mm, act=round(_a1, 0), loss=round(_l1, 0), theo=round(_a1 + _l1, 0),
+                        loss_pct=round(_l1 / max(_a1 + _l1, 1) * 100, 1),
+                        cov=_cov.get(_mm, 1.0),
+                        eflh=round(_a1 / max(len(CFG['turbines']) * 4.0, 1), 0)))
+    energy = dict(monthly=_em, act=round(_A, 0), loss=round(_L, 0), theo=round(_A + _L, 0),
+                  loss_pct=round(_L / max(_A + _L, 1) * 100, 1),
+                  eflh=round(_A / max(len(CFG['turbines']) * 4.0, 1), 0),
+                  cap=round(_cap, 0), cap_ok=bool(_A + _L < _cap),
+                  items=sorted(_wf, key=lambda r: -r['loss']))
+    control = dict(
+        energy=energy,
+        states=[dict(k=k, h=round(float(v), 0), pct=round(float(v / hrs_tot * 100), 1), loss=round(float(loss.get(k, 0)), 0)) for k, v in hrs.items()],
+        avail=round(float(avail * 100), 1),
+        curve=dict(sigma=round(float(pcd.dev_w.std() * 100), 2),
+                   cands=[dict(t=i, dev=round(float(r.dev_w * 100), 2), 判=r['判别']) for i, r in pcd.iterrows() if r['判别'] != '—'],
+                   note='固定判别窗 2025-H2'),
+        anchors='限电命令面占比 2025≈0.197 → 2026≈0.769 (专项锚)')
+    m8 = None
+    try:
+        from src.windscada.perf import faults as fmod
+        if (ST / 'stop_events.parquet').exists():
+            m8 = dict(mtbf=fmod.mtbf_summary(ms), stop_pareto=fmod.stop_pareto(ms), seasonal=fmod.seasonal())
+    except Exception as e:
+        m8 = dict(err=str(e)[:120])
+    m9 = None
+    try:
+        from src.windscada.perf import control as cmod
+        if (ST / 'control_profile.parquet').exists():
+            m9, m9_pending = m9_of(win)          # ★按窗重算(用户令 2026-09-21)
+    except Exception as e:
+        m9 = dict(err=str(e)[:120])
+        m9_pending = False
+    try:
+        from src.windscada.perf import reliability as relmod
+        rel = relmod.overview(CFG, span=span_of(win))
+    except Exception as e:
+        rel = dict(err=str(e)[:120])
+    try:
+        from src.windscada.subsys import fusion as fusmod
+        ftab, fmeta = fusmod.fusion_table(CFG, all_turbines=True)
+        mrows, mheads = fusmod.matrix(CFG, ms)   # 整体时间窗: 矩阵现窗/记忆轨迹随全局窗选择器
+        cap = fusmod.capability()
+        ev, ev_bounds = fusmod.drivetrain_events(ms, CFG)
+        lvcnt = {}
+        for r in mrows:
+            lv = r['振动']['level']; lvcnt[lv] = lvcnt.get(lv, 0) + 1
+        fkpi = dict(定论=lvcnt.get('bad', 0), 预警=lvcnt.get('warn', 0), 候选观察=lvcnt.get('note', 0),
+                    销案正常=lvcnt.get('ok', 0), 未列=lvcnt.get('unlisted', 0),
+                    收录台=len(CFG['turbines']) - lvcnt.get('unlisted', 0), 全场=len(CFG['turbines']),
+                    机制链=sum(1 for r in mrows if '机制链' in r['润滑'].get('note', '')),
+                    系数=dict(wear=cap['classes'].get('wear_progressive', {}).get('coef'),
+                            thermal=cap['classes'].get('thermal_acute', {}).get('coef'),
+                            unknown=cap['classes'].get('unknown', {}).get('coef')))
+        # 矩阵补 报警/工单 两列 (2026-08-28 用户令) — 报警=时间窗内真窗计数+族分布; 工单=历史台账(窗见列头)
+        # 报警/工单两列只统计传动链三部件 (2026-08-28 用户令: 只说发电机/齿轮箱/主轴) —
+        # 本矩阵每行是"该台传动链状态", 全场报警计数会把变桨/偏航/主控的量混进来 (316 条里绝大多数与传动链无关)
+        _DT_PAT = '齿轮|齿箱|润滑油|油冷|滤芯|发电机|定子|绕组|滑环|主轴承|主轴|轴承'
+        _a = _CACHE['al']; _aw = _a[_a.month.isin(ms)]
+        _aw = _aw[_aw.text.str.contains(_DT_PAT, na=False)]
+        _acnt = _aw.groupby('turbine').size()
+        _arank = _acnt.rank(ascending=False, method='min')
+        _FAM = [('齿轮箱', '齿轮|齿箱|润滑油|油冷|滤芯'), ('发电机', '发电机|定子|绕组|滑环'),
+                ('主轴承', '主轴承|主轴')]
+        try:
+            from src.windscada.subsys import workorder as _wo
+            _wod = _wo.load(CFG)
+        except Exception:
+            _wod = None
+        for _r in mrows:
+            _t = _r['turbine']; _g = _aw[_aw.turbine == _t]
+            _fam, _taken = [], set()
+            _CODEFAM = {'3225': '变桨叶片'}       # 文本为空的码走码号归族 (3225=变桨液压, 现场西门子资料对译表)
+            for _nm, _pat in _FAM:
+                _byc = _g.code.astype(str).map(_CODEFAM) == _nm
+                _sub = _g[~_g.index.isin(_taken) & (_g.text.str.contains(_pat, na=False) | _byc)]
+                _taken |= set(_sub.index)
+                if len(_sub): _fam.append(dict(k=_nm, n=int(len(_sub))))
+            _oth = len(_g) - sum(f['n'] for f in _fam)
+            if _oth > 0: _fam.append(dict(k='其他', n=int(_oth)))
+            # 刷屏闸: 单码占比过半 → 条数不代表"问题多"而是一个码在抖 (08# 4609/4918=93.7% 码3225 集中2026-01)
+            _burst = None
+            if len(_g):
+                _bc = _g.groupby('code').size().sort_values(ascending=False)
+                _share = float(_bc.iloc[0]) / len(_g)
+                if _share >= 0.5:
+                    _sub2 = _g[_g.code == _bc.index[0]]
+                    _burst = dict(code=str(_bc.index[0]), share=round(_share, 3), n=int(_bc.iloc[0]),
+                                  months=sorted({str(m)[:7] for m in _sub2.t_on.dt.to_period('M').astype(str)}),
+                                  med_s=float(_sub2.dur_s.median()) if 'dur_s' in _sub2 else None)
+            # 明细 (用户令"浮窗说明之前发生问题"): top 码 + 首末时间
+            _top = []
+            if len(_g):
+                for (_c2, _tx), _sub3 in _g.groupby(['code', 'text']):
+                    _top.append(dict(code=str(_c2), text=str(_tx)[:34], n=int(len(_sub3)),
+                                     first=str(_sub3.t_on.min())[:10], last=str(_sub3.t_on.max())[:10]))
+                _top.sort(key=lambda x: -x['n'])
+            _r['报警'] = dict(n=int(_acnt.get(_t, 0)), rank=int(_arank.get(_t, 0)) if _t in _arank else None,
+                             tot=len(CFG['turbines']), fam=_fam, burst=_burst, items=_top[:6])
+            if _wod is not None and len(_wod):
+                _w = _wod[_wod.turbine == _t]
+                _wtxt = (_w.get('故障名称', '').astype(str) + ' ' + _w.get('故障位置二级', '').astype(str)
+                         + ' ' + _w.get('维修对象', '').astype(str) + ' ' + _w.get('元器件名称', '').astype(str))
+                _w = _w[_wtxt.str.contains(_DT_PAT, na=False)]
+                _acts = _w['维修动作'].replace('', pd.NA).dropna().value_counts() if len(_w) else None
+                _wi = []
+                for _, _wr in _w.sort_values('t_report', ascending=False).head(6).iterrows():
+                    _wi.append(dict(date=str(_wr.get('t_report'))[:10], name=str(_wr.get('故障名称') or '')[:30],
+                                    act=str(_wr.get('维修动作') or ''), part=str(_wr.get('维修对象') or _wr.get('元器件名称') or '')[:14]))
+                _r['工单'] = dict(n=int(len(_w)),
+                                  acts=[dict(k=str(k), n=int(v)) for k, v in (_acts.head(3).items() if _acts is not None else [])],
+                                  items=_wi,
+                                  last=(lambda v: str(v.max())[:10] if len(v) else None)(_w.t_report.dropna()[_w.t_report.dropna().dt.year > 2000]) if len(_w) else None)
+            else:
+                _r['工单'] = dict(n=0, acts=[], last=None)
+        _wyr = ''
+        if _wod is not None and len(_wod):
+            _tv = _wod.t_report.dropna()
+            _tv = _tv[_tv.dt.year > 2000]        # 空日期落 1970 epoch, 剔除后再报窗 (否则列头写"1970~")
+            if len(_tv): _wyr = f"{_tv.min():%Y}~{_tv.max():%Y}"
+        mheads = dict(mheads, 报警=f"传动链三部件 · {ms[0]}~{ms[-1]}" if ms else '传动链三部件',
+                      工单=f"传动链三部件 · 台账 {_wyr}" if _wyr else '台账不可用')
+        # 决策链进度盘 (2026-08-28 用户问"这个到底能怎么用"): 原来只有 29# 一台的静态六格,
+        # 是展示牌不是工作面 — 打开它做不了任何决定。改为对全部需跟踪台报"走到第几步、卡在哪、下一步做什么"。
+        # 判定规则单源在此, 前端只渲染不判断。
+        _CLOOP = {}
+        try:
+            _ch = json.loads((ST.parent / 'm5_cms_tcm' / 'component_history.json').read_text(encoding='utf-8'))
+            for _k, _v in _ch.get('summary', {}).items():
+                if _k.startswith('★换件闭环') and isinstance(_v, list):
+                    for _r in _v:
+                        _CLOOP[_r['turbine']] = _r
+                if _k.startswith('★当前在升') and isinstance(_v, list):
+                    for _r in _v:
+                        _CLOOP.setdefault(_r['turbine'], {}).update(rising=_r)
+        except Exception:
+            pass
+        _HIT = {'bad', 'warn', 'note'}
+        # ★一台可能有多行 (29#/17# 各 2 行: 齿轮箱 + 主轴承)。用 {turbine: row} 直接建字典会被后一行覆盖,
+        # 实测 29# 因此取到"齿轮箱二级行星内齿圈·监视"那行, 而它的决策链讲的是"主轴承+集中润滑泵·定论"
+        # ⇒ 浮窗里部件、温度通道、油样全是另一个部件的 (memory: 画图须确认各维度来自同一个体)。
+        _SEV = {'bad': 0, 'warn': 1, 'note': 2, 'stale': 3, 'ok': 4, 'unlisted': 5}
+        _tbi = {}
+        for _row in ftab.to_dict('records'):
+            _k = _row['turbine']
+            if _k not in _tbi or _SEV.get(_row.get('级'), 9) < _SEV.get(_tbi[_k].get('级'), 9):
+                _tbi[_k] = _row
+        board = []
+        for _r in mrows:
+            t = _r['turbine']
+            srcs = [k for k in ('振动', '温度', '润滑', '油液') if (_r.get(k) or {}).get('level') in _HIT]
+            lvl = (_r.get('振动') or {}).get('level')
+            tb = _tbi.get(t, {})
+            mech = ('机制链' in ((_r.get('润滑') or {}).get('note') or '')
+                    or '机制链' in (tb.get('振动结论') or ''))
+            n_wo = (_r.get('工单') or {}).get('n') or 0
+            cl = _CLOOP.get(t) or {}
+            closed = bool(cl.get('verdict') == '恢复')
+            rising = cl.get('rising')
+            if not (lvl in _HIT or (srcs and lvl != 'ok')):
+                continue
+            # 六步: done=已达 / open=未达 / unknown=数据不足不可判
+            steps = [
+                dict(k='证据', st='done' if srcs else 'open', v=f"{len(srcs)} 源", d='/'.join(srcs)),
+                dict(k='机制', st='done' if mech else 'open',
+                     v='已定性' if mech else '未定性', d='根因定到部件与失效模式'),
+                dict(k='判级', st='done' if lvl in _HIT else 'open',
+                     v=tb.get('证据状态') or lvl or '—', d=f"设备状态 {tb.get('设备状态') or '—'}"),
+                dict(k='排期', st='done' if t == 'WTG29' else 'open',
+                     v='情景A 2026-09' if t == 'WTG29' else '未排',
+                     d='进检修排程沙盘 (风险×损失双轴)'),
+                # ★这一列一律"不可判", 不是偷懒: 工单台账止 2024-11 而判级时间窗在 2026,
+                # 台账里的历史工单不可能是针对本次问题的 ⇒ 用 n>0 判"已派工"会造出假的完成态。
+                dict(k='动作', st='unknown',
+                     v=(f"台账 {n_wo} 单" if n_wo else '台账无'),
+                     d='工单台账 2020~2024-11, 25-26 现场未提供 — 历史单不对应本次问题, 须向现场核实'),
+                # ★历史闭环不占本格: 05#/20# 2024~2025 换过齿轮箱且已验证恢复, 但本轮又有新的候选级证据,
+                # 把历史闭环填进第六格会出现"验收已达而机制未定"的自相矛盾行。历史闭环走 hist_loop 单独标记。
+                dict(k='验收', st='open', v='未验',
+                     d='本轮动作执行后复测同一判据是否回落' + (
+                        f" (该台 {cl.get('replace_date')} 有过一次闭环: {cl.get('component')} 降至 {cl.get('ratio')}×)" if closed else '')),
+            ]
+            _first = next((x for x in steps if x['st'] != 'done'), None)
+            stuck = _first['k'] if _first else None
+            stuck_kind = _first['st'] if _first else None
+            NEXT = {'证据': '补测: 四源全为常态, 先确认监测面有效',
+                    '机制': '定性: 现场/取样把根因定到部件与失效模式 — 机制不清则排期与动作都无依据',
+                    '判级': '送审: 按 RV-1 触发相应审级',
+                    '排期': '排程: 进沙盘比情景, 出建议窗口',
+                    '动作': '核实: 向现场调取 2025~2026 工单 — 台账止于 2024-11, 系统无法判定是否已派工',
+                    '验收': '复测: 动作已执行, 复测同一判据是否回落'}
+            board.append(dict(t=t, lvl=lvl, ostate=tb.get('设备状态'), estate=tb.get('证据状态'),
+                              part=tb.get('部件'), steps=steps, stuck=stuck, stuck_kind=stuck_kind,
+                              next=NEXT.get(stuck, ''), done=sum(1 for x in steps if x['st'] == 'done'),
+                              rising=rising, hist_loop=(cl if closed else None), srcs=srcs))
+        _ORD = {'bad': 0, 'warn': 1, 'note': 2}
+        board.sort(key=lambda r: (_ORD.get(r['lvl'], 3), -r['done']))
+        _cl_rows = [v for v in _CLOOP.values() if v.get('verdict') == '恢复']
+        fus = dict(链盘=dict(rows=board, closed=_cl_rows,
+                            stuck=dict(collections.Counter(r['stuck'] for r in board if r['stuck'])),
+                            wo_bound='工单台账 2020~2024-11 (现场未提供 25-26)'),
+                   表=ftab.to_dict('records'),
+                   矩阵=mrows, 列窗=mheads, kpi=fkpi,
+                   事件=ev, 事件边界=ev_bounds,
+                   能力=dict(classes=cap['classes'], blind=[str(b) for b in cap['blind']], loop=cap.get('loop', {})),
+                   窗=dict(振动=fmeta.get('振动窗', '—'), SCADA=fmeta.get('SCADA窗', ''), 油样=fmeta.get('油样窗', '')),
+                   色标=fmeta.get('色标', {}), 数据时点=fmeta.get('数据时点', ''), handoff日期=fmeta.get('handoff日期', ''),
+                   证据窗末=fmeta.get('证据窗末', ''),
+                   纪律=str(fmeta.get('纪律', '')), 盲区=[str(b) for b in fmeta.get('盲区', [])],
+                   open_items=[str(o) for o in fmeta.get('open_items', [])], gap=fmeta.get('gap'))
+    except Exception as e:
+        fus = dict(err=str(e)[:160])
+    kpi = dict(报警台=n_alarm_t, 关注台=len(watch), 全场=len(CFG['turbines']))
+    return dict(win=win, months=ms, all_months=sorted(_CACHE['tm'].month.unique()),
+                systems=systems, sysdist=sysdist, watch=watch, kpi=kpi, rel=rel, fus=fus, faults=faults, control=control, m8=m8, m9=m9,
+                # ★2026-09-21: 判级轴现在**按所选窗重算**;首次是后台算,这一份仍是旧口径 ⇒ 如实标 pending
+                sysmx_pending=bool(sysmx_pending),
+                m9_pending=bool(m9_pending),
+                win_pending=bool(sysmx_pending or m9_pending),
+                sysmx_span=list(span_of(win)),
+                note=('判级轴(变桨/偏航/蓄能/温度)按所选时间窗重算;曲线按所选时间窗重算;'
+                      '故障统计/五态/温度月轨迹/停机台账=所选窗真窗'
+                      + ('(⚠ 判级矩阵正在按所选时间窗重算,下面系统卡暂为上一份口径,稍后自动刷新)' if sysmx_pending else '')))
+

+ 4 - 1
app_common/app_common_guanlan/version.py

@@ -24,7 +24,7 @@ import json
 import pathlib
 
 NAME = '观澜·如东样板 v2'
-VERSION = '2.36.0'         # ★ 版本只改这里
+VERSION = '2.36.1'         # ★ 版本只改这里
 EDITION = 'offline-single-package'
 PACKAGE_STEM = 'app_guanlang'           # 交付包文件名前缀(用户令 2026-09-17)
 
@@ -54,6 +54,9 @@ BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/
 #   level: major/minor/patch 表示这一版**相对上一版**是哪一级变化;legacy 表示该版用的是
 #   旧编号体系(0.x,未按本规则),仅作历史对账用。
 HISTORY: tuple[dict, ...] = (
+    dict(version='2.36.1', date='2026-09-30', level='patch',
+         title='fleet 搬运完成(AST 传递闭包 591 行)但未计一致:自检仅剩 kpi.关注台 一处差异',
+         note='① 用 AST **传递闭包**把详情层 `fleet_view` 搬进算法服务(`service/fleet_views.py`,591 行):种子 `fleet_view` → 递归展开被调本地函数(共 13 个:span_of/win_range/_month_end/months_of/_load/products_stamp/_product_files/m9_of/_win_get/_mem_mb/_win_key/sysmx_of)→ 带上被引用的 12 个模块级全局(CFG/LOCK/ST/TS/_CACHE/_HEAVY_LOCK/_STAMP/_STAMP_TTL/_WIN_BUSY/_WIN_CACHE/_WIN_ERR/_WIN_LOCK)。② 本地自检(搬运版 `fleet_view("2026年")` vs 线上 `/detail/api/fleet`):**顶层 18 键,仅 `kpi.关注台` 一处不同(13 vs 15)** —— 属"重算时点/缓存"类差异(搬运版会触发按窗重算,线上那份是既有缓存结果),不是搬运漏项。③ 因此**本轮不把 `/api/fleet` 计入一致**(计分板仍 9/17),下一轮先把两侧置于同一时点(预热后各取两次、或等按窗重算完成再比)再判定。④ 期间实逮两次"漏依赖":`LOCK`、以及只算直接引用不够(`_load` 调 `products_stamp`,后者又调别的)⇒ 改为传递闭包后一次到位。'),
     dict(version='2.36.0', date='2026-09-30', level='minor',
          title='/api/channels 一致(Java 原生实现)⇒ 计分板 9/17',
          note='① 这是**第一个真正用 Java 实现**的 detail_api 端点(非代理):读契约 YAML(Spring Boot 自带 snakeyaml)→ 取每组 liveness 为“活”的列 → `{code, cn: ch_cn(col, with_tag=False) + " · " + col}`,与 Python `channel_list()`(serve.py:1974-1980)逐字对齐。② `ch_cn` 的 STEM(70 项)/SUFFIX(8 项) 数据表用 **AST 从 i18n.py 原样抽出**生成 Java 常量(不手抄,漏项会被对拍逮住)。③ 踩点记录:契约 YAML 实际在 `reference/rudong/windscada_contract.yaml`(由 `src.windscada.config.farm()` 的 contract 键给出);注解默认值里写 Windows 绝对路径会被 Java 当转义序列(反斜杠 + w 非法)⇒ 改**安装根相对路径** + 两处探测(工作目录/仓库根)。④ 为何此处不“搬进算法服务”:列名中文化属**后端展示口径**,且 Python 后端终将删除 ⇒ 用 Java 实现才符合终态。⑤ 实测:**一致 9/17** · 有差异 0 · 未实现 7 · 跳过 1;`--require` 九条门禁通过。⑥ 下一批:算数大头 **fleet**(10 个页签取数源)· curves · 然后 LLM/编排类 ask/ask_status/ask_models/rpt_compose。'),

+ 1 - 1
docs/src/数据要求说明_观澜_2.36.0.md → docs/src/数据要求说明_观澜_2.36.1.md

@@ -1,4 +1,4 @@
-# 数据要求说明 · 观澜 v2 风电场智能分析系统 · 版本 2.36.0
+# 数据要求说明 · 观澜 v2 风电场智能分析系统 · 版本 2.36.1
 
 ## 1 文档说明
 

+ 9 - 9
docs/src/系统设计说明_观澜_2.36.0.md → docs/src/系统设计说明_观澜_2.36.1.md

@@ -1,4 +1,4 @@
-# 系统设计说明 · 观澜 v2 风电场智能分析系统 · 版本 2.36.0
+# 系统设计说明 · 观澜 v2 风电场智能分析系统 · 版本 2.36.1
 
 ## 1 文档说明
 
@@ -6,19 +6,19 @@
 
 ### 1.1 目的与范围
 
-本文是"观澜 v2(风电场智能分析系统)"(海上风电场智能分析离线系统)的设计说明(下称"本系统"),面向版本 2.36.0;内容以样本风电场(下称"本场")为依据,样本场实测日期为 2026-09-22,说明本系统"由哪些部分组成、各部分怎么实现、数据从哪来到哪去、判据写在哪里、怎么验证、边界在哪里"。
+本文是"观澜 v2(风电场智能分析系统)"(海上风电场智能分析离线系统)的设计说明(下称"本系统"),面向版本 2.36.1;内容以样本风电场(下称"本场")为依据,样本场实测日期为 2026-09-22,说明本系统"由哪些部分组成、各部分怎么实现、数据从哪来到哪去、判据写在哪里、怎么验证、边界在哪里"。
 
 本文覆盖十五个设计面:总体架构与分层、目录结构与路径真源、数据接入与重算链、判级与算法、时间窗口径、服务与前端、本体与知识层、本机模型接入、运维控制台与重算编排、安装与服务化与版本管理、质量保证、安全与离线边界、可移植性与资源占用、多场适用性与换场迁移、已知边界与未实现。
 
-本文不重复需求条目本身(那是《需求分析_观澜_2.36.0.docx》的职责),也不重复操作步骤的逐步手册(那是 docs/重算操作手册_v0.1.md 与《使用说明书》v0.2(随包 docs/)的职责);本文只回答"设计上为什么这样、落在哪个文件的哪一处、用什么机器守卫保证它不漂移"。
+本文不重复需求条目本身(那是《需求分析_观澜_2.36.1.docx》的职责),也不重复操作步骤的逐步手册(那是 docs/重算操作手册_v0.1.md 与《使用说明书》v0.2(随包 docs/)的职责);本文只回答"设计上为什么这样、落在哪个文件的哪一处、用什么机器守卫保证它不漂移"。
 
 ### 1.2 读者与用法
 
 现场运维与检修人员可看第 3 章、第 7 章、第 8 章、第 11 章,了解自己能点到的页面背后读的是什么口径;场站管理人员与换场交付人员可看第 6 章、第 7 章、第 16 章、第 17 章,了解判级与可靠性指标的口径与边界,以及换场时要重新标定哪些参数;研发与交付人员应通读全文,重点是第 4 章、第 5 章、第 12 章、第 13 章;验收方可直接按第 13 章的质量门与附录 A 的编写依据逐条复核。
 
-### 1.3 与《需求分析_观澜_2.36.0.docx》的对应关系
+### 1.3 与《需求分析_观澜_2.36.1.docx》的对应关系
 
-需求分析写"要什么、为谁、优先级与验收门",设计说明写"怎么实现、落在哪、如何自证"。两文的章节对应关系如表 1-1 所示;与《数据要求说明_观澜_2.36.0.docx》的数据侧口径去向见 16.6 节。
+需求分析写"要什么、为谁、优先级与验收门",设计说明写"怎么实现、落在哪、如何自证"。两文的章节对应关系如表 1-1 所示;与《数据要求说明_观澜_2.36.1.docx》的数据侧口径去向见 16.6 节。
 
 | 本文章节 | 需求分析对应章 | 对应关系说明 |
 |---|---|---|
@@ -41,7 +41,7 @@
 
 ### 1.4 口径与依据
 
-本文所有数字来自仓库文件或命令的实跑输出,不采用估算与推测;查不到、未实现的,一律写"未取证"或"未实现",并在第 17 章汇总。本文中"本次实测"与各表"实测"列一律指样本场实测(2026-09-22):即在样本风电场的一套实例上的一次实跑,换场后这些数字会变、方法与口径不变。版本号的唯一真源是 src/version.py 的 VERSION 常量,本版为 2.36.0;打包文件名由 src/version.py 的 package_name() 给出,为 app_guanlang_v2.36.0.zip。
+本文所有数字来自仓库文件或命令的实跑输出,不采用估算与推测;查不到、未实现的,一律写"未取证"或"未实现",并在第 17 章汇总。本文中"本次实测"与各表"实测"列一律指样本场实测(2026-09-22):即在样本风电场的一套实例上的一次实跑,换场后这些数字会变、方法与口径不变。版本号的唯一真源是 src/version.py 的 VERSION 常量,本版为 2.36.1;打包文件名由 src/version.py 的 package_name() 给出,为 app_guanlang_v2.36.1.zip。
 
 本文遵循四条写作口径:含"窗"且确实指时间窗口的,一律写全"时间窗"(天气窗、作业窗、预览窗、观测窗属领域词,保持原样);影响的是风电机组时写"影响机组"或"影响机组数";使用英文简写时必须写成"中文(英文简写)"形式,如平均无故障间隔(MTBF)、平均停机间隔(MTBO)、单次停机时长(MDT);全文用简体中文与半角数字与单位。
 
@@ -839,7 +839,7 @@ Windows 安装入口是 install.bat 与 install.ps1,步骤号写死在输出
 
 ### 12.3 安装记录与版本三守卫
 
-安装记录 install-info.json 是"这台机器装的是哪一版"的唯一凭据,字段如表 12-3 所示。装完写、卸载时删(删掉等于这台机器回到"没装过")。需要如实指出:本机该文件记录的版本是 2.5.0(装于 2026-09-17),落后于当前代码版本 2.36.0,因此它正是"安装前检查会提示版本差异"的活样本;重装或升级后会随之更新。
+安装记录 install-info.json 是"这台机器装的是哪一版"的唯一凭据,字段如表 12-3 所示。装完写、卸载时删(删掉等于这台机器回到"没装过")。需要如实指出:本机该文件记录的版本是 2.5.0(装于 2026-09-17),落后于当前代码版本 2.36.1,因此它正是"安装前检查会提示版本差异"的活样本;重装或升级后会随之更新。
 
 | 字段 | 含义 |
 |---|---|
@@ -1200,7 +1200,7 @@ Linux 侧的适配程度如表 15-4 所示,全部为"已有实现但在本次
 
 ### 16.6 与同批交付文档的对应关系
 
-本章对应《需求分析_观澜_2.36.0.docx》第 11 章(场配置化、机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、换场验收检查表)与《数据要求说明_观澜_2.36.0.docx》第 12 章(通用必选与可选的判定规则、场配置字段对照、数据源形态适配、换场收资差异清单、按场裁剪步骤);本章给的是设计侧的落点、换场作业单与检查表,数据侧的收资口径与字段要求以数据要求说明为准,需求侧的验收项以需求分析为准。
+本章对应《需求分析_观澜_2.36.1.docx》第 11 章(场配置化、机组与机型可替换、数据源形态可适配、阈值按场标定、术语与单位可配、跨场统一口径、按场裁剪收资、换场验收检查表)与《数据要求说明_观澜_2.36.1.docx》第 12 章(通用必选与可选的判定规则、场配置字段对照、数据源形态适配、换场收资差异清单、按场裁剪步骤);本章给的是设计侧的落点、换场作业单与检查表,数据侧的收资口径与字段要求以数据要求说明为准,需求侧的验收项以需求分析为准。
 
 ***
 
@@ -1266,7 +1266,7 @@ Linux 侧的适配程度如表 15-4 所示,全部为"已有实现但在本次
 
 | 章节 | 来源文件 |
 |---|---|
-| 1 文档说明 | src/version.py(VERSION 与 HISTORY 的 2.36.0 条目)、docs/需求分析_观澜_2.36.0.docx、scripts/delivery_docs_figures.py |
+| 1 文档说明 | src/version.py(VERSION 与 HISTORY 的 2.36.1 条目)、docs/需求分析_观澜_2.36.1.docx、scripts/delivery_docs_figures.py |
 | 2 设计目标与原则 | docs/系统设计说明.md(四条设计铁律)、app_ontology/.../ontology/mcp_server.py 头部、src/windscada/subsys/fusion.py 头部、src/windscada/perf/reliability.py |
 | 3 总体架构 | configs/serve.json、scripts/guanlan_gateway.py、guanlan.py(组件启动表)、run/pids.json、docs/系统设计说明.md(产物全景) |
 | 4 目录结构与路径真源 | src/paths.py、configs/registry.yaml、docs/系统设计说明.md(路径约定与统一记录)、本次目录实测 |

+ 21 - 20
docs/src/需求分析_观澜_2.36.0.md → docs/src/需求分析_观澜_2.36.1.md

@@ -1,4 +1,4 @@
-# 需求分析 · 观澜 v2 风电场智能分析系统 · 版本 2.36.0
+# 需求分析 · 观澜 v2 风电场智能分析系统 · 版本 2.36.1
 
 ## 1 文档说明
 
@@ -6,7 +6,7 @@
 
 ### 1.1 目的
 
-本文是「观澜 v2(风电场智能分析系统)」的**需求分析文档**,回答四个问题:这套系统究竟要满足谁的什么需要;这些需要从哪来、经过哪些版本变成现在的样子;每一条需要对应什么功能、什么输入、什么输出、拿什么判据验收;以及哪些事本版明确不做。本文与同批交付的《系统设计说明_观澜_2.36.0.docx》《数据要求说明_观澜_2.36.0.docx》配套:本文讲「要什么、凭什么算做到了」,设计说明讲「怎么做的」,数据要求说明讲「要哪些数据、什么形态、什么单位」。
+本文是「观澜 v2(风电场智能分析系统)」的**需求分析文档**,回答四个问题:这套系统究竟要满足谁的什么需要;这些需要从哪来、经过哪些版本变成现在的样子;每一条需要对应什么功能、什么输入、什么输出、拿什么判据验收;以及哪些事本版明确不做。本文与同批交付的《系统设计说明_观澜_2.36.1.docx》《数据要求说明_观澜_2.36.1.docx》配套:本文讲「要什么、凭什么算做到了」,设计说明讲「怎么做的」,数据要求说明讲「要哪些数据、什么形态、什么单位」。
 
 本文的写作口径是**只写能取证的事实**:每个数字、每条结论都能指到仓库里的某个文件、某次实跑输出或某条版本记录;查不到、取不到的一律写明「未取证」或「未到位」,不做推测性补全。因此文中会出现少量「未取证」的说明句,那是刻意留下的诚实边界,不是遗漏。
 
@@ -18,19 +18,19 @@
 
 ### 1.3 版本对应关系
 
-系统版本号只有一个真源:仓库内的 src/version.py 的 VERSION 一行。本版 VERSION = 2.36.0,版本记录(HISTORY)共 65 条,人读的版本表由 scripts/version_log.py 从 HISTORY 生成到 docs/版本记录.md,并由 version_log.py --check 与 guanlan.py check 双重校验「记录表与代码一致」。
+系统版本号只有一个真源:仓库内的 src/version.py 的 VERSION 一行。本版 VERSION = 2.36.1,版本记录(HISTORY)共 66 条,人读的版本表由 scripts/version_log.py 从 HISTORY 生成到 docs/版本记录.md,并由 version_log.py --check 与 guanlan.py check 双重校验「记录表与代码一致」。
 
 表 1-1 版本与文档的对应关系
 
 | 项 | 值 | 取证方式 |
 |---|---|---|
-| 系统版本 | 2.36.0 | src/version.py 的 VERSION;guanlan.py check 实跑报「版本管理: 观澜 v2(风电场智能分析系统) v2.36.0」 |
-| 交付包名 | app_guanlang_v2.36.0.zip | src/version.py 的 package_name();同一条 check 输出 |
-| 本文版本 | 2.36.0(与系统版本同号) | 本文标题与 src/version.py 的 VERSION |
+| 系统版本 | 2.36.1 | src/version.py 的 VERSION;guanlan.py check 实跑报「版本管理: 观澜 v2(风电场智能分析系统) v2.36.1」 |
+| 交付包名 | app_guanlang_v2.36.1.zip | src/version.py 的 package_name();同一条 check 输出 |
+| 本文版本 | 2.36.1(与系统版本同号) | 本文标题与 src/version.py 的 VERSION |
 | 版本史条目数 | 13 条(最新一条即本次文档交付) | src/version.py 的 HISTORY;docs/版本记录.md |
 | 版本号规则 | v大版本号.中版本号.小版本号;大改方案或架构、中改非核心功能、小为消缺 | src/version.py 的 BUMP_RULE 与 LEVEL_MEANING;docs/系统设计说明.md §15 |
 | 版本级别机器判据 | level_of(旧, 新) 返回 major 或 minor 或 patch 或 same | src/version.py 的 level_of() |
-| 本次变化级别 | 小(patch,源码模块化重构推进 P1:公共层九个平台件实体迁移,旧路径留兼容转发壳;系统功能与运行为未变) | HISTORY 中 2.36.0 条目的 level 字段 |
+| 本次变化级别 | 小(patch,源码模块化重构推进 P1:公共层九个平台件实体迁移,旧路径留兼容转发壳;系统功能与运行为未变) | HISTORY 中 2.36.1 条目的 level 字段 |
 
 本版相对 2.10.0 的实质变化是**交付文档对外化**:三份交付文档(需求分析、系统设计说明、数据要求说明)全文不体现具体风电场(去标识化),并新增多风电场适用性一章;系统功能未变,属交付物修订。依据用户令原文:「修改三份文档,内容参考样本风电场,但不体现样本风电场,且具有不同风电场适用性」(引用时把样本场名按去标识化口径写成「样本风电场」)。
 
@@ -119,10 +119,11 @@
 
 ### 3.2 需求演进时间轴
 
-表 3-1 版本史与用户令要点(依据 src/version.py 的 HISTORY,共 65 条)
+表 3-1 版本史与用户令要点(依据 src/version.py 的 HISTORY,共 66 条)
 
 | 版本 | 日期 | 用户令要点 | 变化级别 |
 |---|---|---|---|
+| 2.36.1 | 2026-09-30 | fleet 搬运完成但未计一致:自检仅剩 kpi.关注台 一处差异 | 小(patch) |
 | 2.36.0 | 2026-09-30 | /api/channels 一致⇒ 计分板 9/17 | 中(minor) |
 | 2.35.0 | 2026-09-30 | /api/maint_std 逐值一致 ⇒ 计分板 8/17 | 中(minor) |
 | 2.34.0 | 2026-09-30 | /api/maint_framework 逐值一致 ⇒ 计分板 7/17 | 中(minor) |
@@ -189,7 +190,7 @@
 | 0.4.0 | 2026-09-17 | 打包默认不含输入数据与产物与日志与临时文件;无窗口启动;统一配置与日志;页面归口;服务化 | 旧编号(legacy) |
 | 0.2.0 | 2026-09-01 | 含产物的旧交付基线,历史上用作「包内无生成端」那批产物的补救源 | 旧编号(legacy) |
 
-需求的演进如图 1-1 所示。该图依据 src/version.py 的 HISTORY 绘制,覆盖上表 65 条记录(即两级旧编号 0.4.0 与 0.2.0,以及从 2.5.0 起按规则递增的二十三个版本)。按级别统计,这二十三个版本里「中」级变化32条(2.36.0、2.35.0、2.34.0、2.33.0、2.32.0、2.31.0、2.30.0、2.29.0、2.28.0、2.27.0、2.26.0、2.25.0、2.24.0、2.23.0、2.22.0、2.21.0、2.20.0、2.19.0、2.18.0、2.17.0、2.16.0、2.15.0、2.14.0、2.13.0、2.12.0、2.11.0、2.10.0、2.9.0、2.8.0、2.7.0、2.6.0、2.5.0),「小」级变化31条(2.30.1、2.29.1、2.28.1、2.24.2、2.24.1、2.23.3、2.23.2、2.23.1、2.22.1、2.21.2、2.21.1、2.15.2、2.15.1、2.12.1、2.11.9、2.11.8、2.11.7、2.11.6、2.11.5、2.11.4、2.11.3、2.11.2、2.11.1、2.10.4、2.10.3、2.10.2、2.10.1、2.9.2、2.9.1、2.8.2、2.8.1),没有大版本升级。这个分布清楚地显示出这条线的性质——**功能面在 2.5.0 到 2.7.0 之间快速铺开,之后转入以消缺与口径统一为主的密集收敛期**。
+需求的演进如图 1-1 所示。该图依据 src/version.py 的 HISTORY 绘制,覆盖上表 66 条记录(即两级旧编号 0.4.0 与 0.2.0,以及从 2.5.0 起按规则递增的二十四个版本)。按级别统计,这二十四个版本里「中」级变化32条(2.36.0、2.35.0、2.34.0、2.33.0、2.32.0、2.31.0、2.30.0、2.29.0、2.28.0、2.27.0、2.26.0、2.25.0、2.24.0、2.23.0、2.22.0、2.21.0、2.20.0、2.19.0、2.18.0、2.17.0、2.16.0、2.15.0、2.14.0、2.13.0、2.12.0、2.11.0、2.10.0、2.9.0、2.8.0、2.7.0、2.6.0、2.5.0),「小」级变化32条(2.36.1、2.30.1、2.29.1、2.28.1、2.24.2、2.24.1、2.23.3、2.23.2、2.23.1、2.22.1、2.21.2、2.21.1、2.15.2、2.15.1、2.12.1、2.11.9、2.11.8、2.11.7、2.11.6、2.11.5、2.11.4、2.11.3、2.11.2、2.11.1、2.10.4、2.10.3、2.10.2、2.10.1、2.9.2、2.9.1、2.8.2、2.8.1),没有大版本升级。这个分布清楚地显示出这条线的性质——**功能面在 2.5.0 到 2.7.0 之间快速铺开,之后转入以消缺与口径统一为主的密集收敛期**。
 
 ![图 1-1 观澜需求演进时间轴(依据 src/version.py 的 HISTORY)](figures/fig-req-01-需求演进时间轴.png)
 
@@ -208,7 +209,7 @@
 | 所有的计算均要形成观澜的源代码 | 逐族补生成端:变桨面、在升闭环、三层基线、融合面 handoff、总览页、由台账生成 claim | 反向呼应审计成立 3,548 件、不成立 0 件、未归类 0 件 |
 | 运行期一律不从交付包补齐 | 第 ⑤ 步由「补齐随包件」改为「反向呼应审计」,只报账不搬运 | rebuild_all --dry-run 计划中 ⑤ 步命令为 products_reverse_audit.py --check |
 | 清除产物不留备份 | products_state.py --off --yes 改真删除;--on 与门户恢复按钮移除 | 控制台按钮语义与 409 前置校验;docs/系统设计说明.md §7 |
-| 装成服务并在安装时检查版本 | win_service.py 以 ctypes 直连 SCM;service_main.py 与 systemd 单元;install-info.json 记录版本并比对 | check 实跑报「卸载入口在位 2 个」「版本管理 v2.36.0」 |
+| 装成服务并在安装时检查版本 | win_service.py 以 ctypes 直连 SCM;service_main.py 与 systemd 单元;install-info.json 记录版本并比对 | check 实跑报「卸载入口在位 2 个」「版本管理 v2.36.1」 |
 | 观澜改为监听所有 IP | serve.json 增 public_host 且出厂为 0.0.0.0,仅门户网关用它,组件仍绑本机 | check 实跑显示 6 个端口全部运行中;README 第二节写明无鉴权风险与回退口径 |
 | 描述口径:时间窗、影响机组、中文(英文简写) | 界面文案与报告构建器统一改写;i18n_en 键同步 | docs/系统设计说明.md §17.1 与 §17.2;语言包 845 条前后端成对 |
 | 整理需求与设计与数据接入各写一份 Word 文档 | docs/src 下 markdown 源件;渲染器输出带域目录的 docx;插图生成器生成 14 张图,数字全部从真件取 | HISTORY 2.10.0 条目;本版三份文档 |
@@ -424,7 +425,7 @@
 | NFR-09 | 安全只读:不写回现场系统、不改原始件、不产生控制指令;对外暴露时须自行在网侧限来源 | 原始件按只读输入对待,落位冲突默认拒绝;对外只有一个网关入口;启动日志每次提醒页面无鉴权 | docs/输入数据放置指导_v0.1.md §3;README_先读我.MD 第二节 |
 | NFR-10 | 可维护性:单一真源,改一处不必改多处;关键口径有机器守卫 | 版本号只有一行真源并由三处守卫盯住;路径、配置、日志、语言各有唯一取用口与审计器 | docs/系统设计说明.md §15.2 与 §11.1;docs/系统设计说明.md §3.1 |
 | NFR-11 | 无人值守与自愈:长跑的服务要能被托管并自动拉起 | 服务体每 15 秒巡检并拉起掉线组件;崩溃重启策略;远程部署一律用服务,不用会话前台进程 | docs/系统设计说明.md §14.1 与 §14.4;README_先读我.MD 第二节 |
-| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型与数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 本文第 11 章;《数据要求说明_观澜_2.36.0.docx》第 8 章与第 12 章 |
+| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型与数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 本文第 11 章;《数据要求说明_观澜_2.36.1.docx》第 8 章与第 12 章 |
 
 ### 6.2 性能与耗时实测
 
@@ -460,7 +461,7 @@
 
 | 真源 | 管什么 | 守卫与实跑结果 |
 |---|---|---|
-| src/version.py | 名称、版本、版本规则、版本史、包名 | 版本记录一致性检查;check 实跑报 v2.36.0 且记录表与代码一致 |
+| src/version.py | 名称、版本、版本规则、版本史、包名 | 版本记录一致性检查;check 实跑报 v2.36.1 且记录表与代码一致 |
 | src/paths.py | 一切路径解析的基准与助手 | 配置审计器检查不手拼路径;实跑无不一致 |
 | configs 目录与登记表 | 端口、模型档、场配置、页面归口、配置登记 | 配置审计实跑已知缺口与白名单 14 条、提示 7 条 |
 | src/logfile.py | 日志目录、命名、行格式、保留策略 | 日志审计实跑无不一致、提示 87 条 |
@@ -471,7 +472,7 @@
 
 ### 7.1 数据族与功能映射
 
-系统的数据需求可以概括成一句话:**七类现场源件加一类共享机理资料,喂出九个产物仓,页面只读产物**。每个源类目录名就是摄入接口,改名等于换接口。数据族与功能的对应关系如下表;逐类的字段、单位、必须性与质量要求见同批交付的《数据要求说明_观澜_2.36.0.docx》。
+系统的数据需求可以概括成一句话:**七类现场源件加一类共享机理资料,喂出九个产物仓,页面只读产物**。每个源类目录名就是摄入接口,改名等于换接口。数据族与功能的对应关系如下表;逐类的字段、单位、必须性与质量要求见同批交付的《数据要求说明_观澜_2.36.1.docx》。
 
 表 7-1 数据族到功能的映射(样本场实测(2026-09-22))
 
@@ -504,7 +505,7 @@
 
 ### 7.3 与数据要求说明的分工
 
-本文只回答「要哪些数据、这些数据支撑什么功能」。数据的字段级要求(核心测点的名称、单位、必须性、缺失替代、对齐规则、质量门与核对锚点)以及面向现场的收资清单,写在《数据要求说明_观澜_2.36.0.docx》里。该文档按用户令要求与现场收资文件逐条对照,并对现场收资层面的已知缺失逐条如实记录,例如测风塔数据为零交付、故障录波只有 4 台、振动侧 handoff 正本缺失由观澜自算件顶上、远端机器未安装本机模型等。
+本文只回答「要哪些数据、这些数据支撑什么功能」。数据的字段级要求(核心测点的名称、单位、必须性、缺失替代、对齐规则、质量门与核对锚点)以及面向现场的收资清单,写在《数据要求说明_观澜_2.36.1.docx》里。该文档按用户令要求与现场收资文件逐条对照,并对现场收资层面的已知缺失逐条如实记录,例如测风塔数据为零交付、故障录波只有 4 台、振动侧 handoff 正本缺失由观澜自算件顶上、远端机器未安装本机模型等。
 
 ## 8 页面与信息架构需求
 
@@ -586,7 +587,7 @@
 | 运行环境与依赖 | 10 | Python 版本不低于 3.11(实测 3.12.10);九个第三方依赖逐个导入 | 全 OK |
 | 静态质量门 | 4 | 源码可编译 229 个文件;语言包 845 条成对;入口引用闭合 48 条;入口脚本编码守则 | 全 OK |
 | 子进程口径 | 3 | 捕获输出可用;无窗口启动输出进日志;无窗口位已设,标志位 0x8000200 | 全 OK |
-| 版本与卸载入口 | 2 | 版本管理与记录表一致(v2.36.0,包名 app_guanlang_v2.36.0.zip);卸载入口两个都在位 | 全 OK |
+| 版本与卸载入口 | 2 | 版本管理与记录表一致(v2.36.1,包名 app_guanlang_v2.36.1.zip);卸载入口两个都在位 | 全 OK |
 | 制品与台账审计 | 5 | 反向呼应 3,548 件成立;页面归口检查 21 项;配置统一;日志统一;输入数据放置体检 26,503 件结构合规 | 全 OK |
 | 产物与发布件在位 | 10 | 标准仓、本体对象库、findings、事实契约、门户、仿真合页服务与资料包、三维资产、仿真回放资产、治理清单交付件 | 全 OK |
 | 原始件目录 | 1 | 原始件目录存在(无数据时此项不影响页面) | 全 OK |
@@ -712,7 +713,7 @@
 
 本章有两个词要先定义。「本场」指**当前被选中的场**,由配置选定,不必然等于本文取数的样本风电场;「换场」指从一场切到另一场运行的整套动作(改配置、放数据、重跑、按检查表复验),不是把两场的数据混在一棵树里。本章沿用的占位符与附录 A 一致:`<场>`、`<场站>`、`<机型>` 按实际风电场替换。
 
-本章需求与《数据要求说明_观澜_2.36.0.docx》《系统设计说明_观澜_2.36.0.docx》配套使用:本章讲「换场要满足什么」,数据要求说明讲「要向新场收哪些数据、哪些可以裁剪」,《设计说明》讲「配置在哪一层生效、哪些参数属于场相关层」。
+本章需求与《数据要求说明_观澜_2.36.1.docx》《系统设计说明_观澜_2.36.1.docx》配套使用:本章讲「换场要满足什么」,数据要求说明讲「要向新场收哪些数据、哪些可以裁剪」,《设计说明》讲「配置在哪一层生效、哪些参数属于场相关层」。
 
 取证边界要如实写一句:FR-44 至 FR-51 与 NFR-12 有既有的场配置层、摄入层与场级字段语义登记作依据;FR-52 的跨场横向对比是本次新增的口径要求,其实跑记录与机器守卫随后续版本补齐——本文不谎称已经通过。
 
@@ -791,7 +792,7 @@
 
 | 编号 | 需求 | 判据 | 依据文件 |
 |---|---|---|---|
-| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型、数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 《数据要求说明_观澜_2.36.0.docx》第 8 章与第 12 章;docs/输入数据放置指导_v0.1.md |
+| NFR-12 | 收资清单按场裁剪:以通用收资模板为底,按本场的机型、数据源形态与专题范围裁剪,逐条写明必须、建议、可选或可替代以及「不收会怎样」 | 每一族都有必须性分级与缺件后果且可复核;换场时清单随场定义与机型变化,不是照抄样本场 | 《数据要求说明_观澜_2.36.1.docx》第 8 章与第 12 章;docs/输入数据放置指导_v0.1.md |
 
 ### 11.9 换场验收需求
 
@@ -934,8 +935,8 @@
 | 场定义与机型与物理约束参数的写法样例 | app_ETL/configs/farms/<场>.yaml |
 | 机型—场站数据契约与字段单位真源 | app_ETL/configs/contracts/<机型>_<场>.yaml 与 app_ETL/configs/canonical/dictionary.yaml |
 | 场级字段语义登记与其它厂商导出形态的对齐 | app_ETL/configs/farms/<场>/field_semantic_registry.yaml |
-| 按场裁剪的收资清单与必须性分级 | docs/src/数据要求说明_观澜_2.36.0.md |
-| 场无关引擎与场相关参数的分层与换场作业单 | docs/src/系统设计说明_观澜_2.36.0.md |
+| 按场裁剪的收资清单与必须性分级 | docs/src/数据要求说明_观澜_2.36.1.md |
+| 场无关引擎与场相关参数的分层与换场作业单 | docs/src/系统设计说明_观澜_2.36.1.md |
 
 表中 `<场>`、`<场站>`、`<机型>` 为占位符,按实际风电场替换。
 
@@ -978,4 +979,4 @@
 | 换场 | — | 从一场切到另一场运行的整套动作:改配置、放数据、重跑、按检查表复验 | 本文 §11.9 |
 | 缺族降级 | — | 某族数据缺失时按缺件如实标注并降低该族功能,不造数、不用他场数字顶替 | 本文 FR-49 |
 | 按场标定 | — | 把阈值、时间窗锚点、限电窗、温度档带宽等场相关参数按本场取值 | 本文 FR-50 |
-| 通用收资模板 | — | 与场无关的收资条目底稿,按场裁剪后作为现场收资清单 | docs/src/数据要求说明_观澜_2.36.0.md |
+| 通用收资模板 | — | 与场无关的收资条目底稿,按场裁剪后作为现场收资清单 | docs/src/数据要求说明_观澜_2.36.1.md |

BIN
docs/数据要求说明_观澜_2.36.0.docx


BIN
docs/数据要求说明_观澜_2.36.1.docx


+ 6 - 5
docs/版本记录.md

@@ -7,7 +7,7 @@
 <!-- VERSION-LOG:BEGIN -->
 ## 版本号规则(用户令 2026-09-17)
 
-编号 **`v<大版本号>.<中版本号>.<小版本号>`**,例如 `v2.36.0`。
+编号 **`v<大版本号>.<中版本号>.<小版本号>`**,例如 `v2.36.1`。
 
 | 级别 | 含义 |
 |---|---|
@@ -17,12 +17,13 @@
 
 **怎么改**:改动落在"解决方案/架构/核心功能" → 大 +1(中/小归 0);落在"非核心功能的新增/减少/修改" → 中 +1(小归 0);只是"消缺完善" → 小 +1;一次发布混了几类,取最高那一级
 
-**打包文件名** = `app_guanlang_v2.36.0.zip`(规则:`app_guanlang_v<版本号>.zip`)。
+**打包文件名** = `app_guanlang_v2.36.1.zip`(规则:`app_guanlang_v<版本号>.zip`)。
 
-版本号只写在 `src/version.py` 一行(`VERSION = '2.36.0'`);打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。
+版本号只写在 `src/version.py` 一行(`VERSION = '2.36.1'`);打包器、安装脚本、安装记录 `install-info.json`、自检都读它,不另设真源。
 
 | 版本 | 日期 | 级别 | 主要变更 | 说明 |
 |---|---|---|---|---|
+| `v2.36.1` | 2026-09-30 | 小 | fleet 搬运完成(AST 传递闭包 591 行)但未计一致:自检仅剩 kpi.关注台 一处差异 | ① 用 AST **传递闭包**把详情层 `fleet_view` 搬进算法服务(`service/fleet_views.py`,591 行):种子 `fleet_view` → 递归展开被调本地函数(共 13 个:span_of/win_range/_month_end/months_of/_load/products_stamp/_product_files/m9_of/_win_get/_mem_mb/_win_key/sysmx_of)→ 带上被引用的 12 个模块级全局(CFG/LOCK/ST/TS/_CACHE/_HEAVY_LOCK/_STAMP/_STAMP_TTL/_WIN_BUSY/_WIN_CACHE/_WIN_ERR/_WIN_LOCK)。② 本地自检(搬运版 `fleet_view("2026年")` vs 线上 `/detail/api/fleet`):**顶层 18 键,仅 `kpi.关注台` 一处不同(13 vs 15)** —— 属"重算时点/缓存"类差异(搬运版会触发按窗重算,线上那份是既有缓存结果),不是搬运漏项。③ 因此**本轮不把 `/api/fleet` 计入一致**(计分板仍 9/17),下一轮先把两侧置于同一时点(预热后各取两次、或等按窗重算完成再比)再判定。④ 期间实逮两次"漏依赖":`LOCK`、以及只算直接引用不够(`_load` 调 `products_stamp`,后者又调别的)⇒ 改为传递闭包后一次到位。 |
 | `v2.36.0` | 2026-09-30 | 中 | /api/channels 一致(Java 原生实现)⇒ 计分板 9/17 | ① 这是**第一个真正用 Java 实现**的 detail_api 端点(非代理):读契约 YAML(Spring Boot 自带 snakeyaml)→ 取每组 liveness 为“活”的列 → `{code, cn: ch_cn(col, with_tag=False) + " · " + col}`,与 Python `channel_list()`(serve.py:1974-1980)逐字对齐。② `ch_cn` 的 STEM(70 项)/SUFFIX(8 项) 数据表用 **AST 从 i18n.py 原样抽出**生成 Java 常量(不手抄,漏项会被对拍逮住)。③ 踩点记录:契约 YAML 实际在 `reference/rudong/windscada_contract.yaml`(由 `src.windscada.config.farm()` 的 contract 键给出);注解默认值里写 Windows 绝对路径会被 Java 当转义序列(反斜杠 + w 非法)⇒ 改**安装根相对路径** + 两处探测(工作目录/仓库根)。④ 为何此处不“搬进算法服务”:列名中文化属**后端展示口径**,且 Python 后端终将删除 ⇒ 用 Java 实现才符合终态。⑤ 实测:**一致 9/17** · 有差异 0 · 未实现 7 · 跳过 1;`--require` 九条门禁通过。⑥ 下一批:算数大头 **fleet**(10 个页签取数源)· curves · 然后 LLM/编排类 ask/ask_status/ask_models/rpt_compose。 |
 | `v2.35.0` | 2026-09-30 | 中 | /api/maint_std 逐值一致 ⇒ 计分板 8/17(维护面三端点齐活) | ① `service/maint_views.py` 追加 `maint_std()`:把详情层 `/api/maint_std` 分支(`standards.run_all(wo)` + `standards.score(wo, r)`,异常 → `{err: 运维标准判据失败: …}`)**逐字搬进目标栈**;登记表新增 `maint_std`。② `MaintFrameworkController` 增加 `/api/maint_std`(代理 + 对外脱敏)。③ 实测:**一致 8/17**(facts · dq_findings · maint_survey · maint_framework · maint_std · ont_list · ont_obj · ont_chain)· 有差异 0 · 未实现 8 · 跳过 1(`/api/ask` POST);`--require` 八条门禁通过。④ 下一批:`channels` · 算数大头 `fleet`(10 个页签取数源)· `curves` · LLM 类 `ask`/`ask_status`/`ask_models`/`rpt_compose`(先给裁判补 POST 请求体构造)。 |
 | `v2.34.0` | 2026-09-30 | 中 | /api/maint_framework 逐值一致 ⇒ 计分板 7/17 | ① 新增算法服务模块 `service/maint_views.py`:把详情层 `maint_framework_view()`(serve.py:361-394)**搬进目标栈**(读同一份 objects.json、调同一批 `maint/framework.py` 函数、保留英文化分支),不是重写 ⇒ 输出与详情层应当逐值一致;登记表新增 `maint_framework`(可选 `lang`)。② 新增 `MaintFrameworkController`(Java):代理算法服务 + 对外脱敏。③ 实测:**一致 7/17** · 有差异 0 · 未实现 9 · 跳过 1;`--require` 七条门禁通过。④ "搬"这一手让数据/规则类端点的迁移成本显著下降:只要 Python 侧逻辑能整体搬进算法服务包,Java 就只是一层代理 + 脱敏,一致性天然成立;真正的重写只留给纯展示/组装逻辑。⑤ 下一批:`maint_std`(同一手法)· `channels` · 算数大头 `fleet`(10 个页签取数源)· `curves` · LLM 类 `ask`/`ask_status`/`ask_models`/`rpt_compose`。 |
@@ -91,8 +92,8 @@
 
 ## 本次交付
 
-* 版本:`v2.36.0`(观澜·如东样板 v2)
-* 交付包:`app_guanlang_v2.36.0.zip`
+* 版本:`v2.36.1`(观澜·如东样板 v2)
+* 交付包:`app_guanlang_v2.36.1.zip`
 * 服务名:`guanlan`(Windows 服务 / systemd 单元)
 * 安装 / 卸载:`install.bat`、`install.sh` / `uninstall.bat`、`uninstall.sh`
 <!-- VERSION-LOG:END -->

BIN
docs/系统设计说明_观澜_2.36.0.docx → docs/系统设计说明_观澜_2.36.1.docx


BIN
docs/需求分析_观澜_2.36.0.docx → docs/需求分析_观澜_2.36.1.docx