572 lines
31 KiB
Python
572 lines
31 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""O2–O4 统计方法学广度正式 tc 套件(2026-08-05,_DATA_VERSION v2.30,TestClient 真实 API)。
|
|||
|
|
|
|||
|
|
O2 幼年–成年遗传相关 / O3 非线性基因组预测 / O4 约束选择指数 —— 前后端+引擎落地后的 API 回归护栏:
|
|||
|
|
|
|||
|
|
[O2] POST /type-b-heredity env_dim=stage(发育阶段当"环境")→ 判别 fixture(两全同胞家系 +
|
|||
|
|
共享遗传效应跨阶段)落库 r_g>0.5(童期可借力成株)、n_common=全树数、envs_json 双阶段。
|
|||
|
|
[O3] POST /gblup/run method=rrblup/bayesb → 落库 PredictionModel(method=RRBLUP/BayesB);
|
|||
|
|
bayesb 同 seed 两次运行 EBV 逐位一致(可复现,MLOps 铁律);rrblup 与 gblup EBV 相关>0.9(对偶)。
|
|||
|
|
POST /cv/run method=rrblup/bayesb → KFCV/RRBLUP、KFCV/BayesB 完整 CV(mean_pearson∈[0,1])。
|
|||
|
|
[O4] POST /selection-index method=restricted(+economic_weights)→ SI_RES + restricted/constraint_mode=
|
|||
|
|
ΔG=0 标记 + 受限性状 b≈0(对角 G 时 ΔG=0 ⇔ b=0,判别投影生效);method=smith_hazel(economic)
|
|||
|
|
→ asc 性状 direction 翻转 b<0、desc 性状 b>0。
|
|||
|
|
|
|||
|
|
fixture(镜像 e2e_o234_20260805 判别结构):4 种质亲本(两全同胞家系)+ 2 组合 × 6 株 = 12 株;
|
|||
|
|
基因型数据集 12 SNP 标记(确保多态)全株分型;cX(desc)/cY(asc) 表型独立(P 近对角,r_g=0 可控);
|
|||
|
|
ABLUP 批次手插(reliability=0 → Calo 分母 0 → r_g=0 → G 精确对角,restricted ΔG=0 ⇔ b_受限=0)。
|
|||
|
|
依赖: 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 numpy as np # noqa: E402
|
|||
|
|
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
|
|||
|
|
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 GenotypeCallModel # noqa: E402
|
|||
|
|
from app.api.v1.module_bre.marker.model import MarkerModel # noqa: E402
|
|||
|
|
from app.api.v1.module_bre.statistics.model import ( # noqa: E402
|
|||
|
|
PredictionModel, PredictionValueModel, TypeBResultModel,
|
|||
|
|
CvResultModel, CvFoldModel, SelectionIndexModel, StatisticsJobModel,
|
|||
|
|
)
|
|||
|
|
from scripts.breeding_stats import blup, genomic # noqa: E402
|
|||
|
|
|
|||
|
|
create_app = main.create_app
|
|||
|
|
TOKEN = None
|
|||
|
|
ok, fail = 0, 0
|
|||
|
|
|
|||
|
|
PREFIX = "O234"
|
|||
|
|
SEED = 20260805
|
|||
|
|
N_FAM = 2
|
|||
|
|
N_PER = 6
|
|||
|
|
N = N_FAM * N_PER
|
|||
|
|
M = 12
|
|||
|
|
GT = {0: "0/0", 1: "0/1", 2: "1/1"}
|
|||
|
|
tokens: dict[str, list[int]] = {
|
|||
|
|
"trait": [], "tree": [], "combo": [], "germ": [], "target": [],
|
|||
|
|
"obs": [], "dataset": [], "sample": [], "call": [], "marker": [],
|
|||
|
|
}
|
|||
|
|
pred_ids: list[int] = []
|
|||
|
|
cv_ids: list[int] = []
|
|||
|
|
idx_ids: list[int] = []
|
|||
|
|
GS_PREDS: dict[str, list[int]] = {"rr": [], "bb": [], "gb": []}
|
|||
|
|
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"c%_{PREFIX}")))).scalars())
|
|||
|
|
trait_like = [f"c%_{PREFIX}"]
|
|||
|
|
for pat in trait_like:
|
|||
|
|
await db.execute(delete(TypeBResultModel).where(
|
|||
|
|
TypeBResultModel.trait_code.like(pat)))
|
|||
|
|
cv_rows = list((await db.execute(select(CvResultModel.id).where(
|
|||
|
|
CvResultModel.trait_code.like(f"c%_{PREFIX}")))).scalars())
|
|||
|
|
if cv_rows:
|
|||
|
|
await db.execute(delete(CvFoldModel).where(CvFoldModel.cv_result_id.in_(cv_rows)))
|
|||
|
|
await db.execute(delete(CvResultModel).where(CvResultModel.id.in_(cv_rows)))
|
|||
|
|
await db.execute(delete(SelectionIndexModel).where(
|
|||
|
|
SelectionIndexModel.result_json.op("->>")("traits").like(f"%_{PREFIX}%")))
|
|||
|
|
await db.execute(delete(StatisticsJobModel).where(
|
|||
|
|
StatisticsJobModel.params_json.op("->>")("trait_code").like(f"c%_{PREFIX}%")))
|
|||
|
|
if trait_ids:
|
|||
|
|
await db.execute(delete(PredictionValueModel).where(
|
|||
|
|
PredictionValueModel.trait_id.in_(trait_ids)))
|
|||
|
|
await db.execute(delete(PredictionModel).where(
|
|||
|
|
PredictionModel.trait_id.in_(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)))
|
|||
|
|
ds_ids = list((await db.execute(select(GenotypingDatasetModel.id).where(
|
|||
|
|
GenotypingDatasetModel.dataset_name == f"GS_{PREFIX}"))).scalars())
|
|||
|
|
if ds_ids:
|
|||
|
|
smp = list((await db.execute(select(GenotypeSampleModel.id).where(
|
|||
|
|
GenotypeSampleModel.dataset_id.in_(ds_ids)))).scalars())
|
|||
|
|
if smp:
|
|||
|
|
await db.execute(delete(GenotypeCallModel).where(
|
|||
|
|
GenotypeCallModel.sample_id.in_(smp)))
|
|||
|
|
await db.execute(delete(GenotypeSampleModel).where(
|
|||
|
|
GenotypeSampleModel.id.in_(smp)))
|
|||
|
|
await db.execute(delete(GenotypingDatasetModel).where(
|
|||
|
|
GenotypingDatasetModel.id.in_(ds_ids)))
|
|||
|
|
m_ids = list((await db.execute(select(MarkerModel.id).where(
|
|||
|
|
MarkerModel.marker_name.like(f"MK%_{PREFIX}")))).scalars())
|
|||
|
|
if m_ids:
|
|||
|
|
await db.execute(delete(MarkerModel).where(MarkerModel.id.in_(m_ids)))
|
|||
|
|
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] o234 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:
|
|||
|
|
rng = np.random.default_rng(SEED)
|
|||
|
|
|
|||
|
|
target = TargetModel(target_name=f"目标{PREFIX}", created_id=1)
|
|||
|
|
db.add(target)
|
|||
|
|
await db.flush()
|
|||
|
|
tokens["target"].append(target.id)
|
|||
|
|
|
|||
|
|
cX = TraitModel(trait_code=f"cX_{PREFIX}", trait_name=f"单果重{PREFIX}", data_type="numeric",
|
|||
|
|
unit="1", is_core="1", direction="desc", into_ebv="1",
|
|||
|
|
default_h2=0.5, created_id=1)
|
|||
|
|
cY = TraitModel(trait_code=f"cY_{PREFIX}", trait_name=f"可溶固形物{PREFIX}", data_type="numeric",
|
|||
|
|
unit="%", is_core="1", direction="asc", into_ebv="1",
|
|||
|
|
default_h2=0.5, created_id=1)
|
|||
|
|
cS = TraitModel(trait_code=f"cS_{PREFIX}", trait_name=f"幼成相关{PREFIX}", data_type="numeric",
|
|||
|
|
unit="1", is_core="1", direction="desc", into_ebv="1",
|
|||
|
|
default_h2=0.6, created_id=1)
|
|||
|
|
db.add_all([cX, cY, cS])
|
|||
|
|
await db.flush()
|
|||
|
|
tokens["trait"] = [cX.id, cY.id, cS.id]
|
|||
|
|
FIX["cX"], FIX["cY"], FIX["cS"] = cX, cY, cS
|
|||
|
|
|
|||
|
|
germ_f, germ_m = [], []
|
|||
|
|
for i in range(N_FAM):
|
|||
|
|
gf = BreedingGermplasmModel(cultivar_name=f"FM{i}_{PREFIX}", can_be_female=True, created_id=1)
|
|||
|
|
gm = BreedingGermplasmModel(cultivar_name=f"MM{i}_{PREFIX}", can_be_male=True, created_id=1)
|
|||
|
|
db.add_all([gf, gm])
|
|||
|
|
await db.flush()
|
|||
|
|
germ_f.append(gf.id)
|
|||
|
|
germ_m.append(gm.id)
|
|||
|
|
tokens["germ"] = germ_f + germ_m
|
|||
|
|
|
|||
|
|
combos, combo_of = [], {}
|
|||
|
|
for i in range(N_FAM):
|
|||
|
|
c = CrossCombinationModel(
|
|||
|
|
combination_code=f"C{i}_{PREFIX}", bre_target_id=target.id,
|
|||
|
|
female_parent_id=germ_f[i], male_parent_id=germ_m[i],
|
|||
|
|
design_type="full_diallel", created_id=1)
|
|||
|
|
db.add(c)
|
|||
|
|
await db.flush()
|
|||
|
|
combos.append(c.id)
|
|||
|
|
tokens["combo"] = combos
|
|||
|
|
|
|||
|
|
trees: list[TreeModel] = []
|
|||
|
|
for i in range(N_FAM):
|
|||
|
|
for k in range(N_PER):
|
|||
|
|
t = TreeModel(combination_id=combos[i], tree_no=f"{PREFIX}-{i:02d}-{k:02d}",
|
|||
|
|
status="alive", stage="seedling", generation="F1",
|
|||
|
|
planted_date="2023-03-10", created_id=1)
|
|||
|
|
db.add(t)
|
|||
|
|
trees.append(t)
|
|||
|
|
await db.flush()
|
|||
|
|
tokens["tree"] = [t.id for t in trees]
|
|||
|
|
FIX["trees"] = trees
|
|||
|
|
|
|||
|
|
# 基因型剂量矩阵(确保每列多态:全同列翻一个样本)
|
|||
|
|
dos = rng.binomial(2, 0.5, size=(N, M)).astype(int)
|
|||
|
|
for j in range(M):
|
|||
|
|
if len(set(dos[:, j])) == 1:
|
|||
|
|
dos[0, j] = 2 - int(dos[0, j])
|
|||
|
|
# cX 表型挂钩标记 3 剂量(GBLUP/rrBLUP 有信号);cY 独立(P 近对角)
|
|||
|
|
obs_rows = []
|
|||
|
|
for i, t in enumerate(trees):
|
|||
|
|
fam = i // N_PER
|
|||
|
|
d3 = dos[i, 3]
|
|||
|
|
obs_rows.append(TraitObservationModel(
|
|||
|
|
tree_id=t.id, trait_id=cX.id, value_numeric=float(100.0 + 4.0 * (d3 - 1) + rng.normal(0, 1.2)),
|
|||
|
|
evaluate_year=2025, created_id=1))
|
|||
|
|
obs_rows.append(TraitObservationModel(
|
|||
|
|
tree_id=t.id, trait_id=cY.id, value_numeric=float(30.0 + rng.normal(0, 0.8)),
|
|||
|
|
evaluate_year=2025, created_id=1))
|
|||
|
|
dev = rng.normal(0, 0.4)
|
|||
|
|
g = 6.0 if fam == 0 else 14.0
|
|||
|
|
for stage in ("juvenile", "evaluation"):
|
|||
|
|
obs_rows.append(TraitObservationModel(
|
|||
|
|
tree_id=t.id, trait_id=cS.id, value_numeric=float(10.0 + g + dev + rng.normal(0, 0.6)),
|
|||
|
|
stage=stage, evaluate_year=2025, created_id=1))
|
|||
|
|
db.add_all(obs_rows)
|
|||
|
|
await db.flush()
|
|||
|
|
tokens["obs"] = [o.id for o in obs_rows]
|
|||
|
|
|
|||
|
|
# ---- 基因型数据集:全株分型 ----
|
|||
|
|
ds = GenotypingDatasetModel(dataset_name=f"GS_{PREFIX}", platform="SNP", purpose="GS", created_id=1)
|
|||
|
|
db.add(ds)
|
|||
|
|
await db.flush()
|
|||
|
|
tokens["dataset"].append(ds.id)
|
|||
|
|
FIX["dataset_id"] = ds.id
|
|||
|
|
markers: list[MarkerModel] = []
|
|||
|
|
for j in range(M):
|
|||
|
|
markers.append(MarkerModel(marker_name=f"MK{j}_{PREFIX}", marker_type="SNP",
|
|||
|
|
chromosome=str(j % 5), position=j * 100, created_id=1))
|
|||
|
|
db.add_all(markers)
|
|||
|
|
await db.flush()
|
|||
|
|
tokens["marker"] = [m.id for m in markers]
|
|||
|
|
|
|||
|
|
samples, calls = [], []
|
|||
|
|
for i, t in enumerate(trees):
|
|||
|
|
s = GenotypeSampleModel(sample_name=f"{PREFIX}-S{i:02d}", dataset_id=ds.id,
|
|||
|
|
source_type="tree", source_id=t.id, created_id=1)
|
|||
|
|
db.add(s)
|
|||
|
|
samples.append(s)
|
|||
|
|
await db.flush()
|
|||
|
|
tokens["sample"] = [s.id for s in samples]
|
|||
|
|
for i, s in enumerate(samples):
|
|||
|
|
for j, m in enumerate(markers):
|
|||
|
|
calls.append(GenotypeCallModel(sample_id=s.id, marker_id=m.id,
|
|||
|
|
allele=GT[int(dos[i, j])], created_id=1))
|
|||
|
|
db.add_all(calls)
|
|||
|
|
await db.flush()
|
|||
|
|
tokens["call"] = [c.id for c in calls]
|
|||
|
|
|
|||
|
|
# ---- ABLUP 批次(手插,供选择指数):reliability=0 → Calo 分母 0 → r_g=0 → G 对角 ----
|
|||
|
|
ev = blup.ENGINE_VERSION
|
|||
|
|
for tc, trait, scale in (("X", cX, 4.0), ("Y", cY, 2.0)):
|
|||
|
|
pred = PredictionModel(model_name=f"AB_{PREFIX}_{tc}", trait_id=trait.id,
|
|||
|
|
method="ABLUP", heritability=0.5, engine_version=ev,
|
|||
|
|
input_hash="a" * 64, is_active=True, created_id=1)
|
|||
|
|
db.add(pred)
|
|||
|
|
await db.flush()
|
|||
|
|
pred_ids.append(pred.id)
|
|||
|
|
vals = []
|
|||
|
|
for r_i, t in enumerate(trees):
|
|||
|
|
vals.append(PredictionValueModel(
|
|||
|
|
prediction_id=pred.id, tree_id=t.id, trait_id=trait.id,
|
|||
|
|
predicted_value=float(rng.normal(0, scale)), reliability=0.0,
|
|||
|
|
pa=0.0, rank=r_i + 1, created_id=1))
|
|||
|
|
db.add_all(vals)
|
|||
|
|
await db.commit()
|
|||
|
|
finally:
|
|||
|
|
await engine.dispose()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _values_by_tree(client, pred_id: int) -> dict[int, float]:
|
|||
|
|
r = client.get(f"/api/v1/bre/statistics/predictions/{pred_id}/values", headers=auth())
|
|||
|
|
b = r.json()
|
|||
|
|
assert r.status_code == 200 and b.get("code") == 0, f"values 失败 {r.status_code} {str(r.text)[:120]}"
|
|||
|
|
return {int(v["tree_id"]): float(v["predicted_value"]) for v in (b.get("data") or [])
|
|||
|
|
if v.get("tree_id") is not None}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _pearson(x: dict, y: dict) -> float:
|
|||
|
|
ks = sorted(set(x) & set(y))
|
|||
|
|
a = np.array([x[k] for k in ks])
|
|||
|
|
c = np.array([y[k] for k in ks])
|
|||
|
|
return float(np.corrcoef(a, c)[0, 1])
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _verify_db() -> None:
|
|||
|
|
"""DB 层 golden 断言(镜像 ssgblup tc 惯例):HTTP 已落库,此处核对 method/engine_version/折数。"""
|
|||
|
|
engine, sf = create_async_engine_and_session()
|
|||
|
|
try:
|
|||
|
|
async with sf() as db:
|
|||
|
|
for pid in GS_PREDS.get("rr", []):
|
|||
|
|
p = await db.get(PredictionModel, pid)
|
|||
|
|
check("[DB] rrblup 落库 method=RRBLUP", p is not None and p.method == "RRBLUP",
|
|||
|
|
f"method={p.method if p else None}")
|
|||
|
|
check("[DB] rrblup engine_version=genomic.ENGINE_VERSION",
|
|||
|
|
p is not None and p.engine_version == genomic.ENGINE_VERSION,
|
|||
|
|
f"ev={p.engine_version if p else None}")
|
|||
|
|
nv = len(list((await db.execute(select(PredictionValueModel).where(
|
|||
|
|
PredictionValueModel.prediction_id == pid))).scalars()))
|
|||
|
|
check("[DB] rrblup EBV 条数 = 基因型树数", nv == N, f"n={nv}")
|
|||
|
|
for pid in GS_PREDS.get("bb", []):
|
|||
|
|
p = await db.get(PredictionModel, pid)
|
|||
|
|
check("[DB] bayesb 落库 method=BayesB", p is not None and p.method == "BayesB",
|
|||
|
|
f"method={p.method if p else None}")
|
|||
|
|
for pid in GS_PREDS.get("gb", []):
|
|||
|
|
p = await db.get(PredictionModel, pid)
|
|||
|
|
check("[DB] gblup 落库 method=GBLUP", p is not None and p.method == "GBLUP",
|
|||
|
|
f"method={p.method if p else None}")
|
|||
|
|
if cv_ids:
|
|||
|
|
rows = list((await db.execute(select(CvResultModel).where(
|
|||
|
|
CvResultModel.id.in_(cv_ids)).order_by(CvResultModel.id))).scalars())
|
|||
|
|
expect = ["KFCV/RRBLUP", "KFCV/BayesB"]
|
|||
|
|
for c, exp in zip(rows, expect):
|
|||
|
|
check(f"[DB] CV 落库 method={exp}", c.method == exp, f"method={c.method}")
|
|||
|
|
nf = len(list((await db.execute(select(CvFoldModel).where(
|
|||
|
|
CvFoldModel.cv_result_id == c.id))).scalars()))
|
|||
|
|
check(f"[DB] {exp} 折数=5", nf == 5, f"n_folds={nf}")
|
|||
|
|
check(f"[DB] {exp} mean_pearson∈[−1,1]",
|
|||
|
|
c.mean_pearson is not None and -1.0 <= float(c.mean_pearson) <= 1.0,
|
|||
|
|
f"mp={c.mean_pearson}")
|
|||
|
|
finally:
|
|||
|
|
await engine.dispose()
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _cleanup() -> None:
|
|||
|
|
engine, sf = create_async_engine_and_session()
|
|||
|
|
try:
|
|||
|
|
async with sf() as db:
|
|||
|
|
if pred_ids or cv_ids:
|
|||
|
|
await db.execute(delete(StatisticsJobModel).where(
|
|||
|
|
StatisticsJobModel.result_ref.in_(pred_ids + cv_ids)))
|
|||
|
|
if idx_ids:
|
|||
|
|
await db.execute(delete(SelectionIndexModel).where(SelectionIndexModel.id.in_(idx_ids)))
|
|||
|
|
if cv_ids:
|
|||
|
|
await db.execute(delete(CvFoldModel).where(CvFoldModel.cv_result_id.in_(cv_ids)))
|
|||
|
|
await db.execute(delete(CvResultModel).where(CvResultModel.id.in_(cv_ids)))
|
|||
|
|
if pred_ids:
|
|||
|
|
await db.execute(delete(PredictionValueModel).where(
|
|||
|
|
PredictionValueModel.prediction_id.in_(pred_ids)))
|
|||
|
|
await db.execute(delete(PredictionModel).where(PredictionModel.id.in_(pred_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["dataset"]:
|
|||
|
|
await db.execute(delete(GenotypingDatasetModel).where(GenotypingDatasetModel.id.in_(tokens["dataset"])))
|
|||
|
|
if tokens["marker"]:
|
|||
|
|
await db.execute(delete(MarkerModel).where(MarkerModel.id.in_(tokens["marker"])))
|
|||
|
|
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] o234 tc 数据已清(pred={len(pred_ids)} cv={len(cv_ids)} idx={len(idx_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()
|
|||
|
|
cX, cY, cS = FIX["cX"], FIX["cY"], FIX["cS"]
|
|||
|
|
dsid = FIX["dataset_id"]
|
|||
|
|
|
|||
|
|
# ── O2 type_b env_dim=stage(幼-成遗传相关)──
|
|||
|
|
r = client.post("/api/v1/bre/statistics/type-b-heredity",
|
|||
|
|
json={"trait_id": cS.id, "trait_code": cS.trait_code,
|
|||
|
|
"env_dim": "stage", "method": "reml", "year": None},
|
|||
|
|
headers=H)
|
|||
|
|
check("[O2] type-b env_dim=stage HTTP 200", r.status_code == 200, f"{r.status_code}")
|
|||
|
|
tb = r.json()
|
|||
|
|
rid = tb.get("data") if tb.get("code") == 0 else None
|
|||
|
|
check("[O2] 返回 id", isinstance(rid, int), f"rid={rid}")
|
|||
|
|
det = None
|
|||
|
|
if isinstance(rid, int):
|
|||
|
|
g = client.get(f"/api/v1/bre/statistics/type-b/{rid}", headers=H)
|
|||
|
|
det = g.json().get("data") if g.status_code == 200 and g.json().get("code") == 0 else None
|
|||
|
|
check("[O2] env_dim=stage 落库", det is not None and det.get("env_dim") == "stage")
|
|||
|
|
if det:
|
|||
|
|
envs = det.get("envs_json") or {}
|
|||
|
|
check("[O2] 双阶段各 12 株", sorted(envs) == ["evaluation", "juvenile"]
|
|||
|
|
and all(v == N for v in envs.values()), f"envs_json={envs}")
|
|||
|
|
mg = det.get("matrix_json") or {}
|
|||
|
|
rg = mg.get("evaluation", {}).get("juvenile")
|
|||
|
|
check(f"[O2] 幼年–成年遗传相关 r_g={rg} > 0.5", rg is not None and float(rg) > 0.5)
|
|||
|
|
check("[O2] n_common=12(同树双阶段)", det.get("n_common") == N, f"n_common={det.get('n_common')}")
|
|||
|
|
check("[O2] data_version=v2.30", det.get("data_version") == "v2.30",
|
|||
|
|
f"{det.get('data_version')}")
|
|||
|
|
|
|||
|
|
# ── O3 run_gblup:rrblup / bayesb(可复现)/ gblup(对偶)──
|
|||
|
|
pid_rr = None
|
|||
|
|
r = client.post("/api/v1/bre/statistics/gblup/run",
|
|||
|
|
json={"dataset_id": dsid, "trait_id": cX.id, "trait_code": cX.trait_code,
|
|||
|
|
"method": "rrblup", "seed": SEED}, headers=H)
|
|||
|
|
b = r.json()
|
|||
|
|
check("[O3] gblup/run rrblup HTTP 200", r.status_code == 200, f"{r.status_code}")
|
|||
|
|
pid_rr = b.get("data") if b.get("code") == 0 else None
|
|||
|
|
if isinstance(pid_rr, int):
|
|||
|
|
pred_ids.append(pid_rr)
|
|||
|
|
GS_PREDS["rr"].append(pid_rr)
|
|||
|
|
v_rr = _values_by_tree(client, pid_rr)
|
|||
|
|
check("[O3] rrblup EBV 覆盖全部基因型树", len(v_rr) == N, f"n={len(v_rr)}")
|
|||
|
|
|
|||
|
|
pid_bb = []
|
|||
|
|
for _ in range(2):
|
|||
|
|
r = client.post("/api/v1/bre/statistics/gblup/run",
|
|||
|
|
json={"dataset_id": dsid, "trait_id": cX.id, "trait_code": cX.trait_code,
|
|||
|
|
"method": "bayesb", "seed": SEED}, headers=H)
|
|||
|
|
b = r.json()
|
|||
|
|
pid = b.get("data") if b.get("code") == 0 else None
|
|||
|
|
check("[O3] gblup/run bayesb HTTP 200", r.status_code == 200, f"{r.status_code}")
|
|||
|
|
if isinstance(pid, int):
|
|||
|
|
pred_ids.append(pid)
|
|||
|
|
GS_PREDS["bb"].append(pid)
|
|||
|
|
pid_bb.append(pid)
|
|||
|
|
if len(pid_bb) == 2:
|
|||
|
|
v1 = _values_by_tree(client, pid_bb[0])
|
|||
|
|
v2 = _values_by_tree(client, pid_bb[1])
|
|||
|
|
check("[O3] bayesb 同 seed 两次 EBV 逐位一致(可复现)", v1 == v2)
|
|||
|
|
|
|||
|
|
r = client.post("/api/v1/bre/statistics/gblup/run",
|
|||
|
|
json={"dataset_id": dsid, "trait_id": cX.id, "trait_code": cX.trait_code,
|
|||
|
|
"method": "gblup", "seed": SEED}, headers=H)
|
|||
|
|
b = r.json()
|
|||
|
|
pid_gb = b.get("data") if b.get("code") == 0 else None
|
|||
|
|
check("[O3] gblup/run gblup HTTP 200", r.status_code == 200, f"{r.status_code}")
|
|||
|
|
if isinstance(pid_gb, int):
|
|||
|
|
pred_ids.append(pid_gb)
|
|||
|
|
GS_PREDS["gb"].append(pid_gb)
|
|||
|
|
v_gb = _values_by_tree(client, pid_gb)
|
|||
|
|
if pid_rr is not None:
|
|||
|
|
corr = _pearson(v_rr, v_gb)
|
|||
|
|
check(f"[O3] rrblup/GBLUP EBV 相关 {corr:.3f} > 0.9(对偶)", corr > 0.9)
|
|||
|
|
|
|||
|
|
# method 校验:非法方法 → 409
|
|||
|
|
r = client.post("/api/v1/bre/statistics/gblup/run",
|
|||
|
|
json={"dataset_id": dsid, "trait_id": cX.id, "trait_code": cX.trait_code,
|
|||
|
|
"method": "gbm", "seed": SEED}, headers=H)
|
|||
|
|
check("[O3] method 非法 → 409", r.status_code == 409, f"{r.status_code}")
|
|||
|
|
|
|||
|
|
# ── O3 run_cv:rrblup / bayesb GS 分派 ──
|
|||
|
|
for meth, label in (("rrblup", "KFCV/RRBLUP"), ("bayesb", "KFCV/BayesB")):
|
|||
|
|
r = client.post("/api/v1/bre/statistics/cv/run",
|
|||
|
|
json={"trait_id": cX.id, "trait_code": cX.trait_code,
|
|||
|
|
"dataset_id": dsid, "method": meth, "k": 5,
|
|||
|
|
"split": "random", "seed": SEED}, headers=H)
|
|||
|
|
b = r.json()
|
|||
|
|
cid = b.get("data") if b.get("code") == 0 else None
|
|||
|
|
check(f"[O3] cv/run {meth} HTTP 200", r.status_code == 200, f"{r.status_code}")
|
|||
|
|
if isinstance(cid, int):
|
|||
|
|
cv_ids.append(cid)
|
|||
|
|
g = client.get(f"/api/v1/bre/statistics/cv/{cid}", headers=H)
|
|||
|
|
gb2 = g.json().get("data") if g.status_code == 200 and g.json().get("code") == 0 else None
|
|||
|
|
res = (gb2 or {}).get("result") or {}
|
|||
|
|
check(f"[O3] {label} 落库 + mean_pearson∈[−1,1]",
|
|||
|
|
res.get("method") == label and res.get("mean_pearson") is not None
|
|||
|
|
and -1.0 <= float(res["mean_pearson"]) <= 1.0,
|
|||
|
|
f"method={res.get('method')} pearson={res.get('mean_pearson')}")
|
|||
|
|
|
|||
|
|
# ── O4 selection_index:restricted + economic ──
|
|||
|
|
bidX = pred_ids[0] # ABLUP_X(手插)
|
|||
|
|
bidY = pred_ids[1] # ABLUP_Y(手插)
|
|||
|
|
r = client.post("/api/v1/bre/statistics/selection-index",
|
|||
|
|
json={"method": "restricted", "aggregate": "tree", "g_method": "calo",
|
|||
|
|
"weights": {cX.trait_code: 1.0, cY.trait_code: 1.0},
|
|||
|
|
"batch_ids": {cX.trait_code: bidX, cY.trait_code: bidY},
|
|||
|
|
"restricted_traits": [cX.trait_code],
|
|||
|
|
"economic_weights": {cX.trait_code: 1.0, cY.trait_code: 1.0},
|
|||
|
|
"top_n": 50}, headers=H)
|
|||
|
|
b = r.json()
|
|||
|
|
check("[O4] selection-index restricted HTTP 200", r.status_code == 200, f"{r.status_code}")
|
|||
|
|
d = b.get("data") if b.get("code") == 0 else None
|
|||
|
|
if isinstance(d, dict) and d.get("id"):
|
|||
|
|
idx_ids.append(d["id"])
|
|||
|
|
check("[O4] 标记 SI_RES + restricted + ΔG=0 + economic",
|
|||
|
|
d.get("method") == "SI_RES" and d.get("restricted") == [cX.trait_code]
|
|||
|
|
and d.get("constraint_mode") == "ΔG=0" and d.get("economic") is True)
|
|||
|
|
bw = d.get("b") or {}
|
|||
|
|
bx, by = bw.get(cX.trait_code), bw.get(cY.trait_code)
|
|||
|
|
# r_g=0(reliability=0 → Calo 分母 0)→ G 精确对角 → ΔG_受限=0 ⇔ b_受限=0
|
|||
|
|
check("[O4] 受限性状 b≈0(ΔG=0 投影生效)", bx is not None and abs(float(bx)) < 1e-4,
|
|||
|
|
f"b_cX={bx}")
|
|||
|
|
check("[O4] 非受限性状自由响应", by is not None and abs(float(by)) > 0.05,
|
|||
|
|
f"b_cY={by}")
|
|||
|
|
check("[O4] 排名 top 落库", isinstance(d.get("top"), list) and len(d.get("top")) > 0,
|
|||
|
|
f"n_top={len(d.get('top') or [])}")
|
|||
|
|
|
|||
|
|
# economic 方向翻转:cY asc(越小越优)→ 经济权重为正时 b<0
|
|||
|
|
r = client.post("/api/v1/bre/statistics/selection-index",
|
|||
|
|
json={"method": "smith_hazel", "aggregate": "tree", "g_method": "calo",
|
|||
|
|
"weights": {cX.trait_code: 1.0, cY.trait_code: 1.0},
|
|||
|
|
"batch_ids": {cX.trait_code: bidX, cY.trait_code: bidY},
|
|||
|
|
"economic_weights": {cX.trait_code: 2.0, cY.trait_code: 1.0},
|
|||
|
|
"top_n": 50}, headers=H)
|
|||
|
|
b = r.json()
|
|||
|
|
check("[O4] selection-index smith_hazel+economic HTTP 200", r.status_code == 200, f"{r.status_code}")
|
|||
|
|
d = b.get("data") if b.get("code") == 0 else None
|
|||
|
|
if isinstance(d, dict) and d.get("id"):
|
|||
|
|
idx_ids.append(d["id"])
|
|||
|
|
check("[O4] 标记 SI_SH + economic", d.get("method") == "SI_SH" and d.get("economic") is True)
|
|||
|
|
bw = d.get("b") or {}
|
|||
|
|
bx, by = bw.get(cX.trait_code), bw.get(cY.trait_code)
|
|||
|
|
check("[O4] economic 方向翻转:desc 性状 b>0", bx is not None and float(bx) > 0, f"b_cX={bx}")
|
|||
|
|
check("[O4] asc 性状 direction 翻转 b<0", by is not None and float(by) < 0, f"b_cY={by}")
|
|||
|
|
|
|||
|
|
# 门禁:restricted 缺 restricted_traits → 409
|
|||
|
|
r = client.post("/api/v1/bre/statistics/selection-index",
|
|||
|
|
json={"method": "restricted", "aggregate": "tree", "g_method": "calo",
|
|||
|
|
"weights": {cX.trait_code: 1.0, cY.trait_code: 1.0},
|
|||
|
|
"batch_ids": {cX.trait_code: bidX, cY.trait_code: bidY},
|
|||
|
|
"top_n": 50}, headers=H)
|
|||
|
|
check("[O4] restricted 缺 restricted_traits → 409", r.status_code == 409, f"{r.status_code}")
|
|||
|
|
finally:
|
|||
|
|
asyncio.run(_verify_db())
|
|||
|
|
asyncio.run(_cleanup())
|
|||
|
|
|
|||
|
|
print(f"\n===== o234 tc 套件:ok={ok} fail={fail} =====")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main_()
|
|||
|
|
sys.exit(1 if fail else 0)
|