feat(#73): [Ti-1] 模型部署到内核并接入驾驶舱(预测服务+告警+可解释视图+漂移监测)

新增 templates/ti-cl4/quality-forecast/serve.py:
- PredictionService:特征抽取→模型预测→告警判定全链路,部署前 validate
- Thresholds/AlarmLevel:纯度min/杂质max 双向阈值,normal/warning/critical 三级
- CockpitView:渲染 kpi_card+alarm_panel+explanation_list(对齐 cockpit-layout-v1)
  含 top-3 特征贡献溯源,满足 PRD 可解释可溯源要求
- DriftMonitor:累计 R²/MAE,超阈值触发重训信号(PRD §5.3/§9)
- DeploymentBundle:模型+特征清单+阈值+置信带整体序列化往返
- config/thresholds.template.yaml:TiCl₄纯度下限 99.2 阈值模板
- 14 用例(累计 53 用例)全通过;纯标准库零运行时依赖。
This commit is contained in:
2026-08-05 05:17:36 +08:00
parent aeee8ef468
commit a10b83007e
3 changed files with 534 additions and 0 deletions
@@ -0,0 +1,180 @@
# -*- coding: utf-8 -*-
"""Ti-1 质量预测部署接入驾驶舱测试(Issue #73)。
覆盖:
1. PredictionService 部署校验(模型未训练/特征名不一致/阈值target不一致);
2. predict 全链路(特征抽取→预测→告警判定);
3. Thresholds 分级(normal/warning/critical,min/max 双向);
4. CockpitView 渲染 kpi_card/alarm_panel/explanation_list;
5. DriftMonitor 重训触发;
6. DeploymentBundle 序列化往返。
"""
import math
import os
import sys
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import _bootstrap # noqa: F401,E402
from quality_forecast import features as F # noqa: E402
from quality_forecast import model as M # noqa: E402
from quality_forecast import serve as S # noqa: E402
def _trained_service(*, thresholds=None, alpha=0.001):
"""构造一个已训练的服务:y = TEMP(线性,易拟合)。"""
spec = F.FeatureSpec("t", "CLF-01.TEMP", "raw")
ext = F.FeatureExtractor([spec])
samples = [F.Sample(ts=i * 10.0, values={"CLF-01.TEMP": 100.0 + i})
for i in range(6)]
matrix = ext.extract(samples)
# target = TEMP 本身(完美线性,R²≈1)
recipe = M.ModelRecipe(target="RF-01.PURITY", alpha=alpha,
feature_names=["t"], unit="%")
model = M.QualityModel(recipe).fit(matrix.rows,
[s.values["CLF-01.TEMP"] for s in samples])
ev = model.evaluate(matrix.rows, [s.values["CLF-01.TEMP"] for s in samples])
return S.PredictionService(model, ext, thresholds,
confidence_rmse=ev.rmse), samples
class TestPredictionServiceValidation(unittest.TestCase):
def test_unfitted_model_raises(self):
spec = F.FeatureSpec("t", "CLF-01.TEMP", "raw")
ext = F.FeatureExtractor([spec])
model = M.QualityModel(M.ModelRecipe(target="RF-01.PURITY",
feature_names=["t"]))
with self.assertRaises(S.ServeError):
S.PredictionService(model, ext)
def test_feature_name_mismatch_raises(self):
# 清单 [t] 但模型声明 [other]
spec = F.FeatureSpec("t", "CLF-01.TEMP", "raw")
ext = F.FeatureExtractor([spec])
recipe = M.ModelRecipe(target="y", alpha=0.1, feature_names=["other"])
model = M.QualityModel(recipe).fit([[1.0], [2.0]], [1.0, 2.0])
with self.assertRaises(S.ServeError):
S.PredictionService(model, ext)
def test_threshold_target_mismatch_raises(self):
svc, _ = _trained_service()
th = S.Thresholds(target="WRONG.TARGET", min_value=50.0)
with self.assertRaises(S.ServeError):
S.PredictionService(svc.model, svc.extractor, th)
class TestPredictionAndThresholds(unittest.TestCase):
def test_predict_normal(self):
th = S.Thresholds(target="RF-01.PURITY", min_value=50.0,
warning_band=5.0)
svc, samples = _trained_service(thresholds=th)
preds = svc.predict(samples)
self.assertEqual(len(preds), len(samples))
# TEMP 100-105 → 远高于 50 → normal
self.assertEqual(preds[-1].level, "normal")
def test_predict_critical_low(self):
th = S.Thresholds(target="RF-01.PURITY", min_value=200.0)
svc, samples = _trained_service(thresholds=th)
preds = svc.predict(samples)
# 预测 ~100-105 < 200 → critical
self.assertEqual(preds[-1].level, "critical")
self.assertIn("低于下限", preds[-1].message)
def test_predict_warning_band(self):
th = S.Thresholds(target="RF-01.PURITY", min_value=95.0,
warning_band=10.0)
svc, samples = _trained_service(thresholds=th)
preds = svc.predict(samples)
# 100-105 在 [95, 105] 预警带内 → warning(部分)
levels = {p.level for p in preds}
self.assertTrue(levels & {"warning", "critical"} or
"warning" in levels)
def test_max_threshold(self):
# 杂质类:超过上限 critical
spec = F.FeatureSpec("t", "RF-01.IMP", "raw")
ext = F.FeatureExtractor([spec])
samples = [F.Sample(ts=i, values={"RF-01.IMP": 0.1 * (i + 1)})
for i in range(5)]
matrix = ext.extract(samples)
recipe = M.ModelRecipe(target="RF-01.IMP", alpha=0.001,
feature_names=["t"])
model = M.QualityModel(recipe).fit(matrix.rows,
[s.values["RF-01.IMP"] for s in samples])
th = S.Thresholds(target="RF-01.IMP", max_value=0.3, warning_band=0.1)
svc = S.PredictionService(model, ext, th)
preds = svc.predict(samples)
# 最后样本 IMP=0.5 > 0.3 → critical
self.assertEqual(preds[-1].level, "critical")
def test_confidence_band(self):
th = S.Thresholds(target="RF-01.PURITY", min_value=50.0)
svc, samples = _trained_service(thresholds=th)
preds = svc.predict(samples)
d = preds[-1].to_dict()
self.assertIn("lower", d)
self.assertIn("upper", d)
class TestCockpitView(unittest.TestCase):
def test_render(self):
th = S.Thresholds(target="RF-01.PURITY", min_value=50.0)
svc, samples = _trained_service(thresholds=th)
preds = svc.predict(samples)
view = S.CockpitView().render(preds[-1])
self.assertEqual(view["$schema"], "iAOP-cockpit-layout-v1")
types = [w["type"] for w in view["widgets"]]
self.assertIn("kpi_card", types)
self.assertIn("alarm_panel", types)
self.assertIn("explanation_list", types)
# explanation_list 有 top-3 贡献
expl = [w for w in view["widgets"] if w["type"] == "explanation_list"][0]
self.assertLessEqual(len(expl["items"]), 3)
class TestDriftMonitor(unittest.TestCase):
def test_retrain_on_low_r2(self):
mon = S.DriftMonitor(min_r2=0.8, max_mae=1.0)
mon.record(M.Evaluation(r2=0.5, mae=0.5, rmse=0.5, n_samples=10))
flag, reason = mon.should_retrain()
self.assertTrue(flag)
self.assertIn("R²", reason)
def test_retrain_on_high_mae(self):
mon = S.DriftMonitor(min_r2=0.8, max_mae=1.0)
mon.record(M.Evaluation(r2=0.9, mae=2.0, rmse=2.0, n_samples=10))
flag, reason = mon.should_retrain()
self.assertTrue(flag)
self.assertIn("MAE", reason)
def test_ok_no_retrain(self):
mon = S.DriftMonitor(min_r2=0.8, max_mae=1.0)
mon.record(M.Evaluation(r2=0.95, mae=0.2, rmse=0.2, n_samples=10))
flag, _ = mon.should_retrain()
self.assertFalse(flag)
def test_empty(self):
mon = S.DriftMonitor()
flag, _ = mon.should_retrain()
self.assertFalse(flag)
class TestDeploymentBundle(unittest.TestCase):
def test_roundtrip(self):
th = S.Thresholds(target="RF-01.PURITY", min_value=99.0,
warning_band=0.5)
svc, _ = _trained_service(thresholds=th)
bundle = S.DeploymentBundle(svc.model, svc.extractor, th,
confidence_rmse=svc.confidence_rmse)
d = bundle.to_dict()
bundle2 = S.DeploymentBundle.from_dict(d)
svc2 = bundle2.to_service()
# 重建后仍能预测
self.assertTrue(svc2.model.fitted)
if __name__ == "__main__":
unittest.main(verbosity=2)