# -*- 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)