From e20b6f0d4e7b1dea3d1aa8a4b27807cd62b6a157 Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Wed, 23 Aug 2023 10:16:37 +0100 Subject: [PATCH 01/49] Added file with unit tests for slicer averagers based on constructed data distributions with analytical solutions. This file will eventually replace utest_averaging.py --- .../utest_averaging_analytical.py | 891 ++++++++++++++++++ 1 file changed, 891 insertions(+) create mode 100644 test/sasdataloader/utest_averaging_analytical.py diff --git a/test/sasdataloader/utest_averaging_analytical.py b/test/sasdataloader/utest_averaging_analytical.py new file mode 100644 index 00000000..fcf4a757 --- /dev/null +++ b/test/sasdataloader/utest_averaging_analytical.py @@ -0,0 +1,891 @@ +""" +This file contains unit tests for the various averagers found in +sasdata/data_util/manipulations.py - These tests are based on analytical +formulae rather than imported data files. +""" + +import unittest +from unittest.mock import patch + +import numpy as np +from scipy import integrate + +from sasdata.dataloader import data_info +from sasdata.data_util.manipulations import (_Slab, SlabX, SlabY, Boxsum, + Boxavg, CircularAverage, Ring, + _Sector, SectorQ, SectorPhi) + + +class MatrixToData2D: + """ + Create Data2D objects from supplied 2D arrays of data. + Error data can also be included. + + Adapted from sasdata.data_util.manipulations.reader_2D_converter + """ + + def __init__(self, data2d=None, err_data=None): + if data2d is None: + msg = "Data must be supplied to convert to Data2D" + raise ValueError(msg) + else: + matrix = np.asarray(data2d) + + if matrix.ndim != 2: + msg = "Supplied array must have 2 dimensions to convert to Data2D" + raise ValueError(msg) + + if err_data is not None: + err_data = np.asarray(err_data) + if err_data.shape != matrix.shape: + msg = "Data and errors must have the same shape" + raise ValueError(msg) + + # qmax can be any number, 1 just makes things simple. + self.qmax = 1 + qx_bins = np.linspace(start=-1 * self.qmax, + stop=self.qmax, + num=matrix.shape[1], + endpoint=True) + qy_bins = np.linspace(start=-1 * self.qmax, + stop=self.qmax, + num=matrix.shape[0], + endpoint=True) + + # Creating arrays in Data2D's preferred format. + data2d = matrix.flatten() + if err_data is None or np.any(err_data <= 0): + # Error data of some kind is needed, so we fabricate some + err_data = np.sqrt(np.abs(data2d)) # TODO - use different approach + else: + err_data = err_data.flatten() + qx_data = np.tile(qx_bins, (len(qy_bins), 1)).flatten() + qy_data = np.tile(qy_bins, (len(qx_bins), 1)).swapaxes(0, 1).flatten() + q_data = np.sqrt(qx_data * qx_data + qy_data * qy_data) + mask = np.ones(len(data2d), dtype=bool) + + # Creating a Data2D object to use for testing the averagers. + self.data = data_info.Data2D(data=data2d, err_data=err_data, + qx_data=qx_data, qy_data=qy_data, + q_data=q_data, mask=mask) + + +class CircularTestingMatrix: + """ + This class is used to generate a 2D array representing a function in polar + coordinates. The function, f(r, φ) = R(r) * Φ(φ), factorises into simple + radial and angular parts. This makes it easy to determine the form of the + function after one of the parts has been averaged over, and therefore good + for testing the directional averagers in manipulations.py. + This testing is done by comparing the area under the functions, as these + will only match if the limits defining the ROI were applied correctly. + + f(r, φ) = R(r) * Φ(φ) + R(r) = r ; where 0 <= r <= 1. + Φ(φ) = 1 + sin(ν * φ) ; where ν is the frequency and 0 <= φ <= 2π. + """ + + def __init__(self, frequency=1, matrix_size=201, major_axis=None): + """ + :param frequency: No. times Φ(φ) oscillates over the 0 <= φ <= 2π range + This parameter is largely arbitrary. + :param matrix_size: The len() of the output matrix. + Note that odd numbers give a centrepoint of 0,0. + :param major_axis: 'Q' or 'Phi' - the axis plotted against by the + averager being tested. + """ + if major_axis not in ('Q', 'Phi'): + msg = "Major axis must be either 'Q' or 'Phi'." + raise ValueError(msg) + + self.freq = frequency + self.matrix_size = matrix_size + self.major = major_axis + + # Grid with same dimensions as data matrix, ranging from -1 to 1 + x, y = np.meshgrid(np.linspace(-1, 1, self.matrix_size), + np.linspace(-1, 1, self.matrix_size)) + # radius is 0 at the centre, and 1 at (0, +/-1) and (+/-1, 0) + radius = np.sqrt(x**2 + y**2) + angle = np.arctan2(y, x) + # Create the 2D array of data + # The sinusoidal part is shifted up by 1 so its average is never 0 + self.matrix = radius * (1 + np.sin(self.freq * angle)) + + def area_under_region(self, r_min=0, r_max=1, phi_min=0, phi_max=2*np.pi): + """ + Integral of the testing matrix along the major axis, between the limits + specified. This can be compared to the integral under the 1D data + output by the averager being tested to confirm it's working properly. + + :param r_min: value defining the minimum Q in the ROI. + :param r_max: value defining the maximum Q in the ROI. + :param phi_min: value defining the minimum Phi in the ROI. + :param phi_max: value defining the maximum Phi in the ROI. + """ + + phi_range = phi_max - phi_min + # ∫(1 + sin(ν * φ)) dφ = φ + (-cos(ν * φ) / ν) + constant. + sine_part_integ = phi_range - (np.cos(self.freq * phi_max) - + np.cos(self.freq * phi_min)) / self.freq + sine_part_avg = sine_part_integ / phi_range + + # ∫(r) dr = r²/2 + constant. + linear_part_integ = (r_max ** 2 - r_min ** 2) / 2 + # The average radius is weighted towards higher radii. The probability + # of a point having a given radius value is proportional to the radius: + # P(r) = k * r ; where k is some proportionality constant. + # ∫[r₀, r₁] P(r) dr = 1, which can be solved for k. This can then be + # substituted into ⟨r⟩ = ∫[r₀, r₁] P(r) * r dr, giving: + linear_part_avg = 2/3 * (r_max**3 - r_min**3) / (r_max**2 - r_min**2) + + # The integral along the major axis is modulated by the average value + # along the minor axis (between the limits). + if self.major == 'Q': + calculated_area = sine_part_avg * linear_part_integ + else: + calculated_area = linear_part_avg * sine_part_integ + + return calculated_area + + +class SlabTests(unittest.TestCase): + """ + This class contains all the unit tests for the _Slab class from + manipulations.py + """ + + def test_slab_init(self): + """ + Test that _Slab's __init__ method does what it's supposed to. + """ + x_min = 1 + x_max = 2 + y_min = 3 + y_max = 4 + bin_width = 0.1 + fold = True + + slab_object = _Slab(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, + bin_width=bin_width, fold=fold) + + self.assertEqual(slab_object.x_min, x_min) + self.assertEqual(slab_object.x_max, x_max) + self.assertEqual(slab_object.y_min, y_min) + self.assertEqual(slab_object.y_max, y_max) + self.assertEqual(slab_object.bin_width, bin_width) + self.assertEqual(slab_object.fold, fold) + + def test_slab_multiple_detectors(self): + """ + Test that _Slab raises an error when there are multiple detectors + """ + averager_data = MatrixToData2D(np.ones([100, 100])) + detector1 = data_info.Detector() + detector2 = data_info.Detector() + averager_data.data.detector.append(detector1) + averager_data.data.detector.append(detector2) + + slab_object = _Slab() + self.assertRaises(RuntimeError, slab_object._avg, averager_data.data, 'x') + + def test_slab_unknown_axis(self): + """ + Test that _Slab raises an error when given an invalid major axis + """ + averager_data = MatrixToData2D(np.ones([100, 100])) + major = 'neither_x_nor_y' + + slab_object = _Slab() + self.assertRaises(RuntimeError, slab_object._avg, averager_data.data, major) + + def test_slab_no_points_to_average(self): + """ + Test _Slab raises ValueError when the ROI contains no data + """ + test_data = np.ones([100, 100]) + averager_data = MatrixToData2D(data2d=test_data) + + # Default params for _Slab are all zeros. Effectively, there is no ROI. + slab_object = _Slab() + self.assertRaises(ValueError, slab_object._avg, averager_data.data, 'x') + + def test_slab_averaging_x_without_fold(self): + """ + Test that _Slab can average correctly when x is the major axis + """ + matrix_size = 201 + x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), + np.linspace(-1, 1, matrix_size)) + # Create a distribution which is quadratic in x and linear in y + test_data = x**2 * y + averager_data = MatrixToData2D(data2d=test_data) + + # Set up region of interest to average over - the limits are arbitrary. + x_min = -0.5 * averager_data.qmax # = -0.5 + x_max = averager_data.qmax # = 1 + y_min = -0.5 * averager_data.qmax # = -0.5 + y_max = averager_data.qmax # = 1 + bin_width = (x_max - x_min) / matrix_size + # Explicitly not using fold in this test + fold = False + + slab_object = _Slab(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, + bin_width=bin_width, fold=fold) + data1d = slab_object._avg(averager_data.data, maj='x') + + # ∫x² dx = x³ / 3 + constant. + x_part_integ = (x_max**3 - x_min**3) / 3 + # ∫y dy = y² / 2 + constant. + y_part_integ = (y_max**2 - y_min**2) / 2 + y_part_avg = y_part_integ / (y_max - y_min) + expected_area = y_part_avg * x_part_integ + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 2) + + # TODO - also check the errors are being calculated correctly + + def test_slab_averaging_y_without_fold(self): + """ + Test that _Slab can average correctly when y is the major axis + """ + matrix_size = 201 + x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), + np.linspace(-1, 1, matrix_size)) + # Create a distribution which is linear in x and quadratic in y + test_data = x * y**2 + averager_data = MatrixToData2D(data2d=test_data) + + # Set up region of interest to average over - the limits are arbitrary. + x_min = -0.5 * averager_data.qmax # = -0.5 + x_max = averager_data.qmax # = 1 + y_min = -0.5 * averager_data.qmax # = -0.5 + y_max = averager_data.qmax # = 1 + bin_width = (y_max - y_min) / matrix_size + # Explicitly not using fold in this test + fold = False + + slab_object = _Slab(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, + bin_width=bin_width, fold=fold) + data1d = slab_object._avg(averager_data.data, maj='y') + + # ∫x dx = x² / 2 + constant. + x_part_integ = (x_max**2 - x_min**2) / 2 + x_part_avg = x_part_integ / (x_max - x_min) # or (x_min + x_max) / 2 + # ∫y² dy = y³ / 3 + constant. + y_part_integ = (y_max**3 - y_min**3) / 3 + expected_area = x_part_avg * y_part_integ + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 2) + + # TODO - also check the errors are being calculated correctly + + def test_slab_averaging_x_with_fold(self): + """ + Test that _Slab can average correctly when x is the major axis + """ + matrix_size = 201 + x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), + np.linspace(-1, 1, matrix_size)) + # Create a distribution which is quadratic in x and linear in y + test_data = x**2 * y + averager_data = MatrixToData2D(data2d=test_data) + + # Set up region of interest to average over - the limits are arbitrary. + x_min = -0.5 * averager_data.qmax # = -0.5 + x_max = averager_data.qmax # = 1 + y_min = -0.5 * averager_data.qmax # = -0.5 + y_max = averager_data.qmax # = 1 + bin_width = (x_max - x_min) / matrix_size + # Explicitly using fold in this test + fold = True + + slab_object = _Slab(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, + bin_width=bin_width, fold=fold) + data1d = slab_object._avg(averager_data.data, maj='x') + + # Negative values of x are not graphed when fold = True + x_min = 0 + # ∫x² dx = x³ / 3 + constant. + x_part_integ = (x_max**3 - x_min**3) / 3 + # ∫y dy = y² / 2 + constant. + y_part_integ = (y_max**2 - y_min**2) / 2 + y_part_avg = y_part_integ / (y_max - y_min) + expected_area = y_part_avg * x_part_integ + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 2) + + # TODO - also check the errors are being calculated correctly + + def test_slab_averaging_y_with_fold(self): + """ + Test that _Slab can average correctly when y is the major axis + """ + matrix_size = 201 + x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), + np.linspace(-1, 1, matrix_size)) + # Create a distribution which is linear in x and quadratic in y + test_data = x * y**2 + averager_data = MatrixToData2D(data2d=test_data) + + # Set up region of interest to average over - the limits are arbitrary. + x_min = -0.5 * averager_data.qmax # = -0.5 + x_max = averager_data.qmax # = 1 + y_min = -0.5 * averager_data.qmax # = -0.5 + y_max = averager_data.qmax # = 1 + bin_width = (y_max - y_min) / matrix_size + # Explicitly using fold in this test + fold = True + + slab_object = _Slab(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, + bin_width=bin_width, fold=fold) + data1d = slab_object._avg(averager_data.data, maj='y') + + # Negative values of y are not graphed when fold = True, so don't + # include them in the area calculation. + y_min = 0 + # ∫x dx = x² / 2 + constant. + x_part_integ = (x_max**2 - x_min**2) / 2 + x_part_avg = x_part_integ / (x_max - x_min) # or (x_min + x_max) / 2 + # ∫y² dy = y³ / 3 + constant. + y_part_integ = (y_max**3 - y_min**3) / 3 + expected_area = x_part_avg * y_part_integ + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 2) + + # TODO - also check the errors are being calculated correctly + + +class BoxsumTests(unittest.TestCase): + """ + This class contains all the unit tests for the Boxsum class from + manipulations.py + """ + + def test_boxsum_init(self): + """ + Test that Boxsum's __init__ method does what it's supposed to. + """ + x_min = 1 + x_max = 2 + y_min = 3 + y_max = 4 + + box_object = Boxsum(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + + self.assertEqual(box_object.x_min, x_min) + self.assertEqual(box_object.x_max, x_max) + self.assertEqual(box_object.y_min, y_min) + self.assertEqual(box_object.y_max, y_max) + + def test_boxsum_multiple_detectors(self): + """ + Test Boxsum raises an error when there are multiple detectors. + """ + averager_data = MatrixToData2D(np.ones([100, 100])) + detector1 = data_info.Detector() + detector2 = data_info.Detector() + averager_data.data.detector.append(detector1) + averager_data.data.detector.append(detector2) + + box_object = Boxsum() + self.assertRaises(RuntimeError, box_object, averager_data.data) + + def test_boxsum_total(self): + """ + Test that Boxsum can find the sum of all of a data set + """ + # Creating a 100x100 matrix for a distribution which is flat in y + # and linear in x. + test_data = np.tile(np.arange(100), (100, 1)) + averager_data = MatrixToData2D(data2d=test_data) + + # Selected region is entire data set + x_min = -1 * averager_data.qmax + x_max = averager_data.qmax + y_min = -1 * averager_data.qmax + y_max = averager_data.qmax + box_object = Boxsum(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + result, error, npoints = box_object(averager_data.data) + correct_sum = np.sum(test_data) + # When averager_data was created, we didn't include any error data. + # Stand-in error data is created, equal to np.sqrt(data2D). + # With the current method of error calculation, this is the result we + # should expect. This may need to change at some point. + correct_error = np.sqrt(np.sum(test_data)) + + self.assertAlmostEqual(result, correct_sum, 6) + self.assertAlmostEqual(error, correct_error, 6) + + def test_boxsum_subset_total(self): + """ + Test that Boxsum can find the sum of a portion of a data set + """ + # Creating a 100x100 matrix for a distribution which is flat in y + # and linear in x. + test_data = np.tile(np.arange(100), (100, 1)) + averager_data = MatrixToData2D(data2d=test_data) + + # Selection region covers the inner half of the +&- x&y axes + x_min = -0.5 * averager_data.qmax + x_max = 0.5 * averager_data.qmax + y_min = -0.5 * averager_data.qmax + y_max = 0.5 * averager_data.qmax + # Extracting the inner half of the data set + inner_portion = test_data[25:75, 25:75] + + box_object = Boxsum(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + result, error, npoints = box_object(averager_data.data) + correct_sum = np.sum(inner_portion) + # When averager_data was created, we didn't include any error data. + # Stand-in error data is created, equal to np.sqrt(data2D). + # With the current method of error calculation, this is the result we + # should expect. This may need to change at some point. + correct_error = np.sqrt(np.sum(inner_portion)) + + self.assertAlmostEqual(result, correct_sum, 6) + self.assertAlmostEqual(error, correct_error, 6) + + def test_boxsum_zero_sum(self): + """ + Test that Boxsum returns 0 when there are no points within the ROI + """ + test_data = np.ones([100, 100]) + # Make a hole in the middle with zeros + test_data[25:75, 25:75] = np.zeros([50, 50]) + averager_data = MatrixToData2D(data2d=test_data) + + # Selection region covers the inner half of the +&- x&y axes + x_min = -0.5 * averager_data.qmax + x_max = 0.5 * averager_data.qmax + y_min = -0.5 * averager_data.qmax + y_max = 0.5 * averager_data.qmax + box_object = Boxsum(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + result, error, npoints = box_object(averager_data.data) + + self.assertAlmostEqual(result, 0, 6) + self.assertAlmostEqual(error, 0, 6) + + +class BoxavgTests(unittest.TestCase): + """ + This class contains all the unit tests for the Boxavg class from + manipulations.py + """ + + def test_boxavg_init(self): + """ + Test that Boxavg's __init__ method does what it's supposed to. + """ + x_min = 1 + x_max = 2 + y_min = 3 + y_max = 4 + + box_object = Boxavg(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + + self.assertEqual(box_object.x_min, x_min) + self.assertEqual(box_object.x_max, x_max) + self.assertEqual(box_object.y_min, y_min) + self.assertEqual(box_object.y_max, y_max) + + def test_boxavg_multiple_detectors(self): + """ + Test Boxavg raises an error when there are multiple detectors. + """ + averager_data = MatrixToData2D(np.ones([100, 100])) + detector1 = data_info.Detector() + detector2 = data_info.Detector() + averager_data.data.detector.append(detector1) + averager_data.data.detector.append(detector2) + + box_object = Boxavg() + self.assertRaises(RuntimeError, box_object, averager_data.data) + + def test_boxavg_total(self): + """ + Test that Boxavg can find the average of all of a data set + """ + # Creating a 100x100 matrix for a distribution which is flat in y + # and linear in x. + test_data = np.tile(np.arange(100), (100, 1)) + averager_data = MatrixToData2D(data2d=test_data) + + # Selected region is entire data set + x_min = -1 * averager_data.qmax + x_max = averager_data.qmax + y_min = -1 * averager_data.qmax + y_max = averager_data.qmax + box_object = Boxavg(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + result, error = box_object(averager_data.data) + correct_avg = np.mean(test_data) + # When averager_data was created, we didn't include any error data. + # Stand-in error data is created, equal to np.sqrt(data2D). + # With the current method of error calculation, this is the result we + # should expect. This may need to change at some point. + correct_error = np.sqrt(np.sum(test_data)) / test_data.size + + self.assertAlmostEqual(result, correct_avg, 6) + self.assertAlmostEqual(error, correct_error, 6) + + def test_boxavg_subset_total(self): + """ + Test that Boxavg can find the average of a portion of a data set + """ + # Creating a 100x100 matrix for a distribution which is flat in y + # and linear in x. + test_data = np.tile(np.arange(100), (100, 1)) + averager_data = MatrixToData2D(data2d=test_data) + + # Selection region covers the inner half of the +&- x&y axes + x_min = -0.5 * averager_data.qmax + x_max = 0.5 * averager_data.qmax + y_min = -0.5 * averager_data.qmax + y_max = 0.5 * averager_data.qmax + # Extracting the inner half of the data set + inner_portion = test_data[25:75, 25:75] + + box_object = Boxavg(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + result, error = box_object(averager_data.data) + correct_avg = np.mean(inner_portion) + # When averager_data was created, we didn't include any error data. + # Stand-in error data is created, equal to np.sqrt(data2D). + # With the current method of error calculation, this is the result we + # should expect. This may need to change at some point. + correct_error = np.sqrt(np.sum(inner_portion)) / inner_portion.size + + self.assertAlmostEqual(result, correct_avg, 6) + self.assertAlmostEqual(error, correct_error, 6) + + def test_boxavg_zero_average(self): + """ + Test that Boxavg returns 0 when there are no points within the ROI + """ + test_data = np.ones([100, 100]) + # Make a hole in the middle with zeros + test_data[25:75, 25:75] = np.zeros([50, 50]) + averager_data = MatrixToData2D(data2d=test_data) + + # Selection region covers the inner half of the +&- x&y axes + x_min = -0.5 * averager_data.qmax + x_max = 0.5 * averager_data.qmax + y_min = -0.5 * averager_data.qmax + y_max = 0.5 * averager_data.qmax + box_object = Boxavg(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + result, error = box_object(averager_data.data) + + self.assertAlmostEqual(result, 0, 6) + self.assertAlmostEqual(error, 0, 6) + + +class CircularAverageTests(unittest.TestCase): + """ + This class contains all the tests for the CircularAverage class + from manipulations.py + """ + + def test_circularaverage_init(self): + """ + Test that CircularAverage's __init__ method does what it's supposed to. + """ + r_min = 1 + r_max = 2 + bin_width = 0.01 + + circ_object = CircularAverage(r_min=r_min, r_max=r_max, + bin_width=bin_width) + + self.assertEqual(circ_object.r_min, r_min) + self.assertEqual(circ_object.r_max, r_max) + self.assertEqual(circ_object.bin_width, bin_width) + + def test_circularaverage_dq_retrieval(self): + """ + Test that CircularAverage is able to calclate dq_data correctly when + the data provided has dqx_data and dqy_data. + """ + + # I'm saving the implementation of this bit for later + pass + + def test_circularaverage_multiple_detectors(self): + """ + Test CircularAverage raises an error when there are multiple detectors + """ + + # This test can't be implemented yet, because CircularAverage does not + # check the number of detectors. + # TODO - establish whether CircularAverage should be making this check. + pass + + def test_circularaverage_check_q_data(self): + """ + Check CircularAverage ensures the data supplied has `q_data` populated + """ + # test_data = np.ones([100, 100]) + # averager_data = DataMatrixToData2D(test_data) + # # Overwrite q_data so it's empty + # averager_data.data.q_data = np.array([]) + # circ_object = CircularAverage() + # self.assertRaises(RuntimeError, circ_object, averager_data.data) + + # This doesn't work. I'll come back to this later too + pass + + def test_circularaverage_check_valid_radii(self): + """ + Test that CircularAverage raises ValueError when r_min > r_max + """ + test_data = np.ones([100, 100]) + averager_data = MatrixToData2D(test_data) + + circ_object = CircularAverage(r_min=0.1, r_max=0.05) + self.assertRaises(ValueError, circ_object, averager_data.data) + + def test_circularaverage_no_points_to_average(self): + """ + Test CircularAverage raises ValueError when the ROI contains no data + """ + test_data = np.ones([100, 100]) + averager_data = MatrixToData2D(test_data) + + # Region of interest well outside region with data + circ_object = CircularAverage(r_min=2 * averager_data.qmax, + r_max=3 * averager_data.qmax) + self.assertRaises(ValueError, circ_object, averager_data.data) + + def test_circularaverage_averages_circularly(self): + """ + Test that CircularAverage can calculate a circular average correctly. + """ + test_data = CircularTestingMatrix(frequency=2, major_axis='Q') + averager_data = MatrixToData2D(test_data.matrix) + + # Test the ability to average over a subsection of the data + r_min = averager_data.qmax * 0.25 + r_max = averager_data.qmax * 0.75 + + circ_object = CircularAverage(r_min=r_min, r_max=r_max) + data1d = circ_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max) + actual_area = integrate.trapezoid(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 3) + + # TODO - also check the errors are being calculated correctly + + +class RingTests(unittest.TestCase): + """ + This class contains the tests for the Ring class from manipulations.py + A.K.A AnnulusSlicer on the sasview side + """ + + def test_ring_init(self): + """ + Test that Ring's __init__ method does what it's supposed to. + """ + r_min = 1 + r_max = 2 + nbins = 100 + + # Note that Ring also has params center_x and center_y, but these are + # not used by the slicers and there is a 'todo' in manipulations.py to + # remove them. For this reason, I have not tested their initialisation. + ring_object = Ring(r_min=r_min, r_max=r_max, nbins=nbins) + + self.assertEqual(ring_object.r_min, r_min) + self.assertEqual(ring_object.r_max, r_max) + self.assertEqual(ring_object.nbins_phi, nbins) + + def test_ring_non_plottable_data(self): + """ + Test that RuntimeError is raised if the data supplied isn't plottable + """ + # with patch("sasdata.data_util.manipulations.Ring.data2D.__class__.__name__") as p: + # p.return_value = "bad_name" + # ring_object = Ring() + # self.assertRaises(RuntimeError, ring_object.__call__) + + # I can't seem to get patch working, in this test or in others. + pass + + def test_ring_no_points_to_average(self): + """ + Test Ring raises ValueError when the ROI contains no data + """ + test_data = np.ones([100, 100]) + averager_data = MatrixToData2D(test_data) + + # Region of interest well outside region with data + ring_object = Ring(r_min=2 * averager_data.qmax, + r_max=3 * averager_data.qmax) + self.assertRaises(ValueError, ring_object, averager_data.data) + + def test_ring_averages_azimuthally(self): + """ + Test that Ring can calculate an azimuthal average correctly. + """ + test_data = CircularTestingMatrix(frequency=1, matrix_size=201, + major_axis='Phi') + averager_data = MatrixToData2D(test_data.matrix) + + # Test the ability to average over a subsection of the data + r_min = 0.25 * averager_data.qmax + r_max = 0.75 * averager_data.qmax + nbins = int(test_data.matrix_size / 2) + + ring_object = Ring(r_min=r_min, r_max=r_max, nbins=nbins) + data1d = ring_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max) + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 1) + + # TODO - also check the errors are being calculated correctly + + +class SectorTests(unittest.TestCase): + """ + This class contains the tests for the _Sector class from manipulations.py + On the sasview side, this includes SectorSlicer and WedgeSlicer. + + The parameters frequency, r_min, r_max, phi_min and phi_max are largely + arbitrary, and the tests should pass if any sane value is used for them. + """ + + def test_sector_init(self): + """ + Test that _Sector's __init__ method does what it's supposed to. + """ + r_min = 1 + r_max = 2 + phi_min = 0 + phi_max = np.pi + nbins = 100 + base = 10 + + sector_object = _Sector(r_min=r_min, r_max=r_max, phi_min=phi_min, + phi_max=phi_max, nbins=nbins, base=base) + + self.assertEqual(sector_object.r_min, r_min) + self.assertEqual(sector_object.r_max, r_max) + self.assertEqual(sector_object.phi_min, phi_min) + self.assertEqual(sector_object.phi_max, phi_max) + self.assertEqual(sector_object.nbins, nbins) + self.assertEqual(sector_object.base, base) + + def test_sector_non_plottable_data(self): + """ + Test that RuntimeError is raised if the data supplied isn't plottable + """ + # Implementing this test can wait + pass + + def test_sector_phi_averaging(self): + """ + Test _Sector can average correctly with a major axis of phi, when all + of min/max r & phi params are specified and have their expected form. + """ + test_data = CircularTestingMatrix(frequency=1, matrix_size=201, + major_axis='Phi') + averager_data = MatrixToData2D(test_data.matrix) + + r_min = 0.1 * averager_data.qmax + r_max = 0.9 * averager_data.qmax + phi_min = np.pi/6 + phi_max = 5*np.pi/6 + nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable + + wedge_object = SectorPhi(r_min=r_min, r_max=r_max, phi_min=phi_min + np.pi, + phi_max=phi_max + np.pi, nbins=nbins) + data1d = wedge_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min, + phi_max=phi_max) + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 1) + + # TODO - Something is very wrong with this test + + def test_sector_q_averaging_without_fold(self): + """ + Test _Sector can average correctly w/ major axis q and fold disabled. + All min/max r & phi params are specified and have their expected form. + """ + test_data = CircularTestingMatrix(frequency=1, matrix_size=201, + major_axis='Q') + averager_data = MatrixToData2D(test_data.matrix) + + r_min = 0.1 * averager_data.qmax + r_max = 0.9 * averager_data.qmax + phi_min = np.pi/6 + phi_max = 5*np.pi/6 + nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable + + wedge_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min + np.pi, + phi_max=phi_max + np.pi, nbins=nbins) + # Explicitly set fold to False - results span full +/- range + wedge_object.fold = False + data1d = wedge_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min, + phi_max=phi_max) + # With fold set to False, the sector on the opposite side of the origin + # to the one specified is also graphed as negative Q values. Therefore, + # the area of this other half needs to be accounted for. + expected_area += test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min+np.pi, + phi_max=phi_max+np.pi) + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 1) + + def test_sector_q_averaging_with_fold(self): + """ + Test _Sector can average correctly w/ major axis q and fold enabled. + All min/max r & phi params are specified and have their expected form. + """ + test_data = CircularTestingMatrix(frequency=1, matrix_size=201, + major_axis='Q') + averager_data = MatrixToData2D(test_data.matrix) + + r_min = 0.1 * averager_data.qmax + r_max = 0.9 * averager_data.qmax + phi_min = np.pi/6 + phi_max = 5*np.pi/6 + nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable + + wedge_object = SectorQ(r_min=r_min, r_max=r_max, + phi_min=phi_min + np.pi, + phi_max=phi_max + np.pi, nbins=nbins) + # Explicitly set fold to True - points either side of 0,0 are averaged + wedge_object.fold = True + data1d = wedge_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min, + phi_max=phi_max) + # With fold set to True, points from the sector on the opposite side of + # the origin to the one specified are averaged with points from the + # specified sector. + expected_area += test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min+np.pi, + phi_max=phi_max+np.pi) + expected_area /= 2 + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 1) + + +if __name__ == '__main__': + unittest.main() From 2743ffb8a21b926378116c0b947cdf7ab7f0745d Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Thu, 31 Aug 2023 11:03:02 +0100 Subject: [PATCH 02/49] Initial version of averagers with cartesian ROI, and made corresponding changes to the unit tests --- sasdata/data_util/new_manipulations.py | 324 ++++++++++++++++++ .../utest_averaging_analytical.py | 240 +++++++------ 2 files changed, 450 insertions(+), 114 deletions(-) create mode 100644 sasdata/data_util/new_manipulations.py diff --git a/sasdata/data_util/new_manipulations.py b/sasdata/data_util/new_manipulations.py new file mode 100644 index 00000000..591b6688 --- /dev/null +++ b/sasdata/data_util/new_manipulations.py @@ -0,0 +1,324 @@ +import numpy as np +from abc import ABC, abstractmethod +from typing import Union + +from sasdata.dataloader.data_info import Data1D, Data2D + + +class Binning: + """ + """ + + def __init__(self, min_value, max_value, nbins, base=None): + """ + """ + self.minimum = min_value + self.maximum = max_value + self.nbins = nbins + self.base = base + + def get_index(self, value): + """ + """ + if self.base: + numerator = (np.log(value) - np.log(self.minimum)) \ + / np.log(self.base) + denominator = (np.log(self.maximum) - np.log(self.minimum)) \ + / np.log(self.base) + else: + numerator = value - self.minimum + denominator = self.maximum - self.minimum + + bin_index = int(np.floor(self.nbins * numerator / denominator)) + + # Bins are indexed from 0 to nbins-1, so this check protects against + # out-of-range indices when value == maximum. + if bin_index == self.nbins: + bin_index -= 1 + + return bin_index + + +class CartesianROI(ABC): + """ + Base class for manipulators with a rectangular region of interest. + """ + + def __init__(self, qx_min: float = 0, qx_max: float = 0, + qy_min: float = 0, qy_max: float = 0) -> None: + """ + Placeholder + """ + + # Units A^-1 + self.qx_min = qx_min + self.qx_max = qx_max + self.qy_min = qy_min + self.qy_max = qy_max + + # Define data related variables + self.data = None + self.err_data = None + self.qx_data = None + self.qy_data = None + self.mask_data = None + + @abstractmethod + def __call__(self, data2d: Data2D = None) -> Union[float, Data1D]: + """ + Placeholder + """ + return + + # This method might be better placed in a parent class + def validate_and_assign_data(self, data2d: Data2D = None) -> None: + """ + Check that the data supplied valid and assign data variables. + """ + if not isinstance(data2d, Data2D): + msg = "Data supplied must be of type Data2D." + raise TypeError(msg) + if len(data2d.detector) > 1: + msg = f"Invalid number of detectors: {len(data2d.detector)}" + raise ValueError(msg) + + finite_data = np.isfinite(data2d.data) + self.data = data2d.data[finite_data] + self.err_data = data2d.err_data[finite_data] + self.qx_data = data2d.qx_data[finite_data] + self.qy_data = data2d.qy_data[finite_data] + self.mask_data = data2d.mask[finite_data] + + @property + def roi_mask(self): + """ + Return a boolean array listing the elements of self.data which are + inside the ROI. This property should only be accessed after + CartesianROI has been called. + """ + if any(data is None for data in [self.qx_data, self.qy_data, + self.mask_data]): + raise RuntimeError + + within_x_lims = (self.qx_data >= self.qx_min) & \ + (self.qx_data <= self.qx_max) + within_y_lims = (self.qy_data >= self.qy_min) & \ + (self.qy_data <= self.qy_max) + + # Don't return masked-off data + return within_x_lims & within_y_lims & self.mask_data + + +class PolarROI(ABC): + """ + Base class for manipulators whose ROI is defined with polar coordinates. + """ + + def __init__(self, r_min: float = 0, r_max: float = 1000, + phi_min: float = 0, phi_max: float = 2*np.pi) -> None: + """ + Placeholder + """ + + # Units A^-1 for radii, radians for angles + self.r_min = r_min + self.r_max = r_max + self.phi_min = phi_min + self.phi_max = phi_max + + # Define data related variables + self.data = None + self.err_data = None + self.q_data = None + self.qx_data = None + self.qy_data = None + self.mask_data = None + + @abstractmethod + def __call__(self, data2d: Data2D = None) -> Union[float, Data1D]: + """ + Placeholder + """ + return + + # This method might be better placed in a parent class + def validate_and_assign_data(self, data2d: Data2D = None) -> None: + """ + Check that the data supplied valid and assign data variables. + """ + if not isinstance(data2d, Data2D): + msg = "Data supplied must be of type Data2D." + raise TypeError(msg) + if len(data2d.detector) > 1: + msg = f"Invalid number of detectors: {len(data2d.detector)}" + raise ValueError(msg) + + finite_data = np.isfinite(data2d.data) + self.data = data2d.data[finite_data] + self.err_data = data2d.err_data[finite_data] + self.q_data = data2d.q_data[finite_data] + self.qx_data = data2d.qx_data[finite_data] + self.qy_data = data2d.qy_data[finite_data] + self.mask_data = data2d.mask[finite_data] + + +class Boxsum(CartesianROI): + """ + Perform the sum of counts in a 2D region of interest. + """ + + def __init__(self, qx_min: float = 0, qx_max: float = 0, + qy_min: float = 0, qy_max: float = 0) -> None: + super().__init__(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) + + def __call__(self, data2d: Data2D = None) -> float: + """ + Placeholder + """ + self.validate_and_assign_data(data2d) + total_sum, error, count = self._sum() + + return total_sum, error, count + + def _sum(self) -> float: + """ + Placeholder + """ + + # Currently the weights are binary, but could be fractional in future + weights = self.roi_mask.astype(int) + + data = weights * self.data + err_squared = weights * weights * self.err_data * self.err_data + # No points should have zero error, if they do then assume the worst + err_squared[self.err_data == 0] = (weights * data)[self.err_data == 0] + + total_sum = np.sum(data) + total_count = np.sum(weights) + total_errors_squared = np.sum(err_squared) + + return total_sum, np.sqrt(total_errors_squared), total_count + + +class Boxavg(Boxsum): + """ + Perform the average of counts in a 2D region of interest. + """ + + def __init__(self, qx_min: float = 0, qx_max: float = 0, + qy_min: float = 0, qy_max: float = 0) -> None: + super().__init__(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) + + def __call__(self, data2d: Data2D) -> float: + """ + Placeholder + """ + self.validate_and_assign_data(data2d) + total_sum, error, count = super()._sum() + + return (total_sum / count), (error / count) + + +class _Slab(CartesianROI): + """ + Compute average I(Q) for a region of interest + """ + + def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, + qy_max: float = 0, nbins: int = 100, fold: bool = False): + super().__init__(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) + self.nbins = nbins + self.fold = fold + + def __call__(self, data2d: Data2D = None) -> Data1D: + pass + + def _avg(self, data2d: Data2D, major_axis: str) -> Data1D: + """ + Placeholder + """ + self.validate_and_assign_data(data2d) + + # TODO - change weights + weights = self.roi_mask.astype(int) + + if major_axis == 'x': + q_major = self.qx_data + binning = Binning(min_value=0 if self.fold else self.qx_min, + max_value=self.qx_max, nbins=self.nbins) + elif major_axis == 'y': + q_major = self.qy_data + binning = Binning(min_value=0 if self.fold else self.qy_min, + max_value=self.qy_max, nbins=self.nbins) + else: + msg = f"Unrecognised axis: {major_axis}" + raise ValueError(msg) + + q_values = np.zeros(self.nbins) + intensity = np.zeros(self.nbins) + errs_squared = np.zeros(self.nbins) + bin_counts = np.zeros(self.nbins) + + for index, q_value in enumerate(q_major): + # Skip over datapoints with no relevance + # This should include masked datapoints. + if weights[index] == 0: + continue + + if self.fold and q_value < 0: + q_value = -q_value + + q_bin = binning.get_index(q_value) + q_values[q_bin] += weights[index] * q_value + intensity[q_bin] += weights[index] * self.data[index] + errs_squared[q_bin] += (weights[index] * self.err_data[index]) ** 2 + # No points should have zero error, assume the worst if they do + if self.err_data[index] == 0.0: + errs_squared[q_bin] += weights[index] ** 2 * abs(self.data[index]) + else: + errs_squared[q_bin] += (weights[index] * self.err_data[index]) ** 2 + bin_counts[q_bin] += weights[index] + + errors = np.sqrt(errs_squared) + q_values /= bin_counts + intensity /= bin_counts + errors /= bin_counts + + finite = (np.isfinite(q_values) & np.isfinite(intensity)) + if not finite.any(): + msg = "Average Error: No points inside ROI to average..." + raise ValueError(msg) + + return Data1D(x=q_values[finite], y=intensity[finite], dy=errors[finite]) + + +class SlabX(_Slab): + """ + Compute average I(Qx) for a region of interest + """ + + def __call__(self, data2d: Data2D = None) -> Data1D: + """ + Compute average I(Qx) for a region of interest + :param data2d: Data2D object + :return: Data1D object + """ + return self._avg(data2d, 'x') + + +class SlabY(_Slab): + """ + Compute average I(Qy) for a region of interest + """ + + def __call__(self, data2d: Data2D = None) -> Data1D: + """ + Compute average I(Qy) for a region of interest + :param data2d: Data2D object + :return: Data1D object + """ + return self._avg(data2d, 'y') + diff --git a/test/sasdataloader/utest_averaging_analytical.py b/test/sasdataloader/utest_averaging_analytical.py index fcf4a757..2d850e5b 100644 --- a/test/sasdataloader/utest_averaging_analytical.py +++ b/test/sasdataloader/utest_averaging_analytical.py @@ -11,9 +11,9 @@ from scipy import integrate from sasdata.dataloader import data_info -from sasdata.data_util.manipulations import (_Slab, SlabX, SlabY, Boxsum, - Boxavg, CircularAverage, Ring, - _Sector, SectorQ, SectorPhi) +from sasdata.data_util.manipulations import (CircularAverage, Ring, _Sector, + SectorQ, SectorPhi) +from sasdata.data_util.new_manipulations import _Slab, SlabX, SlabY, Boxsum, Boxavg class MatrixToData2D: @@ -159,21 +159,21 @@ def test_slab_init(self): """ Test that _Slab's __init__ method does what it's supposed to. """ - x_min = 1 - x_max = 2 - y_min = 3 - y_max = 4 - bin_width = 0.1 + qx_min = 1 + qx_max = 2 + qy_min = 3 + qy_max = 4 + nbins = 100 fold = True - slab_object = _Slab(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, - bin_width=bin_width, fold=fold) + slab_object = _Slab(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, + qy_max=qy_max, nbins=nbins, fold=fold) - self.assertEqual(slab_object.x_min, x_min) - self.assertEqual(slab_object.x_max, x_max) - self.assertEqual(slab_object.y_min, y_min) - self.assertEqual(slab_object.y_max, y_max) - self.assertEqual(slab_object.bin_width, bin_width) + self.assertEqual(slab_object.qx_min, qx_min) + self.assertEqual(slab_object.qx_max, qx_max) + self.assertEqual(slab_object.qy_min, qy_min) + self.assertEqual(slab_object.qy_max, qy_max) + self.assertEqual(slab_object.nbins, nbins) self.assertEqual(slab_object.fold, fold) def test_slab_multiple_detectors(self): @@ -187,7 +187,7 @@ def test_slab_multiple_detectors(self): averager_data.data.detector.append(detector2) slab_object = _Slab() - self.assertRaises(RuntimeError, slab_object._avg, averager_data.data, 'x') + self.assertRaises(ValueError, slab_object._avg, averager_data.data, 'x') def test_slab_unknown_axis(self): """ @@ -197,7 +197,7 @@ def test_slab_unknown_axis(self): major = 'neither_x_nor_y' slab_object = _Slab() - self.assertRaises(RuntimeError, slab_object._avg, averager_data.data, major) + self.assertRaises(ValueError, slab_object._avg, averager_data.data, major) def test_slab_no_points_to_average(self): """ @@ -222,23 +222,23 @@ def test_slab_averaging_x_without_fold(self): averager_data = MatrixToData2D(data2d=test_data) # Set up region of interest to average over - the limits are arbitrary. - x_min = -0.5 * averager_data.qmax # = -0.5 - x_max = averager_data.qmax # = 1 - y_min = -0.5 * averager_data.qmax # = -0.5 - y_max = averager_data.qmax # = 1 - bin_width = (x_max - x_min) / matrix_size + qx_min = -0.5 * averager_data.qmax # = -0.5 + qx_max = averager_data.qmax # = 1 + qy_min = -0.5 * averager_data.qmax # = -0.5 + qy_max = averager_data.qmax # = 1 + nbins = int((qx_max - qx_min) / 2 * matrix_size) # Explicitly not using fold in this test fold = False - slab_object = _Slab(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, - bin_width=bin_width, fold=fold) - data1d = slab_object._avg(averager_data.data, maj='x') + slab_object = _Slab(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, + qy_max=qy_max, nbins=nbins, fold=fold) + data1d = slab_object._avg(averager_data.data, major_axis='x') # ∫x² dx = x³ / 3 + constant. - x_part_integ = (x_max**3 - x_min**3) / 3 + x_part_integ = (qx_max**3 - qx_min**3) / 3 # ∫y dy = y² / 2 + constant. - y_part_integ = (y_max**2 - y_min**2) / 2 - y_part_avg = y_part_integ / (y_max - y_min) + y_part_integ = (qy_max**2 - qy_min**2) / 2 + y_part_avg = y_part_integ / (qy_max - qy_min) expected_area = y_part_avg * x_part_integ actual_area = integrate.simpson(data1d.y, data1d.x) @@ -258,23 +258,23 @@ def test_slab_averaging_y_without_fold(self): averager_data = MatrixToData2D(data2d=test_data) # Set up region of interest to average over - the limits are arbitrary. - x_min = -0.5 * averager_data.qmax # = -0.5 - x_max = averager_data.qmax # = 1 - y_min = -0.5 * averager_data.qmax # = -0.5 - y_max = averager_data.qmax # = 1 - bin_width = (y_max - y_min) / matrix_size + qx_min = -0.5 * averager_data.qmax # = -0.5 + qx_max = averager_data.qmax # = 1 + qy_min = -0.5 * averager_data.qmax # = -0.5 + qy_max = averager_data.qmax # = 1 + nbins = int((qx_max - qx_min) / 2 * matrix_size) # Explicitly not using fold in this test fold = False - slab_object = _Slab(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, - bin_width=bin_width, fold=fold) - data1d = slab_object._avg(averager_data.data, maj='y') + slab_object = _Slab(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, + qy_max=qy_max, nbins=nbins, fold=fold) + data1d = slab_object._avg(averager_data.data, major_axis='y') # ∫x dx = x² / 2 + constant. - x_part_integ = (x_max**2 - x_min**2) / 2 - x_part_avg = x_part_integ / (x_max - x_min) # or (x_min + x_max) / 2 + x_part_integ = (qx_max**2 - qx_min**2) / 2 + x_part_avg = x_part_integ / (qx_max - qx_min) # or (x_min + x_max) / 2 # ∫y² dy = y³ / 3 + constant. - y_part_integ = (y_max**3 - y_min**3) / 3 + y_part_integ = (qy_max**3 - qy_min**3) / 3 expected_area = x_part_avg * y_part_integ actual_area = integrate.simpson(data1d.y, data1d.x) @@ -294,25 +294,25 @@ def test_slab_averaging_x_with_fold(self): averager_data = MatrixToData2D(data2d=test_data) # Set up region of interest to average over - the limits are arbitrary. - x_min = -0.5 * averager_data.qmax # = -0.5 - x_max = averager_data.qmax # = 1 - y_min = -0.5 * averager_data.qmax # = -0.5 - y_max = averager_data.qmax # = 1 - bin_width = (x_max - x_min) / matrix_size + qx_min = -0.5 * averager_data.qmax # = -0.5 + qx_max = averager_data.qmax # = 1 + qy_min = -0.5 * averager_data.qmax # = -0.5 + qy_max = averager_data.qmax # = 1 + nbins = int((qx_max - qx_min) / 2 * matrix_size) # Explicitly using fold in this test fold = True - slab_object = _Slab(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, - bin_width=bin_width, fold=fold) - data1d = slab_object._avg(averager_data.data, maj='x') + slab_object = _Slab(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, + qy_max=qy_max, nbins=nbins, fold=fold) + data1d = slab_object._avg(averager_data.data, major_axis='x') # Negative values of x are not graphed when fold = True - x_min = 0 + qx_min = 0 # ∫x² dx = x³ / 3 + constant. - x_part_integ = (x_max**3 - x_min**3) / 3 + x_part_integ = (qx_max**3 - qx_min**3) / 3 # ∫y dy = y² / 2 + constant. - y_part_integ = (y_max**2 - y_min**2) / 2 - y_part_avg = y_part_integ / (y_max - y_min) + y_part_integ = (qy_max**2 - qy_min**2) / 2 + y_part_avg = y_part_integ / (qy_max - qy_min) expected_area = y_part_avg * x_part_integ actual_area = integrate.simpson(data1d.y, data1d.x) @@ -332,26 +332,26 @@ def test_slab_averaging_y_with_fold(self): averager_data = MatrixToData2D(data2d=test_data) # Set up region of interest to average over - the limits are arbitrary. - x_min = -0.5 * averager_data.qmax # = -0.5 - x_max = averager_data.qmax # = 1 - y_min = -0.5 * averager_data.qmax # = -0.5 - y_max = averager_data.qmax # = 1 - bin_width = (y_max - y_min) / matrix_size + qx_min = -0.5 * averager_data.qmax # = -0.5 + qx_max = averager_data.qmax # = 1 + qy_min = -0.5 * averager_data.qmax # = -0.5 + qy_max = averager_data.qmax # = 1 + nbins = int((qx_max - qx_min) / 2 * matrix_size) # Explicitly using fold in this test fold = True - slab_object = _Slab(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, - bin_width=bin_width, fold=fold) - data1d = slab_object._avg(averager_data.data, maj='y') + slab_object = _Slab(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, + qy_max=qy_max, nbins=nbins, fold=fold) + data1d = slab_object._avg(averager_data.data, major_axis='y') # Negative values of y are not graphed when fold = True, so don't # include them in the area calculation. - y_min = 0 + qy_min = 0 # ∫x dx = x² / 2 + constant. - x_part_integ = (x_max**2 - x_min**2) / 2 - x_part_avg = x_part_integ / (x_max - x_min) # or (x_min + x_max) / 2 + x_part_integ = (qx_max**2 - qx_min**2) / 2 + x_part_avg = x_part_integ / (qx_max - qx_min) # or (x_min + x_max) / 2 # ∫y² dy = y³ / 3 + constant. - y_part_integ = (y_max**3 - y_min**3) / 3 + y_part_integ = (qy_max**3 - qy_min**3) / 3 expected_area = x_part_avg * y_part_integ actual_area = integrate.simpson(data1d.y, data1d.x) @@ -370,17 +370,18 @@ def test_boxsum_init(self): """ Test that Boxsum's __init__ method does what it's supposed to. """ - x_min = 1 - x_max = 2 - y_min = 3 - y_max = 4 + qx_min = 1 + qx_max = 2 + qy_min = 3 + qy_max = 4 - box_object = Boxsum(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + box_object = Boxsum(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) - self.assertEqual(box_object.x_min, x_min) - self.assertEqual(box_object.x_max, x_max) - self.assertEqual(box_object.y_min, y_min) - self.assertEqual(box_object.y_max, y_max) + self.assertEqual(box_object.qx_min, qx_min) + self.assertEqual(box_object.qx_max, qx_max) + self.assertEqual(box_object.qy_min, qy_min) + self.assertEqual(box_object.qy_max, qy_max) def test_boxsum_multiple_detectors(self): """ @@ -393,7 +394,7 @@ def test_boxsum_multiple_detectors(self): averager_data.data.detector.append(detector2) box_object = Boxsum() - self.assertRaises(RuntimeError, box_object, averager_data.data) + self.assertRaises(ValueError, box_object, averager_data.data) def test_boxsum_total(self): """ @@ -405,11 +406,12 @@ def test_boxsum_total(self): averager_data = MatrixToData2D(data2d=test_data) # Selected region is entire data set - x_min = -1 * averager_data.qmax - x_max = averager_data.qmax - y_min = -1 * averager_data.qmax - y_max = averager_data.qmax - box_object = Boxsum(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + qx_min = -1 * averager_data.qmax + qx_max = averager_data.qmax + qy_min = -1 * averager_data.qmax + qy_max = averager_data.qmax + box_object = Boxsum(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) result, error, npoints = box_object(averager_data.data) correct_sum = np.sum(test_data) # When averager_data was created, we didn't include any error data. @@ -431,14 +433,15 @@ def test_boxsum_subset_total(self): averager_data = MatrixToData2D(data2d=test_data) # Selection region covers the inner half of the +&- x&y axes - x_min = -0.5 * averager_data.qmax - x_max = 0.5 * averager_data.qmax - y_min = -0.5 * averager_data.qmax - y_max = 0.5 * averager_data.qmax + qx_min = -0.5 * averager_data.qmax + qx_max = 0.5 * averager_data.qmax + qy_min = -0.5 * averager_data.qmax + qy_max = 0.5 * averager_data.qmax # Extracting the inner half of the data set inner_portion = test_data[25:75, 25:75] - box_object = Boxsum(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + box_object = Boxsum(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) result, error, npoints = box_object(averager_data.data) correct_sum = np.sum(inner_portion) # When averager_data was created, we didn't include any error data. @@ -460,11 +463,12 @@ def test_boxsum_zero_sum(self): averager_data = MatrixToData2D(data2d=test_data) # Selection region covers the inner half of the +&- x&y axes - x_min = -0.5 * averager_data.qmax - x_max = 0.5 * averager_data.qmax - y_min = -0.5 * averager_data.qmax - y_max = 0.5 * averager_data.qmax - box_object = Boxsum(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + qx_min = -0.5 * averager_data.qmax + qx_max = 0.5 * averager_data.qmax + qy_min = -0.5 * averager_data.qmax + qy_max = 0.5 * averager_data.qmax + box_object = Boxsum(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) result, error, npoints = box_object(averager_data.data) self.assertAlmostEqual(result, 0, 6) @@ -481,17 +485,18 @@ def test_boxavg_init(self): """ Test that Boxavg's __init__ method does what it's supposed to. """ - x_min = 1 - x_max = 2 - y_min = 3 - y_max = 4 + qx_min = 1 + qx_max = 2 + qy_min = 3 + qy_max = 4 - box_object = Boxavg(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + box_object = Boxavg(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) - self.assertEqual(box_object.x_min, x_min) - self.assertEqual(box_object.x_max, x_max) - self.assertEqual(box_object.y_min, y_min) - self.assertEqual(box_object.y_max, y_max) + self.assertEqual(box_object.qx_min, qx_min) + self.assertEqual(box_object.qx_max, qx_max) + self.assertEqual(box_object.qy_min, qy_min) + self.assertEqual(box_object.qy_max, qy_max) def test_boxavg_multiple_detectors(self): """ @@ -504,7 +509,7 @@ def test_boxavg_multiple_detectors(self): averager_data.data.detector.append(detector2) box_object = Boxavg() - self.assertRaises(RuntimeError, box_object, averager_data.data) + self.assertRaises(ValueError, box_object, averager_data.data) def test_boxavg_total(self): """ @@ -516,11 +521,12 @@ def test_boxavg_total(self): averager_data = MatrixToData2D(data2d=test_data) # Selected region is entire data set - x_min = -1 * averager_data.qmax - x_max = averager_data.qmax - y_min = -1 * averager_data.qmax - y_max = averager_data.qmax - box_object = Boxavg(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + qx_min = -1 * averager_data.qmax + qx_max = averager_data.qmax + qy_min = -1 * averager_data.qmax + qy_max = averager_data.qmax + box_object = Boxavg(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) result, error = box_object(averager_data.data) correct_avg = np.mean(test_data) # When averager_data was created, we didn't include any error data. @@ -542,14 +548,15 @@ def test_boxavg_subset_total(self): averager_data = MatrixToData2D(data2d=test_data) # Selection region covers the inner half of the +&- x&y axes - x_min = -0.5 * averager_data.qmax - x_max = 0.5 * averager_data.qmax - y_min = -0.5 * averager_data.qmax - y_max = 0.5 * averager_data.qmax + qx_min = -0.5 * averager_data.qmax + qx_max = 0.5 * averager_data.qmax + qy_min = -0.5 * averager_data.qmax + qy_max = 0.5 * averager_data.qmax # Extracting the inner half of the data set inner_portion = test_data[25:75, 25:75] - box_object = Boxavg(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + box_object = Boxavg(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) result, error = box_object(averager_data.data) correct_avg = np.mean(inner_portion) # When averager_data was created, we didn't include any error data. @@ -571,11 +578,12 @@ def test_boxavg_zero_average(self): averager_data = MatrixToData2D(data2d=test_data) # Selection region covers the inner half of the +&- x&y axes - x_min = -0.5 * averager_data.qmax - x_max = 0.5 * averager_data.qmax - y_min = -0.5 * averager_data.qmax - y_max = 0.5 * averager_data.qmax - box_object = Boxavg(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + qx_min = -0.5 * averager_data.qmax + qx_max = 0.5 * averager_data.qmax + qy_min = -0.5 * averager_data.qmax + qy_max = 0.5 * averager_data.qmax + box_object = Boxavg(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) result, error = box_object(averager_data.data) self.assertAlmostEqual(result, 0, 6) @@ -594,14 +602,18 @@ def test_circularaverage_init(self): """ r_min = 1 r_max = 2 - bin_width = 0.01 + bin_width = 0.001 + # nbins = 100 circ_object = CircularAverage(r_min=r_min, r_max=r_max, bin_width=bin_width) + # circ_object = CircularAverage(r_min=r_min, r_max=r_max, + # nbins=nbins) self.assertEqual(circ_object.r_min, r_min) self.assertEqual(circ_object.r_max, r_max) self.assertEqual(circ_object.bin_width, bin_width) + # self.assertEqual(circ_object.nbins, nbins) def test_circularaverage_dq_retrieval(self): """ From aacf1d79c116028af253b583ba303afe578b2334 Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Fri, 1 Sep 2023 07:29:30 +0100 Subject: [PATCH 03/49] Changed the binning/weighting process for _Slab to make the fractional binning upgrade easier --- sasdata/data_util/new_manipulations.py | 116 +++++++++++++++++-------- 1 file changed, 78 insertions(+), 38 deletions(-) diff --git a/sasdata/data_util/new_manipulations.py b/sasdata/data_util/new_manipulations.py index 591b6688..82caf577 100644 --- a/sasdata/data_util/new_manipulations.py +++ b/sasdata/data_util/new_manipulations.py @@ -1,12 +1,32 @@ -import numpy as np from abc import ABC, abstractmethod from typing import Union +import numpy as np + from sasdata.dataloader.data_info import Data1D, Data2D +def weights_for_interval(array, l_bound, u_bound, interval_type='half-open'): + """ + If and when fractional binning is implemented (ask Lucas), this function + will be changed so that instead of outputting zeros and ones, it gives + fractional values instead. These will depend on how close the array value + is to being within the interval defined. + """ + if interval_type == 'half-open': + in_range = (l_bound <= array) & (array < u_bound) + elif interval_type == 'closed': + in_range = (l_bound <= array) & (array <= u_bound) + else: + msg = f"Unrecognised interval_type: {interval_type}" + raise ValueError(msg) + + return np.asarray(in_range, dtype=int) + + class Binning: """ + TODO - add docstring """ def __init__(self, min_value, max_value, nbins, base=None): @@ -16,8 +36,9 @@ def __init__(self, min_value, max_value, nbins, base=None): self.maximum = max_value self.nbins = nbins self.base = base + self.bin_width = (max_value - min_value) / nbins - def get_index(self, value): + def get_index(self, value: float) -> int: """ """ if self.base: @@ -38,6 +59,14 @@ def get_index(self, value): return bin_index + def get_interval(self, bin_number: int) -> float: + """ + """ + start = self.minimum + self.bin_width * bin_number + stop = self.minimum + self.bin_width * (bin_number + 1) + + return start, stop + class CartesianROI(ABC): """ @@ -47,7 +76,7 @@ class CartesianROI(ABC): def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, qy_max: float = 0) -> None: """ - Placeholder + TODO - add docstring """ # Units A^-1 @@ -66,7 +95,7 @@ def __init__(self, qx_min: float = 0, qx_max: float = 0, @abstractmethod def __call__(self, data2d: Data2D = None) -> Union[float, Data1D]: """ - Placeholder + TODO - add docstring """ return @@ -89,6 +118,11 @@ def validate_and_assign_data(self, data2d: Data2D = None) -> None: self.qy_data = data2d.qy_data[finite_data] self.mask_data = data2d.mask[finite_data] + # No points should have zero error, if they do then assume the error is + # the square root of the data. + self.err_data[self.err_data == 0] = \ + np.sqrt(np.abs(self.data[self.err_data == 0])) + @property def roi_mask(self): """ @@ -117,7 +151,7 @@ class PolarROI(ABC): def __init__(self, r_min: float = 0, r_max: float = 1000, phi_min: float = 0, phi_max: float = 2*np.pi) -> None: """ - Placeholder + TODO - add docstring """ # Units A^-1 for radii, radians for angles @@ -137,7 +171,7 @@ def __init__(self, r_min: float = 0, r_max: float = 1000, @abstractmethod def __call__(self, data2d: Data2D = None) -> Union[float, Data1D]: """ - Placeholder + TODO - add docstring """ return @@ -161,6 +195,11 @@ def validate_and_assign_data(self, data2d: Data2D = None) -> None: self.qy_data = data2d.qy_data[finite_data] self.mask_data = data2d.mask[finite_data] + # No points should have zero error, if they do then assume the error is + # the square root of the data. + self.err_data[self.err_data == 0] = \ + np.sqrt(np.abs(self.data[self.err_data == 0])) + class Boxsum(CartesianROI): """ @@ -183,7 +222,7 @@ def __call__(self, data2d: Data2D = None) -> float: def _sum(self) -> float: """ - Placeholder + TODO - add docstring """ # Currently the weights are binary, but could be fractional in future @@ -191,12 +230,10 @@ def _sum(self) -> float: data = weights * self.data err_squared = weights * weights * self.err_data * self.err_data - # No points should have zero error, if they do then assume the worst - err_squared[self.err_data == 0] = (weights * data)[self.err_data == 0] total_sum = np.sum(data) - total_count = np.sum(weights) total_errors_squared = np.sum(err_squared) + total_count = np.sum(weights) return total_sum, np.sqrt(total_errors_squared), total_count @@ -213,7 +250,7 @@ def __init__(self, qx_min: float = 0, qx_max: float = 0, def __call__(self, data2d: Data2D) -> float: """ - Placeholder + TODO - add docstring """ self.validate_and_assign_data(data2d) total_sum, error, count = super()._sum() @@ -238,49 +275,52 @@ def __call__(self, data2d: Data2D = None) -> Data1D: def _avg(self, data2d: Data2D, major_axis: str) -> Data1D: """ - Placeholder + TODO - add docstring """ self.validate_and_assign_data(data2d) - # TODO - change weights - weights = self.roi_mask.astype(int) - if major_axis == 'x': q_major = self.qx_data + q_minor = self.qy_data + minor_lims = (self.qy_min, self.qy_max) binning = Binning(min_value=0 if self.fold else self.qx_min, max_value=self.qx_max, nbins=self.nbins) elif major_axis == 'y': q_major = self.qy_data + q_minor = self.qx_data + minor_lims = (self.qx_min, self.qx_max) binning = Binning(min_value=0 if self.fold else self.qy_min, max_value=self.qy_max, nbins=self.nbins) else: msg = f"Unrecognised axis: {major_axis}" raise ValueError(msg) - q_values = np.zeros(self.nbins) - intensity = np.zeros(self.nbins) - errs_squared = np.zeros(self.nbins) - bin_counts = np.zeros(self.nbins) - - for index, q_value in enumerate(q_major): - # Skip over datapoints with no relevance - # This should include masked datapoints. - if weights[index] == 0: - continue - - if self.fold and q_value < 0: - q_value = -q_value - - q_bin = binning.get_index(q_value) - q_values[q_bin] += weights[index] * q_value - intensity[q_bin] += weights[index] * self.data[index] - errs_squared[q_bin] += (weights[index] * self.err_data[index]) ** 2 - # No points should have zero error, assume the worst if they do - if self.err_data[index] == 0.0: - errs_squared[q_bin] += weights[index] ** 2 * abs(self.data[index]) + if self.fold: + q_major = np.abs(q_major) + + major_weights = np.zeros((self.nbins, q_major.size)) + for m in range(self.nbins): + # Include the value at the end of the binning range, but otherwise + # use half-open intervals so each value belongs in only one bin. + if m == self.nbins - 1: + interval = 'closed' else: - errs_squared[q_bin] += (weights[index] * self.err_data[index]) ** 2 - bin_counts[q_bin] += weights[index] + interval = 'half-open' + bin_start, bin_end = binning.get_interval(bin_number=m) + major_weights[m] \ + = weights_for_interval(array=q_major, l_bound=bin_start, + u_bound=bin_end, + interval_type=interval) + minor_weights = weights_for_interval(array=q_minor, + l_bound=minor_lims[0], + u_bound=minor_lims[1], + interval_type='closed') + weights = major_weights * minor_weights + + q_values = np.sum(weights * q_major, axis=1) + intensity = np.sum(weights * self.data, axis=1) + errs_squared = np.sum((weights * self.err_data)**2, axis=1) + bin_counts = np.sum(weights, axis=1) errors = np.sqrt(errs_squared) q_values /= bin_counts From 62e85b47445028697e3002f4a364227c2518996f Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Mon, 4 Sep 2023 08:32:49 +0100 Subject: [PATCH 04/49] Refactoring to test SlabX, SlabY, SectorQ and SectorPhi rather than their respective parent classes: _Slab and _Sector (which will be removed soon) --- .../utest_averaging_analytical.py | 298 ++++++++++++------ 1 file changed, 194 insertions(+), 104 deletions(-) diff --git a/test/sasdataloader/utest_averaging_analytical.py b/test/sasdataloader/utest_averaging_analytical.py index 2d850e5b..3b2f3ee0 100644 --- a/test/sasdataloader/utest_averaging_analytical.py +++ b/test/sasdataloader/utest_averaging_analytical.py @@ -11,9 +11,8 @@ from scipy import integrate from sasdata.dataloader import data_info -from sasdata.data_util.manipulations import (CircularAverage, Ring, _Sector, - SectorQ, SectorPhi) -from sasdata.data_util.new_manipulations import _Slab, SlabX, SlabY, Boxsum, Boxavg +from sasdata.data_util.manipulations import CircularAverage, Ring, SectorQ, SectorPhi +from sasdata.data_util.new_manipulations import SlabX, SlabY, Boxsum, Boxavg class MatrixToData2D: @@ -25,11 +24,11 @@ class MatrixToData2D: """ def __init__(self, data2d=None, err_data=None): - if data2d is None: + if data2d is not None: + matrix = np.asarray(data2d) + else: msg = "Data must be supplied to convert to Data2D" raise ValueError(msg) - else: - matrix = np.asarray(data2d) if matrix.ndim != 2: msg = "Supplied array must have 2 dimensions to convert to Data2D" @@ -149,15 +148,15 @@ def area_under_region(self, r_min=0, r_max=1, phi_min=0, phi_max=2*np.pi): return calculated_area -class SlabTests(unittest.TestCase): +class SlabXTests(unittest.TestCase): """ - This class contains all the unit tests for the _Slab class from + This class contains all the unit tests for the SlabX class from manipulations.py """ - def test_slab_init(self): + def test_slabx_init(self): """ - Test that _Slab's __init__ method does what it's supposed to. + Test that SlabX's __init__ method does what it's supposed to. """ qx_min = 1 qx_max = 2 @@ -166,7 +165,7 @@ def test_slab_init(self): nbins = 100 fold = True - slab_object = _Slab(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, + slab_object = SlabX(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max, nbins=nbins, fold=fold) self.assertEqual(slab_object.qx_min, qx_min) @@ -176,9 +175,9 @@ def test_slab_init(self): self.assertEqual(slab_object.nbins, nbins) self.assertEqual(slab_object.fold, fold) - def test_slab_multiple_detectors(self): + def test_slabx_multiple_detectors(self): """ - Test that _Slab raises an error when there are multiple detectors + Test that SlabX raises an error when there are multiple detectors """ averager_data = MatrixToData2D(np.ones([100, 100])) detector1 = data_info.Detector() @@ -186,33 +185,29 @@ def test_slab_multiple_detectors(self): averager_data.data.detector.append(detector1) averager_data.data.detector.append(detector2) - slab_object = _Slab() - self.assertRaises(ValueError, slab_object._avg, averager_data.data, 'x') - - def test_slab_unknown_axis(self): - """ - Test that _Slab raises an error when given an invalid major axis - """ - averager_data = MatrixToData2D(np.ones([100, 100])) - major = 'neither_x_nor_y' - - slab_object = _Slab() - self.assertRaises(ValueError, slab_object._avg, averager_data.data, major) + slab_object = SlabX() + self.assertRaises(ValueError, slab_object, averager_data.data) - def test_slab_no_points_to_average(self): + def test_slabx_no_points_to_average(self): """ - Test _Slab raises ValueError when the ROI contains no data + Test SlabX raises ValueError when the ROI contains no data """ test_data = np.ones([100, 100]) averager_data = MatrixToData2D(data2d=test_data) - # Default params for _Slab are all zeros. Effectively, there is no ROI. - slab_object = _Slab() - self.assertRaises(ValueError, slab_object._avg, averager_data.data, 'x') + # Region of interest well outside region with data + qx_min = 2 * averager_data.qmax + qx_max = 3 * averager_data.qmax + qy_min = 2 * averager_data.qmax + qy_max = 3 * averager_data.qmax + + slab_object = SlabX(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) + self.assertRaises(ValueError, slab_object, averager_data.data) - def test_slab_averaging_x_without_fold(self): + def test_slabx_averaging_without_fold(self): """ - Test that _Slab can average correctly when x is the major axis + Test that SlabX can average correctly when x is the major axis """ matrix_size = 201 x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), @@ -230,9 +225,9 @@ def test_slab_averaging_x_without_fold(self): # Explicitly not using fold in this test fold = False - slab_object = _Slab(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, + slab_object = SlabX(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max, nbins=nbins, fold=fold) - data1d = slab_object._avg(averager_data.data, major_axis='x') + data1d = slab_object(averager_data.data) # ∫x² dx = x³ / 3 + constant. x_part_integ = (qx_max**3 - qx_min**3) / 3 @@ -246,15 +241,15 @@ def test_slab_averaging_x_without_fold(self): # TODO - also check the errors are being calculated correctly - def test_slab_averaging_y_without_fold(self): + def test_slabx_averaging_with_fold(self): """ - Test that _Slab can average correctly when y is the major axis + Test that SlabX can average correctly when x is the major axis """ matrix_size = 201 x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), np.linspace(-1, 1, matrix_size)) - # Create a distribution which is linear in x and quadratic in y - test_data = x * y**2 + # Create a distribution which is quadratic in x and linear in y + test_data = x**2 * y averager_data = MatrixToData2D(data2d=test_data) # Set up region of interest to average over - the limits are arbitrary. @@ -263,34 +258,94 @@ def test_slab_averaging_y_without_fold(self): qy_min = -0.5 * averager_data.qmax # = -0.5 qy_max = averager_data.qmax # = 1 nbins = int((qx_max - qx_min) / 2 * matrix_size) - # Explicitly not using fold in this test - fold = False + # Explicitly using fold in this test + fold = True - slab_object = _Slab(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, + slab_object = SlabX(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max, nbins=nbins, fold=fold) - data1d = slab_object._avg(averager_data.data, major_axis='y') + data1d = slab_object(averager_data.data) - # ∫x dx = x² / 2 + constant. - x_part_integ = (qx_max**2 - qx_min**2) / 2 - x_part_avg = x_part_integ / (qx_max - qx_min) # or (x_min + x_max) / 2 - # ∫y² dy = y³ / 3 + constant. - y_part_integ = (qy_max**3 - qy_min**3) / 3 - expected_area = x_part_avg * y_part_integ + # Negative values of x are not graphed when fold = True + qx_min = 0 + # ∫x² dx = x³ / 3 + constant. + x_part_integ = (qx_max**3 - qx_min**3) / 3 + # ∫y dy = y² / 2 + constant. + y_part_integ = (qy_max**2 - qy_min**2) / 2 + y_part_avg = y_part_integ / (qy_max - qy_min) + expected_area = y_part_avg * x_part_integ actual_area = integrate.simpson(data1d.y, data1d.x) self.assertAlmostEqual(actual_area, expected_area, 2) # TODO - also check the errors are being calculated correctly - def test_slab_averaging_x_with_fold(self): + +class SlabYTests(unittest.TestCase): + """ + This class contains all the unit tests for the SlabY class from + manipulations.py + """ + + def test_slaby_init(self): + """ + Test that SlabY's __init__ method does what it's supposed to. + """ + qx_min = 1 + qx_max = 2 + qy_min = 3 + qy_max = 4 + nbins = 100 + fold = True + + slab_object = SlabY(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, + qy_max=qy_max, nbins=nbins, fold=fold) + + self.assertEqual(slab_object.qx_min, qx_min) + self.assertEqual(slab_object.qx_max, qx_max) + self.assertEqual(slab_object.qy_min, qy_min) + self.assertEqual(slab_object.qy_max, qy_max) + self.assertEqual(slab_object.nbins, nbins) + self.assertEqual(slab_object.fold, fold) + + def test_slaby_multiple_detectors(self): """ - Test that _Slab can average correctly when x is the major axis + Test that SlabY raises an error when there are multiple detectors + """ + averager_data = MatrixToData2D(np.ones([100, 100])) + detector1 = data_info.Detector() + detector2 = data_info.Detector() + averager_data.data.detector.append(detector1) + averager_data.data.detector.append(detector2) + + slab_object = SlabY() + self.assertRaises(ValueError, slab_object, averager_data.data) + + def test_slaby_no_points_to_average(self): + """ + Test SlabY raises ValueError when the ROI contains no data + """ + test_data = np.ones([100, 100]) + averager_data = MatrixToData2D(data2d=test_data) + + # Region of interest well outside region with data + qx_min = 2 * averager_data.qmax + qx_max = 3 * averager_data.qmax + qy_min = 2 * averager_data.qmax + qy_max = 3 * averager_data.qmax + + slab_object = SlabY(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) + self.assertRaises(ValueError, slab_object, averager_data.data) + + def test_slaby_averaging_without_fold(self): + """ + Test that SlabY can average correctly when y is the major axis """ matrix_size = 201 x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), np.linspace(-1, 1, matrix_size)) - # Create a distribution which is quadratic in x and linear in y - test_data = x**2 * y + # Create a distribution which is linear in x and quadratic in y + test_data = x * y**2 averager_data = MatrixToData2D(data2d=test_data) # Set up region of interest to average over - the limits are arbitrary. @@ -299,21 +354,19 @@ def test_slab_averaging_x_with_fold(self): qy_min = -0.5 * averager_data.qmax # = -0.5 qy_max = averager_data.qmax # = 1 nbins = int((qx_max - qx_min) / 2 * matrix_size) - # Explicitly using fold in this test - fold = True + # Explicitly not using fold in this test + fold = False - slab_object = _Slab(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, + slab_object = SlabY(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max, nbins=nbins, fold=fold) - data1d = slab_object._avg(averager_data.data, major_axis='x') + data1d = slab_object(averager_data.data) - # Negative values of x are not graphed when fold = True - qx_min = 0 - # ∫x² dx = x³ / 3 + constant. - x_part_integ = (qx_max**3 - qx_min**3) / 3 - # ∫y dy = y² / 2 + constant. - y_part_integ = (qy_max**2 - qy_min**2) / 2 - y_part_avg = y_part_integ / (qy_max - qy_min) - expected_area = y_part_avg * x_part_integ + # ∫x dx = x² / 2 + constant. + x_part_integ = (qx_max**2 - qx_min**2) / 2 + x_part_avg = x_part_integ / (qx_max - qx_min) # or (x_min + x_max) / 2 + # ∫y² dy = y³ / 3 + constant. + y_part_integ = (qy_max**3 - qy_min**3) / 3 + expected_area = x_part_avg * y_part_integ actual_area = integrate.simpson(data1d.y, data1d.x) self.assertAlmostEqual(actual_area, expected_area, 2) @@ -322,7 +375,7 @@ def test_slab_averaging_x_with_fold(self): def test_slab_averaging_y_with_fold(self): """ - Test that _Slab can average correctly when y is the major axis + Test that SlabY can average correctly when y is the major axis """ matrix_size = 201 x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), @@ -340,9 +393,9 @@ def test_slab_averaging_y_with_fold(self): # Explicitly using fold in this test fold = True - slab_object = _Slab(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, + slab_object = SlabY(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max, nbins=nbins, fold=fold) - data1d = slab_object._avg(averager_data.data, major_axis='y') + data1d = slab_object(averager_data.data) # Negative values of y are not graphed when fold = True, so don't # include them in the area calculation. @@ -713,6 +766,7 @@ def test_ring_init(self): self.assertEqual(ring_object.r_min, r_min) self.assertEqual(ring_object.r_max, r_max) + # TODO - replace nbins_phi with nbins for consitency self.assertEqual(ring_object.nbins_phi, nbins) def test_ring_non_plottable_data(self): @@ -763,7 +817,7 @@ def test_ring_averages_azimuthally(self): # TODO - also check the errors are being calculated correctly -class SectorTests(unittest.TestCase): +class SectorQTests(unittest.TestCase): """ This class contains the tests for the _Sector class from manipulations.py On the sasview side, this includes SectorSlicer and WedgeSlicer. @@ -772,9 +826,9 @@ class SectorTests(unittest.TestCase): arbitrary, and the tests should pass if any sane value is used for them. """ - def test_sector_init(self): + def test_sectorq_init(self): """ - Test that _Sector's __init__ method does what it's supposed to. + Test that SectorQ's __init__ method does what it's supposed to. """ r_min = 1 r_max = 2 @@ -783,7 +837,7 @@ def test_sector_init(self): nbins = 100 base = 10 - sector_object = _Sector(r_min=r_min, r_max=r_max, phi_min=phi_min, + sector_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min, phi_max=phi_max, nbins=nbins, base=base) self.assertEqual(sector_object.r_min, r_min) @@ -793,44 +847,16 @@ def test_sector_init(self): self.assertEqual(sector_object.nbins, nbins) self.assertEqual(sector_object.base, base) - def test_sector_non_plottable_data(self): + def test_sectorq_non_plottable_data(self): """ Test that RuntimeError is raised if the data supplied isn't plottable """ # Implementing this test can wait pass - def test_sector_phi_averaging(self): - """ - Test _Sector can average correctly with a major axis of phi, when all - of min/max r & phi params are specified and have their expected form. + def test_sectorq_averaging_without_fold(self): """ - test_data = CircularTestingMatrix(frequency=1, matrix_size=201, - major_axis='Phi') - averager_data = MatrixToData2D(test_data.matrix) - - r_min = 0.1 * averager_data.qmax - r_max = 0.9 * averager_data.qmax - phi_min = np.pi/6 - phi_max = 5*np.pi/6 - nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - - wedge_object = SectorPhi(r_min=r_min, r_max=r_max, phi_min=phi_min + np.pi, - phi_max=phi_max + np.pi, nbins=nbins) - data1d = wedge_object(averager_data.data) - - expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, - phi_min=phi_min, - phi_max=phi_max) - actual_area = integrate.simpson(data1d.y, data1d.x) - - self.assertAlmostEqual(actual_area, expected_area, 1) - - # TODO - Something is very wrong with this test - - def test_sector_q_averaging_without_fold(self): - """ - Test _Sector can average correctly w/ major axis q and fold disabled. + Test SectorQ can average correctly w/ major axis q and fold disabled. All min/max r & phi params are specified and have their expected form. """ test_data = CircularTestingMatrix(frequency=1, matrix_size=201, @@ -862,9 +888,9 @@ def test_sector_q_averaging_without_fold(self): self.assertAlmostEqual(actual_area, expected_area, 1) - def test_sector_q_averaging_with_fold(self): + def test_sectorq_averaging_with_fold(self): """ - Test _Sector can average correctly w/ major axis q and fold enabled. + Test SectorQ can average correctly w/ major axis q and fold enabled. All min/max r & phi params are specified and have their expected form. """ test_data = CircularTestingMatrix(frequency=1, matrix_size=201, @@ -899,5 +925,69 @@ def test_sector_q_averaging_with_fold(self): self.assertAlmostEqual(actual_area, expected_area, 1) +class SectorPhiTests(unittest.TestCase): + """ + This class contains the tests for the SectorPhi class from manipulations.py + On the sasview side, this includes SectorSlicer and WedgeSlicer. + + The parameters frequency, r_min, r_max, phi_min and phi_max are largely + arbitrary, and the tests should pass if any sane value is used for them. + """ + + def test_sectorphi_init(self): + """ + Test that SectorPhi's __init__ method does what it's supposed to. + """ + r_min = 1 + r_max = 2 + phi_min = 0 + phi_max = np.pi + nbins = 100 + base = 10 + + sector_object = SectorPhi(r_min=r_min, r_max=r_max, phi_min=phi_min, + phi_max=phi_max, nbins=nbins, base=base) + + self.assertEqual(sector_object.r_min, r_min) + self.assertEqual(sector_object.r_max, r_max) + self.assertEqual(sector_object.phi_min, phi_min) + self.assertEqual(sector_object.phi_max, phi_max) + self.assertEqual(sector_object.nbins, nbins) + self.assertEqual(sector_object.base, base) + + def test_sectorphi_non_plottable_data(self): + """ + Test that RuntimeError is raised if the data supplied isn't plottable + """ + # Implementing this test can wait + pass + + def test_sectorphi_averaging(self): + """ + Test _Sector can average correctly with a major axis of phi, when all + of min/max r & phi params are specified and have their expected form. + """ + test_data = CircularTestingMatrix(frequency=1, matrix_size=201, + major_axis='Phi') + averager_data = MatrixToData2D(test_data.matrix) + + r_min = 0.1 * averager_data.qmax + r_max = 0.9 * averager_data.qmax + phi_min = np.pi/6 + phi_max = 5*np.pi/6 + nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable + + wedge_object = SectorPhi(r_min=r_min, r_max=r_max, phi_min=phi_min + np.pi, + phi_max=phi_max + np.pi, nbins=nbins) + data1d = wedge_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min, + phi_max=phi_max) + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 1) + + if __name__ == '__main__': unittest.main() From a0f365cebdbd8683175f7a9c865c9039e50e7032 Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Thu, 7 Sep 2023 11:21:11 +0100 Subject: [PATCH 05/49] Restructured ROI classes and added DirectionalAverage class, which offers a generalised method of calculating the directional average. All the averagers from the old manipulations.py have been rewritten to use the DirecitonalAverage class. --- sasdata/data_util/new_manipulations.py | 584 +++++++++++++++++-------- 1 file changed, 413 insertions(+), 171 deletions(-) diff --git a/sasdata/data_util/new_manipulations.py b/sasdata/data_util/new_manipulations.py index 82caf577..b5ee734a 100644 --- a/sasdata/data_util/new_manipulations.py +++ b/sasdata/data_util/new_manipulations.py @@ -1,6 +1,3 @@ -from abc import ABC, abstractmethod -from typing import Union - import numpy as np from sasdata.dataloader.data_info import Data1D, Data2D @@ -13,6 +10,8 @@ def weights_for_interval(array, l_bound, u_bound, interval_type='half-open'): fractional values instead. These will depend on how close the array value is to being within the interval defined. """ + # These checks could be modified to return fractional bin weights. + # The last value in the binning range must be included for to pass utests if interval_type == 'half-open': in_range = (l_bound <= array) & (array < u_bound) elif interval_type == 'closed': @@ -24,82 +23,129 @@ def weights_for_interval(array, l_bound, u_bound, interval_type='half-open'): return np.asarray(in_range, dtype=int) -class Binning: +class DirectionalAverage: """ - TODO - add docstring + TODO - write a docstring """ - def __init__(self, min_value, max_value, nbins, base=None): + def __init__(self, major_data, minor_data, major_lims=None, + minor_lims=None, nbins=100): """ """ - self.minimum = min_value - self.maximum = max_value + if any(not isinstance(data, (list, np.ndarray)) for + data in (major_data, minor_data)): + msg = "Must provide major & minor coordinate arrays for binning." + raise ValueError(msg) + if any(lims is not None and len(lims) != 2 for + lims in (major_lims, minor_lims)): + msg = "Limits arrays must have 2 elements or be NoneType" + raise ValueError(msg) + if not isinstance(nbins, int): + msg = "Parameter 'nbins' must be an integer" + raise TypeError(msg) + + self.major_data = np.asarray(major_data) + self.minor_data = np.asarray(minor_data) + if major_lims is None: + self.major_lims = (self.major_data.min(), self.major_data.max()) + else: + self.major_lims = major_lims + if minor_lims is None: + self.minor_lims = (self.minor_data.min(), self.minor_data.max()) + else: + self.minor_lims = minor_lims self.nbins = nbins - self.base = base - self.bin_width = (max_value - min_value) / nbins - def get_index(self, value: float) -> int: + @property + def bin_width(self): """ + Return the bin width """ - if self.base: - numerator = (np.log(value) - np.log(self.minimum)) \ - / np.log(self.base) - denominator = (np.log(self.maximum) - np.log(self.minimum)) \ - / np.log(self.base) - else: - numerator = value - self.minimum - denominator = self.maximum - self.minimum + return (self.major_lims[1] - self.major_lims[0]) / self.nbins + + def get_bin_interval(self, bin_number): + """ + Return the upper and lower limits defining a given bin + """ + bin_start = self.major_lims[0] + bin_number * self.bin_width + bin_end = self.major_lims[0] + (bin_number + 1) * self.bin_width + + return bin_start, bin_end + def get_bin_index(self, value): + """ + """ + numerator = value - self.major_lims[0] + denominator = self.major_lims[1] - self.major_lims[0] bin_index = int(np.floor(self.nbins * numerator / denominator)) - # Bins are indexed from 0 to nbins-1, so this check protects against - # out-of-range indices when value == maximum. + # Bins are indexed from 0 to nbins-1, so tihs check protects against + # out-of-range indices when value == self.major_lims[1] if bin_index == self.nbins: bin_index -= 1 return bin_index - def get_interval(self, bin_number: int) -> float: + def compute_weights(self): """ """ - start = self.minimum + self.bin_width * bin_number - stop = self.minimum + self.bin_width * (bin_number + 1) + major_weights = np.zeros((self.nbins, self.major_data.size)) + for m in range(self.nbins): + # Include the value at the end of the binning range, but in + # general use half-open intervals so each value begins in only + # one bin. + if m == self.nbins - 1: + interval = 'closed' + else: + interval = 'half-open' + bin_start, bin_end = self.get_bin_interval(bin_number=m) + major_weights[m] = weights_for_interval(array=self.major_data, + l_bound=bin_start, + u_bound=bin_end, + interval_type=interval) + minor_weights = weights_for_interval(array=self.minor_data, + l_bound=self.minor_lims[0], + u_bound=self.minor_lims[1], + interval_type='closed') + return major_weights * minor_weights - return start, stop + def __call__(self, data, err_data): + """ + """ + weights = self.compute_weights() + x_axis_values = np.sum(weights * self.major_data, axis=1) + intensity = np.sum(weights * data, axis=1) + errs_squared = np.sum((weights * err_data)**2, axis=1) + bin_counts = np.sum(weights, axis=1) -class CartesianROI(ABC): + errors = np.sqrt(errs_squared) + x_axis_values /= bin_counts + intensity /= bin_counts + errors /= bin_counts + + finite = (np.isfinite(x_axis_values) & np.isfinite(intensity)) + if not finite.any(): + msg = "Average Error: No points inside ROI to average..." + raise ValueError(msg) + + return x_axis_values[finite], intensity[finite], errors[finite] + + +class GenericROI: """ - Base class for manipulators with a rectangular region of interest. + TODO - add docstring """ - def __init__(self, qx_min: float = 0, qx_max: float = 0, - qy_min: float = 0, qy_max: float = 0) -> None: + def __init__(self): """ - TODO - add docstring """ - - # Units A^-1 - self.qx_min = qx_min - self.qx_max = qx_max - self.qy_min = qy_min - self.qy_max = qy_max - - # Define data related variables self.data = None self.err_data = None + self.q_data = None self.qx_data = None self.qy_data = None - self.mask_data = None - - @abstractmethod - def __call__(self, data2d: Data2D = None) -> Union[float, Data1D]: - """ - TODO - add docstring - """ - return - # This method might be better placed in a parent class def validate_and_assign_data(self, data2d: Data2D = None) -> None: """ Check that the data supplied valid and assign data variables. @@ -111,94 +157,70 @@ def validate_and_assign_data(self, data2d: Data2D = None) -> None: msg = f"Invalid number of detectors: {len(data2d.detector)}" raise ValueError(msg) - finite_data = np.isfinite(data2d.data) - self.data = data2d.data[finite_data] - self.err_data = data2d.err_data[finite_data] - self.qx_data = data2d.qx_data[finite_data] - self.qy_data = data2d.qy_data[finite_data] - self.mask_data = data2d.mask[finite_data] + # Only use data which is finite and not masked off + valid_data = np.isfinite(data2d.data) & data2d.mask + + self.data = data2d.data[valid_data] + self.err_data = data2d.err_data[valid_data] + self.q_data = data2d.q_data[valid_data] + self.qx_data = data2d.qx_data[valid_data] + self.qy_data = data2d.qy_data[valid_data] # No points should have zero error, if they do then assume the error is - # the square root of the data. + # the square root of the data. This code was added to replicate + # previous functionality. It's a bit dodgy, so feel free to remove. self.err_data[self.err_data == 0] = \ np.sqrt(np.abs(self.data[self.err_data == 0])) - @property - def roi_mask(self): + +class CartesianROI(GenericROI): + """ + Base class for manipulators with a rectangular region of interest. + """ + + def __init__(self, qx_min: float = 0, qx_max: float = 0, + qy_min: float = 0, qy_max: float = 0) -> None: """ - Return a boolean array listing the elements of self.data which are - inside the ROI. This property should only be accessed after - CartesianROI has been called. + TODO - add docstring """ - if any(data is None for data in [self.qx_data, self.qy_data, - self.mask_data]): - raise RuntimeError - - within_x_lims = (self.qx_data >= self.qx_min) & \ - (self.qx_data <= self.qx_max) - within_y_lims = (self.qy_data >= self.qy_min) & \ - (self.qy_data <= self.qy_max) - # Don't return masked-off data - return within_x_lims & within_y_lims & self.mask_data + super().__init__() + # Units A^-1 + self.qx_min = qx_min + self.qx_max = qx_max + self.qy_min = qy_min + self.qy_max = qy_max -class PolarROI(ABC): +class PolarROI(GenericROI): """ Base class for manipulators whose ROI is defined with polar coordinates. """ - def __init__(self, r_min: float = 0, r_max: float = 1000, + def __init__(self, r_min: float, r_max: float, phi_min: float = 0, phi_max: float = 2*np.pi) -> None: """ TODO - add docstring """ + super().__init__() + self.phi_data = None + + if r_min >= r_max: + msg = "Minimum radius cannot be greater than maximum radius." + raise ValueError(msg) # Units A^-1 for radii, radians for angles self.r_min = r_min self.r_max = r_max self.phi_min = phi_min self.phi_max = phi_max - # Define data related variables - self.data = None - self.err_data = None - self.q_data = None - self.qx_data = None - self.qy_data = None - self.mask_data = None - - @abstractmethod - def __call__(self, data2d: Data2D = None) -> Union[float, Data1D]: - """ - TODO - add docstring - """ - return - - # This method might be better placed in a parent class def validate_and_assign_data(self, data2d: Data2D = None) -> None: """ Check that the data supplied valid and assign data variables. """ - if not isinstance(data2d, Data2D): - msg = "Data supplied must be of type Data2D." - raise TypeError(msg) - if len(data2d.detector) > 1: - msg = f"Invalid number of detectors: {len(data2d.detector)}" - raise ValueError(msg) - - finite_data = np.isfinite(data2d.data) - self.data = data2d.data[finite_data] - self.err_data = data2d.err_data[finite_data] - self.q_data = data2d.q_data[finite_data] - self.qx_data = data2d.qx_data[finite_data] - self.qy_data = data2d.qy_data[finite_data] - self.mask_data = data2d.mask[finite_data] - - # No points should have zero error, if they do then assume the error is - # the square root of the data. - self.err_data[self.err_data == 0] = \ - np.sqrt(np.abs(self.data[self.err_data == 0])) + super().validate_and_assign_data(data2d) + self.phi_data = np.arctan2(self.qy_data, self.qx_data) class Boxsum(CartesianROI): @@ -208,12 +230,15 @@ class Boxsum(CartesianROI): def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, qy_max: float = 0) -> None: + """ + TODO - add docstring + """ super().__init__(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max) def __call__(self, data2d: Data2D = None) -> float: """ - Placeholder + TODO - add docstring """ self.validate_and_assign_data(data2d) total_sum, error, count = self._sum() @@ -226,7 +251,15 @@ def _sum(self) -> float: """ # Currently the weights are binary, but could be fractional in future - weights = self.roi_mask.astype(int) + x_weights = weights_for_interval(array=self.qx_data, + l_bound=self.qx_min, + u_bound=self.qx_max, + interval_type='closed') + y_weights = weights_for_interval(array=self.qy_data, + l_bound=self.qy_min, + u_bound=self.qy_max, + interval_type='closed') + weights = x_weights * y_weights data = weights * self.data err_squared = weights * weights * self.err_data * self.err_data @@ -245,6 +278,9 @@ class Boxavg(Boxsum): def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, qy_max: float = 0) -> None: + """ + TODO - add docstring + """ super().__init__(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max) @@ -258,107 +294,313 @@ def __call__(self, data2d: Data2D) -> float: return (total_sum / count), (error / count) -class _Slab(CartesianROI): +class SlabX(CartesianROI): """ - Compute average I(Q) for a region of interest + Compute average I(Qx) for a region of interest """ def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, qy_max: float = 0, nbins: int = 100, fold: bool = False): + """ + TODO - add docstring + """ super().__init__(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max) self.nbins = nbins self.fold = fold def __call__(self, data2d: Data2D = None) -> Data1D: - pass + """ + Compute average I(Qx) for a region of interest + :param data2d: Data2D object + :return: Data1D object + """ + self.validate_and_assign_data(data2d) + + if self.fold: + major_lims = (0, self.qx_max) + self.qx_data = np.abs(self.qx_data) + else: + major_lims = (self.qx_min, self.qx_max) + minor_lims = (self.qy_min, self.qy_max) + + directional_average = DirectionalAverage(major_data=self.qx_data, + minor_data=self.qy_data, + major_lims=major_lims, + minor_lims=minor_lims, + nbins=self.nbins) + qx_data, intensity, error = \ + directional_average(data=self.data, err_data=self.err_data) + + return Data1D(x=qx_data, y=intensity, dy=error) + + +class SlabY(CartesianROI): + """ + Compute average I(Qy) for a region of interest + """ - def _avg(self, data2d: Data2D, major_axis: str) -> Data1D: + def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, + qy_max: float = 0, nbins: int = 100, fold: bool = False): """ TODO - add docstring """ + super().__init__(qx_min=qx_min, qx_max=qx_max, + qy_min=qy_min, qy_max=qy_max) + self.nbins = nbins + self.fold = fold + + def __call__(self, data2d: Data2D = None) -> Data1D: + """ + Compute average I(Qy) for a region of interest + :param data2d: Data2D object + :return: Data1D object + """ self.validate_and_assign_data(data2d) - if major_axis == 'x': - q_major = self.qx_data - q_minor = self.qy_data - minor_lims = (self.qy_min, self.qy_max) - binning = Binning(min_value=0 if self.fold else self.qx_min, - max_value=self.qx_max, nbins=self.nbins) - elif major_axis == 'y': - q_major = self.qy_data - q_minor = self.qx_data - minor_lims = (self.qx_min, self.qx_max) - binning = Binning(min_value=0 if self.fold else self.qy_min, - max_value=self.qy_max, nbins=self.nbins) + if self.fold: + major_lims = (0, self.qy_max) + self.qy_data = np.abs(self.qy_data) else: - msg = f"Unrecognised axis: {major_axis}" - raise ValueError(msg) + major_lims = (self.qy_min, self.qy_max) + minor_lims = (self.qx_min, self.qx_max) - if self.fold: - q_major = np.abs(q_major) + directional_average = DirectionalAverage(major_data=self.qy_data, + minor_data=self.qx_data, + major_lims=major_lims, + minor_lims=minor_lims, + nbins=self.nbins) + qy_data, intensity, error = \ + directional_average(data=self.data, err_data=self.err_data) - major_weights = np.zeros((self.nbins, q_major.size)) - for m in range(self.nbins): - # Include the value at the end of the binning range, but otherwise - # use half-open intervals so each value belongs in only one bin. - if m == self.nbins - 1: - interval = 'closed' - else: - interval = 'half-open' - bin_start, bin_end = binning.get_interval(bin_number=m) - major_weights[m] \ - = weights_for_interval(array=q_major, l_bound=bin_start, - u_bound=bin_end, - interval_type=interval) - minor_weights = weights_for_interval(array=q_minor, - l_bound=minor_lims[0], - u_bound=minor_lims[1], - interval_type='closed') - weights = major_weights * minor_weights + return Data1D(x=qy_data, y=intensity, dy=error) - q_values = np.sum(weights * q_major, axis=1) - intensity = np.sum(weights * self.data, axis=1) - errs_squared = np.sum((weights * self.err_data)**2, axis=1) - bin_counts = np.sum(weights, axis=1) - errors = np.sqrt(errs_squared) - q_values /= bin_counts - intensity /= bin_counts - errors /= bin_counts +class CircularAverage(PolarROI): + """ + Perform circular averaging on 2D data - finite = (np.isfinite(q_values) & np.isfinite(intensity)) - if not finite.any(): - msg = "Average Error: No points inside ROI to average..." - raise ValueError(msg) + The data returned is the distribution of counts + as a function of Q + """ + + def __init__(self, r_min: float, r_max: float, nbins: int = 100) -> None: + """ + TODO - add docstring + """ + super().__init__(r_min=r_min, r_max=r_max, phi_min=0, phi_max=2*np.pi) + self.nbins = nbins + + def __call__(self, data2d: Data2D = None) -> Data1D: + """ + TODO - add docstring + """ + self.validate_and_assign_data(data2d) + + # Averaging takes place between radial limits + major_lims = (self.r_min, self.r_max) + # Average over the full angular range + directional_average = DirectionalAverage(major_data=self.q_data, + minor_data=self.phi_data, + major_lims=major_lims, + minor_lims=None, + nbins=self.nbins) + q_data, intensity, error = \ + directional_average(data=self.data, err_data=self.err_data) - return Data1D(x=q_values[finite], y=intensity[finite], dy=errors[finite]) + return Data1D(x=q_data, y=intensity, dy=error) -class SlabX(_Slab): +class Ring(PolarROI): """ - Compute average I(Qx) for a region of interest + Defines a ring on a 2D data set. + The ring is defined by r_min, r_max, and + the position of the center of the ring. + + The data returned is the distribution of counts + around the ring as a function of phi. + + Phi_min and phi_max should be defined between 0 and 2*pi + in anti-clockwise starting from the x- axis on the left-hand side """ + def __init__(self, r_min: float, r_max: float, nbins: int = 100) -> None: + """ + TODO - add docstring + """ + super().__init__(r_min=r_min, r_max=r_max, phi_min=0, phi_max=2*np.pi) + self.nbins = nbins + def __call__(self, data2d: Data2D = None) -> Data1D: """ - Compute average I(Qx) for a region of interest - :param data2d: Data2D object - :return: Data1D object + TODO - add docstring """ - return self._avg(data2d, 'x') + self.validate_and_assign_data(data2d) + # Averaging takes place between radial limits + minor_lims = (self.r_min, self.r_max) + # Average over the full angular range + directional_average = DirectionalAverage(major_data=self.phi_data, + minor_data=self.q_data, + major_lims=None, + minor_lims=minor_lims, + nbins=self.nbins) + phi_data, intensity, error = \ + directional_average(data=self.data, err_data=self.err_data) -class SlabY(_Slab): + return Data1D(x=phi_data, y=intensity, dy=error) + + +class SectorQ(PolarROI): """ - Compute average I(Qy) for a region of interest + Sector average as a function of Q for both wings. setting the _Sector.fold + attribute determines whether or not the two sectors are averaged together + (folded over) or separate. In the case of separate (not folded), the + qs for the "minor wing" are arbitrarily set to a negative value. + I(Q) is returned and the data is averaged over phi. + + A sector is defined by r_min, r_max, phi_min, phi_max. + where r_min, r_max, phi_min, phi_max >0. + The number of bins in Q also has to be defined. """ + def __init__(self, r_min: float, r_max: float, phi_min: float, + phi_max: float, nbins: int = 100, fold: bool = True) -> None: + """ + """ + super().__init__(r_min=r_min, r_max=r_max, + phi_min=phi_min, phi_max=phi_max) + self.nbins = nbins + self.fold = fold + def __call__(self, data2d: Data2D = None) -> Data1D: """ - Compute average I(Qy) for a region of interest - :param data2d: Data2D object - :return: Data1D object """ - return self._avg(data2d, 'y') + self.validate_and_assign_data(data2d) + + # Transform all angles to the range [0,2π), where phi_min is at zero. + # We won't need to convert back later because we're plotting against Q. + phi_offset = self.phi_min + self.phi_min = 0.0 + self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) + self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) + + major_lims = (self.r_min, self.r_max) + minor_lims = (self.phi_min, self.phi_max) + # Secondary region of interest covers angles on opposite side of origin + minor_lims_alt = (self.phi_min + np.pi, self.phi_max + np.pi) + + primary_region = DirectionalAverage(major_data=self.q_data, + minor_data=self.phi_data, + major_lims=major_lims, + minor_lims=minor_lims, + nbins=self.nbins) + secondary_region = DirectionalAverage(major_data=self.q_data, + minor_data=self.phi_data, + major_lims=major_lims, + minor_lims=minor_lims_alt, + nbins=self.nbins) + + primary_q, primary_I, primary_err = \ + primary_region(data=self.data, err_data=self.err_data) + secondary_q, secondary_I, secondary_err = \ + secondary_region(data=self.data, err_data=self.err_data) + + if self.fold: + # Combining the two regions requires re-binning; the q value + # arrays may be unequal lengths, or the indices may correspond to + # different q values. To average the results from >2 ROIs you would + # need to generalise this process. + combined_q = np.zeros(self.nbins) + average_intensity = np.zeros(self.nbins) + combined_err = np.zeros(self.nbins) + bin_counts = np.zeros(self.nbins) + for old_index, q_val in enumerate(primary_q): + old_index = int(old_index) + new_index = primary_region.get_bin_index(q_val) + combined_q[new_index] += q_val + average_intensity[new_index] += primary_I[old_index] + combined_err[new_index] += primary_err[old_index] ** 2 + bin_counts[new_index] += 1 + for old_index, q_val in enumerate(secondary_q): + old_index = int(old_index) + new_index = secondary_region.get_bin_index(q_val) + combined_q[new_index] += q_val + average_intensity[new_index] += secondary_I[old_index] + combined_err[new_index] += secondary_err[old_index] ** 2 + bin_counts[new_index] += 1 + + combined_q /= bin_counts + average_intensity /= bin_counts + combined_err = np.sqrt(combined_err) / bin_counts + + finite = (np.isfinite(combined_q) & np.isfinite(average_intensity)) + + data1d = Data1D(x=combined_q[finite], y=average_intensity[finite], + dy=combined_err[finite]) + else: + combined_q = np.append(np.flip(-1 * secondary_q), primary_q) + combined_intensity = np.append(np.flip(secondary_I), primary_I) + combined_error = np.append(np.flip(secondary_err), primary_err) + data1d = Data1D(x=combined_q, y=combined_intensity, + dy=combined_error) + + return data1d + + +class SectorPhi(PolarROI): + """ + Sector average as a function of phi. + I(phi) is return and the data is averaged over Q. + + A sector is defined by r_min, r_max, phi_min, phi_max. + The number of bin in phi also has to be defined. + """ + + def __init__(self, r_min: float, r_max: float, phi_min: float, + phi_max: float, nbins: int = 100) -> None: + """ + TODO - add docstring + """ + super().__init__(r_min=r_min, r_max=r_max, + phi_min=phi_min, phi_max=phi_max) + self.nbins = nbins + + def __call__(self, data2d: Data2D = None) -> Data1D: + """ + TODO - add docstring + """ + self.validate_and_assign_data(data2d) + + # Transform all angles to the range [0,2π), where phi_min is at zero. + # Remember to transform back afterwards + phi_offset = self.phi_min + self.phi_min = 0.0 + self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) + self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) + + # Averaging takes place between angular and radial limits + # When phi_max and phi_min have the same angle, ROI is a full circle. + if self.phi_max == 0: + major_lims = None + else: + major_lims = (self.phi_min, self.phi_max) + minor_lims = (self.r_min, self.r_max) + + directional_average = DirectionalAverage(major_data=self.phi_data, + minor_data=self.q_data, + major_lims=major_lims, + minor_lims=minor_lims, + nbins=self.nbins) + phi_data, intensity, error = \ + directional_average(data=self.data, err_data=self.err_data) + + # Convert angular data back to the original phi range + phi_data += phi_offset + # In the old manipulations.py, we also had this shift to plot the data + # at the centre of the bins. I'm not sure why it's only angular binning + # which gets this treatment. + phi_data += directional_average.bin_width / 2 + + return Data1D(x=phi_data, y=intensity, dy=error) From 7b6188a4f09478b99c4bc1e39d14ba6e34040e72 Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Thu, 7 Sep 2023 11:21:49 +0100 Subject: [PATCH 06/49] Updated the unit tests to suit the new_manipulations.py implementation. --- .../utest_averaging_analytical.py | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/test/sasdataloader/utest_averaging_analytical.py b/test/sasdataloader/utest_averaging_analytical.py index 3b2f3ee0..6b424000 100644 --- a/test/sasdataloader/utest_averaging_analytical.py +++ b/test/sasdataloader/utest_averaging_analytical.py @@ -11,8 +11,9 @@ from scipy import integrate from sasdata.dataloader import data_info -from sasdata.data_util.manipulations import CircularAverage, Ring, SectorQ, SectorPhi -from sasdata.data_util.new_manipulations import SlabX, SlabY, Boxsum, Boxavg +from sasdata.data_util.new_manipulations import (SlabX, SlabY, Boxsum, Boxavg, + CircularAverage, Ring, + SectorQ, SectorPhi) class MatrixToData2D: @@ -655,18 +656,13 @@ def test_circularaverage_init(self): """ r_min = 1 r_max = 2 - bin_width = 0.001 - # nbins = 100 + nbins = 100 - circ_object = CircularAverage(r_min=r_min, r_max=r_max, - bin_width=bin_width) - # circ_object = CircularAverage(r_min=r_min, r_max=r_max, - # nbins=nbins) + circ_object = CircularAverage(r_min=r_min, r_max=r_max, nbins=nbins) self.assertEqual(circ_object.r_min, r_min) self.assertEqual(circ_object.r_max, r_max) - self.assertEqual(circ_object.bin_width, bin_width) - # self.assertEqual(circ_object.nbins, nbins) + self.assertEqual(circ_object.nbins, nbins) def test_circularaverage_dq_retrieval(self): """ @@ -705,11 +701,7 @@ def test_circularaverage_check_valid_radii(self): """ Test that CircularAverage raises ValueError when r_min > r_max """ - test_data = np.ones([100, 100]) - averager_data = MatrixToData2D(test_data) - - circ_object = CircularAverage(r_min=0.1, r_max=0.05) - self.assertRaises(ValueError, circ_object, averager_data.data) + self.assertRaises(ValueError, CircularAverage, r_min=0.1, r_max=0.05) def test_circularaverage_no_points_to_average(self): """ @@ -727,20 +719,25 @@ def test_circularaverage_averages_circularly(self): """ Test that CircularAverage can calculate a circular average correctly. """ - test_data = CircularTestingMatrix(frequency=2, major_axis='Q') + test_data = CircularTestingMatrix(frequency=2, matrix_size=201, + major_axis='Q') averager_data = MatrixToData2D(test_data.matrix) # Test the ability to average over a subsection of the data r_min = averager_data.qmax * 0.25 r_max = averager_data.qmax * 0.75 - circ_object = CircularAverage(r_min=r_min, r_max=r_max) + nbins = test_data.matrix_size + circ_object = CircularAverage(r_min=r_min, r_max=r_max, nbins=nbins) data1d = circ_object(averager_data.data) expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max) actual_area = integrate.trapezoid(data1d.y, data1d.x) - self.assertAlmostEqual(actual_area, expected_area, 3) + # This used to be able to pass with a precision of 3 d.p. with the old + # manipulations.py - I'm not sure why it doesn't anymore. + # This is still a good level of precision compared to the others though + self.assertAlmostEqual(actual_area, expected_area, 2) # TODO - also check the errors are being calculated correctly @@ -766,8 +763,7 @@ def test_ring_init(self): self.assertEqual(ring_object.r_min, r_min) self.assertEqual(ring_object.r_max, r_max) - # TODO - replace nbins_phi with nbins for consitency - self.assertEqual(ring_object.nbins_phi, nbins) + self.assertEqual(ring_object.nbins, nbins) def test_ring_non_plottable_data(self): """ @@ -804,7 +800,7 @@ def test_ring_averages_azimuthally(self): # Test the ability to average over a subsection of the data r_min = 0.25 * averager_data.qmax r_max = 0.75 * averager_data.qmax - nbins = int(test_data.matrix_size / 2) + nbins = test_data.matrix_size // 2 ring_object = Ring(r_min=r_min, r_max=r_max, nbins=nbins) data1d = ring_object(averager_data.data) @@ -835,17 +831,19 @@ def test_sectorq_init(self): phi_min = 0 phi_max = np.pi nbins = 100 - base = 10 + # base = 10 + # sector_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min, + # phi_max=phi_max, nbins=nbins, base=base) sector_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min, - phi_max=phi_max, nbins=nbins, base=base) + phi_max=phi_max, nbins=nbins) self.assertEqual(sector_object.r_min, r_min) self.assertEqual(sector_object.r_max, r_max) self.assertEqual(sector_object.phi_min, phi_min) self.assertEqual(sector_object.phi_max, phi_max) self.assertEqual(sector_object.nbins, nbins) - self.assertEqual(sector_object.base, base) + # self.assertEqual(sector_object.base, base) def test_sectorq_non_plottable_data(self): """ @@ -869,8 +867,8 @@ def test_sectorq_averaging_without_fold(self): phi_max = 5*np.pi/6 nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - wedge_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min + np.pi, - phi_max=phi_max + np.pi, nbins=nbins) + wedge_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min, + phi_max=phi_max, nbins=nbins) # Explicitly set fold to False - results span full +/- range wedge_object.fold = False data1d = wedge_object(averager_data.data) @@ -943,17 +941,19 @@ def test_sectorphi_init(self): phi_min = 0 phi_max = np.pi nbins = 100 - base = 10 + # base = 10 + # sector_object = SectorPhi(r_min=r_min, r_max=r_max, phi_min=phi_min, + # phi_max=phi_max, nbins=nbins, base=base) sector_object = SectorPhi(r_min=r_min, r_max=r_max, phi_min=phi_min, - phi_max=phi_max, nbins=nbins, base=base) + phi_max=phi_max, nbins=nbins) self.assertEqual(sector_object.r_min, r_min) self.assertEqual(sector_object.r_max, r_max) self.assertEqual(sector_object.phi_min, phi_min) self.assertEqual(sector_object.phi_max, phi_max) self.assertEqual(sector_object.nbins, nbins) - self.assertEqual(sector_object.base, base) + # self.assertEqual(sector_object.base, base) def test_sectorphi_non_plottable_data(self): """ @@ -977,8 +977,8 @@ def test_sectorphi_averaging(self): phi_max = 5*np.pi/6 nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - wedge_object = SectorPhi(r_min=r_min, r_max=r_max, phi_min=phi_min + np.pi, - phi_max=phi_max + np.pi, nbins=nbins) + wedge_object = SectorPhi(r_min=r_min, r_max=r_max, phi_min=phi_min, + phi_max=phi_max, nbins=nbins) data1d = wedge_object(averager_data.data) expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, From acc2bc7faa585d085c029748ced7f9836badba5a Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Fri, 8 Sep 2023 10:03:15 +0100 Subject: [PATCH 07/49] Added dedicated WedgeQ and WedgePhi classes, plus corresponding unit test. The old SectorPhi now links to WedgePhi. --- sasdata/data_util/new_manipulations.py | 70 +++++++++++- .../utest_averaging_analytical.py | 105 +++++++++++++----- 2 files changed, 143 insertions(+), 32 deletions(-) diff --git a/sasdata/data_util/new_manipulations.py b/sasdata/data_util/new_manipulations.py index b5ee734a..a12f9418 100644 --- a/sasdata/data_util/new_manipulations.py +++ b/sasdata/data_util/new_manipulations.py @@ -26,6 +26,10 @@ def weights_for_interval(array, l_bound, u_bound, interval_type='half-open'): class DirectionalAverage: """ TODO - write a docstring + + Note that the old version of manipulations.py had an option for logarithmic + binning which was only used by SectorQ. This functionality is never called + upon by SasView however, so I haven't implemented it here (yet). """ def __init__(self, major_data, minor_data, major_lims=None, @@ -548,19 +552,58 @@ def __call__(self, data2d: Data2D = None) -> Data1D: return data1d -class SectorPhi(PolarROI): +class WedgeQ(PolarROI): + """ + TODO - add docstring """ - Sector average as a function of phi. - I(phi) is return and the data is averaged over Q. - A sector is defined by r_min, r_max, phi_min, phi_max. - The number of bin in phi also has to be defined. + def __init__(self, r_min: float, r_max: float, phi_min: float, + phi_max: float, nbins: int = 100) -> None: + """ + """ + super().__init__(r_min=r_min, r_max=r_max, + phi_min=phi_min, phi_max=phi_max) + self.nbins = nbins + + def __call__(self, data2d: Data2D = None) -> Data1D: + """ + """ + self.validate_and_assign_data(data2d) + + # Transform all angles to the range [0,2π), where phi_min is at zero. + # We won't need to convert back later because we're plotting against Q. + phi_offset = self.phi_min + self.phi_min = 0.0 + self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) + self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) + + # Averaging takes place between radial and angular limits + major_lims = (self.r_min, self.r_max) + # When phi_max and phi_min have the same angle, ROI is a full circle. + if self.phi_max == 0: + minor_lims = None + else: + minor_lims = (self.phi_min, self.phi_max) + + directional_average = DirectionalAverage(major_data=self.q_data, + minor_data=self.phi_data, + major_lims=major_lims, + minor_lims=minor_lims, + nbins=self.nbins) + q_data, intensity, error = \ + directional_average(data=self.data, err_data=self.err_data) + + return Data1D(x=q_data, y=intensity, dy=error) + + +class WedgePhi(PolarROI): + """ + TODO - add docstring """ def __init__(self, r_min: float, r_max: float, phi_min: float, phi_max: float, nbins: int = 100) -> None: """ - TODO - add docstring """ super().__init__(r_min=r_min, r_max=r_max, phi_min=phi_min, phi_max=phi_max) @@ -604,3 +647,18 @@ def __call__(self, data2d: Data2D = None) -> Data1D: return Data1D(x=phi_data, y=intensity, dy=error) + +class SectorPhi(WedgePhi): + """ + Sector average as a function of phi. + I(phi) is return and the data is averaged over Q. + + A sector is defined by r_min, r_max, phi_min, phi_max. + The number of bin in phi also has to be defined. + """ + + # This class has only been kept around in case users are using it in + # scripts, SectorPhi was never used by SasView. The functionality is now in + # use through WedgeSlicer.py, so the rewritten version of this class has + # been named WedgePhi. + diff --git a/test/sasdataloader/utest_averaging_analytical.py b/test/sasdataloader/utest_averaging_analytical.py index 6b424000..358dd335 100644 --- a/test/sasdataloader/utest_averaging_analytical.py +++ b/test/sasdataloader/utest_averaging_analytical.py @@ -13,7 +13,7 @@ from sasdata.dataloader import data_info from sasdata.data_util.new_manipulations import (SlabX, SlabY, Boxsum, Boxavg, CircularAverage, Ring, - SectorQ, SectorPhi) + SectorQ, WedgeQ, WedgePhi) class MatrixToData2D: @@ -815,7 +815,7 @@ def test_ring_averages_azimuthally(self): class SectorQTests(unittest.TestCase): """ - This class contains the tests for the _Sector class from manipulations.py + This class contains the tests for the SectorQ class from manipulations.py On the sasview side, this includes SectorSlicer and WedgeSlicer. The parameters frequency, r_min, r_max, phi_min and phi_max are largely @@ -826,8 +826,8 @@ def test_sectorq_init(self): """ Test that SectorQ's __init__ method does what it's supposed to. """ - r_min = 1 - r_max = 2 + r_min = 0 + r_max = 1 phi_min = 0 phi_max = np.pi nbins = 100 @@ -861,7 +861,7 @@ def test_sectorq_averaging_without_fold(self): major_axis='Q') averager_data = MatrixToData2D(test_data.matrix) - r_min = 0.1 * averager_data.qmax + r_min = 0 r_max = 0.9 * averager_data.qmax phi_min = np.pi/6 phi_max = 5*np.pi/6 @@ -895,7 +895,7 @@ def test_sectorq_averaging_with_fold(self): major_axis='Q') averager_data = MatrixToData2D(test_data.matrix) - r_min = 0.1 * averager_data.qmax + r_min = 0 r_max = 0.9 * averager_data.qmax phi_min = np.pi/6 phi_max = 5*np.pi/6 @@ -923,18 +923,71 @@ def test_sectorq_averaging_with_fold(self): self.assertAlmostEqual(actual_area, expected_area, 1) -class SectorPhiTests(unittest.TestCase): +class WedgeQTests(unittest.TestCase): """ - This class contains the tests for the SectorPhi class from manipulations.py - On the sasview side, this includes SectorSlicer and WedgeSlicer. + This class contains the tests for the WedgeQ class from manipulations.py The parameters frequency, r_min, r_max, phi_min and phi_max are largely arbitrary, and the tests should pass if any sane value is used for them. """ - def test_sectorphi_init(self): + def test_wedgeq_init(self): """ - Test that SectorPhi's __init__ method does what it's supposed to. + Test that WedgeQ's __init__ method does what it's supposed to. + """ + r_min = 1 + r_max = 2 + phi_min = 0 + phi_max = np.pi + nbins = 10 + + wedge_object = WedgeQ(r_min=r_min, r_max=r_max, phi_min=phi_min, + phi_max=phi_max, nbins=nbins) + + self.assertEqual(wedge_object.r_min, r_min) + self.assertEqual(wedge_object.r_max, r_max) + self.assertEqual(wedge_object.phi_min, phi_min) + self.assertEqual(wedge_object.phi_max, phi_max) + self.assertEqual(wedge_object.nbins, nbins) + + def test_wedgeq_averaging(self): + """ + Test WedgeQ can average correctly, when all of min/max r & phi params + are specified and have their expected form. + """ + test_data = CircularTestingMatrix(frequency=3, matrix_size=201, + major_axis='Q') + averager_data = MatrixToData2D(test_data.matrix) + + r_min = 0.1 * averager_data.qmax + r_max = 0.9 * averager_data.qmax + phi_min = np.pi/6 + phi_max = 5*np.pi/6 + nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable + + wedge_object = WedgeQ(r_min=r_min, r_max=r_max, phi_min=phi_min, + phi_max=phi_max, nbins=nbins) + data1d = wedge_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min, + phi_max=phi_max) + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 1) + + +class WedgePhiTests(unittest.TestCase): + """ + This class contains the tests for the WedgePhi class from manipulations.py + + The parameters frequency, r_min, r_max, phi_min and phi_max are largely + arbitrary, and the tests should pass if any sane value is used for them. + """ + + def test_wedgephi_init(self): + """ + Test that WedgePhi's __init__ method does what it's supposed to. """ r_min = 1 r_max = 2 @@ -943,29 +996,29 @@ def test_sectorphi_init(self): nbins = 100 # base = 10 - # sector_object = SectorPhi(r_min=r_min, r_max=r_max, phi_min=phi_min, + # wedge_object = WedgePhi(r_min=r_min, r_max=r_max, phi_min=phi_min, # phi_max=phi_max, nbins=nbins, base=base) - sector_object = SectorPhi(r_min=r_min, r_max=r_max, phi_min=phi_min, - phi_max=phi_max, nbins=nbins) + wedge_object = WedgePhi(r_min=r_min, r_max=r_max, phi_min=phi_min, + phi_max=phi_max, nbins=nbins) - self.assertEqual(sector_object.r_min, r_min) - self.assertEqual(sector_object.r_max, r_max) - self.assertEqual(sector_object.phi_min, phi_min) - self.assertEqual(sector_object.phi_max, phi_max) - self.assertEqual(sector_object.nbins, nbins) - # self.assertEqual(sector_object.base, base) + self.assertEqual(wedge_object.r_min, r_min) + self.assertEqual(wedge_object.r_max, r_max) + self.assertEqual(wedge_object.phi_min, phi_min) + self.assertEqual(wedge_object.phi_max, phi_max) + self.assertEqual(wedge_object.nbins, nbins) + # self.assertEqual(wedge_object.base, base) - def test_sectorphi_non_plottable_data(self): + def test_wedgephi_non_plottable_data(self): """ Test that RuntimeError is raised if the data supplied isn't plottable """ # Implementing this test can wait pass - def test_sectorphi_averaging(self): + def test_wedgephi_averaging(self): """ - Test _Sector can average correctly with a major axis of phi, when all - of min/max r & phi params are specified and have their expected form. + Test WedgePhi can average correctly, when all of min/max r & phi params + are specified and have their expected form. """ test_data = CircularTestingMatrix(frequency=1, matrix_size=201, major_axis='Phi') @@ -977,8 +1030,8 @@ def test_sectorphi_averaging(self): phi_max = 5*np.pi/6 nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - wedge_object = SectorPhi(r_min=r_min, r_max=r_max, phi_min=phi_min, - phi_max=phi_max, nbins=nbins) + wedge_object = WedgePhi(r_min=r_min, r_max=r_max, phi_min=phi_min, + phi_max=phi_max, nbins=nbins) data1d = wedge_object(averager_data.data) expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, From 204bc633ccc89899f98139bf5a7dc006f8c31362 Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Sun, 17 Sep 2023 13:11:26 +0100 Subject: [PATCH 08/49] Added documentation to new manipulations module --- sasdata/data_util/new_manipulations.py | 421 +++++++++++++++++++------ 1 file changed, 329 insertions(+), 92 deletions(-) diff --git a/sasdata/data_util/new_manipulations.py b/sasdata/data_util/new_manipulations.py index a12f9418..1c7de226 100644 --- a/sasdata/data_util/new_manipulations.py +++ b/sasdata/data_util/new_manipulations.py @@ -1,3 +1,7 @@ +""" +This module contains various data processors used by Sasview's slicers. +""" + import numpy as np from sasdata.dataloader.data_info import Data1D, Data2D @@ -5,13 +9,23 @@ def weights_for_interval(array, l_bound, u_bound, interval_type='half-open'): """ + Weight coordinate data by position relative to a specified interval. + + :param array: the array for which the weights are calculated + :param l_bound: value defining the lower limit of the region of interest + :param u_bound: value defining the upper limit of the region of interest + :param interval_type: determines whether the value defined by u_bound is + included within the interval. + If and when fractional binning is implemented (ask Lucas), this function will be changed so that instead of outputting zeros and ones, it gives fractional values instead. These will depend on how close the array value is to being within the interval defined. """ - # These checks could be modified to return fractional bin weights. - # The last value in the binning range must be included for to pass utests + + # Whether the endpoint should be included depends on circumstance. + # Half-open is used when binning the major axis (except for the final bin) + # and closed used for the minor axis and the final bin of the major axis. if interval_type == 'half-open': in_range = (l_bound <= array) & (array < u_bound) elif interval_type == 'closed': @@ -25,19 +39,43 @@ def weights_for_interval(array, l_bound, u_bound, interval_type='half-open'): class DirectionalAverage: """ - TODO - write a docstring + Average along one coordinate axis of 2D data and return data for a 1D plot. + This can also be thought of as a projection onto the major axis: 2D -> 1D. + + This class operates on a decomposed Data2D object, and returns data needed + to construct a Data1D object. The class is instantiated with two arrays of + orthogonal coordinate data (depending on the coordinate system, these may + have undergone some pre-processing) and two corresponding two-element + tuples/lists defining the lower and upper limits on the Region of Interest + (ROI) for each coordinate axis. One of these axes is averaged along, and + the other is divided into bins and becomes the dependent variable of the + eventual 1D plot. These are called the minor and major axes respectively. + When a class instance is called, it is passed the intensity and error data + from the original Data2D object. These should not have undergone any + coordinate system dependent pre-processing. Note that the old version of manipulations.py had an option for logarithmic binning which was only used by SectorQ. This functionality is never called upon by SasView however, so I haven't implemented it here (yet). """ - def __init__(self, major_data, minor_data, major_lims=None, + def __init__(self, major_axis=None, minor_axis=None, major_lims=None, minor_lims=None, nbins=100): """ + Set up direction of averaging, limits on the ROI, & the number of bins. + + :param major_axis: Coordinate data for axis onto which the 2D data is + projected. + :param minor_axis: Coordinate data for the axis perpendicular to the + major axis. + :param major_lims: Lower and upper bounds of the ROI along the major + axis. Given as a 2 element tuple/list. + :param minor_lims: Lower and upper bounds of the ROI along the minor + axis. Given as a 2 element tuple/list. + :param nbins: The number of bins the major axis is divided up into. """ - if any(not isinstance(data, (list, np.ndarray)) for - data in (major_data, minor_data)): + if any(not isinstance(coordinate_data, (list, np.ndarray)) for + coordinate_data in (major_axis, minor_axis)): msg = "Must provide major & minor coordinate arrays for binning." raise ValueError(msg) if any(lims is not None and len(lims) != 2 for @@ -48,14 +86,15 @@ def __init__(self, major_data, minor_data, major_lims=None, msg = "Parameter 'nbins' must be an integer" raise TypeError(msg) - self.major_data = np.asarray(major_data) - self.minor_data = np.asarray(minor_data) + self.major_axis = np.asarray(major_axis) + self.minor_axis = np.asarray(minor_axis) + # In some cases all values from a given axis are part of the ROI. if major_lims is None: - self.major_lims = (self.major_data.min(), self.major_data.max()) + self.major_lims = (self.major_axis.min(), self.major_axis.max()) else: self.major_lims = major_lims if minor_lims is None: - self.minor_lims = (self.minor_data.min(), self.minor_data.max()) + self.minor_lims = (self.minor_axis.min(), self.minor_axis.max()) else: self.minor_lims = minor_lims self.nbins = nbins @@ -63,13 +102,15 @@ def __init__(self, major_data, minor_data, major_lims=None, @property def bin_width(self): """ - Return the bin width + Return the bin width based on the range of the major axis and nbins """ return (self.major_lims[1] - self.major_lims[0]) / self.nbins def get_bin_interval(self, bin_number): """ - Return the upper and lower limits defining a given bin + Return the upper and lower limits defining a bin, given its index. + + :param bin_number: The index of the bin (between 0 and self.nbins - 1) """ bin_start = self.major_lims[0] + bin_number * self.bin_width bin_end = self.major_lims[0] + (bin_number + 1) * self.bin_width @@ -78,6 +119,9 @@ def get_bin_interval(self, bin_number): def get_bin_index(self, value): """ + Return the index of the bin to which the supplied value belongs. + + :param value: A coordinate value from somewhere along the major axis. """ numerator = value - self.major_lims[0] denominator = self.major_lims[1] - self.major_lims[0] @@ -92,22 +136,26 @@ def get_bin_index(self, value): def compute_weights(self): """ + Return weights array for the contribution of each datapoint to each bin + + Each row of the weights array corresponds to the bin with the same + index. """ - major_weights = np.zeros((self.nbins, self.major_data.size)) + major_weights = np.zeros((self.nbins, self.major_axis.size)) for m in range(self.nbins): # Include the value at the end of the binning range, but in - # general use half-open intervals so each value begins in only + # general use half-open intervals so each value belongs in only # one bin. if m == self.nbins - 1: interval = 'closed' else: interval = 'half-open' bin_start, bin_end = self.get_bin_interval(bin_number=m) - major_weights[m] = weights_for_interval(array=self.major_data, + major_weights[m] = weights_for_interval(array=self.major_axis, l_bound=bin_start, u_bound=bin_end, interval_type=interval) - minor_weights = weights_for_interval(array=self.minor_data, + minor_weights = weights_for_interval(array=self.minor_axis, l_bound=self.minor_lims[0], u_bound=self.minor_lims[1], interval_type='closed') @@ -115,10 +163,14 @@ def compute_weights(self): def __call__(self, data, err_data): """ + Compute the directional average of the supplied intensity & error data. + + :param data: intensity data from the origninal Data2D object. + :param err_data: the corresponding errors for the intensity data. """ weights = self.compute_weights() - x_axis_values = np.sum(weights * self.major_data, axis=1) + x_axis_values = np.sum(weights * self.major_axis, axis=1) intensity = np.sum(weights * data, axis=1) errs_squared = np.sum((weights * err_data)**2, axis=1) bin_counts = np.sum(weights, axis=1) @@ -138,11 +190,16 @@ def __call__(self, data, err_data): class GenericROI: """ - TODO - add docstring + Base class used to set up the data from a Data2D object for processing. + This class performs any coordinate system independent setup and validation. """ def __init__(self): """ + Assign the variables used to label the properties of the Data2D object. + + In classes inheriting from GenericROI, the variables used to define the + boundaries of the Region Of Interest are also set up during __init__. """ self.data = None self.err_data = None @@ -152,8 +209,13 @@ def __init__(self): def validate_and_assign_data(self, data2d: Data2D = None) -> None: """ - Check that the data supplied valid and assign data variables. + Check that the data supplied is valid and assign data to variables. + This method must be executed before any further data processing happens + + :param data2d: A Data2D object which is the target of a child class' + data manipulations. """ + # Check that the supplied data2d is valid and usable. if not isinstance(data2d, Data2D): msg = "Data supplied must be of type Data2D." raise TypeError(msg) @@ -164,6 +226,8 @@ def validate_and_assign_data(self, data2d: Data2D = None) -> None: # Only use data which is finite and not masked off valid_data = np.isfinite(data2d.data) & data2d.mask + # Assign properties of the Data2D object to variables for reference + # during data processing. self.data = data2d.data[valid_data] self.err_data = data2d.err_data[valid_data] self.q_data = data2d.q_data[valid_data] @@ -179,17 +243,23 @@ def validate_and_assign_data(self, data2d: Data2D = None) -> None: class CartesianROI(GenericROI): """ - Base class for manipulators with a rectangular region of interest. + Base class for data manipulators with a Cartesian (rectangular) ROI. """ def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, qy_max: float = 0) -> None: """ - TODO - add docstring + Assign the variables used to label the properties of the Data2D object. + Also establish the upper and lower bounds defining the ROI. + + The units of these parameters are A^-1 + :param qx_min: Lower bound of the ROI along the Q_x direction. + :param qx_max: Upper bound of the ROI along the Q_x direction. + :param qy_min: Lower bound of the ROI along the Q_y direction. + :param qy_max: Upper bound of the ROI along the Q_y direction. """ super().__init__() - # Units A^-1 self.qx_min = qx_min self.qx_max = qx_max self.qy_min = qy_min @@ -198,13 +268,22 @@ def __init__(self, qx_min: float = 0, qx_max: float = 0, class PolarROI(GenericROI): """ - Base class for manipulators whose ROI is defined with polar coordinates. + Base class for data manipulators with a polar ROI. """ def __init__(self, r_min: float, r_max: float, phi_min: float = 0, phi_max: float = 2*np.pi) -> None: """ - TODO - add docstring + Assign the variables used to label the properties of the Data2D object. + Also establish the upper and lower bounds defining the ROI. + + The units are A^-1 for radial parameters, and radians for anglar ones. + :param r_min: Lower bound of the ROI along the Q direction. + :param r_max: Upper bound of the ROI along the Q direction. + :param phi_min: Lower bound of the ROI along the Phi direction. + :param phi_max: Upper bound of the ROI along the Phi direction. + + Note that Phi is measured anti-clockwise from the positive x-axis. """ super().__init__() @@ -222,27 +301,42 @@ def __init__(self, r_min: float, r_max: float, def validate_and_assign_data(self, data2d: Data2D = None) -> None: """ Check that the data supplied valid and assign data variables. + This method must be executed before any further data processing happens + + :param data2d: A Data2D object which is the target of a child class' + data manipulations. """ + + # Most validation and pre-processing is taken care of by GenericROI. super().validate_and_assign_data(data2d) + # Phi data can be calculated from the Cartesian Q coordinates. self.phi_data = np.arctan2(self.qy_data, self.qx_data) class Boxsum(CartesianROI): """ - Perform the sum of counts in a 2D region of interest. + Compute the sum of the intensity within a rectangular Region Of Interest. """ def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, qy_max: float = 0) -> None: """ - TODO - add docstring + Set up the Region of Interest and its boundaries. + + The units of these parameters are A^-1 + :param qx_min: Lower bound of the ROI along the Q_x direction. + :param qx_max: Upper bound of the ROI along the Q_x direction. + :param qy_min: Lower bound of the ROI along the Q_y direction. + :param qy_max: Upper bound of the ROI along the Q_y direction. """ super().__init__(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max) def __call__(self, data2d: Data2D = None) -> float: """ - TODO - add docstring + Coordinate data processing operations and return the results. + + :param data2d: The Data2D object for which the sum is calculated. """ self.validate_and_assign_data(data2d) total_sum, error, count = self._sum() @@ -251,7 +345,9 @@ def __call__(self, data2d: Data2D = None) -> float: def _sum(self) -> float: """ - TODO - add docstring + Determine which data are inside the ROI and compute their sum. + Also calculate the error on this calculation and the total number of + datapoints in the region. """ # Currently the weights are binary, but could be fractional in future @@ -266,6 +362,8 @@ def _sum(self) -> float: weights = x_weights * y_weights data = weights * self.data + # Not certain that the weights should be squared here, I'm just copying + # how it was done in the old manipulations.py err_squared = weights * weights * self.err_data * self.err_data total_sum = np.sum(data) @@ -277,20 +375,28 @@ def _sum(self) -> float: class Boxavg(Boxsum): """ - Perform the average of counts in a 2D region of interest. + Compute the average intensity within a rectangular Region Of Interest. """ def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, qy_max: float = 0) -> None: """ - TODO - add docstring + Set up the Region of Interest and its boundaries. + + The units of these parameters are A^-1 + :param qx_min: Lower bound of the ROI along the Q_x direction. + :param qx_max: Upper bound of the ROI along the Q_x direction. + :param qy_min: Lower bound of the ROI along the Q_y direction. + :param qy_max: Upper bound of the ROI along the Q_y direction. """ super().__init__(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max) def __call__(self, data2d: Data2D) -> float: """ - TODO - add docstring + Coordinate data processing operations and return the results. + + :param data2d: The Data2D object for which the average is calculated. """ self.validate_and_assign_data(data2d) total_sum, error, count = super()._sum() @@ -300,13 +406,30 @@ def __call__(self, data2d: Data2D) -> float: class SlabX(CartesianROI): """ - Compute average I(Qx) for a region of interest + Average I(Q_x, Q_y) along the y direction (within a ROI), giving I(Q_x). + + This class is initialised by specifying the boundaries of the ROI and is + called by supplying a Data2D object. It returns a Data1D object. + The averaging process can also be thought of as projecting 2D -> 1D. + + There also exists the option to "fold" the ROI, where Q data on opposite + sides of the origin but with equal magnitudes are averaged together, + resulting in a 1D plot with only positive Q values shown. """ def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, qy_max: float = 0, nbins: int = 100, fold: bool = False): """ - TODO - add docstring + Set up the ROI boundaries, the binning of the output 1D data, and fold. + + The units of these parameters are A^-1 + :param qx_min: Lower bound of the ROI along the Q_x direction. + :param qx_max: Upper bound of the ROI along the Q_x direction. + :param qy_min: Lower bound of the ROI along the Q_y direction. + :param qy_max: Upper bound of the ROI along the Q_y direction. + :param nbins: The number of bins data is sorted into along Q_x. + :param fold: Whether the two halves of the ROI along Q_x should be + folded together during averaging. """ super().__init__(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max) @@ -315,12 +438,18 @@ def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, def __call__(self, data2d: Data2D = None) -> Data1D: """ - Compute average I(Qx) for a region of interest - :param data2d: Data2D object - :return: Data1D object + Compute the 1D average of 2D data, projecting along the Q_x axis. + + :param data2d: The Data2D object for which the average is computed. + :return: Data1D object for plotting. """ self.validate_and_assign_data(data2d) + # SlabX is used by SasView's BoxInteractorX, which is designed so that + # the ROI is always centred on the origin. If this ever changes, then + # the behaviour of fold here will also need to change. Perhaps we could + # apply a transformation to the data like the one used in WedgePhi. + if self.fold: major_lims = (0, self.qx_max) self.qx_data = np.abs(self.qx_data) @@ -328,8 +457,8 @@ def __call__(self, data2d: Data2D = None) -> Data1D: major_lims = (self.qx_min, self.qx_max) minor_lims = (self.qy_min, self.qy_max) - directional_average = DirectionalAverage(major_data=self.qx_data, - minor_data=self.qy_data, + directional_average = DirectionalAverage(major_axis=self.qx_data, + minor_axis=self.qy_data, major_lims=major_lims, minor_lims=minor_lims, nbins=self.nbins) @@ -341,13 +470,30 @@ def __call__(self, data2d: Data2D = None) -> Data1D: class SlabY(CartesianROI): """ - Compute average I(Qy) for a region of interest + Average I(Q_x, Q_y) along the x direction (within a ROI), giving I(Q_y). + + This class is initialised by specifying the boundaries of the ROI and is + called by supplying a Data2D object. It returns a Data1D object. + The averaging process can also be thought of as projecting 2D -> 1D. + + There also exists the option to "fold" the ROI, where Q data on opposite + sides of the origin but with equal magnitudes are averaged together, + resulting in a 1D plot with only positive Q values shown. """ def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, qy_max: float = 0, nbins: int = 100, fold: bool = False): """ - TODO - add docstring + Set up the ROI boundaries, the binning of the output 1D data, and fold. + + The units of these parameters are A^-1 + :param qx_min: Lower bound of the ROI along the Q_x direction. + :param qx_max: Upper bound of the ROI along the Q_x direction. + :param qy_min: Lower bound of the ROI along the Q_y direction. + :param qy_max: Upper bound of the ROI along the Q_y direction. + :param nbins: The number of bins data is sorted into along Q_y. + :param fold: Whether the two halves of the ROI along Q_y should be + folded together during averaging. """ super().__init__(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, qy_max=qy_max) @@ -356,12 +502,18 @@ def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, def __call__(self, data2d: Data2D = None) -> Data1D: """ - Compute average I(Qy) for a region of interest - :param data2d: Data2D object - :return: Data1D object + Compute the 1D average of 2D data, projecting along the Q_y axis. + + :param data2d: The Data2D object for which the average is computed. + :return: Data1D object for plotting. """ self.validate_and_assign_data(data2d) + # SlabY is used by SasView's BoxInteractorY, which is designed so that + # the ROI is always centred on the origin. If this ever changes, then + # the behaviour of fold here will also need to change. Perhaps we could + # apply a transformation to the data like the one used in WedgePhi. + if self.fold: major_lims = (0, self.qy_max) self.qy_data = np.abs(self.qy_data) @@ -369,8 +521,8 @@ def __call__(self, data2d: Data2D = None) -> Data1D: major_lims = (self.qy_min, self.qy_max) minor_lims = (self.qx_min, self.qx_max) - directional_average = DirectionalAverage(major_data=self.qy_data, - minor_data=self.qx_data, + directional_average = DirectionalAverage(major_axis=self.qy_data, + minor_axis=self.qx_data, major_lims=major_lims, minor_lims=minor_lims, nbins=self.nbins) @@ -382,30 +534,41 @@ def __call__(self, data2d: Data2D = None) -> Data1D: class CircularAverage(PolarROI): """ - Perform circular averaging on 2D data + Calculate I(|Q|) by circularly averaging 2D data between 2 radial limits. - The data returned is the distribution of counts - as a function of Q + This class is initialised by specifying lower and upper limits on the + magnitude of Q values to consider during the averaging, though currently + SasView always calls this class using the full range of data. When called, + this class is supplied with a Data2D object. It returns a Data1D object + where intensity is given as a function of Q only. """ def __init__(self, r_min: float, r_max: float, nbins: int = 100) -> None: """ - TODO - add docstring + Set up the lower and upper radial limits as well as the number of bins. + + The units are A^-1 for the radial parameters. + :param r_min: Lower limit for |Q| values to use during averaging. + :param r_max: Upper limit for |Q| values to use during averaging. + :param nbins: The number of bins data is sorted into along |Q| the axis """ - super().__init__(r_min=r_min, r_max=r_max, phi_min=0, phi_max=2*np.pi) + super().__init__(r_min=r_min, r_max=r_max) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: """ - TODO - add docstring + Compute the 1D average of 2D data, projecting along the Q axis. + + :param data2d: The Data2D object for which the average is computed. + :return: Data1D object for plotting. """ self.validate_and_assign_data(data2d) # Averaging takes place between radial limits major_lims = (self.r_min, self.r_max) - # Average over the full angular range - directional_average = DirectionalAverage(major_data=self.q_data, - minor_data=self.phi_data, + # minor_lims is None because a full-circle angular range is used + directional_average = DirectionalAverage(major_axis=self.q_data, + minor_axis=self.phi_data, major_lims=major_lims, minor_lims=None, nbins=self.nbins) @@ -417,35 +580,41 @@ def __call__(self, data2d: Data2D = None) -> Data1D: class Ring(PolarROI): """ - Defines a ring on a 2D data set. - The ring is defined by r_min, r_max, and - the position of the center of the ring. + Calculate I(φ) by radially averaging 2D data between 2 radial limits. - The data returned is the distribution of counts - around the ring as a function of phi. - - Phi_min and phi_max should be defined between 0 and 2*pi - in anti-clockwise starting from the x- axis on the left-hand side + This class is initialised by specifying lower and upper limits on the + magnitude of Q values to consider during the averaging. When called, + this class is supplied with a Data2D object. It returns a Data1D object. + This Data1D object gives intensity as a function of the angle from the + positive x-axis, φ, only. """ def __init__(self, r_min: float, r_max: float, nbins: int = 100) -> None: """ - TODO - add docstring + Set up the lower and upper radial limits as well as the number of bins. + + The units are A^-1 for the radial parameters. + :param r_min: Lower limit for |Q| values to use during averaging. + :param r_max: Upper limit for |Q| values to use during averaging. + :param nbins: The number of bins data is sorted into along Phi the axis """ - super().__init__(r_min=r_min, r_max=r_max, phi_min=0, phi_max=2*np.pi) + super().__init__(r_min=r_min, r_max=r_max) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: """ - TODO - add docstring + Compute the 1D average of 2D data, projecting along the Phi axis. + + :param data2d: The Data2D object for which the average is computed. + :return: Data1D object for plotting. """ self.validate_and_assign_data(data2d) # Averaging takes place between radial limits minor_lims = (self.r_min, self.r_max) - # Average over the full angular range - directional_average = DirectionalAverage(major_data=self.phi_data, - minor_data=self.q_data, + # major_lims is None because a full-circle angular range is used + directional_average = DirectionalAverage(major_axis=self.phi_data, + minor_axis=self.q_data, major_lims=None, minor_lims=minor_lims, nbins=self.nbins) @@ -457,20 +626,39 @@ def __call__(self, data2d: Data2D = None) -> Data1D: class SectorQ(PolarROI): """ - Sector average as a function of Q for both wings. setting the _Sector.fold - attribute determines whether or not the two sectors are averaged together - (folded over) or separate. In the case of separate (not folded), the - qs for the "minor wing" are arbitrarily set to a negative value. - I(Q) is returned and the data is averaged over phi. - - A sector is defined by r_min, r_max, phi_min, phi_max. - where r_min, r_max, phi_min, phi_max >0. - The number of bins in Q also has to be defined. + Project I(Q, φ) data onto I(Q) within a region defined by Cartesian limits. + + The projection is computed by averaging together datapoints with the same + angle φ (so long as they are within the ROI), measured anticlockwise from + the positive x-axis. + + This class is initialised by specifying lower and upper limits on both the + magnitude of Q and the angle φ. These four parameters specify the primary + Region Of Interest, however there is a secondary ROI with the same |Q| + values on the opposite side of the origin (φ + π). How this secondary ROI + is treated depends on the value of the `fold` parameter. If fold is set to + True, data on opposite sides of the origin are averaged together and the + results are plotted against positive values of Q. If fold is set to False, + the data from the two regions are graphed separeately, with the secondary + ROI data labelled using negative Q values. + + When called, this class is supplied with a Data2D object. It returns a + Data1D object where intensity is given as a function of Q only. """ def __init__(self, r_min: float, r_max: float, phi_min: float, phi_max: float, nbins: int = 100, fold: bool = True) -> None: """ + Set up the ROI boundaries, the binning of the output 1D data, and fold. + + The units are A^-1 for radial parameters, and radians for anglar ones. + :param r_min: Lower limit for |Q| values to use during averaging. + :param r_max: Upper limit for |Q| values to use during averaging. + :param phi_min: Lower limit for φ values (in the primary ROI). + :param phi_max: Upper limit for φ values (in the primary ROI). + :param nbins: The number of bins data is sorted into along the |Q| axis + :param fold: Whether the primary and secondary ROIs should be folded + together during averaging. """ super().__init__(r_min=r_min, r_max=r_max, phi_min=phi_min, phi_max=phi_max) @@ -479,10 +667,15 @@ def __init__(self, r_min: float, r_max: float, phi_min: float, def __call__(self, data2d: Data2D = None) -> Data1D: """ + Compute the 1D average of 2D data, projecting along the Q_y axis. + + :param data2d: The Data2D object for which the average is computed. + :return: Data1D object for plotting. """ self.validate_and_assign_data(data2d) - # Transform all angles to the range [0,2π), where phi_min is at zero. + # Transform all angles to the range [0,2π) where phi_min is at zero, + # eliminating errors when the ROI straddles the 2π -> 0 discontinuity. # We won't need to convert back later because we're plotting against Q. phi_offset = self.phi_min self.phi_min = 0.0 @@ -494,13 +687,13 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # Secondary region of interest covers angles on opposite side of origin minor_lims_alt = (self.phi_min + np.pi, self.phi_max + np.pi) - primary_region = DirectionalAverage(major_data=self.q_data, - minor_data=self.phi_data, + primary_region = DirectionalAverage(major_axis=self.q_data, + minor_axis=self.phi_data, major_lims=major_lims, minor_lims=minor_lims, nbins=self.nbins) - secondary_region = DirectionalAverage(major_data=self.q_data, - minor_data=self.phi_data, + secondary_region = DirectionalAverage(major_axis=self.q_data, + minor_axis=self.phi_data, major_lims=major_lims, minor_lims=minor_lims_alt, nbins=self.nbins) @@ -543,6 +736,7 @@ def __call__(self, data2d: Data2D = None) -> Data1D: data1d = Data1D(x=combined_q[finite], y=average_intensity[finite], dy=combined_err[finite]) else: + # The secondary ROI is labelled with negative Q values. combined_q = np.append(np.flip(-1 * secondary_q), primary_q) combined_intensity = np.append(np.flip(secondary_I), primary_I) combined_error = np.append(np.flip(secondary_err), primary_err) @@ -554,12 +748,29 @@ def __call__(self, data2d: Data2D = None) -> Data1D: class WedgeQ(PolarROI): """ - TODO - add docstring + Project I(Q, φ) data onto I(Q) within a region defined by Cartesian limits. + + The projection is computed by averaging together datapoints with the same + angle φ (so long as they are within the ROI), measured anticlockwise from + the positive x-axis. + + This class is initialised by specifying lower and upper limits on both the + magnitude of Q and the angle φ. + When called, this class is supplied with a Data2D object. It returns a + Data1D object where intensity is given as a function of Q only. """ def __init__(self, r_min: float, r_max: float, phi_min: float, phi_max: float, nbins: int = 100) -> None: """ + Set up the ROI boundaries, and the binning of the output 1D data. + + The units are A^-1 for radial parameters, and radians for anglar ones. + :param r_min: Lower limit for |Q| values to use during averaging. + :param r_max: Upper limit for |Q| values to use during averaging. + :param phi_min: Lower limit for φ values (in the primary ROI). + :param phi_max: Upper limit for φ values (in the primary ROI). + :param nbins: The number of bins data is sorted into along the |Q| axis """ super().__init__(r_min=r_min, r_max=r_max, phi_min=phi_min, phi_max=phi_max) @@ -567,10 +778,15 @@ def __init__(self, r_min: float, r_max: float, phi_min: float, def __call__(self, data2d: Data2D = None) -> Data1D: """ + Compute the 1D average of 2D data, projecting along the Q_y axis. + + :param data2d: The Data2D object for which the average is computed. + :return: Data1D object for plotting. """ self.validate_and_assign_data(data2d) - # Transform all angles to the range [0,2π), where phi_min is at zero. + # Transform all angles to the range [0,2π) where phi_min is at zero, + # eliminating errors when the ROI straddles the 2π -> 0 discontinuity. # We won't need to convert back later because we're plotting against Q. phi_offset = self.phi_min self.phi_min = 0.0 @@ -585,8 +801,8 @@ def __call__(self, data2d: Data2D = None) -> Data1D: else: minor_lims = (self.phi_min, self.phi_max) - directional_average = DirectionalAverage(major_data=self.q_data, - minor_data=self.phi_data, + directional_average = DirectionalAverage(major_axis=self.q_data, + minor_axis=self.phi_data, major_lims=major_lims, minor_lims=minor_lims, nbins=self.nbins) @@ -598,12 +814,29 @@ def __call__(self, data2d: Data2D = None) -> Data1D: class WedgePhi(PolarROI): """ - TODO - add docstring + Project I(Q, φ) data onto I(φ) within a region defined by Cartesian limits. + + The projection is computed by averaging together datapoints with the same + Q value (so long as they are within the ROI). + + This class is initialised by specifying lower and upper limits on both the + magnitude of Q and the angle φ, measured anticlockwise from the positive + x-axis. + When called, this class is supplied with a Data2D object. It returns a + Data1D object where intensity is given as a function of Q only. """ def __init__(self, r_min: float, r_max: float, phi_min: float, phi_max: float, nbins: int = 100) -> None: """ + Set up the ROI boundaries, and the binning of the output 1D data. + + The units are A^-1 for radial parameters, and radians for anglar ones. + :param r_min: Lower limit for |Q| values to use during averaging. + :param r_max: Upper limit for |Q| values to use during averaging. + :param phi_min: Lower limit for φ values to use during averaging. + :param phi_max: Upper limit for φ values to use during averaging. + :param nbins: The number of bins data is sorted into along the φ axis. """ super().__init__(r_min=r_min, r_max=r_max, phi_min=phi_min, phi_max=phi_max) @@ -611,12 +844,16 @@ def __init__(self, r_min: float, r_max: float, phi_min: float, def __call__(self, data2d: Data2D = None) -> Data1D: """ - TODO - add docstring + Compute the 1D average of 2D data, projecting along the Q_y axis. + + :param data2d: The Data2D object for which the average is computed. + :return: Data1D object for plotting. """ self.validate_and_assign_data(data2d) - # Transform all angles to the range [0,2π), where phi_min is at zero. - # Remember to transform back afterwards + # Transform all angles to the range [0,2π) where phi_min is at zero, + # eliminating errors when the ROI straddles the 2π -> 0 discontinuity. + # Remember to transform back afterwards as we're plotting against phi. phi_offset = self.phi_min self.phi_min = 0.0 self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) @@ -630,8 +867,8 @@ def __call__(self, data2d: Data2D = None) -> Data1D: major_lims = (self.phi_min, self.phi_max) minor_lims = (self.r_min, self.r_max) - directional_average = DirectionalAverage(major_data=self.phi_data, - minor_data=self.q_data, + directional_average = DirectionalAverage(major_axis=self.phi_data, + minor_axis=self.q_data, major_lims=major_lims, minor_lims=minor_lims, nbins=self.nbins) From 6dccdeb40b6a830868d721a25311cbc6d0a8ac6e Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Sun, 17 Sep 2023 18:37:14 +0100 Subject: [PATCH 09/49] Replaced python logical_and with numpy logical_and for speed --- sasdata/data_util/new_manipulations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sasdata/data_util/new_manipulations.py b/sasdata/data_util/new_manipulations.py index 1c7de226..6fa3e6be 100644 --- a/sasdata/data_util/new_manipulations.py +++ b/sasdata/data_util/new_manipulations.py @@ -27,9 +27,9 @@ def weights_for_interval(array, l_bound, u_bound, interval_type='half-open'): # Half-open is used when binning the major axis (except for the final bin) # and closed used for the minor axis and the final bin of the major axis. if interval_type == 'half-open': - in_range = (l_bound <= array) & (array < u_bound) + in_range = np.logical_and(l_bound <= array, array < u_bound) elif interval_type == 'closed': - in_range = (l_bound <= array) & (array <= u_bound) + in_range = np.logical_and(l_bound <= array, array <= u_bound) else: msg = f"Unrecognised interval_type: {interval_type}" raise ValueError(msg) @@ -127,7 +127,7 @@ def get_bin_index(self, value): denominator = self.major_lims[1] - self.major_lims[0] bin_index = int(np.floor(self.nbins * numerator / denominator)) - # Bins are indexed from 0 to nbins-1, so tihs check protects against + # Bins are indexed from 0 to nbins-1, so this check protects against # out-of-range indices when value == self.major_lims[1] if bin_index == self.nbins: bin_index -= 1 From adff0d222310fd995d8898318f68829cf2b821ec Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Sun, 17 Sep 2023 18:39:14 +0100 Subject: [PATCH 10/49] Removed some superfluous logical_and checks. Both arrays should have been identical anyway --- sasdata/data_util/new_manipulations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sasdata/data_util/new_manipulations.py b/sasdata/data_util/new_manipulations.py index 6fa3e6be..af2f7580 100644 --- a/sasdata/data_util/new_manipulations.py +++ b/sasdata/data_util/new_manipulations.py @@ -180,7 +180,7 @@ def __call__(self, data, err_data): intensity /= bin_counts errors /= bin_counts - finite = (np.isfinite(x_axis_values) & np.isfinite(intensity)) + finite = np.isfinite(intensity) if not finite.any(): msg = "Average Error: No points inside ROI to average..." raise ValueError(msg) @@ -731,7 +731,7 @@ def __call__(self, data2d: Data2D = None) -> Data1D: average_intensity /= bin_counts combined_err = np.sqrt(combined_err) / bin_counts - finite = (np.isfinite(combined_q) & np.isfinite(average_intensity)) + finite = np.isfinite(average_intensity) data1d = Data1D(x=combined_q[finite], y=average_intensity[finite], dy=combined_err[finite]) From 078b2dd9bd7588edef96a8d4824c2af23375aebd Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Mon, 18 Sep 2023 11:20:22 +0100 Subject: [PATCH 11/49] Forgot to remove 'angles + np.pi' from SectorQ call, no longer needed since rewrite. --- test/sasdataloader/utest_averaging_analytical.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/sasdataloader/utest_averaging_analytical.py b/test/sasdataloader/utest_averaging_analytical.py index 358dd335..58842d0d 100644 --- a/test/sasdataloader/utest_averaging_analytical.py +++ b/test/sasdataloader/utest_averaging_analytical.py @@ -901,9 +901,8 @@ def test_sectorq_averaging_with_fold(self): phi_max = 5*np.pi/6 nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - wedge_object = SectorQ(r_min=r_min, r_max=r_max, - phi_min=phi_min + np.pi, - phi_max=phi_max + np.pi, nbins=nbins) + wedge_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min, + phi_max=phi_max, nbins=nbins) # Explicitly set fold to True - points either side of 0,0 are averaged wedge_object.fold = True data1d = wedge_object(averager_data.data) From 85cd0ef8a195e22ec0c36b79c5f1020f63399c95 Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Fri, 22 Sep 2023 18:10:48 +0100 Subject: [PATCH 12/49] Added unit tests for DirectionalAverage class --- sasdata/data_util/new_manipulations.py | 4 + .../utest_averaging_analytical.py | 153 +++++++++++++++++- 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/sasdata/data_util/new_manipulations.py b/sasdata/data_util/new_manipulations.py index af2f7580..572f3d47 100644 --- a/sasdata/data_util/new_manipulations.py +++ b/sasdata/data_util/new_manipulations.py @@ -88,7 +88,11 @@ def __init__(self, major_axis=None, minor_axis=None, major_lims=None, self.major_axis = np.asarray(major_axis) self.minor_axis = np.asarray(minor_axis) + if self.major_axis.size != self.minor_axis.size: + msg = "Major and minor axes must have same length" + raise ValueError(msg) # In some cases all values from a given axis are part of the ROI. + # An alternative approach may be needed for fractional weights. if major_lims is None: self.major_lims = (self.major_axis.min(), self.major_axis.max()) else: diff --git a/test/sasdataloader/utest_averaging_analytical.py b/test/sasdataloader/utest_averaging_analytical.py index 58842d0d..53ce39aa 100644 --- a/test/sasdataloader/utest_averaging_analytical.py +++ b/test/sasdataloader/utest_averaging_analytical.py @@ -13,7 +13,8 @@ from sasdata.dataloader import data_info from sasdata.data_util.new_manipulations import (SlabX, SlabY, Boxsum, Boxavg, CircularAverage, Ring, - SectorQ, WedgeQ, WedgePhi) + SectorQ, WedgeQ, WedgePhi, + DirectionalAverage) class MatrixToData2D: @@ -1041,5 +1042,155 @@ def test_wedgephi_averaging(self): self.assertAlmostEqual(actual_area, expected_area, 1) +class DirectionalAverageValidationTests(unittest.TestCase): + """ + This class tests DirectionalAverage's data validation checks. + """ + + def test_missing_coordinate_data(self): + """ + Ensure a ValueError is raised if no axis data is supplied. + """ + self.assertRaises(ValueError, DirectionalAverage, + major_axis=None, minor_axis=None) + + def test_inappropriate_limits_arrays(self): + """ + Ensure a ValueError is raised if the wrong number of limits is suppied. + """ + self.assertRaises(ValueError, DirectionalAverage, major_axis=[], + minor_axis=[], major_lims=[], minor_lims=[]) + + def test_nbins_not_int(self): + """ + Ensure a TypeError is raised if the parameter nbins is not an integer. + """ + self.assertRaises(TypeError, DirectionalAverage, major_axis=[], + minor_axis=[], nbins=10.0) + + def test_axes_unequal_lengths(self): + """ + Ensure ValueError is raised if the major and minor axes don't match. + """ + self.assertRaises(ValueError, DirectionalAverage, major_axis=[0, 1, 2], + minor_axis=[3, 4]) + + def test_no_limits_on_an_axis(self): + """ + Ensure correct behaviour when there are no limits provided. + The min. and max. values from major/minor_axis are taken as the limits. + """ + dir_avg = DirectionalAverage(major_axis=[1, 2, 3], + minor_axis=[4, 5, 6]) + self.assertEqual(dir_avg.major_lims, (1, 3)) + self.assertEqual(dir_avg.minor_lims, (4, 6)) + + +class DirectionalAverageFunctionalityTests(unittest.TestCase): + """ + Placeholder + """ + + def setUp(self): + """ + Setup for the DirectionalAverageFunctionalityTests tests. + """ + + # 21 bins, with spacing 0.1 + self.qx_data = np.linspace(-1, 1, 21) + self.qy_data = self.qx_data + x, y = np.meshgrid(self.qx_data, self.qy_data) + # quadratic in x, linear in y + data = x * x * y + self.data2d = MatrixToData2D(data) + + # ROI is the first quadrant only. Same limits for both axes. + self.lims = (0.0, 1.0) + self.in_roi = (self.lims[0] <= self.qx_data) & \ + (self.qx_data <= self.lims[1]) + self.nbins = int(np.sum(self.in_roi)) + # Note that the bin width is less than the spacing of the datapoints, + # because we're insisting that there be as many bins as datapoints. + self.bin_width = (self.lims[1] - self.lims[0]) / self.nbins + + self.directional_average = \ + DirectionalAverage(major_axis=self.data2d.data.qx_data, + minor_axis=self.data2d.data.qy_data, + major_lims=self.lims, + minor_lims=self.lims, nbins=self.nbins) + + def test_bin_width(self): + """ + Test that the bin width is calculated correctly. + """ + self.assertAlmostEqual(self.directional_average.bin_width, + self.bin_width) + + def test_get_bin_interval(self): + """ + Test that the get_bin_interval method works correctly. + """ + for b in range(self.nbins): + bin_start, bin_end = self.directional_average.get_bin_interval(b) + expected_bin_start = self.lims[0] + b * self.bin_width + expected_bin_end = self.lims[0] + (b + 1) * self.bin_width + self.assertAlmostEqual(bin_start, expected_bin_start, 10) + self.assertAlmostEqual(bin_end, expected_bin_end, 10) + + def test_get_bin_index(self): + """ + Test that the get_bin_index method works correctly. + """ + # use values at the edges of bins, and values in the middles + values = np.linspace(self.lims[0], self.lims[1], self.nbins * 2) + expected_indices = np.repeat(np.arange(self.nbins), 2) + for n, v in enumerate(values): + self.assertAlmostEqual(self.directional_average.get_bin_index(v), + expected_indices[n], 10) + + def test_binary_weights(self): + """ + Test weights are calculated correctly when the bins & ROI are aligned. + When aligned perfectly, the weights should be ones and zeros only. + + Variations on this test will be needed once fractional weighting is + possible. These should have ROIs which do not line up perfectly with + the bins. + """ + + # I think this test needs mocks, it'd be very complex otherwise. + # I'm struggling to come up with a test for this one. + pass + + def test_directional_averaging(self): + """ + Test that a directinal average is computed correctly. + + Variations on this test will be needed once fractional weighting is + possible. These should have ROIs which do not line up perfectly with + the bins. + """ + x_axis_values, intensity, errors = \ + self.directional_average(data=self.data2d.data.data, + err_data=self.data2d.data.err_data) + + expected_x = self.qx_data[self.in_roi] + expected_intensity = np.mean(self.qy_data[self.in_roi]) * expected_x**2 + + np.testing.assert_array_almost_equal(x_axis_values, expected_x, 10) + np.testing.assert_array_almost_equal(intensity, expected_intensity, 10) + # TODO - also implement check for correct errors + + def test_no_points_in_roi(self): + """ + Test that ValueError is raised if there were on points in the ROI. + """ + # move the region of interest to outside the range of the data + self.directional_average.major_lims = (2, 3) + self.directional_average.minor_lims = (2, 3) + self.assertRaises(ValueError, self.directional_average, + self.data2d.data.data, self.data2d.data.err_data) + + if __name__ == '__main__': unittest.main() From 31eae908c9328cba238d9f65e563bdca33a3fa49 Mon Sep 17 00:00:00 2001 From: krzywon Date: Mon, 16 Oct 2023 10:03:28 -0400 Subject: [PATCH 13/49] Move averaging tests from data loader to manipulations folder --- test/{sasdataloader => sasmanipulations}/utest_averaging.py | 0 .../utest_averaging_analytical.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename test/{sasdataloader => sasmanipulations}/utest_averaging.py (100%) rename test/{sasdataloader => sasmanipulations}/utest_averaging_analytical.py (100%) diff --git a/test/sasdataloader/utest_averaging.py b/test/sasmanipulations/utest_averaging.py similarity index 100% rename from test/sasdataloader/utest_averaging.py rename to test/sasmanipulations/utest_averaging.py diff --git a/test/sasdataloader/utest_averaging_analytical.py b/test/sasmanipulations/utest_averaging_analytical.py similarity index 100% rename from test/sasdataloader/utest_averaging_analytical.py rename to test/sasmanipulations/utest_averaging_analytical.py From 346946052de44915951e8e52a44a860b810725e9 Mon Sep 17 00:00:00 2001 From: krzywon Date: Mon, 16 Oct 2023 11:08:59 -0400 Subject: [PATCH 14/49] Move files used in averaging tests --- test/sasmanipulations/data/MAR07232_rest.h5 | Bin 0 -> 544104 bytes test/sasmanipulations/data/avg_testdata.txt | 21 ++++++++++++++++++ .../data/ring_testdata.txt | 0 .../data/sectorphi_testdata.txt | 0 .../data/sectorq_testdata.txt | 0 .../data/slabx_testdata.txt | 0 .../data/slaby_testdata.txt | 0 7 files changed, 21 insertions(+) create mode 100644 test/sasmanipulations/data/MAR07232_rest.h5 create mode 100644 test/sasmanipulations/data/avg_testdata.txt rename test/{sasdataloader => sasmanipulations}/data/ring_testdata.txt (100%) rename test/{sasdataloader => sasmanipulations}/data/sectorphi_testdata.txt (100%) rename test/{sasdataloader => sasmanipulations}/data/sectorq_testdata.txt (100%) rename test/{sasdataloader => sasmanipulations}/data/slabx_testdata.txt (100%) rename test/{sasdataloader => sasmanipulations}/data/slaby_testdata.txt (100%) diff --git a/test/sasmanipulations/data/MAR07232_rest.h5 b/test/sasmanipulations/data/MAR07232_rest.h5 new file mode 100644 index 0000000000000000000000000000000000000000..053eecaf64d449f3b8328ff7ea02a4f3b7962978 GIT binary patch literal 544104 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%0D2~ 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 literal 0 HcmV?d00001 diff --git a/test/sasmanipulations/data/avg_testdata.txt b/test/sasmanipulations/data/avg_testdata.txt new file mode 100644 index 00000000..3fd5dde3 --- /dev/null +++ b/test/sasmanipulations/data/avg_testdata.txt @@ -0,0 +1,21 @@ +0.00019987186878 -0.01196215 0.148605728355 +0.000453772721237 0.02091606 0.23372601 +0.000750492390439 -0.01337855 0.17169562 +0.00103996394336 0.03062 0.13136407 +0.0013420198959 0.0811008333333 0.10681163 +0.001652061869 0.167022288372 0.10098903 +0.00196086470492 27.5554711176 0.7350533 +0.00226262401224 105.031578947 1.35744586624 +0.00256734439716 82.1791776119 1.10749938588 +0.0028637128388 54.714657971 0.890486416264 +0.00315257408712 36.8455584416 0.691746880003 +0.00344644126616 24.8938701149 0.534917225468 +0.00374248202229 16.5905619565 0.424655384023 +0.00404393067437 11.4714217925 0.328969543128 +0.004346317814 8.05405805556 0.273083524998 +0.00465162170627 5.5823291129 0.21217630209 +0.00495449803049 4.2574845082 0.186808495528 +0.00525641407066 3.30448963768 0.154743584955 +0.00555735057365 2.6995389781 0.140373302568 +0.00585577429002 2.03298512 0.116418358232 + diff --git a/test/sasdataloader/data/ring_testdata.txt b/test/sasmanipulations/data/ring_testdata.txt similarity index 100% rename from test/sasdataloader/data/ring_testdata.txt rename to test/sasmanipulations/data/ring_testdata.txt diff --git a/test/sasdataloader/data/sectorphi_testdata.txt b/test/sasmanipulations/data/sectorphi_testdata.txt similarity index 100% rename from test/sasdataloader/data/sectorphi_testdata.txt rename to test/sasmanipulations/data/sectorphi_testdata.txt diff --git a/test/sasdataloader/data/sectorq_testdata.txt b/test/sasmanipulations/data/sectorq_testdata.txt similarity index 100% rename from test/sasdataloader/data/sectorq_testdata.txt rename to test/sasmanipulations/data/sectorq_testdata.txt diff --git a/test/sasdataloader/data/slabx_testdata.txt b/test/sasmanipulations/data/slabx_testdata.txt similarity index 100% rename from test/sasdataloader/data/slabx_testdata.txt rename to test/sasmanipulations/data/slabx_testdata.txt diff --git a/test/sasdataloader/data/slaby_testdata.txt b/test/sasmanipulations/data/slaby_testdata.txt similarity index 100% rename from test/sasdataloader/data/slaby_testdata.txt rename to test/sasmanipulations/data/slaby_testdata.txt From dbacebdcb99f780b9f0bea282b444e6ab6ed9fa0 Mon Sep 17 00:00:00 2001 From: krzywon Date: Mon, 16 Oct 2023 11:09:37 -0400 Subject: [PATCH 15/49] Create and apply interval type enum to remove hard-coded strings --- sasdata/data_util/new_manipulations.py | 84 +++++++++++++------------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/sasdata/data_util/new_manipulations.py b/sasdata/data_util/new_manipulations.py index 572f3d47..8b116621 100644 --- a/sasdata/data_util/new_manipulations.py +++ b/sasdata/data_util/new_manipulations.py @@ -1,40 +1,44 @@ """ This module contains various data processors used by Sasview's slicers. """ - +from enum import StrEnum, auto import numpy as np from sasdata.dataloader.data_info import Data1D, Data2D -def weights_for_interval(array, l_bound, u_bound, interval_type='half-open'): - """ - Weight coordinate data by position relative to a specified interval. - - :param array: the array for which the weights are calculated - :param l_bound: value defining the lower limit of the region of interest - :param u_bound: value defining the upper limit of the region of interest - :param interval_type: determines whether the value defined by u_bound is - included within the interval. - - If and when fractional binning is implemented (ask Lucas), this function - will be changed so that instead of outputting zeros and ones, it gives - fractional values instead. These will depend on how close the array value - is to being within the interval defined. - """ +class IntervalType(StrEnum): + HALF_OPEN = auto() + CLOSED = auto() + + def weights_for_interval(self, array, l_bound, u_bound): + """ + Weight coordinate data by position relative to a specified interval. - # Whether the endpoint should be included depends on circumstance. - # Half-open is used when binning the major axis (except for the final bin) - # and closed used for the minor axis and the final bin of the major axis. - if interval_type == 'half-open': - in_range = np.logical_and(l_bound <= array, array < u_bound) - elif interval_type == 'closed': - in_range = np.logical_and(l_bound <= array, array <= u_bound) - else: - msg = f"Unrecognised interval_type: {interval_type}" - raise ValueError(msg) + :param array: the array for which the weights are calculated + :param l_bound: value defining the lower limit of the region of interest + :param u_bound: value defining the upper limit of the region of interest + :param interval_type: determines whether the value defined by u_bound is + included within the interval. + + If and when fractional binning is implemented (ask Lucas), this function + will be changed so that instead of outputting zeros and ones, it gives + fractional values instead. These will depend on how close the array value + is to being within the interval defined. + """ + + # Whether the endpoint should be included depends on circumstance. + # Half-open is used when binning the major axis (except for the final bin) + # and closed used for the minor axis and the final bin of the major axis. + if self.name.lower() == 'half_open': + in_range = np.logical_and(l_bound <= array, array < u_bound) + elif self.name.lower() == 'closed': + in_range = np.logical_and(l_bound <= array, array <= u_bound) + else: + msg = f"Unrecognised interval_type: {self.name}" + raise ValueError(msg) - return np.asarray(in_range, dtype=int) + return np.asarray(in_range, dtype=int) class DirectionalAverage: @@ -146,23 +150,22 @@ def compute_weights(self): index. """ major_weights = np.zeros((self.nbins, self.major_axis.size)) + closed = IntervalType.CLOSED for m in range(self.nbins): # Include the value at the end of the binning range, but in # general use half-open intervals so each value belongs in only # one bin. if m == self.nbins - 1: - interval = 'closed' + interval = closed else: - interval = 'half-open' + interval = IntervalType.HALF_OPEN bin_start, bin_end = self.get_bin_interval(bin_number=m) - major_weights[m] = weights_for_interval(array=self.major_axis, + major_weights[m] = interval.weights_for_interval(array=self.major_axis, l_bound=bin_start, - u_bound=bin_end, - interval_type=interval) - minor_weights = weights_for_interval(array=self.minor_axis, + u_bound=bin_end) + minor_weights = closed.weights_for_interval(array=self.minor_axis, l_bound=self.minor_lims[0], - u_bound=self.minor_lims[1], - interval_type='closed') + u_bound=self.minor_lims[1]) return major_weights * minor_weights def __call__(self, data, err_data): @@ -355,14 +358,13 @@ def _sum(self) -> float: """ # Currently the weights are binary, but could be fractional in future - x_weights = weights_for_interval(array=self.qx_data, + interval = IntervalType.CLOSED + x_weights = interval.weights_for_interval(array=self.qx_data, l_bound=self.qx_min, - u_bound=self.qx_max, - interval_type='closed') - y_weights = weights_for_interval(array=self.qy_data, + u_bound=self.qx_max) + y_weights = interval.weights_for_interval(array=self.qy_data, l_bound=self.qy_min, - u_bound=self.qy_max, - interval_type='closed') + u_bound=self.qy_max) weights = x_weights * y_weights data = weights * self.data From 9941521f323dd914360ba6e3ff5b377b9ca4d64f Mon Sep 17 00:00:00 2001 From: krzywon Date: Mon, 16 Oct 2023 11:48:41 -0400 Subject: [PATCH 16/49] Allow for non-linear bin spacings in the directional averaging --- sasdata/data_util/new_manipulations.py | 34 +++++++++++++++++--------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/sasdata/data_util/new_manipulations.py b/sasdata/data_util/new_manipulations.py index 8b116621..64713cd3 100644 --- a/sasdata/data_util/new_manipulations.py +++ b/sasdata/data_util/new_manipulations.py @@ -106,24 +106,33 @@ def __init__(self, major_axis=None, minor_axis=None, major_lims=None, else: self.minor_lims = minor_lims self.nbins = nbins + # Assume a linear spacing for now, but allow for log, fibonacci, etc. implementations in the future + # Add one to bin because this is for the limits, not centroids. + self.bin_limits = np.linspace(self.major_lims[0], self.major_lims[1], self.nbins + 1) @property - def bin_width(self): - """ - Return the bin width based on the range of the major axis and nbins + def bin_widths(self) -> np.ndarray: + """Return a numpy array of all bin widths, regardless of the point spacings.""" + return np.asarray([self.bin_width_n(i) for i in range(0, self.nbins)]) + + def bin_width_n(self, bin_number: int) -> float: + """Calculate the bin width for the nth bin. + :param bin_number: The starting array index of the bin between 0 and self.nbins - 1. + :return: The bin width, as a float. """ - return (self.major_lims[1] - self.major_lims[0]) / self.nbins + lower, upper = self.get_bin_interval(bin_number) + return upper - lower - def get_bin_interval(self, bin_number): + def get_bin_interval(self, bin_number: int) -> (float, float): """ - Return the upper and lower limits defining a bin, given its index. + Return the lower and upper limits defining a bin, given its index. :param bin_number: The index of the bin (between 0 and self.nbins - 1) + :return: A tuple of the interval limits as (lower, upper). """ - bin_start = self.major_lims[0] + bin_number * self.bin_width - bin_end = self.major_lims[0] + (bin_number + 1) * self.bin_width - - return bin_start, bin_end + # Ensure bin_number is an integer and not a float or a string representation + bin_number = int(bin_number) + return self.bin_limits[bin_number], self.bin_limits[bin_number+1] def get_bin_index(self, value): """ @@ -859,7 +868,7 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # Transform all angles to the range [0,2π) where phi_min is at zero, # eliminating errors when the ROI straddles the 2π -> 0 discontinuity. - # Remember to transform back afterwards as we're plotting against phi. + # Remember to transform back afterward as we're plotting against phi. phi_offset = self.phi_min self.phi_min = 0.0 self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) @@ -886,7 +895,8 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # In the old manipulations.py, we also had this shift to plot the data # at the centre of the bins. I'm not sure why it's only angular binning # which gets this treatment. - phi_data += directional_average.bin_width / 2 + # TODO: Update this once non-linear binning options are implemented + phi_data += directional_average.bin_widths / 2 return Data1D(x=phi_data, y=intensity, dy=error) From 8eba5a9c4283edc8bc0547f5a31ac8a921924f5e Mon Sep 17 00:00:00 2001 From: krzywon Date: Mon, 16 Oct 2023 11:49:07 -0400 Subject: [PATCH 17/49] Update unit tests to account for new bin widths --- test/sasmanipulations/utest_averaging_analytical.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/sasmanipulations/utest_averaging_analytical.py b/test/sasmanipulations/utest_averaging_analytical.py index 53ce39aa..9b788352 100644 --- a/test/sasmanipulations/utest_averaging_analytical.py +++ b/test/sasmanipulations/utest_averaging_analytical.py @@ -1123,8 +1123,7 @@ def test_bin_width(self): """ Test that the bin width is calculated correctly. """ - self.assertAlmostEqual(self.directional_average.bin_width, - self.bin_width) + self.assertAlmostEqual(np.average(self.directional_average.bin_widths), self.bin_width) def test_get_bin_interval(self): """ From 53965a48b06440dec5063e2c9171a9fda26107c8 Mon Sep 17 00:00:00 2001 From: krzywon Date: Mon, 16 Oct 2023 12:49:39 -0400 Subject: [PATCH 18/49] Rename manipulations_new to averaging and update internal references to match --- sasdata/data_util/{new_manipulations.py => averaging.py} | 0 test/sasmanipulations/utest_averaging_analytical.py | 6 ++---- 2 files changed, 2 insertions(+), 4 deletions(-) rename sasdata/data_util/{new_manipulations.py => averaging.py} (100%) diff --git a/sasdata/data_util/new_manipulations.py b/sasdata/data_util/averaging.py similarity index 100% rename from sasdata/data_util/new_manipulations.py rename to sasdata/data_util/averaging.py diff --git a/test/sasmanipulations/utest_averaging_analytical.py b/test/sasmanipulations/utest_averaging_analytical.py index 9b788352..e6f09d8c 100644 --- a/test/sasmanipulations/utest_averaging_analytical.py +++ b/test/sasmanipulations/utest_averaging_analytical.py @@ -11,10 +11,8 @@ from scipy import integrate from sasdata.dataloader import data_info -from sasdata.data_util.new_manipulations import (SlabX, SlabY, Boxsum, Boxavg, - CircularAverage, Ring, - SectorQ, WedgeQ, WedgePhi, - DirectionalAverage) +from sasdata.data_util.averaging import (SlabX, SlabY, Boxsum, Boxavg, CircularAverage, Ring, + SectorQ, WedgeQ, WedgePhi, DirectionalAverage) class MatrixToData2D: From bb66e0a593f1f46a30e8c354c40482eb38b0093a Mon Sep 17 00:00:00 2001 From: krzywon Date: Mon, 16 Oct 2023 12:50:37 -0400 Subject: [PATCH 19/49] Add deprecation warning that is triggered on import of manipulations.py --- sasdata/data_util/manipulations.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sasdata/data_util/manipulations.py b/sasdata/data_util/manipulations.py index c470d3fb..cdacc46e 100644 --- a/sasdata/data_util/manipulations.py +++ b/sasdata/data_util/manipulations.py @@ -22,9 +22,13 @@ import math import numpy as np +from typing import Optional, Union +from warnings import warn from sasdata.dataloader.data_info import Data1D, Data2D +warn("sasdata.data_util.manipulations is deprecated and replaced by sasdata.data_util.averaging.", + DeprecationWarning, stacklevel=2) def position_and_wavelength_to_q(dx: float, dy: float, detector_distance: float, wavelength: float) -> float: """ From 82af3682f47353c4b377e0bb6b64cfe8afbe18ca Mon Sep 17 00:00:00 2001 From: krzywon Date: Mon, 16 Oct 2023 13:14:58 -0400 Subject: [PATCH 20/49] Use Enum instead of StrEnum to ensure backwards compatibility with python versions 3.8 and 3.9 --- sasdata/data_util/averaging.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index 64713cd3..c3f8a074 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -1,13 +1,13 @@ """ This module contains various data processors used by Sasview's slicers. """ -from enum import StrEnum, auto +from enum import Enum, auto import numpy as np from sasdata.dataloader.data_info import Data1D, Data2D -class IntervalType(StrEnum): +class IntervalType(Enum): HALF_OPEN = auto() CLOSED = auto() From 953a89b9ef66d60153ba5de2ecb6b460dd160d8a Mon Sep 17 00:00:00 2001 From: krzywon Date: Tue, 24 Oct 2023 15:24:24 -0400 Subject: [PATCH 21/49] Move 2D data restructure function to data_info where it is more semantically relevant --- sasdata/data_util/manipulations.py | 38 ----------------------------- sasdata/dataloader/data_info.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/sasdata/data_util/manipulations.py b/sasdata/data_util/manipulations.py index cdacc46e..bd8ba3fa 100644 --- a/sasdata/data_util/manipulations.py +++ b/sasdata/data_util/manipulations.py @@ -236,44 +236,6 @@ def get_dq_data(data2d: Data2D) -> np.array: dq_data = np.sqrt(dqx_data**2 + dqy_data**2) return dq_data -################################################################################ - - -def reader2D_converter(data2d: Data2D | None = None) -> Data2D: - """ - convert old 2d format opened by IhorReader or danse_reader - to new Data2D format - This is mainly used by the Readers - - :param data2d: 2d array of Data2D object - :return: 1d arrays of Data2D object - - """ - if data2d.data is None or data2d.x_bins is None or data2d.y_bins is None: - raise ValueError("Can't convert this data: data=None...") - new_x = np.tile(data2d.x_bins, (len(data2d.y_bins), 1)) - new_y = np.tile(data2d.y_bins, (len(data2d.x_bins), 1)) - new_y = new_y.swapaxes(0, 1) - - new_data = data2d.data.flatten() - qx_data = new_x.flatten() - qy_data = new_y.flatten() - q_data = np.sqrt(qx_data * qx_data + qy_data * qy_data) - if data2d.err_data is None or np.any(data2d.err_data <= 0): - new_err_data = np.sqrt(np.abs(new_data)) - else: - new_err_data = data2d.err_data.flatten() - mask = np.ones(len(new_data), dtype=bool) - - output = data2d - output.data = new_data - output.err_data = new_err_data - output.qx_data = qx_data - output.qy_data = qy_data - output.q_data = q_data - output.mask = mask - - return output ################################################################################ diff --git a/sasdata/dataloader/data_info.py b/sasdata/dataloader/data_info.py index 16676a4b..38270fe2 100644 --- a/sasdata/dataloader/data_info.py +++ b/sasdata/dataloader/data_info.py @@ -22,6 +22,8 @@ import copy import math from math import fabs +import copy +from typing import Optional import numpy as np @@ -1234,6 +1236,43 @@ def _perform_union(self, other): return result +def reader2D_converter(data2d: Optional[Data2D] = None) -> Data2D: + """ + convert old 2d format opened by IhorReader or danse_reader + to new Data2D format + This is mainly used by the Readers + + :param data2d: 2d array of Data2D object + :return: 1d arrays of Data2D object + + """ + if data2d.data is None or data2d.x_bins is None or data2d.y_bins is None: + raise ValueError("Can't convert this data: data=None...") + new_x = np.tile(data2d.x_bins, (len(data2d.y_bins), 1)) + new_y = np.tile(data2d.y_bins, (len(data2d.x_bins), 1)) + new_y = new_y.swapaxes(0, 1) + + new_data = data2d.data.flatten() + qx_data = new_x.flatten() + qy_data = new_y.flatten() + q_data = np.sqrt(qx_data * qx_data + qy_data * qy_data) + if data2d.err_data is None or np.any(data2d.err_data <= 0): + new_err_data = np.sqrt(np.abs(new_data)) + else: + new_err_data = data2d.err_data.flatten() + mask = np.ones(len(new_data), dtype=bool) + + output = data2d + output.data = new_data + output.err_data = new_err_data + output.qx_data = qx_data + output.qy_data = qy_data + output.q_data = q_data + output.mask = mask + + return output + + def combine_data_info_with_plottable(data, datainfo): """ A function that combines the DataInfo data in self.current_datainto with a From 053c3af385cf25464fe63cdb3cad1cf2a4ef4855 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Tue, 24 Oct 2023 17:14:52 +0100 Subject: [PATCH 22/49] Grammar --- sasdata/data_util/manipulations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sasdata/data_util/manipulations.py b/sasdata/data_util/manipulations.py index bd8ba3fa..fdd6a9fa 100644 --- a/sasdata/data_util/manipulations.py +++ b/sasdata/data_util/manipulations.py @@ -200,7 +200,7 @@ def get_dq_data(data2d: Data2D) -> np.array: Get the dq for resolution averaging The pinholes and det. pix contribution present in both direction of the 2D which must be subtracted when - converting to 1D: dq_overlap should calculated ideally at + converting to 1D: dq_overlap should be calculated ideally at q = 0. Note This method works on only pinhole geometry. Extrapolate dqx(r) and dqy(phi) at q = 0, and take an average. ''' From 5ea8793ed1e4d03618eb3517172b077534565d01 Mon Sep 17 00:00:00 2001 From: krzywon Date: Wed, 25 Oct 2023 12:17:04 -0400 Subject: [PATCH 23/49] Revert removal of reader2d_converter from manipulations --- sasdata/data_util/manipulations.py | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/sasdata/data_util/manipulations.py b/sasdata/data_util/manipulations.py index fdd6a9fa..20f94c4d 100644 --- a/sasdata/data_util/manipulations.py +++ b/sasdata/data_util/manipulations.py @@ -236,6 +236,44 @@ def get_dq_data(data2d: Data2D) -> np.array: dq_data = np.sqrt(dqx_data**2 + dqy_data**2) return dq_data +################################################################################ + + +def reader2D_converter(data2d: Optional[Data2D] = None) -> Data2D: + """ + convert old 2d format opened by IhorReader or danse_reader + to new Data2D format + This is mainly used by the Readers + + :param data2d: 2d array of Data2D object + :return: 1d arrays of Data2D object + + """ + if data2d.data is None or data2d.x_bins is None or data2d.y_bins is None: + raise ValueError("Can't convert this data: data=None...") + new_x = np.tile(data2d.x_bins, (len(data2d.y_bins), 1)) + new_y = np.tile(data2d.y_bins, (len(data2d.x_bins), 1)) + new_y = new_y.swapaxes(0, 1) + + new_data = data2d.data.flatten() + qx_data = new_x.flatten() + qy_data = new_y.flatten() + q_data = np.sqrt(qx_data * qx_data + qy_data * qy_data) + if data2d.err_data is None or np.any(data2d.err_data <= 0): + new_err_data = np.sqrt(np.abs(new_data)) + else: + new_err_data = data2d.err_data.flatten() + mask = np.ones(len(new_data), dtype=bool) + + output = data2d + output.data = new_data + output.err_data = new_err_data + output.qx_data = qx_data + output.qy_data = qy_data + output.q_data = q_data + output.mask = mask + + return output ################################################################################ From 0296c49f8e4ed965025ca093bfe7fe3a5d645b41 Mon Sep 17 00:00:00 2001 From: krzywon Date: Wed, 25 Oct 2023 12:20:58 -0400 Subject: [PATCH 24/49] Update deprecation messages --- sasdata/data_util/manipulations.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sasdata/data_util/manipulations.py b/sasdata/data_util/manipulations.py index 20f94c4d..8ca475a2 100644 --- a/sasdata/data_util/manipulations.py +++ b/sasdata/data_util/manipulations.py @@ -27,8 +27,9 @@ from sasdata.dataloader.data_info import Data1D, Data2D -warn("sasdata.data_util.manipulations is deprecated and replaced by sasdata.data_util.averaging.", - DeprecationWarning, stacklevel=2) +warn("sasdata.data_util.manipulations is deprecated. Unless otherwise noted, update your import to " + "sasdata.data_util.averaging.", DeprecationWarning, stacklevel=2) + def position_and_wavelength_to_q(dx: float, dy: float, detector_distance: float, wavelength: float) -> float: """ @@ -249,6 +250,8 @@ def reader2D_converter(data2d: Optional[Data2D] = None) -> Data2D: :return: 1d arrays of Data2D object """ + warn("reader2D_converter should be imported in the future sasdata.dataloader.data_info.", + DeprecationWarning, stacklevel=2) if data2d.data is None or data2d.x_bins is None or data2d.y_bins is None: raise ValueError("Can't convert this data: data=None...") new_x = np.tile(data2d.x_bins, (len(data2d.y_bins), 1)) From db3147b04554e23fc02ba2e5b48bb3b5f28af1f1 Mon Sep 17 00:00:00 2001 From: krzywon Date: Wed, 25 Oct 2023 12:57:59 -0400 Subject: [PATCH 25/49] Port RingCut from manipulations to averaging --- sasdata/data_util/averaging.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index c3f8a074..ec45c712 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -915,3 +915,38 @@ class SectorPhi(WedgePhi): # use through WedgeSlicer.py, so the rewritten version of this class has # been named WedgePhi. +################################################################################ + + +class Ringcut(PolarROI): + """ + Defines a ring on a 2D data set. + The ring is defined by r_min, r_max, and + the position of the center of the ring. + + The data returned is the region inside the ring + + Phi_min and phi_max should be defined between 0 and 2*pi + in anti-clockwise starting from the x- axis on the left-hand side + """ + + def __init__(self, r_min: float = 0.0, r_max: float = 0.0, phi_min: float = 0.0, phi_max: float = 2*np.pi): + super().__init__(r_min, r_max, phi_min, phi_max) + + def __call__(self, data2D: Data2D) -> np.ndarray[bool]: + """ + Apply the ring to the data set. + Returns the angular distribution for a given q range + + :param data2D: Data2D object + + :return: index array in the range + """ + super().validate_and_assign_data(data2D) + + # Get data + q_data = np.sqrt(self.qx_data * self.qx_data + self.qy_data * self.qy_data) + + # check whether each data point is inside ROI + out = (self.r_min <= q_data) & (self.r_max >= q_data) + return out From 044b1e6479fc40fe0d4addaf9e3896a641b15d2b Mon Sep 17 00:00:00 2001 From: krzywon Date: Wed, 25 Oct 2023 12:58:33 -0400 Subject: [PATCH 26/49] Port Boxcut from manipulations to averaging --- sasdata/data_util/averaging.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index ec45c712..f63aae8c 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -950,3 +950,27 @@ def __call__(self, data2D: Data2D) -> np.ndarray[bool]: # check whether each data point is inside ROI out = (self.r_min <= q_data) & (self.r_max >= q_data) return out + + +class Boxcut(CartesianROI): + """ + Find a rectangular 2D region of interest. + """ + + def __init__(self, x_min: float = 0.0, x_max: float = 0.0, y_min: float = 0.0, y_max: float = 0.0): + super().__init__(x_min, x_max, y_min, y_max) + + def __call__(self, data2D: Data2D) -> np.ndarray[bool]: + """ + Find a rectangular 2D region of interest where data points inside the ROI are True, and False otherwise + + :param data2D: Data2D object + :return: mask, 1d array (len = len(data)) + """ + super().validate_and_assign_data(data2D) + + # check whether each data point is inside ROI + outx = (self.qx_min <= self.qx_data) & (self.qx_max > self.qx_data) + outy = (self.qy_min <= self.qy_data) & (self.qy_max > self.qy_data) + + return outx & outy From 49008642064c122c598a0b7ec09e573d016e9d57 Mon Sep 17 00:00:00 2001 From: krzywon Date: Wed, 25 Oct 2023 12:58:56 -0400 Subject: [PATCH 27/49] Update documentation in manipulations to point to new test location --- sasdata/data_util/manipulations.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sasdata/data_util/manipulations.py b/sasdata/data_util/manipulations.py index 8ca475a2..9689cfd5 100644 --- a/sasdata/data_util/manipulations.py +++ b/sasdata/data_util/manipulations.py @@ -1,12 +1,11 @@ """ Data manipulations for 2D data sets. -Using the meta data information, various types of averaging -are performed in Q-space +Using the meta data information, various types of averaging are performed in Q-space To test this module use: ``` cd test -PYTHONPATH=../src/ python2 -m sasdataloader.test.utest_averaging DataInfoTests.test_sectorphi_quarter +PYTHONPATH=../src/ python2 -m sasmanipulations.test.utest_averaging DataInfoTests.test_sectorphi_quarter ``` """ ##################################################################### From 7aa9bf31fa90f5c22c3dd64ff8fdfe26b9787d71 Mon Sep 17 00:00:00 2001 From: krzywon Date: Wed, 25 Oct 2023 17:20:55 -0400 Subject: [PATCH 28/49] Move Sectorcut to averaging from manipulations --- sasdata/data_util/averaging.py | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index f63aae8c..c065e232 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -974,3 +974,52 @@ def __call__(self, data2D: Data2D) -> np.ndarray[bool]: outy = (self.qy_min <= self.qy_data) & (self.qy_max > self.qy_data) return outx & outy + + +class Sectorcut(PolarROI): + """ + Defines a sector (major + minor) region on a 2D data set. + The sector is defined by phi_min, phi_max, + where phi_min and phi_max are defined by the right + and left lines wrt central line. + + Phi_min and phi_max are given in units of radian + and (phi_max-phi_min) should not be larger than pi + """ + + def __init__(self, phi_min: float = 0.0, phi_max: float = np.pi): + super().__init__(0, np.inf, phi_min, phi_max) + + def __call__(self, data2D: Data2D) -> np.ndarray[bool]: + """ + Find a rectangular 2D region of interest where data points inside the ROI are True, and False otherwise + + :param data2D: Data2D object + :return: mask, 1d array (len = len(data)) + """ + super().validate_and_assign_data(data2D) + + phi_offset = self.phi_min + self.phi_min = 0.0 + self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) + self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) + + phi_min_major, phi_max_major = (self.r_min, self.r_max) + phi_min_minor, phi_max_minor = (self.phi_min, self.phi_max) + + if phi_min_major > phi_max_major: + out_major = (phi_min_major <= self.phi_data) + \ + (phi_max_major > self.phi_data) + else: + out_major = (phi_min_major <= self.phi_data) & ( + phi_max_major > self.phi_data) + + if phi_min_minor > phi_max_minor: + out_minor = (phi_min_minor <= self.phi_data) + \ + (phi_max_minor >= self.phi_data) + else: + out_minor = (phi_min_minor <= self.phi_data) & \ + (phi_max_minor >= self.phi_data) + out = out_major & out_minor + + return out From 2d2874be8951a2205972e223143177a16ccd73f0 Mon Sep 17 00:00:00 2001 From: krzywon Date: Wed, 25 Oct 2023 17:21:51 -0400 Subject: [PATCH 29/49] Use unmasked data for masking purposes --- sasdata/data_util/averaging.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index c065e232..163d07a8 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -944,8 +944,8 @@ def __call__(self, data2D: Data2D) -> np.ndarray[bool]: """ super().validate_and_assign_data(data2D) - # Get data - q_data = np.sqrt(self.qx_data * self.qx_data + self.qy_data * self.qy_data) + # Calculate q_data using unmasked qx_data and qy_data + q_data = np.sqrt(data2D.qx_data * data2D.qx_data + data2D.qy_data * data2D.qy_data) # check whether each data point is inside ROI out = (self.r_min <= q_data) & (self.r_max >= q_data) @@ -970,8 +970,8 @@ def __call__(self, data2D: Data2D) -> np.ndarray[bool]: super().validate_and_assign_data(data2D) # check whether each data point is inside ROI - outx = (self.qx_min <= self.qx_data) & (self.qx_max > self.qx_data) - outy = (self.qy_min <= self.qy_data) & (self.qy_max > self.qy_data) + outx = (self.qx_min <= data2D.qx_data) & (self.qx_max > data2D.qx_data) + outy = (self.qy_min <= data2D.qy_data) & (self.qy_max > data2D.qy_data) return outx & outy From 14a8f1204da7eeb0ffb7a203c91c698f6ce6dc4e Mon Sep 17 00:00:00 2001 From: krzywon Date: Thu, 26 Oct 2023 15:37:36 -0400 Subject: [PATCH 30/49] Fix issue where sector cut only masked one half of region --- sasdata/data_util/averaging.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index 163d07a8..fb0379b0 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -999,27 +999,27 @@ def __call__(self, data2D: Data2D) -> np.ndarray[bool]: """ super().validate_and_assign_data(data2D) + # Ensure unmasked data is used for the phi_data calculation to ensure data sizes match + self.phi_data = np.arctan2(data2D.qy_data, data2D.qx_data) + # Calculate q_data using unmasked qx_data and qy_data to ensure data sizes match + q_data = np.sqrt(data2D.qx_data * data2D.qx_data + data2D.qy_data * data2D.qy_data) + phi_offset = self.phi_min self.phi_min = 0.0 self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) + phi_shifted = self.phi_data - np.pi - phi_min_major, phi_max_major = (self.r_min, self.r_max) - phi_min_minor, phi_max_minor = (self.phi_min, self.phi_max) + # Determine angular bounds for both upper and lower half of image + phi_min_angle, phi_max_angle = (self.phi_min, self.phi_max) - if phi_min_major > phi_max_major: - out_major = (phi_min_major <= self.phi_data) + \ - (phi_max_major > self.phi_data) - else: - out_major = (phi_min_major <= self.phi_data) & ( - phi_max_major > self.phi_data) + # Determine regions of interest + out_radial = (self.r_min <= q_data) & (self.r_max > q_data) + out_upper = (phi_min_angle <= self.phi_data) & (phi_max_angle >= self.phi_data) + out_lower = (phi_min_angle <= phi_shifted) & (phi_max_angle >= phi_shifted) - if phi_min_minor > phi_max_minor: - out_minor = (phi_min_minor <= self.phi_data) + \ - (phi_max_minor >= self.phi_data) - else: - out_minor = (phi_min_minor <= self.phi_data) & \ - (phi_max_minor >= self.phi_data) - out = out_major & out_minor + upper_roi = out_radial & out_upper + lower_roi = out_radial & out_lower + out = upper_roi | lower_roi return out From 07f4bed681f6f85a97a678b07e463f6c66042ec1 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Wed, 6 Dec 2023 14:51:43 +0000 Subject: [PATCH 31/49] Type hinting --- sasdata/data_util/averaging.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index fb0379b0..c2c1e6b4 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -4,6 +4,9 @@ from enum import Enum, auto import numpy as np +from numpy.typing import ArrayLike +from typing import Optional + from sasdata.dataloader.data_info import Data1D, Data2D @@ -18,8 +21,6 @@ def weights_for_interval(self, array, l_bound, u_bound): :param array: the array for which the weights are calculated :param l_bound: value defining the lower limit of the region of interest :param u_bound: value defining the upper limit of the region of interest - :param interval_type: determines whether the value defined by u_bound is - included within the interval. If and when fractional binning is implemented (ask Lucas), this function will be changed so that instead of outputting zeros and ones, it gives @@ -63,8 +64,12 @@ class DirectionalAverage: upon by SasView however, so I haven't implemented it here (yet). """ - def __init__(self, major_axis=None, minor_axis=None, major_lims=None, - minor_lims=None, nbins=100): + def __init__(self, + major_axis: ArrayLike, + minor_axis: ArrayLike, + major_lims: Optional[tuple[float, float]]=None, + minor_lims: Optional[tuple[float, float]]=None, + nbins: int=100): """ Set up direction of averaging, limits on the ROI, & the number of bins. @@ -78,14 +83,17 @@ def __init__(self, major_axis=None, minor_axis=None, major_lims=None, axis. Given as a 2 element tuple/list. :param nbins: The number of bins the major axis is divided up into. """ - if any(not isinstance(coordinate_data, (list, np.ndarray)) for + + if any(not hasattr(coordinate_data, "__array__") for coordinate_data in (major_axis, minor_axis)): msg = "Must provide major & minor coordinate arrays for binning." raise ValueError(msg) + if any(lims is not None and len(lims) != 2 for lims in (major_lims, minor_lims)): msg = "Limits arrays must have 2 elements or be NoneType" raise ValueError(msg) + if not isinstance(nbins, int): msg = "Parameter 'nbins' must be an integer" raise TypeError(msg) @@ -677,6 +685,7 @@ def __init__(self, r_min: float, r_max: float, phi_min: float, """ super().__init__(r_min=r_min, r_max=r_max, phi_min=phi_min, phi_max=phi_max) + self.nbins = nbins self.fold = fold From cc05ea7154fe8daf05061c6e706fb372e75dc646 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Thu, 7 Dec 2023 10:15:13 +0000 Subject: [PATCH 32/49] Fix for first bug --- sasdata/data_util/averaging.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index c2c1e6b4..7f0597ab 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -67,9 +67,9 @@ class DirectionalAverage: def __init__(self, major_axis: ArrayLike, minor_axis: ArrayLike, - major_lims: Optional[tuple[float, float]]=None, - minor_lims: Optional[tuple[float, float]]=None, - nbins: int=100): + major_lims: Optional[tuple[float, float]] = None, + minor_lims: Optional[tuple[float, float]] = None, + nbins: int = 100): """ Set up direction of averaging, limits on the ROI, & the number of bins. @@ -95,8 +95,12 @@ def __init__(self, raise ValueError(msg) if not isinstance(nbins, int): - msg = "Parameter 'nbins' must be an integer" - raise TypeError(msg) + # TODO: Make classes that depend on this provide ints, its quite a thing to fix though + try: + nbins = int(nbins) + except: + msg = f"Parameter 'nbins' must should be convertable to an integer via int(), got type {type(nbins)} (={nbins})" + raise TypeError(msg) self.major_axis = np.asarray(major_axis) self.minor_axis = np.asarray(minor_axis) @@ -685,7 +689,7 @@ def __init__(self, r_min: float, r_max: float, phi_min: float, """ super().__init__(r_min=r_min, r_max=r_max, phi_min=phi_min, phi_max=phi_max) - + self.nbins = nbins self.fold = fold From 91da04ab8f196628dc5d4d285bde101cf480a9e0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 14:07:04 +0000 Subject: [PATCH 33/49] [pre-commit.ci lite] apply automatic fixes for ruff linting errors --- sasdata/data_util/averaging.py | 7 +++---- sasdata/data_util/manipulations.py | 5 ++--- sasdata/dataloader/data_info.py | 4 +--- .../utest_averaging_analytical.py | 15 ++++++++++++--- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index 7f0597ab..8ef6384f 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -2,10 +2,9 @@ This module contains various data processors used by Sasview's slicers. """ from enum import Enum, auto -import numpy as np +import numpy as np from numpy.typing import ArrayLike -from typing import Optional from sasdata.dataloader.data_info import Data1D, Data2D @@ -67,8 +66,8 @@ class DirectionalAverage: def __init__(self, major_axis: ArrayLike, minor_axis: ArrayLike, - major_lims: Optional[tuple[float, float]] = None, - minor_lims: Optional[tuple[float, float]] = None, + major_lims: tuple[float, float] | None = None, + minor_lims: tuple[float, float] | None = None, nbins: int = 100): """ Set up direction of averaging, limits on the ROI, & the number of bins. diff --git a/sasdata/data_util/manipulations.py b/sasdata/data_util/manipulations.py index 9689cfd5..3a211549 100644 --- a/sasdata/data_util/manipulations.py +++ b/sasdata/data_util/manipulations.py @@ -19,10 +19,9 @@ # TODO: copy the meta data from the 2D object to the resulting 1D object import math +from warnings import warn import numpy as np -from typing import Optional, Union -from warnings import warn from sasdata.dataloader.data_info import Data1D, Data2D @@ -239,7 +238,7 @@ def get_dq_data(data2d: Data2D) -> np.array: ################################################################################ -def reader2D_converter(data2d: Optional[Data2D] = None) -> Data2D: +def reader2D_converter(data2d: Data2D | None = None) -> Data2D: """ convert old 2d format opened by IhorReader or danse_reader to new Data2D format diff --git a/sasdata/dataloader/data_info.py b/sasdata/dataloader/data_info.py index 38270fe2..8c3a199e 100644 --- a/sasdata/dataloader/data_info.py +++ b/sasdata/dataloader/data_info.py @@ -22,8 +22,6 @@ import copy import math from math import fabs -import copy -from typing import Optional import numpy as np @@ -1236,7 +1234,7 @@ def _perform_union(self, other): return result -def reader2D_converter(data2d: Optional[Data2D] = None) -> Data2D: +def reader2D_converter(data2d: Data2D | None = None) -> Data2D: """ convert old 2d format opened by IhorReader or danse_reader to new Data2D format diff --git a/test/sasmanipulations/utest_averaging_analytical.py b/test/sasmanipulations/utest_averaging_analytical.py index e6f09d8c..80e09bcb 100644 --- a/test/sasmanipulations/utest_averaging_analytical.py +++ b/test/sasmanipulations/utest_averaging_analytical.py @@ -5,14 +5,23 @@ """ import unittest -from unittest.mock import patch import numpy as np from scipy import integrate +from sasdata.data_util.averaging import ( + Boxavg, + Boxsum, + CircularAverage, + DirectionalAverage, + Ring, + SectorQ, + SlabX, + SlabY, + WedgePhi, + WedgeQ, +) from sasdata.dataloader import data_info -from sasdata.data_util.averaging import (SlabX, SlabY, Boxsum, Boxavg, CircularAverage, Ring, - SectorQ, WedgeQ, WedgePhi, DirectionalAverage) class MatrixToData2D: From 2c3a2a479959fce6f3ab78f43f559eb182298ede Mon Sep 17 00:00:00 2001 From: Dirk Honecker Date: Thu, 13 Nov 2025 22:35:27 +0000 Subject: [PATCH 34/49] test must provide coordinate arrays for binning and can only check that bins can not be converted to an integer. --- sasdata/data_util/averaging.py | 2 +- test/sasmanipulations/utest_averaging_analytical.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index 8ef6384f..b8dba0d7 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -98,7 +98,7 @@ def __init__(self, try: nbins = int(nbins) except: - msg = f"Parameter 'nbins' must should be convertable to an integer via int(), got type {type(nbins)} (={nbins})" + msg = f"Parameter 'nbins' must be convertable to an integer via int(), got type {type(nbins)} (={nbins})" raise TypeError(msg) self.major_axis = np.asarray(major_axis) diff --git a/test/sasmanipulations/utest_averaging_analytical.py b/test/sasmanipulations/utest_averaging_analytical.py index 80e09bcb..8a7c2bd1 100644 --- a/test/sasmanipulations/utest_averaging_analytical.py +++ b/test/sasmanipulations/utest_averaging_analytical.py @@ -1070,10 +1070,11 @@ def test_inappropriate_limits_arrays(self): def test_nbins_not_int(self): """ - Ensure a TypeError is raised if the parameter nbins is not an integer. + Ensure a TypeError is raised if the parameter nbins is not a number + that can be converted to integer. """ - self.assertRaises(TypeError, DirectionalAverage, major_axis=[], - minor_axis=[], nbins=10.0) + self.assertRaises(TypeError, DirectionalAverage, major_axis=np.array([0, 1]), + minor_axis=np.array([0, 1]), nbins=np.array([])) def test_axes_unequal_lengths(self): """ From 78d1f532d27c08d59159c9c7c3ce9ca738682d14 Mon Sep 17 00:00:00 2001 From: Dirk Honecker Date: Thu, 13 Nov 2025 22:52:05 +0000 Subject: [PATCH 35/49] Test_no_limits_on_an_axis must provide arrays for binning. --- test/sasmanipulations/utest_averaging_analytical.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/sasmanipulations/utest_averaging_analytical.py b/test/sasmanipulations/utest_averaging_analytical.py index 8a7c2bd1..189a4e9e 100644 --- a/test/sasmanipulations/utest_averaging_analytical.py +++ b/test/sasmanipulations/utest_averaging_analytical.py @@ -1088,8 +1088,8 @@ def test_no_limits_on_an_axis(self): Ensure correct behaviour when there are no limits provided. The min. and max. values from major/minor_axis are taken as the limits. """ - dir_avg = DirectionalAverage(major_axis=[1, 2, 3], - minor_axis=[4, 5, 6]) + dir_avg = DirectionalAverage(major_axis=np.array([1, 2, 3]), + minor_axis=np.array([4, 5, 6])) self.assertEqual(dir_avg.major_lims, (1, 3)) self.assertEqual(dir_avg.minor_lims, (4, 6)) From fdfefd9ff2addc2d8855a52a2e576368ec0f279d Mon Sep 17 00:00:00 2001 From: Dirk Honecker Date: Fri, 14 Nov 2025 16:12:42 +0000 Subject: [PATCH 36/49] trying to appease codescene --- sasdata/data_util/averaging.py | 216 ++++++++++++++++----------------- 1 file changed, 104 insertions(+), 112 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index b8dba0d7..8210cc80 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -2,6 +2,7 @@ This module contains various data processors used by Sasview's slicers. """ from enum import Enum, auto +from typing import Optional, Tuple import numpy as np from numpy.typing import ArrayLike @@ -10,10 +11,10 @@ class IntervalType(Enum): - HALF_OPEN = auto() - CLOSED = auto() + HALF_OPEN = 'half_open' + CLOSED = 'closed' - def weights_for_interval(self, array, l_bound, u_bound): + def weights_for_interval(self, array: ArrayLike, l_bound: float, u_bound: float) -> np.ndarray: """ Weight coordinate data by position relative to a specified interval. @@ -21,8 +22,8 @@ def weights_for_interval(self, array, l_bound, u_bound): :param l_bound: value defining the lower limit of the region of interest :param u_bound: value defining the upper limit of the region of interest - If and when fractional binning is implemented (ask Lucas), this function - will be changed so that instead of outputting zeros and ones, it gives + Returns a boolean mask indicating membership in the interval. + If fractional binning is implemented, this function should be changed to fractional values instead. These will depend on how close the array value is to being within the interval defined. """ @@ -30,15 +31,26 @@ def weights_for_interval(self, array, l_bound, u_bound): # Whether the endpoint should be included depends on circumstance. # Half-open is used when binning the major axis (except for the final bin) # and closed used for the minor axis and the final bin of the major axis. - if self.name.lower() == 'half_open': + if self is IntervalType.HALF_OPEN: in_range = np.logical_and(l_bound <= array, array < u_bound) - elif self.name.lower() == 'closed': + elif self is IntervalType.CLOSED: in_range = np.logical_and(l_bound <= array, array <= u_bound) else: msg = f"Unrecognised interval_type: {self.name}" raise ValueError(msg) - return np.asarray(in_range, dtype=int) + return np.asarray(in_range, dtype=bool) + +def _normalize_angles(phi_data: np.ndarray, phi_min: float, phi_max: float) -> Tuple[np.ndarray, float, float]: + """ + Normalize phi_data and phi_min/phi_max so phi_min is mapped to 0 and phi_data is in [0, 2*pi). + Returns (phi_data_normalized, phi_min_normalized, phi_max_normalized). + """ + phi_offset = phi_min + phi_min_norm = 0.0 + phi_max_norm = (phi_max - phi_offset) % (2 * np.pi) + phi_data_norm = (phi_data - phi_offset) % (2 * np.pi) + return phi_data_norm, phi_min_norm, phi_max_norm class DirectionalAverage: @@ -60,17 +72,9 @@ class DirectionalAverage: Note that the old version of manipulations.py had an option for logarithmic binning which was only used by SectorQ. This functionality is never called - upon by SasView however, so I haven't implemented it here (yet). - """ + upon by SasView however. - def __init__(self, - major_axis: ArrayLike, - minor_axis: ArrayLike, - major_lims: tuple[float, float] | None = None, - minor_lims: tuple[float, float] | None = None, - nbins: int = 100): - """ - Set up direction of averaging, limits on the ROI, & the number of bins. + Set up direction of averaging, limits on the ROI, & the number of bins. :param major_axis: Coordinate data for axis onto which the 2D data is projected. @@ -81,7 +85,14 @@ def __init__(self, :param minor_lims: Lower and upper bounds of the ROI along the minor axis. Given as a 2 element tuple/list. :param nbins: The number of bins the major axis is divided up into. - """ + """ + + def __init__(self, + major_axis: ArrayLike, + minor_axis: ArrayLike, + major_lims: tuple[float, float] | None = None, + minor_lims: tuple[float, float] | None = None, + nbins: int = 100): if any(not hasattr(coordinate_data, "__array__") for coordinate_data in (major_axis, minor_axis)): @@ -94,7 +105,6 @@ def __init__(self, raise ValueError(msg) if not isinstance(nbins, int): - # TODO: Make classes that depend on this provide ints, its quite a thing to fix though try: nbins = int(nbins) except: @@ -108,17 +118,12 @@ def __init__(self, raise ValueError(msg) # In some cases all values from a given axis are part of the ROI. # An alternative approach may be needed for fractional weights. - if major_lims is None: - self.major_lims = (self.major_axis.min(), self.major_axis.max()) - else: - self.major_lims = major_lims - if minor_lims is None: - self.minor_lims = (self.minor_axis.min(), self.minor_axis.max()) - else: - self.minor_lims = minor_lims + self.major_lims = (self.major_axis.min(), self.major_axis.max()) if major_lims is None else major_lims + self.minor_lims = (self.minor_axis.min(), self.minor_axis.max()) if minor_lims is None else minor_lims + self.nbins = nbins # Assume a linear spacing for now, but allow for log, fibonacci, etc. implementations in the future - # Add one to bin because this is for the limits, not centroids. + # Bin limits define interval edges (nbins + 1 values), not centre. self.bin_limits = np.linspace(self.major_lims[0], self.major_lims[1], self.nbins + 1) @property @@ -134,73 +139,83 @@ def bin_width_n(self, bin_number: int) -> float: lower, upper = self.get_bin_interval(bin_number) return upper - lower - def get_bin_interval(self, bin_number: int) -> (float, float): + def get_bin_interval(self, bin_number: int) -> Tuple[float, float]: """ Return the lower and upper limits defining a bin, given its index. :param bin_number: The index of the bin (between 0 and self.nbins - 1) :return: A tuple of the interval limits as (lower, upper). """ - # Ensure bin_number is an integer and not a float or a string representation + # Ensure bin_number is an integer bin_number = int(bin_number) - return self.bin_limits[bin_number], self.bin_limits[bin_number+1] + return float(self.bin_limits[bin_number]), float(self.bin_limits[bin_number + 1]) - def get_bin_index(self, value): + def get_bin_index(self, value: float) -> int: """ Return the index of the bin to which the supplied value belongs. - :param value: A coordinate value from somewhere along the major axis. """ - numerator = value - self.major_lims[0] - denominator = self.major_lims[1] - self.major_lims[0] + numerator = float(value) - float(self.major_lims[0]) + denominator = float(self.major_lims[1]) - float(self.major_lims[0]) + if denominator == 0: + # all values map to bin 0 + return 0 bin_index = int(np.floor(self.nbins * numerator / denominator)) # Bins are indexed from 0 to nbins-1, so this check protects against # out-of-range indices when value == self.major_lims[1] - if bin_index == self.nbins: - bin_index -= 1 - - return bin_index + return int(np.clip(bin_index, 0, self.nbins - 1)) - def compute_weights(self): + def compute_weights(self) -> np.ndarray: """ Return weights array for the contribution of each datapoint to each bin - Each row of the weights array corresponds to the bin with the same index. """ - major_weights = np.zeros((self.nbins, self.major_axis.size)) + n_points = self.major_axis.size + major_weights = np.zeros((self.nbins, n_points), dtype=float) closed = IntervalType.CLOSED for m in range(self.nbins): # Include the value at the end of the binning range, but in # general use half-open intervals so each value belongs in only # one bin. - if m == self.nbins - 1: - interval = closed - else: - interval = IntervalType.HALF_OPEN + interval = closed if m == self.nbins - 1 else IntervalType.HALF_OPEN bin_start, bin_end = self.get_bin_interval(bin_number=m) - major_weights[m] = interval.weights_for_interval(array=self.major_axis, - l_bound=bin_start, - u_bound=bin_end) - minor_weights = closed.weights_for_interval(array=self.minor_axis, - l_bound=self.minor_lims[0], - u_bound=self.minor_lims[1]) - return major_weights * minor_weights + major_mask = interval.weights_for_interval(array=self.major_axis, + l_bound=bin_start, + u_bound=bin_end) + major_weights[m, :] = major_mask.astype(float) + # If minor_lims is None we include all points in minor axis + if self.minor_lims is None: + minor_mask = np.ones(n_points, dtype=float) + else: + minor_mask = IntervalType.CLOSED.weights_for_interval(array=self.minor_axis, + l_bound=self.minor_lims[0], + u_bound=self.minor_lims[1]).astype(float) + return major_weights * minor_mask[np.newaxis, :] - def __call__(self, data, err_data): + def __call__(self, data: ArrayLike, err_data: ArrayLike): """ Compute the directional average of the supplied intensity & error data. :param data: intensity data from the origninal Data2D object. :param err_data: the corresponding errors for the intensity data. """ + data = np.asarray(data) + err_data = np.asarray(err_data) weights = self.compute_weights() + # Sum across points for each bin + bin_counts = np.sum(weights, axis=1) + # Avoid division by zero: compute sums only where count > 0 x_axis_values = np.sum(weights * self.major_axis, axis=1) intensity = np.sum(weights * data, axis=1) errs_squared = np.sum((weights * err_data)**2, axis=1) - bin_counts = np.sum(weights, axis=1) + + # Prepare results, only compute division where bin_counts > 0 + valid_bins = bin_counts > 0 + if not np.any(valid_bins): + raise ValueError("Average Error: No bins inside ROI to average...") errors = np.sqrt(errs_squared) x_axis_values /= bin_counts @@ -228,11 +243,11 @@ def __init__(self): In classes inheriting from GenericROI, the variables used to define the boundaries of the Region Of Interest are also set up during __init__. """ - self.data = None - self.err_data = None - self.q_data = None - self.qx_data = None - self.qy_data = None + self.data: Optional[np.ndarray] = None + self.err_data: Optional[np.ndarray] = None + self.q_data: Optional[np.ndarray] = None + self.qx_data: Optional[np.ndarray] = None + self.qy_data: Optional[np.ndarray] = None def validate_and_assign_data(self, data2d: Data2D = None) -> None: """ @@ -314,7 +329,7 @@ def __init__(self, r_min: float, r_max: float, """ super().__init__() - self.phi_data = None + self.phi_data: Optional[np.ndarray] = None if r_min >= r_max: msg = "Minimum radius cannot be greater than maximum radius." @@ -380,11 +395,11 @@ def _sum(self) -> float: # Currently the weights are binary, but could be fractional in future interval = IntervalType.CLOSED x_weights = interval.weights_for_interval(array=self.qx_data, - l_bound=self.qx_min, - u_bound=self.qx_max) + l_bound=self.qx_min, + u_bound=self.qx_max).astype(float) y_weights = interval.weights_for_interval(array=self.qy_data, - l_bound=self.qy_min, - u_bound=self.qy_max) + l_bound=self.qy_min, + u_bound=self.qy_max).astype(float) weights = x_weights * y_weights data = weights * self.data @@ -392,9 +407,9 @@ def _sum(self) -> float: # how it was done in the old manipulations.py err_squared = weights * weights * self.err_data * self.err_data - total_sum = np.sum(data) - total_errors_squared = np.sum(err_squared) - total_count = np.sum(weights) + total_sum = float(np.sum(data)) + total_errors_squared = float(np.sum(err_squared)) + total_count = float(np.sum(weights)) return total_sum, np.sqrt(total_errors_squared), total_count @@ -426,7 +441,8 @@ def __call__(self, data2d: Data2D) -> float: """ self.validate_and_assign_data(data2d) total_sum, error, count = super()._sum() - + if count == 0: + raise ValueError("Boxavg: no points in ROI to compute average.") return (total_sum / count), (error / count) @@ -476,14 +492,12 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # the behaviour of fold here will also need to change. Perhaps we could # apply a transformation to the data like the one used in WedgePhi. - if self.fold: - major_lims = (0, self.qx_max) - self.qx_data = np.abs(self.qx_data) - else: - major_lims = (self.qx_min, self.qx_max) + # Use local variables to avoid mutating object state + qx_data_local = np.abs(self.qx_data) if self.fold else self.qx_data + major_lims = (0, self.qx_max) if self.fold else (self.qx_min, self.qx_max) minor_lims = (self.qy_min, self.qy_max) - directional_average = DirectionalAverage(major_axis=self.qx_data, + directional_average = DirectionalAverage(major_axis=qx_data_local, minor_axis=self.qy_data, major_lims=major_lims, minor_lims=minor_lims, @@ -540,14 +554,11 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # the behaviour of fold here will also need to change. Perhaps we could # apply a transformation to the data like the one used in WedgePhi. - if self.fold: - major_lims = (0, self.qy_max) - self.qy_data = np.abs(self.qy_data) - else: - major_lims = (self.qy_min, self.qy_max) + qy_data_local = np.abs(self.qy_data) if self.fold else self.qy_data + major_lims = (0, self.qy_max) if self.fold else (self.qy_min, self.qy_max) minor_lims = (self.qx_min, self.qx_max) - directional_average = DirectionalAverage(major_axis=self.qy_data, + directional_average = DirectionalAverage(major_axis=qy_data_local, minor_axis=self.qx_data, major_lims=major_lims, minor_lims=minor_lims, @@ -704,16 +715,13 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # Transform all angles to the range [0,2π) where phi_min is at zero, # eliminating errors when the ROI straddles the 2π -> 0 discontinuity. # We won't need to convert back later because we're plotting against Q. - phi_offset = self.phi_min - self.phi_min = 0.0 - self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) - self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) + # Work with local normalized angles to avoid mutating object state + phi_data_norm, phi_min_norm, phi_max_norm = _normalize_angles(self.phi_data, self.phi_min, self.phi_max) major_lims = (self.r_min, self.r_max) - minor_lims = (self.phi_min, self.phi_max) + minor_lims = (phi_min_norm, phi_max_norm) # Secondary region of interest covers angles on opposite side of origin - minor_lims_alt = (self.phi_min + np.pi, self.phi_max + np.pi) - + minor_lims_alt = (phi_min_norm + np.pi, phi_max_norm + np.pi) primary_region = DirectionalAverage(major_axis=self.q_data, minor_axis=self.phi_data, major_lims=major_lims, @@ -815,18 +823,10 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # Transform all angles to the range [0,2π) where phi_min is at zero, # eliminating errors when the ROI straddles the 2π -> 0 discontinuity. # We won't need to convert back later because we're plotting against Q. - phi_offset = self.phi_min - self.phi_min = 0.0 - self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) - self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) - + phi_data_norm, phi_min_norm, phi_max_norm = _normalize_angles(self.phi_data, self.phi_min, self.phi_max) # Averaging takes place between radial and angular limits major_lims = (self.r_min, self.r_max) - # When phi_max and phi_min have the same angle, ROI is a full circle. - if self.phi_max == 0: - minor_lims = None - else: - minor_lims = (self.phi_min, self.phi_max) + minor_lims = None if phi_max_norm == 0 else (phi_min_norm, phi_max_norm) directional_average = DirectionalAverage(major_axis=self.q_data, minor_axis=self.phi_data, @@ -882,16 +882,11 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # eliminating errors when the ROI straddles the 2π -> 0 discontinuity. # Remember to transform back afterward as we're plotting against phi. phi_offset = self.phi_min - self.phi_min = 0.0 - self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) - self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) + phi_data_norm, phi_min_norm, phi_max_norm = _normalize_angles(self.phi_data, self.phi_min, self.phi_max) # Averaging takes place between angular and radial limits # When phi_max and phi_min have the same angle, ROI is a full circle. - if self.phi_max == 0: - major_lims = None - else: - major_lims = (self.phi_min, self.phi_max) + major_lims = None if phi_max_norm == 0 else (self.phi_min_norm, phi_max_norm) minor_lims = (self.r_min, self.r_max) directional_average = DirectionalAverage(major_axis=self.phi_data, @@ -1016,14 +1011,11 @@ def __call__(self, data2D: Data2D) -> np.ndarray[bool]: # Calculate q_data using unmasked qx_data and qy_data to ensure data sizes match q_data = np.sqrt(data2D.qx_data * data2D.qx_data + data2D.qy_data * data2D.qy_data) - phi_offset = self.phi_min - self.phi_min = 0.0 - self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) - self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) - phi_shifted = self.phi_data - np.pi + phi_data_norm, phi_min_norm, phi_max_norm = _normalize_angles(self.phi_data, self.phi_min, self.phi_max) + phi_shifted = phi_data_norm - np.pi # Determine angular bounds for both upper and lower half of image - phi_min_angle, phi_max_angle = (self.phi_min, self.phi_max) + phi_min_angle, phi_max_angle = (phi_min_norm, phi_max_norm) # Determine regions of interest out_radial = (self.r_min <= q_data) & (self.r_max > q_data) From 81e8e698622b8195d254a732086c9d9e503a3720 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 14 Nov 2025 16:13:17 +0000 Subject: [PATCH 37/49] [pre-commit.ci lite] apply automatic fixes for ruff linting errors --- sasdata/data_util/averaging.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index 8210cc80..a23b9d1b 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -1,8 +1,7 @@ """ This module contains various data processors used by Sasview's slicers. """ -from enum import Enum, auto -from typing import Optional, Tuple +from enum import Enum import numpy as np from numpy.typing import ArrayLike @@ -41,7 +40,7 @@ def weights_for_interval(self, array: ArrayLike, l_bound: float, u_bound: float) return np.asarray(in_range, dtype=bool) -def _normalize_angles(phi_data: np.ndarray, phi_min: float, phi_max: float) -> Tuple[np.ndarray, float, float]: +def _normalize_angles(phi_data: np.ndarray, phi_min: float, phi_max: float) -> tuple[np.ndarray, float, float]: """ Normalize phi_data and phi_min/phi_max so phi_min is mapped to 0 and phi_data is in [0, 2*pi). Returns (phi_data_normalized, phi_min_normalized, phi_max_normalized). @@ -139,7 +138,7 @@ def bin_width_n(self, bin_number: int) -> float: lower, upper = self.get_bin_interval(bin_number) return upper - lower - def get_bin_interval(self, bin_number: int) -> Tuple[float, float]: + def get_bin_interval(self, bin_number: int) -> tuple[float, float]: """ Return the lower and upper limits defining a bin, given its index. @@ -215,7 +214,7 @@ def __call__(self, data: ArrayLike, err_data: ArrayLike): # Prepare results, only compute division where bin_counts > 0 valid_bins = bin_counts > 0 if not np.any(valid_bins): - raise ValueError("Average Error: No bins inside ROI to average...") + raise ValueError("Average Error: No bins inside ROI to average...") errors = np.sqrt(errs_squared) x_axis_values /= bin_counts @@ -243,11 +242,11 @@ def __init__(self): In classes inheriting from GenericROI, the variables used to define the boundaries of the Region Of Interest are also set up during __init__. """ - self.data: Optional[np.ndarray] = None - self.err_data: Optional[np.ndarray] = None - self.q_data: Optional[np.ndarray] = None - self.qx_data: Optional[np.ndarray] = None - self.qy_data: Optional[np.ndarray] = None + self.data: np.ndarray | None = None + self.err_data: np.ndarray | None = None + self.q_data: np.ndarray | None = None + self.qx_data: np.ndarray | None = None + self.qy_data: np.ndarray | None = None def validate_and_assign_data(self, data2d: Data2D = None) -> None: """ @@ -329,7 +328,7 @@ def __init__(self, r_min: float, r_max: float, """ super().__init__() - self.phi_data: Optional[np.ndarray] = None + self.phi_data: np.ndarray | None = None if r_min >= r_max: msg = "Minimum radius cannot be greater than maximum radius." @@ -1012,7 +1011,7 @@ def __call__(self, data2D: Data2D) -> np.ndarray[bool]: q_data = np.sqrt(data2D.qx_data * data2D.qx_data + data2D.qy_data * data2D.qy_data) phi_data_norm, phi_min_norm, phi_max_norm = _normalize_angles(self.phi_data, self.phi_min, self.phi_max) - phi_shifted = phi_data_norm - np.pi + phi_shifted = phi_data_norm - np.pi # Determine angular bounds for both upper and lower half of image phi_min_angle, phi_max_angle = (phi_min_norm, phi_max_norm) From 1c5bef6ccc64a97cf1590b97eab6c0c662eed87c Mon Sep 17 00:00:00 2001 From: Dirk Honecker Date: Sat, 15 Nov 2025 01:21:50 +0000 Subject: [PATCH 38/49] rewrite to address codescene review comments --- sasdata/data_util/averaging.py | 412 +++++++++--------- .../utest_averaging_analytical.py | 91 ++-- 2 files changed, 237 insertions(+), 266 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index 8210cc80..4848e833 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -2,7 +2,6 @@ This module contains various data processors used by Sasview's slicers. """ from enum import Enum, auto -from typing import Optional, Tuple import numpy as np from numpy.typing import ArrayLike @@ -11,10 +10,10 @@ class IntervalType(Enum): - HALF_OPEN = 'half_open' - CLOSED = 'closed' + HALF_OPEN = auto() + CLOSED = auto() - def weights_for_interval(self, array: ArrayLike, l_bound: float, u_bound: float) -> np.ndarray: + def weights_for_interval(self, array, l_bound, u_bound): """ Weight coordinate data by position relative to a specified interval. @@ -22,8 +21,8 @@ def weights_for_interval(self, array: ArrayLike, l_bound: float, u_bound: float) :param l_bound: value defining the lower limit of the region of interest :param u_bound: value defining the upper limit of the region of interest - Returns a boolean mask indicating membership in the interval. - If fractional binning is implemented, this function should be changed to + If and when fractional binning is implemented (ask Lucas), this function + will be changed so that instead of outputting zeros and ones, it gives fractional values instead. These will depend on how close the array value is to being within the interval defined. """ @@ -31,26 +30,15 @@ def weights_for_interval(self, array: ArrayLike, l_bound: float, u_bound: float) # Whether the endpoint should be included depends on circumstance. # Half-open is used when binning the major axis (except for the final bin) # and closed used for the minor axis and the final bin of the major axis. - if self is IntervalType.HALF_OPEN: + if self.name.lower() == 'half_open': in_range = np.logical_and(l_bound <= array, array < u_bound) - elif self is IntervalType.CLOSED: + elif self.name.lower() == 'closed': in_range = np.logical_and(l_bound <= array, array <= u_bound) else: msg = f"Unrecognised interval_type: {self.name}" raise ValueError(msg) - return np.asarray(in_range, dtype=bool) - -def _normalize_angles(phi_data: np.ndarray, phi_min: float, phi_max: float) -> Tuple[np.ndarray, float, float]: - """ - Normalize phi_data and phi_min/phi_max so phi_min is mapped to 0 and phi_data is in [0, 2*pi). - Returns (phi_data_normalized, phi_min_normalized, phi_max_normalized). - """ - phi_offset = phi_min - phi_min_norm = 0.0 - phi_max_norm = (phi_max - phi_offset) % (2 * np.pi) - phi_data_norm = (phi_data - phi_offset) % (2 * np.pi) - return phi_data_norm, phi_min_norm, phi_max_norm + return np.asarray(in_range, dtype=int) class DirectionalAverage: @@ -72,39 +60,41 @@ class DirectionalAverage: Note that the old version of manipulations.py had an option for logarithmic binning which was only used by SectorQ. This functionality is never called - upon by SasView however. - - Set up direction of averaging, limits on the ROI, & the number of bins. - - :param major_axis: Coordinate data for axis onto which the 2D data is - projected. - :param minor_axis: Coordinate data for the axis perpendicular to the - major axis. - :param major_lims: Lower and upper bounds of the ROI along the major - axis. Given as a 2 element tuple/list. - :param minor_lims: Lower and upper bounds of the ROI along the minor - axis. Given as a 2 element tuple/list. - :param nbins: The number of bins the major axis is divided up into. + upon by SasView however, so I haven't implemented it here (yet). """ def __init__(self, major_axis: ArrayLike, minor_axis: ArrayLike, - major_lims: tuple[float, float] | None = None, - minor_lims: tuple[float, float] | None = None, + lims: tuple[tuple[float, float] | None, tuple[float, float] | None] | None = None, nbins: int = 100): + """ + Set up direction of averaging, limits on the ROI, & the number of bins. + :param major_axis: Coordinate data for axis onto which the 2D data is + projected. + :param minor_axis: Coordinate data for the axis perpendicular to the + major axis. + :param lims: Tuple (major_lims, minor_lims). Each element may be a + 2-tuple or None. + :param nbins: The number of bins the major axis is divided up into. + """ + if any(not hasattr(coordinate_data, "__array__") for coordinate_data in (major_axis, minor_axis)): msg = "Must provide major & minor coordinate arrays for binning." raise ValueError(msg) - if any(lims is not None and len(lims) != 2 for - lims in (major_lims, minor_lims)): - msg = "Limits arrays must have 2 elements or be NoneType" - raise ValueError(msg) + if lims is None: + major_lims = minor_lims = None + else: + if not (isinstance(lims, (list, tuple)) and len(lims) == 2): + msg = "Parameter 'lims' must be a 2-tuple (major_lims, minor_lims) or None." + raise ValueError(msg) + major_lims, minor_lims = lims if not isinstance(nbins, int): + # TODO: Make classes that depend on this provide ints, its quite a thing to fix though try: nbins = int(nbins) except: @@ -118,12 +108,17 @@ def __init__(self, raise ValueError(msg) # In some cases all values from a given axis are part of the ROI. # An alternative approach may be needed for fractional weights. - self.major_lims = (self.major_axis.min(), self.major_axis.max()) if major_lims is None else major_lims - self.minor_lims = (self.minor_axis.min(), self.minor_axis.max()) if minor_lims is None else minor_lims - + if major_lims is None: + self.major_lims = (self.major_axis.min(), self.major_axis.max()) + else: + self.major_lims = major_lims + if minor_lims is None: + self.minor_lims = (self.minor_axis.min(), self.minor_axis.max()) + else: + self.minor_lims = minor_lims self.nbins = nbins # Assume a linear spacing for now, but allow for log, fibonacci, etc. implementations in the future - # Bin limits define interval edges (nbins + 1 values), not centre. + # Add one to bin because this is for the limits, not centroids. self.bin_limits = np.linspace(self.major_lims[0], self.major_lims[1], self.nbins + 1) @property @@ -139,83 +134,73 @@ def bin_width_n(self, bin_number: int) -> float: lower, upper = self.get_bin_interval(bin_number) return upper - lower - def get_bin_interval(self, bin_number: int) -> Tuple[float, float]: + def get_bin_interval(self, bin_number: int) -> (float, float): """ Return the lower and upper limits defining a bin, given its index. :param bin_number: The index of the bin (between 0 and self.nbins - 1) :return: A tuple of the interval limits as (lower, upper). """ - # Ensure bin_number is an integer + # Ensure bin_number is an integer and not a float or a string representation bin_number = int(bin_number) - return float(self.bin_limits[bin_number]), float(self.bin_limits[bin_number + 1]) + return self.bin_limits[bin_number], self.bin_limits[bin_number+1] - def get_bin_index(self, value: float) -> int: + def get_bin_index(self, value): """ Return the index of the bin to which the supplied value belongs. + :param value: A coordinate value from somewhere along the major axis. """ - numerator = float(value) - float(self.major_lims[0]) - denominator = float(self.major_lims[1]) - float(self.major_lims[0]) - if denominator == 0: - # all values map to bin 0 - return 0 + numerator = value - self.major_lims[0] + denominator = self.major_lims[1] - self.major_lims[0] bin_index = int(np.floor(self.nbins * numerator / denominator)) # Bins are indexed from 0 to nbins-1, so this check protects against # out-of-range indices when value == self.major_lims[1] - return int(np.clip(bin_index, 0, self.nbins - 1)) + if bin_index == self.nbins: + bin_index -= 1 + + return bin_index - def compute_weights(self) -> np.ndarray: + def compute_weights(self): """ Return weights array for the contribution of each datapoint to each bin + Each row of the weights array corresponds to the bin with the same index. """ - n_points = self.major_axis.size - major_weights = np.zeros((self.nbins, n_points), dtype=float) + major_weights = np.zeros((self.nbins, self.major_axis.size)) closed = IntervalType.CLOSED for m in range(self.nbins): # Include the value at the end of the binning range, but in # general use half-open intervals so each value belongs in only # one bin. - interval = closed if m == self.nbins - 1 else IntervalType.HALF_OPEN + if m == self.nbins - 1: + interval = closed + else: + interval = IntervalType.HALF_OPEN bin_start, bin_end = self.get_bin_interval(bin_number=m) - major_mask = interval.weights_for_interval(array=self.major_axis, - l_bound=bin_start, - u_bound=bin_end) - major_weights[m, :] = major_mask.astype(float) - # If minor_lims is None we include all points in minor axis - if self.minor_lims is None: - minor_mask = np.ones(n_points, dtype=float) - else: - minor_mask = IntervalType.CLOSED.weights_for_interval(array=self.minor_axis, - l_bound=self.minor_lims[0], - u_bound=self.minor_lims[1]).astype(float) - return major_weights * minor_mask[np.newaxis, :] + major_weights[m] = interval.weights_for_interval(array=self.major_axis, + l_bound=bin_start, + u_bound=bin_end) + minor_weights = closed.weights_for_interval(array=self.minor_axis, + l_bound=self.minor_lims[0], + u_bound=self.minor_lims[1]) + return major_weights * minor_weights - def __call__(self, data: ArrayLike, err_data: ArrayLike): + def __call__(self, data, err_data): """ Compute the directional average of the supplied intensity & error data. :param data: intensity data from the origninal Data2D object. :param err_data: the corresponding errors for the intensity data. """ - data = np.asarray(data) - err_data = np.asarray(err_data) weights = self.compute_weights() - # Sum across points for each bin - bin_counts = np.sum(weights, axis=1) - # Avoid division by zero: compute sums only where count > 0 x_axis_values = np.sum(weights * self.major_axis, axis=1) intensity = np.sum(weights * data, axis=1) errs_squared = np.sum((weights * err_data)**2, axis=1) - - # Prepare results, only compute division where bin_counts > 0 - valid_bins = bin_counts > 0 - if not np.any(valid_bins): - raise ValueError("Average Error: No bins inside ROI to average...") + bin_counts = np.sum(weights, axis=1) errors = np.sqrt(errs_squared) x_axis_values /= bin_counts @@ -243,11 +228,11 @@ def __init__(self): In classes inheriting from GenericROI, the variables used to define the boundaries of the Region Of Interest are also set up during __init__. """ - self.data: Optional[np.ndarray] = None - self.err_data: Optional[np.ndarray] = None - self.q_data: Optional[np.ndarray] = None - self.qx_data: Optional[np.ndarray] = None - self.qy_data: Optional[np.ndarray] = None + self.data = None + self.err_data = None + self.q_data = None + self.qx_data = None + self.qy_data = None def validate_and_assign_data(self, data2d: Data2D = None) -> None: """ @@ -288,19 +273,17 @@ class CartesianROI(GenericROI): Base class for data manipulators with a Cartesian (rectangular) ROI. """ - def __init__(self, qx_min: float = 0, qx_max: float = 0, - qy_min: float = 0, qy_max: float = 0) -> None: + def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[float, float] = (0.0, 0.0)) -> None: """ Assign the variables used to label the properties of the Data2D object. Also establish the upper and lower bounds defining the ROI. The units of these parameters are A^-1 - :param qx_min: Lower bound of the ROI along the Q_x direction. - :param qx_max: Upper bound of the ROI along the Q_x direction. - :param qy_min: Lower bound of the ROI along the Q_y direction. - :param qy_max: Upper bound of the ROI along the Q_y direction. + :param qx_range: Bounds of the ROI along the Q_x direction. + :param qy_range: Bounds of the ROI along the Q_y direction. """ - + qx_min, qx_max = qx_range + qy_min, qy_max = qy_range super().__init__() self.qx_min = qx_min self.qx_max = qx_max @@ -313,23 +296,21 @@ class PolarROI(GenericROI): Base class for data manipulators with a polar ROI. """ - def __init__(self, r_min: float, r_max: float, - phi_min: float = 0, phi_max: float = 2*np.pi) -> None: + def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi)) -> None: """ Assign the variables used to label the properties of the Data2D object. Also establish the upper and lower bounds defining the ROI. The units are A^-1 for radial parameters, and radians for anglar ones. - :param r_min: Lower bound of the ROI along the Q direction. - :param r_max: Upper bound of the ROI along the Q direction. - :param phi_min: Lower bound of the ROI along the Phi direction. - :param phi_max: Upper bound of the ROI along the Phi direction. + :param r_range: Tuple (r_min, r_max) defining limits for |Q| values to use during averaging. + :param phi_range: Tuple (phi_min, phi_max) defining limits for φ in radians (in the ROI). Note that Phi is measured anti-clockwise from the positive x-axis. """ - + r_min, r_max = r_range + phi_min, phi_max = phi_range super().__init__() - self.phi_data: Optional[np.ndarray] = None + self.phi_data = None if r_min >= r_max: msg = "Minimum radius cannot be greater than maximum radius." @@ -360,19 +341,18 @@ class Boxsum(CartesianROI): Compute the sum of the intensity within a rectangular Region Of Interest. """ - def __init__(self, qx_min: float = 0, qx_max: float = 0, - qy_min: float = 0, qy_max: float = 0) -> None: + def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[float, float] = (0.0, 0.0)) -> None: """ Set up the Region of Interest and its boundaries. The units of these parameters are A^-1 - :param qx_min: Lower bound of the ROI along the Q_x direction. - :param qx_max: Upper bound of the ROI along the Q_x direction. - :param qy_min: Lower bound of the ROI along the Q_y direction. - :param qy_max: Upper bound of the ROI along the Q_y direction. + :param qx_range: Bounds of the ROI along the Q_x direction. + :param qy_range: Bounds of the ROI along the Q_y direction. """ - super().__init__(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + qx_min, qx_max = qx_range + qy_min, qy_max = qy_range + super().__init__(qx_range=(qx_min, qx_max), + qy_range=(qy_min, qy_max)) def __call__(self, data2d: Data2D = None) -> float: """ @@ -395,11 +375,11 @@ def _sum(self) -> float: # Currently the weights are binary, but could be fractional in future interval = IntervalType.CLOSED x_weights = interval.weights_for_interval(array=self.qx_data, - l_bound=self.qx_min, - u_bound=self.qx_max).astype(float) + l_bound=self.qx_min, + u_bound=self.qx_max) y_weights = interval.weights_for_interval(array=self.qy_data, - l_bound=self.qy_min, - u_bound=self.qy_max).astype(float) + l_bound=self.qy_min, + u_bound=self.qy_max) weights = x_weights * y_weights data = weights * self.data @@ -407,9 +387,9 @@ def _sum(self) -> float: # how it was done in the old manipulations.py err_squared = weights * weights * self.err_data * self.err_data - total_sum = float(np.sum(data)) - total_errors_squared = float(np.sum(err_squared)) - total_count = float(np.sum(weights)) + total_sum = np.sum(data) + total_errors_squared = np.sum(err_squared) + total_count = np.sum(weights) return total_sum, np.sqrt(total_errors_squared), total_count @@ -419,19 +399,18 @@ class Boxavg(Boxsum): Compute the average intensity within a rectangular Region Of Interest. """ - def __init__(self, qx_min: float = 0, qx_max: float = 0, - qy_min: float = 0, qy_max: float = 0) -> None: + def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[float, float] = (0.0, 0.0)) -> None: """ Set up the Region of Interest and its boundaries. The units of these parameters are A^-1 - :param qx_min: Lower bound of the ROI along the Q_x direction. - :param qx_max: Upper bound of the ROI along the Q_x direction. - :param qy_min: Lower bound of the ROI along the Q_y direction. - :param qy_max: Upper bound of the ROI along the Q_y direction. + :param qx_range: Bounds of the ROI along the Q_x direction. + :param qy_range: Bounds of the ROI along the Q_y direction. """ - super().__init__(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + qx_min, qx_max = qx_range + qy_min, qy_max = qy_range + super().__init__(qx_range=(qx_min, qx_max), + qy_range=(qy_min, qy_max)) def __call__(self, data2d: Data2D) -> float: """ @@ -441,8 +420,7 @@ def __call__(self, data2d: Data2D) -> float: """ self.validate_and_assign_data(data2d) total_sum, error, count = super()._sum() - if count == 0: - raise ValueError("Boxavg: no points in ROI to compute average.") + return (total_sum / count), (error / count) @@ -459,22 +437,21 @@ class SlabX(CartesianROI): resulting in a 1D plot with only positive Q values shown. """ - def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, - qy_max: float = 0, nbins: int = 100, fold: bool = False): + def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[float, float] = (0.0, 0.0), nbins: int = 100, fold: bool = False): """ Set up the ROI boundaries, the binning of the output 1D data, and fold. The units of these parameters are A^-1 - :param qx_min: Lower bound of the ROI along the Q_x direction. - :param qx_max: Upper bound of the ROI along the Q_x direction. - :param qy_min: Lower bound of the ROI along the Q_y direction. - :param qy_max: Upper bound of the ROI along the Q_y direction. + :param qx_range: Bounds of the ROI along the Q_x direction. + :param qy_range: Bounds of the ROI along the Q_y direction. :param nbins: The number of bins data is sorted into along Q_x. :param fold: Whether the two halves of the ROI along Q_x should be folded together during averaging. """ - super().__init__(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + qx_min, qx_max = qx_range + qy_min, qy_max = qy_range + super().__init__(qx_range=(qx_min, qx_max), + qy_range=(qy_min, qy_max)) self.nbins = nbins self.fold = fold @@ -492,15 +469,16 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # the behaviour of fold here will also need to change. Perhaps we could # apply a transformation to the data like the one used in WedgePhi. - # Use local variables to avoid mutating object state - qx_data_local = np.abs(self.qx_data) if self.fold else self.qx_data - major_lims = (0, self.qx_max) if self.fold else (self.qx_min, self.qx_max) + if self.fold: + major_lims = (0, self.qx_max) + self.qx_data = np.abs(self.qx_data) + else: + major_lims = (self.qx_min, self.qx_max) minor_lims = (self.qy_min, self.qy_max) - directional_average = DirectionalAverage(major_axis=qx_data_local, + directional_average = DirectionalAverage(major_axis=self.qx_data, minor_axis=self.qy_data, - major_lims=major_lims, - minor_lims=minor_lims, + lims=(major_lims,minor_lims), nbins=self.nbins) qx_data, intensity, error = \ directional_average(data=self.data, err_data=self.err_data) @@ -521,22 +499,22 @@ class SlabY(CartesianROI): resulting in a 1D plot with only positive Q values shown. """ - def __init__(self, qx_min: float = 0, qx_max: float = 0, qy_min: float = 0, - qy_max: float = 0, nbins: int = 100, fold: bool = False): + def __init__(self, qx_range: tuple[float, float] = (0.0, 0), qy_range: tuple[float, float] = (0.0, 0), nbins: int = 100, fold: bool = False): """ Set up the ROI boundaries, the binning of the output 1D data, and fold. The units of these parameters are A^-1 - :param qx_min: Lower bound of the ROI along the Q_x direction. - :param qx_max: Upper bound of the ROI along the Q_x direction. - :param qy_min: Lower bound of the ROI along the Q_y direction. + :param qx_range: Bounds of the ROI along the Q_x direction. + :param qy_range: Bounds of the ROI along the Q_y direction. :param qy_max: Upper bound of the ROI along the Q_y direction. :param nbins: The number of bins data is sorted into along Q_y. :param fold: Whether the two halves of the ROI along Q_y should be folded together during averaging. """ - super().__init__(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + qx_min, qx_max = qx_range + qy_min, qy_max = qy_range + super().__init__(qx_range=(qx_min, qx_max), + qy_range=(qy_min, qy_max)) self.nbins = nbins self.fold = fold @@ -554,14 +532,16 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # the behaviour of fold here will also need to change. Perhaps we could # apply a transformation to the data like the one used in WedgePhi. - qy_data_local = np.abs(self.qy_data) if self.fold else self.qy_data - major_lims = (0, self.qy_max) if self.fold else (self.qy_min, self.qy_max) + if self.fold: + major_lims = (0, self.qy_max) + self.qy_data = np.abs(self.qy_data) + else: + major_lims = (self.qy_min, self.qy_max) minor_lims = (self.qx_min, self.qx_max) - directional_average = DirectionalAverage(major_axis=qy_data_local, + directional_average = DirectionalAverage(major_axis=self.qy_data, minor_axis=self.qx_data, - major_lims=major_lims, - minor_lims=minor_lims, + lims=(major_lims,minor_lims), nbins=self.nbins) qy_data, intensity, error = \ directional_average(data=self.data, err_data=self.err_data) @@ -580,7 +560,7 @@ class CircularAverage(PolarROI): where intensity is given as a function of Q only. """ - def __init__(self, r_min: float, r_max: float, nbins: int = 100) -> None: + def __init__(self, r_range: tuple[float, float], nbins: int = 100) -> None: """ Set up the lower and upper radial limits as well as the number of bins. @@ -589,7 +569,8 @@ def __init__(self, r_min: float, r_max: float, nbins: int = 100) -> None: :param r_max: Upper limit for |Q| values to use during averaging. :param nbins: The number of bins data is sorted into along |Q| the axis """ - super().__init__(r_min=r_min, r_max=r_max) + r_min, r_max = r_range + super().__init__(r_range=(r_min, r_max)) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -606,8 +587,7 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # minor_lims is None because a full-circle angular range is used directional_average = DirectionalAverage(major_axis=self.q_data, minor_axis=self.phi_data, - major_lims=major_lims, - minor_lims=None, + lims=(major_lims,None), nbins=self.nbins) q_data, intensity, error = \ directional_average(data=self.data, err_data=self.err_data) @@ -626,7 +606,7 @@ class Ring(PolarROI): positive x-axis, φ, only. """ - def __init__(self, r_min: float, r_max: float, nbins: int = 100) -> None: + def __init__(self, r_range: tuple[float, float], nbins: int = 100) -> None: """ Set up the lower and upper radial limits as well as the number of bins. @@ -635,7 +615,8 @@ def __init__(self, r_min: float, r_max: float, nbins: int = 100) -> None: :param r_max: Upper limit for |Q| values to use during averaging. :param nbins: The number of bins data is sorted into along Phi the axis """ - super().__init__(r_min=r_min, r_max=r_max) + r_min, r_max = r_range + super().__init__(r_range=(r_min, r_max)) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -652,8 +633,7 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # major_lims is None because a full-circle angular range is used directional_average = DirectionalAverage(major_axis=self.phi_data, minor_axis=self.q_data, - major_lims=None, - minor_lims=minor_lims, + lims=(None,minor_lims), nbins=self.nbins) phi_data, intensity, error = \ directional_average(data=self.data, err_data=self.err_data) @@ -683,22 +663,22 @@ class SectorQ(PolarROI): Data1D object where intensity is given as a function of Q only. """ - def __init__(self, r_min: float, r_max: float, phi_min: float, - phi_max: float, nbins: int = 100, fold: bool = True) -> None: + def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi), nbins: int = 100, fold: bool = True) -> None: """ Set up the ROI boundaries, the binning of the output 1D data, and fold. The units are A^-1 for radial parameters, and radians for anglar ones. - :param r_min: Lower limit for |Q| values to use during averaging. - :param r_max: Upper limit for |Q| values to use during averaging. - :param phi_min: Lower limit for φ values (in the primary ROI). - :param phi_max: Upper limit for φ values (in the primary ROI). + :param r_range: Tuple (r_min, r_max) defining limits for |Q| values to use during averaging. + :param phi_range: Tuple (phi_min, phi_max) defining limits for φ in radians (in the primary ROI). + :Defaults to full circle (0, 2*pi). :param nbins: The number of bins data is sorted into along the |Q| axis :param fold: Whether the primary and secondary ROIs should be folded together during averaging. """ - super().__init__(r_min=r_min, r_max=r_max, - phi_min=phi_min, phi_max=phi_max) + r_min, r_max = r_range + phi_min, phi_max = phi_range + super().__init__(r_range=(r_min, r_max), + phi_range=(phi_min, phi_max)) self.nbins = nbins self.fold = fold @@ -715,22 +695,23 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # Transform all angles to the range [0,2π) where phi_min is at zero, # eliminating errors when the ROI straddles the 2π -> 0 discontinuity. # We won't need to convert back later because we're plotting against Q. - # Work with local normalized angles to avoid mutating object state - phi_data_norm, phi_min_norm, phi_max_norm = _normalize_angles(self.phi_data, self.phi_min, self.phi_max) + phi_offset = self.phi_min + self.phi_min = 0.0 + self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) + self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) major_lims = (self.r_min, self.r_max) - minor_lims = (phi_min_norm, phi_max_norm) + minor_lims = (self.phi_min, self.phi_max) # Secondary region of interest covers angles on opposite side of origin - minor_lims_alt = (phi_min_norm + np.pi, phi_max_norm + np.pi) + minor_lims_alt = (self.phi_min + np.pi, self.phi_max + np.pi) + primary_region = DirectionalAverage(major_axis=self.q_data, minor_axis=self.phi_data, - major_lims=major_lims, - minor_lims=minor_lims, + lims=(major_lims,minor_lims), nbins=self.nbins) secondary_region = DirectionalAverage(major_axis=self.q_data, minor_axis=self.phi_data, - major_lims=major_lims, - minor_lims=minor_lims_alt, + lims=(major_lims,minor_lims_alt), nbins=self.nbins) primary_q, primary_I, primary_err = \ @@ -795,20 +776,20 @@ class WedgeQ(PolarROI): Data1D object where intensity is given as a function of Q only. """ - def __init__(self, r_min: float, r_max: float, phi_min: float, - phi_max: float, nbins: int = 100) -> None: + def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi), nbins: int = 100) -> None: """ Set up the ROI boundaries, and the binning of the output 1D data. The units are A^-1 for radial parameters, and radians for anglar ones. - :param r_min: Lower limit for |Q| values to use during averaging. - :param r_max: Upper limit for |Q| values to use during averaging. - :param phi_min: Lower limit for φ values (in the primary ROI). - :param phi_max: Upper limit for φ values (in the primary ROI). + :param r_range: Tuple (r_min, r_max) defining limits for |Q| values to use during averaging. + :param phi_range: Tuple (phi_min, phi_max) defining limits for φ in radians (in the primary ROI). + :Defaults to full circle (0, 2*pi). :param nbins: The number of bins data is sorted into along the |Q| axis """ - super().__init__(r_min=r_min, r_max=r_max, - phi_min=phi_min, phi_max=phi_max) + r_min, r_max = r_range + phi_min, phi_max = phi_range + super().__init__(r_range=(r_min, r_max), + phi_range=(phi_min, phi_max)) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -823,15 +804,22 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # Transform all angles to the range [0,2π) where phi_min is at zero, # eliminating errors when the ROI straddles the 2π -> 0 discontinuity. # We won't need to convert back later because we're plotting against Q. - phi_data_norm, phi_min_norm, phi_max_norm = _normalize_angles(self.phi_data, self.phi_min, self.phi_max) + phi_offset = self.phi_min + self.phi_min = 0.0 + self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) + self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) + # Averaging takes place between radial and angular limits major_lims = (self.r_min, self.r_max) - minor_lims = None if phi_max_norm == 0 else (phi_min_norm, phi_max_norm) + # When phi_max and phi_min have the same angle, ROI is a full circle. + if self.phi_max == 0: + minor_lims = None + else: + minor_lims = (self.phi_min, self.phi_max) directional_average = DirectionalAverage(major_axis=self.q_data, minor_axis=self.phi_data, - major_lims=major_lims, - minor_lims=minor_lims, + lims=(major_lims,minor_lims), nbins=self.nbins) q_data, intensity, error = \ directional_average(data=self.data, err_data=self.err_data) @@ -853,20 +841,20 @@ class WedgePhi(PolarROI): Data1D object where intensity is given as a function of Q only. """ - def __init__(self, r_min: float, r_max: float, phi_min: float, - phi_max: float, nbins: int = 100) -> None: + def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi), nbins: int = 100) -> None: """ Set up the ROI boundaries, and the binning of the output 1D data. The units are A^-1 for radial parameters, and radians for anglar ones. - :param r_min: Lower limit for |Q| values to use during averaging. - :param r_max: Upper limit for |Q| values to use during averaging. - :param phi_min: Lower limit for φ values to use during averaging. - :param phi_max: Upper limit for φ values to use during averaging. + :param r_range: Tuple (r_min, r_max) defining limits for |Q| values to use during averaging. + :param phi_range: Tuple (phi_min, phi_max) defining angular bounds in radians. + Defaults to full circle (0, 2*pi). :param nbins: The number of bins data is sorted into along the φ axis. """ - super().__init__(r_min=r_min, r_max=r_max, - phi_min=phi_min, phi_max=phi_max) + r_min, r_max = r_range + phi_min, phi_max = phi_range + super().__init__(r_range=(r_min, r_max), + phi_range=(phi_min, phi_max)) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -882,17 +870,21 @@ def __call__(self, data2d: Data2D = None) -> Data1D: # eliminating errors when the ROI straddles the 2π -> 0 discontinuity. # Remember to transform back afterward as we're plotting against phi. phi_offset = self.phi_min - phi_data_norm, phi_min_norm, phi_max_norm = _normalize_angles(self.phi_data, self.phi_min, self.phi_max) + self.phi_min = 0.0 + self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) + self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) # Averaging takes place between angular and radial limits # When phi_max and phi_min have the same angle, ROI is a full circle. - major_lims = None if phi_max_norm == 0 else (self.phi_min_norm, phi_max_norm) + if self.phi_max == 0: + major_lims = None + else: + major_lims = (self.phi_min, self.phi_max) minor_lims = (self.r_min, self.r_max) directional_average = DirectionalAverage(major_axis=self.phi_data, minor_axis=self.q_data, - major_lims=major_lims, - minor_lims=minor_lims, + lims=(major_lims,minor_lims), nbins=self.nbins) phi_data, intensity, error = \ directional_average(data=self.data, err_data=self.err_data) @@ -935,10 +927,11 @@ class Ringcut(PolarROI): Phi_min and phi_max should be defined between 0 and 2*pi in anti-clockwise starting from the x- axis on the left-hand side - """ + """ - def __init__(self, r_min: float = 0.0, r_max: float = 0.0, phi_min: float = 0.0, phi_max: float = 2*np.pi): - super().__init__(r_min, r_max, phi_min, phi_max) + def __init__(self, r_range: tuple[float, float] = (0.0, 0.0), phi_range: tuple[float, float] = (0.0, 2*np.pi)): + + super().__init__(r_range, phi_range) def __call__(self, data2D: Data2D) -> np.ndarray[bool]: """ @@ -993,9 +986,9 @@ class Sectorcut(PolarROI): Phi_min and phi_max are given in units of radian and (phi_max-phi_min) should not be larger than pi """ - - def __init__(self, phi_min: float = 0.0, phi_max: float = np.pi): - super().__init__(0, np.inf, phi_min, phi_max) + + def __init__(self, phi_range: tuple[float, float] = (0.0, np.pi)): + super().__init__(r_range=(0, np.inf), phi_range=phi_range) def __call__(self, data2D: Data2D) -> np.ndarray[bool]: """ @@ -1011,11 +1004,14 @@ def __call__(self, data2D: Data2D) -> np.ndarray[bool]: # Calculate q_data using unmasked qx_data and qy_data to ensure data sizes match q_data = np.sqrt(data2D.qx_data * data2D.qx_data + data2D.qy_data * data2D.qy_data) - phi_data_norm, phi_min_norm, phi_max_norm = _normalize_angles(self.phi_data, self.phi_min, self.phi_max) - phi_shifted = phi_data_norm - np.pi + phi_offset = self.phi_min + self.phi_min = 0.0 + self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) + self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) + phi_shifted = self.phi_data - np.pi # Determine angular bounds for both upper and lower half of image - phi_min_angle, phi_max_angle = (phi_min_norm, phi_max_norm) + phi_min_angle, phi_max_angle = (self.phi_min, self.phi_max) # Determine regions of interest out_radial = (self.r_min <= q_data) & (self.r_max > q_data) diff --git a/test/sasmanipulations/utest_averaging_analytical.py b/test/sasmanipulations/utest_averaging_analytical.py index 189a4e9e..90d46c41 100644 --- a/test/sasmanipulations/utest_averaging_analytical.py +++ b/test/sasmanipulations/utest_averaging_analytical.py @@ -174,8 +174,7 @@ def test_slabx_init(self): nbins = 100 fold = True - slab_object = SlabX(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, - qy_max=qy_max, nbins=nbins, fold=fold) + slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) self.assertEqual(slab_object.qx_min, qx_min) self.assertEqual(slab_object.qx_max, qx_max) @@ -210,8 +209,7 @@ def test_slabx_no_points_to_average(self): qy_min = 2 * averager_data.qmax qy_max = 3 * averager_data.qmax - slab_object = SlabX(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) self.assertRaises(ValueError, slab_object, averager_data.data) def test_slabx_averaging_without_fold(self): @@ -234,8 +232,7 @@ def test_slabx_averaging_without_fold(self): # Explicitly not using fold in this test fold = False - slab_object = SlabX(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, - qy_max=qy_max, nbins=nbins, fold=fold) + slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) data1d = slab_object(averager_data.data) # ∫x² dx = x³ / 3 + constant. @@ -270,8 +267,7 @@ def test_slabx_averaging_with_fold(self): # Explicitly using fold in this test fold = True - slab_object = SlabX(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, - qy_max=qy_max, nbins=nbins, fold=fold) + slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) data1d = slab_object(averager_data.data) # Negative values of x are not graphed when fold = True @@ -306,8 +302,7 @@ def test_slaby_init(self): nbins = 100 fold = True - slab_object = SlabY(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, - qy_max=qy_max, nbins=nbins, fold=fold) + slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) self.assertEqual(slab_object.qx_min, qx_min) self.assertEqual(slab_object.qx_max, qx_max) @@ -342,8 +337,7 @@ def test_slaby_no_points_to_average(self): qy_min = 2 * averager_data.qmax qy_max = 3 * averager_data.qmax - slab_object = SlabY(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) self.assertRaises(ValueError, slab_object, averager_data.data) def test_slaby_averaging_without_fold(self): @@ -366,8 +360,7 @@ def test_slaby_averaging_without_fold(self): # Explicitly not using fold in this test fold = False - slab_object = SlabY(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, - qy_max=qy_max, nbins=nbins, fold=fold) + slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) data1d = slab_object(averager_data.data) # ∫x dx = x² / 2 + constant. @@ -402,8 +395,7 @@ def test_slab_averaging_y_with_fold(self): # Explicitly using fold in this test fold = True - slab_object = SlabY(qx_min=qx_min, qx_max=qx_max, qy_min=qy_min, - qy_max=qy_max, nbins=nbins, fold=fold) + slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) data1d = slab_object(averager_data.data) # Negative values of y are not graphed when fold = True, so don't @@ -437,8 +429,7 @@ def test_boxsum_init(self): qy_min = 3 qy_max = 4 - box_object = Boxsum(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + box_object = Boxsum(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) self.assertEqual(box_object.qx_min, qx_min) self.assertEqual(box_object.qx_max, qx_max) @@ -472,8 +463,7 @@ def test_boxsum_total(self): qx_max = averager_data.qmax qy_min = -1 * averager_data.qmax qy_max = averager_data.qmax - box_object = Boxsum(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + box_object = Boxsum(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) result, error, npoints = box_object(averager_data.data) correct_sum = np.sum(test_data) # When averager_data was created, we didn't include any error data. @@ -502,8 +492,7 @@ def test_boxsum_subset_total(self): # Extracting the inner half of the data set inner_portion = test_data[25:75, 25:75] - box_object = Boxsum(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + box_object = Boxsum(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) result, error, npoints = box_object(averager_data.data) correct_sum = np.sum(inner_portion) # When averager_data was created, we didn't include any error data. @@ -529,8 +518,7 @@ def test_boxsum_zero_sum(self): qx_max = 0.5 * averager_data.qmax qy_min = -0.5 * averager_data.qmax qy_max = 0.5 * averager_data.qmax - box_object = Boxsum(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + box_object = Boxsum(qx_range=(qx_min, qx_max), qy_range=(qy_min, qy_max)) result, error, npoints = box_object(averager_data.data) self.assertAlmostEqual(result, 0, 6) @@ -552,8 +540,7 @@ def test_boxavg_init(self): qy_min = 3 qy_max = 4 - box_object = Boxavg(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + box_object = Boxavg(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) self.assertEqual(box_object.qx_min, qx_min) self.assertEqual(box_object.qx_max, qx_max) @@ -587,8 +574,7 @@ def test_boxavg_total(self): qx_max = averager_data.qmax qy_min = -1 * averager_data.qmax qy_max = averager_data.qmax - box_object = Boxavg(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + box_object = Boxavg(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) result, error = box_object(averager_data.data) correct_avg = np.mean(test_data) # When averager_data was created, we didn't include any error data. @@ -617,8 +603,7 @@ def test_boxavg_subset_total(self): # Extracting the inner half of the data set inner_portion = test_data[25:75, 25:75] - box_object = Boxavg(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + box_object = Boxavg(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) result, error = box_object(averager_data.data) correct_avg = np.mean(inner_portion) # When averager_data was created, we didn't include any error data. @@ -644,8 +629,7 @@ def test_boxavg_zero_average(self): qx_max = 0.5 * averager_data.qmax qy_min = -0.5 * averager_data.qmax qy_max = 0.5 * averager_data.qmax - box_object = Boxavg(qx_min=qx_min, qx_max=qx_max, - qy_min=qy_min, qy_max=qy_max) + box_object = Boxavg(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) result, error = box_object(averager_data.data) self.assertAlmostEqual(result, 0, 6) @@ -666,7 +650,7 @@ def test_circularaverage_init(self): r_max = 2 nbins = 100 - circ_object = CircularAverage(r_min=r_min, r_max=r_max, nbins=nbins) + circ_object = CircularAverage(r_range=(r_min, r_max), nbins=nbins) self.assertEqual(circ_object.r_min, r_min) self.assertEqual(circ_object.r_max, r_max) @@ -709,7 +693,7 @@ def test_circularaverage_check_valid_radii(self): """ Test that CircularAverage raises ValueError when r_min > r_max """ - self.assertRaises(ValueError, CircularAverage, r_min=0.1, r_max=0.05) + self.assertRaises(ValueError, CircularAverage, r_range=(0.1, 0.05)) def test_circularaverage_no_points_to_average(self): """ @@ -719,8 +703,7 @@ def test_circularaverage_no_points_to_average(self): averager_data = MatrixToData2D(test_data) # Region of interest well outside region with data - circ_object = CircularAverage(r_min=2 * averager_data.qmax, - r_max=3 * averager_data.qmax) + circ_object = CircularAverage(r_range=(2 * averager_data.qmax,3 * averager_data.qmax)) self.assertRaises(ValueError, circ_object, averager_data.data) def test_circularaverage_averages_circularly(self): @@ -736,7 +719,7 @@ def test_circularaverage_averages_circularly(self): r_max = averager_data.qmax * 0.75 nbins = test_data.matrix_size - circ_object = CircularAverage(r_min=r_min, r_max=r_max, nbins=nbins) + circ_object = CircularAverage(r_range=(r_min, r_max), nbins=nbins) data1d = circ_object(averager_data.data) expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max) @@ -767,7 +750,7 @@ def test_ring_init(self): # Note that Ring also has params center_x and center_y, but these are # not used by the slicers and there is a 'todo' in manipulations.py to # remove them. For this reason, I have not tested their initialisation. - ring_object = Ring(r_min=r_min, r_max=r_max, nbins=nbins) + ring_object = Ring(r_range=(r_min, r_max), nbins=nbins) self.assertEqual(ring_object.r_min, r_min) self.assertEqual(ring_object.r_max, r_max) @@ -793,8 +776,7 @@ def test_ring_no_points_to_average(self): averager_data = MatrixToData2D(test_data) # Region of interest well outside region with data - ring_object = Ring(r_min=2 * averager_data.qmax, - r_max=3 * averager_data.qmax) + ring_object = Ring(r_range=(2 * averager_data.qmax, 3 * averager_data.qmax)) self.assertRaises(ValueError, ring_object, averager_data.data) def test_ring_averages_azimuthally(self): @@ -810,7 +792,7 @@ def test_ring_averages_azimuthally(self): r_max = 0.75 * averager_data.qmax nbins = test_data.matrix_size // 2 - ring_object = Ring(r_min=r_min, r_max=r_max, nbins=nbins) + ring_object = Ring(r_range=(r_min, r_max), nbins=nbins) data1d = ring_object(averager_data.data) expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max) @@ -843,8 +825,8 @@ def test_sectorq_init(self): # sector_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min, # phi_max=phi_max, nbins=nbins, base=base) - sector_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min, - phi_max=phi_max, nbins=nbins) + + sector_object = SectorQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) self.assertEqual(sector_object.r_min, r_min) self.assertEqual(sector_object.r_max, r_max) @@ -875,8 +857,7 @@ def test_sectorq_averaging_without_fold(self): phi_max = 5*np.pi/6 nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - wedge_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min, - phi_max=phi_max, nbins=nbins) + wedge_object = SectorQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) # Explicitly set fold to False - results span full +/- range wedge_object.fold = False data1d = wedge_object(averager_data.data) @@ -909,8 +890,7 @@ def test_sectorq_averaging_with_fold(self): phi_max = 5*np.pi/6 nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - wedge_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min, - phi_max=phi_max, nbins=nbins) + wedge_object = SectorQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) # Explicitly set fold to True - points either side of 0,0 are averaged wedge_object.fold = True data1d = wedge_object(averager_data.data) @@ -948,8 +928,7 @@ def test_wedgeq_init(self): phi_max = np.pi nbins = 10 - wedge_object = WedgeQ(r_min=r_min, r_max=r_max, phi_min=phi_min, - phi_max=phi_max, nbins=nbins) + wedge_object = WedgeQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) self.assertEqual(wedge_object.r_min, r_min) self.assertEqual(wedge_object.r_max, r_max) @@ -972,8 +951,7 @@ def test_wedgeq_averaging(self): phi_max = 5*np.pi/6 nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - wedge_object = WedgeQ(r_min=r_min, r_max=r_max, phi_min=phi_min, - phi_max=phi_max, nbins=nbins) + wedge_object = WedgeQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) data1d = wedge_object(averager_data.data) expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, @@ -1005,8 +983,7 @@ def test_wedgephi_init(self): # wedge_object = WedgePhi(r_min=r_min, r_max=r_max, phi_min=phi_min, # phi_max=phi_max, nbins=nbins, base=base) - wedge_object = WedgePhi(r_min=r_min, r_max=r_max, phi_min=phi_min, - phi_max=phi_max, nbins=nbins) + wedge_object = WedgePhi(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) self.assertEqual(wedge_object.r_min, r_min) self.assertEqual(wedge_object.r_max, r_max) @@ -1037,8 +1014,7 @@ def test_wedgephi_averaging(self): phi_max = 5*np.pi/6 nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - wedge_object = WedgePhi(r_min=r_min, r_max=r_max, phi_min=phi_min, - phi_max=phi_max, nbins=nbins) + wedge_object = WedgePhi(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) data1d = wedge_object(averager_data.data) expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, @@ -1066,7 +1042,7 @@ def test_inappropriate_limits_arrays(self): Ensure a ValueError is raised if the wrong number of limits is suppied. """ self.assertRaises(ValueError, DirectionalAverage, major_axis=[], - minor_axis=[], major_lims=[], minor_lims=[]) + minor_axis=[], lims=([], [])) def test_nbins_not_int(self): """ @@ -1124,8 +1100,7 @@ def setUp(self): self.directional_average = \ DirectionalAverage(major_axis=self.data2d.data.qx_data, minor_axis=self.data2d.data.qy_data, - major_lims=self.lims, - minor_lims=self.lims, nbins=self.nbins) + lims=(self.lims,self.lims), nbins=self.nbins) def test_bin_width(self): """ From f02ba2a3059399e2bef6bdcc20f1a5610303b2eb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 15 Nov 2025 01:31:48 +0000 Subject: [PATCH 39/49] [pre-commit.ci lite] apply automatic fixes for ruff linting errors --- sasdata/data_util/averaging.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index 37953420..0126ffc4 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -4,7 +4,6 @@ from enum import Enum, auto - import numpy as np from numpy.typing import ArrayLike @@ -82,7 +81,7 @@ def __init__(self, 2-tuple or None. :param nbins: The number of bins the major axis is divided up into. """ - + if any(not hasattr(coordinate_data, "__array__") for coordinate_data in (major_axis, minor_axis)): msg = "Must provide major & minor coordinate arrays for binning." @@ -90,7 +89,7 @@ def __init__(self, if lims is None: major_lims = minor_lims = None - else: + else: if not (isinstance(lims, (list, tuple)) and len(lims) == 2): msg = "Parameter 'lims' must be a 2-tuple (major_lims, minor_lims) or None." raise ValueError(msg) @@ -723,7 +722,7 @@ def __call__(self, data2d: Data2D = None) -> Data1D: nbins=self.nbins) secondary_region = DirectionalAverage(major_axis=self.q_data, minor_axis=self.phi_data, - lims=(major_lims,minor_lims_alt), + lims=(major_lims,minor_lims_alt), nbins=self.nbins) primary_q, primary_I, primary_err = \ @@ -939,7 +938,7 @@ class Ringcut(PolarROI): Phi_min and phi_max should be defined between 0 and 2*pi in anti-clockwise starting from the x- axis on the left-hand side - """ + """ def __init__(self, r_range: tuple[float, float] = (0.0, 0.0), phi_range: tuple[float, float] = (0.0, 2*np.pi)): @@ -998,7 +997,7 @@ class Sectorcut(PolarROI): Phi_min and phi_max are given in units of radian and (phi_max-phi_min) should not be larger than pi """ - + def __init__(self, phi_range: tuple[float, float] = (0.0, np.pi)): super().__init__(r_range=(0, np.inf), phi_range=phi_range) From bb4af70d1a2744eb0960bf0b4bc4d908864be771 Mon Sep 17 00:00:00 2001 From: Dirk Honecker Date: Sat, 15 Nov 2025 11:43:37 +0000 Subject: [PATCH 40/49] reduce complexity in averaging_tests --- sasdata/data_util/averaging.py | 93 ++---- .../utest_averaging_analytical.py | 302 ++++++++---------- 2 files changed, 163 insertions(+), 232 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index 37953420..8a97df49 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -4,13 +4,11 @@ from enum import Enum, auto - import numpy as np from numpy.typing import ArrayLike from sasdata.dataloader.data_info import Data1D, Data2D - class IntervalType(Enum): HALF_OPEN = auto() CLOSED = auto() @@ -42,8 +40,6 @@ def weights_for_interval(self, array, l_bound, u_bound): return np.asarray(in_range, dtype=int) - - class DirectionalAverage: """ Average along one coordinate axis of 2D data and return data for a 1D plot. @@ -90,19 +86,17 @@ def __init__(self, if lims is None: major_lims = minor_lims = None - else: - if not (isinstance(lims, (list, tuple)) and len(lims) == 2): - msg = "Parameter 'lims' must be a 2-tuple (major_lims, minor_lims) or None." - raise ValueError(msg) + elif not (isinstance(lims, (list, tuple)) and len(lims) == 2): + msg = "Parameter 'lims' must be a 2-tuple (major_lims, minor_lims) or None." + raise ValueError(msg) + else: major_lims, minor_lims = lims - if not isinstance(nbins, int): - # TODO: Make classes that depend on this provide ints, its quite a thing to fix though - try: - nbins = int(nbins) - except: - msg = f"Parameter 'nbins' must be convertable to an integer via int(), got type {type(nbins)} (={nbins})" - raise TypeError(msg) + try: + nbins = int(nbins) + except: + msg = f"Parameter 'nbins' must be convertable to an integer via int(), got type {type(nbins)} (={nbins})" + raise TypeError(msg) self.major_axis = np.asarray(major_axis) self.minor_axis = np.asarray(minor_axis) @@ -137,7 +131,6 @@ def bin_width_n(self, bin_number: int) -> float: lower, upper = self.get_bin_interval(bin_number) return upper - lower - def get_bin_interval(self, bin_number: int) -> tuple[float, float]: """ @@ -223,7 +216,6 @@ def __call__(self, data, err_data): return x_axis_values[finite], intensity[finite], errors[finite] - class GenericROI: """ Base class used to set up the data from a Data2D object for processing. @@ -244,7 +236,6 @@ def __init__(self): self.qx_data = None self.qy_data = None - def validate_and_assign_data(self, data2d: Data2D = None) -> None: """ Check that the data supplied is valid and assign data to variables. @@ -278,7 +269,6 @@ def validate_and_assign_data(self, data2d: Data2D = None) -> None: self.err_data[self.err_data == 0] = \ np.sqrt(np.abs(self.data[self.err_data == 0])) - class CartesianROI(GenericROI): """ Base class for data manipulators with a Cartesian (rectangular) ROI. @@ -301,7 +291,6 @@ def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[f self.qy_min = qy_min self.qy_max = qy_max - class PolarROI(GenericROI): """ Base class for data manipulators with a polar ROI. @@ -347,7 +336,6 @@ def validate_and_assign_data(self, data2d: Data2D = None) -> None: # Phi data can be calculated from the Cartesian Q coordinates. self.phi_data = np.arctan2(self.qy_data, self.qx_data) - class Boxsum(CartesianROI): """ Compute the sum of the intensity within a rectangular Region Of Interest. @@ -361,10 +349,8 @@ def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[f :param qx_range: Bounds of the ROI along the Q_x direction. :param qy_range: Bounds of the ROI along the Q_y direction. """ - qx_min, qx_max = qx_range - qy_min, qy_max = qy_range - super().__init__(qx_range=(qx_min, qx_max), - qy_range=(qy_min, qy_max)) + super().__init__(qx_range=qx_range, + qy_range=qy_range) def __call__(self, data2d: Data2D = None) -> float: """ @@ -405,7 +391,6 @@ def _sum(self) -> float: return total_sum, np.sqrt(total_errors_squared), total_count - class Boxavg(Boxsum): """ Compute the average intensity within a rectangular Region Of Interest. @@ -419,10 +404,8 @@ def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[f :param qx_range: Bounds of the ROI along the Q_x direction. :param qy_range: Bounds of the ROI along the Q_y direction. """ - qx_min, qx_max = qx_range - qy_min, qy_max = qy_range - super().__init__(qx_range=(qx_min, qx_max), - qy_range=(qy_min, qy_max)) + super().__init__(qx_range=qx_range, + qy_range=qy_range) def __call__(self, data2d: Data2D) -> float: """ @@ -435,7 +418,6 @@ def __call__(self, data2d: Data2D) -> float: return (total_sum / count), (error / count) - class SlabX(CartesianROI): """ Average I(Q_x, Q_y) along the y direction (within a ROI), giving I(Q_x). @@ -460,10 +442,8 @@ def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[f :param fold: Whether the two halves of the ROI along Q_x should be folded together during averaging. """ - qx_min, qx_max = qx_range - qy_min, qy_max = qy_range - super().__init__(qx_range=(qx_min, qx_max), - qy_range=(qy_min, qy_max)) + super().__init__(qx_range=qx_range, + qy_range=qy_range) self.nbins = nbins self.fold = fold @@ -497,7 +477,6 @@ def __call__(self, data2d: Data2D = None) -> Data1D: return Data1D(x=qx_data, y=intensity, dy=error) - class SlabY(CartesianROI): """ Average I(Q_x, Q_y) along the x direction (within a ROI), giving I(Q_y). @@ -523,10 +502,8 @@ def __init__(self, qx_range: tuple[float, float] = (0.0, 0), qy_range: tuple[flo :param fold: Whether the two halves of the ROI along Q_y should be folded together during averaging. """ - qx_min, qx_max = qx_range - qy_min, qy_max = qy_range - super().__init__(qx_range=(qx_min, qx_max), - qy_range=(qy_min, qy_max)) + super().__init__(qx_range=qx_range, + qy_range=qy_range) self.nbins = nbins self.fold = fold @@ -560,7 +537,6 @@ def __call__(self, data2d: Data2D = None) -> Data1D: return Data1D(x=qy_data, y=intensity, dy=error) - class CircularAverage(PolarROI): """ Calculate I(|Q|) by circularly averaging 2D data between 2 radial limits. @@ -581,8 +557,7 @@ def __init__(self, r_range: tuple[float, float], nbins: int = 100) -> None: :param r_max: Upper limit for |Q| values to use during averaging. :param nbins: The number of bins data is sorted into along |Q| the axis """ - r_min, r_max = r_range - super().__init__(r_range=(r_min, r_max)) + super().__init__(r_range=r_range) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -606,7 +581,6 @@ def __call__(self, data2d: Data2D = None) -> Data1D: return Data1D(x=q_data, y=intensity, dy=error) - class Ring(PolarROI): """ Calculate I(φ) by radially averaging 2D data between 2 radial limits. @@ -627,8 +601,7 @@ def __init__(self, r_range: tuple[float, float], nbins: int = 100) -> None: :param r_max: Upper limit for |Q| values to use during averaging. :param nbins: The number of bins data is sorted into along Phi the axis """ - r_min, r_max = r_range - super().__init__(r_range=(r_min, r_max)) + super().__init__(r_range=r_range) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -652,7 +625,6 @@ def __call__(self, data2d: Data2D = None) -> Data1D: return Data1D(x=phi_data, y=intensity, dy=error) - class SectorQ(PolarROI): """ Project I(Q, φ) data onto I(Q) within a region defined by Cartesian limits. @@ -687,10 +659,7 @@ def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] :param fold: Whether the primary and secondary ROIs should be folded together during averaging. """ - r_min, r_max = r_range - phi_min, phi_max = phi_range - super().__init__(r_range=(r_min, r_max), - phi_range=(phi_min, phi_max)) + super().__init__(r_range=r_range, phi_range=phi_range) self.nbins = nbins self.fold = fold @@ -773,7 +742,6 @@ def __call__(self, data2d: Data2D = None) -> Data1D: return data1d - class WedgeQ(PolarROI): """ Project I(Q, φ) data onto I(Q) within a region defined by Cartesian limits. @@ -798,10 +766,7 @@ def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] :Defaults to full circle (0, 2*pi). :param nbins: The number of bins data is sorted into along the |Q| axis """ - r_min, r_max = r_range - phi_min, phi_max = phi_range - super().__init__(r_range=(r_min, r_max), - phi_range=(phi_min, phi_max)) + super().__init__(r_range=r_range, phi_range=phi_range) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -838,7 +803,6 @@ def __call__(self, data2d: Data2D = None) -> Data1D: return Data1D(x=q_data, y=intensity, dy=error) - class WedgePhi(PolarROI): """ Project I(Q, φ) data onto I(φ) within a region defined by Cartesian limits. @@ -863,10 +827,7 @@ def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] Defaults to full circle (0, 2*pi). :param nbins: The number of bins data is sorted into along the φ axis. """ - r_min, r_max = r_range - phi_min, phi_max = phi_range - super().__init__(r_range=(r_min, r_max), - phi_range=(phi_min, phi_max)) + super().__init__(r_range=r_range, phi_range=phi_range) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -911,7 +872,6 @@ def __call__(self, data2d: Data2D = None) -> Data1D: return Data1D(x=phi_data, y=intensity, dy=error) - class SectorPhi(WedgePhi): """ Sector average as a function of phi. @@ -928,7 +888,6 @@ class SectorPhi(WedgePhi): ################################################################################ - class Ringcut(PolarROI): """ Defines a ring on a 2D data set. @@ -963,14 +922,13 @@ def __call__(self, data2D: Data2D) -> np.ndarray[bool]: out = (self.r_min <= q_data) & (self.r_max >= q_data) return out - class Boxcut(CartesianROI): """ Find a rectangular 2D region of interest. """ - def __init__(self, x_min: float = 0.0, x_max: float = 0.0, y_min: float = 0.0, y_max: float = 0.0): - super().__init__(x_min, x_max, y_min, y_max) + def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[float, float] = (0.0, 0.0)): + super().__init__(qx_range=qx_range, qy_range=qy_range) def __call__(self, data2D: Data2D) -> np.ndarray[bool]: """ @@ -987,7 +945,6 @@ def __call__(self, data2D: Data2D) -> np.ndarray[bool]: return outx & outy - class Sectorcut(PolarROI): """ Defines a sector (major + minor) region on a 2D data set. @@ -1016,14 +973,12 @@ def __call__(self, data2D: Data2D) -> np.ndarray[bool]: # Calculate q_data using unmasked qx_data and qy_data to ensure data sizes match q_data = np.sqrt(data2D.qx_data * data2D.qx_data + data2D.qy_data * data2D.qy_data) - phi_offset = self.phi_min self.phi_min = 0.0 self.phi_max = (self.phi_max - phi_offset) % (2 * np.pi) self.phi_data = (self.phi_data - phi_offset) % (2 * np.pi) phi_shifted = self.phi_data - np.pi - # Determine angular bounds for both upper and lower half of image phi_min_angle, phi_max_angle = (self.phi_min, self.phi_max) diff --git a/test/sasmanipulations/utest_averaging_analytical.py b/test/sasmanipulations/utest_averaging_analytical.py index 90d46c41..98483414 100644 --- a/test/sasmanipulations/utest_averaging_analytical.py +++ b/test/sasmanipulations/utest_averaging_analytical.py @@ -23,6 +23,88 @@ ) from sasdata.dataloader import data_info +# TODO - also check the errors are being calculated correctly + +# ------------------------ +# Helpers +# ------------------------ +def make_dd_from_func(func, matrix_size=201): + """ + Create a MatrixToData2D from a function of (x, y). Returns the MatrixToData2D + instance and matrix_size for convenience. + func should accept (x, y) meshgrid arrays and return a 2D array. + """ + x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), + np.linspace(-1, 1, matrix_size)) + mat = func(x, y) + return MatrixToData2D(data2d=mat), matrix_size + + +def integrate_1d_output(output, method="simpson"): + """ + Integrate output from an averager consistently. + - If output is a Data1D-like object with .x and .y -> integrate y(x) + - If output is a tuple (result, error[, npoints]) -> return numeric result + """ + if hasattr(output, "x") and hasattr(output, "y"): + if method == "trapezoid": + return integrate.trapezoid(output.y, output.x) + return integrate.simpson(output.y, output.x) + if isinstance(output, tuple) and len(output) >= 1: + return output[0] + raise TypeError("Unsupported averager output type: %r" % type(output)) + + +def expected_slabx_area(qx_min, qx_max, qy_min, qy_max): + # data = x^2 * y -> integrate x^2 dx and average y across qy range + x_part_integ = (qx_max**3 - qx_min**3) / 3 + y_part_integ = (qy_max**2 - qy_min**2) / 2 + y_part_avg = y_part_integ / (qy_max - qy_min) + return y_part_avg * x_part_integ + +def expected_slaby_area(qx_min, qx_max, qy_min, qy_max): + # data = x * y^2 -> integrate y^2 dy and average x across qx range + y_part_integ = (qy_max**3 - qy_min**3) / 3 + x_part_integ = (qx_max**2 - qx_min**2) / 2 + x_part_avg = x_part_integ / (qx_max - qx_min) + return x_part_avg * y_part_integ + +def make_uniform_dd(shape=(100, 100), value=1.0): + """Convenience for tests that need a constant matrix Data2D.""" + mat = np.full(shape, value, dtype=float) + return MatrixToData2D(data2d=mat) + +def run_and_integrate(averager, dd, integrator="simpson"): + """ + Run an averager (callable) with a Data2D container returned by MatrixToData2D + and return the integrated result (scalar area / sum) consistently. + """ + out = averager(dd.data) + return integrate_1d_output(out, method=("trapezoid" if integrator == "trapezoid" else "simpson")) + +def expected_boxsum_and_err(matrix, slice_rows=None, slice_cols=None): + """ + Compute expected Boxsum (sum) and its error for a given 2D numpy matrix. + Optional slice indices can restrict the region (tuples/lists of indices). + """ + mat = np.asarray(matrix) + if slice_rows is not None and slice_cols is not None: + mat = mat[np.ix_(slice_rows, slice_cols)] + total = np.sum(mat) + err = np.sqrt(np.sum(mat)) + return total, err + +def expected_boxavg_and_err(matrix, slice_rows=None, slice_cols=None): + """ + Compute expected Boxavg (mean) and its error for a given 2D numpy matrix. + Error uses sqrt(sum)/N as in existing tests. + """ + mat = np.asarray(matrix) + if slice_rows is not None and slice_cols is not None: + mat = mat[np.ix_(slice_rows, slice_cols)] + avg = np.mean(mat) if mat.size > 0 else 0.0 + err = np.sqrt(np.sum(mat)) / mat.size if mat.size > 0 else 0.0 + return avg, err class MatrixToData2D: """ @@ -77,7 +159,6 @@ def __init__(self, data2d=None, err_data=None): qx_data=qx_data, qy_data=qy_data, q_data=q_data, mask=mask) - class CircularTestingMatrix: """ This class is used to generate a 2D array representing a function in polar @@ -125,7 +206,6 @@ def area_under_region(self, r_min=0, r_max=1, phi_min=0, phi_max=2*np.pi): Integral of the testing matrix along the major axis, between the limits specified. This can be compared to the integral under the 1D data output by the averager being tested to confirm it's working properly. - :param r_min: value defining the minimum Q in the ROI. :param r_max: value defining the maximum Q in the ROI. :param phi_min: value defining the minimum Phi in the ROI. @@ -153,10 +233,8 @@ def area_under_region(self, r_min=0, r_max=1, phi_min=0, phi_max=2*np.pi): calculated_area = sine_part_avg * linear_part_integ else: calculated_area = linear_part_avg * sine_part_integ - return calculated_area - class SlabXTests(unittest.TestCase): """ This class contains all the unit tests for the SlabX class from @@ -216,12 +294,10 @@ def test_slabx_averaging_without_fold(self): """ Test that SlabX can average correctly when x is the major axis """ - matrix_size = 201 - x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), - np.linspace(-1, 1, matrix_size)) - # Create a distribution which is quadratic in x and linear in y - test_data = x**2 * y - averager_data = MatrixToData2D(data2d=test_data) + def func(x, y): + return x**2 * y + averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) + # Set up region of interest to average over - the limits are arbitrary. qx_min = -0.5 * averager_data.qmax # = -0.5 @@ -235,28 +311,18 @@ def test_slabx_averaging_without_fold(self): slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) data1d = slab_object(averager_data.data) - # ∫x² dx = x³ / 3 + constant. - x_part_integ = (qx_max**3 - qx_min**3) / 3 - # ∫y dy = y² / 2 + constant. - y_part_integ = (qy_max**2 - qy_min**2) / 2 - y_part_avg = y_part_integ / (qy_max - qy_min) - expected_area = y_part_avg * x_part_integ - actual_area = integrate.simpson(data1d.y, data1d.x) + expected_area = expected_slabx_area(qx_min, qx_max, qy_min, qy_max) + actual_area = integrate_1d_output(data1d, method="simpson") self.assertAlmostEqual(actual_area, expected_area, 2) - # TODO - also check the errors are being calculated correctly - def test_slabx_averaging_with_fold(self): """ Test that SlabX can average correctly when x is the major axis """ - matrix_size = 201 - x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), - np.linspace(-1, 1, matrix_size)) - # Create a distribution which is quadratic in x and linear in y - test_data = x**2 * y - averager_data = MatrixToData2D(data2d=test_data) + def func(x, y): + return x**2 * y + averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) # Set up region of interest to average over - the limits are arbitrary. qx_min = -0.5 * averager_data.qmax # = -0.5 @@ -271,20 +337,12 @@ def test_slabx_averaging_with_fold(self): data1d = slab_object(averager_data.data) # Negative values of x are not graphed when fold = True - qx_min = 0 - # ∫x² dx = x³ / 3 + constant. - x_part_integ = (qx_max**3 - qx_min**3) / 3 - # ∫y dy = y² / 2 + constant. - y_part_integ = (qy_max**2 - qy_min**2) / 2 - y_part_avg = y_part_integ / (qy_max - qy_min) - expected_area = y_part_avg * x_part_integ - actual_area = integrate.simpson(data1d.y, data1d.x) + qx_min_fold = 0 + expected_area = expected_slabx_area(qx_min_fold, qx_max, qy_min, qy_max) + actual_area = integrate_1d_output(data1d, method="simpson") self.assertAlmostEqual(actual_area, expected_area, 2) - # TODO - also check the errors are being calculated correctly - - class SlabYTests(unittest.TestCase): """ This class contains all the unit tests for the SlabY class from @@ -320,7 +378,6 @@ def test_slaby_multiple_detectors(self): detector2 = data_info.Detector() averager_data.data.detector.append(detector1) averager_data.data.detector.append(detector2) - slab_object = SlabY() self.assertRaises(ValueError, slab_object, averager_data.data) @@ -344,12 +401,10 @@ def test_slaby_averaging_without_fold(self): """ Test that SlabY can average correctly when y is the major axis """ - matrix_size = 201 - x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), - np.linspace(-1, 1, matrix_size)) - # Create a distribution which is linear in x and quadratic in y - test_data = x * y**2 - averager_data = MatrixToData2D(data2d=test_data) + def func(x, y): + return x * y**2 + averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) + # Set up region of interest to average over - the limits are arbitrary. qx_min = -0.5 * averager_data.qmax # = -0.5 @@ -363,28 +418,19 @@ def test_slaby_averaging_without_fold(self): slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) data1d = slab_object(averager_data.data) - # ∫x dx = x² / 2 + constant. - x_part_integ = (qx_max**2 - qx_min**2) / 2 - x_part_avg = x_part_integ / (qx_max - qx_min) # or (x_min + x_max) / 2 - # ∫y² dy = y³ / 3 + constant. - y_part_integ = (qy_max**3 - qy_min**3) / 3 - expected_area = x_part_avg * y_part_integ - actual_area = integrate.simpson(data1d.y, data1d.x) + expected_area = expected_slaby_area(qx_min, qx_max, qy_min, qy_max) + actual_area = integrate_1d_output(data1d, method="simpson") self.assertAlmostEqual(actual_area, expected_area, 2) - # TODO - also check the errors are being calculated correctly - def test_slab_averaging_y_with_fold(self): """ Test that SlabY can average correctly when y is the major axis """ - matrix_size = 201 - x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), - np.linspace(-1, 1, matrix_size)) - # Create a distribution which is linear in x and quadratic in y - test_data = x * y**2 - averager_data = MatrixToData2D(data2d=test_data) + def func(x, y): + return x * y**2 + + averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) # Set up region of interest to average over - the limits are arbitrary. qx_min = -0.5 * averager_data.qmax # = -0.5 @@ -400,20 +446,12 @@ def test_slab_averaging_y_with_fold(self): # Negative values of y are not graphed when fold = True, so don't # include them in the area calculation. - qy_min = 0 - # ∫x dx = x² / 2 + constant. - x_part_integ = (qx_max**2 - qx_min**2) / 2 - x_part_avg = x_part_integ / (qx_max - qx_min) # or (x_min + x_max) / 2 - # ∫y² dy = y³ / 3 + constant. - y_part_integ = (qy_max**3 - qy_min**3) / 3 - expected_area = x_part_avg * y_part_integ - actual_area = integrate.simpson(data1d.y, data1d.x) + qy_min_fold = 0 + expected_area = expected_slaby_area(qx_min, qx_max, qy_min_fold, qy_max) + actual_area = integrate_1d_output(data1d, method="simpson") self.assertAlmostEqual(actual_area, expected_area, 2) - # TODO - also check the errors are being calculated correctly - - class BoxsumTests(unittest.TestCase): """ This class contains all the unit tests for the Boxsum class from @@ -440,14 +478,14 @@ def test_boxsum_multiple_detectors(self): """ Test Boxsum raises an error when there are multiple detectors. """ - averager_data = MatrixToData2D(np.ones([100, 100])) + dd = make_uniform_dd((100, 100), value=1.0) detector1 = data_info.Detector() detector2 = data_info.Detector() - averager_data.data.detector.append(detector1) - averager_data.data.detector.append(detector2) + dd.data.detector.append(detector1) + dd.data.detector.append(detector2) box_object = Boxsum() - self.assertRaises(ValueError, box_object, averager_data.data) + self.assertRaises(ValueError, box_object, dd.data) def test_boxsum_total(self): """ @@ -456,21 +494,11 @@ def test_boxsum_total(self): # Creating a 100x100 matrix for a distribution which is flat in y # and linear in x. test_data = np.tile(np.arange(100), (100, 1)) - averager_data = MatrixToData2D(data2d=test_data) + dd = MatrixToData2D(data2d=test_data) - # Selected region is entire data set - qx_min = -1 * averager_data.qmax - qx_max = averager_data.qmax - qy_min = -1 * averager_data.qmax - qy_max = averager_data.qmax - box_object = Boxsum(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) - result, error, npoints = box_object(averager_data.data) - correct_sum = np.sum(test_data) - # When averager_data was created, we didn't include any error data. - # Stand-in error data is created, equal to np.sqrt(data2D). - # With the current method of error calculation, this is the result we - # should expect. This may need to change at some point. - correct_error = np.sqrt(np.sum(test_data)) + box_object = Boxsum(qx_range=(-1 * dd.qmax, dd.qmax), qy_range=(-1 * dd.qmax, dd.qmax)) + result, error, npoints = box_object(dd.data) + correct_sum, correct_error = expected_boxsum_and_err(test_data) self.assertAlmostEqual(result, correct_sum, 6) self.assertAlmostEqual(error, correct_error, 6) @@ -482,24 +510,13 @@ def test_boxsum_subset_total(self): # Creating a 100x100 matrix for a distribution which is flat in y # and linear in x. test_data = np.tile(np.arange(100), (100, 1)) - averager_data = MatrixToData2D(data2d=test_data) + dd = MatrixToData2D(data2d=test_data) - # Selection region covers the inner half of the +&- x&y axes - qx_min = -0.5 * averager_data.qmax - qx_max = 0.5 * averager_data.qmax - qy_min = -0.5 * averager_data.qmax - qy_max = 0.5 * averager_data.qmax - # Extracting the inner half of the data set + # region corresponds to central 50x50 in original test + box_object = Boxsum(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) + result, error, npoints = box_object(dd.data) inner_portion = test_data[25:75, 25:75] - - box_object = Boxsum(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) - result, error, npoints = box_object(averager_data.data) - correct_sum = np.sum(inner_portion) - # When averager_data was created, we didn't include any error data. - # Stand-in error data is created, equal to np.sqrt(data2D). - # With the current method of error calculation, this is the result we - # should expect. This may need to change at some point. - correct_error = np.sqrt(np.sum(inner_portion)) + correct_sum, correct_error = expected_boxsum_and_err(inner_portion) self.assertAlmostEqual(result, correct_sum, 6) self.assertAlmostEqual(error, correct_error, 6) @@ -509,17 +526,11 @@ def test_boxsum_zero_sum(self): Test that Boxsum returns 0 when there are no points within the ROI """ test_data = np.ones([100, 100]) - # Make a hole in the middle with zeros - test_data[25:75, 25:75] = np.zeros([50, 50]) - averager_data = MatrixToData2D(data2d=test_data) + test_data[25:75, 25:75] = 0 + dd = MatrixToData2D(data2d=test_data) - # Selection region covers the inner half of the +&- x&y axes - qx_min = -0.5 * averager_data.qmax - qx_max = 0.5 * averager_data.qmax - qy_min = -0.5 * averager_data.qmax - qy_max = 0.5 * averager_data.qmax - box_object = Boxsum(qx_range=(qx_min, qx_max), qy_range=(qy_min, qy_max)) - result, error, npoints = box_object(averager_data.data) + box_object = Boxsum(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) + result, error, npoints = box_object(dd.data) self.assertAlmostEqual(result, 0, 6) self.assertAlmostEqual(error, 0, 6) @@ -551,14 +562,14 @@ def test_boxavg_multiple_detectors(self): """ Test Boxavg raises an error when there are multiple detectors. """ - averager_data = MatrixToData2D(np.ones([100, 100])) + dd = make_uniform_dd((100, 100), value=1.0) detector1 = data_info.Detector() detector2 = data_info.Detector() - averager_data.data.detector.append(detector1) - averager_data.data.detector.append(detector2) + dd.data.detector.append(detector1) + dd.data.detector.append(detector2) box_object = Boxavg() - self.assertRaises(ValueError, box_object, averager_data.data) + self.assertRaises(ValueError, box_object, dd.data) def test_boxavg_total(self): """ @@ -567,21 +578,11 @@ def test_boxavg_total(self): # Creating a 100x100 matrix for a distribution which is flat in y # and linear in x. test_data = np.tile(np.arange(100), (100, 1)) - averager_data = MatrixToData2D(data2d=test_data) + dd = MatrixToData2D(data2d=test_data) - # Selected region is entire data set - qx_min = -1 * averager_data.qmax - qx_max = averager_data.qmax - qy_min = -1 * averager_data.qmax - qy_max = averager_data.qmax - box_object = Boxavg(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) - result, error = box_object(averager_data.data) - correct_avg = np.mean(test_data) - # When averager_data was created, we didn't include any error data. - # Stand-in error data is created, equal to np.sqrt(data2D). - # With the current method of error calculation, this is the result we - # should expect. This may need to change at some point. - correct_error = np.sqrt(np.sum(test_data)) / test_data.size + box_object = Boxavg(qx_range=(-1 * dd.qmax, dd.qmax), qy_range=(-1 * dd.qmax, dd.qmax)) + result, error = box_object(dd.data) + correct_avg, correct_error = expected_boxavg_and_err(test_data) self.assertAlmostEqual(result, correct_avg, 6) self.assertAlmostEqual(error, correct_error, 6) @@ -593,24 +594,12 @@ def test_boxavg_subset_total(self): # Creating a 100x100 matrix for a distribution which is flat in y # and linear in x. test_data = np.tile(np.arange(100), (100, 1)) - averager_data = MatrixToData2D(data2d=test_data) + dd = MatrixToData2D(data2d=test_data) - # Selection region covers the inner half of the +&- x&y axes - qx_min = -0.5 * averager_data.qmax - qx_max = 0.5 * averager_data.qmax - qy_min = -0.5 * averager_data.qmax - qy_max = 0.5 * averager_data.qmax - # Extracting the inner half of the data set + box_object = Boxavg(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) + result, error = box_object(dd.data) inner_portion = test_data[25:75, 25:75] - - box_object = Boxavg(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) - result, error = box_object(averager_data.data) - correct_avg = np.mean(inner_portion) - # When averager_data was created, we didn't include any error data. - # Stand-in error data is created, equal to np.sqrt(data2D). - # With the current method of error calculation, this is the result we - # should expect. This may need to change at some point. - correct_error = np.sqrt(np.sum(inner_portion)) / inner_portion.size + correct_avg, correct_error = expected_boxavg_and_err(inner_portion) self.assertAlmostEqual(result, correct_avg, 6) self.assertAlmostEqual(error, correct_error, 6) @@ -622,15 +611,10 @@ def test_boxavg_zero_average(self): test_data = np.ones([100, 100]) # Make a hole in the middle with zeros test_data[25:75, 25:75] = np.zeros([50, 50]) - averager_data = MatrixToData2D(data2d=test_data) + dd = MatrixToData2D(data2d=test_data) - # Selection region covers the inner half of the +&- x&y axes - qx_min = -0.5 * averager_data.qmax - qx_max = 0.5 * averager_data.qmax - qy_min = -0.5 * averager_data.qmax - qy_max = 0.5 * averager_data.qmax - box_object = Boxavg(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) - result, error = box_object(averager_data.data) + box_object = Boxavg(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) + result, error = box_object(dd.data) self.assertAlmostEqual(result, 0, 6) self.assertAlmostEqual(error, 0, 6) @@ -730,9 +714,6 @@ def test_circularaverage_averages_circularly(self): # This is still a good level of precision compared to the others though self.assertAlmostEqual(actual_area, expected_area, 2) - # TODO - also check the errors are being calculated correctly - - class RingTests(unittest.TestCase): """ This class contains the tests for the Ring class from manipulations.py @@ -800,9 +781,6 @@ def test_ring_averages_azimuthally(self): self.assertAlmostEqual(actual_area, expected_area, 1) - # TODO - also check the errors are being calculated correctly - - class SectorQTests(unittest.TestCase): """ This class contains the tests for the SectorQ class from manipulations.py @@ -1161,7 +1139,6 @@ def test_directional_averaging(self): np.testing.assert_array_almost_equal(x_axis_values, expected_x, 10) np.testing.assert_array_almost_equal(intensity, expected_intensity, 10) - # TODO - also implement check for correct errors def test_no_points_in_roi(self): """ @@ -1173,6 +1150,5 @@ def test_no_points_in_roi(self): self.assertRaises(ValueError, self.directional_average, self.data2d.data.data, self.data2d.data.err_data) - if __name__ == '__main__': unittest.main() From 6de12327cd58e01843c0a761fc2448835f6a2d2c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 15 Nov 2025 11:47:55 +0000 Subject: [PATCH 41/49] [pre-commit.ci lite] apply automatic fixes for ruff linting errors --- sasdata/data_util/averaging.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index 2398a902..60dde6e8 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -9,6 +9,7 @@ from sasdata.dataloader.data_info import Data1D, Data2D + class IntervalType(Enum): HALF_OPEN = auto() CLOSED = auto() From 726143a20f11e486858b3934d9a1e18d74fb0f5d Mon Sep 17 00:00:00 2001 From: Dirk Honecker Date: Sat, 15 Nov 2025 13:10:47 +0000 Subject: [PATCH 42/49] further reducing code complexity and refactoring --- sasdata/data_util/averaging.py | 74 ++++-- test/sasmanipulations/helper.py | 233 ++++++++++++++++++ .../utest_averaging_analytical.py | 212 +--------------- 3 files changed, 289 insertions(+), 230 deletions(-) create mode 100644 test/sasmanipulations/helper.py diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index 2398a902..5c266ffd 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -79,45 +79,79 @@ def __init__(self, :param nbins: The number of bins the major axis is divided up into. """ + # Step 1: quick checks and parsing + self._validate_coordinate_arrays(major_axis, minor_axis) + major_lims, minor_lims = self._parse_lims(lims) + self.nbins = self._coerce_nbins(nbins) + + # Step 2: assign arrays and check sizes + self.major_axis, self.minor_axis = self._assign_axes_and_check_lengths(major_axis, minor_axis) + + # Step 3: set final limits and compute bin limits + self.major_lims, self.minor_lims = self._set_default_lims_and_bin_limits(major_lims, minor_lims) + + def _validate_coordinate_arrays(self, major_axis, minor_axis) -> None: + """Ensure both major and minor coordinate inputs are array-like.""" if any(not hasattr(coordinate_data, "__array__") for - coordinate_data in (major_axis, minor_axis)): + coordinate_data in (major_axis, minor_axis)): msg = "Must provide major & minor coordinate arrays for binning." raise ValueError(msg) + def _parse_lims(self, lims): + """ + Validate the lims parameter and return (major_lims, minor_lims). + Accepts None or a 2-tuple (major_lims, minor_lims). Each of the two + elements may be None or a 2-tuple of floats. + """ if lims is None: - major_lims = minor_lims = None + return None, None - elif not (isinstance(lims, (list, tuple)) and len(lims) == 2): + if not (isinstance(lims, (list, tuple)) and len(lims) == 2): msg = "Parameter 'lims' must be a 2-tuple (major_lims, minor_lims) or None." raise ValueError(msg) - else: - major_lims, minor_lims = lims + major_lims, minor_lims = lims + return major_lims, minor_lims + + def _coerce_nbins(self, nbins): + """Coerce nbins to int, raising a TypeError with the original message on failure.""" try: - nbins = int(nbins) - except: + return int(nbins) + except Exception: msg = f"Parameter 'nbins' must be convertable to an integer via int(), got type {type(nbins)} (={nbins})" raise TypeError(msg) - self.major_axis = np.asarray(major_axis) - self.minor_axis = np.asarray(minor_axis) - if self.major_axis.size != self.minor_axis.size: + def _assign_axes_and_check_lengths(self, major_axis, minor_axis): + """Assign axes to numpy arrays and check they have equal length.""" + major_arr = np.asarray(major_axis) + minor_arr = np.asarray(minor_axis) + if major_arr.size != minor_arr.size: msg = "Major and minor axes must have same length" raise ValueError(msg) - # In some cases all values from a given axis are part of the ROI. - # An alternative approach may be needed for fractional weights. + return major_arr, minor_arr + + def _set_default_lims_and_bin_limits(self, major_lims, minor_lims): + """ + Determine final major and minor limits (using data min/max if None) + and compute bin_limits based on major_lims and self.nbins. + Returns (major_lims_final, minor_lims_final). + """ + # Major limits if major_lims is None: - self.major_lims = (self.major_axis.min(), self.major_axis.max()) + major_lims_final = (self.major_axis.min(), self.major_axis.max()) else: - self.major_lims = major_lims + major_lims_final = major_lims + + # Minor limits if minor_lims is None: - self.minor_lims = (self.minor_axis.min(), self.minor_axis.max()) + minor_lims_final = (self.minor_axis.min(), self.minor_axis.max()) else: - self.minor_lims = minor_lims - self.nbins = nbins - # Assume a linear spacing for now, but allow for log, fibonacci, etc. implementations in the future - # Add one to bin because this is for the limits, not centroids. - self.bin_limits = np.linspace(self.major_lims[0], self.major_lims[1], self.nbins + 1) + minor_lims_final = minor_lims + + # Store and compute bin limits (nbins + 1 points for boundaries) + self.bin_limits = np.linspace(major_lims_final[0], major_lims_final[1], self.nbins + 1) + + return major_lims_final, minor_lims_final @property def bin_widths(self) -> np.ndarray: diff --git a/test/sasmanipulations/helper.py b/test/sasmanipulations/helper.py new file mode 100644 index 00000000..e5574b40 --- /dev/null +++ b/test/sasmanipulations/helper.py @@ -0,0 +1,233 @@ +""" +Shared test helpers for averaging tests. +""" +import numpy as np +from scipy import integrate +from sasdata.dataloader import data_info + +def make_dd_from_func(func, matrix_size=201): + """ + Create a MatrixToData2D from a function of (x, y). Returns the MatrixToData2D + instance and matrix_size for convenience. + func should accept (x, y) meshgrid arrays and return a 2D array. + """ + x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), + np.linspace(-1, 1, matrix_size)) + mat = func(x, y) + return MatrixToData2D(data2d=mat), matrix_size + +def make_uniform_dd(shape=(100, 100), value=1.0): + mat = np.full(shape, value, dtype=float) + return MatrixToData2D(data2d=mat) + +def integrate_1d_output(output, method="simpson"): + """ + Integrate output from an averager consistently. + - If output is a Data1D-like object with .x and .y -> integrate y(x) + - If output is a tuple (result, error[, npoints]) -> return numeric result + """ + if hasattr(output, "x") and hasattr(output, "y"): + if method == "trapezoid": + return integrate.trapezoid(output.y, output.x) + return integrate.simpson(output.y, output.x) + if isinstance(output, tuple) and len(output) >= 1: + return output[0] + raise TypeError("Unsupported averager output type: %r" % type(output)) + + +def expected_slabx_area(qx_min, qx_max, qy_min, qy_max): + # data = x^2 * y -> integrate x^2 dx and average y across qy range + x_part_integ = (qx_max**3 - qx_min**3) / 3 + y_part_integ = (qy_max**2 - qy_min**2) / 2 + y_part_avg = y_part_integ / (qy_max - qy_min) + return y_part_avg * x_part_integ + +def expected_slaby_area(qx_min, qx_max, qy_min, qy_max): + # data = x * y^2 -> integrate y^2 dy and average x across qx range + y_part_integ = (qy_max**3 - qy_min**3) / 3 + x_part_integ = (qx_max**2 - qx_min**2) / 2 + x_part_avg = x_part_integ / (qx_max - qx_min) + return x_part_avg * y_part_integ + +def make_uniform_dd(shape=(100, 100), value=1.0): + """Convenience for tests that need a constant matrix Data2D.""" + mat = np.full(shape, value, dtype=float) + return MatrixToData2D(data2d=mat) + +def run_and_integrate(averager, dd, integrator="simpson"): + """ + Run an averager (callable) with a Data2D container returned by MatrixToData2D + and return the integrated result (scalar area / sum) consistently. + """ + out = averager(dd.data) + return integrate_1d_output(out, method=("trapezoid" if integrator == "trapezoid" else "simpson")) + +def expected_boxsum_and_err(matrix, slice_rows=None, slice_cols=None): + """ + Compute expected Boxsum (sum) and its error for a given 2D numpy matrix. + Optional slice indices can restrict the region (tuples/lists of indices). + """ + mat = np.asarray(matrix) + if slice_rows is not None and slice_cols is not None: + mat = mat[np.ix_(slice_rows, slice_cols)] + total = np.sum(mat) + err = np.sqrt(np.sum(mat)) + return total, err + +def expected_boxavg_and_err(matrix, slice_rows=None, slice_cols=None): + """ + Compute expected Boxavg (mean) and its error for a given 2D numpy matrix. + Error uses sqrt(sum)/N as in existing tests. + """ + mat = np.asarray(matrix) + if slice_rows is not None and slice_cols is not None: + mat = mat[np.ix_(slice_rows, slice_cols)] + avg = np.mean(mat) if mat.size > 0 else 0.0 + err = np.sqrt(np.sum(mat)) / mat.size if mat.size > 0 else 0.0 + return avg, err + + +class MatrixToData2D: + """ + Create Data2D objects from supplied 2D arrays of data. + Error data can also be included. + + Adapted from sasdata.data_util.manipulations.reader_2D_converter + """ + + def __init__(self, data2d=None, err_data=None): + matrix, err_arr = self._validate_and_convert_inputs(data2d, err_data) + qx_bins, qy_bins = self._compute_bins(matrix.shape) + data_flat, err_flat, qx_data, qy_data, q_data, mask = self._build_flat_arrays(matrix, err_arr, qx_bins, qy_bins) + + # qmax can be any number, 1 just makes things simple. + self.qmax = 1 + # Creating a Data2D object to use for testing the averagers. + self.data = data_info.Data2D(data=data_flat, err_data=err_flat, + qx_data=qx_data, qy_data=qy_data, + q_data=q_data, mask=mask) + + def _validate_and_convert_inputs(self, data2d, err_data): + """Validate inputs and coerce to numpy arrays. Returns (matrix, err_data_or_None).""" + if data2d is None: + raise ValueError("Data must be supplied to convert to Data2D") + matrix = np.asarray(data2d) + if matrix.ndim != 2: + raise ValueError("Supplied array must have 2 dimensions to convert to Data2D") + + if err_data is not None: + err_arr = np.asarray(err_data) + if err_arr.shape != matrix.shape: + raise ValueError("Data and errors must have the same shape") + else: + err_arr = None + return matrix, err_arr + + def _compute_bins(self, matrix_shape): + """Compute qx and qy bin edges given the matrix shape.""" + cols = matrix_shape[1] + rows = matrix_shape[0] + qx_bins = np.linspace(start=-1 * 1, stop=1, num=cols, endpoint=True) + qy_bins = np.linspace(start=-1 * 1, stop=1, num=rows, endpoint=True) + return qx_bins, qy_bins + # qmax can be any number, 1 just makes things simple. + self.qmax = 1 + qx_bins = np.linspace(start=-1 * self.qmax, + stop=self.qmax, + num=matrix.shape[1], + endpoint=True) + qy_bins = np.linspace(start=-1 * self.qmax, + stop=self.qmax, + num=matrix.shape[0], + endpoint=True) + + def _build_flat_arrays(self, matrix, err_arr, qx_bins, qy_bins): + """Flatten matrix and build qx, qy, q arrays plus mask and error handling.""" + data_flat = matrix.flatten() + if err_arr is None or np.any(err_arr <= 0): + # Error data of some kind is needed, so we fabricate some + err_flat = np.sqrt(np.abs(data_flat)) + else: + err_flat = np.asarray(err_arr).flatten() + + qx_data = np.tile(qx_bins, (len(qy_bins), 1)).flatten() + qy_data = np.tile(qy_bins, (len(qx_bins), 1)).swapaxes(0, 1).flatten() + q_data = np.sqrt(qx_data * qx_data + qy_data * qy_data) + mask = np.ones(len(data_flat), dtype=bool) + return data_flat, err_flat, qx_data, qy_data, q_data, mask + +class CircularTestingMatrix: + """ + This class is used to generate a 2D array representing a function in polar + coordinates. The function, f(r, φ) = R(r) * Φ(φ), factorises into simple + radial and angular parts. This makes it easy to determine the form of the + function after one of the parts has been averaged over, and therefore good + for testing the directional averagers in manipulations.py. + This testing is done by comparing the area under the functions, as these + will only match if the limits defining the ROI were applied correctly. + + f(r, φ) = R(r) * Φ(φ) + R(r) = r ; where 0 <= r <= 1. + Φ(φ) = 1 + sin(ν * φ) ; where ν is the frequency and 0 <= φ <= 2π. + """ + + def __init__(self, frequency=1, matrix_size=201, major_axis=None): + """ + :param frequency: No. times Φ(φ) oscillates over the 0 <= φ <= 2π range + This parameter is largely arbitrary. + :param matrix_size: The len() of the output matrix. + Note that odd numbers give a centrepoint of 0,0. + :param major_axis: 'Q' or 'Phi' - the axis plotted against by the + averager being tested. + """ + if major_axis not in ('Q', 'Phi'): + msg = "Major axis must be either 'Q' or 'Phi'." + raise ValueError(msg) + + self.freq = frequency + self.matrix_size = matrix_size + self.major = major_axis + + # Grid with same dimensions as data matrix, ranging from -1 to 1 + x, y = np.meshgrid(np.linspace(-1, 1, self.matrix_size), + np.linspace(-1, 1, self.matrix_size)) + # radius is 0 at the centre, and 1 at (0, +/-1) and (+/-1, 0) + radius = np.sqrt(x**2 + y**2) + angle = np.arctan2(y, x) + # Create the 2D array of data + # The sinusoidal part is shifted up by 1 so its average is never 0 + self.matrix = radius * (1 + np.sin(self.freq * angle)) + + def area_under_region(self, r_min=0, r_max=1, phi_min=0, phi_max=2*np.pi): + """ + Integral of the testing matrix along the major axis, between the limits + specified. This can be compared to the integral under the 1D data + output by the averager being tested to confirm it's working properly. + :param r_min: value defining the minimum Q in the ROI. + :param r_max: value defining the maximum Q in the ROI. + :param phi_min: value defining the minimum Phi in the ROI. + :param phi_max: value defining the maximum Phi in the ROI. + """ + + phi_range = phi_max - phi_min + # ∫(1 + sin(ν * φ)) dφ = φ + (-cos(ν * φ) / ν) + constant. + sine_part_integ = phi_range - (np.cos(self.freq * phi_max) - + np.cos(self.freq * phi_min)) / self.freq + sine_part_avg = sine_part_integ / phi_range + + # ∫(r) dr = r²/2 + constant. + linear_part_integ = (r_max ** 2 - r_min ** 2) / 2 + # The average radius is weighted towards higher radii. The probability + # of a point having a given radius value is proportional to the radius: + # P(r) = k * r ; where k is some proportionality constant. + # ∫[r₀, r₁] P(r) dr = 1, which can be solved for k. This can then be + # substituted into ⟨r⟩ = ∫[r₀, r₁] P(r) * r dr, giving: + linear_part_avg = 2/3 * (r_max**3 - r_min**3) / (r_max**2 - r_min**2) + + # The integral along the major axis is modulated by the average value + # along the minor axis (between the limits). + if self.major == 'Q': + calculated_area = sine_part_avg * linear_part_integ + else: + calculated_area = linear_part_avg * sine_part_integ + return calculated_area \ No newline at end of file diff --git a/test/sasmanipulations/utest_averaging_analytical.py b/test/sasmanipulations/utest_averaging_analytical.py index 98483414..705e71ae 100644 --- a/test/sasmanipulations/utest_averaging_analytical.py +++ b/test/sasmanipulations/utest_averaging_analytical.py @@ -8,7 +8,8 @@ import numpy as np from scipy import integrate - +from test.sasmanipulations.helper import (MatrixToData2D, CircularTestingMatrix, make_dd_from_func, + expected_slabx_area, expected_slaby_area, integrate_1d_output, expected_boxsum_and_err,expected_boxavg_and_err, make_uniform_dd) from sasdata.data_util.averaging import ( Boxavg, Boxsum, @@ -25,215 +26,6 @@ # TODO - also check the errors are being calculated correctly -# ------------------------ -# Helpers -# ------------------------ -def make_dd_from_func(func, matrix_size=201): - """ - Create a MatrixToData2D from a function of (x, y). Returns the MatrixToData2D - instance and matrix_size for convenience. - func should accept (x, y) meshgrid arrays and return a 2D array. - """ - x, y = np.meshgrid(np.linspace(-1, 1, matrix_size), - np.linspace(-1, 1, matrix_size)) - mat = func(x, y) - return MatrixToData2D(data2d=mat), matrix_size - - -def integrate_1d_output(output, method="simpson"): - """ - Integrate output from an averager consistently. - - If output is a Data1D-like object with .x and .y -> integrate y(x) - - If output is a tuple (result, error[, npoints]) -> return numeric result - """ - if hasattr(output, "x") and hasattr(output, "y"): - if method == "trapezoid": - return integrate.trapezoid(output.y, output.x) - return integrate.simpson(output.y, output.x) - if isinstance(output, tuple) and len(output) >= 1: - return output[0] - raise TypeError("Unsupported averager output type: %r" % type(output)) - - -def expected_slabx_area(qx_min, qx_max, qy_min, qy_max): - # data = x^2 * y -> integrate x^2 dx and average y across qy range - x_part_integ = (qx_max**3 - qx_min**3) / 3 - y_part_integ = (qy_max**2 - qy_min**2) / 2 - y_part_avg = y_part_integ / (qy_max - qy_min) - return y_part_avg * x_part_integ - -def expected_slaby_area(qx_min, qx_max, qy_min, qy_max): - # data = x * y^2 -> integrate y^2 dy and average x across qx range - y_part_integ = (qy_max**3 - qy_min**3) / 3 - x_part_integ = (qx_max**2 - qx_min**2) / 2 - x_part_avg = x_part_integ / (qx_max - qx_min) - return x_part_avg * y_part_integ - -def make_uniform_dd(shape=(100, 100), value=1.0): - """Convenience for tests that need a constant matrix Data2D.""" - mat = np.full(shape, value, dtype=float) - return MatrixToData2D(data2d=mat) - -def run_and_integrate(averager, dd, integrator="simpson"): - """ - Run an averager (callable) with a Data2D container returned by MatrixToData2D - and return the integrated result (scalar area / sum) consistently. - """ - out = averager(dd.data) - return integrate_1d_output(out, method=("trapezoid" if integrator == "trapezoid" else "simpson")) - -def expected_boxsum_and_err(matrix, slice_rows=None, slice_cols=None): - """ - Compute expected Boxsum (sum) and its error for a given 2D numpy matrix. - Optional slice indices can restrict the region (tuples/lists of indices). - """ - mat = np.asarray(matrix) - if slice_rows is not None and slice_cols is not None: - mat = mat[np.ix_(slice_rows, slice_cols)] - total = np.sum(mat) - err = np.sqrt(np.sum(mat)) - return total, err - -def expected_boxavg_and_err(matrix, slice_rows=None, slice_cols=None): - """ - Compute expected Boxavg (mean) and its error for a given 2D numpy matrix. - Error uses sqrt(sum)/N as in existing tests. - """ - mat = np.asarray(matrix) - if slice_rows is not None and slice_cols is not None: - mat = mat[np.ix_(slice_rows, slice_cols)] - avg = np.mean(mat) if mat.size > 0 else 0.0 - err = np.sqrt(np.sum(mat)) / mat.size if mat.size > 0 else 0.0 - return avg, err - -class MatrixToData2D: - """ - Create Data2D objects from supplied 2D arrays of data. - Error data can also be included. - - Adapted from sasdata.data_util.manipulations.reader_2D_converter - """ - - def __init__(self, data2d=None, err_data=None): - if data2d is not None: - matrix = np.asarray(data2d) - else: - msg = "Data must be supplied to convert to Data2D" - raise ValueError(msg) - - if matrix.ndim != 2: - msg = "Supplied array must have 2 dimensions to convert to Data2D" - raise ValueError(msg) - - if err_data is not None: - err_data = np.asarray(err_data) - if err_data.shape != matrix.shape: - msg = "Data and errors must have the same shape" - raise ValueError(msg) - - # qmax can be any number, 1 just makes things simple. - self.qmax = 1 - qx_bins = np.linspace(start=-1 * self.qmax, - stop=self.qmax, - num=matrix.shape[1], - endpoint=True) - qy_bins = np.linspace(start=-1 * self.qmax, - stop=self.qmax, - num=matrix.shape[0], - endpoint=True) - - # Creating arrays in Data2D's preferred format. - data2d = matrix.flatten() - if err_data is None or np.any(err_data <= 0): - # Error data of some kind is needed, so we fabricate some - err_data = np.sqrt(np.abs(data2d)) # TODO - use different approach - else: - err_data = err_data.flatten() - qx_data = np.tile(qx_bins, (len(qy_bins), 1)).flatten() - qy_data = np.tile(qy_bins, (len(qx_bins), 1)).swapaxes(0, 1).flatten() - q_data = np.sqrt(qx_data * qx_data + qy_data * qy_data) - mask = np.ones(len(data2d), dtype=bool) - - # Creating a Data2D object to use for testing the averagers. - self.data = data_info.Data2D(data=data2d, err_data=err_data, - qx_data=qx_data, qy_data=qy_data, - q_data=q_data, mask=mask) - -class CircularTestingMatrix: - """ - This class is used to generate a 2D array representing a function in polar - coordinates. The function, f(r, φ) = R(r) * Φ(φ), factorises into simple - radial and angular parts. This makes it easy to determine the form of the - function after one of the parts has been averaged over, and therefore good - for testing the directional averagers in manipulations.py. - This testing is done by comparing the area under the functions, as these - will only match if the limits defining the ROI were applied correctly. - - f(r, φ) = R(r) * Φ(φ) - R(r) = r ; where 0 <= r <= 1. - Φ(φ) = 1 + sin(ν * φ) ; where ν is the frequency and 0 <= φ <= 2π. - """ - - def __init__(self, frequency=1, matrix_size=201, major_axis=None): - """ - :param frequency: No. times Φ(φ) oscillates over the 0 <= φ <= 2π range - This parameter is largely arbitrary. - :param matrix_size: The len() of the output matrix. - Note that odd numbers give a centrepoint of 0,0. - :param major_axis: 'Q' or 'Phi' - the axis plotted against by the - averager being tested. - """ - if major_axis not in ('Q', 'Phi'): - msg = "Major axis must be either 'Q' or 'Phi'." - raise ValueError(msg) - - self.freq = frequency - self.matrix_size = matrix_size - self.major = major_axis - - # Grid with same dimensions as data matrix, ranging from -1 to 1 - x, y = np.meshgrid(np.linspace(-1, 1, self.matrix_size), - np.linspace(-1, 1, self.matrix_size)) - # radius is 0 at the centre, and 1 at (0, +/-1) and (+/-1, 0) - radius = np.sqrt(x**2 + y**2) - angle = np.arctan2(y, x) - # Create the 2D array of data - # The sinusoidal part is shifted up by 1 so its average is never 0 - self.matrix = radius * (1 + np.sin(self.freq * angle)) - - def area_under_region(self, r_min=0, r_max=1, phi_min=0, phi_max=2*np.pi): - """ - Integral of the testing matrix along the major axis, between the limits - specified. This can be compared to the integral under the 1D data - output by the averager being tested to confirm it's working properly. - :param r_min: value defining the minimum Q in the ROI. - :param r_max: value defining the maximum Q in the ROI. - :param phi_min: value defining the minimum Phi in the ROI. - :param phi_max: value defining the maximum Phi in the ROI. - """ - - phi_range = phi_max - phi_min - # ∫(1 + sin(ν * φ)) dφ = φ + (-cos(ν * φ) / ν) + constant. - sine_part_integ = phi_range - (np.cos(self.freq * phi_max) - - np.cos(self.freq * phi_min)) / self.freq - sine_part_avg = sine_part_integ / phi_range - - # ∫(r) dr = r²/2 + constant. - linear_part_integ = (r_max ** 2 - r_min ** 2) / 2 - # The average radius is weighted towards higher radii. The probability - # of a point having a given radius value is proportional to the radius: - # P(r) = k * r ; where k is some proportionality constant. - # ∫[r₀, r₁] P(r) dr = 1, which can be solved for k. This can then be - # substituted into ⟨r⟩ = ∫[r₀, r₁] P(r) * r dr, giving: - linear_part_avg = 2/3 * (r_max**3 - r_min**3) / (r_max**2 - r_min**2) - - # The integral along the major axis is modulated by the average value - # along the minor axis (between the limits). - if self.major == 'Q': - calculated_area = sine_part_avg * linear_part_integ - else: - calculated_area = linear_part_avg * sine_part_integ - return calculated_area class SlabXTests(unittest.TestCase): """ From 493a396a333ca175ccd2ca5ce85649f34092f5de Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 15 Nov 2025 13:20:17 +0000 Subject: [PATCH 43/49] [pre-commit.ci lite] apply automatic fixes for ruff linting errors --- test/sasmanipulations/helper.py | 12 +++++++----- .../sasmanipulations/utest_averaging_analytical.py | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/test/sasmanipulations/helper.py b/test/sasmanipulations/helper.py index e5574b40..1b52d8c8 100644 --- a/test/sasmanipulations/helper.py +++ b/test/sasmanipulations/helper.py @@ -3,8 +3,10 @@ """ import numpy as np from scipy import integrate + from sasdata.dataloader import data_info + def make_dd_from_func(func, matrix_size=201): """ Create a MatrixToData2D from a function of (x, y). Returns the MatrixToData2D @@ -85,8 +87,8 @@ def expected_boxavg_and_err(matrix, slice_rows=None, slice_cols=None): avg = np.mean(mat) if mat.size > 0 else 0.0 err = np.sqrt(np.sum(mat)) / mat.size if mat.size > 0 else 0.0 return avg, err - - + + class MatrixToData2D: """ Create Data2D objects from supplied 2D arrays of data. @@ -105,7 +107,7 @@ def __init__(self, data2d=None, err_data=None): # Creating a Data2D object to use for testing the averagers. self.data = data_info.Data2D(data=data_flat, err_data=err_flat, qx_data=qx_data, qy_data=qy_data, - q_data=q_data, mask=mask) + q_data=q_data, mask=mask) def _validate_and_convert_inputs(self, data2d, err_data): """Validate inputs and coerce to numpy arrays. Returns (matrix, err_data_or_None).""" @@ -155,7 +157,7 @@ def _build_flat_arrays(self, matrix, err_arr, qx_bins, qy_bins): q_data = np.sqrt(qx_data * qx_data + qy_data * qy_data) mask = np.ones(len(data_flat), dtype=bool) return data_flat, err_flat, qx_data, qy_data, q_data, mask - + class CircularTestingMatrix: """ This class is used to generate a 2D array representing a function in polar @@ -230,4 +232,4 @@ def area_under_region(self, r_min=0, r_max=1, phi_min=0, phi_max=2*np.pi): calculated_area = sine_part_avg * linear_part_integ else: calculated_area = linear_part_avg * sine_part_integ - return calculated_area \ No newline at end of file + return calculated_area diff --git a/test/sasmanipulations/utest_averaging_analytical.py b/test/sasmanipulations/utest_averaging_analytical.py index 705e71ae..b7a40965 100644 --- a/test/sasmanipulations/utest_averaging_analytical.py +++ b/test/sasmanipulations/utest_averaging_analytical.py @@ -8,8 +8,7 @@ import numpy as np from scipy import integrate -from test.sasmanipulations.helper import (MatrixToData2D, CircularTestingMatrix, make_dd_from_func, - expected_slabx_area, expected_slaby_area, integrate_1d_output, expected_boxsum_and_err,expected_boxavg_and_err, make_uniform_dd) + from sasdata.data_util.averaging import ( Boxavg, Boxsum, @@ -23,6 +22,17 @@ WedgeQ, ) from sasdata.dataloader import data_info +from test.sasmanipulations.helper import ( + CircularTestingMatrix, + MatrixToData2D, + expected_boxavg_and_err, + expected_boxsum_and_err, + expected_slabx_area, + expected_slaby_area, + integrate_1d_output, + make_dd_from_func, + make_uniform_dd, +) # TODO - also check the errors are being calculated correctly From 409768516684d6bf2c6011ad94286820d78c4479 Mon Sep 17 00:00:00 2001 From: Dirk Honecker Date: Sat, 15 Nov 2025 14:17:29 +0000 Subject: [PATCH 44/49] further reducing code complexity --- sasdata/data_util/averaging.py | 365 +------ sasdata/data_util/binning.py | 215 ++++ sasdata/data_util/interval.py | 34 + sasdata/data_util/roi.py | 122 +++ .../utest_averaging_analytical.py | 956 ------------------ test/sasmanipulations/utest_averaging_box.py | 186 ++++ .../utest_averaging_circle.py | 398 ++++++++ .../utest_averaging_directional.py | 184 ++++ test/sasmanipulations/utest_averaging_slab.py | 237 +++++ 9 files changed, 1379 insertions(+), 1318 deletions(-) create mode 100644 sasdata/data_util/binning.py create mode 100644 sasdata/data_util/interval.py create mode 100644 sasdata/data_util/roi.py delete mode 100644 test/sasmanipulations/utest_averaging_analytical.py create mode 100644 test/sasmanipulations/utest_averaging_box.py create mode 100644 test/sasmanipulations/utest_averaging_circle.py create mode 100644 test/sasmanipulations/utest_averaging_directional.py create mode 100644 test/sasmanipulations/utest_averaging_slab.py diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index a01582c6..9180bf5c 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -9,368 +9,9 @@ from sasdata.dataloader.data_info import Data1D, Data2D - -class IntervalType(Enum): - HALF_OPEN = auto() - CLOSED = auto() - - def weights_for_interval(self, array, l_bound, u_bound): - """ - Weight coordinate data by position relative to a specified interval. - - :param array: the array for which the weights are calculated - :param l_bound: value defining the lower limit of the region of interest - :param u_bound: value defining the upper limit of the region of interest - - If and when fractional binning is implemented (ask Lucas), this function - will be changed so that instead of outputting zeros and ones, it gives - fractional values instead. These will depend on how close the array value - is to being within the interval defined. - """ - - # Whether the endpoint should be included depends on circumstance. - # Half-open is used when binning the major axis (except for the final bin) - # and closed used for the minor axis and the final bin of the major axis. - if self.name.lower() == 'half_open': - in_range = np.logical_and(l_bound <= array, array < u_bound) - elif self.name.lower() == 'closed': - in_range = np.logical_and(l_bound <= array, array <= u_bound) - else: - msg = f"Unrecognised interval_type: {self.name}" - raise ValueError(msg) - - return np.asarray(in_range, dtype=int) - -class DirectionalAverage: - """ - Average along one coordinate axis of 2D data and return data for a 1D plot. - This can also be thought of as a projection onto the major axis: 2D -> 1D. - - This class operates on a decomposed Data2D object, and returns data needed - to construct a Data1D object. The class is instantiated with two arrays of - orthogonal coordinate data (depending on the coordinate system, these may - have undergone some pre-processing) and two corresponding two-element - tuples/lists defining the lower and upper limits on the Region of Interest - (ROI) for each coordinate axis. One of these axes is averaged along, and - the other is divided into bins and becomes the dependent variable of the - eventual 1D plot. These are called the minor and major axes respectively. - When a class instance is called, it is passed the intensity and error data - from the original Data2D object. These should not have undergone any - coordinate system dependent pre-processing. - - Note that the old version of manipulations.py had an option for logarithmic - binning which was only used by SectorQ. This functionality is never called - upon by SasView however, so I haven't implemented it here (yet). - """ - - def __init__(self, - major_axis: ArrayLike, - minor_axis: ArrayLike, - lims: tuple[tuple[float, float] | None, tuple[float, float] | None] | None = None, - nbins: int = 100): - """ - Set up direction of averaging, limits on the ROI, & the number of bins. - - :param major_axis: Coordinate data for axis onto which the 2D data is - projected. - :param minor_axis: Coordinate data for the axis perpendicular to the - major axis. - :param lims: Tuple (major_lims, minor_lims). Each element may be a - 2-tuple or None. - :param nbins: The number of bins the major axis is divided up into. - """ - - # Step 1: quick checks and parsing - self._validate_coordinate_arrays(major_axis, minor_axis) - major_lims, minor_lims = self._parse_lims(lims) - self.nbins = self._coerce_nbins(nbins) - - # Step 2: assign arrays and check sizes - self.major_axis, self.minor_axis = self._assign_axes_and_check_lengths(major_axis, minor_axis) - - # Step 3: set final limits and compute bin limits - self.major_lims, self.minor_lims = self._set_default_lims_and_bin_limits(major_lims, minor_lims) - - def _validate_coordinate_arrays(self, major_axis, minor_axis) -> None: - """Ensure both major and minor coordinate inputs are array-like.""" - if any(not hasattr(coordinate_data, "__array__") for - coordinate_data in (major_axis, minor_axis)): - msg = "Must provide major & minor coordinate arrays for binning." - raise ValueError(msg) - - def _parse_lims(self, lims): - """ - Validate the lims parameter and return (major_lims, minor_lims). - Accepts None or a 2-tuple (major_lims, minor_lims). Each of the two - elements may be None or a 2-tuple of floats. - """ - if lims is None: - return None, None - - if not (isinstance(lims, (list, tuple)) and len(lims) == 2): - msg = "Parameter 'lims' must be a 2-tuple (major_lims, minor_lims) or None." - raise ValueError(msg) - - major_lims, minor_lims = lims - return major_lims, minor_lims - - def _coerce_nbins(self, nbins): - """Coerce nbins to int, raising a TypeError with the original message on failure.""" - try: - return int(nbins) - except Exception: - msg = f"Parameter 'nbins' must be convertable to an integer via int(), got type {type(nbins)} (={nbins})" - raise TypeError(msg) - - def _assign_axes_and_check_lengths(self, major_axis, minor_axis): - """Assign axes to numpy arrays and check they have equal length.""" - major_arr = np.asarray(major_axis) - minor_arr = np.asarray(minor_axis) - if major_arr.size != minor_arr.size: - msg = "Major and minor axes must have same length" - raise ValueError(msg) - return major_arr, minor_arr - - def _set_default_lims_and_bin_limits(self, major_lims, minor_lims): - """ - Determine final major and minor limits (using data min/max if None) - and compute bin_limits based on major_lims and self.nbins. - Returns (major_lims_final, minor_lims_final). - """ - # Major limits - if major_lims is None: - major_lims_final = (self.major_axis.min(), self.major_axis.max()) - else: - major_lims_final = major_lims - - # Minor limits - if minor_lims is None: - minor_lims_final = (self.minor_axis.min(), self.minor_axis.max()) - else: - minor_lims_final = minor_lims - - # Store and compute bin limits (nbins + 1 points for boundaries) - self.bin_limits = np.linspace(major_lims_final[0], major_lims_final[1], self.nbins + 1) - - return major_lims_final, minor_lims_final - - @property - def bin_widths(self) -> np.ndarray: - """Return a numpy array of all bin widths, regardless of the point spacings.""" - return np.asarray([self.bin_width_n(i) for i in range(0, self.nbins)]) - - def bin_width_n(self, bin_number: int) -> float: - """Calculate the bin width for the nth bin. - :param bin_number: The starting array index of the bin between 0 and self.nbins - 1. - :return: The bin width, as a float. - """ - lower, upper = self.get_bin_interval(bin_number) - return upper - lower - - def get_bin_interval(self, bin_number: int) -> tuple[float, float]: - - """ - Return the lower and upper limits defining a bin, given its index. - - :param bin_number: The index of the bin (between 0 and self.nbins - 1) - :return: A tuple of the interval limits as (lower, upper). - """ - # Ensure bin_number is an integer and not a float or a string representation - bin_number = int(bin_number) - return self.bin_limits[bin_number], self.bin_limits[bin_number+1] - - def get_bin_index(self, value): - """ - Return the index of the bin to which the supplied value belongs. - - :param value: A coordinate value from somewhere along the major axis. - """ - numerator = value - self.major_lims[0] - denominator = self.major_lims[1] - self.major_lims[0] - bin_index = int(np.floor(self.nbins * numerator / denominator)) - - # Bins are indexed from 0 to nbins-1, so this check protects against - # out-of-range indices when value == self.major_lims[1] - if bin_index == self.nbins: - bin_index -= 1 - - return bin_index - - def compute_weights(self): - """ - Return weights array for the contribution of each datapoint to each bin - - Each row of the weights array corresponds to the bin with the same - index. - """ - major_weights = np.zeros((self.nbins, self.major_axis.size)) - closed = IntervalType.CLOSED - for m in range(self.nbins): - # Include the value at the end of the binning range, but in - # general use half-open intervals so each value belongs in only - # one bin. - if m == self.nbins - 1: - interval = closed - else: - interval = IntervalType.HALF_OPEN - bin_start, bin_end = self.get_bin_interval(bin_number=m) - major_weights[m] = interval.weights_for_interval(array=self.major_axis, - l_bound=bin_start, - u_bound=bin_end) - minor_weights = closed.weights_for_interval(array=self.minor_axis, - l_bound=self.minor_lims[0], - u_bound=self.minor_lims[1]) - return major_weights * minor_weights - - def __call__(self, data, err_data): - """ - Compute the directional average of the supplied intensity & error data. - - :param data: intensity data from the origninal Data2D object. - :param err_data: the corresponding errors for the intensity data. - """ - weights = self.compute_weights() - - x_axis_values = np.sum(weights * self.major_axis, axis=1) - intensity = np.sum(weights * data, axis=1) - errs_squared = np.sum((weights * err_data)**2, axis=1) - - bin_counts = np.sum(weights, axis=1) - # Prepare results, only compute division where bin_counts > 0 - if not np.any(bin_counts > 0): - raise ValueError("Average Error: No bins inside ROI to average...") - - errors = np.sqrt(errs_squared) - x_axis_values /= bin_counts - intensity /= bin_counts - errors /= bin_counts - - finite = np.isfinite(intensity) - if not finite.any(): - msg = "Average Error: No points inside ROI to average..." - raise ValueError(msg) - - return x_axis_values[finite], intensity[finite], errors[finite] - -class GenericROI: - """ - Base class used to set up the data from a Data2D object for processing. - This class performs any coordinate system independent setup and validation. - """ - - def __init__(self): - """ - Assign the variables used to label the properties of the Data2D object. - - In classes inheriting from GenericROI, the variables used to define the - boundaries of the Region Of Interest are also set up during __init__. - """ - - self.data = None - self.err_data = None - self.q_data = None - self.qx_data = None - self.qy_data = None - - def validate_and_assign_data(self, data2d: Data2D = None) -> None: - """ - Check that the data supplied is valid and assign data to variables. - This method must be executed before any further data processing happens - - :param data2d: A Data2D object which is the target of a child class' - data manipulations. - """ - # Check that the supplied data2d is valid and usable. - if not isinstance(data2d, Data2D): - msg = "Data supplied must be of type Data2D." - raise TypeError(msg) - if len(data2d.detector) > 1: - msg = f"Invalid number of detectors: {len(data2d.detector)}" - raise ValueError(msg) - - # Only use data which is finite and not masked off - valid_data = np.isfinite(data2d.data) & data2d.mask - - # Assign properties of the Data2D object to variables for reference - # during data processing. - self.data = data2d.data[valid_data] - self.err_data = data2d.err_data[valid_data] - self.q_data = data2d.q_data[valid_data] - self.qx_data = data2d.qx_data[valid_data] - self.qy_data = data2d.qy_data[valid_data] - - # No points should have zero error, if they do then assume the error is - # the square root of the data. This code was added to replicate - # previous functionality. It's a bit dodgy, so feel free to remove. - self.err_data[self.err_data == 0] = \ - np.sqrt(np.abs(self.data[self.err_data == 0])) - -class CartesianROI(GenericROI): - """ - Base class for data manipulators with a Cartesian (rectangular) ROI. - """ - - def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[float, float] = (0.0, 0.0)) -> None: - """ - Assign the variables used to label the properties of the Data2D object. - Also establish the upper and lower bounds defining the ROI. - - The units of these parameters are A^-1 - :param qx_range: Bounds of the ROI along the Q_x direction. - :param qy_range: Bounds of the ROI along the Q_y direction. - """ - qx_min, qx_max = qx_range - qy_min, qy_max = qy_range - super().__init__() - self.qx_min = qx_min - self.qx_max = qx_max - self.qy_min = qy_min - self.qy_max = qy_max - -class PolarROI(GenericROI): - """ - Base class for data manipulators with a polar ROI. - """ - - def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi)) -> None: - """ - Assign the variables used to label the properties of the Data2D object. - Also establish the upper and lower bounds defining the ROI. - - The units are A^-1 for radial parameters, and radians for anglar ones. - :param r_range: Tuple (r_min, r_max) defining limits for |Q| values to use during averaging. - :param phi_range: Tuple (phi_min, phi_max) defining limits for φ in radians (in the ROI). - - Note that Phi is measured anti-clockwise from the positive x-axis. - """ - r_min, r_max = r_range - phi_min, phi_max = phi_range - super().__init__() - - self.phi_data = None - - if r_min >= r_max: - msg = "Minimum radius cannot be greater than maximum radius." - raise ValueError(msg) - # Units A^-1 for radii, radians for angles - self.r_min = r_min - self.r_max = r_max - self.phi_min = phi_min - self.phi_max = phi_max - - def validate_and_assign_data(self, data2d: Data2D = None) -> None: - """ - Check that the data supplied valid and assign data variables. - This method must be executed before any further data processing happens - - :param data2d: A Data2D object which is the target of a child class' - data manipulations. - """ - - # Most validation and pre-processing is taken care of by GenericROI. - super().validate_and_assign_data(data2d) - # Phi data can be calculated from the Cartesian Q coordinates. - self.phi_data = np.arctan2(self.qy_data, self.qx_data) +from sasdata.data_util.interval import IntervalType +from sasdata.data_util.binning import DirectionalAverage +from sasdata.data_util.roi import GenericROI, CartesianROI, PolarROI class Boxsum(CartesianROI): """ diff --git a/sasdata/data_util/binning.py b/sasdata/data_util/binning.py new file mode 100644 index 00000000..483deb5b --- /dev/null +++ b/sasdata/data_util/binning.py @@ -0,0 +1,215 @@ +import numpy as np +from numpy.typing import ArrayLike + +from sasdata.data_util.interval import IntervalType + +class DirectionalAverage: + """ + Average along one coordinate axis of 2D data and return data for a 1D plot. + This can also be thought of as a projection onto the major axis: 2D -> 1D. + + This class operates on a decomposed Data2D object, and returns data needed + to construct a Data1D object. The class is instantiated with two arrays of + orthogonal coordinate data (depending on the coordinate system, these may + have undergone some pre-processing) and two corresponding two-element + tuples/lists defining the lower and upper limits on the Region of Interest + (ROI) for each coordinate axis. One of these axes is averaged along, and + the other is divided into bins and becomes the dependent variable of the + eventual 1D plot. These are called the minor and major axes respectively. + When a class instance is called, it is passed the intensity and error data + from the original Data2D object. These should not have undergone any + coordinate system dependent pre-processing. + + Note that the old version of manipulations.py had an option for logarithmic + binning which was only used by SectorQ. This functionality is never called + upon by SasView however, so I haven't implemented it here (yet). + """ + + def __init__(self, + major_axis: ArrayLike, + minor_axis: ArrayLike, + lims: tuple[tuple[float, float] | None, tuple[float, float] | None] | None = None, + nbins: int = 100): + """ + Set up direction of averaging, limits on the ROI, & the number of bins. + + :param major_axis: Coordinate data for axis onto which the 2D data is + projected. + :param minor_axis: Coordinate data for the axis perpendicular to the + major axis. + :param lims: Tuple (major_lims, minor_lims). Each element may be a + 2-tuple or None. + :param nbins: The number of bins the major axis is divided up into. + """ + + # Step 1: quick checks and parsing + self._validate_coordinate_arrays(major_axis, minor_axis) + major_lims, minor_lims = self._parse_lims(lims) + self.nbins = self._coerce_nbins(nbins) + + # Step 2: assign arrays and check sizes + self.major_axis, self.minor_axis = self._assign_axes_and_check_lengths(major_axis, minor_axis) + + # Step 3: set final limits and compute bin limits + self.major_lims, self.minor_lims = self._set_default_lims_and_bin_limits(major_lims, minor_lims) + + def _validate_coordinate_arrays(self, major_axis, minor_axis) -> None: + """Ensure both major and minor coordinate inputs are array-like.""" + if any(not hasattr(coordinate_data, "__array__") for + coordinate_data in (major_axis, minor_axis)): + msg = "Must provide major & minor coordinate arrays for binning." + raise ValueError(msg) + + def _parse_lims(self, lims): + """ + Validate the lims parameter and return (major_lims, minor_lims). + Accepts None or a 2-tuple (major_lims, minor_lims). Each of the two + elements may be None or a 2-tuple of floats. + """ + if lims is None: + return None, None + + if not (isinstance(lims, (list, tuple)) and len(lims) == 2): + msg = "Parameter 'lims' must be a 2-tuple (major_lims, minor_lims) or None." + raise ValueError(msg) + + major_lims, minor_lims = lims + return major_lims, minor_lims + + def _coerce_nbins(self, nbins): + """Coerce nbins to int, raising a TypeError with the original message on failure.""" + try: + return int(nbins) + except Exception: + msg = f"Parameter 'nbins' must be convertable to an integer via int(), got type {type(nbins)} (={nbins})" + raise TypeError(msg) + + def _assign_axes_and_check_lengths(self, major_axis, minor_axis): + """Assign axes to numpy arrays and check they have equal length.""" + major_arr = np.asarray(major_axis) + minor_arr = np.asarray(minor_axis) + if major_arr.size != minor_arr.size: + msg = "Major and minor axes must have same length" + raise ValueError(msg) + return major_arr, minor_arr + + def _set_default_lims_and_bin_limits(self, major_lims, minor_lims): + """ + Determine final major and minor limits (using data min/max if None) + and compute bin_limits based on major_lims and self.nbins. + Returns (major_lims_final, minor_lims_final). + """ + # Major limits + if major_lims is None: + major_lims_final = (self.major_axis.min(), self.major_axis.max()) + else: + major_lims_final = major_lims + + # Minor limits + if minor_lims is None: + minor_lims_final = (self.minor_axis.min(), self.minor_axis.max()) + else: + minor_lims_final = minor_lims + + # Store and compute bin limits (nbins + 1 points for boundaries) + self.bin_limits = np.linspace(major_lims_final[0], major_lims_final[1], self.nbins + 1) + + return major_lims_final, minor_lims_final + + @property + def bin_widths(self) -> np.ndarray: + """Return a numpy array of all bin widths, regardless of the point spacings.""" + return np.asarray([self.bin_width_n(i) for i in range(0, self.nbins)]) + + def bin_width_n(self, bin_number: int) -> float: + """Calculate the bin width for the nth bin. + :param bin_number: The starting array index of the bin between 0 and self.nbins - 1. + :return: The bin width, as a float. + """ + lower, upper = self.get_bin_interval(bin_number) + return upper - lower + + def get_bin_interval(self, bin_number: int) -> tuple[float, float]: + + """ + Return the lower and upper limits defining a bin, given its index. + + :param bin_number: The index of the bin (between 0 and self.nbins - 1) + :return: A tuple of the interval limits as (lower, upper). + """ + # Ensure bin_number is an integer and not a float or a string representation + bin_number = int(bin_number) + return self.bin_limits[bin_number], self.bin_limits[bin_number+1] + + def get_bin_index(self, value): + """ + Return the index of the bin to which the supplied value belongs. + + :param value: A coordinate value from somewhere along the major axis. + """ + numerator = value - self.major_lims[0] + denominator = self.major_lims[1] - self.major_lims[0] + bin_index = int(np.floor(self.nbins * numerator / denominator)) + + # Bins are indexed from 0 to nbins-1, so this check protects against + # out-of-range indices when value == self.major_lims[1] + if bin_index == self.nbins: + bin_index -= 1 + + return bin_index + + def compute_weights(self): + """ + Return weights array for the contribution of each datapoint to each bin + + Each row of the weights array corresponds to the bin with the same + index. + """ + major_weights = np.zeros((self.nbins, self.major_axis.size)) + closed = IntervalType.CLOSED + for m in range(self.nbins): + # Include the value at the end of the binning range, but in + # general use half-open intervals so each value belongs in only + # one bin. + if m == self.nbins - 1: + interval = closed + else: + interval = IntervalType.HALF_OPEN + bin_start, bin_end = self.get_bin_interval(bin_number=m) + major_weights[m] = interval.weights_for_interval(array=self.major_axis, + l_bound=bin_start, + u_bound=bin_end) + minor_weights = closed.weights_for_interval(array=self.minor_axis, + l_bound=self.minor_lims[0], + u_bound=self.minor_lims[1]) + return major_weights * minor_weights + + def __call__(self, data, err_data): + """ + Compute the directional average of the supplied intensity & error data. + + :param data: intensity data from the origninal Data2D object. + :param err_data: the corresponding errors for the intensity data. + """ + weights = self.compute_weights() + + x_axis_values = np.sum(weights * self.major_axis, axis=1) + intensity = np.sum(weights * data, axis=1) + errs_squared = np.sum((weights * err_data)**2, axis=1) + + bin_counts = np.sum(weights, axis=1) + # Prepare results, only compute division where bin_counts > 0 + if not np.any(bin_counts > 0): + raise ValueError("Average Error: No bins inside ROI to average...") + + errors = np.sqrt(errs_squared) + x_axis_values /= bin_counts + intensity /= bin_counts + errors /= bin_counts + + finite = np.isfinite(intensity) + if not finite.any(): + msg = "Average Error: No points inside ROI to average..." + raise ValueError(msg) + + return x_axis_values[finite], intensity[finite], errors[finite] \ No newline at end of file diff --git a/sasdata/data_util/interval.py b/sasdata/data_util/interval.py new file mode 100644 index 00000000..bdadd9cc --- /dev/null +++ b/sasdata/data_util/interval.py @@ -0,0 +1,34 @@ +from enum import Enum, auto + +import numpy as np + +class IntervalType(Enum): + HALF_OPEN = auto() + CLOSED = auto() + + def weights_for_interval(self, array, l_bound, u_bound): + """ + Weight coordinate data by position relative to a specified interval. + + :param array: the array for which the weights are calculated + :param l_bound: value defining the lower limit of the region of interest + :param u_bound: value defining the upper limit of the region of interest + + If and when fractional binning is implemented (ask Lucas), this function + will be changed so that instead of outputting zeros and ones, it gives + fractional values instead. These will depend on how close the array value + is to being within the interval defined. + """ + + # Whether the endpoint should be included depends on circumstance. + # Half-open is used when binning the major axis (except for the final bin) + # and closed used for the minor axis and the final bin of the major axis. + if self.name.lower() == 'half_open': + in_range = np.logical_and(l_bound <= array, array < u_bound) + elif self.name.lower() == 'closed': + in_range = np.logical_and(l_bound <= array, array <= u_bound) + else: + msg = f"Unrecognised interval_type: {self.name}" + raise ValueError(msg) + + return np.asarray(in_range, dtype=int) \ No newline at end of file diff --git a/sasdata/data_util/roi.py b/sasdata/data_util/roi.py new file mode 100644 index 00000000..9d8bcded --- /dev/null +++ b/sasdata/data_util/roi.py @@ -0,0 +1,122 @@ +import numpy as np +from sasdata.dataloader.data_info import Data2D + +class GenericROI: + """ + Base class used to set up the data from a Data2D object for processing. + This class performs any coordinate system independent setup and validation. + """ + + def __init__(self): + """ + Assign the variables used to label the properties of the Data2D object. + + In classes inheriting from GenericROI, the variables used to define the + boundaries of the Region Of Interest are also set up during __init__. + """ + + self.data = None + self.err_data = None + self.q_data = None + self.qx_data = None + self.qy_data = None + + def validate_and_assign_data(self, data2d: Data2D = None) -> None: + """ + Check that the data supplied is valid and assign data to variables. + This method must be executed before any further data processing happens + + :param data2d: A Data2D object which is the target of a child class' + data manipulations. + """ + # Check that the supplied data2d is valid and usable. + if not isinstance(data2d, Data2D): + msg = "Data supplied must be of type Data2D." + raise TypeError(msg) + if len(data2d.detector) > 1: + msg = f"Invalid number of detectors: {len(data2d.detector)}" + raise ValueError(msg) + + # Only use data which is finite and not masked off + valid_data = np.isfinite(data2d.data) & data2d.mask + + # Assign properties of the Data2D object to variables for reference + # during data processing. + self.data = data2d.data[valid_data] + self.err_data = data2d.err_data[valid_data] + self.q_data = data2d.q_data[valid_data] + self.qx_data = data2d.qx_data[valid_data] + self.qy_data = data2d.qy_data[valid_data] + + # No points should have zero error, if they do then assume the error is + # the square root of the data. This code was added to replicate + # previous functionality. It's a bit dodgy, so feel free to remove. + self.err_data[self.err_data == 0] = \ + np.sqrt(np.abs(self.data[self.err_data == 0])) + +class CartesianROI(GenericROI): + """ + Base class for data manipulators with a Cartesian (rectangular) ROI. + """ + + def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[float, float] = (0.0, 0.0)) -> None: + """ + Assign the variables used to label the properties of the Data2D object. + Also establish the upper and lower bounds defining the ROI. + + The units of these parameters are A^-1 + :param qx_range: Bounds of the ROI along the Q_x direction. + :param qy_range: Bounds of the ROI along the Q_y direction. + """ + qx_min, qx_max = qx_range + qy_min, qy_max = qy_range + super().__init__() + self.qx_min = qx_min + self.qx_max = qx_max + self.qy_min = qy_min + self.qy_max = qy_max + +class PolarROI(GenericROI): + """ + Base class for data manipulators with a polar ROI. + """ + + def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi)) -> None: + """ + Assign the variables used to label the properties of the Data2D object. + Also establish the upper and lower bounds defining the ROI. + + The units are A^-1 for radial parameters, and radians for anglar ones. + :param r_range: Tuple (r_min, r_max) defining limits for |Q| values to use during averaging. + :param phi_range: Tuple (phi_min, phi_max) defining limits for φ in radians (in the ROI). + + Note that Phi is measured anti-clockwise from the positive x-axis. + """ + r_min, r_max = r_range + phi_min, phi_max = phi_range + super().__init__() + + self.phi_data = None + + if r_min >= r_max: + msg = "Minimum radius cannot be greater than maximum radius." + raise ValueError(msg) + # Units A^-1 for radii, radians for angles + self.r_min = r_min + self.r_max = r_max + self.phi_min = phi_min + self.phi_max = phi_max + + def validate_and_assign_data(self, data2d: Data2D = None) -> None: + """ + Check that the data supplied valid and assign data variables. + This method must be executed before any further data processing happens + + :param data2d: A Data2D object which is the target of a child class' + data manipulations. + """ + + # Most validation and pre-processing is taken care of by GenericROI. + super().validate_and_assign_data(data2d) + # Phi data can be calculated from the Cartesian Q coordinates. + self.phi_data = np.arctan2(self.qy_data, self.qx_data) \ No newline at end of file diff --git a/test/sasmanipulations/utest_averaging_analytical.py b/test/sasmanipulations/utest_averaging_analytical.py deleted file mode 100644 index b7a40965..00000000 --- a/test/sasmanipulations/utest_averaging_analytical.py +++ /dev/null @@ -1,956 +0,0 @@ -""" -This file contains unit tests for the various averagers found in -sasdata/data_util/manipulations.py - These tests are based on analytical -formulae rather than imported data files. -""" - -import unittest - -import numpy as np -from scipy import integrate - -from sasdata.data_util.averaging import ( - Boxavg, - Boxsum, - CircularAverage, - DirectionalAverage, - Ring, - SectorQ, - SlabX, - SlabY, - WedgePhi, - WedgeQ, -) -from sasdata.dataloader import data_info -from test.sasmanipulations.helper import ( - CircularTestingMatrix, - MatrixToData2D, - expected_boxavg_and_err, - expected_boxsum_and_err, - expected_slabx_area, - expected_slaby_area, - integrate_1d_output, - make_dd_from_func, - make_uniform_dd, -) - -# TODO - also check the errors are being calculated correctly - - -class SlabXTests(unittest.TestCase): - """ - This class contains all the unit tests for the SlabX class from - manipulations.py - """ - - def test_slabx_init(self): - """ - Test that SlabX's __init__ method does what it's supposed to. - """ - qx_min = 1 - qx_max = 2 - qy_min = 3 - qy_max = 4 - nbins = 100 - fold = True - - slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) - - self.assertEqual(slab_object.qx_min, qx_min) - self.assertEqual(slab_object.qx_max, qx_max) - self.assertEqual(slab_object.qy_min, qy_min) - self.assertEqual(slab_object.qy_max, qy_max) - self.assertEqual(slab_object.nbins, nbins) - self.assertEqual(slab_object.fold, fold) - - def test_slabx_multiple_detectors(self): - """ - Test that SlabX raises an error when there are multiple detectors - """ - averager_data = MatrixToData2D(np.ones([100, 100])) - detector1 = data_info.Detector() - detector2 = data_info.Detector() - averager_data.data.detector.append(detector1) - averager_data.data.detector.append(detector2) - - slab_object = SlabX() - self.assertRaises(ValueError, slab_object, averager_data.data) - - def test_slabx_no_points_to_average(self): - """ - Test SlabX raises ValueError when the ROI contains no data - """ - test_data = np.ones([100, 100]) - averager_data = MatrixToData2D(data2d=test_data) - - # Region of interest well outside region with data - qx_min = 2 * averager_data.qmax - qx_max = 3 * averager_data.qmax - qy_min = 2 * averager_data.qmax - qy_max = 3 * averager_data.qmax - - slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) - self.assertRaises(ValueError, slab_object, averager_data.data) - - def test_slabx_averaging_without_fold(self): - """ - Test that SlabX can average correctly when x is the major axis - """ - def func(x, y): - return x**2 * y - averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) - - - # Set up region of interest to average over - the limits are arbitrary. - qx_min = -0.5 * averager_data.qmax # = -0.5 - qx_max = averager_data.qmax # = 1 - qy_min = -0.5 * averager_data.qmax # = -0.5 - qy_max = averager_data.qmax # = 1 - nbins = int((qx_max - qx_min) / 2 * matrix_size) - # Explicitly not using fold in this test - fold = False - - slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) - data1d = slab_object(averager_data.data) - - expected_area = expected_slabx_area(qx_min, qx_max, qy_min, qy_max) - actual_area = integrate_1d_output(data1d, method="simpson") - - self.assertAlmostEqual(actual_area, expected_area, 2) - - def test_slabx_averaging_with_fold(self): - """ - Test that SlabX can average correctly when x is the major axis - """ - def func(x, y): - return x**2 * y - averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) - - # Set up region of interest to average over - the limits are arbitrary. - qx_min = -0.5 * averager_data.qmax # = -0.5 - qx_max = averager_data.qmax # = 1 - qy_min = -0.5 * averager_data.qmax # = -0.5 - qy_max = averager_data.qmax # = 1 - nbins = int((qx_max - qx_min) / 2 * matrix_size) - # Explicitly using fold in this test - fold = True - - slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) - data1d = slab_object(averager_data.data) - - # Negative values of x are not graphed when fold = True - qx_min_fold = 0 - expected_area = expected_slabx_area(qx_min_fold, qx_max, qy_min, qy_max) - actual_area = integrate_1d_output(data1d, method="simpson") - - self.assertAlmostEqual(actual_area, expected_area, 2) - -class SlabYTests(unittest.TestCase): - """ - This class contains all the unit tests for the SlabY class from - manipulations.py - """ - - def test_slaby_init(self): - """ - Test that SlabY's __init__ method does what it's supposed to. - """ - qx_min = 1 - qx_max = 2 - qy_min = 3 - qy_max = 4 - nbins = 100 - fold = True - - slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) - - self.assertEqual(slab_object.qx_min, qx_min) - self.assertEqual(slab_object.qx_max, qx_max) - self.assertEqual(slab_object.qy_min, qy_min) - self.assertEqual(slab_object.qy_max, qy_max) - self.assertEqual(slab_object.nbins, nbins) - self.assertEqual(slab_object.fold, fold) - - def test_slaby_multiple_detectors(self): - """ - Test that SlabY raises an error when there are multiple detectors - """ - averager_data = MatrixToData2D(np.ones([100, 100])) - detector1 = data_info.Detector() - detector2 = data_info.Detector() - averager_data.data.detector.append(detector1) - averager_data.data.detector.append(detector2) - slab_object = SlabY() - self.assertRaises(ValueError, slab_object, averager_data.data) - - def test_slaby_no_points_to_average(self): - """ - Test SlabY raises ValueError when the ROI contains no data - """ - test_data = np.ones([100, 100]) - averager_data = MatrixToData2D(data2d=test_data) - - # Region of interest well outside region with data - qx_min = 2 * averager_data.qmax - qx_max = 3 * averager_data.qmax - qy_min = 2 * averager_data.qmax - qy_max = 3 * averager_data.qmax - - slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) - self.assertRaises(ValueError, slab_object, averager_data.data) - - def test_slaby_averaging_without_fold(self): - """ - Test that SlabY can average correctly when y is the major axis - """ - def func(x, y): - return x * y**2 - averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) - - - # Set up region of interest to average over - the limits are arbitrary. - qx_min = -0.5 * averager_data.qmax # = -0.5 - qx_max = averager_data.qmax # = 1 - qy_min = -0.5 * averager_data.qmax # = -0.5 - qy_max = averager_data.qmax # = 1 - nbins = int((qx_max - qx_min) / 2 * matrix_size) - # Explicitly not using fold in this test - fold = False - - slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) - data1d = slab_object(averager_data.data) - - expected_area = expected_slaby_area(qx_min, qx_max, qy_min, qy_max) - actual_area = integrate_1d_output(data1d, method="simpson") - - self.assertAlmostEqual(actual_area, expected_area, 2) - - def test_slab_averaging_y_with_fold(self): - """ - Test that SlabY can average correctly when y is the major axis - """ - def func(x, y): - return x * y**2 - - averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) - - # Set up region of interest to average over - the limits are arbitrary. - qx_min = -0.5 * averager_data.qmax # = -0.5 - qx_max = averager_data.qmax # = 1 - qy_min = -0.5 * averager_data.qmax # = -0.5 - qy_max = averager_data.qmax # = 1 - nbins = int((qx_max - qx_min) / 2 * matrix_size) - # Explicitly using fold in this test - fold = True - - slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) - data1d = slab_object(averager_data.data) - - # Negative values of y are not graphed when fold = True, so don't - # include them in the area calculation. - qy_min_fold = 0 - expected_area = expected_slaby_area(qx_min, qx_max, qy_min_fold, qy_max) - actual_area = integrate_1d_output(data1d, method="simpson") - - self.assertAlmostEqual(actual_area, expected_area, 2) - -class BoxsumTests(unittest.TestCase): - """ - This class contains all the unit tests for the Boxsum class from - manipulations.py - """ - - def test_boxsum_init(self): - """ - Test that Boxsum's __init__ method does what it's supposed to. - """ - qx_min = 1 - qx_max = 2 - qy_min = 3 - qy_max = 4 - - box_object = Boxsum(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) - - self.assertEqual(box_object.qx_min, qx_min) - self.assertEqual(box_object.qx_max, qx_max) - self.assertEqual(box_object.qy_min, qy_min) - self.assertEqual(box_object.qy_max, qy_max) - - def test_boxsum_multiple_detectors(self): - """ - Test Boxsum raises an error when there are multiple detectors. - """ - dd = make_uniform_dd((100, 100), value=1.0) - detector1 = data_info.Detector() - detector2 = data_info.Detector() - dd.data.detector.append(detector1) - dd.data.detector.append(detector2) - - box_object = Boxsum() - self.assertRaises(ValueError, box_object, dd.data) - - def test_boxsum_total(self): - """ - Test that Boxsum can find the sum of all of a data set - """ - # Creating a 100x100 matrix for a distribution which is flat in y - # and linear in x. - test_data = np.tile(np.arange(100), (100, 1)) - dd = MatrixToData2D(data2d=test_data) - - box_object = Boxsum(qx_range=(-1 * dd.qmax, dd.qmax), qy_range=(-1 * dd.qmax, dd.qmax)) - result, error, npoints = box_object(dd.data) - correct_sum, correct_error = expected_boxsum_and_err(test_data) - - self.assertAlmostEqual(result, correct_sum, 6) - self.assertAlmostEqual(error, correct_error, 6) - - def test_boxsum_subset_total(self): - """ - Test that Boxsum can find the sum of a portion of a data set - """ - # Creating a 100x100 matrix for a distribution which is flat in y - # and linear in x. - test_data = np.tile(np.arange(100), (100, 1)) - dd = MatrixToData2D(data2d=test_data) - - # region corresponds to central 50x50 in original test - box_object = Boxsum(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) - result, error, npoints = box_object(dd.data) - inner_portion = test_data[25:75, 25:75] - correct_sum, correct_error = expected_boxsum_and_err(inner_portion) - - self.assertAlmostEqual(result, correct_sum, 6) - self.assertAlmostEqual(error, correct_error, 6) - - def test_boxsum_zero_sum(self): - """ - Test that Boxsum returns 0 when there are no points within the ROI - """ - test_data = np.ones([100, 100]) - test_data[25:75, 25:75] = 0 - dd = MatrixToData2D(data2d=test_data) - - box_object = Boxsum(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) - result, error, npoints = box_object(dd.data) - - self.assertAlmostEqual(result, 0, 6) - self.assertAlmostEqual(error, 0, 6) - - -class BoxavgTests(unittest.TestCase): - """ - This class contains all the unit tests for the Boxavg class from - manipulations.py - """ - - def test_boxavg_init(self): - """ - Test that Boxavg's __init__ method does what it's supposed to. - """ - qx_min = 1 - qx_max = 2 - qy_min = 3 - qy_max = 4 - - box_object = Boxavg(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) - - self.assertEqual(box_object.qx_min, qx_min) - self.assertEqual(box_object.qx_max, qx_max) - self.assertEqual(box_object.qy_min, qy_min) - self.assertEqual(box_object.qy_max, qy_max) - - def test_boxavg_multiple_detectors(self): - """ - Test Boxavg raises an error when there are multiple detectors. - """ - dd = make_uniform_dd((100, 100), value=1.0) - detector1 = data_info.Detector() - detector2 = data_info.Detector() - dd.data.detector.append(detector1) - dd.data.detector.append(detector2) - - box_object = Boxavg() - self.assertRaises(ValueError, box_object, dd.data) - - def test_boxavg_total(self): - """ - Test that Boxavg can find the average of all of a data set - """ - # Creating a 100x100 matrix for a distribution which is flat in y - # and linear in x. - test_data = np.tile(np.arange(100), (100, 1)) - dd = MatrixToData2D(data2d=test_data) - - box_object = Boxavg(qx_range=(-1 * dd.qmax, dd.qmax), qy_range=(-1 * dd.qmax, dd.qmax)) - result, error = box_object(dd.data) - correct_avg, correct_error = expected_boxavg_and_err(test_data) - - self.assertAlmostEqual(result, correct_avg, 6) - self.assertAlmostEqual(error, correct_error, 6) - - def test_boxavg_subset_total(self): - """ - Test that Boxavg can find the average of a portion of a data set - """ - # Creating a 100x100 matrix for a distribution which is flat in y - # and linear in x. - test_data = np.tile(np.arange(100), (100, 1)) - dd = MatrixToData2D(data2d=test_data) - - box_object = Boxavg(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) - result, error = box_object(dd.data) - inner_portion = test_data[25:75, 25:75] - correct_avg, correct_error = expected_boxavg_and_err(inner_portion) - - self.assertAlmostEqual(result, correct_avg, 6) - self.assertAlmostEqual(error, correct_error, 6) - - def test_boxavg_zero_average(self): - """ - Test that Boxavg returns 0 when there are no points within the ROI - """ - test_data = np.ones([100, 100]) - # Make a hole in the middle with zeros - test_data[25:75, 25:75] = np.zeros([50, 50]) - dd = MatrixToData2D(data2d=test_data) - - box_object = Boxavg(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) - result, error = box_object(dd.data) - - self.assertAlmostEqual(result, 0, 6) - self.assertAlmostEqual(error, 0, 6) - - -class CircularAverageTests(unittest.TestCase): - """ - This class contains all the tests for the CircularAverage class - from manipulations.py - """ - - def test_circularaverage_init(self): - """ - Test that CircularAverage's __init__ method does what it's supposed to. - """ - r_min = 1 - r_max = 2 - nbins = 100 - - circ_object = CircularAverage(r_range=(r_min, r_max), nbins=nbins) - - self.assertEqual(circ_object.r_min, r_min) - self.assertEqual(circ_object.r_max, r_max) - self.assertEqual(circ_object.nbins, nbins) - - def test_circularaverage_dq_retrieval(self): - """ - Test that CircularAverage is able to calclate dq_data correctly when - the data provided has dqx_data and dqy_data. - """ - - # I'm saving the implementation of this bit for later - pass - - def test_circularaverage_multiple_detectors(self): - """ - Test CircularAverage raises an error when there are multiple detectors - """ - - # This test can't be implemented yet, because CircularAverage does not - # check the number of detectors. - # TODO - establish whether CircularAverage should be making this check. - pass - - def test_circularaverage_check_q_data(self): - """ - Check CircularAverage ensures the data supplied has `q_data` populated - """ - # test_data = np.ones([100, 100]) - # averager_data = DataMatrixToData2D(test_data) - # # Overwrite q_data so it's empty - # averager_data.data.q_data = np.array([]) - # circ_object = CircularAverage() - # self.assertRaises(RuntimeError, circ_object, averager_data.data) - - # This doesn't work. I'll come back to this later too - pass - - def test_circularaverage_check_valid_radii(self): - """ - Test that CircularAverage raises ValueError when r_min > r_max - """ - self.assertRaises(ValueError, CircularAverage, r_range=(0.1, 0.05)) - - def test_circularaverage_no_points_to_average(self): - """ - Test CircularAverage raises ValueError when the ROI contains no data - """ - test_data = np.ones([100, 100]) - averager_data = MatrixToData2D(test_data) - - # Region of interest well outside region with data - circ_object = CircularAverage(r_range=(2 * averager_data.qmax,3 * averager_data.qmax)) - self.assertRaises(ValueError, circ_object, averager_data.data) - - def test_circularaverage_averages_circularly(self): - """ - Test that CircularAverage can calculate a circular average correctly. - """ - test_data = CircularTestingMatrix(frequency=2, matrix_size=201, - major_axis='Q') - averager_data = MatrixToData2D(test_data.matrix) - - # Test the ability to average over a subsection of the data - r_min = averager_data.qmax * 0.25 - r_max = averager_data.qmax * 0.75 - - nbins = test_data.matrix_size - circ_object = CircularAverage(r_range=(r_min, r_max), nbins=nbins) - data1d = circ_object(averager_data.data) - - expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max) - actual_area = integrate.trapezoid(data1d.y, data1d.x) - - # This used to be able to pass with a precision of 3 d.p. with the old - # manipulations.py - I'm not sure why it doesn't anymore. - # This is still a good level of precision compared to the others though - self.assertAlmostEqual(actual_area, expected_area, 2) - -class RingTests(unittest.TestCase): - """ - This class contains the tests for the Ring class from manipulations.py - A.K.A AnnulusSlicer on the sasview side - """ - - def test_ring_init(self): - """ - Test that Ring's __init__ method does what it's supposed to. - """ - r_min = 1 - r_max = 2 - nbins = 100 - - # Note that Ring also has params center_x and center_y, but these are - # not used by the slicers and there is a 'todo' in manipulations.py to - # remove them. For this reason, I have not tested their initialisation. - ring_object = Ring(r_range=(r_min, r_max), nbins=nbins) - - self.assertEqual(ring_object.r_min, r_min) - self.assertEqual(ring_object.r_max, r_max) - self.assertEqual(ring_object.nbins, nbins) - - def test_ring_non_plottable_data(self): - """ - Test that RuntimeError is raised if the data supplied isn't plottable - """ - # with patch("sasdata.data_util.manipulations.Ring.data2D.__class__.__name__") as p: - # p.return_value = "bad_name" - # ring_object = Ring() - # self.assertRaises(RuntimeError, ring_object.__call__) - - # I can't seem to get patch working, in this test or in others. - pass - - def test_ring_no_points_to_average(self): - """ - Test Ring raises ValueError when the ROI contains no data - """ - test_data = np.ones([100, 100]) - averager_data = MatrixToData2D(test_data) - - # Region of interest well outside region with data - ring_object = Ring(r_range=(2 * averager_data.qmax, 3 * averager_data.qmax)) - self.assertRaises(ValueError, ring_object, averager_data.data) - - def test_ring_averages_azimuthally(self): - """ - Test that Ring can calculate an azimuthal average correctly. - """ - test_data = CircularTestingMatrix(frequency=1, matrix_size=201, - major_axis='Phi') - averager_data = MatrixToData2D(test_data.matrix) - - # Test the ability to average over a subsection of the data - r_min = 0.25 * averager_data.qmax - r_max = 0.75 * averager_data.qmax - nbins = test_data.matrix_size // 2 - - ring_object = Ring(r_range=(r_min, r_max), nbins=nbins) - data1d = ring_object(averager_data.data) - - expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max) - actual_area = integrate.simpson(data1d.y, data1d.x) - - self.assertAlmostEqual(actual_area, expected_area, 1) - -class SectorQTests(unittest.TestCase): - """ - This class contains the tests for the SectorQ class from manipulations.py - On the sasview side, this includes SectorSlicer and WedgeSlicer. - - The parameters frequency, r_min, r_max, phi_min and phi_max are largely - arbitrary, and the tests should pass if any sane value is used for them. - """ - - def test_sectorq_init(self): - """ - Test that SectorQ's __init__ method does what it's supposed to. - """ - r_min = 0 - r_max = 1 - phi_min = 0 - phi_max = np.pi - nbins = 100 - # base = 10 - - # sector_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min, - # phi_max=phi_max, nbins=nbins, base=base) - - sector_object = SectorQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) - - self.assertEqual(sector_object.r_min, r_min) - self.assertEqual(sector_object.r_max, r_max) - self.assertEqual(sector_object.phi_min, phi_min) - self.assertEqual(sector_object.phi_max, phi_max) - self.assertEqual(sector_object.nbins, nbins) - # self.assertEqual(sector_object.base, base) - - def test_sectorq_non_plottable_data(self): - """ - Test that RuntimeError is raised if the data supplied isn't plottable - """ - # Implementing this test can wait - pass - - def test_sectorq_averaging_without_fold(self): - """ - Test SectorQ can average correctly w/ major axis q and fold disabled. - All min/max r & phi params are specified and have their expected form. - """ - test_data = CircularTestingMatrix(frequency=1, matrix_size=201, - major_axis='Q') - averager_data = MatrixToData2D(test_data.matrix) - - r_min = 0 - r_max = 0.9 * averager_data.qmax - phi_min = np.pi/6 - phi_max = 5*np.pi/6 - nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - - wedge_object = SectorQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) - # Explicitly set fold to False - results span full +/- range - wedge_object.fold = False - data1d = wedge_object(averager_data.data) - - expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, - phi_min=phi_min, - phi_max=phi_max) - # With fold set to False, the sector on the opposite side of the origin - # to the one specified is also graphed as negative Q values. Therefore, - # the area of this other half needs to be accounted for. - expected_area += test_data.area_under_region(r_min=r_min, r_max=r_max, - phi_min=phi_min+np.pi, - phi_max=phi_max+np.pi) - actual_area = integrate.simpson(data1d.y, data1d.x) - - self.assertAlmostEqual(actual_area, expected_area, 1) - - def test_sectorq_averaging_with_fold(self): - """ - Test SectorQ can average correctly w/ major axis q and fold enabled. - All min/max r & phi params are specified and have their expected form. - """ - test_data = CircularTestingMatrix(frequency=1, matrix_size=201, - major_axis='Q') - averager_data = MatrixToData2D(test_data.matrix) - - r_min = 0 - r_max = 0.9 * averager_data.qmax - phi_min = np.pi/6 - phi_max = 5*np.pi/6 - nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - - wedge_object = SectorQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) - # Explicitly set fold to True - points either side of 0,0 are averaged - wedge_object.fold = True - data1d = wedge_object(averager_data.data) - - expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, - phi_min=phi_min, - phi_max=phi_max) - # With fold set to True, points from the sector on the opposite side of - # the origin to the one specified are averaged with points from the - # specified sector. - expected_area += test_data.area_under_region(r_min=r_min, r_max=r_max, - phi_min=phi_min+np.pi, - phi_max=phi_max+np.pi) - expected_area /= 2 - actual_area = integrate.simpson(data1d.y, data1d.x) - - self.assertAlmostEqual(actual_area, expected_area, 1) - - -class WedgeQTests(unittest.TestCase): - """ - This class contains the tests for the WedgeQ class from manipulations.py - - The parameters frequency, r_min, r_max, phi_min and phi_max are largely - arbitrary, and the tests should pass if any sane value is used for them. - """ - - def test_wedgeq_init(self): - """ - Test that WedgeQ's __init__ method does what it's supposed to. - """ - r_min = 1 - r_max = 2 - phi_min = 0 - phi_max = np.pi - nbins = 10 - - wedge_object = WedgeQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) - - self.assertEqual(wedge_object.r_min, r_min) - self.assertEqual(wedge_object.r_max, r_max) - self.assertEqual(wedge_object.phi_min, phi_min) - self.assertEqual(wedge_object.phi_max, phi_max) - self.assertEqual(wedge_object.nbins, nbins) - - def test_wedgeq_averaging(self): - """ - Test WedgeQ can average correctly, when all of min/max r & phi params - are specified and have their expected form. - """ - test_data = CircularTestingMatrix(frequency=3, matrix_size=201, - major_axis='Q') - averager_data = MatrixToData2D(test_data.matrix) - - r_min = 0.1 * averager_data.qmax - r_max = 0.9 * averager_data.qmax - phi_min = np.pi/6 - phi_max = 5*np.pi/6 - nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - - wedge_object = WedgeQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) - data1d = wedge_object(averager_data.data) - - expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, - phi_min=phi_min, - phi_max=phi_max) - actual_area = integrate.simpson(data1d.y, data1d.x) - - self.assertAlmostEqual(actual_area, expected_area, 1) - - -class WedgePhiTests(unittest.TestCase): - """ - This class contains the tests for the WedgePhi class from manipulations.py - - The parameters frequency, r_min, r_max, phi_min and phi_max are largely - arbitrary, and the tests should pass if any sane value is used for them. - """ - - def test_wedgephi_init(self): - """ - Test that WedgePhi's __init__ method does what it's supposed to. - """ - r_min = 1 - r_max = 2 - phi_min = 0 - phi_max = np.pi - nbins = 100 - # base = 10 - - # wedge_object = WedgePhi(r_min=r_min, r_max=r_max, phi_min=phi_min, - # phi_max=phi_max, nbins=nbins, base=base) - wedge_object = WedgePhi(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) - - self.assertEqual(wedge_object.r_min, r_min) - self.assertEqual(wedge_object.r_max, r_max) - self.assertEqual(wedge_object.phi_min, phi_min) - self.assertEqual(wedge_object.phi_max, phi_max) - self.assertEqual(wedge_object.nbins, nbins) - # self.assertEqual(wedge_object.base, base) - - def test_wedgephi_non_plottable_data(self): - """ - Test that RuntimeError is raised if the data supplied isn't plottable - """ - # Implementing this test can wait - pass - - def test_wedgephi_averaging(self): - """ - Test WedgePhi can average correctly, when all of min/max r & phi params - are specified and have their expected form. - """ - test_data = CircularTestingMatrix(frequency=1, matrix_size=201, - major_axis='Phi') - averager_data = MatrixToData2D(test_data.matrix) - - r_min = 0.1 * averager_data.qmax - r_max = 0.9 * averager_data.qmax - phi_min = np.pi/6 - phi_max = 5*np.pi/6 - nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable - - wedge_object = WedgePhi(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) - data1d = wedge_object(averager_data.data) - - expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, - phi_min=phi_min, - phi_max=phi_max) - actual_area = integrate.simpson(data1d.y, data1d.x) - - self.assertAlmostEqual(actual_area, expected_area, 1) - - -class DirectionalAverageValidationTests(unittest.TestCase): - """ - This class tests DirectionalAverage's data validation checks. - """ - - def test_missing_coordinate_data(self): - """ - Ensure a ValueError is raised if no axis data is supplied. - """ - self.assertRaises(ValueError, DirectionalAverage, - major_axis=None, minor_axis=None) - - def test_inappropriate_limits_arrays(self): - """ - Ensure a ValueError is raised if the wrong number of limits is suppied. - """ - self.assertRaises(ValueError, DirectionalAverage, major_axis=[], - minor_axis=[], lims=([], [])) - - def test_nbins_not_int(self): - """ - Ensure a TypeError is raised if the parameter nbins is not a number - that can be converted to integer. - """ - self.assertRaises(TypeError, DirectionalAverage, major_axis=np.array([0, 1]), - minor_axis=np.array([0, 1]), nbins=np.array([])) - - def test_axes_unequal_lengths(self): - """ - Ensure ValueError is raised if the major and minor axes don't match. - """ - self.assertRaises(ValueError, DirectionalAverage, major_axis=[0, 1, 2], - minor_axis=[3, 4]) - - def test_no_limits_on_an_axis(self): - """ - Ensure correct behaviour when there are no limits provided. - The min. and max. values from major/minor_axis are taken as the limits. - """ - dir_avg = DirectionalAverage(major_axis=np.array([1, 2, 3]), - minor_axis=np.array([4, 5, 6])) - self.assertEqual(dir_avg.major_lims, (1, 3)) - self.assertEqual(dir_avg.minor_lims, (4, 6)) - - -class DirectionalAverageFunctionalityTests(unittest.TestCase): - """ - Placeholder - """ - - def setUp(self): - """ - Setup for the DirectionalAverageFunctionalityTests tests. - """ - - # 21 bins, with spacing 0.1 - self.qx_data = np.linspace(-1, 1, 21) - self.qy_data = self.qx_data - x, y = np.meshgrid(self.qx_data, self.qy_data) - # quadratic in x, linear in y - data = x * x * y - self.data2d = MatrixToData2D(data) - - # ROI is the first quadrant only. Same limits for both axes. - self.lims = (0.0, 1.0) - self.in_roi = (self.lims[0] <= self.qx_data) & \ - (self.qx_data <= self.lims[1]) - self.nbins = int(np.sum(self.in_roi)) - # Note that the bin width is less than the spacing of the datapoints, - # because we're insisting that there be as many bins as datapoints. - self.bin_width = (self.lims[1] - self.lims[0]) / self.nbins - - self.directional_average = \ - DirectionalAverage(major_axis=self.data2d.data.qx_data, - minor_axis=self.data2d.data.qy_data, - lims=(self.lims,self.lims), nbins=self.nbins) - - def test_bin_width(self): - """ - Test that the bin width is calculated correctly. - """ - self.assertAlmostEqual(np.average(self.directional_average.bin_widths), self.bin_width) - - def test_get_bin_interval(self): - """ - Test that the get_bin_interval method works correctly. - """ - for b in range(self.nbins): - bin_start, bin_end = self.directional_average.get_bin_interval(b) - expected_bin_start = self.lims[0] + b * self.bin_width - expected_bin_end = self.lims[0] + (b + 1) * self.bin_width - self.assertAlmostEqual(bin_start, expected_bin_start, 10) - self.assertAlmostEqual(bin_end, expected_bin_end, 10) - - def test_get_bin_index(self): - """ - Test that the get_bin_index method works correctly. - """ - # use values at the edges of bins, and values in the middles - values = np.linspace(self.lims[0], self.lims[1], self.nbins * 2) - expected_indices = np.repeat(np.arange(self.nbins), 2) - for n, v in enumerate(values): - self.assertAlmostEqual(self.directional_average.get_bin_index(v), - expected_indices[n], 10) - - def test_binary_weights(self): - """ - Test weights are calculated correctly when the bins & ROI are aligned. - When aligned perfectly, the weights should be ones and zeros only. - - Variations on this test will be needed once fractional weighting is - possible. These should have ROIs which do not line up perfectly with - the bins. - """ - - # I think this test needs mocks, it'd be very complex otherwise. - # I'm struggling to come up with a test for this one. - pass - - def test_directional_averaging(self): - """ - Test that a directinal average is computed correctly. - - Variations on this test will be needed once fractional weighting is - possible. These should have ROIs which do not line up perfectly with - the bins. - """ - x_axis_values, intensity, errors = \ - self.directional_average(data=self.data2d.data.data, - err_data=self.data2d.data.err_data) - - expected_x = self.qx_data[self.in_roi] - expected_intensity = np.mean(self.qy_data[self.in_roi]) * expected_x**2 - - np.testing.assert_array_almost_equal(x_axis_values, expected_x, 10) - np.testing.assert_array_almost_equal(intensity, expected_intensity, 10) - - def test_no_points_in_roi(self): - """ - Test that ValueError is raised if there were on points in the ROI. - """ - # move the region of interest to outside the range of the data - self.directional_average.major_lims = (2, 3) - self.directional_average.minor_lims = (2, 3) - self.assertRaises(ValueError, self.directional_average, - self.data2d.data.data, self.data2d.data.err_data) - -if __name__ == '__main__': - unittest.main() diff --git a/test/sasmanipulations/utest_averaging_box.py b/test/sasmanipulations/utest_averaging_box.py new file mode 100644 index 00000000..45e25dd7 --- /dev/null +++ b/test/sasmanipulations/utest_averaging_box.py @@ -0,0 +1,186 @@ +""" +Unit tests for SlabX and SlabY averagers (moved out of utest_averaging_analytical.py). +""" +import unittest +import numpy as np +from scipy import integrate +from test.sasmanipulations.helper import ( + MatrixToData2D, + make_uniform_dd, + expected_boxsum_and_err, + expected_boxavg_and_err, +) +from sasdata.data_util.averaging import Boxsum, Boxavg +from sasdata.dataloader import data_info + +# TODO - also check the errors are being calculated correctly + +class BoxsumTests(unittest.TestCase): + """ + This class contains all the unit tests for the Boxsum class from + manipulations.py + """ + + def test_boxsum_init(self): + """ + Test that Boxsum's __init__ method does what it's supposed to. + """ + qx_min = 1 + qx_max = 2 + qy_min = 3 + qy_max = 4 + + box_object = Boxsum(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) + + self.assertEqual(box_object.qx_min, qx_min) + self.assertEqual(box_object.qx_max, qx_max) + self.assertEqual(box_object.qy_min, qy_min) + self.assertEqual(box_object.qy_max, qy_max) + + def test_boxsum_multiple_detectors(self): + """ + Test Boxsum raises an error when there are multiple detectors. + """ + dd = make_uniform_dd((100, 100), value=1.0) + detector1 = data_info.Detector() + detector2 = data_info.Detector() + dd.data.detector.append(detector1) + dd.data.detector.append(detector2) + + box_object = Boxsum() + self.assertRaises(ValueError, box_object, dd.data) + + def test_boxsum_total(self): + """ + Test that Boxsum can find the sum of all of a data set + """ + # Creating a 100x100 matrix for a distribution which is flat in y + # and linear in x. + test_data = np.tile(np.arange(100), (100, 1)) + dd = MatrixToData2D(data2d=test_data) + + box_object = Boxsum(qx_range=(-1 * dd.qmax, dd.qmax), qy_range=(-1 * dd.qmax, dd.qmax)) + result, error, npoints = box_object(dd.data) + correct_sum, correct_error = expected_boxsum_and_err(test_data) + + self.assertAlmostEqual(result, correct_sum, 6) + self.assertAlmostEqual(error, correct_error, 6) + + def test_boxsum_subset_total(self): + """ + Test that Boxsum can find the sum of a portion of a data set + """ + # Creating a 100x100 matrix for a distribution which is flat in y + # and linear in x. + test_data = np.tile(np.arange(100), (100, 1)) + dd = MatrixToData2D(data2d=test_data) + + # region corresponds to central 50x50 in original test + box_object = Boxsum(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) + result, error, npoints = box_object(dd.data) + inner_portion = test_data[25:75, 25:75] + correct_sum, correct_error = expected_boxsum_and_err(inner_portion) + + self.assertAlmostEqual(result, correct_sum, 6) + self.assertAlmostEqual(error, correct_error, 6) + + def test_boxsum_zero_sum(self): + """ + Test that Boxsum returns 0 when there are no points within the ROI + """ + test_data = np.ones([100, 100]) + test_data[25:75, 25:75] = 0 + dd = MatrixToData2D(data2d=test_data) + + box_object = Boxsum(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) + result, error, npoints = box_object(dd.data) + + self.assertAlmostEqual(result, 0, 6) + self.assertAlmostEqual(error, 0, 6) + + +class BoxavgTests(unittest.TestCase): + """ + This class contains all the unit tests for the Boxavg class from + manipulations.py + """ + + def test_boxavg_init(self): + """ + Test that Boxavg's __init__ method does what it's supposed to. + """ + qx_min = 1 + qx_max = 2 + qy_min = 3 + qy_max = 4 + + box_object = Boxavg(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) + + self.assertEqual(box_object.qx_min, qx_min) + self.assertEqual(box_object.qx_max, qx_max) + self.assertEqual(box_object.qy_min, qy_min) + self.assertEqual(box_object.qy_max, qy_max) + + def test_boxavg_multiple_detectors(self): + """ + Test Boxavg raises an error when there are multiple detectors. + """ + dd = make_uniform_dd((100, 100), value=1.0) + detector1 = data_info.Detector() + detector2 = data_info.Detector() + dd.data.detector.append(detector1) + dd.data.detector.append(detector2) + + box_object = Boxavg() + self.assertRaises(ValueError, box_object, dd.data) + + def test_boxavg_total(self): + """ + Test that Boxavg can find the average of all of a data set + """ + # Creating a 100x100 matrix for a distribution which is flat in y + # and linear in x. + test_data = np.tile(np.arange(100), (100, 1)) + dd = MatrixToData2D(data2d=test_data) + + box_object = Boxavg(qx_range=(-1 * dd.qmax, dd.qmax), qy_range=(-1 * dd.qmax, dd.qmax)) + result, error = box_object(dd.data) + correct_avg, correct_error = expected_boxavg_and_err(test_data) + + self.assertAlmostEqual(result, correct_avg, 6) + self.assertAlmostEqual(error, correct_error, 6) + + def test_boxavg_subset_total(self): + """ + Test that Boxavg can find the average of a portion of a data set + """ + # Creating a 100x100 matrix for a distribution which is flat in y + # and linear in x. + test_data = np.tile(np.arange(100), (100, 1)) + dd = MatrixToData2D(data2d=test_data) + + box_object = Boxavg(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) + result, error = box_object(dd.data) + inner_portion = test_data[25:75, 25:75] + correct_avg, correct_error = expected_boxavg_and_err(inner_portion) + + self.assertAlmostEqual(result, correct_avg, 6) + self.assertAlmostEqual(error, correct_error, 6) + + def test_boxavg_zero_average(self): + """ + Test that Boxavg returns 0 when there are no points within the ROI + """ + test_data = np.ones([100, 100]) + # Make a hole in the middle with zeros + test_data[25:75, 25:75] = np.zeros([50, 50]) + dd = MatrixToData2D(data2d=test_data) + + box_object = Boxavg(qx_range=(-0.5 * dd.qmax, 0.5 * dd.qmax), qy_range=(-0.5 * dd.qmax, 0.5 * dd.qmax)) + result, error = box_object(dd.data) + + self.assertAlmostEqual(result, 0, 6) + self.assertAlmostEqual(error, 0, 6) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/test/sasmanipulations/utest_averaging_circle.py b/test/sasmanipulations/utest_averaging_circle.py new file mode 100644 index 00000000..28623f11 --- /dev/null +++ b/test/sasmanipulations/utest_averaging_circle.py @@ -0,0 +1,398 @@ +""" +Unit tests for SlabX and SlabY averagers (moved out of utest_averaging_analytical.py). +""" +import unittest +import numpy as np +from scipy import integrate +from test.sasmanipulations.helper import ( + MatrixToData2D, CircularTestingMatrix +) +from sasdata.data_util.averaging import CircularAverage, Ring, SectorQ, WedgeQ, WedgePhi +from sasdata.dataloader import data_info + +# TODO - also check the errors are being calculated correctly + +class CircularAverageTests(unittest.TestCase): + """ + This class contains all the tests for the CircularAverage class + from manipulations.py + """ + + def test_circularaverage_init(self): + """ + Test that CircularAverage's __init__ method does what it's supposed to. + """ + r_min = 1 + r_max = 2 + nbins = 100 + + circ_object = CircularAverage(r_range=(r_min, r_max), nbins=nbins) + + self.assertEqual(circ_object.r_min, r_min) + self.assertEqual(circ_object.r_max, r_max) + self.assertEqual(circ_object.nbins, nbins) + + def test_circularaverage_dq_retrieval(self): + """ + Test that CircularAverage is able to calclate dq_data correctly when + the data provided has dqx_data and dqy_data. + """ + + # I'm saving the implementation of this bit for later + pass + + def test_circularaverage_multiple_detectors(self): + """ + Test CircularAverage raises an error when there are multiple detectors + """ + + # This test can't be implemented yet, because CircularAverage does not + # check the number of detectors. + # TODO - establish whether CircularAverage should be making this check. + pass + + def test_circularaverage_check_q_data(self): + """ + Check CircularAverage ensures the data supplied has `q_data` populated + """ + # test_data = np.ones([100, 100]) + # averager_data = DataMatrixToData2D(test_data) + # # Overwrite q_data so it's empty + # averager_data.data.q_data = np.array([]) + # circ_object = CircularAverage() + # self.assertRaises(RuntimeError, circ_object, averager_data.data) + + # This doesn't work. I'll come back to this later too + pass + + def test_circularaverage_check_valid_radii(self): + """ + Test that CircularAverage raises ValueError when r_min > r_max + """ + self.assertRaises(ValueError, CircularAverage, r_range=(0.1, 0.05)) + + def test_circularaverage_no_points_to_average(self): + """ + Test CircularAverage raises ValueError when the ROI contains no data + """ + test_data = np.ones([100, 100]) + averager_data = MatrixToData2D(test_data) + + # Region of interest well outside region with data + circ_object = CircularAverage(r_range=(2 * averager_data.qmax,3 * averager_data.qmax)) + self.assertRaises(ValueError, circ_object, averager_data.data) + + def test_circularaverage_averages_circularly(self): + """ + Test that CircularAverage can calculate a circular average correctly. + """ + test_data = CircularTestingMatrix(frequency=2, matrix_size=201, + major_axis='Q') + averager_data = MatrixToData2D(test_data.matrix) + + # Test the ability to average over a subsection of the data + r_min = averager_data.qmax * 0.25 + r_max = averager_data.qmax * 0.75 + + nbins = test_data.matrix_size + circ_object = CircularAverage(r_range=(r_min, r_max), nbins=nbins) + data1d = circ_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max) + actual_area = integrate.trapezoid(data1d.y, data1d.x) + + # This used to be able to pass with a precision of 3 d.p. with the old + # manipulations.py - I'm not sure why it doesn't anymore. + # This is still a good level of precision compared to the others though + self.assertAlmostEqual(actual_area, expected_area, 2) + +class RingTests(unittest.TestCase): + """ + This class contains the tests for the Ring class from manipulations.py + A.K.A AnnulusSlicer on the sasview side + """ + + def test_ring_init(self): + """ + Test that Ring's __init__ method does what it's supposed to. + """ + r_min = 1 + r_max = 2 + nbins = 100 + + # Note that Ring also has params center_x and center_y, but these are + # not used by the slicers and there is a 'todo' in manipulations.py to + # remove them. For this reason, I have not tested their initialisation. + ring_object = Ring(r_range=(r_min, r_max), nbins=nbins) + + self.assertEqual(ring_object.r_min, r_min) + self.assertEqual(ring_object.r_max, r_max) + self.assertEqual(ring_object.nbins, nbins) + + def test_ring_non_plottable_data(self): + """ + Test that RuntimeError is raised if the data supplied isn't plottable + """ + # with patch("sasdata.data_util.manipulations.Ring.data2D.__class__.__name__") as p: + # p.return_value = "bad_name" + # ring_object = Ring() + # self.assertRaises(RuntimeError, ring_object.__call__) + + # I can't seem to get patch working, in this test or in others. + pass + + def test_ring_no_points_to_average(self): + """ + Test Ring raises ValueError when the ROI contains no data + """ + test_data = np.ones([100, 100]) + averager_data = MatrixToData2D(test_data) + + # Region of interest well outside region with data + ring_object = Ring(r_range=(2 * averager_data.qmax, 3 * averager_data.qmax)) + self.assertRaises(ValueError, ring_object, averager_data.data) + + def test_ring_averages_azimuthally(self): + """ + Test that Ring can calculate an azimuthal average correctly. + """ + test_data = CircularTestingMatrix(frequency=1, matrix_size=201, + major_axis='Phi') + averager_data = MatrixToData2D(test_data.matrix) + + # Test the ability to average over a subsection of the data + r_min = 0.25 * averager_data.qmax + r_max = 0.75 * averager_data.qmax + nbins = test_data.matrix_size // 2 + + ring_object = Ring(r_range=(r_min, r_max), nbins=nbins) + data1d = ring_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max) + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 1) + +class SectorQTests(unittest.TestCase): + """ + This class contains the tests for the SectorQ class from manipulations.py + On the sasview side, this includes SectorSlicer and WedgeSlicer. + + The parameters frequency, r_min, r_max, phi_min and phi_max are largely + arbitrary, and the tests should pass if any sane value is used for them. + """ + + def test_sectorq_init(self): + """ + Test that SectorQ's __init__ method does what it's supposed to. + """ + r_min = 0 + r_max = 1 + phi_min = 0 + phi_max = np.pi + nbins = 100 + # base = 10 + + # sector_object = SectorQ(r_min=r_min, r_max=r_max, phi_min=phi_min, + # phi_max=phi_max, nbins=nbins, base=base) + + sector_object = SectorQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) + + self.assertEqual(sector_object.r_min, r_min) + self.assertEqual(sector_object.r_max, r_max) + self.assertEqual(sector_object.phi_min, phi_min) + self.assertEqual(sector_object.phi_max, phi_max) + self.assertEqual(sector_object.nbins, nbins) + # self.assertEqual(sector_object.base, base) + + def test_sectorq_non_plottable_data(self): + """ + Test that RuntimeError is raised if the data supplied isn't plottable + """ + # Implementing this test can wait + pass + + def test_sectorq_averaging_without_fold(self): + """ + Test SectorQ can average correctly w/ major axis q and fold disabled. + All min/max r & phi params are specified and have their expected form. + """ + test_data = CircularTestingMatrix(frequency=1, matrix_size=201, + major_axis='Q') + averager_data = MatrixToData2D(test_data.matrix) + + r_min = 0 + r_max = 0.9 * averager_data.qmax + phi_min = np.pi/6 + phi_max = 5*np.pi/6 + nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable + + wedge_object = SectorQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) + # Explicitly set fold to False - results span full +/- range + wedge_object.fold = False + data1d = wedge_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min, + phi_max=phi_max) + # With fold set to False, the sector on the opposite side of the origin + # to the one specified is also graphed as negative Q values. Therefore, + # the area of this other half needs to be accounted for. + expected_area += test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min+np.pi, + phi_max=phi_max+np.pi) + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 1) + + def test_sectorq_averaging_with_fold(self): + """ + Test SectorQ can average correctly w/ major axis q and fold enabled. + All min/max r & phi params are specified and have their expected form. + """ + test_data = CircularTestingMatrix(frequency=1, matrix_size=201, + major_axis='Q') + averager_data = MatrixToData2D(test_data.matrix) + + r_min = 0 + r_max = 0.9 * averager_data.qmax + phi_min = np.pi/6 + phi_max = 5*np.pi/6 + nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable + + wedge_object = SectorQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) + # Explicitly set fold to True - points either side of 0,0 are averaged + wedge_object.fold = True + data1d = wedge_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min, + phi_max=phi_max) + # With fold set to True, points from the sector on the opposite side of + # the origin to the one specified are averaged with points from the + # specified sector. + expected_area += test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min+np.pi, + phi_max=phi_max+np.pi) + expected_area /= 2 + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 1) + + +class WedgeQTests(unittest.TestCase): + """ + This class contains the tests for the WedgeQ class from manipulations.py + + The parameters frequency, r_min, r_max, phi_min and phi_max are largely + arbitrary, and the tests should pass if any sane value is used for them. + """ + + def test_wedgeq_init(self): + """ + Test that WedgeQ's __init__ method does what it's supposed to. + """ + r_min = 1 + r_max = 2 + phi_min = 0 + phi_max = np.pi + nbins = 10 + + wedge_object = WedgeQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) + + self.assertEqual(wedge_object.r_min, r_min) + self.assertEqual(wedge_object.r_max, r_max) + self.assertEqual(wedge_object.phi_min, phi_min) + self.assertEqual(wedge_object.phi_max, phi_max) + self.assertEqual(wedge_object.nbins, nbins) + + def test_wedgeq_averaging(self): + """ + Test WedgeQ can average correctly, when all of min/max r & phi params + are specified and have their expected form. + """ + test_data = CircularTestingMatrix(frequency=3, matrix_size=201, + major_axis='Q') + averager_data = MatrixToData2D(test_data.matrix) + + r_min = 0.1 * averager_data.qmax + r_max = 0.9 * averager_data.qmax + phi_min = np.pi/6 + phi_max = 5*np.pi/6 + nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable + + wedge_object = WedgeQ(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) + data1d = wedge_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min, + phi_max=phi_max) + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 1) + + +class WedgePhiTests(unittest.TestCase): + """ + This class contains the tests for the WedgePhi class from manipulations.py + + The parameters frequency, r_min, r_max, phi_min and phi_max are largely + arbitrary, and the tests should pass if any sane value is used for them. + """ + + def test_wedgephi_init(self): + """ + Test that WedgePhi's __init__ method does what it's supposed to. + """ + r_min = 1 + r_max = 2 + phi_min = 0 + phi_max = np.pi + nbins = 100 + # base = 10 + + # wedge_object = WedgePhi(r_min=r_min, r_max=r_max, phi_min=phi_min, + # phi_max=phi_max, nbins=nbins, base=base) + wedge_object = WedgePhi(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) + + self.assertEqual(wedge_object.r_min, r_min) + self.assertEqual(wedge_object.r_max, r_max) + self.assertEqual(wedge_object.phi_min, phi_min) + self.assertEqual(wedge_object.phi_max, phi_max) + self.assertEqual(wedge_object.nbins, nbins) + # self.assertEqual(wedge_object.base, base) + + def test_wedgephi_non_plottable_data(self): + """ + Test that RuntimeError is raised if the data supplied isn't plottable + """ + # Implementing this test can wait + pass + + def test_wedgephi_averaging(self): + """ + Test WedgePhi can average correctly, when all of min/max r & phi params + are specified and have their expected form. + """ + test_data = CircularTestingMatrix(frequency=1, matrix_size=201, + major_axis='Phi') + averager_data = MatrixToData2D(test_data.matrix) + + r_min = 0.1 * averager_data.qmax + r_max = 0.9 * averager_data.qmax + phi_min = np.pi/6 + phi_max = 5*np.pi/6 + nbins = int(test_data.matrix_size * np.sqrt(2)/4) # usually reliable + + wedge_object = WedgePhi(r_range=(r_min, r_max), phi_range=(phi_min,phi_max), nbins=nbins) + data1d = wedge_object(averager_data.data) + + expected_area = test_data.area_under_region(r_min=r_min, r_max=r_max, + phi_min=phi_min, + phi_max=phi_max) + actual_area = integrate.simpson(data1d.y, data1d.x) + + self.assertAlmostEqual(actual_area, expected_area, 1) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/test/sasmanipulations/utest_averaging_directional.py b/test/sasmanipulations/utest_averaging_directional.py new file mode 100644 index 00000000..c7371726 --- /dev/null +++ b/test/sasmanipulations/utest_averaging_directional.py @@ -0,0 +1,184 @@ +""" +This file contains unit tests for the various averagers found in +sasdata/data_util/manipulations.py - These tests are based on analytical +formulae rather than imported data files. +""" + +import unittest + +import numpy as np +from scipy import integrate +from test.sasmanipulations.helper import (MatrixToData2D, CircularTestingMatrix, make_dd_from_func, + expected_slabx_area, expected_slaby_area, integrate_1d_output, expected_boxsum_and_err,expected_boxavg_and_err, make_uniform_dd) +from sasdata.data_util.averaging import ( + + DirectionalAverage, + + SectorQ, + WedgePhi, + WedgeQ, +) +from sasdata.dataloader import data_info + +# TODO - also check the errors are being calculated correctly + + + + + + + + + + + + +class DirectionalAverageValidationTests(unittest.TestCase): + """ + This class tests DirectionalAverage's data validation checks. + """ + + def test_missing_coordinate_data(self): + """ + Ensure a ValueError is raised if no axis data is supplied. + """ + self.assertRaises(ValueError, DirectionalAverage, + major_axis=None, minor_axis=None) + + def test_inappropriate_limits_arrays(self): + """ + Ensure a ValueError is raised if the wrong number of limits is suppied. + """ + self.assertRaises(ValueError, DirectionalAverage, major_axis=[], + minor_axis=[], lims=([], [])) + + def test_nbins_not_int(self): + """ + Ensure a TypeError is raised if the parameter nbins is not a number + that can be converted to integer. + """ + self.assertRaises(TypeError, DirectionalAverage, major_axis=np.array([0, 1]), + minor_axis=np.array([0, 1]), nbins=np.array([])) + + def test_axes_unequal_lengths(self): + """ + Ensure ValueError is raised if the major and minor axes don't match. + """ + self.assertRaises(ValueError, DirectionalAverage, major_axis=[0, 1, 2], + minor_axis=[3, 4]) + + def test_no_limits_on_an_axis(self): + """ + Ensure correct behaviour when there are no limits provided. + The min. and max. values from major/minor_axis are taken as the limits. + """ + dir_avg = DirectionalAverage(major_axis=np.array([1, 2, 3]), + minor_axis=np.array([4, 5, 6])) + self.assertEqual(dir_avg.major_lims, (1, 3)) + self.assertEqual(dir_avg.minor_lims, (4, 6)) + + +class DirectionalAverageFunctionalityTests(unittest.TestCase): + """ + Placeholder + """ + + def setUp(self): + """ + Setup for the DirectionalAverageFunctionalityTests tests. + """ + + # 21 bins, with spacing 0.1 + self.qx_data = np.linspace(-1, 1, 21) + self.qy_data = self.qx_data + x, y = np.meshgrid(self.qx_data, self.qy_data) + # quadratic in x, linear in y + data = x * x * y + self.data2d = MatrixToData2D(data) + + # ROI is the first quadrant only. Same limits for both axes. + self.lims = (0.0, 1.0) + self.in_roi = (self.lims[0] <= self.qx_data) & \ + (self.qx_data <= self.lims[1]) + self.nbins = int(np.sum(self.in_roi)) + # Note that the bin width is less than the spacing of the datapoints, + # because we're insisting that there be as many bins as datapoints. + self.bin_width = (self.lims[1] - self.lims[0]) / self.nbins + + self.directional_average = \ + DirectionalAverage(major_axis=self.data2d.data.qx_data, + minor_axis=self.data2d.data.qy_data, + lims=(self.lims,self.lims), nbins=self.nbins) + + def test_bin_width(self): + """ + Test that the bin width is calculated correctly. + """ + self.assertAlmostEqual(np.average(self.directional_average.bin_widths), self.bin_width) + + def test_get_bin_interval(self): + """ + Test that the get_bin_interval method works correctly. + """ + for b in range(self.nbins): + bin_start, bin_end = self.directional_average.get_bin_interval(b) + expected_bin_start = self.lims[0] + b * self.bin_width + expected_bin_end = self.lims[0] + (b + 1) * self.bin_width + self.assertAlmostEqual(bin_start, expected_bin_start, 10) + self.assertAlmostEqual(bin_end, expected_bin_end, 10) + + def test_get_bin_index(self): + """ + Test that the get_bin_index method works correctly. + """ + # use values at the edges of bins, and values in the middles + values = np.linspace(self.lims[0], self.lims[1], self.nbins * 2) + expected_indices = np.repeat(np.arange(self.nbins), 2) + for n, v in enumerate(values): + self.assertAlmostEqual(self.directional_average.get_bin_index(v), + expected_indices[n], 10) + + def test_binary_weights(self): + """ + Test weights are calculated correctly when the bins & ROI are aligned. + When aligned perfectly, the weights should be ones and zeros only. + + Variations on this test will be needed once fractional weighting is + possible. These should have ROIs which do not line up perfectly with + the bins. + """ + + # I think this test needs mocks, it'd be very complex otherwise. + # I'm struggling to come up with a test for this one. + pass + + def test_directional_averaging(self): + """ + Test that a directinal average is computed correctly. + + Variations on this test will be needed once fractional weighting is + possible. These should have ROIs which do not line up perfectly with + the bins. + """ + x_axis_values, intensity, errors = \ + self.directional_average(data=self.data2d.data.data, + err_data=self.data2d.data.err_data) + + expected_x = self.qx_data[self.in_roi] + expected_intensity = np.mean(self.qy_data[self.in_roi]) * expected_x**2 + + np.testing.assert_array_almost_equal(x_axis_values, expected_x, 10) + np.testing.assert_array_almost_equal(intensity, expected_intensity, 10) + + def test_no_points_in_roi(self): + """ + Test that ValueError is raised if there were on points in the ROI. + """ + # move the region of interest to outside the range of the data + self.directional_average.major_lims = (2, 3) + self.directional_average.minor_lims = (2, 3) + self.assertRaises(ValueError, self.directional_average, + self.data2d.data.data, self.data2d.data.err_data) + +if __name__ == '__main__': + unittest.main() diff --git a/test/sasmanipulations/utest_averaging_slab.py b/test/sasmanipulations/utest_averaging_slab.py new file mode 100644 index 00000000..a36d02de --- /dev/null +++ b/test/sasmanipulations/utest_averaging_slab.py @@ -0,0 +1,237 @@ +""" +Unit tests for SlabX and SlabY averagers (moved out of utest_averaging_analytical.py). +""" +import unittest +import numpy as np +from scipy import integrate +from test.sasmanipulations.helper import ( + MatrixToData2D, + make_dd_from_func, + expected_slabx_area, + expected_slaby_area, + integrate_1d_output, +) +from sasdata.data_util.averaging import SlabX, SlabY +from sasdata.dataloader import data_info + +# TODO - also check the errors are being calculated correctly + +class SlabXTests(unittest.TestCase): + """ + This class contains all the unit tests for the SlabX class from + manipulations.py + """ + + def test_slabx_init(self): + """ + Test that SlabX's __init__ method does what it's supposed to. + """ + qx_min = 1 + qx_max = 2 + qy_min = 3 + qy_max = 4 + nbins = 100 + fold = True + + slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) + + self.assertEqual(slab_object.qx_min, qx_min) + self.assertEqual(slab_object.qx_max, qx_max) + self.assertEqual(slab_object.qy_min, qy_min) + self.assertEqual(slab_object.qy_max, qy_max) + self.assertEqual(slab_object.nbins, nbins) + self.assertEqual(slab_object.fold, fold) + + def test_slabx_multiple_detectors(self): + """ + Test that SlabX raises an error when there are multiple detectors + """ + averager_data = MatrixToData2D(np.ones([100, 100])) + detector1 = data_info.Detector() + detector2 = data_info.Detector() + averager_data.data.detector.append(detector1) + averager_data.data.detector.append(detector2) + + slab_object = SlabX() + self.assertRaises(ValueError, slab_object, averager_data.data) + + def test_slabx_no_points_to_average(self): + """ + Test SlabX raises ValueError when the ROI contains no data + """ + test_data = np.ones([100, 100]) + averager_data = MatrixToData2D(data2d=test_data) + + # Region of interest well outside region with data + qx_min = 2 * averager_data.qmax + qx_max = 3 * averager_data.qmax + qy_min = 2 * averager_data.qmax + qy_max = 3 * averager_data.qmax + + slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) + self.assertRaises(ValueError, slab_object, averager_data.data) + + def test_slabx_averaging_without_fold(self): + """ + Test that SlabX can average correctly when x is the major axis + """ + def func(x, y): + return x**2 * y + averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) + + + # Set up region of interest to average over - the limits are arbitrary. + qx_min = -0.5 * averager_data.qmax # = -0.5 + qx_max = averager_data.qmax # = 1 + qy_min = -0.5 * averager_data.qmax # = -0.5 + qy_max = averager_data.qmax # = 1 + nbins = int((qx_max - qx_min) / 2 * matrix_size) + # Explicitly not using fold in this test + fold = False + + slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) + data1d = slab_object(averager_data.data) + + expected_area = expected_slabx_area(qx_min, qx_max, qy_min, qy_max) + actual_area = integrate_1d_output(data1d, method="simpson") + + self.assertAlmostEqual(actual_area, expected_area, 2) + + def test_slabx_averaging_with_fold(self): + """ + Test that SlabX can average correctly when x is the major axis + """ + def func(x, y): + return x**2 * y + averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) + + # Set up region of interest to average over - the limits are arbitrary. + qx_min = -0.5 * averager_data.qmax # = -0.5 + qx_max = averager_data.qmax # = 1 + qy_min = -0.5 * averager_data.qmax # = -0.5 + qy_max = averager_data.qmax # = 1 + nbins = int((qx_max - qx_min) / 2 * matrix_size) + # Explicitly using fold in this test + fold = True + + slab_object = SlabX(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) + data1d = slab_object(averager_data.data) + + # Negative values of x are not graphed when fold = True + qx_min_fold = 0 + expected_area = expected_slabx_area(qx_min_fold, qx_max, qy_min, qy_max) + actual_area = integrate_1d_output(data1d, method="simpson") + + self.assertAlmostEqual(actual_area, expected_area, 2) + +class SlabYTests(unittest.TestCase): + """ + This class contains all the unit tests for the SlabY class from + manipulations.py + """ + + def test_slaby_init(self): + """ + Test that SlabY's __init__ method does what it's supposed to. + """ + qx_min = 1 + qx_max = 2 + qy_min = 3 + qy_max = 4 + nbins = 100 + fold = True + + slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) + + self.assertEqual(slab_object.qx_min, qx_min) + self.assertEqual(slab_object.qx_max, qx_max) + self.assertEqual(slab_object.qy_min, qy_min) + self.assertEqual(slab_object.qy_max, qy_max) + self.assertEqual(slab_object.nbins, nbins) + self.assertEqual(slab_object.fold, fold) + + def test_slaby_multiple_detectors(self): + """ + Test that SlabY raises an error when there are multiple detectors + """ + averager_data = MatrixToData2D(np.ones([100, 100])) + detector1 = data_info.Detector() + detector2 = data_info.Detector() + averager_data.data.detector.append(detector1) + averager_data.data.detector.append(detector2) + slab_object = SlabY() + self.assertRaises(ValueError, slab_object, averager_data.data) + + def test_slaby_no_points_to_average(self): + """ + Test SlabY raises ValueError when the ROI contains no data + """ + test_data = np.ones([100, 100]) + averager_data = MatrixToData2D(data2d=test_data) + + # Region of interest well outside region with data + qx_min = 2 * averager_data.qmax + qx_max = 3 * averager_data.qmax + qy_min = 2 * averager_data.qmax + qy_max = 3 * averager_data.qmax + + slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max)) + self.assertRaises(ValueError, slab_object, averager_data.data) + + def test_slaby_averaging_without_fold(self): + """ + Test that SlabY can average correctly when y is the major axis + """ + def func(x, y): + return x * y**2 + averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) + + + # Set up region of interest to average over - the limits are arbitrary. + qx_min = -0.5 * averager_data.qmax # = -0.5 + qx_max = averager_data.qmax # = 1 + qy_min = -0.5 * averager_data.qmax # = -0.5 + qy_max = averager_data.qmax # = 1 + nbins = int((qx_max - qx_min) / 2 * matrix_size) + # Explicitly not using fold in this test + fold = False + + slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) + data1d = slab_object(averager_data.data) + + expected_area = expected_slaby_area(qx_min, qx_max, qy_min, qy_max) + actual_area = integrate_1d_output(data1d, method="simpson") + + self.assertAlmostEqual(actual_area, expected_area, 2) + + def test_slab_averaging_y_with_fold(self): + """ + Test that SlabY can average correctly when y is the major axis + """ + def func(x, y): + return x * y**2 + + averager_data, matrix_size = make_dd_from_func(func, matrix_size=201) + + # Set up region of interest to average over - the limits are arbitrary. + qx_min = -0.5 * averager_data.qmax # = -0.5 + qx_max = averager_data.qmax # = 1 + qy_min = -0.5 * averager_data.qmax # = -0.5 + qy_max = averager_data.qmax # = 1 + nbins = int((qx_max - qx_min) / 2 * matrix_size) + # Explicitly using fold in this test + fold = True + + slab_object = SlabY(qx_range=(qx_min, qx_max), qy_range=(qy_min,qy_max), nbins=nbins, fold=fold) + data1d = slab_object(averager_data.data) + + # Negative values of y are not graphed when fold = True, so don't + # include them in the area calculation. + qy_min_fold = 0 + expected_area = expected_slaby_area(qx_min, qx_max, qy_min_fold, qy_max) + actual_area = integrate_1d_output(data1d, method="simpson") + + self.assertAlmostEqual(actual_area, expected_area, 2) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From bca114beae83488ecdf819e11bc2a902c0fa9a37 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 15 Nov 2025 14:18:01 +0000 Subject: [PATCH 45/49] [pre-commit.ci lite] apply automatic fixes for ruff linting errors --- sasdata/data_util/averaging.py | 8 +++----- sasdata/data_util/binning.py | 3 ++- sasdata/data_util/interval.py | 3 ++- sasdata/data_util/roi.py | 8 +++++--- test/sasmanipulations/utest_averaging_box.py | 15 ++++++++------- test/sasmanipulations/utest_averaging_circle.py | 15 +++++++-------- .../utest_averaging_directional.py | 13 ++++--------- test/sasmanipulations/utest_averaging_slab.py | 13 +++++++------ 8 files changed, 38 insertions(+), 40 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index 9180bf5c..ec9d8fe8 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -2,16 +2,14 @@ This module contains various data processors used by Sasview's slicers. """ -from enum import Enum, auto import numpy as np -from numpy.typing import ArrayLike +from sasdata.data_util.binning import DirectionalAverage +from sasdata.data_util.interval import IntervalType +from sasdata.data_util.roi import CartesianROI, PolarROI from sasdata.dataloader.data_info import Data1D, Data2D -from sasdata.data_util.interval import IntervalType -from sasdata.data_util.binning import DirectionalAverage -from sasdata.data_util.roi import GenericROI, CartesianROI, PolarROI class Boxsum(CartesianROI): """ diff --git a/sasdata/data_util/binning.py b/sasdata/data_util/binning.py index 483deb5b..456fcf78 100644 --- a/sasdata/data_util/binning.py +++ b/sasdata/data_util/binning.py @@ -3,6 +3,7 @@ from sasdata.data_util.interval import IntervalType + class DirectionalAverage: """ Average along one coordinate axis of 2D data and return data for a 1D plot. @@ -212,4 +213,4 @@ def __call__(self, data, err_data): msg = "Average Error: No points inside ROI to average..." raise ValueError(msg) - return x_axis_values[finite], intensity[finite], errors[finite] \ No newline at end of file + return x_axis_values[finite], intensity[finite], errors[finite] diff --git a/sasdata/data_util/interval.py b/sasdata/data_util/interval.py index bdadd9cc..4987bb1a 100644 --- a/sasdata/data_util/interval.py +++ b/sasdata/data_util/interval.py @@ -2,6 +2,7 @@ import numpy as np + class IntervalType(Enum): HALF_OPEN = auto() CLOSED = auto() @@ -31,4 +32,4 @@ def weights_for_interval(self, array, l_bound, u_bound): msg = f"Unrecognised interval_type: {self.name}" raise ValueError(msg) - return np.asarray(in_range, dtype=int) \ No newline at end of file + return np.asarray(in_range, dtype=int) diff --git a/sasdata/data_util/roi.py b/sasdata/data_util/roi.py index 9d8bcded..50777af9 100644 --- a/sasdata/data_util/roi.py +++ b/sasdata/data_util/roi.py @@ -1,6 +1,8 @@ import numpy as np + from sasdata.dataloader.data_info import Data2D + class GenericROI: """ Base class used to set up the data from a Data2D object for processing. @@ -52,8 +54,8 @@ def validate_and_assign_data(self, data2d: Data2D = None) -> None: # the square root of the data. This code was added to replicate # previous functionality. It's a bit dodgy, so feel free to remove. self.err_data[self.err_data == 0] = \ - np.sqrt(np.abs(self.data[self.err_data == 0])) - + np.sqrt(np.abs(self.data[self.err_data == 0])) + class CartesianROI(GenericROI): """ Base class for data manipulators with a Cartesian (rectangular) ROI. @@ -119,4 +121,4 @@ def validate_and_assign_data(self, data2d: Data2D = None) -> None: # Most validation and pre-processing is taken care of by GenericROI. super().validate_and_assign_data(data2d) # Phi data can be calculated from the Cartesian Q coordinates. - self.phi_data = np.arctan2(self.qy_data, self.qx_data) \ No newline at end of file + self.phi_data = np.arctan2(self.qy_data, self.qx_data) diff --git a/test/sasmanipulations/utest_averaging_box.py b/test/sasmanipulations/utest_averaging_box.py index 45e25dd7..f3b4ec4a 100644 --- a/test/sasmanipulations/utest_averaging_box.py +++ b/test/sasmanipulations/utest_averaging_box.py @@ -2,16 +2,17 @@ Unit tests for SlabX and SlabY averagers (moved out of utest_averaging_analytical.py). """ import unittest + import numpy as np -from scipy import integrate + +from sasdata.data_util.averaging import Boxavg, Boxsum +from sasdata.dataloader import data_info from test.sasmanipulations.helper import ( MatrixToData2D, - make_uniform_dd, - expected_boxsum_and_err, expected_boxavg_and_err, + expected_boxsum_and_err, + make_uniform_dd, ) -from sasdata.data_util.averaging import Boxsum, Boxavg -from sasdata.dataloader import data_info # TODO - also check the errors are being calculated correctly @@ -181,6 +182,6 @@ def test_boxavg_zero_average(self): self.assertAlmostEqual(result, 0, 6) self.assertAlmostEqual(error, 0, 6) - + if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/test/sasmanipulations/utest_averaging_circle.py b/test/sasmanipulations/utest_averaging_circle.py index 28623f11..419bd234 100644 --- a/test/sasmanipulations/utest_averaging_circle.py +++ b/test/sasmanipulations/utest_averaging_circle.py @@ -2,13 +2,12 @@ Unit tests for SlabX and SlabY averagers (moved out of utest_averaging_analytical.py). """ import unittest + import numpy as np from scipy import integrate -from test.sasmanipulations.helper import ( - MatrixToData2D, CircularTestingMatrix -) -from sasdata.data_util.averaging import CircularAverage, Ring, SectorQ, WedgeQ, WedgePhi -from sasdata.dataloader import data_info + +from sasdata.data_util.averaging import CircularAverage, Ring, SectorQ, WedgePhi, WedgeQ +from test.sasmanipulations.helper import CircularTestingMatrix, MatrixToData2D # TODO - also check the errors are being calculated correctly @@ -172,7 +171,7 @@ def test_ring_averages_azimuthally(self): actual_area = integrate.simpson(data1d.y, data1d.x) self.assertAlmostEqual(actual_area, expected_area, 1) - + class SectorQTests(unittest.TestCase): """ This class contains the tests for the SectorQ class from manipulations.py @@ -392,7 +391,7 @@ def test_wedgephi_averaging(self): phi_max=phi_max) actual_area = integrate.simpson(data1d.y, data1d.x) - self.assertAlmostEqual(actual_area, expected_area, 1) + self.assertAlmostEqual(actual_area, expected_area, 1) if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/test/sasmanipulations/utest_averaging_directional.py b/test/sasmanipulations/utest_averaging_directional.py index c7371726..7d2a0b68 100644 --- a/test/sasmanipulations/utest_averaging_directional.py +++ b/test/sasmanipulations/utest_averaging_directional.py @@ -7,18 +7,13 @@ import unittest import numpy as np -from scipy import integrate -from test.sasmanipulations.helper import (MatrixToData2D, CircularTestingMatrix, make_dd_from_func, - expected_slabx_area, expected_slaby_area, integrate_1d_output, expected_boxsum_and_err,expected_boxavg_and_err, make_uniform_dd) + from sasdata.data_util.averaging import ( - DirectionalAverage, - - SectorQ, - WedgePhi, - WedgeQ, ) -from sasdata.dataloader import data_info +from test.sasmanipulations.helper import ( + MatrixToData2D, +) # TODO - also check the errors are being calculated correctly diff --git a/test/sasmanipulations/utest_averaging_slab.py b/test/sasmanipulations/utest_averaging_slab.py index a36d02de..b93691ff 100644 --- a/test/sasmanipulations/utest_averaging_slab.py +++ b/test/sasmanipulations/utest_averaging_slab.py @@ -2,17 +2,18 @@ Unit tests for SlabX and SlabY averagers (moved out of utest_averaging_analytical.py). """ import unittest + import numpy as np -from scipy import integrate + +from sasdata.data_util.averaging import SlabX, SlabY +from sasdata.dataloader import data_info from test.sasmanipulations.helper import ( MatrixToData2D, - make_dd_from_func, expected_slabx_area, expected_slaby_area, integrate_1d_output, + make_dd_from_func, ) -from sasdata.data_util.averaging import SlabX, SlabY -from sasdata.dataloader import data_info # TODO - also check the errors are being calculated correctly @@ -232,6 +233,6 @@ def func(x, y): actual_area = integrate_1d_output(data1d, method="simpson") self.assertAlmostEqual(actual_area, expected_area, 2) - + if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() From b8a926fd0c0ab46aae8e905cb548b2a1080e81c5 Mon Sep 17 00:00:00 2001 From: Dirk Honecker Date: Sat, 15 Nov 2025 15:17:17 +0000 Subject: [PATCH 46/49] clean up utest_averaging_directional.py --- .../utest_averaging_directional.py | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/test/sasmanipulations/utest_averaging_directional.py b/test/sasmanipulations/utest_averaging_directional.py index c7371726..89f5daed 100644 --- a/test/sasmanipulations/utest_averaging_directional.py +++ b/test/sasmanipulations/utest_averaging_directional.py @@ -8,31 +8,12 @@ import numpy as np from scipy import integrate -from test.sasmanipulations.helper import (MatrixToData2D, CircularTestingMatrix, make_dd_from_func, - expected_slabx_area, expected_slaby_area, integrate_1d_output, expected_boxsum_and_err,expected_boxavg_and_err, make_uniform_dd) -from sasdata.data_util.averaging import ( - - DirectionalAverage, - - SectorQ, - WedgePhi, - WedgeQ, -) +from test.sasmanipulations.helper import MatrixToData2D +from sasdata.data_util.averaging import DirectionalAverage from sasdata.dataloader import data_info # TODO - also check the errors are being calculated correctly - - - - - - - - - - - class DirectionalAverageValidationTests(unittest.TestCase): """ This class tests DirectionalAverage's data validation checks. @@ -77,7 +58,6 @@ def test_no_limits_on_an_axis(self): self.assertEqual(dir_avg.major_lims, (1, 3)) self.assertEqual(dir_avg.minor_lims, (4, 6)) - class DirectionalAverageFunctionalityTests(unittest.TestCase): """ Placeholder From 520a3ce53b8a75a6d8ebe6e0e26bea8ac2780ad4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 15 Nov 2025 15:28:10 +0000 Subject: [PATCH 47/49] [pre-commit.ci lite] apply automatic fixes for ruff linting errors --- test/sasmanipulations/utest_averaging_directional.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/sasmanipulations/utest_averaging_directional.py b/test/sasmanipulations/utest_averaging_directional.py index 4aadff05..f36111c3 100644 --- a/test/sasmanipulations/utest_averaging_directional.py +++ b/test/sasmanipulations/utest_averaging_directional.py @@ -8,10 +8,8 @@ import numpy as np -from scipy import integrate -from test.sasmanipulations.helper import MatrixToData2D from sasdata.data_util.averaging import DirectionalAverage -from sasdata.dataloader import data_info +from test.sasmanipulations.helper import MatrixToData2D # TODO - also check the errors are being calculated correctly From 704ec2f58105e42bbf1617bc3bd9944156ee6f98 Mon Sep 17 00:00:00 2001 From: Dirk Honecker Date: Tue, 18 Nov 2025 06:21:05 +0000 Subject: [PATCH 48/49] include offset center of ROI fixes sasdata issue 22 --- sasdata/data_util/averaging.py | 30 +- sasdata/data_util/manipulations.py | 1003 +++------------------------- sasdata/data_util/roi.py | 13 +- 3 files changed, 133 insertions(+), 913 deletions(-) diff --git a/sasdata/data_util/averaging.py b/sasdata/data_util/averaging.py index ec9d8fe8..f3c4d78b 100644 --- a/sasdata/data_util/averaging.py +++ b/sasdata/data_util/averaging.py @@ -165,7 +165,7 @@ class SlabY(CartesianROI): resulting in a 1D plot with only positive Q values shown. """ - def __init__(self, qx_range: tuple[float, float] = (0.0, 0), qy_range: tuple[float, float] = (0.0, 0), nbins: int = 100, fold: bool = False): + def __init__(self, qx_range: tuple[float, float] = (0.0, 0.0), qy_range: tuple[float, float] = (0.0, 0), nbins: int = 100, fold: bool = False): """ Set up the ROI boundaries, the binning of the output 1D data, and fold. @@ -223,7 +223,7 @@ class CircularAverage(PolarROI): where intensity is given as a function of Q only. """ - def __init__(self, r_range: tuple[float, float], nbins: int = 100) -> None: + def __init__(self, r_range: tuple[float, float], center: tuple[float, float] = (0.0, 0.0), nbins: int = 100) -> None: """ Set up the lower and upper radial limits as well as the number of bins. @@ -232,7 +232,7 @@ def __init__(self, r_range: tuple[float, float], nbins: int = 100) -> None: :param r_max: Upper limit for |Q| values to use during averaging. :param nbins: The number of bins data is sorted into along |Q| the axis """ - super().__init__(r_range=r_range) + super().__init__(r_range=r_range, center = center) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -267,7 +267,7 @@ class Ring(PolarROI): positive x-axis, φ, only. """ - def __init__(self, r_range: tuple[float, float], nbins: int = 100) -> None: + def __init__(self, r_range: tuple[float, float], center: tuple[float, float] = (0.0, 0.0), nbins: int = 100) -> None: """ Set up the lower and upper radial limits as well as the number of bins. @@ -276,7 +276,7 @@ def __init__(self, r_range: tuple[float, float], nbins: int = 100) -> None: :param r_max: Upper limit for |Q| values to use during averaging. :param nbins: The number of bins data is sorted into along Phi the axis """ - super().__init__(r_range=r_range) + super().__init__(r_range=r_range, center=center) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -322,7 +322,7 @@ class SectorQ(PolarROI): Data1D object where intensity is given as a function of Q only. """ - def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi), nbins: int = 100, fold: bool = True) -> None: + def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi), center: tuple[float, float] = (0.0, 0.0), nbins: int = 100, fold: bool = True) -> None: """ Set up the ROI boundaries, the binning of the output 1D data, and fold. @@ -334,7 +334,7 @@ def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] :param fold: Whether the primary and secondary ROIs should be folded together during averaging. """ - super().__init__(r_range=r_range, phi_range=phi_range) + super().__init__(r_range=r_range, phi_range=phi_range, center = center) self.nbins = nbins self.fold = fold @@ -431,7 +431,7 @@ class WedgeQ(PolarROI): Data1D object where intensity is given as a function of Q only. """ - def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi), nbins: int = 100) -> None: + def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi), center: tuple[float, float] = (0.0, 0.0),nbins: int = 100) -> None: """ Set up the ROI boundaries, and the binning of the output 1D data. @@ -441,7 +441,7 @@ def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] :Defaults to full circle (0, 2*pi). :param nbins: The number of bins data is sorted into along the |Q| axis """ - super().__init__(r_range=r_range, phi_range=phi_range) + super().__init__(r_range=r_range, phi_range=phi_range, center = center) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -492,7 +492,7 @@ class WedgePhi(PolarROI): Data1D object where intensity is given as a function of Q only. """ - def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi), nbins: int = 100) -> None: + def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi), center: tuple[float, float] = (0.0, 0.0), nbins: int = 100) -> None: """ Set up the ROI boundaries, and the binning of the output 1D data. @@ -502,7 +502,7 @@ def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] Defaults to full circle (0, 2*pi). :param nbins: The number of bins data is sorted into along the φ axis. """ - super().__init__(r_range=r_range, phi_range=phi_range) + super().__init__(r_range=r_range, phi_range=phi_range, center = center) self.nbins = nbins def __call__(self, data2d: Data2D = None) -> Data1D: @@ -575,9 +575,9 @@ class Ringcut(PolarROI): in anti-clockwise starting from the x- axis on the left-hand side """ - def __init__(self, r_range: tuple[float, float] = (0.0, 0.0), phi_range: tuple[float, float] = (0.0, 2*np.pi)): + def __init__(self, r_range: tuple[float, float] = (0.0, 0.0), phi_range: tuple[float, float] = (0.0, 2*np.pi), center: tuple[float, float] = (0.0, 0.0)): - super().__init__(r_range, phi_range) + super().__init__(r_range, phi_range, center) def __call__(self, data2D: Data2D) -> np.ndarray[bool]: """ @@ -631,8 +631,8 @@ class Sectorcut(PolarROI): and (phi_max-phi_min) should not be larger than pi """ - def __init__(self, phi_range: tuple[float, float] = (0.0, np.pi)): - super().__init__(r_range=(0, np.inf), phi_range=phi_range) + def __init__(self, phi_range: tuple[float, float] = (0.0, np.pi), center: tuple[float, float] = (0.0, 0.0)): + super().__init__(r_range=(0, np.inf), phi_range=phi_range, center=center) def __call__(self, data2D: Data2D) -> np.ndarray[bool]: """ diff --git a/sasdata/data_util/manipulations.py b/sasdata/data_util/manipulations.py index 5687b6ae..d9db716a 100644 --- a/sasdata/data_util/manipulations.py +++ b/sasdata/data_util/manipulations.py @@ -25,6 +25,30 @@ from sasdata.dataloader.data_info import Data1D, Data2D +################################################################################ +# Backwards-compatible wrappers that delegate to the new implementations +# in averaging.py. +# The original manipulations classes used different parameter names. +# The wrappers below translate the old style +# parameters to the new classes to preserve external behaviour. +################################################################################ + +from sasdata.data_util.averaging import ( + Boxsum as AvgBoxsum, + Boxavg as AvgBoxavg, + SlabX as AvgSlabX, + SlabY as AvgSlabY, + CircularAverage as AvgCircularAverage, + Ring as AvgRing, + SectorQ as AvgSectorQ, + WedgeQ as AvgWedgeQ, + WedgePhi as AvgWedgePhi, + SectorPhi as AvgSectorPhi, + Ringcut as AvgRingcut, + Boxcut as AvgBoxcut, + Sectorcut as AvgSectorcut, +) + warn("sasdata.data_util.manipulations is deprecated. Unless otherwise noted, update your import to " "sasdata.data_util.averaging.", DeprecationWarning, stacklevel=2) @@ -321,980 +345,171 @@ def get_bin_index(self, value): ################################################################################ -class _Slab: - """ - Compute average I(Q) for a region of interest +class SlabX: """ + Wrapper for new SlabX. + Old signature: + SlabX(x_min=0, x_max=0, y_min=0, y_max=0, bin_width=0.001, fold=False) + New signature uses nbins; translate bin_width -> nbins using ceil(range/bin_width) + """ def __init__(self, x_min=0.0, x_max=0.0, y_min=0.0, - y_max=0.0, bin_width=0.001, fold = False): - # Minimum Qx value [A-1] - self.x_min = x_min - # Maximum Qx value [A-1] - self.x_max = x_max - # Minimum Qy value [A-1] - self.y_min = y_min - # Maximum Qy value [A-1] - self.y_max = y_max - # Bin width (step size) [A-1] - self.bin_width = bin_width - # If True, I(|Q|) will be return, otherwise, - # negative q-values are allowed - self.fold = fold + y_max=0.0, bin_width=0.001, fold=False): + # protect against zero-width or negative widths + width = max(abs(x_max - x_min), 1e-12) + nbins = int(math.ceil(width / abs(bin_width))) if bin_width != 0 else 1 + self._impl = AvgSlabX(qx_range=(x_min, x_max), qy_range=(y_min, y_max), + nbins=nbins, fold=fold) def __call__(self, data2D): - return NotImplemented + return self._impl(data2D) - def _avg(self, data2D, maj): - """ - Compute average I(Q_maj) for a region of interest. - The major axis is defined as the axis of Q_maj. - The minor axis is the axis that we average over. - :param data2D: Data2D object - :param maj_min: min value on the major axis - :return: Data1D object - """ - if len(data2D.detector) > 1: - msg = "_Slab._avg: invalid number of " - msg += " detectors: %g" % len(data2D.detector) - raise RuntimeError(msg) - - # Get data - data = data2D.data[np.isfinite(data2D.data)] - err_data =None - if data2D.err_data is not None: - err_data = data2D.err_data[np.isfinite(data2D.data)] - qx_data = data2D.qx_data[np.isfinite(data2D.data)] - qy_data = data2D.qy_data[np.isfinite(data2D.data)] - mask_data = data2D.mask[np.isfinite(data2D.data)] - - # Bin width calculation returns negative values when either axis has no points above 0. - self.bin_width = abs(self.bin_width) - - # Build array of Q intervals - if maj == 'x': - if self.fold: - # Set x_max based on which is further from Qx = 0 - x_max = max(abs(self.x_min),abs(self.x_max)) - # Set x_min based on which is closer to Qx = 0, but will have different limits depending on whether - # x_min and x_max are on the same side of Qx = 0 - if self.x_min*self.x_max >= 0: # If on same side - x_min = min(abs(self.x_min),abs(self.x_max)) - else: - x_min = 0 - else: - x_max = self.x_max - x_min = self.x_min - y_max = self.y_max - y_min = self.y_min - nbins = int(math.ceil((x_max - x_min) / self.bin_width)) - elif maj == 'y': - if self.fold: - # Set y_max based on which is further from Qy = 0 - y_max = max(abs(self.y_min), abs(self.y_max)) - # Set y_min based on which is closer to Qy = 0, but will have different limits depending on whether - # y_min and y_max are on the same side of Qy = 0 - if self.y_min * self.y_max >= 0: # If on same side - y_min = min(abs(self.y_min), abs(self.y_max)) - else: - y_min = 0 - else: - y_max = self.y_max - y_min = self.y_min - x_max = self.x_max - x_min = self.x_min - nbins = int(math.ceil((y_max - y_min) / self.bin_width)) - else: - raise RuntimeError("_Slab._avg: unrecognized axis %s" % str(maj)) - - x = np.zeros(nbins) - y = np.zeros(nbins) - err_y = np.zeros(nbins) - y_counts = np.zeros(nbins) - - # Average pixelsize in q space - for npts in range(len(data)): - if not mask_data[npts]: - # ignore points that are masked - continue - # default frac - frac_x = 0 - frac_y = 0 - # get ROI - if self.fold: - # If folded, need to satisfy absolute value of Q, but also make sure we're only pulling - # from data inside the box (an issue when the box is not centered on 0) - if maj == 'x': - if self.x_min <= qx_data[npts] < self.x_max and x_min <= abs(qx_data[npts]) < x_max: - frac_x = 1 - if self.y_min <= qy_data[npts] < self.y_max: - frac_y = 1 - elif maj == 'y': # The case where maj != 'x' or 'y' was handled earlier - if self.y_min <= qy_data[npts] < self.y_max and y_min <= abs(qy_data[npts]) < y_max: - frac_y = 1 - if self.x_min <= qx_data[npts] < self.x_max: - frac_x = 1 - else: - if self.x_min <= qx_data[npts] < self.x_max: - frac_x = 1 - if self.y_min <= qy_data[npts] < self.y_max: - frac_y = 1 - frac = frac_x * frac_y - - if frac == 0: - continue - # binning: find axis of q - if maj == 'x': - q_value = qx_data[npts] - min_value = x_min - if maj == 'y': - q_value = qy_data[npts] - min_value = y_min - if self.fold and q_value < 0: - q_value = -q_value - # bin - i_q = int(math.ceil((q_value - min_value) / self.bin_width)) - 1 - - # skip outside of max bins - if i_q < 0 or i_q >= nbins: - continue - - # TODO: find better definition of x[i_q] based on q_data - # min_value + (i_q + 1) * self.bin_width / 2.0 - x[i_q] += frac * q_value - y[i_q] += frac * data[npts] - - if err_data is None or err_data[npts] == 0.0: - if data[npts] < 0: - data[npts] = -data[npts] - err_y[i_q] += frac * frac * data[npts] - else: - err_y[i_q] += frac * frac * err_data[npts] * err_data[npts] - y_counts[i_q] += frac - - # Average the sums - for n in range(nbins): - err_y[n] = math.sqrt(err_y[n]) - - err_y = err_y / y_counts - y = y / y_counts - x = x / y_counts - idx = (np.isfinite(y) & np.isfinite(x)) - - if not idx.any(): - msg = "Average Error: No points inside ROI to average..." - raise ValueError(msg) - return Data1D(x=x[idx], y=y[idx], dy=err_y[idx]) - - -class SlabY(_Slab): +class SlabY: """ - Compute average I(Qy) for a region of interest + Wrapper for new SlabY. Same bin_width -> nbins translation as SlabX. """ + def __init__(self, x_min=0.0, x_max=0.0, y_min=0.0, + y_max=0.0, bin_width=0.001, fold=False): + height = max(abs(y_max - y_min), 1e-12) + nbins = int(math.ceil(height / abs(bin_width))) if bin_width != 0 else 1 + self._impl = AvgSlabY(qx_range=(x_min, x_max), qy_range=(y_min, y_max), + nbins=nbins, fold=fold) def __call__(self, data2D): - """ - Compute average I(Qy) for a region of interest + return self._impl(data2D) - :param data2D: Data2D object - :return: Data1D object - """ - return self._avg(data2D, 'y') - - -class SlabX(_Slab): - """ - Compute average I(Qx) for a region of interest - """ - - def __call__(self, data2D): - """ - Compute average I(Qx) for a region of interest - :param data2D: Data2D object - :return: Data1D object - """ - return self._avg(data2D, 'x') ################################################################################ class Boxsum: - """ - Perform the sum of counts in a 2D region of interest. - """ - def __init__(self, x_min=0.0, x_max=0.0, y_min=0.0, y_max=0.0): - # Minimum Qx value [A-1] - self.x_min = x_min - # Maximum Qx value [A-1] - self.x_max = x_max - # Minimum Qy value [A-1] - self.y_min = y_min - # Maximum Qy value [A-1] - self.y_max = y_max + self._impl = AvgBoxsum(qx_range=(x_min, x_max), qy_range=(y_min, y_max)) def __call__(self, data2D): - """ - Perform the sum in the region of interest - - :param data2D: Data2D object - :return: number of counts, error on number of counts, - number of points summed - """ - y, err_y, y_counts = self._sum(data2D) - - # Average the sums - counts = 0 if y_counts == 0 else y - error = 0 if y_counts == 0 else math.sqrt(err_y) - - # Added y_counts to return, SMK & PDB, 04/03/2013 - return counts, error, y_counts - - def _sum(self, data2D): - """ - Perform the sum in the region of interest + return self._impl(data2D) - :param data2D: Data2D object - :return: number of counts, - error on number of counts, number of entries summed - """ - if len(data2D.detector) > 1: - msg = "Circular averaging: invalid number " - msg += "of detectors: %g" % len(data2D.detector) - raise RuntimeError(msg) - # Get data - data = data2D.data[np.isfinite(data2D.data)] - err_data = None - if data2D.err_data is not None: - err_data = data2D.err_data[np.isfinite(data2D.data)] - qx_data = data2D.qx_data[np.isfinite(data2D.data)] - qy_data = data2D.qy_data[np.isfinite(data2D.data)] - mask_data = data2D.mask[np.isfinite(data2D.data)] - - y = 0.0 - err_y = 0.0 - y_counts = 0.0 - - # Average pixelsize in q space - for npts in range(len(data)): - if not mask_data[npts]: - # ignore points that are masked - continue - # default frac - frac_x = 0 - frac_y = 0 - - # get min and max at each points - qx = qx_data[npts] - qy = qy_data[npts] - - # get the ROI - if self.x_min <= qx and self.x_max > qx: - frac_x = 1 - if self.y_min <= qy and self.y_max > qy: - frac_y = 1 - # Find the fraction along each directions - frac = frac_x * frac_y - if frac == 0: - continue - y += frac * data[npts] - if err_data is None or err_data[npts] == 0.0: - if data[npts] < 0: - data[npts] = -data[npts] - err_y += frac * frac * data[npts] - else: - err_y += frac * frac * err_data[npts] * err_data[npts] - y_counts += frac - return y, err_y, y_counts - - -class Boxavg(Boxsum): - """ - Perform the average of counts in a 2D region of interest. - """ +class Boxavg: def __init__(self, x_min=0.0, x_max=0.0, y_min=0.0, y_max=0.0): - super(Boxavg, self).__init__(x_min=x_min, x_max=x_max, - y_min=y_min, y_max=y_max) + self._impl = AvgBoxavg(qx_range=(x_min, x_max), qy_range=(y_min, y_max)) def __call__(self, data2D): - """ - Perform the sum in the region of interest - - :param data2D: Data2D object - :return: average counts, error on average counts - - """ - y, err_y, y_counts = self._sum(data2D) - - # Average the sums - counts = 0 if y_counts == 0 else y / y_counts - error = 0 if y_counts == 0 else math.sqrt(err_y) / y_counts - - return counts, error + return self._impl(data2D) ################################################################################ class CircularAverage: """ - Perform circular averaging on 2D data - - The data returned is the distribution of counts - as a function of Q + Wrapper for new CircularAverage. + Old signature: CircularAverage(r_min=0.0, r_max=0.0, bin_width=0.0005) + New signature uses r_range and nbins; translate bin_width -> nbins. """ - def __init__(self, r_min=0.0, r_max=0.0, bin_width=0.0005): - # Minimum radius included in the average [A-1] - self.r_min = r_min - # Maximum radius included in the average [A-1] - self.r_max = r_max - # Bin width (step size) [A-1] - self.bin_width = bin_width - - def __call__(self, data2D, ismask=False): - """ - Perform circular averaging on the data + width = max(r_max - r_min, 1e-12) + nbins = int(math.ceil(width / abs(bin_width))) if bin_width != 0 else 1 + self._impl = AvgCircularAverage(r_range=(r_min, r_max), nbins=nbins) - :param data2D: Data2D object - :return: Data1D object - """ - # Get data W/ finite values - data = data2D.data[np.isfinite(data2D.data)] - q_data = data2D.q_data[np.isfinite(data2D.data)] - err_data = None - if data2D.err_data is not None: - err_data = data2D.err_data[np.isfinite(data2D.data)] - mask_data = data2D.mask[np.isfinite(data2D.data)] - - dq_data = None - if data2D.dqx_data is not None and data2D.dqy_data is not None: - dq_data = get_dq_data(data2D) - - if len(q_data) == 0: - msg = "Circular averaging: invalid q_data: %g" % data2D.q_data - raise RuntimeError(msg) - - # Build array of Q intervals - nbins = int(math.ceil((self.r_max - self.r_min) / self.bin_width)) - - x = np.zeros(nbins) - y = np.zeros(nbins) - err_y = np.zeros(nbins) - err_x = np.zeros(nbins) - y_counts = np.zeros(nbins) - - for npt in range(len(data)): - - if ismask and not mask_data[npt]: - continue - - frac = 0 - - # q-value at the pixel (j,i) - q_value = q_data[npt] - data_n = data[npt] - - # No need to calculate the frac when all data are within range - if self.r_min >= self.r_max: - raise ValueError("Limit Error: min > max") - - if self.r_min <= q_value and q_value <= self.r_max: - frac = 1 - if frac == 0: - continue - i_q = int(math.floor((q_value - self.r_min) / self.bin_width)) - - # Take care of the edge case at phi = 2pi. - if i_q == nbins: - i_q = nbins - 1 - y[i_q] += frac * data_n - # Take dqs from data to get the q_average - x[i_q] += frac * q_value - if err_data is None or err_data[npt] == 0.0: - if data_n < 0: - data_n = -data_n - err_y[i_q] += frac * frac * data_n - else: - err_y[i_q] += frac * frac * err_data[npt] * err_data[npt] - if dq_data is not None: - # To be consistent with dq calculation in 1d reduction, - # we need just the averages (not quadratures) because - # it should not depend on the number of the q points - # in the qr bins. - err_x[i_q] += frac * dq_data[npt] - else: - err_x = None - y_counts[i_q] += frac - - # Average the sums - for n in range(nbins): - if err_y[n] < 0: - err_y[n] = -err_y[n] - err_y[n] = math.sqrt(err_y[n]) - # if err_x is not None: - # err_x[n] = math.sqrt(err_x[n]) - - err_y = err_y / y_counts - err_y[err_y == 0] = np.average(err_y) - y = y / y_counts - x = x / y_counts - idx = (np.isfinite(y)) & (np.isfinite(x)) - - if err_x is not None: - d_x = err_x[idx] / y_counts[idx] - else: - d_x = None - - if not idx.any(): - msg = "Average Error: No points inside ROI to average..." - raise ValueError(msg) - - return Data1D(x=x[idx], y=y[idx], dy=err_y[idx], dx=d_x) + def __call__(self, data2D): + return self._impl(data2D) ################################################################################ class Ring: """ - Defines a ring on a 2D data set. - The ring is defined by r_min, r_max, and - the position of the center of the ring. - - The data returned is the distribution of counts - around the ring as a function of phi. - - Phi_min and phi_max should be defined between 0 and 2*pi - in anti-clockwise starting from the x- axis on the left-hand side + Wrapper for new Ring. + Old signature: Ring(r_min=0, r_max=0, center_x=0, center_y=0, nbins=36) + New signature: Ring(r_range, nbins) + center_x/center_y are ignored for compatibility. """ - # Todo: remove center. - - def __init__(self, r_min=0, r_max=0, center_x=0, center_y=0, nbins=36): - # Minimum radius - self.r_min = r_min - # Maximum radius - self.r_max = r_max - # Center of the ring in x - self.center_x = center_x - # Center of the ring in y - self.center_y = center_y - # Number of angular bins - self.nbins_phi = nbins + def __init__(self, r_min=0.0, r_max=0.0, center_x=0.0, center_y=0.0, nbins=36): + self._impl = AvgRing(r_range=(r_min, r_max), center=(center_x, center_y),nbins=nbins) def __call__(self, data2D): - """ - Apply the ring to the data set. - Returns the angular distribution for a given q range + return self._impl(data2D) - :param data2D: Data2D object - :return: Data1D object - """ - if data2D.__class__.__name__ not in ["Data2D", "plottable_2D"]: - raise RuntimeError("Ring averaging only take plottable_2D objects") - - Pi = math.pi - - # Get data - data = data2D.data[np.isfinite(data2D.data)] - q_data = data2D.q_data[np.isfinite(data2D.data)] - err_data = None - if data2D.err_data is not None: - err_data = data2D.err_data[np.isfinite(data2D.data)] - qx_data = data2D.qx_data[np.isfinite(data2D.data)] - qy_data = data2D.qy_data[np.isfinite(data2D.data)] - mask_data = data2D.mask[np.isfinite(data2D.data)] - - # Set space for 1d outputs - phi_bins = np.zeros(self.nbins_phi) - phi_counts = np.zeros(self.nbins_phi) - phi_values = np.zeros(self.nbins_phi) - phi_err = np.zeros(self.nbins_phi) - - # Shift to apply to calculated phi values in order - # to center first bin at zero - phi_shift = Pi / self.nbins_phi - - for npt in range(len(data)): - if not mask_data[npt]: - # ignore points that are masked - continue - frac = 0 - # q-value at the point (npt) - q_value = q_data[npt] - data_n = data[npt] - - # phi-value at the point (npt) - phi_value = math.atan2(qy_data[npt], qx_data[npt]) + Pi - - if self.r_min <= q_value and q_value <= self.r_max: - frac = 1 - if frac == 0: - continue - # binning - i_phi = int(math.floor((self.nbins_phi) * - (phi_value + phi_shift) / (2 * Pi))) - - # Take care of the edge case at phi = 2pi. - if i_phi >= self.nbins_phi: - i_phi = 0 - phi_bins[i_phi] += frac * data[npt] - - if err_data is None or err_data[npt] == 0.0: - if data_n < 0: - data_n = -data_n - phi_err[i_phi] += frac * frac * math.fabs(data_n) - else: - phi_err[i_phi] += frac * frac * err_data[npt] * err_data[npt] - phi_counts[i_phi] += frac - - for i in range(self.nbins_phi): - phi_bins[i] = phi_bins[i] / phi_counts[i] - phi_err[i] = math.sqrt(phi_err[i]) / phi_counts[i] - phi_values[i] = 2.0 * math.pi / self.nbins_phi * (1.0 * i) - - idx = (np.isfinite(phi_bins)) - - if not idx.any(): - msg = "Average Error: No points inside ROI to average..." - raise ValueError(msg) - - return Data1D(x=phi_values[idx], y=phi_bins[idx], dy=phi_err[idx]) - - -class _Sector: +class SectorPhi: """ - Defines a sector region on a 2D data set. - The sector is defined by r_min, r_max, phi_min and phi_max. - phi_min and phi_max are defined by the right and left lines wrt a central - line such that phi_max could be less than phi_min if they straddle the - discontinuity from 2pi to 0. - - Phi is defined between 0 and 2*pi in anti-clockwise - starting from the negative x-axis. + Backwards-compatible name for angular sector averaging. + Delegates to new SectorPhi/WedgePhi implementation. """ + def __init__(self, r_min=0.0, r_max=0.0, phi_min=0.0, phi_max=2 * math.pi, center_x=0.0, center_y=0.0, nbins=20): + # SectorPhi in new module is essentially WedgePhi; pass through phi_range and nbins + self._impl = AvgSectorPhi(r_range=(r_min, r_max), phi_range=(phi_min, phi_max),center=(center_x, center_y), + nbins=nbins) - def __init__(self, r_min, r_max, phi_min=0, phi_max=2 * math.pi, nbins=20, - base=None): - ''' - :param base: must be a valid base for an algorithm, i.e., - a positive number - ''' - self.r_min = r_min - self.r_max = r_max - self.phi_min = phi_min - self.phi_max = phi_max - self.nbins = nbins - self.base = base - - # set up to use the asymmetric sector average - default to symmetric - self.fold = True - - def _agv(self, data2D, run='phi'): - """ - Perform sector averaging. - - :param data2D: Data2D object - :param run: define the varying parameter ('phi' , or 'sector') - - :return: Data1D object - """ - if data2D.__class__.__name__ not in ["Data2D", "plottable_2D"]: - raise RuntimeError("Ring averaging only take plottable_2D objects") - - # Get all the data & info - data = data2D.data[np.isfinite(data2D.data)] - q_data = data2D.q_data[np.isfinite(data2D.data)] - err_data=None - if data2D.err_data is not None: - err_data = data2D.err_data[np.isfinite(data2D.data)] - qx_data = data2D.qx_data[np.isfinite(data2D.data)] - qy_data = data2D.qy_data[np.isfinite(data2D.data)] - mask_data = data2D.mask[np.isfinite(data2D.data)] - - dq_data = None - if data2D.dqx_data is not None and data2D.dqy_data is not None: - dq_data = get_dq_data(data2D) - - # set space for 1d outputs - x = np.zeros(self.nbins) - y = np.zeros(self.nbins) - y_err = np.zeros(self.nbins) - x_err = np.zeros(self.nbins) - y_counts = np.zeros(self.nbins) # Cycle counts (for the mean) - - # Get the min and max into the region: 0 <= phi < 2Pi - phi_min = flip_phi(self.phi_min) - phi_max = flip_phi(self.phi_max) - # Now calculate the angles for the opposite side sector, here referred - # to as "minor wing," and ensure these too are within 0 to 2pi - phi_min_minor = flip_phi(phi_min - math.pi) - phi_max_minor = flip_phi(phi_max - math.pi) - - # set up the bins by creating a binning object - if run.lower() == 'phi': - # The check here ensures when a range straddles the discontinuity - # inherent in circular angles (jumping from 2pi to 0) that the - # Binning class still recieves a continuous interval. phi_min/max - # are used here instead of self.phi_min/max as they are always in - # the range 0, 2pi - making their values more predictable. - # Note that their values must not be altered, as they are used to - # determine what points (also in the range 0, 2pi) are in the ROI. - if phi_min > phi_max: - binning = Binning(phi_min, phi_max + 2 * np.pi, self.nbins, self.base) - else: - binning = Binning(phi_min, phi_max, self.nbins, self.base) - elif self.fold: - binning = Binning(self.r_min, self.r_max, self.nbins, self.base) - else: - binning = Binning(-self.r_max, self.r_max, self.nbins, self.base) - - for n in range(len(data)): - if not mask_data[n]: - # ignore points that are masked - continue - - # q-value at the pixel (j,i) - q_value = q_data[n] - data_n = data[n] - - # Is pixel within range? - is_in = False - - # calculate the phi-value of the pixel (j,i) and convert the range - # [-pi,pi] returned by the atan2 function to the [0,2pi] range used - # as the reference frame for these calculations - phi_value = math.atan2(qy_data[n], qx_data[n]) + math.pi - - # No need to calculate: data outside of the radius - if self.r_min > q_value or q_value > self.r_max: - continue - - # For all cases(i.e.,for 'sector' (fold true or false), and 'phi') - # Find pixels within the main ROI (primary sector (main wing) - # in the case of sectors) - if phi_min > phi_max: - is_in = is_in or (phi_value > phi_min or - phi_value < phi_max) - else: - is_in = is_in or (phi_value >= phi_min and - phi_value < phi_max) - - # For sector cuts we need to check if the point is within the - # "minor wing" before checking if it is in the major wing. - # There are effectively two ROIs here as each sector on opposite - # sides of 0,0 need to be checked separately. - if run.lower() == 'sector' and not is_in: - if phi_min_minor > phi_max_minor: - is_in = (phi_value > phi_min_minor or - phi_value < phi_max_minor) - else: - is_in = (phi_value > phi_min_minor and - phi_value < phi_max_minor) - # now, if we want to keep both sides separate we arbitrarily, - # assign negative q to the qs in the minor wing. As calculated, - # all qs are postive and in fact all qs in the same ring are - # the same. This will allow us to plot both sides of 0,0 - # independently. - if not self.fold: - if is_in: - q_value *= -1 - - # data oustide of the phi range - if not is_in: - continue - - # Get the binning index - if run.lower() == 'phi': - # If the original range used to instantiate `binning` was - # shifted by 2pi to accommodate the 2pi to 0 discontinuity, - # then phi_value needs to be shifted too so that it falls in - # the continuous range set up for the binning process. - if phi_min > phi_value: - i_bin = binning.get_bin_index(phi_value + 2 * np.pi) - else: - i_bin = binning.get_bin_index(phi_value) - else: - i_bin = binning.get_bin_index(q_value) - - # Take care of the edge case at phi = 2pi. - if i_bin == self.nbins: - i_bin = self.nbins - 1 - - # Get the total y - y[i_bin] += data_n - x[i_bin] += q_value - if err_data is None or err_data[n] == 0.0: - if data_n < 0: - data_n = -data_n - y_err[i_bin] += data_n - else: - y_err[i_bin] += err_data[n]**2 - - if dq_data is not None: - # To be consistent with dq calculation in 1d reduction, - # we need just the averages (not quadratures) because - # it should not depend on the number of the q points - # in the qr bins. - x_err[i_bin] += dq_data[n] - else: - x_err = None - y_counts[i_bin] += 1 - - # Organize the results - with np.errstate(divide='ignore', invalid='ignore'): - y = y/y_counts - y_err = np.sqrt(y_err)/y_counts - # Calculate x values at the center of the bin depending on the - # the type of averaging (phi or sector) - if run.lower() == 'phi': - # Determining the step size is best done via the binning - # object's interval, as this is set up so max > min in all - # cases. One could also use phi_min and phi_max, so long as - # they have not been changed. - # In setting up x, phi_min makes a better starting point than - # self.phi_min, as the resulting array is garenteed to be > 0 - # throughout. This works better with the sasview gui, which - # will convert the result to the range -pi, pi. - step = (binning.max - binning.min) / self.nbins - x = (np.arange(self.nbins) + 0.5) * step + phi_min - else: - # set q to the average of the q values within each bin - x = x/y_counts - - ### Alternate algorithm - ## We take the center of ring area, not radius. - ## This is more accurate than taking the radial center of ring. - #step = (self.r_max - self.r_min) / self.nbins - #r_inner = self.r_min + step * np.arange(self.nbins) - #x = math.sqrt((r_inner**2 + (r_inner + step)**2) / 2) - - idx = (np.isfinite(y) & np.isfinite(y_err)) - if x_err is not None: - d_x = x_err[idx] / y_counts[idx] - else: - d_x = None - if not idx.any(): - msg = "Average Error: No points inside sector of ROI to average..." - raise ValueError(msg) - return Data1D(x=x[idx], y=y[idx], dy=y_err[idx], dx=d_x) + def __call__(self, data2D): + return self._impl(data2D) -class SectorPhi(_Sector): +class SectorQ: """ - Sector average as a function of phi. - I(phi) is return and the data is averaged over Q. - - A sector is defined by r_min, r_max, phi_min, phi_max. - The number of bin in phi also has to be defined. + Wrapper for new SectorQ. + Old signature: SectorQ(r_min, r_max, phi_min=0, phi_max=2*pi, nbins=20, base=None) + New signature: SectorQ(r_range, phi_range=(0,2pi), nbins=100, fold=True) + Keeps the same default folding behaviour (fold True). """ + def __init__(self, r_min, r_max, phi_min=0, phi_max=2 * math.pi, center_x=0.0, center_y=0.0, nbins=20, base=None): + self._impl = AvgSectorQ(r_range=(r_min, r_max), phi_range=(phi_min, phi_max), center=(center_x, center_y), + nbins=nbins, fold=True) def __call__(self, data2D): - """ - Perform sector average and return I(phi). - - :param data2D: Data2D object - :return: Data1D object - """ - return self._agv(data2D, 'phi') - + return self._impl(data2D) -class SectorQ(_Sector): +class WedgePhi: """ - Sector average as a function of Q for both wings. setting the _Sector.fold - attribute determines whether or not the two sectors are averaged together - (folded over) or separate. In the case of separate (not folded), the - qs for the "minor wing" are arbitrarily set to a negative value. - I(Q) is returned and the data is averaged over phi. - - A sector is defined by r_min, r_max, phi_min, phi_max. - where r_min, r_max, phi_min, phi_max >0. - The number of bin in Q also has to be defined. + Wrapper for new WedgePhi (behaviour matches legacy WedgePhi expectations). """ + def __init__(self, r_min, r_max, phi_min=0, phi_max=2 * math.pi, center_x=0.0, center_y=0.0, nbins=20): + self._impl = AvgWedgePhi(r_range=(r_min, r_max), phi_range=(phi_min, phi_max), center=(center_x, center_y), + nbins=nbins) def __call__(self, data2D): - """ - Perform sector average and return I(Q). - - :param data2D: Data2D object - - :return: Data1D object - """ - return self._agv(data2D, 'sector') - -################################################################################ - + return self._impl(data2D) -class Ringcut: +class WedgeQ: """ - Defines a ring on a 2D data set. - The ring is defined by r_min, r_max, and - the position of the center of the ring. - - The data returned is the region inside the ring - - Phi_min and phi_max should be defined between 0 and 2*pi - in anti-clockwise starting from the x- axis on the left-hand side + Wrapper for new WedgeQ (behaviour matches legacy WedgeQ expectations). """ - - def __init__(self, r_min=0, r_max=0, center_x=0, center_y=0): - # Minimum radius - self.r_min = r_min - # Maximum radius - self.r_max = r_max - # Center of the ring in x - self.center_x = center_x - # Center of the ring in y - self.center_y = center_y + def __init__(self, r_min, r_max, phi_min=0, phi_max=2 * math.pi, center_x=0.0, center_y=0.0, nbins=20): + self._impl = AvgWedgeQ(r_range=(r_min, r_max), phi_range=(phi_min, phi_max), center=(center_x, center_y), + nbins=nbins) def __call__(self, data2D): - """ - Apply the ring to the data set. - Returns the angular distribution for a given q range + return self._impl(data2D) - :param data2D: Data2D object +################################################################################ - :return: index array in the range - """ - if data2D.__class__.__name__ not in ["Data2D", "plottable_2D"]: - raise RuntimeError("Ring cut only take plottable_2D objects") - # Get data - qx_data = data2D.qx_data - qy_data = data2D.qy_data - q_data = np.sqrt(qx_data * qx_data + qy_data * qy_data) +class Ringcut: + def __init__(self, r_min=0.0, r_max=0.0, center_x=0.0, center_y=0.0): + # center_x, center_y ignored for compatibility + self._impl = AvgRingcut(r_range=(r_min, r_max), phi_range=(0.0, 2 * math.pi), center=(center_x, center_y)) - # check whether or not the data point is inside ROI - out = (self.r_min <= q_data) & (self.r_max >= q_data) - return out + def __call__(self, data2D): + return self._impl(data2D) ################################################################################ class Boxcut: - """ - Find a rectangular 2D region of interest. - """ - def __init__(self, x_min=0.0, x_max=0.0, y_min=0.0, y_max=0.0): - # Minimum Qx value [A-1] - self.x_min = x_min - # Maximum Qx value [A-1] - self.x_max = x_max - # Minimum Qy value [A-1] - self.y_min = y_min - # Maximum Qy value [A-1] - self.y_max = y_max + self._impl = AvgBoxcut(qx_range=(x_min, x_max), qy_range=(y_min, y_max)) def __call__(self, data2D): - """ - Find a rectangular 2D region of interest. - - :param data2D: Data2D object - :return: mask, 1d array (len = len(data)) - with Trues where the data points are inside ROI, otherwise False - """ - mask = self._find(data2D) - - return mask - - def _find(self, data2D): - """ - Find a rectangular 2D region of interest. - - :param data2D: Data2D object - - :return: out, 1d array (length = len(data)) - with Trues where the data points are inside ROI, otherwise Falses - """ - if data2D.__class__.__name__ not in ["Data2D", "plottable_2D"]: - raise RuntimeError("Boxcut take only plottable_2D objects") - # Get qx_ and qy_data - qx_data = data2D.qx_data - qy_data = data2D.qy_data - - # check whether or not the data point is inside ROI - outx = (self.x_min <= qx_data) & (self.x_max > qx_data) - outy = (self.y_min <= qy_data) & (self.y_max > qy_data) - - return outx & outy + return self._impl(data2D) ################################################################################ class Sectorcut: - """ - Defines a sector (major + minor) region on a 2D data set. - The sector is defined by phi_min, phi_max, - where phi_min and phi_max are defined by the right - and left lines wrt central line. - - Phi_min and phi_max are given in units of radian - and (phi_max-phi_min) should not be larger than pi - """ - - def __init__(self, phi_min=0, phi_max=math.pi): - self.phi_min = phi_min - self.phi_max = phi_max + def __init__(self, phi_min=0.0, phi_max=math.pi, center_x=0.0, center_y=0.0): + # The new Sectorcut expects a phi_range; set radial range to full image + self._impl = AvgSectorcut(phi_range=(phi_min, phi_max), center=(center_x, center_y)) def __call__(self, data2D): - """ - Find a rectangular 2D region of interest. - - :param data2D: Data2D object - - :return: mask, 1d array (len = len(data)) - - with Trues where the data points are inside ROI, otherwise False - """ - mask = self._find(data2D) - - return mask - - def _find(self, data2D): - """ - Find a rectangular 2D region of interest. - - :param data2D: Data2D object - - :return: out, 1d array (length = len(data)) - - with Trues where the data points are inside ROI, otherwise Falses - """ - if data2D.__class__.__name__ not in ["Data2D", "plottable_2D"]: - raise RuntimeError("Sectorcut take only plottable_2D objects") - Pi = math.pi - # Get data - qx_data = data2D.qx_data - qy_data = data2D.qy_data - - # get phi from data - phi_data = np.arctan2(qy_data, qx_data) - - # Get the min and max into the region: -pi <= phi < Pi - phi_min_major = flip_phi(self.phi_min + Pi) - Pi - phi_max_major = flip_phi(self.phi_max + Pi) - Pi - # check for major sector - if phi_min_major > phi_max_major: - out_major = (phi_min_major <= phi_data) + \ - (phi_max_major > phi_data) - else: - out_major = (phi_min_major <= phi_data) & ( - phi_max_major > phi_data) - - # minor sector - # Get the min and max into the region: -pi <= phi < Pi - phi_min_minor = flip_phi(self.phi_min) - Pi - phi_max_minor = flip_phi(self.phi_max) - Pi - - # check for minor sector - if phi_min_minor > phi_max_minor: - out_minor = (phi_min_minor <= phi_data) + \ - (phi_max_minor >= phi_data) - else: - out_minor = (phi_min_minor <= phi_data) & \ - (phi_max_minor >= phi_data) - out = out_major + out_minor - - return out + return self._impl(data2D) diff --git a/sasdata/data_util/roi.py b/sasdata/data_util/roi.py index 50777af9..ac562ea6 100644 --- a/sasdata/data_util/roi.py +++ b/sasdata/data_util/roi.py @@ -22,6 +22,8 @@ def __init__(self): self.q_data = None self.qx_data = None self.qy_data = None + self.center_x =0.0 + self.center_y =0.0 def validate_and_assign_data(self, data2d: Data2D = None) -> None: """ @@ -46,9 +48,9 @@ def validate_and_assign_data(self, data2d: Data2D = None) -> None: # during data processing. self.data = data2d.data[valid_data] self.err_data = data2d.err_data[valid_data] - self.q_data = data2d.q_data[valid_data] - self.qx_data = data2d.qx_data[valid_data] - self.qy_data = data2d.qy_data[valid_data] + self.q_data = data2d.q_data[valid_data]- np.sqrt(self.center_x**2 + self.center_y**2) + self.qx_data = data2d.qx_data[valid_data]-self.center_x + self.qy_data = data2d.qy_data[valid_data]-self.center_y # No points should have zero error, if they do then assume the error is # the square root of the data. This code was added to replicate @@ -83,7 +85,7 @@ class PolarROI(GenericROI): Base class for data manipulators with a polar ROI. """ - def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi)) -> None: + def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] = (0.0, 2*np.pi), center=tuple[float, float] = (0.0, 0.0)) -> None: """ Assign the variables used to label the properties of the Data2D object. Also establish the upper and lower bounds defining the ROI. @@ -96,6 +98,7 @@ def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] """ r_min, r_max = r_range phi_min, phi_max = phi_range + center_x, center_y = center super().__init__() self.phi_data = None @@ -108,6 +111,8 @@ def __init__(self, r_range: tuple[float, float], phi_range: tuple[float, float] self.r_max = r_max self.phi_min = phi_min self.phi_max = phi_max + self.center_x = center_x + self.center_y = center_y def validate_and_assign_data(self, data2d: Data2D = None) -> None: """ From d328eefb29df486b37edcdd670f384fd533bee83 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 06:21:41 +0000 Subject: [PATCH 49/49] [pre-commit.ci lite] apply automatic fixes for ruff linting errors --- sasdata/data_util/manipulations.py | 45 ++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/sasdata/data_util/manipulations.py b/sasdata/data_util/manipulations.py index d9db716a..f2fb8dcb 100644 --- a/sasdata/data_util/manipulations.py +++ b/sasdata/data_util/manipulations.py @@ -23,31 +23,54 @@ import numpy as np -from sasdata.dataloader.data_info import Data1D, Data2D +from sasdata.data_util.averaging import ( + Boxavg as AvgBoxavg, +) +from sasdata.data_util.averaging import ( + Boxcut as AvgBoxcut, +) ################################################################################ # Backwards-compatible wrappers that delegate to the new implementations -# in averaging.py. +# in averaging.py. # The original manipulations classes used different parameter names. # The wrappers below translate the old style # parameters to the new classes to preserve external behaviour. ################################################################################ - from sasdata.data_util.averaging import ( Boxsum as AvgBoxsum, - Boxavg as AvgBoxavg, - SlabX as AvgSlabX, - SlabY as AvgSlabY, +) +from sasdata.data_util.averaging import ( CircularAverage as AvgCircularAverage, +) +from sasdata.data_util.averaging import ( Ring as AvgRing, - SectorQ as AvgSectorQ, - WedgeQ as AvgWedgeQ, - WedgePhi as AvgWedgePhi, - SectorPhi as AvgSectorPhi, +) +from sasdata.data_util.averaging import ( Ringcut as AvgRingcut, - Boxcut as AvgBoxcut, +) +from sasdata.data_util.averaging import ( Sectorcut as AvgSectorcut, ) +from sasdata.data_util.averaging import ( + SectorPhi as AvgSectorPhi, +) +from sasdata.data_util.averaging import ( + SectorQ as AvgSectorQ, +) +from sasdata.data_util.averaging import ( + SlabX as AvgSlabX, +) +from sasdata.data_util.averaging import ( + SlabY as AvgSlabY, +) +from sasdata.data_util.averaging import ( + WedgePhi as AvgWedgePhi, +) +from sasdata.data_util.averaging import ( + WedgeQ as AvgWedgeQ, +) +from sasdata.dataloader.data_info import Data2D warn("sasdata.data_util.manipulations is deprecated. Unless otherwise noted, update your import to " "sasdata.data_util.averaging.", DeprecationWarning, stacklevel=2)