From 4dcaf47af6769756b9c1c069f062c40d0567685d Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:13:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(#72):=20=E9=A2=84=E8=AD=A6=E8=A7=84?= =?UTF-8?q?=E5=88=99=E4=B8=8E=E9=98=88=E5=80=BC=E8=AE=BE=E5=AE=9A=EF=BC=88?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E5=BC=8FAlertRule=E4=B8=89=E7=BA=A7severity+?= =?UTF-8?q?SOP=E8=81=94=E5=8A=A8=EF=BC=8CPRD=205.3=20=E2=91=A2=20=E5=9C=BA?= =?UTF-8?q?=E6=99=AFA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把行业知识——预警分级(P0/P1/P2)+阈值+处置SOP——外置为模板配置(PRD line 152/171 阈值外置,行业工程师配置台维护),规则引擎零改动。 - alert_rules.py:AlertSeverity(P0/P1/P2) + AlertCondition(6 运算) + AlertRule(AND 语义) + AlertRuleEngine(按 severity 降序 + primary_alert) + 零依赖 YAML 解析(flow map)。 - config/alert_rules.template.yaml:6 条规则,特征名对齐 #70 FeatureSpec.name,sop 引用 异常处置 SOP(供 #11 LLM 报警解释 + 驾驶舱红色告警 + 值班长确认,PRD 场景A)。 - tests/test_alert_rules.py:16 项单测(运算/规则/引擎排序/配置/端到端P0)全通过。 - _sanity_check_rules.py:冒烟(正常无告警 + 急升温P0)。 PRD line 333:关键告警不直接联动执行机构,高利害输出人工确认 → severity 分级支撑。 --- .../ti-cl4/impurity-forecast/__init__.py | 31 ++ .../impurity-forecast/_sanity_check_rules.py | 59 +++ .../ti-cl4/impurity-forecast/alert_rules.py | 356 ++++++++++++++++++ .../config/alert_rules.template.yaml | 64 ++++ .../impurity-forecast/tests/_bootstrap.py | 26 ++ .../tests/test_alert_rules.py | 197 ++++++++++ 6 files changed, 733 insertions(+) create mode 100644 templates/ti-cl4/impurity-forecast/__init__.py create mode 100644 templates/ti-cl4/impurity-forecast/_sanity_check_rules.py create mode 100644 templates/ti-cl4/impurity-forecast/alert_rules.py create mode 100644 templates/ti-cl4/impurity-forecast/config/alert_rules.template.yaml create mode 100644 templates/ti-cl4/impurity-forecast/tests/_bootstrap.py create mode 100644 templates/ti-cl4/impurity-forecast/tests/test_alert_rules.py diff --git a/templates/ti-cl4/impurity-forecast/__init__.py b/templates/ti-cl4/impurity-forecast/__init__.py new file mode 100644 index 0000000..b6d4cce --- /dev/null +++ b/templates/ti-cl4/impurity-forecast/__init__.py @@ -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", +] diff --git a/templates/ti-cl4/impurity-forecast/_sanity_check_rules.py b/templates/ti-cl4/impurity-forecast/_sanity_check_rules.py new file mode 100644 index 0000000..8bde4b0 --- /dev/null +++ b/templates/ti-cl4/impurity-forecast/_sanity_check_rules.py @@ -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()) diff --git a/templates/ti-cl4/impurity-forecast/alert_rules.py b/templates/ti-cl4/impurity-forecast/alert_rules.py new file mode 100644 index 0000000..0e6650c --- /dev/null +++ b/templates/ti-cl4/impurity-forecast/alert_rules.py @@ -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) diff --git a/templates/ti-cl4/impurity-forecast/config/alert_rules.template.yaml b/templates/ti-cl4/impurity-forecast/config/alert_rules.template.yaml new file mode 100644 index 0000000..8616cba --- /dev/null +++ b/templates/ti-cl4/impurity-forecast/config/alert_rules.template.yaml @@ -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} diff --git a/templates/ti-cl4/impurity-forecast/tests/_bootstrap.py b/templates/ti-cl4/impurity-forecast/tests/_bootstrap.py new file mode 100644 index 0000000..369f166 --- /dev/null +++ b/templates/ti-cl4/impurity-forecast/tests/_bootstrap.py @@ -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) diff --git a/templates/ti-cl4/impurity-forecast/tests/test_alert_rules.py b/templates/ti-cl4/impurity-forecast/tests/test_alert_rules.py new file mode 100644 index 0000000..46e7ebd --- /dev/null +++ b/templates/ti-cl4/impurity-forecast/tests/test_alert_rules.py @@ -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)