gr-analytics 0.2.1__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.1
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
@@ -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
@@ -97,4 +97,37 @@ team,AMR,AMR,,2,15.7,,72
97
97
  team,WIL,WIL,,2,13.8,,78
98
98
  team,HAS,HAS,,2,11,,125
99
99
  team,AUD,AUD,,2,9.7,,80
100
- team,CAD,CAD,,2,9,,83
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.1
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
@@ -4,14 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gr_analytics"
7
- version = "0.2.1"
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",
@@ -333,6 +333,18 @@ class TestEdgeCases:
333
333
  result = _score_full(basic_race)
334
334
  assert result.loc[result.driver_name == "Russell", "pts_overtake"].iloc[0] == 0
335
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
+
336
348
 
337
349
  # ---------------------------------------------------------------------------
338
350
  # Australia 2026 round-0 integration test
@@ -534,6 +546,160 @@ class TestOptimalLineupStarCap:
534
546
  assert picked == {"drivera", "driverb", "driverc", "driverd", "driverf", "has"}
535
547
 
536
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
+
702
+
537
703
  # ---------------------------------------------------------------------------
538
704
  # Pretty-print for manual inspection
539
705
  # ---------------------------------------------------------------------------
File without changes
File without changes