Forces as functions of the state, Newton’s second law as a stepper that the rest of the course reuses, and a fall with linear drag checked against its exact solution.
With scripts, this page keeps a record in your browser of whether you completed the lesson. Nothing is stored without them.
With scripts, this place suggests what to do next, with the reason, from the progress saved in your browser. Without them, follow the learning path.
In the first lesson the acceleration was handed to the program as a function of time. Mechanics rarely works that way. Newton’s second law says that the acceleration follows from the forces, and the forces depend on where the particle is and how it moves: a spring pulls harder the further it is stretched, air drag pushes harder the faster the particle goes. The acceleration is a function of the state, and the program has to compute it from the state at every step.
This lesson builds that program in three parts that the rest of the course keeps: force models as plain functions, Newton’s second law as the function that turns their sum into an acceleration, and a stepper with a fixed signature, so that a later lesson can swap explicit Euler for a better method without touching anything else. The test problem is a particle falling against linear drag, one of the few problems with forces that has an exact solution to compare with. The comparison also shows a new way for a simulation to fail: not slowly, through accumulated error, but suddenly, when the time step is too large for the method.
Assumptions
The model and the program rest on these assumptions. Each one names what goes wrong when it does not hold.
A point particle of constant mass. The state is one position vector and one velocity vector. A body that rotates needs an orientation and an angular velocity as well; a rocket or a growing raindrop changes its mass, and is then the wrong form of the law.
The force is a known function of time, position, and velocity. A force that depends on the history of the motion (friction with hysteresis) or on the acceleration itself (the added mass of a body in a fluid, radiation reaction) does not fit the first-order system below.
Forces add. The net force is the vector sum of the forces of the separate models. This is how forces behave in Newtonian mechanics; it fails only when one model already includes the effect of another, which would then be counted twice.
Linear drag, . The fluid pushes back in proportion to the velocity. This holds in creeping flow, when the Reynolds number (fluid density , speed , body size , viscosity ) is well below one, so that viscous forces dominate inertial ones: the drag coefficient of a sphere is then , which makes the force proportional to (Stokes’ law, for a sphere of radius ). Examples are a fog droplet in air, a bead sinking in oil, a dust grain. A ball in air is far outside that range; its drag grows with the square of the speed, and the linear law would predict a terminal speed that is wrong by a large factor. The next lesson has the quadratic law. The numbers in the examples below are chosen to make the tables readable, not taken from any real object.
Uniform gravity in a fluid at rest. is constant, and the fluid does not move, so the drag acts on the velocity relative to the ground. In a wind the drag acts on the velocity relative to the air, and the model must subtract the wind first.
The time step is small compared with the time constant . The derivation below shows exactly what happens otherwise: for the computed velocity overshoots its limit and oscillates, and for it grows without bound. No smaller error term is hidden here; the method simply fails.
Round-off is negligible. As in the first lesson: the steps used here are far above the resolution of double-precision numbers, so that the errors in the tables are the errors of the method.
From forces to an update rule
Newton’s second law for a particle of mass under a net force is
Position and velocity are now vectors, with one component per dimension of the motion: one on a line, two in a plane. The two equations together are a first-order system: the rate of change of the pair is a function of and of the pair itself. Every numerical method for ordinary differential equations takes this form as its input, which is why the program is organised around it. The second-order equation “acceleration equals force over mass” never appears on its own; it is split into “position changes at the velocity” and “velocity changes at the force over mass”.
A force model is a function that returns a vector. Two are enough for this lesson. The weight is constant, , with the gravitational acceleration as a vector, so that the program and not the reader decides which way is down. Linear drag opposes the velocity, , with the drag constant in kg/s. Forces add, so Newton’s law for a particle under both is the function
The update rule is explicit Euler, exactly as in the first lesson, except that the acceleration is evaluated at the whole state instead of at the time alone:
The weight does not change, but the drag follows the velocity, so the net force does not stay the same during a fall. The sketch shows three moments of one fall.
Figure 1: conceptual Forces on a falling particle. The weight stays the same; the drag grows with the velocity, so the net force shrinks to zero.
The arrow lengths are drawn by eye, not measured. At release the net force is the weight alone; when the drag arrow is as long as the weight arrow the net force is zero and the velocity stops changing: the terminal speed.
The exact solution for linear drag
Write , which has the dimension of a time. The velocity equation is then
a linear first-order equation with constant coefficients. It has one constant solution, , at which the drag balances the weight: the terminal velocity. To find every other solution, write , the distance from the terminal velocity. Since is constant and ,
A quantity whose rate of change is proportional to itself, with a negative constant, decays exponentially: , as differentiating confirms. Back in terms of ,
The time constant is the time in which the remaining difference to the terminal velocity shrinks by the factor ; after a fraction 0.050 of it is left, after a fraction 0.0067. Integrating once more, with , gives the position,
Because the equation is linear, the components do not interact: the same formula holds on a line and in a plane, with pointing down and pointing wherever the particle was thrown. That is the property the next lesson loses.
Go deeper:The Coefficient of Drag (Russell Herman, LibreTexts Mathematics, 3.5.7, on the Stokes drag coefficient and the Newtonian regime; derivation.)
Why the drag is linear in the speed at low Reynolds number and quadratic at high: the drag coefficient of a sphere is in creeping flow, at , and approximately constant in the Newtonian drag regime at larger Reynolds number, though not at every large one. The lesson uses only the resulting force laws.
What explicit Euler does to it
The update rule can be solved exactly too. Substituting the acceleration into the velocity update and subtracting on both sides gives
The computed velocity approaches the terminal velocity geometrically, by the factor per step, where the exact solution uses the factor The two agree to first order in : they differ by per step, and over steps that accumulates into an error proportional to , as for every first-order method.
The formula also says what happens when the step is large. For the factor is negative: the computed velocity jumps past the terminal velocity and back, alternating sides. For the factor is less than , and the alternation grows without bound. The exact solution does nothing of the kind, because is positive and below one for every step. This is instability, and it is a property of the method and the step, not of the physics. Numerically verified (linear drag, explicit Euler): Explicit Euler on linear drag converges to the terminal velocity only for , and alternates around it for . The first exercise runs into it on purpose.
The program
The reusable code lives in two modules of the repository, pbc.mechanics.dynamics and pbc.mechanics.drag. The listings in this section are read from those files when the site is built.
The state carries vectors now. Lists and numbers are converted, so that State(t=0.0, x=0.0, v=0.0) describes a particle on a line and State(t=0.0, x=[0.0, 0.0], v=[12.0, 15.0]) one in a plane:
@dataclass(frozen=True)class State:"""The particle at one instant: the time and the vectors of position and velocity. ``x`` and ``v`` are arrays of the same length, one entry per dimension of the motion: one for a line, two for a plane. Lists and numbers are converted. """ t: float# time, s x: Vector # position, m v: Vector # velocity, m/sdef __post_init__(self) ->None: x = np.array(self.x, dtype=np.float64, ndmin=1) v = np.array(self.v, dtype=np.float64, ndmin=1)if x.ndim !=1or x.shape != v.shape:raiseValueError("x and v must be vectors of the same length,"f" got shapes {x.shape} and {v.shape}" )object.__setattr__(self, "x", x)object.__setattr__(self, "v", v)
A force model is any function that takes the time, the position, and the velocity and returns the force as a vector of the same shape. Newton’s second law is the function that turns a mass and any number of force models into the acceleration function that a stepper needs:
def newton(mass: float, *forces: Force) -> Acceleration:"""Newton's second law for a particle of ``mass`` (kg) under ``forces``. Returns the acceleration function a(t, x, v) = (sum of the forces) / m. Each force is a function F(t, x, v) that returns a vector in N. """if mass <=0:raiseValueError(f"mass must be positive, got {mass}")def acceleration(t: float, x: Vector, v: Vector) -> Vector: total = np.zeros_like(x)for force in forces: total = total + force(t, x, v)return total / massreturn acceleration
def weight(mass: float, g: ArrayLike) -> Force:"""The weight m g of a particle of ``mass`` (kg). ``g`` is the gravitational acceleration as a vector in m/s² with the same number of entries as the position: ``[-9.81]`` on a vertical line with up positive, ``[0.0, -9.81]`` in a vertical plane with y upward. """ force = mass * np.array(g, dtype=np.float64, ndmin=1)def weight_force(t: float, x: Vector, v: Vector) -> Vector:return forcereturn weight_force
def linear_drag(b: float) -> Force:"""The linear drag force -b v, with the drag constant ``b`` in kg/s. For a sphere of radius r in a fluid of viscosity eta, Stokes' law gives b = 6 pi eta r. """if b <0:raiseValueError(f"the drag constant must not be negative, got {b}")def drag(t: float, x: Vector, v: Vector) -> Vector:return-b * vreturn drag
The stepper has the signature step(state, acceleration, dt). This is the stepping interface of the course: a later lesson adds steppers with the same signature, and the simulation loop below accepts any of them through its step argument. Explicit Euler is the only one so far:
The table gives the inputs, the output, and their units; the listing shows only the update, and each marked line has a note below it:
Line 126, t=state.t + dt,: Time advances by the step, in seconds.
Line 127, x=state.x + state.v * dt,: The old velocity moves the particle: the position changes at the rate it had at the start of the step.
Line 128, v=state.v + acceleration(state.t, state.x, state.v) * dt,: The acceleration at the old state changes the velocity. Both lines read the old state, so their order does not matter; a stepper that used the new velocity for the position would be a different method.
def simulate( initial: State, acceleration: Acceleration, dt: float, n_steps: int, step: Stepper = euler_step, until: Callable[[State], bool] |None=None,) -> Trajectory:"""Take up to ``n_steps`` steps of length ``dt`` (s) from ``initial``. ``step`` is the stepper; explicit Euler unless another is given. The trajectory holds the initial state and the state after every step. With ``until``, the simulation stops early, after the first state for which ``until(state)`` is true; that state is the last one of the trajectory. """if dt <=0:raiseValueError(f"dt must be positive, got {dt}")if n_steps <0:raiseValueError(f"n_steps must not be negative, got {n_steps}") states = [initial]for _ inrange(n_steps): states.append(step(states[-1], acceleration, dt))if until isnotNoneand until(states[-1]):breakreturn Trajectory.of(states)
The loop can also stop early, when a condition on the state becomes true; the third worked example uses that to stop a throw at the ground, and the next lesson relies on it. The exact solution for the fall with linear drag is the reference to compare with:
def fall_with_linear_drag( initial: State, mass: float, b: float, g: ArrayLike, t: ArrayLike) -> Trajectory:"""The exact motion of a particle of ``mass`` (kg) under its weight and linear drag with the constant ``b`` (kg/s), evaluated at the times ``t``. ``g`` is the gravitational acceleration as a vector in m/s², like the position. With the time constant tau = m / b and the terminal velocity v_T = g tau, the velocity relaxes towards v_T and the position follows: v(t) = v_T + (v_0 - v_T) exp(-(t - t_0) / tau), x(t) = x_0 + v_T (t - t_0) + (v_0 - v_T) tau (1 - exp(-(t - t_0) / tau)). ``t`` is a scalar or an array; the arrays of the result have one row per time. ``b`` must be positive: without drag the motion is the one of ``pbc.mechanics.kinematics.constant_acceleration``. """if mass <=0:raiseValueError(f"mass must be positive, got {mass}")if b <=0:raiseValueError(f"the drag constant must be positive, got {b}") tau = mass / b terminal = np.array(g, dtype=np.float64, ndmin=1) * tau times = np.array(t, dtype=np.float64, ndmin=1) elapsed = (times - initial.t)[:, np.newaxis] decay = np.exp(-elapsed / tau) excess = initial.v - terminalreturn Trajectory( t=times, x=initial.x + terminal * elapsed + excess * tau * (1.0- decay), v=terminal + excess * decay, )
A particle is released from rest and falls against linear drag. On a vertical line the position is one number; the cell takes it as the distance fallen, so that down is positive and is the vector [g]. The mass and the drag constant set the time constant , which the cell computes.
mass =0.5# kgb =0.25# drag constant, kg/stau = mass / b # time constant, sterminal_speed = g * tau # m/sdown = [g] # the gravitational acceleration, on a line with down positiverest = State(t=0.0, x=0.0, v=0.0)falling = newton(mass, weight(mass, down), linear_drag(b))dt =0.1# time step, sn_steps =100
Velocity and distance fallen, simulated and exact, every tenth step; to three decimals.
t (s)
v Euler (m/s)
v exact (m/s)
x Euler (m)
x exact (m)
0.0
0.000
0.000
0.000
0.000
1.0
7.870
7.717
3.873
4.179
2.0
12.582
12.398
14.062
14.431
3.0
15.404
15.237
28.033
28.366
4.0
17.093
16.959
44.268
44.535
5.0
18.104
18.003
61.858
62.060
6.0
18.710
18.637
80.260
80.406
7.0
19.072
19.021
99.149
99.251
8.0
19.289
19.254
118.328
118.398
9.0
19.419
19.395
137.681
137.729
10.0
19.497
19.481
157.139
157.171
The time constant is = 2.0 s and the terminal speed 19.613 m/s. Both velocities rise towards it, and after five time constants the exact one is within 0.7 per cent of it. The simulated velocity runs ahead of the exact one. The derivation says why: after steps the computed difference to the terminal velocity is times the initial one, and = 0.95 is smaller than = 0.9512, so the computed velocity loses its initial difference a little faster at every step. The cell checks the formula directly:
def formula_gap(euler: Trajectory) ->float:"""The largest difference between the simulated velocity and the formula for explicit Euler's v_n, in m/s.""" n = np.arange(n_steps +1) predicted = terminal_speed * (1- (1- dt / tau) ** n)return np.max(np.abs(euler.v[:, 0] - predicted))
The largest difference between the simulation and the formula for is 1.1e-14 m/s, which is round-off. The program does exactly what the analysis of the method says it does, which is a stronger statement than “the error is small”.
Figure 2: simulated Velocity of the falling particle: the coarse explicit Euler steps stay on the same side of the exact curve and close in on the terminal speed (dotted) a little faster than the exact solution does.
The figure shows what the formula predicted: the simulated curve never crosses the terminal speed, and the gap to the exact curve is the small difference between the factors and , taken to the same power.
Halving the time step
The error after a fixed time should halve with the time step. The cell repeats the fall over the same duration with ever more steps and compares the velocity and the position at the end with the exact ones.
def final_errors(n):"""Errors of the last simulated velocity and position, for n steps.""" end = simulate(rest, falling, duration / n, n) reference = fall_with_linear_drag(rest, mass, b, down, end.t[-1])return end.v[-1, 0] - reference.v[0, 0], end.x[-1, 0] - reference.x[0, 0]
def convergence(experiment: Experiment) -> np.ndarray:"""The errors of the final velocity and position for each time step and the observed order of the position error; record their table.""" errors = np.array([final_errors(n) for n in step_counts]) orders = np.log2(errors[:-1, 1] / errors[1:, 1]) experiment.table("convergence", [ ( n, duration / n, errors[i, 0], errors[i, 1], orders[i -1] if i elseNone, )for i, n inenumerate(step_counts) ], columns=[ ("steps", "", "d"), ("time step", "s", ".6f"), ("v error", "m/s", ".6f"), ("x error", "m", ".6f"), ("observed order", "", ".3f"), ], caption="Error of the final velocity and position of the falling ""particle for each time step, and the observed order of the position ""error.", )return orders
Error of the final velocity and position of the falling particle for each time step, and the observed order of the position error.
steps
time step (s)
v error (m/s)
x error (m)
observed order
100
0.100000
0.016032
-0.032065
200
0.050000
0.008138
-0.016277
0.978
400
0.025000
0.004100
-0.008199
0.989
800
0.012500
0.002057
-0.004115
0.995
1600
0.006250
0.001031
-0.002061
0.997
3200
0.003125
0.000516
-0.001032
0.999
Both errors halve with the step, and the observed order of the position error reaches 0.999 at the smallest step. Unlike the constant acceleration of the first lesson, where the ratio was exactly two, the order here approaches one gradually, because the dropped terms contain higher powers of as well.
A throw in the plane
The same functions run a motion in a plane; only the vectors have two components. The particle is thrown from the ground with a horizontal and an upward velocity, with upward so that is [0, -g], and the loop stops at the first state below the ground. For comparison the cell also evaluates the drag-free motion, the parabola of the first lesson.
def throw():"""A throw in the vertical plane with linear drag: the simulated flight, the exact one, the flight without drag, and the largest distance between the simulated and the exact position.""" start = State(t=0.0, x=[0.0, 0.0], v=[12.0, 15.0]) plane = [0.0, -g] thrown = newton(mass, weight(mass, plane), linear_drag(b)) flight = simulate(start, thrown, 0.01, 10_000, until=on_the_ground) exact_flight = fall_with_linear_drag(start, mass, b, plane, flight.t)# The drag-free motion over its own, longer flight: back at the ground when# the vertical velocity has reversed, at t = 2 v_y / g. free_time = np.linspace(0.0, 2* start.v[1] / g, 200)[:, None] without_drag = start.x + start.v * free_time +0.5* np.array(plane) * free_time**2 largest_gap = np.max(np.linalg.norm(flight.x - exact_flight.x, axis=1))return flight, exact_flight, without_drag, largest_gap
The particle is back at the ground after 2.55 s, 17.32 m away, and the largest distance between the simulated and the exact position along the way is 0.068 m.
Figure 3: simulated Linear drag turns the symmetric parabola into a path that peaks earlier and falls more steeply; the explicit Euler points sit on the exact drag curve.
With drag the path is no longer a parabola: it rises less, peaks earlier, and comes down more steeply than it went up, because the drag takes away horizontal velocity all the time. Here the exact curve was still available to draw. For the quadratic drag of a ball in air there is none, and the next lesson shows how to trust a simulation without it.
Limits
The exact solution exists only for linear drag and a constant force. The checks of this lesson compare a method with a solution; for the quadratic drag of the next lesson there is none.
The stability result holds for explicit Euler on this one equation. It says nothing about the limits of other methods or of other forces, and the lesson does not measure them.
Linear drag describes creeping flow only (fourth assumption); the examples use numbers chosen for readability, so no result here applies to a real falling object.
The time step is never taken small enough to show round-off, and the second-order error terms are only argued, not isolated.
The spring of the last exercise shows that explicit Euler misbehaves on an oscillation, but the lesson does not derive by how much; the next lesson does.
Self-check. A simulation of the falling particle with a time step of three time constants shows a velocity that jumps above and below the terminal speed and grows at every step. Does the real particle do that?
Yes. The drag and the weight compete, so the velocity can overshoot its limit.
Not quite: the exact velocity approaches the terminal speed from one side and never crosses it, because the exponential factor is positive.
No. The oscillation belongs to the method: the factor is below minus one for such a step, and the exact solution has no such factor.
Correct: the sixth assumption fails, and the simulation reports its own time step, not the motion.
No. The oscillation is round-off, and a smaller number of digits would remove it.
Not quite: the sequence follows from the update rule alone, in exact arithmetic too. Only a smaller time step removes it.
Solution to the self-check
For this step the computed difference to the terminal speed is multiplied by a factor below minus one at every step, so it alternates in sign and grows. The exact difference is multiplied by , which is between zero and one. The particle falls monotonically towards the terminal speed; the oscillating simulation is an artefact of a time step above twice the time constant.
Exercises
Exercise 1.Too large a step. The time constant of the falling particle is 2 s. Predict what explicit Euler computes for the velocity with a time step of 3 s and with one of 5 s, then run both for ten steps and look at the sequence of computed velocities.
Solution to exercise 1
The computed difference to the terminal velocity is multiplied by at every step. With s the factor is : the velocity jumps past the terminal speed, then back below it, with the overshoot halving every step. With s the factor is : the same alternation, but growing by half every step, without bound.
def large_steps(experiment: Experiment) ->None:"""Exercise 1: ten steps of 3 s and of 5 s; record their velocities.""" runs = {step: simulate(rest, falling, step, 10) for step in (3.0, 5.0)} experiment.table("large-step-velocity", [(i, runs[3.0].v[i, 0], runs[5.0].v[i, 0]) for i inrange(11)], columns=[ ("step", "", "d"), ("v, time step 3 s", "m/s", ".1f"), ("v, time step 5 s", "m/s", ".1f"), ], caption="Computed velocity of the falling particle for two large time steps.", )
Computed velocity of the falling particle for two large time steps.
step
v, time step 3 s (m/s)
v, time step 5 s (m/s)
0
0.0
0.0
1
29.4
49.0
2
14.7
-24.5
3
22.1
85.8
4
18.4
-79.7
5
20.2
168.6
6
19.3
-203.8
7
19.8
354.7
8
19.5
-483.1
9
19.7
773.6
10
19.6
-1111.4
The first column settles at the terminal speed, the wrong way round but settling. The second leaves it further behind at every step. Nothing in the physics does this; the particle simply falls. The instability belongs to the method, and a simulation that shows it is reporting its own time step, not the motion.
Exercise 2.Time to terminal speed. How long does the particle released from rest take to reach 99 per cent of its terminal speed? Derive the answer from the exact solution, then find it from a simulation with a time step of 1 ms as the first instant at which the simulated speed exceeds that value.
Solution to exercise 2
From , the speed is when , that is at .
def time_to_terminal_speed():"""Exercise 2: the time to 99 per cent of the terminal speed, exact and from a simulation with a step of 1 ms, in s.""" predicted = tau * np.log(100) fine_fall = simulate(rest, falling, 0.001, 12_000) reached = fine_fall.t[np.argmax(fine_fall.v[:, 0] >=0.99* terminal_speed)]return predicted, reached
The exact solution gives 9.210 s and the simulation 9.209 s. The simulation reaches the mark slightly early, as expected: the computed velocity runs ahead of the exact one. With a step of 1 ms the difference is of the order of the time step itself.
Exercise 3.A force that depends on position. Replace the drag by a spring that pulls the particle back to the origin with the force , with N/m, no gravity, and the particle released from rest at m. The exact motion is with . Write the force model, simulate one full period with a time step of 10 ms, and compare the position at the end of the period with the exact one. What has happened to the amplitude?
Solution to exercise 3
The interface takes any function of time, position, and velocity, so the spring is one line. Nothing else changes.
k =2.0# spring constant, N/momega = np.sqrt(k / mass)period =2* np.pi / omega
def one_period():"""Exercise 3: one period of the spring with a time step of 10 ms.""" released = State(t=0.0, x=1.0, v=0.0)return simulate(released, newton(mass, spring), 0.01, round(period /0.01))
The period is 3.142 s and the simulation ends at 3.14 s. The simulated position is then 1.0648 m, the exact one 1.0000 m, and the largest excursion so far is 1.0648 m.
The particle comes back further out than it started, and the excursion keeps growing in later periods. Explicit Euler uses the rates at the start of the step for the whole step, and on an oscillation that feeds energy in at every step; the harmonic oscillator lesson derives by how much. That lesson and the numerical integrators lesson introduce the steppers that avoid the growth. They plug into the same simulate through its step argument.
The experiment writes every figure of this page into results/, as <label>.png, and every number and table of the page, as the page shows it, into results/results.json. Its numbers match this page to the digits shown, and its figure files are the ones the page shows; digits beyond those can differ between machines, because floating-point libraries differ. Change the code of the experiment and run it again to see what changes.