Files
dpb/backend/app/api/v1/module_bre/dus/service.py
T
34047007@qq.com b95053c52c init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
2026-08-06 00:17:49 +08:00

837 lines
36 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.
import math
import statistics
from collections import defaultdict
from datetime import datetime
from typing import Any
from fastapi import UploadFile
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from scripts.breeding_stats import fdist
from app.api.v1.module_bre.germplasm.model import BreedingGermplasmModel
from app.api.v1.module_bre.trait.model import TraitModel
from app.api.v1.module_bre.trial_study.model import TrialStudyModel
from app.core.base_crud import assert_no_children, assert_parents_exist
from app.core.base_schema import AuthSchema, ImportResultSchema, PageResultSchema
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.common_util import search_to_dict
from app.utils.excel_util import ExcelUtil
from .crud import (
DusDescriptorCRUD,
DusObservationCRUD,
DusTestCRUD,
)
from .model import DusDescriptorModel, DusObservationModel, DusTestModel
from .schema import (
DusDescriptorCreateSchema,
DusDescriptorOutSchema,
DusDescriptorQueryParam,
DusDescriptorUpdateSchema,
DusObservationCreateSchema,
DusObservationOutSchema,
DusObservationQueryParam,
DusObservationUpdateSchema,
DusTestCreateSchema,
DusTestOutSchema,
DusTestQueryParam,
DusTestUpdateSchema,
)
def _is_blank(v: Any) -> bool:
return v is None or (isinstance(v, str) and v.strip() == "")
def _none_if_blank(v: Any) -> Any:
if _is_blank(v):
return None
return str(v).strip() if isinstance(v, str) else v
def _to_int(v: Any) -> int | None:
if _is_blank(v):
return None
try:
return int(float(str(v).strip()))
except (TypeError, ValueError):
return None
def _to_float(v: Any) -> float | None:
if _is_blank(v):
return None
try:
return float(str(v).strip())
except (TypeError, ValueError):
return None
def _variety_obs(rows: list[dict[str, Any]]) -> dict[int, dict[str, list[Any]]]:
"""把某品种的一组观测行聚成 {descriptor_id: {"numeric": [...], "text": [...]}}。"""
out: dict[int, dict[str, list[Any]]] = defaultdict(lambda: {"numeric": [], "text": []})
for r in rows:
did = r.get("dus_descriptor_id")
if did is None:
continue
if r.get("value_numeric") is not None:
out[did]["numeric"].append(float(r["value_numeric"]))
if r.get("value_text") not in (None, ""):
out[did]["text"].append(str(r["value_text"]))
return dict(out)
def _merge_obs(target: dict[int, dict[str, list[Any]]], rows: list[dict[str, Any]]) -> None:
for r in rows:
did = r.get("dus_descriptor_id")
if did is None:
continue
bucket = target.setdefault(did, {"numeric": [], "text": []})
if r.get("value_numeric") is not None:
bucket["numeric"].append(float(r["value_numeric"]))
if r.get("value_text") not in (None, ""):
bucket["text"].append(str(r["value_text"]))
def _text_modes(vals: list[str]) -> list[str]:
if not vals:
return []
counts: dict[str, int] = defaultdict(int)
for s in vals:
counts[s] += 1
top = max(counts.values())
return sorted([s for s, c in counts.items() if c == top])
def distinctness(
candidate: dict[int, dict[str, list[Any]]],
references: list[dict[int, dict[str, list[Any]]]],
descriptor_meta: dict[int, dict[str, Any]],
alpha: float = 0.01,
) -> dict[str, Any]:
"""DUS 特异性统计判定(纯函数,§8.18)。
- QNvalue_numeric):候选 vs 每参照单因素 ANOVA → MSE,df →
LSD = t_crit(α/2,df)·√(MSE·(1/n_cand + 1/n_ref))|均值差|>LSD 视为 distinct
误差自由度<1(无重复)→ 数据不足 pending。
- PQ/QLvalue_text):表达状态众数比较,候选众数状态 ∉ 参照众数状态集 → distinct。
返回逐描述符判定表 + 总体建议(仅 required 描述符驱动)。
"""
desc_ids = sorted(set(candidate) | {did for ref in references for did in ref})
descriptors: list[dict[str, Any]] = []
distinct_nos: list[str] = []
required_distinct: list[str] = []
pending_nos: list[str] = []
for did in desc_ids:
meta = descriptor_meta.get(did, {})
exp_type = str(meta.get("expression_type") or "QN").upper()
desc_no = str(meta.get("descriptor_no") or did)
desc_name = meta.get("descriptor_name") or ""
required = str(meta.get("required") or "0").strip() == "1"
c_obs = candidate.get(did, {"numeric": [], "text": []})
refs_obs = [ref.get(did, {"numeric": [], "text": []}) for ref in references]
row: dict[str, Any] = {
"descriptor_id": did,
"descriptor_no": desc_no,
"descriptor_name": desc_name,
"expression_type": exp_type,
"required": "1" if required else "0",
"candidate_value": None,
"ref_values": [None] * len(refs_obs),
"statistic": None,
"critical": None,
"df": None,
"distinct": None,
"note": None,
}
if exp_type == "QN":
cand_vals = [v for v in c_obs.get("numeric", []) if v is not None]
ref_val_lists = [
[v for v in r.get("numeric", []) if v is not None] for r in refs_obs
]
row["candidate_value"] = round(statistics.fmean(cand_vals), 4) if cand_vals else None
row["ref_values"] = [
round(statistics.fmean(vs), 4) if vs else None for vs in ref_val_lists
]
groups = [(0, cand_vals)] + [
(i + 1, vs) for i, vs in enumerate(ref_val_lists) if vs
]
groups = [g for g in groups if g[1]]
k = len(groups)
n_total = sum(len(vs) for _, vs in groups)
df_error = n_total - k
if k < 2 or df_error < 1:
row["distinct"] = None
row["note"] = "数据不足:QN 需候选与参照均有有效值且具备重复(误差自由度≥1),无法做 LSD"
pending_nos.append(desc_no)
descriptors.append(row)
continue
means: dict[int, float] = {}
sse = 0.0
for gi, vals in groups:
m = statistics.fmean(vals)
means[gi] = m
sse += sum((x - m) ** 2 for x in vals)
mse = sse / df_error
t = fdist.t_crit(alpha, df_error)
row["df"] = df_error
row["critical"] = round(t, 4)
lsd_used = None
row["distinct"] = False
row["note"] = "与所有参照均未达显著差异"
for i, (gi, vals) in enumerate(groups):
if gi == 0:
continue
n_c, n_r = len(cand_vals), len(vals)
lsd = t * math.sqrt(mse * (1.0 / n_c + 1.0 / n_r))
lsd_used = lsd
if abs(means[0] - means[gi]) > lsd:
row["distinct"] = True
row["note"] = (
f"与参照{i}显著不同(|Δ|={abs(means[0] - means[gi]):.3f} > LSD={lsd:.3f}"
)
break
row["statistic"] = round(lsd_used, 4) if lsd_used is not None else None
else:
cand_modes = _text_modes(c_obs.get("text", []))
ref_modes_all = [_text_modes(r.get("text", [])) for r in refs_obs]
row["candidate_value"] = " / ".join(cand_modes) if cand_modes else None
row["ref_values"] = [" / ".join(ms) if ms else None for ms in ref_modes_all]
if not cand_modes:
row["distinct"] = None
row["note"] = "数据不足:PQ/QL 需候选表达状态"
pending_nos.append(desc_no)
else:
union = {s for ms in ref_modes_all for s in ms}
overlap = any(s in union for s in cand_modes)
row["distinct"] = not overlap
row["note"] = (
f"候选状态{'/'.join(cand_modes)} 不在参照状态集"
if row["distinct"]
else f"候选状态{'/'.join(cand_modes)} 与参照重叠"
)
if row["distinct"] is True:
distinct_nos.append(desc_no)
if required:
required_distinct.append(desc_no)
descriptors.append(row)
req_pending = [d["descriptor_no"] for d in descriptors if d["required"] == "1" and d["distinct"] is None]
req_distinct = [d["descriptor_no"] for d in descriptors if d["required"] == "1" and d["distinct"] is True]
if req_pending:
suggestion = "pending"
elif req_distinct:
suggestion = "distinct"
else:
suggestion = "not_distinct"
warning = None
if pending_nos:
shown = "、".join(pending_nos[:8])
if len(pending_nos) > 8:
shown += "…"
warning = f"{len(pending_nos)} 个描述符数据不足未判定:{shown}"
return {
"alpha": alpha,
"n_references": len(references),
"descriptors": descriptors,
"distinct_descriptors": distinct_nos,
"n_distinct": len(distinct_nos),
"required_distinct": required_distinct,
"n_required_distinct": len(required_distinct),
"suggestion": suggestion,
"warning": warning,
}
class DusService:
"""DUS/品种保护 模块服务层(描述符模板 / 测试记录 / 观测 三组资源)。"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
# ---------- 描述符模板 ----------
async def _check_descriptor_no(self, no: str, exclude_id: int | None = None) -> None:
if _is_blank(no):
raise CustomException(msg="描述符编号不能为空")
result = await self.db.execute(
select(DusDescriptorModel.id).where(
DusDescriptorModel.descriptor_no == str(no).strip(),
DusDescriptorModel.is_deleted.is_(False),
)
)
obj_id = result.scalar_one_or_none()
if obj_id is not None and obj_id != exclude_id:
raise CustomException(msg=f"描述符编号 {no} 已存在", status_code=409)
async def _attach_trait_labels(self, items: list[DusDescriptorOutSchema]) -> None:
ids = {getattr(it, "trait_id") for it in items if getattr(it, "trait_id")}
if not ids:
return
rows = (await self.db.execute(
select(TraitModel.id, TraitModel.trait_name).where(TraitModel.id.in_(ids))
)).all()
ref_map = {rid: name for rid, name in rows}
for it in items:
it.trait_name = ref_map.get(getattr(it, "trait_id"))
async def descriptor_detail(self, id: int) -> DusDescriptorOutSchema:
obj = await DusDescriptorCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="该描述符不存在")
out = DusDescriptorOutSchema.model_validate(obj)
await self._attach_trait_labels([out])
return out
async def descriptor_get_list(
self,
search: DusDescriptorQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[DusDescriptorOutSchema]:
obj_list = await DusDescriptorCRUD(self.auth, self.db).get_list(
search=search_to_dict(search), order_by=order_by
)
outs = [DusDescriptorOutSchema.model_validate(obj) for obj in obj_list]
await self._attach_trait_labels(outs)
return outs
async def descriptor_page(
self,
page_no: int,
page_size: int,
search: DusDescriptorQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[DusDescriptorOutSchema]:
offset = (page_no - 1) * page_size
result = await DusDescriptorCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search, {}),
out_schema=DusDescriptorOutSchema,
)
await self._attach_trait_labels(result.items)
return result
async def descriptor_create(self, data: DusDescriptorCreateSchema) -> DusDescriptorOutSchema:
await self._check_descriptor_no(data.descriptor_no)
await assert_parents_exist(self.db, [(TraitModel, data.trait_id, '性状字典')])
obj = await DusDescriptorCRUD(self.auth, self.db).create(data=data)
out = DusDescriptorOutSchema.model_validate(obj)
await self._attach_trait_labels([out])
return out
async def descriptor_update(self, id: int, data: DusDescriptorUpdateSchema) -> DusDescriptorOutSchema:
obj = await DusDescriptorCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="更新失败,该描述符不存在")
if not _is_blank(data.descriptor_no):
await self._check_descriptor_no(data.descriptor_no, exclude_id=id)
await assert_parents_exist(self.db, [(TraitModel, data.trait_id, '性状字典')])
obj = await DusDescriptorCRUD(self.auth, self.db).update(id=id, data=data)
out = DusDescriptorOutSchema.model_validate(obj)
await self._attach_trait_labels([out])
return out
async def descriptor_delete(self, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
objs = await DusDescriptorCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)})
obj_map = {o.id: o for o in objs}
for id_ in ids:
if id_ not in obj_map:
raise CustomException(msg="删除失败,该描述符不存在")
await assert_no_children(self.db, ids, [(DusObservationModel, "dus_descriptor_id", "DUS观测")])
await DusDescriptorCRUD(self.auth, self.db).delete(ids=ids)
async def descriptor_list_options(self) -> list[dict[str, Any]]:
obj_list = await DusDescriptorCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
return [{"value": o.id, "label": f"{o.descriptor_no} {o.descriptor_name}"} for o in obj_list]
@staticmethod
def descriptor_batch_export(obj_list: list[dict[str, Any]]) -> bytes:
mapping_dict = {
"descriptor_no": "描述符编号",
"descriptor_name": "特性中文名",
"trait_code": "关联性状编码",
"trait_name": "关联性状名称",
"expression_type": "表达类型",
"method": "测试方法",
"example_varieties": "标准/示例品种",
"test_stage": "测试生育期",
"required": "是否必测",
"remark": "备注",
"created_time": "创建时间",
"created_by": "创建者",
}
data = [dict(item) for item in obj_list]
for item in data:
creator = item.get("created_by")
item["created_by"] = creator.get("name", "未知") if isinstance(creator, dict) else "未知"
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
async def descriptor_batch_import(self, file: UploadFile, update_support: bool = False) -> ImportResultSchema:
header_dict = {
"描述符编号": "descriptor_no",
"特性中文名": "descriptor_name",
"关联性状编码": "trait_code",
"表达类型": "expression_type",
"测试方法": "method",
"标准/示例品种": "example_varieties",
"测试生育期": "test_stage",
"是否必测": "required",
"备注": "remark",
}
try:
contents = await file.read()
rows = ExcelUtil.read_excel_to_dicts(contents)
await file.close()
if not rows:
raise CustomException(msg="导入文件为空")
missing_headers = [h for h in header_dict if h not in rows[0]]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
trait_rows = (await self.db.execute(select(TraitModel.trait_code, TraitModel.id))).all()
trait_map = {code: tid for code, tid in trait_rows}
error_msgs: list[str] = []
success_count = 0
crud = DusDescriptorCRUD(self.auth, self.db)
seen_nos: set[str] = set()
for i, row in enumerate(rows, start=1):
try:
no = row.get("descriptor_no")
if _is_blank(no):
raise CustomException(msg="描述符编号不能为空")
name = row.get("descriptor_name")
if _is_blank(name):
raise CustomException(msg="特性中文名不能为空")
no = str(no).strip()
if no in seen_nos:
raise CustomException(msg=f"文件内描述符编号重复:{no}")
seen_nos.add(no)
await self._check_descriptor_no(no)
trait_code = _none_if_blank(row.get("trait_code"))
trait_id = trait_map.get(str(trait_code)) if trait_code else None
fields = {
"descriptor_no": no,
"descriptor_name": str(name).strip(),
"trait_id": trait_id,
"trait_code": trait_code,
"expression_type": str(row.get("expression_type") or "QN").strip(),
"method": _none_if_blank(row.get("method")),
"example_varieties": _none_if_blank(row.get("example_varieties")),
"test_stage": _none_if_blank(row.get("test_stage")),
"required": str(row.get("required") or "1").strip(),
"remark": _none_if_blank(row.get("remark")),
}
await crud.create(data=DusDescriptorCreateSchema(**fields))
success_count += 1
except Exception as e:
error_msgs.append(f"第{i}行: {e!s}")
continue
return ImportResultSchema(
valid_count=success_count,
invalid_count=len(error_msgs),
message_list=error_msgs,
)
except Exception as e:
logger.error(f"批量导入描述符失败: {e!s}")
raise CustomException(msg=f"导入失败: {e!s}")
@staticmethod
def descriptor_import_template_download() -> bytes:
header_list = [
"描述符编号",
"特性中文名",
"关联性状编码",
"表达类型",
"测试方法",
"标准/示例品种",
"测试生育期",
"是否必测",
"备注",
]
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=["表达类型", "是否必测"],
option_list=[{"表达类型": ["QN", "PQ", "QL"]}, {"是否必测": ["1", "0"]}],
)
# ---------- 测试记录 ----------
async def _check_test_name(self, name: str, exclude_id: int | None = None) -> None:
if _is_blank(name):
raise CustomException(msg="测试名称不能为空")
result = await self.db.execute(
select(DusTestModel.id).where(
DusTestModel.test_name == str(name).strip(),
DusTestModel.is_deleted.is_(False),
)
)
obj_id = result.scalar_one_or_none()
if obj_id is not None and obj_id != exclude_id:
raise CustomException(msg=f"测试名称 {name} 已存在", status_code=409)
async def _attach_test_labels(self, items: list[DusTestOutSchema]) -> None:
if not items:
return
germ_ids = {getattr(it, "germplasm_id") for it in items if getattr(it, "germplasm_id")}
trial_ids = {getattr(it, "trial_study_id") for it in items if getattr(it, "trial_study_id")}
if germ_ids:
rows = (await self.db.execute(
select(BreedingGermplasmModel.id, BreedingGermplasmModel.cultivar_name).where(
BreedingGermplasmModel.id.in_(germ_ids)
)
)).all()
ref_map = {rid: name for rid, name in rows}
for it in items:
it.germplasm_name = ref_map.get(getattr(it, "germplasm_id"))
if trial_ids:
rows = (await self.db.execute(
select(TrialStudyModel.id, TrialStudyModel.study_name).where(
TrialStudyModel.id.in_(trial_ids)
)
)).all()
ref_map = {rid: name for rid, name in rows}
for it in items:
it.trial_study_name = ref_map.get(getattr(it, "trial_study_id"))
async def test_detail(self, id: int) -> DusTestOutSchema:
obj = await DusTestCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="该测试记录不存在")
out = DusTestOutSchema.model_validate(obj)
await self._attach_test_labels([out])
return out
async def test_get_list(
self,
search: DusTestQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[DusTestOutSchema]:
obj_list = await DusTestCRUD(self.auth, self.db).get_list(
search=search_to_dict(search), order_by=order_by
)
outs = [DusTestOutSchema.model_validate(obj) for obj in obj_list]
await self._attach_test_labels(outs)
return outs
async def test_page(
self,
page_no: int,
page_size: int,
search: DusTestQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[DusTestOutSchema]:
offset = (page_no - 1) * page_size
result = await DusTestCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search, {}),
out_schema=DusTestOutSchema,
)
await self._attach_test_labels(result.items)
return result
async def test_create(self, data: DusTestCreateSchema) -> DusTestOutSchema:
await self._check_test_name(data.test_name)
await assert_parents_exist(self.db, [
(BreedingGermplasmModel, data.germplasm_id, '申请品种'),
(TrialStudyModel, data.trial_study_id, '测试点'),
])
obj = await DusTestCRUD(self.auth, self.db).create(data=data)
out = DusTestOutSchema.model_validate(obj)
await self._attach_test_labels([out])
return out
async def test_update(self, id: int, data: DusTestUpdateSchema) -> DusTestOutSchema:
obj = await DusTestCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="更新失败,该测试记录不存在")
if not _is_blank(data.test_name):
await self._check_test_name(data.test_name, exclude_id=id)
await assert_parents_exist(self.db, [
(BreedingGermplasmModel, data.germplasm_id, '申请品种'),
(TrialStudyModel, data.trial_study_id, '测试点'),
])
obj = await DusTestCRUD(self.auth, self.db).update(id=id, data=data)
out = DusTestOutSchema.model_validate(obj)
await self._attach_test_labels([out])
return out
async def test_delete(self, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
objs = await DusTestCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)})
obj_map = {o.id: o for o in objs}
for id_ in ids:
if id_ not in obj_map:
raise CustomException(msg="删除失败,该测试记录不存在")
await assert_no_children(self.db, ids, [(DusObservationModel, "dus_test_id", "DUS观测")])
await DusTestCRUD(self.auth, self.db).delete(ids=ids)
async def test_list_options(self) -> list[dict[str, Any]]:
obj_list = await DusTestCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
return [{"value": o.id, "label": o.test_name} for o in obj_list]
@staticmethod
def test_batch_export(obj_list: list[dict[str, Any]]) -> bytes:
mapping_dict = {
"test_name": "测试名称",
"germplasm_name": "申请品种",
"trial_study_name": "测试点",
"tester": "测试人",
"test_date": "测试日期",
"status": "状态",
"conclusion": "结论",
"report_path": "报告附件",
"remark": "备注",
"created_time": "创建时间",
"created_by": "创建者",
}
data = [dict(item) for item in obj_list]
for item in data:
creator = item.get("created_by")
item["created_by"] = creator.get("name", "未知") if isinstance(creator, dict) else "未知"
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
# ---------- 观测 ----------
async def _check_observation_dup(
self, dus_test_id: int | None, dus_descriptor_id: int | None, exclude_id: int | None = None
) -> None:
if dus_test_id is None or dus_descriptor_id is None:
return
conditions = [
DusObservationModel.dus_test_id == dus_test_id,
DusObservationModel.dus_descriptor_id == dus_descriptor_id,
DusObservationModel.is_deleted.is_(False),
]
if exclude_id is not None:
conditions.append(DusObservationModel.id != exclude_id)
result = await self.db.execute(select(func.count()).select_from(DusObservationModel).where(*conditions))
if result.scalar() or 0:
raise CustomException(msg="该(测试,描述符)观测已存在", status_code=409)
async def _attach_descriptor_labels(self, items: list[DusObservationOutSchema]) -> None:
ids = {getattr(it, "dus_descriptor_id") for it in items if getattr(it, "dus_descriptor_id")}
if not ids:
return
rows = (await self.db.execute(
select(DusDescriptorModel.id, DusDescriptorModel.descriptor_no, DusDescriptorModel.descriptor_name).where(
DusDescriptorModel.id.in_(ids)
)
)).all()
ref_map = {rid: (no, name) for rid, no, name in rows}
for it in items:
pair = ref_map.get(getattr(it, "dus_descriptor_id"))
if pair:
it.descriptor_no, it.descriptor_name = pair
async def observation_detail(self, id: int) -> DusObservationOutSchema:
obj = await DusObservationCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="该观测不存在")
out = DusObservationOutSchema.model_validate(obj)
await self._attach_descriptor_labels([out])
return out
async def observation_get_list(
self,
search: DusObservationQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[DusObservationOutSchema]:
obj_list = await DusObservationCRUD(self.auth, self.db).get_list(
search=search_to_dict(search), order_by=order_by
)
outs = [DusObservationOutSchema.model_validate(obj) for obj in obj_list]
await self._attach_descriptor_labels(outs)
return outs
async def observation_page(
self,
page_no: int,
page_size: int,
search: DusObservationQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[DusObservationOutSchema]:
offset = (page_no - 1) * page_size
result = await DusObservationCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search, {}),
out_schema=DusObservationOutSchema,
)
await self._attach_descriptor_labels(result.items)
return result
async def observation_create(self, data: DusObservationCreateSchema) -> DusObservationOutSchema:
await self._check_observation_dup(data.dus_test_id, data.dus_descriptor_id)
await assert_parents_exist(self.db, [
(DusTestModel, data.dus_test_id, 'DUS测试'),
(DusDescriptorModel, data.dus_descriptor_id, '描述符'),
])
obj = await DusObservationCRUD(self.auth, self.db).create(data=data)
out = DusObservationOutSchema.model_validate(obj)
await self._attach_descriptor_labels([out])
return out
async def observation_update(self, id: int, data: DusObservationUpdateSchema) -> DusObservationOutSchema:
obj = await DusObservationCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="更新失败,该观测不存在")
await self._check_observation_dup(data.dus_test_id, data.dus_descriptor_id, exclude_id=id)
await assert_parents_exist(self.db, [
(DusTestModel, data.dus_test_id, 'DUS测试'),
(DusDescriptorModel, data.dus_descriptor_id, '描述符'),
])
obj = await DusObservationCRUD(self.auth, self.db).update(id=id, data=data)
out = DusObservationOutSchema.model_validate(obj)
await self._attach_descriptor_labels([out])
return out
async def observation_delete(self, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
objs = await DusObservationCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)})
obj_map = {o.id: o for o in objs}
for id_ in ids:
if id_ not in obj_map:
raise CustomException(msg="删除失败,该观测不存在")
await DusObservationCRUD(self.auth, self.db).delete(ids=ids)
@staticmethod
def observation_batch_export(obj_list: list[dict[str, Any]]) -> bytes:
mapping_dict = {
"descriptor_no": "描述符编号",
"descriptor_name": "特性名称",
"value_numeric": "数值表达值",
"value_text": "文本表达值",
"expression_note": "表达说明",
"remark": "备注",
"created_time": "创建时间",
"created_by": "创建者",
}
data = [dict(item) for item in obj_list]
for item in data:
creator = item.get("created_by")
item["created_by"] = creator.get("name", "未知") if isinstance(creator, dict) else "未知"
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
# ---------- 特异性统计判定(§8.18 ----------
async def test_distinctness(
self,
test_id: int,
reference_test_ids: list[int] | None = None,
alpha: float = 0.01,
) -> dict[str, Any]:
test = await DusTestCRUD(self.auth, self.db).get(id=test_id)
if not test:
raise CustomException(msg="该测试记录不存在")
if reference_test_ids:
ref_ids = [rid for rid in reference_test_ids if rid != test_id]
if not ref_ids:
raise CustomException(msg="参照测试不能为空或与候选相同")
ref_objs = await DusTestCRUD(self.auth, self.db).get_list(search={"id": ("in", ref_ids)})
ref_map = {r.id: r for r in ref_objs}
missing = [rid for rid in ref_ids if rid not in ref_map]
if missing:
raise CustomException(msg=f"参照测试不存在: {missing}")
else:
if test.trial_study_id is None:
raise CustomException(
msg="候选测试未指定测试点,无法自动选取参照;请显式传入 reference_test_ids"
)
ref_objs = await DusTestCRUD(self.auth, self.db).get_list(
search={"trial_study_id": test.trial_study_id}
)
ref_objs = [r for r in ref_objs if r.id != test_id]
if not ref_objs:
raise CustomException(msg="同一测试点无其他测试可作参照")
all_ids = [test_id] + [r.id for r in ref_objs]
obs_rows = (await self.db.execute(
select(
DusObservationModel.dus_test_id,
DusObservationModel.dus_descriptor_id,
DusObservationModel.value_numeric,
DusObservationModel.value_text,
).where(
DusObservationModel.dus_test_id.in_(all_ids),
DusObservationModel.is_deleted.is_(False),
)
)).all()
by_test: dict[int, list[dict[str, Any]]] = defaultdict(list)
for row in obs_rows:
by_test[row.dus_test_id].append({
"dus_descriptor_id": row.dus_descriptor_id,
"value_numeric": row.value_numeric,
"value_text": row.value_text,
})
# 品种分组:同 germplasm_id 的多个测试视为同一品种组的重复观测(跨年/多点)
cand_key: Any = test.germplasm_id if test.germplasm_id is not None else f"self{test_id}"
group_obs: dict[Any, dict[int, dict[str, list[Any]]]] = {}
_merge_obs(group_obs.setdefault(cand_key, {}), by_test.get(test_id, []))
for ro in ref_objs:
key: Any = ro.germplasm_id if ro.germplasm_id is not None else f"t{ro.id}"
if key == cand_key:
_merge_obs(group_obs[cand_key], by_test.get(ro.id, []))
else:
_merge_obs(group_obs.setdefault(key, {}), by_test.get(ro.id, []))
candidate = group_obs[cand_key]
references = [group_obs[k] for k in group_obs if k != cand_key]
if not references:
raise CustomException(msg="参照测试均为候选同品种,无法构成参照集")
all_dids = set(candidate) | {did for ref in references for did in ref}
descriptor_meta: dict[int, dict[str, Any]] = {}
if all_dids:
desc_rows = (await self.db.execute(
select(
DusDescriptorModel.id,
DusDescriptorModel.descriptor_no,
DusDescriptorModel.descriptor_name,
DusDescriptorModel.expression_type,
DusDescriptorModel.required,
).where(DusDescriptorModel.id.in_(all_dids))
)).all()
descriptor_meta = {
row.id: {
"descriptor_no": row.descriptor_no,
"descriptor_name": row.descriptor_name,
"expression_type": row.expression_type,
"required": row.required,
}
for row in desc_rows
}
if not descriptor_meta:
raise CustomException(msg="候选与参照均无观测数据,无法判定")
report = distinctness(candidate, references, descriptor_meta, alpha=alpha)
report["candidate_test_id"] = test_id
report["reference_test_ids"] = [r.id for r in ref_objs]
report["ran_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conclusion = test.conclusion
if report["suggestion"] in ("distinct", "not_distinct") and conclusion in (None, "", "pending"):
conclusion = report["suggestion"]
data: dict[str, Any] = {"analysis_json": report}
if conclusion != test.conclusion:
data["conclusion"] = conclusion
await DusTestCRUD(self.auth, self.db).update(id=test_id, data=data)
return report
async def distinctness_report(self, test_id: int) -> dict[str, Any]:
test = await DusTestCRUD(self.auth, self.db).get(id=test_id)
if not test:
raise CustomException(msg="该测试记录不存在")
if not test.analysis_json:
raise CustomException(msg="该测试尚未运行特异性判定(POST /bre/dus_test/distinctness/{id}")
return test.analysis_json