Merge main into Python cells as data

This commit is contained in:
prxnvt
2026-11-01 14:38:52 -05:00
12 changed files with 1724 additions and 69 deletions
+279 -31
View File
@@ -1,6 +1,6 @@
use super::{
CMD, CellRef, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef, Result, Session, Workbook,
bad, bool_param, f64_param, json, split_sheet, str_param,
CMD, CellRef, DEFAULT_EVALS, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_EVALS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef,
Result, Session, Workbook, bad, bool_param, f64_param, json, split_sheet, str_param,
};
// ---------------------------------------------------------------- model
@@ -128,6 +128,10 @@ pub(super) struct Model {
pub(super) non_negative: bool,
pub(super) method: Method,
pub(super) evo: EvoOptions,
/// The evaluation limit (in place of Excel's iterations and time limits).
pub(super) max_evals: u64,
/// Integer Optimality, as a fraction.
pub(super) gap: f64,
}
/// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`.
@@ -289,13 +293,24 @@ pub(super) fn parse_model(s: &Session, p: &Json) -> Result<Model> {
"evolutionary" | "evo" | "genetic" => Method::Evolutionary,
m => return Err(bad(CMD, format!("unknown method `{m}` (grg, simplex or evolutionary)"))),
};
Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p) })
let num = |k: &str| p.get(k).and_then(Json::as_f64).or_else(|| str_param(p, k).and_then(num_text)).filter(|v| v.is_finite());
let max_evals = num("maxEvaluations").filter(|v| *v >= 1.0).map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
// Integer Optimality is in percent: "5" and "5%" both mean 5 % (number parsing would read
// "5%" as 0.05).
let percent = p
.get("integerOptimality")
.and_then(Json::as_f64)
.or_else(|| str_param(p, "integerOptimality").and_then(|t| num_text(t.trim().trim_end_matches('%'))))
.filter(|v| v.is_finite());
let gap = percent.map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0);
Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p), max_evals, gap })
}
// ---------------------------------------------------------------- saved model
fn ref_text(sheet_name: &str, cells: &str) -> String {
let q = crate::pivot::quote_sheet(sheet_name);
/// `"B2:B4,D1"` as absolute references, each qualified by `sheet_name` when given.
fn abs_text(sheet_name: Option<&str>, cells: &str) -> String {
let q = sheet_name.map(|n| format!("{}!", crate::pivot::quote_sheet(n))).unwrap_or_default();
cells
.split(',')
.map(str::trim)
@@ -303,16 +318,49 @@ fn ref_text(sheet_name: &str, cells: &str) -> String {
.filter_map(|part| {
let r = RangeRef::parse(split_sheet(part).1.trim())?;
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1);
Some(if r.is_single() { format!("{q}!{}", abs(r.start)) } else { format!("{q}!{}:{}", abs(r.start), abs(r.end)) })
Some(if r.is_single() { format!("{q}{}", abs(r.start)) } else { format!("{q}{}:{}", abs(r.start), abs(r.end)) })
})
.collect::<Vec<_>>()
.join(",")
}
fn ref_text(sheet_name: &str, cells: &str) -> String {
abs_text(Some(sheet_name), cells)
}
/// The evaluation limit and the generations without improvement have no Excel equivalent:
/// Excel's `solver_itr` counts iterations (100 by default) and `solver_mni` seconds, so a
/// workbook from Excel would cut GridCraft's search short. They are saved under GridCraft's own
/// names, which Excel ignores.
const EVALS_NAME: &str = "gridcraft_solver_evals";
const STALL_NAME: &str = "gridcraft_solver_stall";
/// The names of a saved model after `solver_`, as Excel's Solver writes them (GridCraft writes
/// some of them). Saving a model and Reset All remove these and no other `solver_` names.
const SOLVER_KEYS: [&str; 28] = [
"adj", "cvg", "drv", "eng", "est", "itr", "lin", "mip", "mni", "mrt", "msl", "neg", "nod", "num", "nwt", "opt", "pre", "rbv", "rlx", "rsd",
"scl", "sho", "ssz", "tim", "tol", "typ", "val", "ver",
];
/// Whether `name` is one of the names of a saved model: Excel's Solver names (`solver_opt`,
/// `solver_lhs1` and so on) and GridCraft's own. A user's name such as `solver_budget` is not.
pub(super) fn is_model_name(name: &str) -> bool {
let name = name.to_ascii_lowercase();
if name == EVALS_NAME || name == STALL_NAME {
return true;
}
let Some(key) = name.strip_prefix("solver_") else { return false };
if SOLVER_KEYS.contains(&key) {
return true;
}
// One name per constraint: solver_lhs1, solver_rel1, solver_rhs1, …
["lhs", "rel", "rhs"].iter().filter_map(|p| key.strip_prefix(p)).any(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
/// The model as hidden names scoped to its sheet (replacing any saved before).
pub(super) fn save_model(wb: &mut Workbook, m: &Model) {
let Some(sheet_name) = wb.sheet(m.sheet).map(|s| s.name.clone()) else { return };
wb.names.retain(|n| !(n.scope == Some(m.sheet) && n.name.to_ascii_lowercase().starts_with("solver_")));
wb.names.retain(|n| !(n.scope == Some(m.sheet) && is_model_name(&n.name)));
let mut put = |name: String, formula: String| {
wb.names.push(DefinedName { name, scope: Some(m.sheet), formula, comment: String::new(), hidden: true });
};
@@ -350,26 +398,31 @@ pub(super) fn save_model(wb: &mut Workbook, m: &Model) {
Method::Evolutionary => "3",
};
put("solver_eng".into(), eng.into());
if m.method == Method::Evolutionary {
put("solver_ssz".into(), m.evo.population.to_string());
put("solver_mrt".into(), m.evo.mutation.to_string());
put("solver_rsd".into(), m.evo.seed.to_string());
put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into());
}
put(EVALS_NAME.into(), m.max_evals.to_string());
put("solver_tol".into(), m.gap.to_string());
// The Evolutionary options are kept whatever the method, as Solver Options sets them, so
// switching methods doesn't lose them.
put("solver_ssz".into(), m.evo.population.to_string());
put("solver_mrt".into(), m.evo.mutation.to_string());
put("solver_rsd".into(), m.evo.seed.to_string());
put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into());
put("solver_cvg".into(), m.evo.convergence.to_string());
put(STALL_NAME.into(), m.evo.stall.to_string());
put("solver_ver".into(), "3".into());
}
/// The saved model of a sheet as `data.solver` params, if any.
///
/// A sheet's own (sheet-level) model wins over one saved as workbook-level names, and its names
/// are read alone, so the two never mix. A sheet-level `solver_opt` of `""` (written by Reset
/// All over a workbook-level model) means the sheet has no model.
pub fn saved_model(wb: &Workbook, sheet: usize) -> Option<Json> {
let get = |n: &str| {
wb.names
.iter()
.find(|d| d.name.eq_ignore_ascii_case(n) && (d.scope == Some(sheet) || d.scope.is_none()))
.map(|d| d.formula.trim().trim_start_matches('=').to_string())
};
let named = |n: &str, scope: Option<usize>| wb.names.iter().find(|d| d.name.eq_ignore_ascii_case(n) && d.scope == scope);
let scope = if named("solver_opt", Some(sheet)).is_some() { Some(sheet) } else { None };
let get = |n: &str| named(n, scope).map(|d| d.formula.trim().trim_start_matches('=').to_string());
let local = |t: String| -> String { t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",") };
let objective = local(get("solver_opt")?);
if objective.is_empty() {
if objective.trim_matches('"').trim().is_empty() {
return None;
}
let goal = match get("solver_typ").as_deref().map(str::trim) {
@@ -408,11 +461,21 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option<Json> {
"nonNegative": get("solver_neg").as_deref().map(str::trim) != Some("2"),
"method": method,
});
for (name, key) in [("solver_ssz", "populationSize"), ("solver_mrt", "mutationRate"), ("solver_rsd", "randomSeed")] {
for (name, key) in [
("solver_ssz", "populationSize"),
("solver_mrt", "mutationRate"),
("solver_rsd", "randomSeed"),
("solver_cvg", "convergence"),
(STALL_NAME, "generationsWithoutImprovement"),
(EVALS_NAME, "maxEvaluations"),
] {
if let Some(v) = get(name).and_then(|v| num_text(&v)) {
model[key] = json!(v);
model[key] = number(v);
}
}
if let Some(v) = get("solver_tol").and_then(|v| num_text(&v)) {
model["integerOptimality"] = number(percent(v));
}
if let Some(v) = get("solver_rbv") {
model["requireBounds"] = json!(v.trim() != "2");
}
@@ -449,15 +512,200 @@ pub(super) fn model_params(wb: &Workbook, m: &Model) -> Json {
"constraints": constraints,
"nonNegative": m.non_negative,
"method": method,
"maxEvaluations": m.max_evals,
"integerOptimality": number(percent(m.gap)),
});
if m.method == Method::Evolutionary {
let e = &m.evo;
out["populationSize"] = json!(e.population);
out["mutationRate"] = json!(e.mutation);
out["randomSeed"] = json!(e.seed);
out["convergence"] = json!(e.convergence);
out["generationsWithoutImprovement"] = json!(e.stall);
out["requireBounds"] = json!(e.require_bounds);
}
let e = &m.evo;
out["populationSize"] = json!(e.population);
out["mutationRate"] = json!(e.mutation);
out["randomSeed"] = json!(e.seed);
out["convergence"] = json!(e.convergence);
out["generationsWithoutImprovement"] = json!(e.stall);
out["requireBounds"] = json!(e.require_bounds);
out
}
// ---------------------------------------------------------------- model on cells
/// The cells Save Model writes, top to bottom, as formula text.
pub(super) fn model_formulas(m: &Model) -> Result<Vec<String>> {
let one_area = |t: &str| !t.contains(',');
let mut rows = Vec::with_capacity(m.cons.len() + 3);
let obj = abs_text(None, &m.objective.a1());
rows.push(match m.goal {
Goal::Max => format!("=MAX({obj})"),
Goal::Min => format!("=MIN({obj})"),
Goal::Value(v) => format!("={obj}={}", gridcraft_core::number_to_text(v)),
});
rows.push(format!("=COUNT({})", abs_text(None, &m.vars_text)));
for c in &m.cons {
if !one_area(&c.lhs_text) || !one_area(&c.rhs_text) {
return Err(EngineError::Other("Save Model needs each side of a constraint to be one range.".into()));
}
let lhs = abs_text(None, &c.lhs_text);
rows.push(match (c.rel, &c.rhs) {
(Rel::Int, _) => format!("=AND({lhs}=INT({lhs}))"),
(Rel::Bin, _) => format!("=AND(({lhs}=0)+({lhs}=1))"),
(rel, Rhs::Cells(_)) => format!("=AND({lhs}{}{})", rel.text(), abs_text(None, &c.rhs_text)),
(rel, Rhs::Const(v)) => format!("=AND({lhs}{}{})", rel.text(), gridcraft_core::number_to_text(*v)),
(rel, Rhs::None) => format!("=AND({lhs}{}0)", rel.text()),
});
}
let e = &m.evo;
let n = gridcraft_core::number_to_text;
let eng = match m.method {
Method::Grg => 1,
Method::Simplex => 2,
Method::Evolutionary => 3,
};
let yes = |b: bool| if b { 1 } else { 2 };
rows.push(format!(
"={{{eng},{},{},{},{},{},{},{},{},{}}}",
yes(m.non_negative),
e.population,
n(e.mutation),
e.seed,
yes(e.require_bounds),
n(e.convergence),
e.stall,
m.max_evals,
n(percent(m.gap))
));
Ok(rows)
}
/// `NAME(inner)` → inner, ignoring case.
fn call_args(t: &str, name: &str) -> Option<String> {
let head = t.get(..name.len() + 1)?;
(head.eq_ignore_ascii_case(&format!("{name}(")) && t.ends_with(')')).then(|| t.get(name.len() + 1..t.len() - 1).map(str::to_string))?
}
/// References without sheet names or `$`.
fn local_refs(t: &str) -> String {
t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",")
}
fn not_a_model() -> EngineError {
EngineError::Other("The range doesn't hold a Solver model saved with Save Model.".into())
}
/// How many values the options row of Save Model holds at least: method, non-negative,
/// population size, mutation rate, random seed, require bounds, convergence and generations
/// without improvement.
const OPTIONS_FIELDS: usize = 8;
/// The options row of Save Model, `{method, non-negative, …}`, into `model`. Refused when it
/// holds fewer than [`OPTIONS_FIELDS`] values or a value that isn't a number: a row cut short
/// or edited by hand would otherwise load with default options in place of the saved ones.
fn read_options_row(model: &mut Json, list: &str) -> Result<()> {
let incomplete = || {
EngineError::Other(format!(
"The model's options row doesn't hold the {OPTIONS_FIELDS} or more numbers Save Model writes, so the range isn't a complete model saved with Save Model."
))
};
// Every value is checked; only the first ones are kept (the row is one formula, so its
// length is bounded by the formula's).
let mut v: Vec<f64> = Vec::with_capacity(16);
let mut count = 0usize;
for t in list.split([',', ';']) {
let x = num_text(t).ok_or_else(incomplete)?;
count = count.saturating_add(1);
if v.len() < 16 {
v.push(x);
}
}
if count < OPTIONS_FIELDS {
return Err(incomplete());
}
let get = |k: usize| v.get(k).copied();
model["method"] = json!(match get(0).map(|x| x as i64) {
Some(2) => "simplex",
Some(3) => "evolutionary",
_ => "grg",
});
model["nonNegative"] = json!(get(1) != Some(2.0));
for (k, key) in [
(2, "populationSize"),
(3, "mutationRate"),
(4, "randomSeed"),
(6, "convergence"),
(7, "generationsWithoutImprovement"),
(8, "maxEvaluations"),
(9, "integerOptimality"),
] {
if let Some(x) = get(k) {
model[key] = json!(x);
}
}
model["requireBounds"] = json!(get(5) != Some(2.0));
Ok(())
}
/// One constraint row of Save Model (`AND(relation)`) as a `data.solver` constraint.
fn read_constraint_row(t: String) -> Result<String> {
let t = call_args(&t, "AND").unwrap_or(t);
if let Some((x, y)) = t.split_once("=INT(").or_else(|| t.split_once("=int(")) {
if y.strip_suffix(')') != Some(x) {
return Err(not_a_model());
}
return Ok(format!("{} int", local_refs(x)));
}
if let Some(x) = t.strip_prefix('(').and_then(|x| x.strip_suffix("=1)")).and_then(|x| x.split_once("=0)+(")).filter(|(a, b)| a == b) {
return Ok(format!("{} bin", local_refs(x.0)));
}
let (l, rel, r) = split_constraint(&t).ok_or_else(not_a_model)?;
let r = r.trim_matches('"');
Ok(match r.to_ascii_lowercase().as_str() {
"integer" => format!("{} int", local_refs(&l)),
"binary" => format!("{} bin", local_refs(&l)),
_ if num_text(r).is_some() => format!("{} {} {r}", local_refs(&l), rel.text()),
_ => format!("{} {} {}", local_refs(&l), rel.text(), local_refs(r)),
})
}
/// Reads formula text written by Save Model back into `data.solver` params.
pub(super) fn parse_model_formulas(rows: &[String]) -> Result<Json> {
let body = |t: &str| t.trim().trim_start_matches('=').replace(' ', "");
let first = body(rows.first().ok_or_else(not_a_model)?);
let (goal, objective, value) = if let Some(o) = call_args(&first, "MAX") {
("max", o, 0.0)
} else if let Some(o) = call_args(&first, "MIN") {
("min", o, 0.0)
} else {
let (o, v) = first.split_once('=').ok_or_else(not_a_model)?;
("value", o.to_string(), num_text(v).ok_or_else(not_a_model)?)
};
let variables = call_args(&body(rows.get(1).ok_or_else(not_a_model)?), "COUNT").ok_or_else(not_a_model)?;
let mut model = json!({"objective": local_refs(&objective), "goal": goal, "value": value, "variables": local_refs(&variables)});
let mut constraints = Vec::new();
let rest = rows.get(2..).unwrap_or(&[]);
for (i, row) in rest.iter().enumerate() {
let t = body(row);
if let Some(list) = t.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
// The options: the last row.
if i + 1 != rest.len() {
return Err(not_a_model());
}
read_options_row(&mut model, list)?;
break;
}
constraints.push(json!(read_constraint_row(t)?));
}
model["constraints"] = json!(constraints);
Ok(model)
}
/// Integer Optimality as a percent from its stored fraction. The product is rounded to 1E-10 %,
/// so a fraction such as 0.07 reads back as 7 and not 7.000000000000001.
fn percent(fraction: f64) -> f64 {
let p = fraction * 100.0;
let rounded = (p * 1e10).round() / 1e10;
if rounded.is_finite() { rounded } else { p }
}
/// A number read from a hidden name as JSON: a whole number as an integer, so the Solver dialog
/// shows 20000 and not 20000.0.
fn number(v: f64) -> Json {
// Whole numbers below 2^53 are exact as integers.
if v.fract() == 0.0 && v.abs() < 9_007_199_254_740_992.0 { json!(v as i64) } else { json!(v) }
}
+305
View File
@@ -0,0 +1,305 @@
//! Load/Save Model and Reset All: the model written on cells of its sheet, read back from them,
//! and removed from the sheet.
use super::model::{Rhs, is_model_name, model_formulas, parse_model, parse_model_formulas, save_model, saved_model};
use super::{
Cell, CellRef, DefinedName, EngineError, Json, LOAD_CMD, MAX_CONSTRAINTS, RangeRef, Result, SAVE_CMD, Session, Sheet, Value, bad, bool_param,
edit, json, split_sheet, str_param,
};
use crate::cmd::analysis::OVERWRITE_ERROR;
use crate::cmd::cell_param_on;
/// Most cells a model written by Save Model takes: the objective, the variables, the options
/// and one per constraint.
const MAX_MODEL_ROWS: usize = MAX_CONSTRAINTS + 3;
fn is_options_row(formula: &str) -> bool {
formula.trim().trim_start_matches('=').trim_start().starts_with('{')
}
/// The formulas from `start` down: to the first cell without a formula, or through the options
/// row (`{…}`) that ends a model written by Save Model; at most [`MAX_MODEL_ROWS`] cells.
fn formulas_down(sheet: &Sheet, start: CellRef) -> Vec<(CellRef, String)> {
let mut out = Vec::new();
for k in 0..MAX_MODEL_ROWS as u32 {
let Some(c) = start.row.checked_add(k).map(|row| CellRef::new(row, start.col)) else { break };
let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref()).map(|f| f.text.clone()) else { break };
let last = is_options_row(&f);
out.push((c, f));
if last {
break;
}
}
out
}
/// The cells of a model written by Save Model that starts at `start` (none if the cells there
/// don't hold one).
fn saved_model_cells(sheet: &Sheet, start: CellRef) -> Vec<CellRef> {
let old = formulas_down(sheet, start);
let rows: Vec<String> = old.iter().map(|(_, f)| f.clone()).collect();
if rows.last().is_some_and(|f| is_options_row(f)) && parse_model_formulas(&rows).is_ok() {
old.into_iter().map(|(c, _)| c).collect()
} else {
Vec::new()
}
}
/// How many cells the options row of Save Model (`={…}`, one row) spans.
fn options_width(formula: &str) -> u32 {
u32::try_from(formula.split(',').count()).unwrap_or(u32::MAX)
}
/// The options row is an array constant that spills `width - 1` cells to the right of `anchor`.
/// Refused, before anything is written, when the spill would run off the sheet or be blocked
/// (the anchor would show `#SPILL!`): a cell there holds content or another formula's spilled
/// values, or a merged area or a table meets the row. `ours` are the cells Save Model
/// writes or clears (a model saved at the same place before, whose options spill may be
/// replaced); `used` are the model's own cells, which the spill must not cover.
fn check_options_spill(sheet: &Sheet, anchor: CellRef, width: u32, ours: &[CellRef], used: &[CellRef]) -> Result<()> {
if width <= 1 {
return Ok(());
}
let end = anchor.col.checked_add(width - 1).filter(|c| *c < gridcraft_core::MAX_COLS).ok_or_else(|| {
EngineError::Other(format!(
"The saved model's options need {width} cells to the right from {}; choose another place to save it.",
anchor.a1()
))
})?;
let full = RangeRef::new(anchor, CellRef::new(anchor.row, end));
let spill = RangeRef::new(CellRef::new(anchor.row, anchor.col.saturating_add(1)), CellRef::new(anchor.row, end));
let blocked =
|why: String| EngineError::Other(format!("The saved model's options fill {}, but {why}; choose another place to save it.", spill.a1()));
if let Some(c) = used.iter().find(|c| spill.contains(**c)) {
return Err(blocked(format!("{} is a cell of the model", c.a1())));
}
for c in spill.iter() {
if sheet.cells.has(c) {
return Err(blocked(format!("{} isn't empty", c.a1())));
}
if sheet.spill.contains_key(&c) && sheet.spill_anchor(c).is_none_or(|a| !ours.contains(&a)) {
return Err(blocked(format!("{} holds the values of another formula", c.a1())));
}
}
if sheet.merges.iter().any(|m| m.intersects(&full)) {
return Err(blocked("merged cells are in the way".into()));
}
// A dynamic array can't spill into a table.
if sheet.tables.iter().any(|t| t.range.intersects(&full)) {
return Err(blocked("a table is in the way".into()));
}
Ok(())
}
/// Refused when a cell of the model's column holds another formula's spilled values: the
/// formula written there would block that spill (the other formula would show `#SPILL!`).
/// `ours` are as in [`check_options_spill`]: a model saved at the same place before may be
/// replaced.
fn check_column_spills(sheet: &Sheet, column: RangeRef, ours: &[CellRef]) -> Result<()> {
for c in column.iter() {
if sheet.spill.contains_key(&c) && sheet.spill_anchor(c).is_none_or(|a| !ours.contains(&a)) {
return Err(EngineError::Other(format!(
"The saved model would cover {}, which holds the values of another formula; choose another place to save it.",
c.a1()
)));
}
}
Ok(())
}
/// Refused, unless `overwrite`, when a cell of the model's column holds a value or a formula
/// (a model saved there before included): Save Model then asks before it replaces data, as
/// Excel does. The message carries [`OVERWRITE_ERROR`], which the dialog turns into its
/// question.
fn check_column_empty(sheet: &Sheet, column: RangeRef, overwrite: bool) -> Result<()> {
let taken = column.iter().find(|c| sheet.cell(*c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()));
match taken {
Some(c) if !overwrite => Err(EngineError::Other(format!(
"The range {} {OVERWRITE_ERROR} ({} isn't empty); pass overwrite: true to replace it.",
column.a1(),
c.a1()
))),
_ => Ok(()),
}
}
/// `data.solverSaveModel`: writes the model on cells of its sheet, in one step. Saving over a
/// longer model saved at the same cell clears the rest of it, so Load Model reads the new one.
pub(super) fn save_model_cells(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let params = match p.get("model") {
Some(m @ Json::Object(_)) => m.clone(),
Some(Json::Null) | None => {
saved_model(&d.wb, d.wb.active_sheet()).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))?
}
Some(_) => return Err(bad(SAVE_CMD, "`model` must be data.solver params")),
};
let model = parse_model(s, &params)?;
let to = cell_param_on(s, p, "to", model.sheet)?.ok_or_else(|| bad(SAVE_CMD, "missing `to` (the first cell of the saved model)"))?;
let rows = model_formulas(&model)?;
let count = u32::try_from(rows.len()).unwrap_or(u32::MAX);
let last = to
.row
.checked_add(count.saturating_sub(1))
.filter(|r| *r < gridcraft_core::MAX_ROWS)
.ok_or_else(|| EngineError::Other("The saved model doesn't fit below that cell.".into()))?;
let range = RangeRef::new(to, CellRef::new(last, to.col));
// The model's own cells are never overwritten.
let mut used = vec![model.objective];
used.extend(&model.vars);
for c in &model.cons {
used.extend(&c.lhs);
if let Rhs::Cells(r) = &c.rhs {
used.extend(r);
}
}
if used.iter().any(|c| range.contains(*c)) {
return Err(EngineError::Other(format!("{} overlaps the model's cells; choose another place to save it.", range.a1())));
}
let sheet = model.sheet;
let sh = s.doc()?.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// What is left of a model saved here before; the new model's own cells stay.
let stale: Vec<CellRef> = saved_model_cells(sh, to).into_iter().filter(|c| !range.contains(*c) && !used.contains(c)).collect();
let ours: Vec<CellRef> = range.iter().chain(stale.iter().copied()).collect();
let width = rows.last().map_or(1, |f| options_width(f));
check_column_spills(sh, range, &ours)?;
check_options_spill(sh, CellRef::new(last, to.col), width, &ours, &used)?;
// Last, so the question is asked only when replacing the data would succeed.
check_column_empty(sh, range, bool_param(p, "overwrite").unwrap_or(false))?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let cells: Vec<CellRef> = range.iter().collect();
for (c, f) in cells.iter().zip(&rows) {
let mut cell = Cell::formula(gridcraft_model::Formula::new(f));
cell.style = sh.style_id(*c);
sh.set_cell(*c, cell);
}
for c in &stale {
let style = sh.style_id(*c);
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style, py: None });
}
for c in cells.into_iter().chain(stale) {
cx.touch(sheet, c);
}
Ok(())
})?;
Ok(json!({"range": range.a1()}))
}
/// The formulas of a model range, in order. Every cell up to the last formula must hold one: a
/// cell without a formula there (a constraint overwritten by a constant, say) would shift the
/// rows below it and drop a constraint or the options. Empty cells after the last formula are
/// allowed.
fn cells_with_formulas(r: RangeRef, formula: impl Fn(CellRef) -> Option<String>) -> Result<Vec<String>> {
let cells: Vec<(CellRef, Option<String>)> = r.iter().map(|c| (c, formula(c))).collect();
let end = cells.iter().rposition(|(_, f)| f.is_some()).map_or(0, |i| i + 1);
let mut rows = Vec::with_capacity(end);
for (c, f) in cells.into_iter().take(end) {
let f = f.ok_or_else(|| EngineError::Other(format!("{} holds no formula, so the range isn't a model saved with Save Model.", c.a1())))?;
rows.push(f);
}
Ok(rows)
}
/// A model read down from `start` must end with its options row, which Save Model always
/// writes last: a cell without a formula before it (a constraint cleared, say) would otherwise
/// end the read early and load the rows above it as the whole model, with default options.
fn complete_from_cell(start: CellRef, down: &[(CellRef, String)]) -> Result<()> {
match down.last() {
Some((_, f)) if is_options_row(f) => Ok(()),
_ if down.len() >= MAX_MODEL_ROWS => Err(EngineError::Other(format!(
"No options row ends the model within {MAX_MODEL_ROWS} cells of {}, so it isn't a model saved with Save Model.",
start.a1()
))),
last => {
let gap = last.map_or(Some(start), |(c, _)| c.row.checked_add(1).map(|row| CellRef::new(row, c.col)));
Err(EngineError::Other(match gap {
Some(gap) => format!(
"{} holds no formula before the model's options row, so {} doesn't start a complete model saved with Save Model.",
gap.a1(),
start.a1()
),
None => format!("{} doesn't start a complete model saved with Save Model.", start.a1()),
}))
}
}
}
/// `data.solverLoadModel`: makes a model written by Save Model the active sheet's model.
pub(super) fn load_model_cells(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let active = d.wb.active_sheet();
let text = str_param(p, "from").ok_or_else(|| bad(LOAD_CMD, "missing `from` (the saved model's range)"))?;
let (sh, body) = split_sheet(text);
if sh.is_some_and(|n| d.wb.sheet_index(&n) != Some(active)) {
return Err(EngineError::Other("Load Model reads a model saved on the active sheet.".into()));
}
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(LOAD_CMD, format!("not a range: `{text}`")))?;
let cap = MAX_MODEL_ROWS;
let sheet = d.wb.sheet(active).ok_or(EngineError::NoDocument)?;
let formula = |c: CellRef| sheet.cell(c).and_then(|x| x.formula.as_ref()).map(|f| f.text.clone());
let rows: Vec<String> = if r.is_single() {
let down = formulas_down(sheet, r.start);
complete_from_cell(r.start, &down)?;
down.into_iter().map(|(_, f)| f).collect()
} else {
if r.count() > cap as u64 {
return Err(EngineError::Other(format!("The range is too large for a saved model (at most {cap} cells).")));
}
let rows = cells_with_formulas(r, formula)?;
if !rows.last().is_some_and(|f| is_options_row(f)) {
return Err(EngineError::Other(format!(
"{} doesn't end with the model's options row, so it isn't a complete model saved with Save Model.",
r.a1()
)));
}
rows
};
let params = parse_model_formulas(&rows)?;
let model = parse_model(s, &params)?;
if model.sheet != active {
return Err(EngineError::Other("Load Model reads a model of the active sheet.".into()));
}
edit(s, |cx| {
save_model(&mut cx.wb, &model);
Ok(())
})?;
Ok(json!({"model": saved_model(&s.doc()?.wb, active)}))
}
/// Whether a defined name belongs to the saved model of `sheet`.
fn is_sheet_model_name(n: &DefinedName, sheet: usize) -> bool {
n.scope == Some(sheet) && is_model_name(&n.name)
}
/// `data.solverReset`: Reset All removes the active sheet's model. A model the sheet reads from
/// workbook-level names (which other sheets may read too) is left in place and masked by an
/// empty sheet-level `solver_opt`, so no other sheet's model changes.
pub(super) fn reset_model(s: &mut Session, _p: &Json) -> Result<Json> {
let d = s.doc()?;
let active = d.wb.active_sheet();
let removed = d.wb.names.iter().filter(|n| is_sheet_model_name(n, active)).count();
let mut after = d.wb.names.clone();
after.retain(|n| !is_sheet_model_name(n, active));
let masked = {
let mut probe = (*d.wb).clone();
probe.names = after;
saved_model(&probe, active).is_some()
};
if removed > 0 || masked {
edit(s, |cx| {
cx.wb.names.retain(|n| !is_sheet_model_name(n, active));
if masked {
cx.wb.names.push(DefinedName {
name: "solver_opt".into(),
scope: Some(active),
formula: "\"\"".into(),
comment: String::new(),
hidden: true,
});
}
Ok(())
})?;
}
Ok(json!({"removed": removed, "masked": masked}))
}
+3 -7
View File
@@ -2,8 +2,8 @@ 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,
Cell, CellRef, Ctx, DocState, EngineError, Json, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad, edit, json, optimise,
str_param,
};
// ---------------------------------------------------------------- reports
@@ -362,11 +362,7 @@ pub(super) fn report(s: &mut Session, p: &Json) -> Result<Json> {
};
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 budget = model.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)?;
+532
View File
@@ -400,6 +400,538 @@ fn solver_report_insertion_keeps_variable_edit_guards() {
}
}
fn formula(s: &Session, a1: &str) -> String {
let d = s.doc().unwrap();
d.wb.active().unwrap().cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()).unwrap_or_default()
}
#[test]
fn save_and_load_model_round_trip_through_cells() {
let mut s = s();
set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2"), ("C2", "24")]);
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= C2", "B3 <= 6", "A1:A2 int", "A2 bin", "A1 >= 1"], "method": "simplex"});
s.execute("data.solver", model.clone()).unwrap();
let out = s.execute("data.solverSaveModel", json!({"to": "H1"})).unwrap();
assert_eq!(out["range"], "H1:H8");
let rows: Vec<String> = (1..=8).map(|r| formula(&s, &format!("H{r}"))).collect();
assert_eq!(
rows,
[
"MAX($B$1)",
"COUNT($A$1:$A$2)",
"AND($B$2<=$C$2)",
"AND($B$3<=6)",
"AND($A$1:$A$2=INT($A$1:$A$2))",
"AND(($A$2=0)+($A$2=1))",
"AND($A$1>=1)",
"{2,1,100,0.075,0,1,0.0001,50,20000,0}",
]
);
// The constraint cells show whether each constraint holds at the solution (4, 0).
assert_eq!(at(&s, "Sheet1", "H3"), Value::Bool(true));
assert_eq!(at(&s, "Sheet1", "H2"), Value::number(2.0));
// Reset All removes the model; Load Model restores it from the range or its first cell.
assert_eq!(s.execute("data.solverReset", json!({})).unwrap()["removed"].as_u64().map(|n| n > 0), Some(true));
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null());
assert_eq!(s.execute("data.solverReset", json!({})).unwrap()["removed"], 0);
for from in ["H1:H8", "H1"] {
let loaded = s.execute("data.solverLoadModel", json!({"from": from})).unwrap();
assert_eq!(loaded["model"]["objective"], "B1", "{loaded}");
assert_eq!(loaded["model"]["constraints"], json!(["B2 <= C2", "B3 <= 6", "A1:A2 int", "A2 bin", "A1 >= 1"]));
assert_eq!(loaded["model"]["method"], "simplex");
}
set(&mut s, &[("A1", "0"), ("A2", "0")]);
let r = s.execute("data.solver", json!({"saved": true})).unwrap();
assert_eq!((r["status"].as_str(), n(&s, "A1"), n(&s, "A2")), (Some("optimal"), 4.0, 0.0), "{r}");
// A Value Of goal and the Evolutionary options survive too; an explicit model is saved
// as given.
let evo = json!({"objective": "B1", "goal": "value", "value": -2.5, "variables": "A1:A2", "constraints": ["A1:A2 <= 10"], "nonNegative": false, "method": "evolutionary", "populationSize": 40, "randomSeed": 3, "requireBounds": false});
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "J10", "model": evo})).unwrap()["range"], "J10:J13");
assert_eq!(formula(&s, "J10"), "$B$1=-2.5");
let m = s.execute("data.solverLoadModel", json!({"from": "J10:J13"})).unwrap()["model"].clone();
assert_eq!((m["goal"].as_str(), m["value"].as_f64(), m["nonNegative"].as_bool()), (Some("value"), Some(-2.5), Some(false)), "{m}");
assert_eq!((m["method"].as_str(), m["populationSize"].as_f64(), m["randomSeed"].as_f64()), (Some("evolutionary"), Some(40.0), Some(3.0)));
assert_eq!(m["requireBounds"], false);
// Each command is one undo step.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.execute("data.solver", json!({})).unwrap()["model"]["method"], "simplex");
// Refused: no model or no target, overlapping the model, off the sheet, ranges that
// hold no model, other sheets and oversized ranges.
set(&mut s, &[("L1", "=1+1"), ("L2", "=SUM(A1:A2)")]);
for (cmd, p) in [
("data.solverSaveModel", json!({})),
("data.solverSaveModel", json!({"to": "A1"})),
("data.solverSaveModel", json!({"to": "B3"})),
("data.solverSaveModel", json!({"to": "A1048575"})),
("data.solverSaveModel", json!({"to": "H1", "model": {"objective": "B1", "variables": "A1,A2", "constraints": ["A1,A2 <= 3"]}})),
("data.solverSaveModel", json!({"to": "H1", "model": "B1"})),
("data.solverLoadModel", json!({})),
("data.solverLoadModel", json!({"from": "L1:L2"})),
("data.solverLoadModel", json!({"from": "Z1"})),
("data.solverLoadModel", json!({"from": "H2:H8"})),
("data.solverLoadModel", json!({"from": "H1:H1048576"})),
("data.solverLoadModel", json!({"from": "Nope!H1"})),
] {
assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p}");
}
s.execute("data.solverReset", json!({})).unwrap();
assert!(s.execute("data.solverSaveModel", json!({"to": "H1"})).is_err(), "no model to save");
}
#[test]
fn reset_all_masks_a_workbook_level_model_and_keeps_other_sheets_models() {
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
let (one, two) = {
let wb = &s.doc().unwrap().wb;
(wb.sheet_index("Sheet1").unwrap(), wb.sheet_index("Two").unwrap())
};
// A model saved as workbook-level names, which every sheet reads.
{
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
for (name, formula) in [
("solver_opt", "Sheet1!$B$1"),
("solver_adj", "Sheet1!$A$1"),
("solver_typ", "1"),
("solver_num", "0"),
("solver_eng", "2"),
("solver_ssz", "500"),
] {
wb.names.push(gridcraft_model::DefinedName {
name: name.into(),
scope: None,
formula: formula.into(),
comment: String::new(),
hidden: true,
});
}
}
let model = |s: &Session, sheet: usize| super::saved_model(&s.doc().unwrap().wb, sheet);
assert_eq!(model(&s, one).map(|m| m["objective"].clone()), Some(json!("B1")));
// Reset All empties this sheet's model, which the dialog then shows, and no other sheet's.
let r = s.execute("data.solverReset", json!({})).unwrap();
assert_eq!(model(&s, one), None, "{r}");
assert_eq!(r["masked"], true, "{r}");
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null());
assert_eq!(model(&s, two).map(|m| m["objective"].clone()), Some(json!("B1")));
assert_eq!(s.doc().unwrap().wb.names.iter().filter(|n| n.scope.is_none()).count(), 6);
// Undo brings the model back with its options; the mask survives XLSX.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(model(&s, one).and_then(|m| m["populationSize"].as_f64()), Some(500.0));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(model(&s, one), None);
let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "r.xlsx", "base64": b64})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
assert_eq!(model(&s, one), None);
assert!(model(&s, two).is_some());
// A model saved on the sheet replaces the mask and is read alone: the workbook-level
// population size doesn't leak into it.
s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 3"], "method": "simplex"})).unwrap();
let m = model(&s, one).unwrap();
assert_eq!((m["objective"].as_str(), m["populationSize"].as_f64()), (Some("B1"), Some(100.0)), "{m}");
assert_eq!(s.execute("data.solverReset", json!({})).unwrap()["masked"], true);
assert_eq!(model(&s, one), None);
}
#[test]
fn saving_a_shorter_model_over_a_longer_one_clears_the_rest() {
let mut s = s();
set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2"), ("H9", "=1+1")]);
let long = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6", "A1 <= 3", "A2 <= 3"]});
let short = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24"]});
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1", "model": long})).unwrap()["range"], "H1:H7");
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1", "model": short, "overwrite": true})).unwrap()["range"], "H1:H4");
// The old model's last rows are cleared; a formula below it stays.
for r in 5..=7 {
assert_eq!(formula(&s, &format!("H{r}")), "", "H{r}");
}
assert_eq!(formula(&s, "H9"), "1+1");
let loaded = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap();
assert_eq!(loaded["model"]["constraints"], json!(["B2 <= 24"]), "{loaded}");
// Reading down from one cell stops at the options row even with formulas right below it.
set(&mut s, &[("H5", "=2+2")]);
let loaded = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap();
assert_eq!(loaded["model"]["constraints"], json!(["B2 <= 24"]), "{loaded}");
// Saving over cells that hold no model leaves the cells below them as they are.
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H8", "model": short, "overwrite": true})).unwrap()["range"], "H8:H11");
assert_eq!(formula(&s, "H5"), "2+2");
}
#[test]
fn save_model_asks_before_it_replaces_data_and_is_one_undo_step() {
let mut s = s();
set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("H2", "kept"), ("H3", "=1+1")]);
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24"]});
// A value or a formula in the column: refused, the sheet unchanged, the cell named.
let e = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model.clone()})).unwrap_err().to_string();
assert!(e.contains(crate::cmd::analysis::OVERWRITE_ERROR) && e.contains("H1:H4") && e.contains("H2"), "{e}");
assert_eq!((at(&s, "Sheet1", "H2"), formula(&s, "H3"), formula(&s, "H1")), (Value::text("kept"), "1+1".into(), String::new()));
// So is a model saved there before, and `overwrite` only as a true boolean.
assert!(s.execute("data.solverSaveModel", json!({"to": "J10", "model": model.clone()})).is_ok());
for overwrite in [json!(null), json!(false), json!("true"), json!(1)] {
let e = s.execute("data.solverSaveModel", json!({"to": "J10", "model": model.clone(), "overwrite": overwrite})).unwrap_err();
assert!(e.to_string().contains(crate::cmd::analysis::OVERWRITE_ERROR), "{overwrite}: {e}");
}
// overwrite: true replaces the data in one undo step, which brings it all back.
let out = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model, "overwrite": true})).unwrap();
assert_eq!(out["range"], "H1:H4");
assert_eq!((formula(&s, "H1"), formula(&s, "H2"), formula(&s, "H3")), ("MAX($B$1)".into(), "COUNT($A$1:$A$2)".into(), "AND($B$2<=24)".into()));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(
(at(&s, "Sheet1", "H2"), formula(&s, "H3"), formula(&s, "H1"), formula(&s, "H4")),
(Value::text("kept"), "1+1".into(), String::new(), String::new())
);
assert_eq!(formula(&s, "J10"), "MAX($B$1)");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(formula(&s, "H2"), "COUNT($A$1:$A$2)");
}
#[test]
fn saving_and_resetting_a_model_keep_the_users_own_solver_names() {
let mut s = s();
set(&mut s, &[("A1", "0"), ("B1", "=ABS(A1-3)"), ("C1", "100")]);
let name = |name: &str, scope: Option<usize>, formula: &str, hidden: bool| gridcraft_model::DefinedName {
name: name.into(),
scope,
formula: formula.into(),
comment: String::new(),
hidden,
};
{
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
// The user's own names, on the sheet and the workbook, and Excel's Solver names the
// model replaces.
wb.names.push(name("solver_budget", Some(0), "Sheet1!$C$1", false));
wb.names.push(name("Solver_Lhs_Total", Some(0), "Sheet1!$C$1", false));
wb.names.push(name("solver_budget", None, "Sheet1!$C$1", false));
wb.names.push(name("solver_lin", Some(0), "2", true));
wb.names.push(name("solver_lhs12", Some(0), "Sheet1!$A$1", true));
}
let names = |s: &Session| -> Vec<String> {
let mut v: Vec<String> = s.doc().unwrap().wb.names.iter().map(|n| format!("{}@{:?}", n.name, n.scope)).collect();
v.sort();
v
};
let mine = ["Solver_Lhs_Total@Some(0)", "solver_budget@None", "solver_budget@Some(0)"];
let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex"});
s.execute("data.solver", model.clone()).unwrap();
let after = names(&s);
assert!(mine.iter().all(|n| after.contains(&n.to_string())), "{after:?}");
assert!(!after.iter().any(|n| n.starts_with("solver_lin") || n.starts_with("solver_lhs12")), "{after:?}");
// Load Model saves the model again; Reset All removes it and nothing else.
s.execute("data.solverSaveModel", json!({"to": "H1"})).unwrap();
s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap();
assert!(mine.iter().all(|n| names(&s).contains(&n.to_string())), "{:?}", names(&s));
let model_names = names(&s).len() - mine.len();
let r = s.execute("data.solverReset", json!({})).unwrap();
assert_eq!((r["removed"].as_u64(), r["masked"].as_bool()), (Some(model_names as u64), Some(false)), "{r}");
assert_eq!(names(&s), mine);
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null());
}
#[test]
fn save_model_refuses_a_place_where_the_options_cannot_spill() {
let mut s = s();
set(&mut s, &[("B1", "=ABS(A1-3)"), ("H24", "=SEQUENCE(1,3)"), ("K30", "=SEQUENCE(3)"), ("M50", "=SEQUENCE(3)")]);
// Tables whose empty data rows meet an options row: one in its way, one holding the
// options cell.
for range in ["T59:U62", "W69:W71"] {
s.execute("insert.table", json!({"range": range, "header": true})).unwrap();
}
let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["F4 <= 5"]});
let save = |s: &mut Session, to: &str| s.execute("data.solverSaveModel", json!({"to": to, "model": model.clone()}));
// The options row (H4) spills into I4:Q4: a value in its last cell is in the way.
set(&mut s, &[("Q4", "x")]);
let e = save(&mut s, "H1").unwrap_err().to_string();
assert!(e.contains("I4:Q4") && e.contains("Q4 isn't empty"), "{e}");
assert_eq!((formula(&s, "H1"), formula(&s, "H4")), (String::new(), String::new()));
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null());
// With the row clear the options spill, and saving again at the same cell replaces them.
set(&mut s, &[("Q4", "")]);
for overwrite in [false, true] {
let p = json!({"to": "H1", "model": model.clone(), "overwrite": overwrite});
assert_eq!(s.execute("data.solverSaveModel", p).unwrap()["range"], "H1:H4");
assert_eq!(
(at(&s, "Sheet1", "H4"), at(&s, "Sheet1", "I4"), at(&s, "Sheet1", "O4"), at(&s, "Sheet1", "Q4")),
(Value::number(1.0), Value::number(1.0), Value::number(50.0), Value::number(0.0))
);
}
// Refused: in the options row, a formula (H24), another formula's spilled values (K31,
// from K30), a cell of the model (F4), merged cells, a table and the sheet's last columns;
// a column over another formula's spilled values (I24 of H24:J24, M51 of M50:M52) or with
// its options cell in a table.
s.execute("home.mergeCells", json!({"range": "S8:T9"})).unwrap();
for (to, why) in [
("G21", "H24 isn't empty"),
("J28", "K31 holds the values of another formula"),
("E1", "F4 is a cell of the model"),
("R5", "merged"),
("S57", "T60:AB60, but a table is in the way"),
("XFA1", "need 10 cells"),
("I21", "cover I24, which holds the values of another formula"),
("M51", "cover M51, which holds the values of another formula"),
("W67", "X70:AF70, but a table is in the way"),
] {
let e = save(&mut s, to).unwrap_err().to_string();
assert!(e.contains(why), "{to}: {e}");
assert_eq!(formula(&s, to), "", "{to}");
}
assert_eq!(
(at(&s, "Sheet1", "J24"), at(&s, "Sheet1", "K32"), at(&s, "Sheet1", "M52")),
(Value::number(3.0), Value::number(3.0), Value::number(3.0))
);
}
#[test]
fn load_model_refuses_a_range_with_a_cell_without_a_formula() {
let mut s = s();
set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2")]);
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6", "A1 <= 3"], "method": "simplex"});
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1", "model": model})).unwrap()["range"], "H1:H6");
// A range with empty cells after the model loads it.
let loaded = s.execute("data.solverLoadModel", json!({"from": "H1:H9"})).unwrap();
assert_eq!(loaded["model"]["constraints"], json!(["B2 <= 24", "B3 <= 6", "A1 <= 3"]), "{loaded}");
s.execute("data.solverReset", json!({})).unwrap();
// A constraint overwritten by a constant would drop that constraint; a gap before the
// options row would drop the options. Both are refused, naming the cell.
for (cell, input) in [("H4", "7"), ("H5", "")] {
let before = formula(&s, cell);
set(&mut s, &[(cell, input)]);
let e = s.execute("data.solverLoadModel", json!({"from": "H1:H6"})).unwrap_err().to_string();
assert!(e.contains(cell), "{e}");
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null(), "{cell}");
set(&mut s, &[(cell, &format!("={before}"))]);
}
assert!(s.execute("data.solverLoadModel", json!({"from": "H1:H6"})).is_ok());
}
#[test]
fn load_model_refuses_a_model_cut_short_and_keeps_the_old_one() {
let mut s = s();
set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2")]);
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6", "A1 <= 3"], "method": "simplex"});
s.execute("data.solver", model).unwrap();
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1"})).unwrap()["range"], "H1:H6");
let kept = |s: &mut Session| {
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
assert_eq!((m["constraints"].clone(), m["method"].clone()), (json!(["B2 <= 24", "B3 <= 6", "A1 <= 3"]), json!("simplex")), "{m}");
};
// A constraint cleared (H4), the options row cleared (H6) or replaced by a constant: from
// the first cell or as a range, the rows above the gap are not loaded as the whole model.
for (cell, input) in [("H4", ""), ("H6", ""), ("H6", "7")] {
let before = formula(&s, cell);
set(&mut s, &[(cell, input)]);
for from in ["H1", "H1:H6", "H1:H9"] {
let e = s.execute("data.solverLoadModel", json!({"from": from})).unwrap_err().to_string();
assert!(e.contains("complete model") || e.contains(cell), "{cell} {from}: {e}");
kept(&mut s);
}
set(&mut s, &[(cell, &format!("={before}"))]);
}
// An options row cut short or edited by hand: fewer than the eight values Save Model
// writes, or a value that isn't a number (among the eight, after them or after the sixteenth).
let options = formula(&s, "H6");
for row in [
"={1}",
"={2,1,100,0.075,0,1,0.0001}",
"={2,1,100,0.075,0,1,\"x\",50}",
"={2,TRUE,100,0.075,0,1,0.0001,50}",
"={2,1,100,0.075,0,1,0.0001,50,\"x\"}",
"={2,1,100,0.075,0,1,0.0001,50,9,10,11,12,13,14,15,16,\"x\"}",
] {
set(&mut s, &[("H6", row)]);
for from in ["H1", "H1:H6"] {
let e = s.execute("data.solverLoadModel", json!({"from": from})).unwrap_err().to_string();
assert!(e.contains("complete model"), "{row} {from}: {e}");
kept(&mut s);
}
}
// More values than eight (as a later version may write) still load.
set(&mut s, &[("H6", &format!("={}", options.trim_end_matches('}').to_string() + ",7,8}"))]);
assert!(s.execute("data.solverLoadModel", json!({"from": "H1"})).is_ok());
kept(&mut s);
set(&mut s, &[("H6", &format!("={options}"))]);
// The gap is named when reading down from one cell.
set(&mut s, &[("H4", "")]);
let e = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap_err().to_string();
assert!(e.contains("H4 holds no formula"), "{e}");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.execute("data.solverLoadModel", json!({"from": "H1"})).is_ok());
kept(&mut s);
}
#[test]
fn load_model_reads_a_bounded_column_and_refuses_malformed_cells() {
let mut s = s();
set(&mut s, &[("B1", "=ABS(A1-3)")]);
let put = |s: &mut Session, col: u32, rows: std::ops::Range<u32>, f: &dyn Fn(u32) -> String| {
let d = s.doc_mut().unwrap();
let sh = std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.wb).sheets[0]);
for r in rows {
sh.set_cell(CellRef::new(r, col), gridcraft_model::Cell::formula(gridcraft_model::Formula::new(&f(r))));
}
};
// A column of formulas far longer than any model: read from its first cell, the read stops
// after the most rows a model takes and the column is refused; as a range, it is refused
// before any cell is read.
put(&mut s, 15, 0..5_000, &|r| format!("{r}+1"));
for from in ["P1", "P1:P5000", "P1:P1048576", "A1:XFD1048576"] {
assert!(s.execute("data.solverLoadModel", json!({"from": from})).is_err(), "{from}");
}
// Malformed and hostile cells and ranges: an error each, never a panic, no model.
let bad = ["AND(", "{", "{1e400,2", "MAX(Nope!A1)", "COUNT($A$1:$A$2", "AND($B$1<=", "MAX($B$1)&\"x\""];
put(&mut s, 17, 0..bad.len() as u32, &|r| bad[r as usize].to_string());
put(&mut s, 18, 0..1, &|_| format!("AND({})", "(".repeat(10_000)));
for r in 1..=bad.len() {
assert!(s.execute("data.solverLoadModel", json!({"from": format!("R{r}")})).is_err(), "R{r}");
}
for from in ["S1", "", ":", "P1:", "H0", "Sheet9!H1", "'Sheet1'!R1:R7", "R7:R1"] {
assert!(s.execute("data.solverLoadModel", json!({"from": from})).is_err(), "{from}");
}
assert!(s.execute("data.solverLoadModel", json!({"from": 7})).is_err());
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null());
}
#[test]
fn load_model_keeps_the_evolutionary_convergence_and_generations() {
let mut s = s();
set(&mut s, &[("B1", "=ABS(A1-3)")]);
let evo = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "evolutionary", "convergence": 0.001, "generationsWithoutImprovement": 20});
s.execute("data.solverSaveModel", json!({"to": "H1", "model": evo})).unwrap();
let m = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap()["model"].clone();
assert_eq!((m["convergence"].as_f64(), m["generationsWithoutImprovement"].as_f64()), (Some(0.001), Some(20.0)), "{m}");
// They survive XLSX; Excel's time limit (solver_mni, in seconds) isn't written or read.
let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "c.xlsx", "base64": b64})).unwrap();
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
assert_eq!((m["convergence"].as_f64(), m["generationsWithoutImprovement"].as_f64()), (Some(0.001), Some(20.0)), "{m}");
assert!(!s.doc().unwrap().wb.names.iter().any(|n| n.name == "solver_mni"));
// Reset All removes the GridCraft name with the rest.
s.execute("data.solverReset", json!({})).unwrap();
assert!(s.doc().unwrap().wb.names.iter().all(|n| n.scope != Some(0)));
}
#[test]
fn solver_options_are_saved_with_the_model() {
let mut s = s();
set(&mut s, &[("B1", "=10*A1+13*A2"), ("B2", "=5*A1+6*A2")]);
let p = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 10", "A1:A2 bin"], "method": "evolutionary", "maxEvaluations": "3000", "integerOptimality": 5, "convergence": 0.001, "generationsWithoutImprovement": 20, "mutationRate": "0.1"});
let r = s.execute("data.solver", p).unwrap();
assert!(r["evaluations"].as_u64().unwrap() <= 3001, "{r}");
// The solve's own result gives whole numbers as integers too.
for k in ["maxEvaluations", "integerOptimality", "generationsWithoutImprovement", "populationSize", "randomSeed"] {
assert!(r["model"][k].is_i64() || r["model"][k].is_u64(), "{k}: {r}");
}
// The options survive XLSX in the hidden names, as given or as text.
let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "o.xlsx", "base64": b64})).unwrap();
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
let f = |k: &str| m[k].as_f64();
assert_eq!((f("maxEvaluations"), f("integerOptimality"), f("convergence")), (Some(3000.0), Some(5.0), Some(0.001)), "{m}");
assert_eq!((f("generationsWithoutImprovement"), f("mutationRate")), (Some(20.0), Some(0.1)));
// Whole numbers read back as integers, so the Solver dialog shows 3000 and not 3000.0.
for k in ["maxEvaluations", "integerOptimality", "generationsWithoutImprovement", "populationSize", "randomSeed"] {
assert!(m[k].is_i64(), "{k}: {m}");
}
assert_eq!(m["maxEvaluations"].to_string(), "3000");
// Out-of-range options are clamped, not refused.
let r = s.execute("data.solver", json!({"saved": true, "maxEvaluations": "1e300", "integerOptimality": -4})).unwrap();
assert_eq!((r["model"]["maxEvaluations"].as_f64(), r["model"]["integerOptimality"].as_f64()), (Some(200_000.0), Some(0.0)));
}
#[test]
fn excel_iteration_and_time_limits_are_not_read_as_gridcraft_options() {
let mut s = s();
set(&mut s, &[("A1", "0"), ("B1", "=ABS(A1-3)")]);
let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "evolutionary"});
s.execute("data.solver", model.clone()).unwrap();
let names = |s: &Session| -> Vec<String> {
let d = s.doc().unwrap();
d.wb.names.iter().filter(|n| n.scope == Some(0)).map(|n| n.name.clone()).collect()
};
// GridCraft's evaluation limit and generations go under its own names, not Excel's.
let saved = names(&s);
assert!(saved.iter().all(|n| n != "solver_itr" && n != "solver_mni"), "{saved:?}");
assert!(saved.iter().any(|n| n == "gridcraft_solver_evals") && saved.iter().any(|n| n == "gridcraft_solver_stall"), "{saved:?}");
// Excel's own limits, as a workbook from Excel holds them: 100 iterations, 30 seconds.
{
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
wb.names.retain(|n| !n.name.starts_with("gridcraft_"));
for (name, formula) in [("solver_itr", "100"), ("solver_mni", "30")] {
wb.names.push(gridcraft_model::DefinedName {
name: name.into(),
scope: Some(0),
formula: formula.into(),
comment: String::new(),
hidden: true,
});
}
}
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
assert!(m["maxEvaluations"].is_null() && m["generationsWithoutImprovement"].is_null(), "{m}");
set(&mut s, &[("A1", "0")]);
let r = s.execute("data.solver", json!({"saved": true})).unwrap();
assert!(r["evaluations"].as_u64().unwrap() > 100, "{r}");
assert!((n(&s, "A1") - 3.0).abs() < 1e-3, "{r}");
// Saving again replaces GridCraft's names; Reset All removes them with the rest. Simplex
// LP refuses the nonlinear ABS model, but the model is saved before the solve.
let r = s.execute(
"data.solver",
json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex", "maxEvaluations": 700, "keep": false}),
);
let m = super::saved_model(&s.doc().unwrap().wb, 0).unwrap();
assert_eq!((m["method"].as_str(), m["maxEvaluations"].as_f64()), (Some("simplex"), Some(700.0)), "{m} {r:?}");
let saved = names(&s);
assert_eq!(
saved.iter().filter(|n| n.starts_with("gridcraft_")).collect::<Vec<_>>(),
["gridcraft_solver_evals", "gridcraft_solver_stall"],
"{saved:?}"
);
s.execute("data.solver", model).unwrap();
s.execute("data.solverReset", json!({})).unwrap();
assert!(names(&s).is_empty(), "{:?}", names(&s));
}
#[test]
fn integer_optimality_reads_a_percent_sign_as_percent() {
let mut s = s();
set(&mut s, &[("B1", "=3*A1")]);
for (given, want) in [(json!("5%"), 5.0), (json!(" 2.5 % "), 2.5), (json!("5"), 5.0), (json!(5), 5.0), (json!("150%"), 100.0)] {
let p = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4", "A1 int"], "method": "simplex", "integerOptimality": given});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["model"]["integerOptimality"].as_f64(), Some(want), "{given}: {r}");
}
// Percents whose fraction is not exact in binary read back as given, from the solve's
// result and from the saved model.
for given in [7.0, 29.0, 0.1] {
let p = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4", "A1 int"], "method": "simplex", "integerOptimality": given});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["model"]["integerOptimality"].as_f64(), Some(given), "{r}");
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
assert_eq!(m["integerOptimality"].as_f64(), Some(given), "{m}");
}
}
#[test]
fn evolutionary_options_survive_a_solve_with_another_method() {
let mut s = s();
set(&mut s, &[("B1", "=3*A1")]);
let p = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "grg", "populationSize": 40, "mutationRate": 0.2, "randomSeed": 7, "convergence": 0.001, "generationsWithoutImprovement": 25, "requireBounds": false});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["model"]["populationSize"].as_f64(), Some(40.0), "{r}");
// The saved model keeps them for the next time the method is Evolutionary.
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
let f = |k: &str| m[k].as_f64();
assert_eq!((f("populationSize"), f("mutationRate"), f("randomSeed")), (Some(40.0), Some(0.2), Some(7.0)), "{m}");
assert_eq!((f("convergence"), f("generationsWithoutImprovement"), m["requireBounds"].as_bool()), (Some(0.001), Some(25.0), Some(false)));
}
#[test]
fn limits_report_of_a_nonlinear_model() {
// The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the