Skip to content
Open
5 changes: 3 additions & 2 deletions dandischema/consts.py
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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.
Expand Down
36 changes: 27 additions & 9 deletions dandischema/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", [])

Expand Down Expand Up @@ -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)
6 changes: 6 additions & 0 deletions dandischema/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
53 changes: 53 additions & 0 deletions dandischema/tests/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ def test_migrate_downgrade() -> None:
"numberOfSubjects": 1,
"numberOfSamples": 1,
"numberOfCells": 1,
"numberOfSessions": 1,
"dataStandard": [
{
"schemaKey": "StandardsType",
Expand All @@ -506,6 +507,7 @@ def test_migrate_downgrade() -> None:
"numberOfBytes": 608720,
"numberOfFiles": 2,
"numberOfSubjects": 1,
"numberOfSessions": 2,
"dataStandard": [
{
"schemaKey": "StandardsType",
Expand Down Expand Up @@ -553,6 +555,7 @@ def test_migrate_downgrade() -> None:
"numberOfSubjects": 2,
"numberOfSamples": 1,
"numberOfCells": 1,
"numberOfSessions": 2,
"dataStandard": [
{
"schemaKey": "StandardsType",
Expand Down Expand Up @@ -716,13 +719,63 @@ 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"])
== 1
) # 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",
Expand Down
6 changes: 6 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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_*
Expand Down
Loading