feat(#72): 预警规则与阈值设定(声明式AlertRule三级severity+SOP联动,PRD 5.3 ③ 场景A) #113

Closed
bot_dev1 wants to merge 1 commits from feature/issue-72 into main
6 changed files with 733 additions and 0 deletions
@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
"""iAOP-Template-Ti 一期 · 炉层杂质预警规则引擎包(Issue #72)。
导出声明式预警规则引擎(AlertRuleEngine)、预警分级(AlertSeverity)、规则/告警
数据类与模板配置加载。换行业只改模板配置(alert_rules.template.yaml),引擎零改动
(PRD line 152/171:阈值外置,行业工程师在配置台维护)。
注:特征工程(#70)与无监督模型(#71)见各自分支,合入后与本规则引擎组合,
覆盖 PRD 5.3 ③ 场景 A 全链路(采集→特征→评分→规则分级→驾驶舱红色告警→LLM 解释)。
"""
from __future__ import annotations
from .alert_rules import (
Alert,
AlertCondition,
AlertRule,
AlertRuleEngine,
AlertRulesTemplateConfig,
AlertSeverity,
load_alert_rules_config,
)
__all__ = [
"Alert",
"AlertCondition",
"AlertRule",
"AlertRuleEngine",
"AlertRulesTemplateConfig",
"AlertSeverity",
"load_alert_rules_config",
]
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""炉层杂质预警规则引擎冒烟脚本(Issue #72)。
直接运行验证:模板规则资产可加载、规则引擎对急升温特征向量产出 P0 告警、
无异常时不告警。零第三方依赖。
"""
import importlib.util
import os
import sys
_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
def _load_pkg(name, path):
if name in sys.modules:
return
spec = importlib.util.spec_from_file_location(
name, os.path.join(path, "__init__.py"),
submodule_search_locations=[path])
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
_load_pkg("impurity_forecast", _PKG_DIR)
from impurity_forecast import AlertRuleEngine, AlertSeverity # noqa: E402
CONFIG = os.path.join(_PKG_DIR, "config", "alert_rules.template.yaml")
def main() -> int:
eng = AlertRuleEngine.from_template_config(CONFIG)
print(f"[OK] 规则配置加载: {len(eng.rules)} 条预警规则")
for r in eng.rules:
print(f" {r.describe()} (sop={r.sop or '-'})")
# 1) 正常工况不告警
normal = eng.evaluate(1, {"炉温_ema5": 850.0, "炉温_rate10": 0.01,
"氯气流量_std10": 3.0, "炉压_rate10": 0.01,
"炉层状态_mean10": 60.0})
assert not normal, "正常工况不应告警"
print("[OK] 正常工况:无告警")
# 2) 急升温 + 炉压急变 → P0 红色告警(PRD 场景A)
abnormal = eng.evaluate(2, {"炉温_ema5": 905.0, "炉温_rate10": 0.06,
"氯气流量_std10": 4.0, "炉压_rate10": 0.09,
"炉层状态_mean10": 60.0})
assert abnormal, "异常工况应触发告警"
primary = eng.primary_alert(abnormal)
assert primary.severity == AlertSeverity.P0, "主告警应为 P0"
print(f"[OK] 异常工况:主告警 {primary.severity.value}({primary.message})"
f" sop={primary.sop}")
print("炉层杂质预警规则引擎冒烟通过 ✅")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,356 @@
# -*- coding: utf-8 -*-
"""炉层杂质预警 · 预警规则与阈值设定引擎(Issue #72 / PRD 5.3 ③)。
PRD 5.3 ③ / 场景 A(line 80):异常检测模型触发 → 驾驶舱红色告警 + LLM 生成
"原因+处置建议" → 值班长确认。本模块把"行业知识"——**预警分级 + 阈值 + 处置 SOP**——
外置为模板配置(PRD line 152/171:阈值外置 JSON,行业工程师在配置台维护),引擎
按规则评估特征向量产出带 severity 的 Alert。
设计要点
--------
1. **声明式 AlertRule**:每条规则声明 ``id`` + ``severity``(P0/P1/P2)+ ``condition``
(特征名 + 比较运算 + 阈值)+ ``sop``(处置 SOP 引用,供 LLM 报警解释/驾驶舱展示)。
2. **severity 三级**(PRD line 333:关键告警不直接联动执行机构,高利害人工确认):
- ``P0``(critical):红色告警,立即人工确认 + 紧急处置;
- ``P1``(warning):黄色告警,加强监控 + 预备处置;
- ``P2``(info):提示,记录跟踪。
3. **规则引擎**:``AlertRuleEngine.evaluate`` 对一个特征向量评估全部规则,返回命中的
Alert 列表(取最高 severity 为主告警);可与 #70/#71 组合(特征向量/异常分数均可作为
condition 输入)。
4. **零依赖 YAML 子集解析**(对齐 data-bus / rag-kb / #70),阈值外置模板资产。
"""
from __future__ import annotations
import math
import os
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Dict, List, Optional, Sequence, Tuple
NAN = float("nan")
class AlertSeverity(str, Enum):
"""预警严重度三级(PRD line 80 红色告警 / line 333 关键告警人工确认)。"""
P0 = "P0" # critical:红色,立即人工确认 + 紧急处置
P1 = "P1" # warning:黄色,加强监控 + 预备处置
P2 = "P2" # info:提示,记录跟踪
@property
def label(self) -> str:
return {AlertSeverity.P0: "严重", AlertSeverity.P1: "警告",
AlertSeverity.P2: "提示"}[self]
@property
def rank(self) -> int:
"""排序权重,越大越严重(用于取主告警)。"""
return {AlertSeverity.P0: 3, AlertSeverity.P1: 2, AlertSeverity.P2: 1}[self]
# 比较运算符注册表(condition.op 取值)
OPS: Dict[str, Callable[[float, float], bool]] = {
">": lambda a, b: a > b,
">=": lambda a, b: a >= b,
"<": lambda a, b: a < b,
"<=": lambda a, b: a <= b,
"==": lambda a, b: a == b,
}
def _is_num(x: object) -> bool:
return isinstance(x, (int, float)) and not (isinstance(x, float) and math.isnan(x))
@dataclass
class AlertCondition:
"""单条触发条件:特征名 + 比较运算 + 阈值。"""
feature: str
op: str
threshold: float
def __post_init__(self) -> None:
if self.op not in OPS:
raise ValueError(f"未知比较运算 {self.op!r}(应为 {sorted(OPS)})")
def matches(self, values: Dict[str, float]) -> bool:
v = values.get(self.feature)
if not _is_num(v):
return False
return OPS[self.op](float(v), self.threshold)
@dataclass
class AlertRule:
"""声明式预警规则(模板配置中的一行规则声明)。
Attributes:
id: 规则 id(稳定标识,供驾驶舱/审计引用)。
severity: 严重度(P0/P1/P2)。
conditions: 触发条件列表(AND 语义:全部满足才命中)。
message: 告警文案(驾驶舱展示)。
sop: 处置 SOP 引用(PRD 场景A:LLM 报警解释 + 值班长确认)。
"""
id: str
severity: AlertSeverity
conditions: List[AlertCondition]
message: str = ""
sop: str = ""
def __post_init__(self) -> None:
if not self.id:
raise ValueError("AlertRule.id 不能为空")
if not self.conditions:
raise ValueError(f"规则 {self.id!r} 至少需要 1 条 condition")
def matches(self, values: Dict[str, float]) -> bool:
return all(c.matches(values) for c in self.conditions)
def describe(self) -> str:
conds = " 且 ".join(f"{c.feature}{c.op}{c.threshold}" for c in self.conditions)
return f"[{self.severity.value}] {self.id}: {conds}"
@dataclass
class Alert:
"""一次预警命中(规则 + 触发时的特征快照)。"""
rule_id: str
severity: AlertSeverity
message: str
sop: str
timestamp: float
snapshot: Dict[str, float] = field(default_factory=dict)
class AlertRuleEngine:
"""预警规则引擎:评估特征向量,产出带 severity 的 Alert 列表。
换行业只改模板配置(AlertRule 列表),引擎零改动(PRD line 152/171)。
用法::
engine = AlertRuleEngine(rules)
alerts = engine.evaluate(timestamp=100, values={"炉温_ema5": 920.0})
if alerts:
primary = engine.primary_alert(alerts) # 取最高 severity
"""
def __init__(self, rules: Sequence[AlertRule]):
if not rules:
raise ValueError("AlertRuleEngine 至少需要 1 条规则")
ids = set()
for r in rules:
if r.id in ids:
raise ValueError(f"规则 id 重复:{r.id!r}")
ids.add(r.id)
self.rules: List[AlertRule] = list(rules)
@classmethod
def from_template_config(cls, path: str) -> "AlertRuleEngine":
return cls(load_alert_rules_config(path).rules)
def evaluate(self, timestamp: float,
values: Dict[str, float]) -> List[Alert]:
"""评估一个特征向量,返回全部命中规则的 Alert(按 severity 降序)。"""
hits: List[Alert] = []
for rule in self.rules:
if rule.matches(values):
hits.append(Alert(
rule_id=rule.id, severity=rule.severity,
message=rule.message, sop=rule.sop,
timestamp=timestamp, snapshot=dict(values),
))
hits.sort(key=lambda a: a.severity.rank, reverse=True)
return hits
def primary_alert(self, alerts: Sequence[Alert]) -> Optional[Alert]:
"""取最高 severity 的主告警(驾驶舱红色告警)。无命中返回 None。"""
return alerts[0] if alerts else None
# ---------------------------------------------------------------------------
# 模板配置(零依赖 YAML 子集解析,对齐 #70 / data-bus / rag-kb)
# ---------------------------------------------------------------------------
@dataclass
class AlertRulesTemplateConfig:
"""模板预警规则配置:模板元信息 + AlertRule 列表。"""
template: str
version: str
rules: List[AlertRule]
description: str = ""
def _parse_scalar(text: str) -> str:
t = text.split(" #", 1)[0].strip()
if len(t) >= 2 and t[0] == t[-1] and t[0] in ("'", '"'):
return t[1:-1]
return t
def _parse_flow_value(text: str):
"""解析 ``key: value`` 右侧的值,支持行内 flow map ``{k: v, k: v}``。
其余(标量 / 引号串)退化为 :func:`_parse_scalar`。flow map 用于
``conditions: [{feature: x, op: ">", threshold: 900.0}]`` 这种紧凑声明。
"""
t = text.split(" #", 1)[0].strip()
if t.startswith("{") and t.endswith("}"):
inner = t[1:-1].strip()
out: Dict[str, object] = {}
if not inner:
return out
for part in inner.split(","):
if ":" not in part:
raise ValueError(f"flow map 项不是键值对:{part!r}")
k, _, v = part.partition(":")
out[k.strip()] = _parse_scalar(v)
return out
return _parse_scalar(text)
def _strip_comments(lines: List[str]) -> List[Tuple[str, int]]:
out = []
for i, ln in enumerate(lines):
s = ln.strip()
if not s or s.startswith("#"):
continue
out.append((ln, i + 1))
return out
def _parse_node(lines: List[Tuple[str, int]], i: int, indent: int):
text, _ = lines[i]
if text.lstrip(" ").startswith("- "):
items: List[object] = []
while i < len(lines):
t, no = lines[i]
stripped = t.lstrip(" ")
if not stripped.startswith("- "):
break
lead_j = len(t) - len(t.lstrip(" "))
if lead_j != indent:
break
item_text = stripped[2:].strip()
if not item_text:
raise ValueError(f"alerts.yaml 第 {no} 行:list 项为空")
# 行内 flow map({k: v, ...})优先用 _parse_flow_value,避免被
# 下方「含 : 即 map 项」分支误判(flow map 也含 :)。
if item_text.startswith("{") and item_text.endswith("}"):
items.append(_parse_flow_value(item_text))
i += 1
elif ":" in item_text:
map_indent = len(t) - len(t.lstrip(" ")) + 2
lines[i] = (" " * map_indent + item_text, no)
v, i = _parse_node(lines, i, map_indent)
items.append(v)
else:
items.append(_parse_flow_value(item_text))
i += 1
return items, i
result: Dict[str, object] = {}
while i < len(lines):
t, no = lines[i]
lead_j = len(t) - len(t.lstrip(" "))
if lead_j < indent or t.lstrip(" ").startswith("- "):
break
if lead_j > indent:
raise ValueError(f"alerts.yaml 第 {no} 行缩进异常")
if ":" not in t:
raise ValueError(f"alerts.yaml 第 {no} 行不是合法键值对:{t!r}")
key, _, rest = t.partition(":")
key = key.strip()
rest = rest.strip()
if rest:
result[key] = _parse_flow_value(rest)
i += 1
continue
if i + 1 >= len(lines):
raise ValueError(f"alerts.yaml 第 {no} 行 {key!r} 缺少值")
sub_indent = len(lines[i + 1][0]) - len(lines[i + 1][0].lstrip(" "))
if sub_indent <= indent:
raise ValueError(f"alerts.yaml 第 {no} 行 {key!r} 缺少值(无嵌套)")
v, i = _parse_node(lines, i + 1, sub_indent)
result[key] = v
return result, i
def _load_yaml_text(text: str) -> Dict[str, object]:
lines = _strip_comments(text.splitlines())
if not lines:
return {}
top_indent = len(lines[0][0]) - len(lines[0][0].lstrip(" "))
value, next_i = _parse_node(lines, 0, top_indent)
if not isinstance(value, dict):
raise ValueError("alerts.yaml 顶层必须是 map")
if next_i < len(lines):
raise ValueError(f"alerts.yaml 第 {lines[next_i][1]} 行:顶层存在多个节点")
return value
def load_alert_rules_config(path: str) -> AlertRulesTemplateConfig:
"""从模板预警规则 YAML 资产加载配置。
期望结构(详见 ``config/alert_rules.template.yaml``)::
template: ti-cl4
version: 1.0.0
rules:
- id: bed_temp_critical
severity: P0
message: 炉温超上限,立即降流减料
sop: SOP-CL-001
conditions:
- {feature: 炉温_ema5, op: ">", threshold: 900.0}
"""
with open(path, "r", encoding="utf-8") as fh:
data = _load_yaml_text(fh.read())
template = str(data.get("template", "")).strip()
if not template:
raise ValueError("alerts.yaml 缺少 template 字段")
version = str(data.get("version", "1.0.0")).strip() or "1.0.0"
description = str(data.get("description", "")).strip()
raw_rules = data.get("rules") or []
if not isinstance(raw_rules, list):
raise ValueError("alerts.yaml rules 必须是 list")
rules: List[AlertRule] = []
for idx, item in enumerate(raw_rules):
if not isinstance(item, dict):
raise ValueError(f"alerts.yaml rules[{idx}] 必须是 map")
rid = str(item.get("id", "")).strip()
sev_name = str(item.get("severity", "")).strip().upper()
sev_map = {s.value: s for s in AlertSeverity}
if sev_name not in sev_map:
raise ValueError(
f"alerts.yaml rules[{idx}] 未知 severity {sev_name!r}"
f"(应为 {sorted(sev_map)})")
message = str(item.get("message", "")).strip()
sop = str(item.get("sop", "")).strip()
raw_conds = item.get("conditions") or []
if not isinstance(raw_conds, list):
raise ValueError(f"alerts.yaml rules[{idx}] conditions 必须是 list")
conds: List[AlertCondition] = []
for ci, c in enumerate(raw_conds):
if not isinstance(c, dict):
raise ValueError(f"alerts.yaml rules[{idx}].conditions[{ci}] 必须是 map")
feature = str(c.get("feature", "")).strip()
op = str(c.get("op", "")).strip()
if op not in OPS:
raise ValueError(
f"alerts.yaml rules[{idx}].conditions[{ci}] 未知 op {op!r}")
try:
threshold = float(c.get("threshold"))
except (TypeError, ValueError) as exc:
raise ValueError(
f"alerts.yaml rules[{idx}].conditions[{ci}] threshold 不是数值") from exc
conds.append(AlertCondition(feature=feature, op=op, threshold=threshold))
rules.append(AlertRule(id=rid, severity=sev_map[sev_name],
conditions=conds, message=message, sop=sop))
return AlertRulesTemplateConfig(template=template, version=version,
rules=rules, description=description)
@@ -0,0 +1,64 @@
# iAOP-Template-Ti 一期 · 炉层杂质预警规则与阈值模板资产(Issue #72 / PRD 5.3 ③)
#
# 把"行业知识"——预警分级 + 阈值 + 处置 SOP——外置为本配置(PRD line 152/171:
# 阈值外置,行业工程师在配置台维护),规则引擎(alert_rules.py)零改动。
#
# severity 三级(PRD line 80 红色告警 / line 333 关键告警人工确认):
# P0 critical 红色,立即人工确认 + 紧急处置;
# P1 warning 黄色,加强监控 + 预备处置;
# P2 info 提示,记录跟踪。
#
# 特征名对齐 #70 features.template.yaml 的 FeatureSpec.name(如 炉温_ema5、炉压_rate10);
# sop 引用异常处置 SOP(供 #11 LLM 报警解释 + 驾驶舱展示 + 值班长确认)。
template: ti-cl4
version: 1.0.0
description: 炉层杂质预警规则与阈值(声明式 AlertRule,PRD 5.3 ③ 场景A)
rules:
# ---- P0 严重:炉温超上限,立即降流减料(SOP-CL-001) -----------------
- id: bed_temp_critical
severity: P0
message: 炉温超工艺上限,立即降低氯气流量并减少加料,10 分钟未回落按紧急停机处理
sop: SOP-CL-001
conditions:
- {feature: 炉温_ema5, op: ">", threshold: 900.0}
# ---- P0 严重:炉温急升趋势(提前量信号,PRD 提前≥30min) -------------
- id: bed_temp_rising_critical
severity: P0
message: 炉温急升趋势,疑似炉层状态恶化,预备紧急处置并通知班长
sop: SOP-CL-002
conditions:
- {feature: 炉温_rate10, op: ">", threshold: 0.05}
# ---- P0 严重:炉压急变(压力异常是炉层恶化强信号) -------------------
- id: bed_pressure_critical
severity: P0
message: 炉压急变,排查炉层状态与尾气系统,必要时降负荷
sop: SOP-CL-003
conditions:
- {feature: 炉压_rate10, op: ">", threshold: 0.08}
# ---- P1 警告:氯气流量波动度越界(流态化异常先兆) -------------------
- id: cl2_flow_warning
severity: P1
message: 氯气流量波动增大,检查供料与流态化状态,加强监控
sop: SOP-CL-004
conditions:
- {feature: 氯气流量_std10, op: ">", threshold: 8.0}
# ---- P1 警告:炉层状态均值超阈(杂质富集表征) -----------------------
- id: bed_state_warning
severity: P1
message: 炉层状态偏高,关注杂质富集趋势,按批次增加检测频次
sop: SOP-CL-005
conditions:
- {feature: 炉层状态_mean10, op: ">", threshold: 85.0}
# ---- P2 提示:炉温接近上限(预警预备) -------------------------------
- id: bed_temp_near_limit
severity: P2
message: 炉温接近工艺上限,记录并跟踪趋势
sop: ""
conditions:
- {feature: 炉温_ema5, op: ">", threshold: 880.0}
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""测试引导:把连字符目录挂载为可导入包(与 core 模块同款模式)。
- ``templates/ti-cl4/impurity-forecast`` → 包名 ``impurity_forecast``。
本规则引擎零内核依赖(纯标准库),仅挂载自身包即可。
"""
import importlib.util
import os
import sys
PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _load_package(name: str, path: str) -> None:
"""按文件路径完整加载一个包(执行其 __init__.py)。"""
if name in sys.modules:
return
init_py = os.path.join(path, "__init__.py")
spec = importlib.util.spec_from_file_location(
name, init_py, submodule_search_locations=[path])
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
_load_package("impurity_forecast", PKG_DIR)
@@ -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)