db = $db ?? \Config\Database::connect(); } public function forSchoolYear(string $schoolYear): array { $schoolYear = trim($schoolYear); if ($schoolYear === '') { return self::summarize([], $schoolYear); } $builder = $this->db->table('student_class sc') ->select([ 'sc.student_id', 'sc.class_section_id', 's.gender', 'cs.class_section_name', ]) ->select('MAX(CASE WHEN LOWER(ss.semester) = "fall" THEN ss.semester_score END) AS fall_score', false) ->select('MAX(CASE WHEN LOWER(ss.semester) = "spring" THEN ss.semester_score END) AS spring_score', false) ->join('students s', 's.id = sc.student_id', 'left') ->join('classSection cs', 'cs.class_section_id = sc.class_section_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 = ' . $this->db->escape($schoolYear), 'left' ) ->where('sc.school_year', $schoolYear) ->groupStart() ->where('sc.is_event_only', 0) ->orWhere('sc.is_event_only', null) ->groupEnd() ->groupBy('sc.student_id, sc.class_section_id, s.gender, cs.class_section_name') ->orderBy('cs.class_section_name', 'ASC'); return self::summarize($builder->get()->getResultArray(), $schoolYear); } public static function summarize(array $rows, string $schoolYear = ''): array { $students = []; $classSizes = []; $trophyCandidates = []; foreach ($rows as $row) { $studentId = (int) ($row['student_id'] ?? 0); if ($studentId <= 0) { continue; } $sectionId = (int) ($row['class_section_id'] ?? 0); $sectionName = trim((string) ($row['class_section_name'] ?? '')); if ($sectionName === '') { $sectionName = $sectionId > 0 ? 'Section ' . $sectionId : 'Unassigned'; } $classKey = $sectionId > 0 ? 'section-' . $sectionId : 'name-' . $sectionName; $classSizes[$classKey] ??= ['label' => $sectionName, 'students' => []]; $classSizes[$classKey]['students'][$studentId] = self::genderKey($row['gender'] ?? null); $fall = is_numeric($row['fall_score'] ?? null) ? (float) $row['fall_score'] : null; $spring = is_numeric($row['spring_score'] ?? null) ? (float) $row['spring_score'] : null; $score = $fall !== null && $spring !== null ? ($fall + $spring) / 2 : ($fall ?? $spring); if (! isset($students[$studentId])) { $students[$studentId] = [ 'gender' => self::genderKey($row['gender'] ?? null), 'scores' => [], 'isKg' => false, ]; } if (preg_match('/(^|[^a-z])kg([^a-z]|$)/i', $sectionName) === 1) { $students[$studentId]['isKg'] = true; } if ($score !== null) { $students[$studentId]['scores'][] = $score; } $trophyCandidates[$classKey][$studentId] = [ 'studentId' => $studentId, 'gender' => self::genderKey($row['gender'] ?? null), 'score' => $score, ]; } $trophyWinnerIds = []; foreach ($trophyCandidates as $classCandidates) { $threshold = self::trophyThreshold( array_column($classCandidates, 'score'), self::TROPHY_PERCENTILE ); if ($threshold === null) { continue; } foreach ($classCandidates as $candidate) { if ($candidate['score'] !== null && $candidate['score'] >= $threshold) { $trophyWinnerIds[$candidate['studentId']] = true; } } } $gender = ['Male' => 0, 'Female' => 0]; $results = ['Pass' => 0, 'Fail' => 0, 'Awaiting results' => 0]; $topGender = ['Male' => 0, 'Female' => 0]; $trophyGender = ['Male' => 0, 'Female' => 0]; $scoreBands = ['Below 60' => 0, '60–69' => 0, '70–79' => 0, '80–89' => 0, '90–100' => 0, 'KG – Passed' => 0]; $genderByStat = [ 'Passed' => ['Male' => 0, 'Female' => 0], 'Failed' => ['Male' => 0, 'Female' => 0], 'Top performers' => ['Male' => 0, 'Female' => 0], 'Trophy winners' => ['Male' => 0, 'Female' => 0], ]; $scoreTotal = 0.0; $scoredStudents = 0; foreach ($students as $studentId => $student) { $studentGender = $student['gender']; if ($studentGender !== null) { $gender[$studentGender]++; if (isset($trophyWinnerIds[$studentId])) { $trophyGender[$studentGender]++; $genderByStat['Trophy winners'][$studentGender]++; } } if ($student['scores'] === []) { if ($student['isKg']) { $results['Pass']++; $scoreBands['KG – Passed']++; if ($studentGender !== null) { $genderByStat['Passed'][$studentGender]++; } } else { $results['Awaiting results']++; } continue; } $score = array_sum($student['scores']) / count($student['scores']); $scoreTotal += $score; $scoredStudents++; $resultKey = $student['isKg'] || $score >= self::PASS_SCORE ? 'Pass' : 'Fail'; $results[$resultKey]++; if ($studentGender !== null) { $genderByStat[$resultKey === 'Pass' ? 'Passed' : 'Failed'][$studentGender]++; } if ($score >= self::TOP_PERFORMER_SCORE) { if ($studentGender !== null) { $topGender[$studentGender]++; $genderByStat['Top performers'][$studentGender]++; } } if ($student['isKg']) { $scoreBands['KG – Passed']++; } elseif ($score >= self::TOP_PERFORMER_SCORE) { $scoreBands['90–100']++; } elseif ($score >= 80) { $scoreBands['80–89']++; } elseif ($score >= 70) { $scoreBands['70–79']++; } elseif ($score >= 60) { $scoreBands['60–69']++; } else { $scoreBands['Below 60']++; } } uasort($classSizes, static fn (array $a, array $b): int => strnatcasecmp($a['label'], $b['label'])); $classLabels = []; $classValues = []; $classMaleValues = []; $classFemaleValues = []; foreach ($classSizes as $class) { $classLabels[] = $class['label']; $classValues[] = count($class['students']); $classMaleValues[] = count(array_filter( $class['students'], static fn (?string $gender): bool => $gender === 'Male' )); $classFemaleValues[] = count(array_filter( $class['students'], static fn (?string $gender): bool => $gender === 'Female' )); } $topPerformers = array_sum($topGender); $trophyWinners = array_sum($trophyGender); $passCount = $results['Pass']; return [ 'schoolYear' => $schoolYear, 'totalStudents' => count($students), 'totalClasses' => count($classSizes), 'scoredStudents' => $scoredStudents, 'averageScore' => $scoredStudents > 0 ? round($scoreTotal / $scoredStudents, 1) : null, 'passRate' => ($results['Pass'] + $results['Fail']) > 0 ? round($passCount / ($results['Pass'] + $results['Fail']) * 100, 1) : null, 'topPerformers' => $topPerformers, 'trophyWinners' => $trophyWinners, 'gender' => $gender, 'results' => $results, 'topGender' => $topGender, 'trophyGender' => $trophyGender, 'genderByStat' => $genderByStat, 'classSizes' => [ 'labels' => $classLabels, 'values' => $classValues, 'male' => $classMaleValues, 'female' => $classFemaleValues, ], 'scoreBands' => $scoreBands, ]; } private static function genderKey(mixed $gender): ?string { return match (strtolower(trim((string) $gender))) { 'male', 'm', 'boy', 'boys' => 'Male', 'female', 'f', 'girl', 'girls' => 'Female', default => null, }; } /** * Match the final trophy report: 75th percentile per class, at least three * scored winners when available, with tied scores included. */ private static function trophyThreshold(array $scores, float $percentile): ?float { $scores = array_values(array_filter($scores, static fn ($score): bool => is_numeric($score))); $scores = array_map('floatval', $scores); sort($scores); $count = count($scores); if ($count === 0) { return null; } $minimumWinners = 3; $maximumWinners = max($minimumWinners, (int) floor($count * (1 - $percentile / 100))); $index = ($percentile / 100) * ($count - 1); $lower = (int) floor($index); $upper = (int) ceil($index); $threshold = $lower === $upper ? $scores[$lower] : $scores[$lower] + ($index - $lower) * ($scores[$upper] - $scores[$lower]); $winnerCount = self::countAtOrAbove($scores, $threshold); if ($winnerCount < $minimumWinners) { $descending = array_reverse($scores); return $descending[min($minimumWinners, $count) - 1]; } if ($winnerCount <= $maximumWinners) { return $threshold; } $descending = array_reverse($scores); $threshold = $descending[$maximumWinners - 1]; $winnerCount = self::countAtOrAbove($scores, $threshold); if ($winnerCount <= $maximumWinners) { return $threshold; } $higherScores = array_values(array_unique(array_filter( $scores, static fn (float $score): bool => $score > $threshold ))); sort($higherScores); foreach ($higherScores as $candidate) { if (self::countAtOrAbove($scores, $candidate) <= $maximumWinners) { $threshold = $candidate; $winnerCount = self::countAtOrAbove($scores, $candidate); break; } } if ($winnerCount < $minimumWinners) { $descending = array_reverse($scores); return $descending[min($minimumWinners, $count) - 1]; } return $threshold; } private static function countAtOrAbove(array $scores, float $threshold): int { return count(array_filter($scores, static fn (float $score): bool => $score >= $threshold)); } }