diff --git a/api/src/dataUtil/classifiersData.ts b/api/src/dataUtil/classifiersData.ts index 5b7f7239..1bba4008 100644 --- a/api/src/dataUtil/classifiersData.ts +++ b/api/src/dataUtil/classifiersData.ts @@ -3,7 +3,8 @@ import { loadJSON } from "../utils"; export type USPSAScoring = "Virginia" | "Comstock" | "Fixed Time"; export type SCSAScoring = "Time Plus"; -export type Scoring = USPSAScoring | SCSAScoring; +export type NAScoring = ""; +export type Scoring = USPSAScoring | SCSAScoring | NAScoring; export interface ClassifierJSON { id: string; @@ -35,6 +36,10 @@ export const basicInfoForClassifier = (c: ClassifierJSON): ClassifierBasicInfo = scoring: c?.scoring, }); +export const basicInfoForClassifierNumber = ( + classifierCode: string, +): ClassifierBasicInfo => basicInfoForClassifier(classifiersByNumber[classifierCode]); + export const basicInfoForClassifierCode = ( classifierCode: string, ): ClassifierBasicInfo | undefined => { diff --git a/api/src/db/classifiers.ts b/api/src/db/classifiers.ts index 2a01afe3..7b34849e 100644 --- a/api/src/db/classifiers.ts +++ b/api/src/db/classifiers.ts @@ -5,10 +5,11 @@ import mongoose, { Model } from "mongoose"; import { stringSort } from "../../../shared/utils/sort"; import { - basicInfoForClassifier, classifiers as _classifiers, - classifiersByNumber, ClassifierJSON, + basicInfoForClassifierNumber, + ClassifierBasicInfo, + Scoring, } from "../dataUtil/classifiersData"; import { divisionsForScoresAdapter, divShortNames } from "../dataUtil/divisions"; import { hhfsForDivision } from "../dataUtil/hhf"; @@ -86,16 +87,17 @@ const extendedInfoForClassifier = ( c: ClassifierJSON, division: string, hitFactorScores: Score[], + classifiersOnly?: boolean, ) => { if (!division || !c?.id) { return {}; } const divisionHHFs = hhfsForDivision(division); - if (!divisionHHFs) { + if (!divisionHHFs && classifiersOnly) { return {}; } - const curHHFInfo = divisionHHFs.find(dHHF => dHHF.classifier === c.id); - const hhf = Number(curHHFInfo.hhf); + const curHHFInfo = divisionHHFs?.find(dHHF => dHHF.classifier === c.id); + const hhf = Number(curHHFInfo?.hhf) || -1; const topXPercentileStats = x => ({ [`top${x}PercentilePercent`]: @@ -140,7 +142,7 @@ const extendedInfoForClassifier = ( .sort((a, b) => stringSort(a, b, "id", 1)); const result = { - updated: curHHFInfo.updated, //actualLastUpdate, // before was using curHHFInfo.updated, and it's bs + updated: curHHFInfo?.updated, //actualLastUpdate, // before was using curHHFInfo.updated, and it's bs hhf, prevHHF: hhfs.findLast(curHistorical => curHistorical.hhf !== hhf)?.hhf ?? hhf, hhfs, @@ -199,6 +201,7 @@ const ClassifierSchema = new mongoose.Schema< const WORST_QUALITY_DISTANCE_FROM_TARGET = 100; const scoresCountOffset = runsCount => { + return 0; if (runsCount < 200) { return -40; } else if (runsCount < 400) { @@ -231,11 +234,11 @@ ClassifierSchema.virtual("hqQuality").get(function () { scoresCountOffset(this.runs) + Percent( WORST_QUALITY_DISTANCE_FROM_TARGET - - (10.0 * Math.abs(1 - this.inverse95CurPercentPercentile) + - 4.0 * Math.abs(5 - this.inverse85CurPercentPercentile) + - 1.0 * Math.abs(15 - this.inverse75CurPercentPercentile) + - 0.5 * Math.abs(45 - this.inverse60CurPercentPercentile) + - 0.3 * Math.abs(85 - this.inverse40CurPercentPercentile)), + (5.0 * Math.abs(1 - this.inverse95CurPercentPercentile) + + 5.0 * Math.abs(5 - this.inverse85CurPercentPercentile) + + 2.5 * Math.abs(15 - this.inverse75CurPercentPercentile) + + 1.0 * Math.abs(45 - this.inverse60CurPercentPercentile) + + 0.5 * Math.abs(85 - this.inverse40CurPercentPercentile)), WORST_QUALITY_DISTANCE_FROM_TARGET, ) ); @@ -244,14 +247,33 @@ ClassifierSchema.index({ classifier: 1, division: 1 }, { unique: true }); ClassifierSchema.index({ division: 1 }); export const Classifiers = mongoose.model("Classifiers", ClassifierSchema); +export interface StageInfo { + code: string; // aka classifierCode/classifierNumber + number: number; // 1, 2, 3, etc + name: string; + scoring: Scoring; +} + +const basicInfoForStage = (stageClassifierCode: string): ClassifierBasicInfo => ({ + id: stageClassifierCode, + code: stageClassifierCode, + classifier: stageClassifierCode, + name: stageClassifierCode.split(".")[1], + scoring: "", +}); + export const singleClassifierExtendedMetaDoc = async ( division: string, classifier: string, recHHFReady?: RecHHF, + classifiersOnly?: boolean, ) => { - const c = classifiersByNumber[classifier]; - const basicInfo = basicInfoForClassifier(c); + const basicInfo = classifiersOnly + ? basicInfoForClassifierNumber(classifier) + : basicInfoForStage(classifier); + if (!basicInfo?.code) { + console.log(classifier); return null; } const [recHHFQuery, hitFactorScoresRaw] = await Promise.all([ @@ -282,7 +304,7 @@ export const singleClassifierExtendedMetaDoc = async ( return { division, ...basicInfo, - ...extendedInfoForClassifier(c, division, hitFactorScores), + ...extendedInfoForClassifier(basicInfo, division, hitFactorScores, classifiersOnly), recHHF, ...inverseRecPercentileStats(100), ...inverseRecPercentileStats(95), @@ -399,8 +421,14 @@ export const rehydrateSingleClassifier = async ( classifier: string, division: string, recHHF?: RecHHF, + onlyActualClassifiers: boolean = true, ) => { - const doc = await singleClassifierExtendedMetaDoc(division, classifier, recHHF); + const doc = await singleClassifierExtendedMetaDoc( + division, + classifier, + recHHF, + onlyActualClassifiers, + ); if (doc) { return Classifiers.updateOne( { division, classifier }, @@ -413,7 +441,10 @@ export const rehydrateSingleClassifier = async ( }; // linear rehydration to prevent OOMs on uploader and mongod -export const rehydrateClassifiers = async (classifiers: ClassifierDivision[]) => { +export const rehydrateClassifiers = async ( + classifiers: ClassifierDivision[], + onlyActualClassifiers: boolean = true, +) => { const recHHFs = await RecHHFs.find({ classifierDivision: { $in: classifiers.map(c => [c.classifier, c.division].join(":")), @@ -432,6 +463,7 @@ export const rehydrateClassifiers = async (classifiers: ClassifierDivision[]) => classifier, division, recHHFsByClassifierDivision[[classifier, division].join(":")], + onlyActualClassifiers, ); } }; diff --git a/api/src/db/index.ts b/api/src/db/index.ts index c70b4594..efe716ab 100644 --- a/api/src/db/index.ts +++ b/api/src/db/index.ts @@ -16,7 +16,8 @@ export const connect = async () => { } const url = !LOCAL_DEV ? MONGO_URL : MONGO_URL_LOCAL; - const publicLogsDBName = url!.split("@")[1]?.split(".")[0] || "local"; + const publicLogsDBHost = url!.split("@")[1]?.split(".")[0] || "local"; + const dbName = url!.split("?")[0]?.split("/").reverse()[0] || "root"; const _connect = () => { console.error("DB: connecting"); @@ -35,7 +36,7 @@ export const connect = async () => { await _connect(); }); mongoose.connection.on("connected", () => { - console.error(`DB: connected to ${publicLogsDBName}`); + console.error(`DB: connected to ${publicLogsDBHost} ${dbName}`); }); mongoose.connection.on("reconnected", () => { diff --git a/api/src/db/matches.ts b/api/src/db/matches.ts index f9226a1d..a04233f5 100644 --- a/api/src/db/matches.ts +++ b/api/src/db/matches.ts @@ -22,6 +22,17 @@ interface AlgoliaMatchNumericFilters { timestamp_utc_updated: number; } +export interface MatchDef { + match_id: string; + match_name: string; + match_type: string; + match_subtype: string; + match_creationdate: string; + match_modifieddate: string; + match_date: string; // 2024-12-31 format + templateName: string; +} + const MatchesSchema = new mongoose.Schema( { updated: Date, @@ -96,6 +107,28 @@ const fetchMatchesRange = async ( })); }; +export const matchFromMatchDef = ( + h: MatchDef, + forcedTemplateName?: string, +): Match & AlgoliaMatchNumericFilters => { + if (!h) { + return h; + } + const updated = new Date(`${h.match_modifieddate}Z`); + return { + updated, + created: new Date(`${h.match_creationdate}Z`), + id: Number.parseInt(h.match_id.split("-").reverse()[0], 16), + name: h.match_name, + uuid: h.match_id, + date: h.match_date, + timestamp_utc_updated: updated.getTime(), + type: h.match_type, + subType: h.match_subtype, + templateName: forcedTemplateName || h.templateName, + }; +}; + const fetchMatchesRangeByTimestamp = async ( latestTimestamp: number, template = "USPSA", diff --git a/api/src/db/recHHF.ts b/api/src/db/recHHF.ts index 816de566..c8b4b5f5 100644 --- a/api/src/db/recHHF.ts +++ b/api/src/db/recHHF.ts @@ -12,6 +12,11 @@ import { HF, Percent, PositiveOrMinus1 } from "../dataUtil/numbers"; import { minorHFScoresAdapter, Scores } from "./scores"; +const VERY_LOW_SAMPLE_SIZE_DIVISIONS = new Set([ + // pcsl2gun + "practical", + "competition", +]); const LOW_SAMPLE_SIZE_DIVISIONS = new Set([ "rev", "scsa_ss", @@ -416,6 +421,36 @@ const decidedHHFFunctions = { "23-01": r1, "23-02": r15, }, + + practical: { + "1.GOLDCOUNTRY": r5, // or 15 + "2.BELT": r1, + "3.HARDBASS": r5, + "4.CROSSROADS": r5, + "5.TRAPHOUSE": r5, + "6.LEADPEDAL": r5, + "7.BUYHIGHSELLLOW": r5, + "8.WEARESOBACK": r15, + "9.ITSSOOVER": r15, + "10.AREYAWINNINSON": r5, + "11.MOEZAMBIQUE": r5, + "12.WOLVERINES": r15, + }, + + competition: { + "1.GOLDCOUNTRY": r5, + "2.BELT": r5, + "3.HARDBASS": r5, + "4.CROSSROADS": r15, + "5.TRAPHOUSE": r5, + "6.LEADPEDAL": r5, + "7.BUYHIGHSELLLOW": r5, + "8.WEARESOBACK": r5, + "9.ITSSOOVER": r5, + "10.AREYAWINNINSON": r5, + "11.MOEZAMBIQUE": r5, + "12.WOLVERINES": r5, + }, }; // First manual recHHF Function review notes: @@ -430,6 +465,11 @@ const recommendedHHFFunctionFor = ({ division, number }) => { return decided; } + // Not enough data for pcsl nats + if (VERY_LOW_SAMPLE_SIZE_DIVISIONS.has(division)) { + return r5; + } + // Not enough data for revolver in ANY of the classifiers, drop to r5 for defaults if (LOW_SAMPLE_SIZE_DIVISIONS.has(division)) { return r5; diff --git a/api/src/db/shooters.ts b/api/src/db/shooters.ts index 62497240..b4dd67e2 100644 --- a/api/src/db/shooters.ts +++ b/api/src/db/shooters.ts @@ -12,7 +12,6 @@ import { hfuDivisionCompatabilityMap, hfuDivisionsShortNamesThatNeedMinorHF, mapDivisions, - uspsaDivShortNames, } from "../dataUtil/divisions"; import { psClassUpdatesByMemberNumber } from "../dataUtil/uspsa"; import { loadJSON, processImportAsyncSeq } from "../utils"; @@ -483,18 +482,13 @@ export const reclassifyShooters = async shooters => { ]); const updates = shooters - .filter( - ({ memberNumber, division }) => - // TODO: Implement Reclassify Shooters for SCSA - // https://github.com/CodeHowlerMonkey/hitfactor.info/issues/69 - memberNumber && uspsaDivShortNames.find(x => x === division), - ) .map(({ memberNumber, division, name }) => { if (!memberNumber) { return []; } const recMemberScores = recScoresByMemberNumber[memberNumber]; const curMemberScores = curScoresByMemberNumber[memberNumber]; + console.log(`${memberNumber}: ${recMemberScores.length} rec scores`); const recalcByCurPercent = calculateUSPSAClassification( curMemberScores, "curPercent", diff --git a/api/src/db/stats.ts b/api/src/db/stats.ts index d926c01c..ccff36e9 100644 --- a/api/src/db/stats.ts +++ b/api/src/db/stats.ts @@ -2,7 +2,7 @@ import mongoose from "mongoose"; import { ClassificationLetter } from "../../../data/types/USPSA"; -import { divShortNames, mapDivisions } from "../dataUtil/divisions"; +import { allDivShortNames, mapAllDivisions, mapDivisions } from "../dataUtil/divisions"; import { Shooters } from "./shooters"; @@ -56,7 +56,7 @@ const addCurClassField = () => ({ }); export const statsByDivision = async (field: string) => { - const byDiv = mapDivisions(() => ({})); + const byDiv = mapAllDivisions(() => ({})); const dbResults = await Shooters.aggregate([ addCurClassField(), { @@ -77,7 +77,7 @@ export const statsByDivision = async (field: string) => { ]); dbResults.forEach(({ _id: [classLetter, division], count }) => { - if (!divShortNames.includes(division)) { + if (!allDivShortNames.includes(division)) { return; } diff --git a/api/src/routes/api/classifiers/index.js b/api/src/routes/api/classifiers/index.js index 7251fdbf..324c2d4a 100644 --- a/api/src/routes/api/classifiers/index.js +++ b/api/src/routes/api/classifiers/index.js @@ -183,11 +183,6 @@ const classifiersRoutes = async fastify => { const { division, number } = req.params; const c = classifiers.find(cur => cur.classifier === number); - if (!c) { - res.statusCode = 404; - return { info: null }; - } - const basic = basicInfoForClassifier(c); const [extended, recHHFInfo] = await Promise.all([ Classifiers.findOne({ division, classifier: number }).lean(), @@ -253,8 +248,6 @@ const classifiersRoutes = async fastify => { fastify.get("/:division/:number/chart", async req => { const { division, number } = req.params; - const { full: fullString } = req.query; - const full = Number(fullString); const runs = await Scores.aggregate([ { @@ -298,6 +291,7 @@ const classifiersRoutes = async fastify => { { $addFields: { recHHF: _getRecHHFField("recHHF"), + name: _getShooterField("name"), }, }, { @@ -338,32 +332,17 @@ const classifiersRoutes = async fastify => { { $sort: { hf: -1 } }, ]); - const hhf = curHHFForDivisionClassifier({ number, division }); const allPoints = runs.map((run, index, allRuns) => ({ x: HF(run.hf), y: PositiveOrMinus1(Percent(index, allRuns.length)), - memberNumber: run.memberNumber, + memberNumber: run.name, curPercent: run.curPercent || 0, curHHFPercent: run.curHHFPercent || 0, recPercent: run.recPercent || 0, scoreRecPercent: run.scoreRecPercent || 0, })); - // for zoomed in mode return all points - if (full === 1) { - return allPoints; - } - - // always return top 100 points, and reduce by 0.5% grouping for other to make render easier - const first50 = allPoints.slice(0, 100); - const other = allPoints.slice(100, allPoints.length); - return [ - ...first50, - ...uniqBy( - other, - ({ x }) => Math.floor((200 * x) / hhf), // 0.5% grouping for graph points reduction - ), - ]; + return allPoints; }); }; diff --git a/api/src/routes/api/shooters/index.js b/api/src/routes/api/shooters/index.js index daeddebd..741e27e1 100644 --- a/api/src/routes/api/shooters/index.js +++ b/api/src/routes/api/shooters/index.js @@ -146,10 +146,8 @@ const shootersRoutes = async fastify => { fastify.get("/:division/chart", async req => { const { division } = req.params; - const sport = sportForDivision(division); const shootersTable = await Shooters.find({ division, - ...(sport !== "hfu" ? { current: { $gt: 0 } } : {}), reclassificationsRecPercentCurrent: { $gt: 0 }, }) .select([ @@ -157,6 +155,7 @@ const shootersRoutes = async fastify => { "reclassificationsCurPercentCurrent", "reclassificationsRecPercentCurrent", "memberNumber", + "name", ]) .lean() .limit(0); @@ -167,16 +166,7 @@ const shootersRoutes = async fastify => { curHHFPercent: c.reclassificationsCurPercentCurrent, recPercent: c.reclassificationsRecPercentCurrent, memberNumber: c.memberNumber, - })) - .sort(safeNumSort("curPercent")) - .map((c, i, all) => ({ - ...c, - curPercentPercentile: (100 * i) / (all.length - 1), - })) - .sort(safeNumSort("curHHFPercent")) - .map((c, i, all) => ({ - ...c, - curHHFPercentPercentile: (100 * i) / (all.length - 1), + name: c.name, })) .sort(safeNumSort("recPercent")) .map((c, i, all) => ({ diff --git a/api/src/routes/api/shooters/test/uspsaClassification.test.js b/api/src/routes/api/shooters/test/uspsaClassification.test.js index 55d2897f..ca4cb77d 100644 --- a/api/src/routes/api/shooters/test/uspsaClassification.test.js +++ b/api/src/routes/api/shooters/test/uspsaClassification.test.js @@ -778,3 +778,155 @@ test("calculateUSPSAClassification A111317 should have co", () => { const result = calculateUSPSAClassification(noCurPercentButExpected, "curPercent"); assert.strictEqual(Number(result.co.percent.toFixed(2)), 72.6); }); + +test.only("pcslNats", () => { + const recScores = [ + { + hf: 5.7098, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "9.IT'S SO OVER", + classifierDivision: "9.IT'S SO OVER:competition", + percent: 0, + curPercent: -570.98, + recPercent: 111.9327, + source: "Stage Score", + }, + { + hf: 3.3858, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "1.GOLD COUNTRY", + classifierDivision: "1.GOLD COUNTRY:competition", + percent: 0, + curPercent: -338.58, + recPercent: 100.5076, + source: "Stage Score", + }, + { + hf: 7.762, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "8.WE ARE SO BACK", + classifierDivision: "8.WE ARE SO BACK:competition", + percent: 0, + curPercent: -776.2, + recPercent: 100.0026, + source: "Stage Score", + }, + { + hf: 5.1448, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "11.MOEZAMBIQUE", + classifierDivision: "11.MOEZAMBIQUE:competition", + percent: 0, + curPercent: -514.48, + recPercent: 97.1597, + source: "Stage Score", + }, + { + hf: 5.7792, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "10.ARE YA WINNIN SON", + classifierDivision: "10.ARE YA WINNIN SON:competition", + percent: 0, + curPercent: -577.92, + recPercent: 96.8982, + source: "Stage Score", + }, + { + hf: 5.29, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "7.BUY HIGH SELL LOW", + classifierDivision: "7.BUY HIGH SELL LOW:competition", + percent: 0, + curPercent: -529, + recPercent: 92.3937, + source: "Stage Score", + }, + { + hf: 3.0614, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "12.WOLVERINES", + classifierDivision: "12.WOLVERINES:competition", + percent: 0, + curPercent: -306.14, + recPercent: 90.0147, + source: "Stage Score", + }, + { + hf: 5.262, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "4.CROSSROADS", + classifierDivision: "4.CROSSROADS:competition", + percent: 0, + curPercent: -526.2, + recPercent: 86.3189, + source: "Stage Score", + }, + { + hf: 5.9628, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "5.TRAP HOUSE", + classifierDivision: "5.TRAP HOUSE:competition", + percent: 0, + curPercent: -596.28, + recPercent: 83.2003, + source: "Stage Score", + }, + { + hf: 4.5485, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "6.LEAD PEDAL", + classifierDivision: "6.LEAD PEDAL:competition", + percent: 0, + curPercent: -454.85, + recPercent: 73.2601, + source: "Stage Score", + }, + { + hf: 5.2349, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "2.BELT", + classifierDivision: "2.BELT:competition", + percent: 0, + curPercent: -523.49, + recPercent: 71.8724, + source: "Stage Score", + }, + { + hf: 5.7714, + sd: "2024-12-04T00:00:00.000Z", + memberNumber: "VARICKBEISE", + division: "competition", + classifier: "3.HARDBASS", + classifierDivision: "3.HARDBASS:competition", + percent: 0, + curPercent: -577.14, + recPercent: 66.7391, + source: "Stage Score", + }, + ]; + + const result = calculateUSPSAClassification(recScores, "recPercent"); + assert.strictEqual(Number(result.competition.percent.toFixed(2)), 100.0); +}); diff --git a/api/src/worker/uploads.ts b/api/src/worker/uploads.ts index c39981f0..f8e936e4 100644 --- a/api/src/worker/uploads.ts +++ b/api/src/worker/uploads.ts @@ -33,9 +33,9 @@ import { AfterUploadShooters, type AfterUploadShooter } from "../db/afterUploadS import { rehydrateClassifiers } from "../db/classifiers"; import { DQs } from "../db/dq"; import { connect } from "../db/index"; -import { Matches } from "../db/matches"; +import { Match, MatchDef, Matches } from "../db/matches"; import { hydrateRecHHFsForClassifiers } from "../db/recHHF"; -import { Scores } from "../db/scores"; +import { Score, Scores } from "../db/scores"; import { reclassifyShooters } from "../db/shooters"; import { hydrateStats } from "../db/stats"; @@ -211,7 +211,7 @@ const scsaMatchInfo = async matchInfo => { skipResults: true, }); if (!match || !scoresJson) { - return EmptyMatchResultsFactory(); + return EmptySingleMatchResultFactory(match); } /* match_penalties Structure: @@ -381,24 +381,56 @@ const scsaMatchInfo = async matchInfo => { return { scores, match, results: [] }; } catch (e) {} - return EmptyMatchResultsFactory(); + return EmptySingleMatchResultFactory(match); }; -export const uspsaOrHitFactorMatchInfo = async matchInfo => { - const { uuid } = matchInfo; - const { matchDef: match, results, scores: scoresJson } = await fetchPS(uuid); +const badCharsRegExp = /[\s:\t.,\-_+=!?']/gi; +const memberNumberFromMatchDefShooter = (s, mustHaveMemberNumbers) => { + if (mustHaveMemberNumbers) { + return s.sh_id; + } + + return s.sh_id || [s.sh_fn, s.sh_ln].join("").replace(badCharsRegExp, "").toUpperCase(); +}; + +const classifierCodeFromMatchDefStage = (s, onlyActualClassifiers) => { + if (onlyActualClassifiers) { + return s.classifiercode; + } + + return `${s.stage_number || ""}.${s.stage_name.replace(badCharsRegExp, "").toUpperCase()}`; +}; + +export const hitFactorLikeMatchInfo = ( + matchInfo: Match, + s3MatchFiles, + onlyClassifiers: boolean = true, + mustHaveMemberNumbers: boolean = true, +): { match: MatchDef; results: unknown; scores: Score[] } => { + const { matchDef: match, results, scores: scoresJson } = s3MatchFiles; if (!match || !results || !scoresJson) { - return EmptyMatchResultsFactory(); + return EmptySingleMatchResultFactory(match); } const { match_shooters, match_stages } = match; - const shootersMap = Object.fromEntries(match_shooters.map(s => [s.sh_uuid, s.sh_id])); + const shootersMap = Object.fromEntries( + match_shooters.map(s => [ + s.sh_uuid, + memberNumberFromMatchDefShooter(s, mustHaveMemberNumbers), + ]), + ); match.memberNumberToNamesMap = Object.fromEntries( - match_shooters.map(s => [s.sh_id, [s.sh_fn, s.sh_ln].filter(Boolean).join(" ")]), + match_shooters.map(s => [ + memberNumberFromMatchDefShooter(s, mustHaveMemberNumbers), + [s.sh_fn, s.sh_ln].filter(Boolean).join(" "), + ]), ); const classifiersMap = Object.fromEntries( match_stages - .filter(s => !!s.stage_classifiercode) - .map(s => [s.stage_uuid, s.stage_classifiercode]), + .filter(s => !onlyClassifiers || !!s.stage_classifiercode) + .map(s => [s.stage_uuid, classifierCodeFromMatchDefStage(s, onlyClassifiers)]), + /*.filter( + ([uuid, stageCode]) => !["2", "3", "8", "9"].includes(stageCode.split(".")[0]), + ),*/ ); const classifierUUIDs = Object.keys(classifiersMap); const classifierResults = results.filter(r => classifierUUIDs.includes(r.stageUUID)); @@ -466,7 +498,7 @@ export const uspsaOrHitFactorMatchInfo = async matchInfo => { memberNumber, classifier, division, - upload: uuid, + upload: match.match_id, clubid: match.match_clubcode, club_name: match.match_clubname || match.match_name, matchName: match.match_name, @@ -496,6 +528,26 @@ export const uspsaOrHitFactorMatchInfo = async matchInfo => { return { scores, match, results }; }; +export const uspsaOrHitFactorMatchInfo = async matchInfo => { + const { uuid } = matchInfo; + const s3MatchFiles = await fetchPS(uuid); + return hitFactorLikeMatchInfo(matchInfo, s3MatchFiles); +}; + +export const pcslNatsMatchInfo = async matchInfo => { + const { uuid } = matchInfo; + const s3MatchFiles = await fetchPS(uuid); + const result = hitFactorLikeMatchInfo(matchInfo, s3MatchFiles, false, false); + return { + ...result, + match: { + ...result.match, + templateName: matchInfo.templateName, + }, + }; +}; + +const EmptySingleMatchResultFactory = match => ({ scores: [], match, results: [] }); const EmptyMatchResultsFactory = () => ({ scores: [], matches: [], results: [] }); const uploadResultsForMatches = async matches => { @@ -505,6 +557,9 @@ const uploadResultsForMatches = async matches => { case "Steel Challenge": return scsaMatchInfo(match); + case "PCSLNats": + return pcslNatsMatchInfo(match); + case "USPSA": case "Hit Factor": default: { @@ -556,7 +611,10 @@ export const processUploadResults = async ({ uploadResults }) => { } acc.push({ - memberNumber: shooter.sh_id, + memberNumber: memberNumberFromMatchDefShooter( + shooter, + match.templateName !== "PCSLNats", + ), lastName: shooter.sh_ln, firstName: shooter.sh_fn, division: shooter.sh_dvp, @@ -570,6 +628,7 @@ export const processUploadResults = async ({ uploadResults }) => { }); return acc; }, []); + await DQs.bulkWrite( dqDocs.map(dq => ({ updateOne: { @@ -745,7 +804,7 @@ export const dqNames = async () => { console.log(JSON.stringify(dqs, null, 2)); }; -const metaClassifiersLoop = async (batchSize = 8) => { +const metaClassifiersLoop = async (batchSize = 8, onlyActualClassifiers = false) => { const totalCount = await AfterUploadClassifiers.countDocuments({}); console.log(`${totalCount} classifiers to update`); @@ -758,7 +817,7 @@ const metaClassifiersLoop = async (batchSize = 8) => { } await hydrateRecHHFsForClassifiers(classifiers); - await rehydrateClassifiers(classifiers); + await rehydrateClassifiers(classifiers, onlyActualClassifiers); await AfterUploadClassifiers.deleteMany({ _id: { $in: classifiers.map(c => c._id) }, }); @@ -795,7 +854,11 @@ const metaShootersLoop = async (batchSize = 8) => { } }; -export const metaLoop = async (curTry = 1, maxTries = 3) => { +export const metaLoop = async ( + onlyActualClassifiers = true, + curTry = 1, + maxTries = 3, +) => { try { await metaClassifiersLoop(); await metaShootersLoop(); @@ -803,7 +866,7 @@ export const metaLoop = async (curTry = 1, maxTries = 3) => { } catch (err) { console.error(err); if (curTry < maxTries) { - return metaLoop(curTry + 1, maxTries); + return metaLoop(onlyActualClassifiers, curTry + 1, maxTries); } } }; diff --git a/data/types/USPSA.ts b/data/types/USPSA.ts index b8eef28a..d5ea01a1 100644 --- a/data/types/USPSA.ts +++ b/data/types/USPSA.ts @@ -7,6 +7,7 @@ export interface HHFJSON { id: string; classifier: string; // number id of classifier in classifers.json hhf: string; // number in toFixed(4) + updated?: string; // date in "2022-03-01 21:39:39" format } /** diff --git a/package-lock.json b/package-lock.json index 7198c4cb..3f94e87b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { - "name": "hitfactor", + "name": "hitfactorinfo", "version": "0.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "hitfactor", + "name": "hitfactorinfo", "version": "0.0.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-s3": "^3.637.0", "lodash.uniqby": "^4.7.0", + "simple-statistics": "^7.8.7", "undici": "^6.18.2", "uuid": "^9.0.1" }, @@ -39,7 +40,7 @@ "zenrows": "^1.3.2" }, "engines": { - "node": "20.11.x" + "node": "20.x" } }, "node_modules/@aws-crypto/crc32": { @@ -5567,6 +5568,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-statistics": { + "version": "7.8.7", + "resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.8.7.tgz", + "integrity": "sha512-ed5FwTNYvkMTfbCai1U+r3symP+lIPKWCqKdudpN4NFNMn9RtDlFtSyAQhCp4oPH0YBjWu/qnW+5q5ZkPB3uHQ==", + "license": "ISC", + "engines": { + "node": "*" + } + }, "node_modules/spawn-command": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", @@ -10204,6 +10214,11 @@ "object-inspect": "^1.13.1" } }, + "simple-statistics": { + "version": "7.8.7", + "resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.8.7.tgz", + "integrity": "sha512-ed5FwTNYvkMTfbCai1U+r3symP+lIPKWCqKdudpN4NFNMn9RtDlFtSyAQhCp4oPH0YBjWu/qnW+5q5ZkPB3uHQ==" + }, "spawn-command": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", diff --git a/package.json b/package.json index b8100bf9..56c956b2 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "engines": { - "node": "20.11.x" + "node": "20.x" }, "scripts": { "========================= Dev Scripts ====================": "", @@ -62,6 +62,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.637.0", "lodash.uniqby": "^4.7.0", + "simple-statistics": "^7.8.7", "undici": "^6.18.2", "uuid": "^9.0.1" } diff --git a/scripts/upload/singleMatch.ts b/scripts/upload/singleMatch.ts new file mode 100644 index 00000000..acac1bf8 --- /dev/null +++ b/scripts/upload/singleMatch.ts @@ -0,0 +1,66 @@ +/* eslint-disable no-console */ +import { connect } from "../../api/src/db/index"; +import { Matches, matchFromMatchDef } from "../../api/src/db/matches"; +import { + fetchPS, + metaLoop, + // hitFactorLikeMatchInfo, + uploadMatches, +} from "../../api/src/worker/uploads"; + +const go = async () => { + const matchUUID = process.argv[2]; + const matchTemplateName = process.argv[2]; + if (!matchUUID || !matchTemplateName) { + console.error("must provide match name and templateName"); + process.exit(1); + } + + const { matchDef } = await fetchPS(matchUUID); + const match = matchFromMatchDef(matchDef, process.argv[3]); + console.log(JSON.stringify(match, null, 2)); + await connect(); + if (!match?.name) { + console.error("bad match"); + process.exit(1); + } + await Matches.bulkWrite([ + { + updateOne: { + filter: { + uuid: match.uuid, + }, + update: { $set: match }, + upsert: true, + }, + }, + ]); + + /* + const shit = hitFactorLikeMatchInfo(match, { matchDef, results, scores }, false, false); + console.log(JSON.stringify(shit.scores, null, 2)); + */ + + const upload = await uploadMatches({ matches: [match] }); + console.log(JSON.stringify(upload, null, 2)); + await Matches.bulkWrite( + [match].map(m => ({ + updateOne: { + filter: { uuid: m.uuid }, + update: { + $set: { + uploaded: new Date(), + hasScores: true, + }, + }, + }, + })), + ); + console.error("marked match as uploaded"); + await metaLoop(false); + + console.error("done"); + process.exit(0); +}; + +go(); diff --git a/shared/constants/divisions.ts b/shared/constants/divisions.ts index 401c0cb5..4d120b80 100644 --- a/shared/constants/divisions.ts +++ b/shared/constants/divisions.ts @@ -5,6 +5,18 @@ export const pairToDivision = pair => pair.split(":")[1]; // TODO: add allDivisions for multisport, use uspsaDivisions for USPSA only export const uspsaDivisions = divisionsFromJson.divisions; +export const pcsl2gunDivisions = [ + { + id: "0", + long_name: "Competition", + short_name: "competition", + }, + { + id: "00", + long_name: "Practical", + short_name: "practical", + }, +]; /** ["opn", "ltd", "l10", "prod", "rev", "ss", "co", "lo", "pcc"] */ export const divShortNames = uspsaDivisions.map(c => c.short_name.toLowerCase()); export const uspsaDivShortNames = divShortNames; @@ -72,7 +84,8 @@ export const hfuDivisionsShortNamesThatNeedMinorHF = ["comp", "irn"]; export const nameForDivision = div => uspsaDivShortToLong[div] || hfuDivisions.find(d => d.short === div)?.long || - scsaDivisions.find(d => d.short === div)?.long; + scsaDivisions.find(d => d.short === div)?.long || + div[0].toUpperCase() + div.slice(1); export const sportForDivision = division => { if (hfuDivisionsShortNames.indexOf(division) >= 0) { @@ -293,6 +306,8 @@ export const divisionsForRecHHFAdapter = division => { export const allDivShortNames = [ ...uspsaDivShortNames, ...hfuDivisionsShortNames, + "practical", + "competition", // TODO: pcsl // TODO: scsa ]; diff --git a/shared/constants/pcsl.js b/shared/constants/pcsl.js new file mode 100644 index 00000000..7ab27dbc --- /dev/null +++ b/shared/constants/pcsl.js @@ -0,0 +1,18 @@ +export const pcslStageHackName = code => + ({ + "1.GOLDCOUNTRY": "Gold Country", + "2.BELT": "BELT", + "3.HARDBASS": "Hardbass", + "4.CROSSROADS": "Crossroads", + "5.TRAPHOUSE": "Trap House", + "6.LEADPEDAL": "Lead Pedal", + "7.BUYHIGHSELLLOW": "Buy High, Sell Low", + "8.WEARESOBACK": "We Are So Back!", + "9.ITSSOOVER": "It's So Over!", + "10.AREYAWINNINSON": "Are Ya Winnin, Son?", + "11.MOEZAMBIQUE": "MOE-Zambique", + "12.WOLVERINES": "Wolverines!", + })[code] || code; + +export const pcslStageHackNumber = code => code?.split(".")[0]; +export const pcslStageHackCode = code => `Stage ${pcslStageHackNumber(code)}`; diff --git a/shared/utils/classification.js b/shared/utils/classification.js index ada7c323..7f249052 100644 --- a/shared/utils/classification.js +++ b/shared/utils/classification.js @@ -138,6 +138,8 @@ const windowSizeForScore = windowSize => { return 6; }; +const cappedScoreFunc = score => Math.min(100, score); +const uncappedScoreFunc = score => score; const ageForDate = (now, sd) => (now - new Date(sd)) / (28 * 24 * 60 * 60 * 1000); export const percentAndAgesForDivWindow = ( div, @@ -145,14 +147,17 @@ export const percentAndAgesForDivWindow = ( percentField = "percent", now = new Date(), ) => { + const isRecMode = percentField === "recPercent"; //de-dupe needs to be done in reverse, because percent are sorted asc let window = state[div].window; - if (percentField !== "recPercent") { + if (!isRecMode) { // don't use best dupe for recommended, only most recent one window = window.toSorted((a, b) => numSort(a, b, percentField, -1)); } const dFlagsApplied = uniqBy(window, c => c.classifier); + const scoreFunc = isRecMode ? uncappedScoreFunc : cappedScoreFunc; + // remove lowest 2 const newLength = windowSizeForScore(dFlagsApplied.length); const fFlagsApplied = dFlagsApplied @@ -160,7 +165,7 @@ export const percentAndAgesForDivWindow = ( .slice(0, newLength); const percent = fFlagsApplied.reduce( (acc, curValue, curIndex, allInWindow) => - acc + Math.min(100, curValue[percentField]) / allInWindow.length, + acc + scoreFunc(curValue[percentField]) / allInWindow.length, 0, ); diff --git a/shared/utils/weibull.ts b/shared/utils/weibull.ts new file mode 100644 index 00000000..81dcd9af --- /dev/null +++ b/shared/utils/weibull.ts @@ -0,0 +1,59 @@ +import * as ss from "simple-statistics"; + +const optimize = (fn, start) => { + let bestParams = start; + let bestLoss = fn(start); + const step = 0.025; + for (let i = -40; i <= 40; i++) { + for (let j = -40; j <= 40; j++) { + const testParams = [bestParams[0] + i * step, bestParams[1] + j * step]; + const loss = fn(testParams); + if (loss < bestLoss) { + bestLoss = loss; + bestParams = testParams; + } + } + } + return bestParams; +}; + +const probabilityDistributionFn = (x: number, k: number, lambda: number): number => + (k / lambda) * Math.pow(x / lambda, k - 1) * Math.exp(-Math.pow(x / lambda, k)); + +const lossFnFactory = + (data: number[]) => + ([k, lambda]: [number, number]) => + data.reduce( + (sum, x) => sum - Math.log(probabilityDistributionFn(x, k, lambda) || 1e-10), + 0, + ); + +const findParams = data => { + if (!data) { + return [1, 1]; + } + const mean = ss.mean(data); + const variance = ss.variance(data); + + return optimize(lossFnFactory(data), [mean ** 2 / variance, mean]); +}; + +/** + * Fits Weibull distribution to provided dataset and returns an bucket of stuff: + * - Cumulative Distribution Function y(x) and it's reverse x(y) + * - fitted k & lambda params for Weibull Distribution + * - Recommended HHFs, using 99th/95%, 95th/85% and 85th/75% targets + * + * @param dataPoints array of hit-factor scores + */ +export const solveWeibull = (dataPoints: number[]) => { + const [k, lambda] = findParams(dataPoints); + + const cdf = x => 100 - 100 * (1 - Math.exp(-Math.pow(x / lambda, k))); + const reverseCDF = y => lambda * Math.pow(Math.log(100 / y), 1 / k); + const hhf1 = reverseCDF(1) / 0.95; + const hhf5 = reverseCDF(5) / 0.85; + const hhf15 = reverseCDF(15) / 0.75; + + return { k, lambda, cdf, reverseCDF, hhf1, hhf5, hhf15 }; +}; diff --git a/tsconfig.json b/tsconfig.json index 86e70454..9dc84a96 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "module": "ES2020", "moduleResolution": "Bundler", "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "jsx": "react-jsx", "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ "strict": true, /* Enable all strict type-checking options. */ "skipLibCheck": true, /* Skip type checking all .d.ts files. */ diff --git a/web/index.html b/web/index.html index edd91d07..61cbe873 100644 --- a/web/index.html +++ b/web/index.html @@ -9,7 +9,7 @@ - Howler Monkey Classifiers + PCSL.HitFactor.Info
diff --git a/web/package-lock.json b/web/package-lock.json index b7b177db..f55c67ac 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -34,8 +34,7 @@ "use-debounce": "^10.0.0", "uuid": "^9.0.1", "vite": "^5.0.8" - }, - "devDependencies": {} + } }, "node_modules/@babel/runtime": { "version": "7.23.9", diff --git a/web/src/components/ClassifierCell.jsx b/web/src/components/ClassifierCell.jsx index 99cc68a1..7c18e4db 100644 --- a/web/src/components/ClassifierCell.jsx +++ b/web/src/components/ClassifierCell.jsx @@ -1,6 +1,7 @@ import cx from "classnames"; import { Textfit } from "react-textfit"; +import { pcslStageHackName, pcslStageHackCode } from "../../../shared/constants/pcsl"; import { useIsHFU } from "../utils/useIsHFU"; export const ClassifierCell = ({ info, onClick, fallback, showScoring, division }) => { @@ -19,7 +20,7 @@ export const ClassifierCell = ({ info, onClick, fallback, showScoring, division >
- {code || fallback} + {pcslStageHackCode(code) || fallback}
{isHFU && (
{division?.toUpperCase()}
@@ -30,7 +31,7 @@ export const ClassifierCell = ({ info, onClick, fallback, showScoring, division
- {name} + {pcslStageHackName(code)}
diff --git a/web/src/components/DivisionNavigation.jsx b/web/src/components/DivisionNavigation.jsx index d71fc834..e6b6fca4 100644 --- a/web/src/components/DivisionNavigation.jsx +++ b/web/src/components/DivisionNavigation.jsx @@ -6,7 +6,7 @@ import { useParams } from "react-router-dom"; import { divisionChangeMap, hfuDivisions, - uspsaDivisions, + pcsl2gunDivisions as uspsaDivisions, sportName, scsaDivisions, } from "../../../shared/constants/divisions"; @@ -14,6 +14,7 @@ import features from "../../../shared/features"; import usePreviousEffect from "../utils/usePreviousEffect"; const SportSelector = ({ sportCode, setSportCode, uspsaOnly, disableSCSA, hideSCSA }) => { + return null; const menu = useRef(null); const items = [ { diff --git a/web/src/components/RunsTable.jsx b/web/src/components/RunsTable.jsx index b1a14a72..2f9889c9 100644 --- a/web/src/components/RunsTable.jsx +++ b/web/src/components/RunsTable.jsx @@ -141,7 +141,6 @@ const RunsTable = ({ classifier, division, clubs, onShooterSelection }) => { stripedRows lazy value={data ?? []} - tableStyle={{ minWidth: "50rem" }} {...sortProps} {...pageProps} paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink" @@ -205,59 +204,6 @@ const RunsTable = ({ classifier, division, clubs, onShooterSelection }) => { headerTooltip="What classifier percentage this score SHOULD earn if Recommended HHFs are used." headerTooltipOptions={headerTooltipOptions} /> -