# -*- 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)