From 4dfd4b275b69b814eb5b9407d94f52fb5a147013 Mon Sep 17 00:00:00 2001 From: Andreas Date: Thu, 16 Jul 2026 14:32:20 +0200 Subject: [PATCH] Improve genetic optimizer convergence --- CHANGELOG.md | 18 +- src/akkudoktoreos/core/ems.py | 18 +- .../optimization/genetic/genetic.py | 371 ++++++++++-- src/akkudoktoreos/server/eos.py | 19 +- tests/single_test_optimization.py | 2 +- tests/test_genetic_seeding.py | 145 ++++- tests/testdata/optimize_result_1_be.json | 136 ++--- tests/testdata/optimize_result_2.json | 568 +++++++++--------- tests/testdata/optimize_result_2_be.json | 472 +++++++-------- 9 files changed, 1084 insertions(+), 665 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4aadfd7..4fa96f64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,10 +62,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ETS forecasts. A median fallback is used when the available history is too short for ETS. ### Changed -- Use a fixed, diverse genetic start population with ten exact warm-start copies, up to fifty - locally mutated warm-start neighbours, up to one hundred randomized domain-informed battery, - direct-marketing, EV, and flexible-appliance schedules, and a guaranteed random remainder. - Retain 150 parents while generating 150 offspring per generation. +- Scale the diverse genetic start population with the configured population size while preserving + the established 300-member mix: exact warm starts, locally mutated neighbours, randomized + domain-informed battery/direct-marketing/EV/appliance schedules, and a guaranteed random + remainder. Survivor and offspring counts now follow `optimization.genetic.individuals` instead + of remaining fixed at 150. +- Add coherent battery block mutations and energy-shift mutations that move weak battery exports + into several later expensive self-consumption slots in one step. A bounded, fitness-checked + local search applies the same neighbourhood to the final incumbent, avoiding local minima that + cannot be crossed by an individually disadvantageous single-slot mutation. - Memoize successful canonical fitness evaluations within one optimization run, including repaired EV genomes and auxiliary metrics. Log cache hits, misses, key count, and hit rate after each run; failed evaluations and results from previous runs are never reused. @@ -80,6 +85,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). are deprecated in favour of `appliance_starts` and `result.home_appliance_energy_wh`. ### Fixed +- Respect `optimization.genetic.individuals` and `optimization.genetic.generations` independently + in automatic and `/optimize` runs. Previously the individual count was accidentally passed as + the generation count, the configured generation count was ignored, and every generation still + generated 150 offspring. The deprecated `?ngen=` query remains a generation-count override; + `?individuals=` can override the population for one API run. - Allow the direct-marketing optimizer to select a true battery self-consumption state with DC charging and local-load discharge enabled in the same slot. Existing warm-start state numbers remain compatible, and educated guesses now use the combined state for PV/load overlap instead diff --git a/src/akkudoktoreos/core/ems.py b/src/akkudoktoreos/core/ems.py index c6589a38..12a40491 100644 --- a/src/akkudoktoreos/core/ems.py +++ b/src/akkudoktoreos/core/ems.py @@ -159,6 +159,7 @@ class EnergyManagement( mode: EnergyManagementMode, genetic_parameters: Optional[GeneticOptimizationParameters] = None, genetic_individuals: Optional[int] = None, + genetic_generations: Optional[int] = None, genetic_seed: Optional[int] = None, force_enable: Optional[bool] = False, force_update: Optional[bool] = False, @@ -180,8 +181,9 @@ class EnergyManagement( parameter set for the genetic algorithm. If not provided, it will be constructed based on the current configuration and predictions. genetic_individuals (int, optional): The number of individuals for the - genetic algorithm. Defaults to the algorithm's internal default (400) - if not specified. + initial genetic population. Defaults to the configured value. + genetic_generations (int, optional): The number of generations to + evolve. Defaults to the configured value. genetic_seed (int, optional): The seed for the genetic algorithm. Defaults to the algorithm's internal random seed if not specified. force_enable (bool, optional): If True, bypasses any disabled state @@ -248,6 +250,8 @@ class EnergyManagement( # Take values from config if not given if genetic_individuals is None: genetic_individuals = cls.config.optimization.genetic.individuals + if genetic_generations is None: + genetic_generations = cls.config.optimization.genetic.generations if genetic_seed is None: genetic_seed = cls.config.optimization.genetic.seed @@ -262,7 +266,8 @@ class EnergyManagement( solution = optimization.optimierung_ems( start_hour=cls._start_datetime.hour, parameters=genetic_parameters, - ngen=genetic_individuals, + ngen=genetic_generations, + individuals=genetic_individuals, ) except: logger.exception("Energy management optimization failed.") @@ -305,6 +310,7 @@ class EnergyManagement( mode: Optional[EnergyManagementMode] = None, genetic_parameters: Optional[GeneticOptimizationParameters] = None, genetic_individuals: Optional[int] = None, + genetic_generations: Optional[int] = None, genetic_seed: Optional[int] = None, force_enable: Optional[bool] = False, force_update: Optional[bool] = False, @@ -328,8 +334,9 @@ class EnergyManagement( parameter set for the genetic algorithm. If not provided, it will be constructed based on the current configuration and predictions. genetic_individuals (int, optional): The number of individuals for the - genetic algorithm. Defaults to the algorithm's internal default (400) - if not specified. + initial genetic population. Defaults to the configured value. + genetic_generations (int, optional): The number of generations to + evolve. Defaults to the configured value. genetic_seed (int, optional): The seed for the genetic algorithm. Defaults to the algorithm's internal random seed if not specified. force_enable (bool, optional): If True, bypasses any disabled state @@ -354,6 +361,7 @@ class EnergyManagement( mode=mode, genetic_parameters=genetic_parameters, genetic_individuals=genetic_individuals, + genetic_generations=genetic_generations, genetic_seed=genetic_seed, force_enable=force_enable, force_update=force_update, diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index c5c23372..19e23078 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -180,6 +180,7 @@ class GeneticSimulation(PydanticBaseModel): ev_discharge_hours: Optional[NDArray[Shape["*"], float]] = Field( default=None, json_schema_extra={"description": "TBD"} ) + def prepare( self, parameters: GeneticEnergyManagementParameters, @@ -562,8 +563,13 @@ class GeneticOptimization(OptimizationBase): WARM_START_MUTATIONS = 50 EDUCATED_GUESS_TARGET = 100 MIN_RANDOM_POPULATION_FRACTION = 0.25 - SURVIVOR_COUNT = 150 - OFFSPRING_COUNT = 150 + WARM_START_COPY_FRACTION = 0.10 + WARM_START_MUTATION_FRACTION = 0.20 + EDUCATED_GUESS_FRACTION = 0.40 + BLOCK_MUTATION_PROBABILITY = 0.20 + ENERGY_SHIFT_MUTATION_PROBABILITY = 0.35 + LOCAL_SEARCH_MAX_EVALUATIONS = 96 + LOCAL_SEARCH_MAX_PASSES = 4 EDUCATED_GUESS_EXPORT_QUANTILES = (0.60, 0.75, 0.90) # Slot-math helpers — single source of truth for the optimization grid. @@ -787,9 +793,7 @@ class GeneticOptimization(OptimizationBase): gene_index += 1 return ApplianceGeneLayout(genes) - def _decode_appliance_starts( - self, appliance_gene_values: list[int] - ) -> dict[int, list[int]]: + def _decode_appliance_starts(self, appliance_gene_values: list[int]) -> dict[int, list[int]]: """Map appliance gene values to absolute start slots per appliance. Each gene value is an index into its gene's ``allowed_start_slots``; it is @@ -1053,6 +1057,95 @@ class GeneticOptimization(OptimizationBase): return ac_charge, dc_charge, discharge, battery_grid_export + def _mutate_battery_block(self, individual: list[int]) -> None: + """Mutate a short future block to one coherent operating policy.""" + start_slot = self._start_day_slot() + if start_slot >= self.total_slots: + return + + state_layout = self._battery_state_layout() + len_bat = len(self.bat_possible_charge_values) + policy_states = [0, len_bat] + if state_layout.self_consumption_state is not None: + policy_states.append(state_layout.self_consumption_state) + if state_layout.dc_allowed_state is not None: + policy_states.append(state_layout.dc_allowed_state) + if state_layout.grid_export_state is not None: + policy_states.append(state_layout.grid_export_state) + + block_start = random.randint(start_slot, self.total_slots - 1) # noqa: S311 + max_length = min(12, self.total_slots - block_start) + block_length = random.randint(2, max(2, max_length)) if max_length > 1 else 1 # noqa: S311 + state = random.choice(policy_states) # noqa: S311 + individual[block_start : block_start + block_length] = [state] * block_length + + def _energy_shift_target_slots( + self, + individual: list[int], + source_slot: int, + ) -> list[int]: + """Return later idle slots where retained battery energy avoids costly import.""" + try: + prices = np.asarray(self.simulation.elect_price_hourly, dtype=float) + feed_in = np.asarray(self.simulation.elect_revenue_per_hour_arr, dtype=float) + pv = np.asarray(self.simulation.pv_prediction_wh, dtype=float) + load = np.asarray(self.simulation.load_energy_array, dtype=float) + except Exception: + return [] + if any(values.size < self.total_slots for values in (prices, feed_in, pv, load)): + return [] + + len_bat = len(self.bat_possible_charge_values) + source_tariff = float(feed_in[source_slot]) + candidates = [ + slot + for slot in range(source_slot + 1, self.total_slots) + if 0 <= int(individual[slot]) < len_bat + and load[slot] > pv[slot] + and prices[slot] > source_tariff + ] + return sorted( + candidates, + key=lambda slot: (float(prices[slot]), float(load[slot] - pv[slot])), + reverse=True, + ) + + def _mutate_energy_shift(self, individual: list[int]) -> bool: + """Move battery energy from a weak export into later expensive self-consumption.""" + state_layout = self._battery_state_layout() + export_state = state_layout.grid_export_state + self_state = state_layout.self_consumption_state + if export_state is None or self_state is None: + return False + + start_slot = self._start_day_slot() + viable: list[tuple[int, list[int]]] = [] + for source_slot in range(start_slot, self.total_slots): + if int(individual[source_slot]) != export_state: + continue + targets = self._energy_shift_target_slots(individual, source_slot) + if targets: + viable.append((source_slot, targets)) + if not viable: + return False + + # Prefer later/lower-value exports, but retain random diversity among + # the viable tail instead of always producing one identical neighbour. + try: + feed_in = np.asarray(self.simulation.elect_revenue_per_hour_arr, dtype=float) + viable.sort(key=lambda item: (float(feed_in[item[0]]), -item[0])) + except Exception: + viable.sort(key=lambda item: -item[0]) + source_slot, targets = random.choice(viable[: min(6, len(viable))]) # noqa: S311 + + individual[source_slot] = self_state + target_count = min(len(targets), random.randint(4, 10)) # noqa: S311 + len_bat = len(self.bat_possible_charge_values) + pv = np.asarray(self.simulation.pv_prediction_wh, dtype=float) + for target_slot in targets[:target_count]: + individual[target_slot] = self_state if pv[target_slot] > 0.0 else len_bat + return True + def mutate(self, individual: list[int]) -> tuple[list[int]]: """Custom mutation function for the individual.""" total_states = self._battery_state_layout().total_states @@ -1065,6 +1158,15 @@ class GeneticOptimization(OptimizationBase): charge_discharge_mutated = np.clip(charge_discharge_mutated, 0, total_states - 1) individual[: self.total_slots] = charge_discharge_mutated + # Point mutation alone struggles with energy-coupled valleys: removing + # an export is temporarily worse until several later bypass slots also + # consume the retained energy. Add coherent neighbourhood moves that + # can cross that valley in one offspring. + if random.random() < self.BLOCK_MUTATION_PROBABILITY: # noqa: S311 + self._mutate_battery_block(individual) + if random.random() < self.ENERGY_SHIFT_MUTATION_PROBABILITY: # noqa: S311 + self._mutate_energy_shift(individual) + # 2. Mutating the EV charge part, if active if self.optimize_ev: ev_charge_part = individual[self.total_slots : self.total_slots * 2] @@ -1214,10 +1316,7 @@ class GeneticOptimization(OptimizationBase): for offset in range(result_slots): slot = start_slot + offset charge_index = int(ev_charge_indices[slot]) - if ( - ev_soc[offset] >= 100.0 - 1e-9 - and ev_possible_charge_values[charge_index] > 0.0 - ): + if ev_soc[offset] >= 100.0 - 1e-9 and ev_possible_charge_values[charge_index] > 0.0: ev_charge_indices[slot] = zero_charge_index changed = True @@ -1245,8 +1344,7 @@ class GeneticOptimization(OptimizationBase): return schedule required_stored_wh = max( - ev.min_soc_wh - - ev.capacity_wh * ev.initial_soc_percentage / 100.0, + ev.min_soc_wh - ev.capacity_wh * ev.initial_soc_percentage / 100.0, 0.0, ) if required_stored_wh <= 0.0: @@ -1277,11 +1375,7 @@ class GeneticOptimization(OptimizationBase): if not positive_rates: return schedule - max_stored_wh = ( - ev.max_charge_power_w - * self.slot_duration_h - * ev.charging_efficiency - ) + max_stored_wh = ev.max_charge_power_w * self.slot_duration_h * ev.charging_efficiency remaining_wh = required_stored_wh for slot in candidates: required_rate = remaining_wh / max(max_stored_wh, 1e-9) @@ -1305,6 +1399,7 @@ class GeneticOptimization(OptimizationBase): load = np.asarray(self.simulation.load_energy_array, dtype=float) genes: list[int] = [] for gene in self.appliance_layout.genes: + def opportunity_cost(position: int) -> float: slot = gene.allowed_start_slots[position] return float(feed_in[slot] if pv[slot] > load[slot] else prices[slot]) @@ -1417,15 +1512,23 @@ class GeneticOptimization(OptimizationBase): # feed-in slots. At low tariffs PV is preferentially stored instead. if self.optimize_battery_grid_export and future_feed_in.size: for quantile in self.EDUCATED_GUESS_EXPORT_QUANTILES: + export_guess = policy_guess( + import_quantile=0.70, + export_quantile=quantile, + pv_surplus_ratio=1.0, + allow_ac_arbitrage=False, + ) add_guess( - policy_guess( - import_quantile=0.70, - export_quantile=quantile, - pv_surplus_ratio=1.0, - allow_ac_arbitrage=False, - ), + export_guess, ev_pv, ) + # Seed coordinated alternatives that retain a weak export and + # spend the energy in later expensive import slots. + for shifted in self._grid_export_shift_candidates( + export_guess, + max_sources=2, + )[:6]: + add_guess(shifted, ev_pv) inverter = self.simulation.inverter ac_arbitrage_possible = inverter is not None and ( @@ -1473,6 +1576,9 @@ class GeneticOptimization(OptimizationBase): elif load[slot] > pv[slot] and prices[slot] >= high_import_price: randomized[slot] = discharge_state + if random.random() < 0.5: # noqa: S311 + self._mutate_energy_shift(randomized) + add_guess( randomized, ev_pv if random.random() < 0.5 else ev_price, # noqa: S311 @@ -1510,6 +1616,104 @@ class GeneticOptimization(OptimizationBase): break return neighbors + def _grid_export_shift_candidates( + self, + individual: list[int], + *, + max_sources: int = 6, + ) -> list[list[int]]: + """Build deterministic export-to-self-consumption neighbourhood candidates.""" + state_layout = self._battery_state_layout() + export_state = state_layout.grid_export_state + self_state = state_layout.self_consumption_state + if export_state is None or self_state is None: + return [] + + start_slot = self._start_day_slot() + try: + feed_in = np.asarray(self.simulation.elect_revenue_per_hour_arr, dtype=float) + pv = np.asarray(self.simulation.pv_prediction_wh, dtype=float) + except Exception: + return [] + if feed_in.size < self.total_slots or pv.size < self.total_slots: + return [] + + sources = [ + slot + for slot in range(start_slot, self.total_slots) + if int(individual[slot]) == export_state + ] + # Search weak and late export decisions first. They are the most likely + # to compete with later, more valuable avoided grid imports. + sources.sort(key=lambda slot: (float(feed_in[slot]), -slot)) + + len_bat = len(self.bat_possible_charge_values) + candidates: list[list[int]] = [] + seen: set[tuple[int, ...]] = set() + viable_sources = 0 + for source_slot in sources: + targets = self._energy_shift_target_slots(individual, source_slot) + if not targets: + continue + viable_sources += 1 + counts = sorted({min(len(targets), count) for count in (2, 4, 6, 8, 10, 12)}) + for count in counts: + candidate = list(individual) + candidate[source_slot] = self_state + for target_slot in targets[:count]: + candidate[target_slot] = self_state if pv[target_slot] > 0.0 else len_bat + key = tuple(int(value) for value in candidate) + if key in seen: + continue + seen.add(key) + candidates.append(candidate) + if viable_sources >= max_sources: + break + return candidates + + def _locally_improve_grid_export( + self, + individual: list[int], + *, + max_evaluations: int, + ) -> tuple[Any, int, int, float, float]: + """Improve the incumbent through bounded, fitness-checked energy shifts.""" + best = creator.Individual(individual) + original_fitness = getattr(individual, "fitness", None) + if original_fitness is not None and original_fitness.valid: + best.fitness.values = original_fitness.values + if hasattr(individual, "extra_data"): + best.extra_data = individual.extra_data + + if not hasattr(self.toolbox, "evaluate"): + value = float(best.fitness.values[0]) if best.fitness.valid else float("inf") + return best, 0, 0, value, value + if not best.fitness.valid: + best.fitness.values = self.toolbox.evaluate(best) + + initial_value = float(best.fitness.values[0]) + evaluations = 0 + improvements = 0 + for _ in range(self.LOCAL_SEARCH_MAX_PASSES): + pass_best = best + for genome in self._grid_export_shift_candidates(best): + if evaluations >= max_evaluations: + break + candidate = creator.Individual(genome) + candidate.fitness.values = self.toolbox.evaluate(candidate) + evaluations += 1 + if candidate.fitness.values[0] < pass_best.fitness.values[0] - 1e-9: + pass_best = candidate + if pass_best is best: + break + best = pass_best + improvements += 1 + if evaluations >= max_evaluations: + break + + final_value = float(best.fitness.values[0]) + return best, evaluations, improvements, initial_value, final_value + def setup_deap_environment(self, opti_param: dict[str, Any], start_hour: int) -> None: """Set up the DEAP environment with fitness and individual creation rules.""" self.opti_param = opti_param @@ -1892,6 +2096,7 @@ class GeneticOptimization(OptimizationBase): self, start_solution: Optional[list[float]] = None, ngen: int = 200, + individuals: Optional[int] = None, ) -> tuple[Any, dict[str, list[Any]]]: """Run the optimization process using a genetic algorithm. @@ -1904,13 +2109,14 @@ class GeneticOptimization(OptimizationBase): random.seed(self.fix_seed) # Set the number of inviduals in a generation - try: - individuals = self.config.optimization.genetic.individuals - if individuals is None: - raise - except: - individuals = 300 - logger.error("Individuals not configured. Using {}.", individuals) + if individuals is None: + try: + individuals = self.config.optimization.genetic.individuals + if individuals is None: + raise ValueError("individuals is not configured") + except Exception: + individuals = 300 + logger.error("Individuals not configured. Using {}.", individuals) hof = tools.HallOfFame(1) stats = tools.Statistics(lambda ind: ind.fitness.values) @@ -1924,9 +2130,7 @@ class GeneticOptimization(OptimizationBase): valid_start_solution: Optional[list[float]] = None if start_solution is not None: n_appliance_genes = self.appliance_layout.n_genes - expected_length = ( - self.total_slots * (2 if self.optimize_ev else 1) + n_appliance_genes - ) + expected_length = self.total_slots * (2 if self.optimize_ev else 1) + n_appliance_genes start_solution = self._start_solution_for_slot_grid(start_solution) if len(start_solution) != expected_length: @@ -1943,42 +2147,57 @@ class GeneticOptimization(OptimizationBase): else: valid_start_solution = start_solution - # Keep the configured initial population size fixed. With the default - # 300 individuals this yields 10 exact warm starts, 50 local variants, - # 100 educated guesses and 140 fully random candidates. + # Scale the seed families with small populations without changing the + # established 300-individual defaults. This prevents a 100-member run + # from spending 60% of its budget on the warm-start neighbourhood. + exact_warm_target = min( + self.WARM_START_COPIES, + max(1, int(individuals * self.WARM_START_COPY_FRACTION + 0.999999)), + ) + warm_mutation_target = min( + self.WARM_START_MUTATIONS, + max(1, int(individuals * self.WARM_START_MUTATION_FRACTION + 0.999999)), + ) + educated_guess_target = min( + self.EDUCATED_GUESS_TARGET, + max(1, int(individuals * self.EDUCATED_GUESS_FRACTION + 0.999999)), + ) minimum_random = max( int(individuals * self.MIN_RANDOM_POPULATION_FRACTION + 0.999999), - individuals - - ( - self.WARM_START_COPIES - + self.WARM_START_MUTATIONS - + self.EDUCATED_GUESS_TARGET - ), + individuals - (exact_warm_target + warm_mutation_target + educated_guess_target), ) seed_budget = max(individuals - minimum_random, 0) - seeded: list[list[float]] = [] + seeded: list[list[Any]] = [] exact_warm_count = 0 warm_neighbors: list[list[int]] = [] if valid_start_solution is not None and seed_budget > 0: - exact_warm_count = min(self.WARM_START_COPIES, seed_budget) + exact_warm_count = min(exact_warm_target, seed_budget) seeded.extend([valid_start_solution] * exact_warm_count) remaining_seed_budget = seed_budget - len(seeded) warm_neighbors = self._mutated_warm_start_neighbors( valid_start_solution, - min(self.WARM_START_MUTATIONS, remaining_seed_budget), + min(warm_mutation_target, remaining_seed_budget), ) seeded.extend(warm_neighbors) remaining_seed_budget = seed_budget - len(seeded) educated_guesses = self._educated_guess_individuals( - min(self.EDUCATED_GUESS_TARGET, remaining_seed_budget) + min(educated_guess_target, remaining_seed_budget) ) seeded.extend(educated_guesses) random_count = max(individuals - len(seeded), 0) population = [creator.Individual(seed) for seed in seeded] population.extend(self.toolbox.population(n=random_count)) + logger.info( + "Genetic settings: {} individuals, {} generations, {} survivors, " + "{} offspring per generation.", + individuals, + ngen, + individuals, + individuals, + ) logger.info( "Initial population {}: {} exact warm starts, {} warm mutations, " "{} educated guesses, {} random candidates.", @@ -1996,12 +2215,16 @@ class GeneticOptimization(OptimizationBase): self._fitness_cache_hits = 0 self._fitness_cache_misses = 0 self._fitness_cache_enabled = True + local_evaluations = 0 + local_improvements = 0 + local_initial_fitness = float("nan") + local_final_fitness = float("nan") try: pop, log = algorithms.eaMuPlusLambda( population, self.toolbox, - mu=self.SURVIVOR_COUNT, - lambda_=self.OFFSPRING_COUNT, + mu=individuals, + lambda_=individuals, cxpb=0.6, mutpb=0.4, ngen=ngen, @@ -2009,13 +2232,34 @@ class GeneticOptimization(OptimizationBase): halloffame=hof, verbose=self.verbose, ) + ( + best_solution, + local_evaluations, + local_improvements, + local_initial_fitness, + local_final_fitness, + ) = self._locally_improve_grid_export( + hof[0], + max_evaluations=min( + self.LOCAL_SEARCH_MAX_EVALUATIONS, + max(individuals, 1), + ), + ) finally: self._fitness_cache_enabled = False + if local_improvements: + logger.info( + "Grid-export local search: {} improvements in {} evaluations, " + "fitness {:.6f} -> {:.6f}.", + local_improvements, + local_evaluations, + local_initial_fitness, + local_final_fitness, + ) + cache_lookups = self._fitness_cache_hits + self._fitness_cache_misses - cache_hit_rate = ( - self._fitness_cache_hits / cache_lookups if cache_lookups > 0 else 0.0 - ) + cache_hit_rate = self._fitness_cache_hits / cache_lookups if cache_lookups > 0 else 0.0 logger.info( "Fitness cache: {} hits, {} misses, {:.1%} hit rate, {} keys.", self._fitness_cache_hits, @@ -2036,6 +2280,12 @@ class GeneticOptimization(OptimizationBase): "hit_rate": cache_hit_rate, "keys": len(self._fitness_cache), }, + "local_search": { + "evaluations": local_evaluations, + "improvements": local_improvements, + "initial_fitness": local_initial_fitness, + "final_fitness": local_final_fitness, + }, } member: dict[str, list[float]] = {"bilanz": [], "verluste": [], "nebenbedingung": []} @@ -2046,7 +2296,7 @@ class GeneticOptimization(OptimizationBase): member["verluste"].append(extra_value2) member["nebenbedingung"].append(extra_value3) - return hof[0], member + return best_solution, member def optimierung_ems( self, @@ -2054,6 +2304,7 @@ class GeneticOptimization(OptimizationBase): start_hour: Optional[int] = None, worst_case: bool = False, ngen: Optional[int] = None, + individuals: Optional[int] = None, ) -> GeneticSolution: """Perform EMS (Energy Management System) optimization and visualize results.""" direct_marketing_enabled = self._direct_marketing_enabled() @@ -2177,9 +2428,7 @@ class GeneticOptimization(OptimizationBase): ) for appliance_params in home_appliance_params ] - self.appliance_layout = self._build_appliance_layout( - home_appliances, self._slot0_datetime - ) + self.appliance_layout = self._build_appliance_layout(home_appliances, self._slot0_datetime) # Initialize the inverter and energy management system. slot_duration_h # lets the Inverter scale max_power_wh to a per-slot energy cap. @@ -2205,16 +2454,18 @@ class GeneticOptimization(OptimizationBase): # Setup the DEAP environment and optimization process. The appliance # genome layout (built above) drives the appliance gene block; evaluate # gets the slot index (its break-even loop walks the slot arrays from "now"). - self.setup_deap_environment( - {"home_appliance": self.appliance_layout.n_genes}, start_hour - ) + self.setup_deap_environment({"home_appliance": self.appliance_layout.n_genes}, start_hour) self.toolbox.register( "evaluate", lambda ind: self.evaluate(ind, parameters, start_slot, worst_case), ) start_time = time.time() - start_solution, extra_data = self.optimize(parameters.start_solution, ngen=generations) + start_solution, extra_data = self.optimize( + parameters.start_solution, + ngen=generations, + individuals=individuals, + ) elapsed_time = time.time() - start_time logger.debug(f"Time evaluate inner: {elapsed_time:.4f} sec.") @@ -2222,8 +2473,8 @@ class GeneticOptimization(OptimizationBase): simulation_result = self.evaluate_inner(start_solution) # Prepare results - discharge_hours_bin, eautocharge_hours_index, appliance_gene_values = ( - self.split_individual(start_solution) + discharge_hours_bin, eautocharge_hours_index, appliance_gene_values = self.split_individual( + start_solution ) # Materialize the per-device appliance results only for the final best diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 05bdf3a4..e8463944 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -1408,7 +1408,21 @@ async def fastapi_optimize( Optional[int], Query(description="Defaults to current hour of the day.") ] = None, ngen: Annotated[ - Optional[int], Query(description="Number of indivuals to generate for genetic algorithm.") + Optional[int], + Query( + description=( + "Deprecated alias for the number of genetic generations. " + "Defaults to optimization.genetic.generations." + ), + ge=1, + ), + ] = None, + individuals: Annotated[ + Optional[int], + Query( + description="Override optimization.genetic.individuals for this run.", + ge=10, + ), ] = None, ) -> GeneticSolution: """Deprecated: Optimize. @@ -1429,7 +1443,8 @@ async def fastapi_optimize( start_datetime=start_datetime, mode=EnergyManagementMode.OPTIMIZATION, genetic_parameters=parameters, - genetic_individuals=ngen, + genetic_individuals=individuals, + genetic_generations=ngen, ) except Exception as e: raise HTTPException(status_code=400, detail=f"Optimize error: {e}.") diff --git a/tests/single_test_optimization.py b/tests/single_test_optimization.py index f8856dc0..75756f0d 100755 --- a/tests/single_test_optimization.py +++ b/tests/single_test_optimization.py @@ -430,7 +430,7 @@ def run_optimization( start_datetime=start_datetime, mode=EnergyManagementMode.OPTIMIZATION, genetic_parameters=parameters, - genetic_individuals=ngen, + genetic_generations=ngen, genetic_seed=seed, ) ) diff --git a/tests/test_genetic_seeding.py b/tests/test_genetic_seeding.py index bae0bf4c..53338d9e 100644 --- a/tests/test_genetic_seeding.py +++ b/tests/test_genetic_seeding.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import numpy as np import pytest @@ -7,6 +7,7 @@ from deap import creator from akkudoktoreos.config.config import ConfigEOS from akkudoktoreos.core.coreabc import get_ems +from akkudoktoreos.core.emsettings import EnergyManagementMode from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization from akkudoktoreos.utils.datetimeutil import to_datetime @@ -21,6 +22,36 @@ def _configure_hourly_grid(config_eos: ConfigEOS, *, start_hour: int = 0) -> Non get_ems(init=True).set_start_datetime(to_datetime().set(hour=start_hour, minute=0)) +def test_energy_management_forwards_individuals_and_generations_separately( + config_eos: ConfigEOS, +): + _configure_hourly_grid(config_eos) + config_eos.optimization.genetic.individuals = 100 + config_eos.optimization.genetic.generations = 80 + ems = get_ems(init=True) + parameters = MagicMock() + solution = MagicMock() + optimizer = MagicMock() + optimizer.optimierung_ems.return_value = solution + + with ( + patch("akkudoktoreos.adapter.adapterabc.AdapterContainer.update_data"), + patch("akkudoktoreos.core.ems.GeneticOptimization", return_value=optimizer), + ): + ems._run( + start_datetime=to_datetime().set(hour=0, minute=0), + mode=EnergyManagementMode.OPTIMIZATION, + genetic_parameters=parameters, + ) + + optimizer.optimierung_ems.assert_called_once_with( + start_hour=0, + parameters=parameters, + ngen=80, + individuals=100, + ) + + def test_ev_repair_is_resimulated_before_fitness_assignment(config_eos: ConfigEOS): _configure_hourly_grid(config_eos) opt = GeneticOptimization(fixed_seed=42) @@ -44,7 +75,9 @@ def test_ev_repair_is_resimulated_before_fitness_assignment(config_eos: ConfigEO eauto=None, ) - with patch.object(opt, "evaluate_inner", side_effect=[first_result, repaired_result]) as evaluate: + with patch.object( + opt, "evaluate_inner", side_effect=[first_result, repaired_result] + ) as evaluate: fitness = opt.evaluate(individual, parameters, start_hour=0, worst_case=False) # type: ignore[arg-type] assert evaluate.call_count == 2 @@ -124,7 +157,9 @@ def test_mutated_warm_start_neighbors_keep_elapsed_slots(config_eos: ConfigEOS): assert all(neighbor != start_solution for neighbor in neighbors) -def test_initial_population_uses_fixed_seed_budget_and_150_survivors(config_eos: ConfigEOS): +def test_initial_population_uses_fixed_seed_budget_and_configured_population( + config_eos: ConfigEOS, +): _configure_hourly_grid(config_eos) config_eos.optimization.genetic.individuals = 300 opt = GeneticOptimization(fixed_seed=42) @@ -164,8 +199,108 @@ def test_initial_population_uses_fixed_seed_budget_and_150_survivors(config_eos: assert first_genes.count(6) == 50 assert first_genes.count(7) == 100 assert first_genes.count(9) == 140 - assert captured["mu"] == 150 - assert captured["lambda"] == 150 + assert captured["mu"] == 300 + assert captured["lambda"] == 300 + + +def test_small_population_scales_warm_and_educated_seed_families(config_eos: ConfigEOS): + _configure_hourly_grid(config_eos) + config_eos.optimization.genetic.individuals = 100 + opt = GeneticOptimization(fixed_seed=42) + opt.optimize_ev = False + opt.setup_deap_environment({"home_appliance": 0}, start_hour=0) + start_solution = [5] * opt.total_slots + captured: dict[str, object] = {} + + def warm_neighbors(_solution, count): + captured["warm_count"] = count + return [[6] * opt.total_slots for _ in range(count)] + + def educated(count): + captured["educated_count"] = count + return [[7] * opt.total_slots for _ in range(count)] + + def fake_ea(population, toolbox, **kwargs): + captured["population"] = list(population) + captured["mu"] = kwargs["mu"] + captured["lambda"] = kwargs["lambda_"] + for individual in population: + individual.fitness.values = (float(sum(individual)),) + individual.extra_data = (0.0, 0.0, 0.0) + kwargs["halloffame"].update(population) + return population, SimpleNamespace(select=lambda _name: []) + + with ( + patch.object(opt, "_mutated_warm_start_neighbors", side_effect=warm_neighbors), + patch.object(opt, "_educated_guess_individuals", side_effect=educated), + patch.object( + opt.toolbox, + "population", + side_effect=lambda n: [creator.Individual([9] * opt.total_slots) for _ in range(n)], + ), + patch("akkudoktoreos.optimization.genetic.genetic.algorithms.eaMuPlusLambda", fake_ea), + ): + opt.optimize(start_solution=start_solution, ngen=1) + + population = captured["population"] + first_genes = [individual[0] for individual in population] # type: ignore[union-attr] + assert len(population) == 100 # type: ignore[arg-type] + assert first_genes.count(5) == 10 + assert first_genes.count(6) == 20 + assert first_genes.count(7) == 40 + assert first_genes.count(9) == 30 + assert captured["warm_count"] == 20 + assert captured["educated_count"] == 40 + assert captured["mu"] == 100 + assert captured["lambda"] == 100 + + +def test_local_search_moves_weak_export_to_later_expensive_import(config_eos: ConfigEOS): + _configure_hourly_grid(config_eos) + opt = GeneticOptimization(fixed_seed=42) + opt.optimize_ev = False + opt.optimize_dc_charge = True + opt.optimize_battery_grid_export = True + opt.bat_possible_charge_values = [1.0] + opt.setup_deap_environment({"home_appliance": 0}, start_hour=0) + + slots = opt.total_slots + export_state = 5 + self_consumption_state = 6 + discharge_state = 1 + source = 10 + targets = list(range(20, 32)) + base = [self_consumption_state] * slots + base[source] = export_state + for slot in targets: + base[slot] = 0 + + opt.simulation.elect_price_hourly = np.full(slots, 0.10) + opt.simulation.elect_price_hourly[targets] = 0.30 + opt.simulation.elect_revenue_per_hour_arr = np.full(slots, 0.05) + opt.simulation.elect_revenue_per_hour_arr[source] = 0.20 + opt.simulation.pv_prediction_wh = np.zeros(slots) + opt.simulation.load_energy_array = np.full(slots, 100.0) + + def evaluate(individual): + export_value = -0.20 if individual[source] == export_state else 0.0 + avoided_import = -0.05 * sum(individual[slot] == discharge_state for slot in targets) + return (export_value + avoided_import,) + + opt.toolbox.register("evaluate", evaluate) + incumbent = creator.Individual(base) + incumbent.fitness.values = evaluate(incumbent) + + best, evaluations, improvements, initial, final = opt._locally_improve_grid_export( + incumbent, + max_evaluations=96, + ) + + assert evaluations > 0 + assert improvements == 1 + assert final < initial + assert best[source] == self_consumption_state + assert sum(best[slot] == discharge_state for slot in targets) >= 6 def test_educated_guesses_encode_high_price_direct_marketing(config_eos: ConfigEOS): diff --git a/tests/testdata/optimize_result_1_be.json b/tests/testdata/optimize_result_1_be.json index ebc525bd..d0995b71 100644 --- a/tests/testdata/optimize_result_1_be.json +++ b/tests/testdata/optimize_result_1_be.json @@ -110,16 +110,16 @@ 0, 0, 0, - 1, - 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, 0, 1, - 0, - 0, - 0, - 0, - 0, - 0, 1, 1, 0, @@ -237,12 +237,11 @@ 0.0, 0.0, 0.0, - 0.0, - 0.21367321450420126, + 0.022582049506752234, + 0.3039575, 0.19320652266312205, 0.1358062627100041, 0.0692592282596561, - 0.023370695807696434, 0.0, 0.0, 0.0, @@ -262,7 +261,8 @@ 0.0, 0.0, 0.0, - 0.11745809857246167, + 0.0, + 0.09514375330616998, 0.15236390731688437, 0.10316291465819699, 0.05435777788576338, @@ -272,10 +272,10 @@ 0.0, 0.0 ], - "Gesamt_Verluste": 3638.132237848992, - "Gesamtbilanz_Euro": 0.5580695656866563, - "Gesamteinnahmen_Euro": 1.0626586223779864, - "Gesamtkosten_Euro": 1.6207281880646427, + "Gesamt_Verluste": 3421.6165248449383, + "Gesamtbilanz_Euro": 0.5951993628823129, + "Gesamteinnahmen_Euro": 1.1298399163065491, + "Gesamtkosten_Euro": 1.725039279188862, "Home_appliance_wh_per_hour": [ 0.0, 0.0, @@ -318,16 +318,16 @@ ], "home_appliance_energy_wh": {}, "Kosten_Euro_pro_Stunde": [ - 0.0, - 0.0, + 0.10013413727316416, + 0.09007999454232955, 0.11436917344552285, - 0.0, + 0.07557452231671152, 0.0, 4.55656845588237e-17, 0.001414013162203277, 0.005881449073870462, 0.05258762370598476, - 0.1614775630079859, + 0.0, 0.0, 0.0, 0.26650619799999997, @@ -358,16 +358,16 @@ 0.16484566 ], "Netzbezug_Wh_pro_Stunde": [ - 0.0, - 0.0, + 439.1848126015972, + 407.23324838304495, 546.436566868241, - 0.0, + 402.20607938643707, 0.0, 2.2737367544323206e-13, 6.433180901743754, 25.909467285772962, 175.4675465665157, - 505.40708296709204, + 0.0, 0.0, 0.0, 912.38, @@ -402,12 +402,11 @@ 0.0, 0.0, 0.0, - 0.0, - 3052.474492917161, + 322.60070723931767, + 4342.25, 2760.093180901744, 1940.089467285773, 989.4175465665157, - 333.86708296709196, 0.0, 0.0, 0.0, @@ -427,7 +426,8 @@ 0.0, 0.0, 0.0, - 1677.9728367494527, + 0.0, + 1359.1964758024283, 2176.6272473840627, 1473.7559236885286, 776.5396840823341, @@ -438,16 +438,16 @@ 0.0 ], "Verluste_Pro_Stunde": [ - 97.85621559422765, - 101.92059640365329, + 37.96737751219166, + 46.38878980596536, 39.91398802418894, - 106.67021307906845, - 582.6179999999995, - 154.77306084994075, + 51.82392952637247, + 543.9059151312817, 0.0, 0.0, 0.0, 0.0, + 108.98319763338179, 106.80230977350088, 133.7321802766326, 0.0, @@ -467,7 +467,7 @@ 165.15986945297027, 65.92300791736113, 538.2984000000001, - 240.58122702042965, + 278.8343903340726, 0.0, 0.0, 0.0, @@ -479,35 +479,35 @@ ], "akku_soc_pro_stunde": [ 80.0, - 79.16421888032488, - 78.69989843940196, - 79.80862032896276, - 79.51691497639054, - 95.70074830972388, + 81.05464937533866, + 82.3432268699488, + 83.45194875950959, + 84.8915023574644, 100.0, 100.0, 100.0, 100.0, 100.0, - 96.82533215994886, - 92.60407390132525, - 92.60407390132525, - 92.60407390132525, - 90.38140689030597, - 86.64496577460349, - 83.65624332281286, - 81.03576295091202, - 78.64117789581559, - 76.53679600876325, - 74.35484869471367, - 70.91195845376538, - 67.41428833522274, - 68.03406859993031, - 69.53927969874645, - 71.94548598914552, - 76.53326014061692, - 78.36445480498806, - 93.3171881383214, + 98.93741213017658, + 95.76274429012544, + 91.54148603150185, + 91.54148603150185, + 91.54148603150185, + 89.31881902048255, + 85.58237790478007, + 82.59365545298944, + 79.97317508108861, + 77.57859002599218, + 75.47420813893983, + 73.29226082489025, + 69.84937058394196, + 66.35170046539932, + 66.97148073010689, + 68.47669182892304, + 70.8828981193221, + 75.4706722707935, + 77.30186693516465, + 92.254600268498, 100.0, 100.0, 100.0, @@ -717,18 +717,18 @@ 0.0, 0.0, 0.0, - 2.0, - 2.0, - 1.0, - 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, 0.0, 1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, 1.0, 1.0, 0.0, diff --git a/tests/testdata/optimize_result_2.json b/tests/testdata/optimize_result_2.json index 5c417682..d60de8cb 100644 --- a/tests/testdata/optimize_result_2.json +++ b/tests/testdata/optimize_result_2.json @@ -15,7 +15,7 @@ 0.0, 0.0, 0.0, - 0.0, + 1.0, 0.0, 0.0, 0.0, @@ -110,23 +110,23 @@ 0, 0, 0, - 0, - 0, - 0, - 0, - 0, - 0, + 1, + 1, + 1, + 1, + 1, 0, 0, 0, 0, 1, 1, + 1, 0, 0, 1, 1, - 1, + 0, 1, 1, 1, @@ -145,7 +145,7 @@ 0, 1, 1, - 1, + 0, 0, 0 ], @@ -161,16 +161,16 @@ 0.0, 0.0, 0.0, - 0.875, 0.625, - 0.625, - 0.375, - 0.875, - 0.75, - 0.75, - 0.375, - 0.1, + 1.0, 0.0, + 0.375, + 0.625, + 1.0, + 0.625, + 0.875, + 0.0, + 0.3, 0.0, 0.0, 0.0, @@ -202,16 +202,16 @@ ], "result": { "Last_Wh_pro_Stunde": [ - 10713.07, - 7963.91, - 8220.56, - 7772.03, - 13323.67, - 9456.82, - 9496.22, - 5243.78, - 2233.12, - 1178.71, + 7953.07, + 12103.91, + 1320.56, + 5272.03, + 8063.67, + 17059.173961709643, + 8116.22, + 10763.78, + 1129.12, + 4490.71, 1050.98, 988.56, 912.38, @@ -231,8 +231,8 @@ 827.01, 1257.98, 1232.67, - 871.26, - 860.88, + 3371.26, + 3360.88, 1158.03, 1222.72, 1221.04, @@ -243,43 +243,43 @@ ], "EAuto_SoC_pro_Stunde": [ 5.0, - 20.294999999999998, - 31.22, - 42.144999999999996, - 48.699999999999996, - 63.995000000000005, - 77.105, - 90.215, - 96.77, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518, - 98.518 + 15.925, + 33.405, + 33.405, + 39.96, + 50.885000000000005, + 68.365, + 79.29, + 94.585, + 94.585, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001 ], "Einnahmen_Euro_pro_Stunde": [ 0.0, @@ -313,19 +313,45 @@ 0.0, 0.0, 0.0, - 0.053661230306193436, - 0.05435777788576338, + 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 ], - "Gesamt_Verluste": 6227.580163897914, - "Gesamtbilanz_Euro": 12.033027623527284, - "Gesamteinnahmen_Euro": 0.10801900819195681, - "Gesamtkosten_Euro": 12.14104663171924, + "Gesamt_Verluste": 8756.080717524794, + "Gesamtbilanz_Euro": 9.985237573040141, + "Gesamteinnahmen_Euro": 0.0, + "Gesamtkosten_Euro": 9.985237573040141, "Home_appliance_wh_per_hour": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, 0.0, 0.0, 0.0, @@ -337,36 +363,36 @@ 0.0, 0.0, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, 0.0 ], "home_appliance_energy_wh": { "dishwasher1": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, 0.0, 0.0, 0.0, @@ -378,112 +404,86 @@ 0.0, 0.0, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, 0.0 ] }, "Kosten_Euro_pro_Stunde": [ - 2.23047612, - 1.5308798733801507, - 1.4889564723943407, - 1.242149738556099, - 1.3742255912165289, - 0.8479283355019763, - 1.2336553365360492, - 0.5407163922683618, - 0.20558284318867343, - 0.1614775630079859, + 0.5980075076051746, + 1.473337992, + 0.0, + 0.0, + 0.0, + 2.3442290999999997, + 0.9428514400845971, + 1.762926136273946, + 0.05258762370598476, + 0.0, 0.0, 0.0, 0.26650619799999997, 0.19588158, 0.0, 0.0, + 0.22802125600000003, 0.0, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, + 0.08281196422730584, + 0.16677339, + 0.264113778992023, + 0.2536463762855701, 0.1306329312971816, 0.07362195915902499, 0.060401289430882174, 0.009619897970888898, 0.07029121023060134, 4.179128154646605e-17, - 2.3325608707865465e-05, - 0.0021886029750169123, + 0.04064749825228731, + 0.1994538035279033, 0.013012984677295973, 0.08357424731947552, 0.0, 0.0, - 0.0, + 0.293043269, 0.214398479, 0.16484566 ], "Netzbezug_Wh_pro_Stunde": [ - 9782.789999999999, - 6920.795087613701, - 7113.9821901306295, - 6610.695787951565, - 7476.74423948057, - 4231.179318872138, - 5612.626644840988, - 2382.0105386271443, - 685.962106068313, - 505.40708296709204, + 2622.8399456367306, + 6660.66, + 0.0, + 0.0, + 0.0, + 11697.75, + 4289.587989465865, + 7766.194432924872, + 175.4675465665157, + 0.0, 0.0, 0.0, 912.38, 704.61, 0.0, 0.0, + 694.34, 0.0, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, + 248.38621543882974, + 506.91, + 799.8600211751151, + 833.8145177040436, 537.58407941227, 322.9033296448464, 273.0618871197205, 45.96224544141853, 374.088399311343, 2.2737367544323206e-13, - 0.11639525303326081, - 9.957247384062384, + 202.83182760622412, + 907.4331370696236, 57.32592368852852, 278.85968408233407, 0.0, 0.0, - 0.0, + 987.01, 733.99, 592.97 ], @@ -519,8 +519,8 @@ 0.0, 0.0, 0.0, - 766.589004374192, - 776.5396840823341, + 0.0, + 0.0, 0.0, 0.0, 0.0, @@ -528,84 +528,84 @@ 0.0 ], "Verluste_Pro_Stunde": [ - 483.0, - 345.0162105136441, - 345.0194628156755, - 207.04269455418785, - 503.6273087376684, - 449.2115182646565, - 424.3543973809186, - 225.74286463525735, - 102.70945272819762, - 40.06404995605101, + 945.0059934764078, + 1152.0, + 114.42806532440363, + 768.1700560862765, + 752.877211603942, + 1152.0, + 362.1897587359037, + 485.44493195098454, + 118.73010558798194, + 641.287728412984, 106.80230977350088, 133.7321802766326, 0.0, 0.0, 70.41409090909087, 118.37045454545455, - 94.68272727272722, + 0.0, 83.01681818181817, 75.86045454545456, - 66.66681818181814, - 69.12409090909085, - 109.07302361034766, - 116.99491129525349, + 32.79597062197777, + 0.0, + 0.0012025410138062752, + 3.292931608338464, 22.312089529472388, 54.18759955738153, 86.6234264543665, 165.15986945297027, 65.92300791736113, 538.2984000000001, - 441.9379674303641, - 261.1952696860876, - 84.86003031772043, - 0.0, + 166.26381931274682, + 68.89237644835487, + 176.85071084262336, + 93.18476208988011, 111.78035844493081, 90.18403329253945, - 134.59227272727276, + 0.0, 0.0, 0.0 ], "akku_soc_pro_stunde": [ 80.0, - 80.0, - 80.00045029204567, - 80.00099092581443, - 80.00217688565297, - 80.57515768392155, - 81.55325541349534, - 81.8408775629653, - 82.36151269172245, - 83.68121971195016, - 84.79410998850713, - 81.619442148456, - 77.39818388983241, - 77.39818388983241, - 77.39818388983241, - 75.17551687881311, - 71.43907576311062, - 68.45035331131999, - 65.82987293941916, - 63.43528788432273, - 61.33090599727039, - 59.14895868322081, - 55.706068442272525, - 52.208398323729895, - 52.82817858843747, - 54.33338968725362, - 56.73959597765268, - 61.327370129124084, - 63.158564793495216, - 78.11129812682856, - 90.387352777672, - 97.64277693561888, - 100.0, - 100.0, - 98.60068046043587, - 96.11252124341868, - 91.86402778611841, - 91.86402778611841 + 61.060772546061834, + 42.12137860666789, + 40.878014722863334, + 23.18290087405168, + 13.89226749676402, + 30.55893416343069, + 31.036427461650234, + 31.104342238066472, + 34.40240072662152, + 19.405325997784466, + 16.23065815773333, + 12.00939989910972, + 12.00939989910972, + 12.00939989910972, + 9.786732888090437, + 6.050291772387957, + 6.050291772387957, + 3.429811400487131, + 1.0352263453907122, + 0.0, + 0.0, + 3.3403917050174315e-05, + 0.09144092700684212, + 0.7112211917144087, + 2.216432290530563, + 4.622638580929631, + 9.210412732401027, + 11.04160739677217, + 25.994340730105503, + 30.612780155459586, + 32.526457279024996, + 37.438977024653425, + 40.027442638261206, + 38.62812309869707, + 36.139963881679876, + 36.139963881679876, + 36.139963881679876 ], "Electricity_price": [ 0.000228, @@ -702,16 +702,16 @@ 0.0, 0.0, 0.0, - 0.875, 0.625, - 0.625, - 0.375, - 0.875, - 0.75, - 0.75, - 0.375, - 0.1, + 1.0, 0.0, + 0.375, + 0.625, + 1.0, + 0.625, + 0.875, + 0.0, + 0.3, 0.0, 0.0, 0.0, @@ -795,37 +795,37 @@ "capacity_wh": 60000, "charging_efficiency": 0.95, "max_charge_power_w": 11040, - "soc_wh": 59110.8, + "soc_wh": 59897.4, "initial_soc_percentage": 5 }, "start_solution": [ 0.0, 0.0, + 1.0, + 2.0, + 1.0, + 1.0, 0.0, 0.0, 0.0, + 2.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 2.0, 0.0, 0.0, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, + 1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, - 1.0, + 0.0, 1.0, 1.0, 1.0, @@ -844,63 +844,63 @@ 0.0, 1.0, 1.0, - 1.0, 0.0, 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 3.0, + 1.0, + 4.0, + 3.0, + 6.0, + 0.0, + 1.0, + 3.0, + 6.0, + 3.0, + 5.0, + 0.0, + 2.0, + 3.0, + 3.0, + 5.0, + 4.0, + 6.0, + 3.0, + 0.0, + 0.0, + 1.0, + 5.0, + 0.0, 3.0, 6.0, + 3.0, + 1.0, + 3.0, + 2.0, + 0.0, + 2.0, 2.0, 4.0, - 5.0, - 6.0, - 0.0, - 1.0, - 2.0, - 0.0, - 5.0, - 3.0, 3.0, 1.0, 5.0, - 4.0, - 4.0, - 1.0, - 1.0, - 2.0, - 2.0, - 1.0, - 4.0, - 6.0, - 4.0, 5.0, - 6.0, + 3.0, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 3.0 + 2.0, + 29.0 ], - "washingstart": 13, + "washingstart": 39, "appliance_starts": { "dishwasher1": [ - "2025-01-15 13:00:00+01:00" + "2025-01-16 15:00:00+01:00" ] } } \ No newline at end of file diff --git a/tests/testdata/optimize_result_2_be.json b/tests/testdata/optimize_result_2_be.json index 89eb4c7b..d60de8cb 100644 --- a/tests/testdata/optimize_result_2_be.json +++ b/tests/testdata/optimize_result_2_be.json @@ -15,7 +15,7 @@ 0.0, 0.0, 0.0, - 0.0, + 1.0, 0.0, 0.0, 0.0, @@ -112,6 +112,29 @@ 0, 1, 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, 0, 0, 0, @@ -124,29 +147,6 @@ 1, 0, 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 1, - 1, - 0, 0 ], "battery_grid_export_allowed": [], @@ -161,16 +161,16 @@ 0.0, 0.0, 0.0, + 0.625, 1.0, - 0.5, - 0.75, + 0.0, + 0.375, + 0.625, + 1.0, + 0.625, 0.875, - 0.5, - 0.375, - 1.0, - 0.375, - 0.0, 0.0, + 0.3, 0.0, 0.0, 0.0, @@ -202,16 +202,16 @@ ], "result": { "Last_Wh_pro_Stunde": [ - 12093.07, - 6583.91, - 9600.56, - 10792.03, - 6683.67, - 5316.82, - 12256.22, - 5243.78, + 7953.07, + 12103.91, + 1320.56, + 5272.03, + 8063.67, + 17059.173961709643, + 8116.22, + 10763.78, 1129.12, - 1178.71, + 4490.71, 1050.98, 988.56, 912.38, @@ -229,10 +229,10 @@ 992.46, 1155.99, 827.01, - 3757.98, - 3732.67, - 871.26, - 860.88, + 1257.98, + 1232.67, + 3371.26, + 3360.88, 1158.03, 1222.72, 1221.04, @@ -243,43 +243,43 @@ ], "EAuto_SoC_pro_Stunde": [ 5.0, - 22.48, - 31.22, - 44.330000000000005, - 59.62499999999999, + 15.925, + 33.405, + 33.405, + 39.96, + 50.885000000000005, 68.365, - 74.92, - 92.4, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955, - 98.955 + 79.29, + 94.585, + 94.585, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001, + 99.82900000000001 ], "Einnahmen_Euro_pro_Stunde": [ 0.0, @@ -321,10 +321,10 @@ 0.0, 0.0 ], - "Gesamt_Verluste": 7415.050669156861, - "Gesamtbilanz_Euro": 10.086958235191952, + "Gesamt_Verluste": 8756.080717524794, + "Gesamtbilanz_Euro": 9.985237573040141, "Gesamteinnahmen_Euro": 0.0, - "Gesamtkosten_Euro": 10.086958235191952, + "Gesamtkosten_Euro": 9.985237573040141, "Home_appliance_wh_per_hour": [ 0.0, 0.0, @@ -353,10 +353,10 @@ 0.0, 0.0, 0.0, - 2500.0, - 2500.0, 0.0, 0.0, + 2500.0, + 2500.0, 0.0, 0.0, 0.0, @@ -394,10 +394,10 @@ 0.0, 0.0, 0.0, - 2500.0, - 2500.0, 0.0, 0.0, + 2500.0, + 2500.0, 0.0, 0.0, 0.0, @@ -408,82 +408,82 @@ ] }, "Kosten_Euro_pro_Stunde": [ - 1.5419161199999998, - 0.2524105556335792, - 1.7777665549410084, - 1.809540886, - 0.24971825220475874, - 0.12061259291950001, - 1.83015129135275, - 0.5407163922683618, + 0.5980075076051746, + 1.473337992, + 0.0, + 0.0, + 0.0, + 2.3442290999999997, + 0.9428514400845971, + 1.762926136273946, 0.05258762370598476, - 0.1614775630079859, + 0.0, 0.0, 0.0, 0.26650619799999997, 0.19588158, 0.0, 0.0, + 0.22802125600000003, 0.0, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, + 0.08281196422730584, + 0.16677339, + 0.264113778992023, + 0.2536463762855701, 0.1306329312971816, 0.07362195915902499, 0.060401289430882174, 0.009619897970888898, - 0.4410873661898387, - 0.0, - 2.3325608707865465e-05, - 0.0021886029750169123, + 0.07029121023060134, + 4.179128154646605e-17, + 0.04064749825228731, + 0.1994538035279033, 0.013012984677295973, - 0.0, - 0.17784012884918773, + 0.08357424731947552, 0.0, 0.0, + 0.293043269, 0.214398479, 0.16484566 ], "Netzbezug_Wh_pro_Stunde": [ - 6762.789999999999, - 1141.0965444556023, - 8493.867916583891, - 9630.34, - 1358.64119806724, - 601.8592461052895, - 8326.43899614536, - 2382.0105386271443, + 2622.8399456367306, + 6660.66, + 0.0, + 0.0, + 0.0, + 11697.75, + 4289.587989465865, + 7766.194432924872, 175.4675465665157, - 505.40708296709204, + 0.0, 0.0, 0.0, 912.38, 704.61, 0.0, 0.0, + 694.34, 0.0, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, + 248.38621543882974, + 506.91, + 799.8600211751151, + 833.8145177040436, 537.58407941227, 322.9033296448464, 273.0618871197205, 45.96224544141853, - 2347.45804252176, - 0.0, - 0.11639525303326081, - 9.957247384062384, + 374.088399311343, + 2.2737367544323206e-13, + 202.83182760622412, + 907.4331370696236, 57.32592368852852, - 0.0, - 556.6201215937018, + 278.85968408233407, 0.0, 0.0, + 987.01, 733.99, 592.97 ], @@ -528,84 +528,84 @@ 0.0 ], "Verluste_Pro_Stunde": [ + 945.0059934764078, 1152.0, - 876.0523853346722, - 414.00574999006693, - 483.0, - 359.25494376806876, - 303.4931095326348, - 556.8118795374434, - 225.74286463525735, + 114.42806532440363, + 768.1700560862765, + 752.877211603942, + 1152.0, + 362.1897587359037, + 485.44493195098454, 118.73010558798194, - 40.06404995605101, + 641.287728412984, 106.80230977350088, 133.7321802766326, 0.0, 0.0, 70.41409090909087, 118.37045454545455, - 94.68272727272722, + 0.0, 83.01681818181817, 75.86045454545456, - 66.66681818181814, - 69.12409090909085, - 109.07302361034766, - 116.99491129525349, + 32.79597062197777, + 0.0, + 0.0012025410138062752, + 3.292931608338464, 22.312089529472388, 54.18759955738153, 86.6234264543665, 165.15986945297027, - 2.727365102611202, - 238.2983999999999, - 441.9379674303641, - 261.1952696860876, + 65.92300791736113, + 538.2984000000001, + 166.26381931274682, + 68.89237644835487, 176.85071084262336, - 131.21108264656203, - 35.877614591244196, + 93.18476208988011, + 111.78035844493081, 90.18403329253945, - 134.59227272727276, + 0.0, 0.0, 0.0 ], "akku_soc_pro_stunde": [ 80.0, - 61.06060606060606, - 42.12266726939745, - 42.122826991343764, - 42.122826991343764, - 44.435464318234565, - 47.11582847191886, - 47.249491792403404, - 47.770126921160546, - 51.0681854097156, - 52.181075686272585, - 49.006407846221435, - 44.78514958759783, - 44.78514958759783, - 44.78514958759783, - 42.562482576578546, - 38.82604146087607, - 35.837319009085434, - 33.2168386371846, - 30.822253582088187, - 28.717871695035846, - 26.53592438098626, - 23.09303414003797, - 19.595364021495346, - 20.215144286202914, - 21.72035538501907, - 24.126561675418138, - 28.71433582688953, - 28.79009596862873, - 35.40949596862873, - 47.68555061947217, - 54.94097477741905, - 59.85349452304748, - 61.241634361507465, - 62.2382347668198, - 59.7500755498026, - 55.50158209250233, - 55.50158209250233 + 61.060772546061834, + 42.12137860666789, + 40.878014722863334, + 23.18290087405168, + 13.89226749676402, + 30.55893416343069, + 31.036427461650234, + 31.104342238066472, + 34.40240072662152, + 19.405325997784466, + 16.23065815773333, + 12.00939989910972, + 12.00939989910972, + 12.00939989910972, + 9.786732888090437, + 6.050291772387957, + 6.050291772387957, + 3.429811400487131, + 1.0352263453907122, + 0.0, + 0.0, + 3.3403917050174315e-05, + 0.09144092700684212, + 0.7112211917144087, + 2.216432290530563, + 4.622638580929631, + 9.210412732401027, + 11.04160739677217, + 25.994340730105503, + 30.612780155459586, + 32.526457279024996, + 37.438977024653425, + 40.027442638261206, + 38.62812309869707, + 36.139963881679876, + 36.139963881679876, + 36.139963881679876 ], "Electricity_price": [ 0.000228, @@ -702,16 +702,16 @@ 0.0, 0.0, 0.0, + 0.625, 1.0, - 0.5, - 0.75, + 0.0, + 0.375, + 0.625, + 1.0, + 0.625, 0.875, - 0.5, - 0.375, - 1.0, - 0.375, - 0.0, 0.0, + 0.3, 0.0, 0.0, 0.0, @@ -795,43 +795,44 @@ "capacity_wh": 60000, "charging_efficiency": 0.95, "max_charge_power_w": 11040, - "soc_wh": 59373.0, + "soc_wh": 59897.4, "initial_soc_percentage": 5 }, "start_solution": [ - 1.0, 0.0, - 2.0, - 2.0, 0.0, 1.0, 2.0, - 2.0, - 1.0, - 2.0, 1.0, 1.0, 0.0, 0.0, 0.0, - 0.0, - 0.0, + 2.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 2.0, 0.0, 0.0, 0.0, 1.0, 1.0, + 1.0, 0.0, 0.0, 1.0, 1.0, + 0.0, + 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, - 1.0, - 1.0, - 1.0, + 0.0, 0.0, 0.0, 0.0, @@ -842,65 +843,64 @@ 0.0, 0.0, 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, 0.0, - 1.0, - 1.0, 0.0, 0.0, 0.0, 0.0, 2.0, 3.0, - 6.0, + 1.0, 4.0, 3.0, - 1.0, 6.0, + 0.0, 1.0, + 3.0, 6.0, - 2.0, - 4.0, + 3.0, 5.0, - 2.0, - 1.0, - 6.0, - 1.0, - 3.0, - 4.0, - 3.0, - 3.0, + 0.0, 2.0, 3.0, - 0.0, - 0.0, - 6.0, - 4.0, - 5.0, - 6.0, - 1.0, - 2.0, - 4.0, 3.0, - 3.0, - 0.0, - 6.0, 5.0, 4.0, - 1.0, 6.0, + 3.0, + 0.0, + 0.0, 1.0, + 5.0, + 0.0, + 3.0, + 6.0, + 3.0, + 1.0, + 3.0, + 2.0, + 0.0, + 2.0, 2.0, 4.0, + 3.0, 1.0, - 6.0, - 1.0, - 4.0, - 27.0 + 5.0, + 5.0, + 3.0, + 0.0, + 2.0, + 29.0 ], - "washingstart": 37, + "washingstart": 39, "appliance_starts": { "dishwasher1": [ - "2025-01-16 13:00:00+01:00" + "2025-01-16 15:00:00+01:00" ] } } \ No newline at end of file