Module ball_drop_sim

Module to simulate the dropping of balls under gravity. The simulation can handle multiple balls, track their velocity, acceleration, position over time, and display these values graphically.

Functions

def main() ‑> None

Main function to run the Ball Drop Simulation.

The function first retrieves user input or a test case to initialize balls with specific properties. It then creates a BallDropSimulator instance and runs the simulation, with or without a graphical user interface (GUI) based on the provided arguments.

Classes

class BallDropSimulator (balls: list[Ball])

A class to simulate the drop of multiple balls under the influence of gravity and environmental factors. It supports both graphical simulation with VPython and command-line output.

Initialize the BallDropSimulator with a list of Ball objects.

Args

balls : list[Ball]
List of Ball objects to be simulated.
Expand source code
class BallDropSimulator:
    """
    A class to simulate the drop of multiple balls under the influence of gravity
    and environmental factors. It supports both graphical simulation with VPython
    and command-line output.
    """

    # Class constants for graphical and display parameters
    _LABEL_RANGE: Final[int] = 10
    _LABEL_STEP: Final[float] = 0.7
    _LABEL_Y_OVERHEAD: Final[int] = 3
    _GRAPH_WIDTH: Final[int] = 600
    _GRAPH_HEIGHT: Final[int] = 400
    _MAIN_CANVAS_SIZE: Final[tuple[int, int]] = (600, 600)

    # Flag to indicate whether the GUI should be disabled (True = no GUI)
    _no_gui: bool = False

    def __init__(self, balls: list[Ball]) -> None:
        """
        Initialize the BallDropSimulator with a list of Ball objects.

        Args:
            balls (list[Ball]): List of Ball objects to be simulated.
        """
        self._validate_balls(balls)
        self._balls: list[Ball] = balls

        # Variables to keep track of time
        self._total_time: float = 0
        self._time_label: Optional[vp.label] = None

        # Initialize graph variables (Will be None for now)
        self._velocity_graph: Optional[vp.graph] = None
        self._acceleration_graph: Optional[vp.graph] = None
        self._position_graph: Optional[vp.graph] = None

        if BallDropSimulator._no_gui is False:
            # Initialize canvases for graphical output
            self._canvas: vp.canvas = self._create_main_canvas()
            self._runtime_canvas: vp.canvas = self._create_runtime_canvas()
            self._parameter_canvas: vp.canvas = self._create_parameter_canvas()

            # Initialize label containers for graphical display
            self._height_labels: list[vp.label] = []
            self._speed_labels: list[vp.label] = []
            self._max_speed_labels: list[vp.label] = []
            self._terminal_velocity_labels: list[vp.label] = []
            self._first_impact_labels: list[vp.label] = []
            self._stop_time_labels: list[vp.label] = []

            # Initialize plot containers for graphical data
            self._velocity_plots: list[vp.gcurve] = []
            self._acceleration_plots: list[vp.gcurve] = []
            self._position_plots: list[vp.gcurve] = []

            # Setup simulation components
            self._setup_simulation()
        else:
            # If no GUI, print details of each ball to the console
            for i, ball in enumerate(balls):
                print(f'\nBall{i+1}:')
                print(f'  {ball.specs}')
                print(f'  {ball.env}')
                print(f'  Initial Height: {ball.init_height:.2f} m')

    @staticmethod
    def quit_simulation() -> None:
        """
        Stop the VPython server and quit the simulation.
        """
        if BallDropSimulator._no_gui is False:
            # We don't import vp_services until needed, because importing it will start
            # the server, if not started already.
            import vpython.no_notebook as vp_services  # type: ignore[import-untyped]
            vp_services.stop_server()

    @classmethod
    def disable_gui(cls, no_gui: bool) -> None:
        """
        Enables or disables the GUI based on user input.

        Args:
            no_gui (bool): If True, disable the GUI.
        """
        cls._no_gui = no_gui

    @property
    def balls(self) -> list[Ball]:
        """
        Retrieve the list of balls being simulated.

        Returns:
            list[Ball]: The list of balls in the simulation.
        """
        return self._balls

    @property
    def total_time(self) -> float:
        """
        Retrieve the total time of the simulation.

        Returns:
            float: The total time elapsed in the simulation.
        """
        return self._total_time

    @property
    def _max_height(self) -> float:
        """
        Calculate and return the maximum height among all the balls.

        Returns:
            float: The maximum height of any ball in the simulation.
        """
        return max(ball.position.y for ball in self._balls)

    @property
    def _grid_range(self) -> int:
        """
        Determine the grid range for the simulation based on the maximum height.

        Returns:
            int: The calculated grid range.
        """
        return int(self._max_height)

    def run(self) -> None:
        """
        Start and run the ball drop simulation. If GUI is enabled, the simulation
        will display real-time graphical information. Otherwise, the results will be printed.
        """
        FPS: Final[int] = 100  # Simulation frame rate (Frames Per Second)
        dt: float = 1/FPS  # Time step for the simulation
        t: float = 0  # Initial time

        # Initialize and update the labels with the starting time
        self._update_labels(t)

        # Run the simulation until all balls have stopped
        while True:
            vp.rate(FPS)
            t += dt

            # Update each ball's state
            for ball in self._balls:
                ball.update(dt, t)

            # Update the labels and graphical data
            self._update_labels(t)

            # Stop the simulation if all balls have stopped
            if all(ball.has_stopped for ball in self._balls):
                msg: str = f'Total Time: {t:.2f} s'
                if self._time_label:
                    self._time_label.text = msg
                else:
                    print(f'\n{msg}')
                break

        # Store the total simulation time
        self._total_time = t

        # If no GUI, print ball data to the console
        if BallDropSimulator._no_gui is True:
            for i, ball in enumerate(self._balls):
                print(f'\nBall{i+1}:')
                print(f'  Max speed: {ball.max_speed:.2f} m/s')
                print(f'  Terminal velocity reached?: {ball.terminal_vel_reached}. '
                      f'({ball.terminal_velocity:.2f} m/s)')
                print(f'  Time for 1st impact: {ball.first_impact_time:.2f} s')
                print(f'  Time to stop: {ball.stop_time:.2f} s')

    def _validate_balls(self, balls: list[Ball]) -> None:
        """
        Validate that the balls parameter is a list of Ball objects.

        Args:
            balls (list[Ball]): The list of Ball objects to validate.

        Raises:
            ValueError: If the input is not a list or contains invalid elements.
        """
        if not isinstance(balls, list):
            raise ValueError("'balls' parameter must be a list")
        if not all(isinstance(ball, Ball) for ball in balls):
            raise ValueError("All elements in 'balls' must be instances of Ball")
        if not balls:
            raise ValueError("'balls' list cannot be empty")

    def _create_main_canvas(self) -> vp.canvas:
        """Create and return the main simulation canvas."""
        return vp.canvas(
            title='Ball Drop Simulator',
            width=self._MAIN_CANVAS_SIZE[0],
            height=self._MAIN_CANVAS_SIZE[1],
            background=vp.color.white,
            align='left'
        )

    def _create_runtime_canvas(self) -> vp.canvas:
        """Create and return the runtime information canvas."""
        return vp.canvas(
            width=self._MAIN_CANVAS_SIZE[0],
            height=self._MAIN_CANVAS_SIZE[1],
            background=vp.color.white,
            align='left'
        )

    def _create_parameter_canvas(self) -> vp.canvas:
        """Create and return the parameter information canvas."""
        return vp.canvas(
            width=self._MAIN_CANVAS_SIZE[0],
            height=self._MAIN_CANVAS_SIZE[1],
            background=vp.color.white,
            align='left'
        )

    def _setup_simulation(self) -> None:
        """Set up all components of the simulation including the grid, ball visuals, labels, and graphs."""
        # Calculate and set x-positions for all balls
        x_positions = self._calculate_x_positions()
        for ball, x_pos in zip(self._balls, x_positions):
            ball.position.x = x_pos  # Set the x-position for each ball

        # Create various components needed for the simulation
        self._create_grid()
        self._create_ball_visuals()
        self._create_runtime_labels()
        self._create_parameters_labels()
        self._create_graphs()

    def _calculate_x_positions(self) -> list[float]:
        """Calculate evenly spaced x-positions for all balls based on the grid size.

        Returns:
            list[float]: The calculated x-positions for each ball.
        """
        grid_range: int = self._grid_range
        num_balls: int = len(self._balls)
        segment_width: float = 2 * grid_range / (num_balls + 1)

        # Calculate and return x-positions for each ball by placing them within segments along the x-axis
        return [-grid_range + segment_width * (i + 1) for i in range(num_balls)]

    def _create_grid(self) -> None:
        """Create the visual grid with both horizontal and vertical lines, and add height labels."""
        grid_range: int = self._grid_range
        step: int = int(grid_range / 10)

        self._canvas.select()  # Activate the canvas for drawing

        # Draw vertical lines across the grid
        for x in vp.arange(-grid_range, grid_range + step, step):
            vp.curve(
                pos=[vp.vector(x, 0, 0), vp.vector(x, grid_range, 0)],
                color=vp.color.gray(0.7)  # Light gray grid lines
            )

        # Draw horizontal lines and add height labels
        for y in vp.arange(0, grid_range + step, step):
            vp.curve(
                pos=[vp.vector(-grid_range, y, 0), vp.vector(grid_range, y, 0)],
                color=vp.color.gray(0.7)
            )

            # Add height labels on every second line
            if y % 2 == 0:
                vp.label(
                    pos=vp.vector(-grid_range - step, y, 0),
                    text=f'{y:.0f}',  # Display height value
                    box=False
                )

        # Add the time label beneath the grid
        self._time_label = vp.label(
            pos=vp.vector(-2 * step, -step, 0),
            align='left',
            box=False
        )

    def _create_ball_visuals(self) -> None:
        """Create visual representations of the balls in the simulation."""
        for ball in self._balls:
            ball.create_visual(self._canvas)  # Each ball creates its own visual

    def _create_runtime_labels(self) -> None:
        """Create labels that display runtime information such as height, speed, and impact times."""
        line_num: float = self._LABEL_RANGE + self._LABEL_Y_OVERHEAD  # Start label positioning

        self._runtime_canvas.select()  # Select the canvas for runtime labels

        for i, ball in enumerate(self._balls):
            # Header label for each ball
            vp.label(
                pos=vp.vector(-self._LABEL_RANGE, line_num * self._LABEL_STEP, 0),
                text=f'Ball {i + 1}:',
                align='left',
                box=False,
                color=ball.color  # Color matching the ball
            )
            line_num -= 1

            # Label for initial height
            vp.label(
                pos=vp.vector(-self._LABEL_RANGE, line_num * self._LABEL_STEP, 0),
                text=f'  Initial Height: {ball.position.y:.2f} m',
                align='left',
                box=False,
                color=ball.color
            )
            line_num -= 1

            # Create dynamic labels for the ball (e.g., height, speed)
            self._create_dynamic_labels(ball, line_num)
            line_num -= 6  # Space for labels of the next ball

        self._canvas.select()  # Return control to the main canvas

    def _create_dynamic_labels(self, ball: Ball, start_line: float) -> None:
        """Create dynamic labels for displaying real-time ball properties (e.g., height, speed).

        Args:
            ball (Ball): The ball for which to create the dynamic labels.
            start_line (float): The starting vertical position for the labels.
        """
        line_num = start_line
        label_positions = [
            ('height', '  Height: '),
            ('speed', '  Speed: '),
            ('max_speed', '  Max Speed: '),
            ('terminal_velocity', '  Terminal Velocity Reached? '),
            ('first_impact', '  Time for first impact: '),
            ('stop_time', '  Time to stop: ')
        ]

        # Create and store labels for each ball property
        for label_type, prefix in label_positions:
            label = vp.label(
                pos=vp.vector(-self._LABEL_RANGE, line_num * self._LABEL_STEP, 0),
                text=prefix,
                align='left',
                box=False,
                color=ball.color
            )
            # Store the label in the corresponding list (e.g., _height_labels, _speed_labels)
            getattr(self, f'_{label_type}_labels').append(label)
            line_num -= 1

    def _create_parameters_labels(self) -> None:
        """Create labels to display the ball specifications and environment parameters."""
        line_num: float = self._LABEL_RANGE + self._LABEL_Y_OVERHEAD  # Initial label position

        self._parameter_canvas.select()  # Select canvas for parameter labels

        for i, ball in enumerate(self._balls):
            # List of ball specification and environment labels to display
            params = [
                (f'Ball {i+1}:', 0),
                ('  Specifications:', 0),
                (f'    Mass: {ball.specs.mass:.4g} kg', 0),
                (f'    Radius: {ball.specs.radius:.2f} m', 0),
                (f'    Drag Coefficient: {ball.specs.drag_coefficient:.2f}', 0),
                ('  Environment:', 0),
                (f'    Gravity: {ball.env.gravity:.2f} m/s²', 0),
                (f'    Air Density: {ball.env.air_density:.2f} kg/m³', 0),
                (f'    CoR: {ball.env.cor:.2f}', 0)
            ]

            # Display each parameter label
            for text, extra_space in params:
                vp.label(
                    pos=vp.vector(-self._LABEL_RANGE, line_num * self._LABEL_STEP, 0),
                    text=text,
                    align='left',
                    box=False,
                    color=ball.color
                )
                line_num -= (1 + extra_space)  # Adjust line for spacing

        self._canvas.select()  # Return to the main canvas

    def _create_graphs(self) -> None:
        """Create graphs for velocity, acceleration, and position over time."""
        # Create graph for velocity vs time
        self._velocity_graph = vp.graph(
            title="Velocity vs Time",
            xtitle="Time (s)",
            ytitle="Velocity (m/s)",
            width=self._GRAPH_WIDTH,
            height=self._GRAPH_HEIGHT,
            align='left'
        )

        # Create graph for acceleration vs time
        self._acceleration_graph = vp.graph(
            title="Acceleration vs Time",
            xtitle="Time (s)",
            ytitle="Acceleration (m/s²)",
            width=self._GRAPH_WIDTH,
            height=self._GRAPH_HEIGHT,
            align='left'
        )

        # Create graph for position vs time
        self._position_graph = vp.graph(
            title="Position vs Time",
            xtitle="Time (s)",
            ytitle="Height (m)",
            width=self._GRAPH_WIDTH,
            height=self._GRAPH_HEIGHT,
            align='left'
        )

        # Create plot curves for each ball
        for i, ball in enumerate(self._balls):
            self._velocity_plots.append(
                vp.gcurve(graph=self._velocity_graph, color=ball.color, label=f'Ball {i+1}')
            )
            self._acceleration_plots.append(
                vp.gcurve(graph=self._acceleration_graph, color=ball.color, label=f'Ball {i+1}')
            )
            self._position_plots.append(
                vp.gcurve(graph=self._position_graph, color=ball.color, label=f'Ball {i+1}')
            )

    def _update_labels(self, t: float) -> None:
        """Update the dynamic labels and plots for each ball during the simulation.

        Args:
            t (float): The current time in the simulation.
        """
        if BallDropSimulator._no_gui is False:
            if self._time_label:
                self._time_label.text = f'Time: {t:.2f} s'

            for i, ball in enumerate(self._balls):
                # Update plots
                self._velocity_plots[i].plot(t, ball.velocity.y)
                self._acceleration_plots[i].plot(t, ball.acceleration.y)
                self._position_plots[i].plot(t, ball.position.y)

                # Update labels
                self._height_labels[i].text = f'  Height: {ball.position.y:.2f} m'
                self._speed_labels[i].text = f'  Speed: {abs(ball.velocity.y):.2f} m/s'
                self._max_speed_labels[i].text = f'  Max Speed: {ball.max_speed:.2f} m/s'

                # Update terminal velocity status
                self._terminal_velocity_labels[i].text = (
                    f'  Terminal velocity reached? '
                    f'{"Yes" if ball.terminal_vel_reached else "No"} '
                    f'({ball.terminal_velocity:.2f} m/s)'
                )

                # Update impact and stop times
                if ball.has_hit_ground and ball.first_impact_time is not None:
                    self._first_impact_labels[i].text = (
                        f'  Time for first impact: {ball.first_impact_time:.2f} s'
                    )

                # If ball has stopped, print the stop time
                if ball.has_stopped and ball.stop_time is not None:
                    self._stop_time_labels[i].text = (
                        f'  Time to stop: {ball.stop_time:.2f} s'
                    )

Static methods

def disable_gui(no_gui: bool) ‑> None

Enables or disables the GUI based on user input.

Args

no_gui : bool
If True, disable the GUI.
def quit_simulation() ‑> None

Stop the VPython server and quit the simulation.

Instance variables

prop balls : list[Ball]

Retrieve the list of balls being simulated.

Returns

list[Ball]
The list of balls in the simulation.
Expand source code
@property
def balls(self) -> list[Ball]:
    """
    Retrieve the list of balls being simulated.

    Returns:
        list[Ball]: The list of balls in the simulation.
    """
    return self._balls
prop total_time : float

Retrieve the total time of the simulation.

Returns

float
The total time elapsed in the simulation.
Expand source code
@property
def total_time(self) -> float:
    """
    Retrieve the total time of the simulation.

    Returns:
        float: The total time elapsed in the simulation.
    """
    return self._total_time

Methods

def run(self) ‑> None

Start and run the ball drop simulation. If GUI is enabled, the simulation will display real-time graphical information. Otherwise, the results will be printed.