Data: Solver with Simplex LP, integer branch and bound and a nonlinear method (#326)

data.solver maximises, minimises or targets a value in an objective cell by
changing variable cells under <=, >=, =, int and bin constraints. Simplex LP
linearises the model through recalculation, checks linearity and solves it
with a two-phase simplex; integers use branch and bound. The GRG Nonlinear
choice runs an augmented-Lagrangian projected-gradient method. The model is
saved as hidden solver_* names; Solver Parameters and Solver Results dialogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 12:56:36 +02:00
parent 9f896c1249
commit dc75b2cf5e
14 changed files with 1704 additions and 9 deletions

View File

@ -14,6 +14,7 @@ pub mod pivot;
pub mod print;
pub mod review;
pub mod sheet;
pub mod solver;
pub mod spelling;
pub mod view;
pub mod whatif;
@ -118,6 +119,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(extra::specs());
v.extend(draw::specs());
v.extend(spelling::specs());
v.extend(solver::specs());
v.extend(pivot::specs());
v.extend(whatif::specs());
v.extend(print::specs());

File diff suppressed because it is too large Load Diff

View File

@ -317,6 +317,23 @@ impl Dialog {
json!({"header": true}),
),
"goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})),
"solver" => {
// Starts from the sheet's saved model, else the active cell as objective.
let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet));
let mut values = json!({"objective": active, "goal": "max", "value": "0", "variables": "", "constraints": [], "nonNegative": true, "method": "grg"});
if let (Some(m), Some(v)) = (saved.as_ref().and_then(Json::as_object), values.as_object_mut()) {
for (k, x) in m {
v.insert(k.clone(), if k == "value" { json!(x.to_string()) } else { x.clone() });
}
}
if let Some(v) = values.as_object_mut() {
v.insert("newOp".into(), json!("<="));
}
let mut d = Dialog::custom("solver", "Solver Parameters", values);
d.command = Some("data.solver");
d
}
"solverResults" => Dialog::custom("solverResults", "Solver Results", params.clone()),
"subtotal" => Dialog::form(
"subtotal",
"Subtotal",
@ -659,6 +676,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"formatCells" => 560.0,
"about" => 660.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
"solver" => 460.0,
_ => 380.0,
};
let window = egui::Window::new(d.title.clone())
@ -681,6 +699,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"manageRules" => manage_rules(app, ui),
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
"solver" => solver(ui, &mut d, &mut confirm, &mut open),
"solverResults" => solver_results(app, ui, &mut d, &mut open),
"spelling" => spelling(app, ui, &mut d, &mut open),
"agents" => {
ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:");
@ -877,7 +897,12 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
if confirm && let Some(cmd) = d.command {
let params = build_params(&d);
match app.run(cmd, params) {
Ok(_) => open = false,
Ok(r) => {
open = false;
if d.name == "solver" {
app.open_dialog("solverResults", r);
}
}
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
}
} else if confirm {
@ -1607,6 +1632,144 @@ fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mu
}
}
/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's
/// command; the result opens Solver Results.
fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default();
let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32| {
let mut t = text(d, k);
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).changed() {
d.values.insert(k.into(), json!(t));
}
};
ui.horizontal(|ui| {
ui.label("Set Objective:");
edit(ui, d, "objective", 200.0);
});
let goal = text(d, "goal");
ui.horizontal(|ui| {
ui.label("To:");
for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] {
if ui.radio(goal == g, label).clicked() {
d.values.insert("goal".into(), json!(g));
}
}
edit(ui, d, "value", 80.0);
});
ui.label("By Changing Variable Cells:");
edit(ui, d, "variables", 420.0);
ui.label("Subject to the Constraints:");
let list: Vec<String> = d
.values
.get("constraints")
.and_then(Json::as_array)
.map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect())
.unwrap_or_default();
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.set_min_size(vec2(420.0, 90.0));
egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| {
for (i, c) in list.iter().enumerate() {
if ui.selectable_label(d.list_index == i, c).clicked() {
d.list_index = i;
}
}
});
});
ui.horizontal(|ui| {
edit(ui, d, "newLhs", 110.0);
let op = text(d, "newOp");
egui::ComboBox::from_id_salt("solverOp").selected_text(op.clone()).width(60.0).show_ui(ui, |ui| {
for o in ["<=", "=", ">=", "int", "bin"] {
if ui.selectable_label(op == o, o).clicked() {
d.values.insert("newOp".into(), json!(o));
}
}
});
if !matches!(op.as_str(), "int" | "bin") {
edit(ui, d, "newRhs", 110.0);
}
if ui.button("Add").clicked() {
let lhs = text(d, "newLhs");
if !lhs.trim().is_empty() {
let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) };
let mut list = list.clone();
list.push(format!("{} {op}{rhs}", lhs.trim()));
d.values.insert("constraints".into(), json!(list));
d.values.insert("newLhs".into(), json!(""));
d.values.insert("newRhs".into(), json!(""));
}
}
if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() {
let mut list = list.clone();
if d.list_index < list.len() {
list.remove(d.list_index);
}
d.values.insert("constraints".into(), json!(list));
}
});
let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true);
if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() {
d.values.insert("nonNegative".into(), json!(nonneg));
}
let method = text(d, "method");
ui.horizontal(|ui| {
ui.label("Select a Solving Method:");
let label = |m: &str| if m == "simplex" { "Simplex LP" } else { "GRG Nonlinear" };
egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| {
for m in ["grg", "simplex"] {
if ui.selectable_label(method == m, label(m)).clicked() {
d.values.insert("method".into(), json!(m));
}
}
});
});
ui.label(
egui::RichText::new(if method == "simplex" {
"Simplex LP: for linear models; integer and binary constraints by branch and bound."
} else {
"GRG Nonlinear: a local optimum of a smooth model, from the current values."
})
.small(),
);
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let solve =
ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent));
if solve.clicked() && !text(d, "objective").trim().is_empty() {
*confirm = true;
}
if ui.button(" Close ").clicked() {
*open = false;
}
});
}
/// Solver Results: the outcome, and keeping or restoring the variable cells.
fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string();
ui.label(message);
let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false);
let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false);
if changed {
ui.add_space(4.0);
if ui.radio(!restore, "Keep Solver Solution").clicked() {
d.values.insert("restore".into(), json!(false));
}
if ui.radio(restore, "Restore Original Values").clicked() {
d.values.insert("restore".into(), json!(true));
}
}
let mut confirm = false;
ok_cancel(ui, &mut confirm, open);
if confirm {
// The solution (and the saved model) is one undo step.
if changed && restore {
app.run_or_alert("edit.undo", json!({}));
}
*open = false;
}
}
fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
let r = d.result.clone().unwrap_or(Json::Null);
if let Some(e) = r.get("error").and_then(Json::as_str) {

View File

@ -313,6 +313,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Group", "グループ化"),
("Ungroup", "グループ解除"),
("Subtotal", "小計"),
("Solver", "ソルバー"),
("What-If\nAnalysis", "What-If 分析"),
("Data Table…", "データ テーブル…"),
("Scenario Manager…", "シナリオ マネージャー…"),

View File

@ -164,6 +164,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "그룹",
"Ungroup" => "그룹 해제",
"Subtotal" => "부분합",
"Solver" => "해 찾기",
"What-If\nAnalysis" => "가상 분석",
"Data Table…" => "데이터 표…",
"Scenario Manager…" => "시나리오 관리자…",

View File

@ -165,6 +165,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "Agrupar",
"Ungroup" => "Desagrupar",
"Subtotal" => "Subtotal",
"Solver" => "Solver",
"What-If\nAnalysis" => "Análise de\nHipóteses",
"Data Table…" => "Tabela de Dados…",
"Scenario Manager…" => "Gerenciador de Cenários…",

View File

@ -164,6 +164,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "Группировать",
"Ungroup" => "Разгруппировать",
"Subtotal" => "Промежуточный итог",
"Solver" => "Поиск решения",
"What-If\nAnalysis" => "Анализ «что\nесли»",
"Data Table…" => "Таблица данных…",
"Scenario Manager…" => "Диспетчер сценариев…",

View File

@ -164,6 +164,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "组合",
"Ungroup" => "取消组合",
"Subtotal" => "分类汇总",
"Solver" => "规划求解",
"What-If\nAnalysis" => "假设分析",
"Data Table…" => "数据表…",
"Scenario Manager…" => "方案管理器…",

View File

@ -1580,6 +1580,10 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
app.open_dialog("subtotal", json!({}));
}
});
sep(ui);
if big_button(ui, Icon::Function, "Solver", "Solver", false).clicked() {
app.open_dialog("solver", json!({}));
}
}
fn review(app: &mut SheetApp, ui: &mut Ui) {

View File

@ -228,6 +228,7 @@ fn dialogs_open_and_close() {
"spelling",
"goTo",
"about",
"solver",
] {
h.state_mut().open_dialog(name, json!({}));
h.run_steps(2);
@ -237,6 +238,37 @@ fn dialogs_open_and_close() {
}
}
#[test]
fn solver_dialog_loads_the_saved_model_and_results_can_restore() {
let mut s = blank();
for (c, v) in [("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")] {
s.execute("cell.set", json!({"cell": c, "input": v})).unwrap();
}
let r = s
.execute(
"data.solver",
json!({"objective": "B1", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", "B3 <= 18"], "method": "simplex"}),
)
.unwrap();
assert_eq!(s.active().unwrap().wb.active().unwrap().value(CellRef::parse("B1").unwrap()), Value::Number(36.0));
let mut h = harness(s);
h.state_mut().open_dialog("solver", json!({}));
h.run_steps(2);
let d = h.state().dialog.as_ref().unwrap();
assert_eq!(d.values.get("objective"), Some(&json!("B1")));
assert_eq!(d.values.get("method"), Some(&json!("simplex")));
assert_eq!(d.values.get("constraints").and_then(|c| c.as_array()).map(Vec::len), Some(3));
h.state_mut().dialog = None;
// Solver Results › Restore Original Values undoes the solution.
h.state_mut().open_dialog("solverResults", r);
h.state_mut().dialog.as_mut().unwrap().values.insert("restore".into(), json!(true));
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none());
assert_eq!(value(&h, "A1"), Value::Number(0.0));
assert_eq!(value(&h, "B1"), Value::Number(0.0));
}
#[test]
fn about_renders_every_tab() {
let mut h = harness(blank());

View File

@ -98,8 +98,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h.
### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F)
- `data.solver`, `data.analysisToolPak`, `data.forecastSheet` missing; FORECAST.ETS family missing.
**Estimate:** 22–35 h.
- `data.analysisToolPak`, `data.forecastSheet` missing; FORECAST.ETS family missing. Solver
(`data.solver`) has Simplex LP with integer/binary branch and bound and a local nonlinear method
(augmented Lagrangian, not GRG); Evolutionary, AllDifferent constraints, Solver options and
reports remain. **Estimate:** 16–28 h.
### 14. Page Layout view and print polish (U, F)
- No paper view with rulers and in-place header/footer editing; web print exports only (#141, PR

View File

@ -4,7 +4,7 @@
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
**Overall: 258 / 290 commands implemented (89.0%).**
**Overall: 259 / 290 commands implemented (89.3%).**
| Tab | Implemented | Total | Parity |
|---|---|---|---|
@ -14,7 +14,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
| Draw | 4 | 4 | 100% |
| Page Layout | 23 | 23 | 100% |
| Formulas | 23 | 23 | 100% |
| Data | 20 | 28 | 71% |
| Data | 21 | 28 | 75% |
| Review | 9 | 18 | 50% |
| View | 18 | 20 | 90% |
| Automate | 3 | 3 | 100% |
@ -240,7 +240,7 @@ Implemented:
- `formulas.calculateNow` Calculate Now (Calculation)
- `formulas.calculateSheet` Calculate Sheet (Calculation)
## Data (20 / 28, 71%)
## Data (21 / 28, 75%)
Implemented:
@ -264,6 +264,7 @@ Implemented:
- `data.subtotal` Subtotal (Outline)
- `data.showDetail` Show Detail (Outline)
- `data.hideDetail` Hide Detail (Outline)
- `data.solver` Solver (Analysis)
Missing:
@ -273,7 +274,6 @@ Missing:
- `data.relationships` Relationships (Data Tools)
- `data.forecastSheet` Forecast Sheet (Forecast)
- `data.analyzeData` Analyze Data (Analysis)
- `data.solver` Solver (Analysis)
- `data.analysisToolPak` Data Analysis (Analysis)
## Review (9 / 18, 50%)

View File

@ -87,7 +87,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel |
| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) |
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data |
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Solver (Simplex LP and a local nonlinear method; no Evolutionary or reports). Missing: Analysis ToolPak, Forecast Sheet, Analyze Data |
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection. Missing: comment navigation, Show Changes, Allow Edit Ranges, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) |

View File

@ -65,7 +65,7 @@ shortcut lists for Windows and Mac, then fill the gaps: 6–10 h.
Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort,
Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal
Seek, Scenario Manager and others exist. Missing or partial: Options, Solver, Data Analysis,
Seek, Scenario Manager, Solver and others exist. Missing or partial: Options, Data Analysis,
Forecast Sheet, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges,
Workbook Links, Accessibility checker.