#!/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())