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:
huangfu
2026-06-02 21:11:00 +08:00
commit 8ff9715fb7
45 changed files with 9293 additions and 0 deletions

View 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,
}