418 lines
14 KiB
Python
418 lines
14 KiB
Python
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
|