add new ststs feature
This commit is contained in:
@@ -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, '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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user