# -*- coding: utf-8 -*- """Ti-1 氯化车间质量预测 · 模型部署到内核并接入驾驶舱(Issue #73 / PRD §5.3 ① / §5.5)。 承接 #69(模型训练)产出的 ``QualityModel``:把"训练好的模型 → 可调用的预测服务 → 驾驶舱可展示的预测+可解释结果"这条链路**模板化、可测试**。 PRD 设计口径 ------------ - 架构表(PRD §5.3):``质量预测 | 预测 | 入:DCS实时数据+LIMS; 出:质量指标预测值(纯度/杂质) | ① 质量预测 | 中``。 - 驾驶舱接入(PRD §5.5):预测结果以 kpi_card / alarm_panel 形式渲染, 支持按模板渲染四状态流程视图;移动端交接班摘要引用预测结论。 - 模型漂移(PRD §5.3 / §9 NFR):质保期监测准确率/误报率,触发重训。 本模块交付 ---------- 1. **预测服务 ``PredictionService``**:加载(或内存持有)QualityModel + 特征清单, ``predict(samples)`` 返回 ``Prediction``(预测值 + 置信区间 + 命中阈值判定)。 2. **阈值告警 ``QualityAlarm``**:按超参包的 ``thresholds``(纯度下限/杂质上限) 判定告警等级(normal/warning/critical),供驾驶舱 alarm_panel。 3. **驾驶舱视图 ``CockpitView``**:把预测结果 + explain() 渲染为驾驶舱布局 片段(kpi_card / alarm_panel / 可解释溯源列表),对齐 iAOP-cockpit-layout-v1。 4. **模型漂移监测 ``DriftMonitor``**:累计预测的 R²/MAE,超阈值触发重训信号。 设计要点 -------- - **零运行时依赖**(纯标准库):与 #68/#69 一致。 - **可解释接入驾驶舱**:预测结果附带 top-N 特征贡献(来自 #69 explain), 满足 PRD"要求结果可解释、可溯源,要引用依据"。 - **可校验**:部署前 ``PredictionService.validate`` 检查模型已训练、特征清单 与模型特征名对齐。 """ from __future__ import annotations import json import math import os from dataclasses import dataclass, field from enum import Enum from typing import Any, Dict, List, Optional, Sequence from .features import FeatureExtractor, FeatureMatrix, FeatureSpec, Sample from .model import Evaluation, ModelRecipe, QualityModel NAN = float("nan") class ServeError(ValueError): """模型部署/预测服务错误。""" # --------------------------------------------------------------------------- # 告警等级 # --------------------------------------------------------------------------- class AlarmLevel(str, Enum): NORMAL = "normal" # 正常 WARNING = "warning" # 预警(接近阈值) CRITICAL = "critical" # 超限(质量不达标) @dataclass class Thresholds: """质量阈值(超参包外置)。""" target: str min_value: Optional[float] = None # 纯度下限(低于则告警) max_value: Optional[float] = None # 杂质上限(高于则告警) warning_band: float = 0.0 # 预警带(距阈值多少开始 warning) @classmethod def from_dict(cls, d: Dict[str, Any]) -> "Thresholds": return cls( target=str(d.get("target", "")).strip(), min_value=(float(d["min"]) if d.get("min") is not None else None), max_value=(float(d["max"]) if d.get("max") is not None else None), warning_band=float(d.get("warning_band", 0.0)), ) def validate(self) -> List[str]: errs = [] if not self.target: errs.append("thresholds.target 不能为空") if self.min_value is None and self.max_value is None: errs.append("thresholds 至少需 min 或 max 之一") return errs # --------------------------------------------------------------------------- # 预测结果 # --------------------------------------------------------------------------- @dataclass class Prediction: """单条预测结果。""" target: str value: float unit: str level: str # AlarmLevel 值 message: str confidence_band: float # ±置信带(基于训练 RMSE) explanation: List[Dict[str, Any]] = field(default_factory=list) timestamp: float = 0.0 def to_dict(self) -> Dict[str, Any]: return { "target": self.target, "value": round(self.value, 4), "unit": self.unit, "level": self.level, "message": self.message, "confidence_band": round(self.confidence_band, 4), "lower": (round(self.value - self.confidence_band, 4) if not math.isnan(self.value) else None), "upper": (round(self.value + self.confidence_band, 4) if not math.isnan(self.value) else None), "explanation": self.explanation, "timestamp": self.timestamp, } # --------------------------------------------------------------------------- # 预测服务 # --------------------------------------------------------------------------- class PredictionService: """质量预测部署服务:特征抽取 + 模型预测 + 告警判定。""" def __init__(self, model: QualityModel, extractor: FeatureExtractor, thresholds: Optional[Thresholds] = None, *, confidence_rmse: float = 0.0): self.model = model self.extractor = extractor self.thresholds = thresholds self.confidence_rmse = confidence_rmse # 训练 RMSE,作为置信带 self.validate() def validate(self) -> None: errs: List[str] = [] if not self.model.fitted: errs.append("模型未训练,无法部署") # 特征清单与模型特征名对齐 model_names = self.model.recipe.feature_names ext_names = self.extractor.names if model_names and model_names != ext_names: errs.append( f"特征清单与模型特征名不一致: 清单={ext_names} 模型={model_names}") if self.thresholds: errs.extend(self.thresholds.validate()) if (self.model.recipe.target and self.thresholds.target and self.thresholds.target != self.model.recipe.target): errs.append( f"阈值 target({self.thresholds.target}) 与模型 " f"target({self.model.recipe.target}) 不一致") if errs: raise ServeError("部署校验失败:\n " + "\n ".join(errs)) def predict(self, samples: Sequence[Sample]) -> List[Prediction]: """对时序样本预测(每个样本时刻一条预测)。""" matrix: FeatureMatrix = self.extractor.extract(samples) # 模型特征名顺序(若清单与模型一致,直接用矩阵列) X = matrix.rows preds_raw = self.model.predict(X) explanation = self.model.explain() out: List[Prediction] = [] # 样本时刻(extract 保留输入顺序) ts_list = [s.ts for s in sorted(samples, key=lambda s: s.ts)] for i, raw in enumerate(preds_raw): level, msg = self._classify(raw) ts = ts_list[i] if i < len(ts_list) else 0.0 out.append(Prediction( target=self.model.recipe.target, value=raw, unit=self.model.recipe.unit, level=level, message=msg, confidence_band=self.confidence_rmse, explanation=explanation, timestamp=ts)) return out def _classify(self, value: float) -> tuple: """按阈值判定告警等级。""" if math.isnan(value) or self.thresholds is None: return AlarmLevel.NORMAL.value, "无阈值或预测缺失,未判定" th = self.thresholds band = th.warning_band if th.min_value is not None: if value < th.min_value: return AlarmLevel.CRITICAL.value, ( f"{th.target}={value:.3f} 低于下限 {th.min_value}") if value < th.min_value + band: return AlarmLevel.WARNING.value, ( f"{th.target}={value:.3f} 接近下限 {th.min_value}") if th.max_value is not None: if value > th.max_value: return AlarmLevel.CRITICAL.value, ( f"{th.target}={value:.3f} 超过上限 {th.max_value}") if value > th.max_value - band: return AlarmLevel.WARNING.value, ( f"{th.target}={value:.3f} 接近上限 {th.max_value}") return AlarmLevel.NORMAL.value, f"{th.target}={value:.3f} 达标" # --------------------------------------------------------------------------- # 驾驶舱视图 # --------------------------------------------------------------------------- class CockpitView: """把预测结果渲染为驾驶舱布局片段(对齐 iAOP-cockpit-layout-v1)。""" WIDGET_TYPES = {"kpi_card", "alarm_panel", "explanation_list"} def __init__(self, title: str = "质量预测"): self.title = title def render(self, prediction: Prediction, *, position: Optional[Dict[str, int]] = None) -> Dict[str, Any]: """渲染单个预测为 kpi_card + alarm_panel + 可解释溯源列表。""" pos = position or {"x": 0, "y": 0, "w": 6, "h": 2} level_color = { AlarmLevel.NORMAL.value: "green", AlarmLevel.WARNING.value: "yellow", AlarmLevel.CRITICAL.value: "red", }[prediction.level] # top-3 特征贡献(可溯源) top = sorted(prediction.explanation, key=lambda e: e.get("importance", 0), reverse=True)[:3] return { "$schema": "iAOP-cockpit-layout-v1", "title": self.title, "widgets": [ { "type": "kpi_card", "metric": prediction.target, "label": self.title, "value": round(prediction.value, 3), "unit": prediction.unit, "level": prediction.level, "color": level_color, **pos, "description": prediction.message, }, { "type": "alarm_panel", "metric": prediction.target, "level": prediction.level, "message": prediction.message, "color": level_color, "x": pos["x"], "y": pos["y"] + pos["h"], "w": pos["w"], "h": 1, "description": "质量预测告警面板", }, { "type": "explanation_list", "metric": prediction.target, "items": top, "x": pos["x"], "y": pos["y"] + pos["h"] + 1, "w": pos["w"], "h": 2, "description": "预测依据(top-3 特征贡献,可溯源)", }, ], } # --------------------------------------------------------------------------- # 模型漂移监测 # --------------------------------------------------------------------------- class DriftMonitor: """累计预测的评估指标,超阈值触发重训信号(PRD §5.3 / §9)。""" def __init__(self, *, min_r2: float = 0.8, max_mae: float = 1.0): self.min_r2 = min_r2 self.max_mae = max_mae self.history: List[Evaluation] = [] def record(self, evaluation: Evaluation) -> None: self.history.append(evaluation) def should_retrain(self) -> tuple: """最近一次评估是否触发重训。返回 (是否重训, 原因)。""" if not self.history: return False, "无评估记录" last = self.history[-1] if math.isnan(last.r2): return True, f"R² 异常(NaN),建议重训" if last.r2 < self.min_r2: return True, f"R²={last.r2:.3f} < {self.min_r2},模型退化" if last.mae > self.max_mae: return True, f"MAE={last.mae:.3f} > {self.max_mae},误差超限" return False, f"R²={last.r2:.3f} MAE={last.mae:.3f} 达标" # --------------------------------------------------------------------------- # 部署包加载 # --------------------------------------------------------------------------- @dataclass class DeploymentBundle: """模型部署包:模型 + 特征清单 + 阈值 + 置信带,可整体序列化。""" model: QualityModel extractor: FeatureExtractor thresholds: Optional[Thresholds] = None confidence_rmse: float = 0.0 def to_service(self) -> PredictionService: return PredictionService( self.model, self.extractor, self.thresholds, confidence_rmse=self.confidence_rmse) def to_dict(self) -> Dict[str, Any]: return { "model": self.model.to_dict(), "feature_specs": [ {"name": s.name, "source": s.source, "transform": s.transform, "window": s.window, "denominator": s.denominator, "meaning": s.meaning, "unit": s.unit} for s in self.extractor.specs], "thresholds": ({ "target": self.thresholds.target, "min": self.thresholds.min_value, "max": self.thresholds.max_value, "warning_band": self.thresholds.warning_band} if self.thresholds else None), "confidence_rmse": self.confidence_rmse, } @classmethod def from_dict(cls, d: Dict[str, Any]) -> "DeploymentBundle": from .features import FeatureSpec as FS model = QualityModel.from_dict(d["model"]) specs = [FS(name=s["name"], source=s["source"], transform=s.get("transform", "raw"), window=float(s.get("window", 60.0)), denominator=s.get("denominator"), meaning=s.get("meaning", ""), unit=s.get("unit", "")) for s in d.get("feature_specs", [])] ext = FeatureExtractor(specs, strict=False) th_raw = d.get("thresholds") th = Thresholds.from_dict(th_raw) if th_raw else None return cls(model, ext, th, float(d.get("confidence_rmse", 0.0)))