Simplifying and making calculation logic clearer

This commit is contained in:
Dominic Ferrando
2025-09-15 10:53:29 -04:00
parent f71067be85
commit 0ea5a54986
5 changed files with 2313 additions and 2291 deletions
+217 -197
View File
@@ -1,4 +1,4 @@
import { Activity, Attribute, attributes, ActivityPerformance, findNearestPoints, getActivityAttribute, getAttributeActivities, Player, StandardsMap, BaseLevel, Standard } from "./util"; import { Activity, Attribute, attributes, ActivityPerformance, findNearestPoints, getActivityAttribute, getAttributeActivities, Player, StandardsMap, Levels, Standard, Metrics, activities } from "./util";
import rawStandards from "./standards.json" assert { type: "json" } import rawStandards from "./standards.json" assert { type: "json" }
// SOURCES // SOURCES
@@ -13,236 +13,256 @@ import rawStandards from "./standards.json" assert { type: "json" }
const standards = rawStandards as StandardsMap; const standards = rawStandards as StandardsMap;
type BaseLevelValues = Record<BaseLevel, number> type TableData = Record<Activity, {
// ------------------------------------------------------------------------------------------------- }>
// Level utilities (helpers)
// -------------------------------------------------------------------------------------------------
function findLevel(levels: Record<number, number>, performance: number): number { type LevelCalculatorConfig = {
let resultLevel = 1; compressTo?: number;
let resultLevelDiff = Infinity; expandIters?: number;
for (const level in levels) {
const diff = Math.abs(performance - levels[level as unknown as number]);
if (diff < resultLevelDiff) {
resultLevel = +level;
resultLevelDiff = diff;
}
}
return resultLevel;
} }
function compressLevels( export class LevelCalculator {
levels: Record<number, number>, cfg: Required<LevelCalculatorConfig>;
targetLevelsAmount: number,
): Record<number, number> {
const levelsAmount = Object.keys(levels).length;
if (levelsAmount < targetLevelsAmount) {
throw new Error(
"Target levels amount must be greater than or equal to the current levels amount",
);
}
const ratio = levelsAmount / targetLevelsAmount; public constructor(cfg: LevelCalculatorConfig = {}) {
const compressedLevels: Record<number, number> = {}; this.cfg = {
compressTo: cfg.compressTo ?? 100,
for (let i = 0; i < targetLevelsAmount; ++i) { expandIters: cfg.expandIters ?? 5
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;
}
function expandLevels(
levels: BaseLevel | Record<number, number>,
i: number
): Record<number, number> {
if (i == 0) {
// ensure we always use number keys
const newLevels = {};
for (let k = 0; k < Object.keys(levels).length; ++k) {
newLevels[k + 1] = levels[Object.keys(levels)[k]];
}
return newLevels;
};
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 expandLevels(newLevels, i - 1); // -------------------------------------------------------------------------------------------------
} // Level utilities (helpers)
// -------------------------------------------------------------------------------------------------
// ------------------------------------------------------------------------------------------------- findLevel(levels: Levels, performance: number): number {
// Standards interpolation (heavy math bits) let resultLevel = 1;
// ------------------------------------------------------------------------------------------------- let resultLevelDiff = Infinity;
function interpolateStandardsValues( for (const level in levels) {
lower: BaseLevelValues, const diff = Math.abs(performance - levels[level as unknown as number]);
upper: BaseLevelValues, if (diff < resultLevelDiff) {
ratio: number, resultLevel = +level;
): BaseLevelValues { resultLevelDiff = diff;
function lerp(k: BaseLevel): number { }
return (lower[k] as number) + ((upper[k] as number) - (lower[k] as number)) * ratio; }
return resultLevel;
} }
return { compressLevels(
physicallyActive: lerp("physicallyActive"), levels: Levels,
beginner: lerp("beginner"), targetLevelsAmount: number,
intermediate: lerp("intermediate"), ): Levels {
advanced: lerp("advanced"), const levelsAmount = Object.keys(levels).length;
elite: lerp("elite"), if (levelsAmount < targetLevelsAmount) {
}; throw new Error(
} "Target levels amount must be greater than or equal to the current levels amount",
);
}
function interpolateByBodyWeight( const ratio = levelsAmount / targetLevelsAmount;
targetAge: number, const compressedLevels: Levels = {};
bodyWeightKG: number,
standardsArr: Standard[],
): BaseLevelValues | null {
const ageFilteredStandards = standardsArr.filter((s) => s.metrics.age === targetAge);
if (ageFilteredStandards[0].metrics.weight === -1) return ageFilteredStandards[0].levels;
const weightArray = [...new Set(ageFilteredStandards.map((s) => s.metrics.weight))].sort((a, b) => a - b); for (let i = 0; i < targetLevelsAmount; ++i) {
const { lower, upper } = findNearestPoints(bodyWeightKG, weightArray); const ratioIndex = i * ratio;
const lowerIndex = Math.floor(ratioIndex);
const upperIndex = Math.ceil(ratioIndex);
const weightLower = ageFilteredStandards.find((s) => s.metrics.weight === lower); const lowerValue = levels[lowerIndex + 1];
const weightUpper = ageFilteredStandards.find((s) => s.metrics.weight === upper); const upperValue = levels[upperIndex + 1];
if (!weightLower || !weightUpper) return null;
let weightRatio = upper === lower ? 1 : (bodyWeightKG - lower) / (upper - lower); const weight = ratioIndex - lowerIndex;
weightRatio = Math.max(0, Math.min(1, weightRatio)); compressedLevels[i + 1] = lowerValue + (upperValue - lowerValue) * weight;
}
return interpolateStandardsValues(weightLower.levels, weightUpper.levels, weightRatio);
}
function interpolateActivityLevels(
activity: Activity,
player: Player,
): Record<number, number> {
// Filter dataset
const filteredStandards = standards[activity]
.filter(a => a.metrics.gender === player.gender);
if (filteredStandards.length === 0) return {};
// Age interpolation
const ageArray = [...new Set(filteredStandards.map((s) => s.metrics.age))].sort((a, b) => a - b);
const { lower: ageLower, upper: ageUpper } = findNearestPoints(player.age, ageArray);
const lowerValues = interpolateByBodyWeight(ageLower, player.weightKG, filteredStandards);
const upperValues = interpolateByBodyWeight(ageUpper, player.weightKG, filteredStandards);
if (!lowerValues || !upperValues) return {};
let ageRatio = ageUpper === ageLower ? 1 : (player.age - ageLower) / (ageUpper - ageLower);
ageRatio = Math.max(0, Math.min(1, ageRatio));
const levels = interpolateStandardsValues(lowerValues, upperValues, ageRatio);
if (levels) {
const expandedLevels = expandLevels(levels, 5);
const compressedLevels = compressLevels(expandedLevels, 100);
return compressedLevels; return compressedLevels;
} else {
return {};
}
}
// -------------------------------------------------------------------------------------------------
// Level calculations (public API)
// -------------------------------------------------------------------------------------------------
export function calcAttributeLevel(
attribute: Attribute,
player: Player,
activityPerformances: ActivityPerformance[],
): number {
// must be activities of the given attribute
if (activityPerformances.some((bp) => getActivityAttribute(bp.activity) !== attribute)) {
throw new Error("Wrong activity performance attribute");
} }
// must perform all of an attributes activities expandLevels(
for (const activity of getAttributeActivities(attribute)) { levels: Levels,
const filteredActivites = activityPerformances.map((bp) => bp.activity); i: number
if (!filteredActivites.includes(activity)) ): Levels {
return 0; 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);
} }
// must have all activties using a valid performance value (> 0) // -------------------------------------------------------------------------------------------------
for (const activityPerformance of activityPerformances) { // Standards interpolation (heavy math bits)
if (activityPerformance.performance <= 0) // -------------------------------------------------------------------------------------------------
return 0
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");
function lerp(lvl: number): number {
return (lower[lvl] as number) + ((upper[lvl] as number) - (lower[lvl] as number)) * ratio;
}
const interpolatedLevels = Object.keys(lower).map(lvl => lerp(+lvl));
return interpolatedLevels;
} }
const activityLevels = activityPerformances.map((abp) => interpolateByBodyWeight(
findLevel(interpolateActivityLevels(abp.activity, player), abp.performance), targetAge: number,
); bodyWeightKG: number,
standardsArr: Standard[],
): Levels | null {
const ageFilteredStandards = standardsArr.filter((s) => s.metrics.age === targetAge);
if (ageFilteredStandards[0].metrics.weight === -1) return ageFilteredStandards[0].levels;
const activityLevelsAvg = Math.round( const weightArray = [...new Set(ageFilteredStandards.map((s) => s.metrics.weight))].sort((a, b) => a - b);
activityLevels.reduce((sum, curr) => sum + curr, 0) / activityLevels.length, const { lower, upper } = findNearestPoints(bodyWeightKG, weightArray);
);
return activityLevelsAvg; const weightLower = ageFilteredStandards.find((s) => s.metrics.weight === lower);
} const weightUpper = ageFilteredStandards.find((s) => s.metrics.weight === upper);
if (!weightLower || !weightUpper) return null;
export function calcAllAttributeLevels( let weightRatio = upper === lower ? 1 : (bodyWeightKG - lower) / (upper - lower);
player: Player, weightRatio = Math.max(0, Math.min(1, weightRatio));
activityPerformances: ActivityPerformance[],
): Record<Attribute, number> {
const attrLevels: Record<Attribute, number> = {
[attributes.STRENGTH]: 0,
[attributes.POWER]: 0,
[attributes.ENDURANCE]: 0,
// [attributes.SPEED]: 0,
[attributes.AGILITY]: 0,
} as const;
for (const value of Object.values(player)) { return this.interpolateLevels(weightLower.levels, weightUpper.levels, weightRatio);
if (!value)
return attrLevels;
} }
(Object.values(attributes) as Attribute[]).forEach((attribute) => { getInterpolatedActivityStandard(
const attrActivityPerformances = activityPerformances.filter( activity: Activity,
(bp) => getActivityAttribute(bp.activity) === attribute, metrics: Metrics,
): Standard {
const interpolatedStandard: Standard = {
metrics: metrics,
levels: {}
};
// Filter dataset
const standardsByGender = standards[activity]
.filter(a => a.metrics.gender === metrics.gender);
if (standardsByGender.length === 0) return interpolatedStandard;
// Age interpolation
const ageArray = [...new Set(standardsByGender.map((s) => s.metrics.age))].sort((a, b) => a - b);
const { lower: ageLower, upper: ageUpper } = findNearestPoints(metrics.age, ageArray);
const lowerValues = this.interpolateByBodyWeight(ageLower, metrics.weight, standardsByGender);
const upperValues = this.interpolateByBodyWeight(ageUpper, metrics.weight, standardsByGender);
if (!lowerValues || !upperValues) return interpolatedStandard;
let ageRatio = ageUpper === ageLower ? 1 : (metrics.age - ageLower) / (ageUpper - ageLower);
ageRatio = Math.max(0, Math.min(1, ageRatio));
const levels = this.interpolateLevels(lowerValues, upperValues, ageRatio);
const expandedLevels = this.expandLevels(levels, this.cfg.expandIters);
const compressedLevels = this.compressLevels(expandedLevels, this.cfg.compressTo);
interpolatedStandard.levels = compressedLevels;
return interpolatedStandard;
}
// -------------------------------------------------------------------------------------------------
// Level calculations (public API)
// -------------------------------------------------------------------------------------------------
// public generateTableData(metrics: Metrics) {
// const tableData: Record<Activity, Object> = {};
//
// for (const activity of Object.values(activities)) {
// const activityLevels = this.interpolateActivityLevels(activity, metrics);
// }
// }
public calcAttributeLevel(
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.getInterpolatedActivityStandard(p.activity, player.metrics);
return this.findLevel(interpolatedStandard.levels, p.performance);
});
const activityLevelsAvg = Math.round(
activityLevels.reduce((sum, curr) => sum + curr, 0) / activityLevels.length,
); );
attrLevels[attribute] = calcAttributeLevel(attribute, player, attrActivityPerformances);
});
return attrLevels; return activityLevelsAvg;
}
export function calcPlayerLevel(player: Player, activityPerformances: ActivityPerformance[]): number {
for (const value of Object.values(player)) {
if (!value)
return 0;
} }
const attrLevels = calcAllAttributeLevels(player, activityPerformances); public calcAllAttributeLevels(
for (const level of Object.values(attrLevels)) { player: Player,
if (!level) activityPerformances: ActivityPerformance[],
return 0; ): Record<Attribute, number> {
const attrLevels: Record<Attribute, number> = {
[attributes.STRENGTH]: 0,
[attributes.POWER]: 0,
[attributes.ENDURANCE]: 0,
// [attributes.SPEED]: 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.calcAttributeLevel(attribute, player, attrActivityPerformances);
});
return attrLevels;
} }
const attrLevelsSum = Object.values(attrLevels).reduce((sum, lvl) => sum + lvl, 0); public calcPlayerLevel(player: Player, activityPerformances: ActivityPerformance[]): number {
return Math.round(attrLevelsSum / Object.keys(attributes).length); for (const value of Object.values(player)) {
} if (!value)
return 0;
}
const attrLevels = this.calcAllAttributeLevels(player, activityPerformances);
for (const level of Object.values(attrLevels)) {
if (!level)
return 0;
}
const attrLevelsSum = Object.values(attrLevels).reduce((sum, lvl) => sum + lvl, 0);
return Math.round(attrLevelsSum / Object.keys(attributes).length);
}
}
+2071 -2071
View File
File diff suppressed because it is too large Load Diff
+12 -6
View File
@@ -1,10 +1,12 @@
import { activities, ActivityPerformance, feetToCm, genders, inchesToCm, lbToKg, minToMs, Player, secToMs } from "./util"; import { activities, ActivityPerformance, feetToCm, genders, inchesToCm, lbToKg, minToMs, Player, secToMs } from "./util";
import { calcAllAttributeLevels, calcPlayerLevel } from "./calc.ts"; import { LevelCalculator } from "./calc.ts";
const player: Player = { const player: Player = {
age: 18, metrics: {
weightKG: lbToKg(200), age: 18,
gender: genders.MALE weight: lbToKg(200),
gender: genders.MALE
}
}; };
const computedPerformances: ActivityPerformance[] = [ const computedPerformances: ActivityPerformance[] = [
@@ -38,9 +40,13 @@ const computedPerformances: ActivityPerformance[] = [
}, },
]; ];
console.log(Bun.argv);
// const levelCalculator = new LevelCalculator({ compressTo: +Bun.argv[2], expandIters: +Bun.argv[3] });
const levelCalculator = new LevelCalculator();
const levels = { const levels = {
attributes: calcAllAttributeLevels(player, computedPerformances), attributes: levelCalculator.calcAllAttributeLevels(player, computedPerformances),
player: calcPlayerLevel(player, computedPerformances) player: levelCalculator.calcPlayerLevel(player, computedPerformances)
}; };
console.log(levels); console.log(levels);
+4 -11
View File
@@ -16,9 +16,7 @@ export type Attribute = typeof attributes[keyof typeof attributes];
// ------------------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------------------
export interface Player { export interface Player {
age: number, metrics: Metrics;
weightKG: number,
gender: Gender
} }
export const activities = Object.freeze({ export const activities = Object.freeze({
@@ -75,14 +73,9 @@ export interface ActivityPerformance {
performance: number; performance: number;
} }
export type BaseLevel = export type Levels = Record<number, number>
| "physicallyActive"
| "beginner"
| "intermediate"
| "advanced"
| "elite";
interface Metrics { export interface Metrics {
age: number; age: number;
weight: number; weight: number;
gender: Gender; gender: Gender;
@@ -90,7 +83,7 @@ interface Metrics {
export interface Standard { export interface Standard {
metrics: Metrics; metrics: Metrics;
levels: Record<BaseLevel, number>; levels: Levels;
} }
export type StandardsMap = Record< export type StandardsMap = Record<
+9 -6
View File
@@ -1,11 +1,11 @@
import { calcAllAttributeLevels, calcPlayerLevel } from "./calculator/calc.ts"; import { LevelCalculator } from "./calculator/calc.ts";
import { ActivityPerformance, Player } from "./calculator/util.ts"; import { ActivityPerformance, Player } from "./calculator/util.ts";
import { Printful } from "./commerce/printful.ts" import { Printful } from "./commerce/printful.ts"
import { Webflow } from "./commerce/webflow.ts"; import { Webflow } from "./commerce/webflow.ts";
interface CalcRequest { interface CalcRequest {
player: Player, player: Player,
performances: ActivityPerformance[] activityPerformances: ActivityPerformance[]
} }
export class ClientResponse extends Response { export class ClientResponse extends Response {
@@ -34,12 +34,14 @@ const server = Bun.serve({
}, },
async POST(req) { async POST(req) {
const calcReq: CalcRequest = await req.json(); const calcReq: CalcRequest = await req.json();
const { player, performances } = calcReq; const { player, activityPerformances } = calcReq;
const levelCalculator = new LevelCalculator();
return ClientResponse.json({ return ClientResponse.json({
levels: { levels: {
attributes: calcAllAttributeLevels(player, performances), attributes: levelCalculator.calcAllAttributeLevels(player, activityPerformances),
player: calcPlayerLevel(player, performances) player: levelCalculator.calcPlayerLevel(player, activityPerformances)
} }
}) })
} }
@@ -90,7 +92,8 @@ const server = Bun.serve({
} }
// DELETE // DELETE
case Printful.Webhook.Event.ProductDeleted: { case Printful.Webhook.Event.ProductDeleted: {
await Webflow.Products.remove(payload.data.sync_product.external_id); const webflowProductId = payload.data.sync_product.external_id;
await Webflow.Products.remove(webflowProductId);
break; break;
} }
} }