Skip to content

Commit e63f9a2

Browse files
authored
Merge pull request #207 from effigies/feat/multi-inheritance
feat: Implement multi-inheritance by passing extra entities to walkBack
2 parents 4c622f6 + 45b5151 commit e63f9a2

File tree

4 files changed

+141
-46
lines changed

4 files changed

+141
-46
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<!--
2+
A new scriv changelog fragment.
3+
4+
Uncomment the section that is right (remove the HTML comment wrapper).
5+
For top level release notes, leave all the headers commented out.
6+
-->
7+
8+
### Added
9+
10+
- Support multi-inheritance for associated files.
11+
This will allow for multiple `electrodes.tsv` files,
12+
distinguished by the `space-` entity. ([#206] [#207])
13+
14+
[#206]: https://github.com/bids-standard/bids-validator/issues/206
15+
[#207]: https://github.com/bids-standard/bids-validator/pull/207
16+
17+
<!--
18+
### Changed
19+
20+
- A bullet item for the Changed category.
21+
22+
-->
23+
<!--
24+
### Fixed
25+
26+
- A bullet item for the Fixed category.
27+
28+
-->
29+
<!--
30+
### Deprecated
31+
32+
- A bullet item for the Deprecated category.
33+
34+
-->
35+
<!--
36+
### Removed
37+
38+
- A bullet item for the Removed category.
39+
40+
-->
41+
<!--
42+
### Security
43+
44+
- A bullet item for the Security category.
45+
46+
-->
47+
<!--
48+
### Infrastructure
49+
50+
- A bullet item for the Infrastructure category.
51+
52+
-->

src/files/inheritance.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { assert, assertEquals, assertThrows } from '@std/assert'
2+
import type { BIDSFile } from '../types/filetree.ts'
23
import { pathsToTree } from './filetree.ts'
34
import { walkBack } from './inheritance.ts'
45

@@ -47,4 +48,28 @@ Deno.test('walkback inheritance tests', async (t) => {
4748
])
4849
},
4950
)
51+
await t.step(
52+
'Passing targetEntities enables multiple matches',
53+
async () => {
54+
const rootFileTree = pathsToTree([
55+
'/space-talairach_electrodes.tsv',
56+
'/space-talairach_electrodes.json',
57+
'/sub-01/ieeg/sub-01_task-rest_ieeg.edf',
58+
'/sub-01/ieeg/sub-01_task-rest_ieeg.json',
59+
'/sub-01/ieeg/sub-01_space-anat_electrodes.tsv',
60+
'/sub-01/ieeg/sub-01_space-anat_electrodes.json',
61+
'/sub-01/ieeg/sub-01_space-MNI_electrodes.tsv',
62+
'/sub-01/ieeg/sub-01_space-MNI_electrodes.json',
63+
])
64+
const dataFile = rootFileTree.get('sub-01/ieeg/sub-01_task-rest_ieeg.edf') as BIDSFile
65+
const electrodes = walkBack(dataFile, true, ['.tsv'], 'electrodes', ['space'])
66+
const localElectrodes: BIDSFile[] = electrodes.next().value
67+
assertEquals(localElectrodes.map((f) => f.path), [
68+
'/sub-01/ieeg/sub-01_space-anat_electrodes.tsv',
69+
'/sub-01/ieeg/sub-01_space-MNI_electrodes.tsv',
70+
])
71+
const rootElectrodes: BIDSFile = electrodes.next().value
72+
assertEquals(rootElectrodes.path, '/space-talairach_electrodes.tsv')
73+
},
74+
)
5075
})

src/files/inheritance.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,37 @@
11
import type { BIDSFile, FileTree } from '../types/filetree.ts'
2-
import type { Context } from '@bids/schema'
32
import { readEntities } from '../schema/entities.ts'
43

5-
export function* walkBack(
4+
5+
type Ret<T> = T extends [string, ...string[]] ? (BIDSFile | BIDSFile[]) : BIDSFile
6+
7+
/** Find associated files in order of proximity to a source file.
8+
*
9+
* This function implements the BIDS Inheritance Principle.
10+
*
11+
* @param {BIDSFile} source
12+
* The source file to start the search from.
13+
* @param {boolean} [inherit=true]
14+
* If true, search up the file tree for associated files.
15+
* If false, the associated file must be found in the same directory.
16+
* @param {string[]} [targetExtensions='.json']
17+
* The extensions of associated files.
18+
* @param {string} [targetSuffix]
19+
* The suffix of associated files. If not provided, it defaults to the suffix of the source file.
20+
* @param {string[]} [targetEntities]
21+
* Additional entities permitted in associated files.
22+
* A non-empty value implies that multiple values may be returned.
23+
* By default, associated files must have a subset of the entities in the source file.
24+
*
25+
* @returns {Generator<BIDSFile | BIDSFile[]>}
26+
* A generator that yields associated files or arrays of files.
27+
*/
28+
export function* walkBack<T extends string[]>(
629
source: BIDSFile,
730
inherit: boolean = true,
831
targetExtensions: string[] = ['.json'],
932
targetSuffix?: string,
10-
): Generator<BIDSFile> {
33+
targetEntities?: T,
34+
): Generator<Ret<T>> {
1135
const sourceParts = readEntities(source.name)
1236

1337
targetSuffix = targetSuffix || sourceParts.suffix
@@ -19,19 +43,24 @@ export function* walkBack(
1943
return (
2044
targetExtensions.includes(extension) &&
2145
suffix === targetSuffix &&
22-
Object.keys(entities).every((entity) => entities[entity] === sourceParts.entities[entity])
46+
Object.keys(entities).every((entity) =>
47+
entities[entity] === sourceParts.entities[entity] || targetEntities?.includes(entity)
48+
)
2349
)
2450
})
2551
if (candidates.length > 1) {
2652
const exactMatch = candidates.find((file) => {
27-
const { suffix, extension, entities } = readEntities(file.name)
53+
const { entities } = readEntities(file.name)
2854
return Object.keys(sourceParts.entities).every((entity) =>
2955
entities[entity] === sourceParts.entities[entity]
3056
)
3157
})
3258
if (exactMatch) {
3359
exactMatch.viewed = true
3460
yield exactMatch
61+
} else if (targetEntities?.length) {
62+
candidates.forEach((file) => (file.viewed = true))
63+
yield candidates as Ret<T>
3564
} else {
3665
const paths = candidates.map((x) => x.path).sort()
3766
throw {

src/schema/associations.ts

Lines changed: 30 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,25 @@
1-
import type {
2-
Aslcontext,
3-
Associations,
4-
Bval,
5-
Bvec,
6-
Channels,
7-
Coordsystem,
8-
Events,
9-
M0Scan,
10-
Magnitude,
11-
Magnitude1,
12-
} from '@bids/schema/context'
1+
import type { Aslcontext, Associations, Bval, Bvec, Channels, Events } from '@bids/schema/context'
132
import type { Schema as MetaSchema } from '@bids/schema/metaschema'
143

15-
import type { BIDSFile, FileTree } from '../types/filetree.ts'
4+
import type { BIDSFile } from '../types/filetree.ts'
165
import type { BIDSContext } from './context.ts'
17-
import type { DatasetIssues } from '../issues/datasetIssues.ts'
18-
import type { readEntities } from './entities.ts'
196
import { loadTSV } from '../files/tsv.ts'
207
import { parseBvalBvec } from '../files/dwi.ts'
218
import { walkBack } from '../files/inheritance.ts'
229
import { evalCheck } from './applyRules.ts'
2310
import { expressionFunctions } from './expressionLanguage.ts'
2411

25-
// type AssociationsLookup = Record<keyof ContextAssociations, { extensions: string[], inherit: boolean, load: ... }
12+
function defaultAssociation(file: BIDSFile, _options: any): Promise<{ path: string }> {
13+
return Promise.resolve({ path: file.path })
14+
}
2615

2716
/**
28-
* This object describes associated files for data files in a bids dataset
29-
* For any given datafile we iterate over every key/value in this object.
30-
* For each entry we see if any files in the datafiles directory have:
31-
* - a suffix that matches the key
32-
* - an extension in the entry's extension array.
33-
* - that all the files entities and their values match those of the datafile
34-
* If the entry allows for inheritance we recurse up the filetree looking for other applicable files.
35-
* The load functions are incomplete, some associations need to read data from a file so they're
36-
* returning promises for now.
17+
* This object describes lookup functions for files associated to data files in a bids dataset.
18+
* For any given data file we iterate over the associations defined schema.meta.associations.
19+
* If the selectors match the data file, we attempt to find an associated file,
20+
* and use the given function to load the data from that file.
21+
*
22+
* Many associations only consist of a path; this object is for more complex associations.
3723
*/
3824
const associationLookup = {
3925
events: async (file: BIDSFile, options: { maxRows: number }): Promise<Events> => {
@@ -60,15 +46,6 @@ const associationLookup = {
6046
volume_type: columns.get('volume_type') || [],
6147
}
6248
},
63-
m0scan: (file: BIDSFile, options: any): Promise<M0Scan> => {
64-
return Promise.resolve({ path: file.path })
65-
},
66-
magnitude: (file: BIDSFile, options: any): Promise<Magnitude> => {
67-
return Promise.resolve({ path: file.path })
68-
},
69-
magnitude1: (file: BIDSFile, options: any): Promise<Magnitude1> => {
70-
return Promise.resolve({ path: file.path })
71-
},
7249
bval: async (file: BIDSFile, options: any): Promise<Bval> => {
7350
const contents = await file.text()
7451
const rows = parseBvalBvec(contents)
@@ -106,9 +83,6 @@ const associationLookup = {
10683
sampling_frequency: columns.get('sampling_frequency'),
10784
}
10885
},
109-
coordsystem: (file: BIDSFile, options: any): Promise<Coordsystem> => {
110-
return Promise.resolve({ path: file.path })
111-
},
11286
}
11387

11488
export async function buildAssociations(
@@ -117,24 +91,39 @@ export async function buildAssociations(
11791
const associations: Associations = {}
11892

11993
const schema: MetaSchema = context.dataset.schema as MetaSchema
94+
// Augment rule type with an entities field that should be present in BIDS 1.10.1+
95+
type ruleType = MetaSchema['meta']['associations'][keyof MetaSchema['meta']['associations']]
96+
type AugmentedRuleType = ruleType & {
97+
target: ruleType['target'] & { entities?: string[] }
98+
}
12099

121100
Object.assign(context, expressionFunctions)
122101
// @ts-expect-error
123102
context.exists.bind(context)
124103

125-
for (const [key, rule] of Object.entries(schema.meta.associations)) {
104+
for (const key of Object.keys(schema.meta.associations)) {
105+
const rule = schema.meta.associations[key] as AugmentedRuleType
126106
if (!rule.selectors!.every((x) => evalCheck(x, context))) {
127107
continue
128108
}
129-
let file
109+
let file: BIDSFile | BIDSFile[]
130110
let extension: string[] = []
131111
if (typeof rule.target.extension === 'string') {
132112
extension = [rule.target.extension]
133113
} else if (Array.isArray(rule.target.extension)) {
134114
extension = rule.target.extension
135115
}
136116
try {
137-
file = walkBack(context.file, rule.inherit, extension, rule.target.suffix).next().value
117+
file = walkBack(
118+
context.file,
119+
rule.inherit,
120+
extension,
121+
rule.target.suffix,
122+
rule.target?.entities ?? [],
123+
).next().value
124+
if (Array.isArray(file)) {
125+
file = file[0]
126+
}
138127
} catch (error) {
139128
if (
140129
error && typeof error === 'object' && 'code' in error &&
@@ -150,7 +139,7 @@ export async function buildAssociations(
150139

151140
if (file) {
152141
// @ts-expect-error
153-
const load = associationLookup[key]
142+
const load = associationLookup[key] ?? defaultAssociation
154143
// @ts-expect-error
155144
associations[key] = await load(file, { maxRows: context.dataset.options?.maxRows }).catch(
156145
(error: any) => {

0 commit comments

Comments
 (0)