File size: 35,945 Bytes
e516f1f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 | from .base_qubo import BaseQUBO
import numpy as np
class GraphQUBO(BaseQUBO):
def __init__(
self,
problem,
penalties,
name="graph",
var_limit=156, # 65 131
window_max_steps=None,
distance_scaling="enhanced_linear",
robot_window_limits=None,
log_reductions=True,
verbose_level=2,
):
self.graph = problem.graph
self.num_nodes = len(self.graph.nodes)
super().__init__(
problem,
penalties,
name=name,
var_limit=var_limit,
window_max_steps=window_max_steps,
distance_scaling=distance_scaling,
robot_window_limits=robot_window_limits,
log_reductions=log_reductions,
verbose_level=verbose_level,
)
# Multi-robot support: calculate total variables for all robots
# Use problem.T (total timeline) not total_t (window size) to match QUBOBuilder
self.initial_num_vars = (
self.num_nodes * self.problem.T * self.problem.num_robots
)
# Compute goal-oriented connectivity potential for each robot
# This helps avoid dead-ends by identifying nodes where neighbors lead away from goal
# Store per-robot potentials for multi-robot scenarios
self.P_connectivity_per_robot = {}
self.P_obs = self.compute_spatial_obstacle_potential()
self.logger.standard("Window max steps:", self.max_window_size())
def build(self, constraints_to_apply=None):
"""Build the QUBO dictionary for graph-based pathfinding."""
if constraints_to_apply is None:
penalty_to_constraint = {
"K_hot": "one_hot",
"K_adj": "adjacency_reward",
"K_start": "start",
"K_goal": "goal",
"K_lock": "lock",
"K_bt": "backtracking",
"K_crash": "crash",
"K_swap": "swap",
}
constraints_to_apply = [
v for k, v in penalty_to_constraint.items() if k in self.penalties
]
# apply_swap_penalty already applies the same-node/same-time term
# itself (weighted by K_crash), so don't also run apply_crash_penalty
# separately — that would double-count it.
if "swap" in constraints_to_apply and "crash" in constraints_to_apply:
constraints_to_apply.remove("crash")
self.Q = {}
if "one_hot" in constraints_to_apply:
self.apply_one_hot()
if "start" in constraints_to_apply:
self.apply_start_penalty()
if "goal" in constraints_to_apply:
self.apply_goal_penalty()
if "adjacency" in constraints_to_apply:
self.apply_adjacency_constraint()
if "lock" in constraints_to_apply:
self.apply_lock_after_goal()
if "adjacency_reward" in constraints_to_apply:
self.apply_adjacency_reward()
if "backtracking" in constraints_to_apply:
self.apply_backtracking_penalty()
if "crash" in constraints_to_apply:
self.apply_crash_penalty()
if "swap" in constraints_to_apply:
self.apply_swap_penalty()
if "multi_robot_proximity" in constraints_to_apply:
self.apply_multi_robot_proximity_penalty()
return self.Q
def _nodes(self, robot_id, t):
"""Return active node IDs for (robot_id, t), falling back to all nodes."""
if self._active_cells is not None:
return self._active_cells.get((robot_id, t), [])
return range(self.num_nodes)
def get_logical_variables(self):
"""
Returns (fixed_ones, active_cells):
- fixed_ones: {flat_idx: 1} for variables known to be 1.
Absent entries are implicitly 0 — no zeros stored.
- active_cells: {(robot_id, t): [node_id, ...]} built directly from BFS.
"""
fixed_ones = {}
active_cells = {}
robot_nums = self.problem.get_robot_nums()
for robot_id in self.get_active_robot_in_window():
robot_offset = robot_nums[robot_id] * (self.num_nodes * self.total_t)
robot = self.problem.robots[robot_id]
start_time = robot.start_time
start = 0
if self.current_T < start_time:
start = start_time - self.current_T
end_time = robot.T + robot.start_time
end = end_time - self.current_T
if end_time > self.current_T + self.t_max:
end = self.t_max
start_node, goal_node = self.problem.get_graph_robot_current_goal(robot_id)
start_idx = start_node + (self.num_nodes * start) + robot_offset
fixed_ones[start_idx] = 1
self.logger.debug(start_idx, "fixed to 1 for robot", robot_id)
reachable = self.reachable_positions_aggressive(
robot, start_node, start, end
)
if goal_node in reachable.get(start + 1, set()):
self.logger.standard(
f"Goal is reachable at timestep 1 for robot {robot_id}. Fixing instantaneous path."
)
active_cells[(robot_id, start)] = [start_node]
for t in range(start + 1, end):
goal_idx = goal_node + (self.num_nodes * t) + robot_offset
fixed_ones[goal_idx] = 1
active_cells[(robot_id, t)] = [goal_node]
else:
active_cells[(robot_id, start)] = [start_node]
for t in range(start + 1, end):
active_cells[(robot_id, t)] = list(reachable.get(t, {goal_node}))
return fixed_ones, active_cells
def apply_one_hot(self):
"""Apply one-hot constraint: exactly one node per time step per robot."""
K_hot = self.penalties["K_hot"]
robot_nums = self.problem.get_robot_nums()
for robot_id in self.get_active_robot_in_window():
robot_offset = robot_nums[robot_id] * (self.num_nodes * self.total_t)
robot = self.problem.robots[robot_id]
start_time = robot.start_time
start = 0
if self.current_T < start_time:
start = start_time - self.current_T
end_time = robot.T + start_time
end = end_time - self.current_T
if end_time > self.current_T + self.t_max:
end = self.t_max
for t in range(start, end):
indices = [
node_id + (self.num_nodes * t) + robot_offset
for node_id in self._nodes(robot_id, t)
]
for n in indices:
self.Q[(n, n)] = self.Q.get((n, n), 0) - K_hot
for i, n in enumerate(indices):
for m in indices[i + 1 :]:
self.Q[(n, m)] = self.Q.get((n, m), 0) + 2 * K_hot
def apply_start_penalty(self):
"""Apply start node penalty: must start at the given node for each robot."""
K_start = self.penalties["K_start"]
robot_nums = self.problem.get_robot_nums()
for robot_id in self.get_active_robot_in_window():
robot_offset = robot_nums[robot_id] * (self.num_nodes * self.total_t)
start_time = self.problem.robots[robot_id].start_time
start = 0
if self.current_T < start_time:
start = start_time - self.current_T
# Get start node for this robot (assuming it's stored as node index)
start_node = self.problem.get_graph_robot_current_goal(robot_id)[0]
start_idx = start_node + (start * self.num_nodes) + robot_offset
self.Q[(start_idx, start_idx)] = (
self.Q.get((start_idx, start_idx), 0) - K_start
)
def apply_goal_penalty(self):
"""Apply goal node penalty: encourage reaching the goal for each robot."""
K_goal = self.penalties["K_goal"]
robot_nums = self.problem.get_robot_nums()
for robot_id in self.get_active_robot_in_window():
robot = self.problem.robots[robot_id]
start_time = robot.start_time
end_time = robot.T + start_time
if end_time > self.current_T + self.t_max:
# Goal not reachable in this window, use approximation
self.apply_goal_approximation_penalty(robot_id)
else:
# Goal is reachable, apply standard goal penalty
robot_offset = robot_nums[robot_id] * (self.num_nodes * self.total_t)
start = 0
if self.current_T < start_time:
start = start_time - self.current_T
end = end_time - self.current_T
goal_node = self.problem.get_graph_robot_current_goal(robot_id)[1]
window_constant = 1 + (0.6 / end)
for t in range(start + 1, end):
goal_idx = goal_node + (self.num_nodes * t) + robot_offset
time_factor = 1 + ((t - start) / (end - start))
self.Q[(goal_idx, goal_idx)] = (
self.Q.get((goal_idx, goal_idx), 0)
- K_goal * time_factor * window_constant
)
def apply_lock_after_goal(self):
"""Apply lock-after-goal constraint: once at goal, stay there for each robot."""
K_lock = self.penalties["K_lock"]
robot_nums = self.problem.get_robot_nums()
for robot_id in self.get_active_robot_in_window():
robot_offset = robot_nums[robot_id] * (self.num_nodes * self.total_t)
robot = self.problem.robots[robot_id]
start_time = robot.start_time
start = 0
if self.current_T < start_time:
start = start_time - self.current_T
end_time = robot.T + start_time
end = end_time - self.current_T
if end_time > self.current_T + self.t_max:
end = self.t_max
# Get goal node for this robot
goal_node = self.problem.get_graph_robot_current_goal(robot_id)[1]
for t in range(start, end - 1):
if goal_node not in self._nodes(robot_id, t):
continue
goal_idx_t = goal_node + (self.num_nodes * t) + robot_offset
goal_idx_t1 = goal_node + (self.num_nodes * (t + 1)) + robot_offset
self.Q[(goal_idx_t, goal_idx_t)] = (
self.Q.get((goal_idx_t, goal_idx_t), 0) + K_lock
)
self.Q[(goal_idx_t, goal_idx_t1)] = (
self.Q.get((goal_idx_t, goal_idx_t1), 0) - K_lock
)
def apply_adjacency_constraint(self):
"""
Apply adjacency constraint: only move between connected nodes for each robot.
It enforces edge movements and penalizes non-adjacent moves.
"""
K_adj = self.penalties["K_adj"]
adjacency = self.graph.adjacency
robot_nums = self.problem.get_robot_nums()
for robot_id in self.get_active_robot_in_window():
robot_offset = robot_nums[robot_id] * (self.num_nodes * self.total_t)
robot = self.problem.robots[robot_id]
start_time = robot.start_time
start = 0
if self.current_T < start_time:
start = start_time - self.current_T
end_time = robot.T + start_time
end = end_time - self.current_T
if end_time > self.current_T + self.t_max:
end = self.t_max
for t in range(start, end - 1):
next_active = set(self._nodes(robot_id, t + 1))
for node_i in self._nodes(robot_id, t):
n = node_i + (self.num_nodes * t) + robot_offset
for node_j, weight in adjacency[node_i]:
if self._active_cells is not None and node_j not in next_active:
continue
m = node_j + (self.num_nodes * (t + 1)) + robot_offset
self.Q[(n, m)] = self.Q.get((n, m), 0) - K_adj * weight
for node_j in next_active:
if node_j != node_i and (node_j, 1.0) not in adjacency[node_i]:
m = node_j + (self.num_nodes * (t + 1)) + robot_offset
self.Q[(n, m)] = self.Q.get((n, m), 0) + K_adj
def apply_adjacency_reward(self):
"""Apply adjacency reward: encourage moving to adjacent nodes for each robot."""
K_adj = self.penalties["K_adj"]
adjacency = self.graph.adjacency
robot_nums = self.problem.get_robot_nums()
for robot_id in self.get_active_robot_in_window():
robot_offset = robot_nums[robot_id] * (self.num_nodes * self.total_t)
robot = self.problem.robots[robot_id]
start_time = robot.start_time
start = 0
if self.current_T < start_time:
start = start_time - self.current_T
end_time = robot.T + start_time
end = end_time - self.current_T
if end_time > self.current_T + self.t_max:
end = self.t_max
for t in range(start, end - 1):
next_active = set(self._nodes(robot_id, t + 1))
for node_i in self._nodes(robot_id, t):
n = node_i + (self.num_nodes * t) + robot_offset
self.Q[(n, n)] = self.Q.get((n, n), 0) + K_adj
for node_j, weight in adjacency[node_i]:
if self._active_cells is not None and node_j not in next_active:
continue
m = node_j + (self.num_nodes * (t + 1)) + robot_offset
self.Q[(n, m)] = self.Q.get((n, m), 0) - K_adj * weight
def calculate_euclidean_penalty(self, raw_dist, K_goal_approx, time_factor):
"""
Calculate Euclidean distance penalty using the specified scaling method.
Args:
raw_dist: Raw Euclidean distance
K_goal_approx: Goal approximation penalty coefficient
time_factor: Time-based scaling factor
Returns:
K_dis: Calculated distance penalty
"""
if self.distance_scaling == "enhanced_linear":
dist_to_goal = raw_dist * 0.165
K_dis = K_goal_approx * (1 / (0.7 + dist_to_goal)) * time_factor
elif self.distance_scaling == "exponential":
dist_to_goal = raw_dist * 1.2
K_dis = K_goal_approx * (1 / (1 + dist_to_goal)) * time_factor
elif self.distance_scaling == "quadratic":
dist_to_goal = raw_dist**1.3
K_dis = K_goal_approx * (1 / (1 + dist_to_goal)) * time_factor
elif self.distance_scaling == "logarithmic":
import numpy as np
dist_to_goal = np.log(1 + raw_dist * 2)
K_dis = K_goal_approx * (1 / (1 + dist_to_goal)) * time_factor
elif self.distance_scaling == "adaptive":
num_nodes = self.num_nodes
if num_nodes <= 9:
dist_to_goal = raw_dist * 0.4
K_dis = K_goal_approx * (1 / (0.2 + dist_to_goal)) * time_factor
elif num_nodes <= 25:
dist_to_goal = raw_dist * 0.8
K_dis = K_goal_approx * (1 / (0.4 + dist_to_goal)) * time_factor
else:
dist_to_goal = raw_dist * 1.2
K_dis = K_goal_approx * (1 / (0.8 + dist_to_goal)) * time_factor
else:
dist_to_goal = raw_dist * 2
K_dis = K_goal_approx * (1 / (1 + dist_to_goal)) * time_factor
return K_dis
def _compute_node_connectivity_potential(self, goal_node=None, start_node=None):
"""
Compute goal-oriented and directionally-aware connectivity potential for each node.
This measures TWO factors:
1. How many neighbors move you CLOSER to the goal (goal-oriented)
2. How many neighbors are DIRECTIONALLY ALIGNED with the path from start to goal
Nodes whose neighbors point away from the start→goal direction get higher potential.
Args:
goal_node: Target node ID (if None, uses simple degree-based)
start_node: Current position node ID (if None, ignores directional factor)
Returns:
Array of potential values, one per node.
"""
P_nodes = np.zeros(self.num_nodes)
# If no specific goal, compute general connectivity
if goal_node is None:
# Use simple degree-based for general case
degrees = np.zeros(self.num_nodes)
for node_id in range(self.num_nodes):
degrees[node_id] = len(self.graph.adjacency.get(node_id, []))
max_degree = max(degrees) if max(degrees) > 0 else 1
for node_id in range(self.num_nodes):
normalized_degree = degrees[node_id] / max_degree
P_nodes[node_id] = np.exp(-(normalized_degree**2))
else:
# Goal-oriented: count neighbors that move you CLOSER to goal
goal_pos = self.graph.get_node_position(goal_node)
if goal_pos is None:
return P_nodes # Fallback to zeros
# Get start position if provided (for directional alignment)
start_pos = None
if start_node is not None:
start_pos = self.graph.get_node_position(start_node)
for node_id in range(self.num_nodes):
node_pos = self.graph.get_node_position(node_id)
if node_pos is None:
continue
# Factor 1: Goal-oriented connectivity
# Distance from current node to goal
current_dist = self.problem.euclidean_distance(node_pos, goal_pos)
# Count neighbors and how many lead closer to goal
neighbors = self.graph.adjacency.get(node_id, [])
total_neighbors = len(neighbors)
if total_neighbors == 0:
P_nodes[node_id] = 1.0 # Isolated node = maximum penalty
continue
# Count "good" neighbors (those closer to goal than current node)
good_neighbors = 0
for neighbor_id, _ in neighbors:
neighbor_pos = self.graph.get_node_position(neighbor_id)
if neighbor_pos is not None:
neighbor_dist = self.problem.euclidean_distance(
neighbor_pos, goal_pos
)
if neighbor_dist < current_dist:
good_neighbors += 1
# Heuristic: Inverse of (1 + good_neighbors)
# 0 good neighbors (dead end) -> 1/1 = 1.0
# 1 good neighbor (narrow) -> 1/2 = 0.5
# 2 good neighbors -> 1/3 = 0.33
# 3 good neighbors -> 1/4 = 0.25
P_nodes[node_id] = 1.0 / (1.0 + good_neighbors)
return P_nodes
def compute_spatial_obstacle_potential(self, sigma=1.5, isolation_threshold=3):
"""
Compute obstacle potential using node positions and connectivity patterns.
Nodes with few connections are likely near obstacles/boundaries and create
repulsive potential fields similar to the grid-based approach.
Args:
sigma: Controls spatial decay (higher = wider influence)
isolation_threshold: Nodes with fewer neighbors are considered near obstacles
Returns:
Array of potential values per node
"""
P_nodes = np.zeros(self.num_nodes)
# Collect positions and identify "obstacle-adjacent" nodes
obstacle_nodes = []
all_positions = {}
for node_id in range(self.num_nodes):
pos = self.graph.get_node_position(node_id)
if pos is not None:
all_positions[node_id] = pos
# Low connectivity suggests proximity to obstacles
neighbors = self.graph.adjacency.get(node_id, [])
if len(neighbors) <= isolation_threshold:
obstacle_nodes.append((node_id, pos))
if len(obstacle_nodes) == 0 or len(all_positions) == 0:
return P_nodes
# Compute potential: each obstacle node creates Gaussian repulsion
for node_id, node_pos in all_positions.items():
potential = 0.0
for obs_id, obs_pos in obstacle_nodes:
if node_id == obs_id:
# Self-contribution (node is itself near obstacle)
potential += 1.0
else:
# Spatial distance-based Gaussian decay
dist = self.problem.euclidean_distance(node_pos, obs_pos)
potential += np.exp(-(dist**2) / (2 * sigma**2))
P_nodes[node_id] = potential
# Normalize to [0, 1]
if np.max(P_nodes) > 0:
P_nodes /= np.max(P_nodes)
return P_nodes
def apply_goal_approximation_penalty(self, robot_id):
"""
Apply goal approximation penalty using Euclidean distance heuristic.
Encourages getting closer to the goal when it cannot be reached in current window.
Includes goal-oriented connectivity potential to avoid dead-ends.
"""
K_goal_approx = self.penalties.get("K_goal_approx", 0.5)
if K_goal_approx == 0:
return
K_deadend_repel = (
0.4 # Dead-end repulsion strength (for low-connectivity nodes)
)
K_repel = 1.0 # Probably requires higher tuning, still need to ponder if higher means better
robot_nums = self.problem.get_robot_nums()
robot_offset = robot_nums[robot_id] * (self.num_nodes * self.total_t)
robot = self.problem.robots[robot_id]
start_time = robot.start_time
start = 0
if self.current_T < start_time:
start = start_time - self.current_T
goal_node = self.problem.get_graph_robot_current_goal(robot_id)[1]
goal_pos = self.graph.get_node_position(goal_node)
# Get current position (start node for this window)
start_node = self.problem.get_graph_robot_current_goal(robot_id)[0]
# Compute goal-oriented and proximity-aware connectivity potential for this robot
# Cache it to avoid recomputation
if robot_id not in self.P_connectivity_per_robot:
self.P_connectivity_per_robot[robot_id] = (
self._compute_node_connectivity_potential(goal_node, start_node)
)
for t in range(start + 1, self.t_max):
time_factor = (1.2) ** (5 * (t - start) / (self.t_max - start))
for node_id in self._nodes(robot_id, t):
node_pos = self.graph.get_node_position(node_id)
if node_pos is None or goal_pos is None:
continue
# Goal attraction (distance-based)
raw_dist = self.problem.euclidean_distance(node_pos, goal_pos)
K_dis = self.calculate_euclidean_penalty(
raw_dist, K_goal_approx, time_factor
)
# Dead-end repulsion (goal-oriented connectivity)
# Penalize nodes with lower connectivity towards goal
# P value is 1.0 (dead end), 0.5 (1 neighbor), 0.33 (2 neighbors)...
# We apply the penalty scaled by this value
K_repel_val = self.penalties.get("K_deadend_repel", 0.2)
K_deadend = (
K_repel_val * self.P_connectivity_per_robot[robot_id][node_id]
)
K_obs = K_repel # * self.P_obs[node_id] # This is environment-oriented (avoid obstacles)
var_idx = node_id + (self.num_nodes * t) + robot_offset
# Apply both goal attraction and dead-end repulsion
self.Q[(var_idx, var_idx)] = (
self.Q.get((var_idx, var_idx), 0) - K_dis + K_obs + K_deadend
)
def apply_backtracking_penalty(self):
"""Apply backtracking penalty: discourage revisiting nodes for each robot."""
K_bt = self.penalties["K_bt"]
robot_nums = self.problem.get_robot_nums()
for robot_id in self.get_active_robot_in_window():
robot_offset = robot_nums[robot_id] * (self.num_nodes * self.total_t)
robot = self.problem.robots[robot_id]
start_time = robot.start_time
start = 0
if self.current_T < start_time:
start = start_time - self.current_T
end_time = robot.T + start_time
end = end_time - self.current_T
if end_time > self.current_T + self.t_max:
end = self.t_max
goal_node = self.problem.get_graph_robot_current_goal(robot_id)[1]
# With aggressive BFS each node appears at exactly one timestep, so
# iterating range(start, end) for every node would create phantom Q entries
# for timesteps where the node is not active.
active_sets = {t: set(self._nodes(robot_id, t)) for t in range(start, end)}
all_reachable = set()
for t in range(start, end):
all_reachable.update(active_sets[t])
for node_i in all_reachable:
if node_i == goal_node:
continue
active_ts = [t for t in range(start, end) if node_i in active_sets[t]]
for idx1, t1 in enumerate(active_ts):
n1 = node_i + (self.num_nodes * t1) + robot_offset
for t2 in active_ts[idx1 + 1 :]:
n2 = node_i + (self.num_nodes * t2) + robot_offset
self.Q[(n1, n2)] = self.Q.get((n1, n2), 0) + K_bt
if robot.active and robot.path:
len_sol = len(robot.path)
for t in range(start, end):
for p_idx, pos in enumerate(robot.path):
node_id = self.graph.get_node_from_position(pos[:2])
if node_id not in active_sets[t]:
continue
n = node_id + (self.num_nodes * t) + robot_offset
time_factor = (1 + (len_sol - p_idx)) / len_sol
self.Q[(n, n)] = self.Q.get((n, n), 0) + K_bt * time_factor
def apply_crash_penalty(self):
K_crash = self.penalties.get("K_crash", 0)
robot_nums = self.problem.get_robot_nums()
active_robots_per_timestep = self.get_active_robots_per_timestep_in_window()
for t, active_robots in active_robots_per_timestep.items():
if len(active_robots) < 2:
continue
t_window = t - self.current_T
for robot_id1 in active_robots:
for robot_id2 in active_robots:
if robot_nums[robot_id1] >= robot_nums[robot_id2]:
continue
robot_offset1 = robot_nums[robot_id1] * (
self.num_nodes * self.total_t
)
robot_offset2 = robot_nums[robot_id2] * (
self.num_nodes * self.total_t
)
shared_nodes = set(self._nodes(robot_id1, t_window)) & set(
self._nodes(robot_id2, t_window)
)
for node_i in shared_nodes:
idx1 = node_i + (self.num_nodes * t_window) + robot_offset1
idx2 = node_i + (self.num_nodes * t_window) + robot_offset2
self.Q[(idx1, idx2)] = self.Q.get((idx1, idx2), 0) + K_crash
def apply_swap_penalty(self):
"""
Approximate inter-robot swap-collision penalty (graph equivalent of
QUBOBuilder.apply_swap_penalty — cells become nodes).
P_swap = K_crash * r1[t]*r2[t] + K_swap * (r1[t+1]*r2[t] + r1[t]*r2[t+1])
Summed over nodes shared by two robots, this penalizes both a true swap
(robot1 moves into a node as robot2 moves out, and vice versa at the
neighboring node) and same-node/same-time occupancy (the crash case).
The two are weighted independently: the same-node/same-time term reuses
K_crash as-is (identical to apply_crash_penalty, so crash protection
doesn't get diluted), while the cross-time terms get their own K_swap.
Those cross-time terms also fire when one robot simply follows another
into a just-vacated node, which isn't an actual collision — an accepted
overconstraint of this pairwise approximation vs. the exact ancilla-based
formulation — so K_swap is the knob to tune down if that false-positive
cost outweighs the benefit, independently of crash protection.
When K_swap is active it replaces apply_crash_penalty (see build()) so
the same-time term isn't double counted.
"""
K_crash = self.penalties.get("K_crash", 0)
K_swap = self.penalties.get("K_swap", 0)
robot_nums = self.problem.get_robot_nums()
active_robots_per_timestep = self.get_active_robots_per_timestep_in_window()
for t, active_robots in active_robots_per_timestep.items():
if len(active_robots) < 2:
continue
t_window = t - self.current_T
next_active_robots = active_robots_per_timestep.get(t + 1, [])
for robot_id1 in active_robots:
for robot_id2 in active_robots:
if robot_nums[robot_id1] >= robot_nums[robot_id2]:
continue
robot_offset1 = robot_nums[robot_id1] * (
self.num_nodes * self.total_t
)
robot_offset2 = robot_nums[robot_id2] * (
self.num_nodes * self.total_t
)
# Same-node, same-time term (native crash constraint)
shared_nodes_t = set(self._nodes(robot_id1, t_window)) & set(
self._nodes(robot_id2, t_window)
)
for node_i in shared_nodes_t:
idx1 = node_i + (self.num_nodes * t_window) + robot_offset1
idx2 = node_i + (self.num_nodes * t_window) + robot_offset2
self.Q[(idx1, idx2)] = self.Q.get((idx1, idx2), 0) + K_crash
# Cross-time swap terms, only meaningful if both robots are
# still active in the window at t+1
if (
robot_id1 not in next_active_robots
or robot_id2 not in next_active_robots
):
continue
t_next_window = t_window + 1
# robot1 arrives at t+1 where robot2 was at t
shared_r1_next = set(self._nodes(robot_id1, t_next_window)) & set(
self._nodes(robot_id2, t_window)
)
for node_i in shared_r1_next:
idx1 = node_i + (self.num_nodes * t_next_window) + robot_offset1
idx2 = node_i + (self.num_nodes * t_window) + robot_offset2
self.Q[(idx1, idx2)] = self.Q.get((idx1, idx2), 0) + K_swap
# robot2 arrives at t+1 where robot1 was at t
shared_r2_next = set(self._nodes(robot_id1, t_window)) & set(
self._nodes(robot_id2, t_next_window)
)
for node_i in shared_r2_next:
idx1 = node_i + (self.num_nodes * t_window) + robot_offset1
idx2 = node_i + (self.num_nodes * t_next_window) + robot_offset2
self.Q[(idx1, idx2)] = self.Q.get((idx1, idx2), 0) + K_swap
def reachable_positions(self, robot, start_node, start, end):
# Initialize reachable set with the start node at start_time
reachable_at_time = {start: {start_node}}
# Perform a BFS-like traversal to find all reachable nodes at each time step
for t in range(start, end - 1):
reachable_at_time[t + 1] = set()
for node_i in reachable_at_time[t]:
for node_j, _ in self.graph.adjacency.get(node_i, []):
reachable_at_time[t + 1].add(node_j)
return reachable_at_time
def reachable_positions_aggressive(self, robot, start_node, start_time, end_time):
goal_node = self.problem.get_graph_robot_current_goal(robot.robot_id)[1]
reachable_at_time = {start_time: {start_node}}
visited = {start_node} # Prevent revisiting previously reached nodes
# Match QUBOBuilder loop range: start at start_time + 1, end at end_time
for t in range(start_time + 1, end_time):
prev_layer = reachable_at_time[t - 1]
curr_layer = set()
for node_i in prev_layer:
for node_j, _ in self.graph.adjacency.get(node_i, []):
# Only expand to new nodes not yet visited
if node_j not in visited:
curr_layer.add(node_j)
visited.add(node_j)
# Stop early if no new nodes are reachable
if not curr_layer:
break
# To make sure goal is always reachable (and not conflict with goal lock)
if goal_node in visited:
curr_layer.add(goal_node)
reachable_at_time[t] = curr_layer
# print(reachable_at_time)
return reachable_at_time
# def apply_multi_robot_proximity_penalty(self):
# """Apply proximity penalty: discourage robots from being too close to each other."""
# K_proximity = self.penalties.get('K_proximity', 2)
# # For each time step, check robot proximity
# for t in range(self.T):
# for r1_num in range(self.problem.num_robots):
# for r2_num in range(r1_num + 1, self.problem.num_robots):
# robot1_offset = r1_num * (self.num_nodes * self.T)
# robot2_offset = r2_num * (self.num_nodes * self.T)
# # Check all node pairs for proximity
# for node1_id in range(self.num_nodes):
# for node2_id in range(self.num_nodes):
# # Skip if same node (handled by collision penalty)
# if node1_id == node2_id:
# continue
# # Check if nodes are adjacent (proximity)
# is_adjacent = False
# for (adj_node, _) in self.graph.adjacency.get(node1_id, []):
# if adj_node == node2_id:
# is_adjacent = True
# break
# if is_adjacent:
# idx1 = node1_id + (self.num_nodes * t) + robot1_offset
# idx2 = node2_id + (self.num_nodes * t) + robot2_offset
# # Add penalty for robots being adjacent
# self.Q[(idx1, idx2)] = self.Q.get((idx1, idx2), 0) + K_proximity
|