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