diff --git a/src/cr/cube/crunch_cube.py b/src/cr/cube/crunch_cube.py index 4de36e796..265080232 100644 --- a/src/cr/cube/crunch_cube.py +++ b/src/cr/cube/crunch_cube.py @@ -568,22 +568,6 @@ def dim_types(self): def ndim(self): return len(self.dimensions) - @lazyproperty - def row_dim_ind(self): - """ - Index of the row dimension in the cube - :rtype: int - """ - return 0 if self.ndim < 3 else 1 - - @lazyproperty - def col_dim_ind(self): - """ - Index of the column dimension in the cube - :rtype: int - """ - return 1 if self.ndim < 3 else 2 - @lazyproperty def ind_selected(self): return self._get_mr_slice() diff --git a/src/cr/cube/cube_slice.py b/src/cr/cube/cube_slice.py index 85074d024..c31d703ea 100644 --- a/src/cr/cube/cube_slice.py +++ b/src/cr/cube/cube_slice.py @@ -1,10 +1,10 @@ '''Home of the CubeSlice class.''' +from functools import partial import numpy as np -from .utils import lazyproperty - +# pylint: disable=too-few-public-methods class CubeSlice(object): '''Implementation of CubeSlice class. @@ -13,10 +13,32 @@ class CubeSlice(object): achieved by slicing. ''' + row_dim_ind = 0 + col_dim_ind = 1 + def __init__(self, cube, index): self._cube = cube self._index = index + def __getattr__(self, attr): + cube_attr = getattr(self._cube, attr) + + # API Method calls + if callable(cube_attr): + return partial(self._call_cube_method, attr) + + # API properties + get_only_last_two = ( + self._cube.ndim == 3 and + hasattr(cube_attr, '__len__') and + attr != 'name' + ) + if get_only_last_two: + return cube_attr[-2:] + + # If not defined on self._cube, return CubeSlice properties + return cube_attr + def _update_args(self, kwargs): if self.ndim < 3: @@ -25,134 +47,106 @@ def _update_args(self, kwargs): # pass them to the underlying cube (which is the slice). return kwargs + # Handling API methods that include 'axis' parameter + axis = kwargs.get('axis') - if axis is None or isinstance(axis, tuple): - # If no axis was passed, we don't need to update anything. If axis - # was a tuple, it means that the caller was expecting a 3D cube. - return kwargs + # Expected usage of the 'axis' parameter from CubeSlice is 0, 1, or + # None. CrunchCube handles all other logic. The only 'smart' thing + # about the handling here, is that the axes are increased for 3D cubes. + # This way the 3Dness is hidden from the user and he still sees 2D + # crosstabs, with col and row axes (0 and 1), which are transformed to + # corresponding numbers in case of 3D cubes (namely 1 and 2). In the + # case of None, we need to analyze across all valid dimensions, and the + # CrunchCube takes care of that (no need to update axis if it's None). + # If the user provides a tuple, it's considered that he "knows" what + # he's doing, and the axis argument is not updated in this case. + if isinstance(axis, int): + kwargs['axis'] += 1 + + # Handling API methods that include H&S parameter + + # For most cr.cube methods, we use the 'include_transforms_for_dims' + # parameter name. For some, namely the prune_indices, we use the + # 'transforms'. These are parameters that tell to the cr.cube "which + # dimensions to include the H&S for". The only point of this parameter + # (from the perspective of the cr.exporter) is to exclude the 0th + # dimension's H&S in the case of 3D cubes. + hs_dims_key = ( + 'transforms' + if 'transforms' in kwargs else + 'include_transforms_for_dims' + ) + hs_dims = kwargs.get(hs_dims_key) + if isinstance(hs_dims, list): + # Keep the 2D illusion for the user. If a user sees a 2D slice, he + # still needs to be able to address both dimensions (for which he + # wants the H&S included) as 0 and 1. Since these are offset by a 0 + # dimension in a 3D case, inside the cr.cube, we need to increase + # the indexes of the required dims. + kwargs[hs_dims_key] = [dim + 1 for dim in hs_dims] - kwargs['axis'] += 1 return kwargs def _update_result(self, result): if self.ndim < 3 or len(result) - 1 < self._index: return result result = result[self._index] - if not isinstance(result, np.ndarray): + if isinstance(result, tuple): + return np.array(result) + elif not isinstance(result, np.ndarray): result = np.array([result]) return result def _call_cube_method(self, method, *args, **kwargs): kwargs = self._update_args(kwargs) result = getattr(self._cube, method)(*args, **kwargs) + if method in ('labels', 'inserted_hs_indices'): + return result[-2:] return self._update_result(result) - # Properties - - @lazyproperty - def ndim(self): - '''Get number of dimensions.''' - return self._cube.ndim - - @lazyproperty - def name(self): + @property + def table_name(self): '''Get slice name. In case of 2D return cube name. In case of 3D, return the combination of the cube name with the label of the corresponding slice (nth label of the 0th dimension). ''' - title = self._cube.name - if self.ndim < 3: - return title + return None + title = self._cube.name table_name = self._cube.labels()[0][self._index] return '%s: %s' % (title, table_name) - @lazyproperty - def rows_title(self): - '''Get title of the rows dimension. - - For 3D it's the 1st dimension (0th dimension of the current slice). - ''' - return self._cube.dimensions[1].name - - @lazyproperty - def inserted_rows_indices(self): - ''' Get correct inserted rows indices for the corresponding slice. - - For 3D cubes, a list of tuples is returned from the cube, when invoking - the inserted_hs_indices method. The job of this property is to fetch - the correct tuple (the one corresponding to the current slice index), - and return the 0th value (the one corresponding to the rows). - ''' - return self._cube.inserted_hs_indices()[self._index][0] - - @lazyproperty - def has_means(self): - '''Get has_means from cube.''' - return self._cube.has_means - - @lazyproperty - def dimensions(self): - '''Get slice dimensions. + @property + def has_ca(self): + '''Check if the cube slice has the CA dimension. - For 2D cubes just get their dimensions. For 3D cubes, don't get the - first dimension (only take slice dimensions). + This is used to distinguish between slices that are considered 'normal' + (like CAT x CAT), that might be a part of te 3D cube that has 0th dim + as the CA items (subvars). In such a case, we still need to process + the slices 'normally', and not address the CA items constraints. ''' - return self._cube.dimensions[-2:] - - # API Methods - - def labels(self, *args, **kwargs): - '''Return correct labels for slice.''' - return self._cube.labels(*args, **kwargs)[-2:] - - def prune_indices(self, *args, **kwargs): - '''Extract correct row/col prune indices from 3D cube.''' - if self.ndim < 3: - return self._cube.prune_indices(*args, **kwargs) - return list(self._cube.prune_indices(*args, **kwargs)[self._index]) - - def as_array(self, *args, **kwargs): - '''Call cube's as_array, and return correct slice.''' - return self._call_cube_method('as_array', *args, **kwargs) - - def proportions(self, *args, **kwargs): - '''Call cube's proportions, and return correct slice.''' - return self._call_cube_method('proportions', *args, **kwargs) - - def margin(self, *args, **kwargs): - '''Call cube's margin, and return correct slice.''' - return self._call_cube_method('margin', *args, **kwargs) - - def population_counts(self, *args, **kwargs): - '''Get population counts.''' - return self._call_cube_method('population_counts', *args, **kwargs) - - @lazyproperty - def standardized_residuals(self): - '''Get cube's standardized residuals.''' - return self._cube.standardized_residuals - - def index(self, *args, **kwargs): - '''Get index.''' - return self._call_cube_method('index', *args, **kwargs) - - def zscore(self, *args, **kwargs): - '''Get index.''' - return self._call_cube_method('zscore', *args, **kwargs) - - @lazyproperty - def dim_types(self): - '''Get dimension types of the cube slice.''' - return self._cube.dim_types[-2:] - - def pvals(self, *args, **kwargs): - '''Get pvals of the cube.''' - return self._call_cube_method('pvals', *args, **kwargs) - - def inserted_hs_indices(self, *args, **kwargs): - '''Get inserted H&S indices.''' - return self._cube.inserted_hs_indices(*args, **kwargs)[-2:] + return 'categorical_array' in self.dim_types + + @property + def mr_dim_ind(self): + '''Get the correct index of the MR dimension in the cube slice.''' + mr_dim_ind = self._cube.mr_dim_ind + if self.ndim == 3: + if isinstance(mr_dim_ind, int): + return mr_dim_ind - 1 + elif isinstance(mr_dim_ind, tuple): + return tuple(i - 1 for i in mr_dim_ind) + + return mr_dim_ind + + @property + def ca_main_axis(self): + '''For univariate CA, the main axis is the categorical axis''' + ca_ind = self.dim_types.index('categorical_array') + if ca_ind is not None: + return 1 - ca_ind + else: + return None diff --git a/tests/unit/test_cube_slice.py b/tests/unit/test_cube_slice.py index 16e136c1a..c17cb1d9d 100644 --- a/tests/unit/test_cube_slice.py +++ b/tests/unit/test_cube_slice.py @@ -1,7 +1,7 @@ '''Unit tests for the CubeSlice class.''' from unittest import TestCase from mock import Mock -from mock import patch +import numpy as np from cr.cube.cube_slice import CubeSlice @@ -20,14 +20,11 @@ def test_init(self): def test_ndim_invokes_ndim_from_cube(self): '''Test if ndim calls corresponding cube's method.''' - fake_ndim = Mock() - cube = Mock() - cube.ndim = fake_ndim - + cube = Mock(ndim=3) cs = CubeSlice(cube, 1) - assert cs.ndim == fake_ndim + assert cs.ndim == 3 - def test_name(self): + def test_table_name(self): '''Test correct name is returned. In case of 2D return cube name. In case of 3D, return the combination @@ -47,9 +44,9 @@ def test_name(self): cube.labels.return_value = fake_labels cube.ndim = 3 cs = CubeSlice(cube, 1) - assert cs.name == 'Cube Title: Analysis Slice XY' + assert cs.table_name == 'Cube Title: Analysis Slice XY' + assert cs.name == 'Cube Title' - @patch('cr.cube.cube_slice.CubeSlice.ndim', 3) def test_proportions(self): '''Test that proportions method delegetes its call to CrunchCube. @@ -58,6 +55,7 @@ def test_proportions(self): for row and column directions. ''' cube = Mock() + cube.ndim = 3 array = [Mock(), Mock(), Mock()] cube.proportions.return_value = array @@ -71,7 +69,6 @@ def test_proportions(self): cs = CubeSlice(cube, index=1) assert cs.proportions() == array[1] - @patch('cr.cube.cube_slice.CubeSlice.ndim', 3) def test_margin(self): '''Test that margin method delegetes its call to CrunchCube. @@ -80,6 +77,7 @@ def test_margin(self): for row and column directions. ''' cube = Mock() + cube.ndim = 3 array = [Mock(), Mock(), Mock()] cube.margin.return_value = array cs = CubeSlice(cube, 1) @@ -92,7 +90,6 @@ def test_margin(self): # Assert correct slice is returned when index is set assert cs.margin() == array[1] - @patch('cr.cube.cube_slice.CubeSlice.ndim', 3) def test_as_array(self): '''Test that as_array method delegetes its call to CrunchCube. @@ -102,6 +99,7 @@ def test_as_array(self): correct slice needs to be returned. ''' cube = Mock() + cube.ndim = 3 array = [Mock(), Mock(), Mock()] cube.as_array.return_value = array @@ -116,43 +114,26 @@ def test_as_array(self): cs = CubeSlice(cube, index=1) assert cs.as_array() == array[1] - def test_rows_title(self): - '''Assert correct rows title is returned.''' - cube = Mock() - cube.ndim = 3 - cube.dimensions = [Mock(), Mock(), Mock()] - cube.dimensions[1].name = '1st Dimension Name' - cs = CubeSlice(cube, 1) - assert cs.rows_title == '1st Dimension Name' - - def test_inserted_rows_indices(self): - '''Assert correct inserted row indices are returned. - ''' - fake_indices = Mock() - cube = Mock() - cube.inserted_hs_indices.return_value = [ - Mock(), (fake_indices, Mock()), Mock(), - ] - cs = CubeSlice(cube, 1) - assert cs.inserted_rows_indices == fake_indices - def test_cube_slice_labels(self): '''Test correct labels are returned for row and col dimensions.''' cube = Mock() + cube.ndim = 3 all_labels = [Mock(), Mock(), Mock()] cube.labels.return_value = all_labels cs = CubeSlice(cube, 1) assert cs.labels() == all_labels[-2:] - @patch('cr.cube.cube_slice.CubeSlice.ndim', 3) def test_prune_indices(self): '''Assert that correct prune indices are extracted from 3D cube.''' cube = Mock() + cube.ndim = 3 all_prune_inds = [Mock(), (1, 2), Mock()] cube.prune_indices.return_value = all_prune_inds cs = CubeSlice(cube, 1) # Assert extracted indices tuple is converted to list - assert cs.prune_indices() == [1, 2] + actual = cs.prune_indices() + expected = np.array([1, 2]) + np.testing.assert_array_equal(actual, expected) def test_has_means(self): '''Test that has_means invokes same method on CrunchCube.''' @@ -161,3 +142,13 @@ def test_has_means(self): cube.has_means = expected actual = CubeSlice(cube, 1).has_means assert actual == expected + + def test_dim_types(self): + '''Test only last 2 dim types are returned.''' + cube = Mock() + all_dim_types = [Mock(), Mock(), Mock()] + expected = all_dim_types[-2:] + cube.dim_types = all_dim_types + cube.ndim = 3 + actual = CubeSlice(cube, 0).dim_types + assert actual == expected