configModel = new ConfigurationModel(); } public function index() { $db = \Config\Database::connect(); $currentYear = $this->configModel->getConfig('school_year') ?? ''; $selectedYear = $this->request->getGet('school_year') ?? $currentYear; $percentile = (float) ($this->request->getGet('percentile') ?? 75); $percentile = max(1.0, min(99.0, $percentile)); // Available school years from class assignments so the current year can // still appear even if fall scores are not fully entered yet. $years = $db->table('student_class') ->select('school_year') ->distinct() ->orderBy('school_year', 'DESC') ->get() ->getResultArray(); $years = array_column($years, 'school_year'); // Build a fall-score-based projection. We start from class assignments so // students without a recorded fall score still appear as "not yet // projected" rather than disappearing from the page. $rows = $db->table('student_class sc') ->select([ 'sc.student_id', 'sc.class_section_id', 'MAX(ss.semester_score) AS fall_score', 'cs.class_section_name', 'CONCAT(s.firstname, " ", s.lastname) AS student_name', 's.school_id', 's.gender', ]) ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') ->join('students s', 's.id = sc.student_id', 'left') ->join( 'semester_scores ss', 'ss.student_id = sc.student_id' . ' AND ss.class_section_id = sc.class_section_id' . ' AND ss.school_year = ' . $db->escape($selectedYear) . ' AND LOWER(ss.semester) = "fall"', 'left' ) ->where('sc.school_year', $selectedYear) ->orderBy('cs.class_section_name', 'ASC') ->orderBy('fall_score', 'DESC') ->orderBy('student_name', 'ASC') ->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id') ->get() ->getResultArray(); // Group rows by class section $sections = []; foreach ($rows as $row) { $sid = (int) ($row['class_section_id'] ?? 0); $name = $row['class_section_name'] ?? 'Section ' . $sid; if (!isset($sections[$sid])) { $sections[$sid] = ['name' => $name, 'students' => []]; } $sections[$sid]['students'][] = [ 'student_id' => (int) $row['student_id'], 'name' => $row['student_name'], 'school_id' => $row['school_id'], 'gender' => $row['gender'] ?? '', 'fall_score' => is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null, ]; } // Calculate trophy thresholds from fall scores only, then project who is // currently on track for a year-end trophy. $classResults = []; foreach ($sections as $sid => $section) { usort($section['students'], static function (array $a, array $b): int { $aScore = $a['fall_score']; $bScore = $b['fall_score']; if ($aScore === null && $bScore === null) { return strcmp($a['name'], $b['name']); } if ($aScore === null) { return 1; } if ($bScore === null) { return -1; } return $bScore <=> $aScore ?: strcmp($a['name'], $b['name']); }); $scores = array_column($section['students'], 'fall_score'); $calc = $this->calculateThreshold($scores, $percentile); $threshold = $calc['threshold']; $students = array_map(function (array $student) use ($threshold): array { $student['projected_trophy'] = $threshold !== null && $student['fall_score'] !== null && $student['fall_score'] >= $threshold; return $student; }, $section['students']); $scoredCount = count(array_filter( array_column($students, 'fall_score'), static fn ($score) => $score !== null )); $boys = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'male'); $girls = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'female'); $trophyBoys = array_filter($boys, static fn ($s) => $s['projected_trophy']); $trophyGirls = array_filter($girls, static fn ($s) => $s['projected_trophy']); $nBoys = count($boys); $nGirls = count($girls); $nTrophyBoys = count($trophyBoys); $nTrophyGirls = count($trophyGirls); $total = count($students); $classResults[] = [ 'section_id' => $sid, 'section_name' => $section['name'], 'students' => $students, 'threshold' => $threshold, 'trophy_count' => $calc['winners'], 'student_count' => $total, 'scored_count' => $scoredCount, 'method' => $calc['method'], 'boys' => $nBoys, 'girls' => $nGirls, 'trophy_boys' => $nTrophyBoys, 'trophy_girls' => $nTrophyGirls, 'pct_boys' => $total > 0 ? round($nBoys / $total * 100) : 0, 'pct_girls' => $total > 0 ? round($nGirls / $total * 100) : 0, 'pct_trophy_boys' => $nBoys > 0 ? round($nTrophyBoys / $nBoys * 100) : 0, 'pct_trophy_girls' => $nGirls > 0 ? round($nTrophyGirls / $nGirls * 100) : 0, 'pct_trophy_total' => $total > 0 ? round($calc['winners'] / $total * 100): 0, ]; } return view('administrator/trophy', [ 'classResults' => $classResults, 'selectedYear' => $selectedYear, 'selectedPercentile'=> $percentile, 'years' => $years, ]); } public function winners() { $db = \Config\Database::connect(); $currentYear = $this->configModel->getConfig('school_year') ?? ''; $selectedYear = $this->request->getGet('school_year') ?? $currentYear; $percentile = (float) ($this->request->getGet('percentile') ?? 75); $percentile = max(1.0, min(99.0, $percentile)); $years = $db->table('student_class') ->select('school_year')->distinct() ->orderBy('school_year', 'DESC') ->get()->getResultArray(); $years = array_column($years, 'school_year'); $ey = $db->escape($selectedYear); $rows = $db->table('student_class sc') ->select([ 'sc.student_id', 'sc.class_section_id', 'cs.class_section_name', 'CONCAT(s.firstname, " ", s.lastname) AS student_name', 's.school_id', 's.gender', 'MAX(sf.semester_score) AS fall_score', 'MAX(sp.semester_score) AS spring_score', ]) ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') ->join('students s', 's.id = sc.student_id', 'left') ->join('semester_scores sf', 'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id' . ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left') ->join('semester_scores sp', 'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id' . ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left') ->where('sc.school_year', $selectedYear) ->orderBy('cs.class_section_name', 'ASC') ->orderBy('student_name', 'ASC') ->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender') ->get()->getResultArray(); $sections = []; foreach ($rows as $row) { $sid = (int) ($row['class_section_id'] ?? 0); $name = $row['class_section_name'] ?? 'Section ' . $sid; if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []]; $fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null; $spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null; $year = ($fall !== null && $spring !== null) ? round(($fall + $spring) / 2, 1) : ($fall ?? $spring); $sections[$sid]['students'][] = [ 'name' => $row['student_name'], 'school_id' => $row['school_id'], 'gender' => $row['gender'] ?? '', 'fall_score' => $fall, 'spring_score' => $spring, 'year_score' => $year, ]; } $classResults = []; foreach ($sections as $section) { usort($section['students'], static function ($a, $b) { if ($a['fall_score'] === null && $b['fall_score'] === null) return strcmp($a['name'], $b['name']); if ($a['fall_score'] === null) return 1; if ($b['fall_score'] === null) return -1; return $b['fall_score'] <=> $a['fall_score'] ?: strcmp($a['name'], $b['name']); }); $scores = array_column($section['students'], 'fall_score'); $calc = $this->calculateThreshold($scores, $percentile); $threshold = $calc['threshold']; $allStudents = $section['students']; $winners = array_values(array_filter($allStudents, function ($s) use ($threshold) { return $threshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $threshold; })); if (empty($winners)) continue; $boys = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'male'); $girls = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'female'); $trophyBoys = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'male'); $trophyGirls = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'female'); $n = count($allStudents); $nBoys = count($boys); $nGirls = count($girls); $nTB = count($trophyBoys); $nTG = count($trophyGirls); $classResults[] = [ 'section_name' => $section['name'], 'threshold' => $threshold, 'winners' => $winners, 'student_count' => $n, 'boys' => $nBoys, 'girls' => $nGirls, 'trophy_boys' => $nTB, 'trophy_girls' => $nTG, 'pct_boys' => $n > 0 ? round($nBoys / $n * 100) : 0, 'pct_girls' => $n > 0 ? round($nGirls / $n * 100) : 0, 'pct_trophy_boys' => $nBoys > 0 ? round($nTB / $nBoys * 100) : 0, 'pct_trophy_girls' => $nGirls > 0 ? round($nTG / $nGirls * 100) : 0, 'pct_trophy_total' => $n > 0 ? round(count($winners) / $n * 100) : 0, ]; } return view('administrator/trophy_winners', [ 'classResults' => $classResults, 'selectedYear' => $selectedYear, 'selectedPercentile' => $percentile, 'years' => $years, ]); } public function final() { $db = \Config\Database::connect(); $currentYear = $this->configModel->getConfig('school_year') ?? ''; $selectedYear = $this->request->getGet('school_year') ?? $currentYear; $percentile = (float) ($this->request->getGet('percentile') ?? 75); $percentile = max(1.0, min(99.0, $percentile)); $years = $db->table('student_class') ->select('school_year')->distinct() ->orderBy('school_year', 'DESC') ->get()->getResultArray(); $years = array_column($years, 'school_year'); $ey = $db->escape($selectedYear); $rows = $db->table('student_class sc') ->select([ 'sc.student_id', 'sc.class_section_id', 'cs.class_section_name', 'CONCAT(s.firstname, " ", s.lastname) AS student_name', 's.school_id', 's.gender', 'MAX(sf.semester_score) AS fall_score', 'MAX(sp.semester_score) AS spring_score', ]) ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') ->join('students s', 's.id = sc.student_id', 'left') ->join('semester_scores sf', 'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id' . ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left') ->join('semester_scores sp', 'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id' . ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left') ->where('sc.school_year', $selectedYear) ->orderBy('cs.class_section_name', 'ASC') ->orderBy('student_name', 'ASC') ->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender') ->get()->getResultArray(); $sections = []; foreach ($rows as $row) { $sid = (int) ($row['class_section_id'] ?? 0); $name = $row['class_section_name'] ?? 'Section ' . $sid; if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []]; $fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null; $spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null; $year = ($fall !== null && $spring !== null) ? round(($fall + $spring) / 2, 1) : ($fall ?? $spring); $sections[$sid]['students'][] = [ 'name' => $row['student_name'], 'school_id' => $row['school_id'], 'gender' => $row['gender'] ?? '', 'fall_score' => $fall, 'spring_score' => $spring, 'year_score' => $year, ]; } $classResults = []; foreach ($sections as $section) { $students = $section['students']; $fallCalc = $this->calculateThreshold(array_column($students, 'fall_score'), $percentile); $yearCalc = $this->calculateThreshold(array_column($students, 'year_score'), $percentile); $fallThreshold = $fallCalc['threshold']; $yearThreshold = $yearCalc['threshold']; $annotated = array_map(static function (array $s) use ($fallThreshold, $yearThreshold): array { $predicted = $fallThreshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $fallThreshold; $actual = $yearThreshold !== null && $s['year_score'] !== null && $s['year_score'] >= $yearThreshold; $status = match (true) { $predicted && $actual => 'confirmed', !$predicted && $actual => 'surprise', $predicted && !$actual => 'missed', default => 'none', }; return $s + compact('predicted', 'actual', 'status'); }, $students); usort($annotated, static function (array $a, array $b): int { if ($a['year_score'] === null && $b['year_score'] === null) return strcmp($a['name'], $b['name']); if ($a['year_score'] === null) return 1; if ($b['year_score'] === null) return -1; return $b['year_score'] <=> $a['year_score'] ?: strcmp($a['name'], $b['name']); }); $nConfirmed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'confirmed')); $nSurprise = count(array_filter($annotated, static fn ($s) => $s['status'] === 'surprise')); $nMissed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'missed')); $nPredicted = count(array_filter($annotated, static fn ($s) => $s['predicted'])); $nActual = count(array_filter($annotated, static fn ($s) => $s['actual'])); $n = count($annotated); $classResults[] = [ 'section_name' => $section['name'], 'students' => $annotated, 'student_count' => $n, 'fall_threshold' => $fallThreshold, 'year_threshold' => $yearThreshold, 'predicted_count' => $nPredicted, 'actual_count' => $nActual, 'confirmed' => $nConfirmed, 'surprises' => $nSurprise, 'missed' => $nMissed, 'accuracy' => $nPredicted > 0 ? round($nConfirmed / $nPredicted * 100) : ($nActual === 0 ? 100 : 0), ]; } return view('administrator/trophy_final', [ 'classResults' => $classResults, 'selectedYear' => $selectedYear, 'selectedPercentile' => $percentile, 'years' => $years, ]); } private function calculateThreshold(array $scores, float $percentile = 75.0): array { $scores = array_values(array_filter( $scores, static fn ($v) => is_numeric($v) && $v !== null )); $scores = array_map('floatval', $scores); sort($scores); // ascending $n = count($scores); if ($n === 0) { return ['threshold' => null, 'winners' => 0, 'method' => 'empty']; } $minWinners = 3; // never fewer than 3, regardless of class size or score count // Hard maximum: top (100 - percentile)% of class, but never below the minimum. $maxWinners = max($minWinners, (int) floor($n * (1 - $percentile / 100))); $threshold = $this->empiricalPercentile($scores, $percentile); $winners = $this->countAtOrAbove($scores, $threshold); if ($winners < $minWinners) { // Too few qualify — REDUCE the threshold down to the score of the // 3rd-ranked student so the minimum of 3 is always met. // If fewer than 3 students have scores, award all of them. $target = min($minWinners, $n); $desc = array_reverse($scores); // descending $threshold = $desc[$target - 1]; // score at rank $target $winners = $this->countAtOrAbove($scores, $threshold); // ties included return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced']; } if ($winners <= $maxWinners) { return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile']; } // Too many winners — raise threshold to respect the cap. $result = $this->capByRank($scores, $maxWinners); // capByRank may overshoot and drop below the minimum (e.g. bimodal scores // where only 2 students are in the top cluster). Enforce min=3 here too. if ($result['winners'] < $minWinners) { $target = min($minWinners, $n); $desc = array_reverse($scores); $threshold = $desc[$target - 1]; $winners = $this->countAtOrAbove($scores, $threshold); return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap']; } return $result; } /** * Raise the threshold until at most $max students qualify. * Returns the result without checking the minimum — caller must do that. */ private function capByRank(array $sortedScores, int $max): array { $desc = array_reverse($sortedScores); $threshold = $desc[$max - 1]; $winners = $this->countAtOrAbove($sortedScores, $threshold); if ($winners <= $max) { return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct']; } // Ties push it over — walk up through distinct scores. $unique = array_values(array_unique(array_filter( $sortedScores, static fn ($s) => $s > $threshold ))); sort($unique); foreach ($unique as $candidate) { $w = $this->countAtOrAbove($sortedScores, $candidate); if ($w <= $max) { return ['threshold' => $candidate, 'winners' => $w, 'method' => 'capped_25pct']; } } // All scores are equal. return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal']; } private function empiricalPercentile(array $sortedScores, float $p): float { $n = count($sortedScores); if ($n === 0) return 0.0; $index = ($p / 100.0) * ($n - 1); $lower = (int) floor($index); $upper = (int) ceil($index); if ($lower === $upper) return $sortedScores[$lower]; return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]); } private function countAtOrAbove(array $scores, float $threshold): int { return count(array_filter($scores, static fn ($s) => $s >= $threshold)); } }