Files

572 lines
31 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
"""O2O4 统计方法学广度正式 tc 套件(2026-08-05_DATA_VERSION v2.30TestClient 真实 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 完整 CVmean_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_hazeleconomic
→ 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_gbluprrblup / 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_cvrrblup / 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_indexrestricted + 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=0reliability=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)