272 lines
8.3 KiB
JavaScript
272 lines
8.3 KiB
JavaScript
const axios = require('axios');
|
|
const { getRedisClient } = require('../utils/redis');
|
|
|
|
const SCHEME_TYPES = ['DROP', 'SWITCH', 'HEDGE', 'MIXED', 'UNKNOWN'];
|
|
const MIN_POSSESSIONS = 8;
|
|
const CACHE_TTL = 21600; // 6 hours in seconds
|
|
const NBA_STATS_BASE = process.env.NBA_STATS_URL || 'http://localhost:8000';
|
|
const PYTHON_SERVICE_BASE = process.env.PYTHON_SERVICE_URL || 'http://localhost:5001';
|
|
|
|
/**
|
|
* Get cache key for scheme classification.
|
|
* Keyed per opponent per game day.
|
|
*/
|
|
function getCacheKey(opponentTeam, gameDate) {
|
|
return `scheme:${opponentTeam}:${gameDate}`;
|
|
}
|
|
|
|
/**
|
|
* Fetch play-by-play data for a team's last 5 games.
|
|
* Returns raw possession data for PnR analysis.
|
|
*/
|
|
async function fetchPlayByPlay(teamAbbr) {
|
|
const url = `${NBA_STATS_BASE}/team/playbyplay`;
|
|
const response = await axios.get(url, {
|
|
params: { team: teamAbbr, last_n_games: 5 },
|
|
timeout: 15000,
|
|
});
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* Extract pick-and-roll defensive possessions from play-by-play data.
|
|
* Looks for PnR ball handler and roll man actions in the play descriptions.
|
|
*/
|
|
function extractPnRPossessions(plays) {
|
|
if (!Array.isArray(plays)) return [];
|
|
|
|
const pnrIndicators = [
|
|
/pick.?and.?roll/i,
|
|
/screen.*roll/i,
|
|
/ball.?screen/i,
|
|
/pnr/i,
|
|
/hedge/i,
|
|
/drop.*coverage/i,
|
|
/switch.*screen/i,
|
|
/ice.*screen/i,
|
|
/blitz.*screen/i,
|
|
/trap.*screen/i,
|
|
];
|
|
|
|
return plays.filter((play) => {
|
|
const desc = play.description || play.play_description || '';
|
|
return pnrIndicators.some((pattern) => pattern.test(desc));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Classify the defensive coverage scheme from PnR possessions.
|
|
* Requires minimum 8 possessions to produce a classification.
|
|
*/
|
|
function classifyScheme(pnrPossessions) {
|
|
if (!pnrPossessions || pnrPossessions.length < MIN_POSSESSIONS) {
|
|
return { scheme: 'UNKNOWN', confidence: 0, possessions_analyzed: pnrPossessions ? pnrPossessions.length : 0, reason: 'insufficient_data' };
|
|
}
|
|
|
|
const counts = { DROP: 0, SWITCH: 0, HEDGE: 0 };
|
|
|
|
for (const poss of pnrPossessions) {
|
|
const desc = (poss.description || poss.play_description || '').toLowerCase();
|
|
|
|
if (/drop|sag|contain|soft/i.test(desc)) {
|
|
counts.DROP++;
|
|
} else if (/switch|swap/i.test(desc)) {
|
|
counts.SWITCH++;
|
|
} else if (/hedge|blitz|trap|hard.*show|ice/i.test(desc)) {
|
|
counts.HEDGE++;
|
|
}
|
|
}
|
|
|
|
const total = counts.DROP + counts.SWITCH + counts.HEDGE;
|
|
if (total === 0) {
|
|
return { scheme: 'UNKNOWN', confidence: 0, possessions_analyzed: pnrPossessions.length, reason: 'no_classifiable_actions' };
|
|
}
|
|
|
|
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
|
const topScheme = dominant[0][0];
|
|
const topCount = dominant[0][1];
|
|
const dominance = topCount / total;
|
|
|
|
// MIXED if no scheme exceeds 55% dominance
|
|
if (dominance < 0.55) {
|
|
return {
|
|
scheme: 'MIXED',
|
|
confidence: Math.round(dominance * 100),
|
|
possessions_analyzed: pnrPossessions.length,
|
|
breakdown: counts,
|
|
};
|
|
}
|
|
|
|
return {
|
|
scheme: topScheme,
|
|
confidence: Math.round(dominance * 100),
|
|
possessions_analyzed: pnrPossessions.length,
|
|
breakdown: counts,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch defensive scheme from Python Synergy service.
|
|
* Returns full defensive play type distribution when available.
|
|
* Falls back to null if Synergy service is unavailable.
|
|
*/
|
|
async function fetchSynergyScheme(teamId) {
|
|
try {
|
|
const url = `${PYTHON_SERVICE_BASE}/api/synergy/defensive-scheme/${teamId}`;
|
|
const response = await axios.get(url, { timeout: 10000 });
|
|
const data = response.data;
|
|
if (data && data.defensive_distribution) {
|
|
return {
|
|
scheme: classifyFromDistribution(data.defensive_distribution),
|
|
distribution: data.defensive_distribution,
|
|
source: 'synergy',
|
|
};
|
|
}
|
|
return null;
|
|
} catch (e) {
|
|
// Synergy unavailable — fallback to regex
|
|
console.warn('[VYNDR] Synergy scheme fetch unavailable:', e.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Classify scheme from Synergy defensive play type distribution.
|
|
* Maps play type frequencies to scheme classification.
|
|
*/
|
|
function classifyFromDistribution(distribution) {
|
|
if (!distribution || Object.keys(distribution).length === 0) return 'UNKNOWN';
|
|
|
|
const pnrHandler = distribution['PRBallHandler'] || {};
|
|
const pnrRollman = distribution['PRRollman'] || {};
|
|
const isolation = distribution['Isolation'] || {};
|
|
|
|
const pnrFreq = (pnrHandler.frequency_pct || 0) + (pnrRollman.frequency_pct || 0);
|
|
if (pnrFreq < 0.05) return 'UNKNOWN'; // too little PnR data
|
|
|
|
// High PPP allowed on PnR = likely DROP (giving up mid-range)
|
|
// Low PPP on PnR = likely SWITCH or HEDGE (disrupting)
|
|
const pnrPPP = pnrHandler.ppp || 0;
|
|
const pnrTO = pnrHandler.to_pct || 0;
|
|
|
|
if (pnrPPP > 0.95 && pnrTO < 0.10) return 'DROP';
|
|
if (pnrPPP < 0.80 && pnrTO > 0.15) return 'HEDGE';
|
|
if (isolation.frequency_pct > 0.15) return 'SWITCH'; // switch-heavy teams force isolations
|
|
|
|
return 'MIXED';
|
|
}
|
|
|
|
/**
|
|
* Get scheme classification for an opponent.
|
|
* Tries Synergy first, falls back to play-by-play regex.
|
|
* Checks cache first. Graceful degradation: returns UNKNOWN if all sources unavailable.
|
|
*/
|
|
async function getSchemeClassification(opponentTeam, gameDate) {
|
|
const redis = getRedisClient();
|
|
const cacheKey = getCacheKey(opponentTeam, gameDate || new Date().toISOString().split('T')[0]);
|
|
|
|
// Check cache
|
|
try {
|
|
const cached = await redis.get(cacheKey);
|
|
if (cached) {
|
|
return { ...JSON.parse(cached), source: 'cache' };
|
|
}
|
|
} catch (e) {
|
|
// Redis failure is non-fatal
|
|
console.warn('[VYNDR] Scheme cache read error:', e.message);
|
|
}
|
|
|
|
// Try Synergy service first (enhanced path)
|
|
try {
|
|
const synergyResult = await fetchSynergyScheme(opponentTeam);
|
|
if (synergyResult) {
|
|
const result = {
|
|
opponent: opponentTeam,
|
|
game_date: gameDate || new Date().toISOString().split('T')[0],
|
|
...synergyResult,
|
|
classified_at: new Date().toISOString(),
|
|
};
|
|
try { await redis.set(cacheKey, JSON.stringify(result), 'EX', CACHE_TTL); } catch (e) { /* non-fatal */ }
|
|
return { ...result, source: 'synergy' };
|
|
}
|
|
} catch (e) {
|
|
console.warn('[VYNDR] Synergy fallthrough to regex:', e.message);
|
|
}
|
|
|
|
// Fallback to play-by-play regex classification
|
|
try {
|
|
const pbpData = await fetchPlayByPlay(opponentTeam);
|
|
const plays = pbpData.plays || pbpData.play_by_play || [];
|
|
const pnrPossessions = extractPnRPossessions(plays);
|
|
const classification = classifyScheme(pnrPossessions);
|
|
|
|
const result = {
|
|
opponent: opponentTeam,
|
|
game_date: gameDate || new Date().toISOString().split('T')[0],
|
|
...classification,
|
|
classified_at: new Date().toISOString(),
|
|
};
|
|
|
|
// Cache the result
|
|
try {
|
|
await redis.set(cacheKey, JSON.stringify(result), 'EX', CACHE_TTL);
|
|
} catch (e) {
|
|
console.warn('[VYNDR] Scheme cache write error:', e.message);
|
|
}
|
|
|
|
return { ...result, source: 'live' };
|
|
} catch (e) {
|
|
// Graceful degradation — grade still produces even if this service is down
|
|
console.warn('[VYNDR] Scheme classifier unavailable:', e.message);
|
|
return {
|
|
opponent: opponentTeam,
|
|
game_date: gameDate || new Date().toISOString().split('T')[0],
|
|
scheme: 'UNKNOWN',
|
|
confidence: 0,
|
|
possessions_analyzed: 0,
|
|
reason: 'api_unavailable',
|
|
source: 'fallback',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log scheme classification to model_predictions_extended table.
|
|
* Phase 1: Data collection only. Not user-visible.
|
|
* User-visible activation happens Day 31.
|
|
*/
|
|
async function logSchemeToExtended(predictionId, schemeResult, supabaseClient) {
|
|
if (!supabaseClient || !predictionId) return;
|
|
|
|
try {
|
|
const { error } = await supabaseClient
|
|
.from('model_predictions_extended')
|
|
.update({
|
|
active_role_tonight: `scheme:${schemeResult.scheme}`,
|
|
model_version: '1.0-scheme',
|
|
})
|
|
.eq('prediction_id', predictionId);
|
|
|
|
if (error) {
|
|
console.warn('[VYNDR] Scheme log write error:', error.message);
|
|
}
|
|
} catch (e) {
|
|
// Non-fatal — silent logging only
|
|
console.warn('[VYNDR] Scheme log error:', e.message);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
SCHEME_TYPES,
|
|
MIN_POSSESSIONS,
|
|
CACHE_TTL,
|
|
getCacheKey,
|
|
fetchPlayByPlay,
|
|
fetchSynergyScheme,
|
|
classifyFromDistribution,
|
|
extractPnRPossessions,
|
|
classifyScheme,
|
|
getSchemeClassification,
|
|
logSchemeToExtended,
|
|
};
|