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.
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/PKG-INFO +1 -1
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/gr_analytics/__init__.py +130 -44
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/gr_analytics/data/driver_data.csv +34 -1
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/gr_analytics.egg-info/PKG-INFO +1 -1
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/pyproject.toml +2 -4
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/tests/test_scoring.py +166 -0
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/README.md +0 -0
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/gr_analytics.egg-info/SOURCES.txt +0 -0
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/gr_analytics.egg-info/dependency_links.txt +0 -0
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/gr_analytics.egg-info/requires.txt +0 -0
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/gr_analytics.egg-info/top_level.txt +0 -0
- {gr_analytics-0.2.1 → gr_analytics-0.3.0}/setup.cfg +0 -0
|
@@ -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
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
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
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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["
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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,
|
|
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
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
# ---
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
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 +
|
|
713
|
+
total = -result.fun + locked_obj
|
|
629
714
|
else:
|
|
630
|
-
best_row = locked_drivers.loc[locked_drivers[
|
|
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
|
-
|
|
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
|
|
@@ -4,14 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gr_analytics"
|
|
7
|
-
version = "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",
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|