Merge PR #112/#113 (feat #71 无监督模型 + #72 告警规则,与 #70 特征工程联合)

This commit is contained in:
2026-08-05 08:21:41 +08:00
parent 234a476ec0
commit a0b21866fe
8 changed files with 1261 additions and 3 deletions
@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
"""炉层杂质预警规则引擎单元测试(Issue #72)。
覆盖:
- AlertCondition 运算匹配(含缺失值不触发、未知 op 拒绝);
- AlertRule AND 语义、空条件拒绝、id 重复拒绝;
- AlertSeverity 排序(primary_alert 取最高);
- AlertRuleEngine.evaluate 命中(多规则按 severity 降序);
- 模板配置 YAML 加载(含 flow map condition、错误 severity/op 拒绝);
- 端到端:模板资产加载 → 急升温特征向量 → P0 命中(场景A 红色告警)。
"""
import math
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _bootstrap # noqa: F401 挂载 impurity_forecast 包
from impurity_forecast import ( # noqa: E402
Alert,
AlertCondition,
AlertRule,
AlertRuleEngine,
AlertSeverity,
load_alert_rules_config,
)
NAN = float("nan")
CONFIG_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"config", "alert_rules.template.yaml")
# ---------------------------------------------------------------------------
# 1. AlertCondition
# ---------------------------------------------------------------------------
class AlertConditionTest(unittest.TestCase):
def test_ops(self):
self.assertTrue(AlertCondition("x", ">", 10).matches({"x": 11}))
self.assertTrue(AlertCondition("x", ">=", 10).matches({"x": 10}))
self.assertTrue(AlertCondition("x", "<", 10).matches({"x": 9}))
self.assertTrue(AlertCondition("x", "<=", 10).matches({"x": 10}))
self.assertTrue(AlertCondition("x", "==", 10).matches({"x": 10}))
self.assertFalse(AlertCondition("x", ">", 10).matches({"x": 10}))
def test_missing_value_not_match(self):
self.assertFalse(AlertCondition("x", ">", 10).matches({"x": NAN}))
self.assertFalse(AlertCondition("x", ">", 10).matches({"y": 100}))
def test_unknown_op_rejected(self):
with self.assertRaises(ValueError):
AlertCondition("x", "!=", 10)
# ---------------------------------------------------------------------------
# 2. AlertRule
# ---------------------------------------------------------------------------
class AlertRuleTest(unittest.TestCase):
def test_and_semantics(self):
rule = AlertRule(id="r1", severity=AlertSeverity.P0, conditions=[
AlertCondition("a", ">", 10),
AlertCondition("b", "<", 5),
])
self.assertTrue(rule.matches({"a": 11, "b": 4}))
self.assertFalse(rule.matches({"a": 11, "b": 6})) # b 不满足
self.assertFalse(rule.matches({"a": 9, "b": 4})) # a 不满足
def test_empty_conditions_rejected(self):
with self.assertRaises(ValueError):
AlertRule(id="r", severity=AlertSeverity.P0, conditions=[])
def test_empty_id_rejected(self):
with self.assertRaises(ValueError):
AlertRule(id="", severity=AlertSeverity.P0,
conditions=[AlertCondition("a", ">", 1)])
def test_describe(self):
rule = AlertRule(id="r1", severity=AlertSeverity.P0, conditions=[
AlertCondition("炉温_ema5", ">", 900.0),
])
self.assertIn("P0", rule.describe())
self.assertIn("炉温_ema5>900.0", rule.describe())
# ---------------------------------------------------------------------------
# 3. AlertRuleEngine
# ---------------------------------------------------------------------------
class AlertRuleEngineTest(unittest.TestCase):
def _engine(self) -> AlertRuleEngine:
return AlertRuleEngine([
AlertRule(id="p0_rule", severity=AlertSeverity.P0, conditions=[
AlertCondition("炉温", ">", 900.0)]),
AlertRule(id="p1_rule", severity=AlertSeverity.P1, conditions=[
AlertCondition("氯气", ">", 8.0)]),
AlertRule(id="p2_rule", severity=AlertSeverity.P2, conditions=[
AlertCondition("炉温", ">", 880.0)]),
])
def test_empty_rules_rejected(self):
with self.assertRaises(ValueError):
AlertRuleEngine([])
def test_duplicate_id_rejected(self):
with self.assertRaises(ValueError):
AlertRuleEngine([
AlertRule(id="dup", severity=AlertSeverity.P0,
conditions=[AlertCondition("a", ">", 1)]),
AlertRule(id="dup", severity=AlertSeverity.P1,
conditions=[AlertCondition("b", ">", 1)]),
])
def test_evaluate_returns_sorted_by_severity(self):
eng = self._engine()
# 炉温=890 同时命中 p2(>880);氯气=10 命中 p1
alerts = eng.evaluate(100, {"炉温": 890.0, "氯气": 10.0})
ids = [a.rule_id for a in alerts]
self.assertEqual(ids, ["p1_rule", "p2_rule"]) # P1 > P2
self.assertEqual([a.severity for a in alerts],
[AlertSeverity.P1, AlertSeverity.P2])
def test_primary_alert_picks_highest(self):
eng = self._engine()
# 炉温=920 命中 p0 + p2 → 主告警 P0
alerts = eng.evaluate(100, {"炉温": 920.0})
primary = eng.primary_alert(alerts)
self.assertIsNotNone(primary)
self.assertEqual(primary.severity, AlertSeverity.P0)
def test_no_hit_returns_empty(self):
eng = self._engine()
self.assertEqual(eng.evaluate(100, {"炉温": 850.0, "氯气": 5.0}), [])
self.assertIsNone(eng.primary_alert([]))
# ---------------------------------------------------------------------------
# 4. 模板配置 YAML 加载
# ---------------------------------------------------------------------------
class ConfigLoadTest(unittest.TestCase):
def test_load_template_config(self):
cfg = load_alert_rules_config(CONFIG_PATH)
self.assertEqual(cfg.template, "ti-cl4")
self.assertGreaterEqual(len(cfg.rules), 5)
ids = [r.id for r in cfg.rules]
self.assertIn("bed_temp_critical", ids)
self.assertIn("cl2_flow_warning", ids)
def test_flow_map_condition_parsed(self):
cfg = load_alert_rules_config(CONFIG_PATH)
r = next(x for x in cfg.rules if x.id == "bed_temp_critical")
self.assertEqual(r.severity, AlertSeverity.P0)
self.assertEqual(r.conditions[0].feature, "炉温_ema5")
self.assertEqual(r.conditions[0].op, ">")
self.assertEqual(r.conditions[0].threshold, 900.0)
self.assertEqual(r.sop, "SOP-CL-001")
def test_engine_from_template_config(self):
eng = AlertRuleEngine.from_template_config(CONFIG_PATH)
# 炉温_ema5=920 命中 bed_temp_critical (P0) + bed_temp_near_limit (P2)
alerts = eng.evaluate(1, {"炉温_ema5": 920.0})
ids = [a.rule_id for a in alerts]
self.assertIn("bed_temp_critical", ids)
self.assertEqual(eng.primary_alert(alerts).severity, AlertSeverity.P0)
# ---------------------------------------------------------------------------
# 5. 端到端:急升温场景命中 P0(PRD 场景A 红色告警)
# ---------------------------------------------------------------------------
class EndToEndScenarioATest(unittest.TestCase):
def test_rising_temp_triggers_p0(self):
"""模拟炉层杂质富集的急升温:规则引擎应在 ema 平滑值越界时产出 P0 告警。"""
eng = AlertRuleEngine.from_template_config(CONFIG_PATH)
# 特征向量:炉温_ema5 越过 900 上限
values = {"炉温_ema5": 905.0, "炉温_rate10": 0.06,
"氯气流量_std10": 5.0, "炉压_rate10": 0.02,
"炉层状态_mean10": 60.0}
alerts = eng.evaluate(timestamp=100, values=values)
primary = eng.primary_alert(alerts)
self.assertIsNotNone(primary, "急升温应触发预警")
self.assertEqual(primary.severity, AlertSeverity.P0,
"主告警应为 P0 红色告警")
# 命中的 P0 规则应有处置 SOP(供 LLM 报警解释 + 值班长确认)
self.assertTrue(any(a.sop for a in alerts if a.severity == AlertSeverity.P0))
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
"""炉层杂质预警无监督模型单元测试(Issue #71)。
覆盖:
- ZScoreScorer:fit 估计 μ/σ、score 异常分数(含 σ=0 恒定列、缺失值、未 fit 拒绝);
- ThresholdRule:分数阈值 ∪ 特征 breach 决策;
- ImpurityForecaster:fit/predict 端到端;
- evaluate_lead_time:提前量评估(对齐 PRD 提前 ≥ 30min);
- 序列化:to_dict/from_dict/save/load 可复现。
"""
import math
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _bootstrap # noqa: F401 挂载 impurity_forecast 包
from impurity_forecast import ( # noqa: E402
AlertDecision,
FeatureVectorLike,
ImpurityForecaster,
ThresholdRule,
ZScoreScorer,
evaluate_lead_time,
)
NAN = float("nan")
def _approx(a: float, b: float, eps: float = 1e-6) -> bool:
if math.isnan(a) and math.isnan(b):
return True
return abs(a - b) <= eps
def vec(ts: float, **kw) -> FeatureVectorLike:
return FeatureVectorLike(timestamp=ts, values=dict(kw))
# ---------------------------------------------------------------------------
# 1. ZScoreScorer
# ---------------------------------------------------------------------------
class ZScoreScorerTest(unittest.TestCase):
def test_fit_estimates_mean_std(self):
sc = ZScoreScorer().fit([
vec(1, x=10.0), vec(2, x=12.0), vec(3, x=14.0), vec(4, x=12.0),
])
self.assertTrue(sc.fitted)
# mean=12, std=sqrt(((10-12)^2+(12-12)^2+(14-12)^2+(12-12)^2)/4)=sqrt(2)=1.414
self.assertTrue(_approx(sc._mean["x"], 12.0))
self.assertTrue(_approx(sc._std["x"], math.sqrt(2.0)))
def test_score_normal_is_low(self):
sc = ZScoreScorer().fit([vec(i, x=100.0) for i in range(20)])
# 正常段(等于均值)分数应为 0
scores = sc.score([vec(100, x=100.0)])
self.assertTrue(_approx(scores[0], 0.0))
def test_score_anomaly_is_high(self):
# 正常段均值 100、std≈1.414;异常值 110 → |110-100|/1.414≈7.07
sc = ZScoreScorer().fit([vec(i, x=100.0 + (i % 3)) for i in range(20)])
scores = sc.score([vec(99, x=110.0)])
self.assertGreater(scores[0], 5.0)
def test_score_takes_max_across_features(self):
sc = ZScoreScorer().fit([
vec(1, a=0.0, b=0.0), vec(2, a=2.0, b=2.0), vec(3, a=1.0, b=1.0),
])
# a/b 均值=1,std≈0.816;输入 a=1(近均值)、b=10(远)→ 取 b 的偏离
scores = sc.score([vec(4, a=1.0, b=10.0)])
# b 的 z = |10-1|/0.816 ≈ 11.02,应远大于 a 的 z≈0
self.assertGreater(scores[0], 10.0)
def test_constant_column_deviation_flagged(self):
# 训练段恒定(std=0),推理段偏离 → 用大常数识别为异常
sc = ZScoreScorer().fit([vec(i, c=5.0) for i in range(10)])
scores = sc.score([vec(11, c=5.0), vec(12, c=6.0)])
self.assertTrue(_approx(scores[0], 0.0)) # 不偏离
self.assertGreater(scores[1], 1e5) # 偏离 → 大常数
def test_missing_value_skipped(self):
sc = ZScoreScorer().fit([vec(1, x=10.0), vec(2, x=12.0)])
# x 缺失(NaN)不应崩溃,分数按可用特征计算(这里全缺失 → 0)
scores = sc.score([vec(3, x=NAN)])
self.assertTrue(_approx(scores[0], 0.0))
def test_not_fitted_raises(self):
with self.assertRaises(ValueError):
ZScoreScorer().score([vec(1, x=1.0)])
def test_fit_empty_raises(self):
with self.assertRaises(ValueError):
ZScoreScorer().fit([])
# ---------------------------------------------------------------------------
# 2. ThresholdRule
# ---------------------------------------------------------------------------
class ThresholdRuleTest(unittest.TestCase):
def test_score_below_threshold_no_alert(self):
rule = ThresholdRule(score_threshold=3.0)
d = rule.decide(1.0, {"x": 1.0}, score=2.0)
self.assertFalse(d.triggered)
def test_score_above_threshold_alerts(self):
rule = ThresholdRule(score_threshold=3.0)
d = rule.decide(1.0, {"x": 1.0}, score=4.5)
self.assertTrue(d.triggered)
self.assertTrue(any("异常分数" in r for r in d.reasons))
def test_feature_breach_alerts(self):
rule = ThresholdRule(score_threshold=3.0,
feature_thresholds={"炉温_ema5": 900.0})
# 分数低,但特征超阈值 → 仍预警
d = rule.decide(1.0, {"炉温_ema5": 950.0}, score=1.0)
self.assertTrue(d.triggered)
self.assertTrue(any("炉温_ema5" in r for r in d.reasons))
def test_score_threshold_must_be_positive(self):
with self.assertRaises(ValueError):
ThresholdRule(score_threshold=0)
with self.assertRaises(ValueError):
ThresholdRule(score_threshold=-1)
# ---------------------------------------------------------------------------
# 3. ImpurityForecaster 端到端
# ---------------------------------------------------------------------------
class ForecasterTest(unittest.TestCase):
def test_fit_then_predict(self):
f = ImpurityForecaster(rule=ThresholdRule(score_threshold=3.0))
normal = [vec(i, x=100.0 + (i % 3)) for i in range(20)]
f.fit(normal)
decisions = f.predict(normal + [vec(99, x=200.0)])
# 正常段无预警;最后一条异常值预警
self.assertFalse(any(d.triggered for d in decisions[:-1]))
self.assertTrue(decisions[-1].triggered)
def test_evaluate_returns_leadtime(self):
f = ImpurityForecaster(rule=ThresholdRule(score_threshold=3.0))
f.fit([vec(i, x=100.0) for i in range(10)])
# 构造:ts 0..9 正常,ts 10 起开始异常(递增)
samples = [vec(i, x=100.0) for i in range(10)] + \
[vec(i, x=100.0 + 5.0 * (i - 9)) for i in range(10, 20)]
decisions, lt = f.evaluate(samples, anomaly_ts=19.0)
# 应在 ts=19(峰值)前触发 → 提前量为正
self.assertIsNotNone(lt.first_alert_ts)
self.assertGreater(lt.lead_seconds, 0)
self.assertGreater(lt.lead_minutes, 0)
# ---------------------------------------------------------------------------
# 4. evaluate_lead_time
# ---------------------------------------------------------------------------
class LeadTimeTest(unittest.TestCase):
def test_no_alert_returns_none(self):
decisions = [AlertDecision(timestamp=t, score=1.0, triggered=False)
for t in [1, 2, 3]]
lt = evaluate_lead_time(decisions, anomaly_ts=3.0)
self.assertIsNone(lt.first_alert_ts)
self.assertIsNone(lt.lead_seconds)
def test_alert_before_anomaly_positive_lead(self):
decisions = [
AlertDecision(timestamp=1, score=1.0, triggered=False),
AlertDecision(timestamp=5, score=4.0, triggered=True),
AlertDecision(timestamp=10, score=5.0, triggered=True),
]
lt = evaluate_lead_time(decisions, anomaly_ts=10.0)
self.assertEqual(lt.first_alert_ts, 5)
# 提前量 = 10 - 5 = 5s
self.assertTrue(_approx(lt.lead_seconds, 5.0))
self.assertTrue(_approx(lt.lead_minutes, 5.0 / 60))
# ---------------------------------------------------------------------------
# 5. 序列化
# ---------------------------------------------------------------------------
class SerializationTest(unittest.TestCase):
def test_roundtrip_dict(self):
sc = ZScoreScorer().fit([vec(1, x=10.0), vec(2, x=20.0)])
d = sc.to_dict()
sc2 = ZScoreScorer.from_dict(d)
self.assertTrue(sc2.fitted)
# 复现:同一输入分数一致
s1 = sc.score([vec(3, x=15.0)])
s2 = sc2.score([vec(3, x=15.0)])
self.assertTrue(_approx(s1[0], s2[0]))
def test_save_load_file(self):
sc = ZScoreScorer().fit([vec(1, x=10.0), vec(2, x=20.0)])
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
path = fh.name
try:
sc.save(path)
sc2 = ZScoreScorer.load(path)
self.assertTrue(sc2.fitted)
finally:
os.unlink(path)
if __name__ == "__main__":
unittest.main(verbosity=2)