Files
iAOP/templates/ti-cl4/quality-forecast/tests/test_serve.py
T
bot_dev1 a10b83007e 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 用例)全通过;纯标准库零运行时依赖。
2026-08-05 05:17:36 +08:00

181 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)