Files
CADx-4/tests/fake_delivery.py
T
iomgaa c582fe426f feat(build): 任务书 02 第四阶段代码——build 装入 CEUS 结果
- build.yaml 加必填字段 ceus_run(CEUS 运行目录或 null)。
- 不为 null 时按视频号找结果;在 failed.csv 里或缺结果目录的整例跳过,记 C15;关键帧图沿用 C14 检查。
- 资料库超声一路加 keyframes/<槽位>.png(去附加数据重编码)和 tic.json(白名单逐字段构造:
  四条曲线的拟合成败与 R²、灌注指标、紫圈曲线是否可用、7 个槽位的定位与视频时间或原因)。
- manifest 的 CEUS 来源写运行号;泄露扫描已覆盖 tic.json,补命中测试。
- 真实构建等配准改完、全量 CEUS 运行之后再做。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 05:55:57 -04:00

273 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""测试用假交付包:形状照 40 例交付包写(表头、工作表名、目录结构),内容全部编造。
这里的表头和文件名是按任务书 01 第二部分的表格独立写的,不取自 delivery 的常量,
这样 delivery 的常量写错时测试能发现。各条测试在 `FakeDelivery` 上改出要测的情况,再 `write`。
"""
from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, cast
import openpyxl
import yaml
from PIL import Image, PngImagePlugin
from cadx.build import BuildSummary, run_build
REPO_ROOT = Path(__file__).resolve().parents[1]
DIAGNOSIS_COLUMN = "病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)"
CLINICAL_HEADER = [
"Case_ID",
"Sex",
"Age",
"Diagnosis",
"Symptom_Pain",
"Symptom_Jaundice",
"Symptom_WeightLoss",
"Symptom_Fever",
"Symptom_Asymptomatic",
"Hx_Pancreatitis",
"Hx_Alcohol",
"Hx_Gallstones",
"Hx_Malignancy",
"Hx_Family_PC",
"CA199",
"CA199_Level",
"CEA",
"CEA_Level",
"Amylase",
"Amylase_Level",
"Lipase",
"Lipase_Level",
"TBil",
"TBil_Level",
]
ULTRASOUND_HEADER = [
"患者ID",
"CEUS视频ID",
"性别",
"年龄",
DIAGNOSIS_COLUMN,
"推注开始时间s",
"超声描述",
]
MR_HEADER = ["患者ID", "性别", "年龄", DIAGNOSIS_COLUMN, "MR描述"]
CLINICAL_PATH = "Clinical_agent/临床工作簿_40例.xlsx"
ULTRASOUND_PATH = "CEUS_agent/超声报告文本_40例.xlsx"
MR_PATH = "MR_agent/MR报告文本_40例.xlsx"
# 编造的报告文字:不含亚型名、结论段标题、性别字、「数字岁」和复核措辞。
ULTRASOUND_TEXT = "胰体部见一无回声区,边界清晰,壁薄。造影后囊壁呈等增强,内部未见增强。"
MR_TEXT = "胰体部见一类圆形长T2信号灶,边界清楚,内见细分隔;增强后分隔轻度强化。"
# 测试用的样例映射表:含义是编的,只用来验证解码路径。
SAMPLE_CODE_MAPS: dict[str, dict[int, str]] = {
"Sex": {1: "男", 2: "女"},
"Diagnosis": {1: "IPMN", 2: "SPN", 3: "MCN", 4: "SCN"},
**{
column: {0: "否", 1: "是"}
for column in (
"Symptom_Pain",
"Symptom_Jaundice",
"Symptom_WeightLoss",
"Symptom_Fever",
"Symptom_Asymptomatic",
"Hx_Pancreatitis",
"Hx_Alcohol",
"Hx_Gallstones",
"Hx_Malignancy",
"Hx_Family_PC",
)
},
**{
column: {0: "正常", 1: "异常"}
for column in ("CA199_Level", "CEA_Level", "Amylase_Level", "Lipase_Level", "TBil_Level")
},
}
@dataclass
class FakeCase:
"""一例:三张表各一行,病例目录里的文件名和单例报告表的行。"""
number: int
clinical: dict[str, object]
ultrasound: dict[str, object]
mr: dict[str, object]
dir_files: list[str]
# None 表示单例报告表照抄超声汇总表那一行。
case_report_rows: list[list[object]] | None = None
case_report_sheet: str = "Sheet1"
@property
def no(self) -> str:
return f"{self.number:03d}"
def make_case(number: int) -> FakeCase:
"""一例全部检查都能通过的病例。诊断按病例号轮转 1–4。"""
no = f"{number:03d}"
sex = 1 + number % 2
age = 30 + number
diagnosis = 1 + (number - 1) % 4
clinical: dict[str, object] = dict.fromkeys(CLINICAL_HEADER, 0)
clinical.update(
{
"Case_ID": f"P{no}",
"Sex": sex,
"Age": age,
"Diagnosis": diagnosis,
"Symptom_Pain": 1,
"CA199": 12.5,
"CA199_Level": 0,
"CEA": 3,
"CEA_Level": 1,
"Amylase": None,
"Amylase_Level": None,
"Lipase": None,
"Lipase_Level": None,
"TBil": 10.2,
"TBil_Level": 0,
}
)
sex_text = {1: "男", 2: "女"}[sex]
ultrasound: dict[str, object] = {
"患者ID": str(number),
"CEUS视频ID": f"U{no}",
"性别": sex_text,
"年龄": f"{age}岁",
DIAGNOSIS_COLUMN: diagnosis,
"推注开始时间s": 5,
"超声描述": ULTRASOUND_TEXT,
}
mr: dict[str, object] = {
"患者ID": str(number),
"性别": sex_text,
"年龄": f"{age}岁",
DIAGNOSIS_COLUMN: diagnosis,
"MR描述": MR_TEXT,
}
files = [f"U{no}_key.png", f"U{no}_roi2.mp4", f"U{no}_roi2_report.xlsx"]
return FakeCase(number, clinical, ultrasound, mr, files)
@dataclass
class FakeDelivery:
"""一个假交付包。`extra_rows` 是附加在汇总表末尾的整行(测试编号认不出的行)。"""
cases: list[FakeCase]
sheet_names: dict[str, str] = field(
default_factory=lambda: {
CLINICAL_PATH: "Sheet",
ULTRASOUND_PATH: "超声报告",
MR_PATH: "MR报告",
}
)
headers: dict[str, list[str]] = field(
default_factory=lambda: {
CLINICAL_PATH: list(CLINICAL_HEADER),
ULTRASOUND_PATH: list(ULTRASOUND_HEADER),
MR_PATH: list(MR_HEADER),
}
)
extra_rows: dict[str, list[list[object]]] = field(
default_factory=lambda: {CLINICAL_PATH: [], ULTRASOUND_PATH: [], MR_PATH: []}
)
def case(self, number: int) -> FakeCase:
return next(c for c in self.cases if c.number == number)
def write(self, root: Path) -> Path:
tables = {
CLINICAL_PATH: [c.clinical for c in self.cases],
ULTRASOUND_PATH: [c.ultrasound for c in self.cases],
MR_PATH: [c.mr for c in self.cases],
}
for path, rows in tables.items():
header = self.headers[path]
values = [[row.get(h) for h in header] for row in rows]
_write_workbook(
root / path, self.sheet_names[path], [header, *values, *self.extra_rows[path]]
)
for case in self.cases:
folder = root / "CEUS_agent" / case.no
folder.mkdir(parents=True, exist_ok=True)
for name in case.dir_files:
if name.endswith(".png"):
_write_png(folder / name, case.number)
elif name.endswith(".xlsx"):
row = [case.ultrasound.get(h) for h in ULTRASOUND_HEADER]
rows = case.case_report_rows or [list(ULTRASOUND_HEADER), row]
_write_workbook(folder / name, case.case_report_sheet, rows)
else:
(folder / name).write_bytes(b"not a real video")
(root / "交付说明_40例_20260908.md").write_text("说明不读", encoding="utf-8")
return root
def make_delivery(count: int) -> FakeDelivery:
"""病例号 1..count、全部能通过检查的假交付包。"""
return FakeDelivery(cases=[make_case(n) for n in range(1, count + 1)])
def _write_workbook(path: Path, sheet: str, rows: Sequence[Sequence[object]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
workbook = openpyxl.Workbook()
worksheet = workbook.active
assert worksheet is not None
worksheet.title = sheet
for row in rows:
# 假数据里的值都是 openpyxl 认得的单元格类型(字符串、数字、None)。
worksheet.append(cast("list[Any]", list(row)))
workbook.save(path)
def _write_png(path: Path, number: int) -> None:
pixels = bytes(v for i in range(24) for v in (number * 10 % 256, i * 10, 255 - i * 10))
image = Image.frombytes("RGB", (6, 4), pixels)
info = PngImagePlugin.PngInfo()
info.add_text("PatientName", "should-not-survive")
image.save(path, format="PNG", pnginfo=info)
def write_config(
tmp_path: Path,
delivery_dir: Path,
seed: int,
code_maps: dict[str, dict[int, str]] | None,
ceus_run: Path | None,
) -> Path:
"""在 tmp_path 写一份 build.yaml(路径都写绝对路径);给了映射表就同时写 code_maps.yaml。"""
maps_path = None
if code_maps is not None:
maps_path = tmp_path / "code_maps.yaml"
maps_path.write_text(yaml.safe_dump(code_maps, allow_unicode=True), encoding="utf-8")
config = {
"delivery_dir": str(delivery_dir),
"library_root": str(tmp_path / "library"),
"answers_root": str(tmp_path / "answers"),
"alias_seed": seed,
"decode_codes": code_maps is not None,
"code_maps": None if maps_path is None else str(maps_path),
"ceus_run": None if ceus_run is None else str(ceus_run),
}
path = tmp_path / "build.yaml"
path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8")
return path
def build(
tmp_path: Path,
delivery: FakeDelivery,
seed: int,
code_maps: dict[str, dict[int, str]] | None,
now: datetime,
) -> BuildSummary:
"""写出假交付包和配置,跑一次不装 CEUS 结果的构建。"""
delivery_dir = delivery.write(tmp_path / "delivery")
return run_build(write_config(tmp_path, delivery_dir, seed, code_maps, None), REPO_ROOT, now)