From f2e9d459513bb18e419bdbf0e5a4c8c9be5971ad Mon Sep 17 00:00:00 2001 From: Ben Dichter Date: Wed, 1 Jul 2026 22:13:22 -0400 Subject: [PATCH 1/4] Add species ontology recommender and in-file HERD annotation Introduces `neuroconv.tools.ontology`, an offline, dependency-free lookup of common neuroscience species. `make_nwbfile_from_metadata` now: - emits a non-blocking suggestion (`validate_species`) when `Subject.species` is a recognized common name (e.g. "mouse") or a likely typo, pointing at the canonical Latin binomial and its NCBITaxon identifier; and - attaches a machine-readable NCBITaxon external-resource reference (`add_species_external_resource`) for a recognized binomial, stored in-file under `/general/external_resources` via HDMF's HERD, so downstream tools can resolve the term without guessing. The HERD is built with pynwb's type map (required for the `Subject.species` attribute to resolve) and the write is idempotent. In-file HERD storage relies on pynwb>=4.0.0, which is now the minimum pin. Closes #1771 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 + .../nwb_helpers/_metadata_and_file_helpers.py | 16 +- src/neuroconv/tools/ontology/__init__.py | 19 ++ .../tools/ontology/_external_resources.py | 77 ++++++ src/neuroconv/tools/ontology/_species.py | 228 ++++++++++++++++++ .../test_ontology_external_resources.py | 97 ++++++++ .../test_tools/test_ontology_species.py | 104 ++++++++ 7 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 src/neuroconv/tools/ontology/__init__.py create mode 100644 src/neuroconv/tools/ontology/_external_resources.py create mode 100644 src/neuroconv/tools/ontology/_species.py create mode 100644 tests/test_minimal/test_tools/test_ontology_external_resources.py create mode 100644 tests/test_minimal/test_tools/test_ontology_species.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 210d6225da..d1d3a0a4e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ * Added `TDTEventsInterface` for converting discrete events (epocs) from a TDT tank folder, depending on the `ndx-events` extension. [PR #1751](https://github.com/catalystneuro/neuroconv/pull/1751) * Added `VameInterface` for converting VAME behavioral segmentation data to NWB using the `ndx-vame` extension. Writes per-frame motif labels (`MotifSeries`), optional latent-space embeddings (`LatentSpaceSeries`), optional community labels (`CommunitySeries`), and serializes the VAME project config as JSON. [PR #1737](https://github.com/catalystneuro/neuroconv/pull/1737) * Added `AxonIntracellularInterface` for converting intracellular electrophysiology recorded in Axon Binary Format (.abf). Each instance handles one electrode in one file, writing its response as a single continuous `PatchClampSeries` (with an optional paired stimulus taken either from a recorded monitor channel or from a reconstructed protocol command) and one `IntracellularRecordings` row per sweep, addressed by `(start_index, count)` ranges and tagged with the run's `sequence` and `stimulus_type` columns. The clamp mode (`voltage_clamp`, `current_clamp`, or `izero`) is selected explicitly because Axon clamp-mode metadata is unreliable. It uses the dict-based metadata format keyed by `metadata_key`. The interface writes only the per-sweep recordings rows; the upper icephys hierarchy tables (`SimultaneousRecordings` and above) are assembled separately once the full set of channels and files is known. [PR #1746](https://github.com/catalystneuro/neuroconv/pull/1746) +* Added `neuroconv.tools.ontology` with `validate_species`/`get_species_suggestion`, an offline recommender that maps common species names and typos to their canonical Latin binomial and NCBITaxon identifier. `make_nwbfile_from_metadata` now emits a non-blocking suggestion when `Subject.species` is a recognized common name (e.g. `"mouse"` → `Mus musculus` / `NCBITaxon:10090`) or a likely typo. [PR #1771](https://github.com/catalystneuro/neuroconv/pull/1771) +* Added HERD external-resource integration for `Subject.species`. When the species is a recognized Latin binomial, `make_nwbfile_from_metadata` now attaches a machine-readable NCBITaxon reference to the file (stored in-file under `/general/external_resources` via `add_species_external_resource`), so downstream tools can resolve the term without guessing. [PR #1771](https://github.com/catalystneuro/neuroconv/pull/1771) * Added `XClustSortingInterface` for converting XClust (.CEL) spike sorting data, using the `XClustSortingExtractor` from SpikeInterface. [PR #1691](https://github.com/catalystneuro/neuroconv/pull/1691) * Added `IntanStimInterface` for converting electrical stimulation current data from Intan RHS2000 systems. Stimulation channels (one per amplifier channel, named `{channel}_STIM`) are written as a `TimeSeries` with `unit="A"` (Amperes), with the conversion factor derived automatically from `stim_step_size` in the .rhs file header. [PR #1711](https://github.com/catalystneuro/neuroconv/pull/1711) * Added a `saved_files_are_split` parameter to `IntanRecordingInterface`, `IntanAnalogInterface`, and `IntanStimInterface` to support Intan RHX recordings saved with the "create a new save file every N minutes" option. When enabled, all sibling `.rhd`/`.rhs` files in the session folder are concatenated in filename order. When disabled, the interface warns if sibling rotation files are detected next to the one the user pointed at. [PR #1724](https://github.com/catalystneuro/neuroconv/pull/1724) diff --git a/src/neuroconv/tools/nwb_helpers/_metadata_and_file_helpers.py b/src/neuroconv/tools/nwb_helpers/_metadata_and_file_helpers.py index c3bdc890fa..0eed4815c6 100644 --- a/src/neuroconv/tools/nwb_helpers/_metadata_and_file_helpers.py +++ b/src/neuroconv/tools/nwb_helpers/_metadata_and_file_helpers.py @@ -21,6 +21,7 @@ ) from ...utils.dict import DeepDict, load_dict_from_file from ...utils.json_schema import validate_metadata +from ..ontology import add_species_external_resource, validate_species def get_module(nwbfile: NWBFile, name: str, description: str = None): @@ -109,6 +110,13 @@ def make_nwbfile_from_metadata(metadata: dict) -> NWBFile: schema_path = Path(__file__).resolve().parent.parent.parent / "schemas" / "base_metadata_schema.json" base_metadata_schema = load_dict_from_file(file_path=schema_path) assert metadata is not None, "Metadata is required to create an NWBFile but metadata=None was passed." + + # Recommend a standardized species term (non-blocking). Runs before schema validation so + # that common names (e.g. "mouse") surface a helpful suggestion even though the schema's + # binomial pattern will subsequently reject them. + if isinstance(metadata.get("Subject"), dict): + validate_species(metadata["Subject"].get("species")) + validate_metadata(metadata=metadata, schema=base_metadata_schema) nwbfile_kwargs = deepcopy(metadata["NWBFile"]) @@ -133,7 +141,13 @@ def make_nwbfile_from_metadata(metadata: dict) -> NWBFile: ) nwbfile_kwargs["subject"] = Subject(**nwbfile_kwargs["subject"]) - return NWBFile(**nwbfile_kwargs) + nwbfile = NWBFile(**nwbfile_kwargs) + + # Attach a machine-readable NCBITaxon reference for the subject species (in-file HERD). + # No-op when there is no subject or the species is not recognized. + add_species_external_resource(nwbfile) + + return nwbfile def add_device_from_metadata(nwbfile: NWBFile, modality: str = "Ecephys", metadata: dict | None = None): diff --git a/src/neuroconv/tools/ontology/__init__.py b/src/neuroconv/tools/ontology/__init__.py new file mode 100644 index 0000000000..a9d586ca11 --- /dev/null +++ b/src/neuroconv/tools/ontology/__init__.py @@ -0,0 +1,19 @@ +"""Tools for recommending standardized ontology terms for NWB metadata.""" + +from ._external_resources import add_species_external_resource +from ._species import ( + SPECIES_TERMS, + SpeciesTerm, + get_species_suggestion, + get_species_term, + validate_species, +) + +__all__ = [ + "SPECIES_TERMS", + "SpeciesTerm", + "add_species_external_resource", + "get_species_suggestion", + "get_species_term", + "validate_species", +] diff --git a/src/neuroconv/tools/ontology/_external_resources.py b/src/neuroconv/tools/ontology/_external_resources.py new file mode 100644 index 0000000000..c3a4c9947d --- /dev/null +++ b/src/neuroconv/tools/ontology/_external_resources.py @@ -0,0 +1,77 @@ +"""Attach ontology entity references to NWB files via HDMF's HERD. + +HERD (HDMF External Resources Data) lets an NWB file carry machine-readable links from its +metadata values to entities in external ontologies. NeuroConv uses it to annotate values it +can recognize -- currently ``Subject.species`` -> NCBITaxon -- so downstream tools (e.g. the +DANDI archive) can resolve the term without guessing. + +The reference is stored in-file under ``/general/external_resources``, which requires +``pynwb >= 4.0.0`` (guaranteed by NeuroConv's dependency pin). +""" + +from pynwb import NWBFile, get_type_map + +from ._species import get_species_term + +__all__ = ["add_species_external_resource"] + + +def _species_already_annotated(herd, subject) -> bool: + """Whether ``herd`` already has a species entity for ``subject`` (keeps the call idempotent).""" + try: + existing = herd.get_object_entities(subject, attribute="species") + except ValueError: + # Raised when the subject is not yet registered in the object table. + return False + return not existing.empty + + +def add_species_external_resource(nwbfile: NWBFile) -> bool: + """ + Annotate ``nwbfile.subject.species`` with its NCBITaxon entity via HERD. + + Adds an external-resource reference mapping the subject's species (a Latin binomial) to its + NCBITaxon identifier, stored in-file under ``/general/external_resources``. This is a no-op + (returns ``False``) when there is no subject or the species is not recognized. + + Parameters + ---------- + nwbfile : NWBFile + The file whose subject species should be annotated. Modified in place. + + Returns + ------- + bool + ``True`` if a reference was added, ``False`` otherwise. + """ + subject = getattr(nwbfile, "subject", None) + if subject is None: + return False + + species = subject.species + term = get_species_term(species) + if term is None: + return False + + from hdmf.common import HERD + + herd = nwbfile.external_resources + is_new_herd = herd is None + if is_new_herd: + herd = HERD(type_map=get_type_map()) + elif _species_already_annotated(herd, subject): + return False + + herd.add_ref( + container=subject, + attribute="species", + key=species, + entity_id=term.ncbitaxon_id, + entity_uri=term.entity_uri, + ) + + # ``external_resources`` is write-once; only assign when we created the HERD, otherwise we + # have extended the object already linked to the file in place. + if is_new_herd: + nwbfile.external_resources = herd + return True diff --git a/src/neuroconv/tools/ontology/_species.py b/src/neuroconv/tools/ontology/_species.py new file mode 100644 index 0000000000..07dc07e2a4 --- /dev/null +++ b/src/neuroconv/tools/ontology/_species.py @@ -0,0 +1,228 @@ +"""Lightweight, offline recommendation of standardized species terms. + +NWB stores ``Subject.species`` as a binomial Latin name (or a taxonomy URL). To make +files interoperable and to support downstream external-resource annotation (HERD / +NCBITaxon), this module recognizes common neuroscience species and gently suggests the +canonical Latin binomial and its NCBITaxon identifier when a user supplies a common name +(``"mouse"``) or a likely typo (``"Homo sapien"``). + +This is intentionally a small curated table rather than a full ontology client: it runs +offline, has no extra dependencies, and only emits a suggestion when it is confident there +is a better term. Valid-but-uncommon species pass silently. +""" + +import difflib +import warnings +from dataclasses import dataclass + +__all__ = [ + "SPECIES_TERMS", + "SpeciesTerm", + "get_species_suggestion", + "get_species_term", + "validate_species", +] + + +@dataclass(frozen=True) +class SpeciesTerm: + """A canonical species term with its NCBITaxon external identifier.""" + + canonical_name: str + ncbitaxon_id: str + + @property + def entity_uri(self) -> str: + """Resolvable URI for the NCBITaxon entity (usable as a HERD ``entity_uri``).""" + taxon_number = self.ncbitaxon_id.split(":", 1)[1] + return f"http://purl.obolibrary.org/obo/NCBITaxon_{taxon_number}" + + +# Canonical Latin binomial -> NCBITaxon identifier (CURIE) for common neuroscience species. +SPECIES_TERMS: dict[str, SpeciesTerm] = { + canonical_name: SpeciesTerm(canonical_name=canonical_name, ncbitaxon_id=ncbitaxon_id) + for canonical_name, ncbitaxon_id in { + "Mus musculus": "NCBITaxon:10090", + "Rattus norvegicus": "NCBITaxon:10116", + "Homo sapiens": "NCBITaxon:9606", + "Macaca mulatta": "NCBITaxon:9544", + "Macaca fascicularis": "NCBITaxon:9541", + "Callithrix jacchus": "NCBITaxon:9483", + "Mustela putorius furo": "NCBITaxon:9669", + "Danio rerio": "NCBITaxon:7955", + "Drosophila melanogaster": "NCBITaxon:7227", + "Caenorhabditis elegans": "NCBITaxon:6239", + "Gallus gallus": "NCBITaxon:9031", + "Sus scrofa": "NCBITaxon:9823", + "Oryctolagus cuniculus": "NCBITaxon:9986", + "Cavia porcellus": "NCBITaxon:10141", + "Felis catus": "NCBITaxon:9685", + "Canis lupus familiaris": "NCBITaxon:9615", + "Ovis aries": "NCBITaxon:9940", + "Mesocricetus auratus": "NCBITaxon:10036", + "Monodelphis domestica": "NCBITaxon:13616", + "Taeniopygia guttata": "NCBITaxon:59729", + "Xenopus laevis": "NCBITaxon:8355", + "Ambystoma mexicanum": "NCBITaxon:8296", + "Carassius auratus": "NCBITaxon:7957", + "Apis mellifera": "NCBITaxon:7460", + }.items() +} + + +# Common (English) names -> canonical Latin binomial. Keys are compared case-insensitively. +_COMMON_NAME_TO_CANONICAL: dict[str, str] = { + "mouse": "Mus musculus", + "house mouse": "Mus musculus", + "rat": "Rattus norvegicus", + "norway rat": "Rattus norvegicus", + "human": "Homo sapiens", + "macaque": "Macaca mulatta", + "rhesus macaque": "Macaca mulatta", + "rhesus monkey": "Macaca mulatta", + "cynomolgus macaque": "Macaca fascicularis", + "crab-eating macaque": "Macaca fascicularis", + "marmoset": "Callithrix jacchus", + "common marmoset": "Callithrix jacchus", + "ferret": "Mustela putorius furo", + "zebrafish": "Danio rerio", + "fruit fly": "Drosophila melanogaster", + "fly": "Drosophila melanogaster", + "worm": "Caenorhabditis elegans", + "c. elegans": "Caenorhabditis elegans", + "chicken": "Gallus gallus", + "pig": "Sus scrofa", + "rabbit": "Oryctolagus cuniculus", + "guinea pig": "Cavia porcellus", + "cat": "Felis catus", + "dog": "Canis lupus familiaris", + "sheep": "Ovis aries", + "hamster": "Mesocricetus auratus", + "golden hamster": "Mesocricetus auratus", + "syrian hamster": "Mesocricetus auratus", + "opossum": "Monodelphis domestica", + "zebra finch": "Taeniopygia guttata", + "axolotl": "Ambystoma mexicanum", + "goldfish": "Carassius auratus", + "honeybee": "Apis mellifera", + "honey bee": "Apis mellifera", +} + + +def get_species_suggestion(species: str) -> tuple[SpeciesTerm, str] | None: + """ + Suggest a canonical species term for a user-provided species string. + + The lookup is high-precision: it only returns a suggestion when ``species`` is a + recognized common name or a close typo of a known Latin binomial. An exact match to a + known canonical name, an unrecognized (but possibly valid) binomial, or a taxonomy URL + all return ``None`` (nothing to suggest). + + Parameters + ---------- + species : str + The species value as it would be written to ``Subject.species``. + + Returns + ------- + tuple of (SpeciesTerm, str) or None + The suggested canonical term and a human-readable reason for the suggestion, or + ``None`` when no confident suggestion is available. + """ + if not isinstance(species, str): + return None + + stripped = species.strip() + if stripped == "": + return None + + # Already a canonical Latin binomial we recognize -> nothing to suggest. + if stripped in SPECIES_TERMS: + return None + + # A taxonomy URL is already a machine-readable reference -> leave it alone. + lowered = stripped.lower() + if lowered.startswith(("http://", "https://")) or lowered.startswith("ncbitaxon:"): + return None + + # Recognized common (English) name. + if lowered in _COMMON_NAME_TO_CANONICAL: + canonical_name = _COMMON_NAME_TO_CANONICAL[lowered] + reason = f"{species!r} is a common name" + return SPECIES_TERMS[canonical_name], reason + + # Likely typo of a known binomial (e.g. "Homo sapien" -> "Homo sapiens"). + close_matches = difflib.get_close_matches(stripped, SPECIES_TERMS.keys(), n=1, cutoff=0.85) + if close_matches: + canonical_name = close_matches[0] + reason = f"{species!r} closely matches a known species name" + return SPECIES_TERMS[canonical_name], reason + + return None + + +def get_species_term(species: str | None) -> SpeciesTerm | None: + """ + Resolve a species value to its canonical term, including exact canonical matches. + + Unlike :func:`get_species_suggestion` (which only fires when there is a *better* term to + recommend), this returns the :class:`SpeciesTerm` whenever the species can be recognized + at all: an exact canonical binomial, a common name, or a likely typo. It is the lookup + used to attach an NCBITaxon external-resource reference to an already-valid species value. + + Parameters + ---------- + species : str or None + The species value as it would be written to ``Subject.species``. + + Returns + ------- + SpeciesTerm or None + The canonical term for a recognized species, or ``None`` when it cannot be resolved. + """ + if not isinstance(species, str): + return None + + stripped = species.strip() + if stripped in SPECIES_TERMS: + return SPECIES_TERMS[stripped] + + suggestion = get_species_suggestion(species) + return suggestion[0] if suggestion is not None else None + + +def validate_species(species: str | None) -> SpeciesTerm | None: + """ + Warn (non-blocking) when a better standardized species term is available. + + This never raises and never blocks conversion; it only emits a ``UserWarning`` with a + concrete suggestion when ``species`` is a recognized common name or a likely typo. It + is safe to call on any metadata value, including ``None``. + + Parameters + ---------- + species : str or None + The species value as it would be written to ``Subject.species``. + + Returns + ------- + SpeciesTerm or None + The suggested canonical term (also surfaced via the warning), or ``None`` when no + suggestion was made. + """ + if species is None: + return None + + suggestion = get_species_suggestion(species) + if suggestion is None: + return None + + term, reason = suggestion + warnings.warn( + f"Subject species {reason}. Consider using the Latin binomial " + f"{term.canonical_name!r} ({term.ncbitaxon_id}) for interoperability. " + f"See https://bioregistry.io/{term.ncbitaxon_id}", + UserWarning, + stacklevel=2, + ) + return term diff --git a/tests/test_minimal/test_tools/test_ontology_external_resources.py b/tests/test_minimal/test_tools/test_ontology_external_resources.py new file mode 100644 index 0000000000..627dd7ba3f --- /dev/null +++ b/tests/test_minimal/test_tools/test_ontology_external_resources.py @@ -0,0 +1,97 @@ +"""Tests for attaching ontology entity references (HERD) to NWB files.""" + +from datetime import datetime + +from dateutil.tz import tzutc +from pynwb import NWBFile +from pynwb.file import Subject + +from neuroconv.tools.ontology import add_species_external_resource + + +def _make_nwbfile(species=None, with_subject=True) -> NWBFile: + nwbfile = NWBFile( + session_description="d", + identifier="id", + session_start_time=datetime(2020, 1, 1, tzinfo=tzutc()), + ) + if with_subject: + nwbfile.subject = Subject(subject_id="s1", species=species) + return nwbfile + + +class TestAddSpeciesExternalResourceNoOps: + def test_no_subject(self): + nwbfile = _make_nwbfile(with_subject=False) + assert add_species_external_resource(nwbfile) is False + assert nwbfile.external_resources is None + + def test_species_is_none(self): + nwbfile = _make_nwbfile(species=None) + assert add_species_external_resource(nwbfile) is False + assert nwbfile.external_resources is None + + def test_unrecognized_species(self): + nwbfile = _make_nwbfile(species="Octodon degus") + assert add_species_external_resource(nwbfile) is False + assert nwbfile.external_resources is None + + +class TestAddSpeciesExternalResource: + def test_recognized_species_is_annotated(self): + nwbfile = _make_nwbfile(species="Mus musculus") + assert add_species_external_resource(nwbfile) is True + + dataframe = nwbfile.external_resources.to_dataframe() + assert dataframe["key"].tolist() == ["Mus musculus"] + assert dataframe["entity_id"].tolist() == ["NCBITaxon:10090"] + assert dataframe["entity_uri"].tolist() == ["http://purl.obolibrary.org/obo/NCBITaxon_10090"] + + def test_reference_points_at_subject_species(self): + nwbfile = _make_nwbfile(species="Homo sapiens") + add_species_external_resource(nwbfile) + + objects = nwbfile.external_resources.objects.to_dataframe() + assert objects["object_id"].tolist() == [nwbfile.subject.object_id] + assert objects["relative_path"].tolist() == ["species"] + + def test_idempotent(self): + nwbfile = _make_nwbfile(species="Mus musculus") + assert add_species_external_resource(nwbfile) is True + assert add_species_external_resource(nwbfile) is False + assert len(nwbfile.external_resources.entities[:]) == 1 + + def test_extends_existing_herd_in_place(self): + from hdmf.common import HERD + from pynwb import get_type_map + + nwbfile = _make_nwbfile(species="Mus musculus") + herd = HERD(type_map=get_type_map()) + herd.add_ref( + container=nwbfile.subject, + attribute="subject_id", + key="s1", + entity_id="EXAMPLE:1", + entity_uri="https://example.org/1", + ) + nwbfile.external_resources = herd + + assert add_species_external_resource(nwbfile) is True + assert nwbfile.external_resources is herd # extended in place, not replaced + assert len(herd.entities[:]) == 2 + + def test_round_trips_through_file(self, tmp_path): + from pynwb import NWBHDF5IO + + nwbfile = _make_nwbfile(species="Rattus norvegicus") + add_species_external_resource(nwbfile) + + path = tmp_path / "species_herd.nwb" + with NWBHDF5IO(path, "w") as io: + io.write(nwbfile) + with NWBHDF5IO(path, "r") as io: + read_nwbfile = io.read() + dataframe = read_nwbfile.external_resources.to_dataframe() + + assert dataframe["key"].tolist() == ["Rattus norvegicus"] + assert dataframe["entity_id"].tolist() == ["NCBITaxon:10116"] diff --git a/tests/test_minimal/test_tools/test_ontology_species.py b/tests/test_minimal/test_tools/test_ontology_species.py new file mode 100644 index 0000000000..6c80c8db6a --- /dev/null +++ b/tests/test_minimal/test_tools/test_ontology_species.py @@ -0,0 +1,104 @@ +"""Tests for the offline species term recommendation tools.""" + +import pytest + +from neuroconv.tools.ontology import ( + SPECIES_TERMS, + SpeciesTerm, + get_species_suggestion, + get_species_term, + validate_species, +) + + +class TestSpeciesTermsTable: + def test_entries_are_species_terms_with_ncbitaxon_ids(self): + for canonical_name, term in SPECIES_TERMS.items(): + assert isinstance(term, SpeciesTerm) + assert term.canonical_name == canonical_name + assert term.ncbitaxon_id.startswith("NCBITaxon:") + + def test_entity_uri_is_derived_from_ncbitaxon_id(self): + term = SPECIES_TERMS["Mus musculus"] + assert term.ncbitaxon_id == "NCBITaxon:10090" + assert term.entity_uri == "http://purl.obolibrary.org/obo/NCBITaxon_10090" + + +class TestGetSpeciesSuggestion: + @pytest.mark.parametrize( + "species", + [ + "Mus musculus", # exact canonical name + "http://purl.obolibrary.org/obo/NCBITaxon_10090", # taxonomy URL + "NCBITaxon:10090", # CURIE + "Octodon degus", # valid but uncommon binomial, not in table + "", # empty + None, # not a string + 42, # not a string + ], + ) + def test_returns_none_when_nothing_to_suggest(self, species): + assert get_species_suggestion(species) is None + + def test_common_name_suggestion(self): + suggestion = get_species_suggestion("mouse") + assert suggestion is not None + term, reason = suggestion + assert term.canonical_name == "Mus musculus" + assert term.ncbitaxon_id == "NCBITaxon:10090" + assert "common name" in reason + + def test_common_name_is_case_insensitive_and_stripped(self): + term, _ = get_species_suggestion(" Rhesus Macaque ") + assert term.canonical_name == "Macaca mulatta" + + def test_typo_suggestion(self): + suggestion = get_species_suggestion("Homo sapien") + assert suggestion is not None + term, reason = suggestion + assert term.canonical_name == "Homo sapiens" + assert "closely matches" in reason + + +class TestGetSpeciesTerm: + def test_exact_canonical_name_resolves(self): + term = get_species_term("Mus musculus") + assert term.canonical_name == "Mus musculus" + assert term.ncbitaxon_id == "NCBITaxon:10090" + + def test_common_name_resolves(self): + term = get_species_term("mouse") + assert term.canonical_name == "Mus musculus" + + def test_typo_resolves(self): + term = get_species_term("Homo sapien") + assert term.canonical_name == "Homo sapiens" + + @pytest.mark.parametrize("species", ["Octodon degus", "", None, 42]) + def test_unrecognized_returns_none(self, species): + assert get_species_term(species) is None + + +class TestValidateSpecies: + def test_no_warning_for_canonical_name(self, recwarn): + result = validate_species("Mus musculus") + assert result is None + assert len(recwarn) == 0 + + def test_no_warning_for_none(self, recwarn): + assert validate_species(None) is None + assert len(recwarn) == 0 + + def test_no_warning_for_unknown_binomial(self, recwarn): + assert validate_species("Octodon degus") is None + assert len(recwarn) == 0 + + def test_warns_and_returns_term_for_common_name(self): + with pytest.warns(UserWarning, match="Mus musculus"): + term = validate_species("mouse") + assert term.canonical_name == "Mus musculus" + assert term.ncbitaxon_id == "NCBITaxon:10090" + + def test_warning_message_points_to_bioregistry(self): + with pytest.warns(UserWarning, match="bioregistry.io/NCBITaxon:9606"): + validate_species("human") From 9c7c56a7c78be051e2cc5b9263568cf1c13d2996 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:15:30 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/neuroconv/tools/nwb_helpers/_metadata_and_file_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/neuroconv/tools/nwb_helpers/_metadata_and_file_helpers.py b/src/neuroconv/tools/nwb_helpers/_metadata_and_file_helpers.py index 0eed4815c6..a2fce92cd6 100644 --- a/src/neuroconv/tools/nwb_helpers/_metadata_and_file_helpers.py +++ b/src/neuroconv/tools/nwb_helpers/_metadata_and_file_helpers.py @@ -19,9 +19,9 @@ configure_backend, get_default_backend_configuration, ) +from ..ontology import add_species_external_resource, validate_species from ...utils.dict import DeepDict, load_dict_from_file from ...utils.json_schema import validate_metadata -from ..ontology import add_species_external_resource, validate_species def get_module(nwbfile: NWBFile, name: str, description: str = None): From 07a4afb08da3c084e6a143aafab368acd5334497 Mon Sep 17 00:00:00 2001 From: Ben Dichter Date: Sat, 4 Jul 2026 09:26:26 -0400 Subject: [PATCH 3/4] Add ontology submodule to expected tools import structure The new neuroconv.tools.ontology submodule is attached to the tools namespace via nwb_helpers' import of add_species_external_resource, so test_tools' expected_structure must include it. Co-Authored-By: Claude Opus 4.8 --- tests/imports.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/imports.py b/tests/imports.py index d0774fb193..c662ca09a6 100644 --- a/tests/imports.py +++ b/tests/imports.py @@ -63,6 +63,7 @@ def test_tools(self): "path_expansion", "processes", "iterative_write", + "ontology", # Attached to namespace by nwb_helpers import of add_species_external_resource # Functions and classes imported on the __init__ "get_format_summaries", "get_package", From 017f1fdb983a0f424358e3a455077f6da0b68fcf Mon Sep 17 00:00:00 2001 From: Ben Dichter Date: Sat, 4 Jul 2026 09:34:08 -0400 Subject: [PATCH 4/4] Document species ontology tools Add a user guide page covering the non-blocking species suggestion and the in-file NCBITaxon (HERD) annotation, an API reference page for neuroconv.tools.ontology, and wire both into their toctrees. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + docs/api/tools.ontology.rst | 4 ++ docs/api/tools.rst | 1 + docs/user_guide/index.rst | 1 + docs/user_guide/species_ontology.rst | 79 ++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+) create mode 100644 docs/api/tools.ontology.rst create mode 100644 docs/user_guide/species_ontology.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index e3bae41105..2fc708c89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ * Added `metadata_key` support to recording interfaces (`BaseRecordingExtractorInterface`, inherited by `BaseLFPExtractorInterface` and all recording interfaces) and `use_new_metadata_format` parameter to `get_metadata()` for the dict-based metadata pipeline. `metadata_key` defaults to the value of `es_key`. [PR #1747](https://github.com/catalystneuro/neuroconv/pull/1747) ## Improvements +* Added documentation for the species ontology tools: a user guide page (`docs/user_guide/species_ontology.rst`) covering the non-blocking species suggestion and the in-file NCBITaxon (HERD) annotation, plus an API reference page for `neuroconv.tools.ontology`. [PR #1772](https://github.com/catalystneuro/neuroconv/pull/1772) * Migrated `MockPoseEstimationInterface` to the dict-based metadata shape used by the rest of the unified pipeline: top-level `metadata["Devices"]`, with `metadata["Behavior"]["Pose"]["PoseEstimations"][metadata_key]` and `metadata["Behavior"]["Pose"]["Skeletons"][metadata_key]` sub-registries linked via `device_metadata_key` and `skeleton_metadata_key`. The constructor argument `pose_estimation_metadata_key` was renamed to `metadata_key` for consistency with the other dict-based interfaces. This anchors the target shape for the upcoming DLC, Lightning Pose, and SLEAP interface migrations. [PR #1745](https://github.com/catalystneuro/neuroconv/pull/1745) * `MiniscopeBehaviorInterface` no longer depends on `ndx-miniscope` for raw format parsing; `ndx-miniscope` is used only for NWB construction. Legacy V3 Miniscope folders now raise a clear error on both Miniscope interfaces pointing users to the issue tracker so V3 support can be added against real fixtures. [PR #1725](https://github.com/catalystneuro/neuroconv/pull/1725) * Added a conversion gallery example for aligning ScanImage imaging data with external DAQ sync pulses, demonstrating use of `get_original_frame_indices` and `slice_samples` from `ScanImageImagingExtractor`. [PR #1709](https://github.com/catalystneuro/neuroconv/pull/1709) diff --git a/docs/api/tools.ontology.rst b/docs/api/tools.ontology.rst new file mode 100644 index 0000000000..5b798c8da3 --- /dev/null +++ b/docs/api/tools.ontology.rst @@ -0,0 +1,4 @@ +Ontology +======== + +.. automodule:: neuroconv.tools.ontology diff --git a/docs/api/tools.rst b/docs/api/tools.rst index 7ff0633aa1..0c4abe4734 100644 --- a/docs/api/tools.rst +++ b/docs/api/tools.rst @@ -11,6 +11,7 @@ Tools tools.testing tools.iterative_write tools.path_expansion + tools.ontology tools.signal_processing tools.data_transfers tools.nwb_helpers diff --git a/docs/user_guide/index.rst b/docs/user_guide/index.rst index 6abfa51cad..408907bddb 100644 --- a/docs/user_guide/index.rst +++ b/docs/user_guide/index.rst @@ -24,6 +24,7 @@ and synchronize data across multiple sources. temporal_alignment csvs expand_path + species_ontology backend_configuration linking_sorted_data yaml diff --git a/docs/user_guide/species_ontology.rst b/docs/user_guide/species_ontology.rst new file mode 100644 index 0000000000..4fadc645f1 --- /dev/null +++ b/docs/user_guide/species_ontology.rst @@ -0,0 +1,79 @@ +Species Ontology +================ + +NWB stores a subject's species in :py:attr:`Subject.species ` as a +binomial Latin name (e.g. ``"Mus musculus"``) or a taxonomy URL. Consistent, standardized species +values make files interoperable and allow downstream tools such as the +`DANDI Archive `_ to resolve the exact organism without guessing. + +NeuroConv helps with this in two complementary ways, both applied automatically by +:py:func:`~neuroconv.tools.nwb_helpers.make_nwbfile_from_metadata` (and therefore by every +conversion that builds an NWB file through it): + +1. A **non-blocking suggestion** when the species looks like a common name or a typo. +2. A **machine-readable NCBITaxon annotation** attached in-file when the species is recognized. + +The lookup is a small, curated, offline table of common neuroscience species +(:py:data:`~neuroconv.tools.ontology.SPECIES_TERMS`). It requires no network access and no extra +dependencies, and it is intentionally high-precision: it only speaks up when it is confident there +is a better term. Valid-but-uncommon binomials pass through silently. + +Suggesting a standardized term +------------------------------ + +When ``Subject.species`` is a recognized common name (e.g. ``"mouse"``) or a likely typo of a known +binomial (e.g. ``"Homo sapien"``), NeuroConv emits a ``UserWarning`` recommending the canonical +Latin binomial and its NCBITaxon identifier. This never raises and never blocks a conversion. + +.. code-block:: python + + from neuroconv.tools.ontology import validate_species + + validate_species("mouse") + # UserWarning: Subject species 'mouse' is a common name. Consider using the Latin binomial + # 'Mus musculus' (NCBITaxon:10090) for interoperability. See https://bioregistry.io/NCBITaxon:10090 + + validate_species("Homo sapien") + # UserWarning: Subject species 'Homo sapien' closely matches a known species name. Consider using + # the Latin binomial 'Homo sapiens' (NCBITaxon:9606) for interoperability. ... + + validate_species("Mus musculus") # already canonical -> no warning, returns the term + validate_species("Octodon degus") # valid but not in the table -> no warning, returns None + +To resolve a value to its canonical term without emitting a warning, use +:py:func:`~neuroconv.tools.ontology.get_species_term`, which also succeeds on exact canonical +matches: + +.. code-block:: python + + from neuroconv.tools.ontology import get_species_term + + term = get_species_term("rhesus macaque") + term.canonical_name # 'Macaca mulatta' + term.ncbitaxon_id # 'NCBITaxon:9544' + term.entity_uri # 'http://purl.obolibrary.org/obo/NCBITaxon_9544' + +Annotating the file with an NCBITaxon reference +----------------------------------------------- + +When the species resolves to a recognized term, NeuroConv attaches a machine-readable external +resource reference mapping ``Subject.species`` to its NCBITaxon entity. This is stored **in-file** +under ``/general/external_resources`` using HDMF's HERD (External Resources Data), so the link +travels with the file: + +.. code-block:: python + + from neuroconv.tools.ontology import add_species_external_resource + + # nwbfile.subject.species == "Mus musculus" + added = add_species_external_resource(nwbfile) # returns True + nwbfile.external_resources # now carries a Mus musculus -> NCBITaxon:10090 reference + +The call is a no-op (returns ``False``) when there is no subject or the species is not recognized, +and it is idempotent: an existing ``external_resources`` HERD is extended in place rather than +replaced, and a species that is already annotated is not added twice. + +.. note:: + + In-file HERD storage requires ``pynwb >= 4.0.0``, which is NeuroConv's minimum supported + version.