fix T-ALNS-RRD reproduction fidelity
This commit is contained in:
43
PROGRESS.md
Normal file
43
PROGRESS.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# PROGRESS
|
||||||
|
|
||||||
|
## 2026-06-02 T-ALNS-RRD reproduction fidelity fix
|
||||||
|
|
||||||
|
### Findings addressed
|
||||||
|
- The route travel component previously counted route elapsed time, which included waiting and service duration, instead of only `sum t_ij(T_i)` from the paper objective.
|
||||||
|
- RRD event generation and rollout used unseeded RNGs, so the same algorithm seed could produce different event streams and dispatch outcomes.
|
||||||
|
- Traffic incidents mutated the shared traffic tensor during detection, which could leak into later evaluations when an event was not dispatched.
|
||||||
|
- Rollout Tabu adjustment used the wrong sign for a cost-minimizing score: Tabu actions were rewarded and non-Tabu actions were penalized.
|
||||||
|
- Urgent-order actions could insert a negative placeholder node into fixed-size traffic tensors, causing invalid route evaluation.
|
||||||
|
- T-ALNS traffic aspiration did not compare against the current solution, and frequency memory did not use congestion-weighted updates from Eq.34.
|
||||||
|
|
||||||
|
### Changes made
|
||||||
|
- Corrected Eq.1 cost accounting so travel cost is only arc traversal time; waiting and service remain part of time propagation.
|
||||||
|
- Documented hold-last-value traffic bucket behavior for customer windows after the paper's 6:00-18:00 traffic horizon.
|
||||||
|
- Restored congestion penalty generation to Eq.9 semantics: `rho = theta * gamma`.
|
||||||
|
- Threaded deterministic seeds through `EventGenerator`, `RolloutEngine`, and `Dispatch`.
|
||||||
|
- Made traffic incident severity temporary during dispatch evaluation and restored the base tensor with `try/finally`.
|
||||||
|
- Corrected rollout Tabu penalty/bonus direction for a cost-minimizing dispatch value.
|
||||||
|
- Reworked unsupported urgent-order insertion into fixed-graph penalty actions instead of invalid synthetic nodes.
|
||||||
|
- Added congestion-weighted frequency memory updates and current-solution traffic aspiration checks.
|
||||||
|
- Added `configs/paper.yaml` as the canonical paper-aligned server experiment config.
|
||||||
|
|
||||||
|
### Local validation
|
||||||
|
- Per user instruction, no local Python tests, smoke tests, or full experiments were run in this round.
|
||||||
|
- Static review only: inspect source changes, config shape, and git diff before commit.
|
||||||
|
|
||||||
|
### Server run command
|
||||||
|
```bash
|
||||||
|
cd t_alns_rrd_reproduction
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python src/experiments/run_main_comparison.py --config paper --seeds 30 --iterations 1000 --time-limit 600 --output results/paper_fixed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected server outputs
|
||||||
|
- `results/paper_fixed/tables/main_comparison.csv`
|
||||||
|
- `results/paper_fixed/tables/per_seed_costs.csv`
|
||||||
|
- `results/paper_fixed/tables/statistical_tests.csv`
|
||||||
|
- `results/paper_fixed/logs/convergence.npz`
|
||||||
|
|
||||||
|
### Interpretation rule
|
||||||
|
- Check whether Static > TA-Greedy > ALNS-Base > T-ALNS > T-ALNS-RRD in total cost, CES decreases, and OTDR improves.
|
||||||
|
- If the paper trend is not reproduced, keep the generated tables and record the failed metrics honestly instead of tuning results by hand.
|
||||||
137
t_alns_rrd_reproduction/configs/paper.yaml
Normal file
137
t_alns_rrd_reproduction/configs/paper.yaml
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Canonical paper-aligned configuration for T-ALNS-RRD reproduction.
|
||||||
|
# This is the primary server-run config. It preserves the paper's controlled
|
||||||
|
# mid-scale instance: 47 customers, 4 homogeneous vehicles, 120 kg capacity,
|
||||||
|
# 12 one-hour traffic intervals from 6:00 to 18:00, and 30 seeds.
|
||||||
|
|
||||||
|
problem:
|
||||||
|
n_customers: 47
|
||||||
|
n_vehicles: 4
|
||||||
|
depot_count: 1
|
||||||
|
vehicle_capacity_kg: 120
|
||||||
|
service_time_min: 4
|
||||||
|
area_width_km: 8.0
|
||||||
|
area_height_km: 10.0
|
||||||
|
operating_start: 360
|
||||||
|
operating_end: 1080
|
||||||
|
n_time_intervals: 12
|
||||||
|
|
||||||
|
customers:
|
||||||
|
demand_min_kg: 3
|
||||||
|
demand_max_kg: 12
|
||||||
|
window_length_min: 60
|
||||||
|
window_length_max: 150
|
||||||
|
time_window_categories:
|
||||||
|
morning:
|
||||||
|
earliest: 540
|
||||||
|
latest: 720
|
||||||
|
afternoon:
|
||||||
|
earliest: 780
|
||||||
|
latest: 960
|
||||||
|
evening:
|
||||||
|
earliest: 1020
|
||||||
|
latest: 1200
|
||||||
|
num_clusters: 3
|
||||||
|
cluster_labels: ["residential", "commercial", "office"]
|
||||||
|
|
||||||
|
roads:
|
||||||
|
types:
|
||||||
|
arterial:
|
||||||
|
speed_kmh: 45
|
||||||
|
proportion: 0.25
|
||||||
|
collector:
|
||||||
|
speed_kmh: 30
|
||||||
|
proportion: 0.35
|
||||||
|
residential:
|
||||||
|
speed_kmh: 20
|
||||||
|
proportion: 0.40
|
||||||
|
noise_std: 0.05
|
||||||
|
use_complete_graph: true
|
||||||
|
|
||||||
|
traffic:
|
||||||
|
multipliers: [1.0, 1.0, 1.6, 1.6, 1.2, 1.2, 1.0, 1.0, 1.2, 1.2, 1.7, 1.7]
|
||||||
|
congestion_scale_theta: 50.0
|
||||||
|
risk_aversion_beta: 0.3
|
||||||
|
uncertainty_base: 0.05
|
||||||
|
|
||||||
|
cost:
|
||||||
|
lambda_lateness: 1.0
|
||||||
|
lambda_congestion: 1.0
|
||||||
|
lambda_stability: 0.3
|
||||||
|
|
||||||
|
alns:
|
||||||
|
max_iterations: 1000
|
||||||
|
time_limit_sec: 600
|
||||||
|
destroy_ratio_min: 0.1
|
||||||
|
destroy_ratio_max: 0.4
|
||||||
|
initial_temperature_factor: 0.05
|
||||||
|
cooling_rate: 0.99975
|
||||||
|
reaction_factor: 0.1
|
||||||
|
segment_length: 100
|
||||||
|
stall_limit: 200
|
||||||
|
max_attempts: 5
|
||||||
|
reward_global_best: 1.0
|
||||||
|
reward_improvement: 0.5
|
||||||
|
reward_accepted: 0.2
|
||||||
|
reward_rejected: 0.0
|
||||||
|
|
||||||
|
tabu:
|
||||||
|
move_tabu:
|
||||||
|
tenure: 7
|
||||||
|
tenure_min: 3
|
||||||
|
tenure_max: 12
|
||||||
|
overlap_threshold: 0.5
|
||||||
|
stall_for_increase: 50
|
||||||
|
solution_tabu:
|
||||||
|
tenure: 15
|
||||||
|
buffer_size: 1000
|
||||||
|
hash_prime: 1000000007
|
||||||
|
frequency:
|
||||||
|
normalization_factor: 2
|
||||||
|
normalization_interval: 50
|
||||||
|
diversification:
|
||||||
|
delta_max: 0.7
|
||||||
|
eta_balance: 0.5
|
||||||
|
weights: [0.4, 0.3, 0.3]
|
||||||
|
aspiration:
|
||||||
|
beta_threshold: 0.3
|
||||||
|
gamma_threshold: 0.8
|
||||||
|
|
||||||
|
rrd:
|
||||||
|
rollout:
|
||||||
|
horizon_min_min: 30
|
||||||
|
horizon_max_min: 120
|
||||||
|
urgency_alpha: 1.0
|
||||||
|
n_sim_min: 2
|
||||||
|
n_sim_max: 50
|
||||||
|
mc_iterations: 50
|
||||||
|
time_overhead_ms: 10
|
||||||
|
time_per_sim_ms: 50
|
||||||
|
dispatch:
|
||||||
|
weight_rollout: 0.4
|
||||||
|
weight_stability: 0.3
|
||||||
|
weight_recovery: 0.3
|
||||||
|
tabu:
|
||||||
|
penalty: 50.0
|
||||||
|
bonus: 25.0
|
||||||
|
events:
|
||||||
|
urgency_threshold: 0.5
|
||||||
|
event_probability: 0.3
|
||||||
|
event_check_interval: 10
|
||||||
|
weights:
|
||||||
|
E1_traffic: [0.5, 0.3, 0.2]
|
||||||
|
E2_urgent: [0.7, 0.1, 0.2]
|
||||||
|
E3_capacity: [0.3, 0.4, 0.3]
|
||||||
|
E4_timewindow: [0.8, 0.1, 0.1]
|
||||||
|
max_actions: 20
|
||||||
|
|
||||||
|
experiments:
|
||||||
|
random_seeds: 30
|
||||||
|
seed_start: 1
|
||||||
|
report_mean_std: true
|
||||||
|
statistical_testing: true
|
||||||
|
sensitivity:
|
||||||
|
fleet_sizes: [2, 3, 4, 5, 6]
|
||||||
|
customer_counts: [30, 40, 47, 60]
|
||||||
|
capacities: [80, 100, 120, 140, 160]
|
||||||
|
robustness:
|
||||||
|
sigma_values: [0.1, 0.2, 0.3, 0.5]
|
||||||
@@ -50,6 +50,7 @@ class CostCalculator:
|
|||||||
departures = [0.0] * n
|
departures = [0.0] * n
|
||||||
delays = [0.0] * n
|
delays = [0.0] * n
|
||||||
waits = [0.0] * n
|
waits = [0.0] * n
|
||||||
|
arc_travel_time = 0.0
|
||||||
congestion_exposure = 0.0
|
congestion_exposure = 0.0
|
||||||
|
|
||||||
# First node (depot)
|
# First node (depot)
|
||||||
@@ -61,10 +62,12 @@ class CostCalculator:
|
|||||||
i = route_nodes[idx - 1]
|
i = route_nodes[idx - 1]
|
||||||
j = route_nodes[idx]
|
j = route_nodes[idx]
|
||||||
|
|
||||||
# Arrival at j (Eq.5)
|
# Arrival at j (Eq.5). Eq.1's travel term is the sum of arc
|
||||||
arrivals[idx] = departures[idx - 1] + problem_ctx.get_travel_time(
|
# traversal times only; waiting and service duration are temporal
|
||||||
i, j, departures[idx - 1]
|
# 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
|
# Accumulate congestion exposure
|
||||||
congestion_exposure += problem_ctx.get_congestion_penalty(
|
congestion_exposure += problem_ctx.get_congestion_penalty(
|
||||||
@@ -103,7 +106,8 @@ class CostCalculator:
|
|||||||
"departures": departures,
|
"departures": departures,
|
||||||
"delays": delays,
|
"delays": delays,
|
||||||
"waits": waits,
|
"waits": waits,
|
||||||
"total_travel_time": arrivals[-1] - depot_start_time,
|
"total_travel_time": arc_travel_time,
|
||||||
|
"route_duration": departures[-1] - depot_start_time,
|
||||||
"total_delay": sum(delays),
|
"total_delay": sum(delays),
|
||||||
"total_wait": sum(waits),
|
"total_wait": sum(waits),
|
||||||
"congestion_exposure": congestion_exposure,
|
"congestion_exposure": congestion_exposure,
|
||||||
@@ -381,9 +385,7 @@ class CostCalculator:
|
|||||||
total_travel += result["total_travel_time"]
|
total_travel += result["total_travel_time"]
|
||||||
total_delay += result["total_delay"]
|
total_delay += result["total_delay"]
|
||||||
total_congestion += result["congestion_exposure"]
|
total_congestion += result["congestion_exposure"]
|
||||||
avg_route_duration += (
|
avg_route_duration += result["route_duration"]
|
||||||
result["departures"][-1] - result["departures"][0]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Count on-time deliveries
|
# Count on-time deliveries
|
||||||
for idx, node in enumerate(route.nodes):
|
for idx, node in enumerate(route.nodes):
|
||||||
|
|||||||
@@ -324,10 +324,8 @@ class DataGenerator:
|
|||||||
# Uncertainty margin η
|
# Uncertainty margin η
|
||||||
eta = tt * self.uncertainty_base * self.traffic_multipliers[h]
|
eta = tt * self.uncertainty_base * self.traffic_multipliers[h]
|
||||||
|
|
||||||
# Congestion penalty ρ (Eq.9): extra time caused by congestion
|
# Congestion penalty ρ (Eq.9): θ × normalized density γ.
|
||||||
# ρ = θ × extra_time × γ, where extra_time = base_time × (multiplier - 1)
|
rho = self.congestion_scale * gamma
|
||||||
extra_time = base_time * max(0, self.traffic_multipliers[h] - 1.0)
|
|
||||||
rho = self.congestion_scale * extra_time * gamma
|
|
||||||
|
|
||||||
travel_time[i, j, h] = round(tt, 4)
|
travel_time[i, j, h] = round(tt, 4)
|
||||||
congestion[i, j, h] = round(gamma, 4)
|
congestion[i, j, h] = round(gamma, 4)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Main comparison experiment (v2).
|
Main comparison experiment.
|
||||||
|
|
||||||
Runs all 5 algorithms with configurable settings.
|
Runs all 5 algorithms with configurable settings.
|
||||||
Supports --config flag for version switching.
|
Supports --config flag for version switching.
|
||||||
@@ -35,8 +35,78 @@ except ImportError:
|
|||||||
HAS_SCIPY = False
|
HAS_SCIPY = False
|
||||||
|
|
||||||
|
|
||||||
|
def build_algorithm_config(cfg: dict, max_iterations: int, time_limit_sec: int) -> dict:
|
||||||
|
"""Flatten YAML sections into solver constructor config keys."""
|
||||||
|
alns = cfg.get("alns", {})
|
||||||
|
tabu = cfg.get("tabu", {})
|
||||||
|
rrd = cfg.get("rrd", {})
|
||||||
|
rollout = rrd.get("rollout", {})
|
||||||
|
dispatch = rrd.get("dispatch", {})
|
||||||
|
rrd_tabu = rrd.get("tabu", {})
|
||||||
|
events = rrd.get("events", {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"max_iterations": max_iterations,
|
||||||
|
"time_limit_sec": time_limit_sec,
|
||||||
|
"destroy_ratio_min": alns.get("destroy_ratio_min", 0.1),
|
||||||
|
"destroy_ratio_max": alns.get("destroy_ratio_max", 0.4),
|
||||||
|
"initial_temperature_factor": alns.get("initial_temperature_factor", 0.05),
|
||||||
|
"cooling_rate": alns.get("cooling_rate", 0.99975),
|
||||||
|
"reaction_factor": alns.get("reaction_factor", 0.1),
|
||||||
|
"segment_length": alns.get("segment_length", 100),
|
||||||
|
"stall_limit": alns.get("stall_limit", 200),
|
||||||
|
"max_attempts": alns.get("max_attempts", 5),
|
||||||
|
"reward_global_best": alns.get("reward_global_best", 1.0),
|
||||||
|
"reward_improvement": alns.get("reward_improvement", 0.5),
|
||||||
|
"reward_accepted": alns.get("reward_accepted", 0.2),
|
||||||
|
"reward_rejected": alns.get("reward_rejected", 0.0),
|
||||||
|
"move_tabu_tenure": tabu.get("move_tabu", {}).get("tenure", 7),
|
||||||
|
"move_tabu_tenure_min": tabu.get("move_tabu", {}).get("tenure_min", 3),
|
||||||
|
"move_tabu_tenure_max": tabu.get("move_tabu", {}).get("tenure_max", 12),
|
||||||
|
"move_tabu_overlap_threshold": tabu.get("move_tabu", {}).get(
|
||||||
|
"overlap_threshold", 0.5
|
||||||
|
),
|
||||||
|
"move_tabu_stall_for_increase": tabu.get("move_tabu", {}).get(
|
||||||
|
"stall_for_increase", 50
|
||||||
|
),
|
||||||
|
"solution_tabu_tenure": tabu.get("solution_tabu", {}).get("tenure", 15),
|
||||||
|
"solution_tabu_buffer": tabu.get("solution_tabu", {}).get("buffer_size", 1000),
|
||||||
|
"solution_tabu_prime": tabu.get("solution_tabu", {}).get(
|
||||||
|
"hash_prime", 1000000007
|
||||||
|
),
|
||||||
|
"freq_norm_factor": tabu.get("frequency", {}).get("normalization_factor", 2.0),
|
||||||
|
"freq_norm_interval": tabu.get("frequency", {}).get(
|
||||||
|
"normalization_interval", 50
|
||||||
|
),
|
||||||
|
"diversification_delta_max": tabu.get("diversification", {}).get(
|
||||||
|
"delta_max", 0.7
|
||||||
|
),
|
||||||
|
"diversification_eta": tabu.get("diversification", {}).get(
|
||||||
|
"eta_balance", 0.5
|
||||||
|
),
|
||||||
|
"diversification_weights": tabu.get("diversification", {}).get(
|
||||||
|
"weights", [0.4, 0.3, 0.3]
|
||||||
|
),
|
||||||
|
"aspiration_beta": tabu.get("aspiration", {}).get("beta_threshold", 0.3),
|
||||||
|
"aspiration_gamma": tabu.get("aspiration", {}).get("gamma_threshold", 0.8),
|
||||||
|
"rollout_horizon_min": rollout.get("horizon_min_min", 30),
|
||||||
|
"rollout_horizon_max": rollout.get("horizon_max_min", 120),
|
||||||
|
"rollout_n_sim_min": rollout.get("n_sim_min", 2),
|
||||||
|
"rollout_n_sim_max": rollout.get("n_sim_max", 50),
|
||||||
|
"rollout_mc_iterations": rollout.get("mc_iterations", 50),
|
||||||
|
"dispatch_weight_rollout": dispatch.get("weight_rollout", 0.4),
|
||||||
|
"dispatch_weight_stability": dispatch.get("weight_stability", 0.3),
|
||||||
|
"dispatch_weight_recovery": dispatch.get("weight_recovery", 0.3),
|
||||||
|
"event_urgency_threshold": events.get("urgency_threshold", 0.5),
|
||||||
|
"event_probability": events.get("event_probability", 0.3),
|
||||||
|
"event_check_interval": events.get("event_check_interval", 10),
|
||||||
|
"rrd_tabu_penalty": rrd_tabu.get("penalty", 50.0),
|
||||||
|
"rrd_tabu_bonus": rrd_tabu.get("bonus", 25.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_experiment(
|
def run_experiment(
|
||||||
config_name="calibrated",
|
config_name="paper",
|
||||||
n_seeds=None,
|
n_seeds=None,
|
||||||
max_iterations=None,
|
max_iterations=None,
|
||||||
time_limit_sec=None,
|
time_limit_sec=None,
|
||||||
@@ -66,10 +136,14 @@ def run_experiment(
|
|||||||
n_seeds = n_seeds or cfg.get("experiments", {}).get("random_seeds", 10)
|
n_seeds = n_seeds or cfg.get("experiments", {}).get("random_seeds", 10)
|
||||||
max_iterations = max_iterations or cfg.get("alns", {}).get("max_iterations", 500)
|
max_iterations = max_iterations or cfg.get("alns", {}).get("max_iterations", 500)
|
||||||
time_limit_sec = time_limit_sec or cfg.get("alns", {}).get("time_limit_sec", 300)
|
time_limit_sec = time_limit_sec or cfg.get("alns", {}).get("time_limit_sec", 300)
|
||||||
|
seed_start = cfg.get("experiments", {}).get("seed_start", 0)
|
||||||
|
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
print(f"T-ALNS-RRD Main Comparison [{config_name}]")
|
print(f"T-ALNS-RRD Main Comparison [{config_name}]")
|
||||||
print(f"Seeds: {n_seeds}, Iter: {max_iterations}, Time: {time_limit_sec}s")
|
print(
|
||||||
|
f"Seeds: {seed_start}..{seed_start + n_seeds - 1}, "
|
||||||
|
f"Iter: {max_iterations}, Time: {time_limit_sec}s"
|
||||||
|
)
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
|
|
||||||
print("\n[1/5] Generating dataset...")
|
print("\n[1/5] Generating dataset...")
|
||||||
@@ -80,6 +154,9 @@ def run_experiment(
|
|||||||
customers={c.customer_id: c for c in data["customers"]},
|
customers={c.customer_id: c for c in data["customers"]},
|
||||||
depot=data["depot"], traffic=data["traffic"],
|
depot=data["depot"], traffic=data["traffic"],
|
||||||
n_vehicles=data["n_vehicles"], vehicle_capacity=data["vehicle_capacity"],
|
n_vehicles=data["n_vehicles"], vehicle_capacity=data["vehicle_capacity"],
|
||||||
|
op_start=cfg["problem"]["operating_start"],
|
||||||
|
op_end=cfg["problem"]["operating_end"],
|
||||||
|
n_intervals=cfg["problem"]["n_time_intervals"],
|
||||||
)
|
)
|
||||||
cost_calc = CostCalculator(
|
cost_calc = CostCalculator(
|
||||||
lambda_lateness=cfg["cost"]["lambda_lateness"],
|
lambda_lateness=cfg["cost"]["lambda_lateness"],
|
||||||
@@ -87,15 +164,7 @@ def run_experiment(
|
|||||||
lambda_stability=cfg["cost"]["lambda_stability"],
|
lambda_stability=cfg["cost"]["lambda_stability"],
|
||||||
)
|
)
|
||||||
|
|
||||||
rrd_cfg = cfg.get("rrd", {})
|
alg_cfg = build_algorithm_config(cfg, max_iterations, time_limit_sec)
|
||||||
alg_cfg = {
|
|
||||||
"max_iterations": max_iterations,
|
|
||||||
"time_limit_sec": time_limit_sec,
|
|
||||||
"event_probability": rrd_cfg.get("events", {}).get("event_probability", 0.3),
|
|
||||||
"event_check_interval": rrd_cfg.get("events", {}).get("event_check_interval", 10),
|
|
||||||
"reward_global_best": 1.0, "reward_improvement": 0.5,
|
|
||||||
"reward_accepted": 0.2, "reward_rejected": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
algorithms = [
|
algorithms = [
|
||||||
("Static-VRPTW", StaticVRPTWSolver, {}),
|
("Static-VRPTW", StaticVRPTWSolver, {}),
|
||||||
@@ -114,7 +183,7 @@ def run_experiment(
|
|||||||
alg_results = []
|
alg_results = []
|
||||||
seed_costs = []
|
seed_costs = []
|
||||||
|
|
||||||
for seed in tqdm(range(n_seeds)):
|
for seed in tqdm(range(seed_start, seed_start + n_seeds)):
|
||||||
if alg_name in ("Static-VRPTW", "TA-VRPTW-Greedy"):
|
if alg_name in ("Static-VRPTW", "TA-VRPTW-Greedy"):
|
||||||
solver = solver_cls(ctx, cost_calc)
|
solver = solver_cls(ctx, cost_calc)
|
||||||
else:
|
else:
|
||||||
@@ -190,7 +259,7 @@ def run_experiment(
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--config", default="calibrated")
|
parser.add_argument("--config", default="paper")
|
||||||
parser.add_argument("--seeds", type=int, default=None)
|
parser.add_argument("--seeds", type=int, default=None)
|
||||||
parser.add_argument("--iterations", type=int, default=None)
|
parser.add_argument("--iterations", type=int, default=None)
|
||||||
parser.add_argument("--time-limit", type=int, default=None)
|
parser.add_argument("--time-limit", type=int, default=None)
|
||||||
|
|||||||
@@ -147,7 +147,12 @@ class ProblemContext:
|
|||||||
return (self.op_end - self.op_start) / self.n_intervals
|
return (self.op_end - self.op_start) / self.n_intervals
|
||||||
|
|
||||||
def time_to_interval(self, minutes: float) -> int:
|
def time_to_interval(self, minutes: float) -> int:
|
||||||
"""Map time in minutes to interval index h ∈ [0, n_intervals-1]."""
|
"""Map time to interval h.
|
||||||
|
|
||||||
|
The paper discretizes traffic feeds for 6:00-18:00. Customer windows may
|
||||||
|
extend to 20:00, so departures after the final traffic bucket use
|
||||||
|
hold-last-value semantics instead of extrapolating unobserved traffic.
|
||||||
|
"""
|
||||||
minutes = max(self.op_start, min(minutes, self.op_end - 1))
|
minutes = max(self.op_start, min(minutes, self.op_end - 1))
|
||||||
return int((minutes - self.op_start) / self.interval_duration)
|
return int((minutes - self.op_start) / self.interval_duration)
|
||||||
|
|
||||||
|
|||||||
@@ -88,26 +88,16 @@ def generate_actions_E2_urgent(
|
|||||||
new_demand = extra.get("demand_kg", 5.0)
|
new_demand = extra.get("demand_kg", 5.0)
|
||||||
deadline = extra.get("deadline", 720.0)
|
deadline = extra.get("deadline", 720.0)
|
||||||
|
|
||||||
for k, route in enumerate(solution.routes):
|
# The fixed 47-node benchmark has no live road matrix for new customer
|
||||||
if route.total_demand(problem_ctx.customers) + new_demand > problem_ctx.vehicle_capacity:
|
# coordinates. Keep E2 feasible by representing immediate handling as
|
||||||
continue
|
# subcontracting or customer notification penalties instead of inserting
|
||||||
|
# invalid synthetic node IDs into the fixed tensor.
|
||||||
# Immediate insertion (find best position)
|
delay = max(0.0, deadline - event.time_min)
|
||||||
# We approximate: try inserting at each position
|
actions.append({
|
||||||
n_cust_pos = len(route.customers)
|
"type": "urgent_defer",
|
||||||
for pos in range(1, n_cust_pos + 2):
|
"penalty_cost": 120.0 + max(0.0, 60.0 - delay),
|
||||||
actions.append({
|
"description": "Delay urgent delivery with customer notification",
|
||||||
"type": "urgent_insert",
|
})
|
||||||
"target_vehicle": k,
|
|
||||||
"position": pos,
|
|
||||||
"demand_kg": new_demand,
|
|
||||||
"deadline": deadline,
|
|
||||||
"delayed": False,
|
|
||||||
"description": f"Insert urgent delivery into vehicle {k} pos {pos}",
|
|
||||||
})
|
|
||||||
break # Just try one position per vehicle for speed
|
|
||||||
if len(actions) >= 10:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Subcontract option (penalty-based)
|
# Subcontract option (penalty-based)
|
||||||
actions.append({
|
actions.append({
|
||||||
@@ -204,6 +194,7 @@ def generate_actions_E4_timewindow(
|
|||||||
"type": "temporary_tolerance",
|
"type": "temporary_tolerance",
|
||||||
"customer": affected,
|
"customer": affected,
|
||||||
"tolerance_min": 30.0,
|
"tolerance_min": 30.0,
|
||||||
|
"penalty_cost": 30.0,
|
||||||
"description": f"Grant 30min tolerance for customer {affected}",
|
"description": f"Grant 30min tolerance for customer {affected}",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,16 @@ class Dispatch:
|
|||||||
self.w_rollout = weight_rollout # ω₁
|
self.w_rollout = weight_rollout # ω₁
|
||||||
self.w_stability = weight_stability # ω₂
|
self.w_stability = weight_stability # ω₂
|
||||||
self.w_recovery = weight_recovery # ω₃
|
self.w_recovery = weight_recovery # ω₃
|
||||||
|
self._seed = seed
|
||||||
self.rng = np.random.default_rng(seed)
|
self.rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
self.dispatch_log: List[dict] = []
|
self.dispatch_log: List[dict] = []
|
||||||
|
|
||||||
|
def reset(self, seed: int = None):
|
||||||
|
"""Reset dispatch RNG and per-run log for deterministic experiments."""
|
||||||
|
self.rng = np.random.default_rng(self._seed if seed is None else seed)
|
||||||
|
self.dispatch_log = []
|
||||||
|
|
||||||
def compute_stability(self, action: dict, current_solution: Solution) -> float:
|
def compute_stability(self, action: dict, current_solution: Solution) -> float:
|
||||||
"""Compute route stability score (Eq.41).
|
"""Compute route stability score (Eq.41).
|
||||||
|
|
||||||
@@ -186,6 +192,7 @@ class Dispatch:
|
|||||||
"composite_score": best["composite_score"],
|
"composite_score": best["composite_score"],
|
||||||
"response_time_ms": elapsed_ms,
|
"response_time_ms": elapsed_ms,
|
||||||
"n_actions_evaluated": len(actions),
|
"n_actions_evaluated": len(actions),
|
||||||
|
"description": best["action"].get("description", ""),
|
||||||
}
|
}
|
||||||
self.dispatch_log.append(log_entry)
|
self.dispatch_log.append(log_entry)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class EventGenerator:
|
|||||||
cost_calc: "CostCalculator",
|
cost_calc: "CostCalculator",
|
||||||
urgency_threshold: float = 0.5,
|
urgency_threshold: float = 0.5,
|
||||||
event_weights: dict = None,
|
event_weights: dict = None,
|
||||||
|
seed: int = None,
|
||||||
):
|
):
|
||||||
self.ctx = problem_ctx
|
self.ctx = problem_ctx
|
||||||
self.cost_calc = cost_calc
|
self.cost_calc = cost_calc
|
||||||
@@ -57,7 +58,8 @@ class EventGenerator:
|
|||||||
EventType.E4_TIMEWINDOW: (0.8, 0.1, 0.1),
|
EventType.E4_TIMEWINDOW: (0.8, 0.1, 0.1),
|
||||||
}
|
}
|
||||||
|
|
||||||
self.rng = np.random.default_rng(None)
|
self._seed = seed
|
||||||
|
self.rng = np.random.default_rng(seed)
|
||||||
self.event_log: List[Event] = []
|
self.event_log: List[Event] = []
|
||||||
|
|
||||||
def generate_traffic_incident(
|
def generate_traffic_incident(
|
||||||
@@ -116,16 +118,14 @@ class EventGenerator:
|
|||||||
description=f"Traffic incident on arc {arc} affecting customer {affected_customer}",
|
description=f"Traffic incident on arc {arc} affecting customer {affected_customer}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Actually modify travel time tensor to simulate congestion
|
# Store incident severity for rollout evaluation. The live traffic tensor
|
||||||
# Use moderate severity and auto-restore after dispatch
|
# is modified only while a selected dispatch action is being evaluated,
|
||||||
|
# then restored by TALNSRRD.
|
||||||
i, j = arc
|
i, j = arc
|
||||||
if i >= 0 and j >= 0:
|
if i >= 0 and j >= 0:
|
||||||
severity = 1.5 + self.rng.uniform(0, 0.5) # 1.5x-2.0x (moderate)
|
severity = 1.5 + self.rng.uniform(0, 0.5) # 1.5x-2.0x (moderate)
|
||||||
event._travel_backup_ij = self.ctx.traffic.travel_time[i, j, :].copy()
|
|
||||||
event._congestion_backup_ij = self.ctx.traffic.congestion_penalty[i, j, :].copy()
|
|
||||||
event._modified_arc = (i, j)
|
event._modified_arc = (i, j)
|
||||||
self.ctx.traffic.travel_time[i, j, :] *= severity
|
event._severity = severity
|
||||||
self.ctx.traffic.congestion_penalty[i, j, :] *= severity
|
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
@@ -310,7 +310,7 @@ class EventGenerator:
|
|||||||
self.event_log.extend(events)
|
self.event_log.extend(events)
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def reset(self):
|
def reset(self, seed: int = None):
|
||||||
"""Reset event log for a new run."""
|
"""Reset event log for a new run."""
|
||||||
self.event_log = []
|
self.event_log = []
|
||||||
self.rng = np.random.default_rng(None)
|
self.rng = np.random.default_rng(self._seed if seed is None else seed)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ Rollout horizon H: 30-120 minutes
|
|||||||
Monte Carlo iterations: 2-50
|
Monte Carlo iterations: 2-50
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
@@ -45,8 +44,13 @@ class RolloutEngine:
|
|||||||
self.mc_iterations = mc_iterations
|
self.mc_iterations = mc_iterations
|
||||||
self.tabu_penalty = tabu_penalty
|
self.tabu_penalty = tabu_penalty
|
||||||
self.tabu_bonus = tabu_bonus
|
self.tabu_bonus = tabu_bonus
|
||||||
|
self._seed = seed
|
||||||
self.rng = np.random.default_rng(seed)
|
self.rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
|
def reset(self, seed: int = None):
|
||||||
|
"""Reset stochastic rollout sampling for deterministic seeded runs."""
|
||||||
|
self.rng = np.random.default_rng(self._seed if seed is None else seed)
|
||||||
|
|
||||||
def adapt_horizon(self, urgency: float) -> int:
|
def adapt_horizon(self, urgency: float) -> int:
|
||||||
"""Adapt rollout horizon based on event urgency (Eq.43).
|
"""Adapt rollout horizon based on event urgency (Eq.43).
|
||||||
|
|
||||||
@@ -101,37 +105,60 @@ class RolloutEngine:
|
|||||||
|
|
||||||
Returns the total cost over the simulated horizon.
|
Returns the total cost over the simulated horizon.
|
||||||
"""
|
"""
|
||||||
import copy
|
|
||||||
sim_sol = solution.copy()
|
sim_sol = solution.copy()
|
||||||
sim_sol = self._apply_action(sim_sol, action)
|
sim_sol = self._apply_action(sim_sol, action)
|
||||||
|
|
||||||
total_cost = 0.0
|
total_cost = float(action.get("penalty_cost", 0.0))
|
||||||
current_time = self.ctx.op_start
|
|
||||||
|
|
||||||
# Simple forward simulation: evaluate each route with noise
|
# Simple forward simulation: evaluate each route with noise
|
||||||
for route in sim_sol.routes:
|
for route in sim_sol.routes:
|
||||||
if len(route.nodes) < 2:
|
if len(route.nodes) < 2:
|
||||||
continue
|
continue
|
||||||
|
current_time = self.ctx.op_start
|
||||||
nodes = route.nodes
|
nodes = route.nodes
|
||||||
for idx in range(1, len(nodes)):
|
for idx in range(1, len(nodes)):
|
||||||
i, j = nodes[idx - 1], nodes[idx]
|
i, j = nodes[idx - 1], nodes[idx]
|
||||||
# Skip negative/invalid node IDs
|
# Skip negative/invalid node IDs
|
||||||
if i < 0 or i >= self.ctx.n_nodes or j < 0 or j >= self.ctx.n_nodes:
|
if i < 0 or i >= self.ctx.n_nodes or j < 0 or j >= self.ctx.n_nodes:
|
||||||
continue
|
continue
|
||||||
# Add noise to travel time
|
# Add noise to travel time. Local reroute is a road-level
|
||||||
base_tt = self.ctx.get_travel_time(i, j, current_time)
|
# detour in the complete-graph reproduction, so the route's
|
||||||
|
# customer sequence stays fixed while this arc is costed via
|
||||||
|
# an intermediate road waypoint.
|
||||||
|
if (
|
||||||
|
action.get("type") == "local_reroute"
|
||||||
|
and tuple(action.get("arc", ())) == (i, j)
|
||||||
|
and 0 < action.get("bypass", -1) < self.ctx.n_nodes
|
||||||
|
):
|
||||||
|
bypass = action["bypass"]
|
||||||
|
base_tt = (
|
||||||
|
self.ctx.get_travel_time(i, bypass, current_time)
|
||||||
|
+ self.ctx.get_travel_time(bypass, j, current_time)
|
||||||
|
)
|
||||||
|
congestion = (
|
||||||
|
self.ctx.get_congestion_penalty(i, bypass, current_time)
|
||||||
|
+ self.ctx.get_congestion_penalty(bypass, j, current_time)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
base_tt = self.ctx.get_travel_time(i, j, current_time)
|
||||||
|
congestion = self.ctx.get_congestion_penalty(i, j, current_time)
|
||||||
noise = 1.0 + self.rng.normal(0, noise_std)
|
noise = 1.0 + self.rng.normal(0, noise_std)
|
||||||
tt = max(base_tt * noise, 0.0)
|
tt = max(base_tt * noise, 0.0)
|
||||||
current_time += tt
|
current_time += tt
|
||||||
|
|
||||||
# Congestion cost
|
# Congestion cost
|
||||||
congestion = self.ctx.get_congestion_penalty(i, j, current_time)
|
|
||||||
total_cost += tt + self.cost_calc.lambda_congestion * congestion
|
total_cost += tt + self.cost_calc.lambda_congestion * congestion
|
||||||
|
|
||||||
# Delay penalty
|
# Delay penalty
|
||||||
if j != 0 and j in self.ctx.customers:
|
if j != 0 and j in self.ctx.customers:
|
||||||
cust = self.ctx.customers[j]
|
cust = self.ctx.customers[j]
|
||||||
lateness = max(0.0, current_time - cust.latest_time_min)
|
tolerance = (
|
||||||
|
action.get("tolerance_min", 0.0)
|
||||||
|
if action.get("type") == "temporary_tolerance"
|
||||||
|
and action.get("customer") == j
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
lateness = max(0.0, current_time - cust.latest_time_min - tolerance)
|
||||||
total_cost += self.cost_calc.lambda_lateness * lateness
|
total_cost += self.cost_calc.lambda_lateness * lateness
|
||||||
|
|
||||||
# Service time
|
# Service time
|
||||||
@@ -182,22 +209,21 @@ class RolloutEngine:
|
|||||||
if removed:
|
if removed:
|
||||||
is_tabu = tabu_mem.is_tabu(removed, action["type"], "", 0)
|
is_tabu = tabu_mem.is_tabu(removed, action["type"], "", 0)
|
||||||
if is_tabu:
|
if is_tabu:
|
||||||
V_adjusted -= self.tabu_penalty
|
V_adjusted += self.tabu_penalty
|
||||||
else:
|
else:
|
||||||
V_adjusted += self.tabu_bonus
|
V_adjusted = max(0.0, V_adjusted - self.tabu_bonus)
|
||||||
|
|
||||||
return V_adjusted
|
return V_adjusted
|
||||||
|
|
||||||
def _apply_action(self, solution: Solution, action: dict) -> Solution:
|
def _apply_action(self, solution: Solution, action: dict) -> Solution:
|
||||||
"""Apply a candidate action to a solution (returns modified copy)."""
|
"""Apply a candidate action to a solution (returns modified copy)."""
|
||||||
import copy
|
|
||||||
sol = solution.copy()
|
sol = solution.copy()
|
||||||
action_type = action.get("type", "")
|
action_type = action.get("type", "")
|
||||||
|
|
||||||
if action_type == "local_reroute":
|
if action_type == "local_reroute":
|
||||||
# Simplified: we don't actually modify the route structure
|
# Complete-graph reproduction: route sequence is unchanged and the
|
||||||
# In practice, this would insert a bypass node
|
# detour effect is represented by action penalty/rollout scoring.
|
||||||
pass
|
return sol
|
||||||
|
|
||||||
elif action_type == "customer_reassign":
|
elif action_type == "customer_reassign":
|
||||||
cust = action.get("customer")
|
cust = action.get("customer")
|
||||||
@@ -213,16 +239,13 @@ class RolloutEngine:
|
|||||||
sol.routes[target].insert(cust, insert_pos)
|
sol.routes[target].insert(cust, insert_pos)
|
||||||
|
|
||||||
elif action_type == "urgent_insert":
|
elif action_type == "urgent_insert":
|
||||||
target = action.get("target_vehicle")
|
# Urgent customers are not part of the fixed 47-customer tensor in
|
||||||
pos = action.get("position", 1)
|
# this methodological reproduction. They are scored via penalty
|
||||||
# Create temp customer ID (negative)
|
# actions; inserting synthetic negative nodes would invalidate Eq.1.
|
||||||
temp_id = -100 # placeholder
|
return sol
|
||||||
if target is not None and target < sol.n_vehicles:
|
|
||||||
sol.routes[target].insert(temp_id, pos)
|
|
||||||
|
|
||||||
elif action_type == "subcontract":
|
elif action_type in ("urgent_defer", "subcontract"):
|
||||||
# No route change, just penalty
|
return sol
|
||||||
pass
|
|
||||||
|
|
||||||
elif action_type == "redistribute":
|
elif action_type == "redistribute":
|
||||||
cust = action.get("customer")
|
cust = action.get("customer")
|
||||||
@@ -249,7 +272,6 @@ class RolloutEngine:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
elif action_type == "temporary_tolerance":
|
elif action_type == "temporary_tolerance":
|
||||||
# Accept delay without route change
|
return sol
|
||||||
pass
|
|
||||||
|
|
||||||
return sol
|
return sol
|
||||||
|
|||||||
@@ -62,8 +62,12 @@ class TALNSRRD:
|
|||||||
"dispatch_weight_rollout": 0.4,
|
"dispatch_weight_rollout": 0.4,
|
||||||
"dispatch_weight_stability": 0.3,
|
"dispatch_weight_stability": 0.3,
|
||||||
"dispatch_weight_recovery": 0.3,
|
"dispatch_weight_recovery": 0.3,
|
||||||
|
"event_urgency_threshold": 0.5,
|
||||||
"event_probability": 0.3,
|
"event_probability": 0.3,
|
||||||
"event_check_interval": 10, # Check for events every N iterations
|
"event_check_interval": 10, # Check for events every N iterations
|
||||||
|
"rollout_mc_iterations": 50,
|
||||||
|
"rrd_tabu_penalty": 50.0,
|
||||||
|
"rrd_tabu_bonus": 25.0,
|
||||||
}
|
}
|
||||||
if config:
|
if config:
|
||||||
default_cfg.update(config)
|
default_cfg.update(config)
|
||||||
@@ -75,7 +79,7 @@ class TALNSRRD:
|
|||||||
# Initialize RRD components
|
# Initialize RRD components
|
||||||
self.event_generator = EventGenerator(
|
self.event_generator = EventGenerator(
|
||||||
problem_ctx, cost_calc,
|
problem_ctx, cost_calc,
|
||||||
urgency_threshold=0.3, # Lower threshold for more events
|
urgency_threshold=self.cfg["event_urgency_threshold"],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.rollout_engine = RolloutEngine(
|
self.rollout_engine = RolloutEngine(
|
||||||
@@ -84,6 +88,9 @@ class TALNSRRD:
|
|||||||
horizon_max=self.cfg["rollout_horizon_max"],
|
horizon_max=self.cfg["rollout_horizon_max"],
|
||||||
n_sim_min=self.cfg["rollout_n_sim_min"],
|
n_sim_min=self.cfg["rollout_n_sim_min"],
|
||||||
n_sim_max=self.cfg["rollout_n_sim_max"],
|
n_sim_max=self.cfg["rollout_n_sim_max"],
|
||||||
|
mc_iterations=self.cfg["rollout_mc_iterations"],
|
||||||
|
tabu_penalty=self.cfg["rrd_tabu_penalty"],
|
||||||
|
tabu_bonus=self.cfg["rrd_tabu_bonus"],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.dispatch = Dispatch(
|
self.dispatch = Dispatch(
|
||||||
@@ -99,6 +106,34 @@ class TALNSRRD:
|
|||||||
self.event_count = 0
|
self.event_count = 0
|
||||||
self.dispatched_count = 0
|
self.dispatched_count = 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _seed_with_offset(seed: int, offset: int):
|
||||||
|
return None if seed is None else seed + offset
|
||||||
|
|
||||||
|
def _apply_event_traffic(self, event) -> dict:
|
||||||
|
"""Temporarily apply incident severity to the affected traffic arc."""
|
||||||
|
modified_arc = getattr(event, "_modified_arc", None)
|
||||||
|
severity = getattr(event, "_severity", None)
|
||||||
|
if modified_arc is None or severity is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
i, j = modified_arc
|
||||||
|
backup = {
|
||||||
|
"arc": (i, j),
|
||||||
|
"travel_time": self.ctx.traffic.travel_time[i, j, :].copy(),
|
||||||
|
"congestion_penalty": self.ctx.traffic.congestion_penalty[i, j, :].copy(),
|
||||||
|
}
|
||||||
|
self.ctx.traffic.travel_time[i, j, :] *= severity
|
||||||
|
self.ctx.traffic.congestion_penalty[i, j, :] *= severity
|
||||||
|
return backup
|
||||||
|
|
||||||
|
def _restore_event_traffic(self, backup: dict):
|
||||||
|
if not backup:
|
||||||
|
return
|
||||||
|
i, j = backup["arc"]
|
||||||
|
self.ctx.traffic.travel_time[i, j, :] = backup["travel_time"]
|
||||||
|
self.ctx.traffic.congestion_penalty[i, j, :] = backup["congestion_penalty"]
|
||||||
|
|
||||||
def solve(self, seed: int = None) -> Solution:
|
def solve(self, seed: int = None) -> Solution:
|
||||||
"""Run T-ALNS-RRD optimization (Algorithm 3)."""
|
"""Run T-ALNS-RRD optimization (Algorithm 3)."""
|
||||||
rng = np.random.default_rng(seed)
|
rng = np.random.default_rng(seed)
|
||||||
@@ -108,7 +143,11 @@ class TALNSRRD:
|
|||||||
self.talns.move_tabu.clear()
|
self.talns.move_tabu.clear()
|
||||||
self.talns.sol_tabu.clear()
|
self.talns.sol_tabu.clear()
|
||||||
self.talns.freq_mem.clear()
|
self.talns.freq_mem.clear()
|
||||||
self.event_generator.reset()
|
self.event_generator.reset(self._seed_with_offset(seed, 101))
|
||||||
|
self.rollout_engine.reset(self._seed_with_offset(seed, 202))
|
||||||
|
self.dispatch.reset(self._seed_with_offset(seed, 303))
|
||||||
|
self.event_count = 0
|
||||||
|
self.dispatched_count = 0
|
||||||
|
|
||||||
# Initialize weights
|
# Initialize weights
|
||||||
self.talns.destroy_weights = {name: 1.0 for name in self.talns.destroy_ops}
|
self.talns.destroy_weights = {name: 1.0 for name in self.talns.destroy_ops}
|
||||||
@@ -162,30 +201,29 @@ class TALNSRRD:
|
|||||||
"move_tabu": self.talns.move_tabu,
|
"move_tabu": self.talns.move_tabu,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Select and apply dispatch action
|
backup = self._apply_event_traffic(event)
|
||||||
action = self.dispatch.select_action(
|
action = None
|
||||||
event, S_current, S_best, tabu_structs
|
S_dispatched = None
|
||||||
)
|
try:
|
||||||
|
# Select and apply dispatch action under the temporary
|
||||||
|
# disrupted traffic state, then restore the base tensor.
|
||||||
|
action = self.dispatch.select_action(
|
||||||
|
event, S_current, S_best, tabu_structs
|
||||||
|
)
|
||||||
|
if action is not None:
|
||||||
|
S_dispatched = self.dispatch.apply_action(action, S_current)
|
||||||
|
finally:
|
||||||
|
self._restore_event_traffic(backup)
|
||||||
|
|
||||||
if action is not None:
|
if action is not None and S_dispatched is not None:
|
||||||
S_current = self.dispatch.apply_action(action, S_current)
|
S_current = S_dispatched
|
||||||
self.dispatched_count += 1
|
self.dispatched_count += 1
|
||||||
# Restore traffic tensor after dispatch (event handled)
|
|
||||||
modified_arc = getattr(event, '_modified_arc', None)
|
|
||||||
if modified_arc is not None:
|
|
||||||
i, j = modified_arc
|
|
||||||
backup = getattr(event, '_travel_backup_ij', None)
|
|
||||||
if backup is not None:
|
|
||||||
self.ctx.traffic.travel_time[i, j, :] = backup
|
|
||||||
cb = getattr(event, '_congestion_backup_ij', None)
|
|
||||||
if cb is not None:
|
|
||||||
self.ctx.traffic.congestion_penalty[i, j, :] = cb
|
|
||||||
# Update cost after dispatch
|
# Update cost after dispatch
|
||||||
current_cost = self.cost_calc.compute_total_cost(S_current, self.ctx)
|
current_cost = self.cost_calc.compute_total_cost(S_current, self.ctx)
|
||||||
|
|
||||||
# Update Tabu memory with dispatch action
|
# Update Tabu memory with dispatch action
|
||||||
self.talns.sol_tabu.add(S_current, iter_count)
|
self.talns.sol_tabu.add(S_current, iter_count)
|
||||||
self.talns.freq_mem.update(S_current)
|
self.talns._update_frequency_memory(S_current)
|
||||||
|
|
||||||
if current_cost < best_cost:
|
if current_cost < best_cost:
|
||||||
S_best = S_current.copy()
|
S_best = S_current.copy()
|
||||||
@@ -239,7 +277,9 @@ class TALNSRRD:
|
|||||||
# Check solution tabu
|
# Check solution tabu
|
||||||
if self.talns.sol_tabu.is_tabu(S_new, iter_count):
|
if self.talns.sol_tabu.is_tabu(S_new, iter_count):
|
||||||
new_cost_temp = self.cost_calc.compute_total_cost(S_new, self.ctx)
|
new_cost_temp = self.cost_calc.compute_total_cost(S_new, self.ctx)
|
||||||
if not self.talns._check_aspiration(S_new, new_cost_temp, best_cost, removed):
|
if not self.talns._check_aspiration(
|
||||||
|
S_new, new_cost_temp, best_cost, removed, S_current
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
new_cost = self.cost_calc.compute_total_cost(S_new, self.ctx)
|
new_cost = self.cost_calc.compute_total_cost(S_new, self.ctx)
|
||||||
@@ -251,7 +291,7 @@ class TALNSRRD:
|
|||||||
|
|
||||||
self.talns.move_tabu.add(set(removed), d_name, r_name, iter_count)
|
self.talns.move_tabu.add(set(removed), d_name, r_name, iter_count)
|
||||||
self.talns.sol_tabu.add(S_new, iter_count)
|
self.talns.sol_tabu.add(S_new, iter_count)
|
||||||
self.talns.freq_mem.update(S_new)
|
self.talns._update_frequency_memory(S_new)
|
||||||
|
|
||||||
if new_cost < best_cost:
|
if new_cost < best_cost:
|
||||||
S_best = S_new.copy()
|
S_best = S_new.copy()
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ class TALNS:
|
|||||||
new_cost: float,
|
new_cost: float,
|
||||||
best_cost: float,
|
best_cost: float,
|
||||||
removed_customers: List[int],
|
removed_customers: List[int],
|
||||||
|
S_current: Solution = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check aspiration criteria (Eq.29-31).
|
"""Check aspiration criteria (Eq.29-31).
|
||||||
|
|
||||||
@@ -239,14 +240,38 @@ class TALNS:
|
|||||||
if self.freq_mem.is_low_frequency(c, route_idx, self.cfg["aspiration_beta"]):
|
if self.freq_mem.is_low_frequency(c, route_idx, self.cfg["aspiration_beta"]):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Traffic adaptation aspiration (Eq.31): accept if significantly reduces congestion
|
# Traffic adaptation aspiration (Eq.31): accept if the candidate
|
||||||
# (Simplified: check congestion exposure reduction)
|
# significantly reduces congestion exposure versus the current route set.
|
||||||
current_congestion = self.cost_calc.compute_congestion_exposure(S_new, self.ctx)
|
if S_current is not None:
|
||||||
# We don't have the "current" solution reference here, simplified check
|
new_congestion = self.cost_calc.compute_congestion_exposure(S_new, self.ctx)
|
||||||
# Full implementation would compare to current solution
|
current_congestion = self.cost_calc.compute_congestion_exposure(
|
||||||
|
S_current, self.ctx
|
||||||
|
)
|
||||||
|
if new_congestion < self.cfg["aspiration_gamma"] * current_congestion:
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _route_congestion_weights(self, solution: Solution) -> Dict[tuple, float]:
|
||||||
|
"""Build incoming-arc congestion weights for Eq.34 frequency memory."""
|
||||||
|
weights = {}
|
||||||
|
for route in solution.routes:
|
||||||
|
result = self.cost_calc.propagate_route(route.nodes, self.ctx)
|
||||||
|
for idx in range(1, len(route.nodes)):
|
||||||
|
i = route.nodes[idx - 1]
|
||||||
|
j = route.nodes[idx]
|
||||||
|
if j == 0:
|
||||||
|
continue
|
||||||
|
depart = result["departures"][idx - 1]
|
||||||
|
weights[(i, j)] = self.ctx.get_congestion_penalty(i, j, depart)
|
||||||
|
return weights
|
||||||
|
|
||||||
|
def _update_frequency_memory(self, solution: Solution):
|
||||||
|
self.freq_mem.update(
|
||||||
|
solution,
|
||||||
|
congestion_weights=self._route_congestion_weights(solution),
|
||||||
|
)
|
||||||
|
|
||||||
def solve(self, seed: int = None) -> Solution:
|
def solve(self, seed: int = None) -> Solution:
|
||||||
"""Run T-ALNS optimization (Algorithm 2)."""
|
"""Run T-ALNS optimization (Algorithm 2)."""
|
||||||
rng = np.random.default_rng(seed)
|
rng = np.random.default_rng(seed)
|
||||||
@@ -345,7 +370,9 @@ class TALNS:
|
|||||||
if not self.cfg.get("disable_solution_tabu", False):
|
if not self.cfg.get("disable_solution_tabu", False):
|
||||||
if self.sol_tabu.is_tabu(S_new, iter_count):
|
if self.sol_tabu.is_tabu(S_new, iter_count):
|
||||||
new_cost_temp = self.cost_calc.compute_total_cost(S_new, self.ctx)
|
new_cost_temp = self.cost_calc.compute_total_cost(S_new, self.ctx)
|
||||||
if not self._check_aspiration(S_new, new_cost_temp, best_cost, removed):
|
if not self._check_aspiration(
|
||||||
|
S_new, new_cost_temp, best_cost, removed, S_current
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Evaluate cost
|
# Evaluate cost
|
||||||
@@ -362,7 +389,7 @@ class TALNS:
|
|||||||
if not self.cfg.get("disable_solution_tabu", False):
|
if not self.cfg.get("disable_solution_tabu", False):
|
||||||
self.sol_tabu.add(S_new, iter_count)
|
self.sol_tabu.add(S_new, iter_count)
|
||||||
if not self.cfg.get("disable_frequency_memory", False):
|
if not self.cfg.get("disable_frequency_memory", False):
|
||||||
self.freq_mem.update(S_new)
|
self._update_frequency_memory(S_new)
|
||||||
|
|
||||||
if new_cost < best_cost:
|
if new_cost < best_cost:
|
||||||
S_best = S_new.copy()
|
S_best = S_new.copy()
|
||||||
|
|||||||
Reference in New Issue
Block a user