using SimOptDecisions
using Distributions
using Random
using CairoMakie
# Config
Base.@kwdef struct HouseElevationConfig{T<:AbstractFloat} <: AbstractConfig
horizon::Int = 70
gauge_height_above_ref::T = 0.0
house_height_above_ref::T = 4.0
house_area_ft2::T = 1500.0
house_value::T = 200_000.0
end
# SOW
struct HouseElevationSOW{T<:AbstractFloat} <: AbstractSOW
gev_μ::T
gev_σ::T
gev_ξ::T
dd_threshold::T
dd_saturation::T
discount_rate::T
end
function sample_sow(rng::AbstractRNG)
gev_μ = rand(rng, Normal(2.8, 0.3))
gev_σ = rand(rng, truncated(Normal(1.0, 0.15); lower=0.3))
gev_ξ = rand(rng, truncated(Normal(0.15, 0.05); lower=-0.2, upper=0.5))
dd_threshold = rand(rng, Normal(0.0, 0.25))
dd_saturation = rand(rng, Normal(8.0, 0.5))
discount_rate = rand(rng, truncated(Normal(0.03, 0.015); lower=0.01, upper=0.07))
return HouseElevationSOW(gev_μ, gev_σ, gev_ξ, dd_threshold, dd_saturation, discount_rate)
end
# State, Action, Policy
struct SeaLevelState{T<:AbstractFloat} <: AbstractState
msl::T
end
struct ElevationAction{T<:AbstractFloat} <: AbstractAction
elevation_ft::T
end
struct ElevationPolicy{T<:AbstractFloat} <: AbstractPolicy
elevation_ft::T
end
function SimOptDecisions.get_action(
policy::ElevationPolicy, state::SeaLevelState, sow::HouseElevationSOW, t::TimeStep
)
return ElevationAction(policy.elevation_ft)
end3. Running a Simulation
Physics, callbacks, and your first simulation
Now we’ll implement the physics and simulation logic, then run our first simulation.
Setup
First, we need all the type definitions from the previous section:
Domain Physics
Depth-Damage Function
The depth-damage function converts flood depth (at the house) to fractional damage. We use a logistic curve with a threshold and saturation point.
function depth_damage(depth::T, threshold::T, saturation::T) where {T<:AbstractFloat}
depth <= threshold && return zero(T)
depth >= saturation && return one(T)
midpoint = (threshold + saturation) / 2
steepness = T(6) / (saturation - threshold)
return one(T) / (one(T) + exp(-steepness * (depth - midpoint)))
endCode
let
depths = range(-1, 10; length=200)
fig = Figure(; size=(600, 400))
ax = Axis(fig[1, 1];
xlabel="Flood depth at house (ft)",
ylabel="Damage (fraction of house value)",
title="Depth-Damage Function")
damages = [depth_damage(d, 0.0, 8.0) for d in depths]
lines!(ax, depths, damages; linewidth=2, label="Nominal (threshold=0, sat=8)")
for (thresh, sat) in [(-0.5, 7.5), (0.5, 8.5)]
damages_alt = [depth_damage(d, thresh, sat) for d in depths]
lines!(ax, depths, damages_alt; linewidth=1, linestyle=:dash, alpha=0.5)
end
axislegend(ax; position=:rb)
fig
endElevation Cost Function
The cost to elevate increases with height. We use the cost model from Doss-Gollin et al. (2022).
function elevation_cost(Δh::Real, area_ft2::Real, house_value::Real)
Δh <= 0 && return 0.0
Δh > 14 && error("Cannot elevate more than 14 ft")
base_cost = 10_000 + 300 + 470 + 4_300 + 2_175 + 3_500 # = $20,745
thresholds = [0.0, 5.0, 8.5, 12.0, 14.0]
rates = [80.36, 82.5, 86.25, 103.75, 113.75]
rate = rates[1]
for i in 1:(length(thresholds) - 1)
if Δh <= thresholds[i + 1]
t = (Δh - thresholds[i]) / (thresholds[i + 1] - thresholds[i])
rate = rates[i] + t * (rates[i + 1] - rates[i])
break
end
rate = rates[i + 1]
end
cost_dollars = base_cost + area_ft2 * rate
return cost_dollars / house_value
end
elevation_cost(Δh::Real) = elevation_cost(Δh, 1500.0, 200_000.0)Code
let
heights = 0:14
costs = [elevation_cost(h) for h in heights]
fig = Figure(; size=(600, 400))
ax = Axis(fig[1, 1];
xlabel="Elevation height (ft)",
ylabel="Cost (fraction of house value)",
title="Elevation Cost Function (1500 ft², \$200k house)")
scatterlines!(ax, heights, costs; markersize=10)
fig
endThe Five Callbacks
SimOptDecisions uses five callbacks to run simulations:
| Callback | Purpose | Returns |
|---|---|---|
initialize |
Create initial state | State |
time_axis |
Define time points | Iterable |
get_action |
Map policy + state to action | Action |
run_timestep |
Execute one step | (new_state, step_record) |
finalize |
Aggregate into outcome | Anything |
Initialize
Create the initial state:
function SimOptDecisions.initialize(
::HouseElevationConfig, ::HouseElevationSOW, ::AbstractRNG
)
return SeaLevelState(0.0) # MSL at reference datum
endTime Axis
Define the simulation time points:
function SimOptDecisions.time_axis(config::HouseElevationConfig, ::HouseElevationSOW)
return 1:(config.horizon)
endRun Timestep
Execute one year: sample a storm surge, compute damage:
function SimOptDecisions.run_timestep(
state::SeaLevelState,
action::ElevationAction,
sow::HouseElevationSOW,
config::HouseElevationConfig,
t::TimeStep,
rng::AbstractRNG,
)
# Construction cost at t=1 only
construction_cost = if SimOptDecisions.Utils.is_first(t)
elevation_cost(action.elevation_ft, config.house_area_ft2, config.house_value)
else
0.0
end
# Sample annual maximum surge
surge_dist = GeneralizedExtremeValue(sow.gev_μ, sow.gev_σ, sow.gev_ξ)
surge_at_gauge = rand(rng, surge_dist)
# Convert to water level relative to reference datum
water_level = config.gauge_height_above_ref + surge_at_gauge + state.msl
# House floor level with elevation
house_floor_level = config.house_height_above_ref + action.elevation_ft
# Flood depth at house
flood_depth_at_house = water_level - house_floor_level
# Compute damage
damage_fraction = depth_damage(flood_depth_at_house, sow.dd_threshold, sow.dd_saturation)
step_record = (construction_cost=construction_cost, damage_fraction=damage_fraction)
return (state, step_record)
endFinalize
Aggregate annual damages into NPV:
function SimOptDecisions.finalize(
final_state::SeaLevelState,
step_records::Vector,
config::HouseElevationConfig,
sow::HouseElevationSOW,
)
construction_cost = step_records[1].construction_cost
npv_damages = sum(
step_records[t].damage_fraction * SimOptDecisions.Utils.discount_factor(sow.discount_rate, t)
for t in eachindex(step_records)
)
return (
construction_cost=construction_cost,
npv_damages=npv_damages,
annual_damages=[o.damage_fraction for o in step_records],
)
endRunning Your First Simulation
Now we can run a simulation! Let’s try one policy with one SOW:
# Create instances
config = HouseElevationConfig()
rng = Random.Xoshiro(42)
sow = sample_sow(rng)
policy = ElevationPolicy(5.0) # Elevate 5 feet
# Run simulation
outcome = simulate(config, sow, policy)
# Inspect results
println("Construction cost: $(round(outcome.construction_cost, digits=3)) (fraction of house value)")
println("NPV damages: $(round(outcome.npv_damages, digits=3)) (fraction of house value)")
println("Years with damage: $(count(d -> d > 0, outcome.annual_damages))")Construction cost: 0.722 (fraction of house value)
NPV damages: 0.036 (fraction of house value)
Years with damage: 1
Visualizing Annual Damages
Let’s see when damage occurred over the 70-year horizon:
Code
let
fig = Figure(; size=(700, 400))
ax = Axis(fig[1, 1];
xlabel="Year",
ylabel="Damage (fraction of house value)",
title="Annual Damages (5 ft elevation, one SOW)")
barplot!(ax, 1:length(outcome.annual_damages), outcome.annual_damages; color=:steelblue)
fig
endComparing Elevation Levels
Let’s compare different elevation choices on the same SOW:
Code
let
elevations = 0:14
total_costs = Float64[]
for elev in elevations
policy = ElevationPolicy(Float64(elev))
# Use same RNG seed for each to make comparison fair
outcome = simulate(config, sow, policy, Random.Xoshiro(42))
push!(total_costs, outcome.construction_cost + outcome.npv_damages)
end
fig = Figure(; size=(700, 400))
ax = Axis(fig[1, 1];
xlabel="Elevation (ft)",
ylabel="Total cost (fraction of house value)",
title="Total Cost by Elevation (same SOW)")
barplot!(ax, collect(elevations), total_costs; color=:steelblue)
# Mark minimum
min_idx = argmin(total_costs)
scatter!(ax, [elevations[min_idx]], [total_costs[min_idx]];
color=:red, markersize=20, marker=:star5)
fig
endSummary
We’ve now:
- Implemented domain physics (depth-damage, elevation cost)
- Written the five callbacks (
initialize,time_axis,get_action,run_timestep,finalize) - Run our first simulation with
simulate(config, sow, policy) - Inspected the outcome and visualized results
But this was just one SOW (one possible future). The “optimal” elevation depends on which future actually occurs.
Next Steps
In the next section, we’ll evaluate a single policy across many SOWs to understand how it performs under uncertainty.