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",