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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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