Files
iAOP/templates/ti-cl4/quality-forecast/tests/test_features.py
T
bot_dev1 c6dc7d2344 feat(#68): [Ti-1] 氯化车间质量预测特征工程(基于点位字典)
新增 templates/ti-cl4/quality-forecast/features.py:
- PointDict:解析 point_dict CSV(9 列,对齐 core/edge-gateway),检索/存在性校验
- FeatureSpec:声明式特征(source/transform/window/meaning),换行业只改清单
- FeatureExtractor:按清单从时序样本抽取特征矩阵,缺失值 NaN 占位
- 9 算子:raw/mean/std/min/max/range/diff/slope/ratio
- 零依赖 YAML 子集加载(与 recipe-optim/data-bus 同款)
- config/features.template.yaml:7 个默认特征(炉温/配比/CO波动/炉层/TiCl₄纯度)
- 22 用例全通过;纯标准库零运行时依赖。
2026-08-05 05:14:39 +08:00

229 lines
8.2 KiB
Python

# -*- coding: utf-8 -*-
"""Ti-1 质量预测特征工程测试(Issue #68)。
覆盖:
1. 点位字典加载(CSV 解析、检索、存在性校验、缺列报错);
2. FeatureSpec 校验(非法算子/未知点位/负窗口/ratio 缺 denominator);
3. 各 transform 算子(raw/mean/std/min/max/range/diff/slope/ratio)数值正确;
4. 缺失点位 → NaN 占位;
5. 滚动窗:window 外的样本不参与;
6. 声明式加载(YAML 子集 + JSON);
7. 重复特征名报错;
8. 模板资产 features.template.yaml 可加载并通过校验(对齐默认点位集)。
"""
import math
import os
import sys
import unittest
HERE = os.path.dirname(os.path.abspath(__file__)) # .../quality-forecast/tests
PKG_DIR = os.path.dirname(HERE) # .../quality-forecast
TI_CL4_DIR = os.path.dirname(PKG_DIR) # .../ti-cl4
sys.path.insert(0, HERE)
import _bootstrap # noqa: F401,E402 挂载 quality_forecast 包
from quality_forecast import features as F # noqa: E402
PDICT_DEFAULT = os.path.join(
TI_CL4_DIR, "point-dict", "point_dict.default.csv")
FEATURES_TPL = os.path.join(
PKG_DIR, "config", "features.template.yaml")
def _pdict():
return F.PointDict.from_csv(PDICT_DEFAULT)
def _samples(values, ts0=0.0, step=10.0):
"""构造样本序列:values 是 [{point_id: v}, ...]。"""
out = []
for i, vmap in enumerate(values):
out.append(F.Sample(ts=ts0 + i * step, values=dict(vmap)))
return out
class TestPointDict(unittest.TestCase):
def test_load_default_csv(self):
pd = _pdict()
self.assertTrue(pd.has("CLF-01.TEMP"))
self.assertIn("CLF-01", {p.device_id for p in pd.points})
def test_by_point_id_unknown_raises(self):
pd = _pdict()
with self.assertRaises(F.FeatureError):
pd.by_point_id("NOPE")
def test_by_device(self):
pd = _pdict()
clf = pd.by_device("CLF-01")
self.assertTrue(all(p.device_id == "CLF-01" for p in clf))
self.assertGreater(len(clf), 0)
class TestFeatureSpecValidate(unittest.TestCase):
def test_bad_transform(self):
s = F.FeatureSpec(name="x", source="CLF-01.TEMP", transform="bogus")
self.assertIn("非法 transform", "\n".join(s.validate()))
def test_negative_window(self):
s = F.FeatureSpec(name="x", source="CLF-01.TEMP", window=-1)
self.assertIn("window 不能为负", "\n".join(s.validate()))
def test_ratio_needs_denominator(self):
s = F.FeatureSpec(name="x", source="CLF-01.CL2", transform="ratio")
self.assertIn("denominator", "\n".join(s.validate()))
def test_unknown_point_with_dict(self):
pd = _pdict()
s = F.FeatureSpec(name="x", source="UNKNOWN.PT")
errs = s.validate(pd)
self.assertTrue(any("不在点位字典" in e for e in errs))
def test_constant_source_ok(self):
pd = _pdict()
s = F.FeatureSpec(name="x", source="1.5")
self.assertEqual(s.validate(pd), [])
class TestTransforms(unittest.TestCase):
def setUp(self):
self.pd = _pdict()
# 4 个样本,TEMP 单调上升
self.samples = _samples([
{"CLF-01.TEMP": 100.0},
{"CLF-01.TEMP": 110.0},
{"CLF-01.TEMP": 120.0},
{"CLF-01.TEMP": 130.0},
], step=10.0)
def test_raw(self):
ext = F.FeatureExtractor(
[F.FeatureSpec("t", "CLF-01.TEMP", "raw")], self.pd)
m = ext.extract(self.samples)
self.assertEqual(m.column("t")[-1], 130.0)
def test_mean(self):
ext = F.FeatureExtractor(
[F.FeatureSpec("t", "CLF-01.TEMP", "mean", window=1000)],
self.pd)
m = ext.extract(self.samples)
self.assertAlmostEqual(m.column("t")[-1], 115.0)
def test_min_max_range(self):
ext = F.FeatureExtractor([
F.FeatureSpec("mn", "CLF-01.TEMP", "min", window=1000),
F.FeatureSpec("mx", "CLF-01.TEMP", "max", window=1000),
F.FeatureSpec("rg", "CLF-01.TEMP", "range", window=1000),
], self.pd)
m = ext.extract(self.samples)
last = m.rows[-1]
self.assertEqual(last[0], 100.0) # min
self.assertEqual(last[1], 130.0) # max
self.assertEqual(last[2], 30.0) # range
def test_std(self):
ext = F.FeatureExtractor(
[F.FeatureSpec("s", "CLF-01.TEMP", "std", window=1000)],
self.pd)
m = ext.extract(self.samples)
# 无偏样本标准差:100,110,120,130 → 12.9099...
self.assertAlmostEqual(m.column("s")[-1],
math.sqrt(500.0 / 3), places=4)
def test_diff(self):
ext = F.FeatureExtractor(
[F.FeatureSpec("d", "CLF-01.TEMP", "diff")], self.pd)
m = ext.extract(self.samples)
self.assertEqual(m.column("d")[-1], 10.0)
def test_slope(self):
ext = F.FeatureExtractor(
[F.FeatureSpec("sl", "CLF-01.TEMP", "slope", window=1000)],
self.pd)
m = ext.extract(self.samples)
# 每 10s +10 → 斜率 1.0
self.assertAlmostEqual(m.column("sl")[-1], 1.0, places=6)
def test_ratio(self):
ext = F.FeatureExtractor([
F.FeatureSpec("r", "CLF-01.CL2", "ratio",
denominator="CLF-01.FEED"),
], self.pd)
samples = _samples([
{"CLF-01.CL2": 30.0, "CLF-01.FEED": 10.0},
{"CLF-01.CL2": 60.0, "CLF-01.FEED": 20.0},
], step=10.0)
m = ext.extract(samples)
self.assertAlmostEqual(m.column("r")[-1], 3.0)
class TestMissingAndWindow(unittest.TestCase):
def test_missing_point_is_nan(self):
ext = F.FeatureExtractor(
[F.FeatureSpec("t", "CLF-01.TEMP", "raw")], _pdict())
# 样本里没有 TEMP → NaN
samples = _samples([{"CLF-01.PRES": 1.0}])
m = ext.extract(samples)
self.assertTrue(math.isnan(m.column("t")[0]))
def test_window_excludes_old(self):
ext = F.FeatureExtractor(
[F.FeatureSpec("t", "CLF-01.TEMP", "mean", window=15)],
_pdict())
# window=15s 只含最近 ≤2 个样本(step=10)
samples = _samples([{"CLF-01.TEMP": 0.0},
{"CLF-01.TEMP": 100.0},
{"CLF-01.TEMP": 200.0}], step=10.0)
m = ext.extract(samples)
# 最后时刻 window=15 → 含 ts=20(100) 与 ts=30(200) → 均值 150
self.assertAlmostEqual(m.column("t")[-1], 150.0)
def test_drop_nan_rows(self):
ext = F.FeatureExtractor(
[F.FeatureSpec("t", "CLF-01.TEMP", "raw")], _pdict())
samples = _samples([
{"CLF-01.PRES": 1.0}, # TEMP 缺失 → NaN
{"CLF-01.TEMP": 50.0},
])
m = ext.extract(samples).drop_nan_rows()
self.assertEqual(len(m.rows), 1)
class TestLoading(unittest.TestCase):
def test_load_template_yaml(self):
pd = _pdict()
with open(FEATURES_TPL, "r", encoding="utf-8") as fh:
text = fh.read()
ext = F.load_feature_specs(text, pd)
self.assertGreater(len(ext.names), 0)
# 抽取一次能跑通(合成样本)
samples = _samples([{"CLF-01.TEMP": 850.0, "CLF-01.CL2": 120.0,
"CLF-01.FEED": 4.0, "CLF-01.CO": 2.0,
"CLF-01.BED": 60.0, "RF-01.PURITY": 99.0,
"RF-01.IMP": 0.3}])
m = ext.extract(samples)
self.assertEqual(len(m.names), len(ext.names))
self.assertEqual(len(m.rows), 1)
def test_load_json(self):
import json
text = json.dumps({"features": [
{"name": "t", "source": "CLF-01.TEMP", "transform": "raw"}]})
ext = F.load_feature_specs(text, _pdict())
self.assertEqual(ext.names, ["t"])
def test_duplicate_names_raise(self):
with self.assertRaises(F.FeatureError):
F.FeatureExtractor([
F.FeatureSpec("dup", "CLF-01.TEMP"),
F.FeatureSpec("dup", "CLF-01.PRES"),
], _pdict())
def test_empty_features_raise(self):
with self.assertRaises(F.FeatureError):
F.load_feature_specs("features: []", _pdict())
if __name__ == "__main__":
unittest.main(verbosity=2)