Files
dpb/backend/scripts/e2e_eval.py
T

308 lines
13 KiB
Python
Raw Normal View History

"""桃育种后端「单株评价 → 性状观测 → 统计分析」部署自测脚本(仅标准库 urllib)。
用途:一键验证 breeding 模块评价与统计全链路是否打通,可部署后回归自测。
链路:登录 → 建基础数据(基地/试验地/育种目标/杂交组合/单株)
→ 建评价(tree_evaluation) → 批量建观测(trait_observation)
→ 跑统计(describe / correlation / traits / selection-index)
→ 断言统计结果确实来自 trait_observation(均值精确回读)
特点:
· 登录自适应验证码(dev 开启时自动 captcha/get → slider/complete → 带 key 登录)。
· 双单元:①精确校验单元(单组合单株, 固定值, 断言 describe 均值精确回读)
②批量链路单元(多组合多单株, 仅验证 HTTP 200 与计数)。
· 默认跑完自动清理,库不留脏数据(CLEANUP=True)。
· 任意非 200 或断言失败立即退出码 1;全过退出码 0。
用法:
cd d:\\dpb\\dpb\\backend
$env:ENVIRONMENT='dev'
C:\\ai\\miniconda3\\envs\\dpb\\python.exe scripts/e2e_eval.py
"""
import datetime
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
BASE = "http://localhost:5667"
RUN = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
CLEANUP = True # 部署自测默认清理,不留脏数据
N_COMBOS = 3 # 批量单元:组合数
N_TREES = 4 # 批量单元:每组合单株数
CORE = ["max_fruit_weight", "avg_fruit_weight", "longitudinal_dia",
"transverse_dia", "lateral_dia", "flesh_thickness", "ssc"]
HEADERS = {"Content-Type": "application/json"}
_COLLECT = {"obs": [], "eval": [], "tree": [], "combo": [], "plot": [], "site": [], "target": []}
TOKEN = None
def track(kind, obj):
if isinstance(obj, list):
_COLLECT[kind].extend(obj)
else:
_COLLECT[kind].append(obj)
return obj
def _req(method, url, *, data=None, headers=None, form=False):
h = dict(HEADERS)
if headers:
h.update(headers)
body = None
if data is not None:
if form:
body = urllib.parse.urlencode(data).encode()
h["Content-Type"] = "application/x-www-form-urlencoded"
else:
body = json.dumps(data).encode()
req = urllib.request.Request(url, data=body, method=method, headers=h)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read().decode())
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode())
except Exception: # noqa: BLE001
return e.code, {"raw": e.read().decode()[:400]}
def step(name, status, body, *, expect=200):
if status != expect:
print(f"[FAIL] {name} -> HTTP {status}")
print(" BODY:", str(body)[:600])
sys.exit(1)
print(f"[OK ] {name} -> HTTP {status}")
return body
def assert_(cond, msg):
if not cond:
print(f"[FAIL] ASSERT: {msg}")
sys.exit(1)
print(f"[OK ] ASSERT: {msg}")
def login():
data = {"username": "super", "password": "123456", "grant_type": "password", "login_type": "PC端"}
st, b = _req("POST", f"{BASE}/api/v1/system/auth/login", data=data, form=True)
if st == 200 and b.get("code") == 0:
return b["data"]["access_token"]
# dev 开启验证码:拿 key -> 标记 verified -> 带 key 登录
key = _req("GET", f"{BASE}/api/v1/system/auth/captcha/get")[1]["data"]["key"]
_req("POST", f"{BASE}/api/v1/system/auth/captcha/slider/complete", data={"captcha_key": key})
data["captcha_key"] = key
st, b = _req("POST", f"{BASE}/api/v1/system/auth/login", data=data, form=True)
if st != 200 or b.get("code") != 0:
print("LOGIN FAILED", st, b)
sys.exit(1)
return b["data"]["access_token"]
def create(domain, payload):
st, b = _req("POST", f"{BASE}/api/v1/bre/{domain}/create", data=payload,
headers={"Authorization": f"Bearer {TOKEN}"})
return step(f"POST /bre/{domain}/create", st, b)["data"]
def gget(domain, params=None):
url = f"{BASE}/api/v1/bre/{domain}/list"
if params:
url += "?" + urllib.parse.urlencode(params)
st, b = _req("GET", url, headers={"Authorization": f"Bearer {TOKEN}"})
return step(f"GET /bre/{domain}/list", st, b)
def _lq(name, items):
"""构造 FastAPI list[str] 查询串:trait_codes=a&trait_codes=b(逗号串会被当单值)。"""
return "&".join(f"{name}={urllib.parse.quote(str(i))}" for i in items)
def gen_value(code, ci, ti):
"""批量单元确定性造数(落在合理量程,仅用于链路验证,非真实育种值)。"""
bases = {"max_fruit_weight": 80, "avg_fruit_weight": 60, "longitudinal_dia": 50,
"transverse_dia": 55, "lateral_dia": 52, "flesh_thickness": 8, "ssc": 8}
off = (ci * 7 + ti * 1.7 + CORE.index(code) * 0.9) % 30
return round(bases[code] + off, 1)
def precise_unit(core_traits, gid, pid):
"""精确均值校验:单组合单株 + 7 固定值观测,断言 describe 回读精确均值。"""
print("\n--- 单元① 精确均值校验 (单组合/单株, 固定值) ---")
site = create("site", {"site_name": f"E2E基地{RUN}-P", "remark": "自测自动创建"}, )
track("site", site["id"])
plot = create("plot", {"site_id": site["id"], "plot_code": f"P{RUN}-P"})
track("plot", plot["id"])
target = create("target", {"target_name": f"E2E目标{RUN}-P"})
track("target", target["id"])
combo = create("cross_combination", {
"combination_code": f"E2E-CC-{RUN}-P",
"bre_target_id": target["id"], "female_parent_id": gid,
"male_parent_id": gid, "cross_method": "人工杂交", "cross_year": 2026,
})
track("combo", combo["id"])
tree = create("tree", {
"combination_id": combo["id"], "plot_id": plot["id"], "tree_no": f"T{RUN}-P",
"row_no": 1, "col_no": 1, "planted_date": "2026-03-15", "status": "1",
"bre_personnel_id": pid,
})
track("tree", tree["id"])
ev = create("tree_evaluation", {
"combination_id": combo["id"], "tree_id": tree["id"], "evaluate_date": "2026-07-29",
"evaluate_year": 2026, "bre_personnel_id": pid, "remark": f"E2E自测{RUN}-P",
})
track("eval", ev["id"])
values = {"max_fruit_weight": 120.5, "avg_fruit_weight": 98.2, "longitudinal_dia": 65.1,
"transverse_dia": 70.3, "lateral_dia": 68.0, "flesh_thickness": 12.4, "ssc": 12.8}
for t in core_traits:
code = t["trait_code"]
ob = create("trait_observation", {
"evaluation_id": ev["id"], "tree_id": tree["id"], "combination_id": combo["id"],
"trait_id": t["id"], "category": t.get("category"), "trait_code": code,
"trait_name": t.get("trait_name"), "data_type": t.get("data_type"),
"evaluate_year": 2026, "value_numeric": values.get(code),
})
track("obs", ob["id"])
# describe?group_by=combination:该组合 stats 的 mean 应精确等于插入值
qs = _lq("trait_codes", CORE)
st, b = _req("GET", f"{BASE}/api/v1/bre/statistics/describe?{qs}&group_by=combination",
headers={"Authorization": f"Bearer {TOKEN}"})
de = step("GET /bre/statistics/describe(group_by=combination)", st, b)
groups = de["data"].get("groups", [])
grp = next((g for g in groups if g["group"] == combo["id"]), None)
assert_(grp is not None, f"describe 回读包含本组合 {combo['id']}")
stat_map = {s["trait"]: s for s in grp["stats"]}
for code, val in values.items():
assert_(code in stat_map, f"describe 含性状 {code}")
assert_(abs(stat_map[code]["mean"] - val) < 1e-6, f"{code} 均值精确回读={val} (got {stat_map[code]['mean']})")
assert_(stat_map[code]["n"] == 1, f"{code} 覆盖单株数 n==1")
assert_(de["data"]["total"] == 1, "describe total==1 (单株)")
return 1 # 贡献的单株数
def batch_unit(n_combos, n_trees, core_traits, gid, pid):
"""批量链路单元:多组合多单株,仅验证接口不报错且计数正确。"""
print(f"\n--- 单元② 批量链路 (组合×单株 = {n_combos}×{n_trees}) ---")
site = create("site", {"site_name": f"E2E基地{RUN}-B", "remark": "自测自动创建"})
track("site", site["id"])
plot = create("plot", {"site_id": site["id"], "plot_code": f"P{RUN}-B"})
track("plot", plot["id"])
target = create("target", {"target_name": f"E2E目标{RUN}-B"})
track("target", target["id"])
total_trees = 0
for ci in range(n_combos):
combo = create("cross_combination", {
"combination_code": f"E2E-CC-{RUN}-B{ci}",
"bre_target_id": target["id"], "female_parent_id": gid,
"male_parent_id": gid, "cross_method": "人工杂交", "cross_year": 2026,
})
track("combo", combo["id"])
for ti in range(n_trees):
tree = create("tree", {
"combination_id": combo["id"], "plot_id": plot["id"],
"tree_no": f"T{RUN}-B{ci}-{ti}", "row_no": ti + 1, "col_no": 1,
"planted_date": "2026-03-15", "status": "1", "bre_personnel_id": pid,
})
track("tree", tree["id"])
ev = create("tree_evaluation", {
"combination_id": combo["id"], "tree_id": tree["id"], "evaluate_date": "2026-07-29",
"evaluate_year": 2026, "bre_personnel_id": pid, "remark": f"E2E自测{RUN}-B{ci}-{ti}",
})
track("eval", ev["id"])
for t in core_traits:
code = t["trait_code"]
ob = create("trait_observation", {
"evaluation_id": ev["id"], "tree_id": tree["id"], "combination_id": combo["id"],
"trait_id": t["id"], "category": t.get("category"), "trait_code": code,
"trait_name": t.get("trait_name"), "data_type": t.get("data_type"),
"evaluate_year": 2026, "value_numeric": gen_value(code, ci, ti),
})
track("obs", ob["id"])
total_trees += 1
return total_trees
def stat_checks(precise_trees, batch_trees):
print("\n--- 统计接口断言 ---")
# traits 核心性状数
st, b = _req("GET", f"{BASE}/api/v1/bre/statistics/traits?core_only=1",
headers={"Authorization": f"Bearer {TOKEN}"})
tr = step("GET /bre/statistics/traits?core_only=1", st, b)
assert_(len(tr["data"]) == 7, f"核心性状数==7 (got {len(tr['data'])})")
# describe 全局 total
qs = _lq("trait_codes", CORE)
st, b = _req("GET", f"{BASE}/api/v1/bre/statistics/describe?{qs}&group_by=combination",
headers={"Authorization": f"Bearer {TOKEN}"})
de = step("GET /bre/statistics/describe", st, b)
expected_total = precise_trees + batch_trees
assert_(de["data"]["total"] == expected_total,
f"describe total=={expected_total} (got {de['data']['total']})")
# correlation 矩阵维度与对角
cqs = _lq("trait_codes", ["avg_fruit_weight", "ssc"])
st, b = _req("GET", f"{BASE}/api/v1/bre/statistics/correlation?{cqs}",
headers={"Authorization": f"Bearer {TOKEN}"})
co = step("GET /bre/statistics/correlation", st, b)
assert_(co["data"]["n"] >= 1 and len(co["data"]["matrix"]) == 2, "correlation 矩阵 2x2")
assert_(co["data"]["matrix"][0][0] == 1.0 and co["data"]["matrix"][1][1] == 1.0, "correlation 对角线==1.0")
# selection-index 返回排名
st, b = _req("POST", f"{BASE}/api/v1/bre/statistics/selection-index",
data={"weights": {c: 1.0 for c in CORE}, "year": 2026, "top_n": 10},
headers={"Authorization": f"Bearer {TOKEN}"})
si = step("POST /bre/statistics/selection-index", st, b)
top = si["data"].get("top") if isinstance(si.get("data"), dict) else None
assert_(isinstance(top, list) and len(top) >= 1,
f"selection-index 返回非空排名 (n={len(top) if isinstance(top, list) else '?'})")
def cleanup():
if not CLEANUP:
print("\nCLEANUP=False,测试数据保留;再次运行自动用新 RUN 后缀避免冲突)")
return
print("\n清理测试数据...")
h = {"Authorization": f"Bearer {TOKEN}"}
order = [("trait_observation", "obs"), ("tree_evaluation", "eval"), ("tree", "tree"),
("cross_combination", "combo"), ("plot", "plot"), ("site", "site"), ("target", "target")]
for domain, key in order:
ids = _COLLECT[key]
if not ids:
continue
st, _ = _req("DELETE", f"{BASE}/api/v1/bre/{domain}/delete", data=ids, headers=h)
print(f" DELETE /bre/{domain}/delete {len(ids)} 条 -> HTTP {st}")
def main():
global TOKEN
TOKEN = login()
print(f"登录成功,token 前缀: {TOKEN[:12]}...\n")
tr = gget("trait", {"page": 1, "page_size": 100})
traits = tr["data"]["items"]
core_traits = [t for t in traits if t["trait_code"] in set(CORE) and t.get("data_type") == "numeric"]
assert_(len(core_traits) == 7, f"核心数值性状命中 7/7 (got {len(core_traits)})")
print(f"核心数值性状: {[t['trait_code'] for t in core_traits]}\n")
germ = gget("germplasm", {"page": 1, "page_size": 5})
gid = germ["data"]["items"][0]["id"]
pers = gget("personnel", {"page": 1, "page_size": 5})
pid = pers["data"]["items"][0]["id"]
try:
p = precise_unit(core_traits, gid, pid)
b = batch_unit(N_COMBOS, N_TREES, core_traits, gid, pid)
stat_checks(p, b)
print("\n=== 部署自测全部通过 ✅ ===")
finally:
cleanup()
if __name__ == "__main__":
main()