276 lines
16 KiB
Python
276 lines
16 KiB
Python
# -*- 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()
|