2026-03-09 15:34:31 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Golf / Qualifying Points Simulator
|
|
|
|
|
|
*
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
|
* Monte Carlo simulation of the 4 golf majors using a Plackett-Luce ranking model.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Algorithm:
|
|
|
|
|
|
* 1. Load participants and their actual QP from completed majors.
|
|
|
|
|
|
* 2. For each incomplete major, build a simulated field of FIELD_SIZE players:
|
|
|
|
|
|
* - Tracked participants: strength = exp(PL_BETA × SG_Total)
|
|
|
|
|
|
* - Synthetic rest-of-field: strength = 1.0 (SG = 0, field average)
|
|
|
|
|
|
* 3. Draw finishing positions using the Plackett-Luce model:
|
|
|
|
|
|
* P(player i placed next) ∝ strength_i / sum(remaining strengths)
|
|
|
|
|
|
* 4. Award QP for top placements per qualifyingPointConfig.
|
|
|
|
|
|
* 5. Rank all tracked players by total QP; tally 1st–8th placement counts.
|
|
|
|
|
|
* 6. Return normalized SimulationResult[].
|
|
|
|
|
|
*
|
|
|
|
|
|
* Strength calibration (PL_BETA = 1.5, FIELD_SIZE = 156):
|
|
|
|
|
|
* SG +3.0 → win prob ≈ 12% (elite major contender)
|
|
|
|
|
|
* SG +2.0 → win prob ≈ 5.8%
|
|
|
|
|
|
* SG 0.0 → win prob ≈ 0.6% (field average)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Per-major odds (optional):
|
|
|
|
|
|
* If American odds are stored for this major and a player has no SG: Total,
|
|
|
|
|
|
* the odds are converted to an SG-equivalent skill for that major.
|
|
|
|
|
|
* If SG: Total is available it always takes precedence.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Major name → odds column mapping (matched case-insensitively):
|
|
|
|
|
|
* "Masters" → mastersOdds
|
|
|
|
|
|
* "PGA Championship" → pgaChampionshipOdds
|
|
|
|
|
|
* "US Open" / "U.S. Open" → usOpenOdds
|
|
|
|
|
|
* "The Open" / "Open" → openChampionshipOdds
|
2026-03-09 15:34:31 -07:00
|
|
|
|
*/
|
|
|
|
|
|
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
|
import { eq, and, inArray } from "drizzle-orm";
|
|
|
|
|
|
import * as schema from "~/database/schema";
|
|
|
|
|
|
import { getGolfSkillsMap, type GolfSkillsRecord } from "~/models/golf-skills";
|
|
|
|
|
|
import { getQPConfig } from "~/models/qualifying-points";
|
2026-03-09 15:34:31 -07:00
|
|
|
|
import type { Simulator, SimulationResult } from "./types";
|
|
|
|
|
|
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const NUM_SIMULATIONS = 10_000;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Simulated field size for each major.
|
|
|
|
|
|
* Major fields typically have 156 players. Tracked participants fill their slots;
|
|
|
|
|
|
* the remainder are synthetic "rest of field" players at strength 1.0.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const FIELD_SIZE = 156;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Plackett-Luce exponential scaling factor.
|
|
|
|
|
|
* strength_i = exp(PL_BETA × sgTotal_i)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Calibration (typical 50-player tracked field + 106 rest-of-field at SG=0):
|
|
|
|
|
|
* SG +3.0 wins a single major ~12%, SG +2.0 ~6%, SG 0.0 ~0.6%
|
|
|
|
|
|
*
|
|
|
|
|
|
* Higher beta increases separation between skill levels, concentrating QP
|
|
|
|
|
|
* accumulation toward the best players across all 4 majors.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const PL_BETA = 1.5;
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** Convert American odds to implied probability. Returns null for invalid input. */
|
|
|
|
|
|
export function americanToImplied(odds: number): number | null {
|
|
|
|
|
|
if (odds === 0) return null;
|
|
|
|
|
|
const p = odds > 0 ? 100 / (odds + 100) : -odds / (-odds + 100);
|
|
|
|
|
|
return p > 0 && p <= 1 ? p : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Determine which per-major odds column to use for a scoring event, based on its name.
|
|
|
|
|
|
* Returns null if the name doesn't match any known major pattern.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function getMajorOddsKey(
|
|
|
|
|
|
eventName: string
|
|
|
|
|
|
): keyof Pick<
|
|
|
|
|
|
GolfSkillsRecord,
|
|
|
|
|
|
"mastersOdds" | "usOpenOdds" | "openChampionshipOdds" | "pgaChampionshipOdds"
|
|
|
|
|
|
> | null {
|
|
|
|
|
|
const n = eventName.toLowerCase();
|
|
|
|
|
|
if (n.includes("masters")) return "mastersOdds";
|
|
|
|
|
|
if (n.includes("pga championship") || (n.includes("pga") && !n.includes("tour"))) return "pgaChampionshipOdds";
|
|
|
|
|
|
if (n.includes("us open") || n.includes("u.s. open")) return "usOpenOdds";
|
|
|
|
|
|
if (n.includes("open")) return "openChampionshipOdds";
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Resolve a player's effective skill score (in SG: Total units) for a specific major.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Priority:
|
|
|
|
|
|
* 1. sgTotal (if set — applies to all majors uniformly)
|
|
|
|
|
|
* 2. Per-major odds converted to an SG-equivalent
|
|
|
|
|
|
* 3. 0.0 (field average fallback)
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function resolveSkill(
|
|
|
|
|
|
skills: GolfSkillsRecord | undefined,
|
|
|
|
|
|
oddsKey: keyof Pick<GolfSkillsRecord, "mastersOdds" | "usOpenOdds" | "openChampionshipOdds" | "pgaChampionshipOdds"> | null
|
|
|
|
|
|
): number {
|
|
|
|
|
|
if (skills?.sgTotal !== null && skills?.sgTotal !== undefined) {
|
|
|
|
|
|
return skills.sgTotal;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (oddsKey && skills) {
|
|
|
|
|
|
const rawOdds = skills[oddsKey];
|
|
|
|
|
|
if (rawOdds !== null && rawOdds !== undefined) {
|
|
|
|
|
|
const implied = americanToImplied(rawOdds);
|
|
|
|
|
|
if (implied !== null) {
|
|
|
|
|
|
// Convert implied win probability to SG-equivalent:
|
|
|
|
|
|
// strength = exp(PL_BETA × sg) ≈ implied × FIELD_SIZE
|
|
|
|
|
|
// sg = ln(implied × FIELD_SIZE) / PL_BETA
|
|
|
|
|
|
const strength = Math.max(implied * FIELD_SIZE, 0.01);
|
|
|
|
|
|
return Math.log(strength) / PL_BETA;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface FieldPlayer { id: string | null; strength: number }
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Simulate one major using the Plackett-Luce model.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Draws finishing positions for tracked players and the synthetic rest-of-field,
|
|
|
|
|
|
* awarding QP to tracked players who land in scoring positions (top N per qpConfig).
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns Map from participantId → QP awarded (0 if outside scoring positions)
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function simulateMajor(
|
|
|
|
|
|
trackedPlayers: { id: string; strength: number }[],
|
|
|
|
|
|
restCount: number,
|
|
|
|
|
|
restStrength: number,
|
|
|
|
|
|
qpConfig: Map<number, number>
|
|
|
|
|
|
): Map<string, number> {
|
|
|
|
|
|
const maxScoringPosition = Math.max(...qpConfig.keys());
|
|
|
|
|
|
const fieldSize = trackedPlayers.length + restCount;
|
|
|
|
|
|
const positionsToSimulate = Math.min(maxScoringPosition, fieldSize);
|
|
|
|
|
|
|
|
|
|
|
|
// Pool of remaining players (tracked with real ids, rest-of-field with null ids)
|
|
|
|
|
|
const remaining: FieldPlayer[] = [
|
|
|
|
|
|
...trackedPlayers.map((p) => ({ id: p.id, strength: p.strength })),
|
|
|
|
|
|
...Array.from<unknown, FieldPlayer>({ length: restCount }, () => ({ id: null, strength: restStrength })),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
let totalStrength = remaining.reduce((sum, p) => sum + p.strength, 0);
|
|
|
|
|
|
const result = new Map<string, number>();
|
|
|
|
|
|
|
|
|
|
|
|
for (let placement = 1; placement <= positionsToSimulate; placement++) {
|
|
|
|
|
|
// Sample a winner proportional to strength
|
|
|
|
|
|
let r = Math.random() * totalStrength;
|
|
|
|
|
|
let winnerIdx = remaining.length - 1; // fallback to last in case of floating-point drift
|
|
|
|
|
|
for (let i = 0; i < remaining.length; i++) {
|
|
|
|
|
|
r -= remaining[i].strength;
|
|
|
|
|
|
if (r <= 0) { winnerIdx = i; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const winner = remaining[winnerIdx];
|
|
|
|
|
|
if (winner.id !== null) {
|
|
|
|
|
|
result.set(winner.id, qpConfig.get(placement) ?? 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
totalStrength -= winner.strength;
|
|
|
|
|
|
// Swap winner to end and pop — O(1) removal vs O(N) splice
|
|
|
|
|
|
remaining[winnerIdx] = remaining[remaining.length - 1];
|
|
|
|
|
|
remaining.pop();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Tracked players not drawn in scoring positions get 0 QP
|
|
|
|
|
|
for (const p of trackedPlayers) {
|
|
|
|
|
|
if (!result.has(p.id)) result.set(p.id, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
|
export class GolfSimulator implements Simulator {
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
|
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
|
|
|
|
|
|
// Load participants, skills, QP config, and scoring events in parallel.
|
|
|
|
|
|
const [allParticipants, skillsMap, qpConfigRows, events] = await Promise.all([
|
|
|
|
|
|
db
|
|
|
|
|
|
.select({ id: schema.participants.id })
|
|
|
|
|
|
.from(schema.participants)
|
|
|
|
|
|
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
|
|
|
|
|
|
getGolfSkillsMap(sportsSeasonId),
|
|
|
|
|
|
getQPConfig(sportsSeasonId),
|
|
|
|
|
|
db.query.scoringEvents.findMany({
|
|
|
|
|
|
where: and(
|
|
|
|
|
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
|
|
|
|
eq(schema.scoringEvents.eventType, "major_tournament")
|
|
|
|
|
|
),
|
|
|
|
|
|
orderBy: (e, { asc }) => [asc(e.eventDate)],
|
|
|
|
|
|
}),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
if (allParticipants.length === 0) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`No participants found for sports season ${sportsSeasonId}. ` +
|
|
|
|
|
|
`Add participants before running the simulation.`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const participantIds = allParticipants.map((p) => p.id);
|
|
|
|
|
|
|
|
|
|
|
|
const qpConfig = new Map<number, number>(
|
|
|
|
|
|
qpConfigRows.map((row) => [row.placement, Number(row.points)])
|
2026-03-09 15:34:31 -07:00
|
|
|
|
);
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
|
|
|
|
|
|
|
if (events.length === 0) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`No major_tournament scoring events found for sports season ${sportsSeasonId}. ` +
|
|
|
|
|
|
`Create the 4 major scoring events first (e.g. "Masters", "US Open", "The Open", "PGA Championship").`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For completed majors, read actual QP from eventResults.
|
|
|
|
|
|
const completedEventIds = events.filter((e) => e.isComplete).map((e) => e.id);
|
|
|
|
|
|
const actualQPMap = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
|
|
|
|
|
|
|
|
|
|
|
if (completedEventIds.length > 0) {
|
|
|
|
|
|
const actualResults = await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
participantId: schema.eventResults.participantId,
|
|
|
|
|
|
qualifyingPointsAwarded: schema.eventResults.qualifyingPointsAwarded,
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.eventResults)
|
|
|
|
|
|
.where(inArray(schema.eventResults.scoringEventId, completedEventIds));
|
|
|
|
|
|
|
|
|
|
|
|
for (const r of actualResults) {
|
|
|
|
|
|
if (r.qualifyingPointsAwarded !== null) {
|
|
|
|
|
|
const prev = actualQPMap.get(r.participantId) ?? 0;
|
|
|
|
|
|
actualQPMap.set(r.participantId, prev + parseFloat(r.qualifyingPointsAwarded));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const incompleteMajors = events.filter((e) => !e.isComplete);
|
|
|
|
|
|
|
|
|
|
|
|
// Pre-compute per-player strengths per incomplete major (outside the Monte Carlo loop).
|
|
|
|
|
|
// strength = exp(PL_BETA × effectiveSkill); minimum clamped to 0.01 to avoid division issues.
|
|
|
|
|
|
const majorConfigs = incompleteMajors.map((event) => {
|
|
|
|
|
|
const oddsKey = getMajorOddsKey(event.name);
|
|
|
|
|
|
const players = participantIds.map((id) => ({
|
|
|
|
|
|
id,
|
|
|
|
|
|
strength: Math.max(Math.exp(PL_BETA * resolveSkill(skillsMap.get(id), oddsKey)), 0.01),
|
|
|
|
|
|
}));
|
|
|
|
|
|
const restCount = Math.max(0, FIELD_SIZE - players.length);
|
|
|
|
|
|
const restStrength = 1.0; // exp(PL_BETA * 0) = 1, representing SG = 0
|
|
|
|
|
|
return { players, restCount, restStrength };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Monte Carlo loop.
|
|
|
|
|
|
const counts: number[][] = Array.from({ length: participantIds.length }, () =>
|
|
|
|
|
|
Array<number>(8).fill(0)
|
|
|
|
|
|
);
|
|
|
|
|
|
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
|
|
|
|
|
|
|
|
|
|
|
|
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
|
|
|
|
|
|
const simQP = new Map<string, number>(actualQPMap);
|
|
|
|
|
|
|
|
|
|
|
|
for (const { players, restCount, restStrength } of majorConfigs) {
|
|
|
|
|
|
const majorResult = simulateMajor(players, restCount, restStrength, qpConfig);
|
|
|
|
|
|
for (const [pid, qp] of majorResult) {
|
|
|
|
|
|
simQP.set(pid, (simQP.get(pid) ?? 0) + qp);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Rank all tracked participants by total QP descending.
|
|
|
|
|
|
const ranked = [...simQP.entries()].toSorted((a, b) => b[1] - a[1]);
|
|
|
|
|
|
|
|
|
|
|
|
for (let rank = 0; rank < Math.min(8, ranked.length); rank++) {
|
|
|
|
|
|
const idx = idToIndex.get(ranked[rank][0]);
|
|
|
|
|
|
if (idx !== undefined) counts[idx][rank]++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Normalize counts to probabilities.
|
|
|
|
|
|
return participantIds.map((participantId, i) => ({
|
|
|
|
|
|
participantId,
|
|
|
|
|
|
probabilities: {
|
|
|
|
|
|
probFirst: counts[i][0] / NUM_SIMULATIONS,
|
|
|
|
|
|
probSecond: counts[i][1] / NUM_SIMULATIONS,
|
|
|
|
|
|
probThird: counts[i][2] / NUM_SIMULATIONS,
|
|
|
|
|
|
probFourth: counts[i][3] / NUM_SIMULATIONS,
|
|
|
|
|
|
probFifth: counts[i][4] / NUM_SIMULATIONS,
|
|
|
|
|
|
probSixth: counts[i][5] / NUM_SIMULATIONS,
|
|
|
|
|
|
probSeventh: counts[i][6] / NUM_SIMULATIONS,
|
|
|
|
|
|
probEighth: counts[i][7] / NUM_SIMULATIONS,
|
|
|
|
|
|
},
|
|
|
|
|
|
source: "golf_qualifying_points_monte_carlo",
|
|
|
|
|
|
}));
|
2026-03-09 15:34:31 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|