hockey-blast-common-lib 0.1.65__py3-none-any.whl → 0.1.67__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hockey_blast_common_lib/aggregate_all_stats.py +14 -0
- hockey_blast_common_lib/aggregate_game_stats_all.py +150 -0
- hockey_blast_common_lib/aggregate_game_stats_goalie.py +257 -0
- hockey_blast_common_lib/aggregate_game_stats_skater.py +361 -0
- hockey_blast_common_lib/aggregate_goalie_stats.py +79 -0
- hockey_blast_common_lib/aggregate_human_stats.py +12 -0
- hockey_blast_common_lib/aggregate_referee_stats.py +75 -0
- hockey_blast_common_lib/aggregate_scorekeeper_stats.py +91 -0
- hockey_blast_common_lib/aggregate_skater_stats.py +118 -0
- hockey_blast_common_lib/aggregate_team_goalie_stats.py +265 -0
- hockey_blast_common_lib/aggregate_team_skater_stats.py +313 -0
- hockey_blast_common_lib/hockey_blast_sample_backup.sql.gz +0 -0
- hockey_blast_common_lib/models.py +16 -1
- hockey_blast_common_lib/stats_models.py +211 -5
- hockey_blast_common_lib/utils.py +65 -0
- {hockey_blast_common_lib-0.1.65.dist-info → hockey_blast_common_lib-0.1.67.dist-info}/METADATA +1 -1
- hockey_blast_common_lib-0.1.67.dist-info/RECORD +34 -0
- hockey_blast_common_lib-0.1.65.dist-info/RECORD +0 -29
- {hockey_blast_common_lib-0.1.65.dist-info → hockey_blast_common_lib-0.1.67.dist-info}/WHEEL +0 -0
- {hockey_blast_common_lib-0.1.65.dist-info → hockey_blast_common_lib-0.1.67.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Aggregate skater statistics by team.
|
|
3
|
+
|
|
4
|
+
This module aggregates player statistics for each team, counting only games
|
|
5
|
+
where the player was on that specific team (using GameRoster.team_id).
|
|
6
|
+
|
|
7
|
+
Key difference from regular aggregation:
|
|
8
|
+
- Aggregates by (aggregation_id, team_id, human_id) instead of just (aggregation_id, human_id)
|
|
9
|
+
- Filters to only games where GameRoster.team_id matches the target team
|
|
10
|
+
- Stores results in OrgStatsSkaterTeam / DivisionStatsSkaterTeam
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
17
|
+
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
import sqlalchemy
|
|
21
|
+
from sqlalchemy import and_, case, func
|
|
22
|
+
|
|
23
|
+
from hockey_blast_common_lib.db_connection import create_session
|
|
24
|
+
from hockey_blast_common_lib.models import (
|
|
25
|
+
Division,
|
|
26
|
+
Game,
|
|
27
|
+
GameRoster,
|
|
28
|
+
Goal,
|
|
29
|
+
Human,
|
|
30
|
+
Organization,
|
|
31
|
+
Penalty,
|
|
32
|
+
Team,
|
|
33
|
+
)
|
|
34
|
+
from hockey_blast_common_lib.options import (
|
|
35
|
+
MIN_GAMES_FOR_DIVISION_STATS,
|
|
36
|
+
MIN_GAMES_FOR_ORG_STATS,
|
|
37
|
+
)
|
|
38
|
+
from hockey_blast_common_lib.progress_utils import create_progress_tracker
|
|
39
|
+
from hockey_blast_common_lib.stats_models import (
|
|
40
|
+
DivisionStatsSkaterTeam,
|
|
41
|
+
OrgStatsSkaterTeam,
|
|
42
|
+
)
|
|
43
|
+
from hockey_blast_common_lib.utils import (
|
|
44
|
+
calculate_percentile_value,
|
|
45
|
+
get_non_human_ids,
|
|
46
|
+
get_percentile_human,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Import status constants for game filtering
|
|
50
|
+
FINAL_STATUS = "Final"
|
|
51
|
+
FINAL_SO_STATUS = "Final(SO)"
|
|
52
|
+
FORFEIT_STATUS = "FORFEIT"
|
|
53
|
+
NOEVENTS_STATUS = "NOEVENTS"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def aggregate_team_skater_stats(session, aggregation_type, aggregation_id):
|
|
57
|
+
"""
|
|
58
|
+
Aggregate skater stats by team for an organization or division.
|
|
59
|
+
|
|
60
|
+
For each team in the aggregation scope, calculates stats for all players
|
|
61
|
+
who played for that team, counting only games where they were on that team.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
session: Database session
|
|
65
|
+
aggregation_type: "org" or "division"
|
|
66
|
+
aggregation_id: ID of the organization or division
|
|
67
|
+
"""
|
|
68
|
+
# Capture start time for aggregation tracking
|
|
69
|
+
aggregation_start_time = datetime.utcnow()
|
|
70
|
+
|
|
71
|
+
human_ids_to_filter = get_non_human_ids(session)
|
|
72
|
+
|
|
73
|
+
# Determine aggregation details
|
|
74
|
+
if aggregation_type == "org":
|
|
75
|
+
StatsModel = OrgStatsSkaterTeam
|
|
76
|
+
min_games = MIN_GAMES_FOR_ORG_STATS
|
|
77
|
+
aggregation_name = (
|
|
78
|
+
session.query(Organization)
|
|
79
|
+
.filter(Organization.id == aggregation_id)
|
|
80
|
+
.first()
|
|
81
|
+
.organization_name
|
|
82
|
+
)
|
|
83
|
+
filter_condition = Game.org_id == aggregation_id
|
|
84
|
+
elif aggregation_type == "division":
|
|
85
|
+
StatsModel = DivisionStatsSkaterTeam
|
|
86
|
+
min_games = MIN_GAMES_FOR_DIVISION_STATS
|
|
87
|
+
aggregation_name = (
|
|
88
|
+
session.query(Division).filter(Division.id == aggregation_id).first().level
|
|
89
|
+
)
|
|
90
|
+
filter_condition = Game.division_id == aggregation_id
|
|
91
|
+
else:
|
|
92
|
+
raise ValueError(f"Invalid aggregation type: {aggregation_type}")
|
|
93
|
+
|
|
94
|
+
print(f"Aggregating team skater stats for {aggregation_name}...")
|
|
95
|
+
|
|
96
|
+
# Delete existing stats for this aggregation
|
|
97
|
+
session.query(StatsModel).filter(StatsModel.aggregation_id == aggregation_id).delete()
|
|
98
|
+
session.commit()
|
|
99
|
+
|
|
100
|
+
# Get all teams in this aggregation scope
|
|
101
|
+
if aggregation_type == "org":
|
|
102
|
+
teams_query = (
|
|
103
|
+
session.query(Team.id, Team.name)
|
|
104
|
+
.join(Game, (Game.home_team_id == Team.id) | (Game.visitor_team_id == Team.id))
|
|
105
|
+
.filter(Game.org_id == aggregation_id)
|
|
106
|
+
.distinct()
|
|
107
|
+
)
|
|
108
|
+
else: # division
|
|
109
|
+
teams_query = (
|
|
110
|
+
session.query(Team.id, Team.name)
|
|
111
|
+
.join(Game, (Game.home_team_id == Team.id) | (Game.visitor_team_id == Team.id))
|
|
112
|
+
.filter(Game.division_id == aggregation_id)
|
|
113
|
+
.distinct()
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
teams = teams_query.all()
|
|
117
|
+
print(f"Found {len(teams)} teams in {aggregation_name}")
|
|
118
|
+
|
|
119
|
+
# Process each team
|
|
120
|
+
progress = create_progress_tracker(len(teams), description="Processing teams")
|
|
121
|
+
for team_id, team_name in teams:
|
|
122
|
+
progress.update(1)
|
|
123
|
+
|
|
124
|
+
# Aggregate stats for this team
|
|
125
|
+
# Filter to only games where players were on THIS team
|
|
126
|
+
games_played_query = (
|
|
127
|
+
session.query(
|
|
128
|
+
GameRoster.human_id,
|
|
129
|
+
func.count(Game.id).label("games_played"),
|
|
130
|
+
func.count(Game.id).label("games_participated"),
|
|
131
|
+
func.count(Game.id).label("games_with_stats"),
|
|
132
|
+
func.array_agg(Game.id).label("game_ids"),
|
|
133
|
+
)
|
|
134
|
+
.join(Game, Game.id == GameRoster.game_id)
|
|
135
|
+
.filter(
|
|
136
|
+
GameRoster.team_id == team_id, # KEY: Filter by team
|
|
137
|
+
~GameRoster.role.ilike("g"), # Exclude goalies
|
|
138
|
+
GameRoster.human_id.notin_(human_ids_to_filter),
|
|
139
|
+
Game.status.in_([FINAL_STATUS, FINAL_SO_STATUS, FORFEIT_STATUS, NOEVENTS_STATUS]),
|
|
140
|
+
filter_condition, # org_id or division_id filter
|
|
141
|
+
)
|
|
142
|
+
.group_by(GameRoster.human_id)
|
|
143
|
+
.having(func.count(Game.id) >= min_games)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
games_played_data = games_played_query.all()
|
|
147
|
+
if not games_played_data:
|
|
148
|
+
continue # No players met minimum games for this team
|
|
149
|
+
|
|
150
|
+
# Create stats dictionary
|
|
151
|
+
stats_dict = {}
|
|
152
|
+
for row in games_played_data:
|
|
153
|
+
stats_dict[row.human_id] = {
|
|
154
|
+
"games_played": row.games_played,
|
|
155
|
+
"games_participated": row.games_participated,
|
|
156
|
+
"games_with_stats": row.games_with_stats,
|
|
157
|
+
"game_ids": row.game_ids,
|
|
158
|
+
"first_game_id": row.game_ids[0] if row.game_ids else None,
|
|
159
|
+
"last_game_id": row.game_ids[-1] if row.game_ids else None,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Aggregate goals, assists, points
|
|
163
|
+
goals_assists_query = (
|
|
164
|
+
session.query(
|
|
165
|
+
GameRoster.human_id,
|
|
166
|
+
func.count(func.distinct(case((Goal.goal_scorer_id == GameRoster.human_id, Goal.id)))).label("goals"),
|
|
167
|
+
func.count(
|
|
168
|
+
func.distinct(
|
|
169
|
+
case(
|
|
170
|
+
(
|
|
171
|
+
(Goal.assist_1_id == GameRoster.human_id) | (Goal.assist_2_id == GameRoster.human_id),
|
|
172
|
+
Goal.id,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
).label("assists"),
|
|
177
|
+
)
|
|
178
|
+
.join(Game, Game.id == GameRoster.game_id)
|
|
179
|
+
.outerjoin(Goal, Game.id == Goal.game_id)
|
|
180
|
+
.filter(
|
|
181
|
+
GameRoster.team_id == team_id, # KEY: Filter by team
|
|
182
|
+
~GameRoster.role.ilike("g"),
|
|
183
|
+
GameRoster.human_id.in_(stats_dict.keys()),
|
|
184
|
+
Game.status.in_([FINAL_STATUS, FINAL_SO_STATUS]),
|
|
185
|
+
filter_condition,
|
|
186
|
+
)
|
|
187
|
+
.group_by(GameRoster.human_id)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
for row in goals_assists_query.all():
|
|
191
|
+
if row.human_id in stats_dict:
|
|
192
|
+
stats_dict[row.human_id]["goals"] = row.goals
|
|
193
|
+
stats_dict[row.human_id]["assists"] = row.assists
|
|
194
|
+
stats_dict[row.human_id]["points"] = row.goals + row.assists
|
|
195
|
+
|
|
196
|
+
# Aggregate penalties
|
|
197
|
+
penalties_query = (
|
|
198
|
+
session.query(
|
|
199
|
+
GameRoster.human_id,
|
|
200
|
+
func.count(Penalty.id).label("penalties"),
|
|
201
|
+
func.sum(case((Penalty.penalty_minutes == "GM", 1), else_=0)).label("gm_penalties"),
|
|
202
|
+
)
|
|
203
|
+
.join(Game, Game.id == GameRoster.game_id)
|
|
204
|
+
.outerjoin(Penalty, and_(Game.id == Penalty.game_id, Penalty.penalized_player_id == GameRoster.human_id))
|
|
205
|
+
.filter(
|
|
206
|
+
GameRoster.team_id == team_id, # KEY: Filter by team
|
|
207
|
+
~GameRoster.role.ilike("g"),
|
|
208
|
+
GameRoster.human_id.in_(stats_dict.keys()),
|
|
209
|
+
Game.status.in_([FINAL_STATUS, FINAL_SO_STATUS, FORFEIT_STATUS, NOEVENTS_STATUS]),
|
|
210
|
+
filter_condition,
|
|
211
|
+
)
|
|
212
|
+
.group_by(GameRoster.human_id)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
for row in penalties_query.all():
|
|
216
|
+
if row.human_id in stats_dict:
|
|
217
|
+
stats_dict[row.human_id]["penalties"] = row.penalties
|
|
218
|
+
stats_dict[row.human_id]["gm_penalties"] = row.gm_penalties
|
|
219
|
+
|
|
220
|
+
# Calculate per-game averages
|
|
221
|
+
for human_id, stats in stats_dict.items():
|
|
222
|
+
games_with_stats = stats.get("games_with_stats", 0)
|
|
223
|
+
if games_with_stats > 0:
|
|
224
|
+
stats["goals_per_game"] = stats.get("goals", 0) / games_with_stats
|
|
225
|
+
stats["assists_per_game"] = stats.get("assists", 0) / games_with_stats
|
|
226
|
+
stats["points_per_game"] = stats.get("points", 0) / games_with_stats
|
|
227
|
+
stats["penalties_per_game"] = stats.get("penalties", 0) / games_with_stats
|
|
228
|
+
stats["gm_penalties_per_game"] = stats.get("gm_penalties", 0) / games_with_stats
|
|
229
|
+
else:
|
|
230
|
+
stats["goals_per_game"] = 0.0
|
|
231
|
+
stats["assists_per_game"] = 0.0
|
|
232
|
+
stats["points_per_game"] = 0.0
|
|
233
|
+
stats["penalties_per_game"] = 0.0
|
|
234
|
+
stats["gm_penalties_per_game"] = 0.0
|
|
235
|
+
|
|
236
|
+
# Insert stats for each player on this team
|
|
237
|
+
for human_id, stats in stats_dict.items():
|
|
238
|
+
skater_stat = StatsModel(
|
|
239
|
+
aggregation_id=aggregation_id,
|
|
240
|
+
team_id=team_id,
|
|
241
|
+
human_id=human_id,
|
|
242
|
+
games_played=stats.get("games_played", 0),
|
|
243
|
+
games_participated=stats.get("games_participated", 0),
|
|
244
|
+
games_with_stats=stats.get("games_with_stats", 0),
|
|
245
|
+
goals=stats.get("goals", 0),
|
|
246
|
+
assists=stats.get("assists", 0),
|
|
247
|
+
points=stats.get("points", 0),
|
|
248
|
+
penalties=stats.get("penalties", 0),
|
|
249
|
+
gm_penalties=stats.get("gm_penalties", 0),
|
|
250
|
+
goals_per_game=stats.get("goals_per_game", 0.0),
|
|
251
|
+
assists_per_game=stats.get("assists_per_game", 0.0),
|
|
252
|
+
points_per_game=stats.get("points_per_game", 0.0),
|
|
253
|
+
penalties_per_game=stats.get("penalties_per_game", 0.0),
|
|
254
|
+
gm_penalties_per_game=stats.get("gm_penalties_per_game", 0.0),
|
|
255
|
+
total_in_rank=len(stats_dict),
|
|
256
|
+
first_game_id=stats.get("first_game_id"),
|
|
257
|
+
last_game_id=stats.get("last_game_id"),
|
|
258
|
+
# Initialize streak fields to 0 (not calculated for team stats)
|
|
259
|
+
current_point_streak=0,
|
|
260
|
+
current_point_streak_avg_points=0.0,
|
|
261
|
+
# Ranks will be assigned later if needed
|
|
262
|
+
games_played_rank=0,
|
|
263
|
+
games_participated_rank=0,
|
|
264
|
+
games_with_stats_rank=0,
|
|
265
|
+
goals_rank=0,
|
|
266
|
+
assists_rank=0,
|
|
267
|
+
points_rank=0,
|
|
268
|
+
penalties_rank=0,
|
|
269
|
+
gm_penalties_rank=0,
|
|
270
|
+
goals_per_game_rank=0,
|
|
271
|
+
assists_per_game_rank=0,
|
|
272
|
+
points_per_game_rank=0,
|
|
273
|
+
penalties_per_game_rank=0,
|
|
274
|
+
gm_penalties_per_game_rank=0,
|
|
275
|
+
current_point_streak_rank=0,
|
|
276
|
+
current_point_streak_avg_points_rank=0,
|
|
277
|
+
aggregation_started_at=aggregation_start_time,
|
|
278
|
+
)
|
|
279
|
+
session.add(skater_stat)
|
|
280
|
+
|
|
281
|
+
session.commit()
|
|
282
|
+
|
|
283
|
+
# Update all records with completion timestamp
|
|
284
|
+
aggregation_end_time = datetime.utcnow()
|
|
285
|
+
session.query(StatsModel).filter(
|
|
286
|
+
StatsModel.aggregation_id == aggregation_id
|
|
287
|
+
).update({StatsModel.aggregation_completed_at: aggregation_end_time})
|
|
288
|
+
session.commit()
|
|
289
|
+
|
|
290
|
+
progress.finish()
|
|
291
|
+
print(f"✓ Team skater stats aggregation complete for {aggregation_name}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def run_aggregate_team_skater_stats():
|
|
295
|
+
"""
|
|
296
|
+
Run team skater stats aggregation for all organizations and divisions.
|
|
297
|
+
"""
|
|
298
|
+
from hockey_blast_common_lib.utils import get_all_division_ids_for_org
|
|
299
|
+
|
|
300
|
+
session = create_session("boss")
|
|
301
|
+
|
|
302
|
+
# Get all org_id present in the Organization table
|
|
303
|
+
org_ids = session.query(Organization.id).all()
|
|
304
|
+
org_ids = [org_id[0] for org_id in org_ids]
|
|
305
|
+
|
|
306
|
+
for org_id in org_ids:
|
|
307
|
+
# Aggregate for organization level
|
|
308
|
+
aggregate_team_skater_stats(session, "org", org_id)
|
|
309
|
+
|
|
310
|
+
# Aggregate for all divisions in this organization
|
|
311
|
+
division_ids = get_all_division_ids_for_org(session, org_id)
|
|
312
|
+
for division_id in division_ids:
|
|
313
|
+
aggregate_team_skater_stats(session, "division", division_id)
|
|
Binary file
|
|
@@ -53,7 +53,8 @@ class Game(db.Model):
|
|
|
53
53
|
time = db.Column(db.Time)
|
|
54
54
|
day_of_week = db.Column(db.Integer) # 1 to 7 for Monday to Sunday
|
|
55
55
|
period_length = db.Column(db.Integer) # In minutes
|
|
56
|
-
location = db.Column(db.String(100))
|
|
56
|
+
location = db.Column(db.String(100)) # DEPRECATED: Use location_id instead
|
|
57
|
+
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), nullable=True)
|
|
57
58
|
scorekeeper_id = db.Column(db.Integer, db.ForeignKey("humans.id"))
|
|
58
59
|
referee_1_id = db.Column(db.Integer, db.ForeignKey("humans.id"))
|
|
59
60
|
referee_2_id = db.Column(db.Integer, db.ForeignKey("humans.id"))
|
|
@@ -72,6 +73,7 @@ class Game(db.Model):
|
|
|
72
73
|
home_ot_score = db.Column(db.Integer, default=0)
|
|
73
74
|
visitor_ot_score = db.Column(db.Integer, default=0)
|
|
74
75
|
game_type = db.Column(db.String(50))
|
|
76
|
+
live_time = db.Column(db.String(50), nullable=True) # e.g., "Period 1, 1:10 left" for live games
|
|
75
77
|
went_to_ot = db.Column(db.Boolean, default=False)
|
|
76
78
|
home_period_1_shots = db.Column(db.Integer)
|
|
77
79
|
home_period_2_shots = db.Column(db.Integer)
|
|
@@ -245,6 +247,7 @@ class Level(db.Model):
|
|
|
245
247
|
org_id = db.Column(db.Integer, db.ForeignKey("organizations.id"), nullable=False)
|
|
246
248
|
skill_value = db.Column(db.Float) # A number from 0 (NHL) to 100 (pedestrian)
|
|
247
249
|
level_name = db.Column(db.String(100))
|
|
250
|
+
short_name = db.Column(db.String(50)) # Shortened display name (e.g., "D-7B-W" for "Adult Division 7B West")
|
|
248
251
|
level_alternative_name = db.Column(db.String(100))
|
|
249
252
|
is_seed = db.Column(db.Boolean, nullable=True, default=False) # New field
|
|
250
253
|
skill_propagation_sequence = db.Column(db.Integer, nullable=True, default=-1)
|
|
@@ -253,6 +256,17 @@ class Level(db.Model):
|
|
|
253
256
|
)
|
|
254
257
|
|
|
255
258
|
|
|
259
|
+
class Location(db.Model):
|
|
260
|
+
__tablename__ = "locations"
|
|
261
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
262
|
+
location_in_game_source = db.Column(db.String(200), nullable=False, unique=True) # Raw string from Game.location, e.g., "San Jose Orange (N)"
|
|
263
|
+
location_name = db.Column(db.String(200), nullable=True) # Optional: Facility name, e.g., "Sharks Ice At San Jose"
|
|
264
|
+
rink_name = db.Column(db.String(200), nullable=True) # Optional: Specific rink, e.g., "Orange (N)"
|
|
265
|
+
address = db.Column(db.String(500), nullable=True)
|
|
266
|
+
google_maps_link = db.Column(db.String(500), nullable=True)
|
|
267
|
+
master_location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), nullable=True) # Points to the canonical location for this rink
|
|
268
|
+
|
|
269
|
+
|
|
256
270
|
class LevelsMonthly(db.Model):
|
|
257
271
|
__tablename__ = "levels_monthly"
|
|
258
272
|
id = db.Column(db.Integer, primary_key=True)
|
|
@@ -427,6 +441,7 @@ class Season(db.Model):
|
|
|
427
441
|
id = db.Column(db.Integer, primary_key=True)
|
|
428
442
|
season_number = db.Column(db.Integer)
|
|
429
443
|
season_name = db.Column(db.String(100))
|
|
444
|
+
base_season_name = db.Column(db.String(100)) # Static prefix for season name (e.g., "Silver Stick", "Over", etc.)
|
|
430
445
|
start_date = db.Column(db.Date)
|
|
431
446
|
end_date = db.Column(db.Date)
|
|
432
447
|
league_number = db.Column(
|
|
@@ -4,7 +4,13 @@ from sqlalchemy.orm import synonym
|
|
|
4
4
|
from hockey_blast_common_lib.models import db
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
class
|
|
7
|
+
class AggregationTimestampMixin:
|
|
8
|
+
"""Mixin to add aggregation timestamp tracking to all stats models."""
|
|
9
|
+
aggregation_started_at = db.Column(db.DateTime, nullable=True)
|
|
10
|
+
aggregation_completed_at = db.Column(db.DateTime, nullable=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseStatsHuman(AggregationTimestampMixin, db.Model):
|
|
8
14
|
__abstract__ = True
|
|
9
15
|
id = db.Column(db.Integer, primary_key=True)
|
|
10
16
|
human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False)
|
|
@@ -92,7 +98,7 @@ class BaseStatsHuman(db.Model):
|
|
|
92
98
|
)
|
|
93
99
|
|
|
94
100
|
|
|
95
|
-
class BaseStatsSkater(db.Model):
|
|
101
|
+
class BaseStatsSkater(AggregationTimestampMixin, db.Model):
|
|
96
102
|
__abstract__ = True
|
|
97
103
|
id = db.Column(db.Integer, primary_key=True)
|
|
98
104
|
human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False)
|
|
@@ -193,7 +199,7 @@ class BaseStatsSkater(db.Model):
|
|
|
193
199
|
)
|
|
194
200
|
|
|
195
201
|
|
|
196
|
-
class BaseStatsGoalie(db.Model):
|
|
202
|
+
class BaseStatsGoalie(AggregationTimestampMixin, db.Model):
|
|
197
203
|
__abstract__ = True
|
|
198
204
|
id = db.Column(db.Integer, primary_key=True)
|
|
199
205
|
human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False)
|
|
@@ -261,7 +267,7 @@ class BaseStatsGoalie(db.Model):
|
|
|
261
267
|
)
|
|
262
268
|
|
|
263
269
|
|
|
264
|
-
class BaseStatsReferee(db.Model):
|
|
270
|
+
class BaseStatsReferee(AggregationTimestampMixin, db.Model):
|
|
265
271
|
__abstract__ = True
|
|
266
272
|
id = db.Column(db.Integer, primary_key=True)
|
|
267
273
|
human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False)
|
|
@@ -331,7 +337,7 @@ class BaseStatsReferee(db.Model):
|
|
|
331
337
|
)
|
|
332
338
|
|
|
333
339
|
|
|
334
|
-
class BaseStatsScorekeeper(db.Model):
|
|
340
|
+
class BaseStatsScorekeeper(AggregationTimestampMixin, db.Model):
|
|
335
341
|
__abstract__ = True
|
|
336
342
|
id = db.Column(db.Integer, primary_key=True)
|
|
337
343
|
human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False)
|
|
@@ -922,3 +928,203 @@ class SkillValuePPGRatio(db.Model):
|
|
|
922
928
|
"from_skill_value", "to_skill_value", name="_from_to_skill_value_uc"
|
|
923
929
|
),
|
|
924
930
|
)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
# Team-based statistics models (inherit from existing bases, add team_id field)
|
|
934
|
+
class OrgStatsSkaterTeam(BaseStatsSkater):
|
|
935
|
+
__tablename__ = "org_stats_skater_team"
|
|
936
|
+
org_id = db.Column(db.Integer, db.ForeignKey("organizations.id"), nullable=False)
|
|
937
|
+
team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False)
|
|
938
|
+
aggregation_id = synonym("org_id")
|
|
939
|
+
|
|
940
|
+
@declared_attr
|
|
941
|
+
def aggregation_type(cls):
|
|
942
|
+
return "org_team"
|
|
943
|
+
|
|
944
|
+
@classmethod
|
|
945
|
+
def get_aggregation_column(cls):
|
|
946
|
+
return "org_id"
|
|
947
|
+
|
|
948
|
+
@declared_attr
|
|
949
|
+
def __table_args__(cls):
|
|
950
|
+
return (
|
|
951
|
+
db.UniqueConstraint(
|
|
952
|
+
"org_id",
|
|
953
|
+
"team_id",
|
|
954
|
+
"human_id",
|
|
955
|
+
name="_org_team_human_uc_skater_team1",
|
|
956
|
+
),
|
|
957
|
+
db.Index("idx_org_team_team_id", "team_id"),
|
|
958
|
+
db.Index("idx_org_team_human_id", "human_id"),
|
|
959
|
+
db.Index("idx_org_team_goals_per_game", "org_id", "goals_per_game"),
|
|
960
|
+
db.Index("idx_org_team_points_per_game", "org_id", "points_per_game"),
|
|
961
|
+
db.Index("idx_org_team_assists_per_game", "org_id", "assists_per_game"),
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
class DivisionStatsSkaterTeam(BaseStatsSkater):
|
|
966
|
+
__tablename__ = "division_stats_skater_team"
|
|
967
|
+
division_id = db.Column(db.Integer, db.ForeignKey("divisions.id"), nullable=False)
|
|
968
|
+
team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False)
|
|
969
|
+
aggregation_id = synonym("division_id")
|
|
970
|
+
|
|
971
|
+
@declared_attr
|
|
972
|
+
def aggregation_type(cls):
|
|
973
|
+
return "division_team"
|
|
974
|
+
|
|
975
|
+
@classmethod
|
|
976
|
+
def get_aggregation_column(cls):
|
|
977
|
+
return "division_id"
|
|
978
|
+
|
|
979
|
+
@declared_attr
|
|
980
|
+
def __table_args__(cls):
|
|
981
|
+
return (
|
|
982
|
+
db.UniqueConstraint(
|
|
983
|
+
"division_id",
|
|
984
|
+
"team_id",
|
|
985
|
+
"human_id",
|
|
986
|
+
name="_division_team_human_uc_skater_team1",
|
|
987
|
+
),
|
|
988
|
+
db.Index("idx_division_team_team_id", "team_id"),
|
|
989
|
+
db.Index("idx_division_team_human_id", "human_id"),
|
|
990
|
+
db.Index("idx_division_team_goals_per_game", "division_id", "goals_per_game"),
|
|
991
|
+
db.Index("idx_division_team_points_per_game", "division_id", "points_per_game"),
|
|
992
|
+
db.Index("idx_division_team_assists_per_game", "division_id", "assists_per_game"),
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
class OrgStatsGoalieTeam(BaseStatsGoalie):
|
|
997
|
+
__tablename__ = "org_stats_goalie_team"
|
|
998
|
+
org_id = db.Column(db.Integer, db.ForeignKey("organizations.id"), nullable=False)
|
|
999
|
+
team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False)
|
|
1000
|
+
aggregation_id = synonym("org_id")
|
|
1001
|
+
|
|
1002
|
+
@declared_attr
|
|
1003
|
+
def aggregation_type(cls):
|
|
1004
|
+
return "org_team"
|
|
1005
|
+
|
|
1006
|
+
@classmethod
|
|
1007
|
+
def get_aggregation_column(cls):
|
|
1008
|
+
return "org_id"
|
|
1009
|
+
|
|
1010
|
+
@declared_attr
|
|
1011
|
+
def __table_args__(cls):
|
|
1012
|
+
return (
|
|
1013
|
+
db.UniqueConstraint(
|
|
1014
|
+
"org_id",
|
|
1015
|
+
"team_id",
|
|
1016
|
+
"human_id",
|
|
1017
|
+
name="_org_team_human_uc_goalie_team1",
|
|
1018
|
+
),
|
|
1019
|
+
db.Index("idx_org_team_goalie_team_id", "team_id"),
|
|
1020
|
+
db.Index("idx_org_team_goalie_human_id", "human_id"),
|
|
1021
|
+
db.Index("idx_org_team_goalie_save_pct", "org_id", "save_percentage"),
|
|
1022
|
+
db.Index("idx_org_team_goalie_gaa", "org_id", "goals_allowed_per_game"),
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
class DivisionStatsGoalieTeam(BaseStatsGoalie):
|
|
1027
|
+
__tablename__ = "division_stats_goalie_team"
|
|
1028
|
+
division_id = db.Column(db.Integer, db.ForeignKey("divisions.id"), nullable=False)
|
|
1029
|
+
team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False)
|
|
1030
|
+
aggregation_id = synonym("division_id")
|
|
1031
|
+
|
|
1032
|
+
@declared_attr
|
|
1033
|
+
def aggregation_type(cls):
|
|
1034
|
+
return "division_team"
|
|
1035
|
+
|
|
1036
|
+
@classmethod
|
|
1037
|
+
def get_aggregation_column(cls):
|
|
1038
|
+
return "division_id"
|
|
1039
|
+
|
|
1040
|
+
@declared_attr
|
|
1041
|
+
def __table_args__(cls):
|
|
1042
|
+
return (
|
|
1043
|
+
db.UniqueConstraint(
|
|
1044
|
+
"division_id",
|
|
1045
|
+
"team_id",
|
|
1046
|
+
"human_id",
|
|
1047
|
+
name="_division_team_human_uc_goalie_team1",
|
|
1048
|
+
),
|
|
1049
|
+
db.Index("idx_division_team_goalie_team_id", "team_id"),
|
|
1050
|
+
db.Index("idx_division_team_goalie_human_id", "human_id"),
|
|
1051
|
+
db.Index("idx_division_team_goalie_save_pct", "division_id", "save_percentage"),
|
|
1052
|
+
db.Index("idx_division_team_goalie_gaa", "division_id", "goals_allowed_per_game"),
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
# Per-Game Statistics Models (for RAG system)
|
|
1057
|
+
# These models store individual game performance data for each player/goalie
|
|
1058
|
+
# CRITICAL: Only non-zero rows are saved (games where player recorded stats)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
class GameStatsSkater(db.Model):
|
|
1062
|
+
"""Per-game skater statistics.
|
|
1063
|
+
|
|
1064
|
+
Stores individual game performance for skaters.
|
|
1065
|
+
Only records where player had non-zero stats are saved.
|
|
1066
|
+
Optimized for queries like "show me top N games by points for player X".
|
|
1067
|
+
"""
|
|
1068
|
+
__tablename__ = "game_stats_skater"
|
|
1069
|
+
|
|
1070
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
1071
|
+
game_id = db.Column(db.Integer, db.ForeignKey("games.id"), nullable=False, index=True)
|
|
1072
|
+
human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False, index=True)
|
|
1073
|
+
team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False, index=True)
|
|
1074
|
+
org_id = db.Column(db.Integer, db.ForeignKey("organizations.id"), nullable=False, index=True)
|
|
1075
|
+
level_id = db.Column(db.Integer, db.ForeignKey("levels.id"), nullable=False, index=True)
|
|
1076
|
+
|
|
1077
|
+
# Denormalized game metadata for sorting/filtering
|
|
1078
|
+
game_date = db.Column(db.Date, nullable=False, index=True)
|
|
1079
|
+
game_time = db.Column(db.Time, nullable=False)
|
|
1080
|
+
|
|
1081
|
+
# Performance stats
|
|
1082
|
+
goals = db.Column(db.Integer, default=0, nullable=False)
|
|
1083
|
+
assists = db.Column(db.Integer, default=0, nullable=False)
|
|
1084
|
+
points = db.Column(db.Integer, default=0, nullable=False)
|
|
1085
|
+
penalty_minutes = db.Column(db.Integer, default=0, nullable=False)
|
|
1086
|
+
|
|
1087
|
+
# Tracking
|
|
1088
|
+
created_at = db.Column(db.DateTime, nullable=False, default=db.func.current_timestamp())
|
|
1089
|
+
|
|
1090
|
+
__table_args__ = (
|
|
1091
|
+
db.UniqueConstraint("game_id", "human_id", name="_game_human_uc_skater"),
|
|
1092
|
+
db.Index("idx_game_stats_skater_human_date", "human_id", "game_date", postgresql_using="btree"),
|
|
1093
|
+
db.Index("idx_game_stats_skater_human_team_date", "human_id", "team_id", "game_date", postgresql_using="btree"),
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
class GameStatsGoalie(db.Model):
|
|
1098
|
+
"""Per-game goalie statistics.
|
|
1099
|
+
|
|
1100
|
+
Stores individual game performance for goalies.
|
|
1101
|
+
Only records where goalie faced shots are saved.
|
|
1102
|
+
Optimized for queries like "show me top N games by save% for goalie X".
|
|
1103
|
+
"""
|
|
1104
|
+
__tablename__ = "game_stats_goalie"
|
|
1105
|
+
|
|
1106
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
1107
|
+
game_id = db.Column(db.Integer, db.ForeignKey("games.id"), nullable=False, index=True)
|
|
1108
|
+
human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False, index=True)
|
|
1109
|
+
team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False, index=True)
|
|
1110
|
+
org_id = db.Column(db.Integer, db.ForeignKey("organizations.id"), nullable=False, index=True)
|
|
1111
|
+
level_id = db.Column(db.Integer, db.ForeignKey("levels.id"), nullable=False, index=True)
|
|
1112
|
+
|
|
1113
|
+
# Denormalized game metadata for sorting/filtering
|
|
1114
|
+
game_date = db.Column(db.Date, nullable=False, index=True)
|
|
1115
|
+
game_time = db.Column(db.Time, nullable=False)
|
|
1116
|
+
|
|
1117
|
+
# Performance stats
|
|
1118
|
+
goals_allowed = db.Column(db.Integer, default=0, nullable=False)
|
|
1119
|
+
shots_faced = db.Column(db.Integer, default=0, nullable=False)
|
|
1120
|
+
saves = db.Column(db.Integer, default=0, nullable=False) # Computed: shots_faced - goals_allowed
|
|
1121
|
+
save_percentage = db.Column(db.Float, default=0.0, nullable=False) # Computed: saves / shots_faced
|
|
1122
|
+
|
|
1123
|
+
# Tracking
|
|
1124
|
+
created_at = db.Column(db.DateTime, nullable=False, default=db.func.current_timestamp())
|
|
1125
|
+
|
|
1126
|
+
__table_args__ = (
|
|
1127
|
+
db.UniqueConstraint("game_id", "human_id", "team_id", name="_game_human_uc_goalie"),
|
|
1128
|
+
db.Index("idx_game_stats_goalie_human_date", "human_id", "game_date", postgresql_using="btree"),
|
|
1129
|
+
db.Index("idx_game_stats_goalie_human_team_date", "human_id", "team_id", "game_date", postgresql_using="btree"),
|
|
1130
|
+
)
|