"""湿度修正 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