mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 21:56:53 -06:00
Merge pull request #327 from vangel76/feat/solver
Data: Solver with Simplex LP, integer branch and bound and a nonlinear method (#326) # Conflicts: # crates/ui-egui/src/dialogs.rs # crates/ui-egui/src/i18n.rs # crates/ui-egui/src/i18n/ko.rs # crates/ui-egui/src/i18n/pt.rs # crates/ui-egui/src/i18n/ru.rs # crates/ui-egui/src/i18n/zh.rs # crates/ui-egui/src/ribbon.rs # crates/ui-egui/tests/interaction.rs # docs/gaps.md # docs/parity-checklist.md # docs/target-app-parity.md # docs/ui-parity.md
This commit is contained in:
@@ -15,6 +15,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;
|
||||
@@ -134,6 +135,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(analysis::specs());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -376,6 +376,23 @@ impl Dialog {
|
||||
d.command = Some("data.analysisToolPak");
|
||||
d
|
||||
}
|
||||
"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",
|
||||
@@ -790,6 +807,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
|
||||
"recommendedCharts" => 600.0,
|
||||
"analysisToolPak" => 420.0,
|
||||
"solver" => 460.0,
|
||||
_ => 380.0,
|
||||
};
|
||||
let window = egui::Window::new(d.title.clone())
|
||||
@@ -815,6 +833,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
|
||||
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
|
||||
"analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"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:");
|
||||
@@ -1012,7 +1032,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);
|
||||
}
|
||||
}
|
||||
// Data Analysis asks before replacing data in its output range.
|
||||
Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => {
|
||||
d.values.insert("askOverwrite".into(), json!(true));
|
||||
@@ -2127,6 +2152,144 @@ fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confi
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
||||
@@ -327,6 +327,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Subtotal", "小計"),
|
||||
("Data\nAnalysis", "データ分析"),
|
||||
("Data Analysis", "データ分析"),
|
||||
("Solver", "ソルバー"),
|
||||
("What-If\nAnalysis", "What-If 分析"),
|
||||
("Data Table…", "データ テーブル…"),
|
||||
("Scenario Manager…", "シナリオ マネージャー…"),
|
||||
|
||||
@@ -170,6 +170,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Subtotal" => "부분합",
|
||||
"Data\nAnalysis" => "데이터 분석",
|
||||
"Data Analysis" => "데이터 분석",
|
||||
"Solver" => "해 찾기",
|
||||
"What-If\nAnalysis" => "가상 분석",
|
||||
"Data Table…" => "데이터 표…",
|
||||
"Scenario Manager…" => "시나리오 관리자…",
|
||||
|
||||
@@ -171,6 +171,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Subtotal" => "Subtotal",
|
||||
"Data\nAnalysis" => "Análise de Dados",
|
||||
"Data Analysis" => "Análise de Dados",
|
||||
"Solver" => "Solver",
|
||||
"What-If\nAnalysis" => "Análise de\nHipóteses",
|
||||
"Data Table…" => "Tabela de Dados…",
|
||||
"Scenario Manager…" => "Gerenciador de Cenários…",
|
||||
|
||||
@@ -170,6 +170,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Subtotal" => "Промежуточный итог",
|
||||
"Data\nAnalysis" => "Анализ данных",
|
||||
"Data Analysis" => "Анализ данных",
|
||||
"Solver" => "Поиск решения",
|
||||
"What-If\nAnalysis" => "Анализ «что\nесли»",
|
||||
"Data Table…" => "Таблица данных…",
|
||||
"Scenario Manager…" => "Диспетчер сценариев…",
|
||||
|
||||
@@ -170,6 +170,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Subtotal" => "分类汇总",
|
||||
"Data\nAnalysis" => "数据分析",
|
||||
"Data Analysis" => "数据分析",
|
||||
"Solver" => "规划求解",
|
||||
"What-If\nAnalysis" => "假设分析",
|
||||
"Data Table…" => "数据表…",
|
||||
"Scenario Manager…" => "方案管理器…",
|
||||
|
||||
@@ -1648,6 +1648,10 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() {
|
||||
app.open_dialog("analysisToolPak", 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) {
|
||||
|
||||
@@ -378,6 +378,7 @@ fn dialogs_open_and_close() {
|
||||
"pivotCalculatedField",
|
||||
"about",
|
||||
"analysisToolPak",
|
||||
"solver",
|
||||
] {
|
||||
h.state_mut().open_dialog(name, json!({}));
|
||||
h.run_steps(2);
|
||||
@@ -420,6 +421,37 @@ fn data_analysis_dialog_runs_a_tool_and_asks_before_overwriting() {
|
||||
assert!(h.state().message.is_none());
|
||||
}
|
||||
|
||||
#[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());
|
||||
|
||||
@@ -265,6 +265,7 @@ Implemented:
|
||||
- `data.showDetail` Show Detail (Outline)
|
||||
- `data.hideDetail` Hide Detail (Outline)
|
||||
- `data.analysisToolPak` Data Analysis (Analysis)
|
||||
- `data.solver` Solver (Analysis)
|
||||
|
||||
Missing:
|
||||
|
||||
@@ -275,6 +276,7 @@ Missing:
|
||||
- `data.forecastSheet` Forecast Sheet (Forecast)
|
||||
- `data.analyzeData` Analyze Data (Analysis)
|
||||
- `data.solver` Solver (Analysis)
|
||||
- `data.analysisToolPak` Data Analysis (Analysis)
|
||||
|
||||
## Review (10 / 18, 56%)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user