diff --git a/__pycache__/mutation.cpython-312.pyc b/__pycache__/mutation.cpython-312.pyc new file mode 100644 index 0000000..228de8f Binary files /dev/null and b/__pycache__/mutation.cpython-312.pyc differ diff --git a/__pycache__/utils.cpython-312.pyc b/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000..d8b3cb0 Binary files /dev/null and b/__pycache__/utils.cpython-312.pyc differ diff --git a/__pycache__/utils.cpython-313.pyc b/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000..d83a8ed Binary files /dev/null and b/__pycache__/utils.cpython-313.pyc differ diff --git a/ga/__init__.py b/ga/__init__.py new file mode 100644 index 0000000..aae359d --- /dev/null +++ b/ga/__init__.py @@ -0,0 +1,9 @@ +"""GA package: operators, population, evaluator for Rute SPKLU problem.""" +from .operators import selection, tournament_selection, get_elites, bcrc_crossover, apply_mutation, apply_mutation_advanced +from .evaluator import fitness_function +from .population import generate_random_population + +__all__ = [ + 'selection', 'tournament_selection', 'get_elites', + 'bcrc_crossover', 'apply_mutation', 'apply_mutation_advanced', 'fitness_function', 'generate_random_population' +] diff --git a/ga/__pycache__/__init__.cpython-312.pyc b/ga/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..eab3458 Binary files /dev/null and b/ga/__pycache__/__init__.cpython-312.pyc differ diff --git a/ga/__pycache__/__init__.cpython-313.pyc b/ga/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..5e3a7f4 Binary files /dev/null and b/ga/__pycache__/__init__.cpython-313.pyc differ diff --git a/ga/__pycache__/evaluator.cpython-312.pyc b/ga/__pycache__/evaluator.cpython-312.pyc new file mode 100644 index 0000000..b9b0877 Binary files /dev/null and b/ga/__pycache__/evaluator.cpython-312.pyc differ diff --git a/ga/__pycache__/evaluator.cpython-313.pyc b/ga/__pycache__/evaluator.cpython-313.pyc new file mode 100644 index 0000000..e75ba8b Binary files /dev/null and b/ga/__pycache__/evaluator.cpython-313.pyc differ diff --git a/ga/__pycache__/operators.cpython-312.pyc b/ga/__pycache__/operators.cpython-312.pyc new file mode 100644 index 0000000..1af318c Binary files /dev/null and b/ga/__pycache__/operators.cpython-312.pyc differ diff --git a/ga/__pycache__/operators.cpython-313.pyc b/ga/__pycache__/operators.cpython-313.pyc new file mode 100644 index 0000000..9688b24 Binary files /dev/null and b/ga/__pycache__/operators.cpython-313.pyc differ diff --git a/ga/__pycache__/population.cpython-312.pyc b/ga/__pycache__/population.cpython-312.pyc new file mode 100644 index 0000000..004bd32 Binary files /dev/null and b/ga/__pycache__/population.cpython-312.pyc differ diff --git a/ga/__pycache__/population.cpython-313.pyc b/ga/__pycache__/population.cpython-313.pyc new file mode 100644 index 0000000..7ed259b Binary files /dev/null and b/ga/__pycache__/population.cpython-313.pyc differ diff --git a/ga/evaluator.py b/ga/evaluator.py new file mode 100644 index 0000000..ed9c1e4 --- /dev/null +++ b/ga/evaluator.py @@ -0,0 +1,59 @@ +"""Fitness evaluator: simulates battery and charging for delimiter-flat chromosomes.""" +from typing import List, Dict, Tuple, Any +from utils import split_routes, get_distance, INFEASIBLE_PENALTY + + +def fitness_function(chromosome: List[str], distance_matrix: Dict[Tuple[str, str], float], + battery_capacity: float = 100.0, consumption_rate: float = 1.0, + charging_rate: float = 1.0, w_distance: float = 0.6, w_charging: float = 0.4) -> float: + """Compute fitness: weighted sum of total distance and charging time. + + Returns a smaller-is-better fitness. Infeasible solutions get a large penalty. + Chromosome encoding: delimiter-flat list with 'S*' representing charging stations, + 'D*' depots and 'C*' customers. + """ + total_distance = 0.0 + total_charging_time = 0.0 + + routes = split_routes(chromosome) + + for route in routes: + battery = battery_capacity + for i in range(len(route) - 1): + a, b = route[i], route[i + 1] + dist = get_distance(a, b, distance_matrix) + if dist == float('inf'): + return INFEASIBLE_PENALTY + total_distance += dist + needed_energy = dist * consumption_rate + + # If current node is charging station, charge minimally to reach next station/depot + if a.startswith('S'): + # lookahead to next S or D + energy_needed_to_next = 0.0 + for j in range(i, len(route) - 1): + u, v = route[j], route[j + 1] + d2 = get_distance(u, v, distance_matrix) + if d2 == float('inf'): + return INFEASIBLE_PENALTY + energy_needed_to_next += d2 * consumption_rate + if route[j + 1].startswith('S') or route[j + 1].startswith('D'): + break + required_energy = max(0.0, energy_needed_to_next - battery) + charge_time = required_energy / charging_rate + total_charging_time += charge_time + battery += required_energy + if battery > battery_capacity: + battery = battery_capacity + + if battery < needed_energy: + # insufficient battery to travel (invalid) + return INFEASIBLE_PENALTY + + battery -= needed_energy + + # normalization + D = total_distance / 100.0 + C = total_charging_time / 100.0 + fitness = w_distance * D + w_charging * C + return fitness diff --git a/ga/operators.py b/ga/operators.py new file mode 100644 index 0000000..9578b7b --- /dev/null +++ b/ga/operators.py @@ -0,0 +1,129 @@ +"""Selection, crossover and mutation operators for GA. + +Functions expect delimiter-flat chromosome encoding (list[str]) where '|' separates routes. +""" +from typing import List, Tuple, Dict, Any +import random +import copy +from utils import split_routes, join_routes, get_distance + + +def get_elites(population: List[List[str]], fitness_scores: List[float], elite_size: int) -> List[List[str]]: + paired = list(zip(population, fitness_scores)) + paired.sort(key=lambda x: x[1]) + return [p for p, _ in paired[:elite_size]] + + +def tournament_selection(population: List[List[str]], fitness_scores: List[float], tournament_size: int) -> List[str]: + indices = random.sample(range(len(population)), k=min(tournament_size, len(population))) + best = None + best_score = float('inf') + for i in indices: + if fitness_scores[i] < best_score: + best_score = fitness_scores[i] + best = population[i] + return best + + +def selection(population: List[List[str]], fitness_scores: List[float], elite_size: int, tournament_size: int) -> List[List[str]]: + parents: List[List[str]] = [] + parents.extend(get_elites(population, fitness_scores, elite_size)) + remaining = len(population) - elite_size + for _ in range(remaining): + parents.append(tournament_selection(population, fitness_scores, tournament_size)) + return parents + + +# ---- BCRC crossover (from notebook) adapted ---- +def remove_customer_routes(routes: List[List[str]], customer: str) -> List[List[str]]: + return [[x for x in r if x != customer] for r in routes] + + +def insertion_cost(route: List[str], customer: str, dist: Dict[Tuple[str, str], float]) -> Tuple[float, int]: + if len(route) < 2: + return 0.0, 0 + best_cost = float('inf') + best_pos = 0 + for i in range(len(route) - 1): + a, b = route[i], route[i+1] + cost_before = get_distance(a, b, dist) + cost_after = get_distance(a, customer, dist) + get_distance(customer, b, dist) + delta = cost_after - cost_before + if delta < best_cost: + best_cost = delta + best_pos = i + 1 + return best_cost, best_pos + + +def bcrc_crossover(chrom1: List[str], chrom2: List[str], dist: Dict[Tuple[str, str], float]) -> List[str]: + p1 = copy.deepcopy(chrom1) + p2 = copy.deepcopy(chrom2) + routes1 = split_routes(p1) + routes2 = split_routes(p2) + all_customers = [x for r in routes1 for x in r if x.startswith('C')] + if not all_customers: + return chrom2.copy() + customer = random.choice(all_customers) + child_routes = remove_customer_routes(routes2, customer) + best_global_cost = float('inf') + best_route_idx = None + best_insert_pos = None + for idx, route in enumerate(child_routes): + if len(route) < 2: + continue + cost, pos = insertion_cost(route, customer, dist) + if cost < best_global_cost: + best_global_cost = cost + best_route_idx = idx + best_insert_pos = pos + if best_route_idx is not None: + child_routes[best_route_idx].insert(best_insert_pos, customer) + else: + for idx, route in enumerate(child_routes): + if len(route) >= 2: + child_routes[idx].insert(1, customer) + break + return join_routes(child_routes) + + +# ---- Mutation (simpler variant) ---- +def apply_mutation(chromosome: List[str], mutation_rate: float = 0.1) -> List[str]: + # simple swap mutation within a route + if random.random() > mutation_rate: + return chromosome + routes = split_routes(chromosome) + # pick a random route with at least two customers (excluding depots) + route_idx = None + for _ in range(5): + i = random.randrange(len(routes)) + customers = [x for x in routes[i] if x.startswith('C')] + if len(customers) >= 2: + route_idx = i + break + if route_idx is None: + return chromosome + # perform swap on customer positions within chosen route + route = routes[route_idx] + cust_positions = [i for i, g in enumerate(route) if g.startswith('C')] + a, b = random.sample(cust_positions, 2) + route[a], route[b] = route[b], route[a] + routes[route_idx] = route + return join_routes(routes) + + +# ---- Advanced mutation wrapper (uses legacy `mutation.py` implementation) ---- +def apply_mutation_advanced(chromosome: List[str], depot_locations: Dict[str, tuple], customer_locations: Dict[str, tuple], mutation_probability: float = 0.1, beta: float = 0.2) -> List[str]: + """Use existing sophisticated mutation logic from `mutation.py` if available. + + This function imports the original `apply_mutation` from `mutation.py` which + implements inter-depot and intra-depot strategies. It provides a thin wrapper + so callers can choose between the simple mutation above or the advanced one. + """ + try: + import mutation as mut + except Exception: + # fallback to simple mutation if module not importable + return apply_mutation(chromosome, mutation_rate=mutation_probability) + + # the legacy mutation expects depot/customer dicts and returns a new chromosome + return mut.apply_mutation(chromosome, depot_locations, customer_locations, mutation_probability=mutation_probability, beta=beta) diff --git a/ga/population.py b/ga/population.py new file mode 100644 index 0000000..689cd42 --- /dev/null +++ b/ga/population.py @@ -0,0 +1,32 @@ +"""Population generation utilities for GA. + +This module contains a small random population generator that creates delimiter-flat +chromosomes for use in smoke runs. For production, replace with the more advanced +generator in `populasi_awal_final.py` adapted to delimiter encoding. +""" +from typing import List, Dict, Any +import random +from utils import flatten_from_nested + + +def simple_route_from_depot_customers(depot: str, customers: List[str]) -> List[str]: + # simple route: depot -> customers in order -> depot + return [depot] + customers + [depot] + + +def generate_random_population(depots: List[str], customers: List[str], population_size: int = 10) -> List[List[str]]: + population: List[List[str]] = [] + for _ in range(population_size): + remaining = customers.copy() + random.shuffle(remaining) + # split into random chunks (random number of routes) + num_routes = random.randint(1, max(1, min(len(depots), len(customers)))) + # split customers roughly evenly + chunk_size = max(1, len(customers) // num_routes) + routes = [] + for i in range(0, len(customers), chunk_size): + chunk = remaining[i:i+chunk_size] + depot = random.choice(depots) + routes.append(simple_route_from_depot_customers(depot, chunk)) + population.append(flatten_from_nested(routes)) + return population diff --git a/main.py b/main.py new file mode 100644 index 0000000..4ec691e --- /dev/null +++ b/main.py @@ -0,0 +1,143 @@ +"""Minimal GA runner (smoke test) for Rute SPKLU problem. + +This script builds a tiny synthetic problem and runs a few GA generations to +demonstrate end-to-end wiring of utils, ga.population, ga.operators and ga.evaluator. +""" +from ga.population import generate_random_population +from ga.operators import selection, bcrc_crossover, apply_mutation +from ga.evaluator import fitness_function +from utils import build_distance_matrix, load_nodes_from_csv + +import argparse +import random +import os + + +def synthetic_nodes(): + # create small synthetic nodes. IDs: D1, S1, C1..C5 + nodes = { + 'D1': {'x': 0.0, 'y': 0.0, 'demand': 0}, + 'S1': {'x': 5.0, 'y': 0.0, 'demand': 0}, + 'C1': {'x': 2.0, 'y': 1.0, 'demand': 1}, + 'C2': {'x': 2.5, 'y': -1.0, 'demand': 1}, + 'C3': {'x': 4.0, 'y': 1.5, 'demand': 1}, + 'C4': {'x': -1.0, 'y': -0.5, 'demand': 1}, + 'C5': {'x': -2.0, 'y': 1.0, 'demand': 1}, + } + return nodes + + +def run_smoke(): + random.seed(1) + nodes = synthetic_nodes() + dist = build_distance_matrix(nodes) + + depots = ['D1'] + customers = ['C1', 'C2', 'C3', 'C4', 'C5'] + + pop = generate_random_population(depots, customers, population_size=8) + + generations = 20 + elite_size = 1 + tournament_size = 3 + + best = None + best_score = float('inf') + + for g in range(generations): + fitnesses = [fitness_function(ch, dist) for ch in pop] + # track best + for ch, f in zip(pop, fitnesses): + if f < best_score: + best_score = f + best = ch + + print(f"Gen {g}: best={best_score:.6f}") + + parents = selection(pop, fitnesses, elite_size=elite_size, tournament_size=tournament_size) + + # crossover + next_pop = [] + while len(next_pop) < len(pop): + a = random.choice(parents) + b = random.choice(parents) + child = bcrc_crossover(a, b, dist) + child = apply_mutation(child, mutation_rate=0.2) + next_pop.append(child) + + pop = next_pop + + print('\nBest chromosome:', best) + print('Best fitness:', best_score) + + +def run_from_csv(csv_path: str): + """Run GA using nodes loaded from a CSV file. + + The loader expects node IDs in the CSV that start with 'D' for depot, + 'S' for charging stations and 'C' for customers. If those prefixes are + not present, the function will attempt to infer depots/customers heuristically. + """ + nodes = load_nodes_from_csv(csv_path) + if not nodes: + print(f"No nodes loaded from {csv_path}; falling back to synthetic dataset.") + return run_smoke() + + dist = build_distance_matrix(nodes) + + # infer roles by prefix (D=depot, S=spklu, C=customer) + depots = [n for n in nodes.keys() if str(n).upper().startswith('D')] + spklus = [n for n in nodes.keys() if str(n).upper().startswith('S')] + customers = [n for n in nodes.keys() if str(n).upper().startswith('C')] + + # fallback heuristics + if not depots: + # if no D* found, take first node as depot + depots = [next(iter(nodes.keys()))] + if not customers: + # all non-depot/non-spklu nodes are customers + customers = [n for n in nodes.keys() if n not in depots + spklus] + + print(f"Using {len(depots)} depots, {len(spklus)} SPKLU, {len(customers)} customers from {csv_path}") + + pop = generate_random_population(depots, customers, population_size=20) + + generations = 50 + elite_size = 2 + tournament_size = 3 + + best = None + best_score = float('inf') + + for g in range(generations): + fitnesses = [fitness_function(ch, dist) for ch in pop] + for ch, f in zip(pop, fitnesses): + if f < best_score: + best_score = f + best = ch + print(f"Gen {g}: best={best_score:.6f}") + parents = selection(pop, fitnesses, elite_size=elite_size, tournament_size=tournament_size) + next_pop = [] + while len(next_pop) < len(pop): + a = random.choice(parents) + b = random.choice(parents) + child = bcrc_crossover(a, b, dist) + child = apply_mutation(child, mutation_rate=0.2) + next_pop.append(child) + pop = next_pop + + print('\nBest chromosome:', best) + print('Best fitness:', best_score) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Run GA for Rute SPKLU (smoke or real CSV)') + parser.add_argument('--csv', '-c', help='Path to nodes CSV (optional). If omitted, runs synthetic smoke example.') + args = parser.parse_args() + + if args.csv and os.path.exists(args.csv): + run_from_csv(args.csv) + else: + if args.csv: + print(f"CSV path provided but not found: {args.csv}. Running synthetic smoke instead.") + run_smoke() diff --git a/mutation.py b/mutation.py index c21674b..e6a21f5 100644 --- a/mutation.py +++ b/mutation.py @@ -1,6 +1,10 @@ import random +import logging -def apply_mutation(chromosome, depot_locations, customer_locations, mutation_probability=0.1, beta=0.2): +logger = logging.getLogger(__name__) + + +def apply_mutation(chromosome, depot_locations, customer_locations, mutation_probability=0.1, beta=0.2, verbose: bool = False): """ Fungsi memilih antara inter-depot atau intra-depot parameter: @@ -14,10 +18,12 @@ def apply_mutation(chromosome, depot_locations, customer_locations, mutation_pro border_customers = find_border_customers(chromosome, depot_locations, customer_locations, beta) if border_customers and random.random() < 0.7: - print(" [MUTASI] Melakukan Inter-Depot Mutation") + if verbose: + logger.info("[MUTASI] Melakukan Inter-Depot Mutation") return inter_depot_mutation(chromosome, border_customers, depot_locations) else: - print(" [MUTASI] Melakukan Intra-Depot Mutation") + if verbose: + logger.info("[MUTASI] Melakukan Intra-Depot Mutation") return intra_depot_mutation(chromosome) def find_border_customers(chromosome, depot_locations, customer_locations, beta): @@ -59,15 +65,15 @@ def inter_depot_mutation(chromosome, border_customers, depot_locations): if not candidate_depots: return chromosome - + # Pilih depot tujuan secara random dari candidate depots new_depot = random.choice(candidate_depots) - - print(f" [INTER-DEPOT] Memindahkan {customer} dari {selected['current_depot']} ke {new_depot}") - + + logger.info("[INTER-DEPOT] Memindahkan %s dari %s ke %s", customer, selected['current_depot'], new_depot) + # Lakukan reassignment customer ke depot barucls new_chromosome = reassign_customer_to_depot(chromosome, customer, selected['current_depot'], new_depot) - + return new_chromosome def intra_depot_mutation(chromosome): @@ -94,13 +100,14 @@ def intra_depot_mutation(chromosome): # Swap mutation - tukar dua posisi random pos1, pos2 = random.sample(range(len(customers)), 2) customers[pos1], customers[pos2] = customers[pos2], customers[pos1] - print(f" [INTRA-DEPOT] Swap {customers[pos2]} dan {customers[pos1]} dalam {selected_route['depot']}") + logger.info("[INTRA-DEPOT] Swap %s dan %s dalam %s", customers[pos2], customers[pos1], selected_route['depot']) elif mutation_type == 'inversion' and len(customers) >= 2: # Inversion mutation - balik urutan segmen start, end = sorted(random.sample(range(len(customers)), 2)) - customers[start:end+1] = reversed(customers[start:end+1]) - print(f" [INTRA-DEPOT] Inversion posisi {start}-{end} dalam {selected_route['depot']}") + # reverse slice in place using slicing + customers[start:end+1] = customers[start:end+1][::-1] + logger.info("[INTRA-DEPOT] Inversion posisi %d-%d dalam %s", start, end, selected_route['depot']) elif mutation_type == 'insertion' and len(customers) >= 2: # Insertion mutation - pindah customer ke posisi lain @@ -112,7 +119,7 @@ def intra_depot_mutation(chromosome): customer_moved = customers.pop(from_pos) customers.insert(to_pos, customer_moved) - print(f" [INTRA-DEPOT] Insert {customer_moved} dari pos {from_pos} ke {to_pos} dalam {selected_route['depot']}") + logger.info("[INTRA-DEPOT] Insert %s dari pos %d ke %d dalam %s", customer_moved, from_pos, to_pos, selected_route['depot']) # Update full_route selected_route['full_route'] = [selected_route['depot']] + customers + [selected_route['depot']] diff --git a/populasi_awal_final.py b/populasi_awal_final.py index 5b87031..8e71f02 100644 --- a/populasi_awal_final.py +++ b/populasi_awal_final.py @@ -169,6 +169,21 @@ def generate_initial_population(config, pop_size=10, use_spklu=True): return population + + def generate_initial_population_flat(config, pop_size=10, use_spklu=True): + """Wrapper: returns delimiter-flat chromosomes by flattening nested routes. + + This preserves the original generator behavior while providing the + delimiter-flat format used by the GA modules (e.g. ['D1','C1','D1','|',...]). + """ + from utils import flatten_from_nested + + nested = generate_initial_population(config, pop_size=pop_size, use_spklu=use_spklu) + flat_pop = [] + for chrom in nested: + flat_pop.append(flatten_from_nested(chrom)) + return flat_pop + # ----- MAIN ----- if __name__ == "__main__": print("=" * 80) diff --git a/selection.py b/selection.py index 106e0c2..b831fe5 100644 --- a/selection.py +++ b/selection.py @@ -1,119 +1,29 @@ -import random +"""Compatibility wrapper for selection functions. -def get_elites(population, fitness_scores, elite_size): - """ - Membantu 'selection': Memilih individu terbaik (elites) dari populasi. - Berdasarkan skor fitness, di mana nilai yang lebih rendah lebih baik. - """ - # Pasangkan setiap individu dengan skor fitness-nya - population_with_fitness = list(zip(population, fitness_scores)) - - # Urutkan berdasarkan fitness (ascending, karena lebih rendah lebih baik) - sorted_population = sorted(population_with_fitness, key=lambda x: x[1]) - - # Ambil 'elite_size' individu terbaik - elites = [individual for individual, fitness in sorted_population[:elite_size]] - - return elites +This file preserves the original names `get_elites`, `run_tournament`, and +`selection` for scripts that imported them directly. Internally we delegate to +the new implementations in `ga.operators` to keep a single source of truth. +""" +from ga.operators import get_elites, tournament_selection as run_tournament, selection -def run_tournament(population, fitness_scores, tournament_size): - """ - Membantu 'selection': Menjalankan satu putaran tournament selection. - Memilih 'tournament_size' individu secara acak dan mengembalikan - yang terbaik (skor fitness terendah) dari kelompok tersebut. - """ - # Pilih indeks secara acak untuk turnamen - tournament_indices = random.sample(range(len(population)), tournament_size) - - # Siapkan variabel untuk melacak pemenang - best_individual = None - best_fitness = float('inf') # 'inf' karena kita mencari nilai minimum +__all__ = ['get_elites', 'run_tournament', 'selection'] - # Loop melalui peserta turnamen - for index in tournament_indices: - individual = population[index] - fitness = fitness_scores[index] - - # Jika individu ini lebih baik dari pemenang saat ini, jadikan dia pemenang - if fitness < best_fitness: - best_fitness = fitness - best_individual = individual - - return best_individual -# --- FUNGSI UTAMA SELECTION --- -def selection(population, fitness_scores, elite_size, tournament_size): - """ - Melakukan proses seleksi utama menggabungkan Elitism dan Tournament Selection - untuk memilih orang tua (parents) untuk generasi berikutnya. - - Args: - population (list): Daftar semua kromosom di populasi saat ini. - fitness_scores (list): Daftar skor fitness yang sesuai dengan 'population'. - elite_size (int): Jumlah individu terbaik yang akan lolos (Elitism). - tournament_size (int): Jumlah individu yang bertarung di setiap turnamen. - - Returns: - list: Daftar orang tua baru yang terpilih (new_parents). - """ - new_parents = [] - - # [cite_start]1. Elitism: Pertahankan individu terbaik secara langsung [cite: 208, 211] - # Fungsi ini mengambil 'elite_size' individu terbaik - elites = get_elites(population, fitness_scores, elite_size) - new_parents.extend(elites) - - # [cite_start]2. Tournament Selection: Isi sisa populasi [cite: 203, 211] - # Kita perlu memilih 'len(population) - elite_size' individu lagi - num_to_select = len(population) - elite_size - for _ in range(num_to_select): - # Jalankan turnamen untuk memilih satu orang tua - parent = run_tournament(population, fitness_scores, tournament_size) - new_parents.append(parent) - - return new_parents - -# --- CONTOH PENGGUNAAN --- if __name__ == "__main__": - - # Ini adalah POPULASI TIRUAN (DUMMY) + # minimal demo using the operators module + import random DUMMY_POPULATION = [ - ['D1', 'C1', 'S1', 'C2', 'D2'], # Kromosom 1 - ['D2', 'C3', 'C4', 'D1'], # Kromosom 2 - ['D1', 'C5', 'D1'], # Kromosom 3 - ['D2', 'C1', 'C3', 'S1', 'C2', 'C4', 'D2'], # Kromosom 4 - ['D1', 'C4', 'C2', 'S1', 'C1', 'C3', 'D1'] # Kromosom 5 - ] - - # Ini adalah SKOR FITNESS TIRUAN (DUMMY) - # [cite_start](Nilai LEBIH RENDAH = LEBIH BAIK, sesuai problem statement 'minimize' [cite: 34]) - DUMMY_FITNESS_SCORES = [ - 150.5, # Fitness untuk Kromosom 1 - 120.2, # Fitness untuk Kromosom 2 (Terbaik) - 210.0, # Fitness untuk Kromosom 3 - 185.7, # Fitness untuk Kromosom 4 - 135.9 # Fitness untuk Kromosom 5 (Kedua terbaik) + ['D1', 'C1', 'S1', 'C2', 'D2'], + ['D2', 'C3', 'C4', 'D1'], + ['D1', 'C5', 'D1'], + ['D2', 'C1', 'C3', 'S1', 'C2', 'C4', 'D2'], + ['D1', 'C4', 'C2', 'S1', 'C1', 'C3', 'D1'] ] - - # Parameter untuk GA - ELITE_SIZE = 1 # Jumlah individu terbaik yang langsung dipertahankan [cite: 208] - TOURNAMENT_SIZE = 2 # Jumlah individu yang bersaing di setiap turnamen [cite: 205] - - print("--- POPULASI AWAL & FITNESS ---") - for i in range(len(DUMMY_POPULATION)): - print(f"Fitness: {DUMMY_FITNESS_SCORES[i]:.1f} | Kromosom: {DUMMY_POPULATION[i]}") - - # === PANGGIL FUNGSI SELECTION === - selected_parents = selection( - DUMMY_POPULATION, - DUMMY_FITNESS_SCORES, - ELITE_SIZE, - TOURNAMENT_SIZE - ) - - print(f"\n--- ORANG TUA TERPILIH (Ukuran: {len(selected_parents)}) ---") - print(f"(Menggunakan Elitism: {ELITE_SIZE} & Tournament Size: {TOURNAMENT_SIZE})") - - # Tampilkan hasil - for i, parent in enumerate(selected_parents): - print(f"Orang Tua {i+1}: {parent}") \ No newline at end of file + DUMMY_FITNESS_SCORES = [150.5, 120.2, 210.0, 185.7, 135.9] + ELITE_SIZE = 1 + TOURNAMENT_SIZE = 2 + + selected_parents = selection(DUMMY_POPULATION, DUMMY_FITNESS_SCORES, ELITE_SIZE, TOURNAMENT_SIZE) + print(f"Selected parents (count={len(selected_parents)}):") + for p in selected_parents: + print(p) \ No newline at end of file diff --git a/tests/test_utils_and_ops.py b/tests/test_utils_and_ops.py new file mode 100644 index 0000000..14abd13 --- /dev/null +++ b/tests/test_utils_and_ops.py @@ -0,0 +1,41 @@ +import unittest +import random +import sys +import os + +# ensure project root is on sys.path for imports when running tests directly +PROJECT_ROOT = os.path.dirname(os.path.dirname(__file__)) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + +from utils import split_routes, join_routes +from ga.operators import selection +import mutation + + +class TestUtilsAndOperators(unittest.TestCase): + def test_split_join_roundtrip(self): + chrom = ['D1', 'C1', 'C2', 'D1', '|', 'D2', 'C3', 'D2'] + routes = split_routes(chrom) + self.assertEqual(join_routes(routes), chrom) + + def test_selection_basic(self): + pop = [['a'], ['b'], ['c']] + fitness = [10.0, 5.0, 7.0] + parents = selection(pop, fitness, elite_size=1, tournament_size=2) + # elites preserved + self.assertIn(['b'], parents) + self.assertEqual(len(parents), len(pop)) + + def test_reassign_customer_to_depot(self): + chrom = ['D1', 'C1', 'D1', '|', 'D2', 'C2', 'D2'] + # move C1 from D1 to D2 + new = mutation.reassign_customer_to_depot(chrom, 'C1', 'D1', 'D2') + self.assertIn('C1', new) + # ensure delimiter encoding preserved + self.assertIsInstance(new, list) + + +if __name__ == '__main__': + random.seed(1) + unittest.main() diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..d16ebf9 --- /dev/null +++ b/utils.py @@ -0,0 +1,218 @@ +"""Utilities for Genetic Algorithm MDVRP / SPKLU project. + +This module centralizes helpers used across selection, mutation, crossover, +population generation and fitness evaluation: +- loading nodes from CSV +- building a canonical distance matrix (tuple-keyed) +- flexible distance lookup (supports dict-of-dict and tuple-keyed) +- chromosome encoding/decoding helpers (delimiter '|' encoding) +- small constants (penalty) + +Keep functions pure and well-typed so other modules can import them. +""" +from __future__ import annotations + +import csv +import math +from dataclasses import dataclass +from typing import Dict, Tuple, List, Any, Optional + +# Penalty used for infeasible solutions (minimization problem) +INFEASIBLE_PENALTY = 1e6 + + +def load_nodes_from_csv(filename: str, delimiter: str = ';') -> Dict[str, Dict[str, Any]]: + """Load nodes from a CSV file. + + Returns a mapping node_id -> {'x': float, 'y': float, 'demand': int} + Node id is returned as string (preserves values like 'D1' or numeric indices). + The CSV is expected to have headers that include at least columns named + 'index' (or 'id'), 'x', 'y' and optionally 'Demand' or 'demand'. The function + is permissive and will attempt to parse common formats. + """ + nodes: Dict[str, Dict[str, Any]] = {} + with open(filename, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f, delimiter=delimiter) + for row in reader: + # find id column + node_id = None + for k in ('index', 'id', 'node', 'name'): + if k in row and row[k] not in (None, ''): + node_id = str(row[k]).strip() + break + + if node_id is None: + # try first column + try: + node_id = str(next(iter(row.values()))).strip() + except StopIteration: + continue + + try: + x_raw = row.get('x') or row.get('X') or row.get('lon') or row.get('longitude') + y_raw = row.get('y') or row.get('Y') or row.get('lat') or row.get('latitude') + x = float(str(x_raw).replace(',', '.')) + y = float(str(y_raw).replace(',', '.')) + except Exception: + # skip malformed rows + continue + + demand_raw = row.get('Demand') or row.get('demand') or row.get('d') or 0 + try: + demand = int(str(demand_raw)) + except Exception: + try: + demand = int(float(str(demand_raw).replace(',', '.'))) + except Exception: + demand = 0 + + nodes[node_id] = {'x': x, 'y': y, 'demand': demand} + + return nodes + + +def build_distance_matrix(nodes: Dict[str, Dict[str, Any]], symmetric: bool = True) -> Dict[Tuple[str, str], float]: + """Build a tuple-keyed distance matrix from nodes mapping. + + nodes: mapping node_id -> {'x': float, 'y': float, ...} + Returns dict[(a,b)] -> euclidean_distance + If symmetric=True, (a,b) == (b,a) distance is stored by computing once. + """ + dist: Dict[Tuple[str, str], float] = {} + keys = list(nodes.keys()) + for i, a in enumerate(keys): + ax, ay = nodes[a]['x'], nodes[a]['y'] + for j in range(i + 1, len(keys)): + b = keys[j] + bx, by = nodes[b]['x'], nodes[b]['y'] + d = math.hypot(ax - bx, ay - by) + dist[(a, b)] = d + if symmetric: + dist[(b, a)] = d + + # self distance + dist[(a, a)] = 0.0 + + return dist + + +def get_distance(a: str, b: str, distance_matrix: Any, default: float = float('inf')) -> float: + """Retrieve distance between a and b from flexible distance_matrix. + + Supports two common shapes: + - dict with tuple keys, e.g. distance_matrix[(a,b)] = 12.3 + - nested dict, e.g. distance_matrix[a][b] = 12.3 + + Returns default if no entry found. + """ + if a == b: + return 0.0 + + # tuple-keyed dict + try: + if (a, b) in distance_matrix: + return distance_matrix[(a, b)] + except Exception: + pass + + # nested dict-like + try: + if a in distance_matrix and b in distance_matrix[a]: + return distance_matrix[a][b] + if b in distance_matrix and a in distance_matrix[b]: + return distance_matrix[b][a] + except Exception: + pass + + return default + + +def split_routes(chromosome: List[str], delimiter: str = '|') -> List[List[str]]: + """Split a delimiter-flat chromosome into list of routes. + + Example: ['D1','C1','D1','|','D2','C2','D2'] -> [['D1','C1','D1'], ['D2','C2','D2']] + """ + routes: List[List[str]] = [] + cur: List[str] = [] + for gene in chromosome: + if gene == delimiter: + if cur: + routes.append(cur.copy()) + cur = [] + else: + cur.append(gene) + if cur: + routes.append(cur.copy()) + return routes + + +def join_routes(routes: List[List[str]], delimiter: str = '|') -> List[str]: + """Join nested routes into delimiter-flat chromosome. + + Example: [['D1','C1','D1'], ['D2','C2','D2']] -> ['D1','C1','D1','|','D2','C2','D2'] + """ + chrom: List[str] = [] + for i, r in enumerate(routes): + if i > 0: + chrom.append(delimiter) + chrom.extend(r) + return chrom + + +def flatten_from_nested(chromosome_nested: List[List[str]], delimiter: str = '|') -> List[str]: + """Alias to join_routes for clarity.""" + return join_routes(chromosome_nested, delimiter=delimiter) + + +def nested_from_flat(chromosome_flat: List[str], delimiter: str = '|') -> List[List[str]]: + """Alias to split_routes for clarity.""" + return split_routes(chromosome_flat, delimiter=delimiter) + + +def rebuild_chromosome_from_routes(routes: List[dict]) -> List[str]: + """Compatibility helper used by older code: expects routes as dict with 'full_route'. + + Each route dict should include 'full_route' which is a list of nodes. + Returns delimiter-flat chromosome. + """ + chrom: List[str] = [] + for i, route in enumerate(routes): + if i > 0: + chrom.append('|') + chrom.extend(route.get('full_route', [])) + return chrom + + +def euclidean_distance(a: Tuple[float, float], b: Tuple[float, float]) -> float: + """Simple 2D Euclidean distance between coordinate tuples.""" + return math.hypot(a[0] - b[0], a[1] - b[1]) + + +@dataclass +class GAConfig: + population_size: int = 50 + generations: int = 200 + elite_size: int = 2 + tournament_size: int = 3 + crossover_rate: float = 0.8 + mutation_rate: float = 0.1 + battery_capacity: float = 100.0 + consumption_rate: float = 1.0 + charging_rate: float = 1.0 + w_distance: float = 0.6 + w_charging: float = 0.4 + + +__all__ = [ + 'load_nodes_from_csv', + 'build_distance_matrix', + 'get_distance', + 'split_routes', + 'join_routes', + 'flatten_from_nested', + 'nested_from_flat', + 'rebuild_chromosome_from_routes', + 'euclidean_distance', + 'INFEASIBLE_PENALTY', + 'GAConfig', +] diff --git a/utlis.py b/utlis.py index 864f462..172c913 100644 --- a/utlis.py +++ b/utlis.py @@ -1,11 +1,22 @@ -def load_data(file_path): - # import pandas as pd - # data = pd.read_csv(file_path) - # return data - data=[] - return data +"""Compatibility shim for older code that imported `utlis`. -def get_dist(a,b): # euclidean distance - return a+b +This file used to contain minimal helper stubs. We now forward the common +helpers to `utils.py` so older imports keep working while the canonical +implementations live in `utils.py`. +""" +from typing import Any +from utils import load_nodes_from_csv, build_distance_matrix, get_distance + +def load_data(file_path: str, delimiter: str = ';') -> Any: + """Backward-compatible wrapper around `utils.load_nodes_from_csv`. + + Returns the same mapping produced by `load_nodes_from_csv`. + """ + return load_nodes_from_csv(file_path, delimiter=delimiter) + + +def get_dist(a: str, b: str, distance_matrix: Any): + """Wrapper around `utils.get_distance` for legacy calls.""" + return get_distance(a, b, distance_matrix) \ No newline at end of file