import pandas as pd import numpy as np import math from scipy.stats import nbinom # Share of Premier League goals that are own goals, which the team projection # counts but no player is credited with: 245 of 6,700 in data/ (2020-21 on). OWN_GOAL_RATE = 0.0366 # Each share column -> (the per-player columns it can be built from, first # present wins; the fraction of the team projection it hands out). # # Goals share on open-play xG where the baselines carry it, because penalty # goals are handed out separately by taker (pen_share); sharing blended xG as # well would count a taker's penalties twice. The own-goal cut is applied to # the team total in calculate_single_match_points, so the fraction here is 1. SHARE_STATS = { "xG_share": (("baseline_npxG_p90", "baseline_xG_p90"), 1.0), "xA_share": (("baseline_xA_p90",), 1.0), "xCBIT_share": (("baseline_CBIT_p90",), 1.0), "xCBITR_share": (("baseline_CBITR_p90",), 1.0), "YC_share": (("baseline_yc_p90",), 1.0), "RC_share": (("baseline_rc_p90",), 1.0), "pen_share": (("pen_strength",), 1.0), } def _share_source(player_df, candidates): """The first of `candidates` present on player_df, as floats >= 0 (else 0).""" for col in candidates: if col in player_df.columns: return player_df[col].astype(float).clip(lower=0) return pd.Series(0.0, index=player_df.index) def closed_shares(player_df, gw_matches, gw_xmins, gw, share_context=None): """Per-player shares for one gameweek that hand out each team's total once. The static shares divide a player's per-90 rate by his team's historical per-90 total. Numerator and denominator are measured independently, so nothing makes a squad add up to its team: at GW5 Newcastle's players were projected for 67% of Newcastle's goals and Ipswich's for 133% of Ipswich's assists, largely because a signing's rate was earned at his old club and divided by his new club's total. Here the denominator is the squad itself - every player's rate weighted by the minutes he is projected to play in this match - so the shares of a team always sum to one match's worth of that team (less own goals, for goals). Relative quality within the squad decides who gets what. Returns (shares DataFrame indexed like player_df, context). `context` records each player's contribution and each team's denominator so that a later single-player recalculation can swap in just his own contribution rather than re-deriving a squad of one - which would hand one player his whole team's output. """ if "team" not in player_df.columns: return None, None sources = {col: _share_source(player_df, cands) for col, (cands, _) in SHARE_STATS.items()} home = gw_matches["home_team_num"].value_counts() away = gw_matches["away_team_num"].value_counts() n_matches = player_df["team"].map(lambda t: int(home.get(t, 0) + away.get(t, 0))) # Per-match minutes, exactly as the scoring loop derives them, so the # allocation closes against the same minutes the points are scored on. mins = gw_xmins.astype(float) per_match = np.where((n_matches > 1) & (mins > 35), mins * 0.97, mins) per_match = pd.Series(np.where(n_matches > 0, per_match, 0.0), index=player_df.index) contrib = pd.DataFrame(index=player_df.index) for col in SHARE_STATS: contrib[col] = sources[col] * per_match / 90.0 teams = player_df["team"] denom = contrib.groupby(teams).sum() if share_context is not None and gw in share_context.get("denom", {}): # Single-player run: start from the full squad's totals and swap this # player's old contribution for the new one. base_denom = share_context["denom"][gw] base_contrib = share_context["contrib"][gw] rows = [] for idx in player_df.index: pid, team = player_df.at[idx, "id"], teams.at[idx] row = base_denom.loc[team].copy() if team in base_denom.index else contrib.loc[idx] * 0 if pid in base_contrib.index: row = row - base_contrib.loc[pid] rows.append(row + contrib.loc[idx]) team_denom = pd.DataFrame(rows, index=player_df.index) else: team_denom = denom.reindex(teams.values) team_denom.index = player_df.index shares = pd.DataFrame(index=player_df.index) for col, (_, fraction) in SHARE_STATS.items(): d = team_denom[col].replace(0, np.nan) shares[col] = (fraction * sources[col] / d).fillna(0.0) context = { "contrib": contrib.set_index(player_df["id"]), "denom": denom, } return shares, context # How strongly a player's own projected BPS tilts his position's base bonus # rate. Bonus is mostly noise - between defenders the spread of bonus per 90 # is larger than its mean - so the tilt is deliberately gentle: at 0.5, twice # his position's average BPS earns 1.41x the base rate, not 2x. BONUS_TILT_POWER = 0.5 BONUS_TILT_CAP = 3.0 def bonus_reference(rows): """Minutes-weighted projected BPS per 90 for each position, over a gameweek.""" bps, mins = {}, {} for _, _, pos, match_bps, m in rows: if m <= 0: continue bps[pos] = bps.get(pos, 0.0) + max(match_bps, 0.0) mins[pos] = mins.get(pos, 0.0) + m return {pos: bps[pos] / (mins[pos] / 90.0) for pos in bps if mins[pos] > 0} def apply_bonus(rows, bonus_model, reference): """Expected bonus per (player index, match id) for one gameweek. bonus = position's base rate per 90 x minutes/90 x (his BPS per 90 / position's average BPS per 90) ** BONUS_TILT_POWER The base rate is what that position actually earns (bonus_model.json, from FPL's own history), so across a position the projection starts where reality is. Replaces BPS / 29.4, which projected about 10 bonus points a match when 6 exist, over-projected defenders 2.2x and gave keepers none. """ base = {int(k): float(v) for k, v in bonus_model["base_per90"].items()} out = {} for index, match_id, pos, match_bps, m in rows: if m <= 0: continue ref = reference.get(pos) per90 = max(match_bps, 0.0) / (m / 90.0) tilt = (per90 / ref) ** BONUS_TILT_POWER if ref and ref > 0 else 1.0 out[(index, match_id)] = base.get(int(pos), 0.0) * (m / 90.0) * min(tilt, BONUS_TILT_CAP) return out def poisson_probability_of_conceding_2_or_more_goals(lambd): """Calculates the probability of conceding 2 or more goals using Poisson distribution.""" p_0 = math.exp(-lambd) p_1 = lambd * math.exp(-lambd) return 1 - p_0 - p_1 def poisson_pmf(k, lambd): """Calculates the Poisson Probability Mass Function P(X=k).""" if k < 0: return 0.0 if lambd < 1e-9: # Treat very small lambda as zero for stability return 1.0 if k == 0 else 0.0 return (lambd**k * math.exp(-lambd)) / math.factorial(k) def neg_binom_probability_of_value(expected_mean, value, dispersion=1.0): """ Calculates the exact probability (PMF) of getting exactly 'value' events. Used for: Saves, Goals, Assists. """ if expected_mean <= 0: return 0.0 if dispersion <= 1.0: # Fallback to Poisson if no dispersion return poisson_pmf(value, expected_mean) # Convert Mean + Dispersion to n, p p = 1 / dispersion n = (expected_mean * p) / (1 - p) return nbinom.pmf(value, n, p) def neg_binom_probability_at_least(expected_mean, threshold, dispersion=1.0): """ Calculates probability of getting 'threshold' OR MORE events. Used for: DefCons (CBIT), Recoveries. """ if expected_mean <= 0: return 0.0 if dispersion <= 1.0: # Use existing Poisson logic if dispersion is low return 1 - poisson_cdf(threshold - 1, expected_mean) p = 1 / dispersion n = (expected_mean * p) / (1 - p) # Probability of X >= threshold is (1 - CDF(threshold - 1)) return 1 - nbinom.cdf(threshold - 1, n, p) def calculate_expected_conceded_points(lambd): """ Calculates the expected fantasy points from goals conceded based on a -1 point penalty for every 2 goals. """ total_expected_points = 0 max_goals_to_check = 10 for k in range(max_goals_to_check + 1): prob_k = poisson_pmf(k=k, lambd=lambd) points_for_k_goals = -(k // 2) total_expected_points += prob_k * points_for_k_goals return total_expected_points def poisson_cdf(k, lambd): """Calculates the Poisson Cumulative Distribution Function P(X<=k).""" if k < 0: return 0.0 if lambd < 1e-9: # Treat very small lambda as zero for stability return 1.0 if k >= 0 else 0.0 return sum(poisson_pmf(i, lambd) for i in range(math.floor(k) + 1)) def apply_team_skepticism(df, skepticism_factors): """ Applies a skepticism multiplier to a player's base points based on their team. """ if not skepticism_factors: return df for team_id, multiplier in skepticism_factors.items(): players_on_team = df[df["team"] == team_id].index df.loc[players_on_team, "base_pts"] *= multiplier return df def calculate_single_match_points( player, match_row, xMins_in_match, points_config, player_penalty_shares, is_gk=False, is_def=False, is_mid=False, is_fwd=False, mins_probs=None, ): """ Calculates points for a single match given the xMins and match projections. Includes full logic for CBIT, CBITR, Penalty Saves, and dynamic BPS. `mins_probs` is an optional (p_start, p_60) pair - the probability of any appearance and of reaching 60 minutes - taken from Solio's minutes curve. Where it is given, everything that FPL gates on 60 minutes is scored against those probabilities instead of against a threshold on the mean. That threshold is a step function applied to an expected value, which is wrong wherever the expectation sits near the step. It paid a full appearance point to anyone above 0.0 minutes - 100 players on the GW5 board had between 0 and 5 expected minutes and collected one each - and paid a flat 1 across the whole 40-75 minute rotation band where the honest answer runs from 1.08 to 1.33. Clean sheets were worse in kind: a defender at 59.9 expected minutes lost his entire clean-sheet expectation and one at 60.1 kept all of it. With no probabilities supplied the old threshold behaviour is kept intact, so a player Solio has no curve for scores exactly as before. """ if xMins_in_match <= 0: return {"pts": 0.0, "xG": 0.0, "xA": 0.0, "CS": 0.0, "cbit": 0.0, "cbitr": 0.0} scaling_factor = xMins_in_match / 90.0 # p_any / p_60 drive the appearance, clean-sheet and minutes-BPS terms. # Falling back to the threshold reproduces the old numbers exactly. if mins_probs is not None: p_any, p_60 = float(mins_probs[0]), float(mins_probs[1]) else: p_any = 1.0 if xMins_in_match > 0 else 0.0 p_60 = 1.0 if xMins_in_match >= 60 else 0.0 player_team_num = player["team"] player_pos = player["element_type"] # 1. Identify Home/Away and get Opponent Stats if player_team_num == match_row["home_team_num"]: team_proj_goals = match_row["mc_home_goals_mean"] team_conc_goals = match_row["mc_away_goals_mean"] team_proj_assists = match_row["mc_home_assists_xa_mean"] team_proj_cbit = match_row["mc_home_CBIT_mean"] team_proj_cbitr = match_row["mc_home_CBITR_mean"] team_proj_saves = match_row["mc_home_keeper_saves_mean"] team_proj_yc = match_row["mc_home_yc_mean"] team_proj_rc = match_row["mc_home_rc_mean"] cs_odds = match_row["home_clean_sheet_odds"] else: team_proj_goals = match_row["mc_away_goals_mean"] team_conc_goals = match_row["mc_home_goals_mean"] team_proj_assists = match_row["mc_away_assists_xa_mean"] team_proj_cbit = match_row["mc_away_CBIT_mean"] team_proj_cbitr = match_row["mc_away_CBITR_mean"] team_proj_saves = match_row["mc_away_keeper_saves_mean"] team_proj_yc = match_row["mc_away_yc_mean"] team_proj_rc = match_row["mc_away_rc_mean"] cs_odds = match_row["away_clean_sheet_odds"] # 2. Player Share Calculations # Goals players can be credited with: the team's projection less own goals. player_goals = team_proj_goals * (1.0 - OWN_GOAL_RATE) # Penalties are their own pot. A team is awarded them in proportion to how # much it is projected to score, they go to its takers by pen_share (closed # per team, weighted by who is on the pitch), and they convert at the # league rate. Open-play goals are what is left, shared on npxG - so a # taker's penalties are counted once. Rates come from penalty_model.json # via points_config; without it the old constant per-position points apply. pen_model = points_config.get("penalty_model") proj_pens_taken = 0.0 proj_pen_goals = 0.0 if pen_model and "pen_share" in player.index: team_pens = (pen_model["pens_per_team_match"] * team_proj_goals / pen_model["goals_per_team_match"]) team_pen_goals = team_pens * pen_model["conversion"] proj_pens_taken = player["pen_share"] * team_pens proj_pen_goals = proj_pens_taken * pen_model["conversion"] open_play_goals = max(player_goals - team_pen_goals, 0.0) proj_goals = player["xG_share"] * open_play_goals + proj_pen_goals else: proj_goals = player["xG_share"] * player_goals proj_assists = player["xA_share"] * team_proj_assists proj_cbit = player["xCBIT_share"] * team_proj_cbit proj_cbitr = player["xCBITR_share"] * team_proj_cbitr proj_saves = 0 proj_pen_saves = 0 if is_gk: proj_saves = (player["baseline_xSaves_p90"] + team_proj_saves) / 2 proj_pen_saves = player["baseline_pksave_p90"] # --- GOALS & ASSISTS --- pts_goals = ( sum( poisson_pmf(k, proj_goals) * k * points_config["goal"][player_pos] for k in range(9) ) * scaling_factor ) pts_assists = ( sum( poisson_pmf(k, proj_assists) * k * points_config["assist"] for k in range(9) ) * scaling_factor ) # --- CLEAN SHEET & CONCEDED --- # FPL pays a clean sheet only from 60 minutes, so the expectation is the # clean-sheet odds weighted by the chance he actually gets there. pts_cs = cs_odds * points_config["clean_sheet"][player_pos] * p_60 pts_conc = ( calculate_expected_conceded_points(team_conc_goals) * scaling_factor if (is_gk or is_def) and team_conc_goals is not None else 0.0 ) # --- CARDS --- pts_yc = (player["YC_share"] * team_proj_yc * -1) * scaling_factor pts_rc = (player["RC_share"] * team_proj_rc * -3) * scaling_factor # --- SAVES & PENALTY SAVES (GK) --- pts_saves = 0.0 pts_pen_save = 0.0 if is_gk: expected_saves_pts_unscaled = sum( neg_binom_probability_of_value(proj_saves, k, dispersion=1.5) * ((k // 3) * points_config["saves_per_3"]) for k in range(21) ) pts_saves = expected_saves_pts_unscaled * scaling_factor expected_pen_saved_pts_unscaled = sum( poisson_pmf(k, proj_pen_saves) * (k * 5) for k in range(3) ) pts_pen_save = expected_pen_saved_pts_unscaled * scaling_factor # --- CBIT & CBITR --- pts_cbit = ( ( neg_binom_probability_at_least(proj_cbit, 10, dispersion=3.2) * 2 * scaling_factor ) if is_def else 0.0 ) pts_cbitr = 0.0 if is_mid: pts_cbitr = ( neg_binom_probability_at_least(proj_cbitr, 12, dispersion=2.8) * 2 * scaling_factor ) elif is_fwd: pts_cbitr = ( neg_binom_probability_at_least(proj_cbitr, 12, dispersion=1.7) * 2 * scaling_factor ) # --- PENALTY POINTS (Taker) --- # With the penalty model, scored penalties are already inside proj_goals # and earn goal points there; what is left is the -2 for each miss. pts_penalty = 0.0 if pen_model and "pen_share" in player.index: pts_penalty = -2.0 * proj_pens_taken * (1.0 - pen_model["conversion"]) * scaling_factor elif player_penalty_shares and player["id"] in player_penalty_shares: pen_share = player_penalty_shares[player["id"]] base_pen_pts = points_config["penalty_points_per_position"].get(player_pos, 0) pts_penalty = (base_pen_pts * pen_share) * scaling_factor # --- APPEARANCE --- # 1 point for appearing at all, a second for reaching 60: # E = 1*P(0 < mins < 60) + 2*P(mins >= 60) = p_any + p_60 pts_app = p_any + p_60 # --- BONUS POINTS --- bps_floor = player["baseline_bps_floor_p90"] * scaling_factor bps_mins = 3 * p_any + 3 * p_60 scaled_goals = proj_goals * scaling_factor scaled_assists = proj_assists * scaling_factor scaled_saves = proj_saves * scaling_factor if is_gk else 0 scaled_pen_saves = proj_pen_saves * scaling_factor if is_gk else 0 scaled_yc = player["YC_share"] * team_proj_yc * scaling_factor scaled_rc = player["RC_share"] * team_proj_rc * scaling_factor bps_goals = scaled_goals * (24 if is_fwd else (18 if is_mid else 12)) bps_assists = scaled_assists * 9 bps_cs = cs_odds * 12 * p_60 if (is_gk or is_def) else 0 bps_saves = scaled_saves * 2 bps_pen_saves = scaled_pen_saves * 15 bps_cards = (scaled_yc * -3) + (scaled_rc * -9) # Missed penalty: -6 BPS. Zero without the penalty model. bps_cards += -6.0 * proj_pens_taken * (1.0 - pen_model["conversion"]) * scaling_factor if pen_model and "pen_share" in player.index else 0.0 total_projected_bps = ( bps_floor + bps_mins + bps_goals + bps_assists + bps_cs + bps_saves + bps_pen_saves + bps_cards ) # With a bonus model, bonus is awarded per gameweek in calculate_all_points # (it compares each player's BPS with his position's average across the # whole gameweek, which a single match cannot see). This match contributes # its projected BPS; see apply_bonus. if points_config.get("bonus_model"): pts_bonus = 0.0 else: pts_bonus = total_projected_bps / 29.4 if not is_gk else 0.0 # --- FINAL SUM --- total_pts = ( pts_goals + pts_assists + pts_cs + pts_conc + pts_yc + pts_rc + pts_saves + pts_pen_save + pts_cbit + pts_cbitr + pts_penalty + pts_app + pts_bonus ) return { # Where the points came from, for diagnostics; nothing reads it for # scoring. "breakdown": { "app": pts_app, "goals": pts_goals, "assists": pts_assists, "cs": pts_cs, "conceded": pts_conc, "cards": pts_yc + pts_rc, "saves": pts_saves + pts_pen_save, "defcon": pts_cbit + pts_cbitr, "penalty": pts_penalty, "bonus": pts_bonus, "bps": total_projected_bps, }, "pts": total_pts, "xG": proj_goals * scaling_factor, "xA": proj_assists * scaling_factor, "CS": cs_odds * p_60, "cbit": proj_cbit * scaling_factor, "cbitr": proj_cbitr * scaling_factor, } def calculate_all_points( player_df_base, match_df, player_penalty_shares, MINS_SCALING_BONUS, pos_map, teams_dict_1, teams_dict, points_config, effective_xmins_overrides, MINS_THRESHOLD, RAMP_UP_PERIOD, decay_rates, ramp_up_rates, user_player_status_overrides, team_skepticism, effective_availability_multipliers, effective_mins_probs=None, share_context=None, ): RAMP_UP_PERIOD = 3 # Filled on a full run with each gameweek's squad contributions, so a # later single-player run (share_context passed in) can close its # allocation against the full squad. See closed_shares. recorded_share_context = {"contrib": {}, "denom": {}, "bonus_ref": {}} bonus_model = points_config.get("bonus_model") # {player_id: {gw: [p_start, p_60]}} from Solio's minutes curve, via # xmins_pipeline. Absent for any player it has no curve for, and absent # entirely for callers that do not pass it - both fall back to the old # 60-minute threshold inside calculate_single_match_points. effective_mins_probs = effective_mins_probs or {} player_df = player_df_base.copy() final_df_output = pd.DataFrame( { "Pos": player_df["element_type"].map(pos_map), "ID": player_df["id"], "Name": player_df["web_name"], "BV": player_df["now_cost"], "SV": player_df["now_cost"], "Team": player_df["Team"], } ) continuous_xMins_progression = player_df["baseline_xMins"].copy() has_baseline_xmins_override = getattr(player_df, "attrs", {}).get( "has_baseline_xmins_override", False ) all_baseline_overrides = getattr(player_df, "attrs", {}).get( "all_baseline_overrides", {} ) unique_gws = sorted(match_df["GW"].unique()) match_projections_col = {index: {} for index in player_df.index} for gw_idx, gw in enumerate(unique_gws): if has_baseline_xmins_override and gw == 1: for index, player in player_df.iterrows(): player_id = player["id"] if ( player_id in all_baseline_overrides and "baseline_xMins" in all_baseline_overrides[player_id] ): continuous_xMins_progression.loc[index] = all_baseline_overrides[ player_id ]["baseline_xMins"] gw_calc_df = pd.DataFrame(index=player_df.index) gw_calc_df["team"] = player_df["team"] gw_calc_df["id"] = player_df["id"] gw_calc_df["web_name"] = player_df["web_name"] gw_calc_df["player_name"] = player_df["name"] gw_calc_df["xG_share"] = player_df["xG_share"] gw_calc_df["xA_share"] = player_df["xA_share"] gw_calc_df["baseline_xMins"] = player_df["baseline_xMins"] gw_calc_df["baseline_bps_floor_p90"] = player_df["baseline_bps_floor_p90"] gw_calc_df["base_pts"] = 0.0 # VECTORIZED XMINS CALCULATION player_ids_array = player_df["id"].values n_players = len(player_ids_array) status_list = [ user_player_status_overrides.get(pid, {"status": "default"})["status"] for pid in player_ids_array ] weeks_out_list = [ user_player_status_overrides.get(pid, {}).get("weeks_out", 0) for pid in player_ids_array ] status_array = np.array(status_list, dtype=object) weeks_out_array = np.array(weeks_out_list) is_not_starter = status_array == "not_a_starter" is_suspended = status_array == "suspended" is_injured = status_array == "injured" is_default = ~(is_not_starter | is_suspended | is_injured) baseline_mins_array = player_df["baseline_xMins"].values prev_continuous_xmins_array = continuous_xMins_progression.values calculated_xmins_array = np.zeros(n_players, dtype=float) next_continuous_xmins_array = np.zeros(n_players, dtype=float) first_gw = min(unique_gws) is_first_gw = gw == first_gw is_available_first_gw = ~(is_not_starter | is_suspended | is_injured) # CASE 1: First GW + Available if is_first_gw: mask_first_available = is_available_first_gw calculated_xmins_array[mask_first_available] = baseline_mins_array[ mask_first_available ] calculated_xmins_array[is_not_starter] = 0 # CASE 3: Suspended mask_suspended_during = is_suspended & (gw <= weeks_out_array) mask_suspended_return = is_suspended & (gw == weeks_out_array + 1) mask_suspended_after = is_suspended & (gw > weeks_out_array + 1) calculated_xmins_array[mask_suspended_during] = 0 calculated_xmins_array[mask_suspended_return] = baseline_mins_array[ mask_suspended_return ] decay_rate_susp = decay_rates.get("suspended", decay_rates.get("default", 0.99)) ramp_rate_susp = ramp_up_rates.get("suspended", ramp_up_rates.get("default", 0)) mask_susp_decay = mask_suspended_after & ( prev_continuous_xmins_array >= MINS_THRESHOLD ) mask_susp_ramp = mask_suspended_after & ( prev_continuous_xmins_array < MINS_THRESHOLD ) calculated_xmins_array[mask_susp_decay] = ( prev_continuous_xmins_array[mask_susp_decay] * decay_rate_susp ) calculated_xmins_array[mask_susp_ramp] = np.minimum( prev_continuous_xmins_array[mask_susp_ramp] + ramp_rate_susp, 90 ) # CASE 4: Injured mask_injured_out = is_injured & (gw <= weeks_out_array) calculated_xmins_array[mask_injured_out] = 0 mask_injured_recovering = is_injured & (gw > weeks_out_array) weeks_since_injury_array = np.maximum(0, gw - weeks_out_array) mask_ramp_phase = mask_injured_recovering & ( weeks_since_injury_array <= RAMP_UP_PERIOD ) calculated_xmins_array[mask_ramp_phase] = ( baseline_mins_array[mask_ramp_phase] / RAMP_UP_PERIOD ) * weeks_since_injury_array[mask_ramp_phase] mask_post_ramp = mask_injured_recovering & ( weeks_since_injury_array > RAMP_UP_PERIOD ) decay_rate_default = decay_rates.get("default", 0.99) ramp_rate_default = ramp_up_rates.get( "default", ramp_up_rates.get("injured", 0) ) mask_post_decay = mask_post_ramp & ( prev_continuous_xmins_array >= MINS_THRESHOLD ) mask_post_ramp_up = mask_post_ramp & ( prev_continuous_xmins_array < MINS_THRESHOLD ) calculated_xmins_array[mask_post_decay] = ( prev_continuous_xmins_array[mask_post_decay] * decay_rate_default ) calculated_xmins_array[mask_post_ramp_up] = np.minimum( prev_continuous_xmins_array[mask_post_ramp_up] + ramp_rate_default, 90 ) # CASE 5: Default/healthy mask_default_calc = is_default & ~(is_first_gw & is_available_first_gw) element_type_array = player_df["element_type"].values is_gk = element_type_array == 1 mask_gk_default = mask_default_calc & is_gk calculated_xmins_array[mask_gk_default] = prev_continuous_xmins_array[ mask_gk_default ] mask_outfield_default = mask_default_calc & (~is_gk) mask_outf_decay = mask_outfield_default & ( prev_continuous_xmins_array >= MINS_THRESHOLD ) calculated_xmins_array[mask_outf_decay] = ( prev_continuous_xmins_array[mask_outf_decay] * decay_rate_default ) mask_outf_ramp = ( mask_outfield_default & (prev_continuous_xmins_array < MINS_THRESHOLD) & (baseline_mins_array > 0) ) calculated_xmins_array[mask_outf_ramp] = np.minimum( prev_continuous_xmins_array[mask_outf_ramp] + ramp_rate_default, 90 ) # Sub-threshold with no baseline to ramp towards. This branch used to # set nothing, which left the zero the array was initialised with - so # a fringe player Solio had on 21 minutes at GW10 read 0 from GW11, # once the chain started carrying overrides. Decay him from where he # was instead. A player with no override and no baseline starts the # chain at 0 and decays 0 to 0, so nothing changes for him. mask_outf_hold = ( mask_outfield_default & (prev_continuous_xmins_array < MINS_THRESHOLD) & (baseline_mins_array <= 0) ) calculated_xmins_array[mask_outf_hold] = ( prev_continuous_xmins_array[mask_outf_hold] * decay_rate_default ) calculated_xmins_array = np.clip(calculated_xmins_array, 0, 90) next_continuous_xmins_array = calculated_xmins_array.copy() # APPLY OVERRIDES AND AVAILABILITY xMins_for_current_gw_display = calculated_xmins_array.copy() for idx in range(n_players): player_id = player_ids_array[idx] availability_mult = effective_availability_multipliers.get( player_id, {} ).get(gw, 1.0) xMins_for_current_gw_display[idx] *= availability_mult if ( player_id in effective_xmins_overrides and gw in effective_xmins_overrides[player_id] ): override = effective_xmins_overrides[player_id][gw] xMins_for_current_gw_display[idx] = override # Carry the override into the chain, not just the display. # Without this the chain kept running from baseline_xMins # underneath the overrides, and the first gameweek past them # jumped back onto it: Haaland went 83 at GW10 (Solio) to 71 # at GW11 (his own baseline of 75, decayed six times), Enzo # went 76 -> 81, and Gibbs-White - whose baseline is 0 - went # 83 -> 0. Seeding from the override makes the next gameweek # continue from the last number we actually believed. next_continuous_xmins_array[idx] = override xMins_for_current_gw_display = pd.Series( xMins_for_current_gw_display, index=player_df.index ) next_gw_continuous_xMins = pd.Series( next_continuous_xmins_array, index=player_df.index ) gw_calc_df[f"{gw}_xMins"] = xMins_for_current_gw_display # STREAMLINED MATCH SCORING LOOP gw_matches = match_df[match_df["GW"] == gw] gw_shares, gw_share_ctx = closed_shares( player_df, gw_matches, gw_calc_df[f"{gw}_xMins"], gw, share_context ) if gw_share_ctx is not None and share_context is None: recorded_share_context["contrib"][gw] = gw_share_ctx["contrib"] recorded_share_context["denom"][gw] = gw_share_ctx["denom"] bonus_rows = [] for index, player in player_df.iterrows(): if gw_shares is not None: player = player.copy() for col in SHARE_STATS: player[col] = gw_shares.at[index, col] player_team_num = player["team"] my_matches = gw_matches[ (gw_matches["home_team_num"] == player_team_num) | (gw_matches["away_team_num"] == player_team_num) ] if my_matches.empty: gw_calc_df.loc[index, "base_pts"] = 0 gw_calc_df.loc[index, f"{gw}_xMins"] = 0 gw_calc_df.loc[index, "gw_xG"] = 0.0 gw_calc_df.loc[index, "gw_xA"] = 0.0 gw_calc_df.loc[index, "gw_CS"] = 0.0 gw_calc_df.loc[index, "gw_cbit"] = 0.0 gw_calc_df.loc[index, "gw_cbitr"] = 0.0 continue base_gw_mins = gw_calc_df.loc[index, f"{gw}_xMins"] mins_per_match = ( base_gw_mins * 0.97 if len(my_matches) > 1 and base_gw_mins > 35 else base_gw_mins ) # The stored probabilities describe the gameweek, not one fixture. # They were combined across a gameweek's matches as 1 - prod(1-p), # so split them back out the same way rather than applying the # gameweek figure to each match and counting it twice. gw_probs = effective_mins_probs.get(player["id"], {}).get(gw) match_probs = None if gw_probs: n = len(my_matches) match_probs = tuple( p if n == 1 else 1 - (1 - min(max(float(p), 0.0), 1.0)) ** (1.0 / n) for p in gw_probs[:2] ) total_gw_pts = 0 total_gw_xg = 0 total_gw_xa = 0 total_gw_cs = 0 total_gw_cbit = 0 total_gw_cbitr = 0 for _, match_row in my_matches.iterrows(): stats = calculate_single_match_points( player=player, match_row=match_row, xMins_in_match=mins_per_match, mins_probs=match_probs, points_config=points_config, player_penalty_shares=player_penalty_shares, is_gk=(player["element_type"] == 1), is_def=(player["element_type"] == 2), is_mid=(player["element_type"] == 3), is_fwd=(player["element_type"] == 4), ) total_gw_pts += stats["pts"] total_gw_xg += stats["xG"] total_gw_xa += stats["xA"] total_gw_cs += stats["CS"] total_gw_cbit += stats["cbit"] total_gw_cbitr += stats["cbitr"] is_home = player_team_num == match_row["home_team_num"] opp_num = ( match_row["away_team_num"] if is_home else match_row["home_team_num"] ) match_id = ( f"{match_row['home_team_num']}_vs_{match_row['away_team_num']}" ) if bonus_model and "breakdown" in stats: bonus_rows.append((index, match_id, int(player["element_type"]), stats["breakdown"]["bps"], mins_per_match)) match_projections_col[index][match_id] = { "opponent_team_id": int(opp_num), "is_home": bool(is_home), "default_gw": int(gw), "Pts": round(stats["pts"], 3), "xMins": round(mins_per_match, 1), "xG": round(stats["xG"], 3), "xA": round(stats["xA"], 3), "CS": round(stats["CS"], 3), } gw_calc_df.loc[index, "base_pts"] = total_gw_pts gw_calc_df.loc[index, "gw_xG"] = total_gw_xg gw_calc_df.loc[index, "gw_xA"] = total_gw_xa gw_calc_df.loc[index, "gw_CS"] = total_gw_cs gw_calc_df.loc[index, "gw_cbit"] = total_gw_cbit gw_calc_df.loc[index, "gw_cbitr"] = total_gw_cbitr if bonus_model and bonus_rows: # A single-player run cannot see its position's gameweek average, # so it reuses the full run's. if share_context is not None and gw in share_context.get("bonus_ref", {}): reference = share_context["bonus_ref"][gw] else: reference = bonus_reference(bonus_rows) recorded_share_context["bonus_ref"][gw] = reference for (index, match_id), bonus in apply_bonus(bonus_rows, bonus_model, reference).items(): gw_calc_df.loc[index, "base_pts"] += bonus entry = match_projections_col[index].get(match_id) if entry is not None: entry["Pts"] = round(entry["Pts"] + bonus, 3) gw_calc_df = apply_team_skepticism(gw_calc_df, team_skepticism) gw_calc_df["total_pts"] = gw_calc_df["base_pts"] final_df_output[f"{gw}_xMins"] = round(gw_calc_df[f"{gw}_xMins"], 0) final_df_output[f"{gw}_Pts"] = round(gw_calc_df["total_pts"], 2) final_df_output[f"{gw}_xG"] = round(gw_calc_df["gw_xG"], 2) final_df_output[f"{gw}_xA"] = round(gw_calc_df["gw_xA"], 2) final_df_output[f"{gw}_CS"] = gw_calc_df["gw_CS"] final_df_output[f"{gw}_cbit"] = gw_calc_df["gw_cbit"] final_df_output[f"{gw}_cbitr"] = gw_calc_df["gw_cbitr"] continuous_xMins_progression = next_gw_continuous_xMins.copy() final_df_output["Total Points"] = final_df_output.filter(like="_Pts").sum(axis=1) final_df_output["Average Points"] = round( (final_df_output.filter(like="_Pts").sum(axis=1)) / len(unique_gws), 2 ) final_df_output["match_projections"] = pd.Series(match_projections_col) if share_context is None and recorded_share_context["denom"]: final_df_output.attrs["share_context"] = recorded_share_context return final_df_output