@@ -0,0 +1,192 @@
|
||||
"""Роутер методик суточных норм: 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}
|
||||
Reference in New Issue
Block a user