Search

Search for a page or for a specific topic

Can't find what you're looking for ? Check the Docs

Settings

Documentation

Explore our library and utilize it for your ease. Solve equations, plot functions, and create matrices with our extensive set of features.

Installation

You can install the library via pip:

pip install kiwicalc

Importing

There are several ways to import the library. Either import the whole thing and abbreviate it:

                    import kiwicalc as kw
                

Or import specific elements from the library:

                    from kiwicalc import ...
                

Or import everything like that:

                        from kiwicalc import *
                    

Solving Equations

You can solve a variety of equations using our library by using methods or classes in order to solve them. Creating new objects will enable more features, but it's usually less memory performant than using methods.

Linear Equations

The quickest way to solve a linear equation is to use the solve_linear method. The method accepts a string that represents the equation, and optionally also a collection of the variables_dict in the equation. For example:

                    
from kiwicalc import solve_linear
linear_equation = "3x + 7 = -x -5"
result = solve_linear(linear_equation)
print(result)
                    
                

If you want to speed up this method, specify the variables that appear in it:

                        result = solve_linear(linear_equation, ('x',))
                    

You can also use the LinearEquation class, which offers more features.

class LinearEquation

                        class LinearEquation(Equation):
                    
Properties
  • equation - the string that represents the linear equation
  • variables - a list of the variables that appear in the equation
  • num_of_variables - the number of variables that appear in the equation
  • first_side - a string that represents the first side of the equation
  • second_side - a string that represents the second side of the equation
  • solution - the solution of the equation
  • variables_dict - An internal dictionary that represents the coefficients of the variables in the equation, when all moved to the first side.

Create a new LinearEquation object

                        def __init__(self, equation: str, variables: Iterable = None, calc_now: bool = False):
                    

You have to enter the equation as a string. For instance: "3x+5 = 8x-4". In addition, you can specify the variables that appear in the equation, so we won't have to figure it out ourselves. This results in a significant runtime improvement (even though it's pretty fast regardless). You can also set the calc_now parameter to True if you want that the equation will be solved when the LinearEquation object is created. For instance:

                        my_equation = LinearEquation("3x + 5 = 8", variables=('x',))
                    

Fetching the solutions of the equation

You can either get the solutions with the solve() method, or the solution property. For example:

                        my_equation = LinearEquation("3x + 5 = 8", variables=('x',))
print(my_equation.solution)
print(my_equation.solve())
                    

Solve while printing the steps to the solution

                        my_equation = LinearEquation("3x - 7 + 2x + 6 = 4x - 4 + 6x")
print(my_equation.show_steps())
                    

Output:

This method is not well optimized yet, but it works pretty well!

Simplifying the equation

You can simplify the linear equation (without necessarily solving it) via the simplify() method. For instance:

                        my_equation = LinearEquation("4 + 3x + 2y + 5x + 6 = 2 + x - y", variables=('x', 'y'))
my_equation.simplify()
print(my_equation)
                    

Plot the solution

If the equation indeed has a solution, you can plot it as the intersection of two linear functions. Here is the signature of the method:

                        def plot_solution(self, start: float = -10, stop: float = 10, step: float = 0.01, ymin: float = -10,
                      ymax: float = 10, show_axis=True, show=True, title: str = None, with_legend=True):
                    
                        my_equation = LinearEquation("3x - 7 + 2x + 6 = 4x - 4 + 6x")
print(my_equation.plot_solution())
                    

Output:

Polynomial Equations

Short sum up

If you don't know the degree of the polynomial equation ( linear, quadratic, cubic ... ), and you just want it solved, you can use the solve_polynomial() method. The method accepts either the coefficients of the polynomials or a string that represents the equation and returns a set of solutions. The method picks the method of solving according to the length of the coefficients. For instance, the coefficients `[1,6,8]` represent the equation `x^2 + 6x + 8` and therefore the quadratic formula will be used. Pay attention that entering the coefficients directly will be faster than entering a string, if speed is a significant consideration for you. Here is the signature of the method:

                        def solve_polynomial(coefficients, epsilon: float = 0.000001, nmax: int = 10_000):
                    
For example:
                        print(solve_polynomial([1, 0, 0, 0, 0, 0, 2, 0, 0, -18]))
                    
                        print(solve_polynomial("x^2 + 3x^3 + 12x + 5 = 2x -4 + x^2"))
                    

You can also enter a string that represents a polynomial (and not an equation) as well:

                        print(solve_polynomial("x^2 + 6x + 8"))
                    

Alternatively, you can create a PolyEquation object, and call the solve() method or the solution property. This can be much simpler, but might be a bit less efficient. For example:

                        
# Solving with PolyEquation objects
from kiwicalc import PolyEquation
poly_equation = PolyEquation("x^2 - 8x = 15")
print(poly_equation.solution)
                        
                    

However, if you know for instance that the equation is of degree 2,3 or 4, then you should use the appropriate methods for each degree, which are explained below.

Quadratic Equations

In order to solve quadratic equations you can use the solve_quadratic() method. The method accepts 3 parameters: `a`, `b`, and `c` which represent the coefficients in the formula: `ax^2 + bx +c`. The method will compute 2 solutions, whether they are real or complex. If you are only interested in the real solutions, you can use the solve_quadratic_real() method. Of course, the method internally uses the known quadratic formula: `x = (-b +- sqrt(b^2-4ac))/(2a) .`
For example, lets solve the equation `x^2 + 6x + 8` and `x^2 + x + 2` with both of the methods

                        
solutions = solve_quadratic(1,6,8)
real_solutions = solve_quadratic_real(1,6,8)
print(solutions)
print(real_solutions)
                        
                    

And now lets solve the equation `x^2 + x + 1` with both of the methods:

                        
solutions2 = solve_quadratic(1,1,2)
real_solutions2 = solve_quadratic_real(1,1,2)
print(solutions2)
print(real_solutions2)
                        
                    

You can also solve quadratic equations with parameters via the solve_quadratic_params. For instance:

                        a, b, c = Var('a'), Var('b'), Var('c')
results = solve_quadratic_params(a, b, c)
print(result[0])
print(result[1])
                    
It's also possible to use the QuadraticEquation class for a wider variety of operations on quadratic equations.

class QuadraticEquation

                        class QuadraticEquation(Equation):
                    

Properties

The QuadraticEquation class has the same properties as the LinearEquation class, since both of them inherit from the Equation class.

create a new QuadraticEquation object

You can create a new QuadraticEquation object by entering a string that represents the equation, and optionally you can also enter variables that appear in the equation. For instance:

                        my_equation = QuadraticEquation("x^2 + 6x + 8 = 0")
                    

Solving the equation

You can solve the quadratic equation with solve() method. Here is the signature of the method:

                        def solve(self, mode='complex'):
                    

You can choose how to solve the quadratic equation via the mode parameter:

  • 'complex' - solve for all the solutions, including complex solutions
  • 'real' - solve only for the real solutions

For instance:

                         my_equation = QuadraticEquation("x^2 + 6x + 8 = 0")
solution = my_equation.solve()
print(solution)
                    
                        my_equation = QuadraticEquation("x^2 + 6x + 8 = 0")
solution = my_equation.solve('real')
print(solution)
                    

Get a simplified equation

You can simplify quadratic expressions via the simplified_str() method. Currently, it's only available for quadratic equations with 1 variable. For instance:

                        my_equation = QuadraticEquation("x^2 + 2x + 4x + 8 = 0")
print(my_equation)
print(my_equation.simplified_str())
                    

Get the coefficients of the equation

You can get the coefficients of the equation via the coefficients() method. For instance:

                        my_equation = QuadraticEquation("x^2 + 6x + 8 = 0")
print(my_equation.coefficients())
                    

Generate a random quadratic equation

You can generate a random quadratic equation via the static method random(). The method accepts several optional parameters:

  • values - the range of the coefficients in the equation. Default: (-15, 15)
  • digits_after - the number of digits after the decimal point of the solutions. Default: 0
  • variable - a string that represents the variable which appears in the equation.
  • strict_syntax - a boolean value that determines whether the equation will be created in the traditional form of `ax^2 + bx + c = 0`. The default is True.
  • get_solutions - if set to True, the function will return both the equation and the solutions of the equation as a tuple of (equation, solutions).

For example:

                        print(QuadraticEquation.random())
                    
                        print(QuadraticEquation.random(digits_after=1, variable='y'))
                    

Export to PDF worksheets

You can use the random_worksheet() method to create a PDF document with 1 page of equations, and optionally also an additional page of solutions. Here is the signature of the method:

                        @staticmethod
def random_worksheet(path:str=None, title="Quadratic Equations Worksheet", num_of_equations=20,
                     solutions_range=(-15, 15), digits_after: int = 0, get_solutions=True):
                        
                    

For instance:

                        print(QuadraticEquation.random_worksheet("worksheet1.pdf", title="Example"))
                    

You can also create a PDF worksheets with several pages via the random_worksheets() method. Here is the signature of the method:

                        @staticmethod
def random_worksheets(path=None, num_of_pages=2, equations_per_page=20, titles=None,
                     solutions_range=(-15, 15), digits_after: int = 0, get_solutions=False):
                    

For instance:

                        print(QuadraticEquation.random_worksheets("worksheet2.pdf", num_of_pages=5))
                    

Cubic Equations

Lets move to cubic equations, namely, equations in the form `ax^3+bx^2+cx+d` where `a!=0`. You can solve cubic equations with the solve_cubic() method. The method accepts four real coefficients: `a`, `b`, `c`, `d`, and returns a tuple of the 3 solutions. If you are only interested in the real solutions, you can use the solve_cubic_real method. For example, lets solve the equation `x^3 + 3x^2 - 4x - 8`.

                        
solutions = solve_cubic(1, 3, -4, -8)
real_solutions = solve_cubic_real(1, 3, -4, -8)
print(solutions)
print(real_solutions)
                        
                    

You can also use the CubicEquation class, which offers the same methods in the QuadraticEquation class. For instance:

                        my_equation = CubicEquation("x^3 + 3x^2 - 4x - 8 = 0")
print(my_equation.solution)
print(my_equation.coefficients())
print(my_equation.random())
                    

Quartic Equations

You can also solve quartic equations, namely, equations in the form `ax^4 + bx^3 + cx^2 + dx + e` with the solve_quartic() method.

The method accepts the 5 coefficients: `a`, `b`, `c`, `d`, `e`, and returns the 5 solutions ( either real or complex ). For example, lets solve the equation `x^4 + 3x^2 - 6x + 1`:
                        solution = solve_quartic(1, 0, 0, 0, -16) # x^2 - 16 = 0
print(solution)

# output:
# [(2+0j), 2j, -2j, (-2+0j)]
                    

Source: http://www.1728.org/quartic2.htm

You can also use the QuarticEquation class, which offers the same methods in the QuadraticEquation class. For instance:

                        my_equation = QuarticEquation("x^4 - 16 = 0")
print(my_equation.solution)
print(my_equation.coefficients())
print(my_equation.random())
                    

According to Abel's impossibility theorem, a general formula for solving equations of degree 5 or higher does not exist. Therefore, in order to solve such equations, we can use use numerical methods. These methods are iterative - they generate closer approximations to the solutions on each iteration, so after a certain number of iterations the approximations converge to the solutions. Hopefully this general explanation of numerical root-finding methods was clear enough for you to understand. If not, that's ok, since you don't have to know how the library works in order to use it.

Polynomial equations of degree 5 or more

You can solve these kind of equations with the numerical root-finding methods which were implemented in the library, so I recommend you look at the numerical methods section of this documentation. In short, if you wish to find only 1 root, you can use the newton_raphson() and its counterparts, but if you wish to find all of the roots, you can use the durand_kerner() method or the aberth_method() method.

                        
# Add examples here
                        
                    

Solving A System Of Equations

As to this version, you can only solve systems of linear and polynomial equations. Other systems of equations might be available as a beta feature in the next version or update.

System of linear equations

You can solve a system of linear equations via the solve_linear_system() method. The method accepts a collection of strings where each string is a linear equation, and optionally also a collection of the variables that appear in the equation. By entering the variables yourself instead of letting the method to deduce them, you save runtime. Here is the signature of method:

                            def solve_linear_system(equations, variables=None):
                        

The method will return a dictionary with the variables as keys and the solutions as the values. Internally, the Gaussian Elimination method is used to solve the system. For example:

                        solutions = solve_linear_system(("3x - 4y + 3 = -z + 9", "-3x + 5 -2z = 2y - 9 + x", "2x + 4y - z = 4"))
print(solutions)
                    

class LinearSystem

You can also use the LinearSystem class. Creating a LinearSystem object will be a bit slower, but will provide with some helpful features, like converting the equation system to a matrix. For more on the LinearSystem class, go to the LinearSystem section. For instance, lets declare the LinearSystem object.

Properties

  • variables_dict - a list of all of the variables_dict in the
  • equations - a list of all of the equations(list of strings) in the system.
                        linear_system = LinearSystem(("3x - 4y + 3 = -z + 9", "-3x + 5 -2z = 2y - 9 + x", "2x + 4y - z = 4"))
print(linear_system.get_solutions())
# linear_system.solutions - will also work
                    

Systems of polynomial equations

You can solve a system of polynomial equations using the solve_poly_system method. Further elaboration on the method is written on the numerical methods section of this documentation. Here is an example of using the method to find the solutions of the system: $$ \left\{ \begin{array}{c} x^2 + y^2 = 25 \\ 2x + 3y = 18 \\ \end{array} \right. $$ with the initial guesses `X_0 = [[2],[1]]`

                        # Solving systems of polynomial equations via KiwiCalc
solutions = solve_poly_system(["x^2 + y^2 = 25", "2x + 3y = 18"], {'x': 2, 'y': 1})
print(solutions)
# output: '{'x': 3.0000001628514434, 'y': 3.999999891432371}'
                    

Plotting Methods

The module contains a set of plotting functions with matplotlib. These methods are extremely easy to use - you don't have to engage with the relatively sophisticated technical details involving matplotlib's interface. Although many classes in KiwiCalc provide plotting abilities, you don't have to learn any of them to use these methods.

scatter_dots()

You can scatter dots in 2D via the scatter_dots() method. Here is the signature of the method:

                        def scatter_dots(x_values, y_values, title: str = "", ymin:float=-10, ymax:float=10,show_axis=True,
     show=True):
                    

For example:

                        scatter_dots([1,2,3,4],[2,4,6,8], title="Matplotlib is awesome")
                    

Output:

scatter_dots_3d()

You can scatter dots in 3D via the scatter_dots_3d() method. Here is the signature of the method:

                        def scatter_dots_3d(x_values, y_values, z_values, title: str = "", fig=None,
      ax=None, show_axis=True, show=True):
                    

For example:

                        scatter_dots_3d([4, 2, 8, -5, 3, 8, 9, -6, 1], [1, 9, -2, 3, 4, 5, 1, 6, 7], [1, -1, 3, 4, 8, 1, 4, 2, 6])
                    

Output:

plot_function()

You can plot functions with only variable on a 2D axis system via the plot_function() method. Here is the signature of the method:

                        def plot_function(func: Union[Callable, str], start: float = -10, stop: float = 10,
                 step: float = 0.01, ymin: float = -10, ymax: float = 10, title=None, show_axis=True, show=True,
                 fig=None, ax=None, formatText=True, values=None):
                    

Parameters

  • func- A function to plot. We offer a variety of ways to enter this function. You can enter any matching callable object, such as lambda expressions and Function objects. You can even enter a string that represents the function. This is as simple as it gets...
  • start- Where to start plotting
  • stop- Where to stop plotting
  • step- Interval between each `x`
  • ymin- Smallest `y` in the scope
  • ymax- Biggest `y` in the scope
  • title- The title of the graph
  • show_axis- Whether to show the `x` and `y` axis or not ( True / False)
  • show- Whether to show the graph or not ( True / False)
  • fig- An existing matplotlib figure to plot in. In order to use this parameter, you must also the ax parameter.
  • ax- An existing axis in matplotlib. In order to use this parameter, you must also the fig parameter.
  • formatText- An experimental feature: whether to try formatting the text into latex or not. Currently it's slow and therefore the default is False. However, in later versions, this might be more advanced.
  • values- Instead of specifying a range via the start, stop and step parameters, you can specify the `x` values yourself.

For instance:

                        plot_function("f(x) = x^2")
                    

Output:

                        plot_function(Function("f(x) = sin(x)"))
                    
                        plot_function(lambda x: 2*x)
                    

plot_function_3d()

You can plot functions with 2 variables on a 3D axis system via the plot_function_3d() method. Here is the signature of the method:

                        def plot_function_3d(given_function:"Union[Callable, str]", start: float = -3, stop: float = 3, step: float = 0.1, xlabel: str = "X Values",
      ylabel: str = "Y Values", zlabel: str = "Z Values"):
                    

For instance:

                        plot_function_3d("f(x,y) = sin(x)*cos(y)")
                    

Output:

scatter_function()

You can scatter functions with only 1 variables on a 2D axis system via the scatter_function() method. Here is the signature of the method:

                        def scatter_function(func: Union[Callable, str], start: float = -10, stop: float = 10,
        step: float = 0.5, ymin: float = -10, ymax: float = 10, title="", show_axis=True, show=True):
                    

For example:

                            scatter_function("f(x) = sin(x)")
                    

Output:

                            scatter_function(lambda x:x**2)
                    
                            scatter_function(Function("f(x) = 2x"))
                    

scatter_function_3d()

You can scatter functions with 2 variables on a 3D axis system via the scatter_function_3d() method. Here is the signature of the method:

                       def scatter_function_3d(func: "Union[Function, str]", start: float = -10, stop: float = 10,
     step: float = 0.5, title=None, show=True):
                    

For example:

                        scatter_function_3d("f(x,y) = x + y")
                    
                        import math
scatter_function_3d(lambda x, y: math.sin(x)*math.cos(y))
                    

                        scatter_function_3d(Function("f(x,y) = xy"), title="Hi there!")
                    

plot_functions()

You can plot several functions with only 1 variable on a 2D axis system via the plot_functions() method. Here is the signature of the method:

                        def plot_functions(functions, start: float = -10, stop: float = 10, step: float = 0.01, ymin: float = -10,
     ymax: float = 10, text: str = None,
     show_axis: bool = True, show: bool = True):
                    

For example

                        plot_functions(["f(x) = 2x", "f(x) = x^2", "f(x) = sin(x)"])
                    

plot_functions_3d()

You can plot several functions with 2 variables on a 3D axis system via the plot_functions_3d() method. Here is the signature of the method:

                        def plot_functions_3d(functions: "Iterable[Union[Callable, str, IExpression]]", start: float = -5, stop: float = 5,
     step: float = 0.1,
     xlabel: str = "X Values",
     ylabel: str = "Y Values", zlabel: str = "Z Values"):
                    

For instance:

                        plot_functions_3d(["f(x,y) = sin(x)*cos(y)", "f(x,y) = sin(x)*ln(y)"])
                    

Output:

If the plot is too laggy, try increasing the step parameter.

plot_multiple()

You can plot several functions on different figures via the plot_multiple() method. Here is the signature of the method:

                        def plot_multiple(funcs, shape: Tuple[int, int] = None, start: float = -10, stop: float = 10,
                  step: float = 0.01, ymin: float = -10, ymax: float = 10, title=None, show_axis=True, show=True,
                  values=None):
                    

The method accepts parameters:

  • funcs- a collection of functions - you can enter strings, lambda expressions, Function objects, algebraic expressions, etc.
  • shape(optional) A tuple of two integers that represents the shape of the figures - for instance, a grid of 3x3 figures. If not specified, the best fitting shape will be chosen automatically.
  • start(optional)Where to start plotting from - float. Default is -10
  • stop(optional)Where to end plotting - float. The default is 10
  • step(optional)- The distance between each x value in the plotting range, of type float. The default is 0.01. Smaller steps result in higher accuracy but it makes the plotting process much slower and often cause lags.
  • ymin(optional)- the smallest `y` value that will be visible in the scope of the plot, of type float. The default is -10.
  • ymax(optional)- the highest `y` value that will be visible in the scope, of type float. The default is 10.
  • title(optional)- The title of the plot.
  • show_axis(optional)- Whether to show the axis or not in the plot. The default is True.
  • show(optional)- Whether to show the plot. The default is True.
  • values(optional)- Default is None. If this parameter is not None, its value will be used as the range of x values to plot in instead of the start, stop, and step parameters.

For instance:

                        plot_multiple(["f(x) = 4x", "f(x) = x^2", "f(x) = x^3", "f(x)= 8", "f(x)=ln(x)", "f(x)=e^x", "f(x)=|x|", "f(x)=sin(x)", "f(x)=cos(x)"])
                    

Output:

plot_complex()

You can plot a complex number or several complex numbers via the plot_complex() method. Here is the signature of the method:

                        def plot_complex(*numbers: complex, title:str="", show=True):
                    

Parameters

  • *numbers - complex numbers to plot
  • title - specify a title for the plot
  • show - whether to show the plot or not ( True / False)

For example:

                        plot_complex(complex(5, 4), complex(3, -2))
                    

Output:

Class Function

You can quickly declare mathematical functions, execute them, and plot them via the Function class. You can also find the roots of the function numerically and also its minimum and maximum points depending on the type of the function, Support for derivatives, partial derivatives, and integrals is supported or developed for certain types of functions.

Properties

  • function_string - A string that represents the function. For instance, "f(x) = x^2"
  • function_signature - The signature of a function is the function declaration on the function_string property. For instance, "f(x)", "g(x,y)".
  • function_expression - A string that represents only the expression of the function; For instance, the expression of the function "f(x) = x^2"
  • lambda_expression - A lambda expression that represents the function. If one cannot be generated, it will be set to None.
  • variables_dict - A list of all of the variables_dict that appear in the function. For instance, it will be ['x','y'] for the function "f(x,y) = x+y".
  • num_of_variables - the number of variables_dict that appear in the function.
  • classification - Experimental feature - classifying functions. The classification is presented via the class Function.Classification. Namely, the Classification class is nested within the Function class, and inherits from Enum.
                                    class Classification(Enum):
        linear = 1,
        quadratic = 2,
        polynomial = 3,
        trigonometric = 4,
        logarithmic = 5,
        exponent = 6,
        constant = 7,
        command = 8,
        linear_several_parameters = 8,
        non_linear_several_parameters = 9,
        exponent_several_parameters = 10,
        predicate = 11
                                

Creating a new Function object

                        # The __init__ method's signature
    def __init__(self, func=None):
                    

There are several ways to create a new Function object:

  1. Entering a string in mathematical format

    The string representation of the function must follow this syntax:

                                    "function_name(a,b,c...) = ......"
                                

    For example:

                                    # Creating a new Function object, with a string in math syntax
    sine = Function("f(x)=sin(x)")
                                
  2. Entering a string in a lambda syntax

    You can also define your function with a lambda-like syntax.
    Either in a more pythonic syntax:
                                    # Creating a Function object via a string in python-like lambda expression syntax.
    sine = Function("lambda x:sin(x)")
                                
  3. or in a more C# or Javascript like manner:

                                    sine = Function("x => sin(x)")
                                

    You can also enter strings that represent lambdas with multiple parameters, separated by commas.

    For instance, a function that takes three parameters, and returns their sum:
                                    three_sum = Function("x, y, z => x + y + z")
                                
  4. Entering a Mono or Poly expression

    You can also create a function by entering a monomial (Mono) and polynomial(Poly). These two classes are documented later in this documentation. For example:
                            x = Var('x')
    func = Function(-x**2 + 6*x + 7)
    
                       

    Keep in mind that while this method is rather simple, it's a bit slower, since extra steps are being taken for the conversions.

  5. Entering a lambda expression

    Entering a lambda expression as a parameter is not highly recommended, however, it will work rather fast if, and only if, you declare the lambda expression inside the constructor, and not in previous lines. For example:
                                    from math import sin,pi
    sine = Function(lambda x:sin(x))
    print(sine(pi/2))
    
    # output:
    # 1.0
    
                                

Calling the function

                        def __call__(self, *parameters):

Parameters

*parameters - an unlimited number of parameters. these parameters will be assigned respectively to the Function's object variables_dict. For example:
                        parabola = Function("f(x)=x**2")
print(parabola(5))

Output:

                        25.0
                    
                        three_sum = Function("g(a,b,c)=a+b+c")
print(three_sum(6,5,4))
# output:
#15.0
                        # Creating functions that return True or False
equality = Function("f(a,b) = a==b")
print(equality(5,5))
print(equality(4,6))

# output:
# True
# False
                        from math import pi
trigo_op = Function("f(x) = -sin(x) + 2cos(2x)")
print(trigo_op(math.pi/2))
# output:
# -3.0

Getting the variables that appear in the function

functions can contain several variables, and sometimes it is necessary to find out what variables_dict does a function contain. For that, you can either use the variablesproperty, or the square brackets operator. If you choose to use the square brackets operator, you can enter an index to the function or use list slicing. If an index is given to the function the variable name (type str) in the specified index will be returned. In case of list slicing, a list of all function's variables_dict between the specified indices will be returned. For example:

# Retrieving the variables appearing in a function
three_sum = Function("g(a,b,c)=a+b+c")
print(three_sum.variables)
print(three_sum[0])
print(three_sum[1:3])
                    

Derivative of a function

As for this version, only linear and polynomial derivatives are fully implemented. Trigonometric derivatives are also available, but the support is still basic and needs to be extended. A larger variety of derivatives will be supported in the future.

                        # The method's signature
def derivative(self):
                    
The method accepts no parameters and returns a Function object that represent the derivative of the original function. for example:
                        func = Function("f(x)=x**2+2x-5")
print(func.derivative())
                    

Partial Derivatives

As for now, partial derivatives are only supported in linear and polynomial functions. The support is expected to be extended in the next versions.

                        func = Function("f(x,y) = x^2 * y^2")
print(func.partial_derivative('x'))
print(func.partial_derivative('y'))
print(func.partial_derivative('xy'))
                    

Plotting

You can plot Function objects with the plot() method. The plotting is done behind the scenes with the python library matplotlib, as for this version. Functions with two variables_dict will automatically be plotted in 3D space.

                        def plot(self, start: float = -10, end: float = 10, step: float = 0.01, ymin: float = -10, ymax: float = 10,
        others: "Optional[Iterable[Function]]" = None, show_axis=True, show=True):

                    

Parameters:

You can adjust the graph by modifying these settings:

Name Type Default Value Meaning
start float `-10` The `x` value that plotting starts from
end float `10` The `x` value in which the plotting ends
step float `0.05` The distance between each computed point in the `x` axis
ymin float `-10` The lowest y value that is shown (without scrolling down the graph)
ymax float `10` The highest y value that is shown (without scrolling up the graph)
show_axis True True Whether to draw an axis system along with the graph, or not
show True True Whether to show the graph or not.
  • start: type float, the number to start plotting from. Default value is -10.
  • end: type float, Plotting from start to end
  • step:type float, the interval between each dot in the graph. Default value is 0.01.
  • scatter: type bool, if set to True, the graph will be scattered and not plotted. Default value: False
  • ymin: type float, the minimum value of y in the graph's perspective.
  • ymax: type float, the maximum value of y in the graph's perspective.
  • others: other functions that you wish to plot together with the current function
  • show_axis: whether to show the axis or not
  • show - whether to show the graph, or not


For example, lets plot the function `f(x) = x^2` when `-10 <= x <= 10`. And lets customize the interval between each x value to `0.1`, by setting the step parameter. Smaller distances between each dot will result in better accuracy of the function, but it will also take more time.

                    # Plotting ( via matplotlib )
fn = Function("f(x) = x^2")
fn.plot(start=-10, stop=10, step=0.1)
                
You can also define and plot some more sophisticated and versatile functions. For example, lets plot the function `f(x) = xe^{sin(x)} - 3ln(abs(4x))`
                        # Plotting ( via matplotlib )
example_function = Function("f(x)=xe^sin(x)-3ln(|4x|)")
example_function.plot(start=-10, stop=10)
                    

Scattering

You can scatter functions via the scatter(), scatter2d() and scatter3d() methods. The scatter method chooses whether to use the scatter2d() method or the scatter3d() method, depending on the number of variables. If you already know whether to scatter in 2d or 3d, you should use the scatter2d() and scatter3d() methods, as they are more explicit and offer more parameters. Here are the signatures of the three methods:

                        def scatter(self, start: float = -15, end: float = 15, step: float = 0.1, ymin=-15, ymax=15, show_axis=True,show=True):
                    
                        def scatter2d(self, start: float = -15, stop: float = 15, step: float = 0.3, ymin=-15, ymax=15, show_axis=True,
              show=True, basic=True):
                    
                        def scatter3d(self, start: float = -3, stop: float = 3,
              step: float = 0.3,
              xlabel: str = "X Values",
              ylabel: str = "Y Values", zlabel: str = "Z Values", show=True, fig=None, ax=None,
              write_labels=True, meshgrid=None, title=""):
                    

For example:

                        fn = Function("f(x) = x^2")
fn.scatter2d()
                    
                        fn1 = Function("f(x,y) = sin(x) * cos(y)")
fn1.scatter3d()
                    

Additional Experimental Feature: If the basic parameter in the scatter2d() is set to False, the points will contain labels. This may be more convenient, but it's also slower. For instance:

 fn2 = Function("f(x) = x^2")
fn2.scatter2d(basic=False)

Context Manager

You can use a context manager for a Function object, namely, using the with keyword. That way, the Function object will be deleted once the code goes out of the scope of the with segment.

                        with Function("f(x) = x^2") as fn:
    pass # Do some stuff here
                    

class FunctionCollection

The FunctionCollection class is used in order to represent a collection of functions, namely, a collection of Function objects.

Properties

  • functions - returns a list of the Function objects in the collection.
  • num_of_functions - returns the number of the Function objects in the collection.

__init__()

You can create a new instance FunctionCollection by entering Function objects or strings that represent the functions. Here is the signature of the method:

                        def __init__(self, *functions, gen_copies=False):
                    

In addition, if gen_copies is set to True, the collection will take the copies of the items instead of the originals. Here are some examples:

                        functions = FunctionCollection(Function("f(x) = x^2"), Function("g(x) = sin(x)"), Function("h(x) = 2x"))
print(functions)

                    
                        functions = FunctionCollection("f(x) = x^2", "g(x) = sin(x)", "h(x) = 2x")
                    
                        functions = FunctionCollection(Function("f(x) =x^2"), "g(x) = sin(x)", Function("h(x) = 2x"))
                    

The output will be the same in all of these ways:

                        1. f(x)=x**2
2. g(x)=sin(x)
3. h(x)=2x
                    

add_function()

You can add new functions to the collection after initialization via the add_function method. You can either enter a Function object or a string that represent a function.For instance:

                        functions.add_function(Function("f(x) = 2x"))
                        functions.add_function("f(x) = 2x")
                    

extend()

You can add a collection of functions via the extend() method. The method takes a collection of either Function objects or strings. Here is the signature of the method:

                        def extend(self, functions: Iterable[Union[Function, str]]):
                    

For example:

                        functions.extend(["f(x,y) = x+y", "g(x,y) = cos(x)+cos(y)", "h(x,y) = sin(x)*sin(y)"])
                    

clear()

Pretty straightforward, you can use the clear() method to clear the collection of functions. After that, the collection will considered empty.

is_empty()

The function will return true if the collection is empty, otherwise, False. For example:

                        functions = FunctionCollection()
print(functions.is_empty())
functions.add_function("f(x) = ln(x)")
print(functions.is_empty())
                        
The output is:
         True
False
                    
     functions = FunctionCollection(Function("f(x) =x^2"), "g(x) = sin(x)", Function("h(x) = 2x"))
functions.clear()
print(functions.is_empty())
                    

The output is

                        True
                    

random_function()

Fetch a random Function object from the collection. For example:

                        functions = FunctionCollection(Function("f(x) =x^2"), "g(x) = sin(x)", Function("h(x) = 2x"))
print(functions.random_function())
                    

And the output is:

                        h(x)=2x
                    

random_value()

Fetch a random value from a random function in the collection.

Parameters:

  • a - lower bound of the range of numbers to pick the parameters from.
  • b - higher bound of the range of numbers
  • mode - if mode is "int", integer parameters will be randomly picked via random.randint(a, b), if mode is "float", parameters of type float will be randomly chosen via the random.uniform(a, b) method. The default is "int".

For example:

                        functions = FunctionCollection(Function("f(x) =x^2"), "g(x) = sin(x)", Function("h(x) = 2x"))
print(functions.random_value(1, 10))
                    
                        functions = FunctionCollection("f(x,y) = x + y", "g(x,y) = x - y", "h(x,y) = sin(x) * cos(y)")
print(functions.random_value(1, 10))
                    

plot()

You can plot all of the functions on a shared axis system via the plot() method. Here is the signature of the method:

                    def plot(self, start: float = -10, stop: float = 10,
        step: float = 0.01, ymin: float = -10, ymax: float = 10, text=None, show_axis=True, show=True):
                

For example:

                        functions = FunctionCollection("f(x,y) = x + y", "g(x,y) = x - y", "h(x,y) = sin(x) * cos(y)")
functions.plot()
                    

scatter()

You can scatter all of the functions on a shared axis system via the plot() method. Here is the signature of the method:

                        def scatter(self, start: float = -10, stop: float = 10,
        step: float = 0.01, ymin: float = -10, ymax: float = 10, text=None, show_axis=True, show=True):
                    

For instance:

                        functions = FunctionCollection("f(x,y) = x + y", "g(x,y) = x - y", "h(x,y) = sin(x) * cos(y)")
functions.scatter()
                    

__len__()

You can also fetch the number of functions in the collection via the len() method. For instance:

                        functions = FunctionCollection("f(x) = 2x", "g(x) = x^2", "h(x) = sin(x)")
print(len(functions))
                    

Output:

                        3
                    

class FunctionChain

You can execute several functions on the parameters via the FunctionChain class. For instance, lets define some functions: `f(x) = 2x`, `g(x) = x^2`, and `h(x) = x-2`. They will be executed one after another, where each function's input is the last function's output: `h(g(f(x)))`. For instance, for `x = 4`, `f(4) = 8`, `g(8) = 64` and `h(64) = 62`. Therefore, the final output will be `62`. The FunctionChain class inherits from FunctionCollection, since it's a special collection of functions. Therefore, you can also use methods from FunctionCollection here.

Creating a new instance

You can create a new instance of FunctionChain via the __init__() method by entering functions, exactly the same like creating a FunctionCollection object. Here is the signature of the method:

                        def __init__(self, *functions):
                    

For example:

                        function_chain = FunctionChain("f(x) = x^2", "g(x) = x+5", "h(x) = sin(x)")
                    

In addition, you can also create new instances via the chain() method in the Function class. For instance:

                        function_chain = Function("f(x) = x^2").chain(Function("f(x)=x+5")).chain(Function("h(x) = sin(x)"))
                    

Execute

You can call all of the functions via the execute_all() method, or the built in __call__() method. For example:

                        function_chain = FunctionChain("f(x) = x^2", "g(x) = x+5", "h(x) = sin(x)")
print(function_chain.execute_all(1))
print(function_chain(1))
                    

Output:

                        0.8414709848078965
0.8414709848078965       
                    

Customized Execution

You can execute the functions in a reverse order via the execute_reverse() method.In addition, you can execute only certain functions from the collection via the execute_indices() method. The method accepts a collection of indices in the collection, and execute the functions in these indices by the specified order. For instance:

                        function_chain = FunctionChain("f(x) = x^2", "g(x) = x+5", "h(x) = sin(x)")
print(function_chain.execute_reverse())  # execute in reverse order
print(function_chain.execute_indices([3, 1, 0, 2]))
                    

Plotting

You can plot the FunctionChain object via the plot() method, Here is the signature of method:

                        def plot(self, start: float = -10, stop: float = 10,
         step: float = 0.01, ymin: float = -10, ymax: float = 10, text=None, show_axis=True, show=True,
         values=None):
                    

For example:

                        function_chain = FunctionChain("f(x) = x^2", "g(x) = x+5", "h(x) = sin(x)")
function_chain.plot()
                    

Scattering

You can also scatter the functions via the scatter() method. For instance:

                        function_chain = FunctionChain("f(x) = x^2", "g(x) = x+5", "h(x) = sin(x)")
function_chain.plot()
                    

chain()

You can chain another function to the collection by using the chain() method. For instance:

                        function_chain = FunctionChain("f(x) = x^2", "g(x) = x+5")
function_chain.chain("h(x) = sin(x)")
                    

Indexers

You can fetch the item by its index via the [] operator. For instance:

                        function_chain = FunctionChain("f(x) = x^2", "g(x) = x+5")
print(function_chain[0])
                    

PDF Worksheets

You can generate PDF worksheets in different mathematical subjects and languages either via the worksheet() method or via a system of classes. These classes allow you to customize each exercise so you can create worksheets with exercises of different topics and languages. Also, they offer a wider variety of exercises, while you can only generate equations with the worksheet() method as for this version.

worksheet()

You can create a PDF worksheet document with exercises of the same topic via the worksheet() method. You also get an additional pages of final solutions as default. Here are the parameters of the method:

  • path- a string that represents the path of the file. Default is None, so a path is generated automatically if not specified.
  • dtype - A string that represents the type of the equations. The default is 'linear'. The possible Values are:
    • 'linear'
    • 'quadratic'
    • 'cubic'
    • 'quartic'
    • 'polynomial'
    • 'trigo'
    • 'log'
  • num_of_pages - the number of pages in the document (not including the solutions). Default is 1.
  • equations_per_page - The number of equations per page. The default is 20.
  • get_solutions - Whether to add pages with final solutions. Default is True.
  • digits_after- The maximum amount of digits after the decimal points of the solutions.
  • titles- a list of strings that represent the titles of the pages (including the solutions). Default is None.

For example:

                        worksheet()
worksheet("linearWorkheet.pdf", num_of_pages=10)
worksheet("myWorksheet.pdf", dtype='quadratic')
                    

Now lets cover the classes.

PDFWorksheet

                        class PDFWorksheet:
                    

The PDFWorksheet class represents the whole PDF document.

Properties

  • pages - a list of the pages in the document of type PDFPage.
  • num_of_pages - the number of pages in the document (including the solutions)
  • current_page - the current page that we're working on, of type PDFPage

Create a new PDFWorksheet object

You can create a new PDFWorksheet object via the __init__() method. Here are the parameters of the method:

  • title - a string for the title of the first page. Default is "Worksheet".
  • ordered - whether to number the exercises and the solutions. Default is True.

Just pay attention that when you create a new document, the first page is created automatically as well, so you don't have to add the first page.

add_exercise()

The method accepts an exercise of type PDFExercise or one of its subclasses, and adds it to the current page.

end_page()

Ending the current page. This will make sure to add a page of solutions if needed

next_page()

Add a new page, and move to the new page. After calling it, a new blank page is added and we can add exercises to it.

create()

creates the PDF file in the given path. If the path is not given, the PDF file will be generated in a random path in the current working directory.

PDFPage

                        class PDFPage:
                    

This class represents a single page in the PDF document. Usually, you won't have to access it and modify it directly.

Properties

  • exercises - a list of the exercises in the page.
  • title - the title of the page

Create a new PDFPage

Parameters to the constructor:

  • title - the title of the page. Default is "Worksheet"
  • exercises - a list of exercises in the page. Default is None

Iterate on the page

You can iterate on the list of the exercises in the page directly. For instance:

                        for exercise in page:
    ....
                    

PDFExercise

                        class PDFExercise:
                    

This class represents an exercise in a PDF page.

Properties:

  • exercise - A string that represents the exercise.
  • number - The serial number of the exercise in the document.
  • dtype- The datatype of the exercise (of type str)
  • solution - A string that represents the solution of the exercise.
  • has_solution - Whether the exercise comes with a solution or not.
  • lang - the language of the exercise and the solution ( if there is one).

Creating a new exercise

Usually, you'll be creating new exercises via the subclasses. Here are the available exercises as for this version:

  • PDFLinearFunction - Analyze a linear function. Here is the signature of the constructor:
                                    def __init__(self, with_solution: bool = True, lang: str = 'en'):
                                

    For instance:

                                    exercise = PDFLinearFunction()
                                
  • PDFLinearIntersection - Find intersection between two linear functions. Here is the signature of the constructor:
    def __init__(self, with_solution=True, lang='en'):
  • PDFLinearSystem - Solve a linear system of equations. Here is the signature of the constructor:
                                    def __init__(self, with_solution=True, lang='en', num_of_equations=None, digits_after: int = 0):
                                

    For instance:

                                    exercise = exercise = PDFLinearSystem()
                                
  • PDFLinearFromPoints - Find a linear function from two points. Here is the signature of the constructor:
                                    def __init__(self, with_solution: bool = True, lang: str = "en"):
                                

    For instance:

                                    exercise = PDFLinearFromPoints()
                                
  • PDFLinearFromPointSlope - Find a linear function from a point and a given slope. Here is the signature of the constructor:
                                    def __init__(self, with_solution: bool = True, lang: str = "en"):
                                

    For instance:

                                    exercise = PDFLinearFromPointAndSlope()
                                
  • PDFPolyFunction - Analyze a polynomial function. Here is the signature of the method:
                                    def __init__(self, with_solution: bool = True, degree: int = None, lang: str = 'en'):
                                

    For instance:

                                    exercise = PDFPolyFunction()
                                
  • PDFQuadraticFunction - Analyze a quadratic function. Here is the signature of the method:
                                    def __init__(self, with_solution: bool = True, lang: str = "en"):
                                

    For instance:

                                    exercise = PDFQuadraticFunction()
                                
  • PDFCubicFunction - Analyze a cubic function. Here is the signature of the constructor:
                                    def __init__(self, with_solution: bool = True, lang: str = "en"):
                                

    For instance:

                                    exercise = PDFCubicFunction()
                                
  • PDFQuarticFunction - Analyze a quartic function. Here is the signature of the constructor:
                                    def __init__(self, with_solution: bool = True, lang: str = "en"):
                                

    For instance:

                                    exercise = PDFQuarticFunction()
                                
  • PDFLinearEquation - Solve a linear equation. Here is the signature of the constructor:
                                    def __init__(self, with_solution=True, number: int = None):
                                

    For instance:

                                    exercise = PDFLinearEquation()
                                
  • PDFQuadraticEquation - Solve a quadratic equation. Here is the signature of the constructor:
                                    def __init__(self, with_solution=True, number: int = None):
                                

    For instance:

                                    exercise = PDFQuadraticEquation()
                                
  • PDFCubicEquation - Solve a cubic equation. Here is the signature of the constructor:
                                    def __init__(self, with_solution=True, number: int = None):
                                

    For instance:

                                    exercise = PDFCubicEquation()
                                
  • PDFQuarticEquation - Solve a quartic equation. Here is the signature of the constructor:
                                    def __init__(self, with_solution=True, number: int = None):
                                

    For instance:

                                    exercise = PDFQuarticEquation()
                                
  • PDFPolyEquation - Solve a polynomial equation. Here is the signature of the constructor:
                                    def __init__(self, with_solution=True, number: int = None):
                                

    For instance:

                                    exercise = PDFPolyEquation()
                                

Here is a full example of creating a worksheet.

                        pdf_worksheet = PDFWorksheet("Functions")
pdf_worksheet.add_exercise(PDFCubicFunction(lang='es'))
pdf_worksheet.add_exercise(PDFQuadraticFunction())
pdf_worksheet.add_exercise(PDFLinearFromPointAndSlope())
pdf_worksheet.end_page()
pdf_worksheet.next_page("Equations")
for _ in range(10):
    pdf_worksheet.add_exercise(PDFLinearEquation())
    pdf_worksheet.add_exercise(PDFQuadraticEquation())
pdf_worksheet.end_page()
pdf_worksheet.create("worksheetExample.pdf")
                    

Class IExpression

IExpression is an abstract class that all of the algebraic expressions in this library inherit from. Therefore, knowing this interface is imperative for a deeper understanding of the library.

Abstract methods

  • assign() - assigning a value to an expression. For example, the function call assign(x=5) means that x will be replaced with 5 every time it appears in the expression. You can assign multiple variables at the same time. For instance: assign(x=5, y=4)
  • try_evaluate() - Try to evaluate the algebraic expression to int or float. If not possible, None will be returned.
  • variables - An abstract property. Each algebraic expression must have this property, that returns a collection of the variables that appear in it (a list of strings).
  • __iadd__() - the += operator
  • __isub__() - the -= operator.
  • __imul__() - the *= operator.
  • __ipow__() - the **= operator.
  • __itruediv__() - the /= operator.
  • __neg__() - the negative sign operator. For instance: `-x`
  • __copy__() - Copying the object.
  • __str__() - A string representation of the expression.
  • simplify() - try to simplify the expression.
  • to_dict() - convert the object to a dictionary
  • from_dict() (static method) - create a new expression from a matching dictionary.
  • __eq__() - the == operator.
  • __ne__() - the != operator.

Methods

  • when() - assign values, but on a copy of the original expression.
  • __add__() - the + operator - applying += on a copy of the expression.
  • __radd__()- Same as __add__()
  • __sub__()- the - operator - applying -= on a copy of the expression.
  • __mul__()- the * operator - applying *= on a copy of the expression.
  • __rmul__() - the * operator - applying *= on a copy of the expression.
  • __pow__() - the **= operator - applying **= on a copy of the expression.
  • __abs__() - the abs() built in method. An Abs object will be returned as default.
  • reinman() - Reinman's method for finding integrals numerically. Here is the signature of the method:
                                    def reinman(self, a: float, b: float, N: int):
                                
  • trapz() - The Trapezoid method for finding integrals numerically. Here is the signature of the method:
                                    def trapz(self, a: float, b: float, N: int):
                                
  • simpson() - Simpson's method for finding integrals numerically. Here is the signature of the method:
                                def simpson(self, a: float, b: float, N: int):
                            
  • secant() - The Secant method for finding a root of the expression. Here is the signature of the method:
                                    def secant(self, n_0: float, n_1: float, epsilon: float = 0.00001, nmax: int = 10_000:
                                
  • bisection() - The bisection method for finding a root of the expression. Here is the signature of the method:
                                    def bisection(self, a: float, b: float, epsilon: float = 0.00001, nmax=100000):
                                
  • plot() - Plotting an expression in 2D or 3D.
  • scatter() - Scattering an expression in 2D or 3D.
  • to_json() - convert the expression to a string in JSON format. This is done via the to_dict() method. Here is the signature of the method:
  • export_json() - Save the expression's JSON representation in a JSON file in the specified path. Here is the signature of the method:
                                    def export_json(self, path: str):
                                
  • to_Function() - Try to convert the expression to a Function object. If not successful, None will be returned.

class ExpressionSum

                        class ExpressionSum(IExpression, IPlottable, IScatterable):
                    

The ExpressionSum class is responsible for handling with collection of expressions with different types ( even though all inherit from IExpression). Usually, you won't have to create the ExpressionSum object manually, as it will be generated automatically as a result of arithmetic operations between expressions of different types, if needed. This interface doesn't do some fancy stuff like advanced derivatives ( yet 😉 ), but it supports arithmetic operations, assigning values, plotting and scattering in 2D/3D, and more. It was developed to give an answer to edge cases that aren't included in the other classes. Quite interestingly, the ExpressionSum class actually also implements the IExpression interface, as it's an algebraic expression too. For instance:

                        expressions = 3*x + Ln(x) + Sin(x)
other_expressions = ExpressionSum([3*x, Ln(x), Sin(x)])
                        
                    

Addition

You can add ExpressionSum objects via the + operator.

                        x = Var('x')
expressions = Sin(x) + Ln(x)
other_expressions = Cos(x) + 2*x
print(expressions+other_expressions)
                    

Subtraction

You can subtract ExpressionSum objects via the - operator.

                        x = Var('x')
expressions = Sin(x) - Ln(x)
expressions -= Cos(x)
                    

Multiplication

You can multiply ExpressionSum objects via the + operators.

                        x, y = Var('x'), Var('y')
one = Sin(x) + Cos(x)
two = 3*x
print(one * two)
                    

Division

You can divide ExpressionSum objects via the / operator. For instance:

                        x = Var('x')
        print((x ** 2 + Sin(x)) / x)
                    

Power

You can raise the ExpressionSum object via the ** operator. For example:

                        x = Var('x')
        print((x+Sin(x))**2)
                    

Assign values

Evaluate to int or float

You can try to evaluate the ExpressionSum object into float or int via the try_evaluate() method. For instance:

                        x = Var('x')
my_expressions = Sin(x) + Ln(x) + 4
my_expressions.assign(x=5)
                    

to_lambda()

You can generate a lambda expression from the ExpressionSum object via the to_lambda() method. For instance:

                        x = Var('x')
my_expressions = Sin(x) + Ln(x) + 4
print(my_expressions.to_lambda())
                    

Plot

You can plot an ExpressionSum object by using the plot() method. Here is the signature of the method:

                        def plot(self, start: float = -6, stop: float = 6, step: float = 0.3, ymin: float = -10,
        ymax: float = 10, title: str = None, formatText: bool = False,
        show_axis: bool = True, show: bool = True, fig=None, ax=None, values=None):
                    
Here is an example in 2D:
                        x = Var('x')
result = Sin(x)*Cos(Ln(x**3 - 6)) + Root(2*x-5)
result.plot()
                    
And here is an example in 3D:
                        x = Var('x')
y = Var('y')
result = Sin(x) + Cos(y) * Ln(x)
result.plot()

This is how it actually looks in matplotlib:

3D graph

Scatter

You can also scatter an ExpressionSum object in 2D/3D via the scatter()method.

For instance:

                        x = Var('x')
y = Var('y')
result = Sin(x) + Cos(y) * Ln(x)
result.scatter()
                    

class Mono

This class is used for representing a single polynomial expression, such as `3x^2`, `6x`, etc.. In order to use this class, you must import it first: from kiwicalc import Mono

Creating A New Mono object

There are several main ways to create a Mono object:

The first approach:

You need to enter `2` parameters:

  • The coefficient of the expression, should be a float
  • A dictionary that contains the variables_dict' name and exponents. Each key must be unique!

For example:

                        expression = Mono(5, {'x': 2, 'y': 3})
print(expression)

# output:
# 5x^2*y^3

The second approach:

You can also enter a string, such as "3x^2*y^4". This approach is more intuitive, however, it is slower in performance.

                        expression = Mono("3x^2*y^4")
print(expression)
# output:
# '3x^2*y^4'
                    

The third approach:

If you wish to just create a free number, like `5`, `9.6`, etc, enter it as the coefficient:

                        four = Mono(4)
print(four)

# output:
# 4

The fourth approach ( shorter, but slower )

You can actually create a Mono object using the Var class. For example:

                        x = Var('x')
mono_expression = 3*x**2

In the example below we defined a variable, and created a single expression `3x^2` This expression will be automatically evaluated into a Mono object. This can be done with as many variables_dict as you wish, and will be covered more thoroughly in the section regarding the Var class.

This approach is quite intuitive and simple, but also more costly in runtime. This is because `x` is first brought to power by `2`, which creates a new copy of Mono object, and then it's multiplied by `3`, which creates another object. So here instead of creating one object, we created 3 objects : `x`, `x^2`, and `3x^2`. Hopefully, that was a clear enough explanation about the inside mechanism, which I will hopefully modify in future versions.

Arithmetic Operators

As it will be further elaborated in the parts regarding the Var and Poly classes, you can perform addition,subtraction,multiplication and division, using their corresponding operators in python.
Lets define two Mono objects:
                        first_mono, second_mono = Mono("3x^2"), Mono("2x^2")
print(first_mono+second_mono)
print(first_mono - second_mono)
print(first_mono * second_mono)
print(first_mono / second_mono)

Assigning values

Suppose you have the monomial 3x^2, and you wish to assign x=5 to it. After x is assigned, the expression turns to 75.
You can assign numbers to variables_dict by using the assign() method from an instance of a Mono object. In order to use this method, you must enter keyword arguments.
For example:
                        m = Mono(coefficient=3, variables_dict = {'x':2, 'y':1}) # 3x^2*y
m.assign(x=4)
print(m)
m.assign(y=3)
print(m)
# output:
# 48y
# 144
                    

You can also assign several variables_dict in the same time:

                        m.assign(x=4, y=3)
                    
If you want to assign a variable to the expression and get the result without changing the original expression, you can use the when() method.
For example:
                        
m = Mono(coefficient=3, variables_dict = {'x':2, 'y':1}) # creating the expression
assigned_expression = m.when(x=4, y=3) # saving the assigned expression without modifying the original.
print(assigned_expression)
print(m)
output: 144 3x^2*y

Evaluation into int or float

Sometimes the monomials we deal with will only represent numbers, So we might want to convert them to a float number. In order to do that, we can use the try_evaluate() method. This is the signature of the method - it accepts no parameters, and returns None if the expression can't be evaluated to a number.
def try_evaluate(self) -> Optional[float]:
                    
For instance,
                        m = Mono(5)
print(m.try_evaluate())
                    
Another example: lets create an expression, assign a value to it so it becomes a number, and then fetch the float value with the aforementioned method.
                        m = Mono(coefficient=2, variables_dict = {'x':4}) # creating 2*x^4
assigned_expression = m.when(x=3) # assigning x=3 without changing the original object
print(f"Value is {assigned_expression} and the type is {type(assigned_expression)}")
evaluated_number = assigned_expression.try_evaluate()
print(f"Value is {evaluated_number} and type is {type(evaluated_number)}")
# output: # Value is 162 and the type is <class '__main__.Mono'> # Value is 162 and type is <class 'int'>

From the output of the program, you can understand that to get an integer or a float out of the object, we must call try_evaluate().

Plot

You can plot Mono objects via the plot() method. For a a deeper dive into the method, go to the relevant section in the IExpression class. For example:

                        my_mono = x ** 2
my_mono.plot()
                    

Scatter

You can scatter Mono objects via the scatter()r method. For a a deeper dive into the method, go to the relevant section in the IExpression class. For example:

                        my_mono = x ** 2
my_mono.scatter()
                    

class Var

The var class enables you to define variables and then use arithmetic operations on them without ever changing the original variable. Var inherits from Mono, and in order to use it, you must import it first: from kiwicalc import Var

Creating a new variable

Creating a new Var object is extremely simple, and requires only the name of the variable. For example

                        x = Var('x')
y = Var('y')
z = Var('z')

Calculations with Var

Since Var inherits from Mono, You can use the `+` , `-` , * , `/` , ** operators for calculating algebraic expressions, similar to how it's done in the Mono class.

Example 2 - Basic calculations with Var

                        
x = Var('x')
y = Var('y')
print((x-5)**2)
print((x+y)*(x-y))
print((x+y)**2)
print((x+y+5)**3)

# output:

# x^2-10x+25
# x^2-(y^2)
# x^2+2x*y+y^2
# x^3+3x^2*y+15x^2+3x*y^2+30x*y+75x+y^3+15y^2+75y+125
                        

While here the examples show the use of 2 variables_dict (x and y), you can use as many variables_dict as you'd like. That way you can simplify sophisticated polynomials in an instant, which could have taken hours by hand.

Derivatives and Integrals

You can use Var to compute the polynomial's derivatives and integrals, (it evaluates to Mono or Poly object).

Note: This feature is only possible with one variable. For example:
                        print((3*x**2).derivative())
print((6*x).integral())

# output:
# 6x
# 3x^2

Checking if two expressions are equal

You can equate algebraic expressions using the '==' operator, or the method __eq__(). You could also check if two algebraic expressions aren't equal, using the '!=' operator or the __ne__() method.

For example:

                        print(3*y == 2*x+6)
print((2*x + 2*y) / 2 == y + (2* x**2) / (2*x))
print(2 * x + 5 != 3*y*x**2 - 4)

# output:
# False
# True
# True

                    

class Poly

Polynomials are mainly supported via the Poly class. This class represents a polynomial, namely, a collection of monomials. Since a monomial is represented by the Mono class, each Poly object contains a collection of Mono objects. In order to use the class, you must import it first: from kiwicalc import Poly

Creating A New Polynomial

There are several ways to create a Poly object:

  1. Entering a collection of Mono or Var objects

    A given collection of Mono objects will be evaluated and inserted into a new Poly object. In addition, ints, floats and valid strings will also be accepted as items in the collection. The easiest way to do that is to use the Var class. For Example:

                                        
    x, y = Var('x'), Var('y')
    polynomial = Poly((3*x**2,2.6,"4y^2",7*x**4 * y**5))
                                        
                                

    You can also create the Mono objects on the spot. For instance:

                                    polynomial = Poly((Mono(2,{'x':3,'y':4}),Mono("4xy^3")))
                                
  2. Create a Poly object with Var object arithmetics

    You can actually construct Poly objects in an even simpler manner using the Var class. However, this approach might be slower, as more objects are created during the Var objects arithmetic. For example:

                                    x = Var('x')
    polynomial = x**2 + 3*x + 6
    
                                

    The expression on the right actually computes to a Poly object, so the polynomial is generated for us automatically.

  3. Create a Poly object from a string

    Just enter a string in the right format, and a corresponding Poly object will be generated soon enough! This approach is rather simple, however, it requires string manipulation from the class, and thus it might be a bit more expensive on runtime than the first approach. Despite the obvious simplicity of this approach, as for this version, the strings must follow specific rules and limitations:

    • The variable name represented by a single English letter, i.e: a-z , A-Z.
    • Use of parenthesis isn't supported yet.
    • Division operator isn't supported yet.

    For instance:

                                expression = Poly("8 + 3x^2 + 6x + 9 + 2x^3 + 2x^2")
    print(expression)
    
    # output:
    # '2x^3+5x^2+6x+17'
                            
  4. Create a new Poly object by copying an existing one

    You can also create a new Poly object from an existing one. Alternatively, you can copy Poly objects with the __copy__() method. For example:

                                    x = Var('x')
    existing_polynomial = 3*x**2- 6*x + 7
    new_polynomial = Poly(existing_polynomial)
    
                                

Arithmetic Operators for polynomials

You can perform arithmetic operations between polynomials with the corresponding operators:

  • Addition is done by the + operator
  • Subtraction is done by the - operator
  • Multiplication is done by the * operator
  • Division is done by the / operator.
  • Power is done by the ** operator. (polynomial ** number)

Polynomial Addition

You can easily add up polynomials with the + operator.

Example 5 - Polynomial Addition

                        x = Var('x')
y = Var('y')
first = 2*x + 3
second = 3*y - 4*x + 5
print(first+second)

# output:
# -2x+8+3y

Polynomial Subtraction

You can also subtract polynomials using the - operator.

Example 6 - Polynomial Subtraction

                        x = Var('x')
first = 3*x + 5
second = 2*x - 1

print(first-second)
# output:
# x+6

Polynomial Multiplication

You can use the * operator in order to multiply between:

  • A polynomial and another polynomial.
  • A polynomial and any IExpression object.
  • A polynomial and a float or an int.

For example:

                        
x = Var('x')
y = Var('y')
print((x + 5) * (x - 2))
print( (x + y) * (x - y))
# output: # x^2+3x-10 # x^2-y^2

Polynomial Division

You can use the built in / operator in order to divide between polynomials. For example:

                        x = Var('x')
first = x**2 + 6*x + 8
second = x + 4
division_result = first / second
print(division_result)

Additional feature

You can also divide by a string that represents a polynomial, for the sake of simplicity.

Raising a polynomial by a power

You can raise a polynomial by an integer or float power with the ** operator.

For example:

                        a, b = Var('a'), Var('b')
print((a+b)**2)

# output:
# a^2+2a*b+b^2

Assigning values

You can assign values to variables_dict using the assign() method from a Poly object. For example, after assigning `x=5` for the expression `2x-4` It will represent `6`:

                        x = Var('x')
poly = 2*x - 4
poly.assign(x=5)
print(poly)
# output: # 6

Sometimes we want to see what happens when we assign a certain value to a polynomial, but we want to leave the original polynomial unchanged. For that, we can use the when() method. For example:

                        y = Var('y') # Declaring a variable
original = 3*y**2 + 6*y + 7 # Creating the original polynomial
assigned = original.when(y=-2) # Saving the assigned polynomial
print(f"Original is {original}")
print(f"Assigned is {assigned}")

# output:
# Original is 3y^2+6y+7
# Assigned is 7

Evaluating a polynomial to a number

Sometimes when a polynomial represents a free number, we want to extract its value as an integer. For that, we can use the try_evaluate() method. The method will return the int or float value if the polynomial represents a free number, and None otherwise.

For example,
                        x = Var('x') # Declaring a variable named x
poly = 3*x - 2 # Creating a polynomial
poly.assign(x=4) # Assigning a value to it, so it will hold a free number
number = poly.try_evaluate() # Extract the free number from the polynomial
print(f"poly represents {poly} and its type is {type(poly)}")
print(f"number is {number} and its type is {type(number)}")
# output: # poly represents 10 and its type is <class '__main__.Poly'> # number is 10 and its type is <class 'int'>

Plot Polynomials

You can plot univariate polynomials (polynomials with only one variable) in a 2D axis system via the plot() method. Here is the signature of the method:

                        def plot(self, start: float = -10, stop: float = 10,step: float = 0.01, ymin: float = -10, ymax: float = 10, text=None, fig=None, ax=None, show_axis=True,show=True):
                    

For example:

                        x = Var('x')
poly = x**2 + 6*x + 8
poly.plot()
                    

Scatter polynomials

You can scatter univariate polynomials (polynomials with only one variable) on a 2D axis.

For example:

                        x = Var('x')
poly = x**2 + 6*x + 8
poly.scatter()
                    

Derivatives

You can find a derivative of a polynomial via the derivative() method. The method will return a corresponding Poly or Mono object that represents the derivative. For example:

                        x = Var('x')
poly = 2*x**3 - 6*x + 7
print(poly.derivative())
                    

Partial Derivatives

You can find the partial derivatives in respect to a variable with the partial_derivative() method. The method accepts a string that represent the corresponding variable/s. For instance, the partial derivative of the polynomial function `f(x,y)=x^2+y^2` in respect to `x` can be denoted as `f'_x` and it is `2x`. For example:

                        x, y = Var('x'), Var('y')
f = x**2 + y**2
f_x = f.partial_derivative('x')
print(f_x)
                    

You can also do several partial derivatives. For instance, let `f(x) = x^2 + 2xy + y^2`. In order to find `f'_{xy}` we have to first derive for `x`: `\frac(∂f)(∂x) = 2x + 2y` , and then for `y`, and therefore `\frac(∂f)(∂x ∂y) = 2`. It's important to mention that the order in which the partial derivatives are done doesn't alter the final result, hence, `f'_{xy} = f'_{yx}` For example:

                        x, y = Var('x'), Var('y')
f = x**2 + 2*x*y + y**2
f_xy = f.partial_derivative('xy')
                    

Class FastPoly

*experimental new feature

The FastPoly class serves as an alternative to the Poly class when handling with polynomials. For most simple cases, it should be considerably faster and memory performant, but it's still rather limited.

Advantages

There are several advantages of using the FastPoly class instead of the Poly class in certain cases.

  • Faster - faster root-finding, addition, and subtraction when the polynomial is of a relatively small degree.
  • More memory performant - For most simple cases, the FastPoly class uses less memory than the Poly class. That stems from the difference between the storage of the two ways: a Poly object contains a collection of Mono objects, while a FastPoly object stores the coefficients of the different variables. In addition, Poly objects tend to store large amounts of memory when containing a large number of expressions, while FastPoly expressions will store lower amounts of memory, as long the degree of of the polynomial will be low.

Disadvantages

  • Slower and less memory performant on polynomials with higher degrees. As mentioned earlier, PolyFast stores collections of coefficients, so a polynomial of degree 1000 will be represented with a 1000 coefficients. Storing such big amounts of memory is inefficient and counterproductive.

  • Still partially implemented - As for this version, FastPoly still doesn't support multiplying and dividing polynomials. Also, there is no support yet for powers with the ** operator.
  • Incompatible with IExpression - Most expressions in KiwiCalc inherit from the IExpression interface. That way, they are more compatible with each other, and able to interact with each other. However, FastPoly isn't fully compatible with it yet.
  • No Support for mixed expressions - Currently, the FastPoly class doesn't support expressions with expressions such as `xy`. For instance, you could represent the expression `x^2 + 2xy + y^2` via the Poly class, but not with the FastPoly class.

Conclusion

While FastPoly tends to be much faster and performant on simple polynomials and polynomials with many expressions than Poly, it takes a lot of of memory for polynomials with higher degrees, and it is still quite limited with its features. Therefore, it would be best to use FastPoly on polynomials with lower degrees when not needing sophisticated features and compatibility with other types of expressions.

Properties

  • variables(list) - a copy of a list of the variables that appear in the expression. For instance, the list will be ['x', 'y'] for the expression `x^2 + 2y - 7`.
  • num_of_variables(int) - get the number of variables in the expression without fetching a copy of the list of variables.
  • variables_dict(dict) - The polynomial is represented internally in a dictionary.
  • degree(Union[float, dict]) - The highest power in the expression. If the polynomial contains only 1 variable, the result will be integer. For instance, for the polynomial `x^2 + 2x + 1` the result would be 2. For expressions with several variables, a dictionary with the highest powers of each variable will be returned. For example, for the polynomial `2x^3 - 2y + 5y^2 + 1`, the result would be {'x':[2,0,0], 'y':[5,-2], 'free':1}.

Creating a FastPoly object.

There are several ways to create a PolyFast object.

  1. Entering a string- The most prominent and easiest method to create a new PolyFast object is by entering a string that represents a polynomial. The string will be parsed internally into a dictionary and will be stored in the object. For example:
                                    # creating a PolyFast object
    fast_poly = FastPoly("3x^5 - 2y^2 + 3x + 14")
                                

    You can also specify the variables in the expression to speed us the parsing of the string.

                                    fast_poly = FastPoly("3x^5 - 2y^2 + 3x + 14", variables=('x', 'y'))
                                
  2. Entering the parsed dictionary directly- As mentioned before, the polynomial is represented internally via a dictionary. Each key-value pair in the dictionary is a variable that appears in the expression and a list of its coefficients. In addition, a special key 'free' is added to the dictionary to represent the free number. For instance, the dictionary for the expression `x^2 + 2x + 6` will be
                                    {'x':[1,2], 'free':6}
                                
    And the dictionary for the expression `2x^3 + y^3 + 7` will be
                                    {'x':[2,0,0], 'y':[1,0,0], 'free':7}
                                
    You can enter a dictionary in this syntax in order to create a PolyFast object in a shorter time. For example, this is how we can create the expression `2n^4 - 32`:
                                    FastPoly({'n':[2,0,0,0], 'free':-32})
                                
  3. Entering a list or tuple of coefficients- You can create a polynomial with 1 variable by entering its coefficients and the name of the variable. If the name of the variable isn't given, the default is 'x'. For instance, the coefficients of the expression `n^2 + 2n + 1` are [1,2,1], so you can create the expression in the following way:
                                    fast_poly = FastPoly([1,2,1], variables=('n',))
                                

Adding and Subtracting

You can add and subtract FastPoly objects via the `+` and `-` operators. For example:

                        poly1 = FastPoly("2x^3 + 5x -7")
poly2 = FastPoly("x^2 + 4y^2 - x^3 + 5x + 6")
print(poly1+poly2)
#output: 'x^3+x^2+10x+4y^2-1'
                    
                        poly1 = FastPoly("2x^3 + 6x^2 + 5")
poly2 = FastPoly("x^4 + x^3 - 5x^2 + 6")
print(poly1-poly2)
#output: '-x^4+x^3+11x^2-1' 
                    

Finding the roots of the polynomial

If the polynomial has only 1 variable, you can use the roots() method to find its roots. For instance:

                        fast_poly = FastPoly("x^4+8x^3+11x^2-20x")
print(fast_poly.roots())
# output: [0j, (1+0j), (-5+0j), (-4+0j)]
                        
                    

assign()

You can assign values to variables via the assign() method. For example:

                        my_poly = FastPoly("x^2 + y^2")
my_poly.assign(x=5)
print(my_poly)
# output: 'y^2+25'
                    

when()

You can an assigned copy of the object via the when() method. For example:

                        my_poly = FastPoly("x^2 + y^2")
print(my_poly.when(x=5))
print(my_poly)
# output: 'y^2+25'
# 'x^2 + y^2' 
                    

try_evaluate()

You can try to evaluate a polynomial into an int or float via the try_evaluate()

method. If not possible, None will be created. For instance:
                        poly1 = FastPoly("5")
poly2 = FastPoly("x^2 + 6x + 8")
print(poly1.try_evaluate())
print(poly2.try_evaluate())
#output: '5.0'
#'None'
                    

plot()

You can plot FastPoly object with 1 or 2 variables in 2D or 3D respectively via the plot() method. Here is a detailed explanation about the method. For example:

                        fast_poly = FastPoly("x^3 - 2x + 1")
fast_poly.plot()
                    

scatter()

you can scatter FastPoly objects with 1 or two variables in 2D or 3D respectively via the scatter() method. For instance:

                        fast_poly = FastPoly("x^3 - 2x + 1")
fast_poly.scatter()
                    

class Log

*experimental feature
                    class Log(IExpression, IPlottable, IScatterable):
                    

You can handle with logarithms via the Log class. You can apply arithmetic operations to the logarithms, and integrate them with other types of expressions. Each Log object represent an expression of the form `nlog_m(b)^k`, where `n` is the coefficient, `m` is the base, `b` is the internal expression and the power is `k`. The interface is designed to be quite flexible - for instance, you can represent logarithms of both numbers and algebraic expressions, including polynomials, trigonometric expressions, other logarithms, and even factorials. However, this interface still requires further polishing, and therefore further support will be added in next versions. As for now, it is considered an experimental feature.

Creating a new instance

Parameters
  • expression - the internal expression inside the logarithm. You can enter here a string, an IExpression object, or a number. Read below about the different ways to use this parameter.
  • base(optional). The type of the base is float or int. The default is `10`.
  • coefficient(optional) - The coefficient of the expression. The type of the coefficient is IExpression and the default is Mono(1)

You can specify the internal expression in several ways:

  1. Entering a string - You can enter a string that represents the logarithm. The string will be internally parsed and processed. However, you have to specify the type of the expression inside the logarithm. For example:
                                    my_log = Log("x^2 + 6x + 8", base=2, dtype='poly')
    other_log = Log("sin(x) + cos(2x)", base=10, dtype='trigo')
                                
  2. Entering an IExpression - you can also enter an algebraic expression. For instance:
                                    x = Var('x')
    my_log = Log(3*x+5, base=2)
    other_log = Log(Sin(x), base=5)
                                
  3. Entering a number - You can also enter an int or a float. For instance:

                                    my_log = Log(100, base=10)
    print(my_log.try_evaluate())
                                

Addition

You can add Log objects, or other expressions via the + operator. When adding Log objects with the same base, the result will be evaluated via the formula `log_a(x) + log_a(y) = log_a(xy)`. For example:

                        x = Var('x')
my_log = Log(2*x)
other_log = Log(x)
print(my_log+other_log)
                    

Subtraction

You can subtract Log objects, or other algebraic expressions via the - operator. When subtracting Log objects with the same base, the result will be evaluated via the formula `log_a(x) - log_a(y) = log_a(\frac(x)(y))`. For example:

                        x = Var('x')
my_log = Log(x**2 + 6*x + 8)
other_log = Log(x+4)
print(my_log-other_log)
                    

Multiplication

You can multiply Log objects by other expressions via the * operator.

                        n = Var('n')
my_log = Log(n+5, base=2)
my_log *= 3
my_log *= 2*n**2
print(my_log)
                    

Division

You can divide logarithms via the / operator. For instance:

                        x = Var('x')
my_log = 3 * Log(x) ** 2
other_log = 2*Log(x)
print(my_log / other_log)
                    

Power

You can raise a Log object by a power via the ** operator. For example:

                        x = Var('x')
print(Log(x) ** 2)
                    

Equating logarithms

You can check the equality of Log objects via the == operator. Currently, the algorithm does not cover every case, and therefore there might be some false negatives in abnormal cases.

                        x = Var('x')
my_log = Log(2*x+10, base=2)
other_log = Log(2*x+10, base=2)
print(my_log == other_log)
                    

Evaluate int or float

You can try to evaluate a Log object to int or float via the try_evaluate() method. If not possible, None will be returned. For example:

                        x = Var('x')
my_log = Log(2*x, base=2)
print(my_log.try_evaluate())
my_log.assign(x=4)
print(my_log.try_evaluate())
                    

class TrigoExpr

*experimental feature
                        class TrigoExpr(IExpression, IPlottable, IScatterable):
                    

The TrigoExpr class represents a trigonometric expression. It is still under development, but it already supports relatively advanced features with ease. For instance, it integrates relatively well with other types of expressions, it supports basic arithmetic operations, it can compute simple cases of derivatives and some common trigonometric identities. This feature will be heavily extended in the future or modified, to be even more robust.

Creating a new instance

There are several ways to create a TrigoExpr object.

  1. Entering a string. For example:
                                    print(TrigoExpr('sin(3x)', dtype='poly'))
    print(TrigoExpr('sin(log(2x+5))', dtype='log'))
                                

    Output:

                                    sin(3x)
    sin(log10(2x+5))
                                
  2. Using subclasses - TrigoExpr has many subclasses that make it more intuitive to create new trigonometric expressions:
    • Sin
    • Cos
    • Tan
    • Cot
    • Sec
    • Csc
    • Asin
    • Acos
    • Atan

    You have two main ways to create new objects via these subclasses: Entering a string that represents the internal expression and its type, or alternatively entering the algebraic expression directly. For instance:

                                    my_sin = Sin('2x', dtype='poly')
    my_cos = Cos('log(3x)', dtype='log')
                                
                                    x = Var('x')
    my_sin = Sin(2*x)
    my_cos = Sin(Log(3*x))
                                

Addition

You can add TrigoExpr objects with the + operator. For instance:

                        import math
x = Var('x')
print(Sin(x) + Sin(x))
print(Sin(x) + Cos(x))
print(Sin(math.pi/2) + 4)
                    

Subtraction

You can subtract TrigoExpr objects with the - operator. For instance:

                       import math
x = Var('x')
print(Cos(x) - 2*Sin(x))
print(2*Sin(x) - Sin(x))
print(3*Sin(math.pi/2) - 2)
                    

Multiplication

You can multiply TrigoExpr objects via the * operator. For instance:

                        x = Var('x')
print(Sin(x)*Cos(x))
print(5*Sin(2*x))
print(3*x**2 * Tan(Log(x)))
                    

Output:

                        sin(x)*cos(x)
5sin(2x)
1*(3x^2)*tan(log10(x))
                    

Division

You can divide TrigoExpr objects via the / operator. For example:

x = Var('x')
print(Sin(x)/Cos(x))
print(2*Sin(x)*Cos(x) / Sin(2*x) )
print((3*x*Sin(x)) / Log(x))

                    

Power

Equating

You can check equality between two TrigoExpr expressions via the == operator. However, checking equality between trigonometric expressions is highly problematic, considering trigonometric identities. For instance, `cos(x) = cos(x+2\pi)`, `sin(2x) = 2sin(x)cos(x)` The algorithm for this method doesn't take into consideration all of the relevant identities, and therefore there might be false negatives. However, there is little to none chance of false positives occurring. Here are some examples:

                        x = Var('x')
print(Sin(2*x) == Sin(2*x))
print(Sin(x) == Sin(x+2*math.pi))
print(2*Sin(x)*Cos(x) == Sin(2*x))
print(Sin(x) == Sin(x+5))
                    

Simplify

This method is used mainly internally, but can also be used manually by the user, in order to clean the expression from negligible expressions. For instance, the expression `sin(x)^2*cos(2x)^0` can be simplified to `sin(x)^2`.

to_lambda()

You can generate an executable lambda expression from the TrigoExpr object via the to_lambda() method. For instance:

                        import math
x = Var('x')
my_lambda = Sin(x).to_lambda()
print(my_lambda(math.pi/2))
                    

Output:

                        1.0
                    

Evaluate to int or float

You can try to evaluate the expression into int or float via the try_evaluate() method. For instance, the expression `sin(\frac(\pi)(2))` can be evaluated into `1`. If the expression can't be evaluated, None will be returned. For example:

import math
x = Var('x')
my_trigo = Sin(math.pi)
my_eval = my_trigo.try_evaluate()
if my_eval is not None:
    print("the expression could be evaluated")
else:
    print("the expression couldn't be evaluated")

newton

You can use newton's method in order to find a root of the trigonometric expression. For example:

                        x = Var('x')
print(Sin(x).newton(initial_value=2))
                    

reinman()

You can use the Reinman Sum Method with via the reinman() method in order to find the finite integral of the trigonometric function. For instance:

                        import math
x = Var('x')
print(Sin(x).reinman(0, math.pi, 20))
                    
                        1.9954413183201944
                    

trapz()

You can also use the trapezoid rule via the trapz() method in order to find the finite integral of the trigonometric function. For example:

                        import math
x = Var('x')
print(Sin(x).trapz(0, math.pi, 20))
                    

Output:

                        1.9958859727087144
                    

Here we also see that `\int_0^\pi sin(x)\dx\ ~~ 2`

simpson()

You can also use Simpson's method in order to find the finite integral:

                        import math
x = Var('x')
print(Sin(x).simpson(0, math.pi, 20))
                    

Output:

                        1.9863695787474476
                    

class TrigoExprs

*experimental feature
                        class TrigoExprs(ExpressionSum, IPlottable, IScatterable):
                    

The TrigoExprs class is used in order to represent a collection of TrigoExpr objects. For instance, the expression `sin(x)*cos(y) + tan(x)` is composed of 2 TrigoExprs objects.

Creating a new instance

You have several ways to create a new TrigoExprs object.

  1. As a result of of an operation - you can actually generate TrigoExprsobjects without explicitly calling the constructor, but by executing operations on TrigoExpr objects. For example:

                                    x = Var('x')
    result = Sin(x) + Cos(x)
    print(type(result))
                                

    The output:

                                    
                                
  2. Entering a string - you can choose to enter a string that represents the trigonometric expressions. You also need to enter the datatype of the expressions inside the trigonometric functions, so they will be processed accordingly. The default datatype is 'poly', and hence if the dtype parameter is not specified, the internal expressions will be processed as polynomials. It is possible that in next versions the strings will be classified and processed without any help from the user. Here are some examples for entering a string to the constructor:

                                print(TrigoExprs("3sin(x) - 2cos(x)"))
                            

Addition

You can add TrigoExprs objects via the + operator. For example:

                        first = TrigoExprs("sin(x)^2 + 5cos(y) + 7")
second = TrigoExprs("4cos(y) + 5tan(2x)")
print(first+second)
                    

Subtraction

You can subtract TrigoExprs objects via the - operator. For example:

                        first = TrigoExprs("3sin(x) + 4cos(x)")
second = TrigoExprs("2sin(x) - cos(x) + 4")
print(first-second)
                    

Multiplication

You can multiply TrigoExprs objects via the * operator. For example:

                        x= Var('x')
first = Sin(x) + Cos(x)
second = Sin(x) - Cos(x)
print(first * second)
                    

Division

You can divide TrigoExprs objects via the / operator. For example:

                        x = Var('x')
first = 3*Sin(x)**2 + 3*Cos(x)*Sin(x)
second = Sin(x)
print(first / second)
                    

Power

You can use the ** operator for raising a trigonometric expression by a power. For instance:

                        x = Var('x')
print((Sin(x)+Cos(x))**2)
print(Sin(x)+Cos(x)**(x+5))
                        
                    

Evaluate to int or float

You can try to evaluate the expression to int or float via the try_evaluate() method. If not possible, None will be returned. For example:

                        import math
my_trigo = Sin(math.pi/2) + Cos(math.pi/2)
print(my_trigo.try_evaluate())
                    

Assign values

You can assign values to variables in the expression via the assign() method. For example:

                        import math
x = Var('x')
my_trigo = Sin(x) + Cos(2*x)
print(my_trigo.assign(x=math.pi/2))
                    

to_lambda()

You can generate a lambda expression from the TrigoExprs object via the to_lambda() method. For example:

                        import math
x, y = Var('x'), Var('y')
my_trigo = Sin(x)*Cos(y) + Sin(y)*Cos(x)
my_lambda = my_trigo.to_lambda()
print(my_lambda(math.pi/4, math.pi/3))
                    
                        import math
x = Var('x')
my_trigo = Sin(x) + Cos(x)
my_lambda = my_trigo.to_lambda()
print(my_lambda(math.pi/2))
                    

Plot

You can plot the TrigoExprs object in 2D or 3D via the plot() method. For example:

                        x = Var('x')
my_trigo= Sin(x) + Cos(x)
my_trigo.plot()
                    
                        x = Var('x')
y = Var('y')
my_trigo = Sin(x) * Cos(y)
my_trigo.plot()         
                    

Scatter

You can scatter the TrigoExprs object in 2D or 3D via the plot() method. For example:

                        x = Var('x')
my_trigo= Sin(x) + Cos(x)
my_trigo.scatter()
                    
                        x = Var('x')
y = Var('y')
my_trigo = Sin(x) * Cos(y)
my_trigo.scatter()      
                    

class Root

*experimental feature
                        class Root(IExpression, IPlottable, IScatterable):
                    

You can create algebraic expressions with roots via the Root class. Each Root object represents an expression in the form `a\root(n)(x)` This interface is still experimental, and it will be extended in the future for better support of arithmetic operations, derivatives, integrals, etc. However, it still offers a decent set of features for handling roots, such as assigning values, plotting, and scattering.

Properties

  • coefficient - the coefficient of the expression. Either algebraic expression or number
  • inside - the internal expression inside the root. Either algebraic expression or number
  • root - The root. default is 2.
  • variables - The variables that appear in the expression.

Creating a new instance

You can create a new instance of Root via the __init__() method. Here is the signature of the method:

                        def __init__(self, inside: Union[IExpression, float, int], root_by: Union[IExpression, float, int] = 2
                 , coefficient: Union[int, float, IExpression] = Mono(1)):
                    
For example, this is how you can create the expression `\root(3)(2x+5)`:
                        x = Var('x')
my_root = Root(2*x+5, 3)
print(my_root)
                    

You can also use the subclass Sqrt to create Square roots in a more explicit way:

                        x = Var('x')
my_root = Sqrt(x**2 + 6*x + 8)
                    

This is equivalent to:

                        x = Var('x')
my_root = Root(x**2 + 6*x + 8, 2)
                    

Addition

You can add Root objects via the + operator. For instance:

                         print(Sqrt(2 * x) + Sqrt(2 * x))
print(Sqrt(4) + Sqrt(6))
                    

Subtraction

You can subtract Root objects via the - operator

                        print(2*Sqrt(x) - Sqrt(x))
print(Sqrt(6) - Sqrt(4))
                    

Multiplication

You can multiply Root objects via the * operator

                        print(2 * Sqrt(x) * Sqrt(x))
print(Sqrt(6) * Sqrt(4))
                    

Division

You can divide Root objects via the / operator

                        print(2 * Sqrt(x) / Sqrt(x))
print(Sqrt(16) / Sqrt(4))
                    

Power

You can raise a root by a power (and even cancel the root) by the ** operator. For instance:

                        print(Sqrt(x) ** 2)
print(Sqrt(5) ** 2)
                    

Assign values

You can assign values to the expression via the assign() method. If you don't want to change the original expression, you can use the when() method. For example:

                        my_root = Sqrt(x)
print(my_root.when(x=5))
my_root.assign(x=4)
print(my_root)
                    

Evaluate to int or float

You can try to evaluate Root objects to int or float via the try_evaluate() method. For instance:

                        print(Sqrt(25).try_evaluate())
print(Sqrt(x).try_evaluate())
                        
                    

Plotting

You can plot and scatter Root objects via the plot() and scatter() methods. For instance:

                        Sqrt(x).plot()
Sqrt(x).scatter()
                    

For more information about this method, view the relevant section in class IExpression.

class Abs

*experimental feature
                        class Abs(IExpression, IPlottable, IScatterable):
                    

Creating a new instance

You have several ways to create a new Abs via the __init__() method. Here is the signature of the constructor:

                        def __init__(self, expression: Union[IExpression, int, float], power: Union[int, float, IExpression] = 1,
                 coefficient: Union[int, float, IExpression] = 1, gen_copies=True):
                    

For example:

                        x = Var('x')
print(Abs(x))
print(Abs(-x))
print(Abs(5))
print(Abs(expression=x, power=2, coefficient=3))

Addition

You can add Abs objects via the + operator. For example:

                        x = Var('x')
print(2*Abs(3*x) + Abs(3*x))
print(Abs(5) + Abs(-5))
                    

Subtraction

You can subtract Abs objects via the - operator. For instance:

                        x = Var('x')
print(2 * Abs(3 * x) - Abs(3 * x))
print(Abs(5) - Abs(-5))
                    

Multiplication

You can multiply Abs objects via the * operator. For example:

                        x = Var('x')
print(2 * Abs(3 * x) * Abs(3 * x))
print(Abs(5) * Abs(-5))
                    

Division

You can divide Abs objects via the / operator. For instance:

                        x = Var('x')
print(2 * Abs(3 * x) / Abs(3 * x))
print(Abs(x) / 5)
print(Abs(5) / Abs(-5))
                    

Power

You can raise Abs objects by a power via the ** operator. For example:

                        print(Abs(x) ** 2)
                    

Assign values

You can assign values with the assign() method, or with the when() method if you don't want to change the original object. For instance:

                        my_abs = Abs(x)
print(my_abs.when(x=-5))
my_abs.assign(x=4)
print(my_abs)
                    

Evaluate to int or float

You can try to evaluate the Abs object to int or float via the try_evaluate() method. For example:

                        my_abs = Abs(-5)
print(my_abs.try_evaluate())
                    

to_lambda()

You can generate a lambda expression from the Abs object via the to_lambda() method. For instance:

                        my_abs = Abs(x)
print(my_abs.to_lambda())
                    

Plot

You can plot Abs objects via the plot() method. For example:

                        x = Var('x')
my_abs = Abs(x)
my_abs.plot()
                        x, y = Var('x'), Var('y')
my_abs = Abs(x + y)
my_abs.plot(step=0.3)
                    

Scatter

You can scatter Abs objects via the scatter() method. For instance:

                        x = Var('x')
my_abs = Abs(x)
my_abs.scatter()
                    
                        x, y = Var('x'), Var('y')
my_abs = Abs(x + y)
my_abs.scatter(step=0.3)
                    

class Exponent

*experimental feature
                        class Exponent(IExpression, IPlottable, IScatterable):
                    

Creating a new instance

You have several ways to create exponent. For instance:

                        x = Var('x')
exponent1 = 3*Exponent(x, x)
exponent2 = 3 *x ** x
exponent3 = Exponent(base=x, power=x, coefficient=3)
                    

Arithmetic Operators

You can apply arithmetic operators to the Exponent object. For example:

                        x = Var('x')
my_exponent = x ** x
print(my_exponent + x ** x)
print(my_exponent - x ** x)
print(my_exponent * 2)
print(my_exponent / 2)
                    

Assign values

You can assign values to the Exponent objects via the assign() method, or via the when() method if you don't want to modify the original object. For instance:

                        x, y = Var('x'), Var('y')
my_exponent = x ** x
print(my_exponent.when(x=2))

other_exponent = x ** y
print(my_exponent.when(y=2))
                    

Evaluate to int or float

You can try to evaluate the Exponent object via the try_evaluate() method. For instance:

                        x = Var('x')
my_exponent = Exponent(2, 2)
print(my_exponent.try_evaluate())
                    

to_lambda()

You can generate a lambda expression from the exponent object via the to_lambda() method. For example:

                        x = Var('x')
print((x**x).to_lambda())
                    

Plotting

You can plot and scatter Exponent objects via the plot() and scatter() method. For instance:

                        x, y = Var('x'), Var('y')
(x**x).plot()
(x**x).scatter()
(x**y).plot()
(x**y).scatter()
                    

class Factorial

*experimental feature

You can represent factorial expressions in the form `a(x!)^n` via the Factorial class.

Properties

  1. coefficient - the coefficient of the expression (algebraic expression or number)
  2. expression - the internal expression that the factorial is applied to (algebraic expression or number)
  3. power - algebraic expression or number.

Creating a new instance

You can create a new Factorial expression in two main ways via the __init__() method:

                        def __init__(self, expression: Optional[Union[IExpression, int, float, str]],
            coefficient: Union[IExpression, int, float] = Mono(1),
            power: Union[IExpression, int, float] = Mono(1), dtype='poly'):
                    
  • Enter the parameters directly - You can specify 3 parameters: the internal expression, the coefficient of the expression, and the power. Both the coefficient and the power will be 1 if you don't specify them, but you have to specify the internal expression. For example:
                                x = Var('x')
    my_factorial = Factorial(x**2 + 6*x + 8, coefficient = Sin(x), power = 2)
    print(my_factorial)
                                
    The output is:
                                    ((x^2+6x+8)!)**2
                                
  • Enter a string - you can enter a string that represents the expression. You also need to specify the datatype of the expressions inside. The default is 'poly'. For example:
                                    my_factorial = Factorial('(3x+5)!', dtype='poly')
                                

Addition

You can add Factorial objects via the + operator. For instance:

                            x = Var('x')
print(Factorial(x) + Factorial(x))
print(Factorial(5)+Factorial(4))
                        

Subtraction

You can subtract Factorial objects via the - operator. For instance:

                            x = Var('x')
print(Factorial(x) - 0.5*Factorial(x))
print(Factorial(5)-Factorial(4))
                        

Multiplication

You can multiply Factorial objects via the * operator. For instance:

                        x = Var('x')
print(Factorial(x)*Factorial(2*x))
print(2*x*Factorial(Sin(x)))
print(5*Factorial(1))
                    

Division

You can divide Factorial objects via the / operator. For instance:

                        x = Var('x')
print(2*Factorial(x)/Factorial(x))
print(Factorial(3*x+4)/Factorial(2*x-1))
                    

Power

                        x = Var('x')
print(Factorial(x) ** 2)
                    

Evaluate to float or int

You can evaluate the expression to int or float via the try_evaluate() method. If not possible, None will be returned. For example:

                         my_factorial = Factorial(5)
my_eval = my_factorial.try_evaluate()
print(f"the evaluation is {my_eval} and its type is {type(my_eval)}")
                    

Assign values

You can assign values via the assign() method. For example:

                        x = Var('x')
my_factorial = Factorial(x+2)
my_factorial.assign(x=2)
print(my_factorial)
                    

Plot

You can plot the Factorial object in 2D or 3D via the plot() method. For instance:

                        x = Var('x')
my_factorial = Factorial(Sin(x))
my_factorial.plot(start=-5, stop=5, step=0.01)
                    

Scatter

You can also scatter the Factorial object. For example:

                        x = Var('x')
my_factorial = Factorial(Sin(x))
my_factorial.plot(start=-5, stop=5, step=0.01)
                    

Numerical methods

Finite Integrals

A finite integral of a function is an integral of the form `\int_a^b f(x)\dx\ `, which evaluates to `F(b) - F(a)` when `F(x)` is the antiderivative of `f(x)`. Sometimes it is difficult or impossible to integrate `f(x)` in order to get to `F(x)`, and thus the finite integral must be computed numerically. We currently support three numerical methods that compute the finite integral of a given function: Reinman's sum, Trapezoid Method, and Simpson's Method.

Reinman's Sum

Reinman's Sum is the most known method to approximate the finite integral numerically. The method approximates the sum of areas ofthe rectangles that are trapped between the graph and the `x` axis. For example:

                        import math
reinman(lambda x:sin(x), 0, math.pi, 20)
                    

Trapezoid Method

The Trapezoid Rule is a type of Reinman Sum, where you sum the the areas of the trapped trapezoid - namely, it computes the finite integral of a function by dividing the space trapped between the function and the `x` axis, into trapezoids, and summing their areas. Trapezoids under the `x` axis will have a negative "area".

In order to do that, we choose the range of x values: `a` and `b`, that represent the limits in the integral `\int_a^b f(x)\dx\ `. We also need to choose an integer `N`, that determines the number of intervals and the number of trapezoids. we also compute the length of each interval( denoted as `\Delta x` ) using the following formula: `\frac(b-a)(N)`. The formula for the entire thing is: `\int_a^b f(x)\dx\ \approx \frac(\Delta x)(2) \sum_{k=1}^n f(x_k) + f(x_k-1)`.

When `N->\infty`, the expression tends to the finite integral. However, while extremely big `N` values lead to better accuracy, they also result in a slower runtime.

The Trapezoid Method is available via the trapz() method. Here is how the method is implemented under the hood:

                        def trapz(f: Callable, a, b, N: int):
    if N == 0:
        raise ValueError("Trapz(): N cannot be 0")
    dx = (b - a) / N
    return 0.5 * dx * sum((f(a + i * dx) + f(a + (i - 1) * dx)) for i in range(1, int(N) + 1))
                    

As you can see, the method is very lightweight and simple.

Sources:

Simpson's method

Simpson's method is another approach for computing finite integrals. Here is the signature of the method:

                        def simpson(f: Callable, a, b, N: int):
                    

For instance:

                        print(simpson(lambda x: sin(x), 0, pi, 11))
                    

Output:

                        2.0001095173150043
                    

Single-Root Algorithms

Some numerical root-finding methods are only destined to find only 1 solution, depending on the given input. Amongst them, is the well known Newton-Raphson method, Halley's method, Steffensen's method, etc. Each of these methods requires different parameters, and different numbers of iterations, and has pros and cons.

Newton-Raphson method

The Newton-Raphson method is one of the most known root-finding algorithms. Its formula is also rather simple: `x_{n+1} = x_{n} - \frac(f(x))(f'(x))`. The process of this method, seen here in the formula is quite simple to comprehend: First, we choose the initial value - an arbitrary number, preferably close to the root. Then, the next item will be equal to the previous item, minus the division between the function and its derivative (with the previous number). Then, we repeat the process, until the value of the function with our item is very close to 0. For more details visit the wikipedia page about Newton's method Newton's method is implemented in this library via the newton_raphson() method. You can import it directly, like this:

from kiwicalc import newton_raphson This is the signature of the method:
                        def newton_raphson(f_0: Callable, f_1: Callable, initial_value: float = 0, epsilon=0.00001) -> float:
                    

The method accepts a function `f_0`, a derivative `f_1` , and an initial value. You can also change the default epsilon value, which is `0.00001`. Epsilon represents how close a point be close to the x axis to be considered a root. For example, lets find a root of the polynomial `2x^3 -5x^2 -23x - 10`. For that, we can express the function its derivative via lambda expressions, and choose an initial guess for the result.

                        origin_function = lambda x: 2 * x ** 3 - 5 * x ** 2 - 23 * x - 10
first_derivative = lambda x: 6 * x ** 2 - 10 * x - 23
initial_value = 8
print(newton_raphson(origin_function, first_derivative, initial_value))

# output:
# 5.0
                    

Thus we know that `x = 5` is one of the roots of the function!


Lets try using a different initial value, `-10` for instance:
                        other_solution = newton_raphson(origin_function, first_derivative, -10)
print(other_solution)
# output:
# -2.0
                    
We discovered another root! `x = -2.0`.
Example 4 - Integrating the Newton-Raphson method with the Function class

Say we defined the same function from the previous example:

                        
origin_function = Function("f(x) = 2x^3 -5x^2 -23x - 10")
                        

Now we can get its derivative (as a Function object):

                        first_derivative:Function = origin_function.derivative()

And use the newton_raphson method the same as eariler:

                        solution = newton_raphson(origin_function,first_derivative,9)
print(solution)
# output:
# 5.0


You could also shorten this process, by calling to newton_raphson() from inside the Function class:

                        origin_function = Function("f(x) = 2x^3 -5x^2 -23x - 10")
print(origin_function.newton(7))

# output:
# 5.0

That way, you only need to pass the initial value as a parameter ( in this case, 7). Similarly, you can integrate it with the classes regarding algebraic expressions in this project:

Example 2 - Integrating the Newton-Raphson method to find the roots of Algebraic expressions
                        x = Var('x')
print((2*x**3 - 5*x**2 - 23*x - 10).newton(5))

Halley's Method

Halley's method, named after the British Mathematician Halley Edmund `(1656 - 1742)` is another method for finding a single root of a function. Unlike the aforementioned Newton's method, Halley's method also requires the second derivative of a function. However, it converges cubically to the solution, compared to Newton's method which converges quadratically, and hence it will take less iterations to find the solution. This is the method's formula (this step is returned until convergence with the solution): `x_{n+1} = x_n - \frac(2f(x_n)f'(x_n))(2[f'(x_n)]^2 - f(x_n)f''(x_n))`

It's considered a good practice to use Halley's method instead of Newton's method when it's easy to find the derivatives of a function. This is the signature of the implementation of halley's method:

                            def halleys_method(f_0: Callable, f_1: Callable, f_2: Callable, initial_value: float, epsilon: float = 0.00001,
                   nmax:int=100000):
                        

Here are some examples for different approaches to using Halley's method:

                        
f_0 = lambda n: 2 * n ** 3 - 5 * n ** 2 - 23 * n - 10 # function
f_1 = lambda n: 6 * n ** 2 - 10 * n - 23 # first derivative
f_2 = lambda n: 12 * n - 10 # second derivative
initial_value = 0 # initial approximation ( doesn't have to be 0 obviously )
print(halleys_method(f_0, f_1, f_2, initial_value))

# output:
# -0.49999999999999994
                        
                    

Therefore, we know that `x = -0.5` is a solution. You can round up the result to -0.5 via the round_decimal() method, if that bothers you, or if you need to present the result to the user.

Chebychev's method

`x_{n+1} = x_n - \frac(f(x_n))(f'(x_n)) * (1+\frac(f(x_n)*f''(x_n))(2(f'(x_n))^2))`

Chebychev's Method is named by the 19th century Russian Mathematician Pafnuty Chebyshev. It shares many characteristics with Halley's method's ; Both methods are used to find a single root, both a have 3rd order of convergence, and both require the function, its derivative, its second derivative and an initial value.

Some researchers have managed to optimize chebychev's method by some modifications, but these newer versions are not currently supported in this version. In order to use this method, you must import it beforehand from the library: from kiwicalc import chebychevs_method Here are some examples of using Chebychev's method:

                            
f_0 = lambda n: 2 * n ** 3 - 5 * n ** 2 - 23 * n - 10
f_1 = lambda n: 6 * n ** 2 - 10 * n - 23
f_2 = lambda n: 12 * n - 10
initial_value = 0 # It doesn't have to be zero obviously
print(chebychevs_method(f_0, f_1, f_2, initial_value))

# output:
# -0.4999999999999998
                            

Steffensen's Method

Steffensen's method is another single root-finding method. It differs from the previous two in that it only requires a function and an initial value, compared to Newton's method which also requires the first derivative and Halley's method which also requires the first and second derivative. It is considered a good practice to use Steffensen's method when you wish to find one root of a function, but differentiating it isn't possible, or too costly in time or memory.

In order to use the method you must import it first:

from kiwicalc import steffensen_method

Lets test out Steffensen's Method. As mentioned before, it takes two parameters, a function and an initial approximation. So lets apply it to the function `2x^3 -5*x - 7` and the initial value `8`.

                        
print(steffensen_method(lambda x: 2 * x ** 3 - 5 * x - 7, 8))

# output:
# 2.050976417088196
                        

                    

Therefore, we know that `x=2.0501` is approximately the root of the function.

Bisection Method

The bisection method is a single root-finding algorithm which only applies for continuous functions. Unlike the aforementioned methods, the bisection method requires two x values that their corresponding y values are of opposite signs, in addition to the function of course. This ensures that a root is found between these two dots (remember that the function needs to be continuous). Knowing that, the method will perform a "binary search", namely, the method will split the interval between the two dots into 2 in each iteration. It's considered a rather easy and intuitive method, but it's also quite slow compared to some other numeric algorithms.

In order to use this method you must import it first: from kiwicalc import bisection_method

Here is the signature of the method:

                        def bisection_method(f: Callable, a: float, b: float, epsilon: float = 0.00001, nmax: int = 10000):
                    

Here are some examples of using the method: Consider the function `f(x) = x^2 - 5x`. Let's graph it:

We see that the function has two roots on `x = 0` and `x = 5`. We can also see that when `x<5`, then `y<0`, and when `x>5` or `x<0`, then `y>0`. Thus, for instance, we know that `f(2) < 0` and that `f(9) > 0`. Since they have opposite y values, when we enter `2` and `9` to the bisection method, it will converge to the root between them, which is `x=5` in this case.

                        
parabola = lambda x: x ** 2 - 5 * x # creating the function
print(bisection_method(parabola, 2, 9))

# output:
# 4.999995231628418
                        

As you can see, we got a pretty good approximation of the root. If that's not enough for you , you can always round the result with the round_decimal() method or decrease the epsilon parameter of the function. However, keep in mind that decreasing the epsilon parameter would also lead to more iterations, and consequently to a slower execution.

Multi-Root Algorithms

Multi-root algorithms are algorithms that find several roots of a function.

Durand-Kerner Method

The Durand-Kerner method, also known as the Weierstrass method is an iterative approach for finding all of the real and complex roots of a polynomial. It was first discovered by the German mathematician Karl Weierstrass in 1891, and was later discovered by Durand(1960) and Kerner (1966). This method requires the function and a collection of its coefficients.

Here are some examples:
                        func = lambda x: x ** 4 - 16
coefficients = [1, 0, 0, 0, -16]
print(durand_kerner(func, coefficients))
                    

Output:

                        {(2+0j), -2j, 2j, (-2+0j)}
                    

You can also use the durand_kerner2() method if you only have the coefficients. For instance:

                        print(durand_kerner2([1, 0, 0, 0, -16]))
                    

You can also run it from polynomial

                        
# EXAMPLES HERE WITH A POLYEXPR AND VAR
                        

Aberth-Ehrlich Method

Aberth's method is another method for simultaneous calculations of multiple roots of a polynomial function. It was first developed in `1967` and it's named after the mathematicians Oliver Aberth and Louis W. Ehrlich. Overall, in most implementations it is considered faster than the Durand-Kerner method, as it converges faster to the roots, and thus less iterations are performed. This implementation of the method consists of several steps:

  1. creating `n` complex approximations for the n roots, each one will convert to a different root. These approximations will be placed evenly on a circle on the complex plane that its center will be `(0,0)`, and its radius will be determined by the following formula: `R = ^n\sqrt[abs(\frac(p_0)(p_n))]`, where `n` is the highest power in the expression, `p_0` is the coefficient of the free number of the expression, and `p_n` is the coefficient of the highest power in the expression.
  2. For each approximation to the root, we will compute an offset, by the following formula: `w_k = \frac(\frac(p(z_k))(p'(z_k)))(1-\frac(p(z_k))(p'(z_k)) * \sum_{j!=k}(\frac(1)(z_k-z_j)))`
  3. Subtract each offset from its corresponding root. This will bring the approximations closer to the roots.
  4. Repeat steps `2` and `3` until all the approximations have converged to the solutions.

The method requires the original function, its derivative, and the coefficients of the function.It will return a set of the complex approximations of the roots. Here is the signature of the method:

                        def aberth_method(f_0: Callable, f_1: Callable, coefficients, epsilon: float = 0.000001, nmax: int = 100000) -> set:
Here are some examples of using this method:
                        
func = lambda x: 5 * x ** 4 - 1
derivative = lambda x: 20 * x ** 3
coefficients = [5, 0, 0, 0, -1]
print(aberth_method(func, derivative, coefficients))
# output:
# {(0.66874+0j), 0.66874j, -0.66874j, (-0.66874+0j)}
                        
                    

Generalized Newton

The generalized newton's method is a numerical method for finding the solutions of systems of nonlinear equations. Here are the steps of the underlying algorithm:

  1. Creating `X` - a vector of initial guesses to the solutions
  2. Computing `J(X)` - a corresponding jacobian matrix from the system of equations
  3. If all of the guesses have converged to the appropriate solutions, return them and exit
  4. Otherwise, modify the next set of solutions: `X_{n+1} = X_n - J(X_n)^{-1}F(X_n)`
  5. Repeat phases 3 and 4 until the guesses have converged to the solutions.

For example, given this system of equations: $$ \left\{ \begin{array}{c} x^2 + y^2 = 25 \\ 2x + 3y = 18 \\ \end{array} \right. $$ We can extract `F(X)`, a system of functions: $$ \left\{ \begin{array}{c} f_1(x,y) = x^2 + y^2 - 25 \\ f_2(x,y) = 2x + 3y - 18 \\ \end{array} \right. $$

Hence the jacobian matrix `J(X)` will be: `[[\frac(∂f_1)(∂x), \frac(∂f_1)(∂y) ],[\frac(∂f_2)(∂x), \frac(∂f_2)(∂y)]]`

Lets compute the partial_derivatives: `\frac(∂f_1)(∂x) = 2x`, `\frac(∂f_1)(∂y) = 2y`, `\frac(∂f_2)(∂x) = 2`, `\frac(∂f_2)(∂y) = 3`
And therefore the `J(X)` will be:

`J(X) = [[2x, 2y ],[2, 3]]`

Now we need to choose two guesses for the solutions for `x` and `y`:
`X_0 = [[1],[2]]`

We can check whether the current guesses are accurate by checking if `F(X_0) ~~ [[0],[0]]`. However, ` F(X_0) = F([[1],[2]]) = [[-20],[-10]]`, and therefore we have to keep iterating.

In order to update the guesses and find `X_1`, we need to apply `X_1 = X_0 - J(X_0)^{-1}F(X_0)`, And for that we need to compute `J(X_0)` and `J(X_0)^{-1}`.

`J(X_0) = [[2,4],[2,3]]`
`J(X_0)^{-1} = [[-1.5, 2],[1,-1]]`

And therefore `X_1 = [[1],[2]] - [[-1.5, 2],[1,-1]] * [[-20],[-10]] = [[-9],[12]]`

After several iterations, we will reach the solutions `x ~~ 2.5384` and `y ~~ 4.3077`. However, for different initial guesses, we might get different sets of solutions. For instance, for `X_0 = [[2],[1]]`, we will actually get `x = 3` and `y = 4`.

Currently, you can use this method only to solve systems of polynomial equations, via the solve_poly_system() method.

Parameters

  • equations- a collection of equations(type str) or polynomials (type Poly)
  • initial_vals - a dictionary that represents the initial guesses. For instance: {'x':2, 'y':1} will be interpreted as `X_0 = [[2],[1]]`.
  • epsilon Determines the negligible difference. Default is `0.00001`
  • nmax - The maximum number of iterations. Default is `10000`

For instance, this is how we would solve the aforementioned example:

                        # Solving systems of polynomial equations via KiwiCalc
solutions = solve_poly_system(["x^2 + y^2 = 25", "2x + 3y = 18"], {'x': 2, 'y': 1})
print(solutions)
# output: '{'x': 3.0000001628514434, 'y': 3.999999891432371}'
                    

Machine Learning

The library currently contains only several fundamental machine learning methods. More methods will hopefully be added in the future.

Gradient Descent & Ascent

The gradient descent and ascent methods are numerical methods for finding the minimum and maximum points of a function, respectively.

Linear Regression

Linear Regression is a popular method for describing a linear connection between two variables or more. Currently, you can use the linear_regression() method for finding a trend-line from points in a 2D axis system. The method accepts a collection of x values and a corresponding list of y values, and returns either a lambda expression in the form lambda x: a*x+b or `a` and `b` directly. Just to clarify, `a` and `b` represent the coefficients of the known equation `y = ax + b`.

Parameters

  • axes- A collection of x values
  • y_values- A collection of y values
  • get_values- Whether to get the `a` and `b` values in the linear equation `y=ax+b`. Default is False, so only the lambda expression of the linear equation is returned.

Here is the signature of the method:

                        def linear_regression(axes, y_values, get_values:bool=False):
                    

For example:

                        ages = (43, 21, 25, 42, 57, 59)
glucose_levels = (99, 65, 79, 75, 87, 81)
print(linear_regression(ages, glucose_levels, get_values=True))
                    

Mean Absolute Value

The Mean Absolute Value (MAV) is a method for calculating the error between two functions in a given range. The method accepts two functions and range of numbers to check in. and sums the absolute value of difference of the y values for each value. This is the formula for `N` values: $$ \frac{1}{n} \sum\limits_{i = 1}^n {|\hat{y_i} - y_i|} $$ You can use this method via the mav() method. Here is the signature of the method:

                        def mav(func1: Callable, func2: Callable, start: float, stop: float, step: float):
                    

Mean Square Value

The Mean Square Value (MSV) is another function for computing the error between two given functions.The method accepts two functions and range of numbers to check in and sums the difference of the squared y values for each value. This is the formula for `N` values: $$ \frac{1}{n} \sum\limits_{i = 1}^n {(\hat{y_n} - y_n)^2} $$ You can use this method via the msv() method. Here is the signature of the method:

                        def msv(func1: Callable, func2: Callable, start: float, stop: float, step: float):
                    

Mean Root Value

The Mean Root Value (MRV) is another error function. Here is the formula: $$ \frac{1}{n} \sum\limits_{i = 1}^n {\sqrt{(| \hat{y_n} - y_n) |}} $$ You can use this method via the mrv() method. Here is the signature of the method:

                        def mrv(func1: Callable, func2: Callable, start: float, stop: float, step: float):
                    

class Point

                        class Point:
                    

The Point class represents a point - namely, a collection of coordinates. This class also has two subclasses - Point2D and Point3D specifically for points in 2d and 3d space. You can create a new Point objects by entering a collection of coordinates, or by using the aforementioned subclasses.

The coordinates of a point can be either numbers or algebraic expressions, but pay attention that points with algebraic coordinates cannot be plotted. You can technically also create points with more than 2 or 3 coordinates, but they cannot be plotted as well. Here are some examples of creating new points:

Properties

  • coordinates - a list of the coordinates
  • dimensions - the number of coordinates

Point2D objects have 2 additional properties: x, and y. Point3D objects have 3 additional properties: x, y and z.

                        x, y = Var('x'), Var('y')
print(Point2D(3, 5))
print(Point((3, 5)))
print(Point3D(7, 2, 1))
print(Point((7, 2, 1)))
print(Point((6, 4, 7, 3)))
print(Point2D(x+5, y-4))
print(Point3D(x-3, 4, Sin(y)))
                    

Arithmetic Operations

You can add and subtract points with equal number of coordinates via the + and - operators. For example:

                        print(Point2D(4, 1) + Point2D(5, -3))
print(Point3D(2, 1, 7) - Point3D(-7, 4, 9))
print(Point((4, -2, -1, 6)) + Point((-3, 4, 8, 1)))
                    

Max and min coordinates

You can get the biggest and smallest coordinates via the max__coord() and min_coord() methods. For example:

                        print(Point((4,5,1,8)).max_coord())
print(Point((7,5)).min_coord())
                    

sum()

You can calculate the sum of the coordinates of the point via the sum() method. For example:

                        x = Var('x')
print(Point((5, 4, 1, 3)).sum())
print(Point3D(x, 2*x, x+6).sum())
                    

class PointCollection

The PointCollection class represents a collection of points. Namely, a collection of Point objects.

Properties

  • points - a list of points.

Create a new PointCollection object

The constructor accepts a collection of points. Each point can be represented via a Point object, or other collections such as list, tuple ,set, etc. For instance:

                        my_points = PointCollection([Point((2, 6)), [2, 4], Point2D(9, 3)])
print(my_points)
                    

The points don't have to be in the same dimensions, but it makes much more sense that way. In addition, some features won't be available if the points have different dimensions. The PointCollection class has several subclasses: Point1DCollection, Point2DCollection, Point3DCollection and Point4DCollection. You should use them if you can, even if we're not using them in some examples for the sake of simplicity.

add_point()

You can add a new point to the collection via the add_point() method. For instance:

                        my_points = PointCollection([Point((2, 6)), [2, 4], Point2D(9, 3)])
my_points.add_point(Point((5, 3)))
print(my_points)
                    

remove_point()

You can also remove points by their index. For example:

                        my_points = PointCollection([Point((2, 6)), [2, 4], Point2D(9, 3)])
my_points.remove_point(0)  # remove the first point
print(my_points)
                    

Max and min distances

You can find the longest and shortest distances between any 2 points in the collection via the longest_distance() and shortest_distance(). In other words, this methods find the distance of the furthest and closest pair of points. However, this will only work if all of the points in the collection are in the same dimensions, namely, have the same number of coordinates. For instance:

                        my_points = PointCollection([[1, -4, 5], [7, -5, -2], [5, 3, 9], [-2, 6, 4]])
print(my_points.longest_distance())
print(my_points.shortest_distance())
                    

You can also fetch the pairs of points:

                        my_points = PointCollection([[1, -4, 5], [7, -5, -2], [5, 3, 9], [-2, 6, 4]])
longest_distance, longest_pair = my_points.longest_distance(get_points=True)
print(F"Longest Distance: {longest_distance}, Longest Pair: {longest_pair}")
shortest_distance, shortest_pair = my_points.shortest_distance(get_points=True)
print(F"Shortest Distance: {shortest_distance}, Shortest Pair: {shortest_pair}")
                    

Scatter points

You can scatter the points in 1D, 2D, 3D, and 4D. For example:

                        # scatter in 1D
points = PointCollection([[1], [3], [5], [6]])
points.scatter()
                    

Output:

                        # scatter in 2D
points = Point2DCollection([[1, 2], [6, -4], [-3, 1], [4, 2], [7, -5], [4, -3], [-2, 1], [-3, 4], [5, 2], [1, -5]])
points.scatter()
                    

Output:

                        # scatter in 3D
points = Point3DCollection([[random.randint(1, 100), random.randint(1, 100), random.randint(1, 100)] for _ in range(100)])
points.scatter()
                    

Output:

                        # Scatter in 4D
points = Point4DCollection(
    [[random.randint(1, 100), random.randint(1, 100), random.randint(1, 100), random.randint(1, 100)] for _ in
     range(100)])
    points.scatter()
                    

Output:

class Point2DCollection

As mentioned before, the Point2DCollection class is a subclass of the PointCollection class, specifically designed to handle collections of points in the 2d space. You can create and handle Point2DCollection objects just like PointCollection objects, only that you get several extra features.

Properties

This class has 2 additional properties to increase the simplicity of the interface:

  • x_values - a list of the `x` coordinates of the points in the collection
  • y_values - a l ist of the `y` coordinates of the points in the collection

linear_regression()

You can use the linear_regression() method in order to find a linear function in the form `y = ax + b`. By default, a lambda expression that represents the linear function will be returned. However, if the get_tuple parameter is set to True, a tuple of the `a` and `b` coefficients of the equation `y=ax+b` will be returned instead. For instance:

                        my_dots = Point2DCollection([(1, 3), (2, 5), (3, 7), (4, 9), (5, 11), (6, 13)])
print(my_dots.linear_regression())
print(my_dots.linear_regression(get_tuple=True))
                    

Output:

                        <function linear_regression<locals>.<lambda> at 0x0000026216697F70>
(2.0, 1.0)
                    

plot_regression

You can plot the linear-regression function via the plot_regression() function. For instance:

                        my_dots = Point2DCollection([(1, 3), (2, 5), (3, 7), (4, 9), (5, 11), (6, 13)])
print(my_dots.plot_regression())
                    

Output:

scatter_with_regression()

You can scatter the points together with the linear-regression line. For instance:

                        points = Point2DCollection.random(100, (-10, 10))
points.scatter_with_regression()
                    

Output:

class Graph2D

The Graph2D class enables you to plot many functions, expressions and geometric shapes extremely easily in matplotlib. You just create a Graph2D object, add the items you wish to plot, and plot them together. You can also scatter these items as well.

Properties

  • items - a list of all of the Plottable items that were added to the Graph2D object

Create a new Graph2D object

The Graph2D constructor accepts a collection of objects to be plotted in 2D axis system. Here is the signature of the __init__() method:

                        def __init__(self, objs: Iterable[IPlottable] = ()):
                    
As you can see, Graph2D can only accept objects that inherit from IPlottable, namely, they have a matching plot() method.

Here is a list of the objects currently implementing this interface:

  • FastPoly
  • Poly
  • Log
  • Ln
  • Function
  • FunctionCollection
  • FunctionChain
  • Point2D
  • Circle
  • Vector2D

For example:

                        my_graph = Graph2D((Function("f(x) = 2x"),Poly("x^3 - 3")))
                    

In addition, you can also initialize the Graph2D object without anything:

                        my_graph = Graph2D()
                    

Until plottable items are added, this Graph2D object will be considered empty and can't be plotted.

add()

You can add plottable objects to the graph via the add() method. For instance:

                        my_graph = Graph2D()
my_graph.add(Function("f(x) = x^3-3"))
my_graph.add(Poly("2x^4 - 3x + 5"))
my_graph.add(Circle(5, (0, 0)))
                    

plot()

You can plot all of the items via the plot() method. The method takes many optional parameters so you can customize your graph conveniently, however, you can also call the method without any parameters(the default values will be used). Here is the signature of the method:

                        def plot(self, start: float = -10, stop: float = 10,
    step: float = 0.01, ymin: float = -10, ymax: float = 10, text=None, show_axis=True, show=True,
    formatText=False, values=None):
                    

Here are the parameters:

  • start - initial x to plot from. Default: -10
  • stop - last x value in the plot. Default: 10
  • step - the interval between each x. Default: 0.01
  • ymin - lowest y value in the initial scope Default: -10
  • ymax - highest y value in the initial scope. Default: 10
  • text - customize the graph's title
  • formatText - try to format the text to latex (currently experimental). Default: False
  • values - instead of start, stop and step - you can just insert a list of x values to plot in. Default: None

For instance:

                        my_graph = Graph2D()
my_graph.add(Function("f(x) = x^3-3"))
my_graph.add(Poly("2x^4 - 3x + 5"))
my_graph.add(Circle(5, (0, 0)))
my_graph.plot()
                    

Output:

Class Graph3D

                        class Graph3D(Graph)
                    

The Graph3D enables you to plot expressions and functions in 3D conveniently.

Class Vector

Vectors are something that has both magnitude and direction. For instance, the vector `(2, 3)` represents moving moving `2` units on the `x` axis, and `3` units on the `y` axis. Vectors are essential to a plethora of math fields, including Linear Algebra, and therefore they are supported in this library. You can create and work with vectors via the Vector class. While you can create vectors with any dimension, you can only plot vectors with `2` or `3` elements, in 2D and 3D, respectively.

The Vector class also has 2 subclasses: Vector2D and Vector3D. You should use them instead if you're working with 2D and 3D vectors.

Properties

Each vector has the following properties:

  • direction_vector - returns the direction vector which also end_coordinate minus
  • start_coordinate - returns the start coordinate, the origin of the vector
  • end_coordinate - returns the end coordinate, the end of the vector

Creating a new Vector object

There are several ways to create a vector object:

  1. Enter the start_coordinate and the end_coordinate
                                    Vector(start_coordinate=(1,2),end_coordinate=(4,6))
                                
  2. Enter the start_coordinate and the direction_vector
                            Vector(direction_vector=(3,4),start_coordinate=(1,2))
                        
  3. Enter the end_coordinate and the direction_vector
                                     Vector(direction_vector=(3,4),end_coordinate=(4,6))
                                
  4. Enter only the direction vector, start_coordinate will be (0,0...) as default.
                                    Vector(3,4))
                                
                        print(Vector(start_coordinate=(1,2),end_coordinate=(4,6)))
print(Vector(direction_vector=(3,4),start_coordinate=(1,2)))
print(Vector(direction_vector=(3,4),end_coordinate=(4,6)))
print(Vector(direction_vector=(3,4)))

# output:
# 'start: [1, 2] end: [4, 6] direction: [3, 4]'
# 'start: [1, 2] end: [4, 6] direction: [3, 4]'
# 'start: [1, 2] end: [4, 6] direction: [3, 4]'
# 'start: [0, 0] end: [3, 4] direction: [3, 4]'

Creating a copy of a vector

You can create a copy of a Object via the __copy__() method. For instance:

                        vec = Vector(start_coordinate=(7, 8, 5), direction_vector=(2, 5, 4))
print(vec.__copy__())
# output:
# start: [7, 8, 5] end: [9, 13, 9] direction: [2, 5, 4]
                    

Adding Vectors

Where adding vectors, we'll actually be adding up the direction_vectors, without considering the start and the end of each vector. Using __add__() and __radd__() allows us to use the `+` operator to add up vectors. For example:

                        vec1 = Vector((1, 1, 1))
vec2 = Vector((2, 2, 2))
vec3 = Vector((3, 3, 3))
print(vec1 + vec2 + vec3)

# output:
# 'start: [0, 0, 0] end: [6, 6, 6] direction: [6, 6, 6]'

Subtracting Vectors

You can subtract vectors too using the `-` operator, thanks to the built-in method __sub__(). For instance:

                        vec1 = Vector((1, 1, 1))
vec2 = Vector((2, 2, 2))
vec3 = Vector((3, 3, 3))
print(vec3 - vec2 - vec1)

# output:
# 'start: [0, 0, 0] end: [0, 0, 0] direction: [0, 0, 0]'

Scalar Multiplication Of Vectors

The result of the product of two vectors is a number. The multiplication of 2 vectors is equal to the sum of the products of the matching coordinates from both vectors. For example, `(6, 2, 5) * (3, 4 ,2) = 6 * 3 + 2 * 4+ 5 * 2 = 36.` With __mul__(), you can use the * ( asterisk ) operator to multiply vectors. For example:

                        vec1 = Vector((6, 2, 5))
vec2 = Vector((3, 4, 2))
print(vec1*vec2)

# output:
# 36

Flipping a vector's direction

In order to flip the direction of a vector, you change the sign of each of its coordinates. With __neg__ (), this can be done with the subtraction operator. For example:

                        vec = Vector((-1, 2, 3))
print(vec)
print(-vec)

or
                        print(vec.__neg__())
# output:
# start: [0, 0, 0] end: [-1, 2, 3] direction: [-1, 2, 3]
# start: [-1, 2, 3] end: [0, 0, 0] direction: [1, -2, -3]
                    

Getting The Vector's Length

There are several approaches to get the vector's length, but they are are all basically different sides of the same coin. For example:

                        
vec = Vector((3, 4))
print(len(vec)) # first approach using len()
print(vec.__len__()) # second approach using __len__()
print(vec.length()) # third approach using length()

# output:
# 5
# 5
# 5

Checking whether vectors are equal

Vectors are considered equal if they have the same direction vector. You can check equality with the == operator. For instance:

                        print(Vector((3, 4, 8)) == Vector((3, 4, 8)))
                    

Output:

True

Equating directions

You can check whether vectors have the same direction via the equal_direction_ratio()method. Namely, whether there's a ratio between their direction vectors. For instance:

                        print(Vector((1, 2, 4)).equal_direction_ratio(Vector((2, 4, 8))))
print(Vector((x, 2 * x, 4 * x)).equal_direction_ratio(Vector((2 * x, 4 * x, 8 * x))))
print(Vector((x, 2 * x, 4 * x)).equal_direction_ratio(Vector((2 * x ** 2, 4 * x ** 2, 8 * x ** 2))))
                    

Output:

                        True
True
True
                    

Class VectorCollection

Sometimes, you want to apply certain processes to a group of vectors - in that case you can use the VectorCollection class. The VectorCollection class, as its name suggests, represents a collection of vectors.
By defining a VectorCollection rather than a simple list of Vector objects, you can plot all the vectors in a single axis system, instead of separately, and use other basic analysis tools.

How To Create A New Vector Collection

                        def __init__(self, *vectors):
                    

In order to create a VectorCollection, you could enter as much vectors as you wish as parameters. Each vector should be represented by a Vector object, However, tuples and lists which are entered to the constructor will be converted automatically to Vector objects and added to the collection.

For Example:

                        # You can enter Vector objects, lists, or tuples.
vectors = VectorCollection(Vector((7, 5, 3)), (8, 1, 2), [9, 4, 7])

# Or if you already have an existing list of vectors:
lst_of_vectors = [Vector((7, 5, 3)), (8, 1, 2), [9, 4, 7]]
other_vectors = VectorCollection(*lst_of_vectors)
                        

Properties:

  • vectors - you can get and set the list of vectors of the collection with this property.
  • num_of_vectors - you can get the number of vectors in the collection, namely, the length of the vectors list.

How to plot the vectors ?

The vectors will be displayed via the matplotlib library, using the plot_in_range(start=0,end=None ) method. This method accepts two optional parameters:

  • start - where the plotting starts. default value is `0`.
  • end - where the plotting ends. default value is None. If left unchanged, it will be set automatically to a proper value.

you can use the plot_all() method to call plot_in_range() with its default values in a more succinct way. plot() doesn't accept any parameters. For example, plotting vectors in 2D and 3D:

                        vectors = VectorCollection(Vector(start_coordinate=(7, 5), end_coordinate=(3, 4)), Vector((1, 9)))
vectors.append(Vector(start_coordinate=(2, 2), end_coordinate=(7, 7)))
vectors.plot()
                        vectors = VectorCollection(Vector((7, 5, 3)), Vector((8, 1, 2)), Vector((9, 4, 7)))
vectors.plot()
# shortly after, a matplotlib graph will be displayed.

Finding the vectors with the max and min lengths in the collection

You can find the vector with the maximum length with longest( ) and the vector with the shortest length with shortest( ), as shown below:

                        # creating the vectors and printing the initial collection
vectors = VectorCollection(Vector((2, 1, 6)), Vector((8, 1, 2)), Vector((9, 4, 7)))

# fetching both the index of the longest vector in the collection, and the longest vector itself.
index, longest_vector = vectors.longest(get_index=True)
print(f"Vector {longest_vector} is in index {index}")

# fetching both the index of the shortest vector and the shortest vector, but this time, also removing it from
# the collection.
index, shortest_vector = vectors.shortest(get_index=True, remove=True)
print(f"removed the vector {shortest_vector} from index {index}")

There are also two optional parameters for both of the functions: get_index ( of type bool: if set to True, the functions will return a tuple in the form: (index, vector), Namely, a tuple which consists of the vector's index in the collection and the vector itself, will be returned. The default value is False remove ( of type bool): If set to True, the vector that will be returned, will also be removed from the collection. The default value is True.
For example:

                        
# UPDATE ALL OF THE EXAMPLES ON THE VECTOR CLASS !@!!!!
# creating the vectors and printing the initial collection
vectors = VectorCollection(Vector((2, 1, 6)), Vector((8, 1, 2)), Vector((9, 4, 7)))
print(f"The original collection: {[vector.get_direction() for vector in vectors]}")
# fetching both the index of the longest vector in the collection, and the longest vector itself.
index, longest_vector = vectors.longest(get_index=True)
print(f"The vector {longest_vector.get_direction()} is located in index {index}.")
# fetching both the index of the shortest vector and the shortest vector, but this time, also removing it from
# the collection.
index, shortest_vector = vectors.shortest(get_index=True, remove=True)
print(f"The vector {shortest_vector.get_direction()} was removed from index {index}.")
# printing the modified expression
print(f"The modified collection: {[vector.get_direction() for vector in vectors]}")
# output:
# 'The original collection: [[2, 1, 6], [8, 1, 2], [9, 4, 7]]'
# 'The vector [9, 4, 7] is located in index 2.'
# 'The vector [2, 1, 6] was removed from index 0.'
# 'The modified collection: [[8, 1, 2], [9, 4, 7]]'
                        

class Circle

                        class Circle(IPlottable):
                    

The Circle class enables you to represent circles of the equations of the form `(x-a)^2 + (y-b)^2 = R^2`, where `(a, b)` is the center of the circle and `R` is its radius.

Properties

  • radius - the radius of the circle.
  • diameter - the diameter of the circle
  • center - the center of the circle.
  • center_x - the x coordinate of the center point
  • center_y - the y coordinate of the center point.
  • left_edge - the left edge of the circle.
  • right_edge - the right edge of the circle.
  • top_edge - the top edge of the circle
  • bottom_edge - the bottom edge of the circle.

Create a new circle object

You can create a a new circle object by entering the radius of the circle and its center. For instance:

                        x = Var('x')
my_circle = Circle(5, (3, 3))
other_circle = Circle(x+5, (Cos(x), Sin(x)))
                    

Area

The area of a circle is calculated via the `\pir^2`. You can compute the area of a circle via the area() method. For example:

                        r = Var('r')
print(Circle(5, (3,1)).perimeter())
print(Circle(r, (1, -4)).perimeter())
                    

Perimeter

The perimeter of a circle is calculated via the `2\pir`. You can find the perimeter of a circle via the perimeter() method.

                        r = Var('r')
print(Circle(5, (3, 1)).perimeter())
print(Circle(r, (1, -4)).perimeter())
                    

point_inside()

Check whether the given point is located inside the circle. You can enter a Point object or any iterable object as a point.

                        my_circle = Circle(5, (0, 0))
print(my_circle.point_inside((1, 1)))
                    

is_inside()

Check whether the circle is inside another circle.

                        small_circle = Circle(5, (0, 0))
big_circle = Circle(10, (0, 0))
                    

plot()

Plot the circle on an axis system. For instance:

                        my_circle = Circle(5, (0, 0))
my_circle.plot()
                    

Matrices

Matrices are one of the key foundations for Linear Algebra. They have countless mathematical and scientific applications. We offer a basic implementation of a matrix, via the Matrix class. The advantage of using this class, is that it's able to integrate with other classes in this module ; for instance, you can create matrices with polynomials, trigonometric expressions, logarithms, and much more. In the future, further support for Linear Algebra operations will be added, speed optimizations will be conducted via C or C++, and compatibility with NumPy will be added as well.

properties

  • num_of_rows(int) - the number of num_of_rows in the matrix. -
  • num_of_columns (int) - the number of num_of_columns in each row.
  • matrix - a list of lists that represent the matrix.

Creating a new matrix

                        def __init__(self, dimensions=None, matrix=None):
                    

Parameters for initializing

  • dimensions(Iterable) - you can create a matrix filled with zeroes in the given dimensions, in the syntax(rows, columns). For example: (5, 6) , [5, 6], "5x6", "5,6"
  • matrix - if you want to create a matrix from an existing matrix, you can enter it here ( list of lists ).

Here are a few examples of creating new matrices:

                        mat = Matrix(dimensions=(3, 3))
mat1 = Matrix(dimensions="2x2")
mat2 = Matrix(dimensions="1,3")
mat3 = Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(mat)
print(mat1)
print(mat2)
print(mat3)

Swapping Rows

you can swap num_of_rows with the replace_lines method. Here is the method's signature

                        def replace_lines(self, line1:int, line2:int):
                    

line1 is the index of the first row, and line2 is the index of the second row. For example:

                        mat = Matrix(matrix=[[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(mat)

mat.replace_rows(0, 1)
print(mat)
                    

Multiplying a row

You can multiply a row in the matrix by an expression via the multiply_row() method.

For example:

                        mat = Matrix(matrix=[[1, 2, 3], [4, 5, 6], [7, 8, 9]])
mat.multiply_row(2,row=1)
print(mat)

Dividing a row

You could divide a row by a scalar using the divide_row( ) method. method, which does not accept division by `0` . you can also divide the row using the multiply_row( ) method by multiplying by the reciprocal of the scalar. For example, multiplying by `\frac(1)(3)` is the same as dividing by `3`.

                        my_matrix = Matrix([[1,2,3],[4,5,6]])
my_matrix.divide_row(2,row=0)

Finding the min and max

While you can find the smallest and biggest values of a matrix yourself just by yourself by iteration, you can just use the min() and max() from the Matrix object. For example:

                        
mat = Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(f'the maximum is {mat.max()}')
print(f'the minimum is {mat.min()}')

output:

the maximum is 9
the minimum is 1
                        

Finding the sum of the matrix

You can get the sum of all the elements in the matrix, by using the sum() method from the Matrix object.

                        
# Add an example with IExpression expressions !!!!!!!!!!!!
mat = Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(mat.sum())

# output:
# 45
                        

Equating Matrices

You can check whether two matrices are equal via the __eq__() method, or the `==` operator ( It's the same thing ). For example:

                        
mat = Matrix([[5, 3, 2], [8, 7, 1]])
mat1 = Matrix([[6, 4, 7], [9, 1, 3]])

if mat == mat1:
    print("The matrices are equal")
else:
    print("The matrices are not equal")


# output:
# 'The matrices are not equal'
                        

Iterating on a matrix

There are several approaches to iterate over a Matrix object.

1. Iterate on the matrix property

Each Matrix object has a matrix property, which is just a list of lists.
You can iterate on it just like any other list. For example:

                        mat = Matrix(((1, 2, 3), (4, 5, 6), (7, 8, 9)))
for row in mat.matrix:
    for item in row:
        print(item)

2.Iterate directly on the object

You actually don't have to use the matrix property, since the Matrix class implements the __ iter __ () and __ next __ () methods, and hence a matrix object is considered iterable.

Example 8 - Iterating directly on the Matrix object

                        mat = Matrix(((1, 2, 3), (4, 5, 6), (7, 8, 9)))
for row in mat:
    for item in row:
        print(item)

Iterate on the indices

Since a matrix is composed of a list of lists, and lists follow indices, you can access elements in the matrix by two indices, the index of the row(starting from 0) and the index of the column (starting from 0). This can be done by calling the range() method from the Matrix object. This method will yield the row index and the column index. For example:

                        mat = Matrix(matrix=((1, 2, 3), (4, 5, 6), (7, 8, 9)))
for i, j in mat.range():
    print(f"The item in row {i} and column {j} is {mat[i][j]}")
            

Adding Matrices

You can add matrices with the same dimensions. Each item in the new matrix will be the sum of the corresponding elements in the two matrix. For instance, here is how to add matrices with the dimensions `2xx2`:

`[[x_1,x_2],[x_3,x_4]] + [[y_1,y_2],[y_3,y_4]] = [[x_1+y_1, x_2+y_2],[x_3+y_3,x_4+y_4]]`

Here is an example with some easy numbers to clarify:

`[[1,5],[3,2],[4,6]] + [[7,4],[6,1],[2,3]] = [[8,9],[9,3],[6,9]] `

You can add up Matrix objects by using the `+` operator. For example:

                        mat1 = Matrix([[1,3],[7,6]])
mat2 = Matrix([[5,8],[4,2]])
print(mat1+mat2)
                    

Subtracting Matrices

Just like adding matrices, you can only subtract matrices with the same dimensions, namely, the same number of rows and columns. For instance:

`[[2,4],[1,7],[8,3]] - [[5,5],[1,4],[3,2]] = [[-3,-1],[0,3],[5,1]] `

You can subtract matrices via the `-` operator. For example:

                        mat1 = Matrix([[2,4],[1,7],[8,3]])
mat2 = Matrix([[5,5],[1,4],[3,2]])
print(mat1-mat2)
                    

Multiplying Matrices

2 Matrices can be multiplied if the number of columns in one of the matrices is equal to the number of rows in the other matrix. Namely, the two matrices need to be in the dimensions `mxxn` and `nxxp`.

An element in the row `i` and the column `j` in the new matrix will be equal to the dot product of the line at index `i` in the first matrix and the column at index `j` in the second matrix. For instance:

`[[3,2],[7,4],[9,1]] * [[2,4,1],[6,3,2]] = [[(3,2)*(2,6),(3,2)*(4,3),(3,2)*(1,2)], [(7,4)*(2,6),(7,4)*(4,3),(7,4)*(1,2)],[(9,1)*(2,6),(9,1)*(4,3),(9,1)*(1,2)]] = [[18,18,7],[38,40,15],[24,39,11]]`

You can multiply Matrix objects with the @ operator. For example:

                        mat1 = Matrix([[3,2],[7,4],[9,1]])
mat2 = Matrix([[2,4,1],[6,3,2]])
print(mat1@mat2)
                    

Determinants

You can find the determinant of a square matrix via the determinant() method. Useful to know, if the columns of the matrix are linearly dependant, the determinant will always be 0. Also, if the determinant of a matrix is 0, we can deduce that it does not have an inverse.

                        mat = Matrix([[1,2,3],[4,5,6],[7,8,9]])
print(mat.determinant())
                    

For a deeper dive into determinants, I highly recommend watching 3Blue1Brown's video:

Transpose

The transpose of a matrix `A` is denoted `A^T`, and it's a new matrix where the rows become the columns, and the rows become the columns. Therefore, for a matrix `A` with dimensions `mxxn` the transpose `A^T` has the dimensions `nxxm`. For instance:

`A = [[1,2],[3,4],[5,6]]`, `A^T = [[1,3,5],[2,4,6]]`

You can get the transpose of a Matrix object via the transpose method. For example:

                        mat = Matrix([[1,2],[3,4],[5,6]])
print(mat.transpose())
                    

Inverse

You can find the inverse of a matrix via the inverse() method. If the matrix doesn't have an inverse the method will return None. For example:

                        mat = Matrix([[1,2,3],[4,5,6],[7,8,9]])
print(mat.inverse())
                        mat = Matrix([[1,2],[3,4]])
print(mat.inverse())
                    

class Surface

Surface are used to represents surfaces with the equation `ax + by + cz + d = 0`

Properties

  • a - the `a` coefficient in the equation.
  • b - the `b` coefficient in the equation.
  • c - the `c` coefficient in the equation.
  • d - the `d` coefficient in the equation.

Creating a new Surface object

You can create a new Surface object by entering a string that represents the equation or with a collection of the coefficients `a`, `b`, `c`, and `d`. You can plot it too later. For instance:

                        my_surface = Surface("7x + 3y + z + 9 = 0")
my_surface.plot()
                    
                        my_surface = Surface([7, 3, 1, 9])
my_surface.plot()
                    

Output:

Checking Surface Equality

You can check whether Surface objects with the == operator. For example:

                        first_surface = Surface("7x + 3y + z + 9 = 0")
second_surface = Surface([7, 1, 3, 9])
print(first_surface == second_surface)
                    

class ArithmeticProg

In an arithmetic progression, there is a constant difference between each two consecutive items in the sequence. You can represent this kind of sequences via the ArithmeticProg class.

class ArithmeticProg(Sequence):

Properties

  • difference - the difference between each pair of items in the sequence.
  • first - the first element.

Creating a new ArithmeticProg object

The constructor takes 2 parameters:

  • first_numbers - a collection of the first numbers
  • difference - the ratio of the series.

You have two ways to use the constructor:

  1. Enter the first element and the difference of the series. For example:
                                    my_sequence = ArithmeticProg([3], 2)
                                
  2. Enter at least 2 of the first elements of the series. Then the difference will calculated internally. For example:
                                    my_sequence = ArithmeticProg([3, 5])
    print(my_sequence)
                                

in_index()

You can fetch an item in the sequence by its index via the in_index() method. Just pay attention that indices of sequences start from 1 and not from 0. For instance:

                        my_sequence = ArithmeticProg([3], 2)
print(my_sequence.in_index(2))
                    

sum_first_n()

You can find the sum of the first `n` elements in the series via the sum_first_n() method. The sum is calculated via the formula `S = \frac(n)(2)(2a_1 + d(n-1))`. For example:

                        my_sequence = ArithmeticProg([3], 2)
print(my_sequence.sum_first_n(3))
                    

index_of()

Get the index of a given number in the sequence. If the given number is not in the sequence, -1 will be returned. For example:

                        my_sequence = ArithmeticProg([3], 2)
print(my_sequence.index_of(7))
print(my_sequence.index_of(10))
                    

GeometricSeq

Geometric sequences are sequences where there's a constant ratio between each pair of consecutive items. For instance: `2, 4, 8, 16 ...`. You can create geometric sequences via the GeometricSeq class.

Properties

  • first - the first item in the sequence
  • ratio - the ratio between every two consecutive items in the series.

Create a new GeometricSeq object

The constructor takes two parameters:

  • first_numbers - a collection of the first items in the sequence
  • ratio - the ratio of the sequence

You have two ways to create a new geometric sequence:

  • Enter the first item of the sequence, and the ratio. For instance:
                                my_sequence = GeometricSeq([2], 2)
    print(my_sequence)
                            
  • Enter at least two of the first items in the sequence.
                                my_sequence = GeometricSeq([2,4])
    print(my_sequence)
                            

in_index()

You can find an element in a specified index via the in_index() method. For example:

                        my_sequence = GeometricSeq([2, 4])
print(my_sequence.in_index(3))
                    

index_of()

You can find the index of an element in the sequence via the in_index() method. If the element doesn't exist in the sequence, -1 will be returned. For instance:

                        my_sequence = GeometricSeq([2], 2)
print(my_sequence.index_of(8))
                    

If you only need to know if an item exists in the sequence, you should you use the in operator. For example:

                        my_sequence = GeometricSeq([2], 2)
print(8 in my_sequence)
print(7 in my_sequence)
                    

plot()

You can also plot the sequence via the plot() method from the Sequence base class. For example:

                        my_sequence = GeometricSeq([2], 2)
my_sequence.plot(start=1, stop=10, step=1)
                    

Class RecursiveSeq

Recursive sequences are sequences where each item is recursively defined by previous items. The most known example of a recursive sequence is the Fibonacci sequence, named after the famous italian mathematician Fibonacci (11-12th centuries). The first two items in the Fibonacci sequence are `1, 1` and each element later is equal to the sum of the two previous items: `1, 1, 2, 3, 5, 8, 13, 21, 34...`

The sequence has been discovered to possess many intriguing qualities, and even take place in natural phenomena. Using this class, one may declare his own Fibonacci sequence, and actually almost any recursive sequence he/she had in mind. You can define and work with the RecursiveSeq class.

How to create a new recursive sequences

The constructor for the RecursiveSeq class requires a string and an Iterable of the first elements of the sequence. The string should represent the recursive sequence, and must follow a specific syntax; The current value of the sequence is written as a_n, previous items can be written as a_{n-1}, a_{n-2} ... and the next items can be written as a_{n+1}, a_{n+2} ...

A recursive sequence is defined by the the definition of the a_n. Therefore, the string must always begin with the definition of a_n:

                        "a_n = ....."
                    
For example, this string represents the Fibonacci sequence, where the initial values of the sequence are `(1 ,1, 2)`:
                        "a_n = a_{n-1} + a_{n-2}"
                    
And this string is a recursive definition for factorial, where the first elements are `(1, 2)`.
                        a_n = a_{n-1} * n
                    
There is one key concept one has to remember when entering the first elements of a recursive sequence to this interface; For each previous item you refer to in the string, you have to add one more initial value. For example, if only refer to a_n in the string, you need to enter at least 1 initial value, but if you also refer to a_{n-1}, you need to enter at least 2 initial values, and so on. That's why factorial requires 2 starting values, and Fibonacci takes 3 starting values. Here is how these sequences will actually look in the code:
                        fibonacci = RecursiveSeq("a_n = a_{n-1} + a_{n-2}", (1, 1, 2))
my_factorial = RecursiveSeq("a_n = a_{n-1} * n", (1, 2))

You can also use more advanced calculations in order to define your own recursive sequence. Here are some examples:

# An example of a custom recursive method.
custom_recursion = RecursiveSeq("a_n = a_{n-1}^2 + 0.5*ln(a_{n-2})", [e, 1, 1.5])
print(f"The custom recursive method is {custom_recursion.at_n(4)} at place 4")

Fetching the item on the `n`th place

Declaring the sequence is the most difficult part. Once you manage to that, you can use all the features effortlessly. One of the most important of these features is finding the item on given place. The only thing that one needs to remember here is that unlike arrays, sequences start from the index `1`. Thus, index `1` represents the first item, index of `2` to represents the second, etc. There are two syntactic approaches to fetch the item in the `n`th place

  1. Use the method at_n()
  2. Use indexers ([ ])

Iterating on a sequence

You can iterate on elements of a sequence within a given range using a for loop, in two main approaches.
  1. Use the range() method.
  2. Use slicing, in a ([start:stop:step]) format

Probability Trees

You can create, process, and visualize probability trees with the ProbabilityTree class. Probability trees can be rather useful when working with conditional occurrences of events. We technically don't really have to use them in order to solve sophisticated problems, but they can make our life much easier and help us share the relevant information in an understandable way for anyone.

Our class is built on the defusedxml and anytree modules. Those provide with the basic API for general trees. Additional methods have been added regarding probabilities.

from kiwicalc import ProbabilityTree

Properties

  • root- fetches the root node of the tree

Creating a new tree

def __init__(self, root=None, json_path=None, xml_path=None):
  • root - the root of the tree ( of type Occurrence )
  • json_path - you could import the tree from json format.
  • xml_path - you could also import the tree from xml format.

For example, lets represent a simple scenario where all of the student take a test. 40% will pass the test, while 60% will fail. Out of the 40% who passes the test, 10% will ace it.

                    
tree = ProbabilityTree(root=Occurrence(1, "taking_the_test"))
pass_test = tree.add(0.4, "pass_test")
fail_test = tree.add(0.6, "fail_test")
ace_test = tree.add(0.1, "ace_test", parent=pass_test)
print(tree.get_probability(path="taking_the_test/pass_test/ace_test"))

output:
0.04
                    

Here, we did the following steps:

  1. We defined the tree, with the root node. The probability to take the test is 1, since everyone takes the test.
  2. We added child nodes to the root via the add() method. One node represents the probability of success in the test (0.4), and the other the probability to fail the test (0.6). Put differently, 40% passed the test, while 60% failed.
  3. We created another node that represents the 10% who aced the test out of the 40% who passed it.

If the probability to pass the test is `p_k`, and the the probability to ace it out of those who passed it is `p_n`, the probability of a random student to ace the test is `p_f = p_k * p_n`. We know that `p_k = 0.4`, and `p_n = 0.1`, and therefore `p_f = 0.4 * 0.1 = 0.04`. So finally, we know that only `4%` of the students aced the test, which means that either the teacher is terrible at his job, or the students are stupid.

In case these last two paragraphs haven't been clear, here is how you would visualize the tree:

Example 2: Creating a Probability Tree via JSON

tree = ProbabilityTree(json_path='cooltree.json')

Each node in the json representation of the tree must contain three keys: parent ( the parent node ), identifier ( the unique name of the node ) and the chance of the occurrence, as shown below:

                    
 {
      "root": {
      "parent":null,
      "identifier":"root",
      "chance":1
    },
    "son1": {
      "parent":"root",
      "identifier":"son1",
      "chance":0.6
    },
    "son2": {
      "parent":"root",
      "identifier":"son2",
      "chance":0.4
    }
  }
                    

Example 3 - Creating a Probability Tree via XML format

                    tree = ProbabilityTree(xml_path="cooltree.xml")
                

Each node tag must contain 3 attributes :

  1. parent - the identifier attribute of the parent node
  2. identifier - a unique string that describes the occurrence.
  3. chance - the probability of the occurrence, between 0 and 1. ( 0 is 0% and 1 is 100%)

The first node will always be the root node. Its parent attribute will be set to None.

                    <tree>
    <node>
        <parent>None</parent>
        <identifier>root</identifier>
        <chance>1</chance>
    </node>
    <node>
        <parent>root</parent>
        <identifier>son1</identifier>
        <chance>0.6</chance>
    </node>
    <node>
        <parent>root</parent>
        <identifier>son2</identifier>
        <chance>0.4</chance>
    </node>
</tree>
               

Generate a JSON file from a tree object

Using the to_json() method, you can generate a json file from a ProbabilityTree object. For instance:

    tree = ProbabilityTree()
first_son = tree.add(0.5, "son1") # adding a son
second_son = tree.add(0.5, "son2") # adding another son
tree.export_json("mytree.json") # creating the file in the given path

In the first statement, we created the ProbabilityTree object. In that, we also generated a default root node, that its chance of occurrence is `1` (`100%`). Later on, we created two child nodes for the root - first_son and second_son. Finally, we exported the tree to a JSON file called my_tree.json in our working directory.

    {
    "root": {
        "parent": null,
        "identifier": "root",
        "chance": 1
    },
    "son2": {
        "parent": "root",
        "identifier": "son2",
        "chance": 0.5
    },
    "son1": {
        "parent": "root",
        "identifier": "son1",
        "chance": 0.5
    }
}

Fetch a node by its identifier

As mentioned earlier, each node in the tree has a unique string, called identifier. You can fetch a node by its identifier with the get_node_by_id() method. Here is the signature of the method:

                    def get_node_by_id(self, identifier: str):
                

For example:

                    // Add code here
                

Get the path of a node

The path of a node in the tree gives us a sense of its location, similar to a system of files. We will also disregard the root element in the path. We can fetch the path of a node with the get_node_path() method:

                def get_node_path(self, node: Union[str, Node]):
                    

The method either accepts the Node object itself, or its identifier (a string). For instance:

// Add an example