314 lines
12 KiB
Python
314 lines
12 KiB
Python
# -*- 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)
|