mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Calc: iterative calculation for circular references (Maximum Iterations, Maximum Change)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4dca02c89b
commit
fdb74c27dd
+154
-45
@@ -6,7 +6,9 @@
|
||||
//! O(log n + matches) however many ranges share the column. After an edit, the dirty set is the
|
||||
//! transitive closure of dependents; it is evaluated in topological order, with on-demand
|
||||
//! evaluation as a safety net for dynamic references. Cycles produce `#CIRC!` unless iterative
|
||||
//! calculation is on.
|
||||
//! calculation is on: then a formula read while it is being evaluated gives its previous value,
|
||||
//! and the formulas that read one are evaluated again until the loop settles
|
||||
//! (`PassHost::iterate_loops`).
|
||||
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
@@ -58,6 +60,10 @@ const WIDE: u32 = 64;
|
||||
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
|
||||
const PENDING: usize = 32;
|
||||
|
||||
/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas
|
||||
/// evaluated again), however high Maximum Iterations is.
|
||||
const MAX_ITERATION_WORK: usize = 10_000_000;
|
||||
|
||||
/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows
|
||||
/// `a..=b`?" in O(log n + k).
|
||||
///
|
||||
@@ -389,6 +395,44 @@ struct PassHost<'a> {
|
||||
cycle: bool,
|
||||
cycles: Vec<Key>,
|
||||
depth: usize,
|
||||
/// Iterative calculation: a circular reference reads the cell's previous value instead of
|
||||
/// giving `#CIRC!`.
|
||||
iterate: bool,
|
||||
/// Values from the previous iteration; cells not in it read their stored value.
|
||||
prev: HashMap<Key, Value>,
|
||||
/// Formulas being evaluated, innermost last (kept only when iterating).
|
||||
stack: Vec<Key>,
|
||||
/// Formulas found on a circular reference (when iterating).
|
||||
members: HashSet<Key>,
|
||||
/// Results that read a previous value, directly or through other formulas.
|
||||
tainted: HashSet<Key>,
|
||||
/// Whether the formula being evaluated has read a previous value.
|
||||
taint: bool,
|
||||
}
|
||||
|
||||
impl<'a> PassHost<'a> {
|
||||
fn new(wb: &'a Workbook, exprs: &'a HashMap<Key, std::sync::Arc<Expr>>, rng: &'a mut u64, now: f64) -> Self {
|
||||
PassHost {
|
||||
wb,
|
||||
exprs,
|
||||
results: HashMap::with_capacity_and_hasher(exprs.len(), Default::default()),
|
||||
pending: exprs.keys().copied().collect(),
|
||||
in_progress: HashSet::default(),
|
||||
spills: HashMap::default(),
|
||||
used: HashMap::default(),
|
||||
rng,
|
||||
now,
|
||||
cycle: false,
|
||||
cycles: vec![],
|
||||
depth: 0,
|
||||
iterate: false,
|
||||
prev: HashMap::default(),
|
||||
stack: Vec::new(),
|
||||
members: HashSet::default(),
|
||||
tainted: HashSet::default(),
|
||||
taint: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
|
||||
@@ -415,18 +459,29 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
|
||||
impl PassHost<'_> {
|
||||
fn compute(&mut self, k: Key) -> Value {
|
||||
if let Some(v) = self.results.get(&k) {
|
||||
return v.clone();
|
||||
let v = v.clone();
|
||||
if self.iterate && self.tainted.contains(&k) {
|
||||
self.taint = true;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
let Some(expr) = self.exprs.get(&k) else {
|
||||
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
|
||||
};
|
||||
if self.in_progress.contains(&k) || self.depth > 2000 {
|
||||
self.cycle = true;
|
||||
if self.iterate {
|
||||
return self.circular_read(k);
|
||||
}
|
||||
self.cycles.push(k);
|
||||
return Value::Error(CellError::Circ);
|
||||
}
|
||||
self.in_progress.insert(k);
|
||||
self.depth += 1;
|
||||
let outer_taint = std::mem::replace(&mut self.taint, false);
|
||||
if self.iterate {
|
||||
self.stack.push(k);
|
||||
}
|
||||
let expr = std::sync::Arc::clone(expr);
|
||||
let v = {
|
||||
let mut ev = Evaluator::new(self, k.0, k.1);
|
||||
@@ -441,6 +496,15 @@ impl PassHost<'_> {
|
||||
self.depth -= 1;
|
||||
self.in_progress.remove(&k);
|
||||
self.pending.remove(&k);
|
||||
if self.iterate {
|
||||
self.stack.pop();
|
||||
if self.taint {
|
||||
self.tainted.insert(k);
|
||||
} else {
|
||||
self.tainted.remove(&k);
|
||||
}
|
||||
}
|
||||
self.taint |= outer_taint;
|
||||
// A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`,
|
||||
// FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells
|
||||
// of their own, which stay blank.
|
||||
@@ -472,6 +536,68 @@ impl PassHost<'_> {
|
||||
self.results.insert(k, v.clone());
|
||||
v
|
||||
}
|
||||
|
||||
/// A formula's previous value for iterative calculation: from the last iteration, else the
|
||||
/// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0.
|
||||
fn previous(&self, k: Key) -> Value {
|
||||
let v = match self.prev.get(&k) {
|
||||
Some(v) => v.clone(),
|
||||
None => self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(),
|
||||
};
|
||||
if matches!(v, Value::Empty | Value::Error(CellError::Circ)) { Value::Number(0.0) } else { v }
|
||||
}
|
||||
|
||||
/// Reading `k` while it is being evaluated, with iterative calculation on: its previous value.
|
||||
/// The formulas around the loop are the ones whose changes decide when iteration stops.
|
||||
fn circular_read(&mut self, k: Key) -> Value {
|
||||
self.taint = true;
|
||||
match self.stack.iter().rposition(|s| *s == k) {
|
||||
Some(i) => self.members.extend(self.stack.get(i..).unwrap_or_default().iter().copied()),
|
||||
// Too deep to follow: the formula itself is watched.
|
||||
None => {
|
||||
self.members.insert(k);
|
||||
}
|
||||
}
|
||||
self.previous(k)
|
||||
}
|
||||
|
||||
/// Iterative calculation, after a first pass over `order` that met circular references. The
|
||||
/// formulas that read a previous value (the loops and what depends on them) are evaluated
|
||||
/// again in the same order, each pass starting from the last one's values and reading what it
|
||||
/// has already produced (Gauss-Seidel), until no formula on a loop changes by `max_change` or
|
||||
/// more, or after `max_iterations` passes in all. Returns the number of passes.
|
||||
fn iterate_loops(&mut self, order: &[Key], max_iterations: u32, max_change: f64) -> u32 {
|
||||
let again: Vec<Key> = order.iter().copied().filter(|k| self.tainted.contains(k)).collect();
|
||||
let set: HashSet<Key> = again.iter().copied().collect();
|
||||
// Bounds the work whatever Maximum Iterations says.
|
||||
let max_passes = u32::try_from(MAX_ITERATION_WORK / again.len().max(1)).unwrap_or(u32::MAX).max(1);
|
||||
let mut passes = 1u32;
|
||||
while passes < max_iterations.min(max_passes) && !self.converged(max_change) {
|
||||
self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect();
|
||||
self.pending = set.clone();
|
||||
self.spills.retain(|a, _| !set.contains(a));
|
||||
for k in &again {
|
||||
self.compute(*k);
|
||||
}
|
||||
passes += 1;
|
||||
}
|
||||
passes
|
||||
}
|
||||
|
||||
/// Whether every formula on a loop changed by less than `max_change` in the last pass (text,
|
||||
/// logical and error values: not at all).
|
||||
fn converged(&self, max_change: f64) -> bool {
|
||||
self.members.iter().all(|k| {
|
||||
let Some(new) = self.results.get(k) else { return true };
|
||||
match (new, &self.previous(*k)) {
|
||||
(Value::Number(a), Value::Number(b)) => {
|
||||
let d = (a - b).abs();
|
||||
d < max_change || d == 0.0
|
||||
}
|
||||
(new, old) => new == old,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as
|
||||
@@ -532,6 +658,9 @@ impl Host for PassHost<'_> {
|
||||
if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col {
|
||||
let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize);
|
||||
if dr < arr.rows && dc < arr.cols {
|
||||
if self.iterate && self.tainted.contains(anchor) {
|
||||
self.taint = true;
|
||||
}
|
||||
return arr.get(dr, dc).cloned().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
@@ -590,6 +719,18 @@ impl Host for PassHost<'_> {
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
// Iterating: an array spilled by a formula that read a previous value.
|
||||
if self.iterate
|
||||
&& self.spills.iter().any(|(a, arr)| {
|
||||
let end = CellRef::new(
|
||||
a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)),
|
||||
a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)),
|
||||
);
|
||||
a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range)
|
||||
})
|
||||
{
|
||||
self.taint = true;
|
||||
}
|
||||
for (i, v) in new {
|
||||
if let Some(slot) = out.get_mut(i) {
|
||||
*slot = v;
|
||||
@@ -825,23 +966,17 @@ impl Calc {
|
||||
let mut rng = self.rng;
|
||||
let tp = prof_now();
|
||||
let (results, spills, cycles) = {
|
||||
let mut host = PassHost {
|
||||
wb,
|
||||
exprs: &exprs,
|
||||
results: HashMap::with_capacity_and_hasher(dirty.len(), Default::default()),
|
||||
pending: exprs.keys().copied().collect(),
|
||||
in_progress: HashSet::default(),
|
||||
spills: HashMap::default(),
|
||||
used: HashMap::default(),
|
||||
rng: &mut rng,
|
||||
now,
|
||||
cycle: false,
|
||||
cycles: vec![],
|
||||
depth: 0,
|
||||
};
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
|
||||
host.iterate = wb.calc.iterative;
|
||||
for k in &dirty {
|
||||
host.compute(*k);
|
||||
}
|
||||
if host.iterate && !host.members.is_empty() {
|
||||
let passes = host.iterate_loops(&dirty, wb.calc.max_iterations, wb.calc.max_change);
|
||||
if std::env::var_os("GRIDCRAFT_PROFILE").is_some() {
|
||||
eprintln!(" iterative calculation: {} passes over {} formulas", passes, host.tainted.len());
|
||||
}
|
||||
}
|
||||
(host.results, host.spills, host.cycles)
|
||||
};
|
||||
self.rng = rng;
|
||||
@@ -961,20 +1096,7 @@ pub fn evaluate(wb: &Workbook, sheet: usize, at: CellRef, formula: &str) -> Valu
|
||||
pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> Value {
|
||||
let exprs = HashMap::default();
|
||||
let mut rng = 0x1234_5678_9ABC_DEF0u64;
|
||||
let mut host = PassHost {
|
||||
wb,
|
||||
exprs: &exprs,
|
||||
results: HashMap::default(),
|
||||
pending: HashSet::default(),
|
||||
in_progress: HashSet::default(),
|
||||
spills: HashMap::default(),
|
||||
used: HashMap::default(),
|
||||
rng: &mut rng,
|
||||
now: now_serial(),
|
||||
cycle: false,
|
||||
cycles: vec![],
|
||||
depth: 0,
|
||||
};
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now_serial());
|
||||
let mut ev = Evaluator::new(&mut host, sheet, at);
|
||||
ev.value(expr)
|
||||
}
|
||||
@@ -998,7 +1120,7 @@ pub(crate) mod tests {
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, Workbook};
|
||||
|
||||
use super::{Calc, HashMap, HashSet, Host, Intervals, Key, PassHost};
|
||||
use super::{Calc, HashMap, Host, Intervals, Key, PassHost};
|
||||
|
||||
/// xorshift64 for repeatable pseudo-random cases.
|
||||
fn rng(state: &mut u64) -> u64 {
|
||||
@@ -1214,20 +1336,7 @@ pub(crate) mod tests {
|
||||
// of a real pass partway through; `visit_range` has to cope with the formulas it reads
|
||||
// being calculated under it.
|
||||
let mut rng = 1u64;
|
||||
let mut host = PassHost {
|
||||
wb,
|
||||
pending: exprs.keys().copied().collect(),
|
||||
exprs: &exprs,
|
||||
results: HashMap::default(),
|
||||
in_progress: HashSet::default(),
|
||||
spills: HashMap::default(),
|
||||
used: HashMap::default(),
|
||||
rng: &mut rng,
|
||||
now: 0.0,
|
||||
cycle: false,
|
||||
cycles: vec![],
|
||||
depth: 0,
|
||||
};
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, 0.0);
|
||||
|
||||
let mut seen = Vec::new();
|
||||
host.visit_range(0, range, &mut |v| {
|
||||
|
||||
@@ -120,6 +120,105 @@ fn errors_and_cycles() {
|
||||
assert_eq!(t.get("C2"), Value::Error(CellError::Name));
|
||||
}
|
||||
|
||||
impl T {
|
||||
fn iterative(max_iterations: u32, max_change: f64) -> T {
|
||||
let mut t = T::new();
|
||||
t.wb.calc.iterative = true;
|
||||
t.wb.calc.set_max_iterations(max_iterations);
|
||||
t.wb.calc.set_max_change(max_change);
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_calculation_stops_at_maximum_iterations() {
|
||||
// A1 =A1+1 never settles: every recalculation adds Maximum Iterations.
|
||||
let mut t = T::iterative(100, 0.001);
|
||||
t.set("A1", "=A1+1");
|
||||
assert_eq!(t.num("A1"), 100.0);
|
||||
assert!(t.calc.circular.is_empty());
|
||||
// An edit outside the loop doesn't recalculate it.
|
||||
t.set("B1", "7");
|
||||
assert_eq!(t.num("A1"), 100.0);
|
||||
// Each recalculation starts from the value the cell shows.
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), 200.0);
|
||||
t.wb.calc.set_max_iterations(5);
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), 205.0);
|
||||
// Through a range that holds the formula itself.
|
||||
t.set("C1", "1");
|
||||
t.set("C2", "2");
|
||||
t.set("C3", "=SUM(C1:C3)");
|
||||
assert_eq!(t.num("C3"), 15.0);
|
||||
// Excel's largest Maximum Iterations.
|
||||
let mut t = T::iterative(32767, 0.001);
|
||||
t.set("A1", "=A1+1");
|
||||
assert_eq!(t.num("A1"), 32767.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_calculation_converges_within_maximum_change() {
|
||||
let mut t = T::iterative(100, 0.001);
|
||||
t.set("A1", "=(A2+10)/2");
|
||||
t.set("A2", "=A1");
|
||||
assert!((t.num("A1") - 10.0).abs() < 0.002, "{}", t.num("A1"));
|
||||
assert!((t.num("A2") - 10.0).abs() < 0.002, "{}", t.num("A2"));
|
||||
assert!(t.calc.circular.is_empty());
|
||||
// A loop fed by a constant, and a formula that reads the loop, follow a change.
|
||||
t.set("D1", "10");
|
||||
t.set("A1", "=(A2+D1)/2");
|
||||
t.set("E1", "=A1*2");
|
||||
t.set("D1", "30");
|
||||
assert!((t.num("A1") - 30.0).abs() < 0.002, "{}", t.num("A1"));
|
||||
assert!((t.num("E1") - 60.0).abs() < 0.004, "{}", t.num("E1"));
|
||||
// Already settled: recalculating changes it by less than Maximum Change.
|
||||
let a1 = t.num("A1");
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert!((t.num("A1") - a1).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_calculation_stops_once_changes_are_below_maximum_change() {
|
||||
// A1 halves its distance to 10 each pass, from 5: 7.5, 8.75, 9.375, 9.6875, and A2 trails
|
||||
// one pass behind. Maximum Change 1 stops once neither moved by 1 or more.
|
||||
let mut t = T::iterative(100, 1.0);
|
||||
t.set("A1", "=(A2+10)/2");
|
||||
assert_eq!(t.num("A1"), 5.0);
|
||||
t.set("A2", "=A1");
|
||||
assert_eq!((t.num("A1"), t.num("A2")), (9.6875, 9.375));
|
||||
// Maximum Iterations 2 stops first.
|
||||
let mut t = T::iterative(2, 1.0);
|
||||
t.set("A1", "=(A2+10)/2");
|
||||
t.set("A2", "=A1");
|
||||
assert_eq!((t.num("A1"), t.num("A2")), (8.75, 7.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_calculation_of_text_and_errors() {
|
||||
let mut t = T::iterative(100, 0.001);
|
||||
t.set("A1", "=IF(ISTEXT(A1),A1&\"\",\"go\")");
|
||||
assert_eq!(t.get("A1"), Value::from("go"));
|
||||
t.set("B1", "=B1+1/0");
|
||||
assert_eq!(t.get("B1"), Value::Error(CellError::Div0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_references_without_iterative_calculation() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=A1+1");
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
assert_eq!(t.calc.circular, vec![(0, c("A1"))]);
|
||||
// Switched on, the loop is calculated from 0 rather than from the error.
|
||||
t.wb.calc.iterative = true;
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), 100.0);
|
||||
assert!(t.calc.circular.is_empty());
|
||||
t.wb.calc.iterative = false;
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let mut t = T::new();
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Calculation Options",
|
||||
["Formulas", "Calculation"],
|
||||
None,
|
||||
"{mode: automatic|automaticExceptTables|manual, iterative?, maxIterations?, maxChange?}",
|
||||
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
|
||||
has_doc,
|
||||
calc_options
|
||||
),
|
||||
@@ -546,6 +546,7 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
_ => None,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let before = cx.wb.calc.clone();
|
||||
if let Some(m) = mode {
|
||||
cx.wb.calc.mode = m;
|
||||
}
|
||||
@@ -553,14 +554,18 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
cx.wb.calc.iterative = v;
|
||||
}
|
||||
if let Some(v) = u32_param(p, "maxIterations") {
|
||||
cx.wb.calc.max_iterations = v.clamp(1, 32767);
|
||||
cx.wb.calc.set_max_iterations(v);
|
||||
}
|
||||
if let Some(v) = f64_param(p, "maxChange") {
|
||||
cx.wb.calc.max_change = v.abs();
|
||||
cx.wb.calc.set_max_change(v);
|
||||
}
|
||||
if mode == Some(CalcMode::Automatic) {
|
||||
let c = &cx.wb.calc;
|
||||
// Switching to automatic, or changing how circular references are calculated outside
|
||||
// manual mode, recalculates the workbook.
|
||||
let iteration_changed = (c.iterative, c.max_iterations, c.max_change) != (before.iterative, before.max_iterations, before.max_change);
|
||||
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) {
|
||||
cx.structural = true;
|
||||
}
|
||||
Ok(json!({"mode": format!("{:?}", cx.wb.calc.mode)}))
|
||||
Ok(json!({"mode": format!("{:?}", c.mode), "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1495,6 +1495,44 @@ fn reopen(s: &mut Session) {
|
||||
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculation_options_enable_iterative_calculation() {
|
||||
let mut s = s();
|
||||
let near = |s: &Session, a: &str, want: f64| {
|
||||
let got = v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
assert!((got - want).abs() < 0.002, "{a} = {:?}", v(s, a));
|
||||
};
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=(A2+10)/2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=A1"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(gridcraft_core::CellError::Circ));
|
||||
assert!(!s.doc().unwrap().calc.circular.is_empty());
|
||||
// Switching it on recalculates the loop.
|
||||
let r = s.execute("formulas.calculationOptions", json!({"iterative": true})).unwrap();
|
||||
assert_eq!(r, json!({"mode": "Automatic", "iterative": true, "maxIterations": 100, "maxChange": 0.001}));
|
||||
near(&s, "A1", 10.0);
|
||||
near(&s, "A2", 10.0);
|
||||
assert!(s.doc().unwrap().calc.circular.is_empty());
|
||||
// Empty params change nothing and report the settings.
|
||||
let r = s.execute("formulas.calculationOptions", json!({})).unwrap();
|
||||
assert_eq!(r["iterative"], true);
|
||||
// Limits are kept within Excel's: 1–32767 iterations, a non-negative change.
|
||||
let r = s.execute("formulas.calculationOptions", json!({"maxIterations": 0, "maxChange": -0.5})).unwrap();
|
||||
assert_eq!((r["maxIterations"].as_u64(), r["maxChange"].as_f64()), (Some(1), Some(0.5)));
|
||||
let r = s.execute("formulas.calculationOptions", json!({"maxIterations": 100000, "maxChange": 0.0001})).unwrap();
|
||||
assert_eq!((r["maxIterations"].as_u64(), r["maxChange"].as_f64()), (Some(32767), Some(0.0001)));
|
||||
// Saved with the workbook.
|
||||
reopen(&mut s);
|
||||
let calc = s.doc().unwrap().wb.calc.clone();
|
||||
assert_eq!((calc.iterative, calc.max_iterations, calc.max_change), (true, 32767, 0.0001));
|
||||
near(&s, "A1", 10.0);
|
||||
// Undo brings the previous settings back.
|
||||
s.execute("formulas.calculationOptions", json!({"iterative": false})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(gridcraft_core::CellError::Circ));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.calc.iterative);
|
||||
near(&s, "A1", 10.0);
|
||||
}
|
||||
|
||||
fn chart_data(s: &mut Session) {
|
||||
s.execute(
|
||||
"range.setValues",
|
||||
|
||||
@@ -18,11 +18,16 @@ pub enum CalcMode {
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// File › Options › Formulas › Calculation options, stored per workbook (`<calcPr>`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CalcSettings {
|
||||
pub mode: CalcMode,
|
||||
/// Enable iterative calculation: circular references are calculated repeatedly instead of
|
||||
/// reporting an error.
|
||||
pub iterative: bool,
|
||||
/// Maximum Iterations, 1 to [`CalcSettings::MAX_ITERATIONS`].
|
||||
pub max_iterations: u32,
|
||||
/// Maximum Change: iteration stops once no value in a circular reference changes by more.
|
||||
pub max_change: f64,
|
||||
/// Use precision as displayed.
|
||||
pub precision_as_displayed: bool,
|
||||
@@ -30,7 +35,32 @@ pub struct CalcSettings {
|
||||
|
||||
impl Default for CalcSettings {
|
||||
fn default() -> Self {
|
||||
CalcSettings { mode: CalcMode::Automatic, iterative: false, max_iterations: 100, max_change: 0.001, precision_as_displayed: false }
|
||||
CalcSettings {
|
||||
mode: CalcMode::Automatic,
|
||||
iterative: false,
|
||||
max_iterations: Self::DEFAULT_ITERATIONS,
|
||||
max_change: Self::DEFAULT_CHANGE,
|
||||
precision_as_displayed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CalcSettings {
|
||||
pub const DEFAULT_ITERATIONS: u32 = 100;
|
||||
pub const DEFAULT_CHANGE: f64 = 0.001;
|
||||
/// The largest Maximum Iterations Excel accepts.
|
||||
pub const MAX_ITERATIONS: u32 = 32_767;
|
||||
|
||||
/// Sets Maximum Iterations, kept within 1 to [`CalcSettings::MAX_ITERATIONS`].
|
||||
pub fn set_max_iterations(&mut self, n: u32) {
|
||||
self.max_iterations = n.clamp(1, Self::MAX_ITERATIONS);
|
||||
}
|
||||
|
||||
/// Sets Maximum Change (its magnitude); a value that isn't finite is ignored.
|
||||
pub fn set_max_change(&mut self, d: f64) {
|
||||
if d.is_finite() {
|
||||
self.max_change = d.abs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,10 +176,10 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
};
|
||||
wb.calc.iterative = c.flag("iterate", false);
|
||||
if let Some(n) = c.attr_u32("iterateCount") {
|
||||
wb.calc.max_iterations = n.clamp(1, 32767);
|
||||
wb.calc.set_max_iterations(n);
|
||||
}
|
||||
if let Some(d) = c.attr_f64("iterateDelta") {
|
||||
wb.calc.max_change = d.abs();
|
||||
wb.calc.set_max_change(d);
|
||||
}
|
||||
wb.calc.precision_as_displayed = !c.flag("fullPrecision", true);
|
||||
}
|
||||
|
||||
@@ -572,6 +572,60 @@ fn package_structure() {
|
||||
assert!(texts["xl/workbook.xml"].contains("fullCalcOnLoad"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterative_calculation_settings_roundtrip() {
|
||||
let calc_pr = |wb: &Workbook| {
|
||||
let bytes = write_xlsx(wb).unwrap();
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap();
|
||||
let c = book.child("calcPr").unwrap();
|
||||
let attrs = ["calcMode", "iterate", "iterateCount", "iterateDelta", "fullPrecision"].map(|a| c.attr(a).map(str::to_string));
|
||||
(attrs, read_xlsx(&bytes).unwrap().0.calc)
|
||||
};
|
||||
let s = |v: &str| Some(v.to_string());
|
||||
// Defaults: nothing written.
|
||||
let mut wb = Workbook::new();
|
||||
assert_eq!(calc_pr(&wb), ([None, None, None, None, None], CalcSettings::default()));
|
||||
// On: the switch and both limits, beside the other calculation settings.
|
||||
wb.calc.mode = CalcMode::Manual;
|
||||
wb.calc.iterative = true;
|
||||
wb.calc.precision_as_displayed = true;
|
||||
let (attrs, back) = calc_pr(&wb);
|
||||
assert_eq!(attrs, [s("manual"), s("1"), s("100"), s("0.001"), s("0")]);
|
||||
assert_eq!(back, wb.calc);
|
||||
// Off: limits that differ from the defaults are kept for when it is switched on again.
|
||||
wb.calc.iterative = false;
|
||||
wb.calc.set_max_iterations(250);
|
||||
wb.calc.set_max_change(0.0005);
|
||||
let (attrs, back) = calc_pr(&wb);
|
||||
assert_eq!(attrs, [s("manual"), None, s("250"), s("0.0005"), s("0")]);
|
||||
assert_eq!(back, wb.calc);
|
||||
// Out-of-range values in a file are kept within Excel's limits.
|
||||
let xml = |attrs: &str| {
|
||||
format!(
|
||||
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="A" sheetId="1" r:id="rId1"/></sheets><calcPr {attrs}/></workbook>"#
|
||||
)
|
||||
};
|
||||
for (attrs, count, change) in [
|
||||
(r#"iterate="1" iterateCount="0" iterateDelta="-2""#, 1, 2.0),
|
||||
(r#"iterate="true" iterateCount="99999" iterateDelta="NaN""#, CalcSettings::MAX_ITERATIONS, CalcSettings::DEFAULT_CHANGE),
|
||||
] {
|
||||
let wbx = xml(attrs);
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>"#;
|
||||
let ws = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData/></worksheet>"#;
|
||||
let bytes = super::make_zip(&[
|
||||
("[Content_Types].xml", super::CT.as_bytes()),
|
||||
("_rels/.rels", super::ROOT_RELS.as_bytes()),
|
||||
("xl/workbook.xml", wbx.as_bytes()),
|
||||
("xl/_rels/workbook.xml.rels", rels.as_bytes()),
|
||||
("xl/worksheets/sheet1.xml", ws.as_bytes()),
|
||||
]);
|
||||
let calc = read_xlsx(&bytes).unwrap().0.calc;
|
||||
assert!(calc.iterative, "{attrs}");
|
||||
assert_eq!((calc.max_iterations, calc.max_change), (count, change), "{attrs}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_workbook_roundtrip() {
|
||||
let wb = Workbook::new();
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::RangeRef;
|
||||
use gridcraft_formula::quote_sheet;
|
||||
use gridcraft_model::{CalcMode, Style, Visibility, Workbook};
|
||||
use gridcraft_model::{CalcMode, CalcSettings, Style, Visibility, Workbook};
|
||||
|
||||
use crate::IoError;
|
||||
use crate::package::ZipOut;
|
||||
@@ -289,8 +289,17 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
|
||||
CalcMode::Manual => w.push_str(" calcMode=\"manual\""),
|
||||
CalcMode::AutomaticExceptTables => w.push_str(" calcMode=\"autoNoTable\""),
|
||||
}
|
||||
if wb.calc.iterative {
|
||||
let _ = write!(w, " iterate=\"1\" iterateCount=\"{}\" iterateDelta=\"{}\"", wb.calc.max_iterations, crate::xml::num(wb.calc.max_change));
|
||||
// CT_CalcPr defaults: iterate off, 100 iterations, 0.001 maximum change. The limits are kept
|
||||
// while iteration is off, as Options keeps them.
|
||||
let calc = &wb.calc;
|
||||
if calc.iterative {
|
||||
w.push_str(" iterate=\"1\"");
|
||||
}
|
||||
if calc.iterative || calc.max_iterations != CalcSettings::DEFAULT_ITERATIONS {
|
||||
let _ = write!(w, " iterateCount=\"{}\"", calc.max_iterations);
|
||||
}
|
||||
if calc.iterative || calc.max_change != CalcSettings::DEFAULT_CHANGE {
|
||||
let _ = write!(w, " iterateDelta=\"{}\"", crate::xml::num(calc.max_change));
|
||||
}
|
||||
if wb.calc.precision_as_displayed {
|
||||
w.push_str(" fullPrecision=\"0\"");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Function parity: worksheet functions and formulas
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (iterative calculation for circular references) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
|
||||
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
|
||||
@@ -80,7 +80,7 @@ PIVOTBY, REGEXTEST…) are all present.
|
||||
| Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) |
|
||||
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
|
||||
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done |
|
||||
| Iterative calculation for circular references | cycle detection done; the Enable Iterative Calculation setting is stored and saved to XLSX, but the calc engine does not iterate (circular references stay unresolved) |
|
||||
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened |
|
||||
| 15-significant-digit comparison semantics | **missing** (#57: `=0.1+0.2=0.3` is FALSE) |
|
||||
| External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update |
|
||||
| Localized function names and `;` separators | **missing** (#45); en-US only |
|
||||
@@ -103,6 +103,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
|
||||
| 2026-10-11 | minor | ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added: Financial 55 / 55; 505 / 523 by name, 496 working |
|
||||
| 2026-10-11 | minor | PERCENTOF and EUROCONVERT added: 503 / 523 by name, 494 working |
|
||||
| 2026-10-11 | minor | BAHTTEXT added: 502 / 523 by name, 493 working |
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§3 narrowed by XLSX pass-through, #330) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§6: iterative calculation landed) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
|
||||
using GridCraft for real work first). This is the work list: pick from the top, check the open
|
||||
@@ -60,9 +60,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
|
||||
### 6. Calculation correctness bugs (F)
|
||||
- **Missing:** 15-significant-digit equality; spill updates after structural edits and cleared
|
||||
blockers; 3-D span deletion; qualified names after sheet rename; iterative calculation.
|
||||
- **Evidence:** #57, #133, #139, #140, #134; Enable Iterative Calculation stored but unused;
|
||||
PRs #148, #155, #156, #157.
|
||||
blockers; 3-D span deletion; qualified names after sheet rename.
|
||||
- **Evidence:** #57, #133, #139, #140, #134; PRs #148, #155, #156, #157. Iterative calculation
|
||||
for circular references landed (no Options dialog yet).
|
||||
- **Impact:** wrong numbers are worse than missing features.
|
||||
- **Estimate:** 10–15 h. [function-parity.md](function-parity.md#formula-language)
|
||||
|
||||
@@ -159,6 +159,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) |
|
||||
| 2026-10-11 | minor | §16: comment navigation, Show Comments, Show All Notes and Convert to Comments landed (#281) |
|
||||
| 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) |
|
||||
| 2026-10-11 | minor | §3: XLSX pass-through keeps custom XML, external links, connections, query tables, printer settings, controls and OLE objects (#330, #324) |
|
||||
|
||||
Reference in New Issue
Block a user