gr-analytics 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gr_analytics
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Scoring and salary calculation for GridRival fantasy F1
5
5
  Author: nce8
6
6
  License-Expression: MIT
@@ -17,6 +17,8 @@ Requires-Dist: numpy
17
17
 
18
18
  # gr_analytics
19
19
 
20
+ ![Tests](https://github.com/nickeubank/gr_analytics/actions/workflows/tests.yml/badge.svg)
21
+
20
22
  Python package for scoring and salary calculation in [GridRival](https://www.gridrival.com/) fantasy F1.
21
23
 
22
24
  ## Installation
@@ -87,11 +89,13 @@ Qualifying and race positions must each form a complete sequence `1..n` with no
87
89
  `score_event` returns a DataFrame with all drivers and constructors, with scoring columns appended:
88
90
 
89
91
  **Drivers:**
92
+
90
93
  - `pts_qualifying`, `pts_race`, `pts_overtake`, `pts_improvement`, `pts_completion`, `pts_teammate`
91
94
  - `points_earned` — total fantasy points
92
95
  - `salary_after_event`, `salary_change`
93
96
 
94
97
  **Constructors:**
98
+
95
99
  - `pts_qualifying`, `pts_race` (sum across both drivers, using constructor-specific tables)
96
100
  - `points_earned`, `salary_after_event`, `salary_change`
97
101
 
@@ -100,6 +104,7 @@ Qualifying and race positions must each form a complete sequence `1..n` with no
100
104
  All scoring follows GridRival's rules for Grand Prix events (no sprint races).
101
105
 
102
106
  ### Drivers
107
+
103
108
  | Bonus | Rule |
104
109
  |-------|------|
105
110
  | Qualifying | P1=50, P2=48, … P22=8 (step −2) |
@@ -110,9 +115,11 @@ All scoring follows GridRival's rules for Grand Prix events (no sprint races).
110
115
  | Teammate | Points for beating teammate by margin: ≥1 pos=2 pts, ≥4=5, ≥8=8, ≥13=12 |
111
116
 
112
117
  ### Constructors
118
+
113
119
  Constructor qualifying and race points use separate tables (P1=30/60, step −1/−2 per driver) summed across both drivers. No overtake, improvement, completion, or teammate bonuses.
114
120
 
115
121
  ### Salary Adjustment
122
+
116
123
  After each race, salaries adjust based on the gap between a driver's actual starting salary and the default salary for their points-ranking position:
117
124
 
118
125
  ```
@@ -1,5 +1,7 @@
1
1
  # gr_analytics
2
2
 
3
+ ![Tests](https://github.com/nickeubank/gr_analytics/actions/workflows/tests.yml/badge.svg)
4
+
3
5
  Python package for scoring and salary calculation in [GridRival](https://www.gridrival.com/) fantasy F1.
4
6
 
5
7
  ## Installation
@@ -70,11 +72,13 @@ Qualifying and race positions must each form a complete sequence `1..n` with no
70
72
  `score_event` returns a DataFrame with all drivers and constructors, with scoring columns appended:
71
73
 
72
74
  **Drivers:**
75
+
73
76
  - `pts_qualifying`, `pts_race`, `pts_overtake`, `pts_improvement`, `pts_completion`, `pts_teammate`
74
77
  - `points_earned` — total fantasy points
75
78
  - `salary_after_event`, `salary_change`
76
79
 
77
80
  **Constructors:**
81
+
78
82
  - `pts_qualifying`, `pts_race` (sum across both drivers, using constructor-specific tables)
79
83
  - `points_earned`, `salary_after_event`, `salary_change`
80
84
 
@@ -83,6 +87,7 @@ Qualifying and race positions must each form a complete sequence `1..n` with no
83
87
  All scoring follows GridRival's rules for Grand Prix events (no sprint races).
84
88
 
85
89
  ### Drivers
90
+
86
91
  | Bonus | Rule |
87
92
  |-------|------|
88
93
  | Qualifying | P1=50, P2=48, … P22=8 (step −2) |
@@ -93,9 +98,11 @@ All scoring follows GridRival's rules for Grand Prix events (no sprint races).
93
98
  | Teammate | Points for beating teammate by margin: ≥1 pos=2 pts, ≥4=5, ≥8=8, ≥13=12 |
94
99
 
95
100
  ### Constructors
101
+
96
102
  Constructor qualifying and race points use separate tables (P1=30/60, step −1/−2 per driver) summed across both drivers. No overtake, improvement, completion, or teammate bonuses.
97
103
 
98
104
  ### Salary Adjustment
105
+
99
106
  After each race, salaries adjust based on the gap between a driver's actual starting salary and the default salary for their points-ranking position:
100
107
 
101
108
  ```
@@ -388,27 +388,62 @@ def score_event(scenario: pd.DataFrame, round: int = None) -> pd.DataFrame:
388
388
  """
389
389
  Score a Grand Prix event for drivers and constructors.
390
390
 
391
+ Merges ``scenario`` with the built-in ``driver_data.csv`` for the given
392
+ round to obtain starting salaries, team affiliations, eight-race
393
+ averages, and constructor rows. Then scores every driver and
394
+ constructor and computes post-event salary adjustments.
395
+
391
396
  Parameters
392
397
  ----------
393
- scenario : DataFrame with columns:
394
- driver_abbr, qualifying_position, race_position
395
- completed_qualifying: 1 if driver completed qualifying, 0 if DNQ.
396
- DNQ drivers get 0 qualifying points but overtake points still
397
- use their official qualifying_position.
398
- completed_pct (optional): fraction of race completed (0.0-1.0).
399
- Used to calculate completion bonus (3 pts each at 25/50/75/90%).
400
- Defaults to 1.0 (full completion) if column is absent.
398
+ scenario : DataFrame
399
+ One row per driver with the following columns:
400
+
401
+ - **driver_abbr** : str three-letter driver code (e.g. "VER").
402
+ - **qualifying_position** : int — grid position (1–22).
403
+ - **race_position** : int finishing position (1–22).
404
+ - **completed_qualifying** : int (0 or 1), optional — whether the
405
+ driver set a qualifying time. DNQ drivers (0) receive 0
406
+ qualifying points but overtake points still use their official
407
+ qualifying_position. Defaults to 1 if column is absent.
408
+ - **completed_pct** : float or "DNS", optional — fraction of race
409
+ distance completed (0.0–1.0), or the string "DNS" for drivers
410
+ who did not start. Used to calculate completion bonus (3 pts
411
+ each at 25%/50%/75%/90%). DNS drivers receive 0 for all
412
+ race-related points. Defaults to 1.0 (full completion) if
413
+ column is absent.
414
+
415
+ Qualifying and race positions must each be a complete 1…n
416
+ sequence with no duplicates.
417
+
401
418
  round : int, optional
402
- Race round number. Defaults to the maximum round in driver_data.
419
+ Race round number used to look up driver/constructor data (salary,
420
+ eight-race average, team mapping). Defaults to the maximum round
421
+ in driver_data.
403
422
 
404
423
  Returns
405
424
  -------
406
- DataFrame with scoring columns appended.
407
- Drivers get: pts_qualifying, pts_race, pts_overtake, pts_improvement,
408
- pts_completion, pts_teammate, points_earned, salary_after_event,
409
- salary_change
410
- Constructors get: pts_qualifying, pts_race, points_earned, salary_after_event,
411
- salary_change
425
+ DataFrame
426
+ All driver and constructor rows with scoring columns appended.
427
+
428
+ **Drivers:** pts_qualifying, pts_race, pts_overtake,
429
+ pts_improvement, pts_completion, pts_teammate, points_earned,
430
+ salary_after_event, salary_change.
431
+
432
+ **Constructors:** pts_qualifying, pts_race, points_earned,
433
+ salary_after_event, salary_change (qualifying and race points
434
+ are the sum of constructor-specific point tables across both
435
+ drivers, not the driver tables).
436
+
437
+ Raises
438
+ ------
439
+ ValueError
440
+ If qualifying or race positions contain duplicates, gaps, or
441
+ out-of-range values.
442
+
443
+ Notes
444
+ -----
445
+ If the driver_data contains a ``held`` column with value 1, the
446
+ function prints the total points and salary change for held picks.
412
447
  """
413
448
  _validate_scenario(scenario)
414
449
 
@@ -420,6 +455,28 @@ def score_event(scenario: pd.DataFrame, round: int = None) -> pd.DataFrame:
420
455
  drivers_dd = dd[dd["type"] == "driver"].copy()
421
456
  teams_dd = dd[dd["type"] == "team"].copy()
422
457
 
458
+ # Validate driver_abbr values before merging
459
+ dd_abbrs = set(drivers_dd["driver_abbr"])
460
+ scenario_abbrs = set(scenario["driver_abbr"])
461
+ missing_from_scenario = dd_abbrs - scenario_abbrs
462
+ missing_from_dd = scenario_abbrs - dd_abbrs
463
+ errors = []
464
+ if missing_from_scenario:
465
+ errors.append(
466
+ f"Drivers in driver_data (round {round}) but not in scenario: "
467
+ f"{sorted(missing_from_scenario)}"
468
+ )
469
+ if missing_from_dd:
470
+ errors.append(
471
+ f"Drivers in scenario but not in driver_data (round {round}): "
472
+ f"{sorted(missing_from_dd)}"
473
+ )
474
+ if errors:
475
+ raise ValueError(
476
+ "driver_abbr mismatch between scenario and driver_data:\n"
477
+ + "\n".join(f" - {e}" for e in errors)
478
+ )
479
+
423
480
  drivers_merged = drivers_dd.merge(
424
481
  scenario,
425
482
  on="driver_abbr",
@@ -472,7 +529,7 @@ def score_my_team(
472
529
  (result["type"] == "driver") & (result["driver_abbr"].isin(drivers))
473
530
  ].copy()
474
531
  my_team = result[
475
- (result["type"] == "team") & (result["driver_name"] == team)
532
+ (result["type"] == "team") & (result["driver_abbr"] == team)
476
533
  ].copy()
477
534
 
478
535
  my_picks = pd.concat([my_drivers, my_team])
@@ -490,7 +547,8 @@ def score_my_team(
490
547
  def optimal_lineup(
491
548
  scored: pd.DataFrame,
492
549
  locked_in: list = None,
493
- optimize_for: str = "points",
550
+ locked_out: list = None,
551
+ optimize_for="points",
494
552
  budget: float = 100.0,
495
553
  star_salary_cap: float = 19.0,
496
554
  ) -> pd.DataFrame:
@@ -505,11 +563,17 @@ def optimal_lineup(
505
563
  driver_abbr values (drivers) or driver_name team codes (e.g. "MER")
506
564
  that must appear in the lineup. These count against the budget and
507
565
  the 5-driver / 1-constructor slot limits.
508
- optimize_for : {"points", "salary_change"}
509
- Objective to maximise. For "points", the star driver (who earns
510
- double points) is chosen optimally across all candidates. For
511
- "salary_change", the lineup maximises total salary change; no star
512
- is designated since the star does not affect salary in GR.
566
+ locked_out : list of str, optional
567
+ driver_abbr values (drivers) or driver_name team codes that must
568
+ be excluded from the lineup. These are removed from the candidate
569
+ pool before optimisation.
570
+ optimize_for : {"points", "salary_change"} or float
571
+ Objective to maximise. ``"points"`` is equivalent to balance=1
572
+ (pure points with star doubling). ``"salary_change"`` is equivalent
573
+ to balance=0 (pure salary change). A float between 0 and 1 blends:
574
+ ``objective = (points_earned / 100) * balance + salary_change * (1 - balance)``
575
+ The star driver (whose points component is doubled) is chosen
576
+ optimally whenever balance > 0.
513
577
  budget : float
514
578
  Salary budget in £M available for non-locked-in picks (default 100).
515
579
  Locked-in drivers are treated as free (already under contract).
@@ -520,16 +584,35 @@ def optimal_lineup(
520
584
  Returns
521
585
  -------
522
586
  DataFrame with the 6 selected rows plus a `star` column (1 = starred
523
- driver, only set when optimize_for="points").
587
+ driver, set when optimize_for="points" or a float balance > 0).
524
588
  """
525
589
  if locked_in is None:
526
590
  locked_in = []
527
- if optimize_for not in ("points", "salary_change"):
528
- raise ValueError("optimize_for must be 'points' or 'salary_change'")
529
-
530
- obj_col = "points_earned" if optimize_for == "points" else "salary_change"
591
+ if locked_out is None:
592
+ locked_out = []
593
+
594
+ # Determine balance: 1.0 = pure points, 0.0 = pure salary_change
595
+ if isinstance(optimize_for, (int, float)) and not isinstance(optimize_for, bool):
596
+ balance = float(optimize_for)
597
+ if not 0.0 <= balance <= 1.0:
598
+ raise ValueError("optimize_for as a float must be between 0 and 1")
599
+ elif optimize_for == "points":
600
+ balance = 1.0
601
+ elif optimize_for == "salary_change":
602
+ balance = 0.0
603
+ else:
604
+ raise ValueError(
605
+ "optimize_for must be 'points', 'salary_change', or a float between 0 and 1"
606
+ )
531
607
 
532
608
  df = scored.copy()
609
+
610
+ # Remove locked-out entries from the pool entirely
611
+ _is_locked_out = df["driver_abbr"].isin(locked_out) | (
612
+ (df["type"] == "team") & df["driver_name"].isin(locked_out)
613
+ )
614
+ df = df[~_is_locked_out].copy()
615
+
533
616
  df["_locked"] = df["driver_abbr"].isin(locked_in) | (
534
617
  (df["type"] == "team") & df["driver_name"].isin(locked_in)
535
618
  )
@@ -554,7 +637,9 @@ def optimal_lineup(
554
637
  free = free.sort_values("type").reset_index(drop=True)
555
638
 
556
639
  salaries_arr = free["starting_salary"].values.astype(float)
557
- obj_arr = free[obj_col].values.astype(float)
640
+ pts_arr = free["points_earned"].values.astype(float)
641
+ sal_arr = free["salary_change"].values.astype(float)
642
+ obj_arr = pts_arr / 100.0 * balance + sal_arr * (1.0 - balance)
558
643
 
559
644
  bounds = Bounds(0, 1)
560
645
  integrality = np.ones(len(free))
@@ -581,14 +666,16 @@ def optimal_lineup(
581
666
  raise RuntimeError(f"Optimization failed: {result.message}")
582
667
  return result
583
668
 
584
- if optimize_for == "salary_change":
585
- result = _run_milp(obj_arr)
586
- picked = free[result.x.astype(bool)].copy()
587
- full = pd.concat([picked, locked], ignore_index=True)
588
- full["star"] = 0
589
- return full.drop(columns=["_locked"])
669
+ # Locked contribution for total comparison
670
+ if not locked.empty:
671
+ locked_obj = (
672
+ locked["points_earned"].values.astype(float) / 100.0 * balance
673
+ + locked["salary_change"].values.astype(float) * (1.0 - balance)
674
+ ).sum()
675
+ else:
676
+ locked_obj = 0.0
590
677
 
591
- # --- points optimization: loop over star candidates ---
678
+ # --- star optimization: loop over star candidates ---
592
679
  free_drivers = free[free["type"] == "driver"]
593
680
  if star_salary_cap is not None:
594
681
  free_drivers = free_drivers[free_drivers["starting_salary"] <= star_salary_cap]
@@ -602,7 +689,8 @@ def optimal_lineup(
602
689
  for i in star_candidates:
603
690
  obj_copy = obj_arr.copy()
604
691
  if i != -1:
605
- obj_copy[i] *= 2
692
+ # Star doubles points_earned only → add the points component again
693
+ obj_copy[i] += pts_arr[i] / 100.0 * balance
606
694
 
607
695
  result = _run_milp(obj_copy)
608
696
  picked = free[result.x.astype(bool)].copy()
@@ -612,11 +700,8 @@ def optimal_lineup(
612
700
  if i != -1:
613
701
  star_abbr = free.at[i, "driver_abbr"]
614
702
  full.loc[full["driver_abbr"] == star_abbr, "star"] = 1
615
- # total = doubled star pts + rest
616
- star_pts = free.at[i, obj_col]
617
- total = (
618
- -result.fun + locked[obj_col].sum() + star_pts
619
- ) # star_pts counted twice via obj_copy
703
+ star_bonus = pts_arr[i] / 100.0 * balance
704
+ total = -result.fun + locked_obj + star_bonus
620
705
  else:
621
706
  # Star is the highest-points locked_in driver (within salary cap)
622
707
  locked_drivers = locked[locked["type"] == "driver"]
@@ -625,12 +710,13 @@ def optimal_lineup(
625
710
  locked_drivers["starting_salary"] <= star_salary_cap
626
711
  ]
627
712
  if locked_drivers.empty:
628
- total = -result.fun + locked[obj_col].sum()
713
+ total = -result.fun + locked_obj
629
714
  else:
630
- best_row = locked_drivers.loc[locked_drivers[obj_col].idxmax()]
715
+ best_row = locked_drivers.loc[locked_drivers["points_earned"].idxmax()]
631
716
  star_abbr = best_row["driver_abbr"]
632
717
  full.loc[full["driver_abbr"] == star_abbr, "star"] = 1
633
- total = -result.fun + locked[obj_col].sum() + best_row[obj_col]
718
+ star_bonus = best_row["points_earned"] / 100.0 * balance
719
+ total = -result.fun + locked_obj + star_bonus
634
720
 
635
721
  if total > best_total:
636
722
  best_total = total
@@ -0,0 +1,133 @@
1
+ type,driver_abbr,driver_name,driver_team,round,starting_salary,eight_race_average,points_from_last_race
2
+ driver,VER,M. Verstappen,RBR,0,30,1,
3
+ driver,RUS,G. Russell,MER,0,28.7,2,
4
+ driver,NOR,L. Norris,MCL,0,27.4,3,
5
+ driver,PIA,O. Piastri,MCL,0,26.1,4,
6
+ driver,ANT,K. Antonelli,MER,0,24.8,5,
7
+ driver,LEC,C. Leclerc,FER,0,23.5,6,
8
+ driver,ALO,F. Alonso,AMR,0,22.2,7,
9
+ driver,HAM,L. Hamilton,FER,0,20.9,8,
10
+ driver,HAD,I. Hadjar,RBR,0,19.6,9,
11
+ driver,GAS,P. Gasly,ALP,0,18.3,10,
12
+ driver,STR,L. Stroll,AMR,0,17,11,
13
+ driver,SAI,C. Sainz Jr,WIL,0,15.7,12,
14
+ driver,LAW,L. Lawson,RBS,0,14.4,13,
15
+ driver,ALB,A. Albon,WIL,0,13.1,14,
16
+ driver,HUL,N. Hulkenberg,AUD,0,11.8,15,
17
+ driver,BOR,G. Bortoleto,AUD,0,10.5,16,
18
+ driver,BEA,O. Bearman,HAS,0,9.2,17,
19
+ driver,OCO,E. Ocon,HAS,0,7.9,18,
20
+ driver,BOT,V. Bottas,CAD,0,4.7,19,
21
+ driver,PER,S. Perez,CAD,0,4.7,19,
22
+ driver,COL,F. Colapinto,ALP,0,4.7,19,
23
+ driver,LIN,A. Lindblad,RBS,0,4.6,20,
24
+ team,MER,MER,,0,28.5,1,
25
+ team,MCL,MCL,,0,28.5,2,
26
+ team,RBR,RBR,,0,25,3,
27
+ team,FER,FER,,0,22.5,4,
28
+ team,AMR,AMR,,0,20,5,
29
+ team,WIL,WIL,,0,17.5,6,
30
+ team,RBS,RBS,,0,15,7,
31
+ team,ALP,ALP,,0,12.5,8,
32
+ team,AUD,AUD,,0,10,9,
33
+ team,CAD,CAD,,0,7.5,10,
34
+ team,HAS,HAS,,0,5,11,
35
+ driver,VER,M. Verstappen,RBR,1,28.2,2,
36
+ driver,RUS,G. Russell,MER,1,29.6,2,
37
+ driver,NOR,L. Norris,MCL,1,26.7,4,
38
+ driver,PIA,O. Piastri,MCL,1,24.1,7,
39
+ driver,ANT,K. Antonelli,MER,1,25.9,5,
40
+ driver,LEC,C. Leclerc,FER,1,24.5,6,
41
+ driver,ALO,F. Alonso,AMR,1,20.2,9,
42
+ driver,HAM,L. Hamilton,FER,1,22.1,8,
43
+ driver,HAD,I. Hadjar,RBR,1,17.6,11,
44
+ driver,GAS,P. Gasly,ALP,1,18.3,10,
45
+ driver,STR,L. Stroll,AMR,1,15,12,
46
+ driver,SAI,C. Sainz Jr,WIL,1,13.9,13,
47
+ driver,LAW,L. Lawson,RBS,1,14.5,13,
48
+ driver,ALB,A. Albon,WIL,1,13.9,14,
49
+ driver,HUL,N. Hulkenberg,AUD,1,9.8,16,
50
+ driver,BOR,G. Bortoleto,AUD,1,12.5,6,
51
+ driver,BEA,O. Bearman,HAS,1,11.2,16,
52
+ driver,OCO,E. Ocon,HAS,1,9.9,18,
53
+ driver,BOT,V. Bottas,CAD,1,4.5,19,
54
+ driver,PER,S. Perez,CAD,1,6.4,19,
55
+ driver,COL,F. Colapinto,ALP,1,6.7,19,
56
+ driver,LIN,A. Lindblad,RBS,1,6.6,18,
57
+ team,MER,MER,,1,28.8,,
58
+ team,MCL,MCL,,1,25.7,,
59
+ team,RBR,RBR,,1,22.4,,
60
+ team,FER,FER,,1,23.7,,
61
+ team,AMR,AMR,,1,17,,
62
+ team,WIL,WIL,,1,16.1,,
63
+ team,RBS,RBS,,1,17.4,,
64
+ team,ALP,ALP,,1,14.2,,
65
+ team,AUD,AUD,,1,9.8,,
66
+ team,CAD,CAD,,1,7.3,,
67
+ team,HAS,HAS,,1,8,,
68
+ driver,RUS,G. Russell,MER,2,29.5,2,171
69
+ driver,ANT,K. Antonelli,MER,2,27.5,5,174
70
+ driver,VER,M. Verstappen,RBR,2,26.2,4,132
71
+ driver,LEC,C. Leclerc,FER,2,24.8,6,164
72
+ driver,NOR,L. Norris,MCL,2,24.7,6,107
73
+ driver,HAM,L. Hamilton,FER,2,24.1,7,169
74
+ driver,PIA,O. Piastri,MCL,2,22.1,8,50
75
+ driver,GAS,P. Gasly,ALP,2,19.8,10,143
76
+ driver,ALO,F. Alonso,AMR,2,18.2,10,78
77
+ driver,HAD,I. Hadjar,RBR,2,18.1,11,118
78
+ driver,LAW,L. Lawson,RBS,2,16.5,13,142
79
+ driver,SAI,C. Sainz Jr,WIL,2,15.9,12,123
80
+ driver,BEA,O. Bearman,HAS,2,13.2,15,186
81
+ driver,STR,L. Stroll,AMR,2,13,13,75
82
+ driver,ALB,A. Albon,WIL,2,11.9,15,68
83
+ driver,HUL,N. Hulkenberg,AUD,2,11.8,16,81
84
+ driver,OCO,E. Ocon,HAS,2,10.7,17,124
85
+ driver,BOR,G. Bortoleto,AUD,2,10.5,16,90
86
+ driver,COL,F. Colapinto,ALP,2,8.7,18,130
87
+ driver,LIN,A. Lindblad,RBS,2,8.6,17,143
88
+ driver,PER,S. Perez,CAD,2,7.3,19,102
89
+ driver,BOT,V. Bottas,CAD,2,6.5,19,94
90
+ team,MER,MER,,2,29.1,,177
91
+ team,FER,FER,,2,24.6,,163
92
+ team,MCL,MCL,,2,22.7,,77
93
+ team,RBR,RBR,,2,21.7,,110
94
+ team,RBS,RBS,,2,17.3,,123
95
+ team,ALP,ALP,,2,16.8,,121
96
+ team,AMR,AMR,,2,15.7,,72
97
+ team,WIL,WIL,,2,13.8,,78
98
+ team,HAS,HAS,,2,11,,125
99
+ team,AUD,AUD,,2,9.7,,80
100
+ team,CAD,CAD,,2,9,,83
101
+ driver,RUS,G. Russell,MER,3,29.5,3,164
102
+ driver,ANT,K. Antonelli,MER,3,28.7,4,173
103
+ driver,LEC,C. Leclerc,FER,3,26.3,5,162
104
+ driver,NOR,L. Norris,MCL,3,24.7,6,118
105
+ driver,VER,M. Verstappen,RBR,3,24.6,5,133
106
+ driver,PIA,O. Piastri,MCL,3,24.1,8,91
107
+ driver,HAM,L. Hamilton,FER,3,23.4,7,158
108
+ driver,GAS,P. Gasly,ALP,3,21.7,10,143
109
+ driver,LAW,L. Lawson,RBS,3,18,12,140
110
+ driver,HAD,I. Hadjar,RBR,3,17.7,11,117
111
+ driver,ALO,F. Alonso,AMR,3,16.2,11,79
112
+ driver,SAI,C. Sainz Jr,WIL,3,14.9,13,115
113
+ driver,HUL,N. Hulkenberg,AUD,3,13.3,15,95
114
+ driver,OCO,E. Ocon,HAS,3,12.7,16,130
115
+ driver,BOR,G. Bortoleto,AUD,3,11.5,16,98
116
+ driver,BEA,O. Bearman,HAS,3,11.2,15,142
117
+ driver,STR,L. Stroll,AMR,3,11,14,69
118
+ driver,ALB,A. Albon,WIL,3,9.9,16,70
119
+ driver,LIN,A. Lindblad,RBS,3,9.7,17,131
120
+ driver,COL,F. Colapinto,ALP,3,9,17,117
121
+ driver,PER,S. Perez,CAD,3,7.5,18,97
122
+ driver,BOT,V. Bottas,CAD,3,6.2,19,87
123
+ team,MER,MER,,3,29.3,,175
124
+ team,FER,FER,,3,24.6,,161
125
+ team,MCL,MCL,,3,23.8,,106
126
+ team,RBR,RBR,,3,21.8,,116
127
+ team,ALP,ALP,,3,17.5,,120
128
+ team,RBS,RBS,,3,17.3,,120
129
+ team,AMR,AMR,,3,12.8,,69
130
+ team,WIL,WIL,,3,12.7,,80
131
+ team,HAS,HAS,,3,11.2,,114
132
+ team,AUD,AUD,,3,10.8,,92
133
+ team,CAD,CAD,,3,8.4,,80
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gr_analytics
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Scoring and salary calculation for GridRival fantasy F1
5
5
  Author: nce8
6
6
  License-Expression: MIT
@@ -17,6 +17,8 @@ Requires-Dist: numpy
17
17
 
18
18
  # gr_analytics
19
19
 
20
+ ![Tests](https://github.com/nickeubank/gr_analytics/actions/workflows/tests.yml/badge.svg)
21
+
20
22
  Python package for scoring and salary calculation in [GridRival](https://www.gridrival.com/) fantasy F1.
21
23
 
22
24
  ## Installation
@@ -87,11 +89,13 @@ Qualifying and race positions must each form a complete sequence `1..n` with no
87
89
  `score_event` returns a DataFrame with all drivers and constructors, with scoring columns appended:
88
90
 
89
91
  **Drivers:**
92
+
90
93
  - `pts_qualifying`, `pts_race`, `pts_overtake`, `pts_improvement`, `pts_completion`, `pts_teammate`
91
94
  - `points_earned` — total fantasy points
92
95
  - `salary_after_event`, `salary_change`
93
96
 
94
97
  **Constructors:**
98
+
95
99
  - `pts_qualifying`, `pts_race` (sum across both drivers, using constructor-specific tables)
96
100
  - `points_earned`, `salary_after_event`, `salary_change`
97
101
 
@@ -100,6 +104,7 @@ Qualifying and race positions must each form a complete sequence `1..n` with no
100
104
  All scoring follows GridRival's rules for Grand Prix events (no sprint races).
101
105
 
102
106
  ### Drivers
107
+
103
108
  | Bonus | Rule |
104
109
  |-------|------|
105
110
  | Qualifying | P1=50, P2=48, … P22=8 (step −2) |
@@ -110,9 +115,11 @@ All scoring follows GridRival's rules for Grand Prix events (no sprint races).
110
115
  | Teammate | Points for beating teammate by margin: ≥1 pos=2 pts, ≥4=5, ≥8=8, ≥13=12 |
111
116
 
112
117
  ### Constructors
118
+
113
119
  Constructor qualifying and race points use separate tables (P1=30/60, step −1/−2 per driver) summed across both drivers. No overtake, improvement, completion, or teammate bonuses.
114
120
 
115
121
  ### Salary Adjustment
122
+
116
123
  After each race, salaries adjust based on the gap between a driver's actual starting salary and the default salary for their points-ranking position:
117
124
 
118
125
  ```
@@ -4,14 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gr_analytics"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Scoring and salary calculation for GridRival fantasy F1"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
11
11
  license = "MIT"
12
- authors = [
13
- {name = "nce8"},
14
- ]
12
+ authors = [{ name = "nce8" }]
15
13
  keywords = ["fantasy", "f1", "formula1", "gridrival", "motorsport"]
16
14
  classifiers = [
17
15
  "Programming Language :: Python :: 3",
@@ -10,7 +10,12 @@ from pathlib import Path
10
10
  import pandas as pd
11
11
  import pytest
12
12
 
13
- from gr_analytics import _score_constructors, _score_drivers, optimal_lineup, score_event
13
+ from gr_analytics import (
14
+ _score_constructors,
15
+ _score_drivers,
16
+ optimal_lineup,
17
+ score_event,
18
+ )
14
19
 
15
20
  _TESTS_DIR = Path(__file__).parent
16
21
 
@@ -328,6 +333,18 @@ class TestEdgeCases:
328
333
  result = _score_full(basic_race)
329
334
  assert result.loc[result.driver_name == "Russell", "pts_overtake"].iloc[0] == 0
330
335
 
336
+ def test_score_event_unknown_driver_abbr_raises(self):
337
+ """score_event raises ValueError when scenario contains a driver_abbr not in driver_data."""
338
+ scenario = pd.read_csv(_TESTS_DIR / "test_australia.csv")
339
+ # Replace one valid abbreviation with a bogus one
340
+ scenario.loc[scenario["driver_abbr"] == "VER", "driver_abbr"] = "ZZZ"
341
+ with pytest.raises(ValueError, match="driver_abbr mismatch") as exc_info:
342
+ score_event(scenario, round=0)
343
+ # Both directions should be reported
344
+ msg = str(exc_info.value)
345
+ assert "ZZZ" in msg, "error should mention the unknown abbreviation"
346
+ assert "VER" in msg, "error should mention the missing driver from driver_data"
347
+
331
348
 
332
349
  # ---------------------------------------------------------------------------
333
350
  # Australia 2026 round-0 integration test
@@ -484,6 +501,204 @@ class TestOptimalLineupStarCap:
484
501
  star_row = result[result["star"] == 1]
485
502
  assert star_row.iloc[0]["driver_abbr"] == "D3"
486
503
 
504
+ def test_budget_constraint_drops_expensive_driver(self):
505
+ """With budget=120 the solver must skip drivere (too expensive) and pick driverf."""
506
+ df = pd.DataFrame(
507
+ {
508
+ "type": [
509
+ "driver",
510
+ "driver",
511
+ "driver",
512
+ "driver",
513
+ "driver",
514
+ "driver",
515
+ "team",
516
+ ],
517
+ "driver_abbr": [
518
+ "drivera",
519
+ "driverb",
520
+ "driverc",
521
+ "driverd",
522
+ "drivere",
523
+ "driverf",
524
+ "has",
525
+ ],
526
+ "driver_name": [
527
+ "drivera",
528
+ "driverb",
529
+ "driverc",
530
+ "driverd",
531
+ "drivere",
532
+ "driverf",
533
+ "has",
534
+ ],
535
+ "points_earned": [30, 30, 30, 30, 10, 7, 30],
536
+ "starting_salary": [20, 20, 20, 20, 20, 15, 1],
537
+ "salary_change": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
538
+ }
539
+ )
540
+ result = optimal_lineup(
541
+ df, optimize_for="points", budget=120, star_salary_cap=19.0
542
+ )
543
+ picked = set(result["driver_abbr"].dropna().tolist()) | set(
544
+ result.loc[result["type"] == "team", "driver_name"].tolist()
545
+ )
546
+ assert picked == {"drivera", "driverb", "driverc", "driverd", "driverf", "has"}
547
+
548
+
549
+ # ---------------------------------------------------------------------------
550
+ # locked_out tests
551
+ # ---------------------------------------------------------------------------
552
+
553
+
554
+ class TestLockedOut:
555
+
556
+ def test_locked_out_excludes_high_value_driver(self):
557
+ """A driver worth 999 points at cost 1 is excluded when locked_out."""
558
+ df = pd.DataFrame(
559
+ {
560
+ "type": [
561
+ "driver",
562
+ "driver",
563
+ "driver",
564
+ "driver",
565
+ "driver",
566
+ "driver",
567
+ "team",
568
+ ],
569
+ "driver_abbr": ["GOD", "D1", "D2", "D3", "D4", "D5", None],
570
+ "driver_name": ["GOD", "D1", "D2", "D3", "D4", "D5", "TEAM"],
571
+ "starting_salary": [1.0, 15.0, 15.0, 15.0, 15.0, 15.0, 10.0],
572
+ "points_earned": [999.0, 50.0, 40.0, 30.0, 20.0, 10.0, 60.0],
573
+ "salary_change": [9.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
574
+ }
575
+ )
576
+ result = optimal_lineup(df, locked_out=["GOD"], star_salary_cap=None)
577
+ picked_abbrs = set(result["driver_abbr"].dropna())
578
+ assert "GOD" not in picked_abbrs
579
+
580
+ def test_locked_out_team_excluded(self):
581
+ """A constructor is excluded when its team code is in locked_out."""
582
+ df = pd.DataFrame(
583
+ {
584
+ "type": [
585
+ "driver",
586
+ "driver",
587
+ "driver",
588
+ "driver",
589
+ "driver",
590
+ "team",
591
+ "team",
592
+ ],
593
+ "driver_abbr": ["D1", "D2", "D3", "D4", "D5", None, None],
594
+ "driver_name": ["D1", "D2", "D3", "D4", "D5", "GOOD", "BAD"],
595
+ "starting_salary": [15.0, 15.0, 15.0, 15.0, 15.0, 10.0, 1.0],
596
+ "points_earned": [50.0, 40.0, 30.0, 20.0, 10.0, 60.0, 999.0],
597
+ "salary_change": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 9.0],
598
+ }
599
+ )
600
+ result = optimal_lineup(df, locked_out=["BAD"], star_salary_cap=None)
601
+ picked_teams = set(result.loc[result["type"] == "team", "driver_name"])
602
+ assert "BAD" not in picked_teams
603
+
604
+
605
+ # ---------------------------------------------------------------------------
606
+ # optimize_for balance (float) tests
607
+ # ---------------------------------------------------------------------------
608
+
609
+
610
+ @pytest.fixture
611
+ def balance_pool():
612
+ """
613
+ Pool where points-optimal and salary_change-optimal lineups differ.
614
+
615
+ D1: high points, low salary_change
616
+ D2: low points, high salary_change
617
+ D3–D5: filler
618
+ """
619
+ return pd.DataFrame(
620
+ {
621
+ "type": ["driver", "driver", "driver", "driver", "driver", "team"],
622
+ "driver_abbr": ["D1", "D2", "D3", "D4", "D5", None],
623
+ "driver_name": ["D1", "D2", "D3", "D4", "D5", "TEAM"],
624
+ "starting_salary": [18.0, 18.0, 10.0, 10.0, 10.0, 10.0],
625
+ "points_earned": [200.0, 50.0, 100.0, 100.0, 100.0, 100.0],
626
+ "salary_change": [0.0, 5.0, 1.0, 1.0, 1.0, 1.0],
627
+ }
628
+ )
629
+
630
+
631
+ class TestOptimizeForBalance:
632
+
633
+ def test_balance_1_matches_points(self, balance_pool):
634
+ """optimize_for=1.0 should pick same drivers as optimize_for='points'."""
635
+ result_pts = optimal_lineup(balance_pool, optimize_for="points")
636
+ result_bal = optimal_lineup(balance_pool, optimize_for=1.0)
637
+ pts_abbrs = sorted(result_pts["driver_abbr"].dropna().tolist())
638
+ bal_abbrs = sorted(result_bal["driver_abbr"].dropna().tolist())
639
+ assert bal_abbrs == pts_abbrs
640
+
641
+ def test_balance_0_matches_salary_change(self, balance_pool):
642
+ """optimize_for=0.0 should pick same drivers as optimize_for='salary_change'."""
643
+ result_sal = optimal_lineup(balance_pool, optimize_for="salary_change")
644
+ result_bal = optimal_lineup(balance_pool, optimize_for=0.0)
645
+ sal_abbrs = sorted(result_sal["driver_abbr"].dropna().tolist())
646
+ bal_abbrs = sorted(result_bal["driver_abbr"].dropna().tolist())
647
+ assert bal_abbrs == sal_abbrs
648
+
649
+ def test_balance_star_assigned_when_balance_positive(self, balance_pool):
650
+ """When optimize_for is a positive float, a star driver should be assigned."""
651
+ result = optimal_lineup(balance_pool, optimize_for=0.5)
652
+ assert (result["star"] == 1).sum() == 1
653
+
654
+ def test_balance_0_star_has_no_effect(self, balance_pool):
655
+ """When optimize_for=0.0, a star may be set but has no impact on lineup choice."""
656
+ result_sal = optimal_lineup(balance_pool, optimize_for="salary_change")
657
+ result_bal = optimal_lineup(balance_pool, optimize_for=0.0)
658
+ sal_abbrs = sorted(result_sal["driver_abbr"].dropna().tolist())
659
+ bal_abbrs = sorted(result_bal["driver_abbr"].dropna().tolist())
660
+ assert bal_abbrs == sal_abbrs
661
+
662
+ def test_balance_out_of_range_raises(self, balance_pool):
663
+ """optimize_for outside [0, 1] should raise ValueError."""
664
+ with pytest.raises(ValueError, match="between 0 and 1"):
665
+ optimal_lineup(balance_pool, optimize_for=1.5)
666
+ with pytest.raises(ValueError, match="between 0 and 1"):
667
+ optimal_lineup(balance_pool, optimize_for=-0.1)
668
+
669
+ def test_balance_midpoint_blends_objectives(self):
670
+ """
671
+ At balance=0.5 the solver should prefer the candidate that best
672
+ combines normalised points and salary change.
673
+
674
+ D1: pts=200, sal_change=0 → obj = (200/100)*0.5 + 0*0.5 = 1.0
675
+ D2: pts=50, sal_change=5 → obj = (50/100)*0.5 + 5*0.5 = 2.75
676
+ With budget=68 only one of D1/D2 fits alongside D3–D6+TEAM.
677
+ D2 has higher blended obj → solver picks D2 over D1.
678
+ """
679
+ df = pd.DataFrame(
680
+ {
681
+ "type": [
682
+ "driver",
683
+ "driver",
684
+ "driver",
685
+ "driver",
686
+ "driver",
687
+ "driver",
688
+ "team",
689
+ ],
690
+ "driver_abbr": ["D1", "D2", "D3", "D4", "D5", "D6", None],
691
+ "driver_name": ["D1", "D2", "D3", "D4", "D5", "D6", "TEAM"],
692
+ "starting_salary": [18.0, 18.0, 10.0, 10.0, 10.0, 10.0, 10.0],
693
+ "points_earned": [200.0, 50.0, 100.0, 100.0, 100.0, 100.0, 100.0],
694
+ "salary_change": [0.0, 5.0, 1.0, 1.0, 1.0, 1.0, 1.0],
695
+ }
696
+ )
697
+ result = optimal_lineup(df, optimize_for=0.5, budget=68.0)
698
+ picked = set(result["driver_abbr"].dropna())
699
+ assert "D2" in picked
700
+ assert "D1" not in picked
701
+
487
702
 
488
703
  # ---------------------------------------------------------------------------
489
704
  # Pretty-print for manual inspection
@@ -1,67 +0,0 @@
1
- type,driver_abbr,driver_name,driver_team,round,starting_salary,eight_race_average
2
- driver,VER,M. Verstappen,RBR,0,30,1
3
- driver,RUS,G. Russell,MER,0,28.7,2
4
- driver,NOR,L. Norris,MCL,0,27.4,3
5
- driver,PIA,O. Piastri,MCL,0,26.1,4
6
- driver,ANT,K. Antonelli,MER,0,24.8,5
7
- driver,LEC,C. Leclerc,FER,0,23.5,6
8
- driver,ALO,F. Alonso,AMR,0,22.2,7
9
- driver,HAM,L. Hamilton,FER,0,20.9,8
10
- driver,HAD,I. Hadjar,RBR,0,19.6,9
11
- driver,GAS,P. Gasly,ALP,0,18.3,10
12
- driver,STR,L. Stroll,AMR,0,17,11
13
- driver,SAI,C. Sainz Jr,WIL,0,15.7,12
14
- driver,LAW,L. Lawson,RBS,0,14.4,13
15
- driver,ALB,A. Albon,WIL,0,13.1,14
16
- driver,HUL,N. Hulkenberg,AUD,0,11.8,15
17
- driver,BOR,G. Bortoleto,AUD,0,10.5,16
18
- driver,BEA,O. Bearman,HAS,0,9.2,17
19
- driver,OCO,E. Ocon,HAS,0,7.9,18
20
- driver,BOT,V. Bottas,CAD,0,4.7,19
21
- driver,PER,S. Perez,CAD,0,4.7,19
22
- driver,COL,F. Colapinto,ALP,0,4.7,19
23
- driver,LIN,A. Lindblad,RBS,0,4.6,20
24
- team,MER,MER,,0,28.5,1
25
- team,MCL,MCL,,0,28.5,2
26
- team,RBR,RBR,,0,25,3
27
- team,FER,FER,,0,22.5,4
28
- team,AMR,AMR,,0,20,5
29
- team,WIL,WIL,,0,17.5,6
30
- team,RBS,RBS,,0,15,7
31
- team,ALP,ALP,,0,12.5,8
32
- team,AUD,AUD,,0,10,9
33
- team,CAD,CAD,,0,7.5,10
34
- team,HAS,HAS,,0,5,11
35
- driver,VER,M. Verstappen,RBR,1,28.2,2
36
- driver,RUS,G. Russell,MER,1,29.6,2
37
- driver,NOR,L. Norris,MCL,1,26.7,4
38
- driver,PIA,O. Piastri,MCL,1,24.1,7
39
- driver,ANT,K. Antonelli,MER,1,25.9,5
40
- driver,LEC,C. Leclerc,FER,1,24.5,6
41
- driver,ALO,F. Alonso,AMR,1,20.2,9
42
- driver,HAM,L. Hamilton,FER,1,22.1,8
43
- driver,HAD,I. Hadjar,RBR,1,17.6,11
44
- driver,GAS,P. Gasly,ALP,1,18.3,10
45
- driver,STR,L. Stroll,AMR,1,15,12
46
- driver,SAI,C. Sainz Jr,WIL,1,13.9,13
47
- driver,LAW,L. Lawson,RBS,1,14.5,13
48
- driver,ALB,A. Albon,WIL,1,13.9,14
49
- driver,HUL,N. Hulkenberg,AUD,1,9.8,16
50
- driver,BOR,G. Bortoleto,AUD,1,12.5,6
51
- driver,BEA,O. Bearman,HAS,1,11.2,16
52
- driver,OCO,E. Ocon,HAS,1,9.9,18
53
- driver,BOT,V. Bottas,CAD,1,4.5,19
54
- driver,PER,S. Perez,CAD,1,6.4,19
55
- driver,COL,F. Colapinto,ALP,1,6.7,19
56
- driver,LIN,A. Lindblad,RBS,1,6.6,18
57
- team,MER,MER,,1,28.8,
58
- team,MCL,MCL,,1,25.7,
59
- team,RBR,RBR,,1,22.4,
60
- team,FER,FER,,1,23.7,
61
- team,AMR,AMR,,1,17,
62
- team,WIL,WIL,,1,16.1,
63
- team,RBS,RBS,,1,17.4,
64
- team,ALP,ALP,,1,14.2,
65
- team,AUD,AUD,,1,9.8,
66
- team,CAD,CAD,,1,7.3,
67
- team,HAS,HAS,,1,8,
File without changes