Exercises — scipy.integrate — odeint, solve_ivp (RK45, DOP853), quad
Two ideas run through the whole page, so let us pin them down in plain words before any symbol appears.
Look at the picture: the left panel is an area problem (quad); the right panel is a path problem (solve_ivp).

Level 1 — Recognition (which tool, which signature)
L1.1
You are handed a Python function f(x) returning a height, and asked for the number . Which SciPy call?
Recall Solution
quad. You have the function and want a number (an area) — that is the definition of a definite integral. Call:
from scipy.integrate import quad
value, error = quad(f, 0, 1)It returns a tuple: value = the estimate, error = an estimate of the absolute error. If you only see one number printed, you forgot to unpack the tuple.
L1.2
Two functions are written as rhs_A(t, y) and rhs_B(y, t). One is meant for solve_ivp, the other for odeint. Match them.
Recall Solution
solve_ivpwants time first:f(t, y)→ sorhs_A(t, y).odeintis the old interface (wraps LSODA) and is reversed:f(y, t)→ sorhs_B(y, t).
Mnemonic from the parent: "solve_ivp = time is super first." Old = reversed.
L1.3
Classify each as definite-integral (quad) or IVP (solve_ivp):
(a) area under from to ; (b) trajectory of a pendulum from a release angle; (c) total charge delivered by a known current; (d) position of a rocket given its known thrust-acceleration and launch state.
Recall Solution
- (a)
quad— a fixed area. - (b)
solve_ivp— you know the slope law and an initial state, want the path. - (c)
quad— you HAVE and want the accumulated number. - (d)
solve_ivp— acceleration is the slope of velocity; integrate the path from initial conditions.
Rule of thumb: "Do I already know the whole curve and want its area?" → quad. "Do I only know the rate of change and a start?" → solve_ivp.
Level 2 — Application (call it correctly)
L2.1
Compute with quad and state the exact value.
Recall Solution
Antiderivative is , so exact value .
from scipy.integrate import quad
val, err = quad(lambda x: 3*x**2 + 1, 0, 2)
# val ≈ 10.0, err ≈ 1.1e-13quad is exact for polynomials up to high degree (Gauss quadrature with nodes nails degree ), so a cubic integrand comes back essentially at machine precision — see Gaussian quadrature.
L2.2
Solve on with solve_ivp and give to 4 decimals. Exact solution is .
Recall Solution
.
from scipy.integrate import solve_ivp
import numpy as np
sol = solve_ivp(lambda t, y: -2*y, [0, 5], [3.0],
rtol=1e-9, atol=1e-12)
sol.y[0, -1] # ≈ 0.00013620y0=[3.0]is a list because the state is always a vector, even a 1-element one.sol.y[0]picks the first (only) state component;[-1]is the last time — the value at .
L2.3
Rewrite the same call with odeint and give the correct value at .
Recall Solution
from scipy.integrate import odeint
import numpy as np
t = np.linspace(0, 5, 200)
y = odeint(lambda y, t: -2*y, 3.0, t) # note (y, t)!
y[-1, 0] # ≈ 0.00013620Two things flip vs solve_ivp: the RHS is f(y, t), and the output shape is (len(t), n_states) — so the final value is y[-1, 0] (last row, first column), not y[0, -1].
Level 3 — Analysis (predict and explain)
L3.1
By hand, apply Simpson's rule to using only the three points . Compare to the exact value. Explain the result.
Recall Solution
Simpson on with midpoint and half-width : Exact: . Zero error. Why exact? Simpson fits a parabola (degree 2) through 3 points, yet the error term is . For a cubic, everywhere — so Simpson is secretly exact for cubics too. This is the same "free extra order" that makes Gauss rules so cheap (see Riemann sums and numpy.trapz and simpson).
L3.2
You integrate an oscillator over a long time with solve_ivp and default tolerance rtol=1e-3. The amplitude visibly grows. Bug or physics?
Recall Solution
Neither a bug nor real physics — it's accumulated numerical error. Each step has a tiny error; over thousands of oscillations these add up, and with a loose rtol=1e-3 the solver is allowed large local slips that can slowly pump energy in or out. The true solution conserves amplitude (see Harmonic oscillator).
Fix: tighten tolerances (rtol=1e-10, atol=1e-12) or use a higher-order method (DOP853). The figure shows loose vs tight tolerance over many periods.

L3.3
Why does t_eval=np.linspace(0, 10, 5) not make the answer less accurate, even though it looks like only 5 steps?
Recall Solution
Because t_eval only chooses where the result is reported, not how the solver steps. Internally solve_ivp takes as many adaptive steps as accuracy demands (governed by rtol/atol) and then interpolates onto your t_eval points. Accuracy is set by tolerances and method — never by t_eval. Reporting at 5 points vs 500 points gives the same underlying integration.
Level 4 — Synthesis (combine ideas)
L4.1
Reduce (with ) to a first-order system, integrate with solve_ivp from , and give at . Show the exact target.
Recall Solution
Why reduce? Every solver accepts only first-order systems. Introduce state vector : Exact solution , so .
from scipy.integrate import solve_ivp
import numpy as np
w = 2.0
sol = solve_ivp(lambda t, y: [y[1], -w**2*y[0]],
[0, np.pi], [1.0, 0.0],
method='DOP853', rtol=1e-11, atol=1e-13)
sol.y[0, -1] # ≈ 1.0L4.2
Verify the same solved oscillator conserves energy . Compute at and (numerically) at .
Recall Solution
At : .
At the true state is again → . Numerically with DOP853 at tight tolerance, stays to . Energy drift is a direct read-out of solver error, which is why oscillators are the classic accuracy stress-test.
L4.3
Combine both tools: a current (amps) flows for . Find the total charge with quad, and separately find the charge as a function of time by solving .
Recall Solution
Charge as one number — this is an area, use quad:
(Standard result with .)
from scipy.integrate import quad
import numpy as np
Q, err = quad(lambda t: np.exp(-t)*np.sin(t), 0, np.inf) # Q ≈ 0.5Charge over time — this is a path, use solve_ivp:
from scipy.integrate import solve_ivp
sol = solve_ivp(lambda t, q: np.exp(-t)*np.sin(t), [0, 20], [0.0],
rtol=1e-10, atol=1e-12)
sol.y[0, -1] # ≈ 0.5 (q(∞) matches Q)The two answers must agree: the ODE's final value is the definite integral. This is the Fundamental Theorem of Calculus made numerical — quad accumulates area directly, solve_ivp accumulates it as a running total.
Level 5 — Mastery (edge-cases and stiffness)
L5.1
The system , is stiff. You run method='RK45' and it crawls, taking thousands of steps. Explain precisely why, and give the fix.
Recall Solution
Why it crawls: the term is a very fast decay (time-scale s) sitting alongside the slow drive (time-scale s). That coexistence of fast + slow scales is the definition of stiffness (see Stiff differential equations). An explicit method like RK45 is only stable if its step resolves the fastest scale: roughly . Even once the fast transient has died and the solution is smooth, RK45 is forced to keep tiny steps just to avoid blowing up — this is a Numerical stability limit, not an accuracy limit.
Fix: use an implicit solver that is stable at large :
from scipy.integrate import solve_ivp
sol = solve_ivp(lambda t, y: -1000*y + 3000 - 2000*__import__('numpy').exp(-t),
[0, 5], [0.0], method='BDF', rtol=1e-8, atol=1e-10)BDF, Radau, or LSODA take big smooth steps here. See Runge-Kutta methods for why explicit RK cannot.
L5.2
quad on over : the integrand blows up at . Does quad handle it? What is the exact answer, and what warns you if something is off?
Recall Solution
Exact: — an integrable singularity (the area is finite even though the height isn't).
from scipy.integrate import quad
import numpy as np
val, err = quad(lambda x: 1/np.sqrt(x), 0, 1) # val ≈ 2.0quad's adaptive Gauss–Kronrod clusters points near the trouble spot and returns with a small err. Warning signals: a large returned err, or an IntegrationWarning. For a known kink or singularity, pass points=[...] (or split the integral) so the sampler is told where to be careful.
L5.3
You integrate over a domain containing a reported-only discontinuity in slope, and you must catch when first crosses zero, not just report on a grid. Which solve_ivp feature, and why can't t_eval do it?
Recall Solution
Use an event function: a function g(t, y) whose sign change the solver watches for, locating the crossing by root-finding between steps.
def hit_zero(t, y): return y[0]
hit_zero.terminal = True # stop at the crossing (optional)
sol = solve_ivp(rhs, [0, 10], y0, events=hit_zero, rtol=1e-9)
sol.t_events[0] # exact crossing time(s)Why not t_eval: t_eval only samples the solution at pre-chosen times — the true crossing almost never lands on a grid point. Events do a root solve to pinpoint the moment to full precision. This is one of the concrete reasons the parent recommends solve_ivp over odeint.
Recall One-line self-test
quad returns a tuple ::: (value, estimated_absolute_error)
solve_ivp RHS order ::: f(t, y) — time first
odeint RHS order and output shape ::: f(y, t); output (len(t), n_states)
Cure for a stiff ODE ::: switch to an implicit method (BDF/Radau/LSODA)
What t_eval controls ::: where the answer is reported, never the internal step size