From 55157507683ea78d9a93cc478b4b1575d954133c Mon Sep 17 00:00:00 2001 From: Trevor McKay Date: Mon, 31 Aug 2026 16:17:22 -0400 Subject: [PATCH] Forward routing gRPC settings and map node name strings to ints - Forward verbose_mode and error_logging from SolverSettings and dicts and expose SolverSettings getters so the client can read those flags - Map named initial-solution node types to wire integers --- .../cuopt/cuopt/grpc/client/grpc_client.pyx | 99 ++++++++++++++++--- .../cuopt/cuopt/routing/vehicle_routing.pxd | 4 +- python/cuopt/cuopt/routing/vehicle_routing.py | 10 ++ .../cuopt/routing/vehicle_routing_wrapper.pyx | 8 +- .../test_routing_grpc_serialization.py | 63 ++++++++++++ 5 files changed, 169 insertions(+), 15 deletions(-) diff --git a/python/cuopt/cuopt/grpc/client/grpc_client.pyx b/python/cuopt/cuopt/grpc/client/grpc_client.pyx index 6e1909fc2f..3f9cdcb0e5 100644 --- a/python/cuopt/cuopt/grpc/client/grpc_client.pyx +++ b/python/cuopt/cuopt/grpc/client/grpc_client.pyx @@ -731,6 +731,52 @@ def _to_host(x): return np.asarray(x) +_NODE_TYPE_NAMES = { + "Depot": 0, + "Pickup": 1, + "Delivery": 2, + "Break": 3, +} + + +def _is_node_type_name_array(values): + """True when values are node-type names rather than wire integers. + + Integer and unsigned arrays (including uint8, the local DataModel storage + type) pass through. Name arrays are Unicode (U), byte strings (S), NumPy 2 + variable-width strings (T), or object arrays of str/bytes. Object arrays of + ints must not take the name path. + """ + kind = values.dtype.kind + if kind in "UST": + return True + if kind != "O" or values.size == 0: + return False + # Types are a 1-D sequence. Flatten only for a stray column (n, 1). + sample = values[0] if values.ndim == 1 else values.reshape(-1)[0] + return isinstance(sample, (str, bytes, np.str_, np.bytes_)) + + +def _node_type_name(value): + if isinstance(value, bytes): + return value.decode("utf-8") + return value + + +def _routing_node_types(values): + """Normalize routing node names or enum values to wire integers.""" + values = np.asarray(_to_host(values)) + if not _is_node_type_name_array(values): + return values + try: + return np.asarray( + [_NODE_TYPE_NAMES[_node_type_name(value)] for value in values.ravel()], + dtype=np.int32, + ) + except KeyError as error: + raise ValueError(f"unknown routing node type {error.args[0]!r}") from error + + # --- numpy -> std::vector fillers ------------------------------------------ cdef void _fill_i32(vector[int32_t]& v, arr) except *: @@ -868,7 +914,9 @@ cdef void _populate(cpu_routing_problem_t& p, data_model) except *: elif name == "add_initial_solutions": _fill_i32(p.initial_solutions.vehicle_ids, args[0]) _fill_i32(p.initial_solutions.routes, args[1]) - _fill_i32(p.initial_solutions.types, args[2]) + _fill_i32( + p.initial_solutions.types, _routing_node_types(args[2]) + ) _fill_i32(p.initial_solutions.sol_offsets, args[3]) elif name == "set_min_vehicles": p.min_vehicles = int(args[0]) @@ -965,6 +1013,42 @@ cdef _solution_to_py(cpu_routing_solution_t s): } +# dump_best_results is intentionally not forwarded. The proto and C++ mapper +# already carry dump_best_results_path, but that writes a debug file on the +# gRPC server host rather than returning data to the client. Wire it up later +# if a remote-debug use case appears. +cdef void _apply_routing_settings( + routing_solver_settings_t[int, float]& s, settings +) except *: + if settings is None: + return + if isinstance(settings, dict): + tl = settings.get("time_limit") + if tl is not None: + s.set_time_limit(float(tl)) + verbose = settings.get("verbose_mode", settings.get("verbose")) + if verbose is not None: + s.set_verbose_mode(bool(verbose)) + error_logging = settings.get("error_logging") + if error_logging is not None: + s.set_error_logging_mode(bool(error_logging)) + return + + get_time_limit = getattr(settings, "get_time_limit", None) + if get_time_limit is not None: + tl = get_time_limit() + if tl is not None: + s.set_time_limit(float(tl)) + get_verbose_mode = getattr(settings, "get_verbose_mode", None) + if get_verbose_mode is not None: + s.set_verbose_mode(bool(get_verbose_mode())) + get_error_logging_mode = getattr( + settings, "get_error_logging_mode", None + ) + if get_error_logging_mode is not None: + s.set_error_logging_mode(bool(get_error_logging_mode())) + + cdef class RoutingClient: """Client for solving VRP problems on a remote cuOpt gRPC server.""" @@ -996,18 +1080,7 @@ cdef class RoutingClient: ) cdef _apply_settings(self, routing_solver_settings_t[int, float]& s, settings): - if settings is None: - return - if isinstance(settings, dict): - tl = settings.get("time_limit") - if tl is not None: - s.set_time_limit(float(tl)) - return - get_time_limit = getattr(settings, "get_time_limit", None) - if get_time_limit is not None: - tl = get_time_limit() - if tl is not None: - s.set_time_limit(float(tl)) + _apply_routing_settings(s, settings) def submit(self, data_model, settings=None): """Serialize and submit a VRP problem; return its ``job_id``.""" diff --git a/python/cuopt/cuopt/routing/vehicle_routing.pxd b/python/cuopt/cuopt/routing/vehicle_routing.pxd index 7f89d33ff8..d5e714a41e 100644 --- a/python/cuopt/cuopt/routing/vehicle_routing.pxd +++ b/python/cuopt/cuopt/routing/vehicle_routing.pxd @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 @@ -128,6 +128,8 @@ cdef extern from "cuopt/routing/solve.hpp" namespace "cuopt::routing": void dump_best_results(const string &file_path, i_t interval) except+ f_t get_time_limit() except+ + bool get_verbose_mode() except+ + bool get_error_logging_mode() except+ cdef extern from "cuopt/routing/cython/cython.hpp" namespace "cuopt::cython": # noqa cdef unique_ptr[vehicle_routing_ret_t] call_solve( diff --git a/python/cuopt/cuopt/routing/vehicle_routing.py b/python/cuopt/cuopt/routing/vehicle_routing.py index bff3aefc22..0f0433f6c9 100644 --- a/python/cuopt/cuopt/routing/vehicle_routing.py +++ b/python/cuopt/cuopt/routing/vehicle_routing.py @@ -1510,6 +1510,16 @@ def get_time_limit(self): """ return super().get_time_limit() + @catch_cuopt_exception + def get_verbose_mode(self): + """Return whether verbose solver output is enabled.""" + return super().get_verbose_mode() + + @catch_cuopt_exception + def get_error_logging_mode(self): + """Return whether constraint error logging is enabled.""" + return super().get_error_logging_mode() + @catch_cuopt_exception def get_best_results_file_path(self): """ diff --git a/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx b/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx index a290132d50..972ffd86b0 100644 --- a/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx +++ b/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 @@ -788,6 +788,12 @@ cdef class SolverSettings: def get_time_limit(self): return self.c_solver_settings.get().get_time_limit() + def get_verbose_mode(self): + return self.c_solver_settings.get().get_verbose_mode() + + def get_error_logging_mode(self): + return self.c_solver_settings.get().get_error_logging_mode() + def get_best_results_file_path(self): return self.file_path diff --git a/python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py b/python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py index fa43287ee2..e7e4abfd16 100644 --- a/python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py +++ b/python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py @@ -115,6 +115,69 @@ def test_populate_breaks(): assert s["uniform_breaks"] == 1 +def test_populate_initial_solution_node_type_names(): + dm = routing.DataModel(3, 1, 2) + dm.add_cost_matrix(np.eye(3, dtype=np.float32)) + dm.add_initial_solutions( + np.array([0, 0, 0, 0], np.int32), + np.array([0, 0, 1, 0], np.int32), + np.array(["Depot", "Pickup", "Delivery", "Depot"]), + np.array([0, 4], np.int32), + ) + assert problem_summary(dm)["initial_solutions_routes"] == 4 + + +def test_routing_node_types_accepts_string_and_integer_arrays(): + from cuopt.grpc.client import grpc_client as grpc_native + + names = ["Depot", "Pickup", "Delivery", "Break"] + expected = np.array([0, 1, 2, 3], dtype=np.int32) + assert np.array_equal(grpc_native._routing_node_types(names), expected) + assert np.array_equal( + grpc_native._routing_node_types(np.array(names, dtype=object)), + expected, + ) + assert np.array_equal( + grpc_native._routing_node_types(np.array(names)), expected + ) + assert np.array_equal( + grpc_native._routing_node_types( + np.array([b"Depot", b"Pickup", b"Delivery", b"Break"]) + ), + expected, + ) + assert np.array_equal(grpc_native._routing_node_types(expected), expected) + assert np.array_equal( + grpc_native._routing_node_types(expected.astype(np.uint8)), + expected.astype(np.uint8), + ) + assert np.array_equal( + grpc_native._routing_node_types(np.array([0, 1, 2, 3], dtype=object)), + np.array([0, 1, 2, 3], dtype=object), + ) + string_dtype = getattr(np.dtypes, "StringDType", None) + if string_dtype is not None: + assert np.array_equal( + grpc_native._routing_node_types( + np.array(names, dtype=string_dtype()) + ), + expected, + ) + + +def test_routing_settings_object_exposes_values_the_client_forwards(): + # RoutingClient copies these through get_time_limit / get_verbose_mode / + # get_error_logging_mode. The dict branch of _apply_routing_settings reads + # the same fields as time_limit, verbose_mode (or verbose), and error_logging. + settings = routing.SolverSettings() + settings.set_time_limit(3.5) + settings.set_verbose_mode(True) + settings.set_error_logging_mode(False) + assert settings.get_time_limit() == 3.5 + assert settings.get_verbose_mode() is True + assert settings.get_error_logging_mode() is False + + def test_populate_handles_pandas_host_inputs(): """Pandas (host) inputs map identically to numpy.