from __future__ import annotations from dataclasses import dataclass from typing import Any, Callable from scipy.optimize import minimize from app.models import Component ShareTuple = tuple[float, float, float] ScoreFn = Callable[[ShareTuple], float] @dataclass class OptimizeSharesResult: shares: ShareTuple score: float evaluations: int def _normalize_shares(s1: float, s2: float, min_share: float) -> ShareTuple | None: s3 = 1.0 - s1 - s2 ms = max(min_share, 0.0) if s1 < ms - 1e-9 or s2 < ms - 1e-9 or s3 < ms - 1e-9: return None if abs(s1 + s2 + s3 - 1.0) > 1e-6: return None return (round(s1, 8), round(s2, 8), round(s3, 8)) def _start_points(min_share: float, grid_step: float) -> list[tuple[float, float]]: """Multi-start seeds: simplex center, corners, and a few grid_step hints.""" ms = max(min_share, 0.0) max_pair = max(1.0 - 2 * ms, ms) center = round((1.0 - ms) / 3.0, 6) points: list[tuple[float, float]] = [ (center, center), (ms, ms), (max_pair, ms), (ms, max_pair), (max_pair, max_pair), ] step = max(grid_step, 0.1) if ms <= step <= max_pair: points.append((step, ms)) points.append((ms, step)) deduped: list[tuple[float, float]] = [] seen: set[tuple[float, float]] = set() for s1, s2 in points: if s1 + s2 > 1.0 - ms + 1e-9: continue key = (round(s1, 6), round(s2, 6)) if key in seen: continue seen.add(key) deduped.append(key) return deduped def optimize_shares_for_triplet( triplet: tuple[Component, Component, Component], score_fn: ScoreFn, *, min_share: float, grid_step: float = 0.1, ) -> OptimizeSharesResult | None: del triplet ms = max(min_share, 0.0) max_s1 = max(1.0 - 2 * ms, ms) evaluations = 0 def objective(x: Any) -> float: nonlocal evaluations evaluations += 1 shares = _normalize_shares(float(x[0]), float(x[1]), ms) if shares is None: return 1e18 return score_fn(shares) bounds = [(ms, max_s1), (ms, max_s1)] constraints = [{"type": "ineq", "fun": lambda x: 1.0 - ms - float(x[0]) - float(x[1])}] best_score: float | None = None best_shares: ShareTuple | None = None for s1, s2 in _start_points(ms, grid_step): if s1 + s2 > 1.0 - ms + 1e-9: continue try: res = minimize( objective, [s1, s2], method="SLSQP", bounds=bounds, constraints=constraints, options={"ftol": 1e-8, "maxiter": 40}, ) except Exception: continue if not res.success and res.fun >= 1e17: continue shares = _normalize_shares(float(res.x[0]), float(res.x[1]), ms) if shares is None: continue score = float(res.fun) if best_score is None or score < best_score: best_score = score best_shares = shares if best_shares is None or best_score is None: return None return OptimizeSharesResult(shares=best_shares, score=best_score, evaluations=evaluations)