mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 00:41:56 -06:00
* Data: Solver Sensitivity report Builds on the Answer and Limits reports. reports: [sensitivity] (on data.solver and data.solverReport) writes a Sensitivity report for models solved by Simplex LP or GRG Nonlinear without integer constraints and with a Max or Min objective. Simplex LP: the model is linearised at the solution and solved again, and the report is read from the final tableau: each variable's reduced cost and the range of its objective coefficient over which the basis stays optimal, and each constraint's shadow price and the range of its right side over which that price holds (1E+30 for no limit). GRG Nonlinear: difference gradients at the solution give each binding constraint's Lagrange multiplier (a least-squares fit on the variables inside their bounds, dropping constraints whose multiplier has the wrong sign) and each variable's reduced gradient. The Solver Results dialog offers the report. * Data: Solver Results offers the Sensitivity report only where it applies Integer and binary models, the Evolutionary method and Value Of goals have no Sensitivity report, and choosing it with other reports made the whole report request fail. Solver Results now disables the Sensitivity choice for such models (with the reason on hover) and leaves it out of the request, so the other chosen reports are still written. sensitivity_applies (in cmd::solver) reads the model from data.solver params, as the dialog holds them; a test checks that it agrees with the report for each kind of model. * Data: Solver bounds the nonlinear Sensitivity report's work The Lagrange multipliers of a GRG Nonlinear model are found by least squares over the binding constraints; a constraint whose multiplier has the wrong sign is dropped and the rest are solved again. Each pass costs about 2·k²·f operations for k binding constraints and f free variables, and each pass drops at most one constraint, so a model at the 1,000-constraint limit could take minutes and hold the window. The passes now share a limit of about 10⁹ operations (about a second). Past it the report is refused with a message, before anything changes: the workbook and the Undo history stay as they were, as for the other report refusals. The limit is lowered in tests so the refusal can be reached with a small model. * Data: Solver refuses a Simplex Sensitivity report for a point that is not optimal The Simplex LP Sensitivity report solves the linearised model again to read its final tableau, but shows the final values it was given. data.solverReport accepts any values, so a report could pair values that are not optimal (or not feasible) with the shadow prices, reduced costs and ranges of the true optimum. The report now checks that the given point satisfies every constraint and bound and reaches the optimal objective (both within the constraint precision; another optimal point with the same objective is accepted) and is refused otherwise, before anything changes. * Data: Solver compares the Sensitivity report's objective in both directions The check that a point is optimal compared its objective with the optimum in one direction only, as for an inequality. A point just outside a bound (within the constraint precision) can still have an objective far below the minimum when its costs are large, and that point was accepted. The objective is now compared by the size of the difference, so such a point is refused. * Data: Solver refuses a nonlinear Sensitivity report for a point that is not optimal data.solverReport accepts any finite variable values. The Simplex LP report already refuses values that are not optimal, but the GRG Nonlinear report described whatever point it was given: for example, a minimisation of A1^2 given A1 = 1 found no binding rows and reported a reduced gradient of 2 instead of requiring the optimum at A1 = 0. The nonlinear report now checks, from the gradients it already computes, that the values are a Karush-Kuhn-Tucker point before anything changes: every constraint holds within the tolerance the nonlinear method accepts a solution within, every bound holds within the constraint precision, each variable inside its bounds has a reduced gradient near zero, and a variable at a bound has a reduced gradient that points only out of that bound for the objective's direction. A binding row whose multiplier has the wrong sign is still dropped, and the gradient it would have explained now causes the refusal. The stationarity tolerance is 1E-3 of the largest objective-gradient component (at least 1), well above the reduced gradients left at optima the method solves. * Data: Solver writes the other reports when the Sensitivity report does not apply to the solution Since the nonlinear Sensitivity report refuses values that are not optimal, data.solver with reports that include sensitivity failed as a whole when the run stopped before an optimum (an evaluation limit) or the values were not one, so the solution and the Answer and Limits reports were lost. Solver Results also offered Sensitivity after any solve, and ticking it with Answer after such a run wrote no report. data.solver now writes the Sensitivity report only when the run reached an optimal or converged solution that the report accepts. Otherwise the solve still applies, the other reports are written, and the result lists the report in skippedReports with the reason. Models that have no Sensitivity report (integer, Evolutionary, Value Of) are refused as before. data.solverReport still refuses values that are not optimal; its new skipNotOptimal parameter leaves the report out instead. Solver Results offers Sensitivity only after an optimal or converged status, with the reason on hover, passes skipNotOptimal, and shows the reason when the report is left out. * Data: Solver skips a Sensitivity report in a protected workbook without refusing the call Structure protection is now checked only when a report sheet will be written. A data.solverReport request with skipNotOptimal whose only report is skipped changes nothing, so it succeeds in a workbook whose structure is protected and returns skippedReports; a request that would write a sheet is still refused. * Data: Solver refuses report sheets in a protected workbook before building them A request that must write a report sheet (any Answer or Limits report, or a Sensitivity report that is not allowed to be skipped) is refused before any report is built, so a protected workbook does not run the Limits report's optimisations first. The check after the reports are built remains for a Sensitivity report that may be skipped. * Data: Solver reports Simplex sensitivity only at the vertex it describes The Simplex LP Sensitivity report solves the linearised model again and takes its prices and ranges from that final tableau, but it discarded the vertex the solve returned and showed the values it was given. When the optimum is not unique, another optimal point passed the optimality check, so the report paired those values with the ranges of a different vertex. The report now also requires the given values to match the re-solved vertex within the constraint precision, and refuses another optimum with a message that says so. A Sensitivity report that data.solver or Solver Results may skip is left out instead, as for other points the report does not support. Values from a Simplex LP solve still match, so the usual Solver Results flow is unchanged. * Data: Solver gives a redundant constraint's right side no allowable change The Simplex LP Sensitivity report ranges each right side over the basic values of the real columns only. A row that the other rows make redundant (max x with x = 2 and 2x = 4) keeps its artificial column basic at zero after phase one, and changing either right side alone moves that artificial, so no change has a solution. The report showed 1E+30 and 2 for the first row instead. When a basic artificial has a nonzero entry in the column of the right side being changed, both allowable changes are now 0. The objective-coefficient ranges are unaffected: such a row has no cost and no entries in the real columns, and a cost change does not affect feasibility. * Data: Solver builds the Simplex Sensitivity report's program at the solve's start values The Simplex LP solve linearises the model at the start values, but the Sensitivity report linearised it again at the solution. The difference coefficients then differ in their last bits, and when the optimum is not unique that can break a tie between pivots the other way: the report's solve returned the other vertex and refused the solve's own values. With max 0.1x + 0.1y over a tied edge, more than half of the sampled starting points were refused. The solve and the report now build the program with one function, simplex_program, and the report builds it from the start values it is given (data.solver's start values, which Solver Results passes back as original), so its solve repeats the solve's pivots and returns the same vertex. Start values at which the model cannot be evaluated fall back to the solution, and values that are not the solve's are still refused by the vertex check. * Data: Solver's Sensitivity report sets its noise levels from the coefficients, not the objective's value The figures a Sensitivity report prints as 0 were those below 1E-9 of the objective's value (1E-6 for the nonlinear figures), so a constant in the objective hid real prices: max 1E9 + x with x <= 10 wrote the row's shadow price, exactly 1, as 0, although the constant changes nothing about the optimisation. A shadow price and a reduced cost are combinations of the objective coefficients, so Simplex LP now takes the noise level from the largest coefficient; a reduced gradient is a part of the objective's gradient and a Lagrange multiplier objective per unit of its row's gradient, so GRG Nonlinear takes it from the gradient's size, and the multipliers' sign test compares lambda times the row's gradient size with it. Allowable changes keep the tableau's pivot tolerance as before. * Data: Solver Sensitivity report tests for variables at a bound and for both goals Hand-derived cases pin the report's conventions. Simplex LP: a maximum's variable at its upper bound (reduced cost 1, Allowable Increase 1E+30, Allowable Decrease 1), a minimum with a >= row and a variable at its upper bound (reduced cost -1, Allowable Increase 1, Allowable Decrease 1E+30), and Winston's Dakota Furniture problem, whose variable at zero has reduced cost -5 with Allowable Increase 5 and Allowable Decrease 1E+30, with every shadow price and allowable range worked out from the final basis. GRG Nonlinear: the Lagrange multiplier is the derivative of the optimal objective with respect to the right side and the reduced gradient that with respect to the variable's bound for a minimum (-1) and the negated maximum (+1), and the mirror image of the existing minimum with a variable at its bound (multiplier +2, reduced gradient -4). * Docs: ROADMAP.md records the Solver Sensitivity report The detail documents (docs/gaps.md, docs/target-app-parity.md) record the report, but the roadmap summary did not: its header change note, progress log and revision history now carry it. * Data: Solver's Sensitivity report bounds each figure's rounding noise by its own terms, not the model's largest coefficient or gradient The noise level a figure had to exceed to print was 1E-9 of the largest objective coefficient (Simplex LP) or 1E-6 of the largest gradient component (GRG Nonlinear) for every figure, so an unrelated large coefficient hid real values: max 1E9*x + y with x <= 1 and y <= 1 as rows wrote the second row's shadow price, exactly 1, as 0, and the same model's Lagrange multiplier 1 and a third variable's reduced cost or gradient -1 went the same way. The rounding error of a sum scales with the sum of its terms' sizes, so each figure now has its own bound from the terms it is made of. Simplex LP: a shadow price y_i = sum over the basis rows of c_B(r) * Binv(r, i) is bounded by 1E-9 of those terms' sizes (the row's unit column of the final tableau holds Binv's column i); a reduced cost cf_j - sum of a_ij * y_i by 1E-9 of |cf_j| plus |a_ij| times each price's term sizes, so a price that is a small difference of large terms carries their error into the reduced costs it is used in. GRG Nonlinear: a multiplier solved from its row of the normal equations is bounded by 1E-6 of its own terms (the row's gradient times the objective's, and its coupling terms with the other active rows times their multipliers) over the row's squared norm; the multipliers' sign test uses the same bound, so a row leaves the active set on the criterion the report rounds with; a reduced gradient is bounded by 1E-6 of its own component and the multipliers' terms plus the multipliers' bounds times |g_ij|. Nearly parallel active gradients amplify every multiplier's error beyond a bound local to a row; such multipliers print as computed. A constant in the objective still changes no bound, since none depends on the objective's value. The nonlinear method stops on the 1E9 model from (0, 0) with "unbounded", so its two regression tests ask for the report at the optimum through data.solverReport. * Data: Solver's Sensitivity report tests a nonlinear point's optimality by each variable's and row's own scale, not the model's largest The Karush-Kuhn-Tucker check that refuses a Sensitivity report for a point that is not optimal accepted a reduced gradient within 1E-3 of the largest gradient component, and a row violation within ten times the constraint precision of the largest constant right side. Both scales were the model's, not the figure's: on max 1E9*x + y with x <= 1 and y <= 1 as rows the tolerance was 1E6, so (1, 0.5), where raising y gains 1 per unit, passed as optimal and the report was written for it; on max y - (x - 2)^2 with x <= 1E9 and y <= 1, (2, 1.0001) passed although the second row is over by 1E-4 against its own precision of 2E-5. Each reduced gradient is now tested against 1E-3 of the terms it is made of (the objective's component and the multipliers' parts, at least 1, the same floor as before), and each row against ten times the constraint precision of its own right side. The nonlinear method itself accepts a solution relative to the largest constant right side; a row over by more than its own precision is not satisfied whatever the other rows' sizes, so the report refuses it. The term sizes are shared with the reduced gradients' rounding bound. Two regression tests, each proving the workbook is left as it was and that the optimum is still reported. * Data: Solver refuses a Sensitivity report the model has none of before any report is built or the solve runs The refusals that apply to the model rather than the solution (integer or binary constraints, the Evolutionary method, a Value Of goal) were made inside the report loop, when it reached the Sensitivity report, so a request listing the Limits or Answer report first built those reports for an error: a Value Of model asking for limits and sensitivity ran the Limits report's two optimisations per variable and received only the refusal, and data.solver ran its whole solve before it. prepare_reports now checks the model's refusal before the loop, in both modes (a refusal of the model was already an error in both; only the solution's not-optimal refusal is skipped under skipNotOptimal), and data.solver checks it before the solve. The regression test asks for limits and sensitivity on an integer model, which now gets the Sensitivity refusal instead of the Limits report's, and on a Value Of model, which gets the Sensitivity refusal with no sheet written. --------- Co-authored-by: FredApps <FredApps@users.noreply.github.com>
336 lines
18 KiB
Rust
336 lines
18 KiB
Rust
//! Solver (Data › Analysis › Solver): finds values for variable cells that maximise, minimise
|
||
//! or hit a value in an objective cell, subject to constraints.
|
||
//!
|
||
//! The workbook is the model: every evaluation sets the variable cells on a copy of the
|
||
//! workbook and recalculates their dependents ([`Trial`]), so any formula works. Two methods:
|
||
//!
|
||
//! - **Simplex LP**: the model is linearised from `n + 1` evaluations and checked for linearity
|
||
//! at one more point; then a dense two-phase simplex (Dantzig's rule, Bland's rule when it
|
||
//! stalls) solves it exactly. Integer and binary constraints use depth-first branch and bound.
|
||
//! - **GRG Nonlinear** (the name Excel's dialog uses): GridCraft's method is *not* a generalised
|
||
//! reduced gradient. It is an augmented-Lagrangian method (Powell–Hestenes–Rockafellar) whose
|
||
//! inner problems are solved by projected gradient descent with Barzilai–Borwein steps and an
|
||
//! Armijo line search, on central-difference gradients. Variable bounds are kept by
|
||
//! projection. It finds a local optimum of a smooth model; integer constraints use a small
|
||
//! branch and bound on top.
|
||
//!
|
||
//! - **Evolutionary**: a genetic search for models that aren't smooth (IF, lookups, rounding),
|
||
//! with tournament selection, blend crossover and Gaussian mutation inside the variable bounds,
|
||
//! feasibility-first ranking and integer rounding, followed by a compass (pattern) search around
|
||
//! the best member. It stops when the population has converged, when the best member stops
|
||
//! improving, or at the evaluation budget.
|
||
//!
|
||
//! Work is bounded by an evaluation budget (no clock: `Instant` traps on wasm). The model is
|
||
//! saved as hidden sheet-scoped names (`solver_opt`, `solver_adj`, `solver_lhs1`…), so it
|
||
//! travels with the workbook through XLSX.
|
||
|
||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||
use gridcraft_model::{Cell, DefinedName, Sheet, Workbook};
|
||
use serde_json::{Value as Json, json};
|
||
|
||
use super::check_arrays;
|
||
use super::whatif::Trial;
|
||
use super::{CommandSpec, Ctx, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, str_param};
|
||
use gridcraft_core::split_sheet;
|
||
|
||
mod evolutionary;
|
||
mod lp;
|
||
mod model;
|
||
mod model_range;
|
||
mod nlp;
|
||
mod problem;
|
||
mod reports;
|
||
mod sensitivity;
|
||
|
||
use evolutionary::{EvoEnd, evolutionary};
|
||
use lp::{Outcome, SimplexProgram, branch_and_bound, simplex_program, solve_lp};
|
||
use model::{Goal, Method, Model, model_params, parse_model, save_model};
|
||
pub use model::{saved_model, sensitivity_applies, sensitivity_status};
|
||
use model_range::{load_model_cells, reset_model, save_model_cells};
|
||
use nlp::{nlp, project};
|
||
use problem::{Evaluator, as_num, build_problem};
|
||
use reports::{NotOptimal, 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).
|
||
const MAX_CONSTRAINTS: usize = 1000;
|
||
const DEFAULT_EVALS: u64 = 20_000;
|
||
const MAX_EVALS: u64 = 200_000;
|
||
/// Most simplex pivots × tableau cells per solve.
|
||
const LP_WORK: u64 = 2_000_000_000;
|
||
const MAX_NODES_LP: usize = 10_000;
|
||
const MAX_NODES_NLP: usize = 200;
|
||
/// Evolutionary population sizes.
|
||
const MIN_POPULATION: usize = 10;
|
||
const MAX_POPULATION: usize = 1000;
|
||
/// Constraint precision (Excel's default 0.000001).
|
||
const PRECISION: f64 = 1e-6;
|
||
const EPS: f64 = 1e-9;
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"data.solver",
|
||
"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 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|sensitivity|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], skippedReports?: [{report: \"sensitivity\", reason}] (a Sensitivity report left out because the run did not reach an optimum or the values are not one; the solve and the other reports still apply)}. No `objective` → {model: the saved model} (the menu opens the dialog).",
|
||
has_doc,
|
||
run
|
||
),
|
||
cmd!(
|
||
"data.solverReport",
|
||
"Solver Reports",
|
||
[],
|
||
None,
|
||
"{reports: [answer|sensitivity|limits], values?: {cell: number} (the solution; default the variable cells' current values), original?: {cell: number} (the values before solving; default `values`), message?: text (the result line), skipNotOptimal?: bool (default false: a Sensitivity report for values that are not optimal refuses the call; true leaves it out and writes the others), model?: data.solver params (default the sheet's saved model, with any data.solver params given here over it)} → {sheets: [names], skippedReports?: [{report, reason}]}. Writes Answer, Sensitivity and Limits reports on new sheets before the model's sheet; Sensitivity and Limits need Simplex LP or GRG Nonlinear and no integer constraints, and Sensitivity a Max or Min objective. data.solver's result can be passed as is.",
|
||
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
|
||
),
|
||
]
|
||
}
|
||
|
||
/// What a solve ended with, and why Simplex LP or Evolutionary stopped.
|
||
struct Solve {
|
||
outcome: Outcome,
|
||
not_linear: bool,
|
||
evo_end: Option<EvoEnd>,
|
||
}
|
||
|
||
/// Variable bounds and integrality flags.
|
||
#[derive(Clone, Copy)]
|
||
struct Bounds<'a> {
|
||
lo: &'a [f64],
|
||
hi: &'a [f64],
|
||
integer: &'a [bool],
|
||
}
|
||
|
||
/// Runs the model's method for `goal` within `lo..hi` from `x0`.
|
||
fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], bounds: Bounds, gap: f64) -> Solve {
|
||
let Bounds { lo, hi, integer } = bounds;
|
||
let any_int = integer.iter().any(|b| *b);
|
||
let mut not_linear = false;
|
||
let mut evo_end = None;
|
||
let outcome = if lo.iter().zip(hi).any(|(l, h)| l > h) {
|
||
Outcome::Infeasible
|
||
} else {
|
||
match m.method {
|
||
Method::Simplex => match simplex_program(ev, goal, x0) {
|
||
Err("nonlinear") | Err("error") => {
|
||
not_linear = true;
|
||
Outcome::Infeasible
|
||
}
|
||
Err(_) => Outcome::Limit,
|
||
Ok(SimplexProgram { c, rows, mut work, .. }) => {
|
||
let mut relax = |l: &[f64], h: &[f64]| solve_lp(&c, &rows, l, h, &mut work);
|
||
if any_int { branch_and_bound(lo, hi, integer, MAX_NODES_LP, gap, &mut relax).0 } else { relax(lo, hi) }
|
||
}
|
||
},
|
||
Method::Grg => {
|
||
if any_int {
|
||
let mut relax = |l: &[f64], h: &[f64]| {
|
||
let mut start = x0.to_vec();
|
||
project(&mut start, l, h);
|
||
nlp(ev, &start, l, h, goal)
|
||
};
|
||
branch_and_bound(lo, hi, integer, MAX_NODES_NLP, gap, &mut relax).0
|
||
} else {
|
||
nlp(ev, x0, lo, hi, goal)
|
||
}
|
||
}
|
||
Method::Evolutionary => {
|
||
let (out, end) = evolutionary(ev, x0, lo, hi, integer, goal, &m.evo);
|
||
evo_end = Some(end);
|
||
out
|
||
}
|
||
}
|
||
};
|
||
Solve { outcome, not_linear, evo_end }
|
||
}
|
||
|
||
// ---------------------------------------------------------------- command
|
||
|
||
fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||
let d = s.doc()?;
|
||
let active = d.wb.active_sheet();
|
||
let mut params = p.clone();
|
||
if bool_param(p, "saved").unwrap_or(false) {
|
||
let saved = saved_model(&d.wb, active).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))?;
|
||
if let (Some(base), Some(over)) = (saved.as_object(), p.as_object()) {
|
||
let mut merged = base.clone();
|
||
for (k, v) in over {
|
||
merged.insert(k.clone(), v.clone());
|
||
}
|
||
params = Json::Object(merged);
|
||
}
|
||
}
|
||
let has_objective = ["objective", "setObjective", "set"].iter().any(|k| str_param(¶ms, k).is_some_and(|t| !t.trim().is_empty()));
|
||
if !has_objective {
|
||
let model = saved_model(&d.wb, active);
|
||
s.ui_requests.push(crate::UiRequest::Dialog("solver".into(), json!({})));
|
||
return Ok(json!({"model": model}));
|
||
}
|
||
let model = parse_model(s, ¶ms)?;
|
||
let (budget, gap) = (model.max_evals, model.gap);
|
||
let keep = bool_param(¶ms, "keep").unwrap_or(true);
|
||
let save = bool_param(¶ms, "saveModel").unwrap_or(true);
|
||
let reports = report_kinds(CMD, ¶ms)?;
|
||
let prob = build_problem(&model);
|
||
// A Sensitivity report the model has none of (integer, Evolutionary, Value Of) fails the
|
||
// call, so it is refused before the solve runs rather than after it.
|
||
if let Some(kinds) = &reports
|
||
&& kinds.contains(&reports::ReportKind::Sensitivity)
|
||
&& let Some(why) = sensitivity::sensitivity_refusal(&model, &prob)
|
||
{
|
||
return Err(EngineError::Other(why.into()));
|
||
}
|
||
let d = s.doc()?;
|
||
let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?;
|
||
let x0: Vec<f64> = model.vars.iter().map(|c| as_num(&sh.value(*c))).map(|v| if v.is_finite() { v } else { 0.0 }).collect();
|
||
let mut ev = Evaluator {
|
||
trial: Trial::new(d),
|
||
sheet: model.sheet,
|
||
vars: model.vars.clone(),
|
||
objective: model.objective,
|
||
rows: prob.rows.clone(),
|
||
evals: 0,
|
||
budget,
|
||
target: None,
|
||
};
|
||
if model.method == Method::Evolutionary && model.evo.require_bounds && prob.lo.iter().chain(&prob.hi).any(|b| !b.is_finite()) {
|
||
return Err(EngineError::Other(
|
||
"The Evolutionary method needs a lower and an upper bound on every variable cell (constraints such as \"A1:A3 <= 10\"), or requireBounds: false."
|
||
.into(),
|
||
));
|
||
}
|
||
let Solve { outcome, not_linear, evo_end } =
|
||
optimise(&mut ev, &model, model.goal, &x0, Bounds { lo: &prob.lo, hi: &prob.hi, integer: &prob.integer }, gap);
|
||
let (status, message, solution) = match outcome {
|
||
_ if not_linear => ("notLinear", "The model isn't linear in the variable cells, so Simplex LP can't solve it. Try GRG Nonlinear.", None),
|
||
Outcome::Solved(x, _, true) if model.method == Method::Evolutionary => match evo_end {
|
||
Some(EvoEnd::Stalled) => {
|
||
("cannotImprove", "Solver stopped when the best solution stopped improving. All constraints are satisfied.", Some(x))
|
||
}
|
||
_ => ("converged", "The population converged. The best solution found satisfies all constraints.", Some(x)),
|
||
},
|
||
Outcome::Solved(x, _, true) => (
|
||
if model.method == Method::Simplex { "optimal" } else { "converged" },
|
||
if model.method == Method::Simplex {
|
||
"Solver found an optimal solution. All constraints are satisfied."
|
||
} else {
|
||
"Solver converged to a locally optimal solution. All constraints are satisfied."
|
||
},
|
||
Some(x),
|
||
),
|
||
Outcome::Solved(x, _, false) => {
|
||
("iterationLimit", "Solver stopped at its evaluation limit; the best solution found satisfies the constraints.", Some(x))
|
||
}
|
||
Outcome::Limit => ("iterationLimit", "Solver stopped at its evaluation limit without finding a feasible solution.", None),
|
||
Outcome::Infeasible => ("infeasible", "Solver could not find a solution that satisfies all constraints.", None),
|
||
Outcome::Unbounded => ("unbounded", "The objective can grow without limit: the problem is unbounded.", None),
|
||
};
|
||
let evaluations = ev.evals;
|
||
// The objective's value at the solution.
|
||
let objective = solution.as_ref().and_then(|x| {
|
||
ev.budget = ev.budget.saturating_add(1);
|
||
ev.eval(x).map(|p| p.f)
|
||
});
|
||
let mut values = serde_json::Map::new();
|
||
if let Some(x) = &solution {
|
||
for (c, v) in model.vars.iter().zip(x) {
|
||
values.insert(c.a1(), json!(v));
|
||
}
|
||
}
|
||
let original: serde_json::Map<String, Json> = model.vars.iter().zip(&x0).map(|(c, v)| (c.a1(), json!(v))).collect();
|
||
let changed = keep && solution.is_some();
|
||
// A Sensitivity report the solution doesn't support is left out (with its reason), so the
|
||
// solve and the other reports still apply.
|
||
let (built, skipped) = match (&reports, &solution) {
|
||
(Some(kinds), Some(x)) => {
|
||
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x.clone(), message: message.to_string(), budget };
|
||
prepare_reports(s.doc()?, kinds, &inp, NotOptimal::Skip { optimum: sensitivity_status(status) })?
|
||
}
|
||
_ => (Vec::new(), Vec::new()),
|
||
};
|
||
let count = built.len();
|
||
let before = s.doc()?.wb.clone();
|
||
let mut out = json!({
|
||
"status": status,
|
||
"message": message,
|
||
"objective": objective,
|
||
"values": values,
|
||
"original": original,
|
||
"model": model_params(&before, &model),
|
||
"evaluations": evaluations,
|
||
"changed": changed,
|
||
});
|
||
let names = if changed || save || count > 0 {
|
||
let sheet = model.sheet;
|
||
edit(s, |cx| {
|
||
if changed && let Some(x) = &solution {
|
||
let sh = cx.sheet_mut(sheet)?;
|
||
for (c, v) in model.vars.iter().zip(x) {
|
||
let style = sh.style_id(*c);
|
||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
|
||
}
|
||
for c in &model.vars {
|
||
cx.touch(sheet + count, *c);
|
||
}
|
||
}
|
||
if save {
|
||
save_model(&mut cx.wb, &model);
|
||
}
|
||
if count > 0 {
|
||
// Inserting sheets makes this structural, so retain the variable edit's array check.
|
||
check_arrays(&before, &cx.wb)?;
|
||
}
|
||
Ok(write_reports(cx, sheet, built))
|
||
})?
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
insert_report_selections(s, model.sheet, count);
|
||
if reports.is_some() && solution.is_some() {
|
||
out["reports"] = json!(names);
|
||
}
|
||
if !skipped.is_empty() {
|
||
out["skippedReports"] = json!(skipped);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|