Preformu / tests /test_arrhenius.py
Kevinshh's picture
Deploy Kernel+Skill architecture to HF Spaces; wire advanced stability features; remove deprecated entry points
19729e9
Raw
History Blame Contribute Delete
17.3 kB
"""湿度修正 Arrhenius 与温湿度双因素建模的单元测试(任务 13)。
覆盖需求:
- 10.1:提供多温度且含 RH 时支持 Extended Arrhenius 双因素模型
(lnk = lnA − Ea/RT + B·RH)拟合。
- 10.2:数据不足以支撑双因素模型时优雅降级为标准 Arrhenius / 单点假设,
并声明所用模型与原因。
- 10.3:Ea 采用默认假设值(83.14 kJ/mol)时明确标注其为假设值而非实测值。
- 10.4:仅 2 个温度点时计算实测 Ea 但声明双温点回归的统计可信度局限。
- 10.5:Arrhenius / Extended Arrhenius 计算为纯 Python(compute 阶段),不依赖 LLM。
导入路径由 ``tests/conftest.py`` 与仓库根 ``conftest.py`` 统一设置,
故可直接导入顶层 ``skills`` 包。
"""
from __future__ import annotations
import inspect
import math
import sys
import numpy as np
import pytest
from skills.stability.arrhenius import (
R,
EA_DEFAULT,
MODEL_EXTENDED,
MODEL_STANDARD,
MODEL_ASSUMED,
ArrheniusResult,
analyze_arrhenius,
fit_standard_arrhenius,
fit_moisture_arrhenius,
)
# ---------------------------------------------------------------------------
# 合成数据辅助:由已知模型生成 k 值,便于检验参数回收
# ---------------------------------------------------------------------------
def _k_standard(temps_K, ln_A, Ea):
"""标准 Arrhenius:k = exp(lnA − Ea/RT)。"""
temps = np.asarray(temps_K, dtype=float)
return np.exp(ln_A - Ea / (R * temps)).tolist()
def _k_extended(temps_K, rh, ln_A, Ea, B):
"""双因素:k = exp(lnA − Ea/RT + B·RH)。"""
temps = np.asarray(temps_K, dtype=float)
rh = np.asarray(rh, dtype=float)
return np.exp(ln_A - Ea / (R * temps) + B * rh).tolist()
def _collinear_rh(temps_K):
"""构造与 1/T 严格仿射相关的 RH 序列(使设计矩阵秩不足)。
令 RH = a + b·(1/T),则 [1, 1/T, RH] 三列线性相关,rank < 3。
选取 a/b 使生成的 RH 落在合理的非负范围内。
"""
inv = 1.0 / np.asarray(temps_K, dtype=float)
inv_lo, inv_hi = inv.min(), inv.max()
# 把 [inv_lo, inv_hi] 线性映射到 [20, 60] %RH。
if math.isclose(inv_hi, inv_lo):
return (np.full_like(inv, 40.0)).tolist()
b = (60.0 - 20.0) / (inv_hi - inv_lo)
a = 20.0 - b * inv_lo
return (a + b * inv).tolist()
# 常用真值。
LN_A_TRUE = 30.0
EA_TRUE = 80000.0 # 80 kJ/mol
B_TRUE = 0.04 # 每 %RH 的 lnk 增量
T40, T50, T60 = 313.15, 323.15, 333.15
# ---------------------------------------------------------------------------
# 需求 10.1:双因素(Extended Arrhenius)拟合
# ---------------------------------------------------------------------------
def test_moisture_arrhenius_recovers_parameters():
"""无噪声合成数据下,双因素拟合应精确回收 lnA / Ea / B。"""
temps = [T40, T40, T60, T60]
rh = [25.0, 60.0, 25.0, 60.0]
ks = _k_extended(temps, rh, LN_A_TRUE, EA_TRUE, B_TRUE)
res = fit_moisture_arrhenius(temps, rh, ks)
assert res.model == MODEL_EXTENDED
assert res.is_moisture_model is True
assert res.B == pytest.approx(B_TRUE, rel=1e-6)
assert res.Ea == pytest.approx(EA_TRUE, rel=1e-6)
assert res.ln_A == pytest.approx(LN_A_TRUE, rel=1e-6)
assert res.ea_is_assumed is False
assert res.n_temperatures == 2
assert res.n_rh_levels == 2
assert res.n_points == 4
# n=4, 3 参数 → df=1 → 有自由度 → 无统计局限标记。
assert res.statistical_limitation is False
def test_moisture_arrhenius_with_degrees_of_freedom():
"""更多数据点(n > 3)时双因素模型具自由度,R² 有意义且无统计局限标记。"""
temps = [T40, T40, T50, T60, T60, T50]
rh = [25.0, 60.0, 40.0, 25.0, 60.0, 75.0]
ks = _k_extended(temps, rh, LN_A_TRUE, EA_TRUE, B_TRUE)
res = fit_moisture_arrhenius(temps, rh, ks)
assert res.model == MODEL_EXTENDED
assert res.statistical_limitation is False
assert res.R2 == pytest.approx(1.0, abs=1e-9) # 无噪声 → 完美拟合
assert res.B == pytest.approx(B_TRUE, rel=1e-6)
def test_analyze_selects_extended_when_sufficient():
"""analyze_arrhenius 在温度/湿度水平与点数充足时自动选择双因素模型。"""
temps = [T40, T40, T60, T60, T50]
rh = [25.0, 60.0, 25.0, 60.0, 75.0]
ks = _k_extended(temps, rh, LN_A_TRUE, EA_TRUE, B_TRUE)
res = analyze_arrhenius(temps, ks, rh_values=rh)
assert res.model == MODEL_EXTENDED
assert res.degraded is False
assert res.B is not None
def test_moisture_arrhenius_rejects_collinear_design():
"""温度与湿度共线(无法分离效应)时双因素拟合抛错以触发降级。"""
# RH 与 1/T 严格仿射相关 → 设计矩阵 [1, 1/T, RH] 秩不足。
temps = [T40, T50, T60, T40]
rh = _collinear_rh(temps)
ks = _k_extended(temps, rh, LN_A_TRUE, EA_TRUE, B_TRUE)
with pytest.raises(ValueError):
fit_moisture_arrhenius(temps, rh, ks)
# ---------------------------------------------------------------------------
# 需求 10.2:降级路径(标准 Arrhenius / 假设值)并声明
# ---------------------------------------------------------------------------
def test_degrade_to_standard_when_rh_levels_insufficient():
"""提供 RH 但仅单一湿度水平 → 降级标准 Arrhenius 并声明原因。"""
temps = [T40, T50, T60]
rh = [60.0, 60.0, 60.0] # 仅 1 个 RH 水平
ks = _k_standard(temps, LN_A_TRUE, EA_TRUE)
res = analyze_arrhenius(temps, ks, rh_values=rh)
assert res.model == MODEL_STANDARD
assert res.degraded is True
assert res.B is None
assert "湿度水平不足" in res.degrade_reason
# 声明中应说明降级与原因(需求 10.2)。
assert any("降级" in d for d in res.declarations)
def test_degrade_to_standard_when_points_insufficient():
"""RH 水平足够但点数 < 4(不足以支撑三参数)→ 降级标准 Arrhenius。"""
temps = [T40, T60, T40]
rh = [25.0, 60.0, 60.0] # 2 温度水平、2 RH 水平,但仅 3 点
ks = _k_standard(temps, LN_A_TRUE, EA_TRUE)
res = analyze_arrhenius(temps, ks, rh_values=rh)
assert res.model == MODEL_STANDARD
assert res.degraded is True
assert "数据点不足" in res.degrade_reason
def test_degrade_to_standard_on_failed_extended_fit():
"""双因素水平/点数表面充足但设计共线时,analyze 捕获异常并降级标准模型。"""
# 4 点、表面 2 温度 2 RH,但 RH 与 1/T 共线 → lstsq 秩不足触发降级。
temps = [T40, T60, T40, T60]
rh = [25.0, 75.0, 25.0, 75.0] # RH 与温度一一绑定 → 共线
ks = _k_standard(temps, LN_A_TRUE, EA_TRUE)
res = analyze_arrhenius(temps, ks, rh_values=rh)
assert res.model == MODEL_STANDARD
assert res.degraded is True
assert res.B is None
def test_standard_arrhenius_recovers_ea():
"""无 RH 的标准 Arrhenius 应回收实测 Ea,且不标记为假设值。"""
temps = [T40, T50, T60]
ks = _k_standard(temps, LN_A_TRUE, EA_TRUE)
res = analyze_arrhenius(temps, ks)
assert res.model == MODEL_STANDARD
assert res.degraded is False
assert res.Ea == pytest.approx(EA_TRUE, rel=1e-6)
assert res.ln_A == pytest.approx(LN_A_TRUE, rel=1e-6)
assert res.ea_is_assumed is False
assert res.statistical_limitation is False # n=3 > 2
# ---------------------------------------------------------------------------
# 需求 10.3:Ea 默认假设值标注
# ---------------------------------------------------------------------------
def test_single_temperature_falls_back_to_assumed_ea():
"""仅单一温度水平 → 采用默认假设 Ea 并明确标注为假设值。"""
temps = [T40, T40, T40] # 仅 1 个温度水平
ks = [1e-4, 1.1e-4, 0.9e-4]
res = analyze_arrhenius(temps, ks)
assert res.model == MODEL_ASSUMED
assert res.ea_is_assumed is True
assert res.degraded is True
assert res.Ea == pytest.approx(EA_DEFAULT, rel=1e-12)
assert res.ea_kj_per_mol == pytest.approx(83.14, abs=1e-2)
assert res.statistical_limitation is True
# 声明须明确标注「假设」与默认值(需求 10.3)。
assert any("假设" in d for d in res.declarations)
def test_assumed_ea_respects_custom_default():
"""可传入自定义默认 Ea;仍标注为假设值。"""
temps = [T50]
ks = [2e-4]
custom = 90000.0
res = analyze_arrhenius(temps, ks, ea_default=custom)
assert res.model == MODEL_ASSUMED
assert res.Ea == pytest.approx(custom, rel=1e-12)
assert res.ea_is_assumed is True
# ---------------------------------------------------------------------------
# 需求 10.4:仅 2 个温度点 → 实测 Ea + 统计局限声明
# ---------------------------------------------------------------------------
def test_two_temperature_points_compute_measured_ea_with_limitation():
"""2 温度点:计算实测 Ea(非假设),但声明无自由度的统计局限。"""
temps = [T40, T60]
ks = _k_standard(temps, LN_A_TRUE, EA_TRUE)
res = analyze_arrhenius(temps, ks)
assert res.model == MODEL_STANDARD
assert res.ea_is_assumed is False # 实测,非假设
assert res.Ea == pytest.approx(EA_TRUE, rel=1e-6) # 双点精确解
assert res.statistical_limitation is True # 无自由度
assert res.n_points == 2
# 声明须提到统计可信度局限 / 无自由度(需求 10.4)。
assert any(
("自由度" in d) or ("局限" in d) or ("可信度" in d)
for d in res.declarations
)
# ---------------------------------------------------------------------------
# 预测与加速因子的物理一致性
# ---------------------------------------------------------------------------
def test_predict_k_matches_known_model():
"""拟合后预测的 k 应与生成数据的真值模型一致。"""
temps = [T40, T50, T60]
ks = _k_standard(temps, LN_A_TRUE, EA_TRUE)
res = fit_standard_arrhenius(temps, ks)
expected = math.exp(LN_A_TRUE - EA_TRUE / (R * T50))
assert res.predict_k(T50) == pytest.approx(expected, rel=1e-6)
def test_acceleration_factor_consistent_with_arrhenius():
"""标准模型的加速因子应等于 exp[(Ea/R)(1/T_long − 1/T_acc)]。"""
temps = [T40, T50, T60]
ks = _k_standard(temps, LN_A_TRUE, EA_TRUE)
res = fit_standard_arrhenius(temps, ks)
af = res.acceleration_factor(T_accelerated=313.15, T_longterm=298.15)
expected = math.exp((EA_TRUE / R) * (1 / 298.15 - 1 / 313.15))
assert af == pytest.approx(expected, rel=1e-6)
assert af > 1.0 # 加速条件速率更高
def test_moisture_model_predict_includes_rh_term():
"""双因素模型预测应随 RH 升高而增大(B > 0 时)。"""
temps = [T40, T40, T60, T60]
rh = [25.0, 60.0, 25.0, 60.0]
ks = _k_extended(temps, rh, LN_A_TRUE, EA_TRUE, B_TRUE)
res = fit_moisture_arrhenius(temps, rh, ks)
k_low_rh = res.predict_k(T50, rh_percent=20.0)
k_high_rh = res.predict_k(T50, rh_percent=80.0)
assert k_high_rh > k_low_rh
# ---------------------------------------------------------------------------
# 输入校验
# ---------------------------------------------------------------------------
def test_rejects_nonpositive_k():
with pytest.raises(ValueError):
analyze_arrhenius([T40, T50], [0.0, 1e-4])
def test_rejects_nonpositive_temperature():
with pytest.raises(ValueError):
analyze_arrhenius([0.0, T50], [1e-4, 2e-4])
def test_rejects_mismatched_lengths():
with pytest.raises(ValueError):
analyze_arrhenius([T40, T50], [1e-4])
def test_rejects_negative_rh():
with pytest.raises(ValueError):
analyze_arrhenius([T40, T50], [1e-4, 2e-4], rh_values=[-5.0, 60.0])
def test_rejects_empty_input():
with pytest.raises(ValueError):
analyze_arrhenius([], [])
# ---------------------------------------------------------------------------
# 需求 10.5:纯 Python,compute 阶段不依赖 LLM(design Property 1)
# ---------------------------------------------------------------------------
def test_compute_functions_take_no_service_or_llm_handle():
"""Arrhenius 计算函数签名不得包含 svc / llm 等服务句柄参数。"""
for fn in (analyze_arrhenius, fit_standard_arrhenius, fit_moisture_arrhenius):
params = set(inspect.signature(fn).parameters)
assert "svc" not in params
assert "llm" not in params
assert "services" not in params
def test_module_does_not_import_llm():
"""模块源码不得引入任何 LLM / 服务依赖(架构级阻断幻觉计算)。"""
import skills.stability.arrhenius as arr_mod
src = inspect.getsource(arr_mod).lower()
for forbidden in ("import openai", "llm_service", "model_invoker", "llm_providers"):
assert forbidden not in src
if __name__ == "__main__": # pragma: no cover
sys.exit(pytest.main([__file__, "-v"]))
# ---------------------------------------------------------------------------
# 活化能不确定性(SE / 95% CI)与前瞻性 ASAP 设计建议(专家反馈)
# ---------------------------------------------------------------------------
def test_ea_uncertainty_not_estimable_for_two_temperatures():
"""仅 2 个温度点:Ea 可算但标准误/置信区间不可估计(df=0),且明确声明。"""
res = analyze_arrhenius([298.15, 313.15], [0.0146, 0.1167])
assert res.model == MODEL_STANDARD
assert res.Ea_se is None
assert res.Ea_ci_lower is None and res.Ea_ci_upper is None
assert res.ea_se_kj_per_mol is None
assert res.ea_ci_kj_per_mol is None
# 声明中应提示无法估计不确定性并建议增加温度点。
joined = " ".join(res.declarations)
assert "无自由度" in joined or "置信区间" in joined
def test_ea_uncertainty_estimable_for_three_temperatures():
"""≥3 个温度点:给出 Ea 标准误与 95% 置信区间(J/mol 与 kJ/mol 一致)。"""
res = analyze_arrhenius([298.15, 313.15, 323.15], [0.0146, 0.1167, 0.35])
assert res.model == MODEL_STANDARD
assert res.Ea_se is not None and res.Ea_se > 0
assert res.Ea_ci_lower is not None and res.Ea_ci_upper is not None
assert res.Ea_ci_lower < res.Ea < res.Ea_ci_upper
# 3 个不同温度水平 → 非"温度水平受限"。
assert res.temperature_levels_limited is False
# kJ 换算与 J 一致。
assert res.ea_se_kj_per_mol == pytest.approx(res.Ea_se / 1000.0)
lo, hi = res.ea_ci_kj_per_mol
assert lo == pytest.approx(res.Ea_ci_lower / 1000.0)
assert hi == pytest.approx(res.Ea_ci_upper / 1000.0)
def test_two_temperature_levels_with_replicates_flags_limitation():
"""关键科学局限:多批次重复使 df≥1 可算出 CI,但仅 2 个**温度水平**时,
该 CI 仅反映重复散度、Arrhenius 线性不可检验,须标记 temperature_levels_limited
并在声明中警示 Ea 不确定性可能被低估。"""
# 3 批次 × 2 温度水平(25°C / 40°C)= 6 个点,df=4。
temps = [298.15, 298.15, 298.15, 313.15, 313.15, 313.15]
ks = [0.0146, 0.0155, 0.0162, 0.1167, 0.122, 0.131]
res = analyze_arrhenius(temps, ks)
assert res.model == MODEL_STANDARD
assert res.n_temperatures == 2
assert res.n_points == 6
# CI 数学上可算(df≥1)……
assert res.Ea_se is not None and res.Ea_ci_lower is not None
# ……但温度水平受限标记为真,且声明含低估/线性不可检验警示。
assert res.temperature_levels_limited is True
joined = " ".join(res.declarations)
assert "温度水平" in joined
assert ("低估" in joined) and ("线性" in joined)
# 仍给出补充第 3 温度水平的前瞻性建议。
assert any("温度" in s for s in res.design_suggestions)
def test_three_temperature_levels_not_limited():
"""3 个不同温度水平:temperature_levels_limited 为 False(线性可检验)。"""
temps = [298.15, 313.15, 323.15, 298.15, 313.15, 323.15]
ks = [0.0146, 0.1167, 0.35, 0.0152, 0.121, 0.36]
res = analyze_arrhenius(temps, ks)
assert res.n_temperatures == 3
assert res.temperature_levels_limited is False
def test_design_suggestions_present_when_degraded():
"""传统 ICH 双温双湿(温湿共线)无法解析 B 时,应给出前瞻性 ASAP 设计建议。"""
res = analyze_arrhenius([298.15, 313.15], [0.0146, 0.1167], rh_values=[60.0, 75.0])
assert res.design_suggestions, "降级场景应给出前瞻性实验设计建议"
joined = " ".join(res.design_suggestions)
# 应建议补充温度点与温湿度交叉点(ASAP)。
assert "温度" in joined
assert "交叉" in joined or "ASAP" in joined
def test_design_suggestions_are_pure_python_no_llm():
"""设计建议为确定性纯 Python 产物:同输入两次调用结果一致。"""
a = analyze_arrhenius([298.15, 313.15], [0.0146, 0.1167], rh_values=[60.0, 75.0])
b = analyze_arrhenius([298.15, 313.15], [0.0146, 0.1167], rh_values=[60.0, 75.0])
assert a.design_suggestions == b.design_suggestions