From ba7679b650c2337404b156fffb9ade8702564ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Heim?= Date: Wed, 24 Jun 2026 17:05:21 -0300 Subject: [PATCH 1/2] Bump highs-sys --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8761f89..4ccb911 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,5 +9,5 @@ repository = "https://github.com/rust-or/highs" keywords = ["linear-programming", "optimization", "math", "solver"] [dependencies] -highs-sys = "1.12.0" +highs-sys = "1.14.3" log = "0.4.27" From 6a0bea7a1d51b1580da7312646a6377025e209a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Heim?= Date: Wed, 24 Jun 2026 21:05:33 -0300 Subject: [PATCH 2/2] Add support for SC/SI variables --- src/lib.rs | 237 ++++++++++++++++++++++++++++++++++++++++++++-- src/matrix_col.rs | 70 +++++++++++++- src/matrix_row.rs | 42 +++++++- 3 files changed, 338 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 17e557b..e7b71a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -178,13 +178,16 @@ where &mut self, col_factor: f64, bounds: B, - is_integral: bool, + integrality: Integrality, ) { - if is_integral && self.integrality.is_none() { - self.integrality = Some(vec![0; self.num_cols()]); + let raw = integrality.as_raw(); + // Only allocate the integrality vector once a non-continuous column + // appears. A pure-LP problem keeps it `None` and uses `Highs_passLp`. + if raw != VAR_TYPE_CONTINUOUS && self.integrality.is_none() { + self.integrality = Some(vec![VAR_TYPE_CONTINUOUS; self.num_cols()]); } - if let Some(integrality) = &mut self.integrality { - integrality.push(if is_integral { 1 } else { 0 }); + if let Some(existing) = &mut self.integrality { + existing.push(raw); } self.colcost.push(col_factor); let low = bound_value(bounds.start_bound()).unwrap_or(f64::NEG_INFINITY); @@ -265,6 +268,47 @@ pub struct SolvedModel { highs: HighsPtr, } +/// The kind of values a variable (column) may take. +/// +/// For [`SemiContinuous`](Integrality::SemiContinuous) and +/// [`SemiInteger`](Integrality::SemiInteger) variables, the value is either `0` +/// or lies within the column's `[lower, upper]` bounds. +#[derive(Clone, Copy, Eq, PartialEq, Hash, Debug, Default)] +pub enum Integrality { + /// Any real value within the column bounds. + #[default] + Continuous, + /// Any integer value within the column bounds. + Integer, + /// Either `0` or any real value within the column bounds. + SemiContinuous, + /// Either `0` or any integer value within the column bounds. + SemiInteger, +} + +impl Integrality { + fn as_raw(self) -> HighsInt { + match self { + Integrality::Continuous => VAR_TYPE_CONTINUOUS, + Integrality::Integer => VAR_TYPE_INTEGER, + Integrality::SemiContinuous => VAR_TYPE_SEMI_CONTINUOUS, + Integrality::SemiInteger => VAR_TYPE_SEMI_INTEGER, + } + } +} + +impl From for Integrality { + /// `true` maps to [`Integrality::Integer`] and `false` to + /// [`Integrality::Continuous`]. + fn from(is_integer: bool) -> Self { + if is_integer { + Integrality::Integer + } else { + Integrality::Continuous + } + } +} + /// Whether to maximize or minimize the objective function #[repr(C)] #[derive(Clone, Copy, Eq, PartialEq, Debug)] @@ -628,6 +672,27 @@ impl Model { row_factors: impl IntoIterator, is_integer: bool, ) -> Result + where + N: Into + Copy, + B: RangeBounds, + { + self.try_add_column_with_integrality_kind( + col_factor, + bounds, + row_factors, + is_integer.into(), + ) + } + + /// Same as [`Model::try_add_column`] but lets you set the column's + /// [`Integrality`] (continuous, integer, semicontinuous, or semi-integer). + pub fn try_add_column_with_integrality_kind( + &mut self, + col_factor: f64, + bounds: B, + row_factors: impl IntoIterator, + integrality: Integrality, + ) -> Result where N: Into + Copy, B: RangeBounds, @@ -644,12 +709,13 @@ impl Model { factors.as_ptr() ))?; } - if is_integer { + let raw = integrality.as_raw(); + if raw != VAR_TYPE_CONTINUOUS { unsafe { highs_call!(Highs_changeColIntegrality( self.highs.mut_ptr(), (self.highs.num_cols()? - 1).try_into().unwrap(), - is_integer.into() + raw ))?; } } @@ -657,6 +723,84 @@ impl Model { Ok(Col(self.highs.num_cols()? - 1)) } + /// Same as [`Model::add_column`] but lets you set the column's + /// [`Integrality`] (continuous, integer, semicontinuous, or semi-integer). + /// + /// # Panics + /// + /// If HiGHS returns an error status value. + #[inline] + pub fn add_column_with_integrality_kind + Copy, B: RangeBounds>( + &mut self, + col_factor: f64, + bounds: B, + row_factors: impl IntoIterator, + integrality: Integrality, + ) -> Col { + self.try_add_column_with_integrality_kind(col_factor, bounds, row_factors, integrality) + .unwrap_or_else(|e| panic!("HiGHS error: {e:?}")) + } + + /// Add a semicontinuous variable: its value is `0` or within `bounds`. + /// + /// # Panics + /// + /// If HiGHS returns an error status value. + pub fn add_semi_continuous_column + Copy, B: RangeBounds>( + &mut self, + col_factor: f64, + bounds: B, + row_factors: impl IntoIterator, + ) -> Col { + self.try_add_semi_continuous_column(col_factor, bounds, row_factors) + .unwrap_or_else(|e| panic!("HiGHS error: {e:?}")) + } + + /// Add a semicontinuous variable: its value is `0` or within `bounds`. + pub fn try_add_semi_continuous_column + Copy, B: RangeBounds>( + &mut self, + col_factor: f64, + bounds: B, + row_factors: impl IntoIterator, + ) -> Result { + self.try_add_column_with_integrality_kind( + col_factor, + bounds, + row_factors, + Integrality::SemiContinuous, + ) + } + + /// Add a semi-integer variable: its value is `0` or an integer within `bounds`. + /// + /// # Panics + /// + /// If HiGHS returns an error status value. + pub fn add_semi_integer_column + Copy, B: RangeBounds>( + &mut self, + col_factor: f64, + bounds: B, + row_factors: impl IntoIterator, + ) -> Col { + self.try_add_semi_integer_column(col_factor, bounds, row_factors) + .unwrap_or_else(|e| panic!("HiGHS error: {e:?}")) + } + + /// Add a semi-integer variable: its value is `0` or an integer within `bounds`. + pub fn try_add_semi_integer_column + Copy, B: RangeBounds>( + &mut self, + col_factor: f64, + bounds: B, + row_factors: impl IntoIterator, + ) -> Result { + self.try_add_column_with_integrality_kind( + col_factor, + bounds, + row_factors, + Integrality::SemiInteger, + ) + } + /// Updates the cost of a column pub fn change_column_cost(&mut self, col: Col, cost: f64) { unsafe { @@ -1397,4 +1541,83 @@ mod test { assert!((cols[0] - 0.5).abs() < 1e-6, "x = {}", cols[0]); assert!((cols[1] - 0.5).abs() < 1e-6, "y = {}", cols[1]); } + + #[test] + fn test_semi_continuous_column() { + use crate::status::HighsModelStatus::Optimal; + // min x s.t. x >= 3, x in {0} U [5, 10]. + let mut pb = RowProblem::new(); + let x = pb.add_semi_continuous_column(1., 5.0..=10.0); + pb.add_row(3.., [(x, 1.)]); + let solved = pb.optimise(Sense::Minimise).solve(); + assert_eq!(solved.status(), Optimal); + let cols = solved.get_solution().columns().to_vec(); + assert!((cols[0] - 5.0).abs() < 1e-6, "x = {}", cols[0]); + } + + #[test] + fn test_semi_continuous_column_off() { + use crate::status::HighsModelStatus::Optimal; + // min x with x in {0} U [5, 10] and nothing forcing it on => x = 0. + let mut pb = RowProblem::new(); + let _x = pb.add_semi_continuous_column(1., 5.0..=10.0); + let solved = pb.optimise(Sense::Minimise).solve(); + assert_eq!(solved.status(), Optimal); + assert!( + solved.objective_value().abs() < 1e-9, + "obj = {}", + solved.objective_value() + ); + } + + #[test] + fn test_semi_integer_column() { + use crate::status::HighsModelStatus::Optimal; + // max x s.t. x <= 7.5, x in {0} U {5, 6, ..., 10}. + let mut pb = RowProblem::new(); + let x = pb.add_semi_integer_column(1., 5.0..=10.0); + pb.add_row(..=7.5, [(x, 1.)]); + let solved = pb.optimise(Sense::Maximise).solve(); + assert_eq!(solved.status(), Optimal); + let cols = solved.get_solution().columns().to_vec(); + assert!((cols[0] - 7.0).abs() < 1e-6, "x = {}", cols[0]); + } + + #[test] + fn test_semi_continuous_col_problem() { + use crate::status::HighsModelStatus::Optimal; + let mut pb = ColProblem::new(); + let c = pb.add_row(3..); // x >= 3 + pb.add_semi_continuous_column(1., 5.0..=10.0, [(c, 1.)]); + let solved = pb.optimise(Sense::Minimise).solve(); + assert_eq!(solved.status(), Optimal); + let cols = solved.get_solution().columns().to_vec(); + assert!((cols[0] - 5.0).abs() < 1e-6, "x = {}", cols[0]); + } + + #[test] + fn test_semi_integer_col_incremental() { + use crate::status::HighsModelStatus::Optimal; + let mut model = ColProblem::new().optimise(Sense::Maximise); + let x = model.add_semi_integer_column(1., 5.0..=10.0, vec![]); + model.add_row(..=7.5, [(x, 1.)]); + let solved = model.solve(); + assert_eq!(solved.status(), Optimal); + let cols = solved.get_solution().columns().to_vec(); + assert!((cols[0] - 7.0).abs() < 1e-6, "x = {}", cols[0]); + } + + #[test] + fn test_try_add_semi_continuous_column() { + use crate::status::HighsModelStatus::Optimal; + let mut model = ColProblem::new().optimise(Sense::Minimise); + let x = model + .try_add_semi_continuous_column(1., 5.0..=10.0, vec![]) + .expect("add semi-continuous column"); + model.add_row(3.., [(x, 1.)]); + let solved = model.solve(); + assert_eq!(solved.status(), Optimal); + let cols = solved.get_solution().columns().to_vec(); + assert!((cols[0] - 5.0).abs() < 1e-6, "x = {}", cols[0]); + } } diff --git a/src/matrix_col.rs b/src/matrix_col.rs index e3551e6..268038f 100644 --- a/src/matrix_col.rs +++ b/src/matrix_col.rs @@ -4,7 +4,7 @@ use std::convert::TryInto; use std::ops::RangeBounds; use std::os::raw::c_int; -use crate::Problem; +use crate::{Integrality, Problem}; /// Represents a constraint #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -92,6 +92,24 @@ impl Problem { bounds: B, row_factors: I, is_integer: bool, + ) { + self.add_column_with_integrality_kind(col_factor, bounds, row_factors, is_integer.into()); + } + + /// Same as add_column, but lets you set the variable's [`Integrality`] + /// (continuous, integer, semicontinuous, or semi-integer). + #[inline] + pub fn add_column_with_integrality_kind< + N: Into + Copy, + B: RangeBounds, + ITEM: Borrow<(Row, f64)>, + I: IntoIterator, + >( + &mut self, + col_factor: f64, + bounds: B, + row_factors: I, + integrality: Integrality, ) { self.matrix .astart @@ -105,6 +123,54 @@ impl Problem { self.matrix.aindex.push(row.0); self.matrix.avalue.push(factor); } - self.add_column_inner(col_factor, bounds, is_integer); + self.add_column_inner(col_factor, bounds, integrality); + } + + /// Add a semicontinuous variable: its value is `0` or within `bounds`. + /// + /// The lower bound is the threshold below which (other than `0`) the + /// variable may not lie. A finite upper bound is recommended by HiGHS, + /// though an unbounded upper bound is also accepted. + pub fn add_semi_continuous_column< + N: Into + Copy, + B: RangeBounds, + ITEM: Borrow<(Row, f64)>, + I: IntoIterator, + >( + &mut self, + col_factor: f64, + bounds: B, + row_factors: I, + ) { + self.add_column_with_integrality_kind( + col_factor, + bounds, + row_factors, + Integrality::SemiContinuous, + ); + } + + /// Add a semi-integer variable: its value is `0` or an integer within `bounds`. + /// + /// The lower bound is the threshold below which (other than `0`) the + /// variable may not lie. A finite upper bound is recommended by HiGHS, + /// though an unbounded upper bound is also accepted. + pub fn add_semi_integer_column< + N: Into + Copy, + B: RangeBounds, + ITEM: Borrow<(Row, f64)>, + I: IntoIterator, + >( + &mut self, + col_factor: f64, + bounds: B, + row_factors: I, + ) { + self.add_column_with_integrality_kind( + col_factor, + bounds, + row_factors, + Integrality::SemiInteger, + ); } } diff --git a/src/matrix_row.rs b/src/matrix_row.rs index 8a22f55..fd21c4b 100644 --- a/src/matrix_row.rs +++ b/src/matrix_row.rs @@ -5,7 +5,7 @@ use std::ops::RangeBounds; use std::os::raw::c_int; use crate::matrix_col::ColMatrix; -use crate::Problem; +use crate::{Integrality, Problem}; /// Represents a variable #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -55,13 +55,51 @@ impl Problem { col_factor: f64, bounds: B, is_integer: bool, + ) -> Col { + self.add_column_with_integrality_kind(col_factor, bounds, is_integer.into()) + } + + /// Same as add_column, but lets you set the variable's [`Integrality`] + /// (continuous, integer, semicontinuous, or semi-integer). + #[inline] + pub fn add_column_with_integrality_kind + Copy, B: RangeBounds>( + &mut self, + col_factor: f64, + bounds: B, + integrality: Integrality, ) -> Col { let col = Col(self.num_cols()); - self.add_column_inner(col_factor, bounds, is_integer); + self.add_column_inner(col_factor, bounds, integrality); self.matrix.columns.push((vec![], vec![])); col } + /// Add a semicontinuous variable: its value is `0` or within `bounds`. + /// + /// The lower bound is the threshold below which (other than `0`) the + /// variable may not lie. A finite upper bound is recommended by HiGHS, + /// though an unbounded upper bound is also accepted. + pub fn add_semi_continuous_column + Copy, B: RangeBounds>( + &mut self, + col_factor: f64, + bounds: B, + ) -> Col { + self.add_column_with_integrality_kind(col_factor, bounds, Integrality::SemiContinuous) + } + + /// Add a semi-integer variable: its value is `0` or an integer within `bounds`. + /// + /// The lower bound is the threshold below which (other than `0`) the + /// variable may not lie. A finite upper bound is recommended by HiGHS, + /// though an unbounded upper bound is also accepted. + pub fn add_semi_integer_column + Copy, B: RangeBounds>( + &mut self, + col_factor: f64, + bounds: B, + ) -> Col { + self.add_column_with_integrality_kind(col_factor, bounds, Integrality::SemiInteger) + } + /// Add a constraint to the problem. /// - `bounds` are the maximal and minimal allowed values for the linear expression in the constraint /// - `row_factors` are the coefficients in the linear expression expressing the constraint