File size: 30,798 Bytes
e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 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 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 | """
qubo_cli.py β Command-line interface for the Quantum QUBO Pathfinding solver.
This script exposes all runtime parameters via argparse, making it suitable
for deployment, scripted experiments, and integration with external systems.
For a hands-on Python example with inline comments and full control, see
qubo.py instead. This script mirrors its logic but driven entirely by CLI args.
Usage examples:
# Basic solve with DWave
python qubo_cli.py --map maps/synthetic/10x10/obs10x10_hard --problem four_robots --solver dwave
# Benchmark run with PennyLane on GPU
python qubo_cli.py --map maps/synthetic/5x5/obs5x5 --solver pennylane --device lightning.gpu --benchmark --num-runs 5
# Benchmark run with PennyLane on Windows (no lightning.gpu wheels; use lightning.qubit)
python qubo_cli.py --map maps/synthetic/5x5/obs5x5 --solver pennylane --device lightning.qubit --benchmark --num-runs 5
# Graph-based problem
python qubo_cli.py --map maps/graph/city --problem two_robots --builder graph --solver dwave --penalty-set graph
# Override penalties individually
python qubo_cli.py --map maps/synthetic/10x10/obs10x10_hard --K-hot 9 --K-adj 4.8 --K-start 6.5 --K-goal 3.0
# Suppress all output (silent mode)
python qubo_cli.py --map maps/synthetic/10x10/obs10x10_hard --verbose 0
# Solve and open an animated visualization in the browser
python qubo_cli.py --map maps/synthetic/5x5/obs5x5 --problem two_robots --visualize
# Solve and save the animation as a GIF (or .html for interactive)
python qubo_cli.py --map maps/synthetic/5x5/obs5x5 --problem two_robots --visualize -o run.gif
"""
import argparse
import sys
from pathlib import Path
from pennylane import numpy as np
from quantum.solvers import SolverFactory
from quantum.pathFormulation import PathfindingProblem
import quantum.config.parser as config_parser
from quantum.builder import (
QUBOBuilder, GraphQUBO, GridILPBuilder, GraphILPBuilder,
GridCBSBuilder, GraphCBSBuilder,
)
import quantum.benchmark.benchmark as bm_module
from quantum.utils.logger import set_verbose_level, get_logger
from quantum.utils.paths import clip_path_at_goal
import time
_HERE = Path(__file__).parent # quantum/
# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="qubo_cli",
description=(
"Quantum QUBO Pathfinding solver CLI.\n\n"
"For a fully-annotated Python example, see qubo.py."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
# ---- Problem definition ------------------------------------------------
prob = parser.add_argument_group("Problem")
prob.add_argument(
"--map",
"-m",
required=True,
metavar="PATH",
help="Path (without extension) to the map config, e.g. maps/synthetic/10x10/obs10x10_hard",
)
prob.add_argument(
"--problem",
"-p",
default="four_robots",
metavar="NAME",
help="Problem name defined inside the map config (default: four_robots)",
)
prob.add_argument(
"--builder",
"-b",
choices=["grid", "graph"],
default="grid",
help="QUBO builder type: 'grid' (QUBOBuilder) or 'graph' (GraphQUBO) (default: grid)",
)
prob.add_argument(
"--distance-scaling",
default="enhanced_linear",
metavar="MODE",
help="Distance scaling mode passed to QUBOBuilder (default: enhanced_linear)",
)
prob.add_argument(
"--window-limit",
default=[],
nargs="+",
metavar="N or ROBOT=N",
help=(
"Window step limit. Pass a single integer to cap all robots globally "
"(e.g. --window-limit 6), or 'robot_id=N' pairs for per-robot limits "
"(e.g. --window-limit robot_0=5 robot_1=3)."
),
)
prob.add_argument(
"--var-limit",
type=int,
default=None,
metavar="N",
help="Variable limit passed to QUBO builders. (default: Grid=1650, Graph=1201)",
)
prob.add_argument(
"--coordinate-format",
choices=["matrix", "cartesian"],
default="matrix",
help=(
"Coordinate convention for start/goal in the problem config and for "
"printed/visualized output paths: 'matrix' (row, col), Spooky's native "
"convention (default), or 'cartesian' (x, y) robotics/Y-up. Per-robot "
"'coordinate_format' entries in the map YAML take precedence over this."
),
)
prob.add_argument(
"--no-reduction-log",
action="store_true",
default=False,
help=(
"Disable reduction logging during QUBO preprocessing. "
"Faster, but prevents BFS-based variable unfixing during diagonal reduction. "
"Use to benchmark the overhead of reduction tracking."
),
)
# ---- Penalty set -------------------------------------------------------
pen = parser.add_argument_group("Penalties")
pen.add_argument(
"--penalty-set",
default="swap",
metavar="SET",
help=(
"Named penalty set from config.yaml to use as base "
"(default: swap). Overridden by individual --K-* flags."
),
)
# Individual overrides β if given, they take precedence over the set
pen.add_argument(
"--K-hot",
type=float,
default=None,
metavar="VAL",
help="Override K_hot penalty",
)
pen.add_argument(
"--K-adj",
type=float,
default=None,
metavar="VAL",
help="Override K_adj penalty",
)
pen.add_argument(
"--K-start",
type=float,
default=None,
metavar="VAL",
help="Override K_start penalty",
)
pen.add_argument(
"--K-goal",
type=float,
default=None,
metavar="VAL",
help="Override K_goal penalty",
)
pen.add_argument(
"--K-lock",
type=float,
default=None,
metavar="VAL",
help="Override K_lock penalty",
)
pen.add_argument(
"--K-bt", type=float, default=None, metavar="VAL", help="Override K_bt penalty"
)
pen.add_argument(
"--K-tp", type=float, default=None, metavar="VAL", help="Override K_tp penalty"
)
pen.add_argument(
"--K-crash",
type=float,
default=None,
metavar="VAL",
help="Override K_crash penalty",
)
pen.add_argument(
"--K-swap",
type=float,
default=None,
metavar="VAL",
help="Override K_swap penalty",
)
pen.add_argument(
"--K-obs",
type=float,
default=None,
metavar="VAL",
help="Override K_obs penalty",
)
pen.add_argument(
"--K-goal-approx",
type=float,
default=None,
metavar="VAL",
help="Override K_goal_approx penalty",
)
# ---- Solver ------------------------------------------------------------
sol = parser.add_argument_group("Solver")
sol.add_argument(
"--solver",
"-s",
choices=["dwave", "pennylane", "qiskit_remote", "qiskit_iqm", "ilp", "cbs"],
default="dwave",
help="Solver backend (default: dwave)",
)
sol.add_argument(
"--normalize-scale",
type=float,
default=None,
metavar="N",
help=(
"QUBO normalization scale factor. "
"Defaults: dwave=4.0, pennylane=1.0. "
"See qubo.py comments for per-qubit guidance."
),
)
sol.add_argument(
"--num-reads",
default=None,
metavar="N|auto",
help=(
"Number of solver reads. Pass an integer or 'auto' "
"(default: dwave=4, pennylane=auto)"
),
)
sol.add_argument(
"--no-preprocess",
action="store_true",
default=False,
help=(
"Disable variable reduction (QUBO: BFS logical-variable reduction, "
"diagonal pruning, and the correction loop, runs the simple "
"raw-sampler loop instead. ILP: BFS reachability pruning of the "
"decision variables, solves the unpruned model instead). Enabled "
"by default for both."
),
)
sol.add_argument(
"--pyomo-solver",
default="appsi_highs",
metavar="NAME",
help=(
"Pyomo solver backend name (only used with --solver ilp), "
"e.g. 'appsi_highs' (default), 'cbc', 'glpk'."
),
)
sol.add_argument(
"--time-limit",
type=float,
default=30,
metavar="SECONDS",
help=(
"Solver time limit in seconds (--solver ilp or cbs). ILP/HiGHS "
"returns its best incumbent found so far if the limit is hit "
"before proving optimality; CBS returns its best incumbent "
"found in its constraint-tree search so far. Default: 30."
),
)
sol.add_argument(
"--node-limit",
type=int,
default=5000,
metavar="N",
help=(
"Max constraint-tree nodes CBS will expand (only used with "
"--solver cbs) β a second safety cap alongside --time-limit, "
"whichever is hit first stops the search. Default: 5000."
),
)
# PennyLane / QAOA-specific
pl = parser.add_argument_group(
"PennyLane / QAOA (only used when --solver pennylane)"
)
pl.add_argument(
"--device",
default="lightning.gpu",
metavar="DEV",
help=(
"PennyLane device string, e.g. 'lightning.gpu', 'lightning.qubit', "
"'qiskit.remote' (default: lightning.gpu)"
),
)
pl.add_argument(
"--threads",
type=int,
default=None,
metavar="N",
help=(
"CPU thread count for lightning.qubit's OpenMP backend "
"(default.qubit/lightning.gpu unaffected). Default: unset, uses "
"every available core (OpenMP's own default)."
),
)
pl.add_argument(
"--layers",
type=int,
default=2,
metavar="N",
help="Number of QAOA layers (default: 2)",
)
pl.add_argument(
"--optimizer",
default="QNG",
metavar="OPT",
help="Optimizer name passed to PennyLane solver (default: QNG)",
)
pl.add_argument(
"--opt-steps",
type=int,
default=30,
metavar="N",
help="Number of optimizer steps (default: 30)",
)
pl.add_argument(
"--init-params",
default=None,
metavar="FILE",
help=(
"Path to a .npy file containing initial QAOA parameters. "
"If omitted, the built-in default params from qubo.py are used."
),
)
pl.add_argument(
"--machine",
default=None,
metavar="NAME",
help=(
"Pin a specific hardware backend/machine (only used with "
"--device qiskit.remote or qiskit.iqm / --solver qiskit_remote or "
"qiskit_iqm). IBM: an exact backend name, e.g. 'ibm_torino' "
"(default: least_busy). IQM: 'sirius', 'garnet', or 'emerald' "
"(default: auto-picks the smallest that fits the window)."
),
)
# ---- Run mode ----------------------------------------------------------
run = parser.add_argument_group("Run mode")
run_ex = run.add_mutually_exclusive_group()
run_ex.add_argument(
"--benchmark",
action="store_true",
help=(
"Run in benchmark mode (multiple runs, saves JSON results with path validation). "
"Use --num-runs 1 as a single validated solve with path checking instead of bare --solve."
),
)
run_ex.add_argument(
"--solve",
action="store_true",
default=True,
help="[default] Run a single solve and print the decoded path",
)
run.add_argument(
"--num-runs",
type=int,
default=10,
metavar="N",
help="Number of benchmark runs (only used with --benchmark, default: 10)",
)
run.add_argument(
"--clip-at-goal",
action="store_true",
help=(
"Trim each robot's printed path once it's parked at goal, keeping "
"only the first arrival (output-only; doesn't affect solving/windowing). "
"Useful for feeding paths to an external planner."
),
)
run.add_argument(
"--benchmark-level",
type=int,
choices=[1, 2, 3],
default=2,
metavar="1|2|3",
help=(
"Benchmark output detail level: "
"1=Summary only, 2=+Paths, 3=+Raw bits (default: 2)"
),
)
# ---- Visualization -----------------------------------------------------
viz_g = parser.add_argument_group("Visualization (single-solve mode only)")
viz_g.add_argument(
"--visualize",
nargs="?",
const="animated",
choices=["animated", "static", "steps"],
default=None,
metavar="MODE",
help=(
"Visualize the solved paths: 'animated' (default), 'static', or "
"'steps'. Opens a browser window unless -o/--output is given."
),
)
viz_g.add_argument(
"--output",
"-o",
default=None,
metavar="FILE",
help=(
"Save the visualization instead of opening a browser. Format from "
"extension: .html (interactive), .gif (animated mode only), "
".png/.svg/.pdf (static image via kaleido)."
),
)
viz_g.add_argument(
"--viz-discrete",
action="store_true",
help=(
"Animate the raw discrete timeline (one frame per QUBO timestep) "
"instead of smooth interpolated motion."
),
)
# ---- Config & misc -----------------------------------------------------
misc = parser.add_argument_group("Config & misc")
misc.add_argument(
"--config",
default=str(_HERE / "config/config.yaml"),
metavar="FILE",
help="Path to the main YAML config file (default: <package>/config/config.yaml)",
)
misc.add_argument(
"--materials",
default=str(_HERE / "config/materials.yaml"),
metavar="FILE",
help="Path to the materials YAML file (default: <package>/config/materials.yaml)",
)
misc.add_argument(
"--verbose",
"-v",
type=int,
choices=[0, 1, 2, 3],
default=None,
metavar="0-3",
help=(
"Verbose level: 0=Silent, 1=Minimal, 2=Standard, 3=Debug. "
"Overrides the value in config.yaml."
),
)
return parser
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def parse_window_limits(raw: list[str], robot_ids) -> dict:
"""
Parse window limit entries into {robot_id: max_steps}.
Accepts either:
- A single integer to cap all robots: ['6']
- Per-robot pairs: ['robot_0=5', 'robot_1=3']
"""
if not raw:
return {}
if len(raw) == 1 and "=" not in raw[0]:
try:
n = int(raw[0])
except ValueError:
raise argparse.ArgumentTypeError(
f"Invalid --window-limit value '{raw[0]}'. "
f"Expected an integer or 'robot_id=N' pairs."
)
return {robot_id: n for robot_id in robot_ids}
limits = {}
for entry in raw:
if "=" not in entry:
raise argparse.ArgumentTypeError(
f"Invalid --window-limit format '{entry}'. "
f"Use a single integer for a global limit or 'robot_id=N' pairs."
)
robot_id, n = entry.split("=", 1)
limits[robot_id.strip()] = int(n.strip())
return limits
def build_penalties(config: dict, args: argparse.Namespace) -> dict:
"""
Start from the named penalty set in config.yaml, then apply any individual
--K-* overrides supplied via the CLI.
"""
penalties = dict(config["penalty_sets"][args.penalty_set])
penalties.setdefault("name", args.penalty_set)
overrides = {
"K_hot": args.K_hot,
"K_adj": args.K_adj,
"K_start": args.K_start,
"K_goal": args.K_goal,
"K_lock": args.K_lock,
"K_bt": args.K_bt,
"K_tp": args.K_tp,
"K_crash": args.K_crash,
"K_swap": args.K_swap,
"K_obs": args.K_obs,
"K_goal_approx": args.K_goal_approx,
}
for key, val in overrides.items():
if val is not None:
penalties[key] = val
return penalties
def build_solver(args: argparse.Namespace, verbose_level: int):
"""Instantiate the correct solver from CLI arguments."""
logger = get_logger()
if args.solver == "dwave":
norm_scale = args.normalize_scale if args.normalize_scale is not None else 4.0
num_reads = (
int(args.num_reads) if args.num_reads and args.num_reads != "auto" else 4
)
logger.minimal(f"Creating DWave solver (scale={norm_scale}, reads={num_reads})")
return SolverFactory.create_solver(
solver="dwave",
normalize_scale=norm_scale,
num_reads=num_reads,
)
elif args.solver == "pennylane":
norm_scale = args.normalize_scale if args.normalize_scale is not None else 1.0
num_reads = (
int(args.num_reads)
if args.num_reads and args.num_reads != "auto"
else "auto"
)
# Initial QAOA params β load from file or use the defaults from qubo.py
if args.init_params:
init_params = np.load(args.init_params, allow_pickle=False)
init_params = np.array(init_params, requires_grad=True)
logger.minimal(f"Loaded init_params from {args.init_params}")
else:
# Default params tuned for 2-layer QAOA (see qubo.py for context)
init_params = np.array(
[[1.70579, 0.70321062], [0.49879231, 0.49412656]],
requires_grad=True,
)
logger.minimal(
f"Creating PennyLane solver (device={args.device}, "
f"layers={args.layers}, optimizer={args.optimizer}, "
f"steps={args.opt_steps}, scale={norm_scale})"
)
return SolverFactory.create_solver(
solver="pennylane",
normalize_scale=norm_scale,
num_reads=num_reads,
layers=args.layers,
optimizer=args.optimizer,
opt_steps=args.opt_steps,
device=args.device,
params=init_params,
verbose_level=verbose_level,
machine=args.machine,
threads=args.threads,
)
elif args.solver == "qiskit_remote":
# Mirrors the qiskit_hardware setup in qubo.py:
# SolverFactory.create_solver(solver="pennylane", device="qiskit.remote", ...)
# normalize_scale defaults to 4.0 (same as qubo.py's qiskit_hardware).
norm_scale = args.normalize_scale if args.normalize_scale is not None else 4.0
num_reads = (
int(args.num_reads)
if args.num_reads and args.num_reads != "auto"
else "auto"
)
device = args.device if args.device != "lightning.gpu" else "qiskit.remote"
if args.init_params:
init_params = np.load(args.init_params, allow_pickle=False)
init_params = np.array(init_params, requires_grad=True)
logger.minimal(f"Loaded init_params from {args.init_params}")
else:
init_params = np.array(
[[1.70579, 0.70321062], [0.49879231, 0.49412656]],
requires_grad=True,
)
logger.minimal(
f"Creating Qiskit-remote solver via PennyLane "
f"(device={device}, layers={args.layers}, "
f"optimizer={args.optimizer}, steps={args.opt_steps}, scale={norm_scale}, "
f"machine={args.machine or 'auto (least_busy)'})"
)
return SolverFactory.create_solver(
solver="pennylane",
normalize_scale=norm_scale,
num_reads=num_reads,
layers=args.layers,
optimizer=args.optimizer,
opt_steps=args.opt_steps,
device=device,
params=init_params,
verbose_level=verbose_level,
machine=args.machine,
)
elif args.solver == "qiskit_iqm":
norm_scale = args.normalize_scale if args.normalize_scale is not None else 4.0
num_reads = (
int(args.num_reads)
if args.num_reads and args.num_reads != "auto"
else "auto"
)
device = args.device if args.device != "lightning.gpu" else "qiskit.iqm"
if args.init_params:
init_params = np.load(args.init_params, allow_pickle=False)
init_params = np.array(init_params, requires_grad=True)
logger.minimal(f"Loaded init_params from {args.init_params}")
else:
init_params = np.array(
[[1.70579, 0.70321062], [0.49879231, 0.49412656]],
requires_grad=True,
)
logger.minimal(
f"Creating IQM solver via PennyLane "
f"(device={device}, layers={args.layers}, "
f"optimizer={args.optimizer}, steps={args.opt_steps}, scale={norm_scale}, "
f"machine={args.machine or 'auto (smallest tier that fits)'})"
)
return SolverFactory.create_solver(
solver="pennylane",
normalize_scale=norm_scale,
num_reads=num_reads,
layers=args.layers,
optimizer=args.optimizer,
opt_steps=args.opt_steps,
device=device,
params=init_params,
verbose_level=verbose_level,
machine=args.machine,
)
elif args.solver == "ilp":
logger.minimal(
f"Creating ILP solver (pyomo backend={args.pyomo_solver}, "
f"time_limit={args.time_limit}s)"
)
return SolverFactory.create_solver(
solver="ilp",
pyomo_solver_name=args.pyomo_solver,
time_limit=args.time_limit,
)
elif args.solver == "cbs":
logger.minimal(
f"Creating CBS solver (node_limit={args.node_limit}, "
f"time_limit={args.time_limit}s)"
)
return SolverFactory.create_solver(
solver="cbs",
node_limit=args.node_limit,
time_limit=args.time_limit,
)
else:
raise ValueError(f"Unknown solver: {args.solver}")
def run_visualization(args: argparse.Namespace, problem, robot_paths: dict) -> None:
"""
Render the solved paths per --visualize / --output.
No --output: opens the figure in the default browser (plotly's fig.show()).
With --output: saves to the file, format chosen by extension.
"""
logger = get_logger()
from quantum.visualizer import QuantumRoboticsVisualizer
if not robot_paths:
logger.minimal("[viz] No robot paths to visualize.")
return
mode = args.visualize
out = args.output
if out and out.lower().endswith(".gif") and mode != "animated":
logger.minimal(
f"[viz] GIF export requires the animated mode β switching from '{mode}'."
)
mode = "animated"
viz = QuantumRoboticsVisualizer(
(problem.grid.M, problem.grid.N),
title=f"{args.problem} β {Path(args.map).name}",
)
obstacles = problem.grid.obstacles
if mode == "static":
fig = viz.create_static_plot(
obstacles=obstacles, robot_paths=robot_paths, problem=problem
)
elif mode == "steps":
fig = viz.create_step_by_step_plot(
obstacles, robot_paths=robot_paths, problem=problem
)
else: # animated
fig = viz.create_animated_plot(
obstacles=obstacles,
robot_paths=robot_paths,
problem=problem,
smooth=not args.viz_discrete,
)
if not out:
viz.show(fig)
elif out.lower().endswith(".gif"):
viz.write_gif(fig, out)
elif out.lower().endswith((".html", ".htm")):
viz.write_html(fig, out)
else:
viz.write_image(fig, out)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = build_parser()
args = parser.parse_args()
# -- Config --------------------------------------------------------------
config = config_parser.load_config(
args.config, sections=["penalty_sets", "verbose"]
)
# Verbose: CLI > config.yaml
verbose_level = (
args.verbose if args.verbose is not None else config["verbose"]["level"]
)
set_verbose_level(verbose_level)
logger = get_logger()
logger.minimal(f"qubo_cli starting | map={args.map} | problem={args.problem}")
# -- Materials -----------------------------------------------------------
materials_data = config_parser.load_config(args.materials)["materials"]
# -- Problem -------------------------------------------------------------
problem = PathfindingProblem.from_map_config(
args.map,
problem_name=args.problem,
materials_data=materials_data,
coordinate_format=args.coordinate_format,
)
# -- Penalties -----------------------------------------------------------
if args.penalty_set not in config["penalty_sets"]:
logger.minimal(
f"[ERROR] Penalty set '{args.penalty_set}' not found in {args.config}. "
f"Available: {list(config['penalty_sets'].keys())}"
)
sys.exit(1)
penalties = build_penalties(config, args)
logger.minimal(f"Using penalty set: {args.penalty_set} | effective: {penalties}")
# -- Window limits -------------------------------------------------------
window_limits = parse_window_limits(args.window_limit, problem.robots.keys())
# -- Builder -------------------------------------------------------------
builder_kwargs = {
"penalties": penalties,
"name": args.problem,
"robot_window_limits": window_limits if window_limits else None,
"log_reductions": not args.no_reduction_log,
}
if args.var_limit is not None:
builder_kwargs["var_limit"] = args.var_limit
if args.solver == "ilp":
# ILP has no penalty weights, var_limit, or windowing β builder_kwargs
# (penalties/var_limit/robot_window_limits/log_reductions) don't apply.
if args.builder == "grid":
p = problem.as_grid_only()
builder = GridILPBuilder(p, name=args.problem, verbose_level=verbose_level)
else: # graph
p = problem.as_graph_only()
builder = GraphILPBuilder(p, name=args.problem, verbose_level=verbose_level)
elif args.solver == "cbs":
# CBS has no penalty weights, var_limit, or windowing either β same
# reasoning as ILP above.
if args.builder == "grid":
p = problem.as_grid_only()
builder = GridCBSBuilder(p, name=args.problem, verbose_level=verbose_level)
else: # graph
p = problem.as_graph_only()
builder = GraphCBSBuilder(p, name=args.problem, verbose_level=verbose_level)
elif args.builder == "grid":
p = problem.as_grid_only()
builder_kwargs["distance_scaling"] = args.distance_scaling
builder = QUBOBuilder(p, **builder_kwargs)
else: # graph
p = problem.as_graph_only()
builder = GraphQUBO(p, **builder_kwargs)
logger.minimal(
f"Builder: {args.builder.upper()} | window_limits={window_limits or 'none'}"
)
# -- Solver --------------------------------------------------------------
solver = build_solver(args, verbose_level)
# -- Run mode ------------------------------------------------------------
if args.benchmark:
if args.visualize:
logger.minimal(
"[viz] --visualize is only available in single-solve mode; ignoring."
)
logger.minimal(
f"Running benchmark: {args.num_runs} runs, level {args.benchmark_level}"
)
runner = bm_module.BenchmarkRunner(
builder,
solver,
num_runs=args.num_runs,
level=args.benchmark_level,
preprocess=not args.no_preprocess,
)
runner.run_build()
else:
# Single solve
timer = time.time()
# ILP builders rebuild themselves inside solver.solve() (see
# ILPSolver.solve()) so the preprocess flag always takes effect;
# pre-building here would just duplicate work and logging.
if not hasattr(builder, "local_index"):
builder.build()
solution = solver.solve(builder, preprocess=not args.no_preprocess)
# Use p (the grid-only/graph-only problem actually passed to the
# builder), not problem
path = solver.decode_path(solution["solution"], p)
energy = solution["energy"]
if isinstance(energy, list):
energy = sum(energy)
logger.debug(
f"Raw path: {path}"
) # Full decoded tuples β only useful at verbose=3
logger.minimal(f"Energy: {energy:.4f}")
logger.minimal(f"Time: {time.time() - timer:.4f}")
for robot_id, robot in problem.robots.items():
robot_path = robot.path
if args.clip_at_goal:
robot_path = clip_path_at_goal(robot_path, tuple(robot.goal))
formatted_path = [
(*robot.format_position((i, j)), t) for i, j, t in robot_path
]
logger.minimal(f" [{robot_id}] {formatted_path}")
if args.visualize:
# visualizer.py expects native matrix (row, col) input regardless of
# --coordinate-format, so pass the raw decoded path, not a formatted one.
run_visualization(args, problem, solver.get_robot_paths(path))
if __name__ == "__main__":
main()
|