Skip to content

Commit 3767979

Browse files
fgctariknz
andauthored
Show irating change prediction (#18)
Co-authored-by: Tarik Alani <[email protected]> Co-authored-by: tariknz <[email protected]>
1 parent def1e13 commit 3767979

File tree

7 files changed

+239
-6
lines changed

7 files changed

+239
-6
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"javascript.preferences.quoteStyle": "single",
44
"cSpell.words": [
55
"iracing",
6+
"irating",
67
"irdashies"
78
],
89
"svg.preview.background": "black"

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,30 @@ export const IsLappingAhead: Story = {
104104
},
105105
};
106106

107+
export const IRatingChange: Story = {
108+
name: 'iRating Positive Change',
109+
args: {
110+
...Primary.args,
111+
iratingChange: 10,
112+
},
113+
};
114+
115+
export const IRatingChangeNegative: Story = {
116+
name: 'iRating Negative Change',
117+
args: {
118+
...Primary.args,
119+
iratingChange: -58,
120+
},
121+
};
122+
123+
export const IRatingNoChange: Story = {
124+
name: 'iRating No Change',
125+
args: {
126+
...Primary.args,
127+
iratingChange: 0,
128+
},
129+
};
130+
107131
export const Relative = () => {
108132
const getRandomRating = () =>
109133
Math.floor(Math.random() * (1300 - 700 + 1)) + 700;

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { SpeakerHighIcon } from '@phosphor-icons/react';
1+
import { useMemo } from 'react';
2+
import { SpeakerHighIcon, CaretUpIcon, CaretDownIcon } from '@phosphor-icons/react';
23
import { getTailwindStyle } from '@irdashies/utils/colors';
34
import { formatTime } from '@irdashies/utils/time';
45

@@ -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,36 @@ 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+
let icon: React.ReactNode;
57+
58+
if (roundedChange > 0) {
59+
text = `${roundedChange}`;
60+
color = 'text-green-400';
61+
icon = <CaretUpIcon size={10} />;
62+
} else if (roundedChange < 0) {
63+
text = `${Math.abs(roundedChange)}`;
64+
color = 'text-red-400';
65+
icon = <CaretDownIcon size={10} />;
66+
} else {
67+
text = `${roundedChange}`;
68+
icon = null;
69+
}
70+
return { text, color, icon };
71+
}, [iratingChange]);
72+
4673
return (
4774
<tr
4875
key={carIdx}
@@ -82,6 +109,12 @@ export const DriverInfoRow = ({
82109
</div>
83110
</td>
84111
<td>{badge}</td>
112+
{iratingChange !== undefined && <td className={`px-2 text-left ${iratingChangeDisplay.color}`}>
113+
<span className="flex items-center gap-0.5">
114+
{iratingChangeDisplay.icon}
115+
{iratingChangeDisplay.text}
116+
</span>
117+
</td>}
85118
<td className={`px-2`}>{delta?.toFixed(1)}</td>
86119
<td className={`px-2 ${hasFastestTime ? 'text-purple-400' : ''}`}>
87120
{fastestTimeString}

src/frontend/components/Standings/Standings.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const Standings = () => {
3737
hasFastestTime={result.hasFastestTime}
3838
delta={result.delta}
3939
position={result.classPosition}
40+
iratingChange={result.iratingChange}
4041
lastTime={result.lastTime}
4142
fastestTime={result.fastestTime}
4243
onPitRoad={result.onPitRoad}

src/frontend/components/Standings/createStandings.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import type {
55
Session,
66
Telemetry,
77
} from '@irdashies/types';
8+
import {
9+
calculateIRatingGain,
10+
RaceResult,
11+
CalculationResult,
12+
} from '@irdashies/utils/iratingGain';
813

914
export interface Standings {
1015
carIdx: number;
@@ -33,6 +38,7 @@ export interface Standings {
3338
estLapTime: number;
3439
};
3540
radioActive?: boolean;
41+
iratingChange?: number;
3642
}
3743

3844
const calculateDelta = (
@@ -158,6 +164,43 @@ export const groupStandingsByClass = (standings: Standings[]) => {
158164
return sorted;
159165
};
160166

167+
/**
168+
* This method will augment the standings with iRating changes
169+
*/
170+
export const augmentStandingsWithIRating = (
171+
groupedStandings: [string, Standings[]][]
172+
): [string, Standings[]][] => {
173+
return groupedStandings.map(([classId, classStandings]) => {
174+
const raceResultsInput: RaceResult<number>[] = classStandings.map(
175+
(driverStanding) => ({
176+
driver: driverStanding.carIdx,
177+
finishRank: driverStanding.classPosition,
178+
startIRating: driverStanding.driver.rating,
179+
started: true, // This is a critical assumption.
180+
})
181+
);
182+
183+
if (raceResultsInput.length === 0) {
184+
return [classId, classStandings];
185+
}
186+
187+
const iratingCalculationResults = calculateIRatingGain(raceResultsInput);
188+
189+
const iratingChangeMap = new Map<number, number>();
190+
iratingCalculationResults.forEach((calcResult: CalculationResult<number>) => {
191+
iratingChangeMap.set(calcResult.raceResult.driver, calcResult.iratingChange);
192+
});
193+
194+
const augmentedClassStandings = classStandings.map(
195+
(driverStanding) => ({
196+
...driverStanding,
197+
iratingChange: iratingChangeMap.get(driverStanding.carIdx),
198+
})
199+
);
200+
return [classId, augmentedClassStandings];
201+
});
202+
};
203+
161204
/**
162205
* This method will slice up the standings and return only the relevant drivers
163206
* Top 3 drivers are always included for each class

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createDriverStandings,
1414
groupStandingsByClass,
1515
sliceRelevantDrivers,
16+
augmentStandingsWithIRating,
1617
} from '../createStandings';
1718

1819
export const useDriverStandings = ({ buffer }: { buffer: number }) => {
@@ -28,8 +29,8 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => {
2829
const carIdxTrackSurface = useTelemetry('CarIdxTrackSurface');
2930
const radioTransmitCarIdx = useTelemetry('RadioTransmitCarIdx');
3031

31-
const standings = useMemo(() => {
32-
const standings = createDriverStandings(
32+
const standingsWithGain = useMemo(() => {
33+
const initialStandings = createDriverStandings(
3334
{
3435
playerIdx: driverCarIdx,
3536
drivers: sessionDrivers,
@@ -47,8 +48,14 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => {
4748
sessionType,
4849
}
4950
);
50-
const grouped = groupStandingsByClass(standings);
51-
return sliceRelevantDrivers(grouped, { buffer });
51+
const groupedByClass = groupStandingsByClass(initialStandings);
52+
53+
// Calculate iRating changes for race sessions
54+
const augmentedGroupedByClass = sessionType === 'Race'
55+
? augmentStandingsWithIRating(groupedByClass)
56+
: groupedByClass;
57+
58+
return sliceRelevantDrivers(augmentedGroupedByClass, { buffer });
5259
}, [
5360
driverCarIdx,
5461
sessionDrivers,
@@ -63,5 +70,5 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => {
6370
buffer,
6471
]);
6572

66-
return standings;
73+
return standingsWithGain;
6774
};

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)