Support Specialist v1: per-map league metrics + normalized stats

This commit is contained in:
FireHorse 2026-04-15 16:15:07 +10:00
parent 3dc737d819
commit ef88da9c53
13 changed files with 438 additions and 65 deletions

View file

@ -5,6 +5,12 @@ from analysis.team_identity_v2 import format_team_identity, generate_team_identi
from analysis.storylines import generate_storyline from analysis.storylines import generate_storyline
from analysis.predictions_v2 import generate_prediction from analysis.predictions_v2 import generate_prediction
import re
def safe_filename(name):
# Remove all illegal Windows filename characters
return re.sub(r'[\\/:*?"<>|]', '', name)
def export_obs_player_summaries(output_folder, team_players): def export_obs_player_summaries(output_folder, team_players):
""" """
@ -15,7 +21,7 @@ def export_obs_player_summaries(output_folder, team_players):
for p in team_players: for p in team_players:
name = p.get("name", "Unknown") name = p.get("name", "Unknown")
safe_name = name.replace(" ", "_") safe_name = safe_filename(name)
path = os.path.join(output_folder, f"{safe_name}_summary.txt") path = os.path.join(output_folder, f"{safe_name}_summary.txt")
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
@ -29,7 +35,8 @@ def export_obs_team_identity(output_folder, team_players, team_name):
os.makedirs(output_folder, exist_ok=True) os.makedirs(output_folder, exist_ok=True)
path = os.path.join(output_folder, f"{team_name}_identity.txt") safe_team = safe_filename(team_name)
path = os.path.join(output_folder, f"{safe_team}_identity.txt")
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
identity_data = generate_team_identity_block(team_players, team_name) identity_data = generate_team_identity_block(team_players, team_name)
identity_text = format_team_identity(identity_data) identity_text = format_team_identity(identity_data)

View file

@ -7,8 +7,18 @@ def build_player_summary(player):
- Sharpshooter - Sharpshooter
- Consistency - Consistency
- Clutch - Clutch
- Support Specialist
""" """
def safe_pct(value):
try:
return float(value) if value is not None else 0.0
except:
return 0.0
def safe_tier(value):
return value if value not in (None, "", "None") else "D"
name = player.get("name", "Unknown Player") name = player.get("name", "Unknown Player")
slayer = player.get("slayer", {}) slayer = player.get("slayer", {})
@ -16,28 +26,33 @@ def build_player_summary(player):
sharp = player.get("sharpshooter", {}) sharp = player.get("sharpshooter", {})
consistency = player.get("consistency", {}) consistency = player.get("consistency", {})
clutch = player.get("clutch", {}) clutch = player.get("clutch", {})
support = player.get("support_specialist", {})
lines = [] lines = []
lines.append(f"{name}\n") lines.append(f"{name}\n")
# Slayer # Slayer
lines.append(f"Slayer: {slayer.get('tier', 'D')} ({slayer.get('pct', 0):.1f}%)") lines.append(f"Slayer: {safe_tier(slayer.get('tier'))} ({safe_pct(slayer.get('pct')):.1f}%)")
lines.append(f" {slayer.get('summary', '')}") lines.append(f" {slayer.get('summary', '')}")
# Payload # Payload
lines.append(f"\nPayload Objective: {payload.get('tier', 'D')} ({payload.get('pct', 0):.1f}%)") lines.append(f"\nPayload Objective: {safe_tier(payload.get('tier'))} ({safe_pct(payload.get('pct')):.1f}%)")
lines.append(f" {payload.get('summary', '')}") lines.append(f" {payload.get('summary', '')}")
# Sharpshooter # Sharpshooter
lines.append(f"\nSharpshooter: {sharp.get('tier', 'D')} ({sharp.get('pct', 0):.1f}%)") lines.append(f"\nSharpshooter: {safe_tier(sharp.get('tier'))} ({safe_pct(sharp.get('pct')):.1f}%)")
lines.append(f" {sharp.get('summary', '')}") lines.append(f" {sharp.get('summary', '')}")
# Consistency # Consistency
lines.append(f"\nConsistency: {consistency.get('tier', 'D')} ({consistency.get('pct', 0):.1f}%)") lines.append(f"\nConsistency: {safe_tier(consistency.get('tier'))} ({safe_pct(consistency.get('pct')):.1f}%)")
lines.append(f" {consistency.get('summary', '')}") lines.append(f" {consistency.get('summary', '')}")
# Clutch # Clutch
lines.append(f"\nClutch: {clutch.get('tier', 'D')} ({clutch.get('pct', 0):.1f}%)") lines.append(f"\nClutch: {safe_tier(clutch.get('tier'))} ({safe_pct(clutch.get('pct')):.1f}%)")
lines.append(f" {clutch.get('summary', '')}") lines.append(f" {clutch.get('summary', '')}")
# Support Specialist
lines.append(f"\nSupport Specialist: {safe_tier(support.get('tier'))} ({safe_pct(support.get('pct')):.1f}%)")
lines.append(f" {support.get('summary', '')}")
return "\n".join(lines) return "\n".join(lines)

View file

@ -1,23 +1,16 @@
from analysis.team_identity_v2 import summarize_team_tags, classify_team_style from analysis.team_identity_v2 import summarize_team_tags, classify_team_style
from utils.safe_tag import safe_pct, safe_avg, safe_tier
def compute_team_power_score(summary): def compute_team_power_score(summary):
"""
Converts team tag percentiles into a single weighted power score.
Weights reflect real match impact:
Slayer: 30%
Objective: 25%
Consistency: 20%
Clutch: 15%
Sharpshooter: 10%
"""
return ( return (
summary["slayer"]["avg_pct"] * 0.30 + safe_pct(summary["slayer"]["avg_pct"]) * 0.30 +
summary["objective_payload"]["avg_pct"] * 0.25 + safe_pct(summary["objective_payload"]["avg_pct"]) * 0.25 +
summary["consistency"]["avg_pct"] * 0.20 + safe_pct(summary["consistency"]["avg_pct"]) * 0.20 +
summary["clutch"]["avg_pct"] * 0.15 + safe_pct(summary["clutch"]["avg_pct"]) * 0.15 +
summary["sharpshooter"]["avg_pct"] * 0.10 safe_pct(summary["sharpshooter"]["avg_pct"]) * 0.10
) )

View file

@ -10,6 +10,8 @@ __all__ = ["matchup_storyline", "generate_storyline"]
# Team Tag Aggregation # Team Tag Aggregation
# --------------------------------------------------------- # ---------------------------------------------------------
from utils.safe_tag import safe_pct, safe_tier, safe_avg
def summarize_team_tags(team_players): def summarize_team_tags(team_players):
""" """
Aggregates tag tiers + percentiles for a team. Aggregates tag tiers + percentiles for a team.
@ -32,23 +34,11 @@ def summarize_team_tags(team_players):
for p in team_players: for p in team_players:
tag_data = p.get(tag, {}) tag_data = p.get(tag, {})
pcts.append(tag_data.get("pct", 0)) pcts.append(safe_pct(tag_data.get("pct")))
tiers.append(tag_data.get("tier", "D")) tiers.append(safe_tier(tag_data.get("tier")))
if not pcts:
summary[tag] = {
"avg_pct": 0,
"top_tier": "D",
"count_S": 0,
"count_A": 0,
"count_B": 0,
"count_C": 0,
"count_D": 0,
}
continue
summary[tag] = { summary[tag] = {
"avg_pct": sum(p for p in pcts if isinstance(p, (int, float))) / max(1, len([p for p in pcts if isinstance(p, (int, float))])), "avg_pct": safe_avg(pcts),
"top_tier": max(tiers, key=lambda t: "SABCD".index(t)), "top_tier": max(tiers, key=lambda t: "SABCD".index(t)),
"count_S": tiers.count("S"), "count_S": tiers.count("S"),
"count_A": tiers.count("A"), "count_A": tiers.count("A"),

View file

@ -1,3 +1,10 @@
from utils.safe_tag import safe_pct, safe_tier, safe_avg
from utils.safe_tag import safe_pct, safe_tier, safe_avg
def summarize_team_tags(team_players): def summarize_team_tags(team_players):
""" """
Aggregates tag tiers + percentiles for a team. Aggregates tag tiers + percentiles for a team.
@ -16,23 +23,11 @@ def summarize_team_tags(team_players):
for p in team_players: for p in team_players:
tag_data = p.get(tag, {}) tag_data = p.get(tag, {})
pcts.append(tag_data.get("pct", 0)) pcts.append(safe_pct(tag_data.get("pct")))
tiers.append(tag_data.get("tier", "D")) tiers.append(safe_tier(tag_data.get("tier")))
if not pcts:
summary[tag] = {
"avg_pct": 0,
"top_tier": "D",
"count_S": 0,
"count_A": 0,
"count_B": 0,
"count_C": 0,
"count_D": 0,
}
continue
summary[tag] = { summary[tag] = {
"avg_pct": sum(p for p in pcts if isinstance(p, (int, float))) / max(1, len([p for p in pcts if isinstance(p, (int, float))])), "avg_pct": safe_avg(pcts),
"top_tier": max(tiers, key=lambda t: "SABCD".index(t)), "top_tier": max(tiers, key=lambda t: "SABCD".index(t)),
"count_S": tiers.count("S"), "count_S": tiers.count("S"),
"count_A": tiers.count("A"), "count_A": tiers.count("A"),

View file

@ -33,6 +33,7 @@ from match_engine import (
split_two_columns, split_two_columns,
compute_slayer_prediction, compute_slayer_prediction,
rank_match_players_consistency_career, rank_match_players_consistency_career,
rank_match_players_support_specialist,
) )
# --------------------------------------------------------- # ---------------------------------------------------------
@ -377,14 +378,14 @@ class DashLeagueGUI:
p["objective_payload"] = pdata.get("objective_payload", {}) p["objective_payload"] = pdata.get("objective_payload", {})
# DO NOT overwrite match-based DomObj # DO NOT overwrite match-based DomObj
if "objective_domination_components" not in p: if "objective_domination" not in p:
p["objective_domination_components"] = pdata.get( p["objective_domination"] = pdata.get("objective_domination", {})
"objective_domination_components",
{}
)
p["consistency"] = pdata.get("consistency", {}) p["consistency"] = pdata.get("consistency", {})
p["clutch"] = pdata.get("clutch", {}) p["clutch"] = pdata.get("clutch", {})
# Support Specialist
p["support_specialist"] = pdata.get("support_specialist", {})
# ----------------------------- # -----------------------------
@ -591,6 +592,15 @@ Consistency Summary:
if isinstance(score, (int, float)): if isinstance(score, (int, float)):
tags_out.append(f"Cons {score:.2f}") tags_out.append(f"Cons {score:.2f}")
# ---------------------------------------------------------
# Support Specialist (career)
# ---------------------------------------------------------
support = player_entry.get("support_specialist", {})
if isinstance(support, dict):
score = support.get("raw")
if isinstance(score, (int, float)):
tags_out.append(f"Supp {score:.2f}")
# --------------------------------------------------------- # ---------------------------------------------------------
# Payload Objective Specialist (match-based) # Payload Objective Specialist (match-based)
# --------------------------------------------------------- # ---------------------------------------------------------
@ -708,6 +718,7 @@ Consistency Summary:
"Domination Objective Specialist", "Domination Objective Specialist",
"Consistency", "Consistency",
"Clutch", "Clutch",
"Support Specialist",
] ]
ttk.Label(win, text="Select Tag:").pack(anchor="w", padx=10, pady=5) ttk.Label(win, text="Select Tag:").pack(anchor="w", padx=10, pady=5)
@ -744,7 +755,9 @@ Consistency Summary:
elif tag == "Consistency": elif tag == "Consistency":
ranked = rank_match_players_consistency_career(self.current_match_players) ranked = rank_match_players_consistency_career(self.current_match_players)
elif tag == "Support Specialist":
ranked = rank_match_players_support_specialist(self.current_match_players)
else: # Sharpshooter else: # Sharpshooter
ranked = rank_match_players_sharpshooter(self.current_match_players) ranked = rank_match_players_sharpshooter(self.current_match_players)

View file

@ -9,10 +9,12 @@ from tags.objective_domination import compute_dom_objective_for_career_player
from tags.sharpshooter import compute_sharpshooter_for_career_player from tags.sharpshooter import compute_sharpshooter_for_career_player
from tags.consistency import build_consistency_tag from tags.consistency import build_consistency_tag
from tags.clutch import compute_clutch, compute_clutch_raw from tags.clutch import compute_clutch, compute_clutch_raw
from tags.support_specialist import build_support_specialist_tag
from data.league_metrics import compute_league_metrics, compute_league_clutch_metrics from data.league_metrics import compute_league_metrics, compute_league_clutch_metrics
def build_career_database(output_path): def build_career_database(output_path):
print("USING DB:", db_access.DB_PATH) print("USING DB:", db_access.DB_PATH)
@ -101,6 +103,66 @@ def build_career_database(output_path):
for pdata in all_players: for pdata in all_players:
pdata["clutch_raw"] = compute_clutch_raw(pdata) pdata["clutch_raw"] = compute_clutch_raw(pdata)
# ---------------------------------------------------------
# NORMALIZE CAREER STAT KEYS BEFORE LEAGUE METRICS
# ---------------------------------------------------------
for pid, pdata in final_db["players"].items():
career = pdata.get("career", {})
career["push_time"] = (
career.get("push_time")
or career.get("PAY_PushTime")
or pdata.get("PAY_PushTime")
or 0
)
career["captures"] = (
career.get("captures")
or career.get("DOM_Captures")
or pdata.get("DOM_Captures")
or 0
)
career["counters"] = (
career.get("counters")
or career.get("DOM_Counters")
or pdata.get("DOM_Counters")
or 0
)
career["damage"] = (
career.get("damage")
or career.get("Damage")
or pdata.get("Damage")
or 0
)
career["kills"] = (
career.get("kills")
or career.get("Kills")
or pdata.get("Kills")
or 0
)
career["deaths"] = (
career.get("deaths")
or career.get("Deaths")
or pdata.get("Deaths")
or 0
)
career["maps"] = (
career.get("maps")
or pdata.get("maps")
or pdata.get("career", {}).get("maps")
or 1
)
pdata["career"] = career
# --------------------------------------------------------- # ---------------------------------------------------------
# 4. Compute league metrics for ALL TAGS # 4. Compute league metrics for ALL TAGS
@ -112,7 +174,22 @@ def build_career_database(output_path):
league_averages.update(clutch_averages) league_averages.update(clutch_averages)
final_db["league_averages"] = league_averages final_db["league_averages"] = league_averages
from tags.support_specialist import compute_support_specialist # at top of file if not already
# ---------------------------------------------------------
# 4.5 Build Support Specialist distribution
# ---------------------------------------------------------
support_distribution = []
for pid, pdata in final_db["players"].items():
raw_support = compute_support_specialist(pdata, league_averages)
support_distribution.append(raw_support)
# Write back normalized career block
pdata["career"] = career
# --------------------------------------------------------- # ---------------------------------------------------------
# 5. Compute all tags using distributions # 5. Compute all tags using distributions
# --------------------------------------------------------- # ---------------------------------------------------------
@ -140,12 +217,45 @@ def build_career_database(output_path):
) )
# Domination Objective Specialist # Domination Objective Specialist
pdata["objective_domination_components"] = compute_dom_objective_for_career_player( pdata["objective_domination"] = compute_dom_objective_for_career_player(
pdata, pdata,
league_averages, league_averages,
distributions["domination"] distributions["domination"]
) )
# Consistency (legacy adapter)
consistency_result = build_consistency_tag(
floor_current=pdata.get("floor_current"),
floor_career=pdata.get("floor_career"),
floor_league=league_averages.get("consistency_floor", 0.0),
stability_current=pdata.get("stability_current"),
stability_career=pdata.get("stability_career"),
stability_league=league_averages.get("consistency_stability", 0.0),
average_current=pdata.get("average_current"),
average_career=pdata.get("average_career"),
average_league=league_averages.get("consistency_average", 0.0),
matches_played_current=pdata["career"].get("maps", 0),
percentile=None
)
pdata["consistency"] = {
"raw": consistency_result.normalized_score,
"summary": consistency_result.summary_short
}
# Clutch
pdata["clutch"] = compute_clutch(
pdata,
league_averages,
clutch_distribution
)
# Support Specialist
pdata["support_specialist"] = build_support_specialist_tag(
pdata,
league_averages,
support_distribution
)
# --------------------------------------------------------- # ---------------------------------------------------------
# Consistency (Hybrid Career Tag - New System) # Consistency (Hybrid Career Tag - New System)
# --------------------------------------------------------- # ---------------------------------------------------------

View file

@ -30,6 +30,8 @@ def compute_league_metrics(all_players):
"push_time": [], "push_time": [],
"headshot_rate": [], "headshot_rate": [],
"pressure_eff": [], "pressure_eff": [],
"deaths": [],
"dom_actions": [],
} }
# ----------------------------------------- # -----------------------------------------
@ -41,10 +43,11 @@ def compute_league_metrics(all_players):
"payload": [], "payload": [],
"consistency": [], "consistency": [],
"domination": [], "domination": [],
"support_specialist": [],
} }
# ----------------------------------------- # -----------------------------------------
# Build league averages # Build league averages (PER-MAP NORMALIZED)
# ----------------------------------------- # -----------------------------------------
for p in all_players: for p in all_players:
c = p.get("career", {}) c = p.get("career", {})
@ -52,13 +55,17 @@ def compute_league_metrics(all_players):
if maps <= 0: if maps <= 0:
continue continue
kills = c.get("kills", 0) # Per-map normalization
deaths = c.get("deaths", 0) kills = c.get("kills", 0) / maps
damage = c.get("damage", 0) deaths = c.get("deaths", 0) / maps
damage = c.get("damage", 0) / maps
push = c.get("push_time", 0) / maps
captures = c.get("captures", c.get("DOM_Captures", 0)) / maps
counters = c.get("counters", c.get("DOM_Counters", 0)) / maps
shots = c.get("shots", 0) shots = c.get("shots", 0)
shots_hit = c.get("shots_hit", 0) shots_hit = c.get("shots_hit", 0)
headshots = c.get("headshots", 0) headshots = c.get("headshots", 0)
push = c.get("push_time", 0)
KD = kills / deaths if deaths > 0 else kills KD = kills / deaths if deaths > 0 else kills
accuracy = shots_hit / shots if shots > 0 else 0 accuracy = shots_hit / shots if shots > 0 else 0
@ -71,6 +78,8 @@ def compute_league_metrics(all_players):
acc["push_time"].append(push) acc["push_time"].append(push)
acc["headshot_rate"].append(headshot_rate) acc["headshot_rate"].append(headshot_rate)
acc["pressure_eff"].append(pressure_eff) acc["pressure_eff"].append(pressure_eff)
acc["deaths"].append(deaths)
acc["dom_actions"].append(captures + counters)
# ----------------------------------------- # -----------------------------------------
# Compute league averages # Compute league averages
@ -102,6 +111,12 @@ def compute_league_metrics(all_players):
# Domination # Domination
dist["domination"].append(compute_dom_raw(p)) dist["domination"].append(compute_dom_raw(p))
from tags.support_specialist import compute_support_specialist
dist["support_specialist"].append(
compute_support_specialist(p, league_averages)
)
# ----------------------------------------- # -----------------------------------------
# Sort distributions # Sort distributions

View file

@ -1,4 +1,9 @@
import bisect import bisect
from tags.support_specialist import build_support_specialist_tag
# --------------------------------------------------------- # ---------------------------------------------------------
# Percentile + Tier Helpers # Percentile + Tier Helpers

View file

@ -231,4 +231,39 @@ def compute_slayer_prediction(team_a_players, team_b_players):
"teamB_avg": teamB_avg, "teamB_avg": teamB_avg,
"teamA_win": round(pA * 100), "teamA_win": round(pA * 100),
"teamB_win": round(pB * 100), "teamB_win": round(pB * 100),
} }
def rank_match_players_support_specialist(players):
"""
Ranks players by Support Specialist (career tag) using percentile.
"""
ranked = []
for p in players:
tag = p.get("support_specialist", {})
# Use percentile as the ranking basis
pct = tag.get("pct")
if pct is None:
continue
ranked.append({
"name": p.get("name", "Unknown"),
"team": p.get("team", ""),
"pct": pct,
"tier": tag.get("tier", "D"),
"summary": tag.get("summary", ""),
"score_raw": pct, # used for team totals
"score_display": f"{pct:.1f}", # what you see in the table
})
# Sort descending by percentile
ranked.sort(key=lambda x: x["pct"], reverse=True)
# Assign ranks
for i, entry in enumerate(ranked, start=1):
entry["rank"] = i
return ranked

View file

@ -106,4 +106,40 @@ def rank_match_players_clutch(players):
for i, r in enumerate(ranked, start=1): for i, r in enumerate(ranked, start=1):
r["rank"] = i r["rank"] = i
return ranked
def rank_match_players_support_specialist(players):
"""
Ranks players by Support Specialist (career tag).
Uses the already-computed tag stored in each player's data.
"""
ranked = []
for p in players:
tag = p.get("support_specialist", {})
raw = tag.get("raw")
# Skip players with no data
if raw is None:
continue
ranked.append({
"name": p.get("name", "Unknown"),
"team": p.get("team", ""),
"raw": raw,
"pct": tag.get("pct", 0.0),
"tier": tag.get("tier", "D"),
"summary": tag.get("summary", ""),
"score_display": f"{raw:.2f}",
})
# Sort descending by raw score
ranked.sort(key=lambda x: x["raw"], reverse=True)
# Assign ranks
for i, entry in enumerate(ranked, start=1):
entry["rank"] = i
return ranked return ranked

View file

@ -0,0 +1,77 @@
from utils.safe_tag import safe_raw
# ---------------------------------------------------------
# RAW SCORE CALCULATION
# ---------------------------------------------------------
def compute_support_specialist(player, league_averages):
c = player.get("career", {})
dmg = safe_raw(c.get("damage"))
deaths = safe_raw(c.get("deaths"))
push_time = safe_raw(c.get("push_time"))
dom_caps = safe_raw(c.get("captures"))
dom_counters = safe_raw(c.get("counters"))
# League anchors
league_push = max(1.0, league_averages.get("push_time", 1.0))
league_dom_actions = max(1.0, league_averages.get("dom_actions", 1.0))
league_damage = max(1.0, league_averages.get("damage", 1.0))
league_deaths = max(1.0, league_averages.get("deaths", 1.0))
league_pressure = max(1e-6, league_averages.get("pressure_eff", 1.0))
# Objective presence (relative to league, capped)
presence_payload = (push_time / league_push)
presence_dom = ((dom_caps + dom_counters) / league_dom_actions)
objective_presence = max(presence_payload, presence_dom)
objective_presence = max(0.0, min(objective_presence, 2.0))
# Survivability: pressure vs league pressure_eff
player_pressure = dmg / max(1.0, deaths)
survivability = player_pressure / league_pressure
survivability = max(0.0, min(survivability, 2.0))
# Damage component: relative to league damage
damage_component = dmg / league_damage
damage_component = max(0.0, min(damage_component, 2.0))
# Low-death bonus: 1 is good, 0 is bad
low_death_bonus = 1.0 - (deaths / league_deaths)
low_death_bonus = max(0.0, min(low_death_bonus, 1.0))
# Weighted sum, then scale back into 0–1
raw = (
0.35 * objective_presence +
0.35 * survivability +
0.20 * damage_component +
0.10 * low_death_bonus
) / 2.0 # max of the capped components is 2 → divide by 2 to keep ≤ 1
return raw
# ---------------------------------------------------------
# TAG BUILDER (RAW → PERCENTILE → TIER → SUMMARY)
# ---------------------------------------------------------
def build_support_specialist_tag(player, league_averages, distribution):
"""
Converts raw Support Specialist score into full tag output.
"""
from tags.tag_framework import build_tag_output
raw = compute_support_specialist(player, league_averages)
def summary_fn(tier, pct):
if pct >= 85:
return "Elite support presence — stabilizes fights and enables strong objective pushes."
if pct >= 70:
return "Reliable support player with strong survivability and objective presence."
if pct >= 50:
return "Provides steady support value through survivability and objective actions."
if pct >= 30:
return "Occasional support impact but inconsistent objective presence."
return "Limited support impact — low survivability and minimal objective presence."
return build_tag_output(raw, distribution, summary_fn)

82
utils/safe_tag.py Normal file
View file

@ -0,0 +1,82 @@
import math
def safe_pct(value):
"""
Ensures pct is always a float between 0 and 100.
None, NaN, or invalid → 0.0
"""
try:
if value is None:
return 0.0
v = float(value)
if math.isnan(v):
return 0.0
return max(0.0, min(100.0, v))
except:
return 0.0
def safe_tier(value):
"""
Ensures tier is always a valid letter.
None or empty → 'D'
"""
if value in (None, "", "None"):
return "D"
return str(value)
def safe_summary(value):
"""
Ensures summary is always a string.
"""
if value is None:
return ""
return str(value)
def safe_raw(value):
"""
Ensures raw is always a float.
"""
try:
if value is None:
return 0.0
v = float(value)
if math.isnan(v):
return 0.0
return v
except:
return 0.0
def safe_tag(tag_dict):
"""
Normalizes an entire tag dict.
Guarantees all fields exist and are safe.
"""
if not isinstance(tag_dict, dict):
return {
"raw": 0.0,
"pct": 0.0,
"tier": "D",
"summary": ""
}
return {
"raw": safe_raw(tag_dict.get("raw")),
"pct": safe_pct(tag_dict.get("pct")),
"tier": safe_tier(tag_dict.get("tier")),
"summary": safe_summary(tag_dict.get("summary"))
}
def safe_avg(values):
"""
Averages a list of pct values safely.
None, NaN, invalid → treated as 0.0
"""
cleaned = [safe_pct(v) for v in values]
if not cleaned:
return 0.0
return sum(cleaned) / len(cleaned)