新增 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 用例)全通过;纯标准库零运行时依赖。
181 lines
7.3 KiB
Python
181 lines
7.3 KiB
Python
# -*- 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)
|