Skip to content

Commit 5ce5cbe

Browse files
committed
show irating change prediction
1 parent 76b1ba7 commit 5ce5cbe

File tree

4 files changed

+203
-5
lines changed

4 files changed

+203
-5
lines changed

src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useMemo } from 'react';
12
import { SpeakerHigh } from '@phosphor-icons/react';
23
import { getTailwindStyle } from '../../../utils/colors';
34
import { formatTime } from '../../../utils/time';
@@ -12,6 +13,7 @@ interface DriverRowInfoProps {
1213
delta?: number;
1314
position: number;
1415
badge: React.ReactNode;
16+
iratingChange?: number;
1517
lastTime?: number;
1618
fastestTime?: number;
1719
onPitRoad?: boolean;
@@ -38,11 +40,32 @@ export const DriverInfoRow = ({
3840
radioActive,
3941
isLapped,
4042
isLappingAhead,
43+
iratingChange,
4144
}: DriverRowInfoProps) => {
4245
// convert seconds to mm:ss:ms
4346
const lastTimeString = formatTime(lastTime);
4447
const fastestTimeString = formatTime(fastestTime);
4548

49+
const iratingChangeDisplay = useMemo(() => {
50+
if (iratingChange === undefined || iratingChange === null) {
51+
return { text: '-', color: 'text-gray-400' };
52+
}
53+
const roundedChange = Math.round(iratingChange);
54+
let text: string;
55+
let color = 'text-gray-400';
56+
57+
if (roundedChange > 0) {
58+
text = `▲${roundedChange}`;
59+
color = 'text-green-400';
60+
} else if (roundedChange < 0) {
61+
text = `▼${Math.abs(roundedChange)}`;
62+
color = 'text-red-400';
63+
} else {
64+
text = `${roundedChange}`;
65+
}
66+
return { text, color };
67+
}, [iratingChange]);
68+
4669
return (
4770
<tr
4871
key={carIdx}
@@ -82,6 +105,9 @@ export const DriverInfoRow = ({
82105
</div>
83106
</td>
84107
<td>{badge}</td>
108+
<td className={`px-2 text-left ${iratingChangeDisplay.color}`}>
109+
{iratingChangeDisplay.text}
110+
</td>
85111
<td className={`px-2`}>{delta?.toFixed(1)}</td>
86112
<td className={`px-2 ${hasFastestTime ? 'text-purple-400' : ''}`}>
87113
{fastestTimeString}

src/frontend/components/Standings/Standings.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useAutoAnimate } from '@formkit/auto-animate/react';
44
import { DriverClassHeader } from './DriverClassHeader/DriverClassHeader';
55
import { SessionBar } from './SessionBar/SessionBar';
66
import { Fragment } from 'react/jsx-runtime';
7+
import { StandingsWithIRatingGain } from './hooks';
78
import { useCarClassStats, useDriverStandings } from './hooks';
89
import { SessionFooter } from './SessionFooter/SessionFooter';
910

@@ -37,6 +38,7 @@ export const Standings = () => {
3738
hasFastestTime={result.hasFastestTime}
3839
delta={result.delta}
3940
position={result.classPosition}
41+
iratingChange={(result as StandingsWithIRatingGain).iratingChange}
4042
lastTime={result.lastTime}
4143
fastestTime={result.fastestTime}
4244
onPitRoad={result.onPitRoad}

src/frontend/components/Standings/hooks/useDriverStandings.tsx

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,17 @@ import {
1313
createDriverStandings,
1414
groupStandingsByClass,
1515
sliceRelevantDrivers,
16+
Standings as DriverStandingInfo, // Renaming to avoid conflict if needed locally
1617
} from '../createStandings';
18+
import {
19+
calculateIRatingGain,
20+
RaceResult,
21+
CalculationResult,
22+
} from '../../../utils/iratingGain';
23+
24+
export interface StandingsWithIRatingGain extends DriverStandingInfo {
25+
iratingChange?: number;
26+
}
1727

1828
export const useDriverStandings = ({ buffer }: { buffer: number }) => {
1929
const sessionDrivers = useSessionDrivers();
@@ -28,8 +38,8 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => {
2838
const carIdxTrackSurface = useTelemetry('CarIdxTrackSurface');
2939
const radioTransmitCarIdx = useTelemetry('RadioTransmitCarIdx');
3040

31-
const standings = useMemo(() => {
32-
const standings = createDriverStandings(
41+
const standingsWithGain = useMemo(() => {
42+
const initialStandings = createDriverStandings(
3343
{
3444
playerIdx: driverCarIdx,
3545
drivers: sessionDrivers,
@@ -47,8 +57,44 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => {
4757
sessionType,
4858
}
4959
);
50-
const grouped = groupStandingsByClass(standings);
51-
return sliceRelevantDrivers(grouped, { buffer });
60+
const groupedByClass = groupStandingsByClass(initialStandings);
61+
62+
const augmentedGroupedByClass: [string, StandingsWithIRatingGain[]][] = groupedByClass.map(
63+
([classId, classStandings]) => {
64+
const raceResultsInput: RaceResult<number>[] = classStandings.map(
65+
(driverStanding) => ({
66+
driver: driverStanding.carIdx,
67+
finishRank: driverStanding.classPosition,
68+
startIRating: driverStanding.driver.rating,
69+
started: true, // This is a critical assumption.
70+
}),
71+
);
72+
73+
if (raceResultsInput.length === 0) {
74+
return [classId, classStandings as StandingsWithIRatingGain[]];
75+
}
76+
77+
const iratingCalculationResults = calculateIRatingGain(raceResultsInput);
78+
79+
const iratingChangeMap = new Map<number, number>();
80+
iratingCalculationResults.forEach((calcResult: CalculationResult<number>) => {
81+
iratingChangeMap.set(
82+
calcResult.raceResult.driver,
83+
calcResult.iratingChange,
84+
);
85+
});
86+
87+
const augmentedClassStandings: StandingsWithIRatingGain[] = classStandings.map(
88+
(driverStanding) => ({
89+
...driverStanding,
90+
iratingChange: iratingChangeMap.get(driverStanding.carIdx),
91+
}),
92+
);
93+
return [classId, augmentedClassStandings];
94+
},
95+
);
96+
97+
return sliceRelevantDrivers(augmentedGroupedByClass, { buffer });
5298
}, [
5399
driverCarIdx,
54100
sessionDrivers,
@@ -63,5 +109,5 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => {
63109
buffer,
64110
]);
65111

66-
return standings;
112+
return standingsWithGain;
67113
};

src/frontend/utils/iratingGain.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Based on the Rust implementation provided in https://github.com/Turbo87/irating-rs
2+
3+
export interface RaceResult<D = unknown> {
4+
driver: D;
5+
finishRank: number; // u32
6+
startIRating: number; // u32
7+
started: boolean;
8+
}
9+
10+
export interface CalculationResult<D = unknown> {
11+
raceResult: RaceResult<D>;
12+
iratingChange: number; // f32
13+
newIRating: number; // u32
14+
}
15+
16+
function chance(a: number, b: number, factor: number): number {
17+
const expA = Math.exp(-a / factor);
18+
const expB = Math.exp(-b / factor);
19+
return (
20+
((1 - expA) * expB) / ((1 - expB) * expA + (1 - expA) * expB)
21+
);
22+
}
23+
24+
export function calculateIRatingGain<D>(
25+
raceResults: RaceResult<D>[],
26+
): CalculationResult<D>[] {
27+
const br1 = 1600 / Math.LN2;
28+
29+
const numRegistrations = raceResults.length;
30+
if (numRegistrations === 0) {
31+
return [];
32+
}
33+
34+
const numStarters = raceResults.filter((r) => r.started).length;
35+
const numNonStarters = numRegistrations - numStarters;
36+
37+
const chances: number[][] = raceResults.map((resultA) =>
38+
raceResults.map((resultB) =>
39+
chance(resultA.startIRating, resultB.startIRating, br1),
40+
),
41+
);
42+
43+
const expectedScores: number[] = chances.map(
44+
(chancesRow) => chancesRow.reduce((sum, val) => sum + val, 0) - 0.5,
45+
);
46+
47+
const fudgeFactors: number[] = raceResults.map((result) => {
48+
if (!result.started) {
49+
return 0;
50+
}
51+
const x = numRegistrations - numNonStarters / 2;
52+
return (x / 2 - result.finishRank) / 100;
53+
});
54+
55+
let sumChangesStarters = 0;
56+
const changesStarters: (number | null)[] = raceResults.map(
57+
(result, index) => {
58+
if (!result.started) {
59+
return null;
60+
}
61+
if (numStarters === 0) return 0;
62+
63+
const expectedScore = expectedScores[index];
64+
const fudgeFactor = fudgeFactors[index];
65+
const change =
66+
((numRegistrations -
67+
result.finishRank -
68+
expectedScore -
69+
fudgeFactor) *
70+
200) /
71+
numStarters;
72+
sumChangesStarters += change;
73+
return change;
74+
},
75+
);
76+
77+
const expectedScoreNonStartersList: (number | null)[] = raceResults.map(
78+
(result, index) => (!result.started ? expectedScores[index] : null)
79+
);
80+
81+
const sumExpectedScoreNonStarters: number = expectedScoreNonStartersList
82+
.filter((score): score is number => score !== null)
83+
.reduce((sum, val) => sum + val, 0);
84+
85+
const avgExpectedScoreNonStarters = numNonStarters > 0 ? sumExpectedScoreNonStarters / numNonStarters : 0;
86+
87+
const changesNonStarters: (number | null)[] = expectedScoreNonStartersList.map(
88+
(expectedScore) => {
89+
if (expectedScore === null) {
90+
return null;
91+
}
92+
if (numNonStarters === 0) return 0;
93+
if (avgExpectedScoreNonStarters === 0) return 0;
94+
95+
return (
96+
(-sumChangesStarters / numNonStarters) *
97+
(expectedScore / avgExpectedScoreNonStarters)
98+
);
99+
},
100+
);
101+
102+
const changes: number[] = raceResults.map((_result, index) => {
103+
const starterChange = changesStarters[index];
104+
const nonStarterChange = changesNonStarters[index];
105+
106+
if (starterChange !== null) {
107+
return starterChange;
108+
}
109+
if (nonStarterChange !== null) {
110+
return nonStarterChange;
111+
}
112+
throw new Error(`Driver at index ${index} is neither starter nor non-starter.`);
113+
});
114+
115+
return raceResults.map((result, index) => {
116+
const change = changes[index];
117+
const newIRating = Math.max(0, Math.round(result.startIRating + change));
118+
return {
119+
raceResult: result,
120+
iratingChange: change,
121+
newIRating: newIRating,
122+
};
123+
});
124+
}

0 commit comments

Comments
 (0)