7
\$\begingroup\$

I'd like to hear your constructive criticisms about my first OOP project. I explain more what I actually do in the code's comments, and I tried to be quite explanatory with names etc.

import math as m

class projectileMotion():
    """
    This class calculate the movement of a projectile using its initial velocity
    and the angle at witch it is thrown/shot. The initial height of the projectile
    is 0 by default but can be set by the user when creating an instance.

    The friction forces are neglected, thus the only formula used is the
    following: # △X = x0 + v_x⋅△t - a_x/2⋅△t^2  (and some of its variations)
    """
    n_steps = 5 #small here for testing purpose. Between 30 and 50 for more accuracy
    g = 9.81

    def __init__(self, velocity, angle, y_init=0):
        self.y_init = y_init
        self.angle_init = angle
        self.velocity_init = velocity

        self.vx_init = self.calc_vx_init() #Turn the angled velocity vector
        self.vy_init = self.calc_vy_init() #into two normal velocity vectors.

        self.dt, self.dt_vect = self.calc_dt_and_vect()
        self.dx, self.dx_vect = self.calc_dx_and_vect()
        self.dy, self.dy_vect = self.calc_dy_and_vect()

        self.coords_max_y = self.coords_when_y_is_maximum()
        self.pos_vect = self.gives_pos_vect()

    def calc_vx_init(self):
        return self.velocity_init * m.cos(m.radians(self.angle_init))

    def calc_vy_init(self):
        return self.velocity_init * m.sin(m.radians(self.angle_init))

    def calc_dt_and_vect(self):
        """
        t_total = time taken for the object to fall on the ground (y=0)
        t_total = (vy + sqrt(vy**2 + 2*y0*g)) / g
        dt_vect contains the time at n_steps, the first one being 0
            => need to divide by n_steps-1
        """
        dt = (self.vy_init + m.sqrt(self.vy_init**2 + 2*self.y_init*self.g))/self.g
        return dt, [(dt/(self.n_steps-1))*i for i in range(self.n_steps)]

    def calc_dx_and_vect(self):
        dx_vect = [self.vx_init*t for t in self.dt_vect]
        dx = dx_vect[len(dx_vect)-1]
        return dx, dx_vect

    def calc_dy_and_vect(self):
        dy_vect = [(self.y_init + self.vy_init*t - self.g/2*t**2) for t in self.dt_vect]
        dy = dy_vect[len(dy_vect)-1] - self.y_init
        return dy, dy_vect

    def coords_when_y_is_maximum(self):
        """
        max_y atteined when vy - g*t = 0 (when the velocity induced by gravity = vy)
        """
        max_y_t = self.vy_init/self.g
        x = max_y_t*self.vx_init
        y = self.y_init + self.vy_init*max_y_t - self.g/2 * max_y_t**2
        return (x, y)

    def gives_pos_vect(self):
        return list(zip(self.dx_vect, self.dy_vect))

if __name__ == '__main__':

    obj = projectileMotion(velocity=10, angle=30, y_init=5)
    for k, val in obj.__dict__.items(): print(k, val, "\n\n")

Now, this is a second file I used to try doing things with the class. I include it as well since you could have things to say about the way I do things.

from projectile_oop_v2 import projectileMotion

def find_best_angle(speed, y0):
    """
    Find the angle at which the thow goes the farthest
    """
    max_distance = 0
    for angle in range(50):
        distance = projectileMotion(angle=angle, velocity=speed, y_init=y0).dx
        if distance > max_distance:
            max_distance = distance
            best_angle = angle
    return (speed, y0, round(max_distance,1), best_angle)

speed_y0_distance_best_angle = []

for speed in range(1, 101):
    y0 = speed
    speed_y0_distance_best_angle.append(find_best_angle(speed, y0))
"""Could then be processed to make graphs and whatnot"""

for line in speed_y0_distance_best_angle: print(line)

Note: a run() method will most likely be added instead of running everything in the initialization. But you can still comment on that ;)

As a beginner, any genuine advice is a blessing, so please feel free to share all your thoughts.

\$\endgroup\$
0

4 Answers 4

5
\$\begingroup\$

projectileMotion should be ProjectileMotion by PEP8.

Don't alias math as m; math should be fine for your purposes.

You need to somehow document - either by comment or by variable name - that the angle you accept is in degrees.

Technically you don't accept "velocity", since velocity is a vector; you're accepting speed. When you break it down into vector components, then it's velocity.

Your class currently stores more than it should as state. It should only contain enough to "define the curve", rather than storing the entire curve itself.

Add PEP484 type hints.

Consider using generator functions instead of lists when you do iterate over time.

"Coordinates when Y is maximum" is just called the "apex", which is more terse.

Grammar: "this class calculate" -> "this class calculates"; "witch" -> "which".

Your best-angle calculation is brute-force, and an analytical solution is feasible (though I don't know what your level of math is, so you might find it tricky to derive on your own). I adapted a solution from Henelsmith 2016 which describes the problem in great detail.

I don't think it's a good idea to dump your object via blind __dict__ iteration. Make a proper description method.

Your g = 9.81 is technically not the standard gravitational acceleration constant at sea level, and should instead be 9.80665.

Could then be processed to make graphs and whatnot

Indeed; matplotlib makes this easy.

Suggested

import math
from numbers import Real
from typing import Iterator

from matplotlib import pyplot as plt


class ProjectileMotion:
    """
    This class calculates the movement of a projectile using its initial velocity
    and the angle at which it is thrown/shot. The initial height of the projectile
    is 0 by default but can be set by the user when creating an instance.

    The friction forces are neglected, thus the only formula used is the
    following: # △X = x0 + v_x⋅△t - a_x/2⋅△t^2  (and some of its variations)
    """
    G = 9.80665

    def __init__(self, speed0: Real = 1, angle0_degrees: Real = 45, y0: Real = 0) -> None:
        self.y0 = y0
        self.angle0 = math.radians(angle0_degrees)
        self.vx0, self.vy0 = self._get_v0(speed0)
        self.t_apex = self._get_apex_time()
        self.t1 = self._get_t1()

    def _get_v0(self, speed0: Real) -> tuple[float, float]:
        return (
            speed0 * math.cos(self.angle0),
            speed0 * math.sin(self.angle0),
        )

    def _get_t1(self) -> float:
        """
        t_total = time taken for the object to fall on the ground (y=0)
        t_total = (vy + sqrt(vy**2 + 2*y0*g)) / g
        """
        return (
             self.vy0 + math.sqrt(self.vy0**2 + 2*self.y0*self.G)
        ) / self.G

    def get_t(self, n_steps: int) -> Iterator[float]:
        """
        t_vect contains the time at n_steps, the first one being 0
            => need to divide by n_steps-1
        """
        for i in range(n_steps):
            yield self.t1 * i / (n_steps - 1)

    def x_for_t(self, t: float) -> float:
        return self.vx0 * t

    def y_for_t(self, t: float) -> float:
        return self.y0 + self.vy0*t - self.G/2*t**2

    def angle_for_t(self, t: float) -> float:
        return math.degrees(math.atan2(
            self.vy0 - t*self.G,
            self.vx0
        ))

    @property
    def x1(self) -> float:
        return self.x_for_t(self.t1)

    def _get_apex_time(self) -> float:
        """
        max_y attained when vy - g*t = 0 (when the velocity induced by gravity = vy)
        """
        return self.vy0 / self.G

    def desc(self, n_steps: int = 10) -> str:
        return (
            f'Curve over {n_steps} steps:'
            f'\n{"t":>5} {"x":>5} {"y":>5} {"angle°":>6}'
            f'\n'
        ) + '\n'.join(
            f'{t:>5.2f}'
            f' {self.x_for_t(t):>5.2f}'
            f' {self.y_for_t(t):>5.2f}'
            f' {self.angle_for_t(t):>6.1f}'
            for t in self.get_t(n_steps)
        )

    @classmethod
    def with_best_angle(cls, speed0: float, y0: float) -> 'ProjectileMotion':
        """
        https://www.whitman.edu/Documents/Academics/Mathematics/2016/Henelsmith.pdf
        equations 12 & 13
        h: initial height = y0
        v: initial velocity magnitude = speed
        Impact coefficients m = 0, a = 0
        arccotangent(x) = atan(1/x)
        """
        g = ProjectileMotion.G
        v = speed0
        angle = math.atan(
            1/math.sqrt(
                2*y0*g/v/v + 1
            )
        )
        return cls(
            speed0=speed0, angle0_degrees=math.degrees(angle), y0=y0,
        )


def graph(motion: ProjectileMotion) -> plt.Figure:
    fig, ax_x = plt.subplots()
    ax_t: plt.Axes = ax_x.twiny()
    ax_x.set_title('Projectile Motion')

    times = tuple(motion.get_t(n_steps=100))
    ax_x.plot(
        [motion.x_for_t(t) for t in times],
        [motion.y_for_t(t) for t in times],
    )

    ax_x.set_xlabel('x')
    ax_t.set_xlabel('t')
    ax_x.set_ylabel('y')
    ax_x.set_xlim(left=0, right=motion.x_for_t(motion.t1))
    ax_t.set_xlim(left=0, right=motion.t1)
    ax_x.set_ylim(bottom=0)

    return fig


def simple_test() -> None:
    motion = ProjectileMotion(speed0=10, angle0_degrees=30, y0=5)
    print(motion.desc())
    graph(motion)
    plt.show()


def optimisation_test() -> None:
    for y0 in range(1, 102, 10):
        best = ProjectileMotion.with_best_angle(y0=y0, speed0=y0)
        print(f'speed0={y0:3} y0={y0:3} x1={best.x1:6.1f} '
              f'angle0={math.degrees(best.angle0):.1f}')


if __name__ == '__main__':
    print('Simple projectile test:')
    simple_test()
    print()

    print('Angle optimisation test:')
    optimisation_test()

Output

Simple projectile test:
Curve over 10 steps:
    t     x     y angle°
 0.00  0.00  5.00   30.0
 0.18  1.58  5.75   20.3
 0.36  3.16  6.17    9.3
 0.55  4.74  6.27   -2.4
 0.73  6.32  6.04  -14.0
 0.91  7.90  5.48  -24.5
 1.09  9.47  4.60  -33.5
 1.28 11.05  3.39  -41.0
 1.46 12.63  1.86  -47.1
 1.64 14.21  0.00  -52.0

Angle optimisation test:
speed0=  1 y0=  1 x1=   0.5 angle0=12.4
speed0= 11 y0= 11 x1=  20.6 angle0=30.9
speed0= 21 y0= 21 x1=  62.5 angle0=35.7
speed0= 31 y0= 31 x1= 125.2 angle0=38.0
speed0= 41 y0= 41 x1= 208.4 angle0=39.4
speed0= 51 y0= 51 x1= 312.1 angle0=40.4
speed0= 61 y0= 61 x1= 436.2 angle0=41.0
speed0= 71 y0= 71 x1= 580.7 angle0=41.5
speed0= 81 y0= 81 x1= 745.6 angle0=41.9
speed0= 91 y0= 91 x1= 931.0 angle0=42.2
speed0=101 y0=101 x1=1136.7 angle0=42.5

plot

Bonus round: generalised vectors

A different answer suggests performing generalised vector math. While I don't agree with that class-based approach I do agree with the idea: the most direct way to implement this is to use the complex numbers built into Python. Among other benefits, this significantly simplifies the best-angle calculation since it can be expressed in rectilinear space.

import cmath
import math
from typing import Iterator

from matplotlib import pyplot as plt


class ProjectileMotion:
    """
    This class calculates the movement of a projectile using its initial velocity
    and the angle at which it is thrown/shot. The initial height of the projectile
    is 0 by default but can be set by the user when creating an instance.

    The friction forces are neglected, thus the only formula used is the
    following: # △X = x0 + v_x⋅△t - a_x/2⋅△t^2  (and some of its variations)
    """

    G = -9.80665j

    def __init__(self, v0: complex, p0: complex) -> None:
        self.v0 = v0
        self.p0 = p0
        self.t_apex = self._get_apex_time()
        self.t1 = self._get_t1()

    @classmethod
    def from_polar(cls, speed0: float, angle0_degrees: float, x0: float = 0, y0: float = 0) -> 'ProjectileMotion':
        return cls(
            p0=x0 + y0*1j, v0=cmath.rect(speed0, math.radians(angle0_degrees))
        )

    def _get_apex_time(self) -> float:
        """
        max_y attained when vy - g*t = 0 (when the velocity induced by gravity = vy)
        """
        return -self.v0.imag / self.G.imag

    def _get_t1(self) -> float:
        """
        t_total = time taken for the object to fall on the ground (y=0)
        t_total = (vy + sqrt(vy**2 + 2*y0*g)) / g
        """
        y0 = self.p0.imag
        vy0 = self.v0.imag
        ay = -self.G.imag
        return (
             vy0 + math.sqrt(vy0**2 + 2*ay*y0)
        ) / ay

    def get_t(self, n_steps: int) -> Iterator[float]:
        """
        t_vect contains the time at n_steps, the first one being 0
            => need to divide by n_steps-1
        """
        for i in range(n_steps):
            yield self.t1 * i / (n_steps - 1)

    def p_for_t(self, t: float) -> complex:
        return self.p0 + self.v0*t + self.G*t**2/2

    def v_for_t(self, t: float) -> complex:
        return self.v0 + self.G*t

    def angle_for_t(self, t: float) -> float:
        speed, angle = cmath.polar(self.v_for_t(t))
        return math.degrees(angle)

    @property
    def x1(self) -> float:
        return self.p_for_t(self.t1).real

    def desc(self, n_steps: int = 10) -> str:
        return (
            f'Curve over {n_steps} steps:'
            f'\n{"t":>5} {"x":>5} {"y":>5} {"angle°":>6}'
            f'\n'
        ) + '\n'.join(
            f'{t:>5.2f}'
            f' {(p := self.p_for_t(t)).real:>5.2f}'
            f' {p.imag:>5.2f}'
            f' {self.angle_for_t(t):>6.1f}'
            for t in self.get_t(n_steps)
        )

    @classmethod
    def with_best_angle(cls, speed0: float, y0: float) -> 'ProjectileMotion':
        """
        https://www.whitman.edu/Documents/Academics/Mathematics/2016/Henelsmith.pdf
        equations 12 & 13
        h: initial height = y0
        v: initial velocity magnitude = speed
        Impact coefficients m = 0, a = 0
        arccotangent(x) = atan(1/x), but do this in rectilinear space, not polar space
        """
        g = ProjectileMotion.G.imag
        v = speed0
        tan2 = 1/(
            1 - 2*y0*g/v/v
        )
        vx = v/math.sqrt(1 + tan2)
        vy = vx*math.sqrt(tan2)

        return cls(
            v0=vx + 1j*vy,
            p0=y0*1j,
        )


def graph(motion: ProjectileMotion) -> plt.Figure:
    fig, ax_x = plt.subplots()
    ax_t: plt.Axes = ax_x.twiny()
    ax_x.set_title('Projectile Motion')

    pos = [motion.p_for_t(t) for t in motion.get_t(n_steps=100)]
    ax_x.plot(
        [p.real for p in pos],
        [p.imag for p in pos],
    )

    ax_x.set_xlabel('x')
    ax_t.set_xlabel('t')
    ax_x.set_ylabel('y')
    ax_x.set_xlim(left=0, right=motion.p_for_t(motion.t1).real)
    ax_t.set_xlim(left=0, right=motion.t1)
    ax_x.set_ylim(bottom=0)

    return fig


def simple_test() -> None:
    motion = ProjectileMotion.from_polar(speed0=10, angle0_degrees=30, y0=5)
    print(motion.desc())
    graph(motion)
    plt.show()


def optimisation_test() -> None:
    for y0 in range(1, 102, 10):
        best = ProjectileMotion.with_best_angle(y0=y0, speed0=y0)
        speed0, angle0 = cmath.polar(best.v0)
        print(f'speed0={y0:3} y0={y0:3} x1={best.x1:6.1f} '
              f'angle0={math.degrees(angle0):.1f}')


if __name__ == '__main__':
    print('Simple projectile test:')
    simple_test()
    print()

    print('Angle optimisation test:')
    optimisation_test()
\$\endgroup\$
5
  • 1
    \$\begingroup\$ Thanks a lot for all the feedback! I'll look into the code in details in the morning. I should understand by myself, but in case I don't I might come back to you then. This is the first project, but not really the final version. I know I used brute force :o. I planned on using some smarter methods later (e.g. scipy minimize function if I recall). I did this project to kill time during my exam session (uni student) and couldn't find the time nor the brain power to think too much. Also, thank you very much for the vocab insight. English being my third language, I didn't know the precise terms. \$\endgroup\$
    – Rúshi
    Jan 21 at 20:34
  • 1
    \$\begingroup\$ Thanks for having shared Henelsmith's study, this is very interesting. Only read bits of it because I also want to go deeper into the problem and find my own ways. But at some point I'll read through it in details and compare <3 \$\endgroup\$
    – Rúshi
    Jan 21 at 21:39
  • 1
    \$\begingroup\$ Awesome use of Typing thanks I've been struggling with that. I agree about the run() method. \$\endgroup\$
    – wbg
    Jan 22 at 3:21
  • \$\begingroup\$ Great review. I'd argue that G should be a parameter as well, so we can re-use that code on other planets. ;) \$\endgroup\$ Jan 22 at 22:21
  • \$\begingroup\$ @RichardNeumann Ha, I'll keep that in mind. Thanks! \$\endgroup\$
    – Reinderien
    Jan 22 at 22:37
2
\$\begingroup\$

I did not dive deep into the correctness of the calculation. Below, I describe some issues that can help to make code more readable:

  • Using simple aliases like this import math as m is a very bad idea. The names of anything in the code should be more informative. Use: import math
  • Better to use one style: CamelCase or snake_case in the whole project (codebase). I would recommend using CamelCase.
  • The class names should be started with Upper letter.
  • You can use list comprehension instead of simple iteration (for loop)
  • Better if the __init__ method includes only initialization code (define variables). In your example, you run almost all methods in the init method. From my POV I would create a separate method, for example run(), and would move all computation from the init method.
\$\endgroup\$
3
  • \$\begingroup\$ Thanks for sharing your thoughts. The math is correct according to other calculators I could find online, so it does not need a review. I love the idea for the run() method tho :like: I aim to follow PEP8 for my code and I think I followed the conventions for naming. I'll double check that. \$\endgroup\$
    – Rúshi
    Jan 21 at 17:06
  • 1
    \$\begingroup\$ why do you recommend using CamelCase? (that's pascal case btw) \$\endgroup\$ Jan 22 at 2:31
  • 1
    \$\begingroup\$ I like the use of CamelCase for classes and snake_case for methods personally. When there's an instance you don't have to guess if it's a class method or a function imported. \$\endgroup\$
    – wbg
    Jan 22 at 3:15
2
\$\begingroup\$

I'd echo the other's comments, but let's also talk a bit about architecture. At the moment, you've got all of your logic and state in one big class, and the organisation is very ad-hoc, for instance you return a coordinate as a tuple at one point, which is fine, but not necessarily clear.

I'd start by creating a data structure for storing x,y information, you've already used tuples in some cases, so let's try a namedtuple.

from typing import NamedTuple


class Vector(NamedTuple):
    x: float
    y: float

We can then use this in our main class instead of tracking things separately, for backwards compatibility at this stage I'll add a property to avoid changing the methods until we're ready.

    self.initial_location = Vector(x=0, y=y_init) # In the constructor
    ...
    @property
    def y_init(self):
        return self.initial_location.y
    

The velocity vector is given in polar coordinates, we can use a class method to Vector to let us create a vector from polar inputs, and then use that in the constructor for the main class (we can also add properties like before to maintain backwards-compatibility).

    # In Vector
    @classmethod
    def from_polar(cls, magnitude, angle):
        return cls(x=magnitude * math.cos(math.radians(angle)),
                   y=magnitude * math.sin(math.radians(angle)))

    # In ProjectileMotion.__init__
        self.initial_velocity = Vector.from_polar(velocity, angle)

Now we can start to look at the logic. We need to compute position vectors at given times for both the gives_pos_vec and coords_where_y_is_maximum methods, so let's make that a method in its own right:

    def position_at_time(self, time):
        return Vector(x=self.initial_location.x + self.initial_velocity.x*time,
                      y=self.initial_location.y + self.initial_velocity.y*time - self.g/2*time**2)

We can then modify get_pos_vec:

    #In the __init__
        self.dt, self.times = self.calc_dt_and_vect()
        self.positions = [self.position_at_time(time) for time in self.times]
        self.final_position = self.position_at_time(self.dt)
    
    ...
    def gives_pos_vect(self):
        return self.positions

... and coords_where_y_is_maximum

    def coords_when_y_is_maximum(self):
        """
        max_y atteined when vy - g*t = 0 (when the velocity induced by gravity = vy)
        """
        max_y_t = self.vy_init/self.g
        return self.position_at_time(max_y_t)

Now there's a few places you could take it. The motion equations right now don't look much like their mathematical counterparts, we can potentially fix this by adding some mathematical operations to Vector. In general I'd recommend instead using numpy or something which has that kind of functionality already built-in, but for the sake of this example let's implement vector addition + scalar multiplication / division.

# In Vector
    def __add__(self, other: "Vector"):
        return Vector(x=self.x+other.x, y=self.y+other.y)
    
    def __mul__(self, scalar: float):
        return Vector(x=self.x*scalar, y=self.y*scalar)
    
    def __truediv__(self, scalar: float):
        return Vector(x=self.x/scalar, y=self.y/scalar)

If we make acceleration from gravity a vector, this makes our position_at_time method rather pleasant:

    # In the __init__
        self.acceleration = Vector(x=0, y=-self.g)
    ...

    def position_at_time(self, time):
        return self.initial_position + self.initial_velocity*time + self.acceleration/2*time**2

The other methods would require implementing a lot more logic on Vector, but I'd recommend using numpy if you want to go down that route.

Putting it all together, we get something like this:

from typing import NamedTuple
import math


class Vector(NamedTuple):
    x: float
    y: float
    
    def __add__(self, other: "Vector"):
        return Vector(x=self.x+other.x, y=self.y+other.y)
    
    def __mul__(self, scalar: float):
        return Vector(x=self.x*scalar, y=self.y*scalar)
    
    def __truediv__(self, scalar: float):
        return Vector(x=self.x/scalar, y=self.y/scalar)
    
    @classmethod
    def from_polar(cls, magnitude, angle):
        return cls(x=magnitude * math.cos(math.radians(angle)),
                   y=magnitude * math.sin(math.radians(angle)))

class ProjectileMotion():
    """
    This class calculate the movement of a projectile using its initial velocity
    and the angle at witch it is thrown/shot. The initial height of the projectile
    is 0 by default but can be set by the user when creating an instance.

    The friction forces are neglected, thus the only formula used is the
    following: # △X = x0 + v_x⋅△t - a_x/2⋅△t^2  (and some of its variations)
    """
    n_steps = 5 #small here for testing purpose. Between 30 and 50 for more accuracy
    g = 9.81

    def __init__(self, velocity, angle, y_init=0):
        self.initial_position = Vector(x=0, y=y_init)
        self.initial_velocity = Vector.from_polar(velocity, angle)
        self.acceleration = Vector(x=0, y=-self.g)
        
        self.final_time, self.times = self._calc_dt_and_vect()
        self.positions = [self.position_at_time(time) for time in self.times]
        self.final_position = self.position_at_time(self.final_time)
    
    def _calc_dt_and_vect(self):
        """
        t_total = time taken for the object to fall on the ground (y=0)
        t_total = (vy + sqrt(vy**2 + 2*y0*g)) / g
        dt_vect contains the time at n_steps, the first one being 0
            => need to divide by n_steps-1
        """
        dt = (self.initial_velocity.y 
              + math.sqrt(
                  self.initial_velocity.y**2 
                  - 2*self.initial_position.y*self.acceleration.y)
             ) / -self.acceleration.y
        return dt, [(dt/(self.n_steps-1))*i for i in range(self.n_steps)]
    
    def position_at_time(self, time):
        return self.initial_position + self.initial_velocity*time + self.acceleration/2*time**2
    
    @property
    def coords_max_y(self):
        """
        max_y atteined when vy - g*t = 0 (when the velocity induced by gravity = vy)
        """
        return self.position_at_time(-self.initial_velocity.y/self.acceleration.y)
    
    @property
    def pos_vect(self):
        return self.positions
```
\$\endgroup\$
5
  • \$\begingroup\$ Generalising to vectors is a good idea, but the class-based approach is too heavyweight. Python's built-in complex support is perfectly suited to this task. \$\endgroup\$
    – Reinderien
    Jan 22 at 21:12
  • \$\begingroup\$ @Reinderien I disagree. A vector and a complex number are different animals. Extending a 2d vector to a 3d vector is straightforward, but if you use a complex number for the 2d vector, you've painted yourself into a corner. \$\endgroup\$
    – AJNeufeld
    Jan 23 at 5:49
  • \$\begingroup\$ Yes, I think using complex numbers here is just confusing, you're inheriting lots of behaviour that you don't necessarily want (such as complex multiplication). I think a numpy recarray is probably the simplest way to get something close to the maths yet still 'pythonic'. \$\endgroup\$ Jan 23 at 13:13
  • \$\begingroup\$ Well, tanks for the feedback. It's really late right now, but I'll read the code in detail tomorrow and ask some questions if there's things I don't understand. I will also share the current state of my code (which has improved quite a bit after a few more hours spent). But I have a big exam Wednesday, so can't spent too much time on it right now. \$\endgroup\$
    – Rúshi
    Jan 24 at 2:14
  • \$\begingroup\$ Btw I struggle to understand the @property decorator, but I only know very little about decorators in general yet... I've see that there's a couple articles about them on RealPython but couldn't find the time to read more than a fraction of them. \$\endgroup\$
    – Rúshi
    Jan 24 at 2:21
0
\$\begingroup\$

This is my current version of the code (I have yet to do the arithmetic method of finding the best angle, or change it according to the last answers):

from collections import namedtuple
from typing import Iterator
import math

class ProjectileMotion():

    G = 9.81

    def __init__(self, speed_init: float=10, angle_degree_init: float=45, y_init: float=0):
        self.y_init = y_init
        self.angle_init = math.radians(angle_degree_init)
        self.vx, self.vy_init = self._calc_init_velocity_(speed_init)
        self.t = self._calc_t_of_flight_()

    def _calc_init_velocity_(self, speed)-> tuple[float, float]:
        return (
            speed * math.cos(self.angle_init), speed * math.sin(self.angle_init)
            )

    def _calc_t_of_flight_(self)-> float:
        return (
            self.vy_init + math.sqrt(self.vy_init**2 + 2*self.y_init*self.G)
            ) / self.G

    def get_x_in_t(self, t: float=None)-> float:
        if t == None: t = self.t
        return self.vx*t

    def get_y_in_t(self, t: float=None)-> float:
        if t == None: t = self.t
        return self.y_init + self.vy_init*t - self.G/2*t**2

    def get_dy_in_t(self, t: float=None)-> float:
        if t == None: t = self.t
        return self.get_y_in_t(t) - self.y_init

    def get_vy_in_t(self, t: float=None)-> float:
        if t == None: t = self.t
        return self.vy_init - self.G*t

    def get_angle_in_t(self, t: float=None)-> float:
        if t == None: t = self.t
        return math.degrees(math.atan(self.get_vy_in_t(t) / self.vx))

    def get_apex_t(self)-> float:
        return self.vy_init/self.G

    def t_iter(self, n_steps: int=10)-> Iterator[float]:
        for i in range(n_steps):
            yield (i*self.t) / (n_steps-1)

    def curve_desc_in_t(self, t:float)-> tuple:
        named_tup = namedtuple('curve_in_t','t x y dy vy angle')
        return named_tup(round(t,3),
            round(self.get_x_in_t(t),3),
            round(self.get_y_in_t(t),3),
            round(self.get_dy_in_t(t),3),
            round(self.get_vy_in_t(t),3),
            round(self.get_angle_in_t(t),3)
        )

    def curve_desc_str(self, n_steps: int=10)-> str:
        apex_t = self.get_apex_t()
        return(
            f'\nCurve over {n_steps} steps:'
            f'\n{"t":>2} {"x":>5} {"y":>5} {"angle°":>10}\n'
        ) + '\n'.join(
            f'{t:>5.2f}'
            f' {self.get_x_in_t(t):>5.2f}'
            f' {self.get_y_in_t(t):>5.2f}'
            f' {self.get_angle_in_t(t):>6.2f}'
            for t in self.t_iter(n_steps)
        ) + (f'\n\n The apex is (x={self.get_x_in_t(apex_t):>.2f}, y='
            f'{self.get_y_in_t(apex_t):>.2f}) and occurs after {apex_t:>.2f}s\n'
        )

if __name__ == '__main__':

    obj = ProjectileMotion(speed_init=10, angle_degree_init=30, y_init=5)
    
    named_tup = obj.curve_desc_in_t(.5)
    print("\n", named_tup)

    print(obj.curve_desc_str())

Thanks for the help of everyone, I'll keep improving the code with to your comments when I can.

\$\endgroup\$
1
  • 2
    \$\begingroup\$ Thanks for posting this code. It's a good idea to summarise which changes you made, and why - a self-answer ought to review the code, just like any other answer. \$\endgroup\$ Jan 24 at 13:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.