diff --git a/src/damaris/data/Signalpath.py b/src/damaris/data/Signalpath.py index 8cc2a68..276a534 100644 --- a/src/damaris/data/Signalpath.py +++ b/src/damaris/data/Signalpath.py @@ -1,3 +1,5 @@ +import copy + import numpy as N class Signalpath: @@ -5,33 +7,35 @@ class Signalpath: """ rotate signal by **degrees** for phase cycling, etc. + The signalpath object itself is not modified, a copy containing the + rotated data is returned. + :param degrees: rotate signal by this value - :return: + :return: copy of the signalpath object with rotated data """ if self.get_number_of_channels() != 2: raise Exception("rotation defined only for 2 channels") + rotated = copy.copy(self) # simple case 0, 90, 180, 270 degree reduced_angle = divmod(degrees, 90) if abs(reduced_angle[1]) < 1e-6: reduced_angle = reduced_angle[0] % 4 if reduced_angle == 0: - return self + rotated.y = list(self.y) elif reduced_angle == 1: - self.y[1] *= -1 - self.y = [self.y[1], self.y[0]] + rotated.y = [-self.y[1], self.y[0]] elif reduced_angle == 2: - self.y[0] *= -1 - self.y[1] *= -1 + rotated.y = [-self.y[0], -self.y[1]] elif reduced_angle == 3: - self.y[0] *= -1 - self.y = [self.y[1], self.y[0]] + rotated.y = [self.y[1], -self.y[0]] else: - sin_angle = N.sin(degrees / 180.0 * N.pi) - cos_angle = N.cos(degrees / 180.0 * N.pi) - self.y = [cos_angle * self.y[0] - sin_angle * self.y[1], - sin_angle * self.y[0] + cos_angle * self.y[1]] + rad_angle = N.deg2rad(degrees) + sin_angle = N.sin(rad_angle) + cos_angle = N.cos(rad_angle) + rotated.y = [cos_angle * self.y[0] - sin_angle * self.y[1], + sin_angle * self.y[0] + cos_angle * self.y[1]] - return self + return rotated diff --git a/tests/test_Signalpath.py b/tests/test_Signalpath.py new file mode 100644 index 0000000..9cb8cee --- /dev/null +++ b/tests/test_Signalpath.py @@ -0,0 +1,97 @@ +import unittest +from datetime import datetime + +import numpy as np +from damaris.data.ADC_Result import ADC_Result + + +def rotate_reference(y, degrees): + "reference rotation of the two channels by degrees" + rad = np.deg2rad(degrees) + cos, sin = np.cos(rad), np.sin(rad) + return [cos * y[0] - sin * y[1], sin * y[0] + cos * y[1]] + + +class TestSignalpath(unittest.TestCase): + @staticmethod + def create_adc_result(): + x = np.arange(4, dtype="float32") + y = [np.array([10, 20, 30, 40], dtype="int16"), + np.array([15, 25, 35, 45], dtype="int16")] + index = [(0, 3)] + job_date = datetime(2023, 1, 1) + desc = {"key": "value"} + sampl_freq = 1000.0 + job_id = 11 + adc = ADC_Result(x, y, index, sampl_freq, desc, job_id, job_date) + adc.set_nChannels(2) + return adc + + def assertRotated(self, adc, degrees): + rotated = adc.phase(degrees) + expected = rotate_reference(adc.y, degrees) + np.testing.assert_allclose(rotated.y[0], expected[0]) + np.testing.assert_allclose(rotated.y[1], expected[1]) + return rotated + + def test_phase_positive_quarter_turns(self): + """Test phase() with 0, 90, 180 and 270 degrees.""" + adc = self.create_adc_result() + for degrees in (0, 90, 180, 270): + self.assertRotated(adc, degrees) + + def test_phase_negative_quarter_turns(self): + """Test phase() with negative multiples of 90 degrees.""" + adc = self.create_adc_result() + for degrees in (-90, -180, -270, -360): + self.assertRotated(adc, degrees) + + def test_phase_negative_arbitrary_angles(self): + """Test phase() with arbitrary (not multiple of 90) negative angles.""" + adc = self.create_adc_result() + for degrees in (-45, -120, -315): + self.assertRotated(adc, degrees) + + def test_phase_equals_equivalent_positive_angle(self): + """Test that negative angles give the same result as the equivalent positive angle.""" + adc = self.create_adc_result() + for degrees in (-90, -45, -120): + np.testing.assert_allclose(adc.phase(degrees).y, + adc.phase(degrees + 360).y) + + def test_phase_does_not_modify_original(self): + """Test that phase() leaves the original data (and its arrays) untouched.""" + adc = self.create_adc_result() + original = [adc.y[0].copy(), adc.y[1].copy()] + for degrees in (0, 90, 180, 270, -90, -45, 45): + rotated = adc.phase(degrees) + self.assertIsNot(rotated, adc) + self.assertIsNot(rotated.y, adc.y) + for ch in (0, 1): + self.assertIsNot(rotated.y[ch], original[ch]) + np.testing.assert_array_equal(adc.y[ch], original[ch]) + + def test_phase_result_independent(self): + """Test that modifying the phase-rotated copy does not affect the original.""" + adc = self.create_adc_result() + rotated = adc.phase(-90) + rotated.y[0][:] = 9999 + np.testing.assert_array_equal(adc.y[0], [10, 20, 30, 40]) + + def test_phase_chaining(self): + """Test that chained phase() calls add up correctly.""" + adc = self.create_adc_result() + np.testing.assert_allclose(adc.phase(-90).phase(-90).y, + adc.phase(-180).y) + np.testing.assert_allclose(adc.phase(-90).phase(90).y, + adc.phase(0).y) + + def test_phase_wrong_channel_number(self): + """Test the Exception raised for signalpaths with other than 2 channels.""" + adc = self.create_adc_result() + adc.y.pop() + self.assertRaises(Exception, adc.phase, -90) + + +if __name__ == "__main__": + unittest.main()