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('
📏 One-Dimensional Motion
', unsafe_allow_html=True) # Physics equations display with st.expander("📖 Physics Equations (Click to expand)", expanded=False): st.markdown("""

Kinematic Equations for Constant Acceleration:

Where:

""", unsafe_allow_html=True) # Create two columns for controls and results col1, col2 = st.columns([1, 2]) with col1: st.markdown('
🎛️ Control Parameters
', 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('
🎯 Two-Dimensional Projectile Motion
', 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:

Sphere Model (more realistic):

Where:

""", unsafe_allow_html=True) # Create two columns col1, col2 = st.columns([1, 2]) with col1: st.markdown('
🎛️ Launch Parameters
', 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()