Files
gridcraft/crates/engine/src/cmd/solver/reports.rs
T

377 lines
16 KiB
Rust

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<Option<Vec<ReportKind>>> {
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::<Vec<_>>().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<Vec<Value>>) -> 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<f64>,
pub(super) solution: Vec<f64>,
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<Value> {
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<f64> {
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<f64> = c.lhs.iter().map(|_| at.next().unwrap_or(f64::NAN)).collect();
let rhs: Vec<f64> = 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<Lines> {
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<Vec<(String, Lines)>> {
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<String> {
let names: Vec<String> = 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<Json> {
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, &params)?;
let prob = build_problem(&model);
let d = s.doc()?;
let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?;
let current: Vec<f64> = 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<Vec<f64>> {
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}))
}