Skip to content

Commit 37209e3

Browse files
authored
Merge branch 'main' into feat/add-keys-to-assoc-coordsys
2 parents c381d01 + fe09d89 commit 37209e3

File tree

6 files changed

+152
-31
lines changed

6 files changed

+152
-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: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { BIDSContext } from './context.ts'
66
import { loadJSON } from '../files/json.ts'
77
import { loadTSV } from '../files/tsv.ts'
88
import { parseBvalBvec } from '../files/dwi.ts'
9-
import { walkBack } from '../files/inheritance.ts'
9+
import { readSidecars, walkBack } from '../files/inheritance.ts'
1010
import { evalCheck } from './applyRules.ts'
1111
import { expressionFunctions } from './expressionLanguage.ts'
1212
import { readEntities } from './entities.ts'
@@ -15,11 +15,20 @@ import { readText } from '../files/access.ts'
1515

1616
type LoadFunction = (file: BIDSFile, options: any) => Promise<any>
1717
type MultiLoadFunction = (files: BIDSFile[], options: any) => Promise<any>
18+
interface WithSidecar {
19+
sidecar: Record<string, unknown>
20+
}
1821

1922
function defaultAssociation(file: BIDSFile, _options: any): Promise<{ path: string }> {
2023
return Promise.resolve({ path: file.path })
2124
}
2225

26+
async function constructSidecar(file: BIDSFile): Promise<Record<string, unknown>> {
27+
const sidecars = await readSidecars(file)
28+
// Note ordering here gives precedence to the more specific sidecar
29+
return sidecars.values().reduce((acc, json) => ({ ...json, ...acc }), {})
30+
}
31+
2332
/**
2433
* This object describes lookup functions for files associated to data files in a bids dataset.
2534
* For any given data file we iterate over the associations defined schema.meta.associations.
@@ -29,14 +38,15 @@ function defaultAssociation(file: BIDSFile, _options: any): Promise<{ path: stri
2938
* Many associations only consist of a path; this object is for more complex associations.
3039
*/
3140
const associationLookup: Record<string, LoadFunction> = {
32-
events: async (file: BIDSFile, options: { maxRows: number }): Promise<Events> => {
41+
events: async (file: BIDSFile, options: { maxRows: number }): Promise<Events & WithSidecar> => {
3342
const columns = await loadTSV(file, options.maxRows)
3443
.catch((e) => {
3544
return new Map()
3645
})
3746
return {
3847
path: file.path,
3948
onset: columns.get('onset') || [],
49+
sidecar: await constructSidecar(file),
4050
}
4151
},
4252
aslcontext: async (
@@ -90,6 +100,12 @@ const associationLookup: Record<string, LoadFunction> = {
90100
sampling_frequency: columns.get('sampling_frequency'),
91101
}
92102
},
103+
physio: async (file: BIDSFile, options: any): Promise<{path: string} & WithSidecar> => {
104+
return {
105+
path: file.path,
106+
sidecar: await constructSidecar(file),
107+
}
108+
},
93109
}
94110
const multiAssociationLookup: Record<string, MultiLoadFunction> = {
95111
coordsystems: async (
@@ -116,18 +132,12 @@ export async function buildAssociations(
116132
const associations: Associations = {}
117133

118134
const schema: MetaSchema = context.dataset.schema as MetaSchema
119-
// Augment rule type with an entities field that should be present in BIDS 1.10.1+
120-
type ruleType = MetaSchema['meta']['associations'][keyof MetaSchema['meta']['associations']]
121-
type AugmentedRuleType = ruleType & {
122-
target: ruleType['target'] & { entities?: string[] }
123-
}
124135

125136
Object.assign(context, expressionFunctions)
126137
// @ts-expect-error
127138
context.exists.bind(context)
128139

129-
for (const key of Object.keys(schema.meta.associations)) {
130-
const rule = schema.meta.associations[key] as AugmentedRuleType
140+
for (const [key, rule] of Object.entries(schema.meta.associations)) {
131141
if (!rule.selectors!.every((x) => evalCheck(x, context))) {
132142
continue
133143
}

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)