From de8dc2747739b03ea4ee996a4f1016e4eac22de7 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 15 Oct 2026 07:10:56 +0000 Subject: [PATCH 1/7] implement-iterate: green after 1 iteration(s) --- CHANGELOG.md | 4 + src/error-message.ts | 3 +- src/i18n/languages/csCZ.ts | 5 + src/i18n/languages/daDK.ts | 5 + src/i18n/languages/deDE.ts | 5 + src/i18n/languages/enGB.ts | 5 + src/i18n/languages/esES.ts | 5 + src/i18n/languages/fiFI.ts | 5 + src/i18n/languages/frFR.ts | 5 + src/i18n/languages/huHU.ts | 5 + src/i18n/languages/idID.ts | 5 + src/i18n/languages/itIT.ts | 5 + src/i18n/languages/nbNO.ts | 5 + src/i18n/languages/nlNL.ts | 5 + src/i18n/languages/plPL.ts | 5 + src/i18n/languages/ptPT.ts | 5 + src/i18n/languages/ruRU.ts | 5 + src/i18n/languages/svSE.ts | 5 + src/i18n/languages/trTR.ts | 5 + .../categories/statistical.ts | 28 +++ src/interpreter/plugin/PercentilePlugin.ts | 80 +++++++- .../plugin/StatisticalAggregationPlugin.ts | 187 ++++++++++++++++++ src/license/functionCapabilities.ts | 6 +- 23 files changed, 388 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f97c660139..7f193880fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added the `PERCENTRANK.INC` (with its `PERCENTRANK` alias), `MODE.MULT`, `FREQUENCY` and `PROB` statistical functions. + ### Fixed - Fixed the `EDATE` function returning the 28th of February instead of the 29th when the shifted date falls in February of a leap year (e.g. `=EDATE(DATE(2028, 1, 31), 1)` now returns `2028-02-29` instead of `2028-02-28`). [#1781](https://github.com/handsontable/hyperformula/pull/1781) diff --git a/src/error-message.ts b/src/error-message.ts index d42040901c..17d01a33ce 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -29,7 +29,8 @@ export class ErrorMessage { public static OutOfSheet = 'Resulting reference is out of the sheet.' public static WrongType = 'Wrong type of argument.' public static NaN = 'NaN or infinite value encountered.' - public static EqualLength = 'Ranges need to be of equal length.' + public static ProbabilityBounds = 'Probabilities need to be greater than 0, at most 1, and sum up to 1.' + public static EqualLength ='Ranges need to be of equal length.' public static Negative = 'Value cannot be negative.' public static NotBinary = 'String does not represent a binary number.' public static NotOctal = 'String does not represent an octal number.' diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index fea286083c..cf46aff526 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIL', 'PERCENTILE.INC': 'PERCENTIL.INC', 'PERCENTILE.EXC': 'PERCENTIL.EXC', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'QUARTIL', 'QUARTILE.INC': 'QUARTIL.INC', 'QUARTILE.EXC': 'QUARTIL.EXC', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 9264551249..9641c44cca 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'FRAKTIL', 'PERCENTILE.INC': 'FRAKTIL.MEDTAG', 'PERCENTILE.EXC': 'FRAKTIL.UDELAD', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'KVARTIL', 'QUARTILE.INC': 'KVARTIL.MEDTAG', 'QUARTILE.EXC': 'KVARTIL.UDELAD', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 4d3d39319b..4acebfa235 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'QUANTIL', 'PERCENTILE.INC': 'QUANTIL.INKL', 'PERCENTILE.EXC': 'QUANTIL.EXKL', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'QUARTILE', 'QUARTILE.INC': 'QUARTILE.INKL', 'QUARTILE.EXC': 'QUARTILE.EXKL', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index d271b732cd..684bbe1633 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTILE', 'PERCENTILE.INC': 'PERCENTILE.INC', 'PERCENTILE.EXC': 'PERCENTILE.EXC', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'QUARTILE', 'QUARTILE.INC': 'QUARTILE.INC', 'QUARTILE.EXC': 'QUARTILE.EXC', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 9c6b817b98..845c4fda4e 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -390,6 +390,11 @@ export const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIL', 'PERCENTILE.INC': 'PERCENTIL.INC', 'PERCENTILE.EXC': 'PERCENTIL.EXC', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'CUARTIL', 'QUARTILE.INC': 'CUARTIL.INC', 'QUARTILE.EXC': 'CUARTIL.EXC', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 99ff05708f..c156a51a55 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PROSENTTIPISTE', 'PERCENTILE.INC': 'PROSENTTIPISTE.SIS', 'PERCENTILE.EXC': 'PROSENTTIPISTE.ULK', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'NELJÄNNES', 'QUARTILE.INC': 'NELJÄNNES.SIS', 'QUARTILE.EXC': 'NELJÄNNES.ULK', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index f7b9be45f7..2e87f83368 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'CENTILE', 'PERCENTILE.INC': 'CENTILE.INCLURE', 'PERCENTILE.EXC': 'CENTILE.EXCLURE', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'QUARTILE', 'QUARTILE.INC': 'QUARTILE.INCLURE', 'QUARTILE.EXC': 'QUARTILE.EXCLURE', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index e40219553a..fba9f81ade 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTILIS', 'PERCENTILE.INC': 'PERCENTILIS.TARTALMAZ', 'PERCENTILE.EXC': 'PERCENTILIS.KIZÁR', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'KVARTILIS', 'QUARTILE.INC': 'KVARTILIS.TARTALMAZ', 'QUARTILE.EXC': 'KVARTILIS.KIZÁR', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 719cb11db6..a7777ca592 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTILE', 'PERCENTILE.INC': 'PERCENTILE.INC', 'PERCENTILE.EXC': 'PERCENTILE.EXC', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'QUARTILE', 'QUARTILE.INC': 'QUARTILE.INC', 'QUARTILE.EXC': 'QUARTILE.EXC', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 1a494628f8..292c2d944c 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTILE', 'PERCENTILE.INC': 'INC.PERCENTILE', 'PERCENTILE.EXC': 'ESC.PERCENTILE', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'QUARTILE', 'QUARTILE.INC': 'INC.QUARTILE', 'QUARTILE.EXC': 'ESC.QUARTILE', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index ff15415a98..6c75cdb71b 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERSENTIL', 'PERCENTILE.INC': 'PERSENTIL.INK', 'PERCENTILE.EXC': 'PERSENTIL.EKS', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'KVARTIL', 'QUARTILE.INC': 'KVARTIL.INK', 'QUARTILE.EXC': 'KVARTIL.EKS', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index c491e2f44b..066cc06e1c 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIEL', 'PERCENTILE.INC': 'PERCENTIEL.INC', 'PERCENTILE.EXC': 'PERCENTIEL.EXC', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'KWARTIEL', 'QUARTILE.INC': 'KWARTIEL.INC', 'QUARTILE.EXC': 'KWARTIEL.EXC', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 6fa4f016b4..71dfc156c9 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTYL', 'PERCENTILE.INC': 'PERCENTYL.PRZEDZ.ZAMK', 'PERCENTILE.EXC': 'PERCENTYL.PRZEDZ.OTW', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'KWARTYL', 'QUARTILE.INC': 'KWARTYL.PRZEDZ.ZAMK', 'QUARTILE.EXC': 'KWARTYL.PRZEDZ.OTW', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 6c1e68c328..bbe9f90257 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIL', 'PERCENTILE.INC': 'PERCENTIL.INC', 'PERCENTILE.EXC': 'PERCENTIL.EXC', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'QUARTIL', 'QUARTILE.INC': 'QUARTIL.INC', 'QUARTILE.EXC': 'QUARTIL.EXC', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 6b35c1ce2f..81efaee0b6 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'ПЕРСЕНТИЛЬ', 'PERCENTILE.INC': 'ПРОЦЕНТИЛЬ.ВКЛ', 'PERCENTILE.EXC': 'ПРОЦЕНТИЛЬ.ИСКЛ', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'КВАРТИЛЬ', 'QUARTILE.INC': 'КВАРТИЛЬ.ВКЛ', 'QUARTILE.EXC': 'КВАРТИЛЬ.ИСКЛ', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 5f87580087..8beadc6ff6 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIL', 'PERCENTILE.INC': 'PERCENTIL.INK', 'PERCENTILE.EXC': 'PERCENTIL.EXK', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'KVARTIL', 'QUARTILE.INC': 'KVARTIL.INK', 'QUARTILE.EXC': 'KVARTIL.EXK', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 93c2176d37..f4ea170bdc 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'YÜZDEBİRLİK', 'PERCENTILE.INC': 'YÜZDEBİRLİK.DHL', 'PERCENTILE.EXC': 'YÜZDEBİRLİK.HRC', + PERCENTRANK: 'PERCENTRANK', + 'PERCENTRANK.INC': 'PERCENTRANK.INC', + 'MODE.MULT': 'MODE.MULT', + FREQUENCY: 'FREQUENCY', + PROB: 'PROB', QUARTILE: 'DÖRTTEBİRLİK', 'QUARTILE.INC': 'DÖRTTEBİRLİK.DHL', 'QUARTILE.EXC': 'DÖRTTEBİRLİK.HRC', diff --git a/src/interpreter/functionMetadata/categories/statistical.ts b/src/interpreter/functionMetadata/categories/statistical.ts index 7a536e4cd2..035204e999 100644 --- a/src/interpreter/functionMetadata/categories/statistical.ts +++ b/src/interpreter/functionMetadata/categories/statistical.ts @@ -241,6 +241,13 @@ export const STATISTICAL_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=FISHERINV(0.5)'], }, + FREQUENCY: { + category: 'Statistical', + shortDescription: 'Returns a column that counts how many numbers of data_array fall into each interval set by bins_array.', + parameters: [{name: 'data_array', description: 'The range or array of values to count; text, booleans and empty cells are skipped.'}, {name: 'bins_array', description: 'The range or array of interval upper bounds; the result has one row more than there are bounds, the last row counting the values above the largest bound.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=FREQUENCY(A1:A10, B1:B3)'], + }, GAMMA: { category: 'Statistical', shortDescription: 'Returns value of Gamma function.', @@ -346,6 +353,13 @@ export const STATISTICAL_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=MEDIAN(1, 2, 3)', '=MEDIAN(A1:A10)'], }, + 'MODE.MULT': { + category: 'Statistical', + shortDescription: 'Returns a column of the most frequently occurring numbers, in order of first occurrence; #N/A when no number repeats.', + parameters: [{name: 'number1', description: 'A number, cell reference, or range to search for the most frequent numbers; text, booleans and empty cells are skipped. Further numbers or ranges can be passed as additional arguments.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=MODE.MULT(1, 1, 2, 2, 3)', '=MODE.MULT(A1:A10)'], + }, MIN: { category: 'Statistical', shortDescription: 'Returns the minimum value in a list of arguments.', @@ -416,6 +430,13 @@ export const STATISTICAL_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=PERCENTILE.INC(A1:A10, 0.9)'], }, + 'PERCENTRANK.INC': { + category: 'Statistical', + shortDescription: 'Returns the rank of a value within a range as a fraction between 0 and 1, inclusive of both ends.', + parameters: [{name: 'array', description: 'The range of values that defines the ranking; text, booleans and empty cells are skipped.'}, {name: 'x', description: 'The value to rank; #N/A when it lies outside of the numbers in array.'}, {name: 'significance', description: 'The number of decimal digits of the result, truncated rather than rounded; at least 1, 3 by default.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=PERCENTRANK.INC(A1:A10, 5)', '=PERCENTRANK.INC(A1:A10, 5, 2)'], + }, PHI: { category: 'Statistical', shortDescription: 'Returns probability density of normal distribution.', @@ -430,6 +451,13 @@ export const STATISTICAL_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=POISSON.DIST(3, 5, FALSE())', '=POISSON.DIST(3, 5, TRUE())'], }, + PROB: { + category: 'Statistical', + shortDescription: 'Returns the probability that a value falls within the given limits, from a range of values and their probabilities.', + parameters: [{name: 'x_range', description: 'The range of numeric values.'}, {name: 'prob_range', description: 'The range of probabilities of the values in x_range, of the same size; each must be greater than 0 and at most 1, and together they must sum up to 1.'}, {name: 'lower_limit', description: 'The lower limit of the values, or the single value whose probability is returned when upper_limit is omitted.'}, {name: 'upper_limit', description: 'The upper limit of the values; the limits are inclusive.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=PROB(A1:A4, B1:B4, 2)', '=PROB(A1:A4, B1:B4, 2, 3)'], + }, 'QUARTILE.EXC': { category: 'Statistical', shortDescription: 'Returns the quartile of a data set, based on exclusive percentile values.', diff --git a/src/interpreter/plugin/PercentilePlugin.ts b/src/interpreter/plugin/PercentilePlugin.ts index a09cd2c5c9..4a3c00631e 100644 --- a/src/interpreter/plugin/PercentilePlugin.ts +++ b/src/interpreter/plugin/PercentilePlugin.ts @@ -58,7 +58,49 @@ function percentileExclusive(sortedVals: number[], k: number): number | CellErro } /** - * Interpreter plugin for percentile and quartile statistical functions. + * Computes the inclusive percent rank of `x` in the sorted values, the inverse of {@link percentileInclusive}: + * the rank is `index / (n - 1)`, where `index` is the position of the first element equal to `x`, or the + * linearly interpolated position when `x` falls between two elements. + * Assumes sortedVals is non-empty and x lies within [first, last]. + * + * @param sortedVals - pre-sorted array of numeric values (ascending) + * @param x - the value to rank + * @returns the rank in [0, 1], before truncation to the requested significance + */ +function percentRankInclusive(sortedVals: number[], x: number): number { + const n = sortedVals.length + if (n === 1) { + return 1 + } + const upperIndex = sortedVals.findIndex(value => value >= x) + if (sortedVals[upperIndex] === x) { + return upperIndex / (n - 1) + } + const lowerIndex = upperIndex - 1 + const fraction = (x - sortedVals[lowerIndex]) / (sortedVals[upperIndex] - sortedVals[lowerIndex]) + return (lowerIndex + fraction) / (n - 1) +} + +const TRUNCATION_TOLERANCE = 1e-9 + +/** Beyond this many digits a double holds nothing more to keep, and `10 ** digits` would overflow for huge ones. */ +const MAX_SIGNIFICANT_DIGITS = 15 + +/** + * Truncates (does not round) `value` to the given number of decimal digits. The tiny tolerance keeps + * results such as 0.3, which the division leaves a hair below, from being cut to 0.2999. + * + * @param value - a non-negative number + * @param digits - the number of decimal digits to keep + * @returns the truncated value + */ +function truncateToDigits(value: number, digits: number): number { + const scale = Math.pow(10, Math.min(digits, MAX_SIGNIFICANT_DIGITS)) + return Math.floor(value * scale + TRUNCATION_TOLERANCE) / scale +} + +/** + * Interpreter plugin for percentile, percent rank and quartile statistical functions. * * Implements inclusive (INC) and exclusive (EXC) interpolation variants. * QUARTILE functions delegate to PERCENTILE by converting quart index to @@ -81,6 +123,14 @@ export class PercentilePlugin extends FunctionPlugin implements FunctionPluginTy {argumentType: FunctionArgumentType.NUMBER, greaterThan: 0, lessThan: 1}, ], }, + 'PERCENTRANK.INC': { + method: 'percentrank', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER}, + {argumentType: FunctionArgumentType.NUMBER, minValue: 1, defaultValue: 3}, + ], + }, 'QUARTILE.INC': { method: 'quartile', parameters: [ @@ -99,6 +149,7 @@ export class PercentilePlugin extends FunctionPlugin implements FunctionPluginTy public static aliases = { PERCENTILE: 'PERCENTILE.INC', + PERCENTRANK: 'PERCENTRANK.INC', QUARTILE: 'QUARTILE.INC', } @@ -144,6 +195,33 @@ export class PercentilePlugin extends FunctionPlugin implements FunctionPluginTy ) } + /** + * Corresponds to PERCENTRANK(array, x, [significance]) and PERCENTRANK.INC(array, x, [significance]). + * + * Returns the rank of `x` within the numbers of `array` as a fraction in [0, 1], truncated (not rounded) + * to `significance` digits (3 by default; a fractional value is truncated to an integer). + * Returns #N/A when `array` holds no numbers or `x` lies outside of them, #NUM! when `significance` is below 1. + * + * @param ast - procedure AST node + * @param state - interpreter state + * @returns the truncated percent rank, or CellError on invalid input + */ + public percentrank(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('PERCENTRANK.INC'), + (range: SimpleRangeValue, x: number, significance: number) => { + const vals = this.arithmeticHelper.manyToExactNumbers(range.valuesFromTopLeftCorner()) + if (vals instanceof CellError) { + return vals + } + vals.sort((a, b) => a - b) + if (vals.length === 0 || x < vals[0] || x > vals[vals.length - 1]) { + return new CellError(ErrorType.NA, ErrorMessage.ValueNotFound) + } + return truncateToDigits(percentRankInclusive(vals, x), Math.trunc(significance)) + } + ) + } + /** * Corresponds to QUARTILE(array, quart) and QUARTILE.INC(array, quart). * diff --git a/src/interpreter/plugin/StatisticalAggregationPlugin.ts b/src/interpreter/plugin/StatisticalAggregationPlugin.ts index c363bc1351..e73daafb79 100644 --- a/src/interpreter/plugin/StatisticalAggregationPlugin.ts +++ b/src/interpreter/plugin/StatisticalAggregationPlugin.ts @@ -3,6 +3,7 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ +import {ArraySize} from '../../ArraySize' import {CellError, ErrorType} from '../../Cell' import {ErrorMessage} from '../../error-message' import {ProcedureAst} from '../../parser' @@ -35,8 +36,43 @@ import { } from '../doubleDouble' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' +/** + * How far the probabilities of PROB may be from summing up to exactly 1; absorbs the rounding of the + * binary floating-point sum (0.4 + 0.3 + 0.2 + 0.1 is 0.9999999999999999). + */ +const PROBABILITY_SUM_TOLERANCE = 1e-9 + export class StatisticalAggregationPlugin extends FunctionPlugin implements FunctionPluginTypecheck { public static implementedFunctions: ImplementedFunctions = { + 'MODE.MULT': { + method: 'modemult', + sizeOfResultArrayMethod: 'modemultArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.ANY}, + ], + repeatLastArgs: 1, + vectorizationForbidden: true, + }, + 'FREQUENCY': { + method: 'frequency', + sizeOfResultArrayMethod: 'frequencyArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.RANGE}, + ], + vectorizationForbidden: true, + }, + 'PROB': { + method: 'prob', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER}, + {argumentType: FunctionArgumentType.NUMBER, optionalArg: true}, + ], + }, 'AVEDEV': { method: 'avedev', parameters: [ @@ -165,6 +201,157 @@ export class StatisticalAggregationPlugin extends FunctionPlugin implements Func SKEWP: 'SKEW.P', } + /** + * Corresponds to MODE.MULT(number1, [number2, ...]). + * + * Returns a column of the most frequent numbers, in order of first occurrence. Only numbers are counted: + * text, booleans and empty cells are skipped, an error is propagated. Returns #N/A when no number repeats. + * + * @param ast - procedure AST node + * @param state - interpreter state + */ + public modemult(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('MODE.MULT'), + (...args: RawInterpreterValue[]) => { + const values = this.numbersOfArguments(args) + if (values instanceof CellError) { + return values + } + const occurrences = new Map() + values.forEach(value => occurrences.set(value, (occurrences.get(value) ?? 0) + 1)) + const highestCount = Math.max(...occurrences.values()) + if (highestCount < 2) { + return new CellError(ErrorType.NA, ErrorMessage.ValueNotFound) + } + const modes = Array.from(occurrences.keys()).filter(value => occurrences.get(value) === highestCount) + return SimpleRangeValue.onlyNumbers(modes.map(mode => [mode])) + }) + } + + /** + * Predicts the output size of MODE.MULT at parse time. The number of modes depends on the data, so the + * upper bound is used: every mode needs at least two occurrences. The actual result is smaller or equal. + * + * @param ast - procedure AST node + * @param state - interpreter state + */ + public modemultArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length < 1) { + return ArraySize.error() + } + const argumentState = new InterpreterState(state.formulaAddress, true) + const numberOfValues = ast.args + .map(arg => this.arraySizeForAst(arg, argumentState)) + .reduce((sum, size) => sum + size.width * size.height, 0) + return new ArraySize(1, Math.max(1, Math.floor(numberOfValues / 2))) + } + + /** + * Corresponds to FREQUENCY(data_array, bins_array). + * + * Returns a column with one row per bin plus one. The row of a bin counts the numbers above the next smaller + * bin and up to and including that bin, so the intervals follow the ascending order of the bins while each + * row stays at the position of its bin in `bins_array`; of equal bins only the first gets the count, the + * others 0. The last row counts the numbers above the largest bin. Text, booleans and empty cells are skipped + * in both arguments. When `bins_array` holds no number, the result is a single bin below all the data: + * 0, then the count of the numbers. + * + * @param ast - procedure AST node + * @param state - interpreter state + */ + public frequency(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('FREQUENCY'), + (dataArray: SimpleRangeValue, binsArray: SimpleRangeValue) => { + const data = this.arithmeticHelper.manyToExactNumbers(dataArray.valuesFromTopLeftCorner()) + if (data instanceof CellError) { + return data + } + const numericBins = this.arithmeticHelper.manyToExactNumbers(binsArray.valuesFromTopLeftCorner()) + if (numericBins instanceof CellError) { + return numericBins + } + const bins = numericBins.length === 0 ? [-Infinity] : numericBins + const binsAscending = bins.map((_, index) => index).sort((a, b) => bins[a] - bins[b] || a - b) + const sortedBins = binsAscending.map(index => bins[index]) + const sortedCounts: number[] = new Array(bins.length + 1).fill(0) + data.forEach(value => { + const binIndex = sortedBins.findIndex(bin => value <= bin) + sortedCounts[binIndex === -1 ? bins.length : binIndex]++ + }) + const counts: number[] = new Array(bins.length + 1) + binsAscending.forEach((originalIndex, sortedIndex) => { + counts[originalIndex] = sortedCounts[sortedIndex] + }) + counts[bins.length] = sortedCounts[bins.length] + return SimpleRangeValue.onlyNumbers(counts.map(count => [count])) + }) + } + + /** + * Predicts the output size of FREQUENCY at parse time: one row per cell of `bins_array` plus one. The cells + * that are not numbers are skipped at runtime, so the actual result may be shorter. + * + * @param ast - procedure AST node + * @param state - interpreter state + */ + public frequencyArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length !== 2) { + return ArraySize.error() + } + const binsSize = this.arraySizeForAst(ast.args[1], new InterpreterState(state.formulaAddress, true)) + return new ArraySize(1, binsSize.width * binsSize.height + 1) + } + + /** + * Corresponds to PROB(x_range, prob_range, lower_limit, [upper_limit]). + * + * Returns the probability that a value of `x_range` equals `lower_limit`, or lies between `lower_limit` + * and `upper_limit` inclusive. Returns #N/A when the ranges differ in size, and #NUM! when a probability + * is not in (0, 1] or the probabilities do not sum up to 1. Pairs in which either cell is not a number are skipped. + * + * @param ast - procedure AST node + * @param state - interpreter state + */ + public prob(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('PROB'), + (values: SimpleRangeValue, probabilities: SimpleRangeValue, lowerLimit: number, upperLimit?: number) => { + if (values.numberOfElements() !== probabilities.numberOfElements()) { + return new CellError(ErrorType.NA, ErrorMessage.EqualLength) + } + const pairs = parseTwoArrays(values, probabilities) + if (pairs instanceof CellError) { + return pairs + } + const [xs, ps] = pairs + const probabilitySum = ps.reduce((sum, p) => sum + p, 0) + if (ps.some(p => p <= 0 || p > 1) || Math.abs(probabilitySum - 1) > PROBABILITY_SUM_TOLERANCE) { + return new CellError(ErrorType.NUM, ErrorMessage.ProbabilityBounds) + } + const upper = upperLimit ?? lowerLimit + return ps.reduce((sum, p, i) => xs[i] >= lowerLimit && xs[i] <= upper ? sum + p : sum, 0) + }) + } + + /** + * Collects the numbers of function arguments, each being a scalar or a range: errors are propagated, + * everything that is not a number (text, booleans, empty cells) is skipped. + * + * @param args - evaluated arguments of a function + */ + private numbersOfArguments(args: RawInterpreterValue[]): number[] | CellError { + const numbers: number[] = [] + for (const arg of args) { + const found = this.arithmeticHelper.manyToExactNumbers( + arg instanceof SimpleRangeValue ? arg.valuesFromTopLeftCorner() : [arg] + ) + if (found instanceof CellError) { + return found + } + numbers.push(...found) + } + return numbers + } + public avedev(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('AVEDEV'), (...args: RawInterpreterValue[]) => { diff --git a/src/license/functionCapabilities.ts b/src/license/functionCapabilities.ts index a26733ec5f..8d03de64ce 100644 --- a/src/license/functionCapabilities.ts +++ b/src/license/functionCapabilities.ts @@ -84,15 +84,15 @@ const UNGROUPED_FUNCTIONS = [ 'DEC2BIN', 'DEC2OCT', 'DECIMAL', 'DEGREES', 'DELTA', 'DEVSQ', 'DGET', 'DMAX', 'DMIN', 'DOLLARDE', 'DOLLARFR', 'DPRODUCT', 'DSTDEV', 'DSTDEVP', 'DSUM', 'DVAR', 'DVARP', 'EFFECT', 'ERF', 'ERFC', 'EXPON.DIST', 'F.DIST', 'F.DIST.RT', 'F.INV', 'F.INV.RT', 'F.TEST', 'FACT', 'FACTDOUBLE', 'FISHER', - 'FISHERINV', 'FLOOR.MATH', 'FLOOR.PRECISE', 'FORMULATEXT', 'FVSCHEDULE', 'GAMMA', 'GAMMA.DIST', + 'FISHERINV', 'FLOOR.MATH', 'FLOOR.PRECISE', 'FORMULATEXT', 'FREQUENCY', 'FVSCHEDULE', 'GAMMA', 'GAMMA.DIST', 'GAMMA.INV', 'GAMMALN', 'GAUSS', 'GCD', 'GEOMEAN', 'HARMEAN', 'HEX2BIN', 'HEX2OCT', 'HYPGEOM.DIST', 'IMABS', 'IMAGINARY', 'IMARGUMENT', 'IMCONJUGATE', 'IMCOS', 'IMCOSH', 'IMCOT', 'IMCSC', 'IMCSCH', 'IMDIV', 'IMEXP', 'IMLN', 'IMLOG10', 'IMLOG2', 'IMPOWER', 'IMPRODUCT', 'IMREAL', 'IMSEC', 'IMSECH', 'IMSIN', 'IMSINH', 'IMSQRT', 'IMSUB', 'IMSUM', 'IMTAN', 'INTERVAL', 'ISBINARY', 'ISFORMULA', 'ISNONTEXT', 'ISPMT', 'ISREF', 'LCM', 'LOG10', 'LOGNORM.DIST', 'LOGNORM.INV', 'MAXA', 'MAXPOOL', 'MEDIANPOOL', 'MINA', 'MIRR', - 'MMULT', 'MULTINOMIAL', 'NEGBINOM.DIST', 'NETWORKDAYS.INTL', 'NOMINAL', 'NORM.DIST', 'NORM.INV', + 'MMULT', 'MODE.MULT', 'MULTINOMIAL', 'NEGBINOM.DIST', 'NETWORKDAYS.INTL', 'NOMINAL', 'NORM.DIST', 'NORM.INV', 'NORM.S.DIST', 'NORM.S.INV', 'NPER', 'OCT2BIN', 'OCT2DEC', 'OCT2HEX', 'PDURATION', 'PERCENTILE.EXC', - 'PHI', 'POISSON.DIST', 'QUARTILE.EXC', 'QUARTILE.INC', 'RADIANS', 'ROMAN', 'RRI', 'RSQ', 'SEC', 'SECH', + 'PERCENTRANK.INC', 'PHI', 'POISSON.DIST', 'PROB', 'QUARTILE.EXC', 'QUARTILE.INC', 'RADIANS', 'ROMAN', 'RRI', 'RSQ', 'SEC', 'SECH', 'SERIESSUM', 'SHEET', 'SHEETS', 'SINH', 'SKEW', 'SKEW.P', 'SLOPE', 'SPLIT', 'SQRTPI', 'STANDARDIZE', 'STEYX', 'SUMX2MY2', 'SUMX2PY2', 'SYD', 'T.DIST', 'T.DIST.2T', 'T.DIST.RT', 'T.INV', 'T.INV.2T', 'T.TEST', 'TANH', 'TBILLEQ', 'TBILLPRICE', 'TBILLYIELD', 'TDIST', 'TIMEVALUE', 'UNICODE', 'VARA', 'VARPA', From 5f8d07065980c3b1abe5356484e744a29fec2616 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 15 Oct 2026 07:21:35 +0000 Subject: [PATCH 2/7] Add PERCENTRANK.INC, PERCENTRANK, MODE.MULT, FREQUENCY and PROB functions Co-Authored-By: Claude Sonnet 5.5 --- src/i18n/languages/csCZ.ts | 2 +- src/i18n/languages/daDK.ts | 10 +++++----- src/i18n/languages/deDE.ts | 10 +++++----- src/i18n/languages/esES.ts | 10 +++++----- src/i18n/languages/fiFI.ts | 10 +++++----- src/i18n/languages/frFR.ts | 10 +++++----- src/i18n/languages/huHU.ts | 10 +++++----- src/i18n/languages/itIT.ts | 10 +++++----- src/i18n/languages/nbNO.ts | 10 +++++----- src/i18n/languages/nlNL.ts | 8 ++++---- src/i18n/languages/plPL.ts | 10 +++++----- src/i18n/languages/ptPT.ts | 8 ++++---- src/i18n/languages/ruRU.ts | 10 +++++----- src/i18n/languages/svSE.ts | 10 +++++----- src/i18n/languages/trTR.ts | 10 +++++----- 15 files changed, 69 insertions(+), 69 deletions(-) diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index cf46aff526..c3fc88f944 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -393,7 +393,7 @@ const dictionary: RawTranslationPackage = { PERCENTRANK: 'PERCENTRANK', 'PERCENTRANK.INC': 'PERCENTRANK.INC', 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', + FREQUENCY: 'ČETNOSTI', PROB: 'PROB', QUARTILE: 'QUARTIL', 'QUARTILE.INC': 'QUARTIL.INC', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 9641c44cca..bd8b38c50c 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'FRAKTIL', 'PERCENTILE.INC': 'FRAKTIL.MEDTAG', 'PERCENTILE.EXC': 'FRAKTIL.UDELAD', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'PROCENTPLADS', + 'PERCENTRANK.INC': 'PROCENTPLADS.MEDTAG', + 'MODE.MULT': 'HYPPIGST.FLERE', + FREQUENCY: 'FREKVENS', + PROB: 'SANDSYNLIGHED', QUARTILE: 'KVARTIL', 'QUARTILE.INC': 'KVARTIL.MEDTAG', 'QUARTILE.EXC': 'KVARTIL.UDELAD', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 4acebfa235..cdeedd2e0a 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'QUANTIL', 'PERCENTILE.INC': 'QUANTIL.INKL', 'PERCENTILE.EXC': 'QUANTIL.EXKL', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'QUANTILSRANG', + 'PERCENTRANK.INC': 'QUANTILSRANG.INKL', + 'MODE.MULT': 'MODUS.VIELF', + FREQUENCY: 'HÄUFIGKEIT', + PROB: 'WAHRSCHBEREICH', QUARTILE: 'QUARTILE', 'QUARTILE.INC': 'QUARTILE.INKL', 'QUARTILE.EXC': 'QUARTILE.EXKL', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 845c4fda4e..63c7381651 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -390,11 +390,11 @@ export const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIL', 'PERCENTILE.INC': 'PERCENTIL.INC', 'PERCENTILE.EXC': 'PERCENTIL.EXC', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'RANGO.PERCENTIL', + 'PERCENTRANK.INC': 'RANGO.PERCENTIL.INC', + 'MODE.MULT': 'MODA.VARIOS', + FREQUENCY: 'FRECUENCIA', + PROB: 'PROBABILIDAD', QUARTILE: 'CUARTIL', 'QUARTILE.INC': 'CUARTIL.INC', 'QUARTILE.EXC': 'CUARTIL.EXC', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index c156a51a55..48ee95d9d4 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PROSENTTIPISTE', 'PERCENTILE.INC': 'PROSENTTIPISTE.SIS', 'PERCENTILE.EXC': 'PROSENTTIPISTE.ULK', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'PROSENTTIJÄRJESTYS', + 'PERCENTRANK.INC': 'PROSENTTIJÄRJESTYS.SIS', + 'MODE.MULT': 'MOODI.USEA', + FREQUENCY: 'TAAJUUS', + PROB: 'TODENNÄKÖISYYS', QUARTILE: 'NELJÄNNES', 'QUARTILE.INC': 'NELJÄNNES.SIS', 'QUARTILE.EXC': 'NELJÄNNES.ULK', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 2e87f83368..f8dc72cc50 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'CENTILE', 'PERCENTILE.INC': 'CENTILE.INCLURE', 'PERCENTILE.EXC': 'CENTILE.EXCLURE', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'RANG.POURCENTAGE', + 'PERCENTRANK.INC': 'RANG.POURCENTAGE.INCLURE', + 'MODE.MULT': 'MODE.MULTIPLE', + FREQUENCY: 'FREQUENCE', + PROB: 'PROBABILITE', QUARTILE: 'QUARTILE', 'QUARTILE.INC': 'QUARTILE.INCLURE', 'QUARTILE.EXC': 'QUARTILE.EXCLURE', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index fba9f81ade..9d37be66cf 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTILIS', 'PERCENTILE.INC': 'PERCENTILIS.TARTALMAZ', 'PERCENTILE.EXC': 'PERCENTILIS.KIZÁR', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'SZÁZALÉKRANG', + 'PERCENTRANK.INC': 'SZÁZALÉKRANG.TARTALMAZ', + 'MODE.MULT': 'MÓDUSZ.TÖBB', + FREQUENCY: 'GYAKORISÁG', + PROB: 'VALÓSZÍNŰSÉG', QUARTILE: 'KVARTILIS', 'QUARTILE.INC': 'KVARTILIS.TARTALMAZ', 'QUARTILE.EXC': 'KVARTILIS.KIZÁR', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 292c2d944c..90924f60f8 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTILE', 'PERCENTILE.INC': 'INC.PERCENTILE', 'PERCENTILE.EXC': 'ESC.PERCENTILE', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'PERCENT.RANGO', + 'PERCENTRANK.INC': 'INC.PERCENT.RANGO', + 'MODE.MULT': 'MODA.MULT', + FREQUENCY: 'FREQUENZA', + PROB: 'PROBABILITÀ', QUARTILE: 'QUARTILE', 'QUARTILE.INC': 'INC.QUARTILE', 'QUARTILE.EXC': 'ESC.QUARTILE', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 6c75cdb71b..0ec9cad16a 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERSENTIL', 'PERCENTILE.INC': 'PERSENTIL.INK', 'PERCENTILE.EXC': 'PERSENTIL.EKS', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'PROSENTDEL', + 'PERCENTRANK.INC': 'PROSENTDEL.INK', + 'MODE.MULT': 'MODUS.MULT', + FREQUENCY: 'FREKVENS', + PROB: 'SANNSYNLIG', QUARTILE: 'KVARTIL', 'QUARTILE.INC': 'KVARTIL.INK', 'QUARTILE.EXC': 'KVARTIL.EKS', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 066cc06e1c..e89583e4e1 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIEL', 'PERCENTILE.INC': 'PERCENTIEL.INC', 'PERCENTILE.EXC': 'PERCENTIEL.EXC', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', + PERCENTRANK: 'PERCENT.RANG', + 'PERCENTRANK.INC': 'PROCENTRANG.INC', + 'MODE.MULT': 'MODUS.MEERV', FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PROB: 'KANS', QUARTILE: 'KWARTIEL', 'QUARTILE.INC': 'KWARTIEL.INC', 'QUARTILE.EXC': 'KWARTIEL.EXC', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 71dfc156c9..5a8cf01e6c 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTYL', 'PERCENTILE.INC': 'PERCENTYL.PRZEDZ.ZAMK', 'PERCENTILE.EXC': 'PERCENTYL.PRZEDZ.OTW', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'PROCENT.POZYCJA', + 'PERCENTRANK.INC': 'PROC.POZ.PRZEDZ.ZAMK', + 'MODE.MULT': 'WYST.NAJCZĘŚCIEJ.TABL', + FREQUENCY: 'CZĘSTOŚĆ', + PROB: 'PRAWDPD', QUARTILE: 'KWARTYL', 'QUARTILE.INC': 'KWARTYL.PRZEDZ.ZAMK', 'QUARTILE.EXC': 'KWARTYL.PRZEDZ.OTW', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index bbe9f90257..c15e53e929 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -390,10 +390,10 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIL', 'PERCENTILE.INC': 'PERCENTIL.INC', 'PERCENTILE.EXC': 'PERCENTIL.EXC', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', + PERCENTRANK: 'ORDEM.PERCENTUAL', + 'PERCENTRANK.INC': 'ORDEM.PERCENTUAL.INC', + 'MODE.MULT': 'MODO.MÚLT', + FREQUENCY: 'FREQUÊNCIA', PROB: 'PROB', QUARTILE: 'QUARTIL', 'QUARTILE.INC': 'QUARTIL.INC', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 81efaee0b6..17487b0067 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'ПЕРСЕНТИЛЬ', 'PERCENTILE.INC': 'ПРОЦЕНТИЛЬ.ВКЛ', 'PERCENTILE.EXC': 'ПРОЦЕНТИЛЬ.ИСКЛ', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'ПРОЦЕНТРАНГ', + 'PERCENTRANK.INC': 'ПРОЦЕНТРАНГ.ВКЛ', + 'MODE.MULT': 'МОДА.НСК', + FREQUENCY: 'ЧАСТОТА', + PROB: 'ВЕРОЯТНОСТЬ', QUARTILE: 'КВАРТИЛЬ', 'QUARTILE.INC': 'КВАРТИЛЬ.ВКЛ', 'QUARTILE.EXC': 'КВАРТИЛЬ.ИСКЛ', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 8beadc6ff6..8005eb0943 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIL', 'PERCENTILE.INC': 'PERCENTIL.INK', 'PERCENTILE.EXC': 'PERCENTIL.EXK', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'PROCENTRANG', + 'PERCENTRANK.INC': 'PROCENTRANG.INK', + 'MODE.MULT': 'TYPVÄRDE.FLERA', + FREQUENCY: 'FREKVENS', + PROB: 'SANNOLIKHET', QUARTILE: 'KVARTIL', 'QUARTILE.INC': 'KVARTIL.INK', 'QUARTILE.EXC': 'KVARTIL.EXK', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index f4ea170bdc..c8521f48fb 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -390,11 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'YÜZDEBİRLİK', 'PERCENTILE.INC': 'YÜZDEBİRLİK.DHL', 'PERCENTILE.EXC': 'YÜZDEBİRLİK.HRC', - PERCENTRANK: 'PERCENTRANK', - 'PERCENTRANK.INC': 'PERCENTRANK.INC', - 'MODE.MULT': 'MODE.MULT', - FREQUENCY: 'FREQUENCY', - PROB: 'PROB', + PERCENTRANK: 'YÜZDERANK', + 'PERCENTRANK.INC': 'YÜZDERANK.DHL', + 'MODE.MULT': 'ENÇOK_OLAN.ÇOK', + FREQUENCY: 'SIKLIK', + PROB: 'OLASILIK', QUARTILE: 'DÖRTTEBİRLİK', 'QUARTILE.INC': 'DÖRTTEBİRLİK.DHL', 'QUARTILE.EXC': 'DÖRTTEBİRLİK.HRC', From d102bd3434584a57b0981164a049f28f4e4fc145 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 15 Oct 2026 07:25:24 +0000 Subject: [PATCH 3/7] docs: link the pull request in the changelog Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f193880fa..0d525fa965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added -- Added the `PERCENTRANK.INC` (with its `PERCENTRANK` alias), `MODE.MULT`, `FREQUENCY` and `PROB` statistical functions. +- Added the `PERCENTRANK.INC` (with its `PERCENTRANK` alias), `MODE.MULT`, `FREQUENCY` and `PROB` statistical functions. [#1818](https://github.com/handsontable/hyperformula/pull/1818) ### Fixed From 2d8f79013468fb17735885f59edca4f333af31ac Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:18:33 +0000 Subject: [PATCH 4/7] HF-424: Count MODE.MULT values in a loop so a large input cannot exhaust the stack Spreading every value into Math.max and Array.push threw a RangeError for about 200000 values. Also documents that MODE.MULT and FREQUENCY reserve their result area before evaluation, and restores the spacing of an unrelated error message. Co-Authored-By: Claude Opus 5.5 --- docs/guide/known-limitations.md | 1 + hyperformula/src/error-message.ts | 2 +- .../plugin/StatisticalAggregationPlugin.ts | 12 +++++++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index d78cac018d..2352773330 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -46,6 +46,7 @@ a circular reference. * The INDEX function doesn't support returning whole rows or columns of the source range – it always returns the contents of a single cell. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. +* `MODE.MULT` and `FREQUENCY` reserve their result area before evaluation, from the size of their arguments: `MODE.MULT` one row for every two values passed to it, `FREQUENCY` one row per cell of `bins_array` plus one. Rows the result does not fill stay empty, and a value in any reserved cell makes the formula return `#SPILL!`. * The TEXT function does not accept embedded double-quote literals in the format string. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — e.g. `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. If your application requires this escape sequence, supply a custom [`stringifyCurrency`](currency-handling.md) callback. ### UNIQUE function diff --git a/hyperformula/src/error-message.ts b/hyperformula/src/error-message.ts index 17d01a33ce..b97b17506a 100644 --- a/hyperformula/src/error-message.ts +++ b/hyperformula/src/error-message.ts @@ -30,7 +30,7 @@ export class ErrorMessage { public static WrongType = 'Wrong type of argument.' public static NaN = 'NaN or infinite value encountered.' public static ProbabilityBounds = 'Probabilities need to be greater than 0, at most 1, and sum up to 1.' - public static EqualLength ='Ranges need to be of equal length.' + public static EqualLength = 'Ranges need to be of equal length.' public static Negative = 'Value cannot be negative.' public static NotBinary = 'String does not represent a binary number.' public static NotOctal = 'String does not represent an octal number.' diff --git a/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts b/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts index e73daafb79..802ce3b08f 100644 --- a/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts +++ b/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts @@ -218,8 +218,12 @@ export class StatisticalAggregationPlugin extends FunctionPlugin implements Func return values } const occurrences = new Map() - values.forEach(value => occurrences.set(value, (occurrences.get(value) ?? 0) + 1)) - const highestCount = Math.max(...occurrences.values()) + let highestCount = 0 + for (const value of values) { + const count = (occurrences.get(value) ?? 0) + 1 + occurrences.set(value, count) + highestCount = Math.max(highestCount, count) + } if (highestCount < 2) { return new CellError(ErrorType.NA, ErrorMessage.ValueNotFound) } @@ -347,7 +351,9 @@ export class StatisticalAggregationPlugin extends FunctionPlugin implements Func if (found instanceof CellError) { return found } - numbers.push(...found) + for (const value of found) { + numbers.push(value) + } } return numbers } From 65edf3fdd96cbfbec64bea6aa2d7d35d5f7331cb Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 25 Oct 2026 02:00:49 +0000 Subject: [PATCH 5/7] HF-428: Accept PROB probabilities only when their sum is 1 at 15 significant digits The 1e-9 tolerance accepted sums that are off by up to 1e-9, which Excel rejects with #NUM!. Rounding the sum to 15 significant digits still accepts floating-point sums such as 0.1 added ten times. Co-Authored-By: Claude Opus 5.5 --- .../interpreter/plugin/StatisticalAggregationPlugin.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts b/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts index 802ce3b08f..90f16d42e9 100644 --- a/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts +++ b/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts @@ -37,10 +37,11 @@ import { import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' /** - * How far the probabilities of PROB may be from summing up to exactly 1; absorbs the rounding of the - * binary floating-point sum (0.4 + 0.3 + 0.2 + 0.1 is 0.9999999999999999). + * PROB accepts its probabilities when their sum, rounded to this many significant digits, is exactly 1. + * The rounding absorbs the error of the binary floating-point sum (0.1 added ten times is 0.9999999999999999), + * while a sum that is off by 1e-14 or more is rejected. */ -const PROBABILITY_SUM_TOLERANCE = 1e-9 +const PROBABILITY_SUM_SIGNIFICANT_DIGITS = 15 export class StatisticalAggregationPlugin extends FunctionPlugin implements FunctionPluginTypecheck { public static implementedFunctions: ImplementedFunctions = { @@ -328,7 +329,7 @@ export class StatisticalAggregationPlugin extends FunctionPlugin implements Func } const [xs, ps] = pairs const probabilitySum = ps.reduce((sum, p) => sum + p, 0) - if (ps.some(p => p <= 0 || p > 1) || Math.abs(probabilitySum - 1) > PROBABILITY_SUM_TOLERANCE) { + if (ps.some(p => p <= 0 || p > 1) || Number(probabilitySum.toPrecision(PROBABILITY_SUM_SIGNIFICANT_DIGITS)) !== 1) { return new CellError(ErrorType.NUM, ErrorMessage.ProbabilityBounds) } const upper = upperLimit ?? lowerLimit From 9b3f181c0201df6331b98131016430dffba0f1ce Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 25 Oct 2026 02:01:12 +0000 Subject: [PATCH 6/7] HF-424: State the exact size of the result area MODE.MULT reserves Co-Authored-By: Claude Opus 5.5 --- docs/guide/known-limitations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 2352773330..7e6aaef36f 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -46,7 +46,7 @@ a circular reference. * The INDEX function doesn't support returning whole rows or columns of the source range – it always returns the contents of a single cell. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. -* `MODE.MULT` and `FREQUENCY` reserve their result area before evaluation, from the size of their arguments: `MODE.MULT` one row for every two values passed to it, `FREQUENCY` one row per cell of `bins_array` plus one. Rows the result does not fill stay empty, and a value in any reserved cell makes the formula return `#SPILL!`. +* `MODE.MULT` and `FREQUENCY` reserve their result area before evaluation, from the size of their arguments: `MODE.MULT` half as many rows as values passed to it, rounded down, and at least one, `FREQUENCY` one row per cell of `bins_array` plus one. Rows the result does not fill stay empty, and a value in any reserved cell makes the formula return `#SPILL!`. * The TEXT function does not accept embedded double-quote literals in the format string. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — e.g. `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. If your application requires this escape sequence, supply a custom [`stringifyCurrency`](currency-handling.md) callback. ### UNIQUE function From 94872763cd9ab88a3d838a847d3cb512971ed5ec Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 25 Oct 2026 02:31:21 +0000 Subject: [PATCH 7/7] HF-428: Reject PROB probability sums below 1 and add the probabilities with a compensated sum Rounding the plain sum to 15 significant digits accepted 0.9999999999999995, which Excel rejects. The sum is now exact to double-double precision (0.1 added ten times is 1), must be at least 1, and must be 1 when rounded to 15 significant digits. Co-Authored-By: Claude Opus 5.5 --- .../plugin/StatisticalAggregationPlugin.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts b/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts index 90f16d42e9..51d4553c48 100644 --- a/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts +++ b/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts @@ -30,6 +30,8 @@ import { } from './3rdparty/jstat/jstat' import {covariance, regressionSums, RegressionSums, sumOfSquaredDeviations} from '../deviationSums' import { + addDoubleDouble, + DOUBLE_DOUBLE_ZERO, divideDoubleDoubles, multiplyByTwoToThe, roundDoubleDouble, @@ -37,9 +39,9 @@ import { import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' /** - * PROB accepts its probabilities when their sum, rounded to this many significant digits, is exactly 1. - * The rounding absorbs the error of the binary floating-point sum (0.1 added ten times is 0.9999999999999999), - * while a sum that is off by 1e-14 or more is rejected. + * PROB accepts its probabilities when their sum is at least 1 and, rounded to this many significant digits, is 1. + * The sum is compensated (double-double), so 0.1 added ten times is exactly 1. A sum below 1 is rejected + * however close it is, and a sum above 1 is accepted while it is less than 1 + 5e-15. */ const PROBABILITY_SUM_SIGNIFICANT_DIGITS = 15 @@ -328,8 +330,9 @@ export class StatisticalAggregationPlugin extends FunctionPlugin implements Func return pairs } const [xs, ps] = pairs - const probabilitySum = ps.reduce((sum, p) => sum + p, 0) - if (ps.some(p => p <= 0 || p > 1) || Number(probabilitySum.toPrecision(PROBABILITY_SUM_SIGNIFICANT_DIGITS)) !== 1) { + const probabilitySum = roundDoubleDouble(ps.reduce((sum, p) => addDoubleDouble(sum, {hi: p, lo: 0}), DOUBLE_DOUBLE_ZERO)) + const sumIsOne = probabilitySum >= 1 && Number(probabilitySum.toPrecision(PROBABILITY_SUM_SIGNIFICANT_DIGITS)) === 1 + if (ps.some(p => p <= 0 || p > 1) || !sumIsOne) { return new CellError(ErrorType.NUM, ErrorMessage.ProbabilityBounds) } const upper = upperLimit ?? lowerLimit