diff --git a/energy_system_modelling_package/energy_system_modelling_factories/system_sizing_methods/genetic_algorithm/genetic_algorithm.py b/energy_system_modelling_package/energy_system_modelling_factories/system_sizing_methods/genetic_algorithm/genetic_algorithm.py index 5a9d6a68..069db871 100644 --- a/energy_system_modelling_package/energy_system_modelling_factories/system_sizing_methods/genetic_algorithm/genetic_algorithm.py +++ b/energy_system_modelling_package/energy_system_modelling_factories/system_sizing_methods/genetic_algorithm/genetic_algorithm.py @@ -10,55 +10,178 @@ from energy_system_modelling_package.energy_system_modelling_factories.system_si class GeneticAlgorithm: - def __init__(self, population_size=100, generations=50, crossover_rate=0.8, mutation_rate=0.1, + def __init__(self, population_size=50, generations=50, crossover_rate=0.8, mutation_rate=0.1, optimization_scenario='cost', output_path=None): self.population_size = population_size + self.population = [] self.generations = generations self.crossover_rate = crossover_rate self.mutation_rate = mutation_rate self.optimization_scenario = optimization_scenario self.list_of_solutions = [] + self.best_solution = None + self.best_solution_generation = None self.output_path = output_path def energy_system_archetype_optimal_sizing(self): pass # Initialize Population - def initialize_population(self, building, energy_system, heating_design_load=None, cooling_design_load=None, - available_space=None, dt=1800, fuel_price_index=0.05, electricity_tariff_type='fixed', - consumer_price_index=0.04, interest_rate=0.04, discount_rate=0.03, percentage_credit=0, - credit_years=15): + def initialize_population(self, building, energy_system): """ The population to optimize the sizes of generation and storage components of any energy system are optimized. The initial population will be a list of individuals where each of them represent a possible solution to the problem. + :param building: :param energy_system: :return: List """ - population = [] for i in range(self.population_size): - population.append(Individual(building, energy_system, self.optimization_scenario, - heating_design_load=heating_design_load, cooling_design_load=cooling_design_load, - available_space=available_space, dt=dt, fuel_price_index=fuel_price_index, - electricity_tariff_type=electricity_tariff_type, - consumer_price_index=consumer_price_index, interest_rate=interest_rate, - discount_rate=discount_rate, percentage_credit=percentage_credit, - credit_years=credit_years)) - return population + self.population.append(Individual(building, energy_system, self.optimization_scenario)) + + def order_population(self): + """ + ordering the population based on the fitness score in ascending order + :return: + """ + self.population = sorted(self.population, key=lambda x: x.individual['fitness_score']) + + def tournament_selection(self): + selected = [] + for _ in range(len(self.population)): + i, j = random.sample(range(self.population_size), 2) + if self.population[i].individual['fitness_score'] < self.population[j].individual['fitness_score']: + selected.append(copy.deepcopy(self.population[i])) + else: + selected.append(copy.deepcopy(self.population[j])) + return selected + + def crossover(self, parent1, parent2): + """ + Crossover between two parents to produce two children. + + Swaps generation components and storage components between the two parents with a 50% chance. + + :param parent1: First parent individual. + :param parent2: Second parent individual. + :return: Two child individuals (child1 and child2). + """ + if random.random() < self.crossover_rate: + # Deep copy of the parents to create children + child1, child2 = copy.deepcopy(parent1), copy.deepcopy(parent2) + + # Crossover for Generation Components + for i in range(len(parent1.individual['Generation Components'])): + if random.random() < 0.5: + # Swap the entire generation component + child1.individual['Generation Components'][i], child2.individual['Generation Components'][i] = ( + child2.individual['Generation Components'][i], + child1.individual['Generation Components'][i] + ) + + # Crossover for Energy Storage Components + for i in range(len(parent1.individual['Energy Storage Components'])): + if random.random() < 0.5: + # Swap the entire storage component + child1.individual['Energy Storage Components'][i], child2.individual['Energy Storage Components'][i] = ( + child2.individual['Energy Storage Components'][i], + child1.individual['Energy Storage Components'][i] + ) + + return child1, child2 + else: + # If crossover does not happen, return copies of the original parents + return copy.deepcopy(parent1), copy.deepcopy(parent2) + + def mutate(self, individual, building): + """ + Mutates the individual's generation and storage components. + + - `individual`: The individual to mutate (contains generation and storage components). + - `building`: Building data that contains constraints such as peak heating load and available space. + + Returns the mutated individual. + """ + # Mutate Generation Components + for generation_component in individual['Generation Components']: + if random.random() < self.mutation_rate: + if generation_component['nominal_heating_efficiency'] is not None: + # Mutate heating capacity + generation_component['heating_capacity'] = random.uniform( + 0, building.heating_peak_load[cte.YEAR][0]) + if generation_component['nominal_cooling_efficiency'] is not None: + # Mutate cooling capacity + generation_component['cooling_capacity'] = random.uniform( + 0, building.cooling_peak_load[cte.YEAR][0]) + + # Mutate Storage Components + for storage_component in individual['Energy Storage Components']: + if random.random() < self.mutation_rate: + if storage_component['type'] == f'{cte.THERMAL}_storage': + # Mutate the volume of thermal storage + max_available_space = 0.01 * building.volume / building.storeys_above_ground + storage_component['volume'] = random.uniform(0, max_available_space) + if storage_component['heating_coil_capacity'] is not None: + storage_component['volume'] = random.uniform(0, building.heating_peak_load[cte.YEAR][0]) + + return individual def solve_ga(self, building, energy_system): """ Solving GA for a single energy system. Here are the steps: - 1- Population is initialized using the "initialize_population" method in this class. - :param building: - :param energy_system: - :return: + 1. Initialize population using the "initialize_population" method in this class. + 2. Evaluate the initial population using the "score_evaluation" method in the Individual class. + 3. Sort population based on fitness score. + 4. Repeat selection, crossover, and mutation for a fixed number of generations. + 5. Track the best solution found during the optimization process. + + :param building: Building object for the energy system. + :param energy_system: Energy system to optimize. + :return: Best solution after running the GA. """ - energy_system = energy_system - best_individuals = [] - best_solution = None - population = self.initialize_population(building, energy_system) - for individual in population: + # Step 1: Initialize the population + self.initialize_population(building, energy_system) + # Step 2: Evaluate the initial population + for individual in self.population: individual.score_evaluation() - print(individual.individual['feasible']) - print(individual.individual['fitness_score']) + # Step 3: Order population based on fitness scores + self.order_population() + # Track the best solution + self.best_solution = self.population[0] + self.list_of_solutions.append(copy.deepcopy(self.best_solution.individual)) + # Step 4: Run GA for a fixed number of generations + for generation in range(self.generations): + print(f"Generation {generation}") + # Selection (using tournament selection) + selected_population = self.tournament_selection() + # Create the next generation through crossover and mutation + next_population = [] + for i in range(0, self.population_size, 2): + parent1 = selected_population[i] + parent2 = selected_population[i + 1] if (i + 1) < len(selected_population) else selected_population[0] + # Step 5: Apply crossover + child1, child2 = self.crossover(parent1, parent2) + # Step 6: Apply mutation + self.mutate(child1.individual, building) + self.mutate(child2.individual, building) + # Step 7: Evaluate the children + child1.score_evaluation() + child2.score_evaluation() + next_population.extend([child1, child2]) + # Replace old population with the new one + self.population = next_population + # Step 8: Sort the new population based on fitness + self.order_population() + # Track the best solution found in this generation + if self.population[0].individual['fitness_score'] < self.best_solution.individual['fitness_score']: + self.best_solution = self.population[0] + self.best_solution_generation = generation + # Store the best solution in the list of solutions + self.list_of_solutions.append(copy.deepcopy(self.population[0])) + print(f"Best solution found in generation {self.best_solution_generation}") + print(f"Best solution: {self.best_solution.individual}") + + # Return the best solution after running GA + return self.best_solution + + diff --git a/energy_system_modelling_package/energy_system_modelling_factories/system_sizing_methods/genetic_algorithm/individual.py b/energy_system_modelling_package/energy_system_modelling_factories/system_sizing_methods/genetic_algorithm/individual.py index 404d284f..e5982f2a 100644 --- a/energy_system_modelling_package/energy_system_modelling_factories/system_sizing_methods/genetic_algorithm/individual.py +++ b/energy_system_modelling_package/energy_system_modelling_factories/system_sizing_methods/genetic_algorithm/individual.py @@ -11,10 +11,9 @@ import numpy_financial as npf class Individual: - def __init__(self, building, energy_system, optimization_scenario, heating_design_load=None, - cooling_design_load=None, available_space=None, dt=None, fuel_price_index=None, - electricity_tariff_type='fixed', consumer_price_index=None, interest_rate=None, - discount_rate=None, percentage_credit=None, credit_years=None): + def __init__(self, building, energy_system, optimization_scenario, heating_design_load=None, cooling_design_load=None, + dt=900, fuel_price_index=0.05, electricity_tariff_type='fixed', consumer_price_index=0.04, + interest_rate=0.04, discount_rate=0.03, percentage_credit=0, credit_years=15): """ :param building: building object :param energy_system: energy system to be optimized @@ -22,8 +21,6 @@ class Individual: energy consumption, and both together :param heating_design_load: heating design load in W :param cooling_design_load: cooling design load in W - :param available_space: available space for the system. currently it is only used to have a constraint on the - maximum size of the storage tank :param dt the time step size used for simulations :param fuel_price_index the price increase index of all fuels. A single value is used for all fuels. :param electricity_tariff_type the electricity tariff type between 'fixed' and 'variable' for economic optimization @@ -35,7 +32,7 @@ class Individual: self.optimization_scenario = optimization_scenario self.heating_design_load = heating_design_load self.cooling_design_load = cooling_design_load - self.available_space = available_space + self.available_space = building.volume / building.storeys_above_ground self.dt = dt self.fuel_price_index = fuel_price_index self.electricity_tariff_type = electricity_tariff_type @@ -145,7 +142,10 @@ class Individual: if storage_component['type'] == f'{cte.THERMAL}_storage': storage_component['volume'] = random.uniform(0, 0.01 * self.available_space) if storage_component['heating_coil_capacity'] is not None: - storage_component['heating_coil_capacity'] = random.uniform(0, self.heating_design_load) + if self.heating_design_load is not None: + storage_component['heating_coil_capacity'] = random.uniform(0, self.heating_design_load) + else: + storage_component['heating_coil_capacity'] = random.uniform(0, self.building.heating_peak_load[cte.YEAR][0]) def score_evaluation(self): self.initialization() @@ -207,7 +207,7 @@ class Individual: tes.volume = self.individual['Energy Storage Components'][0]['volume'] tes.height = self.building.average_storey_height - 1 tes.heating_coil_capacity = self.individual['Energy Storage Components'][0]['heating_coil_capacity'] \ - if self.individual['Energy Storage Components'][0]['heating_coil_capacity'] is not None else 0 + if self.individual['Energy Storage Components'][0]['heating_coil_capacity'] is not None else None heating_demand_joules = self.building.heating_demand[cte.HOUR] heating_peak_load_watts = self.heating_design_load if (self.heating_design_load is not None) else self.building.heating_peak_load[cte.YEAR][0] @@ -230,7 +230,7 @@ class Individual: tes.volume = self.individual['Energy Storage Components'][0]['volume'] tes.height = self.building.average_storey_height - 1 tes.heating_coil_capacity = self.individual['Energy Storage Components'][0]['heating_coil_capacity'] \ - if self.individual['Energy Storage Components'][0]['heating_coil_capacity'] is not None else 0 + if self.individual['Energy Storage Components'][0]['heating_coil_capacity'] is not None else None dhw_demand_joules = self.building.domestic_hot_water_heat_demand[cte.HOUR] upper_limit_tes = 65 outdoor_temperature = self.building.external_temperature[cte.HOUR] @@ -375,7 +375,7 @@ class Individual: if generation_system.energy_storage_systems is not None: for energy_storage_system in generation_system.energy_storage_systems: if energy_storage_system.type_energy_stored == cte.THERMAL: - if energy_storage_system.heating_coil_capacity is None: + if energy_storage_system.heating_coil_capacity is not None: investment_cost += float(capital_costs_chapter.item('D306010_storage_tank').initial_investment[0]) reposition_cost += float(capital_costs_chapter.item('D306010_storage_tank').reposition[0]) lifetime += float(capital_costs_chapter.item('D306010_storage_tank').lifetime)