| """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 |
|
|
|
|
| |
| 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 |
| |
| 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 |
| |
| 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), |
| ] |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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__": |
| import sys |
| sys.exit(pytest.main([__file__, "-v"])) |
|
|