Files
dpb/backend/scripts/gen_obs_doc.py
T
34047007@qq.com e937fc930c checkpoint: real-scale 数据链 + 统计引擎 + ABLUP 稀疏性能修复
init 后首次落盘,累计工作:
- 数据侧:simulate_breeding_data real-scale 3.5万树重建 + 分层观测
- 引擎侧:ABLUP 稀疏生产档 EM-REML(blup 1.6.0/1.7.0);性能倒挂修复——
  系谱闭包收口为 BFS 可达祖先 + N_EXACT 3000 对齐 N_SUBSAMPLE(选中 200~3000 树
  不再顶进数小时精确迹尾),A/B 数值等价实证 + 守卫探针
- 前端:观测过滤 + 表单/HTTP 工具链完善
- 文档:业务链/观测梳理 + 引擎实施记录 + 规划对照总表
- gitignore:排除 Temp/调试脚本/一次性验证输出
2026-08-07 08:03:10 +08:00

276 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""生成《桃育种观测业务梳理》Word 文档(微软雅黑,含表格与页码)。
运行:C:/ai/miniconda3/envs/dpb/python.exe backend/scripts/gen_obs_doc.py
输出:doc/桃育种观测业务梳理.docx
"""
import os
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
FONT = "微软雅黑"
DARK = RGBColor(0x1F, 0x3B, 0x5C)
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.path.join(ROOT, "doc", "桃育种观测业务梳理.docx")
def set_run(run, size=10.5, bold=False, color=None):
run.font.name = FONT
run.font.size = Pt(size)
run.font.bold = bold
if color is not None:
run.font.color.rgb = color
run._element.rPr.rFonts.set(qn("w:eastAsia"), FONT)
def setup_styles(doc):
normal = doc.styles["Normal"]
normal.font.name = FONT
normal.font.size = Pt(10.5)
normal._element.rPr.rFonts.set(qn("w:eastAsia"), FONT)
for i in range(1, 7):
st = doc.styles["Heading %d" % i]
st.font.name = FONT
st.font.bold = True
st.font.color.rgb = DARK
st.font.size = Pt(max(16 - i * 1.5, 11))
st._element.rPr.rFonts.set(qn("w:eastAsia"), FONT)
def add_title(doc, text, subtitle):
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run(text)
set_run(r, size=20, bold=True, color=DARK)
p.paragraph_format.space_after = Pt(4)
p2 = doc.add_paragraph()
p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
r2 = p2.add_run(subtitle)
set_run(r2, size=11, color=RGBColor(0x60, 0x60, 0x60))
p2.paragraph_format.space_after = Pt(14)
def heading(doc, text, level):
p = doc.add_heading(text, level=level)
for r in p.runs:
set_run(r, bold=True)
p.paragraph_format.space_before = Pt(10 if level <= 2 else 6)
p.paragraph_format.space_after = Pt(4)
return p
def para(doc, text, size=10.5, bold=False, style=None, space_after=6):
p = doc.add_paragraph(style=style)
if text:
r = p.add_run(text)
set_run(r, size=size, bold=bold)
p.paragraph_format.space_after = Pt(space_after)
p.paragraph_format.line_spacing = 1.35
return p
def shade_cell(cell, hexcolor):
tcPr = cell._tc.get_or_add_tcPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), hexcolor)
tcPr.append(shd)
def table(doc, headers, rows, col_widths=None, size=9.5, header_fill="DCE6F1"):
t = doc.add_table(rows=1, cols=len(headers))
t.style = "Table Grid"
t.alignment = WD_TABLE_ALIGNMENT.CENTER
for i, h in enumerate(headers):
cell = t.rows[0].cells[i]
p = cell.paragraphs[0]
r = p.add_run(h)
set_run(r, size=size, bold=True)
p.paragraph_format.space_after = Pt(0)
shade_cell(cell, header_fill)
for row in rows:
cells = t.add_row().cells
for i, v in enumerate(row):
p = cells[i].paragraphs[0]
r = p.add_run(str(v))
set_run(r, size=size)
p.paragraph_format.space_after = Pt(0)
if col_widths:
for i, w in enumerate(col_widths):
for row in t.rows:
row.cells[i].width = Cm(w)
sp = doc.add_paragraph()
sp.paragraph_format.space_after = Pt(2)
return t
def add_page_number(section):
footer = section.footer
p = footer.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p.add_run()
f1 = OxmlElement("w:fldChar"); f1.set(qn("w:fldCharType"), "begin")
it = OxmlElement("w:instrText"); it.set(qn("xml:space"), "preserve"); it.text = "PAGE"
f2 = OxmlElement("w:fldChar"); f2.set(qn("w:fldCharType"), "end")
r._r.append(f1); r._r.append(it); r._r.append(f2)
set_run(r, size=9, color=RGBColor(0x80, 0x80, 0x80))
def main():
doc = Document()
setup_styles(doc)
sec = doc.sections[0]
sec.page_width, sec.page_height = Cm(21.0), Cm(29.7)
sec.left_margin = sec.right_margin = Cm(2.2)
sec.top_margin = sec.bottom_margin = Cm(2.2)
add_page_number(sec)
add_title(doc, "桃育种系统 · 育种观测业务梳理", "2026-08-06 · 内部梳理稿 v1.0")
# 1 背景与目的
heading(doc, "1 背景与目的", 1)
para(doc, "本次梳理源于一个具体问题:单株评价的新增/编辑表单中,「基本信息」之外的六个标签页(基本鉴定、果实外观、果皮、果实大小、果肉、果核)全部显示“暂无性状”,无法输入和修改。")
para(doc, "排查后发现这并非单个页面 bug,而是性状字典、观测数据模型与前端消费三者之间系统性不一致的暴露:库里现存 20 条统计性状的分类(产量/品质/果形/营养/抗性/物候),与表单六个标签页所用的官方描述字典(43 条,六大类)不匹配,导致标签页空转。")
para(doc, "本文档把围绕“观测”的现状梳理清楚:数据模型、核心表关系、三类观测切片、两条繁殖路径、扩繁追溯、性能、性状字典现状与断裂、已知缺口与待定决策,为后续方案设计提供一份完整、可复用的依据。")
# 2 长表设计
heading(doc, "2 观测数据模型:长表设计", 1)
para(doc, "核心观测表为 bre_trait_observation,采用“长表”(tidy)设计:每(单株 × 性状 × 年份)一行,一个性状一条记录。")
table(doc,
["对比维度", "长表(当前)", "宽表(一性状一列)"],
[["数据结构", "每行一个观测值 + trait_id 外键", "每个性状一个列"],
["性状可扩展", "加性状 = 加字典行,无需改表", "加性状 = 加列,需 DDL 迁移"],
["页面/统计解耦", "页面按 trait 过滤即可", "列结构绑定页面"],
["硬编码风险", "低(性状走字典)", "高"],
["记录规模", "单表 358 万", "同规模列更多"]],
col_widths=[3.5, 6.5, 6.5])
para(doc, "为什么必须长表:育种性状集合逐年演进(新性状、新标准、分子性状),宽表每次演进都要迁移;长表加一行字典即可,统计引擎按 data_type=='numeric' 直接消费。代价是记录量大,但见第 6 节,性能可用索引兜住。")
# 3 四张核心表
heading(doc, "3 四张核心表的关系", 1)
heading(doc, "3.1 单株评价 bre_tree_evaluation(主表)", 2)
para(doc, "一棵单株在某一年的一次评价(“头”)。记录评价年份、总评、负载量、评价人等信息。一个评价头对应一批性状观测明细。")
heading(doc, "3.2 性状观测 bre_trait_observation(明细表)", 2)
para(doc, "评价的“细目”,全系统唯一承载 358 万条观测的表。每行一个(单株 × 性状 × 年份)观测值,三态值列并存:")
para(doc, "· value_numeric —— 数值型(消费面:统计引擎)", style="List Bullet")
para(doc, "· value_text —— 文本 / 等级描述", style="List Bullet")
para(doc, "· value_date —— 日期型(物候等)", style="List Bullet")
heading(doc, "3.3 通用观测 bre_observation(独立轻量记录)", 2)
para(doc, "与“单株评价”解耦的散点式观测(园区环境、非评价场景的随记),trait 可空,不进主细结构。")
heading(doc, "3.4 性状字典 bre_trait", 2)
para(doc, "统一性状字典:code、中文名、unit、category、data_type、stage、方向(升/降)、是否入 EBV、h²、权重等。消费面是 data_type=='numeric' 的统计引擎。")
heading(doc, "3.5 主细关系", 2)
table(doc,
["要点", "约定"],
[["基数", "bre_tree_evaluation 1 —— N bre_trait_observation"],
["外键", "evaluation_idCASCADE(删评价头连带删明细)"],
["唯一约束", "UNIQUE(evaluation_id, trait_id):一个评价下同一性状只允许一条"]],
col_widths=[3.5, 13.0])
# 4 三类切片
heading(doc, "4 观测数据的三类业务切片", 1)
para(doc, "观测行按「是否有评价头(evaluation_id)× stage」切为三类,合计 3,583,584 条。")
table(doc,
["切片", "判定", "语义", "记录数", "备注"],
[["A 随评", "evaluation_id 非空", "随单株评价录入的性状观测", "731,904", "45,744 个评价头 × 16 条/头,0 孤儿"],
["B 童期", "evaluation_id IS NULL + stage='juvenile'", "童期幼树的观测", "1,148,160", "无评价头,独立批次"],
["C 克隆苗", "evaluation_id IS NULL + stage='evaluation'", "扩繁产出无性系苗的观测", "1,703,520", "每克隆 4 棵 ramet"]],
col_widths=[1.8, 5.0, 4.4, 2.2, 4.0])
para(doc, "A 与 B/C 的判定只差一个字段是否为空,是历史演进留下的两类形态,方案阶段需决策是否统一(见第 9 节决策 3)。")
# 5 育种流程全景
heading(doc, "5 育种流程全景", 1)
heading(doc, "5.1 有性繁殖路径(实生苗)", 2)
para(doc, "杂交(cross_combination)→ 采粉(pollen)→ 授粉(pollination)→ 种子批(seed_lot)→ 种子处理(seed_treatment)→ 育苗(seedling)→ 种植(planting)→ 单株(tree)。种子苗是实生后代,遗传上每株独一无二,是选育的主体。")
heading(doc, "5.2 无性繁殖路径(克隆苗)", 2)
para(doc, "入选单株(selection_result)→ 晋级为克隆(tree_generation='clone')→ 扩繁(propagation,嫁接/扦插)→ 产出无性系苗 ramet(每克隆 4 棵)→ 克隆苗观测。克隆苗与母株基因型相同,用于性状复测、对比试验、示范与推广。")
heading(doc, "5.3 育苗 vs 克隆", 2)
table(doc,
["维度", "育苗(有性)", "克隆扩繁(无性)"],
[["后代来源", "种子批 seed_lot", "接穗 scion_source(原株)"],
["遗传构成", "实生后代,各不相同", "与母株基因型相同"],
["用途", "选育新材料", "复测 / 示范 / 推广"],
["观测归属", "单株 / 童期", "C 类克隆苗观测"]],
col_widths=[3.5, 6.5, 6.5])
heading(doc, "5.4 扩繁追溯", 2)
para(doc, "扩繁批次(bre_propagation)通过两个外键形成完整链路:")
para(doc, "· scion_source_id —— 接穗来源(germplasm 或 tree 原株)", style="List Bullet")
para(doc, "· produced_clone_id —— 产出的克隆(4 棵 ramet", style="List Bullet")
para(doc, "· 编号自描述:tree_no 形如“{品种码}-{序号}R{棵序}”,仅凭编号即可追溯血缘与棵序。", style="List Bullet")
para(doc, "追溯链:接穗原株 → 扩繁批次 → 产出克隆 → ramet 四棵 → 各棵观测。")
para(doc, "验证结果:扩繁 16,800 条,100% 有来源与产出,逐条可回查,无孤儿记录。")
heading(doc, "5.5 审定推广后的边界", 2)
para(doc, "品种审定(released)后,观测止于推广环节;生产果园的商业化栽植不再进入观测表。当前无销售/推广数据的落点(见缺口 C)。")
# 6 性能
heading(doc, "6 性能保障", 1)
para(doc, "记录量 318 万,表体约 990 MB,11 个索引。实测:")
table(doc,
["场景", "实测耗时", "结论"],
[["前端按单株分页取观测", "≈ 0.39 ms", "数据库层面不是瓶颈"],
["全性状统计取数", "≈ 802 ms", "索引足以支撑"]],
col_widths=[6.5, 3.5, 6.5])
para(doc, "统计引擎的规模瓶颈已随稀疏重构消除:索引化设计矩阵(无稠密 Z)、稀疏 A⁻¹(无稠密 A)、共轭梯度求解,全程 O(n) 内存。个体数 ≤ N_EXACT=6000 用精确迹二分求 λ*(逐列 CG 精确 tr(A⁻¹C²²));> N_EXACT 用随机子样本(≤3000 观测 + 祖先闭包)REMl 估计方差组分(抽样 SE h² ≈ ±0.04,h²/σ² 报告值视为近似区间),再全群 MME 于子样本 λ 下解 EBV。36,720 棵树单性状 ABLUP 在 14 GiB 本机完整跑通不 OOM。")
# 7 性状字典现状与断裂
heading(doc, "7 性状字典现状与断裂", 1)
heading(doc, "7.1 现状", 2)
para(doc, "库里现存 20 条统计性状(分类为产量/品质/果形/营养/抗性/物候),由 ensure_reference 幂等回填;原始 43 条官方描述字典(bre_trait_seed.sql,六大类:基本鉴定/果实外观/果皮/果实大小/果肉/果核)在 wipe 重建时被清空,未回填。")
heading(doc, "7.2 断裂点", 2)
table(doc,
["断裂点", "现象", "根因"],
[["单株评价表单", "6 个 tab 全“暂无性状”,无法录入", "表单按官方字典分类,库里只有 20 条 stats 分类"],
["DUS", "DUS 引用 trait_codegrowth_vigor 等)找不到性状", "描述字典未回填"],
["抗病/需冷量种子", "抗病性 / 生态适应性种子性状缺失", "wipe 未回填 bre_disease_chilling_traits"],
["通用观测下拉", "显示英文 label", "getTraitOptions 返回 label=trait_code"]],
col_widths=[3.4, 6.5, 6.5])
para(doc, "三套来源(stats seed / 官方描述 dict / DUS-抗病 seed)互相覆盖、未统一归口,是断裂的总根因。")
# 8 已知缺口
heading(doc, "8 已知缺口清单", 1)
table(doc,
["缺口", "描述", "现状", "建议方向"],
[["A 童期一年多次测定", "观测表只有 evaluate_year,一年至多一条;童期生长量真实一年多次(如春秋两次)", "无 evaluate_date", "补日期字段或次数维度"],
["B 扩繁接穗来源手填 ID", "表单 scion_source_id 为裸数字输入,靠人工记忆原株 ID", "易错、无校验", "改为下拉,限定 germplasm/tree 来源"],
["C 审定后无销售环节", "观测止于 released,推广量 / 销售无落点", "数据边界", "增加推广/销售记录(或明确不录)"],
["D 通用观测下拉英文", "getTraitOptions 返回英文 trait_code 作 label", "体验差", "返回中文名"]],
col_widths=[3.2, 6.3, 3.0, 4.0])
# 9 待定决策
heading(doc, "9 待定决策", 1)
table(doc,
["决策", "选项", "说明"],
[["决策 1:标签页结构", "官方 6 类 tab / 动态 category 分组", "决定表单录入布局,需与 43 条字典匹配"],
["决策 2:描述性状模拟值", "回填 43 条种子(含模拟值)/ 仅建字典不造值", "决定表单是否立即可用"],
["决策 3:童期观测归属", "B 童期观测归入单株评价流程 / 独立童期页面", "决定 B 类 115 万条在哪里维护"],
["决策 4:移动端采集入口", "本次接入 / 后续迭代", "倾向移动端采集,按评价保存"]],
col_widths=[4.2, 6.3, 6.0])
# 附录
heading(doc, "附录 数据口径(2026-08-06 实测)", 1)
table(doc,
["指标", "数值"],
[["观测总行数", "3,583,584A 731,904 + B 1,148,160 + C 1,703,520"],
["A 随评构成", "45,744 个评价头 × 16 条/头 = 731,9040 孤儿"],
["C 克隆苗构成", "每克隆 4 棵 ramet17 性状 × 2 年 → 136 条/克隆"],
["扩繁批次", "16,800 条,100% 有来源与产出,无孤儿"],
["表体规模", "约 1,129 MB / 9 索引"],
["前端分页查询", "≈ 0.39 ms"],
["全性状统计取数", "≈ 802 ms"],
["模拟规模", "F1 200 组合 → 约 120k 棵树"],
["ABLUP 全量内存", "稠密 Z(84000×86792) ≈ 54.3 GiB,苗高 ≈ 110 GiB → OOM"]],
col_widths=[6.5, 10.0])
doc.save(OUT)
print("OK ->", OUT)
print("size =", os.path.getsize(OUT), "bytes")
if __name__ == "__main__":
main()