<SYSTEM_TASK:>
Given the following text description, write Python code to implement the functionality described below step by step
<END_TASK>
<USER_TASK:>
Description:
 <font color='mediumblue'> What are classes?
A way of organising your code
Data is inherently linked to the things you can do with it.
Pros
Can do everything you can do without classes, but idea is to make it easier
Classes encourage code reuse through a concept called "inheritance" - we will discuss later.
Cons
Can make your code more complicated, and without careful thinking, harder to maintain.
More work for the developer.
<font color='mediumblue'> Start by defining some terminology - Classes vs Objects vs Instances
Often used interchangably but they are different concepts.
A Class is like a template - you could consider the class "Car"
An object is a particular occurence of a class - so, for example, you could have "Ford Mondeo", "Vauxhall Astra", "Lamborghini Gallardo" be objects of type "Car".
An instance is a unique single object.
<font color='mediumblue'> Where are classes used in Python? Everywhere!
You've been using classes all of the time, without even knowing it. Everything in Python is an object. You have some data (number, text, etc.)with some methods (or functions) which are internal to the object, and which you can use on that data. Lets look at a few examples...
Step1:  How can I see what methods an object of type float has?
Step2:  <font color='midnightblue'> Aside - What do all those underscores mean?
They're hidden methods - we'll talk more about these later.
<font color='mediumblue'> Creating a class
Define some key things
Step3:  <font color='mediumblue'> Using the class
Use the same syntax as we use to call a function, BUT the arguments get passed in to the "__init__" function. Note that you ignore the self object, as Python sorts this out.
Step4:  How do I access data stored in the class? with the ".", followed by the name.
Step5:  What happens though if we reuse the variable name 'a'?
Aside
Step6:  So, what happens if we either choose to store something else under the name 'a', or tell Python to delte it?
Step7:  Why bother? This can be achieved without classes very easily
Step8:  Need a better example!
How about a Simulation class?
* Write once, but can take different parameters.
* Can include data analysis methods as well
<font color='mediumblue'> Consider a 1-D box of some length
Step11:  What we're going to try and do is add particles to the box, which have some properties
Step12:  Lets just check this, if a Particle is in a box of length 10, has r0 = 0, v0=5, then after 1 step of length 3, the position should be at position 5
Step13:  Lets add a place to store the particles to the box class, and add a method to add particles to the box
Step14:  <font color='mediumblue'> Now lets get you to do something...
Tasks (30-40 minutes)
Step17:  <font color='mediumblue'> Class Properties
Properties can be used to do interesting things
Special functions as part of a class that we mark with a 'decorator' - '@property'
Lets adjust the class Particle we used to make its data members a property of the class. We also need to write a 'setter' method to set the data members.
Step19:  <font color='midnightblue'> Why bother? It looks the same when we use it!
Well known in programming - 'an interface is a contract'
You might want to at some point rewrite a large portion of the underlying data - how it is stored for example.
If you do this without using properties to access the data, you then need to go through all code that uses this class and change it to use the new variable names.
<font color='mediumblue'> Inheritance
Last part of the course on Classes, but also one of the main reason for using classes!
Inheritance allows you to reuse parts of the code, but change some of the methods. Lets see how it might be useful...
Step20:  Here we have inherited most of the class Particle, and just changed the method 'step' to do something differently. Because we kept the properties the same, we can use this class everywhere that we could use Particle - our Box class can take a mixture of Particles and SlowParticles
<font color='mediumblue'> Magic Methods
Step21:  Notice that there is a method "__add__" - we can define these special methods to allow our class to do things that you can ordinarily do with built in types.
Step22:  Now we've created an 'add' method, we can, create two boxes and add these together!
Step23:  Looks good! But hang on...
Step24:  Why has the mean position of particles in Box C changed? Look at the memory address of the particles
Step27:  Boxes are pointing to the SAME particles!
If we don't want this to happen, we need to write a 'copy' constructor for the class - a function which knows how to create an identical copy of the particle! 
We can do this by using the 'deepcopy' function in the 'copy' module, and redefine the particle and slow particle classes
Step28:  Then, we should change the Box class's 'add' method, to use this copy operation rather than just append the child particles of the existing boxes
<ASSISTANT_TASK:>
Python Code:
a = 10.1
type(a)
Explanation: <font color='mediumblue'> What are classes?
A way of organising your code
Data is inherently linked to the things you can do with it.
Pros
Can do everything you can do without classes, but idea is to make it easier
Classes encourage code reuse through a concept called "inheritance" - we will discuss later.
Cons
Can make your code more complicated, and without careful thinking, harder to maintain.
More work for the developer.
<font color='mediumblue'> Start by defining some terminology - Classes vs Objects vs Instances
Often used interchangably but they are different concepts.
A Class is like a template - you could consider the class "Car"
An object is a particular occurence of a class - so, for example, you could have "Ford Mondeo", "Vauxhall Astra", "Lamborghini Gallardo" be objects of type "Car".
An instance is a unique single object.
<font color='mediumblue'> Where are classes used in Python? Everywhere!
You've been using classes all of the time, without even knowing it. Everything in Python is an object. You have some data (number, text, etc.)with some methods (or functions) which are internal to the object, and which you can use on that data. Lets look at a few examples...
End of explanation
print(dir(a)) # Show all of the methods of a
a.is_integer()
Explanation: How can I see what methods an object of type float has?
End of explanation
# Create a class by using class keyword followed by name.
class MyClass:
    # The 'self' variable ALWAYS needs to be the first variable given to any class method.
    def __init__(self, message):
        # Here we create a new variable inside "self" called "mess" and save the argument "message"
        #  passed from the constructor to it.
        self.mess = message
    
    def say(self):
        print(self.mess)
    
        
    # Don't normally need to write a destructor - one is created by Python automatically. However we do it here
    # just to show you that it can be done:
    def __del__(self):
        print("Deleting object of type MyClass")
Explanation: <font color='midnightblue'> Aside - What do all those underscores mean?
They're hidden methods - we'll talk more about these later.
<font color='mediumblue'> Creating a class
Define some key things:
* self - 'self' is a special type of variable which can be used inside the class to refer to itself.
* Methods - a function which is part of a class, and which have access to data held by a class.
* A constructor - a special method which is called when you create an instance of a class. In Python this function must be called "__init__"
* A destructor - a special method which is called when you destroy an instance of a class.
Aside: If you're a C++/Java programmer, 'self' is exactly equivalent to 'this', but functions must have self as an argument, as it is passed in implicitly as the first argument of any method call in Python.
End of explanation
a = MyClass("Hello")
print(a.mess)
Explanation: <font color='mediumblue'> Using the class
Use the same syntax as we use to call a function, BUT the arguments get passed in to the "__init__" function. Note that you ignore the self object, as Python sorts this out.
End of explanation
# But, we also defined a method called "say" which does the same thing:
a.say()
Explanation: How do I access data stored in the class? with the ".", followed by the name.
End of explanation
print(a)
Explanation: What happens though if we reuse the variable name 'a'?
Aside:
* Your computer has Random Access Memory (RAM) which is used to store information.
* Whenever, in a programming language, you tell the language to store something, you effectively create a 'box' of memory to put those values in.
* The location of the specific 'box' is known as a 'memory address'
* You can see the memory address of a Python object quite easily:
End of explanation
del a
a = MyClass('Hello')
a = 2
Explanation: So, what happens if we either choose to store something else under the name 'a', or tell Python to delte it?
End of explanation
mess = "Hello"
def say(mess):
    print(mess)
    
say(mess)
Explanation: Why bother? This can be achieved without classes very easily:
End of explanation
class Box:
    def __init__(self, length):
        self.length = length
Explanation: Need a better example!
How about a Simulation class?
* Write once, but can take different parameters.
* Can include data analysis methods as well
<font color='mediumblue'> Consider a 1-D box of some length:
What information does it need to know about itself?
* How big is the box?
End of explanation
class Particle:
    def __init__(self, r0, v0):
        
        r0 = initial position
        v0 = initial speed
        
        self.r = r0
        self.v = v0
    
    def step(self, dt, L):
        
        Move the particle
        dt = timestep
        L = length of the containing box
        
        self.r = self.r + self.v * dt
        
        if self.r >= L:
            self.r -= L
        elif self.r < 0:
            self.r += L
    
Explanation: What we're going to try and do is add particles to the box, which have some properties:
* An initial position.
* An initial velocity
$r(t + \delta t) \approx r(t) + v(t)\delta t$
End of explanation
p = Particle(0, 5)
p.step(3, 10)
print(p.r)
Explanation: Lets just check this, if a Particle is in a box of length 10, has r0 = 0, v0=5, then after 1 step of length 3, the position should be at position 5:
End of explanation
class Box:
    def __init__(self, length):
        self.length = length
        self.particles = []
    
    def add_particle(particle):
        self.particles.append(particle)
Explanation: Lets add a place to store the particles to the box class, and add a method to add particles to the box:
End of explanation
class Box:
    def __init__(self, length):
        self.length = length
        self.particles = []
        self.t = 0
        
    def add_particle(self, particle):
        self.particles.append(particle)
    
    def step(self, dt):
        for particle in self.particles:
            particle.step(dt, self.length)
            
    def write(self, filename):
        f = open(filename, 'w')
        for particle in self.particles:
            f.write('{},{}\n'.format(particle.r, particle.v))
        f.close()
        
    def plot(self):
        for particle in self.particles:
            plt.scatter(particle.r, 0)
            
    def load(self, filename):
        f = open(filename, 'r')
        csvfile = csv.reader(f)
        for position, velocity in csvfile:
            p = Particle(position, velocity)
            self.add_particle(p)
        
b = Box(10)
for i in range(10):
    p = Particle(i/2, i/3)
    b.add_particle(p)
    
b.write('test.csv')
!cat test.csv
Explanation: <font color='mediumblue'> Now lets get you to do something...
Tasks (30-40 minutes):
1) Add a method that calculates the average position of Particles in the box (Hint: you might have to think about what to do when there are no particles!)
2) Add a method that makes all of the particles step forwards, and keep track of how much time has passed in the box class.
3) Add a method which plots the current position of the particles in the box.
4) Write a method that writes the current positions and velocities to a CSV file.
5) Write a method that can load a CSV file of positions and velocities, create particles with these and then add them to the Box list of particles. (Hint: Look up the documentation for the module 'csv')
End of explanation
class Particle:
    def __init__(self, r0, v0):
        
        r0 = initial position
        v0 = initial speed
        
        self._r = r0
        self._v = v0
    
    def step(self, dt, L):
        
        Move the particle
        dt = timestep
        L = length of the containing box
        
        self._r = self._r + self._v * dt
        
        if self._r >= L:
            self._r -= L
        elif self._r < 0:
            self._r += L
    
    @property
    def r(self):
        return self._r
    
    @r.setter
    def r_setter(self, value):
        self._r = value
        
    @property
    def v(self):
        return self._v
    
    @r.setter
    def r_setter(self, value):
        self._v = value
Explanation: <font color='mediumblue'> Class Properties
Properties can be used to do interesting things
Special functions as part of a class that we mark with a 'decorator' - '@property'
Lets adjust the class Particle we used to make its data members a property of the class. We also need to write a 'setter' method to set the data members.
End of explanation
class SlowParticle(Particle):
    def __init__(self, r0, v0, slowing_factor):
        Particle.__init__(self, r0, v0)
        self.factor = slowing_factor
    
    def step(self, dt, L):
        
        Move the particle, but change so that if the particle bounces off of a wall,
        it slows down by 50%
        dt = timestep
        L = length of the containing box
        
        self._r = self._r + self._v * dt
        
        if self._r >= L:
            self._r -= L
            self._v /= factor
        elif self._r < 0:
            self._r += L
            self._v /= factor
Explanation: <font color='midnightblue'> Why bother? It looks the same when we use it!
Well known in programming - 'an interface is a contract'
You might want to at some point rewrite a large portion of the underlying data - how it is stored for example.
If you do this without using properties to access the data, you then need to go through all code that uses this class and change it to use the new variable names.
<font color='mediumblue'> Inheritance
Last part of the course on Classes, but also one of the main reason for using classes!
Inheritance allows you to reuse parts of the code, but change some of the methods. Lets see how it might be useful...
End of explanation
a = 1.0
print(dir(a))
Explanation: Here we have inherited most of the class Particle, and just changed the method 'step' to do something differently. Because we kept the properties the same, we can use this class everywhere that we could use Particle - our Box class can take a mixture of Particles and SlowParticles
<font color='mediumblue'> Magic Methods:
Remember earlier, when we did:
End of explanation
class Box:
    def __init__(self, length):
        self.length = length
        self.particles = []
        self.t = 0
    
    def __add__(self, other):
        
        if self.length == other.length:
            b = Box(self.length)
            
            for p in self.particles:
                b.add_particle(p)
                
            for p in other.particles:
                b.add_particle(p)
            
            return b
        else:
            return ValueError('To add two boxes they must be of the same length')
        
    def mean_position(self):
        l = np.sum([p.r for p in self.particles])/len(self.particles)
        return l
    
    def add_particle(self, particle):
        self.particles.append(particle)
    
    def step(self, dt):
        for particle in self.particles:
            particle.step(dt, self.length)
            
    def write(self, filename):
        f = open(filename, 'w')
        for particle in self.particles:
            f.write('{},{}\n'.format(particle.r, particle.v))
        f.close()
        
    def plot(self):
        for particle in self.particles:
            plt.scatter(particle.r, 0)
            
    def load(self, filename):
        f = open(filename, 'r')
        csvfile = csv.reader(f)
        for position, velocity in csvfile:
            p = Particle(position, velocity)
            self.add_particle(p)
            
    def __repr__(self):
        if len(self.particles) == 1:
            return 'Box containing 1 particle'
        else:
            return 'Box containing {} particles'.format(len(self.particles))
Explanation: Notice that there is a method "__add__" - we can define these special methods to allow our class to do things that you can ordinarily do with built in types.
End of explanation
a = Box(10)
a.add_particle(Particle(10, 10))
b = Box(10)
b.add_particle(Particle(15, 10))
c = a + b
print(a)
print(b)
print(c)
Explanation: Now we've created an 'add' method, we can, create two boxes and add these together!
End of explanation
a.mean_position(), b.mean_position(), c.mean_position()
a.step(0.5)
a.mean_position(), b.mean_position(), c.mean_position()
Explanation: Looks good! But hang on...
End of explanation
a.particles, c.particles
Explanation: Why has the mean position of particles in Box C changed? Look at the memory address of the particles:
End of explanation
import copy
class Particle:
    def __init__(self, r0, v0):
        
        r0 = initial position
        v0 = initial speed
        
        self.r = r0
        self.v = v0
    
    def step(self, dt, L):
        
        Move the particle
        dt = timestep
        L = length of the containing box
        
        self.r = self.r + self.v * dt
        
        if self.r >= L:
            self.r -= L
        elif self.r < 0:
            self.r += L
    
    def copy(self):
        return copy.deepcopy(self)
Explanation: Boxes are pointing to the SAME particles!
If we don't want this to happen, we need to write a 'copy' constructor for the class - a function which knows how to create an identical copy of the particle! 
We can do this by using the 'deepcopy' function in the 'copy' module, and redefine the particle and slow particle classes:
End of explanation
class Box:
    def __init__(self, length):
        self.length = length
        self.particles = []
        self.t = 0
    
    def __add__(self, other):
        
        if self.length == other.length:
            b = Box(self.length)
            
            for p in self.particles:
                b.add_particle(p)
                
            for p in other.particles:
                b.add_particle(p)
            
            return b
        else:
            return ValueError('To add two boxes they must be of the same length')
        
    def mean_position(self):
        l = np.sum([p.r for p in self.particles])/len(self.particles)
        return l
    
    def add_particle(self, particle):
        self.particles.append(particle.copy())
    
    def step(self, dt):
        for particle in self.particles:
            particle.step(dt, self.length)
            
    def write(self, filename):
        f = open(filename, 'w')
        for particle in self.particles:
            f.write('{},{}\n'.format(particle.r, particle.v))
        f.close()
        
    def plot(self):
        for particle in self.particles:
            plt.scatter(particle.r, 0)
            
    def load(self, filename):
        f = open(filename, 'r')
        csvfile = csv.reader(f)
        for position, velocity, ptype in csvfile:
            p = Particle(position, velocity)
            self.add_particle(p)
            
    def __repr__(self):
        if len(self.particles) == 1:
            return 'Box containing 1 particle'
        else:
            return 'Box containing {} particles'.format(len(self.particles))
a = Box(10)
a.add_particle(Particle(10, 10))
b = Box(10)
b.add_particle(Particle(15, 10))
c = a + b
print(a)
print(b)
print(c)
Explanation: Then, we should change the Box class's 'add' method, to use this copy operation rather than just append the child particles of the existing boxes:
End of explanation
<END_TASK>