| """多 CQA 木桶原理联合决策单元测试(任务 15 / 需求 11)。 |
| |
| 覆盖需求: |
| - 11.1:同时支持上限型规格(杂质,越界为超上限)与下限型规格(含量,越界为低于下限)。 |
| - 11.2:分别对每个 CQA 计算其满足规格的最长时间。 |
| - 11.3:最终货架期 = min(各 CQA 单独货架期)(木桶原理),并标明决定性 CQA。 |
| - 11.4:下限型 CQA 风险判定基于预测区间**下界**与下限的关系(而非上界与上限)。 |
| - 11.5:各 CQA 单独结论与联合结论可区分呈现(per_cqa + 联合结果)。 |
| |
| 纯 Python 计算,不调用 LLM(设计 Property 1)。导入路径由 ``tests/conftest.py`` |
| 与仓库根 ``conftest.py`` 统一设置。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import sys |
|
|
| import numpy as np |
| import pytest |
|
|
| from skills.stability.stability_calculator import StabilityCalculator |
| from skills.stability.multi_cqa import ( |
| cqa_shelf_life_from_fit, |
| evaluate_multi_cqa, |
| joint_shelf_life, |
| _first_crossing, |
| _normalize_spec_type, |
| ) |
| from schemas.decision_result import ( |
| CQAShelfLifeResult, |
| JointShelfLifeResult, |
| SpecType, |
| ) |
|
|
|
|
| @pytest.fixture |
| def calc() -> StabilityCalculator: |
| return StabilityCalculator() |
|
|
|
|
| |
| IMPURITY_TIMES = [0.0, 3.0, 6.0, 9.0, 12.0] |
| IMPURITY_VALUES = [0.10, 0.155, 0.205, 0.262, 0.305] |
|
|
| |
| FAST_IMPURITY_VALUES = [0.10, 0.21, 0.305, 0.41, 0.50] |
|
|
| |
| ASSAY_TIMES = [0.0, 3.0, 6.0, 9.0, 12.0] |
| ASSAY_VALUES = [100.0, 99.2, 98.5, 97.6, 96.9] |
|
|
|
|
| |
| |
| |
|
|
| def test_normalize_spec_type_variants(): |
| assert _normalize_spec_type("upper") == "upper" |
| assert _normalize_spec_type("lower") == "lower" |
| assert _normalize_spec_type("上限") == "upper" |
| assert _normalize_spec_type("下限型") == "lower" |
| assert _normalize_spec_type(SpecType.LOWER) == "lower" |
| assert _normalize_spec_type(SpecType.UPPER) == "upper" |
| |
| assert _normalize_spec_type("") == "upper" |
| assert _normalize_spec_type(None) == "upper" |
|
|
|
|
| def test_first_crossing_upper_and_lower(): |
| times = [0.0, 1.0, 2.0, 3.0, 4.0] |
| |
| up_bound = [0.0, 1.0, 2.0, 3.0, 4.0] |
| tc, crossed = _first_crossing(times, up_bound, 2.5, "upper") |
| assert crossed is True |
| assert tc == pytest.approx(2.5, abs=1e-9) |
|
|
| |
| low_bound = [4.0, 3.0, 2.0, 1.0, 0.0] |
| tc2, crossed2 = _first_crossing(times, low_bound, 1.5, "lower") |
| assert crossed2 is True |
| assert tc2 == pytest.approx(2.5, abs=1e-9) |
|
|
| |
| tc3, crossed3 = _first_crossing(times, [0.0, 0.1, 0.2, 0.3, 0.4], 10.0, "upper") |
| assert crossed3 is False |
| assert tc3 == pytest.approx(4.0) |
|
|
|
|
| |
| |
| |
|
|
| def test_upper_cqa_shelf_life_uses_ci_upper_bound(calc): |
| """上限型 CQA:货架期由 CI 上界达到上限的时间决定,且早于点估计越界时间。""" |
| fit = calc.fit_zero_order(IMPURITY_TIMES, IMPURITY_VALUES) |
| res = cqa_shelf_life_from_fit( |
| fit, cqa_name="总杂质", spec_type="upper", |
| specification_limit=0.5, horizon_months=48.0, |
| ) |
| assert res.spec_type == "upper" |
|
|
| |
| band = calc.compute_ci_band(fit, t_start=0.0, t_end=48.0, num=361, clip_negative=True) |
| tc_upper, _ = _first_crossing(band["times"], band["upper"], 0.5, "upper") |
| tc_point, _ = _first_crossing(band["times"], band["point"], 0.5, "upper") |
|
|
| assert res.shelf_life_months == pytest.approx(tc_upper, abs=0.2) |
| |
| assert res.shelf_life_months <= tc_point + 1e-6 |
|
|
|
|
| def test_lower_cqa_shelf_life_uses_ci_lower_bound(calc): |
| """下限型 CQA(含量):货架期由 CI 下界达到下限的时间决定(需求 11.4)。""" |
| fit = calc.fit_zero_order(ASSAY_TIMES, ASSAY_VALUES) |
| res = cqa_shelf_life_from_fit( |
| fit, cqa_name="含量", spec_type="lower", |
| specification_limit=95.0, horizon_months=48.0, |
| ) |
| assert res.spec_type == "lower" |
|
|
| band = calc.compute_ci_band(fit, t_start=0.0, t_end=48.0, num=361, clip_negative=False) |
| tc_lower, _ = _first_crossing(band["times"], band["lower"], 95.0, "lower") |
| tc_point, _ = _first_crossing(band["times"], band["point"], 95.0, "lower") |
|
|
| |
| assert res.shelf_life_months == pytest.approx(tc_lower, abs=0.2) |
| |
| assert res.shelf_life_months <= tc_point + 1e-6 |
|
|
|
|
| def test_cqa_meeting_spec_within_horizon_is_censored(calc): |
| """考察窗内始终满足规格 → 标记右删失,货架期取封顶值。""" |
| fit = calc.fit_zero_order(IMPURITY_TIMES, IMPURITY_VALUES) |
| res = cqa_shelf_life_from_fit( |
| fit, cqa_name="总杂质", spec_type="upper", |
| specification_limit=5.0, |
| horizon_months=24.0, |
| ) |
| assert res.censored is True |
| assert res.shelf_life_months == pytest.approx(24.0) |
| assert res.risk_level == "compliant" |
|
|
|
|
| |
| |
| |
|
|
| def test_joint_shelf_life_is_min_and_labels_determining_cqa(): |
| """联合货架期取最短者,determining_cqa 指向最短板。""" |
| a = CQAShelfLifeResult(cqa_name="含量", spec_type="lower", |
| specification_limit=95.0, shelf_life_months=30.0) |
| b = CQAShelfLifeResult(cqa_name="总杂质", spec_type="upper", |
| specification_limit=0.5, shelf_life_months=18.0) |
| c = CQAShelfLifeResult(cqa_name="水分", spec_type="upper", |
| specification_limit=3.0, shelf_life_months=42.0) |
|
|
| joint = joint_shelf_life([a, b, c]) |
| assert isinstance(joint, JointShelfLifeResult) |
| assert joint.joint_shelf_life_months == pytest.approx(18.0) |
| assert joint.determining_cqa == "总杂质" |
| assert len(joint.per_cqa) == 3 |
|
|
|
|
| def test_evaluate_multi_cqa_min_across_cqa(calc): |
| """端到端:多 CQA(含上限+下限)联合货架期 = min(各单独货架期)。""" |
| cqa_inputs = [ |
| { |
| "cqa_name": "总杂质", "times": IMPURITY_TIMES, "values": IMPURITY_VALUES, |
| "spec_type": "upper", "specification_limit": 0.5, |
| }, |
| { |
| "cqa_name": "降解产物", "times": IMPURITY_TIMES, "values": FAST_IMPURITY_VALUES, |
| "spec_type": "upper", "specification_limit": 0.5, |
| }, |
| { |
| "cqa_name": "含量", "times": ASSAY_TIMES, "values": ASSAY_VALUES, |
| "spec_type": "lower", "specification_limit": 95.0, |
| }, |
| ] |
| joint = evaluate_multi_cqa(cqa_inputs, horizon_months=48.0) |
|
|
| per = {c.cqa_name: c.shelf_life_months for c in joint.per_cqa} |
| expected_min = min(per.values()) |
| expected_cqa = min(per, key=per.get) |
|
|
| |
| assert joint.joint_shelf_life_months == pytest.approx(expected_min) |
| assert joint.determining_cqa == expected_cqa |
|
|
| |
| assert per["降解产物"] < per["总杂质"] |
|
|
|
|
| def test_evaluate_multi_cqa_lower_limit_can_be_determining(calc): |
| """下限型 CQA 在下降足够快时可成为决定性短板。""" |
| fast_assay = [100.0, 96.5, 93.0, 89.0, 85.0] |
| slow_impurity = [0.10, 0.12, 0.14, 0.165, 0.185] |
| cqa_inputs = [ |
| { |
| "cqa_name": "总杂质", "times": IMPURITY_TIMES, "values": slow_impurity, |
| "spec_type": "upper", "specification_limit": 0.5, |
| }, |
| { |
| "cqa_name": "含量", "times": ASSAY_TIMES, "values": fast_assay, |
| "spec_type": "lower", "specification_limit": 95.0, |
| }, |
| ] |
| joint = evaluate_multi_cqa(cqa_inputs, horizon_months=48.0) |
| assert joint.determining_cqa == "含量" |
| per = {c.cqa_name: c.shelf_life_months for c in joint.per_cqa} |
| assert joint.joint_shelf_life_months == pytest.approx(min(per.values())) |
|
|
|
|
| def test_joint_requires_at_least_one_cqa(): |
| with pytest.raises(ValueError): |
| joint_shelf_life([]) |
|
|
|
|
| |
| |
| |
|
|
| def _build_intent(primary_cqa: str, spec_limit: float): |
| from schemas.analysis_intent import ( |
| AnalysisIntent, AnalysisType, UserPreferences, HardConstraints, |
| ExtractedDataSummary, |
| ) |
| return AnalysisIntent( |
| raw_goal="预测多 CQA 货架期", |
| analysis_type=AnalysisType.SHELF_LIFE_PREDICTION, |
| preferences=UserPreferences(target_timepoints=[24], required_confidence=0.95), |
| constraints=HardConstraints(primary_cqa=primary_cqa, specification_limit=spec_limit), |
| data_summary=ExtractedDataSummary(), |
| ) |
|
|
|
|
| def _build_extracted_data(): |
| return { |
| "batches": [ |
| { |
| "batch_id": "B001", |
| "batch_type": "target", |
| "conditions": [ |
| { |
| "condition_id": "25C_60RH", |
| "timepoints": IMPURITY_TIMES, |
| "cqa_data": [ |
| { |
| "cqa_name": "总杂质", |
| "values": IMPURITY_VALUES, |
| "spec_type": "upper", |
| "specification_limit": 0.5, |
| }, |
| { |
| "cqa_name": "降解产物", |
| "values": FAST_IMPURITY_VALUES, |
| "spec_type": "upper", |
| "specification_limit": 0.5, |
| }, |
| { |
| "cqa_name": "含量", |
| "values": ASSAY_VALUES, |
| "spec_type": "lower", |
| "specification_limit": 95.0, |
| }, |
| ], |
| } |
| ], |
| } |
| ] |
| } |
|
|
|
|
| def test_decision_engine_populates_joint_shelf_life(): |
| """决策引擎在多 CQA 数据下产出联合货架期,且 = min(各 CQA)。""" |
| from layers.regulatory_decision_engine import RegulatoryDecisionEngine |
|
|
| engine = RegulatoryDecisionEngine() |
| intent = _build_intent(primary_cqa="总杂质", spec_limit=0.5) |
| result = engine.execute(intent, _build_extracted_data()) |
|
|
| assert result.joint_shelf_life is not None |
| joint = result.joint_shelf_life |
| per = {c.cqa_name: c.shelf_life_months for c in joint.per_cqa} |
| assert set(per) == {"总杂质", "降解产物", "含量"} |
| assert joint.joint_shelf_life_months == pytest.approx(min(per.values())) |
| assert joint.determining_cqa == min(per, key=per.get) |
|
|
| |
| steps = [e["step"] for e in result.calculation_trace.entries] |
| assert "multi_cqa_joint_shelf_life" in steps |
|
|
|
|
| def test_decision_engine_backward_compatible_without_spec_type(): |
| """无 per-CQA spec 信息时,joint_shelf_life 为 None,既有单 CQA 流程不受影响。""" |
| from layers.regulatory_decision_engine import RegulatoryDecisionEngine |
|
|
| engine = RegulatoryDecisionEngine() |
| intent = _build_intent(primary_cqa="总杂质", spec_limit=0.5) |
| data = { |
| "batches": [ |
| { |
| "batch_id": "B001", |
| "batch_type": "target", |
| "conditions": [ |
| { |
| "condition_id": "25C_60RH", |
| "timepoints": IMPURITY_TIMES, |
| "cqa_data": [ |
| {"cqa_name": "总杂质", "values": IMPURITY_VALUES}, |
| ], |
| } |
| ], |
| } |
| ] |
| } |
| result = engine.execute(intent, data) |
| assert result.joint_shelf_life is None |
| |
| assert result.can_proceed is True |
| assert len(result.kinetic_fits) >= 1 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(pytest.main([__file__, "-v"])) |
|
|