mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 05:06:51 -06:00
377 lines
16 KiB
Rust
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, ¶ms)?;
|
|
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}))
|
|
}
|