Implement season guess evaluation
All checks were successful
Build Formula10 Docker Image / build-docker (push) Successful in 15s

This commit is contained in:
2024-12-08 19:25:06 +01:00
parent c509746688
commit cef40a9e8b
4 changed files with 106 additions and 43 deletions

View File

@ -1,5 +1,5 @@
import json import json
from typing import Any, Callable, Dict, List, overload from typing import Any, Callable, Dict, List, overload, Tuple
import numpy as np import numpy as np
from formula10 import cache from formula10 import cache
@ -11,6 +11,7 @@ from formula10.domain.model.season_guess import SeasonGuess
from formula10.domain.model.season_guess_result import SeasonGuessResult from formula10.domain.model.season_guess_result import SeasonGuessResult
from formula10.domain.model.team import Team from formula10.domain.model.team import Team
from formula10.domain.model.user import User from formula10.domain.model.user import User
from formula10.database.validation import find_single_or_none_strict
# Guess points # Guess points
@ -68,6 +69,7 @@ WDC_STANDING_2023: Dict[str, int] = {
"Kevin Magnussen": 19, "Kevin Magnussen": 19,
"Logan Sargeant": 21, "Logan Sargeant": 21,
} }
WCC_STANDING_2023: Dict[str, int] = { WCC_STANDING_2023: Dict[str, int] = {
"Red Bull": 1, "Red Bull": 1,
"Mercedes": 2, "Mercedes": 2,
@ -81,6 +83,12 @@ WCC_STANDING_2023: Dict[str, int] = {
"Haas": 10, "Haas": 10,
} }
# In case a substitute driver is driving, those points have to be subtracted from the actual driver
# (Driver_ID, Race_ID, Points)
WDC_SUBSTITUTE_POINTS: List[Tuple[int, int, int]] = [
(15, 2, 6), # Bearman raced for Sainz in Saudi Arabia
(8, 17, 1), # Bearman raced for Magnussen in Azerbaijan
]
def standing_points(race_guess: RaceGuess, race_result: RaceResult) -> int: def standing_points(race_guess: RaceGuess, race_result: RaceResult) -> int:
guessed_driver_position: int | None = race_result.driver_standing_position( guessed_driver_position: int | None = race_result.driver_standing_position(
@ -106,6 +114,16 @@ def dnf_points(race_guess: RaceGuess, race_result: RaceResult) -> int:
return 0 return 0
def substitute_points(driver: Driver, race_number: int) -> int:
predicate: Callable[[Tuple[int, int, int]], bool] = lambda substitution: driver.id == substitution[0] and race_number == substitution[1]
substitution: Tuple[int, int, int] = find_single_or_none_strict(predicate, WDC_SUBSTITUTE_POINTS)
if substitution is not None:
return substitution[2]
else:
return 0
class PointsModel(Model): class PointsModel(Model):
""" """
This class bundles all data + functionality required to do points calculations. This class bundles all data + functionality required to do points calculations.
@ -124,7 +142,7 @@ class PointsModel(Model):
points_per_step = dict() points_per_step = dict()
for user in self.all_users(): for user in self.all_users():
points_per_step[user.name] = [0] * ( points_per_step[user.name] = [0] * (
len(self.all_races()) + 1 len(self.all_races()) + 1
) # Start at index 1, like the race numbers ) # Start at index 1, like the race numbers
for race_guess in self.all_race_guesses(): for race_guess in self.all_race_guesses():
@ -152,10 +170,10 @@ class PointsModel(Model):
""" """
driver_points_per_step = dict() driver_points_per_step = dict()
for driver in self.all_drivers( for driver in self.all_drivers(
include_none=False, include_inactive=include_inactive include_none=False, include_inactive=include_inactive
): ):
driver_points_per_step[driver.name] = [0] * ( driver_points_per_step[driver.name] = [0] * (
len(self.all_races()) + 1 len(self.all_races()) + 1
) # Start at index 1, like the race numbers ) # Start at index 1, like the race numbers
for race_result in self.all_race_results(): for race_result in self.all_race_results():
@ -170,8 +188,10 @@ class PointsModel(Model):
driver_points_per_step[driver.name][race_number] += ( driver_points_per_step[driver.name][race_number] += (
DRIVER_FASTEST_LAP_POINTS DRIVER_FASTEST_LAP_POINTS
if race_result.fastest_lap_driver == driver if race_result.fastest_lap_driver == driver
and int(position) <= 10
else 0 else 0
) )
driver_points_per_step[driver.name][race_number] -= substitute_points(driver, race_number)
for position, driver in race_result.sprint_standing.items(): for position, driver in race_result.sprint_standing.items():
driver_name: str = driver.name driver_name: str = driver.name
@ -192,7 +212,7 @@ class PointsModel(Model):
team_points_per_step = dict() team_points_per_step = dict()
for team in self.all_teams(include_none=False): for team in self.all_teams(include_none=False):
team_points_per_step[team.name] = [0] * ( team_points_per_step[team.name] = [0] * (
len(self.all_races()) + 1 len(self.all_races()) + 1
) # Start at index 1, like the race numbers ) # Start at index 1, like the race numbers
for race_result in self.all_race_results(): for race_result in self.all_race_results():
@ -237,7 +257,7 @@ class PointsModel(Model):
""" """
points_per_step_cumulative: Dict[str, List[int]] = dict() points_per_step_cumulative: Dict[str, List[int]] = dict()
for driver_name, points in self.driver_points_per_step( for driver_name, points in self.driver_points_per_step(
include_inactive=True include_inactive=True
).items(): ).items():
points_per_step_cumulative[driver_name] = np.cumsum(points).tolist() points_per_step_cumulative[driver_name] = np.cumsum(points).tolist()
@ -245,7 +265,7 @@ class PointsModel(Model):
@overload @overload
def driver_points_by( def driver_points_by(
self, *, driver_name: str, include_inactive: bool self, *, driver_name: str, include_inactive: bool
) -> List[int]: ) -> List[int]:
""" """
Returns a list of points per race for a specific driver. Returns a list of points per race for a specific driver.
@ -256,7 +276,7 @@ class PointsModel(Model):
@overload @overload
def driver_points_by( def driver_points_by(
self, *, race_name: str, include_inactive: bool self, *, race_name: str, include_inactive: bool
) -> Dict[str, int]: ) -> Dict[str, int]:
""" """
Returns a dictionary of points per driver for a specific race. Returns a dictionary of points per driver for a specific race.
@ -267,7 +287,7 @@ class PointsModel(Model):
@overload @overload
def driver_points_by( def driver_points_by(
self, *, driver_name: str, race_name: str, include_inactive: bool self, *, driver_name: str, race_name: str, include_inactive: bool
) -> int: ) -> int:
""" """
Returns the points for a specific race for a specific driver. Returns the points for a specific race for a specific driver.
@ -282,11 +302,11 @@ class PointsModel(Model):
timeout=None, args_to_ignore=["self"] timeout=None, args_to_ignore=["self"]
) # Cleanup when adding/updating race results ) # Cleanup when adding/updating race results
def driver_points_by( def driver_points_by(
self, self,
*, *,
driver_name: str | None = None, driver_name: str | None = None,
race_name: str | None = None, race_name: str | None = None,
include_inactive: bool include_inactive: bool
) -> List[int] | Dict[str, int] | int: ) -> List[int] | Dict[str, int] | int:
if driver_name is not None and race_name is None: if driver_name is not None and race_name is None:
return self.driver_points_per_step(include_inactive=include_inactive)[ return self.driver_points_per_step(include_inactive=include_inactive)[
@ -298,7 +318,7 @@ class PointsModel(Model):
points_by_race: Dict[str, int] = dict() points_by_race: Dict[str, int] = dict()
for _driver_name, points in self.driver_points_per_step( for _driver_name, points in self.driver_points_per_step(
include_inactive=include_inactive include_inactive=include_inactive
).items(): ).items():
points_by_race[_driver_name] = points[race_number] points_by_race[_driver_name] = points[race_number]
@ -341,7 +361,7 @@ class PointsModel(Model):
standing: Dict[int, List[str]] = dict() standing: Dict[int, List[str]] = dict()
for position in range( for position in range(
1, len(self.all_drivers(include_none=False, include_inactive=True)) + 1 1, len(self.all_drivers(include_none=False, include_inactive=True)) + 1
): ):
standing[position] = list() standing[position] = list()
@ -391,7 +411,7 @@ class PointsModel(Model):
return 0 return 0
return ( return (
WDC_STANDING_2023[driver_name] - self.wdc_standing_by_driver()[driver_name] WDC_STANDING_2023[driver_name] - self.wdc_standing_by_driver()[driver_name]
) )
@cache.cached( @cache.cached(
@ -583,7 +603,7 @@ class PointsModel(Model):
timeout=None, args_to_ignore=["self"] timeout=None, args_to_ignore=["self"]
) # Cleanup when adding/updating race results or users ) # Cleanup when adding/updating race results or users
def points_by( def points_by(
self, *, user_name: str | None = None, race_name: str | None = None self, *, user_name: str | None = None, race_name: str | None = None
) -> List[int] | Dict[str, int] | int: ) -> List[int] | Dict[str, int] | int:
if user_name is not None and race_name is None: if user_name is not None and race_name is None:
return self.points_per_step()[user_name] return self.points_per_step()[user_name]
@ -604,30 +624,64 @@ class PointsModel(Model):
raise Exception("points_by received an illegal combination of arguments") raise Exception("points_by received an illegal combination of arguments")
def total_points_by(self, user_name: str) -> int: def season_points_by(self, *, user_name: str) -> int:
"""
Returns the number of points from seasonguesses for a specific user.
"""
big_picks = (int(self.hot_take_correct(user_name=user_name)) * 10
+ int(self.p2_constructor_correct(user_name=user_name)) * 10
+ int(self.overtakes_correct(user_name=user_name)) * 10
+ int(self.dnfs_correct(user_name=user_name)) * 10
+ int(self.most_gained_correct(user_name=user_name)) * 10
+ int(self.most_lost_correct(user_name=user_name)) * 10)
small_picks = 0
guess: SeasonGuess = self.season_guesses_by(user_name=user_name)
for driver in guess.team_winners:
if self.is_team_winner(driver):
small_picks += 3
else:
small_picks -= 3
# NOTE: Not picked drivers that had a podium are also wrong
for driver in self.all_drivers(include_none=False, include_inactive=True):
if driver in guess.podiums and self.has_podium(driver):
small_picks += 3
elif driver in guess.podiums and not self.has_podium(driver):
small_picks -=2
elif driver not in guess.podiums and self.has_podium(driver):
small_picks -=2
return big_picks + small_picks
def total_points_by(self, *, user_name: str, include_season: bool) -> int:
""" """
Returns the total number of points for a specific user. Returns the total number of points for a specific user.
""" """
return sum(self.points_by(user_name=user_name)) if include_season:
return sum(self.points_by(user_name=user_name)) + self.season_points_by(user_name=user_name)
else:
return sum(self.points_by(user_name=user_name))
def users_sorted_by_points(self) -> List[User]: def users_sorted_by_points(self, *, include_season: bool) -> List[User]:
""" """
Returns the list of users, sorted by their points from race guesses (in descending order). Returns the list of users, sorted by their points from race guesses (in descending order).
""" """
comparator: Callable[[User], int] = lambda user: self.total_points_by(user.name) comparator: Callable[[User], int] = lambda user: self.total_points_by(user_name=user.name, include_season=include_season)
return sorted(self.all_users(), key=comparator, reverse=True) return sorted(self.all_users(), key=comparator, reverse=True)
@cache.cached( @cache.cached(
timeout=None, key_prefix="points_user_standing" timeout=None, key_prefix="points_user_standing"
) # Cleanup when adding/updating race results or users ) # Cleanup when adding/updating race results or users
def user_standing(self) -> Dict[str, int]: def user_standing(self, *, include_season: bool) -> Dict[str, int]:
standing: Dict[str, int] = dict() standing: Dict[str, int] = dict()
position: int = 1 position: int = 1
last_points: int = 0 last_points: int = 0
for user in self.users_sorted_by_points(): for user in self.users_sorted_by_points(include_season=include_season):
if self.total_points_by(user.name) < last_points: if self.total_points_by(user_name=user.name, include_season=include_season) < last_points:
users_with_this_position = 0 users_with_this_position = 0
for _user, _position in standing.items(): for _user, _position in standing.items():
if _position == position: if _position == position:
@ -639,7 +693,7 @@ class PointsModel(Model):
standing[user.name] = position standing[user.name] = position
last_points = self.total_points_by(user.name) last_points = self.total_points_by(user_name=user.name, include_season=include_season)
return standing return standing
@ -671,7 +725,7 @@ class PointsModel(Model):
if self.picks_count(user_name) == 0: if self.picks_count(user_name) == 0:
return 0.0 return 0.0
return self.total_points_by(user_name) / self.picks_count(user_name) return self.total_points_by(user_name=user_name, include_season=False) / self.picks_count(user_name)
# #
# Season guess evaluation # Season guess evaluation
@ -738,12 +792,11 @@ class PointsModel(Model):
teammates: List[Driver] = self.drivers_by( teammates: List[Driver] = self.drivers_by(
team_name=driver.team.name, include_inactive=True team_name=driver.team.name, include_inactive=True
) )
teammate: Driver = teammates[0] if teammates[1] == driver else teammates[1]
return ( # Min - Highest position is the lowest place number
self.wdc_standing_by_driver()[driver.name] winner: Driver = min(teammates, key=lambda driver: self.wdc_standing_by_driver()[driver.name])
<= self.wdc_standing_by_driver()[teammate.name]
) return driver == winner
@cache.memoize( @cache.memoize(
timeout=None, args_to_ignore=["self"] timeout=None, args_to_ignore=["self"]

View File

@ -29,6 +29,8 @@
<th scope="col" class="text-center" style="min-width: 50px;">Place</th> <th scope="col" class="text-center" style="min-width: 50px;">Place</th>
<th scope="col" class="text-center" style="min-width: 50px;">User</th> <th scope="col" class="text-center" style="min-width: 50px;">User</th>
<th scope="col" class="text-center" style="min-width: 100px;">Points</th> <th scope="col" class="text-center" style="min-width: 100px;">Points</th>
<th scope="col" class="text-center" style="min-width: 100px;">Points (Race)</th>
<th scope="col" class="text-center" style="min-width: 100px;">Points (Season)</th>
<th scope="col" class="text-center" style="min-width: 100px;">Total picks</th> <th scope="col" class="text-center" style="min-width: 100px;">Total picks</th>
<th scope="col" class="text-center" style="min-width: 100px;" data-bs-toggle="tooltip" <th scope="col" class="text-center" style="min-width: 100px;" data-bs-toggle="tooltip"
title="Any points count as correct">Correct picks title="Any points count as correct">Correct picks
@ -38,12 +40,14 @@
</thead> </thead>
<tbody> <tbody>
{% for user in points.users_sorted_by_points() %} {% for user in points.users_sorted_by_points(include_season=True) %}
{% set user_standing = points.user_standing()[user.name] %} {% set user_standing = points.user_standing(include_season=True)[user.name] %}
<tr class="{% if user_standing == 1 %}table-danger{% endif %}"> <tr class="{% if user_standing == 1 %}table-danger{% endif %}">
<td class="text-center text-nowrap">{{ user_standing }}</td> <td class="text-center text-nowrap">{{ user_standing }}</td>
<td class="text-center text-nowrap">{{ user.name }}</td> <td class="text-center text-nowrap">{{ user.name }}</td>
<td class="text-center text-nowrap">{{ points.total_points_by(user.name) }}</td> <td class="text-center text-nowrap">{{ points.total_points_by(user_name=user.name, include_season=True) }}</td>
<td class="text-center text-nowrap">{{ points.total_points_by(user_name=user.name, include_season=False) }}</td>
<td class="text-center text-nowrap">{{ points.season_points_by(user_name=user.name) }}</td>
<td class="text-center text-nowrap">{{ points.picks_count(user.name) }}</td> <td class="text-center text-nowrap">{{ points.picks_count(user.name) }}</td>
<td class="text-center text-nowrap">{{ points.picks_with_points_count(user.name) }}</td> <td class="text-center text-nowrap">{{ points.picks_with_points_count(user.name) }}</td>
<td class="text-center text-nowrap">{{ "%0.2f" % points.points_per_pick(user.name) }}</td> <td class="text-center text-nowrap">{{ "%0.2f" % points.points_per_pick(user.name) }}</td>
@ -57,7 +61,7 @@
<div class="card shadow-sm mb-2"> <div class="card shadow-sm mb-2">
<div class="card-header"> <div class="card-header">
History History (Race)
</div> </div>
<div class="card-body"> <div class="card-body">

View File

@ -45,7 +45,7 @@
{% for user in model.all_users() %} {% for user in model.all_users() %}
<td class="text-center text-nowrap" style="min-width: 100px;"> <td class="text-center text-nowrap" style="min-width: 100px;">
<a href="/race/{{ user.name_sanitized }}" class="link-dark">{{ user.name }} <a href="/race/{{ user.name_sanitized }}" class="link-dark">{{ user.name }}
({{ points.total_points_by(user.name) }})</a> ({{ points.total_points_by(user_name=user.name, include_season=False) }})</a>
</td> </td>
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View File

@ -95,8 +95,10 @@
winners:</h6> winners:</h6>
<div class="grid mt-2 container" style="row-gap: 0;"> <div class="grid mt-2 container" style="row-gap: 0;">
{% for team in model.all_teams(include_none=false) %} {% for team in model.all_teams(include_none=false) %}
{% set driver_a = model.drivers_by(team_name=team.name, include_inactive=False)[0] %} {# HACK: Choosing 0 and 1 will chose the drivers with the lowest IDs (although there could be others). #}
{% set driver_b = model.drivers_by(team_name=team.name, include_inactive=False)[1] %} {# This means the drivers chosen from at the start of the season will be visible. #}
{% set driver_a = model.drivers_by(team_name=team.name, include_inactive=True)[0] %}
{% set driver_b = model.drivers_by(team_name=team.name, include_inactive=True)[1] %}
<div class="g-col-6"> <div class="g-col-6">
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
@ -133,8 +135,10 @@
title="Which driver will reach at least a single podium?">Drivers with podium(s):</h6> title="Which driver will reach at least a single podium?">Drivers with podium(s):</h6>
<div class="grid mt-2 container" style="row-gap: 0;"> <div class="grid mt-2 container" style="row-gap: 0;">
{% for team in model.all_teams(include_none=false) %} {% for team in model.all_teams(include_none=false) %}
{% set driver_a = model.drivers_by(team_name=team.name, include_inactive=False)[0] %} {# HACK: Choosing 0 and 1 will chose the drivers with the lowest IDs (although there could be others). #}
{% set driver_b = model.drivers_by(team_name=team.name, include_inactive=False)[1] %} {# This means the drivers chosen from at the start of the season will be visible. #}
{% set driver_a = model.drivers_by(team_name=team.name, include_inactive=True)[0] %}
{% set driver_b = model.drivers_by(team_name=team.name, include_inactive=True)[1] %}
<div class="g-col-6"> <div class="g-col-6">
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
@ -145,7 +149,8 @@
{% if (user_guess is not none) and (driver_a in user_guess.podiums) %}checked="checked"{% endif %} {% if (user_guess is not none) and (driver_a in user_guess.podiums) %}checked="checked"{% endif %}
{% if season_guess_open == false %}disabled="disabled"{% endif %}> {% if season_guess_open == false %}disabled="disabled"{% endif %}>
<label class="form-check-label {% if (user_guess is not none) and (driver_a in user_guess.podiums) and points.has_podium(driver_a) %}text-success <label class="form-check-label {% if (user_guess is not none) and (driver_a in user_guess.podiums) and points.has_podium(driver_a) %}text-success
{% elif (user_guess is not none) and (driver_a in user_guess.podiums) and (not points.has_podium(driver_a)) %}text-danger{% endif %}" {% elif (user_guess is not none) and (driver_a in user_guess.podiums) and (not points.has_podium(driver_a)) %}text-danger
{% elif (user_guess is not none) and (driver_a not in user_guess.podiums) and (points.has_podium(driver_a)) %}text-danger{% endif %}"
for="podium-{{ driver_a.id }}-{{ user.id }}">{{ driver_a.name }}</label> for="podium-{{ driver_a.id }}-{{ user.id }}">{{ driver_a.name }}</label>
</div> </div>
</div> </div>
@ -159,7 +164,8 @@
{% if (user_guess is not none) and (driver_b in user_guess.podiums) %}checked="checked"{% endif %} {% if (user_guess is not none) and (driver_b in user_guess.podiums) %}checked="checked"{% endif %}
{% if season_guess_open == false %}disabled="disabled"{% endif %}> {% if season_guess_open == false %}disabled="disabled"{% endif %}>
<label class="form-check-label {% if (user_guess is not none) and (driver_b in user_guess.podiums) and points.has_podium(driver_b) %}text-success <label class="form-check-label {% if (user_guess is not none) and (driver_b in user_guess.podiums) and points.has_podium(driver_b) %}text-success
{% elif (user_guess is not none) and (driver_b in user_guess.podiums) and (not points.has_podium(driver_b)) %}text-danger{% endif %}" {% elif (user_guess is not none) and (driver_b in user_guess.podiums) and (not points.has_podium(driver_b)) %}text-danger
{% elif (user_guess is not none) and (driver_b not in user_guess.podiums) and (points.has_podium(driver_b)) %}text-danger{% endif %}"
for="podium-{{ driver_b.id }}-{{ user.id }}">{{ driver_b.name }}</label> for="podium-{{ driver_b.id }}-{{ user.id }}">{{ driver_b.name }}</label>
</div> </div>
</div> </div>