Files
gridcraft/crates/ui-egui/src/dialogs/analysis.rs
T
Pranav 4e43ad8868 UI: RefEdit follow-ups: a dialog keeps the sheet it was opened on, a click on the selected cell picks, quoted sheet names in Solver (#3180)
* UI: a dialog's reference boxes belong to the sheet it was opened on, recorded when it opens rather than at the first pick

* UI: a click on the cell already selected writes it into the reference box being picked into

* Solver: references split into areas only at commas outside quoted sheet names, through one splitter shared with the reference boxes

* Solver: a constraint splits at its relation only outside quoted sheet names, in constraint text and in a model loaded from cells

* Solver: Save Model counts a constraint's areas outside quoted sheet names, and reads a number on the right side as one value whatever its separators

* UI: a Ctrl+click that follows a link is no pick of the cell already selected

The press on the grid tells the reference box being picked into that a
cell was selected only in the branches that select: Shift+click, Ctrl+click
that adds a cell, and a plain click. A Ctrl+click on a link's text follows
the link and selects nothing, so a link back to the selected cell no longer
replaces the box's text.

* UI: while a reference box picks, a second quick click on a cell picks it

A click on the same cell within the double-click interval started editing
the cell and skipped the pick, also when the earlier click came before the
box was clicked into. While a reference box is the one grid selections
fill, the double-click handling is skipped and the click picks.

* Docs: reference boxes take a second quick click as a pick; a followed link is none

* UI: while a reference box picks, a click on a link or checkbox picks its cell

A link was followed (Go To moved the selection into the box, and a web
link's question replaced the dialog) and a checkbox was toggled. While a
reference box is the one grid selections fill, a click only picks the cell
clicked, with or without Ctrl. The test of the Ctrl+click on a link back to
the selected cell becomes part of a test of links on the same sheet, a web
link and a checkbox.
2026-11-05 13:20:51 +01:00

742 lines
35 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.
use super::ref_edit::{RefLabel, SHEET_DELETED, current_area_ref, end_pick, home_sheet_deleted, ref_edit};
use super::show::{build_params, form_fields, ok_cancel};
use super::{Dialog, Field, Json, Map, SheetApp, json, vec2};
/// Data › What-If Analysis › Goal Seek. OK runs `data.goalSeek` here; Cancel clears `open`, as
/// the shared buttons do, so cancelling is never taken for a confirmation.
pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
egui::Grid::new("gs").show(ui, |ui| {
let l = ui.label("Set cell:");
ref_edit(app, ui, d, "set", RefLabel::By(l.id), 200.0);
ui.end_row();
let l = ui.label("To value:");
let mut to = d.values.get("to").and_then(Json::as_str).unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut to).desired_width(170.0)).labelled_by(l.id).changed() {
d.values.insert("to".into(), json!(to));
}
ui.end_row();
let l = ui.label("By changing cell:");
ref_edit(app, ui, d, "changing", RefLabel::By(l.id), 200.0);
ui.end_row();
});
let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
let (set, to, changing) = (get("set"), get("to"), get("changing"));
ok_cancel(ui, confirm, open);
if *confirm && home_sheet_deleted(app, d) {
app.message = Some((d.title.clone(), SHEET_DELETED.into()));
*confirm = false;
}
if *confirm && let Err(e) = end_pick(app, d) {
// The dialog's sheet can't be shown again (a draft on the picked sheet could not be
// saved): the cells would be read against the wrong sheet, so Goal Seek doesn't run.
app.message = Some((d.title.clone(), crate::clean_error(&e)));
*confirm = false;
}
if *confirm {
match app.run("data.goalSeek", json!({"set": set, "to": to.parse::<f64>().unwrap_or(0.0), "changing": changing})) {
Ok(r) => app.toast = Some((format!("Goal Seeking found a solution: {}", r["value"]), crate::now_ms())),
Err(e) => app.message = Some(("Goal Seek".into(), crate::clean_error(&e))),
}
}
}
/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart).
fn toolpak_fields(tool: &str, values: &Map<String, Json>) -> Vec<Field> {
use Field::*;
let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] };
let labels = || Check { key: "labels", label: "Labels in first row" };
let range = || Ref { key: "range", label: "Input Range:" };
let two = || vec![Ref { key: "range1", label: "Variable 1 Range:" }, Ref { key: "range2", label: "Variable 2 Range:" }];
match tool {
"descriptive" => vec![
range(),
grouped(),
labels(),
Check { key: "summary", label: "Summary statistics" },
Text { key: "confidence", label: "Confidence Level for Mean (%), blank for none:" },
Text { key: "kthLargest", label: "Kth Largest (blank for none):" },
Text { key: "kthSmallest", label: "Kth Smallest (blank for none):" },
],
"histogram" => vec![
range(),
Ref { key: "bins", label: "Bin Range (blank for automatic bins):" },
labels(),
Check { key: "pareto", label: "Pareto (sorted histogram)" },
Check { key: "cumulative", label: "Cumulative Percentage" },
],
"correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()],
"anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }],
// With Labels ticked this tool reads row names from the first column as well as column
// names from the first row, so the caption says both.
"anovaTwoFactorWithout" => {
vec![range(), Check { key: "labels", label: "Labels in first row and first column" }, Text { key: "alpha", label: "Alpha:" }]
}
"anovaTwoFactorWith" => vec![
Note("The input range holds column labels in its first row and sample labels in its first column.".into()),
range(),
Text { key: "rowsPerSample", label: "Rows per sample:" },
Text { key: "alpha", label: "Alpha:" },
],
"regression" => vec![
Ref { key: "inputY", label: "Input Y Range:" },
Ref { key: "inputX", label: "Input X Range:" },
labels(),
Check { key: "constantZero", label: "Constant is Zero" },
Text { key: "confidence", label: "Confidence Level (%):" },
Check { key: "residuals", label: "Residuals" },
Check { key: "standardizedResiduals", label: "Standardized Residuals" },
Check { key: "normalProbability", label: "Normal Probability" },
],
"movingAverage" => {
vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }]
}
"randomNumberGeneration" => {
let dist = values.get("distribution").and_then(Json::as_str).unwrap_or("uniform");
let mut v = vec![Text { key: "variables", label: "Number of Variables:" }];
// A patterned sequence sets its own length, so it has no count to type.
if dist != "patterned" {
v.push(Text { key: "count", label: "Number of Random Numbers:" });
}
v.push(Choice {
key: "distribution",
label: "Distribution:",
options: vec![
("uniform", "Uniform"),
("normal", "Normal"),
("bernoulli", "Bernoulli"),
("binomial", "Binomial"),
("poisson", "Poisson"),
("patterned", "Patterned"),
("discrete", "Discrete"),
],
});
v.extend(match dist {
"normal" => vec![Text { key: "mean", label: "Mean:" }, Text { key: "standardDeviation", label: "Standard deviation:" }],
"bernoulli" => vec![Text { key: "p", label: "p Value:" }],
"binomial" => vec![Text { key: "p", label: "p Value:" }, Text { key: "trials", label: "Number of trials:" }],
"poisson" => vec![Text { key: "lambda", label: "Lambda:" }],
"patterned" => vec![
Note("Each number is repeated, then the whole sequence; together they set how many values are written.".into()),
Text { key: "from", label: "From:" },
Text { key: "to", label: "To:" },
Text { key: "step", label: "In steps of:" },
Text { key: "repeatEach", label: "Repeating each number (times):" },
Text { key: "repeatSequence", label: "Repeating each sequence (times):" },
],
"discrete" => vec![Text { key: "valueRange", label: "Value and Probability Input Range:" }],
_ => vec![Text { key: "from", label: "Between:" }, Text { key: "to", label: "And:" }],
});
// A patterned sequence draws nothing, so it has no seed.
if dist != "patterned" {
v.push(Text { key: "seed", label: "Random Seed (blank for a new sequence):" });
}
v
}
"sampling" => {
let mut v = vec![
range(),
labels(),
Choice { key: "method", label: "Sampling Method:", options: vec![("periodic", "Periodic"), ("random", "Random")] },
];
// Periodic sampling takes every period-th value and draws nothing, so only random
// sampling has a seed.
if values.get("method").and_then(Json::as_str) == Some("random") {
v.push(Text { key: "samples", label: "Number of Samples:" });
v.push(Text { key: "seed", label: "Random Seed (blank for a new sequence):" });
} else {
v.push(Text { key: "period", label: "Period:" });
}
v
}
"fourierAnalysis" => vec![range(), labels(), Check { key: "inverse", label: "Inverse" }],
"exponentialSmoothing" => {
vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }]
}
"fTest" => {
let mut v = two();
v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]);
v
}
"zTest" => {
let mut v = two();
v.extend([
Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" },
Text { key: "variance1", label: "Variable 1 Variance (known):" },
Text { key: "variance2", label: "Variable 2 Variance (known):" },
labels(),
Text { key: "alpha", label: "Alpha:" },
]);
v
}
_ => {
let mut v = two();
v.extend([
Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" },
labels(),
Text { key: "alpha", label: "Alpha:" },
]);
v
}
}
}
/// The starting values of a tool's form: the selection as its input, a new sheet as output.
pub(super) fn toolpak_defaults(sel: &str, tool: &str) -> Map<String, Json> {
let mut v = json!({
"tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "",
"groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "",
"interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0",
"rowsPerSample": "", "variance1": "", "variance2": "", "inverse": false,
"outputTo": "newSheet", "output": "", "newSheet": "",
});
// Random Number Generation and Sampling.
for (k, x) in [
("variables", "1"),
("count", ""),
("distribution", "uniform"),
("from", "0"),
("to", "1"),
("mean", "0"),
("standardDeviation", "1"),
("p", "0.5"),
("trials", "10"),
("lambda", "1"),
("step", "1"),
("repeatEach", "1"),
("repeatSequence", "1"),
("valueRange", ""),
("seed", ""),
("method", "periodic"),
("period", ""),
("samples", ""),
] {
v[k] = json!(x);
}
if tool == "regression" {
v["confidence"] = json!("95");
v["inputY"] = json!(sel);
}
v.as_object().cloned().unwrap_or_default()
}
/// Height a ToolPak form needs besides the tool's own options: title bar, output options, OK
/// and the window's margins.
const TOOLPAK_CHROME: f32 = 240.0;
/// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak`
/// (through the dialog's command); a report that would replace data asks first.
pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
use gridcraft_engine::cmd::analysis::TOOLS;
if d.values.get("askOverwrite").and_then(Json::as_bool) == Some(true) {
ui.label("The output range already holds data. Replace it with the report?");
ui.horizontal(|ui| {
if ui.button("Replace").clicked() {
d.values.remove("askOverwrite");
d.values.insert("overwrite".into(), json!(true));
*confirm = true;
}
if ui.button("Cancel").clicked() {
d.values.remove("askOverwrite");
}
});
return;
}
let tool = d.values.get("tool").and_then(Json::as_str).unwrap_or("").to_string();
let Some((_, label)) = TOOLS.iter().find(|(id, _)| *id == tool) else {
ui.label(egui::RichText::new("Analysis Tools").strong());
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
let mut chosen = false;
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for (id, label) in TOOLS {
let r = ui.selectable_label(pick == *id, *label);
if r.clicked() {
d.values.insert("pick".into(), json!(id));
}
if r.double_clicked() {
chosen = true;
}
}
});
let mut ok = false;
ok_cancel(ui, &mut ok, open);
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) {
// The selection becomes the input range, with its sheet's name when another sheet
// than the dialog's own is shown.
let sel = current_area_ref(app, d);
d.values = toolpak_defaults(&sel, &pick);
}
return;
};
d.title = label.to_string();
d.fields = toolpak_fields(&tool, &d.values);
// The tool's options scroll in a short window (the patterned Random Number Generation form
// is taller than 600 points); the output options and OK below always stay in view.
let room = (ui.ctx().content_rect().height() - TOOLPAK_CHROME).max(120.0);
egui::ScrollArea::vertical().id_salt("toolpakForm").max_height(room).show(ui, |ui| form_fields(app, ui, d));
ui.separator();
ui.label(egui::RichText::new("Output options").strong());
let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string();
for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] {
ui.horizontal(|ui| {
let r = ui.radio(to == choice, text_label);
if r.clicked() {
d.values.insert("outputTo".into(), json!(choice));
// Another output option ends picking into Output Range, so a later selection on
// the grid doesn't choose it again.
if key != "output" && d.refs.target.as_deref() == Some("output") {
d.refs.target = None;
}
}
if key == "output" {
let before = d.values.get(key).cloned();
let was_target = d.refs.target.as_deref() == Some(key);
ref_edit(app, ui, d, key, RefLabel::By(r.id), 190.0);
// Clicking into the box, typing or picking an output range chooses it; only a
// change does, so an option chosen later stays chosen.
if d.refs.target.as_deref() == Some(key) && (!was_target || d.values.get(key) != before.as_ref()) {
d.values.insert("outputTo".into(), json!(choice));
}
} else {
let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() {
d.values.insert(key.into(), json!(t));
d.values.insert("outputTo".into(), json!(choice));
}
}
});
}
ok_cancel(ui, confirm, open);
}
/// The Solver Options fields: (key, label, default).
const SOLVER_OPTIONS: [(&str, &str, &str); 7] = [
("maxEvaluations", "Max Evaluations:", "20000"),
("integerOptimality", "Integer Optimality (%):", "0"),
("convergence", "Convergence:", "0.0001"),
("mutationRate", "Mutation Rate:", "0.075"),
("populationSize", "Population Size:", "100"),
("randomSeed", "Random Seed:", "0"),
("generationsWithoutImprovement", "Maximum Generations without Improvement:", "50"),
];
/// Data › Solver › Options: the Solver Options in the Solver Parameters window. OK keeps the
/// changes for the next solve; Cancel restores them.
fn solver_options(ui: &mut egui::Ui, d: &mut Dialog, edit: impl Fn(&mut egui::Ui, &mut Dialog, &str, f32, &str)) {
let mut done = false;
egui::Grid::new("solverOptions").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
for (title, keys) in [("All Methods", &SOLVER_OPTIONS[..2]), ("Evolutionary", &SOLVER_OPTIONS[2..])] {
ui.label(egui::RichText::new(title).strong());
ui.end_row();
for (key, label, _) in keys {
ui.label(*label);
edit(ui, d, key, 100.0, label);
ui.end_row();
}
}
});
let mut bounds = d.values.get("requireBounds").and_then(Json::as_bool).unwrap_or(true);
if ui.checkbox(&mut bounds, "Require Bounds on Variables").changed() {
d.values.insert("requireBounds".into(), json!(bounds));
}
ui.add_space(8.0);
// A row as tall as the buttons (see `ok_cancel`): a right-to-left layout straight in the
// window takes all the height it is offered.
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(" Cancel ").clicked() {
cancel_solver_options(d);
}
if ui.button(" OK ").clicked() {
done = true;
}
});
});
if done {
close_solver_options(d);
}
}
/// Whether the Solver dialog shows its Solver Options.
pub(super) fn in_solver_options(d: &Dialog) -> bool {
d.name == "solver" && d.values.get("optionsView").and_then(Json::as_bool) == Some(true)
}
/// Solver Options › Cancel (also Escape or closing the window there): back to the parameters
/// with the options as they were.
pub(super) fn cancel_solver_options(d: &mut Dialog) {
if let Some(old) = d.values.get("optionsBackup").and_then(Json::as_object).cloned() {
for (k, v) in old {
d.values.insert(k, v);
}
}
close_solver_options(d);
}
/// Solver Parameters › Options (also opening the dialog with `optionsView`): shows the Solver
/// Options, each field with its value or its default, and keeps the values for Cancel.
pub(super) fn open_solver_options(d: &mut Dialog) {
let mut backup = serde_json::Map::new();
for (key, _, default) in SOLVER_OPTIONS {
let v = d.values.get(key).cloned().unwrap_or_else(|| json!(default));
backup.insert(key.into(), v.clone());
d.values.insert(key.into(), v);
}
backup.insert("requireBounds".into(), d.values.get("requireBounds").cloned().unwrap_or(json!(true)));
d.values.insert("optionsBackup".into(), Json::Object(backup));
d.values.insert("optionsView".into(), json!(true));
d.title = "Solver Options".into();
}
fn close_solver_options(d: &mut Dialog) {
d.values.insert("optionsView".into(), json!(false));
d.values.remove("optionsBackup");
d.title = "Solver Parameters".into();
}
/// Removes the model's options (the Solver Options fields and Require Bounds) from the form. A
/// key that is absent means the option's default, so Reset All and Load Model remove them all
/// before a model of their own; an option of the model before would otherwise go with the next
/// solve.
fn clear_solver_options(d: &mut Dialog) {
for (key, _, _) in SOLVER_OPTIONS {
d.values.remove(key);
}
d.values.remove("requireBounds");
}
/// Before one of Solver's commands other than Solve runs from the form (Reset All, Load, Save,
/// Replace): the dialog's sheet must be there and shown again, as `dialogs::show` requires for
/// Solve, since the commands act on the active sheet and read references without a sheet name
/// against it. Shows the reason and answers `false` when the command must not run: the sheet
/// was deleted, or it cannot be shown again (a draft on the picked sheet could not be saved).
fn at_home(app: &mut SheetApp, d: &mut Dialog) -> bool {
if home_sheet_deleted(app, d) {
app.message = Some((d.title.clone(), SHEET_DELETED.into()));
return false;
}
if let Err(e) = end_pick(app, d) {
app.message = Some((d.title.clone(), crate::clean_error(&e)));
return false;
}
true
}
/// Save Model from the Solver Parameters form: writes the form's model down from `range`. A
/// range that holds data is replaced only with `overwrite`; without it the form asks first.
fn save_solver_model(app: &mut SheetApp, d: &mut Dialog, range: &str, overwrite: bool) {
d.values.remove("askModelOverwrite");
let note = match app.run("data.solverSaveModel", json!({"to": range.trim(), "model": build_params(d), "overwrite": overwrite})) {
Ok(r) => format!("The model was saved to {}.", r.get("range").and_then(Json::as_str).unwrap_or("")),
Err(e) if !overwrite && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => {
d.values.insert("askModelOverwrite".into(), json!(range.trim()));
crate::clean_error(&e)
}
Err(e) => crate::clean_error(&e),
};
d.values.insert("modelNote".into(), json!(note));
}
/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's
/// command; the result opens Solver Results. Reset All clears the form and the sheet's model;
/// Load/Save reads or writes the model on cells of the sheet; Options shows the Solver Options
/// in the same window.
pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let lang = crate::i18n::current(ui.ctx());
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, label: &str| {
let mut t = text(d, k);
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() {
d.values.insert(k.into(), json!(t));
}
};
if d.values.get("optionsView").and_then(Json::as_bool) == Some(true) {
solver_options(ui, d, edit);
return;
}
ui.horizontal(|ui| {
let l = ui.label("Set Objective:");
ref_edit(app, ui, d, "objective", RefLabel::By(l.id), 230.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, "Value Of:");
});
let l = ui.label("By Changing Variable Cells:");
ref_edit(app, ui, d, "variables", RefLabel::By(l.id), 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| {
ref_edit(app, ui, d, "newLhs", RefLabel::Named("Constraint Cell Reference"), 130.0);
let op = text(d, "newOp");
craft_ui::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));
}
}
})
.response
.accessible_name(lang.tr("Constraint Operator"));
if !matches!(op.as_str(), "int" | "bin") {
ref_edit(app, ui, d, "newRhs", RefLabel::Named("Constraint Value"), 130.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| match m {
"simplex" => "Simplex LP",
"evolutionary" => "Evolutionary",
_ => "GRG Nonlinear",
};
craft_ui::ComboBox::from_id_salt("solverMethod")
.selected_text(label(&method))
.show_ui(ui, |ui| {
for m in ["grg", "simplex", "evolutionary"] {
if ui.selectable_label(method == m, label(m)).clicked() {
d.values.insert("method".into(), json!(m));
}
}
})
.response
.accessible_name(lang.tr("Select a Solving Method:"));
});
ui.label(
egui::RichText::new(match method.as_str() {
"simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.",
"evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.",
_ => "GRG Nonlinear: a local optimum of a smooth model, from the current values.",
})
.small(),
);
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.button("Reset All").clicked() && at_home(app, d) {
match app.run("data.solverReset", json!({})) {
Ok(_) => {
for (k, v) in [
("objective", json!("")),
("goal", json!("max")),
("value", json!("0")),
("variables", json!("")),
("constraints", json!([])),
("nonNegative", json!(true)),
("method", json!("grg")),
] {
d.values.insert(k.into(), v);
}
// The options go back to their defaults too.
clear_solver_options(d);
for key in ["optionsBackup", "modelNote", "askModelOverwrite"] {
d.values.remove(key);
}
}
Err(e) => {
d.values.insert("modelNote".into(), json!(crate::clean_error(&e)));
}
}
}
let shown = d.values.get("modelArea").and_then(Json::as_bool).unwrap_or(false);
// Hiding the area ends picking into Model Range, as a box that is not drawn is no longer
// picked into (`ref_edit::window_shown`).
if ui.selectable_label(shown, "Load/Save").clicked() {
d.values.insert("modelArea".into(), json!(!shown));
}
if ui.button("Options").clicked() {
open_solver_options(d);
}
});
if d.values.get("modelArea").and_then(Json::as_bool) == Some(true) {
ui.horizontal(|ui| {
let l = ui.label("Model Range:");
ref_edit(app, ui, d, "modelRange", RefLabel::By(l.id), 170.0);
let range = text(d, "modelRange");
let ready = !range.trim().is_empty();
if ui.add_enabled(ready, egui::Button::new("Load")).clicked() && at_home(app, d) {
d.values.remove("askModelOverwrite");
match app.run("data.solverLoadModel", json!({"from": range.trim()})) {
Ok(r) => {
if let Some(m) = r.get("model").and_then(Json::as_object) {
// The loaded model's options only: one it omits takes its default.
clear_solver_options(d);
for (k, x) in m {
d.values.insert(k.clone(), if k == "value" { json!(x.to_string()) } else { x.clone() });
}
}
d.values.insert("modelNote".into(), json!("The model was loaded."));
}
Err(e) => {
d.values.insert("modelNote".into(), json!(crate::clean_error(&e)));
}
}
}
if ui.add_enabled(ready, egui::Button::new("Save")).clicked() && at_home(app, d) {
save_solver_model(app, d, &range, false);
}
});
// Save Model asks before it replaces data in the model range; the question goes when
// the range is edited, so Replace writes only where it asked about.
let asked = d.values.get("askModelOverwrite").and_then(Json::as_str).map(str::to_string);
if asked.as_deref().is_some_and(|a| a != text(d, "modelRange").trim()) {
d.values.remove("askModelOverwrite");
d.values.remove("modelNote");
} else if let Some(asked) = asked {
ui.horizontal_wrapped(|ui| {
ui.label(format!("{asked} already holds data. Replace it with the model?"));
if ui.button("Replace").clicked() && at_home(app, d) {
save_solver_model(app, d, &asked, true);
}
if ui.button("Don't Replace").clicked() {
d.values.remove("askModelOverwrite");
d.values.remove("modelNote");
}
});
}
}
let note = text(d, "modelNote");
if !note.is_empty() {
ui.label(egui::RichText::new(note).small());
}
ui.add_space(8.0);
// A row as tall as the buttons (see `ok_cancel`): the Load/Save area and the notes can make
// the form shorter, and a right-to-left layout straight in the window would fill what is
// left.
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let t = crate::theme::Tokens::get(ui.ctx());
let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.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.
pub(super) 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));
}
}
// Reports describe the solution whether it is kept or not.
let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty());
// Integer models, the Evolutionary method and Value Of goals have no Sensitivity report, and
// it describes an optimum, so a run that stopped before one (at the evaluation limit) has
// none either.
let model_allows = d.values.get("model").is_none_or(gridcraft_engine::cmd::solver::sensitivity_applies);
let optimum = d.values.get("status").and_then(Json::as_str).is_some_and(gridcraft_engine::cmd::solver::sensitivity_status);
let sensitivity = model_allows && optimum;
let why_not = if model_allows {
"Needs an optimal or converged solution: Solver stopped before reaching one."
} else {
"Needs Simplex LP or GRG Nonlinear, a Max or Min objective and no integer constraints."
};
let offered = |key: &str| key != "sensitivityReport" || sensitivity;
if solved {
ui.add_space(4.0);
ui.label("Reports:");
for (key, label) in [("answerReport", "Answer"), ("sensitivityReport", "Sensitivity"), ("limitsReport", "Limits")] {
let mut on = offered(key) && d.values.get(key).and_then(Json::as_bool).unwrap_or(false);
let check = ui.add_enabled(offered(key), egui::Checkbox::new(&mut on, label)).on_disabled_hover_text(why_not);
if check.changed() {
d.values.insert(key.into(), json!(on));
}
}
}
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!({}));
}
let reports: Vec<&str> = [("answerReport", "answer"), ("sensitivityReport", "sensitivity"), ("limitsReport", "limits")]
.into_iter()
.filter(|(key, _)| offered(key) && d.values.get(*key).and_then(Json::as_bool) == Some(true))
.map(|(_, kind)| kind)
.collect();
if solved && !reports.is_empty() {
let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null);
// A Sensitivity report the values turn out not to support is left out, and the other
// reports are still written.
let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"),
"message": field("message"), "skipNotOptimal": true});
match app.run("data.solverReport", params) {
Ok(r) => {
let reasons: Vec<&str> = r
.get("skippedReports")
.and_then(Json::as_array)
.into_iter()
.flatten()
.filter_map(|k| k.get("reason").and_then(Json::as_str))
.collect();
if !reasons.is_empty() {
app.message = Some(("Solver Results".into(), reasons.join("\n")));
}
}
Err(e) => app.message = Some(("Solver Results".into(), crate::clean_error(&e))),
}
}
*open = false;
}
}