diff --git a/python/cuopt/cuopt/grpc/CMakeLists.txt b/python/cuopt/cuopt/grpc/CMakeLists.txt index 0f3c37f565..f7dd540097 100644 --- a/python/cuopt/cuopt/grpc/CMakeLists.txt +++ b/python/cuopt/cuopt/grpc/CMakeLists.txt @@ -3,5 +3,4 @@ # SPDX-License-Identifier: Apache-2.0 # cmake-format: on -add_subdirectory(linear_programming) -add_subdirectory(routing) +add_subdirectory(client) diff --git a/python/cuopt/cuopt/grpc/__init__.py b/python/cuopt/cuopt/grpc/__init__.py index 9c1baaa6f1..a7a98bcccb 100644 --- a/python/cuopt/cuopt/grpc/__init__.py +++ b/python/cuopt/cuopt/grpc/__init__.py @@ -3,13 +3,19 @@ """gRPC clients for remote cuOpt execution. -This package is the namespace for domain-specific async clients: +This package is the namespace for domain-specific clients: - :mod:`cuopt.grpc.linear_programming` — LP/MILP/QP (submit, result, incumbents) -- :mod:`cuopt.grpc.routing` — VRP/TSP/PDP (future) +- :mod:`cuopt.grpc.routing` — VRP/TSP/PDP -Shared job lifecycle (connect, status, wait, cancel, delete, logs) may live -here later as a base client type. Import domain clients explicitly, e.g. +Both are re-exports from :mod:`cuopt.grpc.client`, a single extension module. +They wrap the same C++ client object, so they are compiled as one unit; the +split into two public modules is an API boundary, not a packaging one. Keeping +one unit is what allows the client to be detached from the solver engines as a +single GPU-free package later — two units would each need their own copy of the +transport layer. + +Import domain clients explicitly, e.g. ``from cuopt.grpc.linear_programming import Client``. Do not re-export ``Client`` from this package — callers must choose the diff --git a/python/cuopt/cuopt/grpc/linear_programming/CMakeLists.txt b/python/cuopt/cuopt/grpc/client/CMakeLists.txt similarity index 60% rename from python/cuopt/cuopt/grpc/linear_programming/CMakeLists.txt rename to python/cuopt/cuopt/grpc/client/CMakeLists.txt index 0f60a2afed..907b1cafee 100644 --- a/python/cuopt/cuopt/grpc/linear_programming/CMakeLists.txt +++ b/python/cuopt/cuopt/grpc/client/CMakeLists.txt @@ -3,6 +3,9 @@ # SPDX-License-Identifier: Apache-2.0 # cmake-format: on +# One extension module for both arms. See grpc_client.pyx for why they share a +# unit: they wrap the same C++ client object, and keeping them together lets the +# client be detached from the engines as a single package later. set(cython_sources grpc_client.pyx) set(linked_libraries cuopt::cuopt) @@ -10,11 +13,6 @@ rapids_cython_create_modules( CXX SOURCE_FILES "${cython_sources}" LINKED_LIBRARIES "${linked_libraries}" - MODULE_PREFIX grpc_ + MODULE_PREFIX grpc_client_ ASSOCIATED_TARGETS cuopt ) - -foreach(_cython_target IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) - target_include_directories( - ${_cython_target} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../../linear_programming/solver") -endforeach() diff --git a/python/cuopt/cuopt/grpc/client/__init__.py b/python/cuopt/cuopt/grpc/client/__init__.py new file mode 100644 index 0000000000..d83fffffc7 --- /dev/null +++ b/python/cuopt/cuopt/grpc/client/__init__.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Compiled gRPC clients for remote cuOpt solves (LP/MIP and routing). + +Prefer the domain-specific entry points, which re-export from here: +:mod:`cuopt.grpc.linear_programming` and :mod:`cuopt.grpc.routing`. +""" + +from cuopt.grpc.client.grpc_client import ( + Client, + GrpcError, + HANDLED_SETTERS, + JobNotReadyError, + JobStatus, + RoutingClient, + RoutingSolveError, + TlsConfig, + problem_summary, +) + +__all__ = [ + "Client", + "GrpcError", + "HANDLED_SETTERS", + "JobNotReadyError", + "JobStatus", + "RoutingClient", + "RoutingSolveError", + "TlsConfig", + "problem_summary", +] diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pxd b/python/cuopt/cuopt/grpc/client/grpc_client.pxd similarity index 55% rename from python/cuopt/cuopt/grpc/routing/grpc_client.pxd rename to python/cuopt/cuopt/grpc/client/grpc_client.pxd index 3099e2f3bb..2e90dea3b3 100644 --- a/python/cuopt/cuopt/grpc/routing/grpc_client.pxd +++ b/python/cuopt/cuopt/grpc/client/grpc_client.pxd @@ -1,13 +1,23 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from libc.stdint cimport int32_t, uint8_t -from libcpp cimport bool +# Single set of declarations for the one C++ client class, cuopt::cython:: +# grpc_python_client_t, which carries both the LP/MIP and the routing arms. +# These used to be declared twice -- once under grpc/linear_programming and once +# under grpc/routing -- and the two copies had already drifted (routing omitted +# is_mip and most methods, and used bool where LP used bint). + +from libc.stdint cimport int32_t, int64_t, uint8_t from libcpp.map cimport map as cpp_map from libcpp.memory cimport unique_ptr from libcpp.string cimport string from libcpp.vector cimport vector +from cuopt.linear_programming.data_model.data_model cimport data_model_view_t +from cuopt.linear_programming.solver.solver cimport solver_ret_t +from cuopt.linear_programming.solver_settings.solver_settings cimport ( + solver_settings_t as lp_solver_settings_t, +) cdef extern from "cuopt/routing/cpu_routing_problem.hpp" namespace "cuopt::routing": # noqa cdef cppclass cpu_cost_matrix_t: @@ -93,14 +103,29 @@ cdef extern from "cuopt/routing/cpu_routing_problem.hpp" namespace "cuopt::routi cdef extern from "cuopt/routing/solver_settings.hpp" namespace "cuopt::routing": # noqa - cdef cppclass solver_settings_t[i_t, f_t]: - solver_settings_t() except + + # Aliased: cuopt::routing::solver_settings_t and the LP one above share a + # name but are unrelated types. + # bint, not libcpp bool: cimporting bool into this .pxd would shadow the + # Python builtin inside grpc_client.pyx, which the LP arm calls. + cdef cppclass routing_solver_settings_t "cuopt::routing::solver_settings_t" [i_t, f_t]: # noqa + routing_solver_settings_t() except + void set_time_limit(f_t seconds) except + - void set_verbose_mode(bool verbose) except + - void set_error_logging_mode(bool logging) except + + void set_verbose_mode(bint verbose) except + + void set_error_logging_mode(bint logging) except + + + +cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": + ctypedef enum grpc_python_tls_mode_t "cuopt::cython::grpc_python_tls_mode_t": + ENV "cuopt::cython::grpc_python_tls_mode_t::ENV" + DISABLED "cuopt::cython::grpc_python_tls_mode_t::DISABLED" + EXPLICIT "cuopt::cython::grpc_python_tls_mode_t::EXPLICIT" + cdef cppclass grpc_python_client_connect_options_t: + grpc_python_tls_mode_t tls_mode + string tls_root_certs + string tls_client_cert + string tls_client_key -cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": # noqa ctypedef enum grpc_job_status_t "cuopt::cython::grpc_job_status_t": QUEUED "cuopt::cython::grpc_job_status_t::QUEUED" PROCESSING "cuopt::cython::grpc_job_status_t::PROCESSING" @@ -110,9 +135,10 @@ cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": NOT_FOUND "cuopt::cython::grpc_job_status_t::NOT_FOUND" cdef cppclass grpc_submit_result_t: - bool success + bint success string error_message string job_id + bint is_mip cdef cppclass grpc_status_result_t: bint success @@ -121,18 +147,75 @@ cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": string message long long result_size_bytes + cdef cppclass grpc_result_outcome_t: + bint not_ready + bint success + string error_message + unique_ptr[solver_ret_t] solution + cdef cppclass grpc_vrp_result_outcome_t: - bool not_ready - bool success + bint not_ready + bint success string error_message cpu_routing_solution_t solution + cdef cppclass grpc_logs_result_t: + bint success + string error_message + vector[string] lines + + cdef cppclass grpc_incumbent_entry_t: + int64_t index + double objective + vector[double] assignment + + cdef cppclass grpc_incumbents_result_t: + bint success + string error_message + vector[grpc_incumbent_entry_t] incumbents + int64_t next_index + bint job_complete + + ctypedef int (*grpc_log_line_callback_t)( + const char* line, size_t line_len, int job_complete, void* user_data + ) noexcept nogil + cdef cppclass grpc_python_client_t: grpc_python_client_t(const string& host, int port) except + - bool connect(string& error_out) except + + grpc_python_client_t( + const string& host, + int port, + const grpc_python_client_connect_options_t& options, + ) except + + bint connect(string& error_out) except + + string last_error() + + # Shared job control + grpc_status_result_t status(const string& job_id) except + + grpc_status_result_t wait(const string& job_id, int timeout_seconds) except + + bint cancel(const string& job_id, string& error_out) except + + bint delete_job(const string& job_id, string& error_out) except + + + # LP / MIP arm + grpc_submit_result_t submit( + data_model_view_t[int, double]* data_model, + lp_solver_settings_t[int, double]* settings, + bint enable_incumbents, + ) except + + grpc_result_outcome_t result(const string& job_id) except + + grpc_logs_result_t fetch_logs(const string& job_id, long long from_byte) except + + bint stream_logs( + const string& job_id, + long long from_byte, + grpc_log_line_callback_t callback, + void* user_data, + ) except + + grpc_incumbents_result_t fetch_incumbents( + const string& job_id, int64_t from_index, int max_count + ) except + + + # Routing arm grpc_submit_result_t submit_vrp( cpu_routing_problem_t* problem, - solver_settings_t[int, float]* settings) except + - grpc_status_result_t wait(const string& job_id, int timeout_seconds) except + + routing_solver_settings_t[int, float]* settings) except + grpc_vrp_result_outcome_t result_vrp(const string& job_id) except + - bool delete_job(const string& job_id, string& error_out) except + diff --git a/python/cuopt/cuopt/grpc/linear_programming/grpc_client.pyx b/python/cuopt/cuopt/grpc/client/grpc_client.pyx similarity index 58% rename from python/cuopt/cuopt/grpc/linear_programming/grpc_client.pyx rename to python/cuopt/cuopt/grpc/client/grpc_client.pyx index 78a72e2413..bdfd35963e 100644 --- a/python/cuopt/cuopt/grpc/linear_programming/grpc_client.pyx +++ b/python/cuopt/cuopt/grpc/client/grpc_client.pyx @@ -6,7 +6,27 @@ # cython: embedsignature = True # cython: language_level = 3 -from cuopt.grpc.linear_programming.grpc_client cimport ( +"""Compiled gRPC clients for remote cuOpt solves. + +One extension module holding both arms, because both wrap the same C++ object +(``cuopt::cython::grpc_python_client_t``). Keeping them in one unit means the +client can later be detached from the solver engines as a single GPU-free +package rather than two that would each need their own copy of the transport. + +The two public classes stay separate -- their feature sets genuinely differ +(log streaming, incumbents and chunked upload are LP-only; VRP is unary-only) -- +and both keep their original import paths via ``cuopt.grpc.linear_programming`` +and ``cuopt.grpc.routing``. +""" + +from cuopt.grpc.client.grpc_client cimport ( + COMPLETED, + cpu_capacity_dimension_t, + cpu_cost_matrix_t, + cpu_routing_problem_t, + cpu_routing_solution_t, + cpu_uniform_break_t, + cpu_vehicle_break_t, grpc_incumbents_result_t, grpc_job_status_t, grpc_logs_result_t, @@ -17,6 +37,8 @@ from cuopt.grpc.linear_programming.grpc_client cimport ( grpc_result_outcome_t, grpc_status_result_t, grpc_submit_result_t, + grpc_vrp_result_outcome_t, + routing_solver_settings_t, ) from cuopt.linear_programming.data_model.data_model_wrapper cimport DataModel from cuopt.linear_programming.solver.solver cimport solver_ret_t @@ -31,21 +53,29 @@ from cuopt.linear_programming.solver_settings.solver_settings cimport ( SolverSettings, ) +from cython.operator cimport dereference as deref, postincrement as postinc + from enum import IntEnum import math import threading import time import warnings -from libc.stdint cimport int64_t +from libc.stdint cimport int32_t, int64_t, uint8_t from libc.stddef cimport size_t +from libcpp.map cimport map as cpp_map from libcpp.memory cimport unique_ptr from libcpp.string cimport string from libcpp.utility cimport move +from libcpp.vector cimport vector import numpy as np +# ============================================================================= +# LP / MIP arm +# ============================================================================= + class JobStatus(IntEnum): QUEUED = grpc_job_status_t.QUEUED PROCESSING = grpc_job_status_t.PROCESSING @@ -647,3 +677,385 @@ def _is_mip(var_types): vt == "I" or vt == b"I" or vt == "S" or vt == b"S" for vt in var_types ) + + +# ============================================================================= +# Routing (VRP) arm +# ============================================================================= + +class RoutingSolveError(RuntimeError): + """A remote VRP job failed or returned no routing solution.""" + + +# Recorded setters that _populate() maps into cpu_routing_problem_t. Must match +# the dispatch in _populate; test_grpc_serialization asserts this covers every +# name in cuopt.routing._deferred._SETTERS so a new setter cannot be missed. +HANDLED_SETTERS = frozenset({ + "add_cost_matrix", + "add_transit_time_matrix", + "set_order_time_windows", + "set_vehicle_time_windows", + "set_vehicle_locations", + "set_pickup_delivery_pairs", + "add_capacity_dimension", + "set_order_service_times", + "add_vehicle_order_match", + "add_order_vehicle_match", + "add_order_precedence", + "add_break_dimension", + "add_vehicle_break", + "set_objective_function", + "add_initial_solutions", + "set_min_vehicles", + "set_order_locations", + "set_order_prizes", + "set_vehicle_types", + "set_drop_return_trips", + "set_skip_first_trips", + "set_vehicle_max_costs", + "set_vehicle_max_times", + "set_vehicle_fixed_costs", + "set_break_locations", +}) + + +def _to_host(x): + """Return a host numpy array from numpy/pandas/cuDF/cupy/list input.""" + if isinstance(x, np.ndarray): + return x + root = type(x).__module__.split(".", 1)[0] + if root in ("pandas", "cudf"): + return x.to_numpy() + if root == "cupy": + return x.get() + return np.asarray(x) + + +# --- numpy -> std::vector fillers ------------------------------------------ + +cdef void _fill_i32(vector[int32_t]& v, arr) except *: + cdef int32_t[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.int32).ravel() + cdef Py_ssize_t n = mv.shape[0] + cdef Py_ssize_t i + v.resize(n) + for i in range(n): + v[i] = mv[i] + + +cdef void _fill_u8(vector[uint8_t]& v, arr) except *: + cdef uint8_t[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.uint8).ravel() + cdef Py_ssize_t n = mv.shape[0] + cdef Py_ssize_t i + v.resize(n) + for i in range(n): + v[i] = mv[i] + + +cdef void _fill_f32(vector[float]& v, arr) except *: + cdef float[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.float32).ravel() + cdef Py_ssize_t n = mv.shape[0] + cdef Py_ssize_t i + v.resize(n) + for i in range(n): + v[i] = mv[i] + + +# --- std::vector -> numpy --------------------------------------------------- + +cdef _i32_to_np(const vector[int32_t]& v): + cdef Py_ssize_t n = v.size() + out = np.empty(n, dtype=np.int32) + cdef int32_t[::1] mv = out + cdef Py_ssize_t i + for i in range(n): + mv[i] = v[i] + return out + + +cdef _f64_to_np(const vector[double]& v): + cdef Py_ssize_t n = v.size() + out = np.empty(n, dtype=np.float64) + cdef double[::1] mv = out + cdef Py_ssize_t i + for i in range(n): + mv[i] = v[i] + return out + + +# --- DataModel IR -> cpu_routing_problem_t ---------------------------------- + +cdef void _add_matrix(vector[cpu_cost_matrix_t]& dst, args) except *: + # _fill_f32 already casts to float32 and C-order ravels (row-major). + cdef cpu_cost_matrix_t cm + cm.vehicle_type = (int(args[1]) if len(args) > 1 else 0) + _fill_f32(cm.matrix, args[0]) + dst.push_back(cm) + + +cdef void _populate(cpu_routing_problem_t& p, data_model) except *: + n_loc, fleet, n_ord = data_model._init_args + p.num_locations = int(n_loc) + p.fleet_size = int(fleet) + p.num_orders = (int(n_loc) if int(n_ord) == -1 else int(n_ord)) + + cdef vector[int32_t] tmp_i + cdef cpu_capacity_dimension_t cap + cdef cpu_uniform_break_t ub + cdef cpu_vehicle_break_t vb + cdef int32_t vid + + for name, args, _ in data_model._calls: + if name == "add_cost_matrix": + _add_matrix(p.cost_matrices, args) + elif name == "add_transit_time_matrix": + _add_matrix(p.transit_time_matrices, args) + elif name == "set_order_time_windows": + _fill_i32(p.order_tw_earliest, args[0]) + _fill_i32(p.order_tw_latest, args[1]) + elif name == "set_vehicle_time_windows": + _fill_i32(p.vehicle_tw_earliest, args[0]) + _fill_i32(p.vehicle_tw_latest, args[1]) + elif name == "set_vehicle_locations": + _fill_i32(p.vehicle_start_locations, args[0]) + _fill_i32(p.vehicle_return_locations, args[1]) + elif name == "set_pickup_delivery_pairs": + _fill_i32(p.pickup_indices, args[0]) + _fill_i32(p.delivery_indices, args[1]) + elif name == "add_capacity_dimension": + cap = cpu_capacity_dimension_t() + cap.name = str(args[0]).encode("utf-8") + _fill_i32(cap.demand, args[1]) + _fill_i32(cap.capacity, args[2]) + p.capacity_dimensions.push_back(cap) + elif name == "set_order_service_times": + vid = (int(args[1]) if len(args) > 1 else -1) + tmp_i.clear() + _fill_i32(tmp_i, args[0]) + p.order_service_times[vid] = tmp_i + elif name == "add_vehicle_order_match": + vid = int(args[0]) + tmp_i.clear() + _fill_i32(tmp_i, args[1]) + p.vehicle_order_match[vid] = tmp_i + elif name == "add_order_vehicle_match": + vid = int(args[0]) + tmp_i.clear() + _fill_i32(tmp_i, args[1]) + p.order_vehicle_match[vid] = tmp_i + elif name == "add_order_precedence": + vid = int(args[0]) + tmp_i.clear() + _fill_i32(tmp_i, args[1]) + p.order_precedence[vid] = tmp_i + elif name == "add_break_dimension": + ub = cpu_uniform_break_t() + _fill_i32(ub.earliest, args[0]) + _fill_i32(ub.latest, args[1]) + _fill_i32(ub.duration, args[2]) + p.uniform_breaks.push_back(ub) + elif name == "add_vehicle_break": + vid = int(args[0]) + vb = cpu_vehicle_break_t() + vb.earliest = int(args[1]) + vb.latest = int(args[2]) + vb.duration = int(args[3]) + if len(args) > 4 and args[4] is not None: + _fill_i32(vb.locations, args[4]) + p.vehicle_breaks[vid].push_back(vb) + elif name == "set_objective_function": + _fill_i32(p.objectives, args[0]) + _fill_f32(p.objective_weights, args[1]) + 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.sol_offsets, args[3]) + elif name == "set_min_vehicles": + p.min_vehicles = int(args[0]) + elif name == "set_order_locations": + _fill_i32(p.order_locations, args[0]) + elif name == "set_order_prizes": + _fill_f32(p.order_prizes, args[0]) + elif name == "set_vehicle_types": + _fill_u8(p.vehicle_types, args[0]) + elif name == "set_drop_return_trips": + _fill_u8(p.drop_return_trips, args[0]) + elif name == "set_skip_first_trips": + _fill_u8(p.skip_first_trips, args[0]) + elif name == "set_vehicle_max_costs": + _fill_f32(p.vehicle_max_costs, args[0]) + elif name == "set_vehicle_max_times": + _fill_f32(p.vehicle_max_times, args[0]) + elif name == "set_vehicle_fixed_costs": + _fill_f32(p.vehicle_fixed_costs, args[0]) + elif name == "set_break_locations": + _fill_i32(p.break_locations, args[0]) + else: + raise KeyError( + f"no VRP gRPC mapping for recorded setter {name!r}; add a case " + "to cuopt.grpc.routing.grpc_client._populate" + ) + + +def problem_summary(data_model): + """Populate a ``cpu_routing_problem_t`` from ``data_model`` and return a + ``{field: size}`` summary. Runs the exact ``_populate`` path used by + ``submit`` (so a mis-mapped or unmapped setter fails here too), without a + server. Intended for tests. + """ + cdef cpu_routing_problem_t p + _populate(p, data_model) + return { + "num_locations": int(p.num_locations), + "fleet_size": int(p.fleet_size), + "num_orders": int(p.num_orders), + "min_vehicles": int(p.min_vehicles), + "cost_matrices": p.cost_matrices.size(), + "transit_time_matrices": p.transit_time_matrices.size(), + "vehicle_start_locations": p.vehicle_start_locations.size(), + "vehicle_return_locations": p.vehicle_return_locations.size(), + "vehicle_tw_earliest": p.vehicle_tw_earliest.size(), + "vehicle_tw_latest": p.vehicle_tw_latest.size(), + "vehicle_types": p.vehicle_types.size(), + "drop_return_trips": p.drop_return_trips.size(), + "skip_first_trips": p.skip_first_trips.size(), + "vehicle_max_costs": p.vehicle_max_costs.size(), + "vehicle_max_times": p.vehicle_max_times.size(), + "vehicle_fixed_costs": p.vehicle_fixed_costs.size(), + "order_locations": p.order_locations.size(), + "order_tw_earliest": p.order_tw_earliest.size(), + "order_tw_latest": p.order_tw_latest.size(), + "order_prizes": p.order_prizes.size(), + "order_service_times": p.order_service_times.size(), + "pickup_indices": p.pickup_indices.size(), + "delivery_indices": p.delivery_indices.size(), + "capacity_dimensions": p.capacity_dimensions.size(), + "break_locations": p.break_locations.size(), + "uniform_breaks": p.uniform_breaks.size(), + "vehicle_breaks": p.vehicle_breaks.size(), + "vehicle_order_match": p.vehicle_order_match.size(), + "order_vehicle_match": p.order_vehicle_match.size(), + "order_precedence": p.order_precedence.size(), + "objectives": p.objectives.size(), + "objective_weights": p.objective_weights.size(), + "initial_solutions_routes": p.initial_solutions.routes.size(), + } + + +cdef _solution_to_py(cpu_routing_solution_t s): + cdef dict objectives = {} + cdef cpp_map[int32_t, double].iterator it = s.objective_values.begin() + while it != s.objective_values.end(): + objectives[int(deref(it).first)] = float(deref(it).second) + postinc(it) + return { + "status": int(s.status), + "status_message": s.status_message.decode("utf-8"), + "error_message": s.error_message.decode("utf-8"), + "vehicle_count": int(s.vehicle_count), + "total_objective_value": float(s.total_objective_value), + "objective_values": objectives, + "route": _i32_to_np(s.route), + "truck_id": _i32_to_np(s.truck_id), + "locations": _i32_to_np(s.locations), + "node_types": _i32_to_np(s.node_types), + "arrival_stamp": _f64_to_np(s.arrival_stamp), + "unserviced_nodes": _i32_to_np(s.unserviced_nodes), + "accepted": _i32_to_np(s.accepted), + } + + +cdef class RoutingClient: + """Client for solving VRP problems on a remote cuOpt gRPC server.""" + + cdef unique_ptr[grpc_python_client_t] _client + + def __cinit__(self, str target="localhost:50051"): + host, _, port = target.rpartition(":") + if not host: + host, port = target, "50051" + cdef string host_cpp = host.encode("utf-8") + cdef string err + self._client.reset(new grpc_python_client_t(host_cpp, int(port))) + if not self._client.get().connect(err): + raise RoutingSolveError( + "failed to connect: " + err.decode("utf-8") + ) + + 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)) + + def submit(self, data_model, settings=None): + """Serialize and submit a VRP problem; return its ``job_id``.""" + cdef cpu_routing_problem_t problem + cdef routing_solver_settings_t[int, float] cpp_settings + _populate(problem, data_model) + self._apply_settings(cpp_settings, settings) + cdef grpc_submit_result_t sub = self._client.get().submit_vrp( + &problem, &cpp_settings + ) + if not sub.success: + raise RoutingSolveError(sub.error_message.decode("utf-8")) + return sub.job_id.decode("utf-8") + + def wait(self, str job_id, int timeout=0): + """Block until the job finishes; return the terminal status int. + + Raises ``RoutingSolveError`` if the wait itself fails (e.g. transport + error or unknown job), mirroring the LP/MILP client. + """ + cdef grpc_status_result_t st = self._client.get().wait( + job_id.encode("utf-8"), timeout + ) + if not st.success: + raise RoutingSolveError(st.error_message.decode("utf-8")) + return st.status + + def result(self, str job_id): + """Fetch and parse the routing solution for a completed job. + + Returns ``None`` if the job is still in flight (mirrors the LP client). + """ + cdef grpc_vrp_result_outcome_t out = self._client.get().result_vrp( + job_id.encode("utf-8") + ) + if out.not_ready: + return None + if not out.success: + raise RoutingSolveError(out.error_message.decode("utf-8")) + return _solution_to_py(out.solution) + + def delete(self, str job_id): + """Delete a job's server-side result; raise ``RoutingSolveError`` on failure.""" + cdef string err + if not self._client.get().delete_job(job_id.encode("utf-8"), err): + raise RoutingSolveError(err.decode("utf-8")) + + def solve(self, data_model, settings=None, *, int timeout=0, bint delete=True): + """Submit, wait, and return the solution (the common path).""" + job_id = self.submit(data_model, settings) + try: + status = self.wait(job_id, timeout) + if status != COMPLETED: + # A non-completed terminal status means the solve failed; + # result() surfaces the server's error_message. If it somehow + # doesn't raise, fall back to a status-only message. + self.result(job_id) + raise RoutingSolveError( + f"job {job_id} did not complete (status {status})" + ) + return self.result(job_id) + finally: + if delete: + self.delete(job_id) diff --git a/python/cuopt/cuopt/grpc/linear_programming/grpc_client.pxd b/python/cuopt/cuopt/grpc/linear_programming/grpc_client.pxd deleted file mode 100644 index 8677266f13..0000000000 --- a/python/cuopt/cuopt/grpc/linear_programming/grpc_client.pxd +++ /dev/null @@ -1,103 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -from libc.stdint cimport int64_t -from libcpp.memory cimport unique_ptr -from libcpp.string cimport string -from libcpp.vector cimport vector - -from cuopt.linear_programming.data_model.data_model cimport data_model_view_t -from cuopt.linear_programming.solver.solver cimport solver_ret_t -from cuopt.linear_programming.solver_settings.solver_settings cimport ( - solver_settings_t, -) - -cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": - ctypedef enum grpc_python_tls_mode_t "cuopt::cython::grpc_python_tls_mode_t": - ENV "cuopt::cython::grpc_python_tls_mode_t::ENV" - DISABLED "cuopt::cython::grpc_python_tls_mode_t::DISABLED" - EXPLICIT "cuopt::cython::grpc_python_tls_mode_t::EXPLICIT" - - cdef cppclass grpc_python_client_connect_options_t: - grpc_python_tls_mode_t tls_mode - string tls_root_certs - string tls_client_cert - string tls_client_key - - ctypedef enum grpc_job_status_t "cuopt::cython::grpc_job_status_t": - QUEUED "cuopt::cython::grpc_job_status_t::QUEUED" - PROCESSING "cuopt::cython::grpc_job_status_t::PROCESSING" - COMPLETED "cuopt::cython::grpc_job_status_t::COMPLETED" - FAILED "cuopt::cython::grpc_job_status_t::FAILED" - CANCELLED "cuopt::cython::grpc_job_status_t::CANCELLED" - NOT_FOUND "cuopt::cython::grpc_job_status_t::NOT_FOUND" - - cdef cppclass grpc_submit_result_t: - bint success - string error_message - string job_id - bint is_mip - - cdef cppclass grpc_status_result_t: - bint success - string error_message - grpc_job_status_t status - string message - long long result_size_bytes - - cdef cppclass grpc_result_outcome_t: - bint not_ready - bint success - string error_message - unique_ptr[solver_ret_t] solution - - cdef cppclass grpc_logs_result_t: - bint success - string error_message - vector[string] lines - - cdef cppclass grpc_incumbent_entry_t: - int64_t index - double objective - vector[double] assignment - - cdef cppclass grpc_incumbents_result_t: - bint success - string error_message - vector[grpc_incumbent_entry_t] incumbents - int64_t next_index - bint job_complete - - ctypedef int (*grpc_log_line_callback_t)( - const char* line, size_t line_len, int job_complete, void* user_data - ) noexcept nogil - - cdef cppclass grpc_python_client_t: - grpc_python_client_t(const string& host, int port) except + - grpc_python_client_t( - const string& host, - int port, - const grpc_python_client_connect_options_t& options, - ) except + - bint connect(string& error_out) - grpc_submit_result_t submit( - data_model_view_t[int, double]* data_model, - solver_settings_t[int, double]* settings, - bint enable_incumbents, - ) except + - grpc_status_result_t status(const string& job_id) except + - grpc_status_result_t wait(const string& job_id, int timeout_seconds) except + - bint cancel(const string& job_id, string& error_out) except + - bint delete_job(const string& job_id, string& error_out) except + - grpc_result_outcome_t result(const string& job_id) except + - grpc_logs_result_t fetch_logs(const string& job_id, long long from_byte) except + - bint stream_logs( - const string& job_id, - long long from_byte, - grpc_log_line_callback_t callback, - void* user_data, - ) except + - grpc_incumbents_result_t fetch_incumbents( - const string& job_id, int64_t from_index, int max_count - ) except + - string last_error() diff --git a/python/cuopt/cuopt/grpc/linear_programming/grpc_client.py b/python/cuopt/cuopt/grpc/linear_programming/grpc_client.py new file mode 100644 index 0000000000..e102cff060 --- /dev/null +++ b/python/cuopt/cuopt/grpc/linear_programming/grpc_client.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Compatibility shim. + +The LP/MIP and routing clients were merged into a single extension module +(:mod:`cuopt.grpc.client.grpc_client`). This module keeps the original import +path working. +""" + +from cuopt.grpc.client.grpc_client import ( # noqa: F401 + Client, + GrpcError, + JobNotReadyError, + JobStatus, + TlsConfig, +) + +__all__ = ["Client", "GrpcError", "JobNotReadyError", "JobStatus", "TlsConfig"] diff --git a/python/cuopt/cuopt/grpc/routing/CMakeLists.txt b/python/cuopt/cuopt/grpc/routing/CMakeLists.txt deleted file mode 100644 index 620e038de2..0000000000 --- a/python/cuopt/cuopt/grpc/routing/CMakeLists.txt +++ /dev/null @@ -1,15 +0,0 @@ -# cmake-format: off -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# cmake-format: on - -set(cython_sources grpc_client.pyx) -set(linked_libraries cuopt::cuopt) - -rapids_cython_create_modules( - CXX - SOURCE_FILES "${cython_sources}" - LINKED_LIBRARIES "${linked_libraries}" - MODULE_PREFIX grpc_routing_ - ASSOCIATED_TARGETS cuopt -) diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.py b/python/cuopt/cuopt/grpc/routing/grpc_client.py new file mode 100644 index 0000000000..38c37dbdfa --- /dev/null +++ b/python/cuopt/cuopt/grpc/routing/grpc_client.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Compatibility shim. + +The routing and LP/MIP clients were merged into a single extension module +(:mod:`cuopt.grpc.client.grpc_client`). This module keeps the original import +path working, including the names the serialization test reaches for +(``HANDLED_SETTERS``, ``problem_summary``). +""" + +from cuopt.grpc.client.grpc_client import ( # noqa: F401 + HANDLED_SETTERS, + RoutingClient, + RoutingSolveError, + problem_summary, +) + +__all__ = [ + "HANDLED_SETTERS", + "RoutingClient", + "RoutingSolveError", + "problem_summary", +] diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx deleted file mode 100644 index 7549099c2e..0000000000 --- a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx +++ /dev/null @@ -1,412 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# distutils: language = c++ - -"""Compiled gRPC client for remote VRP solves, consistent with the LP/MILP -client. Builds a host ``cpu_routing_problem_t`` from a routing ``DataModel`` -(walking its store-then-build IR) and serializes it in C++; no pure-Python -protobuf path. -""" - -from cython.operator cimport dereference as deref, postincrement as postinc -from libc.stdint cimport int32_t, uint8_t -from libcpp.map cimport map as cpp_map -from libcpp.memory cimport unique_ptr -from libcpp.string cimport string -from libcpp.vector cimport vector - -import numpy as np - -from cuopt.grpc.routing.grpc_client cimport ( - COMPLETED, - cpu_capacity_dimension_t, - cpu_cost_matrix_t, - cpu_routing_problem_t, - cpu_routing_solution_t, - cpu_uniform_break_t, - cpu_vehicle_break_t, - grpc_python_client_t, - grpc_status_result_t, - grpc_submit_result_t, - grpc_vrp_result_outcome_t, - solver_settings_t, -) - - -class RoutingSolveError(RuntimeError): - """A remote VRP job failed or returned no routing solution.""" - - -# Recorded setters that _populate() maps into cpu_routing_problem_t. Must match -# the dispatch in _populate; test_grpc_serialization asserts this covers every -# name in cuopt.routing._deferred._SETTERS so a new setter cannot be missed. -HANDLED_SETTERS = frozenset({ - "add_cost_matrix", - "add_transit_time_matrix", - "set_order_time_windows", - "set_vehicle_time_windows", - "set_vehicle_locations", - "set_pickup_delivery_pairs", - "add_capacity_dimension", - "set_order_service_times", - "add_vehicle_order_match", - "add_order_vehicle_match", - "add_order_precedence", - "add_break_dimension", - "add_vehicle_break", - "set_objective_function", - "add_initial_solutions", - "set_min_vehicles", - "set_order_locations", - "set_order_prizes", - "set_vehicle_types", - "set_drop_return_trips", - "set_skip_first_trips", - "set_vehicle_max_costs", - "set_vehicle_max_times", - "set_vehicle_fixed_costs", - "set_break_locations", -}) - - -def _to_host(x): - """Return a host numpy array from numpy/pandas/cuDF/cupy/list input.""" - if isinstance(x, np.ndarray): - return x - root = type(x).__module__.split(".", 1)[0] - if root in ("pandas", "cudf"): - return x.to_numpy() - if root == "cupy": - return x.get() - return np.asarray(x) - - -# --- numpy -> std::vector fillers ------------------------------------------ - -cdef void _fill_i32(vector[int32_t]& v, arr) except *: - cdef int32_t[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.int32).ravel() - cdef Py_ssize_t n = mv.shape[0] - cdef Py_ssize_t i - v.resize(n) - for i in range(n): - v[i] = mv[i] - - -cdef void _fill_u8(vector[uint8_t]& v, arr) except *: - cdef uint8_t[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.uint8).ravel() - cdef Py_ssize_t n = mv.shape[0] - cdef Py_ssize_t i - v.resize(n) - for i in range(n): - v[i] = mv[i] - - -cdef void _fill_f32(vector[float]& v, arr) except *: - cdef float[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.float32).ravel() - cdef Py_ssize_t n = mv.shape[0] - cdef Py_ssize_t i - v.resize(n) - for i in range(n): - v[i] = mv[i] - - -# --- std::vector -> numpy --------------------------------------------------- - -cdef _i32_to_np(const vector[int32_t]& v): - cdef Py_ssize_t n = v.size() - out = np.empty(n, dtype=np.int32) - cdef int32_t[::1] mv = out - cdef Py_ssize_t i - for i in range(n): - mv[i] = v[i] - return out - - -cdef _f64_to_np(const vector[double]& v): - cdef Py_ssize_t n = v.size() - out = np.empty(n, dtype=np.float64) - cdef double[::1] mv = out - cdef Py_ssize_t i - for i in range(n): - mv[i] = v[i] - return out - - -# --- DataModel IR -> cpu_routing_problem_t ---------------------------------- - -cdef void _add_matrix(vector[cpu_cost_matrix_t]& dst, args) except *: - # _fill_f32 already casts to float32 and C-order ravels (row-major). - cdef cpu_cost_matrix_t cm - cm.vehicle_type = (int(args[1]) if len(args) > 1 else 0) - _fill_f32(cm.matrix, args[0]) - dst.push_back(cm) - - -cdef void _populate(cpu_routing_problem_t& p, data_model) except *: - n_loc, fleet, n_ord = data_model._init_args - p.num_locations = int(n_loc) - p.fleet_size = int(fleet) - p.num_orders = (int(n_loc) if int(n_ord) == -1 else int(n_ord)) - - cdef vector[int32_t] tmp_i - cdef cpu_capacity_dimension_t cap - cdef cpu_uniform_break_t ub - cdef cpu_vehicle_break_t vb - cdef int32_t vid - - for name, args, _ in data_model._calls: - if name == "add_cost_matrix": - _add_matrix(p.cost_matrices, args) - elif name == "add_transit_time_matrix": - _add_matrix(p.transit_time_matrices, args) - elif name == "set_order_time_windows": - _fill_i32(p.order_tw_earliest, args[0]) - _fill_i32(p.order_tw_latest, args[1]) - elif name == "set_vehicle_time_windows": - _fill_i32(p.vehicle_tw_earliest, args[0]) - _fill_i32(p.vehicle_tw_latest, args[1]) - elif name == "set_vehicle_locations": - _fill_i32(p.vehicle_start_locations, args[0]) - _fill_i32(p.vehicle_return_locations, args[1]) - elif name == "set_pickup_delivery_pairs": - _fill_i32(p.pickup_indices, args[0]) - _fill_i32(p.delivery_indices, args[1]) - elif name == "add_capacity_dimension": - cap = cpu_capacity_dimension_t() - cap.name = str(args[0]).encode("utf-8") - _fill_i32(cap.demand, args[1]) - _fill_i32(cap.capacity, args[2]) - p.capacity_dimensions.push_back(cap) - elif name == "set_order_service_times": - vid = (int(args[1]) if len(args) > 1 else -1) - tmp_i.clear() - _fill_i32(tmp_i, args[0]) - p.order_service_times[vid] = tmp_i - elif name == "add_vehicle_order_match": - vid = int(args[0]) - tmp_i.clear() - _fill_i32(tmp_i, args[1]) - p.vehicle_order_match[vid] = tmp_i - elif name == "add_order_vehicle_match": - vid = int(args[0]) - tmp_i.clear() - _fill_i32(tmp_i, args[1]) - p.order_vehicle_match[vid] = tmp_i - elif name == "add_order_precedence": - vid = int(args[0]) - tmp_i.clear() - _fill_i32(tmp_i, args[1]) - p.order_precedence[vid] = tmp_i - elif name == "add_break_dimension": - ub = cpu_uniform_break_t() - _fill_i32(ub.earliest, args[0]) - _fill_i32(ub.latest, args[1]) - _fill_i32(ub.duration, args[2]) - p.uniform_breaks.push_back(ub) - elif name == "add_vehicle_break": - vid = int(args[0]) - vb = cpu_vehicle_break_t() - vb.earliest = int(args[1]) - vb.latest = int(args[2]) - vb.duration = int(args[3]) - if len(args) > 4 and args[4] is not None: - _fill_i32(vb.locations, args[4]) - p.vehicle_breaks[vid].push_back(vb) - elif name == "set_objective_function": - _fill_i32(p.objectives, args[0]) - _fill_f32(p.objective_weights, args[1]) - 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.sol_offsets, args[3]) - elif name == "set_min_vehicles": - p.min_vehicles = int(args[0]) - elif name == "set_order_locations": - _fill_i32(p.order_locations, args[0]) - elif name == "set_order_prizes": - _fill_f32(p.order_prizes, args[0]) - elif name == "set_vehicle_types": - _fill_u8(p.vehicle_types, args[0]) - elif name == "set_drop_return_trips": - _fill_u8(p.drop_return_trips, args[0]) - elif name == "set_skip_first_trips": - _fill_u8(p.skip_first_trips, args[0]) - elif name == "set_vehicle_max_costs": - _fill_f32(p.vehicle_max_costs, args[0]) - elif name == "set_vehicle_max_times": - _fill_f32(p.vehicle_max_times, args[0]) - elif name == "set_vehicle_fixed_costs": - _fill_f32(p.vehicle_fixed_costs, args[0]) - elif name == "set_break_locations": - _fill_i32(p.break_locations, args[0]) - else: - raise KeyError( - f"no VRP gRPC mapping for recorded setter {name!r}; add a case " - "to cuopt.grpc.routing.grpc_client._populate" - ) - - -def problem_summary(data_model): - """Populate a ``cpu_routing_problem_t`` from ``data_model`` and return a - ``{field: size}`` summary. Runs the exact ``_populate`` path used by - ``submit`` (so a mis-mapped or unmapped setter fails here too), without a - server. Intended for tests. - """ - cdef cpu_routing_problem_t p - _populate(p, data_model) - return { - "num_locations": int(p.num_locations), - "fleet_size": int(p.fleet_size), - "num_orders": int(p.num_orders), - "min_vehicles": int(p.min_vehicles), - "cost_matrices": p.cost_matrices.size(), - "transit_time_matrices": p.transit_time_matrices.size(), - "vehicle_start_locations": p.vehicle_start_locations.size(), - "vehicle_return_locations": p.vehicle_return_locations.size(), - "vehicle_tw_earliest": p.vehicle_tw_earliest.size(), - "vehicle_tw_latest": p.vehicle_tw_latest.size(), - "vehicle_types": p.vehicle_types.size(), - "drop_return_trips": p.drop_return_trips.size(), - "skip_first_trips": p.skip_first_trips.size(), - "vehicle_max_costs": p.vehicle_max_costs.size(), - "vehicle_max_times": p.vehicle_max_times.size(), - "vehicle_fixed_costs": p.vehicle_fixed_costs.size(), - "order_locations": p.order_locations.size(), - "order_tw_earliest": p.order_tw_earliest.size(), - "order_tw_latest": p.order_tw_latest.size(), - "order_prizes": p.order_prizes.size(), - "order_service_times": p.order_service_times.size(), - "pickup_indices": p.pickup_indices.size(), - "delivery_indices": p.delivery_indices.size(), - "capacity_dimensions": p.capacity_dimensions.size(), - "break_locations": p.break_locations.size(), - "uniform_breaks": p.uniform_breaks.size(), - "vehicle_breaks": p.vehicle_breaks.size(), - "vehicle_order_match": p.vehicle_order_match.size(), - "order_vehicle_match": p.order_vehicle_match.size(), - "order_precedence": p.order_precedence.size(), - "objectives": p.objectives.size(), - "objective_weights": p.objective_weights.size(), - "initial_solutions_routes": p.initial_solutions.routes.size(), - } - - -cdef _solution_to_py(cpu_routing_solution_t s): - cdef dict objectives = {} - cdef cpp_map[int32_t, double].iterator it = s.objective_values.begin() - while it != s.objective_values.end(): - objectives[int(deref(it).first)] = float(deref(it).second) - postinc(it) - return { - "status": int(s.status), - "status_message": s.status_message.decode("utf-8"), - "error_message": s.error_message.decode("utf-8"), - "vehicle_count": int(s.vehicle_count), - "total_objective_value": float(s.total_objective_value), - "objective_values": objectives, - "route": _i32_to_np(s.route), - "truck_id": _i32_to_np(s.truck_id), - "locations": _i32_to_np(s.locations), - "node_types": _i32_to_np(s.node_types), - "arrival_stamp": _f64_to_np(s.arrival_stamp), - "unserviced_nodes": _i32_to_np(s.unserviced_nodes), - "accepted": _i32_to_np(s.accepted), - } - - -cdef class RoutingClient: - """Client for solving VRP problems on a remote cuOpt gRPC server.""" - - cdef unique_ptr[grpc_python_client_t] _client - - def __cinit__(self, str target="localhost:50051"): - host, _, port = target.rpartition(":") - if not host: - host, port = target, "50051" - cdef string host_cpp = host.encode("utf-8") - cdef string err - self._client.reset(new grpc_python_client_t(host_cpp, int(port))) - if not self._client.get().connect(err): - raise RoutingSolveError( - "failed to connect: " + err.decode("utf-8") - ) - - cdef _apply_settings(self, 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)) - - def submit(self, data_model, settings=None): - """Serialize and submit a VRP problem; return its ``job_id``.""" - cdef cpu_routing_problem_t problem - cdef solver_settings_t[int, float] cpp_settings - _populate(problem, data_model) - self._apply_settings(cpp_settings, settings) - cdef grpc_submit_result_t sub = self._client.get().submit_vrp( - &problem, &cpp_settings - ) - if not sub.success: - raise RoutingSolveError(sub.error_message.decode("utf-8")) - return sub.job_id.decode("utf-8") - - def wait(self, str job_id, int timeout=0): - """Block until the job finishes; return the terminal status int. - - Raises ``RoutingSolveError`` if the wait itself fails (e.g. transport - error or unknown job), mirroring the LP/MILP client. - """ - cdef grpc_status_result_t st = self._client.get().wait( - job_id.encode("utf-8"), timeout - ) - if not st.success: - raise RoutingSolveError(st.error_message.decode("utf-8")) - return st.status - - def result(self, str job_id): - """Fetch and parse the routing solution for a completed job. - - Returns ``None`` if the job is still in flight (mirrors the LP client). - """ - cdef grpc_vrp_result_outcome_t out = self._client.get().result_vrp( - job_id.encode("utf-8") - ) - if out.not_ready: - return None - if not out.success: - raise RoutingSolveError(out.error_message.decode("utf-8")) - return _solution_to_py(out.solution) - - def delete(self, str job_id): - """Delete a job's server-side result; raise ``RoutingSolveError`` on failure.""" - cdef string err - if not self._client.get().delete_job(job_id.encode("utf-8"), err): - raise RoutingSolveError(err.decode("utf-8")) - - def solve(self, data_model, settings=None, *, int timeout=0, bint delete=True): - """Submit, wait, and return the solution (the common path).""" - job_id = self.submit(data_model, settings) - try: - status = self.wait(job_id, timeout) - if status != COMPLETED: - # A non-completed terminal status means the solve failed; - # result() surfaces the server's error_message. If it somehow - # doesn't raise, fall back to a status-only message. - self.result(job_id) - raise RoutingSolveError( - f"job {job_id} did not complete (status {status})" - ) - return self.result(job_id) - finally: - if delete: - self.delete(job_id)