Skip to main content

ravenrs_sheet/engine/cmd/solver/
model.rs

1use crate::base::split_areas;
2
3use super::{
4    CMD, CellRef, DEFAULT_EVALS, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_EVALS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef,
5    Result, Session, Workbook, bad, bool_param, f64_param, json, split_sheet, str_param,
6};
7
8// ---------------------------------------------------------------- model
9
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub(super) enum Rel {
12    Le,
13    Eq,
14    Ge,
15    Int,
16    Bin,
17}
18
19impl Rel {
20    fn parse(s: &str) -> Option<Rel> {
21        match s.trim().to_ascii_lowercase().as_str() {
22            "<=" | "≤" | "=<" | "le" => Some(Rel::Le),
23            ">=" | "≥" | "=>" | "ge" => Some(Rel::Ge),
24            "=" | "==" | "eq" => Some(Rel::Eq),
25            "int" | "integer" => Some(Rel::Int),
26            "bin" | "binary" => Some(Rel::Bin),
27            _ => None,
28        }
29    }
30    pub(super) fn text(self) -> &'static str {
31        match self {
32            Rel::Le => "<=",
33            Rel::Eq => "=",
34            Rel::Ge => ">=",
35            Rel::Int => "int",
36            Rel::Bin => "bin",
37        }
38    }
39    /// The relation code of the saved model (`solver_relN`), as in Solver's VBA `SolverAdd`.
40    fn code(self) -> u8 {
41        match self {
42            Rel::Le => 1,
43            Rel::Eq => 2,
44            Rel::Ge => 3,
45            Rel::Int => 4,
46            Rel::Bin => 5,
47        }
48    }
49    fn from_code(c: &str) -> Option<Rel> {
50        match c.trim() {
51            "1" => Some(Rel::Le),
52            "2" => Some(Rel::Eq),
53            "3" => Some(Rel::Ge),
54            "4" => Some(Rel::Int),
55            "5" => Some(Rel::Bin),
56            _ => None,
57        }
58    }
59}
60
61#[derive(Clone, Debug)]
62pub(super) enum Rhs {
63    None,
64    Const(f64),
65    Cells(Vec<CellRef>),
66}
67
68#[derive(Clone, Debug)]
69pub(super) struct Constraint {
70    pub(super) lhs: Vec<CellRef>,
71    lhs_text: String,
72    pub(super) rel: Rel,
73    pub(super) rhs: Rhs,
74    rhs_text: String,
75}
76
77#[derive(Clone, Copy, Debug, PartialEq)]
78pub(super) enum Goal {
79    Max,
80    Min,
81    Value(f64),
82}
83
84#[derive(Clone, Copy, Debug, PartialEq)]
85pub(super) enum Method {
86    Simplex,
87    Grg,
88    Evolutionary,
89}
90
91/// The Evolutionary method's options (Solver Options › Evolutionary). The search keeps no
92/// clock, so the time without improvement is counted in generations.
93#[derive(Clone, Copy, Debug, PartialEq)]
94pub(super) struct EvoOptions {
95    pub(super) population: usize,
96    pub(super) mutation: f64,
97    pub(super) seed: u64,
98    pub(super) convergence: f64,
99    pub(super) stall: u64,
100    pub(super) require_bounds: bool,
101}
102
103impl Default for EvoOptions {
104    fn default() -> EvoOptions {
105        EvoOptions { population: 100, mutation: 0.075, seed: 0, convergence: 1e-4, stall: 50, require_bounds: true }
106    }
107}
108
109fn evo_options(p: &Json) -> EvoOptions {
110    let d = EvoOptions::default();
111    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());
112    EvoOptions {
113        population: num("populationSize").map_or(d.population, |v| v.clamp(MIN_POPULATION as f64, MAX_POPULATION as f64) as usize),
114        mutation: num("mutationRate").map_or(d.mutation, |v| v.clamp(0.0, 1.0)),
115        seed: num("randomSeed").map_or(d.seed, |v| v.clamp(0.0, f64::from(u32::MAX)) as u64),
116        convergence: num("convergence").map_or(d.convergence, |v| v.clamp(0.0, 1.0)),
117        stall: num("generationsWithoutImprovement").map_or(d.stall, |v| v.clamp(1.0, 100_000.0) as u64),
118        require_bounds: bool_param(p, "requireBounds").unwrap_or(d.require_bounds),
119    }
120}
121
122#[derive(Clone, Debug)]
123pub(super) struct Model {
124    pub(super) sheet: usize,
125    pub(super) objective: CellRef,
126    pub(super) goal: Goal,
127    pub(super) vars: Vec<CellRef>,
128    vars_text: String,
129    pub(super) cons: Vec<Constraint>,
130    pub(super) non_negative: bool,
131    pub(super) method: Method,
132    pub(super) evo: EvoOptions,
133    /// The evaluation limit (in place of Excel's iterations and time limits).
134    pub(super) max_evals: u64,
135    /// Integer Optimality, as a fraction.
136    pub(super) gap: f64,
137}
138
139/// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`.
140fn cells_of(wb: &Workbook, t: &str, default_sheet: usize, cap: usize) -> Result<(usize, Vec<CellRef>)> {
141    let mut sheet = None;
142    let mut out = Vec::new();
143    // A comma inside a quoted sheet name (`'Sales, West'!$A$1`) does not end the area.
144    for part in split_areas(t).into_iter().map(str::trim).filter(|x| !x.is_empty()) {
145        let (sh, body) = split_sheet(part);
146        let si = match sh {
147            Some(n) => wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
148            None => default_sheet,
149        };
150        if *sheet.get_or_insert(si) != si {
151            return Err(bad(CMD, "all cells of a reference must be on one sheet"));
152        }
153        let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a reference: `{part}`")))?;
154        if r.count() > cap as u64 || out.len() + r.count() as usize > cap {
155            return Err(bad(CMD, format!("too many cells (at most {cap})")));
156        }
157        out.extend(r.iter());
158    }
159    if out.is_empty() {
160        return Err(bad(CMD, format!("not a reference: `{t}`")));
161    }
162    Ok((sheet.unwrap_or(default_sheet), out))
163}
164
165fn num_text(t: &str) -> Option<f64> {
166    crate::base::parse::parse_number_text(t.trim()).filter(|v| v.is_finite())
167}
168
169/// `t` split at the first `pat` outside quoted sheet names: `'A<=B'!$C$1 <= 5` splits at the
170/// second `<=`. A doubled quote inside a name (`'O''Brien<=1'!A1`) toggles the quoted state
171/// twice, as in [`split_areas`], so it doesn't end the name.
172fn split_unquoted<'a>(t: &'a str, pat: &str) -> Option<(&'a str, &'a str)> {
173    let mut quoted = false;
174    for (i, c) in t.char_indices() {
175        if c == '\'' {
176            quoted = !quoted;
177        } else if !quoted && t.get(i..).is_some_and(|rest| rest.starts_with(pat)) {
178            return Some((t.get(..i)?, t.get(i.saturating_add(pat.len())..)?));
179        }
180    }
181    None
182}
183
184/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs). A relation's characters inside
185/// a quoted sheet name (`'A<=B'!$C$1 <= 5`) are part of the name.
186fn split_constraint(t: &str) -> Option<(String, Rel, String)> {
187    for op in ["<=", ">=", "=<", "=>", "≤", "≥", "=="] {
188        if let Some((a, b)) = split_unquoted(t, op) {
189            return Some((a.trim().into(), Rel::parse(op)?, b.trim().into()));
190        }
191    }
192    if let Some((a, b)) = split_unquoted(t, "=") {
193        return Some((a.trim().into(), Rel::Eq, b.trim().into()));
194    }
195    let lower = t.trim().to_ascii_lowercase();
196    for (suffix, rel) in [("integer", Rel::Int), ("binary", Rel::Bin), ("int", Rel::Int), ("bin", Rel::Bin)] {
197        if let Some(a) = lower.strip_suffix(suffix) {
198            let a = a.trim();
199            return Some((t.trim().get(..a.len())?.trim().to_string(), rel, String::new()));
200        }
201    }
202    None
203}
204
205fn parse_constraint(wb: &Workbook, sheet: usize, c: &Json) -> Result<Constraint> {
206    let (lhs_text, rel, rhs_json) = match c {
207        Json::String(t) => {
208            let (l, r, rhs) = split_constraint(t).ok_or_else(|| bad(CMD, format!("not a constraint: `{t}`")))?;
209            (l, r, Json::String(rhs))
210        }
211        Json::Object(_) => {
212            let lhs = str_param(c, "lhs").or_else(|| str_param(c, "cell")).or_else(|| str_param(c, "range")).unwrap_or("").to_string();
213            let op = str_param(c, "op").or_else(|| str_param(c, "relation")).unwrap_or("");
214            let rel = Rel::parse(op).ok_or_else(|| bad(CMD, format!("unknown constraint relation `{op}`")))?;
215            (lhs, rel, c.get("rhs").or_else(|| c.get("value")).cloned().unwrap_or(Json::Null))
216        }
217        _ => return Err(bad(CMD, "a constraint is a string like \"B5 <= 10\" or {lhs, op, rhs}")),
218    };
219    let (lsi, lhs) = cells_of(wb, &lhs_text, sheet, MAX_CONSTRAINTS)?;
220    if lsi != sheet {
221        return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into()));
222    }
223    let (rhs, rhs_text) = match rel {
224        Rel::Int | Rel::Bin => (Rhs::None, String::new()),
225        _ => match &rhs_json {
226            Json::Number(n) => {
227                let v = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "the constraint's right side must be a number"))?;
228                (Rhs::Const(v), v.to_string())
229            }
230            Json::String(t) => {
231                let t = t.trim().trim_start_matches('=').trim();
232                if let Some(v) = num_text(t) {
233                    (Rhs::Const(v), t.to_string())
234                } else {
235                    let (rsi, cells) = cells_of(wb, t, sheet, MAX_CONSTRAINTS)?;
236                    if rsi != sheet {
237                        return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into()));
238                    }
239                    if cells.len() != 1 && cells.len() != lhs.len() {
240                        return Err(EngineError::Other("The constraint's two sides must be the same size (or the right side one cell).".into()));
241                    }
242                    (Rhs::Cells(cells), t.to_string())
243                }
244            }
245            _ => return Err(bad(CMD, "missing the constraint's right side")),
246        },
247    };
248    Ok(Constraint { lhs, lhs_text, rel, rhs, rhs_text })
249}
250
251pub(super) fn parse_model(s: &Session, p: &Json) -> Result<Model> {
252    let d = s.doc()?;
253    let wb = &d.wb;
254    let obj_text = str_param(p, "objective").or_else(|| str_param(p, "setObjective")).or_else(|| str_param(p, "set")).unwrap_or("");
255    let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet(), 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?;
256    let objective = *obj.first().ok_or_else(|| EngineError::Other("The objective must be a single cell.".into()))?;
257    let sh = wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
258    if sh.cell(objective).is_none_or(|c| c.formula.is_none()) {
259        return Err(EngineError::Other("The objective cell must contain a formula.".into()));
260    }
261    let goal = match str_param(p, "goal").or_else(|| str_param(p, "to")).unwrap_or("max").to_ascii_lowercase().as_str() {
262        "max" | "maximize" | "maximum" => Goal::Max,
263        "min" | "minimize" | "minimum" => Goal::Min,
264        "value" | "valueof" | "value of" => {
265            let v = f64_param(p, "value")
266                .or_else(|| str_param(p, "value").and_then(num_text))
267                .filter(|v| v.is_finite())
268                .ok_or_else(|| bad(CMD, "goal `value` needs a number in `value`"))?;
269            Goal::Value(v)
270        }
271        g => return Err(bad(CMD, format!("unknown goal `{g}` (max, min or value)"))),
272    };
273    let vars_text = str_param(p, "variables").or_else(|| str_param(p, "changing")).or_else(|| str_param(p, "byChanging")).unwrap_or("").to_string();
274    let (vsi, mut vars) = cells_of(wb, &vars_text, sheet, MAX_VARS).map_err(|e| match e {
275        EngineError::BadParams { msg, .. } if msg.starts_with("too many") => {
276            EngineError::Other(format!("Too many variable cells (at most {MAX_VARS})."))
277        }
278        _ => EngineError::Other("The variable cells must be a reference like B2:B4.".into()),
279    })?;
280    if vsi != sheet {
281        return Err(EngineError::Other("The variable cells must be on the objective cell's sheet.".into()));
282    }
283    let mut seen = std::collections::HashSet::new();
284    vars.retain(|c| seen.insert(*c));
285    // Python cells count as formulas: a solution written over one would lose its code.
286    if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some() || c.py.is_some())) {
287        return Err(EngineError::Other("Variable cells must hold constants, not formulas.".into()));
288    }
289    let mut cons = Vec::new();
290    let mut scalar = 0usize;
291    if let Some(list) = p.get("constraints") {
292        let list = list.as_array().ok_or_else(|| bad(CMD, "`constraints` must be an array"))?;
293        if list.len() > MAX_CONSTRAINTS {
294            return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS}).")));
295        }
296        for c in list {
297            let c = parse_constraint(wb, sheet, c)?;
298            scalar = scalar.saturating_add(c.lhs.len());
299            if scalar > MAX_CONSTRAINTS {
300                return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS} cells).")));
301            }
302            if matches!(c.rel, Rel::Int | Rel::Bin) && c.lhs.iter().any(|x| !vars.contains(x)) {
303                return Err(EngineError::Other("Integer and binary constraints must be on variable cells.".into()));
304            }
305            cons.push(c);
306        }
307    }
308    let non_negative = bool_param(p, "nonNegative").unwrap_or(true);
309    let method = match str_param(p, "method").unwrap_or("grg").to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
310        "grg" | "grgnonlinear" | "nonlinear" => Method::Grg,
311        "simplex" | "simplexlp" | "lp" | "linear" => Method::Simplex,
312        "evolutionary" | "evo" | "genetic" => Method::Evolutionary,
313        m => return Err(bad(CMD, format!("unknown method `{m}` (grg, simplex or evolutionary)"))),
314    };
315    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());
316    let max_evals = num("maxEvaluations").filter(|v| *v >= 1.0).map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
317    // Integer Optimality is in percent: "5" and "5%" both mean 5 % (number parsing would read
318    // "5%" as 0.05).
319    let percent = p
320        .get("integerOptimality")
321        .and_then(Json::as_f64)
322        .or_else(|| str_param(p, "integerOptimality").and_then(|t| num_text(t.trim().trim_end_matches('%'))))
323        .filter(|v| v.is_finite());
324    let gap = percent.map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0);
325    Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p), max_evals, gap })
326}
327
328/// Whether a `data.solver` result status is a solution the Sensitivity report can describe: an
329/// optimum (Simplex LP) or a converged local optimum (GRG Nonlinear). A run that stopped at its
330/// evaluation limit, or an Evolutionary run, has none.
331pub fn sensitivity_status(status: &str) -> bool {
332    matches!(status, "optimal" | "converged")
333}
334
335/// Whether `data.solver` params (such as the `model` of its result) describe a model that can
336/// have a Sensitivity report: Simplex LP or GRG Nonlinear, a Max or Min objective and no
337/// integer or binary constraints.
338pub fn sensitivity_applies(p: &Json) -> bool {
339    let method = str_param(p, "method").unwrap_or("grg").to_ascii_lowercase().replace([' ', '_', '-'], "");
340    let goal = str_param(p, "goal").or_else(|| str_param(p, "to")).unwrap_or("max").to_ascii_lowercase();
341    let integer = p.get("constraints").and_then(Json::as_array).is_some_and(|list| {
342        list.iter().any(|c| {
343            let rel = match c {
344                Json::String(t) => split_constraint(t).map(|(_, rel, _)| rel),
345                Json::Object(_) => str_param(c, "op").or_else(|| str_param(c, "relation")).and_then(Rel::parse),
346                _ => None,
347            };
348            matches!(rel, Some(Rel::Int | Rel::Bin))
349        })
350    });
351    !integer && !matches!(method.as_str(), "evolutionary" | "evo" | "genetic") && !matches!(goal.as_str(), "value" | "valueof" | "value of")
352}
353
354// ---------------------------------------------------------------- saved model
355
356/// `"B2:B4,D1"` as absolute references, each qualified by `sheet_name` when given.
357fn abs_text(sheet_name: Option<&str>, cells: &str) -> String {
358    let q = sheet_name.map(|n| format!("{}!", crate::engine::pivot::quote_sheet(n))).unwrap_or_default();
359    split_areas(cells)
360        .into_iter()
361        .map(str::trim)
362        .filter(|x| !x.is_empty())
363        .filter_map(|part| {
364            let r = RangeRef::parse(split_sheet(part).1.trim())?;
365            let abs = |c: CellRef| format!("${}${}", crate::base::col_to_letters(c.col), c.row as u64 + 1);
366            Some(if r.is_single() { format!("{q}{}", abs(r.start)) } else { format!("{q}{}:{}", abs(r.start), abs(r.end)) })
367        })
368        .collect::<Vec<_>>()
369        .join(",")
370}
371
372fn ref_text(sheet_name: &str, cells: &str) -> String {
373    abs_text(Some(sheet_name), cells)
374}
375
376/// The evaluation limit and the generations without improvement have no Excel equivalent:
377/// Excel's `solver_itr` counts iterations (100 by default) and `solver_mni` seconds, so a
378/// workbook from Excel would cut GridCraft's search short. They are saved under GridCraft's own
379/// names, which Excel ignores.
380const EVALS_NAME: &str = "gridcraft_solver_evals";
381const STALL_NAME: &str = "gridcraft_solver_stall";
382
383/// The names of a saved model after `solver_`, as Excel's Solver writes them (GridCraft writes
384/// some of them). Saving a model and Reset All remove these and no other `solver_` names.
385const SOLVER_KEYS: [&str; 28] = [
386    "adj", "cvg", "drv", "eng", "est", "itr", "lin", "mip", "mni", "mrt", "msl", "neg", "nod", "num", "nwt", "opt", "pre", "rbv", "rlx", "rsd",
387    "scl", "sho", "ssz", "tim", "tol", "typ", "val", "ver",
388];
389
390/// Whether `name` is one of the names of a saved model: Excel's Solver names (`solver_opt`,
391/// `solver_lhs1` and so on) and GridCraft's own. A user's name such as `solver_budget` is not.
392pub(super) fn is_model_name(name: &str) -> bool {
393    let name = name.to_ascii_lowercase();
394    if name == EVALS_NAME || name == STALL_NAME {
395        return true;
396    }
397    let Some(key) = name.strip_prefix("solver_") else { return false };
398    if SOLVER_KEYS.contains(&key) {
399        return true;
400    }
401    // One name per constraint: solver_lhs1, solver_rel1, solver_rhs1, …
402    ["lhs", "rel", "rhs"].iter().filter_map(|p| key.strip_prefix(p)).any(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
403}
404
405/// The model as hidden names scoped to its sheet (replacing any saved before).
406pub(super) fn save_model(wb: &mut Workbook, m: &Model) {
407    let Some(sheet_name) = wb.sheet(m.sheet).map(|s| s.name.clone()) else { return };
408    wb.names.retain(|n| !(n.scope == Some(m.sheet) && is_model_name(&n.name)));
409    let mut put = |name: String, formula: String| {
410        wb.names.push(DefinedName { name, scope: Some(m.sheet), formula, comment: String::new(), hidden: true });
411    };
412    put("solver_opt".into(), ref_text(&sheet_name, &m.objective.a1()));
413    let (typ, val) = match m.goal {
414        Goal::Max => (1, 0.0),
415        Goal::Min => (2, 0.0),
416        Goal::Value(v) => (3, v),
417    };
418    put("solver_typ".into(), typ.to_string());
419    put("solver_val".into(), val.to_string());
420    put("solver_adj".into(), ref_text(&sheet_name, &m.vars_text));
421    for (i, c) in m.cons.iter().enumerate() {
422        let k = i + 1;
423        put(format!("solver_lhs{k}"), ref_text(&sheet_name, &c.lhs_text));
424        put(format!("solver_rel{k}"), c.rel.code().to_string());
425        let rhs = match &c.rhs {
426            Rhs::None => {
427                if c.rel == Rel::Bin {
428                    "\"binary\"".to_string()
429                } else {
430                    "\"integer\"".to_string()
431                }
432            }
433            Rhs::Const(v) => v.to_string(),
434            Rhs::Cells(_) => ref_text(&sheet_name, &c.rhs_text),
435        };
436        put(format!("solver_rhs{k}"), rhs);
437    }
438    put("solver_num".into(), m.cons.len().to_string());
439    put("solver_neg".into(), if m.non_negative { "1" } else { "2" }.into());
440    let eng = match m.method {
441        Method::Grg => "1",
442        Method::Simplex => "2",
443        Method::Evolutionary => "3",
444    };
445    put("solver_eng".into(), eng.into());
446    put(EVALS_NAME.into(), m.max_evals.to_string());
447    put("solver_tol".into(), m.gap.to_string());
448    // The Evolutionary options are kept whatever the method, as Solver Options sets them, so
449    // switching methods doesn't lose them.
450    put("solver_ssz".into(), m.evo.population.to_string());
451    put("solver_mrt".into(), m.evo.mutation.to_string());
452    put("solver_rsd".into(), m.evo.seed.to_string());
453    put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into());
454    put("solver_cvg".into(), m.evo.convergence.to_string());
455    put(STALL_NAME.into(), m.evo.stall.to_string());
456    put("solver_ver".into(), "3".into());
457}
458
459/// The saved model of a sheet as `data.solver` params, if any.
460///
461/// A sheet's own (sheet-level) model wins over one saved as workbook-level names, and its names
462/// are read alone, so the two never mix. A sheet-level `solver_opt` of `""` (written by Reset
463/// All over a workbook-level model) means the sheet has no model.
464pub fn saved_model(wb: &Workbook, sheet: usize) -> Option<Json> {
465    let named = |n: &str, scope: Option<usize>| wb.names.iter().find(|d| d.name.eq_ignore_ascii_case(n) && d.scope == scope);
466    let scope = if named("solver_opt", Some(sheet)).is_some() { Some(sheet) } else { None };
467    let get = |n: &str| named(n, scope).map(|d| d.formula.trim().trim_start_matches('=').to_string());
468    let local = |t: String| local_refs(&t);
469    let objective = local(get("solver_opt")?);
470    if objective.trim_matches('"').trim().is_empty() {
471        return None;
472    }
473    let goal = match get("solver_typ").as_deref().map(str::trim) {
474        Some("2") => "min",
475        Some("3") => "value",
476        _ => "max",
477    };
478    let value = get("solver_val").and_then(|v| num_text(&v)).unwrap_or(0.0);
479    let count = get("solver_num").and_then(|v| num_text(&v)).unwrap_or(0.0).clamp(0.0, MAX_CONSTRAINTS as f64) as usize;
480    let mut constraints = Vec::new();
481    for k in 1..=count {
482        let (Some(lhs), Some(rel)) = (get(&format!("solver_lhs{k}")), get(&format!("solver_rel{k}")).and_then(|r| Rel::from_code(&r))) else {
483            continue;
484        };
485        let lhs = local(lhs);
486        constraints.push(match rel {
487            Rel::Int | Rel::Bin => json!(format!("{lhs} {}", rel.text())),
488            _ => {
489                let rhs = get(&format!("solver_rhs{k}")).unwrap_or_default();
490                let rhs = if num_text(&rhs).is_some() { rhs } else { local(rhs) };
491                json!(format!("{lhs} {} {rhs}", rel.text()))
492            }
493        });
494    }
495    let method = match get("solver_eng").as_deref().map(str::trim) {
496        Some("2") => "simplex",
497        Some("3") => "evolutionary",
498        _ => "grg",
499    };
500    let mut model = json!({
501        "objective": objective,
502        "goal": goal,
503        "value": value,
504        "variables": get("solver_adj").map(local).unwrap_or_default(),
505        "constraints": constraints,
506        "nonNegative": get("solver_neg").as_deref().map(str::trim) != Some("2"),
507        "method": method,
508    });
509    for (name, key) in [
510        ("solver_ssz", "populationSize"),
511        ("solver_mrt", "mutationRate"),
512        ("solver_rsd", "randomSeed"),
513        ("solver_cvg", "convergence"),
514        (STALL_NAME, "generationsWithoutImprovement"),
515        (EVALS_NAME, "maxEvaluations"),
516    ] {
517        if let Some(v) = get(name).and_then(|v| num_text(&v)) {
518            model[key] = number(v);
519        }
520    }
521    if let Some(v) = get("solver_tol").and_then(|v| num_text(&v)) {
522        model["integerOptimality"] = number(percent(v));
523    }
524    if let Some(v) = get("solver_rbv") {
525        model["requireBounds"] = json!(v.trim() != "2");
526    }
527    Some(model)
528}
529
530/// The model as `data.solver` params, in the form [`saved_model`] returns (the objective
531/// qualified by its sheet).
532pub(super) fn model_params(wb: &Workbook, m: &Model) -> Json {
533    let sheet = wb.sheet(m.sheet).map(|s| crate::engine::pivot::quote_sheet(&s.name)).unwrap_or_default();
534    let (goal, value) = match m.goal {
535        Goal::Max => ("max", 0.0),
536        Goal::Min => ("min", 0.0),
537        Goal::Value(v) => ("value", v),
538    };
539    let constraints: Vec<Json> = m
540        .cons
541        .iter()
542        .map(|c| match c.rel {
543            Rel::Int | Rel::Bin => json!(format!("{} {}", c.lhs_text, c.rel.text())),
544            _ => json!(format!("{} {} {}", c.lhs_text, c.rel.text(), c.rhs_text)),
545        })
546        .collect();
547    let method = match m.method {
548        Method::Simplex => "simplex",
549        Method::Grg => "grg",
550        Method::Evolutionary => "evolutionary",
551    };
552    let mut out = json!({
553        "objective": format!("{sheet}!{}", m.objective.a1()),
554        "goal": goal,
555        "value": value,
556        "variables": m.vars_text,
557        "constraints": constraints,
558        "nonNegative": m.non_negative,
559        "method": method,
560        "maxEvaluations": m.max_evals,
561        "integerOptimality": number(percent(m.gap)),
562    });
563    let e = &m.evo;
564    out["populationSize"] = json!(e.population);
565    out["mutationRate"] = json!(e.mutation);
566    out["randomSeed"] = json!(e.seed);
567    out["convergence"] = json!(e.convergence);
568    out["generationsWithoutImprovement"] = json!(e.stall);
569    out["requireBounds"] = json!(e.require_bounds);
570    out
571}
572
573// ---------------------------------------------------------------- model on cells
574
575/// The cells Save Model writes, top to bottom, as formula text.
576pub(super) fn model_formulas(m: &Model) -> Result<Vec<String>> {
577    // A comma inside a quoted sheet name (`'Sales, West'!$A$1`) doesn't start another area.
578    let one_area = |t: &str| split_areas(t).into_iter().filter(|a| !a.trim().is_empty()).count() <= 1;
579    let mut rows = Vec::with_capacity(m.cons.len() + 3);
580    let obj = abs_text(None, &m.objective.a1());
581    rows.push(match m.goal {
582        Goal::Max => format!("=MAX({obj})"),
583        Goal::Min => format!("=MIN({obj})"),
584        Goal::Value(v) => format!("={obj}={}", crate::base::number_to_text(v)),
585    });
586    rows.push(format!("=COUNT({})", abs_text(None, &m.vars_text)));
587    for c in &m.cons {
588        // A number on the right side (`1,000`) is no range, whatever its separators.
589        if !one_area(&c.lhs_text) || (matches!(c.rhs, Rhs::Cells(_)) && !one_area(&c.rhs_text)) {
590            return Err(EngineError::Other("Save Model needs each side of a constraint to be one range.".into()));
591        }
592        let lhs = abs_text(None, &c.lhs_text);
593        rows.push(match (c.rel, &c.rhs) {
594            (Rel::Int, _) => format!("=AND({lhs}=INT({lhs}))"),
595            (Rel::Bin, _) => format!("=AND(({lhs}=0)+({lhs}=1))"),
596            (rel, Rhs::Cells(_)) => format!("=AND({lhs}{}{})", rel.text(), abs_text(None, &c.rhs_text)),
597            (rel, Rhs::Const(v)) => format!("=AND({lhs}{}{})", rel.text(), crate::base::number_to_text(*v)),
598            (rel, Rhs::None) => format!("=AND({lhs}{}0)", rel.text()),
599        });
600    }
601    let e = &m.evo;
602    let n = crate::base::number_to_text;
603    let eng = match m.method {
604        Method::Grg => 1,
605        Method::Simplex => 2,
606        Method::Evolutionary => 3,
607    };
608    let yes = |b: bool| if b { 1 } else { 2 };
609    rows.push(format!(
610        "={{{eng},{},{},{},{},{},{},{},{},{}}}",
611        yes(m.non_negative),
612        e.population,
613        n(e.mutation),
614        e.seed,
615        yes(e.require_bounds),
616        n(e.convergence),
617        e.stall,
618        m.max_evals,
619        n(percent(m.gap))
620    ));
621    Ok(rows)
622}
623
624/// `NAME(inner)` → inner, ignoring case.
625fn call_args(t: &str, name: &str) -> Option<String> {
626    let head = t.get(..name.len() + 1)?;
627    (head.eq_ignore_ascii_case(&format!("{name}(")) && t.ends_with(')')).then(|| t.get(name.len() + 1..t.len() - 1).map(str::to_string))?
628}
629
630/// References without sheet names or `$`.
631fn local_refs(t: &str) -> String {
632    split_areas(t).into_iter().map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",")
633}
634
635fn not_a_model() -> EngineError {
636    EngineError::Other("The range doesn't hold a Solver model saved with Save Model.".into())
637}
638
639/// How many values the options row of Save Model holds at least: method, non-negative,
640/// population size, mutation rate, random seed, require bounds, convergence and generations
641/// without improvement.
642const OPTIONS_FIELDS: usize = 8;
643
644/// The options row of Save Model, `{method, non-negative, …}`, into `model`. Refused when it
645/// holds fewer than [`OPTIONS_FIELDS`] values or a value that isn't a number: a row cut short
646/// or edited by hand would otherwise load with default options in place of the saved ones.
647fn read_options_row(model: &mut Json, list: &str) -> Result<()> {
648    let incomplete = || {
649        EngineError::Other(format!(
650            "The model's options row doesn't hold the {OPTIONS_FIELDS} or more numbers Save Model writes, so the range isn't a complete model saved with Save Model."
651        ))
652    };
653    // Every value is checked; only the first ones are kept (the row is one formula, so its
654    // length is bounded by the formula's).
655    let mut v: Vec<f64> = Vec::with_capacity(16);
656    let mut count = 0usize;
657    for t in list.split([',', ';']) {
658        let x = num_text(t).ok_or_else(incomplete)?;
659        count = count.saturating_add(1);
660        if v.len() < 16 {
661            v.push(x);
662        }
663    }
664    if count < OPTIONS_FIELDS {
665        return Err(incomplete());
666    }
667    let get = |k: usize| v.get(k).copied();
668    model["method"] = json!(match get(0).map(|x| x as i64) {
669        Some(2) => "simplex",
670        Some(3) => "evolutionary",
671        _ => "grg",
672    });
673    model["nonNegative"] = json!(get(1) != Some(2.0));
674    for (k, key) in [
675        (2, "populationSize"),
676        (3, "mutationRate"),
677        (4, "randomSeed"),
678        (6, "convergence"),
679        (7, "generationsWithoutImprovement"),
680        (8, "maxEvaluations"),
681        (9, "integerOptimality"),
682    ] {
683        if let Some(x) = get(k) {
684            model[key] = json!(x);
685        }
686    }
687    model["requireBounds"] = json!(get(5) != Some(2.0));
688    Ok(())
689}
690
691/// One constraint row of Save Model (`AND(relation)`) as a `data.solver` constraint.
692fn read_constraint_row(t: String) -> Result<String> {
693    let t = call_args(&t, "AND").unwrap_or(t);
694    if let Some((x, y)) = split_unquoted(&t, "=INT(").or_else(|| split_unquoted(&t, "=int(")) {
695        if y.strip_suffix(')') != Some(x) {
696            return Err(not_a_model());
697        }
698        return Ok(format!("{} int", local_refs(x)));
699    }
700    if let Some(x) = t.strip_prefix('(').and_then(|x| x.strip_suffix("=1)")).and_then(|x| split_unquoted(x, "=0)+(")).filter(|(a, b)| a == b) {
701        return Ok(format!("{} bin", local_refs(x.0)));
702    }
703    let (l, rel, r) = split_constraint(&t).ok_or_else(not_a_model)?;
704    let r = r.trim_matches('"');
705    Ok(match r.to_ascii_lowercase().as_str() {
706        "integer" => format!("{} int", local_refs(&l)),
707        "binary" => format!("{} bin", local_refs(&l)),
708        _ if num_text(r).is_some() => format!("{} {} {r}", local_refs(&l), rel.text()),
709        _ => format!("{} {} {}", local_refs(&l), rel.text(), local_refs(r)),
710    })
711}
712
713/// Reads formula text written by Save Model back into `data.solver` params.
714pub(super) fn parse_model_formulas(rows: &[String]) -> Result<Json> {
715    let body = |t: &str| t.trim().trim_start_matches('=').replace(' ', "");
716    let first = body(rows.first().ok_or_else(not_a_model)?);
717    let (goal, objective, value) = if let Some(o) = call_args(&first, "MAX") {
718        ("max", o, 0.0)
719    } else if let Some(o) = call_args(&first, "MIN") {
720        ("min", o, 0.0)
721    } else {
722        let (o, v) = split_unquoted(&first, "=").ok_or_else(not_a_model)?;
723        ("value", o.to_string(), num_text(v).ok_or_else(not_a_model)?)
724    };
725    let variables = call_args(&body(rows.get(1).ok_or_else(not_a_model)?), "COUNT").ok_or_else(not_a_model)?;
726    let mut model = json!({"objective": local_refs(&objective), "goal": goal, "value": value, "variables": local_refs(&variables)});
727    let mut constraints = Vec::new();
728    let rest = rows.get(2..).unwrap_or(&[]);
729    for (i, row) in rest.iter().enumerate() {
730        let t = body(row);
731        if let Some(list) = t.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
732            // The options: the last row.
733            if i + 1 != rest.len() {
734                return Err(not_a_model());
735            }
736            read_options_row(&mut model, list)?;
737            break;
738        }
739        constraints.push(json!(read_constraint_row(t)?));
740    }
741    model["constraints"] = json!(constraints);
742    Ok(model)
743}
744
745/// Integer Optimality as a percent from its stored fraction. The product is rounded to 1E-10 %,
746/// so a fraction such as 0.07 reads back as 7 and not 7.000000000000001.
747fn percent(fraction: f64) -> f64 {
748    let p = fraction * 100.0;
749    let rounded = (p * 1e10).round() / 1e10;
750    if rounded.is_finite() { rounded } else { p }
751}
752
753/// A number read from a hidden name as JSON: a whole number as an integer, so the Solver dialog
754/// shows 20000 and not 20000.0.
755fn number(v: f64) -> Json {
756    // Whole numbers below 2^53 are exact as integers.
757    if v.fract() == 0.0 && v.abs() < 9_007_199_254_740_992.0 { json!(v as i64) } else { json!(v) }
758}