fix financial and certificates
This commit is contained in:
@@ -0,0 +1,647 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Administrator\Trophy;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Ports the legacy CodeIgniter trophy projection + winner reports to
|
||||
* Laravel JSON payloads for the SPA/admin API.
|
||||
*/
|
||||
class TrophyReportService
|
||||
{
|
||||
public function projection(?string $schoolYear = null, ?float $percentile = null): array
|
||||
{
|
||||
$selectedYear = $this->resolveSchoolYear($schoolYear);
|
||||
$selectedPercentile = $this->sanitizePercentile($percentile);
|
||||
$sections = $this->sectionMap($this->studentRows($selectedYear));
|
||||
|
||||
$classResults = [];
|
||||
|
||||
foreach ($sections as $sectionId => $section) {
|
||||
$students = $this->sortByScore($section['students'], 'fall_score');
|
||||
$scores = array_column($students, 'fall_score');
|
||||
$calc = $this->calculateThreshold($scores, $selectedPercentile);
|
||||
$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;
|
||||
}, $students);
|
||||
|
||||
$scoredCount = count(array_filter(
|
||||
array_column($students, 'fall_score'),
|
||||
static fn ($score): bool => $score !== null
|
||||
));
|
||||
|
||||
[$boys, $girls] = $this->splitByBinaryGender($students);
|
||||
$trophyBoys = array_values(array_filter($boys, static fn (array $student): bool => ! empty($student['projected_trophy'])));
|
||||
$trophyGirls = array_values(array_filter($girls, static fn (array $student): bool => ! empty($student['projected_trophy'])));
|
||||
$studentCount = count($students);
|
||||
|
||||
$classResults[] = [
|
||||
'section_id' => $sectionId,
|
||||
'section_name' => $section['name'],
|
||||
'students' => $students,
|
||||
'threshold' => $threshold,
|
||||
'trophy_count' => $calc['winners'],
|
||||
'student_count' => $studentCount,
|
||||
'scored_count' => $scoredCount,
|
||||
'method' => $calc['method'],
|
||||
'boys' => count($boys),
|
||||
'girls' => count($girls),
|
||||
'trophy_boys' => count($trophyBoys),
|
||||
'trophy_girls' => count($trophyGirls),
|
||||
'pct_boys' => $studentCount > 0 ? round(count($boys) / $studentCount * 100) : 0,
|
||||
'pct_girls' => $studentCount > 0 ? round(count($girls) / $studentCount * 100) : 0,
|
||||
'pct_trophy_boys' => count($boys) > 0 ? round(count($trophyBoys) / count($boys) * 100) : 0,
|
||||
'pct_trophy_girls' => count($girls) > 0 ? round(count($trophyGirls) / count($girls) * 100) : 0,
|
||||
'pct_trophy_total' => $studentCount > 0 ? round($calc['winners'] / $studentCount * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'selected_year' => $selectedYear,
|
||||
'selected_percentile' => $selectedPercentile,
|
||||
'years' => $this->schoolYears(),
|
||||
'class_results' => $classResults,
|
||||
'summary' => $this->projectionSummary($classResults),
|
||||
];
|
||||
}
|
||||
|
||||
public function winners(?string $schoolYear = null, ?float $percentile = null): array
|
||||
{
|
||||
$selectedYear = $this->resolveSchoolYear($schoolYear);
|
||||
$selectedPercentile = $this->sanitizePercentile($percentile);
|
||||
$sections = $this->sectionMap($this->studentRows($selectedYear));
|
||||
|
||||
$classResults = [];
|
||||
|
||||
foreach ($sections as $sectionId => $section) {
|
||||
$students = $this->sortByScore($section['students'], 'fall_score');
|
||||
$calc = $this->calculateThreshold(array_column($students, 'fall_score'), $selectedPercentile);
|
||||
$threshold = $calc['threshold'];
|
||||
|
||||
$winners = array_values(array_filter($students, static function (array $student) use ($threshold): bool {
|
||||
return $threshold !== null
|
||||
&& $student['fall_score'] !== null
|
||||
&& $student['fall_score'] >= $threshold;
|
||||
}));
|
||||
|
||||
if ($winners === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$boys, $girls] = $this->splitByBinaryGender($students);
|
||||
[$winnerBoys, $winnerGirls] = $this->splitByBinaryGender($winners);
|
||||
$studentCount = count($students);
|
||||
|
||||
$classResults[] = [
|
||||
'section_id' => $sectionId,
|
||||
'section_name' => $section['name'],
|
||||
'threshold' => $threshold,
|
||||
'winners' => $winners,
|
||||
'student_count' => $studentCount,
|
||||
'boys' => count($boys),
|
||||
'girls' => count($girls),
|
||||
'trophy_boys' => count($winnerBoys),
|
||||
'trophy_girls' => count($winnerGirls),
|
||||
'pct_boys' => $studentCount > 0 ? round(count($boys) / $studentCount * 100) : 0,
|
||||
'pct_girls' => $studentCount > 0 ? round(count($girls) / $studentCount * 100) : 0,
|
||||
'pct_trophy_boys' => count($boys) > 0 ? round(count($winnerBoys) / count($boys) * 100) : 0,
|
||||
'pct_trophy_girls' => count($girls) > 0 ? round(count($winnerGirls) / count($girls) * 100) : 0,
|
||||
'pct_trophy_total' => $studentCount > 0 ? round(count($winners) / $studentCount * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'selected_year' => $selectedYear,
|
||||
'selected_percentile' => $selectedPercentile,
|
||||
'years' => $this->schoolYears(),
|
||||
'class_results' => $classResults,
|
||||
'summary' => $this->winnersSummary($classResults),
|
||||
];
|
||||
}
|
||||
|
||||
public function final(?string $schoolYear = null, ?float $percentile = null): array
|
||||
{
|
||||
$selectedYear = $this->resolveSchoolYear($schoolYear);
|
||||
$selectedPercentile = $this->sanitizePercentile($percentile);
|
||||
$sections = $this->sectionMap($this->studentRows($selectedYear));
|
||||
|
||||
$classResults = [];
|
||||
$allStudents = [];
|
||||
$passStudents = [];
|
||||
$winnerStudents = [];
|
||||
$winnerStickers = [];
|
||||
|
||||
foreach ($sections as $sectionId => $section) {
|
||||
$students = $section['students'];
|
||||
$fallCalc = $this->calculateThreshold(array_column($students, 'fall_score'), $selectedPercentile);
|
||||
$yearCalc = $this->calculateThreshold(array_column($students, 'year_score'), $selectedPercentile);
|
||||
$fallThreshold = $fallCalc['threshold'];
|
||||
$yearThreshold = $yearCalc['threshold'];
|
||||
|
||||
$annotated = array_map(function (array $student) use ($fallThreshold, $yearThreshold): array {
|
||||
$predicted = $fallThreshold !== null
|
||||
&& $student['fall_score'] !== null
|
||||
&& $student['fall_score'] >= $fallThreshold;
|
||||
$actual = $yearThreshold !== null
|
||||
&& $student['year_score'] !== null
|
||||
&& $student['year_score'] >= $yearThreshold;
|
||||
$status = match (true) {
|
||||
$predicted && $actual => 'confirmed',
|
||||
! $predicted && $actual => 'surprise',
|
||||
$predicted && ! $actual => 'missed',
|
||||
default => 'none',
|
||||
};
|
||||
|
||||
return $student + compact('predicted', 'actual', 'status');
|
||||
}, $students);
|
||||
|
||||
$annotated = $this->sortByScore($annotated, 'year_score');
|
||||
|
||||
foreach ($annotated as $student) {
|
||||
$allStudents[] = $student;
|
||||
|
||||
if ($student['year_score'] !== null && $student['year_score'] >= 60.0) {
|
||||
$passStudents[] = $student;
|
||||
}
|
||||
|
||||
if (! empty($student['actual'])) {
|
||||
$winnerStudents[] = $student;
|
||||
$winnerStickers[] = [
|
||||
'name' => $student['name'],
|
||||
'section' => $section['name'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$confirmed = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'confirmed'));
|
||||
$surprises = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'surprise'));
|
||||
$missed = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'missed'));
|
||||
$predictedCount = count(array_filter($annotated, static fn (array $student): bool => ! empty($student['predicted'])));
|
||||
$actualCount = count(array_filter($annotated, static fn (array $student): bool => ! empty($student['actual'])));
|
||||
$studentCount = count($annotated);
|
||||
|
||||
$classResults[] = [
|
||||
'section_id' => $sectionId,
|
||||
'section_name' => $section['name'],
|
||||
'students' => $annotated,
|
||||
'student_count' => $studentCount,
|
||||
'fall_threshold' => $fallThreshold,
|
||||
'year_threshold' => $yearThreshold,
|
||||
'predicted_count' => $predictedCount,
|
||||
'actual_count' => $actualCount,
|
||||
'confirmed' => $confirmed,
|
||||
'surprises' => $surprises,
|
||||
'missed' => $missed,
|
||||
'accuracy' => $predictedCount > 0 ? round($confirmed / $predictedCount * 100) : ($actualCount === 0 ? 100 : 0),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'selected_year' => $selectedYear,
|
||||
'selected_percentile' => $selectedPercentile,
|
||||
'years' => $this->schoolYears(),
|
||||
'class_results' => $classResults,
|
||||
'summary' => $this->finalSummary($classResults),
|
||||
'winner_gender_summary' => $this->genderBreakdown($winnerStudents),
|
||||
'all_gender_summary' => $this->genderBreakdown($allStudents),
|
||||
'pass_gender_summary' => $this->genderBreakdown($passStudents),
|
||||
'winner_stickers' => array_values(array_filter($winnerStickers, static fn (array $sticker): bool => trim((string) ($sticker['name'] ?? '')) !== '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float|int|string|null> $scores
|
||||
* @return array{threshold: float|null, winners: int, method: string}
|
||||
*/
|
||||
public function calculateThreshold(array $scores, float $percentile = 75.0): array
|
||||
{
|
||||
$scores = array_values(array_filter(
|
||||
$scores,
|
||||
static fn ($value): bool => is_numeric($value) && $value !== null
|
||||
));
|
||||
$scores = array_map('floatval', $scores);
|
||||
sort($scores);
|
||||
|
||||
$count = count($scores);
|
||||
|
||||
if ($count === 0) {
|
||||
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
|
||||
}
|
||||
|
||||
$minWinners = 3;
|
||||
$maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
|
||||
|
||||
$threshold = $this->empiricalPercentile($scores, $percentile);
|
||||
$winners = $this->countAtOrAbove($scores, $threshold);
|
||||
|
||||
if ($winners < $minWinners) {
|
||||
$target = min($minWinners, $count);
|
||||
$descending = array_reverse($scores);
|
||||
$threshold = $descending[$target - 1];
|
||||
$winners = $this->countAtOrAbove($scores, $threshold);
|
||||
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
|
||||
}
|
||||
|
||||
if ($winners <= $maxWinners) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
|
||||
}
|
||||
|
||||
$result = $this->capByRank($scores, $maxWinners);
|
||||
|
||||
if ($result['winners'] < $minWinners) {
|
||||
$target = min($minWinners, $count);
|
||||
$descending = array_reverse($scores);
|
||||
$threshold = $descending[$target - 1];
|
||||
$winners = $this->countAtOrAbove($scores, $threshold);
|
||||
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function resolveSchoolYear(?string $schoolYear): string
|
||||
{
|
||||
$selected = trim((string) $schoolYear);
|
||||
|
||||
if ($selected !== '') {
|
||||
return $selected;
|
||||
}
|
||||
|
||||
return (string) (Configuration::getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
private function sanitizePercentile(?float $percentile): float
|
||||
{
|
||||
$value = $percentile ?? 75.0;
|
||||
|
||||
return max(1.0, min(99.0, $value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function schoolYears(): array
|
||||
{
|
||||
return DB::table('student_class')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
->orderBy('school_year', 'DESC')
|
||||
->pluck('school_year')
|
||||
->filter(static fn ($value): bool => $value !== null && trim((string) $value) !== '')
|
||||
->map(static fn ($value): string => (string) $value)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function studentRows(string $schoolYear): Collection
|
||||
{
|
||||
$fallScores = DB::table('semester_scores')
|
||||
->select(
|
||||
'student_id',
|
||||
'class_section_id',
|
||||
DB::raw('MAX(semester_score) AS fall_score')
|
||||
)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereRaw('LOWER(semester) = ?', ['fall'])
|
||||
->groupBy('student_id', 'class_section_id');
|
||||
|
||||
$springScores = DB::table('semester_scores')
|
||||
->select(
|
||||
'student_id',
|
||||
'class_section_id',
|
||||
DB::raw('MAX(semester_score) AS spring_score')
|
||||
)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereRaw('LOWER(semester) = ?', ['spring'])
|
||||
->groupBy('student_id', 'class_section_id');
|
||||
|
||||
return DB::table('student_class as sc')
|
||||
->select(
|
||||
'sc.student_id',
|
||||
'sc.class_section_id',
|
||||
'cs.class_section_name',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.school_id',
|
||||
's.gender',
|
||||
DB::raw('MAX(sf.fall_score) AS fall_score'),
|
||||
DB::raw('MAX(sp.spring_score) AS spring_score')
|
||||
)
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->leftJoin('students as s', 's.id', '=', 'sc.student_id')
|
||||
->leftJoinSub($fallScores, 'sf', function ($join): void {
|
||||
$join->on('sf.student_id', '=', 'sc.student_id')
|
||||
->on('sf.class_section_id', '=', 'sc.class_section_id');
|
||||
})
|
||||
->leftJoinSub($springScores, 'sp', function ($join): void {
|
||||
$join->on('sp.student_id', '=', 'sc.student_id')
|
||||
->on('sp.class_section_id', '=', 'sc.class_section_id');
|
||||
})
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->groupBy(
|
||||
'sc.student_id',
|
||||
'sc.class_section_id',
|
||||
'cs.class_section_name',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.school_id',
|
||||
's.gender'
|
||||
)
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->get()
|
||||
->map(function (object $row): array {
|
||||
$student = (array) $row;
|
||||
$fall = is_numeric($student['fall_score'] ?? null) ? round((float) $student['fall_score'], 1) : null;
|
||||
$spring = is_numeric($student['spring_score'] ?? null) ? round((float) $student['spring_score'], 1) : null;
|
||||
$year = ($fall !== null && $spring !== null)
|
||||
? round(($fall + $spring) / 2, 1)
|
||||
: ($fall ?? $spring);
|
||||
$firstname = trim((string) ($student['firstname'] ?? ''));
|
||||
$lastname = trim((string) ($student['lastname'] ?? ''));
|
||||
$name = trim($firstname.' '.$lastname);
|
||||
|
||||
return [
|
||||
'student_id' => (int) ($student['student_id'] ?? 0),
|
||||
'class_section_id' => (int) ($student['class_section_id'] ?? 0),
|
||||
'section_name' => (string) ($student['class_section_name'] ?? ''),
|
||||
'name' => $name !== '' ? $name : 'Student #'.($student['student_id'] ?? ''),
|
||||
'firstname' => $firstname !== '' ? $firstname : null,
|
||||
'lastname' => $lastname !== '' ? $lastname : null,
|
||||
'school_id' => $student['school_id'] ?? null,
|
||||
'gender' => (string) ($student['gender'] ?? ''),
|
||||
'fall_score' => $fall,
|
||||
'spring_score' => $spring,
|
||||
'year_score' => $year,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, array<string, mixed>> $rows
|
||||
* @return array<int, array{name: string, students: list<array<string, mixed>>}>
|
||||
*/
|
||||
private function sectionMap(Collection $rows): array
|
||||
{
|
||||
$sections = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$sectionId = (int) ($row['class_section_id'] ?? 0);
|
||||
$name = trim((string) ($row['section_name'] ?? '')) ?: 'Section '.$sectionId;
|
||||
|
||||
if (! isset($sections[$sectionId])) {
|
||||
$sections[$sectionId] = [
|
||||
'name' => $name,
|
||||
'students' => [],
|
||||
];
|
||||
}
|
||||
|
||||
unset($row['section_name']);
|
||||
$sections[$sectionId]['students'][] = $row;
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $students
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sortByScore(array $students, string $scoreKey): array
|
||||
{
|
||||
usort($students, static function (array $left, array $right) use ($scoreKey): int {
|
||||
$leftScore = $left[$scoreKey] ?? null;
|
||||
$rightScore = $right[$scoreKey] ?? null;
|
||||
|
||||
if ($leftScore === null && $rightScore === null) {
|
||||
return strcmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
}
|
||||
|
||||
if ($leftScore === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($rightScore === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return $rightScore <=> $leftScore ?: strcmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
});
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $students
|
||||
* @return array{0: list<array<string, mixed>>, 1: list<array<string, mixed>>}
|
||||
*/
|
||||
private function splitByBinaryGender(array $students): array
|
||||
{
|
||||
$boys = array_values(array_filter($students, fn (array $student): bool => $this->genderKey($student['gender'] ?? '') === 'boys'));
|
||||
$girls = array_values(array_filter($students, fn (array $student): bool => $this->genderKey($student['gender'] ?? '') === 'girls'));
|
||||
|
||||
return [$boys, $girls];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $classResults
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
private function projectionSummary(array $classResults): array
|
||||
{
|
||||
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||
$totalScored = array_sum(array_column($classResults, 'scored_count'));
|
||||
$totalTrophies = array_sum(array_column($classResults, 'trophy_count'));
|
||||
$totalBoys = array_sum(array_column($classResults, 'boys'));
|
||||
$totalGirls = array_sum(array_column($classResults, 'girls'));
|
||||
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
|
||||
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
|
||||
|
||||
return [
|
||||
'classes' => count($classResults),
|
||||
'students' => $totalStudents,
|
||||
'scored' => $totalScored,
|
||||
'trophies' => $totalTrophies,
|
||||
'boys' => $totalBoys,
|
||||
'girls' => $totalGirls,
|
||||
'trophy_boys' => $totalTrophyBoys,
|
||||
'trophy_girls' => $totalTrophyGirls,
|
||||
'pct_scored' => $totalStudents > 0 ? round($totalScored / $totalStudents * 100) : 0,
|
||||
'pct_boys' => $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0,
|
||||
'pct_girls' => $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0,
|
||||
'pct_trophies' => $totalStudents > 0 ? round($totalTrophies / $totalStudents * 100) : 0,
|
||||
'pct_trophy_boys' => $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0,
|
||||
'pct_trophy_girls' => $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $classResults
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
private function winnersSummary(array $classResults): array
|
||||
{
|
||||
$totalWinners = array_sum(array_map(static fn (array $classResult): int => count($classResult['winners'] ?? []), $classResults));
|
||||
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||
$totalBoys = array_sum(array_column($classResults, 'boys'));
|
||||
$totalGirls = array_sum(array_column($classResults, 'girls'));
|
||||
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
|
||||
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
|
||||
|
||||
return [
|
||||
'classes' => count($classResults),
|
||||
'students' => $totalStudents,
|
||||
'winners' => $totalWinners,
|
||||
'boys' => $totalBoys,
|
||||
'girls' => $totalGirls,
|
||||
'trophy_boys' => $totalTrophyBoys,
|
||||
'trophy_girls' => $totalTrophyGirls,
|
||||
'pct_winners' => $totalStudents > 0 ? round($totalWinners / $totalStudents * 100) : 0,
|
||||
'pct_boys' => $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0,
|
||||
'pct_girls' => $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0,
|
||||
'pct_trophy_boys' => $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0,
|
||||
'pct_trophy_girls' => $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $classResults
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
private function finalSummary(array $classResults): array
|
||||
{
|
||||
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||
$totalPredicted = array_sum(array_column($classResults, 'predicted_count'));
|
||||
$totalActual = array_sum(array_column($classResults, 'actual_count'));
|
||||
$totalConfirmed = array_sum(array_column($classResults, 'confirmed'));
|
||||
$totalSurprises = array_sum(array_column($classResults, 'surprises'));
|
||||
$totalMissed = array_sum(array_column($classResults, 'missed'));
|
||||
$overallAccuracy = $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : ($totalActual === 0 ? 100 : 0);
|
||||
|
||||
return [
|
||||
'classes' => count($classResults),
|
||||
'students' => $totalStudents,
|
||||
'predicted' => $totalPredicted,
|
||||
'actual' => $totalActual,
|
||||
'confirmed' => $totalConfirmed,
|
||||
'surprises' => $totalSurprises,
|
||||
'missed' => $totalMissed,
|
||||
'accuracy' => $overallAccuracy,
|
||||
'pct_predicted' => $totalStudents > 0 ? round($totalPredicted / $totalStudents * 100) : 0,
|
||||
'pct_actual' => $totalStudents > 0 ? round($totalActual / $totalStudents * 100) : 0,
|
||||
'pct_confirmed_of_predicted' => $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $students
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
private function genderBreakdown(array $students): array
|
||||
{
|
||||
$stats = ['boys' => 0, 'girls' => 0, 'other' => 0];
|
||||
|
||||
foreach ($students as $student) {
|
||||
$stats[$this->genderKey($student['gender'] ?? '')]++;
|
||||
}
|
||||
|
||||
$total = array_sum($stats);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'boys' => $stats['boys'],
|
||||
'girls' => $stats['girls'],
|
||||
'other' => $stats['other'],
|
||||
'pct_boys' => $total > 0 ? round(($stats['boys'] / $total) * 100, 1) : 0.0,
|
||||
'pct_girls' => $total > 0 ? round(($stats['girls'] / $total) * 100, 1) : 0.0,
|
||||
'pct_other' => $total > 0 ? round(($stats['other'] / $total) * 100, 1) : 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
private function genderKey(?string $gender): string
|
||||
{
|
||||
$value = strtolower(trim((string) $gender));
|
||||
|
||||
return match (true) {
|
||||
in_array($value, ['male', 'm', 'boy', 'boys'], true) => 'boys',
|
||||
in_array($value, ['female', 'f', 'girl', 'girls'], true) => 'girls',
|
||||
default => 'other',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float> $sortedScores
|
||||
* @return array{threshold: float, winners: int, method: string}
|
||||
*/
|
||||
private function capByRank(array $sortedScores, int $max): array
|
||||
{
|
||||
$descending = array_reverse($sortedScores);
|
||||
$threshold = $descending[$max - 1];
|
||||
$winners = $this->countAtOrAbove($sortedScores, $threshold);
|
||||
|
||||
if ($winners <= $max) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
|
||||
}
|
||||
|
||||
$uniqueCandidates = array_values(array_unique(array_filter(
|
||||
$sortedScores,
|
||||
static fn (float $score): bool => $score > $threshold
|
||||
)));
|
||||
sort($uniqueCandidates);
|
||||
|
||||
foreach ($uniqueCandidates as $candidate) {
|
||||
$winnerCount = $this->countAtOrAbove($sortedScores, $candidate);
|
||||
|
||||
if ($winnerCount <= $max) {
|
||||
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float> $sortedScores
|
||||
*/
|
||||
private function empiricalPercentile(array $sortedScores, float $percentile): float
|
||||
{
|
||||
$count = count($sortedScores);
|
||||
|
||||
if ($count === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$index = ($percentile / 100.0) * ($count - 1);
|
||||
$lower = (int) floor($index);
|
||||
$upper = (int) ceil($index);
|
||||
|
||||
if ($lower === $upper) {
|
||||
return $sortedScores[$lower];
|
||||
}
|
||||
|
||||
return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float> $scores
|
||||
*/
|
||||
private 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