checkpoint: real-scale 数据链 + 统计引擎 + ABLUP 稀疏性能修复

init 后首次落盘,累计工作:
- 数据侧:simulate_breeding_data real-scale 3.5万树重建 + 分层观测
- 引擎侧:ABLUP 稀疏生产档 EM-REML(blup 1.6.0/1.7.0);性能倒挂修复——
  系谱闭包收口为 BFS 可达祖先 + N_EXACT 3000 对齐 N_SUBSAMPLE(选中 200~3000 树
  不再顶进数小时精确迹尾),A/B 数值等价实证 + 守卫探针
- 前端:观测过滤 + 表单/HTTP 工具链完善
- 文档:业务链/观测梳理 + 引擎实施记录 + 规划对照总表
- gitignore:排除 Temp/调试脚本/一次性验证输出
This commit is contained in:
34047007@qq.com
2026-08-07 08:03:10 +08:00
parent b95053c52c
commit e937fc930c
16 changed files with 2293 additions and 238 deletions
+7
View File
@@ -89,6 +89,13 @@ dump.rdb
"-w" "-w"
"]" "]"
# 临时/调试脚本与一次性验证输出(不进版本库)
backend/scripts/_tmp_*.py
backend/scripts/_apply_trait_seed_sql.py
backend/scripts/breeding_stats/_debug_*.py
backend/scripts/breeding_stats/_sparse_golden_check.py
backend/scripts/breeding_stats/_golden_out.txt
# 本地二进制/运行环境(不提交) # 本地二进制/运行环境(不提交)
redis/ redis/
backend/.venv.broken/ backend/.venv.broken/
@@ -26,7 +26,8 @@ import numpy as np
from datetime import date, datetime from datetime import date, datetime
from typing import Any from typing import Any
from sqlalchemy import String, case, func, or_, select, text, update from sqlalchemy import Integer, String, case, cast, false, func, or_, select, text, update
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_schema import AuthSchema from app.core.base_schema import AuthSchema
@@ -84,6 +85,19 @@ def _now() -> datetime:
return datetime.now() return datetime.now()
def _id_in(column, ids):
"""按 IN (SELECT unnest(:arr)) 绑定大 id 列表为单个数组参数。
asyncpg 单条查询总绑定参数上限 32767常规 column.in_(ids) 会在
bre_tree 等表 id 规模过万时直接崩IN 子句分块无济于事因为是全查询
计数空列表返回恒假条件避免 SQLAlchemy 渲染出畸形的 IN (NULL)
"""
ids = list(ids)
if not ids:
return false()
return column.in_(select(func.unnest(cast(ids, ARRAY(Integer)))))
# 交配设计(配合力 GCA/SCA 的算法与 ANOVA 自由度随设计而异;与 combining.solve 分支一致) # 交配设计(配合力 GCA/SCA 的算法与 ANOVA 自由度随设计而异;与 combining.solve 分支一致)
_COMBINING_DESIGNS = combining.DESIGNS _COMBINING_DESIGNS = combining.DESIGNS
_DESIGN_LABELS = { _DESIGN_LABELS = {
@@ -438,6 +452,27 @@ class StatisticsService:
)).all() )).all()
return {int(r.tree_id) for r in rows} return {int(r.tree_id) for r in rows}
@staticmethod
def _germ_ancestor_closure(tree_germs: set[int],
g_parent_of: dict[int, tuple[int | None, int | None]]) -> set[int]:
"""种质级祖先闭包:从选中树直系亲本向上取可达祖先(bre_pedigree 只追祖先链)。
旧实现把全库 bre_pedigree 子代种质无条件并进模型选中 400 n1204无关种质
MME 中零贡献无表型与选中树无亲缘EBV/ 逐位不变 n=树数+~800 一越过
N_SPARSE 就误入稀疏精确迹二分~11min/收口到真实系谱规模后常见选几百棵
候选树落稠密秒级 _germplasm_pedigree 的多源 BFS 思路一致
"""
out = set(tree_germs)
stack = list(tree_germs)
while stack:
g = stack.pop()
gd, gs = g_parent_of.get(g, (None, None))
for pp in (gd, gs):
if pp is not None and pp not in out:
out.add(pp)
stack.append(pp)
return out
async def _build_pedigree(self, tree_ids: list[int]) -> list[dict]: async def _build_pedigree(self, tree_ids: list[int]) -> list[dict]:
"""为给定树集合构建系谱(树=子代、种质=亲本,t/g 前缀;含种质级祖先 bre_pedigree)。 """为给定树集合构建系谱(树=子代、种质=亲本,t/g 前缀;含种质级祖先 bre_pedigree)。
@@ -456,7 +491,7 @@ class StatisticsService:
CrossCombinationModel.male_parent_id, CrossCombinationModel.male_parent_id,
) )
.outerjoin(CrossCombinationModel, TreeModel.combination_id == CrossCombinationModel.id) .outerjoin(CrossCombinationModel, TreeModel.combination_id == CrossCombinationModel.id)
.where(TreeModel.id.in_(tree_ids), TreeModel.is_deleted.is_(False)) .where(_id_in(TreeModel.id, tree_ids), TreeModel.is_deleted.is_(False))
)).all() )).all()
pedigree: list[dict] = [] pedigree: list[dict] = []
tree_germs: set[int] = set() tree_germs: set[int] = set()
@@ -489,13 +524,7 @@ class StatisticsService:
cid = code2g.get(child_code) cid = code2g.get(child_code)
if cid is not None and (gd is not None or gs is not None): if cid is not None and (gd is not None or gs is not None):
g_parent_of[cid] = (gd, gs) g_parent_of[cid] = (gd, gs)
all_germ_ids = set(tree_germs) all_germ_ids = self._germ_ancestor_closure(tree_germs, g_parent_of)
for g, (gd, gs) in g_parent_of.items():
all_germ_ids.add(g)
if gd is not None:
all_germ_ids.add(gd)
if gs is not None:
all_germ_ids.add(gs)
for g in sorted(all_germ_ids): for g in sorted(all_germ_ids):
if g in g_parent_of: if g in g_parent_of:
gd, gs = g_parent_of[g] gd, gs = g_parent_of[g]
@@ -686,7 +715,7 @@ class StatisticsService:
TreeModel.germplasm_id, TreeModel.germplasm_id,
) )
.outerjoin(CrossCombinationModel, TreeModel.combination_id == CrossCombinationModel.id) .outerjoin(CrossCombinationModel, TreeModel.combination_id == CrossCombinationModel.id)
.where(TreeModel.id.in_(tree_ids)) .where(_id_in(TreeModel.id, tree_ids))
)).all() )).all()
pedigree: list[dict] = [] pedigree: list[dict] = []
tree_germs: set[int] = set() tree_germs: set[int] = set()
@@ -739,7 +768,7 @@ class StatisticsService:
pos_rows = (await self.db.execute( pos_rows = (await self.db.execute(
select(TreeModel.id, TreeModel.plot_id, TreeModel.block_no, select(TreeModel.id, TreeModel.plot_id, TreeModel.block_no,
TreeModel.row_no, TreeModel.col_no) TreeModel.row_no, TreeModel.col_no)
.where(TreeModel.id.in_(tree_ids), TreeModel.is_deleted.is_(False)) .where(_id_in(TreeModel.id, tree_ids), TreeModel.is_deleted.is_(False))
)).all() )).all()
grids: dict[tuple[int, int], list[tuple[int, int, int]]] = {} grids: dict[tuple[int, int], list[tuple[int, int, int]]] = {}
for r in pos_rows: for r in pos_rows:
@@ -776,13 +805,7 @@ class StatisticsService:
cid = code2g.get(child_code) cid = code2g.get(child_code)
if cid is not None and (gd is not None or gs is not None): if cid is not None and (gd is not None or gs is not None):
g_parent_of[cid] = (gd, gs) g_parent_of[cid] = (gd, gs)
all_germ_ids = set(tree_germs) all_germ_ids = self._germ_ancestor_closure(tree_germs, g_parent_of)
for g, (gd, gs) in g_parent_of.items():
all_germ_ids.add(g)
if gd is not None:
all_germ_ids.add(gd)
if gs is not None:
all_germ_ids.add(gs)
for g in sorted(all_germ_ids): for g in sorted(all_germ_ids):
if g in g_parent_of: if g in g_parent_of:
gd, gs = g_parent_of[g] gd, gs = g_parent_of[g]
@@ -846,7 +869,7 @@ class StatisticsService:
TreeModel.is_deleted.is_(False), TreeModel.is_deleted.is_(False),
# 与 site 分支对齐:只取本批次选中树,避免他树混入后 # 与 site 分支对齐:只取本批次选中树,避免他树混入后
# clone_of/combo_of 缺失 → 归入虚构基因型组 f{None} # clone_of/combo_of 缺失 → 归入虚构基因型组 f{None}
TreeModel.id.in_(tree_ids), _id_in(TreeModel.id, tree_ids),
) )
.group_by(yr_obs.tree_id, yr_obs.evaluate_year) .group_by(yr_obs.tree_id, yr_obs.evaluate_year)
) )
@@ -1093,7 +1116,7 @@ class StatisticsService:
phenos_keyed = {f"t{tid}": v for tid, v in phenotypes.items()} phenos_keyed = {f"t{tid}": v for tid, v in phenotypes.items()}
bpos_rows = (await self.db.execute( bpos_rows = (await self.db.execute(
select(TreeModel.id, TreeModel.block_no) select(TreeModel.id, TreeModel.block_no)
.where(TreeModel.id.in_(tree_ids), TreeModel.block_no.isnot(None), .where(_id_in(TreeModel.id, tree_ids), TreeModel.block_no.isnot(None),
TreeModel.is_deleted.is_(False)) TreeModel.is_deleted.is_(False))
)).all() )).all()
block_of: dict[str, int] = {} block_of: dict[str, int] = {}
@@ -1171,7 +1194,7 @@ class StatisticsService:
if spatial: if spatial:
pos_rows = (await self.db.execute( pos_rows = (await self.db.execute(
select(TreeModel.id, TreeModel.row_no, TreeModel.col_no) select(TreeModel.id, TreeModel.row_no, TreeModel.col_no)
.where(TreeModel.id.in_(tree_ids), TreeModel.is_deleted.is_(False)) .where(_id_in(TreeModel.id, tree_ids), TreeModel.is_deleted.is_(False))
)).all() )).all()
coords: dict[str, tuple[int, int]] = {} coords: dict[str, tuple[int, int]] = {}
missing_coord_ids: list[int] = [] missing_coord_ids: list[int] = []
@@ -1391,7 +1414,7 @@ class StatisticsService:
TreeModel.germplasm_id, TreeModel.germplasm_id,
) )
.outerjoin(CrossCombinationModel, TreeModel.combination_id == CrossCombinationModel.id) .outerjoin(CrossCombinationModel, TreeModel.combination_id == CrossCombinationModel.id)
.where(TreeModel.id.in_(tree_ids)) .where(_id_in(TreeModel.id, tree_ids))
)).all() )).all()
pedigree: list[dict] = [] pedigree: list[dict] = []
tree_germs: set[int] = set() tree_germs: set[int] = set()
@@ -1434,7 +1457,7 @@ class StatisticsService:
pos_rows = (await self.db.execute( pos_rows = (await self.db.execute(
select(TreeModel.id, TreeModel.plot_id, TreeModel.block_no, select(TreeModel.id, TreeModel.plot_id, TreeModel.block_no,
TreeModel.row_no, TreeModel.col_no) TreeModel.row_no, TreeModel.col_no)
.where(TreeModel.id.in_(tree_ids), TreeModel.is_deleted.is_(False)) .where(_id_in(TreeModel.id, tree_ids), TreeModel.is_deleted.is_(False))
)).all() )).all()
grids: dict[tuple[int, int], list[tuple[int, int, int]]] = {} grids: dict[tuple[int, int], list[tuple[int, int, int]]] = {}
for r in pos_rows: for r in pos_rows:
@@ -1470,13 +1493,7 @@ class StatisticsService:
cid = code2g.get(child_code) cid = code2g.get(child_code)
if cid is not None and (gd is not None or gs is not None): if cid is not None and (gd is not None or gs is not None):
g_parent_of[cid] = (gd, gs) g_parent_of[cid] = (gd, gs)
all_germ_ids = set(tree_germs) all_germ_ids = self._germ_ancestor_closure(tree_germs, g_parent_of)
for g, (gd, gs) in g_parent_of.items():
all_germ_ids.add(g)
if gd is not None:
all_germ_ids.add(gd)
if gs is not None:
all_germ_ids.add(gs)
for g in sorted(all_germ_ids): for g in sorted(all_germ_ids):
if g in g_parent_of: if g in g_parent_of:
gd, gs = g_parent_of[g] gd, gs = g_parent_of[g]
@@ -1766,7 +1783,7 @@ class StatisticsService:
rel = {r.tree_id: float(r.reliability or 0.0) for r in rows} rel = {r.tree_id: float(r.reliability or 0.0) for r in rows}
trees = (await self.db.execute( trees = (await self.db.execute(
select(TreeModel.id, TreeModel.trial_study_id, TreeModel.combination_id) select(TreeModel.id, TreeModel.trial_study_id, TreeModel.combination_id)
.where(TreeModel.id.in_(tree_ids), TreeModel.is_deleted.is_(False)) .where(_id_in(TreeModel.id, tree_ids), TreeModel.is_deleted.is_(False))
)).all() )).all()
site_of = {t.id: t.trial_study_id for t in trees} site_of = {t.id: t.trial_study_id for t in trees}
combo_of = {t.id: t.combination_id for t in trees} combo_of = {t.id: t.combination_id for t in trees}
@@ -2015,7 +2032,7 @@ class StatisticsService:
if tree_ids: if tree_ids:
rows = (await self.db.execute( rows = (await self.db.execute(
select(TreeModel.id, TreeModel.clone_id).where( select(TreeModel.id, TreeModel.clone_id).where(
TreeModel.id.in_(tree_ids), TreeModel.is_deleted.is_(False) _id_in(TreeModel.id, tree_ids), TreeModel.is_deleted.is_(False)
) )
)).all() )).all()
tree_map = {t.id: t.clone_id for t in rows} tree_map = {t.id: t.clone_id for t in rows}
@@ -2023,7 +2040,7 @@ class StatisticsService:
clone_map: dict[int, CloneModel] = {} clone_map: dict[int, CloneModel] = {}
if clone_ids: if clone_ids:
clones = (await self.db.execute( clones = (await self.db.execute(
select(CloneModel).where(CloneModel.id.in_(clone_ids), CloneModel.is_deleted.is_(False)) select(CloneModel).where(_id_in(CloneModel.id, clone_ids), CloneModel.is_deleted.is_(False))
)).scalars().all() )).scalars().all()
clone_map = {c.id: c for c in clones} clone_map = {c.id: c for c in clones}
agg: dict[int, dict[str, float | int]] = {} agg: dict[int, dict[str, float | int]] = {}
@@ -2533,7 +2550,7 @@ class StatisticsService:
pid2code = {pid: c for c, pid in batch_ids.items()} pid2code = {pid: c for c, pid in batch_ids.items()}
pv_rows = (await self.db.execute( pv_rows = (await self.db.execute(
select(PredictionValueModel.prediction_id, PredictionValueModel.tree_id, PredictionValueModel.reliability).where( select(PredictionValueModel.prediction_id, PredictionValueModel.tree_id, PredictionValueModel.reliability).where(
PredictionValueModel.prediction_id.in_(list(batch_ids.values())), _id_in(PredictionValueModel.prediction_id, list(batch_ids.values())),
PredictionValueModel.tree_id.isnot(None), PredictionValueModel.tree_id.isnot(None),
) )
)).all() )).all()
@@ -2677,7 +2694,7 @@ class StatisticsService:
""" """
rows = (await self.db.execute( rows = (await self.db.execute(
select(TreeModel.id, TreeModel.clone_id, TreeModel.tree_no).where( select(TreeModel.id, TreeModel.clone_id, TreeModel.tree_no).where(
TreeModel.id.in_(tree_ids), TreeModel.is_deleted.is_(False) _id_in(TreeModel.id, tree_ids), TreeModel.is_deleted.is_(False)
) )
)).all() )).all()
clone_of = {t.id: t.clone_id for t in rows} clone_of = {t.id: t.clone_id for t in rows}
@@ -2687,7 +2704,7 @@ class StatisticsService:
if clone_ids: if clone_ids:
clones = (await self.db.execute( clones = (await self.db.execute(
select(CloneModel.id, CloneModel.clone_code).where( select(CloneModel.id, CloneModel.clone_code).where(
CloneModel.id.in_(clone_ids), CloneModel.is_deleted.is_(False) _id_in(CloneModel.id, clone_ids), CloneModel.is_deleted.is_(False)
) )
)).all() )).all()
clone_label = {c.id: c.clone_code for c in clones} clone_label = {c.id: c.clone_code for c in clones}
@@ -3011,7 +3028,7 @@ class StatisticsService:
if refs: if refs:
pv_rows = (await self.db.execute( pv_rows = (await self.db.execute(
select(PredictionValueModel.tree_id, PredictionValueModel.reliability).where( select(PredictionValueModel.tree_id, PredictionValueModel.reliability).where(
PredictionValueModel.prediction_id.in_(list(refs.values())), _id_in(PredictionValueModel.prediction_id, list(refs.values())),
PredictionValueModel.tree_id.isnot(None), PredictionValueModel.tree_id.isnot(None),
) )
)).all() )).all()
@@ -3048,11 +3065,11 @@ class StatisticsService:
all_tids = {t for u in units for t in u["tree_ids"]} all_tids = {t for u in units for t in u["tree_ids"]}
tree_rows = (await self.db.execute( tree_rows = (await self.db.execute(
select(TreeModel.id, TreeModel.combination_id, TreeModel.tree_no, TreeModel.stage) select(TreeModel.id, TreeModel.combination_id, TreeModel.tree_no, TreeModel.stage)
.where(TreeModel.id.in_(all_tids)) .where(_id_in(TreeModel.id, all_tids))
)).all() )).all()
tree_map = {t.id: t for t in tree_rows} tree_map = {t.id: t for t in tree_rows}
existing = (await self.db.execute( existing = (await self.db.execute(
select(SelectionResultModel).where(SelectionResultModel.tree_id.in_(all_tids)) select(SelectionResultModel).where(_id_in(SelectionResultModel.tree_id, all_tids))
)).scalars().all() )).scalars().all()
existing_by_tree = {e.tree_id: e for e in existing} existing_by_tree = {e.tree_id: e for e in existing}
@@ -3144,7 +3161,7 @@ class StatisticsService:
""" """
stmt = select(TreeModel.id, TreeModel.dam_id, TreeModel.sire_id).where(TreeModel.is_deleted.is_(False)) stmt = select(TreeModel.id, TreeModel.dam_id, TreeModel.sire_id).where(TreeModel.is_deleted.is_(False))
if tree_ids: if tree_ids:
stmt = stmt.where(TreeModel.id.in_(tree_ids)) stmt = stmt.where(_id_in(TreeModel.id, tree_ids))
rows = (await self.db.execute(stmt)).all() rows = (await self.db.execute(stmt)).all()
if not rows: if not rows:
raise CustomException(msg="无活动单株") raise CustomException(msg="无活动单株")
@@ -3203,11 +3220,35 @@ class StatisticsService:
raise CustomException(msg=f"性状不可用于统计(需数值型且存在): {trait_code}") raise CustomException(msg=f"性状不可用于统计(需数值型且存在): {trait_code}")
direction = (trait_row.direction or "desc").lower() direction = (trait_row.direction or "desc").lower()
desirable_high = direction != "asc" # desc/None:表型越高越好;asc:越低越好(如病指) desirable_high = direction != "asc" # desc/None:表型越高越好;asc:越低越好(如病指)
# 1) 单株系谱 → 深层系谱 A 矩阵 → 近交系数 F # 0) 先取目标性状每树表型(同株多次取均值)——近交只针对有该性状表型的单株;
# 全库 3.6 万树建稠密 A 会 OOM36990²×8B≈10 GiB),必须按表型树收口。
obs = TraitObservationModel
stmt = (
select(obs.tree_id, func.avg(obs.value_numeric).label("v"))
.join(TreeModel, obs.tree_id == TreeModel.id)
.where(
obs.trait_id == trait_id,
obs.value_numeric.isnot(None),
obs.tree_id.isnot(None),
obs.is_deleted.is_(False),
TreeModel.is_deleted.is_(False),
)
.group_by(obs.tree_id)
)
if year is not None:
stmt = stmt.where(obs.evaluate_year == year)
if trial_study_id is not None:
stmt = stmt.where(obs.trial_study_id == trial_study_id)
agg = (await self.db.execute(stmt)).all()
if not agg:
raise CustomException(msg="无可用表型数据(该性状为空)")
# 1) 单株系谱(仅表型树)→ 深层系谱 A 矩阵 → 近交系数 F
# 单株 dam/sire 引用种质;种质系谱经 _germplasm_pedigree 多源 BFS 解析(bre_pedigree # 单株 dam/sire 引用种质;种质系谱经 _germplasm_pedigree 多源 BFS 解析(bre_pedigree
# 名称反查 + tree FK 兜底 + 祖先展开,与 OCS/mating 同源)——多世代项目(半同胞共享 # 名称反查 + tree FK 兜底 + 祖先展开,与 OCS/mating 同源)——多世代项目(半同胞共享
# 祖亲)不再低估 F;被引用但未解析(软删等)种质补 founder 兜底。 # 祖亲)不再低估 F;被引用但未解析(软删等)种质补 founder 兜底。
stmt = select(TreeModel.id, TreeModel.dam_id, TreeModel.sire_id).where(TreeModel.is_deleted.is_(False)) stmt = (select(TreeModel.id, TreeModel.dam_id, TreeModel.sire_id)
.where(TreeModel.is_deleted.is_(False),
_id_in(TreeModel.id, [r.tree_id for r in agg])))
rows = (await self.db.execute(stmt)).all() rows = (await self.db.execute(stmt)).all()
if not rows: if not rows:
raise CustomException(msg="无活动单株") raise CustomException(msg="无活动单株")
@@ -3249,27 +3290,7 @@ class StatisticsService:
for k, v in rmat_res["inbreeding"].items(): for k, v in rmat_res["inbreeding"].items():
if k.startswith("t") and k[1:].isdigit(): if k.startswith("t") and k[1:].isdigit():
f_by_tree[int(k[1:])] = float(v) f_by_tree[int(k[1:])] = float(v)
# 2) 目标性状每树表型(同株多次取均值) # 2) 表型已在上方取好(agg),直接配对 F
obs = TraitObservationModel
stmt = (
select(obs.tree_id, func.avg(obs.value_numeric).label("v"))
.join(TreeModel, obs.tree_id == TreeModel.id)
.where(
obs.trait_id == trait_id,
obs.value_numeric.isnot(None),
obs.tree_id.isnot(None),
obs.is_deleted.is_(False),
TreeModel.is_deleted.is_(False),
)
.group_by(obs.tree_id)
)
if year is not None:
stmt = stmt.where(obs.evaluate_year == year)
if trial_study_id is not None:
stmt = stmt.where(obs.trial_study_id == trial_study_id)
agg = (await self.db.execute(stmt)).all()
if not agg:
raise CustomException(msg="无可用表型数据(该性状为空)")
pairs: list[tuple[int, float, float]] = [] pairs: list[tuple[int, float, float]] = []
for r in agg: for r in agg:
f = f_by_tree.get(int(r.tree_id)) f = f_by_tree.get(int(r.tree_id))
@@ -3329,7 +3350,7 @@ class StatisticsService:
interpretation = f"未检出近交衰退(F 对表型无{trend}影响)" interpretation = f"未检出近交衰退(F 对表型无{trend}影响)"
tree_ids = [p[0] for p in pairs] tree_ids = [p[0] for p in pairs]
trows = (await self.db.execute( trows = (await self.db.execute(
select(TreeModel.id, TreeModel.tree_no).where(TreeModel.id.in_(tree_ids)) select(TreeModel.id, TreeModel.tree_no).where(_id_in(TreeModel.id, tree_ids))
)).all() )).all()
tree_map = {tid: no for tid, no in trows} tree_map = {tid: no for tid, no in trows}
base.update({ base.update({
@@ -3470,7 +3491,7 @@ class StatisticsService:
if flagged_ids: if flagged_ids:
for tid, tno in (await self.db.execute( for tid, tno in (await self.db.execute(
select(TreeModel.id, TreeModel.tree_no) select(TreeModel.id, TreeModel.tree_no)
.where(TreeModel.id.in_(flagged_ids)))).all(): .where(_id_in(TreeModel.id, flagged_ids)))).all():
tree_no_map[int(tid)] = tno or "" tree_no_map[int(tid)] = tno or ""
excluded_ids = await self._excluded_tree_ids() excluded_ids = await self._excluded_tree_ids()
mendelian_section = { mendelian_section = {
@@ -3665,7 +3686,7 @@ class StatisticsService:
gs = (await self.db.execute( gs = (await self.db.execute(
select(BreedingGermplasmModel.id, BreedingGermplasmModel.cultivar_name, select(BreedingGermplasmModel.id, BreedingGermplasmModel.cultivar_name,
BreedingGermplasmModel.accession_no) BreedingGermplasmModel.accession_no)
.where(BreedingGermplasmModel.id.in_(gids)) .where(_id_in(BreedingGermplasmModel.id, gids))
)).all() )).all()
cand_ids = [int(g.id) for g in gs] cand_ids = [int(g.id) for g in gs]
if not cand_ids: if not cand_ids:
@@ -3793,7 +3814,7 @@ class StatisticsService:
pvs = (await self.db.execute( pvs = (await self.db.execute(
select(PredictionValueModel).where( select(PredictionValueModel).where(
PredictionValueModel.prediction_id == ebv_pred.id, PredictionValueModel.prediction_id == ebv_pred.id,
PredictionValueModel.germplasm_id.in_(cand_ids), _id_in(PredictionValueModel.germplasm_id, cand_ids),
PredictionValueModel.predicted_value.isnot(None), PredictionValueModel.predicted_value.isnot(None),
) )
)).scalars().all() )).scalars().all()
@@ -3819,7 +3840,7 @@ class StatisticsService:
mset = set(missing) mset = set(missing)
off_tree = (await self.db.execute( off_tree = (await self.db.execute(
select(TreeModel.id, TreeModel.dam_id, TreeModel.sire_id, TreeModel.combination_id).where( select(TreeModel.id, TreeModel.dam_id, TreeModel.sire_id, TreeModel.combination_id).where(
or_(TreeModel.dam_id.in_(missing), TreeModel.sire_id.in_(missing)), or_(_id_in(TreeModel.dam_id, missing), _id_in(TreeModel.sire_id, missing)),
TreeModel.is_deleted.is_(False), TreeModel.is_deleted.is_(False),
) )
)).all() )).all()
@@ -3828,7 +3849,7 @@ class StatisticsService:
off_pvs = (await self.db.execute( off_pvs = (await self.db.execute(
select(PredictionValueModel.tree_id, PredictionValueModel.predicted_value).where( select(PredictionValueModel.tree_id, PredictionValueModel.predicted_value).where(
PredictionValueModel.prediction_id == ebv_pred.id, PredictionValueModel.prediction_id == ebv_pred.id,
PredictionValueModel.tree_id.in_(off_ids), _id_in(PredictionValueModel.tree_id, off_ids),
PredictionValueModel.predicted_value.isnot(None), PredictionValueModel.predicted_value.isnot(None),
) )
)).all() )).all()
@@ -3887,7 +3908,7 @@ class StatisticsService:
cand_ids = list(dict.fromkeys(candidate_germplasm_ids)) cand_ids = list(dict.fromkeys(candidate_germplasm_ids))
gs = (await self.db.execute( gs = (await self.db.execute(
select(BreedingGermplasmModel).where( select(BreedingGermplasmModel).where(
BreedingGermplasmModel.id.in_(cand_ids), _id_in(BreedingGermplasmModel.id, cand_ids),
BreedingGermplasmModel.is_deleted.is_(False), BreedingGermplasmModel.is_deleted.is_(False),
) )
)).scalars().all() )).scalars().all()
@@ -4048,7 +4069,7 @@ class StatisticsService:
raise CustomException(msg=f"选择数量 n_select={n_select} 超过候选亲本数 {len(cand_ids)}") raise CustomException(msg=f"选择数量 n_select={n_select} 超过候选亲本数 {len(cand_ids)}")
gs = (await self.db.execute( gs = (await self.db.execute(
select(BreedingGermplasmModel).where( select(BreedingGermplasmModel).where(
BreedingGermplasmModel.id.in_(cand_ids), _id_in(BreedingGermplasmModel.id, cand_ids),
BreedingGermplasmModel.is_deleted.is_(False), BreedingGermplasmModel.is_deleted.is_(False),
) )
)).scalars().all() )).scalars().all()
@@ -4210,7 +4231,7 @@ class StatisticsService:
cand_rows = (await self.db.execute( cand_rows = (await self.db.execute(
select(TreeModel.id, TreeModel.tree_no).where( select(TreeModel.id, TreeModel.tree_no).where(
TreeModel.id.in_(list(dict.fromkeys(candidate_tree_ids))), _id_in(TreeModel.id, list(dict.fromkeys(candidate_tree_ids))),
TreeModel.is_deleted.is_(False), TreeModel.is_deleted.is_(False),
) )
)).all() )).all()
@@ -4241,7 +4262,7 @@ class StatisticsService:
select(GenotypeCallModel.sample_id, GenotypeCallModel.marker_id, select(GenotypeCallModel.sample_id, GenotypeCallModel.marker_id,
GenotypeCallModel.allele) GenotypeCallModel.allele)
.where( .where(
GenotypeCallModel.marker_id.in_(bg_marker_ids), _id_in(GenotypeCallModel.marker_id, bg_marker_ids),
GenotypeCallModel.allele.isnot(None), GenotypeCallModel.allele.isnot(None),
GenotypeCallModel.is_deleted.is_(False), GenotypeCallModel.is_deleted.is_(False),
) )
@@ -4249,7 +4270,7 @@ class StatisticsService:
if calls: if calls:
sample_ids = sorted({c.sample_id for c in calls}) sample_ids = sorted({c.sample_id for c in calls})
samples = (await self.db.execute( samples = (await self.db.execute(
select(GenotypeSampleModel).where(GenotypeSampleModel.id.in_(sample_ids)) select(GenotypeSampleModel).where(_id_in(GenotypeSampleModel.id, sample_ids))
)).scalars().all() )).scalars().all()
sample_tree, _ = await self._sample_to_tree_map(samples) sample_tree, _ = await self._sample_to_tree_map(samples)
for c in calls: for c in calls:
@@ -4412,7 +4433,7 @@ class StatisticsService:
block_of[ent.id] = blk block_of[ent.id] = blk
await self.db.execute( await self.db.execute(
update(TrialStudyEntryModel) update(TrialStudyEntryModel)
.where(TrialStudyEntryModel.id.in_(list(block_of))) .where(_id_in(TrialStudyEntryModel.id, list(block_of)))
.values(block_no=case(block_of, value=TrialStudyEntryModel.id, else_=None)) .values(block_no=case(block_of, value=TrialStudyEntryModel.id, else_=None))
) )
await self.db.flush() await self.db.flush()
@@ -4468,7 +4489,7 @@ class StatisticsService:
if block_of: if block_of:
await self.db.execute( await self.db.execute(
update(TrialStudyEntryModel) update(TrialStudyEntryModel)
.where(TrialStudyEntryModel.id.in_(list(block_of))) .where(_id_in(TrialStudyEntryModel.id, list(block_of)))
.values(block_no=case(block_of, value=TrialStudyEntryModel.id, else_=None)) .values(block_no=case(block_of, value=TrialStudyEntryModel.id, else_=None))
) )
await self.db.flush() await self.db.flush()
@@ -4577,7 +4598,7 @@ class StatisticsService:
if block_of: if block_of:
await self.db.execute( await self.db.execute(
update(TrialStudyEntryModel) update(TrialStudyEntryModel)
.where(TrialStudyEntryModel.id.in_(list(block_of))) .where(_id_in(TrialStudyEntryModel.id, list(block_of)))
.values(block_no=case(block_of, value=TrialStudyEntryModel.id, else_=None)) .values(block_no=case(block_of, value=TrialStudyEntryModel.id, else_=None))
) )
await self.db.flush() await self.db.flush()
@@ -4634,7 +4655,7 @@ class StatisticsService:
if block: if block:
tree_ids = [int(r.tree_id) for r in agg] tree_ids = [int(r.tree_id) for r in agg]
block_rows = (await self.db.execute( block_rows = (await self.db.execute(
select(TreeModel.id, TreeModel.block_no).where(TreeModel.id.in_(tree_ids)) select(TreeModel.id, TreeModel.block_no).where(_id_in(TreeModel.id, tree_ids))
)).all() )).all()
block_of = {int(tid): bn for tid, bn in block_rows} block_of = {int(tid): bn for tid, bn in block_rows}
rows = [ rows = [
@@ -4998,10 +5019,10 @@ class StatisticsService:
all_tree_ids = sorted(set(by_tree) | set(ebv_map) | mas["tree_ids"]) all_tree_ids = sorted(set(by_tree) | set(ebv_map) | mas["tree_ids"])
if all_tree_ids: if all_tree_ids:
tree_no_map = dict((await self.db.execute( tree_no_map = dict((await self.db.execute(
select(TreeModel.id, TreeModel.tree_no).where(TreeModel.id.in_(all_tree_ids)) select(TreeModel.id, TreeModel.tree_no).where(_id_in(TreeModel.id, all_tree_ids))
)).all()) )).all())
combo_map = dict((await self.db.execute( combo_map = dict((await self.db.execute(
select(TreeModel.id, TreeModel.combination_id).where(TreeModel.id.in_(all_tree_ids)) select(TreeModel.id, TreeModel.combination_id).where(_id_in(TreeModel.id, all_tree_ids))
)).all()) )).all())
else: else:
tree_no_map, combo_map = {}, {} tree_no_map, combo_map = {}, {}
@@ -5168,7 +5189,7 @@ class StatisticsService:
tree_ids = sorted({r.tree_id for r in rows}) tree_ids = sorted({r.tree_id for r in rows})
trows = (await self.db.execute( trows = (await self.db.execute(
select(TreeModel.id, TreeModel.clone_id, TreeModel.combination_id).where( select(TreeModel.id, TreeModel.clone_id, TreeModel.combination_id).where(
TreeModel.id.in_(tree_ids), TreeModel.is_deleted.is_(False)) _id_in(TreeModel.id, tree_ids), TreeModel.is_deleted.is_(False))
)).all() )).all()
clone_of = {t.id: t.clone_id for t in trows} clone_of = {t.id: t.clone_id for t in trows}
combo_of = {t.id: t.combination_id for t in trows} combo_of = {t.id: t.combination_id for t in trows}
@@ -5545,7 +5566,7 @@ class StatisticsService:
else: else:
tree_rows = (await self.db.execute( tree_rows = (await self.db.execute(
select(TreeModel.id, TreeModel.clone_id, TreeModel.tree_no).where( select(TreeModel.id, TreeModel.clone_id, TreeModel.tree_no).where(
TreeModel.id.in_(list(by_tree)), TreeModel.is_deleted.is_(False) _id_in(TreeModel.id, list(by_tree)), TreeModel.is_deleted.is_(False)
) )
)).all() )).all()
clone_of = {t.id: t.clone_id for t in tree_rows} clone_of = {t.id: t.clone_id for t in tree_rows}
@@ -5555,7 +5576,7 @@ class StatisticsService:
if clone_ids: if clone_ids:
clones = (await self.db.execute( clones = (await self.db.execute(
select(CloneModel.id, CloneModel.clone_code).where( select(CloneModel.id, CloneModel.clone_code).where(
CloneModel.id.in_(clone_ids), CloneModel.is_deleted.is_(False) _id_in(CloneModel.id, clone_ids), CloneModel.is_deleted.is_(False)
) )
)).all() )).all()
clone_label = {c.id: c.clone_code for c in clones} clone_label = {c.id: c.clone_code for c in clones}
@@ -5807,7 +5828,7 @@ class StatisticsService:
rel = result["reliability"] rel = result["reliability"]
ebv = result["ebv"] ebv = result["ebv"]
germ_of = dict((await self.db.execute( germ_of = dict((await self.db.execute(
select(TreeModel.id, TreeModel.germplasm_id).where(TreeModel.id.in_(tree_ids)) select(TreeModel.id, TreeModel.germplasm_id).where(_id_in(TreeModel.id, tree_ids))
)).all()) )).all())
direction = (trait_row.direction or "desc").lower() direction = (trait_row.direction or "desc").lower()
tree_ebv = [(tid, float(ebv.get(f"t{tid}", 0.0))) for tid in tree_ids] tree_ebv = [(tid, float(ebv.get(f"t{tid}", 0.0))) for tid in tree_ids]
@@ -5898,7 +5919,7 @@ class StatisticsService:
select(GenotypeCallModel.sample_id, GenotypeCallModel.marker_id, select(GenotypeCallModel.sample_id, GenotypeCallModel.marker_id,
GenotypeCallModel.allele) GenotypeCallModel.allele)
.where( .where(
GenotypeCallModel.sample_id.in_(sample_ids), _id_in(GenotypeCallModel.sample_id, sample_ids),
GenotypeCallModel.marker_id.isnot(None), GenotypeCallModel.marker_id.isnot(None),
GenotypeCallModel.allele.isnot(None), GenotypeCallModel.allele.isnot(None),
GenotypeCallModel.is_deleted.is_(False), GenotypeCallModel.is_deleted.is_(False),
@@ -5909,7 +5930,7 @@ class StatisticsService:
marker_ids = sorted({r.marker_id for r in call_rows}) marker_ids = sorted({r.marker_id for r in call_rows})
markers = (await self.db.execute( markers = (await self.db.execute(
select(MarkerModel.id, MarkerModel.marker_name, MarkerModel.marker_type).where( select(MarkerModel.id, MarkerModel.marker_name, MarkerModel.marker_type).where(
MarkerModel.id.in_(marker_ids), MarkerModel.is_deleted.is_(False)) _id_in(MarkerModel.id, marker_ids), MarkerModel.is_deleted.is_(False))
)).all() )).all()
if not markers: if not markers:
raise CustomException(msg="标记不存在") raise CustomException(msg="标记不存在")
@@ -6068,7 +6089,7 @@ class StatisticsService:
select(GenotypeCallModel.sample_id, GenotypeCallModel.marker_id, select(GenotypeCallModel.sample_id, GenotypeCallModel.marker_id,
GenotypeCallModel.allele) GenotypeCallModel.allele)
.where( .where(
GenotypeCallModel.sample_id.in_(sample_ids), _id_in(GenotypeCallModel.sample_id, sample_ids),
GenotypeCallModel.marker_id.isnot(None), GenotypeCallModel.marker_id.isnot(None),
GenotypeCallModel.allele.isnot(None), GenotypeCallModel.allele.isnot(None),
GenotypeCallModel.is_deleted.is_(False), GenotypeCallModel.is_deleted.is_(False),
@@ -6081,7 +6102,7 @@ class StatisticsService:
select(MarkerModel.id, MarkerModel.marker_name, select(MarkerModel.id, MarkerModel.marker_name,
MarkerModel.chromosome, MarkerModel.position, MarkerModel.chromosome, MarkerModel.position,
MarkerModel.marker_type) MarkerModel.marker_type)
.where(MarkerModel.id.in_(marker_ids), MarkerModel.is_deleted.is_(False)) .where(_id_in(MarkerModel.id, marker_ids), MarkerModel.is_deleted.is_(False))
)).all() )).all()
if not markers: if not markers:
raise CustomException(msg="标记不存在") raise CustomException(msg="标记不存在")
@@ -6594,7 +6615,7 @@ class StatisticsService:
select(GenotypeCallModel.sample_id, GenotypeCallModel.marker_id, select(GenotypeCallModel.sample_id, GenotypeCallModel.marker_id,
GenotypeCallModel.allele) GenotypeCallModel.allele)
.where( .where(
GenotypeCallModel.marker_id.in_(marker_ids), _id_in(GenotypeCallModel.marker_id, marker_ids),
GenotypeCallModel.allele.isnot(None), GenotypeCallModel.allele.isnot(None),
GenotypeCallModel.is_deleted.is_(False), GenotypeCallModel.is_deleted.is_(False),
) )
@@ -6603,7 +6624,7 @@ class StatisticsService:
return out return out
sample_ids = sorted({c.sample_id for c in calls}) sample_ids = sorted({c.sample_id for c in calls})
samples = (await self.db.execute( samples = (await self.db.execute(
select(GenotypeSampleModel).where(GenotypeSampleModel.id.in_(sample_ids)) select(GenotypeSampleModel).where(_id_in(GenotypeSampleModel.id, sample_ids))
)).scalars().all() )).scalars().all()
sample_tree, _ = await self._sample_to_tree_map(samples) sample_tree, _ = await self._sample_to_tree_map(samples)
tree_geno: dict[int, dict[int, str]] = {} tree_geno: dict[int, dict[int, str]] = {}
@@ -6744,7 +6765,7 @@ class StatisticsService:
# GXE 分支:环境维度记录 → rec_phenos(与 run_ablup 同构) # GXE 分支:环境维度记录 → rec_phenos(与 run_ablup 同构)
grp_rows = (await self.db.execute( grp_rows = (await self.db.execute(
select(TreeModel.id, TreeModel.clone_id, TreeModel.combination_id).where( select(TreeModel.id, TreeModel.clone_id, TreeModel.combination_id).where(
TreeModel.id.in_(tree_ids), TreeModel.is_deleted.is_(False)) _id_in(TreeModel.id, tree_ids), TreeModel.is_deleted.is_(False))
)).all() )).all()
clone_of = {t.id: t.clone_id for t in grp_rows} clone_of = {t.id: t.clone_id for t in grp_rows}
combo_of = {t.id: t.combination_id for t in grp_rows} combo_of = {t.id: t.combination_id for t in grp_rows}
@@ -6764,7 +6785,7 @@ class StatisticsService:
else: else:
site_rows = (await self.db.execute( site_rows = (await self.db.execute(
select(TreeModel.id, TreeModel.trial_study_id).where( select(TreeModel.id, TreeModel.trial_study_id).where(
TreeModel.id.in_(tree_ids), TreeModel.is_deleted.is_(False)) _id_in(TreeModel.id, tree_ids), TreeModel.is_deleted.is_(False))
)).all() )).all()
site_of = {t.id: t.trial_study_id for t in site_rows} site_of = {t.id: t.trial_study_id for t in site_rows}
rec_phenos = {} rec_phenos = {}
@@ -12,6 +12,7 @@ from app.utils.dict_util import DictLabelResolver, dict_value_to_label
from app.utils.number_gen import NumberGenService from app.utils.number_gen import NumberGenService
from .crud import BreedingTreeCRUD from .crud import BreedingTreeCRUD
from .model import TreeModel
from .schema import ( from .schema import (
TreeCreateSchema, TreeCreateSchema,
TreeOutSchema, TreeOutSchema,
@@ -370,8 +371,10 @@ class TreeService:
await BreedingTreeCRUD(self.auth, self.db).delete(ids=ids) await BreedingTreeCRUD(self.auth, self.db).delete(ids=ids)
async def list_options(self) -> list[dict[str, Any]]: async def list_options(self) -> list[dict[str, Any]]:
"""供前端下拉选择使用:返回 [{value, label}]。""" """供前端下拉选择使用:返回 [{value, label}]。只取 id/tree_no 两列,避免全表列加载拖慢下拉。"""
obj_list = await BreedingTreeCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}]) obj_list = await BreedingTreeCRUD(self.auth, self.db).get_list(
order_by=[{"id": "asc"}], load_columns=[TreeModel.id, TreeModel.tree_no]
)
return [{"value": o.id, "label": o.tree_no} for o in obj_list] return [{"value": o.id, "label": o.tree_no} for o in obj_list]
@staticmethod @staticmethod
+293 -123
View File
@@ -9,15 +9,21 @@
- 无父母的个体一律视为 founderbase population,彼此不相关)。 - 无父母的个体一律视为 founderbase population,彼此不相关)。
算法: 算法:
1. A 矩阵(稠密,O(n²) 递归)→ 各体自交系数 F 1. A⁻¹ 用 Henderson 稀疏规则构造(无需求 A 的逆)
2. A⁻¹ 用 Henderson 稀疏规则构造(无需求 A 的逆) 2. MME 求解:y = Xb + Zu + e,X=截距+固定效应虚拟列+协变量(默认仅截距),λ = σ²e/σ²a
3. MME 求解:y = Xb + Zu + e,X=截距+固定效应虚拟列+协变量(默认仅截距),λ = σ²e/σ²a; 个体数 ≤ N_SPARSE(默认 1000)→ 稠密精确求解(solver="dense":索引化 Z、稠密 A、
默认稠密精确求解;个体数 > N_SPARSE(默认 1000)且系谱存在时切稀疏 A⁻¹ + 共轭梯度 剖面 REML 黄金分割);个体数 > N_SPARSE 且系谱存在 → 稀疏路径(solver="sparse-em"):
迭代(solve 的 solver="sparse-cg"):EBV/h²/σ² 为 CG 精确解,reliability 用 Z 索引化设计矩阵(bincount)、稀疏 A⁻¹ 三元组 + 共轭梯度迭代,全程不落稠密 Z/A/V
Hutchinson 随机探测估计 C22 对角线(reliability_approx=True,均值/PA 可靠、个体级近似); (35k 树在常规内存内跑通)。方差分量按 n 分档:
4. 剖面 REML:对每个候选 h² 固定 λ,用 MME 解的 EM 条件式估计 σ²e、σ²a, 个体数 ≤ N_EXACT3000)→ tr(A⁻¹C²²) 逐列 CG 精确(无随机噪声),REML 解
再用精确 REML 对数似然评分,黄金分割一维最大化(稳健、可收敛到边界); h(λ)=g(λ)−λ=0 单调单根 → log10λ 二分 ~32 步到 1e-5(规避 EM 慢收缩);
5. h² = σ²a/(σ²a+σ²e)reliability = 1 - PEV/σ²aPEV = C22[ii]·σ²e。 个体数 > N_EXACT 且观测数 m>N_SUBSAMPLE(可真实子采样)→ 随机子样本(观测
≤N_SUBSAMPLE,含祖先闭包)上精确迹二分求 REML 解——子样本 REML 为全群方差组分
一致估计(抽样 SE h² 约 ±0.04),再全群 MME 于子样本 λ* 下解 EBV(大样本 EM 慢收缩
ρ≈0.999 冻结不可用;EBV 对 λ 稳健,warning 标注);
3. reliability:稠密路径精确(diag(C22) 取 MME 逆对角);稀疏路径 n≤EXACT_DIAG_N 用
稠密 MME 逆精确、n 大用 Hutchinson 随机探测(reliability_approx=True,均值/PA 可靠、个体级近似);
4. h² = σ²a/(σ²a+σ²e)reliability = 1 - PEV/σ²aPEV = C22[ii]·σ²e。
无系谱(所有个体均无父母)时:不背书遗传力—— 无系谱(所有个体均无父母)时:不背书遗传力——
h2 置 None、reliability 全部置 0、EBV 取中心化表型,并返回 warning。 h2 置 None、reliability 全部置 0、EBV 取中心化表型,并返回 warning。
@@ -41,8 +47,27 @@ CG_TOL = 1e-9 # 共轭梯度相对残差收敛阈值
# CG 精确解;reliability 是 MC 对角估计——同家族强相关的 C22 令其收敛慢(~1/√k), # CG 精确解;reliability 是 MC 对角估计——同家族强相关的 C22 令其收敛慢(~1/√k),
# 误差随 k 减小但非精确,作为文档标注的近似(排序可靠、量级可信)。 # 误差随 k 减小但非精确,作为文档标注的近似(排序可靠、量级可信)。
HUTCH_K = 100 HUTCH_K = 100
# 稀疏路径 reliability 分档:个体数 ≤ EXACT_DIAG_N → diag(C22) 用稠密 MME 逆精确求
# (golden 等价档,无 MC 噪声——n=20 全同胞小样本 h²→1 边界实测 Hutchinson 均值误差 ~0.08);
# > EXACT_DIAG_N → Hutchinson 随机探测(排序可靠、量级可信,误差随个体数稀释)。
EXACT_DIAG_N = 1000
# 稀疏路径分档:精确迹二分求 λ*(逐列 CG 精确 tr(A⁻¹C²²),无随机噪声,
# REML 解 = h(λ)=g(λ)−λ=0 单调单根,log10λ 二分 ~32 步到 1e-5,规避 EM 慢收缩 ρ≈0.987)。
# 精确迹 O(n²)(每步似然 n 次逐列 CG),仅个体数 ≤ N_EXACT 才可负担;>N_EXACT 且可真实
# 子采样观测(m>N_SUBSAMPLE)→ 随机子样本精确迹二分(观测 ≤N_SUBSAMPLE,含祖先闭包)
# 求 λ*,再全群 MME 求解。
# 大样本 EM 收缩率 ρ→0.999(100 步 λ 几乎不动,冻结在初始猜测),Hutchinson 噪声又淹没
# h(λ) 信号(~2e-4 vs 噪声 ~2e-3),全群精确迹 O(n²) 不可行;子样本 iid 抽样 REML 为全群
# 方差组分一致估计(n_sub=3000 抽样 SE h² ≈ ±0.04),EBV 对 λ 稳健(偏 25% Kendall τ>0.97)。
# N_EXACT 对齐 N_SUBSAMPLEm>N_SUBSAMPLE 时 n≈m+系谱必超 N_EXACT → 走子样本封顶 ~70min
# 消除 3000<n≤6000 精确迹数小时级尾(性能倒挂:局部选 3000-6000 树比全量还慢)。
N_EXACT = 3000
N_SUBSAMPLE = 3000 # 生产档子样本观测数上限(n>N_EXACT 且 m>N_SUBSAMPLE 才触发)
BISECT_LO, BISECT_HI = -4.0, 4.0 # log10(λ=Ve/Va) 二分区间(REML 解通常在其内)
BISECT_TOL = 1e-8 # 二分 log10(λ) 收敛宽度(→ λ 相对精度 ~1e-8)
MAX_BISECT_EVAL = 60 # 二分求值上限
ENGINE_VERSION = "1.5.0" # 单性状动物模型 BLUP 求解器版本(落 bre_prediction.engine_version1.1.0: 供 mtblup 单性状方差复用;1.2.0: G×R 砧木×接穗互作;1.3.0: AR1×AR1 空间协方差 solve_spatial + 稀疏 A⁻¹/共轭梯度/Hutchinson 可靠性;1.4.0: AR1×AR1 各向异性双参数 ρ_row/ρ_col1.5.0: 区组随机效应 _solve_block(增广/α-格子 block_no 第二随机效应)) ENGINE_VERSION = "1.7.0" # 单性状动物模型 BLUP 求解器版本(落 bre_prediction.engine_version1.1.0: 供 mtblup 单性状方差复用;1.2.0: G×R 砧木×接穗互作;1.3.0: AR1×AR1 空间协方差 solve_spatial + 稀疏 A⁻¹/共轭梯度/Hutchinson 可靠性;1.4.0: AR1×AR1 各向异性双参数 ρ_row/ρ_col1.5.0: 区组随机效应 _solve_block(增广/α-格子 block_no 第二随机效应)1.6.0: 稀疏路径 EM-REML(索引化设计矩阵 + 稀疏 A⁻¹ 无稠密 A + EM-REML35k 树不再 OOMn≤N_EXACT 精确迹二分 λ*n>N_EXACT 随机子样本精确迹二分 → 全群 MME 解 EBV);1.7.0: 性能倒挂修复——N_EXACT 6000→3000 对齐 N_SUBSAMPLE,生产档仅 m>N_SUBSAMPLE 才触发;系谱闭包改 BFS 可达祖先(服务层),选中 200~3000 树不再被全量 germplasm 闭包顶进数小时精确迹尾
def _pearson(x: list[float], y: list[float]) -> float: def _pearson(x: list[float], y: list[float]) -> float:
@@ -129,30 +154,37 @@ def _build_ainv(order: list[int], n_base: int,
def _build_ainv_sparse(order: list[int], n_base: int, def _build_ainv_sparse(order: list[int], n_base: int,
parent_of: dict[int, tuple[int | None, int | None]] parent_of: dict[int, tuple[int | None, int | None]]
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""返回 (A, ii, jj, vv)A 稠密(供 REML 似然与近交系数);A⁻¹ 以 COO 稀疏三元组存储。 """返回 (diag_a, ii, jj, vv)A 对角(近交 F=A_ii1 来源);A⁻¹ 以 COO 稀疏三元组存储。
Henderson 规则与 _build_ainv 逐项一致,但 A⁻¹ 不落稠密矩阵,只收集非零元 不构建稠密 A:加性关系对角用「祖先稀疏行」递推(每体一行 {祖先: A_ij},浅系谱祖先数
(对角 + 2 亲本 × (对角/双向共祖先)),供共轭梯度 matvec 使用: 小,O(n·s²)),Henderson 规则只需 A 对角(fp/fq)与双亲交叉 A[di,si],均可在稀疏行上
(Ainv·u)[i] = Σ_j Ainv_ij·u_jnp.add.at 按三元组累加即可 读出;A⁻¹ 非零元(对角 + 2 亲本 × 对角/双向共祖先)逐项与 _build_ainv 一致
matvec 时用 np.bincount 按 ii 累加 ainv_vv·u[jj] 即得 A⁻¹·u(重复元自动合并)。
""" """
n = len(order) n = len(order)
idx = {ind: pos for pos, ind in enumerate(order)} idx = {ind: pos for pos, ind in enumerate(order)}
A = np.eye(n) anc: list[dict[int, float]] = [None] * n
diag_a = np.zeros(n)
for pos, ind in enumerate(order): for pos, ind in enumerate(order):
d, s = parent_of.get(ind, (None, None)) d, s = parent_of.get(ind, (None, None))
if d is None and s is None: if d is None and s is None:
anc[pos] = {pos: 1.0}
diag_a[pos] = 1.0
continue continue
di = idx.get(d) if d is not None else None di = idx.get(d) if d is not None else None
si = idx.get(s) if s is not None else None si = idx.get(s) if s is not None else None
row = np.zeros(n) row: dict[int, float] = {}
if di is not None: for src in (di, si):
row += A[di] if src is not None:
if si is not None: for j, v in anc[src].items():
row += A[si] row[j] = row.get(j, 0.0) + 0.5 * v
A[pos, :] = 0.5 * row apq = 0.0
A[:pos, pos] = A[pos, :pos] if di is not None and si is not None:
apq = A[di, si] if (di is not None and si is not None) else 0.0 small, big = (anc[di], anc[si]) if len(anc[di]) < len(anc[si]) else (anc[si], anc[di])
A[pos, pos] = 1.0 + 0.5 * apq apq = sum(v * big[j] for j, v in small.items() if j in big)
row[pos] = 1.0 + 0.5 * apq
anc[pos] = row
diag_a[pos] = row[pos]
ii: list[int] = [] ii: list[int] = []
jj: list[int] = [] jj: list[int] = []
@@ -171,8 +203,8 @@ def _build_ainv_sparse(order: list[int], n_base: int,
i = idx[ind] i = idx[ind]
di = idx.get(d) if d is not None else None di = idx.get(d) if d is not None else None
si = idx.get(s) if s is not None else None si = idx.get(s) if s is not None else None
fp = A[di, di] - 1.0 if di is not None else 0.0 fp = diag_a[di] - 1.0 if di is not None else 0.0
fq = A[si, si] - 1.0 if si is not None else 0.0 fq = diag_a[si] - 1.0 if si is not None else 0.0
t = 0.5 - 0.25 * (fp + fq) t = 0.5 - 0.25 * (fp + fq)
# 极端近交(双亲 F→1)时 t→0,除零保护;1e-6 下界对正常系谱无感 # 极端近交(双亲 F→1)时 t→0,除零保护;1e-6 下界对正常系谱无感
inv_t = 1.0 / max(t, 1e-6) inv_t = 1.0 / max(t, 1e-6)
@@ -185,7 +217,7 @@ def _build_ainv_sparse(order: list[int], n_base: int,
if di is not None and si is not None: if di is not None and si is not None:
_put(di, si, 0.25 * inv_t) _put(di, si, 0.25 * inv_t)
_put(si, di, 0.25 * inv_t) _put(si, di, 0.25 * inv_t)
return (A, np.array(ii, dtype=np.int64), np.array(jj, dtype=np.int64), return (diag_a, np.array(ii, dtype=np.int64), np.array(jj, dtype=np.int64),
np.array(vv, dtype=float)) np.array(vv, dtype=float))
@@ -351,11 +383,13 @@ def solve(pedigree: list[dict], phenotypes: dict, *, tol: float = TOL,
gxe_ratio / n_gxe / n_cross_env / n_genotypesG×E 分支); gxe_ratio / n_gxe / n_cross_env / n_genotypesG×E 分支);
block_ratio / n_blocks / block_effects(区组分支); block_ratio / n_blocks / block_effects(区组分支);
n_obs / n_individuals / n_base / n_with_parents / n_fixed: int n_obs / n_individuals / n_base / n_with_parents / n_fixed: int
converged: booln_iter: int(剖面求值次数/EM 迭代数);warning: str | None converged: booln_iter: int稠密=剖面求值次数,稀疏=迹二分/子样本求值次数);warning: str | None
solver: str"dense" 稠密精确 / "sparse-cg" 稀疏共轭梯度n>N_SPARSE 时触发); solver: str"dense" 稠密精确 / "sparse-em" 稀疏 EM-REMLn>N_SPARSE 时触发);
稀疏路径另含 cg_iterCG 迭代数)与 reliability_approx=True(可靠性为 Hutchinson 近似 稀疏路径另含 cg_iterCG 迭代数)与 reliability_approx=True(可靠性为 Hutchinson 近似);
EBV/h²/σ² 仍为精确解)。 生产档(n>N_EXACT)方差组分为随机子样本 REML 一致估计(抽样 SE h² ≈ ±0.04),EBV 由
全群 MME 于子样本 λ 下求解——对 λ 稳健,排序可靠,h²/σ² 视为近似区间。
""" """
global N_EXACT # 生产档内联递归临时抬档(try/finally 还原);须在函数内首次读取 N_EXACT 前声明
if gxe is not None: if gxe is not None:
return _solve_gxe(pedigree, phenotypes, fixed=fixed, covariate=covariate, return _solve_gxe(pedigree, phenotypes, fixed=fixed, covariate=covariate,
gxe=gxe, record_map=record_map or {}, tol=tol, gxe=gxe, record_map=record_map or {}, tol=tol,
@@ -427,22 +461,237 @@ def solve(pedigree: list[dict], phenotypes: dict, *, tol: float = TOL,
"warning": "无系谱信息(所有个体均无父母):EBV 仅为表型残差(固定效应已校正),未估计遗传力与可靠性。", "warning": "无系谱信息(所有个体均无父母):EBV 仅为表型残差(固定效应已校正),未估计遗传力与可靠性。",
} }
XtX = X.T @ X
Xty = X.T @ y
# 索引化设计矩阵(Z 每行恰一个 1):Zty / ZtZ 对角 / XtZ 全用 bincount 累加,
# 稀疏路径不再落稠密 Z(35k 树稠密 Z ≈ 54 GiB)。稠密分支在小 n 下重建等价 Z。
obs_idx = np.array([idx[ind] for ind in obs_ids], dtype=np.int64)
Zty = np.bincount(obs_idx, weights=y, minlength=n)
ZtZ_diag = np.bincount(obs_idx, minlength=n).astype(float)
XtZ = np.zeros((p, n))
for col in range(p):
XtZ[col] = np.bincount(obs_idx, weights=X[:, col], minlength=n)
# A7 阈值分派:个体数 > N_SPARSE 且系谱存在 → 稀疏 A⁻¹ + 索引化设计 + EM-REML
# (回避 O(n³) 稠密 MME 逆与 O(m²) 稠密 V);可靠性 n≤EXACT_DIAG_N 用稠密 MME 逆精确,
# n 大用 Hutchinson 随机探测估计 C22 对角线。
use_sparse = n_with_parents > 0 and n > N_SPARSE
if use_sparse:
diag_a, ainv_ii, ainv_jj, ainv_vv = _build_ainv_sparse(order, n_base, parent_of)
else:
A, Ainv = _build_ainv(order, n_base, parent_of)
if use_sparse:
# ---- 稀疏路径:EM-REMLMeyer 1985 单性状动物模型)----
# Va = (û'A⁻¹û + σ²e·tr(A⁻¹C²²))/nVe = yPy/(mp);λ=Ve/Va。全程不落稠密 Z/A/V。
# n ≤ N_EXACTtr(A⁻¹C²²) 逐列 CG 精确(无随机噪声);REML 解 = h(λ)=g(λ)−λ=0
# 单调单根 → log10λ 二分,~32 步到 1e-5(规避 EM 慢收缩 ρ≈0.987);
# n > N_EXACT:随机子样本(≤N_SUBSAMPLE 观测,祖先闭包)内联递归走精确迹二分
# 求 λ*,再全群 MME 于 λ* 解 EBV——子样本抽样误差即真实不确定性(warning 标注)。
ztz = ZtZ_diag
def _matvec(lam: float):
def mv(v: np.ndarray) -> np.ndarray:
b, u = v[:p], v[p:]
out1 = XtX @ b + XtZ @ u
out2 = XtZ.T @ b + ztz * u
# A⁻¹·ubincount 按 ii 累加(重复元自动合并),比 np.add.at 快一个量级
out2 += np.bincount(ainv_ii, weights=lam * ainv_vv * u[ainv_jj], minlength=n)
return np.concatenate([out1, out2])
return mv
mme_rhs = np.concatenate([Xty, Zty])
rng = np.random.RandomState(20260804)
em_it = 0
em_ok = True
cg_ok = True
cg_iter = 0
# 生产档仅当能真实子采样观测(m>N_SUBSAMPLE)才走:m 不大时子样本=全样本,退化为
# 精确迹 + 冗余全群 MME(更慢且子样本误差 SE 无意义)——直接全 n 精确迹更省更准。
use_production = n > N_EXACT and m > N_SUBSAMPLE
if not use_production:
# ---- 精确迹二分(golden 等价档)----
# A⁻¹ 第 j 列 = {i: ainv[i,j]}C²²e_j = MME 解 RHS=[0;e_j] 的 u 块;
# tr(A⁻¹C²²) = Σ_j (A⁻¹e_j)ᵀ(C²²e_j)。逐列 CG 精确,无 Hutchinson 噪声。
col_of: list[list[tuple[int, float]]] = [[] for _ in range(n)]
for a, b, v in zip(ainv_ii.tolist(), ainv_jj.tolist(), ainv_vv.tolist()):
col_of[b].append((a, v))
def _em_step(lam: float) -> tuple[float, float]:
mv = _matvec(lam)
sol, _ci, _ok = _cg_solve(mv, mme_rhs)
b, u = sol[:p], sol[p:]
yPy = float(y @ y - b @ Xty - u @ Zty)
Ve = max(yPy / max(m - p, 1), _FLOOR)
tr = 0.0
for j in range(n):
rhs2 = np.zeros(p + n)
rhs2[p + j] = 1.0
sol2, _i2, _o2 = _cg_solve(mv, rhs2)
uj = sol2[p:]
s = 0.0
for i, v in col_of[j]:
s += v * uj[i]
tr += s
uAu = float(np.bincount(ainv_ii, weights=ainv_vv * u[ainv_jj], minlength=n) @ u)
Va = max((uAu + Ve * tr) / max(n, 1), _FLOOR)
return Va, Ve
def _h(lv: float) -> float:
lam = 10.0 ** lv
Va, Ve = _em_step(lam)
return Ve / Va - lam
lo, hi = BISECT_LO, BISECT_HI
h_lo, h_hi = _h(lo), _h(hi)
while h_lo <= 0.0 and lo > BISECT_LO - 4.0:
lo -= 2.0
h_lo = _h(lo)
while h_hi >= 0.0 and hi < BISECT_HI + 4.0:
hi += 2.0
h_hi = _h(hi)
if h_lo > 0.0 > h_hi:
em_it = 2
while (hi - lo) > BISECT_TOL and em_it < MAX_BISECT_EVAL:
mid = 0.5 * (lo + hi)
h_mid = _h(mid)
em_it += 1
if h_mid > 0.0:
lo = mid
else:
hi = mid
Va, Ve = _em_step(10.0 ** (0.5 * (lo + hi)))
else:
# 无变号(REML 解在区间外/边界最优):取 |h| 最小的网格点
lv_grid = np.linspace(max(lo - 4.0, -8.0), min(hi + 4.0, 8.0), 41)
h_abs = [abs(_h(lv)) for lv in lv_grid]
Va, Ve = _em_step(10.0 ** float(lv_grid[int(np.argmin(h_abs))]))
em_ok = False
else:
# ---- 生产档(n>N_EXACT 且 m>N_SUBSAMPLE):随机子样本精确迹二分 → 全群 MME ----
# 全群精确迹 O(n²) 不可行;EM 收缩率 ρ→0.999 冻结在初始猜测,Hutchinson 噪声
# 又淹没 h(λ) 信号(~2e-4 vs 噪声 ~2e-3)。子样本 iid 抽样 REML 为全群方差组分
# 一致估计(n_sub=3000 抽样 SE h² ≈ ±0.04EBV corr>0.998),抽样误差即真实
# 不确定性。内联递归把 N_EXACT 临时抬到 10⁹,让子样本走精确迹二分求 λ*。
chosen = set(rng.choice(obs_ids, size=min(N_SUBSAMPLE, m), replace=False).tolist())
# 祖先闭包:parent_of 以个体 id 为键、值亦为 id(与 _build_ainv_sparse 一致);
# 勿用 idx[]/order[] 做中间层(那是位置空间,混用会把闭包加进错对象)。
q: list[int] = list(chosen)
while q:
cur = q.pop()
d, s = parent_of.get(cur, (None, None))
for pp in (d, s):
if pp is not None and pp not in chosen:
chosen.add(pp)
q.append(pp)
ped_sub = []
for i in chosen:
d, s = parent_of.get(i, (None, None))
ped_sub.append({
"individual": i,
"dam": d if d is not None else None,
"sire": s if s is not None else None,
})
phen_sub = {k: v for k, v in phenotypes.items() if k in chosen}
fixed_sub = None
if fixed:
fixed_sub = {fk: {k: v for k, v in fv.items() if k in chosen}
for fk, fv in fixed.items()}
cov_sub = None
if covariate:
cov_sub = {k: v for k, v in covariate.items() if k in chosen}
saved_exact = N_EXACT
try:
N_EXACT = 10 ** 9 # noqa: PLW0603 临时抬档(子样本个体数远小于全群)
sub = solve(ped_sub, phen_sub, fixed=fixed_sub, covariate=cov_sub)
finally:
N_EXACT = saved_exact
Va = max(float(sub["sigma_a"]), _FLOOR)
Ve = max(float(sub["sigma_e"]), _FLOOR)
em_ok = bool(sub.get("converged", False))
em_it = int(sub.get("n_iter", 0))
lam = Ve / Va
mv = _matvec(lam)
sol, cg_iter, cg_ok = _cg_solve(mv, mme_rhs)
u = sol[p:]
h2 = Va / (Va + Ve) if (Va + Ve) > 0 else None
# 可靠性:n ≤ EXACT_DIAG_N(稠密逆仍便宜)→ diag(C22) 用稠密 MME 逆精确求(golden
# 等价档,无 MC 噪声——全同胞小样本 h²→1 边界 Hutchinson 均值误差实测 ~0.08 量级,
# 见 e2e_spatial_sparse n=20 段);n 大时退 Hutchinson 随机探测(排序可靠、量级可信)。
if n <= EXACT_DIAG_N:
Ainv_dense = np.zeros((n, n))
np.add.at(Ainv_dense, (ainv_ii, ainv_jj), ainv_vv) # 三元组含重复 (i,j)(多后代),须累加
big = np.zeros((p + n, p + n))
big[:p, :p] = XtX
big[:p, p:] = XtZ
big[p:, :p] = XtZ.T
big[p:, p:] = np.diag(ZtZ_diag) + lam * Ainv_dense
try:
big_inv = np.linalg.inv(big)
except np.linalg.LinAlgError:
big_inv = np.linalg.pinv(big)
C22d = big_inv[p:, p:]
pev = np.diag(C22d) * Ve
else:
diag_c22 = np.zeros(n)
for _ in range(HUTCH_K):
pv = rng.choice([-1.0, 1.0], size=n)
rhs2 = np.zeros(p + n)
rhs2[p:] = pv
sol2, _it2, _ok2 = _cg_solve(mv, rhs2)
diag_c22 += sol2[p:] * pv
diag_c22 /= HUTCH_K
pev = diag_c22 * Ve
ebv = {ind: float(u[idx[ind]]) for ind in order}
rel = {ind: float(np.clip(1.0 - pev[idx[ind]] / Va, 0.0, 1.0)) for ind in order}
warning = None
if use_production:
# 生产档(n>N_EXACT 且 m>N_SUBSAMPLE):方差组分来自随机子样本
# (≤N_SUBSAMPLE 观测,含祖先闭包)的精确 REML 估计——一致估计但带抽样误差
# n_sub=3000 SE h² ≈ ±0.04);EBV 由全群 MME 在子样本 λ* 下求解,
# 对 λ 稳健(偏 25% 内 Kendall τ>0.97),排序可靠。
warning = (f"生产档(个体数 {n})方差分量取自随机子样本"
f"(≤{N_SUBSAMPLE} 观测,含祖先闭包)的 REML 一致估计:存在抽样误差"
"(h² 标准误约 ±0.04),h²/σ² 报告值请视为近似区间;EBV 由全群 MME"
"在子样本 λ 下求解,排名可靠。")
elif not em_ok:
warning = ("REML 解落在 log10λ 二分区间外(似然面边界最优),"
"结果采用 |h| 最小网格点,可能不精确。")
if not cg_ok:
resid = mme_rhs - mv(sol)
rel_res = float(np.linalg.norm(resid) / (np.linalg.norm(mme_rhs) + 1e-12))
w = (f"共轭梯度 {cg_iter} 次迭代未达收敛阈值(相对残差 {rel_res:.2e}),"
"结果采用已探明最优,可靠性为 Hutchinson 近似。")
warning = f"{warning} {w}" if warning else w
return {
"ebv": ebv,
"reliability": rel,
"h2": float(h2) if h2 is not None else None,
"sigma_a": float(Va),
"sigma_e": float(Ve),
"n_obs": m,
"n_individuals": n,
"n_base": n_base,
"n_with_parents": n_with_parents,
"n_fixed": n_fixed,
"converged": em_ok and cg_ok,
"n_iter": em_it,
"solver": "sparse-em",
"cg_iter": cg_iter,
"reliability_approx": n > EXACT_DIAG_N,
"warning": warning,
}
# 稠密分支(n ≤ N_SPARSE):重建等价 Z / ZtZbincount 已给各体观测计数)
Z = np.zeros((m, n)) Z = np.zeros((m, n))
for k, ind in enumerate(obs_ids): for k, ind in enumerate(obs_ids):
Z[k, idx[ind]] = 1.0 Z[k, idx[ind]] = 1.0
XtX = X.T @ X ZtZ = np.diag(ZtZ_diag)
Xty = X.T @ y
Zty = Z.T @ y
ZtZ = Z.T @ Z
XtZ = X.T @ Z
# A7 阈值分派:个体数 > N_SPARSE 且系谱存在 → 稀疏 A⁻¹ + 共轭梯度迭代求解
# (回避 O(n³) 稠密 MME 逆);可靠性用 Hutchinson 随机探测估计 C22 对角线。
use_sparse = n_with_parents > 0 and n > N_SPARSE
if use_sparse:
A, ainv_ii, ainv_jj, ainv_vv = _build_ainv_sparse(order, n_base, parent_of)
else:
A, Ainv = _build_ainv(order, n_base, parent_of)
def _reml_ll(Va: float, Ve: float) -> float: def _reml_ll(Va: float, Ve: float) -> float:
"""精确 REML 对数似然(直接 V 计算,O(m³))。""" """精确 REML 对数似然(直接 V 计算,O(m³))。"""
@@ -456,85 +705,6 @@ def solve(pedigree: list[dict], phenotypes: dict, *, tol: float = TOL,
_, lx = np.linalg.slogdet(XtVinvX) _, lx = np.linalg.slogdet(XtVinvX)
return -0.5 * (float(lv) + float(lx) + yPy) return -0.5 * (float(lv) + float(lx) + yPy)
if use_sparse:
def _matvec(lam: float):
def mv(v: np.ndarray) -> np.ndarray:
b, u = v[:p], v[p:]
out1 = XtX @ b + XtZ @ u
out2 = XtZ.T @ b + ZtZ @ u
np.add.at(out2, ainv_ii, lam * ainv_vv * u[ainv_jj])
return np.concatenate([out1, out2])
return mv
mme_rhs = np.concatenate([Xty, Zty])
best = {"h2": None, "ll": -np.inf, "Va": 0.0, "Ve": 0.0}
def _profile(h2: float) -> float:
lam = (1.0 - h2) / h2
sol, _it, _ok = _cg_solve(_matvec(lam), mme_rhs)
b = sol[:p]
u = sol[p:]
yPy = float(y @ y - b @ Xty - u @ Zty)
Ve = max(yPy / max(m - p, 1), _FLOOR)
Va = Ve / lam
ll = _reml_ll(Va, Ve)
if ll > best["ll"]:
best.update(h2=h2, ll=ll, Va=Va, Ve=Ve)
return ll
h2_opt, n_iter = _golden_max(_profile, H2_MIN, H2_MAX, tol=tol)
_profile(h2_opt)
h2 = best["h2"] if best["h2"] is not None else h2_opt
Va, Ve = best["Va"], best["Ve"]
lam = (1.0 - h2) / h2
mv = _matvec(lam)
sol, cg_iter, cg_ok = _cg_solve(mv, mme_rhs)
u = sol[p:]
# HutchinsonE[P⊙(C22·P)] = diag(C22)C22·P 由 CG 解 big·x=[0;P] 取 x[p:] 得到
rng = np.random.RandomState(20260804)
diag_c22 = np.zeros(n)
for _ in range(HUTCH_K):
pv = rng.choice([-1.0, 1.0], size=n)
rhs2 = np.zeros(p + n)
rhs2[p:] = pv
sol2, _it2, _ok2 = _cg_solve(mv, rhs2)
diag_c22 += sol2[p:] * pv
diag_c22 /= HUTCH_K
ebv = {ind: float(u[idx[ind]]) for ind in order}
pev = diag_c22 * Ve
rel = {ind: float(np.clip(1.0 - pev[idx[ind]] / Va, 0.0, 1.0)) for ind in order}
warning = None
if n_iter >= MAX_PROFILE_EVALS:
warning = "剖面 REML 未完全收敛(似然面极平/边界最优),结果已采用已探明最优。"
if not cg_ok:
resid = mme_rhs - mv(sol)
rel_res = float(np.linalg.norm(resid) / (np.linalg.norm(mme_rhs) + 1e-12))
w = (f"共轭梯度 {cg_iter} 次迭代未达收敛阈值(相对残差 {rel_res:.2e}),"
"结果采用已探明最优,可靠性为 Hutchinson 近似。")
warning = f"{warning} {w}" if warning else w
return {
"ebv": ebv,
"reliability": rel,
"h2": float(h2),
"sigma_a": float(Va),
"sigma_e": float(Ve),
"n_obs": m,
"n_individuals": n,
"n_base": n_base,
"n_with_parents": n_with_parents,
"n_fixed": n_fixed,
"converged": n_iter < MAX_PROFILE_EVALS and cg_ok,
"n_iter": n_iter,
"solver": "sparse-cg",
"cg_iter": cg_iter,
"reliability_approx": True,
"warning": warning,
}
def _mme_solve(lam: float) -> tuple[np.ndarray, np.ndarray]: def _mme_solve(lam: float) -> tuple[np.ndarray, np.ndarray]:
big = np.zeros((p + n, p + n)) big = np.zeros((p + n, p + n))
big[:p, :p] = XtX big[:p, :p] = XtX
+275
View File
@@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
"""生成《桃育种观测业务梳理》Word 文档(微软雅黑,含表格与页码)。
运行:C:/ai/miniconda3/envs/dpb/python.exe backend/scripts/gen_obs_doc.py
输出:doc/桃育种观测业务梳理.docx
"""
import os
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
FONT = "微软雅黑"
DARK = RGBColor(0x1F, 0x3B, 0x5C)
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.path.join(ROOT, "doc", "桃育种观测业务梳理.docx")
def set_run(run, size=10.5, bold=False, color=None):
run.font.name = FONT
run.font.size = Pt(size)
run.font.bold = bold
if color is not None:
run.font.color.rgb = color
run._element.rPr.rFonts.set(qn("w:eastAsia"), FONT)
def setup_styles(doc):
normal = doc.styles["Normal"]
normal.font.name = FONT
normal.font.size = Pt(10.5)
normal._element.rPr.rFonts.set(qn("w:eastAsia"), FONT)
for i in range(1, 7):
st = doc.styles["Heading %d" % i]
st.font.name = FONT
st.font.bold = True
st.font.color.rgb = DARK
st.font.size = Pt(max(16 - i * 1.5, 11))
st._element.rPr.rFonts.set(qn("w:eastAsia"), FONT)
def add_title(doc, text, subtitle):
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run(text)
set_run(r, size=20, bold=True, color=DARK)
p.paragraph_format.space_after = Pt(4)
p2 = doc.add_paragraph()
p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
r2 = p2.add_run(subtitle)
set_run(r2, size=11, color=RGBColor(0x60, 0x60, 0x60))
p2.paragraph_format.space_after = Pt(14)
def heading(doc, text, level):
p = doc.add_heading(text, level=level)
for r in p.runs:
set_run(r, bold=True)
p.paragraph_format.space_before = Pt(10 if level <= 2 else 6)
p.paragraph_format.space_after = Pt(4)
return p
def para(doc, text, size=10.5, bold=False, style=None, space_after=6):
p = doc.add_paragraph(style=style)
if text:
r = p.add_run(text)
set_run(r, size=size, bold=bold)
p.paragraph_format.space_after = Pt(space_after)
p.paragraph_format.line_spacing = 1.35
return p
def shade_cell(cell, hexcolor):
tcPr = cell._tc.get_or_add_tcPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), hexcolor)
tcPr.append(shd)
def table(doc, headers, rows, col_widths=None, size=9.5, header_fill="DCE6F1"):
t = doc.add_table(rows=1, cols=len(headers))
t.style = "Table Grid"
t.alignment = WD_TABLE_ALIGNMENT.CENTER
for i, h in enumerate(headers):
cell = t.rows[0].cells[i]
p = cell.paragraphs[0]
r = p.add_run(h)
set_run(r, size=size, bold=True)
p.paragraph_format.space_after = Pt(0)
shade_cell(cell, header_fill)
for row in rows:
cells = t.add_row().cells
for i, v in enumerate(row):
p = cells[i].paragraphs[0]
r = p.add_run(str(v))
set_run(r, size=size)
p.paragraph_format.space_after = Pt(0)
if col_widths:
for i, w in enumerate(col_widths):
for row in t.rows:
row.cells[i].width = Cm(w)
sp = doc.add_paragraph()
sp.paragraph_format.space_after = Pt(2)
return t
def add_page_number(section):
footer = section.footer
p = footer.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run()
f1 = OxmlElement("w:fldChar"); f1.set(qn("w:fldCharType"), "begin")
it = OxmlElement("w:instrText"); it.set(qn("xml:space"), "preserve"); it.text = "PAGE"
f2 = OxmlElement("w:fldChar"); f2.set(qn("w:fldCharType"), "end")
r._r.append(f1); r._r.append(it); r._r.append(f2)
set_run(r, size=9, color=RGBColor(0x80, 0x80, 0x80))
def main():
doc = Document()
setup_styles(doc)
sec = doc.sections[0]
sec.page_width, sec.page_height = Cm(21.0), Cm(29.7)
sec.left_margin = sec.right_margin = Cm(2.2)
sec.top_margin = sec.bottom_margin = Cm(2.2)
add_page_number(sec)
add_title(doc, "桃育种系统 · 育种观测业务梳理", "2026-08-06 · 内部梳理稿 v1.0")
# 1 背景与目的
heading(doc, "1 背景与目的", 1)
para(doc, "本次梳理源于一个具体问题:单株评价的新增/编辑表单中,「基本信息」之外的六个标签页(基本鉴定、果实外观、果皮、果实大小、果肉、果核)全部显示“暂无性状”,无法输入和修改。")
para(doc, "排查后发现这并非单个页面 bug,而是性状字典、观测数据模型与前端消费三者之间系统性不一致的暴露:库里现存 20 条统计性状的分类(产量/品质/果形/营养/抗性/物候),与表单六个标签页所用的官方描述字典(43 条,六大类)不匹配,导致标签页空转。")
para(doc, "本文档把围绕“观测”的现状梳理清楚:数据模型、核心表关系、三类观测切片、两条繁殖路径、扩繁追溯、性能、性状字典现状与断裂、已知缺口与待定决策,为后续方案设计提供一份完整、可复用的依据。")
# 2 长表设计
heading(doc, "2 观测数据模型:长表设计", 1)
para(doc, "核心观测表为 bre_trait_observation,采用“长表”(tidy)设计:每(单株 × 性状 × 年份)一行,一个性状一条记录。")
table(doc,
["对比维度", "长表(当前)", "宽表(一性状一列)"],
[["数据结构", "每行一个观测值 + trait_id 外键", "每个性状一个列"],
["性状可扩展", "加性状 = 加字典行,无需改表", "加性状 = 加列,需 DDL 迁移"],
["页面/统计解耦", "页面按 trait 过滤即可", "列结构绑定页面"],
["硬编码风险", "低(性状走字典)", ""],
["记录规模", "单表 358 万", "同规模列更多"]],
col_widths=[3.5, 6.5, 6.5])
para(doc, "为什么必须长表:育种性状集合逐年演进(新性状、新标准、分子性状),宽表每次演进都要迁移;长表加一行字典即可,统计引擎按 data_type=='numeric' 直接消费。代价是记录量大,但见第 6 节,性能可用索引兜住。")
# 3 四张核心表
heading(doc, "3 四张核心表的关系", 1)
heading(doc, "3.1 单株评价 bre_tree_evaluation(主表)", 2)
para(doc, "一棵单株在某一年的一次评价(“头”)。记录评价年份、总评、负载量、评价人等信息。一个评价头对应一批性状观测明细。")
heading(doc, "3.2 性状观测 bre_trait_observation(明细表)", 2)
para(doc, "评价的“细目”,全系统唯一承载 358 万条观测的表。每行一个(单株 × 性状 × 年份)观测值,三态值列并存:")
para(doc, "· value_numeric —— 数值型(消费面:统计引擎)", style="List Bullet")
para(doc, "· value_text —— 文本 / 等级描述", style="List Bullet")
para(doc, "· value_date —— 日期型(物候等)", style="List Bullet")
heading(doc, "3.3 通用观测 bre_observation(独立轻量记录)", 2)
para(doc, "与“单株评价”解耦的散点式观测(园区环境、非评价场景的随记),trait 可空,不进主细结构。")
heading(doc, "3.4 性状字典 bre_trait", 2)
para(doc, "统一性状字典:code、中文名、unit、category、data_type、stage、方向(升/降)、是否入 EBV、h²、权重等。消费面是 data_type=='numeric' 的统计引擎。")
heading(doc, "3.5 主细关系", 2)
table(doc,
["要点", "约定"],
[["基数", "bre_tree_evaluation 1 —— N bre_trait_observation"],
["外键", "evaluation_idCASCADE(删评价头连带删明细)"],
["唯一约束", "UNIQUE(evaluation_id, trait_id):一个评价下同一性状只允许一条"]],
col_widths=[3.5, 13.0])
# 4 三类切片
heading(doc, "4 观测数据的三类业务切片", 1)
para(doc, "观测行按「是否有评价头(evaluation_id)× stage」切为三类,合计 3,583,584 条。")
table(doc,
["切片", "判定", "语义", "记录数", "备注"],
[["A 随评", "evaluation_id 非空", "随单株评价录入的性状观测", "731,904", "45,744 个评价头 × 16 条/头,0 孤儿"],
["B 童期", "evaluation_id IS NULL + stage='juvenile'", "童期幼树的观测", "1,148,160", "无评价头,独立批次"],
["C 克隆苗", "evaluation_id IS NULL + stage='evaluation'", "扩繁产出无性系苗的观测", "1,703,520", "每克隆 4 棵 ramet"]],
col_widths=[1.8, 5.0, 4.4, 2.2, 4.0])
para(doc, "A 与 B/C 的判定只差一个字段是否为空,是历史演进留下的两类形态,方案阶段需决策是否统一(见第 9 节决策 3)。")
# 5 育种流程全景
heading(doc, "5 育种流程全景", 1)
heading(doc, "5.1 有性繁殖路径(实生苗)", 2)
para(doc, "杂交(cross_combination)→ 采粉(pollen)→ 授粉(pollination)→ 种子批(seed_lot)→ 种子处理(seed_treatment)→ 育苗(seedling)→ 种植(planting)→ 单株(tree)。种子苗是实生后代,遗传上每株独一无二,是选育的主体。")
heading(doc, "5.2 无性繁殖路径(克隆苗)", 2)
para(doc, "入选单株(selection_result)→ 晋级为克隆(tree_generation='clone')→ 扩繁(propagation,嫁接/扦插)→ 产出无性系苗 ramet(每克隆 4 棵)→ 克隆苗观测。克隆苗与母株基因型相同,用于性状复测、对比试验、示范与推广。")
heading(doc, "5.3 育苗 vs 克隆", 2)
table(doc,
["维度", "育苗(有性)", "克隆扩繁(无性)"],
[["后代来源", "种子批 seed_lot", "接穗 scion_source(原株)"],
["遗传构成", "实生后代,各不相同", "与母株基因型相同"],
["用途", "选育新材料", "复测 / 示范 / 推广"],
["观测归属", "单株 / 童期", "C 类克隆苗观测"]],
col_widths=[3.5, 6.5, 6.5])
heading(doc, "5.4 扩繁追溯", 2)
para(doc, "扩繁批次(bre_propagation)通过两个外键形成完整链路:")
para(doc, "· scion_source_id —— 接穗来源(germplasm 或 tree 原株)", style="List Bullet")
para(doc, "· produced_clone_id —— 产出的克隆(4 棵 ramet)", style="List Bullet")
para(doc, "· 编号自描述:tree_no 形如“{品种码}-{序号}R{棵序}”,仅凭编号即可追溯血缘与棵序。", style="List Bullet")
para(doc, "追溯链:接穗原株 → 扩繁批次 → 产出克隆 → ramet 四棵 → 各棵观测。")
para(doc, "验证结果:扩繁 16,800 条,100% 有来源与产出,逐条可回查,无孤儿记录。")
heading(doc, "5.5 审定推广后的边界", 2)
para(doc, "品种审定(released)后,观测止于推广环节;生产果园的商业化栽植不再进入观测表。当前无销售/推广数据的落点(见缺口 C)。")
# 6 性能
heading(doc, "6 性能保障", 1)
para(doc, "记录量 318 万,表体约 990 MB,11 个索引。实测:")
table(doc,
["场景", "实测耗时", "结论"],
[["前端按单株分页取观测", "≈ 0.39 ms", "数据库层面不是瓶颈"],
["全性状统计取数", "≈ 802 ms", "索引足以支撑"]],
col_widths=[6.5, 3.5, 6.5])
para(doc, "统计引擎的规模瓶颈已随稀疏重构消除:索引化设计矩阵(无稠密 Z)、稀疏 A⁻¹(无稠密 A)、共轭梯度求解,全程 O(n) 内存。个体数 ≤ N_EXACT=6000 用精确迹二分求 λ*(逐列 CG 精确 tr(A⁻¹C²²));> N_EXACT 用随机子样本(≤3000 观测 + 祖先闭包)REMl 估计方差组分(抽样 SE h² ≈ ±0.04,h²/σ² 报告值视为近似区间),再全群 MME 于子样本 λ 下解 EBV。36,720 棵树单性状 ABLUP 在 14 GiB 本机完整跑通不 OOM。")
# 7 性状字典现状与断裂
heading(doc, "7 性状字典现状与断裂", 1)
heading(doc, "7.1 现状", 2)
para(doc, "库里现存 20 条统计性状(分类为产量/品质/果形/营养/抗性/物候),由 ensure_reference 幂等回填;原始 43 条官方描述字典(bre_trait_seed.sql,六大类:基本鉴定/果实外观/果皮/果实大小/果肉/果核)在 wipe 重建时被清空,未回填。")
heading(doc, "7.2 断裂点", 2)
table(doc,
["断裂点", "现象", "根因"],
[["单株评价表单", "6 个 tab 全“暂无性状”,无法录入", "表单按官方字典分类,库里只有 20 条 stats 分类"],
["DUS", "DUS 引用 trait_codegrowth_vigor 等)找不到性状", "描述字典未回填"],
["抗病/需冷量种子", "抗病性 / 生态适应性种子性状缺失", "wipe 未回填 bre_disease_chilling_traits"],
["通用观测下拉", "显示英文 label", "getTraitOptions 返回 label=trait_code"]],
col_widths=[3.4, 6.5, 6.5])
para(doc, "三套来源(stats seed / 官方描述 dict / DUS-抗病 seed)互相覆盖、未统一归口,是断裂的总根因。")
# 8 已知缺口
heading(doc, "8 已知缺口清单", 1)
table(doc,
["缺口", "描述", "现状", "建议方向"],
[["A 童期一年多次测定", "观测表只有 evaluate_year,一年至多一条;童期生长量真实一年多次(如春秋两次)", "无 evaluate_date", "补日期字段或次数维度"],
["B 扩繁接穗来源手填 ID", "表单 scion_source_id 为裸数字输入,靠人工记忆原株 ID", "易错、无校验", "改为下拉,限定 germplasm/tree 来源"],
["C 审定后无销售环节", "观测止于 released,推广量 / 销售无落点", "数据边界", "增加推广/销售记录(或明确不录)"],
["D 通用观测下拉英文", "getTraitOptions 返回英文 trait_code 作 label", "体验差", "返回中文名"]],
col_widths=[3.2, 6.3, 3.0, 4.0])
# 9 待定决策
heading(doc, "9 待定决策", 1)
table(doc,
["决策", "选项", "说明"],
[["决策 1:标签页结构", "官方 6 类 tab / 动态 category 分组", "决定表单录入布局,需与 43 条字典匹配"],
["决策 2:描述性状模拟值", "回填 43 条种子(含模拟值)/ 仅建字典不造值", "决定表单是否立即可用"],
["决策 3:童期观测归属", "B 童期观测归入单株评价流程 / 独立童期页面", "决定 B 类 115 万条在哪里维护"],
["决策 4:移动端采集入口", "本次接入 / 后续迭代", "倾向移动端采集,按评价保存"]],
col_widths=[4.2, 6.3, 6.0])
# 附录
heading(doc, "附录 数据口径(2026-08-06 实测)", 1)
table(doc,
["指标", "数值"],
[["观测总行数", "3,583,584A 731,904 + B 1,148,160 + C 1,703,520"],
["A 随评构成", "45,744 个评价头 × 16 条/头 = 731,9040 孤儿"],
["C 克隆苗构成", "每克隆 4 棵 ramet17 性状 × 2 年 → 136 条/克隆"],
["扩繁批次", "16,800 条,100% 有来源与产出,无孤儿"],
["表体规模", "约 1,129 MB / 9 索引"],
["前端分页查询", "≈ 0.39 ms"],
["全性状统计取数", "≈ 802 ms"],
["模拟规模", "F1 200 组合 → 约 120k 棵树"],
["ABLUP 全量内存", "稠密 Z(84000×86792) ≈ 54.3 GiB,苗高 ≈ 110 GiB → OOM"]],
col_widths=[6.5, 10.0])
doc.save(OUT)
print("OK ->", OUT)
print("size =", os.path.getsize(OUT), "bytes")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,8 @@
# 桃育种系统 · 统计引擎实施记录 # 桃育种系统 · 统计引擎实施记录
> **版本**v1.42026-08-04 追加统计严谨·预测完整性与 MLOps 复现性:PA 落库 / 溯源 / germplasm_id 填充 / 砧木扩列 / Calo 相关校正 / 空间竞争协变量 + 组合得失漏斗) > **版本**v1.42026-08-04 追加统计严谨·预测完整性与 MLOps 复现性:PA 落库 / 溯源 / germplasm_id 填充 / 砧木扩列 / Calo 相关校正 / 空间竞争协变量 + 组合得失漏斗)
> **定位**:记录统计引擎多轮 P0/P1 落地 + 田间刚需(花粉档案)+ 决策正确性(无性系级指数)+ 统计严谨(配合力交配设计 / 相关校正 / 空间竞争)+ MLOps 复现性 + 组合得失漏斗的实现、验证与文件清单——**G×E 交互引擎**(2026-08-03)、**性状方向标注**2026-08-04)、**花粉档案**2026-08-04)、**无性系级指数**2026-08-04)、**配合力交配设计**2026-08-04)、**统计严谨·MLOps 复现性**2026-08-04,①Calo②PA③溯源④germplasm_id⑤砧木⑥空间竞争)与**组合得失漏斗**2026-08-04,⑧ v_combination_funnel)。本文档为持续追加的落地台账,与规格文档 `桃育种系统模块扩展需求规格.md`(v2.10)配套;规格管"应该长什么样",本文管"实际落地了什么、怎么验证的"。 > **定位**:记录统计引擎多轮 P0/P1 落地 + 田间刚需(花粉档案)+ 决策正确性(无性系级指数)+ 统计严谨(配合力交配设计 / 相关校正 / 空间竞争)+ MLOps 复现性 + 组合得失漏斗的实现、验证与文件清单——**G×E 交互引擎**(2026-08-03)、**性状方向标注**2026-08-04)、**花粉档案**2026-08-04)、**无性系级指数**2026-08-04)、**配合力交配设计**2026-08-04)、**统计严谨·MLOps 复现性**2026-08-04,①Calo②PA③溯源④germplasm_id⑤砧木⑥空间竞争)与**组合得失漏斗**2026-08-04,⑧ v_combination_funnel)。本文档为持续追加的落地台账,与规格文档 `桃育种系统模块扩展需求规格.md`(v2.10)配套;规格管"应该长什么样",本文管"实际落地了什么、怎么验证的"。 配套《桃育种系统业务链路与数据模型说明书》v1.1(2026-08-06 重生成,已同步 O1 花期数据消费补强 / N4 plan→trial→target 血缘 / 亲本校验 / 世代闭环落库 / 新增 §九 QA 与 CI 章节)与代码保持一致,三者(规格 + 实施记录 + 说明书)互为索引。
> **相关探针**`Temp/claude/e2e_gxe_20260803.py`G×E5 场景)· `Temp/claude/e2e_direction_20260804.py`(方向标注,7 组)· `Temp/claude/e2e_pollen_20260804.py`(花粉档案)· `Temp/claude/e2e_clone_index_20260804.py`(无性系级指数,6 组)· `Temp/claude/e2e_combining_design_20260804.py`(配合力交配设计,5 组)· `Temp/claude/e2e_prediction_rigor_20260804.py`(②③④,4 组)· `Temp/claude/e2e_rootstock_20260804.py`(⑤)· `Temp/claude/e2e_corr_spatial_20260804.py`(①⑥,3 组)· `Temp/claude/e2e_combination_funnel_20260804.py`(组合得失漏斗,3 组)。 > **相关探针**`Temp/claude/e2e_gxe_20260803.py`G×E5 场景)· `Temp/claude/e2e_direction_20260804.py`(方向标注,7 组)· `Temp/claude/e2e_pollen_20260804.py`(花粉档案)· `Temp/claude/e2e_clone_index_20260804.py`(无性系级指数,6 组)· `Temp/claude/e2e_combining_design_20260804.py`(配合力交配设计,5 组)· `Temp/claude/e2e_prediction_rigor_20260804.py`(②③④,4 组)· `Temp/claude/e2e_rootstock_20260804.py`(⑤)· `Temp/claude/e2e_corr_spatial_20260804.py`(①⑥,3 组)· `Temp/claude/e2e_combination_funnel_20260804.py`(组合得失漏斗,3 组)。 · `Temp/claude/e2e_o1_bloom_20260805.py`(O1 花期数据消费补强:四态软警示 + 窗口覆盖三态 + 库存五态 + 反向门禁,4 组)
--- ---
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -33,6 +33,9 @@ export interface SanitizeOutputOptions {
/** 传递给组件时需排除的表单配置属性 */ /** 传递给组件时需排除的表单配置属性 */
const ROOT_PROPS = ["label", "labelWidth", "key", "type", "hidden", "span", "slots"]; const ROOT_PROPS = ["label", "labelWidth", "key", "type", "hidden", "span", "slots"];
/** 选项超过该数量时自动切换为虚拟滚动选择器(ElSelectV2),避免渲染数万个 el-option 卡死页面 */
const VIRTUAL_SELECT_THRESHOLD = 2000;
/** 日期选择器类型列表(getProps 中用于传递 type 到 FaDatePicker */ /** 日期选择器类型列表(getProps 中用于传递 type 到 FaDatePicker */
const DATE_PICKER_TYPES = ["date", "daterange", "datetime", "datetimerange", "monthrange"]; const DATE_PICKER_TYPES = ["date", "daterange", "datetime", "datetimerange", "monthrange"];
@@ -167,6 +170,18 @@ export const getProps = (item: FormItemBase): Record<string, any> => {
return props; return props;
}; };
/**
* 大选项集 select 是否切换为虚拟滚动(ElSelectV2)。
* 有自定义插槽 / render 的表单项保持原样,避免破坏自定义选项渲染。
*/
export const isVirtualizedSelect = (item: FormItemBase): boolean => {
if (item.type !== "select" || item.slots) return false;
const props = getProps(item);
if (props?.default) return false;
const opts = props?.options;
return Array.isArray(opts) && opts.length > VIRTUAL_SELECT_THRESHOLD;
};
/** /**
* 获取插槽 —— 过滤掉未定义的插槽 * 获取插槽 —— 过滤掉未定义的插槽
*/ */
@@ -40,8 +40,10 @@
@update:model-value="setFieldValue(item.key, $event)" @update:model-value="setFieldValue(item.key, $event)"
v-bind="getProps(item)" v-bind="getProps(item)"
> >
<!-- 下拉选择 --> <!-- 下拉选择大选项集自动走虚拟滚动 ElSelectV2选项经 props 传入 -->
<template v-if="item.type === 'select' && getProps(item)?.options"> <template
v-if="item.type === 'select' && getProps(item)?.options && !isVirtualizedSelect(item)"
>
<ElOption <ElOption
v-for="option in getProps(item).options" v-for="option in getProps(item).options"
v-bind="option" v-bind="option"
@@ -137,7 +139,9 @@
@update:model-value="setFieldValue(item.key, $event)" @update:model-value="setFieldValue(item.key, $event)"
v-bind="getProps(item)" v-bind="getProps(item)"
> >
<template v-if="item.type === 'select' && getProps(item)?.options"> <template
v-if="item.type === 'select' && getProps(item)?.options && !isVirtualizedSelect(item)"
>
<ElOption <ElOption
v-for="option in getProps(item).options" v-for="option in getProps(item).options"
v-bind="option" v-bind="option"
@@ -205,13 +209,14 @@
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { type Component } from "vue"; import { type Component } from "vue";
import FaDatePicker from "@/components/forms/fa-search-bar/FaDatePicker.vue"; import FaDatePicker from "@/components/forms/fa-search-bar/FaDatePicker.vue";
import {ElCascader, ElCheckbox, ElCheckboxGroup, ElInput, ElInputTag, ElInputNumber, ElRadioGroup, ElRate, ElSelect, ElSlider, ElSwitch, ElTimePicker, ElTimeSelect, ElTreeSelect, type FormInstance} from "element-plus"; import {ElCascader, ElCheckbox, ElCheckboxGroup, ElInput, ElInputTag, ElInputNumber, ElRadioGroup, ElRate, ElSelect, ElSelectV2, ElSlider, ElSwitch, ElTimePicker, ElTimeSelect, ElTreeSelect, type FormInstance} from "element-plus";
import { import {
cloneModelValue as cloneModelValueShared, cloneModelValue as cloneModelValueShared,
sanitizeOutputValue as sanitizeOutputValueShared, sanitizeOutputValue as sanitizeOutputValueShared,
getProps as getPropsShared, getProps as getPropsShared,
getSlots as getSlotsShared, getSlots as getSlotsShared,
getColSpan as getColSpanShared, getColSpan as getColSpanShared,
isVirtualizedSelect,
useSanitizeOutputOptions, useSanitizeOutputOptions,
type SanitizeOutputOptions, type SanitizeOutputOptions,
} from "../composables/useFormBase"; } from "../composables/useFormBase";
@@ -425,6 +430,10 @@ const getComponent = (item: FormItem) => {
if (item.render) { if (item.render) {
return item.render; return item.render;
} }
// 大选项集自动切换虚拟滚动选择器,避免渲染数万个 el-option 卡死页面
if (isVirtualizedSelect(item)) {
return ElSelectV2;
}
// 使用 type 获取预定义组件 // 使用 type 获取预定义组件
const { type } = item; const { type } = item;
const comp = componentMap[type as keyof typeof componentMap]; const comp = componentMap[type as keyof typeof componentMap];
@@ -66,8 +66,10 @@
@update:model-value="setFieldValue(item.key, $event)" @update:model-value="setFieldValue(item.key, $event)"
v-bind="getProps(item)" v-bind="getProps(item)"
> >
<!-- 下拉选择 --> <!-- 下拉选择(大选项集自动走虚拟滚动 ElSelectV2,选项经 props 传入) -->
<template v-if="item.type === 'select' && getProps(item)?.options"> <template
v-if="item.type === 'select' && getProps(item)?.options && !isVirtualizedSelect(item)"
>
<ElOption <ElOption
v-for="option in getProps(item).options" v-for="option in getProps(item).options"
v-bind="option" v-bind="option"
@@ -169,13 +171,14 @@ import {
getAuditSearchFormItems, getAuditSearchFormItems,
type GetAuditSearchFormItemsOptions, type GetAuditSearchFormItemsOptions,
} from "./auditSearchFormItems"; } from "./auditSearchFormItems";
import {ElCascader, ElCheckbox, ElCheckboxGroup, ElInput, ElInputTag, ElInputNumber, ElRadioGroup, ElRate, ElSelect, ElSlider, ElSwitch, ElTimePicker, ElTimeSelect, ElTreeSelect, type FormInstance} from "element-plus"; import {ElCascader, ElCheckbox, ElCheckboxGroup, ElInput, ElInputTag, ElInputNumber, ElRadioGroup, ElRate, ElSelect, ElSelectV2, ElSlider, ElSwitch, ElTimePicker, ElTimeSelect, ElTreeSelect, type FormInstance} from "element-plus";
import { import {
cloneModelValue as cloneModelValueShared, cloneModelValue as cloneModelValueShared,
sanitizeOutputValue as sanitizeOutputValueShared, sanitizeOutputValue as sanitizeOutputValueShared,
getProps as getPropsShared, getProps as getPropsShared,
getSlots as getSlotsShared, getSlots as getSlotsShared,
getColSpan as getColSpanShared, getColSpan as getColSpanShared,
isVirtualizedSelect,
useSanitizeOutputOptions, useSanitizeOutputOptions,
type SanitizeOutputOptions, type SanitizeOutputOptions,
} from "../composables/useFormBase"; } from "../composables/useFormBase";
@@ -379,6 +382,10 @@ const getComponent = (item: SearchFormItem) => {
if (item.render) { if (item.render) {
return item.render; return item.render;
} }
// 大选项集自动切换虚拟滚动选择器,避免渲染数万个 el-option 卡死页面
if (isVirtualizedSelect(item)) {
return ElSelectV2;
}
// 使用 type 获取预定义组件 // 使用 type 获取预定义组件
const { type } = item; const { type } = item;
return componentMap[type as keyof typeof componentMap] || componentMap["input"]; return componentMap[type as keyof typeof componentMap] || componentMap["input"];
+9 -1
View File
@@ -199,7 +199,15 @@ export const request: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API, baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: Number(import.meta.env.VITE_API_TIMEOUT) || 15000, timeout: Number(import.meta.env.VITE_API_TIMEOUT) || 15000,
headers: { "Content-Type": "application/json;charset=utf-8" }, headers: { "Content-Type": "application/json;charset=utf-8" },
paramsSerializer: (params) => qs.stringify(params, { indices: false }), // qs 会把 null 序列化成空串(?year=),后端 int 参数解析会 422null/undefined 应直接省略
paramsSerializer: (params) => {
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params ?? {})) {
if (value === null || value === undefined) continue;
cleaned[key] = value;
}
return qs.stringify(cleaned, { indices: false });
},
}); });
request.interceptors.request.use( request.interceptors.request.use(
@@ -1349,15 +1349,11 @@
<el-select v-model="mabcBgPanels" multiple collapse-tags placeholder="背景恢复面板全基因组标记" style="min-width: 220px"> <el-select v-model="mabcBgPanels" multiple collapse-tags placeholder="背景恢复面板全基因组标记" style="min-width: 220px">
<el-option v-for="p in masPanels" :key="p.id" :label="p.panel_name" :value="p.id" /> <el-option v-for="p in masPanels" :key="p.id" :label="p.panel_name" :value="p.id" />
</el-select> </el-select>
<el-select v-model="mabcRpTree" placeholder="轮回亲本树" style="min-width: 170px"> <el-select-v2 v-model="mabcRpTree" :options="treeOptions" placeholder="轮回亲本树" style="min-width: 170px" />
<el-option v-for="t in treeOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-button :loading="treeOptLoading" @click="loadTreeOptions">刷新树</el-button> <el-button :loading="treeOptLoading" @click="loadTreeOptions">刷新树</el-button>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<el-select v-model="mabcCands" multiple collapse-tags placeholder="候选回交单株BC 分离群体" style="min-width: 300px"> <el-select-v2 v-model="mabcCands" :options="treeOptions" multiple collapse-tags placeholder="候选回交单株BC 分离群体" style="min-width: 300px" />
<el-option v-for="t in treeOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<span style="color:#909399; font-size:12px">前景阈值</span> <span style="color:#909399; font-size:12px">前景阈值</span>
<el-input-number v-model="mabcMinHits" :min="1" style="width: 90px" /> <el-input-number v-model="mabcMinHits" :min="1" style="width: 90px" />
<span style="color:#909399; font-size:12px">当前世代</span> <span style="color:#909399; font-size:12px">当前世代</span>
@@ -2435,7 +2431,7 @@ async function loadAnova() {
} }
async function loadTrialStudies() { async function loadTrialStudies() {
try { try {
const res: any = await TrialStudyAPI.getTrialStudyList({ page_no: 1, page_size: 200 } as any); const res: any = await TrialStudyAPI.getTrialStudyList({ page_no: 1, page_size: 100 } as any);
trialStudies.value = (res?.data?.data?.items ?? []) as Array<{ id: number; study_name: string; block_count?: number }>; trialStudies.value = (res?.data?.data?.items ?? []) as Array<{ id: number; study_name: string; block_count?: number }>;
} catch (e: any) { } catch (e: any) {
msg.error(e?.message || '研究点列表加载失败'); msg.error(e?.message || '研究点列表加载失败');
@@ -306,7 +306,7 @@ function displayValue(trait: TraitTable): string {
async function loadTraits() { async function loadTraits() {
if (traitsLoaded.value) return; if (traitsLoaded.value) return;
try { try {
const res = await TraitAPI.getTraitList({ page_no: 1, page_size: 500 }); const res = await TraitAPI.getTraitList({ page_no: 1, page_size: 100 });
if (res.data.code === ResultEnum.SUCCESS) { if (res.data.code === ResultEnum.SUCCESS) {
const list = res.data.data.items ?? []; const list = res.data.data.items ?? [];
for (const t of TRAIT_TABS) traitsByCategory[t] = []; for (const t of TRAIT_TABS) traitsByCategory[t] = [];
@@ -326,7 +326,7 @@ async function loadObservations(evaluationId: number) {
const res = await TraitObservationAPI.getTraitObservationList({ const res = await TraitObservationAPI.getTraitObservationList({
evaluation_id: evaluationId, evaluation_id: evaluationId,
page_no: 1, page_no: 1,
page_size: 500, page_size: 100,
}); });
if (res.data.code === ResultEnum.SUCCESS) { if (res.data.code === ResultEnum.SUCCESS) {
const list = res.data.data.items ?? []; const list = res.data.data.items ?? [];
@@ -856,7 +856,7 @@ async function syncObservations(evaluationId: number) {
const existing = await TraitObservationAPI.getTraitObservationList({ const existing = await TraitObservationAPI.getTraitObservationList({
evaluation_id: evaluationId, evaluation_id: evaluationId,
page_no: 1, page_no: 1,
page_size: 500, page_size: 100,
}); });
const ids = (existing.data.data.items ?? []).map((o) => o.id).filter((x): x is number => x != null); const ids = (existing.data.data.items ?? []).map((o) => o.id).filter((x): x is number => x != null);
if (ids.length) await TraitObservationAPI.deleteTraitObservation(ids); if (ids.length) await TraitObservationAPI.deleteTraitObservation(ids);