Kinematics / src /kinematics_visualizer.py
NavyDevilDoc's picture
Update src/kinematics_visualizer.py
89d1208 verified
import numpy as np
import matplotlib.pyplot as plt
from dataclasses import dataclass
from typing import List, Tuple, Optional
@dataclass
class Motion1D:
"""1D Kinematic motion calculator for point mass"""
initial_position: float = 0.0 # x0 (m)
initial_velocity: float = 0.0 # v0 (m/s)
acceleration: float = 0.0 # a (m/s²)
def position(self, t: float) -> float:
"""Calculate position at time t using x = x0 + v0*t + 0.5*a*t²"""
return self.initial_position + self.initial_velocity * t + 0.5 * self.acceleration * t**2
def velocity(self, t: float) -> float:
"""Calculate velocity at time t using v = v0 + a*t"""
return self.initial_velocity + self.acceleration * t
def time_arrays(self, duration: float, dt: float = 0.01) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Generate time arrays for position, velocity, and acceleration"""
t = np.arange(0, duration + dt, dt)
x = np.array([self.position(time) for time in t])
v = np.array([self.velocity(time) for time in t])
a = np.full_like(t, self.acceleration)
return t, x, v, a
@dataclass
class Motion2D:
"""2D Kinematic motion calculator (projectile motion)"""
launch_speed: float = 0.0 # Initial speed magnitude (m/s)
launch_angle: float = 0.0 # Launch angle in degrees
launch_height: float = 0.0 # Launch height above ground (m)
launch_x: float = 0.0 # Horizontal launch position (m)
gravity: float = 9.81 # Acceleration due to gravity (m/s²)
air_resistance: bool = False # Enable/disable air resistance
drag_coefficient: float = 0.1 # Simple drag coefficient (1/s)
is_sphere: bool = False # Toggle between point mass and sphere
sphere_radius: float = 0.037 # Sphere radius in meters (default: baseball ~37mm)
sphere_density: float = 700 # Sphere density in kg/m³ (default: baseball ~700 kg/m³)
air_density: float = 1.225 # Air density in kg/m³ (sea level)
sphere_drag_coeff: float = 0.47 # Aerodynamic drag coefficient for smooth sphere
def __post_init__(self):
"""Calculate velocity components and sphere properties from launch parameters"""
angle_rad = np.radians(self.launch_angle)
self.initial_velocity_x = self.launch_speed * np.cos(angle_rad)
self.initial_velocity_y = self.launch_speed * np.sin(angle_rad)
self.initial_position = (self.launch_x, self.launch_height)
self.acceleration = (0.0, -self.gravity)
# Calculate sphere properties if using sphere model
if self.is_sphere:
self.sphere_volume = (4/3) * np.pi * self.sphere_radius**3
self.sphere_mass = self.sphere_density * self.sphere_volume
self.sphere_cross_section = np.pi * self.sphere_radius**2
# For sphere: F_drag = 0.5 * rho * Cd * A * v²
# In our linear approximation: F_drag = k * v * m (where k has units 1/s)
# So: k = (0.5 * rho * Cd * A) / m
self.effective_drag_coeff = (0.5 * self.air_density * self.sphere_drag_coeff *
self.sphere_cross_section) / self.sphere_mass
else:
# Point mass defaults
self.sphere_mass = 1.0
self.sphere_volume = 0.0
self.sphere_cross_section = 0.0
self.effective_drag_coeff = self.drag_coefficient
def position(self, t: float) -> Tuple[float, float]:
"""Calculate 2D position at time t"""
if not self.air_resistance:
# No air resistance - standard kinematic equations
x = self.initial_position[0] + self.initial_velocity_x * t
y = self.initial_position[1] + self.initial_velocity_y * t - 0.5 * self.gravity * t**2
else:
# With air resistance - improved model
k = self.effective_drag_coeff
if k == 0:
# Fallback to no air resistance
x = self.initial_position[0] + self.initial_velocity_x * t
y = self.initial_position[1] + self.initial_velocity_y * t - 0.5 * self.gravity * t**2
else:
# Terminal velocity
v_terminal = self.gravity / k
exp_kt = np.exp(-k * t)
# Horizontal motion (no forces except drag)
x = self.initial_position[0] + (self.initial_velocity_x / k) * (1 - exp_kt)
# Vertical motion (gravity + drag)
# Analytical solution: y = y0 + (v0y + vt)/k * (1 - e^(-kt)) - vt*t
y = (self.initial_position[1] +
(self.initial_velocity_y + v_terminal) / k * (1 - exp_kt) -
v_terminal * t)
return x, y
def velocity(self, t: float) -> Tuple[float, float]:
"""Calculate 2D velocity at time t"""
if not self.air_resistance:
# No air resistance
vx = self.initial_velocity_x
vy = self.initial_velocity_y - self.gravity * t
else:
# With air resistance
k = self.effective_drag_coeff
exp_kt = np.exp(-k * t)
# Horizontal velocity
vx = self.initial_velocity_x * exp_kt
# Vertical velocity
v_terminal = self.gravity / k if k > 0 else float('inf')
vy = (self.initial_velocity_y + v_terminal) * exp_kt - v_terminal
return vx, vy
# Lines 140-152 - Update get_sphere_info method
def get_sphere_info(self) -> dict:
"""Return sphere physical properties"""
if not self.is_sphere:
return {}
# Calculate terminal velocity: vt = mg/(0.5 * rho * Cd * A) = g/k
terminal_vel = self.gravity / self.effective_drag_coeff if self.effective_drag_coeff > 0 else float('inf')
return {
'radius_mm': self.sphere_radius * 1000,
'diameter_mm': self.sphere_radius * 2000,
'mass_g': self.sphere_mass * 1000,
'volume_cm3': self.sphere_volume * 1000000,
'cross_section_cm2': self.sphere_cross_section * 10000,
'density_kg_m3': self.sphere_density,
'terminal_velocity_ms': terminal_vel
}
def get_launch_info(self) -> dict:
"""Return comprehensive launch information"""
flight_time = self.calculate_flight_time()
info = {
'launch_speed': self.launch_speed,
'launch_angle': self.launch_angle,
'launch_height': self.launch_height,
'initial_velocity_x': self.initial_velocity_x,
'initial_velocity_y': self.initial_velocity_y,
'flight_time': flight_time,
'range': self.calculate_range(),
'max_height': self.calculate_max_height(),
'air_resistance': self.air_resistance,
'is_sphere': self.is_sphere,
'effective_drag_coeff': self.effective_drag_coeff if self.air_resistance else 0
}
# Add sphere info if applicable
if self.is_sphere:
info.update(self.get_sphere_info())
return info
# Static method for common sphere presets
@staticmethod
def get_sphere_presets() -> dict:
"""Return common sphere presets with realistic properties"""
return {
'ping_pong': {
'name': '🏓 Ping Pong Ball',
'radius': 0.02, # 20mm radius (40mm diameter)
'density': 84, # Very light
'drag_coeff': 0.47
},
'golf': {
'name': '⛳ Golf Ball',
'radius': 0.0214, # 21.4mm radius (42.8mm diameter)
'density': 1130, # Dense
'drag_coeff': 0.24 # Dimpled surface reduces drag
},
'baseball': {
'name': '⚾ Baseball',
'radius': 0.037, # 37mm radius (74mm diameter)
'density': 700, # Medium density
'drag_coeff': 0.35 # Stitched surface
},
'tennis': {
'name': '🎾 Tennis Ball',
'radius': 0.033, # 33mm radius (66mm diameter)
'density': 370, # Light with hollow core
'drag_coeff': 0.51 # Fuzzy surface increases drag
},
'basketball': {
'name': '🏀 Basketball',
'radius': 0.12, # 120mm radius (240mm diameter)
'density': 60, # Very light (hollow)
'drag_coeff': 0.47
},
'bowling': {
'name': '🎳 Bowling Ball',
'radius': 0.108, # 108mm radius (216mm diameter)
'density': 1400, # Very dense
'drag_coeff': 0.47
}
}
def trajectory_data(self, duration: float, dt: float = 0.01) -> dict:
"""Generate complete trajectory data, stopping when projectile hits ground"""
t = np.arange(0, duration + dt, dt)
positions = []
velocities = []
times = []
for time in t:
pos = self.position(time)
vel = self.velocity(time)
# Stop if projectile goes below ground level (y < 0)
if pos[1] < 0 and len(positions) > 0:
break
positions.append(pos)
velocities.append(vel)
times.append(time)
if len(positions) == 0:
# Handle edge case
positions = [(self.launch_x, self.launch_height)]
velocities = [(self.initial_velocity_x, self.initial_velocity_y)]
times = [0]
positions = np.array(positions)
velocities = np.array(velocities)
times = np.array(times)
return {
'time': times,
'x': positions[:, 0],
'y': positions[:, 1],
'vx': velocities[:, 0],
'vy': velocities[:, 1],
'speed': np.sqrt(velocities[:, 0]**2 + velocities[:, 1]**2)
}
def calculate_flight_time(self) -> float:
"""Calculate approximate flight time"""
if not self.air_resistance:
# Original calculation without air resistance
h = self.launch_height
vy0 = self.initial_velocity_y
g = self.gravity
if g == 0:
if vy0 == 0:
return float('inf') if h >= 0 else 0
return h / vy0 if vy0 < 0 else float('inf')
discriminant = vy0**2 + 2*g*h
if discriminant < 0:
return 0
t1 = (vy0 + np.sqrt(discriminant)) / g
t2 = (vy0 - np.sqrt(discriminant)) / g
return max(t1, t2) if max(t1, t2) > 0 else 0
else:
# With air resistance, use numerical approach to find landing time
# This is an approximation - we'll simulate until we hit ground
max_time = 20.0 # Maximum simulation time
dt = 0.01
for t in np.arange(0, max_time, dt):
_, y = self.position(t)
if y <= 0 and t > 0:
return t
return max_time # Fallback
def calculate_range(self) -> float:
"""Calculate horizontal range of projectile"""
flight_time = self.calculate_flight_time()
if flight_time <= 0:
return 0
# Get final position
final_x, _ = self.position(flight_time)
return final_x - self.launch_x
def get_launch_info(self) -> dict:
"""Return comprehensive launch information"""
flight_time = self.calculate_flight_time()
info = {
'launch_speed': self.launch_speed,
'launch_angle': self.launch_angle,
'launch_height': self.launch_height,
'initial_velocity_x': self.initial_velocity_x,
'initial_velocity_y': self.initial_velocity_y,
'flight_time': flight_time,
'range': self.calculate_range(),
'max_height': self.calculate_max_height(),
'air_resistance': self.air_resistance,
'is_sphere': self.is_sphere,
'effective_drag_coeff': self.effective_drag_coeff if self.air_resistance else 0
}
# Add sphere info if applicable
if self.is_sphere:
sphere_info = self.get_sphere_info()
info.update(sphere_info)
return info
def calculate_max_height(self) -> float:
"""Calculate maximum height reached by projectile"""
if not self.air_resistance:
# Analytical solution for no air resistance
if self.gravity == 0:
return float('inf') if self.initial_velocity_y > 0 else self.launch_height
# Time to reach maximum height: when vy = 0
t_max = self.initial_velocity_y / self.gravity
if t_max <= 0: # Projectile going downward initially
return self.launch_height
_, max_y = self.position(t_max)
return max(max_y, self.launch_height)
else:
# Numerical approach for air resistance
flight_time = self.calculate_flight_time()
dt = 0.01
max_height = self.launch_height
for t in np.arange(0, flight_time + dt, dt):
_, y = self.position(t)
if y > max_height:
max_height = y
return max_height
class KinematicsVisualizer:
"""Create visualizations for kinematic motion"""
@staticmethod
def plot_1d_motion(motion: Motion1D, duration: float, title: str = "1D Kinematic Motion"):
"""Create comprehensive 1D motion plots"""
t, x, v, a = motion.time_arrays(duration)
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 12))
fig.suptitle(title, fontsize=16, fontweight='bold')
# Position vs Time
ax1.plot(t, x, 'b-', linewidth=2, label='Position')
ax1.set_ylabel('Position (m)')
ax1.grid(True, alpha=0.3)
ax1.legend()
ax1.set_title('Position vs Time')
# Velocity vs Time
ax2.plot(t, v, 'r-', linewidth=2, label='Velocity')
ax2.set_ylabel('Velocity (m/s)')
ax2.grid(True, alpha=0.3)
ax2.legend()
ax2.set_title('Velocity vs Time')
# Acceleration vs Time
ax3.plot(t, a, 'g-', linewidth=2, label='Acceleration')
ax3.set_xlabel('Time (s)')
ax3.set_ylabel('Acceleration (m/s²)')
ax3.grid(True, alpha=0.3)
ax3.legend()
ax3.set_title('Acceleration vs Time')
plt.tight_layout()
return fig
@staticmethod
def plot_2d_trajectory(motion: Motion2D, duration: float = None, title: str = "2D Projectile Motion"):
"""Create 2D trajectory visualization with launch info"""
if duration is None:
duration = motion.calculate_flight_time()
data = motion.trajectory_data(duration)
info = motion.get_launch_info()
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(15, 10))
# Create detailed title with launch parameters
detailed_title = (f"{title}\n"
f"Launch: {info['launch_speed']:.1f} m/s at {info['launch_angle']:.1f}° "
f"from {info['launch_height']:.1f}m height")
fig.suptitle(detailed_title, fontsize=14, fontweight='bold')
# Trajectory plot with key points marked
ax1.plot(data['x'], data['y'], 'b-', linewidth=2, label='Trajectory')
# Mark launch point
ax1.plot(motion.launch_x, motion.launch_height, 'go', markersize=8, label='Launch')
# Mark landing point
if len(data['x']) > 0:
ax1.plot(data['x'][-1], data['y'][-1], 'ro', markersize=8, label='Landing')
# Mark maximum height
max_height_idx = np.argmax(data['y'])
if len(data['x']) > 0:
ax1.plot(data['x'][max_height_idx], data['y'][max_height_idx], 'mo', markersize=6, label='Max Height')
ax1.set_xlabel('Horizontal Position (m)')
ax1.set_ylabel('Vertical Position (m)')
ax1.grid(True, alpha=0.3)
ax1.legend()
ax1.set_title(f'Trajectory (Range: {info["range"]:.1f}m, Max Height: {info["max_height"]:.1f}m)')
ax1.set_ylim(bottom=0) # Ensure ground is at y=0
# Speed vs Time
ax2.plot(data['time'], data['speed'], 'r-', linewidth=2)
ax2.set_xlabel('Time (s)')
ax2.set_ylabel('Speed (m/s)')
ax2.grid(True, alpha=0.3)
ax2.set_title(f'Speed vs Time (Flight Time: {info["flight_time"]:.1f}s)')
# Velocity components
ax3.plot(data['time'], data['vx'], 'g-', linewidth=2, label=f'Vx (const: {info["initial_velocity_x"]:.1f})')
ax3.plot(data['time'], data['vy'], 'm-', linewidth=2, label=f'Vy (initial: {info["initial_velocity_y"]:.1f})')
ax3.axhline(y=0, color='k', linestyle='--', alpha=0.5)
ax3.set_xlabel('Time (s)')
ax3.set_ylabel('Velocity (m/s)')
ax3.grid(True, alpha=0.3)
ax3.legend()
ax3.set_title('Velocity Components vs Time')
# Position components
ax4.plot(data['time'], data['x'], 'c-', linewidth=2, label='X position')
ax4.plot(data['time'], data['y'], 'orange', linewidth=2, label='Y position')
ax4.axhline(y=0, color='k', linestyle='--', alpha=0.5, label='Ground level')
ax4.set_xlabel('Time (s)')
ax4.set_ylabel('Position (m)')
ax4.grid(True, alpha=0.3)
ax4.legend()
ax4.set_title('Position Components vs Time')
plt.tight_layout()
return fig
# Example usage and test cases
if __name__ == "__main__":
# Example 1: Constant acceleration (car accelerating)
car_motion = Motion1D(initial_position=0, initial_velocity=5, acceleration=2)
# Example 2: Free fall
free_fall = Motion1D(initial_position=100, initial_velocity=0, acceleration=-9.81)
# Example 3: Classic projectile motion (45-degree launch from ground)
projectile_45 = Motion2D(
launch_speed=25, # m/s
launch_angle=45, # degrees
launch_height=0, # meters (ground level)
launch_x=0
)
# Example 4: Projectile launched from height at 30 degrees
projectile_height = Motion2D(
launch_speed=20, # m/s
launch_angle=30, # degrees
launch_height=10, # meters
launch_x=0
)
# Example 5: Horizontal launch (like dropping a ball while moving)
horizontal_launch = Motion2D(
launch_speed=15, # m/s
launch_angle=0, # degrees (horizontal)
launch_height=20, # meters
launch_x=0
)
# Create visualizations
visualizer = KinematicsVisualizer()
# Plot 1D examples
visualizer.plot_1d_motion(car_motion, 10, "Car Acceleration")
visualizer.plot_1d_motion(free_fall, 4.5, "Free Fall Motion")
# Plot 2D examples with different launch conditions
visualizer.plot_2d_trajectory(projectile_45, title="45° Launch from Ground")
visualizer.plot_2d_trajectory(projectile_height, title="30° Launch from 10m Height")
visualizer.plot_2d_trajectory(horizontal_launch, title="Horizontal Launch from 20m")
# Print launch information for educational purposes
print("\n=== LAUNCH ANALYSIS ===")
for name, motion in [("45° Ground Launch", projectile_45),
("30° Height Launch", projectile_height),
("Horizontal Launch", horizontal_launch)]:
info = motion.get_launch_info()
print(f"\n{name}:")
print(f" Launch Speed: {info['launch_speed']:.1f} m/s at {info['launch_angle']:.1f}°")
print(f" Initial Velocity: ({info['initial_velocity_x']:.1f}, {info['initial_velocity_y']:.1f}) m/s")
print(f" Flight Time: {info['flight_time']:.2f} s")
print(f" Range: {info['range']:.1f} m")
print(f" Max Height: {info['max_height']:.1f} m")
plt.show()