Repository: BlueBrain/NeuroM

Issue:
Features should return consistent types
The `NeuroM.features.*` functions return inconsistent types. For example, `segment_taper_rates` returns a list of `floats` while `section_taper_rates` returns a list of `numpy.float32`. This can be annoying to process results (e.g. it's harder to dump the results to JSON strings or stuff like that).
I suggest we make all the types consistent, like casting all the results to built-in Python types.
WDYT @eleftherioszisis @mgeplf ? If you agree I can work on this.

Comments:

Patch:
diff --git a/neurom/features/__init__.py b/neurom/features/__init__.py
index 1970d49..bf80af0 100644
--- a/neurom/features/__init__.py
+++ b/neurom/features/__init__.py
@@ -40,7 +40,9 @@ Examples:
 import inspect
 import operator
 from enum import Enum
-from functools import partial, reduce
+from functools import partial, reduce, wraps
+
+import numpy as np
 
 from neurom.core import Morphology, Neurite, Population
 from neurom.core.morphology import iter_neurites
@@ -226,6 +228,8 @@ def _register_feature(namespace: NameSpace, name, func, shape):
 def feature(shape, namespace: NameSpace, name=None):
     """Feature decorator to automatically register the feature in the appropriate namespace.
 
+    This decorator also ensures that the results of the features are casted to built-in types.
+
     Arguments:
         shape(tuple): the expected shape of the feature values
         namespace(string): a namespace, see :class:`NameSpace`
@@ -233,8 +237,26 @@ def feature(shape, namespace: NameSpace, name=None):
     """
 
     def inner(func):
-        _register_feature(namespace, name or func.__name__, func, shape)
-        return func
+        @wraps(func)
+        def scalar_wrapper(*args, **kwargs):
+            res = func(*args, **kwargs)
+            try:
+                return res.tolist()
+            except AttributeError:
+                return res
+
+        @wraps(func)
+        def matrix_wrapper(*args, **kwargs):
+            res = func(*args, **kwargs)
+            return np.array(res).tolist()
+
+        if shape == ():
+            decorated_func = scalar_wrapper
+        else:
+            decorated_func = matrix_wrapper
+
+        _register_feature(namespace, name or func.__name__, decorated_func, shape)
+        return decorated_func
 
     return inner
 


Test patch:
diff --git a/tests/features/test_features.py b/tests/features/test_features.py
new file mode 100644
index 0000000..42f3ec5
--- /dev/null
+++ b/tests/features/test_features.py
@@ -0,0 +1,98 @@
+"""Miscelaneous tests of features."""
+from pathlib import Path
+from itertools import chain
+
+import numpy as np
+import pytest
+from numpy import testing as npt
+
+import neurom as nm
+from neurom import features
+
+
+@pytest.fixture
+def DATA_PATH():
+    return Path(__file__).parent.parent / "data"
+
+
+@pytest.fixture
+def SWC_PATH(DATA_PATH):
+    return DATA_PATH / "swc"
+
+
+@pytest.fixture
+def MORPHOLOGY(SWC_PATH):
+    return nm.load_morphology(SWC_PATH / "test_morph.swc")
+
+
+@pytest.fixture
+def NEURITE(MORPHOLOGY):
+    return MORPHOLOGY.neurites[0]
+
+
+@pytest.fixture
+def SECTION(NEURITE):
+    return NEURITE.sections[0]
+
+
+@pytest.fixture
+def NRN_FILES(DATA_PATH):
+    return [
+        DATA_PATH / "h5/v1" / f for f in ("Neuron.h5", "Neuron_2_branch.h5", "bio_neuron-001.h5")
+    ]
+
+
+@pytest.fixture
+def POP(NRN_FILES):
+    return nm.load_morphologies(NRN_FILES)
+
+
+def _check_nested_type(data):
+    """Check that the given data contains only built-in types.
+
+    The data should either be an int or float, or a list or tuple of ints or floats.
+    """
+    if isinstance(data, (list, tuple)):
+        for i in data:
+            _check_nested_type(i)
+    else:
+        assert isinstance(data, (int, float))
+
+
+@pytest.mark.parametrize(
+    "feature_name",
+    [
+        pytest.param(name, id=f"Test type of {name} neurite feature")
+        for name in features._NEURITE_FEATURES
+    ],
+)
+def test_neurite_feature_types(feature_name, NEURITE):
+    """Test neurite features."""
+    res = features._NEURITE_FEATURES.get(feature_name)(NEURITE)
+    _check_nested_type(res)
+
+
+@pytest.mark.parametrize(
+    "feature_name",
+    [
+        pytest.param(name, id=f"Test type of {name} morphology feature")
+        for name in features._MORPHOLOGY_FEATURES
+    ],
+)
+def test_morphology_feature_types(feature_name, MORPHOLOGY):
+    """Test morphology features."""
+    res = features._MORPHOLOGY_FEATURES.get(feature_name)(MORPHOLOGY)
+    _check_nested_type(res)
+
+
+@pytest.mark.parametrize(
+    "feature_name",
+    [
+        pytest.param(name, id=f"Test type of {name} population feature")
+        for name in features._POPULATION_FEATURES
+    ],
+)
+def test_population_feature_types(feature_name, POP):
+    """Test population features."""
+    res = features._POPULATION_FEATURES.get(feature_name)(POP)
+    _check_nested_type(res)
diff --git a/tests/features/test_neurite.py b/tests/features/test_neurite.py
index fee2ce3..c31cf3b 100644
--- a/tests/features/test_neurite.py
+++ b/tests/features/test_neurite.py
@@ -133,7 +133,7 @@ def test_section_end_distances():
 
 def test_section_partition_pairs():
     part_pairs = [neurite.partition_pairs(s) for s in SIMPLE.neurites]
-    assert part_pairs == [[(1.0, 1.0)], [(1.0, 1.0)]]
+    assert part_pairs == [[[1.0, 1.0]], [[1.0, 1.0]]]
 
 
 def test_section_bif_radial_distances():