Skip to content

Commit

Permalink
Add functions to export feature data to CSV and JSON formats (#390)
Browse files Browse the repository at this point in the history
* Add functions to export feature data to CSV and JSON formats

* allow integer values for float features

* update gitignore

* handle numpy types when exporting the features

* implement automatic type conversion for settings

* include example for exporting features in neo example

* update tests

* use warning when converting settings from float to int

* issue warning only when truncation is performed
  • Loading branch information
ilkilic authored May 8, 2024
1 parent 226ea09 commit 2380092
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 24 deletions.
1 change: 1 addition & 0 deletions efel/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
*.pyc
cppcore.so
*.ipynb_checkpoints/
33 changes: 33 additions & 0 deletions efel/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
along with this library; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""
import csv
import json
from pathlib import Path
import neo
import numpy as np
Expand Down Expand Up @@ -207,3 +209,34 @@ def load_neo_file(file_name: str, stim_start=None, stim_end=None, **kwargs) -> l
efel_blocks.append(efel_segments)

return efel_blocks


def save_feature_to_json(feature_values, filename):
"""Save feature values as a JSON file."""
class NumpyEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, np.integer):
return int(o)
elif isinstance(o, np.floating):
return float(o)
elif isinstance(o, np.ndarray):
return o.tolist()
else:
return super().default(o)

with open(filename, 'w') as f:
json.dump(feature_values, f, cls=NumpyEncoder)


def save_feature_to_csv(feature_values, filename):
"""Save feature values as a CSV file."""
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(feature_values.keys())
max_list_length = max(len(value) if isinstance(value, (list, np.ndarray))
else 1 for value in feature_values.values())
for i in range(max_list_length):
row_data = [value[i] if isinstance(value, (list, np.ndarray))
and i < len(value) else ''
for value in feature_values.values()]
writer.writerow(row_data)
29 changes: 25 additions & 4 deletions efel/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,20 +116,41 @@ def set_setting(self,
if hasattr(self, setting_name):
expected_types = {f.name: f.type for f in fields(self)}
expected_type = expected_types.get(setting_name)
if expected_type and not isinstance(new_value, expected_type):
if expected_type is None:
raise TypeError(f"type not found for setting '{setting_name}'")
try:
converted_value = expected_type(new_value)
if not isinstance(new_value, expected_type):
log_message = (
"Value '%s' of type '%s' for setting '%s' "
"has been converted to '%s' of type '%s'."
) % (
new_value,
type(new_value).__name__,
setting_name,
converted_value,
expected_type.__name__
)
if expected_type is int and isinstance(new_value, float) and \
new_value != converted_value:
logger.warning(log_message)
else:
logger.debug(log_message)
except (ValueError, TypeError):
raise ValueError(f"Invalid value for setting '{setting_name}'. "
f"Expected type: {expected_type.__name__}.")
else:
logger.debug("Setting '%s' not found in settings. "
"Adding it as a new setting.", setting_name)
converted_value = new_value

if setting_name == "dependencyfile_path":
path = Path(str(new_value))
path = Path(str(converted_value))
if not path.exists():
raise FileNotFoundError(f"Path to dependency file {new_value}"
raise FileNotFoundError(f"Path to dependency file {converted_value}"
"doesn't exist")

setattr(self, setting_name, new_value)
setattr(self, setting_name, converted_value)

def reset_to_default(self):
"""Reset settings to their default values"""
Expand Down
44 changes: 27 additions & 17 deletions examples/neo/load_nwb.ipynb

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions tests/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,61 @@ def test_load_neo_file_nwb():
for trace in traces:
assert trace['stim_start'] == [250]
assert trace['stim_end'] == [600]


@pytest.fixture(params=['tmp.json', 'tmp.csv'])
def filename(request):
yield request.param
if os.path.exists(request.param):
os.remove(request.param)


def load_data(filename):
import csv
import json
if filename.endswith('.json'):
with open(filename, 'r') as f:
return json.load(f)
elif filename.endswith('.csv'):
loaded_data = {}
with open(filename, 'r') as f:
reader = csv.reader(f)
header = next(reader)
for col_name in header:
loaded_data[col_name] = []

for row in reader:
for col_name, value in zip(header, row):
if value != '':
loaded_data[col_name].append(float(value))

for loaded_key in loaded_data.keys():
if not loaded_data[loaded_key]:
loaded_data[loaded_key] = None
return loaded_data


@pytest.mark.parametrize("index", [2, 3, 5])
def test_save_feature(filename, index):
"""Test saving of the features to file."""
import efel
blocks = efel.io.load_neo_file(nwb1_filename, 250, 600)
trace = blocks[0][0][index]
features = ['peak_time', 'AP_height', 'peak_indices', 'spikes_per_burst']
feature_values = efel.get_feature_values([trace], features)[0]

if filename.endswith('.json'):
efel.io.save_feature_to_json(feature_values, filename)
elif filename.endswith('.csv'):
efel.io.save_feature_to_csv(feature_values, filename)

assert os.path.exists(filename)
loaded_data = load_data(filename)
for key in feature_values.keys():
if feature_values[key] is not None and loaded_data[key]:
assert np.allclose(feature_values[key],
loaded_data[key],
rtol=1e-05,
atol=1e-08)
else:
assert feature_values[key] == loaded_data[key]
61 changes: 58 additions & 3 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

from efel.settings import Settings
from efel.api import set_setting, get_settings
import logging


def test_set_setting():
Expand All @@ -44,12 +45,66 @@ def test_set_setting():
assert settings.Threshold == -30.0


@pytest.mark.parametrize("setting_name, new_value, converted_value, expected_type", [
("Threshold", "-30.0", -30.0, float),
("strict_stiminterval", 0, False, bool),
("initburst_freq_threshold", -50.9, -50, int),
("initburst_sahp_start", 5.5, 5, int)
])
def test_set_setting_conversion(caplog,
setting_name,
new_value,
converted_value,
expected_type):
"""Test that the set_setting method correctly updates a setting
and logs a debug warning when converting types."""
settings = Settings()

if setting_name == "initburst_freq_threshold":
logger_level = logging.WARNING
else:
logger_level = logging.DEBUG

with caplog.at_level(logger_level):
settings.set_setting(setting_name, new_value)

expected_log_message = (
"Value '%s' of type '%s' for setting '%s' "
"has been converted to '%s' of type '%s'." % (
new_value,
type(new_value).__name__,
setting_name,
converted_value,
expected_type.__name__
)
)
assert any(record.message == expected_log_message for record in caplog.records)


def test_set_setting_new_setting(caplog):
"""Test that the set_setting method correctly adds a new setting
when the setting is not present."""
settings = Settings()
setting_name = "stim_start"
new_value = 100

with caplog.at_level(logging.DEBUG):
settings.set_setting(setting_name, new_value)

assert getattr(settings, setting_name) == new_value
expected_log_message = (
"Setting '%s' not found in settings. "
"Adding it as a new setting." % setting_name
)
assert any(record.message == expected_log_message for record in caplog.records)
assert getattr(settings, setting_name) == new_value


def test_set_setting_invalid_type():
"""Test that the set_setting method raises a ValueError
when given an invalid type."""
"""Test that the set_setting raises a ValueError when given an invalid type."""
settings = Settings()
with pytest.raises(ValueError):
settings.set_setting("Threshold", "-30.0")
settings.set_setting("Threshold", [-30.0])


def test_set_setting_dependencyfile_path_not_found():
Expand Down

0 comments on commit 2380092

Please sign in to comment.