Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make TSV validation event-based #143

Merged
merged 2 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions bids/types/tsv.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -156,6 +159,7 @@ export class BidsTsvRow extends ParsedHedString {

/**
* Constructor.
*
* @param {ParsedHedString} parsedString The parsed string representing this row.
* @param {Map<string, string>} rowCells The column-to-value mapping for this row.
* @param {BidsTsvFile} tsvFile The file this row belongs to.
Expand All @@ -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}`
}
}
58 changes: 47 additions & 11 deletions bids/validator/bidsHedTsvValidator.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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 = []
Expand All @@ -104,6 +102,44 @@ export class BidsHedTsvValidator {
return tsvHedRows
}

/**
* Parse the HED rows in the TSV file.
*
* @param {Map<string, string>[]} 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.
*
Expand Down Expand Up @@ -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
}

Expand Down
22 changes: 22 additions & 0 deletions utils/map.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import identity from 'lodash/identity'
import isEqual from 'lodash/isEqual'

/**
Expand Down Expand Up @@ -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<U, T[]>} 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
}
Loading