diff --git a/CHANGELOG.md b/CHANGELOG.md index 323c746b2d..dbc3fee31b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Added a message after `npm i hyperformula` that points AI assistants to the SKILLS file. The message needs `--foreground-scripts` to be visible in npm, and pnpm 10 needs the `postinstall` script to be approved first. [#1829](https://github.com/handsontable/hyperformula/pull/1829) +- Added the `PERCENTRANK.INC` (with its `PERCENTRANK` alias), `MODE.MULT`, `FREQUENCY` and `PROB` statistical functions. [#1818](https://github.com/handsontable/hyperformula/pull/1818) ### Fixed diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index d78cac018d..7e6aaef36f 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` 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 diff --git a/hyperformula/src/error-message.ts b/hyperformula/src/error-message.ts index d42040901c..b97b17506a 100644 --- a/hyperformula/src/error-message.ts +++ b/hyperformula/src/error-message.ts @@ -29,6 +29,7 @@ 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 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.' diff --git a/hyperformula/src/i18n/languages/csCZ.ts b/hyperformula/src/i18n/languages/csCZ.ts index fea286083c..c3fc88f944 100644 --- a/hyperformula/src/i18n/languages/csCZ.ts +++ b/hyperformula/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: 'ČETNOSTI', + PROB: 'PROB', QUARTILE: 'QUARTIL', 'QUARTILE.INC': 'QUARTIL.INC', 'QUARTILE.EXC': 'QUARTIL.EXC', diff --git a/hyperformula/src/i18n/languages/daDK.ts b/hyperformula/src/i18n/languages/daDK.ts index 9264551249..bd8b38c50c 100644 --- a/hyperformula/src/i18n/languages/daDK.ts +++ b/hyperformula/src/i18n/languages/daDK.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'FRAKTIL', 'PERCENTILE.INC': 'FRAKTIL.MEDTAG', 'PERCENTILE.EXC': 'FRAKTIL.UDELAD', + 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/hyperformula/src/i18n/languages/deDE.ts b/hyperformula/src/i18n/languages/deDE.ts index 4d3d39319b..cdeedd2e0a 100644 --- a/hyperformula/src/i18n/languages/deDE.ts +++ b/hyperformula/src/i18n/languages/deDE.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'QUANTIL', 'PERCENTILE.INC': 'QUANTIL.INKL', 'PERCENTILE.EXC': 'QUANTIL.EXKL', + 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/hyperformula/src/i18n/languages/enGB.ts b/hyperformula/src/i18n/languages/enGB.ts index d271b732cd..684bbe1633 100644 --- a/hyperformula/src/i18n/languages/enGB.ts +++ b/hyperformula/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/hyperformula/src/i18n/languages/esES.ts b/hyperformula/src/i18n/languages/esES.ts index 9c6b817b98..63c7381651 100644 --- a/hyperformula/src/i18n/languages/esES.ts +++ b/hyperformula/src/i18n/languages/esES.ts @@ -390,6 +390,11 @@ export const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIL', 'PERCENTILE.INC': 'PERCENTIL.INC', 'PERCENTILE.EXC': 'PERCENTIL.EXC', + 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/hyperformula/src/i18n/languages/fiFI.ts b/hyperformula/src/i18n/languages/fiFI.ts index 99ff05708f..48ee95d9d4 100644 --- a/hyperformula/src/i18n/languages/fiFI.ts +++ b/hyperformula/src/i18n/languages/fiFI.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PROSENTTIPISTE', 'PERCENTILE.INC': 'PROSENTTIPISTE.SIS', 'PERCENTILE.EXC': 'PROSENTTIPISTE.ULK', + 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/hyperformula/src/i18n/languages/frFR.ts b/hyperformula/src/i18n/languages/frFR.ts index f7b9be45f7..f8dc72cc50 100644 --- a/hyperformula/src/i18n/languages/frFR.ts +++ b/hyperformula/src/i18n/languages/frFR.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'CENTILE', 'PERCENTILE.INC': 'CENTILE.INCLURE', 'PERCENTILE.EXC': 'CENTILE.EXCLURE', + 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/hyperformula/src/i18n/languages/huHU.ts b/hyperformula/src/i18n/languages/huHU.ts index e40219553a..9d37be66cf 100644 --- a/hyperformula/src/i18n/languages/huHU.ts +++ b/hyperformula/src/i18n/languages/huHU.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTILIS', 'PERCENTILE.INC': 'PERCENTILIS.TARTALMAZ', 'PERCENTILE.EXC': 'PERCENTILIS.KIZÁR', + 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/hyperformula/src/i18n/languages/idID.ts b/hyperformula/src/i18n/languages/idID.ts index 719cb11db6..a7777ca592 100644 --- a/hyperformula/src/i18n/languages/idID.ts +++ b/hyperformula/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/hyperformula/src/i18n/languages/itIT.ts b/hyperformula/src/i18n/languages/itIT.ts index 1a494628f8..90924f60f8 100644 --- a/hyperformula/src/i18n/languages/itIT.ts +++ b/hyperformula/src/i18n/languages/itIT.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTILE', 'PERCENTILE.INC': 'INC.PERCENTILE', 'PERCENTILE.EXC': 'ESC.PERCENTILE', + 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/hyperformula/src/i18n/languages/nbNO.ts b/hyperformula/src/i18n/languages/nbNO.ts index ff15415a98..0ec9cad16a 100644 --- a/hyperformula/src/i18n/languages/nbNO.ts +++ b/hyperformula/src/i18n/languages/nbNO.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERSENTIL', 'PERCENTILE.INC': 'PERSENTIL.INK', 'PERCENTILE.EXC': 'PERSENTIL.EKS', + 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/hyperformula/src/i18n/languages/nlNL.ts b/hyperformula/src/i18n/languages/nlNL.ts index c491e2f44b..e89583e4e1 100644 --- a/hyperformula/src/i18n/languages/nlNL.ts +++ b/hyperformula/src/i18n/languages/nlNL.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIEL', 'PERCENTILE.INC': 'PERCENTIEL.INC', 'PERCENTILE.EXC': 'PERCENTIEL.EXC', + PERCENTRANK: 'PERCENT.RANG', + 'PERCENTRANK.INC': 'PROCENTRANG.INC', + 'MODE.MULT': 'MODUS.MEERV', + FREQUENCY: 'FREQUENCY', + PROB: 'KANS', QUARTILE: 'KWARTIEL', 'QUARTILE.INC': 'KWARTIEL.INC', 'QUARTILE.EXC': 'KWARTIEL.EXC', diff --git a/hyperformula/src/i18n/languages/plPL.ts b/hyperformula/src/i18n/languages/plPL.ts index 6fa4f016b4..5a8cf01e6c 100644 --- a/hyperformula/src/i18n/languages/plPL.ts +++ b/hyperformula/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: '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/hyperformula/src/i18n/languages/ptPT.ts b/hyperformula/src/i18n/languages/ptPT.ts index 6c1e68c328..c15e53e929 100644 --- a/hyperformula/src/i18n/languages/ptPT.ts +++ b/hyperformula/src/i18n/languages/ptPT.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIL', 'PERCENTILE.INC': 'PERCENTIL.INC', 'PERCENTILE.EXC': 'PERCENTIL.EXC', + PERCENTRANK: 'ORDEM.PERCENTUAL', + 'PERCENTRANK.INC': 'ORDEM.PERCENTUAL.INC', + 'MODE.MULT': 'MODO.MÚLT', + FREQUENCY: 'FREQUÊNCIA', + PROB: 'PROB', QUARTILE: 'QUARTIL', 'QUARTILE.INC': 'QUARTIL.INC', 'QUARTILE.EXC': 'QUARTIL.EXC', diff --git a/hyperformula/src/i18n/languages/ruRU.ts b/hyperformula/src/i18n/languages/ruRU.ts index 6b35c1ce2f..17487b0067 100644 --- a/hyperformula/src/i18n/languages/ruRU.ts +++ b/hyperformula/src/i18n/languages/ruRU.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'ПЕРСЕНТИЛЬ', 'PERCENTILE.INC': 'ПРОЦЕНТИЛЬ.ВКЛ', 'PERCENTILE.EXC': 'ПРОЦЕНТИЛЬ.ИСКЛ', + PERCENTRANK: 'ПРОЦЕНТРАНГ', + 'PERCENTRANK.INC': 'ПРОЦЕНТРАНГ.ВКЛ', + 'MODE.MULT': 'МОДА.НСК', + FREQUENCY: 'ЧАСТОТА', + PROB: 'ВЕРОЯТНОСТЬ', QUARTILE: 'КВАРТИЛЬ', 'QUARTILE.INC': 'КВАРТИЛЬ.ВКЛ', 'QUARTILE.EXC': 'КВАРТИЛЬ.ИСКЛ', diff --git a/hyperformula/src/i18n/languages/svSE.ts b/hyperformula/src/i18n/languages/svSE.ts index 5f87580087..8005eb0943 100644 --- a/hyperformula/src/i18n/languages/svSE.ts +++ b/hyperformula/src/i18n/languages/svSE.ts @@ -390,6 +390,11 @@ const dictionary: RawTranslationPackage = { PERCENTILE: 'PERCENTIL', 'PERCENTILE.INC': 'PERCENTIL.INK', 'PERCENTILE.EXC': 'PERCENTIL.EXK', + 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/hyperformula/src/i18n/languages/trTR.ts b/hyperformula/src/i18n/languages/trTR.ts index 93c2176d37..c8521f48fb 100644 --- a/hyperformula/src/i18n/languages/trTR.ts +++ b/hyperformula/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: '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', diff --git a/hyperformula/src/interpreter/functionMetadata/categories/statistical.ts b/hyperformula/src/interpreter/functionMetadata/categories/statistical.ts index 7a536e4cd2..035204e999 100644 --- a/hyperformula/src/interpreter/functionMetadata/categories/statistical.ts +++ b/hyperformula/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/hyperformula/src/interpreter/plugin/PercentilePlugin.ts b/hyperformula/src/interpreter/plugin/PercentilePlugin.ts index a09cd2c5c9..4a3c00631e 100644 --- a/hyperformula/src/interpreter/plugin/PercentilePlugin.ts +++ b/hyperformula/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/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts b/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts index c363bc1351..51d4553c48 100644 --- a/hyperformula/src/interpreter/plugin/StatisticalAggregationPlugin.ts +++ b/hyperformula/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' @@ -29,14 +30,52 @@ import { } from './3rdparty/jstat/jstat' import {covariance, regressionSums, RegressionSums, sumOfSquaredDeviations} from '../deviationSums' import { + addDoubleDouble, + DOUBLE_DOUBLE_ZERO, divideDoubleDoubles, multiplyByTwoToThe, roundDoubleDouble, } from '../doubleDouble' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' +/** + * 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 + 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 +204,164 @@ 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() + 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) + } + 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 = 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 + 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 + } + for (const value of found) { + numbers.push(value) + } + } + return numbers + } + public avedev(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('AVEDEV'), (...args: RawInterpreterValue[]) => { diff --git a/hyperformula/src/license/functionCapabilities.ts b/hyperformula/src/license/functionCapabilities.ts index a26733ec5f..8d03de64ce 100644 --- a/hyperformula/src/license/functionCapabilities.ts +++ b/hyperformula/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',