From e937fc930c65ffd2fa29e5c80e980ea0e5a5f267 Mon Sep 17 00:00:00 2001 From: "34047007@qq.com" <34047007@qq.com> Date: Fri, 7 Aug 2026 08:03:10 +0800 Subject: [PATCH] =?UTF-8?q?checkpoint:=20real-scale=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=93=BE=20+=20=E7=BB=9F=E8=AE=A1=E5=BC=95=E6=93=8E=20+=20ABLU?= =?UTF-8?q?P=20=E7=A8=80=E7=96=8F=E6=80=A7=E8=83=BD=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/调试脚本/一次性验证输出 --- .gitignore | 7 + .../api/v1/module_bre/statistics/service.py | 205 ++- backend/app/api/v1/module_bre/tree/service.py | 7 +- backend/scripts/breeding_stats/blup.py | 416 +++-- backend/scripts/gen_obs_doc.py | 275 +++ backend/scripts/simulate_breeding_data.py | 1544 +++++++++++++++++ ...育种系统业务链路与数据模型说明书 v1.1.docx | Bin 0 -> 61615 bytes doc/桃育种系统统计引擎实施记录.md | 4 +- doc/桃育种系统规划对照总表.md | 2 +- doc/桃育种观测业务梳理.docx | Bin 0 -> 43837 bytes .../forms/composables/useFormBase.ts | 15 + .../src/components/forms/fa-form/index.vue | 17 +- .../components/forms/fa-search-bar/index.vue | 13 +- frontend/web/src/utils/http/index.ts | 10 +- .../src/views/module_bre/statistics/index.vue | 10 +- .../module_bre/tree_evaluation/index.vue | 6 +- 16 files changed, 2293 insertions(+), 238 deletions(-) create mode 100644 backend/scripts/gen_obs_doc.py create mode 100644 backend/scripts/simulate_breeding_data.py create mode 100644 doc/桃育种系统业务链路与数据模型说明书 v1.1.docx create mode 100644 doc/桃育种观测业务梳理.docx diff --git a/.gitignore b/.gitignore index 07523f7..c4c85e6 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/backend/app/api/v1/module_bre/statistics/service.py b/backend/app/api/v1/module_bre/statistics/service.py index 0131528..88f7de0 100644 --- a/backend/app/api/v1/module_bre/statistics/service.py +++ b/backend/app/api/v1/module_bre/statistics/service.py @@ -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 = {} diff --git a/backend/app/api/v1/module_bre/tree/service.py b/backend/app/api/v1/module_bre/tree/service.py index 6f46327..13a7a0d 100644 --- a/backend/app/api/v1/module_bre/tree/service.py +++ b/backend/app/api/v1/module_bre/tree/service.py @@ -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 diff --git a/backend/scripts/breeding_stats/blup.py b/backend/scripts/breeding_stats/blup.py index 226525e..988cd9f 100644 --- a/backend/scripts/breeding_stats/blup.py +++ b/backend/scripts/breeding_stats/blup.py @@ -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, +# 消除 3000N_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 diff --git a/backend/scripts/gen_obs_doc.py b/backend/scripts/gen_obs_doc.py new file mode 100644 index 0000000..df48d47 --- /dev/null +++ b/backend/scripts/gen_obs_doc.py @@ -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() diff --git a/backend/scripts/simulate_breeding_data.py b/backend/scripts/simulate_breeding_data.py new file mode 100644 index 0000000..8147536 --- /dev/null +++ b/backend/scripts/simulate_breeding_data.py @@ -0,0 +1,1544 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""桃育种压测级模拟数据生成器(2026-08-06,自洽式)。 + +目标:在 dev 库(Postgres)上生成 20 年时间线的真实感育种数据—— + - ~5 万棵单株(F1 杂交圃 / F2 自交分离 / BC1 回交) + - 百万级性状观测(童期 juvenile + 成株 evaluation,含数值/文本两型) + - 完整选择晋级链(初选 sp → 复选 ap → 品系 line → 区试 regional → 审定 released), + 晋级走真实服务层 SelectionResultService(创建 clone/germplasm/pedigree、回写树阶段与状态) + - 单株评价、农事操作(采收同步产量)、定植批次、区域试验(trial/study/entry) + +设计要点: + - 参考数据(性状/人员/基地/地块/育种目标/规则/砧木/基础种质池)若缺失则补齐,不清除既有参考数据。 + - 业务数据(bre_* 表)按 FK 序全量清空重建(--no-wipe 跳过)。 + - 数值性状用简化无穷小模型:BV(父母均值+孟德尔抽样)+ 年度效应 + 误差,按性状 h² 截断到有效区间; + 选择依据 = 各性状 BV 标准化加权指数(升序性状权重取负),保证入选单株表型确实更优(选择信号真实)。 + - 过程内直调服务层(AuthSchema 超管身份,Permission 不过滤,免登录免验证码), + 批量晋级复用 create_batch(单事务逐棵 create,失败不拖垮整批)。 + - 淘汰记录(is_selected=eliminated)与淘汰树状态均在各轮正向晋级**全部完成之后**批量回写, + 避免正向晋级校验(_assert_stage_forward)先看到 eliminated 结论而误判“结论回退”。 + +运行: + ENVIRONMENT=dev python backend/scripts/simulate_breeding_data.py --dry-run # 小规模验证 + ENVIRONMENT=dev python backend/scripts/simulate_breeding_data.py --verify # 全量 + 自检 +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import random +import sys +from datetime import UTC, date, datetime +from pathlib import Path + +import numpy as np + +BACKEND_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BACKEND_DIR)) + +# Windows 控制台 GBK 下中文 print 会乱码,统一以 UTF-8 输出 +try: + sys.stdout.reconfigure(encoding="utf-8", line_buffering=True) + sys.stderr.reconfigure(encoding="utf-8", line_buffering=True) +except (AttributeError, ValueError): + pass + +from sqlalchemy import func, insert, select, text # noqa: E402 +from sqlalchemy.dialects.postgresql import insert as pg_insert # noqa: E402 +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine # noqa: E402 + +from app.config.setting import settings # noqa: E402 +from app.core.base_schema import AuthSchema, CoreUserSchema # noqa: E402 +from app.core.bre_audit_ctx import bre_audit_suppress # noqa: E402 +from app.utils.common_util import uuid4_str # noqa: E402 + +from app.api.v1.module_bre.cross_combination.model import CrossCombinationModel # noqa: E402 +from app.api.v1.module_bre.tree.model import TreeModel # noqa: E402 +from app.api.v1.module_bre.planting.model import PlantingModel # noqa: E402 +from app.api.v1.module_bre.tree_evaluation.model import TreeEvaluationModel # noqa: E402 +from app.api.v1.module_bre.trait_observation.model import TraitObservationModel # noqa: E402 +from app.api.v1.module_bre.selection_result.model import SelectionResultModel # noqa: E402 +from app.api.v1.module_bre.field_operation.model import FieldOperationModel # noqa: E402 +from app.api.v1.module_bre.trial.model import TrialModel # noqa: E402 +from app.api.v1.module_bre.trial_study.model import TrialStudyModel # noqa: E402 +from app.api.v1.module_bre.trial_study_entry.model import TrialStudyEntryModel # noqa: E402 +from app.api.v1.module_bre.trait.model import TraitModel # noqa: E402 +from app.api.v1.module_bre.germplasm.model import BreedingGermplasmModel # noqa: E402 +from app.api.v1.module_bre.target.model import TargetModel # noqa: E402 +from app.api.v1.module_bre.plan.model import PlanModel # noqa: E402 +from app.api.v1.module_bre.personnel.model import PersonnelModel # noqa: E402 +from app.api.v1.module_bre.site.model import BreedingSiteModel, BreedingPlotModel # noqa: E402 +from app.api.v1.module_bre.rootstock.model import RootstockModel # noqa: E402 +from app.api.v1.module_bre.selection_rule.model import SelectionRuleModel # noqa: E402 +from app.api.v1.module_bre.selection_result.service import SelectionResultService # noqa: E402 +from app.api.v1.module_bre.selection_result.schema import SelectionResultBatchCreateSchema # noqa: E402 +from app.api.v1.module_bre.pollination.model import PollinationModel # noqa: E402 +from app.api.v1.module_bre.pollen.model import PollenModel # noqa: E402 +from app.api.v1.module_bre.seed_lot.model import SeedLotModel # noqa: E402 +from app.api.v1.module_bre.seed_treatment.model import SeedTreatmentModel # noqa: E402 +from app.api.v1.module_bre.seedling.model import SeedlingModel # noqa: E402 +from app.api.v1.module_bre.propagation.model import PropagationModel # noqa: E402 +from app.api.v1.module_bre.environment_condition.model import EnvironmentConditionModel # noqa: E402 +from app.api.v1.module_bre.observation.model import ObservationModel # noqa: E402 +from app.api.v1.module_bre.treatment.model import TreatmentModel # noqa: E402 +from app.api.v1.module_bre.clone.model import CloneModel # noqa: E402 +# UserModel 必须显式导入:多个 module_bre 模型的 relationship 以字符串引用 UserModel, +# 未注册进 mapper registry 会在首次配置映射时抛 InvalidRequestError +from app.api.v1.module_system.user.model import UserModel # noqa: E402,F401 + +# --------------------------------------------------------------------------- +# 常量与参数 +# --------------------------------------------------------------------------- +WINDOW_END = 2026 # 数据“今天”年份(2026-08-06) +F1_YEARS = range(2007, 2024) # F1 杂交组合年份 2007..2023 +F1_TREES = 60 # 10 人团队真实规模:F1 = 17年×12×60 = 12,240 +F2_COMBOS = 120 +F2_TREES = 30 # F2 = 120×30 = 3,600 +BC_COMBOS = 40 +BC_TREES = 20 # BC = 40×20 = 800(全圃活体合计 ≈ 3.4 万,含 ramet) +K_RAMETS = 4 # 每入选克隆的无性系苗(ramet)数:保证 ABLUP G1 每 clone ≥2 样本 +RAMET_EVAL_YEARS = 2 # 无性系苗成株/童期观测年数(自初选年起的连续年份) + +# 晋级轮次:名称 / 选育结论 / 入选比例 / 起始阶段 / 晋级阶段 / 距定植年偏移 +ROUNDS = [ + ("sp", "selected", 0.35, "seedling", "sp", 3), + ("ap", "primary", 0.50, "sp", "ap", 5), + ("line", "key", 0.40, "ap", "line", 6), + ("regional", "preserved", 0.50, "line", "regional_trial", 7), + ("released", "preserved", 0.30, "regional_trial", "released", 8), +] + +# 数值性状:code, name, unit, category, vmin, vmax, stage, direction, into_ebv, mu, sd, h2, weight +NUMERIC_TRAITS = [ + ("single_tree_yield", "单株产量", "kg", "产量", 0, 60, "evaluation", "desc", "1", 18.0, 8.0, 0.35, 0.25), + ("fruit_number", "结果个数", "个", "产量", 0, 600, "evaluation", "desc", "1", 220.0, 80.0, 0.30, 0.03), + ("avg_fruit_weight", "平均单果重", "g", "果形", 50, 400, "evaluation", "desc", "1", 210.0, 50.0, 0.40, 0.20), + ("marketable_rate", "好果率", "%", "品质", 0, 100, "evaluation", "desc", "1", 82.0, 12.0, 0.30, 0.15), + ("ssc", "可溶性固形物", "%", "品质", 5, 22, "evaluation", "desc", "1", 12.5, 2.2, 0.55, 0.20), + ("firmness_value", "果肉硬度", "kg/cm²", "品质", 0, 20, "evaluation", "desc", "1", 8.5, 2.5, 0.40, 0.05), + ("acidity", "可滴定酸", "%", "品质", 0, 3.5, "evaluation", "asc", "1", 0.7, 0.25, 0.40, -0.05), + ("red_blush", "着色度", "%", "果形", 0, 100, "evaluation", "desc", "1", 55.0, 22.0, 0.50, 0.05), + ("chilling_requirement", "需冷量", "h", "物候", 0, 1200, "evaluation", "asc", "1", 750.0, 180.0, 0.60, -0.05), + ("stone_weight", "核重", "g", "果形", 0, 30, "evaluation", "asc", "1", 8.0, 3.0, 0.30, -0.02), + ("fruit_length", "果实纵径", "mm", "果形", 40, 110, "evaluation", "desc", "1", 68.0, 10.0, 0.35, 0.02), + ("fruit_diameter", "果实横径", "mm", "果形", 40, 120, "evaluation", "desc", "1", 72.0, 12.0, 0.35, 0.02), + ("uniformity", "果实整齐度", "%", "品质", 0, 100, "evaluation", "desc", "1", 78.0, 10.0, 0.25, 0.02), + ("seedling_height", "苗高", "cm", "营养", 0, 400, "juvenile", "desc", "0", 180.0, 50.0, 0.30, 0.0), + ("trunk_diameter", "地径", "cm", "营养", 0, 20, "juvenile", "desc", "0", 6.0, 1.8, 0.30, 0.0), + ("shoot_growth", "新梢生长量", "cm", "营养", 0, 250, "juvenile", "desc", "0", 90.0, 35.0, 0.25, 0.0), + ("leaf_disease_severity", "叶部病害程度", "%", "抗性", 0, 100, "juvenile", "asc", "0", 15.0, 12.0, 0.20, 0.0), +] + +# 文本性状:code, name, category, stage, options, probs +TEXT_TRAITS = [ + ("disease_resistance", "抗病性", "抗性", "evaluation", ["高抗", "抗", "中抗", "感"], [0.12, 0.35, 0.35, 0.18]), + ("flesh_color", "果肉颜色", "品质", "evaluation", ["白色", "黄白", "黄色", "红色"], [0.35, 0.25, 0.30, 0.10]), + ("fruit_shape", "果实形状", "果形", "evaluation", ["圆形", "扁圆", "蟠桃形", "椭圆形"], [0.40, 0.20, 0.15, 0.25]), +] + +# 描述性状(单株鉴定表单 6 标签页官方字典,权威来源:育种管理系统操作手册)。 +# 结构:(code, name, category, data_type, options_or_None, mu_or_None, sd_or_None, vmin_or_None, vmax_or_None, reuse_stats_code_or_None) +# data_type: categorical(分级,选项入 scale_json) / date / numeric +# reuse_stats_code: 值同源于某 stats 观测(如纵径横径复用 stats fruit_length/fruit_diameter),不独立造值 +# 归并策略:avg_fruit_weight/ssc/flesh_color/fruit_shape 与 stats 撞 code → 描述侧定义覆盖升级(category/data_type/scale_json); +# 纵径/横径(longitudinal_dia/transverse_dia)与 stats fruit_length/fruit_diameter 语义重复 → 造值时取同源值。 +DESCRIPTIVE_TRAITS = [ + # ① 基本鉴定 + ("growth_vigor", "生长势", "基本鉴定", "categorical", ["强", "中", "弱"], None, None, None, None, None), + ("growth_type", "生长型", "基本鉴定", "categorical", ["普通", "半矮", "矮"], None, None, None, None, None), + ("first_flower_date", "始花日期", "基本鉴定", "date", None, None, None, None, None, None), + ("flower_type", "花型", "基本鉴定", "categorical", ["蔷薇型", "铃型"], None, None, None, None, None), + ("pollen", "花粉", "基本鉴定", "categorical", ["无", "少", "中", "多"], None, None, None, None, None), + ("load_amount", "负载量", "基本鉴定", "categorical", ["高", "中", "低"], None, None, None, None, None), + ("maturity_uniformity", "成熟一致性", "基本鉴定", "categorical", ["一致", "较一致", "不一致"], None, None, None, None, None), + # ② 果实外观 + ("fruit_shape", "果形", "果实外观", "categorical", ["扁平", "扁圆", "圆", "椭圆", "卵圆"], None, None, None, None, None), + ("apex_shape", "果顶", "果实外观", "categorical", ["突出", "稍突出", "圆平", "稍凹陷", "凹陷"], None, None, None, None, None), + ("stem_cavity_depth", "梗洼深度", "果实外观", "categorical", ["浅", "中", "深"], None, None, None, None, None), + ("stem_cavity_width", "梗洼宽度", "果实外观", "categorical", ["窄", "中", "宽"], None, None, None, None, None), + ("fruit_base", "果基", "果实外观", "categorical", ["正", "稍偏", "偏"], None, None, None, None, None), + ("suture_line", "缝合线", "果实外观", "categorical", ["浅", "中", "深"], None, None, None, None, None), + ("symmetry", "对称性", "果实外观", "categorical", ["对称", "较对称", "不对称"], None, None, None, None, None), + # ③ 果皮 + ("pubescence", "茸毛", "果皮", "categorical", ["无", "少", "中", "多"], None, None, None, None, None), + ("skin_base_color", "底色", "果皮", "categorical", ["淡绿", "绿白", "白", "浅黄", "深黄"], None, None, None, None, None), + ("coloration_area", "着色面积", "果皮", "categorical", ["无", "少", "中", "多", "全"], None, None, None, None, None), + ("coloration_depth", "着色深度", "果皮", "categorical", ["浅", "中", "深"], None, None, None, None, None), + ("coloration_brightness", "着色亮度", "果皮", "categorical", ["暗", "中", "亮"], None, None, None, None, None), + ("coloration_pattern", "着色形态", "果皮", "categorical", ["晕", "条", "斑"], None, None, None, None, None), + ("skin_spot", "果面斑点", "果皮", "categorical", ["少", "中", "多"], None, None, None, None, None), + ("skin_peelability", "果皮剥离度", "果皮", "categorical", ["不能", "难", "易"], None, None, None, None, None), + # ④ 果实大小(数值 core) + ("max_fruit_weight", "最大果重", "果实大小", "numeric", None, 300.0, 70.0, 100, 600, None), + ("avg_fruit_weight", "平均果重", "果实大小", "numeric", None, 210.0, 50.0, 50, 400, None), + ("longitudinal_dia", "纵径", "果实大小", "numeric", None, None, None, None, None, "fruit_length"), + ("transverse_dia", "横径", "果实大小", "numeric", None, None, None, None, None, "fruit_diameter"), + ("lateral_dia", "侧径", "果实大小", "numeric", None, 70.0, 11.0, 40, 115, None), + # ⑤ 果肉 + ("flesh_thickness", "果肉厚度", "果肉", "numeric", None, 18.0, 3.0, 5, 35, None), + ("flesh_color", "肉色", "果肉", "categorical", ["淡绿", "白", "黄白", "黄", "橙黄", "红", "紫红"], None, None, None, None, None), + ("flesh_firmness", "硬度", "果肉", "categorical", ["很软", "软", "中", "硬", "很硬"], None, None, None, None, None), + ("subepidermal_anthocyanin", "皮下花色苷", "果肉", "categorical", ["无", "少", "中", "多"], None, None, None, None, None), + ("flesh_anthocyanin", "果肉花色苷", "果肉", "categorical", ["无", "少", "中", "多"], None, None, None, None, None), + ("stone_cavity_anthocyanin", "核窝花色苷", "果肉", "categorical", ["无", "少", "中", "多"], None, None, None, None, None), + ("ssc", "SSC", "果肉", "numeric", None, 12.5, 2.2, 5, 22, None), + ("fiber", "纤维", "果肉", "categorical", ["少", "中", "多"], None, None, None, None, None), + ("juice", "汁液", "果肉", "categorical", ["少", "中", "多"], None, None, None, None, None), + ("sweet_acidity", "甜酸度", "果肉", "categorical", ["淡甜", "甜", "浓甜", "酸甜", "甜酸适中", "甜酸", "酸"], None, None, None, None, None), + ("aroma", "香气", "果肉", "categorical", ["淡", "中", "多"], None, None, None, None, None), + ("quality_overall", "品质综合", "果肉", "categorical", ["上", "中", "下"], None, None, None, None, None), + # ⑥ 果核 + ("stone_adherence", "粘离性", "果核", "categorical", ["粘核", "半离", "离核"], None, None, None, None, None), + ("stone_size", "核大小", "果核", "categorical", ["小", "中", "大"], None, None, None, None, None), + ("stone_shape", "核形", "果核", "categorical", ["扁平", "近圆", "椭圆", "倒卵圆", "卵圆"], None, None, None, None, None), + ("stone_cracking", "裂核", "果核", "categorical", ["无", "少", "中", "多"], None, None, None, None, None), +] +# 描述性状中与 stats 撞 code、由描述侧覆盖升级现有行的 code 集 +DESCRIPTIVE_OVERRIDE_CODES = {"avg_fruit_weight", "ssc", "flesh_color", "fruit_shape"} +# 描述性状中值同源复用 stats 观测的映射 {描述code: stats code} +DESCRIPTIVE_REUSE_STATS = {t[0]: t[9] for t in DESCRIPTIVE_TRAITS if t[9]} +# 描述性状 numeric core 的单位与先验遗传力(抄 bre_trait_seed.sql 官方值) +DESCRIPTIVE_UNITS = {"max_fruit_weight": "g", "avg_fruit_weight": "g", "longitudinal_dia": "mm", + "transverse_dia": "mm", "lateral_dia": "mm", "flesh_thickness": "mm", "ssc": "%"} +DESCRIPTIVE_H2 = {"max_fruit_weight": 0.5, "avg_fruit_weight": 0.5, "longitudinal_dia": 0.4, + "transverse_dia": 0.4, "lateral_dia": 0.4, "flesh_thickness": 0.3, "ssc": 0.5} +# 描述 numeric core 的遗传代理(不在 bv 矩阵列中):{描述code: (stats code, 系数)}, +# 借同源 stats 性状的 BV 保持遗传相关(最大果重↔平均果重、侧径/肉厚↔横径) +DESCRIPTIVE_BV_PROXY = { + "max_fruit_weight": ("avg_fruit_weight", 1.3), + "lateral_dia": ("fruit_diameter", 0.9), + "flesh_thickness": ("fruit_diameter", 0.3), +} + +# 基础种质池(F1 亲本):name, variety_type, maturity, firmness, s_alleles, avg_fruit_weight, ssc, chilling +FOUNDERS = [ + ("春美", "普通桃", "早", "硬溶质", "S1/S2", 150, 11.5, 650), + ("春雪", "普通桃", "早", "硬溶质", "S1/S3", 180, 12.0, 700), + ("白凤", "普通桃", "中", "软溶质", "S2/S3", 220, 13.0, 780), + ("湖景蜜露", "普通桃", "中", "软溶质", "S3/S4", 240, 12.5, 800), + ("京玉", "普通桃", "中", "硬溶质", "S1/S4", 260, 12.0, 750), + ("大久保", "普通桃", "中", "软溶质", "S2/S4", 280, 11.0, 820), + ("仓方早生", "普通桃", "早", "硬溶质", "S1/S9", 190, 10.5, 700), + ("冈山白", "普通桃", "中", "软溶质", "S2/S9", 300, 12.0, 850), + ("早香玉", "普通桃", "早", "硬溶质", "S3/S9", 160, 11.0, 680), + ("雨花露", "普通桃", "早", "软溶质", "S1/S7", 170, 10.0, 690), + ("霞晖1号", "普通桃", "早", "硬溶质", "S2/S7", 175, 11.5, 670), + ("砂子早生", "普通桃", "早", "硬溶质", "S3/S7", 165, 10.8, 660), + ("中油桃4号", "油桃", "中", "硬溶质", "S1/S3", 210, 12.8, 760), + ("中油桃8号", "油桃", "中", "硬溶质", "S2/S4", 235, 13.5, 780), + ("瑞光18", "油桃", "中", "硬溶质", "S1/S4", 220, 12.2, 770), + ("瑞光28", "油桃", "晚", "硬溶质", "S3/S9", 245, 13.0, 820), + ("瑞蟠4号", "蟠桃", "中", "软溶质", "S2/S3", 210, 12.5, 800), + ("瑞蟠21", "蟠桃", "中", "硬溶质", "S1/S2", 230, 12.8, 790), + ("晚白桃", "普通桃", "晚", "硬溶质", "S7/S9", 270, 12.5, 900), + ("京红", "普通桃", "中", "软溶质", "S1/S9", 250, 11.8, 790), + ("燕红", "普通桃", "中", "硬溶质", "S2/S9", 265, 12.2, 810), + ("新川中岛", "普通桃", "中", "硬溶质", "S3/S4", 280, 13.2, 830), + ("川中岛白桃", "普通桃", "晚", "硬溶质", "S1/S7", 290, 13.5, 860), + ("锦绣黄桃", "黄肉", "中", "硬溶质", "S2/S7", 260, 14.0, 800), + ("锦园", "黄肉", "中", "硬溶质", "S1/S4", 275, 13.8, 820), + ("金童5号", "黄肉", "中", "硬溶质", "S3/S7", 240, 13.0, 790), + ("金童8号", "黄肉", "中", "硬溶质", "S2/S9", 250, 13.2, 800), + ("早黄金", "黄肉", "早", "硬溶质", "S1/S3", 200, 12.8, 720), + ("中蟠桃1号", "蟠桃", "中", "硬溶质", "S2/S4", 225, 12.0, 790), + ("中蟠桃11", "蟠桃", "晚", "硬溶质", "S1/S9", 235, 12.6, 830), + ("中油蟠1号", "油蟠桃", "中", "硬溶质", "S3/S4", 230, 12.8, 810), + ("中华寿桃", "普通桃", "晚", "硬溶质", "S2/S9", 320, 13.0, 950), + ("大五星", "普通桃", "中", "硬溶质", "S1/S4", 300, 12.2, 840), + ("布目早生", "普通桃", "早", "软溶质", "S3/S9", 195, 10.8, 710), + ("冬桃", "普通桃", "晚", "硬溶质", "S7/S9", 310, 13.5, 1100), + ("血桃", "普通桃", "中", "硬溶质", "S1/S7", 200, 11.0, 780), + ("脆桃", "普通桃", "早", "硬溶质", "S2/S3", 175, 10.5, 690), + ("黄肉晚熟", "黄肉", "晚", "硬溶质", "S1/S2", 280, 14.2, 950), + ("白桃(地方)", "普通桃", "中", "软溶质", "S2/S7", 260, 11.5, 850), + ("油桃晚熟", "油桃", "晚", "硬溶质", "S3/S7", 255, 13.8, 880), +] + +PERSONNEL = [ + ("王育桃", "男", "育种家", "主持杂交组合与选择晋级"), + ("李农艺", "女", "农艺师", "负责定植与田间评价"), + ("张田间", "男", "技术员", "负责农事操作与采收记录"), +] +SITES = [("主育种基地", "河南郑州", "华北", 35.6, 113.6, 150, "核心育种圃与品比圃"), + ("试验示范站", "山东泰安", "华北", 36.2, 117.1, 250, "区试与示范栽培点")] +PLOTS = [(0, "P01", "南北行", "选种圃"), + (0, "P02", "南北行", "选种圃"), + (0, "P03", "南北行", "复选圃"), + (0, "P04", "东西行", "品比圃"), + (0, "P05", "东西行", "育种圃"), + (0, "P06", "南北行", "杂种圃"), + (1, "T01", "南北行", "区试圃"), + (1, "T02", "东西行", "区试圃"), + (1, "T03", "南北行", "区试圃")] +PLANS = [("桃-2020-1", "桃树品种选育计划(2020-2030)", "早熟优质大果硬肉耐贮综合育种")] +TARGETS = [("早熟优质", "早熟", "早熟、优质、丰产,树势开张"), + ("大果硬肉", "硬肉", "大果型、硬溶质、耐贮运"), + ("抗病耐贮", "抗病", "抗细菌性穿孔病、耐贮运"), + ("低需冷量", "冷量", "低需冷量、适南岭以南促早栽培")] +RULES = [("初选", "sp", "综合评价得分与指数筛选"), + ("复选", "ap", "连续两年结果表现复核"), + ("品系比较", "line", "品系比较试验表现"), + ("区域试验", "regional_trial", "区域试验产量与品质表现")] +ROOTSTOCKS = [("毛桃", "乔化", "强"), ("山桃", "乔化", "中"), ("GF677", "半矮化", "强")] + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _base() -> dict: + return {"uuid": uuid4_str(), "created_time": _now(), "updated_time": _now(), "is_deleted": False} + + +# --------------------------------------------------------------------------- +# 工具 +# --------------------------------------------------------------------------- +def chunked(seq: list, size: int): + for i in range(0, len(seq), size): + yield seq[i:i + size] + + +def clip_round(v, vmin: float, vmax: float): + return float(np.clip(np.round(v, 1), vmin, vmax)) + + +async def insert_chunked(session, model, rows: list[dict], size: int = 20000) -> int: + total = 0 + for chunk in chunked(rows, size): + await session.execute(insert(model), chunk) + total += len(chunk) + return total + + +# --------------------------------------------------------------------------- +# 参考数据补齐 +# --------------------------------------------------------------------------- +# 官方字典种子(产量/品质/物候/生长量/描述 + 抗病/需冷量 + DUS TG/53 描述符)以 +# backend/sql/*.sql 为单一事实源。模拟脚本只为其自有性状造观测,不建这些字典行; +# wipe 重建若不同步回填,回归断言(e2e_busflow 19 码 / e2e_refine DUS)必缺。 +# 幂等:各文件均为单个 INSERT ... ON CONFLICT(DO UPDATE / DO NOTHING)。 +_REFERENCE_SQL_SEEDS = [ + "bre_yield_phenology_traits.sql", + "bre_disease_chilling_traits.sql", + "bre_dus_seed.sql", +] + + +async def _apply_reference_sql_seeds(session) -> None: + sql_dir = Path(__file__).resolve().parent.parent / "sql" + for fname in _REFERENCE_SQL_SEEDS: + content = (sql_dir / fname).read_text(encoding="utf-8") + for stmt in content.split(";"): + body = "\n".join(ln for ln in stmt.splitlines() + if not ln.strip().startswith("--")).strip() + if body: + await session.execute(text(body)) + await session.commit() + + +async def ensure_reference(session) -> dict: + """补齐参考数据(缺失才插入),返回供后续使用的 id 映射。""" + ref = {} + + # 性状(trait_code 幂等) + existing_codes = {r[0] for r in await session.execute(select(TraitModel.trait_code))} + trait_upserts = [] + for code, name, unit, cat, vmin, vmax, stage, direction, into_ebv, *_ in NUMERIC_TRAITS: + if code not in existing_codes: + trait_upserts.append({"trait_code": code, "trait_name": name, "category": cat, "data_type": "numeric", + "unit": unit, "is_core": "1", "valid_min": vmin, "valid_max": vmax, + "stage": stage, "direction": direction, "into_ebv": into_ebv, + "default_h2": next(t[11] for t in NUMERIC_TRAITS if t[0] == code)}) + for code, name, cat, stage, options, probs in TEXT_TRAITS: + if code not in existing_codes: + trait_upserts.append({"trait_code": code, "trait_name": name, "category": cat, "data_type": "text", + "unit": None, "is_core": "1", "valid_min": None, "valid_max": None, + "stage": stage, "direction": "desc", "into_ebv": "0", + "scale_json": json.dumps(list(zip(options, probs)), ensure_ascii=False), + "default_h2": 0.2}) + if trait_upserts: + await session.execute(insert(TraitModel), [{**_base(), **r} for r in trait_upserts]) + await session.commit() + # 描述性状(43 条官方字典):撞 code 的覆盖升级(category/data_type/scale_json 对齐描述侧), + # 其余幂等插入。desc 覆盖不建重复行(avg_fruit_weight/ssc/flesh_color/fruit_shape 与 stats 同归口)。 + desc_upserts = [] + for code, name, cat, dtype, options, mu, sd, vmin, vmax, _reuse in DESCRIPTIVE_TRAITS: + if dtype == "numeric": + desc_upserts.append({"trait_code": code, "trait_name": name, "category": cat, "data_type": "numeric", + "unit": DESCRIPTIVE_UNITS.get(code), "is_core": "1", + "valid_min": vmin, "valid_max": vmax, "stage": "evaluation", + "direction": "desc", "into_ebv": "1", + "scale_json": None, "default_h2": DESCRIPTIVE_H2.get(code)}) + elif dtype == "categorical": + desc_upserts.append({"trait_code": code, "trait_name": name, "category": cat, "data_type": "categorical", + "unit": None, "is_core": "0", "valid_min": None, "valid_max": None, + "stage": "evaluation", "direction": "desc", "into_ebv": "0", + "scale_json": json.dumps(options, ensure_ascii=False), "default_h2": None}) + else: # date + desc_upserts.append({"trait_code": code, "trait_name": name, "category": cat, "data_type": "date", + "unit": None, "is_core": "0", "valid_min": None, "valid_max": None, + "stage": "evaluation", "direction": "desc", "into_ebv": "0", + "scale_json": None, "default_h2": None}) + for r in desc_upserts: + await session.execute( + pg_insert(TraitModel) + .values({**_base(), **r}) + .on_conflict_do_update( + index_elements=["trait_code"], + # is_core 不入 set_:撞 code 的 stats 行(flesh_color/fruit_shape 等)保留原 is_core, + # 仅对齐 category/data_type/scale_json 等描述侧定义 + set_={"trait_name": r["trait_name"], "category": r["category"], "data_type": r["data_type"], + "unit": r["unit"], "valid_min": r["valid_min"], + "valid_max": r["valid_max"], "scale_json": r["scale_json"], + "direction": r["direction"], "into_ebv": r["into_ebv"], "default_h2": r["default_h2"], + "updated_time": func.now()})) + await session.commit() + await _apply_reference_sql_seeds(session) + rows = await session.execute(select(TraitModel.id, TraitModel.trait_code)) + ref["trait_id_by_code"] = {code: tid for tid, code in rows.all()} + + # 人员 + if (await session.scalar(select(func.count(PersonnelModel.id))) or 0) == 0: + await session.execute(insert(PersonnelModel), [ + {**_base(), "name": n, "gender": g, "role": r, "remark": rm} + for n, g, r, rm in PERSONNEL + ]) + await session.commit() + ref["personnel_ids"] = [r[0] for r in await session.execute( + select(PersonnelModel.id).where(PersonnelModel.is_deleted == False))] # noqa: E712 + + # 基地/地块 + if (await session.scalar(select(func.count(BreedingSiteModel.id))) or 0) == 0: + await session.execute(insert(BreedingSiteModel), [ + {**_base(), "site_name": n, "address": a, "eco_type": e, "latitude": la, "longitude": lo, + "elevation": el, "remark": rm} + for n, a, e, la, lo, el, rm in SITES + ]) + await session.commit() + site_ids = [r[0] for r in await session.execute(select(BreedingSiteModel.id).order_by(BreedingSiteModel.id))] + if (await session.scalar(select(func.count(BreedingPlotModel.id))) or 0) == 0: + await session.execute(insert(BreedingPlotModel), [ + {**_base(), "site_id": site_ids[si], "plot_code": code, "row_orientation": ro, "grid_note": note, + "row_count": 40, "col_count": 30, "area": 5.0} + for si, code, ro, note in PLOTS + ]) + await session.commit() + plot_rows = await session.execute( + select(BreedingPlotModel.id, BreedingPlotModel.site_id).order_by(BreedingPlotModel.id) + .where(BreedingPlotModel.is_deleted == False)) # noqa: E712 + ref["plots"] = [{"id": pid, "site_id": sid} for pid, sid in plot_rows.all()] + + # 计划/目标/规则 + if (await session.scalar(select(func.count(PlanModel.id))) or 0) == 0: + await session.execute(insert(PlanModel), [ + {**_base(), "plan_code": c, "plan_name": n, "objective": o} + for c, n, o in PLANS + ]) + await session.commit() + plan_ids = [r[0] for r in await session.execute( + select(PlanModel.id).where(PlanModel.is_deleted == False))] # noqa: E712 + ref["plan_ids"] = plan_ids + if (await session.scalar(select(func.count(TargetModel.id))) or 0) == 0: + await session.execute(insert(TargetModel), [ + {**_base(), "target_name": n, "series": s, "description": d, "is_preset": "1", + "plan_id": plan_ids[0] if plan_ids else None} + for n, s, d in TARGETS + ]) + await session.commit() + target_rows = await session.execute( + select(TargetModel.id).order_by(TargetModel.id).where(TargetModel.is_deleted == False)) # noqa: E712 + ref["target_ids"] = [r[0] for r in target_rows.all()] + if (await session.scalar(select(func.count(SelectionRuleModel.id))) or 0) == 0: + target0 = ref["target_ids"][0] if ref["target_ids"] else None + await session.execute(insert(SelectionRuleModel), [ + {**_base(), "rule_name": n, "stage": st, "target_id": target0, "conditions_json": "[]", + "logic": "and", "action": "promote", "priority": 1, "enabled": "1", "remark": rm} + for n, st, rm in RULES + ]) + await session.commit() + rule_rows = await session.execute( + select(SelectionRuleModel.id).order_by(SelectionRuleModel.id) + .where(SelectionRuleModel.is_deleted == False)) # noqa: E712 + ref["rule_ids"] = [r[0] for r in rule_rows.all()] + + # 砧木 + if (await session.scalar(select(func.count(RootstockModel.id))) or 0) == 0: + await session.execute(insert(RootstockModel), [ + {**_base(), "rootstock_name": n, "dwarf_class": d, "compatibility": c} + for n, d, c in ROOTSTOCKS + ]) + await session.commit() + root_rows = await session.execute( + select(RootstockModel.id).order_by(RootstockModel.id) + .where(RootstockModel.is_deleted == False)) # noqa: E712 + ref["rootstock_ids"] = [r[0] for r in root_rows.all()] + + # 基础种质池(按名称幂等补齐) + germ_rows = await session.execute(select(BreedingGermplasmModel.id, BreedingGermplasmModel.cultivar_name)) + germ_by_name = {name: gid for gid, name in germ_rows.all()} + founder_missing = [FOUNDERS[i] for i, (name, *_rest) in enumerate(FOUNDERS) if name not in germ_by_name] + if founder_missing: + await session.execute(insert(BreedingGermplasmModel), [ + {**_base(), "cultivar_name": n, "variety_type": vt, "maturity_period": m, "firmness": f, + "s_alleles": s, "avg_fruit_weight": w, "ssc": ssc, "chilling_requirement": c, + "can_be_female": True, "can_be_male": True, "stage": "germplasm", "generation": "F0", + "storage_type": "田间", "is_rootstock": False, "biological_status": "landrace", + "breeding_program": "桃育种项目"} + for n, vt, m, f, s, w, ssc, c in founder_missing + ]) + await session.commit() + germ_rows = await session.execute(select(BreedingGermplasmModel.id, BreedingGermplasmModel.cultivar_name)) + germ_by_name = {name: gid for gid, name in germ_rows.all()} + ref["founder_ids"] = [germ_by_name[f[0]] for f in FOUNDERS] + return ref + + +# --------------------------------------------------------------------------- +# 清表 +# --------------------------------------------------------------------------- +async def wipe_business(session) -> None: + """清空全部 bre_* 业务表(含参考数据与历史测试残留),保留字典(sys_dict_*)。 + + 动态取当前库中所有 bre_ 前缀表,TRUNCATE ... RESTART IDENTITY CASCADE 一次清掉 + 整个外键依赖闭包(已确认无非 bre_ 表外键指向 bre_ 表),确保最终库中 + 只保留字典 + 本脚本重新生成的数据。 + """ + tbls = [r[0] for r in await session.execute(text( + "SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename LIKE 'bre_%'"))] + if tbls: + await session.execute(text(f"TRUNCATE TABLE {', '.join(tbls)} RESTART IDENTITY CASCADE")) + await session.commit() + + +# --------------------------------------------------------------------------- +# 遗传模拟与选择 +# --------------------------------------------------------------------------- +def build_gen_params() -> tuple[list[str], dict, np.ndarray, np.ndarray]: + """返回 (trait_codes, param_map, sigmaA, w)。""" + codes = [t[0] for t in NUMERIC_TRAITS] + params = {} + sigmaA = np.zeros(len(codes)) + w = np.zeros(len(codes)) + for i, t in enumerate(NUMERIC_TRAITS): + code, _name, _unit, _cat, vmin, vmax, _stage, direction, into_ebv, mu, sd, h2, weight = t + params[code] = {"mu": mu, "sd": sd, "h2": h2, "vmin": vmin, "vmax": vmax, + "direction": direction, "into_ebv": into_ebv, "stage": _stage, "idx": i} + sigmaA[i] = sd * np.sqrt(h2) + w[i] = weight + wsum = np.abs(w).sum() + if wsum > 0: + w = w / wsum + return codes, params, sigmaA, w + + +def idx_score(bv: np.ndarray, sd: np.ndarray, w: np.ndarray) -> np.ndarray: + """加权标准化 BV 指数(高 = 优)。""" + return (bv / sd) @ w + + +def sample_bv_parents(rng: np.random.Generator, dam_bv: np.ndarray, sire_bv: np.ndarray, sigmaA: np.ndarray) -> np.ndarray: + """后代 BV = 中亲 + 孟德尔抽样(sd = sigmaA/sqrt(2))。""" + return 0.5 * (dam_bv + sire_bv) + rng.normal(0.0, 1.0, size=sigmaA.shape) * (sigmaA * 0.7071) + + +def select_pool(pool: np.ndarray, idx: np.ndarray, frac: float, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]: + """在候选池内按指数取前 frac(含小随机扰动保持多样性),返回 (选中, 淘汰)。""" + n = len(pool) + k = max(1, int(round(n * frac))) + if k >= n: + return pool, np.array([], dtype=int) + jitter = rng.normal(0.0, 0.05, size=n) + rank = np.argsort(-(idx[pool] + jitter)) + return pool[rank[:k]], pool[rank[k:]] + + +async def apply_elim_status(session) -> None: + """正向晋级全部完成后,将存在淘汰记录的树置为终止态(只进不退,安全)。""" + await session.execute(text( + "UPDATE bre_tree t SET status='eliminated' " + "WHERE EXISTS (SELECT 1 FROM bre_selection_result s " + "WHERE s.tree_id = t.id AND s.is_deleted = false AND s.is_selected = 'eliminated')")) + + +# --------------------------------------------------------------------------- +# 主流程 +# --------------------------------------------------------------------------- +async def main() -> None: + parser = argparse.ArgumentParser(description="桃育种压测级模拟数据生成器") + parser.add_argument("--dry-run", action="store_true", help="小规模验证(精简组合/株数)") + parser.add_argument("--seed", type=int, default=42, help="随机种子") + parser.add_argument("--no-wipe", action="store_true", help="不清空既有业务数据") + parser.add_argument("--verify", action="store_true", help="生成后运行自检") + parser.add_argument("--batch-size", type=int, default=50, help="批量晋级每批单株数") + parser.add_argument("--end-year", type=int, default=WINDOW_END, help="数据终止年份") + args = parser.parse_args() + + if settings.DATABASE_TYPE != "postgres": + raise SystemExit("本脚本仅支持 Postgres(dev 环境)。请以 ENVIRONMENT=dev 运行。") + + dry = args.dry_run + f1_years = list(F1_YEARS) if not dry else [2007, 2008, 2009, 2010] + f1_per_year = 3 if dry else 12 + f1_trees = 24 if dry else F1_TREES + f2_combos = 3 if dry else F2_COMBOS + f2_trees = 16 if dry else F2_TREES + bc_combos = 2 if dry else BC_COMBOS + bc_trees = 12 if dry else BC_TREES + end_year = args.end_year + + rng = np.random.default_rng(args.seed) + pyrand = random.Random(args.seed) + codes, params, sigmaA, w = build_gen_params() + nT = len(codes) + sd = np.array([params[c]["sd"] for c in codes]) + + engine = create_async_engine(settings.ASYNC_DB_URI, pool_size=8, max_overflow=12, pool_pre_ping=True) + Session = async_sessionmaker(engine, expire_on_commit=False) + auth = AuthSchema(user=CoreUserSchema(id=1, username="admin", name="管理员", is_superuser=True)) + t0 = datetime.now(UTC) + counts = {"trees": 0, "evals": 0, "obs": 0, "field_ops": 0, "selections": 0, + "combos": 0, "plantings": 0, "clones": 0, "germplasm": 0, "pedigree": 0, + "trials": 0, "studies": 0, "entries": 0, + "pollination": 0, "pollen": 0, "seed_lots": 0, "seed_treatments": 0, "seedlings": 0, + "propagations": 0, "env_conds": 0, "observations": 0, "treatments": 0} + print(f"[sim] ENVIRONMENT={settings.ENVIRONMENT} DB={settings.DATABASE_TYPE}@{settings.DATABASE_NAME} dry_run={dry}") + + async with Session() as session: + if not args.no_wipe: + print("[sim] 1/8 清空全部 bre_* 业务表(字典保留)...") + await wipe_business(session) + print("[sim] 2/8 补齐参考数据(性状/人员/基地/地块/目标/规则/砧木/基础种质池)...") + ref = await ensure_reference(session) + trait_id = ref["trait_id_by_code"] + personnel_ids = ref["personnel_ids"] + plots = ref["plots"] + founder_ids = ref["founder_ids"] + nf = len(founder_ids) + rule_ids = ref["rule_ids"] + rootstock_ids = ref["rootstock_ids"] + target_ids = ref["target_ids"] + plan_ids = ref["plan_ids"] + + # 基础种质池遗传值(标准正态 × 加性标准差) + founder_bv = rng.normal(0.0, 1.0, (nf, nT)) * sigmaA[None, :] + + combo_info: list[dict] = [] # 每个组合的生成信息(供 F2/BC 亲本 BV、系谱/区试映射) + promo_queue: list[dict] = [] # 正向晋级队列(按组合内轮次顺序,最终走服务层) + elim_accum: list[dict] = [] # 淘汰记录(全部晋级完成后统一批量回写) + + # ---------- F1 ---------- + f1_combos = [(y, seq) for y in f1_years for seq in range(1, f1_per_year + 1)] + print(f"[sim] 3/8 生成 F1 杂交组合 {len(f1_years)} 年度 × {f1_per_year} 组合/年 = {len(f1_combos)} 个 → 单株 ...") + combo_rows = [] + for y, seq in f1_combos: + dam_i = (y * 7 + seq * 3) % nf + sire_i = (dam_i + 1 + seq) % nf + while sire_i == dam_i: + sire_i = (sire_i + 1) % nf + combo_rows.append({ + "combination_code": f"桃{y}-{seq:03d}", "cross_year": y, + "bre_target_id": target_ids[(y + seq) % len(target_ids)], + "female_parent_id": founder_ids[dam_i], "male_parent_id": founder_ids[sire_i], + "cross_method": "人工授粉", "cross_type": "杂交", "design_type": "full_diallel", + "stage": "seedling", "cross_date": f"{y}-04-{10 + seq % 10:02d}", + "seed_count": pyrand.randint(300, 800), + "reason": "目标亲本组配", "remark": "F1 杂种实生苗圃", + }) + res = await session.execute( + insert(CrossCombinationModel).values([{**_base(), **r} for r in combo_rows]).returning(CrossCombinationModel.id)) + f1_combo_ids = list(res.scalars()) + counts["combos"] += len(f1_combo_ids) + + combo_defs = [] + for (y, seq), cid in zip(f1_combos, f1_combo_ids): + code = f"桃{y}-{seq:03d}" + dam_i = (y * 7 + seq * 3) % nf + sire_i = (dam_i + 1 + seq) % nf + while sire_i == dam_i: + sire_i = (sire_i + 1) % nf + combo_defs.append({"code": code, "id": cid, "generation": "F1", + "dam_i": dam_i, "sire_i": sire_i, + "dam_id": founder_ids[dam_i], "sire_id": founder_ids[sire_i], + "planted": y + 2, "cross_year": y, "n": f1_trees}) + + for c in combo_defs: + await _gen_combo(session, counts, combo_info, promo_queue, elim_accum, c, + founder_bv, params, sigmaA, sd, w, codes, rng, pyrand, + personnel_ids, plots, rule_ids, rootstock_ids, end_year, trait_id) + await session.commit() + print(f"[sim] 累计: 组合={counts['combos']} 单株={counts['trees']} 评价={counts['evals']} " + f"观测={counts['obs']} 农事={counts['field_ops']} 淘汰={len(elim_accum)}") + + # ---------- F1 选择晋级(真实服务层 create_batch) ---------- + print("[sim] 4/8 运行 F1 选择晋级(服务层批量,sp/ap/line/regional/released)...") + ok, fail = await run_promotions(session, auth, promo_queue, args.batch_size) + counts["selections"] += ok + promo_queue.clear() + print(f"[sim] F1 晋级成功 {ok} / 失败 {fail}") + + # 收集 line 及以上种质(id / BV),供 F2/BC 亲本与区试 + line_germ = await collect_line_germplasms(session, combo_info) + print(f"[sim] line 及以上种质 {len(line_germ)} 个") + + # ---------- F2 / BC ---------- + print("[sim] 5/8 生成 F2 自交 / BC1 回交组合与单株 ...") + d2c = await _gen_derived_combos( + session, counts, combo_info, promo_queue, elim_accum, line_germ, founder_bv, founder_ids, + target_ids, f2_combos, f2_trees, bc_combos, bc_trees, + params, sigmaA, sd, w, codes, rng, pyrand, personnel_ids, plots, + rule_ids, rootstock_ids, end_year, trait_id) + await session.commit() + print(f"[sim] 派生组合 {d2c} 个,累计单株 {counts['trees']} 观测 {counts['obs']}") + + print("[sim] 6/8 运行 F2/BC 选择晋级 ...") + ok2, fail2 = await run_promotions(session, auth, promo_queue, args.batch_size) + counts["selections"] += ok2 + promo_queue.clear() + print(f"[sim] F2/BC 晋级成功 {ok2} / 失败 {fail2}") + + # 晋级全部完成后:批量回写淘汰记录 + 淘汰树状态 + if elim_accum: + await insert_chunked(session, SelectionResultModel, [{**_base(), **r} for r in elim_accum]) + counts["selections"] += len(elim_accum) + await apply_elim_status(session) + await session.commit() + print(f"[sim] 淘汰记录 {len(elim_accum)} 条已回写,淘汰树状态已置位") + + # ---------- 区域试验 ---------- + print("[sim] 7/8 生成区域试验(trial / study / entry)...") + await gen_trials(session, counts, combo_info, plots, target_ids, plan_ids) + await session.commit() + + # ---------- 业务流空表补齐 ---------- + print("[sim] 8/8 补齐业务流空表(授粉/花粉/种子批/种子处理/育苗/克隆扩繁/环境气象/通用观测/试验处理)...") + await gen_support_data(session, counts, combo_info, personnel_ids, plots, + rootstock_ids, trait_id, params, codes, end_year, pyrand, rng) + await session.commit() + + # ---------- 自检 ---------- + elapsed = (datetime.now(UTC) - t0).total_seconds() + print(f"[sim] 生成完成,耗时 {elapsed:.1f}s。") + await report_counts(session, counts) + if args.verify: + await verify(session, counts, dry) + + await engine.dispose() + + +# --------------------------------------------------------------------------- +# 单个组合生成(组合 → 定植 → 单株 → 选择排程 → 淘汰记录/评价/观测/农事 + 晋级入队) +# --------------------------------------------------------------------------- +async def _gen_combo(session, counts, combo_info, promo_queue, elim_accum, c, + founder_bv, params, sigmaA, sd, w, codes, rng, pyrand, + personnel_ids, plots, rule_ids, rootstock_ids, end_year, trait_id) -> None: + code = c["code"] + cid = c["id"] + n = c["n"] + planted = c["planted"] + generation = c["generation"] + + # 父本 BV + if generation == "F1": + dam_bv, sire_bv = founder_bv[c["dam_i"]], founder_bv[c["sire_i"]] + else: + dam_bv, sire_bv = c["dam_bv"], c["sire_bv"] + + # 单株 BV 与指数 + bv = np.zeros((n, len(codes))) + for i in range(n): + bv[i] = sample_bv_parents(rng, dam_bv, sire_bv, sigmaA) + idx = idx_score(bv, sd, w) + + plot = plots[c["cross_year"] % len(plots)] + root = rootstock_ids[(c["cross_year"] * 3) % len(rootstock_ids)] + persona = personnel_ids[c["cross_year"] % len(personnel_ids)] + + # 定植批次 + plant_row = {**_base(), + "combination_id": cid, "plot_id": plot["id"], "planting_date": f"{planted}-03-15", + "tree_count": n, "row_no": 1, "col_no": 1, "bre_personnel_id": persona, + "rootstock_id": root, "block_no": 1, "remark": f"{code} 定植"} + pres = await session.execute(insert(PlantingModel).values([plant_row]).returning(PlantingModel.id)) + planting_id = list(pres.scalars())[0] + counts["plantings"] += 1 + + # 单株 + tree_rows = [{ + "combination_id": cid, "dam_id": c["dam_id"], "sire_id": c["sire_id"], + "tree_no": f"{code}-{i + 1:03d}", "plot_id": plot["id"], "planting_id": planting_id, + "row_no": i // 20 + 1, "col_no": i % 20 + 1, "rootstock_id": root, + "planted_date": f"{planted}-03-15", "bre_personnel_id": persona, + "status": "alive", "stage": "seedling", "generation": generation, + "remark": f"{code} 实生苗", + } for i in range(n)] + res = await session.execute(insert(TreeModel).values([{**_base(), **r} for r in tree_rows]).returning(TreeModel.id)) + tree_ids = list(res.scalars()) + counts["trees"] += n + + info = {"code": code, "combo_id": cid, "generation": generation, "planted": planted, + "cross_year": c["cross_year"], "n": n, + "tree_ids": tree_ids, "bv": bv, "rounds": {}} + combo_info.append(info) + + # 童期观测(定植年 ~ sp 前一年 / 窗口末) + sp_year = planted + 3 + await gen_juvenile_obs(session, counts, cid, tree_ids, bv, trait_id, params, sd, w, + planted, min(sp_year - 1, end_year), rng, code) + + # 选择排程 + alive_pool = np.arange(n) + current_sel = None + for ri, (rname, is_selected, frac, from_stage, to_stage, off) in enumerate(ROUNDS): + ryear = planted + off + pool = alive_pool if rname == "sp" else current_sel + if pool is None or ryear > end_year: + info["rounds"][rname] = {"year": ryear, "active": False} + continue + sel, elim = select_pool(pool, idx, frac, rng) + # 淘汰记录(暂存,晋级全部完成后统一回写) + for ei in elim: + elim_accum.append({ + "combination_id": cid, "tree_id": tree_ids[ei], "tree_no": f"{code}-{ei + 1:03d}", + "selection_year": ryear, "is_selected": "eliminated", + "from_stage": from_stage, "to_stage": from_stage, + "reason": f"{rname} 轮未达入选标准", "remark": "模拟数据"}) + info["rounds"][rname] = {"year": ryear, "active": True, "sel": sel, "elim": elim} + if rname == "sp": + info["sp_order"] = [int(i) for i in sel] # 晋级 clone 序由此决定 + # 入队正向晋级(选中树) + if len(sel): + promo_queue.append({ + "combination_id": cid, "selection_year": ryear, "is_selected": is_selected, + "from_stage": from_stage, "to_stage": to_stage, + "rule_id": rule_ids[min(ri, len(rule_ids) - 1)] if rule_ids else None, + "approved_by": personnel_ids[0], + "reason": f"{rname} 轮入选(指数前 {len(sel)} 名)", "remark": "模拟数据", + "tree_ids": [tree_ids[int(i)] for i in sel], + }) + current_sel = sel + + # 成株评价与观测:各轮选中树,自 sp_year 至淘汰年前一年/窗口末 + await gen_eval_obs(session, counts, info, cid, tree_ids, bv, trait_id, params, sd, w, + sp_year, end_year, rng, personnel_ids, plot, code, pyrand) + + # 小区级农事(每年) + await gen_plot_ops(session, counts, plot["id"], planted, end_year, personnel_ids, pyrand, code) + + +def _gen_desc_obs_rows(base, tree_id, cid, y, trait_id, code, bvec, idx_of, + pyrand, rng, year_eff=None, reuse_vals=None, + evaluation_id=None, stage="evaluation") -> list[dict]: + """为单棵树单年份生成 43 条描述性状观测(仅入选候选树 / ramet 分层使用)。 + + categorical → value_text(scale_json 选项抽样);date → value_date(花期 3-4 月); + numeric core → value_numeric(遗传代理 + 年度 + 误差);纵/横径复用 stats 同源观测值。 + """ + rows = [] + year_eff = year_eff or {} + reuse_vals = reuse_vals or {} + for dcode, _name, _cat, dtype, options, mu, sd, vmin, vmax, reuse in DESCRIPTIVE_TRAITS: + # avg_fruit_weight/ssc 与 stats 撞 code 且同为 numeric:其观测由既有数值循环写入 + # (同一 (evaluation_id, trait_id) 唯一约束下不可双写),描述侧不再造值。 + if dcode in ("avg_fruit_weight", "ssc"): + continue + row = {**_base(), + "tree_id": tree_id, "combination_id": cid, + "trait_id": trait_id[dcode], "evaluate_year": y, + "stage": stage, "remark": f"{code} 描述观测"} + if evaluation_id is not None: + row["evaluation_id"] = evaluation_id + if dtype == "categorical": + row["value_text"] = str(pyrand.choice(options)) + elif dtype == "date": + row["value_date"] = f"{y}-{pyrand.randint(3, 4):02d}-{pyrand.randint(1, 28):02d}" + else: + if reuse is not None: + v = reuse_vals.get(reuse) + if v is None: + continue + row["value_numeric"] = float(v) + else: + proxy, scale = DESCRIPTIVE_BV_PROXY.get(dcode, (None, 1.0)) + g = scale * bvec[idx_of[proxy]] if proxy is not None and proxy in idx_of else 0.0 + row["value_numeric"] = clip_round(mu + g + year_eff.get(dcode, 0.0) + + rng.normal(0, sd * 0.6), vmin, vmax) + rows.append(row) + return rows + + +async def gen_juvenile_obs(session, counts, cid, tree_ids, bv, trait_id, params, sd, w, + start_year, end_year, rng, code) -> None: + if end_year < start_year: + return + juv = [c for c in params if params[c]["stage"] == "juvenile"] + rows = [] + for y in range(start_year, end_year + 1): + year_eff = {c: rng.normal(0, params[c]["sd"] * 0.10) for c in juv} + for i in range(len(tree_ids)): + for c in juv: + p = params[c] + val = clip_round(p["mu"] + bv[i, p["idx"]] + year_eff[c] + rng.normal(0, p["sd"] * 0.6), p["vmin"], p["vmax"]) + rows.append({**_base(), + "tree_id": tree_ids[i], "combination_id": cid, + "trait_id": trait_id[c], "evaluate_year": y, + "value_numeric": val, "stage": "juvenile", + "remark": f"{code} 童期观测"}) + if rows: + counts["obs"] += await insert_chunked(session, TraitObservationModel, rows) + + +async def gen_eval_obs(session, counts, info, cid, tree_ids, bv, trait_id, params, sd, w, + sp_year, end_year, rng, personnel_ids, plot, code, pyrand) -> None: + eval_num = [c for c in params if params[c]["stage"] == "evaluation"] + text_specs = {t[0]: t for t in TEXT_TRAITS} + idx_of = {c: params[c]["idx"] for c in params} + eval_tree_idx = None + for rname in ("sp", "ap", "line", "regional", "released"): + r = info["rounds"].get(rname) + if r and r.get("active"): + eval_tree_idx = r["sel"] + break + if eval_tree_idx is None: + return + # 各树淘汰年前一年为止(无淘汰则评估至窗口末) + elim_year = {} + for rname in ("ap", "line", "regional", "released"): + r = info["rounds"].get(rname) + if r and r.get("active") and r.get("elim") is not None: + for ei in r["elim"]: + elim_year[ei] = r["year"] - 1 + for y in range(sp_year, end_year + 1): + active = [i for i in eval_tree_idx if elim_year.get(i, end_year + 1) >= y] + if not active: + continue + year_eff = {c: rng.normal(0, params[c]["sd"] * 0.10) for c in eval_num} + # 单株评价(先插,取 id 供观测外键) + e_rows, eval_trees = [], [] + for i in active: + z = idx_score(bv[i:i + 1], sd, w)[0] + score = float(np.clip(55 + 25 * (z / 1.5) + rng.normal(0, 3), 0, 100)) + m = pyrand.randint(6, 8) + d = pyrand.randint(10, 28) + e_rows.append({**_base(), + "combination_id": cid, "tree_id": tree_ids[i], "evaluate_year": y, + "evaluate_date": f"{y}-{m:02d}-{d:02d}", + "bre_personnel_id": personnel_ids[y % len(personnel_ids)], + "overall_score": round(score, 1), + "crop_load": round(pyrand.uniform(0.3, 1.0), 2), + "remark": f"{code} 成株评价"}) + eval_trees.append((i, m, d)) + res = await session.execute(insert(TreeEvaluationModel).values(e_rows).returning(TreeEvaluationModel.id)) + eval_ids = list(res.scalars()) + counts["evals"] += len(e_rows) + eval_map = {i: eid for (i, _m, _d), eid in zip(eval_trees, eval_ids)} + date_map = {i: (m, d) for i, m, d in eval_trees} + # 观测(数值 + 文本)与采收(同源表型,保证一致) + o_rows, h_rows = [], [] + for i in active: + eid = eval_map[i] + vals = {} + for c in eval_num: + p = params[c] + val = clip_round(p["mu"] + bv[i, p["idx"]] + year_eff[c] + rng.normal(0, p["sd"] * 0.6), p["vmin"], p["vmax"]) + vals[c] = val + o_rows.append({**_base(), + "evaluation_id": eid, "tree_id": tree_ids[i], "combination_id": cid, + "trait_id": trait_id[c], "evaluate_year": y, + "value_numeric": val, "stage": "evaluation", + "crop_load": round(pyrand.uniform(0.3, 1.0), 2), + "remark": f"{code} 成株观测"}) + for tcode, _name, _cat, _st, opts, probs in text_specs.values(): + if tcode in ("flesh_color", "fruit_shape"): + continue # 已由描述性状 categorical 覆盖(同一 (evaluation_id, trait_id) 不双写) + o_rows.append({**_base(), + "evaluation_id": eid, "tree_id": tree_ids[i], "combination_id": cid, + "trait_id": trait_id[tcode], "evaluate_year": y, + "value_text": str(pyrand.choices(opts, weights=probs, k=1)[0]), + "stage": "evaluation", "remark": f"{code} 成株观测"}) + # 描述性状:候选树全量 43 条,挂同一评价头(纵/横径复用本次 stats 观测) + o_rows.extend(_gen_desc_obs_rows(_base(), tree_ids[i], cid, y, trait_id, code, + bv[i], idx_of, pyrand, rng, + year_eff=year_eff, reuse_vals=vals, + evaluation_id=eid, stage="evaluation")) + m, d = date_map[i] + h_rows.append({**_base(), + "tree_id": tree_ids[i], "plot_id": plot["id"], "op_type": "harvest", + "op_date": date(y, m, d), "op_detail": "采收并记录单株产量", + "yield_kg": vals.get("single_tree_yield"), + "fruit_count": int(vals.get("fruit_number") or 0), + "avg_fruit_weight": vals.get("avg_fruit_weight"), + "marketable_rate": vals.get("marketable_rate"), + "operator_id": personnel_ids[y % len(personnel_ids)], "status": 1, + "remark": f"{code} 采收"}) + if o_rows: + counts["obs"] += await insert_chunked(session, TraitObservationModel, o_rows) + if h_rows: + counts["field_ops"] += await insert_chunked(session, FieldOperationModel, h_rows) + + +async def gen_plot_ops(session, counts, plot_id, start_year, end_year, personnel_ids, pyrand, code) -> None: + ops = [("fertilize", 3), ("spray", 5), ("prune", 2), ("irrigate", 6)] + rows = [] + for y in range(start_year, end_year + 1): + for op, mo in ops: + rows.append({**_base(), "plot_id": plot_id, "op_type": op, + "op_date": date(y, mo, pyrand.randint(1, 28)), + "op_detail": f"{code} 地块 {op}", "operator_id": personnel_ids[y % len(personnel_ids)], + "status": 1, "remark": "小区例行农事"}) + if rows: + counts["field_ops"] += await insert_chunked(session, FieldOperationModel, rows) + + +# --------------------------------------------------------------------------- +# 批量晋级(真实服务层 create_batch,事务化 + 失败单株回退重试) +# --------------------------------------------------------------------------- +async def run_promotions(session, auth, queue, batch_size) -> tuple[int, int]: + ok = fail = 0 + with bre_audit_suppress(): + for item in queue: + base = {k: item[k] for k in + ("selection_year", "is_selected", "rule_id", "approved_by", + "from_stage", "to_stage", "reason", "remark")} + for chunk in chunked(item["tree_ids"], batch_size): + data = SelectionResultBatchCreateSchema(combination_id=item["combination_id"], tree_id=chunk, **base) + try: + svc = SelectionResultService(auth, session) + res = await svc.create_batch(data) + await session.commit() + ok += res.success_count + fail += res.fail_count + for fd in res.fail_details: + print(f"[sim] !! 晋级失败: {fd}") + except Exception as e: # 整批失败:逐棵回退重试 + await session.rollback() + for tid in chunk: + single = SelectionResultBatchCreateSchema(combination_id=item["combination_id"], tree_id=[tid], **base) + try: + svc = SelectionResultService(auth, session) + r = await svc.create_batch(single) + await session.commit() + ok += r.success_count + fail += r.fail_count + except Exception as e2: + await session.rollback() + fail += 1 + print(f"[sim] !! 单棵晋级失败 tree_id={tid}: {e2}") + return ok, fail + + +# --------------------------------------------------------------------------- +# 派生组合(F2 / BC) +# --------------------------------------------------------------------------- +async def collect_line_germplasms(session, combo_info) -> list[dict]: + """返回 line 及以上选中树的种质信息 [{code, seq, bv, germplasm_id, combo_id, year, clone_code}]。""" + names = [] + for info in combo_info: + r = info["rounds"].get("line") + if not r or not r.get("active") or "sel" not in r: + continue + sp_order = info.get("sp_order", []) + for ei in r["sel"]: + ei = int(ei) + if ei not in sp_order: + continue + seq = sp_order.index(ei) + 1 + names.append({"code": info["code"], "seq": seq, "bv": info["bv"][ei].copy(), + "combo_id": info["combo_id"], "year": r["year"], "clone_code": f"{info['code']}-{seq:04d}"}) + if not names: + return [] + rows = await session.execute( + text("SELECT id, cultivar_name FROM bre_germplasm WHERE cultivar_name = ANY(:codes)"), + {"codes": [n["clone_code"] for n in names]}) + gid_by_name = {name: gid for gid, name in rows.all()} + for n in names: + n["germplasm_id"] = gid_by_name.get(n["clone_code"]) + return [n for n in names if n["germplasm_id"]] + + +async def _gen_derived_combos(session, counts, combo_info, promo_queue, elim_accum, line_germ, + founder_bv, founder_ids, target_ids, f2_combos, f2_trees, + bc_combos, bc_trees, + params, sigmaA, sd, w, codes, rng, pyrand, personnel_ids, plots, + rule_ids, rootstock_ids, end_year, trait_id) -> int: + if not line_germ: + return 0 + candidates = [g for g in line_germ if g["year"] <= end_year - 3] + if not candidates: + return 0 + nfg = len(founder_ids) + ncan = len(candidates) + defs = [] + for k in range(f2_combos): + a = candidates[k % ncan] + b_idx = (k * 7 + 3) % ncan + b = candidates[b_idx] + # 候选池中同一组合的多个 line 种质(同 combo_id)连续成块, + # 若固定 index 取到同块元素会无限循环,故循环体内 index 递增并加防死循环上限 + guard = 0 + while b["combo_id"] == a["combo_id"] and guard < ncan: + b_idx = (b_idx + 1) % ncan + b = candidates[b_idx] + guard += 1 + if b["combo_id"] == a["combo_id"]: + continue + cy = max(a["year"], b["year"]) + 1 + if cy > end_year - 1: + continue + defs.append({"generation": "F2", "dam": a, "sire": b, "cross_year": cy, "n": f2_trees, "kind": "F2"}) + for k in range(bc_combos): + a = candidates[k % len(candidates)] + fi = (k * 5 + 1) % nfg + cy = a["year"] + 1 + if cy > end_year - 1: + continue + defs.append({"generation": "BC1", "dam": a, + "sire": {"bv": founder_bv[fi], "germplasm_id": founder_ids[fi]}, + "cross_year": cy, "n": bc_trees, "kind": "BC"}) + if not defs: + return 0 + combo_rows = [] + for k, d in enumerate(defs): + code = f"桃{d['cross_year']}{d['kind']}-{k + 1:03d}" + combo_rows.append({ + "combination_code": code, "cross_year": d["cross_year"], + "bre_target_id": target_ids[k % len(target_ids)], + "female_parent_id": d["dam"].get("germplasm_id"), + "male_parent_id": d["sire"].get("germplasm_id"), + "cross_method": "人工授粉", "cross_type": "杂交" if d["kind"] == "F2" else "回交", + "design_type": "partial_diallel", "stage": "seedling", + "cross_date": f"{d['cross_year']}-04-{10 + k % 10:02d}", "seed_count": pyrand.randint(200, 500), + "reason": "世代推进", "remark": f"{d['kind']} 分离世代", + }) + res = await session.execute( + insert(CrossCombinationModel).values([{**_base(), **r} for r in combo_rows]).returning(CrossCombinationModel.id)) + ids = list(res.scalars()) + counts["combos"] += len(ids) + nd = len(defs) + for k, (d, cid) in enumerate(zip(defs, ids)): + if k % 20 == 0 or k == nd - 1: + print(f"[sim] F2/BC 组合 {k + 1}/{nd}({d['kind']} {d['cross_year']})...") + c = {"code": f"桃{d['cross_year']}{d['kind']}-{k + 1:03d}", "id": cid, + "generation": d["generation"], "planted": d["cross_year"] + 1, + "dam_bv": d["dam"]["bv"], "sire_bv": d["sire"]["bv"], + "dam_id": d["dam"].get("germplasm_id"), "sire_id": d["sire"].get("germplasm_id"), + "cross_year": d["cross_year"], "n": d["n"]} + await _gen_combo(session, counts, combo_info, promo_queue, elim_accum, c, + founder_bv, params, sigmaA, sd, w, codes, rng, pyrand, + personnel_ids, plots, rule_ids, rootstock_ids, end_year, trait_id) + return len(ids) + + +# --------------------------------------------------------------------------- +# 区域试验 +# --------------------------------------------------------------------------- +async def gen_trials(session, counts, combo_info, plots, target_ids, plan_ids) -> None: + regional = [] # (combo_id, year, clone_code) + for info in combo_info: + r = info["rounds"].get("regional") + if not r or not r.get("active") or "sel" not in r: + continue + sp_order = info.get("sp_order", []) + for ei in r["sel"]: + ei = int(ei) + if ei not in sp_order: + continue + seq = sp_order.index(ei) + 1 + regional.append((info["combo_id"], r["year"], f"{info['code']}-{seq:04d}")) + if not regional: + return + rows = await session.execute( + text("SELECT id, cultivar_name FROM bre_germplasm WHERE cultivar_name = ANY(:codes)"), + {"codes": [name for _, _, name in regional]}) + gid_by_name = {name: gid for gid, name in rows.all()} + by_year: dict[int, list] = {} + for cid, y, name in regional: + gid = gid_by_name.get(name) + if gid: + by_year.setdefault(y, []).append((cid, gid)) + for y, items in sorted(by_year.items()): + trial = {**_base(), "trial_name": f"桃区试-{y}", "plan_id": plan_ids[0] if plan_ids else None, + "target_id": target_ids[y % len(target_ids)] if target_ids else None, + "trial_type": "regional", "design_type": "randomized_block", + "start_year": y, "end_year": y + 2, "objective": f"{y} 年区域试验", "remark": "模拟数据"} + tres = await session.execute(insert(TrialModel).values([trial]).returning(TrialModel.id)) + trial_id = list(tres.scalars())[0] + counts["trials"] += 1 + for site in {p["site_id"] for p in plots}: + study = {**_base(), "trial_id": trial_id, "study_name": f"区试-{y}-site{site}", "site_id": site, + "year": y, "block_count": 3, "season": "夏", "design_type": "randomized_block", + "remark": "模拟数据"} + sres = await session.execute(insert(TrialStudyModel).values([study]).returning(TrialStudyModel.id)) + study_id = list(sres.scalars())[0] + counts["studies"] += 1 + entry_rows = [{**_base(), "trial_study_id": study_id, "entry_number": eno, + "germplasm_id": gid, "combination_id": cid, "block_no": eno % 3 + 1, + "remark": "模拟数据"} + for eno, (cid, gid) in enumerate(items, start=1)] + await insert_chunked(session, TrialStudyEntryModel, entry_rows) + counts["entries"] += len(entry_rows) + + +# --------------------------------------------------------------------------- +# 业务流空表补齐(授粉→花粉→种子批→种子处理→育苗、克隆扩繁、环境气象、通用观测、试验处理) +# --------------------------------------------------------------------------- +async def gen_support_data(session, counts, combo_info, personnel_ids, plots, rootstock_ids, + trait_id, params, codes, end_year, pyrand, rng) -> None: + """为流程侧空表生成模拟数据(纯批量 insert,不走服务层)。 + + 授粉链按组合逐条生成(F1/F2/BC 全含);扩繁只对晋级到 line 的树(其 clone 已由 + 选择晋级服务层创建);气象按 site×year 唯一;通用观测给 line 树补 EAV 记录。 + """ + # 组合基础信息(seed_count / 父本种质) + combo_meta = {cid: {"seed_count": sc, "male_parent_id": mpid} + for cid, sc, mpid in (await session.execute( + text("SELECT id, seed_count, male_parent_id FROM bre_cross_combination"))).all()} + name_by_gid = {gid: name for gid, name in (await session.execute( + select(BreedingGermplasmModel.id, BreedingGermplasmModel.cultivar_name))).all()} + + # 1) 每组合一条授粉链:pollen → pollination → seed_lot → seed_treatment → seedling + pollen_rows, polli_rows, seed_rows, treat_rows, seedl_rows = [], [], [], [], [] + for info in combo_info: + code, cid = info["code"], info["combo_id"] + cy = info["cross_year"] + meta = combo_meta.get(cid, {"seed_count": pyrand.randint(300, 800), "male_parent_id": None}) + seed_count = meta["seed_count"] or pyrand.randint(300, 800) + mpid = meta.get("male_parent_id") + plot = plots[cy % len(plots)] + persona = personnel_ids[cy % len(personnel_ids)] + # 花粉批次(父本混合花粉,母/父本为种质无树) + pollen_rows.append({**_base(), + "lot_code": f"PL-{code}", "male_tree_id": None, "source_type": "mixed", + "source_desc": f"父本混合花粉({name_by_gid.get(mpid, '亲本')})", + "collect_date": date(cy, 3, 15), "collect_method": "采花取粉", + "quantity": f"{pyrand.randint(20, 80)}g", "storage_method": "minus80", + "viability_method": "ttc", "viability_pct": round(pyrand.uniform(70, 92), 1), + "viability_test_date": date(cy, 3, 20), "expiry_date": date(cy, 5, 15), + "remark": "模拟数据"}) + # 授粉 + polli_rows.append({**_base(), + "combination_id": cid, "plot_id": plot["id"], + "pollination_date": f"{cy}-04-{10 + cy % 10:02d}", + "flower_count": pyrand.randint(800, 3000), "effective_count": pyrand.randint(200, 800), + "bre_personnel_id": persona, "pollinator": None, + "emasculation_date": date(cy, 4, 5), "bagging_date": date(cy, 4, 25), + "female_tree_id": None, "male_tree_id": None, + "pollination_method": "人工点授", "pollen_lot_id": None, "remark": "模拟数据"}) + # 育苗出苗数(衔接定植株数 n),种子批 used_count 同步 + emerg = max(int(info["n"] * pyrand.uniform(1.2, 2.0)), 1) + strong = int(emerg * pyrand.uniform(0.85, 1.0)) + seed_rows.append({**_base(), + "combination_id": cid, "pollination_id": None, "lot_code": f"SL-{code}", + "harvest_year": cy, "seed_count": seed_count, "used_count": emerg, + "germination_rate": round(pyrand.uniform(55, 85), 2), + "storage_type": "种子库", "storage_location": f"库A-{cy % 8 + 1}架", + "test_date": date(cy, 8, 15), "remark": "模拟数据"}) + # 种子处理(冬季低温层积) + treat_rows.append({**_base(), + "combination_id": cid, "seed_lot_id": None, "treatment_method": "低温层积催芽", + "treatment_start": f"{cy}-10-01", "treatment_end": f"{cy + 1}-02-15", + "germination_rate": round(pyrand.uniform(55, 85), 1), + "bre_personnel_id": persona, "remark": "模拟数据"}) + # 育苗(stage 3 壮苗,衔接定植) + sowing = f"{cy + 1}-03-05" + seedl_rows.append({**_base(), + "combination_id": cid, "treatment_id": None, "seed_lot_id": None, + "batch_no": f"YP-{cid}-{sowing.replace('-', '')}-1", + "sowing_date": sowing, "tray_no": f"T-{cy % 40 + 1:03d}", "nursery": "育苗圃", + "seedling_count": emerg, "emergence_date": f"{cy + 1}-04-01", + "strong_seedling_date": f"{cy + 1}-05-10", "strong_seedling_count": strong, + "stage": "3", "bre_personnel_id": persona, "remark": "模拟数据"}) + + if pollen_rows: + pollen_ids = list((await session.execute( + insert(PollenModel).values(pollen_rows).returning(PollenModel.id))).scalars()) + for r, pid in zip(polli_rows, pollen_ids): + r["pollen_lot_id"] = pid + polli_ids = list((await session.execute( + insert(PollinationModel).values(polli_rows).returning(PollinationModel.id))).scalars()) + for r, pid in zip(seed_rows, polli_ids): + r["pollination_id"] = pid + seed_ids = list((await session.execute( + insert(SeedLotModel).values(seed_rows).returning(SeedLotModel.id))).scalars()) + for r, sid in zip(treat_rows, seed_ids): + r["seed_lot_id"] = sid + treat_ids = list((await session.execute( + insert(SeedTreatmentModel).values(treat_rows).returning(SeedTreatmentModel.id))).scalars()) + for r, tid, sid in zip(seedl_rows, treat_ids, seed_ids): + r["treatment_id"], r["seed_lot_id"] = tid, sid + await insert_chunked(session, SeedlingModel, seedl_rows) + counts.update(pollen=len(pollen_ids), pollination=len(polli_ids), + seed_lots=len(seed_ids), seed_treatments=len(treat_ids), + seedlings=len(seedl_rows)) + await session.commit() + + # 2) 克隆扩繁闭环:为每个入选克隆补 K 棵共享 clone_id 的无性系苗(ramet)树 + 扩繁批次 + # + 童期/成株观测。ABLUP G1 门禁按 c{clone}(回退 f{组合})分组且要求每组 ≥ min_clone_n, + # 服务层晋级为每入选树各建唯一克隆(1:1),缺 ramet 即每组 n=1 无法建模,故按真实 + # "入选株→克隆→扩繁→新树"闭环补齐;ramet 共享克隆 BV,观测仅加年度与误差噪声。 + clone_id_by_code = {cc: cid for cid, cc in (await session.execute( + select(CloneModel.id, CloneModel.clone_code))).all()} + orig_by_clone: dict[int, tuple] = {} + for tid, cl, cid_, gen, dam, sire, plot_id, root, persona, stg in (await session.execute( + text("SELECT id, clone_id, combination_id, generation, dam_id, sire_id, plot_id, " + "rootstock_id, bre_personnel_id, stage FROM bre_tree " + "WHERE clone_id IS NOT NULL AND is_deleted = false"))).all(): + orig_by_clone[cl] = (tid, cid_, gen, dam, sire, plot_id, root, persona, stg) + eval_num = [c for c in params if params[c]["stage"] == "evaluation"] + juv_num = [c for c in params if params[c]["stage"] == "juvenile"] + idx_of = {c: params[c]["idx"] for c in params} + prop_rows = [] + for info in combo_info: + sp_order = info.get("sp_order", []) + if not sp_order: + continue + code, cy = info["code"], info["cross_year"] + sp_year = info["planted"] + 3 + ev_years = [y for y in range(sp_year, min(sp_year + RAMET_EVAL_YEARS, end_year + 1))] + if not ev_years: + continue + bv = info["bv"] + combo_id = info["combo_id"] + gen = info["generation"] + plot = plots[cy % len(plots)] + local_ramets, local_meta = [], [] + for seq, ei in enumerate(sp_order, 1): + clone_code = f"{code}-{seq:04d}" + cid_ = clone_id_by_code.get(clone_code) + orig = orig_by_clone.get(cid_) if cid_ else None + if not orig: + continue + _tid, _cid, _gen, dam, sire, plot_id, root, persona, stg = orig + rs_id = root or rootstock_ids[(cy + seq) % len(rootstock_ids)] + op_id = persona or personnel_ids[(cy + seq) % len(personnel_ids)] + grafted = pyrand.randint(40, 90) + prop_rows.append({**_base(), + "batch_code": f"PR-{clone_code}", "scion_source_type": "tree", + "scion_source_id": _tid, "produced_clone_id": cid_, + "rootstock_id": rs_id, "method": "嫁接", "graft_date": date(sp_year, 3, 5), + "nursery_site_id": plot["site_id"], "operator_id": op_id, + "scion_count": grafted, "grafted_count": grafted, + "survival_count": int(grafted * pyrand.uniform(0.75, 0.95)), + "destination": "定植", "remark": "模拟数据"}) + for k in range(K_RAMETS): + local_ramets.append({**_base(), + "combination_id": combo_id, "dam_id": dam, "sire_id": sire, + "clone_id": cid_, "plot_id": plot_id or plot["id"], + "tree_no": f"{code}-{seq:04d}R{k + 1:02d}", + "row_no": k + 1, "col_no": seq % 20 + 1, "block_no": seq % 3 + 1, + "rootstock_id": rs_id, "planted_date": f"{sp_year}-03-10", + "bre_personnel_id": op_id, "status": "alive", "stage": stg, + "generation": gen, "remark": f"{clone_code} 克隆无性系苗"}) + local_meta.append((bv[ei], ev_years, code, combo_id)) + if prop_rows: + await insert_chunked(session, PropagationModel, prop_rows) + counts["propagations"] += len(prop_rows) + prop_rows = [] + if local_ramets: + rid_rows = list((await session.execute( + insert(TreeModel).values(local_ramets).returning(TreeModel.id))).scalars()) + counts["trees"] += len(rid_rows) + obs = [] + for tid, (bvec, years, code_, cid_) in zip(rid_rows, local_meta): + for y in years: + year_eff = {c: rng.normal(0, params[c]["sd"] * 0.10) + for c in eval_num + juv_num} + reuse_vals = {} + for c in eval_num + juv_num: + p = params[c] + val = clip_round(p["mu"] + bvec[p["idx"]] + year_eff[c] + + rng.normal(0, p["sd"] * 0.6), p["vmin"], p["vmax"]) + if c in ("fruit_length", "fruit_diameter"): + reuse_vals[c] = val + obs.append({**_base(), + "tree_id": tid, "combination_id": cid_, "trait_id": trait_id[c], + "evaluate_year": y, "value_numeric": val, + "stage": params[c]["stage"], "remark": f"{code_} 克隆观测"}) + # 描述性状:克隆苗全量 43 条,无评价头(tree_id 直挂),纵/横径复用本次观测 + obs.extend(_gen_desc_obs_rows(_base(), tid, cid_, y, trait_id, code_, + bvec, idx_of, pyrand, rng, + year_eff=year_eff, reuse_vals=reuse_vals, + stage="evaluation")) + counts["obs"] += await insert_chunked(session, TraitObservationModel, obs, 20000) + + # 3) 环境气象:site × year(2007..end_year,site×year 唯一) + env_rows = [] + for sid in sorted({p["site_id"] for p in plots}): + for y in range(2007, end_year + 1): + env_rows.append({**_base(), + "site_id": sid, "year": y, + "chilling_hours": round(750 + sid * 40 + pyrand.uniform(-120, 120), 1), + "growing_degree_days": round(3200 + pyrand.uniform(-300, 300), 1), + "rainfall_mm": round(620 + sid * 80 + pyrand.uniform(-120, 120), 1), + "temp_avg": round(14.5 + sid * 0.8 + pyrand.uniform(-1.5, 1.5), 1), + "soil_moisture": round(0.55 + pyrand.uniform(-0.1, 0.1), 2), + "source": "气象站插值", "remark": "模拟数据"}) + if env_rows: + await insert_chunked(session, EnvironmentConditionModel, env_rows) + counts["env_conds"] += len(env_rows) + + # 4) 通用观测(EAV):line 晋级树补 ssc(数值) / 果肉颜色(文本) / 果实形状(文本) + text_specs = {t[0]: t for t in TEXT_TRAITS} + ssc_p = params["ssc"] + obs_rows = [] + for info in combo_info: + r = info["rounds"].get("line") + if not r or not r.get("active") or "sel" not in r: + continue + obs_year = r["year"] + op = personnel_ids[obs_year % len(personnel_ids)] + bv = info["bv"] + for ei in r["sel"]: + ei = int(ei) + tid = info["tree_ids"][ei] + ssc_val = clip_round(ssc_p["mu"] + bv[ei, ssc_p["idx"]] + pyrand.gauss(0, ssc_p["sd"] * 0.4), + ssc_p["vmin"], ssc_p["vmax"]) + obs_rows.append({**_base(), + "tree_id": tid, "trait_id": trait_id.get("ssc"), "obs_date": date(obs_year, 7, 20), + "obs_year": obs_year, "obs_value": f"{ssc_val:.1f}", "obs_type": "numeric", + "operator_id": op, "status": 1, "remark": "模拟数据"}) + for tcode, t_tid in (("flesh_color", trait_id.get("flesh_color")), + ("fruit_shape", trait_id.get("fruit_shape"))): + val = pyrand.choices(text_specs[tcode][4], weights=text_specs[tcode][5], k=1)[0] + obs_rows.append({**_base(), + "tree_id": tid, "trait_id": t_tid, "obs_date": date(obs_year, 7, 20), + "obs_year": obs_year, "obs_value": val, "obs_type": "text", + "operator_id": op, "status": 1, "remark": "模拟数据"}) + if obs_rows: + counts["observations"] += await insert_chunked(session, ObservationModel, obs_rows) + await session.commit() + + # 5) 试验处理:每个研究点 2-3 条 + study_ids = [r[0] for r in (await session.execute(select(TrialStudyModel.id))).all()] + treat_rows = [] + for sid in study_ids: + for tf, lv, desc in (("栽培管理", "对照", "常规栽培"), ("栽培管理", "提质", "控产提质"), + ("施肥", "常规", "常规施肥"), ("施肥", "减量", "减量增效")): + if pyrand.random() < 0.65: + treat_rows.append({**_base(), "trial_study_id": sid, "factor": tf, + "level": lv, "description": desc, "remark": "模拟数据"}) + if treat_rows: + await insert_chunked(session, TreatmentModel, treat_rows) + counts["treatments"] += len(treat_rows) + + +# --------------------------------------------------------------------------- +# 汇总与自检 +# --------------------------------------------------------------------------- +async def report_counts(session, counts) -> None: + print("[sim] 生成统计:") + print(f" 杂交组合 : {counts['combos']}") + print(f" 单株 : {counts['trees']}") + print(f" 单株评价 : {counts['evals']}") + print(f" 性状观测 : {counts['obs']}") + print(f" 农事操作 : {counts['field_ops']}") + print(f" 选育结果(晋级+淘汰) : {counts['selections']}") + print(f" 定植批次 : {counts['plantings']}") + print(f" 区域试验/研究点/参试: {counts['trials']}/{counts['studies']}/{counts['entries']}") + for t, key in (("bre_clone", "clones"), ("bre_germplasm", "germplasm"), ("bre_pedigree", "pedigree")): + n = (await session.execute(text(f"SELECT count(*) FROM {t}"))).scalar() + counts[key] = n + print(f" {t:<16} : {n}") + for t, key in (("bre_pollination", "pollination"), ("bre_pollen", "pollen"), + ("bre_seed_lot", "seed_lots"), ("bre_seed_treatment", "seed_treatments"), + ("bre_seedling", "seedlings"), ("bre_propagation", "propagations"), + ("bre_environment_condition", "env_conds"), ("bre_observation", "observations"), + ("bre_treatment", "treatments")): + print(f" {t:<20} : {counts[key]}") + + +async def verify(session, counts, dry: bool = False) -> None: + print("[sim] 自检 ...") + problems = [] + min_obs = 8000 if dry else 300000 + + def chk(name, cond): + ok_ = bool(cond) + print(f" [{'OK ' if ok_ else 'BAD'}] {name}") + if not ok_: + problems.append(name) + + n_tree = (await session.execute(text("SELECT count(*) FROM bre_tree"))).scalar() + n_obs = (await session.execute(text("SELECT count(*) FROM bre_trait_observation"))).scalar() + n_obs_orphan = (await session.execute(text( + "SELECT count(*) FROM bre_trait_observation o LEFT JOIN bre_tree t ON t.id=o.tree_id " + "WHERE o.tree_id IS NOT NULL AND t.id IS NULL"))).scalar() + n_eval_orphan = (await session.execute(text( + "SELECT count(*) FROM bre_tree_evaluation e LEFT JOIN bre_tree t ON t.id=e.tree_id " + "WHERE t.id IS NULL"))).scalar() + n_sel_orphan = (await session.execute(text( + "SELECT count(*) FROM bre_selection_result s LEFT JOIN bre_tree t ON t.id=s.tree_id " + "WHERE t.id IS NULL"))).scalar() + n_dup_tree = (await session.execute(text( + "SELECT count(*) FROM (SELECT tree_no FROM bre_tree GROUP BY tree_no HAVING count(*)>1) x"))).scalar() + n_dup_clone = (await session.execute(text( + "SELECT count(*) FROM (SELECT clone_code FROM bre_clone GROUP BY clone_code HAVING count(*)>1) x"))).scalar() + n_germ_dup = (await session.execute(text( + "SELECT count(*) FROM (SELECT cultivar_name FROM bre_germplasm " + "WHERE cultivar_name ~ '\\-\\d{4}$' GROUP BY cultivar_name HAVING count(*)>1) x"))).scalar() + n_sp = (await session.execute(text("SELECT count(*) FROM bre_tree WHERE stage='sp'"))).scalar() + n_ap = (await session.execute(text("SELECT count(*) FROM bre_tree WHERE stage='ap'"))).scalar() + n_line = (await session.execute(text("SELECT count(*) FROM bre_tree WHERE stage='line'"))).scalar() + n_elim = (await session.execute(text("SELECT count(*) FROM bre_tree WHERE status='eliminated'"))).scalar() + n_sel_tree = (await session.execute(text( + "SELECT count(*) FROM bre_tree WHERE status IN ('selected','primary','key','preserved')"))).scalar() + n_line_germ = (await session.execute(text( + "SELECT count(*) FROM bre_germplasm WHERE cultivar_name ~ '\\-\\d{4}$'"))).scalar() + n_sel_total = (await session.execute(text("SELECT count(*) FROM bre_selection_result"))).scalar() + n_polli = (await session.execute(text("SELECT count(*) FROM bre_pollination"))).scalar() + n_pollen = (await session.execute(text("SELECT count(*) FROM bre_pollen"))).scalar() + n_lot = (await session.execute(text("SELECT count(*) FROM bre_seed_lot"))).scalar() + n_treat = (await session.execute(text("SELECT count(*) FROM bre_seed_treatment"))).scalar() + n_seedl = (await session.execute(text("SELECT count(*) FROM bre_seedling"))).scalar() + n_prop = (await session.execute(text("SELECT count(*) FROM bre_propagation"))).scalar() + n_env = (await session.execute(text("SELECT count(*) FROM bre_environment_condition"))).scalar() + n_obs_g = (await session.execute(text("SELECT count(*) FROM bre_observation"))).scalar() + n_trt = (await session.execute(text("SELECT count(*) FROM bre_treatment"))).scalar() + n_seedl_orphan = (await session.execute(text( + "SELECT count(*) FROM bre_seedling s LEFT JOIN bre_seed_lot l ON l.id = s.seed_lot_id " + "WHERE s.seed_lot_id IS NOT NULL AND l.id IS NULL"))).scalar() + n_prop_orphan = (await session.execute(text( + "SELECT count(*) FROM bre_propagation p LEFT JOIN bre_clone c ON c.id = p.produced_clone_id " + "WHERE p.produced_clone_id IS NOT NULL AND c.id IS NULL"))).scalar() + n_obs_tree_orphan = (await session.execute(text( + "SELECT count(*) FROM bre_observation o LEFT JOIN bre_tree t ON t.id = o.tree_id " + "WHERE o.tree_id IS NOT NULL AND t.id IS NULL"))).scalar() + n_clone_single = (await session.execute(text( + "SELECT count(*) FROM (SELECT clone_id FROM bre_tree " + "WHERE clone_id IS NOT NULL AND is_deleted = false " + "GROUP BY clone_id HAVING count(*) < 2) x"))).scalar() + n_ramet_orphan = (await session.execute(text( + "SELECT count(*) FROM bre_tree t LEFT JOIN bre_clone c ON c.id = t.clone_id " + "WHERE t.clone_id IS NOT NULL AND c.id IS NULL"))).scalar() + + chk("单株数 > 0", n_tree > 0) + chk(f"观测数 ≥ {min_obs / 10000:g}万", n_obs >= min_obs) + chk("观测无孤儿 tree_id", n_obs_orphan == 0) + chk("评价无孤儿 tree_id", n_eval_orphan == 0) + chk("选育结果无孤儿 tree_id", n_sel_orphan == 0) + chk("单株编号无重复", n_dup_tree == 0) + chk("克隆编号无重复", n_dup_clone == 0) + chk("晋级种质无重名", n_germ_dup == 0) + chk("晋级阶段分布合理", n_sp >= n_ap >= n_line and n_sp > 0) + chk("有淘汰树且选中树存活", n_elim > 0 and n_sel_tree > 0) + chk("晋级种质(line+)> 0", n_line_germ > 0) + chk("选育结果 = 晋级 + 淘汰", n_sel_total == counts["selections"]) + chk("授粉/花粉/种子链有数据", n_polli > 0 and n_pollen > 0 and n_lot > 0 and n_treat > 0 and n_seedl > 0) + chk("扩繁/气象/通用观测/处理有数据", n_prop > 0 and n_env > 0 and n_obs_g > 0 and n_trt > 0) + chk("育苗无孤儿 seed_lot_id", n_seedl_orphan == 0) + chk("扩繁无孤儿 clone_id", n_prop_orphan == 0) + chk("通用观测无孤儿 tree_id", n_obs_tree_orphan == 0) + chk("每个克隆 ≥ 2 棵树(ABLUP G1 可建模)", n_clone_single == 0) + chk("克隆树无孤儿 clone_id", n_ramet_orphan == 0) + + print(f" [信息] stage 分布: seedling/sp/ap/line = " + f"{n_tree - n_sp - n_ap - n_line}/{n_sp}/{n_ap}/{n_line}") + if problems: + print(f"[sim] 自检失败 {len(problems)} 项: {problems}") + raise SystemExit(1) + print("[sim] 自检全部通过 ✓") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/doc/桃育种系统业务链路与数据模型说明书 v1.1.docx b/doc/桃育种系统业务链路与数据模型说明书 v1.1.docx new file mode 100644 index 0000000000000000000000000000000000000000..464320369006a51ad4c3b0d14a7dd1bedcc99914 GIT binary patch literal 61615 zcmY(qQ*>rs)3zPkwr!goTOHd@uGqG1cigd)j&0kv?c~q>d^`WUGuIevtgU%gtvcsX zH5Fw*!O(z!fS`ab3?X!@l#3HmfPsLFAPG|wq|={Pjdh>?br76 z=%a*O|H{`2Owo7+ilwZIQ$ryf3V~AQ9|I4sDV*gj=pY6BRz4e{+^4lT98+}tobYoa zc@^;&%l%wopis-P&Vu2N!#FPuIf;~-dl{|J_KuzGUmpFSqP2x$6>@NCZyiigJAVS2tNv}rn}5EpMr+Zbr5XF%$MP&l636pd z1R?vpCy<)5fmk;gB9o;fuF5<3x&Qf@x;TzZ;B_Tj?|ETf&UQl8e*`g*W^V{m2k}4eJFip!!+zqqF4DWj*kZ zu`FFGwf1KUei3qag5kT@8*FI>wx~ew$8zcBq2Smv$)9&LIXxi7|D&P@g^bY4k9u1m zKtQlRPkl#I8z)AF|JJI+Noi1KB>!sxu@Ul8yJl6U5<6eXlC`c* zB~e`sj9!A{>wROhrL02S71|P;Aax_n#lV8)UX5+)+s1bN4X9M;jsRl!-+RHDHknb6 zqzFQ$6we%HYD5CGTd0HD)D2g&!qvY-^=WJ+5z2zglaL=^ovGAaeCX%xA?(Fvo7p2v ztJ)51+#+8Do1G;M{Dscq0mv#D@+QofTtx^{4yX?zQpeM=f8*P-G5)=^D_Uq*+%lX5 zi72S(xsyFWOm?6QaG5vqmCm;AOB~UYkxr_6PMNuE1u6LfrU@>X>e8;Yn&MV`phjo4 zIj;Zj28D;hs#yMn`RvaIA^&WUiM_F+qdmZh(b(S6^uHj_NgS8&XF?Kt_7+=^Q;WDq zf)bOW0X~bAmP89!X?tR0m$Mnq<(S;wFS6Cjr*$RR<{iEV2yAjTwSXFjB!&=%C0qxX zM8ayKf2|u~Y`o`+(QsF#1ovI{yv^THnDZEq^+YM*z`@k!f=XpE>&h;L^N~@2ce16;jI-VhzwTW7@6Gs*+0PeW3qJTlV$eYODBF~(7J>!u z()AS_4iT7DNGU_vtyPU>C-TzC(P4Yq6;!tZt|bf(Ar-q|_Oh%}D6jx(L)&%BexSH+ zcwu30gWieUU(ng4m9-Z2vw570GR@F8d$Ryx*p1&j3_2(I`Qg@*IV)KTtyDN0OzNNH z99`GspOXf^Z;3DL6+GG(&Ra07Gjjwz>UlSjuaCYv-2bb~!}>br`HwC(U_d}f|9@Q! z0f7I>@_XEFoe8Pqofb^jInokRSP3gJa7lPpWD}=0F}r19j6^yCtc`ik*XK1Hoja8A zF@m>&_q}oKeOcHHvQgeb6@C2m&xoSKCQMUJ^ z-sBWH-`rLklW(3>=$wEKa5oS4G5~XcXR}a67 zxs31D5FXCwW(WNgp^6@zBTca~wC{aD&eI6kaENbxYAEN8N050!0DI&8weo=>O0y;c zsf8b()SQ9pS2t#Vf5FKGmsE8JssS8%ckq6cQ;V7`WQn)>51GtoYF0q3ZoIZadXJ%! zn7?|6k}MVf-G~sE)|mQA{rFg9SxZ-5I|ezS?GoDNb2NIKAfVM|J1s?EQhl3@H!K@d)yqa9 zA#Gq?O0ZZ#qO&85@q>}z20H8Y9`}3k@q+OG1_rXjYHxut5YXW?*#8pN&EC<3@h59t zY=4T=f01#~X{~~*-Yod>^@^tJaaP_-Ih@)nzbBiM?Po}NTlCd7$u+w0?IAKcLy-v; zA%XJrlFg&KN8&8}3j}PBM5w+E8&}GJ;TL9EW=9_4?!(sR=a~MkruB6y5>^mnbdZ-D zjj2?X+I;7emUYdzebpo3_xZK1{Au;YEzd^v^w*5{=^Y{2>f+TEpp{4Py+2OS+v9eP zkgR6<>a3P`li{^Na3ihL??wOn)$en-Q_!__-Rqx8nnECLYlmOQ^5)knq~N>vreEV~ z$ED!7uIqI%gGBrArOWoMu9chH>W14D*P7`%e@kiql4IK|i`Vj{L(8}x<3}5=>#}b9 zGw8mf?v;AGR$6E0Q}f~7q4mw`?qB`=L0(6*!6kS=Uxc~%8$N#8HH!DRd5ih|h8dE<^{Nxw5+`E))wQwn)Zy8gP-pViRsDVJ zQRY)CA4>07YY2y3AoKas?r3TH$qp&M`bp4t@zl}lq9q3)A>;tfeHZi%cImvn=|HI8 zzACuwb#rKcIoBz;)x<0Ko{ztx_vCHxKK)(0*nRkob=~dU?ybkQer4+hwA)p_nf>5* z;g^U{*uyE<@~>mqk957WORc8=YcGw%)%;@HwPpUYd{h2DO}*OBF~slkvu15JPAeLh z&2H>Dk8Qo?(Y9=y`O#97etO?cz*Q! zC~1@l8Of)y#NM*%IPuxFj~+Ck)8GMLC9oX*+ykFZ%c0}=FeamZyZdY5@gmz7@+hhP z!UT2wb9LI6 zcxg+}t6a1CH!Czj49es?k**J|Kho01fSc*6W?X&zjZ@}7`@7GGv^(wF@c+uzx2Tsf zcHODZyAIp0ldWZ*C|6@C)bE)qgOo4BvSyx2cf`+)WIu20CFQEKX{NXAym)nR+pZ7* z$L54O?H|0BU2F4t9HJj1#}Ej1zQT{id4T4EB#-3%L9}qL7}1BVWDu6lX3@~&00+yx zh-Se;3Rq7o)Yc3-o4}Kbc~FR9)}vH`Iene)Q;>N@rOc-wjd&yZag6-BGEwIQ|Fn4UHhMzPY+ zg*g@&Is$#g$cpTc*A^2dh`X?3!O=eU-MZl2xPKP$FMm_lh1QZ=Qc$$hf2kt&ISvWw zN$xOJq)2zp{72tsy^$tCYKF*mb+Q`z(89%deVm>?vvKeRf)SqzhQM*JX)p z9wc}PUlvDUJE$Q!mp4wW`UQ2`f# zbWrx2>g;`SZ3xiY{4Mq|`JRYvR`uufw7*ad?$H^~Oc7f3w*}3V^1@Oa^VQgJYN(7+ zqh_$alpkFq>sF~x{nJLTvprj;c0wLds%4Los*yvTc!qVNrWh&h0rzCKaFI3cY?p-r zO2+Y30(1SWGr6qdw9)(rRXEcJ3IcI;RZ%+sh+_39iSiUZ5s2kb(j|L}@hr*UB1Eom z0q684OVDuuJMf!*r9~vjnL&6tXhHu7;@JL+cYE}@nk&e?*)0Jz+)?ix`%F@dT92<2!h`IO(U}Xl&HXU5|1Q*9GTVD>YNW0>H z8IS_1wB$SN$qNvEiNP2>RuGHsBkEe(Ky_wj&t$8xH-a}4f?^mDLP2Nq4k#<)=8U>C zA4(6nX~J!x;F?GxC$#&{HtFwgTGc?fQ1P3t6L-DM{8@i?B}{yD6y8rg^P2gDSX16e z0-oW{VF{%7n+*7S7}b~ib_$ZDrN$X8$7^fsX$RR)3CAaw8JZd;Io#bY=N4&eQ+hL{ zZSz{bX4anOG5y*e7u~j{4S@VjJ^TK=*zv|-?n~M8err7sUGfWXDxd24#PKtj^!q+E zt$)ILyA1E%zS*_r@atAj3s`*RyB;uJc@0UFe(t=FH-TPQKaC!K&AYT$6n>pjenosH zUq7mATX)R3Pjm9eB6zPu*8n_xrwXoTN#yx+^MzutRA+&(tHdUv-$MZPwa z{On3?o_C<>P&|YRc@ZeR-^I?;CiV4dK9%;*Hm_q%^+{#lj2dD0L2t!wj)hSDlYFHY zj@%F?9qJMVl3J4kbR`;ynQWCpOvP;glIuEky_#Bv_%DPlYagv$I90s7fInIUL=i|- z5cvS9?X z7Dy8=xF=@ysXJ~XjKUCkf2~pZ2ZUi);Or2p$HdgvWBrwYzpXke z@-yMj{eYiEXK*)|fnJZ@AW9Qh;X}Du`a+*ZFZiE^ZUj zw)@`hh5Y@Rs+803?N@6^d0rZ=T$C$gDLz+yQSw6lFuA5)l(%}do*iB0aJ z*KZ8)?Qr0E_9SAgL4)JFGK?**MIcYq;+OZtvH*x>z1sMU6p}^T6h+#iCSAm2g%6wN<3SVc_4nh8@0OnN>Jc6#wdizm{M z-jH$C|1vAWVW~h$2-d@I(c(uMei7phJj1E~;difm?|-T2_y4ZWpft8n(xs%twZ`3C{AKx_%D_Z zbggXM7ht)#8@R+PheC`aZSc|53X_^*sF_Sadq{(&o*uQjt}flrmmd|scSGvZ@?80= zRy_{BSF0^M?LN!BEgJ>NM^HHaL z1DXX;lHdwbOXJDtCa=92H&H4`Dj%ACn_6_Xc$Y8Te%-t5TQ}G`pULBl0ce5kdwQ?yE z&<$8WceB+dJF)jJ*K@1anfjS-v z2EbL{kl1zj5b@ytjG<%se%kO&w1h~7!U!7>k+#H++M$4XTq7yw3U1!MqQH*69kGYEZwX2fJVkE> zSqp|`M)4$*T}9+$Lo{3TQNJ^g&x!EI`e5}yHDvgrUeQDfOU3D-%Hhs zy-Lu5WWmp6$knzY0tlKWvcQ%Tkr+_mok-zDtY(?|rQ?>!rYt3t_AR70Ccd7Z^*g&> zvW>{}5?ZBXXvmed=7yhk5|D&CQn@hhLnzJF26w>JAo!$`*4J(j-t{4~H#3UP!7I7Txus70rp#U)h&lqcr_dWG z$zh+JrW^#-vjmY5dHlgA5>Pufm!lk_#M)W=KN}mpz2$`Nm`92TqPtz3KliN(BP0a$ zS{$3(xJAHox7onA|Mv1ClhPii9L92#uDhwh9DNATd(FAJ#f^vEZtUXIIbtzpL&kmLV6((YGkbVil$pn_^8#5F23Rtb58C=kVmo+yM^Qw!Zl%sy6avXL z%#=*@OPm!DSwUDC;|@53`3!2jZRJKwpF7oxlhPM!2+%z&4>sj$Aa4 zW_aMJieUps*%e45h?gZ$^^el*!p9`*t`7+Bhgt6kIVX0OtU4Z z&lMg#XE^6W4&j3lNlE_+?Su1UrTK0qw6YEe8YhPCgP37zws1_Gu<0P{|`f|x3?5|YX6eeUt(I`;yU}=D4S#?xZj;zosiYu=0 zjB8{?FGOMlX8XKFN9z1Y3Y>)V z{j9BRr7nmZZ==)AK|RRMgWPem#P-oB))S1RUTSX2`V$bOwhG&s;mg%f>B_90v3*B* zSin5F<%M=O6vxXcmIAbce!hgW7D~gOp1CQJ0^JJZXTF+*UiV?d7BGBGNNxd*Rro8B z(?lPNDuKt(b;$B^=I_aKAof=jsY6!CV#W?opD)cx)28l8fhT@v`%JopL=kNA!j_wKVBZ@bw zGxgg#Gd=7m4nqZRo15C(?Ra%6n`b_xW3{O@)+U-+n9=a;N$vJW6>-BX*SH3ThcXjc zKh5^nCQr~9Qa#^CKF;K#DnU_id>@R;TI^cpsiX4eHQr^BOcENgU5AbB7Z%YX1P`j) zoTqos0Nd^1)qe*dLlft)G_%lTvV3fOZOtnAae-)JIZ%gXjn%jiBn;azI*`RXJAdtF zW4kO-9xZD=rZnX=Q)fuoN2p+rK9eywqPdYUlMQW*yp2%BURSA#=h{}>Io0%6XXuCB zx?ql8p_IoRhXPcrZ(qw0I>nQu zX?Uz}$i7D4y%vBK3CrfJVr$S3j!TGTKpH^obkjB|x1>y_h$a&D z%I6veH7e3v%Z5tU+k&9oMY&ed>oXu~WFU5}zk81XHm^vaXLuhLs4Tdpzk0<6oeLVcQSVr}C+|VskU)N#6U4=g6?K)9u z*E@h5tIm+P2E%GEWacFXODpD{2cFS-6_sl%Ia3zs#C!jYqdcSN%|iQfoxefe;`q7u z{BPCd`T1!pqko_=ZOocYm$MPCH7hk^VI=Z7PRv4)(&i?R7ne%|9nM;X{PTv1K4%g&|T}MK+W{#R}@fY zB>nm7gOsP>0$QeaQ!EM{B6WH-3b4jB%6Ms2bq1SS%kZY{H5$3QpW00Nv#6mQnvl8S zrjeB#{uC@`gFP~^o_-K>=7Esv z`eb+WR{iH|SdHy=BMiob6*7>j!jR|UVS2rw3@Pkf#I7`}Wlb!B{d#wXzVR(lYL%U> zAy-9e5VA;w!&6DaryFWFa$&-D`0@2-ji3|VE5(*9J@qP%nwrR6 zgxLyZfq(=EucoXI24?K{V%(2k-shz)CW?05n~nomCu8g2Qk;#O1I}%kh z)5aWf>Nlhp1$e#-&M7%~c{}uxSpr6cVdEXJ7W2a|2^1|JDxD{C;8cb0Puz7sw8ql9cg81?`VKuuX69T!UWX_czyY+ihQk<_do;)Q?|12 zh$BF$z9o{g_LvgNg~u1ni*((E5U1;la&-$>cEua+m{>n7tMOtF9B-eMV9}f~-dhZ| z3dAW1AnTKz=4;W(Ks{)fh!v`!F_g5e3ObA)pA~=Kiv4&TJb)4T~_#t|^u38Ei519UFFkyq)w)|8g%1dBO5 zhWM6VQ3IWxyzZMe>^vQ)u5X#9luJmEY7K49QykBdeAn#G-RyILp`VFh_cbO`BVz_> z84wB0&u-kI(HCr0WpC5d7b?f6y6o*mB!IR{JU9|Edr1t0bLYQoZbx{{U0yo8I&m1Y zHK1JS5b&aO7ZJo=Wo>n^QC*|}o%GbWB8=T|7@3OYaBI1!TgIb6N>(>qe0G<4nIv4K zY_YV9!fg3Git8OCLPtb}g9^9+pch%7XY852ht>`DMcqQ$@ghdtF2_#g`TPKb*r=+@ z%b(1cxJHuatyQoJs=Qt#+_ArG+TOP}QTUY5Z{O^eWYA?a|cK{3j+Z6fw}AcNd+(9&0LZp=9v; z9AO>d^Hu$MPN4H6q89~Z5?OEsbZU$eCCb+ThWgN!jWYeI_=hKF@z9!U(JAj8Y2tvY zzBeDdzpR-8mz!%~Pt)Gp%XHJKGqylU_Iyy};0esP=G7HuZ$p9DcmF*U4(nw1KlvCQ z@CB-Piu$Jn%hSG;($u@a?uYlFe=|e4tlRDMHbGYBtR{W?AyA}DlaM3Uq(dBm6wU?> zFMZX%Q^TEOyXD# z;ttnOp{IUgE3U}xlnlrYAcrKj=AvEW=^lzQh(0#$;K zO%r!1)?fOnqb_tayoWC;_KFf?$_~lD}mzk?++xYblt#S&SNJTe>O%)-)D!leMVgKFaB?BeCq z3sL|ElobAz7sG*lmcrGU>mIkWMP9YZ>4iXP)zvj=1d^u4QYz$5YbM=Exg3%_lHfrE zrDpQU7S%@L=q#tRWAxR0NBZg4d?+Xhi5UemSr`ns7^~FyX((NeO zd%Eldy$!hl;pDx*BgA-7I7+jJ;oj%@_ljD<{4)!GjsOthnbigV<~V?8F^|Ud5Gd^Y zWqfm6#pS{17SJg0d2cV41-eS4z}d7T^g}6*FW3-CnGq8&5?%*WPl0P|3OgMx6~xL( z5$+X(d%U(CAX}2I@V5g=$MEKU662r7@mv|d`>(pg!c^@^5;2LNChNz#=BO`3h%uO)euXRs2y?qaS(NjS25rFkGHZIv*XsOc@^%@-h&9*DJb`Km1nqrmpP6x z|7!a1DEl-3y~SO%6U}C7MY9L;8^~cy9EqpShrVPFa$(FvWT&Y)o<4v_B-Pr zSVZ#rLv|6Mbh8@KfBdpHgeiH&wcr=M^m&7o6FMcaM z)Pk3PpXbvOZK45IhX`><|+Re#8J8A$TMY?b$wozpE#? z3BKzC+|z*){oPqVOzbr|{dP9b0$psQarORGewN?*2q|HywWb-gL>aZx=C#^pSc!*fGw&(W$8~)nU}G{= zGNs&}o_t<&hd{LUl}r$wxY1Z2MNf4yz;KP?NW-pAd%S3L(BZB^M0y-jXcV&R4W@U{ zY+^=gP?d-xEP1ilSb947T&<9 zHMgGe!7A&1?MTGCyGdl3pl3S8adFH{bCr%Z>+z}+kv7ZJv|&pD1tw$imlIxgErMpf z)I}$j2fdw4MG7>^wxL`r)1>Lw4WbkY9fHm;dckl8Q@)zR@lyT{>enrwcl(w7w`#7A zsb9cH<_B?tCDGYz**n_=|03t1Ou2KL}Pk5%~fe8ILE zA)N5t#`{h{WX49r2>v%NTOycbhz=Gy$wq~-nK^$h{+y0EC8EMXdDXP`LJO!~X4 zJ5+-6L%s}_n%!C%l`$a*K3=NnT;EaH44Z;gmymT$TO&m=_9ACm>BnF{C1@2lf0k%e zy;T#UnM=W`ckMqexLnCu{F~*VH=73)m?f}SzR<8gk=8zOZcZjF1uEssDjy|=E-?X$ z8YAV7#8^R!I(0|g<_fG(!xJhhQUecLf`i$}>YeolUGq%k?|#R}$?wE2HH3@E0doTj zZP3PKCP#yudPr;cPTf5yC|X`&B27mzYTKCyRJ}Jb5ndwrn}m?J(pMXJfTY98fM>?z z)bJ&cB&5CF?gLOU&i?!;^VYULc?5YSDXi_hH(bp>v*I64lrE)Wd_!OocHq|ZNOzVL zL9L8avFvu6$$%1@AOt1js2Q0jF0r7PKo?T!oSuTvFlTtZwOBW2sK#^gNmdYlTKhy$ zI_J0N`Yb zH;CN3X+yKR!BkM#&pM5TUa(@--_};RftmHg69J&vEFb zT32A6thx7A8_-?bHt^=us|3SrYGdN=2OJ%{*Dp|RS~YL}yX#!CpYqcB|Vr>TJcxB$&cEI0Z|(6WB~*tYIx>ExWAxA|J+%9E9k zs=}p3*BWW6+$qJZqi%MAOD1V+%)`yxC+O&>$FyYL-z0qMGFHus(+&6n_g_}O3+=c$ zk#_h+O_(;rf_?53LAl60;Yts;@Rs_b;O(x$@`+#io_pcY%Cr7&caYwHYMJDmjbEVw zvG)eke_kcdM%rOgytKsDxf)Y&Z+xNgApwl7=V&8<#Kz}LTzV6WyqNqHq1CVD^0GS| z^P=t2xxIaj>!uA9_h6mO7ixFfT?lQr4AF^bHZhk6Sj-ZG`V~(-YoJiP{qLrn4^2L# zp>??7D?p(9@-gWJOfJkKGL9tUwOW&f$*V)m$2H-1DKE9DkH^l->tszYHs zZhp0rCn+c{@7rDGkG2Q^s(nKc%l~;m-?ERw&VZomS=ZgiC<}&oEFcVzGO{q1CPZu6 zgA9oyO8>fliv=3buJ@Co7%PC#*Wr^g|6~%kY6Uu)7~^q~3fBbnpG!iR26sHo;;aE(bw=MP6@zvWeY~Rdpp>J*{mgW9L_JbgCU=QweTB(&xuzLs~us zIiYFgEbfqW{Dd+y9jRdrE7MCku_4^BfDgdqY37Y%QdEu3$M77rK>Jz*4Hqr`GW>BT zh2owKM5k`$>ckKtP26J^WB(4wQVZrD!*XX9M5E9wJ;xASKnEfI z)G#%@M!qU+qg8;~c4v)C^j*DB1%|~VKT$z?{vs%CPLm_EU-tM-k>-0UkHO=%KJcMg zYB=UKng=Mx7fEwehx@BzgS%p!$fwcd^g|3N82AoA4e(_HXmn8As}P!(8$H9DT3E7u z_Rok3dF4300lshO29%YEpeQn8V@BV4;3>m>G$V(igLhBnEE;y2&aXONN z6x@6;(rzfQNw|wq(oU^d@=5>52|#iaiWDM%nF#*c$hxfy-j*uFy?ujCR|pL$B4&QV zid5hl3M~#1%xq_!O`b7pB51ZI^(kc!C&$ZRlBQys#`>Q};iBug_&K<^2*jI#9&s%3 z`e?6xlWF$R{V`$J7yIL2L4uh{5zN9t(nRtdNz12umc)zK+ZjyG`q$+4ux4gSv(z=) z@Rb7+0RsnTr6yDY*`Jk+1aP8U_*lKpkJI}xxYgAj+?W_AB*M%b2pS|+3~PNZ**zJq zsPLQ~icfeMMk5d@1N<+b9NheZ;MuH8faM;vamN;qDd|Zq0Lw=C{C z2JQJaI- zctw=vtuuWco(TEXrAqgu7Pnc-Hh_Tlw*zKCq<-=tR?H?|L7)_c7d=M&mR8)})=3O; z=ygOUx@MB%L8Lt}QxiiYfh9X9k$uC!T*NM!+B>VTWR9R2jxCQgD-K7Wl-_%mc+5to>nV_?Y zU}ZQJKYNw-_v(uTUK@y&%_GP51K5{^i$BuT5tPKzTSFY!8cMk1a&?^UV7})dsfM2) z2eGi`2q`ZbEM*f+!0?LDvp56^8`%QrME#~d5CKRg%*FgYZCO7)ej0ARE?rsfzS#~c z8_LYW6E+o3AydD3p#*)KMweQ4aCM2hkz;CW$WAZA;U{ z$|NpoSVjV>JAvG^mza(#7(A?Y2kAJ|6$5Y2R*H%Y?^kQ&R475k5h4@*80x-#+(ROI zbAn7z5Vf?%BGfY-46>%rkpj%ntTz}m zr6_l4rp%&o+@IUdjc}I=_5&1%w;|Y5E+TOgplkyjKdKC4Uk3YN7TBQ;?_zM{>-DE8 z`Ro$2i8v7SYujswNR#UO2Sh$z1*iz47F^ZX4JpP$Da+H}Q^T0B2o4~4fr#R-GgMMx zoWt5sCFDa&ieT23puES$LqtG9qBqr4p{3ayL%vLv*Fc&w@n=-fboH;P6^{_;81jFv zTxz+E%gSwZoPftI$3YjmqYJkw?iu^YnF27Q4b6)?ne#6)_F)7P&c=hal6&#toP}e6 zg99*TB!kVR<4kRF#KvLT+Swo~23gC_hNvpB&wtLgVtix93a)hmIST}_{<#GQC&n$ZBV*%>0{WaH`7>RdoCa57*J^k6FIom;os!H$Rd z-DSg`nyP{zv@uwEe^F?R1V7WW+YqB}1-fFe?Al6M5Tk;HJ7|r7!Dax!GKj#|S<{kv za{}u=3uwaPQ*})`m&AtXm|mQ-TXcsb=L*pH8vfVheR|K`mWL@!LYuF2|!el)Gz0HNz(hlKpX%4BNQMZ6X zkmY?tgi|mKXFq>9J52)+SXn@Dl`IHFybeD>%|#pIsW2ESR82W9QuCUute>{+HJ`^EY3mBNz$~={ZDq_M0&i@@euJVE_ z?z|m5VGlS?3sQ2*cvpbD+9tHJ^{>;0GUbh}zZmTrB9Q(0pW~LVoUZ z9rm1#xnVe)ZPcd^POaMw=8TsbY!qt8!sS}uoTEP~|b$Q5nLI8Z={ zp*RFGrXiH!cCD%*gbhn|@0&OSG6ofI+2qgitIkctqW7a?GyOMuNDHnaSnE_^om&eP zR;?#o$V4uZ4+pumTNLCMh}(>lK2#UQt$=|oPnN2=W8iJm^nNYxdPi4o?7OW^FSxN| z-1_DH+s0)#RPTwESw@LEnDOl!3heOO^O}y`C`AC_)b@reNg-^2&4jp=H>3k&LGo>= zseP~z_no^pf9K*`+L6A>OgBByBgD~$tOFe$wOu4z_WFbGMz_q=ee;6W)#A(n4|sDUBNpY^eaPd`aBta%(@%3BEX$Y9(*`r2XNnm?%~Oac=Ux+Hhp3q_&5lnr!{k<{I+C2 zH4p_>`lpK~rOf#xX?Z#U%s!b`ycQU06-@*e8(IXrLQKl9uuO2a|9)0UXb$$s55`cs z>YIt}aTEqF{a0b!%7dUQut_D-)Yc=pbGQztmVDM(L;I>wTcoxXPiY7j^z>7eUWxo? zX83$rIxfX%0AyKXCzF)U5(N-*bUjQ@@+nH4;vR<01)_zT4D-lXkzlJxy%HS(4JtK| zyoWT^u=_H8#ucH^lc8g?BO8o(H5DWtl0=^z6H;HN$Z@?z|FdF@9sQTQ^~l6%CIvR4 zM%M^ln6>Pw?L0ja=z+cW%uJ=b5sNb%9RW@1X~t_uqe^y8x9!N!0~{xt1}Lu3L-$w_ z$Rq`W`wCX3zgmge$!dQseSQR3Qj{GkoJ<-^6L9XB<{#l0O@jdVf--tl?!y`5>ay}S zV@N9YnEY&PyQO73db#uf2fsvy20lKvpwy6JC+ROTUa{8tat+G{PhBH+gA#lGTp=d) z#0$`C%}X|qoJ0dq$U;Xavf~plmt3JMljT>*y)?XMKfM3n{fMmyXaZeod8)(x#5xL8 zp#L4e*S_GW>7j=XAF+~%JU`2qvJP%Vfmb}O!UqD`nsilmiHorccZrD?+f-jn7@GLRkLOnOZMsCw zSWN7~2PiGfAlKvh>8(Rt>QjLVVFd0*bJ7ouuxpk}|C9?2E_Z+3dO=g7{dbdMyC-F6 zD20ZF@Fqh`OPr0VcKv=V8d;%mln)<&x0j-0V*|-sV$Z{e1Jru{!RH(t2fp9FsgoIn zE{8v`RdY{9>wJjPjf+R@7xF!IQg|TU!`vv}M^yOLe>BG3iJ32b{BK*b@|q%Tl7TPr zANQ{WfwmARhw>+kfH)I(f@lCNwGDAdTy2h2d>>hhoQlH>AsR#)Lg#;wO3PQM98JhH zK}Rq~6v(~ET2f&rGu z1~_;d6h#Zj5V%ipNLCP*hz`o@`vTzF*woGohK1ZGLdoYnt(0_R4#-};0l0lZ zMF^^m`I zcJ|XaUnlBSF0>U=SwH)&IgTD2G0>f!{TF?Jszh2GJL8{Da_kR=L$OyDfyn#8t_z+C#eXFMiqu0p+s7%` za4RKwWv3qb1dojKvdFw=S2nc_RNy8HS`3PB;;t?u@?o3gvthnG-QlIlLq&)&vMBfj zi=`N~B%#cVI$84Y<;A8G^k!-aHA&~$haR~J(_Q2dRJ#2#QepFo#MOq63zrTd6ujS&72;O;U_-v>VGkFVw8e%~FdAYgzC~A0 zTIDRXs;Bodc3I3!iAn?@WC6lw!uV zwD>kgz8C`d-V#^!yob$p?U0q`xy-!YLO`)CDCQs(Q)jl$S3$y7z7)qHTqbTR)asMd zyL%bbY6L5Ld(U}ModWGO!`8kCYNYE6+dogc)Y=4}e}vH$<0$`4F@~scunI1cdU<7c$uxcO(iJlrNIwpOY)nYzP=%WtSkB6961Yhyv~SEV z-JmOU8IgSU0%4@S(aK3XsFuejJPK5~;dmR*5+l06+|sNgVYr)%bJ=43%1=8k8V%xh zp&^Ia2R{FWHG4psgO&iUf;%tmAhLQ^3LoYS3SW z7ra-Pn+aF;{X_pq!WmaZQ;dxRKPQVc*dthZ5z6%0ra|6t>q*W4U3v0J`%@2O99Ja? ze?~on9O$zSNqu6;3qmN|UqfvRpktF--0aJ;dHzU@$0dcBHZ>{{&h`sGP|+XKo%N4) ze7U21ss8PE2Q=#=4hezx)gWb{LIvyuaB1EE!wMMmLT+^`81+KZz4W|G{UY84ouDwk z0Uo8eMP%?wgVF#w$}3BOW}lDxaT&}mZA2J<{OKO0-1zuw51vgnddYdxAhe~KpeZhMckxJ0(-9E#ltTIVyTQn z`4wqUITb3Lo;S{%k(~8212{3g<#*mj+m0u%Nz#t)V-Muq250`nw2&ey0-dRe9zD5R zTUb~%z6+v-b$#iF>3=zI$U`vq=hMV&{=)__G&1-9Cb2Hu4l?OpHbPeCWIeRTdg!~RoumrUTXNep~7Z>DLfqFJz^p?;L7m_1dnLKJ{6ZUIX6pS5cqlC^*Q%qN;@74GIH&a zRTz(HIENd4S=i;%*1ul}Z)-6$@SwAr{^HFF+XO9gB9Tn$Spx}!28D`u%t^G7X$b~( zwP@lQu#+=KZ0G?;nyW`I9s~KimYDBqO9=QKv+x+W2g!{k)QD^v*XPZ|eqxb9Ke^rz zehEBjlEA^`ll#J%&Xd$<&q@oA+Jqb$HyR6R9-==HfnG=e#$DZ>!Fhrjp=@}4;BU|I zt$E|<^mja8v~%_^|7pO>Ro80>lD_xd9UlQBiVtL7GJ<)itzrZ#pO0bi7M-DQzm8i^ zB8l|qTA{w-4CkdLj1n+aYHkNp5wk?o z*LQx+M9`y4oHPI#B}tSq6@HxoLG@jl>CYhJum;aaL5~PVRwtnJ6S3EaVAQwb1oI^o z^A*-~hO{!Wwmp|AGo(3}MznXlTVBAr&aWc&g<2^qd&scus`GkTh7Xl|eQeLsTx}?X zL`6RPr9i(;yZ8koGuzz<16vWk%HmDQoyq;tEG>qNo*&7lJvsqYN)NmEh{cW*6d^I9 zTbk0@oZXo0(Nc-YK5Yf>63j50Kb53Cqos+3>$wurt2UQWY-N&c1`7o>o?$VM@j6(z zPV7KfhY*zcyQ_wU7SXoYJv=ylY5MadC!zyq>Y;JNJ z#=R3W8L%9jbi=vQLf1WF$a`^1qkD*})pg<$y_2x6-WJU#|HeOUPK=%4GNLP1B8AQ{ zePK`*L#$mTH*DSp(NUQUZS|?mL8#l(B&nOTxo?!qK&R&76zYGqww7m!EZpl7_9Lss z_7l-k^C~>IsnxzOe;$)oOJaIHAv+bM@5XeRmc(>WuMe3Mx)2H(&WDNwdx6MQNkBj3 zB=O7J#CA`Z-4GBP4dWY>9@`!FDW*q`ojGCoT&V4pNl_5!n++iJG~0)%0!2yKY-IRocl<%!c~8cOX{nH~+3Wt~f91f~1)Ymk!L$ zlRXx9(MQBeSzJR;PH*fD?(&$(sd22d>Ij-y24rX)^uKR-QmyOhnfa->w6~u%^$#A! z!u4J{I>#lW^7c_;x9{6_S|h@HtM=jB5iwtuJG1Z03;U+JdS=iD^jl}?xja5FHO?`NNoV2)_>NP zKWc+GMh5EL;7VNFK26ggVH|bOVI@V-_}bb?t=1rRMy?M%J3T07=qGjOEn1 z3PtHQZ7-Imj95ESigY)o*N(ixk`h~9ij%K?p)S?p#9hWegs${@;VJG|;Rd2;4za_I z{g#Zq@JrnvTMOjKQL*PrpwGrf-5=dIHXmh^budGANxz_~4~(@bL!Dh_Cy(yys!QV` zGR{6z&4dKkG*Tg6&4u~1C-&L7F~C9F7wY`2;?F^h238wc{Tk=5Tgb`QR0_FM^&TB# zB-s64e~^M{NT!-wVMKL_N-P?o@{PAT(SFKaC-r;GHH$>eKoQJBrmDMi@OVwTra3un zrEyWxR;QFfQBp<2+}{o4ykzr5*JBQ(1hst#MCApgMz*@39!nbZelOfWMtu8jb*qlG zu1idbj%lY8S%%|DD$0m6lnm?jMMX8L{O+&m$?2Ch_npN!~~g?hF|xV>a~mKC*+tvlX-yN*LVs zpwZipfXwBJL^p?+VI}(zbhHqMWMpKfLyFxIIhYXc1wk?vWeVkD5fg-*a)ZI?2~a>n ztMO$Sg6elxUNHh5N4@9WKY}4xl_cRq1KgNG$Tn7qTe+NCKoaMZJr-|ymrT@#ey`Z= zr@b@kpK^5yE@L)ixmC8fA7Q5_k5{-nNC!_K;((;ft{#e-tSs5MaFaXH(;+;8kB(~k zS3Hrxl=DJP$n2r9)(g3p{6%2kT*2n3B_9=up;-U!>fULKaFyPiYo+cqv9}!Z!9*C> z)2-<#b93lWCdZrg;e{PC2Rc3G_l;2yB_#o}L^gRH_p(wsKI=5IX7cnmM;G&w%*Bb| z7VhFi+acR|OY1p-G1|g(_mM%_wKFVNTeUSaESTG{yQvi#j{KS?L#re=OiJ{^jO5Ao^K899v%T>SnM=O(oc2~9>7Vm89|kCaNhRwi z@2Qwu^5sVC?_KF=$o$uW1Ba-~m)6lpqZIZUC}5gBi@c_V5OFZk%qWqYe!V7Nq*I2d zay8TBStyN7C;DdGuP|4&lDyk)iY|_xP~knZRm{XT9F^&zK5kueUtnfZecGtY?qcpo zE4SY}IX>6i;dTGK_b=1c|CE~hK{T`av0M__8ZY}7jfu0-{d^t1vesqv5CM<)m3`~G z)SSmK9NOI%j}jUB%!X|Utr5;dNlQfag2*G_)geBA=gGMA2u5}*{uJ_wp|U|giE6uB zc0N5f;(z}V(;O+REEdyzu^lJ@m<Mp2)2hCMPU;P;twc0)3nVyeVD*aO;GBJHjfK=gc`6R&`O|wdtvc9a!Uyg3{4le+thy6XJ zz$ucIzM(md>yB5Md3X%yF?Ykv+<@_xM9D&!zru#?8xPmRdBbHC9I~ieKa2@v4*i1) z$8ZozOC<<_Ygo=YWiRgYOx#J10MUpm4}z_xZnsT_unHO6L)$#a_>3kRm@`;_EGFme zu`M}U>(@r_w@tZ9{X8$Pv%BM@(%n!>wd>;~&PmF~&OgBI#ozKFDYES4M?#Xt5@<5a zzEB`_Yh?9@jTV`a{<(B;1$kse<$qPVK#3}c2$jlikokGdO5j2_@4aox6ln)Jmx-^)b>+Grs$lTKvK0%S~BhEOB>)XLJz6HsaE~AEP3>& z3lNet(HP%)MF|@uedJC6DCj!?&{e+_@NH0p3?D+Zs&BT~*ZRop3QE0~K^i?i<_M_y zWi36{w-1x{WKOiHtqQ~0r>}MCdt(AY?4ob^#Vo-(<6x-%wQeQDUdXK-LYMa=Kea+W zx&SigSsbQV(09t>wPkJ|_y*0HF*v(vA zzq<2+Eufz&F>9^}lbW)NN5otsB2!-GgQ#cP0;Z1Zg(jv~MgN2%A~Ew5-D&&20?SpD zzd7n>OIMa!mOSyf4%QUan5&ZxDwh1NWtK^p+m#h7W6OYFCZzEkP6t`hCoLHDA>4E# z1N^r6!QZK66U4{72UOapd z!sxPAbT5lSxi2WF>JVwbGxm3pIc06y3L|xKQ$Ydb&qz7AOR%o>90cDMXV0AbGX#OJBU6}Md~XuJ&0y~3G3 ztB3ha?y|*@#|IL(n+X`C0jsuuw?eQ}X0WUr~`)|3=l^*@mzQC>S zzMO0b=tZl`0poh}pgbk#QGgiF3OC`jL7IMLyDbsm=m94=mT9k#g{sLtfABrN&jpsR zF|3nAV{OFMhZ}Z+mi*~_t3SRc+Er(BX!vvbn03pk_r|7O%9$O9lxPdL#1)vZ`%&C- zY~~FL>XrLI@p1sYWuX3b78NTV?TsTtM^fMA+F&`%*>-(bKfb6SZtL!E44eyjOWI z%_X=qq9saz&&Z^zL67M0JvEMJd6kSlEfnT{xk0e|sJ=8kGBH+)l;$P5LzytxocWuQ z<2PF%X&H8vMbleC>+)lg$Q+^`hnT)#cLc@rm-17sf`n@*g6L`-07;VMEoQOjn@mWN zmXC$?%ZU-}pE_J51FiftF3s#@Vv>nDqP1kU#*mZq-FQUbB_eSbH0$4Vb(5r#gjSBm56! z+>UU$m~3z(1#nhZl<*@!3TJ%gu+aqCxOq`m_>5NX3HEK8C7i?riP zVzXda>ZIFwL(Qj2&Q>Q9#jD2#!ac-bTFv+)67CAr_do@ZI#OHrfWvp1k<;x)LxUBZ zPoG|!`UFS7WmYItn479ZRoC7LIqkZcE9G;1TcB&m4Z>`g@yFD>)}Egdgzut<4HAa` zX_lDPFH*A`jvR3(VwO8~BcNV{j|WZK_wL8IB}(8iehw}ESnFC}fn(XearDD_YSzNU ze;@{L!h*evP?~y?n!aU>NNb!>P1Dk}3lLfdGe=Kb6I!6F{)_IezS-599AB5zjtX8s zc%h(JFtWwBL?zsky^Td;S+3_aJ-%{1%O(Z8xbe$;mB{|Y^il`~dactDHP4PyJhp1W z9cyBZeM_9HX>-XiHPpI;O4VNoBT=7sJN6`C5i_#)%9Yc(RB7lrETfx^zui)c#~6o} zuC;BMvU!@=-Q&-T?%(WL$eL_-6C z*G-0$bM(kozH6;76yrB1Vd>b)31?t6&b-xfi27(Cc#J42kEZ5yE;P2l)4^u9ENYAvJZrW}!sBFK6)&i=^R)pZCoj$zTGw8O|Kgp3{5F2BW z@xLMk&o?1vs>G+}6*pd=Hr5sHAf3-BM-nEBRa^Oov0q^y0nN40ITKfYQ^AXY>NTt1E(E~SA3*x&m*0_ zdG)lr;4=ot4a-Gn(5{64e!`T?CM{Z!>wCWn&B+)OmJ&6d*u)Rrg>oXo#&_R=dPq;| zM~+gip$!=&B1AeOHVeDeb7#WzE2a8$8L9f}uM!7a?81wJrdacOSl8NpPyh6Dl?@Ps zKHBfumNeIpnYx1uC_egy6-fj9aRg74@3e!we1@2=$)Lw%ft0SPiQ;0t<^-2)!6N7L z>9nURtTdhk4-^7(8LDV>2gmETsb_PRVY?f<$K{!JQ^)b9l%ZE+^mRYbUndO~QK~OH z45W4KGRh8tESuM%AAad%WAkzM8=y3&0(B-p%15~JcUoTX#Ialw*VjS>Qp_WZbIjgY zpwp)Zq<~TgWR47lA$V(Kg!#XjI*zqaD#8`<{e!^z<;Rh!lEAW7$$A9>tA9A_XI=02 zu|AsX{|X|y3sAoue5Cn(L~prQrSj(n?vSX*kgTip@Q0i|yZ7;0%ut_b5jJ?e27O5C z2f^aI!lkL=uy@`Iu%-=Qu>dPr!x6QpqEuUS< zYJycvCSahWNd5YKsH92lp;KPRhrth}Pu6bAxtux?vLxYC!OQFMHe12z`2yVIy!=OT{s$OwFk3 zaGI}4r_0_xw&`^JI3w!*5k+hmO!P*ge~*V9JDXnXbzakkShG4*-@D*!I1gCO_p|2hj|GeUD0iP$*Wruv zSeuRq*5EtFFWR5WJQ&>>41>pdn6B!Yb9D-MrDAwt$=^j%^{^MjR+JJ4_C$v-m%3R$ z)BSy7>9Ju?fcy+us@rc7>NC>GReT2rGq-NJkfP;#Kzc?wp2r?+;J+9mU7NsiX}r6s zBdoA~QdzgFZyOBJvO;X#(q=rgL*mP8EO+){{$YIvFu~%*b26++R=QU*{l;xC%S&1v zIBY3#wwktSdT`IT{i-XRJWt{tHV-#nWu|{wvb23h(wwBN0ky^&|jMXR0Cmk%tj@E!F zv=qJz9CVDEFj~F}5T9^bvYnx;DNr6>_^Tw@NDm?KyOUWMMU|znMmE4pe0G)YcEC9M zHKf+n2s&Zel!19f#=$=mI+RkP`Wna5S+3+fLA*7PJ`%Z}MZAX+RVJh+$^JBqn=|H> zHeT^eJ;%TZ&D&6 zO6C3hU+nc7V5te(Fl)^mW6?M3#Ii?Osb(_N(%21#Nn>E4fohVB4Y3cTp`ep zDFq?rAq})Hb&e1rZon=o?K*^wUGdF%RY`RBE9bibyTI|BepR}woB2;XQsoIoF68lX z9K=gWye*PN1;Dj(GS6D34N(Py$NJ@^{R_a-rqwg8ny&lpwM>?bbtT*NT#5GwRKOiP zbJvD!J3M8#IFKY*2tU_2o>*K(!*OvXC6jV0lj2& zyl?Dsx5Lxkzx*$$jn>$os}Q{l0oXF_PG?YS#(5BzTffu2lHv3_3=msL7k00&A5~EU z8f6j&X{w(n;K!e@3>SkLgE&9+-Gizwrp;dLn{qz>OyoWpy+B?2E6UIgqAs}~4ENId zBd)7^ujKrjPX^S?XpL$4yob|B6-9=Zu5$!EtFs(kDd>)?@eqMy!$&fK8*pulh9+KV ztvjpg-*}KGS&6GX3`KCbyN4Wu`e6NFmldRIQD9&djs2O7?77hU6t~4 z@A*Rx~+If_gm^+lY7cUK1dRCRkTDrfVurh~dU!d&m?YtkkC@>lqy+ZVy1GO8M<2CF{_LsvcZTV*q*3do_1(u z;ppVOwDBm}t>L>I6l}UKAZ?sx{N-_!1zj#an zwO84%m_@B4M66q}K;>uS?@i8f^!O`Y+8bBhF7zpaU0F!6X9%M9-rV`WV(yU_+1DlX zcVc^B5^yvfp$m4TAcpu71B*pep+&P^3BPUwHokrBoCohu4o4biu zXOhImQ|sMeok3II2>l8&6szh!5n=3>jSc8a!}i!Q-(NHF>%5^%Qz>Q_Yk-bvl{P?* zHZ?UU7@dDY`BQjdR}!gm+>yo4QgZazlERzCE<;t z>_?7W2AN!MNl%SBl1j}(v)9H6T~yNyV!y+WlOS#P-4R~zqS5Jsb`x>pu+iE~L!ne| z?Zx83^QKROc!;f|8Tc?EAnHe>c_NEWq`9ks3bPsh0DqP$qKcppMXnUKSck!`H-$hQ zhWLs)^C)vh&zn}6S;rGY9dOEx6G1&#_m8@T%vAl~S|+e{zhcbQwgtPq6`d&RzcrZ^ zE(+MqaVVVE>HFl+;K2HqTQi);Y@9-zqksZ~-@{kkZxZ=w#IQ*5?+oRm2TJta{Q_*I zgdQccN3Pt_xapu)J8hcY_-+h@%a8D<3FK|@MfGo?tzbh(xzhbg|Kw^SGJ5ojcfF*| zRfgrezr`Zi)hXgu?Yl@%m2M1we=+9%UM8*d$>fT)S5vG2U2#5Xp@o)-ua9(-uU}<# z19RSc6Ou|Y&bQ~s!|7KsZO;Wo5P?2mBQDB-m`I~~Am*`%(arrO#iCLtagT{T(?o%A zrW49rvrm)G5g1%A6_2{HEhHk|WNgZ_cGF zmT@Mep}vp8ri&-)89E0s&O=lAbvzM9F*pOTltgkR%=T8|KT79;TLzuyjf524f>dXLV>IsXB^LCh@ZGo z_CVto4$E7m;(t*TUAs-|4xkgcFN)WdiJb-+nJM~ z>kSpQLMrFmFpCn|+lS{YlW{#0D8TQ)ruFu-*~OaA8eieLI`USSoEB<5?>;Zdj6r1) z-bS4E!Mw2+YY(a;dRaCd!1aa0ff}|segvYOgp-4~)B^3CxbQu3h)4G`BAeyMvM!z= zg9!aOiiqovj`pprm##FHl)EexUMIPpZM6HRKy1C0J{xQ6#f`Nus@>|{I=GOe)!a7? zug3n=W4X8yv~cyom*H8|360vpK~AKLIScOHZ@B7CgxO>IOH=f}d@Vs%8Yiv6ma%h2 zfThm;reMfi456Z&j7V4JNc_G>xaAyaLmFyS*!`iu?G?`SJq*@`kKqSxc&Skf7f|u^J7rS*K4i`uw5ND6M%qW(jIn~rlT%@v5Lf=`0Cz)Ja4C`O3SEu zMtd&(6m&V=?lDb+<&BW@vCskrGMn4>x-hPZ>H})P%of8!HIKJ6W9rn1z(2LK%&TD} zQ;F?p55L~f74{J{({QvZc67v>n_;^ZSc^E_Eqw8CTcMF|qQJKoqFB9|HKY<(0_ot1 z10^<7wri$w(&T_m&&b>Vs@pm0&Yh~#(W{HW()a%b)-O}%u}oabdDtnxj1^S*sY|(*CPlD6$rWHT4!565c-5OYU?_cW z(1H`5X(gt3gyjs<>f>|)i;c(g1iwl?hEL6ZN_y{F+<132U7dV49BzvHbq*B7^{U>3 zhm@6o;R}Zmh)dbh_Wxsfbus1m?B#~rZiyW37V@NH?jQY#M1-z@bhS?CSaDk5XR?7z z10Nq0eI5DvizyOuxp{)eLjr~p_U4t%95*A3 z76^QZUiZ+CfcsHcO+XJ%6kgt@#0yh;OFC|$fnQ*c%lk$rvgglBTcOI?3ny4B&k_l= z6D+`NwEg@Z&4tk=*HJVSuE4uUxG$Y4lTlu5 zbr@q~!3DJ?z>r$_wkGB*tR2^A(8+duGuw%rC4xu&?bZh|8)wF1^s$LS`z~6&@HY&L z;CTpTW!fpe4^l@>?AtTJ7P{r27ycY(F>$1%jdaUFsAGR};+6{dZS@75dC3I16WO}_ zV2HOQ2B$WW!v5|e`{V&|zS)*r=ex-)?^yv5#wa`Ts|2&T46Z$Tx>8jk)JPpmVj%7^)S4CP73rI9St@9^1r`5qEOLFOGT*4EYmbqg zd^SfdFer|tbIo@4+R_j|Eb@b&mWAk71>MP``QJs{lDEej7e#IF7BeAEY&Ub2^~Sc0 zf{FQZFAVNGlklJIi46*3Ivy+9BcsQDg>}okDwhm&e{A35$`tnbEX@#Ti%|>2clu=E zxID3YI-skKh+sc1r@=lq)paUmd5qMf9gUD3Ix=@Xf(%j*W4=TfI9O!YRYT!0Zk8X} zs^d|uYjs1b8!Y8aTSoLRiQ+&C9}hEyn=&Y^{@IuD4~A-RHSnA){1TBJ^3{yrrR^?Z zrxi_Bl`|~uBI_cncV5<%QJd^%K;BgJ6k5LQn}-j{9~THPlDq>K*l0PYpmRZ)d!GWh zQDHvN7ZPsrF#kaDS?9Qp(h|j2{rxl0%ISv-a+#>F(g37TO7X#U9GYMn z!n5(I1|d1wj_&PWZzii+ZaXV1clbBc9eQ_U>s9^xzChHjEb8l!vXSfWT^-LnJR9Tt zd_e2MnKmrq3l$&wy~}7vq3S`XK4pUUvpnmYkL{Z-8G#79bPJ^jeJiTTRN4iQY!kM(>w78#59B`L~b@YG+j-OpA-aJ>Y~G5*iF?&{<&FJc4%nOB7bLH>`qc5rmFGI99ZIR)5`D`8Kx z&a1mSzd-N(z&bV%WO!-|gzSQ|@6nylXuBnbbyB)sNJuZ2ugT{lDGMA5>0!5J^=iwD<$Zl8qy53A(P!-((EWC_9MjvJV0#Ia z6Sy>Q-Mm`5>UweTdOzy^ywUUdeBHdcKX&%&fbZ_+@qVgvclq?>?(}}Tw$tXT)=M7l zzR+84zdk7W*fnv3`{ecXcGV#4>ge=tU+eB*kjogJbar^X>%~Rbcr#877@>NT%VGc3 z*sRytS>aOh{DvIzz>aU<%>%I2jY*m93Xgnr@^N+NJ6Ai~XYM#oxp3=9IW%=4Fg-zs za$l`LdfWdq|J!zVl1vR98N=I)GxYPJuCv=i!|Unec=Jz<=SJ1`W(fG^C+Wh*y!T-x zKmB%7iP8P>?y=DAk6X3(ZvAc^f~zj?X5K3HjF`LyJKq!;&ZW22%a&H;>57!$`STWy zV!7AR+LL#Odx0YkwPB^xYtOreR-e#7!cRE4KV2JL?adYAaf8f0R_z;JUHDp8T^|oZ zuZD^tg#Gs0n~ccY-l-ybp5m7mBtFB{FWrW{)#Hm4$@TA!njzj_(#dfh+hIGew{Y82 z_`TgCl-oUdF3?D-!(#7ikRgjgE>XFsL+X)3ek|vBuT*zcZz%eKW%)K&!W8%G~Ehz z>0`F&o`tt~kSV3_qP^h2OH8hZ9B_}7z~3^9w$xktohP1fT1i=JCZ>w{9;@qDw&4C+Dd5XUWHA2+LS$)30<~pjx#zVAIPr zI>EGvDW%}P{KjVmUv4qL!KTQaoc_;86Tb?$l2qKFPd87c%kV>1Dy!BPcGDvsSiD~{ zo~Zr{rr%)&ER^yBiJvp67=*okCdL-AOG&~gW-yRL>`r8WJ;NJpp~w0&*}{nnWN<(l z=S^lbI-mn>j70=dWN$H10uwqckC}m<5?mTlIu7WG;VIqVVfrMt!2Ipvn?YJd#J9i{ zQR16@(Hs80Nbd$e@*gky5@!N0!TkIFM%99WpdE5sPz}yet?&QgJ4^JhX&~@E$iJq7 zQd?kkK)?Uf^q--8i60Jh=ph*Y*8qGLWNz@{0)L~J0ou|16UBcI@xQrJl-pVqy#9OF ze-F%t6c`Vj;4NmXe{u(K2L5-5kPy3Mwx9xkqmFgIt-|zA0p9grmmF3xq`Au-n;yGeQndZjC%5a4yjH%6r8QO*=xQIDB;)ZztNqyedEp25I>Co)!Ub~*88F~0cmJJ*jXXo{!4Mc#E%mpUJ2e#wNvLv+m!={sC~sXck?RWa(sNZtG=-RjrFzLna;_I zqn$Bj|2PhFdi=nWBS8niER#%Qycw1!wM`&<6vmJ+Xj*c(&;0Uv=%5e{U(Nh}=j7w* za@CSRW?uww2<=&YAXQo?ARB9oR8t?od~>b~oNWEmtJ626@JOTF|1(K@>-55Pt$QFl z&80PQ?v{cxr>&iu^+$;D_Xt0+RF`0I`gP@GNQN@(^?5hTPj=J=OxYDy~dnRu3PC(GAX+m4F0xj+S?7x zUi0h=9Q3y`#kMr=S^W>((DP^bpw;bPw5+pcmil0E0QFp-&4|dj_Izf6zXb|xTw@Dj zpiMdkexC2V4~T-)0ySm2?OVO~4PT?y5xa4I?{kp17P~RxI&_*v{ubG{agDPK#NrA# z;kXhhk;md7dTj}7157e&oAo*6!4|{IwCxHSd1;IL4IpBZ+4$~ADOEAbFngJ=A?l@{ z>`R6Ih8dszreZur%4CYedF7vJBZL2h6W)sa*(RqDk{@pw&4Zl;Xm5|;S5#@La=xD5 zXLs|yZu#)WdU;wd9PsIyE8$OmNOSuoe4Afd2%3`G&1o-C8hf5E7ekB#NXrE;0A z*eVqe61)tJ`NHGvNstBML2SC~urRR;;Lr8#Y6+tz{B3L<1_CzIZlTVtb zq|b|fE7|065F~kgK`iayn-XKLapG0PsUbuHahN`P{~g;4c_UUZv@&|8?IXhZe90>z zf}CvBn3F`2KQ{gx#5F}2I`K2ELMPBgl^IV>7Mz_LWsZA><{OG=dAjToJt&!W|n;KG;c2)@hP?zu}UBZe*UBb3($p36)>$@vWNmEJw! zWpTQcRJAQOjOzvqxrsQjo}kUvoJBJc4yP9 z{PC0U3~6k;ub=$^!-CQ_cu7NkY|9{Qt0vP=&!Kb_FPyP|^RoQaiF1iQvHMBYXh-k3 zZ5@Hx7@awIL?rUuE5!_LTC!^$q_5(K)9= zt|v>hz=Q5+sqo4x>d8jShKV3iFj(}mGgV^GqgwXs!{(bElljTfvF-Xr`o)vWXshX> zU6KDTe^<9R>oDZm95^My6eV5^R1-~wsd8^vxG;tcG_)3m^Je)owYS~alJHMiXuinw zS#V13Dax}bB+Jt^CAseyHZ0{k!eaU1ngv4Y82fE1wJ^i$=Ud?-!|GVe^^>Rm&`l9N z<;gGiKbJGrTQhBVH3t?HrP3%WF=aQj7v~%gLd(d5jZWNO>|43d(;j}MoJhxs>2&)F zD&wr_)8$8_qv$ww0Nhz+)v>O?`*#m#p`Ot8$M;dTDCsYfftc9RSU2` zmdW_DuiEx*1Q%itFKW0FP7iMO4#fq9t3eRYqPkEovz>xEJ_mj%Sa@*|CFNh5Z(As3 zo%irul_aRGy_l)IesPn=j8VocNM6{x6ZRvYCWE>`e)~{TZf@ViQSvC?O8y3_XD&)b zQ(wwl{c9Xv5NQth#le{{GTN0{c1+qleCLCwQAuS!s8EI>Ca#L_)EoX#{Se=frDef` zHR^1GWwm@?&L<%iEk(~IT6fU0pFE()MQJ`HQHDb)Zkp-X8+lQoo6vCFu#l}p<{{gz zFNdfWTMIj9h|_FCr|y)59}-VC6d^)d{Gpukb!&{yN}T_+E_EigCZb6Sg@b!H~2UMhcU;~AQeyH zV3G>ac^f8m<`ZPEW?_#mwUrF^!)h4;hiEjGZ1!h4k~JT;X|HLKO+I&;DSllaPoaOe zf|IGP@m5tUEZO#oCR+?b+%M;ZUNX1I0*fhV=JJjh`v+ySk;+WFqGS-WBiZKSO?;m= zukR{y?80)xOi#8#zC$w;hD^Ae_>Mp>hzAl8gF5>W}l z*!`(a$+*$3zVs?fv?V|tUy3D7t1!8fjWn*MULnh!^}lF1Zdeq6StBW$0Tp@J%m+YU ztEi?-eBS2z!0!(y85#y&JJ*(d0=z!*a=jDYveCh*x7MgvDS~fkBrWi0%{W_=o^dEM zky2dRAc$;rkxY}T0oub7Vm~I7y;ps*7+vIu*nM;!SUQG&yocGj{-I?0>e*+%6kgJ@ zNBf%`ijA4crug;6?qjZGa@Q3u%46Db5dQZR`!U!v`7hQ)?UhwX)zLVW%Iw%|Fy{&rN)7d3@e?QEVuT9?m4qD078^n8Vgqs(W{5~>&3D*O6~)u&y3Q2B)ZtP4h|k0ITa z$j2O5v=h^;m|eO;ZvZ{YjYa29-h*A4=i`z|b_(~|tHo0RPbMBI(c!5^ES{FJc*W_r zkKO96U(j%qU0LLZnTny#s~9nz=8;G0h%*fn(hU^XD+uvzB4fURD-9XzkR71b&RDS` zrBQTJBa_%>d#k!TZ(L^aIV^9HoAMZipIdRJ+vX@_r?48j5~;C4jsO9g0{vQg0@G#Xv46A+S7pX1T-I=Sfg{!-$Z8l+K{&*V@wEsE0_8?dN@(H4nK={cd zhLCL)&`tB^ps$ig`(Y6<8hu_T0@-4x-TyNhSl;cMTX>o~2h*HcymJ4-S-coW499RisG!A0%b(M4siP6Yi(KNs#w0}$-Q(@8Ty|fRjL11kVjAMqt2X7N&=l7x|-$-@V12_rD}~t(OZ)Z z-*x5a+uB0i9aJZqQh&3Y7Wrbqxe9i&hMepwKIH4x5yW2y6*ivGp5|Xr@DUJ-<%M|L{EdzHQ`?%Ih%~9I-ACik44_(`;=IwapSuic|RR_4JAgK{r$yH4bK8&c3|;5hhj;t9011 zpj2m_A+ex_6>##zZPSKk^Qz85Ff~dloolo9nkM4CB*QOMxu|ll6$QxOvP}wiS+GU? zMpGI}$y;R{spz9lV~~o!xnY>iuv@7*URwV^k#t|W$`mBz>^;-SDLTjp*F_QFr)gnn z!V~s2@`s1tm7ds?4Pq>y+`Ki$vp}W1q;`5y?UgiVe&G0&3hZ`mx+KE)`teQ~f^H>q zZ5l2Tjs^@ah8Cx2fgvZHkV1^38KzXq^_L3Y>AA41$oDl5zdjNk{u52%g0IAodI`PX zc_Ph#L-|J%xi$@7W~cF$n}3P@&v78g{$m{HeF-ek-S!*#FE#o=CIBN7O(_f&@R1Vd zE(MhDnYh|oMZW9wX;q#o91qbcLnzaMonj-SBPl~HT))0C_XI>$F9P>-~9 zT}4%q{@{p~CR%KO5$~F%S)5-vKBWbVpElofa6$)l%RnmzKGh^_8H{=jm$fX}e~o4u z{=Y^8IQ{>QMm6H<$8kfs99RElRuW^O2|>1PmUL=>>44@^QxWB}L9w5*O)SPDl7xXeB$N>cz+i3C$*Q!?js{2iyzyU8{OAUDCIpx2| z_n%!28y9L^n`=RktxK%Q784u2)Dy3VU9j7|mKKYAzl~BY%N_!#8OeRW<>K?KivF5Q zD-Y3OSI;7zJI{B%ngK4~fVnnlUOC)+T%cFcAsC>3)d%bwB zbO$k=c5PviQxW1tiRs#tav=?=1w2!RR8K9F!xyGgzp~w<61FyXT~=y)2+;)ygp`;2 z!$4mfqI`S<=k%(;k=&A>v)IcHqBbDNMxZ1gJ%e35IJk`mt3?-1{+2xr1%GLpq>ctH z3}<}mI|3?Ah{8K#2P+xlc{ZbA_#vIM2r$@{(0-F%^rKw zxnntg5S>H!xEA~Q@oc~lZq@EfLJlMR|xGuehwEeLY$6?iXC8=6nd#91>_OiPec3Z1M*Pmd!=iv2 zGT-`#4mt)6${RPKr8)WeZcL%B@`=6vw-_}R7w}Bntk_^Lf$KW`;K%_WbBK*(uQp=Fksl$XGryJJ#1oaY0y~9=toBJQ`%r`0p8BZ zfZ_e7Ljg5r!EzO{W?kyu5%~XO>>J}NY4&wv+vdc!or!HsY}>XbnAn)u*2H!)v8{1RxlN-QZIPp@fHzautlmuT zdgU+2$L@(3B{zBfWy%`bEo2LGds!GM+xU!Jr?k+O<%9K4yI7_2^TC8X)=ytlvSjt( zsCkVSR8;iP7q}9L#jUiP3|9(iB9m6CBn4#M6Mi*+qA8dX1#-ssfy@f}<=WBa0+y2K z+I|Gr9;dvvSiYmd!YS0&G*V~|YAxLX%w8*Nn9Za zo79Z5{5XHgK1?)Onsc7SWoF{!A>Gl+>maw2+0O-_`b2I8itCMf*w4)GAh#JU; zWu-{>aef^FyPxZJwuAYwVhyHSX%ohulj7sJjm`^_@ijw{62X)sr%XHngYgzw@wf^4r zoU-qC7j|FI2tyiifw=FV=eS5`IldfMdCx7rYGcb3?(4+(+dJgQsPT+#aWDjgNnADmh2>7+B3_;d z3^RC-`;43;$+8g^_I%U;Ivfi@G?;eHhH}RA^fz`4R*4&{#GjgcysXGJ7wlGPULU$S z#StU98&793@pR zz8XeWl2QgC-EziWUjw#bm7otzPdjpZGTQ$g9`Oo5os5Mb{XwnqhdLQR&8xBV)X8Bu zdD~=L19tO=np7J=y;_U>hdP-)O~d(rqP|C>xFq*|yFD0VI|i3lU{Hlmw$HdTHh^w7 zLhM1=bCa$se`BfT3=9LzBEY2K!pMX%&e>@#SX`grq~Ydx2xx0xG~;~Oc3q>>dr-!u zRfEXZIuSHAKoH^ z?ebgyBtk#KD?gg$t!o-a(#KmNcDQpGTQ(lT$V!yNvtzI&`+-?%CZ^ZuVFmX_^&Dmgb_+vh-ewEPVku zo%|;p@@21|G(Ls_4WH+_)dEpExs~s3o^qwt?A}hcdAb;@Y`1HXW#?m~MV+#$xXy-i z`husP(Yl4!84O7}#CMDRjzYTIjTzBdZoyfw^uEz49~+lU@PKUJ`wjN+qLK7X_g^X$ zvI$?kzFXi-vjVTCl|2qxr2kf#S4`o^ENa|5)Nx8z>k14nf}U;gBVdl2s zHPuLy#2dmE?Xuo+<#Km@_5h;4mmCA)D~SixDIn6YBl|U;122G0LPVCc0oz3!J=4h0 z6Hd9ouTYEYQ*zSa7cP<4C-!u%A0vIl+(Mp4q3eafih?*V53UX(I1c+-t~ zU&lTTa$H!p4Q_isO{?qgMo8AKaB6}9C^sfaJfg7ga$_ujsB&#{fVkpJJ%N~r+CR`R zKhPLTZUs*mbpdGG;FBx4%kBp-UYZ&1z<|roB}g;8E(SyQ>K3vtW$^2oE-iWHd^$VJ zDX}{+33wnTks}b`4~SsQt-w!Lf$h1STi}JJy!eB#jIqS+-M%aP9vjxgrzSQ#;B(Td zeXwi3GMeo`7ek`%c}u|zfFc7geT%V=VD1=^2z;&xg9vg49EIq=(6YC{8>^81f%d!3 zgOSj2BiY#Fzr-;~0L0-2A?Z@`hh~0M7zoeEET=*j3_u0`8+t&)0qtEtNT6PRz_b!~ zuizF!K0UBfV+lN8G4%;V+yh>fx`%NePm6VF8LVz!!2y>YfJF3TfJD7KIrqGl4I6I4 zuUrNf%zXq6We7yz4J3F(kab)CDbdC89}@5C9u3)za zytxqUE+FZV8G*Seqr}7tg^mh9^rd*HV8USHNuqf3lZ%k<(+_phg=jz-Ox_$%= zDxQ|*-Gc6JwBf0=U&rTmA$Tg66gt#nuf+6$cgqJa7#4={iQCYWJYPuDXKXB3Tug;r z#X1Uy`zyiIfW*Oh#MYl|yS5SHCZXxvn&(p5P`gfmx|{+>>?d{1V2#~Df&y1=R1jg0 zhLriJD}ZBvwbbFel?|RjFIpKKdE;77!B+Uc3KLxM?RfRyH!y)*@u9W3JNfl{I3&Z^M1AZzBkPgg>Fi=op0!rlE+bo1!luQ_Hbl9tn*n~Q-t-zUti zr4kEcrBBRVW!)87=J4G6l5S@<4{?JVvKFheZM;s#B!JpY$JB*@6Oc+6sq>uxgrIG1 zK<0#wx$91Qy_7{jH4%gW^9{xROp7DyF^L1UVJ-t2<>mzFCIjvZMdJ!9Y92!3BQzY0 zfiFlONNP@Z3DE&yR{iOw9Iu&TygFns*oIMH3=zaNB*7)6{{;@Hx&P&dIhdg@=eXS- zI6v+Z%dBVAc=c;h;qkaUdZGUiIFH2ZAFAe%#<|trKU7W!^#i>o;=iCqU%(*1D{smP zFzP0&Jyn*t1*RQL79UlkS1}J3y`z?07am)CRQ^3bh0~o0Z-JVDS312%n&F-cXRur+OFkXF`DpVWq>I&6pBs*!8(YmI2unj% zMaFTt-j7Z|Rd12MoE&Kw3$ePWvK6-Fj{S}hpO0u*nF-}BcY%myZc?j(n(hlg=gV97 zw7y;UEa?}A4wUl7XSiytqllrG*nG(?CL!!i%wF{%!39-0m|c99O}qBoeNp+v()cqh z8}#-Tg!(oQCnM6QNKT;4wqV)TbWgjo{_#BNS;6N^<(7JRs?I zwI<-zt|PsdG-a2qs4UFuqToT~x}tc9}UazC{=>Kb>+rbp^o%RE(5+rsj^s zTR{_aGc@Au8Yd6h)GtMD9U24m23B-#JGe@>#Hj*de03Ne8f!s|=-odA2NL!cWUrdh z0|a0Gm*7j5#;v~vKL7*+c;o~|U5q;J=*c%NvnJfCx5AEhEy264>C$X&>C*lN#s>h` zUDf^rSSlrE{OiK z{tft>M$bhZ3pe^p$yTvJWNskguJ_`7+v49@^qCtaud8zZe`w}=5!)CxTa4CR3xM&( z|6N&y`SK^~!$|a(FVqi;J@T7qLf`^}j}WlF0f#)96j&8!lcih5>OYlx5DkcL~IFNKhONI7p5Gpi+P3JBI~ zSBNE~!Ne?+rVEgqU;A6 zV>W?8v*^W<(Z>>q6_V(P!iHzBf2HJ?AWMsw)^@I$In*S`GU}z5Eg%ihQTX}%{ERX% zg*@MKl)#pR!xIty>rP(nu3&Z{`@k(MuRRnf3Z#wCNVZ(ATXlNOL|7XTaLwzAj3x{> zBt#p+{;c}3Z-Q4viOaBzG37GgoVsguKY)gMR#j2)E>Q6>H+o{WID4+>dmR?*@Wu|n zfO@Vl?v(8TOaA@v!uD(KUcVt@I10ygyB)rmPtqwSNRWsfmX zY?=_T*D|vg@Q_1NDbP?ANp!1|xv zsX&KzA6|cBAmWkxgWz;!cC~o}MGi0w7l6}dgMN}6l_HH#R%lVWzhBcD*{f2DA{(3G zvHJ<5nv}10nMi@Z#87T8dG>H_3R)iiX2F+r2SC=v}3kN*+Qq@)!kgI;})3yc4W(uF@G@`cNh5U z*iv(k^Jf(ouPP;)O<#YmkyN4gsS2X!2U-h~Me|F!(Rr9#wWzpSe$)rbaQc4=4H=8Z(nhSyBz9 z^$CIk+#tvyg2wg1F57s6kp0z=AV{N@eu|Mn+=3GkY2`t{X5; z_GY)WR~>Q2m36_97q6`)+H-wj2(swz?$>v&gTM*Wq{RS@?dZa`61Gn084r5=X@n}W zP&ng6=Aou`3O8Y7atSfrKN_d0S#_dq3gVat@Vm^#@+&;ab2>DnqNj`ikjP-g_&o@R zzP#|VT4t7!2}rGUMMZwaI8IwVQya;cwV-r`3`xg9VnzlEeVCPQkZM;zMADHld^?*) z)#2mm=aCBN+Icn&QoFtS5&9FQh@_GFI>R7)0-B(EXbQE^@li0&2woag|0pCi@+jeH zF*3CxCK}k6?yF@~(>rLbLya`+q(BW#H;v^r{8mu*--dU@gWT8{~ZtLnG z;AARfD6Dd8>-5jLoLtS|7J`?^X;qwu115)4D8!#1k`yrGnkE6k893~Pe|O^u{N zR3fM#de@S;wp=(BN?KX+4+zYXEx-~C%A~4|Ft{HE93MNVk*;NyLS8|!5)O4YE;X{s zFGv;hPD#1V+}@=edb|j7w?>hWHE@$$;XFiAA@D$$bU|>2OY+PlM}P=w6)X>6Gd}Q} zKf0s&X8^kQgZ|Q;h5H174q|1ExYU-&xEWWCPrXN9;*AXXf$>8SoQCA2x3z5E4c)Yp zaA_w+i$aA`(CWCWM|B3>}Kv z90VO?%8j#9O!E&2uivP})@ZwmbIIjY-$4W-@=m>iu|6s@rInPj-hEZZ; zk)RTLYed&Ktam6~HhttDYiJCZl%5+7ukc*Kc5KCR8ZSG)aT? z+w%3PcT#n_q2SkOaimhC4~elc?|RuC0P|;Rl92euIEw-xJy=PJfH>hS2_c(C1uuyn z!U>9-+DjKV5UM%9DIDUEQlmC@Tnwg^Dx7FEoEFV?oBW_<_d(yD^!*SUl8@Q0C=8i2 z@vH=JCt7?Q2yqDax4;#{yt_Ez*VVO?FtlU98x-Ptm$I~)h9_KssaWmn-B;^9vpG=j zd)dGJtt07=4(LBRDkX$0Dgiq3-p&ecMPP0>>%}At06&N`d9lIN4~0xiNc(GJ|c+eXuk!Or=j6>DED1@R!RG>hiq@YbTN1#;Vx61blaByFNtaNGP zMm)RSYhLiPdxFH)UGttk-qY6ILj)$p5C8q@{on8C*Y^mV}A1|FAMq5`$1wm8B`oZj*9b!g`TZVj&8seCn9>?mC+oV1x=S$b( z*?~dpD%s#c)a^yo`fF)x3cQ{bLsZz!y9-2+2uu@g`cee zMc0zAhu15+#f?j?o)6$bf;+z=o5vJWUq8S1CLKRF%iYy6pMtJO`qqF8!0mthdhMk@ z?%eZ>-?`om$D9&|a)L9$np5^Y{aQNQR`;D8`vAMETHJb`aV#&yC&(X)Q4@0B)adDj zWv}Fhtqv^i`BlMRuf6SFi8D+M@l@M7aBg9JYls+QaJMD4yYZ&by!1&rNw+@qRXbVj zvg5F;UR+SKtbA>GbF`q*gb-cfj#j*zcD3ZV*|xY!m&r{DyYPK!->}9c%t_#p@_eEH zy!Yz&zPPSa$IE9+(7RteuzKU0LcoQ$`c}t>&qKgX$ouZ{!n09v-_};WN#}}KK3_fZ zN>zR))B<7nEtbY|8zPvXVQjW0bVP>9Lw_gJ%Ph5%VUH2>O`dAqJRqT0of zy}8LVIZ^sp^?UEe@b%H#vGwh4ld$eSjq5iP)8m_O?dx6N=}PbY1@pT76X~L2M1VSA zS3`^^+xs8x8u+%xR~B6|3r)rk%H>n#XsnY<0`FJV&CcT(cF(eJ^t$zK-#F3;sNjyb zTkl6m?zUSn$uMaJUK>0f>bzfS&2f`*+J^j3Mr6Kt)2=WC8lB`DedKjw%hi5ytNA&yP z97V_JkC%52Q*N^&ff;W#o3=`K+D2mzP}ZLrs^z2BE!FL!4LN;fH8<8bo6Co^yz=8; zD+hZSrlP|3XAc0k3wAuaJPzrWeaqo+BaFc-@$H88+%KL#EPmDL4I}iReIf(xG1)7g zx7D-D+skXAwTG|L16jv`eb?Zx+R&Zk8|eAwozfelY&WD_n}7f_)%P_q+MYL}+VB&; zcQyq!4eqvkd_)~`^G5g@H9CEh-owF;hHcXn$5h5yakJUEstGN}A%p7!ftc-z-dFod zI-^#u9PQu>Q$)lFkx@{S`FV}*#N#?z{YXt~&372<@d(I<%+{SqL6X?OcCe8{#g5+xoVKhjWw81No zt?NJ*(A>0rrA1e({DlHv)w^+e#Ax-S?Zp8P7OL08UV? z08S8{0ZzmLPSl9CfZP8Q0&fAT8R#_~w~K7&qtQQle>8HRumQ#^D)~TZ_<;X|@~;j4 z73IHIfyLGgZlC&n1%y*(+7=iO!lD@nAt)FC#`bSo|CY1@a!iM82WSQjXlC_yvu-cdPgNq1DW>)g4>q&@KmTvD+9T z=G%k&Bh;Cr*U{@*Rwp`dR@=t&5q%Y8Q=4&Qb`=5KPOE^+?g>1>*SXE*eO);|M&r$i zTimAd*VriIinmt!j_up)Sf8Vj!}+JN$Gzp>FBY{EUmWqnZ$gw?&p9(Ey^+&iZcSRf zU%6Vl+(L{5Di_9d?zV3GMq54{UOLMIUD`rc!uX*M!eW zN7oiBH&c98e>}(!G*=#t%-Cr=b+!AhrV~v1t)|BqoyU5dUlGo{ZoSY}2}Bk{f)TFp zoT6V`V1~)=cyg{|&2nKMel_OL$wHGe=FiSD_Bxppfzfv@>p3+Ct0x6JczH4d8kWz2 zbPDudB98OAGR6uvAz}z<&_l4u!I-gSE6|CKbA1N0D7K3$3Mf{GmzO8yi!+MC!v$n-I_me zJ|t9VTU5*(>!+}X7W{sx7^VC(!hXb~!om+MtIw|@AXNh*%n0ZKY05FBW5su2aLVJ4 z3h~ipFzq4^mudpj{IWeNSFB-ZvbVKGZGA5DH*&5!X9E0(xfw67yWCkGdh$J;y9vB+ zwZrf(j?3PUFE6p5`7`!?Z?s-#cSq}98U%FN@np*AUjR=Taxmc0*&^EA&9SD%s9tr- z54mr(`g%RKtYsU1D|F}+u}zMz ztM$<4&2f*uvi*WE*ym8kx8eNdNmOoq!TqSEoy6DiTQer@#%X3s4rd|k!SHmGzAmsX zf9)@|(H-8(G^vG(1blF0FZ|BEXj75{N3}jnZ`(Fsp`x2q6uSlmNyFQ5_luLIq_n6B zohx%a7JXnhPU?W--K5^xtAb`C}rAqIA8dtFhb~q4{g(v`!fISU&=k8J0FGKCf~0sZEh(E@#^geyrY` z6_ZW|vw8zg=tq~jygtv^EK{FRJ~J2j89IfOyxsGqYXp@5xe?ssMG6cL@vK{ayo)>z z(3~h-mnYxv#CdF+h%Q;Oh&4FdG6KC)x>#NO4Z}3M6%>2!bnTVkkwW-YZAUdQyUh{dRA|4>I@w>vgjGh?`>UT_J(6q zHdA2nC!EdZ*Z6b|*3hbs6pQx>D$`er3D|5x7k`(mYQ?^JyPFh#7m>%*n9s^jZmYK( zC9F309pzEp%t}VhN+-D0KA$e5@;ja$EN<6|jnO`t_L%u?`KpEj&gFcxk5d1mVbsNV z<~G!QwzaM7;Pnw_*R)fin7v@ql&8`1SdV+YVT56>6moP^u=>sDs%AYgYJ=hE2nBlQ zS{Xl3QC9C9lhCczg_Xjvgw^LOw0Q zoInm);_;qq@LRG3aCcgc6*oht*Sl12x2ex$ks^JKuyOhE7SsYrYgaox+X8oC%s;yQ z&Z4gsbNjp<6s(>)c-Aw=6namOZoFCv9=%$1Z$T2c#-5P+(-)cf58fC_>cfe+#cqFL zeaknRLv&_M-xai9dwp`a9~oM|7;3>yxEDr3-Sw3DI>N*RL7py6N(>b)$wx%K=b!o; z6e-li1W9zTfdE_C(OBj=0M07`uLl48aVPFEJvupTR+6MRP?W-=8U@PEBQlXb?8u*oM@$z2ud>i>LuJ17@+aDmQ1o! z1vn-I<7T0qWHE^(Jhz;BXm-S5l(+w^EbhZFej3qt^lYn(IW8Rq z``n-o*oRpYx7S8`-%F`Hz^5`pIpp+2)!FBK2JmF-vULvXvED}BO^^9jlC$|+!f|u2 zuLR6x`bH<6sp!)BN-OoFf{HJFwOQuO4>@!X31*WVDDgoXJLAzG8f-~p0*8m5%HzJ{ z7zNa45@O5@ihk``wCT-9AG;*(dvYAbUtO*gAAM^u*O5#qDn|T#HtNNiL0((7DFz~B z`Sjux7P>b}1(9*K>!|IHc^oHKJ2-)Dyq4*`lR+xPPp++E6}-4#W^s^Nq*$3W&yBsZ zk0^TfQZzG3C&xFrjc zJszXh)QyLCf&ui5PNlhsdEn z53`t2>DJ-d$yE*i6TCffL(qVNu4}J3UK*jB+V$*ei^1bG>!%uc_aXQtGOKWwl(27{2H#~uw{O-p_@K-W3U$}TvN1A3&-ohyf0b^DS5T4llbm-1}O_NcX*|oMu-^8%O3@Uq^tK2!f`~+#jpS_I7`) z-WM&lbARoVWHq?9WNL$l+&5^Bz0|OB>z-t$_g<}~;G0WMGWHAR(>O04x?03I&S>Eg?-4S|CS`wOi zSf?h$dx&#*F5^Z;=~|weuUfqwz!0m0L~9wkH^-b`+iOy5$^j#>0T>CT^9lL#_0G{^ zu1xiNqdJR)uaXb8%_%)Ov8}xI?LDp8cEzkHlK1m$KavP``uqkI-&))su07g3q?@w5 z*u%Eg0$WmUKvwE37?YZ(vU%WRGn!);kIu_0$vhW@)@kw<8j);6iEYJ}G1p^x-i&BQ%^90%OsEip*=5%XDYmE>PClrFdE0=mX}vXvmif zznqMsQQEo)Xwljl9DRQ!Y*)|}i?aiy}kGdmS0x>Ki&=rOuo$#ztUewjQ~31e3Y zZ70hg)*J)34u1$#tn0j8sd-dsfPlpfxpKDv^ffsk@3>O6NPNoE3axc|yV8Wgir1<2 z;Wx`Ch99SMA}cQLREG;4q6d)ZZgma4>rxP8$GOdW`yJM9Ldp)_srx6_&!%Pw{RTKV zD!a3b)p?_&6j#pb^Gp-egl;Yo?ziOGD?%IwmBXL2FLpTTVbT`a(yh@PC$IE2UiURx zujvRI&(A3Xe(bA+rqD<;gDKEDnP+NIrz5|dY5Cq0b%$T}`)qT#@G1z@2Z$}!433q2 zf8{Mhf7gsc7pKa*OHk3yWBYBh^L-Tjpi(SpAm&K4>}u(1c|?njm&ExjqBAoF5f=)P zGM?IYI~@qH6ok3rQ9AFqN1OZGGb%e~!u1JQEpB{^U`s5d$}12ly`xX|B-M}*GZ zgN+5s#6UnIER6kVvp?Z?DSs@DE>=zuoXIf)LCpy};p{W2`L|DW0}0v+47-FuQA zJfd4yp-^>Ce8OOZ!B7uT9b@D6-xf>`nkAl5>a`0zN|6FufO`glW`TskppcjHpzWuy z)@jtv(p7OtkXZ7ZmS?CLud8jKke!WTZbG0=(|S{T=sq%_^)>zp?6nv#7Xk$YH_?rx zEZ@hODVh*fS>buSALB|P3It}E#}(~}MI*Rj8V*F%Ot|+$1dMb8sRxP}1XTn~moZ~X z+EOhR#y3!unkfMAqf`WR14DwCOs+?(7FaKYXno=pwqQ6AF$fPU@HuwA*Fc$0{iqc( zRNBv0YYTL&$3^yFC~oEuE-PdWmJp^O?bIG9WQ~{MT+q8JvMd;6AguHN;*xCcY1&B& z}(nAD{Ul@!$6l&*c?q2HWKIRM96*m3MkMS1_3q3>E zC{_k^5+n|y7I%A`|I{NT0%l0=Os}m4F80<4PaGH{W6+5dz^9DG(h`FG6)9jK7LHs0 zLTjcJ3D`dm8_V$60#8hrv*lyJeE<6alfx%@7`k@2y}Jgi`=+kF0IC8`T_?96zs^Yl zXuu%rlHic=avy#5g64|S=$>}J6l*g+9gKALZqQe zZniG<0=Nk;^wLOi(NP#x6NkQ@zI>gd8g3?un8i&}p=(WPeI$Av`f^Y=^cB5FW{Fzs zNJKcgFl(gppg%|VZrAx>s`{m;oMWb(5VF3fy}+>$qDyxqb6l<3Si!Jdy^dmkeM~FZ z5AnSd`CZpninVjNp=O-5>nE1TM!P#RTvcow{I{N2yTRF^wbO~U;GxYQLxevJ@II5l z>fTX$``&mW>-w$q#iTCgyhXgd3}U8XB&Qu<;u!r}^=8Czx31n)G_$;X;%fIDpV%0eb$_k&dR5GH zuaFxFOyPs6-fY(mUd*|xp*grU<8yU?9dv&U;K`AfI+kup!cCkjsy3E09aCqftlg$r zPPl$Dq3b;fTigs^WDp?3(KFW4FiqDp|C(nvO9UmRen=_3`NgFOqB^JV%N?S z`809z+5L5hSy)*2z3>>R-8Z5A?aG)Riti4Bt!yo~fZd18Nr~{b6f5zuKz<;5&FwWF zWBV3>*ME|dgA&caOh?Ny!N5BBvsw6CueXbg`zs)|)Qpa_jLWCh`=`ok51Za513yKR zsE)VznGO?OfzQBt?yt*Z<}eR(K|L+TLDw`Kl%=wIBbh2b2V=cjv^m%>MO_xMO^k1?3uALVxxiTF4Z7P-uZ^5) zNiq#S*+6KNxq`CzsAcv?3TiFEUy?iE9I^M9hfQ9;Y!6`R$4Ws9GuC4kA&nEIF1Nn8 z04Zg~+5e7V6=iYiCkr>d1p-YAHHSdMB1?DZ)DqJK?Dea8#~f~oG(nVp>lA`%?$G_q08=jXG#pJkf$3^K^D)xI zX$aMaELVCze18>+Pk0@q@YCaX4uBeiuUoK)I2{;VEW)%i)!dcJ4WQ?PAA*%Q-5_MK zK+3mon2*dUfj8~Pr4unwa;y!l<-iZC zFtf+m2txN-E>l!N)4N_35jp1)6odnjB-A1*_jsz_YQ`s|@;Y6d_k4fbh@Nm0ZRCps z$=Rm{_uFesblDQJhs8`6vxjxzl=rb$4p;W<*|8@4g!!o%IXB>dni=ohx5@gGkPC3I z*e}~(p$=$Fpt?;cy3w%(avtTdtp`*LpV8G7HhM#f6gPy?E7$6FkQ8($?}R*rJaB<= zD62_kx6jSS#84731N&XiRF5fjX{RVEI6q1#6ef~s&n)(sNozg!b?n(erp#CpW z3>xf(4D3N1hSeopTY7);7#+J>#u-?&>@cbpm2IAIZoD=g3v*9hZ5^ zN<5lC?G?)@RD5|j?nLe)%tDuYZ+O~o^`vP}^pm0l6I9{y5tS;!8L9_`!}AVMywVt& zGuGR98iL7z3=|8JsYIP?WIdjUW8i4KWdUA@U|+;fr2sM7);#pYx)YS>r8uF20Ev8X zjaMv}dJ~+axrWr^!XSja(5oL6Y|Gv}bn^|2a7b31M04>PeK*%|CX;62Y)DoT*RoMg z_#$#zHkdP;13#>8jBi>P`$lP%8p7{ze_ZNh&!0gIXK0{_&iXf~8rQ4d-RVF)i7P3TZ% zBmqvW=@` zJN4p$bc|47LwLwwEfgzG>VB?7^c|uF!3yG1If|>1X6(ib;}JHg6QPQ2QFD(;oIDa; zwV$I^QEY}H5CHOM4EU|RKu!WqK%H$*KL{x9AwmKO{ncuhJXZ);H54|rr z-GVqF8B6tJ+L=mIl=mh^`Z4w^(bcX)PhS^;JNV1^5FZ=Gopc6z%jhfFeQ1BLc@L>2 zB|1ZE=Km0lCVo7PUv~fO{bCwg>5w1Kk8r=24mabMnP9b+-Td$7ggLPEma;kjx_ctlTiEyr^4igEsJ06j`vmCk&t^nDpy4Z4&6y9 zsZ#}RG|^JAmf|xyG%bwMXU(YG$ft5ie`?c95ixZ11FVwo-&B~sn(d}d@%32eMt)qs zsMQulMwCBCdQ#>_E{{o+7;a4-Cd7|R+uo8;il8Trk0^X53qny*9LYvg{ZvCKfo?(< zNPRqI0$-NegNUuFl4_J2DW){wffZWMbq`h_Kn=JOxRhyRMf1s{Q`OZ5QJXU4Q*mN` zRBBzPstkcj;9pe6C6fx2nJAT&B?kT-wE+6xQHc;j$q46a$W!4}{*K^F4IqMVuoxc^ zOteKNJQDq0irOmiL{9WnR~*QkYm20YFxgO5sinBD`u^kdv>Q6vAHBQaAIR%%k=IUe zD$^2WoVX$R;jf{PR)Ncc=xqG)+^Q9mqAA6ZXlcI$(d$#D_v|ttf*eLjeyIZ_1J;7a zB~9D`k`um*|A%A}kbjrVnL1@^tvise&#v$}I`)%1Iv2lL-b19x3^$8tjG+-1y*OcJ zItE3JTI0Rq~nYgaRlV&{`4{_Kjw8NbIRdNW zOAouK!H-FSoTHxRB%yto1_CC+B@~cWqNe%9{E~KrH#+W&WJxraNHG^p(U;5Wl~|@i zI`c!t(Inu`q>c)fl_7;+bIISl)0iI=$%gye>!ido`PZz}*II~RTquqwUTP=~VZoEy z3;1n_t#8$6_@epo9OE`gwx=TkQq&vv2@W2Qp*a`jp!Fe&r_sTEH<9orgBlW?arUD( zsR&}A)y9erFkgj8<|_qHl+tX#S5hR6X@0hU+0Zfo{cd6T2q)NkB&w!`N|x!45~4V~ zt`}!dwEZP?!+r=41tAUuy7*$tp_d5m;n5!)C5F$VN0JQBmYJSm`|`#I#HRM~59ha$ z^}O*t1ZYSUIggoPz(j+L>h~ImQQR;M_0JzFIEo)mLty|;KdAtm$|>-J#@Ta!quY>x zHbaw_+R!q*#r%XP!I_fBvBne@3|at?#N|Fi8%xY1+Or3WA;Eb!cS4k8-!jtD9^5x| z58ZiU<%{M;x^1tb;Xp}KA=n!btbDvH57C(-37({_(hg#TlwvQcmGaGAbR{LuUNk)= z-o76mN{TZRf@s_Mdt}r%lo-?jCLXN*V}_5U=tb;Wi~#Z_0)l6qE=BVhW0ta+6A(3s zwwIK}qf~mg$#<_&5Ck^WAx&ZPmvEuO@~_iGG#}wm5I$vI8&iEOY$ zpihDo&-F?B!U-}lw%?1)PLvKCHU;!4Q%*&lMX@)ccPX}b?G+pl)}+ZVc(-xlZ6K;R zdryRHdyLPuV^ff{arURBc9I#eVJTn7q&Z)_bBHkIZvLVrDHYNDhW3Y+J8if0B8isT zH-JeZQ3>5#r_8ZT@R>P82|PWeJc`;{umO%b2UvnLtHjgtdoZeatvX7f@&BDOk9)5< zPMrvv48MLru#x<_g%WgF#^1a^sS~k=hh%s$c${dZc13BkXO!&gvs~A|xF&eXy^R|Y zgkY6C!*b!VCq#Y*uPM=(h&tEqbAY_&)3CLun|$#Ykjw{H%wON9NGq15wP=h=-~E<0 z-BBg!iOH>|;XYCd{j1hd< zR_e#Tg6t7z z_Uq1UG@B%O^M43}=luKvd<8kn6!`#Pd0Sh$vgS2PxiD^B4;KIs3H(LG8<9MD1!??6 z7|=G1r=Rc#j^AZI<+!>IC&S$13~{)Ce-81RK3Hoygb<^lSqPZ~P}BqH4q|G(>xAZq zwi@8#Z~>w>eQq&iKcK>&gu<|W_Jj0a`W@Q<|5qXVF&g=n4iWMOJczJ!`hWENX#KPB zK?Ex-!MyiN3E!Y_^aJPv_6{OlLOI}P5UEH%j~CuDDbn6dfPANgY_30i>|1CbS;!1g z?jYu(tq|k&_b}qw12X#;E2IGreNcZTCis$9eg#R9KJb|6ei*|2t_t>7Vpu{|?fUFF z^#b(xS2rcp(NInpSCHeHUI(-v!j}A%KP)lg=L2jj{i=Rvgb_#P2_F1+FWvzUL9zdI zGW{R|SoD(2<(#?%7#0wD>49=YkNud!b@})bs1_m{e2fN{)zpWbD@a^>z-a6s78wET zD8RfTAsp1l%e!mXa_k^>v+W?7jsx~p7in4YeY9v?tq(#tT5jvJ*yrqXkK=@+ivR6tUDEmuyxW|J zIhd3j0?!J+K9XsQ;Q6w_{mV?8R`R9E@b7B;&o<`uh%mswXjm&su$J)KjDVN+<_ZS# zgBJ&=0{4~Z)pRpjVu@*XJ^WVMs2LRC?}~GCq1&SDQ*7#2yxSRJ)K{+vEr(ObuBxN8 zuUHSmpNjUm^GdN##@=1BFk8MAze>yk13Pz|TH9x@0SarC28B*p&JIvGAmQUrh(Ym}H41mU#b0Ty#WVjAPXzQ&VTZ!0abz8C^ zcvc?Dy7}+H_~eSYPNsV;LT9Z*X2+EkF#ndb9I?KHAG37f?NO11CFc~Db)a}EJH+D4 z*llrMHM<6ZvBm0ULA1@f$`ThT=OmeTgIk=IJIwWA1>SFVToD}MpFmlFr;TEz)xbC> zULDidxBD|7Ui1Mbb&6ZzX#r?6g1r_^W<=;&2l2<59C9o|CUTq;_URce=^j!k-2h>5 z#jOlHSI(!=kR~)BnFqdH6W>mzAWt^z~!#u=Rz7Zi{&WyCJbfPyl+e@ zot&ARW|W(Cd!XlWt1Ak`m11sPTS+(^wTxhL1F8G8mR^4o&R$Fs@GQXK$q~=}WDvWn z@$W_B4J`DAn2})nu3EPrY;5B`mOntDDbzS>1INyhRTfJt?M{VIX&_<-v8lefJq3xK zpT=S#bA}Jo&eVY4Juw$OkS=WTvU0aAAg8+g| z)B*~(o&S{jf7-eVsHnCsJal)bbaxLR-5rwB-Hnpck|Pb$A>AO|AxKI}w?j!t3DW=Q zd%pK%{WELMU31s$@9e$rJ?HM2bN1*C);RaVyNprDBrUTX3%m6Jb*rGD-5||PLfsO5 zSbn27n~|QXa)g2YXP5MlE`fWgZrG~%98BfbUTsJcz#up1Eh$uID<=9cwcIb~zoBv4 zLpCjK$>h@lGNRg=b^xC^sRM0c+|^XT6m!EDQ}ALQ%=D8CZ($E$rC45DhPYu5yg!M# z^JdtO&&_A3$^DA^oHsxGLv3l{I#Ykh6uPVu-oFZtpbjM zF#mhMX6da0v{7+je29S%9KugkY|)%k^RJf*+r+wYVL;~i2RW)?B#?}nhDNOtb#6%o zA}H4-&y_eRf`p|CD}n)&i#Q09gn=|?6No-VSOV&#@54@S+ma>_LwTW(q=(3{5kR@j z#6kQoVY({3s_6vLLF=~@`aHqCarhTkXB+su!F(JDATe~eN(XjFfK-zVN6t8cDQAW( zu&+*>8gbKvu&$7FmU`n5nR~U0p|o7DC{rzIF4Rq`Q-0^?zM+<{l4t8-@!xw)B$%=F9A2~TJ6hvjvWaI6o;2M!R4 z-XoGGC63r?CMW*Q6CbxveISs5-Yw3a{+%oeU^08M52G(4iyPEmli`AjPiLqr5^|U5 z0ym734)Pk+R9f>hVebc@)F3^5k4^C>7x>AE5Y22!{i7K-Y=k_58uz?9!W6V&dD2m9 z?nbv>9~uUFM@D+l$`?1pEnt_(R;dK#OytN=ZK|LVJNZ&g^LeS{HbD>66S1OrA@_xh zr`-N?eoomh^}Jq_ZgtA%>-Yfen3(r zEm_UaXqi1<@xJ;Idl_VKkDy_iml=jSt;kqT7@UI72FrSAt_8%@{OfHfBd)}Y`7?!? zkQCpjG0hPna}xIupH5rs*^plcPRduq9kkt$h?^F{KY{|n{d#}4#05Wt0fII|v+InZ zOdJ{b;-=@3LO9j~3ao}~4S{c1fQ2RfP;UConBe-@1}rfZX&Ej(g8WFaPYgIiwi`rl z;Bz{6l0nA7(ggK5O)~IIY`)8fj=Xs>m3Ml=s0+~OXTKQmUVjfxqCyh>n*q8lcpx6p zkSkA?FSN9(5>0WK^4<;8Af1ca3&KG9PqQaO4c(Q z@T!^vMeznplv(Ou3Ryt?$xku_;g>>+QeYtb$j@bja=#D;rA^FBx#>f$6R<;`8Ql%p zt;_z|4F9HlGU0!f?fzmJ$@U^sczL=sLRe zqvcQZs8ok;ckQX8lam1Mlsuu^C+uX9xz-^{DPo32LpUwxyP=K#l+jT(Q;e3LY8FF) zST{cE2(P`1>YhrgAW^}CfHjizD~%N?^(C0n38_y7Dtm=V@>$&^CPvDY0{085F`ssguaAvYhCw#c>L0nctl+b z!_7RF=mI%mSbV)^OqM!!77f9nkV6)(?Y5pVCQU{wtjWDnvQ~#E!tbuXz7}S%9zI<2{Q10&>D9LO5H9QEvIo z6c@|sX+UDu?E~P?1y}DxWsJLE0Dv7N008Ts1y`0H9=1-_zt&kbXs@^}3gC9G6`M3~ zSRpmKDt%$xqp4X{rgMNSQ+H%b95QUMeJQZ^M=3v!%DH>xKp{9vS6@x$&=ea^oad+tt z$lAc;=9neVG-t0baruSy9O(Kyv5>_cy_XM*o|tf(B5Xm!vYr3lxP!9!yUL4ic(q?f z_YeElW85w^bgJ=p3U*MFQfLW72tK5##zl#^+=jfHnYI({rSTm6MDuhIRWo|&@d>*x%-Qw5?n!*4)E*D)K5{w$L zpLUfK%uA5MS*HEWzVw?^SmITA58cPPXy!Vu`(tT^eXWpnzU;9gvRcwb>K-1IUNB7p z?2v~mG7@`;o}VvrWL3i6=TlrRP{P4i3wVD1jnh8)j2!EZX0!wvkuZyBuwA-m$DUxX zX44IMgD5Z^&C9-)qJ1I>Roh8OB~vCZ$GP;_3#yrYop5NsH{^>e3ZV+&2&6g=X8mat zaYLx5yA^Xjf5)1NWKPniU30*9Eh<&9hf>Q*eUIu$yS*=3y#hxpr8O8yzAdHE%g>RP zU1Zf*%f^LP&LOLQMH=L~tWclG^{vPdeE4C16oK$zojp>OoEI7K9a`3=_IDgi zjj;LZZCXYqI=bHSRvlPpN(OkGHdrVf$BgciGIXLH#+}clEb40g&QUjgSxS@6rC!(oFL|10FQdW7o%l3YhTi>;)%i zezAz*lt|zrBx_3&Z^UpRrR8jZEQT7pzU0>9BWydTquiq`T@4@}V_TQcbFAZH=YrSX z%srT~c=9cL#e9GPQ3w%)pMHRxbhuPtR0emAE)fCxKxJ0SRVTlL3E3-oh$tqPlx$v? zbdm(i>?^%fsA$%TO1>*qiIFW{)om36`4s4+i9z6vLNE95dnl4w?;~+pEY`8%$=Jr_ zeAZor9d&5+_K&;HW3Y107YVOAIbVWl$D5S)iHDcXMa};0750!2Yc< zD`zKO^)cmcR*oCDR zhO@vAEcE2MW8}3tBj^fKHW+6u4Qp$;kzIePGY${sSX{^0El zECOr{s?DW=K__Ef?N6W5`G8hM_pb)vsT2yI#c&ctiM>;G0Wal(&D-`DdI%Mz&wcoq zY$&aG(kp2)ZPYQ%%R){RGEC%C%UO1#-o&JrK+RY5d}5kt+IRtXI9lqg6FgH~YZw#; z+zWZ`G*E=9nZuP|(PUxQfrEN>*B*Ce6eQ>cC444n1=c!-E?)n>c!22X(}7l9RcgR5 z+}yWc6s6;X#GPaA?a4{g=T^6YWu{Yh|K8rl(sUoj z{>fSKM|R@}bppPN=>%IZekOuecATl4ehZAgxYc-5PxD?hu?fJOB?##O7V~KnXPZDi zovoJmejzAgYA}o5riR5P%SnC$-Da>0dC~0No%5lgxyyL8D@Vjh2{rO|{FDw`KC)t% zgu`1XZpHRAMYPdq{h5gixF_XIBt`RMMa`x&95nk_?YAxe$WNZPshxED#XibaGG+cJ zB!AwLN#az^1MmPq8xsJ4_xqOgu(5QsWdGH2{;GYZuk5_UN7#9(gV_q6xnA;&YLz~i zKb$Q>!)}s{t(SW4z@SdCN7IRR=-M4D1Je9+ieQz?dWFm;#iMAnGDYfQ+v5XGSG zw%xD1NQ>~1>PpzJ%UI+1^L{uv)J=q|(%psrmWDSP#aa>i#mB=2-;n$MS>ko*Yjm0! z*~13LP5ZfO!)D}aL;`O_x#sce?#3--ECa^mX4Y`ZE-4uBNDG!FIqqpZND(~*Az_xf z(5_{f#T)Ym|2Qvvadu@AaS0I>x6tzt8sL6U9{;3s-HwH{0cokBFDp@wIUPesz&H6y z^T%j$yCUIyc!A^49hnb@Sa>fqtQ#f$qBijMy#lAKJas&-6idF+#P>eYEH-gW1>Mhk z^In8!TG!vdk7U@|mZkH+9UE289coRr(QiyN7D_W;;eLNrJ|!<72i?yDm*6o1a$Av0 zOfE`MeZRWrLA+0wuK&6yTXmX=f1gAPNBODbG((ND^}8jZ<6Gn-Jkgj<-LGFk!ktGj zBhJODr>#OkBIoj>5pPEB`H(P^Id-;_q#L+|ijeQUdANEW6Ru z>#fgUK30?88NhZS-MM1lZ{M)rzq)4pv48c_Ioq!RFqqiA7I1ygWZY#Ch$;VJzdGmc zWOHHPS>)j?oL;&a4L)3c8Q>ccBD4!QP*Y_xuBRogqs@aM*rf63d*Z$;3C9-vJ+!>% zNQp8?IuLz|%q3^Y?0G~pr*{cs!+u?)+{cvH&09XiX3-i`c@^`3rX)i7CFR=_=$3gJ zr1>D1F0~3Lrcj?mv29|u<$`@GycgSzzV`0_YJuvd9Zee109~?|G)Kz4mg}ZZM zTx%&2yckAzGv*WDWaAIBNh-YGo%Nr{D{$#kuCJD~XqC`tMxsh5aO#c>G^*8}pq3wW zWLTDveo;I+OZqVkwTP1n_nh>DH{}nu3_GTkga%h}3&;4 zyKwzJrfbT4#@Jc)BD>?Mcf3{_ zLd^g0qKwkYC*$kOc~R}IGmAFGC5PlYmB#qFHIch+!IjtbrlxhpH%k&?t>vDK?#JPi zGkhB#)?~PX#(fvPRfWFUT9*{FRw$Hzv+$XVItn;zTbcA6f9|nFVk+^XKPh*~z&X$$ zePC~7;oYQ-FgTh$KZv=@ZZRoFH<1ia^$vVu==pU*_jejeTq%gaCW*W64=YHwkhnVF?8lsP=LEme;s zRF#WW`0~~V{NvDg4+~V#tCV#OPE$>hV^n@mY)k(h8yj50p$vB=oH_0mqvMuIj@3FL z{!uTr=2hW~#&;zgrKuB__4$?U42!+N&Lt~Rv;6oTn;{{M4XY)5EGB)qjvz3Z?5pq4 zEl+eRu@Y+68XbD3ChZR3K3>K<-FUj^7sFIK=%?6UWLDu!!N)`^53WQA7$i5couCz! z@qHbpEpO1W{j5KupM0qmy*q@tVZ$TQ$RXox#?;`+W6sp(L4~>GnFYf^gVP98==oJ$ zQ1I;tjGpIxw?$CtIF}at@iuhIt8dOF?i&(v&;*~5RhYY0r92j5BDPe0dvZ@FU4~`K z>kJRc7TJpHGMA3d9r?@224#8a%@Lm)sSv`J3DmO=hbmgOp%^$^rnOJMeU_vRsC&x@ z1CY2o7L~jy9RQ}%Z;m@Cg0++m);Udd`Aj#sLL2)>S09|_6h3?9BBIPYBr1iCE=uSz zaJ;pSxWFPRF`wpFU8nfEFoeBEbKi^&!9 zxhSin>gH&UT7i#JlNJe_^idc2HVlJ7t_av0nLC4m`CX@AHOB6nQ6WrYt-uO8g6Hq2 z$T83hLHc@?Gplydm*1%D)CP9*-(RIrVc7#O*BmOcLaCS|8eUV*pkDd6%W0Yyy}~k5 z>G&`r#~XKL`Bo4L?TG>s|0JS{+~$s%aGYdh_JPh_OC;ZZX#Awjb8S zsROelC8-MXO)sWP54J3J&j(+2px|6F7j(7BEbmUX$?$ztUTl@`Rhgag!Y62vxn7)u63`ME+4lYmG7aS@Cd3jNz1#tAp0K5 zs64qm+ZH>+4BHPAN64(gw9&Md)$?rjf}>Q0Az&IB!u?{vi9Z9Y9SUvjQi`@)8G&>+ z=t5W@j8oPh9POZ!;!D@SLoCa_ZA;s=5TH#)sD^dTRs}C$dRWPty+RvS-He=J;THON z;gMV-!&{#_Ao9>dky7GRL@gY}&Xq%~m@HavDN;s4fx2ww*`9SrX4VrKt@l$` zU6gI~nxrz+XQmDXgycx8wXW&yA!M!gfrIQ!^=;VZ0^#4^*78L4Y!l7Pjbq+xD{Cp0 zwrPvr&D}x&c|jZ6l$19h0-HeuC;UV3pCYTdIlH+3x~f@mwN4=%xY92>drvoqICEib za3kaZjP{=u49UC#X&tDSq(Ei&zRH5U<{w{Hc^rKfA{E^Vbuy`#V4|O-qd|ODE|m8= zv%QO#hk8)3N>IcW4%iYx`;kh((NGPIVXM}_^m!8 zlA$cov2KsGXndv>HtZyGS3gA}aH?VfOVo}tqTp#*{tyX~}D_8p?9?;vP_G!*deh9B}`tsBj4Uf#AG5N*4;lohm)tHnWY;fD)ZC28`E8J+7uw{SSuEbmoaFFOyq&(sm~YF zHU!B~H=F_J2y4FP403WTA1NN1k1Lsv@E1lXV9GxyXkB-{p!N9C`je3u( z=I1<4YOQV?S^2cleYZ1-=sc+i#i&tAZgOWvGcM+F{D_iZpd!Bj@at@nG8{jx{NZa= zzBbjswNK}1fxf(?NbHOOX(0?l%+=PgpWxouL?OG%BRS8pFa!O6T#OX}@m{W|EQ!Lc z(aG{Hzd4NT&^9Q;xkcC6nB1G4deJZJY?6z$ifAXKq;*#uDW31&J9~?#WuGi9oGy6f@38m7 z)74so`m=9T1Uw8LB%=>+8qD@xF3sBLYIi{WZ^(m4P&?#h&FIHZ#mbVq)}N(Tb;^6q zo%bTycT8*?bsQ@hNr5_!CDssn40Q|#z+t}9?C?%mc!G-^IGsI_m^?3_QM)a^D(lzh z_NQRdj5FPxeG9mMK(BuiX%-=nSgOqns=dZP@ykU~;o6n5y|#kpF3qXMdlm(;cEem> z(tTOpJ{-+Z&0p|a1i5}woiN9Lx;7?w+F;0kgV+gAXWX$}=?cHC&a=NOmhSg-ji~LU zLCg9Uu?k(O>NUtP3z*pLKYBBdXY8fpw?^hKxKsA##X~2GvJ5H-$p$(Cl$w#jrI+D} zN5kocsGuPWcVgDPlg}52mg<^Zb8C|-BK(kc>oAd~_CAb`wRkdofNrYVT$1sR`C zrnU}jb}p9IhuZcoI9j+OZNB`Bj$@b%thflhZCu?2tP;%7e71{4NqhyY)vmAQ6P0n& z=}ccvr>JDbUjx2}3;R9P;y&}pbXA)S7SxaylSRNp!6h7^vOD1w#x;gHSzakXQVWeY z{&cXiaMzi6w&Fex^xbjF|Cn#wH*t#Xi^PEnW9cP+Uf((5)KKLjP_A%*$L9gqZEES= z0N6v@pbqE1pE1*Kb!d@aLt%*t<{5m=llmN)07!}#7-F~4>bi%>fs3K~Wci_*dJHlP z0_fnp2dP^~;+p`+P%m|ppggQ6j3!vZI!+bNB;30{L0@Zpb?IxlUa&Au%SZMl zd$WsK6GO{n!PG+sL{^spo|}~fJYV=P@j0hR7s$L%(1j&xK^G`jgvJFd_&Lm}>1B>5 z0+k~6g~Z`FI<3QGgYV<3aU?%6@mg1DA|?rb{;|ATa}crB{T%@&8P87143nl%rl0M` zZg{4}A|-}hJxejg_m1}BZ6_lvC?+fvnAAAlsk-KiX6L+f8$y(X0DYI4G5X5@XKO|2l?yt>V+z(2{kWYC&Y@GW$ zs(HS@KehhxsuLln<8~-CShOeSLpebdM^j*eI`!%+S`0?U5HNA;NlH)HxX4Ucf`?A! zE?U*NikA(2XhU);Q=Y$mubqh9{lc>Os>71f>^!`TMQBLwLHt1#b3`up05Bz=u04k)PW09jr&4JMVtwo942W|@@H#E!FjJrfn zk5?1s6s!6%MvjTTeO+i8jO9z+twI(wACzOJ&m^? z)m@NL#yqq2b(k@Br#K_HQ^IetYSHtEHiS^R4 z!@j*i$<{-_Y23mJoTCk_NJDy#o*hYT1iPEQ^(I2OmOIC^6A;&a<^8qV10`FzIDazQ z%V>`u+F%tEbiYCbGFlBjQH4H0m%0&aZ1~XuKp~jYe4-J^vCuue;5BY8gK>;ahk)TS ztQXkuih|SE(&4af-WhKJov+Fydv|>^Q>$PS$E5Iq+{u1|qKPCV@(A7>IhxTe21}Q^ zk0@=IoG`lOw2b23)Kd4&v7rBO+zb!y0Uf262N;zDi&J6#gJX(#j6kcFu1tLv;YI~ZL*;e$a-FNmNxYxI$zcFEo%h%ge z`EZl=htCRm&+~6*)k$DEP4|(nv*F~gz>^L8vRo>! z?zoy=5?b|cvfrtS6`nsG1@aH14nPI3nP{6yC5>qcA=hVxbO0Vs1a(j^D39$T&NarJvu1Npr+{W&2^1meiFUbGZ zLT86ySs^Q;IR8naJSKmfc=?wc?(^T|kJ2!Y5gsQZ{zc%Q{Ttzrw8Ufj$5G>d>4)e4rhgnm zehl&0|Na-EZ|(nj **版本**: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 组) --- diff --git a/doc/桃育种系统规划对照总表.md b/doc/桃育种系统规划对照总表.md index 428a94b..114dece 100644 --- a/doc/桃育种系统规划对照总表.md +++ b/doc/桃育种系统规划对照总表.md @@ -2,7 +2,7 @@ > **版本**:2026-07-29 新增 > **定位**:统一三份文档(业务功能规划报告 / 分层路线图 v1.1 / 模块扩展需求规格 v2.11)的模块级映射,消除口头"指回§3/§4"。 -> **状态约定**:✅ 已落地(基础字段) | ⏳ 待建(规划已定,待生成器落地) | ⏳ 待灌(数据) | — 引擎/端点(不建表)。**2026-08-04 追加**:① 统计引擎两轮 P0 已代码落地(G×E + 性状方向标注);② 花粉档案 §3.19 落地(bre_pollen + 授粉窗口校验);③ 选择指数升级为**无性系级聚合**(决策正确性,可靠加权聚合 + 自株退化 + 系级决选);④ 配合力 GCA/SCA 支持**交配设计 `design_type`**(统计严谨:完全双列/部分双列/line×tester(NCII)/NCIII,`combining.solve` 按设计分支双因素模型 + 运行按设计过滤组合);⑤ **统计严谨·MLOps 复现性**(① Smith-Hazel 遗传相关改 **Calo 可靠性校正** r_g=r_EBV/√(rel_i·rel_j);② **PA 落库** pa=√reliability + 批次 accuracy 真 PA;③ **MLOps 溯源** data_version/input_hash/engine_version(同数据重跑哈希一致、改观测必变);④ **germplasm_id 填充**(亲本级 EBV 查询可用);⑤ **砧木字典扩列** 矮化类/砧穗亲和性;⑥ **空间竞争协变量** covariate=competition(Chebyshev 邻域株数,无坐标显式报错));⑥ **组合得失漏斗 v_combination_funnel**(按组合汇总 花→果→种→苗→定植→树→入选 六级派生指标——结实率/出苗率/选择强度滚到组合层,配合力第一手证据,只读端点可查);⑦ **分子育种「建数据能力」**(§3.7 四表落地:bre_marker / bre_genotyping_dataset / bre_genotype_sample(weld 补 sample_name)/ bre_genotype_call,全量 CRUD + Excel 导入导出 + **VCF 导入**(marker/sample 自动建号、GT 编码、幂等)+ **DNA 指纹防错**(无性系混杂检测,同源不一致/异源一致两类标记));⑧ **精修项四连发**(① **DUS/品种保护「数据能力(三表)」** bre_dus_descriptor/test/observation + UPOV TG/53 桃描述符种子 15 条 + 菜单 900230 真实页与 G 段按钮 900800-900808;② **stage 感知** bre_trait.stage(童期/成株)驱动 BLUP/选择指数/决策预览的过滤与警示 + 批次 stage 落库;③ **k-fold 交叉验证外部验证** ABLUP+GXEBLUP 双支持(sire 家系分层留出、逐折容错,bre_cv_result/fold 落库,MLOps 溯源复用);④ **按 site EBV 公平性报告** site 系统性偏差 deviation/flagged + 排名一致性 Spearman + G×E 模式);⑨ **六项能力完善**(① **MT-BLUP 遗传相关**——成对双性状 REML(`mtblup.solve_bivariate`:y 堆叠 Var(u)=G0⊗A,Va/Ve 取单性状 REML、仅对 ρ 黄金分割求极大精确 REML),`run_genetic_corr` 逐对 bivariate 组装 G0 + Higham 半正定投影,落 `bre_genetic_corr_result`(job_type=GENCORR),Smith-Hazel 增 `g_method="mtblup"`(单对不收敛回退 Calo+warning、G 非正定特征值截断、`g_source` 落库可审计);② **GBLUP/ssGBLUP 引擎**——`genomic.py`:VanRaden G method1/2 + MAF 过滤 + blend 岭;`solve_gblup` 仅基因型株、`solve_ssgblup` 单步法 H⁻¹=A⁻¹+[[0,0],[0,G⁻¹−A22⁻¹]] 且 **V 块用 H 协方差**;`run_gblup` 样本 tree 直连/名称兜底、未映射+多等位跳过+warning,落 PredictionModel(GBLUP/ssGBLUP)+EBV 行;③ **DUS 特异性统计检验**——QN LSD(t_crit 由 `fdist.f_icdf` 二分反解 F(1,df))+ PQ/QL 状态众数、**必测描述符**驱动建议、参照=同 trial_study 自动 + 显式覆盖、落 `analysis_json` + conclusion 自动建议;④ **AMMI/Finlay-Wilkinson 稳定性**——FW 环境指数回归 b/R²/se_b/flag_stable(完美拟合 b≈1 判稳)、AMMI 残差 SVD→IPC/ASV/ecovalence、`run_stability` site/year 两维、格子 <2×2→409,落 `bre_stability_result`;⑤ **MLOps 重训+版本回滚**——`bre_prediction.is_active` 版本链(无 active 时首批 active)、`model/drift` 输入哈希漂移检测(轻量 `_gather_digest_inputs`)、`model/retrain` 漂移才重训+active 转移、`model/activate` 回滚原语;⑥ **BLUP stage 拆分**——`bre_trait_observation.stage` 观测级发育阶段、`run_ablup(stage)` 按 coalesce(obs.stage,trait.stage) 过滤+model_name `_{stage}` 后缀+批次 stage 落库、未指定但有分歧→note 警示);⑩ **田间试验精度补强·砧木×接穗随机互作(G×R)**——rootstock 由固定哑变量升级为**第三随机效应槽**(`run_ablup(gxr=True)` → method=GXRBLUP/job_type=GXR,按 `(基因型,砧木)` 分组建 Z₂ 随机互作、克隆坍缩系谱与 G×E 共享、rootstock 强制移出固定效应防共线、不可辨识门禁(同基因型无跨砧木/单元内无重复/残差 df<1)→409、σ²gxr/gxr_ratio/n_cross_rootstock 落批次 note;**Z₂ 单槽位 → G×E/G×R 互斥**(双开 409 + 前端互斥 watcher);求解器 `_solve_gxe` 加 factor_label/factor_name(G×E 警告文案逐字节不变、G×R 显「砧木」)、ENGINE_VERSION 1.2.0;G×E tab 加 G×R 复选框、批次表同显 σ²gxr);⑪ **基因组选择(GS)实证严谨性·GBLUP/ssGBLUP 专用 k-fold 交叉验证**——`run_cv` 加 `dataset_id/method/maf_min`(method=KFCV/GBLUP·KFCV/ssGBLUP),`genomic.kfold_cv_genomic` 掩蔽留出 GEBV vs 表型实证 pearson/RMSE(G/H/Hinv 只建一次、固定种子随机分层——GS 同世代样本无家系树,区别于 ABLUP 的 sire 家系留出)、`_gather_gblup_inputs` 提取(run_gblup↔GS CV 复用)、`_build_h` 缺失基因型补 base、ENGINE_VERSION 1.1.0、_DATA_VERSION v2.11;SSR 多等位 dummy 编码留待后续);⑫ **现代桃育种领域覆盖·S-等位基因交配兼容性 + 抗病/需冷量性状字典**(① `bre_germplasm.s_alleles`(String32,Sf=自交亲和型)+ `cross_combination` create/update 兼容校验——任亲本含 Sf→放行、共享 2 个 S 等位→409「配了不结」硬门禁、共享 1 个→`out.s_compat` 半兼容警示;②③ `bre_trait` 种子 5 条 numeric(细菌性穿孔病/褐腐病/白粉病 0-5 级病情指数 + 需冷量 h + 需热量 GDD,category「抗病性」「生态适应性」,into_ebv=1、direction=asc 进选种目标——统计引擎仅消费 numeric);④ **MAS/QTL/GWAS 闭环**(GWAS 引擎 GLM+PC:SVD 群体结构校正 + 逐标记单标记回归 + Bonferroni/BH-FDR + QTL 区间合并,纯 numpy 复用 fdist 零新依赖;bre_gwas_result/bre_gwas_snp/bre_qtl/bre_mas_panel/bre_mas_panel_marker 五新表;服务端 gwas/run|list|{id} + qtl CRUD + mas-panel CRUD(选标记 favorable_dose/direction/effect);decision_preview 加 marker 条件——童期幼苗无表型/无 EBV 也可被标记辅助选入;前端 900214 GWAS/QTL 页翻转(Manhattan/QQ 裸 echarts + 运行对话框 + 三 tab);e2e 7 段全过 + GBLUP/ssGBLUP 回归零回归,2026-08-04);⑭ **九缺口补齐(2026-08-04,用户重扫 12 项缺口剔除 A1/A4 余 9 项拍板全做,三批独立 e2e + 回归)**——批1 经典侧计算端点(纯计算不建表):**数据质量** `POST /statistics/data-quality`(缺失率/CV + IQR + MAD 稳健 z-score 双法异常标记)+ **遗传增益** `POST /statistics/genetic-gain`(ΔG=k·r_g·σ_A,k 经 Acklam 有理近似 Φ⁻¹ 纯 numpy 零 scipy)+ **主动选配** `POST /statistics/mating-recommend`(S-等位硬过滤 + 亲缘 A 惩罚 score=w_ebv·mid_parent_EBV−w_kin·max(0,r−threshold),不落库);批2 分子侧严谨性:**EMMAX 混合模型 GWAS**(`gwas._emmax`:零模型方差分量复用 `genomic._profile_solve` + eigh 对角化 K→V⁻¹ + 逐标记 GLS t²→fdist,method=emmax,run_gwas 后处理抽共享函数 GLM 逐字节不变)+ **SSR 多等位虚拟编码**(VCF 按 ALT 数定 ssr/snp、`_allelic_from_gt` 每等位一列、`build_g_matrix` 多等位 VanRaden 推广)+ **QTL×E 环境互作**(`POST /statistics/gwas-qtl-x-e` 分环境 GWAS 合并 stable/env-specific/异号 G×QTL 警示,不建表)+ **MAS 面板语义**(bre_mas_panel_marker.mode/favorable_allele/haplotype_group 三列 + `_mas_hit_map` additive/dominance/recessive/allele/haplotype 五语义);批3 求解器核心:**AR1×AR1 空间协方差**(`blup.solve_spatial`:e~N(0,σ²e·R)、R=AR1(ρ)⊗AR1(ρ) R⁻¹ 纯 numpy eigh、REML 对 (h²,ρ) 坐标上升黄金搜索、run_ablup(spatial=True)→method=AR1×AR1、与 G×E/G×R 互斥、缺坐标 409)+ **稀疏 A⁻¹+共轭梯度**(`_build_ainv_sparse` COO + `_cg_solve`、n>N_SPARSE(1000) 阈值分派 solver=sparse-cg、Hutchinson k=100 估 PEV 对角 reliability 近似、稠密路径逐字节不变)。`_DATA_VERSION` v2.13、blup 1.3.0 / genomic 1.2.0 / gwas 1.1.0。详见 `桃育种系统统计引擎实施记录.md`);⑮ **十二项真缺口补齐(2026-08-05,用户全量现状盘点收敛 12 件真缺口、剔除过时「待建」标记,拍板只补这 12 件,分三批独立 e2e + 全量回归零回归)**——批1 引擎三件(solver 扩展独立路径):**ssGWAS**(`gwas._ssgwas`:`build_g_matrix` 岭保正定构 G → `solve_gblup` 全样本 GEBV û → **Wang et al. 单步 GWAS MEM 对角近似**反推标记效应 `ê_j=(M_j'Z'G⁻¹û)/(2Σp_j(1−p_j))`、Wald t=ê/SE → fdist,共享 `_finalize`/`_bh_qvalues`/`_cluster` 后处理,method=ssgwas 透传+前端下拉,gwas 1.2.0)+ **全 MT-BLUP solve_multi**(m 性状 y 堆叠 Var(u)=G0⊗A、**EM-REML 迭代** G0+ve 单性状初始+特征值截断/Higham 投影保正定、max_iter 兜底,输出 G0 全元素+r_g 矩阵+n_iter/converged/warning,`run_genetic_corr(full_mtblup=True)` 一次 m 性状给完整 G0,逐对 bivariate 保活,mtblup 1.1.0)+ **AR1×AR1 双参 ρ aniso**(R_ij=ρ_row^|Δrow|·ρ_col^|Δcol|、ρ_row/ρ_col 交替坐标上升,`run_ablup(spatial_aniso=True)` → method=AR1×AR1(aniso)、note 落 rho_row/rho_col,**aniso=False 与 v1 逐字节一致**,blup 1.4.0);批2 模块四件(标准 CRUD 五件套+前端+菜单翻转+文案清理):**field_operation 农事操作 900051**(op_type 字典施肥/喷药/修剪/灌溉/疏果/套袋/采收 bre_dict 灌入)+ **observation 通用观测 900050**(EAV plot 级通用录入、obs_type numeric/text/date)+ **breeding_report 育种报告 900075**(CRUD + `POST /report/generate` 聚合 bre_prediction/bre_stability_result/bre_cv_result 结构化报告)+ **analysis_dataset 分析数据集 900070**(新表 bre_analysis_dataset jsonb×3 + status,「查看性状值」跳 statistics trait-values 复用既有视图);批3 底座五件:**审计切面**(base_crud 三写注入 CREATE/UPDATE 字段级 diff/DELETE + 批量导入仅汇总 IMPORT 一条 + `GET /bre/audit/list`)+ **统一编号服务**(`app/utils/number_gen.py` NumberGenService + `bre_sequence` + **pg_advisory_xact_lock 原子取号**,迁移 cross_combination/tree/selection_result/seedling 4 处格式保持)+ **超期预警定时任务**(每日 job 按 selection_rule 扫描树 N 年未决策/花粉批次过期/seed_lot 超期 → `bre_alert` + `GET /bre/alert/list` + 前端「预警中心」)+ **备份容灾**(`backend/scripts/pg_backup.sh`/`pg_restore.sh`,Temp/backup 按日期 + N 天轮转,每日 02:00 系统 job)+ **BrAPI + MIAPPE 互操作**(`/brapi/v2` 只读五端点 germplasm/observationvariables/studies/observations/miappe,BrAPI 2.x metadata+pagination+result 包裹,bre_germplasm→germplasmDbId/genus=Prunus/species=persica/subtaxa 映射);`_DATA_VERSION` v2.14、gwas 1.2.0 / mtblup 1.1.0 / blup 1.4.0。详见规格 §8.24–8.26 / `桃育种系统统计引擎实施记录.md` §十七);⑯ **桃育种业务流全面完善(2026-08-05,用户要求「对现有功能,结合桃育种实际业务流程与需求,进行全面测试分析,并完善」,三路审计收敛 10 项真缺口,拍板全部实施 + 两裁决:**导入按钮维持隐藏**(23+ 页显式隐藏非缺口)/ **采收入口走农事操作**(op_type=harvest 结构化产量表单,非独立模块),分三批独立 e2e + 全量回归零回归)**——批A 业务数据链(A1-A6):**果实采收结构化入口**(bre_field_operation 加 yield_kg/fruit_count/avg_fruit_weight/marketable_rate,op_type=harvest 树级采收同步写 bre_trait_observation 4 行、plot 级只留操作记录)+ **产量构成/物候/品质/生长量/描述性状种子 19 条**(bre_yield_phenology_traits.sql:单株产量/果数/均果重/好果率/裂果率 + 盛花期/果实成熟期/落叶期 + 可溶性固形物/可滴定酸/固酸比 + 主干周长/树高/冠幅 + 果形/茸毛/离核/果皮底色/着色类型)+ **种质级观测**(bre_observation.germplasm_id 描述数据入口)+ **授粉→收种→种子处理补链**(seed_lot.pollination_id + seed_treatment.seed_lot_id)+ **EAV 观测校验**(numeric/date parse + valid_min/max 越界 + 同(tree/plot/germplasm,trait,year,date)重复 409)+ **育种阶段自动设置 + 成株性状门禁**(tree stage 按 breeding_generation 推断、童期树录成株性状 409);批B 统计算法(B1-B4):**Type-B 多环境遗传力**(run_type_b_heredity 复用 mtblup.solve_bivariate 把环境当"性状"、env_dim=site/year、落 bre_type_b_result + /type-b-heredity + /type-b/{id})+ **遗传相关 genetic 模式**(correlation mode=genetic 逐对 bivariate 组装 G0,pheno 分支逐字节不变、不落库)+ **选择指数自动权重**(auto_weights=True → default_h2 兜底 0.1、强制 use_h2=False、direction 翻转)+ **UPGMA 层次聚类**(run_cluster 纯 numpy,POST /statistics/cluster 不落库);批C 前端(C1-C4):观测录入增强(observation plot/站点/germplasm 过滤 + germplasm 表单、field_operation harvest 结构化表单)+ 统计可视化 ECharts(correlation 热图 + gblup 可靠性热图/趋势折线 + type-B tab + cluster 树状图)+ combination-funnel 前端入口对话框 + stage_phenotype 容器同步(通用观测/农事操作翻真实页);`_DATA_VERSION` v2.15。详见规格 §8.27 / `桃育种系统统计引擎实施记录.md` §十八);⑰ **遗传链完整性两次修复(2026-08-05,用户先后报告「建种质断链」与「近交惩罚静默关闭」,核实确认 + 拍板落地,e2e 验证通过)**——① **世代闭环修复**:`selection_result._promote_tree_to_clone` 单株晋级到 line/regional_trial/released 建种质时**同步落 `bre_pedigree`**(child_code=品种名、dam/sire=tree.dam/sire 回退组合 female/male_parent_id、combination_id、generation、child_code 幂等)→ 晋升种质不再被统计引擎当 founder,跨世代系谱链闭合(判别点 A[T2,T3]=0→0.125、A[gT1,gA]=0.5);② **近交惩罚「静默关闭」修复**:`mating_recommend` 亲缘矩阵旧靠 `bre_pedigree.child_code` 精确字符串匹配建系谱,名称大小写/空格/代号错配即全空 → 所有配对 r=0 → kin_pen=0 → 近交规避失效且无告警;重写 `_germplasm_pedigree` **多源级联**(① 规范化 strip+casefold 名称反查显式系谱为权威 → ② `tree.germplasm_id` 直连 FK 兜底(多对去重后唯一一致才采纳、歧义回退 founder)→ ③ founder;祖先 BFS 展开 + 去重,修复候选既是 child 又是祖先时系谱重复抛『系谱个体重复』崩溃);`mating_recommend` 新增 **kinship_status(ok/partial/none) + kinship_warning + 逐对『亲缘未解析(r 按 0 计)』旗标**,绝不静默 no-op;设计红线 **FK 只兜底不覆盖显式系谱**(A6 回归 r=0.5 保真)。e2e 14 组断言 + 回归 A6/batch/gaps_base 零回归 + HTTP 核验新字段。详见规格 §8.28 / `桃育种系统统计引擎实施记录.md` §18.5/§18.6。);⑱ **选配「亲本 EBV」回退逻辑方法学修复(2026-08-05,用户报告 `mating_recommend` 旧实现对缺直接 EBV 的候选用「其子代树 EBV 均值」代表亲本育种值——① progeny-test 值非亲本自身值;② 双亲都缺时同一子代树等权计入 dam 与 sire 两侧稀释;③ 跨组合混合无区分,会误导选配排序。核实确认 + 拍板落地)**——`mating_recommend` EBV 回退重写:**direct(germplasm_id 直连亲本自身预测值)优先**、有 direct 绝不被子代树值污染;回退仅当无自身值时用、**显式标注「子代测验近似」并降权 0.5 参与打分**(`_EBV_SOURCE_FACTOR={direct:1.0,progeny:0.5,missing:0.0}`,mid=(f_a·ebv_a+f_b·ebv_b)/2,回退不掩盖 direct);**双亲均缺直接值的子代树剔除**(防双侧等权重复计);记 n_progeny/n_combos 辨别跨组合;返回体加 `candidate_ebv` + `ebv_source.coverage` + 逐对旗标「亲本EBV为子代测验近似(降权0.5)」/「亲本无EBV(按0计)」——EBV 来源显式可查绝不静默降级。e2e 5 组断言 + 回归 A6/亲缘/batch/gaps_base 零回归 + HTTP 核验(真实数据 coverage={direct:0,progeny:0,missing:12})。详见规格 §8.29 / `桃育种系统统计引擎实施记录.md` §18.7。);⑲ **数据质量报告「离群值」方向语义错位修复(2026-08-05,用户报告 §8.23 数据质量端点 IQR/MAD 把任何偏离分布的值都标「离群株」,与育种目标方向错位——正向偏离正是要选的精英单株,核实确认 + 拍板落地)**——`data_quality_report` 方向感知分流:按 `bre_trait.direction`(desc/None=高值优、asc=低值优)判定 `desirable_high`,IQR/MAD 命中的株按偏离侧**双通道分类**(`is_high=v>med`、`is_elite=is_high==desirable_high`):**同向偏离 → `extreme_candidates` 极值候选(精英)**(class=extreme_candidate)/ **反向偏离 → `outliers` 疑似录入错误**(class=outlier,高值优的异常低值、低值优的异常高值),两表互斥;返回体加 `direction` + `extreme_candidates` + `summary.n_extreme_candidates`、`has_warning/n_outliers` 语义收紧为疑似录入错误计数;前端数据质量 tab 改双表(精英 success / 错误 danger)+ 选育方向指示 + 无误报绿色 info alert。e2e 17 株断言(精英 16.0 进 extreme_candidates/side=high、错误 2.0 进 outliers/side=low、互斥不串表)+ 回归 A6/亲缘/batch/gaps_base 零回归 + HTTP 核验(asc 裂果率 bB_DIR5668 与 desc 果重 dA_DIR5668 方向/双表/计数经真实端点正确返回,真实数据无极端值→双表空无误报)。详见规格 §8.30 / `桃育种系统统计引擎实施记录.md` §18.8。);⑳ **ΔG 投影假设声明 + 工程健壮性打磨(2026-08-05,用户报告两点均非功能 bug,核实确认 + 拍板落地,e2e 验证通过)**——① **ΔG 投影假设显式声明**:`genetic_gain` 旧实现 `next_pheno_mean = pheno_mean ± dg` 隐含「环境均值恒定」,年际气候漂移会使投影偏;属投影演示性质,返回体新增 `note` 假设声明(环境均值恒定、未计入年际气候漂移与栽培措施变化;ΔG=k·r_g·σ_A 仅含遗传增益分量,next_pheno_mean 为投影演示值而非实测预测),前端 gain tab info alert 展示;② **空间模型缺坐标优雅降级**:`run_ablup(spatial=True)` 旧实现任一观测树缺 row/col 即整批 ValueError,现改为**剔除缺坐标株表型(保留系谱 → EBV 仍由系谱预测)、仅全缺坐标才整批 409**,剔除数显式写批次 note「缺坐标剔除N株表型(EBV由系谱预测)」、input_hash 用剔除后子集;③ **numpy 工程清理**:7 处函数体内重复 `import numpy` 收敛为模块级单次 + `data_quality_report` 逐株 flag 循环改 numpy 布尔掩码向量化(输出逐字节不变)。e2e spatial 降级场景(5 株含 1 缺坐标 → method=AR1×AR1/train_n=4/note 含剔除/5 株均有 EBV)+ A6 note 断言 + 回归 spatial/A6/亲缘/batch/gaps_base 五套零回归 + vue-tsc EXIT=0 + HTTP 核验(genetic-gain 返回 note、data-quality 返回 direction)。详见规格 §8.31 / `桃育种系统统计引擎实施记录.md` §18.9。);㉑ **A–F 六项功能级缺口·批1 父本验证 + 近交衰退(2026-08-05,用户全模块 grep 实证 A–F 六项缺口均为「有无/深度」问题、非正确性 bug,逐项复核确认 + 拍板「全部六项分批落地」;批1 独立 e2e + 全量回归零回归)**——**A 父本验证(consistency 不落库)**:① `pollination` 花粉来源一致性——`_validate_pollen_parent`(create/update 统一):花粉批次声明采集父本树(pollen.male_tree_id)时授粉声明父本树须一致否则 409(含「不一致」文案),批次无采集父本(混合/外地)或授粉未声明放行不武断;② `tree` 显式亲本 vs 组合父母本警示——`_resolve_parents` 返回 `(dam,sire,warning)`:显式 dam/sire 偏离组合 female/male_parent 时返回体 `warning`「显式母本/父本与组合母本/父本不一致(以显式为准)」(双侧/单侧聚合),**warning 不阻断**(tree 可独立建,武断 409 误伤),schema 只读字段不落库;③ `cross_combination` 同质自交门禁——`_check_parent_roles` 中 `female_parent_id==male_parent_id` → 409「母本与父本同为「{名}」,自交组合请确认」(create/update 生效亲本统一,与 S-等位 409 语义一致);**C 近交衰退分析(计算端点不建表)**——`POST /statistics/inbreeding-depression`(入参 trait_id/trait_code + year/trial_study_id 可选 + min_n=10):`blup.relationship_matrix` 的 F=A_ii−1(按树,**非近交树从 individuals 默认 F=0 兜底**否则被排除)+ trait 树级表型 group_by(tree_id) 均值 → **纯 numpy lstsq 回归 phenotype~F**——`sufficient/reason/n_obs/mean_F/max_F/direction/regression{intercept,slope,r2,t_slope,depression_rate,has_depression,interpretation}/high_inbreeding`(F 降序前 10 含 tree_no/value);`has_depression=slope<0 且 t_slope≤−1.645`(单侧 95% 大样本近似);n候选/空候选/λ<0 409;**E MABC 标记辅助回交**——`POST /statistics/mabc-progress`(纯计算不落库):**前景选择**复用 `_mas_hit_map`(五语义、stage 无关、童期幼苗可用)`foreground_pass`=命中≥阈值;**背景基因组恢复率**对 background 面板逐标记候选 vs 轮回亲本「纯合剂量一致」比例(`_pure_homologous`,杂合不计)均值=恢复率 %;**回交代建议**按 `_BC_LADDER`(F1→BC1→…→BC5→自交固定)+ `_MABC_GENERATIONS`:晋级下一代 / 再回交一代 / 恢复无法评估(轮回亲本缺背景基因型)补测后判定 / 前景未通过;输出 per_tree{前景命中/恢复率/bc_generation/reason} + `recommended`(前景通过按恢复率降序)+ summary + warning;门禁空候选/空面板/generation 非法/background_target∉(0,100]/轮回亲本不存在 409。e2e `ocs` 17 断言(Σc/c≥0/λ 梯度 avg_kinship 单调非增 1.0→0.5157→0.3211→0.2685/λ=0 退化=贪心/partial+warning/三 409)+ `mabc` 23 断言(前景 5pass+1fail/恢复复算 100·75·75·0/reason 晋级·再回交·前景未通过/recommended 恢复率降序/童期 C6/轮回亲本缺基因型 None+warning/六 409)+ 回归 molrigor/gwas/mating_kinship/mating_ebv/gblup_cv/statsflow/spatial/busflow/gaps_base/b8/prediction_rigor/audit_fix 全套零回归 + vue-tsc EXIT=0 + HTTP 核验。`_DATA_VERSION` v2.24。详见规格 §8.34 / `桃育种系统统计引擎实施记录.md` §18.12。);㉔ **A–F 六项功能级缺口·批4 分子亲本验证 + 试验设计深度(2026-08-05,用户实证 grep 指出 genotype_call 对 mendelian/parentage 零命中 + trial_design 仅 RCBD,逐项复核确认 + 拍板「平方格子+一般循环回退」,独立 e2e + 全量回归零回归)**——**A 孟德尔/亲本一致性检验**(`GET /bre/genotype_call/mendelian/check`,纯计算不落库):子代树样本基因型 vs 声明亲本(tree.dam_id/sire_id→germplasm)逐标记 union 规则(子代等位集 ⊆ dam∪sire 并集)判兼容,单株兼容率=兼容/信息位点、rate0→has_warning);系谱消费方断开隔离树亲本链(退化为 founder 表型保留:`_build_pedigree`/`run_ablup`/`inbreeding_depression`/`kinship_matrix`/`run_cv`/`_germplasm_pedigree`,GBLUP/ssGBLUP 基因型样本不受影响);**N2 统计引擎全量数值真值校验**:新探针 `e2e_stats_numerics_20260805.py` **ok=101 fail=0** 逐段独立 ground-truth 对照(blup/gxe/block/spatial-aniso/gblup/ssgblup/bivariate/multi/FW/ammi/rcbd/diallel/ocs/UPGMA/ΔG/data-quality/kinship 全验证),探针自身两处 fixture 缺陷修复(低 h² 小样本 r_g 假收敛换高遗传力良态设计、B4.2 硬编码 tree_id),**引擎本体零缺陷**;`_DATA_VERSION` v2.26、`blup.ENGINE_VERSION` 1.5.0。详见规格 §8.37 / `桃育种系统统计引擎实施记录.md` §18.15。);㉖ **批6 规划↔目标↔试验 可追溯性闭环(2026-08-05,用户全代码库 grep 实证 `bre_plan` 彻底孤立根表——零传入外键、`bre_trial` 无 plan_id/target_id、无 project/program 表;拍板「加试验挂目标」最小双链,独立 e2e 探针 17 断言 + 全量回归零回归)**——**三可空 FK 双链闭合**:`bre_target.plan_id` + `bre_trial.plan_id`(目标链 plan→target→cross_combination→tree)+ `bre_trial.target_id`(试验链 plan→trial→trial_study→tree),weld SQL 幂等、target/trial 三件套透传 + get_list 解析 `plan_name`/`target_name`;**父校验**(target/trial create 坏 plan_id/target_id → 409,None 跳过——孤目标/独立试验仍可建)+ **删除守卫**(plan.delete 有 target/trial 子 409、target.delete 有 combo/trial 子 409);**计划可追溯端点** `GET /bre/plan/trace/{id}`(plan 基础 + targets[] 组合株数 + trials[] 试验点株数 + totals,软删过滤、孤目标不入链、不存在 409);前端 target/trial 「所属计划/目标」select + plan 页「追溯」弹窗。验证:`e2e_plan_trace_20260805.py` 17 断言全过 + 全量回归零回归(含审计切面残留检查限定 `entity_type` 根治跨表撞 id 误报)+ vue-tsc EXIT=0 + HTTP 核验;`_DATA_VERSION` v2.27。详见规格 §8.38 / `桃育种系统统计引擎实施记录.md` §18.16。);㉗ **批7 统计引擎全量十模块 golden 复查·系谱键空间错位修复(2026-08-05,用户质疑 GBLUP/ssGBLUP、genetic_corr、type_b、cluster、stability、OCS、MABC、inbreeding_depression、trial_design、mendelian 十新建模块缺逐模块数值校验(golden-test 缺失)、历史上 G–L 静默失效都靠校验暴露,拍板「全量十项逐一复查」)**——复查确认 **8/10 已 golden**(GBLUP/ssGBLUP、cluster、stability、OCS、MABC、inbreeding_depression、trial_design、mendelian 既有探针已具严格数值断言),**type_b 与 genetic_corr 补齐 golden 缺口**——审计深处暴露**系谱键空间错位真 bug**:`run_type_b_heredity`/`correlation_matrix`(mtblup)/`run_genetic_corr` 四个服务路径把 **int `tree_id` 表型键**传给 `mtblup.solve_bivariate`,而 `_build_pedigree` 系谱用 **str `"t{id}"`**,`blup.solve` 把系谱外个体自动并入 base(无关 founder)→ **系谱被静默丢弃**(平衡数据同株跨环境掩盖;**分裂家系数据**无同株跨环境、靠全同胞链桥接暴露——int 键 → r_B/r_g 锁死 ±0.99 平剖面端点假收敛假阴性)。修复:共享 helper `_tree_individual_keys`(int→`"t{id}"` 对齐系谱键空间)收口**四个调用点**(`_genetic_corr_matrix`/`_smith_hazel_index` mtblup 分支/`run_genetic_corr`/`run_type_b_heredity`),`run_ablup` 早已正确不受影响。新探针 `e2e_rg_golden_20260805.py` **ok=21 fail=0**(20 founder×10 全同胞家系×3 同胞=30 株、3 站点、高遗传力 h²≈0.89:P0 诊断 str 键恢复 r_B=0.7+/int 键锁死 −0.99;P1 type_b 分裂家系 r_B(1,2)>0.5 + **n_common=0**(无共有树、系谱桥接生效直接证据);P2 correlation 平衡 r_g(A,B)>0.65/AC,BC<0.4/判别间隙>0.3/h²∈(0,1);P2b 分裂家系 discriminator r_g(S1,S2)>0.5)。验证:`e2e_statsflow`(单家系 fixture 对键 bug 不敏感 15 断言零变化)+ `e2e_advanced_mtblup` 回归通过 + **全量回归零回归**(60 套件,含版本断言同步)+ vue-tsc EXIT=0 + 后端重启 HTTP 核验(type-b-heredity / genetic-corr/run 分裂家系判别经真实端点)+ 备份镜像 `Temp/claude/backup_keyspace_20260805/`;`_DATA_VERSION` v2.28。详见规格 §8.39 / `桃育种系统统计引擎实施记录.md` §18.17;㉘ **回归基建:统一回归 + 高风险模块正式 tc 套件(2026-08-05,用户三点质疑——tc 零覆盖分析模块 / 批7 无 golden / 新模块算偏当正常输出(G/I 静默失效同款隐患)——核实全部成立,拍板「两者都要」;纯测试基建不 bump 版本)**——`run_regression.py`(仓库根)顺序串跑 `Temp/claude/e2e_*.py` + `backend/scripts/test_bre_analysis_*_tc.py` = **63 测试**、每测试独立子进程 log 到 `regression_logs/`、EXIT 汇总、`DPB_PYTHON` 覆盖解释器、utf-8 防 GBK;**4 个正式 tc 套件**(TestClient 真实 API + golden 数值断言,高风险模块必测):`test_bre_analysis_ssgblup_tc.py` ok=14(非基因型株 GBLUP EBV=0 / ssGBLUP EBV≠0——H⁻¹ 拼接判别)、`test_bre_analysis_ocs_tc.py` ok=23(投影梯度收敛/λ 单调/c'Ac/λ=0 退化)、`test_bre_analysis_mabc_tc.py` ok=24(前景/背景恢复率/晋级/童期)、`test_bre_analysis_mendelian_tc.py` ok=9(POST run 落库 + 隔离注册表 + 阈值 0.0 可逆);教训:HTTP schema 校验 → 422 / 服务层 CustomException → 409、tc 须显式 import UserModel、隔离断言时序、启动残留预清理 `_wipe`。全量回归 58/59(gaps_engines 批量卡死单跑 RC=0 确认);备份镜像 `Temp/claude/backup_test_rigor_20260805/`;交付流程:改统计引擎/业务链后端后跑 `python run_regression.py` 全量 EXIT=0。详见规格 §8.40 / `桃育种系统统计引擎实施记录.md` §18.18。);㉙ **回归基建 2:分析模块 tc 补齐(5 套)+ pytest 接入 + 本机 CI stage(2026-08-05,用户对 4 套 tc 认可后拍板补齐 genetic_corr/type_b_heredity/inbreeding_depression/trial_design/run_stability 五个静默失效风险较低模块,v8 验收标准定为「CI 跑通 pytest 全绿」;纯测试基建不 bump 版本,`_DATA_VERSION` 保持 v2.28)**——**补 5 套正式 tc**(`backend/scripts/test_bre_analysis_{genetic_corr,type_b,inbreeding,trial_design,stability}_tc.py`,TestClient 真实 API + `-X utf8` + `_wipe()` 残留预清理):genetic_corr ok=19(平衡 3 性状 r_g(AB)>0.65/间隙>0.3 + **分裂家系判别** r_g(S1,S2)>0.5 且 n_common=0,独立种子 SEED_S=3)、type_b ok=18(3 站点分裂家系 r_B>0.5 且 n_common=0,复刻 tY-first rng 序→0.990)、inbreeding ok=44(方向感知:衰退 slope<−40/无衰退/上升 slope>30 + deep 系谱 F=0.125 + min_n insufficient)、trial_design ok=25(增广对照 block_no=None 不落库 + α-格子平方 λ≤1/非素数告警/一般贪心 + **block_no=rep*100+block DB 读回** + 四 409)、stability ok=16(FW b≈2/1/0 + flag_stable C1=True + AMMI ASV rank + engine_version 落库);**pytest 接入**——`backend/tests/test_stat_analysis_tc.py` 9 套 @pytest.mark.pg + subprocess 串跑真实脚本、skipif 探测真实 PG16+Redis(**protocol=2**——redis-py 7.x 默认 RESP3 对老 WSL redis-server 报 ResponseError,与 app redis_connect 对齐)、env 显式构建剥 conftest 污染解析 .env.dev 可 TC_CI_* 覆盖;pyproject 注册 pg marker、conftest collect_ignore_glob 排除 scripts;**本机 CI stage `run_ci.py`**(仓库根,step 0 探测 PG+Redis 失败指引 / step 1 `pytest -m pg` / step 2 EXIT=0「CI 全绿」,日志 Temp/claude/ci_logs/;不做 GitHub Actions——全工程无 git 仓库);验证 5 套 EXIT=0(122 断言)+ `pytest -m pg` 9 passed(146s)+ skipif 9 skipped 不红 + run_ci EXIT=0 + run_regression glob 自动纳入;备份镜像 `Temp/claude/backup_analysis_tc2_20260805/`;交付流程:v8 验收 = `python run_ci.py` EXIT=0。详见规格 §8.41 / `桃育种系统统计引擎实施记录.md` §18.19。);㉚ **O2–O4 统计方法学广度(2026-08-05,用户系统核对统计引擎能力后确认三项 P2 方法学缺口——幼年–成年遗传相关 / 非线性基因组预测 / 约束选择指数,均零命中、不影响当前业务正确性属方法库广度,拍板「O2–O4 先落地」+ 两决策:① O4 用经典 Kempthorne–Nordskog =0 闭式解(纯 numpy 不迭代)② 前后端同步扩展;`_DATA_VERSION` v2.28 → v2.29、genomic 1.4.0,无新表)**——**O2 幼年–成年遗传相关**:`_values_by_tree_env` 加 env_dim=stage 分支(`coalesce(obs.stage, TraitModel.stage)` 分组)+ `run_type_b_heredity` env_dim 校验扩 stage——发育阶段当"环境"逐对 `mtblup.solve_bivariate` REML 精确估 r_g(>0.5 表示童期可借力成株、早期间接选择有效),复用 `bre_type_b_result` 不建表,前端 Type-B 下拉加 stage;**O3 非线性基因组预测**:`genomic.py` 1.4.0 加 `solve_rrblup`(岭回归逐标记 u=(Xc'Xc+λI)⁻¹Xc'yc,**与 GBLUP 数学对偶**(同数据 EBV 逐位吻合)、输出 marker effects)+ `solve_bayesb`(BayesB Gibbs 可变选择 δ_j~Bernoulli(1−π)、**确定性 seed 可复现**)+ `kfold_cv_genomic` dosage 分派 rrblup/bayesb(实证 CV);`run_gblup` method 扩 rrblup/bayesb + seed 透传落 PredictionModel(method=RRBLUP/BayesB)+EBV 行、`run_cv` GS 分支 KFCV/RRBLUP·KFCV/BayesB;**O4 约束选择指数**:`_smith_hazel_index` 加 restricted(**Kempthorne-Nordskog Lagrange 闭式投影** b=P⁻¹·G·(I−M)·a、M=C'(C·G·P⁻¹·G·C')⁻¹·C·G·P⁻¹·G → 受限性状 **ΔG=0**;对照旧公式 M=C'(C·P⁻¹·C')⁻¹·C·P⁻¹ 实测 ΔG=4.46 不满足证明修正必要)+ economic_weights(绝对尺度不归一化、可与 restricted 叠加、asc 翻转),method 校验扩 zsum/smith_hazel/restricted、落库 SI_RES;**顺手修复 tc 暴露的既有缺口**:`cv_detail` 折明细缺 `cv_result_id` → 任意 GET /cv/{id} 400(既有 tc 未触达静默失效)——补上可查;验证:e2e 探针 19 断言(对偶逐位 1.42e-13 / BayesB 可复现 / 受限 ΔG=0 机器精度 −8.88e-16 / O2 真实 PG r_g=0.6145)+ 正式 tc 第 10 套 `test_bre_analysis_o234_tc.py` ok=41 fail=0(r_g=0.8995 / bayesb 同 seed 两次 EBV 逐位一致 / rrblup·GBLUP EBV 相关 1.000 / b_受限=0.0 / economic desc>0·asc<0 / 三 409)+ 全量回归零回归 + `run_ci.py` EXIT=0;备份镜像 `Temp/claude/backup_o234_20260805/`。详见规格 §8.42 / `桃育种系统统计引擎实施记录.md` §18.20。;㉛ **O1 花期数据消费补强(2026-08-06,用户系统核对花期字段零消费——`bre_germplasm.flowering_period` 等从未被任何统计/决策逻辑消费,核实确认 + 定性 P1 数据消费补强 + 辅助信息(非 S-allele 级硬错误防呆),拍板三档位落地;`_DATA_VERSION` v2.29 → v2.30,无新表)**——① **花期数据建模升级(结构化区间,前置)**:`bre_germplasm` 加双 Date 列 `bloom_start_date`/`bloom_end_date`(weld SQL 幂等 + COMMENT),`flowering_period` 5 档字典保留作粗粒度 fallback;model/schema/service(Excel 导入导出/模板 header 同步)+ 前端 germplasm 表单改两个独立 date 字段直绑(useCrudForm 无提交前 transform → 弃 daterange,与 acquisition_date 同模式零转换)——花期字段首次被消费;② **mating_recommend 花期重叠软警示(不硬阻断)**:`_bloom_overlap`(结构化区间月日年际比较 `_md()` 取 tm_yday 优先;缺区间 fallback 5 档 `_BLOOM_TIER_ORDER` 相邻/同档视为重叠)四态——overlap(重叠可同期授粉,**不进 flags[] 不阻断**)/ store(父本先开花需采粉贮藏)/ offset(母本先开花注意柱头可授期)/ unknown(缺花期数据提示补录),pairs 加 bloom_status/bloom_msg、非重叠三态追加 flags[](前端红色 tag);③ **授粉窗口规划消费 bre_pollen**:新端点 `GET /bre/pollen/window-plan?window_start=&window_end=`(`window_plan` 服务方法,窗口反向 409)——花期种质 `_project_bloom` 结构化区间按年际月日投影到窗口起始年(3-4 月单年窗口假设)、缺区间 fallback `_BLOOM_TIER_SPAN` 5 档代表区间并标注「粗粒度」;**窗口覆盖三态** covered(整段有批次 collect≤bs and expiry≥be)/partial/none(n_need_pollen 计 none);**库存五态** full/expiring(窗口内到期)/entering(窗口内采集)/expired(窗口前失效)/not_started;前端花粉页「授粉窗口规划」卡(daterange + 生成 + 缺粉 alert + 花期覆盖表 + 库存状态表,默认 03-01~04-30)。验证:e2e 探针 `e2e_o1_bloom_20260805.py` 4 段全断言(花期建模双 Date 落库 / 6 种质 15 对四态(区间三类 + 档位 store + 混合对 unknown)/ 窗口覆盖三态 + 档位投影 + 缺粉集合 / 库存五态 / 反向 409)+ vue-tsc EXIT=0 + 全量回归零回归 + `run_ci.py` EXIT=0 + HTTP 核验;备份镜像 `Temp/claude/backup_o1_bloom_20260805/`。详见规格 §8.43 / `桃育种系统统计引擎实施记录.md` §18.22。 +> **状态约定**:✅ 已落地(基础字段) | ⏳ 待建(规划已定,待生成器落地) | ⏳ 待灌(数据) | — 引擎/端点(不建表)。**2026-08-04 追加**:① 统计引擎两轮 P0 已代码落地(G×E + 性状方向标注);② 花粉档案 §3.19 落地(bre_pollen + 授粉窗口校验);③ 选择指数升级为**无性系级聚合**(决策正确性,可靠加权聚合 + 自株退化 + 系级决选);④ 配合力 GCA/SCA 支持**交配设计 `design_type`**(统计严谨:完全双列/部分双列/line×tester(NCII)/NCIII,`combining.solve` 按设计分支双因素模型 + 运行按设计过滤组合);⑤ **统计严谨·MLOps 复现性**(① Smith-Hazel 遗传相关改 **Calo 可靠性校正** r_g=r_EBV/√(rel_i·rel_j);② **PA 落库** pa=√reliability + 批次 accuracy 真 PA;③ **MLOps 溯源** data_version/input_hash/engine_version(同数据重跑哈希一致、改观测必变);④ **germplasm_id 填充**(亲本级 EBV 查询可用);⑤ **砧木字典扩列** 矮化类/砧穗亲和性;⑥ **空间竞争协变量** covariate=competition(Chebyshev 邻域株数,无坐标显式报错));⑥ **组合得失漏斗 v_combination_funnel**(按组合汇总 花→果→种→苗→定植→树→入选 六级派生指标——结实率/出苗率/选择强度滚到组合层,配合力第一手证据,只读端点可查);⑦ **分子育种「建数据能力」**(§3.7 四表落地:bre_marker / bre_genotyping_dataset / bre_genotype_sample(weld 补 sample_name)/ bre_genotype_call,全量 CRUD + Excel 导入导出 + **VCF 导入**(marker/sample 自动建号、GT 编码、幂等)+ **DNA 指纹防错**(无性系混杂检测,同源不一致/异源一致两类标记));⑧ **精修项四连发**(① **DUS/品种保护「数据能力(三表)」** bre_dus_descriptor/test/observation + UPOV TG/53 桃描述符种子 15 条 + 菜单 900230 真实页与 G 段按钮 900800-900808;② **stage 感知** bre_trait.stage(童期/成株)驱动 BLUP/选择指数/决策预览的过滤与警示 + 批次 stage 落库;③ **k-fold 交叉验证外部验证** ABLUP+GXEBLUP 双支持(sire 家系分层留出、逐折容错,bre_cv_result/fold 落库,MLOps 溯源复用);④ **按 site EBV 公平性报告** site 系统性偏差 deviation/flagged + 排名一致性 Spearman + G×E 模式);⑨ **六项能力完善**(① **MT-BLUP 遗传相关**——成对双性状 REML(`mtblup.solve_bivariate`:y 堆叠 Var(u)=G0⊗A,Va/Ve 取单性状 REML、仅对 ρ 黄金分割求极大精确 REML),`run_genetic_corr` 逐对 bivariate 组装 G0 + Higham 半正定投影,落 `bre_genetic_corr_result`(job_type=GENCORR),Smith-Hazel 增 `g_method="mtblup"`(单对不收敛回退 Calo+warning、G 非正定特征值截断、`g_source` 落库可审计);② **GBLUP/ssGBLUP 引擎**——`genomic.py`:VanRaden G method1/2 + MAF 过滤 + blend 岭;`solve_gblup` 仅基因型株、`solve_ssgblup` 单步法 H⁻¹=A⁻¹+[[0,0],[0,G⁻¹−A22⁻¹]] 且 **V 块用 H 协方差**;`run_gblup` 样本 tree 直连/名称兜底、未映射+多等位跳过+warning,落 PredictionModel(GBLUP/ssGBLUP)+EBV 行;③ **DUS 特异性统计检验**——QN LSD(t_crit 由 `fdist.f_icdf` 二分反解 F(1,df))+ PQ/QL 状态众数、**必测描述符**驱动建议、参照=同 trial_study 自动 + 显式覆盖、落 `analysis_json` + conclusion 自动建议;④ **AMMI/Finlay-Wilkinson 稳定性**——FW 环境指数回归 b/R²/se_b/flag_stable(完美拟合 b≈1 判稳)、AMMI 残差 SVD→IPC/ASV/ecovalence、`run_stability` site/year 两维、格子 <2×2→409,落 `bre_stability_result`;⑤ **MLOps 重训+版本回滚**——`bre_prediction.is_active` 版本链(无 active 时首批 active)、`model/drift` 输入哈希漂移检测(轻量 `_gather_digest_inputs`)、`model/retrain` 漂移才重训+active 转移、`model/activate` 回滚原语;⑥ **BLUP stage 拆分**——`bre_trait_observation.stage` 观测级发育阶段、`run_ablup(stage)` 按 coalesce(obs.stage,trait.stage) 过滤+model_name `_{stage}` 后缀+批次 stage 落库、未指定但有分歧→note 警示);⑩ **田间试验精度补强·砧木×接穗随机互作(G×R)**——rootstock 由固定哑变量升级为**第三随机效应槽**(`run_ablup(gxr=True)` → method=GXRBLUP/job_type=GXR,按 `(基因型,砧木)` 分组建 Z₂ 随机互作、克隆坍缩系谱与 G×E 共享、rootstock 强制移出固定效应防共线、不可辨识门禁(同基因型无跨砧木/单元内无重复/残差 df<1)→409、σ²gxr/gxr_ratio/n_cross_rootstock 落批次 note;**Z₂ 单槽位 → G×E/G×R 互斥**(双开 409 + 前端互斥 watcher);求解器 `_solve_gxe` 加 factor_label/factor_name(G×E 警告文案逐字节不变、G×R 显「砧木」)、ENGINE_VERSION 1.2.0;G×E tab 加 G×R 复选框、批次表同显 σ²gxr);⑪ **基因组选择(GS)实证严谨性·GBLUP/ssGBLUP 专用 k-fold 交叉验证**——`run_cv` 加 `dataset_id/method/maf_min`(method=KFCV/GBLUP·KFCV/ssGBLUP),`genomic.kfold_cv_genomic` 掩蔽留出 GEBV vs 表型实证 pearson/RMSE(G/H/Hinv 只建一次、固定种子随机分层——GS 同世代样本无家系树,区别于 ABLUP 的 sire 家系留出)、`_gather_gblup_inputs` 提取(run_gblup↔GS CV 复用)、`_build_h` 缺失基因型补 base、ENGINE_VERSION 1.1.0、_DATA_VERSION v2.11;SSR 多等位 dummy 编码留待后续);⑫ **现代桃育种领域覆盖·S-等位基因交配兼容性 + 抗病/需冷量性状字典**(① `bre_germplasm.s_alleles`(String32,Sf=自交亲和型)+ `cross_combination` create/update 兼容校验——任亲本含 Sf→放行、共享 2 个 S 等位→409「配了不结」硬门禁、共享 1 个→`out.s_compat` 半兼容警示;②③ `bre_trait` 种子 5 条 numeric(细菌性穿孔病/褐腐病/白粉病 0-5 级病情指数 + 需冷量 h + 需热量 GDD,category「抗病性」「生态适应性」,into_ebv=1、direction=asc 进选种目标——统计引擎仅消费 numeric);④ **MAS/QTL/GWAS 闭环**(GWAS 引擎 GLM+PC:SVD 群体结构校正 + 逐标记单标记回归 + Bonferroni/BH-FDR + QTL 区间合并,纯 numpy 复用 fdist 零新依赖;bre_gwas_result/bre_gwas_snp/bre_qtl/bre_mas_panel/bre_mas_panel_marker 五新表;服务端 gwas/run|list|{id} + qtl CRUD + mas-panel CRUD(选标记 favorable_dose/direction/effect);decision_preview 加 marker 条件——童期幼苗无表型/无 EBV 也可被标记辅助选入;前端 900214 GWAS/QTL 页翻转(Manhattan/QQ 裸 echarts + 运行对话框 + 三 tab);e2e 7 段全过 + GBLUP/ssGBLUP 回归零回归,2026-08-04);⑭ **九缺口补齐(2026-08-04,用户重扫 12 项缺口剔除 A1/A4 余 9 项拍板全做,三批独立 e2e + 回归)**——批1 经典侧计算端点(纯计算不建表):**数据质量** `POST /statistics/data-quality`(缺失率/CV + IQR + MAD 稳健 z-score 双法异常标记)+ **遗传增益** `POST /statistics/genetic-gain`(ΔG=k·r_g·σ_A,k 经 Acklam 有理近似 Φ⁻¹ 纯 numpy 零 scipy)+ **主动选配** `POST /statistics/mating-recommend`(S-等位硬过滤 + 亲缘 A 惩罚 score=w_ebv·mid_parent_EBV−w_kin·max(0,r−threshold),不落库);批2 分子侧严谨性:**EMMAX 混合模型 GWAS**(`gwas._emmax`:零模型方差分量复用 `genomic._profile_solve` + eigh 对角化 K→V⁻¹ + 逐标记 GLS t²→fdist,method=emmax,run_gwas 后处理抽共享函数 GLM 逐字节不变)+ **SSR 多等位虚拟编码**(VCF 按 ALT 数定 ssr/snp、`_allelic_from_gt` 每等位一列、`build_g_matrix` 多等位 VanRaden 推广)+ **QTL×E 环境互作**(`POST /statistics/gwas-qtl-x-e` 分环境 GWAS 合并 stable/env-specific/异号 G×QTL 警示,不建表)+ **MAS 面板语义**(bre_mas_panel_marker.mode/favorable_allele/haplotype_group 三列 + `_mas_hit_map` additive/dominance/recessive/allele/haplotype 五语义);批3 求解器核心:**AR1×AR1 空间协方差**(`blup.solve_spatial`:e~N(0,σ²e·R)、R=AR1(ρ)⊗AR1(ρ) R⁻¹ 纯 numpy eigh、REML 对 (h²,ρ) 坐标上升黄金搜索、run_ablup(spatial=True)→method=AR1×AR1、与 G×E/G×R 互斥、缺坐标 409)+ **稀疏 A⁻¹+共轭梯度**(`_build_ainv_sparse` COO + `_cg_solve`、n>N_SPARSE(1000) 阈值分派 solver=sparse-cg、Hutchinson k=100 估 PEV 对角 reliability 近似、稠密路径逐字节不变)。`_DATA_VERSION` v2.13、blup 1.3.0 / genomic 1.2.0 / gwas 1.1.0。详见 `桃育种系统统计引擎实施记录.md`);⑮ **十二项真缺口补齐(2026-08-05,用户全量现状盘点收敛 12 件真缺口、剔除过时「待建」标记,拍板只补这 12 件,分三批独立 e2e + 全量回归零回归)**——批1 引擎三件(solver 扩展独立路径):**ssGWAS**(`gwas._ssgwas`:`build_g_matrix` 岭保正定构 G → `solve_gblup` 全样本 GEBV û → **Wang et al. 单步 GWAS MEM 对角近似**反推标记效应 `ê_j=(M_j'Z'G⁻¹û)/(2Σp_j(1−p_j))`、Wald t=ê/SE → fdist,共享 `_finalize`/`_bh_qvalues`/`_cluster` 后处理,method=ssgwas 透传+前端下拉,gwas 1.2.0)+ **全 MT-BLUP solve_multi**(m 性状 y 堆叠 Var(u)=G0⊗A、**EM-REML 迭代** G0+ve 单性状初始+特征值截断/Higham 投影保正定、max_iter 兜底,输出 G0 全元素+r_g 矩阵+n_iter/converged/warning,`run_genetic_corr(full_mtblup=True)` 一次 m 性状给完整 G0,逐对 bivariate 保活,mtblup 1.1.0)+ **AR1×AR1 双参 ρ aniso**(R_ij=ρ_row^|Δrow|·ρ_col^|Δcol|、ρ_row/ρ_col 交替坐标上升,`run_ablup(spatial_aniso=True)` → method=AR1×AR1(aniso)、note 落 rho_row/rho_col,**aniso=False 与 v1 逐字节一致**,blup 1.4.0);批2 模块四件(标准 CRUD 五件套+前端+菜单翻转+文案清理):**field_operation 农事操作 900051**(op_type 字典施肥/喷药/修剪/灌溉/疏果/套袋/采收 bre_dict 灌入)+ **observation 通用观测 900050**(EAV plot 级通用录入、obs_type numeric/text/date)+ **breeding_report 育种报告 900075**(CRUD + `POST /report/generate` 聚合 bre_prediction/bre_stability_result/bre_cv_result 结构化报告)+ **analysis_dataset 分析数据集 900070**(新表 bre_analysis_dataset jsonb×3 + status,「查看性状值」跳 statistics trait-values 复用既有视图);批3 底座五件:**审计切面**(base_crud 三写注入 CREATE/UPDATE 字段级 diff/DELETE + 批量导入仅汇总 IMPORT 一条 + `GET /bre/audit/list`)+ **统一编号服务**(`app/utils/number_gen.py` NumberGenService + `bre_sequence` + **pg_advisory_xact_lock 原子取号**,迁移 cross_combination/tree/selection_result/seedling 4 处格式保持)+ **超期预警定时任务**(每日 job 按 selection_rule 扫描树 N 年未决策/花粉批次过期/seed_lot 超期 → `bre_alert` + `GET /bre/alert/list` + 前端「预警中心」)+ **备份容灾**(`backend/scripts/pg_backup.sh`/`pg_restore.sh`,Temp/backup 按日期 + N 天轮转,每日 02:00 系统 job)+ **BrAPI + MIAPPE 互操作**(`/brapi/v2` 只读五端点 germplasm/observationvariables/studies/observations/miappe,BrAPI 2.x metadata+pagination+result 包裹,bre_germplasm→germplasmDbId/genus=Prunus/species=persica/subtaxa 映射);`_DATA_VERSION` v2.14、gwas 1.2.0 / mtblup 1.1.0 / blup 1.4.0。详见规格 §8.24–8.26 / `桃育种系统统计引擎实施记录.md` §十七);⑯ **桃育种业务流全面完善(2026-08-05,用户要求「对现有功能,结合桃育种实际业务流程与需求,进行全面测试分析,并完善」,三路审计收敛 10 项真缺口,拍板全部实施 + 两裁决:**导入按钮维持隐藏**(23+ 页显式隐藏非缺口)/ **采收入口走农事操作**(op_type=harvest 结构化产量表单,非独立模块),分三批独立 e2e + 全量回归零回归)**——批A 业务数据链(A1-A6):**果实采收结构化入口**(bre_field_operation 加 yield_kg/fruit_count/avg_fruit_weight/marketable_rate,op_type=harvest 树级采收同步写 bre_trait_observation 4 行、plot 级只留操作记录)+ **产量构成/物候/品质/生长量/描述性状种子 19 条**(bre_yield_phenology_traits.sql:单株产量/果数/均果重/好果率/裂果率 + 盛花期/果实成熟期/落叶期 + 可溶性固形物/可滴定酸/固酸比 + 主干周长/树高/冠幅 + 果形/茸毛/离核/果皮底色/着色类型)+ **种质级观测**(bre_observation.germplasm_id 描述数据入口)+ **授粉→收种→种子处理补链**(seed_lot.pollination_id + seed_treatment.seed_lot_id)+ **EAV 观测校验**(numeric/date parse + valid_min/max 越界 + 同(tree/plot/germplasm,trait,year,date)重复 409)+ **育种阶段自动设置 + 成株性状门禁**(tree stage 按 breeding_generation 推断、童期树录成株性状 409);批B 统计算法(B1-B4):**Type-B 多环境遗传力**(run_type_b_heredity 复用 mtblup.solve_bivariate 把环境当"性状"、env_dim=site/year、落 bre_type_b_result + /type-b-heredity + /type-b/{id})+ **遗传相关 genetic 模式**(correlation mode=genetic 逐对 bivariate 组装 G0,pheno 分支逐字节不变、不落库)+ **选择指数自动权重**(auto_weights=True → default_h2 兜底 0.1、强制 use_h2=False、direction 翻转)+ **UPGMA 层次聚类**(run_cluster 纯 numpy,POST /statistics/cluster 不落库);批C 前端(C1-C4):观测录入增强(observation plot/站点/germplasm 过滤 + germplasm 表单、field_operation harvest 结构化表单)+ 统计可视化 ECharts(correlation 热图 + gblup 可靠性热图/趋势折线 + type-B tab + cluster 树状图)+ combination-funnel 前端入口对话框 + stage_phenotype 容器同步(通用观测/农事操作翻真实页);`_DATA_VERSION` v2.15。详见规格 §8.27 / `桃育种系统统计引擎实施记录.md` §十八);⑰ **遗传链完整性两次修复(2026-08-05,用户先后报告「建种质断链」与「近交惩罚静默关闭」,核实确认 + 拍板落地,e2e 验证通过)**——① **世代闭环修复**:`selection_result._promote_tree_to_clone` 单株晋级到 line/regional_trial/released 建种质时**同步落 `bre_pedigree`**(child_code=品种名、dam/sire=tree.dam/sire 回退组合 female/male_parent_id、combination_id、generation、child_code 幂等)→ 晋升种质不再被统计引擎当 founder,跨世代系谱链闭合(判别点 A[T2,T3]=0→0.125、A[gT1,gA]=0.5);② **近交惩罚「静默关闭」修复**:`mating_recommend` 亲缘矩阵旧靠 `bre_pedigree.child_code` 精确字符串匹配建系谱,名称大小写/空格/代号错配即全空 → 所有配对 r=0 → kin_pen=0 → 近交规避失效且无告警;重写 `_germplasm_pedigree` **多源级联**(① 规范化 strip+casefold 名称反查显式系谱为权威 → ② `tree.germplasm_id` 直连 FK 兜底(多对去重后唯一一致才采纳、歧义回退 founder)→ ③ founder;祖先 BFS 展开 + 去重,修复候选既是 child 又是祖先时系谱重复抛『系谱个体重复』崩溃);`mating_recommend` 新增 **kinship_status(ok/partial/none) + kinship_warning + 逐对『亲缘未解析(r 按 0 计)』旗标**,绝不静默 no-op;设计红线 **FK 只兜底不覆盖显式系谱**(A6 回归 r=0.5 保真)。e2e 14 组断言 + 回归 A6/batch/gaps_base 零回归 + HTTP 核验新字段。详见规格 §8.28 / `桃育种系统统计引擎实施记录.md` §18.5/§18.6。);⑱ **选配「亲本 EBV」回退逻辑方法学修复(2026-08-05,用户报告 `mating_recommend` 旧实现对缺直接 EBV 的候选用「其子代树 EBV 均值」代表亲本育种值——① progeny-test 值非亲本自身值;② 双亲都缺时同一子代树等权计入 dam 与 sire 两侧稀释;③ 跨组合混合无区分,会误导选配排序。核实确认 + 拍板落地)**——`mating_recommend` EBV 回退重写:**direct(germplasm_id 直连亲本自身预测值)优先**、有 direct 绝不被子代树值污染;回退仅当无自身值时用、**显式标注「子代测验近似」并降权 0.5 参与打分**(`_EBV_SOURCE_FACTOR={direct:1.0,progeny:0.5,missing:0.0}`,mid=(f_a·ebv_a+f_b·ebv_b)/2,回退不掩盖 direct);**双亲均缺直接值的子代树剔除**(防双侧等权重复计);记 n_progeny/n_combos 辨别跨组合;返回体加 `candidate_ebv` + `ebv_source.coverage` + 逐对旗标「亲本EBV为子代测验近似(降权0.5)」/「亲本无EBV(按0计)」——EBV 来源显式可查绝不静默降级。e2e 5 组断言 + 回归 A6/亲缘/batch/gaps_base 零回归 + HTTP 核验(真实数据 coverage={direct:0,progeny:0,missing:12})。详见规格 §8.29 / `桃育种系统统计引擎实施记录.md` §18.7。);⑲ **数据质量报告「离群值」方向语义错位修复(2026-08-05,用户报告 §8.23 数据质量端点 IQR/MAD 把任何偏离分布的值都标「离群株」,与育种目标方向错位——正向偏离正是要选的精英单株,核实确认 + 拍板落地)**——`data_quality_report` 方向感知分流:按 `bre_trait.direction`(desc/None=高值优、asc=低值优)判定 `desirable_high`,IQR/MAD 命中的株按偏离侧**双通道分类**(`is_high=v>med`、`is_elite=is_high==desirable_high`):**同向偏离 → `extreme_candidates` 极值候选(精英)**(class=extreme_candidate)/ **反向偏离 → `outliers` 疑似录入错误**(class=outlier,高值优的异常低值、低值优的异常高值),两表互斥;返回体加 `direction` + `extreme_candidates` + `summary.n_extreme_candidates`、`has_warning/n_outliers` 语义收紧为疑似录入错误计数;前端数据质量 tab 改双表(精英 success / 错误 danger)+ 选育方向指示 + 无误报绿色 info alert。e2e 17 株断言(精英 16.0 进 extreme_candidates/side=high、错误 2.0 进 outliers/side=low、互斥不串表)+ 回归 A6/亲缘/batch/gaps_base 零回归 + HTTP 核验(asc 裂果率 bB_DIR5668 与 desc 果重 dA_DIR5668 方向/双表/计数经真实端点正确返回,真实数据无极端值→双表空无误报)。详见规格 §8.30 / `桃育种系统统计引擎实施记录.md` §18.8。);⑳ **ΔG 投影假设声明 + 工程健壮性打磨(2026-08-05,用户报告两点均非功能 bug,核实确认 + 拍板落地,e2e 验证通过)**——① **ΔG 投影假设显式声明**:`genetic_gain` 旧实现 `next_pheno_mean = pheno_mean ± dg` 隐含「环境均值恒定」,年际气候漂移会使投影偏;属投影演示性质,返回体新增 `note` 假设声明(环境均值恒定、未计入年际气候漂移与栽培措施变化;ΔG=k·r_g·σ_A 仅含遗传增益分量,next_pheno_mean 为投影演示值而非实测预测),前端 gain tab info alert 展示;② **空间模型缺坐标优雅降级**:`run_ablup(spatial=True)` 旧实现任一观测树缺 row/col 即整批 ValueError,现改为**剔除缺坐标株表型(保留系谱 → EBV 仍由系谱预测)、仅全缺坐标才整批 409**,剔除数显式写批次 note「缺坐标剔除N株表型(EBV由系谱预测)」、input_hash 用剔除后子集;③ **numpy 工程清理**:7 处函数体内重复 `import numpy` 收敛为模块级单次 + `data_quality_report` 逐株 flag 循环改 numpy 布尔掩码向量化(输出逐字节不变)。e2e spatial 降级场景(5 株含 1 缺坐标 → method=AR1×AR1/train_n=4/note 含剔除/5 株均有 EBV)+ A6 note 断言 + 回归 spatial/A6/亲缘/batch/gaps_base 五套零回归 + vue-tsc EXIT=0 + HTTP 核验(genetic-gain 返回 note、data-quality 返回 direction)。详见规格 §8.31 / `桃育种系统统计引擎实施记录.md` §18.9。);㉑ **A–F 六项功能级缺口·批1 父本验证 + 近交衰退(2026-08-05,用户全模块 grep 实证 A–F 六项缺口均为「有无/深度」问题、非正确性 bug,逐项复核确认 + 拍板「全部六项分批落地」;批1 独立 e2e + 全量回归零回归)**——**A 父本验证(consistency 不落库)**:① `pollination` 花粉来源一致性——`_validate_pollen_parent`(create/update 统一):花粉批次声明采集父本树(pollen.male_tree_id)时授粉声明父本树须一致否则 409(含「不一致」文案),批次无采集父本(混合/外地)或授粉未声明放行不武断;② `tree` 显式亲本 vs 组合父母本警示——`_resolve_parents` 返回 `(dam,sire,warning)`:显式 dam/sire 偏离组合 female/male_parent 时返回体 `warning`「显式母本/父本与组合母本/父本不一致(以显式为准)」(双侧/单侧聚合),**warning 不阻断**(tree 可独立建,武断 409 误伤),schema 只读字段不落库;③ `cross_combination` 同质自交门禁——`_check_parent_roles` 中 `female_parent_id==male_parent_id` → 409「母本与父本同为「{名}」,自交组合请确认」(create/update 生效亲本统一,与 S-等位 409 语义一致);**C 近交衰退分析(计算端点不建表)**——`POST /statistics/inbreeding-depression`(入参 trait_id/trait_code + year/trial_study_id 可选 + min_n=10):`blup.relationship_matrix` 的 F=A_ii−1(按树,**非近交树从 individuals 默认 F=0 兜底**否则被排除)+ trait 树级表型 group_by(tree_id) 均值 → **纯 numpy lstsq 回归 phenotype~F**——`sufficient/reason/n_obs/mean_F/max_F/direction/regression{intercept,slope,r2,t_slope,depression_rate,has_depression,interpretation}/high_inbreeding`(F 降序前 10 含 tree_no/value);`has_depression=slope<0 且 t_slope≤−1.645`(单侧 95% 大样本近似);n候选/空候选/λ<0 409;**E MABC 标记辅助回交**——`POST /statistics/mabc-progress`(纯计算不落库):**前景选择**复用 `_mas_hit_map`(五语义、stage 无关、童期幼苗可用)`foreground_pass`=命中≥阈值;**背景基因组恢复率**对 background 面板逐标记候选 vs 轮回亲本「纯合剂量一致」比例(`_pure_homologous`,杂合不计)均值=恢复率 %;**回交代建议**按 `_BC_LADDER`(F1→BC1→…→BC5→自交固定)+ `_MABC_GENERATIONS`:晋级下一代 / 再回交一代 / 恢复无法评估(轮回亲本缺背景基因型)补测后判定 / 前景未通过;输出 per_tree{前景命中/恢复率/bc_generation/reason} + `recommended`(前景通过按恢复率降序)+ summary + warning;门禁空候选/空面板/generation 非法/background_target∉(0,100]/轮回亲本不存在 409。e2e `ocs` 17 断言(Σc/c≥0/λ 梯度 avg_kinship 单调非增 1.0→0.5157→0.3211→0.2685/λ=0 退化=贪心/partial+warning/三 409)+ `mabc` 23 断言(前景 5pass+1fail/恢复复算 100·75·75·0/reason 晋级·再回交·前景未通过/recommended 恢复率降序/童期 C6/轮回亲本缺基因型 None+warning/六 409)+ 回归 molrigor/gwas/mating_kinship/mating_ebv/gblup_cv/statsflow/spatial/busflow/gaps_base/b8/prediction_rigor/audit_fix 全套零回归 + vue-tsc EXIT=0 + HTTP 核验。`_DATA_VERSION` v2.24。详见规格 §8.34 / `桃育种系统统计引擎实施记录.md` §18.12。);㉔ **A–F 六项功能级缺口·批4 分子亲本验证 + 试验设计深度(2026-08-05,用户实证 grep 指出 genotype_call 对 mendelian/parentage 零命中 + trial_design 仅 RCBD,逐项复核确认 + 拍板「平方格子+一般循环回退」,独立 e2e + 全量回归零回归)**——**A 孟德尔/亲本一致性检验**(`GET /bre/genotype_call/mendelian/check`,纯计算不落库):子代树样本基因型 vs 声明亲本(tree.dam_id/sire_id→germplasm)逐标记 union 规则(子代等位集 ⊆ dam∪sire 并集)判兼容,单株兼容率=兼容/信息位点、rate0→has_warning);系谱消费方断开隔离树亲本链(退化为 founder 表型保留:`_build_pedigree`/`run_ablup`/`inbreeding_depression`/`kinship_matrix`/`run_cv`/`_germplasm_pedigree`,GBLUP/ssGBLUP 基因型样本不受影响);**N2 统计引擎全量数值真值校验**:新探针 `e2e_stats_numerics_20260805.py` **ok=101 fail=0** 逐段独立 ground-truth 对照(blup/gxe/block/spatial-aniso/gblup/ssgblup/bivariate/multi/FW/ammi/rcbd/diallel/ocs/UPGMA/ΔG/data-quality/kinship 全验证),探针自身两处 fixture 缺陷修复(低 h² 小样本 r_g 假收敛换高遗传力良态设计、B4.2 硬编码 tree_id),**引擎本体零缺陷**;`_DATA_VERSION` v2.26、`blup.ENGINE_VERSION` 1.5.0。详见规格 §8.37 / `桃育种系统统计引擎实施记录.md` §18.15。);㉖ **批6 规划↔目标↔试验 可追溯性闭环(2026-08-05,用户全代码库 grep 实证 `bre_plan` 彻底孤立根表——零传入外键、`bre_trial` 无 plan_id/target_id、无 project/program 表;拍板「加试验挂目标」最小双链,独立 e2e 探针 17 断言 + 全量回归零回归)**——**三可空 FK 双链闭合**:`bre_target.plan_id` + `bre_trial.plan_id`(目标链 plan→target→cross_combination→tree)+ `bre_trial.target_id`(试验链 plan→trial→trial_study→tree),weld SQL 幂等、target/trial 三件套透传 + get_list 解析 `plan_name`/`target_name`;**父校验**(target/trial create 坏 plan_id/target_id → 409,None 跳过——孤目标/独立试验仍可建)+ **删除守卫**(plan.delete 有 target/trial 子 409、target.delete 有 combo/trial 子 409);**计划可追溯端点** `GET /bre/plan/trace/{id}`(plan 基础 + targets[] 组合株数 + trials[] 试验点株数 + totals,软删过滤、孤目标不入链、不存在 409);前端 target/trial 「所属计划/目标」select + plan 页「追溯」弹窗。验证:`e2e_plan_trace_20260805.py` 17 断言全过 + 全量回归零回归(含审计切面残留检查限定 `entity_type` 根治跨表撞 id 误报)+ vue-tsc EXIT=0 + HTTP 核验;`_DATA_VERSION` v2.27。详见规格 §8.38 / `桃育种系统统计引擎实施记录.md` §18.16。);㉗ **批7 统计引擎全量十模块 golden 复查·系谱键空间错位修复(2026-08-05,用户质疑 GBLUP/ssGBLUP、genetic_corr、type_b、cluster、stability、OCS、MABC、inbreeding_depression、trial_design、mendelian 十新建模块缺逐模块数值校验(golden-test 缺失)、历史上 G–L 静默失效都靠校验暴露,拍板「全量十项逐一复查」)**——复查确认 **8/10 已 golden**(GBLUP/ssGBLUP、cluster、stability、OCS、MABC、inbreeding_depression、trial_design、mendelian 既有探针已具严格数值断言),**type_b 与 genetic_corr 补齐 golden 缺口**——审计深处暴露**系谱键空间错位真 bug**:`run_type_b_heredity`/`correlation_matrix`(mtblup)/`run_genetic_corr` 四个服务路径把 **int `tree_id` 表型键**传给 `mtblup.solve_bivariate`,而 `_build_pedigree` 系谱用 **str `"t{id}"`**,`blup.solve` 把系谱外个体自动并入 base(无关 founder)→ **系谱被静默丢弃**(平衡数据同株跨环境掩盖;**分裂家系数据**无同株跨环境、靠全同胞链桥接暴露——int 键 → r_B/r_g 锁死 ±0.99 平剖面端点假收敛假阴性)。修复:共享 helper `_tree_individual_keys`(int→`"t{id}"` 对齐系谱键空间)收口**四个调用点**(`_genetic_corr_matrix`/`_smith_hazel_index` mtblup 分支/`run_genetic_corr`/`run_type_b_heredity`),`run_ablup` 早已正确不受影响。新探针 `e2e_rg_golden_20260805.py` **ok=21 fail=0**(20 founder×10 全同胞家系×3 同胞=30 株、3 站点、高遗传力 h²≈0.89:P0 诊断 str 键恢复 r_B=0.7+/int 键锁死 −0.99;P1 type_b 分裂家系 r_B(1,2)>0.5 + **n_common=0**(无共有树、系谱桥接生效直接证据);P2 correlation 平衡 r_g(A,B)>0.65/AC,BC<0.4/判别间隙>0.3/h²∈(0,1);P2b 分裂家系 discriminator r_g(S1,S2)>0.5)。验证:`e2e_statsflow`(单家系 fixture 对键 bug 不敏感 15 断言零变化)+ `e2e_advanced_mtblup` 回归通过 + **全量回归零回归**(60 套件,含版本断言同步)+ vue-tsc EXIT=0 + 后端重启 HTTP 核验(type-b-heredity / genetic-corr/run 分裂家系判别经真实端点)+ 备份镜像 `Temp/claude/backup_keyspace_20260805/`;`_DATA_VERSION` v2.28。详见规格 §8.39 / `桃育种系统统计引擎实施记录.md` §18.17;㉘ **回归基建:统一回归 + 高风险模块正式 tc 套件(2026-08-05,用户三点质疑——tc 零覆盖分析模块 / 批7 无 golden / 新模块算偏当正常输出(G/I 静默失效同款隐患)——核实全部成立,拍板「两者都要」;纯测试基建不 bump 版本)**——`run_regression.py`(仓库根)顺序串跑 `Temp/claude/e2e_*.py` + `backend/scripts/test_bre_analysis_*_tc.py` = **63 测试**、每测试独立子进程 log 到 `regression_logs/`、EXIT 汇总、`DPB_PYTHON` 覆盖解释器、utf-8 防 GBK;**4 个正式 tc 套件**(TestClient 真实 API + golden 数值断言,高风险模块必测):`test_bre_analysis_ssgblup_tc.py` ok=14(非基因型株 GBLUP EBV=0 / ssGBLUP EBV≠0——H⁻¹ 拼接判别)、`test_bre_analysis_ocs_tc.py` ok=23(投影梯度收敛/λ 单调/c'Ac/λ=0 退化)、`test_bre_analysis_mabc_tc.py` ok=24(前景/背景恢复率/晋级/童期)、`test_bre_analysis_mendelian_tc.py` ok=9(POST run 落库 + 隔离注册表 + 阈值 0.0 可逆);教训:HTTP schema 校验 → 422 / 服务层 CustomException → 409、tc 须显式 import UserModel、隔离断言时序、启动残留预清理 `_wipe`。全量回归 58/59(gaps_engines 批量卡死单跑 RC=0 确认);备份镜像 `Temp/claude/backup_test_rigor_20260805/`;交付流程:改统计引擎/业务链后端后跑 `python run_regression.py` 全量 EXIT=0。详见规格 §8.40 / `桃育种系统统计引擎实施记录.md` §18.18。);㉙ **回归基建 2:分析模块 tc 补齐(5 套)+ pytest 接入 + 本机 CI stage(2026-08-05,用户对 4 套 tc 认可后拍板补齐 genetic_corr/type_b_heredity/inbreeding_depression/trial_design/run_stability 五个静默失效风险较低模块,v8 验收标准定为「CI 跑通 pytest 全绿」;纯测试基建不 bump 版本,`_DATA_VERSION` 保持 v2.28)**——**补 5 套正式 tc**(`backend/scripts/test_bre_analysis_{genetic_corr,type_b,inbreeding,trial_design,stability}_tc.py`,TestClient 真实 API + `-X utf8` + `_wipe()` 残留预清理):genetic_corr ok=19(平衡 3 性状 r_g(AB)>0.65/间隙>0.3 + **分裂家系判别** r_g(S1,S2)>0.5 且 n_common=0,独立种子 SEED_S=3)、type_b ok=18(3 站点分裂家系 r_B>0.5 且 n_common=0,复刻 tY-first rng 序→0.990)、inbreeding ok=44(方向感知:衰退 slope<−40/无衰退/上升 slope>30 + deep 系谱 F=0.125 + min_n insufficient)、trial_design ok=25(增广对照 block_no=None 不落库 + α-格子平方 λ≤1/非素数告警/一般贪心 + **block_no=rep*100+block DB 读回** + 四 409)、stability ok=16(FW b≈2/1/0 + flag_stable C1=True + AMMI ASV rank + engine_version 落库);**pytest 接入**——`backend/tests/test_stat_analysis_tc.py` 9 套 @pytest.mark.pg + subprocess 串跑真实脚本、skipif 探测真实 PG16+Redis(**protocol=2**——redis-py 7.x 默认 RESP3 对老 WSL redis-server 报 ResponseError,与 app redis_connect 对齐)、env 显式构建剥 conftest 污染解析 .env.dev 可 TC_CI_* 覆盖;pyproject 注册 pg marker、conftest collect_ignore_glob 排除 scripts;**本机 CI stage `run_ci.py`**(仓库根,step 0 探测 PG+Redis 失败指引 / step 1 `pytest -m pg` / step 2 EXIT=0「CI 全绿」,日志 Temp/claude/ci_logs/;不做 GitHub Actions——全工程无 git 仓库);验证 5 套 EXIT=0(122 断言)+ `pytest -m pg` 9 passed(146s)+ skipif 9 skipped 不红 + run_ci EXIT=0 + run_regression glob 自动纳入;备份镜像 `Temp/claude/backup_analysis_tc2_20260805/`;交付流程:v8 验收 = `python run_ci.py` EXIT=0。详见规格 §8.41 / `桃育种系统统计引擎实施记录.md` §18.19。);㉚ **O2–O4 统计方法学广度(2026-08-05,用户系统核对统计引擎能力后确认三项 P2 方法学缺口——幼年–成年遗传相关 / 非线性基因组预测 / 约束选择指数,均零命中、不影响当前业务正确性属方法库广度,拍板「O2–O4 先落地」+ 两决策:① O4 用经典 Kempthorne–Nordskog =0 闭式解(纯 numpy 不迭代)② 前后端同步扩展;`_DATA_VERSION` v2.28 → v2.29、genomic 1.4.0,无新表)**——**O2 幼年–成年遗传相关**:`_values_by_tree_env` 加 env_dim=stage 分支(`coalesce(obs.stage, TraitModel.stage)` 分组)+ `run_type_b_heredity` env_dim 校验扩 stage——发育阶段当"环境"逐对 `mtblup.solve_bivariate` REML 精确估 r_g(>0.5 表示童期可借力成株、早期间接选择有效),复用 `bre_type_b_result` 不建表,前端 Type-B 下拉加 stage;**O3 非线性基因组预测**:`genomic.py` 1.4.0 加 `solve_rrblup`(岭回归逐标记 u=(Xc'Xc+λI)⁻¹Xc'yc,**与 GBLUP 数学对偶**(同数据 EBV 逐位吻合)、输出 marker effects)+ `solve_bayesb`(BayesB Gibbs 可变选择 δ_j~Bernoulli(1−π)、**确定性 seed 可复现**)+ `kfold_cv_genomic` dosage 分派 rrblup/bayesb(实证 CV);`run_gblup` method 扩 rrblup/bayesb + seed 透传落 PredictionModel(method=RRBLUP/BayesB)+EBV 行、`run_cv` GS 分支 KFCV/RRBLUP·KFCV/BayesB;**O4 约束选择指数**:`_smith_hazel_index` 加 restricted(**Kempthorne-Nordskog Lagrange 闭式投影** b=P⁻¹·G·(I−M)·a、M=C'(C·G·P⁻¹·G·C')⁻¹·C·G·P⁻¹·G → 受限性状 **ΔG=0**;对照旧公式 M=C'(C·P⁻¹·C')⁻¹·C·P⁻¹ 实测 ΔG=4.46 不满足证明修正必要)+ economic_weights(绝对尺度不归一化、可与 restricted 叠加、asc 翻转),method 校验扩 zsum/smith_hazel/restricted、落库 SI_RES;**顺手修复 tc 暴露的既有缺口**:`cv_detail` 折明细缺 `cv_result_id` → 任意 GET /cv/{id} 400(既有 tc 未触达静默失效)——补上可查;验证:e2e 探针 19 断言(对偶逐位 1.42e-13 / BayesB 可复现 / 受限 ΔG=0 机器精度 −8.88e-16 / O2 真实 PG r_g=0.6145)+ 正式 tc 第 10 套 `test_bre_analysis_o234_tc.py` ok=41 fail=0(r_g=0.8995 / bayesb 同 seed 两次 EBV 逐位一致 / rrblup·GBLUP EBV 相关 1.000 / b_受限=0.0 / economic desc>0·asc<0 / 三 409)+ 全量回归零回归 + `run_ci.py` EXIT=0;备份镜像 `Temp/claude/backup_o234_20260805/`。详见规格 §8.42 / `桃育种系统统计引擎实施记录.md` §18.20。;㉛ **O1 花期数据消费补强(2026-08-06,用户系统核对花期字段零消费——`bre_germplasm.flowering_period` 等从未被任何统计/决策逻辑消费,核实确认 + 定性 P1 数据消费补强 + 辅助信息(非 S-allele 级硬错误防呆),拍板三档位落地;`_DATA_VERSION` v2.29 → v2.30,无新表)**——① **花期数据建模升级(结构化区间,前置)**:`bre_germplasm` 加双 Date 列 `bloom_start_date`/`bloom_end_date`(weld SQL 幂等 + COMMENT),`flowering_period` 5 档字典保留作粗粒度 fallback;model/schema/service(Excel 导入导出/模板 header 同步)+ 前端 germplasm 表单改两个独立 date 字段直绑(useCrudForm 无提交前 transform → 弃 daterange,与 acquisition_date 同模式零转换)——花期字段首次被消费;② **mating_recommend 花期重叠软警示(不硬阻断)**:`_bloom_overlap`(结构化区间月日年际比较 `_md()` 取 tm_yday 优先;缺区间 fallback 5 档 `_BLOOM_TIER_ORDER` 相邻/同档视为重叠)四态——overlap(重叠可同期授粉,**不进 flags[] 不阻断**)/ store(父本先开花需采粉贮藏)/ offset(母本先开花注意柱头可授期)/ unknown(缺花期数据提示补录),pairs 加 bloom_status/bloom_msg、非重叠三态追加 flags[](前端红色 tag);③ **授粉窗口规划消费 bre_pollen**:新端点 `GET /bre/pollen/window-plan?window_start=&window_end=`(`window_plan` 服务方法,窗口反向 409)——花期种质 `_project_bloom` 结构化区间按年际月日投影到窗口起始年(3-4 月单年窗口假设)、缺区间 fallback `_BLOOM_TIER_SPAN` 5 档代表区间并标注「粗粒度」;**窗口覆盖三态** covered(整段有批次 collect≤bs and expiry≥be)/partial/none(n_need_pollen 计 none);**库存五态** full/expiring(窗口内到期)/entering(窗口内采集)/expired(窗口前失效)/not_started;前端花粉页「授粉窗口规划」卡(daterange + 生成 + 缺粉 alert + 花期覆盖表 + 库存状态表,默认 03-01~04-30)。验证:e2e 探针 `e2e_o1_bloom_20260805.py` 4 段全断言(花期建模双 Date 落库 / 6 种质 15 对四态(区间三类 + 档位 store + 混合对 unknown)/ 窗口覆盖三态 + 档位投影 + 缺粉集合 / 库存五态 / 反向 409)+ vue-tsc EXIT=0 + 全量回归零回归 + `run_ci.py` EXIT=0 + HTTP 核验;备份镜像 `Temp/claude/backup_o1_bloom_20260805/`。详见规格 §8.43 / `桃育种系统统计引擎实施记录.md` §18.22。;**2026-08-06 追加**:业务链路与数据模型说明书 v1.1 已与代码同步(O1 花期 / N4 血缘 / 亲本校验 / 世代闭环 / §九 QA 与 CI)。 --- diff --git a/doc/桃育种观测业务梳理.docx b/doc/桃育种观测业务梳理.docx new file mode 100644 index 0000000000000000000000000000000000000000..177f8b0f1264af2644659cbfb023f1ae2c053b24 GIT binary patch literal 43837 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 Jk;Q*}`yYBEmqq{p literal 0 HcmV?d00001 diff --git a/frontend/web/src/components/forms/composables/useFormBase.ts b/frontend/web/src/components/forms/composables/useFormBase.ts index 4a69d77..7fd9247 100644 --- a/frontend/web/src/components/forms/composables/useFormBase.ts +++ b/frontend/web/src/components/forms/composables/useFormBase.ts @@ -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 => { 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; +}; + /** * 获取插槽 —— 过滤掉未定义的插槽 */ diff --git a/frontend/web/src/components/forms/fa-form/index.vue b/frontend/web/src/components/forms/fa-form/index.vue index 48a08f9..722218e 100644 --- a/frontend/web/src/components/forms/fa-form/index.vue +++ b/frontend/web/src/components/forms/fa-form/index.vue @@ -40,8 +40,10 @@ @update:model-value="setFieldValue(item.key, $event)" v-bind="getProps(item)" > - -