PWR-HYBRID-3/code/scripts/reach/reach_scram_pj_fat.jl
Dane Sabo c5133401e0 Session work scratch: scram X_exit refactor, hot-standby SOS, fat scram tubes, model cheatsheet, journal entry
Multi-session work bundle on a draft branch.  Splits into a clean
sequence of commits later; pushed here so it isn't lost on a reboot.

Reach work
- code/scripts/reach/reach_scram_pj.jl: shutdown_margin halfspace
  X_exit (replaces "n <= 1e-4 AND T_f bound" framing); per-step
  envelope extraction added.
- code/scripts/reach/reach_scram_pj_fat.jl: per-step envelope
  extraction added; shutdown_margin discharge logic mirrored from the
  tight scram script.  3 probes (10/30/60s) all discharge from the
  fat union polytope.
- code/scripts/reach/reach_scram_full_fat.jl (NEW): full nonlinear
  PKE scram reach with fat entry.  Hits the stiffness wall at
  ~1.5 s plant time as expected; saves NaN-tolerant per-step
  envelopes.  Demonstrates concretely why PJ is the right tool for
  the longer-horizon proof.
- code/scripts/reach/reach_heatup_pj.jl: T_REF_START_C constant
  (entry-conditioned ramp) replaces T_STANDBY-init that was making
  the FL controller command cooling at t=0.  Per-step extraction
  already in place.
- code/configs/heatup/tight.toml: bumped maxsteps; probe horizon
  parameterized.

Hot-standby SOS barrier
- code/scripts/barrier/barrier_sos_2d_shutdown.jl (NEW): mirrors the
  operation SOS machinery on the hot-standby thermal projection.
  Includes the eps-slack pattern (so feasibility doesn't silently
  collapse to B == 0).
- code/scripts/barrier/barrier_sos_2d.jl: refactored to use the same
  helper.
- code/src/sos_barrier.jl (NEW): solve_sos_barrier_2d helper module
  factoring out the SOS construction; eps-slack with eps_cap=1.0 to
  avoid unbounded primal.

Library
- code/src/pke_states.jl (NEW): single source of truth for canonical
  initial-condition vectors per DRC mode (op, shutdown, heatup) keyed
  off plant + predicates.
- code/scripts/sim/{main_mode_sweep,validate_pj}.jl, code/CLAUDE.md:
  migrated to pke_states.

Predicates + invariants
- reachability/predicates.json: new shutdown_margin predicate (1%
  dk/k tech-spec floor, expressed as alpha_f*T_f + alpha_c*T_c
  halfspace).  Used as scram X_exit.

Plot script
- code/scripts/plot/plot_reach_tubes.jl: plot_tubes_scram_pj() with
  variant=:fat|:tight knob; plot_tubes_scram_full() for full-PKE
  3-panel (T_c, T_f, rho); plot_tubes_heatup_pj() reads results/
  not reachability/.

Journal + memory
- journal/entries/2026-04-27-shutdown-sos-and-scram-X_exit.tex (NEW):
  long-form entry on the SOS hot-standby barrier and the scram X_exit
  refactor.
- journal/journal.tex: input chain updated.
- claude_memory/ — three new session notes:
  * 2026-04-27-scram-X_exit-shutdown-margin.md
  * 2026-04-28-DICE-2026-conference-intel.md (people, sessions,
    strategic notes for the May 12 talk)
  * 2026-04-28-path1-sos-pj-sketch.md (sketch of nonlinear-SOS via
    polynomial multiply-through; saved for an overnight session)

Docs
- docs/model_cheatsheet.md (NEW): one-page reference of state vector,
  dynamics, constants, modes, predicates, sanity numbers — the talk
  prep cheatsheet Dane asked for.
- docs/figures/reach_*_tubes.png: regenerated with the new mat data.
- presentations/prelim-presentation/outline.md: revised arc per the
  April-28 review pass (cuts: Lyapunov-fails standalone slide,
  operation-tube standalone slide, SOS standalone; adds: scopes-of-
  control framing, scram on the headline result slide).
- app/predicate_explorer.jl: minor.

Hacker-Split: end-of-session scratch bundle
2026-05-02 23:02:50 -04:00

306 lines
13 KiB
Julia
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env julia
#
# reach_scram_pj_fat.jl — scram reach from the union of all mode-entry
# reach envelopes + the LOCA scenario envelope.
#
# Morning-review point 2: the real scram X_entry is the set of all
# states the plant could plausibly be in across every mode + accident
# scenarios. Computes the bounding-box hull of:
#
# 1. Hot-standby IC box (narrow, from mode_boundaries.q_shutdown.X_entry_polytope)
# 2. Heatup reach envelope (from results/reach_heatup_pj_tight.mat)
# 3. Operation reach envelope (from results/reach_operation_result.mat)
# 4. LOCA reach final-state envelope (from results/reach_loca_operation.mat)
#
# Then runs scram PJ on the fat X_entry and reports per-halfspace result.
using Pkg
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using LinearAlgebra
using Printf
using ReachabilityAnalysis, LazySets
using JSON
using MAT
# Plant constants.
const LAMBDA = 1e-4
const BETA_1, BETA_2, BETA_3, BETA_4, BETA_5, BETA_6 =
0.000215, 0.001424, 0.001274, 0.002568, 0.000748, 0.000273
const BETA = BETA_1 + BETA_2 + BETA_3 + BETA_4 + BETA_5 + BETA_6
const LAM_1, LAM_2, LAM_3, LAM_4, LAM_5, LAM_6 =
0.0124, 0.0305, 0.111, 0.301, 1.14, 3.01
const P0 = 1e9
const M_F, C_F, M_C, C_C, HA, W_M, M_SG =
50000.0, 300.0, 20000.0, 5450.0, 5e7, 5000.0, 30000.0
const ALPHA_F, ALPHA_C = -2.5e-5, -1e-4
const T_COLD0 = 290.0
const DT_CORE = P0 / (W_M * C_C)
const T_HOT0 = T_COLD0 + DT_CORE
const T_C0 = (T_HOT0 + T_COLD0) / 2
const T_F0 = T_C0 + P0 / HA
const U_SCRAM = -8 * BETA
const Q_SG_DECAY = 0.03 * P0
# X_exit threshold: shutdown_margin halfspace, mirrors predicates.json.
const RHO_SDM = 0.01 # 1% dk/k
const SDM_RHS = -RHO_SDM - U_SCRAM + ALPHA_F*T_F0 + ALPHA_C*T_C0 # ≈ 0.00297
# Taylorized scram RHS (same as reach_scram_pj.jl).
@taylorize function rhs_scram_fat_taylor!(dx, x, p, t)
rho = U_SCRAM + ALPHA_F * (x[7] - T_F0) + ALPHA_C * (x[8] - T_C0)
sum_lam_C = LAM_1*x[1] + LAM_2*x[2] + LAM_3*x[3] +
LAM_4*x[4] + LAM_5*x[5] + LAM_6*x[6]
denom = BETA - rho
n = LAMBDA * sum_lam_C / denom
inv_factor = sum_lam_C / denom
dx[1] = BETA_1 * inv_factor - LAM_1 * x[1]
dx[2] = BETA_2 * inv_factor - LAM_2 * x[2]
dx[3] = BETA_3 * inv_factor - LAM_3 * x[3]
dx[4] = BETA_4 * inv_factor - LAM_4 * x[4]
dx[5] = BETA_5 * inv_factor - LAM_5 * x[5]
dx[6] = BETA_6 * inv_factor - LAM_6 * x[6]
dx[7] = (P0 * n - HA * (x[7] - x[8])) / (M_F * C_F)
dx[8] = (HA * (x[7] - x[8]) - 2 * W_M * C_C * (x[8] - x[9])) / (M_C * C_C)
dx[9] = (2 * W_M * C_C * (x[8] - x[9]) - Q_SG_DECAY) / (M_SG * C_C)
dx[10] = one(x[1])
return nothing
end
# --- Build fat X_entry from union of reach envelopes ---
results_dir = joinpath(@__DIR__, "..", "..", "..", "results")
# 1. Hot-standby box from predicates.json.
pred_raw = JSON.parsefile(joinpath(@__DIR__, "..", "..", "..", "reachability", "predicates.json"))
sh_entry = pred_raw["mode_boundaries"]["q_shutdown"]["X_entry_polytope"]
hs_n_lo, hs_n_hi = sh_entry["n_range"]
hs_Tf_lo, hs_Tf_hi = sh_entry["T_f_range_C"]
hs_Tc_lo, hs_Tc_hi = sh_entry["T_c_range_C"]
hs_Tcold_lo, hs_Tcold_hi = sh_entry["T_cold_range_C"]
# Precursor bounds at hot-standby: C_i = β_i/(λ_i·Λ) · n, with n in hs_n_range.
function C_range_for_n(n_lo, n_hi)
C_lo = [BETA_1/(LAM_1*LAMBDA)*n_lo, BETA_2/(LAM_2*LAMBDA)*n_lo,
BETA_3/(LAM_3*LAMBDA)*n_lo, BETA_4/(LAM_4*LAMBDA)*n_lo,
BETA_5/(LAM_5*LAMBDA)*n_lo, BETA_6/(LAM_6*LAMBDA)*n_lo]
C_hi = [BETA_1/(LAM_1*LAMBDA)*n_hi, BETA_2/(LAM_2*LAMBDA)*n_hi,
BETA_3/(LAM_3*LAMBDA)*n_hi, BETA_4/(LAM_4*LAMBDA)*n_hi,
BETA_5/(LAM_5*LAMBDA)*n_hi, BETA_6/(LAM_6*LAMBDA)*n_hi]
return C_lo, C_hi
end
shutdown_C_lo, shutdown_C_hi = C_range_for_n(hs_n_lo, hs_n_hi)
shutdown_lo = [shutdown_C_lo; hs_Tf_lo; hs_Tc_lo; hs_Tcold_lo] # 9 entries
shutdown_hi = [shutdown_C_hi; hs_Tf_hi; hs_Tc_hi; hs_Tcold_hi]
# 2. Heatup tight envelope (read from results/reach_heatup_pj_tight.mat).
heatup_path = joinpath(results_dir, "reach_heatup_pj_tight.mat")
heatup_lo = fill(NaN, 9); heatup_hi = fill(NaN, 9)
if isfile(heatup_path)
h = matread(heatup_path)
# Use the longest-horizon probe's envelope (T=300 or whatever's there).
Tprobes = sort(collect([parse(Int, replace(split(k, "_")[2], ".0" => "")) for k in keys(h) if startswith(k, "T_") && endswith(k, "_Tc_lo_ts")]))
if !isempty(Tprobes)
T = Tprobes[end]
pre = "T_$(T)_"
heatup_lo = [minimum(vec(h[pre*"Tf_lo_ts"])) - 5, # pad slightly
fill(0.0, 6)...] # 6 Cs from full-state operating range
# Rebuild more carefully:
heatup_C_lo, heatup_C_hi = C_range_for_n(1e-3, 2e-3)
heatup_lo = [heatup_C_lo; minimum(vec(h[pre*"Tf_lo_ts"]));
minimum(vec(h[pre*"Tc_lo_ts"])); minimum(vec(h[pre*"Tco_lo_ts"]))]
heatup_hi = [heatup_C_hi; maximum(vec(h[pre*"Tf_hi_ts"]));
maximum(vec(h[pre*"Tc_hi_ts"])); maximum(vec(h[pre*"Tco_hi_ts"]))]
end
else
println("Warning: $heatup_path not found; using hot-standby as fallback.")
heatup_lo = shutdown_lo; heatup_hi = shutdown_hi
end
# 3. Operation reach envelope from results/reach_operation_result.mat.
op_path = joinpath(results_dir, "reach_operation_result.mat")
op_lo = fill(NaN, 9); op_hi = fill(NaN, 9)
if isfile(op_path)
o = matread(op_path)
X_lo_op = o["X_lo"]; X_hi_op = o["X_hi"] # 10 × M
# Drop row 1 (n) for the 9-state PJ scram model.
op_lo = [minimum(X_lo_op[i, :]) for i in 2:10]
op_hi = [maximum(X_hi_op[i, :]) for i in 2:10]
end
# 4. LOCA operation reach final envelope.
loca_path = joinpath(results_dir, "reach_loca_operation.mat")
loca_lo = fill(NaN, 9); loca_hi = fill(NaN, 9)
if isfile(loca_path)
l = matread(loca_path)
X_env_lo = vec(l["X_envelope_lo"]) # 10 entries
X_env_hi = vec(l["X_envelope_hi"])
loca_lo = X_env_lo[2:10] # drop n
loca_hi = X_env_hi[2:10]
end
# Union = element-wise min/max across all sources.
sources = [
("shutdown", shutdown_lo, shutdown_hi),
("heatup", heatup_lo, heatup_hi),
("operation", op_lo, op_hi),
("loca", loca_lo, loca_hi),
]
fat_lo = fill(+Inf, 9); fat_hi = fill(-Inf, 9)
for (name, lo, hi) in sources
any(isnan, lo) || any(isnan, hi) && continue
for i in 1:9
fat_lo[i] = min(fat_lo[i], lo[i])
fat_hi[i] = max(fat_hi[i], hi[i])
end
end
# LOCA values are absurd on precursors (linear reach numeric blowup) — clamp
# C_i bounds to something physically plausible. Cap at 1000× the
# operating-point C values (generous).
C_clamp_hi = [BETA_i/(LAM_i*LAMBDA) * 1.2e0 for (BETA_i, LAM_i) in
zip([BETA_1,BETA_2,BETA_3,BETA_4,BETA_5,BETA_6],
[LAM_1,LAM_2,LAM_3,LAM_4,LAM_5,LAM_6])]
C_clamp_lo = [BETA_i/(LAM_i*LAMBDA) * 1e-7 for (BETA_i, LAM_i) in
zip([BETA_1,BETA_2,BETA_3,BETA_4,BETA_5,BETA_6],
[LAM_1,LAM_2,LAM_3,LAM_4,LAM_5,LAM_6])]
for i in 1:6
fat_lo[i] = max(fat_lo[i], C_clamp_lo[i])
fat_hi[i] = min(fat_hi[i], C_clamp_hi[i])
end
# Temperatures: clamp to model's trust region ~[200, 400] °C.
for i in 7:9
fat_lo[i] = max(fat_lo[i], 200.0)
fat_hi[i] = min(fat_hi[i], 400.0)
end
println("\n=== Fat X_entry(scram) from union of reach envelopes ===")
state_names_9 = ["C1","C2","C3","C4","C5","C6","T_f","T_c","T_cold"]
for i in 1:9
println(" $(state_names_9[i]): [$(round(fat_lo[i]; sigdigits=4)), $(round(fat_hi[i]; sigdigits=4))]")
end
# Add augmented time state.
x_lo_full = [fat_lo; 0.0]
x_hi_full = [fat_hi; 0.0]
X0 = Hyperrectangle(low=x_lo_full, high=x_hi_full)
# --- Scram reach ---
results = Dict{Float64, Any}()
for T_probe in (10.0, 30.0, 60.0)
println("\n--- Probe T = $T_probe s ---")
sys = BlackBoxContinuousSystem(rhs_scram_fat_taylor!, 10)
prob = InitialValueProblem(sys, X0)
try
alg = TMJets(orderT=4, orderQ=2, abstol=1e-9, maxsteps=100000)
t0 = time()
sol = solve(prob; T=T_probe, alg=alg)
elapsed = time() - t0
flow = flowpipe(sol)
flow_hr = overapproximate(flow, Hyperrectangle)
n_sets = length(flow_hr)
println(" TMJets: $n_sets reach-sets in $(round(elapsed; digits=1)) s")
# --- Per-step envelopes for plotting tubes ---
t_arr = zeros(n_sets)
n_lo_ts = zeros(n_sets); n_hi_ts = zeros(n_sets)
rho_lo_ts = zeros(n_sets); rho_hi_ts = zeros(n_sets)
Tc_lo_ts = zeros(n_sets); Tc_hi_ts = zeros(n_sets)
Tf_lo_ts = zeros(n_sets); Tf_hi_ts = zeros(n_sets)
for (k, R) in enumerate(flow_hr)
s = set(R)
t_arr[k] = high(s, 10)
sumLC_lo_k = LAM_1*low(s,1) + LAM_2*low(s,2) + LAM_3*low(s,3) +
LAM_4*low(s,4) + LAM_5*low(s,5) + LAM_6*low(s,6)
sumLC_hi_k = LAM_1*high(s,1) + LAM_2*high(s,2) + LAM_3*high(s,3) +
LAM_4*high(s,4) + LAM_5*high(s,5) + LAM_6*high(s,6)
rho_lo_k = U_SCRAM + ALPHA_F*(high(s,7) - T_F0) + ALPHA_C*(high(s,8) - T_C0)
rho_hi_k = U_SCRAM + ALPHA_F*(low(s,7) - T_F0) + ALPHA_C*(low(s,8) - T_C0)
denom_lo_k = BETA - rho_hi_k
denom_hi_k = BETA - rho_lo_k
n_lo_ts[k] = denom_lo_k > 0 ? LAMBDA * sumLC_lo_k / denom_hi_k : NaN
n_hi_ts[k] = denom_lo_k > 0 ? LAMBDA * sumLC_hi_k / denom_lo_k : NaN
rho_lo_ts[k] = rho_lo_k
rho_hi_ts[k] = rho_hi_k
Tc_lo_ts[k] = low(s, 8); Tc_hi_ts[k] = high(s, 8)
Tf_lo_ts[k] = low(s, 7); Tf_hi_ts[k] = high(s, 7)
end
last = set(flow_hr[end])
sumLC_lo = LAM_1*low(last,1) + LAM_2*low(last,2) + LAM_3*low(last,3) +
LAM_4*low(last,4) + LAM_5*low(last,5) + LAM_6*low(last,6)
sumLC_hi = LAM_1*high(last,1) + LAM_2*high(last,2) + LAM_3*high(last,3) +
LAM_4*high(last,4) + LAM_5*high(last,5) + LAM_6*high(last,6)
rho_lo = U_SCRAM + ALPHA_F*(low(last,7) - T_F0) + ALPHA_C*(high(last,8) - T_C0)
rho_hi = U_SCRAM + ALPHA_F*(high(last,7) - T_F0) + ALPHA_C*(low(last,8) - T_C0)
denom_lo = BETA - rho_hi
denom_hi = BETA - rho_lo
n_lo = denom_lo > 0 ? LAMBDA * sumLC_lo / denom_hi : NaN
n_hi = denom_lo > 0 ? LAMBDA * sumLC_hi / denom_lo : NaN
# shutdown_margin discharge: alpha_f*T_f + alpha_c*T_c ≤ SDM_RHS.
# Coefficients negative → sup over the box at low(T_f), low(T_c).
sdm_lhs_hi = ALPHA_F*low(last,7) + ALPHA_C*low(last,8)
sdm_ok = sdm_lhs_hi <= SDM_RHS
println(" n envelope: [$(round(n_lo; sigdigits=4)), $(round(n_hi; sigdigits=4))]")
println(" T_c envelope: [$(round(low(last,8); digits=2)), $(round(high(last,8); digits=2))] °C")
println(" T_f envelope: [$(round(low(last,7); digits=2)), $(round(high(last,7); digits=2))] °C")
println(" rho envelope: [$(round(rho_lo; sigdigits=4)), $(round(rho_hi; sigdigits=4))] (shutdown margin = $(round(-rho_hi; sigdigits=4)) dk/k)")
println(" shutdown_margin LHS sup: $(round(sdm_lhs_hi; sigdigits=4)) vs RHS $(round(SDM_RHS; sigdigits=4))$(sdm_ok ? "✓ DISCHARGED" : "× not yet")")
results[T_probe] = (status="OK", n=(n_lo, n_hi), elapsed=elapsed,
rho=(rho_lo, rho_hi), sdm_lhs_hi=sdm_lhs_hi, sdm_ok=sdm_ok,
t_arr=t_arr,
n_lo_ts=n_lo_ts, n_hi_ts=n_hi_ts,
rho_lo_ts=rho_lo_ts, rho_hi_ts=rho_hi_ts,
Tc_lo_ts=Tc_lo_ts, Tc_hi_ts=Tc_hi_ts,
Tf_lo_ts=Tf_lo_ts, Tf_hi_ts=Tf_hi_ts)
catch err
println(" FAILED: ", first(sprint(showerror, err), 300))
results[T_probe] = (status="FAILED",)
break
end
end
println("\n=== Summary ===")
for T_probe in (10.0, 30.0, 60.0)
haskey(results, T_probe) || continue
r = results[T_probe]
if r.status == "OK"
ok_str = r.sdm_ok ? "✓ shutdown_margin DISCHARGED" : "× shutdown_margin not yet"
@printf " T = %4.0f s: n ∈ [%.3e, %.3e] ρ ∈ [%.4f, %.4f] %s\n" T_probe r.n[1] r.n[2] r.rho[1] r.rho[2] ok_str
else
println(" T = $T_probe s: FAILED")
end
end
mat_out = joinpath(results_dir, "reach_scram_pj_fat.mat")
saved = Dict{String,Any}("fat_lo" => fat_lo, "fat_hi" => fat_hi,
"sources" => ["shutdown", "heatup_tight", "operation", "loca_operation"],
"sdm_rhs" => SDM_RHS, "rho_sdm" => RHO_SDM)
for (T_probe, r) in results
if r.status == "OK"
saved["T_$(Int(T_probe))_n_lo"] = r.n[1]
saved["T_$(Int(T_probe))_n_hi"] = r.n[2]
saved["T_$(Int(T_probe))_rho_lo"] = r.rho[1]
saved["T_$(Int(T_probe))_rho_hi"] = r.rho[2]
saved["T_$(Int(T_probe))_sdm_lhs_hi"] = r.sdm_lhs_hi
saved["T_$(Int(T_probe))_sdm_ok"] = r.sdm_ok ? 1.0 : 0.0
# Per-step time series for tube plotting.
saved["T_$(Int(T_probe))_t_arr"] = r.t_arr
saved["T_$(Int(T_probe))_n_lo_ts"] = r.n_lo_ts
saved["T_$(Int(T_probe))_n_hi_ts"] = r.n_hi_ts
saved["T_$(Int(T_probe))_rho_lo_ts"] = r.rho_lo_ts
saved["T_$(Int(T_probe))_rho_hi_ts"] = r.rho_hi_ts
saved["T_$(Int(T_probe))_Tc_lo_ts"] = r.Tc_lo_ts
saved["T_$(Int(T_probe))_Tc_hi_ts"] = r.Tc_hi_ts
saved["T_$(Int(T_probe))_Tf_lo_ts"] = r.Tf_lo_ts
saved["T_$(Int(T_probe))_Tf_hi_ts"] = r.Tf_hi_ts
end
end
matwrite(mat_out, saved)
println("\nSaved fat-entry scram reach to $mat_out")