pschenone/defense_app
0
1"""2defense_app.py — Gradio UI for the Defensive Positioning Model (v2)3Upload as "app.py" to your Hugging Face Space.4"""5 6import io7import os8import tempfile9from copy import deepcopy10 11import gradio as gr12import numpy as np13import pandas as pd14 15import run_defense as rd16 17# ─────────────────────────────────────────────────────────────────────────────18# Presets19# ─────────────────────────────────────────────────────────────────────────────20 21EFFORT_PRESETS = {22 "Preview — fast sanity check": {"n_seeds": 3, "n_steps": 600, "refine_n": 100},23 "Standard — good interactive run":{"n_seeds": 8, "n_steps": 3000, "refine_n": 500},24 "Research — slower, more thorough":{"n_seeds":14, "n_steps": 8000, "refine_n": 1200},25}26 27AREA_OPTIONS = {28 "Central — ball through the middle": "central",29 "Wide — ball on right wing": "wide_right",30 "Wide — ball on left wing": "wide_left",31 "System view — all three areas jointly": "system",32}33 34 35# ─────────────────────────────────────────────────────────────────────────────36# Core run function37# ─────────────────────────────────────────────────────────────────────────────38 39def run_defense(area_label, danger_ui, d_min, effort_label, possession_file, seed):40 area_key = AREA_OPTIONS[area_label]41 params = deepcopy(rd.PARAMS_DEFAULT)42 params.update(EFFORT_PRESETS[effort_label])43 44 # Danger concentration: slider 0–100 maps to k = 0–2.045 # k=1 → box 2.7× more important than halfway (good default)46 # k=2 → box 7.4× more important (strong emphasis)47 params["danger_concentration"] = float(danger_ui) / 50.048 params["d_min"] = float(d_min)49 50 # Load possession coordinates if provided51 possession = None52 if possession_file is not None:53 try:54 possession = rd.load_possession_csv(possession_file.name)55 params["transition_weight"] = 0.356 except Exception:57 pass58 else:59 params["transition_weight"] = 0.060 61 tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)62 tmp.close()63 64 if area_key == "system":65 system = rd.optimise_system(params, seed=int(seed))66 rd.plot_system(system, savepath=tmp.name)67 import matplotlib.pyplot as plt68 plt.close("all")69 md = _system_markdown(system, params)70 csv_str = _system_csv(system)71 else:72 result = rd.optimise(rd.AREAS[area_key], params, seed=int(seed))73 74 # Compute possession→defensive transition and inject into result so75 # plot_on_pitch() draws the arrows and the markdown reports distances.76 if possession is not None:77 col_ind, max_d, total_d = rd.bottleneck_matching(78 possession, result["positions"],79 use_possession_transform=True,80 )81 result["transition"] = {82 "assignment": col_ind.tolist(),83 "max_displacement_m": round(max_d, 2),84 "total_displacement_m": round(total_d, 2),85 }86 87 rd.plot_on_pitch(result, possession=possession, savepath=tmp.name)88 import matplotlib.pyplot as plt89 plt.close("all")90 md = _single_markdown(result, area_key, params)91 csv_str = _single_csv(result)92 93 return md, tmp.name, csv_str94 95 96# ─────────────────────────────────────────────────────────────────────────────97# Markdown helpers98# ─────────────────────────────────────────────────────────────────────────────99 100def _single_markdown(result, area_key, params):101 ucr = result["unweighted_covering_radius_m"]102 wcr = result["weighted_covering_radius"]103 ms = result["min_spacing_m"]104 worst = result["worst_gap_location"]105 k = params["danger_concentration"]106 wg_pitch = float(rd._def_to_pitch_y(worst[1]))107 108 transition_section = ""109 if "transition" in result:110 t = result["transition"]111 transition_section = f"""112 113### Transition from possession shape114 115| Metric | Value |116|---|---|117| Maximum individual displacement | **{t["max_displacement_m"]:.1f} m** |118| Total collective displacement | {t["total_displacement_m"]:.1f} m |119 120*Arrows on the plot show each player moving from their possession position121(shown at the halfway line) to their assigned defensive slot.122The number on each arrow is the distance in metres.*123"""124 125 return f"""126## Result — {result["area"]["label"]}127 128*{result["area"]["info"]}*129 130### Coverage131 132| Metric | Value | Meaning |133|---|---|---|134| **Largest gap** | **{ucr:.1f} m** | Max distance from any point in the defended area to the nearest defender. Lower = tighter block. |135| Weighted covering radius | {wcr:.2f} | Same gap weighted by danger. The quantity the optimiser minimised. |136| Min spacing | {ms:.1f} m | Closest pair of defenders. |137| Worst gap at | x={worst[0]:.1f} m, y={wg_pitch:.1f} m | Full-pitch coordinates. The red × on the plot. |138| Runtime | {result["runtime_s"]:.1f} s | |139 140*Danger concentration k = {k:.2f}:141box edge is {rd.np.exp(k):.1f}× more important than the halfway line.*142{transition_section}"""143 144 145def _system_markdown(system, params):146 k = params["danger_concentration"]147 tr = system["transitions"]148 wr = system["wide_right"]149 wl = system["wide_left"]150 c = system["central"]151 152 return f"""153## System result — all three areas154 155The central shape was optimised jointly with the two wide shapes,156minimising the worst-case transition to either flank simultaneously.157 158### Coverage by area159 160| Area | Largest gap (m) | Min spacing (m) |161|---|---|---|162| Central | {c['unweighted_covering_radius_m']:.1f} | {c['min_spacing_m']:.1f} |163| Wide right | {wr['unweighted_covering_radius_m']:.1f} | {wr['min_spacing_m']:.1f} |164| Wide left | {wl['unweighted_covering_radius_m']:.1f} | {wl['min_spacing_m']:.1f} |165 166### Transitions from central shape167 168| Shift | Max individual run (m) | Total collective run (m) |169|---|---|---|170| Central → Wide right | **{tr['central_to_wide_right']['max_displacement_m']:.1f}** | {tr['central_to_wide_right']['total_displacement_m']:.1f} |171| Central → Wide left | **{tr['central_to_wide_left']['max_displacement_m']:.1f}** | {tr['central_to_wide_left']['total_displacement_m']:.1f} |172| **Worst case** | **{tr['worst_case_transition_m']:.1f}** | — |173 174*The worst-case transition tells you how far the hardest-working player runs175when your team shifts from the central block to either wide block.176A lower number means the two shapes are more compatible.*177 178*Danger concentration k = {k:.2f}: box edge is {rd.np.exp(k):.1f}× more important than halfway.*179"""180 181 182def _single_csv(result):183 pos = result["positions"]184 buf = io.StringIO()185 pd.DataFrame([{"player": i+1, "x": float(pos[i,0]), "y": float(pos[i,1])}186 for i in range(rd.N_PLAYERS)]).to_csv(buf, index=False)187 return buf.getvalue()188 189 190def _system_csv(system):191 rows = []192 for area_key in ("central", "wide_right", "wide_left"):193 pos = system[area_key]["positions"]194 for i in range(rd.N_PLAYERS):195 rows.append({"area": area_key, "player": i+1,196 "x": float(pos[i,0]), "y": float(pos[i,1])})197 buf = io.StringIO()198 pd.DataFrame(rows).to_csv(buf, index=False)199 return buf.getvalue()200 201 202# ─────────────────────────────────────────────────────────────────────────────203# UI text204# ─────────────────────────────────────────────────────────────────────────────205 206INTRO = """207# Defensive Shape Optimiser208 209Finds a 10-player out-of-possession block that minimises the worst spatial210gap in the area that needs defending — with space near your own box211weighted as more costly to leave empty than space near the halfway line.212 213The **System view** solves all three shapes jointly: the central block is214optimised to transition efficiently to either wing when the opponent switches.215 216*Companion to the possession shape optimiser.*217"""218 219HOW_TO_READ = """220---221## How to read the results222 223**The plot** shows your half-pitch (goal at the bottom, halfway at the top).224The yellow-bordered rectangle is the defended area.225Inside it, the background colour shows the weighted gap:226red = a large gap relative to danger level, green = well covered.227The red × marks the worst gap — the point the model most wants to close.228 229**Largest gap (m)** is the bluntest number: the furthest any point in the230defended area is from the nearest defender. Think of it as the diameter231of the largest hole in the net. 8–12 m is typical for a compact block232of 10 players in these areas.233 234**Danger concentration** controls how tightly the block packs toward the235box vs. spreading across the full 36 m depth.236- **Low (≤ 20):** the optimiser spreads players evenly from halfway to box edge.237 Good for a high line that defends the whole corridor.238- **Mid (40–60):** moderate emphasis on the box end.239 Players in the bottom half are closer together; the halfway-line end is thinner.240- **High (≥ 80):** strong packing near the box. The block is very tight241 in the last 15 m but leaves a lot of space between halfway and the top of the area.242 243**System view / transitions:** the maximum individual displacement tells you244how far the hardest-working player must run when you shift from central245to wide. Below 15 m is a comfortable transition; above 25 m suggests the246two shapes are poorly matched.247"""248 249 250# ─────────────────────────────────────────────────────────────────────────────251# Gradio layout252# ─────────────────────────────────────────────────────────────────────────────253 254with gr.Blocks(title="Defensive Shape Optimiser") as demo:255 gr.Markdown(INTRO)256 257 with gr.Row():258 # ── Controls ─────────────────────────────────────────────────────────259 with gr.Column(scale=1):260 261 gr.Markdown("### Scenario")262 area_radio = gr.Radio(263 choices=list(AREA_OPTIONS.keys()),264 value="System view — all three areas jointly",265 label="Ball position / view",266 info="'System view' jointly optimises all three shapes and shows transition costs.",267 )268 269 gr.Markdown("### Defensive philosophy")270 danger_slider = gr.Slider(271 0, 100, value=40, step=5,272 label="Danger concentration near box",273 info=(274 "How much more important is space near your box than space near the halfway line? "275 "0 = treat all depth equally (spread block). "276 "40 = box is ~2.2× more important (recommended starting point). "277 "100 = box is 7× more important (very compact near box, thin near halfway)."278 ),279 )280 d_min_slider = gr.Slider(281 3.0, 9.0, value=5.0, step=0.5,282 label="Minimum spacing between defenders (m)",283 info="Prevents two defenders from occupying the same zone. 5 m is a sensible default.",284 )285 286 gr.Markdown("### Run settings")287 effort_radio = gr.Radio(288 choices=list(EFFORT_PRESETS.keys()),289 value="Standard — good interactive run",290 label="Search effort",291 )292 seed_box = gr.Number(293 value=20260608, precision=0, label="Random seed",294 info="Change to explore different solutions with the same settings.",295 )296 297 gr.Markdown("### Transition analysis (optional)")298 possession_upload = gr.File(299 label="Possession-model coordinates CSV",300 file_types=[".csv"],301 )302 gr.Markdown(303 "_Upload `v21_full_optimized_coordinates.csv` from the possession model "304 "to see transition arrows from attacking positions._"305 )306 307 run_btn = gr.Button("Run model", variant="primary")308 309 # ── Results ───────────────────────────────────────────────────────────310 with gr.Column(scale=2):311 result_md = gr.Markdown("Run the model to see results.")312 shape_img = gr.Image(label="Defensive shape on pitch", type="filepath")313 with gr.Accordion("Coordinates (CSV)", open=False):314 coord_text = gr.Textbox(315 label="Player positions",316 lines=13,317 info="player, x, y — in defensive-frame coordinates.",318 )319 320 run_btn.click(321 run_defense,322 inputs=[area_radio, danger_slider, d_min_slider,323 effort_radio, possession_upload, seed_box],324 outputs=[result_md, shape_img, coord_text],325 )326 327 gr.Markdown(HOW_TO_READ)328 329if __name__ == "__main__":330 demo.queue(default_concurrency_limit=1).launch()331 