1use std::borrow::Cow;
2use std::time::Duration;
3
4use oximo_core::{
5 ConstraintId, Expr, ExprNode, IndexKey, IndexedVar, Model, ObjectiveSense, VarId,
6};
7use rustc_hash::FxHashMap;
8
9use crate::status::SolverStatus;
10
11#[derive(Clone, Debug, Default)]
17pub struct SolutionPoint {
18 pub primal: FxHashMap<VarId, f64>,
19 pub objective: Option<f64>,
20}
21
22impl SolutionPoint {
23 pub fn value(&self, id: VarId) -> Option<f64> {
25 self.primal.get(&id).copied()
26 }
27
28 pub fn value_of(&self, expr: Expr<'_>) -> Option<f64> {
33 let arena = expr.arena.borrow();
34 match arena.get(expr.id) {
35 ExprNode::Var(v) => self.primal.get(v).copied(),
36 _ => None,
37 }
38 }
39
40 pub fn value_of_idx<V, K: Into<IndexKey>>(
45 &self,
46 var: &IndexedVar<'_, V>,
47 key: K,
48 ) -> Option<f64> {
49 var.get(key).and_then(|e| self.value_of(e))
50 }
51
52 pub fn values_of<'iv, 'a, V>(
57 &'iv self,
58 var: &'iv IndexedVar<'a, V>,
59 ) -> impl Iterator<Item = (&'iv IndexKey, f64)> + 'iv {
60 var.iter().filter_map(|(k, e)| self.value_of(*e).map(|v| (k, v)))
61 }
62}
63
64#[derive(Clone, Debug)]
71pub struct SolverResult {
72 pub status: SolverStatus,
73 pub solutions: Vec<SolutionPoint>,
74 pub dual: FxHashMap<ConstraintId, f64>,
75 pub reduced_costs: FxHashMap<VarId, f64>,
76 pub solve_time: Duration,
77 pub iterations: u64,
78 pub raw_log: Option<String>,
79 pub solver_name: Option<Cow<'static, str>>,
80}
81
82impl Default for SolverResult {
83 fn default() -> Self {
84 Self {
85 status: SolverStatus::NotSolved,
86 solutions: Vec::new(),
87 dual: FxHashMap::default(),
88 reduced_costs: FxHashMap::default(),
89 solve_time: Duration::ZERO,
90 iterations: 0,
91 raw_log: None,
92 solver_name: None,
93 }
94 }
95}
96
97impl SolverResult {
98 pub fn result_count(&self) -> usize {
101 self.solutions.len()
102 }
103
104 pub fn solution(&self, i: usize) -> Option<&SolutionPoint> {
106 self.solutions.get(i)
107 }
108
109 pub fn best(&self) -> Option<&SolutionPoint> {
111 self.solutions.first()
112 }
113
114 pub fn objective(&self) -> Option<f64> {
116 self.solutions.first().and_then(|s| s.objective)
117 }
118
119 pub fn primal(&self) -> Option<&FxHashMap<VarId, f64>> {
121 self.solutions.first().map(|s| &s.primal)
122 }
123
124 pub fn value(&self, id: VarId) -> Option<f64> {
126 self.solutions.first().and_then(|s| s.value(id))
127 }
128
129 pub fn value_of(&self, expr: Expr<'_>) -> Option<f64> {
133 self.solutions.first().and_then(|s| s.value_of(expr))
134 }
135
136 pub fn dual_of(&self, c: ConstraintId) -> Option<f64> {
137 self.dual.get(&c).copied()
138 }
139
140 pub fn value_of_idx<V, K: Into<IndexKey>>(
143 &self,
144 var: &IndexedVar<'_, V>,
145 key: K,
146 ) -> Option<f64> {
147 var.get(key).and_then(|e| self.value_of(e))
148 }
149
150 pub fn values_of<'iv, 'a, V>(
153 &'iv self,
154 var: &'iv IndexedVar<'a, V>,
155 ) -> impl Iterator<Item = (&'iv IndexKey, f64)> + 'iv {
156 var.iter().filter_map(|(k, e)| self.value_of(*e).map(|v| (k, v)))
157 }
158
159 pub fn report<'a>(&'a self, model: &'a Model) -> ModelReport<'a> {
166 ModelReport { result: self, model }
167 }
168}
169
170#[derive(Debug)]
173pub struct ModelReport<'a> {
174 result: &'a SolverResult,
175 model: &'a Model,
176}
177
178fn num(x: f64) -> String {
181 let s = format!("{x:.6}");
182 let trimmed = s.trim_end_matches('0').trim_end_matches('.');
183 if trimmed.is_empty() || trimmed == "-0" { "0".to_owned() } else { trimmed.to_owned() }
184}
185
186impl std::fmt::Display for ModelReport<'_> {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 let r = self.result;
189 let m = self.model;
190
191 let sense = {
192 let obj = m.objective();
193 match obj.as_ref().map(|o| o.sense) {
194 Some(ObjectiveSense::Minimize) => "minimize",
195 Some(ObjectiveSense::Maximize) => "maximize",
196 None => "no objective",
197 }
198 };
199
200 writeln!(f, "solution summary")?;
201 writeln!(f, " solver : {}", r.solver_name.as_deref().unwrap_or("(unknown)"))?;
202 writeln!(f, " model : {} ({:?}, {sense})", m.name, m.kind())?;
203 writeln!(f, " status : {:?}", r.status)?;
204 writeln!(f, " solutions : {}", r.result_count())?;
205 match r.objective() {
206 Some(v) => writeln!(f, " objective : {}", num(v))?,
207 None => writeln!(f, " objective : (none)")?,
208 }
209 writeln!(f, " solve time : {:?}", r.solve_time)?;
210 writeln!(f, " iterations : {}", r.iterations)?;
211
212 let vars = m.variables();
214 writeln!(f, "\nvariables ({})", vars.len())?;
215 if let Some(best) = r.best() {
216 let width = vars.iter().map(|v| v.name.len()).max().unwrap_or(0);
217 let show_rc = !r.reduced_costs.is_empty();
218 for v in vars.iter() {
219 let val = best.value(v.id).map_or_else(|| "n/a".to_owned(), num);
220 match (show_rc, r.reduced_costs.get(&v.id)) {
221 (true, Some(rc)) => {
222 writeln!(f, " {:<width$} = {val} (reduced cost {})", v.name, num(*rc))?;
223 }
224 _ => writeln!(f, " {:<width$} = {val}", v.name)?,
225 }
226 }
227 } else {
228 writeln!(f, " (no primal solution)")?;
229 }
230
231 if !r.dual.is_empty() {
233 let cons = m.constraints();
234 writeln!(f, "\nconstraints ({})", cons.len())?;
235 let width = cons.iter().map(|c| c.name.len()).max().unwrap_or(0);
236 for (i, c) in cons.iter().enumerate() {
237 let id = ConstraintId(u32::try_from(i).expect("constraint index fits u32"));
238 let d = r.dual_of(id).map_or_else(|| "n/a".to_owned(), num);
239 writeln!(f, " {:<width$} dual = {d}", c.name)?;
240 }
241 }
242
243 Ok(())
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn empty_result_has_no_solution() {
253 let r = SolverResult::default();
254 assert_eq!(r.result_count(), 0);
255 assert!(r.best().is_none());
256 assert!(r.objective().is_none());
257 assert!(r.primal().is_none());
258 assert!(r.value(VarId(0)).is_none());
259 assert!(r.solution(0).is_none());
260 }
261
262 #[test]
263 fn best_is_solution_zero() {
264 let mut p0 = FxHashMap::default();
265 p0.insert(VarId(0), 1.5);
266 let mut p1 = FxHashMap::default();
267 p1.insert(VarId(0), 2.5);
268 let r = SolverResult {
269 status: SolverStatus::Optimal,
270 solutions: vec![
271 SolutionPoint { primal: p0, objective: Some(10.0) },
272 SolutionPoint { primal: p1, objective: Some(9.0) },
273 ],
274 ..Default::default()
275 };
276 assert_eq!(r.result_count(), 2);
277 assert_eq!(r.objective(), Some(10.0));
278 assert_eq!(r.value(VarId(0)), Some(1.5));
279 assert_eq!(r.solution(1).unwrap().value(VarId(0)), Some(2.5));
280 }
281
282 #[test]
283 fn report_renders_sections() {
284 use oximo_core::{constraint, objective, variable};
285
286 let m = Model::new("toy");
287 variable!(m, x >= 0.0);
288 let c = constraint!(m, c, x <= 5.0);
289 objective!(m, Max, x);
290
291 let mut primal = FxHashMap::default();
292 primal.insert(x.var_id().unwrap(), 5.0);
293 let mut dual = FxHashMap::default();
294 dual.insert(c, 1.0);
295
296 let r = SolverResult {
297 status: SolverStatus::Optimal,
298 solutions: vec![SolutionPoint { primal, objective: Some(5.0) }],
299 dual,
300 solver_name: Some("TestSolver".into()),
301 ..Default::default()
302 };
303
304 let out = r.report(&m).to_string();
305 assert!(out.contains("solver : TestSolver"), "{out}");
306 assert!(out.contains("status : Optimal"), "{out}");
307 assert!(out.contains("objective : 5"), "{out}");
308 assert!(out.contains("(LP, maximize)"), "{out}");
309 assert!(out.contains("x = 5"), "{out}");
310 assert!(out.contains("dual = 1"), "{out}");
311 }
312}