Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -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.modules.zootech.lab.calc.derived import apply_content_derived
from app.modules.zootech.lab.calc.nutrients import norm_diff, weighted_average
from app.modules.zootech.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.modules.zootech.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.modules.zootech.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.modules.zootech.lab.calc.compound import calculate_compound_feed
from app.modules.zootech.lab.calc.derived import apply_content_derived
from app.modules.zootech.lab.calc.nutrients import daily_intake_total, get_nutrient_value, norm_diff, weighted_average
from app.modules.zootech.lab.constants import RATION_TOTAL_KEYS
from app.modules.zootech.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.modules.zootech.wesp_bridge_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.modules.zootech.lab.calc.engine import calculate_ration
from app.modules.zootech.lab.calc.feed_groups import (
FEED_GROUPS,
flatten_group_selections,
triplet_meets_group_rules,
validate_group_selections,
)
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group as _classify_feed_group
from app.modules.zootech.lab.calc.formulate_optimize import optimize_shares_for_triplet
from app.modules.zootech.lab.calc.formulate_score import (
build_calc_lines,
build_triplet_score_model,
score_model_shares,
violation_score,
)
from app.modules.zootech.lab.calc.formulate_validate import validate_component, validate_components
from app.modules.zootech.lab.calc.norms_resolver import NormsParams, NormsResolveRequest, normalize_norms_method, resolve_norms
from app.modules.zootech.lab.calc.nutrients import get_nutrient_value
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.services.component_nutrients import nutrients_calc_dict_batch
from app.modules.zootech.lab.services.profile_norms import load_norms_dict
from app.modules.zootech.wesp_bridge_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.modules.zootech.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.modules.zootech.wesp_bridge_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.modules.zootech.lab.calc.derived import apply_content_derived
from app.modules.zootech.lab.calc.nutrients import daily_intake_total, get_nutrient_value, norm_diff, weighted_average
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, indicator_by_key
from app.modules.zootech.wesp_bridge_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.modules.zootech.lab.calc.feed_groups import classify_feed_group
from app.modules.zootech.lab.calc.gfe_policies import default_om_digestibility_pct
from app.modules.zootech.lab.nutrient_schema import read_from_mapping
from app.modules.zootech.lab.services.component_nutrients import derive_context_for_component, nutrients_full_dict, nutrients_is_empty
from app.modules.zootech.wesp_bridge_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%",
"уСП>7%",
"уСП/кг СВ формул",
"уСП в ОР",
"уСП формул",
"БРА",
"Нераств крохмал",
"доля крохмала",
"Доля белка",
"% перев в кишках",
"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.modules.zootech.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.modules.zootech.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.modules.zootech.lab.calc.nutrients import parse_num
from app.modules.zootech.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.modules.zootech.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.modules.zootech.lab.calc.gfe_norms import apply_dynamic_norms
from app.modules.zootech.lab.calc.racion.moscow import MoscowDairyParams, resolve_moscow_dairy_norms
from app.modules.zootech.lab.calc.racion.piter import PiterDairyParams, PiterPrepError, resolve_piter_dairy_norms
from app.modules.zootech.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.modules.zootech.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.modules.zootech.lab.calc.racion.interp import popr_index
from app.modules.zootech.lab.calc.norms_derived import apply_derived_norms
from app.modules.zootech.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
from app.modules.zootech.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 124 (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.modules.zootech.lab.calc.norms_derived import apply_derived_norms
from app.modules.zootech.lab.calc.racion.interp import lerp
from app.modules.zootech.lab.calc.racion.moscow import MoscowDairyParams
from app.modules.zootech.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
from app.modules.zootech.lab.calc.racion.piter_prep import PiterPrepError, PiterPrepResult, prepare_piter_calc
from app.modules.zootech.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.modules.zootech.lab.calc.racion.interp import lerp
from app.modules.zootech.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.modules.zootech.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()