From dcf72975ed589e2880f7ca3dcbe536e72648bab0 Mon Sep 17 00:00:00 2001 From: Katharina Demmich Date: Thu, 19 Mar 2026 11:52:07 +0100 Subject: [PATCH 01/11] config: adjust configuration for pure speed optimisation Raise ValueErrors for pure speed optimisation if initial population is not read via geojson and more than one file is provided. --- WeatherRoutingTool/config.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/WeatherRoutingTool/config.py b/WeatherRoutingTool/config.py index d75b9fb..8138efd 100644 --- a/WeatherRoutingTool/config.py +++ b/WeatherRoutingTool/config.py @@ -505,6 +505,11 @@ def check_speed_determination(self) -> Self: crossover_only_waypoints = self.GENETIC_CROSSOVER_TYPE == "waypoints" + mutate_only_speed = ((self.GENETIC_MUTATION_TYPE == "speed") + or (self.GENETIC_MUTATION_TYPE == "percentage_change_speed") + or (self.GENETIC_MUTATION_TYPE == "gaussian_speed")) + crossover_only_speed = self.GENETIC_CROSSOVER_TYPE == "speed" + if self.ALGORITHM_TYPE == "genetic": # run mode: route optimisation with constant speed if mutate_only_waypoints and crossover_only_waypoints: @@ -516,14 +521,22 @@ def check_speed_determination(self) -> Self: # run modes: speed optimisation for fixed route as well as simultaneous waypoint and speed optimisation else: - logger.info('Algorithm run mode: speed optimisation for fixed route or simultaneous ' - 'waypoint and speed optimisation.') if self.ARRIVAL_TIME is None or self.BOAT_SPEED is None: raise ValueError('Please provide a valid arrival time and boat speed.') - if self.GENETIC_MUTATION_TYPE == "speed" and self.GENETIC_CROSSOVER_TYPE == "speed": - raise NotImplementedError("Pure speed optimisation of single routes is not yet implemented but planned " - "for the future.") + # run mode: pure speed optimisation + if mutate_only_speed and crossover_only_speed: + logger.info('Algorithm run mode: speed optimisation for fixed route.') + + if self.GENETIC_POPULATION_TYPE != "from_geojson": + raise ValueError('For pure speed optimisaton, only input from geojson is allowed.') + + nof_files = len(os.listdir(self.GENETIC_POPULATION_PATH)) + if nof_files > 1: + raise ValueError( + 'For pure speed optimisaton, only a single route can be optimised. Your directory contains: ', + nof_files) + else: if self.BOAT_SPEED is None: raise ValueError('Please provide a valid boat speed.') From dd540b086ce0fac87c55c304a87b4c0b7429fa2e Mon Sep 17 00:00:00 2001 From: Katharina Demmich Date: Tue, 24 Mar 2026 13:28:43 +0100 Subject: [PATCH 02/11] feature: add plot for speed vs. distance for individuals in one generation --- .../algorithms/genetic/__init__.py | 57 ++++++++++++++++++- .../algorithms/genetic/utils.py | 21 +++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/WeatherRoutingTool/algorithms/genetic/__init__.py b/WeatherRoutingTool/algorithms/genetic/__init__.py index 6887d40..be9a361 100644 --- a/WeatherRoutingTool/algorithms/genetic/__init__.py +++ b/WeatherRoutingTool/algorithms/genetic/__init__.py @@ -196,6 +196,7 @@ def terminate(self, res: Result, problem: RoutingProblem): self.plot_running_metric(res) self.plot_population_per_generation(res, best_route) + self.plot_speed_per_generation(res, best_route) self.plot_convergence(res) self.plot_coverage(res, best_route) self.plot_objective_space(res, best_index) @@ -343,8 +344,61 @@ def plot_running_metric(self, res): plt.cla() plt.close() + def plot_speed_per_generation(self, res, best_route) -> None: + """Plot line diagrams of speed vs. travel distance for each individual in one generation. + + :param res: Result of GA minimization + :type res: pymoo.core.result.Result + :param best_route: Optimum route + :type best_route: np.ndarray + """ + history = res.history + + for igen in range(len(history)): + plt.clf() + plt.close('all') + + fig, ax = plt.subplots(figsize=graphics.get_standard('fig_size')) + plt.rcParams['font.size'] = graphics.get_standard('font_size') + + last_pop = history[igen].pop.get('X') + objs = [] + for iroute in range(0, last_pop.shape[0]): + hist_values = utils.get_hist_values_from_route(last_pop[iroute, 0], self.departure_time) + + new_line = ax.plot( + hist_values["bin_centres"].to(u.km).value, + hist_values["bin_contents"].to(u.m / u.second).value, + color="blue", + alpha=0.3, + linestyle='-', + zorder=2 + ) + objs.append(new_line) + + if igen == (self.n_generations - 1): + hist_values_best_route = utils.get_hist_values_from_route(best_route, self.departure_time) + ax.plot( + hist_values_best_route["bin_centres"].to(u.km).value, + hist_values_best_route["bin_contents"].to(u.m / u.second).value, + color="firebrick", + linewidth=3 + ) + left, right = plt.xlim() + ax.set_xlim(-100, right) + + plt.ylabel("speed (m/s)") + plt.xlabel('travel distance (km)') + plt.xticks() + plt.tight_layout() + ax.legend() + + figname = f"genetic_algorithm_speed {igen:02}.png" + plt.savefig(os.path.join(self.figure_path, figname)) + plt.close(fig) + def plot_population_per_generation(self, res, best_route): - """Plot figures and save them in WRT_FIGURE_PATH + """Plot routes for each individual in one generation on a map. :param res: Result of GA minimization :type res: pymoo.core.result.Result @@ -413,7 +467,6 @@ def plot_population_per_generation(self, res, best_route): cbar = fig.colorbar(route_lc, ax=ax, orientation='vertical', pad=0.15, shrink=0.7) cbar.set_label('Geschwindigkeit ($m/s$)') plt.tight_layout() - ax.legend() figname = f"genetic_algorithm_generation {igen:02}.png" diff --git a/WeatherRoutingTool/algorithms/genetic/utils.py b/WeatherRoutingTool/algorithms/genetic/utils.py index 090dc6f..4e85c0a 100644 --- a/WeatherRoutingTool/algorithms/genetic/utils.py +++ b/WeatherRoutingTool/algorithms/genetic/utils.py @@ -3,9 +3,13 @@ from typing import Optional import numpy as np +from astropy import units as u from geographiclib.geodesic import Geodesic from pymoo.core.duplicate import ElementwiseDuplicateElimination +import WeatherRoutingTool.utils.graphics as graphics +from WeatherRoutingTool.routeparams import RouteParams + logger = logging.getLogger("WRT.genetic") @@ -148,6 +152,23 @@ def route_from_geojson_file(path: str) -> list[tuple[float, float]]: return route_from_geojson(dt) +def get_hist_values_from_route(route: np.array, departure_time): + lats = route[:, 0] + lons = route[:, 1] + speed = route[:, 2] + speed = speed[:-1] * u.meter / u.second + + waypoint_coords = RouteParams.get_per_waypoint_coords( + route_lons=lons, + route_lats=lats, + start_time=departure_time, + bs=speed, ) + dist = waypoint_coords['dist'] + + hist_values = graphics.get_hist_values_from_widths(dist, speed, "speed") + return hist_values + + # ---------- class RouteDuplicateElimination(ElementwiseDuplicateElimination): """Custom duplicate elimination strategy for routing problem.""" From e47a6b1a7b2f67407e3493a496deff1b49c8dbfa Mon Sep 17 00:00:00 2001 From: Katharina Demmich Date: Wed, 25 Mar 2026 11:18:13 +0100 Subject: [PATCH 03/11] style: change axis limits for plotting speed vs. distance --- WeatherRoutingTool/algorithms/genetic/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/WeatherRoutingTool/algorithms/genetic/__init__.py b/WeatherRoutingTool/algorithms/genetic/__init__.py index be9a361..7b43cfb 100644 --- a/WeatherRoutingTool/algorithms/genetic/__init__.py +++ b/WeatherRoutingTool/algorithms/genetic/__init__.py @@ -386,6 +386,7 @@ def plot_speed_per_generation(self, res, best_route) -> None: ) left, right = plt.xlim() ax.set_xlim(-100, right) + ax.set_ylim(0, 10) plt.ylabel("speed (m/s)") plt.xlabel('travel distance (km)') From 5538e958a3306b3c508367c8740f08fd3d1e6d32 Mon Sep 17 00:00:00 2001 From: Katharina Demmich Date: Wed, 25 Mar 2026 11:21:44 +0100 Subject: [PATCH 04/11] feature: add velocity spread to 1st population for pure speed optimisation Every individual of the initial population gets a different, constant velocity. The velocity values are determined as quantiles from a gaussian distribution centered around the user-defined boat speed. A unit test is added, accordingly. --- .../algorithms/genetic/population.py | 57 ++++++++++++++++++- tests/test_genetic.py | 47 ++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/WeatherRoutingTool/algorithms/genetic/population.py b/WeatherRoutingTool/algorithms/genetic/population.py index 24ee40b..0822c3c 100644 --- a/WeatherRoutingTool/algorithms/genetic/population.py +++ b/WeatherRoutingTool/algorithms/genetic/population.py @@ -190,11 +190,53 @@ def __init__(self, config: Config, routes_dir: Path, default_route, constraints_ constraints_list=constraints_list, pop_size=pop_size ) + self.sole_speed_mutation = config.GENETIC_MUTATION_TYPE == "speed" and config.GENETIC_CROSSOVER_TYPE == "speed" + self.min_boat_speed = config.BOAT_SPEED_BOUNDARIES[0] + self.max_boat_speed = config.BOAT_SPEED_BOUNDARIES[1] if not routes_dir.exists() or not routes_dir.is_dir(): raise FileNotFoundError("Routes directory not found") self.routes_dir = routes_dir + @staticmethod + def spread_velocity(min_boat_speed: float, max_boat_speed: float, boat_speed: float, pop_size: float) -> np.ndarray: + """ + Calculate velocity spread for individuals in case of pure speed optimisation. + + The following steps are performed to obtain values for the boat speed that are accumulated around the original + boat speed: + - sample a numpy array from a gaussian distribution (mean = original boat speed, + sigma = 1/4 possible value range of boat speed) + - cut values below the minimum velocity and above the maximum velocity + - determine `pop_size` quantiles with equally spaced probabilities + + :param min_boat_speed: Minimum boat speed + :type min_boat_speed: float + :param max_boat_speed: Maximum boat speed + :type max_boat_speed: float + :param boat_speed: Boat speed + :type boat_speed: float + :param pop_size: Population size + :type pop_size: int + :return: array of quantiles + :rtype: np.ndarray + """ + std_dev = (max_boat_speed - min_boat_speed) / 4 + gaussian_sample = np.random.normal(boat_speed, std_dev, 1000) + gaussian_sample[gaussian_sample < min_boat_speed] = np.nan + gaussian_sample[gaussian_sample > max_boat_speed] = np.nan + gaussian_sample = gaussian_sample[~np.isnan(gaussian_sample)] + + quant_size = 100. / pop_size * 0.01 + + quantiles = np.full(pop_size, np.nan) + quant_sum = 0 + for q in range(pop_size): + quantiles[q] = np.quantile(gaussian_sample, q=quant_sum) + quant_sum += quant_size + + return quantiles + def generate(self, problem, n_samples, **kw): logger.debug(f"Population from geojson routes: {self.routes_dir}") @@ -205,15 +247,22 @@ def generate(self, problem, n_samples, **kw): # FIXME: add test in config.py and raise exception depending on configuration (not only speed optimization...) X = np.full((n_samples, 1), None, dtype=object) + quantiles = None + + # determine quantiles for pure speed optimisation + if self.sole_speed_mutation: + quantiles = self.spread_velocity(self.min_boat_speed, self.max_boat_speed, self.boat_speed.value, + self.pop_size) + # obtain list of filenames files = [] for file in os.listdir(self.routes_dir): if match(r"route_[0-9]+\.(json|geojson)$", file.lower()): files.append(file) - if len(files) == 0: raise ValueError(f"Couldn't find any route in {self.routes_dir} for the initial population.") + # read route(s) from file for i, file in enumerate(files): path = os.path.join(self.routes_dir, file) if not os.path.exists(path): @@ -227,6 +276,12 @@ def generate(self, problem, n_samples, **kw): X[added_routes, 0] = np.copy(X[0, 0]) added_routes += 1 + # mutate velocity in case of pure speed optimisation + if self.sole_speed_mutation: + for i, (rt,) in enumerate(X): + rt[:, -1] = quantiles[i] + rt[-1, -1] = -99. + return X diff --git a/tests/test_genetic.py b/tests/test_genetic.py index 7042c07..ea1ced6 100644 --- a/tests/test_genetic.py +++ b/tests/test_genetic.py @@ -15,7 +15,7 @@ from WeatherRoutingTool.algorithms.genetic.crossover import SinglePointCrossover, SpeedCrossover from WeatherRoutingTool.algorithms.genetic.patcher import PatcherBase, GreatCircleRoutePatcher, IsofuelPatcher, \ GreatCircleRoutePatcherSingleton, IsofuelPatcherSingleton, PatchFactory -from WeatherRoutingTool.algorithms.genetic.population import IsoFuelPopulation +from WeatherRoutingTool.algorithms.genetic.population import IsoFuelPopulation, FromGeojsonPopulation from WeatherRoutingTool.algorithms.genetic.mutation import RandomPlateauMutation, RouteBlendMutation from WeatherRoutingTool.config import Config from WeatherRoutingTool.algorithms.genetic.repair import ConstraintViolationRepair @@ -414,3 +414,48 @@ def test_speed_crossover(plt): pyplot.tight_layout() plt.saveas = "test_speed_crossover.png" + + +def test_spread_velocity(plt): + dirname = os.path.dirname(__file__) + configpath = os.path.join(dirname, 'config.isofuel_single_route.json') + routepath = Path(os.path.join(dirname, 'data/')) + config = Config.assign_config(Path(configpath)) + default_map = [32., 15, 36, 29] + constraint_list = basic_test_func.generate_dummy_constraint_list() + + min_boat_speed = 3 + max_boat_speed = 15 + boat_speed = 7 + population_size = 20 + + pop = FromGeojsonPopulation( + config=config, + default_route=default_map, + constraints_list=constraint_list, + pop_size=population_size, + routes_dir=routepath + ) + quantiles = pop.spread_velocity(min_boat_speed, max_boat_speed, boat_speed, population_size) + + assert quantiles.shape[0] == population_size + assert np.min(quantiles) >= min_boat_speed + assert np.max(quantiles) <= max_boat_speed + + x_dummy = np.full(quantiles.shape, 1) + fig, ax = pyplot.subplots(figsize=graphics.get_standard('fig_size')) + marker_quant = dict( + marker="o", + markersize=5, + markerfacecolor="gold", + markeredgecolor="black", ) + + marker_bounds = dict( + marker="x", + markersize=7, + markerfacecolor="blue", + markeredgecolor="blue", ) + ax.plot(quantiles, x_dummy, **marker_quant, color="none") + ax.plot([min_boat_speed, max_boat_speed, boat_speed], [1, 1, 1], **marker_bounds, color="none") + + plt.saveas = "test_spread_velocidy.png" From c65dd7e07189f2e6795ddb553855736413430610 Mon Sep 17 00:00:00 2001 From: Katharina Demmich Date: Wed, 1 Apr 2026 10:04:25 +0200 Subject: [PATCH 05/11] fix: fix index of objective value in Genetic.consistency_check --- .../algorithms/genetic/__init__.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/WeatherRoutingTool/algorithms/genetic/__init__.py b/WeatherRoutingTool/algorithms/genetic/__init__.py index 7b43cfb..08d5f1c 100644 --- a/WeatherRoutingTool/algorithms/genetic/__init__.py +++ b/WeatherRoutingTool/algorithms/genetic/__init__.py @@ -162,18 +162,30 @@ def optimize( # FIXME temporary consistency check def consistency_check(self, res, problem): + """ + Temporary consistency check to uncover memory issues. + """ X = res.X + res_objs = res.F i_route = 0 + + # solve shape issue in case there is only one objective + if self.n_objs == 1: + res_objs = np.array([res.F]) + X = [X] + for route in X: fuel_dict = problem.get_power(route[0]) - i_obj = 0 + # ordering of objective values in res.F is defined by RoutingProblem.get_objectives() for obj_str in self.objectives: if obj_str == "fuel_consumption": - np.testing.assert_equal(fuel_dict["fuel_sum"].value, res.F[i_route, i_obj], 5) + i_obj = 1 + if self.n_objs == 1: + i_obj = 0 + np.testing.assert_equal(fuel_dict["fuel_sum"].value, res_objs[i_route, i_obj], 5) else: - np.testing.assert_equal(fuel_dict["time_obj"], res.F[i_route, i_obj], 5) - i_obj += 1 + np.testing.assert_equal(fuel_dict["time_obj"], res_objs[i_route, 0], 5) i_route += 1 def terminate(self, res: Result, problem: RoutingProblem): From 714cbb433c2df9b201bc5ec7c207d46076053569 Mon Sep 17 00:00:00 2001 From: Katharina Demmich Date: Wed, 1 Apr 2026 10:13:16 +0200 Subject: [PATCH 06/11] feature: replace SpeedCrossover by TwoPointCrossoverSpeed Implement crossover function that replaces the speed of a random sequence of waypoints of one individual by the average speed of a random sequence of another individual. Replace SpeedCrossover by TwoPointCrossoverSpeed as default crossover for speed optimisation. --- .../algorithms/genetic/crossover.py | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/WeatherRoutingTool/algorithms/genetic/crossover.py b/WeatherRoutingTool/algorithms/genetic/crossover.py index 8de38e7..725977f 100644 --- a/WeatherRoutingTool/algorithms/genetic/crossover.py +++ b/WeatherRoutingTool/algorithms/genetic/crossover.py @@ -281,6 +281,55 @@ def crossover( return o1, o2 +class TwoPointCrossoverSpeed(OffspringRejectionCrossover): + """ + Class for two-point crossover of ship speed. + + The ship speed of a random sequence of one chromosome is replaced by the average ship speed of a random sequence + of another individual. + """ + + def __init__(self, **kw): + super().__init__(**kw) + + def crossover(self, p1, p2) -> tuple[np.ndarray, np.ndarray]: + """ + Crossover implementation for two-point crossover of ship speed. + + :param p1: the first chromosome + :param p2: the second chromosome + :return: the two offspring chromosomes + :rtype: tuple[np.ndarray, np.ndarray] + """ + r1 = deepcopy(p1) + r2 = deepcopy(p2) + + p1x1 = np.random.randint(1, p1.shape[0] - 4) + p1x2 = p1x1 + np.random.randint(3, p1.shape[0] - p1x1 - 1) + + p2x1 = np.random.randint(1, p2.shape[0] - 4) + p2x2 = p2x1 + np.random.randint(3, p2.shape[0] - p2x1 - 1) + + speed1 = p1[:, -1] + speed2 = p2[:, -1] + av_speed_seg1 = np.average(speed1[p1x1:p1x2 + 1]) + av_speed_seg2 = np.average(speed2[p2x1:p2x2 + 1]) + + new_speed1 = np.concatenate([ + speed1[:p1x1], + np.full(p1x2 - p1x1, av_speed_seg2), + speed1[p1x2:], ]) + new_speed2 = np.concatenate([ + speed2[:p2x1], + np.full(p2x2 - p2x1, av_speed_seg1), + speed2[p2x2:], ]) + + r1[:, -1] = new_speed1 + r2[:, -1] = new_speed2 + + return r1, r2 + + # factory # ---------- class CrossoverFactory: @@ -292,7 +341,7 @@ def get_crossover(config: Config, constraints_list: ConstraintsList): if config.GENETIC_CROSSOVER_TYPE == "speed": logger.debug('Setting crossover type of genetic algorithm to "speed".') - return SpeedCrossover( + return TwoPointCrossoverSpeed( config=config, departure_time=departure_time, constraints_list=constraints_list, From 228034df3d37f46d25e6ad5bab1bc890b470b759 Mon Sep 17 00:00:00 2001 From: Katharina Demmich Date: Wed, 1 Apr 2026 10:32:08 +0200 Subject: [PATCH 07/11] feature: modify GaussianSpeedMutation Adjust parameters n_updates and sigma to reduce strong speed fluctuations in output routes. Add smoothening function to allow only speed differences of consecutive waypoints up to a certain limit. --- .../algorithms/genetic/mutation.py | 8 +- .../algorithms/genetic/utils.py | 109 ++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/WeatherRoutingTool/algorithms/genetic/mutation.py b/WeatherRoutingTool/algorithms/genetic/mutation.py index 843041c..8957d57 100644 --- a/WeatherRoutingTool/algorithms/genetic/mutation.py +++ b/WeatherRoutingTool/algorithms/genetic/mutation.py @@ -147,6 +147,9 @@ class NoMutation(MutationBase): def _do(self, problem, X, **kw): return X + def print_mutation_statistics(self): + print('No mutation.') + class RandomPlateauMutation(MutationConstraintRejection): """ @@ -574,7 +577,7 @@ class GaussianSpeedMutation(MutationConstraintRejection): n_updates: int config: Config - def __init__(self, n_updates: int = 10, **kw): + def __init__(self, n_updates: int = 5, **kw): super().__init__( mutation_type="GaussianSpeedMutation", **kw @@ -583,7 +586,7 @@ def __init__(self, n_updates: int = 10, **kw): # FIXME: these numbers should be carefully evaluated # ~99.7 % in interval (0, BOAT_SPEED_MAX) self.mu = 0.5 * self.config.BOAT_SPEED_BOUNDARIES[1] - self.sigma = self.config.BOAT_SPEED_BOUNDARIES[1] / 6 + self.sigma = 1. def mutate(self, problem, rt, **kw): rt_new = copy.deepcopy(rt) @@ -600,6 +603,7 @@ def mutate(self, problem, rt, **kw): new = old_speed rt_new[i][2] = new + rt_new[:, 2] = utils.smoothen_speed(rt_new[:, 2], 1) return rt_new diff --git a/WeatherRoutingTool/algorithms/genetic/utils.py b/WeatherRoutingTool/algorithms/genetic/utils.py index 4e85c0a..27d2e70 100644 --- a/WeatherRoutingTool/algorithms/genetic/utils.py +++ b/WeatherRoutingTool/algorithms/genetic/utils.py @@ -1,3 +1,4 @@ +import copy import json import logging from typing import Optional @@ -169,6 +170,114 @@ def get_hist_values_from_route(route: np.array, departure_time): return hist_values +def check_speed_dif(speed_arr: np.ndarray, max_diff: float) -> list[int]: + """ + Identify indices where the speed difference between consecutive points exceeds a limit. + + This function iterates through the speed array and compares each element + with the previous one. If the absolute difference is greater than the + specified threshold, both the current and previous indices are added to the output list. + + :param speed_arr: array containing speed values + :type speed_arr: np.ndarray + :param max_diff: the maximum allowed difference between consecutive speeds + :return: a sorted list of unique indice pairs for which speed violations occurred + """ + + viol_list = [] + debug = True + + previous = speed_arr[0] + for i in range(speed_arr.shape[0] - 1): + speed = speed_arr[i] + diff = abs(previous - speed) + if debug: + print('diff:', diff) + if diff > max_diff: + viol_list.append(i) + viol_list.append(i - 1) + previous = speed + + if debug: + print("before duplicate removal viol_list: ", viol_list) + viol_list = list(set(viol_list)) + if debug: + print("returning viol_list: ", viol_list) + + return viol_list + + +def smoothen_speed_rec(speed_arr: np.ndarray, viol_list: list[int], n_calls: int) -> tuple[np.ndarray, int]: + r""" + Perform a single pass of weighted averaging on consecutive speed values violating the maximum speed difference. + + Updates values at the provided indices by averaging them with their + immediate neighbors. It uses a weighted formula: + $$(2 \times current + lower + upper) / n\_smooth$$ + + :param speed_arr: the original array of speed values + :param viol_list: list of indices identified as having excessive speed differences + :param n_calls: the current recursion/iteration count + :raises Exception: if ``n_calls`` exceeds the hardcoded limit of 40 + :return: a tuple containing the updated (smoothened) array and the incremented call count + """ + arr_smooth = copy.deepcopy(speed_arr) + max_calls = 40 + debug = False + + if debug: + print('Call: ', n_calls) + + if n_calls > max_calls: + raise Exception("Too many calls to smoothen") + + for ispeed in viol_list: + lower = 0. + upper = 0. + n_smooth = 4 + + if ispeed > 0: + lower = speed_arr[ispeed - 1] + else: + n_smooth -= 1 + if (ispeed < speed_arr.shape[0] - 1) and (speed_arr[ispeed + 1] != -99): + upper = speed_arr[ispeed + 1] + else: + n_smooth -= 1 + arr_smooth[ispeed] = (speed_arr[ispeed] * 2 + lower + upper) / n_smooth + + if debug: + print(' lower: ', lower) + print(' upper: ', upper) + print(' orig: ', speed_arr[ispeed]) + print(' av: ', arr_smooth[ispeed]) + + n_calls += 1 + return arr_smooth, n_calls + + +def smoothen_speed(speed_arr: np.ndarray, max_diff: float) -> np.ndarray: + """ + Iteratively smoothen a speed array until all consecutive differences are within limits. + + This acts as the main controller that repeatedly checks for violations + and applies the smoothing algorithm until all consecutive differences are within limits or + the maximum iteration limit is reached. + + :param speed_arr: the initial array of speed values to be processed + :param max_diff: the allowed difference for speed consecutive speed values + :return: the final smoothened array where no differences exceed ``max_diff`` + """ + viol_list = check_speed_dif(speed_arr, max_diff) + n_calls = 0 + + while viol_list != []: + speed_arr, n_calls = smoothen_speed_rec(speed_arr, viol_list, n_calls) + viol_list = check_speed_dif(speed_arr, max_diff) + + return speed_arr + + # ---------- class RouteDuplicateElimination(ElementwiseDuplicateElimination): """Custom duplicate elimination strategy for routing problem.""" From be2fc60fe048ed970ed8f98d8d37b80ca9460e7e Mon Sep 17 00:00:00 2001 From: Katharina Demmich Date: Wed, 1 Apr 2026 10:38:15 +0200 Subject: [PATCH 08/11] tests: add tests for recent implementations Add tests for: TwoPointCrossoverSpeed, utils.smoothen_speed, utils.check_speed_diff --- tests/test_genetic.py | 87 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/tests/test_genetic.py b/tests/test_genetic.py index ea1ced6..fc3f943 100644 --- a/tests/test_genetic.py +++ b/tests/test_genetic.py @@ -10,9 +10,10 @@ from astropy import units as u import tests.basic_test_func as basic_test_func +import WeatherRoutingTool.algorithms.genetic.utils as utils import WeatherRoutingTool.utils.graphics as graphics from WeatherRoutingTool.algorithms.genetic import Genetic -from WeatherRoutingTool.algorithms.genetic.crossover import SinglePointCrossover, SpeedCrossover +from WeatherRoutingTool.algorithms.genetic.crossover import SinglePointCrossover, SpeedCrossover, TwoPointCrossoverSpeed from WeatherRoutingTool.algorithms.genetic.patcher import PatcherBase, GreatCircleRoutePatcher, IsofuelPatcher, \ GreatCircleRoutePatcherSingleton, IsofuelPatcherSingleton, PatchFactory from WeatherRoutingTool.algorithms.genetic.population import IsoFuelPopulation, FromGeojsonPopulation @@ -459,3 +460,87 @@ def test_spread_velocity(plt): ax.plot([min_boat_speed, max_boat_speed, boat_speed], [1, 1, 1], **marker_bounds, color="none") plt.saveas = "test_spread_velocidy.png" + + +@pytest.mark.parametrize("speed_arr,viol_list", [ + (np.array([1, 2, 3, 4, 5, 6, 7, -99]), []), + (np.array([1, 4, 3, 4, 8, 7, 6, -99]), [0, 1, 3, 4]), +]) +def test_check_speed_dif(speed_arr, viol_list): + """ + Test whether correct lists is returned from utils.check_speed_dif + """ + viol_list_test = utils.check_speed_dif(speed_arr, 2) + assert viol_list_test == viol_list + + +@pytest.mark.parametrize("speed_arr,", [ + (np.array([1., 2., 100000., 4., 5., 6., 1000., -99])), +]) +def test_smoothen_speed_rec_error(speed_arr): + """ + Test whether exception is raised if utils.smoothen_speed_rec function is called too often. + """ + with pytest.raises(Exception) as excinfo: + utils.smoothen_speed(speed_arr, 2) + + assert "Too many calls to smoothen" in str(excinfo.value) + + +@pytest.mark.parametrize("speed_arr,smooth_res", [ + (np.array([1., 2., 5., 4., 5., 6., 10., -99.]), np.array([1., 2.5, 4., 4., 5., 6.75, 8.6666666, -99.])), + (np.array([10., 2., 5., 4., 5., 6., 10., -99.]), + np.array([6.472222, 5.2083333, 4., 4., 5., 6.75, 8.6666666, -99.])), + +]) +def test_smoothen_speed_success(speed_arr, smooth_res): + """ + Test whether correct smoothened list is returned from utils.smoothen_speed. + """ + smooth_arr = utils.smoothen_speed(speed_arr, 2) + + assert np.isclose(smooth_arr, smooth_res).all() + + +def test_twopoint_crossover_speed(plt): + """ + Test whether TwoPointCrossoverSpeed provides sensible results via monitoring plot. + """ + dirname = os.path.dirname(__file__) + configpath = os.path.join(dirname, 'config.isofuel_single_route.json') + config = Config.assign_config(Path(configpath)) + default_map = Map(32., 15, 36, 29) + constraint_list = basic_test_func.generate_dummy_constraint_list() + departure_time = datetime(2025, 4, 1, 11, 11) + + X = get_dummy_route_input() + + sp = TwoPointCrossoverSpeed(config=config, departure_time=departure_time, constraints_list=constraint_list) + o1, o2 = sp.crossover(X[0, 0], X[1, 0]) + + # plot figure with original and mutated routes + fig, ax = graphics.generate_basemap( + map=default_map.get_var_tuple(), + depth=None, + start=(35.199, 15.490), + finish=(32.737, 28.859), + title='', + show_depth=False, + show_gcr=False + ) + old_X1_lc = graphics.get_route_lc(X[0, 0]) + old_X2_lc = graphics.get_route_lc(X[1, 0]) + + new_X1_lc = graphics.get_route_lc(o1) + new_X2_lc = graphics.get_route_lc(o2) + + ax.add_collection(old_X1_lc) + ax.add_collection(old_X2_lc) + ax.add_collection(new_X1_lc) + ax.add_collection(new_X2_lc) + + cbar = fig.colorbar(old_X2_lc, ax=ax, orientation='vertical', pad=0.15, shrink=0.7) + cbar.set_label('Geschwindigkeit ($m/s$)') + + pyplot.tight_layout() + plt.saveas = "test_twopoint_crossover_speed.png" From 29cba017816dcdeaa77fcbc83e00e20c86392d86 Mon Sep 17 00:00:00 2001 From: Katharina Demmich Date: Wed, 1 Apr 2026 14:04:26 +0200 Subject: [PATCH 09/11] style: adapt logging of crossover and genetic/utils.py --- WeatherRoutingTool/algorithms/genetic/crossover.py | 2 +- WeatherRoutingTool/algorithms/genetic/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/WeatherRoutingTool/algorithms/genetic/crossover.py b/WeatherRoutingTool/algorithms/genetic/crossover.py index 725977f..e4416d7 100644 --- a/WeatherRoutingTool/algorithms/genetic/crossover.py +++ b/WeatherRoutingTool/algorithms/genetic/crossover.py @@ -346,7 +346,7 @@ def get_crossover(config: Config, constraints_list: ConstraintsList): departure_time=departure_time, constraints_list=constraints_list, prob=.5, - crossover_type="Speed crossover") + crossover_type="TP Crossover speed") if config.GENETIC_CROSSOVER_TYPE == "waypoints": logger.debug('Setting crossover type of genetic algorithm to "random".') diff --git a/WeatherRoutingTool/algorithms/genetic/utils.py b/WeatherRoutingTool/algorithms/genetic/utils.py index 27d2e70..c37ec07 100644 --- a/WeatherRoutingTool/algorithms/genetic/utils.py +++ b/WeatherRoutingTool/algorithms/genetic/utils.py @@ -185,7 +185,7 @@ def check_speed_dif(speed_arr: np.ndarray, max_diff: float) -> list[int]: """ viol_list = [] - debug = True + debug = False previous = speed_arr[0] for i in range(speed_arr.shape[0] - 1): From 34d8350078da66e63fa121718c7bbeede22ee929 Mon Sep 17 00:00:00 2001 From: Katharina Demmich Date: Wed, 1 Apr 2026 14:05:36 +0200 Subject: [PATCH 10/11] config: raise ValueError for time optimisation & pure waypoint optimisation --- WeatherRoutingTool/config.py | 3 +++ tests/test_config.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/WeatherRoutingTool/config.py b/WeatherRoutingTool/config.py index 8138efd..c5ba018 100644 --- a/WeatherRoutingTool/config.py +++ b/WeatherRoutingTool/config.py @@ -518,6 +518,9 @@ def check_speed_determination(self) -> Self: raise ValueError('Please specify EITHER the boat speed OR the arrival time.') if self.ARRIVAL_TIME is not None and self.BOAT_SPEED is not None: raise ValueError('Please specify EITHER the boat speed OR the arrival time but not both.') + if "arrival_time" in self.GENETIC_OBJECTIVES.keys(): + raise ValueError( + 'Optimisation for arrival-time accuracy is meaningless for pure waypoint optimisation.') # run modes: speed optimisation for fixed route as well as simultaneous waypoint and speed optimisation else: diff --git a/tests/test_config.py b/tests/test_config.py index d680ef6..e1f1ee2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -136,6 +136,8 @@ def test_weather_start_time_compatibility(): @pytest.mark.parametrize("boat_speed,arrival_time,mut_type,cross_type,ierr", [ (7, "2025-12-07T00:00Z", "waypoints", "waypoints", 0), (None, None, "waypoints", "waypoints", 1), + (7, None, "waypoints", "waypoints", 2), + (None, "2025-12-07T00:00Z", "waypoints", "waypoints", 2), ]) def test_boat_speed_arrival_time_waypoint_optimisation_failure(boat_speed, arrival_time, mut_type, cross_type, ierr): config_data, _ = load_example_config() @@ -147,6 +149,7 @@ def test_boat_speed_arrival_time_waypoint_optimisation_failure(boat_speed, arriv error_str_list = [ "Please specify EITHER the boat speed OR the arrival time but not both.", "Please specify EITHER the boat speed OR the arrival time.", + "Optimisation for arrival-time accuracy is meaningless for pure waypoint optimisation." ] with pytest.raises(ValueError) as excinfo: @@ -166,6 +169,7 @@ def test_boat_speed_arrival_time_waypoint_optimisation_success(boat_speed, arriv config_data["GENETIC_MUTATION_TYPE"] = mut_type config_data["GENETIC_CROSSOVER_TYPE"] = cross_type config_data["ALGORITHM_TYPE"] = "genetic" + config_data["GENETIC_OBJECTIVES"] = {"fuel_consumption" : 1.} Config.assign_config(init_mode="from_dict", config_dict=config_data) From 318183abe1178aa057621bfe89fdffcc553e0d22 Mon Sep 17 00:00:00 2001 From: Katharina Demmich Date: Thu, 2 Apr 2026 13:11:12 +0200 Subject: [PATCH 11/11] style: fix linting --- tests/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_config.py b/tests/test_config.py index e1f1ee2..c2e965d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -169,7 +169,7 @@ def test_boat_speed_arrival_time_waypoint_optimisation_success(boat_speed, arriv config_data["GENETIC_MUTATION_TYPE"] = mut_type config_data["GENETIC_CROSSOVER_TYPE"] = cross_type config_data["ALGORITHM_TYPE"] = "genetic" - config_data["GENETIC_OBJECTIVES"] = {"fuel_consumption" : 1.} + config_data["GENETIC_OBJECTIVES"] = {"fuel_consumption": 1.} Config.assign_config(init_mode="from_dict", config_dict=config_data)