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

390 lines
20 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 -*-
"""孟德尔/亲本一致性检验正式 tc 套件:TestClient 走真实 API + 落库/隔离 golden 断言。
fixture(镜像 e2e_mendelian_persist):P1/P2 全 0/1 → T1、P3/P4 全 0/0 → T25 标记)。
T1 全 0/0 → rate=1.0 兼容;T2 全 1/1 → 每标记 alien → rate=0.0 → flag。
经真实 HTTP 端点 POST /api/v1/bre/genotype_call/mendelian/check/run 断言:
[1] 返回 check_id / isolated=1 / cleared=0 + 计数 2/1/0/0
[2] bre_mendelian_check 批次落库(阈值/计数/data_version/input_hash
[3] 子表 2 行:T1 rate=1.0 flag=false / T2 rate=0.0 flag=true incompatible=5
[4] T2 进入隔离注册表 bre_pedigree_exclusionsource=mendelian / source_check_id
[5] 阈值 0.0 重跑 → flagged=0 / cleared=1 + 隔离行清除(可逆)
[6] GET 只读预览零回归(无 check_id / 纯计算)
依赖: 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, select # 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.trait.model import TraitModel # 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, TreePedigreeExclusionModel # noqa: E402
from app.api.v1.module_bre.trait_observation.model import TraitObservationModel # 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 ( # noqa: E402
GenotypeCallModel,
MendelianCheckModel,
MendelianCheckTreeModel,
)
from app.api.v1.module_bre.genotype_call.service import GENOTYPE_DATA_VERSION # noqa: E402
create_app = main.create_app
TOKEN = None
ok, fail = 0, 0
PREFIX = "TC_MEND"
tokens: dict[str, list[int]] = {
"germ": [], "tree": [], "combo": [], "target": [],
"marker": [], "dataset": [], "sample": [], "call": [],
"obs": [],
}
check_ids: list[int] = []
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)
trait = TraitModel(trait_code=f"tM_{PREFIX}", trait_name=f"果径{PREFIX}",
data_type="numeric", unit="mm", is_core="1", direction="desc",
into_ebv="1", default_h2=0.5, created_id=1)
db.add(trait)
await db.flush()
P1 = BreedingGermplasmModel(cultivar_name=f"{PREFIX}-P1", can_be_female=True, created_id=1)
P2 = BreedingGermplasmModel(cultivar_name=f"{PREFIX}-P2", can_be_male=True, created_id=1)
P3 = BreedingGermplasmModel(cultivar_name=f"{PREFIX}-P3", can_be_female=True, created_id=1)
P4 = BreedingGermplasmModel(cultivar_name=f"{PREFIX}-P4", can_be_male=True, created_id=1)
db.add_all([P1, P2, P3, P4])
await db.flush()
tokens["germ"] += [P1.id, P2.id, P3.id, P4.id]
C0 = CrossCombinationModel(combination_code=f"{PREFIX}-C0", bre_target_id=target.id,
female_parent_id=P1.id, male_parent_id=P2.id,
design_type="full_diallel", created_id=1)
C1 = CrossCombinationModel(combination_code=f"{PREFIX}-C1", bre_target_id=target.id,
female_parent_id=P3.id, male_parent_id=P4.id,
design_type="full_diallel", created_id=1)
db.add_all([C0, C1])
await db.flush()
tokens["combo"] += [C0.id, C1.id]
T1 = TreeModel(combination_id=C0.id, tree_no=f"{PREFIX}-T1", status="alive",
stage="evaluation", generation="F1", dam_id=P1.id, sire_id=P2.id, created_id=1)
T2 = TreeModel(combination_id=C1.id, tree_no=f"{PREFIX}-T2", status="alive",
stage="evaluation", generation="F1", dam_id=P3.id, sire_id=P4.id, created_id=1)
db.add_all([T1, T2])
await db.flush()
tokens["tree"] += [T1.id, T2.id]
FIX.update({"T1": T1.id, "T2": T2.id, "P1": P1.id, "P2": P2.id,
"P3": P3.id, "P4": P4.id})
o1 = TraitObservationModel(tree_id=T1.id, combination_id=C0.id, trait_id=trait.id,
value_numeric=25.0, evaluate_year=2025, created_id=1)
o2 = TraitObservationModel(tree_id=T2.id, combination_id=C1.id, trait_id=trait.id,
value_numeric=20.0, evaluate_year=2025, created_id=1)
db.add_all([o1, o2])
await db.flush()
tokens["obs"] += [o1.id, o2.id]
markers = []
for j in range(5):
m = MarkerModel(marker_name=f"{PREFIX}-M{j + 1}", marker_type="SNP",
chromosome=str(j), position=j * 100, created_id=1)
db.add(m)
markers.append(m)
await db.flush()
tokens["marker"] = [m.id for m in markers]
mids = [m.id for m in markers]
ds = GenotypingDatasetModel(dataset_name=f"{PREFIX}-DS", platform="SNP",
purpose="MD", created_id=1)
db.add(ds)
await db.flush()
tokens["dataset"].append(ds.id)
FIX["dataset_id"] = ds.id
def mk_sample(ds_id, no, source_type, source_id):
s = GenotypeSampleModel(sample_name=no, dataset_id=ds_id,
source_type=source_type, source_id=source_id, created_id=1)
db.add(s)
return s
samp = [
mk_sample(ds.id, f"{PREFIX}-P1-S", "germplasm", P1.id),
mk_sample(ds.id, f"{PREFIX}-P2-S", "germplasm", P2.id),
mk_sample(ds.id, f"{PREFIX}-P3-S", "germplasm", P3.id),
mk_sample(ds.id, f"{PREFIX}-P4-S", "germplasm", P4.id),
mk_sample(ds.id, f"{PREFIX}-T1-S", "tree", T1.id),
mk_sample(ds.id, f"{PREFIX}-T2-S", "tree", T2.id),
]
await db.flush()
tokens["sample"] = [s.id for s in samp]
calls = []
gt_map = {"P1": "0/1", "P2": "0/1", "P3": "0/0", "P4": "0/0", "T1": "0/0", "T2": "1/1"}
for name, s in zip(("P1", "P2", "P3", "P4", "T1", "T2"), samp):
for mid in mids:
calls.append(GenotypeCallModel(sample_id=s.id, marker_id=mid,
allele=gt_map[name], 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 _verify_and_cleanup() -> None:
engine, sf = create_async_engine_and_session()
try:
async with sf() as db:
r = FIX["run1"]
# ---- [2] 批次落库 ----
crow = await db.get(MendelianCheckModel, r["check_id"])
check("[2] bre_mendelian_check 落库(阈值/计数/data_version/input_hash",
crow is not None and float(crow.compat_threshold) == 0.98
and crow.trees_checked == 2 and crow.trees_flagged == 1
and crow.data_version == GENOTYPE_DATA_VERSION and crow.input_hash,
f"v={crow.data_version if crow else None}")
# ---- [3] 子表 ----
childs = (await db.execute(select(MendelianCheckTreeModel).where(
MendelianCheckTreeModel.check_id == r["check_id"]))).scalars().all()
child_by_tree = {int(c.tree_id): c for c in childs}
check("[3] 子表 2 行:T1 rate=1.0 flag=false / T2 rate=0.0 flag=true incompatible=5",
len(childs) == 2
and float(child_by_tree[FIX["T1"]].compatibility_rate) == 1.0
and not child_by_tree[FIX["T1"]].flag
and float(child_by_tree[FIX["T2"]].compatibility_rate) == 0.0
and child_by_tree[FIX["T2"]].flag
and child_by_tree[FIX["T2"]].status == "checked"
and child_by_tree[FIX["T2"]].incompatible == 5,
f"T1={child_by_tree[FIX['T1']].compatibility_rate}/{child_by_tree[FIX['T1']].flag} "
f"T2={child_by_tree[FIX['T2']].compatibility_rate}/{child_by_tree[FIX['T2']].flag}")
# ---- [4] 隔离注册表在 main_ 中 run1 后立即验(run2 会清除),此处只验 [5] ----
# ---- [5] 可逆:隔离行清除 ----
if FIX.get("run2"):
excl2 = (await db.execute(select(TreePedigreeExclusionModel).where(
TreePedigreeExclusionModel.is_deleted.is_(False),
TreePedigreeExclusionModel.tree_id == FIX["T2"]))).scalars().all()
check("[5] 阈值 0.0 后 T2 隔离行已清除", len(excl2) == 0, f"n={len(excl2)}")
# ---- cleanup ----
if tokens["tree"]:
await db.execute(delete(TreePedigreeExclusionModel).where(
TreePedigreeExclusionModel.tree_id.in_(tokens["tree"])))
if check_ids:
await db.execute(delete(MendelianCheckTreeModel).where(
MendelianCheckTreeModel.check_id.in_(check_ids)))
await db.execute(delete(MendelianCheckModel).where(
MendelianCheckModel.id.in_(check_ids)))
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["marker"]:
await db.execute(delete(MarkerModel).where(MarkerModel.id.in_(tokens["marker"])))
if tokens["dataset"]:
await db.execute(delete(GenotypingDatasetModel).where(
GenotypingDatasetModel.id.in_(tokens["dataset"])))
if tokens["obs"]:
await db.execute(delete(TraitObservationModel).where(
TraitObservationModel.id.in_(tokens["obs"])))
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"])))
await db.execute(delete(TraitModel).where(TraitModel.trait_code == f"tM_{PREFIX}"))
if tokens["target"]:
await db.execute(delete(TargetModel).where(TargetModel.id.in_(tokens["target"])))
await db.commit()
print(f"[cleanup] 孟德尔 tc 数据已清(check={len(check_ids)}")
finally:
await engine.dispose()
async def _check_isolated(check_id: int) -> None:
"""run1 落库后立即查隔离注册表(必须在 run2 清除之前验)。"""
engine, sf = create_async_engine_and_session()
try:
async with sf() as db:
excl = (await db.execute(select(TreePedigreeExclusionModel).where(
TreePedigreeExclusionModel.is_deleted.is_(False),
TreePedigreeExclusionModel.tree_id == FIX["T2"]))).scalars().all()
check("[4] T2 进入隔离注册表(source=mendelian / source_check_id",
len(excl) == 1 and excl[0].source_type == "mendelian"
and excl[0].source_check_id == check_id, f"n={len(excl)}")
finally:
await engine.dispose()
async def _wipe() -> None:
"""启动前清理本前缀残留(防上次进程被杀/建 fixture 中途失败留下的脏数据)。"""
engine, sf = create_async_engine_and_session()
try:
async with sf() as db:
ds_ids = list((await db.execute(select(GenotypingDatasetModel.id).where(
GenotypingDatasetModel.dataset_name.like(f"{PREFIX}-%")))).scalars())
tree_ids = list((await db.execute(select(TreeModel.id).where(
TreeModel.tree_no.like(f"{PREFIX}-%")))).scalars())
if tree_ids:
await db.execute(delete(TreePedigreeExclusionModel).where(
TreePedigreeExclusionModel.tree_id.in_(tree_ids)))
check_ids = list((await db.execute(select(MendelianCheckModel.id).where(
MendelianCheckModel.dataset_id.in_(ds_ids)))).scalars())
if check_ids:
await db.execute(delete(MendelianCheckTreeModel).where(
MendelianCheckTreeModel.check_id.in_(check_ids)))
await db.execute(delete(MendelianCheckModel).where(
MendelianCheckModel.id.in_(check_ids)))
if ds_ids:
sample_ids = list((await db.execute(select(GenotypeSampleModel.id).where(
GenotypeSampleModel.dataset_id.in_(ds_ids)))).scalars())
if sample_ids:
await db.execute(delete(GenotypeCallModel).where(
GenotypeCallModel.sample_id.in_(sample_ids)))
await db.execute(delete(GenotypeSampleModel).where(
GenotypeSampleModel.id.in_(sample_ids)))
await db.execute(delete(GenotypingDatasetModel).where(
GenotypingDatasetModel.id.in_(ds_ids)))
if tree_ids:
obs_ids = list((await db.execute(select(TraitObservationModel.id).where(
TraitObservationModel.tree_id.in_(tree_ids)))).scalars())
if obs_ids:
await db.execute(delete(TraitObservationModel).where(
TraitObservationModel.id.in_(obs_ids)))
await db.execute(delete(TreeModel).where(TreeModel.id.in_(tree_ids)))
marker_ids = list((await db.execute(select(MarkerModel.id).where(
MarkerModel.marker_name.like(f"{PREFIX}-%")))).scalars())
if marker_ids:
await db.execute(delete(MarkerModel).where(MarkerModel.id.in_(marker_ids)))
combo_ids = list((await db.execute(select(CrossCombinationModel.id).where(
CrossCombinationModel.combination_code.like(f"{PREFIX}-%")))).scalars())
if combo_ids:
await db.execute(delete(CrossCombinationModel).where(
CrossCombinationModel.id.in_(combo_ids)))
germ_ids = list((await db.execute(select(BreedingGermplasmModel.id).where(
BreedingGermplasmModel.cultivar_name.like(f"{PREFIX}-%")))).scalars())
if germ_ids:
await db.execute(delete(BreedingGermplasmModel).where(
BreedingGermplasmModel.id.in_(germ_ids)))
await db.execute(delete(TraitModel).where(TraitModel.trait_code == f"tM_{PREFIX}"))
await db.execute(delete(TargetModel).where(
TargetModel.target_name.like(f"{PREFIX}-%")))
await db.commit()
print(f"[preclean] 孟德尔 tc 前缀残留已清(ds={len(ds_ids)} tree={len(tree_ids)}")
finally:
await engine.dispose()
def main_() -> None:
asyncio.run(_wipe())
asyncio.run(_build_fixture())
try:
with TestClient(create_app()) as client:
login(client)
H = auth()
# ---- [1] POST run ----
r = client.post("/api/v1/bre/genotype_call/mendelian/check/run",
json={"dataset_id": FIX["dataset_id"], "compat_threshold": 0.98},
headers=H)
check("[HTTP] mendelian/check/run 200", r.status_code == 200,
f"{r.status_code} {str(r.text)[:150]}")
b = r.json()
data = b.get("data") if b.get("code") == 0 else None
check("[1] 返回 check_id / isolated=1 / cleared=0", data is not None
and isinstance(data["check_id"], int) and data["isolated"] == 1
and data["cleared"] == 0, f"{data}")
if data:
FIX["run1"] = data
check_ids.append(data["check_id"])
check("[1] 汇总计数 checked=2 / flagged=1 / no_parents=0 / missing=0",
data["trees_checked"] == 2 and data["trees_flagged"] == 1
and data["trees_no_parents"] == 0
and data["trees_missing_parent_genotype"] == 0,
f"{data['trees_checked']}/{data['trees_flagged']}")
# ---- [4] 隔离注册表:run1 刚落库即验(run2 阈值 0.0 会清除)----
asyncio.run(_check_isolated(FIX["run1"]["check_id"]))
# ---- [5] 阈值 0.0 重跑(可逆)----
r2 = client.post("/api/v1/bre/genotype_call/mendelian/check/run",
json={"dataset_id": FIX["dataset_id"], "compat_threshold": 0.0},
headers=H)
b2 = r2.json()
d2 = b2.get("data") if r2.status_code == 200 and b2.get("code") == 0 else None
check("[5] 阈值 0.0flagged=0 / isolated=0 / cleared=1",
d2 is not None and d2["trees_flagged"] == 0
and d2["isolated"] == 0 and d2["cleared"] == 1, f"{d2}")
if d2:
FIX["run2"] = d2
check_ids.append(d2["check_id"])
# ---- [6] GET 只读预览零回归 ----
g = client.get("/api/v1/bre/genotype_call/mendelian/check",
params={"dataset_id": FIX["dataset_id"], "compat_threshold": 0.98},
headers=H)
gb = g.json()
gd = gb.get("data") if g.status_code == 200 and gb.get("code") == 0 else None
check("[6] GET 只读预览原结构(无 check_id / 纯计算)",
gd is not None and "check_id" not in gd
and gd["trees_flagged"] == 1 and len(gd["trees"]) == 2
and gd["summary"]["incompatible_markers"] == 5,
f"flagged={gd and gd['trees_flagged']}")
finally:
asyncio.run(_verify_and_cleanup())
print(f"\n===== 孟德尔 tc 套件:ok={ok} fail={fail} =====")
if __name__ == "__main__":
main_()
sys.exit(1 if fail else 0)