diff --git a/cpp/include/cuopt/mathematical_optimization/constants.h b/cpp/include/cuopt/mathematical_optimization/constants.h index 5e720a645e..6bac2a402e 100644 --- a/cpp/include/cuopt/mathematical_optimization/constants.h +++ b/cpp/include/cuopt/mathematical_optimization/constants.h @@ -191,6 +191,7 @@ /* @brief File format constants for problem I/O */ #define CUOPT_FILE_FORMAT_MPS 0 +#define CUOPT_FILE_FORMAT_LP 1 /* @brief Status codes constants */ #define CUOPT_SUCCESS 0 diff --git a/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp b/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp index fd4c50fa75..7222f697dd 100644 --- a/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp +++ b/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp @@ -164,10 +164,11 @@ class cpu_optimization_problem_t : public optimization_problem_interface_t +#include + +#include +#include + +namespace cuopt::mathematical_optimization::io { + +/** + * @brief Main writer class for LP files + * + * Writes an optimization problem to a file in the LP format understood by + * read_lp(). The emitted dialect is a superset of what a typical solver + * expects and round-trips through read_lp(): + * - Minimize / Maximize objective (with optional quadratic '[ ... ] / 2' term) + * - Subject To constraints (linear, plus optional quadratic '[ ... ]' term) + * - Bounds + * - Generals / Binaries / Semi-Continuous variable sections + * + * Notes / limitations that mirror the LP format itself: + * - Range rows (a linear row with two distinct finite bounds) are emitted as + * two constraints ('_lo' with '>=' and '_up' with '<=') because the LP + * format has no single-line ranged-row syntax. + * - The objective scaling factor is not representable in LP format and is + * therefore not written (identical to the MPS writer). + * + * @tparam i_t data type of the indices + * @tparam f_t data type of the weights and variables + */ +template +class lp_writer_t { + public: + /** + * @brief Ctor. Takes a data model view as input and writes it out as an LP formatted file + * + * @param[in] problem Data model view to write + */ + lp_writer_t(const data_model_view_t& problem); + + /** + * @brief Ctor. Takes a data model as input and writes it out as an LP formatted file + * + * @param[in] problem Data model to write + */ + lp_writer_t(const mps_data_model_t& problem); + + /** + * @brief Writes the problem to an LP formatted file + * + * @param[in] lp_file_path Path to the LP file to write + */ + void write(const std::string& lp_file_path); + + private: + // Owned view (created when constructing from mps_data_model_t) + std::unique_ptr> owned_view_; + // Reference to the view (either external or owned) + const data_model_view_t& problem_; + + // Helper to create view from data model + static data_model_view_t create_view(const mps_data_model_t& model); +}; // class lp_writer_t + +} // namespace cuopt::mathematical_optimization::io diff --git a/cpp/include/cuopt/mathematical_optimization/io/mps_data_model.hpp b/cpp/include/cuopt/mathematical_optimization/io/mps_data_model.hpp index 4ed1d7244f..6e46226c32 100644 --- a/cpp/include/cuopt/mathematical_optimization/io/mps_data_model.hpp +++ b/cpp/include/cuopt/mathematical_optimization/io/mps_data_model.hpp @@ -329,7 +329,7 @@ class mps_data_model_t { bool has_quadratic_constraints() const noexcept; /** whether to maximize or minimize the objective function */ - bool maximize_; + bool maximize_{false}; /** * the constraint matrix itself in the CSR format * @{ diff --git a/cpp/include/cuopt/mathematical_optimization/io/writer.hpp b/cpp/include/cuopt/mathematical_optimization/io/writer.hpp index 032865220e..9e70df2161 100644 --- a/cpp/include/cuopt/mathematical_optimization/io/writer.hpp +++ b/cpp/include/cuopt/mathematical_optimization/io/writer.hpp @@ -23,4 +23,16 @@ namespace cuopt::mathematical_optimization::io { template void write_mps(const data_model_view_t& problem, const std::string& mps_file_path); +/** + * @brief Writes the problem to an LP formatted file + * + * Emits the LP format understood by read_lp(). + * Supports LP, MIP, and QP/QCQP problems, plus semi-continuous variables. + * + * @param[in] problem The problem data model view to write + * @param[in] lp_file_path Path to the LP file to write + */ +template +void write_lp(const data_model_view_t& problem, const std::string& lp_file_path); + } // namespace cuopt::mathematical_optimization::io diff --git a/cpp/include/cuopt/mathematical_optimization/optimization_problem.hpp b/cpp/include/cuopt/mathematical_optimization/optimization_problem.hpp index 5c755281ca..98b695e8aa 100644 --- a/cpp/include/cuopt/mathematical_optimization/optimization_problem.hpp +++ b/cpp/include/cuopt/mathematical_optimization/optimization_problem.hpp @@ -312,10 +312,11 @@ class optimization_problem_t : public optimization_problem_interface_t // ============================================================================ /** - * @brief Write the optimization problem to an MPS file. - * @param[in] mps_file_path Path to the output MPS file + * @brief Write the optimization problem to a file. + * @param[in] file_path Path to the output file + * @param[in] format File format to write (MPS or LP) */ - void write_to_mps(const std::string& mps_file_path) override; + void write_to_file(const std::string& file_path, file_format_t format) override; /* Print scaling information */ void print_scaling_information() const; diff --git a/cpp/include/cuopt/mathematical_optimization/optimization_problem_interface.hpp b/cpp/include/cuopt/mathematical_optimization/optimization_problem_interface.hpp index 5927703f03..cd9c575060 100644 --- a/cpp/include/cuopt/mathematical_optimization/optimization_problem_interface.hpp +++ b/cpp/include/cuopt/mathematical_optimization/optimization_problem_interface.hpp @@ -13,9 +13,12 @@ #include #include +#include +#include #include #include #include +#include #include #include @@ -24,6 +27,32 @@ namespace cuopt::mathematical_optimization { enum class var_t { CONTINUOUS = 0, INTEGER, SEMI_CONTINUOUS }; enum class problem_category_t : int8_t { LP = 0, MIP = 1, IP = 2 }; +/** @brief File format used when serializing an optimization problem. */ +enum class file_format_t { mps = 0, lp = 1 }; + +/** + * @brief Pick the output file format from a path's extension. + * + * A `.lp` suffix selects LP; `.mps` and `.qps` select MPS/QPS. Compressed + * output is not supported, so `.gz` and `.bz2` suffixes are rejected. + * + * @param[in] path Output file path. + * @return The inferred file format. + */ +inline file_format_t file_format_from_path(const std::string& path) +{ + std::string lower(path); + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (lower.ends_with(".lp")) { return file_format_t::lp; } + if (lower.ends_with(".mps") || lower.ends_with(".qps")) { return file_format_t::mps; } + throw std::invalid_argument( + "write: unrecognized output file extension. Supported (case-insensitive): " + ".mps, .qps, .lp. Compressed output is not supported. Given path: " + + path); +} + template class optimization_problem_t; template @@ -380,10 +409,11 @@ class optimization_problem_interface_t { // ============================================================================ /** - * @brief Write the optimization problem to an MPS file. - * @param[in] mps_file_path Path to the output MPS file + * @brief Write the optimization problem to a file. + * @param[in] file_path Path to the output file + * @param[in] format File format to write (MPS or LP) */ - virtual void write_to_mps(const std::string& mps_file_path) = 0; + virtual void write_to_file(const std::string& file_path, file_format_t format) = 0; // ============================================================================ // Comparison diff --git a/cpp/src/io/CMakeLists.txt b/cpp/src/io/CMakeLists.txt index cc4affa890..394de3ed49 100644 --- a/cpp/src/io/CMakeLists.txt +++ b/cpp/src/io/CMakeLists.txt @@ -7,6 +7,7 @@ set(PARSERS_SRC_FILES ${CMAKE_CURRENT_SOURCE_DIR}/data_model_view.cpp ${CMAKE_CURRENT_SOURCE_DIR}/file_to_string.cpp ${CMAKE_CURRENT_SOURCE_DIR}/lp_parser.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/lp_writer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mps_data_model.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mps_parser.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mps_writer.cpp diff --git a/cpp/src/io/lp_writer.cpp b/cpp/src/io/lp_writer.cpp new file mode 100644 index 0000000000..c03d223af6 --- /dev/null +++ b/cpp/src/io/lp_writer.cpp @@ -0,0 +1,531 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::io { + +namespace { + +// The LP format uses these defaults for a variable that is not otherwise +// constrained: lower bound 0, upper bound +infinity. +template +constexpr f_t default_lower() +{ + return f_t(0); +} +template +f_t default_upper() +{ + return std::numeric_limits::infinity(); +} + +} // namespace + +template +lp_writer_t::lp_writer_t(const data_model_view_t& problem) : problem_(problem) +{ +} + +template +data_model_view_t lp_writer_t::create_view( + const mps_data_model_t& model) +{ + data_model_view_t view; + + view.set_maximize(model.get_sense()); + + const auto& A_values = model.get_constraint_matrix_values(); + const auto& A_indices = model.get_constraint_matrix_indices(); + const auto& A_offsets = model.get_constraint_matrix_offsets(); + if (!A_values.empty()) { + view.set_csr_constraint_matrix(A_values.data(), + static_cast(A_values.size()), + A_indices.data(), + static_cast(A_indices.size()), + A_offsets.data(), + static_cast(A_offsets.size())); + } + + const auto& b = model.get_constraint_bounds(); + if (!b.empty()) { view.set_constraint_bounds(b.data(), static_cast(b.size())); } + + const auto& c = model.get_objective_coefficients(); + if (!c.empty()) { view.set_objective_coefficients(c.data(), static_cast(c.size())); } + + view.set_objective_scaling_factor(model.get_objective_scaling_factor()); + view.set_objective_offset(model.get_objective_offset()); + + const auto& lb = model.get_variable_lower_bounds(); + const auto& ub = model.get_variable_upper_bounds(); + if (!lb.empty()) { view.set_variable_lower_bounds(lb.data(), static_cast(lb.size())); } + if (!ub.empty()) { view.set_variable_upper_bounds(ub.data(), static_cast(ub.size())); } + + const auto& var_types = model.get_variable_types(); + if (!var_types.empty()) { + view.set_variable_types(var_types.data(), static_cast(var_types.size())); + } + + const auto& row_types = model.get_row_types(); + if (!row_types.empty()) { + view.set_row_types(row_types.data(), static_cast(row_types.size())); + } + + const auto& cl = model.get_constraint_lower_bounds(); + const auto& cu = model.get_constraint_upper_bounds(); + if (!cl.empty()) { view.set_constraint_lower_bounds(cl.data(), static_cast(cl.size())); } + if (!cu.empty()) { view.set_constraint_upper_bounds(cu.data(), static_cast(cu.size())); } + + view.set_problem_name(model.get_problem_name()); + view.set_objective_name(model.get_objective_name()); + view.set_variable_names(model.get_variable_names()); + view.set_row_names(model.get_row_names()); + + const auto& Q_values = model.get_quadratic_objective_values(); + const auto& Q_indices = model.get_quadratic_objective_indices(); + const auto& Q_offsets = model.get_quadratic_objective_offsets(); + if (!Q_values.empty()) { + view.set_quadratic_objective_matrix(Q_values.data(), + static_cast(Q_values.size()), + Q_indices.data(), + static_cast(Q_indices.size()), + Q_offsets.data(), + static_cast(Q_offsets.size())); + } + + if (model.has_quadratic_constraints()) { + view.set_quadratic_constraints( + std::vector::quadratic_constraint_t>( + model.get_quadratic_constraints())); + } + + return view; +} + +template +lp_writer_t::lp_writer_t(const mps_data_model_t& problem) + : owned_view_(std::make_unique>(create_view(problem))), + problem_(*owned_view_) +{ +} + +template +void lp_writer_t::write(const std::string& lp_file_path) +{ + std::ofstream lp_file(lp_file_path); + + mps_parser_expects(lp_file.is_open(), + error_type_t::ValidationError, + "Error creating output LP file! Given path: %s", + lp_file_path.c_str()); + + const f_t inf = std::numeric_limits::infinity(); + + const auto c_span = problem_.get_objective_coefficients(); + const auto lb_span = problem_.get_variable_lower_bounds(); + const auto ub_span = problem_.get_variable_upper_bounds(); + const auto types_span = problem_.get_variable_types(); + const auto& var_names_ref = problem_.get_variable_names(); + const auto A_values_span = problem_.get_constraint_matrix_values(); + const auto A_indices_span = problem_.get_constraint_matrix_indices(); + const auto A_offsets_span = problem_.get_constraint_matrix_offsets(); + const auto& row_names_ref = problem_.get_row_names(); + const auto& quadratic_constraints = problem_.get_quadratic_constraints(); + + // Variable count is defined by the lower-bound vector (same as the MPS writer). + // Require other per-variable arrays to match when present. + const i_t n_variables = lb_span.size(); + mps_parser_expects(ub_span.size() == static_cast(n_variables), + error_type_t::ValidationError, + "LP writer: variable upper bounds size (%zu) must match lower bounds (%d)", + ub_span.size(), + n_variables); + mps_parser_expects(c_span.empty() || c_span.size() == static_cast(n_variables), + error_type_t::ValidationError, + "LP writer: objective coefficients size (%zu) must match n_variables (%d)", + c_span.size(), + n_variables); + mps_parser_expects(types_span.empty() || types_span.size() == static_cast(n_variables), + error_type_t::ValidationError, + "LP writer: variable types size (%zu) must match n_variables (%d)", + types_span.size(), + n_variables); + mps_parser_expects(var_names_ref.empty() || + var_names_ref.size() == static_cast(n_variables), + error_type_t::ValidationError, + "LP writer: variable names size (%zu) must match n_variables (%d)", + var_names_ref.size(), + n_variables); + + std::vector c(n_variables, f_t(0)); + if (!c_span.empty()) { std::copy(c_span.begin(), c_span.end(), c.begin()); } + + std::vector var_lb(lb_span.begin(), lb_span.end()); + std::vector var_ub(ub_span.begin(), ub_span.end()); + + // Default unset variable types to continuous ('C'); API models often omit types. + std::vector var_types(n_variables, 'C'); + if (!types_span.empty()) { std::copy(types_span.begin(), types_span.end(), var_types.begin()); } + + auto var_name = [&](i_t j) -> std::string { + if (static_cast(j) < var_names_ref.size() && !var_names_ref[j].empty()) { + return var_names_ref[j]; + } + return "C" + std::to_string(j); + }; + + // --- Linear constraint bounds ------------------------------------------- + const auto b_span = problem_.get_constraint_bounds(); + const auto clb_span = problem_.get_constraint_lower_bounds(); + const auto cub_span = problem_.get_constraint_upper_bounds(); + const auto rtype_span = problem_.get_row_types(); + + i_t n_constraints = 0; + if (!b_span.empty()) + n_constraints = b_span.size(); + else if (!clb_span.empty()) + n_constraints = clb_span.size(); + else + n_constraints = cub_span.size(); + + mps_parser_expects(clb_span.empty() || clb_span.size() == static_cast(n_constraints), + error_type_t::ValidationError, + "LP writer: constraint lower bounds size (%zu) must match n_constraints (%d)", + clb_span.size(), + n_constraints); + mps_parser_expects(cub_span.empty() || cub_span.size() == static_cast(n_constraints), + error_type_t::ValidationError, + "LP writer: constraint upper bounds size (%zu) must match n_constraints (%d)", + cub_span.size(), + n_constraints); + mps_parser_expects(b_span.empty() || b_span.size() == static_cast(n_constraints), + error_type_t::ValidationError, + "LP writer: constraint bounds size (%zu) must match n_constraints (%d)", + b_span.size(), + n_constraints); + mps_parser_expects(row_names_ref.empty() || + row_names_ref.size() == static_cast(n_constraints), + error_type_t::ValidationError, + "LP writer: row names size (%zu) must match n_constraints (%d)", + row_names_ref.size(), + n_constraints); + mps_parser_expects(A_offsets_span.empty() || + A_offsets_span.size() == static_cast(n_constraints) + 1, + error_type_t::ValidationError, + "LP writer: CSR offsets size (%zu) must be n_constraints+1 (%d)", + A_offsets_span.size(), + n_constraints + 1); + + std::vector clb(n_constraints); + std::vector cub(n_constraints); + if (clb_span.empty() || cub_span.empty()) { + // Derive from row types + single-sided b (mirrors mps_writer's fallback). + for (size_t i = 0; i < static_cast(n_constraints); ++i) { + f_t rhs = i < b_span.size() ? b_span[i] : f_t(0); + char t = i < rtype_span.size() ? rtype_span[i] : 'E'; + if (t == 'L') { + clb[i] = -inf; + cub[i] = rhs; + } else if (t == 'G') { + clb[i] = rhs; + cub[i] = inf; + } else { // 'E' + clb[i] = rhs; + cub[i] = rhs; + } + } + } else { + std::copy(clb_span.begin(), clb_span.end(), clb.begin()); + std::copy(cub_span.begin(), cub_span.end(), cub.begin()); + } + + // Empty when the row has no name — LP does not require constraint names. + auto row_name = [&](i_t k) -> std::string { + if (static_cast(k) < row_names_ref.size() && !row_names_ref[k].empty()) { + return row_names_ref[k]; + } + return {}; + }; + + // --- Formatting helpers ------------------------------------------------- + const int precision = std::numeric_limits::max_digits10; + auto fmt = [&](f_t v) -> std::string { + if (std::isinf(v)) return v > 0 ? "inf" : "-inf"; + std::ostringstream os; + os << std::setprecision(precision) << v; + return os.str(); + }; + + // Emits a signed algebraic term ("+ <|coeff|> ") with soft line + // wrapping. Continuation lines start with whitespace followed by the sign + // token, never with a bare name, so they can never be mistaken for a + // section header on re-read. + auto emit_term = [&](f_t coeff, const std::string& repr, int& terms_on_line) { + const bool neg = coeff < f_t(0); + const f_t a = neg ? -coeff : coeff; + if (terms_on_line > 0 && (terms_on_line % 8) == 0) { lp_file << "\n "; } + lp_file << (neg ? " - " : " + ") << fmt(a) << " " << repr; + ++terms_on_line; + }; + + lp_file << std::setprecision(precision); + + // --- Objective ---------------------------------------------------------- + lp_file << (problem_.get_sense() ? "Maximize\n" : "Minimize\n"); + { + std::string obj_name = + problem_.get_objective_name().empty() ? "obj" : problem_.get_objective_name(); + lp_file << " " << obj_name << ":"; + + int terms_on_line = 0; + for (i_t j = 0; j < n_variables; ++j) { + if (c[j] != f_t(0)) { emit_term(c[j], var_name(j), terms_on_line); } + } + // A constant objective term is written directly; read_lp folds it into + // the objective offset. + const f_t offset = problem_.get_objective_offset(); + if (std::isfinite(offset) && offset != f_t(0)) { + lp_file << (offset < f_t(0) ? " - " : " + ") << fmt(std::abs(offset)); + } + + // Quadratic objective: build the symmetric Hessian H = Q + Q^T (matching + // the MPS writer), then emit its upper triangle inside a '[ ... ] / 2' + // block. In the LP objective convention a bracket coefficient p on a term + // contributes 0.5*p to the objective, so for H = Q + Q^T the diagonal + // coefficient is H[i][i] and the off-diagonal coefficient is 2*H[i][j]. + if (problem_.has_quadratic_objective()) { + auto Qv = problem_.get_quadratic_objective_values(); + auto Qi = problem_.get_quadratic_objective_indices(); + auto Qo = problem_.get_quadratic_objective_offsets(); + std::vector Q_values(Qv.begin(), Qv.end()); + std::vector Q_indices(Qi.begin(), Qi.end()); + std::vector Q_offsets(Qo.begin(), Qo.end()); + + std::vector H_values; + std::vector H_indices; + std::vector H_offsets; + if (problem_.is_Q_symmetrized()) { + H_values = std::move(Q_values); + H_indices = std::move(Q_indices); + H_offsets = std::move(Q_offsets); + } else { + cuopt::symmetrize_csr( + Q_values, Q_indices, Q_offsets, H_values, H_indices, H_offsets); + } + + // Collect the upper-triangular entries first so we only open the bracket + // when there is at least one nonzero quadratic term. + const i_t n_rows = H_offsets.empty() ? 0 : (H_offsets.size() - 1); + std::vector> upper; + for (i_t i = 0; i < n_rows; ++i) { + for (i_t p = H_offsets[i]; p < H_offsets[i + 1]; ++p) { + const i_t j = H_indices[p]; + const f_t v = H_values[p]; + if (i <= j && v != f_t(0)) { upper.emplace_back(i, j, v); } + } + } + if (!upper.empty()) { + lp_file << " + ["; + int quad_terms = 0; + for (const auto& [i, j, v] : upper) { + if (i == j) { + emit_term(v, var_name(i) + " ^ 2", quad_terms); + } else { + emit_term(f_t(2) * v, var_name(i) + " * " + var_name(j), quad_terms); + } + } + lp_file << " ] / 2"; + } + } + lp_file << "\n"; + } + + // --- Constraints -------------------------------------------------------- + lp_file << "Subject To\n"; + + // Emits ": " when `name` is non-empty, or + // " " when the row is unnamed. + auto write_linear_row = [&](const std::string& name, + const std::vector>& row, + const char* rel, + f_t rhs) { + if (name.empty()) { + lp_file << " "; + } else { + lp_file << " " << name << ":"; + } + int terms_on_line = 0; + for (const auto& [vid, val] : row) { + if (val != f_t(0)) { emit_term(val, var_name(vid), terms_on_line); } + } + lp_file << " " << rel << " " << fmt(rhs) << "\n"; + }; + + for (i_t k = 0; k < n_constraints; ++k) { + std::vector> row; + if (static_cast(k) + 1 < A_offsets_span.size()) { + for (i_t p = A_offsets_span[k]; p < A_offsets_span[k + 1]; ++p) { + row.emplace_back(A_indices_span[p], A_values_span[p]); + } + } + + const f_t lo = clb[k]; + const f_t hi = cub[k]; + const std::string name = row_name(k); + if (lo == hi) { + write_linear_row(name, row, "=", lo); + } else if (std::isinf(lo) && lo < 0 && !std::isinf(hi)) { + write_linear_row(name, row, "<=", hi); + } else if (std::isinf(hi) && hi > 0 && !std::isinf(lo)) { + write_linear_row(name, row, ">=", lo); + } else if (!std::isinf(lo) && !std::isinf(hi)) { + // Range row: the LP format cannot express two finite bounds on a single + // line, so split it into a '>=' row and a '<=' row. + const std::string lo_name = name.empty() ? std::string{} : name + "_lo"; + const std::string up_name = name.empty() ? std::string{} : name + "_up"; + write_linear_row(lo_name, row, ">=", lo); + write_linear_row(up_name, row, "<=", hi); + } + // (-inf, +inf) is a non-constraining row and is intentionally omitted. + } + + // Quadratic constraints (QCQP). The linear part is written first, then a + // '[ ... ]' block (no '/ 2' suffix). Q is stored upper-triangular with the + // full x^T Q x coefficient per variable pair, which is exactly what the LP + // constraint-bracket convention expects. + for (size_t q = 0; q < quadratic_constraints.size(); ++q) { + typename mps_data_model_t::quadratic_constraint_t qc = quadratic_constraints[q]; + const std::string name = qc.constraint_row_name; + const char* rel = qc.constraint_row_type == 'G' ? ">=" + : qc.constraint_row_type == 'E' ? "=" + : "<="; + + if (name.empty()) { + lp_file << " "; + } else { + lp_file << " " << name << ":"; + } + int terms_on_line = 0; + for (size_t t = 0; t < qc.linear_indices.size(); ++t) { + if (qc.linear_values[t] != f_t(0)) { + emit_term(qc.linear_values[t], var_name(qc.linear_indices[t]), terms_on_line); + } + } + + canonicalize_coo_matrix(qc.rows, qc.cols, qc.vals); + lp_file << " + ["; + int quad_terms = 0; + for (size_t p = 0; p < qc.vals.size(); ++p) { + const i_t i = qc.rows[p]; + const i_t j = qc.cols[p]; + const f_t v = qc.vals[p]; + if (v == f_t(0)) continue; + if (i == j) { + emit_term(v, var_name(i) + " ^ 2", quad_terms); + } else { + emit_term(v, var_name(i) + " * " + var_name(j), quad_terms); + } + } + lp_file << " ] " << rel << " " << fmt(qc.rhs_value) << "\n"; + } + + // --- Bounds / integrality / semi-continuous ----------------------------- + // Classify variables. Binaries are integers with [0, 1] bounds; those go in + // the Binaries section (which implies bounds) and get no explicit bound line. + std::vector generals; + std::vector binaries; + std::vector semi_continuous; + + auto is_binary = [&](i_t j) { + return var_types[j] == 'I' && var_lb[j] == f_t(0) && var_ub[j] == f_t(1); + }; + + std::vector bound_lines; + for (i_t j = 0; j < n_variables; ++j) { + const char t = var_types[j]; + if (t == 'I') { + if (is_binary(j)) { + binaries.push_back(j); + } else { + generals.push_back(j); + } + } else if (t == 'S') { + semi_continuous.push_back(j); + } + + if (is_binary(j)) { continue; } // bounds implied by the Binaries section + + const f_t lo = var_lb[j]; + const f_t hi = var_ub[j]; + const std::string name = var_name(j); + std::ostringstream line; + + if (std::isinf(lo) && lo < 0 && std::isinf(hi) && hi > 0) { + line << " " << name << " free"; + } else if (lo == hi) { + line << " " << name << " = " << fmt(lo); + } else { + bool need_lower = (lo != default_lower()); + const bool need_upper = !(std::isinf(hi) && hi > 0); + // A negative upper bound needs an explicit lower bound, otherwise the + // default lower of 0 collides with it on re-read (read_lp rejects this). + if (need_upper && hi < f_t(0) && !need_lower) { need_lower = true; } + + if (need_lower && need_upper) { + line << " " << fmt(lo) << " <= " << name << " <= " << fmt(hi); + } else if (need_lower) { + line << " " << name << " >= " << fmt(lo); + } else if (need_upper) { + line << " " << name << " <= " << fmt(hi); + } else { + continue; // default [0, +inf): nothing to emit + } + } + bound_lines.push_back(line.str()); + } + + if (!bound_lines.empty()) { + lp_file << "Bounds\n"; + for (const auto& l : bound_lines) + lp_file << l << "\n"; + } + + auto write_name_section = [&](const char* header, const std::vector& ids) { + if (ids.empty()) return; + lp_file << header << "\n"; + for (i_t j : ids) + lp_file << " " << var_name(j) << "\n"; + }; + write_name_section("Generals", generals); + write_name_section("Binaries", binaries); + write_name_section("Semi-Continuous", semi_continuous); + + lp_file << "End\n"; + lp_file.close(); +} + +template class lp_writer_t; +template class lp_writer_t; + +} // namespace cuopt::mathematical_optimization::io diff --git a/cpp/src/io/writer.cpp b/cpp/src/io/writer.cpp index c67a1aac4b..00b78e8bcc 100644 --- a/cpp/src/io/writer.cpp +++ b/cpp/src/io/writer.cpp @@ -7,6 +7,7 @@ #include +#include #include namespace cuopt::mathematical_optimization::io { @@ -23,4 +24,16 @@ template void write_mps(const data_model_view_t& problem template void write_mps(const data_model_view_t& problem, const std::string& mps_file_path); +template +void write_lp(const data_model_view_t& problem, const std::string& lp_file_path) +{ + lp_writer_t writer(problem); + writer.write(lp_file_path); +} + +template void write_lp(const data_model_view_t& problem, + const std::string& lp_file_path); +template void write_lp(const data_model_view_t& problem, + const std::string& lp_file_path); + } // namespace cuopt::mathematical_optimization::io diff --git a/cpp/src/mip_heuristics/solve.cu b/cpp/src/mip_heuristics/solve.cu index 6e6daf7a56..2a6e752580 100644 --- a/cpp/src/mip_heuristics/solve.cu +++ b/cpp/src/mip_heuristics/solve.cu @@ -645,11 +645,15 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p if (settings.user_problem_file != "") { CUOPT_LOG_INFO("Writing user problem to file: %s", settings.user_problem_file.c_str()); - op_problem.write_to_mps(settings.user_problem_file); + op_problem.write_to_file( + settings.user_problem_file, + cuopt::mathematical_optimization::file_format_from_path(settings.user_problem_file)); } if (run_presolve && presolve_result_opt.has_value() && settings.presolve_file != "") { CUOPT_LOG_INFO("Writing presolved problem to file: %s", settings.presolve_file.c_str()); - presolve_result_opt->reduced_problem.write_to_mps(settings.presolve_file); + presolve_result_opt->reduced_problem.write_to_file( + settings.presolve_file, + cuopt::mathematical_optimization::file_format_from_path(settings.presolve_file)); } // early_best_user_obj is in user-space. // run_mip_solver stores it in context.initial_upper_bound and converts to target spaces as diff --git a/cpp/src/pdlp/cpu_optimization_problem.cpp b/cpp/src/pdlp/cpu_optimization_problem.cpp index 84f03b5e85..9587017870 100644 --- a/cpp/src/pdlp/cpu_optimization_problem.cpp +++ b/cpp/src/pdlp/cpu_optimization_problem.cpp @@ -722,7 +722,8 @@ cpu_optimization_problem_t::to_optimization_problem(raft::handle_t con // ============================================================================== template -void cpu_optimization_problem_t::write_to_mps(const std::string& mps_file_path) +void cpu_optimization_problem_t::write_to_file(const std::string& file_path, + file_format_t format) { // Data is already in host memory, so we can directly create a view and write cuopt::mathematical_optimization::io::data_model_view_t data_model_view; @@ -813,7 +814,11 @@ void cpu_optimization_problem_t::write_to_mps(const std::string& mps_f data_model_view.set_quadratic_constraints(quadratic_constraints_); } - cuopt::mathematical_optimization::io::write_mps(data_model_view, mps_file_path); + if (format == file_format_t::lp) { + cuopt::mathematical_optimization::io::write_lp(data_model_view, file_path); + } else { + cuopt::mathematical_optimization::io::write_mps(data_model_view, file_path); + } } // ============================================================================== diff --git a/cpp/src/pdlp/cuopt_c.cpp b/cpp/src/pdlp/cuopt_c.cpp index 0288014f40..beabda6bd1 100644 --- a/cpp/src/pdlp/cuopt_c.cpp +++ b/cpp/src/pdlp/cuopt_c.cpp @@ -232,15 +232,19 @@ cuopt_int_t cuOptWriteProblem(cuOptOptimizationProblem problem, if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } if (filename == nullptr) { return CUOPT_INVALID_ARGUMENT; } if (strlen(filename) == 0) { return CUOPT_INVALID_ARGUMENT; } - if (format != CUOPT_FILE_FORMAT_MPS) { return CUOPT_INVALID_ARGUMENT; } + if (format != CUOPT_FILE_FORMAT_MPS && format != CUOPT_FILE_FORMAT_LP) { + return CUOPT_INVALID_ARGUMENT; + } problem_and_stream_view_t* problem_and_stream_view = static_cast(problem); try { - // Use the write_to_mps method from the interface (works for both CPU and GPU) - problem_and_stream_view->get_problem()->write_to_mps(std::string(filename)); + const auto file_format = (format == CUOPT_FILE_FORMAT_LP) + ? cuopt::mathematical_optimization::file_format_t::lp + : cuopt::mathematical_optimization::file_format_t::mps; + problem_and_stream_view->get_problem()->write_to_file(std::string(filename), file_format); } catch (const std::exception& e) { - CUOPT_LOG_INFO("Error writing MPS file: %s", e.what()); + CUOPT_LOG_INFO("Error writing problem file: %s", e.what()); return CUOPT_MPS_FILE_ERROR; } return CUOPT_SUCCESS; diff --git a/cpp/src/pdlp/optimization_problem.cu b/cpp/src/pdlp/optimization_problem.cu index 85d3ef4df4..7783b17761 100644 --- a/cpp/src/pdlp/optimization_problem.cu +++ b/cpp/src/pdlp/optimization_problem.cu @@ -796,7 +796,8 @@ typename optimization_problem_t::view_t optimization_problem_t -void optimization_problem_t::write_to_mps(const std::string& mps_file_path) +void optimization_problem_t::write_to_file(const std::string& file_path, + file_format_t format) { cuopt::mathematical_optimization::io::data_model_view_t data_model_view; @@ -908,7 +909,11 @@ void optimization_problem_t::write_to_mps(const std::string& mps_file_ data_model_view.set_quadratic_constraints(quadratic_constraints_); } - cuopt::mathematical_optimization::io::write_mps(data_model_view, mps_file_path); + if (format == file_format_t::lp) { + cuopt::mathematical_optimization::io::write_lp(data_model_view, file_path); + } else { + cuopt::mathematical_optimization::io::write_mps(data_model_view, file_path); + } } template diff --git a/cpp/src/pdlp/solve.cu b/cpp/src/pdlp/solve.cu index 83cb082981..93df85e2a4 100644 --- a/cpp/src/pdlp/solve.cu +++ b/cpp/src/pdlp/solve.cu @@ -1819,7 +1819,9 @@ optimization_problem_solution_t solve_qcqp( } if (settings.user_problem_file != "") { CUOPT_LOG_INFO("Writing user problem to file: %s", settings.user_problem_file.c_str()); - op_problem.write_to_mps(settings.user_problem_file); + op_problem.write_to_file( + settings.user_problem_file, + cuopt::mathematical_optimization::file_format_from_path(settings.user_problem_file)); } // Convert data structures to dual simplex format and back simplex::user_problem_t dual_simplex_problem = @@ -2041,11 +2043,15 @@ optimization_problem_solution_t solve_lp( if (settings.user_problem_file != "") { CUOPT_LOG_INFO("Writing user problem to file: %s", settings.user_problem_file.c_str()); - op_problem.write_to_mps(settings.user_problem_file); + op_problem.write_to_file( + settings.user_problem_file, + cuopt::mathematical_optimization::file_format_from_path(settings.user_problem_file)); } if (run_presolve && settings.presolve_file != "") { CUOPT_LOG_INFO("Writing presolved problem to file: %s", settings.presolve_file.c_str()); - result->reduced_problem.write_to_mps(settings.presolve_file); + result->reduced_problem.write_to_file( + settings.presolve_file, + cuopt::mathematical_optimization::file_format_from_path(settings.presolve_file)); } // Set the hyper-parameters based on the solver_settings diff --git a/cpp/tests/linear_programming/CMakeLists.txt b/cpp/tests/linear_programming/CMakeLists.txt index bc057db1e2..1b63f6f030 100644 --- a/cpp/tests/linear_programming/CMakeLists.txt +++ b/cpp/tests/linear_programming/CMakeLists.txt @@ -21,6 +21,12 @@ ConfigureTest(MPS_PARSER_TEST ${CMAKE_CURRENT_SOURCE_DIR}/parser_test.cpp LABELS numopt) +# ################################################################################################## +# - LP writer tests -------------------------------------------------------------------------------- +ConfigureTest(LP_WRITER_TEST + ${CMAKE_CURRENT_SOURCE_DIR}/lp_writer_test.cpp + LABELS numopt) + # ################################################################################################## # - C API Tests---------------------------------------------------------------------- # The C API tests require a separate library to be linked against. So we don't use the ConfigureTest macro. diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp index c0616a538a..b56c0d2e73 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp +++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp @@ -316,7 +316,7 @@ TEST(c_api, test_maximize_problem_dual_variables) } } -static bool test_mps_roundtrip(const std::string& mps_file_path) +static bool test_write_roundtrip(const std::string& mps_file_path, cuopt_int_t format) { using cuopt::mathematical_optimization::problem_and_stream_view_t; @@ -324,23 +324,24 @@ static bool test_mps_roundtrip(const std::string& mps_file_path) cuOptOptimizationProblem reread_handle = nullptr; bool result = false; - std::string model_basename = std::filesystem::path(mps_file_path).filename().string(); + const std::string extension = (format == CUOPT_FILE_FORMAT_LP) ? ".lp" : ".mps"; + std::string model_basename = + std::filesystem::path(mps_file_path).stem().string() + extension; std::string temp_file = std::filesystem::temp_directory_path().string() + "/roundtrip_temp_" + model_basename; if (cuOptReadProblem(mps_file_path.c_str(), &original_handle) != CUOPT_SUCCESS) { - std::cerr << "Failed to read original MPS file: " << mps_file_path << std::endl; + std::cerr << "Failed to read original problem file: " << mps_file_path << std::endl; goto cleanup; } - if (cuOptWriteProblem(original_handle, temp_file.c_str(), CUOPT_FILE_FORMAT_MPS) != - CUOPT_SUCCESS) { - std::cerr << "Failed to write MPS file: " << temp_file << std::endl; + if (cuOptWriteProblem(original_handle, temp_file.c_str(), format) != CUOPT_SUCCESS) { + std::cerr << "Failed to write problem file: " << temp_file << std::endl; goto cleanup; } if (cuOptReadProblem(temp_file.c_str(), &reread_handle) != CUOPT_SUCCESS) { - std::cerr << "Failed to re-read MPS file: " << temp_file << std::endl; + std::cerr << "Failed to re-read problem file: " << temp_file << std::endl; goto cleanup; } @@ -365,7 +366,7 @@ class WriteRoundtripTestFixture : public ::testing::TestWithParam { TEST_P(WriteRoundtripTestFixture, roundtrip) { const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); - EXPECT_TRUE(test_mps_roundtrip(rapidsDatasetRootDir + GetParam())); + EXPECT_TRUE(test_write_roundtrip(rapidsDatasetRootDir + GetParam(), CUOPT_FILE_FORMAT_MPS)); } INSTANTIATE_TEST_SUITE_P(c_api, WriteRoundtripTestFixture, @@ -397,6 +398,20 @@ INSTANTIATE_TEST_SUITE_P(c_api, "/mip/enlight11.mps", "/mip/supportcase22.mps")); +class LPWriteRoundtripTestFixture : public ::testing::TestWithParam {}; +TEST_P(LPWriteRoundtripTestFixture, roundtrip) +{ + const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); + EXPECT_TRUE(test_write_roundtrip(rapidsDatasetRootDir + GetParam(), CUOPT_FILE_FORMAT_LP)); +} +INSTANTIATE_TEST_SUITE_P(c_api, + LPWriteRoundtripTestFixture, + ::testing::Values("/linear_programming/afiro_original.mps", + "/mip/50v-10.mps", + "/mip/gen-ip054.mps", + "/mip/neos5.mps", + "/mip/stein9inf.mps")); + class DeterministicBBTestFixture : public ::testing::TestWithParam> {}; TEST_P(DeterministicBBTestFixture, deterministic_reproducibility) diff --git a/cpp/tests/linear_programming/lp_writer_test.cpp b/cpp/tests/linear_programming/lp_writer_test.cpp new file mode 100644 index 0000000000..21df1c113c --- /dev/null +++ b/cpp/tests/linear_programming/lp_writer_test.cpp @@ -0,0 +1,256 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +/** + * LP writer tests. Primary coverage is exact string output for a range of + * input problems (specified as LP text for convenience, then re-parsed and + * rewritten). A couple of round-trip checks are retained for quadratic cases. + */ + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::io { + +namespace { + +struct temp_file_guard_t { + explicit temp_file_guard_t(std::string p) : path(std::move(p)) {} + ~temp_file_guard_t() + { + if (!path.empty()) { + std::error_code ec; + std::filesystem::remove(path, ec); + } + } + std::string path; +}; + +std::string read_file(const std::string& path) +{ + std::ifstream in(path); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +std::string write_lp_to_string(const mps_data_model_t& model, const std::string& tag) +{ + const std::string path = std::string(::testing::TempDir()) + "lp_writer_" + tag + ".lp"; + temp_file_guard_t guard(path); + lp_writer_t writer(model); + writer.write(path); + return read_file(path); +} + +std::string rewrite_lp_text(std::string_view lp_text, const std::string& tag) +{ + return write_lp_to_string(read_lp_from_string(lp_text), tag); +} + +} // namespace + +TEST(lp_writer, simple_lp_exact_output) +{ + // Named rows/vars, mixed constraint senses, non-default bounds. + const std::string out = rewrite_lp_text(R"LP( +Minimize + obj: 3 x + 2 y - z +Subject To + c1: x + y <= 10 + c2: x - z >= -4 + c3: 2 x + y = 6 +Bounds + 0 <= x <= 8 + y >= 1 + -5 <= z <= 5 +End +)LP", + "simple_lp"); + + EXPECT_EQ(out, + "Minimize\n" + " obj: + 3 x + 2 y - 1 z\n" + "Subject To\n" + " c1: + 1 x + 1 y <= 10\n" + " c2: + 1 x - 1 z >= -4\n" + " c3: + 2 x + 1 y = 6\n" + "Bounds\n" + " x <= 8\n" + " y >= 1\n" + " -5 <= z <= 5\n" + "End\n"); +} + +TEST(lp_writer, maximize_and_offset_exact_output) +{ + const std::string out = rewrite_lp_text(R"LP( +Maximize + obj: 2 a + 3 b + 7 +Subject To + r1: a + b <= 4 +End +)LP", + "max_offset"); + + EXPECT_EQ(out, + "Maximize\n" + " obj: + 2 a + 3 b + 7\n" + "Subject To\n" + " r1: + 1 a + 1 b <= 4\n" + "End\n"); +} + +TEST(lp_writer, unnamed_constraints_omit_names) +{ + // No row names in the model → writer must not invent or print names. + mps_data_model_t model; + const std::vector c{1.0, 1.0}; + const std::vector lb{0.0, 0.0}; + const std::vector ub{std::numeric_limits::infinity(), + std::numeric_limits::infinity()}; + const std::vector var_names{"x", "y"}; + const std::vector A_values{1.0, 1.0}; + const std::vector A_indices{0, 1}; + const std::vector A_offsets{0, 2}; + const std::vector clb{-std::numeric_limits::infinity()}; + const std::vector cub{5.0}; + const std::vector row_types{'L'}; + + model.set_objective_coefficients(c); + model.set_maximize(false); + model.set_variable_lower_bounds(lb); + model.set_variable_upper_bounds(ub); + model.set_variable_names(var_names); + model.set_csr_constraint_matrix(A_values, A_indices, A_offsets); + model.set_constraint_lower_bounds(clb); + model.set_constraint_upper_bounds(cub); + model.set_row_types(row_types); + + EXPECT_EQ(write_lp_to_string(model, "unnamed"), + "Minimize\n" + " obj: + 1 x + 1 y\n" + "Subject To\n" + " + 1 x + 1 y <= 5\n" + "End\n"); +} + +TEST(lp_writer, mip_generals_binaries_exact_output) +{ + const std::string out = rewrite_lp_text(R"LP( +Minimize + obj: x + y + 2 z +Subject To + c1: x + y + z <= 5 +Bounds + 0 <= y <= 10 +Generals + y +Binaries + x + z +End +)LP", + "mip"); + + EXPECT_EQ(out, + "Minimize\n" + " obj: + 1 x + 1 y + 2 z\n" + "Subject To\n" + " c1: + 1 x + 1 y + 1 z <= 5\n" + "Bounds\n" + " y <= 10\n" + "Generals\n" + " y\n" + "Binaries\n" + " x\n" + " z\n" + "End\n"); +} + +TEST(lp_writer, free_and_fixed_bounds_exact_output) +{ + const std::string out = rewrite_lp_text(R"LP( +Minimize + obj: p + q + r +Subject To + c1: p + q + r >= 1 +Bounds + p free + q = 3 + r <= 9 +End +)LP", + "bounds"); + + EXPECT_EQ(out, + "Minimize\n" + " obj: + 1 p + 1 q + 1 r\n" + "Subject To\n" + " c1: + 1 p + 1 q + 1 r >= 1\n" + "Bounds\n" + " p free\n" + " q = 3\n" + " r <= 9\n" + "End\n"); +} + +TEST(lp_writer, quadratic_objective_exact_output) +{ + // min 0.5*(2 x^2 + 4 x*y + 6 y^2) + x → written with H = Q+Q^T convention + const std::string out = rewrite_lp_text(R"LP( +Minimize + obj: x + [ 2 x ^ 2 + 4 x * y + 6 y ^ 2 ] / 2 +Subject To + c1: x + y >= 1 +End +)LP", + "qp_obj"); + + EXPECT_EQ(out, + "Minimize\n" + " obj: + 1 x + [ + 2 x ^ 2 + 4 x * y + 6 y ^ 2 ] / 2\n" + "Subject To\n" + " c1: + 1 x + 1 y >= 1\n" + "End\n"); +} + +TEST(lp_writer, quadratic_constraint_round_trip) +{ + // Retain one round-trip for QCQP content (parser/writer quadratic path). + const auto original = read_lp_from_string(R"LP( +Minimize + obj: x + y +Subject To + lin: x + y <= 10 + qc: x + [ x ^ 2 + y ^ 2 ] <= 4 +End +)LP"); + const auto rewritten = + read_lp_from_string(write_lp_to_string(original, "qcqp_rt")); + + EXPECT_EQ(original.get_sense(), rewritten.get_sense()); + EXPECT_EQ(original.get_quadratic_constraints().size(), + rewritten.get_quadratic_constraints().size()); + ASSERT_EQ(rewritten.get_quadratic_constraints().size(), 1u); + EXPECT_EQ(rewritten.get_quadratic_constraints()[0].constraint_row_name, "qc"); + EXPECT_NEAR(rewritten.get_quadratic_constraints()[0].rhs_value, 4.0, 1e-9); +} + +} // namespace cuopt::mathematical_optimization::io diff --git a/cpp/tests/linear_programming/unit_tests/optimization_problem_test.cu b/cpp/tests/linear_programming/unit_tests/optimization_problem_test.cu index 7232bd3bc6..42ae69a059 100644 --- a/cpp/tests/linear_programming/unit_tests/optimization_problem_test.cu +++ b/cpp/tests/linear_programming/unit_tests/optimization_problem_test.cu @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -24,6 +25,20 @@ namespace cuopt::mathematical_optimization { +TEST(file_format_from_path, supports_only_uncompressed_output) +{ + EXPECT_EQ(file_format_from_path("problem.lp"), file_format_t::lp); + EXPECT_EQ(file_format_from_path("problem.LP"), file_format_t::lp); + EXPECT_EQ(file_format_from_path("problem.mps"), file_format_t::mps); + EXPECT_EQ(file_format_from_path("problem.qps"), file_format_t::mps); + + EXPECT_THROW(file_format_from_path("problem.lp.gz"), std::invalid_argument); + EXPECT_THROW(file_format_from_path("problem.lp.bz2"), std::invalid_argument); + EXPECT_THROW(file_format_from_path("problem.mps.gz"), std::invalid_argument); + EXPECT_THROW(file_format_from_path("problem.qps.bz2"), std::invalid_argument); + EXPECT_THROW(file_format_from_path("problem.unknown"), std::invalid_argument); +} + cuopt::mathematical_optimization::io::mps_data_model_t read_from_mps( const std::string& file, bool fixed_mps_format = true) { diff --git a/cpp/tests/qp/unit_tests/mps_writer_test.cpp b/cpp/tests/qp/unit_tests/mps_writer_test.cpp index feae3985ea..add6dc3cf8 100644 --- a/cpp/tests/qp/unit_tests/mps_writer_test.cpp +++ b/cpp/tests/qp/unit_tests/mps_writer_test.cpp @@ -6,7 +6,7 @@ /* clang-format on */ /** - * Builds small unconstrained QPs as optimization_problem_t, writes MPS via write_to_mps, and checks + * Builds small unconstrained QPs as optimization_problem_t, writes MPS via write_to_file, and checks * QUADOBJ coefficients against the symmetric Hessian H = Q + Q^T stored internally (MPS uses * (1/2) x^T H x for the quadratic part). */ @@ -190,7 +190,7 @@ TEST(mps_writer_op, write_to_mps_diagonal_qp_quadobj_matches_symmetrized_hessian std::string const path = std::string(::testing::TempDir()) + "qp_diag_write.mps"; temp_mps_file_guard_t guard(path); - op.write_to_mps(path); + op.write_to_file(path, file_format_t::mps); std::string const content = read_entire_file(path); ASSERT_FALSE(content.empty()) << "MPS file was empty or could not be read"; @@ -231,7 +231,7 @@ TEST(mps_writer_op, write_to_mps_nonsymmetric_Q_quadobj_matches_Q_plus_Q_transpo std::string const path = std::string(::testing::TempDir()) + "qp_nonsym_sparse_write.mps"; temp_mps_file_guard_t guard(path); - op.write_to_mps(path); + op.write_to_file(path, file_format_t::mps); std::string const content = read_entire_file(path); ASSERT_FALSE(content.empty()) << "MPS file was empty or could not be read"; diff --git a/docs/cuopt/source/convex-settings.rst b/docs/cuopt/source/convex-settings.rst index 4d7d8de669..52da0fd9e5 100644 --- a/docs/cuopt/source/convex-settings.rst +++ b/docs/cuopt/source/convex-settings.rst @@ -48,7 +48,7 @@ Solution File User Problem File ^^^^^^^^^^^^^^^^^ -``CUOPT_USER_PROBLEM_FILE`` controls the name of a file where cuOpt should write the user problem. +``CUOPT_USER_PROBLEM_FILE`` controls the name of a file where cuOpt should write the user problem. The output format is chosen from the file extension: ``.lp`` is written in LP format, while ``.mps`` and ``.qps`` are written in MPS/QPS format. Compressed output is not supported. .. note:: The default value is ``""`` and no user problem file is written. This setting is ignored by the cuOpt service. diff --git a/docs/cuopt/source/mip-settings.rst b/docs/cuopt/source/mip-settings.rst index 025370ea3c..d311135374 100644 --- a/docs/cuopt/source/mip-settings.rst +++ b/docs/cuopt/source/mip-settings.rst @@ -48,7 +48,7 @@ Solution File User Problem File ^^^^^^^^^^^^^^^^^ -``CUOPT_USER_PROBLEM_FILE`` controls the name of a file where cuOpt should write the user problem. +``CUOPT_USER_PROBLEM_FILE`` controls the name of a file where cuOpt should write the user problem. The output format is chosen from the file extension: ``.lp`` is written in LP format, while ``.mps`` and ``.qps`` are written in MPS/QPS format. Compressed output is not supported. .. note:: The default value is ``""`` and no user problem file is written. This setting is ignored by the cuOpt service. diff --git a/python/cuopt/cuopt/linear_programming/data_model/data_model.pxd b/python/cuopt/cuopt/linear_programming/data_model/data_model.pxd index b734a17ec5..0dc5650bc4 100644 --- a/python/cuopt/cuopt/linear_programming/data_model/data_model.pxd +++ b/python/cuopt/cuopt/linear_programming/data_model/data_model.pxd @@ -79,3 +79,7 @@ cdef extern from "cuopt/mathematical_optimization/io/writer.hpp" namespace "cuop cdef void write_mps( const data_model_view_t[int, double] data_model, const string user_problem_file) except + + + cdef void write_lp( + const data_model_view_t[int, double] data_model, + const string user_problem_file) except + diff --git a/python/cuopt/cuopt/linear_programming/data_model/data_model.py b/python/cuopt/cuopt/linear_programming/data_model/data_model.py index 7bcdfaea9b..cfd790ed0e 100644 --- a/python/cuopt/cuopt/linear_programming/data_model/data_model.py +++ b/python/cuopt/cuopt/linear_programming/data_model/data_model.py @@ -3,6 +3,7 @@ import os import time +import warnings from . import data_model_wrapper @@ -756,5 +757,55 @@ def get_problem_name(self): return super().get_problem_name() @catch_cuopt_exception - def writeMPS(self, user_problem_file): + def _write_mps(self, user_problem_file): return super().writeMPS(user_problem_file) + + @catch_cuopt_exception + def _write_lp(self, user_problem_file): + return super()._write_lp(user_problem_file) + + @catch_cuopt_exception + def write(self, user_problem_file: str) -> None: + """Write the problem to a file, dispatching on extension. + + Dispatches to the MPS/QPS or LP writer based on the filename suffix + (case-insensitive), matching :func:`cuopt.linear_programming.io.parser.Read`: + + - ``.mps``, ``.qps`` → MPS writer + - ``.lp`` → LP writer + + Compressed output is not supported. + + Parameters + ---------- + user_problem_file : str + Path to an uncompressed MPS, QPS, or LP output file. + + Returns + ------- + None + + Raises + ------ + RuntimeError + If the file extension is not one of the supported suffixes. + Exception + Propagates validation or I/O failures from the underlying writer. + """ + from cuopt.linear_programming.io.format import file_format_from_path + + fmt = file_format_from_path(user_problem_file) + if fmt == "lp": + self._write_lp(user_problem_file) + else: + self._write_mps(user_problem_file) + + @catch_cuopt_exception + def writeMPS(self, user_problem_file: str) -> None: + warnings.warn( + "DataModel.writeMPS is deprecated and will be removed in a future " + "release. Use DataModel.write instead.", + DeprecationWarning, + stacklevel=2, + ) + self._write_mps(user_problem_file) diff --git a/python/cuopt/cuopt/linear_programming/data_model/data_model_wrapper.pyx b/python/cuopt/cuopt/linear_programming/data_model/data_model_wrapper.pyx index ec8bdf3730..63dea663d8 100644 --- a/python/cuopt/cuopt/linear_programming/data_model/data_model_wrapper.pyx +++ b/python/cuopt/cuopt/linear_programming/data_model/data_model_wrapper.pyx @@ -7,7 +7,7 @@ # cython: embedsignature = True # cython: language_level = 3 -from .data_model cimport data_model_view_t, mps_data_model_t, write_mps +from .data_model cimport data_model_view_t, mps_data_model_t, write_lp, write_mps import warnings @@ -522,3 +522,15 @@ cdef class DataModel: self.set_data_model_view() write_mps(self.c_data_model_view.get()[0], user_problem_file.encode('utf-8')) + + def _write_lp(self, user_problem_file): + n_vars = self.get_variable_lower_bounds().shape[0] + if self.variable_types.shape[0] == 0 and n_vars > 0: + self.variable_types = np.array(["C"] * n_vars, dtype="S1") + else: + self.variable_types = type_cast( + self.variable_types, "S1", "variable_types" + ) + self.set_data_model_view() + write_lp(self.c_data_model_view.get()[0], + user_problem_file.encode('utf-8')) diff --git a/python/cuopt/cuopt/linear_programming/io/__init__.py b/python/cuopt/cuopt/linear_programming/io/__init__.py index c6843a9e61..ad0825f103 100644 --- a/python/cuopt/cuopt/linear_programming/io/__init__.py +++ b/python/cuopt/cuopt/linear_programming/io/__init__.py @@ -1,4 +1,5 @@ # SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +from cuopt.linear_programming.io.format import file_format_from_path from cuopt.linear_programming.io.parser import ParseMps, Read, toDict diff --git a/python/cuopt/cuopt/linear_programming/io/format.py b/python/cuopt/cuopt/linear_programming/io/format.py new file mode 100644 index 0000000000..3dcca82996 --- /dev/null +++ b/python/cuopt/cuopt/linear_programming/io/format.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Extension-based file format dispatch for LP I/O.""" + +_MPS_SUFFIXES = (".mps", ".qps") +_LP_SUFFIXES = (".lp",) +_SUPPORTED_WRITE_SUFFIXES = _MPS_SUFFIXES + _LP_SUFFIXES + + +def file_format_from_path(file_path: str) -> str: + """Infer the output format from a file path (case-insensitive). + + Mirrors the extension dispatch used by :func:`Read` and the C++ + ``file_format_from_path`` helper: + + - ``.lp`` → ``"lp"`` + - ``.mps``, ``.qps`` → ``"mps"`` + + Compressed output is not supported; paths ending in ``.gz`` or ``.bz2`` + are rejected. + + Parameters + ---------- + file_path : str + Output file path. + + Returns + ------- + str + ``"lp"`` or ``"mps"``. + + Raises + ------ + RuntimeError + If the file extension is not one of the supported suffixes, including + compressed output suffixes. + """ + lower = file_path.lower() + for suffix in _LP_SUFFIXES: + if lower.endswith(suffix): + return "lp" + for suffix in _MPS_SUFFIXES: + if lower.endswith(suffix): + return "mps" + supported = ", ".join(_SUPPORTED_WRITE_SUFFIXES) + raise RuntimeError( + "write: unrecognized output file extension. " + f"Supported (case-insensitive): {supported}. " + "Compressed output is not supported. " + f"Given path: {file_path}" + ) diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index 10600a543b..77ab031dfe 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -2035,16 +2035,73 @@ def readMPS(cls, mps_file): problem.model = data_model return problem - def writeMPS(self, mps_file): + def writeMPS(self, mps_file: str) -> None: """ Write the problem into an `MPS `__ file. # noqa + + .. deprecated:: + Use :meth:`write` instead. + + Parameters + ---------- + mps_file : str + Path to the MPS output file. + + Returns + ------- + None + + Raises + ------ + Exception + Propagates validation or I/O failures from the underlying writer. + Examples -------- >>> problem.writeMPS("model.mps") """ + warnings.warn( + "Problem.writeMPS is deprecated and will be removed in a future " + "release. Use Problem.write instead.", + DeprecationWarning, + stacklevel=2, + ) + if self.model is None: + self._to_data_model() + self.model._write_mps(mps_file) + + def write(self, file_path: str) -> None: + """ + Write the problem to an MPS, QPS, or LP file. + + Dispatches on the file extension in Python (case-insensitive): + ``.mps`` / ``.qps`` use the MPS writer and ``.lp`` uses the LP writer. + Compressed output is not supported. + + Parameters + ---------- + file_path : str + Path to an uncompressed MPS, QPS, or LP output file. + + Returns + ------- + None + + Raises + ------ + RuntimeError + If the file extension is not one of the supported suffixes. + Exception + Propagates validation or I/O failures from the underlying writer. + + Examples + -------- + >>> problem.write("model.mps") + >>> problem.write("model.lp") + """ if self.model is None: self._to_data_model() - self.model.writeMPS(mps_file) + self.model.write(file_path) @property def NumVariables(self): diff --git a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py index 7d9b611124..307e53b380 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py @@ -777,6 +777,38 @@ def test_write_files(): os.remove("afiro.sol") +def test_write_files_lp_extension(): + # A .lp user_problem_file must be written in LP format (extension + # respected), and must round-trip back to an equivalent problem. + file_path = ( + RAPIDS_DATASET_ROOT_DIR + "/linear_programming/afiro_original.mps" + ) + data_model_obj = Read(file_path) + + settings = solver_settings.SolverSettings() + settings.set_parameter(CUOPT_METHOD, SolverMethod.DualSimplex) + settings.set_parameter(CUOPT_USER_PROBLEM_FILE, "afiro_out.lp") + + solver.Solve(data_model_obj, settings) + + assert os.path.isfile("afiro_out.lp") + + # The file must be LP-formatted, not MPS content in a .lp name. + with open("afiro_out.lp") as f: + content = f.read() + assert "Subject To" in content + assert "ROWS" not in content and "COLUMNS" not in content + + afiro = Read("afiro_out.lp") + os.remove("afiro_out.lp") + + settings.set_parameter(CUOPT_USER_PROBLEM_FILE, "") + solution = solver.Solve(afiro, settings) + + assert solution.get_termination_status() == LPTerminationStatus.Optimal + assert solution.get_primal_objective() == pytest.approx(-464.7531) + + def test_unbounded_problem(): problem = Problem("unbounded") x = problem.addVariable(lb=0.0, vtype=CONTINUOUS, name="x") diff --git a/python/cuopt/cuopt/tests/linear_programming/test_parser.py b/python/cuopt/cuopt/tests/linear_programming/test_parser.py index f40fd505ef..874569745f 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_parser.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_parser.py @@ -254,3 +254,139 @@ def test_read_unrecognized_extension(): Read(path) finally: os.unlink(path) + + +def test_write_lp_exact_output(): + # Continuous + integer variables; assert the precise LP text written. + from cuopt.linear_programming.problem import ( + INTEGER, + MINIMIZE, + Problem, + ) + + problem = Problem("exact_lp") + x = problem.addVariable(lb=0.0, ub=8.0, obj=3.0, name="x") + y = problem.addVariable(lb=1.0, ub=float("inf"), obj=2.0, name="y") + z = problem.addVariable(lb=0.0, ub=10.0, obj=1.0, vtype=INTEGER, name="z") + b = problem.addVariable(lb=0.0, ub=1.0, obj=2.0, vtype=INTEGER, name="b") + problem.addConstraint(x + y <= 10, name="c1") + problem.addConstraint(x + z >= 1, name="c2") + problem.addConstraint(2 * x + y == 6, name="c3") + problem.setObjective(3 * x + 2 * y + z + 2 * b, sense=MINIMIZE) + + with tempfile.NamedTemporaryFile(suffix=".lp", delete=False) as f: + out_path = f.name + try: + problem.write(out_path) + with open(out_path) as f: + written = f.read() + finally: + os.unlink(out_path) + + assert written == ( + "Minimize\n" + " obj: + 3 x + 2 y + 1 z + 2 b\n" + "Subject To\n" + " c1: + 1 x + 1 y <= 10\n" + " c2: + 1 x + 1 z >= 1\n" + " c3: + 2 x + 1 y = 6\n" + "Bounds\n" + " x <= 8\n" + " y >= 1\n" + " z <= 10\n" + "Generals\n" + " z\n" + "Binaries\n" + " b\n" + "End\n" + ) + + +def test_write_read_write(): + # Build a Problem, write it as LP, read that LP back, write it again, and + # assert the two emitted LP files are byte-for-byte identical. A stable + # writer must reach a fixed point after the first write/read cycle. + from cuopt.linear_programming.problem import ( + INTEGER, + MINIMIZE, + Problem, + ) + + problem = Problem("idempotent_lp") + x = problem.addVariable(lb=0.0, ub=8.0, obj=3.0, name="x") + y = problem.addVariable(lb=1.0, ub=float("inf"), obj=2.0, name="y") + z = problem.addVariable(lb=0.0, ub=1.0, obj=-1.0, vtype=INTEGER, name="z") + problem.addConstraint(x + y <= 10, name="c1") + problem.addConstraint(x - z >= -4, name="c2") + problem.addConstraint(2 * x + y == 6, name="c3") + problem.setObjective(3 * x + 2 * y - z, sense=MINIMIZE) + + with tempfile.NamedTemporaryFile(suffix=".lp", delete=False) as f: + first_path = f.name + with tempfile.NamedTemporaryFile(suffix=".lp", delete=False) as f: + second_path = f.name + try: + # First write straight from the Python model. + problem.write(first_path) + with open(first_path) as f: + first_lp = f.read() + + # Read the emitted LP back and write it out a second time. + reparsed = Read(first_path) + reparsed.write(second_path) + with open(second_path) as f: + second_lp = f.read() + finally: + os.unlink(first_path) + os.unlink(second_path) + + assert first_lp == second_lp + + +def test_write_dispatches_on_extension(): + from cuopt.linear_programming.problem import MINIMIZE, Problem + + problem = Problem() + x = problem.addVariable(lb=0.0, ub=10.0, obj=1.0, name="x") + problem.addConstraint(x <= 5, name="c1") + problem.setObjective(x, sense=MINIMIZE) + + with tempfile.NamedTemporaryFile(suffix=".lp", delete=False) as f: + lp_path = f.name + with tempfile.NamedTemporaryFile(suffix=".mps", delete=False) as f: + mps_path = f.name + try: + problem.write(lp_path) + problem.write(mps_path) + with open(lp_path) as f: + lp_content = f.read() + with open(mps_path) as f: + mps_content = f.read() + finally: + os.unlink(lp_path) + os.unlink(mps_path) + + assert "Subject To" in lp_content + assert "ENDATA" in mps_content + + +@pytest.mark.parametrize( + "suffix", + [".xyz", ".lp.gz", ".lp.bz2", ".mps.gz", ".mps.bz2", ".qps.gz", ".qps.bz2"], +) +def test_write_unsupported_extension(suffix): + from cuopt.linear_programming.problem import MINIMIZE, Problem + + problem = Problem() + x = problem.addVariable(lb=0.0, ub=10.0, obj=1.0, name="x") + problem.setObjective(x, sense=MINIMIZE) + + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f: + out_path = f.name + try: + with pytest.raises( + RuntimeError, match="unrecognized output file extension" + ): + problem.write(out_path) + finally: + os.unlink(out_path)