diff --git a/src/lib/engine/helpers/accuracy.ts b/src/lib/engine/helpers/accuracy.ts index 240c5ac..6554281 100644 --- a/src/lib/engine/helpers/accuracy.ts +++ b/src/lib/engine/helpers/accuracy.ts @@ -78,8 +78,12 @@ const getAccuracyWeights = (movesWinPercentage: number[]): number[] => { const getMovesAccuracy = (movesWinPercentage: number[]): number[] => movesWinPercentage.slice(1).map((winPercent, index) => { const lastWinPercent = movesWinPercentage[index]; - const winDiff = Math.abs(lastWinPercent - winPercent); + const isWhiteMove = index % 2 === 0; + const winDiff = isWhiteMove + ? Math.max(0, lastWinPercent - winPercent) + : Math.max(0, winPercent - lastWinPercent); + // Source: https://github.com/lichess-org/lila/blob/a320a93b68dabee862b8093b1b2acdfe132b9966/modules/analyse/src/main/AccuracyPercent.scala#L44 const rawAccuracy = 103.1668100711649 * Math.exp(-0.04354415386753951 * winDiff) - 3.166924740191411; diff --git a/src/lib/engine/helpers/estimateElo.ts b/src/lib/engine/helpers/estimateElo.ts index 3d3d6cb..4c7e174 100644 --- a/src/lib/engine/helpers/estimateElo.ts +++ b/src/lib/engine/helpers/estimateElo.ts @@ -1,54 +1,91 @@ import { ceilsNumber } from "@/lib/math"; import { EstimatedElo, PositionEval } from "@/types/eval"; -export const estimateEloFromEngineOutput = ( +export const computeEstimatedElo = ( + positions: PositionEval[], + whiteElo?: number, + blackElo?: number +): EstimatedElo | undefined => { + if (positions.length < 2) { + return undefined; + } + + const { whiteCpl, blackCpl } = getPlayersAverageCpl(positions); + + const whiteEstimatedElo = getEloFromRatingAndCpl( + whiteCpl, + whiteElo ?? blackElo + ); + const blackEstimatedElo = getEloFromRatingAndCpl( + blackCpl, + blackElo ?? whiteElo + ); + + return { white: whiteEstimatedElo, black: blackEstimatedElo }; +}; + +const getPositionCp = (position: PositionEval): number => { + const line = position.lines[0]; + + if (line.cp !== undefined) { + return ceilsNumber(line.cp, -1000, 1000); + } + + if (line.mate !== undefined) { + return ceilsNumber(line.mate * Infinity, -1000, 1000); + } + + throw new Error("No cp or mate in line"); +}; + +const getPlayersAverageCpl = ( positions: PositionEval[] -): EstimatedElo => { - try { - if (!positions || positions.length === 0) { - return { white: null, black: null }; - } +): { whiteCpl: number; blackCpl: number } => { + let previousCp = getPositionCp(positions[0]); - let totalCPLWhite = 0; - let totalCPLBlack = 0; - let moveCount = 0; - let previousCp = null; - let flag = true; - for (const moveAnalysis of positions) { - if (moveAnalysis.lines && moveAnalysis.lines.length > 0) { - const bestLine = moveAnalysis.lines[0]; - if (bestLine.cp !== undefined) { - if (previousCp !== null) { - const diff = Math.abs(bestLine.cp - previousCp); - if (flag) { - totalCPLWhite += ceilsNumber(diff, -1000, 1000); - } else { - totalCPLBlack += ceilsNumber(diff, -1000, 1000); - } - flag = !flag; - moveCount++; - } - previousCp = bestLine.cp; - } + const { whiteCpl, blackCpl } = positions.slice(1).reduce( + (acc, position, index) => { + const cp = getPositionCp(position); + + if (index % 2 === 0) { + acc.whiteCpl += cp > previousCp ? 0 : Math.min(previousCp - cp, 1000); + } else { + acc.blackCpl += cp < previousCp ? 0 : Math.min(cp - previousCp, 1000); } - } - if (moveCount === 0) { - return { white: null, black: null }; - } + previousCp = cp; + return acc; + }, + { whiteCpl: 0, blackCpl: 0 } + ); - const averageCPLWhite = totalCPLWhite / Math.ceil(moveCount / 2); - const averageCPLBlack = totalCPLBlack / Math.floor(moveCount / 2); + return { + whiteCpl: whiteCpl / Math.ceil((positions.length - 1) / 2), + blackCpl: blackCpl / Math.floor((positions.length - 1) / 2), + }; +}; - const estimateElo = (averageCPL: number) => - 3100 * Math.exp(-0.01 * averageCPL); +// Source: https://lichess.org/forum/general-chess-discussion/how-to-estimate-your-elo-for-a-game-using-acpl-and-what-it-realistically-means +const getEloFromAverageCpl = (averageCpl: number) => + 3100 * Math.exp(-0.01 * averageCpl); - const whiteElo = estimateElo(Math.abs(averageCPLWhite)); - const blackElo = estimateElo(Math.abs(averageCPLBlack)); +const getAverageCplFromElo = (elo: number) => + -100 * Math.log(Math.min(elo, 3100) / 3100); - return { white: whiteElo, black: blackElo }; - } catch (error) { - console.error("Error estimating Elo: ", error); - return { white: null, black: null }; +const getEloFromRatingAndCpl = ( + gameCpl: number, + rating: number | undefined +): number => { + const eloFromCpl = getEloFromAverageCpl(gameCpl); + if (!rating) return eloFromCpl; + + const expectedCpl = getAverageCplFromElo(rating); + const cplDiff = gameCpl - expectedCpl; + if (cplDiff === 0) return eloFromCpl; + + if (cplDiff > 0) { + return rating * Math.exp(-0.005 * cplDiff); + } else { + return rating / Math.exp(-0.005 * -cplDiff); } }; diff --git a/src/lib/engine/helpers/winPercentage.ts b/src/lib/engine/helpers/winPercentage.ts index 6c6813c..cca6411 100644 --- a/src/lib/engine/helpers/winPercentage.ts +++ b/src/lib/engine/helpers/winPercentage.ts @@ -22,9 +22,10 @@ const getWinPercentageFromMate = (mate: number): number => { return getWinPercentageFromCp(mateInf); }; +// Source: https://github.com/lichess-org/lila/blob/a320a93b68dabee862b8093b1b2acdfe132b9966/modules/analyse/src/main/WinPercent.scala#L27 const getWinPercentageFromCp = (cp: number): number => { const cpCeiled = ceilsNumber(cp, -1000, 1000); - const MULTIPLIER = -0.00368208; + const MULTIPLIER = -0.00368208; // Source : https://github.com/lichess-org/lila/pull/11148 const winChances = 2 / (1 + Math.exp(MULTIPLIER * cpCeiled)) - 1; return 50 + 50 * winChances; }; diff --git a/src/lib/engine/uciEngine.ts b/src/lib/engine/uciEngine.ts index 942b75a..d9babdc 100644 --- a/src/lib/engine/uciEngine.ts +++ b/src/lib/engine/uciEngine.ts @@ -1,6 +1,5 @@ import { EngineName } from "@/types/enums"; import { - EstimatedElo, EvaluateGameParams, EvaluatePositionWithUpdateParams, GameEval, @@ -14,7 +13,7 @@ import { computeAccuracy } from "./helpers/accuracy"; import { getIsStalemate, getWhoIsCheckmated } from "../chess"; import { getLichessEval } from "../lichess"; import { getMovesClassification } from "./helpers/moveClassification"; -import { estimateEloFromEngineOutput } from "./helpers/estimateElo"; +import { computeEstimatedElo } from "./helpers/estimateElo"; import { EngineWorker, WorkerJob } from "@/types/engine"; export class UciEngine { @@ -217,6 +216,7 @@ export class UciEngine { depth = 16, multiPv = this.multiPv, setEvaluationProgress, + playersRatings, }: EvaluateGameParams): Promise { this.throwErrorIfNotReady(); setEvaluationProgress?.(1); @@ -278,7 +278,11 @@ export class UciEngine { fens ); const accuracy = computeAccuracy(positions); - const estimatedElo: EstimatedElo = estimateEloFromEngineOutput(positions); + const estimatedElo = computeEstimatedElo( + positions, + playersRatings?.white, + playersRatings?.black + ); this.isReady = true; return { diff --git a/src/sections/analysis/panelBody/analysisTab/index.tsx b/src/sections/analysis/panelBody/analysisTab/index.tsx index 02f2755..82f8990 100644 --- a/src/sections/analysis/panelBody/analysisTab/index.tsx +++ b/src/sections/analysis/panelBody/analysisTab/index.tsx @@ -10,11 +10,12 @@ import { engineMultiPvAtom, engineNameAtom, gameAtom, + gameEvalAtom, } from "../../states"; import LineEvaluation from "./lineEvaluation"; import { useCurrentPosition } from "../../hooks/useCurrentPosition"; import { LineEval } from "@/types/eval"; -import Accuracies from "./accuracies"; +import PlayersMetric from "./playersMetric"; import MoveInfo from "./moveInfo"; import Opening from "./opening"; @@ -24,6 +25,7 @@ export default function AnalysisTab(props: GridProps) { const position = useCurrentPosition(engineName); const game = useAtomValue(gameAtom); const board = useAtomValue(boardAtom); + const gameEval = useAtomValue(gameEvalAtom); const boardHistory = board.history(); const gameHistory = game.history(); @@ -57,8 +59,21 @@ export default function AnalysisTab(props: GridProps) { : { overflow: "hidden", overflowY: "auto", ...props.sx } } > - - + {gameEval && ( + + )} + + {gameEval?.estimatedElo && ( + + )} diff --git a/src/sections/analysis/panelBody/analysisTab/accuracies.tsx b/src/sections/analysis/panelBody/analysisTab/playersMetric.tsx similarity index 51% rename from src/sections/analysis/panelBody/analysisTab/accuracies.tsx rename to src/sections/analysis/panelBody/analysisTab/playersMetric.tsx index 2dbad7d..8379b5b 100644 --- a/src/sections/analysis/panelBody/analysisTab/accuracies.tsx +++ b/src/sections/analysis/panelBody/analysisTab/playersMetric.tsx @@ -1,15 +1,16 @@ import { Grid2 as Grid, Typography } from "@mui/material"; -import { useAtomValue } from "jotai"; -import { gameEvalAtom } from "../../states"; -type props = { - params: "accurecy" | "rating"; -}; -export default function Accuracies(props: props) { - const gameEval = useAtomValue(gameEvalAtom); - - if (!gameEval) return null; +interface Props { + title: string; + whiteValue: string | number; + blackValue: string | number; +} +export default function PlayersMetric({ + title, + whiteValue, + blackValue, +}: Props) { return ( - {props.params === "accurecy" - ? `${gameEval?.accuracy.white.toFixed(1)} %` - : `${Math.round(gameEval?.estimatedElo.white as number)}`} + {whiteValue} - - {props.params === "accurecy" ? "Accuracies" : "Estimated Elo"} - + {title} - {props.params === "accurecy" - ? `${gameEval?.accuracy.black.toFixed(1)} %` - : `${Math.round(gameEval?.estimatedElo.black as number)}`} + {blackValue} ); diff --git a/src/sections/analysis/panelHeader/analyzeButton.tsx b/src/sections/analysis/panelHeader/analyzeButton.tsx index 6e5e7b9..e9aceb9 100644 --- a/src/sections/analysis/panelHeader/analyzeButton.tsx +++ b/src/sections/analysis/panelHeader/analyzeButton.tsx @@ -16,6 +16,7 @@ import { useEngine } from "@/hooks/useEngine"; import { logAnalyticsEvent } from "@/lib/firebase"; import { SavedEvals } from "@/types/eval"; import { useEffect, useCallback } from "react"; +import { usePlayersData } from "@/hooks/usePlayersData"; export default function AnalyzeButton() { const engineName = useAtomValue(engineNameAtom); @@ -29,6 +30,7 @@ export default function AnalyzeButton() { const [gameEval, setEval] = useAtom(gameEvalAtom); const game = useAtomValue(gameAtom); const setSavedEvals = useSetAtom(savedEvalsAtom); + const { white, black } = usePlayersData(gameAtom); const readyToAnalyse = engine?.getIsReady() && game.history().length > 0 && !evaluationProgress; @@ -48,6 +50,10 @@ export default function AnalyzeButton() { depth: engineDepth, multiPv: engineMultiPv, setEvaluationProgress, + playersRatings: { + white: white?.rating, + black: black?.rating, + }, }); setEval(newGameEval); @@ -84,6 +90,8 @@ export default function AnalyzeButton() { gameFromUrl, setGameEval, setSavedEvals, + white.rating, + black.rating, ]); // Automatically analyze when a new game is loaded and ready to analyze diff --git a/src/types/eval.ts b/src/types/eval.ts index 67ebd67..ed36bd6 100644 --- a/src/types/eval.ts +++ b/src/types/eval.ts @@ -22,9 +22,10 @@ export interface Accuracy { } export interface EstimatedElo { - white: number | null; - black: number | null; + white: number; + black: number; } + export interface EngineSettings { engine: EngineName; depth: number; @@ -35,7 +36,7 @@ export interface EngineSettings { export interface GameEval { positions: PositionEval[]; accuracy: Accuracy; - estimatedElo: EstimatedElo; + estimatedElo?: EstimatedElo; settings: EngineSettings; } @@ -59,6 +60,7 @@ export interface EvaluateGameParams { depth?: number; multiPv?: number; setEvaluationProgress?: (value: number) => void; + playersRatings?: { white?: number; black?: number }; } export interface SavedEval {