diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index 1d2683982..95c0cb146 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -78,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 ), @@ -96,7 +96,7 @@ pub fn specs() -> Vec { "Save Model", [], None, - "{to: \"H1\" (the first cell, on the model's sheet), model?: data.solver params (default the sheet's saved model), overwrite?: false} → {range}. Writes the model down a column as formulas: =MAX(objective), =MIN(objective) or =objective=value; =COUNT(variables); one =AND(relation) per constraint (=AND(x=INT(x)) for integer, =AND((x=0)+(x=1)) for binary), which shows TRUE while it holds; and the options as an array constant {method (1 GRG Nonlinear, 2 Simplex LP, 3 Evolutionary), non-negative (1 yes, 2 no), population size, mutation rate, random seed, require bounds (1 yes, 2 no), convergence, generations without improvement}, which spills into the cells to its right. Refused, with the sheet unchanged, when the model's cells, content, another formula's spilled values, merged cells or a table are in the way of the options' spill, or it would run off the sheet, and when the column would cover another formula's spilled values. A column that holds a value or a formula (a model saved there before included) is replaced only with overwrite: true; without it the command is refused (\"already holds data\"), as Excel asks first. One undo step.", + "{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 ), @@ -213,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 af2dbdb42..d6b7d9b8c 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`. @@ -288,7 +292,17 @@ 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 @@ -313,8 +327,11 @@ fn ref_text(sheet_name: &str, cells: &str) -> String { abs_text(Some(sheet_name), cells) } -/// The generations without improvement have no Excel equivalent: Excel's `solver_mni` is a time -/// in seconds. They are saved under a GridCraft name, which Excel ignores. +/// 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 @@ -328,7 +345,7 @@ const SOLVER_KEYS: [&str; 28] = [ /// `solver_lhs1` and so on) and GridCraft's own. A user's name such as `solver_budget` is not. pub(super) fn is_model_name(name: &str) -> bool { let name = name.to_ascii_lowercase(); - if name == STALL_NAME { + if name == EVALS_NAME || name == STALL_NAME { return true; } let Some(key) = name.strip_prefix("solver_") else { return false }; @@ -380,14 +397,16 @@ 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("solver_cvg".into(), m.evo.convergence.to_string()); - put(STALL_NAME.into(), m.evo.stall.to_string()); - } + 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()); } @@ -447,11 +466,15 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option { ("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"); } @@ -488,16 +511,16 @@ 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 } @@ -536,14 +559,16 @@ pub(super) fn model_formulas(m: &Model) -> Result> { }; let yes = |b: bool| if b { 1 } else { 2 }; rows.push(format!( - "={{{eng},{},{},{},{},{},{},{}}}", + "={{{eng},{},{},{},{},{},{},{},{},{}}}", yes(m.non_negative), e.population, n(e.mutation), e.seed, yes(e.require_bounds), n(e.convergence), - e.stall + e.stall, + m.max_evals, + n(percent(m.gap)) )); Ok(rows) } @@ -598,7 +623,15 @@ fn read_options_row(model: &mut Json, list: &str) -> Result<()> { _ => "grg", }); model["nonNegative"] = json!(get(1) != Some(2.0)); - for (k, key) in [(2, "populationSize"), (3, "mutationRate"), (4, "randomSeed"), (6, "convergence"), (7, "generationsWithoutImprovement")] { + 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); } @@ -660,3 +693,18 @@ pub(super) fn parse_model_formulas(rows: &[String]) -> Result { 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/reports.rs b/crates/engine/src/cmd/solver/reports.rs index eade2789b..d3916b896 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 bfe3b0af7..816f38ceb 100644 --- a/crates/engine/src/cmd/solver/tests.rs +++ b/crates/engine/src/cmd/solver/tests.rs @@ -424,7 +424,7 @@ fn save_and_load_model_round_trip_through_cells() { "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}", + "{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). @@ -532,7 +532,7 @@ fn reset_all_masks_a_workbook_level_model_and_keeps_other_sheets_models() { // population size doesn't leak into it. s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 3"], "method": "simplex"})).unwrap(); let m = model(&s, one).unwrap(); - assert_eq!((m["objective"].as_str(), m["populationSize"].is_null()), (Some("B1"), true), "{m}"); + assert_eq!((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); } @@ -645,20 +645,20 @@ fn save_model_refuses_a_place_where_the_options_cannot_spill() { } let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["F4 <= 5"]}); let save = |s: &mut Session, to: &str| s.execute("data.solverSaveModel", json!({"to": to, "model": model.clone()})); - // The options row (H4) spills into I4:O4: a value in its last cell is in the way. - set(&mut s, &[("O4", "x")]); + // 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:O4") && e.contains("O4 isn't empty"), "{e}"); + 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, &[("O4", "")]); + 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")), - (Value::number(1.0), Value::number(1.0), Value::number(50.0)) + (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, @@ -671,11 +671,11 @@ fn save_model_refuses_a_place_where_the_options_cannot_spill() { ("J28", "K31 holds the values of another formula"), ("E1", "F4 is a cell of the model"), ("R5", "merged"), - ("S57", "T60:Z60, but a table is in the way"), - ("XFA1", "need 8 cells"), + ("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:AD70, but a table is in the way"), + ("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}"); @@ -816,6 +816,122 @@ fn load_model_keeps_the_evolutionary_convergence_and_generations() { 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 77092428d..9b04f1384 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -680,8 +680,14 @@ impl Dialog { 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 dcdf98301..40e4028cb 100644 --- a/crates/ui-egui/src/dialogs/analysis.rs +++ b/crates/ui-egui/src/dialogs/analysis.rs @@ -169,17 +169,100 @@ pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Di ok_cancel(ui, confirm, open); } -/// The keys of the model's options in the Solver Parameters form. A key that is absent means -/// the option's default, so Reset All and Load Model remove them all before a model of their -/// own; an option of the model before would otherwise go with the next solve. -const SOLVER_OPTION_KEYS: [&str; 6] = - ["convergence", "mutationRate", "populationSize", "randomSeed", "generationsWithoutImprovement", "requireBounds"]; +/// 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"), +]; -/// Removes the model's options from the form, so the next solve uses their defaults. +/// 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_OPTION_KEYS { + 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 @@ -199,7 +282,8 @@ fn save_solver_model(app: &mut SheetApp, d: &mut Dialog, range: &str, overwrite: /// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's /// 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. +/// 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(); @@ -209,6 +293,10 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf 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:"); @@ -327,9 +415,11 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf ] { d.values.insert(k.into(), v); } + // The options go back to their defaults too. clear_solver_options(d); - d.values.remove("modelNote"); - d.values.remove("askModelOverwrite"); + for key in ["optionsBackup", "modelNote", "askModelOverwrite"] { + d.values.remove(key); + } } Err(e) => { d.values.insert("modelNote".into(), json!(crate::clean_error(&e))); @@ -340,6 +430,9 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf 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| { @@ -393,15 +486,20 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf 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 33c9a0d70..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; @@ -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 5411b3698..4b6ff1a96 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -881,10 +881,19 @@ fn solver_reset_all_and_load_model_drop_the_old_models_options() { // A GRG Nonlinear model on cells, which has no Evolutionary options. s.execute("data.solverSaveModel", json!({"to": "H1", "model": {"objective": "B1", "goal": "min", "variables": "A1"}})).unwrap(); let mut h = harness(s); - let old = json!({"objective": "B1", "goal": "min", "variables": "A1", "method": "evolutionary", "requireBounds": false, "convergence": "0.5", "randomSeed": "11", "populationSize": "40", "mutationRate": "0.5", "generationsWithoutImprovement": "5", "modelArea": true, "modelRange": "H1"}); + 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 = ["requireBounds", "convergence", "randomSeed", "populationSize", "mutationRate", "generationsWithoutImprovement"]; + 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() @@ -933,6 +942,139 @@ fn solver_reset_all_and_load_model_drop_the_old_models_options() { "{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] diff --git a/docs/gaps.md b/docs/gaps.md index 6ddc296ae..85b26a355 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (§13: Solver Load/Save Model and Reset All; earlier: §8: PivotTable Enable show details) · **Target:** Microsoft Excel (Microsoft 365) +> **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 @@ -123,8 +123,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- 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). Load/Save Model writes the - model on cells of its sheet and reads it back; Reset All removes it. AllDifferent constraints, - the Solver Options dialog and the Sensitivity and Population reports remain. + 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. @@ -184,6 +185,7 @@ 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) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0bf7008d6..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 (what-if and analysis: Solver Load/Save Model and Reset All; the Insert Function dialog is named as in Excel; no score change; earlier: automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **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; Load/Save Model and Reset All; no Sensitivity report or options dialog), Forecast Sheet. Missing: 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,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| | 2026-10-31 | minor | The Insert Function dialog is named as in Excel (it was called Formula Builder); no score change | +| 2026-10-31 | minor | What-if and analysis: Solver 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) |