#  Copyright 2018 United Kingdom Research and Innovation
#  Copyright 2018 The University of Manchester
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.
#
# Authors:
# CIL Developers, listed at: https://github.com/TomographicImaging/CIL/blob/master/NOTICE.txt
# Joshua DM Hellier (University of Manchester) [refactorer]
import copy
import math
import warnings
from numbers import Number

import numpy

from .labels import AcquisitionDimension, AngleUnit, AcquisitionType, FillType
from .acquisition_data import AcquisitionData
from .image_geometry import ImageGeometry

class ComponentDescription(object):
    r'''This class enables the creation of vectors and unit vectors used to describe the components of a tomography system
     '''
    def __init__ (self, dof):
        self._dof = dof

    @staticmethod
    def create_vector(val):
        try:
            vec = numpy.array(val, dtype=numpy.float64).reshape(len(val))
        except:
            raise ValueError("Can't convert to numpy array")

        return vec

    @staticmethod
    def create_unit_vector(val):
        vec = ComponentDescription.create_vector(val)
        dot_product = vec.dot(vec)
        if abs(dot_product)>1e-8:
            vec = (vec/numpy.sqrt(dot_product))
        else:
            raise ValueError("Can't return a unit vector of a zero magnitude vector")
        return vec

    def length_check(self, val):
        try:
            val_length = len(val)
        except:
            raise ValueError("Vectors for {0}D geometries must have length = {0}. Got {1}".format(self._dof,val))

        if val_length != self._dof:
            raise ValueError("Vectors for {0}D geometries must have length = {0}. Got {1}".format(self._dof,val))

    @staticmethod
    def test_perpendicular(vector1, vector2):
        dor_prod = vector1.dot(vector2)
        if abs(dor_prod) <1e-10:
            return True
        return False

    @staticmethod
    def test_parallel(vector1, vector2):
        '''For unit vectors only. Returns true if directions are opposite'''
        dor_prod = vector1.dot(vector2)
        if 1- abs(dor_prod) <1e-10:
            return True
        return False


class PositionVector(ComponentDescription):
    r'''This class creates a component of a tomography system with a position attribute
     '''
    @property
    def position(self):
        try:
            return self._position
        except:
            raise AttributeError

    @position.setter
    def position(self, val):
        self.length_check(val)
        self._position = ComponentDescription.create_vector(val)


class DirectionVector(ComponentDescription):
    r'''This class creates a component of a tomography system with a direction attribute
     '''
    @property
    def direction(self):
        try:
            return self._direction
        except:
            raise AttributeError

    @direction.setter
    def direction(self, val):
        self.length_check(val)
        self._direction = ComponentDescription.create_unit_vector(val)


class PositionDirectionVector(PositionVector, DirectionVector):
    r'''This class creates a component of a tomography system with position and direction attributes
     '''
    pass


class Detector1D(PositionVector):
    r'''This class creates a component of a tomography system with position and direction_x attributes used for 1D panels
     '''
    @property
    def direction_x(self):
        try:
            return self._direction_x
        except:
            raise AttributeError

    @direction_x.setter
    def direction_x(self, val):
        self.length_check(val)
        self._direction_x = ComponentDescription.create_unit_vector(val)

    @property
    def normal(self):
        try:
            return ComponentDescription.create_unit_vector([self._direction_x[1], -self._direction_x[0]])
        except:
            raise AttributeError


class Detector2D(PositionVector):
    r'''This class creates a component of a tomography system with position, direction_x and direction_y attributes used for 2D panels
     '''
    @property
    def direction_x(self):
        try:
            return self._direction_x
        except:
            raise AttributeError

    @property
    def direction_y(self):
        try:
            return self._direction_y
        except:
            raise AttributeError

    @property
    def normal(self):
        try:
            return numpy.cross(self._direction_x, self._direction_y)
        except:
            raise AttributeError

    def set_direction(self, x, y):
        """
        Sets the direction_x and direction_y attributes of the detector.
        
        Parameters
        ----------
        x : array-like
            The direction vector for the x-axis of the detector.
        
        y : array-like
            The direction vector for the y-axis of the detector.
        
        Raises
        ------
        ValueError
            If the vectors are not orthogonal or if they do not have the correct length.

        """
        self.length_check(x)
        x = ComponentDescription.create_unit_vector(x)

        self.length_check(y)
        y = ComponentDescription.create_unit_vector(y)

        dot_product = x.dot(y)

        if abs(dot_product) > 1e-4:
            angle_deg = numpy.degrees(numpy.arccos(dot_product))
            raise ValueError(
                "Vectors detector.direction_x and detector.direction_y must be orthogonal. Angle error: {:.4f} degrees (divergence: {:.2f} pixels over 100 pixels)".format(angle_deg, abs(dot_product)*100)
            )
        self._direction_y = y
        self._direction_x = x


class SystemConfiguration:
    """
    This class defines the configuration of a tomography system, including the geometry and acquisition type.
    It provides methods to describe the system, set the origin, and calculate magnification.

    Parameters
    ----------
    dof : int
        The degrees of freedom of the system i.e 2 (2D) or 3 (3D) acquisition.
    geometry : AcquisitionType
        The geometry of the system, such as PARALLEL or CONE_FLEX.
    units : str, optional
        The units of distance used for the configuration, default is 'units'.
    num_vectors : int, optional
        The number of vectors in the system, default is 1.

    Notes
    -----
    SystemConfiguration is an abstract class that should be subclassed to implement specific system configurations.
    It provides a framework for defining the system's geometry and acquisition type, and includes methods for
    calculating properties such as magnification and system description.

    Attributes
    ----------
    SYSTEM_SIMPLE : str
        This indicates that the geometry matches the default definitions and represents an ideal configuration with no offsets or rotations.
    SYSTEM_OFFSET : str
        This indicates that the geometry matches the default definitions but may have detector translation (parallel) or rotation-axis translation (cone or parallel).
    SYSTEM_ADVANCED : str
        This indicates that the geometries rotation axis and detector vertical direction are not parallel, or the rotation axis is not perpendicular to the ray direction.
    SYSTEM_NONSTANDARD : str
        This indicates that the geometry is not a standard configuration, e.g. a cone flex geometry with multiple sources or detectors.

    """    

    SYSTEM_SIMPLE = 'simple'
    SYSTEM_OFFSET = 'offset'
    SYSTEM_ADVANCED = 'advanced'
    SYSTEM_NONSTANDARD = 'non-standard'

    @property
    def dimension(self):
        return self.acquisition_type.dimension

    @property
    def geometry(self):
        return self.acquisition_type.geometry

    @property
    def acquisition_type(self):
        return self._acquisition_type

    @acquisition_type.setter
    def acquisition_type(self, val):
        self._acquisition_type = AcquisitionType(val).validate()


    def __init__(self, dof: int, geometry, units='units', num_vectors=1):
        self.acquisition_type = AcquisitionType(f"{dof}D") | AcquisitionType(geometry)
        self._num_vectors = num_vectors
        self.units = units

        if AcquisitionType.CONE_FLEX & self.geometry:
            self.source = [PositionVector(dof) for _ in range(num_vectors)]
            self.detector = [Detector2D(dof) for _ in range(num_vectors)]
            self.volume_centre = PositionVector(dof)

        else:
            if AcquisitionType.PARALLEL & self.geometry:
                self.ray = DirectionVector(dof)
            else:
                self.source = PositionVector(dof)

            if AcquisitionType.DIM2 & self.dimension:
                self.detector = Detector1D(dof)
                self.rotation_axis = PositionVector(dof)
            else:
                self.detector = Detector2D(dof)
                self.rotation_axis = PositionDirectionVector(dof)

    def __str__(self):
        """Implements the string representation of the system configuration
        """
        raise NotImplementedError

    def __eq__(self, other):
        """Implements the equality check of the system configuration
        """
        raise NotImplementedError

    @staticmethod
    def rotation_vec_to_y(vec):
        ''' returns a rotation matrix that will rotate the projection of vec on the x-y plane to the +y direction [0,1, Z]'''

        vec = ComponentDescription.create_unit_vector(vec)

        axis_rotation = numpy.eye(len(vec))

        if numpy.allclose(vec[:2],[0,1]):
            pass
        elif numpy.allclose(vec[:2],[0,-1]):
            axis_rotation[0][0] = -1
            axis_rotation[1][1] = -1
        else:
            theta = math.atan2(vec[0],vec[1])
            axis_rotation[0][0] = axis_rotation[1][1] = math.cos(theta)
            axis_rotation[0][1] = -math.sin(theta)
            axis_rotation[1][0] = math.sin(theta)

        return axis_rotation

    @staticmethod
    def rotation_vec_to_z(vec):
        ''' returns a rotation matrix that will align vec with the z-direction [0,0,1]'''

        vec = ComponentDescription.create_unit_vector(vec)

        if len(vec) == 2:
            return numpy.array([[1, 0],[0, 1]])

        elif len(vec) == 3:
            axis_rotation = numpy.eye(3)

            if numpy.allclose(vec,[0,0,1]):
                pass
            elif numpy.allclose(vec,[0,0,-1]):
                axis_rotation = numpy.eye(3)
                axis_rotation[1][1] = -1
                axis_rotation[2][2] = -1
            else:
                vx = numpy.array([[0, 0, -vec[0]], [0, 0, -vec[1]], [vec[0], vec[1], 0]])
                axis_rotation = numpy.eye(3) + vx + vx.dot(vx) *  1 / (1 + vec[2])

        else:
            raise ValueError("Vec must have length 3, got {}".format(len(vec)))

        return axis_rotation

    def update_reference_frame(self):
        r'''Transforms the system origin to the rotation_axis position, or the volume centre for Cone3D_Flex geometry
        '''
        if self.acquisition_type & AcquisitionType.CONE_FLEX:
            self.set_origin(self.volume_centre.position)
        else:
            self.set_origin(self.rotation_axis.position)


    def set_origin(self, origin):
        r'''Transforms the system origin to the input origin
        '''
        translation = origin.copy()

        if self.acquisition_type & AcquisitionType.CONE_FLEX:
            for i in range(self._num_vectors):
                self.source[i].position -= translation
                self.detector[i].position -= translation
            self.volume_centre.position -= translation
        else:
            if hasattr(self,'source'):
                self.source.position -= translation

            self.detector.position -= translation
            self.rotation_axis.position -= translation

    def get_centre_slice(self):
        """Returns the 2D system configuration corresponding to the centre slice
        """
        raise NotImplementedError

    def calculate_magnification(self):
        r'''Calculates the magnification of the system using the source to rotate axis,
        and source to detector distance along the direction.

        :return: returns [dist_source_center, dist_center_detector, magnification],  [0] distance from the source to the rotate axis, [1] distance from the rotate axis to the detector, [2] magnification of the system
        :rtype: list
        '''
        raise NotImplementedError

    def system_description(self):
        """
        Returns a string describing the system configuration.
        This method should be overridden in subclasses to provide specific descriptions.

        Returns
        -------
        str
            A string describing the system configuration.
        
        """
        raise NotImplementedError
    def copy(self):
        '''returns a copy of SystemConfiguration'''
        return copy.deepcopy(self)


class Parallel2D(SystemConfiguration):
    r'''This class creates the SystemConfiguration of a parallel beam 2D tomographic system

    :param ray_direction: A 2D vector describing the x-ray direction (x,y)
    :type ray_direction: list, tuple, ndarray
    :param detector_pos: A 2D vector describing the position of the centre of the detector (x,y)
    :type detector_pos: list, tuple, ndarray
    :param detector_direction_x: A 2D vector describing the direction of the detector_x (x,y)
    :type detector_direction_x: list, tuple, ndarray
    :param rotation_axis_pos: A 2D vector describing the position of the axis of rotation (x,y)
    :type rotation_axis_pos: list, tuple, ndarray
    :param units: Label the units of distance used for the configuration
    :type units: string
    '''

    def __init__ (self, ray_direction, detector_pos, detector_direction_x, rotation_axis_pos, units='units'):
        """Constructor method
        """
        super(Parallel2D, self).__init__(dof=2, geometry=AcquisitionType.PARALLEL, units=units)

        #source
        self.ray.direction = ray_direction

        #detector
        self.detector.position = detector_pos
        self.detector.direction_x = detector_direction_x

        #rotate axis
        self.rotation_axis.position = rotation_axis_pos


    def align_reference_frame(self, definition='cil'):
        r'''Transforms and rotates the system to backend definitions

        'cil' sets the origin to the rotation axis and aligns the y axis with the ray-direction
        'tigre' sets the origin to the rotation axis and aligns the y axis with the ray-direction
        '''
        #in this instance definitions are the same
        if definition not in ['cil','tigre']:
            raise ValueError("Geometry can be configured for definition = 'cil' or 'tigre'  only. Got {}".format(definition))

        self.set_origin(self.rotation_axis.position)

        rotation_matrix = SystemConfiguration.rotation_vec_to_y(self.ray.direction)

        self.ray.direction = rotation_matrix.dot(self.ray.direction.reshape(2,1))
        self.detector.position = rotation_matrix.dot(self.detector.position.reshape(2,1))
        self.detector.direction_x = rotation_matrix.dot(self.detector.direction_x.reshape(2,1))


    def system_description(self):
        r'''Returns `simple` if the the geometry matches the default definitions with no offsets or rotations,
            \nReturns `offset` if the the geometry matches the default definitions with centre-of-rotation or detector offsets
            \nReturns `advanced` if the the geometry has rotated or tilted rotation axis or detector, can also have offsets
        '''


        rays_perpendicular_detector = ComponentDescription.test_parallel(self.ray.direction, self.detector.normal)

        #rotation axis position + ray direction hits detector position
        if numpy.allclose(self.rotation_axis.position, self.detector.position): #points are equal so on ray path
            rotation_axis_centred = True
        else:
            vec_a = ComponentDescription.create_unit_vector(self.detector.position - self.rotation_axis.position)
            rotation_axis_centred = ComponentDescription.test_parallel(self.ray.direction, vec_a)

        if not rays_perpendicular_detector:
            config = SystemConfiguration.SYSTEM_ADVANCED
        elif not rotation_axis_centred:
            config =  SystemConfiguration.SYSTEM_OFFSET
        else:
            config =  SystemConfiguration.SYSTEM_SIMPLE

        return config


    def rotation_axis_on_detector(self):
        """
        Calculates the position, on the detector, of the projection of the rotation axis in the world coordinate system

        Returns
        -------
        cil.framework.system_configuration.PositionVector
            Position in the 3D system
        """
        Pv = self.rotation_axis.position
        ratio = (self.detector.position - Pv).dot(self.detector.normal) / self.ray.direction.dot(self.detector.normal)
        out = PositionVector(2)
        out.position = Pv + self.ray.direction * ratio
        return out

    def calculate_centre_of_rotation(self):
        """
        Calculates the position, on the detector, of the projection of the rotation axis in the detector coordinate system

        Note
        ----
         - Origin is in the centre of the detector
         - Axes directions are specified by detector.direction_x, detector.direction_y
         - Units are the units of distance used to specify the component's positions

        Returns
        -------
        Float
            Offset position along the detector x_axis at y=0
        Float
            Angle between the y_axis and the rotation axis projection, in radians
        """

        #convert to the detector coordinate system
        dp1 = self.rotation_axis_on_detector().position - self.detector.position
        offset = self.detector.direction_x.dot(dp1)

        return (offset, 0.0)

    def set_centre_of_rotation(self, offset):
        """ Configures the geometry to have the requested centre of rotation offset at the detector
        """
        offset_current = self.calculate_centre_of_rotation()[0]
        offset_new = offset - offset_current

        self.rotation_axis.position = self.rotation_axis.position + offset_new * self.detector.direction_x

    def __str__(self):
        def csv(val):
            return numpy.array2string(val, separator=', ')

        repres = "2D Parallel-beam tomography\n"
        repres += "System configuration:\n"
        repres += "\tRay direction: {0}\n".format(csv(self.ray.direction))
        repres += "\tRotation axis position: {0}\n".format(csv(self.rotation_axis.position))
        repres += "\tDetector position: {0}\n".format(csv(self.detector.position))
        repres += "\tDetector direction x: {0}\n".format(csv(self.detector.direction_x))
        return repres

    def __eq__(self, other):

        if not isinstance(other, self.__class__):
            return False

        if numpy.allclose(self.ray.direction, other.ray.direction) \
        and numpy.allclose(self.detector.position, other.detector.position)\
        and numpy.allclose(self.detector.direction_x, other.detector.direction_x)\
        and numpy.allclose(self.rotation_axis.position, other.rotation_axis.position):
            return True

        return False

    def get_centre_slice(self):
        return self

    def calculate_magnification(self):
        '''Method to calculate magnification and distance from the sample to
        the detector using the detector positions and the rotation axis.
        For parallel beam geometry magnification = 1

        Returns
        -------
        list
            A list containing the [0] distance from the source to the rotate
            axis, [1] distance from the rotate axis to the detector,
            [2] magnification of the system

        '''
        ab = (self.rotation_axis.position - self.detector.position)
        dist_center_detector = float(numpy.sqrt(ab.dot(ab)))

        return [None, dist_center_detector, 1.0]


class Parallel3D(SystemConfiguration):
    r'''This class creates the SystemConfiguration of a parallel beam 3D tomographic system

    :param ray_direction: A 3D vector describing the x-ray direction (x,y,z)
    :type ray_direction: list, tuple, ndarray
    :param detector_pos: A 3D vector describing the position of the centre of the detector (x,y,z)
    :type detector_pos: list, tuple, ndarray
    :param detector_direction_x: A 3D vector describing the direction of the detector_x (x,y,z)
    :type detector_direction_x: list, tuple, ndarray
    :param detector_direction_y: A 3D vector describing the direction of the detector_y (x,y,z)
    :type detector_direction_y: list, tuple, ndarray
    :param rotation_axis_pos: A 3D vector describing the position of the axis of rotation (x,y,z)
    :type rotation_axis_pos: list, tuple, ndarray
    :param rotation_axis_direction: A 3D vector describing the direction of the axis of rotation (x,y,z)
    :type rotation_axis_direction: list, tuple, ndarray
    :param units: Label the units of distance used for the configuration
    :type units: string
    '''

    def __init__ (self,  ray_direction, detector_pos, detector_direction_x, detector_direction_y, rotation_axis_pos, rotation_axis_direction, units='units'):
        """Constructor method
        """
        super(Parallel3D, self).__init__(dof=3, geometry=AcquisitionType.PARALLEL, units=units)

        #source
        self.ray.direction = ray_direction

        #detector
        self.detector.position = detector_pos
        self.detector.set_direction(detector_direction_x, detector_direction_y)

        #rotate axis
        self.rotation_axis.position = rotation_axis_pos
        self.rotation_axis.direction = rotation_axis_direction

    def align_z(self):
        r'''Transforms the system origin to the rotate axis with z direction aligned to the rotate axis direction
        '''
        self.set_origin(self.rotation_axis.position)

        #calculate rotation matrix to align rotation axis direction with z
        rotation_matrix = SystemConfiguration.rotation_vec_to_z(self.rotation_axis.direction)

        #apply transform
        self.rotation_axis.direction = [0,0,1]
        self.ray.direction = rotation_matrix.dot(self.ray.direction.reshape(3,1))
        self.detector.position = rotation_matrix.dot(self.detector.position.reshape(3,1))
        new_x = rotation_matrix.dot(self.detector.direction_x.reshape(3,1))
        new_y = rotation_matrix.dot(self.detector.direction_y.reshape(3,1))
        self.detector.set_direction(new_x, new_y)


    def align_reference_frame(self, definition='cil'):
        r'''Transforms and rotates the system to backend definitions
        '''
        #in this instance definitions are the same
        if definition not in ['cil','tigre']:
            raise ValueError("Geometry can be configured for definition = 'cil' or 'tigre'  only. Got {}".format(definition))

        self.align_z()
        rotation_matrix = SystemConfiguration.rotation_vec_to_y(self.ray.direction)

        self.ray.direction = rotation_matrix.dot(self.ray.direction.reshape(3,1))
        self.detector.position = rotation_matrix.dot(self.detector.position.reshape(3,1))
        new_direction_x = rotation_matrix.dot(self.detector.direction_x.reshape(3,1))
        new_direction_y = rotation_matrix.dot(self.detector.direction_y.reshape(3,1))
        self.detector.set_direction(new_direction_x, new_direction_y)


    def system_description(self):
        r'''Returns `simple` if the the geometry matches the default definitions with no offsets or rotations,
            \nReturns `offset` if the the geometry matches the default definitions with centre-of-rotation or detector offsets
            \nReturns `advanced` if the the geometry has rotated or tilted rotation axis or detector, can also have offsets
        '''


        '''
        simple
         - rays perpendicular to detector
         - rotation axis parallel to detector y
         - rotation axis position + ray direction hits detector with no x offset (y offsets allowed)
        offset
         - rays perpendicular to detector
         - rotation axis parallel to detector y
        rolled
         - rays perpendicular to detector
         - rays perpendicular to rotation axis
        advanced
         - not rays perpendicular to detector (for parallel just equates to an effective pixel size change?)
         or
         - not rays perpendicular to rotation axis  (tilted, i.e. laminography)
        '''

        rays_perpendicular_detector = ComponentDescription.test_parallel(self.ray.direction, self.detector.normal)
        rays_perpendicular_rotation = ComponentDescription.test_perpendicular(self.ray.direction, self.rotation_axis.direction)
        rotation_parallel_detector_y = ComponentDescription.test_parallel(self.rotation_axis.direction, self.detector.direction_y)

        #rotation axis to detector is parallel with ray
        if numpy.allclose(self.rotation_axis.position, self.detector.position): #points are equal so on ray path
            rotation_axis_centred = True
        else:
            vec_a = ComponentDescription.create_unit_vector(self.detector.position - self.rotation_axis.position)
            rotation_axis_centred = ComponentDescription.test_parallel(self.ray.direction, vec_a)

        if not rays_perpendicular_detector or\
            not rays_perpendicular_rotation or\
            not rotation_parallel_detector_y:
            config = SystemConfiguration.SYSTEM_ADVANCED
        elif not rotation_axis_centred:
            config =  SystemConfiguration.SYSTEM_OFFSET
        else:
            config =  SystemConfiguration.SYSTEM_SIMPLE

        return config


    def __str__(self):
        def csv(val):
            return numpy.array2string(val, separator=', ')

        repres = "3D Parallel-beam tomography\n"
        repres += "System configuration:\n"
        repres += "\tRay direction: {0}\n".format(csv(self.ray.direction))
        repres += "\tRotation axis position: {0}\n".format(csv(self.rotation_axis.position))
        repres += "\tRotation axis direction: {0}\n".format(csv(self.rotation_axis.direction))
        repres += "\tDetector position: {0}\n".format(csv(self.detector.position))
        repres += "\tDetector direction x: {0}\n".format(csv(self.detector.direction_x))
        repres += "\tDetector direction y: {0}\n".format(csv(self.detector.direction_y))
        return repres

    def __eq__(self, other):

        if not isinstance(other, self.__class__):
            return False

        if numpy.allclose(self.ray.direction, other.ray.direction) \
        and numpy.allclose(self.detector.position, other.detector.position)\
        and numpy.allclose(self.detector.direction_x, other.detector.direction_x)\
        and numpy.allclose(self.detector.direction_y, other.detector.direction_y)\
        and numpy.allclose(self.rotation_axis.position, other.rotation_axis.position)\
        and numpy.allclose(self.rotation_axis.direction, other.rotation_axis.direction):

            return True

        return False

    def calculate_magnification(self):
        '''Method to calculate magnification and distance from the sample to
        the detector using the detector positions and the rotation axis.
        For parallel beam geometry magnification = 1

        Returns
        -------
        list
            A list containing the [0] distance from the source to the rotate
            axis, [1] distance from the rotate axis to the detector,
            [2] magnification of the system

        '''
        ab = (self.rotation_axis.position - self.detector.position)
        dist_center_detector = float(numpy.sqrt(ab.dot(ab)))

        return [None, dist_center_detector, 1.0]

    def get_centre_slice(self):
        """Returns the 2D system configuration corresponding to the centre slice
        """
        dp1 = self.rotation_axis.direction.dot(self.ray.direction)
        dp2 = self.rotation_axis.direction.dot(self.detector.direction_x)

        if numpy.isclose(dp1, 0) and numpy.isclose(dp2, 0):
            temp = self.copy()

            #convert to rotation axis reference frame
            temp.align_reference_frame()

            ray_direction = temp.ray.direction[0:2]
            detector_position = temp.detector.position[0:2]
            detector_direction_x = temp.detector.direction_x[0:2]
            rotation_axis_position = temp.rotation_axis.position[0:2]

            return Parallel2D(ray_direction, detector_position, detector_direction_x, rotation_axis_position)

        else:
            raise ValueError('Cannot convert geometry to 2D. Requires axis of rotation to be perpendicular to ray direction and the detector direction x.')


    def rotation_axis_on_detector(self):
        """
        Calculates the position, on the detector, of the projection of the rotation axis in the world coordinate system

        Returns
        -------
        cil.framework.system_configuration.PositionDirectionVector
            Position and direction in the 3D system
        """
        #calculate the rotation axis line with the detector
        vec_a = self.ray.direction

        #calculate the intersection with the detector
        Pv = self.rotation_axis.position
        ratio = (self.detector.position - Pv).dot(self.detector.normal) / vec_a.dot(self.detector.normal)
        point1 = Pv + vec_a * ratio

        Pv = self.rotation_axis.position + self.rotation_axis.direction
        ratio = (self.detector.position - Pv).dot(self.detector.normal) / vec_a.dot(self.detector.normal)
        point2 = Pv + vec_a * ratio

        out = PositionDirectionVector(3)
        out.position = point1
        out.direction = point2 - point1
        return out


    def calculate_centre_of_rotation(self):
        """
        Calculates the position, on the detector, of the projection of the rotation axis in the detector coordinate system

        Note
        ----
         - Origin is in the centre of the detector
         - Axes directions are specified by detector.direction_x, detector.direction_y
         - Units are the units of distance used to specify the component's positions

        Returns
        -------
        Float
            Offset position along the detector x_axis at y=0
        Float
            Angle between the y_axis and the rotation axis projection, in radians
        """
        rotate_axis_projection = self.rotation_axis_on_detector()

        p1 = rotate_axis_projection.position
        p2 = p1 + rotate_axis_projection.direction

        #point1 and point2 are on the detector plane. need to return them in the detector coordinate system
        dp1 = p1 - self.detector.position
        x1 = self.detector.direction_x.dot(dp1)
        y1 = self.detector.direction_y.dot(dp1)
        dp2 = p2 - self.detector.position
        x2 = self.detector.direction_x.dot(dp2)
        y2 = self.detector.direction_y.dot(dp2)

        #y = m * x + c
        #c = y1 - m * x1
        #when y is 0
        #x=-c/m
        #x_y0 = -y1/m + x1
        offset_x_y0 = x1 -y1 * (x2 - x1)/(y2-y1)

        angle = math.atan2(x2 - x1, y2 - y1)
        offset = offset_x_y0

        return (offset, angle)

    def set_centre_of_rotation(self, offset, angle):
        """ Configures the geometry to have the requested centre of rotation offset at the detector
        """

        #two points on the detector
        x1 = offset
        y1 = 0
        x2 = offset + math.tan(angle)
        y2 = 1

        #convert to 3d coordinates in system frame
        p1 = self.detector.position + x1 * self.detector.direction_x + y1 * self.detector.direction_y
        p2 = self.detector.position + x2 * self.detector.direction_x + y2 * self.detector.direction_y

        # find where vec p1 + t * ray dirn intersects plane defined by rotate axis (pos and dir) and det_x direction

        vector_pos=p1
        vec_dirn=self.ray.direction
        plane_pos=self.rotation_axis.position
        plane_normal = numpy.cross(self.detector.direction_x, self.rotation_axis.direction)


        ratio = (plane_pos - vector_pos).dot(plane_normal) / vec_dirn.dot(plane_normal)
        p1_on_plane = vector_pos + vec_dirn * ratio

        vector_pos=p2
        ratio = (plane_pos - vector_pos).dot(plane_normal) / vec_dirn.dot(plane_normal)
        p2_on_plane = vector_pos + vec_dirn * ratio

        self.rotation_axis.position = p1_on_plane
        self.rotation_axis.direction = p2_on_plane - p1_on_plane


class Cone2D(SystemConfiguration):
    r'''This class creates the SystemConfiguration of a cone beam 2D tomographic system

    :param source_pos: A 2D vector describing the position of the source (x,y)
    :type source_pos: list, tuple, ndarray
    :param detector_pos: A 2D vector describing the position of the centre of the detector (x,y)
    :type detector_pos: list, tuple, ndarray
    :param detector_direction_x: A 2D vector describing the direction of the detector_x (x,y)
    :type detector_direction_x: list, tuple, ndarray
    :param rotation_axis_pos: A 2D vector describing the position of the axis of rotation (x,y)
    :type rotation_axis_pos: list, tuple, ndarray
    :param units: Label the units of distance used for the configuration
    :type units: string
    '''

    def __init__ (self, source_pos, detector_pos, detector_direction_x, rotation_axis_pos, units='units'):
        """Constructor method
        """
        super(Cone2D, self).__init__(dof=2, geometry=AcquisitionType.CONE, units=units)

        #source
        self.source.position = source_pos

        #detector
        self.detector.position = detector_pos
        self.detector.direction_x = detector_direction_x

        #rotate axis
        self.rotation_axis.position = rotation_axis_pos


    def align_reference_frame(self, definition='cil'):
        r'''Transforms and rotates the system to backend definitions
        '''
        self.set_origin(self.rotation_axis.position)

        if definition=='cil':
            rotation_matrix = SystemConfiguration.rotation_vec_to_y(self.detector.position - self.source.position)
        elif definition=='tigre':
            rotation_matrix = SystemConfiguration.rotation_vec_to_y(self.rotation_axis.position - self.source.position)
        else:
            raise ValueError("Geometry can be configured for definition = 'cil' or 'tigre'  only. Got {}".format(definition))

        self.source.position = rotation_matrix.dot(self.source.position.reshape(2,1))
        self.detector.position = rotation_matrix.dot(self.detector.position.reshape(2,1))
        self.detector.direction_x = rotation_matrix.dot(self.detector.direction_x.reshape(2,1))


    def system_description(self):
        r'''Returns `simple` if the the geometry matches the default definitions with no offsets or rotations,
            \nReturns `offset` if the the geometry matches the default definitions with centre-of-rotation or detector offsets
            \nReturns `advanced` if the the geometry has rotated or tilted rotation axis or detector, can also have offsets
        '''

        vec_src2det = ComponentDescription.create_unit_vector(self.detector.position - self.source.position)

        principal_ray_centred = ComponentDescription.test_parallel(vec_src2det, self.detector.normal)

        #rotation axis to detector is parallel with centre ray
        if numpy.allclose(self.rotation_axis.position, self.detector.position): #points are equal
            rotation_axis_centred = True
        else:
            vec_b = ComponentDescription.create_unit_vector(self.detector.position - self.rotation_axis.position)
            rotation_axis_centred = ComponentDescription.test_parallel(vec_src2det, vec_b)

        if not principal_ray_centred:
            config = SystemConfiguration.SYSTEM_ADVANCED
        elif not rotation_axis_centred:
            config =  SystemConfiguration.SYSTEM_OFFSET
        else:
            config =  SystemConfiguration.SYSTEM_SIMPLE

        return config

    def __str__(self):
        def csv(val):
            return numpy.array2string(val, separator=', ')

        repres = "2D Cone-beam tomography\n"
        repres += "System configuration:\n"
        repres += "\tSource position: {0}\n".format(csv(self.source.position))
        repres += "\tRotation axis position: {0}\n".format(csv(self.rotation_axis.position))
        repres += "\tDetector position: {0}\n".format(csv(self.detector.position))
        repres += "\tDetector direction x: {0}\n".format(csv(self.detector.direction_x))
        return repres

    def __eq__(self, other):

        if not isinstance(other, self.__class__):
            return False

        if numpy.allclose(self.source.position, other.source.position) \
        and numpy.allclose(self.detector.position, other.detector.position)\
        and numpy.allclose(self.detector.direction_x, other.detector.direction_x)\
        and numpy.allclose(self.rotation_axis.position, other.rotation_axis.position):
            return True

        return False

    def get_centre_slice(self):
        return self

    def calculate_magnification(self):

        ab = (self.rotation_axis.position - self.source.position)
        dist_source_center = float(numpy.sqrt(ab.dot(ab)))

        ab_unit = ab / numpy.sqrt(ab.dot(ab))

        n = self.detector.normal

        #perpendicular distance between source and detector centre
        sd = float((self.detector.position - self.source.position).dot(n))
        ratio = float(ab_unit.dot(n))

        source_to_detector = sd / ratio
        dist_center_detector = source_to_detector - dist_source_center
        magnification = (dist_center_detector + dist_source_center) / dist_source_center

        return [dist_source_center, dist_center_detector, magnification]

    def rotation_axis_on_detector(self):
        """
        Calculates the position, on the detector, of the projection of the rotation axis in the world coordinate system

        Returns
        -------
        PositionVector
            Position in the 3D system
        """
        #calculate the point the rotation axis intersects with the detector
        vec_a = self.rotation_axis.position - self.source.position

        Pv = self.rotation_axis.position
        ratio = (self.detector.position - Pv).dot(self.detector.normal) / vec_a.dot(self.detector.normal)

        out = PositionVector(2)
        out.position = Pv + vec_a * ratio

        return out


    def calculate_centre_of_rotation(self):
        """
        Calculates the position, on the detector, of the projection of the rotation axis in the detector coordinate system

        Note
        ----
         - Origin is in the centre of the detector
         - Axes directions are specified by detector.direction_x, detector.direction_y
         - Units are the units of distance used to specify the component's positions

        Returns
        -------
        Float
            Offset position along the detector x_axis at y=0
        Float
            Angle between the y_axis and the rotation axis projection, in radians
        """
        #convert to the detector coordinate system
        dp1 = self.rotation_axis_on_detector().position - self.detector.position
        offset = self.detector.direction_x.dot(dp1)

        return (offset, 0.0)

    def set_centre_of_rotation(self, offset):
        """ Configures the geometry to have the requested centre of rotation offset at the detector
        """
        offset_current = self.calculate_centre_of_rotation()[0]
        offset_new = offset - offset_current

        cofr_shift = offset_new * self.detector.direction_x /self.calculate_magnification()[2]
        self.rotation_axis.position =self.rotation_axis.position + cofr_shift


class Cone3D(SystemConfiguration):
    r'''This class creates the SystemConfiguration of a cone beam 3D tomographic system

    :param source_pos: A 3D vector describing the position of the source (x,y,z)
    :type source_pos: list, tuple, ndarray
    :param detector_pos: A 3D vector describing the position of the centre of the detector (x,y,z)
    :type detector_pos: list, tuple, ndarray
    :param detector_direction_x: A 3D vector describing the direction of the detector_x (x,y,z)
    :type detector_direction_x: list, tuple, ndarray
    :param detector_direction_y: A 3D vector describing the direction of the detector_y (x,y,z)
    :type detector_direction_y: list, tuple, ndarray
    :param rotation_axis_pos: A 3D vector describing the position of the axis of rotation (x,y,z)
    :type rotation_axis_pos: list, tuple, ndarray
    :param rotation_axis_direction: A 3D vector describing the direction of the axis of rotation (x,y,z)
    :type rotation_axis_direction: list, tuple, ndarray
    :param units: Label the units of distance used for the configuration
    :type units: string
    '''

    def __init__ (self, source_pos, detector_pos, detector_direction_x, detector_direction_y, rotation_axis_pos, rotation_axis_direction, units='units'):
        """Constructor method
        """
        super(Cone3D, self).__init__(dof=3, geometry=AcquisitionType.CONE, units=units)

        #source
        self.source.position = source_pos

        #detector
        self.detector.position = detector_pos
        self.detector.set_direction(detector_direction_x, detector_direction_y)

        #rotate axis
        self.rotation_axis.position = rotation_axis_pos
        self.rotation_axis.direction = rotation_axis_direction

    def align_z(self):
        r'''Transforms the system origin to the rotate axis with z direction aligned to the rotate axis direction
        '''
        self.set_origin(self.rotation_axis.position)
        rotation_matrix = SystemConfiguration.rotation_vec_to_z(self.rotation_axis.direction)

        #apply transform
        self.rotation_axis.direction = [0,0,1]
        self.source.position = rotation_matrix.dot(self.source.position.reshape(3,1))
        self.detector.position = rotation_matrix.dot(self.detector.position.reshape(3,1))
        new_x = rotation_matrix.dot(self.detector.direction_x.reshape(3,1))
        new_y = rotation_matrix.dot(self.detector.direction_y.reshape(3,1))
        self.detector.set_direction(new_x, new_y)


    def align_reference_frame(self, definition='cil'):
        r'''Transforms and rotates the system to backend definitions
        '''

        self.align_z()

        if definition=='cil':
            rotation_matrix = SystemConfiguration.rotation_vec_to_y(self.detector.position - self.source.position)
        elif definition=='tigre':
            rotation_matrix = SystemConfiguration.rotation_vec_to_y(self.rotation_axis.position - self.source.position)
        else:
            raise ValueError("Geometry can be configured for definition = 'cil' or 'tigre'  only. Got {}".format(definition))

        self.source.position = rotation_matrix.dot(self.source.position.reshape(3,1))
        self.detector.position = rotation_matrix.dot(self.detector.position.reshape(3,1))
        new_direction_x = rotation_matrix.dot(self.detector.direction_x.reshape(3,1))
        new_direction_y = rotation_matrix.dot(self.detector.direction_y.reshape(3,1))
        self.detector.set_direction(new_direction_x, new_direction_y)


    def system_description(self):
        r'''Returns `simple` if the the geometry matches the default definitions with no offsets or rotations,
            \nReturns `offset` if the the geometry matches the default definitions with centre-of-rotation or detector offsets
            \nReturns `advanced` if the the geometry has rotated or tilted rotation axis or detector, can also have offsets
        '''

        vec_src2det = ComponentDescription.create_unit_vector(self.detector.position - self.source.position)

        principal_ray_centred = ComponentDescription.test_parallel(vec_src2det, self.detector.normal)
        centre_ray_perpendicular_rotation = ComponentDescription.test_perpendicular(vec_src2det, self.rotation_axis.direction)
        rotation_parallel_detector_y = ComponentDescription.test_parallel(self.rotation_axis.direction, self.detector.direction_y)

        #rotation axis to detector is parallel with centre ray
        if numpy.allclose(self.rotation_axis.position, self.detector.position): #points are equal
            rotation_axis_centred = True
        else:
            vec_b = ComponentDescription.create_unit_vector(self.detector.position - self.rotation_axis.position)
            rotation_axis_centred = ComponentDescription.test_parallel(vec_src2det, vec_b)

        if not principal_ray_centred or\
            not centre_ray_perpendicular_rotation or\
            not rotation_parallel_detector_y:
            config = SystemConfiguration.SYSTEM_ADVANCED
        elif not rotation_axis_centred:
            config =  SystemConfiguration.SYSTEM_OFFSET
        else:
            config =  SystemConfiguration.SYSTEM_SIMPLE

        return config

    def get_centre_slice(self):
        """Returns the 2D system configuration corresponding to the centre slice
        """
        #requires the rotate axis to be perpendicular to the normal of the detector, and perpendicular to detector_direction_x
        dp1 = self.rotation_axis.direction.dot(self.detector.normal)
        dp2 = self.rotation_axis.direction.dot(self.detector.direction_x)

        if numpy.isclose(dp1, 0) and numpy.isclose(dp2, 0):
            temp = self.copy()
            temp.align_reference_frame()
            source_position = temp.source.position[0:2]
            detector_position = temp.detector.position[0:2]
            detector_direction_x = temp.detector.direction_x[0:2]
            rotation_axis_position = temp.rotation_axis.position[0:2]

            return Cone2D(source_position, detector_position, detector_direction_x, rotation_axis_position)
        else:
            raise ValueError('Cannot convert geometry to 2D. Requires axis of rotation to be perpendicular to the detector.')

    def __str__(self):
        def csv(val):
            return numpy.array2string(val, separator=', ')

        repres = "3D Cone-beam tomography\n"
        repres += "System configuration:\n"
        repres += "\tSource position: {0}\n".format(csv(self.source.position))
        repres += "\tRotation axis position: {0}\n".format(csv(self.rotation_axis.position))
        repres += "\tRotation axis direction: {0}\n".format(csv(self.rotation_axis.direction))
        repres += "\tDetector position: {0}\n".format(csv(self.detector.position))
        repres += "\tDetector direction x: {0}\n".format(csv(self.detector.direction_x))
        repres += "\tDetector direction y: {0}\n".format(csv(self.detector.direction_y))
        return repres

    def __eq__(self, other):

        if not isinstance(other, self.__class__):
            return False

        if numpy.allclose(self.source.position, other.source.position) \
        and numpy.allclose(self.detector.position, other.detector.position)\
        and numpy.allclose(self.detector.direction_x, other.detector.direction_x)\
        and numpy.allclose(self.detector.direction_y, other.detector.direction_y)\
        and numpy.allclose(self.rotation_axis.position, other.rotation_axis.position)\
        and numpy.allclose(self.rotation_axis.direction, other.rotation_axis.direction):

            return True

        return False

    def calculate_magnification(self):

        ab = (self.rotation_axis.position - self.source.position)
        dist_source_center = float(numpy.sqrt(ab.dot(ab)))

        ab_unit = ab / numpy.sqrt(ab.dot(ab))

        n = self.detector.normal

        #perpendicular distance between source and detector centre
        sd = float((self.detector.position - self.source.position).dot(n))
        ratio = float(ab_unit.dot(n))

        source_to_detector = sd / ratio
        dist_center_detector = source_to_detector - dist_source_center
        magnification = (dist_center_detector + dist_source_center) / dist_source_center

        return [dist_source_center, dist_center_detector, magnification]

    def rotation_axis_on_detector(self):
        """
        Calculates the position, on the detector, of the projection of the rotation axis in the world coordinate system

        Returns
        -------
        PositionDirectionVector
            Position and direction in the 3D system
        """
        #calculate the intersection with the detector, of source to pv
        Pv = self.rotation_axis.position
        vec_a = Pv - self.source.position
        ratio = (self.detector.position - Pv).dot(self.detector.normal) / vec_a.dot(self.detector.normal)
        point1 = Pv + vec_a * ratio

        #calculate the intersection with the detector, of source to pv
        Pv = self.rotation_axis.position + self.rotation_axis.direction
        vec_a = Pv - self.source.position
        ratio = (self.detector.position - Pv).dot(self.detector.normal) / vec_a.dot(self.detector.normal)
        point2 = Pv + vec_a * ratio

        out = PositionDirectionVector(3)
        out.position = point1
        out.direction = point2 - point1
        return out

    def calculate_centre_of_rotation(self):
        """
        Calculates the position, on the detector, of the projection of the rotation axis in the detector coordinate system

        Note
        ----
         - Origin is in the centre of the detector
         - Axes directions are specified by detector.direction_x, detector.direction_y
         - Units are the units of distance used to specify the component's positions

        Returns
        -------
        Float
            Offset position along the detector x_axis at y=0
        Float
            Angle between the y_axis and the rotation axis projection, in radians
        """
        rotate_axis_projection = self.rotation_axis_on_detector()

        p1 = rotate_axis_projection.position
        p2 = p1 + rotate_axis_projection.direction

        #point1 and point2 are on the detector plane. need to return them in the detector coordinate system
        dp1 = p1 - self.detector.position
        x1 = self.detector.direction_x.dot(dp1)
        y1 = self.detector.direction_y.dot(dp1)
        dp2 = p2 - self.detector.position
        x2 = self.detector.direction_x.dot(dp2)
        y2 = self.detector.direction_y.dot(dp2)

        #y = m * x + c
        #c = y1 - m * x1
        #when y is 0
        #x=-c/m
        #x_y0 = -y1/m + x1
        offset_x_y0 = x1 -y1 * (x2 - x1)/(y2-y1)

        angle = math.atan2(x2 - x1, y2 - y1)
        offset = offset_x_y0

        return (offset, angle)


    def set_centre_of_rotation(self, offset, angle):
        """ Configures the geometry to have the requested centre of rotation offset at the detector
        """
        #two points on the detector
        x1 = offset
        y1 = 0
        x2 = offset + math.tan(angle)
        y2 = 1

        #convert to 3d coordinates in system frame
        p1 = self.detector.position + x1 * self.detector.direction_x + y1 * self.detector.direction_y
        p2 = self.detector.position + x2 * self.detector.direction_x + y2 * self.detector.direction_y

        # vectors from source define plane
        sp1 = p1 - self.source.position
        sp2 = p2 - self.source.position

        #find vector intersection with a plane defined by rotate axis (pos and dir) and det_x direction
        plane_normal = numpy.cross(self.rotation_axis.direction, self.detector.direction_x)

        ratio = (self.rotation_axis.position - self.source.position).dot(plane_normal) / sp1.dot(plane_normal)
        p1_on_plane = self.source.position + sp1 * ratio

        ratio = (self.rotation_axis.position - self.source.position).dot(plane_normal) / sp2.dot(plane_normal)
        p2_on_plane = self.source.position + sp2 * ratio

        self.rotation_axis.position = p1_on_plane
        self.rotation_axis.direction = p2_on_plane - p1_on_plane


class Cone3D_Flex(SystemConfiguration):
    r'''This class creates the SystemConfiguration of a cone beam 3D tomographic system

    Parameters
    ----------
    source_position_set : array_like
        N 3D vectors describing the position of the source (x,y,z)
    detector_position_set : array_like
        N 3D vectors describing the position of the centre of the detector (x,y,z)
    detector_direction_x_set : array_like
        N 3D vectors describing the direction of the detector_x (x,y,z)
    detector_direction_y_set : array_like
        N 3D vectors describing the direction of the detector_y (x,y,z)
    volume_centre_position : array_like, optional
        A 3D vector describing the position of the centre of the reconstructed volume (x,y,z). Default is [0,0,0].
    units : str
        Label the units of distance used for the configuration

    '''

    def __init__ (self, source_position_set, detector_position_set, detector_direction_x_set, detector_direction_y_set, volume_centre_position, units='units'):

        self.num_positions = len(source_position_set)
        super(Cone3D_Flex, self).__init__(dof=3, geometry = 'cone_flex', units=units, num_vectors= self.num_positions)

        # Check that all input sets have the same length
        if not (len(detector_position_set) == self.num_positions and
                len(detector_direction_x_set) == self.num_positions and
                len(detector_direction_y_set) == self.num_positions):
            raise ValueError(f"All input sets must have the same length. Got {self.num_positions} source positions, \
                            {len(detector_position_set)} detector positions, {len(detector_direction_x_set)} detector direction x vectors, \
                            and {len(detector_direction_y_set)} detector direction y vectors.")
    
        # Validate and convert the input arrays
        for i in range(self.num_positions):
            self.source[i].position = source_position_set[i]
            self.detector[i].position = detector_position_set[i]
            self.detector[i].set_direction(detector_direction_x_set[i], detector_direction_y_set[i])

        self.volume_centre.position = volume_centre_position


    def system_description(self):
        """
        Returns the system configuration type
        """
        return SystemConfiguration.SYSTEM_NONSTANDARD

    def get_centre_slice(self):

        """Returns the 2D system configuration corresponding to the centre slice
        """
        raise TypeError('Cone3D_Flex does not support get_centre_slice(). Use the Cone3D or Cone2D classes for 2D slices.')

    def __str__(self):
        def csv(val):
            return numpy.array2string(numpy.array(val), separator=',')

        repres = "Per-projection 3D Cone-beam tomography\n"
        repres += "System configuration:\n"
        repres += f"\tNumber of projections: {self.num_positions}\n"

        num_show = min(10, self.num_positions)
        repres += f"\tSource positions 0-{num_show-1}: {csv([self.source[i].position for i in range(num_show)])}\n"
        repres += f"\tDetector positions 0-{num_show-1}: {csv([self.detector[i].position for i in range(num_show)])}\n"
        repres += f"\tDetector directions x 0-{num_show-1}: {csv([self.detector[i].direction_x for i in range(num_show)])}\n"
        repres += f"\tDetector directions y 0-{num_show-1}: {csv([self.detector[i].direction_y for i in range(num_show)])}\n"
        repres += f"\tVolume centre position: {csv(self.volume_centre.position)}\n"
        repres += f"\tAverage system magnification: {numpy.mean(self.calculate_magnification()[2])}\n"
        return repres

    def __eq__(self, other):

        if not isinstance(other, self.__class__):
            return False

        current_source = [self.source[i].position for i in range(self.num_positions)]
        other_source = [other.source[i].position for i in range(other.num_positions)]

        current_detector_pos = [self.detector[i].position for i in range(self.num_positions)]
        other_detector_pos = [other.detector[i].position for i in range(other.num_positions)]

        current_detector_direction_x = [self.detector[i].direction_x for i in range(self.num_positions)]
        other_detector_direction_x = [other.detector[i].direction_x for i in range(other.num_positions)]

        current_detector_direction_y = [self.detector[i].direction_y for i in range(self.num_positions)]
        other_detector_direction_y = [other.detector[i].direction_y for i in range(other.num_positions)]

        if self.num_positions == other.num_positions \
        and numpy.allclose(current_source, other_source) \
        and numpy.allclose(current_detector_pos, other_detector_pos) \
        and numpy.allclose(current_detector_direction_x, other_detector_direction_x) \
        and numpy.allclose(current_detector_direction_y, other_detector_direction_y) \
        and numpy.allclose(self.volume_centre.position, other.volume_centre.position):

            return True

        return False


    def calculate_magnification(self):
        """
        Calculate the magnification for each position.

        This method calculates the magnification of the volume_centre point for each projection in 
        the acquisition geometry. 

        It computes the distances from the source to the volume_centre and from the volume_centre to the detector, 
        and then uses these distances to determine the magnification.

        Returns
        -------
        list of numpy.ndarray
            A list containing three numpy arrays:
            - dist_source_center: The distances from the source to the center for each position.
            - dist_center_detector: The distances from the center to the detector for each position.
            - magnification: The magnification factors for each position.
        """

        dist_center_detector = numpy.empty(self.num_positions)
        dist_source_center = numpy.empty(self.num_positions)
        magnification = numpy.empty(self.num_positions)

        for idx in range(self.num_positions):
            ab = (self.volume_centre.position - self.source[idx].position)
            dist_source_center[idx]  = float(numpy.sqrt(ab.dot(ab)))

            ab_unit = ab / numpy.sqrt(ab.dot(ab))

            n = self.detector[idx].normal

            #perpendicular distance between source and detector centre
            sd = float((self.detector[idx].position - self.source[idx].position).dot(n))
            ratio = float(ab_unit.dot(n))

            source_to_detector= sd / ratio
            dist_center_detector[idx] = source_to_detector - dist_source_center[idx] 
            magnification[idx] = (dist_center_detector[idx] + dist_source_center[idx] ) / dist_source_center[idx] 


        return [dist_source_center, dist_center_detector, magnification]


class Panel(object):
    r'''This is a class describing the panel of the system.

    :param num_pixels: num_pixels_h or (num_pixels_h, num_pixels_v) containing the number of pixels of the panel
    :type num_pixels: int, list, tuple
    :param pixel_size: pixel_size_h or (pixel_size_h, pixel_size_v) containing the size of the pixels of the panel
    :type pixel_size: int, lust, tuple
    :param origin: the position of pixel 0 (the data origin) of the panel `top-left`, `top-right`, `bottom-left`, `bottom-right`
    :type origin: string
     '''

    @property
    def num_pixels(self):
        return self._num_pixels

    @num_pixels.setter
    def num_pixels(self, val):

        if isinstance(val,int):
            num_pixels_temp = [val, 1]
        else:
            try:
                length_val = len(val)
            except:
                raise TypeError('num_pixels expected int x or [int x, int y]. Got {}'.format(type(val)))


            if length_val == 2:
                try:
                    val0 = int(val[0])
                    val1 = int(val[1])
                except:
                    raise TypeError('num_pixels expected int x or [int x, int y]. Got {0},{1}'.format(type(val[0]), type(val[1])))

                num_pixels_temp = [val0, val1]
            else:
                raise ValueError('num_pixels expected int x or [int x, int y]. Got {}'.format(val))

        if num_pixels_temp[1] > 1 and self._dimension == 2:
            raise ValueError('2D acquisitions expects a 1D panel. Expected num_pixels[1] = 1. Got {}'.format(num_pixels_temp[1]))
        if num_pixels_temp[0] < 1 or num_pixels_temp[1] < 1:
            raise ValueError('num_pixels (x,y) must be >= (1,1). Got {}'.format(num_pixels_temp))
        else:
            self._num_pixels = numpy.array(num_pixels_temp, dtype=numpy.int16)

    @property
    def pixel_size(self):
        return self._pixel_size

    @pixel_size.setter
    def pixel_size(self, val):

        if val is None:
            pixel_size_temp = [1.0,1.0]
        else:
            try:
                length_val = len(val)
            except:
                try:
                    temp = float(val)
                    pixel_size_temp = [temp, temp]

                except:
                    raise TypeError('pixel_size expected float xy or [float x, float y]. Got {}'.format(val))
            else:
                if length_val == 2:
                    try:
                        temp0 = float(val[0])
                        temp1 = float(val[1])
                        pixel_size_temp = [temp0, temp1]
                    except:
                        raise ValueError('pixel_size expected float xy or [float x, float y]. Got {}'.format(val))
                else:
                    raise ValueError('pixel_size expected float xy or [float x, float y]. Got {}'.format(val))

            if pixel_size_temp[0] <= 0 or pixel_size_temp[1] <= 0:
                raise ValueError('pixel_size (x,y) at must be > (0.,0.). Got {}'.format(pixel_size_temp))

        self._pixel_size = numpy.array(pixel_size_temp)

    @property
    def origin(self):
        return self._origin

    @origin.setter
    def origin(self, val):
        allowed = ['top-left', 'top-right','bottom-left','bottom-right']
        if val in allowed:
            self._origin=val
        else:
            raise ValueError('origin expected one of {0}. Got {1}'.format(allowed, val))

    def __str__(self):
        repres = "Panel configuration:\n"
        repres += "\tNumber of pixels: {0}\n".format(self.num_pixels)
        repres += "\tPixel size: {0}\n".format(self.pixel_size)
        repres += "\tPixel origin: {0}\n".format(self.origin)
        return repres

    def __eq__(self, other):

        if not isinstance(other, self.__class__):
            return False

        if numpy.array_equal(self.num_pixels, other.num_pixels) \
            and numpy.allclose(self.pixel_size, other.pixel_size) \
            and self.origin == other.origin:
            return True

        return False

    def __init__ (self, num_pixels, pixel_size, origin, dimension):
        """Constructor method
        """
        self._dimension = dimension
        self.num_pixels = num_pixels
        self.pixel_size = pixel_size
        self.origin = origin


class Channels(object):
    r'''This is a class describing the channels of the data.
    This will be created on initialisation of AcquisitionGeometry.

    :param num_channels: The number of channels of data
    :type num_channels: int
    :param channel_labels: A list of channel labels
    :type channel_labels: list, optional
     '''

    @property
    def num_channels(self):
        return self._num_channels

    @num_channels.setter
    def num_channels(self, val):
        try:
            val = int(val)
        except TypeError:
            raise ValueError('num_channels expected a positive integer. Got {}'.format(type(val)))

        if val > 0:
            self._num_channels = val
        else:
            raise ValueError('num_channels expected a positive integer. Got {}'.format(val))

    @property
    def channel_labels(self):
        return self._channel_labels

    @channel_labels.setter
    def channel_labels(self, val):
        if val is None or len(val) == self._num_channels:
            self._channel_labels = val
        else:
            raise ValueError('labels expected to have length {0}. Got {1}'.format(self._num_channels, len(val)))

    def __str__(self):
        repres = "Channel configuration:\n"
        repres += "\tNumber of channels: {0}\n".format(self.num_channels)

        num_print=min(10,self.num_channels)
        if  hasattr(self, 'channel_labels'):
            repres += "\tChannel labels 0-{0}: {1}\n".format(num_print, self.channel_labels[0:num_print])

        return repres

    def __eq__(self, other):

        if not isinstance(other, self.__class__):
            return False

        if self.num_channels != other.num_channels:
            return False

        if hasattr(self,'channel_labels'):
            if self.channel_labels != other.channel_labels:
                return False

        return True

    def __init__ (self, num_channels, channel_labels):
        """Constructor method
        """
        self.num_channels = num_channels
        if channel_labels is not None:
            self.channel_labels = channel_labels


class Angles(object):
    r'''This is a class describing the angles of the data.

    :param angles: The angular positions of the acquisition data
    :type angles: list, ndarray
    :param initial_angle: The angular offset of the object from the reference frame
    :type initial_angle: float, optional
    :param angle_unit: The units of the stored angles 'degree' or 'radian'
    :type angle_unit: string
     '''

    @property
    def angle_data(self):
        return self._angle_data

    @angle_data.setter
    def angle_data(self, val):
        if val is None:
            raise ValueError('angle_data expected to be a list of floats')
        else:
            try:
                self.num_positions = len(val)

            except TypeError:
                self.num_positions = 1
                val = [val]

            finally:
                try:
                    self._angle_data = numpy.asarray(val, dtype=numpy.float32)
                except:
                    raise ValueError('angle_data expected to be a list of floats')

    @property
    def initial_angle(self):
        return self._initial_angle

    @initial_angle.setter
    def initial_angle(self, val):
        try:
            val = float(val)
        except:
            raise TypeError('initial_angle expected a float. Got {0}'.format(type(val)))

        self._initial_angle = val

    @property
    def angle_unit(self):
        return self._angle_unit.value

    @angle_unit.setter
    def angle_unit(self,val):
        self._angle_unit = AngleUnit(val)

    def __str__(self):
        repres = "Acquisition description:\n"
        repres += "\tNumber of positions: {0}\n".format(self.num_positions)
        # max_num_print = 30
        if self.num_positions < 31:
            repres += "\tAngles 0-{0} in {1}s: {2}\n".format(self.num_positions-1, self.angle_unit, numpy.array2string(self.angle_data[0:self.num_positions], separator=', '))
        else:
            repres += "\tAngles 0-9 in {0}s: {1}\n".format(self.angle_unit, numpy.array2string(self.angle_data[0:10], separator=', '))
            repres += "\tAngles {0}-{1} in {2}s: {3}\n".format(self.num_positions-10, self.num_positions-1, self.angle_unit, numpy.array2string(self.angle_data[self.num_positions-10:self.num_positions], separator=', '))
            repres += "\tFull angular array can be accessed with acquisition_data.geometry.angles\n"
        return repres

    def __eq__(self, other):

        if not isinstance(other, self.__class__):
            return False

        if self.angle_unit != other.angle_unit:
            return False

        if self.initial_angle != other.initial_angle:
            return False

        if not numpy.allclose(self.angle_data, other.angle_data):
            return False

        return True

    def __init__ (self, angles, initial_angle, angle_unit):
        """Constructor method
        """
        self.angle_data = angles
        self.initial_angle = initial_angle
        self.angle_unit = angle_unit


class Configuration(object):
    r'''This class holds the description of the system components.
     '''

    def __init__(self, units_distance='units distance'):
        self.system = None #has distances
        self.angles = None #has angles
        self.panel = None #has distances
        self.channels = Channels(1, None)
        self.units = units_distance

    @property
    def configured(self):
        if self.system is None:
            print("Please configure AcquisitionGeometry using one of the following methods:\
                    \n\tAcquisitionGeometry.create_Parallel2D()\
                    \n\tAcquisitionGeometry.create_Cone3D()\
                    \n\tAcquisitionGeometry.create_Parallel2D()\
                    \n\tAcquisitionGeometry.create_Cone3D()\
                    \n\tAcquisitionGeometry.create_Cone3D_Flex()")
            return False

        configured = True

        # cone_flex geometry does not use angles as it uses per-projection geometries
        if self.angles is None and self.system.geometry != "cone_flex":
            print("Please configure angular data using the set_angles() method")
            configured = False
        if self.panel is None:
            print("Please configure the panel using the set_panel() method")
            configured = False
        return configured

    def shift_detector_in_plane(self,
                                          pixel_offset,
                                          direction='horizontal'):
        """
        Adjusts the position of the detector in a specified direction within the imaging plane.

        Parameters:
        -----------
        pixel_offset : float
            The number of pixels to adjust the detector's position by.
        direction : {'horizontal', 'vertical'}, optional
            The direction in which to adjust the detector's position. Defaults to 'horizontal'.

        Notes:
        ------
        - If `direction` is 'horizontal':
            - If the panel's origin is 'left', positive offsets translate the detector to the right.
            - If the panel's origin is 'right', positive offsets translate the detector to the left.

        - If `direction` is 'vertical':
            - If the panel's origin is 'bottom', positive offsets translate the detector upward.
            - If the panel's origin is 'top', positive offsets translate the detector downward.

        Returns:
        --------
        None
        """

        if direction == 'horizontal':
            pixel_size = self.panel.pixel_size[0]
            pixel_direction = self.system.detector.direction_x

        elif direction == 'vertical':
            pixel_size = self.panel.pixel_size[1]
            pixel_direction = self.system.detector.direction_y

        if 'bottom' in self.panel.origin or 'left' in self.panel.origin:
            self.system.detector.position -= pixel_offset * pixel_direction * pixel_size
        else:
            self.system.detector.position += pixel_offset * pixel_direction * pixel_size


    def __str__(self):
        repres = ""
        if self.configured:
            repres += str(self.system)
            repres += str(self.panel)
            repres += str(self.channels)
            if self.angles is not None:
                repres += str(self.angles)

            repres += "Distances in units: {}".format(self.units)

        return repres

    def __eq__(self, other):

        if not isinstance(other, self.__class__):
            return False

        if self.system == other.system\
        and self.panel == other.panel\
        and self.channels == other.channels\
        and self.angles == other.angles:
            return True

        return False


class BackwardCompat(type):
    @property
    def ANGLE(cls):
        warnings.warn("use AcquisitionDimension.Angle instead", DeprecationWarning, stacklevel=2)
        return AcquisitionDimension.ANGLE

    @property
    def CHANNEL(cls):
        warnings.warn("use AcquisitionDimension.Channel instead", DeprecationWarning, stacklevel=2)
        return AcquisitionDimension.CHANNEL

    @property
    def DEGREE(cls):
        warnings.warn("use AngleUnit.DEGREE", DeprecationWarning, stacklevel=2)
        return AngleUnit.DEGREE

    @property
    def HORIZONTAL(cls):
        warnings.warn("use AcquisitionDimension.HORIZONTAL instead", DeprecationWarning, stacklevel=2)
        return AcquisitionDimension.HORIZONTAL

    @property
    def RADIAN(cls):
        warnings.warn("use AngleUnit.RADIAN instead", DeprecationWarning, stacklevel=2)
        return AngleUnit.RADIAN

    @property
    def VERTICAL(cls):
        warnings.warn("use AcquisitionDimension.VERTICAL instead", DeprecationWarning, stacklevel=2)
        return AcquisitionDimension.VERTICAL


class AcquisitionGeometry(metaclass=BackwardCompat):
    """This class holds the AcquisitionGeometry of the system.

    Please initialise the AcquisitionGeometry using the static methods:

    `AcquisitionGeometry.create_Parallel2D()`

    `AcquisitionGeometry.create_Cone2D()`

    `AcquisitionGeometry.create_Parallel3D()`

    `AcquisitionGeometry.create_Cone3D()`

    `AcquisitionGeometry.create_Cone3D_Flex()`
    """
    @property
    def geom_type(self):
        return self.config.system.geometry

    @property
    def num_projections(self):
        if self.geom_type & AcquisitionType.CONE_FLEX:
            return self.config.system.num_positions
        else:
            return len(self.angles)

    @property
    def pixel_num_h(self):
        return self.config.panel.num_pixels[0]

    @pixel_num_h.setter
    def pixel_num_h(self, val):
        self.config.panel.num_pixels[0] = val

    @property
    def pixel_num_v(self):
        return self.config.panel.num_pixels[1]

    @pixel_num_v.setter
    def pixel_num_v(self, val):
        self.config.panel.num_pixels[1] = val

    @property
    def pixel_size_h(self):
        return self.config.panel.pixel_size[0]

    @pixel_size_h.setter
    def pixel_size_h(self, val):
        self.config.panel.pixel_size[0] = val

    @property
    def pixel_size_v(self):
        return self.config.panel.pixel_size[1]

    @pixel_size_v.setter
    def pixel_size_v(self, val):
        self.config.panel.pixel_size[1] = val

    @property
    def channels(self):
        return self.config.channels.num_channels

    @property
    def angles(self):
        if self.geom_type & AcquisitionType.CONE_FLEX:
            return None
        else:
            return self.config.angles.angle_data

    @property
    def dist_source_center(self):
        """
        Calculate and return the distance from the source to the center of the volume.

        For `Cone3D_Flex` geometry, this method returns a NumPy ndarray with the distances 
        for each position. For other geometries, it returns a single float value.

        Returns:
            float or numpy.ndarray: The distance from the source to the center or an array of distances.
            
        """
        out = self.config.system.calculate_magnification()
        return out[0]
 
    @property
    def dist_center_detector(self):
        """
        Calculate and return the distance from the center of the volume to the detector.

        For `Cone3D_Flex` geometry, this method returns a NumPy ndarray with the distances 
        for each position. For other geometries, it returns a single float value.

        Returns:
            float or numpy.ndarray: The distance from the centre of the volume to the center or an array of distances.
        """
        out = self.config.system.calculate_magnification()
        return out[1]

    @property
    def magnification(self):
        """
        Calculate and return the magnification of the system.

        For `Cone3D_Flex` geometry, this method returns a NumPy ndarray with the distances 
        for each position. For other geometries, it returns a single float value.

        Returns:
            float or numpy.ndarray: The magnification factor or an array of magnifications.
        """
        out = self.config.system.calculate_magnification()
        return out[2]

    @property
    def dimension(self):
        return self.config.system.dimension

    @property
    def shape(self):
        if self.config.system.geometry != "cone_flex":
            shape_dict = {AcquisitionDimension.CHANNEL: self.config.channels.num_channels,
                        AcquisitionDimension.ANGLE: self.config.angles.num_positions,
                        AcquisitionDimension.VERTICAL: self.config.panel.num_pixels[1],
                        AcquisitionDimension.HORIZONTAL: self.config.panel.num_pixels[0]}
        # We are using per-projection geometry, not angles
        else:
            shape_dict = {AcquisitionDimension.CHANNEL: self.config.channels.num_channels,
                        AcquisitionDimension.PROJECTION: self.config.system.num_positions,
                        AcquisitionDimension.VERTICAL: self.config.panel.num_pixels[1],
                        AcquisitionDimension.HORIZONTAL: self.config.panel.num_pixels[0]}

        
        return tuple(shape_dict[label] for label in self.dimension_labels)
       
    @property
    def dimension_labels(self):

        labels_default = list(AcquisitionDimension.get_order_for_engine("cil"))
        if self.config.system.geometry & AcquisitionType.CONE_FLEX:
            labels_default[labels_default.index(AcquisitionDimension.ANGLE)] = AcquisitionDimension.PROJECTION

        shape_default = [self.config.channels.num_channels,
                        self.num_projections,
                        self.config.panel.num_pixels[1],
                        self.config.panel.num_pixels[0]]

        try:
            labels = self._dimension_labels
        except AttributeError:
            labels = labels_default
        labels = list(labels)

        #remove from list labels where len == 1
        for i, x in enumerate(shape_default):
            if x == 0 or x==1:
                try:
                    labels.remove(labels_default[i])
                except ValueError:
                    pass #if not in custom list carry on

        return tuple(labels)

    @dimension_labels.setter
    def dimension_labels(self, val):
        if val is not None:
            if self.config.system.geometry & AcquisitionType.CONE_FLEX:
                if AcquisitionDimension.ANGLE in val:
                    raise ValueError("Cannot set dimension_labels with AcquisitionDimension.ANGLE for Cone3D_Flex geometry. Use PROJECTION instead.")
            elif AcquisitionDimension.PROJECTION in val:
                raise ValueError("Cannot set dimension_labels with AcquisitionDimension.PROJECTION for geometries that use angles. Use ANGLE instead.")
                
            self._dimension_labels = tuple(map(AcquisitionDimension, val))

    @property
    def ndim(self):
        return len(self.dimension_labels)

    @property
    def system_description(self):
        return self.config.system.system_description()

    @property
    def dtype(self):
        return self._dtype

    @dtype.setter
    def dtype(self, val):
        self._dtype = val


    def __init__(self):
        self._dtype = numpy.float32


    def get_centre_of_rotation(self, distance_units='default', angle_units='radian'):
        """
        Returns the system centre of rotation offset at the detector

        Note
        ----
         - Origin is in the centre of the detector
         - Axes directions are specified by detector.direction_x, detector.direction_y

        Parameters
        ----------
        distance_units : string, default='default'
            Units of distance used to calculate the return values.
            'default' uses the same units the system and panel were specified in.
            'pixels' uses pixels sizes in the horizontal and vertical directions as appropriate.
        angle_units : string
            Units to return the angle in. Can take 'radian' or 'degree'.

        Returns
        -------
        Dictionary
            {'offset': (offset, distance_units), 'angle': (angle, angle_units)}
            where,
            'offset' gives the position along the detector x_axis at y=0
            'angle' gives the angle between the y_axis and the projection of the rotation axis on the detector
        """

        if hasattr(self.config.system, 'calculate_centre_of_rotation'):
            offset_distance, angle_rad = self.config.system.calculate_centre_of_rotation()
        else:
            raise TypeError("Cannot get centre of rotation for this geometry. Please use a geometry that supports this method.")

        if distance_units == 'default':
            offset = offset_distance
            offset_units = self.config.units
        elif distance_units == 'pixels':

            offset = offset_distance/ self.config.panel.pixel_size[0]
            offset_units = 'pixels'

            if AcquisitionType.DIM3 & self.dimension and self.config.panel.pixel_size[0] != self.config.panel.pixel_size[1]:
                #if aspect ratio of pixels isn't 1:1 need to convert angle by new ratio
                y_pix = 1 /self.config.panel.pixel_size[1]
                x_pix = math.tan(angle_rad)/self.config.panel.pixel_size[0]
                angle_rad = math.atan2(x_pix,y_pix)
        else:
            raise ValueError("`distance_units` is not recognised. Must be 'default' or 'pixels'. Got {}".format(distance_units))

        angle_units = AngleUnit(angle_units)

        angle = angle_rad
        if angle_units == AngleUnit.DEGREE:
            angle = numpy.degrees(angle_rad)

        return {'offset': (offset, offset_units), 'angle': (angle, angle_units.value)}


    def set_centre_of_rotation(self, offset=0.0, distance_units='default', angle=0.0, angle_units='radian'):
        """
        Configures the system geometry to have the requested centre of rotation offset at the detector.

        Note
        ----
         - Origin is in the centre of the detector
         - Axes directions are specified by detector.direction_x, detector.direction_y

        Parameters
        ----------
        offset: float, default 0.0
            The position of the centre of rotation along the detector x_axis at y=0

        distance_units : string, default='default'
            Units the offset is specified in. Can be 'default'or 'pixels'.
            'default' interprets the input as same units the system and panel were specified in.
            'pixels' interprets the input in horizontal pixels.

        angle: float, default=0.0
            The angle between the detector y_axis and the rotation axis direction on the detector

            Notes
            -----
            If aspect ratio of pixels is not 1:1 ensure the angle is calculated from the x and y values in the correct units.

        angle_units : string, default='radian'
            Units the angle is specified in. Can take 'radian' or 'degree'.

        """

        if not hasattr(self.config.system, 'set_centre_of_rotation'):
            raise TypeError("Cannot set centre of rotation for this geometry. Please use a geometry that supports this method.")

        angle_units = AngleUnit(angle_units)

        angle_rad = angle
        if angle_units == AngleUnit.DEGREE:
            angle_rad = numpy.radians(angle)

        if distance_units =='default':
            offset_distance = offset
        elif distance_units =='pixels':
            offset_distance = offset * self.config.panel.pixel_size[0]
        else:
            raise ValueError("`distance_units` is not recognised. Must be 'default' or 'pixels'. Got {}".format(distance_units))

        if AcquisitionType.DIM2 & self.dimension:
            self.config.system.set_centre_of_rotation(offset_distance)
        else:
            self.config.system.set_centre_of_rotation(offset_distance, angle_rad)


    def set_centre_of_rotation_by_slice(self, offset1, slice_index1=None, offset2=None, slice_index2=None):
        """
        Configures the system geometry to have the requested centre of rotation offset at the detector.

        If two slices are passed the rotation axis will be rotated to pass through both points.

        Note
        ----
         - Offset is specified in pixels
         - Offset can be sub-pixels
         - Offset direction is specified by detector.direction_x

        Parameters
        ----------
        offset1: float
            The offset from the centre of the detector to the projected rotation position at slice_index_1

        slice_index1: int, optional
            The slice number of offset1

        offset2: float, optional
            The offset from the centre of the detector to the projected rotation position at slice_index_2

        slice_index2: int, optional
            The slice number of offset2
        """


        if not hasattr(self.config.system, 'set_centre_of_rotation'):
            raise TypeError("Cannot set centre of rotation for this geometry. Please use a geometry that supports this method.")

        if AcquisitionType.DIM2 & self.dimension:
            if offset2 is not None:
                warnings.warn("2D so offset2 is ignored", UserWarning, stacklevel=2)
            self.set_centre_of_rotation(offset1)

        if offset2 is None or offset1 == offset2:
            offset_x_y0 = offset1
            angle = 0
        else:
            if slice_index1 is None or slice_index2 is None or slice_index1 == slice_index2:
                raise ValueError("Cannot calculate angle. Please specify `slice_index1` and `slice_index2` to define a rotated axis")

            offset_x_y0 = offset1 -slice_index1 * (offset2 - offset1)/(slice_index2-slice_index1)
            angle = math.atan2(offset2 - offset1, slice_index2 - slice_index1)

        self.set_centre_of_rotation(offset_x_y0, 'pixels', angle, 'radian')


    def set_angles(self, angles, initial_angle=0, angle_unit='degree'):
        r'''This method configures the angular information of an AcquisitionGeometry object.

        :param angles: The angular positions of the acquisition data
        :type angles: list, ndarray
        :param initial_angle: The angular offset of the object from the reference frame
        :type initial_angle: float, optional
        :param angle_unit: The units of the stored angles 'degree' or 'radian'
        :type angle_unit: string
        :return: returns a configured AcquisitionGeometry object
        :rtype: AcquisitionGeometry
        '''
        if AcquisitionType.CONE_FLEX & self.geom_type:
            warnings.warn("Angles cannot be set for Cone3D_Flex geometry.", UserWarning, stacklevel=2)
        else:
            self.config.angles = Angles(angles, initial_angle, angle_unit)
        return self

    def set_panel(self, num_pixels, pixel_size=(1,1), origin='bottom-left'):

        r'''This method configures the panel information of an AcquisitionGeometry object.

        :param num_pixels: num_pixels_h or (num_pixels_h, num_pixels_v) containing the number of pixels of the panel
        :type num_pixels: int, list, tuple
        :param pixel_size: pixel_size_h or (pixel_size_h, pixel_size_v) containing the size of the pixels of the panel
        :type pixel_size: int, list, tuple, optional
        :param origin: the position of pixel 0 (the data origin) of the panel 'top-left', 'top-right', 'bottom-left', 'bottom-right'
        :type origin: string, default 'bottom-left'
        :return: returns a configured AcquisitionGeometry object
        :rtype: AcquisitionGeometry
        '''
        dof = {AcquisitionType.DIM2: 2, AcquisitionType.DIM3: 3}[self.config.system.dimension]
        self.config.panel = Panel(num_pixels, pixel_size, origin, dof)
        return self

    def set_channels(self, num_channels=1, channel_labels=None):
        r'''This method configures the channel information of an AcquisitionGeometry object.

        :param num_channels: The number of channels of data
        :type num_channels: int, optional
        :param channel_labels: A list of channel labels
        :type channel_labels: list, optional
        :return: returns a configured AcquisitionGeometry object
        :rtype: AcquisitionGeometry
        '''
        self.config.channels = Channels(num_channels, channel_labels)
        return self

    def set_labels(self, labels=None):
        r'''This method configures the dimension labels of an AcquisitionGeometry object.

        Parameters
        ----------

        labels:  list of strings, optional
            The order of the dimensions describing the data.
            For most geometries expects a list containing at least one of the unique labels: 'channel', 'angle', 'vertical', 'horizontal'.
            default = ['channel','angle','vertical','horizontal']
            For Cone3D_Flex geometries expects a list containing at least one of the unique labels: 'channel', 'projection', 'vertical', 'horizontal'.
            default = ['channel', 'projection', 'vertical', 'horizontal']

        Returns
        --------
        self: AcquisitionGeometry
            a configured AcquisitionGeometry object
        '''
        self.dimension_labels = labels
        return self

    @staticmethod
    def create_Parallel2D(ray_direction=[0, 1], detector_position=[0, 0], detector_direction_x=[1, 0], rotation_axis_position=[0, 0], units='units distance'):
        """
        Creates the AcquisitionGeometry for a parallel beam 2D tomographic system.

        After creating the AcquisitionGeometry object, the panel must be set using the `set_panel()` method and the angles must be set using the `set_angles()` method.
        In addition, `set_channels()` can be used to set the number of channels and their labels. `set_labels()` can be used to set the order of the dimensions describing the data.

        See notes for default directions.

        Parameters
        ----------
        ray_direction : array_like, optional
            A 2D vector describing the x-ray direction (x,y). Default is [0,1].
        detector_position : array_like, optional
            A 2D vector describing the position of the centre of the detector (x,y). Default is [0,0].
        detector_direction_x : array_like, optional
            A 2D vector describing the direction of the detector_x (x,y). Default is [1,0].
        rotation_axis_position : array_like, optional
            A 2D vector describing the position of the axis of rotation (x,y). Default is [0,0].
        units : str
            Label the units of distance used for the configuration. These should be consistent for the geometry and panel.

        Returns
        -------
        AcquisitionGeometry
            An AcquisitionGeometry object.

        Note
        ----
        The geometry is configured with default directions. The geometry can be customised but ensure that the directions are consistent.

        The default position of the detector is [0,0] which means that the detector is assumed to be at the origin. This represents a virtual detector, and may not be the actual position of the detector in a real system.

        The default direction of the detector_x is [1,0]. This means that the detector is assumed to be pointing in the x direction.

        The rotation axis position is [0,0] which means that the rotation axis is assumed to be at the origin.

        The default direction of the ray is [0,1] which means that the ray is assumed to be pointing in the y direction.
        """
        AG = AcquisitionGeometry()
        AG.config = Configuration(units)
        AG.config.system = Parallel2D(ray_direction, detector_position, detector_direction_x, rotation_axis_position, units)
        return AG

    @staticmethod
    def create_Cone2D(source_position, detector_position, detector_direction_x=[1,0], rotation_axis_position=[0,0], units='units distance'):
        """
        Creates the AcquisitionGeometry for a cone beam 2D tomographic system.

        After creating the AcquisitionGeometry object, the panel must be set using the `set_panel()` method and the angles must be set using the `set_angles()` method.
        In addition, `set_channels()` can be used to set the number of channels and their labels. `set_labels()` can be used to set the order of the dimensions describing the data.

        See notes for default directions.

        Parameters
        ----------
        source_position : array_like
            A 2D vector describing the position of the source (x,y). With the default directions, this should lie along the y-axis, see notes.
        detector_position : array_like
            A 2D vector describing the position of the centre of the detector (x,y). With the default directions, this should lie along the y-axis, see notes.
        detector_direction_x : array_like, optional
            A 2D vector describing the direction of the detector_x (x,y). Default is [1,0].
        rotation_axis_position : array_like, optional
            A 2D vector describing the position of the axis of rotation (x,y). Default is [0,0].
        units : str
            Label the units of distance used for the configuration. These should be consistent for the geometry and panel.

        Returns
        -------
        AcquisitionGeometry
            An AcquisitionGeometry object.

        Note
        ----
        The geometry is configured with default directions. The geometry can be customised but ensure that the directions are consistent.

        The default direction of the detector_x is [1,0]. This means that the detector is assumed to be pointing in the x direction.

        The rotation axis position is [0,0] which means that the rotation axis is assumed to be at the origin.

        If these values are left as default then the source and detector should lie along the y-axis such as `(0,-100)`.
        """
        AG = AcquisitionGeometry()
        AG.config = Configuration(units)
        AG.config.system = Cone2D(source_position, detector_position, detector_direction_x, rotation_axis_position, units)
        return AG

    @staticmethod
    def create_Parallel3D(ray_direction=[0,1,0], detector_position=[0,0,0], detector_direction_x=[1,0,0], detector_direction_y=[0,0,1], rotation_axis_position=[0,0,0], rotation_axis_direction=[0,0,1], units='units distance'):
        """
        Creates the AcquisitionGeometry for a parallel beam 3D tomographic system.

        After creating the AcquisitionGeometry object, the panel must be set using the `set_panel()` method and the angles must be set using the `set_angles()` method.
        In addition, `set_channels()` can be used to set the number of channels and their labels. `set_labels()` can be used to set the order of the dimensions describing the data.

        Parameters
        ----------
        ray_direction : array_like, optional
            A 3D vector describing the x-ray direction (x,y,z). Default is [0,1,0].
        detector_position : array_like, optional
            A 3D vector describing the position of the centre of the detector (x,y,z). Default is [0,0,0].
        detector_direction_x : array_like, optional
            A 3D vector describing the direction of the detector_x (x,y,z). Default is [1,0,0].
        detector_direction_y : array_like, optional
            A 3D vector describing the direction of the detector_y (x,y,z). Default is [0,0,1].
        rotation_axis_position : array_like, optional
            A 3D vector describing the position of the axis of rotation (x,y,z). Default is [0,0,0].
        rotation_axis_direction : array_like, optional
            A 3D vector describing the direction of the axis of rotation (x,y,z). Default is [0,0,1].
        units : str
            Label the units of distance used for the configuration. These should be consistent for the geometry and panel.

        Returns
        -------
        AcquisitionGeometry
            An AcquisitionGeometry object.

        Notes
        -----
        The geometry is configured with default directions. The geometry can be customised but ensure that the directions are consistent.

        The default position of the detector is [0,0,0] which means that the detector is assumed to be at the origin. This represents a virtual detector, and may not be the actual position of the detector in a real system.

        The default direction of the detector_x is [1,0,0] and the default direction of the detector_y [0,0,1]. This means that the detector 
        is assumed to be in the x-z plane with the detector_x direction pointing in the x direction and the detector_y direction pointing in the z direction.

        The rotation axis position is [0,0,0] which means that the rotation axis is assumed to be at the origin. 
        The rotation axis direction is [0,0,1] which means that the rotation axis is assumed to be pointing in the z direction.

        The default direction of the ray is [0,1,0] which means that the ray is assumed to be pointing in the y direction.
        """
        AG = AcquisitionGeometry()
        AG.config = Configuration(units)
        AG.config.system = Parallel3D(ray_direction, detector_position, detector_direction_x, detector_direction_y, rotation_axis_position, rotation_axis_direction, units)
        return AG

    @staticmethod
    def create_Cone3D(source_position, detector_position, detector_direction_x=[1,0,0], detector_direction_y=[0,0,1], rotation_axis_position=[0,0,0], rotation_axis_direction=[0,0,1], units='units distance'):
        """
        Creates the AcquisitionGeometry for a cone beam 3D tomographic system.

        After creating the AcquisitionGeometry object, the panel must be set using the `set_panel()` method and the angles must be set using the `set_angles()` method.
        In addition, `set_channels()` can be used to set the number of channels and their labels. `set_labels()` can be used to set the order of the dimensions describing the data.

        Parameters
        ----------
        source_position : array_like
            A 3D vector describing the position of the source (x,y,z). With the default directions, this should lie along the y-axis, see notes.
        detector_position : array_like
            A 3D vector describing the position of the centre of the detector (x,y,z). With the default directions, this should lie along the y-axis, see notes.
        detector_direction_x : array_like, optional
            A 3D vector describing the direction of the detector_x (x,y,z). Default is [1,0,0].
        detector_direction_y : array_like, optional
            A 3D vector describing the direction of the detector_y (x,y,z). Default is [0,0,1].
        rotation_axis_position : array_like, optional
            A 3D vector describing the position of the axis of rotation (x,y,z). Default is [0,0,0].
        rotation_axis_direction : array_like, optional
            A 3D vector describing the direction of the axis of rotation (x,y,z). Default is [0,0,1].
        units : str
            Label the units of distance used for the configuration. These should be consistent for the geometry and panel.

        Returns
        -------
        AcquisitionGeometry
            An AcquisitionGeometry object.

        Note
        ----
        The geometry is configured with default directions. The geometry can be customised but ensure that the directions are consistent.

        The default direction of the detector_x is [1,0,0] and the default direction of the detector_y [0,0,1]. This means that the detector 
        is assumed to be in the x-z plane with the detector_x direction pointing in the x direction and the detector_y direction pointing in the z direction.

        The rotation axis position is [0,0,0] which means that the rotation axis is assumed to be at the origin. 
        The rotation axis direction is [0,0,1] which means that the rotation axis is assumed to be pointing in the z direction.

        If these values are left as default then the source and detector should lie along the y-axis such as `(0,-100,0)`.
        """
        AG = AcquisitionGeometry()
        AG.config = Configuration(units)
        AG.config.system = Cone3D(source_position, detector_position, detector_direction_x, detector_direction_y, rotation_axis_position, rotation_axis_direction, units)
        return AG

    @staticmethod
    def create_Cone3D_Flex(source_position_set, detector_position_set, detector_direction_x_set, detector_direction_y_set, volume_centre_position=[0,0,0], units='units distance'):
        """
        Creates the AcquisitionGeometry for a per-projection cone beam 3D tomographic system.

        After creating the AcquisitionGeometry object, the panel must be set using the `set_panel()` method.
        In addition, `set_channels()` can be used to set the number of channels and their labels. `set_labels()` can be used to set the order of the dimensions describing the data.

        Parameters
        ----------
        source_position_set : array_like
            N 3D vectors describing the position of the source (x,y,z).
        detector_position_set : array_like
            N 3D vectors describing the position of the centre of the detector (x,y,z).
        detector_direction_x_set : array_like
            N 3D vectors describing the direction of the detector_x (x,y,z).
        detector_direction_y_set : array_like
            N 3D vectors describing the direction of the detector_y (x,y,z).
        volume_centre_position : array_like, optional
            A 3D vector describing the position of the centre of the reconstructed volume (x,y,z). Default is [0,0,0].
        units : str
            Label the units of distance used for the configuration. These should be consistent for the geometry and panel.

        Returns
        -------
        AcquisitionGeometry
            An AcquisitionGeometry object.

        """

        AG = AcquisitionGeometry()
        AG.config = Configuration(units)
        AG.config.system = Cone3D_Flex(source_position_set, detector_position_set, detector_direction_x_set, detector_direction_y_set, volume_centre_position, units)
        return AG

    def get_order_by_label(self, dimension_labels, default_dimension_labels):
        order = []
        for i, el in enumerate(default_dimension_labels):
            for j, ek in enumerate(dimension_labels):
                if el == ek:
                    order.append(j)
                    break
        return order

    def __eq__(self, other):

        if isinstance(other, self.__class__) \
            and self.config == other.config \
            and self.dtype == other.dtype \
            and self.dimension_labels == other.dimension_labels:
            return True
        return False

    def clone(self):
        '''returns a copy of the AcquisitionGeometry'''
        return copy.deepcopy(self)

    def copy(self):
        '''alias of clone'''
        return self.clone()

    def get_centre_slice(self):
        '''returns a 2D AcquisitionGeometry that corresponds to the centre slice of the input'''

        if AcquisitionType.DIM2 & self.dimension:
            return self

        AG_2D = copy.deepcopy(self)
        AG_2D.config.system = self.config.system.get_centre_slice()
        AG_2D.config.panel.num_pixels[1] = 1
        AG_2D.config.panel.pixel_size[1] = abs(self.config.system.detector.direction_y[2]) * self.config.panel.pixel_size[1]
        return AG_2D

    def get_ImageGeometry(self, resolution=1.0):
        """
        Returns an ImageGeometry object that corresponds to the AcquisitionGeometry object. The voxel numbers are calculated from 
        the resolution and the pixel sizes of the panel. The voxel sizes are calculated from the resolution and the magnification of the system.
        
        Parameters
        ----------
        resolution : float, optional
            The resolution of the ImageGeometry object. Defaults to 1.0.

        Returns
        -------
        ImageGeometry
            The ImageGeometry object that corresponds to the AcquisitionGeometry object.

        Note
        ----
        For Cone3D_Flex geometries, you must create an ImageGeometry yourself, there is no automatic creation.
        """

        if self.config.system.geometry & AcquisitionType.CONE_FLEX:
            raise TypeError("Cannot create ImageGeometry for this geometry. Please use a geometry that supports this method.")

        mag = self.magnification

        num_voxel_xy = int(numpy.ceil(self.config.panel.num_pixels[0] * resolution))
        voxel_size_xy = self.config.panel.pixel_size[0] / (resolution * mag)

        if AcquisitionType.DIM3 & self.dimension:
            num_voxel_z = int(numpy.ceil(self.config.panel.num_pixels[1] * resolution))
            voxel_size_z = self.config.panel.pixel_size[1] / (resolution * mag)
        else:
            num_voxel_z = 0
            voxel_size_z = 1

        return ImageGeometry(num_voxel_xy, num_voxel_xy, num_voxel_z, voxel_size_xy, voxel_size_xy, voxel_size_z, channels=self.channels)

    def __str__ (self):
        return str(self.config)

    def _get_slice(self, **kwargs):
        '''
        get_slice for geometry with 'angle' dimension label
        '''
        geometry_new = self.copy()

        if kwargs.get("channel", None) is not None:
            geometry_new.config.channels.num_channels = 1
            if hasattr(geometry_new.config.channels,'channel_labels'):
                geometry_new.config.panel.channel_labels = geometry_new.config.panel.channel_labels[kwargs.get("channel")]

        if kwargs.get("angle", None) is not None:
            geometry_new.config.angles.angle_data = geometry_new.config.angles.angle_data[kwargs.get("angle")]

        if kwargs.get("projection", None)  is not None:
            geometry_new.config.system.num_positions = 1
            geometry_new.config.system.source = [self.config.system.source[kwargs.get("projection", None)]]
            geometry_new.config.system.detector = [self.config.system.detector[kwargs.get("projection", None)]]

        if kwargs.get("vertical", None) is not None:
            if AcquisitionType.PARALLEL & geometry_new.geom_type:
                geometry_new = geometry_new.get_centre_slice()
            elif AcquisitionType.CONE:
                if kwargs.get("vertical") == 'centre' or abs(geometry_new.pixel_num_v/2 - kwargs.get("vertical")) < 1e-6:
                    geometry_new = geometry_new.get_centre_slice()
                else:
                    raise ValueError("Cannot return an off-centre vertical slice for this geometry. Expected vertical = 'centre' or None. Got vertical = {0}".format(kwargs.get("vertical")))
            else:
                raise ValueError("Cannot return vertical slice for this geometry. Expected vertical = None. Got vertical = {0}".format(kwargs.get("vertical")))

        if kwargs.get("horizontal", None)  is not None:
            raise ValueError("Cannot return horizontal slice for this geometry. Expected horizontal = None. Got horizontal = {0}".format(kwargs.get("horizontal")))


        return geometry_new

        
    def get_slice(self, *args, **kwargs):
        '''
        Returns a new AcquisitionGeometry of a single slice in the requested direction.

        Parameters
        ----------
        channel: int, optional
            index on channel dimension to slice on. If None, does not slice on this dimension.
        angle/projection: int, optional
            index on angle or projection dimension to slice on. Dimension label depends on the geometry type:
            For CONE_FLEX geometry, use 'projection'.
            For all other geometries, use 'angle'.
        vertical: int, str, optional
            If int, index on vertical dimension to slice on. If str, can be 'centre' to return the slice at the center of the vertical dimension.
        horizontal: int, optional
            index on horizontal dimension to slice on. If None, does not slice on this dimension.

        Returns
        -------
        AcquisitionGeometry
            A new AcquisitionGeometry object containing the requested slice.

        Notes
        -----
        If there is an even number of slices in the vertical dimension and 'centre' is requested, the slice returned will be the average of the two middle slices.
        '''

        for key in kwargs:
            if self.geom_type == AcquisitionType.CONE_FLEX and key == 'angle':
                raise TypeError(f"Cannot use 'angle' for Cone3D_Flex geometry. Use 'projection' instead.")
            elif self.geom_type != AcquisitionType.CONE_FLEX and key == 'projection':
                raise TypeError(f"Cannot use 'projection' for geometries that use angles. Use 'angle' instead.")
            elif key not in AcquisitionDimension:
                raise TypeError(f"'{key}' not in allowed labels {AcquisitionDimension}.")
            
        if args:
            warnings.warn("Positional arguments for get_slice are deprecated. Use keyword arguments instead.", DeprecationWarning, stacklevel=2)
            
            num_args = len(args)

            if num_args > 0:
                kwargs['channel'] = args[0]

            if num_args > 1:
                if self.geom_type & AcquisitionType.CONE_FLEX:
                    kwargs['projection'] = args[1]
                else:
                    kwargs['angle'] = args[1]

            if num_args > 2:
                kwargs['vertical'] = args[2]

            if num_args > 3:
                kwargs['horizontal'] = args[3]

        return self._get_slice(**kwargs)
    

    def allocate(self, value=0, **kwargs):
        '''Allocates an AcquisitionData according to the geometry
        
        Parameters
        ----------
        value : number or string, default=0
            The value to allocate. Accepts a number to allocate a uniform array, 
            None to allocate an empty memory block, or a string to create a random 
            array: 'random' allocates floats between 0 and 1, 'random_int' by default 
            allocates integers between 0 and 100  or between provided `min_value` and 
            `max_value`
        
        **kwargs:
            dtype : numpy data type, optional
                The data type to allocate if different from the geometry data type. 
                Default None allocates an array with the geometry data type.

            seed : int, optional
                A random seed to fix reproducibility, only used if `value` is a random
                method. Default is `None`.

            min_value : int, optional
                The minimum value random integer to generate, only used if `value` 
                is 'random_int'. New since version 25.0.0. Default is 0.
            
            max_value : int, optional
                The maximum value random integer to generate, only used if `value` 
                is 'random_int'. Default is 100.

        Note
        ----
            Since v25.0.0 the methods used by 'random' or 'random_int' use `numpy.random.default_rng`. 
            This method does not use the global numpy.random.seed() so if a seed is 
            required it should be passed directly as a kwarg.
            To allocate random numbers using the earlier behaviour use `value='random_deprecated'` 
            or `value='random_int_deprecated'` 

        '''
        dtype = kwargs.pop('dtype', self.dtype)
        
        out = AcquisitionData(geometry=self.copy(), dtype=dtype)
        if value is not None:
            out.fill(value, **kwargs)

        return out

