<gh_stars>0
import os
import unittest

import numpy as np

try:
    import mock
except ModuleNotFoundError:
    from unittest import mock

from ddt import data, unpack, ddt

from .. import models
from .. import io
from . import DATA_DIR


@ddt
class TestCODA(unittest.TestCase):

    def setUp(self):
        self.inst = inst = models.CODA()

        characteristics = []
        self.values = values = 1, 10, 2, 7.5, 0.3
        for x in values:
            char = mock.Mock()
            char.value = x
            characteristics.append(char)

        inst._characteristics = tuple(characteristics)

        requirements = []
        self.weights = weights = 0.2, 0.1, 0.4, 0.3
        for wt in weights:
            reqt = mock.Mock()
            reqt.weight = wt
            requirements.append(reqt)

        inst._requirements = tuple(requirements)

        correlation = np.array([[0.1, 0.0, 0.9, 0.3, 0.1],
                                [0.0, 0.9, 0.3, 0.1, 0.1],
                                [0.9, 0.3, 0.1, 0.1, 0.0],
                                [0.3, 0.1, 0.1, 0.0, 0.9]])
        self.correlation = correlation

        # Dummy merit values (these would normally be a fraction).
        self.merit = np.array([[24, 85, 78, 17,  5],
                               [99,  7,  3, 88, 13],
                               [41, 63, 52, 17, 31],
                               [51, 95, 53, 60, 36]])

        class MockRelationship(object):
            merit_preset = None
            def __call__(self, x):
                return self._merit_preset

        for	i in range(correlation.shape[0]):
            for j in range(correlation.shape[1]):
                relationship = MockRelationship()
                relationship.correlation = correlation[i,j]
                relationship._merit_preset = self.merit[i,j]
                inst.matrix[i,j] = relationship

    # ----------------------------------------------------------------
    # Test properties
    # ----------------------------------------------------------------
    def test_matrix__unset(self):
        """Matrix should match shape and contain CODANull by default.
        """
        temp_inst = models.CODA()
        self.assertEqual(temp_inst.matrix.shape, (0, 0))

        self.assertEqual(self.inst.matrix.shape, (4, 5))

        temp_inst._requirements += (object(),)
        self.assertEqual(temp_inst.matrix.shape, (1, 0))

        for i, j in zip(*map(range, temp_inst.matrix.shape)):
            self.assertIsInstance(temp_inst.matrix[i,j], models.CODANull)

    def test_characteristics__default(self):
        """Should be an empty tuple by default."""
        temp_inst = models.CODA()
        self.assertIsInstance(temp_inst.characteristics, tuple)
        self.assertEqual(len(temp_inst.characteristics), 0.0)
        self.assertEqual(len(self.inst.requirements), 4)

    def test_correlation(self):
        """Property converts correlation values in array to a matrix.

        Each design relationship models a correlation between a
        requirement and a characteristic parameter. This  should
        therefore be the same dimensions as the overall coda model,
        i.e. (n, m) where n is the number of requirements, and m the
        number of characteristics.
        """
        self.assertIsInstance(self.inst.correlation, np.ndarray)
        self.assertEqual(self.inst.correlation.ndim, 2)
        self.assertEqual(self.inst.correlation.shape, self.inst.shape)
        self.assertTrue((self.inst.correlation==self.correlation).all())

    @mock.patch.object(models.CODA, 'satisfaction',
                       new_callable=mock.PropertyMock)
    def test_merit(self, patch):
        """Sum total of weighted requirement satisfaction."""
        patch.return_value = np.arange(5)
        self.assertAlmostEqual(self.inst.merit, 10)

    def test_parameter_value(self):
        """A row vector containing characteristic parameter values.

        Characteristics are considered to be columns in the underlying
        coda matrix, so characterstic parameter values should reflect
        this to be unambiguous.
        """
        self.assertIsInstance(self.inst.parameter_value, np.ndarray)
        self.assertEqual(self.inst.parameter_value.shape,
                         (1, self.inst.shape[1]))
        self.assertTrue((self.inst.parameter_value==self.values).all())

    @data(
        (np.array([2.0, 10, 2, 7.5, 0.3]), None),
        (np.array([[2.0, 10, 2, 7.5, 0.3]]), None),
        (np.array([[2.0, 10, 2, 7.5, 0.3]]).T, None),
        ([2.0, 10, 2, 7.5, 0.3], None),
        (tuple([2.0, 10, 2, 7.5, 0.3]), None),
        (set([2.0, 10, 2, 7.5, 0.3]), ValueError),
        ([2.0, 10, 2, 7.5], ValueError),
    )
    @unpack
    def test_parameter_value__set(self, value, exception):
        self.assertEqual(self.inst.parameter_value[0,0], 1.0)
        if exception is not None:
            self.assertRaises(exception, setattr, self.inst,
                              'parameter_value', value)
        else:
            setattr(self.inst, 'parameter_value', value)
            self.assertEqual(self.inst.parameter_value[0,0], 2.0)

    def test_requirements__default(self):
        """Should be an empty tuple by default."""
        temp_inst = models.CODA()
        self.assertIsInstance(temp_inst.characteristics, tuple)
        self.assertEqual(len(temp_inst.characteristics), 0.0)
        self.assertEqual(len(self.inst.requirements), 4)

    @mock.patch.object(models.CODA, '_merit')
    @mock.patch.object(models.CODA, 'correlation',
                       new_callable=mock.PropertyMock)
    def test_satisfaction(self, *mocks):
        """Weighted requirement satisfactions.

        This is the merit of each characteristic parameter value for
        each requirement, weighted by correlation factors.

        .. math:
            \frac{\sum_{j=1}^{M} cf .* \eta}{{scf}_i}

        Where

            i = [1..n]
            j = [1..m]

        and

            n = number of requirements
            m = number of characteristics
        """
        correlation, merit = mocks

        a = np.random.rand(3,2)
        correlation.return_value = merit.return_value = a

        # numerator
        num = np.multiply(a, a).sum(axis=1, keepdims=True)

        # denominator
        den = a.sum(axis=1, keepdims=True)

        expected = np.divide(num, den)

        self.assertIsInstance(self.inst.satisfaction, np.ndarray)
        self.assertEqual(self.inst.satisfaction.shape, (3, 1))
        np.testing.assert_array_almost_equal(self.inst.satisfaction,
                                             expected)

    def test_shape(self):
        """Reflects the number of characteristics & requirements.

        A CODA model involves n requirements and m characteristics,
        modelled as an (n, m) array/matrix.
        """
        self.assertEqual(self.inst.shape, (4, 5))

    def test_weight(self):
        """A column vector containing requirement weightings.

        Requirements are considered to be rows in the underlying
        coda matrix, so requirement weights should reflect this to be
        unambiguous.
        """
        self.assertIsInstance(self.inst.weight, np.ndarray)
        self.assertEqual(self.inst.weight.shape,
                         (self.inst.shape[0], 1))
        # Note we must transpose the weight column vector to compare
        # it properly with the simple input weights tuple because of
        # numpy broadcasting producing a boolean matrix.
        self.assertTrue((self.inst.weight.T==self.weights).all())

    # ----------------------------------------------------------------
    # Test methods
    # ----------------------------------------------------------------
    @data(
        #[('Irrelevant requirement', 0.0, ValueError),], # not enforced
        [('Unimportant requirement', 0.1, None),],
        [('Important requirement', 0.9, None),],
        [('Unimportant requirement', 0.1, None),
         ('Important requirement', 0.9, None),],
        [('Sole requirement', 1.0, None),],
        [('Sole requirement', 1.0, None),
         ('Another requirement', 0.1, RuntimeError)],
    )
    def test_add_requirement__prenormalised(self, reqts):
        inst = models.CODA()
        i = 0
        for (name, normwt, exception) in reqts:
            if exception is None:
                inst.add_requirement(name, normwt, normalise=False)
                i += 1
                self.assertEqual(len(inst.requirements), i)
                self.assertEqual(inst.requirements[i-1].name, name)
                self.assertEqual(inst.requirements[i-1].weight,
                                 normwt)
            else:
                self.assertRaises(exception, inst.add_requirement,
                                  name, normwt, normalise=False)

    @data(
        (1.0, 1.0),
        (1.0, 1.0, 1.0, 1.0),
        (0.1, 0.2, 0.3, 0.4)
    )
    def test_add_requirement__unnormalised(self, weights):
        inst = models.CODA()
        for i, wt in enumerate(weights):
            inst.add_requirement('Blah'+str(i), wt, normalise=True)

        self.assertAlmostEqual(
            sum([r.weight for r in inst.requirements]),
            1.0
        )

    @data(
        [('Characteristic', 0.0, 1.0, None, None),],
        [('Characteristic', 0.0, 1.0, 1.0, None),
         ('Another characteristic', -1.0, 11.0, None, None),],
    )
    def test_add_characteristic(self, chars):
        inst = models.CODA()
        i = 0
        for (name, llim, ulim, value, exception) in chars:
            if exception is None:
                inst.add_characteristic(name, (llim, ulim), value)
                i += 1
                self.assertEqual(len(inst.characteristics), i)
                self.assertEqual(inst.characteristics[i-1].name, name)
                self.assertEqual(inst.characteristics[i-1].limits,
                                 (llim, ulim))
                # Value not set in these test data.
                #self.assertEqual(inst.characteristics[i-1].value,
                #				 value)
            else:
                self.assertRaises(exception, inst.add_characteristic,
                                  name, normwt)

    @data(
        [(0, 0, 'max', 0.1, 1.0, None, None),],
        [(0, 0, 'min', 0.1, 1.0, None, None),],
        [(0, 0, 'opt', 0.1, 1.0, 1.0, None),],
        [(0, 5, 'opt', 0.1, 1.0, 1.0, KeyError),],
        [(0, 0, 'max', 0.1, 1.0, None, None),
         (0, 1, 'max', 0.1, 1.0, None, None),],
    )
    def test_add_relationship(self, rels):
        inst = self.inst
        for (r, c, type_, corr, tv, tol, exception) in rels:
            if type_ == 'opt':
                cls = models.CODAOptimise
                args = (r, c, type_, corr, tv, tol)
            else:
                args = (r, c, type_, corr, tv, tol)
                if type_ == 'max':
                    cls = models.CODAMaximise
                else:
                    cls = models.CODAMinimise

            if exception is None:
                inst.add_relationship(*args)
            else:
                self.assertRaises(exception, inst.add_relationship,
                                  *args)
                continue

            self.assertIsInstance(inst.matrix[r,c], cls)
            self.assertEqual(inst.matrix[r,c].correlation, corr)

    @data(
        ['Requirement0', 0, None],
        [0, 'Characteristic0', None],
        ['requirement0', 0, KeyError], # Case-sensitive for now.
        ['Requirement2', 0, KeyError], # Not present.
        ['Requirement0', 'Characteristic0', None],
        ['Requirement1', 'Characteristic0', None],
        [1, 'Characteristic0', None],
    )
    @unpack
    def test_add_relationship__by_name(self, rlkup, clkup, exception):
        """Given two requirements, 1 characteristic - add relations.
        """
        inst = models.CODA()

        mock1 = mock.Mock()
        mock1.name = 'Requirement0'
        mock3 = mock.Mock()
        mock3.name = 'Requirement1'
        inst._requirements = (mock1,mock3)

        mock2 = mock.Mock()
        mock2.name = 'Characteristic0'
        inst._characteristics = (mock2,)

        if exception is None:
            inst.add_relationship(rlkup, clkup, 'max', 1.0, 1.0)
            r = rlkup if isinstance(rlkup, int) else int(rlkup[-1])
            self.assertIsInstance(inst.matrix[r,0], models.CODAMaximise)
        else:
            self.assertRaises(exception, inst.add_relationship,
                              rlkup, clkup, 'max', 1.0, 1.0)

    @mock.patch.object(models.CODA, 'add_relationship')
    @mock.patch.object(models.CODA, 'add_characteristic')
    @mock.patch.object(models.CODA, 'add_requirement')
    def test_read_excel(self, mock_add_requirement,
                        mock_add_characteristic,
                        mock_add_relationship):
        """Constructor adds elements in turn from the parser.

        The parser provides three methods:

          - get_requirements
          - get_characteristics
          - get_relationships

        These all return records defined within io.CODASheet.

        The constructor calls these methods on the parser and uses the
        results to feed arguments to the add_requirement,
        add_characteristic and add_relationship methods on the CODA
        class.

        This unit test mocks the parser and ensures the known return
        values for these get methods are passed to the add methods in
        the correct fashion.
        """
        dummy_records = {
            'requirements': [
                io.CODASheet.ReqRecord('Requirement 1', 0.33),
                io.CODASheet.ReqRecord('Requirement 2', 0.5),
                io.CODASheet.ReqRecord('Requirement 3', 0.17),
            ],
            'characteristics': [
                io.CODASheet.CDefRecord('Characteristic 1', 1, 5),
                io.CODASheet.CDefRecord('Characteristic 2', 10, 20),
            ],
            'relationships': [
                io.CODASheet.MinMaxRelRecord(
                    'Requirement 1',
                    'Characteristic 1',
                    'min',
                    '---', # TODO: Remove redundant information
                    3
                ),
                io.CODASheet.OptRelRecord(
                    'Requirement 2',
                    'Characteristic 1',
                    'opt',
                    'ooo',
                    13,
                    1,
                ),
                io.CODASheet.MinMaxRelRecord(
                    'Requirement 3',
                    'Characteristic 2',
                    'max',
                    '+++',
                    3
                ),
            ]
        }

        stub_parser = mock.MagicMock(spec_set=io.CompactExcelParser)
        for s in 'requirements', 'characteristics', 'relationships':
            method = getattr(stub_parser, 'get_{}'.format(s))
            method.return_value = dummy_records[s]
        mock_parser_class = mock.Mock()
        mock_parser_class.return_value = stub_parser

        sut = models.CODA.read_excel('/dummy/path',
                                     parser_class=mock_parser_class)

        mock_parser_class.assert_called_once_with('/dummy/path')
        mock_add_requirement.assert_has_calls([
            mock.call('Requirement 1', 0.33),
            mock.call('Requirement 2', 0.5),
            mock.call('Requirement 3', 0.17),
        ])
        mock_add_characteristic.assert_has_calls([
            mock.call('Characteristic 1', (1, 5)),
            mock.call('Characteristic 2', (10, 20)),
        ])
        mock_add_relationship.assert_has_calls([
            mock.call('Requirement 1', 'Characteristic 1', 'min',
                      '---', 3),
            mock.call('Requirement 2', 'Characteristic 1', 'opt',
                      'ooo', 13, 1),
            mock.call('Requirement 3', 'Characteristic 2', 'max',
                      '+++', 3),
        ])

    def test__merit(self):
        """Returns a matrix of merit values for design relationships.

        Each design relationship is a model providing a fractional
        decimal value representing the degree to which a requirement
        is satisfied by a given characteristic parameter value. This
        should therefore be the same dimensions as the overall coda
        model, i.e. (n, m) where n is the number of requirements, and
        m the number of characteristics.

        "Internal" method because raw merit values are not considered
        particularly useful on their own at this point.
        """
        self.assertIsInstance(self.inst._merit(), np.ndarray)
        self.assertEqual(self.inst._merit().ndim, 2)
        self.assertEqual(self.inst._merit().shape, self.inst.shape)
        self.assertTrue((self.inst._merit()==self.merit).all())


class TestCODACaseStudy1(unittest.TestCase):
    """Case study of a bicycle wheel design based on ref 1."""

    def setUp(self):
        wheel = self.wheel = models.CODA()
        self._setup_requirements()
        self._setup_characteristics()
        self._setup_relationships()

    def _setup_requirements(self):
        wheel = self.wheel
        for name in ('Stiffness', 'Friction', 'Weight',
                     'Manufacturability', 'Repairability'):
            wheel.add_requirement(name, 0.2)

    def _setup_characteristics(self):
        wheel = self.wheel
        wheel.add_characteristic('Tyre Diameter', (24, 29), 24)
        wheel.add_characteristic('Tyre Width', (11, 18), 13)
        wheel.add_characteristic('Spoke Thickness', (2.8, 5), 4.3)
        wheel.add_characteristic('Use of Composites', (0.05, 0.8),
                                 0.2)

    def _setup_relationships(self):
        wheel = self.wheel

        reqt = 'Stiffness'
        wheel.add_relationship(reqt, 0, 'min', 'strong', 29)
        wheel.add_relationship(reqt, 1, 'max', 'moderate', 12)
        wheel.add_relationship(reqt, 2, 'max', 'strong', 3)
        wheel.add_relationship(reqt, 3, 'opt', 'moderate', 0.5, 0.2)

        reqt = 'Friction'
        wheel.add_relationship(reqt, 'Tyre Diameter', 'max',
                               'moderate', 25)
        wheel.add_relationship(reqt, 'Tyre Width', 'max', 'strong',
                               11)

        reqt = 'Weight'
        wheel.add_relationship(reqt, 'Tyre Diameter', 'min', 'strong',
                               26)
        wheel.add_relationship(reqt, 'Tyre Width', 'min', 'strong',
                               15)
        wheel.add_relationship(reqt, 'Spoke Thickness', 'min',
                               'moderate', 3.5)
        wheel.add_relationship(reqt, 'Use of Composites', 'max',
                               'strong', 0.3)

        reqt = 'Manufacturability'
        wheel.add_relationship(reqt, 'Tyre Width', 'max', 'weak', 12)
        wheel.add_relationship(reqt, 'Spoke Thickness', 'max',
                               'moderate', 2.9)
        wheel.add_relationship(reqt, 'Use of Composites', 'min',
                               'strong', 0.5)

        reqt = 'Repairability'
        wheel.add_relationship(reqt, 'Tyre Width', 'max', 'weak', 14)
        wheel.add_relationship(reqt, 'Spoke Thickness', 'max',
                               'moderate', 3.8)
        wheel.add_relationship(reqt, 'Use of Composites', 'min',
                               'strong', 0.25)

    def test_merit(self):
        self.assertAlmostEqual(self.wheel.merit, .5788, places=4)

    def test_sum_of_correlations(self):
        """Sum of correlation factors for all requirements."""
        np.testing.assert_array_almost_equal(
            self.wheel.correlation.sum(axis=1, keepdims=True),
            np.array([[2.4, 1.2, 3.0, 1.3, 1.3]]).T
        )

    def test_read_excel(self):
        try:
            import pandas
            import xlrd
        except ImportError:
            self.skipTest("`pandas` and `xlrd` required for "
                          "spreadsheet parsing")
        model = models.CODA.read_excel(
            os.path.join(DATA_DIR, 'demo_model_casestudy1.xlsx')
        )

        for char, ref in zip(model.characteristics,
                             self.wheel.characteristics):
            char.value = ref.value
        self.assertEqual(self.wheel.merit, model.merit)


@ddt
class TestCODACharacteristic(unittest.TestCase):

    def setUp(self):
        class CODACharacteristic(models.CODACharacteristic):
            def __init__(self):
                pass
        self.inst = CODACharacteristic()

    def test___init____omit_value(self):
        """Omitting the value on instantiation is valid.

        When modelling a set of designs (typical) we don't necessarily
        want to seed the model with characteristic values.
        """
        inst = models.CODACharacteristic('Name')
        # This might want to be None? Requires everything supporting
        # that as an input though.
        self.assertRaises(AttributeError, getattr, inst, 'value')

    @data(
        (-0.01, ValueError),
        (0.0, None),
        (0.5, None),
        (1.0, None),
        (1.01, ValueError),
    )
    @unpack
    def test_value__set_with_default_limits(self, value, exception):
        if exception is not None:
            self.assertRaises(exception, setattr, self.inst, 'value',
                              value)

        else:
            self.inst.value = value
            self.assertEqual(self.inst.value, value)

    def test_limits__get__default(self):
        self.assertEqual(self.inst.limits, self.inst._default_limits)

    @data((0.0, 1.0), [0.0, 1.0], (None, None), (0, None), (None, 1))
    def test_limits__set__valid(self, value):
        self.inst.limits = value
        self.assertEqual(self.inst.limits, tuple(value))


@ddt
class TestCODARequirement(unittest.TestCase):

    def setUp(self):
        class CODARequirement(models.CODARequirement):
            def __init__(self):
                pass
        self.inst = CODARequirement()

    @data((-0.01, False), (0.0, True), (0.5, True), (1.0, True),
          (1.1, False))
    @unpack
    def test_weight__set(self, wt, valid):
        # Prototypes used context to allow weights to be provided in a
        # non-normalised form and this property would handle the
        # normalisation by inspecting the weights of other
        # requirements. This functionality isn't implemented here, but
        # might still be useful.
        if not valid:
            self.assertRaises(ValueError, setattr, self.inst,
                              'weight', wt)
        else:
            self.inst.weight = wt
            self.assertEqual(self.inst.weight, wt)


@ddt
class TestCODARelationship(unittest.TestCase):

    def setUp(self):
        class Concrete(models.CODARelationship):
            def __init__(self):
                pass

            def __call__(self, x):
                return 0.0

        self.cls = Concrete
        self.inst = Concrete()

    @data([0.0, 0.0, True],
          [0.1, 0.1,  True],
          [0.3, 0.3, True],
          [0.9, 0.9, True],
          [1.0, 0.1, True],
          [0.25, None, False],
          [-0.1, None, False],
          [0, 0.0, True],
          [1, 0.1, True],
          [3, 0.3, True],
          [9, 0.9, True],
          ['none', 0.0, True],
          [None, 0.0, True],
          ['weak', 0.1, True],
          ['moderate', 0.3, True],
          ['medium', 0.3, True],
          ['strong', 0.9, True],
          ['---', 0.9, True],
          ['+++', 0.9, True],
          ['ooo', 0.9, True],
          ['--', 0.3, True],
          ['++', 0.3, True],
          ['oo', 0.3, True],
          ['o', 0.1, True],
          ['o', 0.1, True],
          ['o', 0.1, True],
    )
    @unpack
    def test_correlation(self, value, internal_value, valid):
        """Correlation value must be one of a restricted set."""
        # TODO: It might be more flexible to enforce this further up
        #		for different scaling systems. Could also be done with
        #		a mixin implementation implementation
        self.assertRaises(AttributeError, getattr, self.inst,
                          'correlation')
        if valid:
            self.inst.correlation = value
            self.assertEqual(self.inst.correlation, internal_value)
        else:
            self.assertRaises(ValueError, setattr, self.inst,
                              'correlation', value)

    def test_target(self):
        """Target value may be anything, but check it's settable."""
        self.assertRaises(AttributeError, getattr, self.inst,
                          'target')
        self.inst.target = 0.0
        self.assertEqual(self.inst.target, 0.0)


class TestCODANull(unittest.TestCase):

    def test___init__(self):
        """Takes no arguments, has a correlation and merit of zero."""
        null = models.CODANull()
        self.assertEqual(null.correlation, 0.0)
        self.assertIs(null.target, None)
        self.assertEqual(null(None), 0.0)

    def test__attributes_not_settable(self):
        null = models.CODANull()

        self.assertRaises(TypeError, setattr, null, 'correlation', 1)
        self.assertRaises(TypeError, setattr, null, 'target', 1)


class TestCODAMaximise(unittest.TestCase):

    # TODO: compare function over range.

    def test_merit(self):
        inst = models.CODAMaximise(target=1.0, correlation=None)
        self.assertAlmostEqual(inst(1.0), 0.5)
        self.assertLess(inst(0.1), 0.5)
        self.assertGreater(inst(2.0), 0.5)


class TestCODAMinimise(unittest.TestCase):

    # TODO: compare function over range.

    def test_merit(self):
        inst = models.CODAMinimise(target=1.0, correlation=None)
        self.assertAlmostEqual(inst(1.0), 0.5)
        self.assertGreater(inst(0.1), 0.5)
        self.assertLess(inst(2.0), 0.5)


class TestCODAOptimise(unittest.TestCase):

    # TODO: compare function over range.

    def test_merit(self):
        inst = models.CODAOptimise(target=1.0, correlation=None,
                                   tolerance=0.2)
        self.assertAlmostEqual(inst(0.8), 0.5)
        self.assertAlmostEqual(inst(1.2), 0.5)
        self.assertAlmostEqual(inst(1.0), 1.0)
        self.assertGreater(inst(1.1), 0.5)
        self.assertGreater(inst(0.9), 0.5)
        self.assertLess(inst(2.0), 0.5)
        self.assertLess(inst(0.0), 0.5)


if __name__ == '__main__':
    unittest.main()
