Files
dpb/backend/scripts/test_bre_analysis_mabc_tc.py
T
34047007@qq.com b95053c52c init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
2026-08-06 00:17:49 +08:00

341 lines
17 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.
# -*- coding: utf-8 -*-
"""MABC 标记辅助回交进度正式 tc 套件:TestClient 走真实 API + golden 数值断言(纯计算端点)。
fixture(镜像 e2e_mabc):轮回亲本 rp_tree 背景面板全纯合 1/1;候选 BC 分离株 6 棵
(前景 F1/F2 + 背景 M1..M4min_hits=2):C1(全 1/1→恢复100%)、C2(背景 M2 杂合→75%)、
C3(前景 1 命中 fail→75%)、C4(全 1/1→100%)、C5(背景全杂合→0%)、C6(童期→100%)。
经真实 HTTP 端点 POST /api/v1/bre/statistics/mabc-progress 断言:
[1] 前景命中与通过(C1 pass/C3 fail、n_pass=5
[2] 背景恢复率复算(100/75/75/0+ n_background_compared=4
[3] 回交代建议(C1 晋级 BC2 / C2 再回交 / C3 前景未通过维持)reason 语义
[4] recommended 恰为前景通过子集 + 恢复率降序
[5] 童期 C6 前景通过 + 晋级(stage 无关)
[6] 轮回亲本无基因型 → warning + 恢复率 None
[7] 校验 409:空候选/空前景面板/空背景面板/非法世代/target 越界/轮回亲本不存在
依赖: Redis + PG 正常(TestClient 走真实 lifespan)。运行后自动清理。
"""
import os
os.environ["ENVIRONMENT"] = "dev"
os.environ["PYTHONUTF8"] = "1"
import sys, asyncio # noqa: E402
sys.path.insert(0, r"d:\dpb\dpb\backend")
import main # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy import delete # noqa: E402
from app.core.database import create_async_engine_and_session # noqa: E402
from app.api.v1.module_system.user.model import UserModel # noqa: E402 (注册 mapper)
from app.api.v1.module_bre.target.model import TargetModel # noqa: E402
from app.api.v1.module_bre.germplasm.model import BreedingGermplasmModel # noqa: E402
from app.api.v1.module_bre.cross_combination.model import CrossCombinationModel # noqa: E402
from app.api.v1.module_bre.tree.model import TreeModel # noqa: E402
from app.api.v1.module_bre.marker.model import MarkerModel # noqa: E402
from app.api.v1.module_bre.genotype_dataset.model import GenotypingDatasetModel # noqa: E402
from app.api.v1.module_bre.genotype_sample.model import GenotypeSampleModel # noqa: E402
from app.api.v1.module_bre.genotype_call.model import GenotypeCallModel # noqa: E402
from app.api.v1.module_bre.statistics.model import MasPanelModel, MasPanelMarkerModel # noqa: E402
create_app = main.create_app
TOKEN = None
ok, fail = 0, 0
PREFIX = "TC_MABC"
tokens: dict[str, list[int]] = {
"germ": [], "tree": [], "combo": [], "target": [],
"marker": [], "dataset": [], "sample": [], "call": [],
"panel": [], "panelmarker": [],
}
FIX: dict = {}
def check(name, cond, detail=""):
global ok, fail
if cond:
ok += 1
print(f" [ok] {name} {detail}")
else:
fail += 1
print(f" [FAIL] {name} {detail}")
def login(client):
global TOKEN
d = {"username": "super", "password": "123456", "grant_type": "password", "login_type": "PC端"}
r = client.post("/api/v1/system/auth/login", data=d)
b = r.json()
if r.status_code == 200 and b.get("code") == 0:
TOKEN = b["data"]["access_token"]
return
key = client.get("/api/v1/system/auth/captcha/get").json()["data"]["key"]
client.post("/api/v1/system/auth/captcha/slider/complete", json={"captcha_key": key})
d["captcha_key"] = key
r = client.post("/api/v1/system/auth/login", data=d)
b = r.json()
assert r.status_code == 200 and b.get("code") == 0, f"LOGIN FAIL {r.status_code} {b}"
TOKEN = b["data"]["access_token"]
def auth():
return {"Authorization": f"Bearer {TOKEN}"}
async def _build_fixture() -> None:
engine, sf = create_async_engine_and_session()
try:
async with sf() as db:
target = TargetModel(target_name=f"{PREFIX}-T", created_id=1)
db.add(target)
await db.flush()
tokens["target"].append(target.id)
combo = CrossCombinationModel(
combination_code=f"{PREFIX}-C1", bre_target_id=target.id,
female_parent_id=None, male_parent_id=None, design_type="full_diallel", created_id=1)
db.add(combo)
await db.flush()
tokens["combo"].append(combo.id)
rp_g = BreedingGermplasmModel(cultivar_name=f"{PREFIX}-RP", can_be_female=True,
can_be_male=True, created_id=1)
db.add(rp_g)
await db.flush()
tokens["germ"].append(rp_g.id)
def mk_tree(no, stage="line", generation="BC1"):
t = TreeModel(combination_id=combo.id, tree_no=no, status="alive", stage=stage,
generation=generation, planted_date="2025-03-10", created_id=1)
db.add(t)
return t
rp_tree = mk_tree(f"{PREFIX}-RP-T")
cands = {nm: mk_tree(f"{PREFIX}-{nm}", stage="juvenile" if nm == "C6" else "line")
for nm in ("C1", "C2", "C3", "C4", "C5", "C6")}
rp_nogeno = mk_tree(f"{PREFIX}-RPNG-T")
await db.flush()
tokens["tree"] = [t.id for t in ([rp_tree, rp_nogeno] + list(cands.values()))]
FIX.update({"rp": rp_tree.id, "rp_nogeno": rp_nogeno.id,
"cand": {nm: t.id for nm, t in cands.items()}})
fg_markers, bg_markers = [], []
for j, nm in enumerate(["F1", "F2"]):
m = MarkerModel(marker_name=f"{PREFIX}-{nm}", marker_type="SNP",
chromosome="5", position=100 + j, created_id=1)
db.add(m)
fg_markers.append(m)
for j in range(4):
m = MarkerModel(marker_name=f"{PREFIX}-M{j + 1}", marker_type="SNP",
chromosome=str(j), position=j * 100, created_id=1)
db.add(m)
bg_markers.append(m)
await db.flush()
tokens["marker"] = [m.id for m in fg_markers + bg_markers]
FIX["fg_markers"] = fg_markers
FIX["bg_markers"] = bg_markers
fg_panel = MasPanelModel(panel_name=f"{PREFIX}-FG", n_markers=2, created_id=1)
bg_panel = MasPanelModel(panel_name=f"{PREFIX}-BG", n_markers=4, created_id=1)
db.add_all([fg_panel, bg_panel])
await db.flush()
tokens["panel"] = [fg_panel.id, bg_panel.id]
FIX["fg_panel"] = fg_panel.id
FIX["bg_panel"] = bg_panel.id
for m in fg_markers:
db.add(MasPanelMarkerModel(panel_id=fg_panel.id, marker_id=m.id,
favorable_dose=1, direction="high",
mode="additive", created_id=1))
tokens["panelmarker"].append(0)
for m in bg_markers:
db.add(MasPanelMarkerModel(panel_id=bg_panel.id, marker_id=m.id, created_id=1))
tokens["panelmarker"].append(0)
await db.flush()
ds = GenotypingDatasetModel(dataset_name=f"{PREFIX}-DS", platform="SNP",
purpose="GS", created_id=1)
db.add(ds)
await db.flush()
tokens["dataset"].append(ds.id)
all_markers = fg_markers + bg_markers
def mk_sample(tree, no):
s = GenotypeSampleModel(sample_name=no, dataset_id=ds.id,
source_type="tree", source_id=tree.id, created_id=1)
db.add(s)
return s
rp_s = mk_sample(rp_tree, f"{PREFIX}-RP-S")
samp_by_cand = {nm: mk_sample(t, f"{PREFIX}-{nm}-S") for nm, t in cands.items()}
await db.flush()
tokens["sample"] = [rp_s.id] + [s.id for s in samp_by_cand.values()]
GENO = {
"C1": ("1/1", "1/1", ["1/1", "1/1", "1/1", "1/1"]),
"C2": ("0/1", "1/1", ["1/1", "0/1", "1/1", "1/1"]),
"C3": ("0/0", "1/1", ["1/1", "1/1", "1/1", "0/0"]),
"C4": ("1/1", "0/1", ["1/1", "1/1", "1/1", "1/1"]),
"C5": ("1/1", "1/1", ["0/1", "0/1", "0/1", "0/1"]),
"C6": ("1/1", "1/1", ["1/1", "1/1", "1/1", "1/1"]),
}
calls = []
for j, m in enumerate(all_markers):
calls.append(GenotypeCallModel(sample_id=rp_s.id, marker_id=m.id,
allele="1/1", created_id=1))
for nm, (f1, f2, bg) in GENO.items():
s = samp_by_cand[nm]
calls.append(GenotypeCallModel(sample_id=s.id, marker_id=fg_markers[0].id,
allele=f1, created_id=1))
calls.append(GenotypeCallModel(sample_id=s.id, marker_id=fg_markers[1].id,
allele=f2, created_id=1))
for j, m in enumerate(bg_markers):
calls.append(GenotypeCallModel(sample_id=s.id, marker_id=m.id,
allele=bg[j], created_id=1))
db.add_all(calls)
await db.flush()
tokens["call"] = [c.id for c in calls]
await db.commit()
finally:
await engine.dispose()
async def _cleanup() -> None:
engine, sf = create_async_engine_and_session()
try:
async with sf() as db:
if tokens["call"]:
await db.execute(delete(GenotypeCallModel).where(
GenotypeCallModel.id.in_(tokens["call"])))
if tokens["sample"]:
await db.execute(delete(GenotypeSampleModel).where(
GenotypeSampleModel.id.in_(tokens["sample"])))
if tokens["dataset"]:
await db.execute(delete(GenotypingDatasetModel).where(
GenotypingDatasetModel.id.in_(tokens["dataset"])))
if tokens["panelmarker"]:
await db.execute(delete(MasPanelMarkerModel).where(
MasPanelMarkerModel.panel_id.in_(tokens["panel"])))
if tokens["panel"]:
await db.execute(delete(MasPanelModel).where(MasPanelModel.id.in_(tokens["panel"])))
if tokens["marker"]:
await db.execute(delete(MarkerModel).where(MarkerModel.id.in_(tokens["marker"])))
if tokens["tree"]:
await db.execute(delete(TreeModel).where(TreeModel.id.in_(tokens["tree"])))
if tokens["combo"]:
await db.execute(delete(CrossCombinationModel).where(
CrossCombinationModel.id.in_(tokens["combo"])))
if tokens["germ"]:
await db.execute(delete(BreedingGermplasmModel).where(
BreedingGermplasmModel.id.in_(tokens["germ"])))
if tokens["target"]:
await db.execute(delete(TargetModel).where(TargetModel.id.in_(tokens["target"])))
await db.commit()
print("[cleanup] MABC tc 数据已清")
finally:
await engine.dispose()
def main_() -> None:
asyncio.run(_build_fixture())
try:
with TestClient(create_app()) as client:
login(client)
c = FIX["cand"]
cand_ids = [c[nm] for nm in ("C1", "C2", "C3", "C4", "C5", "C6")]
def post_mabc(cands_, fg, bg, rp_, generation="BC1", background_target=90.0, min_hits=2):
r = client.post("/api/v1/bre/statistics/mabc-progress", json={
"candidate_tree_ids": cands_, "foreground_panel_ids": fg,
"background_panel_ids": bg, "recurrent_parent_tree_id": rp_,
"foreground_min_hits": min_hits, "generation": generation,
"background_target": background_target}, headers=auth())
if r.status_code != 200:
return None, r.status_code
b = r.json()
return (b.get("data") if b.get("code") == 0 else None), r.status_code
m, sc = post_mabc(cand_ids, [FIX["fg_panel"]], [FIX["bg_panel"]], FIX["rp"])
check("[HTTP] mabc-progress 200", sc == 200, f"{sc}")
if m is None:
return
row = {r["tree_id"]: r for r in m["per_tree"]}
# ---- [1] 前景命中与通过 ----
check("[1] C1 前景 pass (2 命中)", row[c["C1"]]["foreground_pass"]
and row[c["C1"]]["foreground_hits"] == 2,
f"{row[c['C1']]['foreground_hits_by_panel']}")
check("[1] C3 前景 fail (1 命中)", not row[c["C3"]]["foreground_pass"]
and row[c["C3"]]["foreground_hits"] == 1, f"{row[c['C3']]['foreground_hits']}")
check("[1] n_pass=5C1/C2/C4/C5/C6", m["summary"]["n_pass"] == 5,
f"{m['summary']['n_pass']}")
# ---- [2] 背景恢复率复算 ----
check("[2] C1 恢复 100%", row[c["C1"]]["background_recovery"] == 100.0)
check("[2] C2 恢复 75%", row[c["C2"]]["background_recovery"] == 75.0,
f"{row[c['C2']]['background_recovery']}")
check("[2] C3 恢复 75%(前景 fail 也评估背景)",
row[c["C3"]]["background_recovery"] == 75.0)
check("[2] C5 恢复 0%(全杂合)", row[c["C5"]]["background_recovery"] == 0.0)
check("[2] 背景对比标记数=4", all(r["n_background_compared"] == 4 for r in row.values()),
f"{[r['n_background_compared'] for r in row.values()]}")
# ---- [3] 回交代建议 ----
check("[3] C1 晋级 BC2", row[c["C1"]]["bc_generation"] == "BC2"
and "晋级" in row[c["C1"]]["reason"], row[c["C1"]]["reason"])
check("[3] C2 再回交 BC1", row[c["C2"]]["bc_generation"] == "BC1"
and "再回交" in row[c["C2"]]["reason"], row[c["C2"]]["reason"])
check("[3] C3 维持 BC1(前景未通过)", row[c["C3"]]["bc_generation"] == "BC1"
and "前景未通过" in row[c["C3"]]["reason"], row[c["C3"]]["reason"])
# ---- [4] recommended 排序 ----
rec_ids = [r["tree_id"] for r in m["recommended"]]
pass_set = {c[nm] for nm in ("C1", "C2", "C4", "C5", "C6")}
check("[4] recommended 恰为前景通过子集", set(rec_ids) == pass_set, f"{rec_ids}")
rec_recovery = [r["background_recovery"] for r in m["recommended"]]
check("[4] 恢复率降序", rec_recovery == [100.0, 100.0, 100.0, 75.0, 0.0],
f"{rec_recovery}")
# ---- [5] 童期候选被前景选中 ----
check("[5] 童期 C6 前景通过 + 晋级", row[c["C6"]]["foreground_pass"]
and row[c["C6"]]["background_recovery"] == 100.0
and row[c["C6"]]["bc_generation"] == "BC2", row[c["C6"]])
# ---- [6] 轮回亲本无基因型 → warning + 恢复率 None ----
m6, sc6 = post_mabc([c["C1"]], [FIX["fg_panel"]], [FIX["bg_panel"]], FIX["rp_nogeno"])
check("[6] mabc 200", sc6 == 200, f"{sc6}")
if m6:
check("[6] warning 提示轮回亲本无背景基因型",
m6["warning"] and "轮回亲本" in m6["warning"], m6["warning"])
check("[6] 恢复率 None + 建议维持世代",
m6["per_tree"][0]["background_recovery"] is None
and m6["per_tree"][0]["bc_generation"] == "BC1"
and "无法评估" in m6["per_tree"][0]["reason"],
m6["per_tree"][0]["reason"])
# ---- [7] 校验 409 ----
bad = [
([], [FIX["fg_panel"]], [FIX["bg_panel"]], FIX["rp"]),
([c["C1"]], [], [FIX["bg_panel"]], FIX["rp"]),
([c["C1"]], [FIX["fg_panel"]], [], FIX["rp"]),
([c["C1"]], [FIX["fg_panel"]], [FIX["bg_panel"]], FIX["rp"]),
([c["C1"]], [FIX["fg_panel"]], [FIX["bg_panel"]], FIX["rp"]),
([c["C1"]], [FIX["fg_panel"]], [FIX["bg_panel"]], 999999999),
]
tags = ["空候选", "空前景面板", "空背景面板", "非法世代", "target 越界", "轮回亲本不存在"]
for (cands_, fg, bg, rp_), tag in zip(bad, tags):
kw = {}
if tag == "非法世代":
kw["generation"] = "BC9"
if tag == "target 越界":
kw["background_target"] = 120.0
_, scx = post_mabc(cands_, fg, bg, rp_, **kw)
# target 越界在 schema 层 Pydantic 校验(le=100)即拦截 → 422;其余走服务层 409
expect = 422 if tag == "target 越界" else 409
check(f"[7] {tag}{expect}", scx == expect, f"{scx}")
finally:
asyncio.run(_cleanup())
print(f"\n===== MABC tc 套件:ok={ok} fail={fail} =====")
if __name__ == "__main__":
main_()
sys.exit(1 if fail else 0)