Files
Enterprise/t_alns_rrd_reproduction/src/cost.py
2026-06-02 21:58:34 +08:00

432 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
arc_travel_time = 0.0
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). Eq.1's travel term is the sum of arc
# traversal times only; waiting and service duration are temporal
# propagation state, not travel cost.
travel_time = problem_ctx.get_travel_time(i, j, departures[idx - 1])
arc_travel_time += travel_time
arrivals[idx] = departures[idx - 1] + travel_time
# 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": arc_travel_time,
"route_duration": departures[-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["route_duration"]
# 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,
}