326 lines
16 KiB
Python
326 lines
16 KiB
Python
# -*- 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)
|