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:
@@ -89,6 +89,13 @@ dump.rdb
|
||||
"-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/
|
||||
backend/.venv.broken/
|
||||
@@ -26,7 +26,8 @@ import numpy as np
|
||||
from datetime import date, datetime
|
||||
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 app.core.base_schema import AuthSchema
|
||||
@@ -84,6 +85,19 @@ def _now() -> datetime:
|
||||
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 分支一致)
|
||||
_COMBINING_DESIGNS = combining.DESIGNS
|
||||
_DESIGN_LABELS = {
|
||||
@@ -438,6 +452,27 @@ class StatisticsService:
|
||||
)).all()
|
||||
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 树 → n≈1204),无关种质
|
||||
在 MME 中零贡献(无表型、与选中树无亲缘),EBV/h² 逐位不变,但 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]:
|
||||
"""为给定树集合构建系谱(树=子代、种质=亲本,t/g 前缀;含种质级祖先 bre_pedigree)。
|
||||
|
||||
@@ -456,7 +491,7 @@ class StatisticsService:
|
||||
CrossCombinationModel.male_parent_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()
|
||||
pedigree: list[dict] = []
|
||||
tree_germs: set[int] = set()
|
||||
@@ -489,13 +524,7 @@ class StatisticsService:
|
||||
cid = code2g.get(child_code)
|
||||
if cid is not None and (gd is not None or gs is not None):
|
||||
g_parent_of[cid] = (gd, gs)
|
||||
all_germ_ids = set(tree_germs)
|
||||
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)
|
||||
all_germ_ids = self._germ_ancestor_closure(tree_germs, g_parent_of)
|
||||
for g in sorted(all_germ_ids):
|
||||
if g in g_parent_of:
|
||||
gd, gs = g_parent_of[g]
|
||||
@@ -686,7 +715,7 @@ class StatisticsService:
|
||||
TreeModel.germplasm_id,
|
||||
)
|
||||
.outerjoin(CrossCombinationModel, TreeModel.combination_id == CrossCombinationModel.id)
|
||||
.where(TreeModel.id.in_(tree_ids))
|
||||
.where(_id_in(TreeModel.id, tree_ids))
|
||||
)).all()
|
||||
pedigree: list[dict] = []
|
||||
tree_germs: set[int] = set()
|
||||
@@ -739,7 +768,7 @@ class StatisticsService:
|
||||
pos_rows = (await self.db.execute(
|
||||
select(TreeModel.id, TreeModel.plot_id, TreeModel.block_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()
|
||||
grids: dict[tuple[int, int], list[tuple[int, int, int]]] = {}
|
||||
for r in pos_rows:
|
||||
@@ -776,13 +805,7 @@ class StatisticsService:
|
||||
cid = code2g.get(child_code)
|
||||
if cid is not None and (gd is not None or gs is not None):
|
||||
g_parent_of[cid] = (gd, gs)
|
||||
all_germ_ids = set(tree_germs)
|
||||
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)
|
||||
all_germ_ids = self._germ_ancestor_closure(tree_germs, g_parent_of)
|
||||
for g in sorted(all_germ_ids):
|
||||
if g in g_parent_of:
|
||||
gd, gs = g_parent_of[g]
|
||||
@@ -846,7 +869,7 @@ class StatisticsService:
|
||||
TreeModel.is_deleted.is_(False),
|
||||
# 与 site 分支对齐:只取本批次选中树,避免他树混入后
|
||||
# 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)
|
||||
)
|
||||
@@ -1093,7 +1116,7 @@ class StatisticsService:
|
||||
phenos_keyed = {f"t{tid}": v for tid, v in phenotypes.items()}
|
||||
bpos_rows = (await self.db.execute(
|
||||
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))
|
||||
)).all()
|
||||
block_of: dict[str, int] = {}
|
||||
@@ -1171,7 +1194,7 @@ class StatisticsService:
|
||||
if spatial:
|
||||
pos_rows = (await self.db.execute(
|
||||
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()
|
||||
coords: dict[str, tuple[int, int]] = {}
|
||||
missing_coord_ids: list[int] = []
|
||||
@@ -1391,7 +1414,7 @@ class StatisticsService:
|
||||
TreeModel.germplasm_id,
|
||||
)
|
||||
.outerjoin(CrossCombinationModel, TreeModel.combination_id == CrossCombinationModel.id)
|
||||
.where(TreeModel.id.in_(tree_ids))
|
||||
.where(_id_in(TreeModel.id, tree_ids))
|
||||
)).all()
|
||||
pedigree: list[dict] = []
|
||||
tree_germs: set[int] = set()
|
||||
@@ -1434,7 +1457,7 @@ class StatisticsService:
|
||||
pos_rows = (await self.db.execute(
|
||||
select(TreeModel.id, TreeModel.plot_id, TreeModel.block_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()
|
||||
grids: dict[tuple[int, int], list[tuple[int, int, int]]] = {}
|
||||
for r in pos_rows:
|
||||
@@ -1470,13 +1493,7 @@ class StatisticsService:
|
||||
cid = code2g.get(child_code)
|
||||
if cid is not None and (gd is not None or gs is not None):
|
||||
g_parent_of[cid] = (gd, gs)
|
||||
all_germ_ids = set(tree_germs)
|
||||
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)
|
||||
all_germ_ids = self._germ_ancestor_closure(tree_germs, g_parent_of)
|
||||
for g in sorted(all_germ_ids):
|
||||
if g in g_parent_of:
|
||||
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}
|
||||
trees = (await self.db.execute(
|
||||
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()
|
||||
site_of = {t.id: t.trial_study_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:
|
||||
rows = (await self.db.execute(
|
||||
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()
|
||||
tree_map = {t.id: t.clone_id for t in rows}
|
||||
@@ -2023,7 +2040,7 @@ class StatisticsService:
|
||||
clone_map: dict[int, CloneModel] = {}
|
||||
if clone_ids:
|
||||
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()
|
||||
clone_map = {c.id: c for c in clones}
|
||||
agg: dict[int, dict[str, float | int]] = {}
|
||||
@@ -2533,7 +2550,7 @@ class StatisticsService:
|
||||
pid2code = {pid: c for c, pid in batch_ids.items()}
|
||||
pv_rows = (await self.db.execute(
|
||||
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),
|
||||
)
|
||||
)).all()
|
||||
@@ -2677,7 +2694,7 @@ class StatisticsService:
|
||||
"""
|
||||
rows = (await self.db.execute(
|
||||
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()
|
||||
clone_of = {t.id: t.clone_id for t in rows}
|
||||
@@ -2687,7 +2704,7 @@ class StatisticsService:
|
||||
if clone_ids:
|
||||
clones = (await self.db.execute(
|
||||
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()
|
||||
clone_label = {c.id: c.clone_code for c in clones}
|
||||
@@ -3011,7 +3028,7 @@ class StatisticsService:
|
||||
if refs:
|
||||
pv_rows = (await self.db.execute(
|
||||
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),
|
||||
)
|
||||
)).all()
|
||||
@@ -3048,11 +3065,11 @@ class StatisticsService:
|
||||
all_tids = {t for u in units for t in u["tree_ids"]}
|
||||
tree_rows = (await self.db.execute(
|
||||
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()
|
||||
tree_map = {t.id: t for t in tree_rows}
|
||||
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()
|
||||
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))
|
||||
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()
|
||||
if not rows:
|
||||
raise CustomException(msg="无活动单株")
|
||||
@@ -3203,11 +3220,35 @@ class StatisticsService:
|
||||
raise CustomException(msg=f"性状不可用于统计(需数值型且存在): {trait_code}")
|
||||
direction = (trait_row.direction or "desc").lower()
|
||||
desirable_high = direction != "asc" # desc/None:表型越高越好;asc:越低越好(如病指)
|
||||
# 1) 单株系谱 → 深层系谱 A 矩阵 → 近交系数 F
|
||||
# 0) 先取目标性状每树表型(同株多次取均值)——近交只针对有该性状表型的单株;
|
||||
# 全库 3.6 万树建稠密 A 会 OOM(36990²×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
|
||||
# 名称反查 + tree FK 兜底 + 祖先展开,与 OCS/mating 同源)——多世代项目(半同胞共享
|
||||
# 祖亲)不再低估 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()
|
||||
if not rows:
|
||||
raise CustomException(msg="无活动单株")
|
||||
@@ -3249,27 +3290,7 @@ class StatisticsService:
|
||||
for k, v in rmat_res["inbreeding"].items():
|
||||
if k.startswith("t") and k[1:].isdigit():
|
||||
f_by_tree[int(k[1:])] = float(v)
|
||||
# 2) 目标性状每树表型(同株多次取均值)
|
||||
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="无可用表型数据(该性状为空)")
|
||||
# 2) 表型已在上方取好(agg),直接配对 F
|
||||
pairs: list[tuple[int, float, float]] = []
|
||||
for r in agg:
|
||||
f = f_by_tree.get(int(r.tree_id))
|
||||
@@ -3329,7 +3350,7 @@ class StatisticsService:
|
||||
interpretation = f"未检出近交衰退(F 对表型无{trend}影响)"
|
||||
tree_ids = [p[0] for p in pairs]
|
||||
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()
|
||||
tree_map = {tid: no for tid, no in trows}
|
||||
base.update({
|
||||
@@ -3470,7 +3491,7 @@ class StatisticsService:
|
||||
if flagged_ids:
|
||||
for tid, tno in (await self.db.execute(
|
||||
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 ""
|
||||
excluded_ids = await self._excluded_tree_ids()
|
||||
mendelian_section = {
|
||||
@@ -3665,7 +3686,7 @@ class StatisticsService:
|
||||
gs = (await self.db.execute(
|
||||
select(BreedingGermplasmModel.id, BreedingGermplasmModel.cultivar_name,
|
||||
BreedingGermplasmModel.accession_no)
|
||||
.where(BreedingGermplasmModel.id.in_(gids))
|
||||
.where(_id_in(BreedingGermplasmModel.id, gids))
|
||||
)).all()
|
||||
cand_ids = [int(g.id) for g in gs]
|
||||
if not cand_ids:
|
||||
@@ -3793,7 +3814,7 @@ class StatisticsService:
|
||||
pvs = (await self.db.execute(
|
||||
select(PredictionValueModel).where(
|
||||
PredictionValueModel.prediction_id == ebv_pred.id,
|
||||
PredictionValueModel.germplasm_id.in_(cand_ids),
|
||||
_id_in(PredictionValueModel.germplasm_id, cand_ids),
|
||||
PredictionValueModel.predicted_value.isnot(None),
|
||||
)
|
||||
)).scalars().all()
|
||||
@@ -3819,7 +3840,7 @@ class StatisticsService:
|
||||
mset = set(missing)
|
||||
off_tree = (await self.db.execute(
|
||||
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),
|
||||
)
|
||||
)).all()
|
||||
@@ -3828,7 +3849,7 @@ class StatisticsService:
|
||||
off_pvs = (await self.db.execute(
|
||||
select(PredictionValueModel.tree_id, PredictionValueModel.predicted_value).where(
|
||||
PredictionValueModel.prediction_id == ebv_pred.id,
|
||||
PredictionValueModel.tree_id.in_(off_ids),
|
||||
_id_in(PredictionValueModel.tree_id, off_ids),
|
||||
PredictionValueModel.predicted_value.isnot(None),
|
||||
)
|
||||
)).all()
|
||||
@@ -3887,7 +3908,7 @@ class StatisticsService:
|
||||
cand_ids = list(dict.fromkeys(candidate_germplasm_ids))
|
||||
gs = (await self.db.execute(
|
||||
select(BreedingGermplasmModel).where(
|
||||
BreedingGermplasmModel.id.in_(cand_ids),
|
||||
_id_in(BreedingGermplasmModel.id, cand_ids),
|
||||
BreedingGermplasmModel.is_deleted.is_(False),
|
||||
)
|
||||
)).scalars().all()
|
||||
@@ -4048,7 +4069,7 @@ class StatisticsService:
|
||||
raise CustomException(msg=f"选择数量 n_select={n_select} 超过候选亲本数 {len(cand_ids)}")
|
||||
gs = (await self.db.execute(
|
||||
select(BreedingGermplasmModel).where(
|
||||
BreedingGermplasmModel.id.in_(cand_ids),
|
||||
_id_in(BreedingGermplasmModel.id, cand_ids),
|
||||
BreedingGermplasmModel.is_deleted.is_(False),
|
||||
)
|
||||
)).scalars().all()
|
||||
@@ -4210,7 +4231,7 @@ class StatisticsService:
|
||||
|
||||
cand_rows = (await self.db.execute(
|
||||
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),
|
||||
)
|
||||
)).all()
|
||||
@@ -4241,7 +4262,7 @@ class StatisticsService:
|
||||
select(GenotypeCallModel.sample_id, GenotypeCallModel.marker_id,
|
||||
GenotypeCallModel.allele)
|
||||
.where(
|
||||
GenotypeCallModel.marker_id.in_(bg_marker_ids),
|
||||
_id_in(GenotypeCallModel.marker_id, bg_marker_ids),
|
||||
GenotypeCallModel.allele.isnot(None),
|
||||
GenotypeCallModel.is_deleted.is_(False),
|
||||
)
|
||||
@@ -4249,7 +4270,7 @@ class StatisticsService:
|
||||
if calls:
|
||||
sample_ids = sorted({c.sample_id for c in calls})
|
||||
samples = (await self.db.execute(
|
||||
select(GenotypeSampleModel).where(GenotypeSampleModel.id.in_(sample_ids))
|
||||
select(GenotypeSampleModel).where(_id_in(GenotypeSampleModel.id, sample_ids))
|
||||
)).scalars().all()
|
||||
sample_tree, _ = await self._sample_to_tree_map(samples)
|
||||
for c in calls:
|
||||
@@ -4412,7 +4433,7 @@ class StatisticsService:
|
||||
block_of[ent.id] = blk
|
||||
await self.db.execute(
|
||||
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))
|
||||
)
|
||||
await self.db.flush()
|
||||
@@ -4468,7 +4489,7 @@ class StatisticsService:
|
||||
if block_of:
|
||||
await self.db.execute(
|
||||
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))
|
||||
)
|
||||
await self.db.flush()
|
||||
@@ -4577,7 +4598,7 @@ class StatisticsService:
|
||||
if block_of:
|
||||
await self.db.execute(
|
||||
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))
|
||||
)
|
||||
await self.db.flush()
|
||||
@@ -4634,7 +4655,7 @@ class StatisticsService:
|
||||
if block:
|
||||
tree_ids = [int(r.tree_id) for r in agg]
|
||||
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()
|
||||
block_of = {int(tid): bn for tid, bn in block_rows}
|
||||
rows = [
|
||||
@@ -4998,10 +5019,10 @@ class StatisticsService:
|
||||
all_tree_ids = sorted(set(by_tree) | set(ebv_map) | mas["tree_ids"])
|
||||
if all_tree_ids:
|
||||
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())
|
||||
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())
|
||||
else:
|
||||
tree_no_map, combo_map = {}, {}
|
||||
@@ -5168,7 +5189,7 @@ class StatisticsService:
|
||||
tree_ids = sorted({r.tree_id for r in rows})
|
||||
trows = (await self.db.execute(
|
||||
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()
|
||||
clone_of = {t.id: t.clone_id for t in trows}
|
||||
combo_of = {t.id: t.combination_id for t in trows}
|
||||
@@ -5545,7 +5566,7 @@ class StatisticsService:
|
||||
else:
|
||||
tree_rows = (await self.db.execute(
|
||||
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()
|
||||
clone_of = {t.id: t.clone_id for t in tree_rows}
|
||||
@@ -5555,7 +5576,7 @@ class StatisticsService:
|
||||
if clone_ids:
|
||||
clones = (await self.db.execute(
|
||||
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()
|
||||
clone_label = {c.id: c.clone_code for c in clones}
|
||||
@@ -5807,7 +5828,7 @@ class StatisticsService:
|
||||
rel = result["reliability"]
|
||||
ebv = result["ebv"]
|
||||
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())
|
||||
direction = (trait_row.direction or "desc").lower()
|
||||
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,
|
||||
GenotypeCallModel.allele)
|
||||
.where(
|
||||
GenotypeCallModel.sample_id.in_(sample_ids),
|
||||
_id_in(GenotypeCallModel.sample_id, sample_ids),
|
||||
GenotypeCallModel.marker_id.isnot(None),
|
||||
GenotypeCallModel.allele.isnot(None),
|
||||
GenotypeCallModel.is_deleted.is_(False),
|
||||
@@ -5909,7 +5930,7 @@ class StatisticsService:
|
||||
marker_ids = sorted({r.marker_id for r in call_rows})
|
||||
markers = (await self.db.execute(
|
||||
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()
|
||||
if not markers:
|
||||
raise CustomException(msg="标记不存在")
|
||||
@@ -6068,7 +6089,7 @@ class StatisticsService:
|
||||
select(GenotypeCallModel.sample_id, GenotypeCallModel.marker_id,
|
||||
GenotypeCallModel.allele)
|
||||
.where(
|
||||
GenotypeCallModel.sample_id.in_(sample_ids),
|
||||
_id_in(GenotypeCallModel.sample_id, sample_ids),
|
||||
GenotypeCallModel.marker_id.isnot(None),
|
||||
GenotypeCallModel.allele.isnot(None),
|
||||
GenotypeCallModel.is_deleted.is_(False),
|
||||
@@ -6081,7 +6102,7 @@ class StatisticsService:
|
||||
select(MarkerModel.id, MarkerModel.marker_name,
|
||||
MarkerModel.chromosome, MarkerModel.position,
|
||||
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()
|
||||
if not markers:
|
||||
raise CustomException(msg="标记不存在")
|
||||
@@ -6594,7 +6615,7 @@ class StatisticsService:
|
||||
select(GenotypeCallModel.sample_id, GenotypeCallModel.marker_id,
|
||||
GenotypeCallModel.allele)
|
||||
.where(
|
||||
GenotypeCallModel.marker_id.in_(marker_ids),
|
||||
_id_in(GenotypeCallModel.marker_id, marker_ids),
|
||||
GenotypeCallModel.allele.isnot(None),
|
||||
GenotypeCallModel.is_deleted.is_(False),
|
||||
)
|
||||
@@ -6603,7 +6624,7 @@ class StatisticsService:
|
||||
return out
|
||||
sample_ids = sorted({c.sample_id for c in calls})
|
||||
samples = (await self.db.execute(
|
||||
select(GenotypeSampleModel).where(GenotypeSampleModel.id.in_(sample_ids))
|
||||
select(GenotypeSampleModel).where(_id_in(GenotypeSampleModel.id, sample_ids))
|
||||
)).scalars().all()
|
||||
sample_tree, _ = await self._sample_to_tree_map(samples)
|
||||
tree_geno: dict[int, dict[int, str]] = {}
|
||||
@@ -6744,7 +6765,7 @@ class StatisticsService:
|
||||
# GXE 分支:环境维度记录 → rec_phenos(与 run_ablup 同构)
|
||||
grp_rows = (await self.db.execute(
|
||||
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()
|
||||
clone_of = {t.id: t.clone_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:
|
||||
site_rows = (await self.db.execute(
|
||||
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()
|
||||
site_of = {t.id: t.trial_study_id for t in site_rows}
|
||||
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 .crud import BreedingTreeCRUD
|
||||
from .model import TreeModel
|
||||
from .schema import (
|
||||
TreeCreateSchema,
|
||||
TreeOutSchema,
|
||||
@@ -370,8 +371,10 @@ class TreeService:
|
||||
await BreedingTreeCRUD(self.auth, self.db).delete(ids=ids)
|
||||
|
||||
async def list_options(self) -> list[dict[str, Any]]:
|
||||
"""供前端下拉选择使用:返回 [{value, label}]。"""
|
||||
obj_list = await BreedingTreeCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
|
||||
"""供前端下拉选择使用:返回 [{value, label}]。只取 id/tree_no 两列,避免全表列加载拖慢下拉。"""
|
||||
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]
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -9,15 +9,21 @@
|
||||
- 无父母的个体一律视为 founder(base population,彼此不相关)。
|
||||
|
||||
算法:
|
||||
1. A 矩阵(稠密,O(n²) 递归)→ 各体自交系数 F;
|
||||
2. A⁻¹ 用 Henderson 稀疏规则构造(无需求 A 的逆);
|
||||
3. MME 求解:y = Xb + Zu + e,X=截距+固定效应虚拟列+协变量(默认仅截距),λ = σ²e/σ²a;
|
||||
默认稠密精确求解;个体数 > N_SPARSE(默认 1000)且系谱存在时切稀疏 A⁻¹ + 共轭梯度
|
||||
迭代(solve 的 solver="sparse-cg"):EBV/h²/σ² 为 CG 精确解,reliability 用
|
||||
Hutchinson 随机探测估计 C22 对角线(reliability_approx=True,均值/PA 可靠、个体级近似);
|
||||
4. 剖面 REML:对每个候选 h² 固定 λ,用 MME 解的 EM 条件式估计 σ²e、σ²a,
|
||||
再用精确 REML 对数似然评分,黄金分割一维最大化(稳健、可收敛到边界);
|
||||
5. h² = σ²a/(σ²a+σ²e);reliability = 1 - PEV/σ²a,PEV = C22[ii]·σ²e。
|
||||
1. A⁻¹ 用 Henderson 稀疏规则构造(无需求 A 的逆);
|
||||
2. MME 求解:y = Xb + Zu + e,X=截距+固定效应虚拟列+协变量(默认仅截距),λ = σ²e/σ²a;
|
||||
个体数 ≤ N_SPARSE(默认 1000)→ 稠密精确求解(solver="dense":索引化 Z、稠密 A、
|
||||
剖面 REML 黄金分割);个体数 > N_SPARSE 且系谱存在 → 稀疏路径(solver="sparse-em"):
|
||||
Z 索引化设计矩阵(bincount)、稀疏 A⁻¹ 三元组 + 共轭梯度迭代,全程不落稠密 Z/A/V
|
||||
(35k 树在常规内存内跑通)。方差分量按 n 分档:
|
||||
个体数 ≤ N_EXACT(3000)→ tr(A⁻¹C²²) 逐列 CG 精确(无随机噪声),REML 解
|
||||
h(λ)=g(λ)−λ=0 单调单根 → log10λ 二分 ~32 步到 1e-5(规避 EM 慢收缩);
|
||||
个体数 > 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/σ²a,PEV = C22[ii]·σ²e。
|
||||
|
||||
无系谱(所有个体均无父母)时:不背书遗传力——
|
||||
h2 置 None、reliability 全部置 0、EBV 取中心化表型,并返回 warning。
|
||||
@@ -41,8 +47,27 @@ CG_TOL = 1e-9 # 共轭梯度相对残差收敛阈值
|
||||
# CG 精确解;reliability 是 MC 对角估计——同家族强相关的 C22 令其收敛慢(~1/√k),
|
||||
# 误差随 k 减小但非精确,作为文档标注的近似(排序可靠、量级可信)。
|
||||
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_SUBSAMPLE:m>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_version;1.1.0: 供 mtblup 单性状方差复用;1.2.0: G×R 砧木×接穗互作;1.3.0: AR1×AR1 空间协方差 solve_spatial + 稀疏 A⁻¹/共轭梯度/Hutchinson 可靠性;1.4.0: AR1×AR1 各向异性双参数 ρ_row/ρ_col;1.5.0: 区组随机效应 _solve_block(增广/α-格子 block_no 第二随机效应))
|
||||
ENGINE_VERSION = "1.7.0" # 单性状动物模型 BLUP 求解器版本(落 bre_prediction.engine_version;1.1.0: 供 mtblup 单性状方差复用;1.2.0: G×R 砧木×接穗互作;1.3.0: AR1×AR1 空间协方差 solve_spatial + 稀疏 A⁻¹/共轭梯度/Hutchinson 可靠性;1.4.0: AR1×AR1 各向异性双参数 ρ_row/ρ_col;1.5.0: 区组随机效应 _solve_block(增广/α-格子 block_no 第二随机效应);1.6.0: 稀疏路径 EM-REML(索引化设计矩阵 + 稀疏 A⁻¹ 无稠密 A + EM-REML,35k 树不再 OOM;n≤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:
|
||||
@@ -129,30 +154,37 @@ def _build_ainv(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]]
|
||||
) -> 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_ii−1 来源);A⁻¹ 以 COO 稀疏三元组存储。
|
||||
|
||||
Henderson 规则与 _build_ainv 逐项一致,但 A⁻¹ 不落稠密矩阵,只收集非零元
|
||||
(对角 + 2 亲本 × (对角/双向共祖先)),供共轭梯度 matvec 使用:
|
||||
(Ainv·u)[i] = Σ_j Ainv_ij·u_j,np.add.at 按三元组累加即可。
|
||||
不构建稠密 A:加性关系对角用「祖先稀疏行」递推(每体一行 {祖先: A_ij},浅系谱祖先数
|
||||
小,O(n·s²)),Henderson 规则只需 A 对角(fp/fq)与双亲交叉 A[di,si],均可在稀疏行上
|
||||
读出;A⁻¹ 非零元(对角 + 2 亲本 × 对角/双向共祖先)逐项与 _build_ainv 一致。
|
||||
matvec 时用 np.bincount 按 ii 累加 ainv_vv·u[jj] 即得 A⁻¹·u(重复元自动合并)。
|
||||
"""
|
||||
n = len(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):
|
||||
d, s = parent_of.get(ind, (None, None))
|
||||
if d is None and s is None:
|
||||
anc[pos] = {pos: 1.0}
|
||||
diag_a[pos] = 1.0
|
||||
continue
|
||||
di = idx.get(d) if d is not None else None
|
||||
si = idx.get(s) if s is not None else None
|
||||
row = np.zeros(n)
|
||||
if di is not None:
|
||||
row += A[di]
|
||||
if si is not None:
|
||||
row += A[si]
|
||||
A[pos, :] = 0.5 * row
|
||||
A[:pos, pos] = A[pos, :pos]
|
||||
apq = A[di, si] if (di is not None and si is not None) else 0.0
|
||||
A[pos, pos] = 1.0 + 0.5 * apq
|
||||
row: dict[int, float] = {}
|
||||
for src in (di, si):
|
||||
if src is not None:
|
||||
for j, v in anc[src].items():
|
||||
row[j] = row.get(j, 0.0) + 0.5 * v
|
||||
apq = 0.0
|
||||
if di is not None and si is not None:
|
||||
small, big = (anc[di], anc[si]) if len(anc[di]) < len(anc[si]) else (anc[si], anc[di])
|
||||
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] = []
|
||||
jj: list[int] = []
|
||||
@@ -171,8 +203,8 @@ def _build_ainv_sparse(order: list[int], n_base: int,
|
||||
i = idx[ind]
|
||||
di = idx.get(d) if d 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
|
||||
fq = A[si, si] - 1.0 if si is not None else 0.0
|
||||
fp = diag_a[di] - 1.0 if di 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)
|
||||
# 极端近交(双亲 F→1)时 t→0,除零保护;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:
|
||||
_put(di, si, 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))
|
||||
|
||||
|
||||
@@ -351,11 +383,13 @@ def solve(pedigree: list[dict], phenotypes: dict, *, tol: float = TOL,
|
||||
gxe_ratio / n_gxe / n_cross_env / n_genotypes(G×E 分支);
|
||||
block_ratio / n_blocks / block_effects(区组分支);
|
||||
n_obs / n_individuals / n_base / n_with_parents / n_fixed: int;
|
||||
converged: bool;n_iter: int(剖面求值次数/EM 迭代数);warning: str | None;
|
||||
solver: str("dense" 稠密精确 / "sparse-cg" 稀疏共轭梯度,n>N_SPARSE 时触发);
|
||||
稀疏路径另含 cg_iter(CG 迭代数)与 reliability_approx=True(可靠性为 Hutchinson 近似,
|
||||
EBV/h²/σ² 仍为精确解)。
|
||||
converged: bool;n_iter: int(稠密=剖面求值次数,稀疏=迹二分/子样本求值次数);warning: str | None;
|
||||
solver: str("dense" 稠密精确 / "sparse-em" 稀疏 EM-REML,n>N_SPARSE 时触发);
|
||||
稀疏路径另含 cg_iter(CG 迭代数)与 reliability_approx=True(可靠性为 Hutchinson 近似);
|
||||
生产档(n>N_EXACT)方差组分为随机子样本 REML 一致估计(抽样 SE h² ≈ ±0.04),EBV 由
|
||||
全群 MME 于子样本 λ 下求解——对 λ 稳健,排序可靠,h²/σ² 视为近似区间。
|
||||
"""
|
||||
global N_EXACT # 生产档内联递归临时抬档(try/finally 还原);须在函数内首次读取 N_EXACT 前声明
|
||||
if gxe is not None:
|
||||
return _solve_gxe(pedigree, phenotypes, fixed=fixed, covariate=covariate,
|
||||
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 仅为表型残差(固定效应已校正),未估计遗传力与可靠性。",
|
||||
}
|
||||
|
||||
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-REML(Meyer 1985 单性状动物模型)----
|
||||
# Va = (û'A⁻¹û + σ²e·tr(A⁻¹C²²))/n;Ve = yPy/(m−p);λ=Ve/Va。全程不落稠密 Z/A/V。
|
||||
# n ≤ N_EXACT:tr(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⁻¹·u:bincount 按 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.04,EBV 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 / ZtZ(bincount 已给各体观测计数)
|
||||
Z = np.zeros((m, n))
|
||||
for k, ind in enumerate(obs_ids):
|
||||
Z[k, idx[ind]] = 1.0
|
||||
XtX = X.T @ X
|
||||
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)
|
||||
ZtZ = np.diag(ZtZ_diag)
|
||||
|
||||
def _reml_ll(Va: float, Ve: float) -> float:
|
||||
"""精确 REML 对数似然(直接 V 计算,O(m³))。"""
|
||||
@@ -456,85 +705,6 @@ def solve(pedigree: list[dict], phenotypes: dict, *, tol: float = TOL,
|
||||
_, lx = np.linalg.slogdet(XtVinvX)
|
||||
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:]
|
||||
# Hutchinson:E[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]:
|
||||
big = np.zeros((p + n, p + n))
|
||||
big[:p, :p] = XtX
|
||||
|
||||
@@ -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_id,CASCADE(删评价头连带删明细)"],
|
||||
["唯一约束", "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_code(growth_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,584(A 731,904 + B 1,148,160 + C 1,703,520)"],
|
||||
["A 随评构成", "45,744 个评价头 × 16 条/头 = 731,904,0 孤儿"],
|
||||
["C 克隆苗构成", "每克隆 4 棵 ramet,17 性状 × 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
Binary file not shown.
@@ -1,8 +1,8 @@
|
||||
# 桃育种系统 · 统计引擎实施记录
|
||||
|
||||
> **版本**:v1.4(2026-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)配套;规格管"应该长什么样",本文管"实际落地了什么、怎么验证的"。
|
||||
> **相关探针**:`Temp/claude/e2e_gxe_20260803.py`(G×E,5 场景)· `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 组)。
|
||||
> **定位**:记录统计引擎多轮 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×E,5 场景)· `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 组)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
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"];
|
||||
|
||||
/** 选项超过该数量时自动切换为虚拟滚动选择器(ElSelectV2),避免渲染数万个 el-option 卡死页面 */
|
||||
const VIRTUAL_SELECT_THRESHOLD = 2000;
|
||||
|
||||
/** 日期选择器类型列表(getProps 中用于传递 type 到 FaDatePicker) */
|
||||
const DATE_PICKER_TYPES = ["date", "daterange", "datetime", "datetimerange", "monthrange"];
|
||||
|
||||
@@ -167,6 +170,18 @@ export const getProps = (item: FormItemBase): Record<string, any> => {
|
||||
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)"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<!-- 下拉选择(大选项集自动走虚拟滚动 ElSelectV2,选项经 props 传入) -->
|
||||
<template
|
||||
v-if="item.type === 'select' && getProps(item)?.options && !isVirtualizedSelect(item)"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
@@ -137,7 +139,9 @@
|
||||
@update:model-value="setFieldValue(item.key, $event)"
|
||||
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
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
@@ -205,13 +209,14 @@
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { type Component } from "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 {
|
||||
cloneModelValue as cloneModelValueShared,
|
||||
sanitizeOutputValue as sanitizeOutputValueShared,
|
||||
getProps as getPropsShared,
|
||||
getSlots as getSlotsShared,
|
||||
getColSpan as getColSpanShared,
|
||||
isVirtualizedSelect,
|
||||
useSanitizeOutputOptions,
|
||||
type SanitizeOutputOptions,
|
||||
} from "../composables/useFormBase";
|
||||
@@ -425,6 +430,10 @@ const getComponent = (item: FormItem) => {
|
||||
if (item.render) {
|
||||
return item.render;
|
||||
}
|
||||
// 大选项集自动切换虚拟滚动选择器,避免渲染数万个 el-option 卡死页面
|
||||
if (isVirtualizedSelect(item)) {
|
||||
return ElSelectV2;
|
||||
}
|
||||
// 使用 type 获取预定义组件
|
||||
const { type } = item;
|
||||
const comp = componentMap[type as keyof typeof componentMap];
|
||||
|
||||
@@ -66,8 +66,10 @@
|
||||
@update:model-value="setFieldValue(item.key, $event)"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<!-- 下拉选择(大选项集自动走虚拟滚动 ElSelectV2,选项经 props 传入) -->
|
||||
<template
|
||||
v-if="item.type === 'select' && getProps(item)?.options && !isVirtualizedSelect(item)"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
@@ -169,13 +171,14 @@ import {
|
||||
getAuditSearchFormItems,
|
||||
type GetAuditSearchFormItemsOptions,
|
||||
} 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 {
|
||||
cloneModelValue as cloneModelValueShared,
|
||||
sanitizeOutputValue as sanitizeOutputValueShared,
|
||||
getProps as getPropsShared,
|
||||
getSlots as getSlotsShared,
|
||||
getColSpan as getColSpanShared,
|
||||
isVirtualizedSelect,
|
||||
useSanitizeOutputOptions,
|
||||
type SanitizeOutputOptions,
|
||||
} from "../composables/useFormBase";
|
||||
@@ -379,6 +382,10 @@ const getComponent = (item: SearchFormItem) => {
|
||||
if (item.render) {
|
||||
return item.render;
|
||||
}
|
||||
// 大选项集自动切换虚拟滚动选择器,避免渲染数万个 el-option 卡死页面
|
||||
if (isVirtualizedSelect(item)) {
|
||||
return ElSelectV2;
|
||||
}
|
||||
// 使用 type 获取预定义组件
|
||||
const { type } = item;
|
||||
return componentMap[type as keyof typeof componentMap] || componentMap["input"];
|
||||
|
||||
@@ -199,7 +199,15 @@ export const request: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||
timeout: Number(import.meta.env.VITE_API_TIMEOUT) || 15000,
|
||||
headers: { "Content-Type": "application/json;charset=utf-8" },
|
||||
paramsSerializer: (params) => qs.stringify(params, { indices: false }),
|
||||
// qs 会把 null 序列化成空串(?year=),后端 int 参数解析会 422;null/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(
|
||||
|
||||
@@ -1349,15 +1349,11 @@
|
||||
<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-select>
|
||||
<el-select v-model="mabcRpTree" placeholder="轮回亲本树" style="min-width: 170px">
|
||||
<el-option v-for="t in treeOptions" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
<el-select-v2 v-model="mabcRpTree" :options="treeOptions" placeholder="轮回亲本树" style="min-width: 170px" />
|
||||
<el-button :loading="treeOptLoading" @click="loadTreeOptions">刷新树</el-button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-select v-model="mabcCands" 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>
|
||||
<el-select-v2 v-model="mabcCands" :options="treeOptions" multiple collapse-tags placeholder="候选回交单株(BC 分离群体)" style="min-width: 300px" />
|
||||
<span style="color:#909399; font-size:12px">前景阈值</span>
|
||||
<el-input-number v-model="mabcMinHits" :min="1" style="width: 90px" />
|
||||
<span style="color:#909399; font-size:12px">当前世代</span>
|
||||
@@ -2435,7 +2431,7 @@ async function loadAnova() {
|
||||
}
|
||||
async function loadTrialStudies() {
|
||||
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 }>;
|
||||
} catch (e: any) {
|
||||
msg.error(e?.message || '研究点列表加载失败');
|
||||
|
||||
@@ -306,7 +306,7 @@ function displayValue(trait: TraitTable): string {
|
||||
async function loadTraits() {
|
||||
if (traitsLoaded.value) return;
|
||||
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) {
|
||||
const list = res.data.data.items ?? [];
|
||||
for (const t of TRAIT_TABS) traitsByCategory[t] = [];
|
||||
@@ -326,7 +326,7 @@ async function loadObservations(evaluationId: number) {
|
||||
const res = await TraitObservationAPI.getTraitObservationList({
|
||||
evaluation_id: evaluationId,
|
||||
page_no: 1,
|
||||
page_size: 500,
|
||||
page_size: 100,
|
||||
});
|
||||
if (res.data.code === ResultEnum.SUCCESS) {
|
||||
const list = res.data.data.items ?? [];
|
||||
@@ -856,7 +856,7 @@ async function syncObservations(evaluationId: number) {
|
||||
const existing = await TraitObservationAPI.getTraitObservationList({
|
||||
evaluation_id: evaluationId,
|
||||
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);
|
||||
if (ids.length) await TraitObservationAPI.deleteTraitObservation(ids);
|
||||
|
||||
Reference in New Issue
Block a user