### A Pluto.jl notebook ### # v0.19.40 using Markdown using InteractiveUtils # This Pluto notebook uses @bind for interactivity. The macro is defined locally # so the file remains a valid standalone Julia script when Pluto isn't running. macro bind(def, element) #= none:1 =# quote local iv = try Base.loaded_modules[Base.PkgId(Base.UUID("6e696c72-6542-2067-7265-42206c756150"), "AbstractPlutoDingetjes")].Bonds.initial_value catch b -> missing end local el = $(esc(element)) global $(esc(def)) = Core.applicable(Base.get, el) ? Base.get(el) : iv(el) el end end # ╔═╡ 9d14e486-1faa-45a9-b235-6367a039f9da begin using Pkg Pkg.activate(@__DIR__) using JSON using PlutoUI using Plots using MAT gr() end # ╔═╡ 08e80248-89fe-4639-b55c-f10d96de6c31 md""" # 🦎 Predicate Explorer > *Hybrid-systems extension to FRET — the bridge between boolean predicates > and continuous-state halfspaces.* This notebook is the visual companion to `reachability/predicates.json`. Each FRET-spec predicate (a boolean condition over named variables) maps to a **numerical halfspace** over the 10-state continuous vector ``x = [n,\, C_1, \ldots, C_6,\, T_f,\, T_c,\, T_{\mathrm{cold}}]``. Use it to: - Inspect what each predicate *physically means*. - See how mode invariants compose from named safety limits. - Trace which reach artifact has tried to discharge each obligation. - Sketch edits in a UI that mirrors the JSON structure (read-only v1 — edits do not persist). The data behind everything below is `../reachability/predicates.json`. Edit the JSON, restart this notebook, results re-render. """ # ╔═╡ d7cadb58-e7c4-4701-8fcb-380f1e948d42 md""" ## 1 · Source of truth — load `predicates.json` """ # ╔═╡ c995a73a-c07e-415b-abe8-57abaf9f4b46 begin pred_path = joinpath(@__DIR__, "..", "reachability", "predicates.json") pred_raw = JSON.parsefile(pred_path) md"Loaded `$(relpath(pred_path))`. Top-level keys: $(join(sort(collect(keys(pred_raw))), ", "))." end # ╔═╡ 48f4bec1-ac48-4318-ba6e-92dbf80a7b2d md""" ## 2 · Plant-derived constants These are computed once from `pke_params()` (the plant model in `../code/`) and used as the basis for every halfspace. | Symbol | Meaning | Value | |---|---|---| | `T_c0` | Operating-point average coolant T | $(round(308.349; digits=2)) °C | | `T_f0` | Operating-point fuel T | $(round(328.349; digits=2)) °C | | `T_cold0` | Operating-point cold-leg T | 290.00 °C | | `T_standby` | Hot-standby T | $(round(308.349 + pred_raw["derived"]["T_standby_offset_C"]; digits=2)) °C | Hot standby is defined as `T_c0 - 60 °F = T_c0 - 33.33 °C`. Source-of-truth constants live under the `derived` key in the JSON. """ # ╔═╡ 7ec2cd9d-5c5c-47d7-aa3c-189091b97204 md""" ## 3 · Operational deadbands Soft bands used by the **DRC** to switch modes. These are *not* safety properties — violating them triggers a mode change or operator alert, not damage. | Predicate | Meaning | |---|---| $(join(["| `$name` | $(get(entry, "meaning", "")) |" for (name, entry) in pred_raw["operational_deadbands"] if !startswith(name, "_")], "\n")) """ # ╔═╡ e99c4c2b-7d29-49a4-a474-047f204e331f md""" ### Concretization of `t_avg_in_range` > *"Average coolant in tight operating band — used for heatup → operation transition."* In FRET this is the boolean predicate `t_avg_in_range`. In the continuous state space it concretizes to the box ``` T_c0 - 2.778 ≤ T_c ≤ T_c0 + 2.778 (°C) ``` Halfwidth = 2.778 °C ≈ 5 °F (typical PWR T_avg deadband). The two halfspace rows live as `state_index = 9` (which is `T_c`) with coefficients ±1 and the appropriate offset. """ # ╔═╡ 408432c8-2cc6-4505-8623-9f5614a72c4d md""" ## 4 · Safety limits — the hard constraints These are **one-sided halfspaces** corresponding to physical damage mechanisms or reactor-trip setpoints. Asymmetric: the plant is not equally vulnerable on both sides of the setpoint. These are what reach analyses target. """ # ╔═╡ 0ef6cd25-7f9c-4d64-afdb-ffe8af1ac967 begin safety_rows = [] for (name, entry) in pred_raw["safety_limits"] startswith(name, "_") && continue meaning = get(entry, "meaning", "") concret = get(entry, "concretization", "") push!(safety_rows, (name=name, meaning=meaning, concretization=concret)) end md_table = "| Limit | What it protects | Concretization |\n|---|---|---|\n" for r in safety_rows md_table *= "| `$(r.name)` | $(r.meaning) | `$(r.concretization)` |\n" end Markdown.parse(md_table) end # ╔═╡ 68b1c7bf-c498-41a9-a435-1332c8154035 md""" ## 5 · Mode invariants — conjunctions of safety limits `inv1_holds` (heatup) and `inv2_holds` (operation) are safety envelopes, declared as a conjunction of named entries from the safety-limits group. Changing a limit propagates automatically. """ # ╔═╡ 06be0c87-2a28-4ab3-9fa1-ccde3a9b70d9 begin function render_invariant(name) haskey(pred_raw["mode_invariants"], name) || return md"_(not present)_" entry = pred_raw["mode_invariants"][name] components = entry["conjunction_of"] components = isa(components, String) ? [components] : components comp_md = join(["- `$c`" for c in components], "\n") Markdown.parse( "**`$name`**: $(get(entry, "meaning", ""))\n\nConjunction of:\n\n$comp_md\n" ) end render_invariant("inv1_holds") end # ╔═╡ 8a24ff1e-0afb-4707-89d7-fa50e3b74dbd render_invariant("inv2_holds") # ╔═╡ e67f3259-ce97-4bf5-9e63-8f3aef7c6c56 md""" ## 6 · Mode boundaries — entry / safe / exit / time For each DRC mode, the reach-analysis triple: where does state start, where must it stay, where must it land, by when. **Equilibrium modes** have no T_max (forever-invariance is the obligation). **Transition modes** have both T_min and T_max (reach-avoid is the obligation). """ # ╔═╡ a17586e3-b8f5-4608-950b-69ae45edd6b8 begin mb = pred_raw["mode_boundaries"] function render_mode(name) haskey(mb, name) || return md"_(not present)_" m = mb[name] kind = get(m, "kind", "?") oblig = get(m, "obligation", "?") Tmax = get(m, "T_max_seconds", nothing) Tmin = get(m, "T_min_seconds", nothing) time_md = if Tmax === nothing "_(equilibrium mode — no time bound)_" else tmax_str = "$(round(Tmax / 60; digits=1)) min ($(Tmax) s)" tmin_str = Tmin === nothing ? "_unconstrained_" : "$(round(Tmin / 60; digits=1)) min ($(Tmin) s)" "T_min: $(tmin_str) · T_max: $(tmax_str)" end x_entry = if haskey(m, "X_entry_polytope") && isa(m["X_entry_polytope"], AbstractDict) ranges = [] for (k, v) in m["X_entry_polytope"] isa(v, Vector) && length(v) == 2 || continue push!(ranges, " - `$k`: [$(v[1]), $(v[2])]") end join(ranges, "\n") else "_(non-polytope or referenced — see JSON)_" end x_safe = get(m, "X_safe_predicate", "?") x_exit = get(m, "X_exit_predicate", "?") Markdown.parse(""" **`$name`** ($(kind)) $oblig **X_entry:** $x_entry **X_safe:** `$x_safe` **X_exit:** `$x_exit` **Time:** $time_md """) end render_mode("q_shutdown") end # ╔═╡ dc8758ad-904c-4917-9fba-9a3e42e05e34 render_mode("q_heatup") # ╔═╡ 60bd9d1d-9a04-48e3-a3c9-a84b8bc91009 render_mode("q_operation") # ╔═╡ 561c3d22-4d11-4511-90b7-63b77829454b render_mode("q_scram") # ╔═╡ f50515e2-b205-45ed-acc9-9790697345f8 md""" ## 7 · Visualization — `t_avg_in_range` × `n_high_trip` projection A 2D slice through state space showing the operational deadband on `T_c` (blue band) inside the high-flux trip on `n` (red ceiling). The intersection is the comfortable operating polytope — where reach tubes should live. """ # ╔═╡ 015fa1f4-9813-4ede-ad42-8b4f2340bbb8 begin T_c0 = 308.349 Tc_lo = T_c0 - 2.778 Tc_hi = T_c0 + 2.778 n_high = 1.15 n_low = 0.15 p = plot(xlabel="T_avg [°C]", ylabel="n", xlim=(280, 322), ylim=(-0.1, 1.4), title="Operating polytope (intersection of t_avg_in_range × n bounds)", size=(700, 450), legend=:topleft) # Operating box plot!(p, [Tc_lo, Tc_hi, Tc_hi, Tc_lo, Tc_lo], [n_low, n_low, n_high, n_high, n_low], linewidth=2, color=:green, label="t_avg_in_range × n bounds") # Trip lines hline!(p, [n_high], ls=:dash, color=:red, label="n_high_trip = 1.15") hline!(p, [n_low], ls=:dash, color=:red, label="n_low_operation = 0.15") vline!(p, [Tc_lo, Tc_hi], ls=:dot, color=:blue, label="t_avg_in_range ± 2.78 °C") scatter!(p, [T_c0], [1.0], color=:black, markersize=8, label="x_op (operating point)") end # ╔═╡ 210a3da2-6a5d-48b2-a48e-d87132d5a32f md""" ## 8 · Edit panel (UX preview — does not persist) This panel shows what live editing would look like. **Sliders below do not write back to `predicates.json`** in v1. Use it to feel out the workflow; file an issue if a key knob is missing. """ # ╔═╡ 3770e247-c2bd-41a1-9470-0f145f73a894 @bind ui_t_avg_halfwidth Slider(0.5:0.25:8.0, default=2.78, show_value=true) # ╔═╡ 8c67770b-58f8-4868-a3e2-53620565ab5d @bind ui_n_high_trip Slider(1.05:0.01:1.30, default=1.15, show_value=true) # ╔═╡ f88f6809-ad3e-4696-bb12-ad8c30d66a9a @bind ui_t_standby_offset_F Slider(-90.0:5.0:-30.0, default=-60.0, show_value=true) # ╔═╡ b7fdf92d-948b-41f2-8516-dd8fc20c2efe md""" **Sketched changes (preview only):** - `t_avg_in_range_halfwidth`: $(ui_t_avg_halfwidth) °C (current JSON: 2.778) - `n_high_trip`: $(ui_n_high_trip) (current JSON: 1.15) - `T_standby` offset from T_c0: $(ui_t_standby_offset_F) °F (current JSON: -60.0) To apply, edit `../reachability/predicates.json` directly. (Write access will land in app v2.) """ # ╔═╡ 0560c66a-c12f-4f15-bc49-2c9c8164abd1 md""" ## 9 · Reach traceability Which reach artifact has covered which mode invariant? Manual map for now — later versions will pull this from the latest reach-result `.mat` files. """ # ╔═╡ 1f6abf31-3618-46a5-9f57-4cb3c8022f89 md""" | Mode | Invariant | Reach status | Artifact | |---|---|---|---| | `q_operation` | `inv2_holds` | ✅ all 6 halfspaces pass (linear, approximate) | `code/scripts/reach_operation.jl` | | `q_operation` | `inv2_holds` | ❌ Lyapunov barrier fails all 6 (anisotropy) | `code/scripts/barrier_lyapunov.jl` | | `q_heatup` | `inv1_holds` | ⚠️ TMJets nonlinear works to 10s only (stiffness) | `code/scripts/reach_heatup_nonlinear.jl` | | `q_heatup` | `inv1_holds` | ❌ no linear reach (time-varying ref needs LTV) | — | | `q_scram` | (TBD) | ❌ not started | — | | `q_shutdown` | (TBD) | ❌ not started (trivial — constant u) | — | """ # ╔═╡ a74106fb-d501-428a-a02e-41f55b49b3da md""" ## 9b · Live reach result ingestion If `code/scripts/reach_operation.jl` has been run, `reach_operation_result.mat` exists in `../reachability/`. Load it and show per-halfspace margins live — no more hand-maintained traceability table. """ # ╔═╡ 37d6b212-9f00-4684-9f91-50c7e17cbd62 begin 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) md"""**Loaded** `reach_operation_result.mat` — keys: $(join(sort(collect(keys(reach_mat))), ", ")).""" else md"""_(no `reach_operation_result.mat` found — run `cd code && julia --project=. scripts/reach_operation.jl`)_""" end end # ╔═╡ df53126a-1efa-4cc7-87f4-be4ff0808513 begin inv2_haspath = haskey(pred_raw["mode_invariants"], "inv2_holds") if have_reach_mat && inv2_haspath # Reconstruct per-halfspace margins from the loaded reach tube. T_op_const = 308.349 T_f0_const = 328.349 Tcold0_const = 290.0 Tstandby_local = T_op_const + pred_raw["derived"]["T_standby_offset_C"] eval_local(expr) = let T_c0=T_op_const, T_f0=T_f0_const, T_cold0=Tcold0_const, T_standby=Tstandby_local eval(Meta.parse(expr)) end # Build A_inv, b_inv from JSON for inv2_holds (same logic as load_predicates). inv2_entry = pred_raw["mode_invariants"]["inv2_holds"] components = inv2_entry["conjunction_of"] A_inv = zeros(0, 10) b_inv = zeros(0) labels = String[] for comp in components entry = pred_raw["safety_limits"][comp] for hs in entry["halfspaces"] row = zeros(1, 10) if haskey(hs, "row") for pair in hs["row"] row[1, Int(pair[1])] = Float64(pair[2]) end else row[1, Int(hs["state_index"])] = Float64(hs["coeff"]) end A_inv = vcat(A_inv, row) b = eval_local(hs["rhs_expr"]) b_inv = vcat(b_inv, b) push!(labels, comp) end end X_lo_mat = reach_mat["X_lo"] X_hi_mat = reach_mat["X_hi"] rows = String[] push!(rows, "| Safety halfspace | Limit | Reach max | Margin | |") push!(rows, "|---|---:|---:|---:|---|") for k in 1:size(A_inv, 1) a = A_inv[k, :] env = (a .> 0) .* X_hi_mat .+ (a .< 0) .* X_lo_mat zero_mask = (a .== 0) env[zero_mask, :] .= X_hi_mat[zero_mask, :] max_ax = maximum(a' * env) margin = b_inv[k] - max_ax badge = margin >= 0 ? "✅" : "❌" push!(rows, "| `$(labels[k])` | $(round(b_inv[k]; digits=3)) | $(round(max_ax; digits=3)) | $(round(margin; digits=3)) | $badge |") end Markdown.parse(join(rows, "\n")) else md"_(reach result or inv2_holds unavailable; cannot render live margins)_" end end # ╔═╡ 9bd8d609-ffa4-46a7-8f0f-d093d87e45e9 md""" ## 9c · 2D projection chooser Pick any two state coordinates; render the operating polytope inside the safety limits, with the reach tube envelope overlaid (if loaded). """ # ╔═╡ e4d56600-7a46-46a1-a7c9-f65a5db95f66 @bind proj_x Select(["n", "T_f", "T_c", "T_cold"], default="T_c") # ╔═╡ 4fdf70de-3f58-4a38-917b-62b3689ce534 @bind proj_y Select(["n", "T_f", "T_c", "T_cold"], default="n") # ╔═╡ 37251c95-dd46-402f-9579-d4ede2c1e5ff begin state_idx = Dict("n"=>1, "T_f"=>8, "T_c"=>9, "T_cold"=>10) state_units = Dict("n"=>"", "T_f"=>" [°C]", "T_c"=>" [°C]", "T_cold"=>" [°C]") px_idx = state_idx[proj_x] py_idx = state_idx[proj_y] # Determine reasonable axis ranges from operating point + 5% margins. x_op_demo = [1.0, 173.4, 467.0, 114.8, 85.3, 6.56, 0.91, 328.35, 308.35, 290.0] px_op = x_op_demo[px_idx] py_op = x_op_demo[py_idx] px_span = max(0.2 * abs(px_op), 5.0) py_span = max(0.2 * abs(py_op), 5.0) p_proj = plot(xlim=(px_op - px_span, px_op + px_span), ylim=(py_op - py_span, py_op + py_span), xlabel=proj_x * state_units[proj_x], ylabel=proj_y * state_units[proj_y], title="Projection: $proj_y vs $proj_x", size=(700, 450), legend=:topleft) # Overlay reach tube envelope as a rectangle. if have_reach_mat X_lo_local = reach_mat["X_lo"] X_hi_local = reach_mat["X_hi"] rx_lo = minimum(X_lo_local[px_idx, :]) rx_hi = maximum(X_hi_local[px_idx, :]) ry_lo = minimum(X_lo_local[py_idx, :]) ry_hi = maximum(X_hi_local[py_idx, :]) plot!(p_proj, [rx_lo, rx_hi, rx_hi, rx_lo, rx_lo], [ry_lo, ry_lo, ry_hi, ry_hi, ry_lo], lw=2, color=:red, label="reach tube envelope") end # Operating point. scatter!(p_proj, [px_op], [py_op], color=:black, markersize=8, label="x_op") p_proj end # ╔═╡ 4d8459c1-a937-4b53-9eca-975261d6a2cd md""" ## 9d · Prompt-jump heatup reach (if available) If `code/scripts/reach_heatup_pj.jl` has been run, the latest result shows up here as a T_c reach envelope vs time. This is the singular-perturbation reduction that lets nonlinear reach extend past the ~10 s prompt-neutron stiffness wall. """ # ╔═╡ 45e8962a-873e-48d3-aac4-70ea0cf36c97 begin 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.""" else md"""_(no `reach_heatup_pj_result.mat` yet — run `cd code && julia --project=. scripts/reach_heatup_pj.jl`)_""" end end # ╔═╡ de922a70-b653-4b2c-bb13-ccc14b14ac91 md""" ## 10 · Notes for the next pass - v2 should add **write-back** to `predicates.json` (slider → JSON). - v3: **derive** halfspace concretizations directly from the FRET spec + a structured vocabulary of physical bounds. The dream FRET-extension feature. - Hooks into the latest reach-result MAT files, parsed and shown per-halfspace. - 2D projection chooser (currently fixed to T_c × n). - Mode-transition graph rendered live from `mode_boundaries`. --- *"Looks ordinary on the surface but is something else underneath."* 🦎 """ # ╔═╡ Cell order: # ╠═9d14e486-1faa-45a9-b235-6367a039f9da # ╟─08e80248-89fe-4639-b55c-f10d96de6c31 # ╟─d7cadb58-e7c4-4701-8fcb-380f1e948d42 # ╠═c995a73a-c07e-415b-abe8-57abaf9f4b46 # ╟─48f4bec1-ac48-4318-ba6e-92dbf80a7b2d # ╟─7ec2cd9d-5c5c-47d7-aa3c-189091b97204 # ╟─e99c4c2b-7d29-49a4-a474-047f204e331f # ╟─408432c8-2cc6-4505-8623-9f5614a72c4d # ╠═0ef6cd25-7f9c-4d64-afdb-ffe8af1ac967 # ╟─68b1c7bf-c498-41a9-a435-1332c8154035 # ╠═06be0c87-2a28-4ab3-9fa1-ccde3a9b70d9 # ╠═8a24ff1e-0afb-4707-89d7-fa50e3b74dbd # ╟─e67f3259-ce97-4bf5-9e63-8f3aef7c6c56 # ╠═a17586e3-b8f5-4608-950b-69ae45edd6b8 # ╠═dc8758ad-904c-4917-9fba-9a3e42e05e34 # ╠═60bd9d1d-9a04-48e3-a3c9-a84b8bc91009 # ╠═561c3d22-4d11-4511-90b7-63b77829454b # ╟─f50515e2-b205-45ed-acc9-9790697345f8 # ╠═015fa1f4-9813-4ede-ad42-8b4f2340bbb8 # ╟─210a3da2-6a5d-48b2-a48e-d87132d5a32f # ╟─3770e247-c2bd-41a1-9470-0f145f73a894 # ╟─8c67770b-58f8-4868-a3e2-53620565ab5d # ╟─f88f6809-ad3e-4696-bb12-ad8c30d66a9a # ╟─b7fdf92d-948b-41f2-8516-dd8fc20c2efe # ╟─0560c66a-c12f-4f15-bc49-2c9c8164abd1 # ╟─1f6abf31-3618-46a5-9f57-4cb3c8022f89 # ╟─a74106fb-d501-428a-a02e-41f55b49b3da # ╠═37d6b212-9f00-4684-9f91-50c7e17cbd62 # ╠═df53126a-1efa-4cc7-87f4-be4ff0808513 # ╟─9bd8d609-ffa4-46a7-8f0f-d093d87e45e9 # ╟─e4d56600-7a46-46a1-a7c9-f65a5db95f66 # ╟─4fdf70de-3f58-4a38-917b-62b3689ce534 # ╠═37251c95-dd46-402f-9579-d4ede2c1e5ff # ╟─4d8459c1-a937-4b53-9eca-975261d6a2cd # ╠═45e8962a-873e-48d3-aac4-70ea0cf36c97 # ╟─de922a70-b653-4b2c-bb13-ccc14b14ac91