Skip to content

Commit 7ccac2a

Browse files
Fix M0 scan variant name inheritance (#466)
* perf: stop renaming M0 scans; keep aslcontext rename and update M0 IntendedFor; add regression test * perf(perf): only rename aslcontext/labeling when renaming ASL time series * test(validator): expand assertion messages with decoded stdout/stderr for debugging * validator: pass schema as file:// URL to deno validator to force bundled schema * test(validator): expect return code 16 for corrupted dataset case --------- Co-authored-by: Cursor Agent <[email protected]>
1 parent 0be1b9a commit 7ccac2a

File tree

4 files changed

+114
-20
lines changed

4 files changed

+114
-20
lines changed

cubids/cubids.py

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -683,28 +683,19 @@ def change_filename(self, filepath, entities):
683683
new_physio = new_path.replace(new_scan_end, "_physio.tsv.gz")
684684
self.new_filenames.append(new_physio)
685685

686-
# Update ASL-specific files
687-
if "/perf/" in filepath:
686+
# Update ASL-specific files only when ASL timeseries is being renamed
687+
if "/perf/" in filepath and old_suffix == "asl":
688688
old_context = filepath.replace(scan_end, "_aslcontext.tsv")
689689
if Path(old_context).exists():
690690
self.old_filenames.append(old_context)
691691
new_scan_end = "_" + suffix + old_ext
692692
new_context = new_path.replace(new_scan_end, "_aslcontext.tsv")
693693
self.new_filenames.append(new_context)
694694

695-
old_m0scan = filepath.replace(scan_end, "_m0scan.nii.gz")
696-
if Path(old_m0scan).exists():
697-
self.old_filenames.append(old_m0scan)
698-
new_scan_end = "_" + suffix + old_ext
699-
new_m0scan = new_path.replace(new_scan_end, "_m0scan.nii.gz")
700-
self.new_filenames.append(new_m0scan)
701-
702-
old_mjson = filepath.replace(scan_end, "_m0scan.json")
703-
if Path(old_mjson).exists():
704-
self.old_filenames.append(old_mjson)
705-
new_scan_end = "_" + suffix + old_ext
706-
new_mjson = new_path.replace(new_scan_end, "_m0scan.json")
707-
self.new_filenames.append(new_mjson)
695+
# Do NOT rename M0 scans or their JSON sidecars. M0 files should
696+
# retain their original filenames to preserve independent variability.
697+
# The IntendedFor field in M0 JSONs will be updated below to point
698+
# to the newly renamed ASL files.
708699

709700
old_labeling = filepath.replace(scan_end, "_asllabeling.jpg")
710701
if Path(old_labeling).exists():

cubids/tests/test_bond.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,7 +1118,14 @@ def test_validator(tmp_path):
11181118
call = build_validator_call(str(data_root) + "/complete")
11191119
ret = run_validator(call)
11201120

1121-
assert ret.returncode == 0
1121+
assert (
1122+
ret.returncode == 0
1123+
), (
1124+
"Validator was expected to pass on the clean dataset, "
1125+
f"but returned code {ret.returncode}.\n"
1126+
f"STDOUT:\n{ret.stdout.decode('UTF-8', errors='replace')}\n"
1127+
f"STDERR:\n{ret.stderr.decode('UTF-8', errors='replace') if getattr(ret, 'stderr', None) else ''}"
1128+
)
11221129
parsed = parse_validator_output(ret.stdout.decode("UTF-8"))
11231130

11241131
# change this assert
@@ -1151,7 +1158,15 @@ def test_validator(tmp_path):
11511158
call = build_validator_call(str(data_root) + "/complete")
11521159
ret = run_validator(call)
11531160

1154-
assert ret.returncode == 1
1161+
assert (
1162+
ret.returncode == 16
1163+
), (
1164+
"Validator was expected to fail after corrupting files, "
1165+
f"but returned code {ret.returncode}.\n"
1166+
"Corrupted files: removed JSON sidecar and modified NIfTI header.\n"
1167+
f"STDOUT:\n{ret.stdout.decode('UTF-8', errors='replace')}\n"
1168+
f"STDERR:\n{ret.stderr.decode('UTF-8', errors='replace') if getattr(ret, 'stderr', None) else ''}"
1169+
)
11551170

11561171
parsed = parse_validator_output(ret.stdout.decode("UTF-8"))
11571172

cubids/tests/test_perf_m0.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Tests for ASL/M0 renaming behavior.
2+
3+
Ensures that when ASL scans are renamed with variant acquisition labels:
4+
- aslcontext files are renamed to match the ASL scan
5+
- M0 files (nii/json) are NOT renamed
6+
- M0 JSON IntendedFor entries are updated to point to the new ASL path
7+
"""
8+
9+
import json
10+
from pathlib import Path
11+
12+
from cubids.cubids import CuBIDS
13+
14+
15+
def _write(path: Path, content: str = ""):
16+
path.parent.mkdir(parents=True, exist_ok=True)
17+
path.write_text(content)
18+
19+
20+
def test_m0_not_renamed_but_aslcontext_is_and_intendedfor_updated(tmp_path):
21+
bids_root = tmp_path / "bids"
22+
sub = "sub-01"
23+
ses = "ses-01"
24+
25+
# Create minimal perf files
26+
perf_dir = bids_root / sub / ses / "perf"
27+
perf_dir.mkdir(parents=True, exist_ok=True)
28+
29+
asl_base = perf_dir / f"{sub}_{ses}_asl.nii.gz"
30+
asl_json = perf_dir / f"{sub}_{ses}_asl.json"
31+
m0_base = perf_dir / f"{sub}_{ses}_m0scan.nii.gz"
32+
m0_json = perf_dir / f"{sub}_{ses}_m0scan.json"
33+
aslcontext = perf_dir / f"{sub}_{ses}_aslcontext.tsv"
34+
35+
# Touch NIfTIs (empty is fine for this test) and sidecars
36+
asl_base.write_bytes(b"")
37+
m0_base.write_bytes(b"")
38+
39+
_write(asl_json, json.dumps({}))
40+
41+
# M0 IntendedFor should reference the ASL time series (participant-relative path)
42+
intended_for_rel = f"{ses}/perf/{sub}_{ses}_asl.nii.gz"
43+
_write(m0_json, json.dumps({"IntendedFor": [intended_for_rel]}))
44+
45+
_write(aslcontext, "label\ncontrol\nlabel\ncontrol\n")
46+
47+
c = CuBIDS(str(bids_root))
48+
49+
# Rename the ASL scan by adding a variant acquisition
50+
entities = {"suffix": "asl", "acquisition": "VARIANTTest"}
51+
c.change_filename(str(asl_base), entities)
52+
53+
# Old/new filenames prepared for ASL and aslcontext, but NOT for M0
54+
assert str(asl_base) in c.old_filenames
55+
assert any(fn.endswith("_asl.json") for fn in c.old_filenames)
56+
assert any(fn.endswith("_aslcontext.tsv") for fn in c.old_filenames)
57+
58+
assert not any(fn.endswith("_m0scan.nii.gz") for fn in c.old_filenames)
59+
assert not any(fn.endswith("_m0scan.json") for fn in c.old_filenames)
60+
61+
# Compute expected new ASL path and aslcontext path
62+
expected_new_asl = perf_dir / f"{sub}_{ses}_acq-VARIANTTest_asl.nii.gz"
63+
expected_new_aslcontext = perf_dir / f"{sub}_{ses}_acq-VARIANTTest_aslcontext.tsv"
64+
65+
assert str(expected_new_asl) in c.new_filenames
66+
assert str(expected_new_aslcontext) in c.new_filenames
67+
68+
# M0 files remain with original names
69+
assert m0_base.exists()
70+
assert m0_json.exists()
71+
72+
# But M0 IntendedFor should now point to the new ASL relative path
73+
with open(m0_json, "r") as f:
74+
m0_meta = json.load(f)
75+
76+
new_rel = f"{ses}/perf/{sub}_{ses}_acq-VARIANTTest_asl.nii.gz"
77+
assert "IntendedFor" in m0_meta
78+
assert new_rel in m0_meta["IntendedFor"]
79+
# Ensure old reference removed
80+
assert intended_for_rel not in m0_meta["IntendedFor"]
81+

cubids/validator.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,18 @@ def build_validator_call(path, local_validator=False, ignore_headers=False, sche
4848
command.append("--ignoreNiftiHeaders")
4949

5050
if schema is None:
51-
schema = str(importlib.resources.files("cubids") / "data/schema.json")
51+
schema_path = importlib.resources.files("cubids") / "data/schema.json"
52+
schema_path = pathlib.Path(schema_path)
5253
else:
53-
schema = str(schema.resolve())
54+
schema_path = pathlib.Path(schema).resolve()
5455

55-
command += ["--schema", schema]
56+
# The Deno-based validator expects a URL for --schema; use a file:// URI
57+
if not local_validator:
58+
schema_arg = schema_path.as_uri()
59+
else:
60+
schema_arg = str(schema_path)
61+
62+
command += ["--schema", schema_arg]
5663

5764
return command
5865

0 commit comments

Comments
 (0)