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:
@@ -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)
|
||||
Reference in New Issue
Block a user