import numpy as np
import pyqtgraph as pg
from pyqtgraph.Qt import QtCore, QtWidgets
# Install pyqtgraph and PyQt6 if not already installed:
# uv pip install pyqtgraph PyQt6
# Enable anti-aliasing for smoother graphics
pg.setConfigOptions(antialias=True)
# === Physics Constants ===
g = 9.82 # Gravity (m/s^2)
m = 1.0 # Mass of pendulum bob (kg)
# === Initial Parameters ===
INITIAL_THETA = 45 * np.pi / 180 # Starting angle (radians)
INITIAL_OMEGA = 0.0 # Starting angular velocity
INITIAL_L = 1.0 # Starting length (meters)
INITIAL_C = 0.5 # Starting damping coefficient
# === Simulation Variables ===
theta = INITIAL_THETA
omega = INITIAL_OMEGA
L = INITIAL_L
c = INITIAL_C
t = 0.0
dt = 1/60 # Time step (60 fps)
# === Energy Data Storage ===
BUFFER_SIZE = 1000
time_data = np.full(BUFFER_SIZE, np.nan)
ke_data = np.full(BUFFER_SIZE, np.nan)
pe_data = np.full(BUFFER_SIZE, np.nan)
data_index = 0
# === Create Application ===
app = pg.mkQApp()
# === Create Main Window ===
win = QtWidgets.QMainWindow()
win.setWindowTitle('PyQtGraph Pendulum Simulation (Simple Version)')
central_widget = QtWidgets.QWidget()
win.setCentralWidget(central_widget)
layout = QtWidgets.QVBoxLayout()
central_widget.setLayout(layout)
# === Create Pendulum Plot ===
plot_widget = pg.PlotWidget()
layout.addWidget(plot_widget)
plot_widget.setBackground('w')
plot_item = plot_widget.getPlotItem()
plot_item.setTitle("Pendulum Simulation")
plot_item.setLabel('bottom', 'X Position (m)')
plot_item.setLabel('left', 'Y Position (m)')
plot_item.setXRange(-2.2, 2.2)
plot_item.setYRange(-2.2, 2.2)
plot_item.setAspectLocked(True)
plot_item.showGrid(x=True, y=True)
# Create pendulum visual elements
pendulum_line = plot_item.plot(pen=pg.mkPen('k', width=5))
pendulum_bob = pg.ScatterPlotItem(size=20, pen=pg.mkPen(None), brush=pg.mkBrush(255, 0, 0))
plot_item.addItem(pendulum_bob)
# === Create Energy Plot ===
energy_plot_widget = pg.PlotWidget()
layout.addWidget(energy_plot_widget)
energy_plot_item = energy_plot_widget.getPlotItem()
energy_plot_item.setTitle("Kinetic and Potential Energy")
energy_plot_item.setLabel('bottom', 'Time (s)')
energy_plot_item.setLabel('left', 'Energy (J)')
energy_plot_item.addLegend()
ke_plot = energy_plot_item.plot(pen=pg.mkPen('b', width=2), name='Kinetic Energy')
pe_plot = energy_plot_item.plot(pen=pg.mkPen('r', width=2), name='Potential Energy')
# === Create Info Label ===
label_widget = pg.GraphicsLayoutWidget()
label_widget.setFixedHeight(120)
param_label = pg.LabelItem(justify='left')
label_widget.addItem(param_label, row=0, col=0)
layout.addWidget(label_widget)
# === Create Length Slider ===
length_container = QtWidgets.QWidget()
length_layout = QtWidgets.QHBoxLayout(length_container)
length_layout.setContentsMargins(0, 0, 0, 0)
length_label_prefix = QtWidgets.QLabel("Length (L):")
length_layout.addWidget(length_label_prefix)
length_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
length_slider.setRange(10, 200)
length_slider.setValue(int(INITIAL_L * 100))
length_slider.setTickPosition(QtWidgets.QSlider.TickPosition.TicksBelow)
length_slider.setTickInterval(25)
length_layout.addWidget(length_slider)
length_value_label = QtWidgets.QLabel(f"{INITIAL_L:.1f} m")
length_layout.addWidget(length_value_label)
layout.addWidget(length_container)
# === Create Damping Slider ===
damping_container = QtWidgets.QWidget()
damping_layout = QtWidgets.QHBoxLayout(damping_container)
damping_layout.setContentsMargins(0, 0, 0, 0)
damping_label_prefix = QtWidgets.QLabel("Damping (c):")
damping_layout.addWidget(damping_label_prefix)
damping_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
damping_slider.setRange(0, 100)
damping_slider.setValue(int(INITIAL_C * 100))
damping_slider.setTickPosition(QtWidgets.QSlider.TickPosition.TicksBelow)
damping_slider.setTickInterval(10)
damping_layout.addWidget(damping_slider)
damping_value_label = QtWidgets.QLabel(f"{INITIAL_C:.2f}")
damping_layout.addWidget(damping_value_label)
layout.addWidget(damping_container)
# === FUNCTIONS ===
def update_visuals():
"""Update the pendulum graphics and info label"""
global theta, omega, t, L, c
# Calculate pendulum bob position
P = L * np.array([np.cos(theta), -np.sin(theta)])
pendulum_line.setData([0, P[0]], [0, P[1]])
pendulum_bob.setData([P[0]], [P[1]])
# Update info label
label_text = f"""
<table>
<tr><td>Time: </td><td>{t:.1f} s</td></tr>
<tr><td>Angle: </td><td>{np.degrees(theta):.1f} °</td></tr>
<tr><td>Omega: </td><td>{np.degrees(omega):.1f} °/s</td></tr>
<tr><td>Length (L): </td><td>{L:.1f} m</td></tr>
<tr><td>Damping (c): </td><td>{c:.2f}</td></tr>
</table>
"""
param_label.setText(label_text)
plot_item.setTitle(f'Time: {t:.1f} s')
def reset_simulation():
"""Reset the simulation to initial conditions"""
global theta, omega, t, time_data, ke_data, pe_data, data_index
print(f"Reset! Time: {t:.1f} s, Angle: {np.degrees(theta):.1f} degrees")
theta = INITIAL_THETA
omega = INITIAL_OMEGA
t = 0.0
# Clear energy data
time_data.fill(np.nan)
ke_data.fill(np.nan)
pe_data.fill(np.nan)
data_index = 0
ke_plot.setData([], [])
pe_plot.setData([], [])
update_visuals()
def pause_simulation():
"""Pause or resume the simulation"""
if timer.isActive():
timer.stop()
print("Simulation paused.")
else:
timer.start(int(dt * 1000))
print("Simulation resumed.")
def update_length(slider_value):
"""Update pendulum length from slider"""
global L
L = slider_value / 100.0
length_value_label.setText(f"{L:.1f} m")
update_visuals()
def update_damping(slider_value):
"""Update damping coefficient from slider"""
global c
c = slider_value / 100.0
damping_value_label.setText(f"{c:.2f}")
update_visuals()
def handle_mouse_click(event):
"""Handle mouse clicks to set pendulum position"""
global theta, omega, t, time_data, ke_data, pe_data, data_index
if event.button() == QtCore.Qt.MouseButton.LeftButton:
mouse_point = plot_item.vb.mapSceneToView(event.scenePos())
x_mouse = mouse_point.x()
y_mouse = mouse_point.y()
if x_mouse == 0 and y_mouse == 0:
return
# Calculate new angle from mouse position
theta = np.arctan2(-y_mouse, x_mouse)
omega = 0.0
t = 0.0
# Clear energy data
time_data.fill(np.nan)
ke_data.fill(np.nan)
pe_data.fill(np.nan)
data_index = 0
ke_plot.setData([], [])
pe_plot.setData([], [])
update_visuals()
event.accept()
else:
event.ignore()
def handle_key_press(event):
"""Handle keyboard input"""
if event.key() == QtCore.Qt.Key.Key_Return:
reset_simulation()
elif event.key() == QtCore.Qt.Key.Key_Space:
pause_simulation()
def pendulum_step():
"""Update physics simulation one time step"""
global theta, omega, t, data_index
# Update time
t += dt
# Update angular velocity and angle
omega += (g / L) * np.cos(theta) * dt + c/(m*L**2) * (-omega) * dt
theta += omega * dt
# Calculate energies
v = L * omega
KE = 0.5 * m * v**2
PE = m * g * L * (1 - np.sin(theta))
# Store data in circular buffer
time_data[data_index] = t
ke_data[data_index] = KE
pe_data[data_index] = PE
data_index = (data_index + 1) % BUFFER_SIZE
# Update energy plots
if np.isnan(time_data[0]): # Buffer not full yet
plot_time = time_data[:data_index]
plot_ke = ke_data[:data_index]
plot_pe = pe_data[:data_index]
else: # Buffer is full, handle wrap-around
plot_time = np.concatenate((time_data[data_index:], time_data[:data_index]))
plot_ke = np.concatenate((ke_data[data_index:], ke_data[:data_index]))
plot_pe = np.concatenate((pe_data[data_index:], pe_data[:data_index]))
ke_plot.setData(plot_time, plot_ke)
pe_plot.setData(plot_time, plot_pe)
update_visuals()
# === Connect Events ===
length_slider.valueChanged.connect(update_length)
damping_slider.valueChanged.connect(update_damping)
plot_item.scene().sigMouseClicked.connect(handle_mouse_click)
win.keyPressEvent = handle_key_press
# === Create Timer for Animation ===
timer = QtCore.QTimer()
timer.timeout.connect(pendulum_step)
timer.start(int(dt * 1000))
# === Initialize and Show ===
reset_simulation()
win.show()
# === Run Application ===
if __name__ == '__main__':
print("Controls:")
print(" - Click to set pendulum position")
print(" - SPACE to pause/resume")
print(" - ENTER to reset")
print(" - Sliders to adjust length and damping")
app.exec()