refactor: scripts subdivision + TOML configs + results/ split + presentation outline

Architecture restructure from morning review:

1. code/scripts/ subdivided into sim/, reach/, barrier/, plot/.
   Easier nav; `barrier/` is the natural place for SOS scale-up scripts.
2. Heatup PJ reach variants consolidated behind TOML configs.
   reach_heatup_pj.jl now takes `--config path/to/config.toml`;
   configs/heatup/baseline.toml (wide entry, from predicates.json) and
   configs/heatup/tight.toml (narrow entry, reproduces all-6-halfspaces
   discharged result). Old reach_heatup_pj_tight.jl and
   reach_heatup_pj_tight_full.jl deleted (superseded).
3. Reach output .mat files moved from reachability/ to results/.
   reachability/ now = specs + docs; results/ = ephemeral outputs
   (gitignored *.mat). README added.
4. OVERNIGHT_NOTES.md archived to claude_memory/2026-04-20-21-overnight-
   session-summary.md (date range in the filename makes the history clearer).

All include() / Pkg.activate() paths in scripts updated for the new
depth. Smoke tests pass (reach_operation.jl generates its .mat in
the new results/ location; sim_sanity.jl matches MATLAB).

Presentation outline for the 20-min prelim talk landed in
presentations/prelim-presentation/outline.md. 14-slide assertion-
evidence format targeting OT-informed cybersecurity audience. Each
slide: one declarative assertion + one figure. Outline includes
which figures already exist and which need to be created, timing
checkpoints, cybersecurity angle to emphasize, and Q&A prep.

New config configs/heatup/with_steam_dump.toml + its companion
scripts/reach/reach_heatup_pj_sd.jl (12-state RHS with Q_sg as an
augmented bounded parameter x[10] and time as x[11]). Kicks off
point 3 from morning review.

Next up: scram X_entry expansion (morning point 2) — LOCA scenario
+ union of mode reach envelopes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dane Sabo 2026-04-21 20:24:48 -04:00
parent c5816c359e
commit 2bbb1871cc
25 changed files with 1024 additions and 557 deletions

View File

@ -324,7 +324,7 @@ no more hand-maintained traceability table.
# ╔═╡ 37d6b212-9f00-4684-9f91-50c7e17cbd62
begin
reach_mat_path = joinpath(@__DIR__, "..", "reachability", "reach_operation_result.mat")
reach_mat_path = joinpath(@__DIR__, "..", "results", "reach_operation_result.mat")
have_reach_mat = isfile(reach_mat_path)
if have_reach_mat
reach_mat = matread(reach_mat_path)
@ -463,7 +463,7 @@ extend past the ~10 s prompt-neutron stiffness wall.
# ╔═╡ 45e8962a-873e-48d3-aac4-70ea0cf36c97
begin
pj_mat_path = joinpath(@__DIR__, "..", "reachability", "reach_heatup_pj_result.mat")
pj_mat_path = joinpath(@__DIR__, "..", "results", "reach_heatup_pj_result.mat")
if isfile(pj_mat_path)
pj_mat = matread(pj_mat_path)
md"""**Loaded** `reach_heatup_pj_result.mat` — pending plot rendering."""

View File

@ -0,0 +1,22 @@
# Baseline heatup-mode reach configuration.
# Wide X_entry as specified in predicates.json mode_boundaries.
# Matches the original reach_heatup_pj.jl defaults.
name = "baseline"
description = "Wide X_entry from mode_boundaries.q_heatup.X_entry_polytope"
# Load X_entry from predicates.json (true) or from this file (false).
use_predicates_entry = true
[tmjets]
orderT = 4
orderQ = 2
abstol = 1e-9
maxsteps = 100000
[probes]
horizons_seconds = [60.0, 300.0, 1800.0, 5400.0]
[output]
save_per_step = true
result_file = "reach_heatup_pj_baseline.mat"

View File

@ -0,0 +1,27 @@
# Tight X_entry heatup reach: T_c in [285, 291] (6 K vs 14 K baseline).
# Produces the clean "all 6 inv1_holds halfspaces discharged at T=300s"
# result from 2026-04-20 overnight session.
name = "tight"
description = "Narrow X_entry on T_c/T_f/T_cold; n in [1e-3, 2e-3]"
use_predicates_entry = false
[entry]
n_range = [1.0e-3, 2.0e-3]
T_f_range_C = [285.0, 291.0]
T_c_range_C = [285.0, 291.0]
T_cold_range_C = [278.0, 285.0]
[tmjets]
orderT = 4
orderQ = 2
abstol = 1e-9
maxsteps = 100000
[probes]
horizons_seconds = [60.0, 300.0]
[output]
save_per_step = true
result_file = "reach_heatup_pj_tight.mat"

View File

@ -0,0 +1,40 @@
# Heatup reach with a bounded secondary-side steam-dump Q_sg.
#
# Instead of Q_sg ≡ 0 (original assumption), treat Q_sg as a bounded
# disturbance in [0, 0.05·P_0]. Physical interpretation: operator
# opens/closes the secondary-side steam dump to manage primary
# temperature during the ramp; exact value not known, but bounded
# by atmospheric-dump capacity (~5% of P_0 rated).
#
# The reach script picks up Q_sg as an augmented state x[11] with
# dx[11] = 0, entry box covering [0, 0.05*P_0].
name = "with_steam_dump"
description = "Tight X_entry + bounded Q_sg ∈ [0, 0.05·P_0] as disturbance"
use_predicates_entry = false
steam_dump_enabled = true
[entry]
n_range = [1.0e-3, 2.0e-3]
T_f_range_C = [285.0, 291.0]
T_c_range_C = [285.0, 291.0]
T_cold_range_C = [278.0, 285.0]
[entry.steam_dump]
# Q_sg bounds in fractions of P_0.
Q_lo_fraction_P0 = 0.0
Q_hi_fraction_P0 = 0.05
[tmjets]
orderT = 4
orderQ = 2
abstol = 1e-9
maxsteps = 100000
[probes]
horizons_seconds = [60.0, 300.0]
[output]
save_per_step = true
result_file = "reach_heatup_pj_with_steam_dump.mat"

View File

@ -8,17 +8,17 @@
# anisotropy, not controller tuning.
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
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"))
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)

View File

@ -10,17 +10,17 @@
# This is the structural anisotropy limitation, not a code bug.
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
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"))
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)

View File

@ -21,7 +21,7 @@
# Uses JuMP + HiGHS (open-source, ships with no license trouble).
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using Printf
using LinearAlgebra
@ -31,10 +31,10 @@ using JSON
using JuMP
using HiGHS
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"))
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)
@ -55,7 +55,7 @@ b_inv2 = inv2.b_poly # 6
# --- Precursor bounds from reach-tube envelope ---
# Read reach_operation_result.mat; take min/max of X_lo, X_hi on precursors.
reach_mat_path = joinpath(@__DIR__, "..", "..", "reachability",
reach_mat_path = joinpath(@__DIR__, "..", "..", "..", "reachability",
"reach_operation_result.mat")
reach = matread(reach_mat_path)
X_lo = reach["X_lo"]; X_hi = reach["X_hi"]

View File

@ -24,7 +24,7 @@
# Using SOS multipliers σ_i(x), w-dependence via lossless-disturbance bound.
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using LinearAlgebra
using MatrixEquations
@ -32,9 +32,9 @@ using DynamicPolynomials
using SumOfSquares
using CSDP
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", "pke_params.jl"))
include(joinpath(@__DIR__, "..", "..", "src", "pke_th_rhs.jl"))
include(joinpath(@__DIR__, "..", "..", "src", "pke_linearize.jl"))
plant = pke_params()
x_op = pke_initial_conditions(plant)

View File

@ -22,17 +22,17 @@
# julia --project=. scripts/plot_reach_tubes.jl heatup_pj
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using MAT
using Plots
gr()
include(joinpath(@__DIR__, "..", "src", "pke_params.jl"))
include(joinpath(@__DIR__, "..", "..", "src", "pke_params.jl"))
const PLANT = pke_params()
function plot_tubes_operation()
mat_path = joinpath(@__DIR__, "..", "..", "reachability", "reach_operation_result.mat")
mat_path = joinpath(@__DIR__, "..", "..", "..", "results", "reach_operation_result.mat")
d = matread(mat_path)
T_vec = vec(d["T"]) # time grid
@ -75,7 +75,7 @@ function plot_tubes_operation()
end
function plot_tubes_heatup_pj()
mat_path = joinpath(@__DIR__, "..", "..", "reachability",
mat_path = joinpath(@__DIR__, "..", "..", "..", "reachability",
"reach_heatup_pj_tight_full.mat")
d = matread(mat_path)
@ -144,7 +144,7 @@ function _plot_common(t, Tc_lo, Tc_hi, Th_lo, Th_hi, Tco_lo, Tco_hi,
fig = plot(p1, p2, p3, p4, layout=(2, 2), size=(1300, 800),
plot_title=title_stem)
figdir = joinpath(@__DIR__, "..", "..", "docs", "figures")
figdir = joinpath(@__DIR__, "..", "..", "..", "docs", "figures")
isdir(figdir) || mkpath(figdir)
outpath = joinpath(figdir, outname)
savefig(fig, outpath)
@ -157,7 +157,7 @@ if which_plot in ("operation", "both")
plot_tubes_operation()
end
if which_plot in ("heatup_pj", "both")
mat_path = joinpath(@__DIR__, "..", "..", "reachability",
mat_path = joinpath(@__DIR__, "..", "..", "..", "reachability",
"reach_heatup_pj_tight_full.mat")
if isfile(mat_path)
plot_tubes_heatup_pj()

View File

@ -26,7 +26,7 @@
# Time carried as augmented state x[11].
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using LinearAlgebra
using ReachabilityAnalysis, LazySets
@ -101,7 +101,7 @@ const KP_HEATUP = 1e-4
end
# --- Build X_entry from predicates.json ---
pred_path = joinpath(@__DIR__, "..", "..", "reachability", "predicates.json")
pred_path = joinpath(@__DIR__, "..", "..", "..", "reachability", "predicates.json")
pred_raw = JSON.parsefile(pred_path)
entry = pred_raw["mode_boundaries"]["q_heatup"]["X_entry_polytope"]

View File

@ -0,0 +1,269 @@
#!/usr/bin/env julia
#
# reach_heatup_pj.jl — nonlinear reach on heatup, prompt-jump model.
#
# Reduced from 10-state to 9-state (n is algebraic). Removes the Λ⁻¹
# stiffness that capped the full-state reach at ~10 s.
#
# State (10D with augmented time):
# x[1..6] = C_1..C_6 (delayed-neutron precursors)
# x[7] = T_f
# x[8] = T_c
# x[9] = T_cold
# x[10] = t (augmented time, dt/dt = 1)
#
# n is algebraic: n = Λ·Σ λ_i C_i / (β - ρ), ρ = K_p·(T_ref - T_c).
#
# Configuration-driven: pass a TOML config path as the first CLI arg,
# or omit for the baseline config.
#
# julia --project=. scripts/reach/reach_heatup_pj.jl # baseline
# julia --project=. scripts/reach/reach_heatup_pj.jl configs/heatup/tight.toml
#
# Configs live in code/configs/heatup/*.toml. See baseline.toml for
# the full schema.
using Pkg
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using LinearAlgebra
using ReachabilityAnalysis, LazySets
using JSON
using MAT
using TOML
# --- Plant constants (must match pke_params) ---
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 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 T_STANDBY = T_C0 - 33.333333
const RAMP_RATE_CS = 28.0 / 3600
const KP_HEATUP = 1e-4
# --- Taylorized heatup PJ RHS ---
@taylorize function rhs_heatup_pj_taylor!(dx, x, p, t)
rho = KP_HEATUP * (T_STANDBY + RAMP_RATE_CS * x[10] - x[8])
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])) / (M_SG * C_C)
dx[10] = one(x[1])
return nothing
end
# --- Config loader ---
function load_config(config_path)
if isfile(config_path)
return TOML.parsefile(config_path)
else
error("Config file not found: $config_path")
end
end
function build_entry_box(config)
if get(config, "use_predicates_entry", false)
pred_path = joinpath(@__DIR__, "..", "..", "..", "reachability", "predicates.json")
pred_raw = JSON.parsefile(pred_path)
entry = pred_raw["mode_boundaries"]["q_heatup"]["X_entry_polytope"]
n_lo, n_hi = entry["n_range"]
T_f_lo, T_f_hi = entry["T_f_range_C"]
T_c_lo, T_c_hi = entry["T_c_range_C"]
T_cold_lo, T_cold_hi = entry["T_cold_range_C"]
else
e = config["entry"]
n_lo, n_hi = e["n_range"]
T_f_lo, T_f_hi = e["T_f_range_C"]
T_c_lo, T_c_hi = e["T_c_range_C"]
T_cold_lo, T_cold_hi = e["T_cold_range_C"]
end
n_mid = 0.5 * (n_lo + n_hi)
C_mid = [BETA_1/(LAM_1*LAMBDA), BETA_2/(LAM_2*LAMBDA),
BETA_3/(LAM_3*LAMBDA), BETA_4/(LAM_4*LAMBDA),
BETA_5/(LAM_5*LAMBDA), BETA_6/(LAM_6*LAMBDA)] .* n_mid
x_lo = [C_mid[1]*(n_lo/n_mid), C_mid[2]*(n_lo/n_mid),
C_mid[3]*(n_lo/n_mid), C_mid[4]*(n_lo/n_mid),
C_mid[5]*(n_lo/n_mid), C_mid[6]*(n_lo/n_mid),
T_f_lo, T_c_lo, T_cold_lo, 0.0]
x_hi = [C_mid[1]*(n_hi/n_mid), C_mid[2]*(n_hi/n_mid),
C_mid[3]*(n_hi/n_mid), C_mid[4]*(n_hi/n_mid),
C_mid[5]*(n_hi/n_mid), C_mid[6]*(n_hi/n_mid),
T_f_hi, T_c_hi, T_cold_hi, 0.0]
return Hyperrectangle(low=x_lo, high=x_hi),
(n_lo=n_lo, n_hi=n_hi,
T_f_lo=T_f_lo, T_f_hi=T_f_hi,
T_c_lo=T_c_lo, T_c_hi=T_c_hi,
T_cold_lo=T_cold_lo, T_cold_hi=T_cold_hi)
end
# --- Per-step envelope extraction ---
function extract_envelopes(flow_hr)
n_steps = length(flow_hr)
t_arr = zeros(n_steps)
Tc_lo_ts = zeros(n_steps); Tc_hi_ts = zeros(n_steps)
Tf_lo_ts = zeros(n_steps); Tf_hi_ts = zeros(n_steps)
Tco_lo_ts = zeros(n_steps); Tco_hi_ts = zeros(n_steps)
n_lo_ts = zeros(n_steps); n_hi_ts = zeros(n_steps)
rho_lo_ts = zeros(n_steps); rho_hi_ts = zeros(n_steps)
for (k, R) in enumerate(flow_hr)
s = set(R)
t_arr[k] = high(s, 10)
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)
Tco_lo_ts[k] = low(s, 9); Tco_hi_ts[k] = high(s, 9)
sumLC_lo = 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 = 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)
t_hi_here = high(s, 10)
t_lo_here = low(s, 10)
Tref_lo = T_STANDBY + RAMP_RATE_CS * t_lo_here
Tref_hi = T_STANDBY + RAMP_RATE_CS * t_hi_here
rho_lo_here = KP_HEATUP * (Tref_lo - high(s, 8))
rho_hi_here = KP_HEATUP * (Tref_hi - low(s, 8))
rho_lo_ts[k] = rho_lo_here
rho_hi_ts[k] = rho_hi_here
denom_lo = BETA - rho_hi_here
denom_hi = BETA - rho_lo_here
if denom_lo > 0
n_lo_ts[k] = LAMBDA * sumLC_lo / denom_hi
n_hi_ts[k] = LAMBDA * sumLC_hi / denom_lo
end
end
return (; t_arr,
Tc_lo_ts, Tc_hi_ts, Tf_lo_ts, Tf_hi_ts,
Tco_lo_ts, Tco_hi_ts, n_lo_ts, n_hi_ts,
rho_lo_ts, rho_hi_ts)
end
# --- Main ---
function main()
default_config = joinpath(@__DIR__, "..", "..", "configs", "heatup", "baseline.toml")
config_path = length(ARGS) > 0 ? ARGS[1] : default_config
# Allow a config path relative to repo root or code/.
if !isfile(config_path)
alt = joinpath(@__DIR__, "..", "..", config_path)
isfile(alt) && (config_path = alt)
end
config = load_config(config_path)
println("\n=== Heatup PJ reach — config: $(config["name"]) ===")
println(" $(get(config, "description", ""))")
X0, entry_info = build_entry_box(config)
println(" X_entry: n ∈ [$(entry_info.n_lo), $(entry_info.n_hi)], " *
"T_c ∈ [$(entry_info.T_c_lo), $(entry_info.T_c_hi)] °C")
tmjets_cfg = config["tmjets"]
probes = config["probes"]["horizons_seconds"]
results = Dict{Float64, Any}()
for T_probe in probes
println("\n--- Probe T = $T_probe s ($(round(T_probe/60; digits=1)) min) ---")
sys = BlackBoxContinuousSystem(rhs_heatup_pj_taylor!, 10)
prob = InitialValueProblem(sys, X0)
try
alg = TMJets(
orderT = tmjets_cfg["orderT"],
orderQ = tmjets_cfg["orderQ"],
abstol = tmjets_cfg["abstol"],
maxsteps = tmjets_cfg["maxsteps"],
)
t_start = time()
sol = solve(prob; T=Float64(T_probe), alg=alg)
elapsed = time() - t_start
flow = flowpipe(sol)
n_sets = length(flow)
println(" TMJets: $n_sets reach-sets, wall $(round(elapsed; digits=1)) s")
flow_hr = overapproximate(flow, Hyperrectangle)
env = extract_envelopes(flow_hr)
println(" n envelope: [$(round(minimum(env.n_lo_ts); sigdigits=4)), $(round(maximum(env.n_hi_ts); sigdigits=4))]")
println(" T_c envelope: [$(round(minimum(env.Tc_lo_ts); digits=2)), $(round(maximum(env.Tc_hi_ts); digits=2))] °C")
println(" T_f envelope: [$(round(minimum(env.Tf_lo_ts); digits=2)), $(round(maximum(env.Tf_hi_ts); digits=2))] °C")
println(" T_cold env: [$(round(minimum(env.Tco_lo_ts); digits=2)), $(round(maximum(env.Tco_hi_ts); digits=2))] °C")
println(" rho env: [$(round(minimum(env.rho_lo_ts); sigdigits=4)), $(round(maximum(env.rho_hi_ts); sigdigits=4))]")
results[T_probe] = (status="OK", n_sets=n_sets, elapsed=elapsed, env=env)
catch err
msg = sprint(showerror, err)
println(" FAILED: ", first(msg, 300))
results[T_probe] = (status="FAILED", err=first(msg, 300))
break
end
end
println("\n=== Summary ===")
for T_probe in probes
haskey(results, T_probe) || continue
r = results[T_probe]
if r.status == "OK"
println(" T = $(T_probe) s: OK, $(r.n_sets) sets, $(round(r.elapsed; digits=1))s wall")
else
println(" T = $(T_probe) s: FAILED")
end
end
# --- Save ---
if get(config, "output", Dict()) |> (o -> get(o, "save_per_step", false))
result_file = config["output"]["result_file"]
mat_out = joinpath(@__DIR__, "..", "..", "..", "results", result_file)
saved = Dict{String, Any}(
"config_name" => config["name"],
"probe_horizons" => collect(probes),
"beta" => BETA,
"Kp" => KP_HEATUP,
"T_c0" => T_C0,
"T_cold0" => T_COLD0,
"T_standby" => T_STANDBY,
)
for T_probe in probes
haskey(results, T_probe) || continue
r = results[T_probe]
r.status == "OK" || continue
pre = "T_$(Int(T_probe))_"
env = r.env
saved[pre * "t_arr"] = env.t_arr
saved[pre * "Tc_lo_ts"] = env.Tc_lo_ts; saved[pre * "Tc_hi_ts"] = env.Tc_hi_ts
saved[pre * "Tf_lo_ts"] = env.Tf_lo_ts; saved[pre * "Tf_hi_ts"] = env.Tf_hi_ts
saved[pre * "Tco_lo_ts"] = env.Tco_lo_ts; saved[pre * "Tco_hi_ts"] = env.Tco_hi_ts
saved[pre * "n_lo_ts"] = env.n_lo_ts; saved[pre * "n_hi_ts"] = env.n_hi_ts
saved[pre * "rho_lo_ts"] = env.rho_lo_ts; saved[pre * "rho_hi_ts"] = env.rho_hi_ts
end
matwrite(mat_out, saved)
println("\nSaved per-step envelopes to $mat_out")
end
end
main()

View File

@ -0,0 +1,178 @@
#!/usr/bin/env julia
#
# reach_heatup_pj_sd.jl — heatup PJ reach WITH bounded Q_sg steam dump.
#
# Adds an augmented state x[11] = Q_sg as a bounded parameter (dx[11] = 0).
# The reach propagates the entry-box range of Q_sg forward; at each reach
# set, the Q_sg extent is the disturbance envelope the controller has to
# reject. Physical story: operator controls secondary steam dump; actual
# value unknown but bounded in [0, 0.05·P_0].
#
# Time is carried as x[12] = t (dt/dt = 1).
#
# Diagnostic script — uses the tight-entry + steam-dump config.
using Pkg
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using LinearAlgebra
using ReachabilityAnalysis, LazySets
using JSON
using MAT
using TOML
# 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 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 T_STANDBY = T_C0 - 33.333333
const RAMP_RATE_CS = 28.0 / 3600
const KP_HEATUP = 1e-4
# 12-state RHS: [C_1..C_6, T_f, T_c, T_cold, Q_sg, t]
# dx[10] = 0 (Q_sg is a bounded parameter)
# dx[11] = 1 (time)
# Hmm — indexing conflict with original version. Reorganize:
# x[1..6] = C_1..C_6
# x[7] = T_f
# x[8] = T_c
# x[9] = T_cold
# x[10] = Q_sg (constant-parameter, dx[10]=0)
# x[11] = t (dx[11]=1)
@taylorize function rhs_heatup_sd_taylor!(dx, x, p, t)
rho = KP_HEATUP * (T_STANDBY + RAMP_RATE_CS * x[11] - x[8])
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]) - x[10]) / (M_SG * C_C) # Q_sg now enters!
dx[10] = zero(x[1]) # Q_sg constant
dx[11] = one(x[1]) # time
return nothing
end
function main()
config_path = length(ARGS) > 0 ? ARGS[1] :
joinpath(@__DIR__, "..", "..", "configs", "heatup", "with_steam_dump.toml")
if !isfile(config_path)
alt = joinpath(@__DIR__, "..", "..", config_path)
isfile(alt) && (config_path = alt)
end
config = TOML.parsefile(config_path)
println("\n=== Heatup PJ reach with steam dump — config: $(config["name"]) ===")
println(" $(get(config, "description", ""))")
e = config["entry"]
n_lo, n_hi = e["n_range"]
T_f_lo, T_f_hi = e["T_f_range_C"]
T_c_lo, T_c_hi = e["T_c_range_C"]
T_cold_lo, T_cold_hi = e["T_cold_range_C"]
sd = e["steam_dump"]
Q_lo = sd["Q_lo_fraction_P0"] * P0
Q_hi = sd["Q_hi_fraction_P0"] * P0
n_mid = 0.5 * (n_lo + n_hi)
C_mid = [BETA_1/(LAM_1*LAMBDA), BETA_2/(LAM_2*LAMBDA),
BETA_3/(LAM_3*LAMBDA), BETA_4/(LAM_4*LAMBDA),
BETA_5/(LAM_5*LAMBDA), BETA_6/(LAM_6*LAMBDA)] .* n_mid
x_lo = [C_mid[1]*(n_lo/n_mid), C_mid[2]*(n_lo/n_mid),
C_mid[3]*(n_lo/n_mid), C_mid[4]*(n_lo/n_mid),
C_mid[5]*(n_lo/n_mid), C_mid[6]*(n_lo/n_mid),
T_f_lo, T_c_lo, T_cold_lo, Q_lo, 0.0]
x_hi = [C_mid[1]*(n_hi/n_mid), C_mid[2]*(n_hi/n_mid),
C_mid[3]*(n_hi/n_mid), C_mid[4]*(n_hi/n_mid),
C_mid[5]*(n_hi/n_mid), C_mid[6]*(n_hi/n_mid),
T_f_hi, T_c_hi, T_cold_hi, Q_hi, 0.0]
X0 = Hyperrectangle(low=x_lo, high=x_hi)
println(" n ∈ [$n_lo, $n_hi], T_c ∈ [$T_c_lo, $T_c_hi] °C")
println(" Q_sg (steam dump) ∈ [$(Q_lo/1e6), $(Q_hi/1e6)] MW")
t_cfg = config["tmjets"]
probes = config["probes"]["horizons_seconds"]
results = Dict{Float64, Any}()
for T_probe in probes
println("\n--- Probe T = $T_probe s ---")
sys = BlackBoxContinuousSystem(rhs_heatup_sd_taylor!, 11)
prob = InitialValueProblem(sys, X0)
try
alg = TMJets(orderT=t_cfg["orderT"], orderQ=t_cfg["orderQ"],
abstol=t_cfg["abstol"], maxsteps=t_cfg["maxsteps"])
t0 = time()
sol = solve(prob; T=Float64(T_probe), alg=alg)
elapsed = time() - t0
flow_hr = overapproximate(flowpipe(sol), Hyperrectangle)
n_sets = length(flow_hr)
println(" TMJets: $n_sets reach-sets in $(round(elapsed; digits=1)) s")
Tc_lo_env = minimum(low(set(R), 8) for R in flow_hr)
Tc_hi_env = maximum(high(set(R), 8) for R in flow_hr)
Tco_lo_env = minimum(low(set(R), 9) for R in flow_hr)
Tco_hi_env = maximum(high(set(R), 9) for R in flow_hr)
Tf_lo_env = minimum(low(set(R), 7) for R in flow_hr)
Tf_hi_env = maximum(high(set(R), 7) for R in flow_hr)
println(" T_c envelope: [$(round(Tc_lo_env; digits=2)), $(round(Tc_hi_env; digits=2))] °C")
println(" T_f envelope: [$(round(Tf_lo_env; digits=2)), $(round(Tf_hi_env; digits=2))] °C")
println(" T_cold env: [$(round(Tco_lo_env; digits=2)), $(round(Tco_hi_env; digits=2))] °C")
# Compare low-T_avg trip (280 °C).
low_trip_ok = Tc_lo_env >= 280.0
println(" Low-T_avg trip (T_c ≥ 280): $(low_trip_ok ? "" : "× loose")")
results[T_probe] = (status="OK", Tc=(Tc_lo_env, Tc_hi_env),
Tf=(Tf_lo_env, Tf_hi_env),
Tcold=(Tco_lo_env, Tco_hi_env),
low_trip_ok=low_trip_ok)
catch err
println(" FAILED: ", first(sprint(showerror, err), 300))
results[T_probe] = (status="FAILED",)
break
end
end
mat_out = joinpath(@__DIR__, "..", "..", "..", "results", config["output"]["result_file"])
saved = Dict{String, Any}("config_name" => config["name"],
"Q_lo" => Q_lo, "Q_hi" => Q_hi)
for (T_probe, r) in results
if r.status == "OK"
pre = "T_$(Int(T_probe))_"
saved[pre * "Tc_lo"] = r.Tc[1]; saved[pre * "Tc_hi"] = r.Tc[2]
saved[pre * "Tf_lo"] = r.Tf[1]; saved[pre * "Tf_hi"] = r.Tf[2]
saved[pre * "Tcold_lo"] = r.Tcold[1]; saved[pre * "Tcold_hi"] = r.Tcold[2]
end
end
matwrite(mat_out, saved)
println("\nSaved to $mat_out")
end
main()

View File

@ -11,7 +11,7 @@
# Linear-reach results here are an approximate-but-useful gut check.
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using Printf
using LinearAlgebra
@ -20,11 +20,11 @@ using Plots
using JSON
using MAT
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"))
include(joinpath(@__DIR__, "..", "src", "reach_linear.jl"))
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"))
include(joinpath(@__DIR__, "..", "..", "src", "reach_linear.jl"))
plant = pke_params()
x_op = pke_initial_conditions(plant)
@ -101,7 +101,7 @@ end
# --- Plots: T_c reach tube, two views ---
CtoF(T) = T * 9/5 + 32
delta_safe_Tc = pred.constants.t_avg_in_range_halfwidth_C
figdir = joinpath(@__DIR__, "..", "..", "docs", "figures")
figdir = joinpath(@__DIR__, "..", "..", "..", "docs", "figures")
isdir(figdir) || mkpath(figdir)
p_safety = plot(T, CtoF.(X_nom[9, :]), lw=1.2, color=:red,
@ -140,7 +140,7 @@ plot!(fig_n, T, X_hi[1, :], fillrange=X_lo[1, :], fillalpha=0.3,
savefig(fig_n, joinpath(figdir, "reach_operation_n.png"))
# --- Save result ---
matfile = joinpath(@__DIR__, "..", "..", "reachability", "reach_operation_result.mat")
matfile = joinpath(@__DIR__, "..", "..", "..", "results", "reach_operation_result.mat")
matwrite(matfile, Dict("T" => collect(T), "R_lo" => R_lo, "R_hi" => R_hi,
"center" => Cnom, "X_lo" => X_lo, "X_hi" => X_hi,
"X_nom" => X_nom, "K" => Matrix(K), "A_cl" => A_cl,

View File

@ -9,7 +9,7 @@
# 9-state PJ model (10D with augmented time).
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using LinearAlgebra
using ReachabilityAnalysis, LazySets
@ -140,7 +140,7 @@ for T_probe in (10.0, 30.0, 60.0)
end
end
mat_out = joinpath(@__DIR__, "..", "..", "reachability", "reach_scram_pj_result.mat")
mat_out = joinpath(@__DIR__, "..", "..", "..", "results", "reach_scram_pj_result.mat")
saved = Dict{String, Any}("probe_horizons" => collect((10.0, 30.0, 60.0)))
for T_probe in (10.0, 30.0, 60.0)
haskey(results, T_probe) || continue

View File

@ -1,243 +0,0 @@
#!/usr/bin/env julia
#
# reach_heatup_pj.jl — nonlinear reach on heatup, prompt-jump model.
#
# Reduced from 10-state to 9-state (n is algebraic). Removes the
# Λ⁻¹ stiffness that capped the full-state reach at ~10 s. We push
# horizons up: 60 s, 300 s, 1800 s, 5400 s, full T_max = 18000 s (5 hr).
#
# State (10D with augmented time):
# x[1..6] = C_1..C_6 (delayed-neutron precursors)
# x[7] = T_f
# x[8] = T_c
# x[9] = T_cold
# x[10] = t (augmented time, dt/dt = 1)
#
# n is algebraic: n = Λ · Σ λ_i C_i / (β - ρ), ρ = K_p · (T_ref - T_c).
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
using LinearAlgebra
using ReachabilityAnalysis, LazySets
using JSON
using MAT
# --- Inlined plant constants (must match pke_params) ---
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
# Note: feedback-linearization in ctrl_heatup_unsat cancels the alpha
# terms exactly, so under closed-loop the effective rho is just Kp·e.
# We don't need ALPHA_F, ALPHA_C in the reach RHS as a result.
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 T_STANDBY = T_C0 - 33.333333
const RAMP_RATE_CS = 28.0 / 3600
const KP_HEATUP = 1e-4
# --- Taylorized PJ heatup RHS, 10D with augmented time ---
@taylorize function rhs_heatup_pj_taylor!(dx, x, p, t)
# rho_total under closed-loop feedback linearization = Kp · (T_ref - T_c).
rho = KP_HEATUP * (T_STANDBY + RAMP_RATE_CS * x[10] - x[8])
# Algebraic prompt-jump n.
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
# Precursor balance under PJ.
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]
# Thermal — n appears algebraically in fuel eq.
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])) / (M_SG * C_C)
# Augmented time.
dx[10] = one(x[1])
return nothing
end
# --- Build X_entry (PJ form: no n) from predicates.json ---
pred_path = joinpath(@__DIR__, "..", "..", "reachability", "predicates.json")
pred_raw = JSON.parsefile(pred_path)
entry = pred_raw["mode_boundaries"]["q_heatup"]["X_entry_polytope"]
n_lo, n_hi = entry["n_range"]
T_f_lo, T_f_hi = entry["T_f_range_C"]
T_c_lo, T_c_hi = entry["T_c_range_C"]
T_cold_lo, T_cold_hi = entry["T_cold_range_C"]
n_mid = 0.5 * (n_lo + n_hi)
C_mid_1 = (BETA_1 / (LAM_1 * LAMBDA)) * n_mid
C_mid_2 = (BETA_2 / (LAM_2 * LAMBDA)) * n_mid
C_mid_3 = (BETA_3 / (LAM_3 * LAMBDA)) * n_mid
C_mid_4 = (BETA_4 / (LAM_4 * LAMBDA)) * n_mid
C_mid_5 = (BETA_5 / (LAM_5 * LAMBDA)) * n_mid
C_mid_6 = (BETA_6 / (LAM_6 * LAMBDA)) * n_mid
# C_i scale linearly with n; sweep across the n_lo..n_hi band.
x_lo = [C_mid_1 * (n_lo / n_mid); C_mid_2 * (n_lo / n_mid);
C_mid_3 * (n_lo / n_mid); C_mid_4 * (n_lo / n_mid);
C_mid_5 * (n_lo / n_mid); C_mid_6 * (n_lo / n_mid);
T_f_lo; T_c_lo; T_cold_lo;
0.0]
x_hi = [C_mid_1 * (n_hi / n_mid); C_mid_2 * (n_hi / n_mid);
C_mid_3 * (n_hi / n_mid); C_mid_4 * (n_hi / n_mid);
C_mid_5 * (n_hi / n_mid); C_mid_6 * (n_hi / n_mid);
T_f_hi; T_c_hi; T_cold_hi;
0.0]
X0 = Hyperrectangle(low=x_lo, high=x_hi)
println("\n=== Nonlinear heatup reach, prompt-jump model ===")
println(" X_entry (n-implied range): n ∈ [$(n_lo), $(n_hi)]")
println(" T_c ∈ [$(T_c_lo), $(T_c_hi)] °C")
T_RAMP_END = (T_C0 - T_STANDBY) / RAMP_RATE_CS
println(" T_ramp_end = $(round(T_RAMP_END; digits=0)) s ($(round(T_RAMP_END/60; digits=1)) min)")
println(" Probing horizons up to T_max(heatup) = 18000 s (5 hr).")
# Probe at increasing horizons. Stop early if any probe fails.
probe_horizons = (60.0, 300.0, 1800.0, 5400.0)
results = Dict{Float64, Any}()
for T_probe in probe_horizons
println("\n--- Probe T = $T_probe s ($(round(T_probe/60; digits=1)) min) ---")
sys = BlackBoxContinuousSystem(rhs_heatup_pj_taylor!, 10)
prob = InitialValueProblem(sys, X0)
try
alg = TMJets(orderT=4, orderQ=2, abstol=1e-9, maxsteps=100000)
t_start = time()
sol = solve(prob; T=T_probe, alg=alg)
elapsed = time() - t_start
flow = flowpipe(sol)
n_sets = length(flow)
println(" TMJets: $n_sets reach-sets, wall-time $(round(elapsed; digits=1)) s")
flow_hr = overapproximate(flow, Hyperrectangle)
n_steps = length(flow_hr)
# Per-timestep envelopes for plotting (saved below).
t_arr = zeros(n_steps)
Tc_lo_ts = zeros(n_steps); Tc_hi_ts = zeros(n_steps)
Tf_lo_ts = zeros(n_steps); Tf_hi_ts = zeros(n_steps)
Tco_lo_ts = zeros(n_steps); Tco_hi_ts = zeros(n_steps)
n_lo_ts = zeros(n_steps); n_hi_ts = zeros(n_steps)
rho_lo_ts = zeros(n_steps); rho_hi_ts = zeros(n_steps)
for (k, R) in enumerate(flow_hr)
s = set(R)
t_arr[k] = high(s, 10) # time-state upper bound ≈ current time
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)
Tco_lo_ts[k] = low(s, 9); Tco_hi_ts[k] = high(s, 9)
# Algebraic n reconstruction at this reach-set.
sumLC_lo = 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 = 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_here = KP_HEATUP * (T_STANDBY - high(s, 8))
rho_hi_here = KP_HEATUP * (T_C0 - low(s, 8))
rho_lo_ts[k] = rho_lo_here
rho_hi_ts[k] = rho_hi_here
denom_lo = BETA - rho_hi_here
denom_hi = BETA - rho_lo_here
if denom_lo > 0
n_lo_ts[k] = LAMBDA * sumLC_lo / denom_hi
n_hi_ts[k] = LAMBDA * sumLC_hi / denom_lo
end
end
# Also global-envelope values (for backward compat).
Tc_lo_env = minimum(Tc_lo_ts); Tc_hi_env = maximum(Tc_hi_ts)
Tf_lo_env = minimum(Tf_lo_ts); Tf_hi_env = maximum(Tf_hi_ts)
Tcold_lo = minimum(Tco_lo_ts); Tcold_hi = maximum(Tco_hi_ts)
n_env_lo = minimum(n_lo_ts); n_env_hi = maximum(n_hi_ts)
println(" n envelope (reconstructed): [$(round(n_env_lo; sigdigits=4)), $(round(n_env_hi; sigdigits=4))]")
println(" T_f envelope: [$(round(Tf_lo_env; digits=2)), $(round(Tf_hi_env; digits=2))] °C")
println(" T_c envelope: [$(round(Tc_lo_env; digits=2)), $(round(Tc_hi_env; digits=2))] °C")
println(" T_cold envelope: [$(round(Tcold_lo; digits=2)), $(round(Tcold_hi; digits=2))] °C")
results[T_probe] = (status="OK", n_sets=n_sets, elapsed=elapsed,
Tc=(Tc_lo_env, Tc_hi_env),
Tf=(Tf_lo_env, Tf_hi_env),
Tcold=(Tcold_lo, Tcold_hi),
n=(n_env_lo, n_env_hi),
t_arr=t_arr,
Tc_lo_ts=Tc_lo_ts, Tc_hi_ts=Tc_hi_ts,
Tf_lo_ts=Tf_lo_ts, Tf_hi_ts=Tf_hi_ts,
Tco_lo_ts=Tco_lo_ts, Tco_hi_ts=Tco_hi_ts,
n_lo_ts=n_lo_ts, n_hi_ts=n_hi_ts,
rho_lo_ts=rho_lo_ts, rho_hi_ts=rho_hi_ts)
catch err
msg = sprint(showerror, err)
println(" FAILED: ", first(msg, 300))
results[T_probe] = (status="FAILED", err=first(msg, 300))
break
end
end
println("\n=== Summary ===")
for T_probe in probe_horizons
haskey(results, T_probe) || continue
r = results[T_probe]
if r.status == "OK"
println(" T = $(T_probe) s: OK, $(r.n_sets) reach-sets, $(round(r.elapsed; digits=1))s wall")
else
println(" T = $(T_probe) s: FAILED")
end
end
# Save the longest-successful probe's envelope arrays for the app.
mat_out = joinpath(@__DIR__, "..", "..", "reachability", "reach_heatup_pj_result.mat")
saved = Dict{String, Any}()
saved["probe_horizons"] = collect(probe_horizons)
for T_probe in probe_horizons
haskey(results, T_probe) || continue
r = results[T_probe]
if r.status == "OK"
pre = "T_$(Int(T_probe))_"
saved[pre * "Tc_lo"] = r.Tc[1]
saved[pre * "Tc_hi"] = r.Tc[2]
saved[pre * "Tf_lo"] = r.Tf[1]
saved[pre * "Tf_hi"] = r.Tf[2]
saved[pre * "Tcold_lo"] = r.Tcold[1]
saved[pre * "Tcold_hi"] = r.Tcold[2]
saved[pre * "n_lo"] = r.n[1]
saved[pre * "n_hi"] = r.n[2]
# Per-timestep envelopes
saved[pre * "t_arr"] = r.t_arr
saved[pre * "Tc_lo_ts"] = r.Tc_lo_ts
saved[pre * "Tc_hi_ts"] = r.Tc_hi_ts
saved[pre * "Tf_lo_ts"] = r.Tf_lo_ts
saved[pre * "Tf_hi_ts"] = r.Tf_hi_ts
saved[pre * "Tco_lo_ts"] = r.Tco_lo_ts
saved[pre * "Tco_hi_ts"] = r.Tco_hi_ts
saved[pre * "n_lo_ts"] = r.n_lo_ts
saved[pre * "n_hi_ts"] = r.n_hi_ts
saved[pre * "rho_lo_ts"] = r.rho_lo_ts
saved[pre * "rho_hi_ts"] = r.rho_hi_ts
end
end
matwrite(mat_out, saved)
println("\nSaved envelope summaries to $mat_out")

View File

@ -1,109 +0,0 @@
#!/usr/bin/env julia
#
# reach_heatup_pj_tight.jl — heatup PJ reach with a tighter X_entry.
#
# The default X_entry (from predicates.json) has T_c ∈ [281, 295] — 14 K
# wide. The baseline PJ reach at T=300s produced T_c envelope
# [272.4, 295.0], violating the low-T_avg trip at 280.
#
# Hypothesis: entry-box width is contributing to tube growth. Try
# T_c ∈ [285, 291] (6 K) and T_f matched, see if the lower envelope
# rises above 280.
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
using LinearAlgebra
using ReachabilityAnalysis, LazySets
using MAT
# Same constants as reach_heatup_pj.jl.
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 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 T_STANDBY = T_C0 - 33.333333
const RAMP_RATE_CS = 28.0 / 3600
const KP_HEATUP = 1e-4
@taylorize function rhs_heatup_pj_tight!(dx, x, p, t)
rho = KP_HEATUP * (T_STANDBY + RAMP_RATE_CS * x[10] - x[8])
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])) / (M_SG * C_C)
dx[10] = one(x[1])
return nothing
end
# Tighter X_entry on T_c and T_f specifically.
n_lo, n_hi = 1.0e-3, 2.0e-3 # narrower n
T_f_lo, T_f_hi = 285.0, 291.0 # was 275295
T_c_lo, T_c_hi = 285.0, 291.0 # was 281295
T_cold_lo, T_cold_hi = 278.0, 285.0 # was 270281 (shifted up)
n_mid = 0.5 * (n_lo + n_hi)
C_mid = [BETA_1/(LAM_1*LAMBDA), BETA_2/(LAM_2*LAMBDA),
BETA_3/(LAM_3*LAMBDA), BETA_4/(LAM_4*LAMBDA),
BETA_5/(LAM_5*LAMBDA), BETA_6/(LAM_6*LAMBDA)] .* n_mid
x_lo = [C_mid[1]*(n_lo/n_mid), C_mid[2]*(n_lo/n_mid),
C_mid[3]*(n_lo/n_mid), C_mid[4]*(n_lo/n_mid),
C_mid[5]*(n_lo/n_mid), C_mid[6]*(n_lo/n_mid),
T_f_lo, T_c_lo, T_cold_lo, 0.0]
x_hi = [C_mid[1]*(n_hi/n_mid), C_mid[2]*(n_hi/n_mid),
C_mid[3]*(n_hi/n_mid), C_mid[4]*(n_hi/n_mid),
C_mid[5]*(n_hi/n_mid), C_mid[6]*(n_hi/n_mid),
T_f_hi, T_c_hi, T_cold_hi, 0.0]
X0 = Hyperrectangle(low=x_lo, high=x_hi)
println("\n=== Heatup PJ reach with TIGHTENED X_entry ===")
println(" T_c ∈ [$(T_c_lo), $(T_c_hi)] (width 6 K, was 14 K)")
println(" T_f ∈ [$(T_f_lo), $(T_f_hi)]")
println(" n-implied ∈ [$(n_lo), $(n_hi)]")
for T_probe in (60.0, 300.0)
println("\n--- Probe T = $T_probe s ---")
sys = BlackBoxContinuousSystem(rhs_heatup_pj_tight!, 10)
prob = InitialValueProblem(sys, X0)
try
alg = TMJets(orderT=4, orderQ=2, abstol=1e-9, maxsteps=100000)
t_start = time()
sol = solve(prob; T=T_probe, alg=alg)
elapsed = time() - t_start
flow = flowpipe(sol)
flow_hr = overapproximate(flow, Hyperrectangle)
Tc_lo_env = minimum(low(set(R), 8) for R in flow_hr)
Tc_hi_env = maximum(high(set(R), 8) for R in flow_hr)
Tf_lo_env = minimum(low(set(R), 7) for R in flow_hr)
Tf_hi_env = maximum(high(set(R), 7) for R in flow_hr)
println(" $(length(flow_hr)) sets in $(round(elapsed; digits=1))s")
println(" T_c envelope: [$(round(Tc_lo_env; digits=2)), $(round(Tc_hi_env; digits=2))] °C")
println(" T_f envelope: [$(round(Tf_lo_env; digits=2)), $(round(Tf_hi_env; digits=2))] °C")
println(" Low-T_avg trip (T_c ≥ 280): $(Tc_lo_env >= 280 ? "✅ DISCHARGED" : "× still loose")")
catch err
println(" FAILED: ", first(sprint(showerror, err), 200))
end
end

View File

@ -1,145 +0,0 @@
#!/usr/bin/env julia
#
# reach_heatup_pj_tight_full.jl — tight-entry heatup PJ reach,
# per-timestep envelopes saved for plotting (T_c, T_h, T_cold, rho, n).
#
# Identical dynamics to reach_heatup_pj_tight.jl but keeps the full
# tube data so we can overlay T_c and T_h (and infer power) on one plot.
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
using LinearAlgebra
using ReachabilityAnalysis, LazySets
using MAT
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 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 T_STANDBY = T_C0 - 33.333333
const RAMP_RATE_CS = 28.0 / 3600
const KP_HEATUP = 1e-4
@taylorize function rhs_heatup_pj_tf!(dx, x, p, t)
rho = KP_HEATUP * (T_STANDBY + RAMP_RATE_CS * x[10] - x[8])
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])) / (M_SG * C_C)
dx[10] = one(x[1])
return nothing
end
# Tight X_entry.
n_lo, n_hi = 1.0e-3, 2.0e-3
T_f_lo, T_f_hi = 285.0, 291.0
T_c_lo, T_c_hi = 285.0, 291.0
T_cold_lo, T_cold_hi = 278.0, 285.0
n_mid = 0.5 * (n_lo + n_hi)
C_mid = [BETA_1/(LAM_1*LAMBDA), BETA_2/(LAM_2*LAMBDA),
BETA_3/(LAM_3*LAMBDA), BETA_4/(LAM_4*LAMBDA),
BETA_5/(LAM_5*LAMBDA), BETA_6/(LAM_6*LAMBDA)] .* n_mid
x_lo = [C_mid[1]*(n_lo/n_mid), C_mid[2]*(n_lo/n_mid),
C_mid[3]*(n_lo/n_mid), C_mid[4]*(n_lo/n_mid),
C_mid[5]*(n_lo/n_mid), C_mid[6]*(n_lo/n_mid),
T_f_lo, T_c_lo, T_cold_lo, 0.0]
x_hi = [C_mid[1]*(n_hi/n_mid), C_mid[2]*(n_hi/n_mid),
C_mid[3]*(n_hi/n_mid), C_mid[4]*(n_hi/n_mid),
C_mid[5]*(n_hi/n_mid), C_mid[6]*(n_hi/n_mid),
T_f_hi, T_c_hi, T_cold_hi, 0.0]
X0 = Hyperrectangle(low=x_lo, high=x_hi)
println("\n=== Tight-entry heatup PJ reach — saving full per-step envelopes ===")
T_probe = 300.0
sys = BlackBoxContinuousSystem(rhs_heatup_pj_tf!, 10)
prob = InitialValueProblem(sys, X0)
alg = TMJets(orderT=4, orderQ=2, abstol=1e-9, maxsteps=100000)
t_start = time()
sol = solve(prob; T=T_probe, alg=alg)
println(" Wall time: $(round(time() - t_start; digits=1)) s")
flow_hr = overapproximate(flowpipe(sol), Hyperrectangle)
n_steps = length(flow_hr)
println(" $n_steps reach-sets")
t_arr = zeros(n_steps)
Tc_lo_ts = zeros(n_steps); Tc_hi_ts = zeros(n_steps)
Tf_lo_ts = zeros(n_steps); Tf_hi_ts = zeros(n_steps)
Tco_lo_ts = zeros(n_steps); Tco_hi_ts = zeros(n_steps)
n_lo_ts = zeros(n_steps); n_hi_ts = zeros(n_steps)
rho_lo_ts = zeros(n_steps); rho_hi_ts = zeros(n_steps)
for (k, R) in enumerate(flow_hr)
s = set(R)
t_arr[k] = high(s, 10)
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)
Tco_lo_ts[k] = low(s, 9); Tco_hi_ts[k] = high(s, 9)
sumLC_lo = 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 = 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)
# Ramp-ref bounds at this reach-set's t.
t_hi_here = high(s, 10)
t_lo_here = low(s, 10)
# T_ref at these times (monotone increasing):
Tref_lo = T_STANDBY + RAMP_RATE_CS * t_lo_here
Tref_hi = T_STANDBY + RAMP_RATE_CS * t_hi_here
rho_lo_here = KP_HEATUP * (Tref_lo - high(s, 8))
rho_hi_here = KP_HEATUP * (Tref_hi - low(s, 8))
rho_lo_ts[k] = rho_lo_here
rho_hi_ts[k] = rho_hi_here
denom_lo = BETA - rho_hi_here
denom_hi = BETA - rho_lo_here
if denom_lo > 0
n_lo_ts[k] = LAMBDA * sumLC_lo / denom_hi
n_hi_ts[k] = LAMBDA * sumLC_hi / denom_lo
end
end
println(" T_c envelope: [$(round(minimum(Tc_lo_ts); digits=2)), $(round(maximum(Tc_hi_ts); digits=2))] °C")
println(" T_hot envelope: [$(round(minimum(2 .* Tc_lo_ts .- Tco_hi_ts); digits=2)), $(round(maximum(2 .* Tc_hi_ts .- Tco_lo_ts); digits=2))] °C")
println(" T_cold env: [$(round(minimum(Tco_lo_ts); digits=2)), $(round(maximum(Tco_hi_ts); digits=2))] °C")
println(" rho envelope: [$(round(minimum(rho_lo_ts); sigdigits=4)), $(round(maximum(rho_hi_ts); sigdigits=4))]")
println(" rho / beta: [$(round(minimum(rho_lo_ts)/BETA; digits=3)), $(round(maximum(rho_hi_ts)/BETA; digits=3))]")
mat_out = joinpath(@__DIR__, "..", "..", "reachability", "reach_heatup_pj_tight_full.mat")
matwrite(mat_out, Dict(
"t_arr" => t_arr,
"Tc_lo_ts" => Tc_lo_ts, "Tc_hi_ts" => Tc_hi_ts,
"Tf_lo_ts" => Tf_lo_ts, "Tf_hi_ts" => Tf_hi_ts,
"Tco_lo_ts" => Tco_lo_ts, "Tco_hi_ts" => Tco_hi_ts,
"n_lo_ts" => n_lo_ts, "n_hi_ts" => n_hi_ts,
"rho_lo_ts" => rho_lo_ts, "rho_hi_ts" => rho_hi_ts,
"T_probe" => T_probe,
"beta" => BETA,
"Kp" => KP_HEATUP,
"T_c0" => T_C0,
"T_cold0" => T_COLD0,
"T_standby" => T_STANDBY,
))
println("\nSaved full per-step envelopes to $mat_out")

View File

@ -7,7 +7,7 @@
# names (overwrites MATLAB outputs — the Julia versions take over).
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using Printf
using LinearAlgebra
@ -16,18 +16,18 @@ using Plots
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", "pke_solver.jl"))
include(joinpath(@__DIR__, "..", "src", "plot_pke_results.jl"))
include(joinpath(@__DIR__, "..", "controllers", "controllers.jl"))
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", "pke_solver.jl"))
include(joinpath(@__DIR__, "..", "..", "src", "plot_pke_results.jl"))
include(joinpath(@__DIR__, "..", "..", "controllers", "controllers.jl"))
# --- Plant + predicates ---
plant = pke_params()
# Load T_standby from predicates.json for the hot-standby IC + heatup ref.
pred_path = joinpath(@__DIR__, "..", "..", "reachability", "predicates.json")
pred_path = joinpath(@__DIR__, "..", "..", "..", "reachability", "predicates.json")
pred_raw = JSON.parsefile(pred_path)
T_standby = plant.T_c0 + pred_raw["derived"]["T_standby_offset_C"]
@ -52,7 +52,7 @@ ctrl_op_lqr, K_lqr, _ = ctrl_operation_lqr_factory(
plant, A, B; Q_lqr=Matrix(Q_lqr), R_lqr=R_lqr)
# --- Figure output directory ---
figdir = joinpath(@__DIR__, "..", "..", "docs", "figures")
figdir = joinpath(@__DIR__, "..", "..", "..", "docs", "figures")
isdir(figdir) || mkpath(figdir)
# --- Run each mode ---

View File

@ -9,7 +9,7 @@
# trajectory that reach tubes can be checked against.
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using OrdinaryDiffEq

View File

@ -6,13 +6,13 @@
# and prints the final state, which should agree with MATLAB to ~1e-3.
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using OrdinaryDiffEq
include(joinpath(@__DIR__, "..", "src", "pke_params.jl"))
include(joinpath(@__DIR__, "..", "src", "pke_th_rhs.jl"))
include(joinpath(@__DIR__, "..", "controllers", "controllers.jl"))
include(joinpath(@__DIR__, "..", "..", "src", "pke_params.jl"))
include(joinpath(@__DIR__, "..", "..", "src", "pke_th_rhs.jl"))
include(joinpath(@__DIR__, "..", "..", "controllers", "controllers.jl"))
plant = pke_params()
x0 = pke_initial_conditions(plant)

View File

@ -11,17 +11,17 @@
# reconstructed n vs the dynamic n. Quantify the error explicitly.
using Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
Pkg.activate(joinpath(@__DIR__, "..", ".."))
using Printf
using LinearAlgebra
using OrdinaryDiffEq
using Plots
include(joinpath(@__DIR__, "..", "src", "pke_params.jl"))
include(joinpath(@__DIR__, "..", "src", "pke_th_rhs.jl"))
include(joinpath(@__DIR__, "..", "src", "pke_th_rhs_pj.jl"))
include(joinpath(@__DIR__, "..", "controllers", "controllers.jl"))
include(joinpath(@__DIR__, "..", "..", "src", "pke_params.jl"))
include(joinpath(@__DIR__, "..", "..", "src", "pke_th_rhs.jl"))
include(joinpath(@__DIR__, "..", "..", "src", "pke_th_rhs_pj.jl"))
include(joinpath(@__DIR__, "..", "..", "controllers", "controllers.jl"))
plant = pke_params()
T_standby = plant.T_c0 - 33.333333
@ -88,7 +88,7 @@ for t_check in (1.0, 5.0, 10.0, 60.0, 300.0, 1200.0, 3000.0)
end
# --- Plot trajectory overlay ---
figdir = joinpath(@__DIR__, "..", "..", "docs", "figures")
figdir = joinpath(@__DIR__, "..", "..", "..", "docs", "figures")
isdir(figdir) || mkpath(figdir)
CtoF(T) = T*9/5 + 32

View File

@ -0,0 +1,395 @@
# Preliminary Example Presentation — Assertion-Evidence Outline
**Format:** Assertion-evidence (Alley). Each slide: one declarative sentence
at the top, one piece of visual evidence below. No bullet soup.
**Duration:** 20 minutes. ~12 slides at ~1.5 min each (60 s typical, up to
2 min for the heavier ones).
**Audience:** OT-informed cybersecurity experts. Mostly CS, some control-theory
familiarity, very little reactor-physics background. Can assume fluency with:
LTL, automata, model-checking, reachability (as a concept), SMT/SAT. Must
explain: PKE, reactivity, precursors, hybrid systems.
**Running thread:** FRET natural-language requirement → LTL → synthesized
discrete controller → continuous plant → per-mode reach-avoid proof
→ hybrid correctness by composition.
**Design principles:**
- **Plots over bullets.** Every result slide anchors on one figure.
- **Physical intuition before math.** Reactor basics before PKE.
- **Honest limitations boxed on each result slide.** Audience are cyber
folks — they respect limits more than triumphs.
- **CS vocabulary by default, engineering terms defined inline.**
- **End with the seam**, not with a victory lap. The thesis question is
"how do discrete proofs and continuous proofs compose?" not "we verified
everything."
---
## Slide 1 — Title + hook
**Assertion:** Verified hybrid control for nuclear startup is a real problem
with no deployed solution.
**Evidence:** Title block + a single image showing a control-room board of
old-school analog gauges adjacent to a digital SCADA screen. Caption: "The
gap we're filling."
**Speak:** Introduce self + advisor + NRC fellowship context. Name the problem:
nuclear reactor operations are 70-80% human-error-driven at severe-accident
level (Chernobyl, TMI, Fukushima all primarily human factors). Plants run on
paper procedures that have no formal verification. The most advanced work to
date (HARDENS, NRC-funded) verified the discrete trip system in isolation and
stopped there. This talk: a preliminary example of bridging discrete
requirements all the way to continuous-state safety proofs, with the seam
between them as the research contribution.
**Reference:** thesis §2, ¶1-4. Cites Kemeny1979, Hogberg_2013, Kiniry2024.
**Figures to make:** find or compose the control-room image.
---
## Slide 2 — The hybrid-systems gap
**Assertion:** Existing tools verify either discrete *or* continuous systems,
never the seam between them.
**Evidence:** Two-column diagram. Left column: discrete (FRET, Spot,
SAL, HARDENS, TLA+). Right column: continuous (MATLAB, Modelica, CORA,
Flow*, HyTech). A dashed line between them labeled "the bridge problem."
**Speak:** HARDENS got to TRL 2-3 on the discrete side alone. Continuous-side
reach tools exist but assume the discrete controller is given as input. What
nobody has done: produce an *end-to-end* proof where the discrete controller's
transitions actually fire in physical time on the modeled plant.
**Reference:** thesis §2.2 "Formal Methods", §2.2.1 HARDENS, §2.2.2 Temporal
Logic. Thesis §2.3 (if exists — continuous methods).
---
## Slide 3 — Running example: the PWR_HYBRID_3 controller
**Assertion:** Our target is a 4-mode hybrid controller that takes a PWR
from hot standby to full power and back.
**Evidence:** The DRC state diagram (from `fret-pipeline/diagrams/PWR_HYBRID_3_DRC_states.png`).
Four nodes — shutdown, heatup, operation, scram — with labeled transitions.
**Speak:** One sentence of nuclear physics: a PWR has neutron population
(power), coolant temperatures at three locations, and precursor concentrations
that determine delayed-neutron generation. Startup is shutdown → heatup →
operation; any fault triggers scram. Mode transitions gated by boolean
conditions like `t_avg_in_range ∧ p_above_crit ∧ inv1_holds`.
Four modes means four *physical* behaviors the plant has to exhibit for
the discrete logic to be sound. This example stresses every layer of the
bridge we're building.
**Evidence path:** `fret-pipeline/diagrams/PWR_HYBRID_3_DRC_states.png`,
`fret-pipeline/pwr_hybrid_3.json` for one example requirement.
---
## Slide 4 — Layer 1: FRET → LTL → synthesized DRC
**Assertion:** Natural-language requirements become a verified discrete
controller automatically.
**Evidence:** One-panel figure: left half = snippet of FRET natural language
("Upon `control_mode = q_heatup ∧ t_avg_in_range ∧ p_above_crit ∧ inv1_holds`
DRC shall at the next timepoint satisfy `control_mode = q_operation`"), right
half = the corresponding LTL, bottom = arrow labeled `ltlsynt` pointing to
the synthesized state-diagram node.
**Speak:** FRET compiles natural-language requirements into LTL (Spot
ecosystem). `ltlsynt` turns LTL into an AIGER circuit representing the
minimal correct discrete controller. This is well-trodden ground in CS-land;
our contribution is *using it* on reactor operating procedures — so far
a formal-methods-free domain.
**Evidence path:** `fret-pipeline/README.md`, `pwr_hybrid_3.json` sample
requirement, `fret-pipeline/circuits/PWR_HYBRID_3_DRC.aag` AIGER circuit.
---
## Slide 5 — Layer 2: the plant model
**Assertion:** A 10-state PKE + thermal-hydraulics model is faithful enough
for reach analysis and tractable enough to actually compute with.
**Evidence:** The state-vector diagram: `x = [n, C_1, ..., C_6, T_f, T_c, T_cold]`
with arrows showing the coupling — neutron balance → fuel heating → coolant
transport → feedback to reactivity.
**Speak:** Point-kinetic equations + three thermal nodes. 10 states, 1
control input (rod worth u), 1 disturbance (steam-generator heat removal Q_sg).
Stiff system: prompt-neutron timescale is 10⁻⁴ s, thermal timescales are
seconds to minutes. That stiffness becomes critical later — it's what forces
the singular-perturbation move.
Five controllers: one per DRC mode (shutdown, heatup, operation under P, under
LQR, scram). All Julia, all in `code/src/` + `code/controllers/`.
**Evidence path:** `code/src/pke_th_rhs.jl`, `journal/journal.pdf` entry
2026-04-17 for derivation.
**Figures to make:** state-vector coupling diagram (can be ASCII → inkscape).
---
## Slide 6 — Layer 3: the reach-avoid obligation per mode
**Assertion:** Discrete correctness transfers to continuous correctness
via one reach-avoid obligation per mode.
**Evidence:** Taxonomy table from `reachability/WALKTHROUGH.md`:
| Mode | Kind | Obligation |
|---|---|---|
| shutdown | equilibrium | stay in X_safe forever |
| heatup | transition | from X_entry, reach X_exit in [T_min, T_max] while maintaining inv1 |
| operation | equilibrium | stay in X_safe forever under bounded Q_sg |
| scram | transition | from any mode, drive n safely subcritical in T_max |
**Speak:** This is the structural insight. Equilibrium modes → forever-invariance
proofs. Transition modes → reach-avoid proofs with time budgets. The DRC
transitions fire iff the reach set enters the right exit predicate. If
every mode's obligation is discharged, the hybrid system is correct by
composition. The methodological contribution of the chapter.
**Reference:** `reachability/WALKTHROUGH.md` §1, `reachability/predicates.json`
`mode_boundaries`.
---
## Slide 7 — Operation mode: discharge all 6 safety halfspaces (the win)
**Assertion:** Under LQR feedback and ±15% Q_sg variation, the operation-mode
reach tube discharges every safety halfspace with orders of magnitude of margin.
**Evidence:** The two-panel figure `docs/figures/reach_operation_tubes.png`.
Left: temperature tubes (T_c, T_hot, T_cold) overlaid; the near-zero width
of T_c visually demonstrates LQR contraction. Right: ΔT_core tube with a
right-axis in MW showing the power is tight around nominal.
Per-halfspace margin table (inset or second slide if space allows):
| Halfspace | Limit | Reach max | Margin |
|---|---:|---:|---:|
| fuel_centerline | 1200 °C | 328.9 | **+871 K** |
| t_avg_high_trip | 320 °C | 308.4 | **+11.6 K** |
| n_high_trip | 1.15 | 1.012 | **+0.14** |
**Speak:** LQR contracts the regulated direction (T_c) from 0.1 K to 0.03 K
halfwidth. Uncontrolled directions (precursors) expand but don't appear in
any safety predicate. Tightest margin is the high-flux trip at 12% headroom.
**Limitations box:** linear-reach of a nonlinear plant — *approximate, not
sound* yet for the physical plant. That's what the next slides address.
**Evidence path:** `code/scripts/reach/reach_operation.jl`,
`docs/figures/reach_operation_tubes.png`.
---
## Slide 8 — Quadratic Lyapunov barrier fails structurally
**Assertion:** The canonical quadratic Lyapunov barrier cannot certify this
plant — *even with LQR inside the barrier* — because of plant anisotropy.
**Evidence:** Bar chart comparing open-loop vs LQR-closed-loop Lyapunov bound
on `n_high_trip` direction: OL is 27 million × nominal, CL is 1,242 × nominal.
Title: "LQR helps 20,000× but both bounds are physically meaningless."
**Speak:** Plant has prompt timescale 10⁻⁴ s vs thermal timescale ~10 s.
Lyapunov matrix P inherits that 10⁴ spread. Resulting ellipsoid stretches
obscenely along the fast directions when projected to physical coordinates.
No amount of controller tuning fixes this — it's set by the plant's own
physics. The ellipsoid geometry cannot match the slab-shaped safe region.
**This is the motivation for polynomial (SOS) or polytopic barriers.**
**Reference:** `code/scripts/barrier/barrier_compare_OL_CL.jl`, journal entry
2026-04-20 (evening).
---
## Slide 9 — Prompt-jump reduction makes nonlinear reach tractable
**Assertion:** Singular-perturbation reduction of the fast neutronics gives
us 30× more reach horizon and a rigorous O(Λ) error bound via Tikhonov.
**Evidence:** Side-by-side: left panel = validate_pj_heatup.png (empirical:
PJ and full-state trajectories overlay perfectly over 50 minutes). Right panel
= the Tikhonov bound written out mathematically:
$$|x(t) - x_{\mathrm{PJ}}(t)| \leq C \cdot \Lambda = \mathcal{O}(10^{-4})$$
**Speak:** Setting dn/dt = 0 and solving algebraically for n gives us
`n = Λ·Σλᵢ·Cᵢ / (β - ρ)`. State drops from 10 to 9, removes the Λ⁻¹
stiffness. Reach tool (TMJets) can take steps on thermal timescale instead
of prompt timescale.
The magic: we *prove* the PJ approximation's validity condition (`β - ρ > 0`
with margin) **as part of the same reach obligation** via the
`prompt_critical_margin_heatup` halfspace in `inv1_holds`. No hand-waving —
the reach machinery proves both the safety claim and the approximation's
soundness together.
**Evidence path:** `code/src/pke_th_rhs_pj.jl`, `code/scripts/sim/validate_pj.jl`,
`journal/` entry 2026-04-21 "Tikhonov bound".
---
## Slide 10 — First sound nonlinear heatup reach (the second win)
**Assertion:** With PJ + a tightened entry box, nonlinear Taylor-model reach
discharges all 6 `inv1_holds` safety halfspaces over the first 300 s of heatup.
**Evidence:** The four-panel `reach_heatup_pj_tubes.png`: temperature tubes
overlaid, ΔT_core, ρ in dollars (stays between -0.25 $ and -0.05 $ —
sub-prompt-critical), n decaying.
**Speak:** TMJets Taylor-model integration. 12,932 reach-sets, 200 s wall time.
Tube **stable** — T_c envelope `[281.05, 291.0]` identical at 60 s and 300 s,
meaning the controller holds the state inside the tube indefinitely. This
is the first sound (modulo O(Λ) PJ error) nonlinear reach-avoid artifact
for this plant.
**Limitations box:** 300 s of a 5-hour `T_max`. Step budget caps horizon; entry
refinement (Blanchini-style) is the path to hours.
**Evidence path:** `code/scripts/reach/reach_heatup_pj.jl configs/heatup/tight.toml`,
`docs/figures/reach_heatup_pj_tubes.png`.
---
## Slide 11 — Degree-4 SOS polynomial barrier, a working proof of concept
**Assertion:** A degree-4 polynomial barrier certificate — the structure that
quadratic Lyapunov cannot provide — works on the operation-mode projection.
**Evidence:** Equation block showing the symbolic polynomial
(abbreviated — the actual 13-term polynomial from `code/scripts/barrier/barrier_sos_2d.jl`).
Side caption: "CSDP returned OPTIMAL. Solve: ~3 seconds."
**Speak:** Polynomials of degree 4 can localize to slab-shaped safety regions
in a way degree-2 (quadratic) cannot. The Prajna-Jadbabaie formulation: find
`B(x)` such that `B ≤ 0` on entry, `B ≥ 0` on unsafe, and `∇B·f ≤ 0` on
`{B=0}`. Sum-of-squares programming reduces this to an SDP. 2D projection
proof of concept; scaling to full 10-state requires bigger solver (Mosek or SCS).
**Reference:** `code/scripts/barrier/barrier_sos_2d.jl`, journal entry
2026-04-21.
---
## Slide 12 — The hybrid correctness story, assembled
**Assertion:** All four pieces — FRET/synthesis, plant model, reach tubes,
SOS/polytopic barriers — compose into a hybrid system correctness argument.
**Evidence:** The composition picture. DRC state diagram at center; each
DRC node has an arrow to a "per-mode obligation box" labeled with its proof
artifact (tube or barrier or certificate). Arrows between nodes = transitions,
labeled with the predicate polytope. Outer dashed box: "hybrid system
closed-loop safety."
**Speak:** What's been built, what's been proven, what's next. Transition
correctness between modes is the next thesis-blocking piece — each mode's
`X_exit` has to be inside the next mode's `X_entry` (with margin). That's an
inclusion check, not a reach — but it's the final structural piece.
---
## Slide 13 — Limitations and next steps
**Assertion:** This is a preliminary example, not a deployable system.
**Evidence:** A two-column list:
| What's proven | What's next |
|---|---|
| Operation mode safe under ±15% load (approximate, via linear reach) | Nonlinear operation reach (tight SOS barrier, LOCA disturbance) |
| Heatup PJ reach discharges all 6 safety halfspaces for 300 s | Extend to full 5-hr `T_max`; model steam-dump disturbance |
| Scram PJ n-decay monotone over 60 s | Expand `X_entry(scram)` to union of all mode tubes + LOCA |
| Degree-4 SOS barrier on 2D projection | Full 10-state SOS barrier |
| PJ error rigorously bounded by Tikhonov | Parametric α uncertainty; DNBR correlation |
**Speak:** The gap from prelim example to deployable system is well-defined:
more states, tighter bounds, real tech-spec numbers, hardware-in-the-loop.
None of these is a research novelty unto itself — they're engineering.
The research contribution is the composition framework, demonstrated end-to-end
on a nontrivial running example. Phase 2 of the thesis is filling in the
gaps and expanding to multiple plants.
---
## Slide 14 — Questions / acknowledgements
**Assertion:** (none — backup slide)
**Evidence:** Thanks to advisor, committee, NRC fellowship. Links to:
repo (gitea), journal PDF, thesis in progress.
**Speak:** Open for questions. Expect questions on:
- "Why not Stateflow/Simulink?" → (have answer prepared; HARDENS used
Cryptol for a reason — formal tool integration matters)
- "What's the relationship to LTLMoP / DragonFly?" → (survey answer)
- "How would this interact with ML components?" → (out of scope for now,
the whole pitch is *no ML in safety-critical loop*)
- "What's your threat model for cybersecurity?" → (tie back to OT audience —
formal methods guarantee the controller's logic; they do not protect the
comms layer or implementation-level vulns. Mention HARDENS' focus on
"correctness of implementation" vs our focus on "correctness of
specification")
---
## Presentation construction notes
### What to build in Beamer later
- Assertion-evidence template (one sentence at top, centered figure below).
- Consistent color coding: green for "discharged" / "proved", red for
"limitation" / "gap", blue for "discrete-layer", amber for "continuous-layer".
- The composition diagram on slide 12 — this will take the most Tikz work.
### Figures that need to be created or dressed up for the talk
- Slide 1: control-room visual.
- Slide 2: discrete/continuous tools comparison.
- Slide 3: use `fret-pipeline/diagrams/PWR_HYBRID_3_DRC_states.png`.
- Slide 4: FRET → LTL → AIGER panel.
- Slide 5: 10-state coupling diagram.
- Slide 7, 10: reuse `docs/figures/reach_*_tubes.png`.
- Slide 8: bar chart (new, easy matplotlib).
- Slide 9: reuse `validate_pj_heatup.png`.
- Slide 11: latex-typeset polynomial + CSDP log snippet.
- Slide 12: composition diagram (Tikz, will take time).
### Cybersecurity angle to emphasize
- The point the OT audience will care about: this kind of verification
**constrains what the controller can physically do**. Even if an attacker
gets past authentication and can inject arbitrary commands, the DRC-plus-
reach-certified envelope limits how bad things can get *within the
physical plant*. That's a different assurance axis than the usual
comms-security one and complements it.
- Formal methods as defense-in-depth: they catch bugs *before* deployment,
which reduces attack surface more than any runtime defense.
- The PJ reduction + Tikhonov approach might be of interest for other
safety-critical stiff systems (power grid, aerospace).
### Things to NOT do
- Don't get lost in reactor-physics detail. One sentence per physical
concept; get them to the CS content fast.
- Don't show code unless it's a slide about *why* the code structure
matters. Code screenshots are terrible evidence.
- Don't oversell. Honest limitations at every stage builds trust with
a skeptical audience.
- Don't use more than 2 bullet points on any slide. Alley rules.
### Timing checkpoints
- Slide 6 (mode-obligation taxonomy) by minute 8.
- Slide 10 (first nonlinear reach result) by minute 13.
- Slide 12 (composition story) by minute 17.
- Slide 13 (limitations) by minute 19.

1
results/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.mat

32
results/README.md Normal file
View File

@ -0,0 +1,32 @@
# results/
Output artifacts from reach / barrier / validation runs. All files
in this directory are **gitignored** — they're regenerated by running
the corresponding script.
## What lives here
Named `<analysis>_<variant>.mat` (MATLAB v7 format, read/written via
Julia's MAT.jl for historical convenience — switching to JLD2 is a
deferred item, see `journal/` for context).
Generated by (as of 2026-04-21):
| File | Generated by |
|---|---|
| `reach_operation_result.mat` | `code/scripts/reach/reach_operation.jl` |
| `reach_heatup_pj_tight_full.mat` | `code/scripts/reach/reach_heatup_pj_tight_full.jl` |
| `reach_scram_pj_result.mat` | `code/scripts/reach/reach_scram_pj.jl` |
Consumed by:
- `code/scripts/plot/plot_reach_tubes.jl` — tube overlay figures.
- `app/predicate_explorer.jl` — live per-halfspace margin rendering.
## Why this directory exists
`reachability/` used to hold both source-of-truth specs
(`predicates.json`, README, WALKTHROUGH.md) and ephemeral reach results.
Mixing stable vs.\ regenerated content made it hard to tell which was
which at a glance. Split: `reachability/` = specs + docs,
`results/` = outputs.