Skip to content

Vol. II, Ch. 6seed 26206

DCT Laboratory — Volume II, Chapter 6

Optimal Control of Enterprise Systems

Seed 26206 · Companion to the chapter and AXIOM Module AXIOM-06 (Vol. II)

Pontryagin, solved by hand twice. Problem 1: quadratic effort cost, terminal capital reward — the adjoint equation gives every control in closed form (uk=λk+1u_k = \lambda_{k+1}), and the costate is verified as the marginal value of capital. Problem 2: linear cost with u∈[0,1]u \in [0,1] — the switching function delivers genuine bang-bang, with the switch at k=1k = 1 computed from a sign change. Mirrored in DCT_V2_Ch06_Lab.xlsx.

import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['figure.dpi']=110

import numpy as np
SEED = 26206
A, Q, N = 0.9, 2.0, 6
K0 = 4.0
# Problem 1: max Q*K_N - sum u^2/2 ; K' = A*K + u
# adjoint: lam_k = A*lam_{k+1}, lam_N = Q  =>  lam_k = Q*A^(N-k) ; u_k = lam_{k+1}
def costate(k): return Q*A**(N-k)
def u_star(k): return costate(k+1)
def solve_p1():
    K, J = K0, 0.0
    for k in range(N):
        u = u_star(k); J -= u*u/2
        K = A*K + u
    return J + Q*K, K
# Problem 2: max Q*K_N - C*sum(u) ; u in [0,1] ; switching sigma_k = lam_{k+1} - C
C = 1.2
def sigma(k): return costate(k+1) - C
def solve_p2():
    K, J = K0, 0.0
    us = []
    for k in range(N):
        u = 1.0 if sigma(k) > 0 else 0.0
        us.append(u); J -= C*u
        K = A*K + u
    return J + Q*K, K, us

def reference_values():
    J1, K1 = solve_p1()
    J2, K2, us = solve_p2()
    switch = next(k for k,u in enumerate(us) if u > 0)
    return {
        "lambda_0": round(costate(0),4),
        "u0_star": round(u_star(0),4), "u5_star": round(u_star(5),4),
        "K_N_p1": round(K1,4), "J_star_p1": round(J1,4),
        "marginal_dJ_dK0": round(Q*A**N,4),      # = lambda_0, exactly (linear dynamics)
        "sigma_0": round(sigma(0),4), "sigma_1": round(sigma(1),4),
        "switch_k": switch, "n_active": int(sum(us)),
        "J_star_p2": round(J2,4), "K_N_p2": round(K2,4),
    }
if __name__ == "__main__":
    [print(f"{k:18s} {v}") for k,v in reference_values().items()]
lambda_0           1.0629
u0_star            1.181
u5_star            2.0
K_N_p1             9.6791
J_star_p1          11.8049
marginal_dJ_dK0    1.0629
sigma_0            -0.019
sigma_1            0.1122
switch_k           1
n_active           5
J_star_p2          6.4417
K_N_p2             6.2209

Panel 1 — The maximum principle, in closed form

Maximize QKN−∑uk2/2Q K_N - \sum u_k^2/2 with Kk+1=0.9Kk+ukK_{k+1} = 0.9K_k + u_k. The Hamiltonian H=−u2/2+λk+1(0.9K+u)H = -u^2/2 + \lambda_{k+1}(0.9K + u); the Costate Evolution Theorem gives λk=0.9λk+1\lambda_k = 0.9\lambda_{k+1} with transversality λN=Q=2\lambda_N = Q = 2, so λk=2⋅0.96−k\lambda_k = 2 \cdot 0.9^{6-k} — and maximizing HH in uu gives uk∗=λk+1u_k^* = \lambda_{k+1}. Controls rise toward the horizon (1.18 → 2.00): late capital decays less before payday, so late effort is worth more.

ks = np.arange(N)
lams = [costate(k) for k in range(N+1)]
us = [u_star(k) for k in ks]
fig, axes = plt.subplots(1,2, figsize=(10,3.9))
axes[0].plot(range(N+1), lams, "o-", c="#0B3D2E", lw=2.2, ms=5)
axes[0].set(xlabel="k", ylabel="λ_k", title="Costate: marginal value of capital, λ_k = 2·0.9^(6−k)")
axes[0].grid(alpha=.25)
axes[1].bar(ks, us, color="#C8A24B", width=.6)
axes[1].set(xlabel="k", ylabel="u_k*", title="Optimal controls: u_k = λ_{k+1}, rising to the horizon")
axes[1].grid(alpha=.25, axis="y")
plt.tight_layout(); plt.show()
J1, K1 = solve_p1()
print(f"u0* = {u_star(0):.4f}   u5* = {u_star(5):.4f}   K_N = {K1:.4f}   J* = {J1:.4f}")
Costate: marginal value of capital, λ_k = 2·0.9^(6−k)
u0* = 1.1810   u5* = 2.0000   K_N = 9.6791   J* = 11.8049

Panel 2 — The costate is a derivative

Adjoint Variables Quantify the Marginal Value of Enterprise States (Prop.): ∂J∗/∂K0=λ0\partial J^*/\partial K_0 = \lambda_0. With linear dynamics this is exact, no finite difference needed: one extra unit of K0K_0 compounds to 0.960.9^6 at the horizon and earns Q⋅0.96=1.0629=λ0Q \cdot 0.9^6 = 1.0629 = \lambda_0. Chapter 3's shadow price, Chapter 4's envelope derivative, and now the costate: the same economic object, its third appearance — this time indexed by time.

print(f"lambda_0 (adjoint at k=0):        {costate(0):.4f}")
print(f"dJ*/dK0 (exact, linear dynamics): {Q*A**N:.4f}")
print("The multiplier family tree: shadow price (Ch.3) = envelope derivative (Ch.4) = costate (Ch.6)")
lambda_0 (adjoint at k=0):        1.0629
dJ*/dK0 (exact, linear dynamics): 1.0629
The multiplier family tree: shadow price (Ch.3) = envelope derivative (Ch.4) = costate (Ch.6)

Panel 3 — Bang-bang: when the Hamiltonian is linear in control

Maximize QKN−1.2∑ukQ K_N - 1.2\sum u_k with uk∈[0,1]u_k \in [0,1]: HH is linear in uu, so the maximum principle pushes uu to a bound, chosen by the switching function σk=λk+1−1.2\sigma_k = \lambda_{k+1} - 1.2. At k=0k = 0: σ=−0.019\sigma = -0.019 (barely negative — don't invest); from k=1k = 1: positive — invest at full throttle. One sign change, one switch, five active periods: the structure of the optimal control read directly off the costate.

J2, K2, us2 = solve_p2()
sigs = [sigma(k) for k in range(N)]
fig, ax = plt.subplots(figsize=(7.8,4.0))
ax.bar(range(N), us2, color="#C8A24B", width=.55, label="u_k* (bang-bang)")
ax2 = ax.twinx()
ax2.plot(range(N), sigs, "o-", c="#0B3D2E", lw=2, label="switching σ_k = λ_{k+1} − c")
ax2.axhline(0, c="#8A8F8B", lw=1, ls=":")
ax.set(xlabel="k", ylabel="u_k*", title="σ crosses zero once — the control switches once (seed 26206)")
ax2.set_ylabel("σ_k")
ax.legend(loc="upper left", frameon=False, fontsize=9); ax2.legend(loc="lower right", frameon=False, fontsize=9)
plt.tight_layout(); plt.show()
print(f"sigma_0 = {sigma(0):+.4f}  sigma_1 = {sigma(1):+.4f}   switch at k = {next(k for k,u in enumerate(us2) if u>0)}")
print(f"J* = {J2:.4f}   K_N = {K2:.4f}   active periods: {int(sum(us2))}")
σ crosses zero once — the control switches once (seed 26206)
sigma_0 = -0.0190  sigma_1 = +0.1122   switch at k = 1
J* = 6.4417   K_N = 6.2209   active periods: 5

Validation — agrees with DCT_V2_Ch06_Lab.xlsx

ref = reference_values()
expected = {"lambda_0":1.0629,"u0_star":1.181,"u5_star":2.0,"K_N_p1":9.6791,"J_star_p1":11.8049,
 "marginal_dJ_dK0":1.0629,"sigma_0":-0.019,"sigma_1":0.1122,"switch_k":1,"n_active":5,
 "J_star_p2":6.4417,"K_N_p2":6.2209}
for k,v in expected.items():
    assert abs(ref[k]-v)<5e-4, f"MISMATCH {k}"
    print(f"PASS  {k:18s} {ref[k]}")
print("\nAll checkpoints agree — seed 26206.")
PASS  lambda_0           1.0629
PASS  u0_star            1.181
PASS  u5_star            2.0
PASS  K_N_p1             9.6791
PASS  J_star_p1          11.8049
PASS  marginal_dJ_dK0    1.0629
PASS  sigma_0            -0.019
PASS  sigma_1            0.1122
PASS  switch_k           1
PASS  n_active           5
PASS  J_star_p2          6.4417
PASS  K_N_p2             6.2209

All checkpoints agree — seed 26206.

Next: Exercises 6.5–6.9 (Part C) move the cost c through the switching threshold; AXIOM-06's Hamiltonian console animates costate, control, and trajectory together. Chapter 7 solves the same problems backward: dynamic programming. Solutions: IM Vol. II, Ch. 6.