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

378 lines
20 KiB
Python
Raw Permalink 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 + 方向感知/深层系谱判别(纯计算不落库)。
fixture(镜像 e2e_inbreeding_depression):6 基源种质(双作),3 组合;12 树
(前 6 非自交 dam≠sire → F=0;后 6 自交 dam==sire → F=0.5)。观测:
- tD 衰退型 y=10060F+ε(desc - tN 无衰退型 y≈100 常数
- tA 病指型 y=10+40F(asc:F↑→指标↑=恶化,方向盲目的 slope<0 判据会误报「未检出」)
经真实 HTTP 端点 POST /api/v1/bre/statistics/inbreeding-depression 断言:
[1] 衰退:slope<-40、t<-3、r²>0.9、has_depression=true、depression_rate<0、
max_F≈0.5、mean_F≈0.25、direction=desc、high_inbreeding 前6=自交(F=0.5)后4=F=0
[2] 无衰退:slope≈0、has_depression=false、文案「未检出」
[3] 样本不足:min_n=20 → sufficient=false + reason 含样本量/阈值、无回归
[4] asc 方向:slope>30、t>3、has_depression=true、文案含「上升」(方向感知)
[5] 深层系谱(P→G7/G8 升谱,候选树 dam=G7 sire=G8 半同胞近交 F=0.125 不低估)
[6] 性状不存在 → 409
依赖: 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 # noqa: E402
from app.api.v1.module_bre.trait_observation.model import TraitObservationModel # noqa: E402
create_app = main.create_app
TOKEN = None
ok, fail = 0, 0
PREFIX = "TCIBRD"
tokens: dict[str, list[int]] = {
"trait": [], "tree": [], "combo": [], "germ": [], "target": [], "obs": [],
}
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 _wipe() -> None:
"""启动前清理本前缀残留(防上次进程被杀/建 fixture 中途失败留下的脏数据)。"""
engine, sf = create_async_engine_and_session()
try:
async with sf() as db:
trait_ids = list((await db.execute(select(TraitModel.id).where(
TraitModel.trait_code.like(f"%_{PREFIX}")))).scalars())
if trait_ids:
obs = list((await db.execute(select(TraitObservationModel.id).where(
TraitObservationModel.trait_id.in_(trait_ids)))).scalars())
if obs:
await db.execute(delete(TraitObservationModel).where(
TraitObservationModel.id.in_(obs)))
tree_ids = list((await db.execute(select(TreeModel.id).where(
TreeModel.tree_no.like(f"{PREFIX}-%")))).scalars())
if tree_ids:
await db.execute(delete(TreeModel).where(TreeModel.id.in_(tree_ids)))
combo_ids = list((await db.execute(select(CrossCombinationModel.id).where(
CrossCombinationModel.combination_code.like(f"C%_{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)))
if trait_ids:
await db.execute(delete(TraitModel).where(TraitModel.id.in_(trait_ids)))
await db.execute(delete(TargetModel).where(TargetModel.target_name == f"目标{PREFIX}"))
await db.commit()
print(f"[preclean] inbreeding tc 前缀残留已清(trait={len(trait_ids)} tree={len(tree_ids)}")
finally:
await engine.dispose()
async def _build_fixture() -> None:
engine, sf = create_async_engine_and_session()
try:
async with sf() as db:
tD = TraitModel(trait_code=f"dD_{PREFIX}", trait_name=f"衰退型{PREFIX}", data_type="numeric",
unit="g", is_core="1", direction="desc", into_ebv="1",
default_h2=0.5, stage="evaluation", created_id=1)
tN = TraitModel(trait_code=f"dN_{PREFIX}", trait_name=f"无衰退型{PREFIX}", data_type="numeric",
unit="g", is_core="1", direction="desc", into_ebv="1",
default_h2=0.5, stage="evaluation", created_id=1)
tA = TraitModel(trait_code=f"dA_{PREFIX}", trait_name=f"病指型{PREFIX}", data_type="numeric",
unit="%", is_core="1", direction="asc", into_ebv="1",
default_h2=0.5, stage="evaluation", created_id=1)
db.add_all([tD, tN, tA])
await db.flush()
tokens["trait"] += [tD.id, tN.id, tA.id]
FIX["D"], FIX["N"], FIX["A"] = tD, tN, tA
target = TargetModel(target_name=f"目标{PREFIX}", created_id=1)
db.add(target)
await db.flush()
tokens["target"].append(target.id)
germs = []
for i in range(6):
g = BreedingGermplasmModel(cultivar_name=f"G{i}_{PREFIX}",
can_be_female=True, can_be_male=True, created_id=1)
db.add(g)
germs.append(g)
await db.flush()
tokens["germ"] += [g.id for g in germs]
combos = []
for i in range(3):
c = CrossCombinationModel(
combination_code=f"C{i}_{PREFIX}", bre_target_id=target.id,
female_parent_id=germs[2 * i].id, male_parent_id=germs[2 * i + 1].id,
design_type="full_diallel", created_id=1,
)
db.add(c)
combos.append(c)
await db.flush()
tokens["combo"] += [c.id for c in combos]
trees: list[TreeModel] = []
for i in range(6):
gA, gB = germs[2 * (i % 3)].id, germs[2 * (i % 3) + 1].id
t = TreeModel(combination_id=combos[i % 3].id, tree_no=f"{PREFIX}-N{i:02d}",
status="alive", stage="evaluation", generation="F1",
dam_id=gA, sire_id=gB, created_id=1)
db.add(t)
trees.append(t)
for i in range(6):
g = germs[i % 3].id # dam==sire → 自交,F=0.5
t = TreeModel(combination_id=combos[i % 3].id, tree_no=f"{PREFIX}-S{i:02d}",
status="alive", stage="evaluation", generation="F1",
dam_id=g, sire_id=g, created_id=1)
db.add(t)
trees.append(t)
await db.flush()
tokens["tree"] += [t.id for t in trees]
obs_rows = []
for i, t in enumerate(trees):
f = 0.5 if i >= 6 else 0.0
oD = TraitObservationModel(tree_id=t.id, combination_id=combos[i % 3].id,
trait_id=tD.id, value_numeric=100.0 - 60.0 * f + (i % 5) * 0.3,
evaluate_year=2025, created_id=1)
oN = TraitObservationModel(tree_id=t.id, combination_id=combos[i % 3].id,
trait_id=tN.id, value_numeric=100.0 + (i % 3) * 0.1,
evaluate_year=2025, created_id=1)
oA = TraitObservationModel(tree_id=t.id, combination_id=combos[i % 3].id,
trait_id=tA.id, value_numeric=10.0 + 40.0 * f,
evaluate_year=2025, created_id=1)
db.add_all([oD, oN, oA])
obs_rows += [oD, oN, oA]
await db.flush()
tokens["obs"] += [o.id for o in obs_rows]
# ---- [5] 深层系谱:P→G7/G8tree FK 升谱),候选树 dam=G7 sire=G8 半同胞近交 F=0.125 ----
gP = BreedingGermplasmModel(cultivar_name=f"GP_{PREFIX}", can_be_female=True, can_be_male=True, created_id=1)
g7 = BreedingGermplasmModel(cultivar_name=f"G7_{PREFIX}", can_be_female=True, can_be_male=True, created_id=1)
g8 = BreedingGermplasmModel(cultivar_name=f"G8_{PREFIX}", can_be_female=True, can_be_male=True, created_id=1)
db.add_all([gP, g7, g8])
await db.flush()
tokens["germ"] += [gP.id, g7.id, g8.id]
eX = TreeModel(combination_id=combos[0].id, tree_no=f"{PREFIX}-X01", status="alive",
stage="evaluation", generation="F1", germplasm_id=g7.id, dam_id=gP.id, created_id=1)
eY = TreeModel(combination_id=combos[0].id, tree_no=f"{PREFIX}-X02", status="alive",
stage="evaluation", generation="F1", germplasm_id=g8.id, dam_id=gP.id, created_id=1)
tD2 = TraitModel(trait_code=f"dD2_{PREFIX}", trait_name=f"深层系谱衰退型{PREFIX}", data_type="numeric",
unit="g", is_core="1", direction="desc", into_ebv="1",
default_h2=0.5, stage="evaluation", created_id=1)
db.add_all([eX, eY, tD2])
await db.flush()
tokens["tree"] += [eX.id, eY.id]
tokens["trait"].append(tD2.id)
FIX["D2"] = tD2
deep_trees: list[TreeModel] = []
for i in range(6):
t = TreeModel(combination_id=combos[0].id, tree_no=f"{PREFIX}-H{i:02d}", status="alive",
stage="evaluation", generation="F1", dam_id=g7.id, sire_id=g8.id, created_id=1)
db.add(t)
deep_trees.append(t)
for i in range(6):
t = TreeModel(combination_id=combos[0].id, tree_no=f"{PREFIX}-C{i:02d}", status="alive",
stage="evaluation", generation="F1", dam_id=germs[0].id, sire_id=germs[1].id, created_id=1)
db.add(t)
deep_trees.append(t)
await db.flush()
tokens["tree"] += [t.id for t in deep_trees]
obs2 = []
for i, t in enumerate(deep_trees):
f = 0.125 if i < 6 else 0.0
o = TraitObservationModel(tree_id=t.id, trait_id=tD2.id, value_numeric=100.0 - 60.0 * f + (i % 4) * 0.2,
evaluate_year=2025, created_id=1)
db.add(o)
obs2.append(o)
await db.flush()
tokens["obs"] += [o.id for o in obs2]
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:
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"])))
if tokens["trait"]:
await db.execute(delete(TraitModel).where(TraitModel.id.in_(tokens["trait"])))
if tokens["target"]:
await db.execute(delete(TargetModel).where(TargetModel.id.in_(tokens["target"])))
await db.commit()
print(f"[cleanup] inbreeding tc 数据已清(trait={len(tokens['trait'])} tree={len(tokens['tree'])} "
f"obs={len(tokens['obs'])}")
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()
code = {k: FIX[k].trait_code for k in ("D", "N", "A", "D2")}
def run(tid, tcode, min_n=10):
r = client.post("/api/v1/bre/statistics/inbreeding-depression",
json={"trait_id": tid, "trait_code": tcode,
"year": None, "trial_study_id": None, "min_n": min_n},
headers=H)
check("[HTTP] inbreeding-depression 200", r.status_code == 200,
f"{r.status_code} {str(r.text)[:120]}")
b = r.json()
return b.get("data") if b.get("code") == 0 else None
# ---- [1] 衰退性状 ----
rD = run(FIX["D"].id, code["D"])
check("[1] sufficient=True", rD is not None and rD.get("sufficient") is True,
f"{rD and rD.get('reason')}")
if rD:
reg = rD["regression"]
check("[1] n_obs=12", rD.get("n_obs") == 12, f"{rD.get('n_obs')}")
check("[1] slope<-40(≈−60", reg["slope"] < -40, f"slope={reg['slope']:.2f}")
check("[1] r²>0.9", reg["r2"] > 0.9, f"r²={reg['r2']:.3f}")
check("[1] has_depression=true", reg["has_depression"] is True)
check("[1] t_slope<-3", reg["t_slope"] < -3, f"t={reg['t_slope']:.2f}")
check("[1] 衰退率<0", reg.get("depression_rate") is not None
and reg["depression_rate"] < 0, f"{reg.get('depression_rate')}")
check("[1] max_F≈0.5", abs(rD["max_F"] - 0.5) < 0.001, f"max_F={rD['max_F']}")
check("[1] mean_F≈0.25", 0.2 < rD["mean_F"] < 0.3, f"mean_F={rD['mean_F']}")
check("[1] direction=desc", rD["direction"] == "desc", f"{rD['direction']}")
check("[1] 文案含近交衰退", "近交衰退" in reg["interpretation"],
f"{reg['interpretation']}")
hi = rD["high_inbreeding"]
check("[1] high_inbreeding 10 条", len(hi) == 10, f"{len(hi)}")
check("[1] 前6=自交(F=0.5)+tree_no", all(abs(x["F"] - 0.5) < 0.001 for x in hi[:6])
and all(x.get("tree_no", "").startswith(f"{PREFIX}-S") for x in hi[:6]),
f"{[x.get('F') for x in hi[:6]]}")
check("[1] 后4=F=0", all(abs(x["F"]) < 1e-6 for x in hi[6:]))
# ---- [2] 无衰退性状 ----
rN = run(FIX["N"].id, code["N"])
check("[2] sufficient=True", rN is not None and rN.get("sufficient") is True)
if rN:
regN = rN["regression"]
check("[2] slope≈0", abs(regN["slope"]) < 5, f"slope={regN['slope']:.2f}")
check("[2] has_depression=false", regN["has_depression"] is False)
check("[2] t>-1.645", regN["t_slope"] > -1.645, f"t={regN['t_slope']:.2f}")
check("[2] 文案含未检出", "未检出" in regN["interpretation"], f"{regN['interpretation']}")
# ---- [3] 样本不足 ----
rLow = run(FIX["D"].id, code["D"], min_n=20)
check("[3] sufficient=false", rLow is not None and rLow.get("sufficient") is False)
if rLow:
check("[3] reason 含样本量+阈值", "12 棵" in rLow["reason"] and "≥20" in rLow["reason"],
f"{rLow['reason']}")
check("[3] 无回归/清单", rLow.get("regression") is None and rLow.get("high_inbreeding") == [])
# ---- [4] asc 方向(病指,越低越好):F↑→指标↑=恶化 ----
rA = run(FIX["A"].id, code["A"])
check("[4] sufficient=True", rA is not None and rA.get("sufficient") is True)
if rA:
regA = rA["regression"]
check("[4] direction=asc", rA["direction"] == "asc", f"{rA['direction']}")
check("[4] slope>30(≈+40", regA["slope"] > 30, f"slope={regA['slope']:.2f}")
check("[4] t>3", regA["t_slope"] > 3, f"t={regA['t_slope']:.2f}")
check("[4] has_depression=true(方向感知)", regA["has_depression"] is True)
check("[4] 文案含上升", "上升" in regA["interpretation"], f"{regA['interpretation']}")
check("[4] max_F>0.4", rA["max_F"] > 0.4, f"max_F={rA['max_F']}")
check("[4] 前6=F=0.5", all(abs(x["F"] - 0.5) < 0.001 for x in rA["high_inbreeding"][:6]))
# ---- [5] 深层系谱近交系数(多世代不低估 F) ----
rD2 = run(FIX["D2"].id, code["D2"])
check("[5] sufficient=True", rD2 is not None and rD2.get("sufficient") is True)
if rD2:
check("[5] n_obs=12", rD2.get("n_obs") == 12, f"{rD2.get('n_obs')}")
check("[5] max_F≈0.125(半同胞近交,浅层=0 低估)", abs(rD2["max_F"] - 0.125) < 0.001,
f"max_F={rD2['max_F']}")
check("[5] mean_F≈0.0625", abs(rD2["mean_F"] - 0.0625) < 0.001, f"mean_F={rD2['mean_F']}")
check("[5] slope<-40", rD2["regression"]["slope"] < -40,
f"slope={rD2['regression']['slope']:.2f}")
check("[5] has_depression=true", rD2["regression"]["has_depression"] is True)
hi2 = rD2["high_inbreeding"]
check("[5] 前6=F=0.125+H株", all(abs(x["F"] - 0.125) < 0.001 for x in hi2[:6])
and all(x.get("tree_no", "").startswith(f"{PREFIX}-H") for x in hi2[:6]),
f"{[x.get('F') for x in hi2[:6]]}")
# ---- [6] 校验 409 ----
r = client.post("/api/v1/bre/statistics/inbreeding-depression",
json={"trait_id": 99999999, "trait_code": f"dZ_{PREFIX}",
"year": None, "trial_study_id": None, "min_n": 10},
headers=H)
check("[6] 性状不存在 → 409", r.status_code == 409, f"{r.status_code}")
r = client.post("/api/v1/bre/statistics/inbreeding-depression",
json={"trait_id": FIX["D"].id, "trait_code": code["D"],
"year": None, "trial_study_id": None, "min_n": 1},
headers=H)
check("[6] min_n<2 → 422", r.status_code == 422, f"{r.status_code}")
finally:
asyncio.run(_verify_and_cleanup())
print(f"\n===== inbreeding tc 套件:ok={ok} fail={fail} =====")
if __name__ == "__main__":
main_()
sys.exit(1 if fail else 0)