Skip to content

Commit ad47603

Browse files
committed
Adds match forecasting, EPA support, and UI polish
Introduces match forecasting and upcoming/noteworthy match features, and adds EPA as a prediction model alongside OPR and XGB. Updates UI for better form consistency, chip presets, and improved interaction for team/event comparison and season records. Expands translations, updates site branding to "FTC Stats", improves accessibility, and adds more detailed team and event insights. Enhances simulation controls, event links, and match prediction explanations for greater clarity and flexibility.
1 parent f2c7d50 commit ad47603

67 files changed

Lines changed: 5686 additions & 483 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from "./logic/stats/make-tep-stats";
1717
export * from "./logic/stats/make-match-stats";
1818
export * from "./logic/predictions/match-prediction";
1919
export * from "./logic/predictions/event-simulation";
20+
export * from "./logic/predictions/epa";
2021
export * from "./logic/TournamentLevel";
2122
export * from "./utils/filter";
2223
export * from "./utils/gql/enum";
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Alliance } from "../Alliance";
2+
import { DESCRIPTORS } from "../descriptors/descriptor-list";
3+
import { Season } from "../Season";
4+
import { TournamentLevel } from "../TournamentLevel";
5+
6+
export type EpaMatch = {
7+
tournamentLevel: TournamentLevel;
8+
scheduledStartTime?: string | null;
9+
actualStartTime?: string | null;
10+
teams: {
11+
teamNumber: number | null;
12+
alliance: Alliance;
13+
surrogate?: boolean | null;
14+
}[];
15+
scores:
16+
| {
17+
red: { totalPoints: number; totalPointsNp?: number | null };
18+
blue: { totalPoints: number; totalPointsNp?: number | null };
19+
}
20+
| null
21+
| undefined;
22+
};
23+
24+
export type EpaOptions = {
25+
baseK?: number;
26+
includeElims?: boolean;
27+
};
28+
29+
function getTimestamp(match: EpaMatch): number {
30+
const raw = match.actualStartTime ?? match.scheduledStartTime;
31+
if (!raw) return 0;
32+
const time = Date.parse(raw);
33+
return Number.isFinite(time) ? time : 0;
34+
}
35+
36+
function getScore(season: Season, score: { totalPoints: number; totalPointsNp?: number | null }) {
37+
const descriptor = DESCRIPTORS[season];
38+
if (!descriptor.pensSubtract && typeof score.totalPointsNp === "number") {
39+
return score.totalPointsNp;
40+
}
41+
return score.totalPoints;
42+
}
43+
44+
export function calculateEpaRatings(
45+
matches: EpaMatch[],
46+
season: Season,
47+
options: EpaOptions = {}
48+
): Record<number, number> {
49+
const baseK = options.baseK ?? 0.25;
50+
const includeElims = options.includeElims ?? false;
51+
52+
const ratings = new Map<number, number>();
53+
const counts = new Map<number, number>();
54+
55+
const sorted = [...matches].sort((a, b) => getTimestamp(a) - getTimestamp(b));
56+
57+
for (const match of sorted) {
58+
if (!match.scores || !("red" in match.scores)) continue;
59+
if (!includeElims && match.tournamentLevel !== TournamentLevel.Quals) continue;
60+
61+
const redTeams = match.teams
62+
.filter((t) => t.alliance === Alliance.Red && !t.surrogate)
63+
.map((t) => t.teamNumber)
64+
.filter((t): t is number => typeof t === "number");
65+
const blueTeams = match.teams
66+
.filter((t) => t.alliance === Alliance.Blue && !t.surrogate)
67+
.map((t) => t.teamNumber)
68+
.filter((t): t is number => typeof t === "number");
69+
70+
if (!redTeams.length || !blueTeams.length) continue;
71+
72+
const redScore = getScore(season, match.scores.red);
73+
const blueScore = getScore(season, match.scores.blue);
74+
75+
const redPred = redTeams.reduce((sum, team) => sum + (ratings.get(team) ?? 0), 0);
76+
const bluePred = blueTeams.reduce((sum, team) => sum + (ratings.get(team) ?? 0), 0);
77+
78+
const redError = redScore - redPred;
79+
const blueError = blueScore - bluePred;
80+
81+
for (const team of redTeams) {
82+
const count = counts.get(team) ?? 0;
83+
const k = baseK / Math.sqrt(count + 1);
84+
const next = (ratings.get(team) ?? 0) + (k * redError) / redTeams.length;
85+
ratings.set(team, next);
86+
counts.set(team, count + 1);
87+
}
88+
89+
for (const team of blueTeams) {
90+
const count = counts.get(team) ?? 0;
91+
const k = baseK / Math.sqrt(count + 1);
92+
const next = (ratings.get(team) ?? 0) + (k * blueError) / blueTeams.length;
93+
ratings.set(team, next);
94+
counts.set(team, count + 1);
95+
}
96+
}
97+
98+
return Object.fromEntries(ratings.entries());
99+
}

packages/common/src/logic/predictions/match-prediction.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,28 @@ function calculateWinProbability(scoreDiff: number, variance: number): number {
3232
return 1 / (1 + Math.exp(-k * normalizedDiff));
3333
}
3434

35+
/**
36+
* Predict match outcome from precomputed alliance scores.
37+
*/
38+
export function predictMatchFromScores(
39+
predictedRedScore: number,
40+
predictedBlueScore: number,
41+
variance: number = 100
42+
): MatchPrediction {
43+
const scoreDiff = predictedRedScore - predictedBlueScore;
44+
const redWinProb = calculateWinProbability(scoreDiff, variance);
45+
const blueWinProb = 1 - redWinProb;
46+
const confidence = Math.min(Math.abs(scoreDiff) / 50, 1);
47+
48+
return {
49+
redWinProbability: redWinProb,
50+
blueWinProbability: blueWinProb,
51+
predictedRedScore: Math.max(0, predictedRedScore),
52+
predictedBlueScore: Math.max(0, predictedBlueScore),
53+
confidence,
54+
};
55+
}
56+
3557
/**
3658
* Predict match outcome based on alliance OPRs
3759
*

packages/server/src/graphql/resolvers/Home.ts

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import {
2+
Alliance,
3+
BoolTy,
24
DESCRIPTORS,
35
DateTimeTy,
46
EventTypeOption,
57
IntTy,
8+
RegionOption,
9+
RemoteOption,
610
Season,
11+
TournamentLevel,
712
getEventTypes,
13+
getRegionCodes,
814
list,
915
nn,
1016
nullTy,
@@ -17,7 +23,58 @@ import { EventGQL } from "./Event";
1723
import { Event } from "../../db/entities/Event";
1824
import { MatchGQL } from "./Match";
1925
import { MatchScore } from "../../db/entities/dyn/match-score";
20-
import { EventTypeOptionGQL } from "./enums";
26+
import { EventTypeOptionGQL, RegionOptionGQL, RemoteOptionGQL } from "./enums";
27+
import { GraphQLObjectType } from "graphql";
28+
import { singleSeasonScoreAwareMatchLoader } from "./Match";
29+
import { DateTime } from "luxon";
30+
import { SelectQueryBuilder } from "typeorm";
31+
32+
const NoteworthyMatchesGQL = new GraphQLObjectType({
33+
name: "NoteworthyMatches",
34+
fields: {
35+
highScore: { type: list(nn(MatchGQL)) },
36+
combinedScore: { type: list(nn(MatchGQL)) },
37+
losingScore: { type: list(nn(MatchGQL)) },
38+
},
39+
});
40+
41+
type MatchKey = { eventSeason: Season; eventCode: string; id: number };
42+
43+
async function loadMatches(keys: MatchKey[]) {
44+
if (!keys.length) return [];
45+
const matches = await singleSeasonScoreAwareMatchLoader(keys, [], true, true);
46+
const byKey = new Map(matches.map((m) => [`${m.eventSeason}-${m.eventCode}-${m.id}`, m]));
47+
return keys
48+
.map((k) => byKey.get(`${k.eventSeason}-${k.eventCode}-${k.id}`))
49+
.filter((m): m is Match => !!m);
50+
}
51+
52+
function applyEventFilters(
53+
q: SelectQueryBuilder<Match>,
54+
{
55+
region,
56+
type,
57+
remote,
58+
}: {
59+
region?: RegionOption | null | undefined;
60+
type?: EventTypeOption | null | undefined;
61+
remote?: RemoteOption | null | undefined;
62+
}
63+
) {
64+
if (region && region !== RegionOption.All) {
65+
q.andWhere("e.region_code IN (:...regions)", { regions: getRegionCodes(region) });
66+
}
67+
68+
if (type && type !== EventTypeOption.All) {
69+
q.andWhere("e.type IN (:...types)", { types: getEventTypes(type) });
70+
}
71+
72+
if (remote && remote !== RemoteOption.All) {
73+
q.andWhere("e.remote = :remote", { remote: remote === RemoteOption.Remote });
74+
}
75+
76+
return q;
77+
}
2178

2279
export const HomeQueries: Record<string, GraphQLFieldConfig<any, any>> = {
2380
activeTeamsCount: {
@@ -119,4 +176,170 @@ export const HomeQueries: Record<string, GraphQLFieldConfig<any, any>> = {
119176
.getOne();
120177
},
121178
},
179+
180+
upcomingMatches: {
181+
type: list(nn(MatchGQL)),
182+
args: {
183+
season: IntTy,
184+
limit: nullTy(IntTy),
185+
minutes: nullTy(IntTy),
186+
region: { type: RegionOptionGQL },
187+
type: { type: EventTypeOptionGQL },
188+
remote: { type: RemoteOptionGQL },
189+
elim: nullTy(BoolTy),
190+
},
191+
resolve: async (
192+
_,
193+
{
194+
season,
195+
limit,
196+
minutes,
197+
region,
198+
type,
199+
remote,
200+
elim,
201+
}: {
202+
season: number;
203+
limit?: number | null;
204+
minutes?: number | null;
205+
region?: RegionOption | null;
206+
type?: EventTypeOption | null;
207+
remote?: RemoteOption | null;
208+
elim?: boolean | null;
209+
}
210+
) => {
211+
const windowMinutes = minutes ?? 60 * 24;
212+
const start = DateTime.now().minus({ minutes: 5 }).toJSDate();
213+
const end = DateTime.now().plus({ minutes: windowMinutes }).toJSDate();
214+
215+
let q = DATA_SOURCE.getRepository(Match)
216+
.createQueryBuilder("m")
217+
.leftJoin(Event, "e", "e.season = m.event_season AND e.code = m.event_code")
218+
.select("m.event_season", "eventSeason")
219+
.addSelect("m.event_code", "eventCode")
220+
.addSelect("m.id", "id")
221+
.where("m.event_season = :season", { season })
222+
.andWhere("NOT m.has_been_played")
223+
.andWhere("m.scheduled_start_time IS NOT NULL")
224+
.andWhere("m.scheduled_start_time > :start", { start })
225+
.andWhere("m.scheduled_start_time < :end", { end });
226+
227+
if (elim !== null && elim !== undefined) {
228+
if (elim) {
229+
q.andWhere("m.tournament_level <> :quals", { quals: TournamentLevel.Quals });
230+
} else {
231+
q.andWhere("m.tournament_level = :quals", { quals: TournamentLevel.Quals });
232+
}
233+
}
234+
235+
q = applyEventFilters(q, { region, type, remote });
236+
237+
q.orderBy("m.scheduled_start_time", "ASC").limit(Math.min(limit ?? 50, 100));
238+
239+
const raw = await q.getRawMany();
240+
const keys: MatchKey[] = raw.map((r) => ({
241+
eventSeason: +r.eventSeason as Season,
242+
eventCode: r.eventCode,
243+
id: +r.id,
244+
}));
245+
246+
return loadMatches(keys);
247+
},
248+
},
249+
250+
noteworthyMatches: {
251+
type: NoteworthyMatchesGQL,
252+
args: {
253+
season: IntTy,
254+
limit: nullTy(IntTy),
255+
region: { type: RegionOptionGQL },
256+
type: { type: EventTypeOptionGQL },
257+
remote: { type: RemoteOptionGQL },
258+
},
259+
resolve: async (
260+
_,
261+
{
262+
season,
263+
limit,
264+
region,
265+
type,
266+
remote,
267+
}: {
268+
season: number;
269+
limit?: number | null;
270+
region?: RegionOption | null;
271+
type?: EventTypeOption | null;
272+
remote?: RemoteOption | null;
273+
}
274+
) => {
275+
const descriptor = DESCRIPTORS[season as Season];
276+
const scoreKey = descriptor.pensSubtract ? "totalPoints" : "totalPointsNp";
277+
const ns = DATA_SOURCE.namingStrategy;
278+
const scoreCol = ns.columnName(scoreKey, undefined, []);
279+
280+
const base = DATA_SOURCE.getRepository(Match)
281+
.createQueryBuilder("m")
282+
.innerJoin(
283+
`match_score_${season}`,
284+
"red",
285+
"m.event_season = red.season AND m.event_code = red.event_code AND m.id = red.match_id AND red.alliance = :red",
286+
{ red: Alliance.Red }
287+
)
288+
.innerJoin(
289+
`match_score_${season}`,
290+
"blue",
291+
"m.event_season = blue.season AND m.event_code = blue.event_code AND m.id = blue.match_id AND blue.alliance = :blue",
292+
{ blue: Alliance.Blue }
293+
)
294+
.leftJoin(Event, "e", "e.season = m.event_season AND e.code = m.event_code")
295+
.select("m.event_season", "eventSeason")
296+
.addSelect("m.event_code", "eventCode")
297+
.addSelect("m.id", "id")
298+
.where("m.event_season = :season", { season })
299+
.andWhere("m.has_been_played")
300+
.andWhere("NOT e.modified_rules");
301+
302+
const filtered = applyEventFilters(base, { region, type, remote });
303+
304+
const maxScoreExpr = `GREATEST(red.${scoreCol}, blue.${scoreCol})`;
305+
const sumScoreExpr = `(red.${scoreCol} + blue.${scoreCol})`;
306+
const losingScoreExpr = `LEAST(red.${scoreCol}, blue.${scoreCol})`;
307+
308+
const cap = Math.min(limit ?? 30, 100);
309+
310+
const highRaw = await filtered
311+
.clone()
312+
.orderBy(maxScoreExpr, "DESC")
313+
.addOrderBy("m.scheduled_start_time", "ASC")
314+
.limit(cap)
315+
.getRawMany();
316+
317+
const combinedRaw = await filtered
318+
.clone()
319+
.orderBy(sumScoreExpr, "DESC")
320+
.addOrderBy("m.scheduled_start_time", "ASC")
321+
.limit(cap)
322+
.getRawMany();
323+
324+
const losingRaw = await filtered
325+
.clone()
326+
.orderBy(losingScoreExpr, "DESC")
327+
.addOrderBy("m.scheduled_start_time", "ASC")
328+
.limit(cap)
329+
.getRawMany();
330+
331+
const toKeys = (rows: any[]): MatchKey[] =>
332+
rows.map((r) => ({
333+
eventSeason: +r.eventSeason as Season,
334+
eventCode: r.eventCode,
335+
id: +r.id,
336+
}));
337+
338+
return {
339+
highScore: await loadMatches(toKeys(highRaw)),
340+
combinedScore: await loadMatches(toKeys(combinedRaw)),
341+
losingScore: await loadMatches(toKeys(losingRaw)),
342+
};
343+
},
344+
},
122345
};

0 commit comments

Comments
 (0)