464 lines
18 KiB
PHP
464 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Scheduling;
|
|
|
|
use App\Services\Scheduling\Data\SchedulingDataset;
|
|
|
|
/**
|
|
* GeneticAlgorithmEngine bertanggung jawab mengorganisir alur eksekusi populasi GA:
|
|
* Inisialisasi -> Evaluasi -> Seleksi Tournament -> Crossover -> Mutasi -> Elitisme.
|
|
*/
|
|
class GeneticAlgorithmEngine
|
|
{
|
|
private FitnessEvaluator $fitnessEvaluator;
|
|
|
|
public function __construct(
|
|
private int $populationSize = 50,
|
|
private float $crossoverRate = 0.8,
|
|
private float $mutationRate = 0.1,
|
|
private int $maxGenerations = 500,
|
|
private int $tournamentSize = 3,
|
|
private int $elitismCount = 2,
|
|
private ?int $seed = null
|
|
) {
|
|
$this->fitnessEvaluator = new FitnessEvaluator();
|
|
}
|
|
|
|
/**
|
|
* Jalankan proses GA untuk mencari solusi jadwal terbaik.
|
|
*/
|
|
public function run(SchedulingDataset $dataset): array
|
|
{
|
|
if ($this->seed !== null) {
|
|
mt_srand($this->seed);
|
|
}
|
|
|
|
$startTime = microtime(true);
|
|
|
|
// 1. Inisialisasi Populasi Awal
|
|
$population = $this->initializePopulation($dataset);
|
|
|
|
// Evaluasi Populasi Awal
|
|
foreach ($population as $chromosome) {
|
|
$this->fitnessEvaluator->evaluate($chromosome, $dataset);
|
|
}
|
|
|
|
$bestChromosome = $this->getBestChromosome($population);
|
|
$generationCount = 0;
|
|
$generationLogs = [];
|
|
|
|
// Loop Generasi
|
|
while ($generationCount < $this->maxGenerations && $bestChromosome->fitness < 1.0) {
|
|
$currentGenNumber = $generationCount; // Generasi saat ini (0, 1, 2, dst.)
|
|
$generationCount++;
|
|
$newPopulation = [];
|
|
$sampleSelectionLogs = [];
|
|
$sampleCrossoverLogs = [];
|
|
$sampleMutationLogs = [];
|
|
|
|
// Elitisme: Ambil n individu terbaik tanpa perubahan
|
|
usort($population, fn(Chromosome $a, Chromosome $b) => $b->fitness <=> $a->fitness);
|
|
for ($i = 0; $i < $this->elitismCount; $i++) {
|
|
$newPopulation[] = $population[$i]->clone();
|
|
}
|
|
|
|
// Reproduksi hingga populasi baru penuh
|
|
while (count($newPopulation) < $this->populationSize) {
|
|
// Seleksi Tournament (k=3)
|
|
[$parent1, $tLog1] = $this->tournamentSelectWithLog($population);
|
|
[$parent2, $tLog2] = $this->tournamentSelectWithLog($population);
|
|
|
|
if (count($sampleSelectionLogs) < 2) {
|
|
$sampleSelectionLogs[] = $tLog1;
|
|
$sampleSelectionLogs[] = $tLog2;
|
|
}
|
|
|
|
// Crossover
|
|
$rCross = round(mt_rand() / mt_getrandmax(), 4);
|
|
if ($rCross <= $this->crossoverRate) {
|
|
[$child1, $child2, $swappedKelas] = $this->crossoverWithLog($parent1, $parent2);
|
|
if (count($sampleCrossoverLogs) < 2) {
|
|
$kelasStr = !empty($swappedKelas) ? implode(', ', array_map(fn($k) => "K{$k}", array_slice($swappedKelas, 0, 5))) : 'K01';
|
|
$sampleCrossoverLogs[] = "Uniform Crossover (r={$rCross} <= Pc {$this->crossoverRate}): Menukarkan alokasi gen kelas [{$kelasStr}...] antara Induk 1 & Induk 2 -> Menghasilkan Anak 1 & 2.";
|
|
}
|
|
} else {
|
|
$child1 = $parent1->clone();
|
|
$child2 = $parent2->clone();
|
|
if (count($sampleCrossoverLogs) < 2) {
|
|
$sampleCrossoverLogs[] = "Crossover Dilewati (r={$rCross} > Pc {$this->crossoverRate}): Anak mengklon penuh gen dari Induk.";
|
|
}
|
|
}
|
|
|
|
// Mutasi
|
|
$mutLog1 = $this->mutateWithLog($child1, $dataset);
|
|
$mutLog2 = $this->mutateWithLog($child2, $dataset);
|
|
|
|
if (!empty($mutLog1) && count($sampleMutationLogs) < 2) {
|
|
$sampleMutationLogs[] = "Mutasi Anak 1: " . $mutLog1;
|
|
}
|
|
if (!empty($mutLog2) && count($sampleMutationLogs) < 2) {
|
|
$sampleMutationLogs[] = "Mutasi Anak 2: " . $mutLog2;
|
|
}
|
|
|
|
// Evaluasi Fitness Anak
|
|
$this->fitnessEvaluator->evaluate($child1, $dataset);
|
|
$this->fitnessEvaluator->evaluate($child2, $dataset);
|
|
|
|
$newPopulation[] = $child1;
|
|
if (count($newPopulation) < $this->populationSize) {
|
|
$newPopulation[] = $child2;
|
|
}
|
|
}
|
|
|
|
$operatorSamples = [
|
|
'selection' => $sampleSelectionLogs,
|
|
'crossover' => $sampleCrossoverLogs,
|
|
'mutation' => empty($sampleMutationLogs) ? ["Mutasi dilewati (tidak ada gen acak yang memenuhi rate Pm = {$this->mutationRate})."] : $sampleMutationLogs,
|
|
];
|
|
|
|
// Simpan log generasi saat ini (termasuk Generasi 0) beserta sampel operator yang memprosesnya
|
|
if ($currentGenNumber <= 10 || $currentGenNumber % 5 === 0) {
|
|
$generationLogs[] = $this->captureGenerationLog($currentGenNumber, $bestChromosome, $population, $dataset, $operatorSamples);
|
|
}
|
|
|
|
$population = $newPopulation;
|
|
$bestChromosome = $this->getBestChromosome($population);
|
|
}
|
|
|
|
// Simpan log untuk generasi terakhir (Final Solusi)
|
|
$finalOperatorSamples = [
|
|
'isFinal' => true,
|
|
'reason' => $bestChromosome->fitness == 1.0 ? "Solusi Optimal (Fitness = 1.0) telah ditemukan. Kriteria Penghentian Terpenuhi." : "Batas Maksimum Generasi ({$this->maxGenerations}) telah dicapai."
|
|
];
|
|
$generationLogs[] = $this->captureGenerationLog($generationCount, $bestChromosome, $population, $dataset, $finalOperatorSamples);
|
|
|
|
$executionTime = round(microtime(true) - $startTime, 3);
|
|
$memoryUsage = round(memory_get_usage() / 1024, 2); // KiloBytes
|
|
$lastLog = !empty($generationLogs) ? end($generationLogs) : null;
|
|
$avgFitness = $lastLog['avgFitness'] ?? 0;
|
|
|
|
return [
|
|
'bestChromosome' => $bestChromosome,
|
|
'generations' => $generationCount,
|
|
'executionTime' => $executionTime,
|
|
'memoryUsage' => $memoryUsage,
|
|
'avgFitness' => $avgFitness,
|
|
'isOptimal' => $bestChromosome->fitness == 1.0,
|
|
'generationLogs' => $generationLogs,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Membuat snapshot log generasi untuk analisis visual persis seperti gambar acuan skripsi
|
|
*/
|
|
/**
|
|
* Membuat snapshot log generasi untuk analisis visual persis seperti gambar acuan skripsi
|
|
*/
|
|
private function captureGenerationLog(int $gen, Chromosome $best, array $population, SchedulingDataset $dataset, array $operatorSamples = []): array
|
|
{
|
|
$namaHariMap = [1 => 'Senin', 2 => 'Selasa', 3 => 'Rabu', 4 => 'Kamis', 5 => 'Jumat', 6 => 'Sabtu', 7 => 'Minggu'];
|
|
$individus = [];
|
|
$fitnesses = [];
|
|
|
|
foreach ($population as $idx => $ind) {
|
|
$fitnesses[] = $ind->fitness;
|
|
|
|
// Analisis per gen individu untuk deteksi kode pelanggaran B1..B7
|
|
$guruSlotMap = [];
|
|
$ruanganSlotMap = [];
|
|
$kelasSlotMap = [];
|
|
$kelasGenesMap = [];
|
|
$geneViolations = [];
|
|
|
|
foreach ($ind->genes as $gIdx => $g) {
|
|
$violations = [];
|
|
$kelas = $dataset->getKelas($g->kelasId);
|
|
$guru = $dataset->getGuru($g->guruId);
|
|
|
|
// B1: Bentrok Guru
|
|
$guruSlotKey = "{$g->guruId}_{$g->hariId}_{$g->sesiId}";
|
|
if (isset($guruSlotMap[$guruSlotKey])) {
|
|
$violations[] = 'B1';
|
|
} else {
|
|
$guruSlotMap[$guruSlotKey] = true;
|
|
}
|
|
|
|
// B2: Bentrok Ruangan (Reguler)
|
|
if (!$kelas->isEkstrakurikuler) {
|
|
$ruanganSlotKey = "{$g->ruanganId}_{$g->hariId}_{$g->sesiId}";
|
|
if (isset($ruanganSlotMap[$ruanganSlotKey])) {
|
|
$violations[] = 'B2';
|
|
} else {
|
|
$ruanganSlotMap[$ruanganSlotKey] = true;
|
|
}
|
|
}
|
|
|
|
// B3: Ketersediaan Guru
|
|
if (!$guru->tersediaAt(new \App\Services\Scheduling\Data\SchedulingSlot($g->hariId, $g->sesiId))) {
|
|
$violations[] = 'B3';
|
|
}
|
|
|
|
// B4: Bentrok Kelas
|
|
$kelasSlotKey = "{$g->kelasId}_{$g->hariId}_{$g->sesiId}";
|
|
if (isset($kelasSlotMap[$kelasSlotKey])) {
|
|
$violations[] = 'B4';
|
|
} else {
|
|
$kelasSlotMap[$kelasSlotKey] = true;
|
|
}
|
|
|
|
// B5: Kualifikasi Level
|
|
if ($guru->levelMaksimal < $kelas->level) {
|
|
$violations[] = 'B5';
|
|
}
|
|
|
|
$kelasGenesMap[$g->kelasId][] = ['gene' => $g, 'index' => $gIdx];
|
|
$geneViolations[$gIdx] = $violations;
|
|
}
|
|
|
|
// B6 & B7
|
|
foreach ($kelasGenesMap as $kelasId => $gItems) {
|
|
if (count($gItems) == 2) {
|
|
$g1 = $gItems[0]['gene']; $idx1 = $gItems[0]['index'];
|
|
$g2 = $gItems[1]['gene']; $idx2 = $gItems[1]['index'];
|
|
|
|
// B6: Sebaran Hari
|
|
$selisihHari = abs($g1->hariId - $g2->hariId);
|
|
if ($g1->hariId == $g2->hariId || $selisihHari < 2) {
|
|
$geneViolations[$idx1][] = 'B6';
|
|
$geneViolations[$idx2][] = 'B6';
|
|
}
|
|
|
|
// B7: Konsistensi Guru
|
|
if ($g1->guruId != $g2->guruId) {
|
|
$geneViolations[$idx1][] = 'B7';
|
|
$geneViolations[$idx2][] = 'B7';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Format Array Gen dengan Penanda Pelanggaran Terperinci
|
|
$genesFormatted = [];
|
|
foreach ($ind->genes as $gIdx => $g) {
|
|
$hariStr = $namaHariMap[$g->hariId] ?? 'Hari_' . $g->hariId;
|
|
$vCode = !empty($geneViolations[$gIdx]) ? $geneViolations[$gIdx][0] : null; // Ambil kode penalti pertama
|
|
$genesFormatted[] = [
|
|
'text' => "[K:{$g->kelasId}, G:{$g->guruId}, R:{$g->ruanganId}, S:{$g->sesiId}, {$hariStr}_{$g->indexPertemuan}]",
|
|
'violation' => $vCode
|
|
];
|
|
}
|
|
|
|
$individus[] = [
|
|
'index' => $idx,
|
|
'fitness' => round($ind->fitness, 4),
|
|
'penalty' => $ind->totalPenalty,
|
|
'genesFormatted' => $genesFormatted
|
|
];
|
|
}
|
|
|
|
$bestGenString = [];
|
|
foreach ($best->genes as $g) {
|
|
$hariStr = $namaHariMap[$g->hariId] ?? 'Hari_' . $g->hariId;
|
|
$bestGenString[] = "[K:{$g->kelasId}, G:{$g->guruId}, R:{$g->ruanganId}, S:{$g->sesiId}, {$hariStr}_{$g->indexPertemuan}]";
|
|
}
|
|
|
|
$minFitness = round(min($fitnesses), 4);
|
|
$maxFitness = round(max($fitnesses), 4);
|
|
$avgFitness = round(array_sum($fitnesses) / count($fitnesses), 4);
|
|
|
|
return [
|
|
'generation' => $gen,
|
|
'fitness' => round($best->fitness, 4),
|
|
'minFitness' => $minFitness,
|
|
'maxFitness' => $maxFitness,
|
|
'avgFitness' => $avgFitness,
|
|
'penalty' => $best->totalPenalty,
|
|
'breakdown' => $best->penaltyBreakdown,
|
|
'bestIndividuString' => implode(',', $bestGenString),
|
|
'individus' => $individus,
|
|
'operatorSamples' => $operatorSamples,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Tournament Selection (k=3) dengan pencatatan log
|
|
*/
|
|
private function tournamentSelectWithLog(array $population): array
|
|
{
|
|
$tournament = [];
|
|
$details = [];
|
|
for ($i = 0; $i < $this->tournamentSize; $i++) {
|
|
$randIdx = array_rand($population);
|
|
$ind = $population[$randIdx];
|
|
$tournament[] = $ind;
|
|
$details[] = "Individu[{$randIdx}] (Fitness: " . round($ind->fitness, 4) . ")";
|
|
}
|
|
$winner = $this->getBestChromosome($tournament);
|
|
$logStr = "Adu Turnamen k=3: " . implode(" VS ", $details) . " -> Winner: Fitness " . round($winner->fitness, 4);
|
|
return [$winner, $logStr];
|
|
}
|
|
|
|
/**
|
|
* Inisialisasi populasi acak
|
|
*/
|
|
private function initializePopulation(SchedulingDataset $dataset): array
|
|
{
|
|
$population = [];
|
|
for ($i = 0; $i < $this->populationSize; $i++) {
|
|
$genes = [];
|
|
foreach ($dataset->kelasList as $kelas) {
|
|
// Tentukan berapa gen untuk kelas ini
|
|
for ($pertemuan = 1; $pertemuan <= $kelas->frekuensiPerMinggu; $pertemuan++) {
|
|
if ($kelas->isEkstrakurikuler) {
|
|
// Kunci slot (H, S, R) sesuai fix, GA cari Guru acak
|
|
$hariId = $kelas->hariFix;
|
|
$sesiId = $kelas->sesiFix;
|
|
$ruanganId = $kelas->ruanganFixId;
|
|
} else {
|
|
// Reguler: Slot (H, S) & Ruangan diacak
|
|
$slot = $dataset->allSlots[array_rand($dataset->allSlots)];
|
|
$hariId = $slot->hari;
|
|
$sesiId = $slot->sesi;
|
|
$ruanganId = $dataset->ruanganRobotkuIds[array_rand($dataset->ruanganRobotkuIds)];
|
|
}
|
|
|
|
// Pilih guru yang kualifikasi level-nya memenuhi (levelMaksimal >= levelKelas)
|
|
$guruIds = array_keys(array_filter(
|
|
$dataset->guruList,
|
|
fn($g) => $g->levelMaksimal >= $kelas->level
|
|
));
|
|
if (empty($guruIds)) {
|
|
$guruIds = array_keys($dataset->guruList);
|
|
}
|
|
$guruId = $guruIds[array_rand($guruIds)];
|
|
|
|
$genes[] = new Gene(
|
|
kelasId: $kelas->id,
|
|
indexPertemuan: $pertemuan,
|
|
guruId: $guruId,
|
|
hariId: $hariId,
|
|
sesiId: $sesiId,
|
|
ruanganId: $ruanganId
|
|
);
|
|
}
|
|
}
|
|
$population[] = new Chromosome($genes);
|
|
}
|
|
return $population;
|
|
}
|
|
|
|
/**
|
|
* Tournament Selection (k=3)
|
|
*/
|
|
private function tournamentSelect(array $population): Chromosome
|
|
{
|
|
$tournament = [];
|
|
for ($i = 0; $i < $this->tournamentSize; $i++) {
|
|
$tournament[] = $population[array_rand($population)];
|
|
}
|
|
return $this->getBestChromosome($tournament);
|
|
}
|
|
|
|
/**
|
|
* Crossover (Uniform Crossover per Kelas untuk menjaga konsistensi B7)
|
|
*/
|
|
private function crossoverWithLog(Chromosome $parent1, Chromosome $parent2): array
|
|
{
|
|
$child1Genes = [];
|
|
$child2Genes = [];
|
|
$swappedKelas = [];
|
|
|
|
// Grouping per kelasId
|
|
$genesByKelas1 = [];
|
|
$genesByKelas2 = [];
|
|
foreach ($parent1->genes as $g) { $genesByKelas1[$g->kelasId][] = $g; }
|
|
foreach ($parent2->genes as $g) { $genesByKelas2[$g->kelasId][] = $g; }
|
|
|
|
foreach ($genesByKelas1 as $kelasId => $gList1) {
|
|
$gList2 = $genesByKelas2[$kelasId];
|
|
if (mt_rand(0, 1) === 1) {
|
|
foreach ($gList1 as $g) { $child1Genes[] = $g->clone(); }
|
|
foreach ($gList2 as $g) { $child2Genes[] = $g->clone(); }
|
|
$swappedKelas[] = $kelasId;
|
|
} else {
|
|
foreach ($gList2 as $g) { $child1Genes[] = $g->clone(); }
|
|
foreach ($gList1 as $g) { $child2Genes[] = $g->clone(); }
|
|
}
|
|
}
|
|
|
|
return [new Chromosome($child1Genes), new Chromosome($child2Genes), $swappedKelas];
|
|
}
|
|
|
|
/**
|
|
* Resetting Mutation dengan Smart Repair B7 (Konsistensi Guru)
|
|
*/
|
|
private function mutateWithLog(Chromosome $chromosome, SchedulingDataset $dataset): string
|
|
{
|
|
$namaHariMap = [1 => 'Senin', 2 => 'Selasa', 3 => 'Rabu', 4 => 'Kamis', 5 => 'Jumat', 6 => 'Sabtu', 7 => 'Minggu'];
|
|
$mutatedDetails = [];
|
|
|
|
// Mutasi acak gen
|
|
foreach ($chromosome->genes as $gene) {
|
|
$r = round(mt_rand() / mt_getrandmax(), 4);
|
|
if ($r <= $this->mutationRate) {
|
|
$kelas = $dataset->getKelas($gene->kelasId);
|
|
$oldGuru = $gene->guruId;
|
|
$oldHari = $namaHariMap[$gene->hariId] ?? 'Hari_'.$gene->hariId;
|
|
$oldSesi = $gene->sesiId;
|
|
|
|
// Ganti Guru dari yang qualified
|
|
$guruIds = array_keys(array_filter(
|
|
$dataset->guruList,
|
|
fn($g) => $g->levelMaksimal >= $kelas->level
|
|
));
|
|
if (!empty($guruIds)) {
|
|
$gene->guruId = $guruIds[array_rand($guruIds)];
|
|
}
|
|
|
|
// Jika kelas reguler, acak ulang (H, S, R)
|
|
if (!$kelas->isEkstrakurikuler) {
|
|
$slot = $dataset->allSlots[array_rand($dataset->allSlots)];
|
|
$gene->hariId = $slot->hari;
|
|
$gene->sesiId = $slot->sesi;
|
|
$gene->ruanganId = $dataset->ruanganRobotkuIds[array_rand($dataset->ruanganRobotkuIds)];
|
|
}
|
|
|
|
$newHari = $namaHariMap[$gene->hariId] ?? 'Hari_'.$gene->hariId;
|
|
$mutatedDetails[] = "Gen [K:{$gene->kelasId}, G:{$oldGuru}] dimutasi (r={$r} <= Pm {$this->mutationRate}) -> G:{$gene->guruId}, Slot: {$oldHari}_S{$oldSesi} diubah ke {$newHari}_S{$gene->sesiId}";
|
|
}
|
|
}
|
|
|
|
// Smart Fix B7 (Menyamakan guru pengajar untuk kelas 2x/minggu)
|
|
$kelasMap = [];
|
|
foreach ($chromosome->genes as $g) {
|
|
$kelasMap[$g->kelasId][] = $g;
|
|
}
|
|
foreach ($kelasMap as $genes) {
|
|
if (count($genes) == 2) {
|
|
if ($genes[0]->guruId != $genes[1]->guruId) {
|
|
$genes[1]->guruId = $genes[0]->guruId;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($mutatedDetails)) {
|
|
return "Mutasi dilewati pada individu ini (tidak ada gen acak dengan r <= Pm {$this->mutationRate}). Smart repair B7 aktif.";
|
|
}
|
|
|
|
return implode(' | ', array_slice($mutatedDetails, 0, 2)) . " + Smart Repair B7";
|
|
}
|
|
|
|
/**
|
|
* Ambil kromosom terbaik dari populasi
|
|
*/
|
|
private function getBestChromosome(array $population): Chromosome
|
|
{
|
|
$best = $population[0];
|
|
foreach ($population as $ind) {
|
|
if ($ind->fitness > $best->fitness) {
|
|
$best = $ind;
|
|
}
|
|
}
|
|
return $best;
|
|
}
|
|
}
|