fpl-solver / solver.py
AnayShukla's picture
solver and ui updates
4f95369
import pulp
def run_milp_model(data: dict):
print("Igniting Advanced Iterative MILP Engine (Original Solver Parity)...")
players = data["players"]
gws = data["gws"]
buy_prices = data["buy_prices"]
sell_prices = data["sell_prices"]
positions = data["positions"]
teams = data["teams"]
ev_matrix = data["ev_matrix"]
raw_ev_matrix = data.get("raw_ev_matrix", ev_matrix)
current_squad = data["current_squad"]
start_itb = data["itb"]
start_ft = data["ft"]
gw_opp_teams = data.get("gw_opp_teams", {})
fh_sell_price = data.get("fh_sell_price", sell_prices)
settings = data.get("settings", {})
# --- STRICT SINGLE SOURCE OF TRUTH ---
# Python no longer guesses. It extracts exactly what React tells it to.
time_limit_sec = int(settings.get("secs", settings.get("time_limit_sec")))
iterations = int(settings.get("iterations", 1))
iteration_diff = int(settings.get("iteration_diff", 1))
iteration_criteria = settings.get("iteration_criteria", "this_gw_transfer_in_out")
banned_ids = settings.get("banned_ids", [])
locked_ids = settings.get("locked_ids", [])
# Directly extracting required scalar values from the React payload
hit_cost = float(settings["hit_cost"])
max_ft = int(settings["max_ft"])
itb_value = float(settings["itb_value"])
vice_weight = float(settings.get("vcap_weight", settings["vice_weight"]))
max_per_team = int(settings["max_per_team"])
no_transfer_last_gws = int(settings["no_transfer_last_gws"])
itb_loss_per_transfer = float(settings["itb_loss_per_transfer"])
ft_use_penalty = float(settings["ft_use_penalty"])
decay_base = float(settings.get("decay_base", 1.0))
# --- FT STATE VALUATION ---
raw_ft_value = float(settings["ft_value"])
use_ftvl_flag = str(settings.get("use_ft_value_list", "false")).lower() in [
"true",
"1",
]
raw_ftvl = settings.get("ft_value_list", {}) if use_ftvl_flag else {}
ft_states_list = list(range(max_ft + 1))
ft_state_value = {}
for s in ft_states_list:
val = float(raw_ftvl.get(str(s), raw_ft_value)) if raw_ftvl else raw_ft_value
ft_state_value[s] = ft_state_value.get(s - 1, 0.0) + val
# --- CHIP SETTINGS ---
use_wc = [int(g) for g in (settings.get("use_wc") or [])]
use_fh = [int(g) for g in (settings.get("use_fh") or [])]
use_bb = [int(g) for g in (settings.get("use_bb") or [])]
use_tc = [int(g) for g in (settings.get("use_tc") or [])]
# Bench weights (Strictly trusts the payload dictionary)
raw_bw = settings["bench_weights"]
bench_weights = {int(k): float(v) for k, v in raw_bw.items()}
gk_bench_w = max(float(bench_weights.get(0, 0.03)), 0.0001)
of_bench_ws = [max(float(bench_weights.get(i, 0.0)), 0.0001) for i in [1, 2, 3]]
avg_of_bench_w = sum(of_bench_ws) / len(of_bench_ws) if of_bench_ws else 0.05
chip_gws: dict[int, str] = {}
for g in use_wc:
if g in gws:
chip_gws[g] = "wc"
for g in use_fh:
if g in gws:
chip_gws[g] = "fh"
for g in use_bb:
if g in gws:
chip_gws[g] = "bb"
for g in use_tc:
if g in gws:
chip_gws[g] = "tc"
all_gws = [gws[0] - 1] + gws
# --- FREE HIT SQUAD REVERSION ---
effective_prev: dict[int, int] = {}
for w in gws:
prev_w = all_gws[all_gws.index(w) - 1]
if chip_gws.get(prev_w) == "fh":
fh_prev_idx = all_gws.index(prev_w) - 1
effective_prev[w] = all_gws[fh_prev_idx]
else:
effective_prev[w] = prev_w
prob = pulp.LpProblem("Luigis_Mansion_FPL_Solver", pulp.LpMaximize)
# --- DECISION VARIABLES ---
squad = pulp.LpVariable.dicts("squad", (players, all_gws), cat="Binary")
lineup = pulp.LpVariable.dicts("lineup", (players, gws), cat="Binary")
captain = pulp.LpVariable.dicts("captain", (players, gws), cat="Binary")
vice_captain = pulp.LpVariable.dicts("vice_captain", (players, gws), cat="Binary")
bench = pulp.LpVariable.dicts("bench", (players, gws, [0, 1, 2, 3]), cat="Binary")
transfer_in = pulp.LpVariable.dicts("transfer_in", (players, gws), cat="Binary")
transfer_out = pulp.LpVariable.dicts("transfer_out", (players, gws), cat="Binary")
itb = pulp.LpVariable.dicts("itb", all_gws, lowBound=0, cat="Continuous")
fts = pulp.LpVariable.dicts(
"fts", all_gws, lowBound=1, upBound=max_ft, cat="Integer"
)
hits = pulp.LpVariable.dicts("hits", gws, lowBound=0, cat="Integer")
ft_below_lb = pulp.LpVariable.dicts("ft_below_lb", gws, cat="Binary")
ft_above_ub = pulp.LpVariable.dicts("ft_above_ub", gws, cat="Binary")
fts_state = pulp.LpVariable.dicts("fts_state", (gws, ft_states_list), cat="Binary")
daux = (
pulp.LpVariable.dicts("daux", (list(set(teams.values())), gws), cat="Binary")
if settings.get("double_defense_pick")
else None
)
gw_with_tr = (
pulp.LpVariable.dicts("gw_with_tr", gws, cat="Binary")
if settings.get("transfer_itb_buffer") is not None
else None
)
# --- FT STATE LINKING ---
for w in gws:
prev_w = all_gws[all_gws.index(w) - 1]
prob += fts[prev_w] == pulp.lpSum(s * fts_state[w][s] for s in ft_states_list)
prob += pulp.lpSum(fts_state[w][s] for s in ft_states_list) == 1
gw_ft_value = {
w: pulp.lpSum(ft_state_value[s] * fts_state[w][s] for s in ft_states_list)
for w in gws
}
gw_ft_gain = {}
for i, w in enumerate(gws):
if i == 0:
# MATCH solver_original.py EXACTLY: difference from 0 for the first gw
gw_ft_gain[w] = gw_ft_value[w] - 0.0
else:
prev_w = gws[i - 1]
gw_ft_gain[w] = gw_ft_value[w] - gw_ft_value[prev_w]
# --- CROSS-PLAY PENALTY LOGIC ---
cp_penalty_expr = {w: 0 for w in gws}
if settings.get("no_opposing_play") and gw_opp_teams:
opp_group = settings.get("opposing_play_group", "all")
pen_val = float(settings.get("opposing_play_penalty", 0.5))
opp_pos = [
("G", "F"),
("G", "M"),
("D", "F"),
("D", "M"),
("F", "G"),
("M", "G"),
("F", "D"),
("M", "D"),
]
cp_list = []
for w in gws:
for p1 in players:
for p2 in players:
# Look up the pre-calculated dictionary!
if p1 != p2 and (teams[p1], teams[p2]) in gw_opp_teams.get(w, []):
if (
opp_group == "all"
or (positions[p1], positions[p2]) in opp_pos
):
cp_list.append((p1, p2, w))
if cp_list:
cp_vars = pulp.LpVariable.dicts("cp", cp_list, cat="Binary")
for p1, p2, w in cp_list:
prob += lineup[p1][w] + lineup[p2][w] <= 1 + cp_vars[(p1, p2, w)]
prob += cp_vars[(p1, p2, w)] <= lineup[p1][w]
prob += cp_vars[(p1, p2, w)] <= lineup[p2][w]
for w in gws:
cp_penalty_expr[w] = pen_val * pulp.lpSum(
cp_vars[(p1, p2, gw)] for (p1, p2, gw) in cp_list if gw == w
)
# --- OBJECTIVE FUNCTION (Decayed to match original) ---
obj_parts = []
for i, w in enumerate(gws):
# Apply decay strictly to Hits, ITB, and FT Gains so the solver respects the horizon timing
decay_factor = pow(decay_base, i)
chip = chip_gws.get(w)
for p in players:
# Player EVs are already pre-decayed by solver_engine.py
ev = ev_matrix[p].get(w, 0)
cap_extra = 2.0 if chip == "tc" else 1.0
obj_parts.append(ev * lineup[p][w])
obj_parts.append(ev * cap_extra * captain[p][w])
obj_parts.append(ev * vice_weight * vice_captain[p][w])
if chip == "bb":
obj_parts.append(ev * 1.0 * (squad[p][w] - lineup[p][w]))
else:
obj_parts.append(ev * gk_bench_w * bench[p][w][0])
if len(of_bench_ws) >= 3:
obj_parts.append(ev * of_bench_ws[0] * bench[p][w][1])
obj_parts.append(ev * of_bench_ws[1] * bench[p][w][2])
obj_parts.append(ev * of_bench_ws[2] * bench[p][w][3])
if chip != "fh":
obj_parts.append(gw_ft_gain[w] * decay_factor)
obj_parts.append(itb[w] * itb_value * decay_factor)
obj_parts.append(-hits[w] * hit_cost * decay_factor)
# FT-use penalty ONLY applied outside of Wildcard/Free Hit
if ft_use_penalty != 0 and chip not in ("wc", "fh"):
for p in players:
obj_parts.append(-transfer_in[p][w] * ft_use_penalty * decay_factor)
if cp_penalty_expr[w] != 0:
obj_parts.append(-cp_penalty_expr[w] * decay_factor)
prob += pulp.lpSum(obj_parts), "Total_EV_Objective"
# --- ADVANCED HORIZON-LEVEL CONSTRAINTS ---
if settings.get("hit_limit") is not None:
prob += pulp.lpSum(hits[w] for w in gws) <= settings["hit_limit"]
if settings.get("future_transfer_limit") is not None and len(gws) > 1:
prob += (
pulp.lpSum(
transfer_in[p][w]
for p in players
for w in gws[1:]
if chip_gws.get(w) not in ("wc", "fh")
)
<= settings["future_transfer_limit"]
)
if settings.get("no_gk_rotation_after") is not None:
target_gw = int(settings["no_gk_rotation_after"])
if target_gw in gws:
for p in players:
if positions[p] == "G":
for w in gws:
if w > target_gw and chip_gws.get(w) != "fh":
prob += lineup[p][w] >= lineup[p][target_gw]
# --- INITIAL CONDITIONS ---
for p in players:
prob += squad[p][all_gws[0]] == (1 if p in current_squad else 0)
prob += itb[all_gws[0]] == start_itb
prob += fts[all_gws[0]] == start_ft
# --- USER BANS & LOCKS ---
for p in players:
if p in banned_ids:
for w in gws:
prob += squad[p][w] == 0
if p in locked_ids:
for w in gws:
prob += squad[p][w] == 1
# --- TARGETED CHIP/PRICE CONSTRAINTS ---
for gw in settings.get("no_chip_gws", []):
if gw in chip_gws:
raise Exception(
f"Contradiction: user tried to play chip in GW {gw} but also assigned it to no_chip_gws!"
)
if settings.get("pick_prices"):
for pos, val in settings["pick_prices"].items():
if not val or pos not in ("G", "D", "M", "F"):
continue
price_pts = [float(x) for x in val.split(",")]
value_dict = {i: price_pts.count(i) for i in set(price_pts)}
for key, count in value_dict.items():
target_players = [
p
for p in players
if positions[p] == pos
and buy_prices[p] >= key - 0.2
and buy_prices[p] <= key + 0.2
]
for w in gws:
prob += pulp.lpSum(squad[p][w] for p in target_players) >= count
# --- PER-GW CONSTRAINTS ---
for w in gws:
prev_w = all_gws[all_gws.index(w) - 1]
eff_prev = effective_prev[w]
chip = chip_gws.get(w)
prob += pulp.lpSum(squad[p][w] for p in players) == 15
prob += pulp.lpSum(squad[p][w] for p in players if positions[p] == "G") == 2
prob += pulp.lpSum(squad[p][w] for p in players if positions[p] == "D") == 5
prob += pulp.lpSum(squad[p][w] for p in players if positions[p] == "M") == 5
prob += pulp.lpSum(squad[p][w] for p in players if positions[p] == "F") == 3
prob += pulp.lpSum(lineup[p][w] for p in players) == 11
prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "G") == 1
prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "D") >= 3
prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "D") <= 5
prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "M") >= 2
prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "M") <= 5
prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "F") >= 1
prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "F") <= 3
prob += pulp.lpSum(captain[p][w] for p in players) == 1
prob += pulp.lpSum(vice_captain[p][w] for p in players) == 1
is_bb = 1 if chip == "bb" else 0
prob += (
pulp.lpSum(bench[p][w][0] for p in players if positions[p] == "G")
== 1 - is_bb
)
for o in [1, 2, 3]:
prob += pulp.lpSum(bench[p][w][o] for p in players) == 1 - is_bb
for p in players:
prob += lineup[p][w] <= squad[p][w]
prob += (
lineup[p][w] + pulp.lpSum(bench[p][w][o] for o in [0, 1, 2, 3])
<= squad[p][w]
)
prob += captain[p][w] <= lineup[p][w]
prob += vice_captain[p][w] <= lineup[p][w]
prob += captain[p][w] + vice_captain[p][w] <= 1
prob += (
squad[p][w]
== squad[p][eff_prev] + transfer_in[p][w] - transfer_out[p][w]
)
for t in set(teams.values()):
prob += (
pulp.lpSum(squad[p][w] for p in players if teams[p] == t)
<= max_per_team
)
if gws.index(w) >= len(gws) - no_transfer_last_gws and chip not in ("wc", "fh"):
prob += pulp.lpSum(transfer_in[p][w] for p in players) == 0
if chip == "fh":
# THE FIX: Decouple the Free Hit budget from the continuous bank!
# The new squad cost must simply be <= total wealth.
prob += itb[eff_prev] + pulp.lpSum(
fh_sell_price[p] * squad[p][eff_prev] for p in players
) >= pulp.lpSum(fh_sell_price[p] * squad[p][w] for p in players)
# The real bank is completely frozen and carries over untouched.
prob += itb[w] == itb[eff_prev]
else:
prob += (
itb[eff_prev]
+ pulp.lpSum(transfer_out[p][w] * sell_prices[p] for p in players)
- pulp.lpSum(transfer_in[p][w] * buy_prices[p] for p in players)
- pulp.lpSum(transfer_in[p][w] * itb_loss_per_transfer for p in players)
== itb[w]
)
gw_transfers = pulp.lpSum(transfer_in[p][w] for p in players)
# FT / Hits Engine
if chip in ("wc", "fh"):
prob += hits[w] == 0
raw_gw_ft = fts[prev_w]
else:
prob += hits[w] >= gw_transfers - fts[prev_w]
raw_gw_ft = fts[prev_w] - gw_transfers + 1
m = 25
prob += raw_gw_ft <= 0 + m * (1 - ft_below_lb[w])
prob += raw_gw_ft >= 1 - m * ft_below_lb[w]
prob += raw_gw_ft >= (max_ft + 1) - m * (1 - ft_above_ub[w])
prob += raw_gw_ft <= max_ft + m * ft_above_ub[w]
prob += fts[w] <= 1 + m * (1 - ft_below_lb[w])
prob += fts[w] >= 1 - m * (1 - ft_below_lb[w])
prob += fts[w] <= max_ft + m * (1 - ft_above_ub[w])
prob += fts[w] >= max_ft - m * (1 - ft_above_ub[w])
prob += fts[w] - raw_gw_ft <= m * ft_below_lb[w] + m * ft_above_ub[w]
prob += raw_gw_ft - fts[w] <= m * ft_below_lb[w] + m * ft_above_ub[w]
# --- ADVANCED GW-LEVEL CONSTRAINTS ---
for pid, tgw in settings.get("banned_next_gw", []):
if pid in players and (tgw == w or tgw is None):
prob += squad[pid][w] == 0
for pid, tgw in settings.get("locked_next_gw", []):
if pid in players and (tgw == w or tgw is None):
prob += squad[pid][w] == 1
for bt in settings.get("booked_transfers", []):
if bt.get("gw") == w:
if bt.get("transfer_in") in players:
prob += transfer_in[bt["transfer_in"]][w] == 1
if bt.get("transfer_out") in players:
prob += transfer_out[bt["transfer_out"]][w] == 1
if settings.get("only_booked_transfers") and w == gws[0]:
forced_ins = [
bt["transfer_in"]
for bt in settings.get("booked_transfers", [])
if bt.get("gw") == w and "transfer_in" in bt
]
forced_outs = [
bt["transfer_out"]
for bt in settings.get("booked_transfers", [])
if bt.get("gw") == w and "transfer_out" in bt
]
for p in players:
prob += transfer_in[p][w] == (1 if p in forced_ins else 0)
prob += transfer_out[p][w] == (1 if p in forced_outs else 0)
if settings.get("num_transfers") is not None and w == gws[0]:
prob += gw_transfers == int(settings["num_transfers"])
if (
settings.get("no_future_transfer")
and w > gws[0]
and chip not in ("wc", "fh")
):
prob += gw_transfers == 0
if settings.get("weekly_hit_limit") is not None:
prob += hits[w] <= int(settings["weekly_hit_limit"])
if w in settings.get("no_transfer_gws", []):
prob += gw_transfers == 0
if (
settings.get("no_transfer_by_position")
and w > gws[0]
and chip not in ("wc", "fh")
):
prob += (
pulp.lpSum(
transfer_in[p][w]
for p in players
if positions[p] in settings["no_transfer_by_position"]
)
== 0
)
mdpt = settings.get("max_defenders_per_team", 3)
if mdpt < 3:
for t in set(teams.values()):
prob += (
pulp.lpSum(
squad[p][w]
for p in players
if teams[p] == t and positions[p] in ("G", "D")
)
<= mdpt
)
if settings.get("double_defense_pick") and daux is not None:
for t in set(teams.values()):
team_defs = pulp.lpSum(
lineup[p][w]
for p in players
if teams[p] == t and positions[p] in ("G", "D")
)
prob += team_defs <= 3 * daux[t][w]
prob += team_defs >= 2 - 3 * (1 - daux[t][w])
if settings.get("transfer_itb_buffer") is not None and gw_with_tr is not None:
prob += 15 * gw_with_tr[w] >= gw_transfers
prob += gw_with_tr[w] <= gw_transfers
prob += itb[w] >= settings["transfer_itb_buffer"] * gw_with_tr[w]
for gw, ft_min in settings.get("force_ft_state_lb", []):
if w == gw:
prob += fts[w] >= ft_min
for gw, ft_max in settings.get("force_ft_state_ub", []):
if w == gw:
prob += fts[w] <= ft_max
if settings.get("no_trs_except_wc") and chip != "wc":
prob += gw_transfers == 0
# --- ITERATION LOOP ---
solutions = []
for i in range(iterations):
print(f"Solving Iteration {i + 1}...")
# THE FIX: Boot up the HiGHS engine instead of CBC
try:
# THE FIX: Force absolute perfection (0.0 gap) during Free Hits to stop lazy benching!
gap = 0.0
solver = pulp.getSolver(
"HiGHS",
msg=False,
timeLimit=time_limit_sec,
options=["presolve=on", f"mip_rel_gap={gap}"],
)
except pulp.PulpSolverError:
print("HiGHS not found! Falling back to CBC...")
solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=time_limit_sec)
prob.solve(solver)
if pulp.LpStatus[prob.status] != "Optimal":
if i == 0:
raise Exception(
"Solver could not find an optimal solution! Check budget, bans, or locks."
)
else:
print(f"No more valid alternate paths found after {i} iterations.")
break
plan = []
pure_ev = 0.0
active_transfers = []
objective_score = None
try:
objective_score = round(float(pulp.value(prob.objective)), 4)
except (TypeError, ValueError):
objective_score = None
for w in gws:
prev_w = all_gws[all_gws.index(w) - 1]
chip = chip_gws.get(w)
# THE FIX 1: Safely extract binary variables using > 0.5 to prevent MILP floating-point drops
t_in_raw = [
p
for p in players
if transfer_in[p][w].varValue is not None
and transfer_in[p][w].varValue > 0.5
]
t_out_raw = [
p
for p in players
if transfer_out[p][w].varValue is not None
and transfer_out[p][w].varValue > 0.5
]
t_in = [p for p in t_in_raw if p not in t_out_raw]
t_out = [p for p in t_out_raw if p not in t_in_raw]
_pos_order = {"G": 0, "D": 1, "M": 2, "F": 3}
t_in.sort(key=lambda x: _pos_order.get(positions.get(x, "M"), 2))
t_out.sort(key=lambda x: _pos_order.get(positions.get(x, "M"), 2))
gw_lineup = [
p
for p in players
if lineup[p][w].varValue is not None and lineup[p][w].varValue > 0.5
]
gw_bench_raw = [
p
for p in players
if squad[p][w].varValue is not None
and squad[p][w].varValue > 0.5
and (lineup[p][w].varValue is None or lineup[p][w].varValue < 0.5)
]
# Safe extractions with fallbacks so indexing never crashes the loop
gw_cap_list = [
p
for p in players
if captain[p][w].varValue is not None and captain[p][w].varValue > 0.5
]
gw_cap = (
gw_cap_list[0]
if gw_cap_list
else (gw_lineup[0] if gw_lineup else players[0])
)
gw_vice_list = [
p
for p in players
if vice_captain[p][w].varValue is not None
and vice_captain[p][w].varValue > 0.5
]
gw_vice = (
gw_vice_list[0]
if gw_vice_list
else (gw_lineup[-1] if gw_lineup else players[-1])
)
gw_hits = int(round(hits[w].varValue or 0))
ft_at_start = int(round(fts[prev_w].varValue or 0))
transfers_made = len(t_in)
fts_free_used = min(transfers_made, ft_at_start)
gks_b = [p for p in gw_bench_raw if positions.get(p, "M") == "G"]
rest_b = sorted(
[p for p in gw_bench_raw if positions.get(p, "M") != "G"],
key=lambda x: raw_ev_matrix[x].get(w, 0),
reverse=True,
)
gw_bench_sorted = (gks_b + rest_b) if gks_b else rest_b
cap_mult = 3 if chip == "tc" else 2
gw_pure_ev = sum(
raw_ev_matrix[p].get(w, 0) * (cap_mult if p == gw_cap else 1)
for p in gw_lineup
)
if chip == "bb":
gw_pure_ev += sum(raw_ev_matrix[p].get(w, 0) for p in gw_bench_sorted)
else:
# THE FIX 2: Stop hardcoding! Use the dynamic weights extracted from React at the top of the file!
of_idx = 0
for p in gw_bench_sorted:
if positions.get(p, "M") == "G":
# Uses the gk_bench_w parsed at line 46
gw_pure_ev += raw_ev_matrix[p].get(w, 0) * gk_bench_w
else:
# Uses the of_bench_ws array parsed at line 47
bw = (
of_bench_ws[of_idx]
if of_idx < len(of_bench_ws)
else avg_of_bench_w
)
gw_pure_ev += raw_ev_matrix[p].get(w, 0) * bw
of_idx += 1
gw_pure_ev -= gw_hits * hit_cost
pure_ev += gw_pure_ev
# RESTORED: Track active transfers so the solver knows what to ban in the next iteration!
if "this_gw" in iteration_criteria and w == gws[0]:
if "in" in iteration_criteria:
for p in t_in:
active_transfers.append(transfer_in[p][w])
if "out" in iteration_criteria:
for p in t_out:
active_transfers.append(transfer_out[p][w])
elif "this_gw" not in iteration_criteria:
for p in t_in:
active_transfers.append(transfer_in[p][w])
for p in t_out:
active_transfers.append(transfer_out[p][w])
plan.append(
{
"gw": w,
"chip": chip,
"transfers_in": t_in,
"transfers_out": t_out,
"lineup": gw_lineup,
"bench": gw_bench_sorted,
"captain": gw_cap,
"vice_captain": gw_vice,
"hits": gw_hits,
"itb": round(itb[w].varValue, 1),
"fts_remaining": int(fts[w].varValue),
"ft_at_start": ft_at_start,
"transfers_made": transfers_made,
"fts_free_used": fts_free_used,
}
)
solutions.append(
{
"id": i + 1,
"ev": round(pure_ev, 2),
"objective_score": objective_score,
"plan": plan,
"chips_used": chip_gws,
"horizon_gws": gws,
}
)
# THE FIX: Restored original parity logic, and fixed the 0-transfer loop properly!
if len(active_transfers) > 0:
prob += (
pulp.lpSum(active_transfers) <= len(active_transfers) - iteration_diff
)
else:
if "this_gw" in iteration_criteria:
prob += pulp.lpSum(transfer_in[p][gws[0]] for p in players) >= 1
else:
prob += pulp.lpSum(transfer_in[p][w] for p in players for w in gws) >= 1
def sort_key(s):
obj = s.get("objective_score")
ev = s.get("ev") or 0
if obj is None:
return (-float(ev), -float(ev))
return (-float(obj), -float(ev))
solutions.sort(key=sort_key)
return {"status": "success", "solutions": solutions}