diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index 2cb3da434..1d2683982 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -36,6 +36,7 @@ use gridcraft_core::split_sheet; mod evolutionary; mod lp; mod model; +mod model_range; mod nlp; mod problem; mod reports; @@ -44,12 +45,15 @@ use evolutionary::{EvoEnd, evolutionary}; use lp::{Outcome, Sense, branch_and_bound, linearize, solve_lp}; pub use model::saved_model; use model::{Goal, Method, Model, model_params, parse_model, save_model}; +use model_range::{load_model_cells, reset_model, save_model_cells}; use nlp::{nlp, project}; use problem::{Evaluator, as_num, build_problem}; use reports::{ReportInput, insert_report_selections, prepare_reports, report, report_kinds, write_reports}; const CMD: &str = "data.solver"; const REPORT_CMD: &str = "data.solverReport"; +const SAVE_CMD: &str = "data.solverSaveModel"; +const LOAD_CMD: &str = "data.solverLoadModel"; /// Excel's limit on decision variables. const MAX_VARS: usize = 200; /// Most scalar constraints (after expanding ranges). @@ -87,6 +91,33 @@ pub fn specs() -> Vec { has_doc, report ), + cmd!( + "data.solverSaveModel", + "Save Model", + [], + None, + "{to: \"H1\" (the first cell, on the model's sheet), model?: data.solver params (default the sheet's saved model), overwrite?: false} → {range}. Writes the model down a column as formulas: =MAX(objective), =MIN(objective) or =objective=value; =COUNT(variables); one =AND(relation) per constraint (=AND(x=INT(x)) for integer, =AND((x=0)+(x=1)) for binary), which shows TRUE while it holds; and the options as an array constant {method (1 GRG Nonlinear, 2 Simplex LP, 3 Evolutionary), non-negative (1 yes, 2 no), population size, mutation rate, random seed, require bounds (1 yes, 2 no), convergence, generations without improvement}, which spills into the cells to its right. Refused, with the sheet unchanged, when the model's cells, content, another formula's spilled values, merged cells or a table are in the way of the options' spill, or it would run off the sheet, and when the column would cover another formula's spilled values. A column that holds a value or a formula (a model saved there before included) is replaced only with overwrite: true; without it the command is refused (\"already holds data\"), as Excel asks first. One undo step.", + has_doc, + save_model_cells + ), + cmd!( + "data.solverLoadModel", + "Load Model", + [], + None, + "{from: \"H1:H6\" (a range on the active sheet; one cell reads down to the first cell without a formula; a range needs a formula in every cell up to its last formula, and may end in empty cells)} → {model}. Reads a model written by Save Model and makes it the sheet's Solver model. The model must end with its options row ({…}), which Save Model writes last; a model cut short (a cell before the options row without a formula, or the options row cleared) is refused and the sheet's model stays as it was.", + has_doc, + load_model_cells + ), + cmd!( + "data.solverReset", + "Reset All", + [], + None, + "{} → {removed, masked}. Removes the active sheet's Solver model. A model read from workbook-level names stays for the other sheets and is masked on this one by an empty sheet-level solver_opt (masked: true).", + has_doc, + reset_model + ), ] } diff --git a/crates/engine/src/cmd/solver/model.rs b/crates/engine/src/cmd/solver/model.rs index d959b5907..af2dbdb42 100644 --- a/crates/engine/src/cmd/solver/model.rs +++ b/crates/engine/src/cmd/solver/model.rs @@ -293,8 +293,9 @@ pub(super) fn parse_model(s: &Session, p: &Json) -> Result { // ---------------------------------------------------------------- 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) @@ -302,16 +303,46 @@ 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::>() .join(",") } +fn ref_text(sheet_name: &str, cells: &str) -> String { + abs_text(Some(sheet_name), cells) +} + +/// The generations without improvement have no Excel equivalent: Excel's `solver_mni` is a time +/// in seconds. They are saved under a GridCraft name, which Excel ignores. +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 == 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 }); }; @@ -354,21 +385,24 @@ pub(super) fn save_model(wb: &mut Workbook, m: &Model) { 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 { - 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| 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::>().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) { @@ -407,7 +441,13 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option { "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"), + ] { if let Some(v) = get(name).and_then(|v| num_text(&v)) { model[key] = json!(v); } @@ -460,3 +500,163 @@ pub(super) fn model_params(wb: &Workbook, m: &Model) -> Json { } out } + +// ---------------------------------------------------------------- model on cells + +/// The cells Save Model writes, top to bottom, as formula text. +pub(super) fn model_formulas(m: &Model) -> Result> { + 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 + )); + Ok(rows) +} + +/// `NAME(inner)` → inner, ignoring case. +fn call_args(t: &str, name: &str) -> Option { + 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::>().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 = 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")] { + 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 { + 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 { + 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) +} diff --git a/crates/engine/src/cmd/solver/model_range.rs b/crates/engine/src/cmd/solver/model_range.rs new file mode 100644 index 000000000..7d47b7176 --- /dev/null +++ b/crates/engine/src/cmd/solver/model_range.rs @@ -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 { + let old = formulas_down(sheet, start); + let rows: Vec = 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 { + 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, ¶ms)?; + 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 = saved_model_cells(sh, to).into_iter().filter(|c| !range.contains(*c) && !used.contains(c)).collect(); + let ours: Vec = 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 = 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 }); + } + 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) -> Result> { + let cells: Vec<(CellRef, Option)> = 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 { + 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 = 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, ¶ms)?; + 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 { + 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})) +} diff --git a/crates/engine/src/cmd/solver/tests.rs b/crates/engine/src/cmd/solver/tests.rs index 3f570fc6e..bfe3b0af7 100644 --- a/crates/engine/src/cmd/solver/tests.rs +++ b/crates/engine/src/cmd/solver/tests.rs @@ -400,6 +400,422 @@ 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 = (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}", + ] + ); + // 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"].is_null()), (Some("B1"), true), "{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, 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 { + let mut v: Vec = 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:O4: a value in its last cell is in the way. + set(&mut s, &[("O4", "x")]); + let e = save(&mut s, "H1").unwrap_err().to_string(); + assert!(e.contains("I4:O4") && e.contains("O4 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, &[("O4", "")]); + 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")), + (Value::number(1.0), Value::number(1.0), Value::number(50.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:Z60, but a table is in the way"), + ("XFA1", "need 8 cells"), + ("I21", "cover I24, which holds the values of another formula"), + ("M51", "cover M51, which holds the values of another formula"), + ("W67", "X70:AD70, 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, 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 limits_report_of_a_nonlinear_model() { // The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2bbb2eba2..77092428d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -663,12 +663,18 @@ impl Dialog { d } "solver" => { - // Starts from the sheet's saved model, else the active cell as objective. + // Starts from the sheet's saved model, else the active cell as objective; fields + // given when opening it go over either. let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet())); let mut values = json!({"objective": active, "goal": "max", "value": "0", "variables": "", "constraints": [], "nonNegative": true, "method": "grg"}); - if let (Some(m), Some(v)) = (saved.as_ref().and_then(Json::as_object), values.as_object_mut()) { - for (k, x) in m { - v.insert(k.clone(), if k == "value" { json!(x.to_string()) } else { x.clone() }); + for m in [saved.as_ref(), Some(¶ms)].into_iter().flatten().filter_map(Json::as_object) { + if let Some(v) = values.as_object_mut() { + for (k, x) in m { + v.insert( + k.clone(), + if k == "value" { json!(x.as_str().map_or_else(|| x.to_string(), str::to_string)) } else { x.clone() }, + ); + } } } if let Some(v) = values.as_object_mut() { diff --git a/crates/ui-egui/src/dialogs/analysis.rs b/crates/ui-egui/src/dialogs/analysis.rs index cc0dc2f9a..dcdf98301 100644 --- a/crates/ui-egui/src/dialogs/analysis.rs +++ b/crates/ui-egui/src/dialogs/analysis.rs @@ -1,4 +1,4 @@ -use super::show::{form_fields, ok_cancel}; +use super::show::{build_params, form_fields, ok_cancel}; use super::{Dialog, Field, Json, Map, SheetApp, json, vec2}; pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { @@ -169,9 +169,38 @@ pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Di ok_cancel(ui, confirm, open); } +/// The keys of the model's options in the Solver Parameters form. A key that is absent means +/// the option's default, so Reset All and Load Model remove them all before a model of their +/// own; an option of the model before would otherwise go with the next solve. +const SOLVER_OPTION_KEYS: [&str; 6] = + ["convergence", "mutationRate", "populationSize", "randomSeed", "generationsWithoutImprovement", "requireBounds"]; + +/// Removes the model's options from the form, so the next solve uses their defaults. +fn clear_solver_options(d: &mut Dialog) { + for key in SOLVER_OPTION_KEYS { + d.values.remove(key); + } +} + +/// Save Model from the Solver Parameters form: writes the form's model down from `range`. A +/// range that holds data is replaced only with `overwrite`; without it the form asks first. +fn save_solver_model(app: &mut SheetApp, d: &mut Dialog, range: &str, overwrite: bool) { + d.values.remove("askModelOverwrite"); + let note = match app.run("data.solverSaveModel", json!({"to": range.trim(), "model": build_params(d), "overwrite": overwrite})) { + Ok(r) => format!("The model was saved to {}.", r.get("range").and_then(Json::as_str).unwrap_or("")), + Err(e) if !overwrite && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => { + d.values.insert("askModelOverwrite".into(), json!(range.trim())); + crate::clean_error(&e) + } + Err(e) => crate::clean_error(&e), + }; + d.values.insert("modelNote".into(), json!(note)); +} + /// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's -/// command; the result opens Solver Results. -pub(super) fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { +/// command; the result opens Solver Results. Reset All clears the form and the sheet's model; +/// Load/Save reads or writes the model on cells of the sheet. +pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { let lang = crate::i18n::current(ui.ctx()); let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default(); let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32, label: &str| { @@ -282,6 +311,87 @@ pub(super) fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open }) .small(), ); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button("Reset All").clicked() { + match app.run("data.solverReset", json!({})) { + Ok(_) => { + for (k, v) in [ + ("objective", json!("")), + ("goal", json!("max")), + ("value", json!("0")), + ("variables", json!("")), + ("constraints", json!([])), + ("nonNegative", json!(true)), + ("method", json!("grg")), + ] { + d.values.insert(k.into(), v); + } + clear_solver_options(d); + d.values.remove("modelNote"); + d.values.remove("askModelOverwrite"); + } + Err(e) => { + d.values.insert("modelNote".into(), json!(crate::clean_error(&e))); + } + } + } + let shown = d.values.get("modelArea").and_then(Json::as_bool).unwrap_or(false); + if ui.selectable_label(shown, "Load/Save").clicked() { + d.values.insert("modelArea".into(), json!(!shown)); + } + }); + if d.values.get("modelArea").and_then(Json::as_bool) == Some(true) { + ui.horizontal(|ui| { + ui.label("Model Range:"); + edit(ui, d, "modelRange", 140.0, "Model Range:"); + let range = text(d, "modelRange"); + let ready = !range.trim().is_empty(); + if ui.add_enabled(ready, egui::Button::new("Load")).clicked() { + d.values.remove("askModelOverwrite"); + match app.run("data.solverLoadModel", json!({"from": range.trim()})) { + Ok(r) => { + if let Some(m) = r.get("model").and_then(Json::as_object) { + // The loaded model's options only: one it omits takes its default. + clear_solver_options(d); + for (k, x) in m { + d.values.insert(k.clone(), if k == "value" { json!(x.to_string()) } else { x.clone() }); + } + } + d.values.insert("modelNote".into(), json!("The model was loaded.")); + } + Err(e) => { + d.values.insert("modelNote".into(), json!(crate::clean_error(&e))); + } + } + } + if ui.add_enabled(ready, egui::Button::new("Save")).clicked() { + save_solver_model(app, d, &range, false); + } + }); + // Save Model asks before it replaces data in the model range; the question goes when + // the range is edited, so Replace writes only where it asked about. + let asked = d.values.get("askModelOverwrite").and_then(Json::as_str).map(str::to_string); + if asked.as_deref().is_some_and(|a| a != text(d, "modelRange").trim()) { + d.values.remove("askModelOverwrite"); + d.values.remove("modelNote"); + } else if let Some(asked) = asked { + ui.horizontal_wrapped(|ui| { + ui.label(format!("{asked} already holds data. Replace it with the model?")); + if ui.button("Replace").clicked() { + save_solver_model(app, d, &asked, true); + } + if ui.button("Don't Replace").clicked() { + d.values.remove("askModelOverwrite"); + d.values.remove("modelNote"); + } + }); + } + } + let note = text(d, "modelNote"); + if !note.is_empty() { + ui.label(egui::RichText::new(note).small()); + } ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let t = crate::theme::Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index a2e452fb0..33c9a0d70 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -181,7 +181,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open), "analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open), - "solver" => solver(ui, &mut d, &mut confirm, &mut open), + "solver" => solver(app, ui, &mut d, &mut confirm, &mut open), "solverResults" => solver_results(app, ui, &mut d, &mut open), "spelling" => spelling(app, ui, &mut d, &mut open), "agents" => { diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index e876d736f..5411b3698 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -794,6 +794,147 @@ fn solver_dialog_loads_the_saved_model_and_results_can_restore() { assert_eq!(value(&h, "B1"), Value::Number(0.0)); } +#[test] +fn solver_dialog_saves_loads_and_resets_the_model() { + let mut s = blank(); + for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let mut h = harness(s); + h.state_mut().open_dialog("solver", json!({})); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + for (k, v) in [ + ("objective", json!("B1")), + ("variables", json!("A1:A2")), + ("constraints", json!(["B2 <= 12", "A1 <= 4"])), + ("modelArea", json!(true)), + ("modelRange", json!("H1")), + ] { + d.values.insert(k.into(), v); + } + } + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(2); + let note = |h: &egui_kittest::Harness<'_, SheetApp>| h.state().dialog.as_ref().unwrap().values.get("modelNote").cloned(); + assert_eq!(note(&h), Some(json!("The model was saved to H1:H5."))); + // Reset All clears the form; Load fills it again from the saved cells. + h.get_by_label("Reset All").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("objective"), Some(&json!(""))); + h.get_by_label("Load").click(); + h.run_steps(2); + let d = h.state().dialog.as_ref().unwrap(); + assert_eq!(d.values.get("objective"), Some(&json!("B1")), "{:?}", d.values.get("modelNote")); + assert_eq!(d.values.get("constraints"), Some(&json!(["B2 <= 12", "A1 <= 4"]))); + let saved = gridcraft_engine::cmd::solver::saved_model(&h.state().session.active().unwrap().wb, 0); + assert_eq!(saved.map(|m| m["variables"].clone()), Some(json!("A1:A2"))); +} + +#[test] +fn solver_dialog_asks_before_save_model_replaces_data() { + let mut s = blank(); + for (c, v) in [("A1", "0"), ("B1", "=3*A1"), ("H2", "kept")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let mut h = harness(s); + let form = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "modelArea": true, "modelRange": "H1"}); + h.state_mut().open_dialog("solver", form); + h.run_steps(2); + let values = |h: &egui_kittest::Harness<'_, SheetApp>| h.state().dialog.as_ref().unwrap().values.clone(); + // Save over data asks first and writes nothing; Don't Replace keeps the data. + h.get_by_label("Save").click(); + h.run_steps(2); + assert_eq!(values(&h).get("askModelOverwrite"), Some(&json!("H1"))); + assert_eq!(value(&h, "H2"), Value::text("kept")); + h.get_by_label("Don't Replace").click(); + h.run_steps(2); + assert_eq!((values(&h).get("askModelOverwrite"), value(&h, "H2")), (None, Value::text("kept"))); + // Editing the range drops the question, so Replace never writes somewhere else. + h.get_by_label("Save").click(); + h.run_steps(2); + h.state_mut().dialog.as_mut().unwrap().values.insert("modelRange".into(), json!("J1")); + h.run_steps(2); + assert_eq!(values(&h).get("askModelOverwrite"), None); + assert!(h.query_by_label("Replace").is_none()); + // Replace writes the model in one undo step. + h.state_mut().dialog.as_mut().unwrap().values.insert("modelRange".into(), json!("H1")); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(2); + h.get_by_label("Replace").click(); + h.run_steps(2); + assert_eq!(values(&h).get("modelNote"), Some(&json!("The model was saved to H1:H4."))); + assert_eq!(values(&h).get("askModelOverwrite"), None); + assert_eq!(value(&h, "H2"), Value::Number(1.0)); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(value(&h, "H2"), Value::text("kept")); +} + +#[test] +fn solver_reset_all_and_load_model_drop_the_old_models_options() { + let mut s = blank(); + for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + // A GRG Nonlinear model on cells, which has no Evolutionary options. + s.execute("data.solverSaveModel", json!({"to": "H1", "model": {"objective": "B1", "goal": "min", "variables": "A1"}})).unwrap(); + let mut h = harness(s); + let old = json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary", "requireBounds": false, "convergence": "0.5", "randomSeed": "11", "populationSize": "40", "mutationRate": "0.5", "generationsWithoutImprovement": "5", "modelArea": true, "modelRange": "H1"}); + h.state_mut().open_dialog("solver", old.clone()); + h.run_steps(2); + let keys = ["requireBounds", "convergence", "randomSeed", "populationSize", "mutationRate", "generationsWithoutImprovement"]; + let left = |h: &egui_kittest::Harness<'_, SheetApp>| -> Vec<&str> { + let d = h.state().dialog.as_ref().unwrap(); + keys.into_iter().filter(|k| d.values.contains_key(*k)).collect() + }; + let kept = |h: &egui_kittest::Harness<'_, SheetApp>| -> Vec<&str> { + let d = h.state().dialog.as_ref().unwrap(); + keys.into_iter().filter(|k| d.values.get(*k) == old.get(*k)).collect() + }; + // Load Model keeps none of the old options: the loaded model's own, or none. + h.get_by_label("Load").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values.get("method"), Some(&json!("grg"))); + assert_eq!(kept(&h), Vec::<&str>::new()); + // Reset All drops them. + { + let d = h.state_mut().dialog.as_mut().unwrap(); + for (k, v) in old.as_object().unwrap() { + d.values.insert(k.clone(), v.clone()); + } + } + h.run_steps(2); + h.get_by_label("Reset All").click(); + h.run_steps(2); + assert_eq!(left(&h), Vec::<&str>::new()); + // A new Evolutionary model then solves with the default options. + { + let d = h.state_mut().dialog.as_mut().unwrap(); + for (k, v) in [ + ("objective", json!("B1")), + ("goal", json!("min")), + ("variables", json!("A1")), + ("constraints", json!(["A1 <= 10"])), + ("method", json!("evolutionary")), + ] { + d.values.insert(k.into(), v); + } + } + h.run_steps(2); + h.get_by_label(" Solve ").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("solverResults"), "{:?}", h.state().message); + let saved = gridcraft_engine::cmd::solver::saved_model(&h.state().session.active().unwrap().wb, 0).unwrap(); + assert_eq!( + (saved["requireBounds"].as_bool(), saved["randomSeed"].as_f64(), saved["convergence"].as_f64()), + (Some(true), Some(0.0), Some(0.0001)), + "{saved}" + ); + assert_eq!((saved["populationSize"].as_f64(), saved["generationsWithoutImprovement"].as_f64()), (Some(100.0), Some(50.0)), "{saved}"); +} + #[test] fn solver_results_write_the_chosen_reports() { let mut s = blank(); diff --git a/docs/gaps.md b/docs/gaps.md index 7a1890324..6ddc296ae 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (§8: PivotTable Enable show details; earlier: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (§13: Solver Load/Save Model and Reset All; earlier: §8: PivotTable Enable show details) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -122,8 +122,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - Solver (`data.solver`, #327) has Simplex LP with integer/binary branch and bound, a local nonlinear method (augmented Lagrangian, not GRG), the Evolutionary method, and Answer and Limits reports. Report refusal leaves the solution, saved model and history unchanged; a - successful solve and its reports share one Undo step (#1050). AllDifferent constraints, - the Solver Options dialog, the Sensitivity and Population reports and Load/Save Model remain. + successful solve and its reports share one Undo step (#1050). Load/Save Model writes the + model on cells of its sheet and reads it back; Reset All removes it. AllDifferent constraints, + the Solver Options dialog and the Sensitivity and Population reports remain. The Analysis ToolPak (`data.analysisToolPak`) has 13 of 19 tools. Forecast Sheet (`data.forecastSheet`) and the FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 12–20 h. @@ -183,6 +184,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | §13: Solver Load/Save Model (`data.solverSaveModel`, `data.solverLoadModel`) and Reset All (`data.solverReset`), with buttons in the Solver Parameters dialog | | 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` | | 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) | | 2026-10-22 | minor | §20: a native window per workbook window, New Window on the same workbook and Arrange All on the desktop; left: Arrange All across monitors, the web's one window, pen pressure (estimate 10–14 h → 5–8 h) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 86837305b..0bf7008d6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (the Insert Function dialog is named as in Excel; no score change; earlier: automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (what-if and analysis: Solver Load/Save Model and Reset All; the Insert Function dialog is named as in Excel; no score change; earlier: automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -89,7 +89,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Kinds survive XLSX round trip (#158, #161). [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | -| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; no Sensitivity report or options dialog). Missing: Forecast Sheet, Analyze Data | +| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; Load/Save Model and Reset All; no Sensitivity report or options dialog), Forecast Sheet. Missing: Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | @@ -335,6 +335,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| | 2026-10-31 | minor | The Insert Function dialog is named as in Excel (it was called Formula Builder); no score change | +| 2026-10-31 | minor | What-if and analysis: Solver Load/Save Model and Reset All; Forecast Sheet moved from Missing to the list of tools, as gaps.md §13 records (no score change) | | 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) (no score change) | | 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) | | 2026-10-22 | minor | Several windows on the desktop: a native window per workbook window, New Window on the same workbook, Switch Windows and Arrange All; "one window" removed from UI/UX fidelity, Hardware and the interaction-fidelity discount; no score change |