In the first lesson, Phugoid Motion, we described the physics of a glider’s oscillatory trajectory, seen as an exchange of kinetic and potential energy. This analysis goes back to Frederick Lanchester, who published his book “Aerodonetics” on aircraft stability in 1908. We concluded that first exposure to our problem of interest by plotting the flight paths predicted by Lanchester’s analysis, known as phugoids.
Here, we will look at the situation when an aircraft is initially moving on the straight-line phugoid (obtained with the parameters , , and in the previous analysis), and experiences a small upset, a wind gust that slightly perturbs its path. It will then enter into a gentle oscillation around the previous straight-line path: a phugoid oscillation.
As in the first lesson, is a depth measured positive downward from the chosen energy-reference level, and is its equilibrium value for trimmed flight. Thus is not the aircraft’s altitude: an upward acceleration is . If we assume that the perturbation is small, then is a good approximation and Newton’s second law in the vertical direction is:
We previously saw that the following relation holds for the ratio of lift to weight, in terms of the trim velocity :
This will be useful: we can divide Equation 1 by the weight and use Equation 2 to replace . Another useful relation from the previous lesson expressed the conservation of energy (per unit mass) as . With this, Equation 1 is rearranged as:
Look at Equation 3 for a moment. Does it ring a bell? Do you recognize it?
If you remember from your physics courses the equation for simple harmonic motion, you should see the similarity!
Take the case of a simple spring. Hooke’s law is , where is a restoring force, the displacement from a position of equilibrium and the spring constant. This results in the following ordinary differential equation for the displacement:
which has the solution , representing simple harmonic motion with an angular frequency and phase angle .
Now look back at Equation 3: it has nearly the same form and it represents simple harmonic motion with angular frequency around the equilibrium depth .
Think about this for a moment ... we can immediately say what the period of the oscillation is: exactly — or, in terms of the trim velocity, .
This is a remarkable result! Think about it: we know nothing about the aircraft, or the flight altitude, yet we can obtain the period of the phugoid oscillation simply as a function of the trim velocity. For example, if trim velocity is 200 knots, we get a phugoid period of about 47 seconds—over that time, you really would not notice anything if you were flying in that aircraft.
Next, we want to be able to compute the trajectory of the aircraft for a given initial perturbation. We will do this by numerically integrating the equation of motion.
Prepare to integrate¶
We want to integrate the differential equation and plot the trajectory of the aircraft. Are you ready?
On paper
This is an important modeling approach that makes the mathematics better adapted to computation: formulate a second-order differential equation as a system of first-order equations, written in vector form. The vector computations are then naturally handled by arrays. Be sure to follow this derivation on paper, taking your own notes.
Equation 3 is a second-order ordinary differential equation (ODE). Let’s represent the time derivative with a prime and write it like this:
There’s a convenient trick when we work with ODEs: we can turn this 2nd-order equation into a system of two 1st-order equations introducing an intermediate variable for the first derivative. Like this:
Here is the rate of change of depth: means downward velocity, while means upward velocity.
Another way to look at a system of two 1st-order ODEs is by using vectors. You can make a vector with the two state variables,
and write the differential system as a single vector equation:
If you call the right-hand-side , then the equation is very short: —but let’s drop those arrows to denote vectors from now on, as they are a bit cumbersome: just remember that and are vectors in the phugoid equation of motion.
Next, we’ll prepare to solve this problem numerically.
Initial value problems¶
Let’s step back for a moment. Suppose we have a first-order ODE . You know that if we were to integrate this, there would be an arbitrary constant of integration. To find its value, we do need to know one point on the curve . When the derivative in the ODE is with respect to time, we call that point the initial value and write something like this:
In the case of a second-order ODE, we already saw how to write it as a system of first-order ODEs, and we would need an initial value for each equation: two conditions are needed to determine our constants of integration. The same applies for higher-order ODEs: if it is of order , we can write it as first-order equations, and we need known values. If we have that data, we call the problem an initial value problem.
Remember the definition of a derivative? The derivative represents the slope of the tangent at a point of the curve , and the definition of the derivative for a function is:
If the step is already very small, we can approximate the derivative by dropping the limit. We can write:
With Equation 11, and because we know , if we have an initial value, we can step by and find the value of , then we can take this value, and find , and so on: we say that we step in time, numerically finding the solution for a range of values: , each separated by . The numerical solution of the ODE is simply the table of values that results from this process.
Discretization¶
In your notebook
Create a new, clean notebook for your work, rather than executing the code cells in this lesson notebook. Keep the lesson open as a reference and reconstruct all of the code presented here: the time grid, the Forward Euler updates, the trajectory plots, the exact solution, and the refinement study. Type the numerical method yourself so that every line can be traced back to an equation in the lesson; copying mechanical details such as imports and plot labels is fine.
As you reconstruct the lesson, make the small changes requested in the local exercises and compare each result with your expectations before continuing. Keep the direct time-integration loops as shown here: near the end of the lesson, you will preserve their results, ask an agent to propose a two-function refactoring, and test whether that restructuring changes the calculation. Consult Reconstruct a lesson for the general workflow.
In order to execute the process described above and find the numerical solution of the ODE, we start by choosing the values —we call these values our grid in time. The first point of the grid is given by our initial value, and the small difference between two consecutive times is called the time step, denoted by . The solution value at time is denoted by .
Let’s build a time grid for our problem. We first choose a final time and the time step . In code, we’ll use readily identifiable variable names: T and dt, respectively. With those values set, we calculate num_steps, the number of updates needed to reach . Because the grid includes both the initial and final times, it contains num_steps + 1 points.
Let’s write some code. The first thing we do in Python is load two scientific-Python libraries: NumPy for numerical functions and arrays, and Matplotlib for plotting.
import numpy as np
import matplotlib.pyplot as pltNow initialize T and dt, calculate num_steps, and build a NumPy array containing the num_steps + 1 time points in the grid.
# Create the time grid.
T = 100.0 # length of the time interval
dt = 0.02 # time-step size
num_steps = int(T / dt) # number of time steps
t = np.linspace(0.0, T, num=num_steps + 1) # time gridWe have our time grid! Now it’s time to apply the numerical time stepping represented by Equation 11.
Euler’s method¶
The approximate solution at time is , and the numerical solution of the differential equation consists of computing a sequence of approximate solutions by the following formula, based on Equation 11:
Equation 12 is called Euler’s method.
Applying Equation 12 to the phugoid system in Equation 6 gives the following algorithm that we need to implement in code:
And solve!¶
To implement Equation 13, we need to set things up in code: define the parameter values needed in the model, initialize a NumPy array to hold the two state variables, and initialize another array for the depth-coordinate values.
# Set the model parameters and initial conditions.
z_0 = 100.0 # initial depth below the energy reference
b_0 = 10.0 # initial downward velocity from a gust or downdraft
z_t = 100.0 # equilibrium depth for trimmed flight
g = 9.81 # acceleration due to gravity
# Set the initial value of the numerical solution.
u = np.array([z_0, b_0])
# Create an array to store the depth coordinate at each grid point.
z = np.zeros(num_steps + 1)
z[0] = z_0Now we can step in time using Euler’s method. range(num_steps) supplies the indices from 0 through num_steps - 1; each pass advances both state variables and stores the new depth at index n + 1.
# Temporal integration using Euler's method.
for n in range(num_steps):
z_n, b_n = u
du_dt = np.array([b_n, g * (1.0 - z_n / z_t)])
u = u + dt * du_dt
z[n + 1] = u[0]Make sure you understand what this code is doing. This is a basic pattern in numerical methods: iterations in a time variable that apply a numerical scheme at each step.
Plot the vertical displacement¶
If the code is correct, we have stored the downward-positive depth coordinate in the array z. For a picture of the aircraft’s motion in the sky, however, we expect the vertical axis to point upward. We therefore define the vertical displacement from trim as
Equation 14 defines as positive above the trim level and negative below it. This change does not alter the numerical solution: Euler’s method still advances and , and we convert from to only for visualization. In particular, a positive makes increase and decrease, so a downdraft appears as downward motion on the plot.
You should explore the Matplotlib Pyplot tutorial (if you need to) and familiarize yourself with the tools that control the size, labels, line style, and so on. Creating good plots is a useful skill: it is about communicating your results effectively.
Here, we set the figure size, vertical limits, grid, and axis labels. The final ax.plot() call draws a continuous red line.
# Set the font family and size to use for Matplotlib figures.
plt.rcParams['font.family'] = 'serif'
plt.rcParams['font.size'] = 12
# Convert depth to vertical displacement, positive upward.
eta = z_t - z
# Plot the vertical displacement from trim.
fig, ax = plt.subplots(figsize=(8, 3))
ax.set_title('Phugoid displacement from trim')
ax.set_xlabel('Time [s]')
ax.set_ylabel(r'Vertical displacement, $\eta$ [m]')
ax.set_xlim(t[0], t[-1])
ax.set_ylim(-70.0, 70.0)
ax.grid()
ax.plot(t, eta, color='tab:red', linestyle='-', linewidth=2)
fig.tight_layout()In your notebook
Try changing the value of b_0 in the initial conditions. Remember that its sign follows the depth coordinate: positive is downward and negative is upward. Study these cases:
What happens with a stronger downdraft ()?
What happens with an updraft ()?
What happens if there is no initial vertical perturbation ()?
Exact solution¶
The equation for phugoid oscillations is a 2nd-order, linear ODE and it has an exact solution of the following form:
where and are constants that we solve for using initial conditions.
Our numerical solution used the initial conditions:
Applying the initial conditions in Equation 16 to Equation 15 and solving for and gives:
We already defined all of the variables in Equation 17, so we can immediately compute the exact depth coordinate . We then apply the sky-view transformation in Equation 14, , for plotting.
omega = np.sqrt(g / z_t)
z_exact = (
b_0 / omega * np.sin(omega * t)
+ (z_0 - z_t) * np.cos(omega * t)
+ z_t
)
eta_exact = z_t - z_exactCompare with the exact solution¶
Now we can plot both the numerical and exact vertical displacements to see how well Euler’s method approximated the phugoid oscillation. Both curves use , so upward and downward on the graph match the aircraft’s motion in the sky.
To add another curve to a plot, call ax.plot() a second time on the same Axes. Giving each curve a label lets ax.legend() identify them.
# Plot the numerical and exact vertical displacements.
fig, ax = plt.subplots(figsize=(8, 3))
ax.set_title('Phugoid displacement from trim')
ax.set_xlabel('Time [s]')
ax.set_ylabel(r'Vertical displacement, $\eta$ [m]')
ax.set_xlim(t[0], t[-1])
ax.set_ylim(-70.0, 70.0)
ax.grid()
ax.plot(
t, eta, label='Numerical',
color='tab:red', linestyle='-', linewidth=2,
)
ax.plot(
t, eta_exact, label='Exact',
color='tab:grey', linestyle='-', linewidth=2,
)
ax.legend()
fig.tight_layout()The two curves agree fairly well at first, but the numerical oscillation grows toward the end. That growth is a numerical artifact: the ideal phugoid is undamped, so its exact amplitude remains constant. Re-run the previous steps with a smaller time step, say , and notice that the artificial growth becomes slower.
This brings up two different features of numerical methods. A method is convergent if, over a fixed time interval, its numerical solution approaches the exact solution as the time-step size tends to zero. If the global error behaves like , the method has order ; Forward Euler is first-order convergent.
Consistency is a related local property. Substitute the exact solution into one step of the numerical method and examine the residual. For Forward Euler, the unnormalized one-step defect is , so the residual per unit time is and tends to zero. Forward Euler is therefore consistent. Consistency alone does not guarantee convergence: the method must also control how errors grow from one step to the next.
Convergence on a fixed interval does not guarantee faithful behavior over arbitrarily long times. For this undamped oscillator, Forward Euler adds a small amount of numerical energy at every step, causing the amplitude to grow. Reducing slows that growth, but no nonzero time step eliminates it completely. The refinement study below asks the fixed-interval question: does the global error tend to zero, and at what rate, as decreases?
On paper
Derive the order of convergence of Forward Euler before examining the numerical errors.
Taylor-series reminder. If is sufficiently smooth, its value one step away can be expanded about as
The notation collects terms whose magnitude is bounded by a constant times as . Use Equation 18 to complete the following argument:
Substitute the differential equation into the Taylor expansion.
Compare the exact Taylor step with the Forward Euler step in Equation 12. Define the one-step defect as
Identify the leading omitted Taylor term and show the order of .
A fixed interval of length contains steps. Assuming errors remain controlled as they propagate across this smooth linear problem, estimate the global error—up to a factor independent of —by multiplying the size of one defect by . Simplify the resulting power of .
State separately the order of the one-step defect and the order of the accumulated global error. Does your result agree with the statement that Forward Euler is first-order convergent?
Keep this derivation beside the log-log error plot below: it supplies the expected slope independently of the computation.
Convergence¶
To compare the two solutions, we need to use a norm of the difference, like the norm, for example.
Equation 20 sums the individual differences between the exact and numerical solutions at the mesh points. In other words, is a discrete representation of the integral over the interval of the absolute difference between the computed and :
Although Equation 21 is written using the depth coordinate , we plotted the upward-positive displacement . Subtracting the same and reversing the sign does not change an absolute difference: .
We check for convergence by calculating the numerical solution using progressively smaller values of dt. We already have most of the code that we need. We just need to add an extra loop and an array of different values to iterate through.
# Set the list of time-step sizes.
dt_values = [0.1, 0.05, 0.01, 0.005, 0.001, 0.0001]
# Create an empty list for the depth-coordinate solution on each grid.
z_solutions = []
for dt_trial in dt_values:
num_steps_trial = int(T / dt_trial)
t_trial = np.linspace(0.0, T, num=num_steps_trial + 1)
# Set the initial conditions.
u_trial = np.array([z_0, b_0])
z_trial = np.zeros(num_steps_trial + 1)
z_trial[0] = z_0
# Temporal integration using Euler's method.
for n in range(num_steps_trial):
z_n, b_n = u_trial
du_dt = np.array([b_n, g * (1.0 - z_n / z_t)])
u_trial = u_trial + dt_trial * du_dt
z_trial[n + 1] = u_trial[0]
z_solutions.append(z_trial)Calculate the error¶
We now have a numerical depth-coordinate solution for each in the list z_solutions. The list method .append() added each completed NumPy array without requiring us to know its length in advance. To calculate the error corresponding to each , we can write a function.
def l1_error(z_numerical, z_exact, dt):
'''Return the discrete L1 error in the numerical depth coordinate.
Parameters
----------
z_numerical : np.ndarray
The numerical depth coordinate as an array of floats.
z_exact : np.ndarray
The exact depth coordinate as an array of floats.
dt : float
The time-step size.
Returns
-------
error : float
Discrete L1 error with respect to the exact solution.
'''
return dt * np.sum(np.abs(z_numerical - z_exact))a = np.array([1, 2, 3])
b = np.array([4, 4, 4])
b - aNow, we iterate through each value and calculate the corresponding error.
In the following code cell, we use the built-in function zip to pair each array in z_solutions with its time-step size in dt_values. The trial-specific names keep this loop from overwriting the baseline variables t, dt, and z_exact used earlier in the lesson.
# Create an empty list to store the errors on each time grid.
error_values = []
for z_trial, dt_trial in zip(z_solutions, dt_values):
num_steps_trial = int(T / dt_trial)
t_trial = np.linspace(0.0, T, num=num_steps_trial + 1)
# Compute the exact depth-coordinate solution.
z_exact_trial = (
b_0 / omega * np.sin(omega * t_trial)
+ (z_0 - z_t) * np.cos(omega * t_trial)
+ z_t
)
# Calculate the L1-norm of the error for the present time grid.
error_values.append(l1_error(z_trial, z_exact_trial, dt_trial))Remember, if the method is convergent then the error should get smaller as gets smaller. To visualize this trend across several scales, we use ax.loglog() to make both axes logarithmic. A relationship then appears as a straight line with slope .
Our paper derivation predicts , so we also plot a reference line proportional to . Its vertical placement is fixed by the error on the finest grid; only its slope carries meaning. In Python, an index of -1 selects the last item, and converting dt_values to a NumPy array lets us scale all its entries at once. The equal aspect setting makes one decade occupy the same visual distance on each axis, so a slope-one line appears at 45 degrees. We are comparing the computed curve visually with the expected order, not calculating an observed value of .
# Construct a first-order reference line through the finest-grid error.
dt_array = np.array(dt_values)
first_order_reference = error_values[-1] * dt_array / dt_array[-1]
# Plot the error versus the time-step size.
fig, ax = plt.subplots(figsize=(5.0, 5.0))
ax.set_title(r'$L_1$ error vs. time-step size')
ax.set_xlabel(r'$\Delta t$')
ax.set_ylabel('Error')
ax.grid()
ax.loglog(
dt_array, error_values, label='Forward Euler error',
color='tab:red', linestyle='--', marker='o',
)
ax.loglog(
dt_array, first_order_reference, label=r'Expected $O(\Delta t)$',
color='tab:blue', linestyle='-',
)
ax.set_aspect('equal', adjustable='box')
ax.legend()
fig.tight_layout()This is the kind of result we like to see: as shrinks toward the left, the error decreases. Over the finer step sizes, the computed curve is approximately parallel to the reference line, visually supporting the first-order result derived on paper.
Refactor the code with an agent¶
With an agent
Your notebook now contains a direct implementation of the phugoid equations and Forward Euler. The repeated update is readable, but separating the model derivatives from the numerical method will make the same pattern easier to use when the state vector grows in the next lesson.
Use an agent to propose this bounded refactoring in your notebook, not in the worked lesson. This is the second appearance of our agent workflow, so the checks and a compact task brief are supplied. Your work is to understand the checks before delegation, review the proposal against the brief, run the checks yourself, and reach a verdict.
For example, if you are using the Jupyter AI interface, select an agent persona (e.g., Codex), attach your saved notebook with the paperclip picker (or @file, confirming that the correct filename appears), and use the short request given below. This is a proposal-only interaction: the agent may read your notebook and return code, but you will decide whether to add and run it. If the agent asks for broader access, decline the request.
Preserve the reference and define the checks first¶
Before asking the agent for code, preserve results from your direct implementation and establish independent expected values. Acceptance criteria written after seeing a proposal can drift toward whatever the proposal happens to do. Here, the original working loop provides regression evidence, while a hand-calculated state checks the equations and Euler update directly.
Add the following cell to your notebook now. The .copy() method creates a separate array whose values will not change if z is later reassigned. The test depth is 120, while the test trim depth is 80, and the test depth rate is -3. These choices make both components of the right-hand side nonzero. They also differ from the lesson parameters, so an implementation that silently hard-codes those values cannot pass accidentally.
# Preserve reference results from the direct implementation.
z_direct = z.copy()
error_values_direct = np.array(error_values)
# Define an independent, non-equilibrium test case.
u_test = np.array([120.0, -3.0])
g_test = 10.0
z_t_test = 80.0
dt_test = 0.1
# Expected values calculated directly from the equations.
rhs_expected = np.array([-3.0, -5.0])
u_next_expected = np.array([119.7, -3.5])The supplied audit will check three levels of the refactoring:
rhs_linear_phugoid()returnsrhs_expectedfor the non-equilibrium state.euler_step()returnsu_next_expectedfor one step.The refactored loop reproduces every value in
z_direct.
Verify the two expected arrays on paper before continuing. From , the derivative is ; one step of size 0.1 gives . These values come from the mathematical specification, not from agent-produced code.
These are the exact checks you will run after adding an accepted refactoring:
np.testing.assert_allclose(
rhs_linear_phugoid(u_test, g_test, z_t_test), rhs_expected
)
np.testing.assert_allclose(
euler_step(
u_test, rhs_linear_phugoid, dt_test, g_test, z_t_test
),
u_next_expected,
)
np.testing.assert_allclose(
u_refactored[0], np.array([z_0, b_0])
)
assert u_refactored.shape == (num_steps + 1, 2)
np.testing.assert_allclose(u_refactored[:, 0], z_direct)Read each assertion now and identify the mathematical or interface requirement it will check. Do not change the expected values to make a later proposal pass.
Record a compact task brief¶
Add the task brief below as a Markdown cell in your notebook. Lesson 1 displayed every specification field separately; here related decisions are combined under four headings. This shorter form is proportional to a local refactoring with supplied checks, while the function interfaces remain explicit because they are part of the numerical design.
Agent refactoring task brief¶
Goal and scope: Propose a right-hand-side function for the linear phugoid and a separate Forward Euler step, then use both in a replacement for the single-grid time-marching loop. Put the proposal in new cells and leave the direct calculation, plots, exact solution, error calculation, and computed trajectory unchanged.
Non-negotiables: Order the state as , with derivative , and calculate both components from the same old state.
rhs_linear_phugoid(u, g, z_t)returns a NumPy array of shape(2,).euler_step(u, f, dt, g, z_t)returnsu + dt * f(u, g, z_t)using vector operations. Store the history inu_refactoredwith shape(num_steps + 1, 2)and initializeu_refactored[0] = np.array([z_0, b_0]). Use NumPy and existing names only—z_directexists, butb_directdoes not. Passgandz_texplicitly; do not use globals, SciPy, classes, in-place state updates, or a packaged solver.Allowed actions: Read the attached notebook and add your proposal in new cells, according to the Goal and scope above. Do not edit other cells or files, run code, use terminal tools beyond the allowed actions. Do not access the network.
Done when: The proposal would pass every supplied check for the right-hand side, one Euler step, the initial state, history shape, and complete depth trajectory. The notebook is edited to add code cells containing exactly the two functions and replacement loop. Return at most 100 words explaining the state ordering and parameter forwarding. Propose no other changes.
This brief is the minimum sufficient specification for this task. The short prompt in the next step merely points the agent to it.
Invoke the agent, then review its proposal¶
Save your notebook so that the attached file contains the reference values, checks, and task brief. Attach it to the agent interface and send only this brief request:
Read the section titled “Agent refactoring task brief” in the attached notebook and propose the requested refactoring. Return only the response requested there.
Treat the response as a proposal. Before adding it to your notebook, check it against the task brief:
Do the state ordering, signs, and right-hand side agree with Equation 6?
Does the Euler step evaluate the complete right-hand side from the current state before updating either state variable?
Do both functions follow their interfaces, use vector operations and explicit parameters, and avoid invented external names?
Does the loop initialize and fill the specified two-column history while leaving the direct calculation intact?
Did the response stay within the allowed actions, requested output, and task boundary?
Record one sentence saying whether you provisionally accept, revise, or reject the proposal and why. If you revise it, record the change. Only then add the accepted version in new cells.
Compare with a neighbor, then audit the refactoring¶
After recording your provisional decision, compare the agent proposal with a classmate’s proposal. Do not judge them by whether the lines are identical. Instead, use the task brief to identify any meaningful differences in the function interfaces, state ordering, parameter use, and time loop. If both proposals appear to satisfy the brief, explain whether the differences are merely stylistic or require additional evidence.
Add your accepted proposal to new cells in your notebook, leaving the direct implementation intact. Then run the supplied checks shown before the task brief. np.testing.assert_allclose() reports a failure when corresponding numerical values disagree beyond floating-point tolerances; silence means that the stated comparison passed. The first two checks inspect the local mathematical pieces, while the shape check and final comparison ask whether their repeated use produced the requested state history and preserved the complete depth trajectory.
If a check fails, do not change its expected value. Compare the proposed code with Equation 6, the Forward Euler update, and the specified interfaces; revise or reject the proposal only after locating the discrepancy.
Reuse the accepted functions in the refinement study¶
After the checks pass, return to the refinement study in your notebook. Keep its outer loop over dt_values, but replace the hand-written derivative and update inside its time loop with the accepted functions:
for n in range(num_steps_trial):
u_trial = euler_step(
u_trial, rhs_linear_phugoid, dt_trial, g, z_t
)
z_trial[n + 1] = u_trial[0]Re-run the error calculation and compare it with the values saved before the refactoring:
np.testing.assert_allclose(error_values, error_values_direct)This final regression check is stronger than deciding that the new error plot merely looks similar. It confirms that the structural change left every reported error value unchanged.
Debrief the activity¶
The agent contributed a proposed code structure, but the model equations, interfaces, constraints, and evidence were fixed before delegation. You inspected the proposal and tested it against both independent calculations and the original implementation.
Your verdict
Write a short debrief that answers these questions:
Why were
rhs_expected,u_next_expected, and the reference results recorded before invoking the agent, and what claim does each supplied check support?Which model and interface decisions were fixed by the task brief, and what implementation choices remained for the agent?
Did you accept, revise, or reject the proposal? Support the verdict with the checks and state one thing that could still be wrong even if they all pass.
Leave a lightweight agent record naming the persona, the attached file, the request, any revision you made, and the verification you performed.
What’s next?¶
This lesson marched a two-component state forward in time using Euler’s method and checked the result against an exact solution. In the next lesson, we will return to the full nonlinear phugoid model, include aerodynamic drag, and expand the state to track speed, trajectory angle, and position. The right-hand side will change, but the vector Euler step you developed here will remain the same—and, without an exact solution, refinement evidence will become even more important.