File size: 9,691 Bytes
0e6887b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""描述性梳理统计器测试(intent-understanding-layer 任务 9)。

覆盖 Property 8(不外推)/ Property 10(语言无关)与需求 7.2–7.4、7.7。
"""

from __future__ import annotations

import math

from skills.descriptive_summary.summarizer import summarize


def test_groups_by_strength_batch_attribute():
    obs = [
        {"strength": "20μg", "batch": "B1", "attribute": "总杂", "value": "0.00"},
        {"strength": "40μg", "batch": "B2", "attribute": "总杂", "value": "0.00"},
    ]
    out = summarize(obs)
    assert out["n_groups"] == 2
    assert set(out["strengths"]) == {"20μg", "40μg"}


def test_mean_and_rsd_math():
    obs = [
        {"strength": "20μg", "batch": "B1", "attribute": "含量", "value": "99.19"},
        {"strength": "20μg", "batch": "B1", "attribute": "含量", "value": "100.00"},
        {"strength": "20μg", "batch": "B1", "attribute": "含量", "value": "97.68"},
    ]
    out = summarize(obs)
    g = out["groups"][0]
    assert g["n"] == 3
    assert math.isclose(g["mean"], (99.19 + 100.0 + 97.68) / 3, rel_tol=1e-9)
    assert g["rsd_pct"] is not None and g["rsd_pct"] > 0


def test_rsd_none_for_single_point():
    out = summarize([{"strength": "x", "batch": "B", "attribute": "a", "value": "5"}])
    assert out["groups"][0]["rsd_pct"] is None


def test_conformance_upper_limit():
    obs = [{"strength": "20μg", "batch": "B1", "attribute": "总杂", "value": "0.5"}]
    out = summarize(obs, spec_limits={"总杂": "总杂≤2.0%"})
    assert out["groups"][0]["within_spec"] is True
    obs2 = [{"strength": "20μg", "batch": "B1", "attribute": "总杂", "value": "3.0"}]
    out2 = summarize(obs2, spec_limits={"总杂": "总杂≤2.0%"})
    assert out2["groups"][0]["within_spec"] is False


def test_conformance_range_assay():
    obs = [{"strength": "20μg", "batch": "B1", "attribute": "含量", "value": "98.92"}]
    out = summarize(obs, spec_limits={"含量": "90.0~110.0%"})
    assert out["groups"][0]["within_spec"] is True


def test_conformance_none_without_spec():
    out = summarize([{"strength": "x", "batch": "B", "attribute": "总杂", "value": "0.5"}])
    assert out["groups"][0]["within_spec"] is None


def test_no_extrapolation_fields_property():
    out = summarize([{"strength": "x", "batch": "B", "attribute": "a", "value": "1"}])
    forbidden = {"shelf_life", "k", "r2", "model", "extrapolation", "target_timepoints", "arrhenius"}
    blob = str(out).lower()
    for f in forbidden:
        assert f not in blob


def test_multi_document_aggregation():
    obs = [
        {"strength": "20μg", "batch": "B1", "attribute": "总杂", "value": "0.1", "source": "a.xlsx"},
        {"strength": "20μg", "batch": "B1", "attribute": "总杂", "value": "0.2", "source": "b.xlsx"},
    ]
    out = summarize(obs)
    g = out["groups"][0]
    assert g["n"] == 2
    assert set(g["sources"]) == {"a.xlsx", "b.xlsx"}


def test_non_numeric_values_skipped():
    obs = [
        {"strength": "x", "batch": "B", "attribute": "a", "value": "样品名称"},
        {"strength": "x", "batch": "B", "attribute": "a", "value": "1.0"},
    ]
    out = summarize(obs)
    assert out["groups"][0]["n"] == 1


def test_conformance_less_than_operators():
    # < / < 上限型
    obs = [{"strength": "20μg", "batch": "B1", "attribute": "溶化时限", "value": "60"}]
    out = summarize(obs, spec_limits={"溶化时限": "<120s"})
    assert out["groups"][0]["within_spec"] is True
    out2 = summarize([{"strength": "20μg", "batch": "B1", "attribute": "溶化时限", "value": "130"}],
                     spec_limits={"溶化时限": "<120s"})
    assert out2["groups"][0]["within_spec"] is False


def test_conformance_greater_than_lower_limit():
    obs = [{"strength": "20μg", "batch": "B1", "attribute": "抗拉强度", "value": "16.98"}]
    out = summarize(obs, spec_limits={"抗拉强度": "≥1"})
    assert out["groups"][0]["within_spec"] is True
    out2 = summarize([{"strength": "20μg", "batch": "B1", "attribute": "抗拉强度", "value": "0.5"}],
                     spec_limits={"抗拉强度": ">1"})
    assert out2["groups"][0]["within_spec"] is False


def test_same_attribute_different_tables_not_merged():
    """不同来源表的同名属性不应被合并(修复过度合并)。"""
    obs = [
        {"strength": "20μg", "batch": "B1", "attribute": "含量mg", "value": "0.834", "table": "含量均匀度"},
        {"strength": "20μg", "batch": "B1", "attribute": "含量mg", "value": "0.826", "table": "含量均匀度"},
        {"strength": "20μg", "batch": "B1", "attribute": "含量mg", "value": "0.826", "table": "含量测定"},
    ]
    out = summarize(obs)
    groups = [g for g in out["groups"] if g["attribute"] == "含量mg"]
    # 两张表 → 两个分组,而非合并成一个 n=3
    assert len(groups) == 2
    tables = {g["table"] for g in groups}
    assert tables == {"含量均匀度", "含量测定"}


def test_spec_limit_normalized_match_attribute_with_unit_suffix():
    """限度键与观测属性名存在单位后缀差异时,归一化回退仍能挂载限度。"""
    # 观测属性 "总杂%",限度键 "总杂" —— 精确匹配失败,归一化匹配成功。
    obs = [{"strength": "20μg", "batch": "B1", "attribute": "总杂%", "value": "0.5"}]
    out = summarize(obs, spec_limits={"总杂": "总杂≤2.0%"})
    g = out["groups"][0]
    assert g["spec_limit"] == "总杂≤2.0%"
    assert g["within_spec"] is True


def test_spec_limit_normalized_match_assay_mg_per_g():
    """'含量mg/g' 观测可匹配 '含量' 限度键。"""
    obs = [{"strength": "20μg", "batch": "B1", "attribute": "含量mg/g", "value": "98.0"}]
    out = summarize(obs, spec_limits={"含量": "90.0~110.0%"})
    assert out["groups"][0]["within_spec"] is True


def test_exact_match_still_preferred_over_normalized():
    """精确键存在时优先精确匹配,不被归一化覆盖。"""
    obs = [{"strength": "x", "batch": "B", "attribute": "总杂", "value": "0.5"}]
    out = summarize(obs, spec_limits={"总杂": "总杂≤2.0%"})
    assert out["groups"][0]["spec_limit"] == "总杂≤2.0%"


def test_display_fields_consistent_format():
    """mean_display / rsd_display 为统一定点串,供表格与叙述共用。"""
    obs = [
        {"strength": "20μg", "batch": "B1", "attribute": "含量", "value": "99.19"},
        {"strength": "20μg", "batch": "B1", "attribute": "含量", "value": "100.00"},
        {"strength": "20μg", "batch": "B1", "attribute": "含量", "value": "97.68"},
    ]
    g = summarize(obs)["groups"][0]
    assert g["mean_display"] == f"{g['mean']:.4g}"
    assert g["rsd_display"] == f"{g['rsd_pct']:.2f}"


def test_single_point_flag():
    """n=1 分组带 single_point 标注;n≥2 不带。"""
    out = summarize([
        {"strength": "x", "batch": "B", "attribute": "a", "value": "5"},
        {"strength": "y", "batch": "B", "attribute": "a", "value": "5"},
        {"strength": "y", "batch": "B", "attribute": "a", "value": "6"},
    ])
    by_strength = {g["strength"]: g for g in out["groups"]}
    assert by_strength["x"]["single_point"] is True
    assert by_strength["y"]["single_point"] is False
    assert by_strength["y"]["rsd_display"] is not None


def test_censored_value_excluded_from_mean_but_counted():
    """删失值(>100)不参与均值/RSD,但计入观测总数并单独保留。"""
    out = summarize([
        {"strength": "20μg", "batch": "B1", "attribute": "耐折度", "value": ">100"},
    ])
    g = out["groups"][0]
    assert g["mean"] is None          # 无真实点测定 → 无均值
    assert g["rsd_pct"] is None
    assert g["n"] == 1                 # 仍计为一个观测
    assert g["n_numeric"] == 0
    assert g["censored_values"] == [">100"]


def test_censored_gt_satisfies_lower_limit():
    """>100 对下限 ≥50 → 必然符合。"""
    out = summarize(
        [{"strength": "20μg", "batch": "B1", "attribute": "耐折度", "value": ">100"}],
        spec_limits={"耐折度": "≥50"},
    )
    assert out["groups"][0]["within_spec"] is True


def test_censored_lt_satisfies_upper_limit():
    """<0.05 对上限 ≤1.0 → 必然符合。"""
    out = summarize(
        [{"strength": "20μg", "batch": "B1", "attribute": "单杂", "value": "<0.05"}],
        spec_limits={"单杂": "≤1.0"},
    )
    assert out["groups"][0]["within_spec"] is True


def test_censored_gt_violates_upper_limit():
    """>100 对上限 ≤80 → 必然违反。"""
    out = summarize(
        [{"strength": "20μg", "batch": "B1", "attribute": "x", "value": ">100"}],
        spec_limits={"x": "≤80"},
    )
    assert out["groups"][0]["within_spec"] is False


def test_censored_indeterminate_returns_none():
    """>100 对上限 ≤120 → 真值可能超限,无法确定 → None(不臆断)。"""
    out = summarize(
        [{"strength": "20μg", "batch": "B1", "attribute": "x", "value": ">100"}],
        spec_limits={"x": "≤120"},
    )
    assert out["groups"][0]["within_spec"] is None


def test_mixed_numeric_and_censored_conformance():
    """数值点 + 删失值混合:全部确定满足 → True。"""
    out = summarize(
        [
            {"strength": "20μg", "batch": "B1", "attribute": "耐折度", "value": "120"},
            {"strength": "20μg", "batch": "B1", "attribute": "耐折度", "value": ">100"},
        ],
        spec_limits={"耐折度": "≥50"},
    )
    g = out["groups"][0]
    assert g["n"] == 2 and g["n_numeric"] == 1
    assert g["mean"] == 120.0          # 仅数值点参与均值
    assert g["within_spec"] is True