add new ststs feature
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 51s
Tests / PHPUnit (push) Successful in 1m25s

This commit is contained in:
root
2026-09-13 02:20:14 -04:00
parent 1787144f27
commit c3a30989b2
16 changed files with 1395 additions and 84 deletions
+315
View File
@@ -0,0 +1,315 @@
<?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));
}
}