193 lines
7.1 KiB
Python
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}
|