Skip to content

Commit e1c90b6

Browse files
NeoMatlabIO: round-trip quantity, array and nested annotations (#1894)
* NeoMatlabIO: round-trip quantity, array and nested annotations _get_matlab_value flattens an annotation dict for MATLAB by splitting a quantity into a magnitude plus a companion <key>_units field, mirroring a nested mapping as a nested struct and standing None up as a sentinel string. The read side undid none of that. It copied every field of the struct straight into the annotations dict, so units came back as a separate key, nested mappings came back as scipy mat_struct objects, and the comparison against the sentinel was done with `value == PY_NONE`, which on an array value yields an array and raises "The truth value of an array with more than one element is ambiguous". That made any file holding an array-valued annotation unreadable. Decoding now mirrors the encoding: a <key>_units field is folded back into the quantity it belongs to, a nested struct is decoded recursively, and the sentinel is tested only on values that are actually strings. On the write side None inside a nested annotation dict was being dropped, because the guard naming the annotations attribute did not survive the recursion, and annotations are the only mapping-valued attribute Neo has. Fixes #852 * Name the Py_None sentinel and clarify the round-trip docstring
1 parent 7f09f3d commit e1c90b6

3 files changed

Lines changed: 107 additions & 9 deletions

File tree

doc/source/authors.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ and may not be the current affiliation of a contributor.
103103
* Reema Gupta [50]
104104
* Sai Asish Yamani [51]
105105
* Kevin Doran [52]
106+
* Aditya Singh (github)
106107

107108
1. Centre de Recherche en Neuroscience de Lyon, CNRS UMR5292 - INSERM U1028 - Université Claude Bernard Lyon 1
108109
2. Unité de Neuroscience, Information et Complexité, CNRS UPR 3293, Gif-sur-Yvette, France

neo/io/neomatlabio.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -334,11 +334,13 @@ def _get_matlab_value(self, ob, attrname):
334334
new_value[key] = subvalue
335335
if subunits:
336336
new_value[f"{key}_units"] = subunits
337-
elif attrname == "annotations":
337+
else:
338338
# In general we don't send None to MATLAB
339339
# but we make an exception for annotations.
340340
# However, we have to save then retrieve some
341341
# special value as actual `None` is ignored by default.
342+
# Annotations are the only mapping-valued attribute Neo has,
343+
# so this holds at every level of a nested annotation too.
342344
new_value[key] = PY_NONE
343345
value = new_value
344346
return value, units
@@ -377,6 +379,41 @@ def create_struct_from_view(self, ob):
377379
struct["viewed_classname"] = viewed_obj.__class__.__name__
378380
return struct
379381

382+
def create_dict_from_struct(self, struct):
383+
"""
384+
Rebuild a Python dict, typically the annotations, from the MATLAB struct that
385+
:meth:`_get_matlab_value` wrote it to.
386+
387+
That method flattens a quantity into a plain magnitude plus a companion
388+
``<key>_units`` field, mirrors nested mappings as nested structs, and stores
389+
`None` as the sentinel string ``"Py_None"`` because MATLAB has no equivalent.
390+
391+
This reverses that flattening, transforming the string ``"Py_None"`` into
392+
Python `None`, the magnitude/units pair into a Quantity scalar or array, and
393+
the nested struct back into a dict, so that a value survives a write/read
394+
round trip unchanged.
395+
"""
396+
new_dict = {}
397+
for field_name in struct._fieldnames:
398+
if field_name.endswith("_units") and field_name[: -len("_units")] in struct._fieldnames:
399+
# this field carries the units of another one and is consumed along with it
400+
continue
401+
402+
value = getattr(struct, field_name)
403+
if hasattr(value, "_fieldnames"):
404+
# a nested mapping, which scipy returns as a struct of its own
405+
value = self.create_dict_from_struct(value)
406+
elif isinstance(value, str) and value == PY_NONE:
407+
# `isinstance` first: an array compared to the sentinel gives an array,
408+
# which is not usable as a condition
409+
value = None
410+
else:
411+
units = getattr(struct, f"{field_name}_units", None)
412+
if units is not None:
413+
value = pq.Quantity(value, str(units))
414+
new_dict[field_name] = value
415+
return new_dict
416+
380417
def create_ob_from_struct(self, struct, classname):
381418
cl = class_by_name[classname]
382419

@@ -508,13 +545,7 @@ def create_ob_from_struct(self, struct, classname):
508545
else:
509546
item = pq.Quantity(item, units)
510547
elif attrtype == dict:
511-
new_item = {}
512-
for fn in item._fieldnames:
513-
value = getattr(item, fn)
514-
if value == PY_NONE:
515-
value = None
516-
new_item[fn] = value
517-
item = new_item
548+
item = self.create_dict_from_struct(item)
518549
else:
519550
item = attrtype(item)
520551

neo/test/iotest/test_neomatlabio.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import os
66
import unittest
7+
import pytest
78
from numpy.testing import assert_array_equal
89
import quantities as pq
910

@@ -33,7 +34,7 @@ def test_write_read_single_spike(self):
3334
block1 = Block(name="test_neomatlabio")
3435
seg = Segment("segment1")
3536
spiketrain1 = SpikeTrain([1] * pq.s, t_stop=10 * pq.s, sampling_rate=1 * pq.Hz)
36-
spiketrain1.annotate(yep="yop", yip=None)
37+
spiketrain1.annotate(yep="yop", yip=None, yop=[3, 4, 5] * pq.ms)
3738
sig1 = AnalogSignal([4, 5, 6] * pq.A, sampling_period=1 * pq.ms)
3839
irrsig1 = IrregularlySampledSignal([0, 1, 2] * pq.ms, [4, 5, 6] * pq.A)
3940
img_sequence_array = [[[column for column in range(2)] for _ in range(2)] for _ in range(2)]
@@ -76,6 +77,11 @@ def test_write_read_single_spike(self):
7677
spiketrain2 = block2.segments[0].spiketrains[0]
7778
assert spiketrain2.annotations["yep"] == "yop"
7879
assert spiketrain2.annotations["yip"] is None
80+
# a quantity annotation keeps its units and does not leak its companion field,
81+
# see https://github.com/NeuralEnsemble/python-neo/issues/852
82+
assert "yop_units" not in spiketrain2.annotations
83+
assert spiketrain2.annotations["yop"].dimensionality == pq.ms.dimensionality
84+
assert_array_equal(spiketrain2.annotations["yop"].magnitude, [3, 4, 5])
7985

8086
# test group retrieval
8187
group2 = block2.groups[0]
@@ -104,5 +110,65 @@ def test_write_read_random_blocks(self):
104110
assert os.stat(filename_orig).st_size == os.stat(filename_roundtripped).st_size
105111

106112

113+
@unittest.skipUnless(HAVE_SCIPY, "requires scipy")
114+
@pytest.mark.parametrize(
115+
"annotation",
116+
[
117+
"a string",
118+
42,
119+
None,
120+
[3, 4, 5],
121+
[3, 4, 5] * pq.ms,
122+
3 * pq.ms,
123+
{"a": 1, "b": None},
124+
{"a": [1, 2] * pq.mV, "b": {"deep": 5 * pq.Hz}},
125+
],
126+
ids=["str", "int", "none", "array", "quantity_array", "quantity_scalar", "dict", "nested_dict"],
127+
)
128+
def test_write_read_annotation_roundtrip(tmp_path, annotation):
129+
"""An annotation must survive a write then read unchanged.
130+
131+
A test case from Issue #852 (https://github.com/NeuralEnsemble/python-neo/issues/852).
132+
Quantity annotations used to come back as bare magnitudes plus a stray ``<key>_units``
133+
key, nested mappings came back as ``scipy.io`` structs, and any array-valued
134+
annotation raised ``ValueError: The truth value of an array ... is ambiguous`` while
135+
being compared against the sentinel that stands in for `None`.
136+
137+
This runs off a temporary file rather than the downloaded test data, so it also
138+
covers installations without datalad.
139+
"""
140+
block = Block(name="test_annotations")
141+
segment = Segment(name="segment1")
142+
block.segments.append(segment)
143+
spiketrain = SpikeTrain([1, 2, 3] * pq.s, t_stop=10 * pq.s, yop=annotation)
144+
segment.spiketrains.append(spiketrain)
145+
segment.check_relationships()
146+
147+
filename = tmp_path / "annotations.mat"
148+
NeoMatlabIO(filename=filename).write_block(block)
149+
read_back = NeoMatlabIO(filename=filename).read_block().segments[0].spiketrains[0].annotations
150+
151+
assert list(read_back) == ["yop"], "no companion field should leak into the annotations"
152+
_assert_annotation_equal(read_back["yop"], annotation)
153+
154+
155+
def _assert_annotation_equal(actual, expected):
156+
if expected is None:
157+
assert actual is None
158+
elif isinstance(expected, dict):
159+
assert isinstance(actual, dict)
160+
assert sorted(actual) == sorted(expected)
161+
for key in expected:
162+
_assert_annotation_equal(actual[key], expected[key])
163+
elif isinstance(expected, pq.Quantity):
164+
assert isinstance(actual, pq.Quantity)
165+
assert actual.dimensionality == expected.dimensionality
166+
assert_array_equal(actual.magnitude, expected.magnitude)
167+
elif isinstance(expected, list):
168+
assert_array_equal(actual, expected)
169+
else:
170+
assert actual == expected
171+
172+
107173
if __name__ == "__main__":
108174
unittest.main()

0 commit comments

Comments
 (0)