diff --git a/include/pyoptinterface/knitro_model.hpp b/include/pyoptinterface/knitro_model.hpp index 01dd976..f13ea57 100644 --- a/include/pyoptinterface/knitro_model.hpp +++ b/include/pyoptinterface/knitro_model.hpp @@ -16,6 +16,9 @@ // Define Knitro C APIs to be dynamically loaded #define APILIST \ + B(KN_checkout_license); \ + B(KN_release_license); \ + B(KN_new_lm); \ B(KN_new); \ B(KN_free); \ B(KN_update); \ @@ -103,6 +106,17 @@ struct KNITROFreeProblemT } }; +struct KNITROFreeLicenseT +{ + void operator()(LM_context *lmc) const + { + if (lmc != nullptr) + { + knitro::KN_release_license(&lmc); + } + } +}; + enum ObjectiveFlags { OBJ_CONSTANT = 1 << 0, // 0x01 @@ -334,6 +348,35 @@ inline ObjectiveSense knitro_obj_sense(int goal) } } +inline void knitro_throw(int error) +{ + if (error != 0) + { + throw std::runtime_error(fmt::format("KNITRO error code: {}", error)); + } +} + +class KNITROEnv +{ + public: + KNITROEnv(bool empty = false); + + KNITROEnv(const KNITROEnv &) = delete; + KNITROEnv &operator=(const KNITROEnv &) = delete; + + KNITROEnv(KNITROEnv &&) = default; + KNITROEnv &operator=(KNITROEnv &&) = default; + + void start(); + bool empty() const; + std::shared_ptr get_lm() const; + void close(); + + private: + void _check_error(int code) const; + std::shared_ptr m_lm = nullptr; +}; + class KNITROModel : public OnesideLinearConstraintMixin, public TwosideLinearConstraintMixin, public OnesideQuadraticConstraintMixin, @@ -346,7 +389,16 @@ class KNITROModel : public OnesideLinearConstraintMixin, public: // Constructor/Init/Close KNITROModel(); + KNITROModel(const KNITROEnv &env); + + KNITROModel(const KNITROModel &) = delete; + KNITROModel &operator=(const KNITROModel &) = delete; + + KNITROModel(KNITROModel &&) = default; + KNITROModel &operator=(KNITROModel &&) = default; + void init(); + void init(const KNITROEnv &env); void close(); // Model information @@ -423,6 +475,14 @@ class KNITROModel : public OnesideLinearConstraintMixin, double get_mip_relative_gap() const; double get_solve_time() const; + // Model state + bool dirty() const; + bool empty() const; + + // Solve status + int get_solve_status() const; + + // Parameter management template void set_raw_parameter(const std::string &name, T value) { @@ -487,13 +547,12 @@ class KNITROModel : public OnesideLinearConstraintMixin, // Internal helpers void _check_error(int error) const; + void _mark_dirty(); + void _unmark_dirty(); void _check_dirty() const; KNINT _variable_index(const VariableIndex &variable) const; KNINT _constraint_index(const ConstraintIndex &constraint) const; - // Member variables - std::unique_ptr m_kc = nullptr; - size_t n_vars = 0; size_t n_cons = 0; size_t n_lincons = 0; @@ -501,6 +560,11 @@ class KNITROModel : public OnesideLinearConstraintMixin, size_t n_coniccons = 0; size_t n_nlcons = 0; + private: + // Member variables + std::shared_ptr m_lm = nullptr; + std::unique_ptr m_kc = nullptr; + std::unordered_map>> m_soc_aux_cons; std::unordered_map m_con_sense_flags; uint8_t m_obj_flag = 0; @@ -508,11 +572,12 @@ class KNITROModel : public OnesideLinearConstraintMixin, std::unordered_map m_pending_outputs; std::vector>> m_evaluators; bool m_need_to_add_callbacks = false; - - bool m_is_dirty = true; int m_solve_status = 0; + bool m_is_dirty = true; private: + void _init(); + void _reset_state(); std::tuple _sense_to_interval(ConstraintSense sense, double rhs); void _update_con_sense_flags(const ConstraintIndex &constraint, ConstraintSense sense); diff --git a/lib/knitro_model.cpp b/lib/knitro_model.cpp index d3e683f..21565fe 100644 --- a/lib/knitro_model.cpp +++ b/lib/knitro_model.cpp @@ -50,36 +50,90 @@ bool load_library(const std::string &path) } } // namespace knitro -KNITROModel::KNITROModel() +void ensure_library_loaded() { - init(); + if (!knitro::is_library_loaded()) + { + throw std::runtime_error("KNITRO library not loaded"); + } } -void KNITROModel::init() +KNITROEnv::KNITROEnv(bool empty) { - if (!knitro::is_library_loaded()) + if (!empty) { - throw std::runtime_error("KNITRO library is not loaded"); + start(); } +} - KN_context *kc_ptr = nullptr; - int error = knitro::KN_new(&kc_ptr); +void KNITROEnv::start() +{ + if (!empty()) + { + return; + } + ensure_library_loaded(); + LM_context *lm = nullptr; + int error = knitro::KN_checkout_license(&lm); _check_error(error); + m_lm = std::shared_ptr(lm, KNITROFreeLicenseT()); +} - m_kc = std::unique_ptr(kc_ptr); +bool KNITROEnv::empty() const +{ + return m_lm == nullptr; } -void KNITROModel::close() +std::shared_ptr KNITROEnv::get_lm() const { - m_kc.reset(); + return m_lm; +} + +void KNITROEnv::close() +{ + m_lm.reset(); +} + +void KNITROEnv::_check_error(int code) const +{ + knitro_throw(code); +} + +KNITROModel::KNITROModel() +{ + init(); +} + +KNITROModel::KNITROModel(const KNITROEnv &env) +{ + init(env); } -void KNITROModel::_check_error(int error) const +void KNITROModel::init() { - if (error != 0) + m_lm.reset(); + _init(); +} + +void KNITROModel::init(const KNITROEnv &env) +{ + if (env.empty()) { - throw std::runtime_error(fmt::format("KNITRO error code: {}", error)); + throw std::runtime_error("Empty environment provided. Call start()..."); } + m_lm = env.get_lm(); + _init(); +} + +void KNITROModel::close() +{ + _reset_state(); + m_lm.reset(); +} + +void KNITROModel::_check_error(int code) const +{ + knitro_throw(code); } // Model information @@ -130,7 +184,7 @@ VariableIndex KNITROModel::add_variable(VariableDomain domain, double lb, double } n_vars++; - m_is_dirty = true; + _mark_dirty(); return variable; } @@ -213,7 +267,7 @@ void KNITROModel::set_variable_domain(const VariableIndex &variable, VariableDom _set_value(knitro::KN_set_var_upbnd, indexVar, ub); } - m_is_dirty = true; + _mark_dirty(); } double KNITROModel::get_variable_rc(const VariableIndex &variable) const @@ -231,7 +285,7 @@ void KNITROModel::delete_variable(const VariableIndex &variable) _set_value(knitro::KN_set_var_lobnd, indexVar, -get_infinity()); _set_value(knitro::KN_set_var_upbnd, indexVar, get_infinity()); n_vars--; - m_is_dirty = true; + _mark_dirty(); } std::string KNITROModel::pprint_variable(const VariableIndex &variable) const @@ -481,7 +535,7 @@ void KNITROModel::delete_constraint(const ConstraintIndex &constraint) m_soc_aux_cons.erase(it); } - m_is_dirty = true; + _mark_dirty(); } void KNITROModel::set_constraint_name(const ConstraintIndex &constraint, const std::string &name) @@ -526,7 +580,7 @@ void KNITROModel::set_normalized_rhs(const ConstraintIndex &constraint, double r _set_value(knitro::KN_set_con_upbnd, indexCon, rhs); } - m_is_dirty = true; + _mark_dirty(); } double KNITROModel::get_normalized_rhs(const ConstraintIndex &constraint) const @@ -557,7 +611,7 @@ void KNITROModel::set_normalized_coefficient(const ConstraintIndex &constraint, _update(); int error = knitro::KN_chg_con_linear_term(m_kc.get(), indexCon, indexVar, coefficient); _check_error(error); - m_is_dirty = true; + _mark_dirty(); } void KNITROModel::_set_linear_constraint(const ConstraintIndex &constraint, @@ -654,7 +708,7 @@ void KNITROModel::set_objective_coefficient(const VariableIndex &variable, doubl _update(); int error = knitro::KN_chg_obj_linear_term(m_kc.get(), indexVar, coefficient); _check_error(error); - m_is_dirty = true; + _mark_dirty(); } void KNITROModel::add_single_nl_objective(ExpressionGraph &graph, const ExpressionHandle &result) @@ -665,7 +719,7 @@ void KNITROModel::add_single_nl_objective(ExpressionGraph &graph, const Expressi m_pending_outputs[&graph].obj_idxs.push_back(i); m_need_to_add_callbacks = true; m_obj_flag |= OBJ_NONLINEAR; - m_is_dirty = true; + _mark_dirty(); } void KNITROModel::set_obj_sense(ObjectiveSense sense) @@ -873,7 +927,7 @@ void KNITROModel::optimize() _pre_solve(); _solve(); _post_solve(); - m_is_dirty = false; + _unmark_dirty(); } // Solve information @@ -909,15 +963,75 @@ double KNITROModel::get_solve_time() const return _get_value(knitro::KN_get_solve_time_real); } +// Dirty state management +void KNITROModel::_mark_dirty() +{ + m_is_dirty = true; +} + +void KNITROModel::_unmark_dirty() +{ + m_is_dirty = false; +} + +bool KNITROModel::dirty() const +{ + return m_is_dirty; +} + +// Model state +bool KNITROModel::empty() const +{ + return m_kc == nullptr; +} + +int KNITROModel::get_solve_status() const +{ + _check_dirty(); + return m_solve_status; +} + // Internal helpers void KNITROModel::_check_dirty() const { - if (m_is_dirty) + if (dirty()) { throw std::runtime_error("Model has been modified since last solve. Call optimize()..."); } } +void KNITROModel::_reset_state() +{ + m_kc.reset(); + n_vars = 0; + n_cons = 0; + n_lincons = 0; + n_quadcons = 0; + n_coniccons = 0; + n_nlcons = 0; + m_soc_aux_cons.clear(); + m_con_sense_flags.clear(); + m_obj_flag = 0; + m_pending_outputs.clear(); + m_evaluators.clear(); + m_need_to_add_callbacks = false; + _mark_dirty(); + m_solve_status = 0; +} + +void KNITROModel::_init() +{ + ensure_library_loaded(); + _reset_state(); + + // Create new KNITRO problem + KN_context *kc = nullptr; + int error = m_lm ? knitro::KN_new_lm(m_lm.get(), &kc) : knitro::KN_new(&kc); + knitro_throw(error); + m_kc = std::unique_ptr(kc); +} + + KNINT KNITROModel::_variable_index(const VariableIndex &variable) const { return _get_index(variable); diff --git a/lib/knitro_model_ext.cpp b/lib/knitro_model_ext.cpp index 16cb35d..8a70670 100644 --- a/lib/knitro_model_ext.cpp +++ b/lib/knitro_model_ext.cpp @@ -18,12 +18,19 @@ NB_MODULE(knitro_model_ext, m) bind_knitro_constants(m); + nb::class_(m, "RawEnv") + .def(nb::init(), nb::arg("empty") = false) + .def("start", &KNITROEnv::start) + .def("empty", &KNITROEnv::empty) + .def("close", &KNITROEnv::close); + #define BIND_F(f) .def(#f, &KNITROModel::f) nb::class_(m, "RawModel") .def(nb::init<>()) - - // clang-format off - BIND_F(init) + .def(nb::init()) + .def("init", nb::overload_cast<>(&KNITROModel::init)) + .def("init", nb::overload_cast(&KNITROModel::init)) + // clang-format off BIND_F(close) BIND_F(get_infinity) BIND_F(get_number_iterations) @@ -225,8 +232,15 @@ NB_MODULE(knitro_model_ext, m) }, nb::arg("param_id")) - .def_rw("m_is_dirty", &KNITROModel::m_is_dirty) - .def_ro("m_solve_status", &KNITROModel::m_solve_status); + // clang-format off + BIND_F(dirty) + BIND_F(empty) + // clang-format on + + // clang-format off + BIND_F(get_solve_status) + // clang-format on + ; #undef BIND_F } diff --git a/src/pyoptinterface/_src/knitro.py b/src/pyoptinterface/_src/knitro.py index 459ed9a..598cab0 100644 --- a/src/pyoptinterface/_src/knitro.py +++ b/src/pyoptinterface/_src/knitro.py @@ -23,7 +23,7 @@ ScalarQuadraticFunction, VariableIndex, ) -from .knitro_model_ext import KN, RawModel, load_library +from .knitro_model_ext import KN, RawEnv, RawModel, load_library from .matrix import add_matrix_constraints from .nlexpr_ext import ExpressionGraph, ExpressionHandle from .nlfunc import ExpressionGraphContext, convert_to_expressionhandle @@ -166,17 +166,21 @@ def autoload_library(): def _termination_status_knitro(model: "Model"): - if model.m_is_dirty: + if model.is_dirty: return TerminationStatusCode.OPTIMIZE_NOT_CALLED - status_code = model.m_solve_status + + code = model.solve_status for ts, rs in _RAW_STATUS_STRINGS: - if status_code == rs: + if code == rs: return ts return TerminationStatusCode.OTHER_ERROR def _result_status_knitro(model: "Model"): - status_code = model.m_solve_status + if model.is_dirty: + return ResultStatusCode.NO_SOLUTION + + code = model.solve_status feasible = { KN.RC_OPTIMAL, @@ -211,9 +215,9 @@ def _result_status_knitro(model: "Model"): KN.RC_MIP_NODE_LIMIT_INFEAS, } - if status_code in feasible: + if code in feasible: return ResultStatusCode.FEASIBLE_POINT - if status_code in infeasible: + if code in infeasible: return ResultStatusCode.INFEASIBLE_POINT return ResultStatusCode.NO_SOLUTION @@ -223,16 +227,15 @@ def _result_status_knitro(model: "Model"): ModelAttribute.ObjectiveSense: lambda model: model.get_obj_sense(), ModelAttribute.TerminationStatus: _termination_status_knitro, ModelAttribute.RawStatusString: lambda model: ( - f"KNITRO status code: {model.m_solve_status}" + f"KNITRO status code: {model.solve_status}" ), ModelAttribute.PrimalStatus: _result_status_knitro, ModelAttribute.NumberOfThreads: lambda model: model.get_raw_parameter( - KN.PARAM_THREADS + KN.PARAM_NUMTHREADS ), ModelAttribute.TimeLimitSec: lambda model: model.get_raw_parameter( - KN.PARAM_TIME_LIMIT + KN.PARAM_MAXTIME ), - # TODO: Bind this in C++ ModelAttribute.BarrierIterations: lambda model: model.get_number_iterations(), ModelAttribute.NodeCount: lambda model: model.get_mip_node_count(), ModelAttribute.ObjectiveBound: lambda model: model.get_obj_bound(), @@ -245,26 +248,63 @@ def _result_status_knitro(model: "Model"): model_attribute_set_func_map = { ModelAttribute.ObjectiveSense: lambda model, x: model.set_obj_sense(x), ModelAttribute.NumberOfThreads: lambda model, x: model.set_raw_parameter( - KN.PARAM_THREADS, x + KN.PARAM_NUMTHREADS, x ), ModelAttribute.Silent: lambda model, x: model.set_raw_parameter( KN.PARAM_OUTLEV, KN.OUTLEV_NONE if x else KN.OUTLEV_ITER_10 ), ModelAttribute.TimeLimitSec: lambda model, x: model.set_raw_parameter( - KN.PARAM_TIME_LIMIT, x + KN.PARAM_MAXTIME, x ), } +class Env(RawEnv): + """ + KNITRO license manager environment. + """ + @property + def is_empty(self): + return self.empty() + + class Model(RawModel): """ KNITRO model class for PyOptInterface. """ - def __init__(self): - super().__init__() + def __init__(self, env: Env = None) -> None: + if env is not None: + super().__init__(env) + else: + super().__init__() self.graph_map: dict[ExpressionGraph, int] = {} + def _reset_graph_map(self) -> None: + self.graph_map.clear() + + def _add_graph_expr( + self, expr: ExpressionHandle + ) -> tuple[ExpressionGraph, ExpressionHandle]: + graph = ExpressionGraphContext.current_graph() + expr = convert_to_expressionhandle(graph, expr) + if not isinstance(expr, ExpressionHandle): + raise ValueError("Expression should be convertible to ExpressionHandle") + if graph not in self.graph_map: + self.graph_map[graph] = len(self.graph_map) + return graph, expr + + def init(self, env: Env = None) -> None: + if env is not None: + super().init(env) + else: + super().init() + self._reset_graph_map() + + def close(self) -> None: + super().close() + self._reset_graph_map() + @staticmethod def supports_variable_attribute( attribute: VariableAttribute, setable: bool = False @@ -292,12 +332,12 @@ def supports_model_attribute( else: return attribute in model_attribute_get_func_map - def number_of_variables(self): + def number_of_variables(self) -> int: return self.n_vars def number_of_constraints( self, constraint_type: Union[ConstraintType, None] = None - ): + ) -> int: if constraint_type is None: return self.n_cons elif constraint_type == ConstraintType.Linear: @@ -326,16 +366,16 @@ def add_linear_constraint( expr: Union[VariableIndex, ScalarAffineFunction, ExprBuilder], interval: Tuple[float, float], name: str = "", - ): ... + ) -> ConstraintIndex: ... @overload def add_linear_constraint( self, con: ComparisonConstraint, name: str = "", - ): ... + ) -> ConstraintIndex: ... - def add_linear_constraint(self, arg, *args, **kwargs): + def add_linear_constraint(self, arg, *args, **kwargs) -> ConstraintIndex: if isinstance(arg, ComparisonConstraint): return self._add_linear_constraint( arg.lhs, arg.sense, arg.rhs, *args, **kwargs @@ -350,16 +390,16 @@ def add_quadratic_constraint( sense: ConstraintSense, rhs: float, name: str = "", - ): ... + ) -> ConstraintIndex: ... @overload def add_quadratic_constraint( self, con: ComparisonConstraint, name: str = "", - ): ... + ) -> ConstraintIndex: ... - def add_quadratic_constraint(self, arg, *args, **kwargs): + def add_quadratic_constraint(self, arg, *args, **kwargs) -> ConstraintIndex: if isinstance(arg, ComparisonConstraint): return self._add_quadratic_constraint( arg.lhs, arg.sense, arg.rhs, *args, **kwargs @@ -375,7 +415,7 @@ def add_nl_constraint( rhs: float, /, name: str = "", - ): ... + ) -> ConstraintIndex: ... @overload def add_nl_constraint( @@ -384,7 +424,7 @@ def add_nl_constraint( interval: Tuple[float, float], /, name: str = "", - ): ... + ) -> ConstraintIndex: ... @overload def add_nl_constraint( @@ -392,24 +432,14 @@ def add_nl_constraint( con, /, name: str = "", - ): ... + ) -> ConstraintIndex: ... - def add_nl_constraint(self, expr, *args, **kwargs): - graph = ExpressionGraphContext.current_graph() - expr = convert_to_expressionhandle(graph, expr) - if not isinstance(expr, ExpressionHandle): - raise ValueError("Expression should be convertible to ExpressionHandle") - if graph not in self.graph_map: - self.graph_map[graph] = len(self.graph_map) + def add_nl_constraint(self, expr, *args, **kwargs) -> ConstraintIndex: + graph, expr = self._add_graph_expr(expr) return self._add_single_nl_constraint(graph, expr, *args, **kwargs) - def add_nl_objective(self, expr): - graph = ExpressionGraphContext.current_graph() - expr = convert_to_expressionhandle(graph, expr) - if not isinstance(expr, ExpressionHandle): - raise ValueError("Expression should be convertible to ExpressionHandle") - if graph not in self.graph_map: - self.graph_map[graph] = len(self.graph_map) + def add_nl_objective(self, expr) -> None: + graph, expr = self._add_graph_expr(expr) self._add_single_nl_objective(graph, expr) def get_model_attribute(self, attr: ModelAttribute): @@ -481,10 +511,20 @@ def e(attribute): value, constraint_attribute_set_func_map, {}, + e, ) - def is_empty(self): - return self.n_vars == 0 + @property + def is_dirty(self) -> bool: + return self.dirty() + + @property + def is_empty(self) -> bool: + return self.empty() and not self.graph_map + + @property + def solve_status(self) -> int: + return self.get_solve_status() Model.add_variables = make_variable_tupledict diff --git a/src/pyoptinterface/knitro.py b/src/pyoptinterface/knitro.py index 75c20d8..4a947b7 100644 --- a/src/pyoptinterface/knitro.py +++ b/src/pyoptinterface/knitro.py @@ -1,4 +1,4 @@ -from pyoptinterface._src.knitro import Model, autoload_library +from pyoptinterface._src.knitro import Env, Model, autoload_library from pyoptinterface._src.knitro_model_ext import ( KN, load_library, @@ -6,6 +6,7 @@ ) __all__ = [ + "Env", "Model", "KN", "autoload_library", diff --git a/tests/test_knitro.py b/tests/test_knitro.py new file mode 100644 index 0000000..332412b --- /dev/null +++ b/tests/test_knitro.py @@ -0,0 +1,475 @@ +import pytest +from pytest import approx + +from pyoptinterface import knitro +import pyoptinterface as poi + + +pytestmark = pytest.mark.skipif( + not knitro.is_library_loaded(), reason="KNITRO library is not loaded" +) + + +def test_new_model_without_env(): + """Test creating a model without an environment (default behavior).""" + model = knitro.Model() + + x = model.add_variable(lb=0.0, ub=10.0) + y = model.add_variable(lb=0.0, ub=10.0) + + model.set_objective(x + y, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x + y, poi.ConstraintSense.GreaterEqual, 5.0) + + model.optimize() + + status = model.get_model_attribute(poi.ModelAttribute.TerminationStatus) + assert status == poi.TerminationStatusCode.OPTIMAL + + x_val = model.get_value(x) + y_val = model.get_value(y) + assert x_val + y_val == approx(5.0) + + +def test_new_model_with_env(): + """Test creating a model with an environment.""" + env = knitro.Env() + model = knitro.Model(env=env) + + x = model.add_variable(lb=0.0, ub=10.0) + y = model.add_variable(lb=0.0, ub=10.0) + + model.set_objective(x + y, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x + y, poi.ConstraintSense.GreaterEqual, 5.0) + + model.optimize() + + status = model.get_model_attribute(poi.ModelAttribute.TerminationStatus) + assert status == poi.TerminationStatusCode.OPTIMAL + + x_val = model.get_value(x) + y_val = model.get_value(y) + assert x_val + y_val == approx(5.0) + + +def test_multiple_models_single_env(): + """Test creating multiple models sharing the same environment.""" + env = knitro.Env() + + model1 = knitro.Model(env=env) + model2 = knitro.Model(env=env) + + x1 = model1.add_variable(lb=0.0, ub=10.0) + model1.set_objective(x1, poi.ObjectiveSense.Minimize) + model1.add_linear_constraint(x1, poi.ConstraintSense.GreaterEqual, 3.0) + model1.optimize() + + x2 = model2.add_variable(lb=0.0, ub=10.0) + model2.set_objective(x2, poi.ObjectiveSense.Minimize) + model2.add_linear_constraint(x2, poi.ConstraintSense.GreaterEqual, 5.0) + model2.optimize() + + assert model1.get_value(x1) == approx(3.0) + assert model2.get_value(x2) == approx(5.0) + + +def test_multiple_models_multiple_envs(): + """Test creating multiple models each with its own environment.""" + env1 = knitro.Env() + env2 = knitro.Env() + + model1 = knitro.Model(env=env1) + model2 = knitro.Model(env=env2) + + x1 = model1.add_variable(lb=0.0, ub=10.0) + model1.set_objective(x1, poi.ObjectiveSense.Minimize) + model1.add_linear_constraint(x1, poi.ConstraintSense.GreaterEqual, 4.0) + model1.optimize() + + x2 = model2.add_variable(lb=0.0, ub=10.0) + model2.set_objective(x2, poi.ObjectiveSense.Minimize) + model2.add_linear_constraint(x2, poi.ConstraintSense.GreaterEqual, 6.0) + model2.optimize() + + assert model1.get_value(x1) == approx(4.0) + assert model2.get_value(x2) == approx(6.0) + + +def test_env_lifetime(): + """Test that environment properly manages its lifetime.""" + env = knitro.Env() + model = knitro.Model(env=env) + + x = model.add_variable(lb=0.0, ub=10.0) + model.set_objective(x, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x, poi.ConstraintSense.GreaterEqual, 2.0) + model.optimize() + + assert model.get_value(x) == approx(2.0) + + model.close() + + try: + del env + except Exception as e: + pytest.fail(f"Deleting environment raised an error: {e}") + + +def test_env_close(): + """Test that env.close() properly releases the license.""" + env = knitro.Env() + + model = knitro.Model(env=env) + x = model.add_variable(lb=0.0, ub=10.0) + model.set_objective(x, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x, poi.ConstraintSense.GreaterEqual, 1.0) + model.optimize() + assert model.get_value(x) == approx(1.0) + model.close() + + try: + env.close() + except Exception as e: + pytest.fail(f"env.close() raised an error: {e}") + + +def test_init_with_env(): + """Test using init method with an environment.""" + env = knitro.Env() + model = knitro.Model() + model.init(env) + x = model.add_variable(lb=0.0, ub=10.0) + model.set_objective(x, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x, poi.ConstraintSense.GreaterEqual, 1.0) + model.optimize() + + assert model.get_value(x) == approx(1.0) + + model.close() + del model + del env + + +def test_model_with_empty_env(): + """Test that creating a model with an empty environment raises an error.""" + env = knitro.Env(empty=True) + assert env.is_empty + + with pytest.raises(RuntimeError, match="Empty environment"): + knitro.Model(env=env) + + +def test_model_init_with_empty_env_after_start(): + """Test that initializing a model with an empty environment after starting raises an error.""" + env = knitro.Env(empty=True) + assert env.is_empty + + env.start() + assert knitro.Model(env=env) is not None + +def test_model_dirty(): + """Test the dirty method.""" + model = knitro.Model() + + assert model.dirty() is True + + x = model.add_variable(lb=0.0, ub=10.0) + assert model.dirty() is True + + model.set_objective(x, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x, poi.ConstraintSense.GreaterEqual, 1.0) + model.optimize() + + assert model.dirty() is False + + model.add_variable(lb=0.0, ub=5.0) + assert model.dirty() is True + + +def test_model_is_dirty(): + """Test the is_dirty property.""" + model = knitro.Model() + + assert model.is_dirty is True + + x = model.add_variable(lb=0.0, ub=10.0) + assert model.is_dirty is True + + model.set_objective(x, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x, poi.ConstraintSense.GreaterEqual, 1.0) + model.optimize() + + assert model.is_dirty is False + + model.add_variable(lb=0.0, ub=5.0) + assert model.is_dirty is True + + +def test_model_empty(): + """Test the empty() method.""" + model = knitro.Model() + assert model.empty() is False + model.close() + assert model.empty() is True + + +def test_model_is_empty_property(): + """Test the is_empty property.""" + model = knitro.Model() + assert model.is_empty is False + model.close() + assert model.is_empty is True + + +def test_number_of_variables(): + """Test number_of_variables() method.""" + model = knitro.Model() + + assert model.number_of_variables() == 0 + + model.add_variable() + assert model.number_of_variables() == 1 + + model.add_variable() + model.add_variable() + assert model.number_of_variables() == 3 + + +def test_number_of_constraints(): + """Test number_of_constraints() method.""" + model = knitro.Model() + x = model.add_variable(lb=-10.0, ub=10.0) + y = model.add_variable(lb=-10.0, ub=10.0) + + assert model.number_of_constraints() == 0 + assert model.number_of_constraints(poi.ConstraintType.Linear) == 0 + assert model.number_of_constraints(poi.ConstraintType.Quadratic) == 0 + + model.add_linear_constraint(x + y, poi.ConstraintSense.LessEqual, 5.0) + assert model.number_of_constraints() == 1 + assert model.number_of_constraints(poi.ConstraintType.Linear) == 1 + + model.add_quadratic_constraint(x * x + y, poi.ConstraintSense.LessEqual, 10.0) + assert model.number_of_constraints() == 2 + assert model.number_of_constraints(poi.ConstraintType.Quadratic) == 1 + + +def test_supports_attribute_methods(): + """Test supports_*_attribute() static methods.""" + assert knitro.Model.supports_variable_attribute(poi.VariableAttribute.Value) is True + assert knitro.Model.supports_variable_attribute(poi.VariableAttribute.LowerBound) is True + assert knitro.Model.supports_variable_attribute(poi.VariableAttribute.LowerBound, setable=True) is True + assert knitro.Model.supports_variable_attribute(poi.VariableAttribute.Value, setable=True) is False + + assert knitro.Model.supports_constraint_attribute(poi.ConstraintAttribute.Primal) is True + assert knitro.Model.supports_constraint_attribute(poi.ConstraintAttribute.Name) is True + assert knitro.Model.supports_constraint_attribute(poi.ConstraintAttribute.Name, setable=True) is True + + assert knitro.Model.supports_model_attribute(poi.ModelAttribute.ObjectiveValue) is True + assert knitro.Model.supports_model_attribute(poi.ModelAttribute.SolverName) is True + assert knitro.Model.supports_model_attribute(poi.ModelAttribute.Silent, setable=True) is True + assert knitro.Model.supports_model_attribute(poi.ModelAttribute.ObjectiveValue, setable=True) is False + assert knitro.Model.supports_model_attribute(poi.ModelAttribute.NumberOfThreads, setable=True) is True + assert knitro.Model.supports_model_attribute(poi.ModelAttribute.TimeLimitSec, setable=True) is True + + +def test_model_attribute_solver_info(): + """Test getting solver info model attributes.""" + model = knitro.Model() + + solver_name = model.get_model_attribute(poi.ModelAttribute.SolverName) + assert solver_name == "KNITRO" + + solver_version = model.get_model_attribute(poi.ModelAttribute.SolverVersion) + assert isinstance(solver_version, str) + assert len(solver_version) > 0 + + +def test_model_attribute_termination_before_solve(): + """Test termination status before solving.""" + model = knitro.Model() + x = model.add_variable(lb=0.0, ub=10.0) + model.set_objective(x, poi.ObjectiveSense.Minimize) + + status = model.get_model_attribute(poi.ModelAttribute.TerminationStatus) + assert status == poi.TerminationStatusCode.OPTIMIZE_NOT_CALLED + + +def test_model_attribute_after_solve(): + """Test model attributes after solving.""" + model = knitro.Model() + x = model.add_variable(lb=0.0, ub=10.0) + y = model.add_variable(lb=0.0, ub=10.0) + + model.set_objective(x + 2 * y, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x + y, poi.ConstraintSense.GreaterEqual, 5.0) + model.optimize() + + status = model.get_model_attribute(poi.ModelAttribute.TerminationStatus) + assert status == poi.TerminationStatusCode.OPTIMAL + + primal_status = model.get_model_attribute(poi.ModelAttribute.PrimalStatus) + assert primal_status == poi.ResultStatusCode.FEASIBLE_POINT + + raw_status = model.get_model_attribute(poi.ModelAttribute.RawStatusString) + assert isinstance(raw_status, str) + assert "KNITRO" in raw_status + + obj_value = model.get_model_attribute(poi.ModelAttribute.ObjectiveValue) + assert obj_value == approx(5.0) + + obj_sense = model.get_model_attribute(poi.ModelAttribute.ObjectiveSense) + assert obj_sense == poi.ObjectiveSense.Minimize + + solve_time = model.get_model_attribute(poi.ModelAttribute.SolveTimeSec) + assert isinstance(solve_time, float) + assert solve_time >= 0.0 + + iterations = model.get_model_attribute(poi.ModelAttribute.BarrierIterations) + assert isinstance(iterations, int) + assert iterations >= 0 + + +def test_set_model_attribute_objective_sense(): + """Test setting objective sense.""" + model = knitro.Model() + model.add_variable(lb=0.0, ub=10.0) + + model.set_model_attribute(poi.ModelAttribute.ObjectiveSense, poi.ObjectiveSense.Maximize) + assert model.get_model_attribute(poi.ModelAttribute.ObjectiveSense) == poi.ObjectiveSense.Maximize + + model.set_model_attribute(poi.ModelAttribute.ObjectiveSense, poi.ObjectiveSense.Minimize) + assert model.get_model_attribute(poi.ModelAttribute.ObjectiveSense) == poi.ObjectiveSense.Minimize + + +def test_set_model_attribute_silent(): + """Test setting silent mode (should not raise error).""" + model = knitro.Model() + x = model.add_variable(lb=0.0, ub=10.0) + model.set_objective(x, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x, poi.ConstraintSense.GreaterEqual, 1.0) + + model.set_model_attribute(poi.ModelAttribute.Silent, True) + model.optimize() + + assert model.get_value(x) == approx(1.0) + + +def test_set_model_attribute_threads(): + """Test setting number of threads.""" + model = knitro.Model() + + model.set_model_attribute(poi.ModelAttribute.NumberOfThreads, 2) + threads = model.get_model_attribute(poi.ModelAttribute.NumberOfThreads) + assert threads == 2 + + +def test_set_model_attribute_time_limit(): + """Test setting time limit (set only, get may not work due to param type mismatch).""" + model = knitro.Model() + + model.set_model_attribute(poi.ModelAttribute.TimeLimitSec, 100.0) + + +def test_variable_attribute_bounds(): + """Test getting and setting variable bounds.""" + model = knitro.Model() + x = model.add_variable(lb=0.0, ub=10.0) + + lb = model.get_variable_attribute(x, poi.VariableAttribute.LowerBound) + ub = model.get_variable_attribute(x, poi.VariableAttribute.UpperBound) + assert lb == approx(0.0) + assert ub == approx(10.0) + + model.set_variable_attribute(x, poi.VariableAttribute.LowerBound, 1.0) + model.set_variable_attribute(x, poi.VariableAttribute.UpperBound, 5.0) + + lb = model.get_variable_attribute(x, poi.VariableAttribute.LowerBound) + ub = model.get_variable_attribute(x, poi.VariableAttribute.UpperBound) + assert lb == approx(1.0) + assert ub == approx(5.0) + + +def test_variable_attribute_name(): + """Test getting and setting variable name.""" + model = knitro.Model() + x = model.add_variable(name="my_var") + + name = model.get_variable_attribute(x, poi.VariableAttribute.Name) + assert name == "my_var" + + model.set_variable_attribute(x, poi.VariableAttribute.Name, "new_name") + name = model.get_variable_attribute(x, poi.VariableAttribute.Name) + assert name == "new_name" + + +def test_variable_attribute_value(): + """Test getting variable value after solve.""" + model = knitro.Model() + x = model.add_variable(lb=0.0, ub=10.0) + + model.set_objective(x, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x, poi.ConstraintSense.GreaterEqual, 3.0) + model.optimize() + + value = model.get_variable_attribute(x, poi.VariableAttribute.Value) + assert value == approx(3.0) + + +def test_variable_attribute_primal_start(): + """Test setting primal start value.""" + model = knitro.Model() + x = model.add_variable(lb=0.0, ub=10.0) + + model.set_variable_attribute(x, poi.VariableAttribute.PrimalStart, 5.0) + + model.set_objective(x, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x, poi.ConstraintSense.GreaterEqual, 2.0) + model.optimize() + + assert model.get_value(x) == approx(2.0) + + +def test_variable_attribute_domain(): + """Test setting variable domain.""" + model = knitro.Model() + x = model.add_variable(lb=0.0, ub=10.0) + + model.set_variable_attribute(x, poi.VariableAttribute.Domain, poi.VariableDomain.Integer) + + model.set_objective(x, poi.ObjectiveSense.Minimize) + model.add_linear_constraint(x, poi.ConstraintSense.GreaterEqual, 2.5) + model.optimize() + + assert model.get_value(x) == approx(3.0) + + +def test_constraint_attribute_name(): + """Test getting and setting constraint name.""" + model = knitro.Model() + x = model.add_variable(lb=0.0, ub=10.0) + + con = model.add_linear_constraint(x, poi.ConstraintSense.LessEqual, 5.0, name="my_con") + + name = model.get_constraint_attribute(con, poi.ConstraintAttribute.Name) + assert name == "my_con" + + model.set_constraint_attribute(con, poi.ConstraintAttribute.Name, "new_con") + name = model.get_constraint_attribute(con, poi.ConstraintAttribute.Name) + assert name == "new_con" + + +def test_constraint_attribute_primal_dual(): + """Test getting constraint primal and dual values after solve.""" + model = knitro.Model() + x = model.add_variable(lb=0.0, ub=10.0) + y = model.add_variable(lb=0.0, ub=10.0) + + model.set_objective(x + y, poi.ObjectiveSense.Minimize) + con = model.add_linear_constraint(x + y, poi.ConstraintSense.GreaterEqual, 5.0) + model.optimize() + + primal = model.get_constraint_attribute(con, poi.ConstraintAttribute.Primal) + assert primal == approx(5.0) + + dual = model.get_constraint_attribute(con, poi.ConstraintAttribute.Dual) + assert isinstance(dual, float)