Files
iAOP/templates/ti-cl4/impurity-forecast/tests/test_alert_rules.py
T

198 lines
8.0 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 -*-
"""炉层杂质预警规则引擎单元测试(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)