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:
parent
9f896c1249
commit
dc75b2cf5e
|
|
@ -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
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -313,6 +313,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Group", "グループ化"),
|
||||
("Ungroup", "グループ解除"),
|
||||
("Subtotal", "小計"),
|
||||
("Solver", "ソルバー"),
|
||||
("What-If\nAnalysis", "What-If 分析"),
|
||||
("Data Table…", "データ テーブル…"),
|
||||
("Scenario Manager…", "シナリオ マネージャー…"),
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Group" => "그룹",
|
||||
"Ungroup" => "그룹 해제",
|
||||
"Subtotal" => "부분합",
|
||||
"Solver" => "해 찾기",
|
||||
"What-If\nAnalysis" => "가상 분석",
|
||||
"Data Table…" => "데이터 표…",
|
||||
"Scenario Manager…" => "시나리오 관리자…",
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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…" => "Диспетчер сценариев…",
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Group" => "组合",
|
||||
"Ungroup" => "取消组合",
|
||||
"Subtotal" => "分类汇总",
|
||||
"Solver" => "规划求解",
|
||||
"What-If\nAnalysis" => "假设分析",
|
||||
"Data Table…" => "数据表…",
|
||||
"Scenario Manager…" => "方案管理器…",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%)
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue