233 lines
7.0 KiB
JavaScript
233 lines
7.0 KiB
JavaScript
/**
|
|
* roleProfileEngine.js
|
|
* Role profiling and classification engine for player analysis.
|
|
* Estimates what basketball role(s) a player fills and detects shifts.
|
|
*/
|
|
|
|
const ROLE_TAXONOMY = [
|
|
'PRIMARY_BALL_HANDLER',
|
|
'SECONDARY_PLAYMAKER',
|
|
'CATCH_SHOOT_SPACER',
|
|
'OFF_BALL_CUTTER',
|
|
'FLOOR_RAISER',
|
|
'SWITCHABLE_DEFENDER',
|
|
'PAINT_PRESENCE',
|
|
'CONNECTOR',
|
|
];
|
|
|
|
const CONDITIONAL_KEYS = [
|
|
'star_out',
|
|
'losing_10_plus',
|
|
'foul_trouble',
|
|
'closing_lineup',
|
|
'winning_15_plus',
|
|
];
|
|
|
|
/**
|
|
* Shannon entropy normalized to 0-1 range.
|
|
* 0 = single role, 1 = equally distributed across all active roles.
|
|
*
|
|
* H = -sum(p_i * log2(p_i)) for all p_i > 0
|
|
* Normalized: H / log2(n) where n = number of non-zero roles
|
|
*
|
|
* @param {Object} roleProfile — keys are role names, values are weights (should sum to ~1)
|
|
* @returns {number} role_variance_score in [0, 1]
|
|
*/
|
|
function calculateRoleVariance(roleProfile) {
|
|
const weights = Object.values(roleProfile).filter((w) => w > 0);
|
|
const n = weights.length;
|
|
|
|
if (n <= 1) return 0;
|
|
|
|
const total = weights.reduce((sum, w) => sum + w, 0);
|
|
if (total === 0) return 0;
|
|
|
|
// Normalize to probabilities
|
|
const probs = weights.map((w) => w / total);
|
|
|
|
// Shannon entropy
|
|
const H = -probs.reduce((sum, p) => {
|
|
return sum + (p > 0 ? p * Math.log2(p) : 0);
|
|
}, 0);
|
|
|
|
// Normalize by max possible entropy for n categories
|
|
const maxH = Math.log2(n);
|
|
if (maxH === 0) return 0;
|
|
|
|
return Math.min(1, Math.max(0, H / maxH));
|
|
}
|
|
|
|
/**
|
|
* Returns the dominant (highest-weight) role from a profile.
|
|
* @param {Object} roleProfile
|
|
* @returns {string|null} role key with highest weight, or null if empty
|
|
*/
|
|
function getDominantRole(roleProfile) {
|
|
if (!roleProfile || Object.keys(roleProfile).length === 0) return null;
|
|
|
|
let maxKey = null;
|
|
let maxVal = -Infinity;
|
|
|
|
for (const [key, val] of Object.entries(roleProfile)) {
|
|
if (val > maxVal) {
|
|
maxVal = val;
|
|
maxKey = key;
|
|
}
|
|
}
|
|
|
|
return maxKey;
|
|
}
|
|
|
|
/**
|
|
* Detect whether tonight's role profile represents a meaningful elevation
|
|
* from the player's baseline.
|
|
*
|
|
* @param {Object} baseProfile — season/rolling baseline role distribution
|
|
* @param {Object} tonightProfile — tonight's role distribution
|
|
* @param {number} threshold — delta above which we flag elevation (default 0.20)
|
|
* @returns {{ elevated: boolean, elevatedRole: string|null, delta: number }}
|
|
*/
|
|
function detectRoleElevation(baseProfile, tonightProfile, threshold = 0.20) {
|
|
const baseDominant = getDominantRole(baseProfile);
|
|
const tonightDominant = getDominantRole(tonightProfile);
|
|
|
|
if (!baseDominant || !tonightDominant) {
|
|
return { elevated: false, elevatedRole: null, delta: 0 };
|
|
}
|
|
|
|
// Find the role with the largest positive shift from base to tonight
|
|
let maxDelta = 0;
|
|
let elevatedRole = null;
|
|
|
|
for (const role of Object.keys(tonightProfile)) {
|
|
const baseWeight = baseProfile[role] || 0;
|
|
const tonightWeight = tonightProfile[role] || 0;
|
|
const delta = tonightWeight - baseWeight;
|
|
|
|
if (delta > maxDelta) {
|
|
maxDelta = delta;
|
|
elevatedRole = role;
|
|
}
|
|
}
|
|
|
|
const elevated = maxDelta > threshold;
|
|
|
|
return {
|
|
elevated,
|
|
elevatedRole: elevated ? elevatedRole : null,
|
|
delta: Math.round(maxDelta * 1000) / 1000,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Look up the conditional role profile for a given game condition.
|
|
*
|
|
* @param {Object} conditionalRoles — map of condition -> roleProfile
|
|
* @param {string} condition — one of CONDITIONAL_KEYS
|
|
* @returns {Object|null} the conditional role profile, or null if not found
|
|
*/
|
|
function getConditionalProfile(conditionalRoles, condition) {
|
|
if (!conditionalRoles || !condition) return null;
|
|
if (!CONDITIONAL_KEYS.includes(condition)) return null;
|
|
return conditionalRoles[condition] || null;
|
|
}
|
|
|
|
/**
|
|
* Estimate a role profile distribution from raw game log stats.
|
|
*
|
|
* Heuristic mapping:
|
|
* - HIGH usage_rate + HIGH assist_rate => PRIMARY_BALL_HANDLER
|
|
* - MED usage_rate + HIGH assist_rate => SECONDARY_PLAYMAKER
|
|
* - LOW usage_rate + HIGH 3pt_share => CATCH_SHOOT_SPACER
|
|
* - HIGH off_ball_movement + cuts => OFF_BALL_CUTTER
|
|
* - HIGH usage_rate + LOW assist_rate => FLOOR_RAISER
|
|
* - Defensive metrics => SWITCHABLE_DEFENDER
|
|
* - HIGH paint touches + rebounds => PAINT_PRESENCE
|
|
* - MED everything => CONNECTOR
|
|
*
|
|
* @param {Array<Object>} gameLogStats — array of game stat objects
|
|
* @returns {Object} role profile with weights summing to ~1.0
|
|
*/
|
|
function calculateRoleProfile(gameLogStats) {
|
|
if (!gameLogStats || gameLogStats.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
// Average the stats across games
|
|
const avg = {};
|
|
const statKeys = [
|
|
'usage_rate',
|
|
'assist_rate',
|
|
'three_point_share',
|
|
'off_ball_movement',
|
|
'paint_touches',
|
|
'rebounds_per_game',
|
|
'defensive_versatility',
|
|
'screen_assists',
|
|
];
|
|
|
|
for (const key of statKeys) {
|
|
const vals = gameLogStats
|
|
.map((g) => g[key])
|
|
.filter((v) => v !== undefined && v !== null);
|
|
avg[key] = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
|
}
|
|
|
|
// Raw role signals (0-1 scale heuristics)
|
|
const raw = {};
|
|
|
|
// PRIMARY_BALL_HANDLER: high usage + high assists
|
|
raw.PRIMARY_BALL_HANDLER = Math.min(1, (avg.usage_rate / 35) * 0.6 + (avg.assist_rate / 40) * 0.4);
|
|
|
|
// SECONDARY_PLAYMAKER: moderate usage + high assists
|
|
const secondaryUsage = avg.usage_rate >= 15 && avg.usage_rate <= 25 ? 1 : 0.3;
|
|
raw.SECONDARY_PLAYMAKER = Math.min(1, secondaryUsage * 0.4 + (avg.assist_rate / 30) * 0.6);
|
|
|
|
// CATCH_SHOOT_SPACER: low usage + high 3pt share
|
|
const lowUsageBonus = avg.usage_rate < 20 ? 0.7 : 0.2;
|
|
raw.CATCH_SHOOT_SPACER = Math.min(1, lowUsageBonus * 0.4 + (avg.three_point_share / 80) * 0.6);
|
|
|
|
// OFF_BALL_CUTTER: off-ball movement driven
|
|
raw.OFF_BALL_CUTTER = Math.min(1, (avg.off_ball_movement / 100) * 0.8 + (1 - avg.usage_rate / 40) * 0.2);
|
|
|
|
// FLOOR_RAISER: high usage + low assists (score-first)
|
|
const lowAssistBonus = avg.assist_rate < 15 ? 0.7 : 0.2;
|
|
raw.FLOOR_RAISER = Math.min(1, (avg.usage_rate / 35) * 0.6 + lowAssistBonus * 0.4);
|
|
|
|
// SWITCHABLE_DEFENDER: defensive versatility
|
|
raw.SWITCHABLE_DEFENDER = Math.min(1, (avg.defensive_versatility / 100));
|
|
|
|
// PAINT_PRESENCE: paint touches + rebounds
|
|
raw.PAINT_PRESENCE = Math.min(1, (avg.paint_touches / 15) * 0.5 + (avg.rebounds_per_game / 12) * 0.5);
|
|
|
|
// CONNECTOR: screen assists + moderate everything
|
|
raw.CONNECTOR = Math.min(1, (avg.screen_assists / 8) * 0.5 + 0.5 * (1 - calculateRoleVariance(raw)));
|
|
|
|
// Normalize to sum to 1
|
|
const total = Object.values(raw).reduce((s, v) => s + v, 0);
|
|
const profile = {};
|
|
|
|
if (total === 0) {
|
|
// Fallback: equal distribution
|
|
for (const role of ROLE_TAXONOMY) {
|
|
profile[role] = 1 / ROLE_TAXONOMY.length;
|
|
}
|
|
} else {
|
|
for (const role of ROLE_TAXONOMY) {
|
|
profile[role] = Math.round(((raw[role] || 0) / total) * 1000) / 1000;
|
|
}
|
|
}
|
|
|
|
return profile;
|
|
}
|
|
|
|
module.exports = {
|
|
ROLE_TAXONOMY,
|
|
CONDITIONAL_KEYS,
|
|
calculateRoleVariance,
|
|
getDominantRole,
|
|
detectRoleElevation,
|
|
getConditionalProfile,
|
|
calculateRoleProfile,
|
|
};
|