From bf9fc2129a7758ae22fbade1070b086912a2af5a Mon Sep 17 00:00:00 2001 From: Zedong Peng Date: Mon, 6 Jul 2026 11:26:03 -0400 Subject: [PATCH 1/4] Add cupdlpx.read to load MPS files from Python --- CMakeLists.txt | 4 - include/mps_parser.h | 11 +- python/README.md | 13 +++ python/cupdlpx/__init__.py | 4 +- python/cupdlpx/_core.py | 2 +- python/cupdlpx/model.py | 33 +++++- python_bindings/_core_bindings.cpp | 61 +++++++++++ src/mps_parser.c | 6 ++ test/test_read_mps.py | 164 +++++++++++++++++++++++++++++ 9 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 test/test_read_mps.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 8ba7aac..ec8e6b1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -193,10 +193,6 @@ target_compile_definitions(cupdlpx_compile_flags INTERFACE PSLP_VERSION="${PSLP_ file(GLOB C_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/*.c") file(GLOB CU_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cu") list(REMOVE_ITEM C_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/cli.c") -if(WIN32) - # mps_parser.c is CLI-only; exclude it on Windows where strtok_r is unavailable - list(REMOVE_ITEM C_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/mps_parser.c") -endif() set(CORE_INCLUDE_DIRS PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include diff --git a/include/mps_parser.h b/include/mps_parser.h index a1eed4f..b448a76 100644 --- a/include/mps_parser.h +++ b/include/mps_parser.h @@ -18,4 +18,13 @@ limitations under the License. #include "cupdlpx_types.h" -lp_problem_t *read_mps_file(const char *filename); +#ifdef __cplusplus +extern "C" +{ +#endif + + lp_problem_t *read_mps_file(const char *filename); + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/python/README.md b/python/README.md index 47d0a66..898349a 100644 --- a/python/README.md +++ b/python/README.md @@ -123,6 +123,19 @@ m = Model(objective_vector=c, variable_upper_bound=ub) ``` +### Reading from MPS Files + +A `Model` can also be created directly from an MPS file (plain or gzip-compressed) with `cupdlpx.read`, similar to `gurobipy.read`: + +```python +import cupdlpx + +m = cupdlpx.read("problem.mps") # or "problem.mps.gz" +m.optimize() +``` + +The objective sense declared in the file (`OBJSENSE` section) is applied to `ModelSense` automatically. + ## Model Sense diff --git a/python/cupdlpx/__init__.py b/python/cupdlpx/__init__.py index 20bb776..00ea958 100644 --- a/python/cupdlpx/__init__.py +++ b/python/cupdlpx/__init__.py @@ -23,10 +23,10 @@ if os.path.isdir(bin_path): os.add_dll_directory(bin_path) -from .model import Model +from .model import Model, read from . import PDLP -__all__ = ["Model"] +__all__ = ["Model", "read"] # versioning from importlib.metadata import version, PackageNotFoundError diff --git a/python/cupdlpx/_core.py b/python/cupdlpx/_core.py index c29e6dd..dd87cfb 100644 --- a/python/cupdlpx/_core.py +++ b/python/cupdlpx/_core.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -from ._cupdlpx_core import solve_once, get_default_params \ No newline at end of file +from ._cupdlpx_core import solve_once, get_default_params, read_mps \ No newline at end of file diff --git a/python/cupdlpx/model.py b/python/cupdlpx/model.py index 873795f..87239f6 100644 --- a/python/cupdlpx/model.py +++ b/python/cupdlpx/model.py @@ -13,13 +13,14 @@ # limitations under the License. from __future__ import annotations +import os import warnings from typing import Any, Optional, Union import numpy as np import scipy.sparse as sp -from ._core import solve_once, get_default_params +from ._core import solve_once, get_default_params, read_mps from . import PDLP # array-like type @@ -49,6 +50,36 @@ def _as_csr_f64_i32(A: sp.spmatrix) -> sp.csr_matrix: return csr +def read(filename: Union[str, os.PathLike]) -> "Model": + """ + Read a linear program from an MPS file (plain or gzip-compressed) and + return a Model, similar to gurobipy.read. + + Parameters: + - filename: Path to a .mps or .mps.gz file. + """ + filename = os.fspath(filename) + if not os.path.isfile(filename): + raise FileNotFoundError(f"No such MPS file: {filename}") + data = read_mps(str(filename)) + m = int(data["num_constraints"]) + n = int(data["num_variables"]) + A = sp.csr_matrix( + (data["values"], data["col_ind"], data["row_ptr"]), shape=(m, n) + ) + model = Model( + objective_vector=data["objective_vector"], + constraint_matrix=A, + constraint_lower_bound=data["constraint_lower_bound"], + constraint_upper_bound=data["constraint_upper_bound"], + variable_lower_bound=data["variable_lower_bound"], + variable_upper_bound=data["variable_upper_bound"], + objective_constant=data["objective_constant"], + ) + model.ModelSense = PDLP.MAXIMIZE if data["maximize"] else PDLP.MINIMIZE + return model + + class _ParamsView: """ A view of the model parameters that allows getting/setting via attributes or keys. diff --git a/python_bindings/_core_bindings.cpp b/python_bindings/_core_bindings.cpp index 390470c..634df80 100644 --- a/python_bindings/_core_bindings.cpp +++ b/python_bindings/_core_bindings.cpp @@ -25,6 +25,7 @@ limitations under the License. #include #include "cupdlpx.h" +#include "mps_parser.h" namespace py = pybind11; @@ -580,6 +581,61 @@ static py::dict solve_once(py::object A, return info; } +// read an MPS file into problem data arrays +static py::dict read_mps_py(const std::string &filename) +{ + lp_problem_t *prob = nullptr; + { + // release GIL during file parsing + py::gil_scoped_release release; + prob = read_mps_file(filename.c_str()); + } + if (!prob) + { + throw std::runtime_error("Failed to read MPS file: " + filename); + } + + const int n = prob->num_variables; + const int m = prob->num_constraints; + const int nnz = prob->constraint_matrix_num_nonzeros; + + auto copy_f64 = [](const double *src, int len) + { + py::array_t out({len}); + if (src && len > 0) + { + std::memcpy(out.request().ptr, src, sizeof(double) * static_cast(len)); + } + return out; + }; + auto copy_i32 = [](const int *src, int len) + { + py::array_t out({len}); + if (src && len > 0) + { + std::memcpy(out.request().ptr, src, sizeof(int32_t) * static_cast(len)); + } + return out; + }; + + py::dict d; + d["num_variables"] = n; + d["num_constraints"] = m; + d["objective_vector"] = copy_f64(prob->objective_vector, n); + d["objective_constant"] = prob->objective_constant; + d["maximize"] = (prob->objective_sense == OBJECTIVE_SENSE_MAXIMIZE); + d["row_ptr"] = copy_i32(prob->constraint_matrix_row_pointers, m + 1); + d["col_ind"] = copy_i32(prob->constraint_matrix_col_indices, nnz); + d["values"] = copy_f64(prob->constraint_matrix_values, nnz); + d["constraint_lower_bound"] = copy_f64(prob->constraint_lower_bound, m); + d["constraint_upper_bound"] = copy_f64(prob->constraint_upper_bound, m); + d["variable_lower_bound"] = copy_f64(prob->variable_lower_bound, n); + d["variable_upper_bound"] = copy_f64(prob->variable_upper_bound, n); + + lp_problem_free(prob); + return d; +} + // module PYBIND11_MODULE(_cupdlpx_core, m) { @@ -587,6 +643,11 @@ PYBIND11_MODULE(_cupdlpx_core, m) m.def("get_default_params", &get_default_params_py, "Return default PDHG parameters as a dict"); + m.def("read_mps", + &read_mps_py, + py::arg("filename"), + "Read an MPS file (optionally gzip-compressed) and return the LP data as a dict"); + m.def("solve_once", &solve_once, py::arg("A"), diff --git a/src/mps_parser.c b/src/mps_parser.c index 5ae437b..825cadd 100644 --- a/src/mps_parser.c +++ b/src/mps_parser.c @@ -25,6 +25,12 @@ limitations under the License. #include #include +// MSVC ships the same functionality under different names +#ifdef _MSC_VER +#define strtok_r strtok_s +#define strdup _strdup +#endif + #define READER_BUFFER_SIZE (4 * 1024 * 1024) typedef struct NameNode diff --git a/test/test_read_mps.py b/test/test_read_mps.py new file mode 100644 index 0000000..91937c4 --- /dev/null +++ b/test/test_read_mps.py @@ -0,0 +1,164 @@ +# Copyright 2025 Haihao Lu +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import gzip + +import numpy as np +import pytest +import scipy.sparse as sp + +import cupdlpx +from cupdlpx import Model, PDLP + +# Same LP as base_lp_data in conftest.py: +# Minimize x1 + x2 +# Subject to +# x1 + 2*x2 == 5 +# x2 <= 2 +# 3*x1 + 2*x2 <= 8 +# x1, x2 >= 0 +MPS_MIN = """NAME TESTLP +ROWS + N COST + E R1 + L R2 + L R3 +COLUMNS + X1 COST 1.0 R1 1.0 + X1 R3 3.0 + X2 COST 1.0 R1 2.0 + X2 R2 1.0 R3 2.0 +RHS + RHS R1 5.0 R2 2.0 + RHS R3 8.0 +ENDATA +""" + +# Same LP but maximization via OBJSENSE +MPS_MAX = MPS_MIN.replace( + "ROWS\n", + "OBJSENSE\n MAXIMIZE\nROWS\n", +) + + +@pytest.fixture +def mps_min_file(tmp_path): + path = tmp_path / "test_min.mps" + path.write_text(MPS_MIN) + return path + + +@pytest.fixture +def mps_max_file(tmp_path): + path = tmp_path / "test_max.mps" + path.write_text(MPS_MAX) + return path + + +@pytest.fixture +def mps_gz_file(tmp_path): + path = tmp_path / "test_min.mps.gz" + with gzip.open(path, "wt") as f: + f.write(MPS_MIN) + return path + + +def _check_min_model_data(model): + """ + Verify the parsed model matches the LP encoded in MPS_MIN. + """ + assert isinstance(model, Model) + assert model.num_vars == 2 + assert model.num_constrs == 3 + # objective + assert np.allclose(model.c, [1.0, 1.0]) + assert model.c0 == 0.0 + # constraint matrix + A = model.A.toarray() if sp.issparse(model.A) else np.asarray(model.A) + assert np.allclose(A, [[1.0, 2.0], [0.0, 1.0], [3.0, 2.0]]) + # constraint bounds + assert np.allclose(model.constr_lb, [5.0, -np.inf, -np.inf]) + assert np.allclose(model.constr_ub, [5.0, 2.0, 8.0]) + # variable bounds (MPS default: 0 <= x < inf) + assert np.allclose(model.lb, [0.0, 0.0]) + assert np.allclose(model.ub, [np.inf, np.inf]) + + +def test_read_builds_model(mps_min_file): + """ + cupdlpx.read should build a Model with the exact problem data. + """ + model = cupdlpx.read(str(mps_min_file)) + _check_min_model_data(model) + assert model.ModelSense == PDLP.MINIMIZE + + +def test_read_accepts_pathlike(mps_min_file): + """ + cupdlpx.read should accept a pathlib.Path. + """ + model = cupdlpx.read(mps_min_file) + _check_min_model_data(model) + + +def test_read_gzip(mps_gz_file): + """ + cupdlpx.read should read .mps.gz files. + """ + model = cupdlpx.read(str(mps_gz_file)) + _check_min_model_data(model) + + +def test_read_objsense_maximize(mps_max_file): + """ + OBJSENSE MAXIMIZE should set ModelSense to PDLP.MAXIMIZE without + negating the objective vector. + """ + model = cupdlpx.read(str(mps_max_file)) + _check_min_model_data(model) + assert model.ModelSense == PDLP.MAXIMIZE + + +def test_read_missing_file_raises(tmp_path): + """ + Reading a nonexistent file should raise FileNotFoundError. + """ + with pytest.raises(FileNotFoundError): + cupdlpx.read(str(tmp_path / "no_such_file.mps")) + + +def test_read_and_optimize_minimize(mps_min_file, atol): + """ + Solve the model built from the MPS file and verify the solution. + Optimal solution: x* = (1, 2), objective = 3 + """ + model = cupdlpx.read(str(mps_min_file)) + model.setParams(OutputFlag=False, Presolve=False) + model.optimize() + assert model.Status == "OPTIMAL", f"Unexpected termination status: {model.Status}" + assert np.allclose(model.X, [1, 2], atol=atol), f"Unexpected primal solution: {model.X}" + assert np.isclose(model.ObjVal, 3, atol=atol), f"Unexpected objective value: {model.ObjVal}" + + +def test_read_and_optimize_maximize(mps_max_file, atol): + """ + Solve the maximization model built from the MPS file. + Optimal solution: x* = (1.5, 1.75), objective = 3.25 + """ + model = cupdlpx.read(str(mps_max_file)) + model.setParams(OutputFlag=False, Presolve=False) + model.optimize() + assert model.Status == "OPTIMAL", f"Unexpected termination status: {model.Status}" + assert np.allclose(model.X, [1.5, 1.75], atol=atol), f"Unexpected primal solution: {model.X}" + assert np.isclose(model.ObjVal, 3.25, atol=atol), f"Unexpected objective value: {model.ObjVal}" From 3b894acc5bfb399fdd6f0850a91747baf00b0749 Mon Sep 17 00:00:00 2001 From: Zedong Peng Date: Mon, 6 Jul 2026 11:35:13 -0400 Subject: [PATCH 2/4] update feasibility polishing summary log --- src/utils.cu | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils.cu b/src/utils.cu index edef51d..4d263b3 100644 --- a/src/utils.cu +++ b/src/utils.cu @@ -1180,10 +1180,11 @@ void pdhg_feas_polish_final_log(const pdhg_solver_state_t *primal_state, const pdhg_solver_state_t *dual_state, bool verbose) { - if (verbose) + if (!verbose) { - printf("---------------------------------------------------------------------------------------\n"); + return; } + printf("---------------------------------------------------------------------------------------\n"); printf("Feasibility Polishing Summary\n"); printf(" Primal Status : %s\n", termination_reason_to_string(primal_state->termination_reason)); printf(" Primal Iterations : %d\n", primal_state->total_count); From 4c4e0e1b01d8fb7b71d7775fdb0818d4bfa6ef2d Mon Sep 17 00:00:00 2001 From: Zedong Peng Date: Mon, 6 Jul 2026 21:54:28 -0400 Subject: [PATCH 3/4] Remove redundant MSVC shim in mps_parser.c --- src/mps_parser.c | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/mps_parser.c b/src/mps_parser.c index 825cadd..5ae437b 100644 --- a/src/mps_parser.c +++ b/src/mps_parser.c @@ -25,12 +25,6 @@ limitations under the License. #include #include -// MSVC ships the same functionality under different names -#ifdef _MSC_VER -#define strtok_r strtok_s -#define strdup _strdup -#endif - #define READER_BUFFER_SIZE (4 * 1024 * 1024) typedef struct NameNode From c4e06b992b1af11e78b9ba166fa0afa927e5213f Mon Sep 17 00:00:00 2001 From: Zedong Peng Date: Mon, 6 Jul 2026 21:58:29 -0400 Subject: [PATCH 4/4] Free lp_problem_t on exception in read_mps_py --- python_bindings/_core_bindings.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python_bindings/_core_bindings.cpp b/python_bindings/_core_bindings.cpp index 634df80..f2919de 100644 --- a/python_bindings/_core_bindings.cpp +++ b/python_bindings/_core_bindings.cpp @@ -17,6 +17,7 @@ limitations under the License. #include #include #include +#include #include #include #include @@ -594,6 +595,8 @@ static py::dict read_mps_py(const std::string &filename) { throw std::runtime_error("Failed to read MPS file: " + filename); } + // free the problem even if a conversion below throws + std::unique_ptr guard(prob, &lp_problem_free); const int n = prob->num_variables; const int m = prob->num_constraints; @@ -632,7 +635,6 @@ static py::dict read_mps_py(const std::string &filename) d["variable_lower_bound"] = copy_f64(prob->variable_lower_bound, n); d["variable_upper_bound"] = copy_f64(prob->variable_upper_bound, n); - lp_problem_free(prob); return d; }