using SimOptDecisions
using Distributions
using Random
using CairoMakie
using Statistics
# === All type definitions (self-contained) ===
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
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
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
# Required for optimization
ElevationPolicy(params::AbstractVector) = ElevationPolicy(params[1])
SimOptDecisions.params(p::ElevationPolicy) = [p.elevation_ft]
SimOptDecisions.param_bounds(::Type{<:ElevationPolicy}) = [(0.0, 14.0)]
function SimOptDecisions.get_action(
policy::ElevationPolicy, state::SeaLevelState, sow::HouseElevationSOW, t::TimeStep
)
return ElevationAction(policy.elevation_ft)
end
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)))
end
function elevation_cost(Δh::Real, area_ft2::Real, house_value::Real)
Δh <= 0 && return 0.0
base_cost = 20_745.0
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
return (base_cost + area_ft2 * rate) / house_value
end
elevation_cost(Δh::Real) = elevation_cost(Δh, 1500.0, 200_000.0)
function SimOptDecisions.initialize(::HouseElevationConfig, ::HouseElevationSOW, ::AbstractRNG)
return SeaLevelState(0.0)
end
function SimOptDecisions.time_axis(config::HouseElevationConfig, ::HouseElevationSOW)
return 1:(config.horizon)
end
function SimOptDecisions.run_timestep(
state::SeaLevelState, action::ElevationAction, sow::HouseElevationSOW,
config::HouseElevationConfig, t::TimeStep, rng::AbstractRNG
)
construction_cost = SimOptDecisions.Utils.is_first(t) ?
elevation_cost(action.elevation_ft, config.house_area_ft2, config.house_value) : 0.0
surge_dist = GeneralizedExtremeValue(sow.gev_μ, sow.gev_σ, sow.gev_ξ)
surge_at_gauge = rand(rng, surge_dist)
water_level = config.gauge_height_above_ref + surge_at_gauge + state.msl
house_floor_level = config.house_height_above_ref + action.elevation_ft
flood_depth_at_house = water_level - house_floor_level
damage_fraction = depth_damage(flood_depth_at_house, sow.dd_threshold, sow.dd_saturation)
return (state, (construction_cost=construction_cost, damage_fraction=damage_fraction))
end
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)
end6. Policy Search
Multi-objective optimization to find the best trade-offs
Instead of manually searching over elevation levels, we can use optimization to automatically find the best policies. When objectives conflict (cost vs. damage), we get a Pareto front of optimal trade-offs.
Setup
Outcomes vs Metrics
Before optimization, understand two key concepts:
- Outcome: Result of a single simulation (one policy × one SOW)
- Metric: Aggregated statistic across many SOWs for a given policy
The optimizer evaluates policies by computing metrics across the SOW ensemble.
"""
Compute metrics from outcomes across many SOWs.
Returns a NamedTuple that the optimizer can reference.
"""
function calculate_metrics(outcomes)
damages = [o.npv_damages for o in outcomes]
construction = outcomes[1].construction_cost
return (
construction_cost=construction,
expected_damage=mean(damages),
cvar_95=mean(sort(damages)[(end - max(1, length(damages) ÷ 20)):end]),
worst_case=maximum(damages),
)
endSetting Up the Problem
config = HouseElevationConfig()
n_sows = 1000
rng = Random.Xoshiro(42)
sows = [sample_sow(rng) for _ in 1:n_sows]
println("Config: $(config.horizon)-year horizon, \$$(Int(config.house_value)) house")
println("SOWs: $(n_sows) sampled futures")Config: 70-year horizon, $200000 house
SOWs: 1000 sampled futures
Multi-Objective Problem
We trade off construction cost against expected damage—conflicting objectives:
import Metaheuristics # loads the optimization extension
# Optimize over [3, 14] ft (continuous range)
# We'll add 0 ft separately to handle the discrete gap
prob = OptimizationProblem(
config,
sows,
ElevationPolicy,
calculate_metrics,
[minimize(:construction_cost), minimize(:expected_damage)];
bounds=[(3.0, 14.0)], # custom bounds for smooth optimization
)OptimizationProblem{HouseElevationConfig{Float64}, HouseElevationSOW{Float64}, ElevationPolicy, typeof(calculate_metrics)}(HouseElevationConfig{Float64}(70, 0.0, 4.0, 1500.0, 200000.0), HouseElevationSOW{Float64}[HouseElevationSOW{Float64}(3.0365066804812875, 0.8680212106068401, 0.10631032588951869, -0.18331374110872317, 7.64890240062116, 0.02891127197931195), HouseElevationSOW{Float64}(2.5261229840980217, 1.094743124667513, 0.22193416378557068, -0.08854246084261151, 8.398063459639017, 0.012369433001736197), HouseElevationSOW{Float64}(2.62198148820797, 1.0644409547457927, 0.19761642315606365, 0.032284802915086036, 7.852613053219432, 0.024385973471723183), HouseElevationSOW{Float64}(3.1508535220741116, 1.0385272744163991, 0.16004625443194756, 0.2834748872756562, 8.144894197218777, 0.03327601966065405), HouseElevationSOW{Float64}(2.617265231083102, 1.0973421041907703, 0.2167934731792261, -0.16648013540557244, 7.497201004812148, 0.04212737631233758), HouseElevationSOW{Float64}(2.6857762671706715, 0.9690080328278211, 0.13446280634581328, -0.010118350062088726, 8.052385395894461, 0.015517642694704159), HouseElevationSOW{Float64}(3.0896982248669187, 1.1966526033600249, 0.170208770037958, 0.2994899174531351, 7.385455643459239, 0.05190114622109096), HouseElevationSOW{Float64}(2.7887796907768525, 0.8926094029958801, 0.10204855303721891, 0.23871132138863554, 8.189284201920376, 0.026139587209633768), HouseElevationSOW{Float64}(2.4488340058761127, 1.1396098524494624, 0.1763496994434102, -0.023277577703625953, 8.077624832158886, 0.02579488418677791), HouseElevationSOW{Float64}(2.5471607034554085, 0.9695640248226942, 0.1356734277226387, -0.05794016963802915, 8.527877033976434, 0.012784669743705207) … HouseElevationSOW{Float64}(3.154943776317375, 0.9942331861823888, 0.16098948268650382, 0.04762699225156271, 7.5302773642838, 0.04503504140041249), HouseElevationSOW{Float64}(2.9135749666117077, 1.0177890877980103, 0.18947726286777905, -0.4207742718598112, 8.213295395679053, 0.03310545691725598), HouseElevationSOW{Float64}(2.5449337058968684, 1.2630902096579346, 0.18558769596389765, 0.08282287259428643, 7.459876304894185, 0.04729096442977641), HouseElevationSOW{Float64}(3.0371365288802483, 0.9732238479028417, 0.1305851227184358, 0.2790027556754343, 7.581911813476502, 0.01887313719248586), HouseElevationSOW{Float64}(2.579426994648304, 0.7087510296964865, 0.16688708885715364, -0.09774641513196625, 7.71523460368626, 0.0465985994114465), HouseElevationSOW{Float64}(2.4908412719644164, 1.112873596622874, 0.09705221448106119, -0.06456692252890037, 7.716307728287777, 0.0413284357926222), HouseElevationSOW{Float64}(2.6990457438573445, 0.9446613535979722, 0.1308037201578582, -0.10060726588121222, 7.773259437218494, 0.04192163666589968), HouseElevationSOW{Float64}(2.8240845764361193, 0.7311811457344246, 0.20354460010299635, 0.12778582436684355, 7.422199429957107, 0.029279655192239042), HouseElevationSOW{Float64}(2.972506213918957, 0.9519997609075798, 0.14419626426405974, 0.004968072619444291, 9.031183591355582, 0.038376220748816914), HouseElevationSOW{Float64}(2.7309177703267533, 0.7497132417794324, 0.22088824549170816, -0.3315442613704665, 7.918125612398264, 0.02764433807571119)], ElevationPolicy, calculate_metrics, Objective[Objective(:construction_cost, Minimize), Objective(:expected_damage, Minimize)], FullBatch(), AbstractConstraint[], [(3.0, 14.0)])
Real elevation choices are discrete: you either don’t elevate (0 ft) or elevate to at least 3 ft. Rather than encoding this discontinuity in the policy (which confuses the optimizer), we:
- Optimize over the continuous [3, 14] range
- Add the 0 ft baseline using
merge_into_pareto!
This gives the optimizer a smooth landscape while including discrete alternatives.
Running Optimization
# NSGA-II is a popular multi-objective evolutionary algorithm
backend = MetaheuristicsBackend(;
algorithm=:NSGA2,
max_iterations=100,
population_size=30,
parallel=true
)MetaheuristicsBackend(:NSGA2, 100, 30, true, Dict{Symbol, Any}())
opt_result = SimOptDecisions.optimize(prob, backend)
# Add the "no elevation" baseline to the Pareto front
merge_into_pareto!(opt_result, prob, ElevationPolicy(0.0))
println("Found $(length(opt_result.pareto_params)) Pareto-optimal solutions")Found 31 Pareto-optimal solutions
Understanding the Pareto Front
A solution is Pareto-optimal if you can’t improve one objective without worsening another. The Pareto front shows the efficient frontier of trade-offs.
Code
let
fig = Figure(; size=(600, 600))
ax = Axis(fig[1, 1];
xlabel="Construction cost (fraction of house value)",
ylabel="Expected NPV damages (fraction of house value)",
title="Pareto Front: Cost vs Damage Trade-off",
aspect=1)
pareto_pts = collect(SimOptDecisions.pareto_front(opt_result))
if !isempty(pareto_pts)
construction = [obj[1] for (_, obj) in pareto_pts]
expected = [obj[2] for (_, obj) in pareto_pts]
elevations = [round(p[1]; digits=1) for (p, _) in pareto_pts]
# Iso-cost lines (x + y = constant)
all_costs = construction .+ expected
cost_range = range(minimum(all_costs) * 0.8, maximum(all_costs) * 1.1; length=6)
for total_cost in cost_range
lines!(ax, [0.0, total_cost], [total_cost, 0.0];
color=(:gray, 0.3), linewidth=0.5, linestyle=:dash)
end
scatter!(ax, construction, expected; markersize=12, color=:steelblue)
# Label selected points
labeled = Set{Float64}()
for (i, elev) in enumerate(elevations)
if all(abs(elev - l) > 1.5 for l in labeled)
text!(ax, construction[i], expected[i];
text="$(round(Int, elev)) ft", align=(:left, :bottom), offset=(5, 5))
push!(labeled, elev)
end
end
end
fig
endInterpreting the Results
The diagonal lines show iso-cost contours (total cost = construction + damage). Moving along the Pareto front:
- Lower left: Infeasible—you can’t get low cost and low damage
- Upper left: Low construction, high damage (0 ft)
- Lower right: High construction, low damage (14 ft)
- The curve: Efficient trade-offs between extremes
println("Pareto-optimal solutions:")
for (params, objectives) in pareto_front(opt_result)
elev = round(params[1], digits=1)
cost = round(objectives[1], digits=3)
damage = round(objectives[2], digits=3)
total = round(objectives[1] + objectives[2], digits=3)
println(" $(elev) ft → cost=$cost, damage=$damage (total=$total)")
endPareto-optimal solutions:
3.0 ft → cost=0.716, damage=0.261 (total=0.977)
14.0 ft → cost=0.957, damage=0.007 (total=0.964)
4.0 ft → cost=0.719, damage=0.165 (total=0.884)
9.5 ft → cost=0.79, damage=0.027 (total=0.816)
3.1 ft → cost=0.716, damage=0.254 (total=0.97)
3.3 ft → cost=0.717, damage=0.229 (total=0.946)
13.8 ft → cost=0.949, damage=0.008 (total=0.957)
7.6 ft → cost=0.744, damage=0.046 (total=0.79)
13.2 ft → cost=0.927, damage=0.009 (total=0.936)
7.0 ft → cost=0.738, damage=0.057 (total=0.795)
12.8 ft → cost=0.913, damage=0.01 (total=0.923)
12.5 ft → cost=0.902, damage=0.011 (total=0.914)
12.2 ft → cost=0.891, damage=0.012 (total=0.903)
12.1 ft → cost=0.886, damage=0.013 (total=0.899)
3.4 ft → cost=0.717, damage=0.212 (total=0.93)
3.7 ft → cost=0.718, damage=0.192 (total=0.911)
10.1 ft → cost=0.81, damage=0.023 (total=0.833)
6.2 ft → cost=0.732, damage=0.072 (total=0.804)
11.6 ft → cost=0.868, damage=0.015 (total=0.883)
5.7 ft → cost=0.728, damage=0.084 (total=0.813)
5.2 ft → cost=0.724, damage=0.104 (total=0.828)
4.8 ft → cost=0.722, damage=0.119 (total=0.841)
11.0 ft → cost=0.846, damage=0.017 (total=0.864)
10.7 ft → cost=0.835, damage=0.019 (total=0.854)
4.5 ft → cost=0.721, damage=0.135 (total=0.856)
4.2 ft → cost=0.72, damage=0.151 (total=0.871)
8.6 ft → cost=0.755, damage=0.035 (total=0.79)
9.7 ft → cost=0.797, damage=0.025 (total=0.822)
13.0 ft → cost=0.918, damage=0.01 (total=0.928)
8.8 ft → cost=0.762, damage=0.033 (total=0.795)
0.0 ft → cost=0.0, damage=1.444 (total=1.444)
Selecting from the Pareto Front
The optimizer finds the efficient frontier, but choosing a point is a decision, not an optimization. Different stakeholders might choose differently:
| Stakeholder | Preference | Likely Choice |
|---|---|---|
| Risk-averse homeowner | Minimize worst case | Higher elevation |
| Budget-constrained | Minimize upfront cost | Lower elevation |
| Expected-value optimizer | Minimize total expected cost | Middle of curve |
# Find the solution with minimum total expected cost
pareto_pts = collect(pareto_front(opt_result))
total_costs = [obj[1] + obj[2] for (_, obj) in pareto_pts]
best_idx = argmin(total_costs)
best_params, best_obj = pareto_pts[best_idx]
println("Minimum expected total cost:")
println(" Elevation: $(round(best_params[1], digits=1)) ft")
println(" Construction: $(round(best_obj[1], digits=3))")
println(" Expected damage: $(round(best_obj[2], digits=3))")
println(" Total: $(round(best_obj[1] + best_obj[2], digits=3))")Minimum expected total cost:
Elevation: 7.6 ft
Construction: 0.744
Expected damage: 0.046
Total: 0.79
Summary
We’ve covered the complete SimOptDecisions workflow:
- Define the problem with types (Config, SOW, State, Action, Policy)
- Implement physics (depth-damage, elevation cost)
- Write callbacks (initialize, time_axis, get_action, run_timestep, finalize)
- Run simulations with
simulate() - Evaluate policies across many SOWs
- Explore systematically with grid search or
explore() - Optimize with multi-objective algorithms to find Pareto-optimal solutions
Key Takeaways
- Structure your uncertainty in the SOW type
- Separate physics from framework in standalone functions
- Validate with exploration before optimizing
- Use multi-objective optimization when objectives conflict
- The Pareto front shows trade-offs; choosing a point requires judgment
Discussion
This model makes simplifying assumptions worth noting:
- Stationary climate: Storm distribution doesn’t change over time
- Constant sea level: No sea-level rise (though the state structure supports it)
- Independent damages: No cumulative effects year-to-year
- Single decision: Elevation chosen once, not adaptively
For real applications, you’d want to relax these assumptions. The framework supports time-varying SOWs, evolving state, and more complex policies.
Next Steps
- See Quick Reference for the types and callbacks checklist
- See Time Stepping for advanced time handling
- See Persistence for saving/loading experiments
- See Validation for constraints and validation hooks