Files
34047007@qq.com b95053c52c init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
2026-08-06 00:17:49 +08:00

308 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""桃育种后端「单株评价 → 性状观测 → 统计分析」部署自测脚本(仅标准库 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()