PWR-HYBRID-3/app/predicate_explorer.jl
Dane Sabo 2bbb1871cc 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>
2026-04-21 20:24:48 -04:00

530 lines
19 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.

### 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