journal/ directory, LaTeX-based, dated entries, callout boxes for
derivations / decisions / dead ends / limitations, plus an \apass{}
macro for in-line markers when a later deep-pass is needed.
Retroactive A-style entries for 2026-04-17 (controllers, linearization,
LQR, operation-mode linear reach, Lyapunov barrier) and 2026-04-20
(predicates restructure into deadbands+safety+invariants, OL-vs-CL
barrier analysis, mode-obligation taxonomy, heatup-rate-as-halfspace,
mode_boundaries, first Julia nonlinear reach attempt).
Both entries include derivations written out in math, dead-ends I
hit, code snippets with commentary, figure embeds, and terminal
output where it changed what we did next. The goal is invention-log
depth — readable 4 years from now without the git history to help.
journal/README.md documents the conventions. journal.tex aggregates
all entries into one PDF via latexmk.
Kept claude_memory/ separate as per earlier agreement — those are
short AI-context notes, different audience.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
4.1 KiB
Julia
119 lines
4.1 KiB
Julia
#!/usr/bin/env julia
|
|
#
|
|
# barrier_lyapunov.jl — Lyapunov-ellipsoid barrier cert.
|
|
# Julia port of reachability/barrier_lyapunov.m.
|
|
#
|
|
# Sweeps Qbar(T_c) weight to find the tightest quadratic barrier;
|
|
# reports per-halfspace margins against inv2_holds.
|
|
#
|
|
# Expected result: every halfspace exceeded by orders of magnitude.
|
|
# This is the structural anisotropy limitation, not a code bug.
|
|
|
|
using Pkg
|
|
Pkg.activate(joinpath(@__DIR__, ".."))
|
|
|
|
using Printf
|
|
using LinearAlgebra
|
|
using MatrixEquations
|
|
using JSON
|
|
|
|
include(joinpath(@__DIR__, "..", "src", "pke_params.jl"))
|
|
include(joinpath(@__DIR__, "..", "src", "pke_th_rhs.jl"))
|
|
include(joinpath(@__DIR__, "..", "src", "pke_linearize.jl"))
|
|
include(joinpath(@__DIR__, "..", "src", "load_predicates.jl"))
|
|
|
|
plant = pke_params()
|
|
x_op = pke_initial_conditions(plant)
|
|
pred = load_predicates(plant)
|
|
|
|
# --- Closed-loop ---
|
|
A, B, B_w, _, _, _ = pke_linearize(plant)
|
|
Q_lqr = Diagonal([1.0, 1e-3, 1e-3, 1e-3, 1e-3, 1e-3, 1e-3, 1e-2, 1e2, 1.0])
|
|
R_lqr = 1e6 * ones(1, 1)
|
|
X_ric, _, _ = arec(A, reshape(B, :, 1), R_lqr, Matrix(Q_lqr))
|
|
K = (R_lqr \ reshape(B, 1, :)) * X_ric
|
|
A_cl = A - reshape(B, :, 1) * K
|
|
|
|
# --- Lyapunov ---
|
|
# lyapc solves A' P + P A + Q = 0 for P. Julia's MatrixEquations uses
|
|
# lyapc(A', Q) to solve A' P + P A = -Q (note: sign conventions vary).
|
|
# Check: for a Hurwitz A, lyapc(A, Q) with Q > 0 returns P > 0.
|
|
Qbar = Diagonal([1.0, 1e-4, 1e-4, 1e-4, 1e-4, 1e-4, 1e-4, 1.0, 1e2, 1.0])
|
|
P = lyapc(Matrix(A_cl)', Matrix(Qbar))
|
|
@assert all(eigvals(P) .> 0) "P not positive definite"
|
|
|
|
# --- Disturbance bound ---
|
|
w_bar = 0.15 * plant.P0
|
|
|
|
# Invariant level via the derivation in the MATLAB file.
|
|
P_half = Hermitian(sqrt(Hermitian(P)))
|
|
P_half_inv = inv(P_half)
|
|
mu = minimum(eigvals(Symmetric(P_half_inv * Matrix(Qbar) * P_half_inv)))
|
|
g_bound = sqrt(B_w' * P * B_w)
|
|
c_inv = (2 * w_bar * g_bound / mu)^2
|
|
|
|
delta_entry = [0.01 * x_op[1];
|
|
0.001 .* abs.(x_op[2:7]);
|
|
0.1; 0.1; 0.1]
|
|
c_entry = delta_entry' * abs.(P) * delta_entry
|
|
|
|
gamma = max(c_inv, c_entry)
|
|
|
|
println("\n=== Lyapunov barrier certificate ===")
|
|
@printf " lambda_min(P) = %.3e\n" minimum(eigvals(P))
|
|
@printf " lambda_max(P) = %.3e\n" maximum(eigvals(P))
|
|
@printf " sqrt(B_w' P B_w) = %.3e\n" g_bound
|
|
@printf " mu (Qbar eig on P-metric)= %.3e\n" mu
|
|
@printf " w_bar (15%% P0) = %.3e W\n" w_bar
|
|
@printf " c_inv (invariant level) = %.3e\n" c_inv
|
|
@printf " c_entry (from X_entry) = %.3e\n" c_entry
|
|
@printf " gamma = %.3e\n" gamma
|
|
|
|
# --- inv2_holds per-halfspace barrier check ---
|
|
inv2 = pred.mode_invariants[:inv2_holds]
|
|
A_inv = inv2.A_poly
|
|
b_inv = inv2.b_poly
|
|
comps = inv2.components
|
|
b_dev = b_inv .- A_inv * x_op
|
|
|
|
Pinv = inv(P)
|
|
println("\n=== Lyapunov barrier vs inv2_holds halfspaces ===")
|
|
for k in 1:size(A_inv, 1)
|
|
a = A_inv[k, :]
|
|
max_adx = sqrt(gamma * (a' * Pinv * a))
|
|
margin = b_dev[k] - max_adx
|
|
status = margin < 0 ? "*** TOO LOOSE ***" : "OK"
|
|
@printf " [%-22s] headroom = %8.3f | max a'dx = %8.3f | margin = %+8.3f %s\n" comps[k] b_dev[k] max_adx margin status
|
|
end
|
|
|
|
# --- Qbar(T_c) sweep for tightness ---
|
|
println("\n=== Qbar(T_c) weight sweep ===")
|
|
e9 = zeros(10); e9[9] = 1.0
|
|
weights = [1e1, 1e2, 1e3, 1e4, 1e5, 1e6]
|
|
let best_dTc = Inf, best_w = NaN, best_gamma = NaN, best_P = nothing
|
|
for wTc in weights
|
|
Qbs = copy(Matrix(Qbar))
|
|
Qbs[9, 9] = wTc
|
|
Ps = try
|
|
lyapc(Matrix(A_cl)', Qbs)
|
|
catch
|
|
continue
|
|
end
|
|
any(eigvals(Ps) .<= 0) && continue
|
|
Phs = Hermitian(sqrt(Hermitian(Ps)))
|
|
Phi = inv(Phs)
|
|
mu_s = minimum(eigvals(Symmetric(Phi * Qbs * Phi)))
|
|
g_s = sqrt(B_w' * Ps * B_w)
|
|
ci_s = (2 * w_bar * g_s / mu_s)^2
|
|
ce_s = delta_entry' * abs.(Ps) * delta_entry
|
|
gs = max(ci_s, ce_s)
|
|
Pinv_s = inv(Ps)
|
|
dTc = sqrt(gs * (e9' * Pinv_s * e9))
|
|
@printf " Qbar(9,9) = %.0e -> gamma = %.3e, max|dT_c| = %7.3f K\n" wTc gs dTc
|
|
if dTc < best_dTc
|
|
best_dTc = dTc; best_w = wTc; best_gamma = gs; best_P = Ps
|
|
end
|
|
end
|
|
@printf " Best: Qbar(9,9) = %.0e -> max|dT_c| = %.3f K\n" best_w best_dTc
|
|
end
|