@@ -0,0 +1,4 @@
|
||||
from .engine import calculate_ration
|
||||
from .diff import compare_master_execution
|
||||
|
||||
__all__ = ["calculate_ration", "compare_master_execution"]
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.lab.calc.derived import apply_content_derived
|
||||
from app.lab.calc.nutrients import norm_diff, weighted_average
|
||||
from app.lab.indicators import RATION_ALL_INDICATORS
|
||||
|
||||
|
||||
def _compound_active(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
l
|
||||
for l in lines
|
||||
if l.get("in_compound") and (l.get("daily_kg") or 0) > 0
|
||||
]
|
||||
|
||||
|
||||
def _sum_kg(lines: list[dict[str, Any]]) -> float:
|
||||
return sum(float(l.get("daily_kg") or 0) for l in lines)
|
||||
|
||||
|
||||
def _compound_indicators(
|
||||
lines: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
) -> list[dict[str, Any]]:
|
||||
content_by_key: dict[str, float | None] = {}
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if defn.get("derived"):
|
||||
continue
|
||||
content_by_key[defn["key"]] = weighted_average(
|
||||
lines,
|
||||
total_kg,
|
||||
defn["nutrient_keys"],
|
||||
indicator_key=defn.get("key"),
|
||||
)
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if not defn.get("derived"):
|
||||
continue
|
||||
content_by_key[defn["key"]] = apply_content_derived(
|
||||
content_by_key,
|
||||
defn,
|
||||
compound_mode=True,
|
||||
)
|
||||
rows = []
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
content = content_by_key.get(key)
|
||||
bounds = norms.get(key, {})
|
||||
min_v = bounds.get("min")
|
||||
max_v = bounds.get("max")
|
||||
diff = norm_diff(content, min_v, max_v)
|
||||
if content is None and min_v is None and max_v is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": defn["label"],
|
||||
"unit": defn["unit"],
|
||||
"min": min_v,
|
||||
"max": max_v,
|
||||
"content": content,
|
||||
"diff": diff,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def calculate_compound_feed(
|
||||
lines: list[dict[str, Any]],
|
||||
norms: dict[str, dict[str, float | None]] | None = None,
|
||||
*,
|
||||
profile_mass_kg: float | None = None,
|
||||
heads_per_trip: int = 1,
|
||||
) -> dict[str, Any] | None:
|
||||
del profile_mass_kg, heads_per_trip
|
||||
active = _compound_active(lines)
|
||||
if not active:
|
||||
return None
|
||||
total_kg = _sum_kg(active)
|
||||
cost = None
|
||||
any_cost = False
|
||||
for line in active:
|
||||
kg = line.get("daily_kg")
|
||||
price = line.get("price_per_kg")
|
||||
if kg is None or price is None:
|
||||
continue
|
||||
cost = (cost or 0) + float(kg) * float(price)
|
||||
any_cost = True
|
||||
return {
|
||||
"totals": [
|
||||
{"key": "compound_kg", "label": "Масса комбикорма, кг", "value": total_kg},
|
||||
{
|
||||
"key": "compound_cost",
|
||||
"label": "Стоимость комбикорма",
|
||||
"value": cost if any_cost else None,
|
||||
},
|
||||
],
|
||||
"indicators": _compound_indicators(active, total_kg, norms or {}),
|
||||
"lines": [
|
||||
{
|
||||
"ingredient_name": line.get("ingredient_name") or "—",
|
||||
"daily_kg": line.get("daily_kg"),
|
||||
"share_pct": (
|
||||
(float(line["daily_kg"]) / total_kg) * 100
|
||||
if total_kg > 0 and line.get("daily_kg") is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
for line in active
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.lab.calc.nutrients import rnb
|
||||
|
||||
|
||||
def apply_content_derived(
|
||||
content_by_key: dict[str, float | None],
|
||||
defn: dict[str, Any],
|
||||
*,
|
||||
total_kg: float = 0,
|
||||
heads_per_trip: int = 1,
|
||||
profile_mass_kg: float | None = None,
|
||||
compound_mode: bool = False,
|
||||
) -> float | None:
|
||||
derived = defn.get("derived")
|
||||
if derived == "alias":
|
||||
return content_by_key.get(defn.get("alias_of"))
|
||||
if derived == "pct_of_dm":
|
||||
src = content_by_key.get(defn.get("from_key"))
|
||||
dm = content_by_key.get("dry_matter")
|
||||
if src is None or not dm or dm <= 0:
|
||||
return None
|
||||
return src / dm * 100.0
|
||||
if derived == "g_per_kg_dm":
|
||||
src = content_by_key.get(defn.get("from_key"))
|
||||
dm = content_by_key.get("dry_matter")
|
||||
if src is None or not dm or dm <= 0:
|
||||
return None
|
||||
if compound_mode:
|
||||
return src
|
||||
return src / (dm / 1000.0)
|
||||
if derived == "nel_per_kg_dm":
|
||||
dm = content_by_key.get("dry_matter")
|
||||
nel = content_by_key.get("nel")
|
||||
if not dm or dm <= 0 or nel is None:
|
||||
return None
|
||||
if compound_mode:
|
||||
return nel
|
||||
return nel / (dm / 1000.0)
|
||||
if derived == "ratio":
|
||||
num = content_by_key.get(defn.get("ratio_num"))
|
||||
den = content_by_key.get(defn.get("ratio_den"))
|
||||
if num is None or den is None or den == 0:
|
||||
return None
|
||||
return num / den
|
||||
if derived == "dm_pct_bw":
|
||||
dm = content_by_key.get("dry_matter")
|
||||
if dm is None or not profile_mass_kg or profile_mass_kg <= 0:
|
||||
return None
|
||||
return (dm / 1000.0 / profile_mass_kg) * 100.0
|
||||
if derived == "ration_pct_bw":
|
||||
if not profile_mass_kg or profile_mass_kg <= 0 or total_kg <= 0:
|
||||
return None
|
||||
heads = max(int(heads_per_trip or 1), 1)
|
||||
kg_per_head = total_kg / heads
|
||||
return (kg_per_head / profile_mass_kg) * 100.0
|
||||
if derived in ("rnb", "bra_rnb"):
|
||||
return rnb(
|
||||
content_by_key.get("crude_protein"),
|
||||
content_by_key.get("usp"),
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.lab.constants import DIFF_TOLERANCE_KG
|
||||
|
||||
|
||||
def compare_master_execution(
|
||||
master_lines: list[dict[str, Any]],
|
||||
execution_lines: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
master_map: dict[str, float] = {}
|
||||
for line in master_lines:
|
||||
if not line.get("in_ration"):
|
||||
continue
|
||||
cid = line.get("component_id")
|
||||
if not cid:
|
||||
continue
|
||||
master_map[str(cid)] = float(line.get("daily_kg") or 0)
|
||||
|
||||
exec_map: dict[str, float] = {}
|
||||
for line in execution_lines:
|
||||
cid = line.get("component_id")
|
||||
if not cid:
|
||||
continue
|
||||
exec_map[str(cid)] = float(line.get("daily_kg_total") or 0)
|
||||
|
||||
all_ids = set(master_map) | set(exec_map)
|
||||
diff_lines = []
|
||||
has_changes = False
|
||||
for cid in sorted(all_ids):
|
||||
m = master_map.get(cid)
|
||||
e = exec_map.get(cid)
|
||||
reasons = []
|
||||
if m is None:
|
||||
reasons.append("missing_in_master")
|
||||
has_changes = True
|
||||
if e is None:
|
||||
reasons.append("missing_in_execution")
|
||||
has_changes = True
|
||||
if m is not None and e is not None and abs(m - e) > DIFF_TOLERANCE_KG:
|
||||
reasons.append("kg_mismatch")
|
||||
has_changes = True
|
||||
if reasons:
|
||||
diff_lines.append(
|
||||
{
|
||||
"component_id": cid,
|
||||
"master_kg": m,
|
||||
"execution_kg": e,
|
||||
"reasons": reasons,
|
||||
}
|
||||
)
|
||||
return {"has_changes": has_changes, "lines": diff_lines}
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.lab.calc.compound import calculate_compound_feed
|
||||
from app.lab.calc.derived import apply_content_derived
|
||||
from app.lab.calc.nutrients import daily_intake_total, get_nutrient_value, norm_diff, weighted_average
|
||||
from app.lab.constants import RATION_TOTAL_KEYS
|
||||
from app.lab.indicators import RATION_ALL_INDICATORS
|
||||
|
||||
|
||||
def _active_lines(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
l
|
||||
for l in lines
|
||||
if l.get("in_ration") and (l.get("daily_kg") or 0) > 0
|
||||
]
|
||||
|
||||
|
||||
def _sum_kg(lines: list[dict[str, Any]]) -> float:
|
||||
return sum(float(l.get("daily_kg") or 0) for l in lines)
|
||||
|
||||
|
||||
def _sum_cost(lines: list[dict[str, Any]]) -> float | None:
|
||||
total = 0.0
|
||||
any_cost = False
|
||||
for line in lines:
|
||||
kg = line.get("daily_kg")
|
||||
price = line.get("price_per_kg")
|
||||
if kg is None or price is None:
|
||||
continue
|
||||
total += float(kg) * float(price)
|
||||
any_cost = True
|
||||
return total if any_cost else None
|
||||
|
||||
|
||||
def _sum_ration_percent(lines: list[dict[str, Any]], total_kg: float) -> float | None:
|
||||
if total_kg <= 0:
|
||||
return None
|
||||
return sum((float(l.get("daily_kg") or 0) / total_kg) * 100 for l in lines)
|
||||
|
||||
|
||||
def _compute_totals(
|
||||
ration_type: str,
|
||||
active: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
defs = RATION_TOTAL_KEYS.get(ration_type, RATION_TOTAL_KEYS["BEEF"])
|
||||
ration_kg = _sum_kg(active)
|
||||
values = {
|
||||
"total_kg": total_kg if total_kg > 0 else None,
|
||||
"ration_kg": ration_kg if ration_kg > 0 else None,
|
||||
"ration_pct_sum": _sum_ration_percent(active, total_kg),
|
||||
"cost_total": _sum_cost(active),
|
||||
}
|
||||
return [{"key": d["key"], "label": d["label"], "value": values.get(d["key"])} for d in defs]
|
||||
|
||||
|
||||
def _indicator_content(
|
||||
defn: dict[str, Any],
|
||||
active: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
*,
|
||||
heads_per_trip: int,
|
||||
) -> float | None:
|
||||
key = defn.get("key")
|
||||
if defn.get("aggregation") == "weighted_avg":
|
||||
return weighted_average(
|
||||
active,
|
||||
total_kg,
|
||||
defn["nutrient_keys"],
|
||||
indicator_key=key,
|
||||
)
|
||||
return daily_intake_total(
|
||||
active,
|
||||
defn["nutrient_keys"],
|
||||
heads_per_trip=heads_per_trip,
|
||||
indicator_key=key,
|
||||
)
|
||||
|
||||
|
||||
def _compute_indicators(
|
||||
active: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
*,
|
||||
heads_per_trip: int = 1,
|
||||
profile_mass_kg: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
content_by_key: dict[str, float | None] = {}
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if defn.get("derived"):
|
||||
continue
|
||||
content_by_key[defn["key"]] = _indicator_content(
|
||||
defn, active, total_kg, heads_per_trip=heads_per_trip
|
||||
)
|
||||
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if not defn.get("derived"):
|
||||
continue
|
||||
content_by_key[defn["key"]] = apply_content_derived(
|
||||
content_by_key,
|
||||
defn,
|
||||
total_kg=total_kg,
|
||||
heads_per_trip=heads_per_trip,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
|
||||
rows = []
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
content = content_by_key.get(key)
|
||||
bounds = norms.get(key, {})
|
||||
min_v = bounds.get("min")
|
||||
max_v = bounds.get("max")
|
||||
diff = norm_diff(content, min_v, max_v)
|
||||
if content is None and min_v is None and max_v is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": defn["label"],
|
||||
"unit": defn["unit"],
|
||||
"min": min_v,
|
||||
"max": max_v,
|
||||
"content": content,
|
||||
"diff": diff,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _missing_nutrient_warnings(active: list[dict[str, Any]]) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
for line in active:
|
||||
nutrients = line.get("nutrients") or {}
|
||||
cp = get_nutrient_value(
|
||||
line.get("dry_matter"),
|
||||
nutrients,
|
||||
["Сыр. Протеин"],
|
||||
indicator_key="crude_protein",
|
||||
)
|
||||
if cp is not None:
|
||||
continue
|
||||
name = line.get("ingredient_name") or line.get("component_id") or "?"
|
||||
warnings.append(f"nutrients_missing:{name}")
|
||||
return warnings
|
||||
|
||||
|
||||
def calculate_ration(
|
||||
ration_type: str,
|
||||
lines: list[dict[str, Any]],
|
||||
norms: dict[str, dict[str, float | None]] | None = None,
|
||||
*,
|
||||
heads_per_trip: int = 1,
|
||||
profile_mass_kg: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
norms = norms or {}
|
||||
errors: list[str] = []
|
||||
active = _active_lines(lines)
|
||||
total_kg = _sum_kg(active)
|
||||
heads = max(int(heads_per_trip or 1), 1)
|
||||
if not active:
|
||||
errors.append("Нет строк сырья «в рационе» с дозировкой кг/день")
|
||||
warnings = _missing_nutrient_warnings(active)
|
||||
compound = calculate_compound_feed(
|
||||
lines, norms, profile_mass_kg=profile_mass_kg, heads_per_trip=heads
|
||||
)
|
||||
return {
|
||||
"calculated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"engine": "native",
|
||||
"totals": _compute_totals(ration_type, active, total_kg),
|
||||
"indicators": _compute_indicators(
|
||||
active,
|
||||
total_kg,
|
||||
norms,
|
||||
heads_per_trip=heads,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
),
|
||||
"compound": compound,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Группы кормов для авторациона — канонический тип компонента + legacy-маппинг."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.models import Component
|
||||
|
||||
FEED_GROUPS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "rough",
|
||||
"label": "База — грубые",
|
||||
"shortLabel": "База",
|
||||
"required": True,
|
||||
"minPick": 1,
|
||||
"hint": "Без каркаса партия не взлетит. Я не бизнесмен — я специалист.",
|
||||
"step": 1,
|
||||
},
|
||||
{
|
||||
"id": "succulent",
|
||||
"label": "Влага — сочные",
|
||||
"shortLabel": "Влага",
|
||||
"required": False,
|
||||
"minPick": 0,
|
||||
"hint": "Сочное сырьё. Можно не мешать — но чистота пострадает.",
|
||||
"step": 2,
|
||||
},
|
||||
{
|
||||
"id": "concentrate",
|
||||
"label": "Энергия — концентраты",
|
||||
"shortLabel": "Энергия",
|
||||
"required": False,
|
||||
"minPick": 0,
|
||||
"hint": "Концентрат дозируй как реагент — точно. Держись подальше от моей территории.",
|
||||
"step": 3,
|
||||
},
|
||||
{
|
||||
"id": "other",
|
||||
"label": "Добавки",
|
||||
"shortLabel": "Добавки",
|
||||
"required": False,
|
||||
"minPick": 0,
|
||||
"hint": "Минералы и премикс. Необязательно. Но Хайзенберг бы не пропустил.",
|
||||
"step": 4,
|
||||
},
|
||||
)
|
||||
|
||||
# Канонические значения component.type (выбор в /components)
|
||||
FEED_COMPONENT_TYPES: tuple[dict[str, str], ...] = (
|
||||
{
|
||||
"value": "Грубые корма",
|
||||
"feedGroup": "rough",
|
||||
"description": "Сено, солома.",
|
||||
},
|
||||
{
|
||||
"value": "Сочные корма",
|
||||
"feedGroup": "succulent",
|
||||
"description": "Силос, корнеплоды.",
|
||||
},
|
||||
{
|
||||
"value": "Концентрированные",
|
||||
"feedGroup": "concentrate",
|
||||
"description": "Зерно, комбикорм, жмых, шрот",
|
||||
},
|
||||
{
|
||||
"value": "Добавки",
|
||||
"feedGroup": "other",
|
||||
"description": "Премикс, минералы, витамины, КЖП",
|
||||
},
|
||||
)
|
||||
|
||||
_GROUP_BY_ID = {g["id"]: g for g in FEED_GROUPS}
|
||||
|
||||
_LEGACY_TYPE_TO_GROUP: dict[str, str] = {
|
||||
"зерновые": "concentrate",
|
||||
"энергетические": "concentrate",
|
||||
"белковые": "concentrate",
|
||||
"минеральные": "other",
|
||||
"витаминные": "other",
|
||||
}
|
||||
|
||||
# Старый тип «Объемные корма» — уточните до Грубые/Сочные; эвристика по имени
|
||||
_ROUGH_NAME_KEYS = ("солом", "сено", "hay", "straw")
|
||||
_SUCCULENT_NAME_KEYS = ("силос", "сенаж", "сочн", "корнеплод", "свекл", "морков", "тыкв", "зелен")
|
||||
_CONCENTRATE_NAME_KEYS = ("зерн", "концентр", "комбикорм", "комбик", "жмых", "шрот", "дробин", "пивн")
|
||||
|
||||
|
||||
def _norm(text: str | None) -> str:
|
||||
return (text or "").strip().lower()
|
||||
|
||||
|
||||
def _canonical_type_map() -> dict[str, str]:
|
||||
return {_norm(t["value"]): t["feedGroup"] for t in FEED_COMPONENT_TYPES}
|
||||
|
||||
|
||||
def _canonical_values() -> list[str]:
|
||||
return [t["value"] for t in FEED_COMPONENT_TYPES]
|
||||
|
||||
|
||||
def list_component_feed_types() -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"value": t["value"],
|
||||
"feedGroup": t["feedGroup"],
|
||||
"description": t["description"],
|
||||
"groupLabel": _GROUP_BY_ID[t["feedGroup"]]["label"],
|
||||
}
|
||||
for t in FEED_COMPONENT_TYPES
|
||||
]
|
||||
|
||||
|
||||
def is_canonical_feed_type(component_type: str | None) -> bool:
|
||||
return _norm(component_type) in _canonical_type_map()
|
||||
|
||||
|
||||
def feed_group_for_type(component_type: str | None) -> str | None:
|
||||
"""Группа авторациона по component.type (канон или legacy)."""
|
||||
ctype = _norm(component_type)
|
||||
if not ctype:
|
||||
return None
|
||||
canonical = _canonical_type_map().get(ctype)
|
||||
if canonical:
|
||||
return canonical
|
||||
if ctype in ("объемные корма", "объёмные корма"):
|
||||
return None
|
||||
return _LEGACY_TYPE_TO_GROUP.get(ctype)
|
||||
|
||||
|
||||
def classify_feed_group(comp: Component) -> str:
|
||||
"""Группа для авторациона: сначала component.type, иначе эвристика по имени (legacy)."""
|
||||
by_type = feed_group_for_type(comp.type)
|
||||
if by_type:
|
||||
return by_type
|
||||
|
||||
name = _norm(comp.name)
|
||||
|
||||
def has_any(keys: tuple[str, ...]) -> bool:
|
||||
return any(k in name for k in keys)
|
||||
|
||||
if has_any(_ROUGH_NAME_KEYS):
|
||||
return "rough"
|
||||
if has_any(_SUCCULENT_NAME_KEYS):
|
||||
return "succulent"
|
||||
if has_any(_CONCENTRATE_NAME_KEYS):
|
||||
return "concentrate"
|
||||
if _norm(comp.type) in ("объемные корма", "объёмные корма"):
|
||||
return "succulent"
|
||||
return "other"
|
||||
|
||||
|
||||
def list_feed_groups_api() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": g["id"],
|
||||
"label": g["label"],
|
||||
"shortLabel": g["shortLabel"],
|
||||
"required": g["required"],
|
||||
"minPick": g["minPick"],
|
||||
"hint": g["hint"],
|
||||
"step": g["step"],
|
||||
}
|
||||
for g in FEED_GROUPS
|
||||
]
|
||||
|
||||
|
||||
def parse_group_selections(raw: Any) -> dict[str, list[str]]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict[str, list[str]] = {}
|
||||
for gid in _GROUP_BY_ID:
|
||||
vals = raw.get(gid) or []
|
||||
if isinstance(vals, list):
|
||||
out[gid] = [str(x) for x in vals if x]
|
||||
return out
|
||||
|
||||
|
||||
def flatten_group_selections(selections: dict[str, list[str]]) -> list[str]:
|
||||
seen: list[str] = []
|
||||
for gid in _GROUP_BY_ID:
|
||||
for cid in selections.get(gid) or []:
|
||||
if cid not in seen:
|
||||
seen.append(cid)
|
||||
return seen
|
||||
|
||||
|
||||
def validate_group_selections(selections: dict[str, list[str]]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
for g in FEED_GROUPS:
|
||||
gid = g["id"]
|
||||
picked = selections.get(gid) or []
|
||||
if g["required"] and len(picked) < int(g["minPick"]):
|
||||
errors.append(f"{gid}:need_{g['minPick']}")
|
||||
total = len(flatten_group_selections(selections))
|
||||
if total < 3:
|
||||
errors.append("pool:need_3")
|
||||
return errors
|
||||
|
||||
|
||||
def triplet_meets_group_rules(
|
||||
triplet_ids: set[str],
|
||||
selections: dict[str, list[str]],
|
||||
) -> bool:
|
||||
for g in FEED_GROUPS:
|
||||
if not g["required"]:
|
||||
continue
|
||||
pool = set(selections.get(g["id"]) or [])
|
||||
if not pool:
|
||||
continue
|
||||
if not triplet_ids & pool:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,417 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.lab.calc.engine import calculate_ration
|
||||
from app.lab.calc.feed_groups import (
|
||||
FEED_GROUPS,
|
||||
flatten_group_selections,
|
||||
triplet_meets_group_rules,
|
||||
validate_group_selections,
|
||||
)
|
||||
from app.lab.calc.feed_groups import classify_feed_group as _classify_feed_group
|
||||
from app.lab.calc.formulate_optimize import optimize_shares_for_triplet
|
||||
from app.lab.calc.formulate_score import (
|
||||
build_calc_lines,
|
||||
build_triplet_score_model,
|
||||
score_model_shares,
|
||||
violation_score,
|
||||
)
|
||||
from app.lab.calc.formulate_validate import validate_component, validate_components
|
||||
from app.lab.calc.norms_resolver import NormsParams, NormsResolveRequest, normalize_norms_method, resolve_norms
|
||||
from app.lab.calc.nutrients import get_nutrient_value
|
||||
from app.lab.indicators import RATION_ALL_INDICATORS
|
||||
from app.lab.models import LabAnimalProfile
|
||||
from app.lab.services.component_nutrients import nutrients_calc_dict_batch
|
||||
from app.lab.services.profile_norms import load_norms_dict
|
||||
from app.models import Component
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormulateRequest:
|
||||
profile_id: str
|
||||
candidate_ids: list[str] = field(default_factory=list)
|
||||
group_selections: dict[str, list[str]] = field(default_factory=dict)
|
||||
main_feed_ids: list[str] = field(default_factory=list) # legacy → rough
|
||||
mass_kg: float | None = None
|
||||
milk_yield_kg: float | None = None
|
||||
heads_per_trip: int | None = None
|
||||
total_kg_per_head: float = 7.3
|
||||
optimize_keys: list[str] = field(default_factory=list)
|
||||
objective: str = "min_cost"
|
||||
cost_weight: float = 100.0
|
||||
grid_step: float = 0.1
|
||||
prefilter_k: int = 18
|
||||
min_share: float = 0.05
|
||||
norms_method: str = "wesp"
|
||||
norms_params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
_DEFAULT_OPTIMIZE_KEYS = (
|
||||
"dry_matter",
|
||||
"usp",
|
||||
"nel",
|
||||
"crude_protein",
|
||||
"rnb",
|
||||
"nfc_pct_dm_uk",
|
||||
)
|
||||
|
||||
|
||||
def _indicator_def(key: str) -> dict[str, Any] | None:
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if defn["key"] == key:
|
||||
return defn
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_norms(profile: LabAnimalProfile, req: FormulateRequest) -> tuple[dict, dict]:
|
||||
stored = load_norms_dict(profile.id)
|
||||
mass = req.mass_kg if req.mass_kg is not None else profile.mass_kg
|
||||
milk = req.milk_yield_kg if req.milk_yield_kg is not None else profile.milk_yield_kg
|
||||
from app.lab.services.norms_params import load_norms_params
|
||||
|
||||
method = normalize_norms_method(req.norms_method or profile.norms_method)
|
||||
params = load_norms_params(profile)
|
||||
if req.norms_params:
|
||||
merged = {
|
||||
"milkFatPct": params.milk_fat_pct,
|
||||
"lactationNo": params.lactation_no,
|
||||
"lactationStage": params.lactation_stage,
|
||||
"bodyCondition": params.body_condition,
|
||||
"housingSystem": params.housing_system,
|
||||
"koncOeSv": params.konc_oe_sv,
|
||||
}
|
||||
merged.update(req.norms_params)
|
||||
params = NormsParams.from_dict(merged)
|
||||
resolved, meta = resolve_norms(
|
||||
NormsResolveRequest(
|
||||
method=method,
|
||||
stored=stored,
|
||||
mass_kg=mass,
|
||||
milk_yield_kg=milk,
|
||||
ration_type=profile.ration_type,
|
||||
force_dynamic=method == "wesp",
|
||||
params=params,
|
||||
)
|
||||
)
|
||||
dynamic = meta.get("dynamicNorms") or meta.get("dynamic") or {}
|
||||
return resolved, {"normsMethod": method, "dynamicNorms": dynamic, "normsMeta": meta.get("meta")}
|
||||
|
||||
|
||||
def _rough_component_value(
|
||||
comp: Component,
|
||||
optimize_keys: list[str],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
nutrient_cache: dict[str, dict[str, float]],
|
||||
) -> float | None:
|
||||
nutrients = nutrient_cache.get(comp.id, {})
|
||||
dm_pct = comp.dry_matter
|
||||
total = 0.0
|
||||
counted = 0
|
||||
for key in optimize_keys:
|
||||
defn = _indicator_def(key)
|
||||
if defn is None or defn.get("derived"):
|
||||
continue
|
||||
bounds = norms.get(key) or {}
|
||||
target_min = bounds.get("min")
|
||||
target_max = bounds.get("max")
|
||||
if target_min is None and target_max is None:
|
||||
continue
|
||||
target = None
|
||||
if target_min is not None and target_max is not None:
|
||||
target = (float(target_min) + float(target_max)) / 2.0
|
||||
elif target_max is not None:
|
||||
target = float(target_max) * 0.9
|
||||
elif target_min is not None:
|
||||
target = float(target_min) * 1.1
|
||||
if target is None or target == 0:
|
||||
continue
|
||||
val = get_nutrient_value(
|
||||
dm_pct,
|
||||
nutrients,
|
||||
defn.get("nutrient_keys") or [],
|
||||
indicator_key=key,
|
||||
)
|
||||
if val is None:
|
||||
continue
|
||||
rel = (float(val) - target) / abs(target)
|
||||
total += rel * rel
|
||||
counted += 1
|
||||
return total / counted if counted else None
|
||||
|
||||
|
||||
def _prefilter_candidates(
|
||||
candidates: list[Component],
|
||||
optimize_keys: list[str],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
nutrient_cache: dict[str, dict[str, float]],
|
||||
*,
|
||||
prefilter_k: int,
|
||||
must_keep_ids: set[str] | None = None,
|
||||
) -> list[Component]:
|
||||
must_keep_ids = must_keep_ids or set()
|
||||
pinned = [c for c in candidates if c.id in must_keep_ids]
|
||||
rest = [c for c in candidates if c.id not in must_keep_ids]
|
||||
slots = max(prefilter_k - len(pinned), 0)
|
||||
if len(candidates) <= prefilter_k:
|
||||
return candidates
|
||||
if slots <= 0:
|
||||
return pinned[:prefilter_k]
|
||||
|
||||
prices = [float(c.price) for c in rest if c.price is not None]
|
||||
median_price = sorted(prices)[len(prices) // 2] if prices else 1.0
|
||||
if median_price <= 0:
|
||||
median_price = 1.0
|
||||
|
||||
scored: list[tuple[float, Component]] = []
|
||||
for comp in rest:
|
||||
price = float(comp.price) if comp.price is not None else median_price
|
||||
cost_part = price / median_price
|
||||
nutrient_part = _rough_component_value(comp, optimize_keys, norms, nutrient_cache)
|
||||
if nutrient_part is None:
|
||||
score = cost_part
|
||||
else:
|
||||
score = 0.4 * cost_part + 0.6 * nutrient_part
|
||||
scored.append((score, comp))
|
||||
scored.sort(key=lambda x: x[0])
|
||||
return pinned + [comp for _, comp in scored[:slots]]
|
||||
|
||||
|
||||
def _load_profile(profile_id: str) -> LabAnimalProfile:
|
||||
profile = LabAnimalProfile.query.filter_by(id=profile_id, is_deleted=False).first()
|
||||
if profile is None:
|
||||
raise LookupError("Профиль не найден")
|
||||
return profile
|
||||
|
||||
|
||||
def _load_eligible_components(candidate_ids: list[str]) -> tuple[list[Component], list[dict[str, Any]]]:
|
||||
unique = list(dict.fromkeys(candidate_ids))
|
||||
validations = validate_components(unique)
|
||||
ineligible = [v for v in validations if not v["eligible"]]
|
||||
if ineligible:
|
||||
raise ValueError("ineligible_components", ineligible)
|
||||
comps: list[Component] = []
|
||||
for cid in unique:
|
||||
comp = Component.query.filter_by(id=cid, is_deleted=False).first()
|
||||
if comp is None:
|
||||
raise ValueError("component_not_found", cid)
|
||||
comps.append(comp)
|
||||
return comps, validations
|
||||
|
||||
|
||||
def _resolve_group_selections(req: FormulateRequest) -> dict[str, list[str]]:
|
||||
selections = dict(req.group_selections or {})
|
||||
if req.main_feed_ids and not selections.get("rough"):
|
||||
selections["rough"] = list(req.main_feed_ids)
|
||||
return selections
|
||||
|
||||
|
||||
def formulate(req: FormulateRequest) -> dict[str, Any]:
|
||||
group_selections = _resolve_group_selections(req)
|
||||
candidate_ids = list(req.candidate_ids)
|
||||
if group_selections:
|
||||
pool_errors = validate_group_selections(group_selections)
|
||||
if pool_errors:
|
||||
raise ValueError("group_selection_invalid", pool_errors)
|
||||
candidate_ids = flatten_group_selections(group_selections)
|
||||
|
||||
if len(candidate_ids) < 3:
|
||||
raise ValueError("candidate_ids_min_3")
|
||||
if len(set(candidate_ids)) != len(candidate_ids):
|
||||
raise ValueError("candidate_ids_duplicate")
|
||||
|
||||
profile = _load_profile(req.profile_id)
|
||||
candidates, _ = _load_eligible_components(candidate_ids)
|
||||
nutrient_cache = nutrients_calc_dict_batch(candidate_ids)
|
||||
norms, dynamic = _resolve_norms(profile, req)
|
||||
optimize_keys = req.optimize_keys or list(_DEFAULT_OPTIMIZE_KEYS)
|
||||
heads = max(int(req.heads_per_trip or 10), 1)
|
||||
herd_scale = float(req.total_kg_per_head) * heads
|
||||
profile_mass_kg = req.mass_kg if req.mass_kg is not None else profile.mass_kg
|
||||
|
||||
must_keep: set[str] = set()
|
||||
for g in FEED_GROUPS:
|
||||
if g["required"]:
|
||||
must_keep.update(group_selections.get(g["id"]) or [])
|
||||
|
||||
prefilter_applied = len(candidates) > req.prefilter_k
|
||||
shortlist = _prefilter_candidates(
|
||||
candidates,
|
||||
optimize_keys,
|
||||
norms,
|
||||
nutrient_cache,
|
||||
prefilter_k=req.prefilter_k,
|
||||
must_keep_ids=must_keep,
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
evaluations = 0
|
||||
triplets_evaluated = 0
|
||||
triplet_winners: list[dict[str, Any]] = []
|
||||
|
||||
def _eval_triplet(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
) -> dict[str, Any] | None:
|
||||
nonlocal evaluations
|
||||
model = build_triplet_score_model(
|
||||
triplet,
|
||||
nutrient_cache=nutrient_cache,
|
||||
norms=norms,
|
||||
optimize_keys=optimize_keys,
|
||||
herd_scale=herd_scale,
|
||||
heads=heads,
|
||||
cost_weight=req.cost_weight,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
|
||||
def score_fn(shares: tuple[float, float, float]) -> float:
|
||||
_violation, _cost_head, score = score_model_shares(model, shares)
|
||||
return score
|
||||
|
||||
opt = optimize_shares_for_triplet(
|
||||
triplet,
|
||||
score_fn,
|
||||
min_share=req.min_share,
|
||||
grid_step=req.grid_step,
|
||||
)
|
||||
if opt is None:
|
||||
return None
|
||||
evaluations += opt.evaluations
|
||||
violation, cost_head, score = score_model_shares(model, opt.shares)
|
||||
lines = build_calc_lines(triplet, opt.shares, herd_scale, nutrient_cache)
|
||||
daily = [opt.shares[i] * herd_scale for i in range(3)]
|
||||
return {
|
||||
"score": score,
|
||||
"violation": violation,
|
||||
"costHead": cost_head,
|
||||
"costTotal": cost_head * heads,
|
||||
"triplet": triplet,
|
||||
"shares": opt.shares,
|
||||
"daily": daily,
|
||||
"lines": lines,
|
||||
}
|
||||
|
||||
for triplet in itertools.combinations(shortlist, 3):
|
||||
triplet_ids = {c.id for c in triplet}
|
||||
if not triplet_meets_group_rules(triplet_ids, group_selections):
|
||||
continue
|
||||
triplets_evaluated += 1
|
||||
best_triplet_result = _eval_triplet(triplet)
|
||||
if best_triplet_result is None:
|
||||
continue
|
||||
triplet_winners.append(best_triplet_result)
|
||||
|
||||
if not triplet_winners:
|
||||
if group_selections:
|
||||
raise ValueError("no_feasible_solution_groups")
|
||||
raise ValueError("no_feasible_solution")
|
||||
|
||||
triplet_winners.sort(key=lambda x: x["score"])
|
||||
best = triplet_winners[0]
|
||||
|
||||
full_calc = calculate_ration(
|
||||
profile.ration_type or "DAIRY",
|
||||
best["lines"],
|
||||
norms,
|
||||
heads_per_trip=heads,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
best["calc"] = full_calc
|
||||
best["violation"] = violation_score(
|
||||
full_calc.get("indicators") or [],
|
||||
optimize_keys,
|
||||
norms,
|
||||
)
|
||||
best["score"] = best["violation"] * req.cost_weight + best["costHead"]
|
||||
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
|
||||
alternatives = [
|
||||
{
|
||||
"componentIds": [c.id for c in item["triplet"]],
|
||||
"names": [c.name for c in item["triplet"]],
|
||||
"score": item["score"],
|
||||
"violation": item["violation"],
|
||||
"costPerHead": item["costHead"],
|
||||
}
|
||||
for item in triplet_winners[:3]
|
||||
]
|
||||
alternatives.sort(key=lambda x: x["score"])
|
||||
alternatives = alternatives[:3]
|
||||
|
||||
result_lines = []
|
||||
for i, comp in enumerate(best["triplet"]):
|
||||
s1, s2, s3 = best["shares"]
|
||||
share = (s1, s2, s3)[i]
|
||||
result_lines.append(
|
||||
{
|
||||
"componentId": comp.id,
|
||||
"name": comp.name,
|
||||
"dailyKg": round(best["daily"][i], 4),
|
||||
"sharePct": round(share * 100, 2),
|
||||
"pricePerKg": comp.price,
|
||||
"dryMatterPct": comp.dry_matter,
|
||||
}
|
||||
)
|
||||
|
||||
violation = best["violation"]
|
||||
return {
|
||||
"lines": result_lines,
|
||||
"candidatePoolSize": len(candidates),
|
||||
"groupSelections": group_selections,
|
||||
"shortlistedIds": [c.id for c in shortlist],
|
||||
"costTotal": best["costTotal"],
|
||||
"costPerHead": best["costHead"],
|
||||
"score": best["score"],
|
||||
"violation": violation,
|
||||
"feasible": violation < 0.01,
|
||||
"indicators": best["calc"].get("indicators") or [],
|
||||
"totals": best["calc"].get("totals") or [],
|
||||
"optimizeKeys": optimize_keys,
|
||||
"dynamicNorms": dynamic.get("dynamicNorms") or None,
|
||||
"normsMethod": dynamic.get("normsMethod"),
|
||||
"normsMeta": dynamic.get("normsMeta"),
|
||||
"alternatives": alternatives,
|
||||
"searchStats": {
|
||||
"evaluations": evaluations,
|
||||
"tripletsEvaluated": triplets_evaluated,
|
||||
"durationMs": duration_ms,
|
||||
"prefilterApplied": prefilter_applied,
|
||||
"prefilterK": req.prefilter_k,
|
||||
"scoreEngine": "fast",
|
||||
"optimizer": "slsqp",
|
||||
"normsMethod": dynamic.get("normsMethod"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_formulate_components() -> list[dict[str, Any]]:
|
||||
rows = (
|
||||
Component.query.filter_by(is_active=True, is_deleted=False)
|
||||
.order_by(Component.name)
|
||||
.all()
|
||||
)
|
||||
out: list[dict[str, Any]] = []
|
||||
for comp in rows:
|
||||
v = validate_component(comp.id)
|
||||
feed_group = _classify_feed_group(comp)
|
||||
out.append(
|
||||
{
|
||||
"id": comp.id,
|
||||
"name": comp.name,
|
||||
"type": comp.type,
|
||||
"feedGroup": feed_group,
|
||||
"eligible": v["eligible"],
|
||||
"missing": v["missing"],
|
||||
"warnings": v["warnings"],
|
||||
"dryMatterPct": v["dryMatterPct"],
|
||||
"hasPrice": v["hasPrice"],
|
||||
"price": v["price"],
|
||||
"mainFeedDmGPerKg": v.get("mainFeedDmGPerKg"),
|
||||
"isMainFeed": v.get("isMainFeed", False),
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from scipy.optimize import minimize
|
||||
|
||||
from app.models import Component
|
||||
|
||||
ShareTuple = tuple[float, float, float]
|
||||
ScoreFn = Callable[[ShareTuple], float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class OptimizeSharesResult:
|
||||
shares: ShareTuple
|
||||
score: float
|
||||
evaluations: int
|
||||
|
||||
|
||||
def _normalize_shares(s1: float, s2: float, min_share: float) -> ShareTuple | None:
|
||||
s3 = 1.0 - s1 - s2
|
||||
ms = max(min_share, 0.0)
|
||||
if s1 < ms - 1e-9 or s2 < ms - 1e-9 or s3 < ms - 1e-9:
|
||||
return None
|
||||
if abs(s1 + s2 + s3 - 1.0) > 1e-6:
|
||||
return None
|
||||
return (round(s1, 8), round(s2, 8), round(s3, 8))
|
||||
|
||||
|
||||
def _start_points(min_share: float, grid_step: float) -> list[tuple[float, float]]:
|
||||
"""Multi-start seeds: simplex center, corners, and a few grid_step hints."""
|
||||
ms = max(min_share, 0.0)
|
||||
max_pair = max(1.0 - 2 * ms, ms)
|
||||
center = round((1.0 - ms) / 3.0, 6)
|
||||
points: list[tuple[float, float]] = [
|
||||
(center, center),
|
||||
(ms, ms),
|
||||
(max_pair, ms),
|
||||
(ms, max_pair),
|
||||
(max_pair, max_pair),
|
||||
]
|
||||
step = max(grid_step, 0.1)
|
||||
if ms <= step <= max_pair:
|
||||
points.append((step, ms))
|
||||
points.append((ms, step))
|
||||
deduped: list[tuple[float, float]] = []
|
||||
seen: set[tuple[float, float]] = set()
|
||||
for s1, s2 in points:
|
||||
if s1 + s2 > 1.0 - ms + 1e-9:
|
||||
continue
|
||||
key = (round(s1, 6), round(s2, 6))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(key)
|
||||
return deduped
|
||||
|
||||
|
||||
def optimize_shares_for_triplet(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
score_fn: ScoreFn,
|
||||
*,
|
||||
min_share: float,
|
||||
grid_step: float = 0.1,
|
||||
) -> OptimizeSharesResult | None:
|
||||
del triplet
|
||||
ms = max(min_share, 0.0)
|
||||
max_s1 = max(1.0 - 2 * ms, ms)
|
||||
evaluations = 0
|
||||
|
||||
def objective(x: Any) -> float:
|
||||
nonlocal evaluations
|
||||
evaluations += 1
|
||||
shares = _normalize_shares(float(x[0]), float(x[1]), ms)
|
||||
if shares is None:
|
||||
return 1e18
|
||||
return score_fn(shares)
|
||||
|
||||
bounds = [(ms, max_s1), (ms, max_s1)]
|
||||
constraints = [{"type": "ineq", "fun": lambda x: 1.0 - ms - float(x[0]) - float(x[1])}]
|
||||
|
||||
best_score: float | None = None
|
||||
best_shares: ShareTuple | None = None
|
||||
|
||||
for s1, s2 in _start_points(ms, grid_step):
|
||||
if s1 + s2 > 1.0 - ms + 1e-9:
|
||||
continue
|
||||
try:
|
||||
res = minimize(
|
||||
objective,
|
||||
[s1, s2],
|
||||
method="SLSQP",
|
||||
bounds=bounds,
|
||||
constraints=constraints,
|
||||
options={"ftol": 1e-8, "maxiter": 40},
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if not res.success and res.fun >= 1e17:
|
||||
continue
|
||||
shares = _normalize_shares(float(res.x[0]), float(res.x[1]), ms)
|
||||
if shares is None:
|
||||
continue
|
||||
score = float(res.fun)
|
||||
if best_score is None or score < best_score:
|
||||
best_score = score
|
||||
best_shares = shares
|
||||
|
||||
if best_shares is None or best_score is None:
|
||||
return None
|
||||
return OptimizeSharesResult(shares=best_shares, score=best_score, evaluations=evaluations)
|
||||
@@ -0,0 +1,371 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.lab.calc.derived import apply_content_derived
|
||||
from app.lab.calc.nutrients import daily_intake_total, get_nutrient_value, norm_diff, weighted_average
|
||||
from app.lab.indicators import RATION_ALL_INDICATORS, indicator_by_key
|
||||
from app.models import Component
|
||||
|
||||
|
||||
def resolve_score_closure(optimize_keys: list[str]) -> frozenset[str]:
|
||||
"""Collect base + derived indicator keys needed to score optimize_keys."""
|
||||
needed: set[str] = set(optimize_keys)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for key in list(needed):
|
||||
defn = indicator_by_key(key)
|
||||
if defn is None:
|
||||
continue
|
||||
derived = defn.get("derived")
|
||||
if derived == "alias":
|
||||
dep = defn.get("alias_of")
|
||||
if dep and dep not in needed:
|
||||
needed.add(dep)
|
||||
changed = True
|
||||
elif derived in ("pct_of_dm", "g_per_kg_dm", "nel_per_kg_dm"):
|
||||
dep = defn.get("from_key")
|
||||
if dep and dep not in needed:
|
||||
needed.add(dep)
|
||||
changed = True
|
||||
elif derived in ("rnb", "bra_rnb"):
|
||||
for dep in ("crude_protein", "usp"):
|
||||
if dep not in needed:
|
||||
needed.add(dep)
|
||||
changed = True
|
||||
elif derived == "ratio":
|
||||
for dep in (defn.get("ratio_num"), defn.get("ratio_den")):
|
||||
if dep and dep not in needed:
|
||||
needed.add(dep)
|
||||
changed = True
|
||||
elif derived == "dm_pct_bw":
|
||||
if "dry_matter" not in needed:
|
||||
needed.add("dry_matter")
|
||||
changed = True
|
||||
return frozenset(needed)
|
||||
|
||||
|
||||
def _indicator_content(
|
||||
defn: dict[str, Any],
|
||||
active: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
*,
|
||||
heads_per_trip: int,
|
||||
) -> float | None:
|
||||
key = defn.get("key")
|
||||
if defn.get("aggregation") == "weighted_avg":
|
||||
return weighted_average(
|
||||
active,
|
||||
total_kg,
|
||||
defn["nutrient_keys"],
|
||||
indicator_key=key,
|
||||
)
|
||||
return daily_intake_total(
|
||||
active,
|
||||
defn["nutrient_keys"],
|
||||
heads_per_trip=heads_per_trip,
|
||||
indicator_key=key,
|
||||
)
|
||||
|
||||
|
||||
def compute_score_indicators(
|
||||
active: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
optimize_keys: list[str],
|
||||
*,
|
||||
heads_per_trip: int = 1,
|
||||
profile_mass_kg: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
closure = resolve_score_closure(optimize_keys)
|
||||
content_by_key: dict[str, float | None] = {}
|
||||
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
if key not in closure or defn.get("derived"):
|
||||
continue
|
||||
content_by_key[key] = _indicator_content(
|
||||
defn, active, total_kg, heads_per_trip=heads_per_trip
|
||||
)
|
||||
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
if key not in closure or not defn.get("derived"):
|
||||
continue
|
||||
content_by_key[key] = apply_content_derived(
|
||||
content_by_key,
|
||||
defn,
|
||||
total_kg=total_kg,
|
||||
heads_per_trip=heads_per_trip,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for key in optimize_keys:
|
||||
if key not in closure:
|
||||
continue
|
||||
defn = indicator_by_key(key)
|
||||
if defn is None:
|
||||
continue
|
||||
content = content_by_key.get(key)
|
||||
bounds = norms.get(key, {})
|
||||
min_v = bounds.get("min")
|
||||
max_v = bounds.get("max")
|
||||
diff = norm_diff(content, min_v, max_v)
|
||||
if content is None and min_v is None and max_v is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": defn["label"],
|
||||
"unit": defn["unit"],
|
||||
"min": min_v,
|
||||
"max": max_v,
|
||||
"content": content,
|
||||
"diff": diff,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def violation_score(
|
||||
indicators: list[dict[str, Any]],
|
||||
optimize_keys: list[str],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
) -> float:
|
||||
by_key = {row.get("key"): row for row in indicators if row.get("key")}
|
||||
total = 0.0
|
||||
counted = 0
|
||||
for key in optimize_keys:
|
||||
bounds = norms.get(key) or {}
|
||||
if bounds.get("min") is None and bounds.get("max") is None:
|
||||
continue
|
||||
row = by_key.get(key)
|
||||
if row is None:
|
||||
continue
|
||||
diff = row.get("diff")
|
||||
if diff is None:
|
||||
diff = norm_diff(row.get("content"), bounds.get("min"), bounds.get("max"))
|
||||
if diff is None:
|
||||
continue
|
||||
total += float(diff) ** 2
|
||||
counted += 1
|
||||
return total if counted else 0.0
|
||||
|
||||
|
||||
def _violation_from_content(
|
||||
content_by_key: dict[str, float | None],
|
||||
optimize_keys: list[str],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
) -> float:
|
||||
total = 0.0
|
||||
counted = 0
|
||||
for key in optimize_keys:
|
||||
bounds = norms.get(key) or {}
|
||||
if bounds.get("min") is None and bounds.get("max") is None:
|
||||
continue
|
||||
content = content_by_key.get(key)
|
||||
diff = norm_diff(content, bounds.get("min"), bounds.get("max"))
|
||||
if diff is None:
|
||||
continue
|
||||
total += float(diff) ** 2
|
||||
counted += 1
|
||||
return total if counted else 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TripletScoreModel:
|
||||
triplet: tuple[Component, Component, Component]
|
||||
nutrient_cache: dict[str, dict[str, float]]
|
||||
herd_scale: float
|
||||
heads: int
|
||||
cost_weight: float
|
||||
profile_mass_kg: float | None
|
||||
optimize_keys: list[str]
|
||||
norms: dict[str, dict[str, float | None]]
|
||||
closure: frozenset[str]
|
||||
intake_unit: dict[str, tuple[float | None, float | None, float | None]]
|
||||
weighted_vals: dict[str, tuple[float | None, float | None, float | None]]
|
||||
prices: tuple[float, float, float]
|
||||
|
||||
|
||||
def build_triplet_score_model(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
*,
|
||||
nutrient_cache: dict[str, dict[str, float]],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
optimize_keys: list[str],
|
||||
herd_scale: float,
|
||||
heads: int,
|
||||
cost_weight: float,
|
||||
profile_mass_kg: float | None,
|
||||
) -> TripletScoreModel:
|
||||
closure = resolve_score_closure(optimize_keys)
|
||||
kg_per_share = herd_scale / max(heads, 1)
|
||||
intake_unit: dict[str, tuple[float | None, float | None, float | None]] = {}
|
||||
weighted_vals: dict[str, tuple[float | None, float | None, float | None]] = {}
|
||||
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
if key not in closure or defn.get("derived"):
|
||||
continue
|
||||
vals: list[float | None] = []
|
||||
for comp in triplet:
|
||||
nutrients = nutrient_cache.get(comp.id, {})
|
||||
vals.append(
|
||||
get_nutrient_value(
|
||||
comp.dry_matter,
|
||||
nutrients,
|
||||
defn["nutrient_keys"],
|
||||
indicator_key=key,
|
||||
)
|
||||
)
|
||||
tup = (vals[0], vals[1], vals[2])
|
||||
if defn.get("aggregation") == "weighted_avg":
|
||||
weighted_vals[key] = tup
|
||||
else:
|
||||
intake_unit[key] = tuple(v * kg_per_share if v is not None else None for v in vals)
|
||||
|
||||
prices = tuple(
|
||||
float(comp.price) if comp.price is not None else 0.0 for comp in triplet
|
||||
)
|
||||
return TripletScoreModel(
|
||||
triplet=triplet,
|
||||
nutrient_cache=nutrient_cache,
|
||||
herd_scale=herd_scale,
|
||||
heads=heads,
|
||||
cost_weight=cost_weight,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
optimize_keys=optimize_keys,
|
||||
norms=norms,
|
||||
closure=closure,
|
||||
intake_unit=intake_unit,
|
||||
weighted_vals=weighted_vals,
|
||||
prices=prices,
|
||||
)
|
||||
|
||||
|
||||
def _content_from_shares(
|
||||
model: TripletScoreModel,
|
||||
shares: tuple[float, float, float],
|
||||
) -> dict[str, float | None]:
|
||||
content_by_key: dict[str, float | None] = {}
|
||||
total_kg = model.herd_scale
|
||||
|
||||
for key, coeffs in model.intake_unit.items():
|
||||
parts = [
|
||||
shares[i] * coeffs[i]
|
||||
for i in range(3)
|
||||
if coeffs[i] is not None
|
||||
]
|
||||
content_by_key[key] = sum(parts) if parts else None
|
||||
|
||||
for key, vals in model.weighted_vals.items():
|
||||
num = 0.0
|
||||
den = 0.0
|
||||
for i in range(3):
|
||||
if vals[i] is None:
|
||||
continue
|
||||
num += shares[i] * vals[i]
|
||||
den += shares[i]
|
||||
content_by_key[key] = (num / den) if den > 0 else None
|
||||
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
if key not in model.closure or not defn.get("derived"):
|
||||
continue
|
||||
content_by_key[key] = apply_content_derived(
|
||||
content_by_key,
|
||||
defn,
|
||||
total_kg=total_kg,
|
||||
heads_per_trip=model.heads,
|
||||
profile_mass_kg=model.profile_mass_kg,
|
||||
)
|
||||
return content_by_key
|
||||
|
||||
|
||||
def score_model_shares(
|
||||
model: TripletScoreModel,
|
||||
shares: tuple[float, float, float],
|
||||
) -> tuple[float, float, float]:
|
||||
"""Return (violation, cost_head, score) without building line dicts."""
|
||||
content = _content_from_shares(model, shares)
|
||||
violation = _violation_from_content(content, model.optimize_keys, model.norms)
|
||||
cost_total = sum(shares[i] * model.prices[i] * model.herd_scale for i in range(3))
|
||||
cost_head = cost_total / max(model.heads, 1)
|
||||
score = violation * model.cost_weight + cost_head
|
||||
return violation, cost_head, score
|
||||
|
||||
|
||||
def build_calc_lines(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
shares: tuple[float, float, float],
|
||||
herd_scale: float,
|
||||
nutrient_cache: dict[str, dict[str, float]],
|
||||
) -> list[dict[str, Any]]:
|
||||
lines: list[dict[str, Any]] = []
|
||||
for i, comp in enumerate(triplet):
|
||||
daily_kg = shares[i] * herd_scale
|
||||
lines.append(
|
||||
{
|
||||
"component_id": comp.id,
|
||||
"ingredient_name": comp.name,
|
||||
"daily_kg": daily_kg,
|
||||
"in_ration": True,
|
||||
"in_compound": False,
|
||||
"dry_matter": comp.dry_matter,
|
||||
"price_per_kg": comp.price,
|
||||
"nutrients": nutrient_cache.get(comp.id, {}),
|
||||
}
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def cost_per_head(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
shares: tuple[float, float, float],
|
||||
herd_scale: float,
|
||||
heads: int,
|
||||
) -> float:
|
||||
total = 0.0
|
||||
any_cost = False
|
||||
for i, comp in enumerate(triplet):
|
||||
kg = shares[i] * herd_scale
|
||||
price = comp.price
|
||||
if kg is None or price is None:
|
||||
continue
|
||||
total += float(kg) * float(price)
|
||||
any_cost = True
|
||||
if not any_cost:
|
||||
return 0.0
|
||||
return total / max(heads, 1)
|
||||
|
||||
|
||||
def score_triplet_shares(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
shares: tuple[float, float, float],
|
||||
*,
|
||||
nutrient_cache: dict[str, dict[str, float]],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
optimize_keys: list[str],
|
||||
herd_scale: float,
|
||||
heads: int,
|
||||
cost_weight: float,
|
||||
profile_mass_kg: float | None,
|
||||
) -> tuple[float, float, float, list[dict[str, Any]]]:
|
||||
"""Return (violation, cost_head, score, lines)."""
|
||||
model = build_triplet_score_model(
|
||||
triplet,
|
||||
nutrient_cache=nutrient_cache,
|
||||
norms=norms,
|
||||
optimize_keys=optimize_keys,
|
||||
herd_scale=herd_scale,
|
||||
heads=heads,
|
||||
cost_weight=cost_weight,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
violation, cost_head, score = score_model_shares(model, shares)
|
||||
lines = build_calc_lines(triplet, shares, herd_scale, nutrient_cache)
|
||||
return violation, cost_head, score, lines
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.lab.calc.feed_groups import classify_feed_group
|
||||
from app.lab.calc.gfe_policies import default_om_digestibility_pct
|
||||
from app.lab.nutrient_schema import read_from_mapping
|
||||
from app.lab.services.component_nutrients import derive_context_for_component, nutrients_full_dict, nutrients_is_empty
|
||||
from app.models import Component
|
||||
|
||||
_REQUIRED_EAV_KEYS = ("Сыр. Протеин", "Сырая клетч", "Сырой жир")
|
||||
_MAIN_FEED_KEYS = ("Осн.Корм", "СВ Основной корм")
|
||||
_OMD_KEYS = ("ВРХ Орг Вещ", "КРС Орг Вещ")
|
||||
|
||||
|
||||
def main_feed_dm_g_per_kg(component_id: str | None) -> float | None:
|
||||
if not component_id:
|
||||
return None
|
||||
full = nutrients_full_dict(component_id)
|
||||
val = read_from_mapping(full, _MAIN_FEED_KEYS)
|
||||
return float(val) if val is not None else None
|
||||
|
||||
|
||||
def validate_component(component_id: str) -> dict[str, Any]:
|
||||
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
|
||||
if comp is None:
|
||||
return {
|
||||
"id": component_id,
|
||||
"name": None,
|
||||
"eligible": False,
|
||||
"missing": ["component_not_found"],
|
||||
"warnings": [],
|
||||
"dryMatterPct": None,
|
||||
"hasPrice": False,
|
||||
"price": None,
|
||||
}
|
||||
|
||||
missing: list[str] = []
|
||||
warnings: list[str] = []
|
||||
dry_matter_pct = comp.dry_matter
|
||||
if dry_matter_pct is None or float(dry_matter_pct) <= 0:
|
||||
missing.append("dry_matter")
|
||||
|
||||
full = nutrients_full_dict(component_id)
|
||||
if nutrients_is_empty(component_id):
|
||||
missing.append("nutrients_empty")
|
||||
else:
|
||||
for key in _REQUIRED_EAV_KEYS:
|
||||
if read_from_mapping(full, (key,)) is None:
|
||||
missing.append(key)
|
||||
|
||||
has_price = comp.price is not None and float(comp.price) >= 0
|
||||
if not has_price:
|
||||
warnings.append("price_missing")
|
||||
|
||||
main_feed_dm = read_from_mapping(full, _MAIN_FEED_KEYS)
|
||||
if main_feed_dm is None:
|
||||
warnings.append("main_feed_unset")
|
||||
|
||||
feed_group = classify_feed_group(comp)
|
||||
omd = read_from_mapping(full, _OMD_KEYS)
|
||||
if feed_group in ("rough", "succulent") and omd is None:
|
||||
warnings.append("omd_missing")
|
||||
elif omd is None and comp.dry_matter and float(comp.dry_matter) > 0:
|
||||
ctx = derive_context_for_component(component_id, full)
|
||||
warnings.append(
|
||||
f"omd_defaulted:{default_om_digestibility_pct(ctx):.0f}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": comp.id,
|
||||
"name": comp.name,
|
||||
"eligible": len(missing) == 0,
|
||||
"missing": missing,
|
||||
"warnings": warnings,
|
||||
"dryMatterPct": dry_matter_pct,
|
||||
"hasPrice": has_price,
|
||||
"price": comp.price,
|
||||
"mainFeedDmGPerKg": main_feed_dm,
|
||||
"isMainFeed": main_feed_dm is not None and float(main_feed_dm) > 0,
|
||||
"feedGroup": feed_group,
|
||||
}
|
||||
|
||||
|
||||
def validate_components(component_ids: list[str]) -> list[dict[str, Any]]:
|
||||
return [validate_component(cid) for cid in component_ids]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Динамические нормы по уравнениям GfE (Германия)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
USP_DYNAMIC_KEY = "usp"
|
||||
NEL_DYNAMIC_KEY = "nel"
|
||||
|
||||
# GfE 2001 Milchkühe — Erhaltung + Milch (Standardmilch / FCM)
|
||||
NEL_MAINTENANCE_COEFF = 0.293 # MJ NEL / (kg LM)^0,75 / Tag
|
||||
NEL_PER_KG_MILK_MJ = 3.3 # MJ NEL / kg Milch (FCM)
|
||||
|
||||
|
||||
def gfe_usp_min_g(
|
||||
mass_kg: float | None,
|
||||
milk_yield_kg: float | None = None,
|
||||
) -> float | None:
|
||||
"""
|
||||
Минимальная суточная потребность в усвояемом протеине (уСП / nXP), г/сут.
|
||||
|
||||
GfE: 0,09 × (масса^0,75) × 6,25 + удой × 85 г.
|
||||
"""
|
||||
if mass_kg is None or mass_kg <= 0:
|
||||
return None
|
||||
maintenance = 0.09 * (mass_kg**0.75) * 6.25
|
||||
milk = max(float(milk_yield_kg or 0), 0.0) * 85.0
|
||||
return maintenance + milk
|
||||
|
||||
|
||||
def gfe_nel_min_mj(
|
||||
mass_kg: float | None,
|
||||
milk_yield_kg: float | None = None,
|
||||
) -> float | None:
|
||||
"""
|
||||
Минимальная суточная потребность в ЧЭЛ (NEL), МДж/сут.
|
||||
|
||||
GfE 2001: 0,293 × LM^0,75 + удой × 3,3 (MJ NEL на кг молока).
|
||||
"""
|
||||
if mass_kg is None or mass_kg <= 0:
|
||||
return None
|
||||
maintenance = NEL_MAINTENANCE_COEFF * (mass_kg**0.75)
|
||||
milk = max(float(milk_yield_kg or 0), 0.0) * NEL_PER_KG_MILK_MJ
|
||||
return maintenance + milk
|
||||
|
||||
|
||||
def preview_dynamic_norms(
|
||||
mass_kg: float | None,
|
||||
milk_yield_kg: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Расчётные min по GfE для UI (без учёта сохранённых норм)."""
|
||||
_, dynamic = apply_dynamic_norms(
|
||||
{},
|
||||
mass_kg=mass_kg,
|
||||
milk_yield_kg=milk_yield_kg,
|
||||
)
|
||||
return dynamic
|
||||
|
||||
|
||||
def apply_dynamic_norms(
|
||||
stored: dict[str, dict[str, float | None]],
|
||||
*,
|
||||
mass_kg: float | None,
|
||||
milk_yield_kg: float | None,
|
||||
ration_type: str | None = None,
|
||||
force_dynamic: bool = False,
|
||||
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
|
||||
"""
|
||||
Заполняет нормы по GfE, если в БД min не задан.
|
||||
force_dynamic=True — пересчитать min уСП/ЧЭЛ по массе и удою даже при нормах в БД
|
||||
(автоготовка с явными mass_kg / milk_yield_kg).
|
||||
Возвращает (resolved_norms, dynamic_meta).
|
||||
"""
|
||||
del ration_type
|
||||
resolved: dict[str, dict[str, float | None]] = {
|
||||
key: {"min": bounds.get("min"), "max": bounds.get("max")}
|
||||
for key, bounds in stored.items()
|
||||
}
|
||||
dynamic: dict[str, Any] = {}
|
||||
|
||||
for key, compute, formula in (
|
||||
(USP_DYNAMIC_KEY, gfe_usp_min_g, "0.09×масса^0.75×6.25 + удой×85"),
|
||||
(NEL_DYNAMIC_KEY, gfe_nel_min_mj, "0.293×масса^0.75 + удой×3.3"),
|
||||
):
|
||||
bounds = resolved.get(key, {"min": None, "max": None})
|
||||
if force_dynamic or bounds.get("min") is None:
|
||||
computed = compute(mass_kg, milk_yield_kg)
|
||||
if computed is not None:
|
||||
entry = dict(bounds)
|
||||
entry["min"] = computed
|
||||
resolved[key] = entry
|
||||
dynamic[key] = {"min": computed, "formula": formula}
|
||||
|
||||
return resolved, dynamic
|
||||
@@ -0,0 +1,84 @@
|
||||
"""WESP-политики расчёта на базе GfE 2001 (отличия от zootech Excel — осознанные)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
FeedGroup = Literal["rough", "succulent", "concentrate", "other", "unknown"]
|
||||
|
||||
# --- GfE 2001 константы (формулы не меняем) ---
|
||||
NEL_Q_COEFF = 0.004
|
||||
NEL_Q_REF_PCT = 57.0
|
||||
NEL_BASE = 0.6
|
||||
|
||||
GE_CP = 0.0239
|
||||
GE_FAT = 0.0398
|
||||
GE_FIBER = 0.0201
|
||||
GE_NFE = 0.0175
|
||||
|
||||
ME_FAT = 0.0312
|
||||
ME_FIBER = 0.0136
|
||||
ME_OR_RESIDUE = 0.0147
|
||||
ME_CP = 0.00234
|
||||
|
||||
USP_FAT_THRESHOLD_G_PER_KG_DM = 70.0 # 7% СЖ/кг СВ
|
||||
|
||||
# DCAB (Na+K)-(Cl+S), мэкв при минералах в г/кг СВ
|
||||
DCAB_NA = 43.5
|
||||
DCAB_K = 25.6
|
||||
DCAB_CL = 28.2
|
||||
DCAB_S = 62.4
|
||||
|
||||
# Дефолты переваримости при пустых коэфф. (Excel legacy, кроме ОВ)
|
||||
DEFAULT_CP_DIGEST_PCT = 86.0
|
||||
DEFAULT_FAT_DIGEST_PCT = 75.0
|
||||
DEFAULT_FIBER_DIGEST_PCT = 86.0
|
||||
DEFAULT_NFE_DIGEST_PCT = 94.0
|
||||
DEFAULT_INSOLUBLE_PROTEIN_PCT = 15.0
|
||||
DEFAULT_PROTEIN_FRACTION_PCT = 18.9
|
||||
|
||||
# WESP: дефолт ВРХ орг. вещ. при пустом поле — по классу корма
|
||||
DEFAULT_OMD_ROUGH_PCT = 65.0
|
||||
DEFAULT_OMD_SUCCULENT_PCT = 72.0
|
||||
DEFAULT_OMD_CONCENTRATE_PCT = 91.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeriveContext:
|
||||
feed_group: FeedGroup = "unknown"
|
||||
is_main_feed: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_cells(cls, *, main_feed_g: float = 0.0, feed_group: FeedGroup = "unknown") -> DeriveContext:
|
||||
return cls(
|
||||
feed_group=feed_group,
|
||||
is_main_feed=main_feed_g > 0,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def infer_from_cells(cls, cells: dict[str, float], feed_group: FeedGroup = "unknown") -> DeriveContext:
|
||||
main_feed = cells.get("E", 0.0)
|
||||
return cls.from_cells(main_feed_g=main_feed, feed_group=feed_group)
|
||||
|
||||
|
||||
def default_om_digestibility_pct(ctx: DeriveContext | None) -> float:
|
||||
"""Дефолт переваримости ОВ (%) при отсутствии ВРХ Орг Вещ."""
|
||||
if ctx is None:
|
||||
return DEFAULT_OMD_CONCENTRATE_PCT
|
||||
if ctx.is_main_feed or ctx.feed_group == "rough":
|
||||
return DEFAULT_OMD_ROUGH_PCT
|
||||
if ctx.feed_group == "succulent":
|
||||
return DEFAULT_OMD_SUCCULENT_PCT
|
||||
return DEFAULT_OMD_CONCENTRATE_PCT
|
||||
|
||||
|
||||
def default_digestibility_coefficients() -> dict[str, float]:
|
||||
return {
|
||||
"cp": DEFAULT_CP_DIGEST_PCT,
|
||||
"fat": DEFAULT_FAT_DIGEST_PCT,
|
||||
"fiber": DEFAULT_FIBER_DIGEST_PCT,
|
||||
"nfe": DEFAULT_NFE_DIGEST_PCT,
|
||||
"insoluble_protein": DEFAULT_INSOLUBLE_PROTEIN_PCT,
|
||||
"protein_fraction": DEFAULT_PROTEIN_FRACTION_PCT,
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Каталог показателей zootech «База сырья» (109 колонок)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openpyxl.utils import column_index_from_string, get_column_letter
|
||||
|
||||
# Заголовки row 3 в xlsx_extracted/data_csv_cleaned/База сырья.csv
|
||||
INGREDIENT_HEADERS: tuple[str, ...] = (
|
||||
"№",
|
||||
"Наименование",
|
||||
"Цена 1 кг",
|
||||
"СВ",
|
||||
"Осн.Корм",
|
||||
"Сыр. Протеин",
|
||||
"уСП",
|
||||
"БРА",
|
||||
"ЧЭЛ- КРС",
|
||||
"ОЭ-КРС",
|
||||
"Сырая клетч",
|
||||
"Структур клетч",
|
||||
"Сырой жир",
|
||||
"НДК",
|
||||
"КДК",
|
||||
"NFC",
|
||||
"Ca",
|
||||
"P",
|
||||
"Mg",
|
||||
"Fe",
|
||||
"Zn",
|
||||
"Cu",
|
||||
"Co",
|
||||
"Mn",
|
||||
"Se",
|
||||
"J",
|
||||
"Na",
|
||||
"K",
|
||||
"CL",
|
||||
"S",
|
||||
"DCAB Форм",
|
||||
"Сахар и Крохм",
|
||||
"Нераств Крохм",
|
||||
"Сахар",
|
||||
"Крахмал",
|
||||
"Нераств крахмал",
|
||||
"Вит А",
|
||||
"Вит D",
|
||||
"Вит Е",
|
||||
"Вит В1",
|
||||
"Вит В2",
|
||||
"Вит В6",
|
||||
"Вит В12",
|
||||
"Пант Кальц",
|
||||
"Никот Ки-та",
|
||||
"Фол ки-та",
|
||||
"Холин",
|
||||
"Биотин",
|
||||
"Сырая зола",
|
||||
"БЕР",
|
||||
"Лизин",
|
||||
"Метионин",
|
||||
"Треонин",
|
||||
"Триптофан",
|
||||
"Изолейцин",
|
||||
"Лейцин",
|
||||
"Валин",
|
||||
"Каротин",
|
||||
"b -Каротин",
|
||||
"Линолевая к-та",
|
||||
"Линоленовая ки-та",
|
||||
"Масляная ки-та",
|
||||
"Арахидоновая ки-та",
|
||||
"Полиэновая ки-та",
|
||||
"Мочевина",
|
||||
"СВ Основной корм",
|
||||
"ВРХ Орг Вещ",
|
||||
"Перевар Орг Вещ",
|
||||
"КРС Протеин",
|
||||
"Переварим Протеин",
|
||||
"КРС Сырой жир",
|
||||
"Переварим Сырой жир",
|
||||
"КРС Сырая клетч",
|
||||
"Переварим сырая клетч",
|
||||
"КРС БЭВ",
|
||||
"Переварим БЭВ",
|
||||
"ВЕ",
|
||||
"OЭ КРС форм",
|
||||
"ЧЭЛ - КРС Форм",
|
||||
"НДК Общ",
|
||||
"НДК Осн. Корм",
|
||||
"КДК общ",
|
||||
"% нераствор протеин",
|
||||
"Нерастворим прот",
|
||||
"СЖ/кг СВ",
|
||||
"НСП/кг СВ",
|
||||
"СП/кг СВ",
|
||||
"пОВ/кг СВ",
|
||||
"пСЖ/кг СВ",
|
||||
"уСП<7%CЖ",
|
||||
"уСП>7%CЖ",
|
||||
"уСП/кг СВ формул",
|
||||
"уСП в ОР",
|
||||
"уСП формул",
|
||||
"БРА",
|
||||
"Нераств крохмал",
|
||||
"доля крохмала",
|
||||
"Доля белка",
|
||||
"% перев в кишках",
|
||||
"OEB",
|
||||
"Синтез Мдж",
|
||||
"Промеж рез 1",
|
||||
"Промеж рез 2",
|
||||
"Метаб ОЕТ",
|
||||
"Метаб лизин",
|
||||
"Метабол Треон",
|
||||
"Метабол Лейцин",
|
||||
"Метабол Изолейц",
|
||||
"Метабол Валин",
|
||||
)
|
||||
|
||||
HEADER_TO_LETTER: dict[str, str] = {
|
||||
header: get_column_letter(i + 1) for i, header in enumerate(INGREDIENT_HEADERS)
|
||||
}
|
||||
|
||||
LETTER_TO_HEADER: dict[str, str] = {
|
||||
get_column_letter(i + 1): header for i, header in enumerate(INGREDIENT_HEADERS)
|
||||
}
|
||||
|
||||
# Колонки с формулами в шаблонной строке 6 (native port, не Excel runtime).
|
||||
DERIVED_LETTERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"AE",
|
||||
"AF",
|
||||
"AG",
|
||||
"AX",
|
||||
"BN",
|
||||
"BP",
|
||||
"BR",
|
||||
"BT",
|
||||
"BV",
|
||||
"BX",
|
||||
"BY",
|
||||
"BZ",
|
||||
"CA",
|
||||
"CF",
|
||||
"CG",
|
||||
"CH",
|
||||
"CI",
|
||||
"CJ",
|
||||
"CK",
|
||||
"CL",
|
||||
"CM",
|
||||
"CN",
|
||||
"CP",
|
||||
"CQ",
|
||||
"CX",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DA",
|
||||
"DB",
|
||||
"DC",
|
||||
"DD",
|
||||
"DE",
|
||||
}
|
||||
)
|
||||
|
||||
# Отображаемые поля G–J синхронизируются с расчётными CP/CQ/CA/BZ.
|
||||
DISPLAY_SYNC: tuple[tuple[str, str], ...] = (
|
||||
("G", "CP"), # уСП
|
||||
("H", "CQ"), # RNB (legacy заголовок «БРА», дубль CQ)
|
||||
("I", "CA"), # ЧЭЛ- КРС
|
||||
("J", "BZ"), # ОЭ-КРС
|
||||
)
|
||||
|
||||
DERIVED_HEADERS: frozenset[str] = frozenset(
|
||||
LETTER_TO_HEADER[letter] for letter in DERIVED_LETTERS
|
||||
) | frozenset(LETTER_TO_HEADER[g] for g, _ in DISPLAY_SYNC)
|
||||
|
||||
INPUT_HEADERS: frozenset[str] = frozenset(INGREDIENT_HEADERS) - DERIVED_HEADERS - frozenset(
|
||||
("№", "Наименование", "Цена 1 кг")
|
||||
)
|
||||
|
||||
# Дублирующий заголовок «БРА»/RNB (H и CQ) — в derive используем CQ.
|
||||
DUPLICATE_HEADERS: frozenset[str] = frozenset({"БРА"})
|
||||
|
||||
|
||||
def letter_index(letter: str) -> int:
|
||||
return column_index_from_string(letter) - 1
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Native derive формул zootech «База сырья» — WESP GfE 2001 engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.lab.calc.gfe_policies import (
|
||||
DCAB_CL,
|
||||
DCAB_K,
|
||||
DCAB_NA,
|
||||
DCAB_S,
|
||||
DEFAULT_CP_DIGEST_PCT,
|
||||
DEFAULT_FAT_DIGEST_PCT,
|
||||
DEFAULT_FIBER_DIGEST_PCT,
|
||||
DEFAULT_INSOLUBLE_PROTEIN_PCT,
|
||||
DEFAULT_NFE_DIGEST_PCT,
|
||||
DEFAULT_PROTEIN_FRACTION_PCT,
|
||||
DeriveContext,
|
||||
GE_CP,
|
||||
GE_FAT,
|
||||
GE_FIBER,
|
||||
GE_NFE,
|
||||
ME_CP,
|
||||
ME_FAT,
|
||||
ME_FIBER,
|
||||
ME_OR_RESIDUE,
|
||||
NEL_BASE,
|
||||
NEL_Q_COEFF,
|
||||
NEL_Q_REF_PCT,
|
||||
USP_FAT_THRESHOLD_G_PER_KG_DM,
|
||||
default_om_digestibility_pct,
|
||||
)
|
||||
from app.lab.calc.ingredient_catalog import (
|
||||
DISPLAY_SYNC,
|
||||
HEADER_TO_LETTER,
|
||||
INGREDIENT_HEADERS,
|
||||
LETTER_TO_HEADER,
|
||||
)
|
||||
|
||||
|
||||
def _parse_num(value: Any) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
n = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None if n != n else n
|
||||
|
||||
|
||||
def _normalize_key(value: str) -> str:
|
||||
return " ".join((value or "").split()).strip().lower()
|
||||
|
||||
|
||||
def _v(cells: dict[str, float], letter: str, default: float = 0.0) -> float:
|
||||
return cells.get(letter, default)
|
||||
|
||||
|
||||
def _if_pos(test: float, when_true, when_false: float = 0.0) -> float:
|
||||
"""Excel IF(test>0, …) — ветка when_true не вычисляется при test<=0."""
|
||||
if test > 0:
|
||||
return when_true() if callable(when_true) else when_true
|
||||
return when_false
|
||||
|
||||
|
||||
def dict_to_cells(data: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""Словарь {заголовок: значение} → {буква колонки: значение}."""
|
||||
cells: dict[str, float] = {}
|
||||
if not data:
|
||||
return cells
|
||||
norm_index = {_normalize_key(h): h for h in INGREDIENT_HEADERS}
|
||||
for raw_key, raw_val in data.items():
|
||||
n = _parse_num(raw_val)
|
||||
if n is None:
|
||||
continue
|
||||
nk = _normalize_key(str(raw_key))
|
||||
header = norm_index.get(nk)
|
||||
if header is None:
|
||||
continue
|
||||
letter = HEADER_TO_LETTER.get(header)
|
||||
if letter:
|
||||
cells[letter] = n
|
||||
return cells
|
||||
|
||||
|
||||
def cells_to_dict(cells: dict[str, float]) -> dict[str, float]:
|
||||
out: dict[str, float] = {}
|
||||
for letter, value in cells.items():
|
||||
header = LETTER_TO_HEADER.get(letter)
|
||||
if header and header not in ("№", "Наименование", "Цена 1 кг"):
|
||||
out[header] = value
|
||||
return out
|
||||
|
||||
|
||||
def derive_cells(
|
||||
cells: dict[str, float],
|
||||
*,
|
||||
context: DeriveContext | None = None,
|
||||
) -> dict[str, float]:
|
||||
"""Пересчёт derived-колонок по цепочке формул row 6 «База сырья»."""
|
||||
c = dict(cells)
|
||||
ctx = context or DeriveContext.infer_from_cells(c)
|
||||
omd_default = default_om_digestibility_pct(ctx)
|
||||
|
||||
c["AE"] = DCAB_NA * _v(c, "AA") + DCAB_K * _v(c, "AB") - DCAB_CL * _v(c, "AC") - DCAB_S * _v(c, "AD")
|
||||
c["AG"] = _v(c, "AJ") * _v(c, "AI") / 100.0
|
||||
c["AF"] = _v(c, "AI") - c["AG"] + _v(c, "AH")
|
||||
c["AX"] = _v(c, "D") - _v(c, "F") - _v(c, "K") - _v(c, "M") - _v(c, "AW")
|
||||
c["BN"] = _if_pos(_v(c, "E"), lambda: _v(c, "D") / _v(c, "E") * 1000.0)
|
||||
c["BP"] = _if_pos(
|
||||
_v(c, "BO"),
|
||||
lambda: (_v(c, "D") - _v(c, "AW")) * _v(c, "BO") / 100.0,
|
||||
(_v(c, "D") - _v(c, "AW")) * omd_default / 100.0,
|
||||
)
|
||||
c["BR"] = _if_pos(
|
||||
_v(c, "BQ"), lambda: _v(c, "F") * _v(c, "BQ") / 100.0, _v(c, "F") * DEFAULT_CP_DIGEST_PCT / 100.0
|
||||
)
|
||||
c["BT"] = _if_pos(
|
||||
_v(c, "BS"), lambda: _v(c, "M") * _v(c, "BS") / 100.0, _v(c, "M") * DEFAULT_FAT_DIGEST_PCT / 100.0
|
||||
)
|
||||
c["BV"] = _if_pos(
|
||||
_v(c, "BU"), lambda: _v(c, "K") * _v(c, "BU") / 100.0, _v(c, "K") * DEFAULT_FIBER_DIGEST_PCT / 100.0
|
||||
)
|
||||
c["BX"] = _if_pos(
|
||||
_v(c, "BW"), lambda: c["AX"] * _v(c, "BW") / 100.0, c["AX"] * DEFAULT_NFE_DIGEST_PCT / 100.0
|
||||
)
|
||||
c["BY"] = GE_CP * _v(c, "F") + GE_FAT * _v(c, "M") + GE_FIBER * _v(c, "K") + GE_NFE * c["AX"]
|
||||
c["BZ"] = (
|
||||
ME_FAT * c["BT"]
|
||||
+ ME_FIBER * c["BV"]
|
||||
+ ME_OR_RESIDUE * (c["BP"] - c["BT"] - c["BV"])
|
||||
+ ME_CP * _v(c, "F")
|
||||
)
|
||||
c["CA"] = _if_pos(
|
||||
c["BY"],
|
||||
lambda: (NEL_BASE * (1.0 + NEL_Q_COEFF * (c["BZ"] / c["BY"] * 100.0 - NEL_Q_REF_PCT)) * c["BZ"]),
|
||||
)
|
||||
c["CF"] = _if_pos(
|
||||
_v(c, "CE"),
|
||||
lambda: _v(c, "F") * _v(c, "CE") / 100.0,
|
||||
_v(c, "F") * DEFAULT_INSOLUBLE_PROTEIN_PCT / 100.0,
|
||||
)
|
||||
c["CG"] = _if_pos(_v(c, "D"), lambda: _v(c, "M") * 1000.0 / _v(c, "D"))
|
||||
c["CH"] = _if_pos(_v(c, "D"), lambda: c["CF"] * 1000.0 / _v(c, "D"))
|
||||
c["CI"] = _if_pos(_v(c, "D"), lambda: _v(c, "F") * 1000.0 / _v(c, "D"))
|
||||
c["CJ"] = _if_pos(_v(c, "D"), lambda: c["BP"] / _v(c, "D"))
|
||||
c["CK"] = _if_pos(_v(c, "D"), lambda: c["BT"] / _v(c, "D"))
|
||||
c["CL"] = _if_pos(c["CI"], lambda: (187.7 - 115.4 * c["CH"] / c["CI"]) * c["CJ"] + 1.03 * c["CH"])
|
||||
c["CM"] = _if_pos(c["CI"], lambda: (196.1 - 127.5 * c["CH"] / c["CI"]) * (c["CJ"] - c["CK"]) + 1.03 * c["CH"])
|
||||
co = _v(c, "CO")
|
||||
if c["CG"] < USP_FAT_THRESHOLD_G_PER_KG_DM + 0.01:
|
||||
c["CN"] = c["CL"]
|
||||
elif c["CG"] > USP_FAT_THRESHOLD_G_PER_KG_DM:
|
||||
c["CN"] = c["CM"]
|
||||
else:
|
||||
c["CN"] = 0.0
|
||||
if co < 1.01:
|
||||
c["CP"] = c["CN"] * _v(c, "D") / 1000.0
|
||||
elif co > 1.0:
|
||||
c["CP"] = co
|
||||
else:
|
||||
c["CP"] = 0.0
|
||||
c["CQ"] = (_v(c, "F") - c["CP"]) / 6.25
|
||||
c["CX"] = _if_pos(
|
||||
_v(c, "CT"),
|
||||
lambda: _v(c, "F") * _v(c, "CT") / 100.0,
|
||||
_v(c, "F") * DEFAULT_PROTEIN_FRACTION_PCT / 100.0,
|
||||
)
|
||||
nel = c["CA"]
|
||||
c["CY"] = nel * _v(c, "CW")
|
||||
f_val = _v(c, "F")
|
||||
bo = _v(c, "BO")
|
||||
if f_val == 0.0:
|
||||
c["CZ"] = 0.0
|
||||
c["DA"] = 0.0
|
||||
c["DB"] = 0.0
|
||||
c["DC"] = 0.0
|
||||
c["DD"] = 0.0
|
||||
c["DE"] = 0.0
|
||||
else:
|
||||
c["DA"] = c["CX"] * (_v(c, "AY") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.071 * 0.8
|
||||
c["CZ"] = _if_pos(
|
||||
bo,
|
||||
lambda: c["CX"] * (_v(c, "AZ") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.018 * 0.8,
|
||||
)
|
||||
c["DB"] = _if_pos(
|
||||
bo,
|
||||
lambda: c["CX"] * (_v(c, "BA") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.044 * 0.8,
|
||||
)
|
||||
c["DC"] = _if_pos(
|
||||
_v(c, "BD"),
|
||||
lambda: c["CX"] * (_v(c, "BD") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.063 * 0.8,
|
||||
)
|
||||
c["DD"] = _if_pos(
|
||||
_v(c, "BC"),
|
||||
lambda: c["CX"] * (_v(c, "BC") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.049 * 0.8,
|
||||
)
|
||||
c["DE"] = _if_pos(
|
||||
_v(c, "BE"),
|
||||
lambda: c["CX"] * (_v(c, "BE") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.048 * 0.8,
|
||||
)
|
||||
|
||||
for display, source in DISPLAY_SYNC:
|
||||
c[display] = c[source]
|
||||
|
||||
return c
|
||||
|
||||
|
||||
def derive_ingredient_nutrients(
|
||||
data: dict[str, Any] | None,
|
||||
*,
|
||||
context: DeriveContext | None = None,
|
||||
) -> dict[str, float]:
|
||||
"""Полный набор показателей: входные + пересчитанные derived."""
|
||||
cells = dict_to_cells(data)
|
||||
return cells_to_dict(derive_cells(cells, context=context))
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.lab.calc.nutrients import parse_num
|
||||
from app.lab.constants import NORM_COLUMN_ALIASES, RATION_QUALITY_INDICATORS
|
||||
|
||||
|
||||
def merge_norms_from_profile(profile_data: Any, ration_type: str) -> dict[str, dict[str, float | None]]:
|
||||
del ration_type
|
||||
if not profile_data or not isinstance(profile_data, dict):
|
||||
return {}
|
||||
indicators = profile_data.get("indicators")
|
||||
if isinstance(indicators, dict):
|
||||
out: dict[str, dict[str, float | None]] = {}
|
||||
for key, bounds in indicators.items():
|
||||
if not isinstance(bounds, dict):
|
||||
continue
|
||||
out[str(key)] = {
|
||||
"min": parse_num(bounds.get("min")),
|
||||
"max": parse_num(bounds.get("max")),
|
||||
}
|
||||
return out
|
||||
out = {}
|
||||
for defn in RATION_QUALITY_INDICATORS:
|
||||
key = defn["key"]
|
||||
aliases = NORM_COLUMN_ALIASES.get(key)
|
||||
if not aliases:
|
||||
continue
|
||||
for alias in aliases:
|
||||
entry = profile_data.get(alias)
|
||||
if isinstance(entry, dict):
|
||||
out[key] = {
|
||||
"min": parse_num(entry.get("min")),
|
||||
"max": parse_num(entry.get("max")),
|
||||
}
|
||||
break
|
||||
return out
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Производные min/max норм из базовых показателей RACION."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.lab.indicators import RATION_ALL_INDICATORS, indicator_by_key
|
||||
|
||||
|
||||
def _norm_min(bounds: dict[str, float | None] | None) -> float | None:
|
||||
if not bounds:
|
||||
return None
|
||||
v = bounds.get("min")
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
def _apply_derived_min(
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
defn: dict[str, Any],
|
||||
*,
|
||||
mass_kg: float | None = None,
|
||||
) -> float | None:
|
||||
derived = defn.get("derived")
|
||||
key = defn["key"]
|
||||
if derived == "alias":
|
||||
src = norms.get(defn.get("alias_of") or "")
|
||||
if src and src.get("min") is not None:
|
||||
return src["min"]
|
||||
return None
|
||||
if derived == "pct_of_dm":
|
||||
src = _norm_min(norms.get(defn.get("from_key") or ""))
|
||||
dm = _norm_min(norms.get("dry_matter"))
|
||||
if src is None or not dm or dm <= 0:
|
||||
return None
|
||||
return src / dm * 100.0
|
||||
if derived == "g_per_kg_dm":
|
||||
src = _norm_min(norms.get(defn.get("from_key") or ""))
|
||||
dm = _norm_min(norms.get("dry_matter"))
|
||||
if src is None or not dm or dm <= 0:
|
||||
return None
|
||||
return src / (dm / 1000.0)
|
||||
if derived == "nel_per_kg_dm":
|
||||
dm = _norm_min(norms.get("dry_matter"))
|
||||
nel = _norm_min(norms.get("nel"))
|
||||
if not dm or dm <= 0 or nel is None:
|
||||
return None
|
||||
return nel / (dm / 1000.0)
|
||||
if derived == "ratio":
|
||||
num = _norm_min(norms.get(defn.get("ratio_num") or ""))
|
||||
den = _norm_min(norms.get(defn.get("ratio_den") or ""))
|
||||
if num is None or den is None or den == 0:
|
||||
return None
|
||||
return num / den
|
||||
if derived == "dm_pct_bw":
|
||||
dm = _norm_min(norms.get("dry_matter"))
|
||||
if dm is None or not mass_kg or mass_kg <= 0:
|
||||
return None
|
||||
return (dm / 1000.0 / mass_kg) * 100.0
|
||||
if derived == "ration_pct_bw":
|
||||
return None
|
||||
if derived in ("rnb", "bra_rnb"):
|
||||
return _norm_min(norms.get(key))
|
||||
return None
|
||||
|
||||
|
||||
def apply_derived_norms(
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
*,
|
||||
mass_kg: float | None = None,
|
||||
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
|
||||
"""Дополняет norms производными min; max не трогает."""
|
||||
out = {k: dict(v) for k, v in norms.items()}
|
||||
dynamic: dict[str, Any] = {}
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if not defn.get("derived"):
|
||||
continue
|
||||
key = defn["key"]
|
||||
if _norm_min(out.get(key)) is not None:
|
||||
continue
|
||||
val = _apply_derived_min(out, defn, mass_kg=mass_kg)
|
||||
if val is None:
|
||||
continue
|
||||
rounded = round(val, 3)
|
||||
out[key] = {"min": rounded, "max": out.get(key, {}).get("max")}
|
||||
dynamic[key] = {"min": rounded, "derived": defn.get("derived")}
|
||||
return out, dynamic
|
||||
@@ -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}
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from app.lab.nutrient_schema import resolve_sv_g_per_kg
|
||||
|
||||
|
||||
def normalize_key(value: str) -> str:
|
||||
return " ".join((value or "").split()).strip().lower()
|
||||
|
||||
|
||||
def parse_num(value: Any) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
n = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None if n != n else n
|
||||
|
||||
|
||||
def get_nutrient_value(
|
||||
dry_matter: float | None,
|
||||
nutrients: Mapping[str, Any] | None,
|
||||
nutrient_keys: list[str],
|
||||
*,
|
||||
indicator_key: str | None = None,
|
||||
) -> float | None:
|
||||
"""Только точное совпадение ключа (заголовок или indicator_key slug)."""
|
||||
nutrients = nutrients or {}
|
||||
if indicator_key:
|
||||
for k, v in nutrients.items():
|
||||
if normalize_key(str(k)) == normalize_key(indicator_key):
|
||||
n = parse_num(v)
|
||||
if n is not None:
|
||||
return n
|
||||
for search in nutrient_keys:
|
||||
target = normalize_key(search)
|
||||
for k, v in nutrients.items():
|
||||
if normalize_key(str(k)) != target:
|
||||
continue
|
||||
n = parse_num(v)
|
||||
if n is not None:
|
||||
return n
|
||||
if any(normalize_key(k) == "св" for k in nutrient_keys):
|
||||
return resolve_sv_g_per_kg(nutrients, dry_matter)
|
||||
return None
|
||||
|
||||
|
||||
def weighted_average(
|
||||
lines: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
nutrient_keys: list[str],
|
||||
*,
|
||||
indicator_key: str | None = None,
|
||||
) -> float | None:
|
||||
if total_kg <= 0:
|
||||
return None
|
||||
total = 0.0
|
||||
weight = 0.0
|
||||
for line in lines:
|
||||
kg = float(line.get("daily_kg") or 0)
|
||||
if kg <= 0:
|
||||
continue
|
||||
v = get_nutrient_value(
|
||||
line.get("dry_matter"),
|
||||
line.get("nutrients"),
|
||||
nutrient_keys,
|
||||
indicator_key=indicator_key,
|
||||
)
|
||||
if v is None:
|
||||
continue
|
||||
total += kg * v
|
||||
weight += kg
|
||||
if weight <= 0:
|
||||
return None
|
||||
return total / weight
|
||||
|
||||
|
||||
def daily_intake_total(
|
||||
lines: list[dict[str, Any]],
|
||||
nutrient_keys: list[str],
|
||||
*,
|
||||
heads_per_trip: int = 1,
|
||||
indicator_key: str | None = None,
|
||||
) -> float | None:
|
||||
"""Суточная доза на голову (г или МДж): Σ (кг/день/гол × г/кг). Как Excel Рацион КРС."""
|
||||
heads = max(int(heads_per_trip or 1), 1)
|
||||
total = 0.0
|
||||
any_value = False
|
||||
for line in lines:
|
||||
herd_kg = float(line.get("daily_kg") or 0)
|
||||
if herd_kg <= 0:
|
||||
continue
|
||||
kg = herd_kg / heads
|
||||
v = get_nutrient_value(
|
||||
line.get("dry_matter"),
|
||||
line.get("nutrients"),
|
||||
nutrient_keys,
|
||||
indicator_key=indicator_key,
|
||||
)
|
||||
if v is None:
|
||||
continue
|
||||
total += kg * v
|
||||
any_value = True
|
||||
return total if any_value else None
|
||||
|
||||
|
||||
def rnb(
|
||||
crude_protein: float | None,
|
||||
usp: float | None,
|
||||
) -> float | None:
|
||||
"""RNB (ruminal nitrogen balance, GfE): (сырой протеин − уСП) / 6,25."""
|
||||
if crude_protein is None or usp is None:
|
||||
return None
|
||||
return (crude_protein - usp) / 6.25
|
||||
|
||||
|
||||
def bra_rnb(crude_protein: float | None, usp: float | None) -> float | None:
|
||||
"""Deprecated alias for :func:`rnb`."""
|
||||
return rnb(crude_protein, usp)
|
||||
|
||||
|
||||
def norm_diff(
|
||||
content: float | None,
|
||||
min_val: float | None,
|
||||
max_val: float | None,
|
||||
) -> float | None:
|
||||
if content is None:
|
||||
return None
|
||||
if min_val is not None and content < min_val:
|
||||
return content - min_val
|
||||
if max_val is not None and content > max_val:
|
||||
return content - max_val
|
||||
return 0.0
|
||||
@@ -0,0 +1 @@
|
||||
"""Российские методики суточных норм (Москва / Петербург)."""
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Линейная интерполяция (аналог FRAC в методичке)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def frac(numerator: float, denominator: float) -> float:
|
||||
if denominator == 0:
|
||||
return 0.0
|
||||
return numerator / denominator
|
||||
|
||||
|
||||
def lerp(x: float, x1: float, n1: float, x2: float, n2: float) -> float:
|
||||
"""Norma = n1 + frac(n2 - n1, x2 - x1) * (x - x1)."""
|
||||
if x2 == x1:
|
||||
return n1
|
||||
return n1 + frac(n2 - n1, x2 - x1) * (x - x1)
|
||||
|
||||
|
||||
def popr_index(udoy: float, boundaries: list[float]) -> int:
|
||||
"""1-based индекс столбца POPR_K по суточному удою."""
|
||||
idx = 1
|
||||
for i, bound in enumerate(boundaries, start=1):
|
||||
if udoy >= bound:
|
||||
idx = i + 1
|
||||
return min(idx, len(boundaries) + 1)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Методика Москва — лактирующие коровы (NORM_1_1_CALC)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.lab.calc.racion.interp import popr_index
|
||||
from app.lab.calc.norms_derived import apply_derived_norms
|
||||
from app.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
|
||||
from app.lab.calc.racion.tables import load_moskwa_lactir
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MoscowDairyParams:
|
||||
mass_kg: float
|
||||
milk_yield_kg: float
|
||||
milk_fat_pct: float = 4.0
|
||||
lactation_no: int = 2
|
||||
body_condition: int = 1
|
||||
housing_system: int = 1
|
||||
|
||||
|
||||
def _row_lookup(rows: list[dict], npitv: int) -> dict | None:
|
||||
for row in rows:
|
||||
if row["npitv"] == npitv and row["pom"] == 1:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _popr_value(row: dict, udoy: float, boundaries: list[float]) -> tuple[float | None, float | None]:
|
||||
popr = row.get("popr_k") or []
|
||||
if not popr:
|
||||
return None, None
|
||||
idx = popr_index(udoy, boundaries) - 1
|
||||
idx = max(0, min(idx, len(popr) - 1))
|
||||
p_k = popr[idx]
|
||||
koef = float(row.get("koef") or 0)
|
||||
return p_k, koef
|
||||
|
||||
|
||||
def compute_moscow_norm(npitv: int, params: MoscowDairyParams) -> float | None:
|
||||
data = load_moskwa_lactir()
|
||||
boundaries = data.get("udoy_boundaries") or []
|
||||
row = _row_lookup(data.get("rows") or [], npitv)
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
mass = params.mass_kg
|
||||
udoy = params.milk_yield_kg
|
||||
jir = params.milk_fat_pct
|
||||
wmassa = mass * 1.02 if params.body_condition > 1 else mass
|
||||
|
||||
p_k, koef = _popr_value(row, udoy, boundaries)
|
||||
if p_k is None:
|
||||
return None
|
||||
|
||||
norma: float | None = None
|
||||
if npitv == 1:
|
||||
temp = 0.005 if udoy <= 22 else 0.0025
|
||||
norma = temp * (wmassa - 500) + p_k * udoy - ((4 - jir) * udoy) / 148
|
||||
elif npitv == 2:
|
||||
temp = 0.09 if udoy <= 22 else 0.065
|
||||
norma = temp * (wmassa - 500) + p_k * udoy - ((4 - jir) * udoy) / 15
|
||||
elif npitv == 3:
|
||||
temp = 0.017 if udoy <= 22 else 0.015
|
||||
norma = temp * (wmassa - 500) + p_k * udoy
|
||||
elif 4 <= npitv <= 24:
|
||||
norma = p_k * udoy + (wmassa - 500) * koef
|
||||
if npitv == 10:
|
||||
norma *= 0.393
|
||||
else:
|
||||
return None
|
||||
|
||||
if norma is None:
|
||||
return None
|
||||
if params.lactation_no == 1:
|
||||
norma *= 0.95
|
||||
elif params.lactation_no == 3:
|
||||
norma *= 1.05
|
||||
if params.housing_system == 2:
|
||||
norma *= 1.1
|
||||
return round(norma, 3)
|
||||
|
||||
|
||||
def resolve_moscow_dairy_norms(params: MoscowDairyParams) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
|
||||
resolved: dict[str, dict[str, float | None]] = {}
|
||||
dynamic: dict[str, Any] = {}
|
||||
for npitv in DAIRY_LACTIR_NPITV:
|
||||
value = compute_moscow_norm(npitv, params)
|
||||
if value is None:
|
||||
continue
|
||||
key = NPITV_TO_INDICATOR.get(npitv)
|
||||
if not key:
|
||||
continue
|
||||
resolved[key] = {"min": value, "max": None}
|
||||
dynamic[key] = {"min": value, "npitv": npitv, "method": "racion_moscow", "source": "racion"}
|
||||
resolved, derived_dyn = apply_derived_norms(resolved, mass_kg=params.mass_kg)
|
||||
for k, v in derived_dyn.items():
|
||||
dynamic[k] = {**v, "method": "racion_moscow", "source": "derived"}
|
||||
meta = {
|
||||
"method": "racion_moscow",
|
||||
"massKg": params.mass_kg,
|
||||
"milkYieldKg": params.milk_yield_kg,
|
||||
"milkFatPct": params.milk_fat_pct,
|
||||
}
|
||||
return resolved, {"meta": meta, "dynamic": dynamic}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""NPitV (справочник питательных веществ) → ключи показателей WESP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# NORMY_MOSKWA_LACTIR / NORM_1_1_CALC: NPitV 1–24 (pom=1)
|
||||
NPITV_TO_INDICATOR: dict[int, str] = {
|
||||
1: "feed_units",
|
||||
2: "oe",
|
||||
3: "dry_matter",
|
||||
4: "crude_protein",
|
||||
5: "digestible_protein",
|
||||
6: "crude_fat",
|
||||
7: "nel",
|
||||
8: "rnb",
|
||||
9: "usp",
|
||||
10: "sodium",
|
||||
11: "magnesium",
|
||||
12: "starch",
|
||||
13: "potassium",
|
||||
14: "calcium",
|
||||
15: "phosphorus",
|
||||
16: "iron",
|
||||
17: "copper",
|
||||
18: "zinc",
|
||||
19: "manganese",
|
||||
20: "cobalt",
|
||||
21: "iodine",
|
||||
22: "carotene",
|
||||
23: "vitamin_d",
|
||||
24: "vitamin_e",
|
||||
}
|
||||
|
||||
DAIRY_LACTIR_NPITV: tuple[int, ...] = tuple(range(1, 25))
|
||||
|
||||
# Обратная совместимость
|
||||
DAIRY_CORE_NPITV: tuple[int, ...] = (2, 3, 4, 5, 7, 8, 9, 10, 12, 14, 15)
|
||||
|
||||
INDICATOR_TO_NPITV: dict[str, int] = {v: k for k, v in NPITV_TO_INDICATOR.items()}
|
||||
|
||||
|
||||
def indicator_for_npitv(npitv: int) -> str | None:
|
||||
return NPITV_TO_INDICATOR.get(npitv)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Методика Петербург — лактирующие коровы (NORM_1_2_CALC)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.lab.calc.norms_derived import apply_derived_norms
|
||||
from app.lab.calc.racion.interp import lerp
|
||||
from app.lab.calc.racion.moscow import MoscowDairyParams
|
||||
from app.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
|
||||
from app.lab.calc.racion.piter_prep import PiterPrepError, PiterPrepResult, prepare_piter_calc
|
||||
from app.lab.calc.racion.tables import load_piter_lactir
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PiterDairyParams(MoscowDairyParams):
|
||||
konc_oe_sv: float = 10.3
|
||||
|
||||
|
||||
def _konc_bracket(konc: float, konc_values: list[float]) -> tuple[float, float]:
|
||||
sorted_k = sorted(set(konc_values))
|
||||
positive = [k for k in sorted_k if k > 0]
|
||||
if not positive:
|
||||
return konc, konc
|
||||
if konc <= positive[0]:
|
||||
return positive[0], positive[min(1, len(positive) - 1)]
|
||||
for i in range(len(positive) - 1):
|
||||
if positive[i] <= konc <= positive[i + 1]:
|
||||
return positive[i], positive[i + 1]
|
||||
return positive[-2], positive[-1]
|
||||
|
||||
|
||||
def _udoy_bracket(udoy_jir: float, udoys: list[float]) -> tuple[float, float]:
|
||||
sorted_u = sorted(set(udoys))
|
||||
if len(sorted_u) < 2:
|
||||
return sorted_u[0], sorted_u[0]
|
||||
if udoy_jir <= sorted_u[0]:
|
||||
return sorted_u[0], sorted_u[1]
|
||||
for i in range(len(sorted_u) - 1):
|
||||
if sorted_u[i] <= udoy_jir < sorted_u[i + 1]:
|
||||
return sorted_u[i], sorted_u[i + 1]
|
||||
return sorted_u[-2], sorted_u[-1]
|
||||
|
||||
|
||||
def _norm_at_mass(
|
||||
entry_normy: list[float],
|
||||
mass_ind: int,
|
||||
wmassa: float,
|
||||
m_a: float,
|
||||
m_b: float,
|
||||
) -> float | None:
|
||||
i = mass_ind - 1
|
||||
if i < 0 or i + 1 >= len(entry_normy):
|
||||
return None
|
||||
n_a, n_b = entry_normy[i], entry_normy[i + 1]
|
||||
if n_a <= 0 or n_b <= 0:
|
||||
return None
|
||||
return lerp(wmassa, m_a, n_a, m_b, n_b)
|
||||
|
||||
|
||||
def _entries_for(data: dict, npitv: int) -> list[dict]:
|
||||
npv = 4 if npitv == 5 else npitv
|
||||
return [e for e in data.get("entries") or [] if e["npitv"] == npv]
|
||||
|
||||
|
||||
def _compute_with_konc(
|
||||
entries: list[dict],
|
||||
npitv: int,
|
||||
params: PiterDairyParams,
|
||||
prep: PiterPrepResult,
|
||||
konc_pred: float,
|
||||
konc_sled: float,
|
||||
) -> float | None:
|
||||
by_konc = {k: [e for e in entries if abs(e["konc"] - k) < 1e-6] for k in (konc_pred, konc_sled)}
|
||||
|
||||
def _at_konc(konc: float) -> float | None:
|
||||
rows = by_konc.get(konc) or []
|
||||
if not rows:
|
||||
return None
|
||||
udoys = [e["udoy"] for e in rows]
|
||||
ud_pred, ud_sled = _udoy_bracket(prep.udoy_jir, udoys)
|
||||
if ud_pred == ud_sled:
|
||||
return None
|
||||
e_pred = next((e for e in rows if e["udoy"] == ud_pred), None)
|
||||
e_sled = next((e for e in rows if e["udoy"] == ud_sled), None)
|
||||
if not e_pred or not e_sled:
|
||||
return None
|
||||
n01 = _norm_at_mass(e_pred["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
|
||||
n02 = _norm_at_mass(e_sled["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
|
||||
if n01 is None or n02 is None:
|
||||
return None
|
||||
return lerp(prep.udoy_jir, ud_pred, n01, ud_sled, n02)
|
||||
|
||||
norma1 = _at_konc(konc_pred)
|
||||
norma2 = _at_konc(konc_sled)
|
||||
if norma1 is None or norma2 is None or konc_pred == konc_sled:
|
||||
return None
|
||||
norma = lerp(params.konc_oe_sv, konc_pred, norma1, konc_sled, norma2)
|
||||
if npitv == 5:
|
||||
norma *= 0.65
|
||||
return round(norma, 3)
|
||||
|
||||
|
||||
def _compute_konc_independent(
|
||||
entries: list[dict],
|
||||
params: PiterDairyParams,
|
||||
prep: PiterPrepResult,
|
||||
) -> float | None:
|
||||
rows = [e for e in entries if e["konc"] == -10]
|
||||
if not rows:
|
||||
return None
|
||||
udoys = [e["udoy"] for e in rows]
|
||||
ud_pred, ud_sled = _udoy_bracket(prep.udoy_jir, udoys)
|
||||
if ud_pred == ud_sled:
|
||||
return None
|
||||
e_pred = next((e for e in rows if e["udoy"] == ud_pred), None)
|
||||
e_sled = next((e for e in rows if e["udoy"] == ud_sled), None)
|
||||
if not e_pred or not e_sled:
|
||||
return None
|
||||
n01 = _norm_at_mass(e_pred["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
|
||||
n02 = _norm_at_mass(e_sled["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
|
||||
if n01 is None or n02 is None:
|
||||
return None
|
||||
norma = lerp(prep.udoy_jir, ud_pred, n01, ud_sled, n02)
|
||||
if params.housing_system == 2:
|
||||
norma *= 1.1
|
||||
return round(norma, 3)
|
||||
|
||||
|
||||
def compute_piter_norm(npitv: int, params: PiterDairyParams, prep: PiterPrepResult | None = None) -> float | None:
|
||||
data = load_piter_lactir()
|
||||
entries = _entries_for(data, npitv)
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
if prep is None:
|
||||
prep = prepare_piter_calc(
|
||||
mass_kg=params.mass_kg,
|
||||
milk_yield_kg=params.milk_yield_kg,
|
||||
milk_fat_pct=params.milk_fat_pct,
|
||||
konc_oe_sv=params.konc_oe_sv,
|
||||
body_condition=params.body_condition,
|
||||
)
|
||||
|
||||
konc_vals = sorted({e["konc"] for e in entries if e["konc"] > 0})
|
||||
if konc_vals:
|
||||
min_k = min(konc_vals)
|
||||
if min_k > 0:
|
||||
konc_pred, konc_sled = _konc_bracket(params.konc_oe_sv, konc_vals)
|
||||
return _compute_with_konc(entries, npitv, params, prep, konc_pred, konc_sled)
|
||||
return _compute_konc_independent(entries, params, prep)
|
||||
|
||||
|
||||
def resolve_piter_dairy_norms(params: PiterDairyParams) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
|
||||
prep = prepare_piter_calc(
|
||||
mass_kg=params.mass_kg,
|
||||
milk_yield_kg=params.milk_yield_kg,
|
||||
milk_fat_pct=params.milk_fat_pct,
|
||||
konc_oe_sv=params.konc_oe_sv,
|
||||
body_condition=params.body_condition,
|
||||
)
|
||||
resolved: dict[str, dict[str, float | None]] = {}
|
||||
dynamic: dict[str, Any] = {}
|
||||
for npitv in DAIRY_LACTIR_NPITV:
|
||||
value = compute_piter_norm(npitv, params, prep=prep)
|
||||
if value is None:
|
||||
continue
|
||||
key = NPITV_TO_INDICATOR.get(npitv)
|
||||
if not key:
|
||||
continue
|
||||
resolved[key] = {"min": value, "max": None}
|
||||
dynamic[key] = {"min": value, "npitv": npitv, "method": "racion_piter", "source": "racion"}
|
||||
resolved, derived_dyn = apply_derived_norms(resolved, mass_kg=params.mass_kg)
|
||||
for k, v in derived_dyn.items():
|
||||
dynamic[k] = {**v, "method": "racion_piter", "source": "derived"}
|
||||
meta = {
|
||||
"method": "racion_piter",
|
||||
"massKg": params.mass_kg,
|
||||
"milkYieldKg": params.milk_yield_kg,
|
||||
"koncOeSv": params.konc_oe_sv,
|
||||
"prep": {
|
||||
"massInd": prep.mass_ind,
|
||||
"mA": prep.m_a,
|
||||
"mB": prep.m_b,
|
||||
"koncPred": prep.konc_pred,
|
||||
"koncSled": prep.konc_sled,
|
||||
},
|
||||
}
|
||||
return resolved, {"meta": meta, "dynamic": dynamic}
|
||||
|
||||
|
||||
__all__ = ["PiterDairyParams", "PiterPrepError", "compute_piter_norm", "resolve_piter_dairy_norms"]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Подготовка параметров NORM_1_2_PREP (интервалы массы и концентрации)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.lab.calc.racion.interp import lerp
|
||||
from app.lab.calc.racion.tables import load_normy_info
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PiterPrepResult:
|
||||
mass_ind: int
|
||||
konc_pred: float
|
||||
konc_sled: float
|
||||
m_a: float
|
||||
m_b: float
|
||||
udoy_jir: float
|
||||
wmassa: float
|
||||
|
||||
|
||||
class PiterPrepError(ValueError):
|
||||
def __init__(self, code: int, message: str, *, info: str | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.info = info
|
||||
|
||||
|
||||
def _info_values(nperem: int) -> list[float]:
|
||||
data = load_normy_info()
|
||||
for row in data.get("rows") or []:
|
||||
if row.get("nperem") == nperem:
|
||||
return list(row.get("znachenie") or [])
|
||||
return []
|
||||
|
||||
|
||||
def _fl_from_str(values: list[float], index: int) -> float | None:
|
||||
"""1-based index как FlFromStr в RACION."""
|
||||
i = index - 1
|
||||
if i < 0 or i >= len(values):
|
||||
return None
|
||||
return float(values[i])
|
||||
|
||||
|
||||
def _wmassa(mass_kg: float, body_condition: int) -> float:
|
||||
if body_condition > 1:
|
||||
return mass_kg * 1.02
|
||||
return mass_kg
|
||||
|
||||
|
||||
def prepare_piter_calc(
|
||||
*,
|
||||
mass_kg: float,
|
||||
milk_yield_kg: float,
|
||||
milk_fat_pct: float,
|
||||
konc_oe_sv: float,
|
||||
body_condition: int = 1,
|
||||
) -> PiterPrepResult:
|
||||
"""Порт NORM_1_2_PREP: интервалы для NORM_1_2_CALC."""
|
||||
if milk_yield_kg <= 0:
|
||||
raise PiterPrepError(-11, "Укажите суточный удой, кг")
|
||||
if milk_fat_pct <= 0:
|
||||
raise PiterPrepError(-12, "Укажите жирность молока, %")
|
||||
if mass_kg <= 0:
|
||||
raise PiterPrepError(-14, "Укажите живую массу, кг")
|
||||
if konc_oe_sv <= 0:
|
||||
raise PiterPrepError(-15, "Укажите концентрацию ОЭ/СВ, МДж/кг СВ")
|
||||
|
||||
wmassa = _wmassa(mass_kg, body_condition)
|
||||
udoy_jir = milk_yield_kg * milk_fat_pct * 0.25
|
||||
|
||||
kol_konc_vals = _info_values(7)
|
||||
kol_mass_vals = _info_values(7)
|
||||
if len(kol_konc_vals) < 2 or len(kol_mass_vals) < 2:
|
||||
raise PiterPrepError(-1, "Справочник NORMY_INFO (NPerem=7) не задан")
|
||||
kol_konc = int(kol_konc_vals[0])
|
||||
kol_mass = int(kol_mass_vals[1])
|
||||
if kol_konc < 2 or kol_mass < 2:
|
||||
raise PiterPrepError(-1, "Некорректные размеры таблицы концентраций/масс")
|
||||
|
||||
masses = _info_values(6) or _info_values(14)
|
||||
koncss = _info_values(3)
|
||||
if not masses or not koncss or len(masses) < 2 or len(koncss) < 2:
|
||||
raise PiterPrepError(-1, "Справочник NORMY_INFO: массы или концентрации не заданы")
|
||||
|
||||
# Интервал концентрации
|
||||
konc_ind = 1
|
||||
for i in range(2, kol_konc):
|
||||
v = _fl_from_str(koncss, i)
|
||||
if v is not None and konc_oe_sv >= v:
|
||||
konc_ind = i
|
||||
konc_pred = _fl_from_str(koncss, konc_ind)
|
||||
konc_sled = _fl_from_str(koncss, konc_ind + 1)
|
||||
if konc_pred is None or konc_sled is None or konc_pred < 0 or konc_sled < 0 or konc_pred == konc_sled:
|
||||
raise PiterPrepError(-1, "Не удалось определить интервал концентрации")
|
||||
|
||||
# Интервал массы
|
||||
mass_ind = 1
|
||||
for i in range(2, kol_mass):
|
||||
v = _fl_from_str(masses, i)
|
||||
if v is not None and wmassa >= v:
|
||||
mass_ind = i
|
||||
m_a = _fl_from_str(masses, mass_ind)
|
||||
m_b = _fl_from_str(masses, mass_ind + 1)
|
||||
if m_a is None or m_b is None or m_a < 0 or m_b < 0 or m_a == m_b:
|
||||
raise PiterPrepError(-1, "Не удалось определить интервал массы")
|
||||
|
||||
# Проверка удоя (NPerem=4,5)
|
||||
udoy_str_4 = _info_values(4)
|
||||
udoy_str_5 = _info_values(5)
|
||||
udoy_min = _fl_from_str(udoy_str_4, 1) if udoy_str_4 else None
|
||||
udoy_max = _fl_from_str(udoy_str_5, kol_konc) if udoy_str_5 else None
|
||||
|
||||
if udoy_min is not None and udoy_max is not None:
|
||||
if udoy_jir < udoy_min or udoy_jir > udoy_max:
|
||||
jir_str = _info_values(2)
|
||||
gr_udoy1 = max(udoy_min * 4 / milk_fat_pct, udoy_min)
|
||||
gr_udoy2 = min(udoy_max * 4 / milk_fat_pct, udoy_max)
|
||||
gr_jir1 = udoy_min * 4 / milk_yield_kg
|
||||
gr_jir2 = udoy_max * 4 / milk_yield_kg
|
||||
if jir_str:
|
||||
if len(jir_str) >= 1:
|
||||
gr_jir1 = max(gr_jir1, jir_str[0])
|
||||
if len(jir_str) >= 2:
|
||||
gr_jir2 = min(gr_jir2, jir_str[1])
|
||||
code = -4 if udoy_jir < udoy_min else -5
|
||||
info = f"{gr_udoy1:.4f};{gr_udoy2:.4f};{gr_jir1:.3f};{gr_jir2:.3f};"
|
||||
raise PiterPrepError(code, "Удой вне допустимого диапазона для жирности", info=info)
|
||||
|
||||
# Допустимый диапазон концентрации для udoy_jir
|
||||
i = 1
|
||||
if udoy_jir >= (_fl_from_str(udoy_str_4, 1) or 0):
|
||||
while i < kol_konc - 1:
|
||||
nxt = _fl_from_str(udoy_str_4, i + 1)
|
||||
if nxt is None or nxt > udoy_jir:
|
||||
break
|
||||
i += 1
|
||||
else:
|
||||
while i < kol_konc - 1:
|
||||
nxt = _fl_from_str(udoy_str_4, i + 1)
|
||||
cur = _fl_from_str(udoy_str_4, i)
|
||||
if nxt is None or cur is None or nxt != cur:
|
||||
break
|
||||
i += 1
|
||||
ud_a = _fl_from_str(udoy_str_4, i)
|
||||
ud_b = _fl_from_str(udoy_str_4, i + 1)
|
||||
konc_a = _fl_from_str(koncss, i)
|
||||
konc_b = _fl_from_str(koncss, i + 1)
|
||||
if ud_a is not None and ud_b is not None and konc_a is not None and konc_b is not None:
|
||||
if ud_a == ud_b:
|
||||
konc_max = konc_b
|
||||
else:
|
||||
konc_max = lerp(udoy_jir, ud_a, konc_a, ud_b, konc_b)
|
||||
|
||||
i = kol_konc
|
||||
if udoy_jir <= (_fl_from_str(udoy_str_5, kol_konc) or udoy_jir):
|
||||
while i > 2:
|
||||
prev = _fl_from_str(udoy_str_5, i - 1)
|
||||
if prev is None or prev < udoy_jir:
|
||||
break
|
||||
i -= 1
|
||||
else:
|
||||
while i > 2:
|
||||
prev = _fl_from_str(udoy_str_5, i - 1)
|
||||
last = _fl_from_str(udoy_str_5, kol_konc)
|
||||
if prev is None or last is None or prev != last:
|
||||
break
|
||||
i -= 1
|
||||
ud_a2 = _fl_from_str(udoy_str_5, i - 1)
|
||||
ud_b2 = _fl_from_str(udoy_str_5, i)
|
||||
konc_a2 = _fl_from_str(koncss, i - 1)
|
||||
konc_b2 = _fl_from_str(koncss, i)
|
||||
if ud_a2 is not None and ud_b2 is not None and konc_a2 is not None and konc_b2 is not None:
|
||||
if ud_a2 == ud_b2:
|
||||
konc_min = konc_b2
|
||||
else:
|
||||
konc_min = lerp(udoy_jir, ud_a2, konc_a2, ud_b2, konc_b2)
|
||||
|
||||
konc_lo = _fl_from_str(koncss, 1) or konc_min
|
||||
konc_hi = _fl_from_str(koncss, kol_konc) or konc_max
|
||||
if konc_min < konc_lo:
|
||||
konc_min = konc_lo
|
||||
if konc_max > konc_hi:
|
||||
konc_max = konc_hi
|
||||
if round(konc_oe_sv, 1) < round(konc_min, 1) or round(konc_oe_sv, 1) > round(konc_max, 1):
|
||||
code = -2 if round(konc_oe_sv, 1) < round(konc_min, 1) else -3
|
||||
info = f"{round(konc_min, 1)};{round(konc_max, 1)};"
|
||||
raise PiterPrepError(
|
||||
code,
|
||||
f"Концентрация ОЭ/СВ вне допустимого диапазона ({round(konc_min, 1)}–{round(konc_max, 1)})",
|
||||
info=info,
|
||||
)
|
||||
|
||||
return PiterPrepResult(
|
||||
mass_ind=mass_ind,
|
||||
konc_pred=float(konc_pred),
|
||||
konc_sled=float(konc_sled),
|
||||
m_a=float(m_a),
|
||||
m_b=float(m_b),
|
||||
udoy_jir=udoy_jir,
|
||||
wmassa=wmassa,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Загрузка справочников норм RACION (БД → JSON fallback)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
_SEED_DIR = Path(__file__).resolve().parents[4] / "data" / "seed" / "racion"
|
||||
|
||||
|
||||
def _read_json(name: str) -> dict:
|
||||
path = _SEED_DIR / name
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Справочник не найден: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _load_from_db(loader_name: str) -> dict | None:
|
||||
try:
|
||||
from flask import has_app_context
|
||||
|
||||
if not has_app_context():
|
||||
return None
|
||||
from app.lab.services.racion_reference import (
|
||||
load_moskwa_lactir_from_db,
|
||||
load_normy_info_from_db,
|
||||
load_piter_lactir_from_db,
|
||||
)
|
||||
|
||||
loaders = {
|
||||
"moskwa": load_moskwa_lactir_from_db,
|
||||
"piter": load_piter_lactir_from_db,
|
||||
"info": load_normy_info_from_db,
|
||||
}
|
||||
fn = loaders.get(loader_name)
|
||||
if fn is None:
|
||||
return None
|
||||
data = fn()
|
||||
return data if data else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_moskwa_lactir() -> dict:
|
||||
data = _load_from_db("moskwa")
|
||||
if data:
|
||||
return data
|
||||
return _read_json("moskwa_lactir.json")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_piter_lactir() -> dict:
|
||||
data = _load_from_db("piter")
|
||||
if data:
|
||||
return data
|
||||
return _read_json("piter_lactir.json")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_normy_info() -> dict:
|
||||
data = _load_from_db("info")
|
||||
if data:
|
||||
return data
|
||||
try:
|
||||
return _read_json("normy_info.json")
|
||||
except FileNotFoundError:
|
||||
return {"rows": [], "mass_kg_values": [400, 450, 500, 550, 600, 650, 700, 750]}
|
||||
|
||||
|
||||
def clear_tables_cache() -> None:
|
||||
load_moskwa_lactir.cache_clear()
|
||||
load_piter_lactir.cache_clear()
|
||||
load_normy_info.cache_clear()
|
||||
Reference in New Issue
Block a user