From c337449680100f3b4fec9394fae3a3dbc7674195 Mon Sep 17 00:00:00 2001 From: Trevor McKay Date: Tue, 25 Aug 2026 16:10:50 -0400 Subject: [PATCH] Add conversion routines so that clients using the http server and cuOpt JSON dictionary formats for problems and solutions can easily migrate to use of the gRPC server using the dictionary formats if desired --- conda/recipes/cuopt/recipe.yaml | 2 + dependencies.yaml | 13 +- .../source/cuopt-grpc/python-async-client.rst | 30 ++ .../cuopt/linear_programming/__init__.py | 9 +- .../cuopt/linear_programming/io/__init__.py | 11 +- .../cuopt/linear_programming/io/parser.py | 323 +++++++++++++++++- .../linear_programming/test_dict_convert.py | 224 ++++++++++++ python/cuopt/pyproject.toml | 2 + 8 files changed, 596 insertions(+), 18 deletions(-) create mode 100644 python/cuopt/cuopt/tests/linear_programming/test_dict_convert.py diff --git a/conda/recipes/cuopt/recipe.yaml b/conda/recipes/cuopt/recipe.yaml index 8dbd7d5a23..3542085ae9 100644 --- a/conda/recipes/cuopt/recipe.yaml +++ b/conda/recipes/cuopt/recipe.yaml @@ -90,6 +90,8 @@ requirements: - cupy >=14.0.1,!=14.1.0 - h5py - libcuopt =${{ version }} + - msgpack-numpy =0.4.8 + - msgpack-python =1.2.1 - numba>=0.60.0,<0.65.0 - numba-cuda>=0.22.1 - numpy >=2.0,<3.0 diff --git a/dependencies.yaml b/dependencies.yaml index ca87d3d3b8..784082911f 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -334,26 +334,33 @@ dependencies: common: - output_types: [conda, requirements, pyproject] packages: + - &msgpack_numpy msgpack-numpy==0.4.8 - numba-cuda>=0.22.1 - numba>=0.60.0,<0.65.0 - &pandas pandas>=2.0 - *pyyaml - scipy>=1.14.1 + - output_types: [requirements, pyproject] + packages: + - &msgpack msgpack==1.2.1 + - output_types: conda + packages: + - &msgpack_python msgpack-python==1.2.1 test_python_cuopt_server: common: - output_types: [conda, requirements, pyproject] packages: - &jsonref jsonref==1.1.0 - - &msgpack_numpy msgpack-numpy==0.4.8 + - *msgpack_numpy - pexpect - &requests requests - output_types: [requirements, pyproject] packages: - - &msgpack msgpack==1.2.1 + - *msgpack - output_types: conda packages: - - &msgpack_python msgpack-python==1.2.1 + - *msgpack_python run_cuopt_server: common: diff --git a/docs/cuopt/source/cuopt-grpc/python-async-client.rst b/docs/cuopt/source/cuopt-grpc/python-async-client.rst index 8a73bd910f..701665a743 100644 --- a/docs/cuopt/source/cuopt-grpc/python-async-client.rst +++ b/docs/cuopt/source/cuopt-grpc/python-async-client.rst @@ -69,6 +69,36 @@ from the quick start (same constraint matrix and objective). :class:`~cuopt.linear_programming.problem.Problem`. Always call ``delete`` after you are done with the job so the server can release state. +From a Legacy cuOpt JSON Format Dictionary +=========================================== + +Two data conversion routines have been added that make it easy to migrate clients from +use of the cuOpt http server to the gRPC server. LP/MIP datasets in cuOpt JSON +format can be converted to inputs for the gRPC server, and Solution ojbects +returned from the gRPC server can be converted into cuOpt JSON response +dictionaries. + +``toDataModelAndSettings`` accepts the same input dictionary format that +that ``CuOptServiceSelfHostClient.get_LP_solve()`` accepts. +``toDictFromSolution`` maps a ``Solution`` to the response dictionary +format that ``CuOptServiceSelfHostClient.get_LP_solve()`` optionally returns. + +.. code-block:: python + + from cuopt.linear_programming import toDataModelAndSettings, toDictFromSolution + from cuopt.grpc.linear_programming import Client, JobStatus + + dm, settings = toDataModelAndSettings("problem.json") # or a dict + client = Client("localhost", 5001) + job_id = client.submit(dm, settings) + try: + client.wait(job_id, timeout=120) + solution = client.result(job_id) + envelope = toDictFromSolution(solution) + print(envelope["response"]["solver_response"]["status"]) + finally: + client.delete(job_id) + Variable Names ============== diff --git a/python/cuopt/cuopt/linear_programming/__init__.py b/python/cuopt/cuopt/linear_programming/__init__.py index 835d09d76a..6ba861f234 100644 --- a/python/cuopt/cuopt/linear_programming/__init__.py +++ b/python/cuopt/cuopt/linear_programming/__init__.py @@ -3,7 +3,14 @@ from cuopt.linear_programming import internals from cuopt.linear_programming.data_model import DataModel -from cuopt.linear_programming.io import ParseMps, Read +from cuopt.linear_programming.io import ( + ParseMps, + Read, + toDataModelAndSettings, + toDict, + toDictFromDataModel, + toDictFromSolution, +) from cuopt.linear_programming.problem import Problem from cuopt.linear_programming.solution import Solution from cuopt.linear_programming.solver import BatchSolve, Solve diff --git a/python/cuopt/cuopt/linear_programming/io/__init__.py b/python/cuopt/cuopt/linear_programming/io/__init__.py index c6843a9e61..511024f32d 100644 --- a/python/cuopt/cuopt/linear_programming/io/__init__.py +++ b/python/cuopt/cuopt/linear_programming/io/__init__.py @@ -1,4 +1,11 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from cuopt.linear_programming.io.parser import ParseMps, Read, toDict +from cuopt.linear_programming.io.parser import ( + ParseMps, + Read, + toDataModelAndSettings, + toDict, + toDictFromDataModel, + toDictFromSolution, +) diff --git a/python/cuopt/cuopt/linear_programming/io/parser.py b/python/cuopt/cuopt/linear_programming/io/parser.py index a9132eaf8f..0cc3acad91 100644 --- a/python/cuopt/cuopt/linear_programming/io/parser.py +++ b/python/cuopt/cuopt/linear_programming/io/parser.py @@ -1,12 +1,53 @@ # SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +"""MPS/LP file readers and cuOpt JSON dict converters for LP/MIP. + +``Read`` / ``ParseMps`` load files into :class:`DataModel`. ``toDict`` +(alias ``toDictFromDataModel``) serializes a model to the cuOpt LP/MIP +JSON schema, ``toDataModelAndSettings`` reads that schema back, and +``toDictFromSolution`` maps a +:class:`~cuopt.linear_programming.solution.Solution` to the cuOpt +JSON response format. +""" + +import json as json_module +import os +import zlib + import numpy as np from cuopt.linear_programming.data_model import DataModel from cuopt.linear_programming.io import parser_wrapper from cuopt.linear_programming.io.utilities import ( catch_io_exception, ) +from cuopt.linear_programming.solution.solution import ( + LPTerminationStatus, + MILPTerminationStatus, + ProblemCategory, +) +from cuopt.linear_programming.solver_settings import ( + SolverSettings, + solver_params, +) + +_SOLUTION_STATUSES = ( + LPTerminationStatus.Optimal, + LPTerminationStatus.IterationLimit, + LPTerminationStatus.TimeLimit, + MILPTerminationStatus.Optimal, + MILPTerminationStatus.FeasibleFound, +) + +# cuOpt JSON nests these under solver_config["tolerances"]. Names ending in +# "_tolerance" are looked up there by convention; MIP gaps are the +# exceptions that do not follow that suffix. +_TOLERANCE_EXCEPTIONS = frozenset( + { + "mip_absolute_gap", + "mip_relative_gap", + } +) @catch_io_exception @@ -80,7 +121,41 @@ def ParseMps(mps_file_path: str, fixed_mps_format: bool = False) -> DataModel: return parser_wrapper.ParseMps(mps_file_path, fixed_mps_format) +def _tolist(value): + """Coerce a numpy array to a list; pass lists and None through. + + Setters store whatever the caller supplied, so a model built from a + cuOpt dict holds plain lists where an MPS-parsed model holds arrays. + """ + if value is None: + return None + return value.tolist() if hasattr(value, "tolist") else value + + +def _initial_solution(model, json): + """Return the initial_solution section, or None if the model has none.""" + primal = model.initial_primal_solution + dual = model.initial_dual_solution + if len(primal) == 0 and len(dual) == 0: + return None + initial_solution = {} + if len(primal) > 0: + initial_solution["primal"] = _tolist(primal) if json else primal + if len(dual) > 0: + initial_solution["dual"] = _tolist(dual) if json else dual + return initial_solution + + def toDict(model, json=False): + """Serialize a ``DataModel`` to a cuOpt LP/MIP JSON dict. + + Parameters + ---------- + model : DataModel + json : bool, default False + If True, numpy arrays become lists and infinities become + ``"inf"`` / ``"ninf"`` strings. + """ if not isinstance(model, parser_wrapper.DataModel): raise ValueError( "model must be a cuopt.linear_programming.io.parser_wrapper.DataModel" @@ -100,29 +175,32 @@ def transform(data): if json is True: problem_data = { "csr_constraint_matrix": { - "offsets": model.A_offsets.tolist(), - "indices": model.A_indices.tolist(), - "values": model.A_values.tolist(), + "offsets": _tolist(model.A_offsets), + "indices": _tolist(model.A_indices), + "values": _tolist(model.A_values), }, "constraint_bounds": { - "bounds": model.b.tolist(), - "upper_bounds": model.constraint_upper_bounds.tolist(), - "lower_bounds": model.constraint_lower_bounds.tolist(), - "types": model.host_row_types.tolist(), + "bounds": _tolist(model.b), + "upper_bounds": _tolist(model.constraint_upper_bounds), + "lower_bounds": _tolist(model.constraint_lower_bounds), + "types": _tolist(model.host_row_types), }, "objective_data": { - "coefficients": model.c.tolist(), + "coefficients": _tolist(model.c), "scalability_factor": model.objective_scaling_factor, "offset": model.objective_offset, }, "variable_bounds": { - "upper_bounds": model.variable_upper_bounds.tolist(), - "lower_bounds": model.variable_lower_bounds.tolist(), + "upper_bounds": _tolist(model.variable_upper_bounds), + "lower_bounds": _tolist(model.variable_lower_bounds), }, "maximize": model.maximize, - "variable_types": model.variable_types.tolist(), - "variable_names": model.variable_names.tolist(), + "variable_types": _tolist(model.variable_types), + "variable_names": _tolist(model.variable_names), } + initial_solution = _initial_solution(model, json=True) + if initial_solution is not None: + problem_data["initial_solution"] = initial_solution transform(problem_data) else: problem_data = { @@ -150,4 +228,225 @@ def transform(data): "variable_types": model.variable_types, "variable_names": model.variable_names, } + initial_solution = _initial_solution(model, json=False) + if initial_solution is not None: + problem_data["initial_solution"] = initial_solution return problem_data + + +def toDictFromDataModel(model, json=False): + """Alias of :func:`toDict`, named to match :func:`toDictFromSolution`.""" + return toDict(model, json=json) + + +def _load_mapping(data): + if isinstance(data, dict): + return data + if isinstance(data, str): + if os.path.isfile(data): + extension = os.path.splitext(data)[1].lower() + if extension == ".msgpack": + import msgpack + import msgpack_numpy + + msgpack_numpy.patch() + with open(data, "rb") as f: + return msgpack.load(f, strict_map_key=False) + if extension == ".zlib": + with open(data, "rb") as f: + return json_module.loads(zlib.decompress(f.read())) + with open(data, "r", encoding="utf-8") as f: + return json_module.load(f) + return json_module.loads(data) + raise TypeError( + f"Unsupported input type {type(data)!r}; expected dict, JSON string, " + "or .json/.msgpack/.zlib path" + ) + + +def _as_array(value, dtype=None): + if value is None: + return None + if isinstance(value, list): + if any(x in ("inf", "ninf") for x in value): + value = [ + np.inf if x == "inf" else -np.inf if x == "ninf" else x + for x in value + ] + return np.array(value) if dtype is None else np.array(value, dtype) + return value + + +def _section(payload, name): + value = payload.get(name) + return value if isinstance(value, dict) else {} + + +def _fill_data_model(payload): + data_model = DataModel() + csr = _section(payload, "csr_constraint_matrix") + if not csr: + raise ValueError("cuOpt LP dict is missing csr_constraint_matrix") + data_model.set_csr_constraint_matrix( + _as_array(csr.get("values"), np.float64), + _as_array(csr.get("indices"), np.int32), + _as_array(csr.get("offsets"), np.int32), + ) + + constraint_bounds = _section(payload, "constraint_bounds") + bounds = _as_array(constraint_bounds.get("bounds"), np.float64) + if bounds is not None: + data_model.set_constraint_bounds(bounds) + types = _as_array(constraint_bounds.get("types")) + if types is not None and len(types): + data_model.set_row_types(types) + upper = _as_array(constraint_bounds.get("upper_bounds"), np.float64) + if upper is not None and len(upper): + data_model.set_constraint_upper_bounds(upper) + lower = _as_array(constraint_bounds.get("lower_bounds"), np.float64) + if lower is not None and len(lower): + data_model.set_constraint_lower_bounds(lower) + + objective = _section(payload, "objective_data") + coefficients = _as_array(objective.get("coefficients"), np.float64) + if coefficients is not None: + data_model.set_objective_coefficients(coefficients) + if objective.get("scalability_factor") is not None: + data_model.set_objective_scaling_factor( + objective["scalability_factor"] + ) + if objective.get("offset") is not None: + data_model.set_objective_offset(objective["offset"]) + + variable_bounds = _section(payload, "variable_bounds") + v_upper = _as_array(variable_bounds.get("upper_bounds"), np.float64) + if v_upper is not None: + data_model.set_variable_upper_bounds(v_upper) + v_lower = _as_array(variable_bounds.get("lower_bounds"), np.float64) + if v_lower is not None: + data_model.set_variable_lower_bounds(v_lower) + + initial = _section(payload, "initial_solution") + primal = _as_array(initial.get("primal"), np.float64) + if primal is not None: + data_model.set_initial_primal_solution(primal) + dual = _as_array(initial.get("dual"), np.float64) + if dual is not None: + data_model.set_initial_dual_solution(dual) + + if payload.get("maximize") is not None: + data_model.set_maximize(payload["maximize"]) + if payload.get("variable_types") is not None: + data_model.set_variable_types(_as_array(payload["variable_types"])) + if payload.get("variable_names") is not None: + data_model.set_variable_names(payload["variable_names"]) + return data_model + + +def _fill_solver_settings(payload, warmstart_data=None): + solver_settings = SolverSettings() + solver_config = _section(payload, "solver_config") + if not solver_config and warmstart_data is None: + return solver_settings + + tolerances = _section(solver_config, "tolerances") + if tolerances.get("optimality") is not None: + solver_settings.set_optimality_tolerance(tolerances["optimality"]) + for param in solver_params: + if param in _TOLERANCE_EXCEPTIONS or param.endswith("_tolerance"): + param_value = tolerances.get(param) + else: + param_value = solver_config.get(param) + if param_value is not None and param_value != "": + if isinstance(param_value, bool): + param_value = int(param_value) + solver_settings.set_parameter(param, param_value) + + if warmstart_data is not None: + solver_settings.set_pdlp_warm_start_data(warmstart_data) + return solver_settings + + +def toDataModelAndSettings(data, warmstart_data=None): + """Convert a cuOpt LP/MIP dict to ``(DataModel, SolverSettings)``. + + Lists become numpy arrays and the strings + ``"inf"`` / ``"ninf"`` become IEEE infinities. + + Parameters + ---------- + data : dict or str + A cuOpt JSON dictionary, a JSON string, or the path to a ``.json``, + ``.msgpack``, or ``.zlib`` file holding one. + warmstart_data : optional + PDLP warm-start blob passed to + :meth:`SolverSettings.set_pdlp_warm_start_data`. + + Returns + ------- + (data_model, solver_settings) : tuple + ``DataModel`` and ``SolverSettings`` ready for + ``cuopt.linear_programming.Solve`` or + ``cuopt.grpc.linear_programming.Client.submit``. The settings are + built from the payload's ``solver_config``, and are left at their + defaults when it is absent. + """ + payload = _load_mapping(data) + return _fill_data_model(payload), _fill_solver_settings( + payload, warmstart_data=warmstart_data + ) + + +def toDictFromSolution(sol): + """Map a cuOpt ``Solution`` to a cuOpt http server response dictionary. + + Produces the same envelope as ``cuopt_server`` (``reqId``, + ``response.solver_response``, ``vars``, list-encoded arrays), so a + locally or gRPC-obtained solution can be handed to code written + against the http response. Fields that ``Solution`` does not + provide (``reqId``, ``warnings``, ``total_solve_time``) are ``None``; + fill them in afterward if you need them. The PDLP warm-start blob + is omitted; the server serves it from a separate endpoint. + + Parameters + ---------- + sol : cuopt.linear_programming.solution.Solution + Result from a local ``Solve`` or ``Client.result``. + """ + solution = {} + status = sol.get_termination_status() + + if status in _SOLUTION_STATUSES: + is_lp = sol.get_problem_category() == ProblemCategory.LP + solution["problem_category"] = sol.get_problem_category().name + solution["primal_solution"] = _tolist(sol.get_primal_solution()) + solution["primal_objective"] = sol.get_primal_objective() + solution["solver_time"] = sol.get_solve_time() + solution["solved_by"] = sol.get_solved_by().name + solution["vars"] = sol.get_vars() + if is_lp: + solution["dual_solution"] = _tolist(sol.get_dual_solution()) + solution["dual_objective"] = sol.get_dual_objective() + solution["reduced_cost"] = _tolist(sol.get_reduced_cost()) + lp_stats = sol.get_lp_stats() + solution["lp_statistics"] = {} if lp_stats is None else lp_stats + solution["milp_statistics"] = {} + else: + solution["dual_solution"] = None + solution["dual_objective"] = None + solution["reduced_cost"] = None + solution["lp_statistics"] = {} + milp_stats = sol.get_milp_stats() + solution["milp_statistics"] = ( + {} if milp_stats is None else milp_stats + ) + + return { + "reqId": None, + "response": { + "solver_response": {"status": status.name, "solution": solution}, + "total_solve_time": None, + }, + "warnings": None, + "notes": [sol.get_termination_reason()], + } diff --git a/python/cuopt/cuopt/tests/linear_programming/test_dict_convert.py b/python/cuopt/cuopt/tests/linear_programming/test_dict_convert.py new file mode 100644 index 0000000000..c1fbe48c73 --- /dev/null +++ b/python/cuopt/cuopt/tests/linear_programming/test_dict_convert.py @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for toDict / toDataModelAndSettings / toDictFromSolution.""" + +import copy +import json +import os +import tempfile +import zlib +from types import SimpleNamespace + +import msgpack +import msgpack_numpy +import numpy as np + +from cuopt.linear_programming.io import ( + toDataModelAndSettings, + toDict, + toDictFromSolution, +) +from cuopt.linear_programming.solution.solution import ( + LPTerminationStatus, + ProblemCategory, +) + +LP_EXAMPLE = { + "csr_constraint_matrix": { + "offsets": [0, 2, 4], + "indices": [0, 1, 0, 1], + "values": [3.0, 4.0, 2.7, 10.1], + }, + "constraint_bounds": { + "upper_bounds": [5.4, 4.9], + "lower_bounds": ["ninf", "ninf"], + }, + "objective_data": { + "coefficients": [0.2, 0.1], + "scalability_factor": 1.0, + "offset": 0.0, + }, + "variable_bounds": { + "upper_bounds": ["inf", "inf"], + "lower_bounds": [0.0, 0.0], + }, + "maximize": False, + "variable_names": ["x", "y"], + "solver_config": {"tolerances": {"optimality": 0.0001}, "time_limit": 5}, +} + + +class _FakeSol: + def get_termination_status(self): + return LPTerminationStatus.Optimal + + def get_termination_reason(self): + return "Optimal" + + def get_problem_category(self): + return ProblemCategory.LP + + def get_primal_solution(self): + return np.array([1.0, 2.0]) + + def get_dual_solution(self): + return np.array([0.5]) + + def get_primal_objective(self): + return 3.0 + + def get_dual_objective(self): + return 3.0 + + def get_solve_time(self): + return 0.01 + + def get_solved_by(self): + return SimpleNamespace(name="PDLP") + + def get_vars(self): + return {"x": 1.0, "y": 2.0} + + def get_lp_stats(self): + return {"nb_iterations": 1} + + def get_reduced_cost(self): + return np.array([0.0, 0.0]) + + def get_milp_stats(self): + return None + + +def test_to_data_model_from_mapping(): + dm, settings = toDataModelAndSettings(copy.deepcopy(LP_EXAMPLE)) + assert len(dm.get_objective_coefficients()) == 2 + assert np.allclose(dm.get_objective_coefficients(), [0.2, 0.1]) + assert np.isinf(dm.get_variable_upper_bounds()).all() + assert np.isneginf(dm.get_constraint_lower_bounds()).all() + assert list(dm.get_variable_names()) == ["x", "y"] + assert settings.get_parameter("time_limit") == 5 + assert settings.get_parameter("absolute_primal_tolerance") == 0.0001 + + +def test_to_data_model_defaults_without_solver_config(): + payload = copy.deepcopy(LP_EXAMPLE) + del payload["solver_config"] + dm, settings = toDataModelAndSettings(payload) + assert len(dm.get_objective_coefficients()) == 2 + assert settings.settings_dict == {} + + +def test_to_data_model_reads_mip_gap_from_nested_tolerances(): + payload = copy.deepcopy(LP_EXAMPLE) + payload["solver_config"]["tolerances"]["mip_relative_gap"] = 0.01 + payload["solver_config"]["tolerances"]["mip_absolute_gap"] = 0.02 + _dm, settings = toDataModelAndSettings(payload) + assert settings.get_parameter("mip_relative_gap") == 0.01 + assert settings.get_parameter("mip_absolute_gap") == 0.02 + + +def test_to_data_model_coerces_boolean_solver_parameters_for_grpc(): + payload = copy.deepcopy(LP_EXAMPLE) + payload["solver_config"]["log_to_console"] = False + payload["solver_config"]["mip_scaling"] = True + _dm, settings = toDataModelAndSettings(payload) + assert settings.settings_dict["log_to_console"] == 0 + assert settings.settings_dict["mip_scaling"] == 1 + + +def test_to_data_model_from_json_file(): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as fh: + json.dump(LP_EXAMPLE, fh) + path = fh.name + try: + dm, _settings = toDataModelAndSettings(path) + assert len(dm.get_objective_coefficients()) == 2 + finally: + os.unlink(path) + + +def test_to_data_model_from_msgpack_file(): + msgpack_numpy.patch() + payload = copy.deepcopy(LP_EXAMPLE) + payload["objective_data"]["coefficients"] = np.array([0.2, 0.1]) + with tempfile.NamedTemporaryFile(suffix=".msgpack", delete=False) as fh: + fh.write(msgpack.dumps(payload)) + path = fh.name + try: + dm, settings = toDataModelAndSettings(path) + assert np.allclose(dm.get_objective_coefficients(), [0.2, 0.1]) + assert settings.get_parameter("time_limit") == 5 + finally: + os.unlink(path) + + +def test_to_data_model_from_zlib_file(): + with tempfile.NamedTemporaryFile(suffix=".zlib", delete=False) as fh: + fh.write(zlib.compress(json.dumps(LP_EXAMPLE).encode())) + path = fh.name + try: + dm, settings = toDataModelAndSettings(path) + assert np.allclose(dm.get_objective_coefficients(), [0.2, 0.1]) + assert settings.get_parameter("time_limit") == 5 + finally: + os.unlink(path) + + +def test_to_dict_round_trip_json_true_and_false(): + """A DataModel survives toDict -> toDataModelAndSettings for every schema field.""" + payload = copy.deepcopy(LP_EXAMPLE) + payload["initial_solution"] = {"primal": [0.1, 0.2], "dual": [0.0, 1.0]} + dm, _settings = toDataModelAndSettings(payload) + + for as_json in (True, False): + encoded = toDict(dm, json=as_json) + assert "solver_config" not in encoded + dm2, _settings2 = toDataModelAndSettings(encoded) + assert np.allclose( + dm.get_objective_coefficients(), dm2.get_objective_coefficients() + ) + assert np.allclose( + dm.get_variable_lower_bounds(), dm2.get_variable_lower_bounds() + ) + assert np.isinf(dm2.get_variable_upper_bounds()).all() + assert np.isneginf(dm2.get_constraint_lower_bounds()).all() + assert list(dm2.get_variable_names()) == ["x", "y"] + assert np.allclose(dm2.initial_primal_solution, [0.1, 0.2]) + assert np.allclose(dm2.initial_dual_solution, [0.0, 1.0]) + + +def test_to_dict_emits_only_the_starts_that_are_set(): + payload = copy.deepcopy(LP_EXAMPLE) + payload["initial_solution"] = {"primal": [0.1, 0.2]} + dm, _settings = toDataModelAndSettings(payload) + for as_json in (True, False): + initial = toDict(dm, json=as_json)["initial_solution"] + assert np.allclose(initial["primal"], [0.1, 0.2]) + assert "dual" not in initial + + +def test_to_dict_omits_initial_solution_when_model_has_none(): + dm, _settings = toDataModelAndSettings(copy.deepcopy(LP_EXAMPLE)) + assert "initial_solution" not in toDict(dm, json=True) + assert "initial_solution" not in toDict(dm, json=False) + + +def test_to_dict_from_solution_envelope(): + body = toDictFromSolution(_FakeSol()) + assert body["reqId"] is None + assert body["warnings"] is None + assert body["response"]["total_solve_time"] is None + solver = body["response"]["solver_response"] + assert solver["status"] == "Optimal" + sol = solver["solution"] + assert sol["primal_objective"] == 3.0 + assert sol["primal_solution"] == [1.0, 2.0] + assert sol["vars"] == {"x": 1.0, "y": 2.0} + assert sol["solved_by"] == "PDLP" + assert "pdlpwarmstart_data" not in sol + assert sol["lp_statistics"] == {"nb_iterations": 1} + assert sol["milp_statistics"] == {} + assert body["notes"] == ["Optimal"] diff --git a/python/cuopt/pyproject.toml b/python/cuopt/pyproject.toml index 06eb1c9998..c44fa32539 100644 --- a/python/cuopt/pyproject.toml +++ b/python/cuopt/pyproject.toml @@ -22,6 +22,8 @@ dependencies = [ "cudf==26.10.*,>=0.0.0a0", "cupy-cuda13x[ctk]>=14.0.1,!=14.1.0", "libcuopt==26.10.*,>=0.0.0a0", + "msgpack-numpy==0.4.8", + "msgpack==1.2.1", "numba-cuda>=0.22.1", "numba>=0.60.0,<0.65.0", "numpy>=2.0,<3.0",