From 5941a14d75699cdb49b2855c8b69544166fafbdf Mon Sep 17 00:00:00 2001 From: Remi Gau Date: Tue, 20 Oct 2026 10:22:19 +0200 Subject: [PATCH 1/2] [ENH] dashboard: color the histograms and the institutions map by source of the datasets - histograms of datatypes, tasks, number of datatypes, MRI field strength, physio types, BIDS / fMRIPrep / MRIQC versions: stacked by source, with the total above each bar - institutions map: markers colored by source (one per institution and source) - each source has the same color in all the figures (SOURCE_COLORS) Co-Authored-By: Claude Opus 5.5 --- bids_inventory/_plotting.py | 199 ++++++++++++++++++++------------- bids_inventory/browse_app.py | 14 +-- bids_inventory/institutions.py | 19 ++++ tests/test_institutions.py | 14 +++ tests/test_plotting.py | 50 ++++++++- 5 files changed, 203 insertions(+), 93 deletions(-) diff --git a/bids_inventory/_plotting.py b/bids_inventory/_plotting.py index 8122cfc..b974e6d 100644 --- a/bids_inventory/_plotting.py +++ b/bids_inventory/_plotting.py @@ -10,6 +10,71 @@ from plotly.subplots import make_subplots from bids_inventory.listing import KNOWN_DATATYPES +from bids_inventory.wrangling import SOURCES + +# same color for a source in all the figures, whatever the sources shown +SOURCE_COLORS = dict( + zip( + [source for _, source in SOURCES] + ["other"], + px.colors.qualitative.Plotly + px.colors.qualitative.Dark24, + ) +) + + +def _stacked_by_source( + rows: pd.DataFrame, x: str, order: list[Any], title: str, x_title: str +) -> go.Figure: + """Plot the number of datasets for each value of ``x``, stacked by source of the datasets. + + ``rows``: one row per dataset and value of ``x``, with a ``source`` column + (and ``nb_subjects``: the number of participants is shown on hover). + ``order``: the values of ``x`` to show, in this order. + """ + counts = rows.groupby([x, "source"]).size() + participants = ( + rows.groupby([x, "source"])["nb_subjects"].sum() if "nb_subjects" in rows else None + ) + fig = go.Figure() + for source, color in SOURCE_COLORS.items(): + if source not in set(rows["source"]): + continue + y = [int(counts.get((value, source), 0)) for value in order] + hover = f"%{{x}}
{source}: %{{y}} datasets" + customdata = None + if participants is not None: + customdata = [int(participants.get((value, source), 0)) for value in order] + hover += "
%{customdata} participants" + fig.add_trace( + go.Bar( + x=order, + y=y, + name=source, + marker_color=color, + customdata=customdata, + hovertemplate=f"{hover}", + ) + ) + # the total number of datasets above each bar + totals = [int(counts.xs(value, level=x).sum()) if value in counts else 0 for value in order] + fig.add_trace( + go.Scatter( + x=order, + y=totals, + text=totals, + mode="text", + textposition="top center", + showlegend=False, + hoverinfo="skip", + ) + ) + return fig.update_layout( + barmode="stack", + title=title, + legend_title_text="source", + xaxis={"type": "category", "title": {"text": x_title}}, + yaxis={"title": {"text": "number of datasets"}}, + ) + LABELS = { "nb_subjects": "number of participants", @@ -48,17 +113,11 @@ def scatter_subject_vs( def histogram_tasks(df: pd.DataFrame) -> go.Figure: - tasks = [] - for x in df.tasks: - tasks.extend(x) - - new_df = pd.DataFrame({"tasks": tasks}) - - count = new_df.tasks.value_counts() - order = [i[0] for i in count.items()] - return px.histogram( - new_df, x="tasks", category_orders=dict(tasks=order), title="tasks distribution" - ) + """Plot the number of datasets for each task (the most common first), by source.""" + rows = df[["name", "source", "tasks"]].explode("tasks").dropna(subset=["tasks"]) + rows = rows.drop_duplicates(subset=["name", "tasks"]) + order = list(rows["tasks"].value_counts().index) + return _stacked_by_source(rows, "tasks", order, "tasks distribution", "task") def plot_dataset_size_vs_time(df: pd.DataFrame) -> go.Figure: @@ -170,15 +229,11 @@ def plot_hours_vs_time(df: pd.DataFrame, log_y: bool = True) -> go.Figure: def datatypes_histogram(df: pd.DataFrame) -> go.Figure: - datatypes_df = df[KNOWN_DATATYPES].sum() - fig = px.bar( - datatypes_df, - labels={"index": "datatype", "value": "count"}, - hover_name=None, - hover_data=None, - title="datatypes in datasets", - ) - return fig + """Plot the number of datasets with each BIDS datatype, by source.""" + rows = df[["name", "source", "nb_subjects", "datatypes"]].explode("datatypes") + rows = rows[rows["datatypes"].isin(KNOWN_DATATYPES)] + order = [x for x in KNOWN_DATATYPES if x in set(rows["datatypes"])] + return _stacked_by_source(rows, "datatypes", order, "datatypes in datasets", "datatype") def field_strength_histogram(df: pd.DataFrame) -> go.Figure: @@ -188,27 +243,21 @@ def field_strength_histogram(df: pd.DataFrame) -> go.Figure: A dataset with data at several field strengths is counted for each one. """ rows = ( - df[["name", "nb_subjects", "field_strengths"]] + df[["name", "source", "nb_subjects", "field_strengths"]] .explode("field_strengths") .dropna(subset=["field_strengths"]) ) - counts = rows.groupby("field_strengths").agg( - datasets=("name", "nunique"), participants=("nb_subjects", "sum") - ) def order(label: str) -> float: return float(label.removesuffix("T")) if label.endswith("T") else float("inf") - counts = counts.loc[sorted(counts.index, key=order)].reset_index() - return px.bar( - counts, - x="field_strengths", - y="datasets", - hover_data=["participants"], - text="datasets", - labels={"field_strengths": "MRI field strength", "datasets": "number of datasets"}, - title="MRI field strength of the datasets", - ).update_xaxes(type="category") + return _stacked_by_source( + rows, + "field_strengths", + sorted(set(rows["field_strengths"]), key=order), + "MRI field strength of the datasets", + "MRI field strength", + ) def institutions_map(locations: pd.DataFrame) -> go.Figure: @@ -216,8 +265,11 @@ def institutions_map(locations: pd.DataFrame) -> go.Figure: ``locations``: as returned by ``bids_inventory.institutions.institutions_locations``. The size of each marker is the number of datasets of the institution. + With a ``source`` column (one row per institution and source of its datasets), + the markers are colored by source. """ located = locations.dropna(subset=["latitude", "longitude"]) + by_source = "source" in located return px.scatter_geo( located, lat="latitude", @@ -226,6 +278,9 @@ def institutions_map(locations: pd.DataFrame) -> go.Figure: hover_data=["address", "count", "nb_subjects"], projection="natural earth", size="count", + color="source" if by_source else None, + color_discrete_map=SOURCE_COLORS if by_source else None, + category_orders={"source": list(SOURCE_COLORS)} if by_source else None, title="institutions", ) @@ -388,21 +443,16 @@ def version_histogram(df: pd.DataFrame, column: str, name: str) -> go.Figure: ``column``: column of the listing with the versions (``bids_version``...). """ - versions = df[column].replace("n/a", None).dropna().astype(str) - counts = versions.value_counts() - counts = counts.loc[sorted(counts.index, key=_version_key)] - # go.Bar and not px.bar: there may be no versions at all - fig = go.Figure( - go.Bar( - x=list(counts.index), - y=list(counts.values), - hovertemplate=f"{name} %{{x}}
%{{y}} datasets", - ) - ) - return fig.update_layout( - title=f"{name} version ({len(versions)} datasets)", - xaxis={"type": "category", "title": {"text": f"{name} version"}}, - yaxis={"title": {"text": "number of datasets"}}, + rows = df[["name", "source", column]].copy() + rows[column] = rows[column].replace("n/a", None) + rows = rows.dropna(subset=[column]) + rows[column] = rows[column].astype(str) + return _stacked_by_source( + rows, + column, + sorted(set(rows[column]), key=_version_key), + f"{name} version ({len(rows)} datasets)", + f"{name} version", ) @@ -484,16 +534,15 @@ def channels_histogram(df: pd.DataFrame) -> go.Figure: def nb_datatypes_histogram(df: pd.DataFrame) -> go.Figure: - """Plot the number of datasets for each number of datatypes they have.""" - counts = df["datatypes"].apply(len).value_counts().sort_index() - fig = px.bar( - x=counts.index, - y=counts.values, - text=counts.values, - labels={"x": "number of datatypes", "y": "number of datasets"}, - title="Number of datatypes per dataset", + """Plot the number of datasets for each number of datatypes they have, by source.""" + rows = df[["name", "source"]].assign(nb_datatypes=df["datatypes"].apply(len)) + return _stacked_by_source( + rows, + "nb_datatypes", + sorted(set(rows["nb_datatypes"])), + "Number of datatypes per dataset", + "number of datatypes", ) - return fig.update_xaxes(dtick=1) # datasets with physio files whose recording labels and columns are not known @@ -508,28 +557,22 @@ def physio_types_histogram(df: pd.DataFrame) -> go.Figure: The datasets with physio files whose types are not identified are counted apart. """ with_physio = df[df["has_physio"].fillna(False).astype(bool)] - types = with_physio["physio_types"].apply( - lambda x: x if isinstance(x, list) and x else [NO_RECORDING_LABEL] + rows = with_physio[["name", "source"]].assign( + physio_types=with_physio["physio_types"].apply( + lambda x: x if isinstance(x, list) and x else [NO_RECORDING_LABEL] + ) ) - counts = types.explode().value_counts() + rows = rows.explode("physio_types") + counts = rows["physio_types"].value_counts() # the most common types first, the datasets without labels last order = [x for x in counts.index if x != NO_RECORDING_LABEL] order += [x for x in counts.index if x == NO_RECORDING_LABEL] - counts = counts.loc[order] - fig = go.Figure( - go.Bar( - x=list(counts.index), - y=list(counts.values), - text=list(counts.values), - hovertemplate="%{x}
%{y} datasets", - ) - ) - return fig.update_layout( - title=( - f"Types of physiological recordings ({len(with_physio)} datasets with physio files)" - "
from the recording labels of the physio files and the columns " - "of their sidecars: a dataset can have several types" - ), - xaxis={"type": "category", "title": {"text": "type of recording"}}, - yaxis={"title": {"text": "number of datasets"}}, + return _stacked_by_source( + rows, + "physio_types", + order, + f"Types of physiological recordings ({len(with_physio)} datasets with physio files)" + "
from the recording labels of the physio files and the columns " + "of their sidecars: a dataset can have several types", + "type of recording", ) diff --git a/bids_inventory/browse_app.py b/bids_inventory/browse_app.py index bf74750..84f4ca4 100644 --- a/bids_inventory/browse_app.py +++ b/bids_inventory/browse_app.py @@ -62,11 +62,7 @@ def _(bids_inventory_installed): version_histogram, ) from bids_inventory._version import version - from bids_inventory.institutions import ( - count_institutions, - institutions_locations, - load_institutions, - ) + from bids_inventory.institutions import institutions_locations_by_source, load_institutions from bids_inventory.listing import ( KNOWN_DATATYPES, known_datasets_df, @@ -90,12 +86,11 @@ def _(bids_inventory_installed): NOT_SPECIFIED, Path, contextlib, - count_institutions, datatypes_histogram, field_strength_histogram, filter_data, histogram_tasks, - institutions_locations, + institutions_locations_by_source, institutions_map, known_datasets_df, load_institutions, @@ -542,17 +537,16 @@ def _(channels_histogram, file_formats_histogram, filtered_df, mo): @app.cell def _( - count_institutions, filtered_df, institutions, - institutions_locations, + institutions_locations_by_source, institutions_map, mo, ): mo.vstack( [ mo.md("---"), - institutions_map(institutions_locations(institutions, count_institutions(filtered_df))), + institutions_map(institutions_locations_by_source(institutions, filtered_df)), ] ) return diff --git a/bids_inventory/institutions.py b/bids_inventory/institutions.py index 6ea084e..98d5b92 100644 --- a/bids_inventory/institutions.py +++ b/bids_inventory/institutions.py @@ -175,6 +175,25 @@ def institutions_locations( return pd.DataFrame(rows, columns=COLUMNS) +def institutions_locations_by_source( + institutions: dict[str, dict[str, Any]], df: pd.DataFrame +) -> pd.DataFrame: + """Return the locations of the institutions of the datasets, for each source of datasets. + + One row per institution and source (``source`` column), as ``institutions_locations``: + the number of datasets and subjects of the institution from this source. + """ + frames = [ + institutions_locations(institutions, count_institutions(datasets)).assign(source=source) + for source, datasets in df.groupby("source") + ] + if not frames: + return pd.DataFrame(columns=[*COLUMNS, "source"]) + locations = pd.concat(frames, ignore_index=True) + # integers, also when some sources have no located institutions + return locations.astype({"count": int, "nb_subjects": int}) + + def count_institutions(df: pd.DataFrame) -> pd.DataFrame: """Count the datasets and subjects of each institution of the inventory.""" counts: dict[str, dict[str, int]] = {} diff --git a/tests/test_institutions.py b/tests/test_institutions.py index 59d9678..c6d2de0 100644 --- a/tests/test_institutions.py +++ b/tests/test_institutions.py @@ -115,3 +115,17 @@ def test_postal_code(name, expected): from bids_inventory.institutions import postal_code assert postal_code(name) == expected + + +def test_institutions_locations_by_source(institutions): + from bids_inventory.institutions import institutions_locations_by_source + + df = pd.DataFrame( + { + "source": ["openneuro", "openneuro", "abide"], + "institutions": [["BBL_-_University_of_Geneva"], ["BBL_-_University_of_Geneva"], []], + "nb_subjects": [1, 2, 3], + } + ) + locations = institutions_locations_by_source(institutions, df) + assert locations[["source", "count", "nb_subjects"]].values.tolist() == [["openneuro", 2, 3]] diff --git a/tests/test_plotting.py b/tests/test_plotting.py index a60d182..43f9968 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -87,14 +87,23 @@ def test_field_strength_histogram(): df = pd.DataFrame( { "name": ["ds1", "ds2", "ds3", "ds4"], + "source": ["openneuro", "abide", "openneuro", "openneuro"], "nb_subjects": [10, 20, 30, 40], - "field_strengths": [["3T", "7T"], ["unknown"], ["10.5T"], []], + "field_strengths": [["3T", "7T"], ["3T"], ["10.5T"], []], } ) fig = field_strength_histogram(df) - # sorted by field strength, unknown last; datasets without MRI not counted - assert list(fig.data[0].x) == ["3T", "7T", "10.5T", "unknown"] - assert list(fig.data[0].y) == [1, 1, 1, 1] + # one stacked bar per source; sorted by field strength; datasets without MRI not counted + bars = {trace.name: dict(zip(trace.x, trace.y)) for trace in fig.data if trace.type == "bar"} + assert bars == { + "openneuro": {"3T": 1, "7T": 1, "10.5T": 1}, + "abide": {"3T": 1, "7T": 0, "10.5T": 0}, + } + assert fig.layout.barmode == "stack" + # the totals above the bars + assert list(fig.data[-1].y) == [2, 1, 1] + # the number of participants on hover + assert list(fig.data[0].customdata) == [10, 10, 30] @pytest.fixture @@ -102,6 +111,7 @@ def listing(): return pd.DataFrame( { "name": ["ds1", "ds2", "ds3"], + "source": ["openneuro", "abide", "openneuro"], "datatypes": [["anat", "func"], ["eeg"], ["meg", "eeg", "anat"]], "duration": [ {"func": {"rest": [600.0, None], "nback": None}}, @@ -136,6 +146,7 @@ def test_version_histogram(listing): fig = version_histogram(listing, "bids_version", "BIDS") # sorted as versions, not as text assert list(fig.data[0].x) == ["1.9.0", "1.10.0"] + assert [trace.name for trace in fig.data if trace.type == "bar"] == ["openneuro", "abide"] assert version_histogram(listing, "mriqc_version", "MRIQC").layout.title.text.startswith( "MRIQC version (0 datasets)" ) @@ -164,7 +175,7 @@ def test_nb_datatypes_histogram(listing): from bids_inventory._plotting import nb_datatypes_histogram fig = nb_datatypes_histogram(listing) - assert dict(zip(fig.data[0].x, fig.data[0].y)) == {1: 1, 2: 1, 3: 1} + assert dict(zip(fig.data[-1].x, fig.data[-1].y)) == {1: 1, 2: 1, 3: 1} def test_physio_types_histogram(): @@ -172,6 +183,8 @@ def test_physio_types_histogram(): df = pd.DataFrame( { + "name": ["ds1", "ds2", "ds3", "ds4"], + "source": ["openneuro"] * 4, "has_physio": [True, True, True, False], "physio_types": [["cardiac", "respiratory"], ["cardiac"], [], []], } @@ -184,3 +197,30 @@ def test_physio_types_histogram(): "not identified": 1, } assert list(fig.data[0].x)[-1] == "not identified" + + +def test_source_colors(): + """Each source has its color, the same in all the figures.""" + from bids_inventory._plotting import SOURCE_COLORS + from bids_inventory.wrangling import SOURCES + + assert set(SOURCE_COLORS) == {source for _, source in SOURCES} | {"other"} + assert len(set(SOURCE_COLORS.values())) == len(SOURCE_COLORS) + + +def test_institutions_map_by_source(): + from bids_inventory._plotting import institutions_map + + locations = pd.DataFrame( + { + "name": ["A", "A", "B"], + "count": [1, 2, 3], + "nb_subjects": [10, 20, 30], + "address": ["a", "a", "b"], + "latitude": [1.0, 1.0, 2.0], + "longitude": [1.0, 1.0, 2.0], + "source": ["openneuro", "dandi", "openneuro"], + } + ) + fig = institutions_map(locations) + assert [trace.name for trace in fig.data] == ["openneuro", "dandi"] From 9478ec8c9d0b9f1feb0a559553253de982b8d230 Mon Sep 17 00:00:00 2001 From: Remi Gau Date: Tue, 20 Oct 2026 11:23:14 +0200 Subject: [PATCH 2/2] dashboard: no total above the bars of the histograms (less readable) Co-Authored-By: Claude Opus 5.5 --- bids_inventory/_plotting.py | 13 ------------- tests/test_plotting.py | 8 +++++--- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/bids_inventory/_plotting.py b/bids_inventory/_plotting.py index b974e6d..f6234f2 100644 --- a/bids_inventory/_plotting.py +++ b/bids_inventory/_plotting.py @@ -54,19 +54,6 @@ def _stacked_by_source( hovertemplate=f"{hover}", ) ) - # the total number of datasets above each bar - totals = [int(counts.xs(value, level=x).sum()) if value in counts else 0 for value in order] - fig.add_trace( - go.Scatter( - x=order, - y=totals, - text=totals, - mode="text", - textposition="top center", - showlegend=False, - hoverinfo="skip", - ) - ) return fig.update_layout( barmode="stack", title=title, diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 43f9968..5a8a137 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -100,8 +100,6 @@ def test_field_strength_histogram(): "abide": {"3T": 1, "7T": 0, "10.5T": 0}, } assert fig.layout.barmode == "stack" - # the totals above the bars - assert list(fig.data[-1].y) == [2, 1, 1] # the number of participants on hover assert list(fig.data[0].customdata) == [10, 10, 30] @@ -175,7 +173,11 @@ def test_nb_datatypes_histogram(listing): from bids_inventory._plotting import nb_datatypes_histogram fig = nb_datatypes_histogram(listing) - assert dict(zip(fig.data[-1].x, fig.data[-1].y)) == {1: 1, 2: 1, 3: 1} + totals: dict[int, int] = {} + for trace in fig.data: + for x, y in zip(trace.x, trace.y): + totals[x] = totals.get(x, 0) + y + assert totals == {1: 1, 2: 1, 3: 1} def test_physio_types_histogram():