diff --git a/dandischema/consts.py b/dandischema/consts.py index 863d7647..38310d8d 100644 --- a/dandischema/consts.py +++ b/dandischema/consts.py @@ -1,6 +1,6 @@ from packaging.version import Version as _Version -DANDI_SCHEMA_VERSION = "0.8.0" +DANDI_SCHEMA_VERSION = "0.8.1" ALLOWED_INPUT_SCHEMAS = [ "0.4.4", "0.5.1", @@ -17,12 +17,13 @@ "0.6.9", "0.6.10", "0.7.0", + "0.8.0", DANDI_SCHEMA_VERSION, ] # We establish migrations (back) to only a few recent versions. # When adding changes, please consider whether a migration path should be added. -ALLOWED_TARGET_SCHEMAS = ["0.6.10", "0.7.0", DANDI_SCHEMA_VERSION] +ALLOWED_TARGET_SCHEMAS = ["0.6.10", "0.7.0", "0.8.0", DANDI_SCHEMA_VERSION] # This allows multiple schemas for validation, whereas target schemas focus on # migration. diff --git a/dandischema/metadata.py b/dandischema/metadata.py index 5480a0da..847de382 100644 --- a/dandischema/metadata.py +++ b/dandischema/metadata.py @@ -510,15 +510,32 @@ def _add_asset_to_stats(assetmeta: Dict[str, Any], stats: _stats_type) -> None: # which components already found, so we do not count more than # once in some incorrectly named datasets found: Dict[str, str] = {} - for part in Path(assetmeta["path"]).name.split(".")[0].split("_"): - if not found.get("subject") and part.startswith("sub-"): - found["subject"] = subject = part.split("sub-", 1)[1] - if subject not in stats["subjects"]: - stats["subjects"].append(subject) - if not found.get("sample") and part.startswith("sample-"): - found["sample"] = sample = part.replace("sample-", "") - if sample not in stats["tissuesample"]: - stats["tissuesample"].append(sample) + asset_path = Path(assetmeta["path"]) + # (entity key, BIDS prefix, unique-values bucket in stats; None if not aggregated) + entities = [ + ("subject", "sub-", "subjects"), + ("sample", "sample-", "tissuesample"), + ("session", "ses-", None), + ] + for part in asset_path.name.split(".")[0].split("_"): + for key, prefix, bucket in entities: + if not found.get(key) and part.startswith(prefix): + found[key] = value = part.split(prefix, 1)[1] + if bucket is not None and value not in stats[bucket]: + stats[bucket].append(value) + # If ses- is absent from the filename, fall back to scanning the path + # parts (BIDS keeps `ses-X` as its own directory). Deliberately no such + # fallback for sub-, so that subject counting behavior stays unchanged. + if not found.get("session"): + for part in asset_path.parts[:-1]: + if part.startswith("ses-"): + found["session"] = part.split("ses-", 1)[1] + break + stats["sessions"] = stats.get("sessions", []) + if found.get("subject") and found.get("session"): + pair = (found["subject"], found["session"]) + if pair not in stats["sessions"]: + stats["sessions"].append(pair) stats["dataStandard"] = stats.get("dataStandard", []) @@ -553,4 +570,5 @@ def aggregate_assets_summary(metadata: Iterable[Dict[str, Any]]) -> dict: len(stats.pop("tissuesample", [])) + len(stats.pop("slice", [])) ) or None stats["numberOfCells"] = len(stats.pop("cell", [])) or None + stats["numberOfSessions"] = len(stats.pop("sessions", [])) or None return models.AssetsSummary(**stats).model_dump(mode="json", exclude_none=True) diff --git a/dandischema/models.py b/dandischema/models.py index c91afad0..b5000e2a 100644 --- a/dandischema/models.py +++ b/dandischema/models.py @@ -1200,6 +1200,12 @@ class AssetsSummary(DandiBaseModel): None, json_schema_extra={"readOnly": True} ) # more of NWB numberOfCells: Optional[int] = Field(None, json_schema_extra={"readOnly": True}) + numberOfSessions: Optional[int] = Field( + None, + description="Number of unique (subject, session) pairs, derived from " + "BIDS or DANDI ses-* tokens in asset file names or paths.", + json_schema_extra={"readOnly": True}, + ) dataStandard: Optional[List[StandardsType]] = Field( None, json_schema_extra={"readOnly": True} diff --git a/dandischema/tests/test_metadata.py b/dandischema/tests/test_metadata.py index fd2e3f1e..fd2227ad 100644 --- a/dandischema/tests/test_metadata.py +++ b/dandischema/tests/test_metadata.py @@ -486,6 +486,7 @@ def test_migrate_downgrade() -> None: "numberOfSubjects": 1, "numberOfSamples": 1, "numberOfCells": 1, + "numberOfSessions": 1, "dataStandard": [ { "schemaKey": "StandardsType", @@ -506,6 +507,7 @@ def test_migrate_downgrade() -> None: "numberOfBytes": 608720, "numberOfFiles": 2, "numberOfSubjects": 1, + "numberOfSessions": 2, "dataStandard": [ { "schemaKey": "StandardsType", @@ -553,6 +555,7 @@ def test_migrate_downgrade() -> None: "numberOfSubjects": 2, "numberOfSamples": 1, "numberOfCells": 1, + "numberOfSessions": 2, "dataStandard": [ { "schemaKey": "StandardsType", @@ -716,6 +719,7 @@ def test_aggregation_bids() -> None: assert summary["numberOfFiles"] == 3 assert summary["numberOfSamples"] == 2 assert summary["numberOfSubjects"] == 1 + assert summary["numberOfSessions"] == 2 assert sum("BIDS" in _.get("name", "") for _ in summary["dataStandard"]) == 1 assert ( sum(_.get("name", "").startswith("OME/NGFF") for _ in summary["dataStandard"]) @@ -723,6 +727,55 @@ def test_aggregation_bids() -> None: ) # only a single entry so we do not duplicate them +def _bids_asset(path: str, size: int = 1) -> Dict[str, Any]: + return { + "schemaKey": "Asset", + "schemaVersion": DANDI_SCHEMA_VERSION, + "path": path, + "contentSize": size, + "encodingFormat": "application/x-nwb", + } + + +@pytest.mark.ai_generated +def test_aggregate_number_of_sessions() -> None: + # Same subject, two sessions (session token only in filename) + data = [ + _bids_asset("sub-01/ses-A/eeg/sub-01_ses-A_task-rest_eeg.edf"), + _bids_asset("sub-01/ses-B/eeg/sub-01_ses-B_task-rest_eeg.edf"), + ] + summary = aggregate_assets_summary(data) + assert summary["numberOfSubjects"] == 1 + assert summary["numberOfSessions"] == 2 + + # Two subjects sharing a session id "A" -> two distinct (sub, ses) pairs + data = [ + _bids_asset("sub-01/ses-A/eeg/sub-01_ses-A_task-rest_eeg.edf"), + _bids_asset("sub-02/ses-A/eeg/sub-02_ses-A_task-rest_eeg.edf"), + ] + summary = aggregate_assets_summary(data) + assert summary["numberOfSubjects"] == 2 + assert summary["numberOfSessions"] == 2 + + # ses- only in directory portion (filename omits it) still counts + data = [ + _bids_asset("sub-01/ses-A/anat/sub-01_T1w.nii.gz"), + ] + summary = aggregate_assets_summary(data) + assert summary["numberOfSessions"] == 1 + + # No ses- anywhere -> field is absent + data = [_bids_asset("sub-01/anat/sub-01_T1w.nii.gz")] + summary = aggregate_assets_summary(data) + assert "numberOfSessions" not in summary + + # ses- present but no sub- anywhere -> no (subject, session) pair, so + # no session is counted + data = [_bids_asset("ses-A/eeg/task-rest_eeg.edf")] + summary = aggregate_assets_summary(data) + assert "numberOfSessions" not in summary + + class TestGetJsonschemaValidator: @pytest.mark.parametrize( "schema_version, schema_key, expected_error_msg", diff --git a/tox.ini b/tox.ini index 2a2eb841..92a0511f 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,12 @@ envlist = lint,typing,py3 isolated_build = True [testenv] +# Install editable so the measured source is the in-repo tree rather than the +# copy under the venv's site-packages. Without this, coverage's `source = +# dandischema` resolves to the working tree while the package actually executes +# from site-packages, so no source module is measured and Codecov reports 0% +# coverage for everything but the test files. +usedevelop = true extras = test passenv = DANDI_*