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"]))
|