use super::lp::Outcome; use super::model::{Constraint, Goal, Method, Model, Rel, Rhs, parse_model, saved_model}; use super::problem::{Evaluator, Problem, as_num, build_problem}; use super::{ Cell, CellRef, Ctx, DEFAULT_EVALS, DocState, EngineError, Json, MAX_EVALS, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad, edit, json, optimise, str_param, }; // ---------------------------------------------------------------- reports #[derive(Clone, Copy, Debug, PartialEq)] pub(super) enum ReportKind { Answer, Limits, } impl ReportKind { fn title(self) -> &'static str { match self { ReportKind::Answer => "Answer", ReportKind::Limits => "Limits", } } } /// The `reports` list (`None` when absent). pub(super) fn report_kinds(cmd: &'static str, p: &Json) -> Result>> { let Some(list) = p.get("reports") else { return Ok(None) }; let list = list.as_array().ok_or_else(|| bad(cmd, "`reports` must be an array of answer and limits"))?; let mut kinds = Vec::new(); for k in list { let kind = match k.as_str().map(|t| t.trim().to_ascii_lowercase()).as_deref() { Some("answer") => ReportKind::Answer, Some("limits") => ReportKind::Limits, _ => return Err(bad(cmd, format!("unknown report {k} (answer or limits)"))), }; if !kinds.contains(&kind) { kinds.push(kind); } } Ok(Some(kinds)) } /// A report's cells: (row, column, value, bold). type Lines = Vec<(u32, u32, Value, bool)>; fn abs_ref(c: CellRef) -> String { format!("${}${}", gridcraft_core::col_to_letters(c.col), u64::from(c.row) + 1) } /// A cell's name in the reports: the nearest text to its left and the nearest text above it. fn cell_name(sh: &Sheet, c: CellRef) -> String { let text = |c: CellRef| match sh.value(c) { Value::Text(t) if !t.trim().is_empty() => Some(t.to_string()), _ => None, }; let left = (0..c.col).rev().find_map(|col| text(CellRef::new(c.row, col))); let top = (0..c.row).rev().find_map(|row| text(CellRef::new(row, c.col))); [left, top].into_iter().flatten().collect::>().join(" ") } fn method_name(m: Method) -> &'static str { match m { Method::Simplex => "Simplex LP", Method::Grg => "GRG Nonlinear", Method::Evolutionary => "Evolutionary", } } /// Header labels in bold from column 1, then the rows below them; returns the next free row. fn table(out: &mut Lines, row: u32, headers: &[&str], rows: Vec>) -> u32 { for (k, h) in headers.iter().enumerate() { out.push((row, 1 + k as u32, Value::text(*h), true)); } let mut r = row; for cells in rows { r = r.saturating_add(1); for (k, v) in cells.into_iter().enumerate() { out.push((r, 1 + k as u32, v, false)); } } r.saturating_add(2) } /// Everything a report describes: the model, its starting and final values, and the result. pub(super) struct ReportInput<'a> { pub(super) model: &'a Model, pub(super) prob: &'a Problem, pub(super) original: Vec, pub(super) solution: Vec, pub(super) message: String, pub(super) budget: u64, } fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 { out.push((0, 0, Value::text(format!("GridCraft {} Report", kind.title())), true)); out.push((1, 0, Value::text(format!("Worksheet: {sheet}")), false)); 3 } fn format_constraint_row(sh: &Sheet, c: &Constraint, cell: CellRef, v: f64, rhs: &[f64], k: usize) -> Vec { let (formula, status, slack) = match (c.rel, &c.rhs) { (Rel::Int, _) => (format!("{}=Integer", abs_ref(cell)), "Binding", 0.0), (Rel::Bin, _) => (format!("{}=Binary", abs_ref(cell)), "Binding", 0.0), (rel, rhs_cells) => { let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN); let rhs_text = match rhs_cells { Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(), _ => gridcraft_core::number_to_text(b), }; let slack = (v - b).abs(); let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs()); let slack = if binding { 0.0 } else { slack }; (format!("{}{}{rhs_text}", abs_ref(cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack) } }; let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }; vec![Value::text(abs_ref(cell)), Value::text(cell_name(sh, cell)), num(v), Value::text(formula), Value::text(status), num(slack)] } fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines { let m = inp.model; let mut trial = Trial::new(d); let read = |trial: &mut Trial, x: &[f64], cells: &[CellRef]| -> Vec { let sets: Vec<(usize, CellRef, Value)> = m.vars.iter().zip(x).map(|(c, v)| (m.sheet, *c, Value::number(*v))).collect(); let reads: Vec<(usize, CellRef)> = cells.iter().map(|c| (m.sheet, *c)).collect(); trial.run(&sets, &reads).iter().map(as_num).collect() }; let f0 = read(&mut trial, &inp.original, &[m.objective]).first().copied().unwrap_or(f64::NAN); // Every cell the constraints read, at the solution. let mut cells = vec![m.objective]; for c in &m.cons { cells.extend(&c.lhs); if let Rhs::Cells(r) = &c.rhs { cells.extend(r); } } let vals = read(&mut trial, &inp.solution, &cells); let mut at = vals.iter().copied(); let f1 = at.next().unwrap_or(f64::NAN); let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }; let mut out = Lines::new(); let mut r = title(&mut out, ReportKind::Answer, &sh.name); out.push((r, 0, Value::text(format!("Result: {}", inp.message)), false)); out.push((r + 1, 0, Value::text(format!("Engine: {}", method_name(m.method))), false)); r += 3; let goal = match m.goal { Goal::Max => "Max".to_string(), Goal::Min => "Min".to_string(), Goal::Value(v) => format!("Value Of {v}"), }; out.push((r, 0, Value::text(format!("Objective Cell ({goal})")), true)); let obj = vec![vec![Value::text(abs_ref(m.objective)), Value::text(cell_name(sh, m.objective)), num(f0), num(f1)]]; r = table(&mut out, r + 1, &["Cell", "Name", "Original Value", "Final Value"], obj); out.push((r, 0, Value::text("Variable Cells"), true)); let kind = |j: usize| { let bin = m.cons.iter().any(|c| c.rel == Rel::Bin && m.vars.get(j).is_some_and(|v| c.lhs.contains(v))); match (bin, inp.prob.integer.get(j).copied().unwrap_or(false)) { (true, _) => "Binary", (false, true) => "Integer", _ => "Contin", } }; let vars = m .vars .iter() .enumerate() .map(|(j, c)| { let (a, b) = (inp.original.get(j).copied().unwrap_or(f64::NAN), inp.solution.get(j).copied().unwrap_or(f64::NAN)); vec![Value::text(abs_ref(*c)), Value::text(cell_name(sh, *c)), num(a), num(b), Value::text(kind(j))] }) .collect(); r = table(&mut out, r + 1, &["Cell", "Name", "Original Value", "Final Value", "Integer"], vars); out.push((r, 0, Value::text("Constraints"), true)); let mut rows = Vec::new(); for c in &m.cons { let lhs: Vec = c.lhs.iter().map(|_| at.next().unwrap_or(f64::NAN)).collect(); let rhs: Vec = match &c.rhs { Rhs::Cells(cells) => cells.iter().map(|_| at.next().unwrap_or(f64::NAN)).collect(), Rhs::Const(v) => vec![*v], Rhs::None => vec![], }; for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() { rows.push(format_constraint_row(sh, c, *cell, *v, &rhs, k)); } } table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows); out } fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result { let m = inp.model; if inp.prob.integer.iter().any(|b| *b) { return Err(EngineError::Other("The Limits report doesn't apply to models with integer or binary constraints.".into())); } if m.method == Method::Evolutionary { return Err(EngineError::Other("The Limits report is available for Simplex LP and GRG Nonlinear.".into())); } let n = m.vars.len(); let x = &inp.solution; // Each variable is minimised and maximised with the others held at the solution. let per = (inp.budget / (2 * n as u64).max(1)).max(n as u64 + 3); let mut ev = Evaluator { trial: Trial::new(d), sheet: m.sheet, vars: m.vars.clone(), objective: m.objective, rows: inp.prob.rows.clone(), evals: 0, budget: 0, target: None, }; let objective_at = |ev: &mut Evaluator, x: &[f64]| { ev.target = None; ev.budget = ev.evals.saturating_add(1); ev.eval(x).map(|p| p.f).unwrap_or(f64::NAN) }; let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }; let f = objective_at(&mut ev, x); let mut rows = Vec::new(); for (j, c) in m.vars.iter().enumerate() { let (mut lo, mut hi) = (x.clone(), x.clone()); if let (Some(l), Some(h), Some(pl), Some(ph)) = (lo.get_mut(j), hi.get_mut(j), inp.prob.lo.get(j), inp.prob.hi.get(j)) { *l = *pl; *h = *ph; } let mut ends = Vec::new(); for goal in [Goal::Min, Goal::Max] { ev.target = Some(j); ev.evals = 0; ev.budget = per; let solve = optimise(&mut ev, m, goal, x, super::Bounds { lo: &lo, hi: &hi, integer: &inp.prob.integer }, 0.0); ends.push(match solve.outcome { Outcome::Solved(p, _, _) => { let v = p.get(j).copied().unwrap_or(f64::NAN); (num(v), num(objective_at(&mut ev, &p))) } Outcome::Unbounded => (Value::text("Unbounded"), Value::Empty), _ => (Value::Error(gridcraft_core::CellError::NA), Value::Empty), }); } let mut row = vec![Value::text(abs_ref(*c)), Value::text(cell_name(sh, *c)), num(x.get(j).copied().unwrap_or(f64::NAN))]; for (v, o) in ends { row.push(v); row.push(o); } rows.push(row); } let mut out = Lines::new(); let r = title(&mut out, ReportKind::Limits, &sh.name); let obj = vec![vec![Value::text(abs_ref(m.objective)), Value::text(cell_name(sh, m.objective)), num(f)]]; let r = table(&mut out, r, &["Objective Cell", "Name", "Value"], obj); table(&mut out, r, &["Variable Cell", "Name", "Value", "Lower Limit", "Objective Result", "Upper Limit", "Objective Result"], rows); Ok(out) } /// Builds every report before the solution or saved model can change. pub(super) fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result> { if kinds.is_empty() { return Ok(Vec::new()); } if d.wb.protected_structure { return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); } let sh = d.wb.sheet(inp.model.sheet).ok_or(EngineError::NoDocument)?; let mut built = Vec::new(); for k in kinds { let lines = match k { ReportKind::Answer => answer_report(d, sh, inp), ReportKind::Limits => limits_report(d, sh, inp)?, }; let name = (1..=10_000u32) .map(|i| format!("{} Report {i}", k.title())) .find(|n| d.wb.sheet_index(n).is_none() && !built.iter().any(|(b, _): &(String, Lines)| b == n)) .ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?; built.push((name, lines)); } Ok(built) } /// Inserts prepared reports before the model's sheet, which stays active. pub(super) fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec { let names: Vec = built.iter().map(|(n, _)| n.clone()).collect(); let count = built.len(); if count == 0 { return names; } let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true); for (i, (name, lines)) in built.into_iter().enumerate() { let mut sheet = Sheet::new(name); for (r, c, v, b) in lines { let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT }; sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style }); } for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] { sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() }); } cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet)); } for nm in cx.wb.names.iter_mut() { if let Some(sc) = nm.scope.as_mut() && *sc >= at { *sc += count; } } if cx.wb.active_sheet() >= at { cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_add(count)); } cx.structural = true; names } pub(super) fn insert_report_selections(s: &mut Session, at: usize, count: usize) { if let Ok(d) = s.doc_mut() { for i in 0..count { d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default()); } } } /// `data.solverReport`: reports for a solution of the sheet's saved model. pub(super) fn report(s: &mut Session, p: &Json) -> Result { let kinds = report_kinds(REPORT_CMD, p)?.ok_or_else(|| bad(REPORT_CMD, "missing `reports` (answer, limits)"))?; if kinds.is_empty() { return Err(bad(REPORT_CMD, "`reports` is empty (answer, limits)")); } let d = s.doc()?; // The given model as is, else the saved model with any params given here over it. let params = match p.get("model") { Some(m @ Json::Object(_)) => m.clone(), Some(Json::Null) | None => { let mut params = saved_model(&d.wb, d.wb.active_sheet()).unwrap_or_else(|| json!({})); if let (Some(base), Some(over)) = (params.as_object_mut(), p.as_object()) { for (k, v) in over { base.insert(k.clone(), v.clone()); } } params } Some(_) => return Err(bad(REPORT_CMD, "`model` must be data.solver params")), }; let model = parse_model(s, ¶ms)?; let prob = build_problem(&model); let d = s.doc()?; let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?; let current: Vec = model.vars.iter().map(|c| as_num(&sh.value(*c))).collect(); // `{cell: number}` maps; cells they leave out keep their current values. let point = |key: &str| -> Result> { let mut x = current.clone(); let Some(map) = p.get(key) else { return Ok(x) }; let map = map.as_object().ok_or_else(|| bad(REPORT_CMD, format!("`{key}` must map cells to numbers")))?; for (cell, v) in map { let c = CellRef::parse(&cell.replace('$', "")).ok_or_else(|| bad(REPORT_CMD, format!("not a cell: `{cell}`")))?; let j = model.vars.iter().position(|v| *v == c).ok_or_else(|| bad(REPORT_CMD, format!("`{cell}` is not a variable cell")))?; let v = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(REPORT_CMD, format!("`{key}.{cell}` must be a number")))?; if let Some(e) = x.get_mut(j) { *e = v; } } Ok(x) }; let solution = point("values")?; let original = if p.get("original").is_some() { point("original")? } else { solution.clone() }; let budget = params .get("maxEvaluations") .and_then(Json::as_f64) .filter(|v| v.is_finite() && *v >= 1.0) .map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS)); let message = str_param(p, "message").unwrap_or("").chars().take(500).collect(); let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget }; let built = prepare_reports(s.doc()?, &kinds, &inp)?; let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?; insert_report_selections(s, model.sheet, sheets.len()); Ok(json!({"sheets": sheets})) }