diff --git a/apps/scouting/backend/src/routes/forecast-router.ts b/apps/scouting/backend/src/routes/forecast-router.ts new file mode 100644 index 0000000..3b2fc5e --- /dev/null +++ b/apps/scouting/backend/src/routes/forecast-router.ts @@ -0,0 +1,137 @@ +// בס"ד + +import { Router } from "express"; +import { pipe } from "fp-ts/lib/function"; +import { createTypeCheckingEndpointFlow } from "../middleware/verification"; +import { + type ClimbLevel, + convertGeneralToAllianceData, + defaultAllianceData, + type Forecast, + forecastProps, + type ScoutingForm, +} from "@repo/scouting_types"; +import { right } from "fp-ts/lib/Either"; +import { getFormsCollection } from "./forms-router"; +import { flatMap, fold, fromEither, map, tryCatch } from "fp-ts/lib/TaskEither"; +import { StatusCodes } from "http-status-codes"; +import { groupBy } from "fp-ts/lib/NonEmptyArray"; +import { calculateAverage, mapObject } from "@repo/array-functions"; +import { generalCalculateFuel } from "../fuel/fuel-general"; +import { getAllBPS } from "./teams-router"; +import { calcAverageGeneralFuelData } from "./general-router"; +import { castItem } from "@repo/type-utils"; + +export const forecastRouter = Router(); + +const CLIMB_SCORE_VALUES = { L0: 0, L1: 10, L2: 20, L3: 30 }; + +const TELE_CLIMB_MULTIPLIER = 1; +const AUTO_CLIMB_MULTIPLIER = 1.5; + +const calculateAverageClimbScore = (climbs: ClimbLevel[], isAuto: boolean) => + calculateAverage( + climbs, + (climb) => + CLIMB_SCORE_VALUES[climb] * + (isAuto ? AUTO_CLIMB_MULTIPLIER : TELE_CLIMB_MULTIPLIER), + ); + +const calculateAverageClimbsScore = (forms: ScoutingForm[]) => ({ + auto: calculateAverageClimbScore( + forms.map((form) => form.auto.climb.level), + true, + ), + tele: calculateAverageClimbScore( + forms.map((form) => form.tele.climb.level), + false, + ), +}); + +forecastRouter.get("/", async (req, res) => { + await pipe( + req.query, + castItem, + right, + createTypeCheckingEndpointFlow(forecastProps, (error) => ({ + status: StatusCodes.BAD_REQUEST, + reason: `Query Does Not match Specified Type: ${error}`, + })), + fromEither, + flatMap((query) => + pipe( + getFormsCollection(), + map((collection) => ({ collection, query })), + ), + ), + flatMap(({ collection, query }) => + tryCatch( + async () => ({ + redAlliance: await collection + .find({ + teamNumber: { $in: query.redAlliance }, + }) + .toArray(), + blueAlliance: await collection + .find({ + teamNumber: { $in: query.blueAlliance }, + }) + .toArray(), + }), + (error) => ({ + status: StatusCodes.INTERNAL_SERVER_ERROR, + reason: `Error getting teams from DB: ${error}`, + }), + ), + ), + map((alliancesForms) => + mapObject( + alliancesForms, + groupBy((form: ScoutingForm) => form.teamNumber.toString()), + ), + ), + map((alliancesForms) => + mapObject(alliancesForms, Object.values), + ), + map((alliancesTeamedForms) => ({ + alliancesTeamedForms, + bps: getAllBPS(), + })), + map(({ alliancesTeamedForms, bps }) => + mapObject(alliancesTeamedForms, (allianceTeamedForms) => + allianceTeamedForms.map((teamForms) => ({ + climb: calculateAverageClimbsScore(teamForms), + fuel: calcAverageGeneralFuelData( + teamForms.map((form) => generalCalculateFuel(form, bps)), + ), + })), + ), + ), + map((alliancesTeamedData) => + mapObject(alliancesTeamedData, (allianceTeamedData) => + allianceTeamedData.map(convertGeneralToAllianceData).reduce( + (acc, curr) => ({ + climb: { + auto: acc.climb.auto + curr.climb.auto, + tele: acc.climb.tele + curr.climb.tele, + }, + fuel: { + auto: acc.fuel.auto + curr.fuel.auto, + tele: acc.fuel.tele + curr.fuel.tele, + fullGame: acc.fuel.fullGame + curr.fuel.fullGame, + }, + }), + defaultAllianceData, + ), + ), + ), + fold( + (error) => () => + Promise.resolve(res.status(error.status).send(error.reason)), + (allianceData) => () => + Promise.resolve( + res.status(StatusCodes.OK).json({ allianceData } satisfies Forecast), + ), + ), + )(); +}); diff --git a/apps/scouting/backend/src/routes/general-router.ts b/apps/scouting/backend/src/routes/general-router.ts index 03ca634..b17c67c 100644 --- a/apps/scouting/backend/src/routes/general-router.ts +++ b/apps/scouting/backend/src/routes/general-router.ts @@ -23,7 +23,7 @@ interface AccumulatedFuelData { const ONE_ITEM_ARRAY = 1; -const calcAverageGeneralFuelData = (fuelData: GeneralFuelData[]) => { +export const calcAverageGeneralFuelData = (fuelData: GeneralFuelData[]): GeneralFuelData => { if (fuelData.length === ONE_ITEM_ARRAY || isEmpty(fuelData)) { return firstElement(fuelData); } diff --git a/apps/scouting/backend/src/routes/index.ts b/apps/scouting/backend/src/routes/index.ts index 031242d..dc2b826 100644 --- a/apps/scouting/backend/src/routes/index.ts +++ b/apps/scouting/backend/src/routes/index.ts @@ -4,6 +4,7 @@ import { StatusCodes } from "http-status-codes"; import { tbaRouter } from "./tba"; import { gameRouter } from "./game-router"; import { formsRouter } from "./forms-router"; +import { forecastRouter } from "./forecast-router"; import { teamsRouter } from "./teams-router"; import { generalRouter } from "./general-router"; @@ -12,6 +13,7 @@ export const apiRouter = Router(); apiRouter.use("/forms", formsRouter); apiRouter.use("/tba", tbaRouter); apiRouter.use("/game", gameRouter); +apiRouter.use("/forecast", forecastRouter); apiRouter.use("/team",teamsRouter) apiRouter.use("/general", generalRouter); diff --git a/packages/scouting_types/forecast/index.ts b/packages/scouting_types/forecast/index.ts new file mode 100644 index 0000000..3a87c9b --- /dev/null +++ b/packages/scouting_types/forecast/index.ts @@ -0,0 +1,48 @@ +// בס"ד +import * as t from "io-ts"; +import type { GeneralFuelData } from "../rebuilt"; + +export const forecastProps = t.type({ + redAlliance: t.tuple([t.number, t.number, t.number]), + blueAlliance: t.tuple([t.number, t.number, t.number]), +}); + +interface ClimbAllianceData { + auto: number; + tele: number; +} +interface FuelAllianceData { + auto: number; + tele: number; + fullGame: number; +} + +export interface AllianceData { + climb: ClimbAllianceData; + fuel: FuelAllianceData; +} +export const defaultAllianceData: AllianceData = { + climb: { auto: 0, tele: 0 }, + fuel: { auto: 0, tele: 0, fullGame: 0 }, +}; + +export interface Forecast { + allianceData: { redAlliance: AllianceData; blueAlliance: AllianceData }; +} + +const PASS_POINT_VALUE = 0.6; + +export const convertGeneralToAllianceData = ({ + climb, + fuel, +}: { + climb: ClimbAllianceData; + fuel: GeneralFuelData; +}): AllianceData => ({ + climb, + fuel: { + auto: fuel.auto.scored + fuel.auto.passed * PASS_POINT_VALUE, + tele: fuel.tele.scored + fuel.tele.passed * PASS_POINT_VALUE, + fullGame: fuel.fullGame.scored + fuel.fullGame.passed * PASS_POINT_VALUE, + }, +}); diff --git a/packages/scouting_types/index.ts b/packages/scouting_types/index.ts index 3a6b0d6..88a71e9 100644 --- a/packages/scouting_types/index.ts +++ b/packages/scouting_types/index.ts @@ -1,5 +1,6 @@ // בס"ד export * from "./rebuilt"; +export * from "./forecast"; export * from "./tba"; export * from "./teams" diff --git a/packages/scouting_types/rebuilt/scouting_form/Shift.ts b/packages/scouting_types/rebuilt/scouting_form/Shift.ts index 2b7fda3..d0ff220 100644 --- a/packages/scouting_types/rebuilt/scouting_form/Shift.ts +++ b/packages/scouting_types/rebuilt/scouting_form/Shift.ts @@ -44,6 +44,7 @@ type Interval = t.TypeOf; export type SingleLevelTime = Partial>; export type Climb = ScoutingForm["auto" | "tele"]["climb"]; +export type ClimbLevel = Climb["level"]; export type TeleClimb = t.TypeOf; export type TeleClimbSide = TeleClimb["climbSide"];