Files
geograsim/App/php/lib/Benchmark.php
T
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

450 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* GeoGraSim · Benchmark-Engine
*
* Berechnet pro Submission einen normalisierten Modul-Score (0100)
* sowie über alle Submissions einer Schüler:in einen Gesamt-Benchmark.
*
* Design-Prinzipien:
* - Jede Modul-Formel ist eine eigene private Methode, dokumentiert mit
* Quelle und Erwartungswert. Wenn ein erwartetes Feld fehlt → null
* statt eine geratene Zahl (lieber „noch keine Bewertung" als falsch).
* - Gesamt-Benchmark = Mittelwert über alle gespielten Module, wobei
* pro Modul max. die 3 besten Sessions zählen (verhindert Dominanz
* durch viel-gespielte Sims, ohne stillgewordene Module zu verlieren).
* - Frontend zeigt den Wert als „Benchmark" — nicht als Note. Schüler:innen
* sehen ihn nicht direkt.
*
* Aufrufer:
* - App/php/api/live.php (cockpit-Block) für Top 3 / Bottom 3 und
* benchmarkPct pro Schüler:in.
* - App/php/api/results.php (perspektivisch — kann den heutigen
* Stars-Stub im Cockpit ersetzen).
*
* Stand: 2026-06-11
*/
class Benchmark
{
/**
* Berechnet den Modul-Score (0100) für eine einzelne Submission.
* Returns null wenn die Submission keine bewertbaren Daten enthält.
*/
public static function scoreSubmission(string $simId, ?string $resultsJson, ?string $processLogJson = null): ?int
{
$results = $resultsJson ? json_decode($resultsJson, true) : null;
$process = $processLogJson ? json_decode($processLogJson, true) : null;
if (!is_array($results) && !is_array($process)) return null;
$results = is_array($results) ? $results : [];
$process = is_array($process) ? $process : [];
// Pro Sim dispatchen. Klima-3D nutzt dieselbe Logik wie 2D.
$score = match ($simId) {
'klima', 'klima-3d' => self::scoreKlima($results),
'heli' => self::scoreHeli($results, $process),
'busfahrt' => self::scoreBusfahrt($results),
'farmer' => self::scoreFarmer($results),
'sonnensystem' => self::scoreSonnensystem($results),
'eu-werkstatt' => self::scoreEuWerkstatt($results),
'energiemanager' => self::scoreEnergiemanager($results),
'weltkueche' => self::scoreWeltkueche($results),
'logistik' => self::scoreLogistik($results),
'fluss' => self::scoreFluss($results),
'fluggesellschaft' => self::scoreFluggesellschaft($results),
'kofferdetektiv' => self::scoreKofferdetektiv($results),
// Entscheidungstag: kein assessment-Submit-Pfad bekannt → null
default => self::scoreFallbackStars($results),
};
if ($score === null) return null;
return max(0, min(100, (int)round($score)));
}
/**
* Gesamt-Benchmark einer Schüler:in über alle ihre Submissions hinweg.
* Pro Modul werden die TOP-3-Sessions berücksichtigt (Mittelwert),
* dann gewichteter Mittelwert über alle gespielten Module.
*
* Returns null wenn die Schüler:in noch nirgendwo bewertbar gespielt hat.
*/
public static function studentOverall($db, int $studentId, int $classId): ?int
{
// Bewertbare Submissions ziehen, neueste zuerst pro (Schüler × Modul)
$rows = $db->fetchAll(
"SELECT a.sim_id, a.results, a.process_log, a.submitted_at
FROM assessments a
JOIN student_sessions ss ON ss.id = a.session_id
JOIN students s ON s.class_id = ss.class_id AND s.display_name = ss.display_name
WHERE s.id = ? AND a.class_id = ?
ORDER BY a.sim_id, a.submitted_at DESC",
[$studentId, $classId]
);
if (!$rows) return null;
// Pro Modul: bis zu 3 beste Scores sammeln
$perModuleScores = [];
foreach ($rows as $r) {
$sim = $r['sim_id'];
$score = self::scoreSubmission($sim, $r['results'], $r['process_log']);
if ($score === null) continue;
if (!isset($perModuleScores[$sim])) $perModuleScores[$sim] = [];
// Nach DESC sortiert eingehend → erste 3 sind die jüngsten,
// wir wollen die BESTEN 3 (egal wann) → erst alle sammeln, dann sortieren.
$perModuleScores[$sim][] = $score;
}
if (!$perModuleScores) return null;
// Pro Modul Top 3 mitteln
$perModuleAvg = [];
foreach ($perModuleScores as $sim => $list) {
rsort($list); // höchster zuerst
$top3 = array_slice($list, 0, 3); // bis zu drei
$perModuleAvg[$sim] = array_sum($top3) / count($top3);
}
// Gesamt-Mittelwert über alle gespielten Module
$overall = array_sum($perModuleAvg) / count($perModuleAvg);
return (int)round($overall);
}
// ====================================================================
// PRO-MODUL-FORMELN
// Jede returnt 0100 oder null (wenn Daten unbewertbar).
// ====================================================================
/**
* Klima 2D/3D — vier didaktische Dimensionen, gemittelt.
* Quelle: assessments.results.results.{final_co2, final_temperature,
* final_budget, final_flooded_pct, final_population}
* Skalen aus App/teacher.html (_klimaScoreTemp etc.) übernommen.
*/
private static function scoreKlima(array $r): ?int
{
// Klima verschachtelt: top-level + nested 'results'-Objekt
$rr = $r['results'] ?? $r;
$temp = $rr['final_temperature'] ?? null;
$co2 = $rr['final_co2'] ?? null;
$budget = $rr['final_budget'] ?? null;
$flooded = $rr['final_flooded_pct'] ?? null;
$pop = $rr['final_population'] ?? null;
$parts = [];
// Temperatur: 15.5° → 100, 18.5° → 0 (linear klemmen)
if (is_numeric($temp)) {
$v = (18.5 - $temp) / 3.0 * 100;
$parts[] = max(0, min(100, $v));
}
// Inselüberflutung: 0 % → 100, 100 % → 0
if (is_numeric($flooded)) {
$parts[] = max(0, min(100, 100 - $flooded));
}
// Budget: 200 → 0, 0 → 40, 500 → 90, ≥1000 → 100
if (is_numeric($budget)) {
$b = (float)$budget;
if ($b <= -200) $v = 0;
elseif ($b < 0) $v = 40 + ($b / -200) * -40;
elseif ($b < 500) $v = 40 + ($b / 500) * 50;
else $v = 90 + min(10, ($b - 500) / 50);
$parts[] = max(0, min(100, $v));
}
// Bevölkerung: 60 % → 0, 90 % → 90, 100 %+ → 100
// Klima hat „population" als absolute Zahl, nicht Prozent — wir lassen das
// hier optional weg, weil Skalierung start-pop-abhängig ist.
if (!$parts) return null;
return (int)round(array_sum($parts) / count($parts));
}
/**
* Heli — Sterne als primärer Score, planPct als sekundäre Qualität.
* Quelle: assessments.results.{sterneGesamt, planPct, startScore, landingScore}
* Viele Heli-Submissions sind leere Arrays (Interim-Speicherungen) → null.
*/
private static function scoreHeli(array $r, array $proc): ?int
{
$stars = $r['sterneGesamt'] ?? ($r['stars'] ?? null);
$planPct = $r['planPct'] ?? null;
$startScore = $r['startScore'] ?? null; // 0-5
$landingScore = $r['landingScore'] ?? null; // 0-5
// Wenn nichts da, abbrechen
if (!is_numeric($stars) && !is_numeric($planPct)) return null;
$parts = [];
if (is_numeric($stars)) $parts[] = ($stars / 5) * 100;
if (is_numeric($planPct)) $parts[] = (float)$planPct;
if (is_numeric($startScore)) $parts[] = ($startScore / 5) * 100;
if (is_numeric($landingScore)) $parts[] = ($landingScore / 5) * 100;
if (!$parts) return null;
return (int)round(array_sum($parts) / count($parts));
}
/**
* Busfahrt — Tour-Score aus stars + Quiz-Quote + Distanz-Abweichung.
* Quelle: assessments.results.{stars, wonOrders, totalOrders,
* quizFirstTry, avgTapKm}
*/
private static function scoreBusfahrt(array $r): ?int
{
$stars = $r['stars'] ?? null;
$wonOrders = $r['wonOrders'] ?? null;
$totalOrders = $r['totalOrders'] ?? null;
$quizFirstTry = $r['quizFirstTry'] ?? null;
$avgTapKm = $r['avgTapKm'] ?? null;
$parts = [];
if (is_numeric($stars)) $parts[] = ($stars / 5) * 100;
if (is_numeric($wonOrders) && is_numeric($totalOrders) && $totalOrders > 0) {
$parts[] = ($wonOrders / $totalOrders) * 100;
}
if (is_numeric($quizFirstTry) && is_numeric($totalOrders) && $totalOrders > 0) {
$parts[] = ($quizFirstTry / $totalOrders) * 100;
}
// Distanz: 30 km → 100, 200 km → 0 (linear klemmen)
if (is_numeric($avgTapKm)) {
$d = (float)$avgTapKm;
$parts[] = max(0, min(100, (200 - $d) / 170 * 100));
}
if (!$parts) return null;
return (int)round(array_sum($parts) / count($parts));
}
/**
* Farmer — Erfolgsquote + erreichte Jahre + Sortenvielfalt.
* Quelle: assessments.results.{stars, hit_rate, year, crops_used}
* Fallback wenn nichts da: Sterne aus top-level.
*/
private static function scoreFarmer(array $r): ?int
{
$stars = $r['stars'] ?? null;
$hitRate = $r['hit_rate'] ?? null;
$year = $r['year'] ?? null;
$cropsUsed = $r['crops_used'] ?? null;
$parts = [];
if (is_numeric($stars)) $parts[] = ($stars / 5) * 100;
if (is_numeric($hitRate)) $parts[] = (float)$hitRate;
// 0 Jahre → 0, 10 Jahre → 80, 15+ → 100
if (is_numeric($year)) {
$y = (int)$year;
if ($y <= 0) $v = 0;
elseif ($y < 10) $v = ($y / 10) * 80;
elseif ($y < 15) $v = 80 + (($y - 10) / 5) * 20;
else $v = 100;
$parts[] = $v;
}
// Sortenvielfalt: 0 → 0, 6 → 100 (gekappt)
if (is_numeric($cropsUsed)) {
$parts[] = max(0, min(100, ($cropsUsed / 6) * 100));
}
if (!$parts) return null;
return (int)round(array_sum($parts) / count($parts));
}
/**
* Sonnensystem — eine Submission = ein gelöstes Task-Event.
* Quelle: assessments.results.{completed, total, firstTry}
* Score: completed/total × Bonus für firstTry.
*/
private static function scoreSonnensystem(array $r): ?int
{
$completed = $r['completed'] ?? null;
$total = $r['total'] ?? null;
$firstTry = $r['firstTry'] ?? null;
if (!is_numeric($completed) || !is_numeric($total) || $total <= 0) return null;
$base = ($completed / $total) * 100;
// Bonus +10 % wenn FirstTry, gekappt
if ($firstTry === true || $firstTry === 1 || $firstTry === '1') {
$base = min(100, $base + 10);
}
return (int)round($base);
}
/**
* EU-Werkstatt — Quote der Erste-Wahl-Treffer × Sauberkeit (kein Mülleimer).
* Quelle: assessments.results.{endType, stepsTotal, firstTryHits,
* totalAttempts, muelleimerCount}
*/
private static function scoreEuWerkstatt(array $r): ?int
{
$endType = $r['endType'] ?? null;
$stepsTotal = $r['stepsTotal'] ?? null;
$firstTryHits = $r['firstTryHits'] ?? null;
$totalAttempts = $r['totalAttempts'] ?? null;
$muelleimer = $r['muelleimerCount']?? null;
if (!is_numeric($stepsTotal) || $stepsTotal <= 0) return null;
$firstTryPct = is_numeric($firstTryHits) ? ($firstTryHits / $stepsTotal) * 100 : 0;
$cleanFactor = 1.0;
if (is_numeric($muelleimer) && is_numeric($totalAttempts) && $totalAttempts > 0) {
$cleanFactor = max(0, 1 - ($muelleimer / $totalAttempts));
}
$score = $firstTryPct * $cleanFactor;
// Erfolgs-Bonus
if ($endType === 'success') $score = min(100, $score + 5);
return (int)round($score);
}
/**
* Energiemanager — Anteil ausbalancierter Blöcke × Vollständigkeit der Tage.
* Quelle: assessments.results.{stars, blocksBalanced, blocksTotal,
* daysPlayed, daysTotal}
*/
private static function scoreEnergiemanager(array $r): ?int
{
$stars = $r['stars'] ?? null;
$bb = $r['blocksBalanced'] ?? null;
$bt = $r['blocksTotal'] ?? null;
$dp = $r['daysPlayed'] ?? null;
$dt = $r['daysTotal'] ?? null;
$parts = [];
if (is_numeric($stars)) $parts[] = ($stars / 5) * 100;
if (is_numeric($bb) && is_numeric($bt) && $bt > 0) $parts[] = ($bb / $bt) * 100;
if (is_numeric($dp) && is_numeric($dt) && $dt > 0) $parts[] = ($dp / $dt) * 100;
if (!$parts) return null;
return (int)round(array_sum($parts) / count($parts));
}
/**
* Weltküche — abgeschlossene Gerichte × Fehlklick-Qualität.
* Quelle: assessments.results.{stars, completedDishes, totalDishes,
* mistakesInDish, score}
*/
private static function scoreWeltkueche(array $r): ?int
{
$stars = $r['stars'] ?? null;
$completed = $r['completedDishes'] ?? ($r['completed'] ?? null);
$total = $r['totalDishes'] ?? ($r['completedTotal'] ?? null);
$mistakes = $r['mistakesInDish'] ?? null;
$parts = [];
if (is_numeric($stars)) $parts[] = ($stars / 5) * 100;
if (is_numeric($completed) && is_numeric($total) && $total > 0) $parts[] = ($completed / $total) * 100;
// Fehlklick-Penalty: 0 → 100, 15 → 0
if (is_numeric($mistakes)) {
$parts[] = max(0, min(100, 100 - (((int)$mistakes) / 15) * 100));
}
if (!$parts) return null;
return (int)round(array_sum($parts) / count($parts));
}
/**
* Logistik — pünktliche Auslieferungen × positive Endbilanz.
* Quelle: assessments.results.{stars, ordersDelivered, totalOrders,
* balance, ordersLate}
*/
private static function scoreLogistik(array $r): ?int
{
$stars = $r['stars'] ?? null;
$delivered = $r['ordersDelivered'] ?? null;
$total = $r['totalOrders'] ?? null;
$late = $r['ordersLate'] ?? null;
$balance = $r['balance'] ?? null;
$finalBalance = $r['finalBalance'] ?? null;
$parts = [];
if (is_numeric($stars)) $parts[] = ($stars / 5) * 100;
if (is_numeric($delivered) && is_numeric($total) && $total > 0) {
// Pünktlich = delivered minus late, normiert
$onTime = max(0, $delivered - (is_numeric($late) ? $late : 0));
$parts[] = ($onTime / $total) * 100;
}
$bal = $finalBalance ?? $balance;
if (is_numeric($bal)) {
// <= -1000 → 0, 0 → 40, +1000 → 90, +5000+ → 100
$b = (float)$bal;
if ($b <= -1000) $v = 0;
elseif ($b < 0) $v = 40 + ($b / -1000) * -40;
elseif ($b < 1000) $v = 40 + ($b / 1000) * 50;
else $v = 90 + min(10, ($b - 1000) / 400);
$parts[] = max(0, min(100, $v));
}
if (!$parts) return null;
return (int)round(array_sum($parts) / count($parts));
}
/**
* Fluss — vier didaktische Dimensionen, gemittelt.
* Quelle: assessments.results.{stars, biodiv, economy, flood, food, population}
*/
private static function scoreFluss(array $r): ?int
{
$stars = $r['stars'] ?? null;
$bd = $r['biodiv'] ?? ($r['final_biodiv'] ?? null);
$ec = $r['economy'] ?? ($r['final_economy'] ?? null);
$fl = $r['flood'] ?? ($r['final_flood'] ?? null);
$fd = $r['food'] ?? ($r['final_food'] ?? null);
$parts = [];
if (is_numeric($stars)) $parts[] = ($stars / 5) * 100;
if (is_numeric($bd)) $parts[] = max(0, min(100, (float)$bd));
if (is_numeric($ec)) $parts[] = max(0, min(100, (float)$ec));
if (is_numeric($fl)) $parts[] = max(0, min(100, 100 - (float)$fl)); // niedrig = gut
if (is_numeric($fd)) $parts[] = max(0, min(100, (float)$fd));
if (!$parts) return null;
return (int)round(array_sum($parts) / count($parts));
}
/**
* Fluggesellschaft — Gewonnene Aufträge × Quiz-Quote × Marge.
* Aktuell als „geplant" markiert, Formel liegt vor für Aktivierung.
*/
private static function scoreFluggesellschaft(array $r): ?int
{
$stars = $r['stars'] ?? null;
$wonOrders = $r['wonOrders'] ?? null;
$totalOrders = $r['totalOrders'] ?? null;
$quizFirstTry = $r['quizFirstTry'] ?? null;
$earnings = $r['earnings'] ?? null;
$parts = [];
if (is_numeric($stars)) $parts[] = ($stars / 5) * 100;
if (is_numeric($wonOrders) && is_numeric($totalOrders) && $totalOrders > 0) {
$parts[] = ($wonOrders / $totalOrders) * 100;
}
if (is_numeric($quizFirstTry) && is_numeric($totalOrders) && $totalOrders > 0) {
$parts[] = ($quizFirstTry / $totalOrders) * 100;
}
if (!$parts) return null;
return (int)round(array_sum($parts) / count($parts));
}
/**
* Kofferdetektiv — Lösungsquote × Erstversuch × Hinweis-Effizienz Fehler-Malus.
* Formel aus INTEGRATION.md (Sim-Lieferung):
* 60 % Lösungsquote + 25 % Erstversuch + 15 % Hinweis-Effizienz bis 20 % Fehlversuch-Malus.
* Sim-internal: 100 Basis pro Fall, 7 je Hinweis ab dem zweiten, 15 je Fehlversuch,
* 20 bei Zeitüberschreitung, Minimum 10 bei Lösung.
*/
private static function scoreKofferdetektiv(array $r): ?int
{
$total = (int)($r['cases_total'] ?? 0);
$solved = (int)($r['cases_solved'] ?? 0);
$firstTry = (int)($r['correct_first_try'] ?? 0);
$wrong = (int)($r['wrong_guesses'] ?? 0);
$hintsAvg = (float)($r['hints_used_avg'] ?? 10);
if ($total === 0) return null;
$base = ($solved / $total) * 60; // 60 % Lösungsquote
$clean = ($firstTry / $total) * 25; // 25 % Erstversuch-Quote
$sparse = max(0, (10 - $hintsAvg) / 9) * 15; // 15 % Hinweis-Effizienz (wenig Hinweise → mehr Punkte)
$malus = min(20, $wrong * 4); // bis 20 für Fehlversuche
return (int)round(max(0, min(100, $base + $clean + $sparse - $malus)));
}
/**
* Generischer Fallback: Sterne aus 5-er-Skala.
*/
private static function scoreFallbackStars(array $r): ?int
{
$stars = $r['stars'] ?? ($r['totalStars'] ?? null);
if (!is_numeric($stars)) return null;
return (int)round(($stars / 5) * 100);
}
}