|
| 1 | +""" |
| 2 | +In physics and astronomy, a gravitational N-body simulation is a simulation of a |
| 3 | +dynamical system of particles under the influence of gravity. The system |
| 4 | +consists of a number of bodies, each of which exerts a gravitational force on all |
| 5 | +other bodies. These forces are calculated using Newton's law of universal |
| 6 | +gravitation. The Euler method is used at each time-step to calculate the change in |
| 7 | +velocity and position brought about by these forces. Softening is used to prevent |
| 8 | +numerical divergences when a particle comes too close to another (and the force |
| 9 | +goes to infinity). |
| 10 | +(Description adapted from https://en.wikipedia.org/wiki/N-body_simulation ) |
| 11 | +(See also http://www.shodor.org/refdesk/Resources/Algorithms/EulersMethod/ ) |
| 12 | +""" |
| 13 | + |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import random |
| 18 | + |
| 19 | +from matplotlib import animation |
| 20 | +from matplotlib import pyplot as plt |
| 21 | + |
| 22 | + |
| 23 | +class Body: |
| 24 | + def __init__( |
| 25 | + self, |
| 26 | + position_x: float, |
| 27 | + position_y: float, |
| 28 | + velocity_x: float, |
| 29 | + velocity_y: float, |
| 30 | + mass: float = 1.0, |
| 31 | + size: float = 1.0, |
| 32 | + color: str = "blue", |
| 33 | + ) -> None: |
| 34 | + """ |
| 35 | + The parameters "size" & "color" are not relevant for the simulation itself, |
| 36 | + they are only used for plotting. |
| 37 | + """ |
| 38 | + self.position_x = position_x |
| 39 | + self.position_y = position_y |
| 40 | + self.velocity_x = velocity_x |
| 41 | + self.velocity_y = velocity_y |
| 42 | + self.mass = mass |
| 43 | + self.size = size |
| 44 | + self.color = color |
| 45 | + |
| 46 | + @property |
| 47 | + def position(self) -> tuple[float, float]: |
| 48 | + return self.position_x, self.position_y |
| 49 | + |
| 50 | + @property |
| 51 | + def velocity(self) -> tuple[float, float]: |
| 52 | + return self.velocity_x, self.velocity_y |
| 53 | + |
| 54 | + def update_velocity( |
| 55 | + self, force_x: float, force_y: float, delta_time: float |
| 56 | + ) -> None: |
| 57 | + """ |
| 58 | + Euler algorithm for velocity |
| 59 | +
|
| 60 | + >>> body_1 = Body(0.,0.,0.,0.) |
| 61 | + >>> body_1.update_velocity(1.,0.,1.) |
| 62 | + >>> body_1.velocity |
| 63 | + (1.0, 0.0) |
| 64 | +
|
| 65 | + >>> body_1.update_velocity(1.,0.,1.) |
| 66 | + >>> body_1.velocity |
| 67 | + (2.0, 0.0) |
| 68 | +
|
| 69 | + >>> body_2 = Body(0.,0.,5.,0.) |
| 70 | + >>> body_2.update_velocity(0.,-10.,10.) |
| 71 | + >>> body_2.velocity |
| 72 | + (5.0, -100.0) |
| 73 | +
|
| 74 | + >>> body_2.update_velocity(0.,-10.,10.) |
| 75 | + >>> body_2.velocity |
| 76 | + (5.0, -200.0) |
| 77 | + """ |
| 78 | + self.velocity_x += force_x * delta_time |
| 79 | + self.velocity_y += force_y * delta_time |
| 80 | + |
| 81 | + def update_position(self, delta_time: float) -> None: |
| 82 | + """ |
| 83 | + Euler algorithm for position |
| 84 | +
|
| 85 | + >>> body_1 = Body(0.,0.,1.,0.) |
| 86 | + >>> body_1.update_position(1.) |
| 87 | + >>> body_1.position |
| 88 | + (1.0, 0.0) |
| 89 | +
|
| 90 | + >>> body_1.update_position(1.) |
| 91 | + >>> body_1.position |
| 92 | + (2.0, 0.0) |
| 93 | +
|
| 94 | + >>> body_2 = Body(10.,10.,0.,-2.) |
| 95 | + >>> body_2.update_position(1.) |
| 96 | + >>> body_2.position |
| 97 | + (10.0, 8.0) |
| 98 | +
|
| 99 | + >>> body_2.update_position(1.) |
| 100 | + >>> body_2.position |
| 101 | + (10.0, 6.0) |
| 102 | + """ |
| 103 | + self.position_x += self.velocity_x * delta_time |
| 104 | + self.position_y += self.velocity_y * delta_time |
| 105 | + |
| 106 | + |
| 107 | +class BodySystem: |
| 108 | + """ |
| 109 | + This class is used to hold the bodies, the gravitation constant, the time |
| 110 | + factor and the softening factor. The time factor is used to control the speed |
| 111 | + of the simulation. The softening factor is used for softening, a numerical |
| 112 | + trick for N-body simulations to prevent numerical divergences when two bodies |
| 113 | + get too close to each other. |
| 114 | + """ |
| 115 | + |
| 116 | + def __init__( |
| 117 | + self, |
| 118 | + bodies: list[Body], |
| 119 | + gravitation_constant: float = 1.0, |
| 120 | + time_factor: float = 1.0, |
| 121 | + softening_factor: float = 0.0, |
| 122 | + ) -> None: |
| 123 | + self.bodies = bodies |
| 124 | + self.gravitation_constant = gravitation_constant |
| 125 | + self.time_factor = time_factor |
| 126 | + self.softening_factor = softening_factor |
| 127 | + |
| 128 | + def __len__(self) -> int: |
| 129 | + return len(self.bodies) |
| 130 | + |
| 131 | + def update_system(self, delta_time: float) -> None: |
| 132 | + """ |
| 133 | + For each body, loop through all other bodies to calculate the total |
| 134 | + force they exert on it. Use that force to update the body's velocity. |
| 135 | +
|
| 136 | + >>> body_system_1 = BodySystem([Body(0,0,0,0), Body(10,0,0,0)]) |
| 137 | + >>> len(body_system_1) |
| 138 | + 2 |
| 139 | + >>> body_system_1.update_system(1) |
| 140 | + >>> body_system_1.bodies[0].position |
| 141 | + (0.01, 0.0) |
| 142 | + >>> body_system_1.bodies[0].velocity |
| 143 | + (0.01, 0.0) |
| 144 | +
|
| 145 | + >>> body_system_2 = BodySystem([Body(-10,0,0,0), Body(10,0,0,0, mass=4)], 1, 10) |
| 146 | + >>> body_system_2.update_system(1) |
| 147 | + >>> body_system_2.bodies[0].position |
| 148 | + (-9.0, 0.0) |
| 149 | + >>> body_system_2.bodies[0].velocity |
| 150 | + (0.1, 0.0) |
| 151 | + """ |
| 152 | + for body1 in self.bodies: |
| 153 | + force_x = 0.0 |
| 154 | + force_y = 0.0 |
| 155 | + for body2 in self.bodies: |
| 156 | + if body1 != body2: |
| 157 | + dif_x = body2.position_x - body1.position_x |
| 158 | + dif_y = body2.position_y - body1.position_y |
| 159 | + |
| 160 | + # Calculation of the distance using Pythagoras's theorem |
| 161 | + # Extra factor due to the softening technique |
| 162 | + distance = (dif_x ** 2 + dif_y ** 2 + self.softening_factor) ** ( |
| 163 | + 1 / 2 |
| 164 | + ) |
| 165 | + |
| 166 | + # Newton's law of universal gravitation. |
| 167 | + force_x += ( |
| 168 | + self.gravitation_constant * body2.mass * dif_x / distance ** 3 |
| 169 | + ) |
| 170 | + force_y += ( |
| 171 | + self.gravitation_constant * body2.mass * dif_y / distance ** 3 |
| 172 | + ) |
| 173 | + |
| 174 | + # Update the body's velocity once all the force components have been added |
| 175 | + body1.update_velocity(force_x, force_y, delta_time * self.time_factor) |
| 176 | + |
| 177 | + # Update the positions only after all the velocities have been updated |
| 178 | + for body in self.bodies: |
| 179 | + body.update_position(delta_time * self.time_factor) |
| 180 | + |
| 181 | + |
| 182 | +def update_step( |
| 183 | + body_system: BodySystem, delta_time: float, patches: list[plt.Circle] |
| 184 | +) -> None: |
| 185 | + """ |
| 186 | + Updates the body-system and applies the change to the patch-list used for plotting |
| 187 | +
|
| 188 | + >>> body_system_1 = BodySystem([Body(0,0,0,0), Body(10,0,0,0)]) |
| 189 | + >>> patches_1 = [plt.Circle((body.position_x, body.position_y), body.size, |
| 190 | + ... fc=body.color)for body in body_system_1.bodies] #doctest: +ELLIPSIS |
| 191 | + >>> update_step(body_system_1, 1, patches_1) |
| 192 | + >>> patches_1[0].center |
| 193 | + (0.01, 0.0) |
| 194 | +
|
| 195 | + >>> body_system_2 = BodySystem([Body(-10,0,0,0), Body(10,0,0,0, mass=4)], 1, 10) |
| 196 | + >>> patches_2 = [plt.Circle((body.position_x, body.position_y), body.size, |
| 197 | + ... fc=body.color)for body in body_system_2.bodies] #doctest: +ELLIPSIS |
| 198 | + >>> update_step(body_system_2, 1, patches_2) |
| 199 | + >>> patches_2[0].center |
| 200 | + (-9.0, 0.0) |
| 201 | + """ |
| 202 | + # Update the positions of the bodies |
| 203 | + body_system.update_system(delta_time) |
| 204 | + |
| 205 | + # Update the positions of the patches |
| 206 | + for patch, body in zip(patches, body_system.bodies): |
| 207 | + patch.center = (body.position_x, body.position_y) |
| 208 | + |
| 209 | + |
| 210 | +def plot( |
| 211 | + title: str, |
| 212 | + body_system: BodySystem, |
| 213 | + x_start: float = -1, |
| 214 | + x_end: float = 1, |
| 215 | + y_start: float = -1, |
| 216 | + y_end: float = 1, |
| 217 | +) -> None: |
| 218 | + """ |
| 219 | + Utility function to plot how the given body-system evolves over time. |
| 220 | + No doctest provided since this function does not have a return value. |
| 221 | + """ |
| 222 | + |
| 223 | + INTERVAL = 20 # Frame rate of the animation |
| 224 | + DELTA_TIME = INTERVAL / 1000 # Time between time steps in seconds |
| 225 | + |
| 226 | + fig = plt.figure() |
| 227 | + fig.canvas.set_window_title(title) |
| 228 | + ax = plt.axes( |
| 229 | + xlim=(x_start, x_end), ylim=(y_start, y_end) |
| 230 | + ) # Set section to be plotted |
| 231 | + plt.gca().set_aspect("equal") # Fix aspect ratio |
| 232 | + |
| 233 | + # Each body is drawn as a patch by the plt-function |
| 234 | + patches = [ |
| 235 | + plt.Circle((body.position_x, body.position_y), body.size, fc=body.color) |
| 236 | + for body in body_system.bodies |
| 237 | + ] |
| 238 | + |
| 239 | + for patch in patches: |
| 240 | + ax.add_patch(patch) |
| 241 | + |
| 242 | + # Function called at each step of the animation |
| 243 | + def update(frame: int) -> list[plt.Circle]: |
| 244 | + update_step(body_system, DELTA_TIME, patches) |
| 245 | + return patches |
| 246 | + |
| 247 | + anim = animation.FuncAnimation( # noqa: F841 |
| 248 | + fig, update, interval=INTERVAL, blit=True |
| 249 | + ) |
| 250 | + |
| 251 | + plt.show() |
| 252 | + |
| 253 | + |
| 254 | +def example_1() -> BodySystem: |
| 255 | + """ |
| 256 | + Example 1: figure-8 solution to the 3-body-problem |
| 257 | + This example can be seen as a test of the implementation: given the right |
| 258 | + initial conditions, the bodies should move in a figure-8. |
| 259 | + (initial conditions taken from http://www.artcompsci.org/vol_1/v1_web/node56.html) |
| 260 | + >>> body_system = example_1() |
| 261 | + >>> len(body_system) |
| 262 | + 3 |
| 263 | + """ |
| 264 | + |
| 265 | + position_x = 0.9700436 |
| 266 | + position_y = -0.24308753 |
| 267 | + velocity_x = 0.466203685 |
| 268 | + velocity_y = 0.43236573 |
| 269 | + |
| 270 | + bodies1 = [ |
| 271 | + Body(position_x, position_y, velocity_x, velocity_y, size=0.2, color="red"), |
| 272 | + Body(-position_x, -position_y, velocity_x, velocity_y, size=0.2, color="green"), |
| 273 | + Body(0, 0, -2 * velocity_x, -2 * velocity_y, size=0.2, color="blue"), |
| 274 | + ] |
| 275 | + return BodySystem(bodies1, time_factor=3) |
| 276 | + |
| 277 | + |
| 278 | +def example_2() -> BodySystem: |
| 279 | + """ |
| 280 | + Example 2: Moon's orbit around the earth |
| 281 | + This example can be seen as a test of the implementation: given the right |
| 282 | + initial conditions, the moon should orbit around the earth as it actually does. |
| 283 | + (mass, velocity and distance taken from https://en.wikipedia.org/wiki/Earth |
| 284 | + and https://en.wikipedia.org/wiki/Moon) |
| 285 | + No doctest provided since this function does not have a return value. |
| 286 | + """ |
| 287 | + |
| 288 | + moon_mass = 7.3476e22 |
| 289 | + earth_mass = 5.972e24 |
| 290 | + velocity_dif = 1022 |
| 291 | + earth_moon_distance = 384399000 |
| 292 | + gravitation_constant = 6.674e-11 |
| 293 | + |
| 294 | + # Calculation of the respective velocities so that total impulse is zero, |
| 295 | + # i.e. the two bodies together don't move |
| 296 | + moon_velocity = earth_mass * velocity_dif / (earth_mass + moon_mass) |
| 297 | + earth_velocity = moon_velocity - velocity_dif |
| 298 | + |
| 299 | + moon = Body(-earth_moon_distance, 0, 0, moon_velocity, moon_mass, 10000000, "grey") |
| 300 | + earth = Body(0, 0, 0, earth_velocity, earth_mass, 50000000, "blue") |
| 301 | + return BodySystem([earth, moon], gravitation_constant, time_factor=1000000) |
| 302 | + |
| 303 | + |
| 304 | +def example_3() -> BodySystem: |
| 305 | + """ |
| 306 | + Example 3: Random system with many bodies. |
| 307 | + No doctest provided since this function does not have a return value. |
| 308 | + """ |
| 309 | + |
| 310 | + bodies = [] |
| 311 | + for i in range(10): |
| 312 | + velocity_x = random.uniform(-0.5, 0.5) |
| 313 | + velocity_y = random.uniform(-0.5, 0.5) |
| 314 | + |
| 315 | + # Bodies are created pairwise with opposite velocities so that the |
| 316 | + # total impulse remains zero |
| 317 | + bodies.append( |
| 318 | + Body( |
| 319 | + random.uniform(-0.5, 0.5), |
| 320 | + random.uniform(-0.5, 0.5), |
| 321 | + velocity_x, |
| 322 | + velocity_y, |
| 323 | + size=0.05, |
| 324 | + ) |
| 325 | + ) |
| 326 | + bodies.append( |
| 327 | + Body( |
| 328 | + random.uniform(-0.5, 0.5), |
| 329 | + random.uniform(-0.5, 0.5), |
| 330 | + -velocity_x, |
| 331 | + -velocity_y, |
| 332 | + size=0.05, |
| 333 | + ) |
| 334 | + ) |
| 335 | + return BodySystem(bodies, 0.01, 10, 0.1) |
| 336 | + |
| 337 | + |
| 338 | +if __name__ == "__main__": |
| 339 | + plot("Figure-8 solution to the 3-body-problem", example_1(), -2, 2, -2, 2) |
| 340 | + plot( |
| 341 | + "Moon's orbit around the earth", |
| 342 | + example_2(), |
| 343 | + -430000000, |
| 344 | + 430000000, |
| 345 | + -430000000, |
| 346 | + 430000000, |
| 347 | + ) |
| 348 | + plot("Random system with many bodies", example_3(), -1.5, 1.5, -1.5, 1.5) |
0 commit comments