CW-DoublePendulum 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: CW-DoublePendulum
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: A Python package that uses NumPy and Matplotlib to simulate an interactive double pendulum.
|
5
|
+
Author-email: Chase Worsley <your_email@example.com>
|
6
|
+
License: MIT
|
7
|
+
Project-URL: Homepage, https://github.com/Worsleychase/InteractiveDoublePendulum
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
10
|
+
Classifier: Operating System :: OS Independent
|
11
|
+
Requires-Python: >=3.9
|
12
|
+
Description-Content-Type: text/markdown
|
13
|
+
License-File: LICENSE
|
14
|
+
Requires-Dist: numpy
|
15
|
+
Requires-Dist: matplotlib
|
16
|
+
Dynamic: license-file
|
17
|
+
|
18
|
+
# Interactive Double Pendulum
|
19
|
+
|
20
|
+
Final project for Mathematical & Computational Physics II
|
@@ -0,0 +1,6 @@
|
|
1
|
+
main.py,sha256=T9MMJ_Nzi0p2lX6XWCkdRCroP5-qPcpVfFxxKg_tLMg,12114
|
2
|
+
cw_doublependulum-0.1.0.dist-info/licenses/LICENSE,sha256=P_M8rIY4EFOQArRFqXCsxqdZyDp2kUCszxsaDapqA_4,1090
|
3
|
+
cw_doublependulum-0.1.0.dist-info/METADATA,sha256=KpvoXdLoey1OcCT7q_myQhBSzkVk4PnKXl5-jCYJWzA,719
|
4
|
+
cw_doublependulum-0.1.0.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
|
5
|
+
cw_doublependulum-0.1.0.dist-info/top_level.txt,sha256=ZAMgPdWghn6xTRBO6Kc3ML1y3ZrZLnjZlqbboKXc_AE,5
|
6
|
+
cw_doublependulum-0.1.0.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Worsleychase
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1 @@
|
|
1
|
+
main
|
main.py
ADDED
@@ -0,0 +1,362 @@
|
|
1
|
+
import numpy as np
|
2
|
+
import matplotlib.pyplot as plt
|
3
|
+
from matplotlib.widgets import Slider, CheckButtons, Button
|
4
|
+
from matplotlib.animation import FuncAnimation
|
5
|
+
from matplotlib.collections import LineCollection
|
6
|
+
import time
|
7
|
+
import csv
|
8
|
+
|
9
|
+
# Initial parameters (all can be modified with sliders)
|
10
|
+
l1 = 1.0
|
11
|
+
l2 = 1.0
|
12
|
+
m1 = 1.0
|
13
|
+
m2 = 1.0
|
14
|
+
g = 9.81
|
15
|
+
dt = 0.02
|
16
|
+
|
17
|
+
# Initial conditions: [theta1, omega1, theta2, omega2]
|
18
|
+
state = np.array([np.pi / 2, 0, np.pi / 2, 0], dtype=float)
|
19
|
+
|
20
|
+
# Control flags
|
21
|
+
animationRunning = True
|
22
|
+
collectingData = False
|
23
|
+
|
24
|
+
collectedData = []
|
25
|
+
|
26
|
+
def derivs(state, l1, l2, m1, m2):
|
27
|
+
theta1, omega1, theta2, omega2 = state
|
28
|
+
delta = theta2 - theta1
|
29
|
+
|
30
|
+
denom1 = (m1 + m2) * l1 - m2 * l1 * np.cos(delta)**2
|
31
|
+
denom2 = (l2 / l1) * denom1
|
32
|
+
|
33
|
+
domega1 = (m2 * l1 * omega1**2 * np.sin(delta) * np.cos(delta) +
|
34
|
+
m2 * g * np.sin(theta2) * np.cos(delta) +
|
35
|
+
m2 * l2 * omega2**2 * np.sin(delta) -
|
36
|
+
(m1 + m2) * g * np.sin(theta1)) / denom1
|
37
|
+
|
38
|
+
domega2 = (-m2 * l2 * omega2**2 * np.sin(delta) * np.cos(delta) +
|
39
|
+
(m1 + m2) * g * np.sin(theta1) * np.cos(delta) -
|
40
|
+
(m1 + m2) * l1 * omega1**2 * np.sin(delta) -
|
41
|
+
(m1 + m2) * g * np.sin(theta2)) / denom2
|
42
|
+
|
43
|
+
return np.array([omega1, domega1, omega2, domega2])
|
44
|
+
|
45
|
+
def step():
|
46
|
+
global state
|
47
|
+
k1 = derivs(state, l1, l2, m1, m2)
|
48
|
+
k2 = derivs(state + 0.5 * dt * k1, l1, l2, m1, m2)
|
49
|
+
k3 = derivs(state + 0.5 * dt * k2, l1, l2, m1, m2)
|
50
|
+
k4 = derivs(state + dt * k3, l1, l2, m1, m2)
|
51
|
+
state += (dt / 6) * (k1 + 2 * k2 + 2 * k3 + k4)
|
52
|
+
|
53
|
+
def getPos():
|
54
|
+
theta1, omega1, theta2, omega2 = state
|
55
|
+
x1 = l1 * np.sin(theta1)
|
56
|
+
y1 = -l1 * np.cos(theta1)
|
57
|
+
x2 = x1 + l2 * np.sin(theta2)
|
58
|
+
y2 = y1 - l2 * np.cos(theta2)
|
59
|
+
return x1, y1, x2, y2, theta1, omega1, theta2, omega2
|
60
|
+
|
61
|
+
def updatePlotLimits():
|
62
|
+
# Calculate the range based on the diagonal extent of both pendulums
|
63
|
+
# Add 20% margin
|
64
|
+
maxRange = (l1 + l2) * 1.2
|
65
|
+
ax.set_xlim(-maxRange, maxRange)
|
66
|
+
ax.set_ylim(-maxRange, maxRange)
|
67
|
+
fig.canvas.draw_idle()
|
68
|
+
|
69
|
+
# Function to convert mass to marker size
|
70
|
+
def massToSize(mass):
|
71
|
+
return 3 + 5 * (mass ** 0.7)
|
72
|
+
|
73
|
+
# Make large figure for buttons and whatnot
|
74
|
+
fig = plt.figure(figsize=(12, 8))
|
75
|
+
# Adjust the main plot area to make room for UI elements
|
76
|
+
plt.subplots_adjust(left=0.2, right=0.8, bottom=0.2)
|
77
|
+
ax = plt.subplot(111)
|
78
|
+
ax.set_aspect('equal')
|
79
|
+
updatePlotLimits()
|
80
|
+
ax.plot(0, 0, 'ko') # Center point/pivot
|
81
|
+
ax.set_title('Double Pendulum Simulation')
|
82
|
+
|
83
|
+
# Pendulum components
|
84
|
+
line, = ax.plot([], [], '-', lw=2, color='black')
|
85
|
+
mass1, = ax.plot([], [], 'bo', markersize=massToSize(m1))
|
86
|
+
mass2, = ax.plot([], [], 'ro', markersize=massToSize(m2))
|
87
|
+
|
88
|
+
# Trail lines
|
89
|
+
trailLength = 100
|
90
|
+
trailSegments1 = []
|
91
|
+
trailSegments2 = []
|
92
|
+
trailCmap1 = plt.get_cmap('winter') # these don't really have any gradient for some reason, idc anymore
|
93
|
+
trailCmap2 = plt.get_cmap('autumn')
|
94
|
+
trail1 = ax.add_collection(LineCollection([], linewidths=2, cmap=trailCmap1, alpha=0.6))
|
95
|
+
trail2 = ax.add_collection(LineCollection([], linewidths=2, cmap=trailCmap2, alpha=0.6))
|
96
|
+
|
97
|
+
# Trace flags
|
98
|
+
showTrace1 = True
|
99
|
+
showTrace2 = True
|
100
|
+
|
101
|
+
def init():
|
102
|
+
line.set_data([], [])
|
103
|
+
mass1.set_data([], [])
|
104
|
+
mass2.set_data([], [])
|
105
|
+
trail1.set_segments([])
|
106
|
+
trail2.set_segments([])
|
107
|
+
return line, mass1, mass2, trail1, trail2
|
108
|
+
|
109
|
+
def update(frame):
|
110
|
+
global collectedData
|
111
|
+
|
112
|
+
if animationRunning:
|
113
|
+
step()
|
114
|
+
|
115
|
+
x1, y1, x2, y2, theta1, omega1, theta2, omega2 = getPos()
|
116
|
+
|
117
|
+
# Collect data and setup keys
|
118
|
+
if collectingData:
|
119
|
+
timestamp = time.time()
|
120
|
+
collectedData.append({
|
121
|
+
'time': timestamp,
|
122
|
+
'theta1': theta1,
|
123
|
+
'omega1': omega1,
|
124
|
+
'theta2': theta2,
|
125
|
+
'omega2': omega2,
|
126
|
+
'x1': x1,
|
127
|
+
'y1': y1,
|
128
|
+
'x2': x2,
|
129
|
+
'y2': y2,
|
130
|
+
'l1': l1,
|
131
|
+
'l2': l2,
|
132
|
+
'm1': m1,
|
133
|
+
'm2': m2,
|
134
|
+
'g': g,
|
135
|
+
'dt': dt
|
136
|
+
})
|
137
|
+
|
138
|
+
# Update pendulum position
|
139
|
+
line.set_data([0, x1, x2], [0, y1, y2])
|
140
|
+
mass1.set_data([x1], [y1])
|
141
|
+
mass2.set_data([x2], [y2])
|
142
|
+
|
143
|
+
# Update trail if animation is running
|
144
|
+
if animationRunning:
|
145
|
+
if len(trailSegments1) > 0:
|
146
|
+
prevX1, prevY1 = trailSegments1[-1][-1]
|
147
|
+
prevX2, prevY2 = trailSegments2[-1][-1]
|
148
|
+
|
149
|
+
trailSegments1.append([[prevX1, prevY1], [x1, y1]])
|
150
|
+
trailSegments2.append([[prevX2, prevY2], [x2, y2]])
|
151
|
+
else:
|
152
|
+
trailSegments1.append([[x1, y1], [x1, y1]])
|
153
|
+
trailSegments2.append([[x2, y2], [x2, y2]])
|
154
|
+
|
155
|
+
# Limit trail length
|
156
|
+
if len(trailSegments1) > trailLength:
|
157
|
+
trailSegments1.pop(0)
|
158
|
+
trailSegments2.pop(0)
|
159
|
+
|
160
|
+
# Update trail if enabled
|
161
|
+
if showTrace1 and len(trailSegments1) > 0:
|
162
|
+
n = len(trailSegments1)
|
163
|
+
alphas = np.linspace(0.0, 1.0, n)
|
164
|
+
trail1.set_segments(trailSegments1)
|
165
|
+
trail1.set_array(alphas)
|
166
|
+
else:
|
167
|
+
trail1.set_segments([])
|
168
|
+
|
169
|
+
if showTrace2 and len(trailSegments2) > 0:
|
170
|
+
n = len(trailSegments2)
|
171
|
+
alphas = np.linspace(0.0, 1.0, n)
|
172
|
+
trail2.set_segments(trailSegments2)
|
173
|
+
trail2.set_array(alphas)
|
174
|
+
else:
|
175
|
+
trail2.set_segments([])
|
176
|
+
|
177
|
+
return line, mass1, mass2, trail1, trail2
|
178
|
+
|
179
|
+
ani = FuncAnimation(fig, update, init_func=init, blit=True, interval=20)
|
180
|
+
|
181
|
+
# -------------------- LAYOUT ARRANGEMENT --------------------
|
182
|
+
axcolor = 'lightgoldenrodyellow'
|
183
|
+
|
184
|
+
# ---- SLIDERS (RIGHT SIDE) ----
|
185
|
+
sliderWidth = 0.15
|
186
|
+
sliderHeight = 0.03
|
187
|
+
sliderLeft = 0.82
|
188
|
+
sliderGap = 0.05
|
189
|
+
|
190
|
+
# Create slider axes on the right side
|
191
|
+
axM1 = plt.axes([sliderLeft, 0.7, sliderWidth, sliderHeight], facecolor=axcolor)
|
192
|
+
axM2 = plt.axes([sliderLeft, 0.7 - sliderGap, sliderWidth, sliderHeight], facecolor=axcolor)
|
193
|
+
axL1 = plt.axes([sliderLeft, 0.7 - 2*sliderGap, sliderWidth, sliderHeight], facecolor=axcolor)
|
194
|
+
axL2 = plt.axes([sliderLeft, 0.7 - 3*sliderGap, sliderWidth, sliderHeight], facecolor=axcolor)
|
195
|
+
axG = plt.axes([sliderLeft, 0.7 - 4*sliderGap, sliderWidth, sliderHeight], facecolor=axcolor)
|
196
|
+
axDt = plt.axes([sliderLeft, 0.7 - 5*sliderGap, sliderWidth, sliderHeight], facecolor=axcolor)
|
197
|
+
|
198
|
+
# Create sliders
|
199
|
+
sliderM1 = Slider(axM1, 'Mass 1', 0.1, 5.0, valinit=m1)
|
200
|
+
sliderM2 = Slider(axM2, 'Mass 2', 0.1, 5.0, valinit=m2)
|
201
|
+
sliderL1 = Slider(axL1, 'Length 1', 0.1, 2.0, valinit=l1)
|
202
|
+
sliderL2 = Slider(axL2, 'Length 2', 0.1, 2.0, valinit=l2)
|
203
|
+
sliderG = Slider(axG, 'Gravity', 0.1, 20.0, valinit=g)
|
204
|
+
sliderDt = Slider(axDt, 'Time Step', 0.005, 0.05, valinit=dt)
|
205
|
+
|
206
|
+
# ---- BUTTONS (LEFT SIDE) ----
|
207
|
+
buttonWidth = 0.15
|
208
|
+
buttonHeight = 0.05
|
209
|
+
buttonLeft = 0.02
|
210
|
+
buttonGap = 0.06
|
211
|
+
|
212
|
+
# Add checkbox buttons for trails
|
213
|
+
rax = plt.axes([buttonLeft, 0.7, buttonWidth, 0.1], facecolor=axcolor)
|
214
|
+
check = CheckButtons(
|
215
|
+
rax, ['Trail 1', 'Trail 2'],
|
216
|
+
[showTrace1, showTrace2]
|
217
|
+
)
|
218
|
+
|
219
|
+
# Add buttons
|
220
|
+
resetAx = plt.axes([buttonLeft, 0.6, buttonWidth, buttonHeight], facecolor=axcolor)
|
221
|
+
pauseAx = plt.axes([buttonLeft, 0.6 - buttonGap, buttonWidth, buttonHeight], facecolor=axcolor)
|
222
|
+
collectAx = plt.axes([buttonLeft, 0.6 - 2*buttonGap, buttonWidth, buttonHeight], facecolor=axcolor)
|
223
|
+
|
224
|
+
resetButton = plt.Button(resetAx, 'Reset', color=axcolor)
|
225
|
+
pauseButton = plt.Button(pauseAx, 'Pause', color=axcolor)
|
226
|
+
collectButton = plt.Button(collectAx, 'Collect Data', color=axcolor)
|
227
|
+
|
228
|
+
# -------------------- CALLBACKS -------------------- be careful >:(
|
229
|
+
def updateSliders(val):
|
230
|
+
global m1, m2, l1, l2, g, dt
|
231
|
+
if not collectingData: # Only allow changes when not collecting data
|
232
|
+
m1 = sliderM1.val
|
233
|
+
m2 = sliderM2.val
|
234
|
+
l1 = sliderL1.val
|
235
|
+
l2 = sliderL2.val
|
236
|
+
g = sliderG.val
|
237
|
+
dt = sliderDt.val
|
238
|
+
|
239
|
+
# Update mass marker sizes based on new mass values
|
240
|
+
mass1.set_markersize(massToSize(m1))
|
241
|
+
mass2.set_markersize(massToSize(m2))
|
242
|
+
updatePlotLimits()
|
243
|
+
|
244
|
+
# Clear trails when parameters change
|
245
|
+
trailSegments1.clear()
|
246
|
+
trailSegments2.clear()
|
247
|
+
trail1.set_segments([])
|
248
|
+
trail2.set_segments([])
|
249
|
+
|
250
|
+
sliderM1.on_changed(updateSliders)
|
251
|
+
sliderM2.on_changed(updateSliders)
|
252
|
+
sliderL1.on_changed(updateSliders)
|
253
|
+
sliderL2.on_changed(updateSliders)
|
254
|
+
sliderG.on_changed(updateSliders)
|
255
|
+
sliderDt.on_changed(updateSliders)
|
256
|
+
|
257
|
+
def toggleTrace(label):
|
258
|
+
global showTrace1, showTrace2
|
259
|
+
if label == 'Trail 1':
|
260
|
+
showTrace1 = not showTrace1
|
261
|
+
if not showTrace1:
|
262
|
+
trail1.set_segments([])
|
263
|
+
elif label == 'Trail 2':
|
264
|
+
showTrace2 = not showTrace2
|
265
|
+
if not showTrace2:
|
266
|
+
trail2.set_segments([])
|
267
|
+
|
268
|
+
check.on_clicked(toggleTrace)
|
269
|
+
|
270
|
+
def reset(event):
|
271
|
+
global state, trailSegments1, trailSegments2
|
272
|
+
if not collectingData: # Only reset when not collecting data dummy
|
273
|
+
state = np.array([np.pi / 2, 0, np.pi / 2, 0], dtype=float)
|
274
|
+
trailSegments1.clear()
|
275
|
+
trailSegments2.clear()
|
276
|
+
trail1.set_segments([])
|
277
|
+
trail2.set_segments([])
|
278
|
+
|
279
|
+
resetButton.on_clicked(reset)
|
280
|
+
|
281
|
+
def toggleAnimation(event):
|
282
|
+
global animationRunning
|
283
|
+
animationRunning = not animationRunning
|
284
|
+
pauseButton.label.set_text('Play' if not animationRunning else 'Pause')
|
285
|
+
|
286
|
+
pauseButton.on_clicked(toggleAnimation)
|
287
|
+
|
288
|
+
def toggleDataCollection(event):
|
289
|
+
global collectingData, collectedData
|
290
|
+
|
291
|
+
collectingData = not collectingData
|
292
|
+
|
293
|
+
if collectingData:
|
294
|
+
collectButton.label.set_text('Stop Collecting')
|
295
|
+
collectedData = [] # Clear previous data
|
296
|
+
|
297
|
+
# Disable sliders to ensure good data | might change and allow different stuff, but i see weird behavior idk
|
298
|
+
sliderM1.active = False
|
299
|
+
sliderM2.active = False
|
300
|
+
sliderL1.active = False
|
301
|
+
sliderL2.active = False
|
302
|
+
sliderG.active = False
|
303
|
+
sliderDt.active = False
|
304
|
+
|
305
|
+
# Change slider colors to show they have been imprisoned, how sad it doesn't work
|
306
|
+
axM1.set_facecolor('red')
|
307
|
+
axM2.set_facecolor('red')
|
308
|
+
axL1.set_facecolor('red')
|
309
|
+
axL2.set_facecolor('red')
|
310
|
+
axG.set_facecolor('red')
|
311
|
+
axDt.set_facecolor('red')
|
312
|
+
|
313
|
+
else:
|
314
|
+
# Save data
|
315
|
+
collectButton.label.set_text('Collect Data')
|
316
|
+
|
317
|
+
# Reenable
|
318
|
+
sliderM1.active = True
|
319
|
+
sliderM2.active = True
|
320
|
+
sliderL1.active = True
|
321
|
+
sliderL2.active = True
|
322
|
+
sliderG.active = True
|
323
|
+
sliderDt.active = True
|
324
|
+
|
325
|
+
# Restore da colors
|
326
|
+
axM1.set_facecolor(axcolor)
|
327
|
+
axM2.set_facecolor(axcolor)
|
328
|
+
axL1.set_facecolor(axcolor)
|
329
|
+
axL2.set_facecolor(axcolor)
|
330
|
+
axG.set_facecolor(axcolor)
|
331
|
+
axDt.set_facecolor(axcolor)
|
332
|
+
|
333
|
+
# Save data to file
|
334
|
+
if collectedData:
|
335
|
+
filename = f"pendulum_data_{int(time.time())}.csv"
|
336
|
+
saveDataToFile(filename)
|
337
|
+
plt.figtext(0.5, 0.01, f"Data saved to {filename}", ha="center", bbox={"facecolor":"green", "alpha":0.5, "pad":5})
|
338
|
+
fig.canvas.draw_idle()
|
339
|
+
|
340
|
+
collectButton.on_clicked(toggleDataCollection)
|
341
|
+
|
342
|
+
def saveDataToFile(filename):
|
343
|
+
with open(filename, 'w', newline='') as csvfile:
|
344
|
+
fieldnames = ['time', 'theta1', 'omega1', 'theta2', 'omega2', 'x1', 'y1', 'x2', 'y2', 'l1', 'l2', 'm1', 'm2', 'g', 'dt']
|
345
|
+
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
346
|
+
|
347
|
+
writer.writeheader()
|
348
|
+
for dataPoint in collectedData:
|
349
|
+
writer.writerow(dataPoint)
|
350
|
+
|
351
|
+
# Super duper text box that tells you super duper controls idk what im saying anymore
|
352
|
+
infoText = """Controls:
|
353
|
+
- Use sliders to adjust parameters
|
354
|
+
- Toggle trails with checkboxes
|
355
|
+
- Pause/Play the simulation
|
356
|
+
- Reset to initial state
|
357
|
+
- Collect data for analysis
|
358
|
+
"""
|
359
|
+
# Make da box
|
360
|
+
fig.text(0.02, 0.2, infoText, fontsize=9, bbox=dict(facecolor=axcolor, alpha=0.5))
|
361
|
+
|
362
|
+
plt.show()
|