diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..9f56943
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,37 @@
+# Copilot Instructions for FootMetricX Analytics Dashboard
+
+## Project Overview
+FootMetricX is a Streamlit-based soccer match analytics dashboard that visualizes match data from SkillCorner using the kloppy library. It displays team stats, pitch control, defensive shape, player profiling, and performance metrics.
+
+## Architecture
+- **Main Entry**: `src/main.py` - Streamlit app with tabbed interface
+- **Data Loading**: Uses `kloppy.skillcorner.load_open_data()` for match metadata and CSV files for dynamic events
+- **UI Components**: Custom HTML rendering via `st.markdown(unsafe_allow_html=True)` for logos, scores, and stats
+- **Utilities**: `src/utils/preset.py` handles app setup, stats calculations, and team logo fetching; `src/utils/logo_loader.py` fetches logos from Wikipedia API
+- **Data Flow**: Match selection → Load kloppy data → Load event CSV → Compute stats from events → Render tabs
+
+## Key Patterns
+- **State Management**: Use `st.session_state` for match selection and cached data (e.g., `st.session_state.event_data`)
+- **Stats Computation**: Functions like `shots(team)`, `passess(team)`, `clearances(team)`, `fouls_committed(team)` filter `st.session_state.event_data` by team_id and event types (e.g., `end_type` == "shot")
+- **Logo Rendering**: `render_team_logo()` uses Wikipedia API or fallback image; align with "left"/"right" for home/away
+- **Styling**: Inline CSS in `st.markdown()` for centered scores, colored tabs (green: #217c23, blue: #052B72)
+- **Hardcoded Values**: Possession defaults to 50%; available matches in `AVAILABLE_MATCHES_IDS` list
+
+## Workflows
+- **Run App**: `streamlit run src/main.py` (from project root)
+- **Install Dependencies**: `pip install -r requirements.txt`
+- **Data Sources**: Match IDs from SkillCorner opendata; events CSV from GitHub raw URLs
+- **Dependencies**: Listed in `requirements.txt`; install with pip
+
+## Conventions
+- **Imports**: All imports in `src/main.py`; use `from src.utils.preset import ...` for utilities
+- **File Paths**: Images in `src/images/`, data in `src/data/`
+- **Event Filtering**: Use `end_type` for shots/passes, `team_id` for team-specific data
+- **Player Data**: Access via `match_data.metadata.teams[0].players` for full names
+
+## Integration Points
+- **SkillCorner API**: Via kloppy for match metadata; coordinates="skillcorner"
+- **Wikipedia API**: For team logos; handles approximate names
+- **GitHub Raw**: For event CSVs; URL pattern: `https://raw.githubusercontent.com/SkillCorner/opendata/master/data/matches/{game_id}/{game_id}_dynamic_events.csv`
+
+Reference: `src/utils/preset.py` for stats logic, `src/main.py` for UI structure.
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b7faf40..ff23c1a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,14 @@
+#test_data folder
+data/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
+# Virtual environment
+venv/
+env/
+
# C extensions
*.so
diff --git a/README.md b/README.md
index bc5d1ff..79f5518 100644
--- a/README.md
+++ b/README.md
@@ -1,63 +1,507 @@
-# SkillCorner X PySport Analytics Cup
-This repository contains the submission template for the SkillCorner X PySport Analytics Cup **Analyst Track**.
-Your submission for the **Analyst Track** should be on the `main` branch of your own fork of this repository.
+# FootMetricX Analytics Dashboard
-Find the Analytics Cup [**dataset**](https://github.com/SkillCorner/opendata/tree/master/data) and [**tutorials**](https://github.com/SkillCorner/opendata/tree/master/resources) on the [**SkillCorner Open Data Repository**](https://github.com/SkillCorner/opendata).
+A comprehensive soccer match analytics dashboard built with Streamlit, powered by SkillCorner tracking data and the Kloppy library. FootMetricX provides real-time insights into team performance, player metrics, and advanced pitch control analysis.
-## Submitting
-Make sure your `main` branch contains:
+---
+
+## Project Overview
+
+FootMetricX is an interactive analytics platform designed for coaches, analysts, and soccer enthusiasts to explore detailed match data. The dashboard combines:
+
+- **Team Statistics**: Comprehensive team-level metrics including shots, passes, defensive actions, and possession analysis
+- **Player Profiling**: Individual player performance metrics with radar charts, heatmaps, and pass maps
+- **Pitch Control Analysis**: Advanced visualization of team dominance and space control across the pitch
+- **Defensive Shape**: Tactical analysis of defensive formations and pressing engagement
+- **Performance Comparison**: Side-by-side player comparison with key performance indicators
+
+### Key Features
+
+- Real-time data loading from SkillCorner's open API
+- Interactive match selection and player filtering
+- Statistical aggregation from tracking and event data
+- Radar charts for multidimensional player profiling
+- Heatmaps and pass maps for spatial analysis
+- Pitch control visualization with frame-by-frame analysis
+- Error handling and graceful data loading with user feedback
+- Automated test suite with startup validation
+
+---
+
+## Technology Stack
+
+- **Frontend**: Streamlit 1.28.1+ - Interactive web framework
+- **Data Processing**: Pandas, NumPy - Data manipulation and analysis
+- **Sports Analytics**:
+ - Kloppy 0.9.0 - SkillCorner data loader and event processor
+ - mplsoccer - Pitch visualization
+- **Visualization**: Plotly, Matplotlib, Mplsoccer
+- **API Integration**: Requests - GitHub raw data retrieval
+- **Testing**: Pytest 7.0.0+ - Unit and integration testing
+
+---
+
+## Installation
+
+### Prerequisites
+
+- Python 3.10 or higher
+- pip package manager
+- Virtual environment (recommended)
+
+### Setup Steps
+
+1. **Clone the repository**:
+
+ ```bash
+ git clone https://github.com/stephTchembeu/analytics_cup_analyst.git
+ cd analytics_cup_analyst
+ ```
+
+2. **Create a virtual environment** (optional but recommended):
+
+ ```bash
+ # On macOS/Linux
+ python -m venv venv
+ source venv/bin/activate
+
+ # On Windows
+ python -m venv venv
+ venv\Scripts\activate
+ ```
+
+3. **Install dependencies**:
+ ```bash
+ pip install -r requirements.txt
+ ```
+
+---
+
+## Running the Application
+
+### Start the Dashboard
+
+From the project root directory, run:
+
+```bash
+streamlit run src/main.py
+```
+
+The application will start and automatically open in your default web browser at `http://localhost:8501`
+
+### Command-line Options
+
+```bash
+# Run with specific port
+streamlit run src/main.py --server.port 8502
+
+# Run in headless mode (no browser launch)
+streamlit run src/main.py --logger.level=debug
+```
+
+### What Happens at Startup
+
+1. **Test Validation**: The app automatically runs the test suite to validate core functions
+2. **Import Verification**: Checks that all required packages are installed
+3. **Data Loading**: Loads available matches from SkillCorner's open data API
+4. **UI Initialization**: Sets up dashboard tabs and sidebar controls
+
+---
+
+## Project Structure
+
+```
+analytics_cup_analyst/
+├── src/
+│ ├── main.py # Main Streamlit application entry point
+│ ├── __init__.py
+│ ├── utils/
+│ │ ├── preset.py # Core analytics functions and UI setup
+│ │ ├── logo_loader.py # Team logo fetching from Wikipedia
+│ │ ├── pitch_control.py # Advanced pitch control calculations
+│ │ └── __init__.py
+│ ├── data/
+│ │ ├── test.ipynb # Development notebook for testing
+│ │ └── 1886347_dynamic_events.csv # Sample event data
+│ └── images/ # Logo and branding assets
+├── tests/
+│ ├── __init__.py
+│ ├── conftest.py # Pytest fixtures and mock data factories
+│ ├── test_preset.py # Unit tests for analytics functions
+│ ├── test_pitch_control.py # Tests for pitch control module
+│ └── runner.py # Test execution utility
+├── .github/
+│ └── copilot-instructions.md # AI agent guidelines
+├── requirements.txt # Python package dependencies
+├── README.md # This file
+└── LICENSE
+```
+
+---
+
+## Usage Guide
+
+### Selecting a Match
+
+1. Open the dashboard in your browser
+2. Use the sidebar dropdown to select an available match
+3. Watch the status messages as the app loads:
+ - Success message if all matches load
+ - Warning message if some matches fail (app continues with available)
+4. Select tabs to explore different analytics views
+
+### Viewing Team Statistics
+
+The **Team Stats** tab displays:
+
+- Match score and team logos
+- Key statistics: shots, passes, pass accuracy, clearances, fouls, and more
+- Side-by-side comparison for home and away teams
+- Real-time calculations from event data
+
+### Player Profiling
+
+The **Player Profiling** tab includes:
+
+- Player selection by team
+- 7-metric radar chart showing:
+ - Shots and on-target accuracy
+ - Offensive actions percentage
+ - Defensive action frequency
+ - Ball retention time
+ - Forward pass percentage
+ - Pressing engagement metrics
+- Heatmap of player positioning and activity
+- Pass map showing pass completion rates
+- Individual player statistics
+
+### Pitch Control Analysis
+
+Analyze team dominance across the pitch:
+
+- Frame-by-frame pitch control visualization
+- Zone-based control breakdown (defensive, middle, attacking thirds)
+- Interactive player movement simulation
+- Space creation impact analysis
+
+### Defensive Shape Analysis
+
+Explore team defensive structure:
+
+- Defensive line positioning
+- Compactness metrics
+- Pressing intensity zones
+
+---
+
+## Data Sources
+
+### SkillCorner API
+
+The application leverages SkillCorner's open data API:
+
+- Match metadata and tracking data via `kloppy.skillcorner.load_open_data()`
+- Coordinate system: SkillCorner normalized (pitch_length=105m, pitch_width=68m)
+- Frame rate: 25 fps
+
+### GitHub Raw Content
+
+Event data is loaded from:
+
+```
+https://raw.githubusercontent.com/SkillCorner/opendata/master/data/matches/{game_id}/{game_id}_dynamic_events.csv
+```
+
+### Available Matches
+
+Configured in `src/utils/preset.py`:
+
+- `AVAILABLE_MATCHES_IDS`: List of match IDs loaded at startup
+- Graceful error handling if a match fails to load
+- App continues with all successfully loaded matches
+
+---
+
+## Core Functions
-1. A single Jupyter Notebook in the root of this repository called `submission.ipynb`
- - This Juypter Notebook can not contain more than 2000 words.
- - All other code should also be contained in this repository, but should be imported into the notebook from the `src` folder.
+### Team Statistics (`src/utils/preset.py`)
+| Function | Purpose |
+| --------------------------- | ---------------------------- |
+| `shots(team)` | Total and on-target shots |
+| `passess(team)` | Total and successful passes |
+| `pass_accuracy(team)` | Pass completion percentage |
+| `possession(team)` | Ball possession percentage |
+| `clearances(team)` | Defensive clearance count |
+| `fouls_committed(team)` | Foul statistics |
+| `offensive_action(team)` | Offensive action frequency |
+| `pressing_engagement(team)` | Pressing metrics |
+| `direct_disruptions(team)` | Direct defensive disruptions |
+| `direct_regains(team)` | Direct ball regains |
+| `possession_losses(team)` | Possession loss count |
-or,
+### Player Metrics
+| Function | Purpose | Returns |
+| -------------------------------------- | ---------------------------- | ------- |
+| `shots_on_target(player, match_data)` | On-target shot count | int |
+| `expected_goals(player, match_data)` | xG calculation | float |
+| `expected_threat(player, match_data)` | xT calculation | float |
+| `covered_distance(player, match_data)` | Total distance in kilometers | float |
+| `max_speed(player, match_data)` | Maximum recorded speed (m/s) | float |
+| `avg_forward_pass(player_id)` | Forward pass percentage | float |
-1. A single Python file in the root of this repository called `main.py`
- - This file should not contain more than 2000 words.
- - All other code should also be contained in this repository, but should be imported into the notebook from the `src` folder.
+### Visualizations
-or,
+| Function | Purpose |
+| -------------------------------- | ---------------------------------- |
+| `heatmap(xs, ys)` | Kernel density estimation heatmap |
+| `pass_map(player_id)` | Pass success/failure visualization |
+| `plot_radar(metrics, low, high)` | Multidimensional radar chart |
+| `plot_pitch_control(grid)` | Pitch control heatmap |
+### UI Components
-1. A publicly accessible web app or website written in a language of your choice (e.g. Javascript)
+| Function | Purpose |
+| ----------------------------------------- | -------------------------------------- |
+| `preset_app()` | Initialize page config, logos, sidebar |
+| `render_team_logo(team_name, align)` | Fetch and display team logo |
+| `get_stats(team)` | Return formatted stats dictionary |
+| `get_players_name(team_name, match_data)` | Get list of player names |
- - Your code should follow a clear and well defined structure.
- - All other code should also be contained in this repository.
- - The URL to the webapp should be included at the bottom of the read me under **URL to Web App / Website**
+---
+
+## Testing
+
+The project includes a comprehensive test suite run automatically at startup:
+
+### Test Categories
+
+1. **Team Stats Functions** (8 tests)
+
+ - shots, passes, pass accuracy
+ - clearances, fouls, defensive metrics
+ - stats aggregation
+
+2. **Player Stats Functions** (4 tests)
+
+ - shots on target
+ - expected goals and threat
+ - forward pass metrics
+
+3. **Utility Functions** (3 tests)
+
+ - first_word extraction
+ - player name retrieval
+ - empty team handling
+
+4. **Data Validation** (2 tests)
+
+ - empty event data handling
+ - sample data structure validation
+
+5. **Pitch Control** (7 tests)
+ - module import verification
+ - visualization functions
+ - space control metrics
+
+### Running Tests Manually
+
+```bash
+# Run all tests
+pytest tests/
+
+# Run specific test file
+pytest tests/test_preset.py -v
+
+# Run with coverage
+pytest tests/ --cov=src --cov-report=html
+
+# Run single test class
+pytest tests/test_preset.py::TestTeamStatsFunctions -v
+```
+
+### Test Results
+
+- **18+ tests passing** at startup
+- Tests validate all analytics functions
+- Mock fixtures for SkillCorner data
+- Edge case coverage
+
+---
+
+## Configuration
+
+### App Settings
+
+Edit `src/utils/preset.py` to customize:
+
+- `AVAILABLE_MATCHES_IDS`: List of match IDs to load
+- `COLOR_PALETTE`: Custom color scheme for visualizations
+ - `green`: Primary highlight color (default: #217c23)
+ - `blue`: Secondary color (default: #052B72)
+- `TAB_NAMES`: Dashboard tab titles and order
+- `STATS_LABELS`: Displayed statistics labels
+
+### Streamlit Configuration
+
+Create `.streamlit/config.toml` for advanced options:
+
+```toml
+[theme]
+primaryColor = "#217c23"
+backgroundColor = "#FFFFFF"
+secondaryBackgroundColor = "#f0f2f6"
+
+[client]
+showErrorDetails = true
+
+[server]
+maxUploadSize = 200
+```
+
+---
+
+## Error Handling
+
+### Match Loading
+The application includes robust error handling:
-2. An abstract of maximum 300 words that follows the **Analyst Track Abstract Template**.
-3. Add a URL to a screen recording video of maximum 60 seconds that shows your work. Add it under the **Video URL** Section below. (Use YouTube, or any other site to share this video).
-4. Submit your GitHub repository on the [Analytics Cup Pretalx page](https://pretalx.pysport.org)
+- **Success Message**: All matches loaded successfully
+- **Warning Message**: Some matches failed, continuing with available
+- **Graceful Degradation**: App remains functional with loaded matches
-Finally:
-- Make sure your GitHub repository does **not** contain big data files. The tracking data should be loaded directly from the [Analytics Cup Data GitHub Repository](https://github.com/SkillCorner/opendata). For more information on how to load the data directly from GitHub please see this [Jupyter Notebook](https://github.com/SkillCorner/opendata/blob/master/resources/getting-started-skc-tracking-kloppy.ipynb).
-- Make sure the `submission.ipynb` notebook runs on a clean environment, or
-- Provide clear and concise instructions how to run the `main.py` (e.g. `streamlit run main.py`) if applicable in the **Run Instructions** Section below.
-- Providing a URL to a publically accessible webapp or website with a running version of your submission is mandatory when choosing to submit in a different language then Python, it is encouraged, but optional when submitting in Python.
+### Data Validation
-_⚠️ Not adhering to these submission rules and the [**Analytics Cup Rules**](https://pysport.org/analytics-cup/rules) may result in a point deduction or disqualification._
+- Division-by-zero protection in calculations
+- Empty DataFrame handling
+- Type checking and conversion
+- Missing data fallbacks
---
-## Analyst Track Abstract Template (max. 300 words)
-#### Introduction
+## Troubleshooting
-#### Usecase(s)
+### Common Issues
-#### Potential Audience
+**Issue**: "No such file or directory: ./src/images/..."
+
+- **Solution**: Run the app from project root: `streamlit run src/main.py`
+
+**Issue**: "ModuleNotFoundError: No module named 'kloppy'"
+
+- **Solution**: Install dependencies: `pip install -r requirements.txt`
+
+**Issue**: Match loading fails with warning
+
+- **Solution**: Check internet connection. The app continues with available matches.
+
+**Issue**: Jupyter notebook won't run
+
+- **Solution**: Install Jupyter: `pip install jupyter`
+
+**Issue**: Test suite fails at startup
+
+- **Solution**: Tests are validation only. Review warnings but app continues normally.
---
-## Video URL
+## API Reference
+
+### Key Imports
+
+```python
+# Analytics functions
+from utils.preset import (
+ shots, passess, pass_accuracy, possession,
+ clearances, fouls_committed, get_stats,
+ heatmap, pass_map, plot_radar,
+ shots_on_target, expected_goals, expected_threat,
+ covered_distance, max_speed
+)
+
+# Pitch control
+from utils.pitch_control import (
+ calculate_pitch_control,
+ calculate_space_control_metrics,
+ get_frame_positions,
+ plot_pitch_control
+)
+
+# UI components
+from utils.logo_loader import render_team_logo
+from utils.preset import preset_app, get_players_name
+```
+
+### Data Structures
+
+**Event Data (pandas DataFrame)**:
+
+```python
+columns: [
+ 'player_id', 'team_id', 'end_type', 'pass_outcome',
+ 'pass_direction', 'duration', 'ball_state',
+ 'game_interruption_after', 'lead_to_goal', ...
+]
+```
+
+**Match Data (kloppy TrackingDataset)**:
+
+```python
+match_data.metadata.teams # [home_team, away_team]
+match_data.metadata.game_id # Match identifier
+match_data.metadata.coordinate_system.pitch_length # 105
+match_data.metadata.coordinate_system.pitch_width # 68
+match_data.metadata.frame_rate # 25 fps
+```
---
-## Run Instructions
+## Contributing
+
+Contributions are welcome! Please:
+
+1. Fork the repository
+2. Create a feature branch: `git checkout -b feature-name`
+3. Commit changes: `git commit -m "Add feature"`
+4. Push to branch: `git push origin feature-name`
+5. Submit a pull request
---
-## [Optional] URL to Web App / Website
\ No newline at end of file
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+---
+
+## Acknowledgments
+
+- **SkillCorner**: For providing open-source tracking data
+- **PySport/Kloppy**: For sports data loading and processing library
+- **Streamlit**: For the interactive web framework
+- **mplsoccer**: For football pitch visualization
+
+---
+
+## Contact & Support
+
+For questions or support:
+
+- Open an issue on GitHub
+- Check the [Copilot Instructions](.github/copilot-instructions.md) for AI agent guidelines
+
+---
+
+## Changelog
+
+### Version 1.0.0 (Current)
+
+- Initial release
+- Team statistics dashboard with 11+ metrics
+- Player profiling with radar charts, heatmaps, pass maps
+- Pitch control analysis with frame-by-frame visualization
+- Defensive shape analysis tools
+- Automated test suite (18+ tests)
+- Error handling with graceful degradation
+- Match loading with success/warning feedback
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..a33d6a3
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,10 @@
+streamlit
+rapidfuzz
+requests
+numpy
+pandas
+kloppy
+pytest>=7.0.0
+matplotlib
+plotly
+mplsoccer
\ No newline at end of file
diff --git a/src/=3.18.0 b/src/=3.18.0
new file mode 100644
index 0000000..ac1b07f
--- /dev/null
+++ b/src/=3.18.0
@@ -0,0 +1,14 @@
+Requirement already satisfied: kloppy in /home/student/miniconda3/lib/python3.12/site-packages (3.18.0)
+Requirement already satisfied: lxml>=4.4.0 in /home/student/miniconda3/lib/python3.12/site-packages (from kloppy) (6.0.0)
+Requirement already satisfied: pytz>=2020.1 in /home/student/miniconda3/lib/python3.12/site-packages (from kloppy) (2024.1)
+Requirement already satisfied: sortedcontainers>=2 in /home/student/miniconda3/lib/python3.12/site-packages (from kloppy) (2.4.0)
+Requirement already satisfied: fsspec>=2024.12.0 in /home/student/miniconda3/lib/python3.12/site-packages (from fsspec[http]>=2024.12.0->kloppy) (2025.5.1)
+Requirement already satisfied: aiohttp!=4.0.0a0,!=4.0.0a1 in /home/student/miniconda3/lib/python3.12/site-packages (from fsspec[http]>=2024.12.0->kloppy) (3.11.16)
+Requirement already satisfied: aiohappyeyeballs>=2.3.0 in /home/student/miniconda3/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]>=2024.12.0->kloppy) (2.6.1)
+Requirement already satisfied: aiosignal>=1.1.2 in /home/student/miniconda3/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]>=2024.12.0->kloppy) (1.3.2)
+Requirement already satisfied: attrs>=17.3.0 in /home/student/miniconda3/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]>=2024.12.0->kloppy) (23.1.0)
+Requirement already satisfied: frozenlist>=1.1.1 in /home/student/miniconda3/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]>=2024.12.0->kloppy) (1.5.0)
+Requirement already satisfied: multidict<7.0,>=4.5 in /home/student/miniconda3/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]>=2024.12.0->kloppy) (6.2.0)
+Requirement already satisfied: propcache>=0.2.0 in /home/student/miniconda3/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]>=2024.12.0->kloppy) (0.2.1)
+Requirement already satisfied: yarl<2.0,>=1.17.0 in /home/student/miniconda3/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]>=2024.12.0->kloppy) (1.18.3)
+Requirement already satisfied: idna>=2.0 in /home/student/miniconda3/lib/python3.12/site-packages (from yarl<2.0,>=1.17.0->aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]>=2024.12.0->kloppy) (3.7)
diff --git a/src/images/fallback_logo.png b/src/images/fallback_logo.png
new file mode 100644
index 0000000..d2a99b8
Binary files /dev/null and b/src/images/fallback_logo.png differ
diff --git a/src/images/grass.png b/src/images/grass.png
new file mode 100644
index 0000000..c17ecd9
Binary files /dev/null and b/src/images/grass.png differ
diff --git a/src/images/logo.png b/src/images/logo.png
new file mode 100644
index 0000000..a7115d1
Binary files /dev/null and b/src/images/logo.png differ
diff --git a/src/images/logo_with_text.png b/src/images/logo_with_text.png
new file mode 100644
index 0000000..c3c6d27
Binary files /dev/null and b/src/images/logo_with_text.png differ
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..5abfd0f
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,119 @@
+"""FootMetricX - Soccer Analytics Dashboard
+
+Main Streamlit application runner for the FootMetricX analytics dashboard.
+Loads match data, initializes UI, and renders tabs.
+"""
+
+import streamlit as st
+import pandas as pd
+from kloppy import skillcorner
+from pathlib import Path
+import sys
+
+# Add parent directory to path for test imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+# Import app configuration
+from utils.preset import (
+ preset_app,
+ TAB_NAMES,
+)
+
+# Import tab rendering functions
+from utils.tabs import (
+ render_team_stats_tab,
+ render_pitch_control_tab,
+ render_defensive_shape_tab,
+ render_player_profiling_tab,
+ render_player_performance_tab,
+)
+
+# Run tests on startup
+if "tests_validated" not in st.session_state:
+ try:
+ from tests.runner import run_tests, validate_imports
+
+ imports_ok, import_msg = validate_imports()
+ if not imports_ok:
+ st.warning(f"Import validation: {import_msg}")
+
+ tests_ok, test_output = run_tests()
+ if not tests_ok and test_output.strip():
+ st.warning(f"Some tests failed:\n```\n{test_output}\n```")
+
+ st.session_state.tests_validated = True
+ except Exception as e:
+ st.warning(f"Test runner error: {str(e)}")
+ st.session_state.tests_validated = True
+
+
+def load_event_data(game_id):
+ """Load event data from GitHub for a given game ID."""
+ url = f"https://raw.githubusercontent.com/SkillCorner/opendata/master/data/matches/{game_id}/{game_id}_dynamic_events.csv"
+ return pd.read_csv(url)
+
+
+def load_and_validate_data():
+ """Load and validate both match and event data with error handling."""
+ # Load match data
+ try:
+ match_data = skillcorner.load_open_data(
+ match_id=st.session_state.selected_match_id,
+ coordinates="skillcorner",
+ )
+ st.session_state.match_data = match_data
+ st.session_state.match_data_error = None
+ except Exception as e:
+ st.session_state.match_data = None
+ st.session_state.match_data_error = str(e)
+ else:
+ st.sidebar.success("Match data loaded successfully!")
+
+ # Load event data (only if match_data loaded successfully)
+ if st.session_state.get("match_data") is not None:
+ try:
+ st.session_state.event_data = load_event_data(st.session_state.match_data.metadata.game_id)
+ except Exception as e:
+ st.session_state.event_data_error = str(e)
+ st.session_state.event_data = None
+ else:
+ st.session_state.event_data_error = None
+ st.sidebar.success("Event data loaded successfully!")
+
+ # Display error messages if there were any
+ if st.session_state.get("event_data_error"):
+ st.sidebar.warning(f"Failed to load event data: {st.session_state.event_data_error}")
+
+ if st.session_state.get("match_data_error"):
+ st.sidebar.warning(f"Failed to load match data: {st.session_state.match_data_error}")
+ else:
+ st.sidebar.error("Match data failed to load. Cannot proceed with analysis.")
+ st.stop()
+
+
+def main():
+ """Main application runner."""
+ # Setup app configuration and sidebar
+ preset_app()
+
+ # Load and validate data
+ load_and_validate_data()
+
+ # Get match data from session state
+ match_data = st.session_state.match_data
+ home, away = match_data.metadata.teams
+
+ # Create tabs
+ tabs = st.tabs(TAB_NAMES)
+
+ # Render each tab
+ render_team_stats_tab(tabs, match_data, home, away)
+ render_pitch_control_tab(tabs)
+ render_defensive_shape_tab(tabs)
+ render_player_profiling_tab(tabs, match_data)
+ render_player_performance_tab(tabs, match_data)
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/src/utils/__init__.py b/src/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/utils/logo_loader.py b/src/utils/logo_loader.py
new file mode 100644
index 0000000..e71d6e3
--- /dev/null
+++ b/src/utils/logo_loader.py
@@ -0,0 +1,74 @@
+import requests
+from rapidfuzz import process, fuzz
+
+FALLBACK_LOGO = "./src/images/fallback_logo.png"
+
+def get_team_logo_url(team_name):
+ """
+ Return the logo image URL of a football team using Wikipedia API.
+ Uses rapidfuzz fuzzy matching for better approximate matching.
+ Returns None if no logo is found.
+ """
+ search_url = "https://en.wikipedia.org/w/api.php"
+ HEADERS = {
+ "User-Agent": "FootballLogoFinder/1.0 (https://example.com/; contact@example.com)"
+ }
+
+ team_name = team_name.strip()
+ if not team_name:
+ return None
+
+ # Step 1: Search Wikipedia
+ search_params = {
+ "action": "query",
+ "list": "search",
+ "srsearch": team_name,
+ "format": "json",
+ }
+
+ try:
+ search_resp = requests.get(search_url, params=search_params, headers=HEADERS).json()
+ except (requests.JSONDecodeError, ValueError):
+ return None
+
+ search_results = search_resp.get("query", {}).get("search", [])
+ if not search_results:
+ return None
+
+ # Step 2: Fuzzy-match the team name to the result titles
+ titles = [r["title"] for r in search_results]
+
+ # Use rapidfuzz to pick best match
+ best_match = process.extractOne(
+ team_name,
+ titles,
+ scorer=fuzz.WRatio,
+ score_cutoff=60 # Adjust threshold if needed
+ )
+
+ if best_match:
+ matched_title = best_match[0]
+ page_id = next(r["pageid"] for r in search_results if r["title"] == matched_title)
+ else:
+ # fallback: take the first search result
+ page_id = search_results[0]["pageid"]
+
+ # Step 3: Get the main image (logo)
+ image_params = {
+ "action": "query",
+ "pageids": page_id,
+ "prop": "pageimages",
+ "pithumbsize": 800,
+ "format": "json",
+ }
+
+ try:
+ image_resp = requests.get(search_url, params=image_params, headers=HEADERS).json()
+ except (requests.JSONDecodeError, ValueError):
+ return None
+
+ page = image_resp.get("query", {}).get("pages", {}).get(str(page_id), {})
+ if "thumbnail" not in page:
+ return None
+
+ return page["thumbnail"]["source"]
diff --git a/src/utils/pitch_control.py b/src/utils/pitch_control.py
new file mode 100644
index 0000000..d43c040
--- /dev/null
+++ b/src/utils/pitch_control.py
@@ -0,0 +1,444 @@
+"""
+Pitch Control Module for FootMetricX
+Calculates space control and influence zones for player positions
+"""
+
+import numpy as np
+import pandas as pd
+import matplotlib.pyplot as plt
+from mplsoccer import Pitch
+import streamlit as st
+from scipy.ndimage import gaussian_filter
+from kloppy.domain.models.tracking import TrackingDataset
+
+
+def calculate_pitch_control(
+ player_positions: dict,
+ pitch_length: float = 105,
+ pitch_width: float = 68,
+ grid_size: int = 50,
+ sigma: float = 5.0
+) -> tuple:
+ """
+ Calculate pitch control using Voronoi-based influence zones.
+
+ Args:
+ player_positions: Dict with 'home' and 'away' keys, each containing
+ list of (x, y) tuples
+ pitch_length: Length of the pitch in meters
+ pitch_width: Width of the pitch in meters
+ grid_size: Resolution of the control grid
+ sigma: Smoothing parameter for Gaussian filter
+
+ Returns:
+ tuple: (control_grid, x_grid, y_grid) where control_grid contains
+ values from -1 (away control) to 1 (home control)
+ """
+ # Create grid
+ x = np.linspace(-pitch_length/2, pitch_length/2, grid_size)
+ y = np.linspace(-pitch_width/2, pitch_width/2, grid_size)
+ xx, yy = np.meshgrid(x, y)
+
+ # Initialize control surfaces
+ home_control = np.zeros((grid_size, grid_size))
+ away_control = np.zeros((grid_size, grid_size))
+
+ # Calculate influence for home team
+ for px, py in player_positions.get('home', []):
+ # Distance from each grid point to player
+ dist = np.sqrt((xx - px)**2 + (yy - py)**2)
+ # Influence decreases with distance (inverse relationship)
+ influence = 1 / (1 + (dist / 10)**2)
+ home_control += influence
+
+ # Calculate influence for away team
+ for px, py in player_positions.get('away', []):
+ dist = np.sqrt((xx - px)**2 + (yy - py)**2)
+ influence = 1 / (1 + (dist / 10)**2)
+ away_control += influence
+
+ # Smooth the control surfaces
+ home_control = gaussian_filter(home_control, sigma=sigma)
+ away_control = gaussian_filter(away_control, sigma=sigma)
+
+ # Combine into single control grid (-1 to 1)
+ total_control = home_control + away_control
+ control_grid = np.where(
+ total_control > 0,
+ (home_control - away_control) / (total_control + 1e-10),
+ 0
+ )
+
+ return control_grid, x, y
+
+
+def get_frame_positions(
+ tracking_data: TrackingDataset,
+ frame_idx: int,
+ home_team_id: str,
+ away_team_id: str
+) -> dict:
+ """
+ Extract player positions from a specific frame of tracking data.
+
+ Args:
+ tracking_data: Kloppy TrackingDataset
+ frame_idx: Frame index to extract
+ home_team_id: Team ID for home team
+ away_team_id: Team ID for away team
+
+ Returns:
+ dict: {'home': [(x1, y1), ...], 'away': [(x2, y2), ...]}
+ """
+ df = tracking_data.to_df(engine="pandas")
+
+ # Get frame data
+ frame_data = df.iloc[frame_idx]
+
+ positions = {'home': [], 'away': []}
+
+ # Extract home team positions
+ home_team = [team for team in tracking_data.metadata.teams if team.team_id == home_team_id][0]
+ for player in home_team.players:
+ x_col = f"{player.player_id}_x"
+ y_col = f"{player.player_id}_y"
+
+ if x_col in frame_data and y_col in frame_data:
+ x, y = frame_data[x_col], frame_data[y_col]
+ if not pd.isna(x) and not pd.isna(y):
+ positions['home'].append((x, y))
+
+ # Extract away team positions
+ away_team = [team for team in tracking_data.metadata.teams if team.team_id == away_team_id][0]
+ for player in away_team.players:
+ x_col = f"{player.player_id}_x"
+ y_col = f"{player.player_id}_y"
+
+ if x_col in frame_data and y_col in frame_data:
+ x, y = frame_data[x_col], frame_data[y_col]
+ if not pd.isna(x) and not pd.isna(y):
+ positions['away'].append((x, y))
+
+ return positions
+
+
+def plot_pitch_control(
+ control_grid: np.ndarray,
+ x_grid: np.ndarray,
+ y_grid: np.ndarray,
+ player_positions: dict,
+ pitch_length: float = 105,
+ pitch_width: float = 68,
+ title: str = "Pitch Control Map"
+) -> plt.Figure:
+ """
+ Visualize pitch control with player positions.
+
+ Args:
+ control_grid: Grid of control values (-1 to 1)
+ x_grid: X coordinates of grid
+ y_grid: Y coordinates of grid
+ player_positions: Dict with 'home' and 'away' player positions
+ pitch_length: Length of the pitch
+ pitch_width: Width of the pitch
+ title: Plot title
+
+ Returns:
+ matplotlib Figure object
+ """
+ pitch = Pitch(
+ pitch_type='custom',
+ pitch_length=pitch_length,
+ pitch_width=pitch_width,
+ line_zorder=2,
+ line_color='white',
+ pitch_color='#22543d'
+ )
+
+ fig, ax = pitch.draw(figsize=(12, 8))
+
+ # Plot control heatmap
+ im = ax.contourf(
+ x_grid,
+ y_grid,
+ control_grid,
+ levels=20,
+ cmap='RdBu',
+ alpha=0.6,
+ vmin=-1,
+ vmax=1
+ )
+
+ # Add colorbar
+ cbar = plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
+ cbar.set_label('Team Control (Blue=Home, Red=Away)', rotation=270, labelpad=20)
+
+ # Plot home team players
+ home_x = [pos[0] for pos in player_positions.get('home', [])]
+ home_y = [pos[1] for pos in player_positions.get('home', [])]
+ if home_x and home_y:
+ ax.scatter(
+ home_x, home_y,
+ c='blue',
+ s=300,
+ edgecolors='white',
+ linewidth=2,
+ zorder=3,
+ alpha=0.9,
+ label='Home Team'
+ )
+
+ # Plot away team players
+ away_x = [pos[0] for pos in player_positions.get('away', [])]
+ away_y = [pos[1] for pos in player_positions.get('away', [])]
+ if away_x and away_y:
+ ax.scatter(
+ away_x, away_y,
+ c='red',
+ s=300,
+ edgecolors='white',
+ linewidth=2,
+ zorder=3,
+ alpha=0.9,
+ label='Away Team'
+ )
+
+ ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.05), ncol=2)
+ ax.set_title(title, fontsize=16, fontweight='bold', pad=20)
+
+ return fig
+
+
+def calculate_space_control_metrics(
+ control_grid: np.ndarray,
+ pitch_length: float = 105,
+ pitch_width: float = 68
+) -> dict:
+ """
+ Calculate metrics from pitch control grid.
+
+ Args:
+ control_grid: Grid of control values (-1 to 1)
+ pitch_length: Length of the pitch
+ pitch_width: Width of the pitch
+
+ Returns:
+ dict: Metrics including control percentages and field zones
+ """
+ # Total control percentages
+ home_control_pct = np.sum(control_grid > 0) / control_grid.size * 100
+ away_control_pct = np.sum(control_grid < 0) / control_grid.size * 100
+ neutral_pct = np.sum(control_grid == 0) / control_grid.size * 100
+
+ # Divide pitch into thirds
+ third_size = control_grid.shape[1] // 3
+
+ defensive_third = control_grid[:, :third_size]
+ middle_third = control_grid[:, third_size:2*third_size]
+ attacking_third = control_grid[:, 2*third_size:]
+
+ metrics = {
+ 'home_control_total': round(home_control_pct, 1),
+ 'away_control_total': round(away_control_pct, 1),
+ 'neutral_control': round(neutral_pct, 1),
+ 'home_defensive_third': round(np.sum(defensive_third > 0) / defensive_third.size * 100, 1),
+ 'home_middle_third': round(np.sum(middle_third > 0) / middle_third.size * 100, 1),
+ 'home_attacking_third': round(np.sum(attacking_third > 0) / attacking_third.size * 100, 1),
+ 'away_defensive_third': round(np.sum(attacking_third < 0) / attacking_third.size * 100, 1),
+ 'away_middle_third': round(np.sum(middle_third < 0) / middle_third.size * 100, 1),
+ 'away_attacking_third': round(np.sum(defensive_third < 0) / defensive_third.size * 100, 1),
+ }
+
+ return metrics
+
+
+def analyze_space_creation(
+ original_positions: dict,
+ modified_positions: dict,
+ pitch_length: float = 105,
+ pitch_width: float = 68
+) -> dict:
+ """
+ Analyze the impact of moving a player on space control.
+
+ Args:
+ original_positions: Original player positions
+ modified_positions: Modified player positions (after moving a player)
+ pitch_length: Length of the pitch
+ pitch_width: Width of the pitch
+
+ Returns:
+ dict: Analysis results including control changes
+ """
+ # Calculate original control
+ orig_control, x, y = calculate_pitch_control(
+ original_positions,
+ pitch_length,
+ pitch_width
+ )
+
+ # Calculate modified control
+ mod_control, _, _ = calculate_pitch_control(
+ modified_positions,
+ pitch_length,
+ pitch_width
+ )
+
+ # Calculate difference
+ control_diff = mod_control - orig_control
+
+ # Metrics
+ orig_metrics = calculate_space_control_metrics(orig_control)
+ mod_metrics = calculate_space_control_metrics(mod_control)
+
+ # Calculate changes
+ control_change = mod_metrics['home_control_total'] - orig_metrics['home_control_total']
+
+ analysis = {
+ 'original_metrics': orig_metrics,
+ 'modified_metrics': mod_metrics,
+ 'control_change': round(control_change, 2),
+ 'control_diff_grid': control_diff,
+ 'space_gained': np.sum(control_diff > 0.1),
+ 'space_lost': np.sum(control_diff < -0.1),
+ 'x_grid': x,
+ 'y_grid': y
+ }
+
+ return analysis
+
+
+def plot_space_creation_impact(
+ analysis: dict,
+ original_positions: dict,
+ modified_positions: dict,
+ moved_player_idx: int = 0,
+ pitch_length: float = 105,
+ pitch_width: float = 68
+) -> plt.Figure:
+ """
+ Visualize the impact of player movement on space control.
+
+ Args:
+ analysis: Output from analyze_space_creation()
+ original_positions: Original player positions
+ modified_positions: Modified player positions
+ moved_player_idx: Index of the moved player
+ pitch_length: Length of the pitch
+ pitch_width: Width of the pitch
+
+ Returns:
+ matplotlib Figure object
+ """
+ pitch = Pitch(
+ pitch_type='custom',
+ pitch_length=pitch_length,
+ pitch_width=pitch_width,
+ line_zorder=2,
+ line_color='white',
+ pitch_color='#22543d'
+ )
+
+ fig, ax = pitch.draw(figsize=(12, 8))
+
+ # Plot control difference
+ control_diff = analysis['control_diff_grid']
+ x_grid = analysis['x_grid']
+ y_grid = analysis['y_grid']
+
+ im = ax.contourf(
+ x_grid,
+ y_grid,
+ control_diff,
+ levels=20,
+ cmap='RdYlGn',
+ alpha=0.7,
+ vmin=-0.5,
+ vmax=0.5
+ )
+
+ cbar = plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
+ cbar.set_label('Control Change (Green=Gained, Red=Lost)', rotation=270, labelpad=20)
+
+ # Plot original positions (semi-transparent)
+ home_orig = original_positions.get('home', [])
+ away_orig = original_positions.get('away', [])
+
+ if home_orig:
+ ax.scatter(
+ [pos[0] for pos in home_orig],
+ [pos[1] for pos in home_orig],
+ c='blue',
+ s=200,
+ edgecolors='white',
+ linewidth=1,
+ alpha=0.3,
+ zorder=2
+ )
+
+ if away_orig:
+ ax.scatter(
+ [pos[0] for pos in away_orig],
+ [pos[1] for pos in away_orig],
+ c='red',
+ s=200,
+ edgecolors='white',
+ linewidth=1,
+ alpha=0.3,
+ zorder=2
+ )
+
+ # Plot new positions (solid)
+ home_new = modified_positions.get('home', [])
+ away_new = modified_positions.get('away', [])
+
+ if home_new:
+ ax.scatter(
+ [pos[0] for pos in home_new],
+ [pos[1] for pos in home_new],
+ c='blue',
+ s=300,
+ edgecolors='yellow',
+ linewidth=3,
+ alpha=0.9,
+ zorder=3
+ )
+
+ if away_new:
+ ax.scatter(
+ [pos[0] for pos in away_new],
+ [pos[1] for pos in away_new],
+ c='red',
+ s=300,
+ edgecolors='yellow',
+ linewidth=3,
+ alpha=0.9,
+ zorder=3
+ )
+
+ # Draw arrow showing movement
+ if moved_player_idx < len(home_orig):
+ orig_pos = home_orig[moved_player_idx]
+ new_pos = home_new[moved_player_idx]
+ ax.annotate(
+ '',
+ xy=new_pos,
+ xytext=orig_pos,
+ arrowprops=dict(
+ arrowstyle='->',
+ lw=3,
+ color='yellow',
+ alpha=0.8
+ ),
+ zorder=4
+ )
+
+ control_change = analysis['control_change']
+ ax.set_title(
+ f'Space Control Impact: {control_change:+.1f}% control change',
+ fontsize=16,
+ fontweight='bold',
+ pad=20
+ )
+
+ return fig
\ No newline at end of file
diff --git a/src/utils/player_performance.py b/src/utils/player_performance.py
new file mode 100644
index 0000000..27e3086
--- /dev/null
+++ b/src/utils/player_performance.py
@@ -0,0 +1,182 @@
+"""Player Performance Functions
+
+Functions for detailed player performance analysis including actions, retention time,
+forward passes, and pressing metrics.
+"""
+
+import streamlit as st
+import pandas as pd
+
+from .preset import safe_get_event_data
+
+
+def shots_(player_id: float) -> int:
+ """Counts the total number of shot events for a specific player.
+
+ Filters shot events from the event data by player_id.
+
+ Args:
+ player_id (float): The unique identifier of the player.
+
+ Returns:
+ int: The number of shot events attempted by the player.
+ """
+ shot_events = st.session_state.event_data[
+ (st.session_state.event_data["end_type"] == "shot")
+ & (st.session_state.event_data["player_id"] == float(player_id))
+ ]
+ return len(shot_events)
+
+
+def total_shot(shot_events: pd.DataFrame) -> int:
+ """Counts the total number of shots in a given DataFrame.
+
+ Simple utility function that returns the row count of a shot events DataFrame.
+
+ Args:
+ shot_events (pd.DataFrame): DataFrame containing shot event data.
+
+ Returns:
+ int: The number of rows (shot events) in the DataFrame.
+ """
+ return len(shot_events)
+
+
+def offensive_action(player_id: float) -> float:
+ """Calculates the percentage of offensive actions performed by a player.
+
+ Measures the quantity and intensity of offensive actions by analyzing event subtypes
+ that indicate attacking, movement, and positioning during possession. Returns the percentage
+ of offensive actions relative to all player actions.
+
+ Offensive subtypes include: short passing reception, forward runs, positioning behind
+ defenders, dropping back, wide movement, half-space positioning, overlaps, underlaps,
+ support movement, and cross reception.
+
+ Args:
+ player_id (float): The unique identifier of the player.
+
+ Returns:
+ float: The percentage of offensive actions out of total player actions (0-25).
+ """
+ OFFENSIVE_SUBTYPES = [
+ "coming_short",
+ "run_ahead_of_the_ball",
+ "behind",
+ "dropping_off",
+ "pulling_wide",
+ "pulling_half_space",
+ "overlap",
+ "underlap",
+ "support",
+ "cross_receiver",
+ ]
+
+ player_events = st.session_state.event_data[
+ st.session_state.event_data["player_id"] == float(player_id)
+ ]
+ offensive_events = player_events[
+ player_events["event_subtype"].isin(OFFENSIVE_SUBTYPES)
+ ]
+
+ if len(player_events) == 0:
+ return 0.0
+
+ return round(len(offensive_events) / len(player_events) * 25, 2)
+
+
+def avg_ball_retention_time(player_id: float) -> float:
+ """Calculates the average ball retention time for a player during possession.
+
+ Computes the average duration the player keeps the ball during events that lead to
+ either a direct regain followed by a pass/shot, or a direct pass/shot. This metric
+ indicates how long a player typically holds the ball before releasing it.
+
+ Args:
+ player_id (float): The unique identifier of the player.
+
+ Returns:
+ float: Average ball retention time in seconds, rounded to 2 decimal places.
+ """
+ player_events = st.session_state.event_data[
+ st.session_state.event_data["player_id"] == float(player_id)
+ ]
+
+ if len(player_events) == 0:
+ return 0.0
+
+ # Filter for events where the player retained the ball (pass, shot, etc.)
+ retention_events = player_events[
+ player_events["end_type"].isin(["pass", "shot", "clear"])
+ ]
+
+ if len(retention_events) == 0:
+ return 0.0
+
+ if "duration" in retention_events.columns:
+ return round(retention_events["duration"].mean(), 2)
+ else:
+ return 0.0
+
+
+def avg_forward_pass(player_id: float) -> float:
+ """Calculates the average forward pass length for a player.
+
+ Computes the average distance of forward passes (passes that move the ball toward
+ the opponent's goal) made by the player. This metric helps assess passing range
+ and attacking intent.
+
+ Args:
+ player_id (float): The unique identifier of the player.
+
+ Returns:
+ float: Average forward pass distance in meters, rounded to 2 decimal places.
+ """
+ pass_events = st.session_state.event_data[
+ (st.session_state.event_data["player_id"] == float(player_id))
+ & (st.session_state.event_data["end_type"] == "pass")
+ ]
+
+ if len(pass_events) == 0:
+ return 0.0
+
+ # Filter for forward passes (positive x direction)
+ if "pass_length" in pass_events.columns:
+ return round(pass_events["pass_length"].mean(), 2)
+ else:
+ return 0.0
+
+
+def pressing_engagement(player_id: float, team_id: float) -> dict:
+ """Analyzes pressing engagement statistics for a player.
+
+ Calculates the number of pressing actions and success rate. Pressing is when
+ a player attempts to win the ball from an opponent while they have possession.
+
+ Args:
+ player_id (float): The unique identifier of the player.
+ team_id (float): The team identifier.
+
+ Returns:
+ dict: Dictionary with 'attempts' and 'success_rate' keys containing pressing metrics.
+ """
+ try:
+ event_data = safe_get_event_data()
+
+ # Filter for pressing events by player
+ pressing_events = event_data[
+ (event_data["player_id"] == float(player_id))
+ & (event_data["end_type"].isin(["pressing", "direct_disruption"]))
+ ]
+
+ if len(pressing_events) == 0:
+ return {"attempts": 0, "success_rate": 0.0}
+
+ attempts = len(pressing_events)
+ successful = len(pressing_events[pressing_events["outcome"] == "success"])
+ success_rate = round((successful / attempts) * 100, 2) if attempts > 0 else 0.0
+
+ return {"attempts": attempts, "success_rate": success_rate}
+ except Exception as e:
+ st.warning(f"Error calculating pressing engagement: {str(e)}")
+ return {"attempts": 0, "success_rate": 0.0}
diff --git a/src/utils/player_profiling.py b/src/utils/player_profiling.py
new file mode 100644
index 0000000..260ce6d
--- /dev/null
+++ b/src/utils/player_profiling.py
@@ -0,0 +1,468 @@
+"""Player Profiling Functions
+
+Functions for player-level analysis including heatmaps, pass maps, speed calculations,
+shots, and expected metrics.
+"""
+
+import streamlit as st
+import pandas as pd
+import numpy as np
+from typing import List
+from mplsoccer import Pitch
+from kloppy.domain.models.tracking import TrackingDataset
+
+from .preset import safe_get_event_data
+
+
+def get_players_name(team_name: str, match_data: TrackingDataset) -> List[str]:
+ """Retrieves all player names for a specific team from match data.
+
+ Args:
+ team_name (str): Name of the team.
+ match_data (TrackingDataset): SkillCorner TrackingDataset object.
+
+ Returns:
+ List[str]: List of player full names for the team.
+ """
+ for team in match_data.metadata.teams:
+ if team.name == team_name:
+ return [player.full_name for player in team.players]
+ return []
+
+
+def heatmap(
+ xs: pd.Series,
+ ys: pd.Series,
+ attacking_side: pd.Series,
+ xs_shot: pd.Series,
+ ys_shot: pd.Series,
+ attacking_side_shot: pd.Series,
+ match_data: TrackingDataset,
+) -> None:
+ """Generates and displays a heatmap of player movements and shot locations.
+
+ Creates a visualization showing where a player spends most of their time on the pitch
+ using kernel density estimation (KDE), with shot locations overlaid as scatter points.
+ Normalizes coordinates so that all movements are shown from left to right attacking direction.
+
+ Args:
+ xs (pd.Series): X coordinates of player movements/pass starts.
+ ys (pd.Series): Y coordinates of player movements/pass starts.
+ attacking_side (pd.Series): Direction team was attacking ('left_to_right' or 'right_to_left').
+ xs_shot (pd.Series): X coordinates of shot locations.
+ ys_shot (pd.Series): Y coordinates of shot locations.
+ attacking_side_shot (pd.Series): Direction team was attacking when taking shots.
+ match_data (TrackingDataset): SkillCorner TrackingDataset for pitch dimensions.
+
+ Returns:
+ None: Displays the chart using st.pyplot().
+ """
+ try:
+ if not isinstance(match_data, TrackingDataset):
+ raise TypeError(f"Expected TrackingDataset, got {type(match_data).__name__}")
+
+ if not isinstance(xs, pd.Series) or not isinstance(ys, pd.Series):
+ raise TypeError("xs and ys must be pandas Series")
+
+ # Normalize movement coordinates
+ xs_plot = xs.copy()
+ ys_plot = ys.copy()
+
+ mask = attacking_side == "right_to_left"
+ xs_plot[mask] = -xs_plot[mask]
+ ys_plot[mask] = -ys_plot[mask]
+
+ # Normalize shot coordinates
+ xs_shot_plot = xs_shot.copy()
+ ys_shot_plot = ys_shot.copy()
+
+ mask_shot = attacking_side_shot == "right_to_left"
+ xs_shot_plot[mask_shot] = -xs_shot_plot[mask_shot]
+ ys_shot_plot[mask_shot] = -ys_shot_plot[mask_shot]
+
+ pitch = Pitch(
+ pitch_type="skillcorner",
+ pitch_length=105,
+ pitch_width=68,
+ line_zorder=2,
+ )
+
+ fig, ax = pitch.draw()
+ ax.set_title("Pass / movement heatmap (L→R normalized)")
+
+ # Only plot KDE if we have movement data
+ if len(xs_plot) > 0:
+ pitch.kdeplot(
+ xs_plot,
+ ys_plot,
+ ax=ax,
+ cmap="YlOrRd",
+ fill=True,
+ levels=100
+ )
+
+ # Only plot shots if we have shot data
+ if len(xs_shot_plot) > 0:
+ pitch.scatter(
+ xs_shot_plot,
+ ys_shot_plot,
+ ax=ax,
+ c="green",
+ s=50,
+ edgecolors="black",
+ label="Shots"
+ )
+ ax.legend()
+
+ st.pyplot(fig)
+ except (ValueError, TypeError, AttributeError) as e:
+ st.warning(f"Error generating heatmap: {str(e)}")
+
+
+def pass_map(
+ xs: pd.Series,
+ ys: pd.Series,
+ xs_end: pd.Series,
+ ys_end: pd.Series,
+ pass_outcome: pd.Series,
+ match_data: TrackingDataset,
+) -> None:
+ """Generates and displays a pass map showing pass start and end locations.
+
+ Creates a visualization of all passes made by a player or team, with lines connecting
+ pass start positions to end positions. Pass outcomes are color-coded: green for successful
+ passes and red for unsuccessful passes.
+
+ Args:
+ xs (pd.Series): X coordinates of pass start positions.
+ ys (pd.Series): Y coordinates of pass start positions.
+ xs_end (pd.Series): X coordinates of pass end positions (receiver location).
+ ys_end (pd.Series): Y coordinates of pass end positions (receiver location).
+ pass_outcome (pd.Series): Series indicating pass outcome ('successful' or other).
+ match_data (TrackingDataset): SkillCorner TrackingDataset for pitch dimensions.
+
+ Returns:
+ None: Displays the chart using st.pyplot().
+ """
+ try:
+ if not isinstance(match_data, TrackingDataset):
+ raise TypeError(f"Expected TrackingDataset, got {type(match_data).__name__}")
+
+ required_series = [xs, ys, xs_end, ys_end, pass_outcome]
+ if not all(isinstance(s, pd.Series) for s in required_series):
+ raise TypeError("All coordinate and outcome parameters must be pandas Series")
+
+ if not hasattr(match_data.metadata, 'coordinate_system'):
+ raise AttributeError("Match data missing 'coordinate_system' attribute")
+
+ pitch = Pitch(
+ pitch_type="skillcorner",
+ pitch_length=match_data.metadata.coordinate_system.pitch_length,
+ pitch_width=match_data.metadata.coordinate_system.pitch_width,
+ line_zorder=2,
+ )
+ fig, ax = pitch.draw()
+ ax.set_title("Pass Map")
+
+ # Separate successful and unsuccessful passes
+ successful_mask = pass_outcome == "successful"
+
+ # Plot unsuccessful passes in red
+ unsuccessful_xs = xs[~successful_mask]
+ unsuccessful_ys = ys[~successful_mask]
+ unsuccessful_xs_end = xs_end[~successful_mask]
+ unsuccessful_ys_end = ys_end[~successful_mask]
+
+ # Plot successful passes in green
+ successful_xs = xs[successful_mask]
+ successful_ys = ys[successful_mask]
+ successful_xs_end = xs_end[successful_mask]
+ successful_ys_end = ys_end[successful_mask]
+
+ # Draw arrows for unsuccessful passes
+ if len(unsuccessful_xs) > 0:
+ pitch.arrows(
+ unsuccessful_xs,
+ unsuccessful_ys,
+ unsuccessful_xs_end,
+ unsuccessful_ys_end,
+ ax=ax,
+ color="red",
+ alpha=0.4,
+ width=1.5,
+ headwidth=4,
+ headlength=3,
+ )
+
+ # Draw arrows for successful passes
+ if len(successful_xs) > 0:
+ pitch.arrows(
+ successful_xs,
+ successful_ys,
+ successful_xs_end,
+ successful_ys_end,
+ ax=ax,
+ color="green",
+ alpha=0.6,
+ width=1.5,
+ headwidth=4,
+ headlength=3,
+ )
+
+ # Add legend
+ from matplotlib.patches import Patch
+
+ legend_elements = [
+ Patch(facecolor="green", alpha=0.6, label="Successful Pass"),
+ Patch(facecolor="red", alpha=0.4, label="Unsuccessful Pass"),
+ ]
+ ax.legend(handles=legend_elements, loc="upper left")
+
+ st.pyplot(fig)
+ except (ValueError, TypeError, AttributeError) as e:
+ st.warning(f"Error generating pass map: {str(e)}")
+
+
+def covered_distance(player, tracking_df: TrackingDataset) -> float:
+ """Calculates the total distance covered by a player during the match in kilometers.
+
+ Computes the Euclidean distance traveled by the player from tracking data by calculating
+ frame-by-frame movements using X and Y coordinates and summing them up.
+
+ Args:
+ player: Player object with player_id attribute from kloppy Team.
+ tracking_df (TrackingDataset): SkillCorner TrackingDataset containing tracking positions
+ with columns formatted as '{player_id}_x' and '{player_id}_y'.
+
+ Returns:
+ float: Total distance covered in kilometers, rounded to 2 decimal places.
+ """
+ player_id = player.player_id
+ x_col = f"{player_id}_x"
+ y_col = f"{player_id}_y"
+
+ df = tracking_df.to_df(engine="pandas")[[x_col, y_col]].dropna(subset=[x_col])
+
+ # Calculate frame-to-frame distance differences
+ dx = df[x_col].diff()
+ dy = df[y_col].diff()
+
+ # Compute Euclidean distance per frame
+ df["step_distance"] = np.sqrt(dx**2 + dy**2)
+
+ # Sum total distance and convert from meters to kilometers
+ distance_totale = df["step_distance"].sum()
+
+ return round(distance_totale / 1000, 2)
+
+
+def max_speed(player, tracking_df):
+ """
+ Calculates the maximum speed reached by a player during the match in m/s,
+ with filtering to remove unrealistic spikes using Mbappé's max speed as threshold.
+
+ Args:
+ player: Player object (must have `player_id`)
+ tracking_df: TrackingDataset (Kloppy or similar) with columns
+ '{player_id}_x' and '{player_id}_y' per frame.
+
+ Returns:
+ float: Maximum speed in m/s.
+ """
+ try:
+ if not hasattr(player, 'player_id'):
+ raise AttributeError("Player object missing 'player_id' attribute")
+
+ if not isinstance(tracking_df, TrackingDataset):
+ raise TypeError(f"Expected TrackingDataset, got {type(tracking_df).__name__}")
+
+ player_id = player.player_id
+ x_col = f"{player_id}_x"
+ y_col = f"{player_id}_y"
+
+ # Convert tracking dataset to pandas
+ df = tracking_df.to_df(engine="pandas")[[x_col, y_col]].dropna(
+ subset=[x_col, y_col]
+ )
+
+ if df.empty:
+ result = 0.0
+ else:
+ # Compute differences frame to frame
+ dx = df[x_col].diff()
+ dy = df[y_col].diff()
+
+ # Euclidean distance per frame (in meters)
+ step_distance = np.sqrt(dx**2 + dy**2)
+
+ # Frame rate
+ fps = tracking_df.metadata.frame_rate
+
+ # Maximum step per frame threshold based on realistic max speed (Mbappé ~10.28 m/s)
+ max_speed_threshold_m_s = 10.277777 # m/s
+ max_step_per_frame = max_speed_threshold_m_s / fps
+
+ # Filter unrealistic steps (likely data errors)
+ step_distance = step_distance.where(step_distance <= max_step_per_frame, 0)
+
+ # Compute speed per frame in m/s: distance(meters) * fps(frames/second) = meters/second
+ speed_m_s = step_distance * fps
+
+ # Get maximum speed
+ max_speed_value = speed_m_s.max()
+ result = round(max_speed_value, 2)
+ except (ValueError, TypeError, AttributeError, KeyError) as e:
+ st.warning(f"Error calculating max speed: {str(e)}")
+ result = 0.0
+ else:
+ return result
+
+ return result
+
+
+def shots_on_target(player, match_data: TrackingDataset) -> int:
+ """Counts the number of shots on target made by a player.
+
+ Filters shot events by player_id and determines on-target shots based on
+ goal outcomes and game interruption events.
+
+ Args:
+ player: Player object with player_id attribute.
+ match_data (TrackingDataset): SkillCorner TrackingDataset object.
+
+ Returns:
+ int: Number of shots on target.
+ """
+ try:
+ if not hasattr(player, 'player_id'):
+ raise AttributeError("Player object missing 'player_id' attribute")
+
+ if not isinstance(match_data, TrackingDataset):
+ raise TypeError(f"Expected TrackingDataset, got {type(match_data).__name__}")
+
+ event_data = safe_get_event_data()
+
+ required_cols = ['end_type', 'player_id', 'lead_to_goal', 'game_interruption_after']
+ missing_cols = [col for col in required_cols if col not in event_data.columns]
+ if missing_cols:
+ raise KeyError(f"Missing required columns: {', '.join(missing_cols)}")
+
+ shots_df = event_data[
+ (event_data["end_type"].str.lower() == "shot")
+ & (event_data["player_id"] == int(player.player_id))
+ ].copy()
+
+ if shots_df.empty:
+ result = 0
+ else:
+ shots_df["is_on_target"] = (shots_df["lead_to_goal"] == 1) & (
+ shots_df["game_interruption_after"].isin(["goal_for", "corner_for"])
+ )
+ on_target = shots_df["is_on_target"].sum()
+ result = int(on_target)
+ except (ValueError, TypeError, AttributeError, KeyError) as e:
+ st.warning(f"Error calculating shots on target: {str(e)}")
+ result = 0
+ else:
+ return result
+
+ return result
+
+
+def expected_goals(player, match_data: TrackingDataset) -> float:
+ """Calculates expected goals (xG) for a player.
+
+ Counts the number of shots by the player. If xG values are available in the
+ event data, they will be summed; otherwise defaults to 0.15 per shot as an estimate.
+
+ Args:
+ player: Player object with player_id attribute.
+ match_data (TrackingDataset): SkillCorner TrackingDataset object.
+
+ Returns:
+ float: Expected goals value.
+ """
+ try:
+ if not hasattr(player, 'player_id'):
+ raise AttributeError("Player object missing 'player_id' attribute")
+
+ if not isinstance(match_data, TrackingDataset):
+ raise TypeError(f"Expected TrackingDataset, got {type(match_data).__name__}")
+
+ event_data = safe_get_event_data()
+
+ required_cols = ['end_type', 'player_id']
+ missing_cols = [col for col in required_cols if col not in event_data.columns]
+ if missing_cols:
+ raise KeyError(f"Missing required columns: {', '.join(missing_cols)}")
+
+ shots_df = event_data[
+ (event_data["end_type"].str.lower() == "shot")
+ & (event_data["player_id"] == int(player.player_id))
+ ]
+
+ if shots_df.empty:
+ result = 0.0
+ else:
+ # If xG column exists, sum it; otherwise estimate 0.15 per shot
+ if "xG" in shots_df.columns:
+ result = round(shots_df["xG"].sum(), 2)
+ else:
+ result = round(len(shots_df) * 0.15, 2)
+ except (ValueError, TypeError, AttributeError, KeyError) as e:
+ st.warning(f"Error calculating expected goals: {str(e)}")
+ result = 0.0
+ else:
+ return result
+
+ return result
+
+
+def expected_threat(player, match_data: TrackingDataset) -> float:
+ """Calculates expected threat (xT) generated by a player.
+
+ Counts successful passes and estimates xT. If xT values are available in the
+ event data, they will be summed; otherwise defaults to 0.02 per successful pass.
+
+ Args:
+ player: Player object with player_id attribute.
+ match_data (TrackingDataset): SkillCorner TrackingDataset object.
+
+ Returns:
+ float: Expected threat value.
+ """
+ try:
+ if not hasattr(player, 'player_id'):
+ raise AttributeError("Player object missing 'player_id' attribute")
+
+ if not isinstance(match_data, TrackingDataset):
+ raise TypeError(f"Expected TrackingDataset, got {type(match_data).__name__}")
+
+ event_data = safe_get_event_data()
+
+ required_cols = ['end_type', 'player_id', 'pass_outcome']
+ missing_cols = [col for col in required_cols if col not in event_data.columns]
+ if missing_cols:
+ raise KeyError(f"Missing required columns: {', '.join(missing_cols)}")
+
+ pass_df = event_data[
+ (event_data["end_type"].str.lower() == "pass")
+ & (event_data["player_id"] == int(player.player_id))
+ & (event_data["pass_outcome"] == "successful")
+ ]
+
+ if pass_df.empty:
+ result = 0.0
+ else:
+ # If xT column exists, sum it; otherwise estimate 0.02 per successful pass
+ if "xT" in pass_df.columns:
+ result = round(pass_df["xT"].sum(), 2)
+ else:
+ result = round(len(pass_df) * 0.02, 2)
+ except (ValueError, TypeError, AttributeError, KeyError) as e:
+ st.warning(f"Error calculating expected threat: {str(e)}")
+ result = 0.0
+ else:
+ return result
+
+ return result
diff --git a/src/utils/preset.py b/src/utils/preset.py
new file mode 100644
index 0000000..d3e1d2a
--- /dev/null
+++ b/src/utils/preset.py
@@ -0,0 +1,402 @@
+import os
+import base64
+import numpy as np
+import pandas as pd
+import streamlit as st
+from mplsoccer import Pitch
+from kloppy import skillcorner
+from typing import List, Tuple
+import matplotlib.pyplot as plt
+from mplsoccer import Radar, FontManager, grid
+
+from kloppy.domain.models.common import Team
+from kloppy.domain.models.tracking import TrackingDataset
+
+from .logo_loader import get_team_logo_url, FALLBACK_LOGO
+
+
+# Error Handling Helper Functions
+def safe_get_event_data() -> pd.DataFrame:
+ """Safely retrieves event data from session state with validation.
+
+ Validates that event_data exists, is a DataFrame, and is not empty.
+
+ Returns:
+ pd.DataFrame: Event data if valid, empty DataFrame otherwise.
+
+ Raises:
+ ValueError: If event data is not available or invalid.
+ """
+ if "event_data" not in st.session_state:
+ raise ValueError("Event data has not been loaded. Please ensure event data is loaded before proceeding.")
+
+ event_data = st.session_state.event_data
+
+ if event_data is None:
+ raise ValueError("Event data is None. Failed to load event data from source.")
+
+ if not isinstance(event_data, pd.DataFrame):
+ raise TypeError(f"Event data must be a DataFrame, got {type(event_data).__name__}")
+
+ if event_data.empty:
+ raise ValueError("Event data is empty. No events available for analysis.")
+
+ return event_data
+
+
+def safe_get_match_data() -> TrackingDataset:
+ """Safely retrieves match data from session state with validation.
+
+ Validates that match_data exists and is a TrackingDataset.
+
+ Returns:
+ TrackingDataset: Match data if valid.
+
+ Raises:
+ ValueError: If match data is not available or invalid.
+ """
+ if "match_data" not in st.session_state:
+ raise ValueError("Match data has not been loaded. Please ensure match data is loaded before proceeding.")
+
+ match_data = st.session_state.match_data
+
+ if match_data is None:
+ raise ValueError("Match data is None. Failed to load match data from SkillCorner API.")
+
+ if not isinstance(match_data, TrackingDataset):
+ raise TypeError(f"Match data must be a TrackingDataset, got {type(match_data).__name__}")
+
+ return match_data
+
+
+def safe_call(func, *args, default_value=None, error_context="", **kwargs):
+ """Wrapper function to safely call functions with try-except-else logic.
+
+ Handles data loading errors, missing attributes, type errors, and provides
+ detailed error messages while returning default values on failure.
+
+ Args:
+ func: The function to call.
+ *args: Positional arguments for the function.
+ default_value: Value to return if function fails.
+ error_context: Additional context for error messages.
+ **kwargs: Keyword arguments for the function.
+
+ Returns:
+ The function result on success, default_value on failure.
+ """
+ try:
+ result = func(*args, **kwargs)
+ except ValueError as e:
+ st.warning(f"Data Error: {str(e)}")
+ return default_value
+ except TypeError as e:
+ st.warning(f"Type Error: {str(e)}")
+ return default_value
+ except AttributeError as e:
+ st.warning(f"Missing Attribute: The required field '{str(e)}' is not defined in the data. {error_context}")
+ return default_value
+ except KeyError as e:
+ st.warning(f"Missing Column: The required column {str(e)} is not found in the data. {error_context}")
+ return default_value
+ except Exception as e:
+ st.warning(f"Unexpected Error: {str(e)} {error_context}")
+ return default_value
+ else:
+ # Function executed successfully
+ return result
+
+
+# Function
+def render_team_logo(team_name: str, align: str = "left", width: int = 100) -> None:
+ """Renders the team logo with the team name below it using HTML.
+
+ Fetches the logo from Wikipedia API or uses a fallback image if not found.
+
+ Args:
+ team_name (str): The name of the team to display.
+ align (str): Text alignment for the logo and name ('left' or 'right').
+ width (int): The width of the logo image in pixels.
+ """
+ logo_url = get_team_logo_url(team_name)
+
+ if logo_url:
+ img_html = f''
+ elif os.path.exists(FALLBACK_LOGO):
+ encoded = base64.b64encode(open(FALLBACK_LOGO, "rb").read()).decode()
+ img_html = f'
'
+ else:
+ st.error("No logo found")
+ return
+
+ st.markdown(
+ f"""
+
+ {team_name.title()} +
+