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 用例全通过;纯标准库零运行时依赖。
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user