"""ASAP(加速稳定性评估方案)单点交叉数据建模测试(用户场景:温湿度双因素)。 覆盖: - ASAP 单点交叉数据的**检测与解析**(每行:温度/湿度/时间/降解)。 - 湿度修正 Arrhenius 双因素拟合:输出 Ea / lnA / B 及其不确定性。 - 目标条件(如 25°C/60%RH)下达到降解限度的货架期反算。 - 数据不足(温度/湿度水平 < 2 或点 < 4)时优雅拒绝。 - 端到端:compute 走 ASAP 分支并产出可渲染 summary(不再被 ICH 单点预检误拒)。 导入路径由 tests/conftest.py 设置(底座以顶层包导入;skills 为顶层包)。 """ import pytest from kernel.skill_base import ExtractedData, ReportSections from skills.stability.skill import ( StabilitySkill, _detect_asap_points, _detect_time_unit, _threshold_from_goal, _build_intent_dict, ) from skills.stability.asap import ASAPPoint, analyze_asap, rate_from_single_point from services.report_service import ReportService # 用户场景 1 的原始数据(解析后的文本形态)。 ASAP_TEXT = """Sheet1 \tBatch\tTemperature(C)\tHumidity(%RH)\tTime(Days)\tDegradation(%) \tB1\t50\t75\t14\t0.153 \tB1\t60\t40\t14\t0.161 \tB1\t70\t5\t14\t0.156 \tB1\t70\t75\t2\t0.318 \tB1\t80\t15\t4\t0.222 \tB1\t80\t40\t2\t0.287 """ GOAL = ("请导入上述 ASAP 加速数据,建立温湿度双因素动力学模型(Moisture-Modified " "Arrhenius 方程)。请输出计算得到的活化能(Ea)、指前因子(lnA)和湿度敏感系数(B)," "并预测 25°C/60%RH 条件下达到 0.5% 降解限度所需的时间。") # --------------------------------------------------------------------------- # 检测与解析 # --------------------------------------------------------------------------- def test_detect_asap_points_parses_all_rows(): pts = _detect_asap_points(ASAP_TEXT) assert len(pts) == 6 first = pts[0] assert first["temperature_c"] == 50.0 assert first["humidity_rh"] == 75.0 assert first["time"] == 14.0 assert first["degradation_pct"] == pytest.approx(0.153) def test_detect_time_unit_days(): assert _detect_time_unit(ASAP_TEXT) == "days" def test_threshold_parsed_from_goal(): assert _threshold_from_goal(GOAL) == pytest.approx(0.5) def test_non_asap_text_not_detected(): """传统时间序列文本不应被误判为 ASAP。""" text = "批次 B1,25C_60RH 长期;时间点 0 3 6 9 月;总杂质 0.1 0.13 0.16 0.19。" assert _detect_asap_points(text) == [] def test_rate_from_single_point_zero_order(): assert rate_from_single_point(0.30, 2.0) == pytest.approx(0.15) assert rate_from_single_point(0.0, 2.0) is None assert rate_from_single_point(0.3, 0.0) is None # --------------------------------------------------------------------------- # 双因素拟合 + 货架期反算 # --------------------------------------------------------------------------- def _points(): return [ ASAPPoint("B1", 50, 75, 14, 0.153), ASAPPoint("B1", 60, 40, 14, 0.161), ASAPPoint("B1", 70, 5, 14, 0.156), ASAPPoint("B1", 70, 75, 2, 0.318), ASAPPoint("B1", 80, 15, 4, 0.222), ASAPPoint("B1", 80, 40, 2, 0.287), ] def test_analyze_asap_fits_and_predicts(): res = analyze_asap( _points(), threshold_pct=0.5, target_temperature_c=25.0, target_humidity_rh=60.0, time_unit="days", ) assert res.ok assert res.model == "extended_arrhenius" assert res.Ea_kj_per_mol is not None and res.Ea_kj_per_mol > 0 assert res.ln_A is not None assert res.B_per_rh is not None # 湿度敏感系数已解析 # 参数不确定性(df = 6 - 3 = 3 ≥ 1)可估计。 assert res.Ea_se_kj_per_mol is not None assert res.B_se is not None # 目标条件货架期反算成功。 assert res.predicted_time_to_threshold is not None assert res.predicted_time_months is not None # 25°C 在实验温度(50–80°C)范围外 → 外推。 assert res.within_design_space is False assert any("外推" in d for d in res.declarations) def test_analyze_asap_insufficient_levels_refuses(): """温度/湿度水平不足 → ok=False 并给出原因。""" pts = [ ASAPPoint("B1", 50, 75, 14, 0.15), ASAPPoint("B1", 50, 75, 7, 0.08), ASAPPoint("B1", 50, 75, 2, 0.03), ] # 仅 1 个温度、1 个湿度水平 res = analyze_asap(pts, threshold_pct=0.5, target_temperature_c=25.0, target_humidity_rh=60.0) assert res.ok is False assert "温度" in res.error or "湿度" in res.error # --------------------------------------------------------------------------- # 端到端:compute 走 ASAP 分支(不再被 ICH 单点预检误拒) # --------------------------------------------------------------------------- def _asap_extracted(): pts = _detect_asap_points(ASAP_TEXT) data = { "batches": [], "specification_limit": 0.5, "primary_cqa": "降解产物", "target_timepoints": [24, 36], "asap_points": pts, "asap_time_unit": "days", "spec_provided": True, } intent = _build_intent_dict(GOAL, data) return ExtractedData(payload={"data": data, "intent": intent, "extraction_method": "llm"}, method="llm") def test_compute_asap_branch_succeeds_not_refused(): """ASAP 单点交叉数据应走双因素建模分支并成功,而非被 ICH 单点预检拒绝。""" cr = StabilitySkill().compute(_asap_extracted()) assert cr.can_proceed is True, "ASAP 数据不应被『每条件 <3 点』预检误拒" asap = cr.summary.get("asap") assert asap is not None assert asap["model"] == "extended_arrhenius" assert asap["Ea_kJ_per_mol"] > 0 assert asap["B_per_rh"] is not None assert asap["predicted_time_months"] is not None def test_compute_asap_report_renders_requested_outputs(): """报告应呈现 Ea / lnA / B 与货架期反算(用户请求的输出)。""" cr = StabilitySkill().compute(_asap_extracted()) class _Meta: id = "stability"; display_name = "稳定性预测"; version = "1.0.0" html = ReportService().assemble(_Meta(), cr, ReportSections(sections={}), lang="zh") assert "ASAP 湿度修正" in html assert "活化能 Ea" in html assert "指前因子 lnA" in html assert "湿度敏感系数 B" in html assert "降解限度所需时间" in html # 目标条件超出实验范围 → 外推警示。 assert "外推" in html if __name__ == "__main__": # pragma: no cover import sys sys.exit(pytest.main([__file__, "-v"]))