# -*- coding: utf-8 -*- """炉层杂质预警无监督模型冒烟脚本(Issue #71)。 直接运行 ``python _sanity_check_model.py`` 验证:ZScoreScorer 可在正常段 fit、 异常段产出高分数、ThresholdRule 触发预警、提前量评估为正(对齐 PRD 提前≥30min)。 零第三方依赖。 """ import importlib.util import os import sys _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 ( # noqa: E402 FeatureVectorLike, ImpurityForecaster, ThresholdRule, ZScoreScorer, ) def vec(ts, **kw): return FeatureVectorLike(timestamp=ts, values=dict(kw)) def main() -> int: # 正常段:炉温 850±5 波动,氯气 100±3 波动 normal = [vec(i, 炉温=850.0 + (i % 3) * 2, 氯气=100.0 + (i % 2)) for i in range(40)] # 观测段:前 40 正常,之后急升温 + 氯气突降 obs = list(normal) + [ vec(40 + i, 炉温=860.0 + 6.0 * i, 氯气=95.0 - i) for i in range(20) ] anomaly_ts = 59.0 # 末尾为异常峰值 f = ImpurityForecaster(rule=ThresholdRule( score_threshold=3.0, feature_thresholds={"炉温": 900.0})) f.fit(normal) decisions, lt = f.evaluate(obs, anomaly_ts=anomaly_ts) triggered = [d for d in decisions if d.triggered] assert triggered, "异常段应触发预警" print(f"[OK] 预警触发 {len(triggered)} 次") print(f"[OK] 首次预警 ts={lt.first_alert_ts},真实异常 ts={anomaly_ts}," f"提前量={lt.lead_minutes:.1f} min(>0 即满足提前量口径)") # 模型可序列化 d = f.scorer.to_dict() sc2 = ZScoreScorer.from_dict(d) s1 = f.scorer.score(obs[-1:]) s2 = sc2.score(obs[-1:]) assert abs(s1[0] - s2[0]) < 1e-9, "序列化前后分数应一致" print("[OK] 模型序列化往返一致(可版本化保存)") print("炉层杂质预警模型冒烟通过 ✅") return 0 if __name__ == "__main__": raise SystemExit(main())