// Copyright (c) 2025-2026, RTE (https://www.rte-france.com) // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, // you can obtain one at http://mozilla.org/MPL/2.0/. // SPDX-License-Identifier: MPL-2.0 // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. import type { GameSessionLog, GameStudyResult } from './types'; // --------------------------------------------------------------------------- // Shared scoring model. // // This is the SINGLE SOURCE OF TRUTH for how a game session is scored, kept // deliberately simple so it can be reproduced exactly by the Codabench // `scoring_program/score.py` (see that file's docstring — the two must stay // numerically identical). The frontend uses it for the live results preview; // the Codabench scorer uses the Python twin to rank submissions. // // Per study (0..100): // physical = 60 * remediationFraction // actions = 25 * remediationFraction * actionEfficiency // time = 15 * remediationFraction * timeEfficiency // where: // remediationFraction = how much of the overload the player removed, // 1.0 when the worst line is back under 100 %. // actionEfficiency = rewards using fewer of the allowed actions. // timeEfficiency = rewards finishing well within the time limit. // Session score = mean of per-study scores. // --------------------------------------------------------------------------- export const WEIGHTS = { physical: 60, actions: 25, time: 15 } as const; function clamp01(x: number): number { return Math.max(0, Math.min(1, x)); } /** * Fraction of the overload removed. 1.0 == worst line back under 100 %. * 0.0 == no improvement (or no action taken). */ export function remediationFraction(s: GameStudyResult): number { if (s.finalMaxRho == null) return 0; if (s.solved || s.finalMaxRho < 1.0) return 1; const baseline = s.baselineMaxRho; if (baseline == null || baseline <= 1.0) return s.solved ? 1 : 0; // Linear credit for partial relief between baseline and the 100 % target. return clamp01((baseline - s.finalMaxRho) / (baseline - 1.0)); } export function actionEfficiency(s: GameStudyResult): number { if (s.numActions < 1) return 0; const span = Math.max(1, s.maxActions); return clamp01(1 - (s.numActions - 1) / span); } export function timeEfficiency(s: GameStudyResult): number { const limitMs = s.timeLimitSeconds * 1000; if (limitMs <= 0) return 0; return clamp01(1 - s.durationMs / limitMs); } export interface StudyScore { studyId: string; label: string; physical: number; actions: number; time: number; total: number; remediationFraction: number; solved: boolean; } export function scoreStudy(s: GameStudyResult): StudyScore { const frac = remediationFraction(s); const physical = WEIGHTS.physical * frac; const actions = WEIGHTS.actions * frac * actionEfficiency(s); const time = WEIGHTS.time * frac * timeEfficiency(s); return { studyId: s.studyId, label: s.label, physical, actions, time, total: physical + actions + time, remediationFraction: frac, solved: s.solved, }; } export interface SessionScore { finalScore: number; solvedCount: number; nStudies: number; perStudy: StudyScore[]; } export function scoreSession(log: GameSessionLog): SessionScore { const perStudy = log.studies.map(scoreStudy); const nStudies = perStudy.length; const finalScore = nStudies ? perStudy.reduce((a, b) => a + b.total, 0) / nStudies : 0; return { finalScore, solvedCount: log.studies.filter((s) => s.solved).length, nStudies, perStudy, }; }