PWR-HYBRID-3/code/scripts/plot/plot_reach_tubes.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

318 lines
14 KiB
Julia
Raw 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
#
# plot_reach_tubes.jl — multi-panel reach-tube visualization.
#
# Produces a four-panel figure from a reach-result .mat file:
# (1) Temperature tube overlay: T_c, T_hot, T_cold envelopes together.
# The gap between T_hot and T_cold is a direct proxy for core
# thermal power (P = W·c_c·ΔT).
# (2) ΔT_core = T_hot - T_cold envelope (the power proxy, explicit).
# (3) Reactivity ρ envelope, normalized by β (in dollars).
# (4) Normalized power n envelope.
#
# Two input formats supported:
# operation: reach_operation_result.mat (linear reach, has R_lo/R_hi
# matrices, time vector T, nominal X_nom).
# heatup_pj: reach_heatup_pj_tight_full.mat (per-timestep envelopes
# Tc_lo_ts/Tc_hi_ts/... already extracted; rho from PJ
# controller).
#
# Usage:
# julia --project=. scripts/plot_reach_tubes.jl operation
# julia --project=. scripts/plot_reach_tubes.jl heatup_pj
using Pkg
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using MAT
using Plots
gr()
include(joinpath(@__DIR__, "..", "..", "src", "pke_params.jl"))
const PLANT = pke_params()
function plot_tubes_operation()
mat_path = joinpath(@__DIR__, "..", "..", "..", "results", "reach_operation_result.mat")
d = matread(mat_path)
T_vec = vec(d["T"]) # time grid
X_lo = d["X_lo"] # 10 × M lower bounds
X_hi = d["X_hi"] # 10 × M upper bounds
X_nom = d["X_nom"] # 10 × M nominal
# States at indices: n=1, T_f=8, T_c=9, T_cold=10.
n_lo = X_lo[1, :]; n_hi = X_hi[1, :]
Tf_lo = X_lo[8, :]; Tf_hi = X_hi[8, :]
Tc_lo = X_lo[9, :]; Tc_hi = X_hi[9, :]
Tco_lo = X_lo[10, :]; Tco_hi = X_hi[10, :]
# T_hot = 2*T_c - T_cold; envelope via worst-case signed combination.
Th_lo = 2 .* Tc_lo .- Tco_hi
Th_hi = 2 .* Tc_hi .- Tco_lo
# ΔT_core = T_hot - T_cold = 2*(T_c - T_cold).
dT_lo = 2 .* (Tc_lo .- Tco_hi)
dT_hi = 2 .* (Tc_hi .- Tco_lo)
# ρ under LQR: ρ_total = u + α_f·dT_f + α_c·dT_c where u = -K(x-x_op).
# For a quick envelope, compute ρ at the nominal trajectory and
# inflate by bounds of the feedback contributions from the tube.
# Simpler: use total reactivity = u + α_f*(T_f-T_f0) + α_c*(T_c-T_c0).
# u under LQR is small; we approximate ρ envelope by α feedback
# alone (the u contribution is ≤ few cents).
rho_nom = PLANT.alpha_f .* (X_nom[8, :] .- PLANT.T_f0) .+
PLANT.alpha_c .* (X_nom[9, :] .- PLANT.T_c0)
# Envelope via worst-case T_f, T_c.
rho_lo = PLANT.alpha_f .* (Tf_hi .- PLANT.T_f0) .+
PLANT.alpha_c .* (Tc_hi .- PLANT.T_c0) # both α < 0, max T → min ρ
rho_hi = PLANT.alpha_f .* (Tf_lo .- PLANT.T_f0) .+
PLANT.alpha_c .* (Tc_lo .- PLANT.T_c0)
title_stem = "Operation-mode LQR reach tubes"
_plot_common(T_vec, Tc_lo, Tc_hi, Th_lo, Th_hi, Tco_lo, Tco_hi,
dT_lo, dT_hi, rho_lo, rho_hi, n_lo, n_hi, title_stem,
"reach_operation_tubes.png")
end
function plot_tubes_heatup_pj(probe_seconds::Int=8000)
# Reads the per-probe save format from reach_heatup_pj.jl with the
# latest tight.toml config. Format: keys prefixed with "T_{probe}_"
# for each requested probe horizon (e.g. T_8000_Tc_lo_ts).
mat_path = joinpath(@__DIR__, "..", "..", "..", "results",
"reach_heatup_pj_tight.mat")
d = matread(mat_path)
pre = "T_$(probe_seconds)_"
if !haskey(d, pre * "t_arr")
# Fall back to legacy bare-key format (older mat files).
pre = ""
haskey(d, "t_arr") || error("Neither '$pre' nor bare keys found in $mat_path. Available probes: " *
join([replace(k, r"_t_arr$" => "") for k in keys(d) if endswith(k, "_t_arr")], ", "))
end
t_arr = vec(d[pre * "t_arr"])
Tc_lo = vec(d[pre * "Tc_lo_ts"]); Tc_hi = vec(d[pre * "Tc_hi_ts"])
Tf_lo = vec(d[pre * "Tf_lo_ts"]); Tf_hi = vec(d[pre * "Tf_hi_ts"])
Tco_lo = vec(d[pre * "Tco_lo_ts"]); Tco_hi = vec(d[pre * "Tco_hi_ts"])
n_lo = vec(d[pre * "n_lo_ts"]); n_hi = vec(d[pre * "n_hi_ts"])
rho_lo = vec(d[pre * "rho_lo_ts"]); rho_hi = vec(d[pre * "rho_hi_ts"])
Th_lo = 2 .* Tc_lo .- Tco_hi
Th_hi = 2 .* Tc_hi .- Tco_lo
dT_lo = 2 .* (Tc_lo .- Tco_hi)
dT_hi = 2 .* (Tc_hi .- Tco_lo)
title_stem = "Heatup PJ (tight entry, T=$probe_seconds s) reach tubes"
_plot_common(t_arr, Tc_lo, Tc_hi, Th_lo, Th_hi, Tco_lo, Tco_hi,
dT_lo, dT_hi, rho_lo, rho_hi, n_lo, n_hi, title_stem,
"reach_heatup_pj_tubes.png")
end
function _plot_common(t, Tc_lo, Tc_hi, Th_lo, Th_hi, Tco_lo, Tco_hi,
dT_lo, dT_hi, rho_lo, rho_hi, n_lo, n_hi,
title_stem, outname)
CtoF(T) = T*9/5 + 32
# Panel 1: T_c / T_hot / T_cold overlaid.
p1 = plot(xlabel="Time [s]", ylabel="T [°C]",
title="T tubes", legend=:right)
plot!(p1, t, Tc_hi, fillrange=Tc_lo, fillalpha=0.30, color=:red,
linealpha=0, label="T_c tube")
plot!(p1, t, Th_hi, fillrange=Th_lo, fillalpha=0.25, color=:orange,
linealpha=0, label="T_hot tube")
plot!(p1, t, Tco_hi, fillrange=Tco_lo, fillalpha=0.25, color=:blue,
linealpha=0, label="T_cold tube")
# Panel 2: ΔT_core = T_hot - T_cold (power proxy at constant flow).
P_lo_MW = PLANT.W * PLANT.c_c .* dT_lo ./ 1e6
P_hi_MW = PLANT.W * PLANT.c_c .* dT_hi ./ 1e6
p2 = plot(xlabel="Time [s]", ylabel="ΔT_core = T_hot - T_cold [K]",
title="Core ΔT envelope (power proxy)", legend=:right)
plot!(p2, t, dT_hi, fillrange=dT_lo, fillalpha=0.30, color=:purple,
linealpha=0, label="ΔT_core [K]")
p2b = twinx(p2)
plot!(p2b, t, P_hi_MW, fillrange=P_lo_MW, fillalpha=0.0, color=:gray,
linealpha=0.5, linestyle=:dot, label="P=W·c_c·ΔT [MW]",
ylabel="Primary thermal power [MWth]")
# Panel 3: ρ envelope in dollars.
rho_lo_d = rho_lo ./ PLANT.beta
rho_hi_d = rho_hi ./ PLANT.beta
p3 = plot(xlabel="Time [s]", ylabel="ρ [\$]",
title="Reactivity envelope (1 \$ = β = prompt-critical)",
legend=:right)
plot!(p3, t, rho_hi_d, fillrange=rho_lo_d, fillalpha=0.3,
color=:darkgreen, linealpha=0, label="ρ / β")
hline!(p3, [1.0, -1.0], ls=:dash, color=:red,
label="prompt ±1 \$")
hline!(p3, [0.0], ls=:dot, color=:black, label="critical")
# Panel 4: n envelope (log scale if needed).
p4 = plot(xlabel="Time [s]", ylabel="n (normalized power)",
title="n envelope", legend=:right)
plot!(p4, t, n_hi, fillrange=n_lo, fillalpha=0.3, color=:black,
linealpha=0, label="n tube")
fig = plot(p1, p2, p3, p4, layout=(2, 2), size=(1300, 800),
plot_title=title_stem)
figdir = joinpath(@__DIR__, "..", "..", "..", "docs", "figures")
isdir(figdir) || mkpath(figdir)
outpath = joinpath(figdir, outname)
savefig(fig, outpath)
println("Saved $outpath")
end
function plot_tubes_scram_full(probe_seconds::Int=60)
# 3-panel scram tube plot using FULL-PKE reach (no prompt-jump
# algebraic substitution). T_c on top, T_f in the middle, ρ on the
# bottom. Per Dane's 2026-04-30 evening request: "I'd love tubes for
# t_c t_f and rho."
mat_path = joinpath(@__DIR__, "..", "..", "..", "results",
"reach_scram_full_fat.mat")
d = matread(mat_path)
pre = "T_$(probe_seconds)_"
haskey(d, pre * "t_arr") || error("No per-step data for probe $probe_seconds in $mat_path")
t_arr = vec(d[pre * "t_arr"])
Tc_lo = vec(d[pre * "Tc_lo_ts"]); Tc_hi = vec(d[pre * "Tc_hi_ts"])
Tf_lo = vec(d[pre * "Tf_lo_ts"]); Tf_hi = vec(d[pre * "Tf_hi_ts"])
rho_lo = vec(d[pre * "rho_lo_ts"]); rho_hi = vec(d[pre * "rho_hi_ts"])
# Convert ρ to dollars.
rho_lo_d = rho_lo ./ PLANT.beta
rho_hi_d = rho_hi ./ PLANT.beta
rho_sdm_dollars = 0.01 / PLANT.beta
# Panel 1: T_c envelope
p1 = plot(xlabel="Time after rod insertion [s]", ylabel="T_c [°C]",
title="Average coolant temperature envelope",
legend=:right, dpi=180)
plot!(p1, t_arr, Tc_hi, fillrange=Tc_lo, fillalpha=0.35,
color=:red, linealpha=0, label="T_c tube")
# Panel 2: T_f envelope
p2 = plot(xlabel="Time after rod insertion [s]", ylabel="T_f [°C]",
title="Fuel temperature envelope",
legend=:right, dpi=180)
plot!(p2, t_arr, Tf_hi, fillrange=Tf_lo, fillalpha=0.35,
color=:orange, linealpha=0, label="T_f tube")
# Panel 3: ρ envelope in dollars
p3 = plot(xlabel="Time after rod insertion [s]",
ylabel="ρ [\$] (1 \$ = β = prompt-critical)",
title="Reactivity envelope",
legend=:right, dpi=180)
plot!(p3, t_arr, rho_hi_d, fillrange=rho_lo_d, fillalpha=0.35,
color=:darkgreen, linealpha=0, label="ρ tube")
hline!(p3, [0.0], ls=:dot, color=:black, label="critical")
hline!(p3, [-rho_sdm_dollars], ls=:dash, color=:red, lw=2,
label="ρ_SDM = -0.01 (\$ = $(round(-rho_sdm_dollars; digits=2)))")
fig = plot(p1, p2, p3, layout=(3, 1), size=(900, 900),
plot_title="Full-PKE scram reach tubes (T=$probe_seconds s, fat entry)")
figdir = joinpath(@__DIR__, "..", "..", "..", "docs", "figures")
isdir(figdir) || mkpath(figdir)
outpath = joinpath(figdir, "reach_scram_full_tubes.png")
savefig(fig, outpath)
println("Saved $outpath")
end
function plot_tubes_scram_pj(probe_seconds::Int=60; variant::Symbol=:fat)
# 2-panel scram tube plot: rho(t) on top, n(t) below.
# variant=:tight reads reach_scram_pj_result.mat (small box around
# the operating point). variant=:fat reads reach_scram_pj_fat.mat
# (union over shutdown + heatup-tight + operation + LOCA envelopes).
mat_filename = variant == :fat ? "reach_scram_pj_fat.mat" :
"reach_scram_pj_result.mat"
mat_path = joinpath(@__DIR__, "..", "..", "..", "results", mat_filename)
d = matread(mat_path)
pre = "T_$(probe_seconds)_"
haskey(d, pre * "t_arr") || error("No per-step data for probe $probe_seconds in $mat_path")
t_arr = vec(d[pre * "t_arr"])
rho_lo = vec(d[pre * "rho_lo_ts"])
rho_hi = vec(d[pre * "rho_hi_ts"])
n_lo = vec(d[pre * "n_lo_ts"])
n_hi = vec(d[pre * "n_hi_ts"])
# Convert rho to dollars (rho / beta).
rho_lo_d = rho_lo ./ PLANT.beta
rho_hi_d = rho_hi ./ PLANT.beta
# Panel 1: rho envelope in dollars.
p1 = plot(xlabel="Time after rod insertion [s]",
ylabel="ρ [\$] (1 \$ = β = prompt-critical)",
title="Reactivity envelope",
legend=:right, dpi=180)
plot!(p1, t_arr, rho_hi_d, fillrange=rho_lo_d, fillalpha=0.35,
color=:darkgreen, linealpha=0, label="ρ tube")
hline!(p1, [0.0], ls=:dot, color=:black, label="critical")
# Shutdown margin threshold rho_SDM = 0.01 dk/k → in dollars: 0.01/beta
rho_sdm_dollars = 0.01 / PLANT.beta
hline!(p1, [-rho_sdm_dollars], ls=:dash, color=:red, lw=2,
label="ρ_SDM = -0.01 (\$ = $(round(-rho_sdm_dollars; digits=2)))")
# Panel 2: n envelope (log scale).
# Filter NaN / non-positive (PJ reconstruction can produce slack-negatives).
n_lo_pos = max.(n_lo, 1e-9)
n_hi_pos = max.(n_hi, 1e-9)
p2 = plot(xlabel="Time after rod insertion [s]",
ylabel="n (normalized power)",
title="Power envelope (log scale)",
legend=:right, yaxis=:log, dpi=180)
plot!(p2, t_arr, n_hi_pos, fillrange=n_lo_pos, fillalpha=0.35,
color=:black, linealpha=0, label="n tube")
hline!(p2, [1e-4], ls=:dash, color=:orange, lw=2, label="n = 10⁻⁴ (p_above_crit floor)")
title_suffix = variant == :fat ? "fat entry, T=$probe_seconds s" :
"T=$probe_seconds s"
fig = plot(p1, p2, layout=(2, 1), size=(900, 700),
plot_title="Scram reach tubes (PJ-reduced PKE, $title_suffix)")
figdir = joinpath(@__DIR__, "..", "..", "..", "docs", "figures")
isdir(figdir) || mkpath(figdir)
out_filename = variant == :fat ? "reach_scram_pj_fat_tubes.png" :
"reach_scram_pj_tubes.png"
outpath = joinpath(figdir, out_filename)
savefig(fig, outpath)
println("Saved $outpath")
end
# CLI dispatch.
which_plot = length(ARGS) > 0 ? ARGS[1] : "both"
if which_plot in ("operation", "both")
plot_tubes_operation()
end
if which_plot in ("scram_full", "both")
mat_path = joinpath(@__DIR__, "..", "..", "..", "results",
"reach_scram_full_fat.mat")
probe_s = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 60
if isfile(mat_path)
plot_tubes_scram_full(probe_s)
else
println("Skipping scram_full plot — $mat_path not found.")
end
end
if which_plot in ("scram_pj", "both")
mat_path = joinpath(@__DIR__, "..", "..", "..", "results",
"reach_scram_pj_result.mat")
probe_s = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 60
if isfile(mat_path)
plot_tubes_scram_pj(probe_s)
else
println("Skipping scram_pj plot — $mat_path not found.")
println("(Run scripts/reach/reach_scram_pj.jl first.)")
end
end
if which_plot in ("heatup_pj", "both")
mat_path = joinpath(@__DIR__, "..", "..", "..", "results",
"reach_heatup_pj_tight.mat")
# Optional second CLI arg: probe horizon in seconds (default 8000).
probe = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 8000
if isfile(mat_path)
plot_tubes_heatup_pj(probe)
else
println("Skipping heatup_pj plot — $mat_path not found.")
println("(Run scripts/reach/reach_heatup_pj.jl configs/heatup/tight.toml first.)")
end
end