Merge PR #111
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# iAOP-Template-Ti 一期 · 炉层杂质预警特征工程(Impurity Forecast)
|
||||
|
||||
对应 PRD 5.3「③ 炉层杂质预警」与 Issue #70「[Ti-1] 炉层杂质预警特征工程」。
|
||||
|
||||
PRD 5.3 明确:**特征工程层的跨行业差异落在 FeatureSpec,不落代码**。本模块
|
||||
实现一个**声明式特征工程引擎**——特征以 `FeatureSpec` 描述(如 `EMA(炉温, 5min)`、
|
||||
`RollingStd(氯气流量, 10)`、`RateOfChange(炉压)`),由工艺模板定义、引擎解释执行;
|
||||
**换行业只改模板配置(`config/features.template.yaml`),引擎零改动**。
|
||||
|
||||
## 验收口径(PRD 5.3 ③ / 验收表)
|
||||
|
||||
- 炉层杂质预警提前 ≥ 30 分钟;
|
||||
- 误报率 ≤ 8%;
|
||||
- 一期:阈值 + 无监督上线(数据门槛低);3 个月后转监督。
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
templates/ti-cl4/impurity-forecast/
|
||||
├── __init__.py 包入口(导出 FeatureEngine / FeatureSpec 等)
|
||||
├── features.py 声明式特征工程引擎(FeatureSpec + 算子 + YAML 解析)
|
||||
├── _sanity_check.py 冒烟脚本(零环境依赖,直接运行)
|
||||
├── config/
|
||||
│ └── features.template.yaml 模板特征配置资产(炉温/氯气/炉压/炉层 9 条特征)
|
||||
└── tests/
|
||||
├── _bootstrap.py 测试引导(连字符目录挂载为可导入包)
|
||||
└── test_features.py 24 项单测(校验/算子/对齐/breach/配置/端到端)
|
||||
```
|
||||
|
||||
## 内置算子(声明式 FeatureSpec 的 `kind`)
|
||||
|
||||
| kind | 含义 | 参数 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `raw` | 原始值透传 | — | `炉温_raw` |
|
||||
| `ema` | 指数滑动平均(平滑去噪) | `alpha ∈ (0,1]` | `炉温_ema5 = ema(炉温, alpha=0.2)` |
|
||||
| `rolling_std` | 滚动标准差(波动度) | `window > 0` | `氯气流量_std10` |
|
||||
| `rolling_mean` | 滚动均值 | `window > 0` | `炉层状态_mean10` |
|
||||
| `rolling_min` / `rolling_max` | 滚动极值 | `window > 0` | 配合阈值 |
|
||||
| `rate_of_change` | 变化率 `(x[t]-x[t-w])/x[t-w]` | `window > 0` | `炉压_rate10` |
|
||||
|
||||
## 用法
|
||||
|
||||
```python
|
||||
from impurity_forecast import FeatureEngine, load_feature_config
|
||||
|
||||
# 1) 从模板资产构建引擎
|
||||
engine = FeatureEngine.from_template_config("config/features.template.yaml")
|
||||
|
||||
# 2) 时序样本流 → 按时间对齐的特征向量序列
|
||||
samples = [
|
||||
{"ts": 1, "CLF-01.TEMP": 850.0, "CLF-01.CL2": 100.0,
|
||||
"CLF-01.PRES": 10.0, "CLF-01.BED": 60.0},
|
||||
# ...
|
||||
]
|
||||
vectors = engine.transform(samples)
|
||||
|
||||
# 3) 无监督阈值判定(一期上线口径)
|
||||
for vec in vectors:
|
||||
if vec.is_complete:
|
||||
breach = engine.breach(vec) # 超阈值的 (特征名, 当前值) 列表
|
||||
if breach:
|
||||
... # 触发预警(提前量信号)
|
||||
```
|
||||
|
||||
## 运行测试 / 冒烟
|
||||
|
||||
```bash
|
||||
cd templates/ti-cl4/impurity-forecast
|
||||
python -m unittest discover -s tests # 24 项单测
|
||||
python _sanity_check.py # 冒烟:配置加载 + transform + breach
|
||||
```
|
||||
|
||||
## 设计要点
|
||||
|
||||
1. **零第三方依赖**:纯 Python 标准库;YAML 子集自解析(对齐 data-bus / rag-kb)。
|
||||
2. **模板化**:测点对齐 `templates/ti-cl4/point-dict/point_dict.default.csv` 的
|
||||
`point_id`;特征声明集中在 `config/features.template.yaml`。
|
||||
3. **缺失值治理**:缺失测点用 `NaN` 占位并记录缺失率,作为误报率治理输入。
|
||||
4. **预热语义**:滚动/变化率类算子在窗口未满时输出 `NaN`("尚不足以计算"),
|
||||
避免冷启动误报。
|
||||
5. **配套**:本特征矩阵供 #71「炉层杂质预警模型训练」消费(监督/无监督均可用)。
|
||||
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""iAOP-Template-Ti 一期 · 炉层杂质预警特征工程包(Issue #70)。
|
||||
|
||||
导出声明式 FeatureSpec 特征工程引擎,供预警模型训练(#71)与驾驶舱
|
||||
告警面板复用。换行业只改模板配置,引擎零改动(PRD 5.3 特征工程层)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .features import (
|
||||
FeatureKind,
|
||||
FeatureSpec,
|
||||
FeatureSpecError,
|
||||
FeatureValue,
|
||||
FeatureVector,
|
||||
FeatureEngine,
|
||||
FeatureTemplateConfig,
|
||||
load_feature_config,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FeatureKind",
|
||||
"FeatureSpec",
|
||||
"FeatureSpecError",
|
||||
"FeatureValue",
|
||||
"FeatureVector",
|
||||
"FeatureEngine",
|
||||
"FeatureTemplateConfig",
|
||||
"load_feature_config",
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""炉层杂质预警特征工程冒烟脚本(Issue #70)。
|
||||
|
||||
直接运行 ``python _sanity_check.py`` 验证:模板资产可加载、引擎可对
|
||||
合成时序产出完整特征向量、阈值 breach 可观测。零第三方依赖。
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
# impurity-forecast 目录名含连字符,不能作为 Python 包名直接 import;
|
||||
# 用 importlib 按文件路径加载为合法包 impurity_forecast(同 tests/_bootstrap.py)。
|
||||
_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load_pkg(name, path):
|
||||
if name in sys.modules:
|
||||
return
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, os.path.join(path, "__init__.py"),
|
||||
submodule_search_locations=[path])
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
|
||||
_load_pkg("impurity_forecast", _PKG_DIR)
|
||||
|
||||
from impurity_forecast import FeatureEngine, load_feature_config # noqa: E402
|
||||
|
||||
CONFIG = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"config", "features.template.yaml")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cfg = load_feature_config(CONFIG)
|
||||
print(f"[OK] 模板特征配置加载: template={cfg.template} "
|
||||
f"version={cfg.version} features={len(cfg.specs)}")
|
||||
for line in FeatureEngine(cfg.specs).describe():
|
||||
print(" -", line)
|
||||
|
||||
eng = FeatureEngine.from_template_config(CONFIG)
|
||||
# 合成 30 步温和时序(不触发 breach),验证预热后向量完整
|
||||
samples = [
|
||||
{"ts": i, "CLF-01.TEMP": 850.0, "CLF-01.CL2": 100.0,
|
||||
"CLF-01.PRES": 10.0, "CLF-01.BED": 60.0}
|
||||
for i in range(30)
|
||||
]
|
||||
vecs = eng.transform(samples)
|
||||
last = vecs[-1]
|
||||
assert last.is_complete, "预热后特征向量应完整"
|
||||
assert eng.breach(last) == [], "温和时序不应触发 breach"
|
||||
print(f"[OK] transform: 30 步时序 → 末向量完整,缺失率={last.missing_rate:.2%}")
|
||||
|
||||
# 合成急升温序列,验证阈值 breach 可观测
|
||||
hot = [{"ts": i, "CLF-01.TEMP": 850.0 + 8.0 * i, "CLF-01.CL2": 100.0,
|
||||
"CLF-01.PRES": 10.0, "CLF-01.BED": 60.0} for i in range(30)]
|
||||
hot_vecs = eng.transform(hot)
|
||||
breached = any(eng.breach(v) for v in hot_vecs)
|
||||
assert breached, "急升温序列应触发 breach"
|
||||
print("[OK] 急升温序列触发 breach(提前量信号可观测)")
|
||||
print("炉层杂质预警特征工程冒烟通过 ✅")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,73 @@
|
||||
# iAOP-Template-Ti 一期 · 炉层杂质预警特征工程模板资产(Issue #70 / PRD 5.3 ③)
|
||||
#
|
||||
# 换行业只改本配置,特征引擎(features.py)零改动(PRD 5.3:特征工程层
|
||||
# 跨行业差异落在 FeatureSpec,不落代码)。
|
||||
#
|
||||
# 测点对齐 templates/ti-cl4/point-dict/point_dict.default.csv 的 point_id:
|
||||
# CLF-01.TEMP 炉温 CLF-01.PRES 炉压
|
||||
# CLF-01.CL2 氯气流量 CLF-01.CO CO含量
|
||||
# CLF-01.CO2 CO₂含量 CLF-01.BED 炉层状态
|
||||
#
|
||||
# 验收口径(PRD 5.3 ③ / 表验收):炉层杂质预警提前 ≥ 30min,误报率 ≤ 8%。
|
||||
# 一期阈值 + 无监督上线;阈值参考工艺规范(沸腾氯化炉温 850±50℃,超 900℃
|
||||
# 触发降流减料),3 个月后转监督再调参。
|
||||
template: ti-cl4
|
||||
version: 1.0.0
|
||||
description: 炉层杂质预警特征工程(声明式 FeatureSpec,PRD 5.3 ③)
|
||||
|
||||
specs:
|
||||
# ---- 炉温:原始 + 平滑 + 变化率(趋势预警) -------------------------
|
||||
- name: 炉温_raw
|
||||
kind: raw
|
||||
point: CLF-01.TEMP
|
||||
unit: ℃
|
||||
threshold: 900.0 # 工艺上限:超 900℃ 触发降流减料(SOP-CL-001)
|
||||
- name: 炉温_ema5
|
||||
kind: ema
|
||||
point: CLF-01.TEMP
|
||||
unit: ℃
|
||||
params: {alpha: 0.2}
|
||||
threshold: 900.0 # 平滑后更稳,避免毛刺误报
|
||||
- name: 炉温_rate10
|
||||
kind: rate_of_change
|
||||
point: CLF-01.TEMP
|
||||
unit: "1/min"
|
||||
params: {window: 10}
|
||||
threshold: 0.05 # 5%/10min 急升趋势,提前量信号
|
||||
|
||||
# ---- 氯气流量:波动度(流态化异常先兆) ------------------------------
|
||||
- name: 氯气流量_raw
|
||||
kind: raw
|
||||
point: CLF-01.CL2
|
||||
unit: m³/h
|
||||
- name: 氯气流量_std10
|
||||
kind: rolling_std
|
||||
point: CLF-01.CL2
|
||||
unit: m³/h
|
||||
params: {window: 10}
|
||||
threshold: 8.0 # 滚动标准差越界 → 供料不稳
|
||||
|
||||
# ---- 炉压:变化率(压力异常是炉层状态恶化强信号) --------------------
|
||||
- name: 炉压_raw
|
||||
kind: raw
|
||||
point: CLF-01.PRES
|
||||
unit: kPa
|
||||
- name: 炉压_rate10
|
||||
kind: rate_of_change
|
||||
point: CLF-01.PRES
|
||||
unit: "1/min"
|
||||
params: {window: 10}
|
||||
threshold: 0.08 # 8%/10min 压力急变
|
||||
|
||||
# ---- 炉层状态:原始值 + 滚动均值(杂质富集表征) ---------------------
|
||||
- name: 炉层状态_raw
|
||||
kind: raw
|
||||
point: CLF-01.BED
|
||||
unit: "%"
|
||||
threshold: 85.0
|
||||
- name: 炉层状态_mean10
|
||||
kind: rolling_mean
|
||||
point: CLF-01.BED
|
||||
unit: "%"
|
||||
params: {window: 10}
|
||||
threshold: 85.0
|
||||
@@ -0,0 +1,559 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""炉层杂质预警 · 声明式特征工程引擎(Issue #70 / PRD 5.3 异常·杂质预警)。
|
||||
|
||||
PRD 5.3 明确:**特征工程层的跨行业差异落在 FeatureSpec,不落代码**。
|
||||
即特征以声明式规格描述(如 ``EMA(炉温, 5min)``、``RollingStd(氯气流量, 10)``、
|
||||
``RateOfChange(炉压)``),由工艺模板定义、本引擎解释执行;换行业只改模板
|
||||
配置(``config/features.template.yaml``),引擎零改动。
|
||||
|
||||
一期(Template-Ti)落地「炉层杂质预警」(PRD 5.3 ③,验收:提前 ≥ 30min、
|
||||
误报率 ≤ 8%)。数据门槛低,先以**阈值 + 无监督**上线,3 个月后转监督
|
||||
(PRD 4.1);故本期特征工程面向无监督异常评分,同时产出监督可用的特征矩阵。
|
||||
|
||||
设计要点
|
||||
--------
|
||||
1. **声明式 FeatureSpec**:每条特征声明 ``kind``(算子)+ ``point``(来源测点)
|
||||
+ ``params``(窗口/周期等),引擎按 kind 分派到内置算子。
|
||||
2. **内置算子**(零第三方依赖,纯标准库):
|
||||
- ``raw`` 原始值透传;
|
||||
- ``ema`` 指数滑动平均(平滑、去噪);
|
||||
- ``rolling_std`` 滚动标准差(波动度);
|
||||
- ``rate_of_change`` 变化率(速率预警);
|
||||
- ``rolling_mean`` 滚动均值;
|
||||
- ``rolling_min`` / ``rolling_max`` 滚动极值(配合阈值)。
|
||||
3. **时序对齐**:按时间戳对齐多测点为特征向量,缺失测点用 ``NaN`` 占位
|
||||
并记录缺失率(误报率治理输入)。
|
||||
4. **零依赖 YAML 子集解析**(与 data-bus / rag-kb 同款),解析模板资产。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Callable, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
# 缺失值统一用 float('nan'),便于上层用 math.isnan 判定与屏蔽。
|
||||
NAN = float("nan")
|
||||
|
||||
|
||||
class FeatureSpecError(ValueError):
|
||||
"""FeatureSpec 声明或执行错误(未知算子 / 缺参 / 窗口非法等)。"""
|
||||
|
||||
|
||||
class FeatureKind(str, Enum):
|
||||
"""内置特征算子(声明式 FeatureSpec 的 ``kind`` 取值)。"""
|
||||
|
||||
RAW = "raw" # 原始值透传
|
||||
EMA = "ema" # 指数滑动平均:params={"alpha": 0.2}
|
||||
ROLLING_STD = "rolling_std" # 滚动标准差:params={"window": 10}
|
||||
ROLLING_MEAN = "rolling_mean" # 滚动均值
|
||||
ROLLING_MIN = "rolling_min" # 滚动最小值
|
||||
ROLLING_MAX = "rolling_max" # 滚动最大值
|
||||
RATE_OF_CHANGE = "rate_of_change" # 变化率:(x[t]-x[t-w])/x[t-w]
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return {
|
||||
FeatureKind.RAW: "原始值",
|
||||
FeatureKind.EMA: "指数滑动平均",
|
||||
FeatureKind.ROLLING_STD: "滚动标准差",
|
||||
FeatureKind.ROLLING_MEAN: "滚动均值",
|
||||
FeatureKind.ROLLING_MIN: "滚动最小值",
|
||||
FeatureKind.ROLLING_MAX: "滚动最大值",
|
||||
FeatureKind.RATE_OF_CHANGE: "变化率",
|
||||
}[self]
|
||||
|
||||
|
||||
# 算子注册表:kind 名 → 算子实现。未知 kind 在注册阶段即拒绝(避免拼写漂移)。
|
||||
KIND_REGISTRY: Dict[str, FeatureKind] = {k.value: k for k in FeatureKind}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureSpec:
|
||||
"""单条声明式特征规格(模板配置中的一行特征声明)。
|
||||
|
||||
Attributes:
|
||||
name: 特征输出名(特征向量列名,工艺可读,如 ``炉温_ema5``)。
|
||||
kind: 算子(见 :class:`FeatureKind`)。
|
||||
point: 来源测点 id(对齐点位字典 point_id,如 ``CLF-01.TEMP``)。
|
||||
params: 算子参数(如 EMA 的 alpha、rolling_* 的 window)。
|
||||
unit: 特征单位(可选,用于驾驶舱展示)。
|
||||
threshold: 预警阈值(可选,无监督阈值上线的判定边界)。
|
||||
"""
|
||||
|
||||
name: str
|
||||
kind: FeatureKind
|
||||
point: str
|
||||
params: Dict[str, float] = field(default_factory=dict)
|
||||
unit: str = ""
|
||||
threshold: Optional[float] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.name:
|
||||
raise FeatureSpecError("FeatureSpec.name 不能为空")
|
||||
if not self.point:
|
||||
raise FeatureSpecError(f"特征 {self.name!r} 缺少 point(来源测点)")
|
||||
# 参数合法性校验:滚动/变化率类必须有正整数 window
|
||||
if self.kind in (FeatureKind.ROLLING_STD, FeatureKind.ROLLING_MEAN,
|
||||
FeatureKind.ROLLING_MIN, FeatureKind.ROLLING_MAX,
|
||||
FeatureKind.RATE_OF_CHANGE):
|
||||
w = self.params.get("window")
|
||||
if w is None:
|
||||
raise FeatureSpecError(
|
||||
f"特征 {self.name!r}({self.kind.value})缺少 window 参数")
|
||||
try:
|
||||
wf = float(w)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise FeatureSpecError(
|
||||
f"特征 {self.name!r} window 必须是整数,实际 {w!r}") from exc
|
||||
if wf != int(wf):
|
||||
raise FeatureSpecError(
|
||||
f"特征 {self.name!r} window 必须是整数,实际 {w!r}")
|
||||
wi = int(wf)
|
||||
if wi <= 0:
|
||||
raise FeatureSpecError(
|
||||
f"特征 {self.name!r} window 必须 > 0,实际 {wi}")
|
||||
self.params["window"] = wi
|
||||
if self.kind is FeatureKind.EMA:
|
||||
alpha = self.params.get("alpha")
|
||||
if alpha is None:
|
||||
raise FeatureSpecError(f"特征 {self.name!r}(ema)缺少 alpha 参数")
|
||||
try:
|
||||
af = float(alpha)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise FeatureSpecError(
|
||||
f"特征 {self.name!r} alpha 必须是数值,实际 {alpha!r}") from exc
|
||||
if not (0.0 < af <= 1.0):
|
||||
raise FeatureSpecError(
|
||||
f"特征 {self.name!r} alpha 须在 (0,1],实际 {af}")
|
||||
self.params["alpha"] = af
|
||||
|
||||
def describe(self) -> str:
|
||||
"""工艺可读描述,如 ``炉温_ema5 = ema(CLF-01.TEMP, alpha=0.2)``。"""
|
||||
pa = ", ".join(f"{k}={v}" for k, v in self.params.items())
|
||||
return f"{self.name} = {self.kind.value}({self.point}{', ' + pa if pa else ''})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 算子实现:输入为按时间排序的标量序列(可能含 NAN),输出等长变换序列。
|
||||
# 滚动窗口在序列前段(样本不足 window 个)输出 NAN,表示"尚不足以计算"。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _is_num(x: object) -> bool:
|
||||
return isinstance(x, (int, float)) and not (isinstance(x, float) and math.isnan(x))
|
||||
|
||||
|
||||
def _rolling_window(values: Sequence[float], window: int,
|
||||
reducer) -> List[float]:
|
||||
"""通用滚动归约:前 window-1 个位置输出 NAN。"""
|
||||
out: List[float] = []
|
||||
buf: List[float] = []
|
||||
for v in values:
|
||||
if _is_num(v):
|
||||
buf.append(float(v))
|
||||
# 非数值视为缺失,不进缓冲区(窗口按"有效样本数"计数,更稳健)
|
||||
if len(buf) >= window:
|
||||
out.append(float(reducer(buf[-window:])))
|
||||
else:
|
||||
out.append(NAN)
|
||||
return out
|
||||
|
||||
|
||||
def _op_raw(values: Sequence[float], params: Dict[str, float]) -> List[float]:
|
||||
return [float(v) if _is_num(v) else NAN for v in values]
|
||||
|
||||
|
||||
def _op_ema(values: Sequence[float], params: Dict[str, float]) -> List[float]:
|
||||
alpha = float(params["alpha"])
|
||||
out: List[float] = []
|
||||
prev: Optional[float] = None
|
||||
for v in values:
|
||||
if not _is_num(v):
|
||||
out.append(NAN)
|
||||
continue
|
||||
x = float(v)
|
||||
prev = x if prev is None else (alpha * x + (1.0 - alpha) * prev)
|
||||
out.append(prev)
|
||||
return out
|
||||
|
||||
|
||||
def _op_rate_of_change(values: Sequence[float],
|
||||
params: Dict[str, float]) -> List[float]:
|
||||
window = int(params["window"])
|
||||
out: List[float] = []
|
||||
num: List[float] = []
|
||||
for v in values:
|
||||
if _is_num(v):
|
||||
num.append(float(v))
|
||||
if len(num) >= window + 1:
|
||||
base = num[-(window + 1)]
|
||||
cur = num[-1]
|
||||
out.append((cur - base) / base if base else NAN)
|
||||
else:
|
||||
out.append(NAN)
|
||||
return out
|
||||
|
||||
|
||||
# kind → 算子函数 注册(FeatureEngine 分派用)
|
||||
OPERATORS: Dict[FeatureKind, Callable[[Sequence[float], Dict[str, float]], List[float]]] = {
|
||||
FeatureKind.RAW: _op_raw,
|
||||
FeatureKind.EMA: _op_ema,
|
||||
FeatureKind.ROLLING_STD: lambda v, p: _rolling_window(v, int(p["window"]),
|
||||
lambda w: _std(w)),
|
||||
FeatureKind.ROLLING_MEAN: lambda v, p: _rolling_window(v, int(p["window"]),
|
||||
lambda w: sum(w) / len(w)),
|
||||
FeatureKind.ROLLING_MIN: lambda v, p: _rolling_window(v, int(p["window"]), min),
|
||||
FeatureKind.ROLLING_MAX: lambda v, p: _rolling_window(v, int(p["window"]), max),
|
||||
FeatureKind.RATE_OF_CHANGE: _op_rate_of_change,
|
||||
}
|
||||
|
||||
|
||||
def _std(samples: Sequence[float]) -> float:
|
||||
"""总体标准差(无监督波动度特征;零依赖实现)。"""
|
||||
n = len(samples)
|
||||
if n == 0:
|
||||
return NAN
|
||||
mean = sum(samples) / n
|
||||
var = sum((x - mean) ** 2 for x in samples) / n
|
||||
return math.sqrt(var)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 特征值 / 特征向量 / 引擎
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FeatureValue = float # 单个特征值(可能为 NAN)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureVector:
|
||||
"""某时刻对齐后的特征向量(多特征列 + 时间戳 + 缺失率)。"""
|
||||
|
||||
timestamp: float
|
||||
values: Dict[str, FeatureValue] # name → 特征值
|
||||
missing_rate: float = 0.0 # 本时刻缺失特征占比(误报率治理输入)
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
"""所有特征均非缺失(监督训练样本需完整向量)。"""
|
||||
return all(_is_num(v) for v in self.values.values())
|
||||
|
||||
def breach_features(self) -> List[str]:
|
||||
"""返回超阈值 breach 的特征名(无监督阈值上线判定)。"""
|
||||
# 阈值判定由 FeatureEngine 注入(见 engine.breach),这里仅占位。
|
||||
return []
|
||||
|
||||
|
||||
class FeatureEngine:
|
||||
"""声明式特征工程引擎:解释 FeatureSpec 列表,对时序样本计算特征矩阵。
|
||||
|
||||
换行业只改模板配置(FeatureSpec 列表),引擎零改动(PRD 5.3)。
|
||||
|
||||
用法::
|
||||
|
||||
engine = FeatureEngine(specs)
|
||||
vectors = engine.transform(samples)
|
||||
for vec in vectors:
|
||||
if vec.is_complete:
|
||||
... # 喂给无监督评分器或监督训练
|
||||
"""
|
||||
|
||||
def __init__(self, specs: Sequence[FeatureSpec]):
|
||||
if not specs:
|
||||
raise FeatureSpecError("FeatureEngine 至少需要一条 FeatureSpec")
|
||||
# 同名特征直接拒绝(避免特征矩阵列冲突)
|
||||
seen = set()
|
||||
for s in specs:
|
||||
if s.name in seen:
|
||||
raise FeatureSpecError(f"特征名重复:{s.name!r}")
|
||||
seen.add(s.name)
|
||||
self.specs: List[FeatureSpec] = list(specs)
|
||||
# 按来源测点聚合,减少重复取数
|
||||
self._by_point: Dict[str, List[FeatureSpec]] = {}
|
||||
for s in self.specs:
|
||||
self._by_point.setdefault(s.point, []).append(s)
|
||||
|
||||
# -- 配置资产 ---------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_template_config(cls, path: str) -> "FeatureEngine":
|
||||
"""从模板特征配置 YAML 资产构建引擎(零第三方依赖)。"""
|
||||
return cls(load_feature_config(path).specs)
|
||||
|
||||
# -- 计算 -------------------------------------------------------------
|
||||
|
||||
def required_points(self) -> List[str]:
|
||||
"""引擎依赖的全部来源测点 id(去重保序)。"""
|
||||
seen, out = set(), []
|
||||
for s in self.specs:
|
||||
if s.point not in seen:
|
||||
seen.add(s.point)
|
||||
out.append(s.point)
|
||||
return out
|
||||
|
||||
def transform(self, samples: Sequence[Dict[str, object]],
|
||||
ts_key: str = "ts") -> List[FeatureVector]:
|
||||
"""把时序样本流变换为按时间对齐的特征向量序列。
|
||||
|
||||
Args:
|
||||
samples: 按 时间升序 排列的样本列表;每条样本是 ``{ts_key: epoch秒,
|
||||
point_id: value, ...}`` 形态的 dict(对齐点位字典 point_id)。
|
||||
ts_key: 时间戳键名(默认 ``ts``)。
|
||||
|
||||
Returns:
|
||||
与 samples 等长的 FeatureVector 列表(按时间对齐)。
|
||||
"""
|
||||
if not samples:
|
||||
return []
|
||||
# 1) 按测点抽取时间序列(保持原顺序)
|
||||
point_series: Dict[str, List[float]] = {p: [] for p in self._by_point}
|
||||
timestamps: List[float] = []
|
||||
for sample in samples:
|
||||
ts = sample.get(ts_key)
|
||||
try:
|
||||
timestamps.append(float(ts) if ts is not None else NAN)
|
||||
except (TypeError, ValueError):
|
||||
timestamps.append(NAN)
|
||||
for p in point_series:
|
||||
v = sample.get(p)
|
||||
point_series[p].append(float(v) if _is_num(v) else NAN)
|
||||
|
||||
# 2) 对每个测点的序列逐特征计算
|
||||
# feature_columns[name] = 与时间等长的特征值序列
|
||||
feature_columns: Dict[str, List[float]] = {}
|
||||
for point, series in point_series.items():
|
||||
for spec in self._by_point[point]:
|
||||
op = OPERATORS.get(spec.kind)
|
||||
if op is None: # 理论上 __post_init__ 已拦截,防御性
|
||||
raise FeatureSpecError(f"未实现的算子 {spec.kind.value!r}")
|
||||
feature_columns[spec.name] = op(series, spec.params)
|
||||
|
||||
# 3) 按时间戳对齐为特征向量
|
||||
n = len(samples)
|
||||
names = [s.name for s in self.specs]
|
||||
vectors: List[FeatureVector] = []
|
||||
for i in range(n):
|
||||
row = {name: feature_columns[name][i] for name in names}
|
||||
missing = sum(1 for v in row.values() if not _is_num(v))
|
||||
vectors.append(FeatureVector(
|
||||
timestamp=timestamps[i],
|
||||
values=row,
|
||||
missing_rate=missing / len(names) if names else 0.0,
|
||||
))
|
||||
return vectors
|
||||
|
||||
# -- 无监督阈值判定(一期上线口径) -----------------------------------
|
||||
|
||||
def breach(self, vector: FeatureVector) -> List[Tuple[str, float]]:
|
||||
"""返回超阈值的 ``(特征名, 当前值)`` 列表(无监督阈值上线判定)。
|
||||
|
||||
一期 PRD 5.3 ③:阈值 + 无监督先上线;3 个月后转监督。本方法支持
|
||||
FeatureSpec 声明的 ``threshold``(绝对值越界即 breach)。
|
||||
"""
|
||||
out: List[Tuple[str, float]] = []
|
||||
spec_by_name = {s.name: s for s in self.specs}
|
||||
for name, val in vector.values.items():
|
||||
if not _is_num(val):
|
||||
continue
|
||||
spec = spec_by_name.get(name)
|
||||
if spec is None or spec.threshold is None:
|
||||
continue
|
||||
if val > spec.threshold:
|
||||
out.append((name, val))
|
||||
return out
|
||||
|
||||
def describe(self) -> List[str]:
|
||||
"""返回全部特征的工艺可读描述(文档/审计用)。"""
|
||||
return [s.describe() for s in self.specs]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模板配置资产(零依赖 YAML 子集解析,对齐 data-bus / rag-kb)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class FeatureTemplateConfig:
|
||||
"""模板特征配置:模板元信息 + FeatureSpec 列表。"""
|
||||
|
||||
template: str
|
||||
version: str
|
||||
specs: List[FeatureSpec]
|
||||
description: str = ""
|
||||
|
||||
|
||||
def _parse_scalar(text: str) -> str:
|
||||
"""去掉标量两侧引号与行内注释。"""
|
||||
t = text.split(" #", 1)[0].strip()
|
||||
if len(t) >= 2 and t[0] == t[-1] and t[0] in ("'", '"'):
|
||||
return t[1:-1]
|
||||
return t
|
||||
|
||||
|
||||
def _parse_flow_value(text: str):
|
||||
"""解析 ``key: value`` 右侧的值,支持行内 flow map ``{k: v, k: v}``。
|
||||
|
||||
其余(标量 / 引号串)退化为 :func:`_parse_scalar`。flow map 用于
|
||||
``params: {alpha: 0.2, window: 10}`` 这种紧凑声明。
|
||||
"""
|
||||
t = text.split(" #", 1)[0].strip()
|
||||
if t.startswith("{") and t.endswith("}"):
|
||||
inner = t[1:-1].strip()
|
||||
out: Dict[str, object] = {}
|
||||
if not inner:
|
||||
return out
|
||||
for part in inner.split(","):
|
||||
if ":" not in part:
|
||||
raise FeatureSpecError(f"flow map 项不是键值对:{part!r}")
|
||||
k, _, v = part.partition(":")
|
||||
out[k.strip()] = _parse_scalar(v)
|
||||
return out
|
||||
return _parse_scalar(text)
|
||||
|
||||
|
||||
def _strip_comments(lines: List[str]) -> List[Tuple[str, int]]:
|
||||
out = []
|
||||
for i, ln in enumerate(lines):
|
||||
s = ln.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
out.append((ln, i + 1))
|
||||
return out
|
||||
|
||||
|
||||
def _parse_node(lines: List[Tuple[str, int]], i: int, indent: int):
|
||||
"""递归解析 YAML 节点(map / list / scalar)。返回 (value, next_i)。"""
|
||||
text, _ = lines[i]
|
||||
# ---- list 节点 ----
|
||||
if text.lstrip(" ").startswith("- "):
|
||||
items: List[object] = []
|
||||
while i < len(lines):
|
||||
t, no = lines[i]
|
||||
stripped = t.lstrip(" ")
|
||||
if not stripped.startswith("- "):
|
||||
break
|
||||
lead_j = len(t) - len(t.lstrip(" "))
|
||||
if lead_j != indent:
|
||||
break
|
||||
item_text = stripped[2:].strip()
|
||||
if not item_text:
|
||||
raise FeatureSpecError(f"features.yaml 第 {no} 行:list 项为空")
|
||||
if ":" in item_text:
|
||||
map_indent = len(t) - len(t.lstrip(" ")) + 2
|
||||
lines[i] = (" " * map_indent + item_text, no)
|
||||
v, i = _parse_node(lines, i, map_indent)
|
||||
items.append(v)
|
||||
else:
|
||||
items.append(_parse_flow_value(item_text))
|
||||
i += 1
|
||||
return items, i
|
||||
# ---- map 节点 ----
|
||||
result: Dict[str, object] = {}
|
||||
while i < len(lines):
|
||||
t, no = lines[i]
|
||||
lead_j = len(t) - len(t.lstrip(" "))
|
||||
if lead_j < indent or t.lstrip(" ").startswith("- "):
|
||||
break
|
||||
if lead_j > indent:
|
||||
raise FeatureSpecError(
|
||||
f"features.yaml 第 {no} 行缩进异常(期望 {indent},实际 {lead_j})")
|
||||
if ":" not in t:
|
||||
raise FeatureSpecError(f"features.yaml 第 {no} 行不是合法键值对:{t!r}")
|
||||
key, _, rest = t.partition(":")
|
||||
key = key.strip()
|
||||
rest = rest.strip()
|
||||
if rest:
|
||||
result[key] = _parse_flow_value(rest)
|
||||
i += 1
|
||||
continue
|
||||
if i + 1 >= len(lines):
|
||||
raise FeatureSpecError(f"features.yaml 第 {no} 行 {key!r} 缺少值")
|
||||
sub_indent = len(lines[i + 1][0]) - len(lines[i + 1][0].lstrip(" "))
|
||||
if sub_indent <= indent:
|
||||
raise FeatureSpecError(f"features.yaml 第 {no} 行 {key!r} 缺少值(无嵌套)")
|
||||
v, i = _parse_node(lines, i + 1, sub_indent)
|
||||
result[key] = v
|
||||
return result, i
|
||||
|
||||
|
||||
def _load_yaml_text(text: str) -> Dict[str, object]:
|
||||
lines = _strip_comments(text.splitlines())
|
||||
if not lines:
|
||||
return {}
|
||||
top_indent = len(lines[0][0]) - len(lines[0][0].lstrip(" "))
|
||||
value, next_i = _parse_node(lines, 0, top_indent)
|
||||
if not isinstance(value, dict):
|
||||
raise FeatureSpecError("features.yaml 顶层必须是 map")
|
||||
if next_i < len(lines):
|
||||
raise FeatureSpecError(
|
||||
f"features.yaml 第 {lines[next_i][1]} 行:顶层存在多个节点")
|
||||
return value
|
||||
|
||||
|
||||
def load_feature_config(path: str) -> FeatureTemplateConfig:
|
||||
"""从模板特征 YAML 资产加载配置。
|
||||
|
||||
期望结构(详见 ``config/features.template.yaml``)::
|
||||
|
||||
template: ti-cl4
|
||||
version: 1.0.0
|
||||
description: 炉层杂质预警特征工程
|
||||
specs:
|
||||
- name: 炉温_raw
|
||||
kind: raw
|
||||
point: CLF-01.TEMP
|
||||
- name: 炉温_ema5
|
||||
kind: ema
|
||||
point: CLF-01.TEMP
|
||||
params: {alpha: 0.2}
|
||||
threshold: 900.0
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = _load_yaml_text(fh.read())
|
||||
|
||||
template = str(data.get("template", "")).strip()
|
||||
if not template:
|
||||
raise FeatureSpecError("features.yaml 缺少 template 字段")
|
||||
version = str(data.get("version", "1.0.0")).strip() or "1.0.0"
|
||||
description = str(data.get("description", "")).strip()
|
||||
|
||||
raw_specs = data.get("specs") or []
|
||||
if not isinstance(raw_specs, list):
|
||||
raise FeatureSpecError("features.yaml specs 必须是 list")
|
||||
specs: List[FeatureSpec] = []
|
||||
for idx, item in enumerate(raw_specs):
|
||||
if not isinstance(item, dict):
|
||||
raise FeatureSpecError(f"features.yaml specs[{idx}] 必须是 map")
|
||||
name = str(item.get("name", "")).strip()
|
||||
kind_name = str(item.get("kind", "")).strip()
|
||||
if kind_name not in KIND_REGISTRY:
|
||||
raise FeatureSpecError(
|
||||
f"features.yaml specs[{idx}] 未知算子 {kind_name!r}"
|
||||
f"(应为 {sorted(KIND_REGISTRY)})")
|
||||
point = str(item.get("point", "")).strip()
|
||||
unit = str(item.get("unit", "")).strip()
|
||||
raw_params = item.get("params") or {}
|
||||
if not isinstance(raw_params, dict):
|
||||
raise FeatureSpecError(f"features.yaml specs[{idx}] params 必须是 map")
|
||||
params: Dict[str, float] = {}
|
||||
for pk, pv in raw_params.items():
|
||||
try:
|
||||
params[pk] = float(pv)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise FeatureSpecError(
|
||||
f"features.yaml specs[{idx}] 参数 {pk}={pv!r} 不是数值") from exc
|
||||
threshold = item.get("threshold")
|
||||
if threshold not in (None, ""):
|
||||
try:
|
||||
threshold = float(threshold)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise FeatureSpecError(
|
||||
f"features.yaml specs[{idx}] threshold 不是数值") from exc
|
||||
else:
|
||||
threshold = None
|
||||
specs.append(FeatureSpec(
|
||||
name=name, kind=KIND_REGISTRY[kind_name], point=point,
|
||||
params=params, unit=unit, threshold=threshold,
|
||||
))
|
||||
return FeatureTemplateConfig(
|
||||
template=template, version=version, specs=specs, description=description)
|
||||
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""测试引导:把连字符目录挂载为可导入包(与 core 模块同款模式)。
|
||||
|
||||
- ``templates/ti-cl4/impurity-forecast`` → 包名 ``impurity_forecast``。
|
||||
本引擎零内核依赖(纯标准库),仅挂载自身包即可。
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _load_package(name: str, path: str) -> None:
|
||||
"""按文件路径完整加载一个包(执行其 __init__.py)。"""
|
||||
if name in sys.modules:
|
||||
return
|
||||
init_py = os.path.join(path, "__init__.py")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, init_py, submodule_search_locations=[path])
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
_load_package("impurity_forecast", PKG_DIR)
|
||||
@@ -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