Merge pull request #280 from vangel76/feat/percentof-euroconvert
Functions: PERCENTOF and EUROCONVERT (#279) # Conflicts: # README.md # ROADMAP.md # crates/functions/src/financial.rs # docs/function-parity.md # docs/gaps.md # docs/target-app-parity.md
This commit is contained in:
commit
fa4d2e5741
|
|
@ -134,6 +134,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
| 2026-10-11 | Startup no longer panics on a system font egui can't parse: fonts are validated and skipped (#226) |
|
||||
| 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) |
|
||||
| 2026-10-11 | Odd-period bond functions ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD: Financial 55 / 55 (#275) |
|
||||
| 2026-10-11 | PERCENTOF and EUROCONVERT worksheet functions (#279) |
|
||||
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
|
||||
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
|
||||
|
|
@ -146,6 +147,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
|
||||
| 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
|
||||
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
|
||||
| 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha |
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
use gridcraft_core::date::{datetime_from_serial, days_in_month, serial_from_ymd};
|
||||
use gridcraft_core::{CellError, DateSystem, Value};
|
||||
|
||||
use crate::util::{R, S, array_numbers, as_array, flatten, has, num, num_val, numbers, opt_bool, opt_num};
|
||||
use crate::util::{R, S, array_numbers, as_array, flatten, has, num, num_val, numbers, opt_bool, opt_num, round_half_away, text, to_sig_digits};
|
||||
use crate::{Arg, Ctx, FnSpec, VAR};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
|
@ -1305,6 +1305,64 @@ fn oddlyield(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||
num_val((red + dc * coupon - paid) / paid * f / dsc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Euro conversion
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The currencies EUROCONVERT documents: ISO code, the irrevocable conversion rate in units per
|
||||
/// euro (Council Regulation (EC) No 2866/98 as amended) and the calculation precision in decimals.
|
||||
const EURO_RATES: &[(&str, f64, i32)] = &[
|
||||
("BEF", 40.3399, 0),
|
||||
("LUF", 40.3399, 0),
|
||||
("DEM", 1.95583, 2),
|
||||
("ESP", 166.386, 0),
|
||||
("FRF", 6.55957, 2),
|
||||
("IEP", 0.787564, 2),
|
||||
("ITL", 1936.27, 0),
|
||||
("NLG", 2.20371, 2),
|
||||
("ATS", 13.7603, 2),
|
||||
("PTE", 200.482, 0),
|
||||
("FIM", 5.94573, 2),
|
||||
("GRD", 340.75, 0),
|
||||
("SIT", 239.64, 2),
|
||||
("EUR", 1.0, 2),
|
||||
];
|
||||
|
||||
fn euro_currency(a: &[Arg], i: usize) -> R<(&'static str, f64, i32)> {
|
||||
let code = text(a, i)?;
|
||||
let code = code.trim();
|
||||
EURO_RATES.iter().copied().find(|(c, _, _)| c.eq_ignore_ascii_case(code)).ok_or(CellError::Value)
|
||||
}
|
||||
|
||||
/// EUROCONVERT(number, source, target, [full_precision], [triangulation_precision]).
|
||||
fn euroconvert(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let x = num(a, 0)?;
|
||||
let (src, src_rate, _) = euro_currency(a, 1)?;
|
||||
let (tgt, tgt_rate, tgt_prec) = euro_currency(a, 2)?;
|
||||
let full = opt_bool(a, 3, false)?;
|
||||
let triangulation = if has(a, 4) {
|
||||
let t = num(a, 4)?.trunc();
|
||||
if t < 3.0 {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
// Beyond 17 significant digits rounding changes nothing.
|
||||
Some(t.min(17.0) as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if src == tgt {
|
||||
return num_val(x);
|
||||
}
|
||||
let mut euros = x / src_rate;
|
||||
if src != "EUR"
|
||||
&& let Some(t) = triangulation
|
||||
{
|
||||
euros = to_sig_digits(euros, t);
|
||||
}
|
||||
let out = euros * tgt_rate;
|
||||
num_val(if full { out } else { round_half_away(out, tgt_prec) })
|
||||
}
|
||||
|
||||
pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
vec![
|
||||
f!(
|
||||
|
|
@ -1686,6 +1744,16 @@ pub(crate) fn specs() -> Vec<FnSpec> {
|
|||
"Yield of a security with an odd last period.",
|
||||
oddlyield
|
||||
),
|
||||
f!(
|
||||
"EUROCONVERT",
|
||||
3,
|
||||
5,
|
||||
AddIn,
|
||||
S,
|
||||
"EUROCONVERT(number, source, target, [full_precision], [triangulation_precision])",
|
||||
"Converts between the euro and the former euro member currencies at the fixed rates.",
|
||||
euroconvert
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -1916,6 +1984,31 @@ mod tests {
|
|||
is_err(ev("ODDFPRICE", vec![n(-5.0), n(1e20), n(0.0), n(1e10), n(0.05), n(0.05), n(100.0), n(2.0)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn euroconvert() {
|
||||
// The examples on Microsoft's EUROCONVERT page.
|
||||
let ec = |x: f64, s: &str, tg: &str, rest: Vec<Arg>| {
|
||||
let mut a = vec![n(x), t(s), t(tg)];
|
||||
a.extend(rest);
|
||||
ev("EUROCONVERT", a)
|
||||
};
|
||||
close(ec(1.2, "DEM", "EUR", vec![]), 0.61);
|
||||
close(ec(1.0, "FRF", "EUR", vec![b(true), n(3.0)]), 0.152);
|
||||
close(ec(1.0, "FRF", "EUR", vec![b(false), n(3.0)]), 0.15);
|
||||
close(ec(1.0, "FRF", "DEM", vec![b(true), n(3.0)]), 0.29728616);
|
||||
// Euro to a member currency rounds to the target's calculation precision (lira: 0).
|
||||
close(ec(10.0, "eur", "ITL", vec![]), 19363.0);
|
||||
close(ec(100.0, "EUR", "IEP", vec![b(true)]), 78.7564);
|
||||
// Same currency returns the number untouched.
|
||||
close(ec(1.23456, "DEM", "DEM", vec![]), 1.23456);
|
||||
// Unknown codes and a triangulation precision below 3 are #VALUE!.
|
||||
is_err(ec(1.0, "USD", "EUR", vec![]), CellError::Value);
|
||||
is_err(ec(1.0, "FRF", "DEM", vec![b(true), n(2.0)]), CellError::Value);
|
||||
// Hostile magnitudes give a number or #NUM!, never a panic.
|
||||
is_err(ec(f64::MAX, "EUR", "ITL", vec![]), CellError::Num);
|
||||
close(ec(1.0, "FRF", "DEM", vec![b(true), n(1e300)]), 1.95583 / 6.55957);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifting() {
|
||||
let v = ev("SLN", vec![av(row(&[100.0, 200.0])), n(0.0), n(10.0)]);
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ pub enum Category {
|
|||
Compatibility,
|
||||
Web,
|
||||
Cube,
|
||||
/// Excel's "Add-in and Automation" category (EUROCONVERT).
|
||||
AddIn,
|
||||
}
|
||||
|
||||
pub struct FnSpec {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,17 @@ fn sum(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||
num_val(numbers(a)?.iter().sum())
|
||||
}
|
||||
|
||||
/// PERCENTOF(data_subset, data_all) = SUM(data_subset) / SUM(data_all).
|
||||
fn percentof(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let part = a.get(..1).ok_or(CellError::Value)?;
|
||||
let all = a.get(1..2).ok_or(CellError::Value)?;
|
||||
let (part, all): (f64, f64) = (numbers(part)?.iter().sum(), numbers(all)?.iter().sum());
|
||||
if all == 0.0 {
|
||||
return Err(CellError::Div0);
|
||||
}
|
||||
num_val(part / all)
|
||||
}
|
||||
|
||||
fn sumsq(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(numbers(a)?.iter().map(|x| x * x).sum())
|
||||
}
|
||||
|
|
@ -1050,6 +1061,16 @@ fn averageif(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||
pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
vec![
|
||||
f!("SUM", 1, VAR, MathTrig, A, "SUM(number1, [number2], ...)", "Adds all the numbers in the arguments.", sum),
|
||||
f!(
|
||||
"PERCENTOF",
|
||||
2,
|
||||
2,
|
||||
MathTrig,
|
||||
A,
|
||||
"PERCENTOF(data_subset, data_all)",
|
||||
"The sum of a subset as a fraction of the sum of all the values.",
|
||||
percentof
|
||||
),
|
||||
f!("SUMSQ", 1, VAR, MathTrig, A, "SUMSQ(number1, [number2], ...)", "Adds the squares of the numbers.", sumsq),
|
||||
f!(
|
||||
"SUMPRODUCT",
|
||||
|
|
@ -1278,6 +1299,18 @@ mod tests {
|
|||
ev(name, vec![n(x), n(y)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentof() {
|
||||
// SUM(subset) / SUM(all): text, booleans and blanks inside ranges are skipped like SUM.
|
||||
let all = arr(vec![vec![nv(10.0)], vec![nv(30.0)], vec![tv("x")], vec![Value::Empty], vec![nv(60.0)]]);
|
||||
let part = arr(vec![vec![nv(10.0)], vec![nv(30.0)]]);
|
||||
close(ev("PERCENTOF", vec![rf(part), rf(all.clone())]), 0.4);
|
||||
close(ev("PERCENTOF", vec![n(25.0), n(200.0)]), 0.125);
|
||||
is_err(ev("PERCENTOF", vec![n(1.0), rf(arr(vec![vec![nv(5.0), nv(-5.0)]]))]), Div0);
|
||||
is_err(ev("PERCENTOF", vec![rf(arr(vec![vec![Value::Error(NA)]])), rf(all)]), NA);
|
||||
is_err(ev("PERCENTOF", vec![t("abc"), n(1.0)]), CellError::Value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sums() {
|
||||
close(ev("SUM", vec![n(1.0), n(2.0), t("3"), b(true)]), 7.0);
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ const FUTURE_FUNCTIONS: &[&str] = &[
|
|||
"PDURATION",
|
||||
"PERCENTILE.EXC",
|
||||
"PERCENTILE.INC",
|
||||
"PERCENTOF",
|
||||
"PERCENTRANK.EXC",
|
||||
"PERCENTRANK.INC",
|
||||
"PERMUTATIONA",
|
||||
|
|
@ -322,6 +323,7 @@ mod tests {
|
|||
fn functions() {
|
||||
assert_eq!(file_function_name("xlookup").as_deref(), Some("_xlfn.XLOOKUP"));
|
||||
assert_eq!(file_function_name("FILTER").as_deref(), Some("_xlfn._xlws.FILTER"));
|
||||
assert_eq!(file_function_name("PERCENTOF").as_deref(), Some("_xlfn.PERCENTOF"));
|
||||
assert_eq!(file_function_name("SUM"), None);
|
||||
assert_eq!(builtin_format(14), Some("m/d/yyyy"));
|
||||
assert_eq!(builtin_id("0.00%"), Some(10));
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Excel's worksheet functions, category by category, against GridCraft's function
|
|||
LEN/LENB, MID/MIDB, REPLACE/REPLACEB, RIGHT/RIGHTB, SEARCH/SEARCHB), BETA.INV, and COPILOT and
|
||||
PY (Microsoft 365 functions not on that page when fetched).
|
||||
- **Ours (505):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the
|
||||
- **Ours (503):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the
|
||||
evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE,
|
||||
OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL,
|
||||
MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name.
|
||||
|
|
@ -40,7 +41,7 @@ Excel's worksheet functions, category by category, against GridCraft's function
|
|||
| Database | 12 | 12 | 12 | 100% | — |
|
||||
| Cube | 7 | 7 | 0 | 0% | (all 7 are `#N/A` stubs) |
|
||||
| Web | 3 | 3 | 1 | 33% | (WEBSERVICE, FILTERXML are stubs; ENCODEURL works) |
|
||||
| Add-in and Automation | 3 | 0 | 0 | 0% | CALL, EUROCONVERT, REGISTER.ID |
|
||||
| Add-in and Automation | 3 | 1 | 1 | 33% | CALL, REGISTER.ID |
|
||||
| AI and Python | 2 | 0 | 0 | 0% | COPILOT, PY |
|
||||
| **Total** | **523** | **505** | **496** | **94.8%** | 18 missing, 9 stubs |
|
||||
|
||||
|
|
@ -55,9 +56,8 @@ PIVOTBY, REGEXTEST…) are all present.
|
|||
|---|---|---|---|
|
||||
| FORECAST.ETS family (4) | yes | 4–6 h | AAA exponential smoothing with seasonality detection; needed for Forecast Sheet too |
|
||||
| GETPIVOTDATA | yes | 2–3 h | Needs the pivot engine's result cache; also the "Generate GetPivotData" option |
|
||||
| TRIMRANGE, PERCENTOF, BAHTTEXT | yes | 1–2 h | TRIMRANGE also implies the `.:` trim-ref operators |
|
||||
| TRIMRANGE, BAHTTEXT | yes | 1–2 h | TRIMRANGE also implies the `.:` trim-ref operators |
|
||||
| IMAGE | yes | 3–5 h | Pictures in cells (see PR #105); fetching URLs needs a privacy decision |
|
||||
| EUROCONVERT | yes | 0.5 h | Fixed conversion table |
|
||||
| CUBE* (7), WEBSERVICE, FILTERXML | partly | 4–8 h | FILTERXML can work offline; CUBE needs an OLAP/data-model source |
|
||||
| TRANSLATE, DETECTLANGUAGE, STOCKHISTORY, COPILOT, PY | no (cloud) | — | Microsoft cloud services; open equivalents only by owner decision |
|
||||
| CALL, REGISTER.ID, RTD | no | — | Native DLL/COM calls; not portable or safe |
|
||||
|
|
@ -97,4 +97,5 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
|||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-10 | major | Created: 501 / 523 by name, 492 working, per category, against Microsoft's published list |
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
|
||||
### 15. Remaining functions and an oracle suite (F)
|
||||
- 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h.
|
||||
- 20 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 17–29 h.
|
||||
[function-parity.md](function-parity.md)
|
||||
|
||||
### 16. Collaboration and review (F)
|
||||
|
|
@ -150,6 +151,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | §15: ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added (18 functions missing) |
|
||||
| 2026-10-11 | minor | §15: PERCENTOF and EUROCONVERT added (20 functions missing) |
|
||||
| 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) |
|
||||
| 2026-10-10 | minor | Noted that no gap blocks the alpha gate |
|
||||
| 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues |
|
||||
|
|
|
|||
|
|
@ -333,6 +333,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
|
|||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD |
|
||||
| 2026-10-11 | minor | Worksheet functions 503 / 523 (96.2%; 494 working): PERCENTOF, EUROCONVERT |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) |
|
||||
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) |
|
||||
| 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha |
|
||||
|
|
|
|||
Loading…
Reference in New Issue