File size: 13,390 Bytes
19729e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
"""多 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()


# 上升型杂质(上限型):从 0.10% 缓慢上升,含轻微散度。
IMPURITY_TIMES = [0.0, 3.0, 6.0, 9.0, 12.0]
IMPURITY_VALUES = [0.10, 0.155, 0.205, 0.262, 0.305]  # ~0.10 + 0.017·t

# 快速上升型杂质(上限型):更陡,应更早越上限 → 更短货架期。
FAST_IMPURITY_VALUES = [0.10, 0.21, 0.305, 0.41, 0.50]  # ~0.10 + 0.033·t

# 下降型含量(下限型):从 100% 缓慢下降。
ASSAY_TIMES = [0.0, 3.0, 6.0, 9.0, 12.0]
ASSAY_VALUES = [100.0, 99.2, 98.5, 97.6, 96.9]  # ~100 − 0.26·t


# ---------------------------------------------------------------------------
# 需求 11.1 / 11.4:spec_type 归一化与下限型方向
# ---------------------------------------------------------------------------

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]
    # 上限型:bound 上升,limit=2.5 → 在 t=2..3 之间穿越。
    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)

    # 下限型:bound 下降,limit=1.5 → 在 t=2..3 之间穿越。
    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)

    # 从不越界 → 右删失,返回 horizon 末端。
    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)


# ---------------------------------------------------------------------------
# 需求 11.2 / 11.4:单 CQA 货架期由正确的预测区间边界决定
# ---------------------------------------------------------------------------

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"

    # 手动用连续带复算 CI 上界穿越时间。
    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)
    # 保守性:CI 上界越界早于点估计越界。
    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)
    # 保守性:CI 下界越界早于点估计越界。
    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"


# ---------------------------------------------------------------------------
# 需求 11.3:联合货架期 = min(各 CQA),决定性 CQA 标注正确
# ---------------------------------------------------------------------------

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)

    # 木桶原理:联合 = 各 CQA 最短者。
    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]  # 快速下降,~ -1.25/月
    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([])


# ---------------------------------------------------------------------------
# 决策引擎集成:execute() 产出 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
    # 既有单 CQA 拟合仍产生。
    assert result.can_proceed is True
    assert len(result.kinetic_fits) >= 1


if __name__ == "__main__":  # pragma: no cover
    sys.exit(pytest.main([__file__, "-v"]))