Files
2026-07-17 12:57:18 +03:00

193 lines
7.1 KiB
Python

"""Роутер методик суточных норм: WESP / Москва / Петербург."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
from app.lab.calc.gfe_norms import apply_dynamic_norms
from app.lab.calc.racion.moscow import MoscowDairyParams, resolve_moscow_dairy_norms
from app.lab.calc.racion.piter import PiterDairyParams, PiterPrepError, resolve_piter_dairy_norms
from app.lab.indicators import RATION_ALL_INDICATORS
NormsMethod = Literal["wesp", "racion_moscow", "racion_piter"]
VALID_NORMS_METHODS: frozenset[str] = frozenset({"wesp", "racion_moscow", "racion_piter"})
@dataclass
class NormsParams:
milk_fat_pct: float | None = None
lactation_no: int | None = None
lactation_stage: int | None = None
body_condition: int | None = None
housing_system: int | None = None
konc_oe_sv: float | None = None
@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> NormsParams:
if not raw:
return cls()
return cls(
milk_fat_pct=_flt(raw.get("milkFatPct", raw.get("milk_fat_pct"))),
lactation_no=_int(raw.get("lactationNo", raw.get("lactation_no"))),
lactation_stage=_int(raw.get("lactationStage", raw.get("lactation_stage"))),
body_condition=_int(raw.get("bodyCondition", raw.get("body_condition"))),
housing_system=_int(raw.get("housingSystem", raw.get("housing_system"))),
konc_oe_sv=_flt(raw.get("koncOeSv", raw.get("konc_oe_sv"))),
)
def _flt(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _int(v: Any) -> int | None:
if v is None or v == "":
return None
try:
return int(v)
except (TypeError, ValueError):
return None
def normalize_norms_method(method: str | None) -> NormsMethod:
m = (method or "wesp").strip().lower()
if m not in VALID_NORMS_METHODS:
return "wesp"
return m # type: ignore[return-value]
@dataclass
class NormsResolveRequest:
method: NormsMethod = "wesp"
stored: dict[str, dict[str, float | None]] = field(default_factory=dict)
mass_kg: float | None = None
milk_yield_kg: float | None = None
ration_type: str | None = None
force_dynamic: bool = False
params: NormsParams = field(default_factory=NormsParams)
def merge_hybrid_norms(
racion_resolved: dict[str, dict[str, float | None]],
stored: dict[str, dict[str, float | None]],
*,
dynamic_meta: dict[str, Any] | None = None,
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
"""
RACION + derived имеют приоритет по min.
stored дополняет ключи без RACION-min; max всегда из stored, если задан.
"""
out: dict[str, dict[str, float | None]] = {}
coverage: dict[str, list[str]] = {
"racion": [],
"derived": [],
"fallback": [],
"missing": [],
}
dynamic = dynamic_meta or {}
all_keys = {d["key"] for d in RATION_ALL_INDICATORS}
all_keys.update(racion_resolved.keys())
all_keys.update(stored.keys())
for key in sorted(all_keys):
rac = racion_resolved.get(key) or {}
st = stored.get(key) or {}
rac_min = rac.get("min")
st_min = st.get("min")
st_max = st.get("max")
source: str | None = None
min_v: float | None = None
if rac_min is not None:
min_v = rac_min
src = (dynamic.get(key) or {}).get("source")
source = "derived" if src == "derived" else "racion"
elif st_min is not None:
min_v = st_min
source = "fallback"
max_v = st_max if st_max is not None else rac.get("max")
if min_v is not None or max_v is not None:
out[key] = {"min": min_v, "max": max_v}
if source == "racion":
coverage["racion"].append(key)
elif source == "derived":
coverage["derived"].append(key)
elif source == "fallback":
coverage["fallback"].append(key)
elif key in {d["key"] for d in RATION_ALL_INDICATORS}:
coverage["missing"].append(key)
coverage["withMin"] = len([k for k, b in out.items() if b.get("min") is not None])
coverage["total"] = len(RATION_ALL_INDICATORS)
return out, coverage
def _require_dairy_params(req: NormsResolveRequest) -> tuple[float, float]:
if req.mass_kg is None or req.mass_kg <= 0:
raise ValueError("Для методики Москва/Петербург укажите живую массу, кг")
if req.milk_yield_kg is None or req.milk_yield_kg <= 0:
raise ValueError("Для методики Москва/Петербург укажите суточный удой, кг")
return float(req.mass_kg), float(req.milk_yield_kg)
def _moscow_params(req: NormsResolveRequest) -> MoscowDairyParams:
mass, milk = _require_dairy_params(req)
p = req.params
return MoscowDairyParams(
mass_kg=mass,
milk_yield_kg=milk,
milk_fat_pct=p.milk_fat_pct if p.milk_fat_pct is not None else 4.0,
lactation_no=p.lactation_no if p.lactation_no is not None else 2,
body_condition=p.body_condition if p.body_condition is not None else 1,
housing_system=p.housing_system if p.housing_system is not None else 1,
)
def _piter_params(req: NormsResolveRequest) -> PiterDairyParams:
mass, milk = _require_dairy_params(req)
p = req.params
konc = p.konc_oe_sv
if konc is None or konc <= 0:
raise ValueError("Для методики Петербург укажите концентрацию ОЭ/СВ, МДж/кг СВ")
return PiterDairyParams(
mass_kg=mass,
milk_yield_kg=milk,
milk_fat_pct=p.milk_fat_pct if p.milk_fat_pct is not None else 4.0,
lactation_no=p.lactation_no if p.lactation_no is not None else 2,
body_condition=p.body_condition if p.body_condition is not None else 1,
housing_system=p.housing_system if p.housing_system is not None else 1,
konc_oe_sv=float(konc),
)
def resolve_norms(req: NormsResolveRequest) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
method = normalize_norms_method(req.method)
if method == "wesp":
resolved, dynamic = apply_dynamic_norms(
req.stored,
mass_kg=req.mass_kg,
milk_yield_kg=req.milk_yield_kg,
ration_type=req.ration_type,
force_dynamic=req.force_dynamic,
)
return resolved, {"normsMethod": "wesp", "dynamicNorms": dynamic}
try:
if method == "racion_moscow":
racion, pack = resolve_moscow_dairy_norms(_moscow_params(req))
else:
racion, pack = resolve_piter_dairy_norms(_piter_params(req))
except PiterPrepError as exc:
raise ValueError(str(exc)) from exc
resolved, coverage = merge_hybrid_norms(racion, req.stored, dynamic_meta=pack.get("dynamic"))
return resolved, {"normsMethod": method, "coverage": coverage, **pack}