Files
alrahma_sunday_school/app/Services/AcademicStatisticsService.php
T
root c3a30989b2
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 51s
Tests / PHPUnit (push) Successful in 1m25s
add new ststs feature
2026-09-13 02:20:14 -04:00

316 lines
12 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
namespace App\Services;
use CodeIgniter\Database\BaseConnection;
class AcademicStatisticsService
{
public const PASS_SCORE = 60.0;
public const TOP_PERFORMER_SCORE = 90.0;
public const TROPHY_PERCENTILE = 75.0;
private BaseConnection $db;
public function __construct(?BaseConnection $db = null)
{
$this->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, '6069' => 0, '7079' => 0, '8089' => 0, '90100' => 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['90100']++;
} elseif ($score >= 80) {
$scoreBands['8089']++;
} elseif ($score >= 70) {
$scoreBands['7079']++;
} elseif ($score >= 60) {
$scoreBands['6069']++;
} 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));
}
}