Initial commit: T-ALNS-RRD paper reproduction project
- Paper: Optimizing urban last mile delivery efficiency (Liu & Wang, 2025) - 5 algorithms: Static-VRPTW, TA-Greedy, ALNS-Base, T-ALNS, T-ALNS-RRD - v1 baseline + v2 calibrated experiments with full results - Tabu memory ablation study with convergence analysis - Comprehensive final report (FINAL_REPORT.md)
This commit is contained in:
429
t_alns_rrd_reproduction/src/cost.py
Normal file
429
t_alns_rrd_reproduction/src/cost.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
Cost functions for the DVRPTW-TA problem.
|
||||
|
||||
Implements the composite objective (Eq.1) and insertion cost evaluation
|
||||
(Eq.16-17) from the paper.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Tuple
|
||||
import numpy as np
|
||||
|
||||
|
||||
class CostCalculator:
|
||||
"""Computes costs for routing solutions under traffic-aware conditions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lambda_lateness: float = 1.0,
|
||||
lambda_congestion: float = 1.0,
|
||||
lambda_stability: float = 0.3,
|
||||
):
|
||||
self.lambda_lateness = lambda_lateness
|
||||
self.lambda_congestion = lambda_congestion
|
||||
self.lambda_stability = lambda_stability
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Route-level time propagation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def propagate_route(
|
||||
self,
|
||||
route_nodes: List[int],
|
||||
problem_ctx: "ProblemContext",
|
||||
depot_start_time: float = 360.0,
|
||||
) -> dict:
|
||||
"""Compute all time values along a route.
|
||||
|
||||
Implements Eq.5:
|
||||
A_j = T_i + t_ij(T_i)
|
||||
S_j = max(A_j, e_j)
|
||||
T_j = S_j + s_j
|
||||
δ_j = max(0, S_j - l_j)
|
||||
|
||||
Returns dict with arrival_times, service_starts, departures,
|
||||
delays, wait_times, and totals.
|
||||
"""
|
||||
customers = problem_ctx.customers
|
||||
n = len(route_nodes)
|
||||
arrivals = [0.0] * n
|
||||
service_starts = [0.0] * n
|
||||
departures = [0.0] * n
|
||||
delays = [0.0] * n
|
||||
waits = [0.0] * n
|
||||
congestion_exposure = 0.0
|
||||
|
||||
# First node (depot)
|
||||
departures[0] = depot_start_time
|
||||
service_starts[0] = depot_start_time
|
||||
arrivals[0] = depot_start_time
|
||||
|
||||
for idx in range(1, n):
|
||||
i = route_nodes[idx - 1]
|
||||
j = route_nodes[idx]
|
||||
|
||||
# Arrival at j (Eq.5)
|
||||
arrivals[idx] = departures[idx - 1] + problem_ctx.get_travel_time(
|
||||
i, j, departures[idx - 1]
|
||||
)
|
||||
|
||||
# Accumulate congestion exposure
|
||||
congestion_exposure += problem_ctx.get_congestion_penalty(
|
||||
i, j, departures[idx - 1]
|
||||
)
|
||||
|
||||
# Service start (wait if early)
|
||||
if j == 0:
|
||||
# Return to depot: no service time, no time window
|
||||
service_starts[idx] = arrivals[idx]
|
||||
delays[idx] = 0.0
|
||||
waits[idx] = 0.0
|
||||
else:
|
||||
cust = customers.get(j)
|
||||
if cust is None:
|
||||
# Should not happen in valid solutions
|
||||
service_starts[idx] = arrivals[idx]
|
||||
delays[idx] = 0.0
|
||||
waits[idx] = 0.0
|
||||
else:
|
||||
# Soft time window: wait if early, record delay if late
|
||||
service_starts[idx] = max(arrivals[idx], cust.earliest_time_min)
|
||||
waits[idx] = max(0.0, cust.earliest_time_min - arrivals[idx])
|
||||
delays[idx] = max(0.0, service_starts[idx] - cust.latest_time_min)
|
||||
|
||||
# Departure (Eq.5)
|
||||
if j == 0:
|
||||
departures[idx] = arrivals[idx]
|
||||
else:
|
||||
service_time = customers[j].service_time_min if j in customers else 0.0
|
||||
departures[idx] = service_starts[idx] + service_time
|
||||
|
||||
return {
|
||||
"arrivals": arrivals,
|
||||
"service_starts": service_starts,
|
||||
"departures": departures,
|
||||
"delays": delays,
|
||||
"waits": waits,
|
||||
"total_travel_time": arrivals[-1] - depot_start_time,
|
||||
"total_delay": sum(delays),
|
||||
"total_wait": sum(waits),
|
||||
"congestion_exposure": congestion_exposure,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Composite cost (Eq.1)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def compute_total_cost(
|
||||
self,
|
||||
solution: "Solution",
|
||||
problem_ctx: "ProblemContext",
|
||||
previous_solution: "Solution" = None,
|
||||
) -> float:
|
||||
"""Compute composite objective value per Eq.1.
|
||||
|
||||
Cost = Σ_k Σ_(i,j) [t_ij(T_i) + λ₂·ρ_ij(T_i)] + λ₁·Σ_j δ_j
|
||||
|
||||
For RRD: also includes λ₃ × stability penalty.
|
||||
"""
|
||||
total_travel_time = 0.0
|
||||
total_delay = 0.0
|
||||
total_congestion = 0.0
|
||||
|
||||
for route in solution.routes:
|
||||
if len(route.nodes) < 2:
|
||||
continue
|
||||
result = self.propagate_route(route.nodes, problem_ctx)
|
||||
|
||||
# Travel time: sum of all arc travel times
|
||||
total_travel_time += result["total_travel_time"]
|
||||
|
||||
# Delay penalty: sum of lateness at all customers
|
||||
total_delay += result["total_delay"]
|
||||
|
||||
# Congestion: sum of ρ_ij on traversed arcs
|
||||
total_congestion += result["congestion_exposure"]
|
||||
|
||||
cost = (
|
||||
total_travel_time
|
||||
+ self.lambda_lateness * total_delay
|
||||
+ self.lambda_congestion * total_congestion
|
||||
)
|
||||
|
||||
# Stability penalty (RRD only, when comparing to previous solution)
|
||||
if previous_solution is not None:
|
||||
stability = self._compute_stability(solution, previous_solution)
|
||||
cost += self.lambda_stability * stability
|
||||
|
||||
return cost
|
||||
|
||||
def compute_travel_time_cost(
|
||||
self, solution: "Solution", problem_ctx: "ProblemContext"
|
||||
) -> float:
|
||||
total = 0.0
|
||||
for route in solution.routes:
|
||||
if len(route.nodes) < 2:
|
||||
continue
|
||||
for idx in range(1, len(route.nodes)):
|
||||
i, j = route.nodes[idx - 1], route.nodes[idx]
|
||||
depart = self._get_departure_at(route.nodes, idx - 1, problem_ctx)
|
||||
total += problem_ctx.get_travel_time(i, j, depart)
|
||||
return total
|
||||
|
||||
def compute_lateness_penalty(
|
||||
self, solution: "Solution", problem_ctx: "ProblemContext"
|
||||
) -> float:
|
||||
total = 0.0
|
||||
for route in solution.routes:
|
||||
result = self.propagate_route(route.nodes, problem_ctx)
|
||||
total += result["total_delay"]
|
||||
return self.lambda_lateness * total
|
||||
|
||||
def compute_congestion_exposure(
|
||||
self, solution: "Solution", problem_ctx: "ProblemContext"
|
||||
) -> float:
|
||||
total = 0.0
|
||||
for route in solution.routes:
|
||||
result = self.propagate_route(route.nodes, problem_ctx)
|
||||
total += result["congestion_exposure"]
|
||||
return total
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Insertion cost (Eq.16-17)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def compute_insertion_cost(
|
||||
self,
|
||||
route: "Route",
|
||||
customer_id: int,
|
||||
position: int,
|
||||
problem_ctx: "ProblemContext",
|
||||
) -> float:
|
||||
"""Compute marginal cost of inserting a customer at a position.
|
||||
|
||||
Eq.16: Δf_ijk = t_ji(T_j) + s_i + t_ik(T_j + t_ji + s_i) - t_jk(T_j)
|
||||
+ λ₁·δ_i(T_i) + λ₂·ρ_ji(T_j)
|
||||
|
||||
This computes only the local change; a full suffix propagation
|
||||
(Eq.17) would recompute downstream times, which is done in
|
||||
compute_insertion_cost_full().
|
||||
"""
|
||||
nodes = route.nodes
|
||||
customers = problem_ctx.customers
|
||||
cust = customers[customer_id]
|
||||
|
||||
# Find predecessor (j) and successor (k) in the route
|
||||
# Position is 1-based among customers
|
||||
if position <= len(route.customers):
|
||||
# Insert between existing nodes
|
||||
insert_idx = position # position in nodes list
|
||||
j = nodes[insert_idx - 1]
|
||||
k = nodes[insert_idx]
|
||||
else:
|
||||
# Insert before returning to depot
|
||||
j = nodes[-2] # last customer before depot
|
||||
k = nodes[-1] # depot (0)
|
||||
|
||||
# Get departure time from j
|
||||
depart_j = self._get_departure_at(nodes, nodes.index(j), problem_ctx)
|
||||
|
||||
# Old cost: t_jk(T_j)
|
||||
old_cost = problem_ctx.get_travel_time(j, k, depart_j)
|
||||
|
||||
# New cost: t_ji(T_j) + s_i + t_ik(T_j + t_ji + s_i)
|
||||
t_ji = problem_ctx.get_travel_time(j, customer_id, depart_j)
|
||||
arrival_i = depart_j + t_ji
|
||||
service_start_i = max(arrival_i, cust.earliest_time_min)
|
||||
t_ik = problem_ctx.get_travel_time(
|
||||
customer_id, k, service_start_i + cust.service_time_min
|
||||
)
|
||||
travel_component = t_ji + cust.service_time_min + t_ik - old_cost
|
||||
|
||||
# Delay penalty at i (λ₁·δ_i)
|
||||
lateness_i = max(0.0, service_start_i - cust.latest_time_min)
|
||||
delay_penalty = self.lambda_lateness * lateness_i
|
||||
|
||||
# Congestion penalty for new arc (j,i) (λ₂·ρ_ji)
|
||||
congestion_penalty = self.lambda_congestion * problem_ctx.get_congestion_penalty(
|
||||
j, customer_id, depart_j
|
||||
)
|
||||
|
||||
return travel_component + delay_penalty + congestion_penalty
|
||||
|
||||
def compute_insertion_cost_full(
|
||||
self,
|
||||
route: "Route",
|
||||
customer_id: int,
|
||||
position: int,
|
||||
problem_ctx: "ProblemContext",
|
||||
) -> float:
|
||||
"""Full insertion cost with suffix propagation (Eq.17).
|
||||
|
||||
Evaluates the complete impact of insertion including all
|
||||
downstream time changes due to FIFO shift.
|
||||
"""
|
||||
# Build candidate route with customer inserted
|
||||
candidate_nodes = list(route.nodes)
|
||||
# position is 1-based among customers
|
||||
insert_at = position
|
||||
candidate_nodes.insert(insert_at, customer_id)
|
||||
|
||||
# Evaluate old route cost
|
||||
old_result = self.propagate_route(route.nodes, problem_ctx)
|
||||
old_cost = (
|
||||
old_result["total_travel_time"]
|
||||
+ self.lambda_lateness * old_result["total_delay"]
|
||||
+ self.lambda_congestion * old_result["congestion_exposure"]
|
||||
)
|
||||
|
||||
# Evaluate new route cost (full propagation)
|
||||
new_result = self.propagate_route(candidate_nodes, problem_ctx)
|
||||
new_cost = (
|
||||
new_result["total_travel_time"]
|
||||
+ self.lambda_lateness * new_result["total_delay"]
|
||||
+ self.lambda_congestion * new_result["congestion_exposure"]
|
||||
)
|
||||
|
||||
return new_cost - old_cost
|
||||
|
||||
def find_best_insertion(
|
||||
self,
|
||||
route: "Route",
|
||||
customer_id: int,
|
||||
problem_ctx: "ProblemContext",
|
||||
use_full: bool = True,
|
||||
) -> Tuple[int, float]:
|
||||
"""Find best position to insert a customer into a route.
|
||||
|
||||
Returns (best_position, best_cost).
|
||||
position is 1-based among customers (1 = first customer, n+1 = last).
|
||||
"""
|
||||
n_cust = len(route.customers)
|
||||
best_pos = 1
|
||||
best_cost = float("inf")
|
||||
cost_fn = self.compute_insertion_cost_full if use_full else self.compute_insertion_cost
|
||||
|
||||
for pos in range(1, n_cust + 2):
|
||||
cost = cost_fn(route, customer_id, pos, problem_ctx)
|
||||
if cost < best_cost:
|
||||
best_cost = cost
|
||||
best_pos = pos
|
||||
|
||||
return best_pos, best_cost
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stability and helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _compute_stability(
|
||||
self, new_sol: "Solution", old_sol: "Solution"
|
||||
) -> float:
|
||||
"""Compute route stability penalty (Eq.41).
|
||||
|
||||
Stability = Σ_k |R^a_k ∩ R^s_k| / |R^a_k ∪ R^s_k|
|
||||
Higher = more stable. We invert for penalty.
|
||||
"""
|
||||
stability = 0.0
|
||||
for k in range(min(new_sol.n_vehicles, old_sol.n_vehicles)):
|
||||
new_set = set(new_sol.routes[k].customers)
|
||||
old_set = set(old_sol.routes[k].customers)
|
||||
union = new_set | old_set
|
||||
inter = new_set & old_set
|
||||
if len(union) > 0:
|
||||
stability += len(inter) / len(union)
|
||||
# Penalize route changes (invert: more stable = lower penalty)
|
||||
return (new_sol.n_vehicles - stability) * 100.0
|
||||
|
||||
def _get_departure_at(
|
||||
self,
|
||||
nodes: List[int],
|
||||
idx: int,
|
||||
problem_ctx: "ProblemContext",
|
||||
start_time: float = 360.0,
|
||||
) -> float:
|
||||
"""Compute the departure time at a given position along a route."""
|
||||
result = self.propagate_route(nodes, problem_ctx, start_time)
|
||||
return result["departures"][idx]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Solution quality metrics
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def evaluate_solution(
|
||||
self,
|
||||
solution: "Solution",
|
||||
problem_ctx: "ProblemContext",
|
||||
include_penalties: bool = True,
|
||||
) -> dict:
|
||||
"""Comprehensive solution evaluation returning all metrics.
|
||||
|
||||
Args:
|
||||
include_penalties: If False, total_cost = pure travel time only
|
||||
(used for Static-VRPTW baseline which doesn't
|
||||
account for congestion or time-dependent costs).
|
||||
"""
|
||||
total_travel = 0.0
|
||||
total_delay = 0.0
|
||||
total_congestion = 0.0
|
||||
n_ontime = 0
|
||||
n_late = 0
|
||||
delays_list = []
|
||||
max_delay = 0.0
|
||||
avg_route_duration = 0.0
|
||||
|
||||
all_cust_ids = problem_ctx.customer_ids
|
||||
assigned = solution.get_assignments()
|
||||
|
||||
for route in solution.routes:
|
||||
if len(route.nodes) < 2:
|
||||
continue
|
||||
result = self.propagate_route(route.nodes, problem_ctx)
|
||||
|
||||
total_travel += result["total_travel_time"]
|
||||
total_delay += result["total_delay"]
|
||||
total_congestion += result["congestion_exposure"]
|
||||
avg_route_duration += (
|
||||
result["departures"][-1] - result["departures"][0]
|
||||
)
|
||||
|
||||
# Count on-time deliveries
|
||||
for idx, node in enumerate(route.nodes):
|
||||
if node == 0:
|
||||
continue
|
||||
delay = result["delays"][idx]
|
||||
if delay <= 0:
|
||||
n_ontime += 1
|
||||
else:
|
||||
n_late += 1
|
||||
delays_list.append(delay)
|
||||
max_delay = max(max_delay, delay)
|
||||
|
||||
n_total = len(all_cust_ids)
|
||||
otdr = n_ontime / n_total if n_total > 0 else 0.0
|
||||
avg_delay = np.mean(delays_list) if delays_list else 0.0
|
||||
avg_route_dur = (
|
||||
avg_route_duration / solution.n_vehicles if solution.n_vehicles > 0 else 0.0
|
||||
)
|
||||
|
||||
if include_penalties:
|
||||
total_cost = (
|
||||
total_travel
|
||||
+ self.lambda_lateness * total_delay
|
||||
+ self.lambda_congestion * total_congestion
|
||||
)
|
||||
else:
|
||||
total_cost = total_travel
|
||||
|
||||
return {
|
||||
"total_cost": total_cost,
|
||||
"travel_time_cost": total_travel,
|
||||
"delay_penalty": self.lambda_lateness * total_delay,
|
||||
"congestion_cost": total_congestion,
|
||||
"otdr": otdr,
|
||||
"avg_delay": avg_delay,
|
||||
"max_delay": max_delay,
|
||||
"late_customers": n_late,
|
||||
"ces": total_congestion,
|
||||
"avg_route_duration": avg_route_dur,
|
||||
"n_assigned": len(assigned),
|
||||
"n_total": n_total,
|
||||
}
|
||||
Reference in New Issue
Block a user