gridcraft/crates/engine/src/cmd/solver.rs

2464 lines
103 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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::whatif::Trial;
use super::*;
const CMD: &str = "data.solver";
const REPORT_CMD: &str = "data.solverReport";
/// 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 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).",
has_doc,
run
),
cmd!(
"data.solverReport",
"Solver Reports",
[],
None,
"{reports: [answer|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), model?: data.solver params (default the sheet's saved model, with any data.solver params given here over it)} → {sheets: [names]}. Writes Answer and Limits reports on new sheets before the model's sheet; Limits needs Simplex LP or GRG Nonlinear and no integer constraints. data.solver's result can be passed as is.",
has_doc,
report
),
]
}
// ---------------------------------------------------------------- model
#[derive(Clone, Copy, Debug, PartialEq)]
enum Rel {
Le,
Eq,
Ge,
Int,
Bin,
}
impl Rel {
fn parse(s: &str) -> Option<Rel> {
match s.trim().to_ascii_lowercase().as_str() {
"<=" | "≤" | "=<" | "le" => Some(Rel::Le),
">=" | "≥" | "=>" | "ge" => Some(Rel::Ge),
"=" | "==" | "eq" => Some(Rel::Eq),
"int" | "integer" => Some(Rel::Int),
"bin" | "binary" => Some(Rel::Bin),
_ => None,
}
}
fn text(self) -> &'static str {
match self {
Rel::Le => "<=",
Rel::Eq => "=",
Rel::Ge => ">=",
Rel::Int => "int",
Rel::Bin => "bin",
}
}
/// The relation code of the saved model (`solver_relN`), as in Solver's VBA `SolverAdd`.
fn code(self) -> u8 {
match self {
Rel::Le => 1,
Rel::Eq => 2,
Rel::Ge => 3,
Rel::Int => 4,
Rel::Bin => 5,
}
}
fn from_code(c: &str) -> Option<Rel> {
match c.trim() {
"1" => Some(Rel::Le),
"2" => Some(Rel::Eq),
"3" => Some(Rel::Ge),
"4" => Some(Rel::Int),
"5" => Some(Rel::Bin),
_ => None,
}
}
}
#[derive(Clone, Debug)]
enum Rhs {
None,
Const(f64),
Cells(Vec<CellRef>),
}
#[derive(Clone, Debug)]
struct Constraint {
lhs: Vec<CellRef>,
lhs_text: String,
rel: Rel,
rhs: Rhs,
rhs_text: String,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Goal {
Max,
Min,
Value(f64),
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Method {
Simplex,
Grg,
Evolutionary,
}
/// The Evolutionary method's options (Solver Options › Evolutionary). The search keeps no
/// clock, so the time without improvement is counted in generations.
#[derive(Clone, Copy, Debug, PartialEq)]
struct EvoOptions {
population: usize,
mutation: f64,
seed: u64,
convergence: f64,
stall: u64,
require_bounds: bool,
}
impl Default for EvoOptions {
fn default() -> EvoOptions {
EvoOptions { population: 100, mutation: 0.075, seed: 0, convergence: 1e-4, stall: 50, require_bounds: true }
}
}
fn evo_options(p: &Json) -> EvoOptions {
let d = EvoOptions::default();
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());
EvoOptions {
population: num("populationSize").map_or(d.population, |v| v.clamp(MIN_POPULATION as f64, MAX_POPULATION as f64) as usize),
mutation: num("mutationRate").map_or(d.mutation, |v| v.clamp(0.0, 1.0)),
seed: num("randomSeed").map_or(d.seed, |v| v.clamp(0.0, f64::from(u32::MAX)) as u64),
convergence: num("convergence").map_or(d.convergence, |v| v.clamp(0.0, 1.0)),
stall: num("generationsWithoutImprovement").map_or(d.stall, |v| v.clamp(1.0, 100_000.0) as u64),
require_bounds: bool_param(p, "requireBounds").unwrap_or(d.require_bounds),
}
}
#[derive(Clone, Debug)]
struct Model {
sheet: usize,
objective: CellRef,
goal: Goal,
vars: Vec<CellRef>,
vars_text: String,
cons: Vec<Constraint>,
non_negative: bool,
method: Method,
evo: EvoOptions,
}
/// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`.
fn cells_of(wb: &Workbook, t: &str, default_sheet: usize, cap: usize) -> Result<(usize, Vec<CellRef>)> {
let mut sheet = None;
let mut out = Vec::new();
for part in t.split(',').map(str::trim).filter(|x| !x.is_empty()) {
let (sh, body) = split_sheet(part);
let si = match sh {
Some(n) => wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
None => default_sheet,
};
if *sheet.get_or_insert(si) != si {
return Err(bad(CMD, "all cells of a reference must be on one sheet"));
}
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a reference: `{part}`")))?;
if r.count() > cap as u64 || out.len() + r.count() as usize > cap {
return Err(bad(CMD, format!("too many cells (at most {cap})")));
}
out.extend(r.iter());
}
if out.is_empty() {
return Err(bad(CMD, format!("not a reference: `{t}`")));
}
Ok((sheet.unwrap_or(default_sheet), out))
}
fn num_text(t: &str) -> Option<f64> {
gridcraft_core::parse::parse_number_text(t.trim()).filter(|v| v.is_finite())
}
/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs).
fn split_constraint(t: &str) -> Option<(String, Rel, String)> {
for op in ["<=", ">=", "=<", "=>", "≤", "≥", "=="] {
if let Some((a, b)) = t.split_once(op) {
return Some((a.trim().into(), Rel::parse(op)?, b.trim().into()));
}
}
if let Some((a, b)) = t.split_once('=') {
return Some((a.trim().into(), Rel::Eq, b.trim().into()));
}
let lower = t.trim().to_ascii_lowercase();
for (suffix, rel) in [("integer", Rel::Int), ("binary", Rel::Bin), ("int", Rel::Int), ("bin", Rel::Bin)] {
if let Some(a) = lower.strip_suffix(suffix) {
let a = a.trim();
return Some((t.trim().get(..a.len())?.trim().to_string(), rel, String::new()));
}
}
None
}
fn parse_constraint(wb: &Workbook, sheet: usize, c: &Json) -> Result<Constraint> {
let (lhs_text, rel, rhs_json) = match c {
Json::String(t) => {
let (l, r, rhs) = split_constraint(t).ok_or_else(|| bad(CMD, format!("not a constraint: `{t}`")))?;
(l, r, Json::String(rhs))
}
Json::Object(_) => {
let lhs = str_param(c, "lhs").or_else(|| str_param(c, "cell")).or_else(|| str_param(c, "range")).unwrap_or("").to_string();
let op = str_param(c, "op").or_else(|| str_param(c, "relation")).unwrap_or("");
let rel = Rel::parse(op).ok_or_else(|| bad(CMD, format!("unknown constraint relation `{op}`")))?;
(lhs, rel, c.get("rhs").or_else(|| c.get("value")).cloned().unwrap_or(Json::Null))
}
_ => return Err(bad(CMD, "a constraint is a string like \"B5 <= 10\" or {lhs, op, rhs}")),
};
let (lsi, lhs) = cells_of(wb, &lhs_text, sheet, MAX_CONSTRAINTS)?;
if lsi != sheet {
return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into()));
}
let (rhs, rhs_text) = match rel {
Rel::Int | Rel::Bin => (Rhs::None, String::new()),
_ => match &rhs_json {
Json::Number(n) => {
let v = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "the constraint's right side must be a number"))?;
(Rhs::Const(v), v.to_string())
}
Json::String(t) => {
let t = t.trim().trim_start_matches('=').trim();
if let Some(v) = num_text(t) {
(Rhs::Const(v), t.to_string())
} else {
let (rsi, cells) = cells_of(wb, t, sheet, MAX_CONSTRAINTS)?;
if rsi != sheet {
return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into()));
}
if cells.len() != 1 && cells.len() != lhs.len() {
return Err(EngineError::Other("The constraint's two sides must be the same size (or the right side one cell).".into()));
}
(Rhs::Cells(cells), t.to_string())
}
}
_ => return Err(bad(CMD, "missing the constraint's right side")),
},
};
Ok(Constraint { lhs, lhs_text, rel, rhs, rhs_text })
}
fn parse_model(s: &Session, p: &Json) -> Result<Model> {
let d = s.doc()?;
let wb = &d.wb;
let obj_text = str_param(p, "objective").or_else(|| str_param(p, "setObjective")).or_else(|| str_param(p, "set")).unwrap_or("");
let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet, 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?;
let objective = *obj.first().ok_or_else(|| EngineError::Other("The objective must be a single cell.".into()))?;
let sh = wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
if sh.cell(objective).is_none_or(|c| c.formula.is_none()) {
return Err(EngineError::Other("The objective cell must contain a formula.".into()));
}
let goal = match str_param(p, "goal").or_else(|| str_param(p, "to")).unwrap_or("max").to_ascii_lowercase().as_str() {
"max" | "maximize" | "maximum" => Goal::Max,
"min" | "minimize" | "minimum" => Goal::Min,
"value" | "valueof" | "value of" => {
let v = f64_param(p, "value")
.or_else(|| str_param(p, "value").and_then(num_text))
.filter(|v| v.is_finite())
.ok_or_else(|| bad(CMD, "goal `value` needs a number in `value`"))?;
Goal::Value(v)
}
g => return Err(bad(CMD, format!("unknown goal `{g}` (max, min or value)"))),
};
let vars_text = str_param(p, "variables").or_else(|| str_param(p, "changing")).or_else(|| str_param(p, "byChanging")).unwrap_or("").to_string();
let (vsi, mut vars) = cells_of(wb, &vars_text, sheet, MAX_VARS).map_err(|e| match e {
EngineError::BadParams { msg, .. } if msg.starts_with("too many") => {
EngineError::Other(format!("Too many variable cells (at most {MAX_VARS})."))
}
_ => EngineError::Other("The variable cells must be a reference like B2:B4.".into()),
})?;
if vsi != sheet {
return Err(EngineError::Other("The variable cells must be on the objective cell's sheet.".into()));
}
let mut seen = std::collections::HashSet::new();
vars.retain(|c| seen.insert(*c));
if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some())) {
return Err(EngineError::Other("Variable cells must hold constants, not formulas.".into()));
}
let mut cons = Vec::new();
let mut scalar = 0usize;
if let Some(list) = p.get("constraints") {
let list = list.as_array().ok_or_else(|| bad(CMD, "`constraints` must be an array"))?;
if list.len() > MAX_CONSTRAINTS {
return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS}).")));
}
for c in list {
let c = parse_constraint(wb, sheet, c)?;
scalar = scalar.saturating_add(c.lhs.len());
if scalar > MAX_CONSTRAINTS {
return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS} cells).")));
}
if matches!(c.rel, Rel::Int | Rel::Bin) && c.lhs.iter().any(|x| !vars.contains(x)) {
return Err(EngineError::Other("Integer and binary constraints must be on variable cells.".into()));
}
cons.push(c);
}
}
let non_negative = bool_param(p, "nonNegative").unwrap_or(true);
let method = match str_param(p, "method").unwrap_or("grg").to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"grg" | "grgnonlinear" | "nonlinear" => Method::Grg,
"simplex" | "simplexlp" | "lp" | "linear" => Method::Simplex,
"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) })
}
// ---------------------------------------------------------------- saved model
fn ref_text(sheet_name: &str, cells: &str) -> String {
let q = crate::pivot::quote_sheet(sheet_name);
cells
.split(',')
.map(str::trim)
.filter(|x| !x.is_empty())
.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)) })
})
.collect::<Vec<_>>()
.join(",")
}
/// The model as hidden names scoped to its sheet (replacing any saved before).
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_")));
let mut put = |name: String, formula: String| {
wb.names.push(DefinedName { name, scope: Some(m.sheet), formula, comment: String::new(), hidden: true });
};
put("solver_opt".into(), ref_text(&sheet_name, &m.objective.a1()));
let (typ, val) = match m.goal {
Goal::Max => (1, 0.0),
Goal::Min => (2, 0.0),
Goal::Value(v) => (3, v),
};
put("solver_typ".into(), typ.to_string());
put("solver_val".into(), val.to_string());
put("solver_adj".into(), ref_text(&sheet_name, &m.vars_text));
for (i, c) in m.cons.iter().enumerate() {
let k = i + 1;
put(format!("solver_lhs{k}"), ref_text(&sheet_name, &c.lhs_text));
put(format!("solver_rel{k}"), c.rel.code().to_string());
let rhs = match &c.rhs {
Rhs::None => {
if c.rel == Rel::Bin {
"\"binary\"".to_string()
} else {
"\"integer\"".to_string()
}
}
Rhs::Const(v) => v.to_string(),
Rhs::Cells(_) => ref_text(&sheet_name, &c.rhs_text),
};
put(format!("solver_rhs{k}"), rhs);
}
put("solver_num".into(), m.cons.len().to_string());
put("solver_neg".into(), if m.non_negative { "1" } else { "2" }.into());
let eng = match m.method {
Method::Grg => "1",
Method::Simplex => "2",
Method::Evolutionary => "3",
};
put("solver_eng".into(), eng.into());
if m.method == Method::Evolutionary {
put("solver_ssz".into(), m.evo.population.to_string());
put("solver_mrt".into(), m.evo.mutation.to_string());
put("solver_rsd".into(), m.evo.seed.to_string());
put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into());
}
put("solver_ver".into(), "3".into());
}
/// The saved model of a sheet as `data.solver` params, if any.
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 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() {
return None;
}
let goal = match get("solver_typ").as_deref().map(str::trim) {
Some("2") => "min",
Some("3") => "value",
_ => "max",
};
let value = get("solver_val").and_then(|v| num_text(&v)).unwrap_or(0.0);
let count = get("solver_num").and_then(|v| num_text(&v)).unwrap_or(0.0).clamp(0.0, MAX_CONSTRAINTS as f64) as usize;
let mut constraints = Vec::new();
for k in 1..=count {
let (Some(lhs), Some(rel)) = (get(&format!("solver_lhs{k}")), get(&format!("solver_rel{k}")).and_then(|r| Rel::from_code(&r))) else {
continue;
};
let lhs = local(lhs);
constraints.push(match rel {
Rel::Int | Rel::Bin => json!(format!("{lhs} {}", rel.text())),
_ => {
let rhs = get(&format!("solver_rhs{k}")).unwrap_or_default();
let rhs = if num_text(&rhs).is_some() { rhs } else { local(rhs) };
json!(format!("{lhs} {} {rhs}", rel.text()))
}
});
}
let method = match get("solver_eng").as_deref().map(str::trim) {
Some("2") => "simplex",
Some("3") => "evolutionary",
_ => "grg",
};
let mut model = json!({
"objective": objective,
"goal": goal,
"value": value,
"variables": get("solver_adj").map(local).unwrap_or_default(),
"constraints": constraints,
"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")] {
if let Some(v) = get(name).and_then(|v| num_text(&v)) {
model[key] = json!(v);
}
}
if let Some(v) = get("solver_rbv") {
model["requireBounds"] = json!(v.trim() != "2");
}
Some(model)
}
/// The model as `data.solver` params, in the form [`saved_model`] returns (the objective
/// qualified by its sheet).
fn model_params(wb: &Workbook, m: &Model) -> Json {
let sheet = wb.sheet(m.sheet).map(|s| crate::pivot::quote_sheet(&s.name)).unwrap_or_default();
let (goal, value) = match m.goal {
Goal::Max => ("max", 0.0),
Goal::Min => ("min", 0.0),
Goal::Value(v) => ("value", v),
};
let constraints: Vec<Json> = m
.cons
.iter()
.map(|c| match c.rel {
Rel::Int | Rel::Bin => json!(format!("{} {}", c.lhs_text, c.rel.text())),
_ => json!(format!("{} {} {}", c.lhs_text, c.rel.text(), c.rhs_text)),
})
.collect();
let method = match m.method {
Method::Simplex => "simplex",
Method::Grg => "grg",
Method::Evolutionary => "evolutionary",
};
let mut out = json!({
"objective": format!("{sheet}!{}", m.objective.a1()),
"goal": goal,
"value": value,
"variables": m.vars_text,
"constraints": constraints,
"nonNegative": m.non_negative,
"method": method,
});
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);
}
out
}
// ---------------------------------------------------------------- evaluation
#[derive(Clone, Copy, Debug, PartialEq)]
enum Sense {
Le,
Ge,
Eq,
}
/// A scalar constraint `lhs (sense) rhs`.
#[derive(Clone, Debug)]
struct Row {
lhs: CellRef,
sense: Sense,
rhs: Option<CellRef>,
rhs_const: f64,
}
/// The values the solvers see at a point: the objective and `lhs − rhs` per row.
#[derive(Clone, Debug)]
struct Point {
f: f64,
g: Vec<f64>,
}
struct Evaluator {
trial: Trial,
sheet: usize,
vars: Vec<CellRef>,
objective: CellRef,
rows: Vec<Row>,
evals: u64,
budget: u64,
/// The Limits report: the objective is this variable itself.
target: Option<usize>,
}
fn as_num(v: &Value) -> f64 {
match v {
Value::Number(n) => *n,
Value::Empty => 0.0,
Value::Bool(b) => f64::from(u8::from(*b)),
_ => f64::NAN,
}
}
impl Evaluator {
fn exhausted(&self) -> bool {
self.evals >= self.budget
}
/// Evaluates the model at `x`; `None` once the budget is spent.
fn eval(&mut self, x: &[f64]) -> Option<Point> {
if self.exhausted() {
return None;
}
self.evals += 1;
let sets: Vec<(usize, CellRef, Value)> = self.vars.iter().zip(x).map(|(c, v)| (self.sheet, *c, Value::number(*v))).collect();
let mut reads = Vec::with_capacity(1 + 2 * self.rows.len());
reads.push((self.sheet, self.objective));
for r in &self.rows {
reads.push((self.sheet, r.lhs));
if let Some(c) = r.rhs {
reads.push((self.sheet, c));
}
}
let vals = self.trial.run(&sets, &reads);
let mut it = vals.iter().map(as_num);
let f = it.next().unwrap_or(f64::NAN);
let f = match self.target {
Some(j) => x.get(j).copied().unwrap_or(f64::NAN),
None => f,
};
let mut g = Vec::with_capacity(self.rows.len());
for r in &self.rows {
let l = it.next().unwrap_or(f64::NAN);
let rv = if r.rhs.is_some() { it.next().unwrap_or(f64::NAN) } else { r.rhs_const };
g.push(l - rv);
}
Some(Point { f, g })
}
}
// ---------------------------------------------------------------- problem
struct Problem {
lo: Vec<f64>,
hi: Vec<f64>,
integer: Vec<bool>,
rows: Vec<Row>,
}
/// Splits the constraints into variable bounds and general rows.
fn build_problem(m: &Model) -> Problem {
let n = m.vars.len();
let mut lo = vec![f64::NEG_INFINITY; n];
let mut hi = vec![f64::INFINITY; n];
let mut has_lower = vec![false; n];
let mut integer = vec![false; n];
let mut rows = Vec::new();
let var_index = |c: &CellRef| m.vars.iter().position(|v| v == c);
for c in &m.cons {
for (k, cell) in c.lhs.iter().enumerate() {
let j = var_index(cell);
match c.rel {
Rel::Int | Rel::Bin => {
if let Some(j) = j {
if let Some(b) = integer.get_mut(j) {
*b = true;
}
if c.rel == Rel::Bin
&& let (Some(l), Some(h), Some(hl)) = (lo.get_mut(j), hi.get_mut(j), has_lower.get_mut(j))
{
*l = l.max(0.0);
*h = h.min(1.0);
*hl = true;
}
}
}
rel => {
let sense = match rel {
Rel::Le => Sense::Le,
Rel::Ge => Sense::Ge,
_ => Sense::Eq,
};
let (rhs, rhs_const) = match &c.rhs {
Rhs::Const(v) => (None, *v),
Rhs::Cells(cells) => (cells.get(k).or_else(|| cells.first()).copied(), 0.0),
Rhs::None => (None, 0.0),
};
match (j, rhs) {
// A constant bound on a variable cell is a box bound.
(Some(j), None) => {
if let (Some(l), Some(h), Some(hl)) = (lo.get_mut(j), hi.get_mut(j), has_lower.get_mut(j)) {
if sense != Sense::Ge {
*h = h.min(rhs_const);
}
if sense != Sense::Le {
*l = l.max(rhs_const);
*hl = true;
}
}
}
_ => rows.push(Row { lhs: *cell, sense, rhs, rhs_const }),
}
}
}
}
}
if m.non_negative {
for (l, hl) in lo.iter_mut().zip(&has_lower) {
if !hl {
*l = l.max(0.0);
}
}
}
Problem { lo, hi, integer, rows }
}
/// What a relaxation (or the whole solve) ended with.
#[derive(Clone, Debug)]
enum Outcome {
/// x, objective to minimise, and whether the solver converged (vs. ran out of budget).
Solved(Vec<f64>, f64, bool),
Infeasible,
Unbounded,
Limit,
}
/// Depth-first branch and bound over a relaxation solver; returns the best integer point.
fn branch_and_bound(
lo: &[f64],
hi: &[f64],
integer: &[bool],
max_nodes: usize,
gap: f64,
relax: &mut dyn FnMut(&[f64], &[f64]) -> Outcome,
) -> (Outcome, usize) {
let mut stack: Vec<(Vec<f64>, Vec<f64>)> = vec![(lo.to_vec(), hi.to_vec())];
let mut best: Option<(Vec<f64>, f64)> = None;
let mut nodes = 0usize;
let mut limited = false;
let mut root = true;
while let Some((l, h)) = stack.pop() {
if nodes >= max_nodes {
limited = true;
break;
}
nodes += 1;
let out = relax(&l, &h);
let was_root = std::mem::replace(&mut root, false);
let (x, obj, converged) = match out {
Outcome::Solved(x, obj, c) => (x, obj, c),
Outcome::Unbounded if was_root => return (Outcome::Unbounded, nodes),
Outcome::Limit => {
limited = true;
break;
}
_ => continue,
};
if !converged {
limited = true;
}
if let Some((_, b)) = &best
&& obj >= b - gap * b.abs().max(1e-12) - 1e-9 * (1.0 + b.abs())
{
continue;
}
// The most fractional integer variable.
let frac = x
.iter()
.zip(integer)
.enumerate()
.filter(|(_, (_, int))| **int)
.map(|(j, (v, _))| (j, (v - v.round()).abs()))
.filter(|(_, f)| *f > 1e-6)
.max_by(|a, b| a.1.total_cmp(&b.1));
match frac {
None => {
let snapped: Vec<f64> = x.iter().zip(integer).map(|(v, int)| if *int { v.round() } else { *v }).collect();
best = Some((snapped, obj));
}
Some((j, _)) => {
let v = x.get(j).copied().unwrap_or(0.0);
let (mut down_h, mut up_l) = (h.clone(), l.clone());
if let Some(e) = down_h.get_mut(j) {
*e = e.min(v.floor());
}
if let Some(e) = up_l.get_mut(j) {
*e = e.max(v.ceil());
}
// Explore the nearer side first (pushed last).
if v - v.floor() < 0.5 {
stack.push((up_l, h));
stack.push((l, down_h));
} else {
stack.push((l, down_h));
stack.push((up_l, h));
}
}
}
}
match best {
Some((x, obj)) => (Outcome::Solved(x, obj, !limited), nodes),
None if limited => (Outcome::Limit, nodes),
None => (Outcome::Infeasible, nodes),
}
}
// ---------------------------------------------------------------- simplex
/// Pivots the tableau on (r, j), updating the objective row.
fn pivot(t: &mut [Vec<f64>], obj: &mut [f64], r: usize, j: usize) {
let Some(prow) = t.get_mut(r) else { return };
let pv = prow.get(j).copied().unwrap_or(0.0);
if pv == 0.0 {
return;
}
for v in prow.iter_mut() {
*v /= pv;
}
let prow = prow.clone();
for (i, row) in t.iter_mut().enumerate() {
if i == r {
continue;
}
let f = row.get(j).copied().unwrap_or(0.0);
if f != 0.0 {
for (v, p) in row.iter_mut().zip(&prow) {
*v -= f * p;
}
}
}
let f = obj.get(j).copied().unwrap_or(0.0);
if f != 0.0 {
for (v, p) in obj.iter_mut().zip(&prow) {
*v -= f * p;
}
}
}
enum Step {
Optimal,
Unbounded,
Limit,
}
/// Minimises with the reduced costs in `obj` (last entry: −objective). Columns not `allowed`
/// never enter.
fn simplex(t: &mut [Vec<f64>], basis: &mut [usize], obj: &mut [f64], allowed: &[bool], budget: &mut u64) -> Step {
let n = obj.len().saturating_sub(1);
let cost = (t.len() as u64 + 1).saturating_mul(n as u64 + 1).max(1);
let mut stalled = 0u32;
loop {
if *budget < cost {
return Step::Limit;
}
*budget -= cost;
let bland = stalled > 50;
let mut enter = None;
let mut best = -EPS;
for (j, d) in obj.iter().take(n).enumerate() {
if !allowed.get(j).copied().unwrap_or(false) || *d >= -EPS {
continue;
}
if bland {
enter = Some(j);
break;
}
if *d < best {
best = *d;
enter = Some(j);
}
}
let Some(j) = enter else { return Step::Optimal };
let mut leave: Option<(usize, f64)> = None;
for (i, row) in t.iter().enumerate() {
let a = row.get(j).copied().unwrap_or(0.0);
if a <= EPS {
continue;
}
let ratio = row.get(n).copied().unwrap_or(0.0) / a;
let better = match leave {
None => true,
Some((li, lr)) => ratio < lr - 1e-12 || (ratio <= lr + 1e-12 && basis.get(i) < basis.get(li)),
};
if better {
leave = Some((i, ratio));
}
}
let Some((r, ratio)) = leave else { return Step::Unbounded };
stalled = if ratio <= EPS { stalled + 1 } else { 0 };
pivot(t, obj, r, j);
if let Some(b) = basis.get_mut(r) {
*b = j;
}
}
}
/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`.
fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], hi: &[f64], budget: &mut u64) -> Outcome {
let n = c.len();
if lo.iter().zip(hi).any(|(l, h)| l > h) {
return Outcome::Infeasible;
}
// x_j = offset + Σ sign·y_k with y ≥ 0.
struct Map {
var: usize,
sign: f64,
}
let mut cols: Vec<Map> = Vec::new();
let mut offset = vec![0.0; n];
let mut extra: Vec<(Vec<(usize, f64)>, Sense, f64)> = Vec::new();
for j in 0..n {
let (l, h) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY));
if l.is_finite() {
if let Some(o) = offset.get_mut(j) {
*o = l;
}
cols.push(Map { var: j, sign: 1.0 });
if h.is_finite() {
extra.push((vec![(cols.len() - 1, 1.0)], Sense::Le, h - l));
}
} else if h.is_finite() {
if let Some(o) = offset.get_mut(j) {
*o = h;
}
cols.push(Map { var: j, sign: -1.0 });
} else {
cols.push(Map { var: j, sign: 1.0 });
cols.push(Map { var: j, sign: -1.0 });
}
}
let ny = cols.len();
// Rows over y: (coefficients, sense, rhs).
let mut yrows: Vec<(Vec<f64>, Sense, f64)> = Vec::with_capacity(rows.len() + extra.len());
for (a, sense, b) in rows {
let mut coef = vec![0.0; ny];
for (k, m) in cols.iter().enumerate() {
if let Some(v) = coef.get_mut(k) {
*v = a.get(m.var).copied().unwrap_or(0.0) * m.sign;
}
}
let shift: f64 = a.iter().zip(&offset).map(|(x, o)| x * o).sum();
yrows.push((coef, *sense, b - shift));
}
for (entries, sense, b) in extra {
let mut coef = vec![0.0; ny];
for (k, v) in entries {
if let Some(e) = coef.get_mut(k) {
*e = v;
}
}
yrows.push((coef, sense, b));
}
let m = yrows.len();
let n_slack = yrows.iter().filter(|r| r.1 != Sense::Eq).count();
// Rows with b < 0 are negated so every right side is ≥ 0.
let normalized: Vec<(Vec<f64>, Sense, f64)> = yrows
.into_iter()
.map(|(a, s, b)| {
if b < 0.0 {
let s = match s {
Sense::Le => Sense::Ge,
Sense::Ge => Sense::Le,
Sense::Eq => Sense::Eq,
};
(a.iter().map(|v| -v).collect(), s, -b)
} else {
(a, s, b)
}
})
.collect();
let n_art = normalized.iter().filter(|r| r.1 != Sense::Le).count();
let width = ny + n_slack + n_art;
let mut t: Vec<Vec<f64>> = Vec::with_capacity(m);
let mut basis = vec![0usize; m];
let (mut si, mut ai) = (ny, ny + n_slack);
for (i, (a, s, b)) in normalized.iter().enumerate() {
let mut row = vec![0.0; width + 1];
for (v, x) in row.iter_mut().zip(a) {
*v = *x;
}
match s {
Sense::Le => {
if let Some(v) = row.get_mut(si) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = si;
}
si += 1;
}
Sense::Ge => {
if let Some(v) = row.get_mut(si) {
*v = -1.0;
}
si += 1;
if let Some(v) = row.get_mut(ai) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = ai;
}
ai += 1;
}
Sense::Eq => {
if let Some(v) = row.get_mut(ai) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = ai;
}
ai += 1;
}
}
if let Some(v) = row.get_mut(width) {
*v = *b;
}
t.push(row);
}
let is_art = |j: usize| j >= ny + n_slack && j < width;
// Phase 1: minimise the sum of the artificials.
let mut obj = vec![0.0; width + 1];
for j in (ny + n_slack)..width {
if let Some(v) = obj.get_mut(j) {
*v = 1.0;
}
}
for (i, row) in t.iter().enumerate() {
if basis.get(i).is_some_and(|b| is_art(*b)) {
for (v, x) in obj.iter_mut().zip(row) {
*v -= x;
}
}
}
let all: Vec<bool> = vec![true; width];
if n_art > 0 {
match simplex(&mut t, &mut basis, &mut obj, &all, budget) {
Step::Limit => return Outcome::Limit,
Step::Unbounded | Step::Optimal => {}
}
let scale = normalized.iter().map(|r| r.2.abs()).fold(1.0f64, f64::max);
if -obj.get(width).copied().unwrap_or(0.0) > 1e-7 * scale {
return Outcome::Infeasible;
}
// Drive zero-valued artificials out of the basis where possible.
for i in 0..m {
if !basis.get(i).is_some_and(|b| is_art(*b)) {
continue;
}
let j = t.get(i).and_then(|row| (0..ny + n_slack).find(|&j| row.get(j).is_some_and(|v| v.abs() > EPS)));
if let Some(j) = j {
pivot(&mut t, &mut obj, i, j);
if let Some(b) = basis.get_mut(i) {
*b = j;
}
}
}
}
// Phase 2: the real costs; artificials may not re-enter.
let mut cost = vec![0.0; width + 1];
for (k, mp) in cols.iter().enumerate() {
if let Some(v) = cost.get_mut(k) {
*v = c.get(mp.var).copied().unwrap_or(0.0) * mp.sign;
}
}
let mut obj = cost.clone();
for (i, row) in t.iter().enumerate() {
let cb = basis.get(i).and_then(|b| cost.get(*b)).copied().unwrap_or(0.0);
if cb != 0.0 {
for (v, x) in obj.iter_mut().zip(row) {
*v -= cb * x;
}
}
}
let allowed: Vec<bool> = (0..width).map(|j| !is_art(j)).collect();
match simplex(&mut t, &mut basis, &mut obj, &allowed, budget) {
Step::Limit => return Outcome::Limit,
Step::Unbounded => return Outcome::Unbounded,
Step::Optimal => {}
}
let mut y = vec![0.0; width];
for (i, row) in t.iter().enumerate() {
if let (Some(b), Some(v)) = (basis.get(i), row.get(width))
&& let Some(e) = y.get_mut(*b)
{
*e = *v;
}
}
let mut x = offset;
for (k, mp) in cols.iter().enumerate() {
if let Some(e) = x.get_mut(mp.var) {
*e += mp.sign * y.get(k).copied().unwrap_or(0.0);
}
}
let val: f64 = c.iter().zip(&x).map(|(a, b)| a * b).sum();
Outcome::Solved(x, val, true)
}
/// Linearises the model at `x0` (n + 1 evaluations) and checks it at one more point. Returns the
/// objective gradient and constant, and per row the gradient and constant of `lhs − rhs`.
#[allow(clippy::type_complexity)]
fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<((Vec<f64>, f64), Vec<(Vec<f64>, f64)>), &'static str> {
let n = x0.len();
let p0 = ev.eval(x0).ok_or("budget")?;
if !p0.f.is_finite() || p0.g.iter().any(|v| !v.is_finite()) {
return Err("error");
}
let mut cf = vec![0.0; n];
let mut ga = vec![vec![0.0; n]; p0.g.len()];
for j in 0..n {
let mut x = x0.to_vec();
if let Some(v) = x.get_mut(j) {
*v += 1.0;
}
let p = ev.eval(&x).ok_or("budget")?;
if let Some(v) = cf.get_mut(j) {
*v = p.f - p0.f;
}
for (row, (gi, g0)) in ga.iter_mut().zip(p.g.iter().zip(&p0.g)) {
if let Some(v) = row.get_mut(j) {
*v = gi - g0;
}
}
}
let dot = |a: &[f64], x: &[f64]| a.iter().zip(x).map(|(p, q)| p * q).sum::<f64>();
let f_const = p0.f - dot(&cf, x0);
let rows: Vec<(Vec<f64>, f64)> = ga.into_iter().zip(&p0.g).map(|(a, g0)| (a.clone(), g0 - dot(&a, x0))).collect();
// Linearity check at a spread-out point.
let xt: Vec<f64> =
x0.iter().enumerate().map(|(j, v)| v + if j % 2 == 0 { 1.7 + 0.3 * (j % 5) as f64 } else { -0.6 - 0.2 * (j % 3) as f64 }).collect();
let pt = ev.eval(&xt).ok_or("budget")?;
let close = |actual: f64, pred: f64, terms: f64| actual.is_finite() && (actual - pred).abs() <= 1e-6 * (1.0 + actual.abs() + terms);
let terms = |a: &[f64]| a.iter().zip(&xt).map(|(p, q)| (p * q).abs()).sum::<f64>();
if !close(pt.f, f_const + dot(&cf, &xt), terms(&cf)) {
return Err("nonlinear");
}
for ((a, k), g) in rows.iter().zip(&pt.g) {
if !close(*g, k + dot(a, &xt), terms(a)) {
return Err("nonlinear");
}
}
Ok(((cf, f_const), rows))
}
// ---------------------------------------------------------------- nonlinear
/// Augmented-Lagrangian value of a point: objective `phi` plus the constraint terms.
fn aug(phi: f64, cons: &[f64], senses: &[Sense], lam: &[f64], mu: f64) -> f64 {
let mut v = phi;
for ((c, s), l) in cons.iter().zip(senses).zip(lam) {
v += match s {
Sense::Eq => l * c + 0.5 * mu * c * c,
_ => {
let t = (l + mu * c).max(0.0);
(t * t - l * l) / (2.0 * mu)
}
};
}
v
}
/// Constraint values in the `≤ 0` / `= 0` form, and the objective to minimise.
fn shape(p: &Point, rows: &[Row], goal: Goal) -> (f64, Vec<f64>) {
let phi = match goal {
Goal::Max => -p.f,
Goal::Min => p.f,
Goal::Value(v) => (p.f - v) * (p.f - v),
};
let cons = p.g.iter().zip(rows).map(|(g, r)| if r.sense == Sense::Ge { -g } else { *g }).collect();
(phi, cons)
}
fn violation(cons: &[f64], senses: &[Sense]) -> f64 {
cons.iter().zip(senses).map(|(c, s)| if *s == Sense::Eq { c.abs() } else { c.max(0.0) }).fold(0.0, f64::max)
}
fn project(x: &mut [f64], lo: &[f64], hi: &[f64]) {
for ((v, l), h) in x.iter_mut().zip(lo).zip(hi) {
if *v < *l {
*v = *l;
}
if *v > *h {
*v = *h;
}
}
}
/// Local minimisation of the model within bounds `lo..hi` from `x0`.
fn nlp(ev: &mut Evaluator, x0: &[f64], lo: &[f64], hi: &[f64], goal: Goal) -> Outcome {
let n = x0.len();
let rows = ev.rows.clone();
let senses: Vec<Sense> = rows.iter().map(|r| r.sense).collect();
let mut x = x0.to_vec();
project(&mut x, lo, hi);
let mut lam = vec![0.0; rows.len()];
let mut mu = 10.0;
let Some(p) = ev.eval(&x) else { return Outcome::Limit };
let (mut phi, mut cons) = shape(&p, &rows, goal);
if !phi.is_finite() {
return Outcome::Infeasible;
}
let phi0 = phi;
let mut prev_viol = violation(&cons, &senses);
let mut converged = false;
let mut out_of_budget = false;
let mut carried: Option<f64> = None;
for _outer in 0..40 {
// Inner: projected gradient on the augmented Lagrangian.
let lval = |phi: f64, cons: &[f64], lam: &[f64], mu: f64| aug(phi, cons, &senses, lam, mu);
let grad = |ev: &mut Evaluator, x: &[f64], lam: &[f64], mu: f64| -> Option<Vec<f64>> {
let mut gr = vec![0.0; n];
for j in 0..n {
let xj = x.get(j).copied().unwrap_or(0.0);
let h = 1e-6 * xj.abs().max(1.0);
let (l, u) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY));
let (a, b) = (if xj - h >= l { xj - h } else { xj }, if xj + h <= u { xj + h } else { xj });
if b <= a {
continue;
}
let mut xa = x.to_vec();
let mut xb = x.to_vec();
if let Some(v) = xa.get_mut(j) {
*v = a;
}
if let Some(v) = xb.get_mut(j) {
*v = b;
}
let (pa, ca) = shape(&ev.eval(&xa)?, &rows, goal);
let (pb, cb) = shape(&ev.eval(&xb)?, &rows, goal);
let d = (lval(pb, &cb, lam, mu) - lval(pa, &ca, lam, mu)) / (b - a);
if let Some(v) = gr.get_mut(j) {
*v = if d.is_finite() { d } else { 0.0 };
}
}
Some(gr)
};
let mut l_cur = lval(phi, &cons, &lam, mu);
let Some(mut g) = grad(ev, &x, &lam, mu) else {
out_of_budget = true;
break;
};
// The step length carries over between outer iterations once known.
let gnorm = g.iter().map(|v| v.abs()).fold(0.0, f64::max);
let mut alpha = carried.unwrap_or(if gnorm > 0.0 { 1.0 / gnorm } else { 1.0 });
let mut inner_done = false;
let mut quiet = 0;
for _inner in 0..500 {
let mut accepted = None;
let mut a = alpha;
for _ in 0..40 {
let mut xn: Vec<f64> = x.iter().zip(&g).map(|(v, d)| v - a * d).collect();
project(&mut xn, lo, hi);
let Some(pn) = ev.eval(&xn) else { break };
let (phn, cn) = shape(&pn, &rows, goal);
let ln = lval(phn, &cn, &lam, mu);
let decrease: f64 = g.iter().zip(x.iter().zip(&xn)).map(|(d, (u, v))| d * (u - v)).sum();
if ln.is_finite() && ln <= l_cur - 1e-4 * decrease {
accepted = Some((xn, phn, cn, ln));
break;
}
a *= 0.25;
}
if ev.exhausted() {
out_of_budget = true;
}
let Some((xn, phn, cn, ln)) = accepted else {
inner_done = true;
break;
};
// Variables or objective running off towards infinity: unbounded.
if phn.abs() > 1e15 * (1.0 + phi0.abs()) || xn.iter().zip(x0).any(|(v, s)| v.abs() > 1e15 * (1.0 + s.abs())) {
return Outcome::Unbounded;
}
let s: Vec<f64> = xn.iter().zip(&x).map(|(a, b)| a - b).collect();
let step = s.iter().map(|v| v.abs()).fold(0.0, f64::max);
let xmax = xn.iter().map(|v| v.abs()).fold(1.0, f64::max);
let small = step <= 1e-10 * xmax || (l_cur - ln).abs() <= 1e-13 * (1.0 + l_cur.abs());
quiet = if small { quiet + 1 } else { 0 };
x = xn;
phi = phn;
cons = cn;
l_cur = ln;
if quiet >= 3 {
inner_done = true;
break;
}
let Some(gn) = grad(ev, &x, &lam, mu) else {
out_of_budget = true;
break;
};
// Barzilai–Borwein step.
let yv: Vec<f64> = gn.iter().zip(&g).map(|(a, b)| a - b).collect();
let sy: f64 = s.iter().zip(&yv).map(|(a, b)| a * b).sum();
let ss: f64 = s.iter().map(|v| v * v).sum();
alpha = if sy > 0.0 { (ss / sy).clamp(1e-12, 1e12) } else { (a * 4.0).min(1e12) };
g = gn;
if g.iter().all(|v| *v == 0.0) {
inner_done = true;
break;
}
}
carried = Some(alpha);
if out_of_budget {
break;
}
let viol = violation(&cons, &senses);
let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max);
if viol <= PRECISION * scale && inner_done {
converged = true;
if rows.is_empty() {
break;
}
}
// Multiplier and penalty updates.
for ((l, c), s) in lam.iter_mut().zip(&cons).zip(&senses) {
*l = match s {
Sense::Eq => *l + mu * c,
_ => (*l + mu * c).max(0.0),
};
}
if viol > 0.25 * prev_viol {
mu = (mu * 10.0).min(1e10);
}
if converged && viol <= PRECISION * scale * 1e-2 {
break;
}
prev_viol = viol;
converged = false;
}
let viol = violation(&cons, &senses);
let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max);
if viol > PRECISION * scale * 10.0 {
return if out_of_budget { Outcome::Limit } else { Outcome::Infeasible };
}
Outcome::Solved(x, phi, (converged || viol <= PRECISION * scale) && !out_of_budget)
}
// ---------------------------------------------------------------- evolutionary
/// A small deterministic generator (SplitMix64), so a seed reproduces a run on every platform.
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Rng {
Rng(seed ^ 0x2545_F491_4F6C_DD1D)
}
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Uniform in [0, 1).
fn uniform(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
}
fn between(&mut self, a: f64, b: f64) -> f64 {
a + (b - a) * self.uniform()
}
fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { usize::try_from(self.next() % n as u64).unwrap_or(0) }
}
/// Standard normal (Box–Muller).
fn normal(&mut self) -> f64 {
let u = self.uniform().max(f64::MIN_POSITIVE);
(-2.0 * u.ln()).sqrt() * (std::f64::consts::TAU * self.uniform()).cos()
}
}
/// A candidate: its point, the objective to minimise and its constraint violation.
#[derive(Clone, Debug)]
struct Member {
x: Vec<f64>,
phi: f64,
viol: f64,
}
impl Member {
/// Feasible beats infeasible; then the lower objective, or the smaller violation.
fn better(&self, o: &Member, tol: f64) -> bool {
match (self.viol <= tol, o.viol <= tol) {
(true, false) => true,
(false, true) => false,
(true, true) => self.phi < o.phi,
(false, false) => self.viol < o.viol,
}
}
}
/// Why the Evolutionary search stopped.
#[derive(Clone, Copy, Debug, PartialEq)]
enum EvoEnd {
/// Almost all of the population is within Convergence of the best member.
Converged,
/// The best member didn't improve for the set number of generations.
Stalled,
/// The evaluation budget ran out.
Budget,
}
/// The search box, integer flags and scoring shared by the genetic and pattern phases.
struct EvoSearch<'a> {
ev: &'a mut Evaluator,
rows: Vec<Row>,
senses: Vec<Sense>,
goal: Goal,
lo: Vec<f64>,
hi: Vec<f64>,
integer: &'a [bool],
tol: f64,
}
impl EvoSearch<'_> {
/// Clamps into the box and rounds integer variables.
fn repair(&self, x: &mut [f64]) {
for (j, v) in x.iter_mut().enumerate() {
let (l, h) = (self.lo.get(j).copied().unwrap_or(0.0), self.hi.get(j).copied().unwrap_or(0.0));
*v = if v.is_finite() { v.clamp(l, h) } else { l };
if self.integer.get(j).copied().unwrap_or(false) {
*v = v.round().clamp(l.ceil(), h.floor());
}
}
}
/// Scores `x`; `None` once the budget is spent.
fn score(&mut self, x: Vec<f64>) -> Option<Member> {
let p = self.ev.eval(&x)?;
let (phi, cons) = shape(&p, &self.rows, self.goal);
let viol = violation(&cons, &self.senses);
let phi = if phi.is_finite() { phi } else { f64::INFINITY };
let viol = if viol.is_finite() && cons.iter().all(|c| c.is_finite()) { viol } else { f64::INFINITY };
Some(Member { x, phi, viol })
}
}
/// The Evolutionary method within `lo..hi` from `x0`.
fn evolutionary(ev: &mut Evaluator, x0: &[f64], lo: &[f64], hi: &[f64], integer: &[bool], goal: Goal, o: &EvoOptions) -> (Outcome, EvoEnd) {
let rows = ev.rows.clone();
let senses: Vec<Sense> = rows.iter().map(|r| r.sense).collect();
let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max);
let mut start = x0.to_vec();
project(&mut start, lo, hi);
// Without a bound, search within 100 of the start.
let blo: Vec<f64> = lo.iter().zip(&start).map(|(l, s)| if l.is_finite() { *l } else { s - 100.0 }).collect();
let bhi: Vec<f64> = hi.iter().zip(&start).map(|(h, s)| if h.is_finite() { *h } else { s + 100.0 }).collect();
let no_integer = blo.iter().zip(&bhi).zip(integer).any(|((l, h), int)| *int && l.ceil() > h.floor());
if blo.iter().zip(&bhi).any(|(l, h)| l > h) || no_integer {
return (Outcome::Infeasible, EvoEnd::Converged);
}
let span: Vec<f64> = blo.iter().zip(&bhi).map(|(l, h)| (h - l).max(1e-9)).collect();
let mut se = EvoSearch { ev, rows, senses, goal, lo: blo, hi: bhi, integer, tol: PRECISION * scale };
let mut rng = Rng::new(o.seed);
let size = o.population.clamp(MIN_POPULATION, MAX_POPULATION);
let tol = se.tol;
let mut end = EvoEnd::Budget;
// The first generation: the start and random points in the box.
let mut pop: Vec<Member> = Vec::with_capacity(size);
se.repair(&mut start);
let mut next_x = Some(start);
while pop.len() < size {
let x = next_x.take().unwrap_or_else(|| {
let mut x: Vec<f64> = se.lo.iter().zip(&se.hi).map(|(l, h)| rng.between(*l, *h)).collect();
se.repair(&mut x);
x
});
match se.score(x) {
Some(m) => pop.push(m),
None => break,
}
}
let mut best: Option<Member> = None;
for m in &pop {
if best.as_ref().is_none_or(|b| m.better(b, tol)) {
best = Some(m.clone());
}
}
let mut last_improved = 0u64;
let mut generation = 0u64;
'search: while pop.len() == size {
let Some(b) = best.clone() else { break };
// Converged: 99% of the population is feasible and within Convergence of the best.
let close = pop.iter().filter(|m| m.viol <= tol && (m.phi - b.phi).abs() <= o.convergence * b.phi.abs().max(1.0)).count();
if b.viol <= tol && generation >= 5 && close * 100 >= size * 99 {
end = EvoEnd::Converged;
break;
}
if generation.saturating_sub(last_improved) >= o.stall {
end = EvoEnd::Stalled;
break;
}
// The next generation: the best survives, the rest are children of two tournaments.
let mut next = Vec::with_capacity(size);
next.push(b.clone());
while next.len() < size {
let mut parent = || {
let (i, k) = (rng.below(size), rng.below(size));
match (pop.get(i), pop.get(k)) {
(Some(p), Some(q)) => Some(if q.better(p, tol) { q.x.clone() } else { p.x.clone() }),
_ => None,
}
};
let (Some(pa), Some(pb)) = (parent(), parent()) else { break 'search };
let mut x: Vec<f64> = pa
.iter()
.zip(&pb)
.zip(&span)
.map(|((a, c), w)| {
let mut v = a + rng.between(-0.25, 1.25) * (c - a);
if rng.uniform() < o.mutation {
v += 0.1 * w * rng.normal();
}
v
})
.collect();
se.repair(&mut x);
match se.score(x) {
Some(m) => next.push(m),
None => break 'search,
}
}
generation += 1;
for m in &next {
let Some(cur) = &best else { continue };
if m.better(cur, tol) {
let improved = cur.viol > tol || cur.phi - m.phi > 1e-9 * cur.phi.abs().max(1.0);
best = Some(m.clone());
if improved {
last_improved = generation;
}
}
}
pop = next;
}
// Polish: a compass search on the continuous variables around the best member.
if let Some(mut b) = best.clone() {
let mut step: Vec<f64> = span.iter().map(|w| 0.01 * w).collect();
let n = b.x.len();
'polish: for _ in 0..10_000 {
if step.iter().zip(&span).zip(integer).all(|((s, w), int)| *int || *s <= 1e-9 * w.max(1.0)) {
break;
}
let mut moved = false;
for j in 0..n {
if integer.get(j).copied().unwrap_or(false) {
continue;
}
for dir in [1.0, -1.0] {
let mut x = b.x.clone();
if let (Some(v), Some(s)) = (x.get_mut(j), step.get(j)) {
*v += dir * s;
}
se.repair(&mut x);
if x == b.x {
continue;
}
let Some(m) = se.score(x) else { break 'polish };
if m.better(&b, tol) {
b = m;
moved = true;
break;
}
}
}
if !moved {
for s in &mut step {
*s *= 0.5;
}
}
}
best = Some(b);
}
match best {
Some(b) if b.viol <= tol => (Outcome::Solved(b.x, b.phi, end != EvoEnd::Budget), end),
_ if end == EvoEnd::Budget => (Outcome::Limit, end),
_ => (Outcome::Infeasible, end),
}
}
/// What a solve ended with, and why Simplex LP or Evolutionary stopped.
struct Solve {
outcome: Outcome,
not_linear: bool,
evo_end: Option<EvoEnd>,
}
/// Runs the model's method for `goal` within `lo..hi` from `x0`.
#[allow(clippy::too_many_arguments)]
fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], lo: &[f64], hi: &[f64], integer: &[bool], gap: f64) -> Solve {
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 linearize(ev, x0) {
Err("nonlinear") | Err("error") => {
not_linear = true;
Outcome::Infeasible
}
Err(_) => Outcome::Limit,
Ok(((cf, f_const), lin)) => {
let mut rows: Vec<(Vec<f64>, Sense, f64)> = lin.iter().zip(&ev.rows).map(|((a, k), r)| (a.clone(), r.sense, -k)).collect();
let c: Vec<f64> = match goal {
Goal::Max => cf.iter().map(|v| -v).collect(),
Goal::Min => cf.clone(),
Goal::Value(v) => {
rows.push((cf.clone(), Sense::Eq, v - f_const));
vec![0.0; cf.len()]
}
};
let size = (rows.len() as u64 + 2 * x0.len() as u64 + 1) * (4 * x0.len() as u64 + 2 * rows.len() as u64 + 1);
let mut work = LP_WORK.max(size);
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 }
}
// ---------------------------------------------------------------- reports
#[derive(Clone, Copy, Debug, PartialEq)]
enum ReportKind {
Answer,
Limits,
}
impl ReportKind {
fn title(self) -> &'static str {
match self {
ReportKind::Answer => "Answer",
ReportKind::Limits => "Limits",
}
}
}
/// The `reports` list (`None` when absent).
fn report_kinds(cmd: &'static str, p: &Json) -> Result<Option<Vec<ReportKind>>> {
let Some(list) = p.get("reports") else { return Ok(None) };
let list = list.as_array().ok_or_else(|| bad(cmd, "`reports` must be an array of answer and limits"))?;
let mut kinds = Vec::new();
for k in list {
let kind = match k.as_str().map(|t| t.trim().to_ascii_lowercase()).as_deref() {
Some("answer") => ReportKind::Answer,
Some("limits") => ReportKind::Limits,
_ => return Err(bad(cmd, format!("unknown report {k} (answer or limits)"))),
};
if !kinds.contains(&kind) {
kinds.push(kind);
}
}
Ok(Some(kinds))
}
/// A report's cells: (row, column, value, bold).
type Lines = Vec<(u32, u32, Value, bool)>;
fn abs_ref(c: CellRef) -> String {
format!("${}${}", gridcraft_core::col_to_letters(c.col), u64::from(c.row) + 1)
}
/// A cell's name in the reports: the nearest text to its left and the nearest text above it.
fn cell_name(sh: &Sheet, c: CellRef) -> String {
let text = |c: CellRef| match sh.value(c) {
Value::Text(t) if !t.trim().is_empty() => Some(t.to_string()),
_ => None,
};
let left = (0..c.col).rev().find_map(|col| text(CellRef::new(c.row, col)));
let top = (0..c.row).rev().find_map(|row| text(CellRef::new(row, c.col)));
[left, top].into_iter().flatten().collect::<Vec<_>>().join(" ")
}
fn method_name(m: Method) -> &'static str {
match m {
Method::Simplex => "Simplex LP",
Method::Grg => "GRG Nonlinear",
Method::Evolutionary => "Evolutionary",
}
}
/// Header labels in bold from column 1, then the rows below them; returns the next free row.
fn table(out: &mut Lines, row: u32, headers: &[&str], rows: Vec<Vec<Value>>) -> u32 {
for (k, h) in headers.iter().enumerate() {
out.push((row, 1 + k as u32, Value::text(*h), true));
}
let mut r = row;
for cells in rows {
r = r.saturating_add(1);
for (k, v) in cells.into_iter().enumerate() {
out.push((r, 1 + k as u32, v, false));
}
}
r.saturating_add(2)
}
/// Everything a report describes: the model, its starting and final values, and the result.
struct ReportInput<'a> {
model: &'a Model,
prob: &'a Problem,
original: Vec<f64>,
solution: Vec<f64>,
message: String,
budget: u64,
}
fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
out.push((0, 0, Value::text(format!("GridCraft {} Report", kind.title())), true));
out.push((1, 0, Value::text(format!("Worksheet: {sheet}")), false));
3
}
fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
let m = inp.model;
let mut trial = Trial::new(d);
let read = |trial: &mut Trial, x: &[f64], cells: &[CellRef]| -> Vec<f64> {
let sets: Vec<(usize, CellRef, Value)> = m.vars.iter().zip(x).map(|(c, v)| (m.sheet, *c, Value::number(*v))).collect();
let reads: Vec<(usize, CellRef)> = cells.iter().map(|c| (m.sheet, *c)).collect();
trial.run(&sets, &reads).iter().map(as_num).collect()
};
let f0 = read(&mut trial, &inp.original, &[m.objective]).first().copied().unwrap_or(f64::NAN);
// Every cell the constraints read, at the solution.
let mut cells = vec![m.objective];
for c in &m.cons {
cells.extend(&c.lhs);
if let Rhs::Cells(r) = &c.rhs {
cells.extend(r);
}
}
let vals = read(&mut trial, &inp.solution, &cells);
let mut at = vals.iter().copied();
let f1 = at.next().unwrap_or(f64::NAN);
let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) };
let mut out = Lines::new();
let mut r = title(&mut out, ReportKind::Answer, &sh.name);
out.push((r, 0, Value::text(format!("Result: {}", inp.message)), false));
out.push((r + 1, 0, Value::text(format!("Engine: {}", method_name(m.method))), false));
r += 3;
let goal = match m.goal {
Goal::Max => "Max".to_string(),
Goal::Min => "Min".to_string(),
Goal::Value(v) => format!("Value Of {v}"),
};
out.push((r, 0, Value::text(format!("Objective Cell ({goal})")), true));
let obj = vec![vec![Value::text(abs_ref(m.objective)), Value::text(cell_name(sh, m.objective)), num(f0), num(f1)]];
r = table(&mut out, r + 1, &["Cell", "Name", "Original Value", "Final Value"], obj);
out.push((r, 0, Value::text("Variable Cells"), true));
let kind = |j: usize| {
let bin = m.cons.iter().any(|c| c.rel == Rel::Bin && m.vars.get(j).is_some_and(|v| c.lhs.contains(v)));
match (bin, inp.prob.integer.get(j).copied().unwrap_or(false)) {
(true, _) => "Binary",
(false, true) => "Integer",
_ => "Contin",
}
};
let vars = m
.vars
.iter()
.enumerate()
.map(|(j, c)| {
let (a, b) = (inp.original.get(j).copied().unwrap_or(f64::NAN), inp.solution.get(j).copied().unwrap_or(f64::NAN));
vec![Value::text(abs_ref(*c)), Value::text(cell_name(sh, *c)), num(a), num(b), Value::text(kind(j))]
})
.collect();
r = table(&mut out, r + 1, &["Cell", "Name", "Original Value", "Final Value", "Integer"], vars);
out.push((r, 0, Value::text("Constraints"), true));
let mut rows = Vec::new();
for c in &m.cons {
let lhs: Vec<f64> = c.lhs.iter().map(|_| at.next().unwrap_or(f64::NAN)).collect();
let rhs: Vec<f64> = match &c.rhs {
Rhs::Cells(cells) => cells.iter().map(|_| at.next().unwrap_or(f64::NAN)).collect(),
Rhs::Const(v) => vec![*v],
Rhs::None => vec![],
};
for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() {
let (formula, status, slack) = match (c.rel, &c.rhs) {
(Rel::Int, _) => (format!("{}=Integer", abs_ref(*cell)), "Binding", 0.0),
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(*cell)), "Binding", 0.0),
(rel, rhs_cells) => {
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
let rhs_text = match rhs_cells {
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
_ => gridcraft_core::number_to_text(b),
};
let slack = (v - b).abs();
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
let slack = if binding { 0.0 } else { slack };
(format!("{}{}{rhs_text}", abs_ref(*cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
}
};
rows.push(vec![
Value::text(abs_ref(*cell)),
Value::text(cell_name(sh, *cell)),
num(*v),
Value::text(formula),
Value::text(status),
num(slack),
]);
}
}
table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows);
out
}
fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
let m = inp.model;
if inp.prob.integer.iter().any(|b| *b) {
return Err(EngineError::Other("The Limits report doesn't apply to models with integer or binary constraints.".into()));
}
if m.method == Method::Evolutionary {
return Err(EngineError::Other("The Limits report is available for Simplex LP and GRG Nonlinear.".into()));
}
let n = m.vars.len();
let x = &inp.solution;
// Each variable is minimised and maximised with the others held at the solution.
let per = (inp.budget / (2 * n as u64).max(1)).max(n as u64 + 3);
let mut ev = Evaluator {
trial: Trial::new(d),
sheet: m.sheet,
vars: m.vars.clone(),
objective: m.objective,
rows: inp.prob.rows.clone(),
evals: 0,
budget: 0,
target: None,
};
let objective_at = |ev: &mut Evaluator, x: &[f64]| {
ev.target = None;
ev.budget = ev.evals.saturating_add(1);
ev.eval(x).map(|p| p.f).unwrap_or(f64::NAN)
};
let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) };
let f = objective_at(&mut ev, x);
let mut rows = Vec::new();
for (j, c) in m.vars.iter().enumerate() {
let (mut lo, mut hi) = (x.clone(), x.clone());
if let (Some(l), Some(h), Some(pl), Some(ph)) = (lo.get_mut(j), hi.get_mut(j), inp.prob.lo.get(j), inp.prob.hi.get(j)) {
*l = *pl;
*h = *ph;
}
let mut ends = Vec::new();
for goal in [Goal::Min, Goal::Max] {
ev.target = Some(j);
ev.evals = 0;
ev.budget = per;
let solve = optimise(&mut ev, m, goal, x, &lo, &hi, &inp.prob.integer, 0.0);
ends.push(match solve.outcome {
Outcome::Solved(p, _, _) => {
let v = p.get(j).copied().unwrap_or(f64::NAN);
(num(v), num(objective_at(&mut ev, &p)))
}
Outcome::Unbounded => (Value::text("Unbounded"), Value::Empty),
_ => (Value::Error(gridcraft_core::CellError::NA), Value::Empty),
});
}
let mut row = vec![Value::text(abs_ref(*c)), Value::text(cell_name(sh, *c)), num(x.get(j).copied().unwrap_or(f64::NAN))];
for (v, o) in ends {
row.push(v);
row.push(o);
}
rows.push(row);
}
let mut out = Lines::new();
let r = title(&mut out, ReportKind::Limits, &sh.name);
let obj = vec![vec![Value::text(abs_ref(m.objective)), Value::text(cell_name(sh, m.objective)), num(f)]];
let r = table(&mut out, r, &["Objective Cell", "Name", "Value"], obj);
table(&mut out, r, &["Variable Cell", "Name", "Value", "Lower Limit", "Objective Result", "Upper Limit", "Objective Result"], rows);
Ok(out)
}
/// Writes the reports on new sheets ("Answer Report 1"…) before the model's sheet, in one step;
/// the model's sheet stays active. Returns the new sheets' names.
fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<String>> {
if kinds.is_empty() {
return Ok(Vec::new());
}
let d = s.doc()?;
if d.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
}
let sh = d.wb.sheet(inp.model.sheet).ok_or(EngineError::NoDocument)?;
let mut built = Vec::new();
for k in kinds {
let lines = match k {
ReportKind::Answer => answer_report(d, sh, inp),
ReportKind::Limits => limits_report(d, sh, inp)?,
};
let name = (1..=10_000u32)
.map(|i| format!("{} Report {i}", k.title()))
.find(|n| d.wb.sheet_index(n).is_none() && !built.iter().any(|(b, _): &(String, Lines)| b == n))
.ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?;
built.push((name, lines));
}
let names: Vec<String> = built.iter().map(|(n, _)| n.clone()).collect();
let at = inp.model.sheet;
let count = built.len();
edit(s, |cx| {
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
for (i, (name, lines)) in built.into_iter().enumerate() {
let mut sheet = Sheet::new(name);
for (r, c, v, b) in lines {
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
}
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
}
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
}
for nm in cx.wb.names.iter_mut() {
if let Some(sc) = nm.scope.as_mut()
&& *sc >= at
{
*sc += count;
}
}
if cx.wb.active_sheet >= at {
cx.wb.active_sheet += count;
}
cx.structural = true;
Ok(())
})?;
if let Ok(d) = s.doc_mut() {
for i in 0..count {
d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default());
}
}
Ok(names)
}
/// `data.solverReport`: reports for a solution of the sheet's saved model.
fn report(s: &mut Session, p: &Json) -> Result<Json> {
let kinds = report_kinds(REPORT_CMD, p)?.ok_or_else(|| bad(REPORT_CMD, "missing `reports` (answer, limits)"))?;
if kinds.is_empty() {
return Err(bad(REPORT_CMD, "`reports` is empty (answer, limits)"));
}
let d = s.doc()?;
// The given model as is, else the saved model with any params given here over it.
let params = match p.get("model") {
Some(m @ Json::Object(_)) => m.clone(),
Some(Json::Null) | None => {
let mut params = saved_model(&d.wb, d.wb.active_sheet).unwrap_or_else(|| json!({}));
if let (Some(base), Some(over)) = (params.as_object_mut(), p.as_object()) {
for (k, v) in over {
base.insert(k.clone(), v.clone());
}
}
params
}
Some(_) => return Err(bad(REPORT_CMD, "`model` must be data.solver params")),
};
let model = parse_model(s, &params)?;
let prob = build_problem(&model);
let d = s.doc()?;
let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?;
let current: Vec<f64> = model.vars.iter().map(|c| as_num(&sh.value(*c))).collect();
// `{cell: number}` maps; cells they leave out keep their current values.
let point = |key: &str| -> Result<Vec<f64>> {
let mut x = current.clone();
let Some(map) = p.get(key) else { return Ok(x) };
let map = map.as_object().ok_or_else(|| bad(REPORT_CMD, format!("`{key}` must map cells to numbers")))?;
for (cell, v) in map {
let c = CellRef::parse(&cell.replace('$', "")).ok_or_else(|| bad(REPORT_CMD, format!("not a cell: `{cell}`")))?;
let j = model.vars.iter().position(|v| *v == c).ok_or_else(|| bad(REPORT_CMD, format!("`{cell}` is not a variable cell")))?;
let v = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(REPORT_CMD, format!("`{key}.{cell}` must be a number")))?;
if let Some(e) = x.get_mut(j) {
*e = v;
}
}
Ok(x)
};
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 message = str_param(p, "message").unwrap_or("").chars().take(500).collect();
let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget };
let sheets = write_reports(s, &kinds, &inp)?;
Ok(json!({"sheets": sheets}))
}
// ---------------------------------------------------------------- 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(&params, 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, &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 keep = bool_param(&params, "keep").unwrap_or(true);
let save = bool_param(&params, "saveModel").unwrap_or(true);
let reports = report_kinds(CMD, &params)?;
let prob = build_problem(&model);
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, &prob.lo, &prob.hi, &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();
if changed || save {
let sheet = model.sheet;
let vars = model.vars.clone();
let m = model.clone();
edit(s, |cx| {
if changed && let Some(x) = &solution {
let sh = cx.sheet_mut(sheet)?;
for (c, v) in 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 &vars {
cx.touch(sheet, *c);
}
}
if save {
save_model(&mut cx.wb, &m);
}
Ok(())
})?;
}
let mut out = json!({
"status": status,
"message": message,
"objective": objective,
"values": values,
"original": original,
"model": model_params(&s.doc()?.wb, &model),
"evaluations": evaluations,
"changed": changed,
});
// Reports describe a solution; they are part of the solve's undo step.
if let (Some(kinds), Some(x)) = (reports, solution) {
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x, message: message.to_string(), budget };
out["reports"] = json!(write_reports(s, &kinds, &inp)?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use gridcraft_core::{CellRef, Value};
use serde_json::json;
use crate::Session;
fn s() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
fn n(s: &Session, a: &str) -> f64 {
let d = s.doc().unwrap();
d.wb.active().unwrap().value(CellRef::parse(a).unwrap()).as_f64().unwrap_or(f64::NAN)
}
/// The first saved-model name scoped to the active sheet.
fn saved(s: &Session) -> String {
let d = s.doc().unwrap();
d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet)).map(|n| n.name.clone()).unwrap_or_default()
}
fn set(s: &mut Session, cells: &[(&str, &str)]) {
for (c, v) in cells {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
}
#[test]
fn simplex_solves_the_wyndor_glass_lp() {
// Hillier & Lieberman's Wyndor Glass problem: max 3x + 5y, x ≤ 4, 2y ≤ 12, 3x + 2y ≤ 18
// → x = 2, y = 6, Z = 36.
let mut s = s();
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")]);
let r = s
.execute(
"data.solver",
json!({"objective": "B1", "goal": "max", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", {"lhs": "B3", "op": "<=", "rhs": 18}], "method": "simplex"}),
)
.unwrap();
assert_eq!(r["status"], "optimal", "{r}");
assert!((n(&s, "A1") - 2.0).abs() < 1e-9 && (n(&s, "A2") - 6.0).abs() < 1e-9);
assert!((n(&s, "B1") - 36.0).abs() < 1e-9);
// The model is saved as hidden names, survives XLSX and solves again from them.
let names: Vec<String> = s.doc().unwrap().wb.names.iter().map(|n| n.name.clone()).collect();
assert!(names.iter().any(|n| n == "solver_opt") && names.iter().any(|n| n == "solver_lhs3"));
let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "m.xlsx", "base64": b64})).unwrap();
set(&mut s, &[("A1", "0"), ("A2", "0")]);
let m = s.execute("data.solver", json!({})).unwrap();
assert_eq!(m["model"]["objective"], "B1");
assert_eq!(m["model"]["method"], "simplex");
let r = s.execute("data.solver", json!({"saved": true})).unwrap();
assert_eq!(r["status"], "optimal");
assert!((n(&s, "B1") - 36.0).abs() < 1e-9);
// Minimise with keep: false leaves the cells alone.
set(&mut s, &[("A1", "1"), ("A2", "1")]);
let r = s.execute("data.solver", json!({"saved": true, "goal": "min", "keep": false})).unwrap();
assert_eq!(r["objective"], 0.0);
assert_eq!((n(&s, "A1"), n(&s, "A2")), (1.0, 1.0));
}
#[test]
fn integer_and_binary_constraints_use_branch_and_bound() {
// max 5x + 4y, 6x + 4y ≤ 24, x + 2y ≤ 6: the LP optimum (3, 1.5) = 21; integers give
// (4, 0) = 20.
let mut s = s();
set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2")]);
let base = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6"], "method": "simplex"});
let r = s.execute("data.solver", base.clone()).unwrap();
assert!((r["objective"].as_f64().unwrap() - 21.0).abs() < 1e-9);
let mut p = base.clone();
p["constraints"].as_array_mut().unwrap().push(json!("A1:A2 int"));
let r = s.execute("data.solver", p).unwrap();
assert_eq!(r["status"], "optimal");
assert_eq!((n(&s, "A1"), n(&s, "A2"), n(&s, "B1")), (4.0, 0.0, 20.0));
// 0/1 knapsack: values 10, 13, 7, weights 5, 6, 4, capacity 10 → items 2 and 3 (20).
set(&mut s, &[("D1", "=10*C1+13*C2+7*C3"), ("D2", "=5*C1+6*C2+4*C3")]);
let r = s
.execute("data.solver", json!({"objective": "D1", "variables": "C1:C3", "constraints": ["D2 <= 10", "C1:C3 bin"], "method": "simplex"}))
.unwrap();
assert_eq!(r["status"], "optimal");
assert_eq!((n(&s, "C1"), n(&s, "C2"), n(&s, "C3"), n(&s, "D1")), (0.0, 1.0, 1.0, 20.0));
}
#[test]
fn grg_finds_smooth_optima() {
// Largest rectangle with perimeter at most 20: a 5 × 5 square, area 25.
let mut s = s();
set(&mut s, &[("A1", "1"), ("A2", "1"), ("B1", "=A1*A2"), ("B2", "=2*A1+2*A2")]);
let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"]})).unwrap();
assert_eq!(r["status"], "converged", "{r}");
assert!((n(&s, "A1") - 5.0).abs() < 1e-3 && (n(&s, "A2") - 5.0).abs() < 1e-3, "{r}");
// Unconstrained minimum of (x − 3)² + (y + 1)² at (3, −1) once negatives are allowed.
set(&mut s, &[("C1", "0"), ("C2", "0"), ("D1", "=(C1-3)^2+(C2+1)^2")]);
s.execute("data.solver", json!({"objective": "D1", "goal": "min", "variables": "C1:C2", "nonNegative": false})).unwrap();
assert!((n(&s, "C1") - 3.0).abs() < 1e-4 && (n(&s, "C2") + 1.0).abs() < 1e-4);
// Value Of: x³ = 8 → x = 2.
set(&mut s, &[("E1", "1"), ("F1", "=E1^3")]);
s.execute("data.solver", json!({"objective": "F1", "goal": "value", "value": 8, "variables": "E1"})).unwrap();
assert!((n(&s, "E1") - 2.0).abs() < 1e-4);
}
#[test]
fn evolutionary_finds_the_global_minimum_of_non_smooth_and_multimodal_models() {
let mut s = s();
// |x − 3| + |y + 2| has a kink at its minimum (3, −2).
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=ABS(A1-3)+ABS(A2+2)")]);
let p =
json!({"objective": "B1", "goal": "min", "variables": "A1:A2", "constraints": ["A1:A2 >= -10", "A1:A2 <= 10"], "method": "evolutionary"});
let r = s.execute("data.solver", p.clone()).unwrap();
assert!(r["status"] == "converged" || r["status"] == "cannotImprove", "{r}");
assert!((n(&s, "A1") - 3.0).abs() < 1e-6 && (n(&s, "A2") + 2.0).abs() < 1e-6, "{r}");
// The same seed repeats the run exactly.
set(&mut s, &[("A1", "0"), ("A2", "0")]);
let again = s.execute("data.solver", p).unwrap();
assert_eq!(again["values"], r["values"]);
assert_eq!(again["evaluations"], r["evaluations"]);
// Rastrigin's function 20 + Σ (x² − 10 cos 2πx) on [−5.12, 5.12]² has its global minimum 0
// at the origin among many local minima; GRG from (3.3, −2.7) stops at a local one.
set(&mut s, &[("C1", "3.3"), ("C2", "-2.7"), ("D1", "=20+C1^2-10*COS(2*PI()*C1)+C2^2-10*COS(2*PI()*C2)")]);
let base = json!({"objective": "D1", "goal": "min", "variables": "C1:C2", "constraints": ["C1:C2 >= -5.12", "C1:C2 <= 5.12"]});
s.execute("data.solver", base.clone()).unwrap();
assert!(n(&s, "D1") > 0.5, "GRG is local: {}", n(&s, "D1"));
set(&mut s, &[("C1", "3.3"), ("C2", "-2.7")]);
let mut p = base.clone();
p["method"] = json!("evolutionary");
let r = s.execute("data.solver", p).unwrap();
assert!(n(&s, "D1") < 1e-8 && n(&s, "C1").abs() < 1e-5 && n(&s, "C2").abs() < 1e-5, "{r}");
// A stepped model: ROUND makes it flat almost everywhere; the minimum is at 7.2.
set(&mut s, &[("E1", "0"), ("F1", "=ABS(ROUND(E1,0)-7)+ABS(E1-7.2)/100")]);
s.execute("data.solver", json!({"objective": "F1", "goal": "min", "variables": "E1", "constraints": ["E1 <= 20"], "method": "evolutionary"}))
.unwrap();
assert!((n(&s, "E1") - 7.2).abs() < 1e-6, "{}", n(&s, "E1"));
}
#[test]
fn evolutionary_handles_integers_bounds_and_the_saved_model() {
let mut s = s();
// The 0/1 knapsack of the branch-and-bound test: items 2 and 3, value 20.
set(&mut s, &[("D1", "=10*C1+13*C2+7*C3"), ("D2", "=5*C1+6*C2+4*C3")]);
let p = json!({"objective": "D1", "variables": "C1:C3", "constraints": ["D2 <= 10", "C1:C3 bin"], "method": "evolutionary", "populationSize": 30, "randomSeed": 7});
let r = s.execute("data.solver", p).unwrap();
assert!(r["status"] == "converged" || r["status"] == "cannotImprove", "{r}");
assert_eq!((n(&s, "C1"), n(&s, "C2"), n(&s, "C3"), n(&s, "D1")), (0.0, 1.0, 1.0, 20.0));
// The method and its options are saved, survive XLSX and solve again.
let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "e.xlsx", "base64": b64})).unwrap();
let m = s.execute("data.solver", json!({})).unwrap();
assert_eq!(m["model"]["method"], "evolutionary");
assert_eq!((m["model"]["populationSize"].as_f64(), m["model"]["randomSeed"].as_f64()), (Some(30.0), Some(7.0)));
set(&mut s, &[("C1", "0"), ("C2", "0"), ("C3", "0")]);
s.execute("data.solver", json!({"saved": true})).unwrap();
assert_eq!(n(&s, "D1"), 20.0);
// Integer constraints: max x + y with x + 2y ≤ 7 on integers in [0, 10] → (7, 0).
set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=A1+A2"), ("B2", "=A1+2*A2")]);
let r = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 7", "A1:A2 <= 10", "A1:A2 int"], "method": "evolutionary"}),
)
.unwrap();
assert_eq!((n(&s, "A1"), n(&s, "A2")), (7.0, 0.0), "{r}");
// Every variable needs bounds unless requireBounds is off; then it searches around the start.
set(&mut s, &[("E1", "1"), ("F1", "=ABS(E1-4)")]);
let p = json!({"objective": "F1", "goal": "min", "variables": "E1", "method": "evolutionary"});
assert!(s.execute("data.solver", p.clone()).is_err());
let mut p = p;
p["requireBounds"] = json!(false);
s.execute("data.solver", p).unwrap();
assert!((n(&s, "E1") - 4.0).abs() < 1e-6);
// Infeasible constraints and a tiny budget are reported without changing the cells.
set(&mut s, &[("E1", "1")]);
let r = s
.execute("data.solver", json!({"objective": "F1", "variables": "E1", "constraints": ["E1 <= 3", "F1 >= 10"], "method": "evolutionary"}))
.unwrap();
assert_eq!(r["status"], "infeasible", "{r}");
let r = s
.execute(
"data.solver",
json!({"objective": "F1", "variables": "E1", "constraints": ["E1 <= 3"], "method": "evolutionary", "maxEvaluations": 3}),
)
.unwrap();
assert_eq!(r["status"], "iterationLimit", "{r}");
assert_eq!(n(&s, "E1"), 1.0);
}
/// Text or number at `a1` on sheet `name`.
fn at(s: &Session, name: &str, a1: &str) -> Value {
let d = s.doc().unwrap();
d.wb.sheet(d.wb.sheet_index(name).unwrap()).unwrap().value(CellRef::parse(a1).unwrap())
}
/// The row of the first cell holding `text` on sheet `name`.
fn row_of(s: &Session, name: &str, text: &str) -> u32 {
let d = s.doc().unwrap();
let sh = d.wb.sheet(d.wb.sheet_index(name).unwrap()).unwrap();
(0..60).find(|r| (0..8).any(|c| sh.value(CellRef::new(*r, c)) == Value::text(text))).unwrap()
}
#[test]
fn answer_and_limits_reports_describe_the_wyndor_glass_solution() {
// Wyndor Glass with labels: doors and windows across, the three plants' hours down.
let mut s = s();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [[null, "Doors", "Windows", "Used", "Available"], ["Batches", 0, 0, null, null], ["Plant 1", 1, 0, null, 4], ["Plant 2", 0, 2, null, 12], ["Plant 3", 3, 2, null, 18], ["Profit", 3, 5, null, null]]}),
)
.unwrap();
for r in 3..=6 {
set(&mut s, &[(&format!("D{r}"), &format!("=SUMPRODUCT(B{r}:C{r},$B$2:$C$2)"))]);
}
let solved = s
.execute(
"data.solver",
json!({"objective": "D6", "variables": "B2:C2", "constraints": ["D3:D5 <= E3:E5"], "method": "simplex", "reports": ["answer", "limits"]}),
)
.unwrap();
assert_eq!(solved["status"], "optimal", "{solved}");
assert_eq!(solved["reports"], json!(["Answer Report 1", "Limits Report 1"]));
assert_eq!(solved["original"], json!({"B2": 0.0, "C2": 0.0}));
let d = s.doc().unwrap();
let names: Vec<&str> = d.wb.sheets.iter().map(|x| x.name.as_str()).collect();
assert_eq!(names, ["Answer Report 1", "Limits Report 1", "Sheet1"]);
assert_eq!(d.wb.active().unwrap().name, "Sheet1", "the model's sheet stays active");
assert_eq!(saved(&s), "solver_opt", "the saved model follows its sheet");
let a = "Answer Report 1";
assert_eq!(at(&s, a, "A1"), Value::text("GridCraft Answer Report"));
// Objective: $D$6 "Profit Used", from 0 to 36.
let r = row_of(&s, a, "$D$6");
assert_eq!(at(&s, a, &format!("C{}", r + 1)), Value::text("Profit Used"));
assert_eq!((at(&s, a, &format!("D{}", r + 1)), at(&s, a, &format!("E{}", r + 1))), (Value::number(0.0), Value::number(36.0)));
// Variables: doors 0 → 2, windows 0 → 6, continuous.
let r = row_of(&s, a, "$B$2") + 1;
assert_eq!(at(&s, a, &format!("C{r}")), Value::text("Batches Doors"));
assert_eq!(at(&s, a, &format!("E{r}")), Value::number(2.0));
assert_eq!(at(&s, a, &format!("F{r}")), Value::text("Contin"));
assert_eq!(at(&s, a, &format!("E{}", r + 1)), Value::number(6.0));
// Constraints: plant 1 has 2 hours to spare; plants 2 and 3 are binding.
let r = row_of(&s, a, "$D$3") + 1;
assert_eq!(at(&s, a, &format!("E{r}")), Value::text("$D$3<=$E$3"));
assert_eq!((at(&s, a, &format!("F{r}")), at(&s, a, &format!("G{r}"))), (Value::text("Not Binding"), Value::number(2.0)));
for k in 1..=2 {
assert_eq!((at(&s, a, &format!("F{}", r + k)), at(&s, a, &format!("G{}", r + k))), (Value::text("Binding"), Value::number(0.0)));
}
// Limits with the other variable held: doors 0..2 (profit 30..36), windows 0..6 (6..36).
let l = "Limits Report 1";
let r = row_of(&s, l, "$B$2") + 1;
let row = |r: u32| ["D", "E", "F", "G", "H"].map(|c| at(&s, l, &format!("{c}{r}")).as_f64().unwrap());
assert_eq!(row(r), [2.0, 0.0, 30.0, 2.0, 36.0]);
assert_eq!(row(r + 1), [6.0, 0.0, 6.0, 6.0, 36.0]);
// data.solverReport writes reports from the saved model and a given solution.
let out =
s.execute("data.solverReport", json!({"reports": ["answer"], "values": {"B2": 2, "C2": 6}, "original": {"B2": 1, "C2": 1}})).unwrap();
assert_eq!(out["sheets"], json!(["Answer Report 2"]));
let r = row_of(&s, "Answer Report 2", "$D$6") + 1;
assert_eq!(at(&s, "Answer Report 2", &format!("D{r}")), Value::number(8.0));
// Undo removes the reports and keeps the solution; refused requests write nothing.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheets.len(), 3);
for p in [
json!({}),
json!({"reports": []}),
json!({"reports": "answer"}),
json!({"reports": ["population"]}),
json!({"reports": ["answer"], "values": {"Z9": 1}}),
json!({"reports": ["answer"], "values": {"B2": "x"}}),
json!({"reports": ["limits"], "constraints": ["D3:D5 <= E3:E5", "B2:C2 int"]}),
json!({"reports": ["limits"], "method": "evolutionary", "constraints": ["D3:D5 <= E3:E5", "B2:C2 <= 10"]}),
] {
assert!(s.execute("data.solverReport", p.clone()).is_err(), "{p}");
}
assert_eq!(s.doc().unwrap().wb.sheets.len(), 3);
// With the solve, its reports and the saved model undone (one step), the result still
// describes the solution.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!((s.doc().unwrap().wb.sheets.len(), n(&s, "B2")), (1, 0.0));
let mut p = solved.clone();
p["reports"] = json!(["answer"]);
assert_eq!(s.execute("data.solverReport", p).unwrap()["sheets"], json!(["Answer Report 1"]));
let r = row_of(&s, "Answer Report 1", "$B$2") + 1;
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(2.0));
}
#[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
// width ranges over 0..5, the area over 0..25.
let mut s = s();
set(&mut s, &[("A1", "1"), ("A2", "1"), ("B1", "=A1*A2"), ("B2", "=2*A1+2*A2")]);
let r =
s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"], "reports": ["limits"]})).unwrap();
assert_eq!(r["reports"], json!(["Limits Report 1"]), "{r}");
let l = "Limits Report 1";
let r = row_of(&s, l, "$A$1") + 1;
let got: Vec<f64> = ["E", "F", "G", "H"].iter().map(|c| at(&s, l, &format!("{c}{r}")).as_f64().unwrap()).collect();
for (g, want) in got.iter().zip([0.0, 0.0, 5.0, 25.0]) {
assert!((g - want).abs() < 1e-3, "{got:?}");
}
}
#[test]
fn infeasible_unbounded_and_nonlinear_models_are_reported() {
let mut s = s();
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
for method in ["simplex", "grg"] {
let r = s
.execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 >= 5", "B1 <= 6"], "method": method}))
.unwrap();
assert_eq!(r["status"], "infeasible", "{method}: {r}");
assert_eq!(r["changed"], false);
assert_eq!(n(&s, "A1"), 1.0, "an infeasible model leaves the cells alone");
}
let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "method": "simplex"})).unwrap();
assert_eq!(r["status"], "unbounded");
let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "method": "grg", "maxEvaluations": 5000})).unwrap();
assert!(r["status"] == "unbounded" || r["status"] == "iterationLimit", "{r}");
set(&mut s, &[("A1", "1"), ("C1", "=A1^2")]);
let r = s.execute("data.solver", json!({"objective": "C1", "goal": "min", "variables": "A1", "method": "simplex"})).unwrap();
assert_eq!(r["status"], "notLinear");
}
#[test]
fn hostile_models_are_refused_without_panicking() {
let mut s = s();
let r = s.execute("data.solver", json!({})).unwrap();
assert!(r["model"].is_null());
assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(n, _) if n == "solver")));
set(&mut s, &[("B1", "=SUM(A1:A1000)"), ("C1", "5")]);
for p in [
json!({"objective": "B1", "variables": "A1:A1000"}),
json!({"objective": "B1", "variables": "A1:XFD1048576"}),
json!({"objective": "C1", "variables": "A1"}),
json!({"objective": "B1:B2", "variables": "A1"}),
json!({"objective": "B1", "variables": "B1"}),
json!({"objective": "B1", "variables": "A1", "goal": "sideways"}),
json!({"objective": "B1", "variables": "A1", "goal": "value"}),
json!({"objective": "B1", "variables": "A1", "method": "evolutionary"}),
json!({"objective": "B1", "variables": "A1", "method": "annealing"}),
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 ?? 3"]}),
json!({"objective": "B1", "variables": "A1", "constraints": "A1 <= 3"}),
json!({"objective": "B1", "variables": "A1", "constraints": ["A1:A3 <= C1:C2"]}),
json!({"objective": "B1", "variables": "A1", "constraints": ["C1 int"]}),
json!({"objective": "B1", "variables": "A1", "constraints": ["A1:A1048576 <= 1"]}),
json!({"objective": "B1", "variables": "A1", "constraints": vec![json!("A1 <= 1"); 1001]}),
json!({"objective": "Nope!B1", "variables": "A1"}),
] {
assert!(s.execute("data.solver", p.clone()).is_err(), "{p}");
}
// Huge evaluation requests are capped, and a model erroring everywhere doesn't panic.
set(&mut s, &[("D1", "=1/0+A1")]);
let r = s.execute("data.solver", json!({"objective": "D1", "variables": "A1", "maxEvaluations": 1e300})).unwrap();
assert_ne!(r["status"], "converged");
}
}