Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion include/mps_parser.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions python/cupdlpx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion python/cupdlpx/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
from ._cupdlpx_core import solve_once, get_default_params, read_mps
33 changes: 32 additions & 1 deletion python/cupdlpx/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions python_bindings/_core_bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
#include <cstdint>
#include <cstring>
#include <limits>
#include <memory>
#include <pybind11/numpy.h>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
Expand All @@ -25,6 +26,7 @@ limitations under the License.
#include <vector>

#include "cupdlpx.h"
#include "mps_parser.h"

namespace py = pybind11;

Expand Down Expand Up @@ -580,13 +582,74 @@ 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);
}
// free the problem even if a conversion below throws
std::unique_ptr<lp_problem_t, decltype(&lp_problem_free)> guard(prob, &lp_problem_free);

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<double> out({len});
if (src && len > 0)
{
std::memcpy(out.request().ptr, src, sizeof(double) * static_cast<size_t>(len));
}
return out;
};
auto copy_i32 = [](const int *src, int len)
{
py::array_t<int32_t> out({len});
if (src && len > 0)
{
std::memcpy(out.request().ptr, src, sizeof(int32_t) * static_cast<size_t>(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);

return d;
}

// module
PYBIND11_MODULE(_cupdlpx_core, m)
{
m.doc() = "cupdlpx core bindings (auto-detect dense/CSR/CSC/COO; initialize default params here)";

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"),
Expand Down
5 changes: 3 additions & 2 deletions src/utils.cu
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
164 changes: 164 additions & 0 deletions test/test_read_mps.py
Original file line number Diff line number Diff line change
@@ -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}"
Loading