Skip to content

assets

lacuna.assets

Unified asset management system for Lacuna.

This module provides centralized management of all neuroimaging assets: - Parcellations (bundled and user-registered) - Templates (from TemplateFlow) - Transforms (from TemplateFlow) - Connectomes (structural and functional, user-registered)

All asset types follow a consistent registry pattern with register/list/load functions.

AssetMetadata dataclass

Bases: ABC

Base class for all asset metadata.

All asset types must subclass this and implement the abstract methods.

Attributes:

Name Type Description
name str

Unique identifier for the asset

description str

Human-readable description

Source code in src/lacuna/assets/base.py
@dataclass(frozen=True)
class AssetMetadata(ABC):
    """Base class for all asset metadata.

    All asset types must subclass this and implement the abstract methods.

    Attributes
    ----------
    name : str
        Unique identifier for the asset
    description : str
        Human-readable description
    """

    name: str
    description: str

    @abstractmethod
    def validate(self) -> None:
        """Validate metadata consistency.

        Raises
        ------
        ValueError
            If metadata is invalid
        """
        pass

    def to_dict(self) -> dict[str, Any]:
        """Serialize to dictionary for storage.

        Returns
        -------
        dict
            Dictionary representation of metadata
        """
        return self.__dict__.copy()

to_dict()

Serialize to dictionary for storage.

Returns:

Type Description
dict

Dictionary representation of metadata

Source code in src/lacuna/assets/base.py
def to_dict(self) -> dict[str, Any]:
    """Serialize to dictionary for storage.

    Returns
    -------
    dict
        Dictionary representation of metadata
    """
    return self.__dict__.copy()

validate() abstractmethod

Validate metadata consistency.

Raises:

Type Description
ValueError

If metadata is invalid

Source code in src/lacuna/assets/base.py
@abstractmethod
def validate(self) -> None:
    """Validate metadata consistency.

    Raises
    ------
    ValueError
        If metadata is invalid
    """
    pass

AssetRegistry

Bases: Generic[T]

Generic registry for any asset type.

Provides consistent registration, listing, and retrieval patterns across all asset types.

Parameters:

Name Type Description Default
asset_type_name str

Human-readable name of asset type (for error messages)

'asset'

Examples:

>>> registry = AssetRegistry[AtlasMetadata]("atlas")
>>> registry.register(atlas_metadata)
>>> atlases = registry.list(space="MNI152NLin2009cAsym")
>>> atlas = registry.get("schaefer2018parcels100networks7")
Source code in src/lacuna/assets/base.py
class AssetRegistry(Generic[T]):
    """Generic registry for any asset type.

    Provides consistent registration, listing, and retrieval
    patterns across all asset types.

    Parameters
    ----------
    asset_type_name : str
        Human-readable name of asset type (for error messages)

    Examples
    --------
    >>> registry = AssetRegistry[AtlasMetadata]("atlas")
    >>> registry.register(atlas_metadata)
    >>> atlases = registry.list(space="MNI152NLin2009cAsym")
    >>> atlas = registry.get("schaefer2018parcels100networks7")
    """

    def __init__(self, asset_type_name: str = "asset"):
        """Initialize empty registry.

        Parameters
        ----------
        asset_type_name : str
            Name of asset type for error messages
        """
        self._registry: dict[str, T] = {}
        self._asset_type_name = asset_type_name

    def register(self, metadata: T) -> None:
        """Register an asset.

        Parameters
        ----------
        metadata : T
            Asset metadata to register

        Raises
        ------
        ValueError
            If asset already registered or metadata invalid
        """
        if metadata.name in self._registry:
            raise ValueError(
                f"{self._asset_type_name.capitalize()} already registered: {metadata.name}"
            )

        # Validate before registering
        metadata.validate()

        self._registry[metadata.name] = metadata

    def unregister(self, name: str) -> None:
        """Unregister an asset.

        Parameters
        ----------
        name : str
            Name of asset to unregister

        Raises
        ------
        KeyError
            If asset not found
        """
        if name not in self._registry:
            raise KeyError(f"{self._asset_type_name.capitalize()} not found: {name}")
        del self._registry[name]

    def list(self, **filters) -> list[T]:
        """List assets matching filters.

        Parameters
        ----------
        **filters
            Attribute filters (e.g., space="MNI152NLin2009cAsym")

        Returns
        -------
        list[T]
            Matching assets

        Examples
        --------
        >>> # Get all assets
        >>> all_assets = registry.list()
        >>>
        >>> # Filter by space
        >>> mni_assets = registry.list(space="MNI152NLin2009cAsym")
        >>>
        >>> # Filter by multiple criteria
        >>> assets = registry.list(space="MNI152NLin2009cAsym", resolution=1.0)
        """
        assets = list(self._registry.values())

        for key, value in filters.items():
            if value is not None:
                assets = [a for a in assets if getattr(a, key, None) == value]

        return assets

    def get(self, name: str) -> T:
        """Get asset metadata by name.

        Parameters
        ----------
        name : str
            Asset name

        Returns
        -------
        T
            Asset metadata

        Raises
        ------
        KeyError
            If asset not found
        """
        if name not in self._registry:
            raise KeyError(
                f"{self._asset_type_name.capitalize()} not found: {name}. "
                f"Available: {list(self._registry.keys())}"
            )
        return self._registry[name]

    def __contains__(self, name: str) -> bool:
        """Check if asset is registered.

        Parameters
        ----------
        name : str
            Asset name

        Returns
        -------
        bool
            True if registered
        """
        return name in self._registry

    def __len__(self) -> int:
        """Get number of registered assets.

        Returns
        -------
        int
            Count of assets
        """
        return len(self._registry)

    def keys(self) -> list[str]:
        """Get all registered asset names.

        Returns
        -------
        list[str]
            Asset names
        """
        return list(self._registry.keys())

__contains__(name)

Check if asset is registered.

Parameters:

Name Type Description Default
name str

Asset name

required

Returns:

Type Description
bool

True if registered

Source code in src/lacuna/assets/base.py
def __contains__(self, name: str) -> bool:
    """Check if asset is registered.

    Parameters
    ----------
    name : str
        Asset name

    Returns
    -------
    bool
        True if registered
    """
    return name in self._registry

__init__(asset_type_name='asset')

Initialize empty registry.

Parameters:

Name Type Description Default
asset_type_name str

Name of asset type for error messages

'asset'
Source code in src/lacuna/assets/base.py
def __init__(self, asset_type_name: str = "asset"):
    """Initialize empty registry.

    Parameters
    ----------
    asset_type_name : str
        Name of asset type for error messages
    """
    self._registry: dict[str, T] = {}
    self._asset_type_name = asset_type_name

__len__()

Get number of registered assets.

Returns:

Type Description
int

Count of assets

Source code in src/lacuna/assets/base.py
def __len__(self) -> int:
    """Get number of registered assets.

    Returns
    -------
    int
        Count of assets
    """
    return len(self._registry)

get(name)

Get asset metadata by name.

Parameters:

Name Type Description Default
name str

Asset name

required

Returns:

Type Description
T

Asset metadata

Raises:

Type Description
KeyError

If asset not found

Source code in src/lacuna/assets/base.py
def get(self, name: str) -> T:
    """Get asset metadata by name.

    Parameters
    ----------
    name : str
        Asset name

    Returns
    -------
    T
        Asset metadata

    Raises
    ------
    KeyError
        If asset not found
    """
    if name not in self._registry:
        raise KeyError(
            f"{self._asset_type_name.capitalize()} not found: {name}. "
            f"Available: {list(self._registry.keys())}"
        )
    return self._registry[name]

keys()

Get all registered asset names.

Returns:

Type Description
list[str]

Asset names

Source code in src/lacuna/assets/base.py
def keys(self) -> list[str]:
    """Get all registered asset names.

    Returns
    -------
    list[str]
        Asset names
    """
    return list(self._registry.keys())

list(**filters)

List assets matching filters.

Parameters:

Name Type Description Default
**filters

Attribute filters (e.g., space="MNI152NLin2009cAsym")

{}

Returns:

Type Description
list[T]

Matching assets

Examples:

>>> # Get all assets
>>> all_assets = registry.list()
>>>
>>> # Filter by space
>>> mni_assets = registry.list(space="MNI152NLin2009cAsym")
>>>
>>> # Filter by multiple criteria
>>> assets = registry.list(space="MNI152NLin2009cAsym", resolution=1.0)
Source code in src/lacuna/assets/base.py
def list(self, **filters) -> list[T]:
    """List assets matching filters.

    Parameters
    ----------
    **filters
        Attribute filters (e.g., space="MNI152NLin2009cAsym")

    Returns
    -------
    list[T]
        Matching assets

    Examples
    --------
    >>> # Get all assets
    >>> all_assets = registry.list()
    >>>
    >>> # Filter by space
    >>> mni_assets = registry.list(space="MNI152NLin2009cAsym")
    >>>
    >>> # Filter by multiple criteria
    >>> assets = registry.list(space="MNI152NLin2009cAsym", resolution=1.0)
    """
    assets = list(self._registry.values())

    for key, value in filters.items():
        if value is not None:
            assets = [a for a in assets if getattr(a, key, None) == value]

    return assets

register(metadata)

Register an asset.

Parameters:

Name Type Description Default
metadata T

Asset metadata to register

required

Raises:

Type Description
ValueError

If asset already registered or metadata invalid

Source code in src/lacuna/assets/base.py
def register(self, metadata: T) -> None:
    """Register an asset.

    Parameters
    ----------
    metadata : T
        Asset metadata to register

    Raises
    ------
    ValueError
        If asset already registered or metadata invalid
    """
    if metadata.name in self._registry:
        raise ValueError(
            f"{self._asset_type_name.capitalize()} already registered: {metadata.name}"
        )

    # Validate before registering
    metadata.validate()

    self._registry[metadata.name] = metadata

unregister(name)

Unregister an asset.

Parameters:

Name Type Description Default
name str

Name of asset to unregister

required

Raises:

Type Description
KeyError

If asset not found

Source code in src/lacuna/assets/base.py
def unregister(self, name: str) -> None:
    """Unregister an asset.

    Parameters
    ----------
    name : str
        Name of asset to unregister

    Raises
    ------
    KeyError
        If asset not found
    """
    if name not in self._registry:
        raise KeyError(f"{self._asset_type_name.capitalize()} not found: {name}")
    del self._registry[name]

FunctionalConnectome dataclass

Loaded functional connectome for fLNM analysis.

Provides path to HDF5 file(s) with voxel-wise timeseries data needed for FunctionalNetworkMapping analysis.

Attributes:

Name Type Description
metadata FunctionalConnectomeMetadata

Connectome metadata

data_path Path

Path to .h5 file or directory with batch files

is_batched bool

True if data_path points to directory with multiple files

Source code in src/lacuna/assets/connectomes/functional.py
@dataclass
class FunctionalConnectome:
    """Loaded functional connectome for fLNM analysis.

    Provides path to HDF5 file(s) with voxel-wise timeseries data
    needed for FunctionalNetworkMapping analysis.

    Attributes
    ----------
    metadata : FunctionalConnectomeMetadata
        Connectome metadata
    data_path : Path
        Path to .h5 file or directory with batch files
    is_batched : bool
        True if data_path points to directory with multiple files
    """

    metadata: FunctionalConnectomeMetadata
    data_path: Path
    is_batched: bool

FunctionalConnectomeMetadata dataclass

Bases: SpatialAssetMetadata

Metadata for a functional connectome (voxel-wise timeseries).

Used for functional lesion network mapping (fLNM). Requires HDF5 file(s) containing whole-brain voxel-wise BOLD timeseries data.

HDF5 structure: - 'timeseries': (n_subjects, n_timepoints, n_voxels) array - 'mask_indices': (3, n_voxels) or (n_voxels, 3) brain mask coordinates - 'mask_affine': (4, 4) affine transformation matrix - 'mask_shape': Tuple as attribute (e.g., (91, 109, 91))

Attributes:

Name Type Description
name str

Unique identifier (e.g., "GSP1000")

space str

Coordinate space (typically "MNI152NLin6Asym")

resolution float

Resolution in mm (typically 2.0 for functional data)

description str

Human-readable description

n_subjects int

Sample size in connectome

modality str

Imaging modality (always "bold")

data_path Path

Path to .h5 file or directory containing batch files

is_batched bool

True if data_path is directory with multiple HDF5 files

Source code in src/lacuna/assets/connectomes/registry.py
@dataclass(frozen=True)
class FunctionalConnectomeMetadata(SpatialAssetMetadata):
    """Metadata for a functional connectome (voxel-wise timeseries).

    Used for functional lesion network mapping (fLNM). Requires HDF5 file(s)
    containing whole-brain voxel-wise BOLD timeseries data.

    HDF5 structure:
    - 'timeseries': (n_subjects, n_timepoints, n_voxels) array
    - 'mask_indices': (3, n_voxels) or (n_voxels, 3) brain mask coordinates
    - 'mask_affine': (4, 4) affine transformation matrix
    - 'mask_shape': Tuple as attribute (e.g., (91, 109, 91))

    Attributes
    ----------
    name : str
        Unique identifier (e.g., "GSP1000")
    space : str
        Coordinate space (typically "MNI152NLin6Asym")
    resolution : float
        Resolution in mm (typically 2.0 for functional data)
    description : str
        Human-readable description
    n_subjects : int
        Sample size in connectome
    modality : str
        Imaging modality (always "bold")
    data_path : Path
        Path to .h5 file or directory containing batch files
    is_batched : bool
        True if data_path is directory with multiple HDF5 files
    """

    n_subjects: int = 0
    modality: str = "bold"
    data_path: Path | None = None
    is_batched: bool = False

    def __repr__(self) -> str:
        """Concise representation showing only essential fields."""
        return (
            f"FunctionalConnectomeMetadata("
            f"name={self.name!r}, "
            f"space={self.space!r}, "
            f"resolution={self.resolution}, "
            f"n_subjects={self.n_subjects}, "
            f"data_path={self.data_path})"
        )

__repr__()

Concise representation showing only essential fields.

Source code in src/lacuna/assets/connectomes/registry.py
def __repr__(self) -> str:
    """Concise representation showing only essential fields."""
    return (
        f"FunctionalConnectomeMetadata("
        f"name={self.name!r}, "
        f"space={self.space!r}, "
        f"resolution={self.resolution}, "
        f"n_subjects={self.n_subjects}, "
        f"data_path={self.data_path})"
    )

Parcellation dataclass

Loaded parcellation with image data, labels, and metadata.

Attributes:

Name Type Description
image Nifti1Image

The parcellation image (3D or 4D for probabilistic parcellations)

labels dict[int, str]

Mapping from region ID to region name

metadata ParcellationMetadata

Parcellation metadata from registry

Source code in src/lacuna/assets/parcellations/loader.py
@dataclass
class Parcellation:
    """Loaded parcellation with image data, labels, and metadata.

    Attributes
    ----------
    image : nib.Nifti1Image
        The parcellation image (3D or 4D for probabilistic parcellations)
    labels : dict[int, str]
        Mapping from region ID to region name
    metadata : ParcellationMetadata
        Parcellation metadata from registry
    """

    image: nib.Nifti1Image
    labels: dict[int, str]
    metadata: ParcellationMetadata

ParcellationMetadata dataclass

Bases: SpatialAssetMetadata

Metadata for a neuroimaging parcellation.

Inherits from SpatialAssetMetadata to include space and resolution validation.

Attributes:

Name Type Description
name str

Unique identifier for the parcellation

space str

Coordinate space (e.g., "MNI152NLin6Asym")

resolution float

Resolution in mm (e.g., 1.0, 2.0)

description str

Human-readable description

parcellation_filename str

Filename of the NIfTI parcellation file

labels_filename str

Filename of the labels text file

citation (str, optional)

Citation information for the parcellation

networks (list[str], optional)

List of network names if parcellation has network organization

n_regions (int, optional)

Number of regions/parcels in the parcellation

is_4d (bool, optional)

Whether the parcellation is 4D (multiple volumes) or 3D (single volume). Default is False. 4D parcellations are transformed volume-by-volume and aggregated independently.

region_labels (list[str] | None, optional)

Human-readable labels for each region (1-indexed, matching ROI values). If None, labels will be auto-generated as "region_001", "region_002", etc. Loaded automatically from labels_filename during parcellation registration.

Source code in src/lacuna/assets/parcellations/registry.py
@dataclass(frozen=True)
class ParcellationMetadata(SpatialAssetMetadata):
    """Metadata for a neuroimaging parcellation.

    Inherits from SpatialAssetMetadata to include space and resolution validation.

    Attributes
    ----------
    name : str
        Unique identifier for the parcellation
    space : str
        Coordinate space (e.g., "MNI152NLin6Asym")
    resolution : float
        Resolution in mm (e.g., 1.0, 2.0)
    description : str
        Human-readable description
    parcellation_filename : str
        Filename of the NIfTI parcellation file
    labels_filename : str
        Filename of the labels text file
    citation : str, optional
        Citation information for the parcellation
    networks : list[str], optional
        List of network names if parcellation has network organization
    n_regions : int, optional
        Number of regions/parcels in the parcellation
    is_4d : bool, optional
        Whether the parcellation is 4D (multiple volumes) or 3D (single volume).
        Default is False. 4D parcellations are transformed volume-by-volume
        and aggregated independently.
    region_labels : list[str] | None, optional
        Human-readable labels for each region (1-indexed, matching ROI values).
        If None, labels will be auto-generated as "region_001", "region_002", etc.
        Loaded automatically from labels_filename during parcellation registration.
    """

    parcellation_filename: str = ""
    labels_filename: str = ""
    citation: str | None = None
    networks: list[str] = field(default_factory=list)
    n_regions: int | None = None
    is_4d: bool = False
    region_labels: list[str] | None = None

SpatialAssetMetadata dataclass

Bases: AssetMetadata

Base class for assets with spatial properties.

Adds coordinate space and resolution tracking.

Attributes:

Name Type Description
name str

Unique identifier for the asset

description str

Human-readable description

space str

Coordinate space identifier (e.g., "MNI152NLin2009cAsym")

resolution float

Voxel resolution in mm (e.g., 1.0, 2.0)

Source code in src/lacuna/assets/base.py
@dataclass(frozen=True)
class SpatialAssetMetadata(AssetMetadata):
    """Base class for assets with spatial properties.

    Adds coordinate space and resolution tracking.

    Attributes
    ----------
    name : str
        Unique identifier for the asset
    description : str
        Human-readable description
    space : str
        Coordinate space identifier (e.g., "MNI152NLin2009cAsym")
    resolution : float
        Voxel resolution in mm (e.g., 1.0, 2.0)
    """

    space: str
    resolution: float

    def validate(self) -> None:
        """Validate space and resolution.

        Raises
        ------
        ValueError
            If space or resolution is invalid
        """
        from lacuna.core.spaces import SUPPORTED_SPACES

        # Check if space is supported
        if self.space not in SUPPORTED_SPACES:
            raise ValueError(f"Unsupported space: {self.space}. " f"Supported: {SUPPORTED_SPACES}")

        # Check resolution
        valid_resolutions = [0.5, 1.0, 2.0]
        if self.resolution not in valid_resolutions:
            raise ValueError(
                f"Unsupported resolution: {self.resolution}. " f"Supported: {valid_resolutions}"
            )

validate()

Validate space and resolution.

Raises:

Type Description
ValueError

If space or resolution is invalid

Source code in src/lacuna/assets/base.py
def validate(self) -> None:
    """Validate space and resolution.

    Raises
    ------
    ValueError
        If space or resolution is invalid
    """
    from lacuna.core.spaces import SUPPORTED_SPACES

    # Check if space is supported
    if self.space not in SUPPORTED_SPACES:
        raise ValueError(f"Unsupported space: {self.space}. " f"Supported: {SUPPORTED_SPACES}")

    # Check resolution
    valid_resolutions = [0.5, 1.0, 2.0]
    if self.resolution not in valid_resolutions:
        raise ValueError(
            f"Unsupported resolution: {self.resolution}. " f"Supported: {valid_resolutions}"
        )

StructuralConnectome dataclass

Loaded structural connectome for sLNM analysis.

Provides tractogram path for StructuralNetworkMapping analysis. TDI is computed on-the-fly during analysis (with optional caching).

Attributes:

Name Type Description
metadata StructuralConnectomeMetadata

Connectome metadata

tractogram_path Path

Path to .tck streamlines file

template_path Path | None

Optional template image path

Source code in src/lacuna/assets/connectomes/structural.py
@dataclass
class StructuralConnectome:
    """Loaded structural connectome for sLNM analysis.

    Provides tractogram path for StructuralNetworkMapping analysis.
    TDI is computed on-the-fly during analysis (with optional caching).

    Attributes
    ----------
    metadata : StructuralConnectomeMetadata
        Connectome metadata
    tractogram_path : Path
        Path to .tck streamlines file
    template_path : Path | None
        Optional template image path
    """

    metadata: StructuralConnectomeMetadata
    tractogram_path: Path
    template_path: Path | None = None

StructuralConnectomeMetadata dataclass

Bases: SpatialAssetMetadata

Metadata for a structural connectome (tractography-based).

Used for structural lesion network mapping (sLNM). Requires: - Tractogram file (.tck format from MRtrix3) - TDI computed on-the-fly during analysis (with optional caching)

Note: Unlike functional connectomes, structural connectomes (tractograms) don't have an inherent voxel resolution - they exist in continuous 3D space. The output resolution is controlled by the StructuralNetworkMapping analysis.

Attributes:

Name Type Description
name str

Unique identifier (e.g., "dTOR985")

space str

Coordinate space (typically "MNI152NLin2009bAsym")

resolution float

Resolution in mm (placeholder value, not used for tractograms)

description str

Human-readable description

modality str

Imaging modality (always "dwi")

tractogram_path Path

Path to .tck streamlines file

template_path Path | None

Optional path to template image defining output grid

Source code in src/lacuna/assets/connectomes/registry.py
@dataclass(frozen=True)
class StructuralConnectomeMetadata(SpatialAssetMetadata):
    """Metadata for a structural connectome (tractography-based).

    Used for structural lesion network mapping (sLNM). Requires:
    - Tractogram file (.tck format from MRtrix3)
    - TDI computed on-the-fly during analysis (with optional caching)

    Note: Unlike functional connectomes, structural connectomes (tractograms)
    don't have an inherent voxel resolution - they exist in continuous 3D space.
    The output resolution is controlled by the StructuralNetworkMapping analysis.

    Attributes
    ----------
    name : str
        Unique identifier (e.g., "dTOR985")
    space : str
        Coordinate space (typically "MNI152NLin2009bAsym")
    resolution : float
        Resolution in mm (placeholder value, not used for tractograms)
    description : str
        Human-readable description
    modality : str
        Imaging modality (always "dwi")
    tractogram_path : Path
        Path to .tck streamlines file
    template_path : Path | None
        Optional path to template image defining output grid
    """

    modality: str = "dwi"
    tractogram_path: Path | None = None
    template_path: Path | None = None

    def __repr__(self) -> str:
        """Concise representation showing only essential fields."""
        return (
            f"StructuralConnectomeMetadata("
            f"name={self.name!r}, "
            f"space={self.space!r}, "
            f"tractogram_path={self.tractogram_path})"
        )

    def validate(self) -> None:
        """Validate space only (tractograms don't have inherent resolution).

        Raises
        ------
        ValueError
            If space is invalid
        """
        from lacuna.core.spaces import SUPPORTED_SPACES

        # Check if space is supported
        if self.space not in SUPPORTED_SPACES:
            raise ValueError(f"Unsupported space: {self.space}. Supported: {SUPPORTED_SPACES}")

__repr__()

Concise representation showing only essential fields.

Source code in src/lacuna/assets/connectomes/registry.py
def __repr__(self) -> str:
    """Concise representation showing only essential fields."""
    return (
        f"StructuralConnectomeMetadata("
        f"name={self.name!r}, "
        f"space={self.space!r}, "
        f"tractogram_path={self.tractogram_path})"
    )

validate()

Validate space only (tractograms don't have inherent resolution).

Raises:

Type Description
ValueError

If space is invalid

Source code in src/lacuna/assets/connectomes/registry.py
def validate(self) -> None:
    """Validate space only (tractograms don't have inherent resolution).

    Raises
    ------
    ValueError
        If space is invalid
    """
    from lacuna.core.spaces import SUPPORTED_SPACES

    # Check if space is supported
    if self.space not in SUPPORTED_SPACES:
        raise ValueError(f"Unsupported space: {self.space}. Supported: {SUPPORTED_SPACES}")

TemplateMetadata dataclass

Bases: SpatialAssetMetadata

Metadata for a reference brain template.

Attributes:

Name Type Description
name str

Template identifier (e.g., "MNI152NLin2009cAsym")

space str

Coordinate space (same as name for templates)

resolution float

Voxel resolution in mm

description str

Human-readable description

modality str

Image modality (e.g., "T1w", "T2w", "FLAIR")

source str

Source of template (always "templateflow")

Source code in src/lacuna/assets/templates/registry.py
@dataclass(frozen=True)
class TemplateMetadata(SpatialAssetMetadata):
    """Metadata for a reference brain template.

    Attributes
    ----------
    name : str
        Template identifier (e.g., "MNI152NLin2009cAsym")
    space : str
        Coordinate space (same as name for templates)
    resolution : float
        Voxel resolution in mm
    description : str
        Human-readable description
    modality : str
        Image modality (e.g., "T1w", "T2w", "FLAIR")
    source : str
        Source of template (always "templateflow")
    """

    modality: str = "T1w"
    source: str = "templateflow"

TransformMetadata dataclass

Bases: AssetMetadata

Metadata for a spatial transformation.

Attributes:

Name Type Description
name str

Transform identifier (e.g., "MNI152NLin6Asym_to_MNI152NLin2009cAsym")

description str

Human-readable description

from_space str

Source coordinate space

to_space str

Target coordinate space

transform_type str

Type of transform ("nonlinear", "affine", "composite")

source str

Source of transform (always "templateflow")

Source code in src/lacuna/assets/transforms/registry.py
@dataclass(frozen=True)
class TransformMetadata(AssetMetadata):
    """Metadata for a spatial transformation.

    Attributes
    ----------
    name : str
        Transform identifier (e.g., "MNI152NLin6Asym_to_MNI152NLin2009cAsym")
    description : str
        Human-readable description
    from_space : str
        Source coordinate space
    to_space : str
        Target coordinate space
    transform_type : str
        Type of transform ("nonlinear", "affine", "composite")
    source : str
        Source of transform (always "templateflow")
    """

    from_space: str = ""
    to_space: str = ""
    transform_type: str = "nonlinear"
    source: str = "templateflow"

    def validate(self) -> None:
        """Validate transform metadata.

        Raises
        ------
        ValueError
            If metadata is invalid
        """
        from lacuna.core.spaces import SUPPORTED_SPACES

        # Check spaces
        for space in [self.from_space, self.to_space]:
            if space and space not in SUPPORTED_SPACES:
                raise ValueError(f"Unsupported space: {space}. " f"Supported: {SUPPORTED_SPACES}")

validate()

Validate transform metadata.

Raises:

Type Description
ValueError

If metadata is invalid

Source code in src/lacuna/assets/transforms/registry.py
def validate(self) -> None:
    """Validate transform metadata.

    Raises
    ------
    ValueError
        If metadata is invalid
    """
    from lacuna.core.spaces import SUPPORTED_SPACES

    # Check spaces
    for space in [self.from_space, self.to_space]:
        if space and space not in SUPPORTED_SPACES:
            raise ValueError(f"Unsupported space: {space}. " f"Supported: {SUPPORTED_SPACES}")

is_template_cached(name)

Check if template is already cached locally.

Parameters:

Name Type Description Default
name str

Template name from registry

required

Returns:

Type Description
bool

True if template is cached, False otherwise

Examples:

>>> from lacuna.assets.templates import is_template_cached
>>> is_template_cached("MNI152NLin2009cAsym_res-1")
True
Source code in src/lacuna/assets/templates/loader.py
def is_template_cached(name: str) -> bool:
    """Check if template is already cached locally.

    Parameters
    ----------
    name : str
        Template name from registry

    Returns
    -------
    bool
        True if template is cached, False otherwise

    Examples
    --------
    >>> from lacuna.assets.templates import is_template_cached
    >>> is_template_cached("MNI152NLin2009cAsym_res-1")
    True
    """
    try:
        template_path = load_template(name)
        return template_path.exists()
    except (FileNotFoundError, KeyError):
        return False

is_transform_cached(name)

Check if transform is already cached locally.

Parameters:

Name Type Description Default
name str

Transform name from registry

required

Returns:

Type Description
bool

True if transform is cached, False otherwise

Examples:

>>> from lacuna.assets.transforms import is_transform_cached
>>> is_transform_cached("MNI152NLin6Asym_to_MNI152NLin2009cAsym")
True
Source code in src/lacuna/assets/transforms/loader.py
def is_transform_cached(name: str) -> bool:
    """Check if transform is already cached locally.

    Parameters
    ----------
    name : str
        Transform name from registry

    Returns
    -------
    bool
        True if transform is cached, False otherwise

    Examples
    --------
    >>> from lacuna.assets.transforms import is_transform_cached
    >>> is_transform_cached("MNI152NLin6Asym_to_MNI152NLin2009cAsym")
    True
    """
    try:
        transform_path = load_transform(name)
        return transform_path.exists()
    except (FileNotFoundError, KeyError):
        return False

list_functional_connectomes(space=None)

List registered functional connectomes.

Parameters:

Name Type Description Default
space str

Filter by coordinate space

None

Returns:

Type Description
list[FunctionalConnectomeMetadata]

Matching connectomes

Examples:

>>> from lacuna.assets.connectomes import list_functional_connectomes
>>>
>>> # List all
>>> connectomes = list_functional_connectomes()
>>>
>>> # Filter by space
>>> mni_connectomes = list_functional_connectomes(space="MNI152NLin6Asym")
Source code in src/lacuna/assets/connectomes/functional.py
def list_functional_connectomes(
    space: str | None = None,
) -> list[FunctionalConnectomeMetadata]:
    """List registered functional connectomes.

    Parameters
    ----------
    space : str, optional
        Filter by coordinate space

    Returns
    -------
    list[FunctionalConnectomeMetadata]
        Matching connectomes

    Examples
    --------
    >>> from lacuna.assets.connectomes import list_functional_connectomes
    >>>
    >>> # List all
    >>> connectomes = list_functional_connectomes()
    >>>
    >>> # Filter by space
    >>> mni_connectomes = list_functional_connectomes(space="MNI152NLin6Asym")
    """
    return _functional_connectome_registry.list(space=space)

list_parcellations(space=None, resolution=None)

List available parcellations with optional filtering.

Parameters:

Name Type Description Default
space str

Filter by coordinate space (e.g., "MNI152NLin6Asym")

None
resolution int

Filter by resolution in mm (e.g., 1, 2)

None

Returns:

Type Description
list[ParcellationMetadata]

List of parcellation metadata matching the filters

Examples:

>>> # List all parcellations
>>> parcellations = list_parcellations()
>>>
>>> # Filter by space
>>> mni6_parcellations = list_parcellations(space="MNI152NLin6Asym")
>>>
>>> # Filter by resolution
>>> res1_parcellations = list_parcellations(resolution=1)
>>>
>>> # Combined filters
>>> filtered = list_parcellations(space="MNI152NLin6Asym", resolution=1)
Source code in src/lacuna/assets/parcellations/registry.py
def list_parcellations(
    space: str | None = None,
    resolution: int | None = None,
) -> list[ParcellationMetadata]:
    """List available parcellations with optional filtering.

    Parameters
    ----------
    space : str, optional
        Filter by coordinate space (e.g., "MNI152NLin6Asym")
    resolution : int, optional
        Filter by resolution in mm (e.g., 1, 2)

    Returns
    -------
    list[ParcellationMetadata]
        List of parcellation metadata matching the filters

    Examples
    --------
    >>> # List all parcellations
    >>> parcellations = list_parcellations()
    >>>
    >>> # Filter by space
    >>> mni6_parcellations = list_parcellations(space="MNI152NLin6Asym")
    >>>
    >>> # Filter by resolution
    >>> res1_parcellations = list_parcellations(resolution=1)
    >>>
    >>> # Combined filters
    >>> filtered = list_parcellations(space="MNI152NLin6Asym", resolution=1)
    """
    parcellations = list(PARCELLATION_REGISTRY.values())

    if space is not None:
        parcellations = [a for a in parcellations if a.space == space]

    if resolution is not None:
        parcellations = [a for a in parcellations if a.resolution == resolution]

    # Sort by name for consistent ordering
    parcellations = sorted(parcellations, key=lambda a: a.name)

    return parcellations

list_structural_connectomes(atlas=None, space=None)

List registered structural connectomes.

Parameters:

Name Type Description Default
atlas str

Filter by atlas name

None
space str

Filter by coordinate space

None

Returns:

Type Description
list[StructuralConnectomeMetadata]

Matching connectomes

Examples:

>>> from lacuna.assets.connectomes import list_structural_connectomes
>>>
>>> # List all
>>> connectomes = list_structural_connectomes()
>>>
>>> # Filter by atlas
>>> schaefer_connectomes = list_structural_connectomes(
...     atlas="schaefer2018parcels100networks7"
... )
Source code in src/lacuna/assets/connectomes/structural.py
def list_structural_connectomes(
    atlas: str | None = None,
    space: str | None = None,
) -> list[StructuralConnectomeMetadata]:
    """List registered structural connectomes.

    Parameters
    ----------
    atlas : str, optional
        Filter by atlas name
    space : str, optional
        Filter by coordinate space

    Returns
    -------
    list[StructuralConnectomeMetadata]
        Matching connectomes

    Examples
    --------
    >>> from lacuna.assets.connectomes import list_structural_connectomes
    >>>
    >>> # List all
    >>> connectomes = list_structural_connectomes()
    >>>
    >>> # Filter by atlas
    >>> schaefer_connectomes = list_structural_connectomes(
    ...     atlas="schaefer2018parcels100networks7"
    ... )
    """
    return _structural_connectome_registry.list(atlas=atlas, space=space)

list_templates(space=None, resolution=None, modality=None)

List available templates from TemplateFlow.

Parameters:

Name Type Description Default
space str

Filter by coordinate space

None
resolution float

Filter by resolution in mm

None
modality str

Filter by modality (e.g., "T1w", "T2w")

None

Returns:

Type Description
list[TemplateMetadata]

Matching templates

Examples:

>>> from lacuna.assets.templates import list_templates
>>>
>>> # List all available templates
>>> templates = list_templates()
>>>
>>> # Filter by space and resolution
>>> mni_1mm = list_templates(space="MNI152NLin2009cAsym", resolution=1.0)
Source code in src/lacuna/assets/templates/registry.py
def list_templates(
    space: str | None = None,
    resolution: float | None = None,
    modality: str | None = None,
) -> list[TemplateMetadata]:
    """List available templates from TemplateFlow.

    Parameters
    ----------
    space : str, optional
        Filter by coordinate space
    resolution : float, optional
        Filter by resolution in mm
    modality : str, optional
        Filter by modality (e.g., "T1w", "T2w")

    Returns
    -------
    list[TemplateMetadata]
        Matching templates

    Examples
    --------
    >>> from lacuna.assets.templates import list_templates
    >>>
    >>> # List all available templates
    >>> templates = list_templates()
    >>>
    >>> # Filter by space and resolution
    >>> mni_1mm = list_templates(space="MNI152NLin2009cAsym", resolution=1.0)
    """
    return TEMPLATE_REGISTRY.list(space=space, resolution=resolution, modality=modality)

list_transforms(from_space=None, to_space=None)

List available transforms from TemplateFlow.

Parameters:

Name Type Description Default
from_space str

Filter by source coordinate space

None
to_space str

Filter by target coordinate space

None

Returns:

Type Description
list[TransformMetadata]

Matching transforms

Examples:

>>> from lacuna.assets.transforms import list_transforms
>>>
>>> # List all available transforms
>>> transforms = list_transforms()
>>>
>>> # Find transforms from NLin6 to NLin2009c
>>> transforms = list_transforms(
...     from_space="MNI152NLin6Asym",
...     to_space="MNI152NLin2009cAsym"
... )
Source code in src/lacuna/assets/transforms/registry.py
def list_transforms(
    from_space: str | None = None,
    to_space: str | None = None,
) -> list[TransformMetadata]:
    """List available transforms from TemplateFlow.

    Parameters
    ----------
    from_space : str, optional
        Filter by source coordinate space
    to_space : str, optional
        Filter by target coordinate space

    Returns
    -------
    list[TransformMetadata]
        Matching transforms

    Examples
    --------
    >>> from lacuna.assets.transforms import list_transforms
    >>>
    >>> # List all available transforms
    >>> transforms = list_transforms()
    >>>
    >>> # Find transforms from NLin6 to NLin2009c
    >>> transforms = list_transforms(
    ...     from_space="MNI152NLin6Asym",
    ...     to_space="MNI152NLin2009cAsym"
    ... )
    """
    # Use registry filtering
    # Note: AssetRegistry.list() doesn't support from_space/to_space directly,
    # so we need to filter manually
    all_transforms = TRANSFORM_REGISTRY.list()

    if from_space is not None:
        all_transforms = [t for t in all_transforms if t.from_space == from_space]

    if to_space is not None:
        all_transforms = [t for t in all_transforms if t.to_space == to_space]

    return all_transforms

load_functional_connectome(name)

Load a functional connectome for fLNM analysis.

Parameters:

Name Type Description Default
name str

Connectome name

required

Returns:

Type Description
FunctionalConnectome

Loaded connectome with path ready for FunctionalNetworkMapping

Raises:

Type Description
KeyError

If connectome not registered

Examples:

>>> from lacuna.assets.connectomes import load_functional_connectome
>>> from lacuna.analysis import FunctionalNetworkMapping
>>>
>>> connectome = load_functional_connectome("GSP1000")
>>> analysis = FunctionalNetworkMapping(
...     connectome_path=connectome.data_path,
...     method="boes"
... )
Source code in src/lacuna/assets/connectomes/functional.py
def load_functional_connectome(name: str) -> FunctionalConnectome:
    """Load a functional connectome for fLNM analysis.

    Parameters
    ----------
    name : str
        Connectome name

    Returns
    -------
    FunctionalConnectome
        Loaded connectome with path ready for FunctionalNetworkMapping

    Raises
    ------
    KeyError
        If connectome not registered

    Examples
    --------
    >>> from lacuna.assets.connectomes import load_functional_connectome
    >>> from lacuna.analysis import FunctionalNetworkMapping
    >>>
    >>> connectome = load_functional_connectome("GSP1000")
    >>> analysis = FunctionalNetworkMapping(
    ...     connectome_path=connectome.data_path,
    ...     method="boes"
    ... )
    """
    metadata = _functional_connectome_registry.get(name)

    return FunctionalConnectome(
        metadata=metadata,
        data_path=metadata.data_path,
        is_batched=metadata.is_batched,
    )

load_parcellation(parcellation_name)

Load an parcellation by name from the registry.

Loads bundled parcellations or user-registered custom parcellations.

Parameters:

Name Type Description Default
parcellation_name str

Name of the parcellation from PARCELLATION_REGISTRY

required

Returns:

Type Description
Parcellation

Loaded parcellation with image, labels, and metadata

Raises:

Type Description
KeyError

If parcellation_name is not in the registry

FileNotFoundError

If parcellation files are not found

Examples:

>>> parcellation = load_parcellation("schaefer2018parcels100networks7")
>>> print(f"Parcellation has {len(parcellation.labels)} regions")
>>> print(f"Space: {parcellation.metadata.space}")
Source code in src/lacuna/assets/parcellations/loader.py
def load_parcellation(
    parcellation_name: str,
) -> Parcellation:
    """Load an parcellation by name from the registry.

    Loads bundled parcellations or user-registered custom parcellations.

    Parameters
    ----------
    parcellation_name : str
        Name of the parcellation from PARCELLATION_REGISTRY

    Returns
    -------
    Parcellation
        Loaded parcellation with image, labels, and metadata

    Raises
    ------
    KeyError
        If parcellation_name is not in the registry
    FileNotFoundError
        If parcellation files are not found

    Examples
    --------
    >>> parcellation = load_parcellation("schaefer2018parcels100networks7")
    >>> print(f"Parcellation has {len(parcellation.labels)} regions")
    >>> print(f"Space: {parcellation.metadata.space}")
    """
    # Get metadata from registry
    if parcellation_name not in PARCELLATION_REGISTRY:
        available = list(PARCELLATION_REGISTRY.keys())
        raise KeyError(
            f"Parcellation '{parcellation_name}' not found in registry. "
            f"Available parcellations: {', '.join(available)}"
        )

    metadata = PARCELLATION_REGISTRY[parcellation_name]

    # Determine parcellation directory (bundled or custom)
    # If parcellation_filename is absolute path, use it directly
    # Otherwise, look in bundled parcellations directory
    parcellation_filename_path = Path(metadata.parcellation_filename)
    if parcellation_filename_path.is_absolute():
        parcellation_path = parcellation_filename_path
    else:
        parcellation_path = BUNDLED_PARCELLATIONS_DIR / metadata.parcellation_filename
    if not parcellation_path.exists():
        raise FileNotFoundError(
            f"Parcellation file not found: {parcellation_path}\n"
            f"Expected: {metadata.parcellation_filename}"
        )

    image = nib.load(parcellation_path)

    # Load labels (same logic: absolute or relative to bundled dir)
    labels_filename_path = Path(metadata.labels_filename)
    if labels_filename_path.is_absolute():
        labels_path = labels_filename_path
    else:
        labels_path = BUNDLED_PARCELLATIONS_DIR / metadata.labels_filename
    if not labels_path.exists():
        raise FileNotFoundError(
            f"Labels file not found: {labels_path}\n" f"Expected: {metadata.labels_filename}"
        )

    labels = _load_labels_file(labels_path)

    return Parcellation(image=image, labels=labels, metadata=metadata)

load_structural_connectome(name)

Load a structural connectome for sLNM analysis.

Parameters:

Name Type Description Default
name str

Connectome name

required

Returns:

Type Description
StructuralConnectome

Loaded connectome with tractogram path ready for StructuralNetworkMapping. TDI will be computed on-the-fly during analysis.

Raises:

Type Description
KeyError

If connectome not registered

Examples:

>>> from lacuna.assets.connectomes import load_structural_connectome
>>> from lacuna.analysis import StructuralNetworkMapping
>>>
>>> connectome = load_structural_connectome("dTOR985")
>>> analysis = StructuralNetworkMapping(
...     connectome_name="dTOR985",
...     cache_tdi=True  # Cache computed TDI for reuse
... )
Source code in src/lacuna/assets/connectomes/structural.py
def load_structural_connectome(name: str) -> StructuralConnectome:
    """Load a structural connectome for sLNM analysis.

    Parameters
    ----------
    name : str
        Connectome name

    Returns
    -------
    StructuralConnectome
        Loaded connectome with tractogram path ready for StructuralNetworkMapping.
        TDI will be computed on-the-fly during analysis.

    Raises
    ------
    KeyError
        If connectome not registered

    Examples
    --------
    >>> from lacuna.assets.connectomes import load_structural_connectome
    >>> from lacuna.analysis import StructuralNetworkMapping
    >>>
    >>> connectome = load_structural_connectome("dTOR985")
    >>> analysis = StructuralNetworkMapping(
    ...     connectome_name="dTOR985",
    ...     cache_tdi=True  # Cache computed TDI for reuse
    ... )
    """
    metadata = _structural_connectome_registry.get(name)

    return StructuralConnectome(
        metadata=metadata,
        tractogram_path=metadata.tractogram_path,
        template_path=metadata.template_path,
    )

load_template(name)

Load a reference brain template by name.

Downloads from TemplateFlow on first use and caches locally.

Supports space equivalence: anatomically identical spaces like MNI152NLin2009[abc]Asym are automatically normalized to their canonical form (cAsym).

Parameters:

Name Type Description Default
name str

Template name from registry (e.g., "MNI152NLin2009cAsym_res-1")

required

Returns:

Type Description
Path

Path to template NIfTI file

Raises:

Type Description
KeyError

If template not found in registry

FileNotFoundError

If template download fails

Examples:

>>> from lacuna.assets.templates import load_template
>>>
>>> # Load MNI template
>>> template_path = load_template("MNI152NLin2009cAsym_res-1")
>>> import nibabel as nib
>>> template = nib.load(template_path)
>>> print(template.shape)
(193, 229, 193)
Source code in src/lacuna/assets/templates/loader.py
def load_template(name: str) -> Path:
    """Load a reference brain template by name.

    Downloads from TemplateFlow on first use and caches locally.

    Supports space equivalence: anatomically identical spaces like
    MNI152NLin2009[abc]Asym are automatically normalized to their
    canonical form (cAsym).

    Parameters
    ----------
    name : str
        Template name from registry (e.g., "MNI152NLin2009cAsym_res-1")

    Returns
    -------
    Path
        Path to template NIfTI file

    Raises
    ------
    KeyError
        If template not found in registry
    FileNotFoundError
        If template download fails

    Examples
    --------
    >>> from lacuna.assets.templates import load_template
    >>>
    >>> # Load MNI template
    >>> template_path = load_template("MNI152NLin2009cAsym_res-1")
    >>> import nibabel as nib
    >>> template = nib.load(template_path)
    >>> print(template.shape)
    (193, 229, 193)
    """
    # Canonicalize space variant in template name before registry lookup
    # e.g., "MNI152NLin2009bAsym_res-2" -> "MNI152NLin2009cAsym_res-2"
    if "_res-" in name:
        space_part, res_part = name.rsplit("_res-", 1)
        canonical_space = _canonicalize_space_variant(space_part)
        canonical_name = f"{canonical_space}_res-{res_part}"
        if canonical_name != name:
            logger.info(
                f"Using space equivalence: {name} → {canonical_name} "
                f"(anatomically identical spaces)"
            )
            name = canonical_name

    # Get metadata from registry
    metadata = TEMPLATE_REGISTRY.get(name)

    # Normalize space to handle equivalence
    space_normalized = _canonicalize_space_variant(metadata.space)

    # Log if normalization occurred
    if space_normalized != metadata.space:
        logger.info(
            f"Using space equivalence: {metadata.space} → {space_normalized} "
            f"(anatomically identical spaces)"
        )

    try:
        import templateflow.api as tflow
    except ImportError as e:
        raise ImportError(
            "TemplateFlow is required for template loading. "
            "Install with: pip install templateflow"
        ) from e

    try:
        # Get template from TemplateFlow (using normalized space)
        template_path = tflow.get(
            space_normalized,
            resolution=metadata.resolution,
            desc=None,
            suffix=metadata.modality,
            extension=".nii.gz",
        )

        if template_path is None or (isinstance(template_path, list) and not template_path):
            raise ValueError(
                f"Template not found in TemplateFlow: {metadata.space} at {metadata.resolution}mm"
            )

        # TemplateFlow can return a list, take first item
        if isinstance(template_path, list):
            template_path = template_path[0]

        return Path(template_path)

    except Exception as e:
        raise FileNotFoundError(
            f"Failed to load template {name} "
            f"(space={metadata.space}, res={metadata.resolution}, modality={metadata.modality}): {e}"
        ) from e

load_transform(name)

Load a spatial transform by name.

Downloads from TemplateFlow on first use and caches locally. Tries both forward and reverse directions since TemplateFlow may only have the transform stored in one direction.

Supports space equivalence: anatomically identical spaces like MNI152NLin2009[abc]Asym are automatically normalized to their canonical form (cAsym).

Parameters:

Name Type Description Default
name str

Transform name from registry (e.g., "MNI152NLin6Asym_to_MNI152NLin2009cAsym")

required

Returns:

Type Description
Path

Path to transform .h5 file

Raises:

Type Description
KeyError

If transform not found in registry

FileNotFoundError

If transform download fails

Examples:

>>> from lacuna.assets.transforms import load_transform
>>>
>>> # Load transform
>>> transform_path = load_transform("MNI152NLin6Asym_to_MNI152NLin2009cAsym")
>>> print(transform_path.exists())
True
Source code in src/lacuna/assets/transforms/loader.py
def load_transform(name: str) -> Path:
    """Load a spatial transform by name.

    Downloads from TemplateFlow on first use and caches locally.
    Tries both forward and reverse directions since TemplateFlow may
    only have the transform stored in one direction.

    Supports space equivalence: anatomically identical spaces like
    MNI152NLin2009[abc]Asym are automatically normalized to their
    canonical form (cAsym).

    Parameters
    ----------
    name : str
        Transform name from registry (e.g., "MNI152NLin6Asym_to_MNI152NLin2009cAsym")

    Returns
    -------
    Path
        Path to transform .h5 file

    Raises
    ------
    KeyError
        If transform not found in registry
    FileNotFoundError
        If transform download fails

    Examples
    --------
    >>> from lacuna.assets.transforms import load_transform
    >>>
    >>> # Load transform
    >>> transform_path = load_transform("MNI152NLin6Asym_to_MNI152NLin2009cAsym")
    >>> print(transform_path.exists())
    True
    """
    # Normalize the requested transform name to handle space aliases
    # e.g., "MNI152NLin2009aAsym_to_X" -> "MNI152NLin2009cAsym_to_X"
    parts = name.split("_to_")
    if len(parts) == 2:
        from_space, to_space = parts
        from_space_normalized = _canonicalize_space_variant(from_space)
        to_space_normalized = _canonicalize_space_variant(to_space)
        normalized_name = f"{from_space_normalized}_to_{to_space_normalized}"
    else:
        normalized_name = name

    # Get metadata from registry using normalized name
    metadata = TRANSFORM_REGISTRY.get(normalized_name)

    # Normalize spaces from metadata to handle equivalence
    from_space_normalized = _canonicalize_space_variant(metadata.from_space)
    to_space_normalized = _canonicalize_space_variant(metadata.to_space)

    # Log if normalization occurred
    if from_space_normalized != metadata.from_space or to_space_normalized != metadata.to_space:
        logger.info(
            f"Using space equivalence: {metadata.from_space} → {from_space_normalized}, "
            f"{metadata.to_space} → {to_space_normalized} (anatomically identical spaces)"
        )

    try:
        import templateflow.api as tflow
    except ImportError as e:
        raise ImportError(
            "TemplateFlow is required for transform loading. "
            "Install with: pip install templateflow"
        ) from e

    import time

    def _wait_for_file(path: Path, timeout: float = 10.0) -> bool:
        """Wait for file to exist with exponential backoff."""
        start_time = time.time()
        delay = 0.1  # Start with 100ms

        while time.time() - start_time < timeout:
            if path.exists() and path.stat().st_size > 0:
                return True
            time.sleep(delay)
            delay = min(delay * 1.5, 1.0)  # Exponential backoff, max 1s

        return False

    # TemplateFlow naming convention:
    # tpl-{target}_from-{source}_mode-image_xfm.h5
    # The transform is stored under the target template

    logger.debug(
        f"Loading transform: {from_space_normalized} → {to_space_normalized} "
        f"(original request: {name})"
    )

    # Pre-create cache directories to avoid TemplateFlow's unlink bug
    # TemplateFlow tries to delete files before checking if they exist
    cache_dir = Path.home() / ".cache" / "templateflow"

    for space in [metadata.to_space, metadata.from_space]:
        space_dir = cache_dir / f"tpl-{space}"
        space_dir.mkdir(parents=True, exist_ok=True)

    # Try forward direction (source -> target)
    transform_path = None
    forward_error = None

    logger.debug(
        f"Querying TemplateFlow for forward transform: {from_space_normalized} → {to_space_normalized}"
    )

    try:
        transform_path = tflow.get(
            to_space_normalized,
            **{"from": from_space_normalized},
            mode="image",
            suffix="xfm",
            extension=".h5",
        )

        if transform_path is not None and transform_path:
            # TemplateFlow can return a list or a single path
            if isinstance(transform_path, list):
                if transform_path:  # Non-empty list
                    transform_path = transform_path[0]
                else:
                    transform_path = None

            if transform_path:
                path = Path(transform_path)
                if _wait_for_file(path):
                    # Verify file integrity
                    file_size = path.stat().st_size
                    if file_size < 1024:  # Suspiciously small (< 1KB)
                        logger.warning(
                            f"Transform file seems corrupted (size: {file_size} bytes): {path}. "
                            "Removing and will re-download."
                        )
                        path.unlink()
                        # Retry download
                        transform_path = tflow.get(
                            to_space_normalized,
                            **{"from": from_space_normalized},
                            mode="image",
                            suffix="xfm",
                            extension=".h5",
                        )
                        if isinstance(transform_path, list) and transform_path:
                            transform_path = transform_path[0]
                        path = Path(transform_path) if transform_path else None
                        if not path or not path.exists():
                            raise FileNotFoundError(
                                "Failed to download valid transform file after retry"
                            )
                        file_size = path.stat().st_size

                    logger.debug(f"Transform loaded: {path.name} ({file_size / (1024**2):.1f} MB)")
                    return path

    except Exception as e:
        forward_error = e
        logger.debug(f"Forward transform query failed: {e}")

    # Try reverse direction (target -> source)
    reverse_error = None

    logger.debug(
        f"Querying TemplateFlow for reverse transform: {to_space_normalized} → {from_space_normalized}"
    )

    try:
        transform_path = tflow.get(
            from_space_normalized,
            **{"from": to_space_normalized},
            mode="image",
            suffix="xfm",
            extension=".h5",
        )

        if transform_path is not None and transform_path:
            # TemplateFlow can return a list or a single path
            if isinstance(transform_path, list):
                if transform_path:  # Non-empty list
                    transform_path = transform_path[0]
                else:
                    transform_path = None

            if transform_path:
                path = Path(transform_path)
                if _wait_for_file(path):
                    # Verify file integrity
                    file_size = path.stat().st_size
                    if file_size < 1024:  # Suspiciously small (< 1KB)
                        logger.warning(
                            f"Transform file seems corrupted (size: {file_size} bytes): {path}. "
                            "Removing and will re-download."
                        )
                        path.unlink()
                        # Retry download
                        transform_path = tflow.get(
                            from_space_normalized,
                            **{"from": to_space_normalized},
                            mode="image",
                            suffix="xfm",
                            extension=".h5",
                        )
                        if isinstance(transform_path, list) and transform_path:
                            transform_path = transform_path[0]
                        path = Path(transform_path) if transform_path else None
                        if not path or not path.exists():
                            raise FileNotFoundError(
                                "Failed to download valid transform file after retry"
                            )
                        file_size = path.stat().st_size

                    logger.info(
                        f"✓ Transform loaded (reverse): {path.name} "
                        f"({file_size / (1024**2):.1f} MB)"
                    )
                    return path

            if transform_path:
                path = Path(transform_path)
                if _wait_for_file(path):
                    return path

    except Exception as e:
        reverse_error = e

    # If we get here, transform wasn't found in either direction
    error_details = []
    if forward_error:
        error_details.append(f"Forward: {forward_error}")
    if reverse_error:
        error_details.append(f"Reverse: {reverse_error}")

    error_msg = (
        f"Transform {normalized_name} not found in TemplateFlow "
        f"({from_space_normalized} ↔ {to_space_normalized}). "
    )

    if error_details:
        error_msg += f" Errors: {'; '.join(error_details)}"
    else:
        error_msg += "The transform file may not be available or download may have failed."

    raise FileNotFoundError(error_msg)

register_functional_connectome(name, space, resolution, data_path, n_subjects=None, description='')

Register a functional connectome for fLNM analysis.

Supports both single HDF5 files and directories with batched files.

HDF5 Required Structure: - 'timeseries': (n_subjects, n_timepoints, n_voxels) array - 'mask_indices': (3, n_voxels) or (n_voxels, 3) coordinates - 'mask_affine': (4, 4) affine matrix - 'mask_shape': Tuple attribute (e.g., (91, 109, 91))

Parameters:

Name Type Description Default
name str

Unique identifier (e.g., "GSP1000")

required
space str

Coordinate space (e.g., "MNI152NLin6Asym")

required
resolution float

Resolution in mm (typically 2.0)

required
data_path str or Path

Path to .h5 file or directory containing batch files

required
n_subjects int

Total sample size (for documentation purposes only)

None
description str

Human-readable description

''

Raises:

Type Description
FileNotFoundError

If data_path doesn't exist

ValueError

If HDF5 structure is invalid

Examples:

>>> from lacuna.assets.connectomes import register_functional_connectome
>>>
>>> # Single file
>>> register_functional_connectome(
...     name="GSP1000",
...     space="MNI152NLin6Asym",
...     resolution=2.0,
...     data_path="/data/gsp/gsp1000_connectome.h5",
...     description="GSP1000 voxel-wise connectome"
... )
>>>
>>> # Batched directory
>>> register_functional_connectome(
...     name="GSP1000_batched",
...     space="MNI152NLin6Asym",
...     resolution=2.0,
...     data_path="/data/gsp/batches/",
...     description="GSP1000 voxel-wise connectome (batched)"
... )
Source code in src/lacuna/assets/connectomes/functional.py
def register_functional_connectome(
    name: str,
    space: str,
    resolution: float,
    data_path: str | Path,
    n_subjects: int | None = None,
    description: str = "",
) -> None:
    """Register a functional connectome for fLNM analysis.

    Supports both single HDF5 files and directories with batched files.

    HDF5 Required Structure:
    - 'timeseries': (n_subjects, n_timepoints, n_voxels) array
    - 'mask_indices': (3, n_voxels) or (n_voxels, 3) coordinates
    - 'mask_affine': (4, 4) affine matrix
    - 'mask_shape': Tuple attribute (e.g., (91, 109, 91))

    Parameters
    ----------
    name : str
        Unique identifier (e.g., "GSP1000")
    space : str
        Coordinate space (e.g., "MNI152NLin6Asym")
    resolution : float
        Resolution in mm (typically 2.0)
    data_path : str or Path
        Path to .h5 file or directory containing batch files
    n_subjects : int, optional
        Total sample size (for documentation purposes only)
    description : str, optional
        Human-readable description

    Raises
    ------
    FileNotFoundError
        If data_path doesn't exist
    ValueError
        If HDF5 structure is invalid

    Examples
    --------
    >>> from lacuna.assets.connectomes import register_functional_connectome
    >>>
    >>> # Single file
    >>> register_functional_connectome(
    ...     name="GSP1000",
    ...     space="MNI152NLin6Asym",
    ...     resolution=2.0,
    ...     data_path="/data/gsp/gsp1000_connectome.h5",
    ...     description="GSP1000 voxel-wise connectome"
    ... )
    >>>
    >>> # Batched directory
    >>> register_functional_connectome(
    ...     name="GSP1000_batched",
    ...     space="MNI152NLin6Asym",
    ...     resolution=2.0,
    ...     data_path="/data/gsp/batches/",
    ...     description="GSP1000 voxel-wise connectome (batched)"
    ... )
    """
    # Convert to path
    data_path = Path(data_path).resolve()

    # Validate path exists
    if not data_path.exists():
        raise FileNotFoundError(f"Data path not found: {data_path}")

    # Determine if batched
    is_batched = data_path.is_dir()

    # Validate HDF5 structure
    if is_batched:
        # Find first .h5 file in directory
        h5_files = list(data_path.glob("*.h5"))
        if not h5_files:
            raise ValueError(f"No .h5 files found in directory: {data_path}")
        test_file = h5_files[0]
    else:
        if data_path.suffix != ".h5":
            raise ValueError(f"Expected .h5 file, got: {data_path.suffix}")
        test_file = data_path

    # Validate required datasets
    try:
        with h5py.File(test_file, "r") as f:
            required = ["timeseries", "mask_indices", "mask_affine"]
            missing = [k for k in required if k not in f]
            if missing:
                raise ValueError(
                    f"HDF5 file missing required datasets: {missing}. " f"Required: {required}"
                )

            # Check mask_shape attribute
            if "mask_shape" not in f.attrs:
                raise ValueError("HDF5 file must have 'mask_shape' attribute")
    except Exception as e:
        raise ValueError(f"Invalid HDF5 file structure: {e}") from e

    # Create metadata
    metadata = FunctionalConnectomeMetadata(
        name=name,
        space=space,
        resolution=resolution,
        description=description or f"Functional connectome: {name}",
        n_subjects=n_subjects or 0,
        data_path=data_path,
        is_batched=is_batched,
    )

    # Register
    _functional_connectome_registry.register(metadata)

register_parcellation(metadata)

Register a custom parcellation with the registry.

Allows users to add their own parcellations to the registry for use with Lacuna's analysis modules.

Parameters:

Name Type Description Default
metadata ParcellationMetadata

Complete metadata for the custom parcellation. The parcellation_filename and labels_filename should be absolute paths to the parcellation files.

required

Raises:

Type Description
ValueError

If an parcellation with the same name already exists in the registry

Examples:

>>> from pathlib import Path
>>> from lacuna.parcellation.registry import register_parcellation, ParcellationMetadata
>>>
>>> # Register a custom parcellation
>>> custom_metadata = ParcellationMetadata(
...     name="MyCustomParcellation",
...     space="MNI152NLin6Asym",
...     resolution=1,
...     description="My custom parcellation",
...     parcellation_filename="/path/to/my_parcellation.nii.gz",
...     labels_filename="/path/to/my_parcellation_labels.txt",
... )
>>> register_parcellation(custom_metadata)
Source code in src/lacuna/assets/parcellations/registry.py
def register_parcellation(metadata: ParcellationMetadata) -> None:
    """Register a custom parcellation with the registry.

    Allows users to add their own parcellations to the registry for use with
    Lacuna's analysis modules.

    Parameters
    ----------
    metadata : ParcellationMetadata
        Complete metadata for the custom parcellation. The parcellation_filename and
        labels_filename should be absolute paths to the parcellation files.

    Raises
    ------
    ValueError
        If an parcellation with the same name already exists in the registry

    Examples
    --------
    >>> from pathlib import Path
    >>> from lacuna.parcellation.registry import register_parcellation, ParcellationMetadata
    >>>
    >>> # Register a custom parcellation
    >>> custom_metadata = ParcellationMetadata(
    ...     name="MyCustomParcellation",
    ...     space="MNI152NLin6Asym",
    ...     resolution=1,
    ...     description="My custom parcellation",
    ...     parcellation_filename="/path/to/my_parcellation.nii.gz",
    ...     labels_filename="/path/to/my_parcellation_labels.txt",
    ... )
    >>> register_parcellation(custom_metadata)
    """
    if metadata.name in PARCELLATION_REGISTRY:
        raise ValueError(
            f"Parcellation '{metadata.name}' already registered. "
            f"Use a different name or unregister the existing parcellation first."
        )

    PARCELLATION_REGISTRY[metadata.name] = metadata

register_parcellation_from_files(name, parcellation_path, labels_path, space, resolution, description, citation=None, networks=None, n_regions=None)

Register a custom parcellation from file paths.

Convenience function that creates ParcellationMetadata from file paths and registers the parcellation.

Parameters:

Name Type Description Default
name str

Unique identifier for the parcellation

required
parcellation_path str or Path

Path to the NIfTI parcellation file

required
labels_path str or Path

Path to the labels text file

required
space str

Coordinate space (e.g., "MNI152NLin6Asym")

required
resolution int

Resolution in mm

required
description str

Human-readable description

required
citation str

Citation information

None
networks list[str]

List of network names if parcellation has network organization

None
n_regions int

Number of regions in the parcellation

None

Examples:

>>> from lacuna.parcellation.registry import register_parcellation_from_files
>>>
>>> register_parcellation_from_files(
...     name="MyParcellation",
...     parcellation_path="/data/parcellations/my_parcellation.nii.gz",
...     labels_path="/data/parcellations/my_parcellation_labels.txt",
...     space="MNI152NLin6Asym",
...     resolution=2,
...     description="Custom 2mm parcellation",
... )
Source code in src/lacuna/assets/parcellations/registry.py
def register_parcellation_from_files(
    name: str,
    parcellation_path: str | Path,
    labels_path: str | Path,
    space: str,
    resolution: int,
    description: str,
    citation: str | None = None,
    networks: list[str] | None = None,
    n_regions: int | None = None,
) -> None:
    """Register a custom parcellation from file paths.

    Convenience function that creates ParcellationMetadata from file paths and
    registers the parcellation.

    Parameters
    ----------
    name : str
        Unique identifier for the parcellation
    parcellation_path : str or Path
        Path to the NIfTI parcellation file
    labels_path : str or Path
        Path to the labels text file
    space : str
        Coordinate space (e.g., "MNI152NLin6Asym")
    resolution : int
        Resolution in mm
    description : str
        Human-readable description
    citation : str, optional
        Citation information
    networks : list[str], optional
        List of network names if parcellation has network organization
    n_regions : int, optional
        Number of regions in the parcellation

    Examples
    --------
    >>> from lacuna.parcellation.registry import register_parcellation_from_files
    >>>
    >>> register_parcellation_from_files(
    ...     name="MyParcellation",
    ...     parcellation_path="/data/parcellations/my_parcellation.nii.gz",
    ...     labels_path="/data/parcellations/my_parcellation_labels.txt",
    ...     space="MNI152NLin6Asym",
    ...     resolution=2,
    ...     description="Custom 2mm parcellation",
    ... )
    """
    # Convert to absolute paths
    parcellation_path = Path(parcellation_path).resolve()
    labels_path = Path(labels_path).resolve()

    # Verify files exist
    if not parcellation_path.exists():
        raise FileNotFoundError(f"Parcellation file not found: {parcellation_path}")
    if not labels_path.exists():
        raise FileNotFoundError(f"Labels file not found: {labels_path}")

    # Load region labels from file
    from lacuna.assets.parcellations.loader import _load_labels_file

    labels_dict = _load_labels_file(labels_path)
    # Convert to list ordered by region ID (1-indexed)
    max_region = max(labels_dict.keys()) if labels_dict else 0
    region_labels = [labels_dict.get(i, f"region_{i:03d}") for i in range(1, max_region + 1)]

    metadata = ParcellationMetadata(
        name=name,
        space=space,
        resolution=resolution,
        description=description,
        parcellation_filename=str(parcellation_path),
        labels_filename=str(labels_path),
        citation=citation,
        networks=networks or [],
        n_regions=n_regions,
        region_labels=region_labels,
    )

    register_parcellation(metadata)

register_parcellations_from_directory(directory, space=None, resolution=None, overwrite=False)

Register all parcellations found in a directory.

Discovers parcellation files in the directory and registers them. Each parcellation should have: - NIfTI file (.nii or .nii.gz) - Labels file with same base name + "_labels.txt" or ".txt"

If space/resolution are not provided, attempts to parse from BIDS-style filenames (tpl-{SPACE}res-{RES}...) or detect from image headers.

Parameters:

Name Type Description Default
directory str or Path

Directory containing parcellation files

required
space str

Default coordinate space for parcellations without BIDS naming

None
resolution int

Default resolution for parcellations without BIDS naming

None
overwrite bool

If True, overwrite existing parcellations with same names

False

Returns:

Type Description
list[str]

Names of successfully registered parcellations

Raises:

Type Description
FileNotFoundError

If directory doesn't exist

Examples:

>>> from lacuna.parcellation.registry import register_parcellations_from_directory
>>>
>>> # Register all parcellations from a directory
>>> registered = register_parcellations_from_directory("/data/my_parcellations")
>>> print(f"Registered {len(registered)} parcellations: {registered}")
>>>
>>> # Register with explicit space/resolution for non-BIDS parcellations
>>> registered = register_parcellations_from_directory(
...     "/data/custom_parcellations",
...     space="MNI152NLin6Asym",
...     resolution=2
... )
Source code in src/lacuna/assets/parcellations/registry.py
def register_parcellations_from_directory(
    directory: str | Path,
    space: str | None = None,
    resolution: int | None = None,
    overwrite: bool = False,
) -> list[str]:
    """Register all parcellations found in a directory.

    Discovers parcellation files in the directory and registers them. Each parcellation should have:
    - NIfTI file (.nii or .nii.gz)
    - Labels file with same base name + "_labels.txt" or ".txt"

    If space/resolution are not provided, attempts to parse from BIDS-style filenames
    (tpl-{SPACE}_res-{RES}_...) or detect from image headers.

    Parameters
    ----------
    directory : str or Path
        Directory containing parcellation files
    space : str, optional
        Default coordinate space for parcellations without BIDS naming
    resolution : int, optional
        Default resolution for parcellations without BIDS naming
    overwrite : bool, default=False
        If True, overwrite existing parcellations with same names

    Returns
    -------
    list[str]
        Names of successfully registered parcellations

    Raises
    ------
    FileNotFoundError
        If directory doesn't exist

    Examples
    --------
    >>> from lacuna.parcellation.registry import register_parcellations_from_directory
    >>>
    >>> # Register all parcellations from a directory
    >>> registered = register_parcellations_from_directory("/data/my_parcellations")
    >>> print(f"Registered {len(registered)} parcellations: {registered}")
    >>>
    >>> # Register with explicit space/resolution for non-BIDS parcellations
    >>> registered = register_parcellations_from_directory(
    ...     "/data/custom_parcellations",
    ...     space="MNI152NLin6Asym",
    ...     resolution=2
    ... )
    """
    import nibabel as nib

    from lacuna.core.spaces import detect_space_from_header

    directory = Path(directory)

    if not directory.exists():
        raise FileNotFoundError(f"Directory not found: {directory}")

    if not directory.is_dir():
        raise ValueError(f"Path is not a directory: {directory}")

    registered_names = []

    # Find all NIfTI files
    nifti_patterns = ["*.nii.gz", "*.nii"]
    nifti_files = []
    for pattern in nifti_patterns:
        nifti_files.extend(directory.glob(pattern))

    for nifti_path in nifti_files:
        # Get base name
        if nifti_path.name.endswith(".nii.gz"):
            base_name = nifti_path.name[:-7]
        else:
            base_name = nifti_path.name[:-4]

        # Skip if already registered and not overwriting
        if base_name in PARCELLATION_REGISTRY and not overwrite:
            continue

        # Look for corresponding labels file
        labels_path = directory / f"{base_name}_labels.txt"
        if not labels_path.exists():
            labels_path = directory / f"{base_name}.txt"

        if not labels_path.exists():
            # Skip parcellation without labels
            continue

        # Parse space and resolution from filename or use defaults
        parcellation_space = space
        parcellation_resolution = resolution

        # Try BIDS-style filename parsing
        parts = base_name.split("_")
        for part in parts:
            if part.startswith("tpl-"):
                parcellation_space = part[4:]
            elif part.startswith("res-"):
                try:
                    res_str = part[4:]
                    parcellation_resolution = (
                        int(res_str) if res_str.isdigit() else parcellation_resolution
                    )
                except (ValueError, AttributeError):
                    pass

        # Fall back to header detection if needed
        if parcellation_space is None or parcellation_resolution is None:
            try:
                parcellation_img = nib.load(nifti_path)
                detected = detect_space_from_header(parcellation_img)
                if detected is not None:
                    detected_space, detected_res = detected
                    if parcellation_space is None:
                        parcellation_space = detected_space
                    if parcellation_resolution is None:
                        parcellation_resolution = detected_res
            except Exception as e:
                logger.debug(f"Could not detect space from header for {nifti_path}: {e}")

        # Skip if we couldn't determine space/resolution
        if parcellation_space is None or parcellation_resolution is None:
            continue

        # Register the parcellation
        try:
            register_parcellation_from_files(
                name=base_name,
                parcellation_path=nifti_path,
                labels_path=labels_path,
                space=parcellation_space,
                resolution=parcellation_resolution,
                description=f"Parcellation from {directory.name}",
            )
            registered_names.append(base_name)
        except Exception as e:
            # Skip parcellations that fail to register
            logger.warning(f"Failed to register parcellation {base_name}: {e}")
            continue

    return registered_names

register_structural_connectome(name, space, tractogram_path, template_path=None, description='')

Register a structural connectome for sLNM analysis.

TDI is computed on-the-fly during analysis. Use cache_tdi=True (default) in StructuralNetworkMapping to cache computed TDIs for reuse, or cache_tdi=False to compute without caching.

Note: Unlike functional connectomes, structural connectomes (tractograms) don't have an inherent voxel resolution - they exist in continuous 3D space. The output resolution is controlled by the output_resolution parameter in StructuralNetworkMapping analysis.

Parameters:

Name Type Description Default
name str

Unique identifier (e.g., "dTOR985")

required
space str

Coordinate space (e.g., "MNI152NLin2009bAsym")

required
tractogram_path str or Path

Path to .tck whole-brain streamlines file

required
template_path str or Path

Path to template image for output grid

None
description str

Human-readable description

''

Raises:

Type Description
FileNotFoundError

If tractogram file doesn't exist

ValueError

If file validation fails

Examples:

>>> from lacuna.assets.connectomes import register_structural_connectome
>>>
>>> # Register tractogram (TDI computed on-the-fly during analysis)
>>> register_structural_connectome(
...     name="dTOR985",
...     space="MNI152NLin2009cAsym",
...     tractogram_path="/data/dtor/dTOR985_tractogram.tck",
...     description="dTOR tractogram - TDI computed on-demand"
... )
Source code in src/lacuna/assets/connectomes/structural.py
def register_structural_connectome(
    name: str,
    space: str,
    tractogram_path: str | Path,
    template_path: str | Path | None = None,
    description: str = "",
) -> None:
    """Register a structural connectome for sLNM analysis.

    TDI is computed on-the-fly during analysis. Use cache_tdi=True (default) in
    StructuralNetworkMapping to cache computed TDIs for reuse, or cache_tdi=False
    to compute without caching.

    Note: Unlike functional connectomes, structural connectomes (tractograms) don't
    have an inherent voxel resolution - they exist in continuous 3D space. The output
    resolution is controlled by the `output_resolution` parameter in
    StructuralNetworkMapping analysis.

    Parameters
    ----------
    name : str
        Unique identifier (e.g., "dTOR985")
    space : str
        Coordinate space (e.g., "MNI152NLin2009bAsym")
    tractogram_path : str or Path
        Path to .tck whole-brain streamlines file
    template_path : str or Path, optional
        Path to template image for output grid
    description : str, optional
        Human-readable description

    Raises
    ------
    FileNotFoundError
        If tractogram file doesn't exist
    ValueError
        If file validation fails

    Examples
    --------
    >>> from lacuna.assets.connectomes import register_structural_connectome
    >>>
    >>> # Register tractogram (TDI computed on-the-fly during analysis)
    >>> register_structural_connectome(
    ...     name="dTOR985",
    ...     space="MNI152NLin2009cAsym",
    ...     tractogram_path="/data/dtor/dTOR985_tractogram.tck",
    ...     description="dTOR tractogram - TDI computed on-demand"
    ... )
    """
    # Convert to paths
    tractogram_path = Path(tractogram_path).resolve()
    template_path = Path(template_path).resolve() if template_path else None

    # Validate tractogram exists
    if not tractogram_path.exists():
        raise FileNotFoundError(f"Tractogram file not found: {tractogram_path}")

    # Validate template exists if provided
    if template_path and not template_path.exists():
        raise FileNotFoundError(f"Template file not found: {template_path}")

    # Validate file extensions
    if tractogram_path.suffix != ".tck":
        raise ValueError(f"Expected .tck file, got: {tractogram_path.suffix}")

    # Create metadata
    # Note: resolution=0.0 as placeholder since tractograms don't have inherent voxel
    # resolution. Output resolution is controlled by StructuralNetworkMapping.output_resolution
    metadata = StructuralConnectomeMetadata(
        name=name,
        space=space,
        resolution=0.0,  # Tractograms don't have inherent voxel resolution
        description=description or f"Structural connectome: {name}",
        tractogram_path=tractogram_path,
        template_path=template_path,
    )

    # Register
    _structural_connectome_registry.register(metadata)

unregister_functional_connectome(name)

Unregister a functional connectome.

Parameters:

Name Type Description Default
name str

Connectome name

required

Raises:

Type Description
KeyError

If connectome not registered

Source code in src/lacuna/assets/connectomes/functional.py
def unregister_functional_connectome(name: str) -> None:
    """Unregister a functional connectome.

    Parameters
    ----------
    name : str
        Connectome name

    Raises
    ------
    KeyError
        If connectome not registered
    """
    _functional_connectome_registry.unregister(name)

unregister_parcellation(name)

Remove an parcellation from the registry.

Parameters:

Name Type Description Default
name str

Name of the parcellation to unregister

required

Raises:

Type Description
KeyError

If parcellation is not in the registry

Examples:

>>> from lacuna.parcellation.registry import unregister_parcellation
>>> unregister_parcellation("MyCustomParcellation")
Source code in src/lacuna/assets/parcellations/registry.py
def unregister_parcellation(name: str) -> None:
    """Remove an parcellation from the registry.

    Parameters
    ----------
    name : str
        Name of the parcellation to unregister

    Raises
    ------
    KeyError
        If parcellation is not in the registry

    Examples
    --------
    >>> from lacuna.parcellation.registry import unregister_parcellation
    >>> unregister_parcellation("MyCustomParcellation")
    """
    if name not in PARCELLATION_REGISTRY:
        raise KeyError(f"Parcellation '{name}' not found in registry")

    del PARCELLATION_REGISTRY[name]

unregister_structural_connectome(name)

Unregister a structural connectome.

Parameters:

Name Type Description Default
name str

Connectome name

required

Raises:

Type Description
KeyError

If connectome not registered

Examples:

>>> from lacuna.assets.connectomes import unregister_structural_connectome
>>> unregister_structural_connectome("dTOR985")
Source code in src/lacuna/assets/connectomes/structural.py
def unregister_structural_connectome(name: str) -> None:
    """Unregister a structural connectome.

    Parameters
    ----------
    name : str
        Connectome name

    Raises
    ------
    KeyError
        If connectome not registered

    Examples
    --------
    >>> from lacuna.assets.connectomes import unregister_structural_connectome
    >>> unregister_structural_connectome("dTOR985")
    """
    _structural_connectome_registry.unregister(name)