瀏覽代碼

init: 初始化项目提交

wmy 3 天之前
當前提交
24de9b11cf
共有 51 個文件被更改,包括 7734 次插入0 次删除
  1. 28 0
      .gitignore
  2. 148 0
      README.md
  3. 13 0
      auto_training/__init__.py
  4. 174 0
      auto_training/data_cleanup.py
  5. 865 0
      auto_training/incremental_trainer.py
  6. 157 0
      auto_training/standalone_train.py
  7. 0 0
      config/__init__.py
  8. 43 0
      config/auto_training.yaml
  9. 387 0
      config/config_api.py
  10. 402 0
      config/config_manager.py
  11. 126 0
      config/db_models.py
  12. 二進制
      config/pickup_config.db
  13. 二進制
      config/pickup_config.db-shm
  14. 0 0
      config/pickup_config.db-wal
  15. 二進制
      config/yaml_backup/db_output/pickup_config_anzhen.db
  16. 二進制
      config/yaml_backup/db_output/pickup_config_jianding.db
  17. 二進制
      config/yaml_backup/db_output/pickup_config_longting.db
  18. 二進制
      config/yaml_backup/db_output/pickup_config_longting.db-shm
  19. 0 0
      config/yaml_backup/db_output/pickup_config_longting.db-wal
  20. 二進制
      config/yaml_backup/db_output/pickup_config_xishan.db
  21. 二進制
      config/yaml_backup/db_output/pickup_config_xishan.db-shm
  22. 0 0
      config/yaml_backup/db_output/pickup_config_xishan.db-wal
  23. 二進制
      config/yaml_backup/db_output/pickup_config_yancheng.db
  24. 96 0
      config/yaml_backup/rtsp_config_anzhen.yaml
  25. 92 0
      config/yaml_backup/rtsp_config_jianding.yaml
  26. 124 0
      config/yaml_backup/rtsp_config_longting.yaml
  27. 140 0
      config/yaml_backup/rtsp_config_xishan.yaml
  28. 96 0
      config/yaml_backup/rtsp_config_yancheng.yaml
  29. 0 0
      core/__init__.py
  30. 190 0
      core/alert_aggregator.py
  31. 315 0
      core/anomaly_classifier.py
  32. 248 0
      core/energy_baseline.py
  33. 135 0
      core/human_detection_reader.py
  34. 289 0
      core/pump_state_monitor.py
  35. 52 0
      data/stable_audio_collection/LT-2/stable_periods.csv
  36. 二進制
      models/LT-2/ae_model.pth
  37. 二進制
      models/LT-2/global_scale.npy
  38. 二進制
      models/LT-2/thresholds/threshold_default.npy
  39. 二進制
      models/LT-5/ae_model.pth
  40. 二進制
      models/LT-5/global_scale.npy
  41. 二進制
      models/LT-5/thresholds/threshold_default.npy
  42. 15 0
      predictor/__init__.py
  43. 121 0
      predictor/config.py
  44. 70 0
      predictor/datasets.py
  45. 119 0
      predictor/model_def.py
  46. 263 0
      predictor/multi_model_predictor.py
  47. 137 0
      predictor/utils.py
  48. 29 0
      requirements.txt
  49. 2401 0
      run_pickup_monitor.py
  50. 277 0
      start.sh
  51. 182 0
      tool/migrate_yaml_to_db.py

+ 28 - 0
.gitignore

@@ -0,0 +1,28 @@
+# IDE
+.idea/
+*.iml
+
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+dist/
+build/
+*.egg
+
+# 虚拟环境
+.venv/
+venv/
+env/
+
+# 系统文件
+.DS_Store
+Thumbs.db
+
+# 日志
+*.log
+logs/
+
+# 模型权重文件(按需取消注释)
+# models/*.pt
+# models/*.pth

+ 148 - 0
README.md

@@ -0,0 +1,148 @@
+# 拾音器异响检测系统
+
+水泵/设备异响实时检测系统。通过 RTSP 拾音器采集音频,基于 AutoEncoder 模型进行异常检测。
+
+---
+
+## 只需 3 步
+
+### 第 1 步:配置水厂信息(只需做一次)
+
+```bash
+# 方式一:从已有 YAML 导入(推荐首次部署)
+python tool/migrate_yaml_to_db.py --yaml 你的配置.yaml --force
+
+# 方式二:通过 API 配置(系统运行后)
+# 见下方「配置管理 API」章节
+```
+
+导入后可检查 DB 信息:
+```bash
+python -c "
+import sys; sys.path.insert(0,'.')
+from config.config_manager import ConfigManager
+mgr = ConfigManager()
+cfg = mgr.get_full_config()
+for p in cfg['plants']:
+    print(f\"水厂: {p['name']} (project_id={p['project_id']}, enabled={p['enabled']})\")
+    for s in p.get('rtsp_streams', []):
+        print(f\"  设备: {s['device_code']} | {s['name']} | model={s.get('model_subdir','')} | url={s['url'][:50]}...\")
+mgr.close()
+"
+```
+
+### 第 2 步:训练模型
+
+```bash
+python auto_training/standalone_train.py --data-dir /你的音频数据目录
+```
+
+**就这一条命令。** 训练结果自动保存到 `models/{设备编码}/` 目录。
+
+数据目录要求:
+```
+你的音频数据目录/
+├── LT-2/          ← 子文件夹名 = 设备编码(与 DB 中 device_code 一致)
+│   ├── xxx.wav
+│   └── 2025-01-01/
+│       └── yyy.wav
+└── LT-5/
+    └── ...
+```
+
+可选参数:
+```bash
+--devices LT-2 LT-5    # 只训练指定设备
+--epochs 100            # 训练轮数
+--lr 0.00005            # 学习率
+```
+
+### 第 3 步:启动运行
+
+```bash
+./start.sh              # 前台运行(调试用)
+./start.sh -d           # 后台运行(生产用)
+./start.sh stop         # 停止
+./start.sh restart      # 重启
+./start.sh status       # 查看状态
+```
+
+---
+
+## 模型更新(后期维护)
+
+| 方式 | 操作 |
+|------|------|
+| 自动热加载 | 替换 `models/{设备编码}/` 下的文件,60 秒内自动生效 |
+| API 上传 | `curl -X POST http://IP:8080/api/model/upload/LT-2 -F "model_file=@ae_model.pth"` |
+| API 触发重载 | `curl -X POST http://IP:8080/api/model/reload/LT-2` |
+| 查看状态 | `curl http://IP:8080/api/model/status` |
+
+## 配置管理 API
+
+系统启动后在 `:8080` 自动提供,支持 Web 端实时修改配置。
+
+| 接口 | 方法 | 说明 |
+|------|------|------|
+| `/api/config` | GET | 获取全量配置 |
+| `/api/config/plants` | GET/POST | 水厂列表 / 创建 |
+| `/api/config/plants/{id}` | GET/PUT/DELETE | 单个水厂 CRUD |
+| `/api/config/streams` | GET/POST | RTSP 流列表 / 创建 |
+| `/api/config/streams/{id}` | PUT/DELETE | 单个流更新 / 删除 |
+| `/api/config/{section}` | GET/PUT | 系统配置读写(audio/prediction/push 等) |
+| `/api/model/status` | GET | 模型加载状态 |
+| `/api/model/reload/{code}` | POST | 重载指定设备模型 |
+| `/api/model/reload-all` | POST | 重载所有模型 |
+| `/api/model/upload/{code}` | POST | 上传模型文件并自动重载 |
+
+**可热更新**(30 秒自动生效):推送开关、告警阈值、投票参数、人体检测开关等。
+
+**需重启生效**:新增/删除 RTSP 流、修改采样率。
+
+---
+
+## 多水厂部署
+
+每个水厂独立部署一个实例,各自拥有独立的 `pickup_config.db`。
+
+```
+服务器A: deploy_pickup/ + pickup_config.db(锡山) + models/(锡山设备模型)
+服务器B: deploy_pickup/ + pickup_config.db(龙亭) + models/(龙亭设备模型)
+```
+
+## 项目结构
+
+```
+deploy_pickup/
+├── run_pickup_monitor.py      # 主入口(采集 + 检测 + 推送)
+├── start.sh                   # 启动/停止/重启脚本
+├── requirements.txt
+│
+├── config/                    # 配置
+│   ├── pickup_config.db       #   运行时配置数据库
+│   ├── config_manager.py      #   配置读写
+│   ├── config_api.py          #   REST API(:8080)
+│   ├── db_models.py           #   表定义
+│   └── auto_training.yaml     #   训练参数
+│
+├── predictor/                 # 推理
+│   ├── model_def.py           #   ConvAutoencoder(base_ch=16)
+│   ├── multi_model_predictor.py   #   多设备模型管理 + 热加载
+│   ├── config.py / datasets.py / utils.py
+│
+├── core/                      # 运行时辅助
+│   ├── alert_aggregator.py    #   跨设备告警聚合
+│   ├── anomaly_classifier.py  #   异常类型分类
+│   ├── pump_state_monitor.py  #   泵状态 PLC 查询
+│   ├── energy_baseline.py     #   泵启停判断
+│   └── human_detection_reader.py  #   人体检测抑制
+│
+├── auto_training/             # 训练(可独立运行)
+│   ├── standalone_train.py    #   ← 训练入口(就用这个)
+│   ├── incremental_trainer.py #   训练器核心
+│   └── data_cleanup.py        #   过期音频/日志清理(可选,手动运行)
+│
+├── models/{设备编码}/          # 模型(训练自动产出)
+├── tool/migrate_yaml_to_db.py # YAML → DB 迁移
+└── data/                      # 运行时音频
+```

+ 13 - 0
auto_training/__init__.py

@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+"""
+auto_training 训练模块
+
+- incremental_trainer: 训练器核心
+- standalone_train: 独立训练 CLI
+- data_cleanup: 数据清理(可选,手动运行)
+"""
+
+from .incremental_trainer import IncrementalTrainer
+from .data_cleanup import DataCleaner
+
+__all__ = ['IncrementalTrainer', 'DataCleaner']

+ 174 - 0
auto_training/data_cleanup.py

@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+data_cleanup.py
+---------------
+每日数据清理任务
+
+功能:
+1. 删除过期的正常音频(超过keep_normal_days天)
+2. 异常音频永久保留(keep_anomaly_days=-1时不删除)
+3. 删除过期的日志文件
+"""
+
+import sys
+import logging
+import shutil
+from pathlib import Path
+from datetime import datetime, timedelta
+import yaml
+
+logger = logging.getLogger('DataCleanup')
+
+
+class DataCleaner:
+    """数据清理器"""
+    
+    def __init__(self, config_file: Path):
+        """初始化清理器"""
+        self.config_file = config_file
+        self.config = self._load_config()
+        
+        # 路径配置
+        self.deploy_root = Path(__file__).parent.parent
+        self.audio_root = self.deploy_root / "data" / "audio"
+        self.anomaly_root = self.deploy_root / "data" / "anomaly_detected"
+        self.backup_dir = self.deploy_root / "models" / "backups"
+        self.logs_dir = self.deploy_root / "logs"
+    
+    def _load_config(self):
+        """加载配置"""
+        with open(self.config_file, 'r', encoding='utf-8') as f:
+            return yaml.safe_load(f)
+    
+    def cleanup_old_normal_audio(self):
+        """清理过期的正常音频"""
+        keep_days = self.config['auto_training']['data']['keep_normal_days']
+        cutoff_date = (datetime.now() - timedelta(days=keep_days)).strftime('%Y%m%d')
+        
+        logger.info(f"清理 {cutoff_date} 之前的正常音频(保留{keep_days}天)")
+        
+        total_deleted = 0
+        total_size = 0
+        
+        if not self.audio_root.exists():
+            return
+        
+        # deploy_pickup使用设备目录结构: audio/{device_code}/{date}/*.wav
+        for device_dir in self.audio_root.iterdir():
+            if not device_dir.is_dir():
+                continue
+            
+            for date_dir in device_dir.iterdir():
+                if not date_dir.is_dir() or date_dir.name == "current":
+                    continue
+                
+                # 检查日期
+                if date_dir.name < cutoff_date:
+                    if date_dir.exists():
+                        for f in date_dir.rglob("*.wav"):
+                            total_size += f.stat().st_size
+                            total_deleted += 1
+                        
+                        shutil.rmtree(date_dir)
+                        logger.info(f"已删除: {device_dir.name}/{date_dir.name}")
+        
+        logger.info(f"正常音频清理完成: 删除 {total_deleted} 个文件, 释放 {total_size / 1e6:.2f} MB")
+    
+    def cleanup_old_anomaly_audio(self):
+        """清理过期的异常音频(-1表示永久保留)"""
+        keep_days = self.config['auto_training']['data']['keep_anomaly_days']
+        
+        if keep_days < 0:
+            logger.info("异常音频配置为永久保留,跳过清理")
+            return
+        
+        cutoff_date = (datetime.now() - timedelta(days=keep_days)).strftime('%Y%m%d')
+        
+        logger.info(f"清理 {cutoff_date} 之前的异常音频(保留{keep_days}天)")
+        
+        total_deleted = 0
+        total_size = 0
+        
+        if not self.anomaly_root.exists():
+            return
+        
+        for date_dir in self.anomaly_root.iterdir():
+            if not date_dir.is_dir():
+                continue
+            
+            if date_dir.name < cutoff_date:
+                for f in date_dir.glob("*.wav"):
+                    total_size += f.stat().st_size
+                    total_deleted += 1
+                
+                shutil.rmtree(date_dir)
+                logger.info(f"已删除: anomaly/{date_dir.name}")
+        
+        logger.info(f"异常音频清理完成: 删除 {total_deleted} 个文件, 释放 {total_size / 1e6:.2f} MB")
+    
+    def cleanup_old_logs(self):
+        """清理过期的日志文件"""
+        logs_config = self.config['auto_training'].get('logs', {})
+        keep_days = logs_config.get('keep_days', 30)
+        
+        cutoff_date = datetime.now() - timedelta(days=keep_days)
+        
+        logger.info(f"清理 {keep_days} 天前的日志文件")
+        
+        total_deleted = 0
+        total_size = 0
+        
+        if not self.logs_dir.exists():
+            return
+        
+        for log_file in self.logs_dir.glob("*.log"):
+            try:
+                mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
+                
+                if mtime < cutoff_date:
+                    total_size += log_file.stat().st_size
+                    log_file.unlink()
+                    total_deleted += 1
+                    logger.info(f"已删除日志: {log_file.name}")
+            except Exception as e:
+                logger.warning(f"删除日志失败: {log_file.name} | {e}")
+        
+        logger.info(f"日志清理完成: 删除 {total_deleted} 个文件, 释放 {total_size / 1e6:.2f} MB")
+    
+    def run_cleanup(self):
+        """执行清理(主入口)"""
+        try:
+            logger.info("=" * 70)
+            logger.info("开始每日数据清理")
+            logger.info("=" * 70)
+            
+            self.cleanup_old_normal_audio()
+            self.cleanup_old_anomaly_audio()
+            self.cleanup_old_logs()
+            
+            logger.info("数据清理完成")
+            return True
+            
+        except Exception as e:
+            logger.error(f"数据清理失败: {e}", exc_info=True)
+            return False
+
+
+def main():
+    """命令行入口"""
+    logging.basicConfig(
+        level=logging.INFO,
+        format='%(asctime)s | %(levelname)-8s | %(message)s',
+        datefmt='%Y-%m-%d %H:%M:%S'
+    )
+    
+    config_file = Path(__file__).parent.parent / "config" / "auto_training.yaml"
+    cleaner = DataCleaner(config_file)
+    success = cleaner.run_cleanup()
+    
+    sys.exit(0 if success else 1)
+
+
+if __name__ == "__main__":
+    main()

+ 865 - 0
auto_training/incremental_trainer.py

@@ -0,0 +1,865 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+incremental_trainer.py
+----------------------
+模型训练模块
+
+功能:
+1. 支持全量训练(指定外部数据目录,每设备独立模型)
+2. 支持增量训练(每日自动训练)
+3. 滑动窗口提取Mel特征(8秒patches)
+4. 每设备独立计算标准化参数和阈值
+5. 产出目录结构与推理端 MultiModelPredictor 对齐
+
+产出目录结构:
+    models/
+    ├── {device_code_1}/
+    │   ├── ae_model.pth
+    │   ├── global_scale.npy
+    │   └── thresholds/
+    │       └── threshold_{device_code_1}.npy
+    └── {device_code_2}/
+        ├── ae_model.pth
+        ├── global_scale.npy
+        └── thresholds/
+            └── threshold_{device_code_2}.npy
+"""
+
+import sys
+import random
+import shutil
+import logging
+import numpy as np
+import torch
+import torch.nn as nn
+from pathlib import Path
+from datetime import datetime, timedelta
+from typing import List, Dict, Tuple, Optional
+import yaml
+
+# 添加父目录到路径
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from predictor import CFG
+from predictor.model_def import ConvAutoencoder
+from predictor.datasets import MelNPYDataset
+from predictor.utils import align_to_target
+
+logger = logging.getLogger('IncrementalTrainer')
+
+
+class IncrementalTrainer:
+    """
+    模型训练器
+
+    支持两种训练模式:
+    1. 全量训练:指定外部数据目录,每设备从零训练独立模型
+    2. 增量训练:使用运行中采集的数据,对已有模型微调(兼容旧逻辑)
+    """
+
+    def __init__(self, config_file: Path):
+        """
+        初始化训练器
+
+        Args:
+            config_file: auto_training.yaml 配置文件路径
+        """
+        self.config_file = config_file
+        self.config = self._load_config()
+
+        # 路径配置
+        self.deploy_root = Path(__file__).parent.parent
+        self.audio_root = self.deploy_root / "data" / "audio"
+        # 模型根目录(所有设备子目录的父目录)
+        self.model_root = self.deploy_root / "models"
+        self.backup_dir = self.model_root / "backups"
+
+        # 临时目录
+        self.temp_mel_dir = self.deploy_root / "data" / "temp_mels"
+
+        # 确保目录存在
+        self.backup_dir.mkdir(parents=True, exist_ok=True)
+
+        # 运行时可覆盖的配置(用于冷启动)
+        self.use_days_ago = None
+        self.sample_hours = None
+        self.epochs = None
+        self.learning_rate = None
+        self.cold_start_mode = False
+
+    def _load_config(self) -> Dict:
+        # 加载配置文件
+        with open(self.config_file, 'r', encoding='utf-8') as f:
+            return yaml.safe_load(f)
+
+    # ========================================
+    # 数据收集
+    # ========================================
+
+    def collect_from_external_dir(self, data_dir: Path,
+                                  device_filter: Optional[List[str]] = None
+                                  ) -> Dict[str, List[Path]]:
+        """
+        从外部数据目录收集训练数据
+
+        目录结构约定:
+            data_dir/
+            ├── LT-2/         <-- 子文件夹名 = device_code
+            │   ├── xxx.wav
+            │   └── ...
+            └── LT-5/
+                ├── yyy.wav
+                └── ...
+
+        支持两种子目录结构:
+        1. 扁平结构:data_dir/{device_code}/*.wav
+        2. 日期嵌套:data_dir/{device_code}/{YYYYMMDD}/*.wav
+
+        Args:
+            data_dir: 外部数据目录路径
+            device_filter: 只训练指定设备(None=全部)
+
+        Returns:
+            {device_code: [wav_files]} 按设备分组的音频文件列表
+        """
+        data_dir = Path(data_dir)
+        if not data_dir.exists():
+            raise FileNotFoundError(f"数据目录不存在: {data_dir}")
+
+        logger.info(f"扫描外部数据目录: {data_dir}")
+        device_files = {}
+
+        for sub_dir in sorted(data_dir.iterdir()):
+            # 跳过非目录
+            if not sub_dir.is_dir():
+                continue
+
+            device_code = sub_dir.name
+
+            # 应用设备过滤
+            if device_filter and device_code not in device_filter:
+                logger.info(f"  跳过设备(不在过滤列表中): {device_code}")
+                continue
+
+            audio_files = []
+
+            # 扁平结构:直接查找 wav 文件
+            audio_files.extend(list(sub_dir.glob("*.wav")))
+            audio_files.extend(list(sub_dir.glob("*.mp4")))
+
+            # 日期嵌套结构:查找子目录中的 wav 文件
+            for nested_dir in sub_dir.iterdir():
+                if nested_dir.is_dir():
+                    audio_files.extend(list(nested_dir.glob("*.wav")))
+                    audio_files.extend(list(nested_dir.glob("*.mp4")))
+
+            # 去重
+            audio_files = list(set(audio_files))
+
+            if audio_files:
+                device_files[device_code] = audio_files
+                logger.info(f"  {device_code}: {len(audio_files)} 个音频文件")
+            else:
+                logger.warning(f"  {device_code}: 无音频文件,跳过")
+
+        total = sum(len(f) for f in device_files.values())
+        logger.info(f"总计: {total} 个音频文件,{len(device_files)} 个设备")
+        return device_files
+
+    def collect_training_data(self, target_date: str) -> Dict[str, List[Path]]:
+        """
+        从内部数据目录收集训练数据(增量训练用)
+
+        Args:
+            target_date: 日期字符串 'YYYYMMDD'
+
+        Returns:
+            {device_code: [wav_files]} 按设备分组的音频文件
+        """
+        logger.info(f"收集 {target_date} 的训练数据")
+
+        sample_hours = self.config['auto_training']['incremental'].get('sample_hours', 0)
+        device_files = {}
+
+        if not self.audio_root.exists():
+            logger.warning(f"音频目录不存在: {self.audio_root}")
+            return device_files
+
+        for device_dir in self.audio_root.iterdir():
+            if not device_dir.is_dir():
+                continue
+
+            device_code = device_dir.name
+            audio_files = []
+
+            # 冷启动模式:收集所有目录的数据
+            if self.cold_start_mode:
+                # 收集current目录
+                current_dir = device_dir / "current"
+                if current_dir.exists():
+                    audio_files.extend(list(current_dir.glob("*.wav")))
+                    audio_files.extend(list(current_dir.glob("*.mp4")))
+
+                # 收集所有日期目录
+                for sub_dir in device_dir.iterdir():
+                    if sub_dir.is_dir() and sub_dir.name.isdigit() and len(sub_dir.name) == 8:
+                        audio_files.extend(list(sub_dir.glob("*.wav")))
+                        audio_files.extend(list(sub_dir.glob("*.mp4")))
+            else:
+                # 正常模式:只收集指定日期的目录
+                date_dir = device_dir / target_date
+                if date_dir.exists():
+                    audio_files.extend(list(date_dir.glob("*.wav")))
+                    audio_files.extend(list(date_dir.glob("*.mp4")))
+
+            # 加上 verified_normal 目录
+            verified_dir = device_dir / "verified_normal"
+            if verified_dir.exists():
+                audio_files.extend(list(verified_dir.glob("*.wav")))
+                audio_files.extend(list(verified_dir.glob("*.mp4")))
+
+            # 去重
+            audio_files = list(set(audio_files))
+
+            # 随机采样(如果配置了采样时长)
+            if sample_hours > 0 and audio_files:
+                files_needed = int(sample_hours * 3600 / 60)
+                if len(audio_files) > files_needed:
+                    audio_files = random.sample(audio_files, files_needed)
+                    logger.info(f"  {device_code}: 随机采样 {len(audio_files)} 个音频")
+                else:
+                    logger.info(f"  {device_code}: {len(audio_files)} 个音频(全部使用)")
+            else:
+                logger.info(f"  {device_code}: {len(audio_files)} 个音频")
+
+            if audio_files:
+                device_files[device_code] = audio_files
+
+        total = sum(len(f) for f in device_files.values())
+        logger.info(f"总计: {total} 个音频文件,{len(device_files)} 个设备")
+        return device_files
+
+    # ========================================
+    # 特征提取(每设备独立标准化参数)
+    # ========================================
+
+    def _extract_mel_for_device(self, device_code: str,
+                                wav_files: List[Path]) -> Tuple[Optional[Path], Optional[Tuple[float, float]]]:
+        """
+        为单个设备提取 Mel 特征并计算独立的 Z-score 标准化参数
+
+        两遍扫描:
+        1. 第一遍:收集所有 mel_db 计算 mean/std
+        2. 第二遍:Z-score 标准化后保存 npy 文件
+
+        Args:
+            device_code: 设备编码
+            wav_files: 该设备的音频文件列表
+
+        Returns:
+            (mel_dir, (global_mean, global_std)),失败返回 (None, None)
+        """
+        import librosa
+
+        # 滑动窗口参数
+        win_samples = int(CFG.WIN_SEC * CFG.SR)
+        hop_samples = int(CFG.HOP_SEC * CFG.SR)
+
+        # 第一遍:收集所有 mel_db 值,用于计算 mean/std
+        all_mel_data = []
+        all_values = []  # 收集所有像素值用于全局统计
+
+        for wav_file in wav_files:
+            try:
+                y, _ = librosa.load(str(wav_file), sr=CFG.SR, mono=True)
+
+                # 跳过过短的音频
+                if len(y) < CFG.SR:
+                    continue
+
+                for idx, start in enumerate(range(0, len(y) - win_samples + 1, hop_samples)):
+                    segment = y[start:start + win_samples]
+
+                    mel_spec = librosa.feature.melspectrogram(
+                        y=segment, sr=CFG.SR, n_fft=CFG.N_FFT,
+                        hop_length=CFG.HOP_LENGTH, n_mels=CFG.N_MELS, power=2.0
+                    )
+                    mel_db = librosa.power_to_db(mel_spec, ref=np.max)
+
+                    # 对齐帧数
+                    if mel_db.shape[1] < CFG.TARGET_FRAMES:
+                        pad = CFG.TARGET_FRAMES - mel_db.shape[1]
+                        mel_db = np.pad(mel_db, ((0, 0), (0, pad)), mode="constant")
+                    else:
+                        mel_db = mel_db[:, :CFG.TARGET_FRAMES]
+
+                    # 收集所有值用于 min/max 计算
+                    all_values.append(mel_db.flatten())
+                    all_mel_data.append((wav_file, idx, mel_db))
+
+            except Exception as e:
+                logger.warning(f"跳过文件 {wav_file.name}: {e}")
+                continue
+
+        if not all_mel_data:
+            logger.warning(f"  {device_code}: 无有效数据")
+            return None, None
+
+        # 计算全局 min/max(Min-Max 标准化参数)
+        all_values_concat = np.concatenate(all_values)
+        global_min = float(np.min(all_values_concat))
+        global_max = float(np.max(all_values_concat))
+
+        logger.info(f"  {device_code}: {len(all_mel_data)} patches | "
+                    f"min={global_min:.4f} max={global_max:.4f}")
+
+        # 第二遍:Min-Max 标准化并保存
+        device_mel_dir = self.temp_mel_dir / device_code
+        device_mel_dir.mkdir(parents=True, exist_ok=True)
+
+        for wav_file, idx, mel_db in all_mel_data:
+            # Min-Max: (x - min) / (max - min)
+            mel_norm = (mel_db - global_min) / (global_max - global_min + 1e-6)
+            npy_name = f"{device_code}@@{wav_file.stem}@@win{idx:05d}.npy"
+            np.save(device_mel_dir / npy_name, mel_norm.astype(np.float32))
+
+        return device_mel_dir, (global_min, global_max)
+
+    def prepare_mel_features_per_device(self, device_files: Dict[str, List[Path]]
+                                        ) -> Dict[str, Tuple[Path, Tuple[float, float]]]:
+        """
+        为每个设备独立提取 Mel 特征
+
+        每设备分别计算自己的 Min-Max 标准化参数 (global_min, global_max)
+
+        Args:
+            device_files: {device_code: [wav_files]}
+
+        Returns:
+            {device_code: (mel_dir, (global_min, global_max))}
+        """
+        logger.info("提取 Mel 特征(每设备独立标准化)")
+
+        # 清空临时目录
+        if self.temp_mel_dir.exists():
+            shutil.rmtree(self.temp_mel_dir)
+        self.temp_mel_dir.mkdir(parents=True, exist_ok=True)
+
+        device_results = {}
+
+        for device_code, wav_files in device_files.items():
+            mel_dir, scale = self._extract_mel_for_device(device_code, wav_files)
+            if mel_dir is not None:
+                device_results[device_code] = (mel_dir, scale)
+
+        total_patches = sum(
+            len(list(mel_dir.glob("*.npy")))
+            for mel_dir, _ in device_results.values()
+        )
+        logger.info(f"提取完成: {total_patches} patches,{len(device_results)} 个设备")
+        return device_results
+
+    # ========================================
+    # 模型训练(每设备独立)
+    # ========================================
+
+    def train_single_device(self, device_code: str, mel_dir: Path,
+                            epochs: int, lr: float,
+                            from_scratch: bool = True
+                            ) -> Tuple[nn.Module, float]:
+        """
+        训练单个设备的独立模型
+
+        Args:
+            device_code: 设备编码
+            mel_dir: 该设备的 Mel 特征目录
+            epochs: 训练轮数
+            lr: 学习率
+            from_scratch: True=从零训练(全量),False=加载已有模型微调(增量)
+
+        Returns:
+            (model, final_loss)
+        """
+        logger.info(f"训练设备 {device_code}: epochs={epochs}, lr={lr}, "
+                    f"mode={'全量' if from_scratch else '增量'}")
+
+        device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
+        model = ConvAutoencoder().to(device)
+
+        # 增量模式下加载已有模型
+        if not from_scratch:
+            model_path = self.model_root / device_code / "ae_model.pth"
+            if model_path.exists():
+                model.load_state_dict(torch.load(model_path, map_location=device))
+                logger.info(f"  已加载已有模型: {model_path}")
+            else:
+                logger.warning(f"  模型不存在,自动切换为全量训练: {model_path}")
+
+        # 加载数据
+        dataset = MelNPYDataset(mel_dir)
+        if len(dataset) == 0:
+            raise ValueError(f"设备 {device_code} 无训练数据")
+
+        batch_size = self.config['auto_training']['incremental']['batch_size']
+        dataloader = torch.utils.data.DataLoader(
+            dataset, batch_size=batch_size, shuffle=True, num_workers=0
+        )
+
+        # 训练
+        model.train()
+        optimizer = torch.optim.Adam(model.parameters(), lr=lr)
+        criterion = nn.MSELoss()
+
+        avg_loss = 0.0
+        for epoch in range(epochs):
+            epoch_loss = 0.0
+            batch_count = 0
+
+            for batch in dataloader:
+                batch = batch.to(device)
+                optimizer.zero_grad()
+                output = model(batch)
+                output = align_to_target(output, batch)
+                loss = criterion(output, batch)
+                loss.backward()
+                optimizer.step()
+                epoch_loss += loss.item()
+                batch_count += 1
+
+            avg_loss = epoch_loss / batch_count
+            # 每10轮或最后一轮打印日志,避免日志刷屏
+            if (epoch + 1) % 10 == 0 or epoch == epochs - 1:
+                logger.info(f"  [{device_code}] Epoch {epoch+1}/{epochs} | Loss: {avg_loss:.6f}")
+
+        return model, avg_loss
+
+    # ========================================
+    # 产出部署(每设备独立目录)
+    # ========================================
+
+    def deploy_device_model(self, device_code: str, model: nn.Module,
+                            scale: Tuple[float, float], mel_dir: Path):
+        """
+        部署单个设备的模型到 models/{device_code}/ 目录
+
+        产出文件:
+        - models/{device_code}/ae_model.pth
+        - models/{device_code}/global_scale.npy  → [mean, std]
+        - models/{device_code}/thresholds/threshold_{device_code}.npy
+
+        Args:
+            device_code: 设备编码
+            model: 训练后的模型
+            scale: (global_min, global_max) Min-Max 标准化参数
+            mel_dir: 该设备的 Mel 特征目录(用于计算阈值)
+        """
+        # 创建设备模型目录
+        device_model_dir = self.model_root / device_code
+        device_model_dir.mkdir(parents=True, exist_ok=True)
+
+        # 1. 保存模型权重
+        model_path = device_model_dir / "ae_model.pth"
+        torch.save(model.state_dict(), model_path)
+        logger.info(f"  模型已保存: {model_path}")
+
+        # 2. 保存 Min-Max 标准化参数 [min, max]
+        scale_path = device_model_dir / "global_scale.npy"
+        np.save(scale_path, np.array([scale[0], scale[1]]))
+        logger.info(f"  标准化参数已保存: {scale_path}")
+
+        # 3. 计算并保存阈值
+        threshold_dir = device_model_dir / "thresholds"
+        threshold_dir.mkdir(parents=True, exist_ok=True)
+        threshold = self._compute_threshold(model, mel_dir)
+        threshold_file = threshold_dir / f"threshold_{device_code}.npy"
+        np.save(threshold_file, threshold)
+        logger.info(f"  阈值已保存: {threshold_file} (值={threshold:.6f})")
+
+    def _compute_threshold(self, model: nn.Module, mel_dir: Path) -> float:
+        """
+        计算单个设备的阈值
+
+        使用 3σ 法则:threshold = mean + 3 * std
+
+        Args:
+            model: 训练后的模型
+            mel_dir: 该设备的 Mel 特征目录
+
+        Returns:
+            阈值(标量)
+        """
+        device = next(model.parameters()).device
+        model.eval()
+
+        dataset = MelNPYDataset(mel_dir)
+        if len(dataset) == 0:
+            logger.warning("无数据计算阈值,使用默认值 0.01")
+            return 0.01
+
+        dataloader = torch.utils.data.DataLoader(
+            dataset, batch_size=64, shuffle=False
+        )
+
+        all_errors = []
+        with torch.no_grad():
+            for batch in dataloader:
+                batch = batch.to(device)
+                output = model(batch)
+                output = align_to_target(output, batch)
+                mse = torch.mean((output - batch) ** 2, dim=[1, 2, 3])
+                all_errors.append(mse.cpu().numpy())
+
+        errors = np.concatenate(all_errors)
+        # 3σ 法则
+        mean_err = float(np.mean(errors))
+        std_err = float(np.std(errors))
+        threshold = mean_err + 3 * std_err
+
+        logger.info(f"  阈值统计: 3σ={threshold:.6f} | "
+                    f"mean={mean_err:.6f} std={std_err:.6f} | "
+                    f"样本数={len(errors)}")
+
+        return threshold
+
+    # ========================================
+    # 全量训练入口
+    # ========================================
+
+    def run_full_training(self, data_dir: Path,
+                          epochs: Optional[int] = None,
+                          lr: Optional[float] = None,
+                          device_filter: Optional[List[str]] = None) -> bool:
+        """
+        全量训练入口:每设备从零训练独立模型
+
+        流程:
+        1. 扫描外部数据目录
+        2. 每设备独立提取 Mel 特征+标准化参数
+        3. 每设备独立训练模型
+        4. 每设备独立部署(模型+标准化+阈值)
+
+        Args:
+            data_dir: 数据目录路径
+            epochs: 训练轮数(None=使用配置文件值)
+            lr: 学习率(None=使用配置文件值)
+            device_filter: 只训练指定设备(None=全部)
+
+        Returns:
+            bool: 是否成功
+        """
+        try:
+            epochs = epochs or self.config['auto_training']['incremental']['epochs']
+            lr = lr or self.config['auto_training']['incremental']['learning_rate']
+
+            logger.info("=" * 60)
+            logger.info(f"全量训练 | 数据目录: {data_dir}")
+            logger.info(f"参数: epochs={epochs}, lr={lr}")
+            if device_filter:
+                logger.info(f"设备过滤: {device_filter}")
+            logger.info("=" * 60)
+
+            # 1. 收集数据
+            device_files = self.collect_from_external_dir(data_dir, device_filter)
+            if not device_files:
+                logger.error("无可用训练数据")
+                return False
+
+            # 2. 每设备提取特征
+            device_results = self.prepare_mel_features_per_device(device_files)
+            if not device_results:
+                logger.error("特征提取失败")
+                return False
+
+            # 3. 每设备独立训练+部署
+            success_count = 0
+            fail_count = 0
+
+            for device_code, (mel_dir, scale) in device_results.items():
+                try:
+                    logger.info(f"\n--- 训练设备: {device_code} ---")
+
+                    # 训练
+                    model, final_loss = self.train_single_device(
+                        device_code, mel_dir, epochs, lr, from_scratch=True
+                    )
+
+                    # 验证
+                    if not self._validate_model(model):
+                        logger.error(f"  {device_code}: 模型验证失败,跳过部署")
+                        fail_count += 1
+                        continue
+
+                    # 部署到 models/{device_code}/
+                    self.deploy_device_model(device_code, model, scale, mel_dir)
+                    success_count += 1
+                    logger.info(f"  {device_code}: 训练+部署完成 | loss={final_loss:.6f}")
+
+                except Exception as e:
+                    logger.error(f"  {device_code}: 训练失败 | {e}", exc_info=True)
+                    fail_count += 1
+
+            logger.info("=" * 60)
+            logger.info(f"全量训练完成: 成功={success_count}, 失败={fail_count}")
+            logger.info("=" * 60)
+            return fail_count == 0
+
+        except Exception as e:
+            logger.error(f"全量训练异常: {e}", exc_info=True)
+            return False
+
+        finally:
+            # 清理临时文件
+            if self.temp_mel_dir.exists():
+                shutil.rmtree(self.temp_mel_dir)
+
+    # ========================================
+    # 增量训练入口(保留兼容)
+    # ========================================
+
+    def run_daily_training(self) -> bool:
+        """
+        执行每日增量训练(保留原有逻辑)
+
+        改造点:每设备独立训练+部署,不再共享模型
+
+        Returns:
+            bool: 是否成功
+        """
+        try:
+            days_ago = (self.use_days_ago if self.use_days_ago is not None
+                        else self.config['auto_training']['incremental']['use_days_ago'])
+            target_date = (datetime.now() - timedelta(days=days_ago)).strftime('%Y%m%d')
+
+            mode_str = "冷启动训练" if self.cold_start_mode else "增量训练"
+            logger.info("=" * 60)
+            logger.info(f"{mode_str} - {target_date}")
+            logger.info("=" * 60)
+
+            # 1. 收集数据
+            device_files = self.collect_training_data(target_date)
+            total = sum(len(f) for f in device_files.values())
+            min_samples = self.config['auto_training']['incremental']['min_samples']
+            if total < min_samples:
+                logger.warning(f"数据不足 ({total} < {min_samples}),跳过")
+                return False
+
+            # 2. 备份模型
+            if self.config['auto_training']['model']['backup_before_train']:
+                self.backup_model(target_date)
+
+            # 3. 每设备提取特征
+            device_results = self.prepare_mel_features_per_device(device_files)
+            if not device_results:
+                logger.error("特征提取失败")
+                return False
+
+            # 4. 训练参数
+            epochs = (self.epochs if self.epochs is not None
+                      else self.config['auto_training']['incremental']['epochs'])
+            lr = (self.learning_rate if self.learning_rate is not None
+                  else self.config['auto_training']['incremental']['learning_rate'])
+
+            # 5. 每设备独立训练+部署
+            # 冷启动=全量训练(从零),增量=加载已有模型微调
+            from_scratch = self.cold_start_mode
+
+            success_count = 0
+            for device_code, (mel_dir, scale) in device_results.items():
+                try:
+                    model, _ = self.train_single_device(
+                        device_code, mel_dir, epochs, lr, from_scratch=from_scratch
+                    )
+
+                    if self._validate_model(model):
+                        if self.config['auto_training']['model']['auto_deploy']:
+                            self.deploy_device_model(device_code, model, scale, mel_dir)
+                        success_count += 1
+                    else:
+                        logger.error(f"{device_code}: 验证失败,跳过部署")
+                except Exception as e:
+                    logger.error(f"{device_code}: 训练失败 | {e}")
+
+            # 6. 更新分类器基线
+            self._update_classifier_baseline(device_files)
+
+            logger.info("=" * 60)
+            logger.info(f"增量训练完成: {success_count}/{len(device_results)} 个设备成功")
+            logger.info("=" * 60)
+            return success_count > 0
+
+        except Exception as e:
+            logger.error(f"训练失败: {e}", exc_info=True)
+            return False
+
+        finally:
+            if self.temp_mel_dir.exists():
+                shutil.rmtree(self.temp_mel_dir)
+
+    # ========================================
+    # 辅助方法
+    # ========================================
+
+    def _validate_model(self, model: nn.Module) -> bool:
+        # 验证模型输出形状是否合理
+        if not self.config['auto_training']['validation']['enabled']:
+            return True
+
+        try:
+            device = next(model.parameters()).device
+            test_input = torch.randn(1, 1, CFG.N_MELS, CFG.TARGET_FRAMES).to(device)
+            with torch.no_grad():
+                output = model(test_input)
+
+            h_diff = abs(output.shape[2] - test_input.shape[2])
+            w_diff = abs(output.shape[3] - test_input.shape[3])
+
+            if h_diff > 8 or w_diff > 8:
+                logger.error(f"形状差异过大: {output.shape} vs {test_input.shape}")
+                return False
+
+            return True
+        except Exception as e:
+            logger.error(f"验证失败: {e}")
+            return False
+
+    def backup_model(self, date_tag: str):
+        """
+        完整备份当前所有设备的模型
+
+        备份目录结构:
+        backups/{date_tag}/{device_code}/
+            ├── ae_model.pth
+            ├── global_scale.npy
+            └── thresholds/
+        """
+        backup_date_dir = self.backup_dir / date_tag
+        backup_date_dir.mkdir(parents=True, exist_ok=True)
+
+        backed_up = 0
+        # 遍历 models/ 下的所有设备子目录
+        for device_dir in self.model_root.iterdir():
+            if not device_dir.is_dir():
+                continue
+            # 跳过 backups 目录本身
+            if device_dir.name == "backups":
+                continue
+            # 检查是否包含模型文件(判断是否为设备目录)
+            if not (device_dir / "ae_model.pth").exists():
+                continue
+
+            device_backup = backup_date_dir / device_dir.name
+            # 递归复制整个设备目录
+            shutil.copytree(device_dir, device_backup, dirs_exist_ok=True)
+            backed_up += 1
+
+        logger.info(f"备份完成: {backed_up} 个设备 -> {backup_date_dir}")
+
+        # 清理旧备份
+        keep = self.config['auto_training']['model']['keep_backups']
+        backup_dirs = sorted(
+            [d for d in self.backup_dir.iterdir() if d.is_dir() and d.name.isdigit()],
+            reverse=True
+        )
+        for old_dir in backup_dirs[keep:]:
+            shutil.rmtree(old_dir)
+            logger.info(f"已删除旧备份: {old_dir.name}")
+
+    def restore_backup(self, date_tag: str) -> bool:
+        """
+        从备份恢复所有设备的模型
+
+        Args:
+            date_tag: 备份日期标签 'YYYYMMDD'
+
+        Returns:
+            bool: 是否恢复成功
+        """
+        backup_date_dir = self.backup_dir / date_tag
+        if not backup_date_dir.exists():
+            logger.error(f"备份目录不存在: {backup_date_dir}")
+            return False
+
+        logger.info(f"从备份恢复: {date_tag}")
+        restored = 0
+
+        for device_backup in backup_date_dir.iterdir():
+            if not device_backup.is_dir():
+                continue
+
+            target_dir = self.model_root / device_backup.name
+            # 递归复制恢复
+            shutil.copytree(device_backup, target_dir, dirs_exist_ok=True)
+            restored += 1
+
+        logger.info(f"恢复完成: {restored} 个设备")
+        return restored > 0
+
+    def _update_classifier_baseline(self, device_files: Dict[str, List[Path]]):
+        # 从训练数据计算并更新分类器基线
+        logger.info("更新分类器基线")
+
+        try:
+            import librosa
+            from core.anomaly_classifier import AnomalyClassifier
+
+            classifier = AnomalyClassifier()
+
+            all_files = []
+            for files in device_files.values():
+                all_files.extend(files)
+
+            if not all_files:
+                logger.warning("无音频文件,跳过基线更新")
+                return
+
+            sample_files = random.sample(all_files, min(50, len(all_files)))
+
+            all_features = []
+            for wav_file in sample_files:
+                try:
+                    y, _ = librosa.load(str(wav_file), sr=CFG.SR, mono=True)
+                    if len(y) < CFG.SR:
+                        continue
+                    features = classifier.extract_features(y, sr=CFG.SR)
+                    if features:
+                        all_features.append(features)
+                except Exception:
+                    continue
+
+            if not all_features:
+                logger.warning("无法提取特征,跳过基线更新")
+                return
+
+            baseline = {}
+            keys = all_features[0].keys()
+            for key in keys:
+                if key == 'has_periodic':
+                    values = [f[key] for f in all_features]
+                    baseline[key] = sum(values) > len(values) / 2
+                else:
+                    values = [f[key] for f in all_features]
+                    baseline[key] = float(np.mean(values))
+
+            classifier.save_baseline(baseline)
+            logger.info(f"  基线已更新 (样本数: {len(all_features)})")
+
+        except Exception as e:
+            logger.warning(f"更新基线失败: {e}")
+
+
+def main():
+    # 命令行入口(增量训练)
+    logging.basicConfig(
+        level=logging.INFO,
+        format='%(asctime)s | %(levelname)-8s | %(message)s',
+        datefmt='%Y-%m-%d %H:%M:%S'
+    )
+
+    config_file = Path(__file__).parent.parent / "config" / "auto_training.yaml"
+    trainer = IncrementalTrainer(config_file)
+    success = trainer.run_daily_training()
+    sys.exit(0 if success else 1)
+
+
+if __name__ == "__main__":
+    main()

+ 157 - 0
auto_training/standalone_train.py

@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+standalone_train.py
+-------------------
+独立全量训练 CLI 入口
+
+使用方法:
+    # 训练指定数据目录下的所有设备
+    python auto_training/standalone_train.py --data-dir /path/to/plant_audio_data
+
+    # 只训练指定设备
+    python auto_training/standalone_train.py --data-dir /path/to/data --devices LT-2 LT-5
+
+    # 自定义训练参数
+    python auto_training/standalone_train.py --data-dir /path/to/data --epochs 100 --lr 0.0001
+
+数据目录结构约定:
+    data_dir/
+    ├── LT-2/                 <-- 子文件夹名 = device_code
+    │   ├── xxx.wav           <-- 扁平结构
+    │   └── ...
+    └── LT-5/
+        ├── 20260301/         <-- 或日期嵌套结构
+        │   ├── yyy.wav
+        │   └── ...
+        └── 20260302/
+            └── ...
+
+产出目录结构:
+    models/
+    ├── LT-2/
+    │   ├── ae_model.pth
+    │   ├── global_scale.npy
+    │   └── thresholds/
+    │       └── threshold_LT-2.npy
+    └── LT-5/
+        ├── ae_model.pth
+        ├── global_scale.npy
+        └── thresholds/
+            └── threshold_LT-5.npy
+"""
+
+import sys
+import argparse
+import logging
+from pathlib import Path
+
+# 添加项目根目录到路径
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="泵异响检测模型 - 全量训练工具",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+示例:
+  python standalone_train.py --data-dir /data/xishan_audio
+  python standalone_train.py --data-dir /data/xishan_audio --devices LT-2 LT-5
+  python standalone_train.py --data-dir /data/xishan_audio --epochs 100 --lr 0.00005
+        """
+    )
+
+    parser.add_argument(
+        "--data-dir", type=str, required=True,
+        help="训练数据目录路径(子文件夹名=设备编码)"
+    )
+    parser.add_argument(
+        "--epochs", type=int, default=None,
+        help="训练轮数(默认使用 auto_training.yaml 中的配置)"
+    )
+    parser.add_argument(
+        "--lr", type=float, default=None,
+        help="学习率(默认使用 auto_training.yaml 中的配置)"
+    )
+    parser.add_argument(
+        "--batch-size", type=int, default=None,
+        help="批大小(默认使用 auto_training.yaml 中的配置)"
+    )
+    parser.add_argument(
+        "--devices", nargs="+", default=None,
+        help="只训练指定设备(空格分隔,如 --devices LT-2 LT-5)"
+    )
+    parser.add_argument(
+        "--log-level", type=str, default="INFO",
+        choices=["DEBUG", "INFO", "WARNING", "ERROR"],
+        help="日志级别(默认 INFO)"
+    )
+
+    args = parser.parse_args()
+
+    # 配置日志
+    logging.basicConfig(
+        level=getattr(logging, args.log_level),
+        format='%(asctime)s | %(levelname)-8s | %(name)s | %(message)s',
+        datefmt='%Y-%m-%d %H:%M:%S'
+    )
+
+    logger = logging.getLogger('StandaloneTrain')
+
+    # 验证数据目录
+    data_dir = Path(args.data_dir)
+    if not data_dir.exists():
+        logger.error(f"数据目录不存在: {data_dir}")
+        sys.exit(1)
+
+    # 加载配置
+    config_file = Path(__file__).parent.parent / "config" / "auto_training.yaml"
+    if not config_file.exists():
+        logger.error(f"配置文件不存在: {config_file}")
+        sys.exit(1)
+
+    # 初始化训练器
+    from auto_training.incremental_trainer import IncrementalTrainer
+
+    trainer = IncrementalTrainer(config_file)
+
+    # 覆盖 batch_size(如果指定)
+    if args.batch_size:
+        trainer.config['auto_training']['incremental']['batch_size'] = args.batch_size
+
+    # 执行全量训练
+    logger.info("=" * 60)
+    logger.info("泵异响检测模型 - 全量训练")
+    logger.info(f"数据目录: {data_dir}")
+    logger.info(f"设备过滤: {args.devices or '全部'}")
+    logger.info(f"训练轮数: {args.epochs or '配置默认'}")
+    logger.info(f"学习率:   {args.lr or '配置默认'}")
+    logger.info(f"批大小:   {args.batch_size or '配置默认'}")
+    logger.info("=" * 60)
+
+    success = trainer.run_full_training(
+        data_dir=data_dir,
+        epochs=args.epochs,
+        lr=args.lr,
+        device_filter=args.devices
+    )
+
+    if success:
+        logger.info("训练全部完成,模型已部署到 models/ 目录")
+        # 列出产出文件
+        model_root = trainer.model_root
+        for device_dir in sorted(model_root.iterdir()):
+            if device_dir.is_dir() and (device_dir / "ae_model.pth").exists():
+                threshold_dir = device_dir / "thresholds"
+                threshold_files = list(threshold_dir.glob("*.npy")) if threshold_dir.exists() else []
+                logger.info(f"  {device_dir.name}/: "
+                           f"model=OK, scale=OK, thresholds={len(threshold_files)}")
+    else:
+        logger.error("训练存在失败,请检查日志")
+
+    sys.exit(0 if success else 1)
+
+
+if __name__ == "__main__":
+    main()

+ 0 - 0
config/__init__.py


+ 43 - 0
config/auto_training.yaml

@@ -0,0 +1,43 @@
+# 自动增量训练配置
+
+auto_training:
+  # 总开关
+  enabled: false                # 暂时关闭自动增训
+  
+  # 数据管理
+  data:
+    keep_normal_days: 7         # 正常音频保留天数
+    keep_anomaly_days: -1       # 异常音频保留天数(-1=永久)
+    cleanup_time: "00:00"       # 每日清理时间(0点)
+    
+  # 增量训练配置
+  incremental:
+    enabled: true
+    schedule_time: "02:00"      # 每日训练时间
+    
+    # 数据采样
+    use_days_ago: 1             # 使用N天前的数据(1=昨天)
+    sample_hours: 2             # 随机采样时长(小时),0=使用全部
+    min_samples: 50             # 最少样本数,不足则跳过
+    
+    # 训练参数
+    epochs: 50                   # 训练轮数
+    learning_rate: 0.0001       # 学习率
+    batch_size: 64              # 批大小
+    
+  # 模型管理
+  model:
+    backup_before_train: true   # 训练前备份
+    keep_backups: 7             # 保留备份数量
+    auto_deploy: true           # 自动部署新模型
+    update_thresholds: true     # 训练后更新阈值npy
+    
+  # 验证配置
+  validation:
+    enabled: true
+  
+  # 冷启动配置(新水厂无模型时)
+  cold_start:
+    enabled: true
+    wait_hours: 2               # 等待收集数据时长
+    min_samples: 100            # 最少样本数

+ 387 - 0
config/config_api.py

@@ -0,0 +1,387 @@
+import logging
+import shutil
+from pathlib import Path
+from typing import Optional
+from fastapi import FastAPI, HTTPException, Query, UploadFile, File
+from pydantic import BaseModel, Field
+from typing import List, Dict, Any
+
+from config.config_manager import ConfigManager
+
+logger = logging.getLogger(__name__)
+
+# FastAPI 实例(由主程序挂载或独立运行)
+app = FastAPI(title="拾音器配置管理 API", version="1.0.0")
+
+# 全局 ConfigManager 实例(由 init_config_api 注入)
+_config_mgr: Optional[ConfigManager] = None
+# 全局 MultiModelPredictor 实例(由 init_config_api 注入,用于模型热加载 API)
+_multi_predictor = None
+
+
+def init_config_api(config_manager: ConfigManager, multi_predictor=None):
+    # 在主程序启动时调用,注入 ConfigManager 和 MultiModelPredictor 实例
+    global _config_mgr, _multi_predictor
+    _config_mgr = config_manager
+    _multi_predictor = multi_predictor
+    logger.info("配置管理 API 已初始化")
+
+
+def get_mgr() -> ConfigManager:
+    if _config_mgr is None:
+        raise HTTPException(status_code=500, detail="ConfigManager 未初始化")
+    return _config_mgr
+
+
+# ========================================
+# Pydantic 模型(请求/响应结构)
+# ========================================
+
+class PlantCreate(BaseModel):
+    name: str = Field(..., description="水厂名称")
+    project_id: int = Field(..., description="项目ID")
+    push_url: str = Field('', description="推送URL")
+    enabled: bool = Field(False, description="是否启用")
+
+
+class PlantUpdate(BaseModel):
+    name: Optional[str] = None
+    project_id: Optional[int] = None
+    push_url: Optional[str] = None
+    enabled: Optional[bool] = None
+
+
+class StreamCreate(BaseModel):
+    plant_id: int = Field(..., description="所属水厂ID")
+    name: str = Field(..., description="设备名称")
+    url: str = Field(..., description="RTSP URL")
+    channel: int = Field(..., description="通道号")
+    device_code: str = Field(..., description="设备编码")
+    pump_name: str = Field('', description="泵名称")
+    model_subdir: str = Field('', description="模型子目录")
+    enabled: bool = Field(True, description="是否启用")
+
+
+class StreamUpdate(BaseModel):
+    name: Optional[str] = None
+    url: Optional[str] = None
+    channel: Optional[int] = None
+    device_code: Optional[str] = None
+    pump_name: Optional[str] = None
+    model_subdir: Optional[str] = None
+    enabled: Optional[bool] = None
+    plant_id: Optional[int] = None
+
+
+class FlowPlcItem(BaseModel):
+    pump_name: str = Field(..., description="泵名称")
+    plc_address: str = Field(..., description="PLC地址")
+
+
+class PumpStatusPlcItem(BaseModel):
+    pump_name: str = Field(..., description="泵名称")
+    point: str = Field(..., description="PLC点位")
+    point_name: str = Field('', description="点位名称")
+
+
+class ConfigUpdate(BaseModel):
+    # 通用的配置更新模型:传入嵌套 dict 或扁平 KV
+    config: Dict[str, Any] = Field(..., description="配置字典")
+
+
+class ApiResponse(BaseModel):
+    code: int = 200
+    msg: str = "success"
+    data: Any = None
+
+
+# ========================================
+# 全量配置接口
+# ========================================
+
+@app.get("/api/config", response_model=ApiResponse, summary="获取全量配置")
+def get_full_config():
+    # 返回与原 rtsp_config.yaml 结构一致的完整配置
+    mgr = get_mgr()
+    return ApiResponse(data=mgr.get_full_config())
+
+
+# ========================================
+# 水厂 CRUD
+# ========================================
+
+@app.get("/api/config/plants", response_model=ApiResponse, summary="获取水厂列表")
+def list_plants():
+    mgr = get_mgr()
+    return ApiResponse(data=mgr.get_plants())
+
+
+@app.get("/api/config/plants/{plant_id}", response_model=ApiResponse, summary="获取单个水厂")
+def get_plant(plant_id: int):
+    mgr = get_mgr()
+    plant = mgr.get_plant(plant_id)
+    if not plant:
+        raise HTTPException(status_code=404, detail=f"水厂不存在: id={plant_id}")
+    return ApiResponse(data=plant)
+
+
+@app.post("/api/config/plants", response_model=ApiResponse, summary="创建水厂")
+def create_plant(body: PlantCreate):
+    mgr = get_mgr()
+    try:
+        plant_id = mgr.create_plant(
+            name=body.name,
+            project_id=body.project_id,
+            push_url=body.push_url,
+            enabled=body.enabled
+        )
+        return ApiResponse(data={"id": plant_id})
+    except Exception as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@app.put("/api/config/plants/{plant_id}", response_model=ApiResponse, summary="更新水厂")
+def update_plant(plant_id: int, body: PlantUpdate):
+    mgr = get_mgr()
+    # 只传递非 None 的字段
+    updates = body.dict(exclude_none=True)
+    if not updates:
+        raise HTTPException(status_code=400, detail="无有效更新字段")
+    mgr.update_plant(plant_id, **updates)
+    return ApiResponse(msg="更新成功")
+
+
+@app.delete("/api/config/plants/{plant_id}", response_model=ApiResponse, summary="删除水厂")
+def delete_plant(plant_id: int):
+    mgr = get_mgr()
+    mgr.delete_plant(plant_id)
+    return ApiResponse(msg="删除成功")
+
+
+# ========================================
+# RTSP 流 CRUD
+# ========================================
+
+@app.get("/api/config/streams", response_model=ApiResponse, summary="获取RTSP流列表")
+def list_streams(plant_id: Optional[int] = Query(None, description="按水厂ID过滤")):
+    mgr = get_mgr()
+    return ApiResponse(data=mgr.get_streams(plant_id))
+
+
+@app.post("/api/config/streams", response_model=ApiResponse, summary="创建RTSP流")
+def create_stream(body: StreamCreate):
+    mgr = get_mgr()
+    try:
+        stream_id = mgr.create_stream(
+            plant_id=body.plant_id,
+            name=body.name,
+            url=body.url,
+            channel=body.channel,
+            device_code=body.device_code,
+            pump_name=body.pump_name,
+            model_subdir=body.model_subdir,
+            enabled=body.enabled
+        )
+        return ApiResponse(data={"id": stream_id})
+    except Exception as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@app.put("/api/config/streams/{stream_id}", response_model=ApiResponse, summary="更新RTSP流")
+def update_stream(stream_id: int, body: StreamUpdate):
+    mgr = get_mgr()
+    updates = body.dict(exclude_none=True)
+    if not updates:
+        raise HTTPException(status_code=400, detail="无有效更新字段")
+    mgr.update_stream(stream_id, **updates)
+    return ApiResponse(msg="更新成功")
+
+
+@app.delete("/api/config/streams/{stream_id}", response_model=ApiResponse, summary="删除RTSP流")
+def delete_stream(stream_id: int):
+    mgr = get_mgr()
+    mgr.delete_stream(stream_id)
+    return ApiResponse(msg="删除成功")
+
+
+# ========================================
+# 流量 PLC 配置
+# ========================================
+
+@app.post("/api/config/plants/{plant_id}/flow-plc", response_model=ApiResponse,
+          summary="设置流量PLC映射")
+def set_flow_plc(plant_id: int, body: FlowPlcItem):
+    mgr = get_mgr()
+    mgr.set_flow_plc(plant_id, body.pump_name, body.plc_address)
+    return ApiResponse(msg="设置成功")
+
+
+@app.delete("/api/config/plants/{plant_id}/flow-plc/{pump_name}", response_model=ApiResponse,
+            summary="删除流量PLC映射")
+def delete_flow_plc(plant_id: int, pump_name: str):
+    mgr = get_mgr()
+    mgr.delete_flow_plc(plant_id, pump_name)
+    return ApiResponse(msg="删除成功")
+
+
+# ========================================
+# 泵状态 PLC 点位
+# ========================================
+
+@app.post("/api/config/plants/{plant_id}/pump-status-plc", response_model=ApiResponse,
+          summary="添加泵状态PLC点位")
+def add_pump_status_plc(plant_id: int, body: PumpStatusPlcItem):
+    mgr = get_mgr()
+    plc_id = mgr.add_pump_status_plc(plant_id, body.pump_name, body.point, body.point_name)
+    return ApiResponse(data={"id": plc_id})
+
+
+@app.delete("/api/config/pump-status-plc/{plc_id}", response_model=ApiResponse,
+            summary="删除泵状态PLC点位")
+def delete_pump_status_plc(plc_id: int):
+    mgr = get_mgr()
+    mgr.delete_pump_status_plc(plc_id)
+    return ApiResponse(msg="删除成功")
+
+
+# ========================================
+# 系统级配置(audio, prediction, push_notification 等)
+# ========================================
+
+@app.get("/api/config/{section}", response_model=ApiResponse,
+         summary="获取指定section的系统配置")
+def get_section_config(section: str):
+    # 限制只允许合法的 section 名
+    allowed_sections = {'audio', 'prediction', 'push_notification', 'scada_api', 'human_detection'}
+    if section not in allowed_sections:
+        raise HTTPException(status_code=400, detail=f"不支持的配置区域: {section}")
+    mgr = get_mgr()
+    return ApiResponse(data=mgr.get_system_config(section))
+
+
+@app.put("/api/config/{section}", response_model=ApiResponse,
+         summary="更新指定section的系统配置")
+def update_section_config(section: str, body: ConfigUpdate):
+    allowed_sections = {'audio', 'prediction', 'push_notification', 'scada_api', 'human_detection'}
+    if section not in allowed_sections:
+        raise HTTPException(status_code=400, detail=f"不支持的配置区域: {section}")
+    mgr = get_mgr()
+    mgr.update_section_config(section, body.config)
+    return ApiResponse(msg="更新成功")
+
+
+# ========================================
+# 模型管理 API
+# ========================================
+
+@app.get("/api/model/status", response_model=ApiResponse, summary="获取模型加载状态")
+def get_model_status():
+    # 返回各设备的模型加载状态
+    if _multi_predictor is None:
+        raise HTTPException(status_code=503, detail="模型预测器未初始化")
+
+    return ApiResponse(data={
+        "registered": _multi_predictor.registered_devices,
+        "loaded": _multi_predictor.loaded_devices,
+        "failed": list(_multi_predictor._failed_devices.keys())
+    })
+
+
+@app.post("/api/model/reload/{device_code}", response_model=ApiResponse,
+          summary="重载指定设备的模型")
+def reload_device_model(device_code: str):
+    # 触发指定设备的模型热加载(替换 models/{device_code}/ 下的文件后调用此接口)
+    if _multi_predictor is None:
+        raise HTTPException(status_code=503, detail="模型预测器未初始化")
+
+    success = _multi_predictor.reload_device(device_code)
+    if success:
+        return ApiResponse(msg=f"设备 {device_code} 模型重载成功")
+    else:
+        raise HTTPException(status_code=500, detail=f"设备 {device_code} 模型重载失败")
+
+
+@app.post("/api/model/reload-all", response_model=ApiResponse,
+          summary="重载所有已注册设备的模型")
+def reload_all_models():
+    # 触发所有已注册设备的模型重载
+    if _multi_predictor is None:
+        raise HTTPException(status_code=503, detail="模型预测器未初始化")
+
+    results = {}
+    for device_code in _multi_predictor.registered_devices:
+        results[device_code] = _multi_predictor.reload_device(device_code)
+
+    return ApiResponse(data=results)
+
+
+@app.post("/api/model/upload/{device_code}", response_model=ApiResponse,
+          summary="上传模型文件并重载")
+async def upload_model(
+    device_code: str,
+    model_file: UploadFile = File(None, description="ae_model.pth 模型权重"),
+    scale_file: UploadFile = File(None, description="global_scale.npy 标准化参数"),
+    threshold_file: UploadFile = File(None, description="threshold.npy 阈值文件")
+):
+    """
+    上传模型文件到 models/{device_code}/ 目录
+
+    支持三种文件(可单独或组合上传):
+    - model_file: ae_model.pth
+    - scale_file: global_scale.npy
+    - threshold_file: threshold_{device_code}.npy
+
+    上传完成后自动触发模型重载。
+    """
+    if _multi_predictor is None:
+        raise HTTPException(status_code=503, detail="模型预测器未初始化")
+
+    # 至少上传一个文件
+    if not any([model_file, scale_file, threshold_file]):
+        raise HTTPException(status_code=400, detail="至少需要上传一个文件")
+
+    # 确定模型目录
+    from predictor.config import CFG
+    device_dir = CFG.MODEL_ROOT / device_code
+    device_dir.mkdir(parents=True, exist_ok=True)
+
+    saved_files = []
+
+    try:
+        # 保存模型权重
+        if model_file and model_file.filename:
+            dest = device_dir / "ae_model.pth"
+            content = await model_file.read()
+            dest.write_bytes(content)
+            saved_files.append(str(dest))
+
+        # 保存标准化参数
+        if scale_file and scale_file.filename:
+            dest = device_dir / "global_scale.npy"
+            content = await scale_file.read()
+            dest.write_bytes(content)
+            saved_files.append(str(dest))
+
+        # 保存阈值
+        if threshold_file and threshold_file.filename:
+            thr_dir = device_dir / "thresholds"
+            thr_dir.mkdir(parents=True, exist_ok=True)
+            dest = thr_dir / f"threshold_{device_code}.npy"
+            content = await threshold_file.read()
+            dest.write_bytes(content)
+            saved_files.append(str(dest))
+
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"文件保存失败: {e}")
+
+    # 上传完成后自动重载
+    reload_ok = _multi_predictor.reload_device(device_code)
+
+    return ApiResponse(
+        msg=f"模型上传成功,重载{'成功' if reload_ok else '失败'}",
+        data={
+            "device_code": device_code,
+            "saved_files": saved_files,
+            "reloaded": reload_ok
+        }
+    )

+ 402 - 0
config/config_manager.py

@@ -0,0 +1,402 @@
+import sqlite3
+import json
+import logging
+import threading
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from config.db_models import get_connection, get_db_path, init_db
+
+logger = logging.getLogger(__name__)
+
+
+class ConfigManager:
+    # 统一的配置管理器,封装 SQLite 读写,对外提供与原 YAML dict 兼容的接口
+    # 线程安全:每个线程使用独立的 SQLite 连接
+
+    def __init__(self, db_path: Optional[str] = None):
+        # db_path 为空时默认使用 config/pickup_config.db
+        self._db_path = Path(db_path) if db_path else get_db_path()
+        # 线程本地存储:每个线程持有独立连接,避免跨线程共享 sqlite3.Connection
+        self._local = threading.local()
+        # 确保表结构已创建
+        init_db(self._db_path)
+        logger.info(f"ConfigManager 初始化完成: {self._db_path}")
+
+    @property
+    def _conn(self):
+        # 线程安全的连接获取:每个线程首次访问时创建独立连接
+        if not hasattr(self._local, 'conn') or self._local.conn is None:
+            self._local.conn = get_connection(self._db_path)
+        return self._local.conn
+
+    def close(self):
+        # 关闭当前线程的连接
+        if hasattr(self._local, 'conn') and self._local.conn:
+            self._local.conn.close()
+            self._local.conn = None
+
+    # ========================================
+    # 兼容层:返回与原 YAML dict 格式一致的配置(供现有代码无缝切换)
+    # ========================================
+
+    def get_full_config(self) -> dict:
+        # 返回完整配置 dict,结构与原 rtsp_config.yaml 完全一致
+        # 这是关键的兼容层方法:现有代码 self.config.get('plants', []) 等调用无需修改
+        config = {}
+
+        # 1. 组装 plants 列表
+        config['plants'] = self._build_plants_list()
+
+        # 2. 组装系统级配置(audio, prediction, push_notification, scada_api, human_detection)
+        for section in ['audio', 'prediction', 'push_notification', 'scada_api', 'human_detection']:
+            config[section] = self._get_section_config(section)
+
+        return config
+
+    def _build_plants_list(self) -> List[dict]:
+        # 从 DB 组装 plants 列表,结构与 YAML 中的 plants 完全一致
+        cursor = self._conn.execute(
+            "SELECT id, name, enabled, project_id, push_url FROM plant ORDER BY id"
+        )
+        plants = []
+        for row in cursor.fetchall():
+            plant_id = row['id']
+            plant = {
+                'name': row['name'],
+                'enabled': bool(row['enabled']),
+                'project_id': row['project_id'],
+                'push_url': row['push_url'],
+                'flow_plc': self._get_flow_plc(plant_id),
+                'pump_status_plc': self._get_pump_status_plc(plant_id),
+                'rtsp_streams': self._get_rtsp_streams(plant_id),
+            }
+            plants.append(plant)
+        return plants
+
+    def _get_flow_plc(self, plant_id: int) -> dict:
+        # 获取流量 PLC 映射:{pump_name: plc_address}
+        cursor = self._conn.execute(
+            "SELECT pump_name, plc_address FROM flow_plc WHERE plant_id = ?",
+            (plant_id,)
+        )
+        return {row['pump_name']: row['plc_address'] for row in cursor.fetchall()}
+
+    def _get_pump_status_plc(self, plant_id: int) -> dict:
+        # 获取泵状态 PLC 配置:{pump_name: [{point, name}, ...]}
+        # 与 YAML 格式一致:同一 pump_name 下可能有多个点位
+        cursor = self._conn.execute(
+            "SELECT pump_name, point, point_name FROM pump_status_plc WHERE plant_id = ? ORDER BY id",
+            (plant_id,)
+        )
+        result = {}
+        for row in cursor.fetchall():
+            pump_name = row['pump_name']
+            if pump_name not in result:
+                result[pump_name] = []
+            result[pump_name].append({
+                'point': row['point'],
+                'name': row['point_name']
+            })
+        return result
+
+    def _get_rtsp_streams(self, plant_id: int) -> List[dict]:
+        # 获取 RTSP 流列表
+        cursor = self._conn.execute(
+            "SELECT name, url, channel, device_code, pump_name, model_subdir "
+            "FROM rtsp_stream WHERE plant_id = ? AND enabled = 1 ORDER BY id",
+            (plant_id,)
+        )
+        streams = []
+        for row in cursor.fetchall():
+            stream = {
+                'name': row['name'],
+                'url': row['url'],
+                'channel': row['channel'],
+                'device_code': row['device_code'],
+                'pump_name': row['pump_name'],
+            }
+            # model_subdir 仅在有值时添加(兼容旧配置中该字段可选的行为)
+            if row['model_subdir']:
+                stream['model_subdir'] = row['model_subdir']
+            streams.append(stream)
+        return streams
+
+    def _get_section_config(self, section: str) -> dict:
+        # 从 system_config 表读取指定 section 的所有 KV,还原为嵌套 dict
+        # 例如 section=prediction, key=voting.window_size, value=5
+        # 还原为 {'voting': {'window_size': 5}}
+        cursor = self._conn.execute(
+            "SELECT key, value, value_type FROM system_config WHERE section = ? ORDER BY key",
+            (section,)
+        )
+        result = {}
+        for row in cursor.fetchall():
+            key_path = row['key']
+            raw_value = row['value']
+            value_type = row['value_type']
+
+            # 类型转换
+            typed_value = self._deserialize_value(raw_value, value_type)
+
+            # 将点号分隔的 key 路径还原为嵌套 dict
+            self._set_nested(result, key_path, typed_value)
+
+        return result
+
+    # ========================================
+    # 系统级配置 CRUD
+    # ========================================
+
+    def get_system_config(self, section: str, key: str = None) -> Any:
+        # 获取系统配置:指定 key 返回单值,不指定返回整个 section dict
+        if key:
+            cursor = self._conn.execute(
+                "SELECT value, value_type FROM system_config WHERE section = ? AND key = ?",
+                (section, key)
+            )
+            row = cursor.fetchone()
+            if row:
+                return self._deserialize_value(row['value'], row['value_type'])
+            return None
+        return self._get_section_config(section)
+
+    def set_system_config(self, section: str, key: str, value: Any,
+                          value_type: str = None, description: str = ''):
+        # 设置系统配置(upsert 语义:存在则更新,不存在则插入)
+        if value_type is None:
+            value_type = self._infer_type(value)
+        serialized = self._serialize_value(value, value_type)
+
+        self._conn.execute(
+            "INSERT INTO system_config (section, key, value, value_type, description) "
+            "VALUES (?, ?, ?, ?, ?) "
+            "ON CONFLICT(section, key) DO UPDATE SET value=excluded.value, "
+            "value_type=excluded.value_type, description=excluded.description",
+            (section, key, serialized, value_type, description)
+        )
+        self._conn.commit()
+        logger.debug(f"配置已更新: [{section}] {key} = {value}")
+
+    def update_section_config(self, section: str, config_dict: dict):
+        # 批量更新整个 section 的配置(将嵌套 dict 展平为 KV 对)
+        flat_items = self._flatten_dict(config_dict)
+        for key, value in flat_items.items():
+            self.set_system_config(section, key, value)
+
+    # ========================================
+    # 水厂 CRUD
+    # ========================================
+
+    def get_plants(self) -> List[dict]:
+        # 获取所有水厂(含关联数据)
+        return self._build_plants_list()
+
+    def get_plant(self, plant_id: int) -> Optional[dict]:
+        cursor = self._conn.execute(
+            "SELECT id, name, enabled, project_id, push_url FROM plant WHERE id = ?",
+            (plant_id,)
+        )
+        row = cursor.fetchone()
+        if not row:
+            return None
+        return {
+            'id': row['id'],
+            'name': row['name'],
+            'enabled': bool(row['enabled']),
+            'project_id': row['project_id'],
+            'push_url': row['push_url'],
+            'flow_plc': self._get_flow_plc(plant_id),
+            'pump_status_plc': self._get_pump_status_plc(plant_id),
+            'rtsp_streams': self._get_rtsp_streams(plant_id),
+        }
+
+    def create_plant(self, name: str, project_id: int, push_url: str = '',
+                     enabled: bool = False) -> int:
+        cursor = self._conn.execute(
+            "INSERT INTO plant (name, enabled, project_id, push_url) VALUES (?, ?, ?, ?)",
+            (name, int(enabled), project_id, push_url)
+        )
+        self._conn.commit()
+        plant_id = cursor.lastrowid
+        logger.info(f"水厂已创建: id={plant_id}, name={name}")
+        return plant_id
+
+    def update_plant(self, plant_id: int, **kwargs):
+        # 动态更新水厂字段
+        allowed_fields = {'name', 'enabled', 'project_id', 'push_url'}
+        updates = {k: v for k, v in kwargs.items() if k in allowed_fields}
+        if not updates:
+            return
+
+        # enabled 字段需要转为 int(SQLite 无原生 bool)
+        if 'enabled' in updates:
+            updates['enabled'] = int(updates['enabled'])
+
+        set_clause = ', '.join(f"{k} = ?" for k in updates)
+        values = list(updates.values()) + [plant_id]
+        self._conn.execute(
+            f"UPDATE plant SET {set_clause} WHERE id = ?", values
+        )
+        self._conn.commit()
+        logger.info(f"水厂已更新: id={plant_id}, fields={list(updates.keys())}")
+
+    def delete_plant(self, plant_id: int):
+        # 级联删除水厂及其关联数据(外键约束自动处理)
+        self._conn.execute("DELETE FROM plant WHERE id = ?", (plant_id,))
+        self._conn.commit()
+        logger.info(f"水厂已删除: id={plant_id}")
+
+    # ========================================
+    # RTSP 流 CRUD
+    # ========================================
+
+    def get_streams(self, plant_id: int = None) -> List[dict]:
+        if plant_id:
+            cursor = self._conn.execute(
+                "SELECT s.*, p.name as plant_name FROM rtsp_stream s "
+                "JOIN plant p ON s.plant_id = p.id WHERE s.plant_id = ? ORDER BY s.id",
+                (plant_id,)
+            )
+        else:
+            cursor = self._conn.execute(
+                "SELECT s.*, p.name as plant_name FROM rtsp_stream s "
+                "JOIN plant p ON s.plant_id = p.id ORDER BY s.id"
+            )
+        return [dict(row) for row in cursor.fetchall()]
+
+    def create_stream(self, plant_id: int, name: str, url: str, channel: int,
+                      device_code: str, pump_name: str = '', model_subdir: str = '',
+                      enabled: bool = True) -> int:
+        cursor = self._conn.execute(
+            "INSERT INTO rtsp_stream (plant_id, name, url, channel, device_code, "
+            "pump_name, model_subdir, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+            (plant_id, name, url, channel, device_code, pump_name, model_subdir, int(enabled))
+        )
+        self._conn.commit()
+        stream_id = cursor.lastrowid
+        logger.info(f"RTSP流已创建: id={stream_id}, device_code={device_code}")
+        return stream_id
+
+    def update_stream(self, stream_id: int, **kwargs):
+        allowed_fields = {'name', 'url', 'channel', 'device_code', 'pump_name',
+                          'model_subdir', 'enabled', 'plant_id'}
+        updates = {k: v for k, v in kwargs.items() if k in allowed_fields}
+        if not updates:
+            return
+        if 'enabled' in updates:
+            updates['enabled'] = int(updates['enabled'])
+
+        set_clause = ', '.join(f"{k} = ?" for k in updates)
+        values = list(updates.values()) + [stream_id]
+        self._conn.execute(
+            f"UPDATE rtsp_stream SET {set_clause} WHERE id = ?", values
+        )
+        self._conn.commit()
+        logger.info(f"RTSP流已更新: id={stream_id}, fields={list(updates.keys())}")
+
+    def delete_stream(self, stream_id: int):
+        self._conn.execute("DELETE FROM rtsp_stream WHERE id = ?", (stream_id,))
+        self._conn.commit()
+        logger.info(f"RTSP流已删除: id={stream_id}")
+
+    # ========================================
+    # 流量 PLC CRUD
+    # ========================================
+
+    def set_flow_plc(self, plant_id: int, pump_name: str, plc_address: str):
+        self._conn.execute(
+            "INSERT INTO flow_plc (plant_id, pump_name, plc_address) VALUES (?, ?, ?) "
+            "ON CONFLICT(plant_id, pump_name) DO UPDATE SET plc_address=excluded.plc_address",
+            (plant_id, pump_name, plc_address)
+        )
+        self._conn.commit()
+
+    def delete_flow_plc(self, plant_id: int, pump_name: str):
+        self._conn.execute(
+            "DELETE FROM flow_plc WHERE plant_id = ? AND pump_name = ?",
+            (plant_id, pump_name)
+        )
+        self._conn.commit()
+
+    # ========================================
+    # 泵状态 PLC CRUD
+    # ========================================
+
+    def add_pump_status_plc(self, plant_id: int, pump_name: str,
+                            point: str, point_name: str = '') -> int:
+        cursor = self._conn.execute(
+            "INSERT INTO pump_status_plc (plant_id, pump_name, point, point_name) "
+            "VALUES (?, ?, ?, ?)",
+            (plant_id, pump_name, point, point_name)
+        )
+        self._conn.commit()
+        return cursor.lastrowid
+
+    def delete_pump_status_plc(self, plc_id: int):
+        self._conn.execute("DELETE FROM pump_status_plc WHERE id = ?", (plc_id,))
+        self._conn.commit()
+
+    # ========================================
+    # 工具方法:类型序列化/反序列化
+    # ========================================
+
+    @staticmethod
+    def _serialize_value(value: Any, value_type: str) -> str:
+        # 所有值统一序列化为字符串存储
+        if value_type == 'json':
+            return json.dumps(value, ensure_ascii=False)
+        if value_type == 'bool':
+            return '1' if value else '0'
+        return str(value)
+
+    @staticmethod
+    def _deserialize_value(raw: str, value_type: str) -> Any:
+        # 根据 value_type 将字符串还原为 Python 对象
+        if value_type == 'int':
+            return int(raw)
+        elif value_type == 'float':
+            return float(raw)
+        elif value_type == 'bool':
+            return raw in ('1', 'true', 'True')
+        elif value_type == 'json':
+            return json.loads(raw)
+        return raw
+
+    @staticmethod
+    def _infer_type(value: Any) -> str:
+        # 根据 Python 类型推断 value_type 标识
+        if isinstance(value, bool):
+            return 'bool'
+        elif isinstance(value, int):
+            return 'int'
+        elif isinstance(value, float):
+            return 'float'
+        elif isinstance(value, (dict, list)):
+            return 'json'
+        return 'str'
+
+    @staticmethod
+    def _set_nested(d: dict, key_path: str, value: Any):
+        # 将点号分隔的 key_path 设置到嵌套 dict 中
+        # 例如 _set_nested({}, "voting.window_size", 5) => {"voting": {"window_size": 5}}
+        keys = key_path.split('.')
+        current = d
+        for key in keys[:-1]:
+            if key not in current:
+                current[key] = {}
+            current = current[key]
+        current[keys[-1]] = value
+
+    @staticmethod
+    def _flatten_dict(d: dict, parent_key: str = '') -> dict:
+        # 将嵌套 dict 展平为点号分隔的 KV 对
+        # 例如 {"voting": {"window_size": 5}} => {"voting.window_size": 5}
+        items = {}
+        for k, v in d.items():
+            new_key = f"{parent_key}.{k}" if parent_key else k
+            if isinstance(v, dict):
+                items.update(ConfigManager._flatten_dict(v, new_key))
+            else:
+                items[new_key] = v
+        return items

+ 126 - 0
config/db_models.py

@@ -0,0 +1,126 @@
+import sqlite3
+import json
+import logging
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# 数据库文件默认名(与 rtsp_config.yaml 同级)
+DEFAULT_DB_NAME = "pickup_config.db"
+
+
+def get_db_path(config_dir=None):
+    # 默认放在 config/ 目录下
+    if config_dir is None:
+        config_dir = Path(__file__).parent
+    return Path(config_dir) / DEFAULT_DB_NAME
+
+
+def get_connection(db_path=None):
+    # 获取 SQLite 连接,启用 WAL 模式以支持并发读写
+    if db_path is None:
+        db_path = get_db_path()
+    conn = sqlite3.connect(str(db_path), timeout=10)
+    conn.row_factory = sqlite3.Row
+    # WAL 模式:允许读写并发,避免锁阻塞
+    conn.execute("PRAGMA journal_mode=WAL")
+    # 启用外键约束
+    conn.execute("PRAGMA foreign_keys=ON")
+    return conn
+
+
+def init_db(db_path=None):
+    # 初始化数据库表结构(幂等操作,已有表则跳过)
+    conn = get_connection(db_path)
+    try:
+        conn.executescript(SCHEMA_SQL)
+        conn.commit()
+        logger.info(f"数据库初始化完成: {db_path or get_db_path()}")
+    finally:
+        conn.close()
+
+
+# 完整的表结构定义
+SCHEMA_SQL = """
+-- 水厂表:每个水厂一条记录
+CREATE TABLE IF NOT EXISTS plant (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    name TEXT NOT NULL UNIQUE,              -- 水厂名称(如"锡山中荷")
+    enabled INTEGER NOT NULL DEFAULT 0,      -- 是否启用(0=禁用, 1=启用)
+    project_id INTEGER NOT NULL,             -- 项目ID(用于数据上报)
+    push_url TEXT NOT NULL DEFAULT '',       -- 异常推送接口URL
+    created_at TEXT DEFAULT (datetime('now','localtime')),
+    updated_at TEXT DEFAULT (datetime('now','localtime'))
+);
+
+-- RTSP 流表:每个拾音器/摄像头一条记录
+CREATE TABLE IF NOT EXISTS rtsp_stream (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    plant_id INTEGER NOT NULL,               -- 所属水厂
+    name TEXT NOT NULL DEFAULT '',            -- 摄像头/拾音器名称(用于告警显示)
+    url TEXT NOT NULL,                        -- RTSP 流地址
+    channel INTEGER NOT NULL,                -- 通道号
+    device_code TEXT NOT NULL,               -- 设备编码(唯一标识,用于文件命名)
+    pump_name TEXT NOT NULL DEFAULT '',      -- 关联泵名称(用于流量/状态查询)
+    model_subdir TEXT DEFAULT '',            -- 模型子目录(默认使用 device_code)
+    enabled INTEGER NOT NULL DEFAULT 1,      -- 是否启用该流
+    created_at TEXT DEFAULT (datetime('now','localtime')),
+    updated_at TEXT DEFAULT (datetime('now','localtime')),
+    FOREIGN KEY (plant_id) REFERENCES plant(id) ON DELETE CASCADE,
+    UNIQUE(device_code)                      -- device_code 全局唯一
+);
+
+-- 流量 PLC 映射表:泵名 -> PLC 地址
+CREATE TABLE IF NOT EXISTS flow_plc (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    plant_id INTEGER NOT NULL,
+    pump_name TEXT NOT NULL,                  -- 泵名称/标识(如"A", "高压泵1流量")
+    plc_address TEXT NOT NULL,               -- PLC 数据点位地址
+    FOREIGN KEY (plant_id) REFERENCES plant(id) ON DELETE CASCADE,
+    UNIQUE(plant_id, pump_name)
+);
+
+-- 泵状态 PLC 点位表:用于检测泵启停
+CREATE TABLE IF NOT EXISTS pump_status_plc (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    plant_id INTEGER NOT NULL,
+    pump_name TEXT NOT NULL,                  -- 拾音器关联的泵名称(对应 rtsp_stream.pump_name)
+    point TEXT NOT NULL,                      -- PLC 运行状态点位地址
+    point_name TEXT NOT NULL DEFAULT '',      -- 点位显示名称(如"1#RO高压泵")
+    FOREIGN KEY (plant_id) REFERENCES plant(id) ON DELETE CASCADE
+);
+
+-- 系统级配置表:KV 存储,存放 audio/prediction/push_notification/scada_api/human_detection
+-- 通过 section + key 组合定位配置项
+CREATE TABLE IF NOT EXISTS system_config (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    section TEXT NOT NULL,                    -- 配置区域(如 audio, prediction, push_notification)
+    key TEXT NOT NULL,                        -- 配置键(支持点号嵌套,如 voting.window_size)
+    value TEXT NOT NULL DEFAULT '',           -- 配置值(JSON 序列化存储)
+    value_type TEXT NOT NULL DEFAULT 'str',   -- 值类型标识(str/int/float/bool/json)
+    description TEXT DEFAULT '',              -- 配置项说明
+    updated_at TEXT DEFAULT (datetime('now','localtime')),
+    UNIQUE(section, key)
+);
+
+-- 更新时间触发器:plant 表
+CREATE TRIGGER IF NOT EXISTS plant_updated_at
+AFTER UPDATE ON plant
+BEGIN
+    UPDATE plant SET updated_at = datetime('now','localtime') WHERE id = NEW.id;
+END;
+
+-- 更新时间触发器:rtsp_stream 表
+CREATE TRIGGER IF NOT EXISTS stream_updated_at
+AFTER UPDATE ON rtsp_stream
+BEGIN
+    UPDATE rtsp_stream SET updated_at = datetime('now','localtime') WHERE id = NEW.id;
+END;
+
+-- 更新时间触发器:system_config 表
+CREATE TRIGGER IF NOT EXISTS config_updated_at
+AFTER UPDATE ON system_config
+BEGIN
+    UPDATE system_config SET updated_at = datetime('now','localtime') WHERE id = NEW.id;
+END;
+"""

二進制
config/pickup_config.db


二進制
config/pickup_config.db-shm


+ 0 - 0
config/pickup_config.db-wal


二進制
config/yaml_backup/db_output/pickup_config_anzhen.db


二進制
config/yaml_backup/db_output/pickup_config_jianding.db


二進制
config/yaml_backup/db_output/pickup_config_longting.db


二進制
config/yaml_backup/db_output/pickup_config_longting.db-shm


+ 0 - 0
config/yaml_backup/db_output/pickup_config_longting.db-wal


二進制
config/yaml_backup/db_output/pickup_config_xishan.db


二進制
config/yaml_backup/db_output/pickup_config_xishan.db-shm


+ 0 - 0
config/yaml_backup/db_output/pickup_config_xishan.db-wal


二進制
config/yaml_backup/db_output/pickup_config_yancheng.db


+ 96 - 0
config/yaml_backup/rtsp_config_anzhen.yaml

@@ -0,0 +1,96 @@
+# ============================================================
+# 安镇新水岛 水厂配置文件
+# ============================================================
+# 使用方法:python tool/migrate_yaml_to_db.py --yaml 本文件路径 --force
+# ============================================================
+
+plants:
+- name: 安镇新水岛                   # 水厂名称
+  enabled: true
+  project_id: 1181                  # 平台项目 ID
+
+  # 流量 PLC 映射
+  flow_plc:
+    1#RO进水流量: AR.1#RO_JSFLOW_O
+    2#RO进水流量: AR.2#RO_JSFLOW_O
+
+  # 泵状态 PLC 点位
+  pump_status_plc:
+    门口:                            # pump_name
+    - point: DR.P_1#ROGYB_RFB
+      name: 1#RO高压泵
+    - point: DR.P_2#ROGYB_RFB
+      name: 2#RO高压泵
+
+  # RTSP 拾音器流
+  rtsp_streams:
+  - name: 一层门口                   # 显示名称
+    url: rtsp://rtsp:newwater123@192.168.60.11:31016/cam/realmonitor?channel=12&subtype=0
+    channel: 12
+    device_code: AZ-12               # 设备编码(训练数据/模型目录名)
+    pump_name: 门口                  # 关联泵名称
+
+# ----------------------------------------------------------
+# 以下为系统级配置(各水厂通用,一般不需要修改)
+# ----------------------------------------------------------
+audio:
+  sample_rate: 16000
+  file_duration: 60
+  segment_duration: 60
+  auto_cleanup:
+    enabled: true
+    delete_normal: true
+    keep_recent_count: 100
+
+prediction:
+  batch_size: 64
+  check_interval: 1.0
+  default_threshold: 0.01
+  voting:
+    enabled: true
+    window_size: 5
+    threshold: 3
+  frequency_history:
+    enabled: true
+    history_minutes: 10
+  energy_detection:
+    enabled: true
+    skip_when_stopped: true
+  save_anomaly_audio:
+    enabled: true
+    save_dir: data/anomaly_detected
+    cooldown_minutes: 15
+    context_capture:
+      enabled: true
+      pre_minutes: 2
+      post_minutes: 2
+
+push_notification:
+  enabled: true
+  alert_enabled: false
+  push_base_urls:
+    - label: "外网"
+      url: "http://120.55.44.4:8900/api/v1/dumu/push-msg"
+    - label: "内网"
+      url: "http://192.168.60.8:8900/api/v1/dumu/push-msg"
+  timeout: 30
+  retry_count: 2
+  cooldown_minutes: 15
+  cooldown_same_type_hours: 24
+  cooldown_diff_type_hours: 1
+  alert_aggregate:
+    enabled: true
+    window_seconds: 300
+    min_devices: 2
+
+scada_api:
+  enabled: true
+  base_url: http://120.55.44.4:8900/api/v1/jinke-cloud/db/device/history-data
+  realtime_url: http://47.96.12.136:8788/api/v1/jinke-cloud/device/current-data
+  jwt_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6NywiVXNlcm5hbWUiOiJhZG1pbiIsIkRlcCI6IjEzNSIsImV4cCI6MTc3NjExOTExNCwiaXNzIjoiZ2luLWJsb2cifQ.0HTtzHZjyd2mHo8VCy8icYROxmntRMuQhyoZsAYRL_M
+  timeout: 10
+
+human_detection:
+  enabled: false
+  db_path: /data/human_detector/detection_status.db
+  cooldown_minutes: 5

+ 92 - 0
config/yaml_backup/rtsp_config_jianding.yaml

@@ -0,0 +1,92 @@
+# ============================================================
+# 健鼎 水厂配置文件
+# ============================================================
+# 使用方法:python tool/migrate_yaml_to_db.py --yaml 本文件路径 --force
+# ============================================================
+
+plants:
+- name: 健鼎                        # 水厂名称
+  enabled: true
+  project_id: 1202                  # 平台项目 ID
+
+  # 流量 PLC 映射(该水厂无流量 PLC)
+  flow_plc: {}
+
+  # 泵状态 PLC 点位
+  pump_status_plc:
+    高压泵:                          # pump_name
+    - point: ns=6;s=P_RO_GYB_RFB
+      name: 反渗透高压泵
+
+  # RTSP 拾音器流
+  rtsp_streams:
+  - name: 反渗透供水泵和高压泵区1     # 显示名称
+    url: rtsp://rtsp:newwater123@192.168.70.11:31018/cam/realmonitor?channel=6&subtype=0
+    channel: 6
+    device_code: JD-6                # 设备编码
+    pump_name: 高压泵                # 关联泵名称
+
+# ----------------------------------------------------------
+# 以下为系统级配置(各水厂通用,一般不需要修改)
+# ----------------------------------------------------------
+audio:
+  sample_rate: 16000
+  file_duration: 60
+  segment_duration: 60
+  auto_cleanup:
+    enabled: true
+    delete_normal: true
+    keep_recent_count: 100
+
+prediction:
+  batch_size: 64
+  check_interval: 1.0
+  default_threshold: 0.01
+  voting:
+    enabled: true
+    window_size: 5
+    threshold: 3
+  frequency_history:
+    enabled: true
+    history_minutes: 10
+  energy_detection:
+    enabled: true
+    skip_when_stopped: true
+  save_anomaly_audio:
+    enabled: true
+    save_dir: data/anomaly_detected
+    cooldown_minutes: 15
+    context_capture:
+      enabled: true
+      pre_minutes: 2
+      post_minutes: 2
+
+push_notification:
+  enabled: true
+  alert_enabled: false
+  push_base_urls:
+    - label: "外网"
+      url: "http://120.55.44.4:8900/api/v1/dumu/push-msg"
+    - label: "内网"
+      url: "http://192.168.60.8:8900/api/v1/dumu/push-msg"
+  timeout: 30
+  retry_count: 2
+  cooldown_minutes: 15
+  cooldown_same_type_hours: 24
+  cooldown_diff_type_hours: 1
+  alert_aggregate:
+    enabled: true
+    window_seconds: 300
+    min_devices: 2
+
+scada_api:
+  enabled: true
+  base_url: http://120.55.44.4:8900/api/v1/jinke-cloud/db/device/history-data
+  realtime_url: http://47.96.12.136:8788/api/v1/jinke-cloud/device/current-data
+  jwt_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6NywiVXNlcm5hbWUiOiJhZG1pbiIsIkRlcCI6IjEzNSIsImV4cCI6MTc3NjExOTExNCwiaXNzIjoiZ2luLWJsb2cifQ.0HTtzHZjyd2mHo8VCy8icYROxmntRMuQhyoZsAYRL_M
+  timeout: 10
+
+human_detection:
+  enabled: false
+  db_path: /data/human_detector/detection_status.db
+  cooldown_minutes: 5

+ 124 - 0
config/yaml_backup/rtsp_config_longting.yaml

@@ -0,0 +1,124 @@
+# ============================================================
+# 龙亭新水岛 水厂配置文件
+# ============================================================
+# 使用方法:python tool/migrate_yaml_to_db.py --yaml 本文件路径 --force
+# 导入后可通过 API(:8080)在线修改,无需再编辑此文件
+# ============================================================
+
+# ----------------------------------------------------------
+# 水厂列表
+# ----------------------------------------------------------
+plants:
+- name: 龙亭新水岛                   # 水厂名称
+  enabled: true                     # 是否启用
+  project_id: 1450                  # 平台项目 ID
+
+  # 流量 PLC 映射(泵名称 -> PLC 地址)
+  flow_plc:
+    高压泵1流量: ns=3;s=1#RO_JSFLOW_O
+    高压泵2流量: ns=3;s=2#RO_JSFLOW_O
+
+  # 泵状态 PLC 点位(val=1 运行, val=0 停机)
+  pump_status_plc:
+    段间泵:                          # pump_name(与 rtsp_streams.pump_name 对应)
+    - point: ns=6;s=P_1#RODJB_RFB
+      name: 1#RO段间泵
+    - point: ns=6;s=P_2#RODJB_RFB
+      name: 2#RO段间泵
+    高压泵:
+    - point: ns=6;s=P_1#ROGYB_RFB
+      name: 1#RO高压泵
+    - point: ns=6;s=P_2#ROGYB_RFB
+      name: 2#RO高压泵
+
+  # RTSP 拾音器流(每个流 = 一台拾音器)
+  rtsp_streams:
+  - name: 龙亭一层冲洗泵区域5         # 显示名称
+    url: rtsp://rtsp:newwater123@192.168.70.11:31016/cam/realmonitor?channel=5&subtype=0
+    channel: 5                       # 通道号
+    device_code: LT-5                # 设备编码(唯一,训练数据/模型目录名)
+    pump_name: 段间泵                # 关联泵名称
+    model_subdir: LT-5              # 模型目录(默认 = device_code)
+  - name: 龙亭一层高压泵区域
+    url: rtsp://rtsp:newwater123@192.168.70.11:31016/cam/realmonitor?channel=2&subtype=0
+    channel: 2
+    device_code: LT-2
+    pump_name: 高压泵
+    model_subdir: LT-2
+
+# ----------------------------------------------------------
+# 音频采集参数
+# ----------------------------------------------------------
+audio:
+  sample_rate: 16000                 # 采样率 Hz(必须与训练一致)
+  file_duration: 60                  # 每个音频文件时长(秒)
+  segment_duration: 60               # FFmpeg 切片时长(秒)
+  auto_cleanup:
+    enabled: true
+    delete_normal: true
+    keep_recent_count: 100
+
+# ----------------------------------------------------------
+# 异常检测参数
+# ----------------------------------------------------------
+prediction:
+  batch_size: 64                     # 推理批大小
+  check_interval: 1.0               # 检查新文件间隔(秒)
+  default_threshold: 0.01           # 默认阈值(模型未加载时)
+  voting:                            # 滑动窗口投票
+    enabled: true
+    window_size: 5                   # 5 个周期约 5 分钟
+    threshold: 3                     # 5 次中 3 次异常才报
+  frequency_history:
+    enabled: true
+    history_minutes: 10
+  energy_detection:                  # 音频能量检测(无 PLC 时判断启停)
+    enabled: true
+    skip_when_stopped: true
+  save_anomaly_audio:
+    enabled: true
+    save_dir: data/anomaly_detected
+    cooldown_minutes: 15
+    context_capture:
+      enabled: true
+      pre_minutes: 2
+      post_minutes: 2
+
+# ----------------------------------------------------------
+# 推送通知
+# ----------------------------------------------------------
+push_notification:
+  enabled: false                     # 总开关(false = 不推送任何消息)
+  alert_enabled: false               # false = 只推心跳不推告警
+  push_base_urls:
+    - label: "外网"
+      url: "http://120.55.44.4:8900/api/v1/dumu/push-msg"
+    - label: "内网"
+      url: "http://192.168.60.8:8900/api/v1/dumu/push-msg"
+  timeout: 30
+  retry_count: 2
+  cooldown_minutes: 15
+  cooldown_same_type_hours: 24
+  cooldown_diff_type_hours: 1
+  alert_aggregate:
+    enabled: true
+    window_seconds: 300
+    min_devices: 2
+
+# ----------------------------------------------------------
+# SCADA/PLC 接口
+# ----------------------------------------------------------
+scada_api:
+  enabled: true
+  base_url: http://120.55.44.4:8900/api/v1/jinke-cloud/db/device/history-data
+  realtime_url: http://47.96.12.136:8788/api/v1/jinke-cloud/device/current-data
+  jwt_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6NywiVXNlcm5hbWUiOiJhZG1pbiIsIkRlcCI6IjEzNSIsImV4cCI6MTc3NjExOTExNCwiaXNzIjoiZ2luLWJsb2cifQ.0HTtzHZjyd2mHo8VCy8icYROxmntRMuQhyoZsAYRL_M
+  timeout: 10
+
+# ----------------------------------------------------------
+# 人体检测抑制
+# ----------------------------------------------------------
+human_detection:
+  enabled: false
+  db_path: /data/human_detector/detection_status.db
+  cooldown_minutes: 5

+ 140 - 0
config/yaml_backup/rtsp_config_xishan.yaml

@@ -0,0 +1,140 @@
+# ============================================================
+# 锡山中荷 水厂配置文件
+# ============================================================
+# 使用方法:python tool/migrate_yaml_to_db.py --yaml 本文件路径 --force
+# 导入后可通过 API(:8080)在线修改,无需再编辑此文件
+# ============================================================
+
+# ----------------------------------------------------------
+# 水厂列表(一个 YAML 文件只配一个水厂)
+# ----------------------------------------------------------
+plants:
+- name: 锡山中荷                    # 水厂名称(用于日志和推送显示)
+  enabled: true                     # 是否启用该水厂的检测
+  project_id: 92                    # 平台项目 ID(推送接口需要)
+
+  # 流量 PLC 映射(泵名称 -> PLC 地址,用于辅助判断运行状态)
+  # key = pump_name(与 rtsp_streams 中的 pump_name 对应)
+  flow_plc:
+    A: C.M.RO1_FT_JS@out            # A 泵进水流量点位
+    B: C.M.RO2_FT_JS@out
+    C: C.M.RO3_FT_JS@out
+    D: C.M.RO4_FT_JS@out
+
+  # 泵状态 PLC 点位(判断泵启停,启停后进入 15 分钟过渡期不检测)
+  # key = pump_name, value = 点位列表
+  # point: SCADA 系统中的点位地址, val=1 表示运行, val=0 表示停机
+  pump_status_plc:
+    A:
+    - point: C.M.RO1_GYB@run
+      name: RO高压泵A                # 显示名称(用于日志)
+    B:
+    - point: C.M.RO2_GYB@run
+      name: RO高压泵B
+    C:
+    - point: C.M.RO3_GYB@run
+      name: RO高压泵C
+    D:
+    - point: C.M.RO4_GYB@run
+      name: RO高压泵D
+
+  # RTSP 拾音器流列表(每个流 = 一台拾音器 = 一个检测点)
+  rtsp_streams:
+  - name: RO高压泵A                  # 拾音器名称(日志和推送显示)
+    url: rtsp://admin:hao123456@192.168.31.240:554/cam/realmonitor?channel=38&subtype=0  # RTSP 地址
+    channel: 38                      # 通道号
+    device_code: 1#-1                # 设备编码(唯一标识,训练数据文件夹名、模型目录名)
+    pump_name: A                     # 关联的泵名称(对应 pump_status_plc 的 key)
+    # model_subdir: 1#-1             # 模型子目录(默认 = device_code,一般无需指定)
+  - name: RO高压泵B
+    url: rtsp://admin:hao123456@192.168.31.240:554/cam/realmonitor?channel=41&subtype=0
+    channel: 41
+    device_code: 1#-2
+    pump_name: B
+  - name: RO高压泵C
+    url: rtsp://admin:hao123456@192.168.31.240:554/cam/realmonitor?channel=42&subtype=0
+    channel: 42
+    device_code: 1#-3
+    pump_name: C
+  - name: RO高压泵D
+    url: rtsp://admin:hao123456@192.168.31.240:554/cam/realmonitor?channel=43&subtype=0
+    channel: 43
+    device_code: 1#-4
+    pump_name: D
+
+# ----------------------------------------------------------
+# 音频采集参数
+# ----------------------------------------------------------
+audio:
+  sample_rate: 16000                 # 采样率 Hz(必须与训练时一致,勿改)
+  file_duration: 60                  # 每个音频文件时长(秒)
+  segment_duration: 60               # FFmpeg 切片时长(秒)
+  auto_cleanup:                      # 自动清理旧音频(节省磁盘)
+    enabled: true
+    delete_normal: true              # 删除正常检测的音频
+    keep_recent_count: 100           # 每设备保留最近 100 个文件
+
+# ----------------------------------------------------------
+# 异常检测参数
+# ----------------------------------------------------------
+prediction:
+  batch_size: 64                     # 推理批大小
+  check_interval: 1.0               # 检查新文件间隔(秒)
+  default_threshold: 0.01           # 默认阈值(模型未加载时使用)
+  voting:                            # 滑动窗口投票(减少误报)
+    enabled: true
+    window_size: 5                   # 窗口大小(5 个检测周期,约 5 分钟)
+    threshold: 3                     # 5 次中 3 次异常才判定为异常
+  frequency_history:                 # 误差频率统计
+    enabled: true
+    history_minutes: 10              # 统计最近 10 分钟的异常频率
+  energy_detection:                  # 音频能量检测(无 PLC 时用于判断泵启停)
+    enabled: true
+    skip_when_stopped: true          # 泵停机时跳过检测
+  save_anomaly_audio:                # 保存异常音频(用于后续分析/训练)
+    enabled: true
+    save_dir: data/anomaly_detected
+    cooldown_minutes: 15             # 同一设备异常音频保存冷却(避免重复保存)
+    context_capture:                 # 上下文捕获(保存异常前后的音频)
+      enabled: true
+      pre_minutes: 2                 # 异常前 2 分钟
+      post_minutes: 2               # 异常后 2 分钟
+
+# ----------------------------------------------------------
+# 推送通知
+# ----------------------------------------------------------
+push_notification:
+  enabled: true                      # 总开关
+  alert_enabled: false               # 是否推送告警(false = 只推心跳,不推异常告警)
+  push_base_urls:                    # 推送基地址列表(每个 base_url/{project_id} 拼接)
+    - label: "外网"                   # 标签(日志显示)
+      url: "http://120.55.44.4:8900/api/v1/dumu/push-msg"
+    - label: "内网"
+      url: "http://192.168.60.8:8900/api/v1/dumu/push-msg"
+  timeout: 30                        # HTTP 超时(秒)
+  retry_count: 2                     # 失败重试次数
+  cooldown_minutes: 15               # 同设备告警冷却时间(分钟)
+  cooldown_same_type_hours: 24       # 同类型异常冷却(小时)
+  cooldown_diff_type_hours: 1        # 不同类型异常冷却(小时)
+  alert_aggregate:                   # 跨设备聚合告警
+    enabled: true
+    window_seconds: 300              # 聚合窗口(秒)
+    min_devices: 2                   # 至少 2 台设备同时异常才触发聚合告警
+
+# ----------------------------------------------------------
+# SCADA/PLC 接口(泵状态查询)
+# ----------------------------------------------------------
+scada_api:
+  enabled: true                      # 是否启用 PLC 查询(false 时用音频能量判断启停)
+  base_url: http://120.55.44.4:8900/api/v1/jinke-cloud/db/device/history-data    # 历史数据接口
+  realtime_url: http://47.96.12.136:8788/api/v1/jinke-cloud/device/current-data  # 实时数据接口
+  jwt_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6NywiVXNlcm5hbWUiOiJhZG1pbiIsIkRlcCI6IjEzNSIsImV4cCI6MTc3NjExOTExNCwiaXNzIjoiZ2luLWJsb2cifQ.0HTtzHZjyd2mHo8VCy8icYROxmntRMuQhyoZsAYRL_M
+  timeout: 10                        # 查询超时(秒)
+
+# ----------------------------------------------------------
+# 人体检测抑制(有人在设备旁时抑制告警,减少误报)
+# ----------------------------------------------------------
+human_detection:
+  enabled: false                     # 是否启用(需要独立的人体检测服务)
+  db_path: /data/human_detector/detection_status.db  # 人体检测状态 DB 路径
+  cooldown_minutes: 5                # 检测到有人后抑制告警的时间(分钟)

+ 96 - 0
config/yaml_backup/rtsp_config_yancheng.yaml

@@ -0,0 +1,96 @@
+# ============================================================
+# 盐城 水厂配置文件
+# ============================================================
+# 使用方法:python tool/migrate_yaml_to_db.py --yaml 本文件路径 --force
+# ============================================================
+
+plants:
+- name: 盐城                        # 水厂名称
+  enabled: true
+  project_id: 1497                  # 平台项目 ID
+
+  # 流量 PLC 映射
+  flow_plc:
+    RO1总进水流量: ns=3;s=RO1_TOTAL_JSLL
+    RO2总进水流量: ns=3;s=RO2_TOTAL_JSLL
+
+  # 泵状态 PLC 点位
+  pump_status_plc:
+    高压泵:                          # pump_name
+    - point: ns=6;s=P_1#ROGYB_RFB
+      name: 1#RO高压泵
+    - point: ns=6;s=P_2#ROGYB_RFB
+      name: 2#RO高压泵
+
+  # RTSP 拾音器流
+  rtsp_streams:
+  - name: 8#一层高压泵区域            # 显示名称
+    url: rtsp://rtsp:newwater123@192.168.70.11:31016/cam/realmonitor?channel=8&subtype=0
+    channel: 8
+    device_code: YC-8                # 设备编码
+    pump_name: 高压泵                # 关联泵名称
+
+# ----------------------------------------------------------
+# 以下为系统级配置(各水厂通用,一般不需要修改)
+# ----------------------------------------------------------
+audio:
+  sample_rate: 16000
+  file_duration: 60
+  segment_duration: 60
+  auto_cleanup:
+    enabled: true
+    delete_normal: true
+    keep_recent_count: 100
+
+prediction:
+  batch_size: 64
+  check_interval: 1.0
+  default_threshold: 0.01
+  voting:
+    enabled: true
+    window_size: 5
+    threshold: 3
+  frequency_history:
+    enabled: true
+    history_minutes: 10
+  energy_detection:
+    enabled: true
+    skip_when_stopped: true
+  save_anomaly_audio:
+    enabled: true
+    save_dir: data/anomaly_detected
+    cooldown_minutes: 15
+    context_capture:
+      enabled: true
+      pre_minutes: 2
+      post_minutes: 2
+
+push_notification:
+  enabled: true
+  alert_enabled: false
+  push_base_urls:
+    - label: "外网"
+      url: "http://120.55.44.4:8900/api/v1/dumu/push-msg"
+    - label: "内网"
+      url: "http://192.168.60.8:8900/api/v1/dumu/push-msg"
+  timeout: 30
+  retry_count: 2
+  cooldown_minutes: 15
+  cooldown_same_type_hours: 24
+  cooldown_diff_type_hours: 1
+  alert_aggregate:
+    enabled: true
+    window_seconds: 300
+    min_devices: 2
+
+scada_api:
+  enabled: true
+  base_url: http://120.55.44.4:8900/api/v1/jinke-cloud/db/device/history-data
+  realtime_url: http://47.96.12.136:8788/api/v1/jinke-cloud/device/current-data
+  jwt_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6NywiVXNlcm5hbWUiOiJhZG1pbiIsIkRlcCI6IjEzNSIsImV4cCI6MTc3NjExOTExNCwiaXNzIjoiZ2luLWJsb2cifQ.0HTtzHZjyd2mHo8VCy8icYROxmntRMuQhyoZsAYRL_M
+  timeout: 10
+
+human_detection:
+  enabled: false
+  db_path: /data/human_detector/detection_status.db
+  cooldown_minutes: 5

+ 0 - 0
core/__init__.py


+ 190 - 0
core/alert_aggregator.py

@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+alert_aggregator.py
+-------------------
+报警聚合器 - 跨设备聚合抑制 + 分类型冷却
+
+两项核心功能:
+1. 跨设备聚合抑制:同一水厂 N 分钟内 >=M 个设备同时报警 -> 全部抑制(环境噪声)
+2. 分类型冷却时间:同类型异常 24 小时冷却,不同类型异常 1 小时冷却
+"""
+
+import logging
+from datetime import datetime, timedelta
+from collections import defaultdict
+
+logger = logging.getLogger(__name__)
+
+
+class AlertAggregator:
+
+    def __init__(self,
+                 push_callback,
+                 aggregate_enabled=True,
+                 window_seconds=300,
+                 min_devices=2,
+                 cooldown_same_type_hours=24,
+                 cooldown_diff_type_hours=1):
+        # 聚合窗口到期后,符合条件的报警通过此回调实际推送
+        self.push_callback = push_callback
+
+        # 跨设备聚合配置
+        self.aggregate_enabled = aggregate_enabled
+        # 聚合窗口时长(秒),该时间段内收集同一水厂的所有报警
+        self.window_seconds = window_seconds
+        # 触发抑制的最小设备数,>=此数量则判定为环境噪声
+        self.min_devices = min_devices
+
+        # 分类型冷却配置
+        # 同一设备同类型异常的冷却时间(小时),防止重复报警
+        self.cooldown_same_type_hours = cooldown_same_type_hours
+        # 同一设备不同类型异常的冷却时间(小时),允许较快报警新类型
+        self.cooldown_diff_type_hours = cooldown_diff_type_hours
+
+        # 聚合窗口状态
+        # key: plant_name
+        # value: {"start_time": datetime, "alerts": [alert_info_dict, ...]}
+        self.pending_windows = {}
+
+        # 冷却记录
+        # key: device_code
+        # value: {anomaly_type_code: last_alert_datetime}
+        self.cooldown_records = defaultdict(dict)
+
+        logger.info(
+            f"报警聚合器已初始化 | "
+            f"聚合={'启用' if aggregate_enabled else '禁用'} "
+            f"窗口={window_seconds}秒 最小设备数={min_devices} | "
+            f"冷却: 同类型={cooldown_same_type_hours}h 不同类型={cooldown_diff_type_hours}h"
+        )
+
+    def check_cooldown(self, device_code, anomaly_type_code):
+        # 检查该设备是否在冷却期内
+        # 返回 True 表示冷却中(应抑制),False 表示可以报警
+        records = self.cooldown_records.get(device_code, {})
+        now = datetime.now()
+
+        if not records:
+            # 该设备从未报过警,不在冷却期
+            return False
+
+        if anomaly_type_code in records:
+            # 同类型异常:检查 24 小时冷却
+            last_time = records[anomaly_type_code]
+            elapsed_hours = (now - last_time).total_seconds() / 3600
+            if elapsed_hours < self.cooldown_same_type_hours:
+                remaining = self.cooldown_same_type_hours - elapsed_hours
+                logger.info(
+                    f"同类型冷却中: {device_code} | "
+                    f"类型={anomaly_type_code} | "
+                    f"剩余 {remaining:.1f} 小时"
+                )
+                return True
+        else:
+            # 不同类型异常:检查最近一次任意类型报警的 1 小时冷却
+            # 取所有类型中最近的一次报警时间
+            last_any = max(records.values()) if records else None
+            if last_any:
+                elapsed_hours = (now - last_any).total_seconds() / 3600
+                if elapsed_hours < self.cooldown_diff_type_hours:
+                    remaining = self.cooldown_diff_type_hours - elapsed_hours
+                    logger.info(
+                        f"不同类型冷却中: {device_code} | "
+                        f"新类型={anomaly_type_code} | "
+                        f"剩余 {remaining:.1f} 小时"
+                    )
+                    return True
+
+        return False
+
+    def record_cooldown(self, device_code, anomaly_type_code):
+        # 记录报警时间,用于后续冷却判断
+        self.cooldown_records[device_code][anomaly_type_code] = datetime.now()
+
+    def submit_alert(self, plant_name, device_code, anomaly_type_code, push_kwargs):
+        # 提交一条报警到聚合队列
+        #
+        # 参数:
+        #     plant_name: 水厂名称(聚合维度)
+        #     device_code: 设备编号
+        #     anomaly_type_code: 异常类型编码(level_two)
+        #     push_kwargs: 原 _push_detection_result 的全部关键字参数
+        now = datetime.now()
+
+        # 第一步:检查分类型冷却
+        if self.check_cooldown(device_code, anomaly_type_code):
+            logger.info(f"报警被冷却抑制: {device_code} | 类型={anomaly_type_code}")
+            return
+
+        if not self.aggregate_enabled:
+            # 聚合禁用时直接推送
+            self._do_push(device_code, anomaly_type_code, push_kwargs)
+            return
+
+        # 第二步:加入聚合窗口
+        if plant_name not in self.pending_windows:
+            # 该水厂没有活跃窗口,创建新窗口
+            self.pending_windows[plant_name] = {
+                "start_time": now,
+                "alerts": []
+            }
+            logger.info(f"聚合窗口已开启: {plant_name} | 窗口={self.window_seconds}秒")
+
+        # 加入队列(去重:同一设备在同一窗口内只保留一次)
+        window = self.pending_windows[plant_name]
+        existing_devices = {a["device_code"] for a in window["alerts"]}
+        if device_code not in existing_devices:
+            window["alerts"].append({
+                "device_code": device_code,
+                "anomaly_type_code": anomaly_type_code,
+                "push_kwargs": push_kwargs,
+                "submit_time": now
+            })
+            logger.info(
+                f"报警已加入聚合窗口: {plant_name}/{device_code} | "
+                f"当前窗口内设备数={len(window['alerts'])}"
+            )
+
+    def check_and_flush(self):
+        # 定期调用,检查所有聚合窗口是否到期,到期后执行聚合判定
+        now = datetime.now()
+        expired_plants = []
+
+        for plant_name, window in self.pending_windows.items():
+            elapsed = (now - window["start_time"]).total_seconds()
+            if elapsed >= self.window_seconds:
+                expired_plants.append(plant_name)
+
+        for plant_name in expired_plants:
+            window = self.pending_windows.pop(plant_name)
+            alerts = window["alerts"]
+            device_count = len(alerts)
+
+            if device_count >= self.min_devices:
+                # 多设备同时报警 -> 判定为环境噪声,全部抑制
+                device_names = [a["device_code"] for a in alerts]
+                logger.warning(
+                    f"聚合抑制: {plant_name} | "
+                    f"{device_count}个设备同时报警,判定为环境噪声 | "
+                    f"设备: {', '.join(device_names)}"
+                )
+            elif device_count == 1:
+                # 仅单个设备报警 -> 正常推送
+                alert = alerts[0]
+                self._do_push(
+                    alert["device_code"],
+                    alert["anomaly_type_code"],
+                    alert["push_kwargs"]
+                )
+            # device_count == 0 理论上不会出现,忽略
+
+    def _do_push(self, device_code, anomaly_type_code, push_kwargs):
+        # 实际执行推送,并记录冷却时间
+        try:
+            self.push_callback(**push_kwargs)
+            # 推送成功后记录冷却
+            self.record_cooldown(device_code, anomaly_type_code)
+            logger.info(f"报警已推送: {device_code} | 类型={anomaly_type_code}")
+        except Exception as e:
+            logger.error(f"报警推送失败: {device_code} | {e}")

+ 315 - 0
core/anomaly_classifier.py

@@ -0,0 +1,315 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+anomaly_classifier.py
+---------------------
+规则分类器 - 用于判断异常类型
+
+异常类型编码 (level_two):
+- 6: 未分类/一般异常
+- 7: 轴承问题
+- 8: 气蚀问题
+- 9: 松动/共振
+- 10: 叶轮问题
+- 11: 阀件/冲击
+"""
+
+import numpy as np
+import logging
+
+logger = logging.getLogger('AnomalyClassifier')
+
+# 异常类型与编码映射
+ANOMALY_TYPES = {
+    'unknown': {'code': 6, 'name': '未分类异常'},
+    'bearing': {'code': 7, 'name': '轴承问题'},
+    'cavitation': {'code': 8, 'name': '气蚀问题'},
+    'loosening': {'code': 9, 'name': '松动/共振'},
+    'impeller': {'code': 10, 'name': '叶轮问题'},
+    'valve': {'code': 11, 'name': '阀件/冲击'},
+}
+
+
+from pathlib import Path
+
+
+# 基线文件路径
+BASELINE_FILE = Path(__file__).parent / "models" / "classifier_baseline.npy"
+
+# 默认基线(泵正常运行时的典型特征值)
+DEFAULT_BASELINE = {
+    'rms': 0.02,
+    'zcr': 0.05,
+    'energy_std': 0.3,
+    'spectral_centroid': 2000.0,
+    'spectral_bandwidth': 1500.0,
+    'spectral_flatness': 0.01,
+    'low_energy': 0.1,
+    'mid_energy': 0.05,
+    'high_energy': 0.02,
+    'has_periodic': False,
+}
+
+
+class AnomalyClassifier:
+    """
+    规则分类器
+    基于音频特征判断异常类型
+    """
+    
+    def __init__(self):
+        # 正常基线特征
+        self.normal_baseline = None
+        # 自动加载基线
+        self._load_baseline()
+    
+    def _load_baseline(self):
+        """
+        从文件加载基线,如不存在则使用默认值
+        """
+        if BASELINE_FILE.exists():
+            try:
+                data = np.load(BASELINE_FILE, allow_pickle=True).item()
+                self.normal_baseline = data
+                logger.info(f"已加载分类器基线: {BASELINE_FILE.name}")
+            except Exception as e:
+                logger.warning(f"加载基线失败: {e},使用默认基线")
+                self.normal_baseline = DEFAULT_BASELINE.copy()
+        else:
+            # 使用默认基线
+            self.normal_baseline = DEFAULT_BASELINE.copy()
+            logger.info("使用默认分类器基线")
+    
+    def set_baseline(self, baseline_features: dict):
+        """
+        设置正常基线
+        
+        参数:
+            baseline_features: 正常状态的平均特征
+        """
+        self.normal_baseline = baseline_features
+    
+    def save_baseline(self, baseline_features: dict = None):
+        """
+        保存基线到文件
+        
+        参数:
+            baseline_features: 基线特征字典,None则保存当前基线
+        """
+        if baseline_features is not None:
+            self.normal_baseline = baseline_features
+        
+        if self.normal_baseline is None:
+            logger.warning("无基线可保存")
+            return False
+        
+        try:
+            BASELINE_FILE.parent.mkdir(parents=True, exist_ok=True)
+            np.save(BASELINE_FILE, self.normal_baseline)
+            logger.info(f"已保存分类器基线: {BASELINE_FILE.name}")
+            return True
+        except Exception as e:
+            logger.error(f"保存基线失败: {e}")
+            return False
+    
+    def extract_features(self, y: np.ndarray, sr: int = 16000) -> dict:
+        """
+        提取音频特征
+        
+        参数:
+            y: 音频信号
+            sr: 采样率
+        
+        返回:
+            特征字典
+        """
+        try:
+            import librosa
+        except ImportError:
+            logger.warning("librosa未安装,无法提取特征")
+            return {}
+        
+        # 时域特征
+        rms = float(np.sqrt(np.mean(y**2)))
+        zcr = float(np.mean(librosa.feature.zero_crossing_rate(y)))
+        
+        # 能量波动
+        frame_length = int(0.025 * sr)
+        hop = int(0.010 * sr)
+        frames = librosa.util.frame(y, frame_length=frame_length, hop_length=hop)
+        frame_energies = np.sum(frames**2, axis=0)
+        energy_std = float(np.std(frame_energies) / (np.mean(frame_energies) + 1e-10))
+        
+        # 频域特征
+        spectral_centroid = float(np.mean(librosa.feature.spectral_centroid(y=y, sr=sr)))
+        spectral_bandwidth = float(np.mean(librosa.feature.spectral_bandwidth(y=y, sr=sr)))
+        spectral_flatness = float(np.mean(librosa.feature.spectral_flatness(y=y)))
+        
+        # 频段能量
+        D = np.abs(librosa.stft(y, n_fft=1024, hop_length=256))
+        freqs = librosa.fft_frequencies(sr=sr, n_fft=1024)
+        
+        low_mask = freqs < 1000
+        mid_mask = (freqs >= 1000) & (freqs < 3000)
+        high_mask = freqs >= 3000
+        
+        low_energy = float(np.mean(D[low_mask, :]))
+        mid_energy = float(np.mean(D[mid_mask, :]))
+        high_energy = float(np.mean(D[high_mask, :]))
+        
+        # 周期性检测(简化版)
+        autocorr = np.correlate(y[:min(len(y), sr)], y[:min(len(y), sr)], mode='full')
+        autocorr = autocorr[len(autocorr)//2:]
+        autocorr = autocorr / (autocorr[0] + 1e-10)
+        
+        min_lag = int(0.01 * sr)
+        max_lag = int(0.5 * sr)
+        search_range = autocorr[min_lag:max_lag]
+        
+        has_periodic = False
+        if len(search_range) > 0:
+            peak_value = np.max(search_range)
+            has_periodic = peak_value > 0.3
+        
+        return {
+            'rms': rms,
+            'zcr': zcr,
+            'energy_std': energy_std,
+            'spectral_centroid': spectral_centroid,
+            'spectral_bandwidth': spectral_bandwidth,
+            'spectral_flatness': spectral_flatness,
+            'low_energy': low_energy,
+            'mid_energy': mid_energy,
+            'high_energy': high_energy,
+            'has_periodic': has_periodic,
+        }
+    
+    def classify(self, current_features: dict) -> tuple:
+        """
+        分类异常类型
+        
+        参数:
+            current_features: 当前音频特征
+        
+        返回:
+            (level_two编码, 异常类型名称, 置信度)
+        """
+        if self.normal_baseline is None:
+            # 无基线,返回未分类
+            return 6, '未分类异常', 0.0
+        
+        # 计算变化率
+        def safe_change(curr, base):
+            if base == 0:
+                return 0.0
+            return (curr - base) / base
+        
+        changes = {
+            'high_freq': safe_change(current_features.get('high_energy', 0), self.normal_baseline.get('high_energy', 1)),
+            'low_freq': safe_change(current_features.get('low_energy', 0), self.normal_baseline.get('low_energy', 1)),
+            'zcr': safe_change(current_features.get('zcr', 0), self.normal_baseline.get('zcr', 1)),
+            'centroid': safe_change(current_features.get('spectral_centroid', 0), self.normal_baseline.get('spectral_centroid', 1)),
+            'bandwidth': safe_change(current_features.get('spectral_bandwidth', 0), self.normal_baseline.get('spectral_bandwidth', 1)),
+            'energy_std': safe_change(current_features.get('energy_std', 0), self.normal_baseline.get('energy_std', 1)),
+            'flatness': safe_change(current_features.get('spectral_flatness', 0), self.normal_baseline.get('spectral_flatness', 1)),
+            'rms': safe_change(current_features.get('rms', 0), self.normal_baseline.get('rms', 1)),
+        }
+        has_periodic = current_features.get('has_periodic', False)
+        
+        # 规则判断
+        scores = {
+            'bearing': 0.0,
+            'cavitation': 0.0,
+            'loosening': 0.0,
+            'impeller': 0.0,
+            'valve': 0.0,
+        }
+        
+        # 轴承问题:高频增加、过零率变高、频谱质心上移
+        if changes['high_freq'] > 0.3:
+            scores['bearing'] += 0.4
+        if changes['zcr'] > 0.2:
+            scores['bearing'] += 0.3
+        if changes['centroid'] > 0.15:
+            scores['bearing'] += 0.3
+        
+        # 气蚀问题:噪声增加、频谱变宽、能量波动大
+        if changes['bandwidth'] > 0.25:
+            scores['cavitation'] += 0.35
+        if changes['energy_std'] > 0.4:
+            scores['cavitation'] += 0.35
+        if changes['flatness'] > 0.2:
+            scores['cavitation'] += 0.3
+        
+        # 松动/共振:低频增加、周期性冲击
+        if changes['low_freq'] > 0.35:
+            scores['loosening'] += 0.5
+        if has_periodic:
+            scores['loosening'] += 0.5
+        
+        # 叶轮问题:能量变化、周期性
+        if changes['rms'] > 0.3:
+            scores['impeller'] += 0.35
+        if abs(changes['centroid']) > 0.25:
+            scores['impeller'] += 0.35
+        if has_periodic:
+            scores['impeller'] += 0.3
+        
+        # 阀件/冲击:能量波动大、低频有增加
+        if changes['energy_std'] > 0.5:
+            scores['valve'] += 0.6
+        if changes['low_freq'] > 0.2:
+            scores['valve'] += 0.4
+        
+        # 选择最高分
+        if max(scores.values()) < 0.3:
+            return 6, '未分类异常', 0.0
+        
+        best_type = max(scores, key=scores.get)
+        confidence = min(scores[best_type], 1.0)
+        
+        code = ANOMALY_TYPES[best_type]['code']
+        name = ANOMALY_TYPES[best_type]['name']
+        
+        return code, name, confidence
+    
+    def classify_audio(self, y: np.ndarray, sr: int = 16000) -> tuple:
+        """
+        直接从音频分类
+        
+        参数:
+            y: 音频信号
+            sr: 采样率
+        
+        返回:
+            (level_two编码, 异常类型名称, 置信度)
+        """
+        features = self.extract_features(y, sr)
+        return self.classify(features)
+
+
+# 全局分类器实例
+_classifier = None
+
+
+def get_classifier() -> AnomalyClassifier:
+    """获取全局分类器实例"""
+    global _classifier
+    if _classifier is None:
+        _classifier = AnomalyClassifier()
+    return _classifier
+
+
+def classify_anomaly(y: np.ndarray, sr: int = 16000) -> tuple:
+    """
+    便捷函数:分类异常类型
+    
+    参数:
+        y: 音频信号
+        sr: 采样率
+    
+    返回:
+        (level_two编码, 异常类型名称, 置信度)
+    """
+    classifier = get_classifier()
+    return classifier.classify_audio(y, sr)

+ 248 - 0
core/energy_baseline.py

@@ -0,0 +1,248 @@
+# -*- coding: utf-8 -*-
+"""
+energy_baseline.py
+------------------
+能量基线自动校准模块 - 基于音频能量判断泵启停状态
+
+功能:
+    1. 冷启动时自动采集能量数据,计算运行/停机阈值
+    2. 根据实时 RMS 能量判断泵状态(开机/停机)
+    3. 通过滑动窗口检测能量趋势变化
+
+与 pump_state_monitor 的区别:
+    - pump_state_monitor: 基于 PLC 查询,准确但需 SCADA 系统
+    - energy_baseline: 基于音频能量,无需外部系统,适合无 PLC 场景
+
+调用流程:
+    baseline = EnergyBaseline(pump_id="ch8", warmup_samples=30)
+    
+    # 每次处理音频时:
+    rms = np.sqrt(np.mean(y ** 2))  # 计算 RMS 能量
+    status = baseline.update(rms)   # 返回 "开机" / "停机"
+    
+    # 判断是否运行中(决定是否做异常检测)
+    if baseline.is_running():
+        # 进行异常检测
+        pass
+
+阈值计算逻辑:
+    - energy_high = 运行时能量下界(高能量分布的第5百分位)
+    - energy_low = energy_high × 30%
+    - 能量 > energy_high -> 运行中
+    - 能量 < energy_low  -> 停机中
+
+注意:
+    - 冷启动约4分钟(30样本×8秒),期间默认返回"开机"
+    - 每500个样本自动重新校准阈值
+"""
+
+import logging
+import numpy as np
+from typing import Dict, List
+
+logger = logging.getLogger(__name__)
+
+
+class EnergyBaseline:
+    """
+    能量基线自动校准类
+    
+    功能:
+    1. 自动采集音频能量数据
+    2. 自动计算运行/停机阈值
+    3. 判断泵状态(运行/停机/开机/停机中)
+    
+    使用方法:
+        baseline = EnergyBaseline(pump_id="ch8")
+        baseline.update(rms_value)  # 每次处理音频时更新
+        status = baseline.get_status()  # 获取当前状态
+    """
+    
+    # 状态常量
+    # 校准期间默认为开机状态
+    STATUS_CALIBRATING = "开机"
+    STATUS_RUNNING = "开机"
+    STATUS_STOPPED = "停机"
+    STATUS_STARTING = "开机"
+    STATUS_STOPPING = "停机"
+    
+    def __init__(self, pump_id: str, warmup_samples: int = 30):
+        """
+        初始化能量基线
+        
+        参数:
+            pump_id: 泵标识
+            warmup_samples: 冷启动需要的样本数(约30×8s=4分钟)
+        """
+        self.pump_id = pump_id
+        self.warmup_samples = warmup_samples
+        
+        # 能量历史记录(用于校准)
+        self.energy_history: List[float] = []
+        
+        # 最近窗口记录(用于趋势判断)
+        self.recent_window: List[float] = []
+        self.window_size = 5
+        
+        # 校准状态
+        self.is_calibrated = False
+        self.energy_high = None  # 运行阈值
+        self.energy_low = None   # 停机阈值
+        
+        # 当前状态
+        self.current_status = self.STATUS_CALIBRATING
+    
+    def update(self, rms: float) -> str:
+        """
+        更新能量并返回当前状态
+        
+        参数:
+            rms: 当前音频的RMS能量值
+        
+        返回:
+            status: 当前泵状态
+        """
+        # 记录到历史(用于校准)
+        self.energy_history.append(rms)
+        
+        # 记录到最近窗口(用于趋势判断)
+        self.recent_window.append(rms)
+        if len(self.recent_window) > self.window_size:
+            self.recent_window.pop(0)
+        
+        # 冷启动阶段:等待积累足够数据
+        if not self.is_calibrated:
+            if len(self.energy_history) >= self.warmup_samples:
+                self._calibrate()
+            else:
+                # 显示校准进度
+                progress = len(self.energy_history)
+                if progress % 5 == 0:  # 每5个样本输出一次
+                    logger.info(f"[{self.pump_id}] 能量校准中: {progress}/{self.warmup_samples} | RMS={rms:.4f}")
+                self.current_status = self.STATUS_CALIBRATING
+                return self.current_status
+        else:
+            # 定期重新校准(每500个样本)
+            if len(self.energy_history) > 500:
+                self.energy_history = self.energy_history[-500:]
+                self._calibrate()
+        
+        # 判断状态
+        self.current_status = self._determine_status()
+        return self.current_status
+    
+    def _calibrate(self):
+        """
+        根据历史数据计算阈值
+        
+        策略:
+        1. 假设大部分时间泵在运行
+        2. 取能量分布的高区间作为"运行能量"
+        3. 计算阈值
+        """
+        energies = np.array(self.energy_history)
+        
+        # 过滤掉极低能量(可能是静音或损坏文件)
+        valid_energies = energies[energies > 0.001]
+        
+        if len(valid_energies) < 10:
+            # 数据不足,使用默认值
+            self.energy_high = 0.05
+            self.energy_low = 0.02
+            self.is_calibrated = True
+            logger.info(f"[{self.pump_id}] 数据不足,使用默认阈值: HIGH={self.energy_high:.4f}, LOW={self.energy_low:.4f}")
+            return
+        
+        # 取高能量部分(假设运行时能量较高)
+        # 使用第30百分位以上的数据
+        threshold = np.percentile(valid_energies, 30)
+        high_energies = valid_energies[valid_energies > threshold]
+        
+        if len(high_energies) > 5:
+            # 运行阈值 = 运行能量分布的第5百分位(下界)
+            self.energy_high = float(np.percentile(high_energies, 5))
+            # 停机阈值 = 运行阈值的30%
+            self.energy_low = self.energy_high * 0.3
+        else:
+            # 使用所有数据的中位数
+            self.energy_high = float(np.median(valid_energies) * 0.7)
+            self.energy_low = self.energy_high * 0.3
+        
+        self.is_calibrated = True
+        logger.info(f"[{self.pump_id}] 阈值自动校准: HIGH={self.energy_high:.4f}, LOW={self.energy_low:.4f}")
+    
+    def _determine_status(self) -> str:
+        """
+        根据能量趋势判断泵状态
+        
+        返回:
+            status: 泵状态字符串
+        """
+        if len(self.recent_window) < 3:
+            return self.STATUS_CALIBRATING
+        
+        # 最近能量和较早能量
+        recent_avg = np.mean(self.recent_window[-2:])   # 最近2个窗口
+        older_avg = np.mean(self.recent_window[:2])     # 较早2个窗口
+        current = self.recent_window[-1]
+        
+        # 判断逻辑
+        # 1. 高→低:停机过程
+        if older_avg > self.energy_high and recent_avg < self.energy_low:
+            return self.STATUS_STOPPING
+        
+        # 2. 低→高:启动过程
+        if older_avg < self.energy_low and recent_avg > self.energy_high:
+            return self.STATUS_STARTING
+        
+        # 3. 稳定高:运行中
+        if current > self.energy_high:
+            return self.STATUS_RUNNING
+        
+        # 4. 稳定低:停机中
+        if current < self.energy_low:
+            return self.STATUS_STOPPED
+        
+        # 5. 中间状态:在阈值之间
+        # 根据与哪个阈值更接近来判断
+        mid_point = (self.energy_high + self.energy_low) / 2
+        if current >= mid_point:
+            return self.STATUS_RUNNING
+        else:
+            return self.STATUS_STOPPED
+    
+    def get_status(self) -> str:
+        """
+        获取当前泵状态
+        
+        返回:
+            status: 当前状态
+        """
+        return self.current_status
+    
+    def is_running(self) -> bool:
+        """
+        判断泵是否在运行
+        
+        只有"运行中"状态返回True
+        用于决定是否进行AE异常检测
+        
+        返回:
+            bool: 是否运行中
+        """
+        return self.current_status == self.STATUS_RUNNING
+    
+    def get_calibration_info(self) -> Dict:
+        """
+        获取校准信息
+        
+        返回:
+            dict: 校准状态和阈值信息
+        """
+        return {
+            'is_calibrated': self.is_calibrated,
+            'energy_high': self.energy_high,
+            'energy_low': self.energy_low,
+            'samples_collected': len(self.energy_history),
+            'current_status': self.current_status
+        }

+ 135 - 0
core/human_detection_reader.py

@@ -0,0 +1,135 @@
+"""
+人体检测 SQLite 读取模块
+
+读取 YOLO 人体检测结果,判断是否在冷却期内
+用于抑制异常报警:5 分钟内检测到人 → 不报警
+"""
+import sqlite3
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Optional
+import logging
+
+logger = logging.getLogger('PickupMonitor')
+
+
+class HumanDetectionReader:
+    """
+    人体检测结果读取器
+    
+    只要任意摄像头在指定时间窗口内检测到人,就返回 True(抑制报警)
+    """
+    
+    def __init__(self, db_path: str, cooldown_minutes: int = 5):
+        """
+        初始化读取器
+        
+        参数:
+            db_path: SQLite 数据库路径
+            cooldown_minutes: 冷却时间(分钟),检测到人后多久内抑制报警
+        """
+        self.db_path = Path(db_path) if db_path else None
+        self.cooldown_minutes = cooldown_minutes
+        
+        # 缓存:避免频繁查询数据库
+        self._cache_result: Optional[bool] = None
+        self._cache_time: Optional[datetime] = None
+        self._cache_ttl_seconds = 10  # 缓存有效期(秒)
+    
+    def _get_connection(self) -> Optional[sqlite3.Connection]:
+        """
+        获取数据库连接
+        
+        返回:
+            连接对象,数据库不存在时返回 None
+        """
+        if self.db_path is None or not self.db_path.exists():
+            return None
+        
+        try:
+            conn = sqlite3.connect(str(self.db_path), timeout=5)
+            return conn
+        except Exception as e:
+            logger.warning(f"人体检测数据库连接失败: {e}")
+            return None
+    
+    def get_last_person_time(self) -> Optional[datetime]:
+        """
+        获取最后一次检测到人的时间(任意摄像头)
+        
+        返回:
+            最后检测到人的时间,无数据返回 None
+        """
+        conn = self._get_connection()
+        if conn is None:
+            return None
+        
+        try:
+            row = conn.execute("""
+                SELECT datetime FROM detections 
+                WHERE detected = 1 
+                ORDER BY datetime DESC 
+                LIMIT 1
+            """).fetchone()
+            conn.close()
+            
+            if row:
+                # 解析时间字符串
+                return datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S")
+            return None
+            
+        except Exception as e:
+            logger.warning(f"查询人体检测数据失败: {e}")
+            if conn:
+                conn.close()
+            return None
+    
+    def is_in_cooldown(self) -> bool:
+        """
+        判断是否在冷却期内(任意摄像头检测到人后的 N 分钟内)
+        
+        返回:
+            True: 在冷却期内,应抑制报警
+            False: 不在冷却期,可正常报警
+        """
+        now = datetime.now()
+        
+        # 检查缓存是否有效
+        if (self._cache_result is not None and 
+            self._cache_time is not None and
+            (now - self._cache_time).total_seconds() < self._cache_ttl_seconds):
+            return self._cache_result
+        
+        # 查询数据库
+        last_person_time = self.get_last_person_time()
+        
+        if last_person_time is None:
+            result = False  # 从未检测到人,不抑制
+        else:
+            # 判断是否在冷却期内
+            cooldown_end = last_person_time + timedelta(minutes=self.cooldown_minutes)
+            result = now < cooldown_end
+        
+        # 更新缓存
+        self._cache_result = result
+        self._cache_time = now
+        
+        return result
+    
+    def get_status_info(self) -> str:
+        """
+        获取状态信息(用于日志)
+        
+        返回:
+            状态描述字符串
+        """
+        last_time = self.get_last_person_time()
+        if last_time is None:
+            return "无人体检测数据"
+        
+        elapsed = (datetime.now() - last_time).total_seconds() / 60
+        if elapsed < self.cooldown_minutes:
+            remaining = self.cooldown_minutes - elapsed
+            return f"冷却中(剩余{remaining:.1f}分钟)"
+        else:
+            return f"已超时({elapsed:.1f}分钟前有人)"

+ 289 - 0
core/pump_state_monitor.py

@@ -0,0 +1,289 @@
+# -*- coding: utf-8 -*-
+"""
+pump_state_monitor.py - 泵状态监控模块
+
+
+================================================================================
+使用示例
+================================================================================
+
+# 初始化
+from pump_state_monitor import PumpStateMonitor
+
+monitor = PumpStateMonitor(
+    scada_url="http://47.96.12.136:8788/api/v1/jinke-cloud/device/current-data",
+    scada_jwt="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6NywiVXNlcm5hbWUiOiJhZG1pbiIsIkRlcCI6IjEzNSIsImV4cCI6MTc3NjExOTExNCwiaXNzIjoiZ2luLWJsb2cifQ.0HTtzHZjyd2mHo8VCy8icYROxmntRMuQhyoZsAYRL_M",
+    project_id=92,
+    transition_window_minutes=15
+)
+
+# 单泵查询
+is_running, _ = monitor.update_pump_state("pump_1", "C.M.RO1_GYB@run", "RO1高压泵")
+in_transition = monitor.is_in_transition("pump_1")
+
+# 多泵批量查询
+pump_configs = [
+    {"point": "C.M.RO1_GYB@run", "name": "RO1高压泵"},
+    {"point": "C.M.RO2_GYB@run", "name": "RO2高压泵"}
+]
+in_transition, pumps = monitor.check_pumps_transition(pump_configs)
+
+# 业务逻辑
+for audio_file in get_audio_files():
+    is_running, _ = monitor.update_pump_state("pump_1", "C.M.RO1_GYB@run", "RO1高压泵")
+    if not is_running:
+        skip_audio(audio_file)     # 泵停机,跳过
+        continue
+    if monitor.is_in_transition("pump_1"):
+        skip_audio(audio_file)     # 过渡期,跳过
+        continue
+    process_audio(audio_file)      # 泵运行且稳定,正常处理
+
+================================================================================
+时间线案例 (过渡期=15分钟)
+================================================================================
+10:00:00  首次调用    -> is_running=True,  in_transition=False  (初始化)
+10:05:00  泵停机      -> is_running=False, in_transition=True   (进入过渡期)
+10:20:00  过渡期结束  -> is_running=False, in_transition=False  (15分钟后)
+10:25:00  泵启动      -> is_running=True,  in_transition=True   (再次进入过渡期)
+10:40:00  过渡期结束  -> is_running=True,  in_transition=False  (可正常处理)
+
+================================================================================
+内部机制
+================================================================================
+1. 查询缓存: 30秒内同一泵只查询一次SCADA,调用方无需控制频率
+2. 状态变化: 本地比较前后状态,变化时自动记录时间
+3. 过渡期判定: 基于状态变化时间,默认15分钟窗口
+4. 首次查询: 不视为过渡期(假设泵已稳定运行)
+
+日志: 泵状态初始化/变化时输出 INFO 级别日志
+"""
+
+
+import requests
+import logging
+from datetime import datetime, timedelta
+from collections import defaultdict
+
+
+logger = logging.getLogger(__name__)
+
+
+class PumpStateMonitor:
+    """
+    泵状态监控器
+    
+    功能:
+        - 查询泵运行状态
+        - 记录状态变化历史
+        - 判断是否处于启停过渡期
+    """
+    
+    def __init__(self, scada_url, scada_jwt, project_id, 
+                 timeout=10, transition_window_minutes=15):
+        """
+        初始化泵状态监控器
+        
+        参数:
+            scada_url: SCADA API地址
+            scada_jwt: JWT认证Token
+            project_id: 项目ID (用于API查询)
+            timeout: 请求超时秒数
+            transition_window_minutes: 启停过渡期窗口(默认 15分钟)
+        """
+        self.scada_url = scada_url
+        self.scada_jwt = scada_jwt
+        self.project_id = project_id
+        self.timeout = timeout
+        self.transition_window_minutes = transition_window_minutes
+        
+        # 状态缓存: {pump_id: True/False}
+        self.current_states = {}
+        
+        # 最后状态变化时间缓存: {pump_id: datetime}
+        # 注意: 由本地记录,不依赖接口返回的 htime(那是数据采集时间,不是状态变化时间)
+        self.state_change_time = {}
+        
+        # 上次查询时间(避免频繁查询)
+        self.last_query_time = {}
+        self.min_query_interval_seconds = 30  # 30 秒查询一次
+    
+    def query_pump_status(self, point, pump_name=""):
+        """
+        查询单个泵的运行状态(使用实时数据接口)
+        
+        使用 current-data 接口直接获取最新一条数据,无需时间窗口查询。
+        
+        参数:
+            point: 点位标识, 如 "C.M.RO1_GYB@run"
+            pump_name: 泵名称, 用于日志
+        
+        返回:
+            (is_running, last_change_time):
+                is_running: True=运行中, False=停机
+                last_change_time: 最后一次状态变化时间 (来自 htime 字段)
+        """
+        headers = {
+            "Content-Type": "application/json",
+            "JWT-TOKEN": self.scada_jwt
+        }
+        
+        # 当前时间戳(毫秒)
+        now_ms = int(datetime.now().timestamp() * 1000)
+        
+        # 请求参数
+        params = {"time": now_ms}
+        
+        # 请求体:使用实时数据接口格式
+        request_body = [
+            {
+                "deviceId": "1",
+                "deviceItems": point,
+                "deviceName": pump_name or point,
+                "project_id": self.project_id
+            }
+        ]
+        
+        try:
+            response = requests.post(
+                self.scada_url,
+                params=params,
+                json=request_body,
+                headers=headers,
+                timeout=self.timeout
+            )
+            
+            if response.status_code == 200:
+                data = response.json()
+                if data.get("code") == 200 and data.get("data"):
+                    # 获取第一条数据(实时接口只返回最新一条)
+                    latest = data["data"][0]
+                    if "val" in latest:
+                        val = int(float(latest["val"]))
+                        is_running = val == 1
+                        
+                        # 解析 htime 时间字段(实时接口返回的是北京时间字符串)
+                        htime_str = latest.get("htime", "")
+                        last_change_time = None
+                        if htime_str:
+                            try:
+                                # 直接解析,不做时区转换(按北京时间处理)
+                                last_change_time = datetime.strptime(htime_str, "%Y-%m-%d %H:%M:%S")
+                            except ValueError:
+                                logger.warning(f"无法解析 htime: {htime_str}")
+                        
+                        logger.debug(f"泵状态查询: {pump_name or point} = {'运行' if is_running else '停机'} (变化时间={htime_str})")
+                        return is_running, last_change_time
+                
+                # 接口返回成功但无数据
+                logger.warning(f"泵状态查询无数据: {pump_name or point}")
+            else:
+                logger.warning(f"泵状态查询HTTP错误: {pump_name or point} | status={response.status_code}")
+                
+        except Exception as e:
+            logger.warning(f"泵状态查询失败: {pump_name or point} | {e}")
+        
+        # 查询失败时默认返回停机状态
+        logger.warning(f"泵状态查询: {pump_name or point} | 查询失败,默认视为停机")
+        return False, None
+    
+    def update_pump_state(self, pump_id, point, pump_name=""):
+        """
+        更新并检测泵状态变化
+        
+        状态变化时间由本地记录,不依赖接口返回的 htime(那是数据采集时间)。
+        
+        参数:
+            pump_id: 泵唯一标识
+            point: SCADA点位
+            pump_name: 泵名称
+        
+        返回:
+            (当前状态 True/False, 最后状态变化时间)
+        """
+        # 限制查询频率
+        now = datetime.now()
+        last_query = self.last_query_time.get(pump_id)
+        if last_query:
+            elapsed = (now - last_query).total_seconds()
+            if elapsed < self.min_query_interval_seconds:
+                # 使用缓存状态
+                cached_state = self.current_states.get(pump_id)
+                cached_time = self.state_change_time.get(pump_id)
+                return cached_state, cached_time
+        
+        # 查询状态(实时接口只返回当前状态,不返回状态变化时间)
+        new_state, _ = self.query_pump_status(point, pump_name)
+        
+        self.last_query_time[pump_id] = now
+        
+        # 检测状态变化
+        old_state = self.current_states.get(pump_id)
+        state_change_time = self.state_change_time.get(pump_id)
+        
+        if old_state is None:
+            # 首次查询:初始化缓存状态
+            # 假设泵已经稳定运行,不设置过渡期(避免程序启动时所有泵都被认为在过渡期)
+            state_change_time = None  # None 表示不在过渡期
+            logger.info(f"泵状态初始化: {pump_name or pump_id} = {'运行' if new_state else '停机'}")
+        elif new_state != old_state:
+            # 状态变化:记录当前时间为变化时间
+            state_change_time = now
+            event = "启动" if new_state else "停机"
+            logger.info(f"泵状态变化: {pump_name or pump_id} {event} | 进入过渡期")
+        
+        # 更新缓存
+        self.current_states[pump_id] = new_state
+        self.state_change_time[pump_id] = state_change_time
+        
+        return new_state, state_change_time
+    
+    def is_in_transition(self, pump_id):
+        """
+        检查泵是否处于启停过渡期
+        
+        基于本地记录的状态变化时间判断。
+        
+        参数:
+            pump_id: 泵唯一标识
+        
+        返回:
+            True=过渡期内(最近N分钟有状态变化), False=稳定状态
+        """
+        last_change_time = self.state_change_time.get(pump_id)
+        if not last_change_time:
+            # None 表示首次查询或无状态变化记录,视为稳定状态
+            return False
+        
+        # 使用本地时间比较(变化时间是本地记录的)
+        elapsed_minutes = (datetime.now() - last_change_time).total_seconds() / 60
+        
+        return elapsed_minutes < self.transition_window_minutes
+    
+    def check_pumps_transition(self, pump_configs):
+        """
+        检查多个泵是否有任何处于启停过渡期
+        
+        参数:
+            pump_configs: 泵配置列表 [{"point": "...", "name": "..."}, ...]
+        
+        返回:
+            (是否有泵在过渡期, 过渡期泵名称列表)
+        """
+        in_transition = False
+        transition_pumps = []
+        
+        for pump_cfg in pump_configs:
+            point = pump_cfg.get("point", "")
+            name = pump_cfg.get("name", point)
+            pump_id = point  # 使用点位作为唯一ID
+            
+            # 更新状态
+            self.update_pump_state(pump_id, point, name)
+            
+            # 检查是否过渡期
+            if self.is_in_transition(pump_id):
+                in_transition = True
+                transition_pumps.append(name)
+        
+        return in_transition, transition_pumps

+ 52 - 0
data/stable_audio_collection/LT-2/stable_periods.csv

@@ -0,0 +1,52 @@
+序号,开始时间,结束时间,时长(分钟)
+1,2026-01-07 21:19:00,2026-01-08 00:08:40,169.7
+2,2026-01-08 00:15:40,2026-01-08 00:49:10,33.5
+3,2026-01-08 00:59:10,2026-01-08 08:09:50,430.7
+4,2026-01-08 08:31:20,2026-01-08 09:34:20,63.0
+5,2026-01-08 09:58:30,2026-01-08 19:27:10,568.7
+6,2026-01-08 19:48:40,2026-01-08 21:30:50,102.2
+7,2026-01-08 21:51:50,2026-01-09 06:44:20,532.5
+8,2026-01-09 07:05:50,2026-01-09 09:14:30,128.7
+9,2026-01-09 11:48:50,2026-01-09 19:22:30,453.7
+10,2026-01-09 19:29:30,2026-01-10 02:04:30,395.0
+11,2026-01-10 02:14:30,2026-01-10 06:16:20,241.8
+12,2026-01-10 06:37:20,2026-01-10 09:02:20,145.0
+13,2026-01-10 09:35:10,2026-01-10 20:31:00,655.8
+14,2026-01-10 20:52:30,2026-01-10 21:27:00,34.5
+15,2026-01-10 21:52:50,2026-01-11 01:24:20,211.5
+16,2026-01-11 01:45:10,2026-01-11 06:43:20,298.2
+17,2026-01-11 07:04:50,2026-01-11 09:25:20,140.5
+18,2026-01-11 09:51:10,2026-01-11 10:27:10,36.0
+19,2026-01-11 10:48:40,2026-01-11 11:28:20,39.7
+20,2026-01-11 11:59:10,2026-01-11 12:24:20,25.2
+21,2026-01-11 12:45:40,2026-01-11 14:13:20,87.7
+22,2026-01-11 14:34:50,2026-01-11 16:24:50,110.0
+23,2026-01-11 16:46:00,2026-01-11 18:01:40,75.7
+24,2026-01-11 18:11:40,2026-01-11 18:48:10,36.5
+25,2026-01-11 19:07:40,2026-01-11 21:46:20,158.7
+26,2026-01-11 22:12:10,2026-01-11 22:58:20,46.2
+27,2026-01-11 23:19:50,2026-01-12 00:31:00,71.2
+28,2026-01-12 00:52:30,2026-01-12 01:51:30,59.0
+29,2026-01-12 02:44:20,2026-01-12 03:20:40,36.3
+30,2026-01-12 03:42:10,2026-01-12 05:05:00,82.8
+31,2026-01-12 05:26:20,2026-01-12 06:52:10,85.8
+32,2026-01-12 07:13:40,2026-01-12 10:29:20,195.7
+33,2026-01-12 10:55:20,2026-01-12 13:04:00,128.7
+34,2026-01-12 16:59:20,2026-01-12 19:06:50,127.5
+35,2026-01-12 19:28:00,2026-01-13 01:41:20,373.3
+36,2026-01-13 02:07:10,2026-01-13 03:28:40,81.5
+37,2026-01-13 03:50:10,2026-01-13 05:38:50,108.7
+38,2026-01-13 06:00:30,2026-01-13 06:43:30,43.0
+39,2026-01-13 07:05:20,2026-01-13 08:02:40,57.3
+40,2026-01-13 08:24:20,2026-01-13 09:08:30,44.2
+41,2026-01-13 09:30:10,2026-01-13 10:19:40,49.5
+42,2026-01-13 10:41:10,2026-01-13 11:56:40,75.5
+43,2026-01-13 12:18:00,2026-01-13 12:53:30,35.5
+44,2026-01-13 13:14:40,2026-01-13 13:42:50,28.2
+45,2026-01-13 18:56:50,2026-01-13 19:37:20,40.5
+46,2026-01-13 19:57:10,2026-01-14 03:32:50,455.7
+47,2026-01-14 03:54:10,2026-01-14 05:35:20,101.2
+48,2026-01-14 06:01:10,2026-01-14 08:12:30,131.3
+49,2026-01-14 08:34:30,2026-01-14 10:35:40,121.2
+50,2026-01-14 10:53:40,2026-01-14 14:01:00,187.3
+51,2026-01-14 14:41:40,2026-01-14 15:26:00,44.3

二進制
models/LT-2/ae_model.pth


二進制
models/LT-2/global_scale.npy


二進制
models/LT-2/thresholds/threshold_default.npy


二進制
models/LT-5/ae_model.pth


二進制
models/LT-5/global_scale.npy


二進制
models/LT-5/thresholds/threshold_default.npy


+ 15 - 0
predictor/__init__.py

@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+"""
+predictor 预测模块
+
+核心组件:
+- MultiModelPredictor: 多设备独立模型管理(热加载)
+- DevicePredictor: 单设备预测器
+- ConvAutoencoder: 卷积自编码器模型
+"""
+
+from .config import CFG
+from .model_def import ConvAutoencoder
+from .multi_model_predictor import MultiModelPredictor, DevicePredictor
+
+__all__ = ['CFG', 'ConvAutoencoder', 'MultiModelPredictor', 'DevicePredictor']

+ 121 - 0
predictor/config.py

@@ -0,0 +1,121 @@
+# -*- coding: utf-8 -*-
+"""
+config.py - 部署环境配置
+========================
+
+包含部署环境所需的配置参数。
+参数必须与训练环境保持一致以确保推理结果正确。
+"""
+
+from pathlib import Path
+
+
+class DeployConfig:
+    """
+    部署配置类
+    
+    包含推理所需的路径和参数配置。
+    与训练环境的config.py保持参数一致。
+    
+    支持两种模型目录结构:
+    1. 默认: models/ae_model.pth, models/thresholds/
+    2. 子目录: models/{subdir}/ae_model.pth, models/{subdir}/thresholds/
+    """
+    
+    # ========================================
+    # 路径配置
+    # ========================================
+    
+    # 部署根目录(predictor的上级目录)
+    DEPLOY_ROOT = Path(__file__).resolve().parent.parent
+    
+    # 模型根目录
+    MODEL_ROOT = DEPLOY_ROOT / "models"
+    
+    # 模型子目录(可通过 set_model_subdir 设置)
+    # 为空时使用 MODEL_ROOT,否则使用 MODEL_ROOT / MODEL_SUBDIR
+    MODEL_SUBDIR = ""
+    
+    # 音频目录
+    AUDIO_DIR = DEPLOY_ROOT / "data" / "audio"
+    
+    @classmethod
+    def set_model_subdir(cls, subdir: str):
+        """
+        设置模型子目录
+        
+        Args:
+            subdir: 子目录名(如 "LT-2"),为空则使用默认目录
+        """
+        cls.MODEL_SUBDIR = subdir
+    
+    @property
+    def MODEL_DIR(self) -> Path:
+        """获取当前模型目录"""
+        if self.MODEL_SUBDIR:
+            return self.MODEL_ROOT / self.MODEL_SUBDIR
+        return self.MODEL_ROOT
+    
+    @property
+    def AE_MODEL_PATH(self) -> Path:
+        """自编码器模型文件路径"""
+        return self.MODEL_DIR / "ae_model.pth"
+    
+    @property
+    def THRESHOLD_DIR(self) -> Path:
+        """阈值目录路径"""
+        return self.MODEL_DIR / "thresholds"
+    
+    @property
+    def SCALE_FILE(self) -> Path:
+        """全局标准化参数文件路径"""
+        return self.MODEL_DIR / "global_scale.npy"
+    
+    # ========================================
+    # 音频参数(必须与训练一致)
+    # ========================================
+    
+    # 采样率
+    SR = 16000
+    
+    # 窗口长度(秒)
+    WIN_SEC = 8.0
+    
+    # 窗口步长(秒)
+    HOP_SEC = 4.0
+    
+    # ========================================
+    # Mel频谱参数(必须与训练一致)
+    # ========================================
+    
+    # Mel频带数
+    N_MELS = 64
+    
+    # FFT窗口大小
+    N_FFT = 1024
+    
+    # STFT步长
+    HOP_LENGTH = 256
+    
+    # 目标帧数
+    TARGET_FRAMES = 504
+    
+    # ========================================
+    # 预测参数
+    # ========================================
+    
+    # 批次大小
+    BATCH_SIZE = 64
+    
+    # 3σ阈值系数(与训练版保持一致)
+    SIGMA_MULTIPLIER = 3.0
+    
+    # 阈值分位数(用于增量训练时计算阈值)
+    THRESHOLD_QUANTILE = 0.95
+    
+    # 异常patch比例阈值
+    ANOMALY_RATIO_THRESHOLD = 0.1
+
+
+# 全局配置实例
+CFG = DeployConfig()

+ 70 - 0
predictor/datasets.py

@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+"""
+datasets.py - PyTorch Dataset封装
+=================================
+
+提供训练和推理所需的数据集类
+"""
+
+from pathlib import Path
+from typing import List, Union
+import numpy as np
+import torch
+from torch.utils.data import Dataset
+
+from .config import CFG
+
+
+class MelNPYDataset(Dataset):
+    """
+    Mel频谱数据集
+    
+    从npy文件加载mel频谱数据。
+    支持两种初始化方式:
+    1. 传入目录路径(自动扫描)
+    2. 传入文件列表
+    """
+    
+    def __init__(self, mel_files_or_dir: Union[Path, List[Path]] = None, 
+                 target_frames: int = None):
+        """
+        初始化
+        
+        参数:
+            mel_files_or_dir: npy文件列表或包含npy文件的目录
+            target_frames: 目标帧数,用于对齐(默认使用CFG.TARGET_FRAMES)
+        """
+        # 处理文件列表或目录
+        if mel_files_or_dir is None:
+            mel_dir = Path(CFG.AUDIO_DIR)
+            self.files = sorted(mel_dir.glob("**/*.npy"))
+        elif isinstance(mel_files_or_dir, (list, tuple)):
+            self.files = [Path(f) for f in mel_files_or_dir]
+        else:
+            mel_dir = Path(mel_files_or_dir)
+            self.files = sorted(mel_dir.glob("**/*.npy"))
+        
+        # 目标帧数
+        self.target_frames = target_frames or CFG.TARGET_FRAMES
+    
+    def __len__(self):
+        # 返回数据集大小
+        return len(self.files)
+    
+    def __getitem__(self, idx):
+        # 加载npy文件,形状为 [n_mels, frames]
+        arr = np.load(self.files[idx])
+        
+        # 对齐帧数
+        if arr.shape[1] > self.target_frames:
+            # 截断
+            arr = arr[:, :self.target_frames]
+        elif arr.shape[1] < self.target_frames:
+            # 填充
+            pad_width = self.target_frames - arr.shape[1]
+            arr = np.pad(arr, ((0, 0), (0, pad_width)), mode='constant')
+        
+        # 增加通道维度,变为 [1, n_mels, frames]
+        arr = np.expand_dims(arr, 0)
+        # 转换为PyTorch tensor
+        return torch.from_numpy(arr).float()

+ 119 - 0
predictor/model_def.py

@@ -0,0 +1,119 @@
+# -*- coding: utf-8 -*-
+"""
+model_def.py - 卷积自编码器模型定义(部署版)
+=============================================
+
+4层卷积自编码器,用于音频异常检测。
+此文件与训练环境的model_def.py结构相同。
+
+模型结构:
+    输入:  [B, 1, 64, 504]
+    瓶颈:  [B, 64, 4, 32]  (压缩比4:1)
+    输出:  [B, 1, 64, 504]
+"""
+
+import torch
+import torch.nn as nn
+
+from .config import CFG
+from .utils import get_device
+
+
+class ConvAutoencoder(nn.Module):
+    """
+    4层卷积自编码器
+    
+    编码器: 4次stride=2下采样
+    解码器: 4次stride=2上采样
+    
+    参数:
+        in_ch: 输入通道数,默认1
+        base_ch: 基础通道数,默认8
+    """
+    
+    def __init__(self, in_ch=1, base_ch=8):
+        """初始化模型"""
+        super().__init__()
+        
+        # 编码器: 4层下采样
+        self.encoder = nn.Sequential(
+            # 第1层: 1→8通道
+            nn.Conv2d(in_ch, base_ch, 3, stride=2, padding=1),
+            nn.BatchNorm2d(base_ch),
+            nn.ReLU(True),
+            
+            # 第2层: 8→16通道
+            nn.Conv2d(base_ch, base_ch*2, 3, stride=2, padding=1),
+            nn.BatchNorm2d(base_ch*2),
+            nn.ReLU(True),
+            
+            # 第3层: 16→32通道
+            nn.Conv2d(base_ch*2, base_ch*4, 3, stride=2, padding=1),
+            nn.BatchNorm2d(base_ch*4),
+            nn.ReLU(True),
+            
+            # 第4层: 32→64通道(瓶颈)
+            nn.Conv2d(base_ch*4, base_ch*8, 3, stride=2, padding=1),
+            nn.BatchNorm2d(base_ch*8),
+            nn.ReLU(True),
+        )
+        
+        # 解码器: 4层上采样
+        self.decoder = nn.Sequential(
+            # 第1层: 64→32通道
+            nn.ConvTranspose2d(base_ch*8, base_ch*4, 3, stride=2, padding=1, output_padding=1),
+            nn.BatchNorm2d(base_ch*4),
+            nn.ReLU(True),
+            
+            # 第2层: 32→16通道
+            nn.ConvTranspose2d(base_ch*4, base_ch*2, 3, stride=2, padding=1, output_padding=1),
+            nn.BatchNorm2d(base_ch*2),
+            nn.ReLU(True),
+            
+            # 第3层: 16→8通道
+            nn.ConvTranspose2d(base_ch*2, base_ch, 3, stride=2, padding=1, output_padding=1),
+            nn.BatchNorm2d(base_ch),
+            nn.ReLU(True),
+            
+            # 第4层: 8→1通道
+            nn.ConvTranspose2d(base_ch, in_ch, 3, stride=2, padding=1, output_padding=1),
+        )
+    
+    def forward(self, x):
+        """
+        前向传播
+        
+        参数:
+            x: 输入tensor [B, 1, H, W]
+        
+        返回:
+            重构tensor [B, 1, H', W']
+        """
+        # 编码
+        z = self.encoder(x)
+        # 解码
+        out = self.decoder(z)
+        return out
+
+
+def load_trained_model():
+    """
+    加载训练好的模型
+    
+    返回:
+        元组 (model, device)
+    """
+    # 获取设备
+    device = get_device()
+    
+    # 创建模型
+    model = ConvAutoencoder().to(device)
+    
+    # 加载权重
+    state = torch.load(CFG.AE_MODEL_PATH, map_location=device)
+    model.load_state_dict(state)
+    
+    # 设置评估模式
+    model.eval()
+    
+    return model, device

+ 263 - 0
predictor/multi_model_predictor.py

@@ -0,0 +1,263 @@
+# -*- coding: utf-8 -*-
+"""
+multi_model_predictor.py - 多设备多模型预测器
+=============================================
+
+支持每个设备(device_code)加载独立的模型目录。
+支持模型热加载(检测文件变化后自动重载)。
+
+使用示例:
+    predictor = MultiModelPredictor()
+    predictor.register_device("LT-2", "LT-2")
+    predictor.register_device("LT-5", "LT-5")
+"""
+
+import os
+import time
+import logging
+from pathlib import Path
+from typing import Dict, Optional, Tuple
+import numpy as np
+import torch
+
+from .config import CFG, DeployConfig
+from .model_def import ConvAutoencoder
+from .utils import get_device
+
+logger = logging.getLogger('MultiModelPredictor')
+
+
+class DevicePredictor:
+    """
+    单设备预测器
+
+    封装一个设备的模型、Min-Max 标准化参数和阈值。
+    """
+
+    def __init__(self, device_code: str, model_subdir: str):
+        self.device_code = device_code
+        self.model_subdir = model_subdir
+
+        # 模型目录路径
+        self.model_dir = CFG.MODEL_ROOT / model_subdir
+        self.model_path = self.model_dir / "ae_model.pth"
+        self.scale_path = self.model_dir / "global_scale.npy"
+        self.threshold_dir = self.model_dir / "thresholds"
+
+        # 加载资源
+        self.torch_device = get_device()
+        self.model = self._load_model()
+        # Min-Max 参数 (min, max)
+        self.global_min, self.global_max = self._load_scale()
+        # 阈值(标量)
+        self.threshold = self._load_threshold()
+
+        # 记录文件 mtime(用于热加载检测)
+        self._model_mtime = self._get_mtime(self.model_path)
+        self._scale_mtime = self._get_mtime(self.scale_path)
+
+        logger.info(f"设备 {device_code} 模型加载完成 | 目录: {model_subdir} | "
+                    f"阈值: {self.threshold:.6f}")
+
+    def _get_mtime(self, path: Path) -> float:
+        # 获取文件修改时间,不存在返回 0
+        try:
+            return os.path.getmtime(path)
+        except OSError:
+            return 0.0
+
+    def has_files_changed(self) -> bool:
+        # 检查模型或标准化参数文件是否有更新
+        new_model_mtime = self._get_mtime(self.model_path)
+        new_scale_mtime = self._get_mtime(self.scale_path)
+        return (new_model_mtime != self._model_mtime or
+                new_scale_mtime != self._scale_mtime)
+
+    def _load_model(self) -> ConvAutoencoder:
+        if not self.model_path.exists():
+            raise FileNotFoundError(f"模型不存在: {self.model_path}")
+
+        model = ConvAutoencoder().to(self.torch_device)
+        state = torch.load(self.model_path, map_location=self.torch_device)
+        model.load_state_dict(state)
+        model.eval()
+        return model
+
+    def _load_scale(self) -> Tuple[float, float]:
+        # 加载 Min-Max 标准化参数 [min, max]
+        if not self.scale_path.exists():
+            raise FileNotFoundError(f"标准化参数不存在: {self.scale_path}")
+
+        scale = np.load(self.scale_path)
+        return float(scale[0]), float(scale[1])
+
+    def _load_threshold(self) -> float:
+        """
+        加载阈值文件
+
+        Returns:
+            overall_threshold
+        """
+        # 按 device_code 查找
+        threshold_file = self.threshold_dir / f"threshold_{self.device_code}.npy"
+        if not threshold_file.exists():
+            # 尝试 default
+            threshold_file = self.threshold_dir / "threshold_default.npy"
+
+        if threshold_file.exists():
+            data = np.load(threshold_file)
+            # 兼容标量和数组格式
+            return float(data.flat[0])
+
+        logger.warning(f"设备 {self.device_code} 无阈值文件,使用默认值 0.01")
+        return 0.01
+
+
+class MultiModelPredictor:
+    """
+    多设备多模型预测器
+
+    支持:
+    - 每设备独立模型、标准化参数和阈值
+    - 模型热加载(检测文件更新后自动重载)
+    - 冷启动设备定期重试
+    """
+
+    # 热加载检查间隔(秒)
+    HOT_RELOAD_INTERVAL = 60
+    # 失败设备重试间隔(秒)
+    FAILED_RETRY_INTERVAL = 300
+
+    def __init__(self):
+        # device_code -> model_subdir 映射
+        self.device_model_map: Dict[str, str] = {}
+
+        # device_code -> DevicePredictor 实例(懒加载)
+        self.predictors: Dict[str, DevicePredictor] = {}
+
+        # 加载失败的设备记录 {device_code: 失败时间}
+        self._failed_devices: Dict[str, float] = {}
+
+        # 上次热加载检查时间
+        self._last_reload_check: float = 0.0
+
+        logger.info("MultiModelPredictor 初始化完成")
+
+    def register_device(self, device_code: str, model_subdir: str):
+        self.device_model_map[device_code] = model_subdir
+        logger.debug(f"注册设备: {device_code} -> models/{model_subdir}/")
+
+    def _check_hot_reload(self):
+        """
+        检查所有已加载设备的模型文件是否有更新
+
+        检查频率由 HOT_RELOAD_INTERVAL 控制(默认60秒),避免频繁 stat 调用。
+        如果检测到文件变化,重新创建 DevicePredictor 实例替换旧的。
+        同时对失败设备定期重试加载。
+        """
+        now = time.time()
+        if now - self._last_reload_check < self.HOT_RELOAD_INTERVAL:
+            return
+        self._last_reload_check = now
+
+        # 1. 检查已加载设备的模型是否更新
+        for device_code in list(self.predictors.keys()):
+            predictor = self.predictors[device_code]
+            if predictor.has_files_changed():
+                logger.info(f"检测到模型文件更新: {device_code},执行热加载")
+                model_subdir = self.device_model_map.get(device_code, device_code)
+                try:
+                    new_predictor = DevicePredictor(device_code, model_subdir)
+                    self.predictors[device_code] = new_predictor
+                    logger.info(f"热加载成功: {device_code}")
+                except Exception as e:
+                    logger.error(f"热加载失败: {device_code} | {e}")
+                    # 保留旧的 predictor 继续使用
+
+        # 2. 对失败设备定期重试
+        for device_code in list(self._failed_devices.keys()):
+            fail_time = self._failed_devices[device_code]
+            if now - fail_time > self.FAILED_RETRY_INTERVAL:
+                model_subdir = self.device_model_map.get(device_code, device_code)
+                try:
+                    predictor = DevicePredictor(device_code, model_subdir)
+                    self.predictors[device_code] = predictor
+                    del self._failed_devices[device_code]
+                    logger.info(f"失败设备重试成功: {device_code}")
+                except Exception:
+                    # 重试仍然失败,更新时间戳
+                    self._failed_devices[device_code] = now
+
+    def get_predictor(self, device_code: str) -> Optional[DevicePredictor]:
+        # 先执行热加载检查
+        self._check_hot_reload()
+
+        # 已加载
+        if device_code in self.predictors:
+            return self.predictors[device_code]
+
+        # 已失败且未到重试时间
+        if device_code in self._failed_devices:
+            return None
+
+        # 首次懒加载
+        model_subdir = self.device_model_map.get(device_code)
+        if not model_subdir:
+            model_subdir = device_code
+
+        try:
+            predictor = DevicePredictor(device_code, model_subdir)
+            self.predictors[device_code] = predictor
+            return predictor
+        except Exception as e:
+            logger.error(f"加载设备 {device_code} 模型失败: {e}")
+            self._failed_devices[device_code] = time.time()
+            return None
+
+    def get_threshold(self, device_code: str) -> Optional[float]:
+        predictor = self.get_predictor(device_code)
+        if predictor:
+            return predictor.threshold
+        return None
+
+    def get_scale(self, device_code: str) -> Tuple[Optional[float], Optional[float]]:
+        # 获取 Z-score 参数 (mean, std)
+        predictor = self.get_predictor(device_code)
+        if predictor:
+            return predictor.global_mean, predictor.global_std
+        return None, None
+
+    def get_model(self, device_code: str) -> Optional[ConvAutoencoder]:
+        predictor = self.get_predictor(device_code)
+        if predictor:
+            return predictor.model
+        return None
+
+    def reload_device(self, device_code: str) -> bool:
+        """
+        手动触发指定设备的模型重载
+
+        用于外部调用(如 API 接口更新模型后通知重载)
+
+        Returns:
+            bool: 是否重载成功
+        """
+        model_subdir = self.device_model_map.get(device_code, device_code)
+        try:
+            new_predictor = DevicePredictor(device_code, model_subdir)
+            self.predictors[device_code] = new_predictor
+            # 清除失败标记
+            self._failed_devices.pop(device_code, None)
+            logger.info(f"手动重载成功: {device_code}")
+            return True
+        except Exception as e:
+            logger.error(f"手动重载失败: {device_code} | {e}")
+            return False
+
+    @property
+    def registered_devices(self) -> list:
+        return list(self.device_model_map.keys())
+
+    @property
+    def loaded_devices(self) -> list:
+        return list(self.predictors.keys())

+ 137 - 0
predictor/utils.py

@@ -0,0 +1,137 @@
+# -*- coding: utf-8 -*-
+"""
+utils.py - 部署环境工具函数
+===========================
+
+部署环境使用的工具函数。
+与训练环境的utils.py功能相同,但去除了训练相关的函数。
+"""
+
+from pathlib import Path
+import re
+import torch
+import torch.nn.functional as F
+import numpy as np
+
+from .config import CFG
+
+
+def ensure_dirs():
+    """
+    确保部署所需目录存在
+    
+    创建以下目录(如不存在):
+    - AUDIO_DIR: 音频文件
+    - MODEL_DIR: 模型文件
+    - THRESHOLD_DIR: 阈值文件
+    """
+    for d in ['AUDIO_DIR', 'MODEL_DIR', 'THRESHOLD_DIR']:
+        if hasattr(CFG, d):
+            getattr(CFG, d).mkdir(parents=True, exist_ok=True)
+
+
+def get_device():
+    """
+    获取可用的计算设备
+    
+    返回:
+        str: "cuda" 或 "cpu"
+    """
+    return "cuda" if torch.cuda.is_available() else "cpu"
+
+
+def align_to_target(pred, target):
+    """
+    将预测tensor对齐到目标tensor的尺寸
+    
+    处理卷积自编码器可能产生的尺寸偏差。
+    
+    参数:
+        pred: 预测tensor [B, C, H, W]
+        target: 目标tensor [B, C, H_target, W_target]
+    
+    返回:
+        对齐后的tensor
+    """
+    # 获取目标尺寸
+    _, _, H_t, W_t = target.shape
+    _, _, H_p, W_p = pred.shape
+    
+    x = pred
+    
+    # H维度对齐
+    if H_p > H_t:
+        start = (H_p - H_t) // 2
+        x = x[:, :, start:start + H_t, :]
+    elif H_p < H_t:
+        diff = H_t - H_p
+        x = F.pad(x, (0, 0, diff // 2, diff - diff // 2))
+    
+    # W维度对齐
+    _, _, _, W_p2 = x.shape
+    if W_p2 > W_t:
+        start = (W_p2 - W_t) // 2
+        x = x[:, :, :, start:start + W_t]
+    elif W_p2 < W_t:
+        diff = W_t - W_p2
+        x = F.pad(x, (diff // 2, diff - diff // 2, 0, 0))
+    
+    return x
+
+
+def parse_metadata_from_filename(path):
+    """
+    从音频文件名解析元数据
+    
+    支持三种格式:
+    1. 4段式: {水厂}_ch{通道}_{起始时间}_{结束时间}.wav
+    2. 3段式: {水厂}_ch{通道}_{时间}.wav
+    3. 新格式: {project_id}_{device_code}_{时间}.wav (如 1450_LT-2_20260115103754.wav)
+    
+    参数:
+        path: 文件路径
+    
+    返回:
+        元组 (plant_id, pump_id, start_time, end_time)
+    """
+    stem = Path(path).stem
+    
+    # 4段式
+    m = re.match(r"(.+?)_ch(\d+)_(\d{14})_(\d{14})", stem)
+    if m:
+        return m.group(1).strip(), f"ch{m.group(2)}", m.group(3), m.group(4)
+    
+    # 3段式
+    m = re.match(r"(.+?)_ch(\d+)_(\d{14})", stem)
+    if m:
+        return m.group(1).strip(), f"ch{m.group(2)}", m.group(3), ""
+    
+    # 新格式: {project_id}_{device_code}_{时间}.wav (如 1450_LT-2_20260115103754.wav)
+    m = re.match(r"(\d+)_([A-Za-z0-9-]+)_(\d{14})", stem)
+    if m:
+        project_id = m.group(1)
+        device_code = m.group(2)
+        timestamp = m.group(3)
+        # 返回 (project_id, device_code, timestamp, "")
+        # 其中 device_code 作为 pump_id 用于阈值查找
+        return project_id, device_code, timestamp, ""
+    
+    raise ValueError(f"文件名格式不符: {stem}")
+
+
+def load_global_scale():
+    """
+    加载全局标准化参数(已过时)
+
+    注意:此函数加载全局共享的 scale 文件,仅用于向后兼容。
+    当前系统使用 DevicePredictor._load_scale() 按设备加载。
+
+    返回:
+        元组 (val_0, val_1)
+        如果文件不存在返回 (None, None)
+    """
+    if not CFG.SCALE_FILE.exists():
+        return None, None
+
+    scale = np.load(CFG.SCALE_FILE)
+    return float(scale[0]), float(scale[1])

+ 29 - 0
requirements.txt

@@ -0,0 +1,29 @@
+# 拾音器异响检测系统 - 依赖清单
+#
+# 安装: pip install -r requirements.txt
+# 国内镜像: pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+#
+# 注意: PyTorch 需根据 CUDA 版本安装,详见 https://pytorch.org
+#       FFmpeg 需单独安装(系统包管理器)
+
+# 深度学习
+torch>=2.0.0              # 模型推理
+
+# 数值计算
+numpy>=1.23.0,<2.0        # PyTorch 2.x 不兼容 NumPy 2.x
+
+# 音频处理
+librosa>=0.10.0           # Mel 频谱提取
+soundfile>=0.12.0         # 音频文件 I/O
+
+# 网络请求
+requests>=2.28.0          # SCADA API / 推送接口调用
+
+# 配置管理 API
+fastapi>=0.100.0          # 配置管理 RESTful 接口
+uvicorn>=0.22.0           # ASGI 服务器
+pydantic>=2.0             # 数据验证
+
+# 训练 / 迁移工具依赖
+pyyaml>=6.0               # YAML 解析(迁移脚本 + auto_training)
+apscheduler>=3.10.0       # 定时任务调度(auto_training)

+ 2401 - 0
run_pickup_monitor.py

@@ -0,0 +1,2401 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+run_pickup_monitor.py
+---------------------
+拾音器异响检测主程序
+
+功能说明:
+该程序用于音频采集设备(拾音器)的实时异常检测。
+与摄像头版本不同,本版本:
+1. 没有视频/图片采集和上报
+2. 上报时包含音频频谱图分析数据
+3. 支持进水流量PLC数据读取
+4. 每1分钟计算一次平均重建误差并上报
+
+上报数据结构:
+{
+    "message": {
+        "channelInfo": {"name": "设备信息"},
+        "requestTime": 时间戳,
+        "classification": {
+            "level_one": 2,  // 音频检测大类
+            "level_two": 6   // 异常类型小类: 6=未分类, 7=轴承, 8=气蚀, 9=松动/共振, 10=叶轮, 11=阀件
+        },
+        "skillInfo": {"name": "异响检测"},
+        "sound_detection": {
+            "abnormalwav": "",           // base64编码的音频
+            "status": 0/1,               // 0=异常, 1=正常
+            "condition": {
+                "running_status": "运行中/停机中",  // 启停状态
+                "inlet_flow": 0.0            // 进水流量
+            },
+            "score": {
+                "abnormal_score": 0.0,   // 当前1分钟内8s平均重构误差
+                "score_threshold": 0.0   // 该设备的异常阀值
+            },
+            "frequency": {
+                "this_frequency": [],              // 当前1分钟的频谱图
+                "normal_frequency_middle": [],    // 过去10分钟的频谱图平均
+                "normal_frequency_upper": [],     // 上限(暂为空)
+                "normal_frequency_lower": []      // 下限(暂为空)
+            }
+        }
+    }
+}
+
+使用方法:
+    python run_pickup_monitor.py
+
+配置文件:
+    config/rtsp_config.yaml
+"""
+
+import subprocess
+import time
+import re
+import signal
+import sys
+import threading
+import logging
+import base64
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+from datetime import datetime, timedelta
+from collections import defaultdict
+
+# 用于计算FFT
+import numpy as np
+
+
+try:
+    import librosa
+except ImportError:
+    print("错误:缺少librosa库,请安装: pip install librosa")
+    sys.exit(1)
+
+try:
+    import requests
+except ImportError:
+    print("错误:缺少requests库,请安装: pip install requests")
+    sys.exit(1)
+
+# 导入预测器模块
+from predictor import MultiModelPredictor, CFG
+
+# 导入配置管理模块(SQLite)
+from config.config_manager import ConfigManager
+from config.config_api import app as config_app, init_config_api
+from config.db_models import get_db_path
+
+# 导入泵状态监控模块(用于检测启停过渡期)
+try:
+    from core.pump_state_monitor import PumpStateMonitor
+    PUMP_STATE_MONITOR_AVAILABLE = True
+except ImportError:
+    PUMP_STATE_MONITOR_AVAILABLE = False
+
+# 导入人体检测读取模块(用于抑制有人时的误报)
+try:
+    from core.human_detection_reader import HumanDetectionReader
+    HUMAN_DETECTION_AVAILABLE = True
+except ImportError:
+    HUMAN_DETECTION_AVAILABLE = False
+
+# 导入报警聚合器(跨设备聚合抑制 + 分类型冷却)
+try:
+    from core.alert_aggregator import AlertAggregator
+    ALERT_AGGREGATOR_AVAILABLE = True
+except ImportError:
+    ALERT_AGGREGATOR_AVAILABLE = False
+
+
+# ========================================
+# 配置日志系统
+# ========================================
+def setup_logging():
+    # 配置日志系统(RotatingFileHandler -> system.log)
+    from logging.handlers import RotatingFileHandler
+
+    # 如果根 logger 已经被上层调用者配置过,则直接复用
+    root = logging.getLogger()
+    if root.handlers:
+        return logging.getLogger('PickupMonitor')
+
+    # 日志配置
+    log_dir = Path(__file__).parent / "logs"
+    log_dir.mkdir(parents=True, exist_ok=True)
+    log_file = log_dir / "system.log"
+
+    formatter = logging.Formatter(
+        '%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s',
+        datefmt='%Y-%m-%d %H:%M:%S'
+    )
+
+    # 按文件大小轮转,最多保留 2 个备份(共 30MB)
+    file_handler = RotatingFileHandler(
+        log_file,
+        maxBytes=10 * 1024 * 1024,
+        backupCount=2,
+        encoding='utf-8'
+    )
+    file_handler.setFormatter(formatter)
+
+    # 控制台输出(前台运行时可见,后台运行时 stdout 已被丢弃不影响)
+    console_handler = logging.StreamHandler(sys.stdout)
+    console_handler.setFormatter(formatter)
+
+    logging.basicConfig(
+        level=logging.INFO,
+        handlers=[file_handler, console_handler]
+    )
+
+    return logging.getLogger('PickupMonitor')
+
+
+# 初始化日志系统
+logger = setup_logging()
+
+# 导入能量基线模块
+try:
+    from core.energy_baseline import EnergyBaseline
+    ENERGY_BASELINE_AVAILABLE = True
+except ImportError:
+    ENERGY_BASELINE_AVAILABLE = False
+    logger.warning("能量基线模块未找到,泵状态检测功能禁用")
+
+
+class RTSPStreamConfig:
+    """
+    RTSP流配置类
+    
+    封装单个RTSP流的配置信息,包含拾音器特有字段
+    """
+    
+    def __init__(self, plant_name, rtsp_url, channel, 
+                 camera_name, device_code, pump_name,
+                 flow_plc, project_id):
+        """
+        初始化RTSP流配置
+        
+        参数:
+            plant_name: 区域名称(泵房-反渗透高压泵等)
+            rtsp_url: RTSP流URL
+            channel: 通道号
+            camera_name: 设备名称
+            device_code: 设备编号(如1#-1)
+            pump_name: 泵名称(A/B/C/D),用于匹配流量PLC
+            flow_plc: 流量PLC地址映射
+        """
+        self.plant_name = plant_name
+        self.rtsp_url = rtsp_url
+        self.channel = channel
+        self.pump_id = f"ch{channel}"
+        self.camera_name = camera_name or f"ch{channel}"
+        self.device_code = device_code
+        self.pump_name = pump_name
+        self.flow_plc = flow_plc or {}
+        self.project_id = project_id
+    
+    def get_flow_plc_address(self):
+        """
+        获取该设备对应的进水流量PLC地址
+        
+        返回:
+            PLC地址字符串,不存在则返回空字符串
+        """
+        if self.pump_name and self.flow_plc:
+            return self.flow_plc.get(self.pump_name, "")
+        return ""
+    
+    def __repr__(self):
+        return f"RTSPStreamConfig(plant='{self.plant_name}', camera='{self.camera_name}', pump='{self.pump_id}')"
+
+
+class FFmpegProcess:
+    """
+    FFmpeg进程管理类
+    
+    负责启动和管理单个RTSP流的FFmpeg进程。
+    进程将RTSP流转换为固定时长的WAV音频文件。
+    
+    文件名格式: {project_id}_{device_code}_{时间戳}.wav
+    """
+    
+    def __init__(self, stream_config, output_dir, config=None):
+        """
+        初始化FFmpeg进程管理器
+        
+        参数:
+            stream_config: RTSP流配置
+            output_dir: 音频输出目录
+            config: 全局配置字典
+        """
+        self.config_dict = config or {}
+        self.stream_config = stream_config
+        self.output_dir = output_dir
+        self.process = None
+        
+        # 从配置中读取文件时长,默认8秒
+        audio_cfg = self.config_dict.get('audio', {})
+        self.file_duration = audio_cfg.get('file_duration', 8)
+        
+        # 获取project_id(从 stream_config 中读取)
+        self.project_id = stream_config.project_id
+    
+    def start(self):
+        """
+        启动FFmpeg进程
+        
+        返回:
+            bool: 启动成功返回True,失败返回False
+        """
+        # 获取设备编号
+        device_code = self.stream_config.device_code or self.stream_config.pump_id
+        
+        # 创建输出目录(每个设备独立目录)
+        # 结构: data/{device_code}/current/
+        current_dir = self.output_dir / device_code / "current"
+        current_dir.mkdir(parents=True, exist_ok=True)
+        
+        # 构建输出文件名模板
+        # 格式: {project_id}_{device_code}_{时间戳}.wav
+        # 例如: 92_1#-1_20251218142000.wav
+        output_pattern = str(current_dir / f"{self.project_id}_{device_code}_%Y%m%d%H%M%S.wav")
+        
+        # 构建FFmpeg命令
+        # 添加内存限制参数,防止 RTSP 缓冲区无限增长导致 OOM
+        cmd = [
+            "ffmpeg",
+            # RTSP 输入参数(内存限制)
+            "-rtsp_transport", "tcp",           # 使用TCP传输
+            "-probesize", "1000000",            # 限制探测大小为1MB(默认5MB)
+            "-analyzeduration", "1000000",      # 限制分析时长为1秒(默认5秒)
+            "-max_delay", "500000",             # 最大延迟500ms
+            "-fflags", "nobuffer",              # 禁用输入缓冲
+            "-flags", "low_delay",              # 低延迟模式
+            "-i", self.stream_config.rtsp_url,  # 输入RTSP流
+            # 音频输出参数
+            "-vn",                              # 不处理视频
+            "-ac", "1",                         # 单声道
+            "-ar", str(CFG.SR),                 # 采样率(16000Hz)
+            "-f", "segment",                    # 分段模式
+            "-segment_time", str(int(self.file_duration)),  # 每段时长
+            "-strftime", "1",                   # 启用时间格式化
+            "-loglevel", "error",               # 只输出错误日志
+            "-y",                               # 覆盖已存在的文件
+            output_pattern,
+        ]
+        
+        try:
+            # 启动FFmpeg进程
+            self.process = subprocess.Popen(
+                cmd,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE
+            )
+            
+            logger.info(f"FFmpeg已启动: {device_code} | {self.stream_config.camera_name} | "
+                  f"文件时长: {self.file_duration}秒 | PID={self.process.pid}")
+            return True
+            
+        except FileNotFoundError:
+            logger.error("FFmpeg错误: 未找到ffmpeg命令,请确保已安装FFmpeg")
+            return False
+        except Exception as e:
+            logger.error(f"FFmpeg启动失败: {device_code} | 错误: {e}")
+            return False
+    
+    def is_running(self):
+        """
+        检查FFmpeg进程是否在运行
+        
+        返回:
+            bool: 进程运行中返回True,否则返回False
+        """
+        if self.process is None:
+            return False
+        return self.process.poll() is None
+    
+    def stop(self):
+        """
+        停止FFmpeg进程
+        """
+        if self.process is not None and self.is_running():
+            logger.info(f"FFmpeg停止: {self.stream_config.plant_name} | {self.stream_config.camera_name} | PID={self.process.pid}")
+            self.process.terminate()
+            try:
+                self.process.wait(timeout=5)
+            except subprocess.TimeoutExpired:
+                logger.warning(f"FFmpeg强制终止: PID={self.process.pid}")
+                self.process.kill()
+
+
+class PickupMonitor:
+    """
+    拾音器监控线程类
+    
+    监控音频目录,调用预测器检测异常,推送告警。
+    
+    主要功能:
+    1. 不截取视频帧(纯音频)
+    2. 计算并上报频谱图数据(this_frequency + normal_frequency_middle)
+    3. 每分钟汇总上报一次
+    4. 使用SCADA API获取进水流量
+    """
+    
+    def __init__(self, audio_dir, multi_predictor, 
+                 stream_configs,
+                 check_interval=1.0, config=None,
+                 config_manager=None):
+        """
+        初始化监控器
+        
+        Args:
+            audio_dir: 音频根目录
+            multi_predictor: 多模型预测器实例
+            stream_configs: 所有RTSP流配置
+            check_interval: 检查间隔(秒)
+            config: 配置字典
+            config_manager: ConfigManager 实例(用于热更新,为 None 时禁用热更新)
+        """
+        # 音频根目录(各设备目录在其下)
+        self.audio_dir = audio_dir
+        self.multi_predictor = multi_predictor
+        self.predictor = None  # 兼容性保留,已废弃
+        self.stream_configs = stream_configs
+        self.check_interval = check_interval
+        self.config = config or {}
+        
+        # 热更新:持有 ConfigManager 引用,定期从 DB 刷新配置
+        self._config_manager = config_manager
+        self._last_config_reload = 0       # 上次配置刷新时间戳
+        self._config_reload_interval = 30  # 配置刷新间隔(秒)
+        
+        # project_id
+        self.project_id = self.config.get('platform', {}).get('project_id', 92)
+        
+        # 构建device_code到stream_config的映射
+        self.device_map = {}
+        for cfg in stream_configs:
+            if cfg.device_code:
+                self.device_map[cfg.device_code] = cfg
+        
+        # 已处理文件集合
+        self.seen_files = set()
+        
+        # 每个设备的检测结果缓存(用于1分钟汇总)
+        # key: device_code(如 "1#-1")
+        self.device_cache = defaultdict(lambda: {
+            "errors": [],          # 每8秒的重建误差列表
+            "last_upload": None,   # 上次上报时间
+            "audio_data": [],      # 用于计算频谱图的音频数据
+            "status": None         # 最近的运行状态
+        })
+        
+        # 频谱图历史缓存(用于计算normal_frequency_middle)
+        # key: device_code, value: list of (timestamp, freq_db)
+        freq_cfg = self.config.get('prediction', {}).get('frequency_history', {})
+        self.freq_history_enabled = freq_cfg.get('enabled', True)
+        self.freq_history_minutes = freq_cfg.get('history_minutes', 10)
+        self.freq_history = defaultdict(list)
+        
+        # 上一次上报状态(True=异常,False=正常,None=初始)
+        # 用于状态变更时去重,防止持续报警
+        self.last_report_status = {}
+        
+        # 上报周期(秒)
+        audio_cfg = self.config.get('audio', {})
+        self.segment_duration = audio_cfg.get('segment_duration', 60)
+        
+        # 异常音频保存配置
+        save_cfg = self.config.get('prediction', {}).get('save_anomaly_audio', {})
+        self.save_anomaly_enabled = save_cfg.get('enabled', True)
+        self.save_anomaly_dir = Path(__file__).parent / save_cfg.get('save_dir', 'data/anomaly_detected')
+        if self.save_anomaly_enabled:
+            self.save_anomaly_dir.mkdir(parents=True, exist_ok=True)
+        
+        # 异常推送配置
+        push_cfg = self.config.get('push_notification', {})
+        self.push_enabled = push_cfg.get('enabled', False)
+        self.alert_enabled = push_cfg.get('alert_enabled', True)  # 是否启用异常告警(false=暂时禁用异常上报)
+        self.push_timeout = push_cfg.get('timeout', 30)
+        self.push_retry_count = push_cfg.get('retry_count', 2)
+        # 推送基地址列表(统一用 base_url/{project_id} 拼接,可无限扩展)
+        raw_urls = push_cfg.get('push_base_urls', [])
+        self.push_base_urls = [
+            {"label": item.get("label", f"target-{i}"), "url": item.get("url", "").rstrip("/")}
+            for i, item in enumerate(raw_urls)
+            if item.get("url")  # 跳过空URL
+        ]
+        # 推送失败记录文件路径
+        failed_log_path = push_cfg.get('failed_push_log', 'data/push_failures.jsonl')
+        self.failed_push_log = Path(__file__).parent / failed_log_path
+        self.failed_push_log.parent.mkdir(parents=True, exist_ok=True)
+        
+        # 如果 alert_enabled 为 False,记录日志提醒
+        if not self.alert_enabled:
+            logger.warning("异常告警已禁用(alert_enabled=false),仅上报心跳状态")
+        
+        # ========================================
+        # 报警聚合器(替代原有固定 cooldown_minutes)
+        # ----------------------------------------
+        # 功能1:跨设备聚合抑制 - 同一水厂多设备同时报警 -> 环境噪声,全部抑制
+        # 功能2:分类型冷却 - 同类型24h,不同类型1h
+        # ========================================
+        agg_cfg = push_cfg.get('alert_aggregate', {})
+        self.alert_aggregator = None
+        if ALERT_AGGREGATOR_AVAILABLE:
+            self.alert_aggregator = AlertAggregator(
+                push_callback=self._push_detection_result,
+                aggregate_enabled=agg_cfg.get('enabled', True),
+                window_seconds=agg_cfg.get('window_seconds', 300),
+                min_devices=agg_cfg.get('min_devices', 2),
+                cooldown_same_type_hours=push_cfg.get('cooldown_same_type_hours', 24),
+                cooldown_diff_type_hours=push_cfg.get('cooldown_diff_type_hours', 1)
+            )
+        else:
+            logger.warning("报警聚合器模块未找到,使用默认报警逻辑")
+        
+        # 上次异常音频保存时间(用于保存冷却时间计算)
+        self.last_anomaly_save_time = {}
+        # 异常保存冷却时间(分钟),同一设备连续异常时,每N分钟只保存一次
+        self.anomaly_save_cooldown_minutes = save_cfg.get('cooldown_minutes', 10)
+        
+        # 当前异常分类结果锁定(持续异常期间保持分类结果不变)
+        # key: device_code, value: (anomaly_type_code, type_name)
+        self.locked_anomaly_type = {}
+        
+        # 滑动窗口投票配置(5次中有3次异常才判定为异常)
+        voting_cfg = self.config.get('prediction', {}).get('voting', {})
+        self.voting_enabled = voting_cfg.get('enabled', True)
+        self.voting_window_size = voting_cfg.get('window_size', 5)   # 窗口大小
+        self.voting_threshold = voting_cfg.get('threshold', 3)       # 异常阈值(>=3次则判定异常)
+        self.detection_history = {}          # 每个设备的检测历史(True=异常)
+        
+        # 阈值容差区间配置(避免边界值反复跳变)
+        # 误差在 threshold*(1-tolerance) ~ threshold*(1+tolerance) 范围内为灰区,维持上一状态
+        self.tolerance_ratio = voting_cfg.get('tolerance_ratio', 0.05)  # 默认5%容差
+        self.last_single_anomaly = {}  # 每个设备上一次的单周期判定结果
+        
+        # 阈值现在由 multi_predictor 管理,每个设备从其对应模型目录加载
+        
+        # 能量检测配置
+        energy_cfg = self.config.get('prediction', {}).get('energy_detection', {})
+        self.energy_detection_enabled = ENERGY_BASELINE_AVAILABLE and energy_cfg.get('enabled', True)
+        self.skip_detection_when_stopped = energy_cfg.get('skip_when_stopped', True)
+        
+        # 能量基线检测器(每个设备一个)
+        if self.energy_detection_enabled:
+            self.energy_baselines = {}
+        else:
+            self.energy_baselines = None
+        
+        # SCADA API配置(用于获取泵状态和进水流量)
+        scada_cfg = self.config.get('scada_api', {})
+        self.scada_enabled = scada_cfg.get('enabled', False)
+        self.scada_url = scada_cfg.get('base_url', '')  # 历史数据接口(备用)
+        self.scada_realtime_url = scada_cfg.get('realtime_url', '')  # 实时数据接口(主用)
+        self.scada_jwt = scada_cfg.get('jwt_token', '')
+        self.scada_timeout = scada_cfg.get('timeout', 10)
+        
+        # 泵状态监控器(用于检测启停过渡期)
+        self.pump_state_monitor = None
+        self.pump_status_plc_configs = {}  # {pump_name: [{point, name}, ...]}
+        if PUMP_STATE_MONITOR_AVAILABLE and self.scada_enabled:
+            # 初始化泵状态监控器
+            # 获取第一个启用水厂的project_id
+            project_id = 0
+            for plant in self.config.get('plants', []):
+                if plant.get('enabled', False):
+                    project_id = plant.get('project_id', 0)
+                    # 加载泵状态点位配置
+                    pump_status_plc = plant.get('pump_status_plc', {})
+                    self.pump_status_plc_configs = pump_status_plc
+                    break
+            
+            if project_id > 0:
+                # 使用实时接口 URL 进行泵状态查询
+                self.pump_state_monitor = PumpStateMonitor(
+                    scada_url=self.scada_realtime_url,  # 使用实时数据接口
+                    scada_jwt=self.scada_jwt,
+                    project_id=project_id,
+                    timeout=self.scada_timeout,
+                    transition_window_minutes=15  # 启停后15分钟内视为过渡期
+                )
+                logger.info(f"泵状态监控器已启用 (project_id={project_id}, 过渡期窗口=15分钟, 使用实时接口)")
+        
+        # 线程控制
+        self.running = False
+        self.thread = None
+        # 推送线程池(避免推送超时阻塞主监控循环)
+        self._push_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="push")
+        
+        # 启动时间(用于跳过启动期间的状态变化日志)
+        self.startup_time = None
+        self.startup_warmup_seconds = 120  # 启动后120秒内不记录状态变化
+        
+        # ========================================
+        # 异常上下文捕获配置
+        # ========================================
+        context_cfg = save_cfg.get('context_capture', {})
+        self.context_capture_enabled = context_cfg.get('enabled', False)
+        self.context_pre_minutes = context_cfg.get('pre_minutes', 2)
+        self.context_post_minutes = context_cfg.get('post_minutes', 2)
+        
+        # 音频文件历史缓存(用于捕获异常前的音频)
+        # key: device_code, value: deque of (timestamp, file_path)
+        from collections import deque
+        # 计算需要保留的历史文件数量(按分钟计算,每分钟约1个文件)
+        history_size = self.context_pre_minutes + 2  # 多保留2个作为缓冲
+        self.audio_file_history = defaultdict(lambda: deque(maxlen=history_size))
+        
+        # 异常上下文捕获状态
+        # key: device_code, value: dict
+        self.anomaly_capture_state = {}
+        
+        if self.context_capture_enabled:
+            logger.info(f"异常上下文捕获已启用 (前{self.context_pre_minutes}分钟 + 后{self.context_post_minutes}分钟)")
+        
+        # ========================================
+        # 人体检测报警抑制配置
+        # ========================================
+        # 只要任意摄像头在冷却时间内检测到人,所有设备的异常报警都会被抑制
+        human_cfg = self.config.get('human_detection', {})
+        self.human_detection_enabled = human_cfg.get('enabled', False) and HUMAN_DETECTION_AVAILABLE
+        self.human_reader = None
+        
+        if self.human_detection_enabled:
+            db_path = human_cfg.get('db_path', '')
+            cooldown_minutes = human_cfg.get('cooldown_minutes', 5)
+            self.human_reader = HumanDetectionReader(
+                db_path=db_path,
+                cooldown_minutes=cooldown_minutes
+            )
+            logger.info(f"人体检测报警抑制已启用 (冷却时间={cooldown_minutes}分钟, 数据库={db_path})")
+    
+    def start(self):
+        """
+        启动监控线程
+        """
+        if self.running:
+            return
+        
+        # 启动前清理current目录中的遗留文件
+        self._cleanup_current_on_startup()
+        
+        self.running = True
+        self.startup_time = datetime.now()  # 记录启动时间
+        self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
+        self.thread.start()
+        # 打印监控的设备列表
+        device_codes = list(self.device_map.keys())
+        logger.info(f"监控线程已启动 | 目录: {self.audio_dir} | 监控设备: {device_codes}")
+    
+    def _cleanup_current_on_startup(self):
+        """
+        启动时清理current目录中的遗留文件
+        
+        删除上次运行遗留的文件,避免混入新采集数据影响检测准确性
+        """
+        cleaned_count = 0
+        
+        for device_code in self.device_map.keys():
+            current_dir = self.audio_dir / device_code / "current"
+            if not current_dir.exists():
+                continue
+            
+            for wav_file in current_dir.glob("*.wav"):
+                try:
+                    wav_file.unlink()
+                    cleaned_count += 1
+                except Exception as e:
+                    logger.warning(f"清理遗留文件失败: {wav_file.name} | {e}")
+        
+        if cleaned_count > 0:
+            logger.info(f"启动清理: 已删除current目录中 {cleaned_count} 个遗留文件")
+    
+    def stop(self):
+        """
+        停止监控线程
+        """
+        if not self.running:
+            return
+        
+        self.running = False
+        if self.thread is not None:
+            self.thread.join(timeout=5)
+        # 关闭推送线程池,等待已提交的推送任务完成
+        self._push_executor.shutdown(wait=True, cancel_futures=False)
+        logger.info(f"监控线程已停止")
+    
+    def _reload_hot_config(self):
+        # 从 DB 热加载可变配置项,无需重启服务
+        # 只刷新运行时可安全变更的参数,不影响 FFmpeg 进程和流映射
+        if self._config_manager is None:
+            return
+        
+        now = time.time()
+        # 未达到刷新间隔则跳过
+        if now - self._last_config_reload < self._config_reload_interval:
+            return
+        self._last_config_reload = now
+        
+        try:
+            # 从 DB 读取最新配置
+            fresh = self._config_manager.get_full_config()
+            self.config = fresh
+            
+            # 刷新推送配置
+            push_cfg = fresh.get('push_notification', {})
+            self.push_enabled = push_cfg.get('enabled', False)
+            self.alert_enabled = push_cfg.get('alert_enabled', True)
+            self.push_timeout = push_cfg.get('timeout', 30)
+            self.push_retry_count = push_cfg.get('retry_count', 2)
+            raw_urls = push_cfg.get('push_base_urls', [])
+            self.push_base_urls = [
+                {"label": item.get("label", f"target-{i}"), "url": item.get("url", "").rstrip("/")}
+                for i, item in enumerate(raw_urls)
+                if item.get("url")
+            ]
+            
+            # 刷新投票配置
+            voting_cfg = fresh.get('prediction', {}).get('voting', {})
+            self.voting_enabled = voting_cfg.get('enabled', True)
+            self.voting_window_size = voting_cfg.get('window_size', 5)
+            self.voting_threshold = voting_cfg.get('threshold', 3)
+            self.tolerance_ratio = voting_cfg.get('tolerance_ratio', 0.05)
+            
+            # 刷新能量检测配置
+            energy_cfg = fresh.get('prediction', {}).get('energy_detection', {})
+            self.skip_detection_when_stopped = energy_cfg.get('skip_when_stopped', True)
+            
+            # 刷新人体检测配置
+            human_cfg = fresh.get('human_detection', {})
+            new_human_enabled = human_cfg.get('enabled', False)
+            if new_human_enabled != self.human_detection_enabled:
+                logger.info(f"人体检测抑制配置变更: {self.human_detection_enabled} -> {new_human_enabled}")
+                self.human_detection_enabled = new_human_enabled
+            
+            logger.debug("配置热刷新完成")
+        except Exception as e:
+            logger.error(f"配置热刷新失败: {e}")
+    
+    def _monitor_loop(self):
+        """
+        监控循环(线程主函数)
+        
+        持续检查各设备的音频目录,处理新生成的WAV文件。
+        每分钟汇总一次结果进行上报。
+        """
+        # 确保根目录存在
+        self.audio_dir.mkdir(parents=True, exist_ok=True)
+        
+        while self.running:
+            try:
+                # 热更新:定期从 DB 刷新可变配置
+                self._reload_hot_config()
+                # 扫描所有设备目录下的current文件夹
+                for device_code in self.device_map.keys():
+                    device_current_dir = self.audio_dir / device_code / "current"
+                    
+                    # 目录不存在则跳过
+                    if not device_current_dir.exists():
+                        continue
+                    
+                    for wav_file in device_current_dir.glob("*.wav"):
+                        # 跳过已处理的文件
+                        if wav_file in self.seen_files:
+                            continue
+                        
+                        # 检查文件是否完整
+                        try:
+                            stat_info = wav_file.stat()
+                            file_age = time.time() - stat_info.st_mtime
+                            file_size = stat_info.st_size
+                            
+                            # 文件修改时间检查(确保文件写入完成)
+                            # FFmpeg生成文件需要时间,太新的文件可能还没写完
+                            if file_age < 12.0:
+                                continue
+                            
+                            # 文件大小检查(60秒 × 16000Hz × 2字节 ≈ 1.9MB)
+                            # 范围:500KB - 3MB
+                            if file_size < 500_000 or file_size > 3_000_000:
+                                # 只有当文件生成已经有一段时间(>20秒)且依然很小,才判定为异常
+                                # 这样可以避免刚开始生成、正在写入的文件被误报
+                                if file_age > 20.0:
+                                    logger.warning(f"文件大小异常: {wav_file.name} ({file_size / 1000:.1f}KB)")
+                                    
+                                    # 自动删除过小的文件(通常是0KB或损坏文件)
+                                    if file_size < 500_000:
+                                        try:
+                                            wav_file.unlink()
+                                            logger.debug(f"删除异常小文件: {wav_file.name}")
+                                        except Exception as e:
+                                            logger.error(f"删除文件失败: {wav_file.name} | {e}")
+                                    
+                                    self.seen_files.add(wav_file)
+                                continue  # 继续下一个文件循环
+                                
+                            # 正常大小的文件继续处理
+                            if file_size < 500_000: # 双重检查,防止漏网
+                                continue
+                        
+                        except Exception as e:
+                            logger.error(f"文件状态检查失败: {wav_file.name} | {e}")
+                            continue
+                        
+
+                        
+                        # 处理新文件
+                        self._process_new_file(wav_file)
+                        
+                        # 标记为已处理
+                        self.seen_files.add(wav_file)
+                
+                # 检查是否需要进行周期性上报
+                self._check_periodic_upload()
+                
+                # 检查聚合窗口是否到期,到期则执行聚合判定并推送
+                if self.alert_aggregator:
+                    self.alert_aggregator.check_and_flush()
+                
+                # 清理过大的已处理文件集合
+                if len(self.seen_files) > 10000:
+                    recent_files = sorted(self.seen_files, 
+                                        key=lambda f: f.stat().st_mtime if f.exists() else 0)[-5000:]
+                    self.seen_files = set(recent_files)
+                
+            except Exception as e:
+                logger.error(f"监控循环错误: {e}")
+            
+            # 等待下一次检查
+            time.sleep(self.check_interval)
+    
+    def _process_new_file(self, wav_file):
+        """
+        处理新的音频文件
+        
+        文件名格式: {project_id}_{device_code}_{时间戳}.wav
+        例如: 92_1#-1_20251218142000.wav
+        
+        流程:
+        1. 加载音频
+        2. 计算能量判断设备状态
+        3. 进行AE异常检测,记录重建误差
+        4. 保存音频数据用于计算频谱图
+        """
+        try:
+            # 从文件名解析device_code
+            # 格式: {project_id}_{device_code}_{时间戳}.wav
+            try:
+                parts = wav_file.stem.split('_')
+                if len(parts) >= 3:
+                    device_code = parts[1]  # 第二部分是device_code
+                else:
+                    device_code = "unknown"
+            except:
+                device_code = "unknown"
+            
+            # ========================================
+            # 泵启停状态检测(基于 PLC 查询)- 优先于模型检查
+            # ----------------------------------------
+            # 逻辑:通过 SCADA API 查询 PLC 点位判断泵是否运行
+            # 作用:
+            #   1. 冷启动模式下也能过滤停机时的音频(保证训练数据质量)
+            #   2. 泵停机时跳过异常检测(避免无意义的检测)
+            #   3. 记录设备状态用于后续上报
+            # 依赖:pump_state_monitor + rtsp_config 中的 pump_status_plc 配置
+            # ========================================
+            device_status = "未知"
+            pump_is_running = True  # 默认认为运行中(PLC 查询失败时的保守策略)
+            
+            # 获取该设备对应的流配置
+            stream_config = self.device_map.get(device_code)
+            
+            if self.pump_state_monitor and stream_config:
+                # 根据设备的 pump_name 找到关联的 PLC 点位配置
+                pump_name = stream_config.pump_name
+                pump_configs = self.pump_status_plc_configs.get(pump_name, [])
+                
+                if pump_configs:
+                    # 遍历所有关联泵,只要有一个运行就认为设备在工作
+                    any_pump_running = False
+                    for pump_cfg in pump_configs:
+                        point = pump_cfg.get("point", "")
+                        name = pump_cfg.get("name", point)
+                        pump_id = point
+                        
+                        # 查询泵状态(带 60 秒缓存,不会频繁请求 SCADA)
+                        is_running, _ = self.pump_state_monitor.update_pump_state(pump_id, point, name)
+                        if is_running:
+                            any_pump_running = True
+                    
+                    pump_is_running = any_pump_running
+                    device_status = "开机" if pump_is_running else "停机"
+                    
+                    # 泵全部停机时跳过(可通过配置禁用此行为)
+                    # 冷启动和正常模式都适用,确保训练数据质量
+                    if self.skip_detection_when_stopped and not pump_is_running:
+                        logger.info(f"泵停机(PLC): {device_code} | 归档到过渡期目录(不用于训练)")
+                        self._move_audio_to_transition_dir(wav_file, "stopped")
+                        return
+                    
+                    # ========================================
+                    # 过渡期检测(泵启停后15分钟内)
+                    # ----------------------------------------
+                    # 目的:过滤启停过程中的不稳定音频
+                    # 确保训练数据只包含稳定运行期的音频
+                    # ========================================
+                    if self.skip_detection_when_stopped:
+                        pump_in_transition, transition_pump_names = \
+                            self.pump_state_monitor.check_pumps_transition(pump_configs)
+                        
+                        if pump_in_transition:
+                            logger.info(f"泵过渡期: {device_code} | 归档到过渡期目录(不用于训练) | "
+                                       f"过渡期泵: {', '.join(transition_pump_names)}")
+                            self._move_audio_to_transition_dir(wav_file, "transition")
+                            return
+            
+            # 获取该设备的预测器(懒加载模型)
+            device_predictor = self.multi_predictor.get_predictor(device_code)
+            if device_predictor is None:
+                logger.info(f"冷启动模式(设备 {device_code} 无模型): 归档 {wav_file.name}")
+                self._move_audio_to_date_dir(wav_file)
+                return
+            # 加载音频
+            try:
+                y, sr = librosa.load(str(wav_file), sr=CFG.SR, mono=True)
+            except Exception as e:
+                logger.error(f"音频加载失败: {wav_file.name} | {e}")
+                return
+            
+            # 记录状态到缓存(用于周期上报)
+            # 注意:泵状态检测已在前面完成,这里只记录状态
+            self.device_cache[device_code]["status"] = device_status
+            
+            # ========================================
+            # 计算重建误差
+            # ========================================
+            error = self._compute_reconstruction_error(wav_file, device_predictor)
+            
+            if error is not None:
+                self.device_cache[device_code]["errors"].append(error)
+            
+            # ========================================
+            # 保存音频数据用于计算频谱图
+            # ========================================
+            self.device_cache[device_code]["audio_data"].append(y)
+            
+            # ========================================
+            # 暂存文件路径,等待周期聚合判定后再归档
+            # ========================================
+            if "pending_files" not in self.device_cache[device_code]:
+                self.device_cache[device_code]["pending_files"] = []
+            self.device_cache[device_code]["pending_files"].append(wav_file)
+            
+            # ========================================
+            # 记录到音频历史缓存(用于异常上下文捕获)
+            # ========================================
+            if self.context_capture_enabled:
+                self.audio_file_history[device_code].append((datetime.now(), wav_file))
+            
+            # 初始化上次上报时间
+            if self.device_cache[device_code]["last_upload"] is None:
+                self.device_cache[device_code]["last_upload"] = datetime.now()
+            
+            # 获取阈值并判断结果
+            threshold = self._get_threshold(device_code)
+            
+            # ========================================
+            # 快速通道:连续多个文件误差极高时快速预警(暂时关闭)
+            # 不走投票窗口,用于捕获突发性严重故障
+            # ========================================
+            # if error is not None and threshold is not None and threshold > 0:
+            #     # 维护快速通道缓冲区
+            #     if "fast_alert_buffer" not in self.device_cache[device_code]:
+            #         self.device_cache[device_code]["fast_alert_buffer"] = []
+            #     
+            #     fast_buf = self.device_cache[device_code]["fast_alert_buffer"]
+            #     # 连续性检查:误差超过 2x 阈值时记录,否则清空缓冲区
+            #     if error > threshold * 2.0:
+            #         fast_buf.append(error)
+            #     else:
+            #         fast_buf.clear()
+            #     
+            #     # 连续 3 个文件(~24秒)都超过 2x 阈值 → 触发快速预警
+            #     FAST_ALERT_CONSECUTIVE = 3
+            #     if len(fast_buf) >= FAST_ALERT_CONSECUTIVE:
+            #         # 检查是否已触发过(避免重复告警)
+            #         last_fast = self.device_cache[device_code].get("last_fast_alert_time")
+            #         now = datetime.now()
+            #         can_fast_alert = (last_fast is None or
+            #                          (now - last_fast).total_seconds() > 300)  # 5分钟冷却
+            #         
+            #         if can_fast_alert:
+            #             # 快速通道同样受抑制逻辑约束
+            #             suppress = False
+            #             # 泵过渡期抑制
+            #             if self.pump_state_monitor and stream_config:
+            #                 pump_name = stream_config.pump_name
+            #                 pump_configs = self.pump_status_plc_configs.get(pump_name, [])
+            #                 if pump_configs:
+            #                     in_transition, _ = self.pump_state_monitor.check_pumps_transition(pump_configs)
+            #                     if in_transition:
+            #                         suppress = True
+            #             # 人体检测抑制
+            #             if self.human_detection_enabled and self.human_reader:
+            #                 if self.human_reader.is_in_cooldown():
+            #                     suppress = True
+            #             # alert_enabled 开关
+            #             if not self.alert_enabled:
+            #                 suppress = True
+            #             
+            #             if not suppress:
+            #                 avg_fast = float(np.mean(fast_buf))
+            #                 logger.warning(
+            #                     f"[!!] 快速通道触发: {device_code} | "
+            #                     f"连续{len(fast_buf)}个文件异常 | "
+            #                     f"平均误差={avg_fast:.6f} 阈值={threshold:.6f}")
+            #                 self.device_cache[device_code]["last_fast_alert_time"] = now
+            #                 fast_buf.clear()
+            #                 # 标记快速预警,在下次周期上报时一并处理
+            #                 self.device_cache[device_code]["fast_alert_pending"] = True
+            
+            if error is not None and threshold is not None:
+                is_anomaly = error > threshold
+                result_tag = "!!" if is_anomaly else "OK"
+                logger.info(f"[{result_tag}] {device_code} | {wav_file.name} | "
+                           f"误差={error:.6f} 阈值={threshold:.6f}")
+            elif error is not None:
+                logger.debug(f"文件预测: {wav_file.name} | 误差={error:.6f} | 阀值=未设置")
+            else:
+                logger.warning(f"预测跳过: {wav_file.name} | 误差计算失败")
+            
+        except Exception as e:
+            logger.error(f"处理文件失败: {wav_file.name} | 错误: {e}")
+    
+    def _compute_reconstruction_error(self, wav_file, device_predictor):
+        """
+        计算单个音频文件的重建误差(Min-Max 标准化)
+        
+        使用8秒窗口、4秒步长切割音频,提取多个patches分别计算误差后取平均值。
+        
+        参数:
+            wav_file: 音频文件路径
+            device_predictor: 设备预测器实例
+        
+        返回:
+            重建误差值(所有patches的平均MSE),失败返回None
+        """
+        try:
+            import torch
+            from predictor.utils import align_to_target
+            
+            # Min-Max 标准化参数
+            global_min = device_predictor.global_min
+            global_max = device_predictor.global_max
+            
+            # 加载音频
+            y, _ = librosa.load(str(wav_file), sr=CFG.SR, mono=True)
+            
+            win_samples = int(CFG.WIN_SEC * CFG.SR)
+            hop_samples = int(CFG.HOP_SEC * CFG.SR)
+            
+            if len(y) < win_samples:
+                logger.warning(f"音频太短,无法提取patches: {wav_file.name}")
+                return None
+            
+            patches = []
+            for start in range(0, len(y) - win_samples + 1, hop_samples):
+                window = y[start:start + win_samples]
+                
+                S = librosa.feature.melspectrogram(
+                    y=window, sr=CFG.SR, n_fft=CFG.N_FFT,
+                    hop_length=CFG.HOP_LENGTH, n_mels=CFG.N_MELS, power=2.0
+                )
+                S_db = librosa.power_to_db(S, ref=np.max)
+                
+                if S_db.shape[1] < CFG.TARGET_FRAMES:
+                    S_db = np.pad(S_db, ((0, 0), (0, CFG.TARGET_FRAMES - S_db.shape[1])))
+                else:
+                    S_db = S_db[:, :CFG.TARGET_FRAMES]
+                
+                # Min-Max 标准化
+                S_norm = (S_db - global_min) / (global_max - global_min + 1e-6)
+                patches.append(S_norm.astype(np.float32))
+            
+            if not patches:
+                logger.warning(f"未能提取任何patches: {wav_file.name}")
+                return None
+            
+            arr = np.stack(patches, 0)
+            arr = np.expand_dims(arr, 1)
+            tensor = torch.from_numpy(arr)
+            
+            torch_device = device_predictor.torch_device
+            tensor = tensor.to(torch_device)
+            
+            with torch.no_grad():
+                recon = device_predictor.model(tensor)
+                recon = align_to_target(recon, tensor)
+                mse_per_patch = torch.mean((recon - tensor) ** 2, dim=[1, 2, 3])
+                mean_mse = torch.mean(mse_per_patch).item()
+            
+            logger.debug(f"重建误差: {wav_file.name} | patches={len(patches)} | MSE={mean_mse:.6f}")
+            return mean_mse
+            
+        except Exception as e:
+            logger.error(f"计算重建误差失败: {wav_file.name} | {e}")
+            return None
+    
+    def _check_periodic_upload(self):
+        """
+        检查是否需要进行周期性上报
+        
+        每分钟汇总一次各设备的检测结果并上报
+        """
+        now = datetime.now()
+        
+        for device_code, cache in self.device_cache.items():
+            # 检查上报时间间隔
+            last_upload = cache.get("last_upload")
+            if last_upload is None:
+                continue
+            
+            elapsed = (now - last_upload).total_seconds()
+            
+            # 达到上报周期
+            if elapsed >= self.segment_duration:
+                # 获取该设备的流配置
+                stream_config = self.device_map.get(device_code)
+                
+                # 计算平均重建误差
+                errors = cache.get("errors", [])
+                avg_error = float(np.mean(errors)) if errors else 0.0
+                
+                # 获取阈值
+                threshold = self._get_threshold(device_code)
+                
+                # 判断当前周期是否异常(带容差区间)
+                # 容差区间:避免边界值反复跳变,但灰区内仍与阈值比较
+                if threshold:
+                    upper_bound = threshold * (1 + self.tolerance_ratio)  # 确定异常边界
+                    lower_bound = threshold * (1 - self.tolerance_ratio)  # 确定正常边界
+                    
+                    if avg_error > upper_bound:
+                        # 超过上边界 -> 确定异常
+                        is_current_anomaly = True
+                    elif avg_error < lower_bound:
+                        # 低于下边界 -> 确定正常
+                        is_current_anomaly = False
+                    else:
+                        # 灰区 -> 与阈值比较(避免异常状态延长)
+                        is_current_anomaly = avg_error > threshold
+                    
+                    # 记录本次判定结果
+                    self.last_single_anomaly[device_code] = is_current_anomaly
+                else:
+                    is_current_anomaly = False
+                
+                # ========================================
+                # 滑动窗口投票:5次中有3次异常才判定为异常
+                # ========================================
+                if device_code not in self.detection_history:
+                    self.detection_history[device_code] = []
+                
+                # 记录本次检测结果
+                self.detection_history[device_code].append(is_current_anomaly)
+                
+                # 保持窗口大小
+                if len(self.detection_history[device_code]) > self.voting_window_size:
+                    self.detection_history[device_code].pop(0)
+                
+                # 投票判定最终异常状态
+                if self.voting_enabled and len(self.detection_history[device_code]) >= self.voting_window_size:
+                    anomaly_count = sum(self.detection_history[device_code])
+                    is_anomaly = anomaly_count >= self.voting_threshold
+                    window_info = f"窗口[{anomaly_count}/{self.voting_window_size}]"
+                else:
+                    is_anomaly = is_current_anomaly
+                    window_info = "窗口未满"
+                
+                # ========================================
+                # 状态变更检测
+                # trigger_alert: 仅在 正常(或初始) -> 异常 时为True
+                # 冷却逻辑已移至 AlertAggregator 内部处理
+                # ========================================
+                last_is_anomaly = self.last_report_status.get(device_code)
+                trigger_alert = False
+                
+                if is_anomaly:
+                    # 只有状态变化(正常->异常) 才触发报警流程
+                    if last_is_anomaly is None or not last_is_anomaly:
+                        # ========================================
+                        # 泵启停过渡期检查(抑制误报)
+                        # ----------------------------------------
+                        # 逻辑:检测到异常时,检查关联泵是否刚启停
+                        # 作用:泵启停过程中音频特征剧烈变化,易被误判为异常
+                        #       过渡期内抑制告警,避免误报
+                        # 过渡期窗口:配置中的 transition_window_minutes(默认15分钟)
+                        # ========================================
+                        pump_in_transition = False
+                        transition_pump_names = []
+                        
+                        if self.pump_state_monitor and stream_config:
+                            pump_name = stream_config.pump_name
+                            pump_configs = self.pump_status_plc_configs.get(pump_name, [])
+                            
+                            if pump_configs:
+                                # 批量检查所有关联泵是否有处于过渡期的
+                                pump_in_transition, transition_pump_names = \
+                                    self.pump_state_monitor.check_pumps_transition(pump_configs)
+                        
+                        if pump_in_transition:
+                            # 有泵处于过渡期 -> 抑制本次告警
+                            trigger_alert = False
+                            logger.info(f"泵启停过渡期,抑制告警: {device_code} | "
+                                       f"过渡期泵: {', '.join(transition_pump_names)}")
+                        else:
+                            # 检查 alert_enabled 开关:如果禁用则不触发告警
+                            if self.alert_enabled:
+                                # ========================================
+                                # 人体检测抑制:任意摄像头检测到人则不报警
+                                # ========================================
+                                if self.human_detection_enabled and self.human_reader:
+                                    if self.human_reader.is_in_cooldown():
+                                        trigger_alert = False
+                                        status_info = self.human_reader.get_status_info()
+                                        logger.info(f"人体检测抑制: {device_code} | {status_info},跳过报警")
+                                    else:
+                                        trigger_alert = True
+                                else:
+                                    trigger_alert = True
+                            else:
+                                trigger_alert = False
+                                logger.debug(f"异常告警已禁用,跳过告警: {device_code}")
+                
+                # 获取运行状态
+                running_status = cache.get("status", "未知")
+                
+                # 获取进水流量
+                inlet_flow = self._get_inlet_flow(stream_config) if stream_config else 0.0
+                
+                # 计算本次频谱图
+                audio_data = cache.get("audio_data", [])
+                freq_db = self._compute_frequency_spectrum(audio_data)
+                
+                # 保存到频谱图历史(只保存dB值)
+                if self.freq_history_enabled and freq_db:
+                    self.freq_history[device_code].append((now, freq_db))
+                    # 清理过期历史
+                    cutoff = now - timedelta(minutes=self.freq_history_minutes)
+                    self.freq_history[device_code] = [
+                        (t, d) for t, d in self.freq_history[device_code]
+                        if t > cutoff
+                    ]
+                
+                # 计算历史频谱图平均值(normal_frequency_middle)
+                freq_middle_db = self._compute_frequency_middle(device_code)
+                
+                # ========================================
+                # 异常分类:只在状态从正常变为异常时进行分类
+                # 持续异常期间沿用上次分类结果,保持一致性
+                # ========================================
+                anomaly_type_code = 6  # 默认:未分类异常
+                type_name = "未分类异常"
+                
+                if is_anomaly and audio_data:
+                    # 检查是否是新的异常(从正常变为异常)
+                    is_new_anomaly = (last_is_anomaly is None or not last_is_anomaly)
+                    
+                    if is_new_anomaly:
+                        # 新异常:进行分类并锁定结果
+                        try:
+                            from core.anomaly_classifier import classify_anomaly
+                            if len(audio_data) > 0:
+                                y = audio_data[-1] if isinstance(audio_data[-1], np.ndarray) else np.array(audio_data[-1])
+                                anomaly_type_code, type_name, confidence = classify_anomaly(y, sr=16000)
+                                # 锁定分类结果
+                                self.locked_anomaly_type[device_code] = (anomaly_type_code, type_name)
+                                logger.info(f"异常分类(新异常): {type_name} (code={anomaly_type_code}, 置信度={confidence:.2f})")
+                        except Exception as e:
+                            logger.warning(f"异常分类失败: {e}")
+                    else:
+                        # 持续异常:沿用锁定的分类结果
+                        if device_code in self.locked_anomaly_type:
+                            anomaly_type_code, type_name = self.locked_anomaly_type[device_code]
+                            logger.debug(f"异常分类(沿用): {type_name} (code={anomaly_type_code})")
+                else:
+                    # 状态正常时清除锁定的分类结果
+                    if device_code in self.locked_anomaly_type:
+                        del self.locked_anomaly_type[device_code]
+                
+                # 上报逻辑
+                if self.push_enabled and stream_config:
+                    # 预读异常音频base64:在文件被归档/清空之前立即读取
+                    # 解决竞态问题:异步推送或聚合器延迟推送时文件可能已被移走
+                    pre_read_wav_b64 = ""
+                    if trigger_alert:
+                        try:
+                            current_pending = cache.get("pending_files", [])
+                            if current_pending and current_pending[0].exists():
+                                with open(current_pending[0], "rb") as f:
+                                    pre_read_wav_b64 = base64.b64encode(f.read()).decode('utf-8')
+                                logger.debug(f"预读异常音频成功: {current_pending[0].name} | size={len(pre_read_wav_b64)}")
+                        except Exception as e:
+                            logger.warning(f"预读异常音频失败: {e}")
+
+                    if trigger_alert and self.alert_aggregator:
+                        # 报警走聚合器:跨设备聚合判定 + 分类型冷却
+                        # 聚合器会在窗口到期后决定是否真正推送
+                        self.alert_aggregator.submit_alert(
+                            plant_name=stream_config.plant_name,
+                            device_code=device_code,
+                            anomaly_type_code=anomaly_type_code,
+                            push_kwargs=dict(
+                                stream_config=stream_config,
+                                device_code=device_code,
+                                is_anomaly=is_anomaly,
+                                trigger_alert=True,
+                                abnormal_score=avg_error,
+                                score_threshold=threshold,
+                                running_status=running_status,
+                                inlet_flow=inlet_flow,
+                                freq_db=freq_db,
+                                freq_middle_db=freq_middle_db,
+                                anomaly_type_code=anomaly_type_code,
+                                abnormal_wav_b64=pre_read_wav_b64
+                            )
+                        )
+                    else:
+                        # 非报警(心跳)或聚合器不可用 -> 提交到线程池异步推送
+                        self._push_executor.submit(
+                            self._push_detection_result,
+                            stream_config=stream_config,
+                            device_code=device_code,
+                            is_anomaly=is_anomaly,
+                            trigger_alert=trigger_alert,
+                            abnormal_score=avg_error,
+                            score_threshold=threshold,
+                            running_status=running_status,
+                            inlet_flow=inlet_flow,
+                            freq_db=freq_db,
+                            freq_middle_db=freq_middle_db,
+                            anomaly_type_code=anomaly_type_code,
+                            abnormal_wav_b64=pre_read_wav_b64
+                        )
+                    
+                    # 更新上一次状态
+                    self.last_report_status[device_code] = is_anomaly
+                
+                # 日志记录
+                thr_str = f"{threshold:.6f}" if threshold else "未设置"
+                # 报警去向说明
+                if trigger_alert and self.alert_aggregator:
+                    alert_dest = "-> 聚合器"
+                elif trigger_alert:
+                    alert_dest = "-> 直接推送"
+                else:
+                    alert_dest = ""
+                
+                # 使用设备名作为标识,增加视觉分隔
+                cam_label = ""
+                if stream_config:
+                    cam_label = f"({stream_config.camera_name})"
+                
+                result_emoji = "!!" if is_anomaly else "OK"
+                alert_str = f"报警=是 {alert_dest}" if trigger_alert else "报警=否"
+                
+                logger.info(
+                    f"[{result_emoji}] {device_code}{cam_label} | "
+                    f"误差={avg_error:.6f} 阈值={thr_str} | "
+                    f"{window_info} | {running_status} | "
+                    f"{'异常' if is_anomaly else '正常'} | {alert_str}"
+                )
+                
+                # ========================================
+                # 根据单次检测结果归档文件
+                # 归档基于 is_current_anomaly(单次检测),而非投票后的 is_anomaly
+                # 投票机制只影响是否推送报警,不影响文件分类
+                # ========================================
+                pending_files = cache.get("pending_files", [])
+                
+                # 检查是否是新的异常(从正常变为异常)
+                is_new_anomaly = is_anomaly and (last_is_anomaly is None or not last_is_anomaly)
+                
+                # ========================================
+                # 异常上下文捕获逻辑
+                # ========================================
+                if self.context_capture_enabled:
+                    # 检查并更新捕获状态
+                    self._update_anomaly_capture_state(device_code, is_anomaly, is_new_anomaly, 
+                                                       avg_error, threshold, now, pending_files)
+                
+                if pending_files:
+                    if is_current_anomaly:
+                        # 单次检测为异常 -> 归档到异常目录
+                        # 检查异常保存冷却时间
+                        last_save = self.last_anomaly_save_time.get(device_code)
+                        should_save = True
+                        
+                        if last_save:
+                            elapsed_minutes = (now - last_save).total_seconds() / 60
+                            should_save = elapsed_minutes >= self.anomaly_save_cooldown_minutes
+                        
+                        if should_save and not self.context_capture_enabled:
+                            # 上下文捕获禁用时,使用原有逻辑:移动到异常待排查目录
+                            for f in pending_files:
+                                self._move_audio_to_anomaly_pending(f)
+                            self.last_anomaly_save_time[device_code] = now
+                            logger.warning(f"已隔离 {len(pending_files)} 个异常文件到待排查目录")
+                        elif self.context_capture_enabled:
+                            # 上下文捕获启用时:
+                            # - 异常文件已在 _update_anomaly_capture_state 中记录路径
+                            # - 文件会在 _save_anomaly_context 中被移动到异常目录
+                            # - 这里暂时保留文件,不做处理
+                            pass  # 文件由捕获逻辑处理
+                        else:
+                            # 冷却时间内,删除文件不保存
+                            for f in pending_files:
+                                try:
+                                    f.unlink()
+                                except Exception as e:
+                                    logger.debug(f"删除冷却期内文件失败: {f.name} | {e}")
+                            remaining_minutes = self.anomaly_save_cooldown_minutes - elapsed_minutes
+                            logger.debug(f"异常保存冷却中: {device_code} | 剩余 {remaining_minutes:.1f} 分钟")
+                    else:
+                        # 单次检测为正常 -> 移到日期目录归档
+                        for f in pending_files:
+                            self._move_audio_to_date_dir(f)
+                        # 状态恢复正常时,清除保存冷却时间(下次异常时可立即保存)
+                        if device_code in self.last_anomaly_save_time:
+                            del self.last_anomaly_save_time[device_code]
+                
+                # 重置缓存
+                cache["errors"] = []
+                cache["audio_data"] = []
+                cache["pending_files"] = []
+                cache["last_upload"] = now
+                logger.info("─" * 60)
+    
+    def _update_anomaly_capture_state(self, device_code, is_anomaly, 
+                                       is_new_anomaly, avg_error,
+                                       threshold, now, pending_files):
+        """
+        更新异常上下文捕获状态
+        
+        状态机:
+        1. 未触发 -> 检测到新异常 -> 触发捕获,从 audio_file_history 回溯获取文件
+        2. 已触发,等待后续 -> 持续收集后续文件
+        3. 已触发,时间到 -> 保存所有文件和元数据
+        
+        修复说明:
+        - anomaly_files 不再依赖 pending_files(可能为空)
+        - 改为从 audio_file_history 回溯获取触发时刻前 1 分钟内的文件
+        - pre_files 改为获取触发时刻前 1~N+1 分钟的文件(排除 anomaly 时间段)
+        
+        参数:
+            device_code: 设备编号
+            is_anomaly: 当前周期是否异常
+            is_new_anomaly: 是否是新异常(从正常变为异常)
+            avg_error: 平均重建误差
+            threshold: 阈值
+            now: 当前时间
+            pending_files: 当前周期的待处理文件
+        """
+        import shutil
+        import json
+        
+        state = self.anomaly_capture_state.get(device_code)
+        
+        if is_new_anomaly and state is None:
+            # 新异常触发:开始捕获
+            # ========================================
+            # 从 audio_file_history 回溯获取文件
+            # ----------------------------------------
+            # anomaly_files: 触发时刻前 1 分钟内的文件(最接近异常的时间段)
+            # pre_files: 触发时刻前 1~(N+1) 分钟的文件(更早的正常时间段)
+            # ========================================
+            anomaly_cutoff = now - timedelta(minutes=1)  # 前1分钟
+            pre_cutoff = now - timedelta(minutes=self.context_pre_minutes + 1)  # 前N+1分钟
+            
+            pre_files = []
+            anomaly_files = []
+            
+            for ts, fpath in self.audio_file_history[device_code]:
+                if not fpath.exists():
+                    continue
+                if ts >= anomaly_cutoff:
+                    # 触发时刻前1分钟内 -> anomaly_files
+                    anomaly_files.append(fpath)
+                elif ts >= pre_cutoff:
+                    # 触发时刻前1~(N+1)分钟 -> pre_files
+                    pre_files.append(fpath)
+            
+            # 如果 anomaly_files 仍为空,把当前 pending_files 加入(兜底)
+            if not anomaly_files and pending_files:
+                anomaly_files = [f for f in pending_files if f.exists()]
+            
+            # 初始化捕获状态
+            self.anomaly_capture_state[device_code] = {
+                "trigger_time": now,
+                "avg_error": avg_error,
+                "threshold": threshold,
+                "pre_files": pre_files,
+                "anomaly_files": anomaly_files,
+                "post_files": [],
+                "post_start_time": now
+            }
+            
+            logger.info(f"异常上下文捕获已触发: {device_code} | "
+                       f"前置文件={len(pre_files)}个 | 异常文件={len(anomaly_files)}个 | "
+                       f"等待后续{self.context_post_minutes}分钟")
+            
+        elif state is not None:
+            # 已触发状态:收集后续文件
+            elapsed_post = (now - state["post_start_time"]).total_seconds() / 60
+            
+            if elapsed_post < self.context_post_minutes:
+                # 还在收集后续文件
+                for f in pending_files:
+                    if f.exists():
+                        state["post_files"].append(f)
+            else:
+                # 时间到,保存所有文件
+                self._save_anomaly_context(device_code, state)
+                # 清除状态
+                del self.anomaly_capture_state[device_code]
+                # 更新保存冷却时间
+                self.last_anomaly_save_time[device_code] = now
+    
+    def _save_anomaly_context(self, device_code: str, state: dict):
+        """
+        保存异常上下文文件到独立目录
+        
+        目录结构:
+        data/anomaly_detected/{device_code}/{异常文件名(不含扩展名)}/
+        ├── 92_1#-1_20260130140313.wav  (保持原文件名)
+        ├── ...
+        └── metadata.json
+        
+        metadata.json 字段说明:
+        - before_trigger: 触发前 1~(N+1) 分钟的文件(正常时期,用于对比)
+        - at_trigger: 触发时刻前 1 分钟内的文件(异常开始出现的时期)
+        - after_trigger: 触发后 N 分钟的文件(不管异常是否恢复)
+        
+        参数:
+            device_code: 设备编号
+            state: 捕获状态字典
+        """
+        import shutil
+        import json
+        
+        try:
+            # 获取第一个异常文件名作为文件夹名
+            anomaly_files = state.get("anomaly_files", [])
+            if not anomaly_files:
+                logger.warning(f"无异常文件,跳过保存: {device_code}")
+                return
+            
+            # 用第一个异常文件名(不含扩展名)作为文件夹名
+            first_anomaly = anomaly_files[0]
+            folder_name = first_anomaly.stem  # 如 92_1#-1_20260130140313
+            save_dir = self.save_anomaly_dir / device_code / folder_name
+            save_dir.mkdir(parents=True, exist_ok=True)
+            
+            # 收集所有文件名(使用新命名)
+            all_files = {"before_trigger": [], "at_trigger": [], "after_trigger": []}
+            
+            # 移动前置文件(来自日期目录,移动后原位置不再保留)
+            for fpath in state.get("pre_files", []):
+                if fpath.exists():
+                    dest = save_dir / fpath.name
+                    shutil.move(str(fpath), str(dest))  # 移动而非复制,避免重复
+                    all_files["before_trigger"].append(fpath.name)
+            
+            # 移动触发时刻文件(保持原名)
+            for fpath in anomaly_files:
+                if fpath.exists():
+                    dest = save_dir / fpath.name
+                    shutil.move(str(fpath), str(dest))
+                    all_files["at_trigger"].append(fpath.name)
+            
+            # 移动后续文件(保持原名)
+            for fpath in state.get("post_files", []):
+                if fpath.exists():
+                    dest = save_dir / fpath.name
+                    shutil.move(str(fpath), str(dest))
+                    all_files["after_trigger"].append(fpath.name)
+            
+            # 生成精简的元数据文件
+            trigger_time = state.get("trigger_time")
+            metadata = {
+                "device_code": device_code,
+                "trigger_time": trigger_time.strftime("%Y-%m-%d %H:%M:%S") if trigger_time else None,
+                "avg_error": round(state.get("avg_error", 0.0), 6),
+                "threshold": round(state.get("threshold", 0.0), 6),
+                "files": all_files
+            }
+            
+            metadata_path = save_dir / "metadata.json"
+            with open(metadata_path, 'w', encoding='utf-8') as f:
+                json.dump(metadata, f, ensure_ascii=False, indent=2)
+            
+            total = sum(len(v) for v in all_files.values())
+            logger.warning(f"异常上下文已保存: {device_code}/{folder_name} | "
+                          f"共{total}个文件 (前{len(all_files['before_trigger'])}+异常{len(all_files['at_trigger'])}+后{len(all_files['after_trigger'])})")
+            
+        except Exception as e:
+            logger.error(f"保存异常上下文失败: {device_code} | {e}")
+    
+    def _compute_frequency_middle(self, device_code):
+        """
+        计算历史频谱图平均值(normal_frequency_middle)
+        
+        参数:
+            device_code: 设备编号
+        
+        返回:
+            dB值列表的平均值
+        """
+        history = self.freq_history.get(device_code, [])
+        if not history or len(history) < 2:
+            return []
+        
+        try:
+            # 收集所有历史dB值(只保留长度一致的)
+            all_db = []
+            ref_len = len(history[0][1])  # 使用第一条记录的长度作为参考
+            
+            for _, db in history:
+                if len(db) == ref_len:
+                    all_db.append(db)
+            
+            if not all_db:
+                return []
+            
+            # 计算各频率点的平均dB
+            avg_db = np.mean(all_db, axis=0).tolist()
+            return avg_db
+            
+        except Exception as e:
+            logger.error(f"计算频谱图历史平均失败: {e}")
+            return []
+    
+    def _get_threshold(self, device_code):
+        """
+        获取指定设备的阈值
+        
+        优先级:
+        1. 从 multi_predictor 获取设备对应模型的阈值
+        2. 使用配置文件中的默认阈值
+        3. 返回0.0(不进行异常判定)
+        
+        参数:
+            device_code: 设备编号(如 "LT-1")
+        
+        返回:
+            阈值,未找到返回默认值或0.0
+        """
+        # 方式1:从 multi_predictor 获取设备阈值
+        thr = self.multi_predictor.get_threshold(device_code)
+        if thr is not None:
+            logger.debug(f"阈值来源: {device_code} -> 设备模型阈值 = {thr:.6f}")
+            return thr
+        
+        # 方式2:使用配置中的默认阈值
+        default_threshold = self.config.get('prediction', {}).get('default_threshold', 0.0)
+        
+        if default_threshold > 0:
+            logger.debug(f"阈值来源: {device_code} -> 配置默认值 = {default_threshold:.6f}")
+            return default_threshold
+        
+        # 首次找不到阈值时记录警告
+        if device_code not in getattr(self, '_threshold_warned', set()):
+            if not hasattr(self, '_threshold_warned'):
+                self._threshold_warned = set()
+            self._threshold_warned.add(device_code)
+            logger.warning(f"未找到阈值: {device_code},将跳过异常判定")
+        
+        return 0.0
+    
+    def _get_inlet_flow(self, stream_config: RTSPStreamConfig) -> float:
+        """
+        获取进水流量(使用实时数据接口)
+        
+        使用 current-data 接口直接获取最新一条数据
+        
+        参数:
+            stream_config: 流配置
+        
+        返回:
+            进水流量值,失败返回0.0
+        """
+        if not self.scada_enabled or not stream_config:
+            logger.debug(f"流量跳过: scada_enabled={self.scada_enabled}, stream_config={stream_config}")
+            return 0.0
+        
+        # 获取PLC数据点位
+        plc_address = stream_config.get_flow_plc_address()
+        if not plc_address:
+            logger.debug(f"流量跳过(无PLC地址): {stream_config.device_code} | pump_name='{stream_config.pump_name}'")
+            return 0.0
+        
+        # 使用水厂配置的 project_id
+        project_id = stream_config.project_id
+        
+        try:
+            # 当前时间戳(毫秒)
+            now_ms = int(datetime.now().timestamp() * 1000)
+            
+            # 请求参数
+            params = {"time": now_ms}
+            
+            # 请求体:使用实时数据接口格式
+            request_body = [
+                {
+                    "deviceId": "1",
+                    "deviceItems": plc_address,
+                    "deviceName": f"流量_{stream_config.pump_name}",
+                    "project_id": project_id
+                }
+            ]
+            
+            # 请求头
+            headers = {
+                "Content-Type": "application/json",
+                "JWT-TOKEN": self.scada_jwt
+            }
+            
+            logger.debug(f"流量请求: {stream_config.device_code} | project_id={project_id} | plc={plc_address}")
+            
+            # 发送 POST 请求到实时接口
+            response = requests.post(
+                self.scada_realtime_url,
+                params=params,
+                json=request_body,
+                headers=headers,
+                timeout=self.scada_timeout
+            )
+            
+            if response.status_code == 200:
+                data = response.json()
+                if data.get("code") == 200:
+                    if data.get("data"):
+                        # 获取第一条数据(实时接口只返回最新一条)
+                        latest = data["data"][0]
+                        if "val" in latest:
+                            flow = float(latest["val"])
+                            logger.debug(f"流量获取成功: {stream_config.device_code} | 流量={flow}")
+                            return flow
+                        else:
+                            logger.debug(f"流量数据无val字段: {stream_config.device_code}")
+                    else:
+                        # API正常返回但无数据
+                        logger.debug(f"流量查询无数据: {stream_config.device_code}")
+                else:
+                    logger.warning(f"流量API返回异常: {stream_config.device_code} | code={data.get('code')} | msg={data.get('msg')}")
+            else:
+                logger.warning(f"流量HTTP错误: {stream_config.device_code} | status={response.status_code}")
+            
+        except Exception as e:
+            logger.warning(f"流量获取异常: {stream_config.device_code} | {e}")
+        
+        return 0.0
+    
+    def _compute_frequency_spectrum(self, audio_data):
+        """
+        计算频谱图数据
+        
+        将1分钟内的多个8秒音频片段合并,计算整体FFT
+        
+        参数:
+            audio_data: 音频数据列表
+        
+        返回:
+            dB值列表(0-8000Hz均匀分布,共400个点)
+        """
+        if not audio_data:
+            return []
+        
+        try:
+            # 合并所有音频片段
+            combined = np.concatenate(audio_data)
+            
+            # 计算FFT
+            n = len(combined)
+            fft_result = np.fft.rfft(combined)
+            freqs = np.fft.rfftfreq(n, 1.0 / CFG.SR)
+            
+            # 计算幅度(转换为dB)
+            magnitude = np.abs(fft_result)
+            # 避免log(0)
+            magnitude = np.maximum(magnitude, 1e-10)
+            db = 20 * np.log10(magnitude)
+            
+            # 降采样到400个点(0-8000Hz均匀分布)
+            max_freq = 8000
+            num_points = 400
+            
+            db_list = []
+            
+            for i in range(num_points):
+                # 计算目标频率(0到8000Hz均匀分布)
+                target_freq = (i / (num_points - 1)) * max_freq
+                # 找到最接近的频率索引
+                idx = np.argmin(np.abs(freqs - target_freq))
+                if idx < len(freqs):
+                    db_list.append(float(db[idx]))
+            
+            return db_list
+            
+        except Exception as e:
+            logger.error(f"计算频谱图失败: {e}")
+            return []
+    
+    def _push_detection_result(self, stream_config, device_code,
+                               is_anomaly, trigger_alert, abnormal_score, score_threshold,
+                               running_status, inlet_flow,
+                               freq_db, freq_middle_db=None,
+                               anomaly_type_code=6,
+                               abnormal_wav_b64=""):
+        """
+        推送检测结果到远程服务器
+        
+        上报格式符合用户要求:
+        - 包含频谱图数据(this_frequency + normal_frequency_middle)
+        - 包含启停状态和进水流量
+        - sound_detection.status 和 abnormalwav 仅在 trigger_alert=True 时上报异常(0)和base64,否则为正常(1)和空
+        
+        参数:
+            stream_config: 流配置
+            device_code: 设备编号
+            is_anomaly: 实际检测是否异常
+            trigger_alert: 是否触发报警(仅在新异常产生时为True)
+            abnormal_score: 平均重建误差
+            score_threshold: 阈值
+            running_status: 启停状态
+            inlet_flow: 进水流量
+            freq_db: 本次频谱图dB值列表
+            freq_middle_db: 历史平均频谱图dB值列表
+            abnormal_wav_b64: 预读的异常音频base64编码(在文件归档前读取,避免竞态)
+        """
+        try:
+            import time as time_module
+            
+            # 获取设备名称
+            camera_name = stream_config.camera_name
+            
+            # 从 push_base_urls 列表构建推送目标(每个 base_url + /{project_id})
+            if not self.push_base_urls:
+                logger.warning(f"未配置push_base_urls: {device_code}")
+                return
+            
+            # 获取该设备对应的 project_id,用于拼接最终推送URL
+            project_id = stream_config.project_id
+            
+            # 构建推送消息
+            request_time = int(time_module.time() * 1000)
+            
+            # 异常音频已通过参数 abnormal_wav_b64 传入(调用方在文件归档前预读)
+            # 如果调用方未提供,记录警告以便排查
+            if trigger_alert and not abnormal_wav_b64:
+                logger.warning(f"报警推送但无异常音频数据: {device_code}")
+
+            # 决定 sound_detection.status
+            # 只有在 trigger_alert=True 时才报 0 (异常)
+            # 其他情况(正常、持续异常)都报 1 (正常/空) -- 根据用户要求: "正常情况下 ... 是空 就行"
+            report_status = 0 if trigger_alert else 1
+            
+            payload = {
+                "message": {
+                    # 通道信息
+                    "channelInfo": {"name": camera_name},
+                    # 请求时间戳
+                    "requestTime": request_time,
+                    # 分类信息
+                    "classification": {
+                        "level_one": 2,                   # 音频检测大类
+                        "level_two": anomaly_type_code    # 异常类型小类(6=未分类, 7=轴承, 8=气蚀, 9=松动, 10=叶轮, 11=阀件)
+                    },
+                    # 技能信息
+                    "skillInfo": {"name": "异响检测"},
+                    # 声音检测数据
+                    "sound_detection": {
+                        # 异常音频
+                        "abnormalwav": abnormal_wav_b64,
+                        # 状态:0=异常(仅新异常), 1=正常(或持续异常)
+                        "status": report_status,
+                        # 设备状态信息
+                        "condition": {
+                            "running_status": "运行中" if running_status == "开机" else "停机中",
+                            "inlet_flow": inlet_flow
+                        },
+                        # 得分信息
+                        "score": {
+                            "abnormal_score": abnormal_score,    # 当前1分钟平均重构误差
+                            "score_threshold": score_threshold   # 该设备异常阀值
+                        },
+                        # 频谱图数据
+                        "frequency": {
+                            "this_frequency": freq_db,                    # 当前1分钟的频谱图
+                            "normal_frequency_middle": freq_middle_db or [],  # 过去10分钟的频谱图平均
+                            "normal_frequency_upper": [],                 # 上限(暂为空)
+                            "normal_frequency_lower": []                  # 下限(暂为空)
+                        }
+                    }
+                }
+            }
+            
+            # 遍历所有推送基地址,逐个拼接 project_id 发送(各目标互不影响)
+            push_targets = [
+                (f"{item['url']}/{project_id}", item['label'])
+                for item in self.push_base_urls
+            ]
+            
+            # 逐个目标推送(各目标互不影响)
+            for target_url, target_label in push_targets:
+                self._send_to_target(
+                    target_url, target_label, payload,
+                    device_code, camera_name, trigger_alert, abnormal_score
+                )
+            
+        except Exception as e:
+            logger.error(f"推送通知异常: {e}")
+    
+    def _send_to_target(self, target_url, target_label, payload,
+                        device_code, camera_name, trigger_alert, abnormal_score):
+        # 向单个推送目标发送数据(含重试逻辑)
+        # 各目标独立调用,某个目标失败不影响其他目标
+        import time as time_module
+        
+        push_success = False
+        for attempt in range(self.push_retry_count + 1):
+            try:
+                response = requests.post(
+                    target_url,
+                    json=payload,
+                    timeout=self.push_timeout,
+                    headers={"Content-Type": "application/json"}
+                )
+                
+                if response.status_code == 200:
+                    alert_tag = "报警" if trigger_alert else "心跳"
+                    logger.info(
+                        f"    [{alert_tag}][{target_label}] {device_code}({camera_name}) | "
+                        f"误差={abnormal_score:.6f}"
+                    )
+                    push_success = True
+                    break
+                else:
+                    logger.warning(
+                        f"推送失败[{target_label}]: {device_code} | URL={target_url} | "
+                        f"状态码={response.status_code} | 内容={response.text[:100]}"
+                    )
+                    
+            except requests.exceptions.Timeout:
+                logger.warning(f"推送超时[{target_label}]: {device_code} | URL={target_url} | 尝试 {attempt + 1}/{self.push_retry_count + 1}")
+            except requests.exceptions.RequestException as e:
+                logger.warning(f"推送异常[{target_label}]: {device_code} | URL={target_url} | {e}")
+            
+            # 重试间隔
+            if attempt < self.push_retry_count:
+                time_module.sleep(1)
+        
+        if not push_success:
+            logger.error(f"推送失败[{target_label}]: {device_code} | URL={target_url} | 已达最大重试次数")
+    
+    def _move_audio_to_date_dir(self, wav_file):
+        """
+        将音频移动到日期目录归档
+        
+        目录结构:
+        deploy_pickup/data/{device_code}/{日期}/{文件名}
+        
+        参数:
+            wav_file: 音频文件路径
+        """
+        try:
+            import shutil
+            
+            # 从文件名提取device_code和日期
+            # 格式: 92_1#-1_20251218142000.wav
+            match = re.match(r'\d+_(.+)_(\d{8})\d{6}\.wav', wav_file.name)
+            if not match:
+                logger.warning(f"无法从文件名提取信息: {wav_file.name}")
+                return
+            
+            device_code = match.group(1)  # 如 1#-1
+            date_str = match.group(2)     # YYYYMMDD
+            
+            # 构建目标目录: data/{device_code}/{日期}/
+            date_dir = self.audio_dir / device_code / date_str
+            date_dir.mkdir(parents=True, exist_ok=True)
+            
+            # 移动文件
+            dest_file = date_dir / wav_file.name
+            shutil.move(str(wav_file), str(dest_file))
+            logger.debug(f"音频已归档: {device_code}/{date_str}/{wav_file.name}")
+            
+        except Exception as e:
+            logger.error(f"移动音频失败: {wav_file.name} | 错误: {e}")
+    
+    def _move_audio_to_transition_dir(self, wav_file, reason):
+        """
+        将泵停机/过渡期的音频移动到过渡期目录
+        
+        目录结构:
+        deploy_pickup/data/{device_code}/pump_transition/{文件名}
+        
+        这些音频不会被用于模型训练,但保留用于分析调试
+        
+        参数:
+            wav_file: 音频文件路径
+            reason: 原因标识(stopped=停机, transition=过渡期)
+        """
+        try:
+            import shutil
+            
+            # 从文件名提取device_code
+            # 格式: 92_1#-1_20251218142000.wav
+            match = re.match(r'\d+_(.+)_\d{14}\.wav', wav_file.name)
+            if not match:
+                logger.warning(f"无法从文件名提取信息: {wav_file.name}")
+                return
+            
+            device_code = match.group(1)  # 如 1#-1
+            
+            # 构建目标目录: data/{device_code}/pump_transition/
+            transition_dir = self.audio_dir / device_code / "pump_transition"
+            transition_dir.mkdir(parents=True, exist_ok=True)
+            
+            # 移动文件
+            dest_file = transition_dir / wav_file.name
+            shutil.move(str(wav_file), str(dest_file))
+            logger.debug(f"过渡期音频已归档: {device_code}/pump_transition/{wav_file.name} ({reason})")
+            
+        except Exception as e:
+            logger.error(f"移动过渡期音频失败: {wav_file.name} | 错误: {e}")
+    
+    def _move_audio_to_anomaly_pending(self, wav_file):
+        """
+        将异常音频移动到待排查目录
+        
+        目录结构:
+        deploy_pickup/data/{device_code}/anomaly_pending/{文件名}
+        
+        用户确认是误报后,可手动将文件移到日期目录参与增训
+        
+        参数:
+            wav_file: 音频文件路径
+        """
+        try:
+            import shutil
+            
+            # 从文件名提取device_code
+            # 格式: 92_1#-1_20251218142000.wav
+            match = re.match(r'\d+_(.+)_\d{14}\.wav', wav_file.name)
+            if not match:
+                logger.warning(f"无法从文件名提取信息: {wav_file.name}")
+                return
+            
+            device_code = match.group(1)  # 如 1#-1
+            
+            # 构建目标目录: data/{device_code}/anomaly_pending/
+            pending_dir = self.audio_dir / device_code / "anomaly_pending"
+            pending_dir.mkdir(parents=True, exist_ok=True)
+            
+            # 移动文件
+            dest_file = pending_dir / wav_file.name
+            shutil.move(str(wav_file), str(dest_file))
+            logger.debug(f"异常音频已隔离: {device_code}/anomaly_pending/{wav_file.name}")
+            
+        except Exception as e:
+            logger.error(f"移动异常音频失败: {wav_file.name} | 错误: {e}")
+    
+    def _cleanup_old_files(self, days: int = 7):
+        """
+        清理超过指定天数的正常音频文件
+        
+        清理规则:
+        - 只清理日期归档目录(data/{device_code}/{日期}/)
+        - 保留current目录和anomaly_detected目录
+        - 超过days天的文件删除
+        
+        参数:
+            days: 保留天数,默认7天
+        """
+        try:
+            import shutil
+            from datetime import datetime, timedelta
+            
+            # 计算截止日期
+            cutoff_date = datetime.now() - timedelta(days=days)
+            cutoff_str = cutoff_date.strftime("%Y%m%d")
+            
+            deleted_count = 0
+            
+            # 遍历每个设备目录
+            for device_dir in self.audio_dir.iterdir():
+                if not device_dir.is_dir():
+                    continue
+                
+                # 检查是否是日期目录(跳过current和其他特殊目录)
+                for subdir in device_dir.iterdir():
+                    if not subdir.is_dir():
+                        continue
+                    
+                    # 跳过current目录
+                    if subdir.name == "current":
+                        continue
+                    
+                    # 检查是否为日期目录(YYYYMMDD格式)
+                    if not re.match(r'^\d{8}$', subdir.name):
+                        continue
+                    
+                    # 如果日期早于截止日期,删除整个目录
+                    if subdir.name < cutoff_str:
+                        try:
+                            shutil.rmtree(subdir)
+                            deleted_count += 1
+                            logger.debug(f"清理过期目录: {device_dir.name}/{subdir.name}")
+                        except Exception as e:
+                            logger.error(f"删除目录失败: {subdir} | {e}")
+            
+            if deleted_count > 0:
+                logger.info(f"清理完成: 共删除{deleted_count}个过期目录")
+                
+        except Exception as e:
+            logger.error(f"清理过期文件失败: {e}")
+
+
+class PickupMonitoringSystem:
+    """
+    拾音器监控系统
+    
+    管理FFmpeg进程和监控线程
+    """
+    
+    def __init__(self, db_path=None):
+        """
+        初始化监控系统
+        
+        参数:
+            db_path: SQLite 数据库路径(为 None 时使用默认路径 config/pickup_config.db)
+        """
+        self.db_path = db_path
+        
+        # 初始化 ConfigManager
+        actual_db = Path(db_path) if db_path else get_db_path()
+        if not actual_db.exists():
+            raise FileNotFoundError(
+                f"\u914d\u7f6e\u6570\u636e\u5e93\u4e0d\u5b58\u5728: {actual_db}\n"
+                f"\u8bf7\u5148\u8fd0\u884c\u8fc1\u79fb\u811a\u672c: python tool/migrate_yaml_to_db.py"
+            )
+        self.config_manager = ConfigManager(str(actual_db))
+        print(f"\u914d\u7f6e\u6e90: SQLite ({actual_db})")
+        
+        self.config = self._load_config()
+        
+        # 冷启动模式标记
+        self.cold_start_mode = False
+        
+        # 初始化多模型预测器(支持每个设备独立模型)
+        self.multi_predictor = MultiModelPredictor()
+        self.predictor = None  # 兼容性保留,已废弃
+        
+        # 从配置中注册所有设备的模型目录映射
+        print("\n正在初始化多模型预测器...")
+        for plant in self.config.get('plants', []):
+            if not plant.get('enabled', False):
+                continue
+            for stream in plant.get('rtsp_streams', []):
+                device_code = stream.get('device_code', '')
+                model_subdir = stream.get('model_subdir', device_code)
+                if device_code and model_subdir:
+                    self.multi_predictor.register_device(device_code, model_subdir)
+                    print(f"  注册设备: {device_code} -> models/{model_subdir}/")
+        
+        print(f"已注册 {len(self.multi_predictor.registered_devices)} 个设备模型映射")
+        
+        # 进程和监控器列表
+        self.ffmpeg_processes = []
+        self.monitors = []
+        
+        # 信号处理
+        signal.signal(signal.SIGINT, self._signal_handler)
+        signal.signal(signal.SIGTERM, self._signal_handler)
+    
+    def _load_config(self):
+        """
+        从 SQLite DB 加载配置
+        
+        返回:
+            Dict: 配置字典
+        """
+        config = self.config_manager.get_full_config()
+        logger.info("配置已从 SQLite 加载")
+        return config
+    
+    def _parse_rtsp_streams(self):
+        """
+        解析配置文件中的RTSP流信息
+        
+        返回:
+            List[RTSPStreamConfig]: RTSP流配置列表
+        """
+        streams = []
+        
+        plants = self.config.get('plants', [])
+        if not plants:
+            raise ValueError("配置文件中未找到水厂配置")
+        
+        for plant in plants:
+            plant_name = plant.get('name')
+            if not plant_name:
+                print("警告: 跳过未命名的区域配置")
+                continue
+            
+            # 检查是否启用该水厂(默认启用以兼容旧配置)
+            if not plant.get('enabled', True):
+                logger.debug(f"跳过禁用的水厂: {plant_name}")
+                continue
+            
+            # 获取流量PLC配置
+            flow_plc = plant.get('flow_plc', {})
+            
+            # 获取该水厂的project_id(每个plant有自己的project_id)
+            project_id = plant.get('project_id', 92)
+            logger.info(f"加载区域配置: {plant_name} | project_id={project_id}")
+            
+            rtsp_streams = plant.get('rtsp_streams', [])
+            for stream in rtsp_streams:
+                url = stream.get('url')
+                channel = stream.get('channel')
+                camera_name = stream.get('name', '')
+                device_code = stream.get('device_code', '')
+                pump_name = stream.get('pump_name', '')
+                
+                if not url or channel is None:
+                    print(f"警告: 跳过不完整的RTSP流配置 (区域: {plant_name})")
+                    continue
+                
+                streams.append(RTSPStreamConfig(
+                    plant_name=plant_name,
+                    rtsp_url=url,
+                    channel=channel,
+                    camera_name=camera_name,
+                    device_code=device_code,
+                    pump_name=pump_name,
+                    flow_plc=flow_plc,
+                    project_id=project_id
+                ))
+        
+        return streams
+    
+    def start(self):
+        """
+        启动监控系统
+        """
+        print("=" * 70)
+        print("拾音器异响检测系统")
+        print("=" * 70)
+        
+        # 解析流配置
+        streams = self._parse_rtsp_streams()
+        print(f"\n共配置 {len(streams)} 个拾音设备:")
+        for stream in streams:
+            print(f"  - {stream.device_code} | {stream.camera_name}")
+        
+        # 启动FFmpeg进程
+        print("\n启动FFmpeg进程...")
+        for stream in streams:
+            ffmpeg = FFmpegProcess(stream, CFG.AUDIO_DIR, self.config)
+            if ffmpeg.start():
+                self.ffmpeg_processes.append(ffmpeg)
+            else:
+                print(f"警告: FFmpeg启动失败,跳过该流: {stream}")
+        
+        if not self.ffmpeg_processes:
+            print("\n错误: 所有FFmpeg进程均启动失败")
+            sys.exit(1)
+        
+        print(f"\n成功启动 {len(self.ffmpeg_processes)}/{len(streams)} 个FFmpeg进程")
+        
+        # 收集所有流配置(用于PickupMonitor)
+        all_stream_configs = [p.stream_config for p in self.ffmpeg_processes]
+        
+        # 启动监控线程(统一一个监控器)
+        print("\n启动监控线程...")
+        check_interval = self.config.get('prediction', {}).get('check_interval', 1.0)
+        
+        monitor = PickupMonitor(
+            audio_dir=CFG.AUDIO_DIR,
+            multi_predictor=self.multi_predictor,
+            stream_configs=all_stream_configs,
+            check_interval=check_interval,
+            config=self.config,
+            config_manager=self.config_manager
+        )
+        monitor.start()
+        self.monitors.append(monitor)
+        
+        print(f"\n成功启动监控线程")
+        print("\n" + "=" * 70)
+        print("系统已启动,开始监控...")
+        print("按 Ctrl+C 停止系统")
+        print("=" * 70 + "\n")
+        
+        # FFmpeg重启配置
+        max_restart_attempts = 5        # 单个进程最大重启次数
+        restart_interval_base = 30    # 基础重启间隔(秒)-> 改为30s
+        restart_counts = {id(p): 0 for p in self.ffmpeg_processes}  # 重启计数
+        
+        # 主循环(带自动重启)
+        try:
+            while True:
+                # 检查每个FFmpeg进程状态
+                for ffmpeg in self.ffmpeg_processes:
+                    if not ffmpeg.is_running():
+                        pid = id(ffmpeg)
+                        device_code = ffmpeg.stream_config.device_code
+                        
+                        # 检查重启次数
+                        if restart_counts.get(pid, 0) < max_restart_attempts:
+                            # 计算等待时间(指数退避)
+                            wait_time = restart_interval_base * (2 ** restart_counts.get(pid, 0))
+                            logger.warning(f"FFmpeg进程停止: {device_code} | 将在{wait_time}秒后重启 | "
+                                         f"重试次数: {restart_counts.get(pid, 0) + 1}/{max_restart_attempts}")
+                            
+                            time.sleep(wait_time)
+                            
+                            # 尝试重启
+                            if ffmpeg.start():
+                                logger.debug(f"FFmpeg重启成功: {device_code}")
+                                restart_counts[pid] = 0  # 重置计数
+                            else:
+                                restart_counts[pid] = restart_counts.get(pid, 0) + 1
+                                logger.error(f"FFmpeg重启失败: {device_code}")
+                        else:
+                            logger.error(f"FFmpeg达到最大重启次数: {device_code} | 已放弃重启")
+                
+                # 检查是否所有进程都已放弃
+                running_count = sum(1 for p in self.ffmpeg_processes if p.is_running())
+                all_abandoned = all(restart_counts.get(id(p), 0) >= max_restart_attempts 
+                                   for p in self.ffmpeg_processes if not p.is_running())
+                
+                if running_count == 0 and all_abandoned:
+                    print("\n错误: 所有FFmpeg进程均已停止且无法重启")
+                    break
+                
+                # 每天0点执行7天清理
+                current_hour = datetime.now().hour
+                current_date = datetime.now().strftime("%Y%m%d")
+                
+                if not hasattr(self, '_last_cleanup_date'):
+                    self._last_cleanup_date = ""
+                
+                # 在0点且今天还没清理过时执行
+                if current_hour == 0 and self._last_cleanup_date != current_date:
+                    logger.info("执行7天过期文件清理...")
+                    for monitor in self.monitors:
+                        monitor._cleanup_old_files(days=7)
+                    self._last_cleanup_date = current_date
+                
+                # 定期打印RTSP状态(每分钟一次)
+                if not hasattr(self, '_last_status_log'):
+                    self._last_status_log = datetime.now()
+                
+                if (datetime.now() - self._last_status_log).total_seconds() >= 60:
+                    running = sum(1 for p in self.ffmpeg_processes if p.is_running())
+                    total = len(self.ffmpeg_processes)
+                    logger.info(f"RTSP状态: {running}/{total} 个FFmpeg进程运行中")
+                    logger.info("─" * 60)
+                    self._last_status_log = datetime.now()
+                
+                time.sleep(10)
+        except KeyboardInterrupt:
+            print("\n\n收到停止信号,正在关闭系统...")
+        finally:
+            self.stop()
+    
+    def stop(self):
+        """
+        停止监控系统
+        """
+        print("\n正在停止监控系统...")
+        
+        print("停止监控线程...")
+        for monitor in self.monitors:
+            monitor.stop()
+        
+        print("停止FFmpeg进程...")
+        for ffmpeg in self.ffmpeg_processes:
+            ffmpeg.stop()
+        
+        print("系统已完全停止")
+    
+    def _signal_handler(self, signum, frame):
+        """
+        信号处理函数
+        """
+        print(f"\n\n收到信号 {signum},正在关闭系统...")
+        self.stop()
+        sys.exit(0)
+
+
+def _start_config_api_server(config_manager, multi_predictor=None, port=18080):
+    # 在后台线程中启动 FastAPI 配置管理 API
+    try:
+        import uvicorn
+        init_config_api(config_manager, multi_predictor)
+        logger.info(f"启动配置管理 API: http://0.0.0.0:{port}")
+        uvicorn.run(config_app, host="0.0.0.0", port=port, log_level="warning")
+    except ImportError:
+        logger.warning("uvicorn 未安装,配置管理 API 无法启动。请安装: pip install uvicorn")
+    except Exception as e:
+        logger.error(f"配置管理 API 启动失败: {e}")
+
+
+def main():
+    """
+    主函数
+    """
+    # 检测 DB 是否存在
+    db_path = get_db_path(Path(__file__).parent / "config")
+    if not db_path.exists():
+        print(f"错误: 配置数据库不存在: {db_path}")
+        print(f"\n请先运行迁移脚本: python tool/migrate_yaml_to_db.py")
+        sys.exit(1)
+    
+    try:
+        system = PickupMonitoringSystem()
+        
+        # 后台线程启动配置管理 API
+        api_port = 18080
+        api_thread = threading.Thread(
+            target=_start_config_api_server,
+            args=(system.config_manager, system.multi_predictor, api_port),
+            daemon=True,
+            name="config-api"
+        )
+        api_thread.start()
+        system.start()
+    except FileNotFoundError as e:
+        print(f"\n错误: {e}")
+        print("\n请确保:")
+        print("1. 已完成训练并计算阈值")
+        print("2. 已复制必要的模型文件到 models/ 目录")
+        sys.exit(1)
+    except Exception as e:
+        print(f"\n严重错误: {e}")
+        import traceback
+        traceback.print_exc()
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 277 - 0
start.sh

@@ -0,0 +1,277 @@
+#!/bin/bash
+# ========================================
+# 拾音器异响检测系统启动脚本
+# ========================================
+#
+# 使用方法:
+#   ./start.sh              # 前台运行
+#   ./start.sh -d           # 后台运行
+#   ./start.sh --daemon     # 后台运行
+#   ./start.sh stop         # 停止服务
+#   ./start.sh restart      # 重启服务
+#   ./start.sh status       # 查看状态
+#
+# 日志文件:
+#   前台运行:直接输出到控制台
+#   后台运行:logs/system.log(RotatingFileHandler 自动轮转)
+#
+
+# 切换到脚本所在目录
+cd "$(dirname "$0")"
+
+# PID文件路径
+PID_FILE="logs/pid.txt"
+
+# ========================================
+# 函数:激活conda环境
+# ========================================
+activate_conda() {
+    if command -v conda &> /dev/null; then
+        # 激活 conda 环境
+        source $(conda info --base)/etc/profile.d/conda.sh
+        conda activate pump_asd
+        echo "已激活 conda 环境: pump_asd"
+    fi
+}
+
+# ========================================
+# 函数:检查进程是否运行
+# ========================================
+is_running() {
+    if [ -f "$PID_FILE" ]; then
+        PID=$(cat "$PID_FILE")
+        # 检查进程是否存在
+        if ps -p "$PID" > /dev/null 2>&1; then
+            return 0  # 运行中
+        fi
+    fi
+    return 1  # 未运行
+}
+
+# ========================================
+# 函数:获取当前PID
+# ========================================
+get_pid() {
+    if [ -f "$PID_FILE" ]; then
+        cat "$PID_FILE"
+    else
+        echo ""
+    fi
+}
+
+# ========================================
+# 函数:启动服务
+# ========================================
+start_service() {
+    # 检查是否已经运行
+    if is_running; then
+        echo "服务已在运行中, PID: $(get_pid)"
+        echo "如需重启,请使用: ./start.sh restart"
+        return 1
+    fi
+    
+    # 激活conda环境
+    activate_conda
+    
+    # 检查必要文件
+    if [ ! -f "run_pickup_monitor.py" ]; then
+        echo "错误: run_pickup_monitor.py 不存在"
+        exit 1
+    fi
+    
+    if [ ! -f "config/pickup_config.db" ]; then
+        echo "错误: config/pickup_config.db 不存在"
+        echo "请先运行迁移脚本: python tool/migrate_yaml_to_db.py"
+        exit 1
+    fi
+    
+    # 创建日志目录
+    mkdir -p logs
+    
+    # 启动服务
+    echo "后台运行模式..."
+    # stdout/stderr 丢弃,所有日志由 RotatingFileHandler 写入 logs/system.log
+    nohup python run_pickup_monitor.py > /dev/null 2>&1 &
+    PID=$!
+    echo $PID > "$PID_FILE"
+    
+    # 等待1秒检查是否正常启动
+    sleep 1
+    if ps -p "$PID" > /dev/null 2>&1; then
+        echo "服务启动成功, PID: $PID"
+        echo "日志文件: logs/system.log"
+        echo ""
+        echo "查看日志: tail -f logs/system.log"
+        echo "停止服务: ./start.sh stop"
+        echo "重启服务: ./start.sh restart"
+    else
+        echo "服务启动失败,请检查日志: logs/system.log"
+        rm -f "$PID_FILE"
+        return 1
+    fi
+}
+
+# ========================================
+# 函数:停止服务
+# ========================================
+stop_service() {
+    if ! is_running; then
+        echo "服务未运行"
+        rm -f "$PID_FILE"
+        return 0
+    fi
+    
+    PID=$(get_pid)
+    echo "正在停止服务, PID: $PID"
+    
+    # 发送 SIGTERM 信号,优雅停止
+    kill "$PID" 2>/dev/null
+    
+    # 等待进程结束(最多等待10秒)
+    WAIT_COUNT=0
+    while ps -p "$PID" > /dev/null 2>&1; do
+        if [ $WAIT_COUNT -ge 10 ]; then
+            echo "进程未响应,强制终止..."
+            kill -9 "$PID" 2>/dev/null
+            break
+        fi
+        sleep 1
+        WAIT_COUNT=$((WAIT_COUNT + 1))
+        echo "等待进程结束... ($WAIT_COUNT/10)"
+    done
+    
+    rm -f "$PID_FILE"
+    echo "服务已停止"
+}
+
+# ========================================
+# 函数:重启服务
+# ========================================
+restart_service() {
+    echo "=========================================="
+    echo "重启拾音器异响检测服务"
+    echo "=========================================="
+    
+    stop_service
+    echo ""
+    sleep 2  # 等待2秒确保资源完全释放
+    start_service
+}
+
+# ========================================
+# 函数:查看服务状态
+# ========================================
+show_status() {
+    echo "=========================================="
+    echo "拾音器异响检测服务状态"
+    echo "=========================================="
+    
+    if is_running; then
+        PID=$(get_pid)
+        echo "状态: 运行中"
+        echo "PID:  $PID"
+        echo ""
+        
+        # 显示进程信息
+        echo "进程详情:"
+        ps -p "$PID" -o pid,ppid,user,%cpu,%mem,etime,command | head -2
+        echo ""
+        
+        # 显示最近日志
+        echo "最近10行日志:"
+        echo "------------------------------------------"
+        tail -10 logs/system.log 2>/dev/null || echo "(无日志)"
+    else
+        echo "状态: 未运行"
+        if [ -f "$PID_FILE" ]; then
+            echo "注意: PID文件存在但进程已停止,可能是异常退出"
+            rm -f "$PID_FILE"
+        fi
+    fi
+}
+
+# ========================================
+# 函数:前台运行
+# ========================================
+run_foreground() {
+    # 检查是否已经运行
+    if is_running; then
+        echo "服务已在后台运行中, PID: $(get_pid)"
+        echo "请先停止: ./start.sh stop"
+        return 1
+    fi
+    
+    # 激活conda环境
+    activate_conda
+    
+    # 检查必要文件
+    if [ ! -f "run_pickup_monitor.py" ]; then
+        echo "错误: run_pickup_monitor.py 不存在"
+        exit 1
+    fi
+    
+    if [ ! -f "config/pickup_config.db" ]; then
+        echo "错误: config/pickup_config.db 不存在"
+        echo "请先运行迁移脚本: python tool/migrate_yaml_to_db.py"
+        exit 1
+    fi
+    
+    # 创建日志目录
+    mkdir -p logs
+    
+    echo "前台运行模式..."
+    python run_pickup_monitor.py
+}
+
+# ========================================
+# 函数:显示帮助
+# ========================================
+show_help() {
+    echo "拾音器异响检测系统 - 启动脚本"
+    echo ""
+    echo "用法: ./start.sh [命令]"
+    echo ""
+    echo "命令:"
+    echo "  (无参数)    前台运行"
+    echo "  -d, --daemon  后台运行"
+    echo "  start       后台启动服务"
+    echo "  stop        停止服务"
+    echo "  restart     重启服务"
+    echo "  status      查看服务状态"
+    echo "  help        显示帮助信息"
+    echo ""
+    echo "示例:"
+    echo "  ./start.sh -d        # 后台启动"
+    echo "  ./start.sh restart   # 重启服务"
+    echo "  ./start.sh status    # 查看状态"
+}
+
+# ========================================
+# 主逻辑
+# ========================================
+case "$1" in
+    stop)
+        stop_service
+        ;;
+    restart)
+        restart_service
+        ;;
+    status)
+        show_status
+        ;;
+    start|-d|--daemon)
+        start_service
+        ;;
+    help|--help|-h)
+        show_help
+        ;;
+    "")
+        run_foreground
+        ;;
+    *)
+        echo "未知命令: $1"
+        echo ""
+        show_help
+        exit 1
+        ;;
+esac

+ 182 - 0
tool/migrate_yaml_to_db.py

@@ -0,0 +1,182 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+import sys
+import yaml
+import logging
+from pathlib import Path
+
+# 将项目根目录加入 sys.path
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from config.config_manager import ConfigManager
+from config.db_models import get_db_path, get_connection
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
+logger = logging.getLogger(__name__)
+
+
+def _clear_all_tables(db_path):
+    # 清空所有业务表数据,保留表结构
+    conn = get_connection(db_path)
+    try:
+        # 按外键依赖顺序删除(先子表后父表)
+        tables = ['pump_status_plc', 'flow_plc', 'rtsp_stream', 'system_config', 'plant']
+        for table in tables:
+            conn.execute(f"DELETE FROM {table}")
+        conn.commit()
+        logger.info("已清空所有配置数据")
+    finally:
+        conn.close()
+
+
+def migrate_yaml_to_db(yaml_path: str, db_path: str = None, force: bool = False):
+    """
+    将 rtsp_config.yaml 中的全部数据迁移到 SQLite 数据库
+
+    Args:
+        yaml_path: YAML 配置文件路径
+        db_path: 数据库路径(默认 config/pickup_config.db)
+        force: 是否清空现有数据后重建(幂等操作)
+    """
+    yaml_file = Path(yaml_path)
+    if not yaml_file.exists():
+        logger.error(f"YAML 文件不存在: {yaml_file}")
+        sys.exit(1)
+
+    with open(yaml_file, 'r', encoding='utf-8') as f:
+        config = yaml.safe_load(f)
+
+    logger.info(f"已读取 YAML: {yaml_file}")
+
+    # 解析实际 DB 路径(用于 _clear_all_tables)
+    actual_db_path = Path(db_path) if db_path else get_db_path()
+
+    # --force 模式:先清空所有数据再导入
+    if force:
+        if actual_db_path.exists():
+            _clear_all_tables(actual_db_path)
+        logger.info("--force 模式:清空后重建")
+
+    # 初始化 ConfigManager(自动创建表结构)
+    mgr = ConfigManager(db_path)
+
+    # ========================================
+    # 1. 迁移水厂和关联数据
+    # ========================================
+    plants = config.get('plants', [])
+    logger.info(f"开始迁移 {len(plants)} 个水厂配置...")
+
+    for plant_data in plants:
+        plant_name = plant_data.get('name', '')
+        if not plant_name:
+            logger.warning("跳过无名称的水厂配置")
+            continue
+
+        plant_id = mgr.create_plant(
+            name=plant_name,
+            project_id=plant_data.get('project_id', 0),
+            push_url=plant_data.get('push_url', ''),
+            enabled=plant_data.get('enabled', False)
+        )
+        logger.info(f"  水厂: {plant_name} (id={plant_id})")
+
+        # 流量 PLC 映射
+        flow_plc = plant_data.get('flow_plc', {})
+        if flow_plc:
+            for pump_name, plc_address in flow_plc.items():
+                mgr.set_flow_plc(plant_id, pump_name, plc_address)
+            logger.info(f"    流量PLC: {len(flow_plc)} 条")
+
+        # 泵状态 PLC 点位
+        pump_status_plc = plant_data.get('pump_status_plc', {})
+        if pump_status_plc:
+            total_points = 0
+            for pump_name, points in pump_status_plc.items():
+                for point_data in points:
+                    mgr.add_pump_status_plc(
+                        plant_id,
+                        pump_name,
+                        point_data.get('point', ''),
+                        point_data.get('name', '')
+                    )
+                    total_points += 1
+            logger.info(f"    泵状态PLC: {total_points} 条")
+
+        # RTSP 流
+        streams = plant_data.get('rtsp_streams', [])
+        for stream in streams:
+            url = stream.get('url', '')
+            if not url:
+                logger.warning(f"    跳过空URL的流: {stream.get('name', '未知')}")
+                continue
+            mgr.create_stream(
+                plant_id=plant_id,
+                name=stream.get('name', ''),
+                url=url,
+                channel=stream.get('channel', 0),
+                device_code=stream.get('device_code', ''),
+                pump_name=stream.get('pump_name', ''),
+                model_subdir=stream.get('model_subdir', ''),
+                enabled=True
+            )
+        logger.info(f"    RTSP流: {len(streams)} 条")
+
+    # ========================================
+    # 2. 迁移系统级配置
+    # ========================================
+    system_sections = ['audio', 'prediction', 'push_notification', 'scada_api', 'human_detection']
+
+    for section in system_sections:
+        section_data = config.get(section, {})
+        if section_data:
+            mgr.update_section_config(section, section_data)
+            flat = mgr._flatten_dict(section_data)
+            logger.info(f"系统配置 [{section}]: {len(flat)} 项")
+
+    # ========================================
+    # 3. 验证迁移结果
+    # ========================================
+    logger.info("\n" + "=" * 60)
+    logger.info("迁移完成,开始验证...")
+
+    restored = mgr.get_full_config()
+
+    orig_plants = [p for p in config.get('plants', [])]
+    db_plants = restored.get('plants', [])
+    logger.info(f"水厂数量: YAML={len(orig_plants)}, DB={len(db_plants)}")
+
+    for section in system_sections:
+        orig_flat = mgr._flatten_dict(config.get(section, {}))
+        db_flat = mgr._flatten_dict(restored.get(section, {}))
+        match = len(orig_flat) == len(db_flat)
+        status = "OK" if match else "FAIL"
+        logger.info(f"配置 [{section}]: YAML={len(orig_flat)}项, DB={len(db_flat)}项 {status}")
+
+    for i, (orig_p, db_p) in enumerate(zip(orig_plants, db_plants)):
+        orig_streams = len(orig_p.get('rtsp_streams', []))
+        db_streams = len(db_p.get('rtsp_streams', []))
+        name = orig_p.get('name', f'水厂{i}')
+        status = "OK" if orig_streams == db_streams else "FAIL"
+        logger.info(f"  {name}: RTSP流 YAML={orig_streams}, DB={db_streams} {status}")
+
+    logger.info("=" * 60)
+    logger.info("迁移验证完成")
+
+    mgr.close()
+
+
+if __name__ == '__main__':
+    import argparse
+
+    parser = argparse.ArgumentParser(description='YAML 配置迁移至 SQLite')
+    parser.add_argument('--yaml', type=str,
+                        default=str(PROJECT_ROOT / 'config' / 'rtsp_config.yaml'),
+                        help='YAML 配置文件路径')
+    parser.add_argument('--db', type=str, default=None,
+                        help='SQLite 数据库路径(默认: config/pickup_config.db)')
+    parser.add_argument('--force', action='store_true',
+                        help='清空现有数据后重建(幂等操作),适合重复导入同一份 YAML')
+    args = parser.parse_args()
+
+    migrate_yaml_to_db(args.yaml, args.db, force=args.force)