simple-logistics 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simple_logistics/__init__.py +17 -0
- simple_logistics/logistics.py +754 -0
- simple_logistics-0.1.0.dist-info/METADATA +158 -0
- simple_logistics-0.1.0.dist-info/RECORD +7 -0
- simple_logistics-0.1.0.dist-info/WHEEL +5 -0
- simple_logistics-0.1.0.dist-info/licenses/LICENSE +21 -0
- simple_logistics-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .logistics import (
|
|
2
|
+
InvalidAdjacencyMatrixError,
|
|
3
|
+
CapacityExceededError,
|
|
4
|
+
TransportNetwork,
|
|
5
|
+
Package,
|
|
6
|
+
Vehicle,
|
|
7
|
+
FleetManager,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"InvalidAdjacencyMatrixError",
|
|
12
|
+
"CapacityExceededError",
|
|
13
|
+
"TransportNetwork",
|
|
14
|
+
"Package",
|
|
15
|
+
"Vehicle",
|
|
16
|
+
"FleetManager",
|
|
17
|
+
]
|
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InvalidAdjacencyMatrixError(Exception):
|
|
7
|
+
"""Custom exception for errors in the adjacency matrix."""
|
|
8
|
+
def __init__(self, message: str):
|
|
9
|
+
super().__init__(f"Adjacency Matrix Error: {message}")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CapacityExceededError(Exception):
|
|
13
|
+
"""Raised when a route sheet assigns more load than allowed to a vehicle."""
|
|
14
|
+
def __init__(self, vehicle_id: str, reason: str):
|
|
15
|
+
super().__init__(f"Capacity error in vehicle '{vehicle_id}': {reason}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TransportNetwork:
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.graph = {}
|
|
21
|
+
|
|
22
|
+
def load_matrix_csv(self, csv_path: str):
|
|
23
|
+
"""Loads the adjacency matrix from a CSV file using Pandas."""
|
|
24
|
+
self.graph = {}
|
|
25
|
+
try:
|
|
26
|
+
df = pd.read_csv(csv_path, index_col=0)
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
raise InvalidAdjacencyMatrixError(f"The file '{csv_path}' does not exist.")
|
|
29
|
+
except Exception as e:
|
|
30
|
+
raise InvalidAdjacencyMatrixError(f"Could not read the CSV file. Detail: {e}")
|
|
31
|
+
|
|
32
|
+
# Validate matrix structure and values
|
|
33
|
+
# A. Check that it is not empty
|
|
34
|
+
if df.empty:
|
|
35
|
+
raise InvalidAdjacencyMatrixError("The CSV file is empty.")
|
|
36
|
+
|
|
37
|
+
# B. Validate that columns and indices are valid string names and not raw numbers
|
|
38
|
+
for col in df.columns:
|
|
39
|
+
if not isinstance(col, str) or col.isdigit():
|
|
40
|
+
raise InvalidAdjacencyMatrixError(f"Column header '{col}' is not a valid name.")
|
|
41
|
+
|
|
42
|
+
for idx in df.index:
|
|
43
|
+
if not isinstance(idx, str) or idx.isdigit():
|
|
44
|
+
raise InvalidAdjacencyMatrixError(f"Row name '{idx}' is not a valid name.")
|
|
45
|
+
|
|
46
|
+
# C. Check that it is an N x N square matrix
|
|
47
|
+
if df.shape[0] != df.shape[1]:
|
|
48
|
+
raise InvalidAdjacencyMatrixError(
|
|
49
|
+
f"The matrix is not square ({df.shape[0]} rows x {df.shape[1]} columns)."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# D. Check that row and column names match exactly
|
|
53
|
+
if list(df.index) != list(df.columns):
|
|
54
|
+
raise InvalidAdjacencyMatrixError(
|
|
55
|
+
"Row and column names must be identical and in the exact same order."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# E. Check that there are no null values (NaN)
|
|
59
|
+
if df.isnull().values.any():
|
|
60
|
+
raise InvalidAdjacencyMatrixError("The matrix contains empty or null cells.")
|
|
61
|
+
|
|
62
|
+
# F. Check that all matrix data are numeric
|
|
63
|
+
if not np.issubdtype(df.values.dtype, np.number):
|
|
64
|
+
raise InvalidAdjacencyMatrixError("The matrix contains non-numeric values.")
|
|
65
|
+
|
|
66
|
+
# G. Check that there are no negative distances
|
|
67
|
+
if (df.values < 0).any():
|
|
68
|
+
raise InvalidAdjacencyMatrixError("Negative distances exist in the matrix.")
|
|
69
|
+
|
|
70
|
+
# H. Check that the main diagonal contains only zeros (0)
|
|
71
|
+
diagonal = np.diag(df.values)
|
|
72
|
+
if not np.all(diagonal == 0):
|
|
73
|
+
raise InvalidAdjacencyMatrixError("The main diagonal must be 0 (distance from a node to itself).")
|
|
74
|
+
|
|
75
|
+
# Convert the matrix into an adjacency dictionary:
|
|
76
|
+
# {"origin1": {"destination1": distance, "destination2": distance}, ...}
|
|
77
|
+
for origin in df.index:
|
|
78
|
+
origin_str = str(origin).strip()
|
|
79
|
+
self.graph[origin_str] = {}
|
|
80
|
+
for destination in df.columns:
|
|
81
|
+
destination_str = str(destination).strip()
|
|
82
|
+
distance = float(df.loc[origin, destination])
|
|
83
|
+
|
|
84
|
+
if distance > 0 and origin_str != destination_str:
|
|
85
|
+
self.graph[origin_str][destination_str] = distance
|
|
86
|
+
|
|
87
|
+
def calculate_optimal_route(self, origin: str, destination: str):
|
|
88
|
+
"""Calculates the shortest path using Dijkstra's algorithm."""
|
|
89
|
+
if origin not in self.graph or destination not in self.graph:
|
|
90
|
+
raise ValueError("Origin or destination does not exist in the route matrix.")
|
|
91
|
+
|
|
92
|
+
# 1. Track distances and unvisited nodes
|
|
93
|
+
distances = {node: float('inf') for node in self.graph}
|
|
94
|
+
distances[origin] = 0
|
|
95
|
+
predecessors = {}
|
|
96
|
+
unvisited = set(self.graph.keys())
|
|
97
|
+
|
|
98
|
+
while unvisited:
|
|
99
|
+
# Find the unvisited node with the smallest accumulated distance
|
|
100
|
+
current_node = min(unvisited, key=lambda node: distances[node])
|
|
101
|
+
|
|
102
|
+
# Stop search if shortest distance is infinity or destination is reached
|
|
103
|
+
if distances[current_node] == float('inf') or current_node == destination:
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
unvisited.remove(current_node)
|
|
107
|
+
|
|
108
|
+
# Update distances of direct neighbors
|
|
109
|
+
for neighbor, weight in self.graph[current_node].items():
|
|
110
|
+
if neighbor in unvisited:
|
|
111
|
+
new_distance = distances[current_node] + weight
|
|
112
|
+
if new_distance < distances[neighbor]:
|
|
113
|
+
distances[neighbor] = new_distance
|
|
114
|
+
predecessors[neighbor] = current_node
|
|
115
|
+
|
|
116
|
+
# 2. Reconstruct the route from destination back to origin
|
|
117
|
+
path = []
|
|
118
|
+
current = destination
|
|
119
|
+
while current in predecessors:
|
|
120
|
+
path.insert(0, current)
|
|
121
|
+
current = predecessors[current]
|
|
122
|
+
|
|
123
|
+
if path or origin == destination:
|
|
124
|
+
path.insert(0, origin)
|
|
125
|
+
|
|
126
|
+
# Return path (list of stops) and distances[destination] (total accumulated distance)
|
|
127
|
+
return path, distances[destination]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Package:
|
|
131
|
+
def __init__(self, package_id: str, customer_id: str, weight_kg: float, volume_m3: float,
|
|
132
|
+
origin: str, destination: str, eta: str, actual_time: str = None):
|
|
133
|
+
self.package_id = package_id
|
|
134
|
+
self.customer_id = customer_id
|
|
135
|
+
self.weight_kg = weight_kg
|
|
136
|
+
self.volume_m3 = volume_m3
|
|
137
|
+
self.origin = origin
|
|
138
|
+
self.destination = destination
|
|
139
|
+
self.eta = eta
|
|
140
|
+
self.actual_time = actual_time
|
|
141
|
+
self.status = "pending"
|
|
142
|
+
self.deviation_minutes = None
|
|
143
|
+
self.co2_emissions_kg = 0.0
|
|
144
|
+
|
|
145
|
+
def update_status(self):
|
|
146
|
+
"""Compares actual_time with eta ('HH:MM' format) accounting for midnight crossings."""
|
|
147
|
+
if not self.actual_time:
|
|
148
|
+
self.status = "pending"
|
|
149
|
+
self.deviation_minutes = None
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
time_format = "%H:%M"
|
|
153
|
+
t_actual = datetime.strptime(self.actual_time.strip(), time_format)
|
|
154
|
+
t_eta = datetime.strptime(self.eta.strip(), time_format)
|
|
155
|
+
|
|
156
|
+
diff_min = (t_actual - t_eta).total_seconds() / 60.0
|
|
157
|
+
# Midnight crossing adjustment (+/- 12-hour window)
|
|
158
|
+
if diff_min < -720:
|
|
159
|
+
diff_min += 1440
|
|
160
|
+
elif diff_min > 720:
|
|
161
|
+
diff_min -= 1440
|
|
162
|
+
|
|
163
|
+
self.deviation_minutes = round(diff_min, 1)
|
|
164
|
+
self.status = "delivered" if self.deviation_minutes <= 0 else "delayed"
|
|
165
|
+
|
|
166
|
+
def to_dict(self):
|
|
167
|
+
return {
|
|
168
|
+
"package_id": self.package_id,
|
|
169
|
+
"customer_id": self.customer_id,
|
|
170
|
+
"origin": self.origin,
|
|
171
|
+
"destination": self.destination,
|
|
172
|
+
"weight_kg": self.weight_kg,
|
|
173
|
+
"volume_m3": self.volume_m3,
|
|
174
|
+
"eta": self.eta,
|
|
175
|
+
"actual_time": self.actual_time,
|
|
176
|
+
"deviation_minutes": self.deviation_minutes,
|
|
177
|
+
"status": self.status,
|
|
178
|
+
"co2_emissions_kg": self.co2_emissions_kg
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Vehicle:
|
|
183
|
+
# Standard emission factors in kg of CO2 per liter of fuel consumed
|
|
184
|
+
CO2_EMISSION_FACTORS = {
|
|
185
|
+
"diesel": 2.68,
|
|
186
|
+
"gasoline": 2.31,
|
|
187
|
+
"hybrid": 1.85,
|
|
188
|
+
"lpg": 1.66,
|
|
189
|
+
"electric": 0.0
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
def __init__(self, vehicle_id: str, engine_type: str, weight_capacity_kg: float,
|
|
193
|
+
volume_capacity_m3: float, theoretical_consumption_km_l: float,
|
|
194
|
+
driver: str = None):
|
|
195
|
+
self.vehicle_id = vehicle_id.strip()
|
|
196
|
+
self.engine_type = engine_type.lower().strip()
|
|
197
|
+
self.weight_capacity_kg = weight_capacity_kg
|
|
198
|
+
self.volume_capacity_m3 = volume_capacity_m3
|
|
199
|
+
self.theoretical_consumption_km_l = theoretical_consumption_km_l
|
|
200
|
+
self.driver = driver.strip() if driver else f"Driver-{self.vehicle_id}"
|
|
201
|
+
self.packages = []
|
|
202
|
+
self.route_km_traveled = 0.0
|
|
203
|
+
self.actual_fuel_l = None
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def current_weight_kg(self):
|
|
207
|
+
return sum(p.weight_kg for p in self.packages)
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def current_volume_m3(self):
|
|
211
|
+
return sum(p.volume_m3 for p in self.packages)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def actual_consumption_km_l(self):
|
|
215
|
+
"""Calculates actual efficiency in km/liter if actual_fuel_l has been recorded."""
|
|
216
|
+
if self.actual_fuel_l and self.actual_fuel_l > 0 and self.route_km_traveled > 0:
|
|
217
|
+
return round(self.route_km_traveled / self.actual_fuel_l, 2)
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def can_load(self, package: Package):
|
|
221
|
+
valid_weight = (self.current_weight_kg + package.weight_kg) <= self.weight_capacity_kg
|
|
222
|
+
valid_volume = (self.current_volume_m3 + package.volume_m3) <= self.volume_capacity_m3
|
|
223
|
+
return valid_weight and valid_volume
|
|
224
|
+
|
|
225
|
+
def load_package(self, package: Package):
|
|
226
|
+
if self.can_load(package):
|
|
227
|
+
self.packages.append(package)
|
|
228
|
+
return True
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
def calculate_co2_emissions(self, km_traveled: float) -> float:
|
|
232
|
+
"""Calculates kg of CO2 emitted based on distance, theoretical consumption, and engine type."""
|
|
233
|
+
if self.theoretical_consumption_km_l <= 0 or km_traveled <= 0:
|
|
234
|
+
return 0.0
|
|
235
|
+
|
|
236
|
+
# Normalize accents in case accented strings are passed
|
|
237
|
+
engine_norm = (
|
|
238
|
+
self.engine_type
|
|
239
|
+
.replace("á", "a")
|
|
240
|
+
.replace("é", "e")
|
|
241
|
+
.replace("í", "i")
|
|
242
|
+
.replace("ó", "o")
|
|
243
|
+
.replace("ú", "u")
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
liters_consumed = km_traveled / self.theoretical_consumption_km_l
|
|
247
|
+
emission_factor = self.CO2_EMISSION_FACTORS.get(engine_norm, 2.50)
|
|
248
|
+
return round(liters_consumed * emission_factor, 2)
|
|
249
|
+
|
|
250
|
+
def to_dict(self):
|
|
251
|
+
return {
|
|
252
|
+
"vehicle_id": self.vehicle_id,
|
|
253
|
+
"driver": self.driver,
|
|
254
|
+
"engine_type": self.engine_type,
|
|
255
|
+
"current_weight_kg": self.current_weight_kg,
|
|
256
|
+
"weight_capacity_kg": self.weight_capacity_kg,
|
|
257
|
+
"current_volume_m3": self.current_volume_m3,
|
|
258
|
+
"volume_capacity_m3": self.volume_capacity_m3,
|
|
259
|
+
"theoretical_consumption_km_l": self.theoretical_consumption_km_l,
|
|
260
|
+
"actual_fuel_l": self.actual_fuel_l,
|
|
261
|
+
"actual_consumption_km_l": self.actual_consumption_km_l,
|
|
262
|
+
"num_packages": len(self.packages)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class FleetManager:
|
|
267
|
+
def __init__(self, transport_network: TransportNetwork):
|
|
268
|
+
"""
|
|
269
|
+
Main fleet orchestrator.
|
|
270
|
+
Receives the previously loaded TransportNetwork instance.
|
|
271
|
+
"""
|
|
272
|
+
self.network = transport_network
|
|
273
|
+
self.vehicles = {} # Dictionary: {vehicle_id: Vehicle_object}
|
|
274
|
+
self._simulation_executed = False
|
|
275
|
+
|
|
276
|
+
def add_vehicle(self, vehicle: Vehicle):
|
|
277
|
+
"""Registers a Vehicle object in the manager."""
|
|
278
|
+
self.vehicles[vehicle.vehicle_id] = vehicle
|
|
279
|
+
|
|
280
|
+
def load_and_validate_route_sheet(self, deliveries_csv_path: str) -> pd.DataFrame:
|
|
281
|
+
"""
|
|
282
|
+
Loads the daily deliveries CSV and validates that packages do not exceed
|
|
283
|
+
the weight and volume limits of their assigned vehicles.
|
|
284
|
+
"""
|
|
285
|
+
try:
|
|
286
|
+
df_deliveries = pd.read_csv(deliveries_csv_path)
|
|
287
|
+
except FileNotFoundError:
|
|
288
|
+
raise FileNotFoundError(f"Route sheet file not found: '{deliveries_csv_path}'")
|
|
289
|
+
|
|
290
|
+
# Verify required columns are present
|
|
291
|
+
required_columns = [
|
|
292
|
+
"package_id", "customer_id", "weight_kg", "volume_m3",
|
|
293
|
+
"origin", "destination", "eta", "vehicle_id"
|
|
294
|
+
]
|
|
295
|
+
for col in required_columns:
|
|
296
|
+
if col not in df_deliveries.columns:
|
|
297
|
+
raise ValueError(f"Missing required column '{col}' in the route sheet.")
|
|
298
|
+
|
|
299
|
+
# Reset vehicle loads and route metrics for the new simulation
|
|
300
|
+
for v in self.vehicles.values():
|
|
301
|
+
v.packages = []
|
|
302
|
+
v.route_km_traveled = 0.0
|
|
303
|
+
v.actual_fuel_l = None
|
|
304
|
+
self._simulation_executed = False
|
|
305
|
+
|
|
306
|
+
# Process row by row
|
|
307
|
+
for idx, row in df_deliveries.iterrows():
|
|
308
|
+
for col in required_columns:
|
|
309
|
+
if pd.isnull(row[col]):
|
|
310
|
+
raise ValueError(f"Row {idx + 2} contains a null value in required column '{col}'.")
|
|
311
|
+
|
|
312
|
+
pkg_id = str(row["package_id"]).strip()
|
|
313
|
+
v_id = str(row["vehicle_id"]).strip()
|
|
314
|
+
|
|
315
|
+
if v_id not in self.vehicles:
|
|
316
|
+
raise ValueError(f"Vehicle '{v_id}' assigned in the CSV is not registered in the fleet.")
|
|
317
|
+
|
|
318
|
+
weight = float(row["weight_kg"])
|
|
319
|
+
volume = float(row["volume_m3"])
|
|
320
|
+
if weight <= 0 or volume <= 0:
|
|
321
|
+
raise ValueError(f"Package '{pkg_id}' must have weight_kg and volume_m3 greater than 0.")
|
|
322
|
+
|
|
323
|
+
vehicle = self.vehicles[v_id]
|
|
324
|
+
|
|
325
|
+
# If CSV includes optional 'driver' column, update driver name on vehicle
|
|
326
|
+
if "driver" in row and pd.notnull(row["driver"]):
|
|
327
|
+
vehicle.driver = str(row["driver"]).strip()
|
|
328
|
+
|
|
329
|
+
# If CSV includes optional 'actual_fuel_l' column, validate consistency and record
|
|
330
|
+
if "actual_fuel_l" in row and pd.notnull(row["actual_fuel_l"]):
|
|
331
|
+
liters = float(row["actual_fuel_l"])
|
|
332
|
+
if liters <= 0:
|
|
333
|
+
raise ValueError(f"The value of 'actual_fuel_l' for '{v_id}' must be greater than 0.")
|
|
334
|
+
if vehicle.actual_fuel_l is not None and not np.isclose(vehicle.actual_fuel_l, liters):
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"Conflicting values of 'actual_fuel_l' for vehicle '{v_id}' "
|
|
337
|
+
f"({vehicle.actual_fuel_l} vs {liters})."
|
|
338
|
+
)
|
|
339
|
+
vehicle.actual_fuel_l = liters
|
|
340
|
+
|
|
341
|
+
origin = str(row["origin"]).strip()
|
|
342
|
+
destination = str(row["destination"]).strip()
|
|
343
|
+
|
|
344
|
+
if origin not in self.network.graph or destination not in self.network.graph:
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"Origin '{origin}' or destination '{destination}' for package '{pkg_id}' does not exist in the network."
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
actual_time = str(row["actual_time"]).strip() if "actual_time" in row and pd.notnull(row["actual_time"]) else None
|
|
350
|
+
|
|
351
|
+
package = Package(
|
|
352
|
+
package_id=pkg_id,
|
|
353
|
+
customer_id=str(row["customer_id"]).strip(),
|
|
354
|
+
weight_kg=weight,
|
|
355
|
+
volume_m3=volume,
|
|
356
|
+
origin=origin,
|
|
357
|
+
destination=destination,
|
|
358
|
+
eta=str(row["eta"]).strip(),
|
|
359
|
+
actual_time=actual_time
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
success = vehicle.load_package(package)
|
|
363
|
+
|
|
364
|
+
if not success:
|
|
365
|
+
raise CapacityExceededError(
|
|
366
|
+
vehicle_id=vehicle.vehicle_id,
|
|
367
|
+
reason=f"Package '{package.package_id}' exceeds weight or volume capacity."
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
print("Route sheet validated successfully: All packages comply with weight and volume limits.")
|
|
371
|
+
return df_deliveries
|
|
372
|
+
|
|
373
|
+
def generate_operations_report(self) -> pd.DataFrame:
|
|
374
|
+
"""Calculates routes, distances, estimated/actual consumption, CO2 emissions, and punctuality per vehicle."""
|
|
375
|
+
results = []
|
|
376
|
+
|
|
377
|
+
for vehicle in self.vehicles.values():
|
|
378
|
+
if not vehicle.packages:
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
total_route = []
|
|
382
|
+
total_km = 0.0
|
|
383
|
+
current_point = vehicle.packages[0].origin
|
|
384
|
+
|
|
385
|
+
for package in vehicle.packages:
|
|
386
|
+
package_leg_km = 0.0
|
|
387
|
+
|
|
388
|
+
# 1. Travel to the package origin if the vehicle is at another node
|
|
389
|
+
if current_point != package.origin:
|
|
390
|
+
pickup_leg, pickup_km = self.network.calculate_optimal_route(current_point, package.origin)
|
|
391
|
+
if pickup_km == float('inf'):
|
|
392
|
+
raise ValueError(f"No possible route exists between '{current_point}' and '{package.origin}'.")
|
|
393
|
+
package_leg_km += pickup_km
|
|
394
|
+
total_route.extend(pickup_leg[1:] if total_route else pickup_leg)
|
|
395
|
+
|
|
396
|
+
# 2. Deliver the package from its origin to its destination
|
|
397
|
+
delivery_leg, delivery_km = self.network.calculate_optimal_route(package.origin, package.destination)
|
|
398
|
+
if delivery_km == float('inf'):
|
|
399
|
+
raise ValueError(f"No possible route exists between '{package.origin}' and '{package.destination}'.")
|
|
400
|
+
|
|
401
|
+
package_leg_km += delivery_km
|
|
402
|
+
total_km += package_leg_km
|
|
403
|
+
total_route.extend(delivery_leg[1:] if total_route else delivery_leg)
|
|
404
|
+
current_point = package.destination
|
|
405
|
+
|
|
406
|
+
# 3. Update punctuality and total service emissions (pickup + delivery)
|
|
407
|
+
package.update_status()
|
|
408
|
+
package.co2_emissions_kg = vehicle.calculate_co2_emissions(package_leg_km)
|
|
409
|
+
|
|
410
|
+
vehicle.route_km_traveled = total_km
|
|
411
|
+
estimated_consumption = round(total_km / vehicle.theoretical_consumption_km_l, 2) if vehicle.theoretical_consumption_km_l > 0 else 0.0
|
|
412
|
+
total_emissions = vehicle.calculate_co2_emissions(total_km)
|
|
413
|
+
|
|
414
|
+
# Actual efficiency metrics if actual fuel data is available
|
|
415
|
+
actual_km_l = vehicle.actual_consumption_km_l
|
|
416
|
+
if actual_km_l is not None and vehicle.theoretical_consumption_km_l > 0:
|
|
417
|
+
efficiency_dev = round(((actual_km_l - vehicle.theoretical_consumption_km_l) / vehicle.theoretical_consumption_km_l) * 100, 2)
|
|
418
|
+
else:
|
|
419
|
+
efficiency_dev = None
|
|
420
|
+
|
|
421
|
+
completed = [p for p in vehicle.packages if p.status != "pending"]
|
|
422
|
+
on_time = sum(1 for p in completed if p.status == "delivered")
|
|
423
|
+
delays = sum(1 for p in completed if p.status == "delayed")
|
|
424
|
+
punctuality_score = round((on_time / len(completed)) * 100, 2) if completed else None
|
|
425
|
+
|
|
426
|
+
results.append({
|
|
427
|
+
"vehicle_id": vehicle.vehicle_id,
|
|
428
|
+
"driver": vehicle.driver,
|
|
429
|
+
"engine": vehicle.engine_type,
|
|
430
|
+
"route_taken": " -> ".join(total_route),
|
|
431
|
+
"total_km": total_km,
|
|
432
|
+
"estimated_consumption_l": estimated_consumption,
|
|
433
|
+
"actual_fuel_l": vehicle.actual_fuel_l,
|
|
434
|
+
"actual_consumption_km_l": actual_km_l,
|
|
435
|
+
"efficiency_deviation_pct": efficiency_dev,
|
|
436
|
+
"co2_emissions_kg": total_emissions,
|
|
437
|
+
"total_packages": len(vehicle.packages),
|
|
438
|
+
"delayed_packages": delays,
|
|
439
|
+
"punctuality_score": punctuality_score
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
self._simulation_executed = True
|
|
443
|
+
return pd.DataFrame(results)
|
|
444
|
+
|
|
445
|
+
def generate_fuel_efficiency_report(
|
|
446
|
+
self,
|
|
447
|
+
consumption_csv_path: str = None,
|
|
448
|
+
inefficiency_threshold_pct: float = 15.0
|
|
449
|
+
) -> pd.DataFrame:
|
|
450
|
+
"""
|
|
451
|
+
Analyzes deviations between actual (km/l) and theoretical fuel efficiency using Pandas
|
|
452
|
+
to detect inefficient driving.
|
|
453
|
+
|
|
454
|
+
- If `consumption_csv_path` is passed, reads a CSV with required columns:
|
|
455
|
+
['vehicle_id', 'actual_fuel_l'] and optional ['actual_km'].
|
|
456
|
+
- If no CSV is passed, uses the data loaded in the vehicles after simulation.
|
|
457
|
+
"""
|
|
458
|
+
output_columns = [
|
|
459
|
+
"vehicle_id", "driver", "engine", "km_traveled",
|
|
460
|
+
"theoretical_consumption_km_l", "actual_consumption_km_l",
|
|
461
|
+
"deviation_km_l", "deviation_pct",
|
|
462
|
+
"theoretical_liters", "actual_fuel_l", "extra_liters",
|
|
463
|
+
"inefficient_driving", "diagnosis"
|
|
464
|
+
]
|
|
465
|
+
|
|
466
|
+
# Ensure route distances are calculated if there are packages pending simulation
|
|
467
|
+
if not self._simulation_executed and any(v.packages for v in self.vehicles.values()):
|
|
468
|
+
self.generate_operations_report()
|
|
469
|
+
|
|
470
|
+
# Build base DataFrame of the registered fleet
|
|
471
|
+
fleet_data = [
|
|
472
|
+
{
|
|
473
|
+
"vehicle_id": v.vehicle_id,
|
|
474
|
+
"driver": v.driver,
|
|
475
|
+
"engine": v.engine_type,
|
|
476
|
+
"theoretical_consumption_km_l": v.theoretical_consumption_km_l,
|
|
477
|
+
"route_km": v.route_km_traveled,
|
|
478
|
+
"loaded_liters": v.actual_fuel_l
|
|
479
|
+
}
|
|
480
|
+
for v in self.vehicles.values()
|
|
481
|
+
]
|
|
482
|
+
if not fleet_data:
|
|
483
|
+
return pd.DataFrame(columns=output_columns)
|
|
484
|
+
|
|
485
|
+
df_fleet = pd.DataFrame(fleet_data)
|
|
486
|
+
|
|
487
|
+
if consumption_csv_path is not None:
|
|
488
|
+
try:
|
|
489
|
+
df_consumption = pd.read_csv(consumption_csv_path)
|
|
490
|
+
except FileNotFoundError:
|
|
491
|
+
raise FileNotFoundError(f"Fuel consumption file not found: '{consumption_csv_path}'")
|
|
492
|
+
|
|
493
|
+
for col in ["vehicle_id", "actual_fuel_l"]:
|
|
494
|
+
if col not in df_consumption.columns:
|
|
495
|
+
raise ValueError(f"Missing required column '{col}' in the fuel consumption CSV.")
|
|
496
|
+
|
|
497
|
+
df_consumption["vehicle_id"] = df_consumption["vehicle_id"].astype(str).str.strip()
|
|
498
|
+
unregistered = set(df_consumption["vehicle_id"]) - set(self.vehicles.keys())
|
|
499
|
+
if unregistered:
|
|
500
|
+
raise ValueError(f"Vehicles not registered in the fleet: {', '.join(sorted(unregistered))}")
|
|
501
|
+
|
|
502
|
+
if (df_consumption["actual_fuel_l"] <= 0).any():
|
|
503
|
+
raise ValueError("All values of 'actual_fuel_l' must be greater than 0.")
|
|
504
|
+
|
|
505
|
+
# Group in case there are multiple refuelings for the same vehicle, preserving NaN if actual_km is missing
|
|
506
|
+
agg_dict = {"actual_fuel_l": "sum"}
|
|
507
|
+
if "actual_km" in df_consumption.columns:
|
|
508
|
+
df_consumption["actual_km"] = pd.to_numeric(df_consumption["actual_km"], errors="coerce")
|
|
509
|
+
agg_dict["actual_km"] = lambda s: s.sum(min_count=1)
|
|
510
|
+
|
|
511
|
+
df_agg = df_consumption.groupby("vehicle_id", as_index=False).agg(agg_dict)
|
|
512
|
+
df = pd.merge(df_fleet, df_agg, on="vehicle_id", how="inner")
|
|
513
|
+
|
|
514
|
+
if "actual_km" in df.columns:
|
|
515
|
+
df["km_traveled"] = df["actual_km"].fillna(df["route_km"])
|
|
516
|
+
else:
|
|
517
|
+
df["km_traveled"] = df["route_km"]
|
|
518
|
+
else:
|
|
519
|
+
df = df_fleet[df_fleet["loaded_liters"].notnull() & (df_fleet["route_km"] > 0)].copy()
|
|
520
|
+
if df.empty:
|
|
521
|
+
return pd.DataFrame(columns=output_columns)
|
|
522
|
+
df["km_traveled"] = df["route_km"]
|
|
523
|
+
df["actual_fuel_l"] = df["loaded_liters"].astype(float)
|
|
524
|
+
|
|
525
|
+
df = df[(df["km_traveled"] > 0) & (df["theoretical_consumption_km_l"] > 0)].copy()
|
|
526
|
+
if df.empty:
|
|
527
|
+
return pd.DataFrame(columns=output_columns)
|
|
528
|
+
|
|
529
|
+
# Vectorized calculations with Pandas
|
|
530
|
+
df["actual_consumption_km_l"] = (df["km_traveled"] / df["actual_fuel_l"]).round(2)
|
|
531
|
+
df["deviation_km_l"] = (df["actual_consumption_km_l"] - df["theoretical_consumption_km_l"]).round(2)
|
|
532
|
+
df["deviation_pct"] = ((df["deviation_km_l"] / df["theoretical_consumption_km_l"]) * 100).round(2)
|
|
533
|
+
|
|
534
|
+
df["theoretical_liters"] = (df["km_traveled"] / df["theoretical_consumption_km_l"]).round(2)
|
|
535
|
+
df["actual_fuel_l"] = df["actual_fuel_l"].round(2)
|
|
536
|
+
df["extra_liters"] = (df["actual_fuel_l"] - df["theoretical_liters"]).round(2)
|
|
537
|
+
|
|
538
|
+
# Actual efficiency lower than theoretical by more than `inefficiency_threshold_pct` indicates inefficient driving
|
|
539
|
+
df["inefficient_driving"] = df["deviation_pct"] < -abs(inefficiency_threshold_pct)
|
|
540
|
+
df["diagnosis"] = np.where(
|
|
541
|
+
df["inefficient_driving"],
|
|
542
|
+
"Inefficient driving",
|
|
543
|
+
np.where(df["deviation_pct"] > 5.0, "Efficient driving", "Normal consumption")
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
return df[output_columns]
|
|
547
|
+
|
|
548
|
+
def generate_punctuality_report(self) -> pd.DataFrame:
|
|
549
|
+
"""
|
|
550
|
+
Evaluates each driver's punctuality by comparing actual_time vs eta
|
|
551
|
+
and calculates their quality score (0 to 100).
|
|
552
|
+
"""
|
|
553
|
+
driver_data = {}
|
|
554
|
+
|
|
555
|
+
for vehicle in self.vehicles.values():
|
|
556
|
+
if not vehicle.packages:
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
driver = vehicle.driver
|
|
560
|
+
if driver not in driver_data:
|
|
561
|
+
driver_data[driver] = {
|
|
562
|
+
"vehicles": [],
|
|
563
|
+
"packages": []
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
driver_data[driver]["vehicles"].append(vehicle.vehicle_id)
|
|
567
|
+
|
|
568
|
+
for package in vehicle.packages:
|
|
569
|
+
package.update_status()
|
|
570
|
+
driver_data[driver]["packages"].append(package)
|
|
571
|
+
|
|
572
|
+
results = []
|
|
573
|
+
for driver, info in driver_data.items():
|
|
574
|
+
packages = info["packages"]
|
|
575
|
+
completed = [p for p in packages if p.status != "pending"]
|
|
576
|
+
on_time = sum(1 for p in completed if p.status == "delivered")
|
|
577
|
+
delayed = sum(1 for p in completed if p.status == "delayed")
|
|
578
|
+
pending = sum(1 for p in packages if p.status == "pending")
|
|
579
|
+
|
|
580
|
+
deviations = [p.deviation_minutes for p in completed if p.deviation_minutes is not None]
|
|
581
|
+
avg_deviation = round(sum(deviations) / len(deviations), 1) if deviations else None
|
|
582
|
+
positive_delays = [d for d in deviations if d > 0]
|
|
583
|
+
max_delay = round(max(positive_delays), 1) if positive_delays else 0.0
|
|
584
|
+
|
|
585
|
+
if completed:
|
|
586
|
+
score = round((on_time / len(completed)) * 100, 2)
|
|
587
|
+
if score >= 95:
|
|
588
|
+
rating = "Excellent"
|
|
589
|
+
elif score >= 80:
|
|
590
|
+
rating = "Good"
|
|
591
|
+
elif score >= 60:
|
|
592
|
+
rating = "Needs Improvement"
|
|
593
|
+
else:
|
|
594
|
+
rating = "Critical"
|
|
595
|
+
else:
|
|
596
|
+
score = None
|
|
597
|
+
rating = "No deliveries"
|
|
598
|
+
|
|
599
|
+
results.append({
|
|
600
|
+
"driver": driver,
|
|
601
|
+
"vehicles": ", ".join(info["vehicles"]),
|
|
602
|
+
"assigned_packages": len(packages),
|
|
603
|
+
"delivered_on_time": on_time,
|
|
604
|
+
"delayed": delayed,
|
|
605
|
+
"pending": pending,
|
|
606
|
+
"avg_deviation_min": avg_deviation,
|
|
607
|
+
"max_delay_min": max_delay,
|
|
608
|
+
"punctuality_score": score,
|
|
609
|
+
"rating": rating
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
return pd.DataFrame(results)
|
|
613
|
+
|
|
614
|
+
def generate_package_co2_report(self) -> pd.DataFrame:
|
|
615
|
+
"""Returns a DataFrame with the carbon footprint broken down by delivered package."""
|
|
616
|
+
if not self._simulation_executed and any(v.packages for v in self.vehicles.values()):
|
|
617
|
+
self.generate_operations_report()
|
|
618
|
+
|
|
619
|
+
records = []
|
|
620
|
+
for vehicle in self.vehicles.values():
|
|
621
|
+
for p in vehicle.packages:
|
|
622
|
+
records.append({
|
|
623
|
+
"package_id": p.package_id,
|
|
624
|
+
"customer_id": p.customer_id,
|
|
625
|
+
"vehicle_id": vehicle.vehicle_id,
|
|
626
|
+
"driver": vehicle.driver,
|
|
627
|
+
"engine": vehicle.engine_type,
|
|
628
|
+
"origin": p.origin,
|
|
629
|
+
"destination": p.destination,
|
|
630
|
+
"eta": p.eta,
|
|
631
|
+
"actual_time": p.actual_time,
|
|
632
|
+
"deviation_minutes": p.deviation_minutes,
|
|
633
|
+
"status": p.status,
|
|
634
|
+
"co2_emissions_kg": p.co2_emissions_kg
|
|
635
|
+
})
|
|
636
|
+
return pd.DataFrame(records)
|
|
637
|
+
|
|
638
|
+
def generate_customer_co2_report(self) -> pd.DataFrame:
|
|
639
|
+
"""Groups and sums total CO2 emissions per customer."""
|
|
640
|
+
df_packages = self.generate_package_co2_report()
|
|
641
|
+
if df_packages.empty:
|
|
642
|
+
return pd.DataFrame(columns=["customer_id", "total_packages", "total_co2_emissions_kg"])
|
|
643
|
+
|
|
644
|
+
return (
|
|
645
|
+
df_packages.groupby("customer_id", as_index=False)
|
|
646
|
+
.agg(
|
|
647
|
+
total_packages=("package_id", "count"),
|
|
648
|
+
total_co2_emissions_kg=("co2_emissions_kg", "sum")
|
|
649
|
+
)
|
|
650
|
+
.round({"total_co2_emissions_kg": 2})
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def generate_vehicle_manifest(self, vehicle_id: str) -> pd.DataFrame:
|
|
654
|
+
"""
|
|
655
|
+
Builds a structured stop-by-stop load manifest for a specific vehicle,
|
|
656
|
+
ready to be viewed by the driver on their mobile device.
|
|
657
|
+
"""
|
|
658
|
+
if vehicle_id not in self.vehicles:
|
|
659
|
+
raise ValueError(f"Vehicle '{vehicle_id}' is not registered in the fleet.")
|
|
660
|
+
|
|
661
|
+
vehicle = self.vehicles[vehicle_id]
|
|
662
|
+
if not vehicle.packages:
|
|
663
|
+
raise ValueError(f"Vehicle '{vehicle_id}' has no assigned packages.")
|
|
664
|
+
|
|
665
|
+
rows = []
|
|
666
|
+
current_point = vehicle.packages[0].origin
|
|
667
|
+
|
|
668
|
+
for order, package in enumerate(vehicle.packages, start=1):
|
|
669
|
+
package.update_status()
|
|
670
|
+
|
|
671
|
+
full_leg = []
|
|
672
|
+
leg_km = 0.0
|
|
673
|
+
requires_transfer = False
|
|
674
|
+
|
|
675
|
+
if current_point != package.origin:
|
|
676
|
+
pickup_leg, pickup_km = self.network.calculate_optimal_route(current_point, package.origin)
|
|
677
|
+
if pickup_km == float('inf'):
|
|
678
|
+
raise ValueError(f"No possible route exists between '{current_point}' and '{package.origin}'.")
|
|
679
|
+
full_leg.extend(pickup_leg)
|
|
680
|
+
leg_km += pickup_km
|
|
681
|
+
requires_transfer = True
|
|
682
|
+
|
|
683
|
+
delivery_leg, delivery_km = self.network.calculate_optimal_route(package.origin, package.destination)
|
|
684
|
+
if delivery_km == float('inf'):
|
|
685
|
+
raise ValueError(f"No possible route exists between '{package.origin}' and '{package.destination}'.")
|
|
686
|
+
|
|
687
|
+
full_leg.extend(delivery_leg[1:] if full_leg else delivery_leg)
|
|
688
|
+
leg_km += delivery_km
|
|
689
|
+
current_point = package.destination
|
|
690
|
+
|
|
691
|
+
operation_type = (
|
|
692
|
+
f"Transfer from {full_leg[0]} + Pickup and Delivery"
|
|
693
|
+
if requires_transfer
|
|
694
|
+
else "Pickup and Delivery"
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
rows.append({
|
|
698
|
+
"stop_order": order,
|
|
699
|
+
"vehicle_id": vehicle.vehicle_id,
|
|
700
|
+
"driver": vehicle.driver,
|
|
701
|
+
"package_id": package.package_id,
|
|
702
|
+
"customer_id": package.customer_id,
|
|
703
|
+
"operation_type": operation_type,
|
|
704
|
+
"origin": package.origin,
|
|
705
|
+
"destination": package.destination,
|
|
706
|
+
"navigation_route": " -> ".join(full_leg),
|
|
707
|
+
"leg_distance_km": round(leg_km, 2),
|
|
708
|
+
"weight_kg": package.weight_kg,
|
|
709
|
+
"volume_m3": package.volume_m3,
|
|
710
|
+
"eta": package.eta,
|
|
711
|
+
"status": package.status,
|
|
712
|
+
"delivered_check": "[ ]",
|
|
713
|
+
"driver_notes": ""
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
return pd.DataFrame(rows)
|
|
717
|
+
|
|
718
|
+
def export_load_manifest(self, vehicle_id: str = None, filename: str = None) -> dict:
|
|
719
|
+
"""
|
|
720
|
+
Generates and exports one or more Load Manifest CSV files directly
|
|
721
|
+
in the current directory, ready for the driver's mobile device.
|
|
722
|
+
|
|
723
|
+
- If `vehicle_id` is specified:
|
|
724
|
+
* Saves `<filename>` if provided, or `manifest_<vehicle_id>.csv` in the current directory.
|
|
725
|
+
- If `vehicle_id` is None:
|
|
726
|
+
* Exports an individual CSV (`manifest_<vehicle_id>.csv`) for each vehicle with assigned cargo.
|
|
727
|
+
|
|
728
|
+
Returns a dictionary {vehicle_id: generated_csv_filename}.
|
|
729
|
+
"""
|
|
730
|
+
generated_files = {}
|
|
731
|
+
|
|
732
|
+
if vehicle_id is not None:
|
|
733
|
+
df_manifest = self.generate_vehicle_manifest(vehicle_id)
|
|
734
|
+
if filename:
|
|
735
|
+
clean_name = filename.strip()
|
|
736
|
+
final_file = clean_name if clean_name.lower().endswith(".csv") else f"{clean_name}.csv"
|
|
737
|
+
else:
|
|
738
|
+
final_file = f"manifest_{vehicle_id}.csv"
|
|
739
|
+
|
|
740
|
+
df_manifest.to_csv(final_file, index=False, encoding="utf-8-sig")
|
|
741
|
+
generated_files[vehicle_id] = final_file
|
|
742
|
+
return generated_files
|
|
743
|
+
|
|
744
|
+
active_vehicles = [v for v in self.vehicles.values() if v.packages]
|
|
745
|
+
if not active_vehicles:
|
|
746
|
+
raise ValueError("No vehicles with assigned packages available to export load manifests.")
|
|
747
|
+
|
|
748
|
+
for vehicle in active_vehicles:
|
|
749
|
+
df_manifest = self.generate_vehicle_manifest(vehicle.vehicle_id)
|
|
750
|
+
final_file = f"manifest_{vehicle.vehicle_id}.csv"
|
|
751
|
+
df_manifest.to_csv(final_file, index=False, encoding="utf-8-sig")
|
|
752
|
+
generated_files[vehicle.vehicle_id] = final_file
|
|
753
|
+
|
|
754
|
+
return generated_files
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple_logistics
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Library for management of vehicles and packages
|
|
5
|
+
Author-email: Beñat Monje <benat.monje@alumni.mondragon.edu>, Txaber Ajangiz <txaber.ajangiz@alumni.mondragon.edu>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: logistics,vehicles,routes
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: numpy>=1.24.0
|
|
15
|
+
Requires-Dist: pandas>=2.0.0
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# Simple Logistics
|
|
19
|
+
|
|
20
|
+
A Python library for transport fleet logistics management. Model distribution networks via adjacency matrices, compute optimal routes using Dijkstra's algorithm, validate vehicle load limits, and generate operational analytics.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
* **Transport Network (`TransportNetwork`):** Strict loading and validation of CSV adjacency matrices and shortest-path calculation.
|
|
25
|
+
* **Fleet & Load Control (`FleetManager`, `Vehicle`, `Package`):** Automated route sheet validation enforcing maximum weight and volume capacities per vehicle.
|
|
26
|
+
* **Logistics Analytics (`FleetManager`):**
|
|
27
|
+
* Fuel efficiency evaluation (actual vs. theoretical consumption).
|
|
28
|
+
* Delivery punctuality measurement (ETA vs. actual delivery time) and vehicle/driver quality scoring.
|
|
29
|
+
* Carbon footprint ($\text{CO}_2$) reporting grouped by package, customer, or route.
|
|
30
|
+
* Structured CSV load manifest export ready for driver mobile devices.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install simple_logistics
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from simple_logistics import (
|
|
42
|
+
TransportNetwork,
|
|
43
|
+
Vehicle,
|
|
44
|
+
FleetManager,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
## Input CSV Schemas
|
|
48
|
+
|
|
49
|
+
### 1. Adjacency Matrix (`network.csv`)
|
|
50
|
+
A square matrix where row and column headers match in exact order, distances are non-negative, and the main diagonal is `0`:
|
|
51
|
+
|
|
52
|
+
```csv
|
|
53
|
+
,Hub_A,Hub_B,Client_1
|
|
54
|
+
Hub_A,0,12.5,30.0
|
|
55
|
+
Hub_B,12.5,0,15.0
|
|
56
|
+
Client_1,30.0,15.0,0
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2. Daily Route Sheet (`deliveries.csv`)
|
|
60
|
+
- **Required columns**: `package_id`, `customer_id`, `weight_kg`, `volume_m3`, `origin`, `destination`, `eta`, `vehicle_id`
|
|
61
|
+
- **Optional columns**: `driver`, `actual_time`, `actual_fuel_l`
|
|
62
|
+
|
|
63
|
+
```csv
|
|
64
|
+
package_id,customer_id,weight_kg,volume_m3,origin,destination,eta,vehicle_id,actual_time,actual_fuel_l
|
|
65
|
+
PKG-001,CUST-10,120.5,1.2,Hub_A,Hub_B,09:30,TRUCK-01,09:20,4.2
|
|
66
|
+
PKG-002,CUST-11,85.0,0.8,Hub_B,Client_1,10:45,TRUCK-01,10:55,4.2
|
|
67
|
+
PKG-003,CUST-10,40.0,0.5,Hub_A,Client_1,11:00,VAN-02,10:50,
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 3. External Fuel Logs (`fuel_logs.csv`, optional)
|
|
71
|
+
- **Required columns**: `vehicle_id`, `actual_fuel_l`
|
|
72
|
+
- **Optional column**: `actual_km`
|
|
73
|
+
|
|
74
|
+
## Quick Start Example
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from fleet_route_engine import (
|
|
78
|
+
TransportNetwork,
|
|
79
|
+
Vehicle,
|
|
80
|
+
FleetManager,
|
|
81
|
+
CapacityExceededError,
|
|
82
|
+
InvalidAdjacencyMatrixError,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# 1. Load and validate the transport network
|
|
86
|
+
network = TransportNetwork()
|
|
87
|
+
try:
|
|
88
|
+
network.load_matrix_csv("network.csv")
|
|
89
|
+
except InvalidAdjacencyMatrixError as e:
|
|
90
|
+
print(f"Network error: {e}")
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
# Optional: Query a single shortest path directly
|
|
94
|
+
route, distance_km = network.calculate_optimal_route("Hub_A", "Client_1")
|
|
95
|
+
print(f"Optimal route: {' -> '.join(route)} ({distance_km} km)")
|
|
96
|
+
|
|
97
|
+
# 2. Initialize the fleet manager and register vehicles
|
|
98
|
+
manager = FleetManager(network)
|
|
99
|
+
|
|
100
|
+
manager.add_vehicle(
|
|
101
|
+
Vehicle(
|
|
102
|
+
vehicle_id="TRUCK-01",
|
|
103
|
+
engine_type="diesel",
|
|
104
|
+
weight_capacity_kg=1500.0,
|
|
105
|
+
volume_capacity_m3=12.0,
|
|
106
|
+
theoretical_consumption_km_l=10.5,
|
|
107
|
+
driver="Alex Rivera",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
manager.add_vehicle(
|
|
112
|
+
Vehicle(
|
|
113
|
+
vehicle_id="VAN-02",
|
|
114
|
+
engine_type="electric",
|
|
115
|
+
weight_capacity_kg=800.0,
|
|
116
|
+
volume_capacity_m3=6.5,
|
|
117
|
+
theoretical_consumption_km_l=6.0,
|
|
118
|
+
driver="Sam Taylor",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# 3. Load and validate the daily route sheet
|
|
123
|
+
try:
|
|
124
|
+
manager.load_and_validate_route_sheet("deliveries.csv")
|
|
125
|
+
except CapacityExceededError as e:
|
|
126
|
+
print(f"Load validation failed: {e}")
|
|
127
|
+
raise
|
|
128
|
+
|
|
129
|
+
# 4. Generate analytical reports (returned as Pandas DataFrames)
|
|
130
|
+
ops_report = manager.generate_operations_report()
|
|
131
|
+
print("\n--- Operations Report ---")
|
|
132
|
+
print(ops_report)
|
|
133
|
+
|
|
134
|
+
fuel_report = manager.generate_fuel_efficiency_report(inefficiency_threshold_pct=15.0)
|
|
135
|
+
print("\n--- Fuel Efficiency Report ---")
|
|
136
|
+
print(fuel_report)
|
|
137
|
+
|
|
138
|
+
punctuality_report = manager.generate_punctuality_report()
|
|
139
|
+
print("\n--- Driver Punctuality Report ---")
|
|
140
|
+
print(punctuality_report)
|
|
141
|
+
|
|
142
|
+
customer_co2_report = manager.generate_customer_co2_report()
|
|
143
|
+
print("\n--- CO2 Emissions by Customer ---")
|
|
144
|
+
print(customer_co2_report)
|
|
145
|
+
|
|
146
|
+
# 5. Export stop-by-stop load manifests for drivers
|
|
147
|
+
exported_files = manager.export_load_manifest()
|
|
148
|
+
print("\nGenerated manifests:", exported_files)
|
|
149
|
+
```
|
|
150
|
+
## Requirements
|
|
151
|
+
|
|
152
|
+
* Python >= 3.9
|
|
153
|
+
* `numpy`
|
|
154
|
+
* `pandas`
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
simple_logistics/__init__.py,sha256=b56Dk6dwNZgbfaz6_sT3Dk-ERLMVvNfJx59Sex7vxLI,321
|
|
2
|
+
simple_logistics/logistics.py,sha256=1j-wafSPABnfYp3Jl1KvrB8jW4gwGj9f7UyqdMaeBB4,33235
|
|
3
|
+
simple_logistics-0.1.0.dist-info/licenses/LICENSE,sha256=K9ubANS_GxRR_00SKXCCus_uMaoz31S66iW-A0LWZIc,1105
|
|
4
|
+
simple_logistics-0.1.0.dist-info/METADATA,sha256=ws-l3WZL_YFgF9e2nOQapczSlDdEhjT1ktUQ8hTrjoE,4978
|
|
5
|
+
simple_logistics-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
simple_logistics-0.1.0.dist-info/top_level.txt,sha256=wtIMdbOUB-XXfA89SsSP7cVuLl3O5gsoebeWvq_Xu50,17
|
|
7
|
+
simple_logistics-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Txaber Ajangiz , Beñat Monje
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simple_logistics
|