diff --git a/apps/scouting/backend/src/fuel/calculations/fuel-averaging.ts b/apps/scouting/backend/src/fuel/calculations/fuel-averaging.ts index ec80a72..7d9ca80 100644 --- a/apps/scouting/backend/src/fuel/calculations/fuel-averaging.ts +++ b/apps/scouting/backend/src/fuel/calculations/fuel-averaging.ts @@ -1,32 +1,41 @@ // בס"ד -import type { FuelObject, ShootEvent } from "@repo/scouting_types"; -import type { BPS } from "../fuel-object"; -import { calculateSum, firstElement, lastElement } from "@repo/array-functions"; +import type { BPS, BPSEvent, FuelObject, ShootEvent } from "@repo/scouting_types"; +import { + calculateAverage, + calculateSum, + firstElement, + isEmpty, + lastElement, +} from "@repo/array-functions"; +import { convertPixelToCentimeters, distanceFromHub } from "@repo/rebuilt_map"; +import { interpolateQuadratic } from "./interpolation"; + +interface ShotStats { + durationMilliseconds: number; + hubDistanceCentimeters: number; +} const EMPTY_INTERVAL_DURATION = 0; const FIRST_INTERVAL_BOUNDARY = 0; const NO_FUEL_COLLECTED = 0; const FIRST_SECTION_AMOUNT = 1; -const LAST_SECTION_LENGTH = 1; +const ONE_SECTION_ONLY_LENGTH = 1; /** * @param sections consists of sections that contains a list of timestamps in ms * @returns mean ball amount */ -const calculateBallAmount = ( - sections: number[][], - shotLength: number, -): number => { +const calculateBallAmount = (sections: number[][], shot: ShotStats): number => { // Base Case 1 - if (shotLength <= EMPTY_INTERVAL_DURATION) { + if (shot.durationMilliseconds <= EMPTY_INTERVAL_DURATION) { return NO_FUEL_COLLECTED; } // Base Case 2: Happens if no section is long enough for the shot length - if (sections.length === LAST_SECTION_LENGTH) { + if (sections.length === ONE_SECTION_ONLY_LENGTH) { const onlySection = firstElement(sections); const ballAmount = onlySection.length; const sectionDuration = lastElement(onlySection); - return (ballAmount / sectionDuration) * shotLength; + return (ballAmount / sectionDuration) * shot.durationMilliseconds; } // finds the average for the first interval, removes it and then recurses @@ -36,9 +45,12 @@ const calculateBallAmount = ( const adjustedSections = sections.map((section) => section.map((timing) => timing - firstIntervalDuration), ); + const firstIntervalSections = adjustedSections.map((section) => section.filter( - (timing) => timing <= FIRST_INTERVAL_BOUNDARY && timing <= shotLength, + (timing) => + timing <= FIRST_INTERVAL_BOUNDARY && + timing <= shot.durationMilliseconds, ), ); @@ -51,35 +63,76 @@ const calculateBallAmount = ( .map((section) => section.filter((timing) => timing > FIRST_INTERVAL_BOUNDARY), ); + return ( avgBallsFirstInterval + - calculateBallAmount(nonFirstSections, shotLength - firstIntervalDuration) + calculateBallAmount(nonFirstSections, { + durationMilliseconds: shot.durationMilliseconds - firstIntervalDuration, + hubDistanceCentimeters: shot.hubDistanceCentimeters, + }) + ); +}; + +const calculateAccuracies = (sections: BPS["events"], shotDuration: number) => { + const durationedSections = sections.map((section) => ({ + shoot: section.shoot.filter((timestamp) => timestamp <= shotDuration), + score: section.score.filter((timestamp) => timestamp <= shotDuration), + positions: section.positions, + })); + + const filteredSections = durationedSections.filter( + (section) => !isEmpty(section.shoot), + ); + + const accuracies = filteredSections.map((section) => ({ + distance: calculateAverage(section.positions, (point) => + distanceFromHub(convertPixelToCentimeters(point)), + ), + accuracy: section.score.length / section.shoot.length, + })); + const sortedAccuracies = accuracies.sort( + (accuracy1, accuracy2) => accuracy1.distance - accuracy2.distance, ); + + return sortedAccuracies; }; -const compareSections = (a: number[], b: number[]) => - lastElement(a) - lastElement(b); +const compareSections = (section1: number[], section2: number[]) => + lastElement(section1) - lastElement(section2); const correctSectionToTimeFromEnd = (sections: number[]) => { const endTimestamp = lastElement(sections); - return sections - .filter((timestamp) => timestamp < endTimestamp) - .map((timestamp) => endTimestamp - timestamp); + return sections.map((timestamp) => endTimestamp - timestamp).reverse(); }; +const formatSections = (sections: BPS["events"]) => + sections + .map((section) => ({ + ...section, + score: correctSectionToTimeFromEnd(section.score), + shoot: correctSectionToTimeFromEnd(section.shoot), + })) + .sort((formattedSection1, formattedSection2) => + compareSections(formattedSection1.shoot, formattedSection2.shoot), + ); + export const calculateFuelByAveraging = ( shot: ShootEvent, isPass: boolean, - sections: BPS["events"], + sections: BPSEvent[], ): Partial => { - const shotLength = shot.interval.end - shot.interval.start; + const shotStats: ShotStats = { + durationMilliseconds: shot.interval.end - shot.interval.start, + hubDistanceCentimeters: distanceFromHub( + convertPixelToCentimeters(shot.startPosition), + ), + }; + + const formattedSections = formatSections(sections); const shotAmount = calculateBallAmount( - sections - .map((section) => section.shoot) - .map(correctSectionToTimeFromEnd) - .sort(compareSections), - shotLength, + formattedSections.map((section) => section.shoot), + shotStats, ); if (isPass) { @@ -89,14 +142,15 @@ export const calculateFuelByAveraging = ( positions: [shot.startPosition], }; } - const scoredAmount = calculateBallAmount( - sections - .map((section) => section.score) - .map(correctSectionToTimeFromEnd) - .sort(compareSections), - shotLength, + const scoredAccuracy = interpolateQuadratic( + shotStats.hubDistanceCentimeters, + calculateAccuracies(formattedSections, shotStats.durationMilliseconds).map( + ({ distance, accuracy }) => ({ x: distance, y: accuracy }), + ), ); + const scoredAmount = shotAmount * scoredAccuracy; + return { scored: scoredAmount, shot: shotAmount, diff --git a/apps/scouting/backend/src/fuel/calculations/fuel-match.ts b/apps/scouting/backend/src/fuel/calculations/fuel-match.ts index 34e2ad8..155d606 100644 --- a/apps/scouting/backend/src/fuel/calculations/fuel-match.ts +++ b/apps/scouting/backend/src/fuel/calculations/fuel-match.ts @@ -1,6 +1,5 @@ // בס"ד -import type { FuelObject, ShootEvent } from "@repo/scouting_types"; -import type { BPS } from "../fuel-object"; +import type { BPS, FuelObject, ShootEvent } from "@repo/scouting_types"; const getIncludedShots = (section: number[], shot: ShootEvent) => { return section.filter( @@ -30,6 +29,7 @@ export const calculateFuelByMatch = ( const scoredAmount = shotBps.flatMap((section) => section.score).length; + return { shot: shotAmount, scored: scoredAmount, diff --git a/apps/scouting/backend/src/fuel/calculations/interpolation.ts b/apps/scouting/backend/src/fuel/calculations/interpolation.ts new file mode 100644 index 0000000..86dfb5f --- /dev/null +++ b/apps/scouting/backend/src/fuel/calculations/interpolation.ts @@ -0,0 +1,35 @@ +// בס"ד + +import { isEmpty } from "@repo/array-functions"; +import type { Point } from "@repo/scouting_types"; + +const QUADRATIC_EXPONENT = 2; +const interpolateTwoPointQuadratic = (x: number, p1: Point, p2: Point) => { + // Assumes p1 is the "peak" (e.g., distance 0) + const a = (p2.y - p1.y) / p2.x ** QUADRATIC_EXPONENT; + return a * x ** QUADRATIC_EXPONENT + p1.y; +}; + +const ONE_ITEM = 1; +const DEFAULT_EMPTY_VALUE = 0; +const LENGTH_THAT_DOESNT_INCLUDE_TWO_ITEMS = 1; +export const interpolateQuadratic = ( + x: number, + points: Point[], + emptyValue = DEFAULT_EMPTY_VALUE, +): number => { + if (isEmpty(points)) { + return emptyValue; + } + + const [first, second] = points; + + if (first.x >= x || points.length === LENGTH_THAT_DOESNT_INCLUDE_TWO_ITEMS) { + return first.y; + } + if (x < second.x) { + return interpolateQuadratic(x, points.slice(ONE_ITEM)); + } + + return interpolateTwoPointQuadratic(x, first, second); +}; diff --git a/apps/scouting/backend/src/fuel/fuel-object.ts b/apps/scouting/backend/src/fuel/fuel-object.ts index 1737d70..323e7ac 100644 --- a/apps/scouting/backend/src/fuel/fuel-object.ts +++ b/apps/scouting/backend/src/fuel/fuel-object.ts @@ -1,5 +1,6 @@ // בס"ד import type { + BPS, FuelObject, Match, Point, @@ -9,11 +10,6 @@ import { calculateFuelByAveraging } from "./calculations/fuel-averaging"; import { calculateFuelByMatch } from "./calculations/fuel-match"; import { ALLIANCE_ZONE_WIDTH_PIXELS } from "@repo/rebuilt_map"; -export interface BPS { - events: { shoot: number[]; score: number[] }[]; - match: Match; -} - const isShotPass = (positionPixels: Point) => positionPixels.x > ALLIANCE_ZONE_WIDTH_PIXELS; diff --git a/apps/scouting/backend/src/routes/teams-router.ts b/apps/scouting/backend/src/routes/teams-router.ts index 9a071f2..85cdf4e 100644 --- a/apps/scouting/backend/src/routes/teams-router.ts +++ b/apps/scouting/backend/src/routes/teams-router.ts @@ -17,6 +17,7 @@ import { getFormsCollection } from "./forms-router"; import { StatusCodes } from "http-status-codes"; import { castItem } from "@repo/type-utils"; import type { + BPS, Match, ScoutingForm, SectionTeamData, @@ -26,7 +27,7 @@ import type { import { ACCURACY_DISTANCES, teamsProps } from "@repo/scouting_types"; import { groupBy } from "fp-ts/lib/NonEmptyArray"; import { calculateSum, isEmpty, mapObject } from "@repo/array-functions"; -import { createFuelObject, type BPS } from "../fuel/fuel-object"; +import { createFuelObject } from "../fuel/fuel-object"; import { splitByDistances } from "../fuel/distance-split"; import { calculateFuelStatisticsOfShift } from "../fuel/fuel-general"; @@ -124,9 +125,10 @@ export const getAllBPS = (): BPS[] => [ score: [1000, 2000, 3000], // eslint-disable-next-line @typescript-eslint/no-magic-numbers shoot: [1000, 1400, 2000, 3000], + positions: [{ x: 300, y: 200 }], }, ], - match: { type: "qualification", number: 8 }, + match: { type: "qualification", number: 10 }, }, ]; diff --git a/packages/scouting_types/rebuilt/fuel/FuelTypes.ts b/packages/scouting_types/rebuilt/fuel/FuelTypes.ts index edffe01..3c26be6 100644 --- a/packages/scouting_types/rebuilt/fuel/FuelTypes.ts +++ b/packages/scouting_types/rebuilt/fuel/FuelTypes.ts @@ -10,8 +10,14 @@ export interface GeneralFuelData { export type GameTime = keyof GeneralFuelData; +export interface BPSEvent { + shoot: number[]; + score: number[]; + positions: Point[]; +} + export interface BPS { - events: { shoot: number[]; score: number[] }[]; + events: BPSEvent[]; match: Match; }