From f260c87da0245cd891e0767504c2dd7179961620 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Tue, 24 Mar 2026 21:44:03 -0700 Subject: [PATCH 01/13] Update by team export csv to use teams from TBA --- src/handler/analysis/csv/getTeamCSV.ts | 75 ++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/handler/analysis/csv/getTeamCSV.ts b/src/handler/analysis/csv/getTeamCSV.ts index b542206..0217b6b 100644 --- a/src/handler/analysis/csv/getTeamCSV.ts +++ b/src/handler/analysis/csv/getTeamCSV.ts @@ -1,4 +1,5 @@ import { Response } from "express"; +import axios from "axios"; import prismaClient from "../../../prismaClient.js"; import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; import { stringify } from "csv-stringify/sync"; @@ -181,6 +182,80 @@ export const getTeamCSV = async ( }); if (datapoints.length === 0) { + // No TeamMatchData found - try to fetch teams from TBA + try { + const url = "https://www.thebluealliance.com/api/v3"; + const teamsResponse = await axios.get( + `${url}/event/${params.data.tournamentKey}/teams/simple`, + { + headers: { + "X-TBA-Auth-Key": process.env.TBA_KEY, + }, + }, + ); + + if (teamsResponse.data && teamsResponse.data.length > 0) { + const teams = teamsResponse.data.map((t: any) => t.team_number as number); + + // Create empty aggregated data for each team from TBA + const emptyData: AggregatedTeamData[] = teams.map((teamNum: number) => ({ + teamNumber: teamNum, + mainRole: "NONE", + secondaryRole: "NONE", + fieldTraversal: null, + avgTotalPoints: 0, + avgAutoPoints: 0, + avgTeleopPoints: 0, + avgFuelPerSecond: 0, + avgAccuracy: 0, + avgVolleysPerMatch: 0, + avgL1StartTime: 0, + avgL2StartTime: 0, + avgL3StartTime: 0, + avgAutoClimbStartTime: 0, + avgDriverAbility: 0, + avgContactDefenseTime: 0, + avgDefenseEffectiveness: 0, + avgCampingDefenseTime: 0, + avgTotalDefenseTime: 0, + avgTimeFeeding: 0, + avgFeedingRate: 0, + avgFeedsPerMatch: 0, + avgTotalFuelOutputted: 0, + avgTotalBallsFed: 0, + avgTotalBallThroughput: 0, + avgOutpostIntakes: 0, + percDisrupts: 0, + percScoresWhileMoving: 0, + percClimbOne: 0, + percClimbTwo: 0, + percClimbThree: 0, + percNoClimb: 0, + percAutoClimb: 0, + matchesImmobile: 0, + numMatches: 0, + numReports: 0, + })); + + const csvString = stringify(emptyData, { + header: true, + columns: emptyData.length ? Object.keys(emptyData[0]) : [], + bom: true, + cast: { + boolean: (b) => (b ? "TRUE" : "FALSE"), + }, + quote: false, + }); + + res.attachment("teamDataDownload.csv"); + res.header("Content-Type", "text/csv"); + res.send(csvString); + return; + } + } catch (error) { + console.error("Error fetching teams from TBA:", error); + } + res.status(400).send("Not enough scouting data from provided sources"); return; } From a4f85eaa59704137d82582c068d9c418618cd8e7 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Tue, 24 Mar 2026 22:45:54 -0700 Subject: [PATCH 02/13] fix team csv export --- src/handler/analysis/csv/getTeamCSV.ts | 201 +++++++++++++++++++------ 1 file changed, 158 insertions(+), 43 deletions(-) diff --git a/src/handler/analysis/csv/getTeamCSV.ts b/src/handler/analysis/csv/getTeamCSV.ts index 0217b6b..8d67324 100644 --- a/src/handler/analysis/csv/getTeamCSV.ts +++ b/src/handler/analysis/csv/getTeamCSV.ts @@ -182,7 +182,7 @@ export const getTeamCSV = async ( }); if (datapoints.length === 0) { - // No TeamMatchData found - try to fetch teams from TBA + // No TeamMatchData found - try to fetch teams from TBA and get their reports from all tournaments try { const url = "https://www.thebluealliance.com/api/v3"; const teamsResponse = await axios.get( @@ -195,51 +195,166 @@ export const getTeamCSV = async ( ); if (teamsResponse.data && teamsResponse.data.length > 0) { - const teams = teamsResponse.data.map((t: any) => t.team_number as number); + const teams = (teamsResponse.data as { team_number: number }[]).map( + (t) => t.team_number + ); - // Create empty aggregated data for each team from TBA - const emptyData: AggregatedTeamData[] = teams.map((teamNum: number) => ({ - teamNumber: teamNum, - mainRole: "NONE", - secondaryRole: "NONE", - fieldTraversal: null, - avgTotalPoints: 0, - avgAutoPoints: 0, - avgTeleopPoints: 0, - avgFuelPerSecond: 0, - avgAccuracy: 0, - avgVolleysPerMatch: 0, - avgL1StartTime: 0, - avgL2StartTime: 0, - avgL3StartTime: 0, - avgAutoClimbStartTime: 0, - avgDriverAbility: 0, - avgContactDefenseTime: 0, - avgDefenseEffectiveness: 0, - avgCampingDefenseTime: 0, - avgTotalDefenseTime: 0, - avgTimeFeeding: 0, - avgFeedingRate: 0, - avgFeedsPerMatch: 0, - avgTotalFuelOutputted: 0, - avgTotalBallsFed: 0, - avgTotalBallThroughput: 0, - avgOutpostIntakes: 0, - percDisrupts: 0, - percScoresWhileMoving: 0, - percClimbOne: 0, - percClimbTwo: 0, - percClimbThree: 0, - percNoClimb: 0, - percAutoClimb: 0, - matchesImmobile: 0, - numMatches: 0, - numReports: 0, - })); + // Build the data source filters + const parsedTeamRule = dataSourceRuleSchema(z.number()).safeParse( + req.user?.teamSourceRule, + ); + const teamFilter = parsedTeamRule.success + ? dataSourceRuleToPrismaFilter(parsedTeamRule.data) + : undefined; - const csvString = stringify(emptyData, { + const parsedTournamentRule = dataSourceRuleSchema(z.string()).safeParse( + req.user?.tournamentSourceRule, + ); + const tournamentFilter = parsedTournamentRule.success + ? dataSourceRuleToPrismaFilter(parsedTournamentRule.data) + : undefined; + + // Fetch all scout reports for these teams from filtered tournaments + const allReports = await prismaClient.scoutReport.findMany({ + where: { + teamMatchData: { + teamNumber: { in: teams }, + ...(tournamentFilter ? { tournamentKey: tournamentFilter } : {}), + }, + ...(teamFilter ? { scouter: { sourceTeamNumber: teamFilter } } : {}), + }, + select: { + uuid: true, + robotRoles: true, + accuracy: true, + endgameClimb: true, + autoClimb: true, + fieldTraversal: true, + driverAbility: true, + defenseEffectiveness: true, + beached: true, + climbSide: true, + climbPosition: true, + scoresWhileMoving: true, + disrupts: true, + feederTypes: true, + intakeType: true, + teamMatchData: { + select: { + teamNumber: true, + }, + }, + events: { + where: eventTimeFilter, + select: { + time: true, + action: true, + position: true, + points: true, + }, + }, + }, + }); + + if (allReports.length === 0) { + res.status(400).send("Not enough scouting data from provided sources"); + return; + } + + // Group reports by team number + const groupedByTeam: Record = {}; + + for (const report of allReports) { + const teamNum = report.teamMatchData.teamNumber; + if (!groupedByTeam[teamNum]) { + groupedByTeam[teamNum] = { reports: [], numMatches: 0 }; + } + groupedByTeam[teamNum].reports.push({ + ...report, + weight: 1, + }); + } + + // Count unique matches per team + const matchCounts = await prismaClient.teamMatchData.groupBy({ + by: ['teamNumber'], + where: { + teamNumber: { in: teams }, + ...(tournamentFilter ? { tournamentKey: tournamentFilter } : {}), + scoutReports: { + some: teamFilter + ? { scouter: { sourceTeamNumber: teamFilter } } + : {}, + }, + }, + _count: { + key: true, + }, + }); + + for (const count of matchCounts) { + if (groupedByTeam[count.teamNumber]) { + groupedByTeam[count.teamNumber].numMatches = count._count.key; + } + } + + // Aggregate data for teams with reports + const teamsWithReports = teams.filter((t: number) => groupedByTeam[t]?.reports?.length > 0); + + if (teamsWithReports.length === 0) { + res.status(400).send("Not enough scouting data from provided sources"); + return; + } + + // Compute metrics using averageManyFast + const metrics: Metric[] = [ + Metric.totalPoints, + Metric.autoPoints, + Metric.teleopPoints, + Metric.driverAbility, + Metric.accuracy, + Metric.defenseEffectiveness, + Metric.fuelPerSecond, + Metric.volleysPerMatch, + Metric.l1StartTime, + Metric.l2StartTime, + Metric.l3StartTime, + Metric.autoClimbStartTime, + Metric.contactDefenseTime, + Metric.campingDefenseTime, + Metric.totalDefenseTime, + Metric.timeFeeding, + Metric.feedingRate, + Metric.feedsPerMatch, + Metric.totalFuelOutputted, + Metric.totalBallsFed, + Metric.totalBallThroughput, + Metric.outpostIntakes, + ]; + + const fast = (await averageManyFast(req.user, { + teams: teamsWithReports, + metrics, + })) as Record>; + + // Aggregate data + const aggregatedData: AggregatedTeamData[] = await Promise.all( + teamsWithReports.map((teamNum: number) => { + const group = groupedByTeam[teamNum]; + return aggregateTeamReports( + teamNum, + group.numMatches, + group.reports, + includeAuto, + includeTeleop, + fast, + ); + }), + ); + + const csvString = stringify(aggregatedData, { header: true, - columns: emptyData.length ? Object.keys(emptyData[0]) : [], + columns: aggregatedData.length ? Object.keys(aggregatedData[0]) : [], bom: true, cast: { boolean: (b) => (b ? "TRUE" : "FALSE"), From 97fb9d1af559e1db8490abac750ae36ce77269ce Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:01:55 -0700 Subject: [PATCH 03/13] ooga booga coola futura --- .../manager/scoutershifts/generateSchedule.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src/handler/manager/scoutershifts/generateSchedule.ts diff --git a/src/handler/manager/scoutershifts/generateSchedule.ts b/src/handler/manager/scoutershifts/generateSchedule.ts new file mode 100644 index 0000000..8610cc6 --- /dev/null +++ b/src/handler/manager/scoutershifts/generateSchedule.ts @@ -0,0 +1,173 @@ +import axios from "axios"; +import z from "zod"; +import prismaClient from "../../../prismaClient"; +import { writeFileSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; + +const generateSchedule = async (tournamentKey: string) => { + if (tournamentKey === undefined) { + throw "tournament key is undefined"; + } + + if (!tournamentKey.startsWith("2026")) { + return; + } + + console.log("generating schedule for " + tournamentKey); + + const url = "https://www.thebluealliance.com/api/v3"; + const tournamentRow = await prismaClient.tournament.findUnique({ + where: { + key: tournamentKey, + }, + }); + + if (tournamentRow === null) { + throw "tournament not found when trying to insert tournament matches"; + } + + const eventResponse = await fetch(`${url}/event/${tournamentKey}`, { + headers: { "X-TBA-Auth-Key": process.env.TBA_KEY }, + }); + + const json = await eventResponse.json(); + + const { remap_teams } = z + .object({ + remap_teams: z + .record(z.string(), z.string()) + .or(z.null()) + .transform((v) => v ?? {}), + }) + .parse(json); + + let matchesResponse = null; + let teamsResponse = null; + console.log("fetching matches for " + tournamentKey); + try { + matchesResponse = await axios.get(`${url}/event/${tournamentKey}/matches`, { + headers: { + "X-TBA-Auth-Key": process.env.TBA_KEY, + }, + }); + teamsResponse = await axios.get(`${url}/event/${tournamentKey}/teams`, { + headers: { + "X-TBA-Auth-Key": process.env.TBA_KEY, + }, + }); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 304) { + return; + } else { + throw error; + } + } + + await prismaClient.tournament.update({ + where: { + key: tournamentKey, + }, + data: { + latestFetchETag: matchesResponse.headers.etag, + }, + }); + + matchesResponse.data = matchesResponse.data.filter( + (match: any) => match.comp_level === "qm", + ); + + matchesResponse.data.sort( + (a: any, b: any) => a.match_number - b.match_number, + ); + + const gaps = await getScheduleGaps(matchesResponse.data); + + const lastEODMatch = gaps + .filter((gap) => gap.type === "EOD") + .slice(-1)[0].match_number; + + matchesResponse.data = matchesResponse.data.filter( + (match: any) => match.match_number <= lastEODMatch, + ); + + console.log(matchesResponse.data); + const teams: Record = Object.fromEntries( + teamsResponse.data + .map((team: any) => [team.team_number, 0]) + .sort((a: number[], b: number[]) => a[0] - b[0]), + ); + + const csvRows = ["Match Number,Scouter 1,Team,Scouter 2,Team,Scouter 3,Team"]; + + for (const match of matchesResponse.data) { + const gap = gaps.find((g) => g.match_number === match.match_number - 1); + if (gap) { + csvRows.push(`${gap.type},-,-,-,-,-,-`); + } + + const teamNumbers = [ + ...match.alliances.red.team_keys, + ...match.alliances.blue.team_keys, + ].map((teamKey: string) => { + if (remap_teams[teamKey]) { + return parseInt(remap_teams[teamKey].replace("frc", "")); + } + return parseInt(teamKey.replace("frc", "")); + }); + + for (const t of teamNumbers) { + if (teams[t] === undefined) teams[t] = 0; + } + + const top3 = [...teamNumbers] + .sort((a, b) => teams[a] - teams[b]) + .slice(0, 3); + + const s0 = top3[match.match_number % 3]; + const s1 = top3[(match.match_number + 1) % 3]; + const s2 = top3[(match.match_number + 2) % 3]; + + csvRows.push(`${match.match_number},,${s0},,${s1},,${s2}`); + + for (const t of top3) { + teams[t]++; + } + } + + csvRows.push("EOD,-,-,-,-,-,-"); + + console.log(teams); + console.log(csvRows.join("\n")); + + writeFileSync( + join(homedir(), "Downloads", "temp", `${tournamentKey}_schedule.csv`), + csvRows.join("\n"), + ); +}; + +generateSchedule("2026casnf"); + +interface Gap { + match_number: number; + gap: number; + type: "LUNCH" | "EOD"; +} + +const getScheduleGaps = async (matches: any[]) => { + let previousTime = matches[0].time; + const gaps: Gap[] = []; + for (const match of matches) { + const gap = match.time - previousTime; + if (gap > 1000) { + gaps.push({ + match_number: match.match_number - 1, + gap: gap, + type: gap < 10000 ? "LUNCH" : "EOD", // or "LUNCH" based on your logic + }); + } + previousTime = match.time; + } + console.log(gaps); + return gaps; +}; From e12d9e10508230886a035d6233f942b64aefd32f Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:02:11 -0700 Subject: [PATCH 04/13] a --- src/handler/manager/scoutershifts/generateSchedule.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handler/manager/scoutershifts/generateSchedule.ts b/src/handler/manager/scoutershifts/generateSchedule.ts index 8610cc6..b678267 100644 --- a/src/handler/manager/scoutershifts/generateSchedule.ts +++ b/src/handler/manager/scoutershifts/generateSchedule.ts @@ -163,7 +163,7 @@ const getScheduleGaps = async (matches: any[]) => { gaps.push({ match_number: match.match_number - 1, gap: gap, - type: gap < 10000 ? "LUNCH" : "EOD", // or "LUNCH" based on your logic + type: gap < 10000 ? "LUNCH" : "EOD", }); } previousTime = match.time; From 148f5fd2f90c46b55b2a2fef5fabc13bcf1a30a8 Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Sat, 28 Mar 2026 10:30:50 -0700 Subject: [PATCH 05/13] glendale --- src/handler/manager/scoutershifts/generateSchedule.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handler/manager/scoutershifts/generateSchedule.ts b/src/handler/manager/scoutershifts/generateSchedule.ts index b678267..ae8bb15 100644 --- a/src/handler/manager/scoutershifts/generateSchedule.ts +++ b/src/handler/manager/scoutershifts/generateSchedule.ts @@ -146,7 +146,7 @@ const generateSchedule = async (tournamentKey: string) => { ); }; -generateSchedule("2026casnf"); +generateSchedule("2026cagle"); interface Gap { match_number: number; From 03b23df2fbce3e49eb77deb5d824f36abd584a8f Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:15:08 -0700 Subject: [PATCH 06/13] hopefully this fixes csv --- src/handler/analysis/coreAnalysis/averageManyFast.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/handler/analysis/coreAnalysis/averageManyFast.ts b/src/handler/analysis/coreAnalysis/averageManyFast.ts index 75ffb3a..d569df2 100644 --- a/src/handler/analysis/coreAnalysis/averageManyFast.ts +++ b/src/handler/analysis/coreAnalysis/averageManyFast.ts @@ -207,12 +207,8 @@ const config: AnalysisFunctionConfig = { const tele = r.events .filter((e) => e.time > autoEnd && e.action === "STOP_SCORING") .reduce((a, b) => a + b.points, 0); - const aClimb = avg( - sr.map((s) => (s.autoClimb !== AutoClimb.SUCCEEDED ? 0 : 15)), - ); - const endgame = avg( - sr.map((s) => endgameToPoints[s.endgameClimb]), - ); + const aClimb = r.autoClimb === AutoClimb.SUCCEEDED ? 15 : 0; + const endgame = endgameToPoints[r.endgameClimb]; if (metric === Metric.autoPoints) return auto; if (metric === Metric.teleopPoints) return tele; return aClimb + auto + tele + endgame; From aed319374f8333afc61cd166643c2bf056d263b4 Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:25:48 -0700 Subject: [PATCH 07/13] please please please work --- .../analysis/coreAnalysis/averageManyFast.ts | 92 ++++++++++--------- 1 file changed, 48 insertions(+), 44 deletions(-) diff --git a/src/handler/analysis/coreAnalysis/averageManyFast.ts b/src/handler/analysis/coreAnalysis/averageManyFast.ts index d569df2..0459b97 100644 --- a/src/handler/analysis/coreAnalysis/averageManyFast.ts +++ b/src/handler/analysis/coreAnalysis/averageManyFast.ts @@ -107,16 +107,22 @@ const config: AnalysisFunctionConfig = { const finalResults: Record> = {}; for (const metric of args.metrics) { - const resultsByTeam: Record = {}; - for (const team of args.teams) resultsByTeam[team] = []; + // Group by team -> tournament -> match values + const resultsByTeamAndTournament: Record> = {}; + for (const team of args.teams) resultsByTeamAndTournament[team] = {}; for (const row of tmd) { if (!row.scoutReports.length) continue; const team = row.teamNumber; + const tnmt = row.tournamentKey; const sr = row.scoutReports; - let tournamentValue = 0; + if (!resultsByTeamAndTournament[team][tnmt]) { + resultsByTeamAndTournament[team][tnmt] = []; + } + + let matchValue = 0; switch (metric) { case Metric.autoClimbStartTime: { @@ -128,12 +134,12 @@ const config: AnalysisFunctionConfig = { ); }); const nonNullTimes = times.filter((t): t is number => t !== null); - if (nonNullTimes.length === 0) { tournamentValue = -1; break; } + if (nonNullTimes.length === 0) { matchValue = -1; break; } const adjustedTimes = nonNullTimes.map((t) => { const remaining = autoEnd - t; return remaining >= 0 ? remaining : 0; }); - tournamentValue = avg(adjustedTimes.length ? adjustedTimes : [0]); + matchValue = avg(adjustedTimes.length ? adjustedTimes : [0]); break; } @@ -156,43 +162,36 @@ const config: AnalysisFunctionConfig = { }); const nonNullTimes = times.filter((t): t is number => t !== null); - if (nonNullTimes.length === 0) { tournamentValue = -1; break; } + if (nonNullTimes.length === 0) { matchValue = -1; break; } const adjustedTimes = nonNullTimes.map((t) => { const remaining = 158 - t; return remaining >= 0 ? remaining : 0; }); - tournamentValue = avg(adjustedTimes.length ? adjustedTimes : [0]); + matchValue = avg(adjustedTimes.length ? adjustedTimes : [0]); break; } case Metric.contactDefenseTime: case Metric.campingDefenseTime: case Metric.totalDefenseTime: { - const perMatch = sr.map((r) => { - const contact = (tournamentValue = avg( - calculateTimeMetric(sr, "DEFENDING"), - )); - const camping = (tournamentValue = avg( - calculateTimeMetric(sr, "CAMPING"), - )); - if (metric === Metric.contactDefenseTime) return contact; - if (metric === Metric.campingDefenseTime) return camping; - return contact + camping; - }); - tournamentValue = avg(perMatch); + const contact = avg(calculateTimeMetric(sr, "DEFENDING")); + const camping = avg(calculateTimeMetric(sr, "CAMPING")); + if (metric === Metric.contactDefenseTime) matchValue = contact; + else if (metric === Metric.campingDefenseTime) matchValue = camping; + else matchValue = contact + camping; break; } /* ---------- SCORED METRICS ---------- */ case Metric.driverAbility: - tournamentValue = avg(sr.map((r) => r.driverAbility)); + matchValue = avg(sr.map((r) => r.driverAbility)); break; case Metric.defenseEffectiveness: - tournamentValue = avg(sr.map((r) => r.defenseEffectiveness)); + matchValue = avg(sr.map((r) => r.defenseEffectiveness)); break; case Metric.accuracy: { const defined = sr.filter((r) => r.accuracy !== null && r.accuracy !== undefined); - tournamentValue = defined.length + matchValue = defined.length ? avg(defined.map((r) => accuracyToPercentage[r.accuracy as any])) : 0; } @@ -200,7 +199,7 @@ const config: AnalysisFunctionConfig = { case Metric.autoPoints: case Metric.teleopPoints: case Metric.totalPoints: { - const perMatch = sr.map((r) => { + const perReport = sr.map((r) => { const auto = r.events .filter((e) => e.time <= autoEnd && e.action === "STOP_SCORING") .reduce((a, b) => a + b.points, 0); @@ -213,25 +212,25 @@ const config: AnalysisFunctionConfig = { if (metric === Metric.teleopPoints) return tele; return aClimb + auto + tele + endgame; }); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } case Metric.fuelPerSecond: { - const perMatch = sr.map((r) => { + const perReport = sr.map((r) => { const totalFuel = r.events .filter((e) => e.action === "STOP_SCORING") .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); - const duration = calculateTimeMetric(sr, "SCORING").reduce( + const duration = calculateTimeMetric([r], "SCORING").reduce( (a, b) => a + b, 0, ); return duration > 0 ? totalFuel / duration : 0; }); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } case Metric.totalFuelOutputted: { - const perMatch = sr.map((r) => { + const perReport = sr.map((r) => { const shotQty = r.events .filter((e) => e.action === "STOP_SCORING") .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); @@ -240,20 +239,20 @@ const config: AnalysisFunctionConfig = { .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); return shotQty + feedQty; }); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } case Metric.totalBallsFed: { - const perMatch = sr.map((r) => { + const perReport = sr.map((r) => { return r.events .filter((e) => e.action === "STOP_FEEDING") .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); }); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } case Metric.totalBallThroughput: { - const perMatch = sr.map((r) => { + const perReport = sr.map((r) => { return r.events .filter( (e) => @@ -261,14 +260,14 @@ const config: AnalysisFunctionConfig = { ) .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); }); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } case Metric.feedsPerMatch: { - const perMatch = sr.map( + const perReport = sr.map( (r) => r.events.filter((e) => e.action === "STOP_FEEDING").length, ); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } case Metric.feedingRate: { @@ -280,43 +279,48 @@ const config: AnalysisFunctionConfig = { (acc, f) => acc + (f.quantity ?? 0), 0, ); - tournamentValue = + matchValue = totalFeedQuantity > 0 ? totalFeedQuantity / avg(feedTime) : 0; break; } case Metric.timeFeeding: { - tournamentValue = avg(calculateTimeMetric(sr, "FEEDING")); + matchValue = avg(calculateTimeMetric(sr, "FEEDING")); break; } case Metric.outpostIntakes: { - const perMatch = sr.map( + const perReport = sr.map( (r) => r.events.filter( (e) => e.action === "INTAKE" && e.position === "OUTPOST", ).length, ); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); break; } default: { - const perMatch = sr.map( + const perReport = sr.map( (r) => r.events.filter((e) => e.action === metricToEvent[metric]) .length, ); - tournamentValue = avg(perMatch); + matchValue = avg(perReport); } } - resultsByTeam[team].push(tournamentValue); + resultsByTeamAndTournament[team][tnmt].push(matchValue); } finalResults[String(metric)] = {}; for (const team of args.teams) { - const teamResults = resultsByTeam[team]; + // First average within each tournament, then apply weighted average across tournaments + const tournamentAverages: number[] = []; + for (const values of Object.values(resultsByTeamAndTournament[team])) { + const valid = values.filter((v) => v !== -1); + if (valid.length > 0) tournamentAverages.push(avg(valid)); + } finalResults[String(metric)][String(team)] = - teamResults.length > 0 ? weightedTourAvgLeft(teamResults) : -1; + tournamentAverages.length > 0 ? weightedTourAvgLeft(tournamentAverages) : -1; } } From d3ab2a0e948a38aa5e46fc4dd6b92e9ef1c06b91 Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:38:49 -0700 Subject: [PATCH 08/13] mas fixes --- .../coreAnalysis/arrayAndAverageTeams.ts | 30 ++++++++++++++----- .../analysis/coreAnalysis/averageManyFast.ts | 21 +++++++------ 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/handler/analysis/coreAnalysis/arrayAndAverageTeams.ts b/src/handler/analysis/coreAnalysis/arrayAndAverageTeams.ts index 649a51f..8f17877 100644 --- a/src/handler/analysis/coreAnalysis/arrayAndAverageTeams.ts +++ b/src/handler/analysis/coreAnalysis/arrayAndAverageTeams.ts @@ -298,6 +298,22 @@ const config: AnalysisFunctionConfig = { }; break; + case Metric.totalBallsFed: + srSelect = { + events: { + select: { action: true, quantity: true }, + }, + } as any; + matchAggregationFunction = (reports) => { + const perReportTotals = reports.map((r) => { + return (r.events ?? []) + .filter((e) => e.action === "STOP_FEEDING") + .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); + }); + return avg(perReportTotals); + }; + break; + case Metric.outpostIntakes: srSelect = { events: { @@ -516,18 +532,16 @@ const config: AnalysisFunctionConfig = { let matchValue = 0; if (metric === Metric.fuelPerSecond) { - // Mirror averageManyFast: use total SCORING duration across all reports + // Total fuel across all reports / total duration across all reports + const totalFuel = (row.scoutReports as any) + .flatMap((r: any) => r.events ?? []) + .filter((e: any) => e.action === "STOP_SCORING") + .reduce((acc: number, cur: any) => acc + (cur.quantity ?? 0), 0); const totalDuration = calculateTimeMetric( row.scoutReports as any, "SCORING", ).reduce((a, b) => a + b, 0); - const perReportRates = (row.scoutReports as any).map((r: any) => { - const totalFuel = (r.events ?? []) - .filter((e: any) => e.action === "STOP_SCORING") - .reduce((acc: number, cur: any) => acc + (cur.quantity ?? 0), 0); - return totalDuration > 0 ? totalFuel / totalDuration : 0; - }); - matchValue = avg(perReportRates); + matchValue = totalDuration > 0 ? totalFuel / totalDuration : 0; } else { matchValue = matchAggregationFunction!(row.scoutReports as any); } diff --git a/src/handler/analysis/coreAnalysis/averageManyFast.ts b/src/handler/analysis/coreAnalysis/averageManyFast.ts index 0459b97..687bf0f 100644 --- a/src/handler/analysis/coreAnalysis/averageManyFast.ts +++ b/src/handler/analysis/coreAnalysis/averageManyFast.ts @@ -216,17 +216,16 @@ const config: AnalysisFunctionConfig = { break; } case Metric.fuelPerSecond: { - const perReport = sr.map((r) => { - const totalFuel = r.events - .filter((e) => e.action === "STOP_SCORING") - .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); - const duration = calculateTimeMetric([r], "SCORING").reduce( - (a, b) => a + b, - 0, - ); - return duration > 0 ? totalFuel / duration : 0; - }); - matchValue = avg(perReport); + // Total fuel across all reports / total duration across all reports + const totalFuel = sr + .flatMap((r) => r.events) + .filter((e) => e.action === "STOP_SCORING") + .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); + const totalDuration = calculateTimeMetric(sr, "SCORING").reduce( + (a, b) => a + b, + 0, + ); + matchValue = totalDuration > 0 ? totalFuel / totalDuration : 0; break; } case Metric.totalFuelOutputted: { From f6a411debf2f31e2fe48dc018a8d4dbf5a303998 Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:11:18 -0700 Subject: [PATCH 09/13] perhaps this shall work --- .../analysis/coreAnalysis/averageManyFast.ts | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/handler/analysis/coreAnalysis/averageManyFast.ts b/src/handler/analysis/coreAnalysis/averageManyFast.ts index 687bf0f..2271f72 100644 --- a/src/handler/analysis/coreAnalysis/averageManyFast.ts +++ b/src/handler/analysis/coreAnalysis/averageManyFast.ts @@ -108,7 +108,10 @@ const config: AnalysisFunctionConfig = { for (const metric of args.metrics) { // Group by team -> tournament -> match values - const resultsByTeamAndTournament: Record> = {}; + const resultsByTeamAndTournament: Record< + number, + Record + > = {}; for (const team of args.teams) resultsByTeamAndTournament[team] = {}; for (const row of tmd) { @@ -134,7 +137,10 @@ const config: AnalysisFunctionConfig = { ); }); const nonNullTimes = times.filter((t): t is number => t !== null); - if (nonNullTimes.length === 0) { matchValue = -1; break; } + if (nonNullTimes.length === 0) { + matchValue = -1; + break; + } const adjustedTimes = nonNullTimes.map((t) => { const remaining = autoEnd - t; return remaining >= 0 ? remaining : 0; @@ -157,12 +163,16 @@ const config: AnalysisFunctionConfig = { if (r.endgameClimb !== required) return null; return firstEventTime( r.events, - (e) => e.action === "CLIMB" && e.time > autoEnd && e.time <= 158, + (e) => + e.action === "CLIMB" && e.time > autoEnd && e.time <= 158, ); }); const nonNullTimes = times.filter((t): t is number => t !== null); - if (nonNullTimes.length === 0) { matchValue = -1; break; } + if (nonNullTimes.length === 0) { + matchValue = -1; + break; + } const adjustedTimes = nonNullTimes.map((t) => { const remaining = 158 - t; return remaining >= 0 ? remaining : 0; @@ -190,9 +200,13 @@ const config: AnalysisFunctionConfig = { break; case Metric.accuracy: { - const defined = sr.filter((r) => r.accuracy !== null && r.accuracy !== undefined); + const defined = sr.filter( + (r) => r.accuracy !== null && r.accuracy !== undefined, + ); matchValue = defined.length - ? avg(defined.map((r) => accuracyToPercentage[r.accuracy as any])) + ? avg( + defined.map((r) => accuracyToPercentage[r.accuracy as any]), + ) : 0; } break; @@ -253,10 +267,7 @@ const config: AnalysisFunctionConfig = { case Metric.totalBallThroughput: { const perReport = sr.map((r) => { return r.events - .filter( - (e) => - e.action === "STOP_FEEDING" || e.action === "STOP_SCORING", - ) + .filter((e) => e.action === "STOP_SCORING") .reduce((acc, cur) => acc + (cur.quantity ?? 0), 0); }); matchValue = avg(perReport); @@ -319,7 +330,9 @@ const config: AnalysisFunctionConfig = { if (valid.length > 0) tournamentAverages.push(avg(valid)); } finalResults[String(metric)][String(team)] = - tournamentAverages.length > 0 ? weightedTourAvgLeft(tournamentAverages) : -1; + tournamentAverages.length > 0 + ? weightedTourAvgLeft(tournamentAverages) + : -1; } } From 88f5ec47392083edf249377d4e21c98d72f846f4 Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:16:47 -0700 Subject: [PATCH 10/13] huh this should have prolly been there for longer --- src/handler/analysis/coreAnalysis/averageManyFast.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/handler/analysis/coreAnalysis/averageManyFast.ts b/src/handler/analysis/coreAnalysis/averageManyFast.ts index 2271f72..4279351 100644 --- a/src/handler/analysis/coreAnalysis/averageManyFast.ts +++ b/src/handler/analysis/coreAnalysis/averageManyFast.ts @@ -222,9 +222,10 @@ const config: AnalysisFunctionConfig = { .reduce((a, b) => a + b.points, 0); const aClimb = r.autoClimb === AutoClimb.SUCCEEDED ? 15 : 0; const endgame = endgameToPoints[r.endgameClimb]; - if (metric === Metric.autoPoints) return auto; - if (metric === Metric.teleopPoints) return tele; - return aClimb + auto + tele + endgame; + if (metric === Metric.autoPoints) + return auto * r.accuracy + aClimb; + if (metric === Metric.teleopPoints) return tele * r.accuracy; + return aClimb + auto * r.accuracy + tele * r.accuracy + endgame; }); matchValue = avg(perReport); break; From 33e556a73d94c265fbe1feec2f5b0c130d7c4afa Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:34:22 -0700 Subject: [PATCH 11/13] coming soon to a production near you --- .../manager/scoutershifts/generateSchedule.ts | 111 ++++++++++++++++-- src/routes/manager/scoutershifts.routes.ts | 3 + 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/handler/manager/scoutershifts/generateSchedule.ts b/src/handler/manager/scoutershifts/generateSchedule.ts index ae8bb15..e3a3629 100644 --- a/src/handler/manager/scoutershifts/generateSchedule.ts +++ b/src/handler/manager/scoutershifts/generateSchedule.ts @@ -1,11 +1,22 @@ import axios from "axios"; -import z from "zod"; +import z, { ZodError } from "zod"; import prismaClient from "../../../prismaClient"; import { writeFileSync } from "fs"; import { join } from "path"; import { homedir } from "os"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth"; +import { Response } from "express"; -const generateSchedule = async (tournamentKey: string) => { +const shiftScouterEx = [ + ["Christian", "Oren", "Ben"], + ["Gabi", "Jasmeh", "Colin"], +]; + +const generateSchedule = async ( + tournamentKey: string, + shiftScouters?: string[][], +) => { + shiftScouters = shiftScouters || shiftScouterEx; if (tournamentKey === undefined) { throw "tournament key is undefined"; } @@ -83,6 +94,8 @@ const generateSchedule = async (tournamentKey: string) => { const gaps = await getScheduleGaps(matchesResponse.data); + const shifts = await buildShifts(gaps, shiftScouters); + const lastEODMatch = gaps .filter((gap) => gap.type === "EOD") .slice(-1)[0].match_number; @@ -91,21 +104,28 @@ const generateSchedule = async (tournamentKey: string) => { (match: any) => match.match_number <= lastEODMatch, ); - console.log(matchesResponse.data); const teams: Record = Object.fromEntries( teamsResponse.data .map((team: any) => [team.team_number, 0]) .sort((a: number[], b: number[]) => a[0] - b[0]), ); - const csvRows = ["Match Number,Scouter 1,Team,Scouter 2,Team,Scouter 3,Team"]; + const csvRows = [ + "Match Number,Scouter 1,Team,Scouter 2,Team,Scouter 3,Team,Expected Time", + ]; for (const match of matchesResponse.data) { const gap = gaps.find((g) => g.match_number === match.match_number - 1); + const newShift = shifts.find((g) => g.end === match.match_number - 1); if (gap) { - csvRows.push(`${gap.type},-,-,-,-,-,-`); + csvRows.push(`${gap.type},,,,,,,`); + } else if (newShift) { + csvRows.push(`SHIFT CHANGE,,,,,,,`); } + const shift = shifts.find( + (s) => match.match_number >= s.start && match.match_number <= s.end, + ); const teamNumbers = [ ...match.alliances.red.team_keys, ...match.alliances.blue.team_keys, @@ -128,25 +148,51 @@ const generateSchedule = async (tournamentKey: string) => { const s1 = top3[(match.match_number + 1) % 3]; const s2 = top3[(match.match_number + 2) % 3]; - csvRows.push(`${match.match_number},,${s0},,${s1},,${s2}`); + csvRows.push( + `${match.match_number},${shift.scouters[0]},${s0},${shift.scouters[1]},${s1},${shift.scouters[2]},${s2},${new Date( + match.time * 1000, + ).toLocaleTimeString("en-US", { + weekday: "short", + hour: "2-digit", + minute: "2-digit", + })}`, + ); for (const t of top3) { teams[t]++; } } - csvRows.push("EOD,-,-,-,-,-,-"); + csvRows.push("EOD,,,,,,,"); - console.log(teams); console.log(csvRows.join("\n")); writeFileSync( join(homedir(), "Downloads", "temp", `${tournamentKey}_schedule.csv`), csvRows.join("\n"), ); + + return csvRows.join("\n"); }; -generateSchedule("2026cagle"); +export const superScoutingSchedule = async ( + req: AuthenticatedRequest, + res: Response, +) => { + try { + const params = z.object({ tournamentKey: z.string() }).parse(req.query); + + const schedule = await generateSchedule(params.tournamentKey); + + res.status(200).send(schedule); + } catch (error) { + if (error instanceof ZodError) { + res.status(400).send("Bad input"); + } else { + res.status(500).send("Internal server error"); + } + } +}; interface Gap { match_number: number; @@ -154,6 +200,19 @@ interface Gap { type: "LUNCH" | "EOD"; } +interface Period { + start: number; + end: number; +} + +interface Shift { + start: number; + end: number; + scouters: string[]; +} + +const optimalMatches = 10; + const getScheduleGaps = async (matches: any[]) => { let previousTime = matches[0].time; const gaps: Gap[] = []; @@ -171,3 +230,37 @@ const getScheduleGaps = async (matches: any[]) => { console.log(gaps); return gaps; }; + +const buildShifts = (gaps: Gap[], shiftScouters: string[][] = []) => { + let periodStart = 1; + const periods: Period[] = []; + const shifts: Shift[] = []; + for (const gap of gaps) { + periods.push({ + start: periodStart, + end: gap.match_number, + }); + periodStart = gap.match_number + 1; + } + console.log(periods.length); + + for (const period of periods) { + const periodLength = period.end - period.start + 1; + console.log(periodLength); + const numShifts = Math.ceil(periodLength / optimalMatches); + console.log(numShifts); + const shiftLength = Math.ceil(periodLength / numShifts); + console.log(shiftLength); + for (let i = 0; i < numShifts; i++) { + shifts.push({ + start: period.start + i * shiftLength, + end: Math.min(period.start + (i + 1) * shiftLength - 1, period.end), + scouters: shiftScouters[i % shiftScouters.length], + }); + } + } + + console.log(shifts); + + return shifts; +}; diff --git a/src/routes/manager/scoutershifts.routes.ts b/src/routes/manager/scoutershifts.routes.ts index adfb18b..d514720 100644 --- a/src/routes/manager/scoutershifts.routes.ts +++ b/src/routes/manager/scoutershifts.routes.ts @@ -5,6 +5,7 @@ import { deleteScouterShift } from "../../handler/manager/scoutershifts/deleteSc import { registry } from "../../lib/openapi.js"; import { z } from "zod"; import { requireVerifiedTeam } from "../../lib/middleware/requireVerifiedTeam.js"; +import { superScoutingSchedule } from "../../handler/manager/scoutershifts/generateSchedule.js"; const router = Router(); @@ -61,6 +62,8 @@ registry.registerPath({ router.use(requireAuth, requireVerifiedTeam); +router.get("/generate", superScoutingSchedule); + router.post("/:uuid", updateScouterShift); router.delete("/:uuid", deleteScouterShift); From 9a15a800d6bf8eb937314877675a400c8e14da80 Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:36:41 -0700 Subject: [PATCH 12/13] command + / --- .../manager/scoutershifts/generateSchedule.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/handler/manager/scoutershifts/generateSchedule.ts b/src/handler/manager/scoutershifts/generateSchedule.ts index e3a3629..542232b 100644 --- a/src/handler/manager/scoutershifts/generateSchedule.ts +++ b/src/handler/manager/scoutershifts/generateSchedule.ts @@ -1,9 +1,9 @@ import axios from "axios"; import z, { ZodError } from "zod"; import prismaClient from "../../../prismaClient"; -import { writeFileSync } from "fs"; -import { join } from "path"; -import { homedir } from "os"; +// import { writeFileSync } from "fs"; +// import { join } from "path"; +// import { homedir } from "os"; import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth"; import { Response } from "express"; @@ -25,7 +25,7 @@ const generateSchedule = async ( return; } - console.log("generating schedule for " + tournamentKey); + // console.log("generating schedule for " + tournamentKey); const url = "https://www.thebluealliance.com/api/v3"; const tournamentRow = await prismaClient.tournament.findUnique({ @@ -55,7 +55,7 @@ const generateSchedule = async ( let matchesResponse = null; let teamsResponse = null; - console.log("fetching matches for " + tournamentKey); + // console.log("fetching matches for " + tournamentKey); try { matchesResponse = await axios.get(`${url}/event/${tournamentKey}/matches`, { headers: { @@ -165,12 +165,12 @@ const generateSchedule = async ( csvRows.push("EOD,,,,,,,"); - console.log(csvRows.join("\n")); + // console.log(csvRows.join("\n")); - writeFileSync( - join(homedir(), "Downloads", "temp", `${tournamentKey}_schedule.csv`), - csvRows.join("\n"), - ); + // writeFileSync( + // join(homedir(), "Downloads", "temp", `${tournamentKey}_schedule.csv`), + // csvRows.join("\n"), + // ); return csvRows.join("\n"); }; @@ -227,7 +227,7 @@ const getScheduleGaps = async (matches: any[]) => { } previousTime = match.time; } - console.log(gaps); + // console.log(gaps); return gaps; }; @@ -242,15 +242,15 @@ const buildShifts = (gaps: Gap[], shiftScouters: string[][] = []) => { }); periodStart = gap.match_number + 1; } - console.log(periods.length); + // console.log(periods.length); for (const period of periods) { const periodLength = period.end - period.start + 1; - console.log(periodLength); + // console.log(periodLength); const numShifts = Math.ceil(periodLength / optimalMatches); - console.log(numShifts); + // console.log(numShifts); const shiftLength = Math.ceil(periodLength / numShifts); - console.log(shiftLength); + // console.log(shiftLength); for (let i = 0; i < numShifts; i++) { shifts.push({ start: period.start + i * shiftLength, @@ -260,7 +260,7 @@ const buildShifts = (gaps: Gap[], shiftScouters: string[][] = []) => { } } - console.log(shifts); + // console.log(shifts); return shifts; }; From aa6de2466ae13e4c76cfee62af7a3297bd66ae5c Mon Sep 17 00:00:00 2001 From: JackAttack-365 <142643773+jackattack-4@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:43:28 -0700 Subject: [PATCH 13/13] good catch --- src/handler/manager/scoutershifts/generateSchedule.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/handler/manager/scoutershifts/generateSchedule.ts b/src/handler/manager/scoutershifts/generateSchedule.ts index 542232b..b919a5e 100644 --- a/src/handler/manager/scoutershifts/generateSchedule.ts +++ b/src/handler/manager/scoutershifts/generateSchedule.ts @@ -1,10 +1,10 @@ import axios from "axios"; import z, { ZodError } from "zod"; -import prismaClient from "../../../prismaClient"; +import prismaClient from "../../../prismaClient.js"; // import { writeFileSync } from "fs"; // import { join } from "path"; // import { homedir } from "os"; -import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; import { Response } from "express"; const shiftScouterEx = [