hockey-blast-common-lib 0.1.50__py3-none-any.whl → 0.1.53__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.
@@ -16,6 +16,53 @@ from hockey_blast_common_lib.utils import get_start_datetime
16
16
  from sqlalchemy import func, case, and_
17
17
  from collections import defaultdict
18
18
  from hockey_blast_common_lib.stats_utils import ALL_ORGS_ID
19
+ from hockey_blast_common_lib.progress_utils import create_progress_tracker
20
+
21
+ def calculate_current_point_streak(session, human_id, filter_condition):
22
+ """
23
+ Calculate the current point streak for a player.
24
+ A point streak is consecutive games (from the most recent game backward) where the player had at least one point.
25
+ Returns a tuple: (streak_length, average_points_during_streak)
26
+ """
27
+ # Get all games for this player ordered by date/time descending (most recent first)
28
+ games = session.query(Game).join(GameRoster, Game.id == GameRoster.game_id).filter(
29
+ GameRoster.human_id == human_id,
30
+ ~GameRoster.role.ilike('g'), # Exclude goalie games
31
+ filter_condition,
32
+ Game.status.like('Final%') # Only final games
33
+ ).order_by(Game.date.desc(), Game.time.desc()).all()
34
+
35
+ if not games:
36
+ return 0, 0.0
37
+
38
+ current_streak = 0
39
+ total_points_in_streak = 0
40
+
41
+ for game in games:
42
+ # Check if the player had any points in this game
43
+ goals = session.query(Goal).filter(
44
+ Goal.game_id == game.id,
45
+ Goal.goal_scorer_id == human_id
46
+ ).count()
47
+
48
+ assists = session.query(Goal).filter(
49
+ Goal.game_id == game.id,
50
+ ((Goal.assist_1_id == human_id) | (Goal.assist_2_id == human_id))
51
+ ).count()
52
+
53
+ total_points = goals + assists
54
+
55
+ if total_points > 0:
56
+ current_streak += 1
57
+ total_points_in_streak += total_points
58
+ else:
59
+ # Streak is broken, stop counting
60
+ break
61
+
62
+ # Calculate average points during streak
63
+ avg_points_during_streak = total_points_in_streak / current_streak if current_streak > 0 else 0.0
64
+
65
+ return current_streak, avg_points_during_streak
19
66
 
20
67
  def aggregate_skater_stats(session, aggregation_type, aggregation_id, names_to_filter_out, debug_human_id=None, aggregation_window=None):
21
68
  human_ids_to_filter = get_human_ids_by_names(session, names_to_filter_out)
@@ -57,7 +104,12 @@ def aggregate_skater_stats(session, aggregation_type, aggregation_id, names_to_f
57
104
  elif aggregation_type == 'level':
58
105
  StatsModel = LevelStatsSkater
59
106
  min_games = MIN_GAMES_FOR_LEVEL_STATS
60
- filter_condition = Division.level_id == aggregation_id
107
+ # Get division IDs for this level to avoid cartesian product
108
+ division_ids = session.query(Division.id).filter(Division.level_id == aggregation_id).all()
109
+ division_ids = [div_id[0] for div_id in division_ids]
110
+ if not division_ids:
111
+ return # No divisions for this level
112
+ filter_condition = Game.division_id.in_(division_ids)
61
113
  # Add filter to only include games for the last 5 years
62
114
  # five_years_ago = datetime.now() - timedelta(days=5*365)
63
115
  # level_window_filter = func.cast(func.concat(Game.date, ' ', Game.time), sqlalchemy.types.TIMESTAMP) >= five_years_ago
@@ -86,39 +138,65 @@ def aggregate_skater_stats(session, aggregation_type, aggregation_id, names_to_f
86
138
  # human_filter = [GameRoster.human_id == debug_human_id]
87
139
 
88
140
  # Aggregate games played for each human in each division, excluding goalies
89
- games_played_stats = session.query(
141
+ games_played_query = session.query(
90
142
  GameRoster.human_id,
91
143
  func.count(Game.id).label('games_played'),
92
144
  func.array_agg(Game.id).label('game_ids')
93
- ).join(Game, Game.id == GameRoster.game_id).join(Division, Game.division_id == Division.id).filter(filter_condition, ~GameRoster.role.ilike('g'), *human_filter).group_by(GameRoster.human_id).all()
145
+ ).join(Game, Game.id == GameRoster.game_id)
146
+
147
+ # Only join Division if not level aggregation (since we filter on Game.division_id directly for levels)
148
+ if aggregation_type != 'level':
149
+ games_played_query = games_played_query.join(Division, Game.division_id == Division.id)
150
+
151
+ games_played_stats = games_played_query.filter(filter_condition, ~GameRoster.role.ilike('g'), *human_filter).group_by(GameRoster.human_id).all()
94
152
 
95
153
  # Aggregate goals for each human in each division, excluding goalies
96
- goals_stats = session.query(
154
+ goals_query = session.query(
97
155
  Goal.goal_scorer_id.label('human_id'),
98
156
  func.count(Goal.id).label('goals'),
99
157
  func.array_agg(Goal.game_id).label('goal_game_ids')
100
- ).join(Game, Game.id == Goal.game_id).join(GameRoster, and_(Game.id == GameRoster.game_id, Goal.goal_scorer_id == GameRoster.human_id)).join(Division, Game.division_id == Division.id).filter(filter_condition, ~GameRoster.role.ilike('g'), *human_filter).group_by(Goal.goal_scorer_id).all()
158
+ ).join(Game, Game.id == Goal.game_id).join(GameRoster, and_(Game.id == GameRoster.game_id, Goal.goal_scorer_id == GameRoster.human_id))
159
+
160
+ if aggregation_type != 'level':
161
+ goals_query = goals_query.join(Division, Game.division_id == Division.id)
162
+
163
+ goals_stats = goals_query.filter(filter_condition, ~GameRoster.role.ilike('g'), *human_filter).group_by(Goal.goal_scorer_id).all()
101
164
 
102
165
  # Aggregate assists for each human in each division, excluding goalies
103
- assists_stats = session.query(
166
+ assists_query = session.query(
104
167
  Goal.assist_1_id.label('human_id'),
105
168
  func.count(Goal.id).label('assists'),
106
169
  func.array_agg(Goal.game_id).label('assist_game_ids')
107
- ).join(Game, Game.id == Goal.game_id).join(GameRoster, and_(Game.id == GameRoster.game_id, Goal.assist_1_id == GameRoster.human_id)).join(Division, Game.division_id == Division.id).filter(filter_condition, ~GameRoster.role.ilike('g'), *human_filter).group_by(Goal.assist_1_id).all()
108
-
109
- assists_stats_2 = session.query(
170
+ ).join(Game, Game.id == Goal.game_id).join(GameRoster, and_(Game.id == GameRoster.game_id, Goal.assist_1_id == GameRoster.human_id))
171
+
172
+ if aggregation_type != 'level':
173
+ assists_query = assists_query.join(Division, Game.division_id == Division.id)
174
+
175
+ assists_stats = assists_query.filter(filter_condition, ~GameRoster.role.ilike('g'), *human_filter).group_by(Goal.assist_1_id).all()
176
+
177
+ assists_query_2 = session.query(
110
178
  Goal.assist_2_id.label('human_id'),
111
179
  func.count(Goal.id).label('assists'),
112
180
  func.array_agg(Goal.game_id).label('assist_2_game_ids')
113
- ).join(Game, Game.id == Goal.game_id).join(GameRoster, and_(Game.id == GameRoster.game_id, Goal.assist_2_id == GameRoster.human_id)).join(Division, Game.division_id == Division.id).filter(filter_condition, ~GameRoster.role.ilike('g'), *human_filter).group_by(Goal.assist_2_id).all()
181
+ ).join(Game, Game.id == Goal.game_id).join(GameRoster, and_(Game.id == GameRoster.game_id, Goal.assist_2_id == GameRoster.human_id))
182
+
183
+ if aggregation_type != 'level':
184
+ assists_query_2 = assists_query_2.join(Division, Game.division_id == Division.id)
185
+
186
+ assists_stats_2 = assists_query_2.filter(filter_condition, ~GameRoster.role.ilike('g'), *human_filter).group_by(Goal.assist_2_id).all()
114
187
 
115
188
  # Aggregate penalties for each human in each division, excluding goalies
116
- penalties_stats = session.query(
189
+ penalties_query = session.query(
117
190
  Penalty.penalized_player_id.label('human_id'),
118
191
  func.count(Penalty.id).label('penalties'),
119
192
  func.sum(case((Penalty.penalty_minutes == 'GM', 1), else_=0)).label('gm_penalties'), # New aggregation for GM penalties
120
193
  func.array_agg(Penalty.game_id).label('penalty_game_ids')
121
- ).join(Game, Game.id == Penalty.game_id).join(GameRoster, and_(Game.id == GameRoster.game_id, Penalty.penalized_player_id == GameRoster.human_id)).join(Division, Game.division_id == Division.id).filter(filter_condition, ~GameRoster.role.ilike('g'), *human_filter).group_by(Penalty.penalized_player_id).all()
194
+ ).join(Game, Game.id == Penalty.game_id).join(GameRoster, and_(Game.id == GameRoster.game_id, Penalty.penalized_player_id == GameRoster.human_id))
195
+
196
+ if aggregation_type != 'level':
197
+ penalties_query = penalties_query.join(Division, Game.division_id == Division.id)
198
+
199
+ penalties_stats = penalties_query.filter(filter_condition, ~GameRoster.role.ilike('g'), *human_filter).group_by(Penalty.penalized_player_id).all()
122
200
 
123
201
  # Combine the results
124
202
  stats_dict = {}
@@ -139,6 +217,8 @@ def aggregate_skater_stats(session, aggregation_type, aggregation_id, names_to_f
139
217
  'assists_per_game': 0.0,
140
218
  'penalties_per_game': 0.0,
141
219
  'gm_penalties_per_game': 0.0, # Initialize GM penalties per game
220
+ 'current_point_streak': 0, # Initialize current point streak
221
+ 'current_point_streak_avg_points': 0.0, # Initialize current point streak average points
142
222
  'game_ids': [],
143
223
  'first_game_id': None,
144
224
  'last_game_id': None
@@ -194,6 +274,14 @@ def aggregate_skater_stats(session, aggregation_type, aggregation_id, names_to_f
194
274
  stat['first_game_id'] = first_game.id if first_game else None
195
275
  stat['last_game_id'] = last_game.id if last_game else None
196
276
 
277
+ # Calculate current point streak (only for all-time stats)
278
+ if aggregation_window is None:
279
+ for key, stat in stats_dict.items():
280
+ aggregation_id, human_id = key
281
+ streak_length, avg_points = calculate_current_point_streak(session, human_id, filter_condition)
282
+ stat['current_point_streak'] = streak_length
283
+ stat['current_point_streak_avg_points'] = avg_points
284
+
197
285
  # Calculate total_in_rank
198
286
  total_in_rank = len(stats_dict)
199
287
 
@@ -214,6 +302,9 @@ def aggregate_skater_stats(session, aggregation_type, aggregation_id, names_to_f
214
302
  assign_ranks(stats_dict, 'assists_per_game')
215
303
  assign_ranks(stats_dict, 'penalties_per_game')
216
304
  assign_ranks(stats_dict, 'gm_penalties_per_game') # Assign ranks for GM penalties per game
305
+ if aggregation_window is None: # Only assign current_point_streak ranks for all-time stats
306
+ assign_ranks(stats_dict, 'current_point_streak')
307
+ assign_ranks(stats_dict, 'current_point_streak_avg_points')
217
308
 
218
309
  # Debug output for specific human
219
310
  if debug_human_id:
@@ -262,6 +353,10 @@ def aggregate_skater_stats(session, aggregation_type, aggregation_id, names_to_f
262
353
  penalties_per_game_rank=stat['penalties_per_game_rank'],
263
354
  gm_penalties_per_game_rank=stat['gm_penalties_per_game_rank'], # Include GM penalties per game rank
264
355
  total_in_rank=total_in_rank,
356
+ current_point_streak=stat.get('current_point_streak', 0),
357
+ current_point_streak_rank=stat.get('current_point_streak_rank', 0),
358
+ current_point_streak_avg_points=stat.get('current_point_streak_avg_points', 0.0),
359
+ current_point_streak_avg_points_rank=stat.get('current_point_streak_avg_points_rank', 0),
265
360
  first_game_id=stat['first_game_id'],
266
361
  last_game_id=stat['last_game_id']
267
362
  )
@@ -281,33 +376,51 @@ def run_aggregate_skater_stats():
281
376
 
282
377
  for org_id in org_ids:
283
378
  division_ids = get_all_division_ids_for_org(session, org_id)
284
- print(f"Aggregating skater stats for {len(division_ids)} divisions in org_id {org_id}...")
285
- total_divisions = len(division_ids)
286
- processed_divisions = 0
287
- for division_id in division_ids:
288
- aggregate_skater_stats(session, aggregation_type='division', aggregation_id=division_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug)
289
- aggregate_skater_stats(session, aggregation_type='division', aggregation_id=division_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Weekly')
290
- aggregate_skater_stats(session, aggregation_type='division', aggregation_id=division_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Daily')
291
- processed_divisions += 1
292
- if human_id_to_debug is None:
293
- print(f"\rProcessed {processed_divisions}/{total_divisions} divisions ({(processed_divisions/total_divisions)*100:.2f}%)", end="")
294
-
295
- aggregate_skater_stats(session, aggregation_type='org', aggregation_id=org_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug)
296
- aggregate_skater_stats(session, aggregation_type='org', aggregation_id=org_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Weekly')
297
- aggregate_skater_stats(session, aggregation_type='org', aggregation_id=org_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Daily')
379
+ org_name = session.query(Organization.organization_name).filter(Organization.id == org_id).scalar() or f"org_id {org_id}"
380
+
381
+ if human_id_to_debug is None and division_ids:
382
+ # Process divisions with progress tracking
383
+ progress = create_progress_tracker(len(division_ids), f"Processing {len(division_ids)} divisions for {org_name}")
384
+ for i, division_id in enumerate(division_ids):
385
+ aggregate_skater_stats(session, aggregation_type='division', aggregation_id=division_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug)
386
+ aggregate_skater_stats(session, aggregation_type='division', aggregation_id=division_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Weekly')
387
+ aggregate_skater_stats(session, aggregation_type='division', aggregation_id=division_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Daily')
388
+ progress.update(i + 1)
389
+ else:
390
+ # Debug mode or no divisions - process without progress tracking
391
+ for division_id in division_ids:
392
+ aggregate_skater_stats(session, aggregation_type='division', aggregation_id=division_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug)
393
+ aggregate_skater_stats(session, aggregation_type='division', aggregation_id=division_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Weekly')
394
+ aggregate_skater_stats(session, aggregation_type='division', aggregation_id=division_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Daily')
395
+
396
+ # Process org-level stats with progress tracking
397
+ if human_id_to_debug is None:
398
+ org_progress = create_progress_tracker(3, f"Processing org-level stats for {org_name}")
399
+ aggregate_skater_stats(session, aggregation_type='org', aggregation_id=org_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug)
400
+ org_progress.update(1)
401
+ aggregate_skater_stats(session, aggregation_type='org', aggregation_id=org_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Weekly')
402
+ org_progress.update(2)
403
+ aggregate_skater_stats(session, aggregation_type='org', aggregation_id=org_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Daily')
404
+ org_progress.update(3)
405
+ else:
406
+ aggregate_skater_stats(session, aggregation_type='org', aggregation_id=org_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug)
407
+ aggregate_skater_stats(session, aggregation_type='org', aggregation_id=org_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Weekly')
408
+ aggregate_skater_stats(session, aggregation_type='org', aggregation_id=org_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug, aggregation_window='Daily')
298
409
 
299
410
  # Aggregate by level
300
411
  level_ids = session.query(Division.level_id).distinct().all()
301
- level_ids = [level_id[0] for level_id in level_ids]
302
- total_levels = len(level_ids)
303
- processed_levels = 0
304
- for level_id in level_ids:
305
- if level_id is None:
306
- continue
307
- if human_id_to_debug is None:
308
- print(f"\rProcessed {processed_levels}/{total_levels} levels ({(processed_levels/total_levels)*100:.2f}%)", end="")
309
- processed_levels += 1
310
- aggregate_skater_stats(session, aggregation_type='level', aggregation_id=level_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug)
412
+ level_ids = [level_id[0] for level_id in level_ids if level_id[0] is not None]
413
+
414
+ if human_id_to_debug is None and level_ids:
415
+ # Process levels with progress tracking
416
+ level_progress = create_progress_tracker(len(level_ids), f"Processing {len(level_ids)} skill levels")
417
+ for i, level_id in enumerate(level_ids):
418
+ aggregate_skater_stats(session, aggregation_type='level', aggregation_id=level_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug)
419
+ level_progress.update(i + 1)
420
+ else:
421
+ # Debug mode or no levels - process without progress tracking
422
+ for level_id in level_ids:
423
+ aggregate_skater_stats(session, aggregation_type='level', aggregation_id=level_id, names_to_filter_out=not_human_names, debug_human_id=human_id_to_debug)
311
424
 
312
425
  if __name__ == "__main__":
313
426
  run_aggregate_skater_stats()
@@ -6,43 +6,53 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
6
6
  from hockey_blast_common_lib.models import Human, Level
7
7
  from hockey_blast_common_lib.stats_models import LevelStatsSkater
8
8
  from hockey_blast_common_lib.db_connection import create_session
9
- from sqlalchemy.sql import func
9
+ from hockey_blast_common_lib.progress_utils import create_progress_tracker
10
10
 
11
- def calculate_skater_skill_value(session, human_id, level_stats):
12
- max_skill_value = 0
11
+ def calculate_skater_skill_value(session, level_stats):
12
+ min_skill_value = float('inf') # Start with infinity since we want the minimum
13
13
 
14
14
  for stat in level_stats:
15
15
  level = session.query(Level).filter(Level.id == stat.level_id).first()
16
16
  if not level or level.skill_value < 0:
17
17
  continue
18
18
  level_skill_value = level.skill_value
19
- ppg_ratio = stat.points_per_game_rank / stat.total_in_rank
20
- games_played_ratio = stat.games_played_rank / stat.total_in_rank
21
-
22
- # Take the maximum of the two ratios
23
- skill_value = level_skill_value * max(ppg_ratio, games_played_ratio)
24
- max_skill_value = max(max_skill_value, skill_value)
25
-
26
- return max_skill_value
19
+
20
+ # Fix critical bug: Invert rank ratios so better players (lower ranks) get higher skill values
21
+ # Rank 1 (best) should get factor close to 1.0, worst rank should get factor close to 0.0
22
+ if stat.total_in_rank > 1:
23
+ ppg_skill_factor = 1 - (stat.points_per_game_rank - 1) / (stat.total_in_rank - 1)
24
+ else:
25
+ ppg_skill_factor = 1.0 # Only one player in level
26
+
27
+ # Apply skill adjustment: range from 0.8 to 1.2 of level base skill
28
+ # Since lower skill_value is better: Best player gets 0.8x (closer to better levels), worst gets 1.2x
29
+ skill_adjustment = 1.2 - 0.4 * ppg_skill_factor
30
+ skill_value = level_skill_value * skill_adjustment
31
+
32
+ # Take the minimum skill value across all levels the player has played in (lower is better)
33
+ min_skill_value = min(min_skill_value, skill_value)
34
+
35
+ return min_skill_value if min_skill_value != float('inf') else 0
27
36
 
28
37
  def assign_skater_skill_values():
29
38
  session = create_session("boss")
30
39
 
31
40
  humans = session.query(Human).all()
32
41
  total_humans = len(humans)
33
- processed_humans = 0
42
+
43
+ # Create progress tracker
44
+ progress = create_progress_tracker(total_humans, "Assigning skater skill values")
34
45
 
35
- for human in humans:
46
+ for i, human in enumerate(humans):
36
47
  level_stats = session.query(LevelStatsSkater).filter(LevelStatsSkater.human_id == human.id).all()
37
48
  if level_stats:
38
- skater_skill_value = calculate_skater_skill_value(session, human.id, level_stats)
49
+ skater_skill_value = calculate_skater_skill_value(session, level_stats)
39
50
  human.skater_skill_value = skater_skill_value
40
51
  session.commit()
41
52
 
42
- processed_humans += 1
43
- print(f"\rProcessed {processed_humans}/{total_humans} humans ({(processed_humans/total_humans)*100:.2f}%)", end="")
53
+ progress.update(i + 1)
44
54
 
45
- print("\nSkater skill values have been assigned to all humans.")
55
+ print("Skater skill values have been assigned to all humans.")
46
56
 
47
57
  if __name__ == "__main__":
48
58
  assign_skater_skill_values()
@@ -0,0 +1,107 @@
1
+ from hockey_blast_common_lib.models import db
2
+
3
+ class H2HStats(db.Model):
4
+ __tablename__ = 'h2h_stats'
5
+ id = db.Column(db.Integer, primary_key=True)
6
+ human1_id = db.Column(db.Integer, db.ForeignKey('humans.id'), nullable=False)
7
+ human2_id = db.Column(db.Integer, db.ForeignKey('humans.id'), nullable=False)
8
+ # Always store with human1_id < human2_id for uniqueness
9
+ __table_args__ = (
10
+ db.UniqueConstraint('human1_id', 'human2_id', name='_h2h_human_pair_uc'),
11
+ db.Index('ix_h2h_human_pair', 'human1_id', 'human2_id'), # Composite index for fast lookup
12
+ )
13
+
14
+ # General
15
+ games_together = db.Column(db.Integer, default=0, nullable=False) # Games where both played (any role, any team)
16
+ games_against = db.Column(db.Integer, default=0, nullable=False) # Games where both played on opposing teams
17
+ games_tied_together = db.Column(db.Integer, default=0, nullable=False)
18
+ games_tied_against = db.Column(db.Integer, default=0, nullable=False) # Games against each other that ended in a tie
19
+ wins_together = db.Column(db.Integer, default=0, nullable=False) # Games both played on same team and won
20
+ losses_together = db.Column(db.Integer, default=0, nullable=False) # Games both played on same team and lost
21
+ h1_wins_vs_h2 = db.Column(db.Integer, default=0, nullable=False) # Games h1's team won vs h2's team
22
+ h2_wins_vs_h1 = db.Column(db.Integer, default=0, nullable=False) # Games h2's team won vs h1's team
23
+
24
+ # Role-specific counts
25
+ games_h1_goalie = db.Column(db.Integer, default=0, nullable=False) # Games where h1 was a goalie and h2 played
26
+ games_h2_goalie = db.Column(db.Integer, default=0, nullable=False) # Games where h2 was a goalie and h1 played
27
+ games_h1_ref = db.Column(db.Integer, default=0, nullable=False) # Games where h1 was a referee and h2 played
28
+ games_h2_ref = db.Column(db.Integer, default=0, nullable=False) # Games where h2 was a referee and h1 played
29
+ games_both_referees = db.Column(db.Integer, default=0, nullable=False) # Games where both were referees
30
+
31
+ # Goals/Assists/Penalties (when both played)
32
+ goals_h1_when_together = db.Column(db.Integer, default=0, nullable=False) # Goals by h1 when both played
33
+ goals_h2_when_together = db.Column(db.Integer, default=0, nullable=False) # Goals by h2 when both played
34
+ assists_h1_when_together = db.Column(db.Integer, default=0, nullable=False) # Assists by h1 when both played
35
+ assists_h2_when_together = db.Column(db.Integer, default=0, nullable=False) # Assists by h2 when both played
36
+ penalties_h1_when_together = db.Column(db.Integer, default=0, nullable=False) # Penalties on h1 when both played
37
+ penalties_h2_when_together = db.Column(db.Integer, default=0, nullable=False) # Penalties on h2 when both played
38
+ gm_penalties_h1_when_together = db.Column(db.Integer, default=0, nullable=False) # GM penalties on h1 when both played
39
+ gm_penalties_h2_when_together = db.Column(db.Integer, default=0, nullable=False) # GM penalties on h2 when both played
40
+
41
+ # Goalie/Skater head-to-head (when one is goalie, other is skater on opposing team)
42
+ h1_goalie_h2_scorer_goals = db.Column(db.Integer, default=0, nullable=False) # Goals scored by h2 against h1 as goalie
43
+ h2_goalie_h1_scorer_goals = db.Column(db.Integer, default=0, nullable=False) # Goals scored by h1 against h2 as goalie
44
+ shots_faced_h1_goalie_vs_h2 = db.Column(db.Integer, default=0, nullable=False) # Shots faced by h1 as goalie vs h2 as skater
45
+ shots_faced_h2_goalie_vs_h1 = db.Column(db.Integer, default=0, nullable=False) # Shots faced by h2 as goalie vs h1 as skater
46
+ goals_allowed_h1_goalie_vs_h2 = db.Column(db.Integer, default=0, nullable=False) # Goals allowed by h1 as goalie vs h2 as skater
47
+ goals_allowed_h2_goalie_vs_h1 = db.Column(db.Integer, default=0, nullable=False) # Goals allowed by h2 as goalie vs h1 as skater
48
+ save_percentage_h1_goalie_vs_h2 = db.Column(db.Float, default=0.0, nullable=False) # Save % by h1 as goalie vs h2 as skater
49
+ save_percentage_h2_goalie_vs_h1 = db.Column(db.Float, default=0.0, nullable=False) # Save % by h2 as goalie vs h1 as skater
50
+
51
+ # Referee/Player
52
+ h1_ref_h2_player_games = db.Column(db.Integer, default=0, nullable=False) # Games h1 was referee, h2 was player
53
+ h2_ref_h1_player_games = db.Column(db.Integer, default=0, nullable=False) # Games h2 was referee, h1 was player
54
+ h1_ref_penalties_on_h2 = db.Column(db.Integer, default=0, nullable=False) # Penalties given by h1 (as ref) to h2
55
+ h2_ref_penalties_on_h1 = db.Column(db.Integer, default=0, nullable=False) # Penalties given by h2 (as ref) to h1
56
+ h1_ref_gm_penalties_on_h2 = db.Column(db.Integer, default=0, nullable=False) # GM penalties given by h1 (as ref) to h2
57
+ h2_ref_gm_penalties_on_h1 = db.Column(db.Integer, default=0, nullable=False) # GM penalties given by h2 (as ref) to h1
58
+
59
+ # Both referees (when both are referees in the same game)
60
+ penalties_given_both_refs = db.Column(db.Integer, default=0, nullable=False) # Total penalties given by both
61
+ gm_penalties_given_both_refs = db.Column(db.Integer, default=0, nullable=False) # Total GM penalties given by both
62
+
63
+ # Shootouts
64
+ h1_shootout_attempts_vs_h2_goalie = db.Column(db.Integer, default=0, nullable=False) # h1 shootout attempts vs h2 as goalie
65
+ h1_shootout_goals_vs_h2_goalie = db.Column(db.Integer, default=0, nullable=False) # h1 shootout goals vs h2 as goalie
66
+ h2_shootout_attempts_vs_h1_goalie = db.Column(db.Integer, default=0, nullable=False) # h2 shootout attempts vs h1 as goalie
67
+ h2_shootout_goals_vs_h1_goalie = db.Column(db.Integer, default=0, nullable=False) # h2 shootout goals vs h1 as goalie
68
+
69
+ # First and last game IDs where both were present
70
+ first_game_id = db.Column(db.Integer, nullable=False) # Game.id of the first game where both were present
71
+ last_game_id = db.Column(db.Integer, nullable=False) # Game.id of the most recent game where both were present
72
+
73
+ class H2HStatsMeta(db.Model):
74
+ __tablename__ = 'h2h_stats_meta'
75
+ id = db.Column(db.Integer, primary_key=True)
76
+ last_run_timestamp = db.Column(db.DateTime, nullable=True) # When the h2h stats were last updated
77
+ last_processed_game_id = db.Column(db.Integer, nullable=True) # Game.id of the latest processed game
78
+
79
+ class SkaterToSkaterStats(db.Model):
80
+ __tablename__ = 'skater_to_skater_stats'
81
+ id = db.Column(db.Integer, primary_key=True)
82
+ skater1_id = db.Column(db.Integer, db.ForeignKey('humans.id'), nullable=False)
83
+ skater2_id = db.Column(db.Integer, db.ForeignKey('humans.id'), nullable=False)
84
+ __table_args__ = (
85
+ db.UniqueConstraint('skater1_id', 'skater2_id', name='_s2s_skater_pair_uc'),
86
+ db.Index('ix_s2s_skater_pair', 'skater1_id', 'skater2_id'),
87
+ )
88
+
89
+ # General stats
90
+ games_against = db.Column(db.Integer, default=0, nullable=False)
91
+ games_tied_against = db.Column(db.Integer, default=0, nullable=False)
92
+ skater1_wins_vs_skater2 = db.Column(db.Integer, default=0, nullable=False)
93
+ skater2_wins_vs_skater1 = db.Column(db.Integer, default=0, nullable=False)
94
+
95
+ # Cumulative stats
96
+ skater1_goals_against_skater2 = db.Column(db.Integer, default=0, nullable=False)
97
+ skater2_goals_against_skater1 = db.Column(db.Integer, default=0, nullable=False)
98
+ skater1_assists_against_skater2 = db.Column(db.Integer, default=0, nullable=False)
99
+ skater2_assists_against_skater1 = db.Column(db.Integer, default=0, nullable=False)
100
+ skater1_penalties_against_skater2 = db.Column(db.Integer, default=0, nullable=False)
101
+ skater2_penalties_against_skater1 = db.Column(db.Integer, default=0, nullable=False)
102
+
103
+ class SkaterToSkaterStatsMeta(db.Model):
104
+ __tablename__ = 'skater_to_skater_stats_meta'
105
+ id = db.Column(db.Integer, primary_key=True)
106
+ last_run_timestamp = db.Column(db.DateTime, nullable=True)
107
+ last_processed_game_id = db.Column(db.Integer, nullable=True)
@@ -351,6 +351,7 @@ class RequestLog(db.Model):
351
351
  path = db.Column(db.String, nullable=False)
352
352
  timestamp = db.Column(db.DateTime, nullable=False)
353
353
  cgi_params = db.Column(db.String, nullable=True)
354
+ response_time_ms = db.Column(db.Float, nullable=True) # Response time in milliseconds
354
355
 
355
356
  # # MANUAL AMENDS HAPPEN HERE :)
356
357
  # from db_connection import create_session
@@ -0,0 +1,91 @@
1
+ import time
2
+ from datetime import datetime, timedelta
3
+
4
+ class ProgressTracker:
5
+ """
6
+ Reusable progress tracker with ETA calculation for stats aggregation processes.
7
+ """
8
+
9
+ def __init__(self, total_items, description="Processing"):
10
+ self.total_items = total_items
11
+ self.description = description
12
+ self.start_time = time.time()
13
+ self.processed_items = 0
14
+ self.last_update_time = self.start_time
15
+
16
+ def update(self, processed_count=None):
17
+ """
18
+ Update progress. If processed_count is None, increment by 1.
19
+ """
20
+ if processed_count is not None:
21
+ self.processed_items = processed_count
22
+ else:
23
+ self.processed_items += 1
24
+
25
+ current_time = time.time()
26
+
27
+ # Only update display if at least 0.1 seconds have passed (to avoid spamming)
28
+ if current_time - self.last_update_time >= 0.1 or self.processed_items == self.total_items:
29
+ self._display_progress()
30
+ self.last_update_time = current_time
31
+
32
+ def _display_progress(self):
33
+ """
34
+ Display progress with percentage, ETA, and elapsed time.
35
+ """
36
+ current_time = time.time()
37
+ elapsed_time = current_time - self.start_time
38
+
39
+ if self.processed_items == 0:
40
+ percentage = 0.0
41
+ eta_str = "calculating..."
42
+ else:
43
+ percentage = (self.processed_items / self.total_items) * 100
44
+
45
+ # Calculate ETA
46
+ if self.processed_items == self.total_items:
47
+ eta_str = "completed!"
48
+ else:
49
+ avg_time_per_item = elapsed_time / self.processed_items
50
+ remaining_items = self.total_items - self.processed_items
51
+ eta_seconds = avg_time_per_item * remaining_items
52
+ eta_str = self._format_time(eta_seconds)
53
+
54
+ elapsed_str = self._format_time(elapsed_time)
55
+
56
+ progress_msg = f"\r{self.description}: {self.processed_items}/{self.total_items} ({percentage:.1f}%) | "
57
+ progress_msg += f"Elapsed: {elapsed_str} | ETA: {eta_str}"
58
+
59
+ print(progress_msg, end="", flush=True)
60
+
61
+ # Add newline when complete
62
+ if self.processed_items == self.total_items:
63
+ print() # Newline to finish the progress line
64
+
65
+ def _format_time(self, seconds):
66
+ """
67
+ Format seconds into a human-readable string.
68
+ """
69
+ if seconds < 60:
70
+ return f"{seconds:.1f}s"
71
+ elif seconds < 3600:
72
+ minutes = int(seconds // 60)
73
+ secs = int(seconds % 60)
74
+ return f"{minutes}m {secs}s"
75
+ else:
76
+ hours = int(seconds // 3600)
77
+ minutes = int((seconds % 3600) // 60)
78
+ return f"{hours}h {minutes}m"
79
+
80
+ def finish(self):
81
+ """
82
+ Mark progress as complete and add final newline.
83
+ """
84
+ self.processed_items = self.total_items
85
+ self._display_progress()
86
+
87
+ def create_progress_tracker(total_items, description="Processing"):
88
+ """
89
+ Factory function to create a progress tracker.
90
+ """
91
+ return ProgressTracker(total_items, description)