Simplifying Logistics with Python: A Practical Guide to Bundling Moves

In the context of a road transport logistics, bundling refers to the practice of combining multiple individual moves into a single shipment or route.

Varun Tyagi
Operations Research Bit
10 min readFeb 8, 2024

--

Image generated using DALL-E 3

What is Bundling

This strategic approach aims to optimise the use of resources, reduce costs, and enhance operational efficiency in the process of relocating individuals and families from one city to another city.

Advantages of Bundling

Cost Efficiency

Bundling moves allows for optimal utilisation of resources, such as trucks and manpower, leading to cost savings. Combined shipments can reduce fuel costs, labor expenses, and overall operational expenses.

Reduced Environmental Impact

It reduces the number of trips and vehicles needed, contributing to a smaller carbon footprint. This aligns with sustainability goals and positions the logistics company as environmentally conscious.

Improved Route Optimisation

It enables route optimisation, leading to shorter travel distances and reduced transit times. This not only saves costs but also enhances customer satisfaction through faster deliveries.

Enhanced Operational Efficiency

Managing bundled moves allows for better planning and coordination. It streamlines logistics operations, minimises delays, and ensures that resources are utilised efficiently, contributing to a smoother relocation process.

Customer Cost Savings

It can also result in cost savings for customers as well. By sharing transportation costs with others moving in the same direction, individual relocation expenses are reduced, making the service more affordable. It generates a win-win situation for the customers and the movers alike.

Increased Service Reliability

It helps in creating more predictable schedules and reliable services. By consolidating moves, the logistics company can better adhere to timelines, providing customers with a more dependable relocation experience.

Disadvantages of Bundling

Not everything is rosy with bundling, there are certain risks and disadvantages associated with bundling

Limited Flexibility

Bundling may limit the flexibility of individual moves. Customers may need specific timelines or have unique requirements that are challenging to accommodate when bundled with others. Creating this logic in the code can also turn the code to be overly complex and difficult to debug.

Increased Complexity

Managing bundled moves adds complexity to logistics operations. Coordinating multiple shipments, ensuring timely arrivals, and addressing diverse customer needs can be more challenging compared to individual moves.

Customer Satisfaction Variability

Bundling moves may lead to varying levels of customer satisfaction. While it can be cost-effective, customers with specific demands or those experiencing delays due to bundling may express dissatisfaction with the service.

Introduction to the Code

Having gained a comprehensive understanding of bundling, including its advantages and disadvantages, we are now poised to delve into the code that illustrates its real-world implementation.

In logistics, efficient transportation is crucial for minimising costs, decreasing carbon footprint and increasing efficiency. In this blog post, we’ll explore a Python script designed to optimise the bundling of moves — combining multiple transportation operations to save time and resources. In practical scenarios, various constraints come into play, such as vehicle capacity, minimum bundling volume for profitability, maximum allowable travel days for vehicles on roads, seas, tracks, or air, among others. The code can be modified to accommodate these additional variables, but for simplicity, we will maintain the current version.

Setting the Stage

The code starts by importing necessary libraries and setting a random seed for reproducibility. The function generate_move_id is introduced to create unique hexanumeric identifiers for moves. In the function, the synthetic data for moves is generated, including MoveID, MoveDate, Distance, Volume, Price, Cost, and City. Coordinates for cities in Germany are also added. We are only focusing on different cities in Germany.

import pandas as pd
import numpy as np
from itertools import combinations
from sklearn.metrics.pairwise import haversine_distances
from math import radians

# Set a random seed for reproducibility
np.random.seed(42)

# Function to generate random hexanumeric MoveID of length 6
def generate_move_id():
hexanumeric_characters = '0123456789ABCDEF'
return ''.join(np.random.choice(list(hexanumeric_characters), size=6))

# Generate synthetic data for 1000 moves
num_moves = 500
cities_germany = ['Berlin', 'Hamburg', 'Munich', 'Cologne', 'Frankfurt', 'Stuttgart', 'Dusseldorf', 'Dortmund']
date_range = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D')

moves_data = pd.DataFrame({
'MoveID': [generate_move_id() for _ in range(num_moves)],
'MoveDate': np.random.choice(date_range, size=num_moves),
'Distance': np.random.uniform(50, 500, size=num_moves),
'Volume': np.random.uniform(10, 20, size=num_moves),
'Price': np.random.uniform(500, 2000, size=num_moves),
'Cost': np.random.uniform(300, 1500, size=num_moves),
})

# Generate synthetic cities in Germany
moves_data['City'] = np.random.choice(cities_germany, size=num_moves)

# Helper function to calculate haversine distance between two points
def haversine_distance(coord1, coord2):
coord1 = [radians(_) for _ in coord1]
coord2 = [radians(_) for _ in coord2]
result = haversine_distances([coord1, coord2])
return result[0][1] * 6371000 # Radius of Earth in meters

# Generate synthetic coordinates for cities in Germany
city_coordinates_germany = {
'Berlin': (52.5200, 13.4050),
'Hamburg': (53.5511, 9.9937),
'Munich': (48.8566, 2.3522),
'Cologne': (50.9375, 6.9603),
'Frankfurt': (50.1109, 8.6821),
'Stuttgart': (48.7758, 9.1829),
'Dusseldorf': (51.2277, 6.7735),
'Dortmund': (51.5136, 7.4653)
}

# Add coordinates to moves_data
moves_data['Coordinates'] = moves_data['City'].map(city_coordinates_germany)

Bundling Algorithm

This section critically explores the central component of the code — the bundling algorithm. The algorithm is designed to enhance the efficiency of routes between cities by identifying co-load bundles (moves occurring on the same day between the same cities), back-load bundles (moves from one city to another and back on different days), and round-trip bundles (three or more moves forming a circular route). The high-level bundling algorithm is introduced, shedding light on its overarching purpose and the key variables at play. With a focus on optimising bundling, the algorithm considers specific criteria such as distance, volume, and cost. The section underscores the significance of cost-effective bundling and illustrates the criteria for creating diverse types of bundles.

We have incorporated certain adjustable assumptions into the code, as outlined below.

  1. Cost Savings for Co-load Bundles (cost_savings_co_load = 0.3)
    The variable ‘cost_savings_co_load’ represents the predefined percentage by which the total cost of individual moves is reduced when bundled together in a co-load scenario. The assigned value of 0.3 indicates a 30% cost savings, emphasising the efficiency gained through combining shipments.
  2. Cost Savings for Back-load Bundles (cost_savings_back_load = 0.4)
    Similarly, ‘cost_savings_back_load’ signifies the percentage reduction in total costs when bundling back-load moves. With a value of 0.4, this variable denotes a 40% cost savings, underlining the financial benefits of strategic bundling.
  3. Cost Savings for Round-trip Bundles (cost_savings_round_trip = 0.5)
    The ‘cost_savings_round_trip’ variable addresses the specific scenario of round-trip bundling. The assigned value of 0.5 indicates a 50% cost savings, emphasising the higher efficiency and financial advantage associated with completing a full cycle of transportation.
# Bundling algorithm
def bundle_moves(moves_data):
bundled_moves = []
total_cost_before = 0
total_cost_after = 0
total_bundles = 0
bundles_per_label = {'co-load move': 0, 'back-load move': 0, 'round-trip move': 0}

# Cost savings variables
cost_savings_co_load = 0.3
cost_savings_back_load = 0.4
cost_savings_round_trip = 0.5

def calculate_cost_savings(move_type, total_cost):
if move_type == "co-load move":
return cost_savings_co_load * total_cost
elif move_type == "back-load move":
return cost_savings_back_load * total_cost
elif move_type == "round-trip move":
return cost_savings_round_trip * total_cost
else:
return 0

Co-Load Bundles

In this section of the code, we are focused on optimising transportation routes specifically for co-load moves. Let’s break down the key elements of this code segment.

Grouping Moves by Date and City

for _, group in moves_data.groupby(['MoveDate','City'])]
The groupby function is used to organize the moves_dataDataFrame into groups based on the MoveDate and Citycolumns. This facilitates the identification of moves that occur on the same day between the same pair of cities.

Checking for Multiple Moves on the Same Day

if len(group)> 1:
This condition ensures that the group contains multiple moves on the same day, making it a potential candidate for co-load bundling. If there is only one move, it implies that there’s no opportunity for co-loading.

Calculating Distances and Finding Optimal Moves

for move1, move2 in combinations....
For each combination of moves within the group, the haversine distance between their respective coordinates is calculated and recorded. The purpose is to identify the optimal pair of moves that minimise the transportation distance.

Bundling Optimal Moves into Co-load Bundles

sorted_distances = ...
The distances are sorted to identify the pair of moves with the shortest distance, signifying the most efficient co-load bundling scenario. The corresponding MoveIDs are then selected.

Recording Cost Before and After Bundling

cost_before = ...
The costs of the selected moves before and after bundling are calculated. This information is crucial for assessing the financial impact of the bundling strategy.

Updating Counters and Displaying Results

total_cost_before += cost_before
Various counters are updated, including the total cost before and after bundling, the count of co-load moves, and the overall count of bundles. The results, including the bundle number, type, and associated cost savings, are displayed for transparency and monitoring

# Optimize routes from city A to city B
for _, group in moves_data.groupby(['MoveDate', 'City']):
if len(group) > 1: # Check if there are multiple moves on the same day between the same pair of cities
distances = []
for move1, move2 in combinations(group['MoveID'], 2):
coord1 = moves_data.loc[moves_data['MoveID'] == move1, 'Coordinates'].iloc[0]
coord2 = moves_data.loc[moves_data['MoveID'] == move2, 'Coordinates'].iloc[0]
distances.append((move1, move2, haversine_distance(coord1, coord2)))

if distances: # Check if distances list is not empty
distances_df = pd.DataFrame(distances, columns=['MoveID1', 'MoveID2', 'Distance'])
sorted_distances = distances_df.sort_values(by='Distance')
move_id_1 = sorted_distances.iloc[0]['MoveID1']
move_id_2 = sorted_distances.iloc[0]['MoveID2']

cost_before = moves_data.loc[moves_data['MoveID'] == move_id_1, 'Cost'].iloc[0] + \
moves_data.loc[moves_data['MoveID'] == move_id_2, 'Cost'].iloc[0]

bundled_moves.append([move_id_1, move_id_2])

cost_after = moves_data.loc[moves_data['MoveID'] == move_id_1, 'Cost'].iloc[0] + \
moves_data.loc[moves_data['MoveID'] == move_id_2, 'Cost'].iloc[0]

total_cost_before += cost_before
total_cost_after += cost_after

move_type = 'co-load move'
bundles_per_label[move_type] += 1
total_bundles += 1
print(f"Bundle {total_bundles} - {[move_id_1, move_id_2]}")
print(f"Type: {move_type}")
print(f"Cost Savings: {calculate_cost_savings(move_type, total_cost_before)}\n")

Back-Load Bundles

This section is dedicated to the identification and consolidation of back-loaded moves. The same logic as described above is applied to this segment of the code, with the definition of a back-load bundle in consideration.

# Identify and bundle back-load moves
for _, group in moves_data.groupby('City'):
distances = []
for move1, move2 in combinations(group['MoveID'], 2):
coord1 = moves_data.loc[moves_data['MoveID'] == move1, 'Coordinates'].iloc[0]
coord2 = moves_data.loc[moves_data['MoveID'] == move2, 'Coordinates'].iloc[0]
distances.append((move1, move2, haversine_distance(coord1, coord2)))

if distances: # Check if distances list is not empty
distances_df = pd.DataFrame(distances, columns=['MoveID1', 'MoveID2', 'Distance'])
sorted_distances = distances_df.sort_values(by='Distance')
move_id_1 = sorted_distances.iloc[0]['MoveID1']
move_id_2 = sorted_distances.iloc[0]['MoveID2']

cost_before = moves_data.loc[moves_data['MoveID'] == move_id_1, 'Cost'].iloc[0] + \
moves_data.loc[moves_data['MoveID'] == move_id_2, 'Cost'].iloc[0]

bundled_moves.append([move_id_1, move_id_2])

cost_after = moves_data.loc[moves_data['MoveID'] == move_id_1, 'Cost'].iloc[0] + \
moves_data.loc[moves_data['MoveID'] == move_id_2, 'Cost'].iloc[0]

total_cost_before += cost_before
total_cost_after += cost_after

move_type = 'back-load move'
bundles_per_label[move_type] += 1
total_bundles += 1
print(f"Bundle {total_bundles} - {[move_id_1, move_id_2]}")
print(f"Type: {move_type}")
print(f"Cost Savings: {calculate_cost_savings(move_type, total_cost_before)}\n")

Round-Trip Bundles

This section identifies and bundles round-trip moves — three or more moves in a circular route. As it is a bit different than co-loading and back-loading, let us check what exactly is happening here.

Grouping Moves by City

round_trip_moves = moves_data.groupby('City')['MoveID'].apply(list).reset_index()
The moves_data is grouped by the Citycolumn, creating a new DataFrame (round_trip_moves) where each row represents a city, and the MoveIdcolumn contains a list of MoveIDs associated with that city.

Iterating Through Groups and Bundling Round-Trip Moves

for _, group in...
For each group in the round_trip_moves , the code checks if there are at least three moves associated with the city. If the condition is met, the first five MoveIDs (at most) are bundled together to form a round-trip bundle. The total cost before and after optimisation is updated based on the costs of the individual moves in the bundle.

The rest of the code is about updating counters and displaying results as we did for co-load and back-load bundles.

In this code, there is also a section where you can add more bundling logic apart from the three mentioned in this blog such as:

  1. Single Origin, Multi-Destination: Bundle moves originating from the same city but going to different destination cities, potentially creating mini-hubs for distribution.
  2. Multi-Origin, Single Destination: Combine moves heading to the same destination city from various origin cities, optimising truck utilisation and reducing empty miles.
  3. Multi-Origin, Multi-Destination: Create complex bundled routes covering multiple origins and destinations, requiring advanced route optimisation algorithms.
  4. Partial Load Bundling: Combine smaller moves with similar schedules and destinations to fill trucks more efficiently.
  5. Flexible Scheduling Bundles: Group moves with flexible scheduling preferences, allowing some adjustment for cost optimisation or last-minute changes.
  6. Storage Bundles: Combine moving services with temporary or long-term storage solutions for clients transitioning between homes.

Many of the mentioned bundles would typically fit into the categories of co-loading, back-loading, or round-trip, although with added complexity.

# Identify and bundle round-trip moves
round_trip_moves = moves_data.groupby('City')['MoveID'].apply(list).reset_index()
for _, group in round_trip_moves.iterrows():
if len(group['MoveID']) >= 3:
bundled_moves.append(group['MoveID'][:5]) # Keep at most 5 moves in a round trip bundle
total_cost_before += sum(moves_data.loc[moves_data['MoveID'].isin(group['MoveID']), 'Cost'])
total_cost_after += sum(moves_data.loc[moves_data['MoveID'] == group['MoveID'][0], 'Cost'])

move_type = 'round-trip move'
bundles_per_label[move_type] += 1
total_bundles += 1
print(f"Bundle {total_bundles} - {group['MoveID'][:5]}")
print(f"Type: {move_type}")
print(f"Cost Savings: {calculate_cost_savings(move_type, total_cost_before)}\n")

print("Total Cost Before Optimization:", total_cost_before)
print("Total Cost After Optimization:", total_cost_after)
print("Total Bundles:", total_bundles)
print("Bundles per Label:", bundles_per_label)

# Additional bundling logic can be added for other types of bundling

return bundled_moves

Run the Bundling Algorithm

Now we will run our bundling algorithm to generate different bundles of moves

# Run the bundling algorithm
bundled_moves = bundle_moves(moves_data)

Results and Conclusion

The final section summarises the results, displaying the total number of bundles, moves in each bundle, and the cost savings achieved. The city coordinates and distances between moves are used to calculate haversine distances, ensuring accurate bundling based on geographical proximity.

# Display results
print("\nResults:")
print("Total Number of Bundles:", total_bundles)
print("Bundles per Label:", bundles_per_label)

# Additional bundling logic can be added for other types of bundling

return bundled_moves

# Run the bundling algorithm
bundled_moves = bundle_moves(moves_data)

# Display bundled moves
print("\nFinal Bundled Moves:")
for bundle in bundled_moves:
print(bundle)

Find Bundling Combinations

We can also include an added functionality, a function to find bundling combinations for a specific MoveID. It calculates the haversine distance between two moves’ coordinates and identifies potential bundling combinations within a specified distance threshold. You can, of course, adjust the distance threshold from 3 to any other number. Make sure to get the units right in the distance threshold.

# Function to find bundling combinations for a specific MoveID
def find_bundling_combinations(moves_data, target_move_id):
combinations_found = False

print(f"Searching for bundling combinations for MoveID: {target_move_id}")

for move_id in moves_data['MoveID']:
if move_id != target_move_id:
coord1 = moves_data.loc[moves_data['MoveID'] == target_move_id, ['Latitude', 'Longitude']].iloc[0].tolist()
coord2 = moves_data.loc[moves_data['MoveID'] == move_id, ['Latitude', 'Longitude']].iloc[0].tolist()
distance = haversine_distance(coord1, coord2)

if distance <= 3: # Adjust as needed
print(f"Potential bundling combination found with MoveID: {move_id}")
combinations_found = True

if not combinations_found:
print("No bundling possibility found for the given MoveID.")

# Example to find bundling combinations for a specific MoveID
target_move_id = 'ABC123' # Change to a valid MoveID for testing
find_bundling_combinations(moves_data, target_move_id)

Conclusion

Optimising logistics through bundling moves can significantly reduce costs, decrease carbon footprint, and improve efficiency. This Python script serves as a practical tool for bundling moves based on various criteria, making it adaptable to different logistics scenarios. The flexibility of the code allows for further customisation to meet specific business needs, providing a foundation for smarter transportation strategies.

Code

Bundling Algorithm

--

--