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:
@@ -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)
|
||||
Reference in New Issue
Block a user