diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 72eed7cf5..553e76440 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -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, 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, + /// Formulas being evaluated, innermost last (kept only when iterating). + stack: Vec, + /// Formulas found on a circular reference (when iterating). + members: HashSet, + /// Results that read a previous value, directly or through other formulas. + tainted: HashSet, + /// Whether the formula being evaluated has read a previous value. + taint: bool, +} + +impl<'a> PassHost<'a> { + fn new(wb: &'a Workbook, exprs: &'a HashMap>, 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 = order.iter().copied().filter(|k| self.tainted.contains(k)).collect(); + let set: HashSet = 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| { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 2205a1e61..0ed303314 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -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(); diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index db8176bf2..35172072d 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -61,7 +61,7 @@ pub fn specs() -> Vec { "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 { _ => 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 { 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})) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cddff222d..0467dd931 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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", diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 3f4d365d9..5d1bed89d 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -18,11 +18,16 @@ pub enum CalcMode { Manual, } +/// File › Options › Formulas › Calculation options, stored per workbook (``). #[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(); + } } } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index a7227cf19..859555333 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -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); } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 9e14d1972..d9cc19c1f 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -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#""# + ) + }; + 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#""#; + let ws = r#""#; + 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(); diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 75461fb2b..20da4c930 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -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, 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\""); diff --git a/docs/function-parity.md b/docs/function-parity.md index 901507e72..d4c6a31fd 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -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 ``), 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 | diff --git a/docs/gaps.md b/docs/gaps.md index 35a8f87a0..db71522bc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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) |