浏览代码

2.9.0 补:按窗重算的稳定性(重活串行 + 退化窗不可判)+ 远端部署实逮

- 重活全局串行(_HEAVY_LOCK): 预热与请求各自起线程会同时算两份按窗中间帧(服务器实测同时开算两份);
  改为排队 + 打印可用内存,峰值=1 份
- temp_nbm: 退化窗('近30日'=2026-09 只有 1 个节拍 ⇒ 比档件为空)时 groupby().apply() 返回空表 ⇒
  .rename('dev_K') 抛 TypeError 把整面判级打断;改为如实'不可判(样本不足)'(不崩、不造数)
- 远端(D:\\产品\\app): 进程必须由 Windows 服务 guanlan 托管(SSH 会话里起的进程随会话关闭而死,
  实测四端口全消失 ⇒ 已 service_ctl.py install + start,STATE: 4 RUNNING);核验: fleet 两窗
  pending→就位、变桨报警 15→7 / 偏航 15→3、rel 窗随窗、curves 图注=所选窗、v2 页含自定义起止控件
zhouyang.xie 2 周之前
父节点
当前提交
54e5afd30a
共有 4 个文件被更改,包括 48 次插入 和 4 次删除
  1. 0 1
      docs/版本记录.md
  2. 34 2
      scripts/windscada_serve.py
  3. 8 1
      src/version.py
  4. 6 0
      src/windscada/subsys/temp_nbm.py

文件差异内容过多而无法显示
+ 0 - 1
docs/版本记录.md


+ 34 - 2
scripts/windscada_serve.py

@@ -799,6 +799,29 @@ _WIN_CACHE: dict = {'sysmx': {}, 'curves': {}}
 _WIN_BUSY: dict = {'sysmx': set(), 'curves': set()}
 _WIN_ERR: dict = {'sysmx': {}, 'curves': {}}
 _WIN_LOCK = _thr.Lock()
+# ★重活**串行**(2026-09-21 服务器实逮): 预热与请求各自起线程 ⇒ 同时跑两个按窗重算(各几百 MB 级
+#   中间帧),小内存机器上进程被系统杀掉(日志停在"按窗重算启动"两行、随后网关 503)。
+#   现在全部重活排队执行:峰值 = 1 份中间帧。
+_HEAVY_LOCK = _thr.Lock()
+
+
+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):
@@ -817,7 +840,11 @@ def _win_get(kind, win, fn):
 
             def _run():
                 try:
-                    v = fn()
+                    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)
@@ -5431,12 +5458,17 @@ if __name__ == '__main__':
     #   判级矩阵 ≈25 s/窗、镜头 ≈10~45 s/窗 —— 不预热的话重启后第一个访客要等 pending 轮询。
     #   这里在**后台守护线程**里顺序把预设窗算进进程缓存(页面照常可用,未就位的窗如实标 pending)。
     def _warm():
+        if _os.environ.get('GUANLAN_WIN_WARM', '1') == '0':
+            print('[warm] 按窗预热被 GUANLAN_WIN_WARM=0 关掉(按需算,首次看窗会 pending 一轮)', flush=True)
+            return
+        import time as _t
         n = 0
         for w in list(WINDOWS):
             try:
                 sysmx_of(w)                       # 未命中即起后台线程
                 if _win_wait('sysmx', w, timeout=180) is not None:
                     n += 1
+                _t.sleep(2)                       # 留口气: 小内存机器上连续重算更容易被系统盯上
             except Exception as e:
                 print(f'[warm] 判级 {w} 失败: {type(e).__name__}: {e}'[:160], flush=True)
         for w in ('2026年',):
@@ -5445,7 +5477,7 @@ if __name__ == '__main__':
                 _win_wait('curves', w, timeout=240)
             except Exception as e:
                 print(f'[warm] 镜头 {w} 失败: {type(e).__name__}: {e}'[:160], flush=True)
-        print(f'[warm] 按窗缓存预热完成: 判级 {n}/{len(WINDOWS)} 窗', flush=True)
+        print(f'[warm] 按窗缓存预热完成: 判级 {n}/{len(WINDOWS)} 窗(可用内存 {_mem_mb()} MB)', flush=True)
 
     _thr.Thread(target=_warm, name='win-warm', daemon=True).start()
     ThreadingHTTPServer((_host, a.port), H).serve_forever()

+ 8 - 1
src/version.py

@@ -67,7 +67,14 @@ HISTORY: tuple[dict, ...] = (
               '启动时后台预热 6 个预设窗(判级+默认窗镜头); '
               '⑥ 实测(本机 38 台): 判级逐系统报警台数随窗变(变桨 15/13/10、偏航 15/11/5、齿轮箱 7/4/4 对应'
               '2026年/2025H2/2026-07),曲线图注窗=所选窗且样本量随窗变;验收: 反向审计 3548 件 0 未归类、'
-              'pages_audit/detail_deps/config_audit/raw_scan(selftest,rc=0)/chain_gap 全绿'),
+              'pages_audit/detail_deps/config_audit/raw_scan(selftest,rc=0)/chain_gap 全绿;'
+              '⑦ 远端部署(D:\\产品\\app,2026-09-21)实逮两处并已修: (a) 预热与请求各自起线程 ⇒ 同时算两份'
+              '按窗中间帧,改为**重活全局串行**(`_HEAVY_LOCK`)并打印可用内存;(b) 退化窗("近30日"=2026-09'
+              '只有 1 个节拍 ⇒ 比档件为空)时 `groupby().apply()` 返回空表 ⇒ `.rename("dev_K")` 抛 TypeError 把'
+              '整面判级打断,改为**如实"不可判(样本不足)"**;另: 远端进程须由 Windows 服务 `guanlan` 托管'
+              '(SSH 会话里起的进程会随会话关闭而死,实测全端口消失 ⇒ 已装服务并 RUNNING),'
+              '远端核验: fleet 两窗 pending→就位、变桨报警 15→7 / 偏航 15→3、rel 窗随窗、'
+              'curves 图注"窗 2026-07-01 ~ 2026-07-31 (随所选窗)"、v2 页含自定义起止控件'),
     dict(version='2.8.2', date='2026-09-21', level='patch',
          title='消缺:扫描器的"变化"判据被两类**非摄入件**常年占住 —— 现场按类目交付的月度通道组库'
                '(`scada_mdb/<年>年/<月>月/<年>-<月>-<类>.mdb`)被当成"数据没人读·缺口",用户指定的按月提取件'

+ 6 - 0
src/windscada/subsys/temp_nbm.py

@@ -165,6 +165,12 @@ def registry(cfg=None, win='cur', span=None):
     else:
         tb = pd.read_parquet(pathlib.Path(cfg['store']) / 'temp_bins.parquet')
     tb = tb[tb.win == win]
+    # ★2026-09-21 实逮(服务器按窗重算时): 窗内样本退化(如"近30日"=2026-09 只有 1 个节拍 ⇒ 比档件为空)时,
+    #   `groupby(...).apply(...)` 返回的是**空 DataFrame** 而不是 Series ⇒ `.rename('dev_K')` 抛
+    #   `TypeError: Index(...) must be called with a collection of some kind, 'dev_K' was passed`,
+    #   把整面判级打断。退化窗应当**如实"不可判"**(样本不足),不是崩。
+    if not len(tb):
+        return pd.DataFrame(columns=['turbine', 'channel', 'dev_K', '判'])
     fleet = tb.groupby(['channel', 'pbin'])['med'].median().rename('fleet_med')
     tb = tb.join(fleet, on=['channel', 'pbin'])
     tb['dev'] = tb['med'] - tb['fleet_med']

部分文件因为文件数量过多而无法显示