Skip to content

Commit fe09d89

Browse files
authored
Merge pull request #260 from bids-standard/bep020
BEP020 patches
2 parents acc89e7 + 6ef07ad commit fe09d89

File tree

6 files changed

+153
-31
lines changed

6 files changed

+153
-31
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 for `associations.physio` and `associations.events.sidecar`.
11+
12+
<!--
13+
### Changed
14+
15+
- A bullet item for the Changed category.
16+
17+
-->
18+
<!--
19+
### Fixed
20+
21+
- A bullet item for the Fixed category.
22+
23+
-->
24+
<!--
25+
### Deprecated
26+
27+
- A bullet item for the Deprecated category.
28+
29+
-->
30+
<!--
31+
### Removed
32+
33+
- A bullet item for the Removed category.
34+
35+
-->
36+
<!--
37+
### Security
38+
39+
- A bullet item for the Security category.
40+
41+
-->
42+
<!--
43+
### Infrastructure
44+
45+
- A bullet item for the Infrastructure category.
46+
47+
-->

src/files/inheritance.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { BIDSFile, FileTree } from '../types/filetree.ts'
22
import { readEntities } from '../schema/entities.ts'
3+
import { loadJSON } from './json.ts'
34

45
type Ret<T> = T extends [string, ...string[]] ? (BIDSFile | BIDSFile[]) : BIDSFile
56

@@ -77,3 +78,20 @@ export function* walkBack<T extends string[]>(
7778
fileTree = fileTree.parent
7879
}
7980
}
81+
82+
export async function readSidecars(
83+
source: BIDSFile,
84+
): Promise<Map<string, Record<string, unknown>>> {
85+
const ret: Map<string, Record<string, unknown>> = new Map()
86+
for (const file of walkBack(source)) {
87+
try {
88+
ret.set(file.path, await loadJSON(file))
89+
} catch (e: any) {
90+
// Expect JSON parsing errors to be handled when the file is loaded directly
91+
if (!e?.code) {
92+
throw e
93+
}
94+
}
95+
}
96+
return ret
97+
}

src/schema/associations.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { assertEquals, assertObjectMatch } from '@std/assert'
2+
import type { BIDSFile, FileTree } from '../types/filetree.ts'
3+
import { loadSchema } from '../setup/loadSchema.ts'
4+
import { pathsToTree } from '../files/filetree.test.ts'
5+
import { nullReadBytes } from '../tests/nullReadBytes.ts'
6+
import { rootFileTree } from './fixtures.test.ts'
7+
import { BIDSContext } from './context.ts'
8+
import { buildAssociations } from './associations.ts'
9+
10+
Deno.test('Test association loading', async (t) => {
11+
const schema = await loadSchema()
12+
await t.step('Load associations for events.tsv', async () => {
13+
const eventsFile = rootFileTree.get(
14+
'sub-01/ses-01/func/sub-01_ses-01_task-movie_physio.tsv.gz',
15+
) as BIDSFile
16+
const context = new BIDSContext(eventsFile, undefined, rootFileTree)
17+
context.dataset.schema = schema
18+
const associations = await buildAssociations(context)
19+
assertObjectMatch(associations, {
20+
events: {
21+
sidecar: {
22+
StimulusPresentation: {
23+
ScreenDistance: 1.8,
24+
ScreenOrigin: ['top', 'left'],
25+
ScreenResolution: [1920, 1080],
26+
ScreenSize: [0.472, 0.265],
27+
},
28+
},
29+
},
30+
})
31+
})
32+
})

src/schema/associations.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,26 @@ import type { BIDSFile } from '../types/filetree.ts'
55
import type { BIDSContext } from './context.ts'
66
import { loadTSV } from '../files/tsv.ts'
77
import { parseBvalBvec } from '../files/dwi.ts'
8-
import { walkBack } from '../files/inheritance.ts'
8+
import { readSidecars, walkBack } from '../files/inheritance.ts'
99
import { evalCheck } from './applyRules.ts'
1010
import { expressionFunctions } from './expressionLanguage.ts'
1111

1212
import { readText } from '../files/access.ts'
1313

14+
interface WithSidecar {
15+
sidecar: Record<string, unknown>
16+
}
17+
1418
function defaultAssociation(file: BIDSFile, _options: any): Promise<{ path: string }> {
1519
return Promise.resolve({ path: file.path })
1620
}
1721

22+
async function constructSidecar(file: BIDSFile): Promise<Record<string, unknown>> {
23+
const sidecars = await readSidecars(file)
24+
// Note ordering here gives precedence to the more specific sidecar
25+
return sidecars.values().reduce((acc, json) => ({ ...json, ...acc }), {})
26+
}
27+
1828
/**
1929
* This object describes lookup functions for files associated to data files in a bids dataset.
2030
* For any given data file we iterate over the associations defined schema.meta.associations.
@@ -24,14 +34,15 @@ function defaultAssociation(file: BIDSFile, _options: any): Promise<{ path: stri
2434
* Many associations only consist of a path; this object is for more complex associations.
2535
*/
2636
const associationLookup = {
27-
events: async (file: BIDSFile, options: { maxRows: number }): Promise<Events> => {
37+
events: async (file: BIDSFile, options: { maxRows: number }): Promise<Events & WithSidecar> => {
2838
const columns = await loadTSV(file, options.maxRows)
2939
.catch((e) => {
3040
return new Map()
3141
})
3242
return {
3343
path: file.path,
3444
onset: columns.get('onset') || [],
45+
sidecar: await constructSidecar(file),
3546
}
3647
},
3748
aslcontext: async (
@@ -85,6 +96,12 @@ const associationLookup = {
8596
sampling_frequency: columns.get('sampling_frequency'),
8697
}
8798
},
99+
physio: async (file: BIDSFile, options: any): Promise<{path: string} & WithSidecar> => {
100+
return {
101+
path: file.path,
102+
sidecar: await constructSidecar(file),
103+
}
104+
},
88105
}
89106

90107
export async function buildAssociations(
@@ -93,18 +110,12 @@ export async function buildAssociations(
93110
const associations: Associations = {}
94111

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

102114
Object.assign(context, expressionFunctions)
103115
// @ts-expect-error
104116
context.exists.bind(context)
105117

106-
for (const key of Object.keys(schema.meta.associations)) {
107-
const rule = schema.meta.associations[key] as AugmentedRuleType
118+
for (const [key, rule] of Object.entries(schema.meta.associations)) {
108119
if (!rule.selectors!.every((x) => evalCheck(x, context))) {
109120
continue
110121
}

src/schema/context.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { ColumnsMap } from '../types/columns.ts'
1717
import { readEntities } from './entities.ts'
1818
import { findDatatype } from './datatypes.ts'
1919
import { DatasetIssues } from '../issues/datasetIssues.ts'
20-
import { walkBack } from '../files/inheritance.ts'
20+
import { readSidecars } from '../files/inheritance.ts'
2121
import { parseGzip } from '../files/gzip.ts'
2222
import { loadTSV, loadTSVGZ } from '../files/tsv.ts'
2323
import { parseTIFF } from '../files/tiff.ts'
@@ -201,29 +201,18 @@ export class BIDSContext implements Context {
201201
if (this.extension === '.json') {
202202
return
203203
}
204-
let sidecars: BIDSFile[] = []
204+
let sidecars: Map<string, Record<string, unknown>>
205205
try {
206-
sidecars = [...walkBack(this.file)]
207-
} catch (error) {
208-
if (
209-
error && typeof error === 'object' && 'code' in error &&
210-
error.code === 'MULTIPLE_INHERITABLE_FILES'
211-
) {
212-
// @ts-expect-error
206+
sidecars = await readSidecars(this.file)
207+
} catch (error: any) {
208+
if (error?.code) {
213209
this.dataset.issues.add(error)
210+
return
214211
} else {
215212
throw error
216213
}
217214
}
218-
for (const file of sidecars) {
219-
const json = await loadJSON(file).catch((error): Record<string, unknown> => {
220-
if (error.key) {
221-
this.dataset.issues.add({ code: error.key, location: file.path })
222-
return {}
223-
} else {
224-
throw error
225-
}
226-
})
215+
for (const [path, json] of sidecars.entries()) {
227216
const overrides = Object.keys(this.sidecar).filter((x) => Object.hasOwn(json, x))
228217
for (const key of overrides) {
229218
if (json[key] !== this.sidecar[key]) {
@@ -232,14 +221,14 @@ export class BIDSContext implements Context {
232221
code: 'SIDECAR_FIELD_OVERRIDE',
233222
subCode: key,
234223
location: overrideLocation,
235-
issueMessage: `Sidecar key defined in ${file.path} overrides previous value (${
224+
issueMessage: `Sidecar key defined in ${path} overrides previous value (${
236225
json[key]
237226
}) from ${overrideLocation}`,
238227
})
239228
}
240229
}
241230
this.sidecar = { ...json, ...this.sidecar }
242-
Object.keys(json).map((x) => this.sidecarKeyOrigin[x] ??= file.path)
231+
Object.keys(json).map((x) => this.sidecarKeyOrigin[x] ??= path)
243232
}
244233
// Hack: round RepetitionTime to 3 decimal places; schema should add rounding function
245234
if (typeof this.sidecar.RepetitionTime === 'number') {

src/schema/fixtures.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@ function readBytes(json: string) {
1818
export const rootFileTree = pathsToTree([
1919
'/dataset_description.json',
2020
'/T1w.json',
21+
'/task-movie_events.json',
22+
'/task-movie_physio.json',
2123
'/sub-01/ses-01_T1w.json',
2224
'/sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz',
2325
'/sub-01/ses-01/anat/sub-01_ses-01_T1w.json',
26+
'/sub-01/ses-01/func/sub-01_ses-01_task-movie_bold.nii.gz',
27+
'/sub-01/ses-01/func/sub-01_ses-01_task-movie_bold.nii.gz',
28+
'/sub-01/ses-01/func/sub-01_ses-01_task-movie_events.tsv',
29+
'/sub-01/ses-01/func/sub-01_ses-01_task-movie_physio.tsv.gz',
2430
...[...Array(10).keys()].map((i) => `/stimuli/stimfile${i}.png`),
2531
])
2632

@@ -35,5 +41,24 @@ const anatFileTree = subjectFileTree.directories[0].directories[0] as FileTree
3541

3642
export const dataFile = anatFileTree.get('sub-01_ses-01_T1w.nii.gz') as BIDSFile
3743
const anatJSONFile = anatFileTree.get('sub-01_ses-01_T1w.json') as BIDSFile
38-
anatJSONFile.readBytes = (size: number) =>
39-
Promise.resolve(new TextEncoder().encode(anatJson) as Uint8Array<ArrayBuffer>)
44+
anatJSONFile.readBytes = readBytes(anatJson)
45+
46+
const eventsSidecar = rootFileTree.get('task-movie_events.json') as BIDSFile
47+
eventsSidecar.readBytes = readBytes(
48+
JSON.stringify({
49+
StimulusPresentation: {
50+
ScreenDistance: 1.8,
51+
ScreenOrigin: ['top', 'left'],
52+
ScreenResolution: [1920, 1080],
53+
ScreenSize: [0.472, 0.265],
54+
},
55+
}),
56+
)
57+
const physioSidecar = rootFileTree.get('task-movie_physio.json') as BIDSFile
58+
physioSidecar.readBytes = readBytes(
59+
JSON.stringify({
60+
SamplingFrequency: 100,
61+
StartTime: 0,
62+
PhysioType: 'eyetrack',
63+
}),
64+
)

0 commit comments

Comments
 (0)