use std::cmp::Ordering;
use std::fmt;
use indexmap::map::Values;
use lp_solvers::lp_format::{AsVariable, LpObjective, WriteToLpFileFormat};
use crate::NUMERIC_EPSILON;
use crate::interner::{NameId, NameInterner};
use crate::model::{Coefficient, ComparisonOp, Constraint, Objective, Sense, Variable, VariableType};
use crate::problem::LpProblem;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LpSolversCompatError {
#[error("multiple objectives found ({count}); lp-solvers only supports single objective")]
MultipleObjectives {
count: usize,
},
#[error("no objectives found; at least one objective is required")]
NoObjectives,
#[error("strict inequality '{operator}' in constraint '{constraint}' is not supported by lp-solvers")]
StrictInequalityNotSupported {
constraint: String,
operator: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LpSolversCompatWarning {
SosConstraintIgnored {
name: String,
},
SemiContinuousApproximated {
name: String,
},
}
impl fmt::Display for LpSolversCompatWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SosConstraintIgnored { name } => {
write!(f, "SOS constraint '{name}' will be ignored; lp-solvers does not support SOS constraints")
}
Self::SemiContinuousApproximated { name } => {
write!(f, "semi-continuous variable '{name}' is not directly supported; treating as continuous")
}
}
}
}
#[derive(Debug, Clone)]
pub struct VariableAdapter<'a> {
name: &'a str,
var_type: &'a VariableType,
}
impl AsVariable for VariableAdapter<'_> {
fn name(&self) -> &str {
self.name
}
fn is_integer(&self) -> bool {
matches!(self.var_type, VariableType::Binary | VariableType::Integer | VariableType::General)
}
fn lower_bound(&self) -> f64 {
match self.var_type {
VariableType::Free => f64::NEG_INFINITY,
VariableType::General
| VariableType::Binary
| VariableType::Integer
| VariableType::UpperBound(_)
| VariableType::SemiContinuous
| VariableType::SOS => 0.0,
VariableType::LowerBound(lb) | VariableType::DoubleBound(lb, _) => *lb,
}
}
fn upper_bound(&self) -> f64 {
match self.var_type {
VariableType::Free
| VariableType::General
| VariableType::Integer
| VariableType::LowerBound(_)
| VariableType::SemiContinuous
| VariableType::SOS => f64::INFINITY,
VariableType::Binary => 1.0,
VariableType::UpperBound(ub) | VariableType::DoubleBound(_, ub) => *ub,
}
}
}
#[derive(Debug, Clone)]
pub struct ExpressionAdapter<'a> {
coefficients: &'a [Coefficient],
interner: &'a NameInterner,
}
impl WriteToLpFileFormat for ExpressionAdapter<'_> {
fn to_lp_file_format(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let non_zero: Vec<_> = self.coefficients.iter().filter(|c| c.value.is_finite() && c.value.abs() > NUMERIC_EPSILON).collect();
if non_zero.is_empty() {
return write!(f, "0");
}
for (i, coeff) in non_zero.iter().enumerate() {
let value = coeff.value;
let name = self.interner.resolve(coeff.name);
if i == 0 {
if value < 0.0 {
if (value.abs() - 1.0).abs() < NUMERIC_EPSILON {
write!(f, "- {name}")?;
} else {
write!(f, "- {} {name}", value.abs())?;
}
} else if (value - 1.0).abs() < NUMERIC_EPSILON {
write!(f, "{name}")?;
} else {
write!(f, "{value} {name}")?;
}
} else {
let sign = if value < 0.0 { "-" } else { "+" };
let abs_val = value.abs();
if (abs_val - 1.0).abs() < NUMERIC_EPSILON {
write!(f, " {sign} {name}")?;
} else {
write!(f, " {sign} {abs_val} {name}")?;
}
}
}
Ok(())
}
}
pub struct VariableIterator<'a> {
inner: Values<'a, NameId, Variable>,
interner: &'a NameInterner,
}
impl<'a> Iterator for VariableIterator<'a> {
type Item = VariableAdapter<'a>;
fn next(&mut self) -> Option<Self::Item> {
self.inner.next().map(|v| VariableAdapter { name: self.interner.resolve(v.name), var_type: &v.var_type })
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
impl ExactSizeIterator for VariableIterator<'_> {}
pub struct ConstraintIterator<'a> {
inner: Values<'a, NameId, Constraint>,
interner: &'a NameInterner,
}
impl<'a> Iterator for ConstraintIterator<'a> {
type Item = lp_solvers::lp_format::Constraint<ExpressionAdapter<'a>>;
fn next(&mut self) -> Option<Self::Item> {
loop {
match self.inner.next() {
Some(Constraint::Standard { coefficients, operator, rhs, .. }) => {
let ordering = match operator {
ComparisonOp::LTE => Ordering::Less,
ComparisonOp::EQ => Ordering::Equal,
ComparisonOp::GTE => Ordering::Greater,
ComparisonOp::LT | ComparisonOp::GT => {
unreachable!("strict inequalities should be caught during validation")
}
};
return Some(lp_solvers::lp_format::Constraint {
lhs: ExpressionAdapter { coefficients, interner: self.interner },
operator: ordering,
rhs: *rhs,
});
}
Some(Constraint::SOS { .. }) => {} None => return None,
}
}
}
}
#[derive(Debug)]
pub struct LpSolversCompat<'a> {
problem: &'a LpProblem,
objective: &'a Objective,
warnings: Vec<LpSolversCompatWarning>,
}
impl<'a> LpSolversCompat<'a> {
pub fn try_new(problem: &'a LpProblem) -> Result<Self, LpSolversCompatError> {
if problem.objectives.is_empty() {
return Err(LpSolversCompatError::NoObjectives);
}
if problem.objectives.len() > 1 {
return Err(LpSolversCompatError::MultipleObjectives { count: problem.objectives.len() });
}
let objective = problem.objectives.values().next().expect("objective must exist: length check passed");
let mut warnings = Vec::new();
for constraint in problem.constraints.values() {
match constraint {
Constraint::Standard { name, operator, .. } => {
if matches!(operator, ComparisonOp::LT | ComparisonOp::GT) {
return Err(LpSolversCompatError::StrictInequalityNotSupported {
constraint: problem.interner.resolve(*name).to_string(),
operator: operator.to_string(),
});
}
}
Constraint::SOS { name, .. } => {
warnings.push(LpSolversCompatWarning::SosConstraintIgnored { name: problem.interner.resolve(*name).to_string() });
}
}
}
for variable in problem.variables.values() {
if matches!(variable.var_type, VariableType::SemiContinuous) {
warnings
.push(LpSolversCompatWarning::SemiContinuousApproximated { name: problem.interner.resolve(variable.name).to_string() });
}
}
Ok(Self { problem, objective, warnings })
}
#[must_use]
pub fn warnings(&self) -> &[LpSolversCompatWarning] {
&self.warnings
}
#[must_use]
pub fn is_fully_compatible(&self) -> bool {
self.warnings.is_empty()
}
}
impl<'a> lp_solvers::lp_format::LpProblem<'a> for LpSolversCompat<'a> {
type Variable = VariableAdapter<'a>;
type Expression = ExpressionAdapter<'a>;
type ConstraintIterator = ConstraintIterator<'a>;
type VariableIterator = VariableIterator<'a>;
fn variables(&'a self) -> Self::VariableIterator {
VariableIterator { inner: self.problem.variables.values(), interner: &self.problem.interner }
}
fn objective(&'a self) -> Self::Expression {
ExpressionAdapter { coefficients: &self.objective.coefficients, interner: &self.problem.interner }
}
fn sense(&'a self) -> LpObjective {
match self.problem.sense {
Sense::Minimize => LpObjective::Minimize,
Sense::Maximize => LpObjective::Maximize,
}
}
fn constraints(&'a self) -> Self::ConstraintIterator {
ConstraintIterator { inner: self.problem.constraints.values(), interner: &self.problem.interner }
}
fn name(&self) -> &str {
self.problem.name.as_deref().unwrap_or("lp_parser_problem")
}
}
pub trait ToLpSolvers {
fn to_lp_solvers(&self) -> Result<LpSolversCompat<'_>, LpSolversCompatError>;
}
impl ToLpSolvers for LpProblem {
fn to_lp_solvers(&self) -> Result<LpSolversCompat<'_>, LpSolversCompatError> {
LpSolversCompat::try_new(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{SOSType, Variable};
fn simple_problem() -> LpProblem {
let mut p = LpProblem::new().with_sense(Sense::Minimize);
let obj_id = p.intern("obj");
let x_id = p.intern("x");
let c1_id = p.intern("c1");
p.objectives
.insert(obj_id, Objective { name: obj_id, coefficients: vec![Coefficient { name: x_id, value: 2.0 }], byte_offset: None });
p.constraints.insert(
c1_id,
Constraint::Standard {
name: c1_id,
coefficients: vec![Coefficient { name: x_id, value: 1.0 }],
operator: ComparisonOp::LTE,
rhs: 10.0,
byte_offset: None,
},
);
p.variables.insert(x_id, Variable::new(x_id).with_var_type(VariableType::General));
p
}
fn adapter(var_type: VariableType) -> VariableAdapter<'static> {
VariableAdapter { name: "x", var_type: Box::leak(Box::new(var_type)) }
}
fn expr_fmt(problem: &LpProblem, coeffs: &[Coefficient]) -> String {
struct D<'a>(ExpressionAdapter<'a>);
impl fmt::Display for D<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.to_lp_file_format(f)
}
}
format!("{}", D(ExpressionAdapter { coefficients: coeffs, interner: &problem.interner }))
}
#[test]
fn test_validation_errors() {
assert!(matches!(LpSolversCompat::try_new(&LpProblem::new()), Err(LpSolversCompatError::NoObjectives)));
let mut p = simple_problem();
let obj2_id = p.intern("obj2");
p.objectives.insert(obj2_id, Objective { name: obj2_id, coefficients: vec![], byte_offset: None });
assert!(matches!(LpSolversCompat::try_new(&p), Err(LpSolversCompatError::MultipleObjectives { count: 2 })));
for op in [ComparisonOp::LT, ComparisonOp::GT] {
let mut p = simple_problem();
let c2_id = p.intern("c2");
p.constraints
.insert(c2_id, Constraint::Standard { name: c2_id, coefficients: vec![], operator: op, rhs: 0.0, byte_offset: None });
assert!(matches!(LpSolversCompat::try_new(&p), Err(LpSolversCompatError::StrictInequalityNotSupported { .. })));
}
}
#[test]
fn test_warnings() {
let mut p = simple_problem();
let sos1_id = p.intern("sos1");
p.constraints.insert(sos1_id, Constraint::SOS { name: sos1_id, sos_type: SOSType::S1, weights: vec![], byte_offset: None });
let c = LpSolversCompat::try_new(&p).unwrap();
assert!(!c.is_fully_compatible());
assert!(matches!(&c.warnings()[0], LpSolversCompatWarning::SosConstraintIgnored { .. }));
let mut p = simple_problem();
let y_id = p.intern("y");
p.variables.insert(y_id, Variable::new(y_id).with_var_type(VariableType::SemiContinuous));
let c = LpSolversCompat::try_new(&p).unwrap();
assert!(matches!(&c.warnings()[0], LpSolversCompatWarning::SemiContinuousApproximated { .. }));
}
#[test]
fn test_variable_bounds() {
let cases: &[(VariableType, f64, f64, bool)] = &[
(VariableType::Free, f64::NEG_INFINITY, f64::INFINITY, false),
(VariableType::General, 0.0, f64::INFINITY, true),
(VariableType::Binary, 0.0, 1.0, true),
(VariableType::Integer, 0.0, f64::INFINITY, true),
(VariableType::LowerBound(5.0), 5.0, f64::INFINITY, false),
(VariableType::UpperBound(10.0), 0.0, 10.0, false),
(VariableType::DoubleBound(-10.0, 10.0), -10.0, 10.0, false),
];
for (vt, lb, ub, is_int) in cases {
let a = adapter(vt.clone());
assert!(
(a.lower_bound() - *lb).abs() < f64::EPSILON
|| (a.lower_bound().is_infinite() && lb.is_infinite() && a.lower_bound().signum() == lb.signum()),
"lower_bound for {vt:?}"
);
assert!(
(a.upper_bound() - *ub).abs() < f64::EPSILON
|| (a.upper_bound().is_infinite() && ub.is_infinite() && a.upper_bound().signum() == ub.signum()),
"upper_bound for {vt:?}"
);
assert_eq!(a.is_integer(), *is_int, "is_integer for {vt:?}");
}
}
#[test]
fn test_problem_sense_and_name() {
let p = simple_problem();
let c = p.to_lp_solvers().unwrap();
assert!(matches!(lp_solvers::lp_format::LpProblem::sense(&c), LpObjective::Minimize));
assert_eq!(lp_solvers::lp_format::LpProblem::name(&c), "lp_parser_problem");
let mut p = simple_problem();
p.sense = Sense::Maximize;
p.name = Some("test".to_string());
let c = p.to_lp_solvers().unwrap();
assert!(matches!(lp_solvers::lp_format::LpProblem::sense(&c), LpObjective::Maximize));
assert_eq!(lp_solvers::lp_format::LpProblem::name(&c), "test");
}
#[test]
#[allow(clippy::many_single_char_names)]
fn test_expression_formatting() {
let mut p = LpProblem::new();
let x = p.intern("x");
let y = p.intern("y");
let z = p.intern("z");
let c = |name: NameId, v: f64| Coefficient { name, value: v };
assert_eq!(expr_fmt(&p, &[]), "0");
assert_eq!(expr_fmt(&p, &[c(x, 0.0), c(y, 0.0)]), "0");
assert_eq!(expr_fmt(&p, &[c(x, f64::NAN), c(y, 2.0)]), "2 y");
assert_eq!(expr_fmt(&p, &[c(x, f64::INFINITY)]), "0");
assert_eq!(expr_fmt(&p, &[c(x, 1.0)]), "x");
assert_eq!(expr_fmt(&p, &[c(x, -1.0)]), "- x");
assert_eq!(expr_fmt(&p, &[c(x, 2.0), c(y, -3.0), c(z, 1.0)]), "2 x - 3 y + z");
assert_eq!(expr_fmt(&p, &[c(x, 0.0), c(y, 2.0)]), "2 y");
}
}