# ____________________________________________________________________________________
#
# Pyomo: Python Optimization Modeling Objects
# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC
# Under the terms of Contract DE-NA0003525 with National Technology and Engineering
# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this
# software.  This software is distributed under the 3-clause BSD License.
# ____________________________________________________________________________________

"""
Unit Tests for pyomo.dae.set_utils
"""

import os
from os.path import abspath, dirname

import pyomo.common.unittest as unittest

from pyomo.environ import (
    Block,
    Constraint,
    ConcreteModel,
    Var,
    Set,
    TransformationFactory,
)
from pyomo.dae import ContinuousSet, DerivativeVar
from pyomo.dae.set_utils import (
    is_explicitly_indexed_by,
    is_in_block_indexed_by,
    get_indices_of_projection,
    get_index_set_except,
    deactivate_model_at,
)

currdir = dirname(abspath(__file__)) + os.sep


def make_model():
    m = ConcreteModel()
    m.time = ContinuousSet(bounds=(0, 10))
    m.space = ContinuousSet(bounds=(0, 5))
    m.set1 = Set(initialize=['a', 'b', 'c'])
    m.set2 = Set(initialize=['d', 'e', 'f'])
    m.fs = Block()

    m.fs.v0 = Var(m.space, initialize=1)

    @m.fs.Block()
    def b1(b):
        b.v = Var(m.time, m.space, initialize=1)
        b.dv = DerivativeVar(b.v, wrt=m.time, initialize=0)

        b.con = Constraint(
            m.time, m.space, rule=lambda b, t, x: b.dv[t, x] == 7 - b.v[t, x]
        )
        # Inconsistent

        @b.Block(m.time)
        def b2(b, t):
            b.v = Var(initialize=2)

    @m.fs.Block(m.time, m.space)
    def b2(b, t, x):
        b.v = Var(m.set1, initialize=2)

        @b.Block(m.set1)
        def b3(b, c):
            b.v = Var(m.set2, initialize=3)

            @b.Constraint(m.set2)
            def con(b, s):
                return 5 * b.v[s] == m.fs.b2[m.time.first(), m.space.first()].v[c]
                # inconsistent

    @m.fs.Constraint(m.time)
    def con1(fs, t):
        return fs.b1.v[t, m.space.last()] == 5

    # Will be inconsistent

    @m.fs.Constraint(m.space)
    def con2(fs, x):
        return fs.b1.v[m.time.first(), x] == fs.v0[x]

    # will be consistent

    disc = TransformationFactory('dae.collocation')
    disc.apply_to(m, wrt=m.time, nfe=5, ncp=2, scheme='LAGRANGE-RADAU')
    disc.apply_to(m, wrt=m.space, nfe=5, ncp=2, scheme='LAGRANGE-RADAU')

    return m


class TestDaeSetUtils(unittest.TestCase):
    # Test explicit/implicit index detection functions
    def test_indexed_by(self):
        m = ConcreteModel()
        m.time = ContinuousSet(bounds=(0, 10))
        m.space = ContinuousSet(bounds=(0, 10))
        m.set = Set(initialize=['a', 'b', 'c'])
        m.set2 = Set(initialize=[('a', 1), ('b', 2)])
        m.v = Var()
        m.v1 = Var(m.time)
        m.v2 = Var(m.time, m.space)
        m.v3 = Var(m.set, m.space, m.time)
        m.v4 = Var(m.time, m.set2)
        m.v5 = Var(m.set2, m.time, m.space)

        @m.Block()
        def b(b):
            b.v = Var()
            b.v1 = Var(m.time)
            b.v2 = Var(m.time, m.space)
            b.v3 = Var(m.set, m.space, m.time)

        @m.Block(m.time)
        def b1(b):
            b.v = Var()
            b.v1 = Var(m.space)
            b.v2 = Var(m.space, m.set)

        @m.Block(m.time, m.space)
        def b2(b):
            b.v = Var()
            b.v1 = Var(m.set)

            @b.Block()
            def b(bl):
                bl.v = Var()
                bl.v1 = Var(m.set)
                bl.v2 = Var(m.time)

        @m.Block(m.set2, m.time)
        def b3(b):
            b.v = Var()
            b.v1 = Var(m.space)

            @b.Block(m.space)
            def b(bb):
                bb.v = Var(m.set)

        disc = TransformationFactory('dae.collocation')
        disc.apply_to(m, wrt=m.time, nfe=5, ncp=2, scheme='LAGRANGE-RADAU')
        disc.apply_to(m, wrt=m.space, nfe=5, ncp=2, scheme='LAGRANGE-RADAU')

        self.assertFalse(is_explicitly_indexed_by(m.v, m.time))
        self.assertTrue(is_explicitly_indexed_by(m.b.v2, m.space))
        self.assertTrue(is_explicitly_indexed_by(m.b.v3, m.time, m.space))

        self.assertFalse(is_in_block_indexed_by(m.v1, m.time))
        self.assertFalse(is_in_block_indexed_by(m.v2, m.set))
        self.assertTrue(is_in_block_indexed_by(m.b1[m.time[1]].v2, m.time))

        self.assertTrue(
            is_in_block_indexed_by(m.b2[m.time[1], m.space[1]].b.v1, m.time)
        )
        self.assertTrue(
            is_in_block_indexed_by(m.b2[m.time[1], m.space[1]].b.v2, m.time)
        )
        self.assertTrue(
            is_explicitly_indexed_by(m.b2[m.time[1], m.space[1]].b.v2, m.time)
        )
        self.assertFalse(
            is_in_block_indexed_by(m.b2[m.time[1], m.space[1]].b.v1, m.set)
        )

        self.assertFalse(
            is_in_block_indexed_by(
                m.b2[m.time[1], m.space[1]].b.v1,
                m.space,
                stop_at=m.b2[m.time[1], m.space[1]],
            )
        )

        # Explicit indexing with multi-dimensional set:
        self.assertTrue(is_explicitly_indexed_by(m.v4, m.time, m.set2))
        self.assertTrue(is_explicitly_indexed_by(m.v5, m.time, m.set2, m.space))

        # Implicit indexing with multi-dimensional set:
        self.assertTrue(is_in_block_indexed_by(m.b3['a', 1, m.time[1]].v, m.set2))
        self.assertTrue(is_in_block_indexed_by(m.b3['a', 1, m.time[1]].v, m.time))
        self.assertTrue(
            is_in_block_indexed_by(m.b3['a', 1, m.time[1]].v1[m.space[1]], m.set2)
        )
        self.assertFalse(
            is_in_block_indexed_by(m.b3['a', 1, m.time[1]].v1[m.space[1]], m.space)
        )
        self.assertTrue(
            is_in_block_indexed_by(m.b3['b', 2, m.time[2]].b[m.space[2]].v['b'], m.set2)
        )
        self.assertTrue(
            is_in_block_indexed_by(m.b3['b', 2, m.time[2]].b[m.space[2]].v['b'], m.time)
        )
        self.assertTrue(
            is_in_block_indexed_by(
                m.b3['b', 2, m.time[2]].b[m.space[2]].v['b'], m.space
            )
        )
        self.assertFalse(
            is_in_block_indexed_by(m.b3['b', 2, m.time[2]].b[m.space[2]].v['b'], m.set)
        )
        self.assertFalse(
            is_in_block_indexed_by(
                m.b3['b', 2, m.time[2]].b[m.space[2]].v['b'],
                m.time,
                stop_at=m.b3['b', 2, m.time[2]],
            )
        )
        self.assertFalse(
            is_in_block_indexed_by(
                m.b3['b', 2, m.time[2]].b[m.space[2]].v['b'], m.time, stop_at=m.b3
            )
        )

    # Test get_index_set_except and _complete_index
    def test_get_index_set_except(self):
        '''
        Tests:
          For components indexed by 0, 1, 2, 3, 4 sets:
            get_index_set_except one, then two (if any) of those sets
            check two items that should be in set_except
            insert item(s) back into these sets via index_getter
        '''
        m = ConcreteModel()
        m.time = ContinuousSet(bounds=(0, 10))
        m.space = ContinuousSet(bounds=(0, 10))
        m.set1 = Set(initialize=['a', 'b', 'c'])
        m.set2 = Set(initialize=['d', 'e', 'f'])
        m.v = Var()
        m.v1 = Var(m.time)
        m.v2 = Var(m.time, m.space)
        m.v3 = Var(m.time, m.space, m.set1)
        m.v4 = Var(m.time, m.space, m.set1, m.set2)

        # Multi-dimensional set:
        m.set3 = Set(initialize=[('a', 1), ('b', 2)])
        m.v5 = Var(m.set3)
        m.v6 = Var(m.time, m.space, m.set3)
        m.v7 = Var(m.set3, m.space, m.time)

        disc = TransformationFactory('dae.collocation')
        disc.apply_to(m, wrt=m.time, nfe=5, ncp=2, scheme='LAGRANGE-RADAU')
        disc.apply_to(m, wrt=m.space, nfe=5, ncp=2, scheme='LAGRANGE-RADAU')

        # Want this to give a TypeError
        # info = get_index_set_except(m.v, m.time)

        # Indexed by one set
        info = get_index_set_except(m.v1, m.time)
        set_except = info['set_except']
        index_getter = info['index_getter']
        self.assertTrue(set_except == [None])
        # Variable is not indexed by anything except time
        # Test that index_getter returns only the new value given,
        # regardless of whether it was part of the set excluded (time):
        self.assertEqual(index_getter((), -1), -1)

        # Indexed by two sets
        info = get_index_set_except(m.v2, m.time)
        set_except = info['set_except']
        index_getter = info['index_getter']
        self.assertTrue(m.space[1] in set_except and m.space.last() in set_except)
        # Here (2,) is the partial index, corresponding to space.
        # Can be provided as a scalar or tuple. 4, the time index,
        # should be inserted before (2,)
        self.assertEqual(index_getter((2,), 4), (4, 2))
        self.assertEqual(index_getter(2, 4), (4, 2))

        # Case where every set is "omitted," now for multiple sets
        info = get_index_set_except(m.v2, m.space, m.time)
        set_except = info['set_except']
        index_getter = info['index_getter']
        self.assertTrue(set_except == [None])
        # 5, 7 are the desired index values for space, time
        # index_getter should put them in the right order for m.v2,
        # even if they are not valid indices for m.v2
        self.assertEqual(index_getter((), 5, 7), (7, 5))

        # Indexed by three sets
        info = get_index_set_except(m.v3, m.time)
        # In this case set_except is a product of the two non-time sets
        # indexing v3
        set_except = info['set_except']
        index_getter = info['index_getter']
        self.assertTrue(
            (m.space[1], 'b') in set_except and (m.space.last(), 'a') in set_except
        )
        # index_getter inserts a scalar index into an index of length 2
        self.assertEqual(index_getter((2, 'b'), 7), (7, 2, 'b'))

        info = get_index_set_except(m.v3, m.space, m.time)
        # Two sets omitted. Now set_except is just set1
        set_except = info['set_except']
        index_getter = info['index_getter']
        self.assertTrue('a' in set_except)
        # index_getter inserts the two new indices in the right order
        self.assertEqual(index_getter('b', 1.2, 1.1), (1.1, 1.2, 'b'))

        # Indexed by four sets
        info = get_index_set_except(m.v4, m.set1, m.space)
        # set_except is a product, and there are two indices to insert
        set_except = info['set_except']
        index_getter = info['index_getter']
        self.assertTrue((m.time[1], 'd') in set_except)
        self.assertEqual(index_getter((4, 'f'), 'b', 8), (4, 8, 'b', 'f'))

        # The intended usage of this function looks something like:
        index_set = m.v4.index_set()
        for partial_index in set_except:
            complete_index = index_getter(partial_index, 'a', m.space[2])
            self.assertTrue(complete_index in index_set)
            # Do something for every index of v4 at 'a' and space[2]

        # Indexed by a multi-dimensional set
        info = get_index_set_except(m.v5, m.set3)
        set_except = info['set_except']
        index_getter = info['index_getter']
        self.assertEqual(set_except, [None])
        self.assertEqual(index_getter((), ('a', 1)), ('a', 1))

        info = get_index_set_except(m.v6, m.set3, m.time)
        set_except = info['set_except']
        index_getter = info['index_getter']
        self.assertTrue(m.space[1] in set_except)
        self.assertEqual(
            index_getter(m.space[1], ('b', 2), m.time[1]),
            (m.time[1], m.space[1], 'b', 2),
        )

        info = get_index_set_except(m.v7, m.time)
        set_except = info['set_except']
        index_getter = info['index_getter']
        self.assertIn(('a', 1, m.space[1]), set_except)
        self.assertEqual(
            index_getter(('a', 1, m.space[1]), m.time[1]),
            ('a', 1, m.space[1], m.time[1]),
        )

        m.v8 = Var(m.time, m.set3, m.time)
        with self.assertRaises(ValueError):
            info = get_index_set_except(m.v8, m.time)
        with self.assertRaises(ValueError):
            info = get_index_set_except(m.v8, m.space)

    def test_deactivate_model_at(self):
        m = make_model()

        deactivate_model_at(m, m.time, m.time[2])
        self.assertTrue(m.fs.con1[m.time[1]].active)
        self.assertFalse(m.fs.con1[m.time[2]].active)
        self.assertTrue(m.fs.con2[m.space[1]].active)
        self.assertFalse(m.fs.b1.con[m.time[2], m.space[1]].active)
        self.assertFalse(m.fs.b2[m.time[2], m.space.last()].active)
        self.assertTrue(m.fs.b2[m.time[2], m.space.last()].b3['a'].con['e'].active)

        deactivate_model_at(m, m.time, [m.time[1], m.time[3]])
        # disc equations at time.first()
        self.assertFalse(m.fs.con1[m.time[1]].active)
        self.assertFalse(m.fs.con1[m.time[3]].active)
        self.assertFalse(m.fs.b1.con[m.time[1], m.space[1]].active)
        self.assertFalse(m.fs.b1.con[m.time[3], m.space[1]].active)

        with self.assertRaises(KeyError):
            deactivate_model_at(
                m, m.time, m.time[1], allow_skip=False, suppress_warnings=True
            )

    def test_get_indices_of_projection(self):
        m = ConcreteModel()
        m.s1 = Set(initialize=[1, 2, 3])
        m.s2 = Set(initialize=[4, 5, 6])
        m.s3 = Set(initialize=['a', 'b'])
        m.s4 = Set(initialize=['c', 'd'])

        # Basic test:
        product = m.s1.cross(m.s2, m.s3, m.s4)
        info = get_indices_of_projection(product, m.s2)
        set_except = info['set_except']
        index_getter = info['index_getter']
        predicted_len = len(m.s1) * len(m.s3) * len(m.s4)
        self.assertEqual(len(set_except), predicted_len)
        removed_index = 4
        for idx in m.s1 * m.s3 * m.s4:
            self.assertIn(idx, set_except)
            full_index = index_getter(idx, removed_index)
            self.assertIn(full_index, product)

        # Works with nested products:
        sub_prod = m.s2.cross(m.s3, m.s4)
        product = m.s1.cross(sub_prod)
        info = get_indices_of_projection(product, m.s2)
        set_except = info['set_except']
        index_getter = info['index_getter']
        predicted_len = len(m.s1) * len(m.s3) * len(m.s4)
        self.assertEqual(len(set_except), predicted_len)
        removed_index = 4
        for idx in m.s1 * m.s3 * m.s4:
            self.assertIn(idx, set_except)
            full_index = index_getter(idx, removed_index)
            self.assertIn(full_index, product)

        # Works with multiple sets:
        product = m.s1.cross(m.s2, m.s3, m.s4)
        info = get_indices_of_projection(product, m.s2, m.s4)
        set_except = info['set_except']
        index_getter = info['index_getter']
        predicted_len = len(m.s1) * len(m.s3)
        self.assertEqual(len(set_except), predicted_len)
        removed_index = (4, 'd')
        for idx in m.s1 * m.s3:
            self.assertIn(idx, set_except)
            full_index = index_getter(idx, *removed_index)
            self.assertIn(full_index, product)


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