'''
Created on 12-Jul-2021

@author: bthakur

TR0 - simplify expression
TR1 - sec-csc to cos-sin
TR2 - tan-cot to sin-cos ratio
TR2i - sin-cos ratio to tan
TR3 - angle canonicalization
TR4 - functions at special angles
TR5 - powers of sin to powers of cos
TR6 - powers of cos to powers of sin
TR7 - reduce cos power (increase angle)
TR8 - expand products of sin-cos to sums
TR9 - contract sums of sin-cos to products
TR10 - separate sin-cos arguments
TR10i - collect sin-cos arguments
TR11 - reduce double angles
TR12 - separate tan arguments
TR12i - collect tan arguments
TR13 - expand product of tan-cot
TRmorrie - prod(cos(x*2**i), (i, 0, k - 1)) -> sin(2**k*x)/(2**k*sin(x))
TR14 - factored powers of sin or cos to cos or sin power
TR15 - negative powers of sin to cot power
TR16 - negative powers of cos to tan power
TR22 - tan-cot powers to negative powers of sec-csc functions
TR111 - negative sin-cos-tan powers to csc-sec-cot

use finite_difference

check https://github.com/sympy/sympy/issues/11155

>>> from sympy import *
>>> y = symbols('y')
>>> U = Function('U')(y)
>>> dU = Function('\delta U')(y)
>>> diff(U + dU,y).expand()
Derivative(U(y), y) + Derivative(\delta U(y), y)

https://stackoverflow.com/questions/46411124/how-to-rewrite-an-abstract-derivative-of-a-sum-as-a-sum-of-derivatives-in-sympy/46411350

'''
import random
import inspect
import traceback
import re
from operator import mul, add
from functools import reduce

from sympy import (symbols, Symbol, limit, latex, Function,
                   Limit, oo, Derivative, simplify,
                   exp, log, expand_log,
                   Mul, Add, Pow, UnevaluatedExpr,
                   Sum, binomial, factorial, Rational
                   )

from sympy import (acos, acot, acoth, acsc, asec, asin, 
                   asinh, atan, atan2, atanh, 
                   cos, cosh, cot, coth, csc, sec, 
                   sin, sinc, sinh, tan, tanh)

from sympy.simplify.fu import (TR111,TR22,TR16,TR15,TR14,
        TRmorrie,TR13,TR12i,TR12,TR11,TR10i,TR10,TR9,
        TR8,TR7,TR6,TR5,TR4,TR3,TR2i,TR2,TR1,TR0,)
        

from xv.km.util import latex_fraction
from ._derivative_solver import DerivativeSolver

from xv.util import UtilClass

class AutoDerivationCalcHelper(UtilClass):
    '''
    Calculate Derivative in calculus
    Example:
        from xv.math.calculus.helper import AutoDerivationCalcHelper
        from sympy import *
        
        differentiationHelper = AutoDerivationCalcHelper(verbose = True)
        
        x = Symbol('x')
        f = ((1 + 1 / x) ** x) **x
        x0 = oo
        
        result = differentiationHelper.get_solution(f, x, x0)
        result_str = differentiationHelper.solution_to_str(result)
        
    doc_style: xv_doc
    '''    
    

    _MAX_SUB_PROCESS = 50
    _LABEL = "D"

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._vars = []
        pass
    
    def _init_vars(self):
        self._vars = list('zwuvmnpqrst')[::-1]

    def _init_solution_config(self):
        self._solution_idx = 0
    
    def _next_solution_idx(self):
        self._solution_idx = self._solution_idx + 1
        return self._solution_idx

    def get_intutive_derivative_computation(self, f, x, x0):
        ltx = self._modified_latex
        
        d_dx = latex_fraction("d", f"d{latex(x)}")

        problem_s = f"""
        Find $\\displaystyle 
        {d_dx} \\left( {ltx(f, mul_symbol='dot')} \\right)
        $
        """
        
        
        soln_items = []
        
        
        #get detailed solution
        ##############################
        var_symbs = [x]
        d_solver = DerivativeSolver(f, x, var_symbs, 
                                             verbose = self.verbose)
        
        soln_result = d_solver.get_soln()
        last_sub_soln = soln_result[-1]
        
        soln_stmts = d_solver.convert_soln_to_stmts(soln_result)
        
        soln_items.extend(soln_stmts)
        
        if self.verbose:
            soln_items.extend(d_solver._soln_stmts)
            
            soln_items.append("_r_exprs:")
            for k, v in d_solver._r_exprs.items():
                r_dict = last_sub_soln['r_dict']
                keys = list(r_dict.keys())
                v_mod = ltx(v, mul_symbol='dot', funct_symbs = keys)
                soln_items.append(f"""{k}: $\\displaystyle 
                                    =  {v_mod.strip()}
                                    $
                                    """)
        

        soln_s = "\n\n".join([soln_item.strip() for soln_item in soln_items
                              if soln_item.strip()])
        
        expr = last_sub_soln['expr']
        mod_args = last_sub_soln['mod_args']
        mod_expr = type(expr)(*mod_args)
        answer_s = f"""
        $\\displaystyle 
        {ltx(Derivative(mod_expr).doit(), mul_symbol='dot')}
        $
        """
        
        
        result = {
            'problem_s': problem_s.strip(),
            'soln_s': soln_s.strip(),
            'answer_s': answer_s.strip(),
            }
        
        
        return result
        pass

    def get_random_bivariate_function(self, category = None):
        '''
        category = None # if 'finite_difference' only first three are returned 
        '''
        
        f = Function('f')
        x = Symbol('x')
        y = Function('y')(x)
        n = Symbol('n', constant = True)
        f = f(x, y)
        
        fs = [y**2 + x**2 - random.randint(1, 10) ** 2,
              ]
            
        return random.choice(fs), x    
        pass

    def get_random_function(self, category = None):
        '''
        category = None # if 'finite_difference' only first three are returned 
        '''
        
        f = Function('f')
        x = Symbol('x')
        n = Symbol('n')
        x0 = 0
        f = f(x)
        
        
        
        if category == 'difficult':
            fs = [sin(x ** Rational(1,3)) ** Rational(1,3),
                  x ** x
                  ]
            return random.choice(fs), x, x0
        
        
        if category == 'finite_difference':
            fs = [x**n,
                  sin(x),
                  cos(x),
                  ]
            return random.choice(fs), x, x0
        
        if category == 'sin_cos':
            lst_params = [x, x**2, 2*x, 2**x, log(x), exp(x)]
            func1 = random.choice([sin, cos]) 
            var1 = random.choice(lst_params)
            if random.choice([True, False]):
                var1 = var1 + random.choice(lst_params)
            f = func1(var1)
            return f, x, x0
            
        if category == 'trig_product':
            lst_params = [x, x**2, 2*x, 2**x, log(x), exp(x)]
            func1 = random.choice([sin, cos]) 
            func2 = random.choice([sin, cos]) 
            var1 = random.choice(lst_params)
            var2 = random.choice(lst_params)
            if random.choice([True, False]):
                var1 = var1 + random.choice(lst_params)
            if random.choice([True, False]):
                var2 = var2 + random.choice(lst_params)
                
            f = func1(var1) * func2(var2)
            return f, x, x0
            
        
        if category == 'product':
            lst_params = [x, x**2, 2*x, 2**x, log(x), exp(x)]
            func1 = random.choice([sin, cos]) 
            func2 = random.choice([sin, cos]) 
            var1 = random.choice(lst_params)
            var2 = random.choice(lst_params)
            if random.choice([True, False]):
                var1 = var1 + random.choice(lst_params)
            if random.choice([True, False]):
                var2 = var2 + random.choice(lst_params)
                
            if random.choice([True, False]):
                term1 = func1(var1)
            else:
                term1 = var1
                
            if random.choice([True, False]):
                term2 = func1(var2)
            else:
                term2 = var2
                
            if random.choice([True, False]):
                f = term1 + term2
            else:
                f = term1 * term2
            return f, x, x0


        if category == 'exponent_x':
            lst_params = [x, x**2, 2*x, 2**x, log(x), exp(x)]
            
            var1 = random.choice(lst_params)
            var2 = random.choice(lst_params)
            if random.choice([True, False]):
                f = var1 ** var2
            else:
                f = var2 ** var1
            return f, x, x0
            
        #custom
        fs = [
                (2*x),
                log(x), 
                2**x,
                x**(2*x),
                (x**4 + 5) ** (-3 * x),
                (x**4 + 5) ** -3,
            ]
        
        trig_funcs = [acos, acot, acoth, acsc, asec, asin, 
                   asinh, atan, atanh, 
                   cos, cosh, cot, coth, csc, sec, 
                   sin, sinc, sinh, tan, tanh, log, 
                   exp]
        
        for trig_func in trig_funcs:
            fs.append(trig_func(x))
            fs.append(trig_func(x ** 2))
            fs.append(trig_func(2 * x))
            fs.append(trig_func(x**2 -2 * x))

        for _ in range(len(fs) // 3):
            f1, f2 = random.sample(fs, 2)
            if random.choice([True, False]):
                fs.append(f1 + f2)
                pass
            else:
                fs.append(f1 * f2)
                pass
            pass

        fs = list(set(fs))

        return random.choice(fs), x, x0
        pass

    def get_solution(self, f, x, x0):
        self._init_solution_config()
        
        solution = self._get_solution_steps(f, x, x0)
        return solution
        pass

    def _get_solution_steps(self, f, x, x0, print_answer = False,
                           check_sub_processes = True):
        ltx = self._modified_latex
        
        solution_idx = self._next_solution_idx()
        
        if solution_idx > AutoDerivationCalcHelper._MAX_SUB_PROCESS: 
            sOut = f"""
            f = {f}
            solution_idx exceeded {AutoDerivationCalcHelper._MAX_SUB_PROCESS}
            """
            return None
        
        label = Symbol(f"{AutoDerivationCalcHelper._LABEL}_{{{solution_idx}}}")
        
        dfdx = Derivative(f, x)
        
        try:
            answer  = simplify(dfdx.doit())
        except:
            answer  = dfdx.doit()

        soln_statement = []
        
        if check_sub_processes:
            try:
                child_solutions = self._get_solution_children(f, x, x0, label)
            except Exception as e:
                sError = f"""Error {self.__class__.__name__}.{inspect.stack()[0][3]}:{e}
                f = {f}
                x = {x}
                x0 = {x0}
                label = {label}
                """
                self._log(sError)
                self._log(traceback.format_exc())
                child_solutions = None
        else:
            child_solutions = None
            
        if not child_solutions:    
            statement = f"""
            $\\displaystyle 
            {latex(label)} = {latex(dfdx)} = {latex(answer)}
            $
            """
            soln_statement.append(statement.strip())
            pass
        
        soln_statement = "\n\n".join(soln_statement)
        
        solution_step = {
            'solution_idx': solution_idx,
            'label': label,
            'method_name': inspect.stack()[0][3],
            'f': f,
            'x': x,
            'x0': x0,
            'Derivative': dfdx,
            'Derivative_latex': ltx(dfdx),
            'statement': soln_statement,
            'answer': answer,
            'children': child_solutions
            }

        return solution_step
        pass

    def _get_solution_children(self, f, x, x0, parent_label):
        trig_function_type = self._get_trig_func_name(f, x)
        
        #print(f"{f}: trig_function_type = {trig_function_type}")
        if not trig_function_type is None:
            #print(f"{f}: type = {trig_function_type} calling _get_trig_func_step")
            child_solutions = self._get_trig_func_step(f, x, x0, parent_label)
            if child_solutions:
                return child_solutions
            pass
        
        child_solutions = {}
        if f == x**Symbol('n'):
            child_solution = self._get_xn_finite_diff_step(
                                    f, x, x0, parent_label)
            if child_solution:
                child_solutions['xn'] = child_solution
                return child_solutions
            pass

        f_explog = self._explog_format(f, x) 
        if not f_explog is None:
            child_solution = self._get_explog_step(
                                    f, x, x0, parent_label)
            if child_solution:
                child_solutions['explog'] = child_solution
                return child_solutions
            pass
        
        if isinstance(f, Pow):
            child_solution = self._get_inverse_step(
                                    f, x, x0, parent_label)
            if child_solution:
                child_solutions['inverse'] = child_solution
                return child_solutions
            pass
        
        
        
        mul_args = self._mul_args(f, x) 
        if not mul_args is None:
            child_solution = self._get_mul_step(
                                    f, mul_args, x, x0, parent_label)
            if child_solution:
                child_solutions['Mul'] = child_solution
                return child_solutions
            pass
        
        
        add_args = self._add_args(f, x) 
        if not add_args is None:
            child_solution = self._get_add_step(
                                    f, add_args, x, x0, parent_label)
            if child_solution:
                child_solutions['Add'] = child_solution
                return child_solutions
            pass
        
        return None
        pass

    def _get_trig_func_step(self, f, x, x0, parent_label):
        modified_fs = self._apply_fu_funcs(f, x)
        
        if modified_fs:
            #print(f"modified_fs = {modified_fs}")
            pass
        
        child_solutions = {}
        
        trf = self._get_trig_tr_transform(f, x)
        #print(f"trf = {trf}")
        if trf:
            if isinstance(trf, Mul):
                child_solution = self._get_mul_step(
                                        f, trf.args, x, x0, parent_label)
                if child_solution:
                    child_solutions['TR8'] = child_solution
                    return child_solutions
                
            if isinstance(trf, Pow):
                child_solution = self._get_inverse_step(
                                        trf, x, x0, parent_label)
                if child_solution:
                    child_solutions['inverse'] = child_solution
                    return child_solutions
                pass
            
            
            child_solution = self._get_trig_TR_step(f, trf, x, x0, parent_label)
            if child_solution:
                child_solutions['TR8'] = child_solution
                return child_solutions
            
            pass
        
        if self._is_sin_cos_basic(f, x):
            child_solution = self._get_sin_cos_step(
                                    f, x, x0, parent_label)
            if child_solution:
                child_solutions['sin_cos'] = child_solution
                return child_solutions
            pass
        
        
        if self._is_sin_cos_product(f, x):
            child_solution = self._get_sin_cos_product_step(
                                    f, x, x0, parent_label)
            if child_solution:
                child_solutions['sin_cos_product'] = child_solution
                return child_solutions
            pass
        
        return None
        pass

    def _get_trig_TR_step(self, f, sum_f, x, x0, parent_label):
        ltx = self._modified_latex
        
        solution_idx = self._solution_idx #self._next_solution_idx()
        label = Symbol(f"{AutoDerivationCalcHelper._LABEL}_{{{solution_idx}}}")

        #print(f"_get_trig_TR_step({sum_f})")

        dfdx = Derivative(f, x)

        child_solutions = self._get_solution_children(sum_f, x, x0, label)
        
        sum_dfdx = Derivative(sum_f, x)
        sum_dfdx_doit = sum_dfdx.doit()
        
        start_statement = f"""
        $\\displaystyle 
        {latex(parent_label)} = {latex(dfdx)}
        $
        """.strip()

        soln_statement = []

        statement = f"""
        $\\displaystyle 
        = {latex(sum_dfdx)}
         $
        """
        soln_statement.append(statement.strip())
        
        
        post_statement = f"""
        $\\displaystyle 
        = {latex(sum_dfdx_doit)}
         $
        """.strip()
        
        soln_statement = "\n\n".join(soln_statement)
        
        solution_step = {
            'solution_idx': solution_idx,
            'label': label,
            'method_name': inspect.stack()[0][3],
            'f': f,
            'x': x,
            'x0': x0,
            'type': 'sin',
            'start_statement': start_statement,
            'statement': soln_statement,
            'post_statement': post_statement,
            'Derivative': dfdx,
            'Derivative_latex': ltx(dfdx),
            'children': child_solutions
            }
        return solution_step
        pass


    def _get_xn_finite_diff_step(self, f, x, x0, parent_label):
        ltx = self._modified_latex
        
        solution_idx = self._solution_idx #self._next_solution_idx()
        label = Symbol(f"{AutoDerivationCalcHelper._LABEL}_{{{solution_idx}}}")

        dfdx = Derivative(f, x)
        dfdx_doit = simplify(dfdx.doit())

        deltax = Symbol(f"\\Delta {latex(x)}")
        deltax0 = 0
        n = Symbol('n')
        k = Symbol('k')

        fdeltax = f.subs(x, x + deltax)
        dfdx_Lim = Limit(fdeltax - f, deltax, deltax0)
        
        fdeltax_sum = Sum(binomial(n, k) * x**k * deltax**(n-k), (k, n, 0))
        fdeltax_sum2 = Sum(binomial(n, k) * x**k * deltax**(n-k), (k, n, 1))
        
        sum1 = binomial(n, 0) * x**n 
        sum1 = sum1 + binomial(n, 1) * x**(n-1) * deltax**1  
        sum1 = sum1 + binomial(n, 2) * x**(n-2) * deltax**2
        sum1 = sum1 + binomial(n, 3) * x**(n-3) * deltax**3
        
        sum2 = binomial(n, 1) * x**(n-1)   
        sum2 = sum2 + binomial(n, 2) * x**(n-2) * deltax**1
        sum2 = sum2 + binomial(n, 3) * x**(n-3) * deltax**2
        
        
        
        numr = fdeltax - f
        numr1 = UnevaluatedExpr(sum1) - f # fdeltax_sum - f
        numr2 = sum1 - f #fdeltax_sum2 
        numr3 = simplify(numr2)
        f_frac_Lim = Limit(numr1/deltax, deltax, deltax0)
        
        f_frac1_Lim = Limit(numr2/deltax, deltax, deltax0)
        f_frac2_Lim = Limit(sum2, deltax, deltax0)
        
        start_statement = f"""
        $\\displaystyle 
        {latex(parent_label)} = {ltx(dfdx)}
        $
        """.strip()

        soln_statement = []

        statement = f"""
        $\\displaystyle 
        = {latex(dfdx_Lim)}
         $
         
        <b>Expand $\\displaystyle 
        {latex(fdeltax)}$ up to first four terms, ignorng higher powers of ${latex(deltax)}$
        </b>
        $\\displaystyle 
        = {latex(f_frac_Lim)}
         $
        
        $\\displaystyle 
        = {latex(f_frac1_Lim)}
         $
        
        $\\displaystyle 
        = {latex(f_frac2_Lim)}
         $
        
        $\\displaystyle 
        = {latex(dfdx_doit)}
         $
        
        """
        soln_statement.append(statement.strip())
        
        post_statement = f"""
        """.strip()
        
        soln_statement = "\n\n".join(soln_statement)
        
        solution_step = {
            'solution_idx': solution_idx,
            'label': label,
            'method_name': inspect.stack()[0][3],
            'f': f,
            'x': x,
            'x0': x0,
            'type': 'sin',
            'start_statement': start_statement,
            'statement': soln_statement,
            'post_statement': post_statement,
            'Derivative': dfdx,
            'Derivative_latex': ltx(dfdx),
            }
        return solution_step
        pass
    
    def _get_sin_cos_step(self, f, x, x0, parent_label):
        ltx = self._modified_latex
        
        solution_idx = self._solution_idx #self._next_solution_idx()
        if solution_idx == 1 and f.args[0] == x:
            return self._get_sin_cos_finite_diff_step(f, x, x0, parent_label)
        
        label = Symbol(f"{AutoDerivationCalcHelper._LABEL}_{{{solution_idx}}}")

        dfdx = Derivative(f, x)
        dfdx_doit = dfdx.doit()
        
        f_arg = f.args[0]

        if f_arg != x:
            sub_solution = self._get_solution_steps(f_arg, x, x0,
                                                   print_answer = False)
            pass
        else:
            sub_solution = None

        start_statement = f"""
        $\\displaystyle 
        {latex(parent_label)} = {latex(dfdx)}
        $
        """.strip()

        soln_statement = []
        if sub_solution:
            z = self._get_new_var()
            dzdx = Derivative(z, x)
            fz = f.subs(f_arg, z)
            dfzdx = Derivative(fz, x)
            dfzdz = Derivative(fz, z)
            dfzdz_doit = dfzdz.doit()

            sub_label = sub_solution['label']
            sub_answer = sub_solution['answer']

            statement = f"""
            $\\displaystyle 
            \\text{{let}}\\ \\qquad 
            {latex(z)} = {latex(f_arg)} 
            $
    
            $\\displaystyle 
            \\implies 
            {latex(parent_label)} 
            = {latex(dfzdx)} 
            = {latex(dfzdz)} {latex(dzdx)}
            = {latex(dfzdz_doit)} {latex(dzdx)}
             $
             
            <b>Let us compute:</b>
            $\\displaystyle 
            {latex(sub_label)} = {latex(dzdx)} 
            $
            """
            soln_statement.append(statement.strip())

        
            post_statement = []
            ps_item =  f"""
            $\\displaystyle 
            \\implies 
            {latex(parent_label)} = {latex(dfzdz_doit)} {latex(dzdx)}
             $"""
            post_statement.append(ps_item.strip())
            
            ps_item = f"""
            $\\displaystyle 
            = {latex(dfzdz_doit)} * \\left({latex(sub_answer)}\\right)
             $"""
            post_statement.append(ps_item.strip())
            
            ps_item = f"$\\displaystyle = {latex(dfdx_doit)}$"
            post_statement.append(ps_item.strip())
            
            post_statement = "\n\n".join(post_statement)

            pass   
        else:
            post_statement = f"""
            $\\displaystyle 
            = {latex(dfdx_doit)}
             $
            """
        
        soln_statement = "\n\n".join(soln_statement)
        
        solution_step = {
            'solution_idx': solution_idx,
            'label': label,
            'method_name': inspect.stack()[0][3],
            'f': f,
            'x': x,
            'x0': x0,
            'type': 'sin',
            'start_statement': start_statement,
            'statement': soln_statement,
            'post_statement': post_statement,
            'Derivative': dfdx,
            'Derivative_latex': ltx(dfdx),
            'sub_solutions': [sub_solution]
            }
        return solution_step
        pass
    
    def _get_inverse_step(self, f, x, x0, parent_label):
        ltx = self._modified_latex
        
        if not isinstance(f, Pow):
            return None
        
        if f.args[0] == x:
            return None
        
        try:
            f_explog = self._explog_format(f, x) 
            if not f_explog is None:
                return None
            
            if f.args[1] in [0, 1]:
                  return None
            
        except Exception as e:
            sError = f"""Error {self.__class__.__name__}.{inspect.stack()[0][3]}:{e}
            f = {f}
            f.args = {f.args}
            """
            self._log(sError)
            self._log(traceback.format_exc())
            pass
        
        solution_idx = self._solution_idx #self._next_solution_idx()
        label = Symbol(f"{AutoDerivationCalcHelper._LABEL}_{{{solution_idx}}}")

        dfdx = Derivative(f, x)
        dfdx_doit = dfdx.doit()

        f_arg = f.args[0]
        
        if f_arg != x:
            sub_solution = self._get_solution_steps(f_arg, x, x0,
                                                   print_answer = False)
            pass
        else:
            sub_solution = None

        start_statement = f"""
        $\\displaystyle 
        {latex(parent_label)} 
        = {latex(dfdx)} 
        $
        """.strip()

        soln_statement = []
        if sub_solution:
            z = self._get_new_var()
            dzdx = Derivative(z, x)
            fxz = UnevaluatedExpr(f_arg) ** f.args[1]
            dfxzdx = Derivative(fxz, x)
            
            fz = UnevaluatedExpr(z) ** f.args[1]
            
            dfzdx = Derivative(fz, x)
            dfzdz = Derivative(fz, z)
            dfzdz_doit = dfzdz.doit()
            
            dfzdz_doit_subs = dfzdz_doit.subs(z, f_arg)

            sub_label = sub_solution['label']
            sub_answer = sub_solution['answer']

            statement = f"""
            $\\displaystyle 
            \\text{{let}}\\ \\qquad 
            {latex(z)} = {latex(f_arg)} 
            $
    
            $\\displaystyle 
            \\implies 
            {latex(parent_label)} = {latex(dfxzdx)} = {latex(dfzdx)} 
            $

            $\\displaystyle 
            = {latex(dfzdz)} {latex(dzdx)}
            = {latex(dfzdz_doit)} {latex(dzdx)}
            = {latex(dfzdz_doit_subs)} {latex(dzdx)}
             $
             
            <b>Let us compute:</b>
            $\\displaystyle 
            {latex(sub_label)} = {latex(dzdx)} 
            $
            """
            soln_statement.append(statement.strip())

            post_statement = []
            
            ps_item =  f"""
            $\\displaystyle 
            \\implies 
            {latex(parent_label)} 
            = {latex(dfdx)} 
             $
            """
            post_statement.append(ps_item.strip())
            
            ps_item =  f"""
            $\\displaystyle 
            = \\left({latex(dfzdz_doit_subs)}\\right) {latex(dzdx)}
             $
            """
            post_statement.append(ps_item.strip())
            
            ps_item =  f"""
            $\\displaystyle 
            = \\left({latex(dfzdz_doit_subs)}\\right) \\cdot \\left({latex(sub_answer)}\\right)
             $
            """
            post_statement.append(ps_item.strip())
            
            ps_item = f"$\\displaystyle = {latex(dfdx_doit)}$"
            post_statement.append(ps_item.strip())
            
            post_statement = "\n\n".join(post_statement)

            pass   
        else:
            post_statement = f"""
            $\\displaystyle 
            = {latex(dfdx_doit)}
             $
            """
        
        soln_statement = "\n\n".join(soln_statement)
        
        solution_step = {
            'solution_idx': solution_idx,
            'label': label,
            'method_name': inspect.stack()[0][3],
            'f': f,
            'x': x,
            'x0': x0,
            'type': 'sin',
            'start_statement': start_statement,
            'statement': soln_statement,
            'post_statement': post_statement,
            'Derivative': dfdx,
            'Derivative_latex': ltx(dfdx),
            'sub_solutions': [sub_solution]
            }
        return solution_step

    

    def _get_explog_step(self, f, x, x0, parent_label):
        solution_idx = self._solution_idx #self._next_solution_idx()
        label = Symbol(f"{AutoDerivationCalcHelper._LABEL}_{{{solution_idx}}}")

        f0 = f
        df0dx = Derivative(f0, x)
        df0dx_doit = df0dx.doit()

        
        f_uneval = exp(UnevaluatedExpr(log(f)))
        df_unevaldx = Derivative(f_uneval, x)
        f_exp = expand_log(log(f), force = True)
        f = exp(UnevaluatedExpr(f_exp))
        dfdx = Derivative(f, x)
        dfdx_doit = dfdx.doit()
        
        f_arg = f.args[0]

        if f_arg != x:
            sub_solution = self._get_solution_steps(f_arg, x, x0,
                                                   print_answer = False)
            pass
        else:
            sub_solution = None

        start_statement = f"""
        $\\displaystyle 
        {latex(parent_label)} 
        = {latex(df0dx)} 
        = {latex(df_unevaldx)} 
        = {latex(dfdx)}
        $
        """.strip()

        soln_statement = []
        if sub_solution:
            z = self._get_new_var()
            dzdx = Derivative(z, x)
            fz = f.subs(f_arg, z)
            dfzdx = Derivative(fz, x)
            dfzdz = Derivative(fz, z)
            dfzdz_doit = dfzdz.doit()

            sub_label = sub_solution['label']
            sub_answer = sub_solution['answer']

            statement = f"""
            $\\displaystyle 
            \\text{{let}}\\ \\qquad 
            {latex(z)} = {latex(f_arg)} 
            $
    
            $\\displaystyle 
            \\implies 
            {latex(parent_label)} = {latex(dfzdx)} 
            = {latex(dfzdz)} {latex(dzdx)}
            = {latex(dfzdz_doit)} {latex(dzdx)}
            = {latex(f)} {latex(dzdx)}
             $
             
            <b>Let us compute:</b>
            $\\displaystyle 
            {latex(sub_label)} = {latex(dzdx)} 
            $
            """
            soln_statement.append(statement.strip())

            post_statement = []
            
            ps_item =  f"""
            $\\displaystyle 
            \\implies 
            {latex(parent_label)} 
            = {latex(df0dx)} 
             $
            """
            post_statement.append(ps_item.strip())
            
            ps_item =  f"""
            $\\displaystyle 
            = \\left({latex(f)}\\right) {latex(dzdx)}
             $
            """
            post_statement.append(ps_item.strip())
            
            ps_item =  f"""
            $\\displaystyle 
            = \\left({latex(f)}\\right) \\cdot \\left({latex(sub_answer)}\\right)
             $
            """
            post_statement.append(ps_item.strip())
            
            ps_item = f"$\\displaystyle = {latex(dfdx_doit)}$"
            post_statement.append(ps_item.strip())
            
            post_statement = "\n\n".join(post_statement)

            pass   
        else:
            post_statement = f"""
            $\\displaystyle 
            = {latex(dfdx_doit)}
             $
            """
        
        soln_statement = "\n\n".join(soln_statement)
        
        solution_step = {
            'solution_idx': solution_idx,
            'label': label,
            'method_name': inspect.stack()[0][3],
            'f': f,
            'x': x,
            'x0': x0,
            'type': 'sin',
            'start_statement': start_statement,
            'statement': soln_statement,
            'post_statement': post_statement,
            'Derivative': dfdx,
            'sub_solutions': [sub_solution]
            }
        return solution_step
        pass

    def _get_sin_cos_finite_diff_step(self, f, x, x0, parent_label):
        ltx = self._modified_latex
        
        solution_idx = self._solution_idx #self._next_solution_idx()
        label = Symbol(f"{AutoDerivationCalcHelper._LABEL}_{{{solution_idx}}}")

        dfdx = Derivative(f, x)
        dfdx_doit = dfdx.doit()


        deltax = Symbol(f"\\Delta {latex(x)}")
        deltax0 = 0
        
        fx = f.subs({x: x})
        fxdeltax = f.subs({x: x + deltax})
        
        numr = fxdeltax - fx
        dfdx_frac = numr / deltax
        dfdx_frac_Lim = Limit(dfdx_frac, deltax, deltax0)
        
        numr_prod = TR9(numr)
        dfdx_prod = numr_prod / deltax
        dfdx_prod_Lim = Limit(dfdx_prod, deltax, deltax0)
        
        sinx2dx2 = sin(deltax/2) / (deltax/2)
        sinx2dx2_Lim = Limit(sinx2dx2, deltax, deltax0)
        sinx2dx2_Lim_doit = sinx2dx2_Lim.doit()
        sinx2dx2_latex = latex_fraction(f"{latex(sin(deltax/2))}",
                                        f"{latex(deltax/2)}")
        
        
        dfdx_prod_subs = dfdx_prod.subs(sinx2dx2, sinx2dx2_Lim_doit)
        dfdx_prod_subs_Lim = Limit(dfdx_prod_subs, deltax, deltax0)
        dfdx_prod_subs_Lim_doit = dfdx_prod_subs_Lim.doit()
        
        lmt_stmt = f"\\lim _{{{latex(deltax)} \\rightarrow {latex(deltax0)}}}"  

        lmt_half_stmt = f"\\lim _{{{latex(deltax/2)} \\rightarrow {latex(deltax0)}}}"  
        sinx2dx2_latex_Lim = f"{lmt_half_stmt} \\left({sinx2dx2_latex}\\right)"

        start_statement = f"""
        $\\displaystyle 
        {latex(parent_label)} = {latex(dfdx)}
        $
        """.strip()

        soln_statement = []

        statement = f"""
        $\\displaystyle 
        = {latex(dfdx_frac_Lim)}
         $
        
        $\\displaystyle 
        = {latex(dfdx_prod_Lim)}
         $
        
        $\\displaystyle 
        = {sinx2dx2_latex_Lim} \\cdot {latex(dfdx_prod_subs_Lim)}
         $
        
        $\\displaystyle 
        = {sinx2dx2_Lim_doit} \\cdot {latex(dfdx_prod_subs_Lim)}
         $
        
        $\\displaystyle 
        = {sinx2dx2_Lim_doit} \\cdot {latex(dfdx_prod_subs_Lim_doit)}
         $
        
        $\\displaystyle 
        = {latex(dfdx_doit)}
         $
        
        """
        soln_statement.append(statement.strip())
        
        post_statement = f"""
        """.strip()
        
        soln_statement = "\n\n".join(soln_statement)
        
        solution_step = {
            'solution_idx': solution_idx,
            'label': label,
            'method_name': inspect.stack()[0][3],
            'f': f,
            'x': x,
            'x0': x0,
            'type': 'sin',
            'start_statement': start_statement,
            'statement': soln_statement,
            'post_statement': post_statement,
            'Derivative': dfdx,
            'Derivative_latex': ltx(dfdx),
            }
        return solution_step
        pass
  
    def _get_sin_cos_product_step(self, f, x, x0, parent_label):
        ltx = self._modified_latex
        
        solution_idx = self._solution_idx #self._next_solution_idx()
        label = Symbol(f"{AutoDerivationCalcHelper._LABEL}_{{{solution_idx}}}")

        dfdx = Derivative(f, x)

        sum_f = TR8(f)
        child_solutions = self._get_solution_children(sum_f, x, x0, label)
        
        sum_dfdx = Derivative(sum_f, x)
        sum_dfdx_doit = sum_dfdx.doit()
        
        start_statement = f"""
        $\\displaystyle 
        {latex(parent_label)} = {latex(dfdx)}
        $
        """.strip()

        soln_statement = []

        statement = f"""
        $\\displaystyle 
        = {latex(sum_dfdx)}
         $
        """
        soln_statement.append(statement.strip())
        
        
        post_statement = f"""
        $\\displaystyle 
        = {latex(sum_dfdx_doit)}
         $
        """.strip()
        
        soln_statement = "\n\n".join(soln_statement)
        
        solution_step = {
            'solution_idx': solution_idx,
            'label': label,
            'method_name': inspect.stack()[0][3],
            'f': f,
            'x': x,
            'x0': x0,
            'type': 'sin',
            'start_statement': start_statement,
            'statement': soln_statement,
            'post_statement': post_statement,
            'Derivative': dfdx,
            'Derivative_latex': ltx(dfdx),
            'children': child_solutions
            }
        return solution_step
        pass

    def _get_add_step(self, f, add_args, x, x0, parent_label):
        ltx = self._modified_latex
        
        solution_idx = self._solution_idx #self._next_solution_idx()
        label = Symbol(f"{AutoDerivationCalcHelper._LABEL}_{{{solution_idx}}}")

        dfdx = Derivative(f, x)
        
        add_args_Ders = [Derivative(term, x)
                        for term in add_args
                        ]
        
        add_args_str = [f"{latex(term)}"
                             for term in add_args]
        add_args_str = " + ".join(add_args_str)
        
        
        add_args_Ders_str = [f"{latex(term)}"
                             for term in add_args_Ders]
        add_args_Ders_str = " + ".join(add_args_Ders_str)
        
        sub_solutions = []
        answers = []
        labels = []
        for add_arg in add_args:
            sub_solution = self._get_solution_steps(add_arg, x, x0,
                                                   print_answer = True)

            labels.append(sub_solution['label'])
            answers.append(sub_solution['answer'])
            sub_solutions.append(sub_solution)
            pass
        
        labels_str = [f"{latex(term)}" for term in labels]
        labels_str = " + ".join(labels_str)
        
        answers_str = [f"{latex(term)}" for term in answers]
        answers_str = " + ".join(answers_str)
        
        answer = reduce(add, answers)
            
        start_statement = f"""
        <b>Convert expression into sum of terms:</b>
        
        $\\displaystyle 
        {latex(label)} = {latex(dfdx)}
        $


        $\\displaystyle 
        = {add_args_Ders_str}
        $

        $\\displaystyle 
        = {labels_str}
        $
        """.strip()
        
        pre_statement = f"""
        <b>Compute derivative of each term separately:</b>
        """.strip()
        
        
        post_statement = []
        ps_item = f"<b>Compute sum of results:</b>"
        post_statement.append(ps_item.strip())
        
        ps_item = f"$\\displaystyle {latex(label)} = {latex(dfdx)}$"
        post_statement.append(ps_item.strip())
        
        ps_item = f"$\\displaystyle = {add_args_Ders_str}$"
        post_statement.append(ps_item.strip())
        
        ps_item = f"$\\displaystyle = {labels_str}$"
        post_statement.append(ps_item.strip())
        
        if latex(answer) != answers_str:
            ps_item = f"$\\displaystyle = {answers_str}$"
            post_statement.append(ps_item.strip())
        
        ps_item = f"$\\displaystyle = {latex(answer)}$"
        post_statement.append(ps_item.strip())
        
        post_statement = "\n\n".join(post_statement)
        
        solution_step = {
            'solution_idx': solution_idx,
            'label': label,
            'method_name': inspect.stack()[0][3],
            'f': f,
            'x': x,
            'x0': x0,
            'Derivative': dfdx,
            'Derivative_latex': ltx(dfdx),
            'type': 'Mul',
            'start_statement': start_statement,
            'pre_statement': pre_statement,
            'post_statement': post_statement,
            'sub_solutions': sub_solutions,
            }
        return solution_step
        pass

    def _get_mul_step(self, f, mul_args, x, x0, parent_label):
        ltx = self._modified_latex
        
        solution_idx = self._solution_idx #self._next_solution_idx()
        label = Symbol(f"{AutoDerivationCalcHelper._LABEL}_{{{solution_idx}}}")

        d_dx_latex = latex_fraction('d', f"d{latex(x)}")

        dfdx = Derivative(f, x)

        t_Ders = [Derivative(term, x)
                        for term in mul_args
                        ]
        
        t_coeff_list = [
                        [t for t_idx, t in enumerate(mul_args) 
                         if t_idx != i]
                        for i in range(len(mul_args))
                        ]
        
        t_coeff = [reduce(mul, term)
                                for term in t_coeff_list
                                ]

        t_coeff_latex = [" \\cdot ".join([latex(t) for t in term])
                                for term in t_coeff_list
                                ]
        
        t_Ders_latex = [
                f"{t_coeff_latex[i]} \\cdot {latex(t_Ders[i])}"
                for i in range(len(mul_args))
                ]
        
        t_Ders_latex_computed = [
                f"{t_coeff[i]} \\cdot {t_Ders[i]}"
                for i in range(len(mul_args))
                ]
        
        t_Ders_simplified = [
                t_coeff[i] * t_Ders[i]
                for i in range(len(mul_args))
                ]
        
        sub_solutions = []
        labels = []
        answers = []
        for mul_arg in mul_args:
            sub_solution = self._get_solution_steps(mul_arg, x, x0,
                                                   print_answer = True)

            labels.append(sub_solution['label'])
            answers.append(sub_solution['answer'])
            sub_solutions.append(sub_solution)
            pass
        
        mul_args_str = [f"{latex(term)}"
                             for term in mul_args]
        mul_args_str = " \\cdot ".join(mul_args_str)
        
        t_Ders_latex_str = [f"{term}"
                             for term in t_Ders_latex]
        t_Ders_latex_str = " + ".join(t_Ders_latex_str)
        
        t_Ders_latex_computed_str = [f"{latex(term)}"
                             for term in t_Ders_latex_computed]
        t_Ders_latex_computed_str = " + ".join(t_Ders_latex_computed_str)
        
        t_Ders_simplified_str = [f"{latex(term)}"
                             for term in t_Ders_simplified]
        t_Ders_simplified_str = " + ".join(t_Ders_simplified_str)
        

        

        labels_str = [
                f"{latex(t_coeff[i])} \\cdot {latex(labels[i])}"
                for i in range(len(mul_args))
                ]
        labels_str = " + ".join(labels_str)
        
        answers_str = [
                f"{latex(t_coeff[i])} \\cdot {latex(answers[i])}"
                for i in range(len(mul_args))
                ]
        answers_str = " + ".join(answers_str)
        
        answers_computed = [
                t_coeff[i] * answers[i]
                for i in range(len(mul_args))
                ]
        
        answers_computed_str = [
                f"{latex(t)}"
                for t in answers_computed
                ]
        
        answers_computed_str = " + ".join(answers_computed_str)
        
        answer = reduce(add, answers_computed)
        
        start_statement = f"""
        <b>Convert expression into factors:</b>
        
        $\\displaystyle 
        {latex(label)} = {latex(dfdx)}
        $

        $\\displaystyle 
        = {d_dx_latex}\\left({{{mul_args_str}}}\\right)
        $

        $\\displaystyle 
        = {t_Ders_latex_str}
        $

        $\\displaystyle 
        = {t_Ders_simplified_str}
        $

        $\\displaystyle 
        = {labels_str}
        $
        """.strip()
        
        pre_statement = f"""
        <b>Compute each derivative separately:</b>
        """.strip()
        
        post_statement = []
        ps_item = f"<b>Combine result of factors:</b>"
        post_statement.append(ps_item.strip())
        
        ps_item = f"$\\displaystyle {latex(label)} = {latex(dfdx)}$"
        post_statement.append(ps_item.strip())
        
        ps_item = f"$\\displaystyle = {labels_str}$"
        post_statement.append(ps_item.strip())
        
        if latex(answer) != answers_str:
            ps_item = f"$\\displaystyle = {answers_str}$"
            post_statement.append(ps_item.strip())
        
        if latex(answer) != answers_computed_str:
            ps_item = f"$\\displaystyle = {answers_computed_str}$"
            post_statement.append(ps_item.strip())
        
        ps_item = f"$\\displaystyle = {latex(answer)}$"
        post_statement.append(ps_item.strip())
        
        post_statement = "\n\n".join(post_statement)
        
        solution_step = {
            'solution_idx': solution_idx,
            'label': label,
            'method_name': inspect.stack()[0][3],
            'f': f,
            'x': x,
            'x0': x0,
            'Derivative': dfdx,
            'Derivative_latex': ltx(dfdx),
            'type': 'Mul',
            'start_statement': start_statement,
            'pre_statement': pre_statement,
            'post_statement': post_statement,
            'sub_solutions': sub_solutions,
            }
        
        return solution_step

    def solution_to_str(self, solution):
        ltx = self._modified_latex
        
        solution_str = self._solution_to_str(solution)

        statement = f"""
        Therefore:
        
        $\\displaystyle 
        {latex(solution['label'])} 
        = {ltx(solution['Derivative'])} 
        = {latex(solution['answer'])} 
        $
        """.strip()
        
        solution_str = f"""{solution_str}
        
        {statement}
        """
        
        return solution_str

    def _solution_to_str(self, solution):
        if solution is None:
            return ""
        
        #keys = list(solution.keys())
        statements = []

        solution_idx = solution.get('solution_idx', None)

        method_name = solution.get('method_name', None)
        if self.verbose:
            statements.append(f"solution_idx: {solution_idx}:: method_name: {method_name}")

        start_statement = solution.get('start_statement', None)
        if start_statement:
            statements.append(start_statement)
            
        pre_statement = solution.get('pre_statement', None)
        if pre_statement:
            statements.append(pre_statement)
            
        statement = solution.get('statement', None)
        if statement:
            statements.append(statement)
            
        sub_solutions = solution.get('sub_solutions', None)
        if sub_solutions:
            for sub_solution in sub_solutions:
                sub_solution_statement = self._solution_to_str(sub_solution)
                if sub_solution_statement:
                    #sub_solution_method_name = sub_solution['method_name']
                    #statements.append(f'#START: sub_solution_statement {sub_solution_method_name}')
                    statements.append(sub_solution_statement)
                    #statements.append(f'##END:  sub_solution_statement end {sub_solution_method_name}')
            
        children = solution.get('children', None)
        if children:
            for k, v in children.items():
                if not isinstance(v, dict):
                    self._log(f"v is not a dict: {type(v)}\n{v}")
                    continue
                child_statement = self._solution_to_str(v)
                if child_statement:
                    statements.append(child_statement)
                pass
            pass

        post_statement = solution.get('post_statement', None)
        if post_statement:
            statements.append(post_statement)
        
        answer_statement = solution.get('answer_statement', None)
        if answer_statement:
            if self.verbose:
                statements.append(f"solution_idx: {solution_idx}:: method_name: {method_name}")
            statements.append(answer_statement)

        
        statements = "\n\n".join(statements)
        return statements
        pass
    
    def _is_sin_cos_product(self, f, x):
        if not isinstance(f, Mul):
            return False
        
        terms = [t for t in f.args
                 if x in t.free_symbols]
        
        if len(terms) < 2:
            return False
        
        is_sin = False
        is_cos = False
        for t in terms:
            if isinstance(f, sin):
                is_sin = True
                continue
            if isinstance(f, cos):
                is_cos = True
                continue
            pass
        
        if is_sin and is_cos:
            return True
        
        return False
        pass

    def _is_sin_cos_basic(self, f, x):
        if not (isinstance(f, sin) or isinstance(f, cos)):
            return False
        
        for term in f.args:
            if x in term.free_symbols:
                return True
        
        return False
        pass

    def _add_args(self, f, x):
        if not isinstance(f, Add):
            return None
        
        terms = [t for t in f.args
                 if x in t.free_symbols]
        
        if len(terms) < 2:
            return None
        
        return f.args
        pass
    
    def _mul_args(self, f, x):
        if not isinstance(f, Mul):
            return None

        terms = [t for t in f.args
                 if x in t.free_symbols]
        
        if len(terms) < 2:
            return None
        
        return f.args
        pass

    def _explog_format(self, f, x):
        if not self._is_pow_of_var(f, x):
            return None
        
        f1 = exp(f.args[1] * log(f.args[0]))
        return f1
        pass

    def _is_pow_of_var(self, f, x):
        if not isinstance(f, Pow):
            return False
        
        if x in f.args[1].free_symbols:
            return True
        
        return False
        pass


    def _get_fu_funcs(self):
        fu_funcs = [TR111,TR22,TR16,TR15,TR14,
            TRmorrie,TR13,TR12i,TR12,TR11,TR10i,TR10,TR9,
            TR8,TR7,TR6,TR5,TR4,TR3,TR2i,TR2,TR1,TR0,]
        return fu_funcs
        pass
    
    
    def _apply_fu_funcs(self, f, x):
        #print(f"Inside self._apply_fu_funcs({f})")
        fu_funcs = self._get_fu_funcs()
        
        type_name = type(f).__name__
        composite_fun_types = [
                'Mul', 
                'tan',
                ]
        if (f.args[0] == x and 
            type_name not in composite_fun_types
            ):
            return None
        
        modified_fs = []
        for fu_func in fu_funcs:
            try:
                f1 = fu_func(f)
                #print(f"{fu_func.__name__}: {f}: {f1}")
                
                if str(f) != str(f1):
                    #print(f"{fu_func.__name__}: {f}: {f1}")
                    modified_fs.append(f1)
                    pass
                pass
            except Exception as e:
                sError = f"""Error {self.__class__.__name__}.{inspect.stack()[0][3]}:{e}
                f = {f}
                """
                self._log(sError)
                self._log(traceback.format_exc())
            pass
        
        return modified_fs
        pass
    
    def _get_trig_tr_transform(self, f, x):
        type_name = type(f).__name__
        
        if isinstance(f, Mul):
            f1 = TR8(f)
            if str(f) != str(f1):
                return f1
        
        if ((isinstance(f, tan) or isinstance(f, cot)) and  
            f.args[0] == x):
            f2 = TR2(f)
            if str(f) != str(f2):
                return f2

        if ((isinstance(f, csc) or isinstance(f, sec)) and  
            f.args[0] == x):
            f3 = TR1(f)
            if str(f) != str(f3):
                return f3

        
        return None
        pass
    
    
    def _get_trig_func_name(self, f, x):
        trig_func_types = self._get_trig_func_names()
        composite_fun_types = ['Mul', 'Add']
        try:
            type_name = type(f).__name__
            
            #print(f"f = {f}: {type_name}")
            if type_name in trig_func_types:
                #print(f"returning type_name = {type_name}")
                return type_name
            
            if not type_name in composite_fun_types:
                #print(f"type_name = {type_name} is not {composite_fun_types}")
                pass
            else:
                #print(f"f.args = {f.args}: {type(f.args)}")
                is_trig_func = True
                for term in list(f.args):
                    term_type_name = type(term).__name__
                    
                    #print(f"term = {term}: {term_type_name}")
                    if not term_type_name in trig_func_types:
                        is_trig_func = False
                    pass
                
                if is_trig_func:
                    comp_type_name = f"{type_name}_trig"
                    
                    #print(f"returning comp_type_name = {comp_type_name}")
                    return comp_type_name
                pass
            pass
        except Exception as e:
            sError = f"""Error {self.__class__.__name__}.{inspect.stack()[0][3]}:{e}
            f = {f}
            x = {x}
            """
            print(sError)
            print(traceback.format_exc())

        return None
        pass
    
    def _get_trig_func_names(self):
        trig_func_types = ['acos', 'acot', 'acoth', 'acsc', 
                           'asec', 'asin', 'asinh', 'atan', 
                           'atan2', 'atanh', 'cos', 
                           'cosh', 'cot', 'coth', 'csc', 
                           'sec', 'sin', 'sinc', 'sinh', 'tan', 
                           'tanh']
        return trig_func_types
        pass

    def _get_new_var(self):
        if len(self._vars) == 0:
            self._init_vars()
        var_name =  self._vars.pop()
        return Symbol(var_name)
        pass
        

    def _modified_latex(self, expr, funct_symbs = None, mul_symbol = None):
        expr = self._convert_function_to_symbol(expr, funct_symbs = funct_symbs)
        expr_latex = latex(expr, mul_symbol = mul_symbol)
        
        expr_latex = expr_latex.replace("\\partial", "d")
        
        return expr_latex
        pass

    def _convert_function_to_symbol(self, expr, funct_symbs = None):

        if not hasattr(expr, 'subs'):
            return expr

        if funct_symbs is None:
            return expr

        if not funct_symbs:
            return expr
        
        temp_dict = {}
        for funct_symb in funct_symbs:
            temp_dict[funct_symb] = Symbol(funct_symb.name)
            pass
        
        if not temp_dict:
            return expr

        expr_subs = expr.subs(temp_dict)
        
        return expr_subs
        pass
    
    def _log_DELETED(self, sOut, to_print = None):
        if to_print is None:
            to_print = self.verbose
            
        if to_print:
            print(sOut)
        
    pass
#class AutoDerivationCalcHelper:
