Интегрирован 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,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