Skip to content
116 changes: 85 additions & 31 deletions apps/scouting/backend/src/fuel/calculations/fuel-averaging.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
),
);

Expand All @@ -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<FuelObject> => {
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) {
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions apps/scouting/backend/src/fuel/calculations/fuel-match.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -30,6 +29,7 @@ export const calculateFuelByMatch = (

const scoredAmount = shotBps.flatMap((section) => section.score).length;


return {
shot: shotAmount,
scored: scoredAmount,
Expand Down
35 changes: 35 additions & 0 deletions apps/scouting/backend/src/fuel/calculations/interpolation.ts
Original file line number Diff line number Diff line change
@@ -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);
};
6 changes: 1 addition & 5 deletions apps/scouting/backend/src/fuel/fuel-object.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// בס"ד
import type {
BPS,
FuelObject,
Match,
Point,
Expand All @@ -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;

Expand Down
6 changes: 4 additions & 2 deletions apps/scouting/backend/src/routes/teams-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -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 },
Comment on lines 125 to +131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this is for trial remove

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its for until the bps gets merged, as this is used in other stuff in the code it shouldnt be removed

},
];

Expand Down
8 changes: 7 additions & 1 deletion packages/scouting_types/rebuilt/fuel/FuelTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading