import streamlit as st
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from kinematics_visualizer import Motion1D, Motion2D, KinematicsVisualizer
# Configure Streamlit page
st.set_page_config(
page_title="Physics Tutorial: Kinematics",
page_icon="🚀",
layout="wide",
initial_sidebar_state="expanded"
)
# Custom CSS for better styling
st.markdown("""
""", unsafe_allow_html=True)
def main():
st.markdown('
🚀 Physics Tutorial: Kinematics
', unsafe_allow_html=True)
# Sidebar for navigation
st.sidebar.title("📚 Select Tutorial")
tutorial_type = st.sidebar.radio(
"Choose a physics concept:",
["1D Motion", "2D Projectile Motion"]
)
if tutorial_type == "1D Motion":
show_1d_motion()
elif tutorial_type == "2D Projectile Motion":
show_2d_motion()
def create_1d_motion_plot_plotly(motion: Motion1D, duration: float, title: str):
"""Create 1D motion plots using Plotly"""
# Generate time arrays
t, x, v, a = motion.time_arrays(duration, dt=0.01)
# Create subplots
fig = make_subplots(
rows=3, cols=1,
subplot_titles=('Position vs Time', 'Velocity vs Time', 'Acceleration vs Time'),
vertical_spacing=0.08,
shared_xaxes=True
)
# Position plot
fig.add_trace(
go.Scatter(x=t, y=x, mode='lines', name='Position',
line=dict(color='blue', width=3),
hovertemplate='Time: %{x:.1f} s
Position: %{y:.1f} m'),
row=1, col=1
)
# Velocity plot
fig.add_trace(
go.Scatter(x=t, y=v, mode='lines', name='Velocity',
line=dict(color='red', width=3),
hovertemplate='Time: %{x:.1f} s
Velocity: %{y:.1f} m/s'),
row=2, col=1
)
# Acceleration plot
fig.add_trace(
go.Scatter(x=t, y=a, mode='lines', name='Acceleration',
line=dict(color='green', width=3),
hovertemplate='Time: %{x:.1f} s
Acceleration: %{y:.1f} m/s²'),
row=3, col=1
)
# Update layout
fig.update_layout(
title=dict(text=title, font=dict(size=18)),
showlegend=False,
height=800,
template='plotly_white'
)
# Update y-axis labels
fig.update_yaxes(title_text="Position (m)", row=1, col=1)
fig.update_yaxes(title_text="Velocity (m/s)", row=2, col=1)
fig.update_yaxes(title_text="Acceleration (m/s²)", row=3, col=1)
fig.update_xaxes(title_text="Time (s)", row=3, col=1)
# Add grid
fig.update_xaxes(showgrid=True, gridwidth=1, gridcolor='rgba(128,128,128,0.3)')
fig.update_yaxes(showgrid=True, gridwidth=1, gridcolor='rgba(128,128,128,0.3)')
return fig
def create_2d_trajectory_plot_plotly(motion: Motion2D, title: str):
"""Create 2D trajectory plot using Plotly"""
flight_time = motion.calculate_flight_time()
data = motion.trajectory_data(flight_time)
fig = go.Figure()
# Add trajectory line
fig.add_trace(go.Scatter(
x=data['x'],
y=data['y'],
mode='lines',
name='Trajectory',
line=dict(color='blue', width=4),
hovertemplate='X: %{x:.1f} m
Y: %{y:.1f} m'
))
# Add launch point
fig.add_trace(go.Scatter(
x=[motion.launch_x],
y=[motion.launch_height],
mode='markers',
name='Launch Point',
marker=dict(color='green', size=12, symbol='circle'),
hovertemplate='Launch
X: %{x:.1f} m
Y: %{y:.1f} m'
))
# Add landing point
if len(data['x']) > 0:
fig.add_trace(go.Scatter(
x=[data['x'][-1]],
y=[data['y'][-1]],
mode='markers',
name='Landing Point',
marker=dict(color='red', size=12, symbol='square'),
hovertemplate='Landing
X: %{x:.1f} m
Y: %{y:.1f} m'
))
# Add maximum height point
if len(data['y']) > 0:
max_height_idx = np.argmax(data['y'])
if max_height_idx > 0:
fig.add_trace(go.Scatter(
x=[data['x'][max_height_idx]],
y=[data['y'][max_height_idx]],
mode='markers',
name='Max Height',
marker=dict(color='orange', size=10, symbol='triangle-up'),
hovertemplate='Max Height
X: %{x:.1f} m
Y: %{y:.1f} m'
))
# Update layout
fig.update_layout(
title=dict(text=title, font=dict(size=18)),
xaxis_title="Horizontal Position (m)",
yaxis_title="Vertical Position (m)",
showlegend=True,
hovermode='closest',
template='plotly_white',
height=600,
margin=dict(l=50, r=50, t=80, b=50)
)
# Set axis ranges
if len(data['x']) > 0 and len(data['y']) > 0:
fig.update_xaxes(range=[0, max(data['x']) * 1.1])
fig.update_yaxes(range=[0, max(data['y']) * 1.2])
else:
fig.update_xaxes(range=[0, 10])
fig.update_yaxes(range=[0, 10])
# Add grid
fig.update_xaxes(showgrid=True, gridwidth=1, gridcolor='rgba(128,128,128,0.3)')
fig.update_yaxes(showgrid=True, gridwidth=1, gridcolor='rgba(128,128,128,0.3)')
return fig
def show_1d_motion():
st.markdown('', unsafe_allow_html=True)
# Physics equations display
with st.expander("📖 Physics Equations (Click to expand)", expanded=False):
st.markdown("""
Kinematic Equations for Constant Acceleration:
- Position: x(t) = x₀ + v₀t + ½at²
- Velocity: v(t) = v₀ + at
- Acceleration: a(t) = constant
Where:
- x₀ = initial position (m)
- v₀ = initial velocity (m/s)
- a = acceleration (m/s²)
- t = time (s)
""", unsafe_allow_html=True)
# Create two columns for controls and results
col1, col2 = st.columns([1, 2])
with col1:
st.markdown('', unsafe_allow_html=True)
# Parameter sliders
initial_pos = st.slider(
"Initial Position (m)",
min_value=-50.0, max_value=50.0, value=0.0, step=1.0,
help="Starting position of the object"
)
initial_vel = st.slider(
"Initial Velocity (m/s)",
min_value=-30.0, max_value=30.0, value=5.0, step=1.0,
help="Starting velocity of the object"
)
acceleration = st.slider(
"Acceleration (m/s²)",
min_value=-15.0, max_value=15.0, value=2.0, step=0.5,
help="Constant acceleration (positive = speeding up in positive direction)"
)
duration = st.slider(
"Simulation Duration (s)",
min_value=1.0, max_value=20.0, value=10.0, step=0.5,
help="How long to run the simulation"
)
# Display current parameters
st.markdown('', unsafe_allow_html=True)
st.markdown("**Current Parameters:**")
st.write(f"• Initial Position: {initial_pos:.1f} m")
st.write(f"• Initial Velocity: {initial_vel:.1f} m/s")
st.write(f"• Acceleration: {acceleration:.1f} m/s²")
st.write(f"• Duration: {duration:.1f} s")
# Calculate final values
final_pos = initial_pos + initial_vel * duration + 0.5 * acceleration * duration**2
final_vel = initial_vel + acceleration * duration
st.markdown("**Final Values:**")
st.write(f"• Final Position: {final_pos:.1f} m")
st.write(f"• Final Velocity: {final_vel:.1f} m/s")
st.markdown('
', unsafe_allow_html=True)
with col2:
# Create and display motion
motion = Motion1D(
initial_position=initial_pos,
initial_velocity=initial_vel,
acceleration=acceleration
)
# Generate title based on motion type
if acceleration > 0:
motion_type = "Accelerating Motion"
elif acceleration < 0:
motion_type = "Decelerating Motion"
else:
motion_type = "Constant Velocity Motion"
# Create and display Plotly chart
fig = create_1d_motion_plot_plotly(motion, duration, motion_type)
st.plotly_chart(fig, use_container_width=True)
def show_2d_motion():
st.markdown('', unsafe_allow_html=True)
# Physics equations display (updated to include sphere physics)
with st.expander("📖 Physics Equations (Click to expand)", expanded=False):
st.markdown("""
Projectile Motion Equations:
Point Mass Model:
- No Air Resistance: Standard kinematic equations
- With Air Resistance: Linear drag model (drag ∝ velocity)
Sphere Model (more realistic):
- Mass: m = ρ × (4/3)π × r³
- Cross-sectional Area: A = π × r²
- Drag Force: Fdrag = ½ρairCdAv²
- Terminal Velocity: vt = √(2mg / ρairCdA)
Where:
- ρ = sphere density (kg/m³)
- r = sphere radius (m)
- Cd = aerodynamic drag coefficient
- ρair = air density (~1.225 kg/m³)
""", unsafe_allow_html=True)
# Create two columns
col1, col2 = st.columns([1, 2])
with col1:
st.markdown('', unsafe_allow_html=True)
# Projectile Model Toggle
st.markdown("### 🎯 Projectile Model")
is_sphere = st.toggle(
"Model as Sphere",
value=False,
help="Toggle between point mass and realistic sphere with physical dimensions"
)
# Sphere properties (only show when sphere model is enabled)
sphere_radius = 0.037 # Default baseball
sphere_density = 700
sphere_drag_coeff = 0.47
if is_sphere:
# Sphere presets
st.markdown("**Quick Sphere Presets:**")
presets = Motion2D.get_sphere_presets()
preset_cols = st.columns(3)
selected_preset = None
for i, (key, preset) in enumerate(presets.items()):
with preset_cols[i % 3]:
if st.button(preset['name'], key=f"sphere_{key}"):
selected_preset = preset
# Apply preset if selected
if selected_preset:
sphere_radius = selected_preset['radius']
sphere_density = selected_preset['density']
sphere_drag_coeff = selected_preset['drag_coeff']
st.markdown("**Custom Sphere Properties:**")
# Sphere radius slider
sphere_radius = st.slider(
"Sphere Radius (mm)",
min_value=10.0, max_value=150.0,
value=sphere_radius*1000, step=1.0,
help="Radius of the sphere in millimeters"
) / 1000 # Convert back to meters
# Sphere density slider
sphere_density = st.slider(
"Sphere Density (kg/m³)",
min_value=50, max_value=2000,
value=int(sphere_density), step=10,
help="Material density - affects mass and terminal velocity"
)
# Drag coefficient slider
sphere_drag_coeff = st.slider(
"Aerodynamic Drag Coefficient",
min_value=0.1, max_value=1.0,
value=sphere_drag_coeff, step=0.01,
help="0.24 (golf ball), 0.35 (baseball), 0.47 (smooth sphere), 0.51 (tennis ball)"
)
# Display calculated properties
temp_motion = Motion2D(
launch_speed=25, launch_angle=45,
is_sphere=True, sphere_radius=sphere_radius,
sphere_density=sphere_density, sphere_drag_coeff=sphere_drag_coeff
)
sphere_info = temp_motion.get_sphere_info()
st.info(f"""
**Calculated Properties:**
• Diameter: {sphere_info['diameter_mm']:.1f} mm
• Mass: {sphere_info['mass_g']:.1f} g
• Cross-section: {sphere_info['cross_section_cm2']:.1f} cm²
• Volume: {sphere_info['volume_cm3']:.1f} cm³
""")
# Air Resistance Toggle
st.markdown("### 🌬️ Air Resistance")
air_resistance_enabled = st.toggle(
"Enable Air Resistance",
value=False,
help="Toggle air resistance on/off to see the difference in trajectory"
)
# Show drag info based on model
if air_resistance_enabled and not is_sphere:
drag_coeff = st.slider(
"Point Mass Drag Coefficient",
min_value=0.01, max_value=0.5, value=0.1, step=0.01,
help="Simple linear drag coefficient for point mass model"
)
elif air_resistance_enabled and is_sphere:
st.info("🌬️ **Sphere Model**: Air resistance calculated from physical properties!")
drag_coeff = 0.1 # Not used for sphere model
else:
drag_coeff = 0.1
st.markdown("---")
# Initialize default values
default_speed = 25.0
default_angle = 45.0
default_height = 0.0
default_gravity = 9.81
# Handle preset button clicks
st.markdown("### 🎯 Launch Presets")
preset_buttons_col1, preset_buttons_col2 = st.columns(2)
with preset_buttons_col1:
if st.button("🏀 Basketball Shot"):
st.session_state.speed_preset = 15.0
st.session_state.angle_preset = 50.0
st.session_state.height_preset = 2.0
st.session_state.gravity_preset = 9.81
with preset_buttons_col2:
if st.button("🚀 Rocket Launch"):
st.session_state.speed_preset = 40.0
st.session_state.angle_preset = 75.0
st.session_state.height_preset = 0.0
st.session_state.gravity_preset = 9.81
# Use preset values if they exist, otherwise use defaults
speed_value = st.session_state.get('speed_preset', default_speed)
angle_value = st.session_state.get('angle_preset', default_angle)
height_value = st.session_state.get('height_preset', default_height)
gravity_value = st.session_state.get('gravity_preset', default_gravity)
st.markdown("### ⚙️ Physics Parameters")
# Parameter sliders
launch_speed = st.slider(
"Launch Speed (m/s)",
min_value=5.0, max_value=150.0, value=speed_value, step=1.0,
help="Initial speed of the projectile"
)
launch_angle = st.slider(
"Launch Angle (degrees)",
min_value=0.0, max_value=90.0, value=angle_value, step=5.0,
help="Angle above horizontal"
)
launch_height = st.slider(
"Launch Height (m)",
min_value=0.0, max_value=50.0, value=height_value, step=1.0,
help="Height above ground level"
)
gravity = st.slider(
"Gravity (m/s²)",
min_value=0.0, max_value=98.1, value=gravity_value, step=9.81,
help="Acceleration due to gravity; 1g per step. Earth = 1g = 9.81 m/s"
)
# Clear preset values when sliders are moved
if (launch_speed != speed_value or launch_angle != angle_value or
launch_height != height_value or gravity != gravity_value):
for key in ['speed_preset', 'angle_preset', 'height_preset', 'gravity_preset']:
if key in st.session_state:
del st.session_state[key]
# Create motion object with all parameters
motion = Motion2D(
launch_speed=launch_speed,
launch_angle=launch_angle,
launch_height=launch_height,
gravity=gravity,
air_resistance=air_resistance_enabled,
drag_coefficient=drag_coeff,
is_sphere=is_sphere,
sphere_radius=sphere_radius,
sphere_density=sphere_density,
sphere_drag_coeff=sphere_drag_coeff
)
info = motion.get_launch_info()
# Display comprehensive analysis
st.markdown('', unsafe_allow_html=True)
st.markdown("**Launch Analysis:**")
st.write(f"• Model: {'Sphere' if is_sphere else 'Point Mass'}")
st.write(f"• Launch Speed: {info['launch_speed']:.1f} m/s")
st.write(f"• Launch Angle: {info['launch_angle']:.1f}°")
st.write(f"• Initial Vₓ: {info['initial_velocity_x']:.1f} m/s")
st.write(f"• Initial Vᵧ: {info['initial_velocity_y']:.1f} m/s")
if is_sphere and 'mass_g' in info:
st.write(f"• Mass: {info['mass_g']:.1f} g")
st.write(f"• Diameter: {info['diameter_mm']:.1f} mm")
if air_resistance_enabled and 'terminal_velocity' in info:
st.write(f"• Terminal Velocity: {info['terminal_velocity']:.1f} m/s")
st.markdown("**Trajectory Results:**")
st.write(f"• Flight Time: {info['flight_time']:.2f} s")
st.write(f"• Range: {info['range']:.1f} m")
st.write(f"• Max Height: {info['max_height']:.1f} m")
# Physics insights
if is_sphere and air_resistance_enabled and 'mass_g' in info:
st.markdown("**Physics Insights:**")
if info['mass_g'] > 200:
st.write("🔹 Heavy object: Less affected by air resistance")
elif info['mass_g'] < 50:
st.write("🔹 Light object: Significantly affected by air resistance")
if info['diameter_mm'] > 100:
st.write("🔹 Large cross-section: More air resistance")
elif info['diameter_mm'] < 50:
st.write("🔹 Small cross-section: Less air resistance")
st.markdown('
', unsafe_allow_html=True)
# Reset button
if st.button("🔄 Reset to Defaults"):
for key in ['speed_preset', 'angle_preset', 'height_preset', 'gravity_preset']:
if key in st.session_state:
del st.session_state[key]
st.rerun()
with col2:
# Create and display trajectory with model info
model_info = f"({'Sphere' if is_sphere else 'Point Mass'})"
air_info = " (with Air Resistance)" if air_resistance_enabled else " (No Air Resistance)"
trajectory_title = f"Projectile Motion - {launch_angle:.0f}° Launch {model_info}{air_info}"
# Create and display main trajectory plot
fig = create_2d_trajectory_plot_plotly(motion, trajectory_title)
st.plotly_chart(fig, use_container_width=True)
# Show model comparison if using sphere
if is_sphere and 'mass_g' in info:
st.markdown("### 📊 Sphere vs Point Mass Comparison")
# Create comparison plot using Plotly
fig_comp = go.Figure()
# Plot sphere model
data_sphere = motion.trajectory_data(motion.calculate_flight_time())
fig_comp.add_trace(go.Scatter(
x=data_sphere['x'],
y=data_sphere['y'],
mode='lines',
name=f'Sphere Model ({info["mass_g"]:.0f}g)',
line=dict(color='red', width=3),
hovertemplate='Sphere
X: %{x:.1f} m
Y: %{y:.1f} m'
))
# Plot equivalent point mass
motion_point = Motion2D(
launch_speed=launch_speed, launch_angle=launch_angle,
launch_height=launch_height, gravity=gravity,
air_resistance=air_resistance_enabled, drag_coefficient=drag_coeff,
is_sphere=False
)
data_point = motion_point.trajectory_data(motion_point.calculate_flight_time())
fig_comp.add_trace(go.Scatter(
x=data_point['x'],
y=data_point['y'],
mode='lines',
name='Point Mass Model',
line=dict(color='blue', width=2, dash='dash'),
hovertemplate='Point Mass
X: %{x:.1f} m
Y: %{y:.1f} m'
))
# Update layout for comparison plot
fig_comp.update_layout(
title="Sphere Model vs Point Mass Model",
xaxis_title="Horizontal Position (m)",
yaxis_title="Vertical Position (m)",
showlegend=True,
template='plotly_white',
height=400
)
fig_comp.update_yaxes(range=[0, None])
fig_comp.update_xaxes(showgrid=True, gridwidth=1, gridcolor='rgba(128,128,128,0.3)')
fig_comp.update_yaxes(showgrid=True, gridwidth=1, gridcolor='rgba(128,128,128,0.3)')
st.plotly_chart(fig_comp, use_container_width=True)
if __name__ == "__main__":
main()