Files
dpb/backend/scripts/test_bre_analysis_stability_tc.py
T

326 lines
16 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""稳定性(AMMI/Finlay-Wilkinson)正式 tc 套件:TestClient 走真实 API + 落库 detail_json 断言。
fixture(镜像 e2e_advanced_stability):3 组合 × 3 站点 cell means
C0=[0,6,12](高响应 → FW b≈2.0、flag_stable=False
C1=[2,5,8] (稳定 → FW b≈1.0、flag_stable=True|b-1|≤2·se(b)
C2=[4,4,4] (低响应 → FW b≈0.0、flag_stable=False
每组合 2 株、每株在 3 站点各有观察值(evaluate_year=2023/2024/2025)。
经真实 HTTP 端点 POST /api/v1/bre/statistics/stability/run 断言:
[1] site 维度:env_dim=site、engine_version 落库、methods=["ammi","finlay"]、
FW b 斜坡(C0>1.3 / C1≈1±0.05 / C2<0.5+ flag_stable 语义(C1=True 其余 False)、
r²≈1(常量响应基因型为 None 合法);AMMI n=9、rank 按 ASV 升序、IPC1 占比≥IPC2、互作 SS>0
[2] year 维度:env_dim=year 且 finlay 落库
[3] 门禁:非法 methods / 非法 gxe_env → 409
[4] list / detail 端点命中本批次
依赖: 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.trait.model import TraitModel # noqa: E402
from app.api.v1.module_bre.target.model import TargetModel # noqa: E402
from app.api.v1.module_bre.trial.model import TrialModel # noqa: E402
from app.api.v1.module_bre.trial_study.model import TrialStudyModel # 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
from app.api.v1.module_bre.statistics.model import StatisticsJobModel, StabilityResultModel # noqa: E402
from scripts.breeding_stats import stability # noqa: E402
create_app = main.create_app
TOKEN = None
ok, fail = 0, 0
PREFIX = "TCSTAB"
trait_code = f"tR_{PREFIX}"
tokens: dict[str, list[int]] = {
"trait": [], "target": [], "germ": [], "combo": [], "tree": [], "obs": [], "study": [],
}
result_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 _wipe() -> None:
"""启动前清理本前缀残留(防上次进程被杀/建 fixture 中途失败留下的脏数据)。"""
engine, sf = create_async_engine_and_session()
try:
async with sf() as db:
result_rows = list((await db.execute(select(StabilityResultModel.id).where(
StabilityResultModel.trait_code == trait_code))).scalars())
if result_rows:
await db.execute(delete(StabilityResultModel).where(
StabilityResultModel.id.in_(result_rows)))
jobs = list((await db.execute(select(StatisticsJobModel.id).where(
StatisticsJobModel.job_type == "STABILITY",
StatisticsJobModel.params_json.op("->>")("trait_code") == trait_code))).scalars())
if jobs:
await db.execute(delete(StatisticsJobModel).where(StatisticsJobModel.id.in_(jobs)))
study_ids = list((await db.execute(select(TrialStudyModel.id).where(
TrialStudyModel.study_name.like(f"站%_{PREFIX}")))).scalars())
if study_ids:
await db.execute(delete(TrialStudyModel).where(TrialStudyModel.id.in_(study_ids)))
await db.execute(delete(TrialModel).where(TrialModel.trial_name == f"区域试验{PREFIX}"))
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)))
await db.execute(delete(BreedingGermplasmModel).where(
BreedingGermplasmModel.cultivar_name.in_([f"FM_{PREFIX}", f"MM_{PREFIX}"])))
await db.execute(delete(TraitModel).where(TraitModel.trait_code == trait_code))
await db.execute(delete(TargetModel).where(TargetModel.target_name == f"目标{PREFIX}"))
await db.commit()
print(f"[preclean] stability tc 前缀残留已清(result={len(result_rows)} study={len(study_ids)}")
finally:
await engine.dispose()
async def _build_fixture() -> None:
engine, sf = create_async_engine_and_session()
try:
async with sf() as db:
trait = TraitModel(trait_code=trait_code, trait_name=f"坐果率{PREFIX}", data_type="numeric",
unit="%", is_core="1", direction="desc", into_ebv="1",
default_h2=0.4, created_id=1)
db.add(trait)
await db.flush()
tokens["trait"].append(trait.id)
target = TargetModel(target_name=f"目标{PREFIX}", created_id=1)
db.add(target)
await db.flush()
tokens["target"].append(target.id)
trial = TrialModel(trial_name=f"区域试验{PREFIX}", start_year=2023, end_year=2025, created_id=1)
db.add(trial)
await db.flush()
studies = []
for i in range(3):
s = TrialStudyModel(trial_id=trial.id, study_name=f"站{i + 1}_{PREFIX}",
year=2023 + i, created_id=1)
db.add(s)
studies.append(s)
await db.flush()
tokens["study"] = [s.id for s in studies]
fm = BreedingGermplasmModel(cultivar_name=f"FM_{PREFIX}", can_be_female=True, created_id=1)
mm = BreedingGermplasmModel(cultivar_name=f"MM_{PREFIX}", can_be_male=True, created_id=1)
db.add_all([fm, mm])
await db.flush()
tokens["germ"] += [fm.id, mm.id]
# 3 组合,每组合 2 株;cell 均值目标:
# C0=[0,6,12] C1=[2,5,8] C2=[4,4,4](对应 3 站点)
targets = {0: [0.0, 6.0, 12.0], 1: [2.0, 5.0, 8.0], 2: [4.0, 4.0, 4.0]}
combos: list[CrossCombinationModel] = []
trees: list[TreeModel] = []
for k in range(3):
combo = CrossCombinationModel(
combination_code=f"C{k}_{PREFIX}", bre_target_id=target.id,
female_parent_id=fm.id, male_parent_id=mm.id,
design_type="full_diallel", created_id=1)
db.add(combo)
await db.flush()
combos.append(combo)
tokens["combo"].append(combo.id)
for j in range(2):
t = TreeModel(combination_id=combo.id, tree_no=f"{PREFIX}-{k}{j}", status="alive",
stage="seedling", generation="F1", planted_date="2022-03-10",
created_id=1)
db.add(t)
trees.append(t)
await db.flush()
tokens["tree"] = [t.id for t in trees]
obs_rows = []
for k, combo in enumerate(combos):
pair = [trees[2 * k], trees[2 * k + 1]]
for si, (study, yv) in enumerate(zip(studies, (2023, 2024, 2025))):
v = targets[k][si]
for t in pair:
o = TraitObservationModel(
tree_id=t.id, combination_id=combo.id, trait_id=trait.id,
value_numeric=v, evaluate_year=yv, trial_study_id=study.id, created_id=1)
db.add(o)
obs_rows.append(o)
await db.flush()
tokens["obs"] = [o.id for o in obs_rows]
FIX["combo_ids"] = [c.id for c in combos]
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 result_ids:
await db.execute(delete(StabilityResultModel).where(
StabilityResultModel.id.in_(result_ids)))
jobs = list((await db.execute(select(StatisticsJobModel.id).where(
StatisticsJobModel.job_type == "STABILITY",
StatisticsJobModel.params_json.op("->>")("trait_code") == trait_code))).scalars())
if jobs:
await db.execute(delete(StatisticsJobModel).where(StatisticsJobModel.id.in_(jobs)))
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["study"]:
await db.execute(delete(TrialStudyModel).where(TrialStudyModel.id.in_(tokens["study"])))
await db.execute(delete(TrialModel).where(TrialModel.trial_name == f"区域试验{PREFIX}"))
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] stability tc 数据已清(result={len(result_ids)} obs={len(tokens['obs'])}")
finally:
await engine.dispose()
def main_() -> None:
asyncio.run(_wipe())
asyncio.run(_build_fixture())
trait_id = tokens["trait"][0]
try:
with TestClient(create_app()) as client:
login(client)
H = auth()
def run(payload):
r = client.post("/api/v1/bre/statistics/stability/run", json=payload, headers=H)
b = r.json()
check(f"[HTTP] stability/run 200", r.status_code == 200,
f"{r.status_code} {str(r.text)[:150]}")
return b.get("data") if b.get("code") == 0 else None
# ================= [1] site 维度 =================
sid = run({"trait_id": trait_id, "trait_code": trait_code,
"gxe_env": "site", "methods": ["ammi", "finlay"]})
result_ids.append(sid)
r = client.get(f"/api/v1/bre/statistics/stability/{sid}", headers=H)
b = r.json()
check("[1] detail 200 + id 命中", r.status_code == 200 and b.get("data", {}).get("id") == sid,
f"{r.status_code}")
d0 = b["data"]
check("[1] env_dim=site + engine_version 落库",
d0["env_dim"] == "site" and d0["engine_version"] == stability.ENGINE_VERSION,
f"env={d0['env_dim']} ver={d0['engine_version']}")
d = d0["detail_json"]
check("[1] methods=['ammi','finlay']", d["methods"] == ["ammi", "finlay"], f"{d['methods']}")
fw = d["finlay"]
rows = {r_["genotype"]: r_ for r_ in fw["rows"]}
k = [f"f{cid}" for cid in FIX["combo_ids"]]
check("[1] FW 3 行(3 组合)", len(rows) == 3 and all(g in rows for g in k), f"{list(rows)}")
bmap = {g: rows[g]["b"] for g in k}
check("[1] FW b 斜坡:C0>1.3 / C1≈1.0±0.05 / C2<0.5",
bmap[k[0]] > 1.3 and abs(bmap[k[1]] - 1.0) < 0.05 and bmap[k[2]] < 0.5,
f"{ {g: round(bmap[g], 3) for g in k} }")
check("[1] flag_stable 语义:C1=True、C0/C2=False",
rows[k[0]]["flag_stable"] is False and rows[k[1]]["flag_stable"] is True
and rows[k[2]]["flag_stable"] is False,
f"{ {g: rows[g]['flag_stable'] for g in k} }")
check("[1] r²≈1(常量响应基因型为 None 合法)",
all(r_["r2"] is None or r_["r2"] >= 0.99 for r_ in fw["rows"]),
f"{ {g: rows[g]['r2'] for g in k} }")
am = d["ammi"]
check("[1] AMMI n=9 格子", am["n"] == 9, f"n={am['n']}")
check("[1] AMMI rank 按 ASV 升序 + 互作 SS>0",
len(am["rows"]) == 3 and all(r_["rank"] == i + 1 for i, r_ in enumerate(am["rows"]))
and am["rows"][0]["asv"] <= am["rows"][-1]["asv"] and am["ss_interaction"] > 0,
f"asv={[round(r_['asv'], 3) for r_ in am['rows']]} ss={round(am['ss_interaction'], 3)}")
check("[1] IPC1 解释比例 ≥ IPC2",
am["ipc_variance"][0]["proportion"] >= am["ipc_variance"][1]["proportion"],
f"[{round(am['ipc_variance'][0]['proportion'], 3)}, {round(am['ipc_variance'][1]['proportion'], 3)}]")
# ================= [2] year 维度 =================
sid2 = run({"trait_id": trait_id, "trait_code": trait_code,
"gxe_env": "year", "methods": ["ammi", "finlay"]})
result_ids.append(sid2)
r2 = client.get(f"/api/v1/bre/statistics/stability/{sid2}", headers=H).json()["data"]
check("[2] year 维度:env_dim=year + finlay 落库",
r2["env_dim"] == "year" and r2["detail_json"]["finlay"]
and r2["detail_json"]["ammi"]["n"] == 9,
f"env={r2['env_dim']} ammi_n={r2['detail_json']['ammi']['n']}")
# ================= [3] 门禁 =================
r = client.post("/api/v1/bre/statistics/stability/run",
json={"trait_id": trait_id, "trait_code": trait_code,
"gxe_env": "site", "methods": ["bad"]}, headers=H)
check("[3] 非法 methods → 409", r.status_code == 409, f"{r.status_code}")
r = client.post("/api/v1/bre/statistics/stability/run",
json={"trait_id": trait_id, "trait_code": trait_code,
"gxe_env": "bad", "methods": ["ammi", "finlay"]}, headers=H)
check("[3] 非法 gxe_env → 409", r.status_code == 409, f"{r.status_code}")
# ================= [4] list / detail =================
r = client.get("/api/v1/bre/statistics/stability", headers=H)
lst = r.json()["data"]
check("[4] list 含本批次 + detail 命中",
any(x["id"] == sid for x in lst) and
client.get(f"/api/v1/bre/statistics/stability/{sid}", headers=H).json()["data"]["id"] == sid,
f"list 共 {len(lst)} 条")
finally:
asyncio.run(_verify_and_cleanup())
print(f"\n===== stability tc 套件:ok={ok} fail={fail} =====")
if __name__ == "__main__":
main_()
sys.exit(1 if fail else 0)