diff --git a/src/damaris/data/ADC_Result.py b/src/damaris/data/ADC_Result.py index 067bd59..5f3e178 100644 --- a/src/damaris/data/ADC_Result.py +++ b/src/damaris/data/ADC_Result.py @@ -67,7 +67,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): self.cont_data = False self.sampling_rate = 0 self.index = [] - self.x = [] + self.x = numpy.array([], dtype="float32") self.y = [] # using all arguments for initialization elif (x is not None) and (y is not None) and (index is not None) and (sampl_freq is not None) and (desc is not None) and (job_id is not None) and (job_date is not None): @@ -75,8 +75,8 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): #for ch in y: # if not numpy.issubdtype(ch.dtype, numpy.integer): # raise TypeError("TypeError: ADC_Result y data must be a list with integer type channels") - self.x = x - self.y = y + self.x = numpy.asarray(x) + self.y = [numpy.asarray(ch) for ch in y] self.index = index self.sampling_rate = sampl_freq self.cont_data = True diff --git a/src/damaris/data/Accumulation.py b/src/damaris/data/Accumulation.py index 9e7e943..dcf6f32 100644 --- a/src/damaris/data/Accumulation.py +++ b/src/damaris/data/Accumulation.py @@ -78,7 +78,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): if self.uses_statistics(): if (y_2 is not None): # y_square is always kept as a list of per-channel arrays - self.y_square: _YChannels = y_2 if isinstance(y_2, list) else list(y_2) + self.y_square: _YChannels = [numpy.asarray(ch) for ch in y_2] elif (y_2 is None) : self.y_square = [] else: @@ -93,12 +93,12 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): self.cont_data = False self.index = [] - self.x = [] + self.x = numpy.array([], dtype="float32") self.y = [] elif (x is not None) and (y is not None) and (index is not None) and (sampl_freq is not None) and (n is not None): - self.x = x - self.y = y + self.x = numpy.asarray(x) + self.y = [numpy.asarray(ch) for ch in y] self.sampling_rate = sampl_freq self.n = n @@ -179,15 +179,15 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): # external interface -------------------------------------------------------------------- - def get_yerr(self, channel: int) -> numpy.ndarray | list: + def get_yerr(self, channel: int) -> numpy.ndarray: """ return error (std.dev/sqrt(n)) of mean """ + if not self.contains_data(): + return numpy.array([], dtype="float32") if not self.uses_statistics(): return numpy.zeros((len(self.y[0]),),dtype="float32") - if not self.contains_data(): - return [] self.lock.acquire() if self.n < 2: @@ -203,13 +203,13 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): # sample standard deviation / sqrt(n) return numpy.nan_to_num(numpy.sqrt(variance_over_n)) - def get_ydata(self, channel: int = 0) -> numpy.ndarray | list: + def get_ydata(self, channel: int = 0) -> numpy.ndarray: """ return mean data """ if not self.contains_data(): - return [] + return numpy.array([], dtype="float32") self.lock.acquire() try: tmp_y = self.y[channel] / self.n diff --git a/src/damaris/data/Drawable.py b/src/damaris/data/Drawable.py index 5689425..be45ee5 100644 --- a/src/damaris/data/Drawable.py +++ b/src/damaris/data/Drawable.py @@ -17,8 +17,11 @@ class Drawable: def __init__(self): # Will be set correctly in one of the subclasses - self.x: numpy.ndarray | list = [] - self.y: list = [] + # x is always a numpy.ndarray, y is always a list of numpy.ndarray + # (one array per channel); empty data is represented by zero-length + # arrays of the same types + self.x: numpy.ndarray = numpy.array([], dtype="float64") + self.y: list[numpy.ndarray] = [] self.styles: dict = { } @@ -37,7 +40,7 @@ class Drawable: self.ymax = 0 - def get_xdata(self) -> numpy.ndarray | list: + def get_xdata(self) -> numpy.ndarray: "Returns a reference to the x-Plotdata (array)" return self.x @@ -50,7 +53,7 @@ class Drawable: raise - def get_ydata(self, channel: int = 0) -> numpy.ndarray | list: + def get_ydata(self, channel: int = 0) -> numpy.ndarray: "Returns the y-Plotdata of channel n (array)" try: return self.y[channel] @@ -140,12 +143,16 @@ class Drawable: def get_xmin(self) -> float: "Returns minimun of x" + if self.x.size == 0: + return 0 return self.x.min() def get_xminpos(self) -> float: "Returns smallest positive value of x" mask = self.x > 0 + if not numpy.any(mask): + return 0 return self.x[mask].min() @@ -156,6 +163,8 @@ class Drawable: def get_xmax(self) -> float: "Returns maximum of x" + if self.x.size == 0: + return 0 return self.x.max() @@ -166,24 +175,21 @@ class Drawable: def get_ymin(self) -> float | int: "Returns minimum of y" - if isinstance(self.y, list): - return min([yarr.min() for yarr in self.y]) - else: - return self.y.min() + if len(self.y) == 0: + return 0 + return min([yarr.min() for yarr in self.y]) def get_yminpos(self) -> float | int: "Returns smallest positive value of y" - if isinstance(self.y, list): - ymins = [] - for ys in self.y: - mask = ys > 0 + ymins = [] + for ys in self.y: + mask = ys > 0 + if numpy.any(mask): ymins.append(ys[mask].min()) - ymin = min(ymins) - else: - mask = self.y > 0 - ymin = self.y[mask].min() - return ymin + if len(ymins) == 0: + return 0 + return min(ymins) def set_ymin(self, ymin: float) -> None: @@ -193,10 +199,9 @@ class Drawable: def get_ymax(self) -> float | int: "Returns maximimum of y" - if isinstance(self.y, list): - return max([yarr.max() for yarr in self.y]) - else: - return self.y.max() + if len(self.y) == 0: + return 0 + return max([yarr.max() for yarr in self.y]) def set_ymax(self, ymax: float) -> None: diff --git a/src/damaris/data/Error_Result.py b/src/damaris/data/Error_Result.py index d2b557d..1bc0e3f 100644 --- a/src/damaris/data/Error_Result.py +++ b/src/damaris/data/Error_Result.py @@ -2,6 +2,7 @@ from .Resultable import Resultable from .Drawable import Drawable +import numpy ############################################################################# # # @@ -47,11 +48,11 @@ class Error_Result(Resultable, Drawable): # Nothing to plot def get_ydata(self, channel=0): - return [0.0] + return numpy.array([0.0]) # Nothing to plot def get_xdata(self): - return [0.0] + return numpy.array([0.0]) #overload of operators und built-ins ------------------------------------------------------- diff --git a/src/damaris/data/MeasurementResult.py b/src/damaris/data/MeasurementResult.py index 97cfdcd..8f3853a 100644 --- a/src/damaris/data/MeasurementResult.py +++ b/src/damaris/data/MeasurementResult.py @@ -195,9 +195,7 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict): return [k,v,e] def get_lineplotdata(self) -> list[numpy.ndarray]: - k=self.get_xdata() - v=numpy.array(self.y, dtype="float64") - return [k, v] + return self.get_xydata() def uses_statistics(self) -> bool: """ @@ -222,16 +220,13 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict): dest.write("# x y ysigma n\n") for x in self.get_xdata(): y = self.data[x] - if isinstance(y, (float, int)): - dest.write("%e%s%e%s0%s1\n" % (x, delimiter, y, delimiter, delimiter)) - else: - dest.write("%e%s%e%s%e%s%d\n" % (x, - delimiter, - y.mean(), - delimiter, - y.mean_error(), - delimiter, - y.n)) + dest.write("%e%s%e%s%e%s%d\n" % (x, + delimiter, + y.mean(), + delimiter, + y.mean_error(), + delimiter, + y.n)) def write_to_hdf(self, hdffile: tables.File, where: str, name: str, title: str, complib: str | None = None, complevel: int | None = None) -> None: @@ -260,17 +255,12 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict): row=mr_table.row xdata=self.get_xdata() if xdata.shape[0]!=0: - for x in self.get_xdata(): + for x in xdata: y=self.data[x] row["x"]=x - if type(y) in [float, int, int]: - row["y"]=y - row["y_err"]=0.0 - row["n"]=1 - else: - row["y"]=y.mean() - row["y_err"]=y.mean_error() - row["n"]=y.n + row["y"]=y.mean() + row["y_err"]=y.mean_error() + row["n"]=y.n row.append() finally: diff --git a/tests/test_ADC_Result.py b/tests/test_ADC_Result.py index c6044bb..3bda02b 100644 --- a/tests/test_ADC_Result.py +++ b/tests/test_ADC_Result.py @@ -316,5 +316,31 @@ class TestADCResult(unittest.TestCase): self.assertRaises(ValueError, adc1.__rfloordiv__, adc2) self.assertRaises(ValueError, adc1.__pow__, adc2) + def test_empty_state_types(self): + """Empty ADC_Result must hold an ndarray x and a list-of-ndarray y.""" + adc = ADC_Result() + self.assertIsInstance(adc.x, np.ndarray) + self.assertIsInstance(adc.y, list) + self.assertEqual(adc.x.size, 0) + self.assertEqual(adc.get_xmin(), 0) + self.assertEqual(adc.get_xmax(), 0) + self.assertEqual(adc.get_xminpos(), 0) + self.assertEqual(adc.get_ymin(), 0) + self.assertEqual(adc.get_ymax(), 0) + self.assertEqual(adc.get_yminpos(), 0) + + def test_constructor_coerces_lists(self): + """Plain lists passed to the constructor must become numpy arrays.""" + x = [0.0, 1.0, 2.0] + y = [[10, 20, 30], [15, 25, 35]] + index = [(0, 2)] + adc = ADC_Result(x, y, index, 1000.0, {"key": "value"}, 1, datetime(2023, 1, 1)) + self.assertIsInstance(adc.x, np.ndarray) + np.testing.assert_array_equal(adc.x, [0.0, 1.0, 2.0]) + for ch in adc.y: + self.assertIsInstance(ch, np.ndarray) + np.testing.assert_array_equal(adc.y[0], [10, 20, 30]) + np.testing.assert_array_equal(adc.y[1], [15, 25, 35]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_Accumulation.py b/tests/test_Accumulation.py index 0fd0a75..f7354c1 100644 --- a/tests/test_Accumulation.py +++ b/tests/test_Accumulation.py @@ -523,5 +523,42 @@ class TestAccumulation(unittest.TestCase): self.assertTrue(accu.lock.acquire(timeout=1), "lock leaked after ValueError in write_to_tecmag") accu.lock.release() + def test_empty_state_accessors(self): + """Empty Accumulation accessors must return ndarrays, min/max helpers 0.""" + accu = Accumulation() + self.assertIsInstance(accu.x, np.ndarray) + self.assertEqual(accu.x.size, 0) + self.assertIsInstance(accu.get_ydata(), np.ndarray) + self.assertEqual(accu.get_ydata().size, 0) + self.assertIsInstance(accu.get_yerr(0), np.ndarray) + self.assertEqual(accu.get_yerr(0).size, 0) + self.assertEqual(accu.get_xmin(), 0) + self.assertEqual(accu.get_xmax(), 0) + self.assertEqual(accu.get_ymin(), 0) + self.assertEqual(accu.get_ymax(), 0) + + def test_get_yerr_without_statistics_on_empty(self): + """get_yerr on an empty accumulation without statistics must not crash.""" + accu = Accumulation(error=False) + err = accu.get_yerr(0) + self.assertIsInstance(err, np.ndarray) + self.assertEqual(err.size, 0) + + def test_constructor_coerces_lists(self): + """Plain lists passed to the constructor must become numpy arrays.""" + x = [0.0, 1.0, 2.0] + y = [[10, 20, 30], [15, 25, 35]] + y_2 = [[100.0, 400.0, 900.0], [225.0, 625.0, 1225.0]] + accu = Accumulation(x=x, y=y, y_2=y_2, n=1, + index=[(0, 2)], sampl_freq=1000.0, error=True) + self.assertIsInstance(accu.x, np.ndarray) + np.testing.assert_array_equal(accu.x, [0.0, 1.0, 2.0]) + for ch in accu.y: + self.assertIsInstance(ch, np.ndarray) + for ch in accu.y_square: + self.assertIsInstance(ch, np.ndarray) + np.testing.assert_array_equal(accu.y[0], [10, 20, 30]) + np.testing.assert_array_equal(accu.y_square[0], [100.0, 400.0, 900.0]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_Drawable.py b/tests/test_Drawable.py new file mode 100644 index 0000000..e025f75 --- /dev/null +++ b/tests/test_Drawable.py @@ -0,0 +1,54 @@ +import unittest + +import numpy as np + +from damaris.data.Drawable import Drawable + + +class TestDrawable(unittest.TestCase): + def test_initial_types(self): + """x must start as an empty ndarray, y as a list of arrays.""" + d = Drawable() + self.assertIsInstance(d.x, np.ndarray) + self.assertEqual(d.x.size, 0) + self.assertIsInstance(d.y, list) + + def test_empty_min_max_helpers(self): + """min/max helpers must return 0 instead of raising on empty data.""" + d = Drawable() + self.assertEqual(d.get_xmin(), 0) + self.assertEqual(d.get_xmax(), 0) + self.assertEqual(d.get_xminpos(), 0) + self.assertEqual(d.get_ymin(), 0) + self.assertEqual(d.get_ymax(), 0) + self.assertEqual(d.get_yminpos(), 0) + + def test_min_max_helpers_with_data(self): + d = Drawable() + d.x = np.array([-1.0, 0.0, 2.0, 5.0]) + d.y = [np.array([1.0, -2.0, 3.0]), np.array([0.5, 4.0, -0.5])] + self.assertEqual(d.get_xmin(), -1.0) + self.assertEqual(d.get_xmax(), 5.0) + self.assertEqual(d.get_xminpos(), 2.0) + self.assertEqual(d.get_ymin(), -2.0) + self.assertEqual(d.get_ymax(), 4.0) + self.assertEqual(d.get_yminpos(), 0.5) + + def test_yminpos_without_positive_values(self): + d = Drawable() + d.x = np.array([1.0]) + d.y = [np.array([-1.0, -2.0])] + self.assertEqual(d.get_yminpos(), 0) + + def test_set_xdata_set_ydata(self): + d = Drawable() + d.x = np.zeros(3) + d.y = [np.zeros(3)] + d.set_xdata(1, 5.0) + d.set_ydata(0, 1, 7.0) + self.assertEqual(d.get_xdata()[1], 5.0) + self.assertEqual(d.get_ydata(0)[1], 7.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_Error_Result.py b/tests/test_Error_Result.py new file mode 100644 index 0000000..1098057 --- /dev/null +++ b/tests/test_Error_Result.py @@ -0,0 +1,23 @@ +import unittest + +import numpy as np + +from damaris.data.Error_Result import Error_Result + + +class TestErrorResult(unittest.TestCase): + def test_plotdata_are_ndarrays(self): + """get_xdata/get_ydata must return numpy arrays like every Drawable.""" + er = Error_Result("core failed") + self.assertIsInstance(er.get_xdata(), np.ndarray) + self.assertIsInstance(er.get_ydata(), np.ndarray) + np.testing.assert_array_equal(er.get_xdata(), [0.0]) + np.testing.assert_array_equal(er.get_ydata(), [0.0]) + + def test_add_returns_self(self): + er = Error_Result("msg") + self.assertIs(er + 1, er) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_MeasurementResult.py b/tests/test_MeasurementResult.py index 772bd84..1657ff9 100644 --- a/tests/test_MeasurementResult.py +++ b/tests/test_MeasurementResult.py @@ -1,8 +1,12 @@ +import io +import os +import tempfile import unittest import numpy as np +import tables -from damaris.data.MeasurementResult import MeasurementResult +from damaris.data.MeasurementResult import MeasurementResult, read_from_hdf class TestMeasurementResult(unittest.TestCase): @@ -30,6 +34,53 @@ class TestMeasurementResult(unittest.TestCase): mr = MeasurementResult("q") self.assertRaises(Exception, mr.__add__, 1) + def test_get_lineplotdata_returns_means(self): + """get_lineplotdata must return the accumulated means, not self.y.""" + mr = MeasurementResult("test_quantity") + for i in range(3): + mr[float(i)] = float(i * i) + k, v = mr.get_lineplotdata() + self.assertIsInstance(k, np.ndarray) + self.assertIsInstance(v, np.ndarray) + self.assertEqual(k.shape, v.shape) + np.testing.assert_array_equal(v, mr.get_ydata()) + np.testing.assert_array_equal(v, [0.0, 1.0, 4.0]) + + def test_get_lineplotdata_empty(self): + """get_lineplotdata of an empty result must return empty arrays.""" + mr = MeasurementResult("q") + k, v = mr.get_lineplotdata() + self.assertEqual(k.shape[0], 0) + self.assertEqual(v.shape[0], 0) + + def test_write_to_csv(self): + mr = MeasurementResult("q") + mr[1.0] = 5.0 + mr[2.0] = 7.0 + buf = io.StringIO() + mr.write_to_csv(buf) + lines = buf.getvalue().splitlines() + self.assertEqual(lines[0], "# quantity:q") + self.assertEqual(lines[1], "# x y ysigma n") + self.assertEqual(len(lines), 4) + self.assertEqual(lines[2].split()[3], "1") + self.assertEqual(lines[3].split()[3], "1") + + def test_write_to_hdf_roundtrip(self): + mr = MeasurementResult("q") + mr[1.0] = 5.0 + mr[2.0] = 7.0 + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.h5") + with tables.open_file(path, mode="w") as h5file: + mr.write_to_hdf(h5file, "/", "mr", "test result") + with tables.open_file(path, mode="r") as h5file: + mr2 = read_from_hdf(h5file.get_node("/mr")) + self.assertIsNotNone(mr2) + self.assertEqual(mr2.quantity_name, "q") + np.testing.assert_array_equal(mr2.get_xdata(), mr.get_xdata()) + np.testing.assert_array_equal(mr2.get_ydata(), mr.get_ydata()) + if __name__ == "__main__": unittest.main()