| """湿度修正 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, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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() |
| |
| 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 |
| B_TRUE = 0.04 |
|
|
| T40, T50, T60 = 313.15, 323.15, 333.15 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| 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(): |
| """温度与湿度共线(无法分离效应)时双因素拟合抛错以触发降级。""" |
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| def test_degrade_to_standard_when_rh_levels_insufficient(): |
| """提供 RH 但仅单一湿度水平 → 降级标准 Arrhenius 并声明原因。""" |
| temps = [T40, T50, T60] |
| rh = [60.0, 60.0, 60.0] |
| 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 |
| |
| 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] |
| 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 捕获异常并降级标准模型。""" |
| |
| temps = [T40, T60, T40, T60] |
| rh = [25.0, 75.0, 25.0, 75.0] |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| def test_single_temperature_falls_back_to_assumed_ea(): |
| """仅单一温度水平 → 采用默认假设 Ea 并明确标注为假设值。""" |
| temps = [T40, T40, T40] |
| 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 |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| 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([], []) |
|
|
|
|
| |
| |
| |
|
|
| 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__": |
| sys.exit(pytest.main([__file__, "-v"])) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| assert res.temperature_levels_limited is False |
| |
| 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 不确定性可能被低估。""" |
| |
| 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 |
| |
| 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) |
| |
| 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) |
| |
| 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 |
|
|