Created
June 11, 2026 21:05
-
-
Save lamont-granquist/51fbc7dedd2c28a983a0ec9c89d9837d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Süslü & Söken (2026), J. Spacecraft & Rockets — Article in Advance | |
| # "Convex Optimization of Lunar Ascent Trajectory with Generalized | |
| # Terminal Conditions" — DOI: 10.2514/1.A36519 | |
| # | |
| # Free-time sequential convex programming (SCvx) replication of Sec. IV | |
| # with both terminal-condition formulations: | |
| # • CTC (Circular Terminal Conditions, eqs. 9–12) — 4 quadratic eqs. | |
| # • GTC (Generalized Terminal Conditions, eqs. 16–23) — 8 vector eqs. | |
| # | |
| # Scenarios (Table 1 vehicle: T_max = 10 kN, I_sp = 300 s, | |
| # m_dry = 400 kg, m_prop = 1600 kg): | |
| # 1. Inclined circular (a = R_M + 200 km, e = 0, i = 60°, Ω = 280°) | |
| # — GTC + CTC | |
| # 2. Equatorial circular (i = 0°, launch lat = 60° N) | |
| # — GTC + CTC (paper: CTC fails) | |
| # 3. Elliptic (a = 3087.4 km, e = 0.372, i = 40°, Ω = 290°, ω = 90°) | |
| # — GTC + CTC (paper: not run) | |
| # | |
| # Paper-typo notes (checked against the printed equations and the | |
| # appendix Jacobian H, eqs. A5–A42): | |
| # • Eqs. 16–20 are correct as printed: | |
| # g₁ = r_y v_z − r_z v_y = h_tx, g₂ = r_z v_x − r_x v_z = h_ty, | |
| # g₃ = r_x v_y − r_y v_x = h_tz, | |
| # g₄ = r_x v_z − r_z v_x = N_tx, g₅ = r_y v_z − r_z v_y = N_ty. | |
| # g₅ is algebraically equal to g₁ (since N = k̂ × h ⇒ N_y = h_x); | |
| # the paper keeps both, so we do too. | |
| # • Eq. 13 has a typesetting slip: the third component of h prints as | |
| # "v_y − r_y v_x", its leading r_x orphaned at the end of the second | |
| # row. Harmless — eq. 18 gives the intended r_x v_y − r_y v_x. | |
| # • The eccentricity expansions (eqs. 15, 21–23) contain subscript and | |
| # sign slips (e.g. eq. 21's middle coefficient v_fy should be v_fz; | |
| # eqs. 22–23 print +μ r_f-terms where e = (1/μ)(v × h − μ r/‖r‖) | |
| # requires −). The appendix H rows (A8 ff.) are consistent with the | |
| # standard e-vector, which terminal_residual() implements directly | |
| # via cross products, so none of these slips affect the code. | |
| # • Appendix A3 shows B[7,4] = +α; dynamics ż = −α u_N requires −α. | |
| # | |
| # Documented deviations from the paper: | |
| # • Trust-region update: the paper defers to the SCvx update rules of | |
| # ref. [43] (accept/reject on the ratio ρ of actual-to-predicted | |
| # reduction of an exact penalty function). Both that ratio test | |
| # (tr_update=:ratio) and an accept-every-step stagnation heuristic | |
| # (tr_update=:stagnation) are implemented; the DEFAULT IS THE | |
| # HEURISTIC because the faithful ratio test converges too slowly | |
| # for the paper's 50-iteration budget on this problem — see the | |
| # scvx() docstring for the measured behavior behind that choice. | |
| # • Trust region on p: eq. 51 bounds only the control. A relative | |
| # trust region on p (scaled with η) is kept because the free-time | |
| # subproblem is otherwise prone to artificial unboundedness in t_f. | |
| # • Discretization: eq. 54 is a first-order-hold on the control but | |
| # explicit Euler in the state, exactly as printed, and is the | |
| # default; its defect is quantified post-hoc by propagating the | |
| # converged control through the full nonlinear dynamics. | |
| # SUSLU_DISC=foh switches to a matrix-exponential FOH (Van Loan | |
| # block exponential per interval, A frozen at the left node, | |
| # residual defined against nonlinear propagation of the reference — | |
| # a multiple-shooting defect). This is NOT what the paper | |
| # discretizes, but it is far more accurate at the same N: the | |
| # nonlinear-prop terminal error drops from km-scale to ≤ 0.03 km | |
| # across the converging cases. Two practical notes: (a) the radius | |
| # in the frozen Jacobian is floored at 0.3 DU because intermediate | |
| # iterates can place reference nodes at nonphysical near-zero radii | |
| # where exp(A Δτ) overflows; (b) the Fig.-7 monotonic-altitude | |
| # variant does not converge under :foh from the default | |
| # initialization — use the default :euler for that comparison. | |
| # | |
| # Extras beyond the basic replication: | |
| # • Convergence additionally requires the virtual controls to vanish | |
| # ("virtual terms must be zero for a converged solution", Sec. III.D). | |
| # • Equatorial CTC failure is retried at N = 50 (Sec. IV.C). | |
| # • SUSLU_MC=<n> runs an n-sample Monte Carlo of Table 4/5 | |
| # (paper: 500 samples, GTC 48.2 % / CTC 0 % convergence). | |
| # • The Fig. 7 lofted-vs-monotonic-altitude comparison is reproduced | |
| # via a linearized altitude-nondecrease constraint. | |
| # | |
| # Internal scaling: canonical units with DU = R_M and μ = 1 for | |
| # numerical conditioning. SI is used only for input/output and plots. | |
| # | |
| # Run with: | |
| # julia --project=. suslu-soken-2026.jl | |
| # Env switches: SUSLU_SOLVER=ECOS|Clarabel, SUSLU_DISC=euler|foh, | |
| # SUSLU_MC=<n samples> | |
| using JuMP | |
| using ECOS | |
| using Clarabel | |
| using LinearAlgebra | |
| using OrdinaryDiffEq | |
| using Printf | |
| using Random | |
| # Solver selection: set to ECOS.Optimizer or Clarabel.Optimizer. | |
| const SOLVER = get(ENV, "SUSLU_SOLVER", "Clarabel") == "ECOS" ? | |
| ECOS.Optimizer : Clarabel.Optimizer | |
| const SOLVER_NAME = SOLVER === ECOS.Optimizer ? "ECOS" : "Clarabel" | |
| # Discretization selection: "euler" = paper's eq. 54 (default), | |
| # "foh" = matrix-exponential FOH (documented deviation, see header). | |
| const DISCRETIZATION = let d = Symbol(get(ENV, "SUSLU_DISC", "euler")) | |
| d in (:euler, :foh) || error("SUSLU_DISC must be 'euler' or 'foh'") | |
| d | |
| end | |
| # Trust-region update selection (see scvx docstring for why the | |
| # accept-always heuristic is the default in both discretizations). | |
| const TR_UPDATE = let t = Symbol(get(ENV, "SUSLU_TR", "stagnation")) | |
| t in (:ratio, :stagnation) || error("SUSLU_TR must be 'ratio' or 'stagnation'") | |
| t | |
| end | |
| ENV["GKSwstype"] = "100" | |
| using Plots | |
| # ─── Physical & vehicle constants (SI) ──────────────────────────────── | |
| const G0 = 9.80665 # m/s² | |
| const MU_SI = 4.902800066e12 # m³/s² (selenocentric) | |
| const R_MOON = 1.7374e6 # m | |
| const OMEGA_M = 2.6617e-6 # rad/s (sidereal rotation about z) | |
| const T_MAX_SI = 10_000.0 # N | |
| const I_SP = 300.0 # s | |
| const M_DRY = 400.0 # kg | |
| const M_PROP = 1600.0 # kg | |
| const M_WET = M_DRY + M_PROP # 2000 kg | |
| const ALPHA_SI = 1 / (I_SP * G0) # s/m, mass-flow coefficient | |
| # ─── Canonical units (μ = 1) ────────────────────────────────────────── | |
| const DU = R_MOON | |
| const TU = sqrt(DU^3 / MU_SI) # ≈ 1059 s | |
| const VU = DU / TU # ≈ 1640 m/s | |
| const AU = DU / TU^2 # ≈ 1.55 m/s² | |
| const ALPHA = ALPHA_SI * VU # canonical α | |
| const T_MAX = T_MAX_SI / AU # canonical thrust-magnitude (kg) | |
| # u_N ≤ T_MAX · exp(−z) with z = ln m_kg | |
| const OMEGA = OMEGA_M * TU # canonical moon angular velocity | |
| const Z_WET = log(M_WET) | |
| const Z_DRY = log(M_DRY) | |
| # ─── Orbital mechanics helpers (canonical units) ───────────────────── | |
| function R_z(θ); c, s = cos(θ), sin(θ); return [c -s 0.0; s c 0.0; 0.0 0.0 1.0]; end | |
| function R_x(θ); c, s = cos(θ), sin(θ); return [1.0 0.0 0.0; 0.0 c -s; 0.0 s c]; end | |
| """Classical elements → (r, v) in MCI. a in canonical DU, μ = 1, angles in rad.""" | |
| function elements_to_rv(a, e, i, Ω, ω, ν) | |
| p = a * (1 - e^2) | |
| r_mag = p / (1 + e*cos(ν)) | |
| r_pf = r_mag * [cos(ν), sin(ν), 0.0] | |
| v_pf = sqrt(1/p) * [-sin(ν), e + cos(ν), 0.0] | |
| Q = R_z(Ω) * R_x(i) * R_z(ω) | |
| return Q * r_pf, Q * v_pf | |
| end | |
| """Target vectors (h, N, e) from a representative (r, v) on the target orbit.""" | |
| function target_vectors(r, v) | |
| h = cross(r, v) | |
| N = cross([0.0, 0.0, 1.0], h) | |
| e = cross(v, h) - r / norm(r) | |
| return h, N, e | |
| end | |
| """(r, v) → classical orbital elements (a, e, i, Ω, ω) in canonical, μ = 1.""" | |
| function rv_to_elements(r, v) | |
| hv = cross(r, v); h = norm(hv) | |
| Nv = cross([0.0, 0.0, 1.0], hv); Nm = norm(Nv) | |
| ev = cross(v, hv) - r/norm(r); e = norm(ev) | |
| a = 1 / (2/norm(r) - dot(v, v)) | |
| i = acos(clamp(hv[3]/h, -1, 1)) | |
| if Nm > 1e-10 | |
| Ω = acos(clamp(Nv[1]/Nm, -1, 1)) | |
| if Nv[2] < 0; Ω = 2π - Ω; end | |
| else | |
| Ω = 0.0 | |
| end | |
| if Nm > 1e-10 && e > 1e-8 | |
| ω = acos(clamp(dot(Nv, ev) / (Nm*e), -1, 1)) | |
| if ev[3] < 0; ω = 2π - ω; end | |
| else | |
| ω = 0.0 | |
| end | |
| return (a=a, e=e, i=i, Ω=Ω, ω=ω) | |
| end | |
| """Launch point in MCI (= MCMF at t=0): from latitude, longitude (deg), altitude (m). | |
| Returns r (canonical) and v = ω_M × r (canonical, including moon-rotation).""" | |
| function launch_state(lat_deg, lon_deg, alt_m) | |
| λ = deg2rad(lat_deg); ϕ = deg2rad(lon_deg) | |
| r_mag = (R_MOON + alt_m) / DU | |
| r = r_mag * [cos(λ)*cos(ϕ), cos(λ)*sin(ϕ), sin(λ)] | |
| v = cross([0.0, 0.0, OMEGA], r) | |
| return r, v | |
| end | |
| """MCI position → MCMF latitude/longitude (deg) after canonical time t.""" | |
| function mci_to_latlon(r, t_canonical) | |
| θ = OMEGA * t_canonical | |
| c, s = cos(-θ), sin(-θ) | |
| r_mcmf = [c*r[1] - s*r[2], s*r[1] + c*r[2], r[3]] | |
| lat = asind(clamp(r_mcmf[3] / norm(r_mcmf), -1, 1)) | |
| lon = atand(r_mcmf[2], r_mcmf[1]) | |
| return lat, lon | |
| end | |
| # ─── Reference trajectory: gravity-turn integration ────────────────── | |
| """ | |
| Vern6 integration of (r, v, z) under full-thrust gravity-turn. | |
| For t < t_kick the thrust direction is fixed at the initial pitched-east | |
| unit vector T̂₀; afterward it tracks v̂ (the classical gravity-turn | |
| maneuver). When propellant is exhausted the vehicle coasts; the burnout | |
| switch is localized by a ContinuousCallback on z − z_dry. | |
| The kick duration is an initialization knob, not a paper parameter | |
| (Table 4 samples only pitch, azimuth, and t_f). The default is 30 s: | |
| with the integrator resolving the kick exactly, a 10 s kick leaves the | |
| nominal equatorial case feasibility-converged but creeping along the | |
| free-t_f direction too slowly to meet eq. 52 within 50 iterations. | |
| (The former hand-rolled RK4 at ~12 s steps never resolved a 10 s kick | |
| anyway, so its effective initialization was different.) | |
| Returns: x̄ (7×(N+1) reference state), ū (3×(N+1) reference thrust | |
| acceleration u = T/m in canonical units), p̄ (canonical t_f). | |
| """ | |
| function gravity_turn_reference(lat_deg, lon_deg, alt_m, t_f_s, N_intervals; | |
| pitch_deg=70.0, azimuth_deg=90.0, | |
| t_kick_s=30.0) | |
| r0, v0 = launch_state(lat_deg, lon_deg, alt_m) | |
| up = r0 / norm(r0) | |
| ϕ = deg2rad(lon_deg); λ = deg2rad(lat_deg) | |
| east = [-sin(ϕ), cos(ϕ), 0.0] | |
| north = cross(up, east) | |
| az = deg2rad(azimuth_deg) | |
| horiz = sin(az)*east + cos(az)*north | |
| pitch = deg2rad(pitch_deg) | |
| T̂0 = cos(pitch)*horiz + sin(pitch)*up | |
| p̄ = t_f_s / TU | |
| function thrust_dir(v, t_can) | |
| if t_can < t_kick_s / TU | |
| return T̂0 | |
| elseif norm(v) > 1e-6 | |
| return v / norm(v) | |
| else | |
| return T̂0 | |
| end | |
| end | |
| function thrust_accel(x, t_can) | |
| v = x[4:6]; z = x[7] | |
| if z > Z_DRY + 1e-6 | |
| u_mag = T_MAX / exp(z) | |
| return u_mag * thrust_dir(v, t_can), u_mag | |
| end | |
| return zeros(3), 0.0 | |
| end | |
| function rhs!(dx, x, _, t_can) | |
| u, u_N = thrust_accel(x, t_can) | |
| dx[1:3] = x[4:6] | |
| dx[4:6] = u .- x[1:3] ./ norm(x[1:3])^3 | |
| dx[7] = -ALPHA * u_N | |
| end | |
| ts = range(0.0, p̄; length=N_intervals+1) | |
| # No-op event at burnout so the step lands exactly on the thrust | |
| # cutoff instead of integrating across the rhs discontinuity. | |
| burnout = ContinuousCallback((x, t, integ) -> x[7] - (Z_DRY + 1e-6), | |
| nothing; affect_neg! = integ -> nothing, | |
| save_positions = (false, false)) | |
| prob = ODEProblem(rhs!, [r0; v0; Z_WET], (0.0, p̄)) | |
| sol = solve(prob, Vern6(); reltol=1e-10, abstol=1e-10, saveat=ts, | |
| tstops=[t_kick_s / TU], callback=burnout) | |
| x̄ = Array(sol) | |
| x̄[7, :] .= max.(x̄[7, :], Z_DRY) # floor for stability | |
| ū = zeros(3, N_intervals+1) | |
| for k in 1:N_intervals+1 | |
| ū[:, k], _ = thrust_accel(x̄[:, k], ts[k]) | |
| end | |
| return x̄, ū, p̄ | |
| end | |
| # ─── Terminal conditions: nonlinear residual and linearization ──────── | |
| """ | |
| Nonlinear terminal residual g(x_f) (zero ⟺ target orbit reached). | |
| mode = :CTC → eqs. 9–12 (4 quadratic equalities) | |
| mode = :GTC → eqs. 16–23 (8 equalities; the e-vector rows use the | |
| standard e = v × h − r/‖r‖ rather than the garbled printed | |
| expansions — see the file header). | |
| """ | |
| function terminal_residual(x_f, h_t, N_t, e_t, mode) | |
| r = x_f[1:3]; v = x_f[4:6] | |
| if mode == :CTC | |
| # For circular target: r_t = |h_t|² (μ = 1, since |h| = √(μ r) ⇒ r = h²), | |
| # cos i_t = h_tz / |h_t|. | |
| ht_mag = norm(h_t) | |
| r_t = ht_mag^2 | |
| cos_it = h_t[3] / ht_mag | |
| return [dot(r, r) - r_t^2, # eq. 9 | |
| dot(v, v) - 1/r_t, # eq. 10, μ = 1 | |
| dot(r, v), # eq. 11 | |
| r[1]*v[2] - r[2]*v[1] - sqrt(r_t)*cos_it] # eq. 12 | |
| else # :GTC — note N = k̂ × h = (−h_y, h_x, 0) | |
| h = cross(r, v) | |
| ev = cross(v, h) - r / norm(r) | |
| return [h[1] - h_t[1], # g1 = h_x | |
| h[2] - h_t[2], # g2 = h_y | |
| h[3] - h_t[3], # g3 = h_z | |
| -h[2] - N_t[1], # g4 = N_x | |
| h[1] - N_t[2], # g5 = N_y (= g1) | |
| ev[1] - e_t[1], | |
| ev[2] - e_t[2], | |
| ev[3] - e_t[3]] | |
| end | |
| end | |
| """ | |
| Build (H, l) such that H · x_f + l = 0 is the first-order linearization | |
| of the terminal conditions at the reference final state x̄_f (eq. 46). | |
| """ | |
| function terminal_linearization(x̄_f, h_t, N_t, e_t, mode) | |
| r̄ = x̄_f[1:3]; v̄ = x̄_f[4:6] | |
| g = terminal_residual(x̄_f, h_t, N_t, e_t, mode) | |
| if mode == :CTC | |
| H = zeros(4, 7) | |
| # g1 = r·r − r_t² (eq. 9) | |
| H[1, 1:3] = 2 .* r̄ | |
| # g2 = v·v − 1/r_t (eq. 10, μ = 1) | |
| H[2, 4:6] = 2 .* v̄ | |
| # g3 = r·v (eq. 11) | |
| H[3, 1:3] = v̄; H[3, 4:6] = r̄ | |
| # g4 = r_x v_y − r_y v_x − √r_t · cos i_t (eq. 12) | |
| H[4, 1] = v̄[2]; H[4, 2] = -v̄[1] | |
| H[4, 4] = -r̄[2]; H[4, 5] = r̄[1] | |
| else # :GTC — eight constraints, N = k̂ × h = (−h_y, h_x, 0) | |
| H = zeros(8, 7) | |
| set!(row, dr, dv) = (H[row, 1:3] = dr; H[row, 4:6] = dv) | |
| # g1: r_y v_z − r_z v_y − h_tx | |
| set!(1, [0.0, v̄[3], -v̄[2]], [0.0, -r̄[3], r̄[2]]) | |
| # g2: r_z v_x − r_x v_z − h_ty | |
| set!(2, [-v̄[3], 0.0, v̄[1]], [r̄[3], 0.0, -r̄[1]]) | |
| # g3: r_x v_y − r_y v_x − h_tz | |
| set!(3, [v̄[2], -v̄[1], 0.0], [-r̄[2], r̄[1], 0.0]) | |
| # g4: r_x v_z − r_z v_x − N_tx | |
| set!(4, [v̄[3], 0.0, -v̄[1]], [-r̄[3], 0.0, r̄[1]]) | |
| # g5: r_y v_z − r_z v_y − N_ty (same expression as g1) | |
| set!(5, [0.0, v̄[3], -v̄[2]], [0.0, -r̄[3], r̄[2]]) | |
| # g6–g8: eccentricity-vector components. | |
| # Using bac-cab, e = r(v·v) − v(r·v) − r/‖r‖ (μ = 1), so | |
| # ∂e_i/∂r_j = δ_ij (v² − 1/‖r‖) − v_i v_j + r_i r_j /‖r‖³ | |
| # ∂e_i/∂v_j = 2 r_i v_j − δ_ij (r·v) − v_i r_j | |
| # (matches appendix eqs. A8–A23, A27–A42 elementwise). | |
| rmag = norm(r̄); rv = dot(r̄, v̄); v2 = dot(v̄, v̄) | |
| for i in 1:3 | |
| drow = [((i==j) ? (v2 - 1/rmag) : 0.0) - v̄[i]*v̄[j] + r̄[i]*r̄[j]/rmag^3 for j in 1:3] | |
| vrow = [2*r̄[i]*v̄[j] - ((i==j) ? rv : 0.0) - v̄[i]*r̄[j] for j in 1:3] | |
| set!(5+i, drow, vrow) | |
| end | |
| end | |
| l = g - H * x̄_f | |
| return H, l | |
| end | |
| # ─── State Jacobian J_x = ∂f/∂x for the linearized dynamics ────────── | |
| function jac_x(r̄) | |
| r = norm(r̄) | |
| J = zeros(7, 7) | |
| J[1:3, 4:6] = I(3) # ṙ = v | |
| J[4:6, 1:3] = -I(3)/r^3 + 3 .* (r̄*r̄') / r^5 # ∂v̇/∂r = ∂(−r/r³)/∂r | |
| # ż depends on the slack u_N only — no state derivative | |
| return J | |
| end | |
| # ─── Nonlinear propagation over one normalized-time interval ───────── | |
| """ | |
| Vern6 integration of the nonlinear dynamics f̂ = p·f over one Δτ interval, | |
| with FOH interpolation of (u, u_N) between the endpoint controls. | |
| """ | |
| function propagate_interval(x0, u0, uN0, u1, uN1, p, Δτ) | |
| T = p * Δτ | |
| function f!(dx, x, _, t) | |
| s = t / T | |
| u = (1 - s) .* u0 .+ s .* u1 | |
| uN = (1 - s) * uN0 + s * uN1 | |
| dx[1:3] = x[4:6] | |
| dx[4:6] = u .- x[1:3] ./ norm(x[1:3])^3 | |
| dx[7] = -ALPHA * uN | |
| end | |
| prob = ODEProblem(f!, copy(x0), (0.0, T)) | |
| sol = solve(prob, Vern6(); reltol=1e-10, abstol=1e-10, | |
| save_everystep=false, save_start=false) | |
| return sol.u[end] | |
| end | |
| # ─── Matrix-exponential FOH discretization (deviation, see header) ─── | |
| """ | |
| Exact first-order-hold discretization of the linearized dynamics over | |
| one interval, with A frozen at the interval's left node: | |
| x_{k+1} = A_d x_k + B⁻ (u_k; u_Nk) + B⁺ (u_{k+1}; u_Nk+1) + F_d p + r_d | |
| The Van Loan block exponential of G = [A I 0; 0 0 I; 0 0 0]·Δτ yields | |
| A_d = e^{AΔτ} together with the integrals | |
| I₀ = ∫₀^Δτ e^{A(Δτ−s)} ds and I₁ = ∫₀^Δτ e^{A(Δτ−s)} s ds, | |
| from which B⁻ = (I₀ − I₁/Δτ)B and B⁺ = (I₁/Δτ)B. r_d is chosen so the | |
| model reproduces the *nonlinear* propagation of the reference across | |
| the interval, i.e. the virtual control measures a multiple-shooting | |
| defect instead of an Euler defect. | |
| """ | |
| function foh_discretize(x̄k, ūk, ūNk, ūk1, ūNk1, p̄, Δτ) | |
| n = 7 | |
| # Floor the radius used in the frozen Jacobian: intermediate SCP | |
| # iterates can place reference nodes at nonphysical near-zero radii, | |
| # where exp(A Δτ) ~ e^{p̄Δτ/r³} overflows (the Euler model degrades | |
| # only polynomially there). 0.3 DU is far below any physical state. | |
| r̄k = x̄k[1:3] | |
| if norm(r̄k) < 0.3 | |
| r̄k = r̄k .* (0.3 / norm(r̄k)) | |
| end | |
| A_c = p̄ .* jac_x(r̄k) | |
| B_c = p̄ .* J_U | |
| F_c = [x̄k[4:6]; ūk .- x̄k[1:3] ./ norm(x̄k[1:3])^3; -ALPHA * ūNk] | |
| G = zeros(3n, 3n) | |
| G[1:n, 1:n] = A_c | |
| G[1:n, n+1:2n] = I(n) | |
| G[n+1:2n, 2n+1:3n] = I(n) | |
| E = exp(G .* Δτ) | |
| A_d = E[1:n, 1:n] | |
| I0 = E[1:n, n+1:2n] | |
| I1 = E[1:n, 2n+1:3n] | |
| Bm = (I0 .- I1 ./ Δτ) * B_c | |
| Bp = (I1 ./ Δτ) * B_c | |
| F_d = I0 * F_c | |
| xprop = propagate_interval(x̄k, ūk, ūNk, ūk1, ūNk1, p̄, Δτ) | |
| r_d = xprop - A_d * x̄k - Bm * [ūk; ūNk] - Bp * [ūk1; ūNk1] - F_d * p̄ | |
| return A_d, Bm, Bp, F_d, r_d | |
| end | |
| # Control Jacobian (constant): ∂f/∂[u; u_N] = [0 0; I 0; 0 −α] | |
| const J_U = begin | |
| M = zeros(7, 4) | |
| M[4:6, 1:3] = I(3) | |
| M[7, 4] = -ALPHA | |
| M | |
| end | |
| # ─── SCvx subproblem ──────────────────────────────────────────────── | |
| """ | |
| Solve one SCvx subproblem at reference (x̄, ū, ūN, p̄). | |
| LCvx (eqs. 39, 48) replaces ‖T‖ = u_N with ‖u‖ ≤ u_N and linearizes the | |
| upper bound u_N ≤ T_max e^(−z̄)(1 − (z − z̄)) + ν_s. | |
| Dynamics use eq. 54 (FOH average-of-endpoints, explicit Euler in the | |
| state) by default; discretization=:foh switches to the | |
| matrix-exponential FOH of foh_discretize() — a documented deviation. | |
| Trust region (eq. 51) caps ‖u − ū‖∞ ≤ η. Terminal constraint | |
| H x_N + l + ν_f = 0 (eq. 49). Objective per eq. 55 (mass-max + L1 | |
| virtual-control penalty). | |
| monotonic_alt=true adds the Sec. IV.C / Fig. 7 variant: altitude is | |
| forced nondecreasing via the linearized radial constraint | |
| r̄ₖ·(r_{k+1} − r_k) ≥ 0. | |
| """ | |
| function solve_subproblem(x̄, ū, ūN, p̄, h_t, N_t, e_t, mode; | |
| η=1.0, η_p=0.50, λ=1e3, fix_p=false, | |
| p_max=Inf, monotonic_alt=false, | |
| discretization=:euler) | |
| Nn = size(x̄, 2) # number of nodes | |
| Ni = Nn - 1 # intervals | |
| Δτ = 1.0 / Ni | |
| model = Model(SOLVER) | |
| set_silent(model) | |
| if SOLVER === ECOS.Optimizer | |
| set_optimizer_attribute(model, "maxit", 200) | |
| end | |
| @variable(model, x[1:7, 1:Nn]) | |
| @variable(model, u[1:3, 1:Nn]) | |
| @variable(model, uN[1:Nn] >= 0) | |
| @variable(model, p >= 0) | |
| if fix_p | |
| @constraint(model, p == p̄) | |
| else | |
| # Per-iter trust region on p (paper's eq. 51 omits this, but it's | |
| # needed in practice to avoid artificial unboundedness in t_f). | |
| @constraint(model, p - p̄ <= η_p * p̄) | |
| @constraint(model, p̄ - p <= η_p * p̄) | |
| # Global ceiling — prevents runaway across many iterations. | |
| if isfinite(p_max) | |
| @constraint(model, p <= p_max) | |
| end | |
| end | |
| # Virtual controls + L1 epigraphs | |
| @variable(model, ν[1:7, 1:Ni]) | |
| @variable(model, νs[1:Nn] >= 0) | |
| n_term = (mode == :CTC) ? 4 : 8 | |
| @variable(model, νf[1:n_term]) | |
| @variable(model, abs_ν[1:7, 1:Ni] >= 0) | |
| @variable(model, abs_νf[1:n_term] >= 0) | |
| @constraint(model, [i=1:7, k=1:Ni], ν[i, k] <= abs_ν[i, k]) | |
| @constraint(model, [i=1:7, k=1:Ni], -ν[i, k] <= abs_ν[i, k]) | |
| @constraint(model, [i=1:n_term], νf[i] <= abs_νf[i]) | |
| @constraint(model, [i=1:n_term], -νf[i] <= abs_νf[i]) | |
| # Initial state (eq. 6–8, lifted via launch_state into the reference) | |
| @constraint(model, x[:, 1] .== x̄[:, 1]) | |
| # Dynamics linearization. Both forms place ν inside a Δτ factor so | |
| # ν has the same per-unit-τ meaning (and penalty weight) in each. | |
| for k in 1:Ni | |
| if discretization == :foh | |
| # Matrix-exponential FOH (deviation from eq. 54, see header) | |
| A_d, Bm, Bp, F_d, r_d = foh_discretize( | |
| x̄[:, k], ū[:, k], ūN[k], ū[:, k+1], ūN[k+1], p̄, Δτ) | |
| rhs = A_d * x[:, k] + Bm * [u[:, k]; uN[k]] + | |
| Bp * [u[:, k+1]; uN[k+1]] + F_d * p + r_d + Δτ .* ν[:, k] | |
| @constraint(model, x[:, k+1] .== rhs) | |
| else | |
| # eq. 54: explicit Euler in the state, FOH average controls | |
| rbar = x̄[1:3, k]; vbar = x̄[4:6, k] | |
| u_ref = ū[:, k]; uN_ref = ūN[k] | |
| Ak = p̄ .* jac_x(rbar) | |
| Bk = p̄ .* J_U | |
| # f̂_k at the reference, which equals F_k · p̄ (eq. A4, with α-sign fix) | |
| Fk = [vbar; | |
| u_ref - rbar / norm(rbar)^3; | |
| -ALPHA * uN_ref] | |
| # r_k = f̂(x̄,ū,p̄) − A x̄ − B ū − F p̄ = −A x̄ − B ū (cancels with f̂) | |
| rk = -Ak * x̄[:, k] - Bk * [u_ref; uN_ref] | |
| u_avg = 0.5 * ([u[:, k]; uN[k]] + [u[:, k+1]; uN[k+1]]) | |
| rhs = x[:, k] + Δτ * (Ak * x[:, k] + Bk * u_avg + Fk * p + rk + ν[:, k]) | |
| @constraint(model, x[:, k+1] .== rhs) | |
| end | |
| end | |
| # Thrust constraints (eqs. 39, 48) at every node | |
| for k in 1:Nn | |
| @constraint(model, [uN[k]; u[:, k]] in SecondOrderCone()) | |
| zbar = x̄[7, k] | |
| @constraint(model, | |
| uN[k] - T_MAX * exp(-zbar) * (1 - (x[7, k] - zbar)) <= νs[k]) | |
| end | |
| # Mass floor (eq. 36) | |
| @constraint(model, x[7, Nn] >= Z_DRY) | |
| # Trust region (eq. 51) on control vector (u; uN) | |
| for k in 1:Nn | |
| @constraint(model, | |
| [η; u[:, k] .- ū[:, k]; uN[k] - ūN[k]] | |
| in MOI.NormInfinityCone(5)) | |
| end | |
| # Optional Fig.-7 variant: altitude nondecreasing, linearized as | |
| # d‖r‖ ≈ r̄·dr / ‖r̄‖ ≥ 0 along the reference. | |
| if monotonic_alt | |
| for k in 1:Ni | |
| rbar = x̄[1:3, k] | |
| @constraint(model, dot(rbar, x[1:3, k+1] .- x[1:3, k]) >= 0) | |
| end | |
| end | |
| # Terminal linearization (eq. 49) | |
| H, l = terminal_linearization(x̄[:, Nn], h_t, N_t, e_t, mode) | |
| @constraint(model, H * x[:, Nn] + l + νf .== 0) | |
| # Objective — exactly eq. 55: J = −z_N + λ‖ν_f‖₁ + λ Σ(‖νᵢ‖₁ + ‖ν_si‖₁). | |
| # (No Δτ on the sum; eq. 55 drops it when discretizing eq. 50.) | |
| @objective(model, Min, | |
| -x[7, Nn] | |
| + λ * sum(abs_νf) | |
| + λ * (sum(abs_ν) + sum(νs))) | |
| optimize!(model) | |
| st = termination_status(model) | |
| ok = st in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED, MOI.ALMOST_OPTIMAL) | |
| if !ok | |
| return (ok=false, status=st) | |
| end | |
| return (ok=true, status=st, | |
| x = value.(x), u = value.(u), uN = value.(uN), p = value(p), | |
| obj = objective_value(model), | |
| ν_max = maximum(abs.(value.(ν))), | |
| νs_max = maximum(value.(νs)), | |
| νf_max = maximum(abs.(value.(νf)))) | |
| end | |
| # ─── Exact penalty function for the SCvx ratio test ────────────────── | |
| """ | |
| Nonlinear penalized cost matching eq. 55 with the virtual controls | |
| replaced by the actual constraint violations: | |
| • dynamics defect, matched to the active discretization: | |
| :euler ν_i = (x_{i+1} − x_i − Δτ p f(x_i, ū_avg)) / Δτ (ν sits | |
| inside the Δτ bracket in eq. 54, hence the division); | |
| :foh ν_i = (x_{i+1} − Φ(x_i, u_i, u_{i+1}, p)) / Δτ, the | |
| multiple-shooting defect against nonlinear propagation; | |
| • LCvx upper-bound violation ν_si = max(0, u_N − T_max e^{−z}); | |
| • terminal residual ν_f = g(x_N). | |
| At a reference point this equals the linearized subproblem objective, | |
| which is what makes the ratio test well-posed. | |
| """ | |
| function penalized_cost(x, u, uN, p, h_t, N_t, e_t, mode, λ; | |
| discretization=:euler) | |
| Nn = size(x, 2); Ni = Nn - 1; Δτ = 1.0 / Ni | |
| J = -x[7, Nn] | |
| for k in 1:Ni | |
| if discretization == :foh | |
| xprop = propagate_interval(x[:, k], u[:, k], uN[k], | |
| u[:, k+1], uN[k+1], p, Δτ) | |
| J += (λ / Δτ) * sum(abs, x[:, k+1] .- xprop) | |
| else | |
| u_avg = 0.5 .* (u[:, k] .+ u[:, k+1]) | |
| uN_avg = 0.5 * (uN[k] + uN[k+1]) | |
| rk = x[1:3, k] | |
| f = [x[4:6, k]; u_avg .- rk ./ norm(rk)^3; -ALPHA * uN_avg] | |
| J += (λ / Δτ) * sum(abs, x[:, k+1] .- x[:, k] .- (Δτ * p) .* f) | |
| end | |
| end | |
| for k in 1:Nn | |
| J += λ * max(0.0, uN[k] - T_MAX * exp(-x[7, k])) | |
| end | |
| J += λ * sum(abs, terminal_residual(x[:, Nn], h_t, N_t, e_t, mode)) | |
| return J | |
| end | |
| # ─── SCvx outer loop ──────────────────────────────────────────────── | |
| """ | |
| SCvx outer loop. Convergence (eq. 52) additionally requires the | |
| virtual controls to vanish — "virtual terms must be zero for a | |
| converged solution" (Sec. III.D). | |
| Two trust-region update rules are available via `tr_update`: | |
| :ratio The SCvx rule of ref. [43] that the paper cites: | |
| ρ = (actual reduction of the exact penalty) / | |
| (predicted reduction by the convex subproblem); | |
| reject the step and shrink η when ρ < ρ₀, grow η when | |
| ρ ≥ ρ₂. | |
| :stagnation (default) Accept every step; starting from η = 1, halve | |
| η (floor 0.12) whenever Δx fails to decrease for three | |
| consecutive iterations. | |
| DEVIATION FROM THE PAPER: the default is :stagnation, not the cited | |
| ratio rule. On this problem the exact-penalty ratio test measured | |
| ρ ≈ 0.3–0.5 almost everywhere (the quadratic terminal constraints make | |
| the linear model consistently over-optimistic), so η shrinks until | |
| feasibility progress — rate-limited by the per-node control trust | |
| region — no longer fits in the paper's 50-iteration budget; with an η | |
| floor it instead deadlocks re-solving an identical rejected subproblem. | |
| Accepting every step exploits the Gauss-Newton character of the | |
| re-linearized terminal constraints and reproduces the paper's iteration | |
| counts. ρ is still computed and logged in both modes. | |
| """ | |
| function scvx(x̄, ū, ūN, p̄, h_t, N_t, e_t, mode; | |
| max_iter=50, ε=3e-3, feas_tol=1e-6, η=1.0, η_p=0.50, λ=1e3, | |
| fix_p=false, p_max=Inf, monotonic_alt=false, | |
| tr_update=:stagnation, discretization=:euler, verbose=true) | |
| # :ratio parameters (standard SCvx values). No useful η floor | |
| # exists: a floor deadlocks on a rejected step (same reference and | |
| # same η reproduce the same candidate), so it is kept tiny. | |
| ρ_0, ρ_1, ρ_2 = 0.0, 0.25, 0.7 | |
| β_shrink, β_grow = 2.0, 2.0 | |
| η_min, η_max = 1e-4, 10.0 | |
| # :stagnation parameters. | |
| η_floor = 0.12 | |
| stagnation = 0 | |
| prev_Δx = Inf | |
| history = NamedTuple[] | |
| converged = false | |
| last = nothing | |
| η_cur = η | |
| J_ref = penalized_cost(x̄, ū, ūN, p̄, h_t, N_t, e_t, mode, λ; | |
| discretization=discretization) | |
| for it in 1:max_iter | |
| ηp_cur = η_p * η_cur / η # p trust region scales with η | |
| res = solve_subproblem(x̄, ū, ūN, p̄, h_t, N_t, e_t, mode; | |
| η=η_cur, η_p=ηp_cur, λ=λ, | |
| fix_p=fix_p, p_max=p_max, | |
| monotonic_alt=monotonic_alt, | |
| discretization=discretization) | |
| if !res.ok | |
| verbose && @printf(" iter %2d solver FAILED: %s\n", it, res.status) | |
| push!(history, (iter=it, ok=false, status=res.status, accepted=false, | |
| ρ=NaN, η=η_cur, Δx=NaN, Δp=NaN, | |
| ν=NaN, νs=NaN, νf=NaN, p=p̄)) | |
| return (ok=false, x=x̄, u=ū, p=p̄, last=last, history=history) | |
| end | |
| J_new = penalized_cost(res.x, res.u, res.uN, res.p, | |
| h_t, N_t, e_t, mode, λ; | |
| discretization=discretization) | |
| pred = J_ref - res.obj # ≥ 0: reference is subproblem-feasible | |
| actual = J_ref - J_new | |
| ρ = pred > 1e-9 ? actual / pred : 1.0 | |
| Δx = maximum(abs.(res.x .- x̄)) | |
| Δp = abs(res.p - p̄) | |
| accepted = tr_update == :ratio ? ρ >= ρ_0 : true | |
| verbose && @printf(" iter %2d %s ρ=%+.3f η=%.3f Δx=%.3e Δp=%.3e νmax=%.2e νs=%.2e νf=%.2e p=%.4f\n", | |
| it, accepted ? "acc" : "REJ", ρ, η_cur, | |
| Δx, Δp, res.ν_max, res.νs_max, res.νf_max, res.p) | |
| push!(history, (iter=it, ok=true, status=res.status, accepted=accepted, | |
| ρ=ρ, η=η_cur, Δx=Δx, Δp=Δp, | |
| ν=res.ν_max, νs=res.νs_max, νf=res.νf_max, p=res.p)) | |
| if accepted | |
| last = res | |
| x̄ = res.x; ū = res.u; ūN = res.uN; p̄ = res.p | |
| J_ref = J_new | |
| feasible = max(res.ν_max, res.νs_max, res.νf_max) <= feas_tol | |
| if Δx + Δp <= ε && feasible | |
| converged = true | |
| verbose && @printf(" converged at iter %d\n", it) | |
| break | |
| end | |
| end | |
| if tr_update == :ratio | |
| if ρ < ρ_1 | |
| η_cur = max(η_cur / β_shrink, η_min) | |
| elseif ρ >= ρ_2 | |
| η_cur = min(η_cur * β_grow, η_max) | |
| end | |
| else | |
| # Shrink trust region if Δx isn't improving (limit-cycle guard). | |
| if Δx > 0.9 * prev_Δx | |
| stagnation += 1 | |
| else | |
| stagnation = 0 | |
| end | |
| if stagnation >= 3 && η_cur > η_floor | |
| η_cur *= 0.5 | |
| stagnation = 0 | |
| verbose && @printf(" [trust region tightened to η=%.3f η_p=%.3f]\n", | |
| η_cur, η_p * η_cur / η) | |
| end | |
| prev_Δx = Δx | |
| end | |
| end | |
| return (ok=converged, x=x̄, u=ū, p=p̄, last=last, history=history) | |
| end | |
| # ─── Post-convergence nonlinear validation ──────────────────────────── | |
| """ | |
| Propagate the converged FOH control profile through the full nonlinear | |
| dynamics (eqs. 1–3 in (u, z) form) with Vern6. The SCP state satisfies | |
| only the linearized, Euler-discretized dynamics of eq. 54, so this | |
| quantifies the true discretization/linearization defect. | |
| """ | |
| function propagate_nonlinear(x0, u, uN, p) | |
| Nn = size(u, 2); Ni = Nn - 1; Δτ = 1.0 / Ni | |
| x = copy(x0) | |
| for k in 1:Ni | |
| x = propagate_interval(x, u[:, k], uN[k], u[:, k+1], uN[k+1], p, Δτ) | |
| end | |
| return x | |
| end | |
| # ─── Ballistic two-body propagation (Vern6) for visualizing the orbit ─ | |
| function propagate_twobody(r0, v0; T_span_can, N_pts=400) | |
| function f!(dx, x, _, _t) | |
| dx[1:3] = x[4:6] | |
| dx[4:6] = -x[1:3] ./ norm(x[1:3])^3 | |
| end | |
| prob = ODEProblem(f!, [r0; v0], (0.0, T_span_can)) | |
| sol = solve(prob, Vern6(); reltol=1e-10, abstol=1e-10, | |
| saveat=range(0.0, T_span_can; length=N_pts+1)) | |
| return Array(sol)[1:3, :] | |
| end | |
| # ─── Scenario configurations ───────────────────────────────────────── | |
| struct Scenario | |
| name :: String | |
| lat0_deg :: Float64 | |
| lon0_deg :: Float64 | |
| alt0_m :: Float64 | |
| a_t_km :: Float64 | |
| e_t :: Float64 | |
| i_t_deg :: Float64 | |
| Ω_t_deg :: Float64 | |
| ω_t_deg :: Float64 | |
| t_f_s :: Float64 | |
| N_nodes :: Int | |
| t_f_fixed :: Bool # paper fixes t_f only for scenario 1 | |
| end | |
| const SCENARIOS = [ | |
| Scenario("inclined_circular", 0.0, -90.0, 0.0, | |
| R_MOON/1000 + 200, 0.0, 60.0, 280.0, 0.0, 1200.0, 200, true), | |
| Scenario("equatorial_circular", 60.0, -90.0, 0.0, | |
| R_MOON/1000 + 200, 0.0, 0.0, 0.0, 0.0, 2400.0, 200, false), | |
| Scenario("elliptic", 20.0, -90.0, 0.0, | |
| 3087.4, 0.37248, 40.0, 290.0, 90.0, 2400.0, 200, false), | |
| ] | |
| function run_case(sc::Scenario, mode::Symbol; max_iter=50, verbose=true, | |
| pitch_deg=70.0, azimuth_deg=90.0, t_f_s=sc.t_f_s, | |
| monotonic_alt=false, discretization=DISCRETIZATION, | |
| tr_update=TR_UPDATE, label=sc.name) | |
| if verbose | |
| @printf("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") | |
| @printf(" %-22s [%s]\n", label, mode) | |
| @printf("───────────────────────────────────────────────────────────────\n") | |
| end | |
| a_can = sc.a_t_km * 1000 / DU | |
| r_t, v_t = elements_to_rv(a_can, sc.e_t, | |
| deg2rad(sc.i_t_deg), deg2rad(sc.Ω_t_deg), deg2rad(sc.ω_t_deg), 0.0) | |
| h_t, N_t, e_t_vec = target_vectors(r_t, v_t) | |
| verbose && @printf(" target a=%.1f km e=%.5f i=%.2f° Ω=%.2f° ω=%.2f°\n", | |
| sc.a_t_km, sc.e_t, sc.i_t_deg, sc.Ω_t_deg, sc.ω_t_deg) | |
| x̄, ū, p̄ = gravity_turn_reference(sc.lat0_deg, sc.lon0_deg, sc.alt0_m, | |
| t_f_s, sc.N_nodes - 1; | |
| pitch_deg=pitch_deg, azimuth_deg=azimuth_deg) | |
| ūN = vec(sqrt.(sum(ū.^2; dims=1))) # LCvx slack reference = ‖ū‖ | |
| # Cap t_f at 1.5× the initial guess to suppress artificial unboundedness. | |
| p_max = sc.t_f_fixed ? Inf : 1.5 * p̄ | |
| t0 = time() | |
| res = scvx(x̄, ū, ūN, p̄, h_t, N_t, e_t_vec, mode; | |
| max_iter=max_iter, fix_p=sc.t_f_fixed, p_max=p_max, | |
| monotonic_alt=monotonic_alt, discretization=discretization, | |
| tr_update=tr_update, verbose=verbose) | |
| elapsed = time() - t0 | |
| if res.ok | |
| elems = rv_to_elements(res.x[1:3, end], res.x[4:6, end]) | |
| m_final = exp(res.x[7, end]) | |
| if verbose | |
| @printf(" → CONVERGED t_f = %.1f s m_f = %.2f kg iters = %d wall = %.2f s\n", | |
| res.p * TU, m_final, length(res.history), elapsed) | |
| @printf(" achieved a=%.1f km e=%.5f i=%.3f° Ω=%.3f° ω=%.3f°\n", | |
| elems.a*DU/1000, elems.e, rad2deg(elems.i), | |
| rad2deg(elems.Ω), rad2deg(elems.ω)) | |
| # Validate against the full nonlinear dynamics (the SCP state | |
| # only satisfies the eq.-54 linearized Euler discretization). | |
| x_nl = propagate_nonlinear(res.x[:, 1], res.last.u, res.last.uN, res.p) | |
| g_nl = terminal_residual(x_nl, h_t, N_t, e_t_vec, mode) | |
| e_nl = rv_to_elements(x_nl[1:3], x_nl[4:6]) | |
| @printf(" nonlinear-prop check ‖g‖∞=%.2e a=%.1f km e=%.5f i=%.3f° Ω=%.3f° Δr_f=%.2f km\n", | |
| maximum(abs.(g_nl)), e_nl.a*DU/1000, e_nl.e, | |
| rad2deg(e_nl.i), rad2deg(e_nl.Ω), | |
| norm(x_nl[1:3] - res.x[1:3, end])*DU/1000) | |
| end | |
| elseif verbose | |
| n_iter = length(res.history) | |
| last_status = isempty(res.history) ? :unknown : res.history[end].status | |
| if res.last !== nothing | |
| @printf(" → DID NOT CONVERGE iters = %d last νf = %.2e last νs = %.2e\n", | |
| n_iter, res.last.νf_max, res.last.νs_max) | |
| # report what the linearized terminal residual achieved | |
| elems = rv_to_elements(res.last.x[1:3, end], res.last.x[4:6, end]) | |
| @printf(" last-iter approx orbit a=%.1f km e=%.4f i=%.2f° Ω=%.2f°\n", | |
| elems.a*DU/1000, elems.e, rad2deg(elems.i), rad2deg(elems.Ω)) | |
| else | |
| @printf(" → FAILED at first iteration (%s)\n", last_status) | |
| end | |
| end | |
| return (sc=sc, mode=mode, res=res, h_t=h_t, elapsed=elapsed, label=label) | |
| end | |
| # ─── Plotting ──────────────────────────────────────────────────────── | |
| function plot_trajectory(case; outdir=".") | |
| res = case.res; sc = case.sc; label = case.label | |
| res.ok || return | |
| # res.last is the final SOCP subproblem solution (has uN, the LCvx slack); | |
| # res.x/u/p are the converged reference, which equals last.x/u/p modulo ε. | |
| lastsol = res.last | |
| x = lastsol.x; p = lastsol.p | |
| Nn = size(x, 2) | |
| ts_can = collect(0:Nn-1) ./ (Nn-1) .* p | |
| ts_si = ts_can .* TU | |
| r_si = x[1:3, :] .* DU | |
| v_si = x[4:6, :] .* VU | |
| m_si = exp.(x[7, :]) | |
| alt = vec(sqrt.(sum(r_si.^2; dims=1))) .- R_MOON | |
| vmag = vec(sqrt.(sum(v_si.^2; dims=1))) | |
| # Recover thrust magnitude: T = m * ‖u_SI‖, where u_SI = u_canonical * AU | |
| u_si_mag = lastsol.uN .* AU # since LCvx is tight, uN ≈ ‖u‖ | |
| T_kN = m_si .* u_si_mag ./ 1000 | |
| # Propagate the achieved orbit one period for the figure | |
| elems = rv_to_elements(x[1:3, end], x[4:6, end]) | |
| a_can = elems.a | |
| T_orbit = 2π * sqrt(a_can^3) # canonical, μ=1 | |
| rs_prop = propagate_twobody(x[1:3, end], x[4:6, end]; | |
| T_span_can=T_orbit, N_pts=400) .* DU | |
| # 3D MCI trajectory + propagated orbit + moon sphere | |
| u_sph = range(0, 2π; length=40); v_sph = range(0, π; length=20) | |
| xs = [cos(u)*sin(v)*R_MOON/1000 for u in u_sph, v in v_sph] | |
| ys = [sin(u)*sin(v)*R_MOON/1000 for u in u_sph, v in v_sph] | |
| zs = [cos(v) *R_MOON/1000 for u in u_sph, v in v_sph] | |
| p3d = surface(xs, ys, zs; color=:lightgray, alpha=0.35, label="", | |
| xlabel="x_MCI [km]", ylabel="y_MCI [km]", zlabel="z_MCI [km]", | |
| title="$(label) [$(case.mode)] — 3D MCI", legend=:topright) | |
| plot!(p3d, r_si[1, :]./1000, r_si[2, :]./1000, r_si[3, :]./1000; | |
| color=:blue, linewidth=2.2, label="Ascent") | |
| plot!(p3d, rs_prop[1, :]./1000, rs_prop[2, :]./1000, rs_prop[3, :]./1000; | |
| color=:blue, linestyle=:dash, label="Propagated orbit") | |
| savefig(p3d, joinpath(outdir, "$(label)_$(case.mode)_3d.png")) | |
| # Ground track | |
| lats = Float64[]; lons = Float64[] | |
| for k in 1:Nn | |
| la, lo = mci_to_latlon(x[1:3, k], ts_can[k]) | |
| push!(lats, la); push!(lons, lo) | |
| end | |
| # propagated ground track: time continues past the burn | |
| lats_p = Float64[]; lons_p = Float64[] | |
| Nprop = size(rs_prop, 2) | |
| ts_p_can = ts_can[end] .+ collect(0:Nprop-1) ./ (Nprop-1) .* T_orbit | |
| for k in 1:Nprop | |
| la, lo = mci_to_latlon(rs_prop[1:3, k] ./ DU, ts_p_can[k]) | |
| push!(lats_p, la); push!(lons_p, lo) | |
| end | |
| p_gt = plot(lons, lats; color=:blue, linewidth=2.2, label="Ascent", | |
| xlabel="Longitude [°]", ylabel="Latitude [°]", | |
| xlims=(-180, 180), ylims=(-90, 90), | |
| title="$(label) [$(case.mode)] — ground track", | |
| legend=:topright) | |
| scatter!(p_gt, [sc.lon0_deg], [sc.lat0_deg]; | |
| color=:black, marker=:star, label="Launch") | |
| # Drawn as dots rather than a line so the ±180° wrap doesn't streak | |
| scatter!(p_gt, lons_p, lats_p; color=:blue, label="Propagated", | |
| markersize=1, markerstrokewidth=0) | |
| savefig(p_gt, joinpath(outdir, "$(label)_$(case.mode)_groundtrack.png")) | |
| # Profiles (altitude, velocity, thrust, mass) | |
| p1 = plot(ts_si, alt./1000; xlabel="Time [s]", ylabel="Altitude [km]", | |
| color=:blue, linewidth=2, label="") | |
| p2 = plot(ts_si, vmag./1000; xlabel="Time [s]", ylabel="Velocity [km/s]", | |
| color=:blue, linewidth=2, label="") | |
| p3 = plot(ts_si, T_kN; xlabel="Time [s]", ylabel="Thrust [kN]", | |
| color=:blue, linewidth=2, label="", ylims=(-0.5, 11)) | |
| p4 = plot(ts_si, m_si; xlabel="Time [s]", ylabel="Mass [kg]", | |
| color=:blue, linewidth=2, label="") | |
| p_prof = plot(p1, p2, p3, p4; layout=(2, 2), size=(900, 600), | |
| plot_title="$(label) [$(case.mode)] — profiles") | |
| savefig(p_prof, joinpath(outdir, "$(label)_$(case.mode)_profiles.png")) | |
| @printf(" saved 3D, ground track, and profile plots\n") | |
| end | |
| """Fig.-7-style overlay: lofted vs monotonically increasing altitude.""" | |
| function plot_fig7(case_lofted, case_mono; outdir=".") | |
| (case_lofted.res.ok && case_mono.res.ok) || return | |
| function series(case) | |
| x = case.res.last.x; p = case.res.last.p | |
| Nn = size(x, 2) | |
| ts = collect(0:Nn-1) ./ (Nn-1) .* p .* TU | |
| alt = vec(sqrt.(sum((x[1:3, :] .* DU).^2; dims=1))) .- R_MOON | |
| vmag = vec(sqrt.(sum((x[4:6, :] .* VU).^2; dims=1))) | |
| m = exp.(x[7, :]) | |
| T_kN = m .* case.res.last.uN .* AU ./ 1000 | |
| return ts, alt, vmag, m, T_kN | |
| end | |
| t1, a1, v1, m1, T1 = series(case_lofted) | |
| t2, a2, v2, m2, T2 = series(case_mono) | |
| lab1 = "Lofted"; lab2 = "Monotonically increasing" | |
| p1 = plot(t1, a1./1000; label=lab1, color=:blue, linewidth=2, | |
| xlabel="Time [s]", ylabel="Altitude [km]") | |
| plot!(p1, t2, a2./1000; label=lab2, color=:red, linewidth=2) | |
| p2 = plot(t1, v1./1000; label=lab1, color=:blue, linewidth=2, | |
| xlabel="Time [s]", ylabel="Velocity [km/s]") | |
| plot!(p2, t2, v2./1000; label=lab2, color=:red, linewidth=2) | |
| p3 = plot(t1, T1; label=lab1, color=:blue, linewidth=2, | |
| xlabel="Time [s]", ylabel="Thrust [kN]", ylims=(-0.5, 11)) | |
| plot!(p3, t2, T2; label=lab2, color=:red, linewidth=2) | |
| p4 = plot(t1, m1; label=lab1, color=:blue, linewidth=2, | |
| xlabel="Time [s]", ylabel="Mass [kg]") | |
| plot!(p4, t2, m2; label=lab2, color=:red, linewidth=2) | |
| p_all = plot(p1, p2, p3, p4; layout=(2, 2), size=(900, 600), | |
| plot_title="equatorial_circular [GTC] — lofted vs monotonic (Fig. 7)") | |
| savefig(p_all, joinpath(outdir, "equatorial_circular_GTC_fig7.png")) | |
| @printf(" saved Fig.-7 comparison plot\n") | |
| end | |
| # ─── Monte Carlo of Table 4/5 (equatorial circular scenario) ───────── | |
| """ | |
| Replicates the paper's convergence analysis: n samples with uniform | |
| pitch ∈ [60°, 90°], launch azimuth ∈ [−180°, 180°], t_f guess | |
| ∈ [1800 s, 2600 s] (Table 4). Paper result (Table 5, 500 samples): | |
| GTC 48.2 % convergence (iters min 8 / mean 21 / max 50), CTC 0 %. | |
| """ | |
| function monte_carlo(sc::Scenario, mode::Symbol, n; rng=Random.Xoshiro(1)) | |
| @printf("\n━━━ Monte Carlo %-22s [%s] n=%d ━━━\n", sc.name, mode, n) | |
| iters = Int[] | |
| n_conv = 0 | |
| for s in 1:n | |
| pitch = 60.0 + 30.0 * rand(rng) | |
| azim = -180.0 + 360.0 * rand(rng) | |
| tf = 1800.0 + 800.0 * rand(rng) | |
| c = run_case(sc, mode; verbose=false, | |
| pitch_deg=pitch, azimuth_deg=azim, t_f_s=tf) | |
| conv = c.res.ok | |
| n_conv += conv | |
| conv && push!(iters, length(c.res.history)) | |
| @printf(" sample %3d/%d pitch=%5.1f° az=%+7.1f° t_f0=%6.1f s → %s\n", | |
| s, n, pitch, azim, tf, | |
| conv ? @sprintf("converged (%d iters)", length(c.res.history)) : | |
| "no convergence") | |
| end | |
| @printf(" %s convergence rate: %.1f %% (paper: %s)\n", | |
| mode, 100 * n_conv / n, mode == :GTC ? "48.2 %" : "0 %") | |
| if !isempty(iters) | |
| @printf(" iterations of converged runs: min %d / mean %.1f / max %d (paper GTC: 8 / 21 / 50)\n", | |
| minimum(iters), sum(iters)/length(iters), maximum(iters)) | |
| end | |
| end | |
| # ─── Main ──────────────────────────────────────────────────────────── | |
| function main() | |
| @printf("\nSüslü & Söken (2026) — lunar ascent SCvx replication\n") | |
| @printf("Solver: %s Discretization: %s%s Trust region: %s\n", | |
| SOLVER_NAME, DISCRETIZATION, | |
| DISCRETIZATION == :foh ? " (matrix-exponential FOH — deviates from eq. 54)" : " (eq. 54)", | |
| TR_UPDATE) | |
| @printf("Canonical units: DU=%.1f km, TU=%.2f s, VU=%.2f m/s, AU=%.4f m/s²\n", | |
| DU/1000, TU, VU, AU) | |
| outdir = @__DIR__ | |
| cases = [] | |
| # GTC for all three scenarios | |
| for sc in SCENARIOS | |
| push!(cases, run_case(sc, :GTC)) | |
| end | |
| # CTC for all three scenarios — paper documents failure for 2 & 3 | |
| for sc in SCENARIOS | |
| c = run_case(sc, :CTC) | |
| push!(cases, c) | |
| # Sec. IV.C: on equatorial CTC failure the paper retries with a | |
| # coarser N = 50 discretization (and fails again). | |
| if !c.res.ok && sc.name == "equatorial_circular" | |
| sc50 = Scenario(sc.name * "_N50", sc.lat0_deg, sc.lon0_deg, | |
| sc.alt0_m, sc.a_t_km, sc.e_t, sc.i_t_deg, | |
| sc.Ω_t_deg, sc.ω_t_deg, sc.t_f_s, 50, sc.t_f_fixed) | |
| push!(cases, run_case(sc50, :CTC; label=sc50.name)) | |
| end | |
| end | |
| # Fig. 7: rerun the equatorial GTC case with the altitude forced to | |
| # be monotonically increasing; the lofted trajectory should retain | |
| # more final mass (paper: 673.6 kg lofted vs 645.7 kg monotonic). | |
| sc_eq = SCENARIOS[2] | |
| case_mono = run_case(sc_eq, :GTC; monotonic_alt=true, | |
| label=sc_eq.name * "_mono") | |
| push!(cases, case_mono) | |
| case_lofted = cases[2] | |
| if case_lofted.res.ok && case_mono.res.ok | |
| @printf("\n Fig. 7 comparison: lofted m_f = %.1f kg vs monotonic m_f = %.1f kg\n", | |
| exp(case_lofted.res.x[7, end]), exp(case_mono.res.x[7, end])) | |
| end | |
| @printf("\n━━━━━━━━━━━━━━━━━━━━━━━━ SUMMARY ━━━━━━━━━━━━━━━━━━━━━━━━\n") | |
| @printf("%-26s %-4s %-12s %-10s %-8s %-8s %-6s\n", | |
| "scenario", "mode", "status", "t_f [s]", "m_f [kg]", "wall [s]", "iters") | |
| for c in cases | |
| n_iter = length(c.res.history) | |
| if c.res.ok | |
| mf = exp(c.res.x[7, end]) | |
| @printf("%-26s %-4s %-12s %-10.1f %-8.1f %-8.2f %-6d\n", | |
| c.label, c.mode, "converged", c.res.p*TU, mf, c.elapsed, n_iter) | |
| else | |
| @printf("%-26s %-4s %-12s %-10s %-8s %-8.2f %-6d\n", | |
| c.label, c.mode, "FAILED", "—", "—", c.elapsed, n_iter) | |
| end | |
| end | |
| # Plot only the successful cases | |
| for c in cases | |
| plot_trajectory(c; outdir=outdir) | |
| end | |
| plot_fig7(case_lofted, case_mono; outdir=outdir) | |
| @printf("\nPaper benchmarks (for reference):\n") | |
| @printf(" inclined_circular [GTC]: a=1937.4 km Ω=280.0° (CTC: Ω=270.034°)\n") | |
| @printf(" equatorial_circular [GTC]: converges 48.2%% over 500 MC samples;\n") | |
| @printf(" CTC: 0%% convergence\n") | |
| @printf(" elliptic [GTC]: a=3087.4 km e=0.37248 i=40° Ω=290° ω=90°\n") | |
| @printf(" Fig. 7: lofted m_f = 673.6 kg vs monotonic m_f = 645.7 kg\n") | |
| # Optional Monte Carlo (Table 4/5), e.g. SUSLU_MC=50 julia suslu-soken-2026.jl | |
| n_mc = parse(Int, get(ENV, "SUSLU_MC", "0")) | |
| if n_mc > 0 | |
| monte_carlo(SCENARIOS[2], :GTC, n_mc) | |
| monte_carlo(SCENARIOS[2], :CTC, n_mc) | |
| end | |
| end | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment