normalize list/array handling in data classes

Establish one invariant across the Drawable hierarchy: x is always a
numpy ndarray, y is always a list of per-channel ndarrays, and empty
data is the same types with zero length.

- Drawable: x starts as empty ndarray; min/max helpers return 0 on
  empty data instead of raising; drop contradictory isinstance(self.y,
  list) branches
- ADC_Result/Accumulation: empty constructors build ndarray x; full
  constructors coerce x/y/y_square with numpy.asarray; Accumulation
  get_ydata/get_yerr return empty ndarrays and no longer crash on
  empty statistics-less accumulations
- MeasurementResult: fix get_lineplotdata to return the accumulated
  means (previously the never-populated self.y, so the GUI line plot
  could never draw); remove dead float/int branches in CSV and HDF
  writers
- Error_Result: get_xdata/get_ydata return ndarrays

Add unittest coverage for the invariant, empty-state accessors,
constructor coercion, line plot data, CSV output and HDF round-trip.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
markusro committed 2026-10-18 16:38:41 +02:00
1 parent 35d040426b
commit 947c4c7f8d
10 files changed
+230 -43

No files matched your search

+3 -3
View File
@@ -67,7 +67,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
self.cont_data = False self.cont_data = False
self.sampling_rate = 0 self.sampling_rate = 0
self.index = [] self.index = []
self.x = [] self.x = numpy.array([], dtype="float32")
self.y = [] self.y = []
# using all arguments for initialization # 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): 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: #for ch in y:
# if not numpy.issubdtype(ch.dtype, numpy.integer): # if not numpy.issubdtype(ch.dtype, numpy.integer):
# raise TypeError("TypeError: ADC_Result y data must be a list with integer type channels") # raise TypeError("TypeError: ADC_Result y data must be a list with integer type channels")
self.x = x self.x = numpy.asarray(x)
self.y = y self.y = [numpy.asarray(ch) for ch in y]
self.index = index self.index = index
self.sampling_rate = sampl_freq self.sampling_rate = sampl_freq
self.cont_data = True self.cont_data = True
+9 -9
View File
@@ -78,7 +78,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
if self.uses_statistics(): if self.uses_statistics():
if (y_2 is not None): if (y_2 is not None):
# y_square is always kept as a list of per-channel arrays # 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) : elif (y_2 is None) :
self.y_square = [] self.y_square = []
else: else:
@@ -93,12 +93,12 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
self.cont_data = False self.cont_data = False
self.index = [] self.index = []
self.x = [] self.x = numpy.array([], dtype="float32")
self.y = [] 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): 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.x = numpy.asarray(x)
self.y = y self.y = [numpy.asarray(ch) for ch in y]
self.sampling_rate = sampl_freq self.sampling_rate = sampl_freq
self.n = n self.n = n
@@ -179,15 +179,15 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
# external interface -------------------------------------------------------------------- # 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 return error (std.dev/sqrt(n)) of mean
""" """
if not self.contains_data():
return numpy.array([], dtype="float32")
if not self.uses_statistics(): if not self.uses_statistics():
return numpy.zeros((len(self.y[0]),),dtype="float32") return numpy.zeros((len(self.y[0]),),dtype="float32")
if not self.contains_data():
return []
self.lock.acquire() self.lock.acquire()
if self.n < 2: if self.n < 2:
@@ -203,13 +203,13 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
# sample standard deviation / sqrt(n) # sample standard deviation / sqrt(n)
return numpy.nan_to_num(numpy.sqrt(variance_over_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 return mean data
""" """
if not self.contains_data(): if not self.contains_data():
return [] return numpy.array([], dtype="float32")
self.lock.acquire() self.lock.acquire()
try: try:
tmp_y = self.y[channel] / self.n tmp_y = self.y[channel] / self.n
+21 -16
View File
@@ -17,8 +17,11 @@ class Drawable:
def __init__(self): def __init__(self):
# Will be set correctly in one of the subclasses # Will be set correctly in one of the subclasses
self.x: numpy.ndarray | list = [] # x is always a numpy.ndarray, y is always a list of numpy.ndarray
self.y: list = [] # (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 = { } self.styles: dict = { }
@@ -37,7 +40,7 @@ class Drawable:
self.ymax = 0 self.ymax = 0
def get_xdata(self) -> numpy.ndarray | list: def get_xdata(self) -> numpy.ndarray:
"Returns a reference to the x-Plotdata (array)" "Returns a reference to the x-Plotdata (array)"
return self.x return self.x
@@ -50,7 +53,7 @@ class Drawable:
raise 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)" "Returns the y-Plotdata of channel n (array)"
try: try:
return self.y[channel] return self.y[channel]
@@ -140,12 +143,16 @@ class Drawable:
def get_xmin(self) -> float: def get_xmin(self) -> float:
"Returns minimun of x" "Returns minimun of x"
if self.x.size == 0:
return 0
return self.x.min() return self.x.min()
def get_xminpos(self) -> float: def get_xminpos(self) -> float:
"Returns smallest positive value of x" "Returns smallest positive value of x"
mask = self.x > 0 mask = self.x > 0
if not numpy.any(mask):
return 0
return self.x[mask].min() return self.x[mask].min()
@@ -156,6 +163,8 @@ class Drawable:
def get_xmax(self) -> float: def get_xmax(self) -> float:
"Returns maximum of x" "Returns maximum of x"
if self.x.size == 0:
return 0
return self.x.max() return self.x.max()
@@ -166,24 +175,21 @@ class Drawable:
def get_ymin(self) -> float | int: def get_ymin(self) -> float | int:
"Returns minimum of y" "Returns minimum of y"
if isinstance(self.y, list): if len(self.y) == 0:
return 0
return min([yarr.min() for yarr in self.y]) return min([yarr.min() for yarr in self.y])
else:
return self.y.min()
def get_yminpos(self) -> float | int: def get_yminpos(self) -> float | int:
"Returns smallest positive value of y" "Returns smallest positive value of y"
if isinstance(self.y, list):
ymins = [] ymins = []
for ys in self.y: for ys in self.y:
mask = ys > 0 mask = ys > 0
if numpy.any(mask):
ymins.append(ys[mask].min()) ymins.append(ys[mask].min())
ymin = min(ymins) if len(ymins) == 0:
else: return 0
mask = self.y > 0 return min(ymins)
ymin = self.y[mask].min()
return ymin
def set_ymin(self, ymin: float) -> None: def set_ymin(self, ymin: float) -> None:
@@ -193,10 +199,9 @@ class Drawable:
def get_ymax(self) -> float | int: def get_ymax(self) -> float | int:
"Returns maximimum of y" "Returns maximimum of y"
if isinstance(self.y, list): if len(self.y) == 0:
return 0
return max([yarr.max() for yarr in self.y]) return max([yarr.max() for yarr in self.y])
else:
return self.y.max()
def set_ymax(self, ymax: float) -> None: def set_ymax(self, ymax: float) -> None:
+3 -2
View File
@@ -2,6 +2,7 @@
from .Resultable import Resultable from .Resultable import Resultable
from .Drawable import Drawable from .Drawable import Drawable
import numpy
############################################################################# #############################################################################
# # # #
@@ -47,11 +48,11 @@ class Error_Result(Resultable, Drawable):
# Nothing to plot # Nothing to plot
def get_ydata(self, channel=0): def get_ydata(self, channel=0):
return [0.0] return numpy.array([0.0])
# Nothing to plot # Nothing to plot
def get_xdata(self): def get_xdata(self):
return [0.0] return numpy.array([0.0])
#overload of operators und built-ins ------------------------------------------------------- #overload of operators und built-ins -------------------------------------------------------
+2 -12
View File
@@ -195,9 +195,7 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict):
return [k,v,e] return [k,v,e]
def get_lineplotdata(self) -> list[numpy.ndarray]: def get_lineplotdata(self) -> list[numpy.ndarray]:
k=self.get_xdata() return self.get_xydata()
v=numpy.array(self.y, dtype="float64")
return [k, v]
def uses_statistics(self) -> bool: def uses_statistics(self) -> bool:
""" """
@@ -222,9 +220,6 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict):
dest.write("# x y ysigma n\n") dest.write("# x y ysigma n\n")
for x in self.get_xdata(): for x in self.get_xdata():
y = self.data[x] 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, dest.write("%e%s%e%s%e%s%d\n" % (x,
delimiter, delimiter,
y.mean(), y.mean(),
@@ -260,14 +255,9 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict):
row=mr_table.row row=mr_table.row
xdata=self.get_xdata() xdata=self.get_xdata()
if xdata.shape[0]!=0: if xdata.shape[0]!=0:
for x in self.get_xdata(): for x in xdata:
y=self.data[x] y=self.data[x]
row["x"]=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"]=y.mean()
row["y_err"]=y.mean_error() row["y_err"]=y.mean_error()
row["n"]=y.n row["n"]=y.n
+26
View File
@@ -316,5 +316,31 @@ class TestADCResult(unittest.TestCase):
self.assertRaises(ValueError, adc1.__rfloordiv__, adc2) self.assertRaises(ValueError, adc1.__rfloordiv__, adc2)
self.assertRaises(ValueError, adc1.__pow__, 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+37
View File
@@ -523,5 +523,42 @@ class TestAccumulation(unittest.TestCase):
self.assertTrue(accu.lock.acquire(timeout=1), "lock leaked after ValueError in write_to_tecmag") self.assertTrue(accu.lock.acquire(timeout=1), "lock leaked after ValueError in write_to_tecmag")
accu.lock.release() 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+54
View File
@@ -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()
+23
View File
@@ -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()
+52 -1
View File
@@ -1,8 +1,12 @@
import io
import os
import tempfile
import unittest import unittest
import numpy as np 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): class TestMeasurementResult(unittest.TestCase):
@@ -30,6 +34,53 @@ class TestMeasurementResult(unittest.TestCase):
mr = MeasurementResult("q") mr = MeasurementResult("q")
self.assertRaises(Exception, mr.__add__, 1) 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__": if __name__ == "__main__":
unittest.main() unittest.main()