# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3 (ipykernel)
#     language: python
#     name: python3
# ---

# # Scientific Computing with Python (Second Edition)
#
# # Chapter 08
#
# *We start by importing all from Numpy. As explained in Chapter 01 the examples are written assuming this import is initially done.*

from numpy import *


# ## 8.1 Introduction to classes
# ### 8.1.1 A guiding example: Rational numbers
# *no code*
# ### 8.1.2 Defining a class and making an instance

class RationalNumber: 
      pass


r = RationalNumber()

type(r)

if isinstance(r, RationalNumber):
    print('Indeed, it belongs to the class RationalNumber')  


# ### 8.1.3 The `__init__` method

class RationalNumber:
    def __init__(self, numerator, denominator):
        self.numerator = numerator
        self.denominator = denominator


q = RationalNumber(10, 20)    # Defines a new object
q.numerator                   # returns 10

q.denominator                 # returns 20

# +
q = RationalNumber(3, 5) # instantiation
q.numerator              # attribute access
q.denominator

a = array([1, 2])        # instantiation
a.shape

z = 5 + 4j               # instantiation
z.imag
# -

q = RationalNumber(3, 5) 
r = RationalNumber(7, 3)
q.numerator = 17
del r.denominator


class RationalNumber:
    def __init__(self, numerator, denominator):
        self.numerator = numerator
        self.denominator = denominator
    def convert2float(self):
        return float(self.numerator) / float(self.denominator)    


q = RationalNumber(10, 20)    # Defines a new object
q.convert2float() # returns 0.5  

RationalNumber.convert2float(q)


# ### 8.1.5 Special methods

class RationalNumber:
    def __init__(self, numerator, denominator):
        self.numerator = numerator
        self.denominator = denominator
    def convert2float(self):
        return float(self.numerator) / float(self.denominator)  
    def __repr__(self): 
        return f'{self.numerator} / {self.denominator}'


q = RationalNumber(10, 20)    # Defines a new object
print(q)


class RationalNumber:
    def __init__(self, numerator, denominator):
        self.numerator = numerator
        self.denominator = denominator
    def convert2float(self):
        return float(self.numerator) / float(self.denominator)  
    def __repr__(self): 
        return f'{self.numerator} / {self.denominator}'
    def add(self, other): 
        p1, q1 = self.numerator, self.denominator 
        if isinstance(other, int):        
            p2, q2 = other, 1 
        else: 
            p2, q2 = other.numerator, other.denominator 
        return RationalNumber(p1 * q2 + p2 * q1, q1 * q2)


q = RationalNumber(1, 2)
p = RationalNumber(1, 3)
q.add(p)   # returns the RationalNumber for 5/6


# *We add now to the class all special methods discussed in the book directly:*

class RationalNumber:
    def __init__(self, numerator, denominator):
        self.numerator = numerator
        self.denominator = denominator
    def convert2float(self):
        return float(self.numerator) / float(self.denominator)  
    def __repr__(self): 
        return f'{self.numerator} / {self.denominator}'
    def __add__(self, other): 
        p1, q1 = self.numerator, self.denominator 
        if isinstance(other, int):        
            p2, q2 = other, 1 
        else: 
            p2, q2 = other.numerator, other.denominator 
        return RationalNumber(p1 * q2 + p2 * q1, q1 * q2)
    def __eq__(self, other):
        return self.denominator * other.numerator == \
            self.numerator * other.denominator
    def __radd__(self, other):
        return self + other


p = RationalNumber(1, 2) # instantiation
q = RationalNumber(2, 4) # instantiation
p == q # True

q = RationalNumber(1, 2)
p = RationalNumber(1, 3)
q + p # RationalNumber(5, 6)

p+5

5+p

# +
import itertools

class  Recursion3Term:
    def __init__(self, a0, a1, u0, u1):
        self.coeff = [a1, a0]
        self.initial = [u1, u0]
    def __iter__(self):
        u1, u0 = self.initial
        yield u0  # (see also Iterators section in Chapter 9) 
        yield u1
        a1, a0 = self.coeff
        while True :
            u1, u0 = a1 * u1 + a0 * u0, u1
            yield u1
    def __getitem__(self, k):
        return list(itertools.islice(self, k, k + 1))[0]


# -

r3 = Recursion3Term(-0.35, 1.2, 1, 1)
for i, r in enumerate(r3):
    if i == 7:
        print(r)  # returns 0.194167
        break

r3[7] # returns 0.194167


# ## 8.2 Attributes that depend on each other

class Triangle:
    def __init__(self,  A, B, C):
        self.A = array(A)
        self.B = array(B)
        self.C = array(C)
        self.a = self.C - self.B
        self.b = self.C - self.A
        self.c = self.B - self.A
    def area(self):
        return abs(cross(self.b, self.c)) / 2


tr = Triangle([0., 0.], [1., 0.], [0., 1.])

tr.area() # returns 0.5

tr.B = [12., 0.]
tr.area() # still returns 0.5, should be 6 instead.


# ### 8.2.1 The function `property`

class Triangle:
    def __init__(self, A, B, C):
        self._A = array(A)
        self._B = array(B)
        self._C = array(C)
        self._a = self._C - self._B
        self._b = self._C - self._A
        self._c = self._B - self._A
    def area(self):
        return abs(cross(self._c, self._b)) / 2.
    def set_B(self, B):
        self._B = B
        self._a = self._C - self._B
        self._c = self._B - self._A
    def get_B(self):
        return self._B
    def del_Pt(self):
        raise Exception('A triangle point cannot be deleted')
    B = property(fget = get_B, fset = set_B, fdel = del_Pt)


tr = Triangle([0., 0.], [1., 0.], [0., 1.])
tr.B = [12., 0.]
tr.area() # returns 6.0


# ## 8.3 Bound and unbound methods

class A:
    def func(self,arg):
        pass


A.func  # <unbound method A.func>
instA = A()  # we create an instance
instA.func  #  <bound method A.func of ... >


# ## 8.4 Class attributes and class methods
# ### 8.4.1 Class attributes

class Newton:
    tol = 1e-8 # this is a class attribute
    def __init__(self,f):
        self.f = f # this is not a class attribute
    ...


f = lambda x: x**2
g = lambda x: 3*x
N1 = Newton(f)
N2 = Newton(g)

N1.tol # 1e-8
N2.tol # 1e-8

Newton.tol = 1e-10
N1.tol # 1e-10
N2.tol # 1e-10

N2.tol = 1.e-4
N1.tol  # still 1.e-10

Newton.tol = 1e-5 
# now all instances of the Newton classes have tol=1e-5
N1.tol # 1.e-5
N2.tol # 1.e-4 
# N2.tol is now detached and therefore not altered

# ### 8.4.2 Class methods

class Polynomial:
    def __init__(self, coeff):
        self.coeff = array(coeff)
    @classmethod
    def by_points(cls, x, y):
        degree = x.shape[0] - 1
        coeff = polyfit(x, y, degree)
        return cls(coeff) 
    def __eq__(self, other):
        return allclose(self.coeff, other.coeff)


# +
p1 = Polynomial.by_points(array([0., 1.]), array([0., 1.]))
p2 = Polynomial([1., 0.])

print(p1 == p2)  # prints True
# -

# ## 8.5 Subclasses and inheritance
#

# +
import matplotlib.pyplot as mp

class OneStepMethod:
    def __init__(self, f, x0, interval, N):
        self.f = f
        self.x0 = x0
        self.interval = [t0, te] = interval
        self.grid = linspace(t0, te, N)
        self.h = (te - t0) / N

    def generate(self):
        ti, ui = self.grid[0], self.x0
        yield ti, ui
        for t in self.grid[1:]:
            ui = ui + self.h * self.step(self.f, ui, ti)
            ti = t
            yield ti, ui

    def solve(self):
        self.solution = array(list(self.generate()))

    def plot(self):
        mp.plot(self.solution[:, 0], self.solution[:, 1])

    def step(self, f, u, t):
        raise NotImplementedError()


# +
class ExplicitEuler(OneStepMethod):
    def step(self, f, u, t):
        return f(u, t)

class MidPointRule(OneStepMethod):
    def step(self, f, u, t):
        return f(u + self.h / 2 * f(u, t), t + self.h / 2)


# +
def f(x, t):
    return -0.5 * x

euler = ExplicitEuler(f, 15., [0., 10.], 20)
euler.solve()
euler.plot()
midpoint = MidPointRule(f, 15., [0., 10.], 20)

midpoint.solve()
midpoint.plot()
# -

argument_list = [f, 15., [0., 10.], 20]
euler = ExplicitEuler(*argument_list)
midpoint = MidPointRule(*argument_list)

# +
euler = ExplicitEuler(f, 15., [0., 10.], 20)
euler.solve()
euler.plot()
midpoint = MidPointRule(f, 15., [0., 10.], 20)

midpoint.solve()
midpoint.plot()


# -

class ExplicitEuler(OneStepMethod):
    def __init__(self,*args, **kwargs):
        self.name='Explicit Euler Method'
        super(ExplicitEuler, self).__init__(*args,**kwargs)
    def step(self, f, u, t):
        return f(u, t)


# ## 8.6 Encapsulation

class Function:
    def __init__(self, f):
        self.f = f
    def __call__(self, x):
        return self.f(x)
    def __add__(self, g):
        def sum(x):
            return self(x) + g(x)
        return type(self)(sum) 
    def __mul__(self, g): 
        def prod(x):
            return self.f(x) * g(x)
        return type(self)(prod)
    def __radd__(self, g):
        return self + g
    def __rmul__(self, g):
        return self * g


T5 = Function(lambda x: cos(5 * arccos(x)))
T6 = Function(lambda x: cos(6 * arccos(x)))

# +
import scipy.integrate as sci

weight = Function(lambda x: 1 / sqrt((1 - x ** 2)))
[integral, errorestimate] = \
        sci.quad(weight * T5 * T6, -1, 1) # (6.510878470473995e-17, 1.3237018925525037e-14)
integral


# -

# ## 8.7 Classes as decorators

class echo:
    text = 'Input parameters of {name}\n'+\
        'Positional parameters {args}\n'+\
        'Keyword parameters {kwargs}\n'
    def __init__(self, f):
        self.f = f
    def __call__(self, *args, **kwargs):
        print(self.text.format(name = self.f.__name__,
              args = args, kwargs = kwargs))
        return self.f(*args, **kwargs)


@echo
def line(m, b, x):
    return m * x + b


line(2., 5., 3.)

line(2., 5., x=3.)


class CountCalls:
    """
    Decorator that keeps track of the number of times 
    a function is called.
    """
    instances = {} 
    def __init__(self, f):
        self.f = f
        self.numcalls = 0
        self.instances[f] = self
    def __call__(self, *args, **kwargs):
        self.numcalls += 1
        return self.f(*args, **kwargs)
    @classmethod
    def counts(cls):
        """
        Return a dict of {function: # of calls} for all 
        registered functions.
        """
        return dict([(f.__name__, cls.instances[f].numcalls) 
                                    for f in cls.instances])


# +
@CountCalls
def line(m, b, x):
    return m * x + b
@CountCalls 
def parabola(a, b, c, x):
    return a * x ** 2 + b * x + c
line(3., -1., 1.)
parabola(4., 5., -1., 2.)
 
CountCalls.counts() # returns {'line': 1, 'parabola': 1}
# -

parabola.numcalls # returns 1

StarcoderdataJupyterScriptsDedupFiltered