Source code for bluecellulab.cell.template

# Copyright 2023-2024 Blue Brain Project / EPFL

# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at

#     http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Module for handling NEURON hoc templates."""

from __future__ import annotations

import hashlib
import os
from pathlib import Path
import re
import string
from typing import NamedTuple, Optional
import uuid

import neuron

from bluecellulab.cell.morphio_wrapper import is_h5_container_path, resolve_case_insensitive_path, split_morphology_path
from bluecellulab.circuit import EmodelProperties
from bluecellulab.exceptions import BluecellulabError
from bluecellulab.type_aliases import HocObjectType

import logging

logger = logging.getLogger(__name__)


[docs] def public_hoc_cell(cell: HocObjectType) -> HocObjectType: """Retrieve the hoc cell to access public hoc functions/attributes.""" if hasattr(cell, "getCell"): return cell.getCell() elif hasattr(cell, "CellRef"): return cell.CellRef else: raise BluecellulabError("""Public cell properties cannot be accessed from the hoc model. Either getCell() or CellRef needs to be provided""")
[docs] class TemplateParams(NamedTuple): template_filepath: str | Path morph_filepath: str | Path template_format: str emodel_properties: Optional[EmodelProperties]
[docs] class NeuronTemplate: """Loads and manages a NEURON HOC cell template together with its morphology. Supports four morphology path formats: - ``.asc`` / ``.swc``: individual morphology files. - ``.h5`` single file: an individual HDF5 morphology file. - ``.h5`` container: a path of the form ``container.h5/cell_name`` where ``container.h5`` is an HDF5 morphologies container and ``cell_name`` is the key of the morphology inside it (with or without a trailing ``.h5`` suffix). - v5-style directory: a directory path passed directly to legacy HOC templates that locate the morphology file internally. """ def __init__( self, template_filepath: str | Path, morph_filepath: str | Path, template_format: str, emodel_properties: Optional[EmodelProperties] ) -> None: """Load the HOC template and validate the morphology path. The morphology path is validated with `_is_valid_morphology_path` before the template is loaded into NEURON. Four formats are accepted: - ``/dir/cell.asc`` or ``/dir/cell.swc`` — plain morphology file. - ``/dir/cell.h5`` — single HDF5 morphology file. - ``/dir/merged.h5/cell_name`` — cell inside an HDF5 container (``cell_name`` may optionally carry a ``.h5`` suffix as produced by `bluecellulab.circuit.circuit_access.SonataCircuitAccess.morph_filepath`). - ``/dir/morphologies/`` — directory, used by v5-style templates that locate the morphology file themselves. Args: template_filepath: Path to the ``.hoc`` template file. morph_filepath: Path to the morphology. For H5 containers this is ``<container.h5>/<cell_name>``; for v5 templates this is the directory that contains the morphology file. template_format: One of ``"v5"``, ``"v6"``, or ``"bluepyopt"``. Controls how arguments are passed to the HOC constructor. emodel_properties: Optional e-model parameters (threshold current, holding current, AIS scaler). Required for ``v6`` templates whose HOC defines ``_NeededAttributes``. Raises: FileNotFoundError: If ``template_filepath`` does not exist on disk or if ``morph_filepath`` cannot be resolved to a valid file, directory, or HDF5 container entry. """ if isinstance(template_filepath, Path): template_filepath = str(template_filepath) if isinstance(morph_filepath, Path): morph_filepath = str(morph_filepath) if not os.path.exists(template_filepath): raise FileNotFoundError(f"Couldn't find template file: {template_filepath}") if not is_h5_container_path(morph_filepath): morph_filepath = resolve_case_insensitive_path(morph_filepath) # Check morphology path - handle H5 container paths if not self._is_valid_morphology_path(morph_filepath): raise FileNotFoundError(f"Couldn't find morphology file: {morph_filepath}") self.template_name = self.load(template_filepath) self.morph_filepath = morph_filepath self.template_format = template_format self.emodel_properties = emodel_properties
[docs] def get_cell(self, gid: Optional[int]) -> HocObjectType: """Returns the hoc object matching the template format.""" morph_filepath = str(self.morph_filepath) # Use split_morphology_path to locate the collection directory. # For H5 containers (morph_dir is an .h5 file), morph_fname must end # with ".h5" so the HOC extension check routes to morphio_read. # We cannot use morph_name + ".h5" here because split_morphology_path # uses os.path.splitext, which splits on the LAST dot and therefore # mangles cell names that contain dots (e.g. the Scale/Clone suffix). # Instead, use os.path.relpath to recover the full bare cell name. morph_dir, morph_name, morph_ext = split_morphology_path(morph_filepath) if os.path.isfile(morph_dir) and morph_dir.endswith('.h5'): bare_name = os.path.relpath(morph_filepath, morph_dir) if bare_name.endswith('.h5'): bare_name = bare_name[:-3] morph_fname = bare_name + '.h5' else: morph_fname = morph_name + morph_ext if self.template_format == "v6": attr_names = getattr( neuron.h, self.template_name.split('_bluecellulab')[0] + "_NeededAttributes", None ) if attr_names is not None: if self.emodel_properties is None: raise BluecellulabError( "EmodelProperties must be provided for template " "format v6 that specifies _NeededAttributes" ) cell = getattr(neuron.h, self.template_name)( gid, morph_dir, morph_fname, *[self.emodel_properties.__getattribute__(name) for name in attr_names.split(";")] ) else: cell = getattr(neuron.h, self.template_name)( gid, morph_dir, morph_fname, ) elif self.template_format == "bluepyopt": cell = getattr(neuron.h, self.template_name)(morph_dir, morph_fname) else: cell = getattr(neuron.h, self.template_name)(gid, self.morph_filepath) return cell
[docs] def load(self, template_filename: str) -> str: """Read a cell template. If template name already exists, rename it. Args: template_filename: path string containing template file. Returns: resulting template name """ with open(template_filename) as template_file: template_content = template_file.read() match = re.search(r"begintemplate\s*(\S*)", template_content) template_name = match.group(1) # type:ignore logger.debug("This Neuron version supports renaming templates, enabling...") # add bluecellulab to the template name, so that we don't interfere with # templates load outside of bluecellulab template_name = "%s_bluecellulab" % template_name template_name = get_neuron_compliant_template_name(template_name) unique_id = uuid.uuid4().hex template_name = f"{template_name}_{unique_id}" template_content = re.sub( r"begintemplate\s*(\S*)", "begintemplate %s" % template_name, template_content, ) template_content = re.sub( r"endtemplate\s*(\S*)", "endtemplate %s" % template_name, template_content, ) neuron.h(template_content) return template_name
def _is_valid_morphology_path(self, morph_filepath: str) -> bool: """Return True if *morph_filepath* points to a loadable morphology. Accepts four cases: 1. **Regular file** (``.asc``, ``.swc``, ``.h5``): ``os.path.isfile`` returns True immediately. 2. **Directory** (v5-style templates): ``os.path.isdir`` returns True immediately. 3. **H5 container** (``container.h5/cell_name``): the path does not exist as a file, so the method walks up via ``os.path.dirname`` until it finds an existing ``.h5`` file. It then opens that file with ``h5py`` and checks that *cell_name* is a top-level key. The cell name may carry a trailing ``.h5`` suffix (as appended by ``SonataCircuitAccess``); that suffix is stripped before the lookup because HDF5 keys are stored without extensions. 4. **Non-existent path**: returns False if the walk-up reaches the filesystem root without finding any existing entry. The walk-up strategy mirrors neurodamus ``split_morphology_path`` and correctly handles cell names that contain dots (e.g. ``cell_x1.000_y0.950_-_Clone_0``) without mis-splitting on the last dot as ``os.path.splitext`` would. Args: morph_filepath: Morphology path string to validate. Returns: True if the path resolves to a valid morphology, False otherwise. """ # Regular file or directory (v5-style templates receive a directory) — always valid if os.path.isfile(morph_filepath) or os.path.isdir(morph_filepath): return True # Walk up via os.path.dirname (same as neurodamus split_morphology_path) candidate = morph_filepath while not os.path.exists(candidate): parent = os.path.dirname(candidate) if parent == candidate: # Reached filesystem root without finding anything return False candidate = parent # If candidate is an H5 container file, validate the cell name inside if os.path.isfile(candidate) and candidate.endswith('.h5'): # Cell name is the relative path after the container. # Strip trailing .h5 extension if present: circuit code (e.g. # sonata_circuit_access) appends .h5 to match neurodamus # convention, but h5py keys are bare names without extensions. cell_name = os.path.relpath(morph_filepath, candidate) if cell_name.endswith('.h5'): cell_name = cell_name[:-3] try: import h5py with h5py.File(candidate, 'r') as f: return cell_name in f except Exception: return False return False
[docs] def shorten_and_hash_string(label: str, keep_length=40, hash_length=9) -> str: """Converts a string to a shorter string if required. Args: label: A string to be converted. keep_length: Length of the original string to keep. hash_length: Length of the hash to generate, should not be more than 20. Returns: If the length of the original label is shorter than the sum of 'keep_length' and 'hash_length' plus one, the original string is returned. Otherwise, a string with structure <partial>_<hash> is returned, where <partial> is the first part of the original string with length equal to <keep_length> and the last part is a hash of 'hash_length' characters, based on the original string. """ if hash_length > 20: raise ValueError( "Parameter hash_length should not exceed 20, " " received: {}".format(hash_length) ) if len(label) <= keep_length + hash_length + 1: return label hash_string = hashlib.sha1(label.encode("utf-8")).hexdigest() return "{}_{}".format(label[0:keep_length], hash_string[0:hash_length])
[docs] def check_compliance_with_neuron(template_name: str) -> bool: """Verify that a given name is compliant with the rules for a NEURON. A name should be a non-empty alphanumeric string, and start with a letter. Underscores are allowed. The length should not exceed 50 characters. """ max_len = 50 return ( bool(template_name) and template_name[0].isalpha() and template_name.replace("_", "").isalnum() and len(template_name) <= max_len )
[docs] def get_neuron_compliant_template_name(name: str) -> str: """Get template name that is compliant with NEURON based on given name.""" template_name = name if not check_compliance_with_neuron(template_name): template_name = template_name.lstrip(string.digits).replace("-", "_") template_name = shorten_and_hash_string( template_name, keep_length=40, hash_length=9 ) logger.debug("Converted template name %s to %s to make it " "NEURON compliant" % (name, template_name)) return template_name