Files
platform/calculator/calc.ts
T
2025-09-15 16:28:15 -04:00

336 lines
12 KiB
TypeScript

import { Activity, Attribute, attributes, ActivityPerformance, getActivityAttribute, getAttributeActivities, Player, StandardsMap, Levels, Standard, Metrics, Gender } from "./util";
// SOURCES
// Squat, Bench, Dead Lift:
// http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf
// 1 mile run:
// https://runninglevel.com/running-times/1-mile-times
// Dash:
// https://marathonhandbook.com/average-100-meter-time/
// Broad Jump:
//
type LevelCalculatorConfig = {
expandIters?: number;
compressTo?: number;
}
type LevelCalculatorOutput = {
player: number,
attributes: Record<Attribute, number>
}
export class LevelCalculator {
cfg: Required<LevelCalculatorConfig>;
standards: Standards;
public constructor(standards: Standards, cfg: LevelCalculatorConfig = {}) {
this.cfg = {
expandIters: cfg.expandIters ?? 5,
compressTo: cfg.compressTo ?? 100
}
this.standards = standards;
}
// -------------------------------------------------------------------------------------------------
// Level calculations
// -------------------------------------------------------------------------------------------------
public calculate(player: Player, activityPerformances: ActivityPerformance[]): LevelCalculatorOutput {
const levels: LevelCalculatorOutput = {
player: 0,
attributes: {
[attributes.STRENGTH]: 0,
[attributes.POWER]: 0,
[attributes.ENDURANCE]: 0,
[attributes.AGILITY]: 0,
} as const
};
for (const value of Object.values(player)) {
if (!value)
return levels;
}
levels.attributes = this.calculateAllAttributeLevels(player, activityPerformances);
for (const level of Object.values(levels.attributes)) {
if (!level)
return levels;
}
const attrLevelsSum = Object.values(levels.attributes).reduce((sum, lvl) => sum + lvl, 0);
levels.player = Math.round(attrLevelsSum / Object.keys(attributes).length);
return levels;
}
private calculateAllAttributeLevels(
player: Player,
activityPerformances: ActivityPerformance[],
): Record<Attribute, number> {
const attrLevels: Record<Attribute, number> = {
[attributes.STRENGTH]: 0,
[attributes.POWER]: 0,
[attributes.ENDURANCE]: 0,
[attributes.AGILITY]: 0,
} as const;
for (const value of Object.values(player)) {
if (!value)
return attrLevels;
}
(Object.values(attributes) as Attribute[]).forEach((attribute) => {
const attrActivityPerformances = activityPerformances.filter(
(p) => getActivityAttribute(p.activity) === attribute,
);
attrLevels[attribute] = this.calculateAttributeLevel(attribute, player, attrActivityPerformances);
});
return attrLevels;
}
private calculateAttributeLevel(
attribute: Attribute,
player: Player,
activityPerformances: ActivityPerformance[],
): number {
// must be activities of the given attribute
if (activityPerformances.some((p) => getActivityAttribute(p.activity) !== attribute)) {
throw new Error("Wrong activity performance attribute");
}
// must perform all of an attributes activities
for (const activity of getAttributeActivities(attribute)) {
const filteredActivites = activityPerformances.map((p) => p.activity);
if (!filteredActivites.includes(activity))
return 0;
}
// must have all activties using a valid performance value (> 0)
for (const activityPerformance of activityPerformances) {
if (activityPerformance.performance <= 0)
return 0
}
const activityLevels = activityPerformances.map((p) => {
const interpolatedStandard = this.getInterpolatedStandard(p.activity, player.metrics);
return this.findLevel(interpolatedStandard, p.performance);
});
const activityLevelsAvg = Math.round(
activityLevels.reduce((sum, curr) => sum + curr, 0) / activityLevels.length,
);
return activityLevelsAvg;
}
// -------------------------------------------------------------------------------------------------
// Standards interpolation (heavy math bits)
// -------------------------------------------------------------------------------------------------
private getInterpolatedStandard(
activity: Activity,
metrics: Metrics,
): Standard {
const interpolateByWeight = (targetAge: number): Levels => {
const { lower: lowerAgeWeightStandard, upper: upperAgeWeightStandard } = this.standards
.byActivity(activity)
.byGender(metrics.gender)
.byAge(targetAge)
.getNearest("weight", metrics.weight);
const weightLower = lowerAgeWeightStandard.metrics.weight;
const weightUpper = upperAgeWeightStandard.metrics.weight;
let weightRatio = weightUpper === weightLower ? 1 : (metrics.weight - weightLower) / (weightUpper - weightLower);
weightRatio = Math.max(0, Math.min(1, weightRatio));
return this.interpolateLevels(lowerAgeWeightStandard.levels, upperAgeWeightStandard.levels, weightRatio);
}
// Find nearest age standards
const { lower: lowerAgeStandard, upper: upperAgeStandard } = this.standards
.byActivity(activity)
.byGender(metrics.gender)
.getNearest("age", metrics.age);
const ageLower = lowerAgeStandard.metrics.age;
const ageUpper = upperAgeStandard.metrics.age;
// Interpolate by age and weight
let ageRatio = ageUpper === ageLower ? 1 : (metrics.age - ageLower) / (ageUpper - ageLower);
ageRatio = Math.max(0, Math.min(1, ageRatio));
const interpolatedLevels = this.interpolateLevels(
interpolateByWeight(ageLower),
interpolateByWeight(ageUpper),
ageRatio
);
return {
metrics: metrics,
levels: interpolatedLevels
};
}
private interpolateLevels(
lower: Levels,
upper: Levels,
ratio: number,
): Levels {
if (Object.keys(lower).length !== Object.keys(upper).length)
throw new Error("Cannot interpolate between varying number of levels");
const lerp = (lvl: string) => (lower[lvl] as number) + ((upper[lvl] as number) - (lower[lvl] as number)) * ratio;
const interpolatedLevels = {};
for (const lvl in lower) {
interpolatedLevels[lvl] = lerp(lvl);
}
return interpolatedLevels;
}
// -------------------------------------------------------------------------------------------------
// Level utilities (helpers)
// -------------------------------------------------------------------------------------------------
private findLevel(standard: Standard, performance: number): number {
let resultLevel = 1;
let resultLevelDiff = Infinity;
for (const level in standard.levels) {
const diff = Math.abs(performance - standard.levels[level as unknown as number]);
if (diff < resultLevelDiff) {
resultLevel = +level;
resultLevelDiff = diff;
}
}
return resultLevel;
}
}
export class Standards {
private standardsMap: StandardsMap;
constructor(standardsMap: StandardsMap) {
this.standardsMap = standardsMap;
for (const activity of Object.keys(this.standardsMap) as Activity[]) {
for (const standard of this.standardsMap[activity]) {
const expandedLevels = this.expandLevels(standard.levels, 5);
const compressedLevels = this.compressLevels(expandedLevels, 100);
standard.levels = compressedLevels;
}
}
}
public byActivity(activity: Activity) {
const self = this;
const state: {
gender?: Gender;
age?: number;
weight?: number;
} = {};
const execMethods = {
getAll: function() {
let filtered = [...self.standardsMap[activity]];
if (state.gender)
filtered = filtered.filter((s) => s.metrics.gender === state.gender);
if (state.age)
filtered = filtered.filter((s) => s.metrics.age === state.age);
if (state.weight)
filtered = filtered.filter((s) => s.metrics.weight === state.weight);
return filtered;
},
getOne: function() {
return execMethods.getAll()[0];
},
getNearest(metric: "age" | "weight", target: number) {
const standards = execMethods.getAll();
const lower = [...standards]
.sort((a, b) => a.metrics[metric] - b.metrics[metric])
.reverse()
.find((s) => s.metrics[metric] <= target) ?? standards.at(0)!;
const upper = [...standards]
.sort((a, b) => a.metrics[metric] - b.metrics[metric])
.find((s) => s.metrics[metric] >= target) ?? standards.at(-1)!;
return { lower, upper };
}
};
const byGender = (gender: Gender) => {
state.gender = gender;
return { byAge, ...execMethods };
}
const byAge = (age: number) => {
state.age = age;
return { byWeight, ...execMethods };
}
const byWeight = (weight: number) => {
state.weight = weight;
return { ...execMethods };
}
return { byGender, ...execMethods }
}
private expandLevels(
levels: Levels,
i: number
): Levels {
if (i == 0) {
return levels;
}
const newLevels = {};
let j = 1;
for (let k = 0; k < Object.keys(levels).length; ++k) {
const currLevel = Object.keys(levels)[k];
const nextLevel = Object.keys(levels)[k + 1];
newLevels[j++] = levels[currLevel];
if (nextLevel) {
newLevels[j++] = (levels[currLevel] + levels[nextLevel]) / 2;
}
}
return this.expandLevels(newLevels, i - 1);
}
private compressLevels(
levels: Levels,
targetLevel: number,
): Levels {
const levelsAmount = Object.keys(levels).length;
if (levelsAmount < targetLevel) {
throw new Error(
"Target levels amount must be greater than or equal to the current levels amount",
);
}
const ratio = levelsAmount / targetLevel;
const compressedLevels: Levels = {};
for (let i = 0; i < targetLevel; ++i) {
const ratioIndex = i * ratio;
const lowerIndex = Math.floor(ratioIndex);
const upperIndex = Math.ceil(ratioIndex);
const lowerValue = levels[lowerIndex + 1];
const upperValue = levels[upperIndex + 1];
const weight = ratioIndex - lowerIndex;
compressedLevels[i + 1] = lowerValue + (upperValue - lowerValue) * weight;
}
return compressedLevels;
}
}