//

Einführung in die Welt der Tourenoptimierung – Echte Routen und realistischere Nebenbedingungen (3/3)

21.6.2022 | 6 Minuten Lesezeit

In diesem Artikel möchte ich euch mit einem Python Jupyter Notebook  zeigen, wie ihr Anwendungsfälle der Tourenoptimierung inklusive Nebenbedingungen lösen und visualisieren könnt. Außerdem zeige ich euch, wie ihr mit OpenStreetMaps die Route zwischen zwei Punkten für verschiedene Transportmodi berechnen lassen könnt. Dieser Artikel gehört zu meiner dreiteiligen Tourenoptimierungs-Blogreihe und baut auf dem zweiten Blogbeitrag  auf, in dem ich gezeigt habe, wie wir ein einfaches Traveling Salesman Problem (TSP) praktisch lösen und visualisieren können. Schaut euch den Artikel am besten vorher an, da ich stark auf dem vorherigen aufbauen werde.

Von der Luftlinie zu echten Routen

Ihr wollt also gerne sehen wie man die Routen zwischen zwei Zielpunkten berechnet, damit die bisherige Planung nicht nur für fliegende Fahrräder funktioniert?

Auch hierfür setze ich, wie bereits im ersten Teil der Umsetzung, auf OpenStreetMaps. Die pyroutelib3 Library bietet eine ganze Reihe verschiedener Transportmodi: Auto, Fahrrad, Bahn und sogar Pferd. Ich nehme passend zum Anwendungsfall und, um dem Ruf der Stadt Münster gerecht zu werden, das Rad. Zum Speichern der Routen verwende ich eine verschachtelte Liste.

Auf der ersten Ebene liegt die einzelne Tour bzw. das einzelne Fahrzeug, da ich aktuell in dem Grundproblem nur jeweils ein Fahrzeug betrachte. Auf der zweiten Ebene liegt dann die Route von einem Zielpunkt zum nächsten (z. B. vom Ausgangslager zum ersten Kunden) und auf der dritten Ebene findet sich eine Liste aus Tupeln mit den einzelnen Geokloordinaten für jeden Pfad. Mit der findNode-Methode suchen wir für den aktuell betrachteten Punkt der nächsten Knoten. Anschließend ermitteln wir mit der doRoute-Methode die kürzeste Route zwischen Start- und Endknoten.

# Initialize router:
router = Router("bike")
 
# Stores the routes of all tours:
tour_plan_all_routes = []
 
# Iterate over tours:
for tour in tour_plan:
 
    # Stores the routes of the current tour:
    curr_tour_route = []
 
    # Pairwise iterate to obtain paths between two given points:
    for idx in range(0, len(tour) - 1):
 
        curr_start_point = coords_list[tour[idx]]
        curr_end_point = coords_list[tour[idx+1]]
 
        # Find Start and End Nodes near desired location:
        start = router.findNode(*curr_start_point)
        end = router.findNode(*curr_end_point)
 
        # Get route:
        status, route = router.doRoute(start, end)
 
        # Get coordinates of route:
        if status == 'success':
            routeLatLons = list(map(router.nodeLatLon, route))
            curr_tour_route.append(routeLatLons)
 
    tour_plan_all_routes.append(curr_tour_route)

Um das besser zu verdeutlichen, visualisiere ich das Ergebnis im nächsten Schritt. Dazu iteriere ich über die verschachtelte Liste und zeichne für jede Verbindung von einem Knoten zum Folgeknoten eine gerade Linie. Das erreiche ich durch eine kleine Anpassung beim Erstellen der Karte.

## Create connections between target points:
# Iterate over tour plan:
for tour in tour_plan:
    # Iterate from first to penultimate element:
    for i in range(0,len(tour) - 1):
        coords_point_a = coords_list[tour[i]]
        coords_point_b = coords_list[tour[i+1]]
 
        folium.PolyLine([coords_point_a, coords_point_b],
                        color="black",
                        weight=3).add_to(map_osm)
 
map_osm

Das Ergebnis sieht dann wie folgt aus:

Wer sich in Münster gut auskennt, erkennt schnell, dass ein Teil der Route auf der Münsteraner Promenade liegt, auf der lediglich Radfahrer zugelassen sind. Das Ergebnis kann sich also sehen lassen.

Erweiterung – Mehrere Fahrer und begrenzte Kapazitäten

Als nächstes möchte ich euch noch zeigen, wie durch ein paar kleine Anpassungen der Ansatz um gängige Nebenbedingungen wie mehrere Fahrzeuge oder Kapazitätsbeschränkungen erweitern werden kann. Kapazitätsbeschränkungen können dabei z. B. ein maximales Volumen oder Gewicht pro Fahrzeug darstellen. Im Folgenden erweitere ich den Demo Case um die Annahme, dass es drei Fahrer gibt, wobei eines der Fahrräder ein Lastenrad mit größerem Volumen ist. Außerdem ergänze ich das Volumen der Produkte, die unsere verschiedenen Kunden erwarten. Darüber hinaus muss ich beim IndexManager berücksichtigen, dass mehrere Transportmittel zur Verfügung stehen. Der darauf folgende Teil bleibt gleich und muss nicht geändert werden. Zusätzlich müssen wir jetzt aber noch ein Demand Callback hinzufügen, um die Kapazitätsbeschränkungen einzubauen. Dieses Callback gibt zu jedem Zielpunkt das zugehörige Nachfragevolumen zurück. Anschließend muss das Demand Callback lediglich noch mit unserem RoutingModel verknüpft werden. Auch dies funktioniert, ähnlich wie beim Demand Callback, mit der RegisterUnaryTransitCallback Methode. Auch hier gibt es eine ganze Reihe an Einstellungsmöglichkeiten. So können beispielsweise Warte-, bzw. Servicezeiten berücksichtigen, was bei anderen Dimensionen sinnvoll sein kann, wir hier aber nicht wollen.

## New:
# Extend to multiple vehicles:
num_vehicles = 3
 
# Capacity constraints:
demands=[0,20,40,50,30,40,40,60,40,20,20]
vehicle_capacities=[200, 100, 100]
 
# The index manager handles the conversion of the solver's internal indices to the location indices of our distance matrix:
index_manager = pywrapcp.RoutingIndexManager(len(dist_matrix_np), num_vehicles, 0) ## 11 nodes, 1 vehicle, 0 warehouse_index
 
 
## Old:
# The routing model is the central object that we can configure to solve our problem:
routing_model = pywrapcp.RoutingModel(index_manager)
 
# Create Callback that is needed to connect our routing model object with the distance matrix:
transit_callback_index = routing_model.RegisterTransitCallback(distance_callback)
 
# The Arc Cost Evaluator tells the model how to compute the costs of choosing one arc and thus driving from one point A to another point B:
routing_model.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
 
# Set the search strategy to the default strategy:
search_parameters = pywrapcp.DefaultRoutingSearchParameters()
 
# Use the 'greedy approach' to create an initial solution that is then improved:
search_parameters.first_solution_strategy = (
    routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)
 
 
## New:
# Add Capacity constraint.
def demand_callback(from_index):
    """Returns the demand of a node."""
    # Convert from routing variable Index to demands NodeIndex.
    from_node = index_manager.IndexToNode(from_index)
    return demands[from_node]
 
demand_callback_index = routing_model.RegisterUnaryTransitCallback(demand_callback)
 
routing_model.AddDimensionWithVehicleCapacity(
    demand_callback_index,  # evaluator_index
    0,  # slack_max
    vehicle_capacities,  # vehicle maximum capacities
    True,  # start cumulative to zero
    'Capacity' # name
)

Abschließend wird die Lösung berechnet. Für die anschließende Visualisierung möchte ich die verschiedenen Touren farblich unterscheidbar darstellen. Dafür lasse ich mir eine Liste der verfügbaren Farben ausgeben. Diese Liste nutze ich dann beim Erstellen der Marker für die unterschiedlichen Touren. Aus Gründen der Übersichtlichkeit nutze ich anstatt der tatsächlichen Routen erneut gerade Linien zur Darstellung.

## Create map object:
map_osm = folium.Map(location=warehouse['coords'], zoom_start=14, tiles='Open Street Map')
 
## Create icon for warehouse:
folium.Marker(
    location=warehouse['coords'],
    icon=folium.Icon(color='green', icon='industry', prefix='fa'),
    popup=warehouse['address'],
    tooltip=warehouse["address"],
    draggable=False).add_to(map_osm)
 
## Create icons for each address:
# Get list of available colors:
color_lst = list(folium.map.Icon.color_options)
color_lst = ['darkblue', 'darkred', 'darkpurple']
 
# Iterate over tours:
for tour_num, tour in enumerate(extended_tour_plan):
    # Iterate over single tour:
    for address_idx in tour:
        # Skip warehouse address:
        if address_idx == 0:
            continue
 
        # Create marker:
        folium.Marker(
            location=coords_list[address_idx],
            icon=folium.Icon(color=color_lst[tour_num], icon='home'),
            popup=address_list[address_idx],
            tooltip=address_list[address_idx],
            draggable=False).add_to(map_osm)
 
## Create connections between target points:
# Iterate over tour plan:
for tour_num, tour in enumerate(extended_tour_plan):
    # Iterate from first to penultimate element:
    for i in range(0,len(tour) - 1):
        coords_point_a = coords_list[tour[i]]
        coords_point_b = coords_list[tour[i+1]]
 
        folium.PolyLine([coords_point_a, coords_point_b],
                        color="black",
                        weight=3).add_to(map_osm)
 
map_osm

Auf diese Art und Weise können mit vergleichsweise geringem Aufwand bereits etwas realistischere Tourenptimierungsprobleme gelöst werden.

Fazit

In der Praxis gibt es weit mehr Nebenbedingungen, die beachtet werden müssen, wie z.B. Lieferzeitfenster, Priorisierung von Zielpunkten, verschiedene Fahrzeugtypen, die unterschiedlich gut für bestimmte Produkte geeignet sind, und so weiter. Erfahrenen Anwendern fallen hier wahrscheinlich sofort eine ganze Liste an weiteren Anforderungen ein, die eine Tourenplanungslösung für ihren Anwendungsfall abdecken sollte. Zum Abschluss möchte ich noch einmal auf den zu Beginn der Blogreihe beschriebenen Praxisfall zurückkommen:

Ein Logistikanbieter, der auf das Fahrrad als Transportmittel setzt, hatte beim Einsatz von Tourenplanungs-SaaS-Produkten Probleme, da Standardlösungen häufig Schwierigkeitenmit der Transportvariante Rad haben. 

Insbesondere die Zeitplanung, also einzuschätzen zu welchem Zeitpunkt sich welcher Fahrer wo befindet, kann Probleme bereiten. Das ist unter anderem auf die unterschiedlichen Fahrgeschwindigkeiten bei den Fahrern zurückzuführen. Selbstverständlich gibt es diese je nach Verkehrsaufkommen auch im Autoverkehr. Aber gerade bei längeren Fahrwegen gibt es bei Fahrradkurieren, im Gegensatz zu motorisierten Lieferdiensten, eine deutlich größere Varianz bei der Ausliefergeschwindigkeit, welche u. a. auf den Fahrradtyp aber auch auf die Erfahrung und Sportlichkeit des Fahrers zurückzuführen ist. All diese beeinflussenden Faktoren sind in der Regel bekannt, können jedoch häufig in Standard-One-Size-fits-All-Lösungen nicht berücksichtigt werden. Dieses Problem ist daher ein gutes Beispiel, warum insgesamt der Trend wieder vermehrt zu Individuallösungen geht. Individuallösung muss dabei übrigens nicht heißen, dass eine Anwendung von Null auf neu gebaut wird. Ein Baukastenprinzip ermöglicht es, verschiedene Grundmodule, die sehr gängige Komponenten beinhalten, miteinander zu verbinden, um sich dann auf die spezielleren Anforderungen zu konzentrieren. Wenn ihr eigene Erfahrungen oder Fragen und Anmerkungen habt, dann freue ich mich auf eine Diskussion in den Kommentaren.

Beitrag teilen

Gefällt mir

0

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen

Wir helfen Deinem Unternehmen

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.