mmcux commited on
Commit
d4b0d54
·
0 Parent(s):

initial commit

Browse files
.dockerignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ venv/
9
+ ENV/
10
+ env.bak/
11
+ venv.bak/
12
+
13
+ # IDE
14
+ .vscode/
15
+ .idea/
16
+ *.swp
17
+ *.swo
18
+ *~
19
+
20
+ # OS
21
+ .DS_Store
22
+ Thumbs.db
23
+
24
+ # Git
25
+ .git/
26
+ .gitignore
27
+
28
+ # Documentation
29
+ *.md
30
+ !README.md
31
+
32
+ # Tests
33
+ tests/
34
+ test_*.py
35
+ *_test.py
36
+
37
+ # Logs
38
+ *.log
39
+ logs/
40
+
41
+ # Temporary files
42
+ *.tmp
43
+ *.temp
44
+ .cache/
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.github/workflows/main.yml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face hub
2
+ on:
3
+ push:
4
+ branches: [main]
5
+
6
+ # to run this workflow manually from the Actions tab
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ sync-to-hub:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v3
14
+ with:
15
+ fetch-depth: 0
16
+ lfs: true
17
+ - name: Push to hub
18
+ env:
19
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
20
+ run: git push --force https://halbers:$HF_TOKEN@huggingface.co/spaces/DAG-UPB/TS-Arena main
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build local
2
+ docker-compose.yml
3
+ venv-ts-frontend/
4
+ .streamlit/
5
+ # Secrets
6
+ .env
7
+
8
+ # Other
9
+ .DS_Store
10
+ */__pycache__/
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y \
6
+ gcc \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY . .
13
+
14
+ EXPOSE 8501
15
+
16
+ HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
17
+
18
+ ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
README.md ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: TS Arena
3
+ emoji: 🚀
4
+ colorFrom: red
5
+ colorTo: red
6
+ sdk: docker
7
+ app_port: 8501
8
+ tags:
9
+ - streamlit
10
+ models:
11
+ - google/timesfm-2.0-500m-pytorch
12
+ - Maple728/TimeMoE-50M
13
+ - Salesforce/moirai-1.1-R-large
14
+ - thuml/sundial-base-128m
15
+
16
+ pinned: false
17
+ short_description: Time Series Forecasting Arena
18
+ ---
19
+
20
+ # TS-Arena Streamlit Dashboard
21
+
22
+ This is a Streamlit version of the TS-Arena Challenge Dashboard.
23
+
24
+ ## Installation
25
+
26
+ 1. Install dependencies:
27
+ ```bash
28
+ pip install -r requirements.txt
29
+ ```
30
+
31
+ 2. Set up environment variables:
32
+ ```bash
33
+ export X_API_URL="your_api_url_here"
34
+ export X_API_KEY="your_api_key_here"
35
+ ```
36
+
37
+ Or create a `.env` file with:
38
+ ```
39
+ X_API_URL=your_api_url_here
40
+ X_API_KEY=your_api_key_here
41
+ ```
42
+
43
+ ## Running the App
44
+
45
+ ```bash
46
+ streamlit run app.py
47
+ ```
48
+
49
+ The app will be available at `http://localhost:8501`
50
+
51
+ ## Features
52
+
53
+ - **Model Ranking**: View and filter model rankings based on performance metrics (Tab 1)
54
+ - **Challenge Visualization**: Select and visualize active and completed challenges (Tab 2)
55
+ - **Time Series Selection**: Choose specific time series to analyze within a challenge
56
+ - **Interactive Plots**: Plotly charts with zoom, pan, and hover features
57
+ - **Upcoming Challenges**: View upcoming challenges in the sidebar
58
+ - **Add Model**: Information on how to participate and register your models (Tab 3)
59
+
60
+ ## Differences from Gradio Version
61
+
62
+ Streamlit has some differences in behavior compared to Gradio:
63
+
64
+ 1. **State Management**: Streamlit uses session state and reruns the entire script on interaction
65
+ 2. **Layout**: Streamlit uses `st.sidebar` for the sidebar and columns for layout
66
+ 3. **Interactivity**: Dropdowns and multiselect widgets automatically trigger reruns
67
+ 4. **Tabs**: Uses `st.tabs()` instead of Gradio's `gr.Tab()`
68
+
69
+ ## File Structure
70
+
71
+ - `app.py` - Main Streamlit application
72
+ - `utils/api_client.py` - API client for fetching challenge data
73
+ - `utils/utils.py` - Utility functions for parsing durations
74
+ - `requirements.txt` - Python dependencies
app.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from components.challenges_tab_component import render_challenges_tab_component
2
+ from components.filter_component import render_filter_component, set_rankings_session_state
3
+ import streamlit as st
4
+ import pandas as pd
5
+ import sys
6
+ import os
7
+
8
+ from components.challenge_list_component import render_challenge_list_component
9
+
10
+ # Add src to path for imports
11
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
12
+ from utils.api_client import ChallengeStatus, DashboardAPIClient
13
+
14
+ # Get API URL from environment variable
15
+ api_url = os.getenv('X_API_URL', '')
16
+ if api_url:
17
+ os.environ['DASHBOARD_API_URL'] = api_url
18
+ print(f"Loaded API URL from X_API_URL: {api_url}", file=sys.stderr)
19
+ else:
20
+ print("Warning: X_API_URL environment variable not set, using default", file=sys.stderr)
21
+
22
+ # Get API key from environment variable
23
+ api_key = os.getenv('X_API_KEY', '')
24
+ if api_key:
25
+ os.environ['DASHBOARD_API_KEY'] = api_key
26
+ print(f"Loaded API key from X_API_KEY environment variable", file=sys.stderr)
27
+ else:
28
+ print("Warning: X_API_KEY environment variable not set", file=sys.stderr)
29
+
30
+ # Initialize API client
31
+ api_client = DashboardAPIClient()
32
+
33
+ # Page config
34
+ st.set_page_config(
35
+ page_title="TS-Arena Challenge Dashboard",
36
+ page_icon="🏟️",
37
+ layout="wide",
38
+ initial_sidebar_state="expanded"
39
+ )
40
+
41
+ # Custom CSS to make sidebar wider
42
+ st.markdown("""
43
+ <style>
44
+ [data-testid="stSidebar"] {
45
+ min-width: 400px;
46
+ max-width: 400px;
47
+ }
48
+ .challenge-list-container {
49
+ max-height: 45vh;
50
+ overflow-y: auto;
51
+ overflow-x: hidden;
52
+ padding-right: 5px;
53
+ }
54
+ .upcoming-list-container {
55
+ max-height: 45vh;
56
+ overflow-y: auto;
57
+ overflow-x: hidden;
58
+ padding-right: 5px;
59
+ }
60
+ /* Custom scrollbar styling */
61
+ .challenge-list-container::-webkit-scrollbar,
62
+ .upcoming-list-container::-webkit-scrollbar {
63
+ width: 8px;
64
+ }
65
+ .challenge-list-container::-webkit-scrollbar-track,
66
+ .upcoming-list-container::-webkit-scrollbar-track {
67
+ background: #f1f1f1;
68
+ border-radius: 4px;
69
+ }
70
+ .challenge-list-container::-webkit-scrollbar-thumb,
71
+ .upcoming-list-container::-webkit-scrollbar-thumb {
72
+ background: #888;
73
+ border-radius: 4px;
74
+ }
75
+ .challenge-list-container::-webkit-scrollbar-thumb:hover,
76
+ .upcoming-list-container::-webkit-scrollbar-thumb:hover {
77
+ background: #555;
78
+ }
79
+ </style>
80
+ """, unsafe_allow_html=True)
81
+
82
+
83
+ # Main app
84
+ st.title("🏟️ TS-Arena – The Live-Forecasting Benchmark (Prototype)")
85
+
86
+ # Sidebar for challenge list
87
+ with st.sidebar:
88
+ st.header("🗓️ Upcoming")
89
+ upcoming_challenges = api_client.list_upcoming_challenges()
90
+ # Show only newest 5 challenges to reduce loading time
91
+ show_first = 5
92
+ challenges_display = upcoming_challenges[:show_first]
93
+ render_challenge_list_component(challenges=challenges_display, api_client=api_client, challange_list_type="upcoming", show_first=5)
94
+
95
+ # Create tabs
96
+ tab1, tab2, tab3 = st.tabs(["Model Ranking", "Challenges", "Add Model"])
97
+
98
+ with tab1:
99
+ with st.spinner("Loading model ranking..."):
100
+ render_filter_component(api_client=api_client, filter_type="model_ranking")
101
+ st.markdown("---")
102
+ if st.session_state.get('filtered_rankings') is None:
103
+ set_rankings_session_state(api_client=api_client)
104
+ st.rerun()
105
+ # Main content area
106
+ st.header("🏆 Model Ranking")
107
+
108
+ # Display rankings table from session state if available
109
+ filtered_rankings = st.session_state.get('filtered_rankings', None)
110
+ if filtered_rankings:
111
+ df_rankings = pd.concat([pd.DataFrame(df) for df in filtered_rankings], ignore_index=True)
112
+ tab_headers = list(df_rankings['time_ranges'].unique())
113
+ tabs = st.tabs(tab_headers)
114
+ for unique_timerange, tab in zip(tab_headers, tabs):
115
+ filtered_by_time = df_rankings[df_rankings['time_ranges'] == unique_timerange].reset_index(drop=True)
116
+ with tab:
117
+ st.dataframe(filtered_by_time, use_container_width=True)
118
+ else:
119
+ st.info("No models match the selected filters")
120
+
121
+ with tab2:
122
+ render_challenges_tab_component(api_client=api_client)
123
+
124
+ with tab3:
125
+ st.header("🚀 Join the TS-Arena Benchmark!")
126
+
127
+ st.markdown("""
128
+ Ready to test your forecasting models against the best? Participate actively in our benchmark challenges!
129
+ Simply send us an email and we'll provide you with an API key to get started.
130
+
131
+ ### How it works:
132
+
133
+ Email us with your organization name and we'll send you an API key.
134
+ With this key, you can register your own models and officially participate in active competitions
135
+ once your model is registered.
136
+ """)
137
+
138
+ col1, col2 = st.columns(2)
139
+
140
+ with col1:
141
+ st.markdown("""
142
+ <a href="mailto:DataAnalytics@wiwi.uni-paderborn.de?subject=TS-Arena API Key Request&body=Hello,%0D%0A%0D%0AI would like to participate in the TS-Arena benchmark.%0D%0A%0D%0AOrganization: [Please specify your organization]%0D%0A%0D%0ABest regards"
143
+ style="display: inline-block; padding: 10px 20px; background-color: #667eea; color: white; text-decoration: none; border-radius: 5px; font-weight: bold;">
144
+ 📧 Request API Key
145
+ </a>
146
+ """, unsafe_allow_html=True)
147
+
148
+ with col2:
149
+ st.markdown("""
150
+ <a href="mailto:DataAnalytics@wiwi.uni-paderborn.de?subject=TS-Arena Questions&body=Hello,%0D%0A%0D%0AI have questions about participating in the TS-Arena benchmark.%0D%0A%0D%0ABest regards"
151
+ style="display: inline-block; padding: 10px 20px; background-color: #764ba2; color: white; text-decoration: none; border-radius: 5px; font-weight: bold;">
152
+ ❓ Ask Questions
153
+ </a>
154
+ """, unsafe_allow_html=True)
155
+
156
+ st.markdown("---")
157
+
158
+ st.markdown("""
159
+ ### What you get:
160
+
161
+ - 🔑 **Personal API Key** for model registration and submission
162
+ - 📊 **Access to benchmark datasets** and challenge specifications
163
+ - 🏆 **Rankings and leaderboards** showing your model's performance
164
+ - 📈 **Detailed evaluation metrics** across multiple time series
165
+ - 🤝 **Community support** from fellow forecasting researchers
166
+
167
+ ### Requirements:
168
+
169
+ - Valid organization or affiliation
170
+ - Commitment to fair participation
171
+ - Adherence to benchmark guidelines
172
+
173
+ For more information, please contact us via email!
174
+
175
+ ### Help Us Validate Your Model Implementation:
176
+ Transparency is at the heart of our live benchmarking project. We have integrated a wide range of state-of-the-art time series forecasting models to provide the community with real-time performance insights.
177
+ To ensure that every model is evaluated under optimal conditions, **we invite the original authors and maintainers to review our implementations.** If you are a developer of one of the featured models, we would value your feedback on:
178
+
179
+ - Model configuration and hyperparameters.
180
+ - Data preprocessing steps.
181
+ - Implementation-specific nuances.
182
+
183
+ Our goal is to represent your work as accurately as possible. Please visit our [GitHub Repository](https://github.com/DAG-UPB) to review the code or open an issue for any suggested improvements.
184
+ """)
185
+ # TODO: Change link to GitHub Repository
components/challange_card_component.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import streamlit as st
3
+ import time
4
+ from datetime import datetime, timezone
5
+
6
+ from utils.api_client import ChallengeStatus
7
+ from utils.utils import duration_to_max_unit, parse_iso8601_duration, to_local
8
+
9
+ @st.fragment(run_every="1s")
10
+ def timer_fragment():
11
+ now = datetime.now(timezone.utc)
12
+
13
+ st.markdown(
14
+ f"""
15
+ <div style='font-size:1.1em; font-weight:bold; color:#1f2937;'>
16
+ ⏱️ {now.strftime("%Y-%m-%d %H:%M:%S %Z")}
17
+ </div>
18
+ """,
19
+ unsafe_allow_html=True,
20
+ )
21
+
22
+ def render_challenge_card(challenge, api_client):
23
+ """Render a challenge card in the sidebar."""
24
+ status = challenge.get('status', 'unknown')
25
+ desc = challenge.get('description', 'TBA')
26
+ challenge_id = str(challenge.get('challenge_id', ''))[:8]
27
+ n_series = challenge.get('n_time_series', 0)
28
+ model_count = challenge.get('model_count', 0)
29
+ forecast_horizon_interval = challenge.get('horizon', 'TBA')
30
+ context_length_interval = challenge.get('context_length', 'TBA')
31
+
32
+ # Try to get frequency from series data for accurate parsing
33
+ frequency_iso = 'PT1H' # Default to 1 hour
34
+ forecast_horizon_from_series = None
35
+ context_length_from_series = None
36
+
37
+ try:
38
+ challenge_id_full = challenge.get('challenge_id')
39
+ series_list = api_client.get_challenge_series(challenge_id_full)
40
+ if series_list and len(series_list) > 0:
41
+ frequency_iso = series_list[0].get('frequency', 'PT1H')
42
+ # Get forecast_horizon and context from series level (more accurate)
43
+ forecast_horizon_from_series = series_list[0].get('horizon')
44
+ except Exception as e:
45
+ print(f"DEBUG: Could not get series data for {challenge_id}: {e}", file=sys.stderr)
46
+
47
+ # Use series-level horizon if available, otherwise fall back to challenge-level
48
+ horizon_to_parse = forecast_horizon_from_series or forecast_horizon_interval
49
+ context_to_parse = context_length_from_series or context_length_interval
50
+
51
+ # Parse intervals with frequency - handle both string intervals and numeric values
52
+ if horizon_to_parse != 'TBA' and horizon_to_parse is not None:
53
+ if isinstance(horizon_to_parse, (int, float)):
54
+ forecast_horizon_num = int(horizon_to_parse)
55
+ elif isinstance(horizon_to_parse, str):
56
+ try:
57
+ _, horizon_seconds = parse_iso8601_duration(horizon_to_parse)
58
+ _, frequency_seconds = parse_iso8601_duration(frequency_iso)
59
+ if frequency_seconds > 0:
60
+ forecast_horizon_num = int(horizon_seconds / frequency_seconds)
61
+ else:
62
+ forecast_horizon_num = 'TBA'
63
+ except Exception as parse_e:
64
+ forecast_horizon_num = 'TBA'
65
+ else:
66
+ forecast_horizon_num = 'TBA'
67
+ else:
68
+ forecast_horizon_num = 'TBA'
69
+
70
+ if context_to_parse != 'TBA' and context_to_parse is not None:
71
+ if isinstance(context_to_parse, (int, float)):
72
+ context_length_num = int(context_to_parse)
73
+ elif isinstance(context_to_parse, str):
74
+ try:
75
+ _, context_seconds = parse_iso8601_duration(context_to_parse)
76
+ _, frequency_seconds = parse_iso8601_duration(frequency_iso)
77
+ if frequency_seconds > 0:
78
+ context_length_num = int(context_seconds / frequency_seconds)
79
+ else:
80
+ context_length_num = 'TBA'
81
+ except Exception as parse_e:
82
+ context_length_num = 'TBA'
83
+ else:
84
+ context_length_num = 'TBA'
85
+ else:
86
+ context_length_num = 'TBA'
87
+
88
+ # Parse frequency for display using duration_to_max_unit
89
+ frequency_display = frequency_iso
90
+ try:
91
+ freq_parts, _ = parse_iso8601_duration(frequency_iso)
92
+ frequency_display = duration_to_max_unit(freq_parts)
93
+ except Exception as e:
94
+ print(f"DEBUG: Could not parse frequency for display: {e}", file=sys.stderr)
95
+
96
+ status_color = '#16a34a' if status == ChallengeStatus.ACTIVE.value else '#2563eb'
97
+
98
+ # Get dates
99
+ reg_start = challenge.get('registration_start', '')
100
+ final_eval = challenge.get('end_time', '')
101
+
102
+ dates_html = ""
103
+ if reg_start:
104
+ reg_start_iso = to_local(reg_start, "UTC").isoformat()
105
+ dates_html += f"""<div><div id="reg-time-{challenge_id_full}" style="font-size: 0.85em; color: #4b5563;">
106
+ 📅 Registration opens: <span></span>
107
+ </div>
108
+
109
+ <script>
110
+ if (!document.querySelector("#reg-time-{challenge_id_full} span").textContent) {{
111
+ const iso{challenge_id_full} = "{reg_start_iso}";
112
+ const date{challenge_id_full} = new Date(iso{challenge_id_full});
113
+
114
+ const formatted{challenge_id_full} = date{challenge_id_full}.toLocaleString(undefined, {{
115
+ year: "numeric",
116
+ month: "2-digit",
117
+ day: "2-digit",
118
+ hour: "2-digit",
119
+ minute: "2-digit",
120
+ second: "2-digit"
121
+ }});
122
+
123
+ document.querySelector("#reg-time-{challenge_id_full} span").textContent = formatted{challenge_id_full};
124
+ }}
125
+ </script>"""
126
+ if final_eval:
127
+ final_iso = str(to_local(final_eval, "UTC").isoformat())
128
+ dates_html += f"""<div><div id="final-time-{challenge_id_full}" style="font-size: 0.85em; color: #4b5563;">
129
+ ⏰ Final Evaluation: <span></span>
130
+ </div>
131
+
132
+ <script>
133
+ if (!document.querySelector("#final-time-{challenge_id_full} span").textContent) {{
134
+ const iso{challenge_id_full} = "{final_iso}";
135
+ const date{challenge_id_full} = new Date(iso{challenge_id_full});
136
+
137
+ const formatted{challenge_id_full} = date{challenge_id_full}.toLocaleString(undefined, {{
138
+ year: "numeric",
139
+ month: "2-digit",
140
+ day: "2-digit",
141
+ hour: "2-digit",
142
+ minute: "2-digit",
143
+ second: "2-digit"
144
+ }});
145
+
146
+ document.querySelector("#final-time-{challenge_id_full} span").textContent = formatted{challenge_id_full};
147
+ }}
148
+ </script></div></div>"""
149
+
150
+ # Countdown Closes in & Starts in
151
+ time = None
152
+ match status:
153
+ # Time has to be in CET for JS countdown.
154
+ case ChallengeStatus.REGISTRATION.value:
155
+ time = to_local(challenge.get('registration_end', ''), "CET")
156
+ case ChallengeStatus.ACTIVE.value:
157
+ time = to_local(challenge.get('end_time', ''), "CET")
158
+ case ChallengeStatus.ANNOUNCED.value:
159
+ time = to_local(challenge.get('registration_start', ''), "CET")
160
+ case _:
161
+ pass
162
+
163
+ markdown = f"""
164
+ <div style='border: 2px solid {status_color}; padding: 15px; margin-bottom: 15px; border-radius: 8px;
165
+ background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);'>
166
+ <div style='display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;'>
167
+ <div style='font-size: 1.2em; font-weight: bold; color: {status_color};'>{desc}</div>
168
+ <div style='background: {status_color}; color: white; padding: 4px 12px; border-radius: 12px;
169
+ font-size: 0.85em; font-weight: bold;'>{status.upper()}</div>
170
+ </div>
171
+ <div style='color: #6b7280; font-size: 0.9em; margin-bottom: 8px;'>ID: {challenge_id}</div>
172
+ <div style='display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px;'>
173
+ <div style='background: white; padding: 8px; border-radius: 5px; border: 1px solid #e5e7eb;'>
174
+ <div style='font-size: 0.75em; color: #9ca3af;'>TIME SERIES</div>
175
+ <div style='font-size: 1.1em; font-weight: bold; color: #1f2937;'>{n_series}</div>
176
+ </div>
177
+ <div style='background: white; padding: 8px; border-radius: 5px; border: 1px solid #e5e7eb;'>
178
+ <div style='font-size: 0.75em; color: #9ca3af;'>MODELS</div>
179
+ <div style='font-size: 1.1em; font-weight: bold; color: #1f2937;'>{model_count}</div>
180
+ </div>
181
+ <div style='background: white; padding: 8px; border-radius: 5px; border: 1px solid #e5e7eb;'>
182
+ <div style='font-size: 0.75em; color: #9ca3af;'>HORIZON (STEPS x FREQ)</div>
183
+ <div style='font-size: 1.1em; font-weight: bold; color: #1f2937;'>{forecast_horizon_num} x {frequency_display}</div>
184
+ </div>
185
+ <div style='background: white; padding: 8px; border-radius: 5px; border: 1px solid #e5e7eb;'>
186
+ <div style='font-size: 0.75em; color: #9ca3af;'>CONTEXT LENGTH</div>
187
+ <div style='font-size: 1.1em; font-weight: bold; color: #1f2937;'>{context_length_num}</div>
188
+ </div>
189
+ </div>
190
+ <div style='margin-top: 10px; padding: 8px; background: rgba(255,255,255,0.7); border-radius: 5px;'>
191
+ {dates_html}
192
+ </div>
193
+ <div class='ts-arena-countdown' data-challenge-time="{time}" data-reg-status="{status}" style='font-size: 0.9em; margin-top: 5px; padding: 5px; background: #f0f4ff; border-radius: 3px;'>{'Loading...'}</div>
194
+ </div>"""
195
+ return markdown
196
+
components/challenge_list_component.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import streamlit.components.v1 as components
3
+ import sys
4
+
5
+ from components.challange_card_component import render_challenge_card
6
+
7
+
8
+ def get_time_js_script()->str:
9
+ return """<script>
10
+ (function(){
11
+ function updateCountdowns(){
12
+ var els = document.querySelectorAll('.ts-arena-countdown');
13
+ if(!els) return "Problem";
14
+ els.forEach(function(el){
15
+ var status = el.getAttribute('data-reg-status');
16
+ if (!status) return;
17
+ var ds = el.getAttribute('data-challenge-time');
18
+ if (!ds) return;
19
+ var targetDate = new Date(ds);
20
+ if(isNaN(targetDate.getTime())) return;
21
+ var now = new Date();
22
+ const cetString = now.toLocaleString('en-US', { timeZone: 'Europe/Paris' });
23
+ now = new Date(cetString);
24
+ var diff = Math.floor((targetDate - now) / 1000);
25
+ var display_time = '';
26
+ console.log('MADE IT TO DIFF')
27
+ if(diff > 0){
28
+ var days = Math.floor(diff / (24*3600));
29
+ diff = diff % (24*3600);
30
+ var hours = Math.floor(diff / 3600);
31
+ diff = diff % 3600;
32
+ var minutes = Math.floor(diff / 60);
33
+ if(days > 0) display_time = days + 'd ' + hours + 'h ' + minutes + 'm';
34
+ else if(hours > 0) display_time = hours + 'h ' + minutes + 'm';
35
+ else display_time = minutes + 'm';
36
+ }
37
+ var diplay_text = 'TBD';
38
+ if (status == 'registration') {
39
+ if (diff <= 0) {
40
+ diplay_text = 'Registration closed';
41
+ } else {
42
+ diplay_text = 'Registration closes in: ' + display_time;
43
+ }
44
+ }
45
+ if (status == 'announced') {
46
+ if (diff <= 0) {
47
+ diplay_text = 'Registration open now!';
48
+ } else {
49
+ diplay_text = 'Registration opens in: ' + display_time;
50
+ }
51
+ }
52
+ if (status == 'active') {
53
+ if (diff <= 0) {
54
+ diplay_text = 'Challenge ended';
55
+ } else {
56
+ diplay_text = 'Results in: ' + display_time;
57
+ }
58
+ }
59
+ el.innerHTML = '\u23F1 <strong style="color: #667eea;">' + diplay_text + '</strong>';
60
+
61
+ });
62
+ }
63
+ updateCountdowns();
64
+ setInterval(updateCountdowns, 3000);
65
+ })();
66
+ </script>
67
+ """
68
+
69
+ def render_challenge_list_component(challenges, api_client, challange_list_type: str = "ongoing", show_first: int = 5):
70
+ # Create scrollable container for active challenges
71
+ challenge_container = st.container(height="stretch")
72
+ with challenge_container:
73
+ try:
74
+ if challenges:
75
+ cards = []
76
+ for challenge in challenges:
77
+ cards.append(render_challenge_card(challenge, api_client=api_client))
78
+ components.html("<div class='ts-arena-upcoming-container' style='font-family: Arial, sans-serif;'>"+"".join(cards)+get_time_js_script()+"</div>", height=700, scrolling=True)
79
+ return
80
+ except Exception as e:
81
+ st.write(e)
82
+ print(f"Error loading {challange_list_type} challenges: {e}", file=sys.stderr)
83
+ if challange_list_type == "ongoing":
84
+ st.write("No active challenges")
85
+ else:
86
+ st.write("No upcoming challenges")
components/challenges_tab_component.py ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ import sys
3
+ import traceback
4
+ from typing import Any, Dict, List, Optional
5
+ import pandas as pd
6
+ from components.filter_models_component import render_filter_models_component
7
+ import plotly.graph_objects as go
8
+ import plotly.express as px
9
+ import numpy as np
10
+ import streamlit as st
11
+
12
+ from components.filter_component import render_filter_component, set_challenges_session_state
13
+ from utils.api_client import ChallengeStatus, DashboardAPIClient, hash_func_dashboard_api_client
14
+ from utils.utils import duration_to_max_unit, parse_iso8601_duration
15
+
16
+
17
+ def get_challenge_horizon_steps_from_series(series_list):
18
+ """
19
+ Uses the series list to determine the forecast horizon in steps.
20
+ """
21
+ frequency_iso = series_list[0].get('frequency', 'PT1H')
22
+ horizon_iso = series_list[0].get('horizon')
23
+ return get_challenge_horizon(frequency_iso, horizon_iso)
24
+
25
+
26
+ def get_challenge_horizon_steps_from_challenge(challenge):
27
+ frequency_iso = challenge.get('frequency', 'PT1H')
28
+ horizon_iso = challenge.get('horizon')
29
+ return get_challenge_horizon(frequency_iso, horizon_iso)
30
+
31
+
32
+ def get_challenge_horizon(frequency_iso: str, horizon_iso: str) -> int:
33
+ if horizon_iso and frequency_iso:
34
+ try:
35
+ _, horizon_seconds = parse_iso8601_duration(horizon_iso)
36
+ _, frequency_seconds = parse_iso8601_duration(frequency_iso)
37
+ if frequency_seconds > 0:
38
+ steps = int(horizon_seconds / frequency_seconds)
39
+ return steps
40
+ except Exception as parse_e:
41
+ print(f"Error parsing horizon: {parse_e}", file=sys.stderr)
42
+ return -1
43
+
44
+
45
+ @st.cache_data(hash_funcs={DashboardAPIClient: hash_func_dashboard_api_client}, show_spinner="Loading forecasts...")
46
+ def get_forecasts(api_client: DashboardAPIClient, challenge_id: str, series_id: int) -> Dict[str, Any]:
47
+ forecasts = api_client.get_series_forecasts(challenge_id, series_id)
48
+ forecasts = dict(
49
+ sorted(forecasts.items(), key=lambda item: item[1]["current_mase"])
50
+ )
51
+ return forecasts
52
+
53
+
54
+ def get_series_choices_for_challenge(challenge_id, api_client: DashboardAPIClient) -> List[Dict[str, Any]]:
55
+ """Get list of time series for a given challenge."""
56
+ if not challenge_id:
57
+ return []
58
+
59
+ try:
60
+ # Don't try to get series for mock challenges
61
+ if str(challenge_id).startswith('mock'):
62
+ return []
63
+
64
+ # Get series list from API
65
+ series_list = api_client.get_challenge_series(challenge_id)
66
+ if not series_list:
67
+ return []
68
+
69
+ # Format series choices with ID and name
70
+ choices = []
71
+ for series_info in series_list:
72
+ series_id = series_info.get('series_id')
73
+ series_name = series_info.get('name', f'Series {series_id}')
74
+ series_description = series_info.get('description', series_id)
75
+ choices.append({
76
+ 'id': series_id,
77
+ 'name': series_name,
78
+ 'description': series_description,
79
+ 'display': f"{series_description} (ID: {series_id})"
80
+ })
81
+
82
+ return choices
83
+ except Exception as e:
84
+ print(f"Error getting series choices: {e}", file=sys.stderr)
85
+ return []
86
+
87
+
88
+ @st.cache_data(show_spinner="Drawing plots...")
89
+ def make_demo_forecast_plot(forecast_horizon, challenge_desc="Demo Challenge"):
90
+ """Generate demo forecast plot with synthetic data."""
91
+ np.random.seed(42)
92
+
93
+ historical_len = 50
94
+ forecast_len = int(forecast_horizon)
95
+ time = np.arange(historical_len + forecast_len)
96
+
97
+ historical_data = 100 + 10 * np.sin(np.linspace(0, 4 * np.pi, historical_len)) + np.random.normal(0, 2, historical_len)
98
+
99
+ fig = go.Figure()
100
+
101
+ # Add historical data
102
+ fig.add_trace(go.Scatter(
103
+ x=time[:historical_len],
104
+ y=historical_data,
105
+ mode='lines',
106
+ name='Historical Data',
107
+ line=dict(color='black', width=3),
108
+ legendgroup='historical',
109
+ ))
110
+
111
+ model_names = [
112
+ "ARIMA", "Prophet", "LSTM", "XGBoost",
113
+ "Random Forest", "ETS", "Theta", "TBATS",
114
+ "Neural Prophet", "Ensemble"
115
+ ]
116
+
117
+ colors = px.colors.qualitative.Plotly + px.colors.qualitative.Set2
118
+
119
+ for i, model_name in enumerate(model_names):
120
+ base_forecast = 100 + 10 * np.sin(np.linspace(4 * np.pi, 4 * np.pi + 2 * np.pi, forecast_len))
121
+ noise = np.random.normal(0, 1 + i * 0.3, forecast_len)
122
+ trend = np.linspace(0, i * 0.5, forecast_len)
123
+ forecast = base_forecast + noise + trend
124
+
125
+ forecast_x = np.concatenate([[time[historical_len - 1]], time[historical_len:]])
126
+ forecast_y = np.concatenate([[historical_data[-1]], forecast])
127
+
128
+ fig.add_trace(go.Scatter(
129
+ x=forecast_x,
130
+ y=forecast_y,
131
+ mode='lines',
132
+ name=model_name,
133
+ line=dict(color=colors[i % len(colors)], width=2, dash='solid'),
134
+ legendgroup=f'model_{i}',
135
+ hovertemplate=f'<b>{model_name}</b><br>Time: %{{x}}<br>Value: %{{y:.2f}}<extra></extra>'
136
+ ))
137
+
138
+ upper_bound = forecast + (3 + i * 0.5)
139
+ lower_bound = forecast - (3 + i * 0.5)
140
+
141
+ fig.add_trace(go.Scatter(
142
+ x=np.concatenate([time[historical_len:], time[historical_len:][::-1]]),
143
+ y=np.concatenate([upper_bound, lower_bound[::-1]]),
144
+ fill='toself',
145
+ fillcolor=colors[i % len(colors)],
146
+ opacity=0.15,
147
+ line=dict(width=0),
148
+ name=f'{model_name} CI',
149
+ legendgroup=f'model_{i}',
150
+ showlegend=False,
151
+ hoverinfo='skip'
152
+ ))
153
+
154
+ fig.add_vline(
155
+ x=historical_len - 0.5,
156
+ line_dash="dash",
157
+ line_color="gray",
158
+ opacity=0.7,
159
+ annotation_text="Forecast Start",
160
+ annotation_position="top"
161
+ )
162
+
163
+ fig.update_layout(
164
+ title={
165
+ 'text': f'📈 {challenge_desc} - Forecast Comparison ({forecast_len} steps ahead)',
166
+ 'x': 0.5,
167
+ 'xanchor': 'center',
168
+ 'font': {'size': 20, 'color': '#2c3e50'}
169
+ },
170
+ xaxis_title='Time',
171
+ yaxis_title='Value',
172
+ hovermode='x unified',
173
+ template='plotly_white',
174
+ height=600,
175
+ font=dict(family="Arial, sans-serif", size=12),
176
+ plot_bgcolor='rgba(245, 245, 245, 0.5)',
177
+ )
178
+
179
+ fig.update_xaxes(
180
+ showgrid=True,
181
+ gridwidth=1,
182
+ gridcolor='lightgray',
183
+ showline=True,
184
+ linewidth=2,
185
+ linecolor='gray'
186
+ )
187
+
188
+ fig.update_yaxes(
189
+ showgrid=True,
190
+ gridwidth=1,
191
+ gridcolor='lightgray',
192
+ showline=True,
193
+ linewidth=2,
194
+ linecolor='gray'
195
+ )
196
+
197
+ return fig
198
+
199
+
200
+ @st.cache_data(hash_funcs={DashboardAPIClient: hash_func_dashboard_api_client}, show_spinner="Drawing plots...")
201
+ def plot_real_challenge_data(challenge: Dict[str, Any], forecast_horizon: int, api_client: DashboardAPIClient, selected_series_ids: List[int] = None, selected_readable_model_ids: List[str] = None) -> Optional[go.Figure]:
202
+ """Plot real challenge data from API with selected series and forecasts."""
203
+ try:
204
+ challenge_id = challenge.get('challenge_id')
205
+ challenge_desc = challenge.get('description', 'Challenge')
206
+
207
+ # Get all series for this challenge
208
+ series_list = api_client.get_challenge_series(challenge_id)
209
+ if not series_list:
210
+ return None
211
+
212
+ # Filter series based on selection
213
+ if selected_series_ids:
214
+ series_list = [s for s in series_list if s.get('series_id') in selected_series_ids]
215
+
216
+ if not series_list:
217
+ fig = go.Figure()
218
+ fig.add_annotation(
219
+ text="No series selected or found",
220
+ xref="paper", yref="paper",
221
+ x=0.5, y=0.5, showarrow=False,
222
+ font=dict(size=16, color="gray")
223
+ )
224
+ return fig
225
+
226
+ # Create figure
227
+ fig = go.Figure()
228
+
229
+ # Color palette for models
230
+ colors = px.colors.qualitative.Plotly + px.colors.qualitative.Set2 + px.colors.qualitative.Set3
231
+ model_color_map = {}
232
+ color_idx = 0
233
+
234
+ inferred_frequency = None
235
+ steps_to_show = forecast_horizon
236
+
237
+ # Process each series
238
+ for series_idx, series_info in enumerate(series_list):
239
+ series_id = series_info.get('series_id')
240
+ series_name = series_info.get('name', f'Series {series_id}')
241
+
242
+ # Get context data (historical)
243
+ context_df = api_client.get_challenge_data_for_series(
244
+ challenge_id, series_id,
245
+ series_info.get('context_start_time'),
246
+ series_info.get('context_end_time')
247
+ )
248
+
249
+ # Get actual data (test/live data)
250
+ actual_df = api_client.get_challenge_data_for_series(
251
+ challenge_id, series_id,
252
+ series_info.get('context_end_time'),
253
+ series_info.get('end_time')
254
+ )
255
+
256
+ # Infer frequency
257
+ if inferred_frequency is None and not context_df.empty and len(context_df) >= 2:
258
+ try:
259
+ frequency_iso = series_info.get('frequency', 'PT1H')
260
+ horizon_iso = series_info.get('horizon') or challenge.get('horizon')
261
+
262
+ if horizon_iso and frequency_iso:
263
+ _, horizon_seconds = parse_iso8601_duration(horizon_iso)
264
+ _, frequency_seconds = parse_iso8601_duration(frequency_iso)
265
+
266
+ if frequency_seconds > 0:
267
+ steps_to_show = int(horizon_seconds / frequency_seconds)
268
+ freq_parts, _ = parse_iso8601_duration(frequency_iso)
269
+ inferred_frequency = duration_to_max_unit(freq_parts)
270
+ except Exception as e:
271
+ print(f"Error calculating steps: {e}", file=sys.stderr)
272
+
273
+ # Add context data (historical)
274
+ if not context_df.empty:
275
+ hist_name = "Historical Data" if len(series_list) == 1 else f"Historical - {series_name}"
276
+ fig.add_trace(go.Scatter(
277
+ x=context_df["ts"],
278
+ y=context_df["value"],
279
+ name=hist_name,
280
+ mode="lines",
281
+ line=dict(color="black", width=3),
282
+ legendgroup=f"series_{series_id}",
283
+ hovertemplate=f"<b>{hist_name}</b><br>Time: %{{x}}<br>Value: %{{y}}<extra></extra>",
284
+ ))
285
+
286
+ # Get forecasts for this series
287
+ forecasts = get_forecasts(api_client, challenge_id, series_id)
288
+
289
+ # Add forecasts
290
+ for model_readable_id, data in forecasts.items():
291
+ df = data["data"]
292
+ model_label = data.get("label", model_readable_id)
293
+ if df.empty:
294
+ continue
295
+
296
+ if not model_readable_id in selected_readable_model_ids:
297
+ continue
298
+
299
+ if model_label not in model_color_map:
300
+ model_color_map[model_label] = colors[color_idx % len(colors)]
301
+ color_idx += 1
302
+
303
+ color = model_color_map[model_label]
304
+ prelim_mase = data.get('current_mase')
305
+ if prelim_mase is None:
306
+ prelim_mase = "N/A"
307
+ else:
308
+ prelim_mase = f"{prelim_mase:.2f}"
309
+ display_name = f"{model_label} - Prelim MASE: {prelim_mase}"
310
+ if len(series_list) > 1:
311
+ display_name += f" ({series_name})"
312
+
313
+ # Connect forecast to last historical point
314
+ if not context_df.empty and not df.empty:
315
+ last_hist_ts = context_df["ts"].iloc[-1]
316
+ last_hist_val = context_df["value"].iloc[-1]
317
+ forecast_x = pd.concat([pd.Series([last_hist_ts]), df["ts"]]).reset_index(drop=True)
318
+ forecast_y = pd.concat([pd.Series([last_hist_val]), df["y"]]).reset_index(drop=True)
319
+ else:
320
+ forecast_x = df["ts"]
321
+ forecast_y = df["y"]
322
+
323
+ fig.add_trace(go.Scatter(
324
+ x=forecast_x,
325
+ y=forecast_y,
326
+ name=display_name,
327
+ mode="lines+markers",
328
+ line=dict(color=color, width=2),
329
+ marker=dict(size=4),
330
+ legendgroup=model_label,
331
+ hovertemplate=f"<b>{display_name}</b><br>Time: %{{x}}<br>Value: %{{y:.2f}}<extra></extra>",
332
+ ))
333
+
334
+ # Add actual data for first series only
335
+ if series_idx == 0 and not actual_df.empty:
336
+ actual_df_limited = actual_df.head(steps_to_show)
337
+ if not actual_df_limited.empty:
338
+ fig.add_trace(go.Scatter(
339
+ x=actual_df_limited["ts"],
340
+ y=actual_df_limited["value"],
341
+ name=f"Actual - {series_name}",
342
+ mode="lines",
343
+ line=dict(color="grey", width=3, dash="dot"),
344
+ marker=dict(size=6, symbol="diamond"),
345
+ legendgroup=f"series_{series_id}",
346
+ hovertemplate=f"<b>Actual - {series_name}</b><br>Time: %{{x}}<br>Value: %{{y}}<extra></extra>",
347
+ ))
348
+
349
+ # Update layout
350
+ fig.update_layout(
351
+ xaxis_title='Time',
352
+ yaxis_title='Value',
353
+ hovermode='x unified',
354
+ template='plotly_white',
355
+ height=600,
356
+ font=dict(family="Arial, sans-serif", size=12),
357
+ plot_bgcolor='rgba(245, 245, 245, 0.5)',
358
+ )
359
+
360
+ fig.update_xaxes(
361
+ showgrid=True,
362
+ gridwidth=1,
363
+ gridcolor='lightgray',
364
+ showline=True,
365
+ linewidth=2,
366
+ linecolor='gray'
367
+ )
368
+
369
+ fig.update_yaxes(
370
+ showgrid=True,
371
+ gridwidth=1,
372
+ gridcolor='lightgray',
373
+ showline=True,
374
+ linewidth=2,
375
+ linecolor='gray'
376
+ )
377
+
378
+ return fig
379
+
380
+ except Exception as e:
381
+ print(f"Error plotting real challenge data: {e}", file=sys.stderr)
382
+ traceback.print_exc()
383
+ return None
384
+
385
+
386
+ def render_challenges_tab_component(api_client: DashboardAPIClient):
387
+ render_filter_component(api_client=api_client, filter_type="active_challenges")
388
+ st.markdown("---")
389
+
390
+ # Main content area
391
+ st.header("🎯 Visualize a Challenge")
392
+
393
+ # Challenge selection
394
+ if st.session_state.get('filtered_challenges') is None:
395
+ with st.spinner("Loading active challenges..."):
396
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT")
397
+ set_challenges_session_state(
398
+ api_client=api_client,
399
+ selected_from_date=now+ "00:00:00.000Z",
400
+ selected_to_date=now + "23:59:59.999Z",
401
+ selected_statuses=[ChallengeStatus.ACTIVE.value],
402
+ )
403
+ st.rerun()
404
+ active_completed_challenges = st.session_state['filtered_challenges']
405
+ challenge_options = {f"{c.get('status')} • {c.get('description')}, Start Date: {datetime.strptime(c.get('start_time'), '%Y-%m-%dT%H:%M:%S.%fZ').strftime('%Y-%m-%d %H:%M:%S') if c.get('start_time') else 'N/A'} ({str(c.get('challenge_id', ''))[:8]})": c
406
+ for c in active_completed_challenges}
407
+
408
+ if challenge_options:
409
+ selected_challenge_key = st.selectbox(
410
+ "Challenge Selection",
411
+ options=list(challenge_options.keys()),
412
+ key="challenge_select"
413
+ )
414
+
415
+ selected_challenge = challenge_options[selected_challenge_key]
416
+ challenge_id = str(selected_challenge.get('challenge_id', ''))
417
+ challenge_id_short = challenge_id[:8] if challenge_id != '' else ''
418
+ challenge_name = selected_challenge.get('description', 'Challenge')
419
+
420
+ # Series selection
421
+ series_options = get_series_choices_for_challenge(challenge_id, api_client)
422
+
423
+ # Display challenge heading
424
+ st.subheader(f"📊 {challenge_name}")
425
+
426
+ # Get detailed challenge info
427
+ status = selected_challenge.get('status', 'unknown')
428
+ n_series = selected_challenge.get('n_time_series', 0)
429
+ model_count = selected_challenge.get('model_count', 0)
430
+ challenge_id = str(selected_challenge.get('challenge_id', ''))[:8]
431
+
432
+ # Get frequency and calculate horizon/context in steps
433
+ frequency_iso = 'PT1H' # Default
434
+ context_length_num = 'N/A'
435
+ frequency_display = 'N/A'
436
+
437
+ try:
438
+ challenge_id_full = selected_challenge.get('challenge_id')
439
+ series_list = api_client.get_challenge_series(challenge_id_full)
440
+ forecast_horizon_steps_num = get_challenge_horizon_steps_from_series(series_list)
441
+ if forecast_horizon_steps_num == -1:
442
+ forecast_horizon_steps_num = get_challenge_horizon_steps_from_challenge(selected_challenge)
443
+ if series_list and len(series_list) > 0:
444
+ frequency_iso = series_list[0].get('frequency', 'PT1H')
445
+ context_iso = series_list[0].get('context_length') or selected_challenge.get('context_length')
446
+
447
+ # Parse frequency for display
448
+ try:
449
+ freq_parts, _ = parse_iso8601_duration(frequency_iso)
450
+ frequency_display = duration_to_max_unit(freq_parts)
451
+ except:
452
+ frequency_display = frequency_iso
453
+
454
+ context_length_num = context_iso
455
+ except Exception as e:
456
+ print(f"Error getting series data for challenge info: {e}", file=sys.stderr)
457
+
458
+ # Status color
459
+ status_color = '#16a34a' if status == ChallengeStatus.ACTIVE.value else '#2563eb'
460
+
461
+ # Compact single-line info display matching Gradio version
462
+ st.markdown(f"""
463
+ <div style='display: flex; align-items: center; gap: 20px; flex-wrap: wrap; margin-bottom: 20px;'>
464
+ <div style='background: {status_color}; color: white; padding: 3px 10px; border-radius: 4px; font-size: 0.8em; font-weight: 600;'>{status.upper()}</div>
465
+ <div style='color: #6b7280; font-size: 0.9em;'>ID: <strong>{challenge_id}</strong></div>
466
+ <div style='height: 20px; width: 1px; background: #d1d5db;'></div>
467
+ <div style='color: #6b7280; font-size: 0.9em;'>Series: <strong style='color: #1f2937;'>{n_series}</strong></div>
468
+ <div style='color: #6b7280; font-size: 0.9em;'>Models: <strong style='color: #1f2937;'>{model_count}</strong></div>
469
+ <div style='color: #6b7280; font-size: 0.9em;'>Horizon: <strong style='color: #1f2937;'>{forecast_horizon_steps_num if forecast_horizon_steps_num != -1 else 'N/A' } Steps</strong></div>
470
+ <div style='color: #6b7280; font-size: 0.9em;'>Context: <strong style='color: #1f2937;'>{context_length_num}</strong></div>
471
+ <div style='color: #6b7280; font-size: 0.9em;'>Frequency: <strong style='color: #1f2937;'>{frequency_display}</strong></div>
472
+ </div>
473
+ """, unsafe_allow_html=True)
474
+
475
+ models = api_client.list_models_for_challenge(challenge_id)
476
+ selected_series_ids, selected_readable_model_ids = render_filter_models_component(series_options, models)
477
+
478
+ # Individual plots for each series (default)
479
+ with st.spinner("Loading individual series plots..."):
480
+ try:
481
+ challenge_id_full = selected_challenge.get('challenge_id')
482
+
483
+ if not str(challenge_id_full).startswith('mock'):
484
+ # Get series list
485
+ series_list = api_client.get_challenge_series(challenge_id_full)
486
+
487
+ if series_list and selected_series_ids:
488
+ # Filter to selected series
489
+ filtered_series = [s for s in series_list if s.get('series_id') in selected_series_ids]
490
+
491
+ if filtered_series:
492
+ # Create scrollable container for individual plots
493
+ individual_plots_container = st.container(height=800)
494
+ with individual_plots_container:
495
+ for series_info in filtered_series:
496
+ series_id = series_info.get('series_id')
497
+ series_name = series_info.get('name', f'Series {series_id}')
498
+ series_desc = series_info.get('description', '')
499
+ if series_desc:
500
+ expander_title = f"📈 {series_desc} (ID: {series_id})"
501
+ else:
502
+ expander_title = f"📈 {series_name} (ID: {series_id})"
503
+ with st.expander(expander_title, expanded=True):
504
+ # Plot this individual series
505
+ fig = plot_real_challenge_data(
506
+ challenge=selected_challenge,
507
+ forecast_horizon=forecast_horizon_steps_num,
508
+ api_client=api_client,
509
+ selected_series_ids=[series_id],
510
+ selected_readable_model_ids=selected_readable_model_ids,
511
+ )
512
+ if fig:
513
+ st.plotly_chart(fig, width="stretch")
514
+ else:
515
+ st.warning(f"Could not load data for {series_name}")
516
+ else:
517
+ st.info("No series selected")
518
+ else:
519
+ st.info("No series available or none selected")
520
+ else:
521
+ st.info("Individual series plots not available for demo data")
522
+ fig = make_demo_forecast_plot(forecast_horizon_steps_num, challenge_name)
523
+ st.plotly_chart(fig, width="stretch")
524
+
525
+ except Exception as e:
526
+ st.error(f"Error loading individual plots: {str(e)}")
527
+ traceback.print_exc()
528
+ # Interactive features info
529
+ st.markdown("""
530
+ **Interactive Features:**
531
+ - 🖱️ **Click legend items** to show/hide individual models
532
+ - 🔍 **Zoom** by dragging a box on the chart
533
+ - 👆 **Pan** by clicking and dragging
534
+ - 🔄 **Reset** by double-clicking the chart
535
+ - 📊 **Hover** to see exact values
536
+ """)
537
+ else:
538
+ st.info("No challenges available")
components/filter_component.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ import streamlit as st
3
+ import pandas as pd
4
+ import sys
5
+
6
+ from utils.api_client import ChallengeStatus, DashboardAPIClient
7
+
8
+ # Cached function to set rankings in session state
9
+ def set_rankings_session_state(
10
+ api_client: DashboardAPIClient,
11
+ selected_domains: Optional[List[str]] = None,
12
+ selected_categories: Optional[List[str]] = None,
13
+ selected_frequencies: Optional[List[str]] = None,
14
+ selected_horizons: Optional[List[str]] = None
15
+ ):
16
+ if 'filtered_rankings' not in st.session_state:
17
+ st.session_state['filtered_rankings'] = None
18
+
19
+ filter_options = api_client.get_filter_options()
20
+ filtered_rankings = []
21
+ for time_range in filter_options.get("time_ranges", ['All']):
22
+ loop_dict = api_client.get_filtered_rankings(
23
+ domains=selected_domains if selected_domains else None,
24
+ categories=selected_categories if selected_categories else None,
25
+ frequencies=selected_frequencies if selected_frequencies else None,
26
+ horizons=selected_horizons if selected_horizons else None,
27
+ time_range=time_range
28
+ )
29
+ loop_dict = [{**item, "time_ranges": time_range} for item in loop_dict]
30
+ filtered_rankings.append(loop_dict)
31
+ st.session_state['filtered_rankings'] = filtered_rankings
32
+
33
+
34
+ def set_challenges_session_state(
35
+ api_client: DashboardAPIClient,
36
+ selected_domains: Optional[List[str]] = None,
37
+ selected_categories: Optional[List[str]] = None,
38
+ selected_frequencies: Optional[List[str]] = None,
39
+ selected_horizons: Optional[List[str]] = None,
40
+ selected_from_date: Optional[str] = None,
41
+ selected_to_date: Optional[str] = None,
42
+ selected_statuses: Optional[List[str]] = None
43
+ ):
44
+ filtered_rankings = api_client.get_filtered_challenges(
45
+ domain=selected_domains if selected_domains else None,
46
+ category=selected_categories if selected_categories else None,
47
+ frequency=selected_frequencies if selected_frequencies else None,
48
+ horizon=selected_horizons if selected_horizons else None,
49
+ from_date=selected_from_date if selected_from_date else None,
50
+ to_date=selected_to_date if selected_to_date else None,
51
+ status=selected_statuses if selected_statuses else None
52
+ )
53
+ if filtered_rankings:
54
+ st.session_state["filtered_challenges"] = filtered_rankings
55
+ else:
56
+ st.session_state["filtered_challenges"] = []
57
+ st.info("No challenges match the selected filters")
58
+
59
+ def render_filter_component(api_client: DashboardAPIClient, filter_type: str = "model_ranking"):
60
+ """
61
+ Render the filter component in the Streamlit app.
62
+
63
+ Args:
64
+ api_client: Instance of DashboardAPIClient to fetch filter options and rankings.
65
+ """
66
+
67
+ # Add filter section right after the header
68
+ if filter_type == "model_ranking":
69
+ st.markdown("### 🔍 Filter Model Rankings")
70
+ elif filter_type == "active_challenges":
71
+ st.markdown("### 🔍 Filter Active Challenges")
72
+
73
+ # Get filter options from API
74
+ filter_options = api_client.get_filter_options()
75
+
76
+ # Create columns for filters
77
+ col1, col2, col3 = st.columns(3)
78
+ col5, col6, col7, col8 = st.columns(4)
79
+
80
+ with col1:
81
+ selected_domains = st.multiselect(
82
+ "Domain",
83
+ options=filter_options.get("domains", []),
84
+ help="Filter by domain",
85
+ key=f"domain_filter_{filter_type}"
86
+ )
87
+
88
+ with col2:
89
+ selected_categories = st.multiselect(
90
+ "Category",
91
+ options=filter_options.get("categories", []),
92
+ help="Filter by category",
93
+ key=f"category_filter_{filter_type}"
94
+ )
95
+
96
+ with col3:
97
+ selected_frequencies = st.multiselect(
98
+ "Frequency",
99
+ options=filter_options.get("frequencies", []),
100
+ help="Filter by time series frequency",
101
+ key=f"frequency_filter_{filter_type}"
102
+ )
103
+
104
+ with col5:
105
+ selected_horizons = st.multiselect(
106
+ "Horizon",
107
+ options=filter_options.get("horizons", []),
108
+ help="Filter by forecast horizon",
109
+ key=f"horizon_filter_{filter_type}"
110
+ )
111
+
112
+ # Active Challenges specific filters
113
+ selected_statuses = None
114
+ selected_from_date = None
115
+ selected_to_date = None
116
+ if filter_type == "active_challenges":
117
+ with col6:
118
+ selected_from_date = st.date_input(
119
+ "End Date From",
120
+ value="today",
121
+ help="Filter by Date",
122
+ key=f"from_date_filter_{filter_type}"
123
+ )
124
+ selected_from_date = selected_from_date.strftime(
125
+ "%Y-%m-%dT00:00:00.000Z")
126
+ with col7:
127
+ selected_to_date = st.date_input(
128
+ "End Date To",
129
+ value='today',
130
+ help="Filter by Date",
131
+ key=f"to_date_filter_{filter_type}"
132
+ )
133
+ selected_to_date = selected_to_date.strftime(
134
+ "%Y-%m-%dT23:59:59.999Z")
135
+ with col8:
136
+ selected_statuses = st.multiselect(
137
+ "Status",
138
+ options=[ChallengeStatus.ACTIVE.value,
139
+ ChallengeStatus.COMPLETED.value],
140
+ default=[ChallengeStatus.ACTIVE.value],
141
+ help="Filter by challenge status",
142
+ key=f"status_filter_{filter_type}"
143
+ )
144
+ # TODO: Filter for registration start and end date
145
+
146
+ # Apply filters button
147
+ if st.button("🔍 Apply Filters", type="primary", key=f"apply_filters_button_{filter_type}"):
148
+ try:
149
+ if filter_type == "model_ranking":
150
+ set_rankings_session_state(
151
+ api_client,
152
+ selected_domains=selected_domains,
153
+ selected_categories=selected_categories,
154
+ selected_frequencies=selected_frequencies,
155
+ selected_horizons=selected_horizons
156
+ )
157
+
158
+ elif filter_type == "active_challenges":
159
+ set_challenges_session_state(
160
+ api_client,
161
+ selected_domains=selected_domains,
162
+ selected_categories=selected_categories,
163
+ selected_frequencies=selected_frequencies,
164
+ selected_horizons=selected_horizons,
165
+ selected_from_date=selected_from_date,
166
+ selected_to_date=selected_to_date,
167
+ selected_statuses=selected_statuses
168
+ )
169
+ except Exception as e:
170
+ st.error(f"Error applying filters: {str(e)}")
components/filter_models_component.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+
3
+ def render_filter_models_component(series_options: list[dict], models: list[dict]):
4
+ # Select series:
5
+ if series_options:
6
+ # Default to first two series only
7
+ default_selection = [s['description'] for s in series_options[:2]]
8
+
9
+ selected_series = st.multiselect(
10
+ "Time Series Selection",
11
+ options=[s['description'] for s in series_options],
12
+ default=default_selection,
13
+ help="Select which time series to display in the plot",
14
+ key="series_select"
15
+ )
16
+
17
+ # Extract series IDs from selection
18
+ selected_series_ids = [
19
+ s['id'] for s in series_options
20
+ if s['description'] in selected_series
21
+ ]
22
+ else:
23
+ selected_series_ids = None
24
+
25
+ # Select models:
26
+ if models:
27
+ selected_readable_model_ids = [model["readable_id"] for model in models]
28
+ max_value = [model["model_size"] for model in models if model["model_size"] is not None]
29
+ max_value = max(max_value) if max_value else 0
30
+ # Filter size
31
+ selected_size = st.number_input(
32
+ "Model Max Size",
33
+ min_value=0,
34
+ value=max_value,
35
+ step=1,
36
+ help="Filter models by size (in million)",
37
+ key="model_size_filter"
38
+ )
39
+ # Nones are always displayed
40
+ deselected_sizes_ids = [model["readable_id"] for model in models if model["model_size"] is not None and model["model_size"] > selected_size]
41
+
42
+ # Filter name
43
+ selected_names = st.text_input(
44
+ "Model Name Contains",
45
+ value="",
46
+ help="Filter models by name containing this text. Multiple names can be separated by commas.",
47
+ key="model_name_filter"
48
+ )
49
+ if selected_names.strip(",").strip() == "":
50
+ deselected_name_ids = []
51
+ else:
52
+ deselected_name_ids = selected_readable_model_ids.copy()
53
+ for multiple in selected_names.split(","):
54
+ multiple = multiple.strip()
55
+ if multiple:
56
+ deselected_name_ids = list(set([model["readable_id"] for model in models if multiple.lower() not in model["name"].lower() and multiple.lower() != model["readable_id"].lower()]) & set(deselected_name_ids))
57
+
58
+ # Filter by architecture
59
+ available_architectures = list(set(model["architecture"] for model in models if model["architecture"] is not None))
60
+ select_architecture = st.multiselect(
61
+ "Model Architecture",
62
+ options=available_architectures,
63
+ default=available_architectures,
64
+ help="Filter by model architecture",
65
+ key="model_architecture_filter"
66
+ )
67
+ deselected_architecture_ids = [
68
+ model["readable_id"] for model in models
69
+ if model["architecture"] not in select_architecture
70
+ ]
71
+
72
+ # Combine filters
73
+ selected_readable_model_ids = list(set(selected_readable_model_ids) - set(deselected_sizes_ids) - set(deselected_name_ids) - set(deselected_architecture_ids))
74
+ else:
75
+ selected_readable_model_ids = None
76
+ return selected_series_ids, selected_readable_model_ids
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ streamlit>=1.28.0
2
+ plotly>=5.17.0
3
+ numpy>=1.24.0
4
+ pandas>=2.0.0
5
+ requests>=2.31.0
6
+ isodate>=0.6.1
7
+ python-dotenv>=1.0.0
utils/__init__.py ADDED
File without changes
utils/api_client.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from enum import Enum
4
+ from typing import List, Dict, Any, Optional, Tuple
5
+ from datetime import datetime, timedelta
6
+ import pandas as pd
7
+ import requests
8
+
9
+ class ChallengeStatus(Enum):
10
+ ACTIVE = "active"
11
+ COMPLETED = "completed"
12
+ REGISTRATION = "registration"
13
+ ANNOUNCED = "announced"
14
+
15
+
16
+ class DashboardAPIClient:
17
+ """API Client für dashboard-api."""
18
+
19
+ def __init__(self):
20
+ self._api_url = os.getenv("DASHBOARD_API_URL")
21
+ self.api_key = os.getenv("DASHBOARD_API_KEY")
22
+
23
+ # Remove trailing slash
24
+ if self._api_url.endswith("/"):
25
+ self._api_url = self._api_url[:-1]
26
+
27
+ print(f"DEBUG: API URL: {self._api_url}", file=sys.stderr)
28
+ print(f"DEBUG: API Key configured: {'Yes' if self.api_key else 'No'}", file=sys.stderr)
29
+
30
+ self.headers = {
31
+ "X-API-Key": self.api_key,
32
+ "Content-Type": "application/json"
33
+ }
34
+ self.timeout = 30 # seconds
35
+
36
+ @property
37
+ def api_url(self) -> str:
38
+ """Property for compatibility with caching functions."""
39
+ return self._api_url
40
+
41
+ @property
42
+ def database_url(self) -> str:
43
+ """Backward compatibility property for cache keys."""
44
+ return self._api_url
45
+
46
+ def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any:
47
+ """Make GET request to API."""
48
+ url = f"{self._api_url}{endpoint}"
49
+ try:
50
+ print(f"DEBUG: GET {url} with params: {params}", file=sys.stderr)
51
+ response = requests.get(url, headers=self.headers, params=params, timeout=self.timeout)
52
+ response.raise_for_status()
53
+ return response.json()
54
+ except requests.exceptions.RequestException as e:
55
+ print(f"ERROR: API request failed: {e}", file=sys.stderr)
56
+ raise
57
+
58
+ # Challenges
59
+ def list_challenges(self) -> List[Dict[str, Any]]:
60
+ """List all challenges including aggregates (models/forecasts)."""
61
+ print("DEBUG: Calling list_challenges", file=sys.stderr)
62
+ try:
63
+ data = self._get("/api/v1/challenges")
64
+ print(f"DEBUG: list_challenges found {len(data)} challenges.", file=sys.stderr)
65
+ if data:
66
+ print("DEBUG: Challenges retrieved:", file=sys.stderr)
67
+ for r in data:
68
+ print(f" - ID: {r.get('challenge_id', 'N/A')}, Status: {r.get('status', 'N/A')}, Desc: {r.get('description', 'N/A')}", file=sys.stderr)
69
+ return data
70
+ except Exception as e:
71
+ print(f"ERROR: in list_challenges: {e}", file=sys.stderr)
72
+ return []
73
+
74
+ def list_completed_challenges_in_range(self, since: datetime) -> List[Dict[str, Any]]:
75
+ """List completed challenges whose end_time >= since."""
76
+ params = {
77
+ "status": ChallengeStatus.COMPLETED.value,
78
+ "from": since.isoformat()
79
+ }
80
+ try:
81
+ return self._get("/api/v1/challenges", params=params)
82
+ except Exception as e:
83
+ print(f"ERROR: in list_completed_challenges_in_range: {e}", file=sys.stderr)
84
+ return []
85
+
86
+ def list_active_challenges(self) -> List[Dict[str, Any]]:
87
+ """Active challenges with status 'registration' and 'announced'."""
88
+ print("DEBUG: Calling list_active_challenges", file=sys.stderr)
89
+ try:
90
+ params = {"status": f"{ChallengeStatus.ACTIVE.value}"}
91
+ data = self._get("/api/v1/challenges", params=params)
92
+ print(f"DEBUG: list_active_challenges found {len(data)} challenges.", file=sys.stderr)
93
+ if data:
94
+ print("DEBUG: Active challenges retrieved:", file=sys.stderr)
95
+ for r in data:
96
+ print(f" - ID: {r.get('challenge_id', 'N/A')}, Status: {r.get('status', 'N/A')}, Desc: {r.get('description', 'N/A')}", file=sys.stderr)
97
+ return data
98
+ except Exception as e:
99
+ print(f"ERROR: in list_active_challenges: {e}", file=sys.stderr)
100
+ return []
101
+
102
+ def list_upcoming_challenges(self) -> List[Dict[str, Any]]:
103
+ """Upcoming challenges with status 'registration' and 'announced'."""
104
+ print("DEBUG: Calling list_upcoming_challenges", file=sys.stderr)
105
+ try:
106
+ params = {"status": f"{ChallengeStatus.REGISTRATION.value},{ChallengeStatus.ANNOUNCED.value}"}
107
+ data = self._get("/api/v1/challenges", params=params)
108
+ print(f"DEBUG: list_upcoming_challenges found {len(data)} challenges.", file=sys.stderr)
109
+ if data:
110
+ print("DEBUG: Upcoming challenges retrieved:", file=sys.stderr)
111
+ for r in data:
112
+ print(f" - ID: {r.get('challenge_id', 'N/A')}, Status: {r.get('status', 'N/A')}, Desc: {r.get('description', 'N/A')}", file=sys.stderr)
113
+ return data
114
+ except Exception as e:
115
+ print(f"ERROR: in list_upcoming_challenges: {e}", file=sys.stderr)
116
+ return []
117
+
118
+ def get_challenge_meta(self, challenge_id: str) -> Optional[Dict[str, Any]]:
119
+ """Get metadata for a challenge."""
120
+ try:
121
+ return self._get(f"/api/v1/challenges/{challenge_id}")
122
+ except requests.exceptions.HTTPError as e:
123
+ if e.response.status_code == 404:
124
+ return None
125
+ raise
126
+ except Exception as e:
127
+ print(f"ERROR: in get_challenge_meta: {e}", file=sys.stderr)
128
+ return None
129
+
130
+ def get_challenge_series(self, challenge_id: str) -> List[Dict[str, Any]]:
131
+ """Time series for a challenge."""
132
+ try:
133
+ return self._get(f"/api/v1/challenges/{challenge_id}/series")
134
+ except Exception as e:
135
+ print(f"ERROR: in get_challenge_series: {e}", file=sys.stderr)
136
+ return []
137
+
138
+ def get_filtered_challenges(
139
+ self,
140
+ status: Optional[str|List[str]]=None,
141
+ from_date: Optional[str]=None,
142
+ to_date: Optional[str]=None,
143
+ frequency: Optional[str|List[str]]=None,
144
+ domain: Optional[str|List[str]]=None,
145
+ category: Optional[str|List[str]]=None,
146
+ horizon: Optional[str|List[str]]=None,
147
+ ) -> List[Dict[str, Any]]:
148
+ """Get challenges filtered by various criteria."""
149
+ try:
150
+ str_status = ",".join(status) if isinstance(status, list) else status
151
+ str_frequency = ",".join(frequency) if isinstance(frequency, list) else frequency
152
+ str_domain = ",".join(domain) if isinstance(domain, list) else domain
153
+ str_category = ",".join(category) if isinstance(category, list) else category
154
+ str_horizon = ",".join(horizon) if isinstance(horizon, list) else horizon
155
+ params = {
156
+ "status": str_status,
157
+ "from": from_date,
158
+ "to": to_date,
159
+ "frequency": str_frequency,
160
+ "domain": str_domain,
161
+ "category": str_category,
162
+ "horizon": str_horizon
163
+ }
164
+ return self._get("/api/v1/challenges", params=params)
165
+ except Exception as e:
166
+ print(f"ERROR: in get_filtered_challenges: {e}", file=sys.stderr)
167
+ return []
168
+
169
+ def get_challenge_data_for_series(
170
+ self, challenge_id: int, series_id: int, start_time, end_time
171
+ ) -> pd.DataFrame:
172
+ """Get all relevant data for a series in a challenge for plotting."""
173
+ try:
174
+ # Handle both datetime objects and string timestamps
175
+ if isinstance(start_time, str):
176
+ start_time_str = start_time
177
+ elif hasattr(start_time, 'isoformat'):
178
+ start_time_str = start_time.isoformat()
179
+ else:
180
+ start_time_str = str(start_time)
181
+
182
+ if isinstance(end_time, str):
183
+ end_time_str = end_time
184
+ elif hasattr(end_time, 'isoformat'):
185
+ end_time_str = end_time.isoformat()
186
+ else:
187
+ end_time_str = str(end_time)
188
+
189
+ params = {
190
+ "start_time": start_time_str,
191
+ "end_time": end_time_str
192
+ }
193
+ data = self._get(f"/api/v1/challenges/{challenge_id}/series/{series_id}/data", params=params)
194
+
195
+ # Convert to DataFrame
196
+ if "data" in data and data["data"]:
197
+ df = pd.DataFrame(data["data"])
198
+ df["ts"] = pd.to_datetime(df["ts"], utc=False)
199
+ return df
200
+ else:
201
+ return pd.DataFrame(columns=["ts", "value"])
202
+ except Exception as e:
203
+ print(f"ERROR: in get_challenge_data_for_series: {e}", file=sys.stderr)
204
+ return pd.DataFrame(columns=["ts", "value"])
205
+
206
+ def get_series_forecasts(self, challenge_id: str, series_id: int) -> Dict[str, Dict[str, pd.DataFrame|str]]:
207
+ """Return, per forecast (model/version), the data points as a DataFrame."""
208
+ try:
209
+ data = self._get(f"/api/v1/challenges/{challenge_id}/series/{series_id}/forecasts")
210
+
211
+ # Convert to Dict[str, DataFrame]
212
+ result = {}
213
+ if "forecasts" in data:
214
+ for model_readable_id, forecast_data in data["forecasts"].items():
215
+ if forecast_data:
216
+ df = pd.DataFrame(forecast_data["data"])
217
+ df["ts"] = pd.to_datetime(df["ts"], utc=False)
218
+ result[model_readable_id] = {"data": df, "label": forecast_data.get("label", ""), "current_mase": forecast_data.get("current_mase", "")}
219
+ return result
220
+ except Exception as e:
221
+ print(f"ERROR: in get_series_forecasts: {e}", file=sys.stderr)
222
+ return {}
223
+
224
+ def list_models_for_challenge(self, challenge_id: str) -> List[str]:
225
+ """List all unique models for a given challenge."""
226
+ try:
227
+ return self._get(f"/api/v1/challenges/{challenge_id}/models")
228
+ except Exception as e:
229
+ print(f"ERROR: in list_models_for_challenge: {e}", file=sys.stderr)
230
+ return []
231
+
232
+ @staticmethod
233
+ def granularity_to_timedelta(granularity: Optional[str]) -> timedelta:
234
+ """Convert granularity string to timedelta."""
235
+ g = (granularity or "hour").lower()
236
+ if "15" in g:
237
+ return timedelta(minutes=15)
238
+ if g.startswith("h"):
239
+ return timedelta(hours=1)
240
+ if g.startswith("d"):
241
+ return timedelta(days=1)
242
+ return timedelta(hours=1)
243
+
244
+ def get_global_rankings(self) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, Optional[datetime]]]:
245
+ """Compute global model rankings.
246
+
247
+ Returns a tuple (results, ranges) where `results` maps a range label to a list of
248
+ dict rows (model_name, n_completed, avg_mase) and `ranges` maps the
249
+ same labels to the lower-bound datetime.
250
+ """
251
+ try:
252
+ data = self._get("/api/v1/models/rankings")
253
+
254
+ # Reconstruct ranges from response
255
+ now = datetime.utcnow()
256
+ ranges: Dict[str, Optional[datetime]] = {
257
+ "Last 7 days": now - timedelta(days=7),
258
+ "Last 30 days": now - timedelta(days=30),
259
+ "Last 90 days": now - timedelta(days=90),
260
+ "Last 365 days": now - timedelta(days=365),
261
+ }
262
+
263
+ results = data.get("ranges", {})
264
+ return results, ranges
265
+ except Exception as e:
266
+ print(f"ERROR: in get_global_rankings: {e}", file=sys.stderr)
267
+ return {}, {}
268
+
269
+ def get_filter_options(self) -> Dict[str, List[str]]:
270
+ """Get available filter options for rankings.
271
+
272
+ GET /api/v1/models/rankings-filters (without parameters)
273
+ Returns dict with keys: domains, categories, subcategories, frequencies, horizons, time_ranges
274
+ """
275
+ try:
276
+ # Call the endpoint without any parameters to get filter options
277
+ data = self._get("/api/v1/models/ranking-filters")
278
+ print(f"DEBUG: get_filter_options raw response: {data}", file=sys.stderr)
279
+ print(f"DEBUG: Response type: {type(data)}", file=sys.stderr)
280
+
281
+ # Validate that we got the expected structure
282
+ if isinstance(data, dict):
283
+ expected_keys = ["domains", "categories", "frequencies", "horizons", "time_ranges"]
284
+ for key in expected_keys:
285
+ if key in data:
286
+ print(f"DEBUG: {key} = {data[key]}", file=sys.stderr)
287
+ else:
288
+ print(f"WARNING: Missing key '{key}' in API response", file=sys.stderr)
289
+
290
+ return data
291
+ else:
292
+ print(f"ERROR: Unexpected data format from filter-rankings: {type(data)}", file=sys.stderr)
293
+ return {
294
+ "domains": [],
295
+ "categories": [],
296
+ "frequencies": [],
297
+ "horizons": [],
298
+ "time_ranges": []
299
+ }
300
+ except Exception as e:
301
+ print(f"ERROR: in get_filter_options: {e}", file=sys.stderr)
302
+ import traceback
303
+ traceback.print_exc()
304
+ return {
305
+ "domains": [],
306
+ "categories": [],
307
+ "frequencies": [],
308
+ "horizons": [],
309
+ "time_ranges": []
310
+ }
311
+
312
+ def get_filtered_rankings(
313
+ self,
314
+ domains: Optional[List[str]] = None,
315
+ categories: Optional[List[str]] = None,
316
+ frequencies: Optional[List[str]] = None,
317
+ horizons: Optional[List[str]] = None,
318
+ time_range: Optional[str] = None
319
+ ) -> List[Dict[str, Any]]:
320
+ """Get filtered model rankings."""
321
+ try:
322
+ params = {}
323
+ if domains:
324
+ params["domain"] = ",".join(domains)
325
+ if categories:
326
+ params["category"] = ",".join(categories)
327
+ if frequencies:
328
+ params["frequency"] = ",".join(frequencies)
329
+ if horizons:
330
+ params["horizon"] = ",".join(horizons)
331
+ if time_range:
332
+ params["time_range"] = time_range
333
+
334
+ data = self._get("/api/v1/models/rankings", params=params)
335
+ return data.get("rankings", [])
336
+ except Exception as e:
337
+ print(f"ERROR: in get_filtered_rankings: {e}", file=sys.stderr)
338
+ return []
339
+
340
+ def hash_func_dashboard_api_client(obj: DashboardAPIClient) -> int:
341
+ return 1
utils/utils.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import re
3
+ import sys
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Dict, List, Optional
6
+ from zoneinfo import ZoneInfo
7
+
8
+ from utils.api_client import ChallengeStatus, DashboardAPIClient
9
+
10
+
11
+ def parse_iso8601_duration(duration):
12
+ """
13
+ Parse an ISO 8601 duration string (e.g., 'P3Y6M4DT12H30M5S')
14
+ into a dictionary and total seconds.
15
+ """
16
+ # Regex pattern for ISO 8601 durations
17
+ pattern = re.compile(
18
+ r'^P' # starts with 'P'
19
+ r'(?:(?P<years>\d+)Y)?' # years
20
+ r'(?:(?P<months>\d+)M)?' # months (in date part)
21
+ r'(?:(?P<weeks>\d+)W)?' # weeks
22
+ r'(?:(?P<days>\d+)D)?' # days
23
+ r'(?:T' # start of time part
24
+ r'(?:(?P<hours>\d+)H)?' # hours
25
+ r'(?:(?P<minutes>\d+)M)?' # minutes
26
+ r'(?:(?P<seconds>\d+)S)?' # seconds
27
+ r')?$' # end of string
28
+ )
29
+
30
+ match = pattern.match(duration)
31
+ if not match:
32
+ raise ValueError(f"Invalid ISO 8601 duration: {duration}")
33
+
34
+ parts = {k: int(v) if v else 0 for k, v in match.groupdict().items()}
35
+
36
+ # Approximate conversions
37
+ SECONDS_IN = {
38
+ 'years': 365 * 24 * 3600, # 1 year ≈ 365 days
39
+ 'months': 30 * 24 * 3600, # 1 month ≈ 30 days
40
+ 'weeks': 7 * 24 * 3600,
41
+ 'days': 24 * 3600,
42
+ 'hours': 3600,
43
+ 'minutes': 60,
44
+ 'seconds': 1,
45
+ }
46
+
47
+ total_seconds = sum(parts[k] * SECONDS_IN[k] for k in parts)
48
+
49
+ return parts, total_seconds
50
+
51
+ def seconds_to_hours(seconds):
52
+ """
53
+ Convert seconds to hours.
54
+
55
+ Args:
56
+ seconds: Number of seconds (int or float)
57
+
58
+ Returns:
59
+ float: Number of hours
60
+ """
61
+ return seconds / 3600
62
+
63
+ def duration_to_max_unit(parts_dict):
64
+ """
65
+ Convert a duration dictionary (from parse_iso8601_duration) to a single unit value.
66
+ Returns the duration in the smallest unit specified in the original duration.
67
+
68
+ For example:
69
+ - 23 hours --> "23 Hours"
70
+ - 1 day 2 hours --> "26 Hours" (hours is smaller unit)
71
+ - 2 days --> "2 Days"
72
+ - 1 week 3 days --> "10 Days" (days is smaller unit)
73
+ - 7 days --> "1 Week" (only days specified, can be converted to weeks)
74
+ - 168 hours --> "168 Hours" (keep as hours since that was the original unit)
75
+
76
+ Args:
77
+ parts_dict: Dictionary from parse_iso8601_duration with keys: years, months, weeks, days, hours, minutes, seconds
78
+
79
+ Returns:
80
+ str: Formatted string like "23 Hours" or "2 Days"
81
+ """
82
+ # Conversion factors to seconds
83
+ SECONDS_IN = {
84
+ 'years': 365 * 24 * 3600,
85
+ 'months': 30 * 24 * 3600,
86
+ 'weeks': 7 * 24 * 3600,
87
+ 'days': 24 * 3600,
88
+ 'hours': 3600,
89
+ 'minutes': 60,
90
+ 'seconds': 1,
91
+ }
92
+
93
+ # Calculate total seconds
94
+ total_seconds = sum(parts_dict.get(k, 0) * SECONDS_IN[k] for k in SECONDS_IN)
95
+
96
+ if total_seconds == 0:
97
+ return "0 Seconds"
98
+
99
+ # Unit mappings (key, singular, plural, divisor)
100
+ UNIT_MAP = [
101
+ ('years', 'Year', 'Years', 365 * 24 * 3600),
102
+ ('months', 'Month', 'Months', 30 * 24 * 3600),
103
+ ('weeks', 'Week', 'Weeks', 7 * 24 * 3600),
104
+ ('days', 'Day', 'Days', 24 * 3600),
105
+ ('hours', 'Hour', 'Hours', 3600),
106
+ ('minutes', 'Minute', 'Minutes', 60),
107
+ ('seconds', 'Second', 'Seconds', 1),
108
+ ]
109
+
110
+ # Find the smallest unit that was actually used in the original duration
111
+ target_unit = None
112
+ for key, singular, plural, divisor in reversed(UNIT_MAP): # Start from smallest
113
+ if parts_dict.get(key, 0) > 0:
114
+ target_unit = (key, singular, plural, divisor)
115
+ break
116
+
117
+ # If we found a target unit, convert to that unit
118
+ if target_unit:
119
+ key, singular, plural, divisor = target_unit
120
+ value = total_seconds // divisor
121
+ unit_name = singular if value == 1 else plural
122
+ return f"{value} {unit_name}"
123
+
124
+ # Fallback: use seconds
125
+ return f"{total_seconds} Seconds"
126
+
127
+ def to_local(dt_val: Any, tz_name: str) -> Optional[datetime]:
128
+ if dt_val is None:
129
+ return None
130
+ try:
131
+ tz = ZoneInfo(tz_name)
132
+ except Exception:
133
+ tz = ZoneInfo("UTC")
134
+ # Normalize input to aware datetime
135
+ if isinstance(dt_val, pd.Timestamp):
136
+ dt = dt_val.to_pydatetime()
137
+ elif isinstance(dt_val, datetime):
138
+ dt = dt_val
139
+ else:
140
+ try:
141
+ ts = pd.to_datetime(dt_val, utc=True)
142
+ dt = ts.to_pydatetime()
143
+ except Exception:
144
+ return None
145
+ if dt.tzinfo is None:
146
+ dt = dt.replace(tzinfo=timezone.utc)
147
+ try:
148
+ return dt.astimezone(tz)
149
+ except Exception:
150
+ return None
151
+
152
+ def get_active_challenges_list(api_client:DashboardAPIClient) -> List[Dict[str, Any]]:
153
+ """Get list of active and completed challenges."""
154
+ try:
155
+ challenges = api_client.list_active_challenges()
156
+
157
+
158
+ # If no challenges from API, return mock data
159
+ if not challenges:
160
+ print("No challenges from API, using mock data", file=sys.stderr)
161
+ challenges = [
162
+ {
163
+ 'challenge_id': 'mock-001',
164
+ 'status': ChallengeStatus.ACTIVE.value,
165
+ 'description': 'Electricity Load Forecasting',
166
+ 'n_time_series': 370,
167
+ 'model_count': 5,
168
+ 'horizon': 24,
169
+ 'context_length': 168,
170
+ 'granularity': 'hourly',
171
+ 'registration_start': '2025-10-01T00:00:00Z',
172
+ 'final_evaluation_at': '2025-12-31T23:59:59Z'
173
+ },
174
+ {
175
+ 'challenge_id': 'mock-002',
176
+ 'status': ChallengeStatus.ACTIVE.value,
177
+ 'description': 'Weather Prediction Challenge',
178
+ 'n_time_series': 50,
179
+ 'model_count': 8,
180
+ 'horizon': 48,
181
+ 'context_length': 336,
182
+ 'granularity': 'hourly',
183
+ 'registration_start': '2025-09-15T00:00:00Z',
184
+ 'final_evaluation_at': '2025-11-30T23:59:59Z'
185
+ },
186
+ {
187
+ 'challenge_id': 'mock-003',
188
+ 'status': ChallengeStatus.COMPLETED.value,
189
+ 'description': 'Retail Sales Forecasting',
190
+ 'n_time_series': 100,
191
+ 'model_count': 12,
192
+ 'horizon': 7,
193
+ 'context_length': 56,
194
+ 'granularity': 'daily',
195
+ 'registration_start': '2025-08-01T00:00:00Z',
196
+ 'final_evaluation_at': '2025-09-30T23:59:59Z'
197
+ }
198
+ ]
199
+
200
+ return challenges
201
+ except Exception as e:
202
+ print(f"Error fetching challenges: {e}", file=sys.stderr)
203
+ # Return mock data on error
204
+ return [
205
+ {
206
+ 'challenge_id': 'mock-001',
207
+ 'status': ChallengeStatus.ACTIVE.value,
208
+ 'description': 'Electricity Load Forecasting',
209
+ 'n_time_series': 370,
210
+ 'model_count': 5,
211
+ 'horizon': 24,
212
+ 'context_length': 168,
213
+ 'granularity': 'hourly',
214
+ 'registration_start': '2025-10-01T00:00:00Z',
215
+ 'final_evaluation_at': '2025-12-31T23:59:59Z'
216
+ }
217
+ ]