@@ -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
|
||||
Reference in New Issue
Block a user