hpde-analytics-cli 1.0.0__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.
@@ -0,0 +1,459 @@
1
+ """
2
+ Report Generator Module
3
+
4
+ Merges and processes event data into a consolidated Time Trials report.
5
+ """
6
+
7
+ import csv
8
+ import json
9
+ import os
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Optional, Set, Tuple
12
+
13
+ try:
14
+ from openpyxl import Workbook
15
+ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
16
+ from openpyxl.utils import get_column_letter
17
+
18
+ OPENPYXL_AVAILABLE = True
19
+ except ImportError:
20
+ OPENPYXL_AVAILABLE = False
21
+
22
+
23
+ class ReportGenerator:
24
+ """Generates consolidated Time Trials reports from MSR data."""
25
+
26
+ def __init__(self, export_dir: str):
27
+ """
28
+ Initialize the report generator.
29
+
30
+ Args:
31
+ export_dir: Directory containing exported CSV files
32
+ """
33
+ self.export_dir = export_dir
34
+ self.entrylist_file = os.path.join(export_dir, "entrylist.csv")
35
+ self.attendees_file = os.path.join(export_dir, "attendees.csv")
36
+ self.assignments_file = os.path.join(export_dir, "assignments.csv")
37
+ self.assignments_json_file = os.path.join(export_dir, "assignments.json")
38
+
39
+ def _read_csv(self, filepath: str) -> List[Dict]:
40
+ """Read a CSV file into a list of dictionaries."""
41
+ rows = []
42
+ with open(filepath, "r", encoding="utf-8") as f:
43
+ reader = csv.DictReader(f)
44
+ for row in reader:
45
+ rows.append(row)
46
+ return rows
47
+
48
+ def _read_json(self, filepath: str) -> Any:
49
+ """Read a JSON file."""
50
+ with open(filepath, "r", encoding="utf-8") as f:
51
+ return json.load(f)
52
+
53
+ def _get_driver_key(self, row: Dict) -> str:
54
+ """Create a unique key for a driver based on first and last name."""
55
+ first = (row.get("firstName") or "").strip().lower()
56
+ last = (row.get("lastName") or "").strip().lower()
57
+ return f"{first}|{last}"
58
+
59
+ def _parse_segment(self, segment: str) -> Optional[str]:
60
+ """Extract the day from segment (Friday/Saturday/Sunday)."""
61
+ if not segment:
62
+ return None
63
+ segment_lower = segment.lower()
64
+ if "friday" in segment_lower:
65
+ return "Friday"
66
+ elif "saturday" in segment_lower:
67
+ return "Saturday"
68
+ elif "sunday" in segment_lower:
69
+ return "Sunday"
70
+ return None
71
+
72
+ def _is_time_trials(self, group: str) -> bool:
73
+ """Check if this is a Time Trials entry."""
74
+ if not group:
75
+ return False
76
+ return "time trials" in group.lower()
77
+
78
+ def _is_instructor(self, group: str) -> bool:
79
+ """Check if this is an Instructing entry."""
80
+ if not group:
81
+ return False
82
+ return "instructing" in group.lower()
83
+
84
+ def _is_advanced_hpde(self, group: str) -> bool:
85
+ """Check if this is an Advanced HPDE entry."""
86
+ if not group:
87
+ return False
88
+ return "advanced hpde" in group.lower()
89
+
90
+ def _is_worker_only(self, segment: str) -> bool:
91
+ """Check if this is a worker-only entry (not a track event)."""
92
+ if not segment:
93
+ return True
94
+ return "workers" in segment.lower()
95
+
96
+ def _get_participation_type(self, is_instructor: bool, is_ayce: bool) -> str:
97
+ """
98
+ Determine participation type for pivot table analysis.
99
+
100
+ Returns one of:
101
+ - TT + Instructor + AYCE
102
+ - TT + Instructor
103
+ - TT + AYCE
104
+ - TT Only
105
+ """
106
+ if is_instructor and is_ayce:
107
+ return "TT + Instructor + AYCE"
108
+ elif is_instructor:
109
+ return "TT + Instructor"
110
+ elif is_ayce:
111
+ return "TT + AYCE"
112
+ else:
113
+ return "TT Only"
114
+
115
+ def _get_day_count(self, day_count: int) -> str:
116
+ """
117
+ Determine day count category for pivot table analysis.
118
+
119
+ Returns: '1 Day', '2 Days', or '3 Days'
120
+ """
121
+ if day_count == 1:
122
+ return "1 Day"
123
+ elif day_count == 2:
124
+ return "2 Days"
125
+ elif day_count >= 3:
126
+ return "3 Days"
127
+ else:
128
+ return ""
129
+
130
+ def _get_class_group(self, tt_class: str) -> str:
131
+ """
132
+ Determine class grouping for pivot table analysis.
133
+
134
+ Groups:
135
+ - Max (Max 1, Max 2, Max 5, Max 6, etc.)
136
+ - Sport (Sport 1, Sport 2, Sport 3, Sport 4, etc.)
137
+ - Tuner (Tuner 1, Tuner 2, Tuner 3, Tuner 4, etc.)
138
+ - Unlimited (Unlimited 1, Unlimited 2, etc.)
139
+ - Other (anything else or empty)
140
+ """
141
+ if not tt_class:
142
+ return "Other"
143
+
144
+ class_lower = tt_class.lower().strip()
145
+
146
+ if class_lower.startswith("max"):
147
+ return "Max"
148
+ elif class_lower.startswith("sport"):
149
+ return "Sport"
150
+ elif class_lower.startswith("tuner"):
151
+ return "Tuner"
152
+ elif class_lower.startswith("unlimited"):
153
+ return "Unlimited"
154
+ else:
155
+ return "Other"
156
+
157
+ def generate_tt_report(self, output_path: Optional[str] = None) -> Tuple[str, int]:
158
+ """
159
+ Generate consolidated Time Trials report.
160
+
161
+ Only includes drivers who participated in Time Trials.
162
+ Tracks if they also instructed and/or did Advanced HPDE (AYCE).
163
+
164
+ Args:
165
+ output_path: Path for output file (default: same dir as input)
166
+
167
+ Returns:
168
+ Tuple of (path to generated report file, number of drivers)
169
+ """
170
+ if not OPENPYXL_AVAILABLE:
171
+ raise ImportError(
172
+ "openpyxl is required for Excel export. Install with: pip install openpyxl"
173
+ )
174
+
175
+ # Read entry list data
176
+ entrylist = self._read_csv(self.entrylist_file)
177
+ attendees = self._read_csv(self.attendees_file)
178
+
179
+ # Read assignments JSON for tire data (not in CSV)
180
+ tire_lookup = {}
181
+ if os.path.exists(self.assignments_json_file):
182
+ assignments_data = self._read_json(self.assignments_json_file)
183
+ for assignment in assignments_data.get("assignments", []):
184
+ key = self._get_driver_key(assignment)
185
+ group = assignment.get("group", "")
186
+ tire = assignment.get("tireBrand", "")
187
+ # Only capture tire from Time Trials entries
188
+ if key and key != "|" and self._is_time_trials(group) and tire:
189
+ tire_lookup[key] = tire
190
+
191
+ # Build attendee lookup for email and member ID
192
+ attendee_lookup = {}
193
+ for att in attendees:
194
+ key = self._get_driver_key(att)
195
+ if key and key != "|":
196
+ attendee_lookup[key] = att
197
+
198
+ # Process entries - group by driver
199
+ drivers: Dict[str, Dict] = {}
200
+
201
+ for entry in entrylist:
202
+ # Skip worker-only entries
203
+ if self._is_worker_only(entry.get("segment", "")):
204
+ continue
205
+
206
+ driver_key = self._get_driver_key(entry)
207
+ if not driver_key or driver_key == "|":
208
+ continue
209
+
210
+ group = entry.get("group", "")
211
+ segment = entry.get("segment", "")
212
+ day = self._parse_segment(segment)
213
+
214
+ # Initialize driver record if new
215
+ if driver_key not in drivers:
216
+ drivers[driver_key] = {
217
+ "firstName": entry.get("firstName", "").strip(),
218
+ "lastName": entry.get("lastName", "").strip(),
219
+ "class": "",
220
+ "make": "",
221
+ "model": "",
222
+ "year": "",
223
+ "vehicleNumber": "",
224
+ "color": "",
225
+ "sponsor": "",
226
+ "tireBrand": "",
227
+ "is_tt": False,
228
+ "is_instructor": False,
229
+ "is_advanced_hpde": False,
230
+ "days_tt": set(),
231
+ "days_instructor": set(),
232
+ "days_advanced": set(),
233
+ "email": "",
234
+ "memberId": "",
235
+ "status": "",
236
+ }
237
+
238
+ driver = drivers[driver_key]
239
+
240
+ # Track Time Trials participation
241
+ if self._is_time_trials(group):
242
+ driver["is_tt"] = True
243
+ if day:
244
+ driver["days_tt"].add(day)
245
+ # Capture vehicle info from TT entry (most relevant)
246
+ if entry.get("class"):
247
+ driver["class"] = entry.get("class", "")
248
+ if entry.get("make"):
249
+ driver["make"] = entry.get("make", "")
250
+ if entry.get("model"):
251
+ driver["model"] = entry.get("model", "")
252
+ if entry.get("year"):
253
+ driver["year"] = entry.get("year", "")
254
+ if entry.get("vehicleNumber"):
255
+ driver["vehicleNumber"] = entry.get("vehicleNumber", "")
256
+ if entry.get("color"):
257
+ driver["color"] = entry.get("color", "")
258
+ if entry.get("sponsor"):
259
+ driver["sponsor"] = entry.get("sponsor", "")
260
+
261
+ # Track Instructor participation
262
+ if self._is_instructor(group):
263
+ driver["is_instructor"] = True
264
+ if day:
265
+ driver["days_instructor"].add(day)
266
+
267
+ # Track Advanced HPDE participation
268
+ if self._is_advanced_hpde(group):
269
+ driver["is_advanced_hpde"] = True
270
+ if day:
271
+ driver["days_advanced"].add(day)
272
+
273
+ # Filter to only Time Trials participants
274
+ tt_drivers = {k: v for k, v in drivers.items() if v["is_tt"]}
275
+
276
+ # Add attendee info (email, memberId, status) and tire data
277
+ for driver_key, driver in tt_drivers.items():
278
+ if driver_key in attendee_lookup:
279
+ att = attendee_lookup[driver_key]
280
+ driver["email"] = att.get("email", "")
281
+ driver["memberId"] = att.get("memberId", "")
282
+ driver["status"] = att.get("status", "")
283
+ if driver_key in tire_lookup:
284
+ driver["tireBrand"] = tire_lookup[driver_key]
285
+
286
+ # Create Excel workbook
287
+ wb = Workbook()
288
+ ws = wb.active
289
+ ws.title = "Time Trials Report"
290
+
291
+ # Define headers
292
+ headers = [
293
+ "First Name",
294
+ "Last Name",
295
+ "Email",
296
+ "Member ID",
297
+ "Class",
298
+ "Class Group",
299
+ "Vehicle #",
300
+ "Vehicle",
301
+ "Color",
302
+ "Tire",
303
+ "Sponsor",
304
+ "Days (TT)",
305
+ "Day Count",
306
+ "Instructor",
307
+ "AYCE",
308
+ "Participation Type",
309
+ "Status",
310
+ ]
311
+
312
+ # Style settings
313
+ header_font = Font(bold=True, color="FFFFFF")
314
+ header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
315
+ header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
316
+ thin_border = Border(
317
+ left=Side(style="thin"),
318
+ right=Side(style="thin"),
319
+ top=Side(style="thin"),
320
+ bottom=Side(style="thin"),
321
+ )
322
+
323
+ # Write headers
324
+ for col, header in enumerate(headers, 1):
325
+ cell = ws.cell(row=1, column=col, value=header)
326
+ cell.font = header_font
327
+ cell.fill = header_fill
328
+ cell.alignment = header_alignment
329
+ cell.border = thin_border
330
+
331
+ # Sort drivers by last name, then first name
332
+ sorted_drivers = sorted(
333
+ tt_drivers.values(), key=lambda d: (d["lastName"].lower(), d["firstName"].lower())
334
+ )
335
+
336
+ # Write data rows
337
+ for row_num, driver in enumerate(sorted_drivers, 2):
338
+ # Determine days string
339
+ days_tt = driver["days_tt"]
340
+ has_fri = "Friday" in days_tt
341
+ has_sat = "Saturday" in days_tt
342
+ has_sun = "Sunday" in days_tt
343
+ day_count = sum([has_fri, has_sat, has_sun])
344
+
345
+ if day_count == 3:
346
+ days_str = "All 3"
347
+ elif day_count == 2:
348
+ if has_fri and has_sat:
349
+ days_str = "Fri/Sat"
350
+ elif has_fri and has_sun:
351
+ days_str = "Fri/Sun"
352
+ else:
353
+ days_str = "Sat/Sun"
354
+ elif day_count == 1:
355
+ if has_fri:
356
+ days_str = "Friday"
357
+ elif has_sat:
358
+ days_str = "Saturday"
359
+ else:
360
+ days_str = "Sunday"
361
+ else:
362
+ days_str = ""
363
+
364
+ # AYCE = Time Trials + Advanced HPDE
365
+ is_ayce = driver["is_tt"] and driver["is_advanced_hpde"]
366
+
367
+ # Derived columns for pivot tables
368
+ class_group = self._get_class_group(driver["class"])
369
+ day_count_str = self._get_day_count(day_count)
370
+ participation_type = self._get_participation_type(driver["is_instructor"], is_ayce)
371
+
372
+ # Combine year, make, model into single Vehicle column
373
+ vehicle_parts = []
374
+ if driver["year"]:
375
+ vehicle_parts.append(str(driver["year"]))
376
+ if driver["make"]:
377
+ vehicle_parts.append(driver["make"])
378
+ if driver["model"]:
379
+ vehicle_parts.append(driver["model"])
380
+ vehicle = " ".join(vehicle_parts)
381
+
382
+ row_data = [
383
+ driver["firstName"],
384
+ driver["lastName"],
385
+ driver["email"],
386
+ driver["memberId"],
387
+ driver["class"],
388
+ class_group,
389
+ driver["vehicleNumber"],
390
+ vehicle,
391
+ driver["color"],
392
+ driver["tireBrand"],
393
+ driver["sponsor"],
394
+ days_str,
395
+ day_count_str,
396
+ "Yes" if driver["is_instructor"] else "No",
397
+ "Yes" if is_ayce else "No",
398
+ participation_type,
399
+ driver["status"],
400
+ ]
401
+
402
+ # Columns to center: Class Group (6), Days (12), Day Count (13), Instructor (14), AYCE (15), Participation Type (16)
403
+ center_cols = [6, 12, 13, 14, 15, 16]
404
+
405
+ for col, value in enumerate(row_data, 1):
406
+ cell = ws.cell(row=row_num, column=col, value=value)
407
+ cell.border = thin_border
408
+ if col in center_cols:
409
+ cell.alignment = Alignment(horizontal="center")
410
+
411
+ # Auto-adjust column widths
412
+ for col in range(1, len(headers) + 1):
413
+ max_length = len(headers[col - 1])
414
+ for row in range(2, len(sorted_drivers) + 2):
415
+ cell_value = ws.cell(row=row, column=col).value
416
+ if cell_value:
417
+ max_length = max(max_length, len(str(cell_value)))
418
+ adjusted_width = min(max_length + 2, 50)
419
+ ws.column_dimensions[get_column_letter(col)].width = adjusted_width
420
+
421
+ # Freeze header row
422
+ ws.freeze_panes = "A2"
423
+
424
+ # Set output path
425
+ if output_path is None:
426
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
427
+ output_path = os.path.join(self.export_dir, f"tt_report_{timestamp}.xlsx")
428
+
429
+ wb.save(output_path)
430
+
431
+ return output_path, len(sorted_drivers)
432
+
433
+
434
+ def generate_report(
435
+ export_dir: str, output_path: Optional[str] = None, verbose: bool = False
436
+ ) -> str:
437
+ """
438
+ Generate Time Trials report from exported data.
439
+
440
+ Args:
441
+ export_dir: Directory containing exported CSV files
442
+ output_path: Optional output file path
443
+ verbose: Print progress messages
444
+
445
+ Returns:
446
+ Path to generated report
447
+ """
448
+ generator = ReportGenerator(export_dir)
449
+
450
+ if verbose:
451
+ print("Generating Time Trials report...")
452
+
453
+ report_path, driver_count = generator.generate_tt_report(output_path)
454
+
455
+ if verbose:
456
+ print(f" [OK] Report generated: {report_path}")
457
+ print(f" {driver_count} Time Trials drivers included")
458
+
459
+ return report_path