From 94b78126023b47f60b365c893e753ff6bb92a071 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 15 Oct 2026 04:53:42 +0000 Subject: [PATCH 1/9] Add MUNIT, MDETERM and MINVERSE functions Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 + docs/guide/list-of-differences.md | 1 + src/error-message.ts | 1 + src/i18n/languages/csCZ.ts | 3 + src/i18n/languages/daDK.ts | 3 + src/i18n/languages/deDE.ts | 3 + src/i18n/languages/enGB.ts | 3 + src/i18n/languages/esES.ts | 3 + src/i18n/languages/fiFI.ts | 3 + src/i18n/languages/frFR.ts | 3 + src/i18n/languages/huHU.ts | 3 + src/i18n/languages/idID.ts | 3 + src/i18n/languages/itIT.ts | 3 + src/i18n/languages/nbNO.ts | 3 + src/i18n/languages/nlNL.ts | 3 + src/i18n/languages/plPL.ts | 3 + src/i18n/languages/ptPT.ts | 3 + src/i18n/languages/ruRU.ts | 3 + src/i18n/languages/svSE.ts | 3 + src/i18n/languages/trTR.ts | 3 + .../categories/matrix-functions.ts | 21 ++ src/interpreter/plugin/MatrixPlugin.ts | 221 +++++++++++++++++- src/license/functionCapabilities.ts | 2 +- 23 files changed, 299 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f97c660139..5b1703a86a 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 `MUNIT`, `MDETERM` and `MINVERSE` matrix functions: the identity matrix, the determinant and the inverse of a square matrix. + ### 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/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 660fda0f98..7ce5fc6f82 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -127,4 +127,5 @@ A few of the rows above share a root cause worth stating once: - **Rounding toward zero, not down.** `INT` discards the fractional part rather than rounding toward negative infinity, so it differs from Excel and Google Sheets for negative input only. `ROUNDDOWN`/`ROUNDUP` are unaffected — they are defined in terms of zero in all three. - **`ISEVEN`/`ISODD` do not truncate.** They test the remainder of the value as given, so a value with a fractional part returns `FALSE` from *both*. Excel and Google Sheets truncate to an integer first, so exactly one of the two is always `TRUE`. +- **`MDETERM`/`MINVERSE` eliminate exactly.** The determinant is computed without division by pivots, so a singular matrix of integers gives exactly `0` and `MINVERSE` returns `#NUM!`. Excel returns rounding noise for such a matrix (`MDETERM({1,2,3;4,5,6;7,8,9})` is `6.66E-16` there). - **`CEILING.MATH`/`FLOOR.MATH` honour only `mode` = 1.** Excel and Google Sheets switch the negative-number rounding direction for any non-zero `mode`. diff --git a/src/error-message.ts b/src/error-message.ts index d42040901c..749e4c2f05 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -13,6 +13,7 @@ export class ErrorMessage { public static EmptyArray = 'Empty array not allowed.' public static ArrayDimensions = 'Array dimensions are not compatible.' public static PoolDimensions = 'Range dimensions are not compatible with the window size and the stride.' + public static SingularMatrix = 'Matrix is singular.' public static NoSpaceForArrayResult = 'No space for array result.' public static ValueSmall = 'Value too small.' public static ValueLarge = 'Value too large.' diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index fea286083c..62eba54e28 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MINIFS', MINUTE: 'MINUTA', MIRR: 'MOD.MÍRA.VÝNOSNOSTI', + MDETERM: 'DETERMINANT', + MINVERSE: 'INVERZE', MMULT: 'SOUČIN.MATIC', + MUNIT: 'MUNIT', MOD: 'MOD', MONTH: 'MĚSÍC', N: 'N', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 9264551249..e53a38e182 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MINHVISER', MINUTE: 'MINUT', MIRR: 'MIA', + MDETERM: 'MDETERM', + MINVERSE: 'MINVERT', MMULT: 'MPRODUKT', + MUNIT: 'MENHED', MOD: 'REST', MONTH: 'MÅNED', N: 'TAL', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 4d3d39319b..7c7156f471 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MINWENNS', MINUTE: 'MINUTE', MIRR: 'QIKV', + MDETERM: 'MDET', + MINVERSE: 'MINV', MMULT: 'MMULT', + MUNIT: 'MEINHEIT', MOD: 'REST', MONTH: 'MONAT', N: 'N', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index d271b732cd..8e1d290288 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -161,7 +161,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MINIFS', MINUTE: 'MINUTE', MIRR: 'MIRR', + MDETERM: 'MDETERM', + MINVERSE: 'MINVERSE', MMULT: 'MMULT', + MUNIT: 'MUNIT', MOD: 'MOD', MONTH: 'MONTH', N: 'N', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 9c6b817b98..20f64ec0a7 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -160,7 +160,10 @@ export const dictionary: RawTranslationPackage = { MINIFS: 'MIN.SI.CONJUNTO', MINUTE: 'MINUTO', MIRR: 'TIRM', + MDETERM: 'MDETERM', + MINVERSE: 'MINVERSA', MMULT: 'MMULT', + MUNIT: 'MUNIT', MOD: 'RESIDUO', MONTH: 'MES', N: 'N', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 99ff05708f..36a1a79f56 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MIN.JOS', MINUTE: 'MINUUTIT', MIRR: 'MSISÄINEN', + MDETERM: 'MDETERM', + MINVERSE: 'MKÄÄNTEINEN', MMULT: 'MKERRO', + MUNIT: 'YKSIKKÖM', MOD: 'JAKOJ', MONTH: 'KUUKAUSI', N: 'N', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index f7b9be45f7..46a129b80f 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MIN.SI', MINUTE: 'MINUTE', MIRR: 'TRIM', + MDETERM: 'DETERMAT', + MINVERSE: 'INVERSEMAT', MMULT: 'PRODUITMAT', + MUNIT: 'MATRICE.UNITAIRE', MOD: 'MOD', MONTH: 'MOIS', N: 'N', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index e40219553a..c6814cbcca 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MINHA', MINUTE: 'PERCEK', MIRR: 'MEGTÉRÜLÉS', + MDETERM: 'MDETERM', + MINVERSE: 'INVERZ.MÁTRIX', MMULT: 'MSZORZAT', + MUNIT: 'MMÁTRIX', MOD: 'MARADÉK', MONTH: 'HÓNAP', N: 'S', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 719cb11db6..f60293e89b 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -161,7 +161,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MIN.JIKAS', MINUTE: 'MENIT', MIRR: 'MIRR', + MDETERM: 'MDETERM', + MINVERSE: 'MINVERSE', MMULT: 'MMULT', + MUNIT: 'MUNIT', MOD: 'MOD', MONTH: 'BULAN', N: 'N', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 1a494628f8..5e54513bc1 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MIN.PIÙ.SE', MINUTE: 'MINUTO', MIRR: 'TIR.VAR', + MDETERM: 'MATR.DETERM', + MINVERSE: 'MATR.INVERSA', MMULT: 'MATR.PRODOTTO', + MUNIT: 'MATR.UNIT', MOD: 'RESTO', MONTH: 'MESE', N: 'NUM', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index ff15415a98..483d8d0e17 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MIN.HVIS.SETT', MINUTE: 'MINUTT', MIRR: 'MODIR', + MDETERM: 'MDETERM', + MINVERSE: 'MINVERS', MMULT: 'MMULT', + MUNIT: 'MENHET', MOD: 'REST', MONTH: 'MÅNED', N: 'N', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index c491e2f44b..a74768736f 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MIN.ALS.VOORWAARDEN', MINUTE: 'MINUUT', MIRR: 'GIR', + MDETERM: 'DETERMINANTMAT', + MINVERSE: 'INVERSEMAT', MMULT: 'PRODUCTMAT', + MUNIT: 'EENHEIDMAT', MOD: 'REST', MONTH: 'MAAND', N: 'N', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 6fa4f016b4..4eac2fd86b 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MIN.WARUNKÓW', MINUTE: 'MINUTA', MIRR: 'MIRR', + MDETERM: 'WYZNACZNIK.MACIERZY', + MINVERSE: 'MACIERZ.ODW', MMULT: 'MACIERZ.ILOCZYN', + MUNIT: 'MACIERZ.JEDNOSTKOWA', MOD: 'MOD', MONTH: 'MIESIĄC', N: 'N', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 6c1e68c328..cf1e979641 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MÍNIMOSES', MINUTE: 'MINUTO', MIRR: 'MTIR', + MDETERM: 'MATRIZ.DETERM', + MINVERSE: 'MATRIZ.INVERSA', MMULT: 'MATRIZ.MULT', + MUNIT: 'UNIDM', MOD: 'MOD', MONTH: 'MÊS', N: 'N', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 6b35c1ce2f..33513b3444 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'МИНЕСЛИ', MINUTE: 'МИНУТЫ', MIRR: 'МВСД', + MDETERM: 'МОПРЕД', + MINVERSE: 'МОБР', MMULT: 'МУМНОЖ', + MUNIT: 'ЕДМАТРИЦА', MOD: 'ОСТАТ', MONTH: 'МЕСЯЦ', N: 'Ч', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 5f87580087..1b04693b78 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'MINIFS', MINUTE: 'MINUT', MIRR: 'MODIR', + MDETERM: 'MDETERM', + MINVERSE: 'MINVERT', MMULT: 'MMULT', + MUNIT: 'MENHET', MOD: 'REST', MONTH: 'MÅNAD', N: 'N', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 93c2176d37..d507c2ee63 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -160,7 +160,10 @@ const dictionary: RawTranslationPackage = { MINIFS: 'ÇOKEĞERMİN', MINUTE: 'DAKİKA', MIRR: 'D_İÇ_VERİM_ORANI', + MDETERM: 'DETERMİNANT', + MINVERSE: 'DİZEY_TERS', MMULT: 'DÇARP', + MUNIT: 'BİRİMMATRİS', MOD: 'MOD', MONTH: 'AY', N: 'S', diff --git a/src/interpreter/functionMetadata/categories/matrix-functions.ts b/src/interpreter/functionMetadata/categories/matrix-functions.ts index 841285db41..10b01c073c 100644 --- a/src/interpreter/functionMetadata/categories/matrix-functions.ts +++ b/src/interpreter/functionMetadata/categories/matrix-functions.ts @@ -24,6 +24,20 @@ export const MATRIX_FUNCTIONS_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=MEDIANPOOL(A1:D4, 2)', '=MEDIANPOOL(A1:D4, 2, 1)'], }, + MDETERM: { + category: 'Matrix functions', + shortDescription: 'Calculates the determinant of a square array. A non-square array or an array with a non-numeric value returns the #VALUE! error.', + parameters: [{name: 'array', description: 'The square range or array of numbers whose determinant is calculated.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=MDETERM(A1:B2)', '=MDETERM({1,2;3,4})'], + }, + MINVERSE: { + category: 'Matrix functions', + shortDescription: 'Calculates the inverse of a square array. A non-square array or an array with a non-numeric value returns the #VALUE! error; a singular array returns the #NUM! error.', + parameters: [{name: 'array', description: 'The square range or array of numbers to invert.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=MINVERSE(A1:B2)', '=MINVERSE({1,2;3,4})'], + }, MMULT: { category: 'Matrix functions', shortDescription: 'Calculates the array product of two arrays.', @@ -31,4 +45,11 @@ export const MATRIX_FUNCTIONS_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=MMULT(A1:B2, D1:E2)', '=MMULT(A1:C2, A4:B6)'], }, + MUNIT: { + category: 'Matrix functions', + shortDescription: 'Returns the identity matrix of the given dimension. A fractional dimension is truncated; a dimension below 1 or a non-numeric one returns the #VALUE! error.', + parameters: [{name: 'dimension', description: 'The number of rows and columns of the returned matrix.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=MUNIT(3)', '=MUNIT(A1)'], + }, } diff --git a/src/interpreter/plugin/MatrixPlugin.ts b/src/interpreter/plugin/MatrixPlugin.ts index e6235f5d89..d64231fab9 100644 --- a/src/interpreter/plugin/MatrixPlugin.ts +++ b/src/interpreter/plugin/MatrixPlugin.ts @@ -8,7 +8,7 @@ import {CellError, ErrorType} from '../../Cell' import {ErrorMessage} from '../../error-message' import {AstNodeType, ProcedureAst} from '../../parser' import {InterpreterState} from '../InterpreterState' -import {InternalScalarValue, InterpreterValue} from '../InterpreterValue' +import {getRawValue, InternalScalarValue, InterpreterValue, isExtendedNumber} from '../InterpreterValue' import {Maybe} from '../../Maybe' import {SimpleRangeValue} from '../../SimpleRangeValue' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' @@ -79,6 +79,112 @@ function poolFunctionArgumentsError(matrix: SimpleRangeValue, windowSize: number return undefined } +/** + * Reads the numbers of a matrix argument, treating typed numbers (percentages, dates, currency) as plain numbers. + * + * @param matrix - the matrix argument + * @returns the numbers of the matrix, the first error found in it, or the #VALUE! error when it holds any non-numeric value + */ +function numbersOrError(matrix: SimpleRangeValue): number[][] | CellError { + let hasNonNumericValue = false + const numbers: number[][] = [] + + for (const row of matrix.rawData()) { + const numbersInRow: number[] = [] + for (const value of row) { + if (value instanceof CellError) { + return value + } + if (!isExtendedNumber(value)) { + hasNonNumericValue = true + continue + } + numbersInRow.push(getRawValue(value)) + } + numbers.push(numbersInRow) + } + + return hasNonNumericValue ? new CellError(ErrorType.VALUE, ErrorMessage.NumberRange) : numbers +} + +/** + * Calculates the determinant with the fraction-free Bareiss elimination, so that a matrix of integers is + * eliminated without rounding noise and a singular one gives exactly 0. Rows are swapped to bring the + * entry of the greatest magnitude to the pivot position. + * + * @param matrix - a square matrix + */ +function determinant(matrix: number[][]): number { + const elements = matrix.map((row) => row.slice()) + const size = elements.length + let sign = 1 + let previousPivot = 1 + + for (let k = 0; k < size - 1; k++) { + let pivotRow = k + for (let i = k + 1; i < size; i++) { + if (Math.abs(elements[i][k]) > Math.abs(elements[pivotRow][k])) { + pivotRow = i + } + } + if (elements[pivotRow][k] === 0) { + return 0 + } + if (pivotRow !== k) { + [elements[k], elements[pivotRow]] = [elements[pivotRow], elements[k]] + sign = -sign + } + for (let i = k + 1; i < size; i++) { + for (let j = k + 1; j < size; j++) { + elements[i][j] = (elements[i][j] * elements[k][k] - elements[i][k] * elements[k][j]) / previousPivot + } + } + previousPivot = elements[k][k] + } + + return sign * elements[size - 1][size - 1] +} + +/** + * Builds the identity matrix. + * + * @param size - the number of rows and columns + */ +function identityMatrix(size: number): number[][] { + return Array.from({length: size}, (_, row) => Array.from({length: size}, (_unused, column) => row === column ? 1 : 0)) +} + +/** + * Inverts a non-singular square matrix with the Gauss-Jordan elimination and partial pivoting. + * + * @param matrix - a square matrix with a non-zero determinant + */ +function invert(matrix: number[][]): number[][] { + const size = matrix.length + const identity = identityMatrix(size) + const augmented = matrix.map((row, i) => row.concat(identity[i])) + + for (let k = 0; k < size; k++) { + let pivotRow = k + for (let i = k + 1; i < size; i++) { + if (Math.abs(augmented[i][k]) > Math.abs(augmented[pivotRow][k])) { + pivotRow = i + } + } + [augmented[k], augmented[pivotRow]] = [augmented[pivotRow], augmented[k]] + const pivot = augmented[k][k] + augmented[k] = augmented[k].map((value) => value / pivot) + for (let i = 0; i < size; i++) { + if (i !== k) { + const factor = augmented[i][k] + augmented[i] = augmented[i].map((value, j) => value - factor * augmented[k][j]) + } + } + } + + return augmented.map((row) => row.slice(size)) +} + export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypecheck { public static implementedFunctions: ImplementedFunctions = { 'MMULT': { @@ -98,6 +204,29 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech ], vectorizationForbidden: true, }, + 'MUNIT': { + method: 'munit', + sizeOfResultArrayMethod: 'munitArraySize', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + ], + vectorizationForbidden: true, + }, + 'MDETERM': { + method: 'mdeterm', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + ], + vectorizationForbidden: true, + }, + 'MINVERSE': { + method: 'minverse', + sizeOfResultArrayMethod: 'minverseArraySize', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + ], + vectorizationForbidden: true, + }, 'MAXPOOL': { method: 'maxpool', sizeOfResultArrayMethod: 'maxpoolArraySize', @@ -152,6 +281,96 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech return arraySizeForMultiplication(left, right) } + /** + * Corresponds to MUNIT(Dimension). + * + * Returns the identity matrix of `Dimension` x `Dimension` cells. A fractional dimension is truncated; + * a dimension below 1, or one that is not a number, returns the #VALUE! error. + */ + public munit(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('MUNIT'), (argument: InternalScalarValue) => { + if (argument instanceof CellError) { + return argument + } + const dimension = typeof argument === 'boolean' ? Number(argument) : argument + if (typeof dimension !== 'number') { + return new CellError(ErrorType.VALUE, ErrorMessage.NumberExpected) + } + const size = Math.trunc(dimension) + if (!(size >= 1)) { + return new CellError(ErrorType.VALUE, ErrorMessage.LessThanOne) + } + return SimpleRangeValue.onlyNumbers(identityMatrix(size)) + }) + } + + public munitArraySize(ast: ProcedureAst): ArraySize { + if (ast.args.length !== 1) { + return ArraySize.error() + } + const argument = ast.args[0] + if (argument.type !== AstNodeType.NUMBER) { + return ArraySize.scalar() + } + const size = Math.trunc(argument.value) + return size >= 1 ? new ArraySize(size, size) : ArraySize.error() + } + + /** + * Corresponds to MDETERM(Array). + * + * Returns the determinant of a square matrix of numbers. A non-square matrix or a non-numeric value + * returns the #VALUE! error. The elimination is exact for integers, so a singular matrix gives 0. + */ + public mdeterm(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('MDETERM'), (matrix: SimpleRangeValue) => { + const numbers = numbersOrError(matrix) + if (numbers instanceof CellError) { + return numbers + } + if (matrix.width() !== matrix.height()) { + return new CellError(ErrorType.VALUE, ErrorMessage.ArrayDimensions) + } + const result = determinant(numbers) + return Number.isFinite(result) ? result : new CellError(ErrorType.NUM, ErrorMessage.NaN) + }) + } + + /** + * Corresponds to MINVERSE(Array). + * + * Returns the inverse of a square matrix of numbers. A non-square matrix or a non-numeric value returns + * the #VALUE! error; a singular matrix returns the #NUM! error. + */ + public minverse(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('MINVERSE'), (matrix: SimpleRangeValue) => { + const numbers = numbersOrError(matrix) + if (numbers instanceof CellError) { + return numbers + } + if (matrix.width() !== matrix.height()) { + return new CellError(ErrorType.VALUE, ErrorMessage.ArrayDimensions) + } + if (determinant(numbers) === 0) { + return new CellError(ErrorType.NUM, ErrorMessage.SingularMatrix) + } + const result = invert(numbers) + if (!result.every((row) => row.every(Number.isFinite))) { + return new CellError(ErrorType.NUM, ErrorMessage.NaN) + } + return SimpleRangeValue.onlyNumbers(result) + }) + } + + public minverseArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length !== 1) { + return ArraySize.error() + } + const metadata = this.metadata('MINVERSE') + const size = this.arraySizeForAst(ast.args[0], new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false))) + return size.width === size.height ? new ArraySize(size.width, size.height) : ArraySize.error() + } + /** * Corresponds to MAXPOOL(Range, Window_size, Stride). * diff --git a/src/license/functionCapabilities.ts b/src/license/functionCapabilities.ts index a26733ec5f..74d947d62b 100644 --- a/src/license/functionCapabilities.ts +++ b/src/license/functionCapabilities.ts @@ -90,7 +90,7 @@ const UNGROUPED_FUNCTIONS = [ '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', + 'MDETERM', 'MINVERSE', 'MMULT', 'MULTINOMIAL', 'MUNIT', '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', 'SERIESSUM', 'SHEET', 'SHEETS', 'SINH', 'SKEW', 'SKEW.P', 'SLOPE', 'SPLIT', 'SQRTPI', 'STANDARDIZE', From 2004308b80f30cd3dfcdb29492ea388663c7bc13 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 15 Oct 2026 06:05:13 +0000 Subject: [PATCH 2/9] 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 5b1703a86a..7b0cbc96c7 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 `MUNIT`, `MDETERM` and `MINVERSE` matrix functions: the identity matrix, the determinant and the inverse of a square matrix. +- Added the `MUNIT`, `MDETERM` and `MINVERSE` matrix functions: the identity matrix, the determinant and the inverse of a square matrix. [#1817](https://github.com/handsontable/hyperformula/pull/1817) ### Fixed From 938b724467002eea041ad28f68d4aefda3f0b727 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:33:15 +0000 Subject: [PATCH 3/9] HF-399: Compute MDETERM and MINVERSE with an LU decomposition with partial pivoting The fraction-free elimination multiplied minors together, so a matrix with large or small elements overflowed or underflowed although its determinant was a number, and MINVERSE returned #NUM! for matrices that have an inverse. A singular matrix is now one whose elimination meets a pivot that is exactly 0, and the list of differences no longer claims an exact elimination. Co-Authored-By: Claude Opus 5.5 --- docs/guide/list-of-differences.md | 1 - .../src/interpreter/plugin/MatrixPlugin.ts | 109 +++++++++++------- 2 files changed, 70 insertions(+), 40 deletions(-) diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 7ce5fc6f82..660fda0f98 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -127,5 +127,4 @@ A few of the rows above share a root cause worth stating once: - **Rounding toward zero, not down.** `INT` discards the fractional part rather than rounding toward negative infinity, so it differs from Excel and Google Sheets for negative input only. `ROUNDDOWN`/`ROUNDUP` are unaffected — they are defined in terms of zero in all three. - **`ISEVEN`/`ISODD` do not truncate.** They test the remainder of the value as given, so a value with a fractional part returns `FALSE` from *both*. Excel and Google Sheets truncate to an integer first, so exactly one of the two is always `TRUE`. -- **`MDETERM`/`MINVERSE` eliminate exactly.** The determinant is computed without division by pivots, so a singular matrix of integers gives exactly `0` and `MINVERSE` returns `#NUM!`. Excel returns rounding noise for such a matrix (`MDETERM({1,2,3;4,5,6;7,8,9})` is `6.66E-16` there). - **`CEILING.MATH`/`FLOOR.MATH` honour only `mode` = 1.** Excel and Google Sheets switch the negative-number rounding direction for any non-zero `mode`. diff --git a/hyperformula/src/interpreter/plugin/MatrixPlugin.ts b/hyperformula/src/interpreter/plugin/MatrixPlugin.ts index d64231fab9..8e3186256d 100644 --- a/hyperformula/src/interpreter/plugin/MatrixPlugin.ts +++ b/hyperformula/src/interpreter/plugin/MatrixPlugin.ts @@ -108,41 +108,70 @@ function numbersOrError(matrix: SimpleRangeValue): number[][] | CellError { } /** - * Calculates the determinant with the fraction-free Bareiss elimination, so that a matrix of integers is - * eliminated without rounding noise and a singular one gives exactly 0. Rows are swapped to bring the - * entry of the greatest magnitude to the pivot position. + * The LU decomposition of a square matrix with partial pivoting: the rows of the matrix, reordered by `permutation`, + * equal the product of a lower triangular matrix with ones on the diagonal and an upper triangular matrix. + */ +interface LuDecomposition { + /** The upper triangular factor on and above the diagonal, the multipliers of the lower one below it. */ + factors: number[][], + /** For each row of `factors`, the index of the row of the original matrix it comes from. */ + permutation: number[], + /** 1 or -1: the sign of the permutation, by which the product of the pivots is multiplied to give the determinant. */ + sign: number, +} + +/** + * Factorizes a square matrix with Gaussian elimination and partial pivoting: at each step, the row with the entry of + * the greatest magnitude in the pivot column becomes the pivot row. * - * @param matrix - a square matrix + * @param matrix - a square matrix, left unchanged + * @returns the decomposition, or undefined when a pivot is exactly 0, that is when the matrix is singular */ -function determinant(matrix: number[][]): number { - const elements = matrix.map((row) => row.slice()) - const size = elements.length +function luDecomposition(matrix: number[][]): Maybe { + const size = matrix.length + const factors = matrix.map((row) => row.slice()) + const permutation = factors.map((_, index) => index) let sign = 1 - let previousPivot = 1 - for (let k = 0; k < size - 1; k++) { + for (let k = 0; k < size; k++) { let pivotRow = k for (let i = k + 1; i < size; i++) { - if (Math.abs(elements[i][k]) > Math.abs(elements[pivotRow][k])) { + if (Math.abs(factors[i][k]) > Math.abs(factors[pivotRow][k])) { pivotRow = i } } - if (elements[pivotRow][k] === 0) { - return 0 + if (factors[pivotRow][k] === 0) { + return undefined } if (pivotRow !== k) { - [elements[k], elements[pivotRow]] = [elements[pivotRow], elements[k]] + [factors[k], factors[pivotRow]] = [factors[pivotRow], factors[k]]; + [permutation[k], permutation[pivotRow]] = [permutation[pivotRow], permutation[k]] sign = -sign } for (let i = k + 1; i < size; i++) { + const multiplier = factors[i][k] / factors[k][k] + factors[i][k] = multiplier for (let j = k + 1; j < size; j++) { - elements[i][j] = (elements[i][j] * elements[k][k] - elements[i][k] * elements[k][j]) / previousPivot + factors[i][j] -= multiplier * factors[k][j] } } - previousPivot = elements[k][k] } - return sign * elements[size - 1][size - 1] + return {factors, permutation, sign} +} + +/** + * Calculates the determinant as the product of the pivots of the LU decomposition. A singular matrix whose + * elimination meets an exact zero pivot gives 0; otherwise the result carries the rounding error of the elimination. + * + * @param matrix - a square matrix + */ +function determinant(matrix: number[][]): number { + const decomposition = luDecomposition(matrix) + if (decomposition === undefined) { + return 0 + } + return decomposition.factors.reduce((product, row, index) => product * row[index], decomposition.sign) } /** @@ -155,34 +184,34 @@ function identityMatrix(size: number): number[][] { } /** - * Inverts a non-singular square matrix with the Gauss-Jordan elimination and partial pivoting. + * Calculates the inverse from the LU decomposition, solving one triangular system pair per column of the identity matrix. * - * @param matrix - a square matrix with a non-zero determinant + * @param decomposition - the LU decomposition of a non-singular matrix */ -function invert(matrix: number[][]): number[][] { - const size = matrix.length - const identity = identityMatrix(size) - const augmented = matrix.map((row, i) => row.concat(identity[i])) +function inverse(decomposition: LuDecomposition): number[][] { + const {factors, permutation} = decomposition + const size = factors.length + const result = identityMatrix(size) - for (let k = 0; k < size; k++) { - let pivotRow = k - for (let i = k + 1; i < size; i++) { - if (Math.abs(augmented[i][k]) > Math.abs(augmented[pivotRow][k])) { - pivotRow = i + for (let column = 0; column < size; column++) { + const solution = permutation.map((originalRow) => originalRow === column ? 1 : 0) + for (let i = 0; i < size; i++) { + for (let j = 0; j < i; j++) { + solution[i] -= factors[i][j] * solution[j] } } - [augmented[k], augmented[pivotRow]] = [augmented[pivotRow], augmented[k]] - const pivot = augmented[k][k] - augmented[k] = augmented[k].map((value) => value / pivot) - for (let i = 0; i < size; i++) { - if (i !== k) { - const factor = augmented[i][k] - augmented[i] = augmented[i].map((value, j) => value - factor * augmented[k][j]) + for (let i = size - 1; i >= 0; i--) { + for (let j = i + 1; j < size; j++) { + solution[i] -= factors[i][j] * solution[j] } + solution[i] /= factors[i][i] } + solution.forEach((value, row) => { + result[row][column] = value + }) } - return augmented.map((row) => row.slice(size)) + return result } export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypecheck { @@ -320,7 +349,7 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech * Corresponds to MDETERM(Array). * * Returns the determinant of a square matrix of numbers. A non-square matrix or a non-numeric value - * returns the #VALUE! error. The elimination is exact for integers, so a singular matrix gives 0. + * returns the #VALUE! error, and a determinant too large for a number returns the #NUM! error. */ public mdeterm(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('MDETERM'), (matrix: SimpleRangeValue) => { @@ -340,7 +369,8 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech * Corresponds to MINVERSE(Array). * * Returns the inverse of a square matrix of numbers. A non-square matrix or a non-numeric value returns - * the #VALUE! error; a singular matrix returns the #NUM! error. + * the #VALUE! error. A matrix whose elimination meets a pivot that is exactly 0, or whose inverse has an element + * too large for a number, returns the #NUM! error. */ public minverse(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('MINVERSE'), (matrix: SimpleRangeValue) => { @@ -351,10 +381,11 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech if (matrix.width() !== matrix.height()) { return new CellError(ErrorType.VALUE, ErrorMessage.ArrayDimensions) } - if (determinant(numbers) === 0) { + const decomposition = luDecomposition(numbers) + if (decomposition === undefined) { return new CellError(ErrorType.NUM, ErrorMessage.SingularMatrix) } - const result = invert(numbers) + const result = inverse(decomposition) if (!result.every((row) => row.every(Number.isFinite))) { return new CellError(ErrorType.NUM, ErrorMessage.NaN) } From b83f3cc6cb8ae2b96301d45b4901ae5b5fc09574 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:33:15 +0000 Subject: [PATCH 4/9] HF-401: Use the MUNIT names from the Microsoft function pages in esES and ruRU Co-Authored-By: Claude Opus 5.5 --- hyperformula/src/i18n/languages/esES.ts | 2 +- hyperformula/src/i18n/languages/ruRU.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hyperformula/src/i18n/languages/esES.ts b/hyperformula/src/i18n/languages/esES.ts index 20f64ec0a7..81c2fe309b 100644 --- a/hyperformula/src/i18n/languages/esES.ts +++ b/hyperformula/src/i18n/languages/esES.ts @@ -163,7 +163,7 @@ export const dictionary: RawTranslationPackage = { MDETERM: 'MDETERM', MINVERSE: 'MINVERSA', MMULT: 'MMULT', - MUNIT: 'MUNIT', + MUNIT: 'M.UNIDAD', MOD: 'RESIDUO', MONTH: 'MES', N: 'N', diff --git a/hyperformula/src/i18n/languages/ruRU.ts b/hyperformula/src/i18n/languages/ruRU.ts index 33513b3444..f4307265d2 100644 --- a/hyperformula/src/i18n/languages/ruRU.ts +++ b/hyperformula/src/i18n/languages/ruRU.ts @@ -163,7 +163,7 @@ const dictionary: RawTranslationPackage = { MDETERM: 'МОПРЕД', MINVERSE: 'МОБР', MMULT: 'МУМНОЖ', - MUNIT: 'ЕДМАТРИЦА', + MUNIT: 'МЕДИН', MOD: 'ОСТАТ', MONTH: 'МЕСЯЦ', N: 'Ч', From b37ce5db540aa5e629c9dd803742e97caa39e0ae Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:33:15 +0000 Subject: [PATCH 5/9] HF-401: Replace the MUNIT example that needs a parse-time size and list MUNIT among such functions Co-Authored-By: Claude Opus 5.5 --- docs/guide/known-limitations.md | 2 +- .../interpreter/functionMetadata/categories/matrix-functions.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index d78cac018d..c57b98d411 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -45,7 +45,7 @@ a circular reference. * For certain inputs, the RATE function might have no solutions, or have multiple solutions. Our implementation uses an iterative algorithm (Newton's method) to find an approximation for one of the solutions to within 1e-7. If the approximation is not found after 50 iterations, the RATE function returns the `#NUM!` error. * 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. +* Array-producing functions (e.g., SEQUENCE, MUNIT, 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. * 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/interpreter/functionMetadata/categories/matrix-functions.ts b/hyperformula/src/interpreter/functionMetadata/categories/matrix-functions.ts index 10b01c073c..ef6d4c68f0 100644 --- a/hyperformula/src/interpreter/functionMetadata/categories/matrix-functions.ts +++ b/hyperformula/src/interpreter/functionMetadata/categories/matrix-functions.ts @@ -50,6 +50,6 @@ export const MATRIX_FUNCTIONS_DOCS: Record = { shortDescription: 'Returns the identity matrix of the given dimension. A fractional dimension is truncated; a dimension below 1 or a non-numeric one returns the #VALUE! error.', parameters: [{name: 'dimension', description: 'The number of rows and columns of the returned matrix.'}], documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', - examples: ['=MUNIT(3)', '=MUNIT(A1)'], + examples: ['=MUNIT(3)', '=MMULT(A1:C3, MUNIT(3))'], }, } From 3d8c23fd6cdc893557c73bfd14ee9e8233712660 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 08:27:08 +0000 Subject: [PATCH 6/9] HF-401: Limit MUNIT to the sheet size and size a signed literal dimension at parse time A dimension above the row or column limit of a sheet is now #VALUE!, like SEQUENCE, instead of allocating the matrix (a dimension of 20000 crashed the process). A dimension written as +3 or -3 is read when the result size is predicted, so =MUNIT(+3) spills a 3x3 matrix. Co-Authored-By: Claude Opus 5.5 --- .../src/interpreter/plugin/MatrixPlugin.ts | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/hyperformula/src/interpreter/plugin/MatrixPlugin.ts b/hyperformula/src/interpreter/plugin/MatrixPlugin.ts index 8e3186256d..ee81057272 100644 --- a/hyperformula/src/interpreter/plugin/MatrixPlugin.ts +++ b/hyperformula/src/interpreter/plugin/MatrixPlugin.ts @@ -6,7 +6,7 @@ import {ArraySize} from '../../ArraySize' import {CellError, ErrorType} from '../../Cell' import {ErrorMessage} from '../../error-message' -import {AstNodeType, ProcedureAst} from '../../parser' +import {Ast, AstNodeType, ProcedureAst} from '../../parser' import {InterpreterState} from '../InterpreterState' import {getRawValue, InternalScalarValue, InterpreterValue, isExtendedNumber} from '../InterpreterValue' import {Maybe} from '../../Maybe' @@ -174,6 +174,22 @@ function determinant(matrix: number[][]): number { return decomposition.factors.reduce((product, row, index) => product * row[index], decomposition.sign) } +/** + * Reads a number written directly in a formula, with an optional sign, such as `3`, `+3` or `-3`. + * + * @param node - an argument of a function call + * @returns the number, or undefined when the argument is anything else and its value is known only after evaluation + */ +function numberLiteral(node: Ast): Maybe { + if (node.type === AstNodeType.NUMBER) { + return node.value + } + if ((node.type === AstNodeType.PLUS_UNARY_OP || node.type === AstNodeType.MINUS_UNARY_OP) && node.value.type === AstNodeType.NUMBER) { + return node.type === AstNodeType.MINUS_UNARY_OP ? -node.value.value : node.value.value + } + return undefined +} + /** * Builds the identity matrix. * @@ -329,6 +345,9 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech if (!(size >= 1)) { return new CellError(ErrorType.VALUE, ErrorMessage.LessThanOne) } + if (size > this.largestIdentityMatrix()) { + return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge) + } return SimpleRangeValue.onlyNumbers(identityMatrix(size)) }) } @@ -337,12 +356,19 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech if (ast.args.length !== 1) { return ArraySize.error() } - const argument = ast.args[0] - if (argument.type !== AstNodeType.NUMBER) { + const dimension = numberLiteral(ast.args[0]) + if (dimension === undefined) { return ArraySize.scalar() } - const size = Math.trunc(argument.value) - return size >= 1 ? new ArraySize(size, size) : ArraySize.error() + const size = Math.trunc(dimension) + return size >= 1 && size <= this.largestIdentityMatrix() ? new ArraySize(size, size) : ArraySize.error() + } + + /** + * The largest dimension `MUNIT` accepts: the identity matrix must fit within both the row and the column limit of a sheet. + */ + private largestIdentityMatrix(): number { + return Math.min(this.config.maxRows, this.config.maxColumns) } /** From afd65cfe29375f47d5ce812ec8dbdc9cbc748971 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 25 Oct 2026 02:23:13 +0000 Subject: [PATCH 7/9] HF-401: Coerce numeric text in MUNIT and let text win over an error in MDETERM and MINVERSE Excel returns a 3x3 matrix for MUNIT("3") and #VALUE! for a matrix that holds both text and an error, whichever comes first. MUNIT now takes its dimension as a number, and the result size of a numeric text literal is known at parse time. Co-Authored-By: Claude Opus 5.5 --- .../src/interpreter/plugin/MatrixPlugin.ts | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/hyperformula/src/interpreter/plugin/MatrixPlugin.ts b/hyperformula/src/interpreter/plugin/MatrixPlugin.ts index ee81057272..ce97cfaace 100644 --- a/hyperformula/src/interpreter/plugin/MatrixPlugin.ts +++ b/hyperformula/src/interpreter/plugin/MatrixPlugin.ts @@ -83,28 +83,32 @@ function poolFunctionArgumentsError(matrix: SimpleRangeValue, windowSize: number * Reads the numbers of a matrix argument, treating typed numbers (percentages, dates, currency) as plain numbers. * * @param matrix - the matrix argument - * @returns the numbers of the matrix, the first error found in it, or the #VALUE! error when it holds any non-numeric value + * @returns the numbers of the matrix; the #VALUE! error when it holds any value that is neither a number nor an error, even + * after an error; otherwise the first error found in it */ function numbersOrError(matrix: SimpleRangeValue): number[][] | CellError { let hasNonNumericValue = false + let firstError: Maybe const numbers: number[][] = [] for (const row of matrix.rawData()) { const numbersInRow: number[] = [] for (const value of row) { if (value instanceof CellError) { - return value - } - if (!isExtendedNumber(value)) { + firstError = firstError ?? value + } else if (isExtendedNumber(value)) { + numbersInRow.push(getRawValue(value)) + } else { hasNonNumericValue = true - continue } - numbersInRow.push(getRawValue(value)) } numbers.push(numbersInRow) } - return hasNonNumericValue ? new CellError(ErrorType.VALUE, ErrorMessage.NumberRange) : numbers + if (hasNonNumericValue) { + return new CellError(ErrorType.VALUE, ErrorMessage.NumberRange) + } + return firstError ?? numbers } /** @@ -175,7 +179,8 @@ function determinant(matrix: number[][]): number { } /** - * Reads a number written directly in a formula, with an optional sign, such as `3`, `+3` or `-3`. + * Reads a number written directly in a formula, with an optional sign, such as `3`, `+3` or `-3`, or as a text + * literal that is a number, such as `"3"`. * * @param node - an argument of a function call * @returns the number, or undefined when the argument is anything else and its value is known only after evaluation @@ -184,6 +189,10 @@ function numberLiteral(node: Ast): Maybe { if (node.type === AstNodeType.NUMBER) { return node.value } + if (node.type === AstNodeType.STRING) { + const value = Number(node.value) + return node.value.trim() !== '' && Number.isFinite(value) ? value : undefined + } if ((node.type === AstNodeType.PLUS_UNARY_OP || node.type === AstNodeType.MINUS_UNARY_OP) && node.value.type === AstNodeType.NUMBER) { return node.type === AstNodeType.MINUS_UNARY_OP ? -node.value.value : node.value.value } @@ -253,7 +262,7 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech method: 'munit', sizeOfResultArrayMethod: 'munitArraySize', parameters: [ - {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.NUMBER}, ], vectorizationForbidden: true, }, @@ -329,18 +338,12 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech /** * Corresponds to MUNIT(Dimension). * - * Returns the identity matrix of `Dimension` x `Dimension` cells. A fractional dimension is truncated; - * a dimension below 1, or one that is not a number, returns the #VALUE! error. + * Returns the identity matrix of `Dimension` x `Dimension` cells. The dimension is coerced to a number, so a + * numeric text such as "3" is accepted, and a fractional dimension is truncated. A dimension below 1, text that is + * not a number, or a dimension above the row or column limit of a sheet returns the #VALUE! error. */ public munit(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - return this.runFunction(ast.args, state, this.metadata('MUNIT'), (argument: InternalScalarValue) => { - if (argument instanceof CellError) { - return argument - } - const dimension = typeof argument === 'boolean' ? Number(argument) : argument - if (typeof dimension !== 'number') { - return new CellError(ErrorType.VALUE, ErrorMessage.NumberExpected) - } + return this.runFunction(ast.args, state, this.metadata('MUNIT'), (dimension: number) => { const size = Math.trunc(dimension) if (!(size >= 1)) { return new CellError(ErrorType.VALUE, ErrorMessage.LessThanOne) From f7a20f9a9aab4739c5fdc271f56a6e19c9272e91 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 25 Oct 2026 02:28:05 +0000 Subject: [PATCH 8/9] HF-401: List MUNIT over a range of dimensions as a difference from Excel Excel lifts MUNIT over a range and returns one result per cell; HyperFormula returns #VALUE!. Co-Authored-By: Claude Opus 5.5 --- docs/guide/list-of-differences.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 660fda0f98..f5ffbb50bb 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -122,6 +122,7 @@ To remove the differences, create [custom implementations](custom-functions.md) | ISODD | =ISODD(3.5) | FALSE | TRUE | TRUE | | CEILING.MATH | =CEILING.MATH(-4.3, 2, 2) | -4 | -6 | -6 | | FLOOR.MATH | =FLOOR.MATH(-4.7, 2, 2) | -6 | -4 | -4 | +| MUNIT | =ROWS(MUNIT(A1:A2))
where A1:A2: 3, 2 | VALUE | Not checked | 2 | A few of the rows above share a root cause worth stating once: From 078e2a9560968bfdecda979806c721bd789c3e42 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 25 Oct 2026 12:52:39 +0000 Subject: [PATCH 9/9] HF-401: Coerce a MUNIT text dimension at parse time the way evaluation does The predicted result size read a text literal with JavaScript number parsing, which ignores the configured decimal separator and accepts forms the engine rejects, so the prediction and the evaluated size could disagree. It now uses the same coercion as a NUMBER argument. Co-Authored-By: Claude Opus 5.5 --- .../src/interpreter/plugin/MatrixPlugin.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/hyperformula/src/interpreter/plugin/MatrixPlugin.ts b/hyperformula/src/interpreter/plugin/MatrixPlugin.ts index ce97cfaace..545c114ccd 100644 --- a/hyperformula/src/interpreter/plugin/MatrixPlugin.ts +++ b/hyperformula/src/interpreter/plugin/MatrixPlugin.ts @@ -179,8 +179,7 @@ function determinant(matrix: number[][]): number { } /** - * Reads a number written directly in a formula, with an optional sign, such as `3`, `+3` or `-3`, or as a text - * literal that is a number, such as `"3"`. + * Reads a number written directly in a formula, with an optional sign, such as `3`, `+3` or `-3`. * * @param node - an argument of a function call * @returns the number, or undefined when the argument is anything else and its value is known only after evaluation @@ -189,10 +188,6 @@ function numberLiteral(node: Ast): Maybe { if (node.type === AstNodeType.NUMBER) { return node.value } - if (node.type === AstNodeType.STRING) { - const value = Number(node.value) - return node.value.trim() !== '' && Number.isFinite(value) ? value : undefined - } if ((node.type === AstNodeType.PLUS_UNARY_OP || node.type === AstNodeType.MINUS_UNARY_OP) && node.value.type === AstNodeType.NUMBER) { return node.type === AstNodeType.MINUS_UNARY_OP ? -node.value.value : node.value.value } @@ -359,7 +354,8 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech if (ast.args.length !== 1) { return ArraySize.error() } - const dimension = numberLiteral(ast.args[0]) + const argument = ast.args[0] + const dimension = argument.type === AstNodeType.STRING ? this.textLiteralAsNumber(argument.value) : numberLiteral(argument) if (dimension === undefined) { return ArraySize.scalar() } @@ -367,6 +363,18 @@ export class MatrixPlugin extends FunctionPlugin implements FunctionPluginTypech return size >= 1 && size <= this.largestIdentityMatrix() ? new ArraySize(size, size) : ArraySize.error() } + /** + * Coerces a text literal to a number the way a NUMBER argument is coerced at evaluation, so that the predicted + * result size agrees with the evaluated one, including the configured decimal separator. + * + * @param text - the text of the literal + * @returns the number, or undefined when the text is not a number + */ + private textLiteralAsNumber(text: string): Maybe { + const coerced = this.coerceScalarToNumberOrError(text) + return isExtendedNumber(coerced) ? getRawValue(coerced) : undefined + } + /** * The largest dimension `MUNIT` accepts: the identity matrix must fit within both the row and the column limit of a sheet. */