Skip to content

Commit 3a3920d

Browse files
author
Michael Johansen
committed
Add a converter to/from Double2DArray.
1 parent d2a957b commit 3a3920d

File tree

4 files changed

+224
-15
lines changed

4 files changed

+224
-15
lines changed

src/nipanel/_convert.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
IntCollectionConverter,
2222
StrCollectionConverter,
2323
)
24-
from nipanel.converters.protobuf_types import DoubleAnalogWaveformConverter, ScalarConverter
24+
from nipanel.converters.protobuf_types import (
25+
Double2DArrayConverter,
26+
DoubleAnalogWaveformConverter,
27+
ScalarConverter,
28+
)
2529

2630
_logger = logging.getLogger(__name__)
2731

@@ -40,6 +44,7 @@
4044
IntCollectionConverter(),
4145
StrCollectionConverter(),
4246
# Protobuf Types
47+
Double2DArrayConverter(),
4348
DoubleAnalogWaveformConverter(),
4449
ScalarConverter(),
4550
]
@@ -66,25 +71,40 @@ def to_any(python_value: object) -> any_pb2.Any:
6671
def _get_best_matching_type(python_value: object) -> str:
6772
underlying_parents = type(python_value).mro() # This covers enum.IntEnum and similar
6873

69-
container_type = None
70-
value_is_collection = _CONVERTIBLE_COLLECTION_TYPES.intersection(underlying_parents)
71-
if value_is_collection:
74+
container_types = []
75+
value_is_collection = any(_CONVERTIBLE_COLLECTION_TYPES.intersection(underlying_parents))
76+
# Variable to use when traversing down through collection types.
77+
working_python_value = python_value
78+
while value_is_collection:
7279
# Assume Sized -- Generators not supported, callers must use list(), set(), ... as desired
73-
if not isinstance(python_value, Collection):
80+
if not isinstance(working_python_value, Collection):
7481
raise TypeError()
75-
if len(python_value) == 0:
82+
if len(working_python_value) == 0:
7683
underlying_parents = type(None).mro()
84+
value_is_collection = False
7785
else:
7886
# Assume homogenous -- collections of mixed-types not supported
79-
visitor = iter(python_value)
80-
first_value = next(visitor)
81-
underlying_parents = type(first_value).mro()
82-
container_type = Collection
87+
visitor = iter(working_python_value)
88+
89+
# Store off the first element. If it's a container, we'll need it in the next while
90+
# loop iteration.
91+
working_python_value = next(visitor)
92+
underlying_parents = type(working_python_value).mro()
93+
94+
# If this element is a collection, we want to continue traversing. Once we find a
95+
# non-collection, underlying_elements will refer to the candidates for the non-
96+
# collection type.
97+
value_is_collection = any(
98+
_CONVERTIBLE_COLLECTION_TYPES.intersection(underlying_parents)
99+
)
100+
container_types.append(Collection.__name__)
83101

84102
best_matching_type = None
85103
candidates = [parent.__name__ for parent in underlying_parents]
86104
for candidate in candidates:
87-
python_typename = f"{container_type.__name__}.{candidate}" if container_type else candidate
105+
containers_str = ".".join(container_types)
106+
python_typename = f"{containers_str}.{candidate}" if containers_str else candidate
107+
print(python_typename)
88108
if python_typename not in _SUPPORTED_PYTHON_TYPES:
89109
continue
90110
best_matching_type = python_typename
@@ -93,7 +113,8 @@ def _get_best_matching_type(python_value: object) -> str:
93113
if not best_matching_type:
94114
payload_type = underlying_parents[0]
95115
raise TypeError(
96-
f"Unsupported type: ({container_type}, {payload_type}) with parents {underlying_parents}. Supported types are: {_SUPPORTED_PYTHON_TYPES}"
116+
f"Unsupported type: ({container_types}, {payload_type}) with parents "
117+
f"{underlying_parents}. Supported types are: {_SUPPORTED_PYTHON_TYPES}"
97118
)
98119
_logger.debug(f"Best matching type for '{repr(python_value)}' resolved to {best_matching_type}")
99120
return best_matching_type

src/nipanel/converters/protobuf_types.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"""Classes to convert between measurement specific protobuf types and containers."""
22

3-
import collections.abc
43
import datetime as dt
4+
from collections.abc import Collection, Mapping
55
from typing import Type, Union
66

77
import hightime as ht
88
import nitypes.bintime as bt
99
import numpy as np
1010
from ni.protobuf.types import scalar_pb2
11+
from ni_measurement_plugin_sdk_service._internal.stubs.ni.protobuf.types.array_pb2 import (
12+
Double2DArray,
13+
)
1114
from ni_measurement_plugin_sdk_service._internal.stubs.ni.protobuf.types.precision_timestamp_pb2 import (
1215
PrecisionTimestamp,
1316
)
@@ -37,6 +40,51 @@
3740
}
3841

3942

43+
class Double2DArrayConverter(Converter[Collection[Collection[float]], Double2DArray]):
44+
"""A converter between Collection[Collection[float]] and Double2DArray."""
45+
46+
@property
47+
def python_typename(self) -> str:
48+
"""The Python type that this converter handles."""
49+
return f"{Collection.__name__}.{Collection.__name__}.{float.__name__}"
50+
51+
@property
52+
def protobuf_message(self) -> Type[Double2DArray]:
53+
"""The type-specific protobuf message for the Python type."""
54+
return Double2DArray
55+
56+
def to_protobuf_message(self, python_value: Collection[Collection[float]]) -> Double2DArray:
57+
"""Convert the Python Collection[Collection[float]] to a protobuf Double2DArray."""
58+
rows = len(python_value)
59+
if rows:
60+
visitor = iter(python_value)
61+
first_subcollection = next(visitor)
62+
columns = len(first_subcollection) if rows else 0
63+
else:
64+
columns = 0
65+
if not all(len(sublist) == columns for sublist in python_value):
66+
raise ValueError("All subcollections must have the same length.")
67+
68+
# Create a flat list in row major order.
69+
flat_list = [item for sublist in python_value for item in sublist]
70+
return Double2DArray(rows=rows, columns=columns, data=flat_list)
71+
72+
def to_python_value(self, protobuf_message: Double2DArray) -> Collection[Collection[float]]:
73+
"""Convert the protobuf Double2DArray to a Python Collection[Collection[float]]."""
74+
if not protobuf_message.data:
75+
return []
76+
if len(protobuf_message.data) % protobuf_message.columns != 0:
77+
raise ValueError("The length of the data list must be divisible by num columns.")
78+
79+
# Convert from a flat list in row major order into a list of lists.
80+
list_of_lists = []
81+
for i in range(0, len(protobuf_message.data), protobuf_message.columns):
82+
row = protobuf_message.data[i : i + protobuf_message.columns]
83+
list_of_lists.append(row)
84+
85+
return list_of_lists
86+
87+
4088
class DoubleAnalogWaveformConverter(Converter[AnalogWaveform[np.float64], DoubleAnalogWaveform]):
4189
"""A converter for AnalogWaveform types with scaled data (double)."""
4290

@@ -79,7 +127,7 @@ def to_protobuf_message(self, python_value: AnalogWaveform[np.float64]) -> Doubl
79127
def _extended_properties_to_attributes(
80128
self,
81129
extended_properties: ExtendedPropertyDictionary,
82-
) -> collections.abc.Mapping[str, WaveformAttributeValue]:
130+
) -> Mapping[str, WaveformAttributeValue]:
83131
return {key: self._value_to_attribute(value) for key, value in extended_properties.items()}
84132

85133
def _value_to_attribute(self, value: ExtendedPropertyValue) -> WaveformAttributeValue:

tests/unit/test_convert.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
from typing import Any, Union
1+
from typing import Any, Collection, Union
22

33
import numpy as np
44
import pytest
55
from google.protobuf import any_pb2, wrappers_pb2
66
from google.protobuf.message import Message
77
from ni.protobuf.types.scalar_pb2 import ScalarData
88
from ni.pythonpanel.v1 import python_panel_types_pb2
9+
from ni_measurement_plugin_sdk_service._internal.stubs.ni.protobuf.types.array_pb2 import (
10+
Double2DArray,
11+
)
912
from ni_measurement_plugin_sdk_service._internal.stubs.ni.protobuf.types.waveform_pb2 import (
1013
DoubleAnalogWaveform,
1114
)
@@ -14,6 +17,7 @@
1417
from typing_extensions import TypeAlias
1518

1619
import nipanel._convert
20+
import tests.types
1721

1822

1923
_AnyWrappersPb2: TypeAlias = Union[
@@ -44,6 +48,11 @@
4448
(456.2, "float"),
4549
(123, "int"),
4650
("mystr", "str"),
51+
(tests.types.MyIntFlags.VALUE1, "int"),
52+
(tests.types.MyIntEnum.VALUE10, "int"),
53+
(tests.types.MixinIntEnum.VALUE11, "int"),
54+
(tests.types.MyStrEnum.VALUE1, "str"),
55+
(tests.types.MixinStrEnum.VALUE11, "str"),
4756
([False, False], "Collection.bool"),
4857
([b"mystr", b"mystr"], "Collection.bytes"),
4958
([456.2, 1.0], "Collection.float"),
@@ -69,6 +78,14 @@
6978
(frozenset([456.2, 1.0]), "Collection.float"),
7079
(frozenset([123, 456]), "Collection.int"),
7180
(frozenset(["mystr", "mystr2"]), "Collection.str"),
81+
([[1.0, 2.0], [1.0, 2.0]], "Collection.Collection.float"),
82+
([(1.0, 2.0), (3.0, 4.0)], "Collection.Collection.float"),
83+
([set([1.0, 2.0]), set([3.0, 4.0])], "Collection.Collection.float"),
84+
([frozenset([1.0, 2.0]), frozenset([3.0, 4.0])], "Collection.Collection.float"),
85+
(([1.0, 2.0], [3.0, 4.0]), "Collection.Collection.float"),
86+
(((1.0, 2.0), (3.0, 4.0)), "Collection.Collection.float"),
87+
((set([1.0, 2.0]), set([3.0, 4.0])), "Collection.Collection.float"),
88+
((frozenset([1.0, 2.0]), frozenset([3.0, 4.0])), "Collection.Collection.float"),
7289
],
7390
)
7491
def test___various_python_objects___get_best_matching_type___returns_correct_type_string(
@@ -194,6 +211,39 @@ def test___python_analog_waveform___to_any___valid_double_analog_waveform() -> N
194211
assert list(unpack_dest.y_data) == [0.0, 0.0, 0.0]
195212

196213

214+
@pytest.mark.parametrize(
215+
"python_value",
216+
[
217+
# lists of collections
218+
([[1.0, 2.0], [3.0, 4.0]]),
219+
([(1.0, 2.0), (3.0, 4.0)]),
220+
([set([1.0, 2.0]), set([3.0, 4.0])]),
221+
([frozenset([1.0, 2.0]), frozenset([3.0, 4.0])]),
222+
# tuples of collections
223+
(([1.0, 2.0], [3.0, 4.0])),
224+
(((1.0, 2.0), (3.0, 4.0))),
225+
((set([1.0, 2.0]), set([3.0, 4.0]))),
226+
((frozenset([1.0, 2.0]), frozenset([3.0, 4.0]))),
227+
# sets and frozensets of collections don't preserve order,
228+
# so they need to be tested separately.
229+
],
230+
)
231+
def test___python_2dcollection_of_float___to_any___valid_double2darray(
232+
python_value: Collection[Collection[float]],
233+
) -> None:
234+
expected_data = [1.0, 2.0, 3.0, 4.0]
235+
expected_rows = 2
236+
expected_columns = 2
237+
result = nipanel._convert.to_any(python_value)
238+
unpack_dest = Double2DArray()
239+
_assert_any_and_unpack(result, unpack_dest)
240+
241+
assert isinstance(unpack_dest, Double2DArray)
242+
assert unpack_dest.rows == expected_rows
243+
assert unpack_dest.columns == expected_columns
244+
assert unpack_dest.data == expected_data
245+
246+
197247
# ========================================================
198248
# Protobuf Types: Protobuf to Python
199249
# ========================================================
@@ -219,6 +269,17 @@ def test___double_analog_waveform___from_any___valid_python_analog_waveform() ->
219269
assert result.dtype == np.float64
220270

221271

272+
def test___double2darray___from_any___valid_python_2dcollection() -> None:
273+
pb_value = Double2DArray(data=[1.0, 2.0, 3.0, 4.0], rows=2, columns=2)
274+
packed_any = _pack_into_any(pb_value)
275+
276+
result = nipanel._convert.from_any(packed_any)
277+
278+
expected_value = [[1.0, 2.0], [3.0, 4.0]]
279+
assert isinstance(result, type(expected_value))
280+
assert result == expected_value
281+
282+
222283
# ========================================================
223284
# Pack/Unpack Helpers
224285
# ========================================================

tests/unit/test_protobuf_type_conversion.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import numpy
44
import pytest
55
from ni.protobuf.types.scalar_pb2 import ScalarData
6+
from ni_measurement_plugin_sdk_service._internal.stubs.ni.protobuf.types.array_pb2 import (
7+
Double2DArray,
8+
)
69
from ni_measurement_plugin_sdk_service._internal.stubs.ni.protobuf.types.waveform_pb2 import (
710
DoubleAnalogWaveform,
811
WaveformAttributeValue,
@@ -12,12 +15,88 @@
1215
from nitypes.waveform import AnalogWaveform, NoneScaleMode, SampleIntervalMode, Timing
1316

1417
from nipanel.converters.protobuf_types import (
18+
Double2DArrayConverter,
1519
DoubleAnalogWaveformConverter,
1620
PrecisionTimestampConverter,
1721
ScalarConverter,
1822
)
1923

2024

25+
# ========================================================
26+
# list[list[float]] to Double2DArray
27+
# Other collection types are tested in test_convert.py
28+
# ========================================================
29+
@pytest.mark.parametrize(
30+
"list_of_lists, expected_data, expected_rows, expected_columns",
31+
[
32+
([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3, 2),
33+
([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 2, 3),
34+
],
35+
)
36+
def test___list_of_lists___convert___valid_double2darray(
37+
list_of_lists: list[list[float]],
38+
expected_data: list[float],
39+
expected_rows: int,
40+
expected_columns: int,
41+
) -> None:
42+
converter = Double2DArrayConverter()
43+
result = converter.to_protobuf_message(list_of_lists)
44+
45+
assert result.data == expected_data
46+
assert result.rows == expected_rows
47+
assert result.columns == expected_columns
48+
49+
50+
def test___list_of_lists_inconsistent_column_length___convert___throws_value_error() -> None:
51+
converter = Double2DArrayConverter()
52+
53+
with pytest.raises(ValueError):
54+
_ = converter.to_protobuf_message([[1.0, 2.0], [3.0, 4.0, 5.0]])
55+
56+
57+
# ========================================================
58+
# Double2DArray to list[list[float]]
59+
# Other collection types are tested in test_convert.py
60+
# ========================================================
61+
@pytest.mark.parametrize(
62+
"double2darray, expected_data",
63+
[
64+
(
65+
Double2DArray(rows=3, columns=2, data=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]),
66+
[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]],
67+
),
68+
(
69+
Double2DArray(rows=2, columns=3, data=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]),
70+
[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
71+
),
72+
],
73+
)
74+
def test___double2darray___convert___valid_list_of_lists(
75+
double2darray: Double2DArray, expected_data: list[list[float]]
76+
) -> None:
77+
converter = Double2DArrayConverter()
78+
list_of_lists = converter.to_python_value(double2darray)
79+
80+
assert list_of_lists == expected_data
81+
82+
83+
def test___double2darray_invalid_num_columns___convert___throws_value_error() -> None:
84+
double2darray = Double2DArray(rows=1, columns=2, data=[1.0, 2.0, 3.0])
85+
converter = Double2DArrayConverter()
86+
87+
with pytest.raises(ValueError):
88+
_ = converter.to_python_value(double2darray)
89+
90+
91+
def test___double2darray_empty_data___convert___returns_empty_list() -> None:
92+
double2darray = Double2DArray(rows=0, columns=0, data=[])
93+
converter = Double2DArrayConverter()
94+
95+
list_of_lists = converter.to_python_value(double2darray)
96+
97+
assert not list_of_lists
98+
99+
21100
# ========================================================
22101
# AnalogWaveform to DoubleAnalogWaveform
23102
# ========================================================

0 commit comments

Comments
 (0)