"""Роутер методик суточных норм: 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}