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

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()