From 17295a84230f483ad6b1bdb5ca5ae357d411efaa Mon Sep 17 00:00:00 2001 From: Alexander Jones Date: Fri, 8 Mar 2024 10:54:31 -0600 Subject: [PATCH 1/2] Add custom version of Map.groupBy() while waiting for wider support --- utils/map.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/utils/map.js b/utils/map.js index 91a761e5..a9864f9f 100644 --- a/utils/map.js +++ b/utils/map.js @@ -1,3 +1,4 @@ +import identity from 'lodash/identity' import isEqual from 'lodash/isEqual' /** @@ -27,3 +28,24 @@ export const filterNonEqualDuplicates = function (list, equalityFunction = isEqu } return [map, duplicates] } + +/** + * Group a list by a given grouping function. + * + * @template T, U + * @param {T[]} list The list to group. + * @param {function (T): U} groupingFunction A function mapping a list value to the key it is to be grouped under. + * @return {Map} The grouped map. + */ +export const groupBy = function (list, groupingFunction = identity) { + const groupingMap = new Map() + for (const listEntry of list) { + const groupingValue = groupingFunction(listEntry) + if (groupingMap.has(groupingValue)) { + groupingMap.get(groupingValue).push(listEntry) + } else { + groupingMap.set(groupingValue, [listEntry]) + } + } + return groupingMap +} From 927e12d2d1d0c8cb26e0abe4fe48c6996dad251e Mon Sep 17 00:00:00 2001 From: Alexander Jones Date: Fri, 8 Mar 2024 10:55:09 -0600 Subject: [PATCH 2/2] Merge TSV rows with the same onset and sort them by onset time --- bids/types/tsv.js | 63 +++++++++++++++++++++++++++ bids/validator/bidsHedTsvValidator.js | 58 +++++++++++++++++++----- 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/bids/types/tsv.js b/bids/types/tsv.js index 7926b7bb..091cbf5c 100644 --- a/bids/types/tsv.js +++ b/bids/types/tsv.js @@ -132,6 +132,9 @@ export class BidsTabularFile extends BidsTsvFile { } } +/** + * A row in a BIDS TSV file. + */ export class BidsTsvRow extends ParsedHedString { /** * The parsed string representing this row. @@ -156,6 +159,7 @@ export class BidsTsvRow extends ParsedHedString { /** * Constructor. + * * @param {ParsedHedString} parsedString The parsed string representing this row. * @param {Map} rowCells The column-to-value mapping for this row. * @param {BidsTsvFile} tsvFile The file this row belongs to. @@ -178,4 +182,63 @@ export class BidsTsvRow extends ParsedHedString { toString() { return super.toString() + ` in TSV file "${this.tsvFile.name}" at line ${this.tsvLine}` } + + /** + * The onset of this row. + * + * @return {number} The onset of this row. + */ + get onset() { + const value = Number(this.rowCells.get('onset')) + if (Number.isNaN(value)) { + throw new Error('Attempting to access the onset of a TSV row without one.') + } + return value + } +} + +/** + * An event in a BIDS TSV file. + */ +export class BidsTsvEvent extends ParsedHedString { + /** + * The file this row belongs to. + * @type {BidsTsvFile} + */ + tsvFile + /** + * The TSV rows making up this event. + * @type {BidsTsvRow[]} + */ + tsvRows + + /** + * Constructor. + * + * @param {BidsTsvFile} tsvFile The file this row belongs to. + * @param {BidsTsvRow[]} tsvRows The TSV rows making up this event. + */ + constructor(tsvFile, tsvRows) { + super(tsvRows.map((tsvRow) => tsvRow.hedString).join(', '), tsvRows.map((tsvRow) => tsvRow.parseTree).flat()) + this.tsvFile = tsvFile + this.tsvRows = tsvRows + } + + /** + * The lines in the TSV file corresponding to this event. + * + * @return {string} The lines in the TSV file corresponding to this event. + */ + get tsvLines() { + return this.tsvRows.map((tsvRow) => tsvRow.tsvLine).join(', ') + } + + /** + * Override of {@link Object.prototype.toString}. + * + * @returns {string} + */ + toString() { + return super.toString() + ` in TSV file "${this.tsvFile.name}" at line(s) ${this.tsvLines}` + } } diff --git a/bids/validator/bidsHedTsvValidator.js b/bids/validator/bidsHedTsvValidator.js index f0b16648..43a22eed 100644 --- a/bids/validator/bidsHedTsvValidator.js +++ b/bids/validator/bidsHedTsvValidator.js @@ -1,11 +1,12 @@ import { BidsHedSidecarValidator } from './bidsHedSidecarValidator' import { BidsHedIssue } from '../types/issues' -import { BidsTsvRow } from '../types/tsv' +import { BidsTsvEvent, BidsTsvRow } from '../types/tsv' import { parseHedString } from '../../parser/main' import ColumnSplicer from '../../parser/columnSplicer' import ParsedHedString from '../../parser/parsedHedString' import { generateIssue } from '../../common/issues/issues' import { validateHedDatasetWithContext } from '../../validator/dataset' +import { groupBy } from '../../utils/map' /** * Validator for HED data in BIDS TSV files. @@ -67,18 +68,15 @@ export class BidsHedTsvValidator { /** * Combine the BIDS sidecar HED data into a BIDS TSV file's HED data. * - * @returns {BidsTsvRow[]} The combined HED string collection for this BIDS TSV file. + * @returns {ParsedHedString[]} The combined HED string collection for this BIDS TSV file. */ parseHed() { const tsvHedRows = this._generateHedRows() - const hedStrings = [] + const hedStrings = this._parseHedRows(tsvHedRows) - tsvHedRows.forEach((row, index) => { - const hedString = this._parseHedRow(row, index + 2) - if (hedString !== null) { - hedStrings.push(hedString) - } - }) + if (this.tsvFile.isTimelineFile) { + return this._mergeEventRows(hedStrings) + } return hedStrings } @@ -91,7 +89,7 @@ export class BidsHedTsvValidator { */ _generateHedRows() { const tsvHedColumns = Array.from(this.tsvFile.parsedTsv.entries()).filter( - ([header]) => this.tsvFile.sidecarHedData.has(header) || header === 'HED', + ([header]) => this.tsvFile.sidecarHedData.has(header) || header === 'HED' || header === 'onset', ) const tsvHedRows = [] @@ -104,6 +102,44 @@ export class BidsHedTsvValidator { return tsvHedRows } + /** + * Parse the HED rows in the TSV file. + * + * @param {Map[]} tsvHedRows A list of single-row column-to-value mappings. + * @return {BidsTsvRow[]} A list of row-based parsed HED strings. + * @private + */ + _parseHedRows(tsvHedRows) { + const hedStrings = [] + + tsvHedRows.forEach((row, index) => { + const hedString = this._parseHedRow(row, index + 2) + if (hedString !== null) { + hedStrings.push(hedString) + } + }) + return hedStrings + } + + /** + * Merge rows with the same onset time into a single event string. + * + * @param {BidsTsvRow[]} rowStrings A list of row-based parsed HED strings. + * @return {BidsTsvEvent[]} A list of event-based parsed HED strings. + * @private + */ + _mergeEventRows(rowStrings) { + const groupedTsvRows = groupBy(rowStrings, (rowString) => rowString.onset) + const sortedOnsetTimes = Array.from(groupedTsvRows.keys()).sort((a, b) => a - b) + const eventStrings = [] + for (const onset of sortedOnsetTimes) { + const onsetRows = groupedTsvRows.get(onset) + const onsetEventString = new BidsTsvEvent(this.tsvFile, onsetRows) + eventStrings.push(onsetEventString) + } + return eventStrings + } + /** * Parse a row in a TSV file. * @@ -141,7 +177,7 @@ export class BidsHedTsvValidator { const [parsedString, parsingIssues] = parseHedString(hedString, this.hedSchemas) const flatParsingIssues = Object.values(parsingIssues).flat() if (flatParsingIssues.length > 0) { - this.issues.push(...BidsHedIssue.fromHedIssues(...flatParsingIssues, this.tsvFile.file, { tsvLine })) + this.issues.push(...BidsHedIssue.fromHedIssues(flatParsingIssues, this.tsvFile.file, { tsvLine })) return null }