Files
iAOP/templates/ti-cl4/impurity-forecast/tests/test_features.py
T
bot_dev1 42d206e503 feat(#70): 炉层杂质预警特征工程(声明式FeatureSpec引擎,PRD 5.3 ③)
新增 templates/ti-cl4/impurity-forecast:声明式特征工程引擎,特征以
FeatureSpec 描述(EMA/RollingStd/RateOfChange 等 7 算子),换行业只改模板配置
features.template.yaml,引擎零改动(PRD 5.3:特征工程层跨行业差异落在
FeatureSpec,不落代码)。

- features.py:FeatureSpec 声明 + 校验 + 7 算子 + 时序对齐 + 阈值 breach + 零依赖 YAML 解析
- config/features.template.yaml:炉温/氯气/炉压/炉层 9 条特征(对齐点位字典 point_id)
- tests/test_features.py:24 项单测(校验/算子/对齐/breach/配置/端到端提前量)全通过
- _sanity_check.py:冒烟脚本(配置加载 + transform + breach 可观测)

验收:一期阈值+无监督上线,提前量信号可观测(PRD 提前≥30min、误报率≤8%口径)。
2026-08-05 01:59:42 +08:00

314 lines
12 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 #70)。
覆盖:
- 声明式 FeatureSpec 校验(缺参 / 非法窗口 / alpha 越界 / 未知算子);
- 各算子数学正确性(raw / ema / rolling_std / rolling_mean / rolling_min/max /
rate_of_change),含缺失值处理;
- 时序对齐与缺失率;
- 无监督阈值 breach 判定;
- 模板配置 YAML 加载(含 flow map / 错误 YAML 拒绝);
- 端到端:模板资产加载 → 引擎 → transform → 提前量信号可观测。
"""
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
FeatureEngine,
FeatureKind,
FeatureSpec,
FeatureSpecError,
FeatureTemplateConfig,
load_feature_config,
)
from impurity_forecast.features import ( # noqa: E402
NAN,
_op_ema,
_op_rate_of_change,
_op_raw,
_rolling_window,
_std,
)
NAN = float("nan")
CONFIG_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"config", "features.template.yaml")
def _approx(a: float, b: float, eps: float = 1e-9) -> bool:
if math.isnan(a) and math.isnan(b):
return True
return abs(a - b) <= eps
# ---------------------------------------------------------------------------
# 1. FeatureSpec 声明校验
# ---------------------------------------------------------------------------
class FeatureSpecValidationTest(unittest.TestCase):
def test_minimal_raw_spec_ok(self):
s = FeatureSpec(name="t", kind=FeatureKind.RAW, point="P1")
self.assertEqual(s.describe(), "t = raw(P1)")
def test_rolling_requires_window(self):
with self.assertRaises(FeatureSpecError):
FeatureSpec(name="x", kind=FeatureKind.ROLLING_STD, point="P1")
def test_rolling_window_must_be_positive_int(self):
with self.assertRaises(FeatureSpecError):
FeatureSpec(name="x", kind=FeatureKind.ROLLING_MEAN, point="P1",
params={"window": 0})
# 非整数(2.5)必须被拒绝,避免窗口语义歧义
with self.assertRaises(FeatureSpecError):
FeatureSpec(name="x", kind=FeatureKind.ROLLING_MEAN, point="P1",
params={"window": 2.5})
def test_ema_alpha_range(self):
with self.assertRaises(FeatureSpecError):
FeatureSpec(name="x", kind=FeatureKind.EMA, point="P1",
params={"alpha": 0}) # 不含 0
with self.assertRaises(FeatureSpecError):
FeatureSpec(name="x", kind=FeatureKind.EMA, point="P1",
params={"alpha": 1.5}) # 超 1
# 合法边界 1.0 通过
s = FeatureSpec(name="x", kind=FeatureKind.EMA, point="P1",
params={"alpha": 1.0})
self.assertEqual(s.params["alpha"], 1.0)
def test_empty_name_or_point_rejected(self):
with self.assertRaises(FeatureSpecError):
FeatureSpec(name="", kind=FeatureKind.RAW, point="P1")
with self.assertRaises(FeatureSpecError):
FeatureSpec(name="x", kind=FeatureKind.RAW, point="")
# ---------------------------------------------------------------------------
# 2. 算子数学正确性
# ---------------------------------------------------------------------------
class OperatorMathTest(unittest.TestCase):
def test_raw_passes_through_with_nan(self):
out = _op_raw([1.0, NAN, 3.0], {})
self.assertTrue(_approx(out[0], 1.0))
self.assertTrue(math.isnan(out[1]))
self.assertTrue(_approx(out[2], 3.0))
def test_ema_recurrence(self):
# alpha=0.5: ema[t] = 0.5*x + 0.5*ema[t-1],首项为 x[0]
out = _op_ema([10.0, 20.0, 30.0], {"alpha": 0.5})
self.assertTrue(_approx(out[0], 10.0))
self.assertTrue(_approx(out[1], 0.5 * 20 + 0.5 * 10)) # 15
self.assertTrue(_approx(out[2], 0.5 * 30 + 0.5 * 15)) # 22.5
def test_ema_skips_nan_without_reset(self):
# 缺失样本不进缓冲区且不重置状态
out = _op_ema([10.0, NAN, 20.0], {"alpha": 1.0})
self.assertTrue(_approx(out[0], 10.0))
self.assertTrue(math.isnan(out[1]))
self.assertTrue(_approx(out[2], 20.0)) # alpha=1 即 raw
def test_rolling_std_window_warmup(self):
vals = [2.0, 4.0, 6.0]
out = _rolling_window(vals, 2, _std)
self.assertTrue(math.isnan(out[0])) # 不足 window
# 窗口 [2,4] 总体标准差 = sqrt(((2-3)^2+(4-3)^2)/2)=sqrt(1)=1
self.assertTrue(_approx(out[1], 1.0))
self.assertTrue(_approx(out[2], 1.0)) # [4,6] 同样
def test_rolling_mean_min_max(self):
vals = [1.0, 2.0, 3.0, 4.0]
mean = _rolling_window(vals, 2, lambda w: sum(w) / len(w))
self.assertTrue(_approx(mean[0], NAN))
self.assertTrue(_approx(mean[1], 1.5))
self.assertTrue(_approx(mean[2], 2.5))
self.assertTrue(_approx(mean[3], 3.5))
self.assertTrue(_approx(_rolling_window(vals, 2, min)[3], 3.0))
self.assertTrue(_approx(_rolling_window(vals, 2, max)[3], 4.0))
def test_rate_of_change_warmup(self):
# window=1: roc[t] = (x[t]-x[t-1])/x[t-1]
out = _op_rate_of_change([100.0, 110.0, 99.0], {"window": 1})
self.assertTrue(math.isnan(out[0])) # 需 window+1 个样本
self.assertTrue(_approx(out[1], 0.10))
self.assertTrue(_approx(out[2], -0.10))
def test_rate_of_change_zero_base_is_nan(self):
out = _op_rate_of_change([0.0, 10.0, 20.0], {"window": 1})
# base=0 → 除零,返回 NAN 而非崩溃
self.assertTrue(math.isnan(out[1]))
# ---------------------------------------------------------------------------
# 3. 引擎:时序对齐 / 缺失率 / 重复名拒绝
# ---------------------------------------------------------------------------
class FeatureEngineTest(unittest.TestCase):
def _engine(self) -> FeatureEngine:
return FeatureEngine([
FeatureSpec(name="炉温_raw", kind=FeatureKind.RAW, point="CLF-01.TEMP"),
FeatureSpec(name="炉温_ema5", kind=FeatureKind.EMA,
point="CLF-01.TEMP", params={"alpha": 0.5},
threshold=900.0),
FeatureSpec(name="氯气_std3", kind=FeatureKind.ROLLING_STD,
point="CLF-01.CL2", params={"window": 3}),
])
def test_empty_specs_rejected(self):
with self.assertRaises(FeatureSpecError):
FeatureEngine([])
def test_duplicate_name_rejected(self):
with self.assertRaises(FeatureSpecError):
FeatureEngine([
FeatureSpec(name="dup", kind=FeatureKind.RAW, point="P1"),
FeatureSpec(name="dup", kind=FeatureKind.RAW, point="P2"),
])
def test_required_points_dedup(self):
eng = self._engine()
self.assertEqual(eng.required_points(), ["CLF-01.TEMP", "CLF-01.CL2"])
def test_transform_aligns_and_missing_rate(self):
eng = self._engine()
samples = [
{"ts": 1, "CLF-01.TEMP": 800.0, "CLF-01.CL2": 100.0},
{"ts": 2, "CLF-01.TEMP": 850.0, "CLF-01.CL2": 120.0},
{"ts": 3, "CLF-01.TEMP": 910.0, "CLF-01.CL2": 90.0},
]
vecs = eng.transform(samples)
self.assertEqual(len(vecs), 3)
# 第一个时刻:rolling_std window=3 不足 → 该列缺失
self.assertAlmostEqual(vecs[0].missing_rate, 1.0 / 3, places=6)
self.assertFalse(vecs[0].is_complete) # @property
# 第三个时刻所有列就绪
self.assertTrue(vecs[2].is_complete)
self.assertAlmostEqual(vecs[2].values["炉温_raw"], 910.0)
# ema 第三个 = 0.5*910 + 0.5*(0.5*850+0.5*800) = 455+0.5*825=455+412.5
self.assertAlmostEqual(vecs[2].values["炉温_ema5"], 867.5, places=4)
def test_transform_missing_point_value(self):
eng = self._engine()
samples = [
{"ts": 1, "CLF-01.TEMP": 800.0}, # CL2 缺失
{"ts": 2, "CLF-01.TEMP": 850.0, "CLF-01.CL2": 100.0},
{"ts": 3, "CLF-01.TEMP": 900.0, "CLF-01.CL2": 110.0},
]
vecs = eng.transform(samples)
self.assertTrue(math.isnan(vecs[0].values["氯气_std3"]))
def test_breach_threshold(self):
eng = self._engine()
vec = type("V", (), {"values": {
"炉温_raw": 800.0,
"炉温_ema5": 950.0, # 超 900
"氯气_std3": 5.0,
}})()
breach = eng.breach(vec)
names = [b[0] for b in breach]
self.assertEqual(names, ["炉温_ema5"])
def test_describe_lists_all_specs(self):
eng = self._engine()
self.assertEqual(len(eng.describe()), 3)
self.assertIn("alpha=0.5", eng.describe()[1])
# ---------------------------------------------------------------------------
# 4. 模板配置 YAML 加载
# ---------------------------------------------------------------------------
class ConfigLoadTest(unittest.TestCase):
def test_load_template_config(self):
cfg = load_feature_config(CONFIG_PATH)
self.assertIsInstance(cfg, FeatureTemplateConfig)
self.assertEqual(cfg.template, "ti-cl4")
self.assertTrue(len(cfg.specs) >= 5)
names = [s.name for s in cfg.specs]
# PRD 示例三件套均存在
self.assertIn("炉温_ema5", names)
self.assertIn("氯气流量_std10", names)
self.assertIn("炉压_rate10", names)
def test_flow_map_params_parsed(self):
cfg = load_feature_config(CONFIG_PATH)
ema = next(s for s in cfg.specs if s.name == "炉温_ema5")
# flow map {alpha: 0.2} 解析为数值参数
self.assertAlmostEqual(ema.params["alpha"], 0.2)
self.assertEqual(ema.threshold, 900.0)
def test_engine_from_template_config(self):
eng = FeatureEngine.from_template_config(CONFIG_PATH)
vecs = eng.transform([
{"ts": i, "CLF-01.TEMP": 850.0 + i, "CLF-01.CL2": 100.0,
"CLF-01.PRES": 10.0, "CLF-01.BED": 60.0}
for i in range(20)
])
# 充分预热后所有特征列就绪
self.assertTrue(vecs[-1].is_complete)
# 无 breach(值均在阈值内)
self.assertEqual(eng.breach(vecs[-1]), [])
def test_unknown_kind_rejected(self):
import tempfile
bad = (
"template: ti-cl4\n"
"version: 1.0.0\n"
"specs:\n"
" - name: x\n"
" kind: not_a_real_kind\n"
" point: P1\n"
)
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False,
encoding="utf-8") as fh:
fh.write(bad)
path = fh.name
try:
with self.assertRaises(FeatureSpecError):
load_feature_config(path)
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# 5. 端到端:提前量信号可观测(PRD 验收:提前 ≥ 30min)
# ---------------------------------------------------------------------------
class EndToEndEarlySignalTest(unittest.TestCase):
def test_rising_temperature_triggers_breach_before_peak(self):
"""模拟炉温阶跃爬升:ema 平滑值应在持续攀升阶段 breach 阈值,
早于物理峰值时刻 —— 体现"提前量"(PRD 5.3 ③:提前 ≥ 30min)。"""
eng = FeatureEngine([
FeatureSpec(name="炉温_ema5", kind=FeatureKind.EMA,
point="CLF-01.TEMP", params={"alpha": 0.4},
threshold=900.0),
])
# 前 10 步平稳 850℃,第 10 步起每步 +8℃ 攀升,第 25 步到峰值 970℃
temps = [850.0] * 10 + [850.0 + 8.0 * (i - 9) for i in range(10, 25)]
samples = [{"ts": i, "CLF-01.TEMP": temps[i]} for i in range(len(temps))]
vecs = eng.transform(samples)
# 第一个 breach 的时刻
first_breach = None
for idx, v in enumerate(vecs):
if eng.breach(v):
first_breach = idx
break
self.assertIsNotNone(first_breach, "未观察到任何 breach")
# breach 应在物理峰值(最后一刻)之前出现 → 提前量可观测
self.assertLess(first_breach, len(vecs) - 1)
if __name__ == "__main__":
unittest.main(verbosity=2)