diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index b56502282..8477ef25f 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). @@ -74,7 +78,7 @@ pub fn specs() -> Vec { "Solver…", ["Data", "Analysis"], None, - "{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden solver_* names), maxEvaluations?: 20000, integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override), reports?: [answer|limits] (written on new sheets when a solution is found)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, original: {cell: value}, model: the model as params, evaluations, changed, reports?: [sheet names]}. No `objective` → {model: the saved model} (the menu opens the dialog).", + "{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden names, solver_* as Excel names them, with the options; the evaluation limit and generations without improvement, which Excel lacks, as gridcraft_solver_evals and gridcraft_solver_stall), maxEvaluations?: 20000 (at most 200000), integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override), reports?: [answer|limits] (written on new sheets when a solution is found)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, original: {cell: value}, model: the model as params, evaluations, changed, reports?: [sheet names]}. No `objective` → {model: the saved model} (the menu opens the dialog).", has_doc, run ), @@ -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, maximum evaluations, integer optimality %}, 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 + ), ] } @@ -182,12 +213,7 @@ fn run(s: &mut Session, p: &Json) -> Result { return Ok(json!({"model": model})); } let model = parse_model(s, ¶ms)?; - 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 gap = params.get("integerOptimality").and_then(Json::as_f64).filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0); + let (budget, gap) = (model.max_evals, model.gap); let keep = bool_param(¶ms, "keep").unwrap_or(true); let save = bool_param(¶ms, "saveModel").unwrap_or(true); let reports = report_kinds(CMD, ¶ms)?; diff --git a/crates/engine/src/cmd/solver/model.rs b/crates/engine/src/cmd/solver/model.rs index 0018172bc..e70d117c7 100644 --- a/crates/engine/src/cmd/solver/model.rs +++ b/crates/engine/src/cmd/solver/model.rs @@ -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 { "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::>() .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 { - 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) { @@ -408,11 +461,21 @@ 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"), + (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> { + 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 { + 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"), + (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 { + 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) +} + +/// 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) } +} 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..1874a8871 --- /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, 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) -> 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/reports.rs b/crates/engine/src/cmd/solver/reports.rs index bfcbb582e..f926c76ae 100644 --- a/crates/engine/src/cmd/solver/reports.rs +++ b/crates/engine/src/cmd/solver/reports.rs @@ -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 { }; 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)?; diff --git a/crates/engine/src/cmd/solver/tests.rs b/crates/engine/src/cmd/solver/tests.rs index 3f570fc6e..816f38ceb 100644 --- a/crates/engine/src/cmd/solver/tests.rs +++ b/crates/engine/src/cmd/solver/tests.rs @@ -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 = (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, 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: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, 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 { + 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::>(), + ["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 diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2bbb2eba2..9b04f1384 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -663,19 +663,31 @@ 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() { v.insert("newOp".into(), json!("<=")); } + let options = values.get("optionsView").and_then(Json::as_bool) == Some(true); let mut d = Dialog::custom("solver", "Solver Parameters", values); d.command = Some("data.solver"); + // Straight into the Solver Options: the same setup as the Options button, so the + // fields show their defaults and Cancel restores what was there. + if options { + analysis::open_solver_options(&mut d); + } d } "solverResults" => Dialog::custom("solverResults", "Solver Results", params.clone()), diff --git a/crates/ui-egui/src/dialogs/analysis.rs b/crates/ui-egui/src/dialogs/analysis.rs index cc0dc2f9a..40e4028cb 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,122 @@ pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Di ok_cancel(ui, confirm, open); } +/// The Solver Options fields: (key, label, default). +const SOLVER_OPTIONS: [(&str, &str, &str); 7] = [ + ("maxEvaluations", "Max Evaluations:", "20000"), + ("integerOptimality", "Integer Optimality (%):", "0"), + ("convergence", "Convergence:", "0.0001"), + ("mutationRate", "Mutation Rate:", "0.075"), + ("populationSize", "Population Size:", "100"), + ("randomSeed", "Random Seed:", "0"), + ("generationsWithoutImprovement", "Maximum Generations without Improvement:", "50"), +]; + +/// Data › Solver › Options: the Solver Options in the Solver Parameters window. OK keeps the +/// changes for the next solve; Cancel restores them. +fn solver_options(ui: &mut egui::Ui, d: &mut Dialog, edit: impl Fn(&mut egui::Ui, &mut Dialog, &str, f32, &str)) { + let mut done = false; + egui::Grid::new("solverOptions").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + for (title, keys) in [("All Methods", &SOLVER_OPTIONS[..2]), ("Evolutionary", &SOLVER_OPTIONS[2..])] { + ui.label(egui::RichText::new(title).strong()); + ui.end_row(); + for (key, label, _) in keys { + ui.label(*label); + edit(ui, d, key, 100.0, label); + ui.end_row(); + } + } + }); + let mut bounds = d.values.get("requireBounds").and_then(Json::as_bool).unwrap_or(true); + if ui.checkbox(&mut bounds, "Require Bounds on Variables").changed() { + d.values.insert("requireBounds".into(), json!(bounds)); + } + ui.add_space(8.0); + // A row as tall as the buttons (see `ok_cancel`): a right-to-left layout straight in the + // window takes all the height it is offered. + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(" Cancel ").clicked() { + cancel_solver_options(d); + } + if ui.button(" OK ").clicked() { + done = true; + } + }); + }); + if done { + close_solver_options(d); + } +} + +/// Whether the Solver dialog shows its Solver Options. +pub(super) fn in_solver_options(d: &Dialog) -> bool { + d.name == "solver" && d.values.get("optionsView").and_then(Json::as_bool) == Some(true) +} + +/// Solver Options › Cancel (also Escape or closing the window there): back to the parameters +/// with the options as they were. +pub(super) fn cancel_solver_options(d: &mut Dialog) { + if let Some(old) = d.values.get("optionsBackup").and_then(Json::as_object).cloned() { + for (k, v) in old { + d.values.insert(k, v); + } + } + close_solver_options(d); +} + +/// Solver Parameters › Options (also opening the dialog with `optionsView`): shows the Solver +/// Options, each field with its value or its default, and keeps the values for Cancel. +pub(super) fn open_solver_options(d: &mut Dialog) { + let mut backup = serde_json::Map::new(); + for (key, _, default) in SOLVER_OPTIONS { + let v = d.values.get(key).cloned().unwrap_or_else(|| json!(default)); + backup.insert(key.into(), v.clone()); + d.values.insert(key.into(), v); + } + backup.insert("requireBounds".into(), d.values.get("requireBounds").cloned().unwrap_or(json!(true))); + d.values.insert("optionsBackup".into(), Json::Object(backup)); + d.values.insert("optionsView".into(), json!(true)); + d.title = "Solver Options".into(); +} + +fn close_solver_options(d: &mut Dialog) { + d.values.insert("optionsView".into(), json!(false)); + d.values.remove("optionsBackup"); + d.title = "Solver Parameters".into(); +} + +/// Removes the model's options (the Solver Options fields and Require Bounds) from the 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. +fn clear_solver_options(d: &mut Dialog) { + for (key, _, _) in SOLVER_OPTIONS { + d.values.remove(key); + } + d.values.remove("requireBounds"); +} + +/// 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; Options shows the Solver Options +/// in the same window. +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| { @@ -180,6 +293,10 @@ pub(super) fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open d.values.insert(k.into(), json!(t)); } }; + if d.values.get("optionsView").and_then(Json::as_bool) == Some(true) { + solver_options(ui, d, edit); + return; + } ui.horizontal(|ui| { ui.label("Set Objective:"); edit(ui, d, "objective", 200.0, "Set Objective:"); @@ -282,16 +399,107 @@ 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); + } + // The options go back to their defaults too. + clear_solver_options(d); + for key in ["optionsBackup", "modelNote", "askModelOverwrite"] { + d.values.remove(key); + } + } + 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 ui.button("Options").clicked() { + open_solver_options(d); + } + }); + 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()); - let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.accent)); - if solve.clicked() && !text(d, "objective").trim().is_empty() { - *confirm = true; - } - if ui.button(" Close ").clicked() { - *open = false; - } + // A row as tall as the buttons (see `ok_cancel`): the Load/Save area and the notes can make + // the form shorter, and a right-to-left layout straight in the window would fill what is + // left. + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let t = crate::theme::Tokens::get(ui.ctx()); + let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.accent)); + if solve.clicked() && !text(d, "objective").trim().is_empty() { + *confirm = true; + } + if ui.button(" Close ").clicked() { + *open = false; + } + }); }); } diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index a2e452fb0..008b23951 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -1,5 +1,5 @@ use super::about::about; -use super::analysis::{analysis_toolpak, goal_seek, solver, solver_results}; +use super::analysis::{analysis_toolpak, cancel_solver_options, goal_seek, in_solver_options, solver, solver_results}; use super::charts::{recommended_charts, select_data}; use super::command_search::command_search; use super::cond_format_rules::manage_rules; @@ -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" => { @@ -376,7 +376,12 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } } if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open { - open = false; + // In Solver Options, Escape (or closing the window) cancels the options only. + if in_solver_options(&d) { + cancel_solver_options(&mut d); + } else { + open = false; + } } if confirm && d.name == "insertPicture" && d.picture_target != app.view_key() { app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into())); diff --git a/crates/ui-egui/tests/it/dialog_button_row.rs b/crates/ui-egui/tests/it/dialog_button_row.rs index 5bbaca51f..7b34fdae4 100644 --- a/crates/ui-egui/tests/it/dialog_button_row.rs +++ b/crates/ui-egui/tests/it/dialog_button_row.rs @@ -26,3 +26,37 @@ fn ok_and_cancel_sit_under_the_last_field() { let gap = ok.top() - last.bottom(); assert!((0.0..40.0).contains(&gap), "OK is {gap} points under the last option"); } + +#[test] +/// Solver Options: OK sits just under Require Bounds on Variables, so the window fits its +/// content rather than covering the sheet. +fn solver_options_buttons_sit_under_the_last_option() { + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 1000.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + crate::common::app(s), + ); + h.state_mut().open_dialog("solver", json!({"optionsView": true})); + h.run_steps(4); + let last = h.get_by_label("Require Bounds on Variables").rect(); + let ok = h.get_by_label(" OK ").rect(); + let gap = ok.top() - last.bottom(); + assert!((0.0..40.0).contains(&gap), "OK is {gap} points under the last option"); + // The Solver Parameters' Solve button sits under its last row too. + h.state_mut().open_dialog("solver", json!({})); + h.run_steps(4); + let gap = |h: &egui_kittest::Harness<'_, SheetApp>| h.get_by_label(" Solve ").rect().top() - h.get_by_label("Load/Save").rect().bottom(); + let fresh = gap(&h); + assert!((0.0..40.0).contains(&fresh), "Solve is {fresh} points under the last row"); + // It stays there when the form gets shorter again: Load/Save shown, then hidden. + for _ in 0..2 { + h.get_by_label("Load/Save").click(); + h.run_steps(4); + } + assert!((gap(&h) - fresh).abs() < 1.0, "Solve is {} points under the last row, not {fresh}", gap(&h)); +} diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index e876d736f..4b6ff1a96 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -794,6 +794,289 @@ 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", "maxEvaluations": "7", "integerOptimality": "5", "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 = [ + "maxEvaluations", + "integerOptimality", + "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}"); + assert_eq!((saved["maxEvaluations"].as_f64(), saved["integerOptimality"].as_f64()), (Some(20000.0), Some(0.0)), "{saved}"); +} + +#[test] +fn solver_options_go_with_the_solve() { + let mut s = blank(); + for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let mut h = harness(s); + h.state_mut().open_dialog("solver", json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary"})); + h.run_steps(2); + // Switching between the parameters and the options changes the window's height, and it + // re-centres over the next frames, so each switch is followed by a few frames. + h.get_by_label("Options").click(); + h.run_steps(4); + assert_eq!(h.state().dialog.as_ref().unwrap().title, "Solver Options"); + // Cancel keeps the options as they were; OK keeps the edits. + let put = |h: &mut egui_kittest::Harness<'_, SheetApp>, k: &str, v: serde_json::Value| { + h.state_mut().dialog.as_mut().unwrap().values.insert(k.into(), v); + }; + put(&mut h, "maxEvaluations", json!("7")); + h.get_by_label(" Cancel ").click(); + h.run_steps(4); + let d = h.state().dialog.as_ref().unwrap(); + assert_eq!((d.title.as_str(), d.values.get("maxEvaluations")), ("Solver Parameters", Some(&json!("20000")))); + h.get_by_label("Options").click(); + h.run_steps(4); + put(&mut h, "requireBounds", json!(false)); + put(&mut h, "randomSeed", json!("11")); + h.get_by_label(" OK ").click(); + h.run_steps(4); + 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); + assert!(matches!(value(&h, "A1"), Value::Number(v) if (v - 4.0).abs() < 1e-6)); + let saved = gridcraft_engine::cmd::solver::saved_model(&h.state().session.active().unwrap().wb, 0).unwrap(); + assert_eq!((saved["randomSeed"].as_f64(), saved["requireBounds"].as_bool()), (Some(11.0), Some(false))); +} + +#[test] +fn solver_reset_all_resets_the_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(); + } + let mut h = harness(s); + let options = json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary", "maxEvaluations": "7", "integerOptimality": "5", "randomSeed": "11", "requireBounds": false}); + h.state_mut().open_dialog("solver", options); + h.run_steps(2); + h.get_by_label("Reset All").click(); + h.run_steps(2); + let d = h.state().dialog.as_ref().unwrap(); + for key in ["maxEvaluations", "integerOptimality", "randomSeed", "requireBounds"] { + assert_eq!(d.values.get(key), None, "{key}"); + } + // Options shows the defaults again. + h.get_by_label("Options").click(); + h.run_steps(2); + let d = h.state().dialog.as_ref().unwrap(); + assert_eq!((d.values.get("maxEvaluations"), d.values.get("randomSeed")), (Some(&json!("20000")), Some(&json!("0")))); +} + +#[test] +fn escape_in_solver_options_cancels_the_options_only() { + let mut s = blank(); + for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let mut h = harness(s); + h.state_mut().open_dialog("solver", json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary"})); + h.run_steps(2); + // A parameter edited before opening Options stays; the option edited there is undone. + h.state_mut().dialog.as_mut().unwrap().values.insert("constraints".into(), json!(["A1 <= 9"])); + h.get_by_label("Options").click(); + h.run_steps(2); + h.state_mut().dialog.as_mut().unwrap().values.insert("maxEvaluations".into(), json!("7")); + key(&mut h, Key::Escape, Modifiers::NONE); + let d = h.state().dialog.as_ref().expect("Escape leaves the Solver dialog open"); + assert_eq!((d.title.as_str(), d.values.get("maxEvaluations")), ("Solver Parameters", Some(&json!("20000")))); + assert_eq!(d.values.get("constraints"), Some(&json!(["A1 <= 9"]))); + // In the parameters, Escape closes the dialog as before. + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); +} + +#[test] +fn solver_dialog_opened_in_its_options_shows_the_defaults_and_cancel_restores_them() { + let mut s = blank(); + for (c, v) in [("A1", "1"), ("B1", "=ABS(A1-4)")] { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + let mut h = harness(s); + let params = + json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 10"], "method": "evolutionary", "optionsView": true}); + let put = |h: &mut egui_kittest::Harness<'_, SheetApp>, k: &str, v: serde_json::Value| { + h.state_mut().dialog.as_mut().unwrap().values.insert(k.into(), v); + }; + let get = |h: &egui_kittest::Harness<'_, SheetApp>, k: &str| h.state().dialog.as_ref().unwrap().values.get(k).cloned(); + // With no saved model, the fields show the defaults, as after the Options button. + h.state_mut().open_dialog("solver", params.clone()); + h.run_steps(4); + assert_eq!(h.state().dialog.as_ref().unwrap().title, "Solver Options"); + assert!(h.query_all_by_label("Max Evaluations:").next().is_some()); + assert_eq!( + (get(&h, "maxEvaluations"), get(&h, "randomSeed"), get(&h, "integerOptimality")), + (Some(json!("20000")), Some(json!("0")), Some(json!("0"))) + ); + // Cancel restores them. + put(&mut h, "randomSeed", json!("11")); + put(&mut h, "requireBounds", json!(false)); + h.get_by_label(" Cancel ").click(); + h.run_steps(4); + assert_eq!(h.state().dialog.as_ref().unwrap().title, "Solver Parameters"); + assert_eq!((get(&h, "randomSeed"), get(&h, "requireBounds")), (Some(json!("0")), Some(json!(true)))); + // So does Escape. + h.state_mut().dialog = None; + h.state_mut().open_dialog("solver", params); + h.run_steps(4); + put(&mut h, "maxEvaluations", json!("7")); + key(&mut h, Key::Escape, Modifiers::NONE); + h.run_steps(2); + assert_eq!((h.state().dialog.as_ref().unwrap().title.as_str(), get(&h, "maxEvaluations")), ("Solver Parameters", Some(json!("20000")))); + // The next Solve uses the defaults. + 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["maxEvaluations"].as_f64(), saved["randomSeed"].as_f64(), saved["requireBounds"].as_bool()), + (Some(20000.0), Some(0.0), Some(true)), + "{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..85b26a355 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 Options; earlier: §13: Solver Load/Save Model and Reset All) · **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,10 @@ 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. Solver Options sets the + evaluation limit, Integer Optimality and the Evolutionary options, which are saved with the + model. AllDifferent constraints 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 +185,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | §13: Solver Options (Max Evaluations, Integer Optimality and the Evolutionary options), saved with the model | +| 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..58d2d1e29 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 Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; 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, Reset All and Solver Options; no Sensitivity report), 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,8 @@ 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 Options (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 |