Compare commits

...
2 Commits
Author SHA1 Message Date
PranavandFredApps f5030432fc Data: Solver Options (#3087)
* Data: Solver Load/Save Model and Reset All

data.solverSaveModel writes a model (the sheet's saved model, or one
given as data.solver params) down a column of its sheet as formulas:
the objective and goal (=MAX(...), =MIN(...) or =cell=value), the
variable cells (=COUNT(...)), one =AND(relation) per constraint, which
shows TRUE while the constraint holds, and the method and its options
as an array constant. It refuses a range that overlaps the model's
cells or runs off the sheet.

data.solverLoadModel reads such a range (or reads down from its first
cell), checks the model and makes it the active sheet's model.
data.solverReset (Reset All) removes the active sheet's model. Each is
one undo step.

The Solver Parameters dialog has Reset All and Load/Save buttons; the
dialog can also be opened with fields that go over the saved model.

* Data: Solver Reset All also clears a model read from workbook-level names

A sheet's Solver model can come from workbook-level names, which every
sheet without a model of its own reads. Reset All removed only the
sheet-level names, so such a model stayed and the dialog showed it
again.

Removing the workbook-level names would change every other sheet that
reads them. Reset All instead leaves them in place and writes an empty
sheet-level solver_opt over them on the active sheet only; the result
says masked: true. saved_model now reads a sheet's own names when the
sheet has a solver_opt, and the workbook-level names only otherwise,
never a mix of both, and treats an empty solver_opt as no model. The
mask survives XLSX and is replaced when a model is saved on the sheet.

* Data: Solver Save Model clears the rest of a longer model saved at the same cell

Saving a model over a longer one saved at the same cell left the old
model's last rows on the sheet, so Load Model from that cell read the new
options row followed by stale constraint rows and refused the range.
Save Model now clears what is left of a model saved there before (only
when those cells hold a complete model written by Save Model, and never
the new model's own cells), keeping the cells' formatting, in the same
undo step. Load Model reading down from one cell also stops at the
options row, so formulas just below a saved model no longer break it.

* Data: Solver Load Model keeps the Evolutionary convergence and generations

Save Model writes Convergence and the generations without improvement in
its options row and Load Model reads them, but the sheet's saved model
(hidden names) did not hold them, so after Load Model, or any solve,
they fell back to their defaults. The saved model now keeps them for the
Evolutionary method: Convergence as solver_cvg, which means the same in
Excel, and the generations as gridcraft_solver_stall, because Excel's
solver_mni is a time in seconds. Saving a model again and Reset All
remove the GridCraft name with the solver_* names.

* Data: Solver Load Model refuses a range with a gap

Load Model from a range of several cells kept only the cells that hold a
formula, so the rows below a cell without one moved up. A constraint cell
overwritten by a constant gave a model without that constraint, and an
empty cell before the options row gave the default options, both without
an error. Load Model now refuses a range in which a cell before the last
formula holds no formula, and names that cell. Empty cells after the
model are still allowed.

* Data: Solver removes only its own names when it saves or resets a model

Saving a model (by a solve or Load Model) and Reset All removed every
name of the sheet that starts with solver_, so a user's own name such as
solver_budget was deleted with the model. They now remove only the
Solver's names: the ones Excel's Solver writes (solver_opt, solver_adj,
solver_lhs1, solver_rel1, solver_rhs1 and so on) and GridCraft's own.
The names are matched by name rather than by the hidden flag, so a model
whose names lost that flag is still replaced in full.

* Data: Solver Save Model refuses a place where the options cannot spill

The options row that Save Model writes is an array constant of eight
values, which spills into the seven cells to the right of its first
cell. Content in any of those cells blocked the spill and the options
cell showed #SPILL!. Save Model now checks the whole spill range before
it writes anything and refuses, naming the cell, when a cell there holds
content or another formula's spilled values or is a cell of the model,
when merged cells or a table meet the row (a dynamic array cannot spill
into a table), or when the range runs past the last column. It also
refuses a place where the model's column would cover another formula's
spilled values, which that formula would lose to #SPILL!. The options of
a model saved at the same cell before may be replaced, as before.

* Data: Solver Reset All and Load Model drop the old model's options

Reset All cleared the model fields of the Solver Parameters form but
kept the Evolutionary options (Convergence, Mutation Rate, Population
Size, Random Seed, generations without improvement and Require Bounds),
and Load Model kept any of them that the loaded model omits, such as all
of them for a GRG Nonlinear or Simplex LP model. A new Evolutionary model
then solved with the old options, for example with Require Bounds off.
Both now remove the model's options from the form first (one list of
their keys), so an option the model doesn't set takes its default.

* Data: Solver Save Model asks before it replaces data

Save Model wrote its formulas over whatever the model range held.
It is now refused, with the sheet unchanged, when a cell of the
range holds a value or a formula (a model saved there before
included), unless `overwrite: true` is passed. The message names
the first such cell and carries the same "already holds data"
phrase as Data Analysis, so the Solver Parameters dialog asks
"Replace it with the model?" with Replace and Don't Replace. The
question belongs to the range it asked about: editing the range,
Load or Reset All drops it. The check runs after the spill checks,
so the question is asked only when replacing would succeed. A save
remains one undo step.

Tests: the engine test refuses a value, a formula and an earlier
model, accepts only a true boolean, and undoes and redoes the
replacing save in one step; the dialog test covers Don't Replace,
an edited range and Replace. Both fail without the change.

* Data: Solver Load Model and Reset All tests for limits and undo

Tests for behaviour already in place: reading down from one cell of
a column of 5,000 formulas stops after the most rows a model takes,
a range larger than a model is refused before any cell is read, and
malformed or hostile cells and ranges (unbalanced brackets, an
overflowing number, an unknown sheet, 10,000 nested brackets, a
non-text `from`) each give an error and leave no model. Undoing
Reset All brings the model back with its options (the population
size).

* Data: Solver Load Model refuses a model without its options row

Load Model from one cell read down to the first cell without a
formula, and a range dropped its trailing cells without formulas.
A constraint cleared in the middle of a saved model, or its options
row cleared or replaced by a constant, therefore left a prefix of
the model, which was loaded as the whole model with default options
and replaced the sheet's model. Every load now requires the model's
last row to be the options row ({...}) that Save Model writes last;
otherwise it is refused, naming the cell without a formula when
reading down from one cell, and the sheet's model stays as it was.

The test clears a middle constraint, clears the options row and
replaces it by a constant, loads from the first cell and as two
ranges, and checks after each refusal that the old model (three
constraints, Simplex LP) is unchanged. It fails without the change.

* Data: Solver Options

The Solver Parameters dialog's Options button shows the Solver Options
in the same window: Max Evaluations and Integer Optimality for all
methods, and Convergence, Mutation Rate, Population Size, Random Seed,
Maximum Generations without Improvement and Require Bounds on Variables
for Evolutionary. OK keeps the changes for the next solve; Cancel
restores them.

The evaluation limit and Integer Optimality are now part of the model:
they are saved in the hidden names (solver_itr, which counts evaluations
in place of iterations, and solver_tol) and in the options row of Save
Model. Both accept numbers or text, as the other options do.

* Data: Solver keeps its own limits out of Excel's Solver names

The evaluation limit was saved as solver_itr. Excel uses that name for
its iteration limit (100 by default), so a workbook saved by Excel would
give GridCraft 100 evaluations and stop its search early. GridCraft now
saves the evaluation limit as gridcraft_solver_evals, which Excel
ignores, and no longer reads solver_itr. Saving a model again and Reset
All remove it with the other model names. Integer Optimality
(solver_tol) keeps Excel's name, as its meaning is the same.

* Data: Solver Reset All also resets the Solver Options

Reset All removes the model's options from the Solver Parameters form,
the Solver Options fields (the evaluation limit, Integer Optimality and
the Evolutionary options) and Require Bounds, so the next Solve uses the
defaults. It now also removes the copy of the options that Options keeps
for Cancel. A test checks that the options are gone after Reset All and
that Options then shows the defaults.

* Data: Solver reads Integer Optimality with a percent sign as percent

Integer Optimality is given in percent, but text such as "5%" was read
by the number parser as 0.05 and then divided by 100 again, so the
solver used 0.05 % instead of 5 %. A trailing percent sign is now
dropped before the number is read, so "5", "5%" and 5 all mean 5 %.

* Data: Solver Options closes with Escape back to the Solver Parameters

Escape in the Solver Options view went to the shared dialog handler,
which closed the whole Solver dialog: the options were not restored and
parameter edits made before opening Options were lost. Escape (and
closing the window) in Solver Options now does what Cancel does: it
restores the options and returns to the Solver Parameters. Escape in the
parameters still closes the dialog.

* Data: Solver keeps the Evolutionary options when another method solves

Solver Options sets the Evolutionary options whatever the solving
method, but the saved model and the model in data.solver's result held
them only for the Evolutionary method. A GRG Nonlinear or Simplex LP
solve (which saves the model by default) dropped them, and switching
back to Evolutionary brought back the defaults. They are now saved and
returned for every method. Two tests that expected them to be absent
for other methods now check their values.

* Data: Solver Options keeps its OK and Cancel row at button height

The Solver Options' OK and Cancel row was a right-to-left layout placed
straight in the window, which takes all the height it is offered, so
the buttons sat well below the last option and the window was taller
than its content. The row is now wrapped in a horizontal row as tall as
the buttons, as the other form dialogs do (ok_cancel).

The window's height now changes when it switches between the parameters
and the options, and the window re-centres over the next frames, so the
test that switches back and forth lets a few frames pass after each
switch before it clicks.

* Data: Solver Parameters keeps its Solve and Close row at button height

The Solve and Close row was a right-to-left layout placed straight in
the window. The window keeps its size when its content gets shorter, as
it does when the Load/Save area or a note is hidden again, and that
layout then filled the space left over, so Solve moved further below
the last row. The row is now a horizontal row as tall as the buttons,
as ok_cancel and the Solver Options do.

* Data: Solver dialog opened in its options sets them up as the Options button does

Opening the Solver dialog straight in the Solver Options (optionsView:
true) only changed the title. It skipped what the Options button does:
filling each option the form lacks with its default and keeping the
current values for Cancel. With no saved model the fields were blank,
and Cancel or Escape found nothing to restore, so the edits stayed and
the next Solve used them. The button and the dialog now share one
function for this.

* Data: Solver reads Integer Optimality back without floating-point noise

Integer Optimality is stored as a fraction, and the product with 100 is not exact for many percents (0.07 * 100 is 7.000000000000001). The percent is now rounded to 1E-10 % wherever it is read back: the saved model, the solve's model params and Save Model's options row.

* Data: Solver reads whole-number options back as integers

The model's numeric options were returned as floating-point JSON, so after a solve the Solver Options showed 20000.0, 100.0, 0.0 and 50.0. Values read from the hidden names, and Integer Optimality in the solve's result, are now JSON integers where they are whole, and the dialog shows 20000, 100, 0 and 50.

* Data: Solver test checks the model saved by the second solve

The test of Excel's iteration and time limits ignored the result of
its second `data.solver` call and checked only names the first call
had already created. It now reads the saved model afterwards and
checks the values the second call changed (Simplex LP and an
evaluation limit of 700). The call itself still fails, as Simplex LP
refuses the nonlinear model, after the model is saved.

* Data: Solver Load Model refuses an options row cut short

The options row of a saved model was read leniently: a missing value
or one that isn't a number took its default, and values after the
sixteenth were not read at all. A row cut short or edited by hand,
such as ={1}, therefore loaded the model with default options in
place of the saved ones (here: GRG Nonlinear in place of Simplex
LP). The row must now hold at least the eight numbers Save Model
writes (method, non-negative, population size, mutation rate, random
seed, require bounds, convergence, generations without improvement),
and every value in it, however many, must be a number; otherwise
Load Model is refused and the sheet's model stays as it was. Only
the first sixteen values are kept. More numbers than eight are still
read, so a row with options a later version adds loads too.

The test loads rows of one value, seven values, a text value among
the eight, after them and after the sixteenth, and a TRUE, from the
first cell and as a range, checking the old model after each
refusal, and a row of ten numbers, which loads. It fails without the
change.

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-01 20:23:08 +01:00
PranavandFredApps 71b130be2d Data: Solver Load/Save Model and Reset All (#3086)
* Data: Solver Load/Save Model and Reset All

data.solverSaveModel writes a model (the sheet's saved model, or one
given as data.solver params) down a column of its sheet as formulas:
the objective and goal (=MAX(...), =MIN(...) or =cell=value), the
variable cells (=COUNT(...)), one =AND(relation) per constraint, which
shows TRUE while the constraint holds, and the method and its options
as an array constant. It refuses a range that overlaps the model's
cells or runs off the sheet.

data.solverLoadModel reads such a range (or reads down from its first
cell), checks the model and makes it the active sheet's model.
data.solverReset (Reset All) removes the active sheet's model. Each is
one undo step.

The Solver Parameters dialog has Reset All and Load/Save buttons; the
dialog can also be opened with fields that go over the saved model.

* Data: Solver Reset All also clears a model read from workbook-level names

A sheet's Solver model can come from workbook-level names, which every
sheet without a model of its own reads. Reset All removed only the
sheet-level names, so such a model stayed and the dialog showed it
again.

Removing the workbook-level names would change every other sheet that
reads them. Reset All instead leaves them in place and writes an empty
sheet-level solver_opt over them on the active sheet only; the result
says masked: true. saved_model now reads a sheet's own names when the
sheet has a solver_opt, and the workbook-level names only otherwise,
never a mix of both, and treats an empty solver_opt as no model. The
mask survives XLSX and is replaced when a model is saved on the sheet.

* Data: Solver Save Model clears the rest of a longer model saved at the same cell

Saving a model over a longer one saved at the same cell left the old
model's last rows on the sheet, so Load Model from that cell read the new
options row followed by stale constraint rows and refused the range.
Save Model now clears what is left of a model saved there before (only
when those cells hold a complete model written by Save Model, and never
the new model's own cells), keeping the cells' formatting, in the same
undo step. Load Model reading down from one cell also stops at the
options row, so formulas just below a saved model no longer break it.

* Data: Solver Load Model keeps the Evolutionary convergence and generations

Save Model writes Convergence and the generations without improvement in
its options row and Load Model reads them, but the sheet's saved model
(hidden names) did not hold them, so after Load Model, or any solve,
they fell back to their defaults. The saved model now keeps them for the
Evolutionary method: Convergence as solver_cvg, which means the same in
Excel, and the generations as gridcraft_solver_stall, because Excel's
solver_mni is a time in seconds. Saving a model again and Reset All
remove the GridCraft name with the solver_* names.

* Data: Solver Load Model refuses a range with a gap

Load Model from a range of several cells kept only the cells that hold a
formula, so the rows below a cell without one moved up. A constraint cell
overwritten by a constant gave a model without that constraint, and an
empty cell before the options row gave the default options, both without
an error. Load Model now refuses a range in which a cell before the last
formula holds no formula, and names that cell. Empty cells after the
model are still allowed.

* Data: Solver removes only its own names when it saves or resets a model

Saving a model (by a solve or Load Model) and Reset All removed every
name of the sheet that starts with solver_, so a user's own name such as
solver_budget was deleted with the model. They now remove only the
Solver's names: the ones Excel's Solver writes (solver_opt, solver_adj,
solver_lhs1, solver_rel1, solver_rhs1 and so on) and GridCraft's own.
The names are matched by name rather than by the hidden flag, so a model
whose names lost that flag is still replaced in full.

* Data: Solver Save Model refuses a place where the options cannot spill

The options row that Save Model writes is an array constant of eight
values, which spills into the seven cells to the right of its first
cell. Content in any of those cells blocked the spill and the options
cell showed #SPILL!. Save Model now checks the whole spill range before
it writes anything and refuses, naming the cell, when a cell there holds
content or another formula's spilled values or is a cell of the model,
when merged cells or a table meet the row (a dynamic array cannot spill
into a table), or when the range runs past the last column. It also
refuses a place where the model's column would cover another formula's
spilled values, which that formula would lose to #SPILL!. The options of
a model saved at the same cell before may be replaced, as before.

* Data: Solver Reset All and Load Model drop the old model's options

Reset All cleared the model fields of the Solver Parameters form but
kept the Evolutionary options (Convergence, Mutation Rate, Population
Size, Random Seed, generations without improvement and Require Bounds),
and Load Model kept any of them that the loaded model omits, such as all
of them for a GRG Nonlinear or Simplex LP model. A new Evolutionary model
then solved with the old options, for example with Require Bounds off.
Both now remove the model's options from the form first (one list of
their keys), so an option the model doesn't set takes its default.

* Data: Solver Save Model asks before it replaces data

Save Model wrote its formulas over whatever the model range held.
It is now refused, with the sheet unchanged, when a cell of the
range holds a value or a formula (a model saved there before
included), unless `overwrite: true` is passed. The message names
the first such cell and carries the same "already holds data"
phrase as Data Analysis, so the Solver Parameters dialog asks
"Replace it with the model?" with Replace and Don't Replace. The
question belongs to the range it asked about: editing the range,
Load or Reset All drops it. The check runs after the spill checks,
so the question is asked only when replacing would succeed. A save
remains one undo step.

Tests: the engine test refuses a value, a formula and an earlier
model, accepts only a true boolean, and undoes and redoes the
replacing save in one step; the dialog test covers Don't Replace,
an edited range and Replace. Both fail without the change.

* Data: Solver Load Model and Reset All tests for limits and undo

Tests for behaviour already in place: reading down from one cell of
a column of 5,000 formulas stops after the most rows a model takes,
a range larger than a model is refused before any cell is read, and
malformed or hostile cells and ranges (unbalanced brackets, an
overflowing number, an unknown sheet, 10,000 nested brackets, a
non-text `from`) each give an error and leave no model. Undoing
Reset All brings the model back with its options (the population
size).

* Data: Solver Load Model refuses a model without its options row

Load Model from one cell read down to the first cell without a
formula, and a range dropped its trailing cells without formulas.
A constraint cleared in the middle of a saved model, or its options
row cleared or replaced by a constant, therefore left a prefix of
the model, which was loaded as the whole model with default options
and replaced the sheet's model. Every load now requires the model's
last row to be the options row ({...}) that Save Model writes last;
otherwise it is refused, naming the cell without a formula when
reading down from one cell, and the sheet's model stays as it was.

The test clears a middle constraint, clears the options row and
replaces it by a constant, loads from the first cell and as two
ranges, and checks after each refusal that the old model (three
constraints, Simplex LP) is unchanged. It fails without the change.

* Data: Solver Load Model refuses an options row cut short

The options row of a saved model was read leniently: a missing value
or one that isn't a number took its default, and values after the
sixteenth were not read at all. A row cut short or edited by hand,
such as ={1}, therefore loaded the model with default options in
place of the saved ones (here: GRG Nonlinear in place of Simplex
LP). The row must now hold at least the eight numbers Save Model
writes (method, non-negative, population size, mutation rate, random
seed, require bounds, convergence, generations without improvement),
and every value in it, however many, must be a number; otherwise
Load Model is refused and the sheet's model stays as it was. Only
the first sixteen values are kept. More numbers than eight are still
read, so a row with options a later version adds loads too.

The test loads rows of one value, seven values, a text value among
the eight, after them and after the sixteenth, and a TRUE, from the
first cell and as a range, checking the old model after each
refusal, and a row of ten numbers, which loads. It fails without the
change.

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-01 20:13:00 +01:00
12 changed files with 1724 additions and 69 deletions
+33 -7
View File
@@ -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, &params)?;
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(&params, "keep").unwrap_or(true);
let save = bool_param(&params, "saveModel").unwrap_or(true);
let reports = report_kinds(CMD, &params)?;
+279 -31
View File
@@ -1,6 +1,6 @@
use super::{
CMD, CellRef, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef, Result, Session, Workbook,
bad, bool_param, f64_param, json, split_sheet, str_param,
CMD, CellRef, DEFAULT_EVALS, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_EVALS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef,
Result, Session, Workbook, bad, bool_param, f64_param, json, split_sheet, str_param,
};
// ---------------------------------------------------------------- model
@@ -128,6 +128,10 @@ pub(super) struct Model {
pub(super) non_negative: bool,
pub(super) method: Method,
pub(super) evo: EvoOptions,
/// The evaluation limit (in place of Excel's iterations and time limits).
pub(super) max_evals: u64,
/// Integer Optimality, as a fraction.
pub(super) gap: f64,
}
/// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`.
@@ -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) }
}
+305
View File
@@ -0,0 +1,305 @@
//! Load/Save Model and Reset All: the model written on cells of its sheet, read back from them,
//! and removed from the sheet.
use super::model::{Rhs, is_model_name, model_formulas, parse_model, parse_model_formulas, save_model, saved_model};
use super::{
Cell, CellRef, DefinedName, EngineError, Json, LOAD_CMD, MAX_CONSTRAINTS, RangeRef, Result, SAVE_CMD, Session, Sheet, Value, bad, bool_param,
edit, json, split_sheet, str_param,
};
use crate::cmd::analysis::OVERWRITE_ERROR;
use crate::cmd::cell_param_on;
/// Most cells a model written by Save Model takes: the objective, the variables, the options
/// and one per constraint.
const MAX_MODEL_ROWS: usize = MAX_CONSTRAINTS + 3;
fn is_options_row(formula: &str) -> bool {
formula.trim().trim_start_matches('=').trim_start().starts_with('{')
}
/// The formulas from `start` down: to the first cell without a formula, or through the options
/// row (`{…}`) that ends a model written by Save Model; at most [`MAX_MODEL_ROWS`] cells.
fn formulas_down(sheet: &Sheet, start: CellRef) -> Vec<(CellRef, String)> {
let mut out = Vec::new();
for k in 0..MAX_MODEL_ROWS as u32 {
let Some(c) = start.row.checked_add(k).map(|row| CellRef::new(row, start.col)) else { break };
let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref()).map(|f| f.text.clone()) else { break };
let last = is_options_row(&f);
out.push((c, f));
if last {
break;
}
}
out
}
/// The cells of a model written by Save Model that starts at `start` (none if the cells there
/// don't hold one).
fn saved_model_cells(sheet: &Sheet, start: CellRef) -> Vec<CellRef> {
let old = formulas_down(sheet, start);
let rows: Vec<String> = old.iter().map(|(_, f)| f.clone()).collect();
if rows.last().is_some_and(|f| is_options_row(f)) && parse_model_formulas(&rows).is_ok() {
old.into_iter().map(|(c, _)| c).collect()
} else {
Vec::new()
}
}
/// How many cells the options row of Save Model (`={…}`, one row) spans.
fn options_width(formula: &str) -> u32 {
u32::try_from(formula.split(',').count()).unwrap_or(u32::MAX)
}
/// The options row is an array constant that spills `width - 1` cells to the right of `anchor`.
/// Refused, before anything is written, when the spill would run off the sheet or be blocked
/// (the anchor would show `#SPILL!`): a cell there holds content or another formula's spilled
/// values, or a merged area or a table meets the row. `ours` are the cells Save Model
/// writes or clears (a model saved at the same place before, whose options spill may be
/// replaced); `used` are the model's own cells, which the spill must not cover.
fn check_options_spill(sheet: &Sheet, anchor: CellRef, width: u32, ours: &[CellRef], used: &[CellRef]) -> Result<()> {
if width <= 1 {
return Ok(());
}
let end = anchor.col.checked_add(width - 1).filter(|c| *c < gridcraft_core::MAX_COLS).ok_or_else(|| {
EngineError::Other(format!(
"The saved model's options need {width} cells to the right from {}; choose another place to save it.",
anchor.a1()
))
})?;
let full = RangeRef::new(anchor, CellRef::new(anchor.row, end));
let spill = RangeRef::new(CellRef::new(anchor.row, anchor.col.saturating_add(1)), CellRef::new(anchor.row, end));
let blocked =
|why: String| EngineError::Other(format!("The saved model's options fill {}, but {why}; choose another place to save it.", spill.a1()));
if let Some(c) = used.iter().find(|c| spill.contains(**c)) {
return Err(blocked(format!("{} is a cell of the model", c.a1())));
}
for c in spill.iter() {
if sheet.cells.has(c) {
return Err(blocked(format!("{} isn't empty", c.a1())));
}
if sheet.spill.contains_key(&c) && sheet.spill_anchor(c).is_none_or(|a| !ours.contains(&a)) {
return Err(blocked(format!("{} holds the values of another formula", c.a1())));
}
}
if sheet.merges.iter().any(|m| m.intersects(&full)) {
return Err(blocked("merged cells are in the way".into()));
}
// A dynamic array can't spill into a table.
if sheet.tables.iter().any(|t| t.range.intersects(&full)) {
return Err(blocked("a table is in the way".into()));
}
Ok(())
}
/// Refused when a cell of the model's column holds another formula's spilled values: the
/// formula written there would block that spill (the other formula would show `#SPILL!`).
/// `ours` are as in [`check_options_spill`]: a model saved at the same place before may be
/// replaced.
fn check_column_spills(sheet: &Sheet, column: RangeRef, ours: &[CellRef]) -> Result<()> {
for c in column.iter() {
if sheet.spill.contains_key(&c) && sheet.spill_anchor(c).is_none_or(|a| !ours.contains(&a)) {
return Err(EngineError::Other(format!(
"The saved model would cover {}, which holds the values of another formula; choose another place to save it.",
c.a1()
)));
}
}
Ok(())
}
/// Refused, unless `overwrite`, when a cell of the model's column holds a value or a formula
/// (a model saved there before included): Save Model then asks before it replaces data, as
/// Excel does. The message carries [`OVERWRITE_ERROR`], which the dialog turns into its
/// question.
fn check_column_empty(sheet: &Sheet, column: RangeRef, overwrite: bool) -> Result<()> {
let taken = column.iter().find(|c| sheet.cell(*c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()));
match taken {
Some(c) if !overwrite => Err(EngineError::Other(format!(
"The range {} {OVERWRITE_ERROR} ({} isn't empty); pass overwrite: true to replace it.",
column.a1(),
c.a1()
))),
_ => Ok(()),
}
}
/// `data.solverSaveModel`: writes the model on cells of its sheet, in one step. Saving over a
/// longer model saved at the same cell clears the rest of it, so Load Model reads the new one.
pub(super) fn save_model_cells(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let params = match p.get("model") {
Some(m @ Json::Object(_)) => m.clone(),
Some(Json::Null) | None => {
saved_model(&d.wb, d.wb.active_sheet()).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))?
}
Some(_) => return Err(bad(SAVE_CMD, "`model` must be data.solver params")),
};
let model = parse_model(s, &params)?;
let to = cell_param_on(s, p, "to", model.sheet)?.ok_or_else(|| bad(SAVE_CMD, "missing `to` (the first cell of the saved model)"))?;
let rows = model_formulas(&model)?;
let count = u32::try_from(rows.len()).unwrap_or(u32::MAX);
let last = to
.row
.checked_add(count.saturating_sub(1))
.filter(|r| *r < gridcraft_core::MAX_ROWS)
.ok_or_else(|| EngineError::Other("The saved model doesn't fit below that cell.".into()))?;
let range = RangeRef::new(to, CellRef::new(last, to.col));
// The model's own cells are never overwritten.
let mut used = vec![model.objective];
used.extend(&model.vars);
for c in &model.cons {
used.extend(&c.lhs);
if let Rhs::Cells(r) = &c.rhs {
used.extend(r);
}
}
if used.iter().any(|c| range.contains(*c)) {
return Err(EngineError::Other(format!("{} overlaps the model's cells; choose another place to save it.", range.a1())));
}
let sheet = model.sheet;
let sh = s.doc()?.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
// What is left of a model saved here before; the new model's own cells stay.
let stale: Vec<CellRef> = saved_model_cells(sh, to).into_iter().filter(|c| !range.contains(*c) && !used.contains(c)).collect();
let ours: Vec<CellRef> = range.iter().chain(stale.iter().copied()).collect();
let width = rows.last().map_or(1, |f| options_width(f));
check_column_spills(sh, range, &ours)?;
check_options_spill(sh, CellRef::new(last, to.col), width, &ours, &used)?;
// Last, so the question is asked only when replacing the data would succeed.
check_column_empty(sh, range, bool_param(p, "overwrite").unwrap_or(false))?;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let cells: Vec<CellRef> = range.iter().collect();
for (c, f) in cells.iter().zip(&rows) {
let mut cell = Cell::formula(gridcraft_model::Formula::new(f));
cell.style = sh.style_id(*c);
sh.set_cell(*c, cell);
}
for c in &stale {
let style = sh.style_id(*c);
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style });
}
for c in cells.into_iter().chain(stale) {
cx.touch(sheet, c);
}
Ok(())
})?;
Ok(json!({"range": range.a1()}))
}
/// The formulas of a model range, in order. Every cell up to the last formula must hold one: a
/// cell without a formula there (a constraint overwritten by a constant, say) would shift the
/// rows below it and drop a constraint or the options. Empty cells after the last formula are
/// allowed.
fn cells_with_formulas(r: RangeRef, formula: impl Fn(CellRef) -> Option<String>) -> Result<Vec<String>> {
let cells: Vec<(CellRef, Option<String>)> = r.iter().map(|c| (c, formula(c))).collect();
let end = cells.iter().rposition(|(_, f)| f.is_some()).map_or(0, |i| i + 1);
let mut rows = Vec::with_capacity(end);
for (c, f) in cells.into_iter().take(end) {
let f = f.ok_or_else(|| EngineError::Other(format!("{} holds no formula, so the range isn't a model saved with Save Model.", c.a1())))?;
rows.push(f);
}
Ok(rows)
}
/// A model read down from `start` must end with its options row, which Save Model always
/// writes last: a cell without a formula before it (a constraint cleared, say) would otherwise
/// end the read early and load the rows above it as the whole model, with default options.
fn complete_from_cell(start: CellRef, down: &[(CellRef, String)]) -> Result<()> {
match down.last() {
Some((_, f)) if is_options_row(f) => Ok(()),
_ if down.len() >= MAX_MODEL_ROWS => Err(EngineError::Other(format!(
"No options row ends the model within {MAX_MODEL_ROWS} cells of {}, so it isn't a model saved with Save Model.",
start.a1()
))),
last => {
let gap = last.map_or(Some(start), |(c, _)| c.row.checked_add(1).map(|row| CellRef::new(row, c.col)));
Err(EngineError::Other(match gap {
Some(gap) => format!(
"{} holds no formula before the model's options row, so {} doesn't start a complete model saved with Save Model.",
gap.a1(),
start.a1()
),
None => format!("{} doesn't start a complete model saved with Save Model.", start.a1()),
}))
}
}
}
/// `data.solverLoadModel`: makes a model written by Save Model the active sheet's model.
pub(super) fn load_model_cells(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let active = d.wb.active_sheet();
let text = str_param(p, "from").ok_or_else(|| bad(LOAD_CMD, "missing `from` (the saved model's range)"))?;
let (sh, body) = split_sheet(text);
if sh.is_some_and(|n| d.wb.sheet_index(&n) != Some(active)) {
return Err(EngineError::Other("Load Model reads a model saved on the active sheet.".into()));
}
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(LOAD_CMD, format!("not a range: `{text}`")))?;
let cap = MAX_MODEL_ROWS;
let sheet = d.wb.sheet(active).ok_or(EngineError::NoDocument)?;
let formula = |c: CellRef| sheet.cell(c).and_then(|x| x.formula.as_ref()).map(|f| f.text.clone());
let rows: Vec<String> = if r.is_single() {
let down = formulas_down(sheet, r.start);
complete_from_cell(r.start, &down)?;
down.into_iter().map(|(_, f)| f).collect()
} else {
if r.count() > cap as u64 {
return Err(EngineError::Other(format!("The range is too large for a saved model (at most {cap} cells).")));
}
let rows = cells_with_formulas(r, formula)?;
if !rows.last().is_some_and(|f| is_options_row(f)) {
return Err(EngineError::Other(format!(
"{} doesn't end with the model's options row, so it isn't a complete model saved with Save Model.",
r.a1()
)));
}
rows
};
let params = parse_model_formulas(&rows)?;
let model = parse_model(s, &params)?;
if model.sheet != active {
return Err(EngineError::Other("Load Model reads a model of the active sheet.".into()));
}
edit(s, |cx| {
save_model(&mut cx.wb, &model);
Ok(())
})?;
Ok(json!({"model": saved_model(&s.doc()?.wb, active)}))
}
/// Whether a defined name belongs to the saved model of `sheet`.
fn is_sheet_model_name(n: &DefinedName, sheet: usize) -> bool {
n.scope == Some(sheet) && is_model_name(&n.name)
}
/// `data.solverReset`: Reset All removes the active sheet's model. A model the sheet reads from
/// workbook-level names (which other sheets may read too) is left in place and masked by an
/// empty sheet-level `solver_opt`, so no other sheet's model changes.
pub(super) fn reset_model(s: &mut Session, _p: &Json) -> Result<Json> {
let d = s.doc()?;
let active = d.wb.active_sheet();
let removed = d.wb.names.iter().filter(|n| is_sheet_model_name(n, active)).count();
let mut after = d.wb.names.clone();
after.retain(|n| !is_sheet_model_name(n, active));
let masked = {
let mut probe = (*d.wb).clone();
probe.names = after;
saved_model(&probe, active).is_some()
};
if removed > 0 || masked {
edit(s, |cx| {
cx.wb.names.retain(|n| !is_sheet_model_name(n, active));
if masked {
cx.wb.names.push(DefinedName {
name: "solver_opt".into(),
scope: Some(active),
formula: "\"\"".into(),
comment: String::new(),
hidden: true,
});
}
Ok(())
})?;
}
Ok(json!({"removed": removed, "masked": masked}))
}
+3 -7
View File
@@ -2,8 +2,8 @@ use super::lp::Outcome;
use super::model::{Constraint, Goal, Method, Model, Rel, Rhs, parse_model, saved_model};
use super::problem::{Evaluator, Problem, as_num, build_problem};
use super::{
Cell, CellRef, Ctx, DEFAULT_EVALS, DocState, EngineError, Json, MAX_EVALS, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad,
edit, json, optimise, str_param,
Cell, CellRef, Ctx, DocState, EngineError, Json, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad, edit, json, optimise,
str_param,
};
// ---------------------------------------------------------------- reports
@@ -362,11 +362,7 @@ pub(super) fn report(s: &mut Session, p: &Json) -> Result<Json> {
};
let solution = point("values")?;
let original = if p.get("original").is_some() { point("original")? } else { solution.clone() };
let budget = params
.get("maxEvaluations")
.and_then(Json::as_f64)
.filter(|v| v.is_finite() && *v >= 1.0)
.map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
let budget = model.max_evals;
let message = str_param(p, "message").unwrap_or("").chars().take(500).collect();
let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget };
let built = prepare_reports(s.doc()?, &kinds, &inp)?;
+532
View File
@@ -400,6 +400,538 @@ fn solver_report_insertion_keeps_variable_edit_guards() {
}
}
fn formula(s: &Session, a1: &str) -> String {
let d = s.doc().unwrap();
d.wb.active().unwrap().cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()).unwrap_or_default()
}
#[test]
fn save_and_load_model_round_trip_through_cells() {
let mut s = s();
set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2"), ("C2", "24")]);
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= C2", "B3 <= 6", "A1:A2 int", "A2 bin", "A1 >= 1"], "method": "simplex"});
s.execute("data.solver", model.clone()).unwrap();
let out = s.execute("data.solverSaveModel", json!({"to": "H1"})).unwrap();
assert_eq!(out["range"], "H1:H8");
let rows: Vec<String> = (1..=8).map(|r| formula(&s, &format!("H{r}"))).collect();
assert_eq!(
rows,
[
"MAX($B$1)",
"COUNT($A$1:$A$2)",
"AND($B$2<=$C$2)",
"AND($B$3<=6)",
"AND($A$1:$A$2=INT($A$1:$A$2))",
"AND(($A$2=0)+($A$2=1))",
"AND($A$1>=1)",
"{2,1,100,0.075,0,1,0.0001,50,20000,0}",
]
);
// The constraint cells show whether each constraint holds at the solution (4, 0).
assert_eq!(at(&s, "Sheet1", "H3"), Value::Bool(true));
assert_eq!(at(&s, "Sheet1", "H2"), Value::number(2.0));
// Reset All removes the model; Load Model restores it from the range or its first cell.
assert_eq!(s.execute("data.solverReset", json!({})).unwrap()["removed"].as_u64().map(|n| n > 0), Some(true));
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null());
assert_eq!(s.execute("data.solverReset", json!({})).unwrap()["removed"], 0);
for from in ["H1:H8", "H1"] {
let loaded = s.execute("data.solverLoadModel", json!({"from": from})).unwrap();
assert_eq!(loaded["model"]["objective"], "B1", "{loaded}");
assert_eq!(loaded["model"]["constraints"], json!(["B2 <= C2", "B3 <= 6", "A1:A2 int", "A2 bin", "A1 >= 1"]));
assert_eq!(loaded["model"]["method"], "simplex");
}
set(&mut s, &[("A1", "0"), ("A2", "0")]);
let r = s.execute("data.solver", json!({"saved": true})).unwrap();
assert_eq!((r["status"].as_str(), n(&s, "A1"), n(&s, "A2")), (Some("optimal"), 4.0, 0.0), "{r}");
// A Value Of goal and the Evolutionary options survive too; an explicit model is saved
// as given.
let evo = json!({"objective": "B1", "goal": "value", "value": -2.5, "variables": "A1:A2", "constraints": ["A1:A2 <= 10"], "nonNegative": false, "method": "evolutionary", "populationSize": 40, "randomSeed": 3, "requireBounds": false});
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "J10", "model": evo})).unwrap()["range"], "J10:J13");
assert_eq!(formula(&s, "J10"), "$B$1=-2.5");
let m = s.execute("data.solverLoadModel", json!({"from": "J10:J13"})).unwrap()["model"].clone();
assert_eq!((m["goal"].as_str(), m["value"].as_f64(), m["nonNegative"].as_bool()), (Some("value"), Some(-2.5), Some(false)), "{m}");
assert_eq!((m["method"].as_str(), m["populationSize"].as_f64(), m["randomSeed"].as_f64()), (Some("evolutionary"), Some(40.0), Some(3.0)));
assert_eq!(m["requireBounds"], false);
// Each command is one undo step.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.execute("data.solver", json!({})).unwrap()["model"]["method"], "simplex");
// Refused: no model or no target, overlapping the model, off the sheet, ranges that
// hold no model, other sheets and oversized ranges.
set(&mut s, &[("L1", "=1+1"), ("L2", "=SUM(A1:A2)")]);
for (cmd, p) in [
("data.solverSaveModel", json!({})),
("data.solverSaveModel", json!({"to": "A1"})),
("data.solverSaveModel", json!({"to": "B3"})),
("data.solverSaveModel", json!({"to": "A1048575"})),
("data.solverSaveModel", json!({"to": "H1", "model": {"objective": "B1", "variables": "A1,A2", "constraints": ["A1,A2 <= 3"]}})),
("data.solverSaveModel", json!({"to": "H1", "model": "B1"})),
("data.solverLoadModel", json!({})),
("data.solverLoadModel", json!({"from": "L1:L2"})),
("data.solverLoadModel", json!({"from": "Z1"})),
("data.solverLoadModel", json!({"from": "H2:H8"})),
("data.solverLoadModel", json!({"from": "H1:H1048576"})),
("data.solverLoadModel", json!({"from": "Nope!H1"})),
] {
assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p}");
}
s.execute("data.solverReset", json!({})).unwrap();
assert!(s.execute("data.solverSaveModel", json!({"to": "H1"})).is_err(), "no model to save");
}
#[test]
fn reset_all_masks_a_workbook_level_model_and_keeps_other_sheets_models() {
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
let (one, two) = {
let wb = &s.doc().unwrap().wb;
(wb.sheet_index("Sheet1").unwrap(), wb.sheet_index("Two").unwrap())
};
// A model saved as workbook-level names, which every sheet reads.
{
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
for (name, formula) in [
("solver_opt", "Sheet1!$B$1"),
("solver_adj", "Sheet1!$A$1"),
("solver_typ", "1"),
("solver_num", "0"),
("solver_eng", "2"),
("solver_ssz", "500"),
] {
wb.names.push(gridcraft_model::DefinedName {
name: name.into(),
scope: None,
formula: formula.into(),
comment: String::new(),
hidden: true,
});
}
}
let model = |s: &Session, sheet: usize| super::saved_model(&s.doc().unwrap().wb, sheet);
assert_eq!(model(&s, one).map(|m| m["objective"].clone()), Some(json!("B1")));
// Reset All empties this sheet's model, which the dialog then shows, and no other sheet's.
let r = s.execute("data.solverReset", json!({})).unwrap();
assert_eq!(model(&s, one), None, "{r}");
assert_eq!(r["masked"], true, "{r}");
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null());
assert_eq!(model(&s, two).map(|m| m["objective"].clone()), Some(json!("B1")));
assert_eq!(s.doc().unwrap().wb.names.iter().filter(|n| n.scope.is_none()).count(), 6);
// Undo brings the model back with its options; the mask survives XLSX.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(model(&s, one).and_then(|m| m["populationSize"].as_f64()), Some(500.0));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(model(&s, one), None);
let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "r.xlsx", "base64": b64})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
assert_eq!(model(&s, one), None);
assert!(model(&s, two).is_some());
// A model saved on the sheet replaces the mask and is read alone: the workbook-level
// population size doesn't leak into it.
s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 3"], "method": "simplex"})).unwrap();
let m = model(&s, one).unwrap();
assert_eq!((m["objective"].as_str(), m["populationSize"].as_f64()), (Some("B1"), Some(100.0)), "{m}");
assert_eq!(s.execute("data.solverReset", json!({})).unwrap()["masked"], true);
assert_eq!(model(&s, one), None);
}
#[test]
fn saving_a_shorter_model_over_a_longer_one_clears_the_rest() {
let mut s = s();
set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2"), ("H9", "=1+1")]);
let long = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6", "A1 <= 3", "A2 <= 3"]});
let short = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24"]});
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1", "model": long})).unwrap()["range"], "H1:H7");
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1", "model": short, "overwrite": true})).unwrap()["range"], "H1:H4");
// The old model's last rows are cleared; a formula below it stays.
for r in 5..=7 {
assert_eq!(formula(&s, &format!("H{r}")), "", "H{r}");
}
assert_eq!(formula(&s, "H9"), "1+1");
let loaded = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap();
assert_eq!(loaded["model"]["constraints"], json!(["B2 <= 24"]), "{loaded}");
// Reading down from one cell stops at the options row even with formulas right below it.
set(&mut s, &[("H5", "=2+2")]);
let loaded = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap();
assert_eq!(loaded["model"]["constraints"], json!(["B2 <= 24"]), "{loaded}");
// Saving over cells that hold no model leaves the cells below them as they are.
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H8", "model": short, "overwrite": true})).unwrap()["range"], "H8:H11");
assert_eq!(formula(&s, "H5"), "2+2");
}
#[test]
fn save_model_asks_before_it_replaces_data_and_is_one_undo_step() {
let mut s = s();
set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("H2", "kept"), ("H3", "=1+1")]);
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24"]});
// A value or a formula in the column: refused, the sheet unchanged, the cell named.
let e = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model.clone()})).unwrap_err().to_string();
assert!(e.contains(crate::cmd::analysis::OVERWRITE_ERROR) && e.contains("H1:H4") && e.contains("H2"), "{e}");
assert_eq!((at(&s, "Sheet1", "H2"), formula(&s, "H3"), formula(&s, "H1")), (Value::text("kept"), "1+1".into(), String::new()));
// So is a model saved there before, and `overwrite` only as a true boolean.
assert!(s.execute("data.solverSaveModel", json!({"to": "J10", "model": model.clone()})).is_ok());
for overwrite in [json!(null), json!(false), json!("true"), json!(1)] {
let e = s.execute("data.solverSaveModel", json!({"to": "J10", "model": model.clone(), "overwrite": overwrite})).unwrap_err();
assert!(e.to_string().contains(crate::cmd::analysis::OVERWRITE_ERROR), "{overwrite}: {e}");
}
// overwrite: true replaces the data in one undo step, which brings it all back.
let out = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model, "overwrite": true})).unwrap();
assert_eq!(out["range"], "H1:H4");
assert_eq!((formula(&s, "H1"), formula(&s, "H2"), formula(&s, "H3")), ("MAX($B$1)".into(), "COUNT($A$1:$A$2)".into(), "AND($B$2<=24)".into()));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(
(at(&s, "Sheet1", "H2"), formula(&s, "H3"), formula(&s, "H1"), formula(&s, "H4")),
(Value::text("kept"), "1+1".into(), String::new(), String::new())
);
assert_eq!(formula(&s, "J10"), "MAX($B$1)");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(formula(&s, "H2"), "COUNT($A$1:$A$2)");
}
#[test]
fn saving_and_resetting_a_model_keep_the_users_own_solver_names() {
let mut s = s();
set(&mut s, &[("A1", "0"), ("B1", "=ABS(A1-3)"), ("C1", "100")]);
let name = |name: &str, scope: Option<usize>, formula: &str, hidden: bool| gridcraft_model::DefinedName {
name: name.into(),
scope,
formula: formula.into(),
comment: String::new(),
hidden,
};
{
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
// The user's own names, on the sheet and the workbook, and Excel's Solver names the
// model replaces.
wb.names.push(name("solver_budget", Some(0), "Sheet1!$C$1", false));
wb.names.push(name("Solver_Lhs_Total", Some(0), "Sheet1!$C$1", false));
wb.names.push(name("solver_budget", None, "Sheet1!$C$1", false));
wb.names.push(name("solver_lin", Some(0), "2", true));
wb.names.push(name("solver_lhs12", Some(0), "Sheet1!$A$1", true));
}
let names = |s: &Session| -> Vec<String> {
let mut v: Vec<String> = s.doc().unwrap().wb.names.iter().map(|n| format!("{}@{:?}", n.name, n.scope)).collect();
v.sort();
v
};
let mine = ["Solver_Lhs_Total@Some(0)", "solver_budget@None", "solver_budget@Some(0)"];
let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex"});
s.execute("data.solver", model.clone()).unwrap();
let after = names(&s);
assert!(mine.iter().all(|n| after.contains(&n.to_string())), "{after:?}");
assert!(!after.iter().any(|n| n.starts_with("solver_lin") || n.starts_with("solver_lhs12")), "{after:?}");
// Load Model saves the model again; Reset All removes it and nothing else.
s.execute("data.solverSaveModel", json!({"to": "H1"})).unwrap();
s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap();
assert!(mine.iter().all(|n| names(&s).contains(&n.to_string())), "{:?}", names(&s));
let model_names = names(&s).len() - mine.len();
let r = s.execute("data.solverReset", json!({})).unwrap();
assert_eq!((r["removed"].as_u64(), r["masked"].as_bool()), (Some(model_names as u64), Some(false)), "{r}");
assert_eq!(names(&s), mine);
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null());
}
#[test]
fn save_model_refuses_a_place_where_the_options_cannot_spill() {
let mut s = s();
set(&mut s, &[("B1", "=ABS(A1-3)"), ("H24", "=SEQUENCE(1,3)"), ("K30", "=SEQUENCE(3)"), ("M50", "=SEQUENCE(3)")]);
// Tables whose empty data rows meet an options row: one in its way, one holding the
// options cell.
for range in ["T59:U62", "W69:W71"] {
s.execute("insert.table", json!({"range": range, "header": true})).unwrap();
}
let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["F4 <= 5"]});
let save = |s: &mut Session, to: &str| s.execute("data.solverSaveModel", json!({"to": to, "model": model.clone()}));
// The options row (H4) spills into I4:Q4: a value in its last cell is in the way.
set(&mut s, &[("Q4", "x")]);
let e = save(&mut s, "H1").unwrap_err().to_string();
assert!(e.contains("I4:Q4") && e.contains("Q4 isn't empty"), "{e}");
assert_eq!((formula(&s, "H1"), formula(&s, "H4")), (String::new(), String::new()));
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null());
// With the row clear the options spill, and saving again at the same cell replaces them.
set(&mut s, &[("Q4", "")]);
for overwrite in [false, true] {
let p = json!({"to": "H1", "model": model.clone(), "overwrite": overwrite});
assert_eq!(s.execute("data.solverSaveModel", p).unwrap()["range"], "H1:H4");
assert_eq!(
(at(&s, "Sheet1", "H4"), at(&s, "Sheet1", "I4"), at(&s, "Sheet1", "O4"), at(&s, "Sheet1", "Q4")),
(Value::number(1.0), Value::number(1.0), Value::number(50.0), Value::number(0.0))
);
}
// Refused: in the options row, a formula (H24), another formula's spilled values (K31,
// from K30), a cell of the model (F4), merged cells, a table and the sheet's last columns;
// a column over another formula's spilled values (I24 of H24:J24, M51 of M50:M52) or with
// its options cell in a table.
s.execute("home.mergeCells", json!({"range": "S8:T9"})).unwrap();
for (to, why) in [
("G21", "H24 isn't empty"),
("J28", "K31 holds the values of another formula"),
("E1", "F4 is a cell of the model"),
("R5", "merged"),
("S57", "T60:AB60, but a table is in the way"),
("XFA1", "need 10 cells"),
("I21", "cover I24, which holds the values of another formula"),
("M51", "cover M51, which holds the values of another formula"),
("W67", "X70:AF70, but a table is in the way"),
] {
let e = save(&mut s, to).unwrap_err().to_string();
assert!(e.contains(why), "{to}: {e}");
assert_eq!(formula(&s, to), "", "{to}");
}
assert_eq!(
(at(&s, "Sheet1", "J24"), at(&s, "Sheet1", "K32"), at(&s, "Sheet1", "M52")),
(Value::number(3.0), Value::number(3.0), Value::number(3.0))
);
}
#[test]
fn load_model_refuses_a_range_with_a_cell_without_a_formula() {
let mut s = s();
set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2")]);
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6", "A1 <= 3"], "method": "simplex"});
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1", "model": model})).unwrap()["range"], "H1:H6");
// A range with empty cells after the model loads it.
let loaded = s.execute("data.solverLoadModel", json!({"from": "H1:H9"})).unwrap();
assert_eq!(loaded["model"]["constraints"], json!(["B2 <= 24", "B3 <= 6", "A1 <= 3"]), "{loaded}");
s.execute("data.solverReset", json!({})).unwrap();
// A constraint overwritten by a constant would drop that constraint; a gap before the
// options row would drop the options. Both are refused, naming the cell.
for (cell, input) in [("H4", "7"), ("H5", "")] {
let before = formula(&s, cell);
set(&mut s, &[(cell, input)]);
let e = s.execute("data.solverLoadModel", json!({"from": "H1:H6"})).unwrap_err().to_string();
assert!(e.contains(cell), "{e}");
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null(), "{cell}");
set(&mut s, &[(cell, &format!("={before}"))]);
}
assert!(s.execute("data.solverLoadModel", json!({"from": "H1:H6"})).is_ok());
}
#[test]
fn load_model_refuses_a_model_cut_short_and_keeps_the_old_one() {
let mut s = s();
set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2")]);
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6", "A1 <= 3"], "method": "simplex"});
s.execute("data.solver", model).unwrap();
assert_eq!(s.execute("data.solverSaveModel", json!({"to": "H1"})).unwrap()["range"], "H1:H6");
let kept = |s: &mut Session| {
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
assert_eq!((m["constraints"].clone(), m["method"].clone()), (json!(["B2 <= 24", "B3 <= 6", "A1 <= 3"]), json!("simplex")), "{m}");
};
// A constraint cleared (H4), the options row cleared (H6) or replaced by a constant: from
// the first cell or as a range, the rows above the gap are not loaded as the whole model.
for (cell, input) in [("H4", ""), ("H6", ""), ("H6", "7")] {
let before = formula(&s, cell);
set(&mut s, &[(cell, input)]);
for from in ["H1", "H1:H6", "H1:H9"] {
let e = s.execute("data.solverLoadModel", json!({"from": from})).unwrap_err().to_string();
assert!(e.contains("complete model") || e.contains(cell), "{cell} {from}: {e}");
kept(&mut s);
}
set(&mut s, &[(cell, &format!("={before}"))]);
}
// An options row cut short or edited by hand: fewer than the eight values Save Model
// writes, or a value that isn't a number (among the eight, after them or after the sixteenth).
let options = formula(&s, "H6");
for row in [
"={1}",
"={2,1,100,0.075,0,1,0.0001}",
"={2,1,100,0.075,0,1,\"x\",50}",
"={2,TRUE,100,0.075,0,1,0.0001,50}",
"={2,1,100,0.075,0,1,0.0001,50,\"x\"}",
"={2,1,100,0.075,0,1,0.0001,50,9,10,11,12,13,14,15,16,\"x\"}",
] {
set(&mut s, &[("H6", row)]);
for from in ["H1", "H1:H6"] {
let e = s.execute("data.solverLoadModel", json!({"from": from})).unwrap_err().to_string();
assert!(e.contains("complete model"), "{row} {from}: {e}");
kept(&mut s);
}
}
// More values than eight (as a later version may write) still load.
set(&mut s, &[("H6", &format!("={}", options.trim_end_matches('}').to_string() + ",7,8}"))]);
assert!(s.execute("data.solverLoadModel", json!({"from": "H1"})).is_ok());
kept(&mut s);
set(&mut s, &[("H6", &format!("={options}"))]);
// The gap is named when reading down from one cell.
set(&mut s, &[("H4", "")]);
let e = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap_err().to_string();
assert!(e.contains("H4 holds no formula"), "{e}");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.execute("data.solverLoadModel", json!({"from": "H1"})).is_ok());
kept(&mut s);
}
#[test]
fn load_model_reads_a_bounded_column_and_refuses_malformed_cells() {
let mut s = s();
set(&mut s, &[("B1", "=ABS(A1-3)")]);
let put = |s: &mut Session, col: u32, rows: std::ops::Range<u32>, f: &dyn Fn(u32) -> String| {
let d = s.doc_mut().unwrap();
let sh = std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.wb).sheets[0]);
for r in rows {
sh.set_cell(CellRef::new(r, col), gridcraft_model::Cell::formula(gridcraft_model::Formula::new(&f(r))));
}
};
// A column of formulas far longer than any model: read from its first cell, the read stops
// after the most rows a model takes and the column is refused; as a range, it is refused
// before any cell is read.
put(&mut s, 15, 0..5_000, &|r| format!("{r}+1"));
for from in ["P1", "P1:P5000", "P1:P1048576", "A1:XFD1048576"] {
assert!(s.execute("data.solverLoadModel", json!({"from": from})).is_err(), "{from}");
}
// Malformed and hostile cells and ranges: an error each, never a panic, no model.
let bad = ["AND(", "{", "{1e400,2", "MAX(Nope!A1)", "COUNT($A$1:$A$2", "AND($B$1<=", "MAX($B$1)&\"x\""];
put(&mut s, 17, 0..bad.len() as u32, &|r| bad[r as usize].to_string());
put(&mut s, 18, 0..1, &|_| format!("AND({})", "(".repeat(10_000)));
for r in 1..=bad.len() {
assert!(s.execute("data.solverLoadModel", json!({"from": format!("R{r}")})).is_err(), "R{r}");
}
for from in ["S1", "", ":", "P1:", "H0", "Sheet9!H1", "'Sheet1'!R1:R7", "R7:R1"] {
assert!(s.execute("data.solverLoadModel", json!({"from": from})).is_err(), "{from}");
}
assert!(s.execute("data.solverLoadModel", json!({"from": 7})).is_err());
assert!(s.execute("data.solver", json!({})).unwrap()["model"].is_null());
}
#[test]
fn load_model_keeps_the_evolutionary_convergence_and_generations() {
let mut s = s();
set(&mut s, &[("B1", "=ABS(A1-3)")]);
let evo = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "evolutionary", "convergence": 0.001, "generationsWithoutImprovement": 20});
s.execute("data.solverSaveModel", json!({"to": "H1", "model": evo})).unwrap();
let m = s.execute("data.solverLoadModel", json!({"from": "H1"})).unwrap()["model"].clone();
assert_eq!((m["convergence"].as_f64(), m["generationsWithoutImprovement"].as_f64()), (Some(0.001), Some(20.0)), "{m}");
// They survive XLSX; Excel's time limit (solver_mni, in seconds) isn't written or read.
let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "c.xlsx", "base64": b64})).unwrap();
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
assert_eq!((m["convergence"].as_f64(), m["generationsWithoutImprovement"].as_f64()), (Some(0.001), Some(20.0)), "{m}");
assert!(!s.doc().unwrap().wb.names.iter().any(|n| n.name == "solver_mni"));
// Reset All removes the GridCraft name with the rest.
s.execute("data.solverReset", json!({})).unwrap();
assert!(s.doc().unwrap().wb.names.iter().all(|n| n.scope != Some(0)));
}
#[test]
fn solver_options_are_saved_with_the_model() {
let mut s = s();
set(&mut s, &[("B1", "=10*A1+13*A2"), ("B2", "=5*A1+6*A2")]);
let p = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 10", "A1:A2 bin"], "method": "evolutionary", "maxEvaluations": "3000", "integerOptimality": 5, "convergence": 0.001, "generationsWithoutImprovement": 20, "mutationRate": "0.1"});
let r = s.execute("data.solver", p).unwrap();
assert!(r["evaluations"].as_u64().unwrap() <= 3001, "{r}");
// The solve's own result gives whole numbers as integers too.
for k in ["maxEvaluations", "integerOptimality", "generationsWithoutImprovement", "populationSize", "randomSeed"] {
assert!(r["model"][k].is_i64() || r["model"][k].is_u64(), "{k}: {r}");
}
// The options survive XLSX in the hidden names, as given or as text.
let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "o.xlsx", "base64": b64})).unwrap();
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
let f = |k: &str| m[k].as_f64();
assert_eq!((f("maxEvaluations"), f("integerOptimality"), f("convergence")), (Some(3000.0), Some(5.0), Some(0.001)), "{m}");
assert_eq!((f("generationsWithoutImprovement"), f("mutationRate")), (Some(20.0), Some(0.1)));
// Whole numbers read back as integers, so the Solver dialog shows 3000 and not 3000.0.
for k in ["maxEvaluations", "integerOptimality", "generationsWithoutImprovement", "populationSize", "randomSeed"] {
assert!(m[k].is_i64(), "{k}: {m}");
}
assert_eq!(m["maxEvaluations"].to_string(), "3000");
// Out-of-range options are clamped, not refused.
let r = s.execute("data.solver", json!({"saved": true, "maxEvaluations": "1e300", "integerOptimality": -4})).unwrap();
assert_eq!((r["model"]["maxEvaluations"].as_f64(), r["model"]["integerOptimality"].as_f64()), (Some(200_000.0), Some(0.0)));
}
#[test]
fn excel_iteration_and_time_limits_are_not_read_as_gridcraft_options() {
let mut s = s();
set(&mut s, &[("A1", "0"), ("B1", "=ABS(A1-3)")]);
let model = json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "evolutionary"});
s.execute("data.solver", model.clone()).unwrap();
let names = |s: &Session| -> Vec<String> {
let d = s.doc().unwrap();
d.wb.names.iter().filter(|n| n.scope == Some(0)).map(|n| n.name.clone()).collect()
};
// GridCraft's evaluation limit and generations go under its own names, not Excel's.
let saved = names(&s);
assert!(saved.iter().all(|n| n != "solver_itr" && n != "solver_mni"), "{saved:?}");
assert!(saved.iter().any(|n| n == "gridcraft_solver_evals") && saved.iter().any(|n| n == "gridcraft_solver_stall"), "{saved:?}");
// Excel's own limits, as a workbook from Excel holds them: 100 iterations, 30 seconds.
{
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
wb.names.retain(|n| !n.name.starts_with("gridcraft_"));
for (name, formula) in [("solver_itr", "100"), ("solver_mni", "30")] {
wb.names.push(gridcraft_model::DefinedName {
name: name.into(),
scope: Some(0),
formula: formula.into(),
comment: String::new(),
hidden: true,
});
}
}
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
assert!(m["maxEvaluations"].is_null() && m["generationsWithoutImprovement"].is_null(), "{m}");
set(&mut s, &[("A1", "0")]);
let r = s.execute("data.solver", json!({"saved": true})).unwrap();
assert!(r["evaluations"].as_u64().unwrap() > 100, "{r}");
assert!((n(&s, "A1") - 3.0).abs() < 1e-3, "{r}");
// Saving again replaces GridCraft's names; Reset All removes them with the rest. Simplex
// LP refuses the nonlinear ABS model, but the model is saved before the solve.
let r = s.execute(
"data.solver",
json!({"objective": "B1", "goal": "min", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex", "maxEvaluations": 700, "keep": false}),
);
let m = super::saved_model(&s.doc().unwrap().wb, 0).unwrap();
assert_eq!((m["method"].as_str(), m["maxEvaluations"].as_f64()), (Some("simplex"), Some(700.0)), "{m} {r:?}");
let saved = names(&s);
assert_eq!(
saved.iter().filter(|n| n.starts_with("gridcraft_")).collect::<Vec<_>>(),
["gridcraft_solver_evals", "gridcraft_solver_stall"],
"{saved:?}"
);
s.execute("data.solver", model).unwrap();
s.execute("data.solverReset", json!({})).unwrap();
assert!(names(&s).is_empty(), "{:?}", names(&s));
}
#[test]
fn integer_optimality_reads_a_percent_sign_as_percent() {
let mut s = s();
set(&mut s, &[("B1", "=3*A1")]);
for (given, want) in [(json!("5%"), 5.0), (json!(" 2.5 % "), 2.5), (json!("5"), 5.0), (json!(5), 5.0), (json!("150%"), 100.0)] {
let p = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4", "A1 int"], "method": "simplex", "integerOptimality": given});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["model"]["integerOptimality"].as_f64(), Some(want), "{given}: {r}");
}
// Percents whose fraction is not exact in binary read back as given, from the solve's
// result and from the saved model.
for given in [7.0, 29.0, 0.1] {
let p = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4", "A1 int"], "method": "simplex", "integerOptimality": given});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["model"]["integerOptimality"].as_f64(), Some(given), "{r}");
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
assert_eq!(m["integerOptimality"].as_f64(), Some(given), "{m}");
}
}
#[test]
fn evolutionary_options_survive_a_solve_with_another_method() {
let mut s = s();
set(&mut s, &[("B1", "=3*A1")]);
let p = json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "grg", "populationSize": 40, "mutationRate": 0.2, "randomSeed": 7, "convergence": 0.001, "generationsWithoutImprovement": 25, "requireBounds": false});
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["model"]["populationSize"].as_f64(), Some(40.0), "{r}");
// The saved model keeps them for the next time the method is Evolutionary.
let m = s.execute("data.solver", json!({})).unwrap()["model"].clone();
let f = |k: &str| m[k].as_f64();
assert_eq!((f("populationSize"), f("mutationRate"), f("randomSeed")), (Some(40.0), Some(0.2), Some(7.0)), "{m}");
assert_eq!((f("convergence"), f("generationsWithoutImprovement"), m["requireBounds"].as_bool()), (Some(0.001), Some(25.0), Some(false)));
}
#[test]
fn limits_report_of_a_nonlinear_model() {
// The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the
+16 -4
View File
@@ -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(&params)].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()),
+220 -12
View File
@@ -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;
}
});
});
}
+8 -3
View File
@@ -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));
}
+283
View File
@@ -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
View File
@@ -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) |
+4 -2
View File
@@ -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 |