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%口径)。
This commit is contained in:
2026-08-05 01:59:42 +08:00
parent f5d2294ee4
commit 42d206e503
7 changed files with 1148 additions and 0 deletions
@@ -0,0 +1,313 @@
# -*- 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)