diff --git a/.buildkite/ci_driver.jl b/.buildkite/ci_driver.jl index a056f31c410..3841aa38b8a 100644 --- a/.buildkite/ci_driver.jl +++ b/.buildkite/ci_driver.jl @@ -28,7 +28,7 @@ sol_res = CA.solve_atmos!(simulation) (; p) = integrator import ClimaCore -import ClimaCore: Topologies, Quadratures, Spaces +import ClimaCore: Topologies, Quadratures, Spaces, Fields import ClimaComms using SciMLBase using PrettyTables @@ -38,6 +38,8 @@ using ClimaTimeSteppers using Test import Tar import Base.Filesystem: rm +import Statistics: mean +import LinearAlgebra: norm_sqr include(joinpath(pkgdir(CA), "post_processing", "ci_plots.jl")) ref_job_id = config.parsed_args["reference_job_id"] @@ -108,6 +110,42 @@ end # Write diagnostics that are in DictWriter to text files CA.write_diagnostics_as_txt(simulation) +if config.parsed_args["check_steady_state"] + @info "Comparing final state to predicted steady-state solution" + + Y_end = integrator.sol.u[end] + FT = eltype(Y_end) + + (; steady_state_velocity, params) = integrator.p + (; zd_rayleigh) = params + @assert !isnothing(steady_state_velocity) + + ᶜuₕ_error_squared = + norm_sqr.(Y_end.c.uₕ .- CA.C12.(steady_state_velocity.ᶜu)) + ᶠu₃_error_squared = + norm_sqr.(Y_end.f.u₃ .- CA.C3.(steady_state_velocity.ᶠu)) + + # Ignore all errors in the sponge layer. + ᶜsponge_mask = FT.(Fields.coordinate_field(Y_end.c).z .< zd_rayleigh) + ᶠsponge_mask = FT.(Fields.coordinate_field(Y_end.f).z .< zd_rayleigh) + uₕ_rmse = sqrt(sum(ᶜuₕ_error_squared .* ᶜsponge_mask) / sum(ᶜsponge_mask)) + u₃_rmse = sqrt(sum(ᶠu₃_error_squared .* ᶠsponge_mask) / sum(ᶠsponge_mask)) + + uₕ_rmse_by_level = map(1:5) do level + sqrt(mean(Fields.level(ᶜuₕ_error_squared, level))) + end + u₃_rmse_by_level = map(1:5) do level + sqrt(mean(Fields.level(ᶠu₃_error_squared, level - Fields.half))) + end + + @info " RMSE of uₕ below sponge layer: $uₕ_rmse" + @info " RMSE of u₃ below sponge layer: $u₃_rmse" + @info " RMSE of uₕ on first 5 levels: $uₕ_rmse_by_level" + @info " RMSE of u₃ on first 5 levels: $u₃_rmse_by_level" + + # TODO: Figure out an appropriate @test for the steady state solution. +end + # Conservation checks if config.parsed_args["check_conservation"] FT = Spaces.undertype(axes(sol.u[end].c.ρ)) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 014fe0fe06d..4559f550b3b 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -30,6 +30,7 @@ steps: - echo "--- Instantiate .buildkite" - "julia --project=.buildkite -e 'using Pkg; Pkg.instantiate(;verbose=true)'" + - "julia --project=.buildkite -e 'using Pkg; Pkg.add(Pkg.PackageSpec(;name=\"ClimaCore\", rev=\"dy/extruded_1d_cuda\"))'" - "julia --project=.buildkite -e 'using Pkg; Pkg.precompile()'" - "julia --project=.buildkite -e 'using CUDA; CUDA.precompile_runtime()'" - "julia --project=.buildkite -e 'using Pkg; Pkg.status()'" @@ -89,6 +90,92 @@ steps: --job_id single_column_precipitation_test artifact_paths: "single_column_precipitation_test/output_active/*" + - group: "Analytic Tests" + steps: + - label: ":computer: Cosine Hills Test (2D, Float64)" + command: > + julia --color=yes --project=.buildkite examples/hybrid/driver.jl + --config_file $CONFIG_PATH/plane_cosine_hills_float64_test.yml + --job_id plane_cosine_hills_float64_test + artifact_paths: "plane_cosine_hills_float64_test/output_active/*" + + - label: "GPU: No Topography Test (2D, Float64, Long Duration, Discrete Balance)" + command: > + julia --color=yes --project=.buildkite examples/hybrid/driver.jl + --config_file $CONFIG_PATH/plane_no_topography_long_float64_test.yml + --job_id gpu_plane_no_topography_long_float64_test + artifact_paths: "gpu_plane_no_topography_long_float64_test/output_active/*" + env: + CLIMACOMMS_DEVICE: "CUDA" + agents: + slurm_gpus: 1 + + - label: "GPU: Cosine Hills Test (2D, Float64, High Resolution)" + command: > + julia --color=yes --project=.buildkite examples/hybrid/driver.jl + --config_file $CONFIG_PATH/plane_cosine_hills_high_res_float64_test.yml + --job_id gpu_plane_cosine_hills_high_res_float64_test + artifact_paths: "gpu_plane_cosine_hills_high_res_float64_test/output_active/*" + env: + CLIMACOMMS_DEVICE: "CUDA" + agents: + slurm_gpus: 1 + + - label: "GPU: Cosine Hills Test (Extruded 2D, Float32)" + command: > + julia --color=yes --project=.buildkite examples/hybrid/driver.jl + --config_file $CONFIG_PATH/extruded_plane_cosine_hills_float32_test.yml + --job_id gpu_extruded_plane_cosine_hills_float32_test + artifact_paths: "gpu_extruded_plane_cosine_hills_float32_test/output_active/*" + env: + CLIMACOMMS_DEVICE: "CUDA" + agents: + slurm_gpus: 1 + + - label: "GPU: Cosine Hills Test (3D, Float32)" + command: > + julia --color=yes --project=.buildkite examples/hybrid/driver.jl + --config_file $CONFIG_PATH/box_cosine_hills_float32_test.yml + --job_id gpu_box_cosine_hills_float32_test + artifact_paths: "gpu_box_cosine_hills_float32_test/output_active/*" + env: + CLIMACOMMS_DEVICE: "CUDA" + agents: + slurm_gpus: 1 + + - label: "GPU: Agnesi Mountain Test (2D, Float64)" + command: > + julia --color=yes --project=.buildkite examples/hybrid/driver.jl + --config_file $CONFIG_PATH/plane_agnesi_mountain_float64_test.yml + --job_id gpu_plane_agnesi_mountain_float64_test + artifact_paths: "gpu_plane_agnesi_mountain_float64_test/output_active/*" + env: + CLIMACOMMS_DEVICE: "CUDA" + agents: + slurm_gpus: 1 + + - label: "GPU: Schar Mountain Test (2D, Float64)" + command: > + julia --color=yes --project=.buildkite examples/hybrid/driver.jl + --config_file $CONFIG_PATH/plane_schar_mountain_float64_test.yml + --job_id gpu_plane_schar_mountain_float64_test + artifact_paths: "gpu_plane_schar_mountain_float64_test/output_active/*" + env: + CLIMACOMMS_DEVICE: "CUDA" + agents: + slurm_gpus: 1 + + - label: "GPU: Schar Mountain Test (2D, Float32)" + command: > + julia --color=yes --project=.buildkite examples/hybrid/driver.jl + --config_file $CONFIG_PATH/plane_schar_mountain_float32_test.yml + --job_id gpu_plane_schar_mountain_float32_test + artifact_paths: "gpu_plane_schar_mountain_float32_test/output_active/*" + env: + CLIMACOMMS_DEVICE: "CUDA" + agents: + slurm_gpus: 1 + - group: "Gravity wave" steps: diff --git a/config/default_configs/default_config.yml b/config/default_configs/default_config.yml index 564f8bda831..e7a95d0ec7e 100644 --- a/config/default_configs/default_config.yml +++ b/config/default_configs/default_config.yml @@ -35,7 +35,7 @@ scalar_hyperdiffusion_coefficient: value: 1.5 # Topography topography: - help: "Define the surface elevation profile [`NoWarp` (default),`Earth`,`DCMIP200`,`Agnesi`, `Schar`, `Hughes2023`]" + help: "Define the surface elevation profile [`NoWarp` (default), `Earth`, `DCMIP200`, `Hughes2023`, `Agnesi`, `Schar`, `Cosine2D`, `Cosine3D`]" value: "NoWarp" mesh_warp_type: help: "Sets the interior mesh warp method [`Linear`, `SLEVE`]" @@ -226,6 +226,9 @@ reproducibility_test: check_conservation: help: "Check conservation of mass and energy [`false` (default), `true`]" value: false +check_steady_state: + help: "Compare steady-state velocity to analytic solution; only available for certain choices of `topography` [`false` (default), `true`]" + value: false ls_adv: help: "Large-scale advection [`nothing` (default), `Bomex`, `LifeCycleTan2018`, `Rico`, `ARM_SGP`, `GATE_III`]" value: ~ diff --git a/config/model_configs/box_cosine_hills_float32_test.yml b/config/model_configs/box_cosine_hills_float32_test.yml new file mode 100644 index 00000000000..7d9acc4c4fe --- /dev/null +++ b/config/model_configs/box_cosine_hills_float32_test.yml @@ -0,0 +1,20 @@ +config: "box" +FLOAT_TYPE: "Float32" +initial_condition: "ConstantBuoyancyFrequencyProfile" +topography: "Cosine3D" +x_max: 300e3 +y_max: 300e3 +z_max: 21e3 +x_elem: 15 +y_elem: 15 +z_elem: 50 +z_stretch: false +dt: "12secs" +t_end: "10days" +rayleigh_sponge: true +toml: [toml/analytic_topography_test.toml] +check_steady_state: true +output_default_diagnostics: false +diagnostics: + - short_name: [orog, ua, wa, uapredicted, wapredicted, uaerror, waerror] + period: 1hours diff --git a/config/model_configs/extruded_plane_cosine_hills_float32_test.yml b/config/model_configs/extruded_plane_cosine_hills_float32_test.yml new file mode 100644 index 00000000000..7a270be4aaf --- /dev/null +++ b/config/model_configs/extruded_plane_cosine_hills_float32_test.yml @@ -0,0 +1,20 @@ +config: "box" +FLOAT_TYPE: "Float32" +initial_condition: "ConstantBuoyancyFrequencyProfile" +topography: "Cosine2D" +x_max: 300e3 +y_max: 40e3 +z_max: 21e3 +x_elem: 15 +y_elem: 2 +z_elem: 50 +z_stretch: false +dt: "12secs" +t_end: "10days" +rayleigh_sponge: true +toml: [toml/analytic_topography_test.toml] +check_steady_state: true +output_default_diagnostics: false +diagnostics: + - short_name: [orog, ua, wa, uapredicted, wapredicted, uaerror, waerror] + period: 1hours diff --git a/config/model_configs/plane_agnesi_mountain_float64_test.yml b/config/model_configs/plane_agnesi_mountain_float64_test.yml new file mode 100644 index 00000000000..52c36fffcf5 --- /dev/null +++ b/config/model_configs/plane_agnesi_mountain_float64_test.yml @@ -0,0 +1,19 @@ +config: "plane" +FLOAT_TYPE: "Float64" +initial_condition: "ConstantBuoyancyFrequencyProfile" +topography: "Agnesi" +x_max: 100e3 +z_max: 21e3 +x_elem: 100 +z_elem: 100 +dz_bottom: 10 +dz_top: 1000 +dt: "0.7secs" +t_end: "4days" +rayleigh_sponge: true +toml: [toml/analytic_topography_test.toml] +check_steady_state: true +output_default_diagnostics: false +diagnostics: + - short_name: [orog, ua, wa, uapredicted, wapredicted, uaerror, waerror] + period: 1hours diff --git a/config/model_configs/plane_cosine_hills_float64_test.yml b/config/model_configs/plane_cosine_hills_float64_test.yml new file mode 100644 index 00000000000..baec2140e4b --- /dev/null +++ b/config/model_configs/plane_cosine_hills_float64_test.yml @@ -0,0 +1,19 @@ +config: "plane" +FLOAT_TYPE: "Float64" +initial_condition: "ConstantBuoyancyFrequencyProfile" +topography: "Cosine2D" +x_max: 300e3 +z_max: 21e3 +x_elem: 15 +z_elem: 50 +z_stretch: false +nh_poly: 2 +dt: "12secs" +t_end: "50days" +rayleigh_sponge: true +toml: [toml/analytic_topography_test.toml] +check_steady_state: true +output_default_diagnostics: false +diagnostics: + - short_name: [orog, ua, wa, uapredicted, wapredicted, uaerror, waerror] + period: 1hours diff --git a/config/model_configs/plane_cosine_hills_high_res_float64_test.yml b/config/model_configs/plane_cosine_hills_high_res_float64_test.yml new file mode 100644 index 00000000000..cf341139dae --- /dev/null +++ b/config/model_configs/plane_cosine_hills_high_res_float64_test.yml @@ -0,0 +1,19 @@ +config: "plane" +FLOAT_TYPE: "Float64" +initial_condition: "ConstantBuoyancyFrequencyProfile" +topography: "Cosine2D" +x_max: 300e3 +z_max: 21e3 +x_elem: 50 +z_elem: 50 +z_stretch: false +nh_poly: 2 +dt: "2secs" +t_end: "1days" +rayleigh_sponge: true +toml: [toml/analytic_topography_test.toml] +check_steady_state: true +output_default_diagnostics: false +diagnostics: + - short_name: [orog, ua, wa, uapredicted, wapredicted, uaerror, waerror] + period: 1hours diff --git a/config/model_configs/plane_no_topography_long_float64_test.yml b/config/model_configs/plane_no_topography_long_float64_test.yml new file mode 100644 index 00000000000..056ef98db3d --- /dev/null +++ b/config/model_configs/plane_no_topography_long_float64_test.yml @@ -0,0 +1,20 @@ +config: "plane" +FLOAT_TYPE: "Float64" +initial_condition: "ConstantBuoyancyFrequencyProfile" +topography: "NoWarp" +discrete_hydrostatic_balance: true +x_max: 300e3 +z_max: 21e3 +x_elem: 3 +z_elem: 50 +z_stretch: false +nh_poly: 2 +dt: "5secs" +t_end: "40days" +rayleigh_sponge: true +toml: [toml/analytic_topography_test.toml] +check_steady_state: true +output_default_diagnostics: false +diagnostics: + - short_name: [orog, ua, wa, uapredicted, wapredicted, uaerror, waerror] + period: 1hours diff --git a/config/model_configs/plane_schar_mountain_float32_test.yml b/config/model_configs/plane_schar_mountain_float32_test.yml new file mode 100644 index 00000000000..4baa2924b85 --- /dev/null +++ b/config/model_configs/plane_schar_mountain_float32_test.yml @@ -0,0 +1,19 @@ +config: "plane" +FLOAT_TYPE: "Float32" +initial_condition: "ConstantBuoyancyFrequencyProfile" +topography: "Schar" +x_max: 100e3 +z_max: 21e3 +x_elem: 100 +z_elem: 100 +dz_bottom: 10 +dz_top: 1000 +dt: "0.7secs" +t_end: "4days" +rayleigh_sponge: true +toml: [toml/analytic_topography_test.toml] +check_steady_state: true +output_default_diagnostics: false +diagnostics: + - short_name: [orog, ua, wa, uapredicted, wapredicted, uaerror, waerror] + period: 1hours diff --git a/config/model_configs/plane_schar_mountain_float64_test.yml b/config/model_configs/plane_schar_mountain_float64_test.yml new file mode 100644 index 00000000000..ad215e3343e --- /dev/null +++ b/config/model_configs/plane_schar_mountain_float64_test.yml @@ -0,0 +1,19 @@ +config: "plane" +FLOAT_TYPE: "Float64" +initial_condition: "ConstantBuoyancyFrequencyProfile" +topography: "Schar" +x_max: 100e3 +z_max: 21e3 +x_elem: 100 +z_elem: 100 +dz_bottom: 10 +dz_top: 1000 +dt: "0.7secs" +t_end: "4days" +rayleigh_sponge: true +toml: [toml/analytic_topography_test.toml] +check_steady_state: true +output_default_diagnostics: false +diagnostics: + - short_name: [orog, ua, wa, uapredicted, wapredicted, uaerror, waerror] + period: 1hours diff --git a/post_processing/ci_plots.jl b/post_processing/ci_plots.jl index 2bdb9f6aba9..8ded6835c60 100644 --- a/post_processing/ci_plots.jl +++ b/post_processing/ci_plots.jl @@ -49,6 +49,7 @@ import ClimaCoreSpectra: power_spectrum_2d using Poppler_jll: pdfunite import Base.Filesystem +import Statistics: mean const days = 86400 @@ -469,6 +470,70 @@ function plot_spectrum_with_line!(grid_loc, spectrum; exponent = -3.0) return nothing end +""" + plot_contours!(place, var) + +Generic alternative to the contour plot and heatmap provided in ClimaAnalysis. +""" +function plot_contours!(place, var) + length(var.dims) == 2 || error("Can only plot 2D variables") + + var_name = var.attributes["short_name"] + var_units = var.attributes["units"] + dim1_name, dim2_name = var.index2dim + dim1_units = var.dim_attributes[dim1_name]["units"] + dim2_units = var.dim_attributes[dim2_name]["units"] + dim1 = var.dims[dim1_name] + dim2 = var.dims[dim2_name] + + CairoMakie.Axis( + place[1, 1]; + title = var.attributes["long_name"], + xlabel = "$dim1_name [$dim1_units]", + ylabel = "$dim2_name [$dim2_units]", + limits = (extrema(dim1), extrema(dim2)), + ) + + # Interpolate between the 11 Spectral colors with the central color replaced + # by transparent white. + spectral_colors = CairoMakie.to_colormap(:Spectral) + color_kwargs = (; + colormap = setindex!(spectral_colors, CairoMakie.RGBA(1, 1, 1, 0), 6), + extendhigh = spectral_colors[11], + extendlow = spectral_colors[1], + ) + + # Center the contour levels around either the average of the data or the + # nearest integer that falls into the data range. + data_avg = mean(var.data) + data_avg_int = round(Int, data_avg) + data_min, data_max = extrema(var.data) + data_mid = data_min < data_avg_int < data_max ? data_avg_int : data_avg + data_delta = maximum(value -> abs(value - data_mid), var.data) + + if data_delta == 0 + # For constant data, use a heatmap to avoid Colorbar's LineAxis error. + label = "$var_name [$var_units]" + plot = CairoMakie.heatmap!(dim1, dim2, var.data; color_kwargs...) + else + n_contours = 22 # The number of contours should be even. + if data_delta > abs(data_mid) / 1e6 + # Center contours around data_mid when data_delta >> |data_mid|. + data = var.data + label = "$var_name [$var_units]" + levels = + range(data_mid - data_delta, data_mid + data_delta, n_contours) + else + # Recenter data and contours around 0 when data_delta << |data_mid|. + data = var.data .- data_mid + label = "$var_name - $data_mid [$var_units]" + levels = range(-data_delta, data_delta, n_contours) + end + plot = CairoMakie.contourf!(dim1, dim2, data; levels, color_kwargs...) + end + CairoMakie.Colorbar(place[1, 2], plot; label) +end + ColumnPlots = Union{ Val{:single_column_hydrostatic_balance_ft64}, Val{:single_column_radiative_equilibrium_gray}, @@ -590,6 +655,123 @@ function make_plots( make_plots_generic(output_paths, vars, y = 0.0, time = LAST_SNAP) end +const MountainTest2D = Union{ + Val{:gpu_plane_agnesi_mountain_float64_test}, + Val{:gpu_plane_schar_mountain_float64_test}, + Val{:gpu_plane_schar_mountain_float32_test}, +} +const PeriodicTopographyTest2D = Union{ + Val{:plane_cosine_hills_float64_test}, + Val{:gpu_plane_no_topography_long_float64_test}, + Val{:gpu_plane_cosine_hills_high_res_float64_test}, +} +const PeriodicTopographyTest3D = Union{ + Val{:gpu_extruded_plane_cosine_hills_float32_test}, + Val{:gpu_box_cosine_hills_float32_test}, +} +const TopographyTest = + Union{MountainTest2D, PeriodicTopographyTest2D, PeriodicTopographyTest3D} + +z_dim_name(var) = haskey(var.dims, "z_reference") ? "z_reference" : "z" +rms(var; dims) = sqrt.(mean(var .^ 2; dims)) +function level_rms(var) + reduced_var = ClimaAnalysis.Var._reduce_over(rms, "x", var) + if haskey(var.dims, "y") + reduced_var = ClimaAnalysis.Var._reduce_over(rms, "y", reduced_var) + end + if haskey(var.attributes, "long_name") + long_name = reduced_var.attributes["long_name"] + reduced_var.attributes["long_name"] = + "RMS " * long_name * " averaged over levels" + end + return reduced_var +end +function column_rms(var) + reduced_var = ClimaAnalysis.Var._reduce_over(rms, z_dim_name(var), var) + if haskey(var.attributes, "long_name") + long_name = reduced_var.attributes["long_name"] + reduced_var.attributes["long_name"] = + "RMS " * long_name * " averaged over columns" + end + return reduced_var +end +plot_lazy_vars(output_paths, output_name, lazy_vars; kwargs...) + make_plots_generic(output_paths, collect(lazy_vars); output_name, kwargs...) +function make_plots(val::TopographyTest, output_paths::Vector{<:AbstractString}) + simdirs = SimDir.(output_paths) + is_mountain_test = val isa MountainTest2D + is_3d = val isa PeriodicTopographyTest3D + z_top = 13e3 # The Rayleigh sponge starts at 13 km and shouldn't be plotted. + + rms_error_vars = Iterators.flatmap((level_rms, column_rms)) do rms_func + Iterators.flatmap(("uaerror", "waerror")) do short_name + Iterators.map(simdirs) do simdir + var = get(simdir; short_name) + var = window(var, z_dim_name(var); right = z_top) + var = slice(var; time = Inf) + rms_func(var) + end + end + end + orog_vars = Iterators.map(simdirs) do simdir + slice(get(simdir; short_name = "orog"); time = Inf) + end + plot_lazy_vars( + output_paths, + "final_rms_errors", + Iterators.flatten((rms_error_vars, orog_vars)), + ) + + if is_mountain_test + final_closeup_vars(short_name) = + Iterators.map(simdirs) do simdir + var = get(simdir; short_name) + var = window(var, z_dim_name(var); right = z_top) + var = window(var, "x"; left = 35e3, right = 65e3) + var = slice(var; time = Inf) + is_3d ? slice(var; y = 0) : var + end + for velocity_short_name in ("ua", "wa") + short_names = velocity_short_name .* ("", "predicted", "error") + plot_lazy_vars( + output_paths, + "final_mountain_closeup_" * velocity_short_name, + Iterators.flatmap(final_closeup_vars, short_names); + plot_fn = plot_contours!, + ) + end + end + + slice_summary_vars(short_name) = + Iterators.flatmap(simdirs) do simdir + var = get(simdir; short_name) + var = window(var, z_dim_name(var); right = z_top) + var = is_3d ? slice(var; y = 0) : var + hours_to_plot = if endswith(short_name, "predicted") + (Inf,) + elseif last(var.dims["time"]) > 24 * 60 * 60 + (1, 2, 24, Inf) + else + (1, 2, 3, 24) + end + Iterators.map(hours_to_plot) do n_hours + slice(var; time = n_hours * 60 * 60) + end + end + for velocity_short_name in ("ua", "wa") + short_names = velocity_short_name .* ("", "predicted", "error") + plot_lazy_vars( + output_paths, + "slice_summary_" * velocity_short_name, + Iterators.flatmap(slice_summary_vars, short_names); + plot_fn = plot_contours!, + ) + end + + # Collecting all variables in this function triggers out-of-memory errors + # for 3D spaces. So, we need to insert a function barrier before collecting. +end + MountainPlots = Union{ Val{:plane_agnesi_mountain_test_uniform}, Val{:plane_agnesi_mountain_test_stretched}, diff --git a/src/ClimaAtmos.jl b/src/ClimaAtmos.jl index eb692ff3e7d..46d739cc261 100644 --- a/src/ClimaAtmos.jl +++ b/src/ClimaAtmos.jl @@ -18,13 +18,15 @@ include(joinpath("solver", "types.jl")) include(joinpath("solver", "cli_options.jl")) include(joinpath("utils", "utilities.jl")) include(joinpath("utils", "debug_utils.jl")) -include(joinpath("topography", "topography.jl")) include(joinpath("utils", "variable_manipulations.jl")) include(joinpath("utils", "read_gcm_driven_scm_data.jl")) include(joinpath("utils", "AtmosArtifacts.jl")) import .AtmosArtifacts as AA +include(joinpath("topography", "topography.jl")) +include(joinpath("topography", "steady_state_solutions.jl")) + include( joinpath("parameterized_tendencies", "radiation", "radiation_utilities.jl"), ) diff --git a/src/cache/cache.jl b/src/cache/cache.jl index c07c463246a..ad418dea3b5 100644 --- a/src/cache/cache.jl +++ b/src/cache/cache.jl @@ -17,6 +17,7 @@ struct AtmosCache{ TRAC, NETFLUXTOA, NETFLUXSFC, + SSV, CONSCHECK, } """Timestep of the simulation (in seconds). This is also used by callbacks and tendencies""" @@ -62,6 +63,9 @@ struct AtmosCache{ net_energy_flux_toa::NETFLUXTOA net_energy_flux_sfc::NETFLUXSFC + """Predicted steady-state velocity, if `check_steady_state` is `true`""" + steady_state_velocity::SSV + """Conservation check for prognostic surface temperature""" conservation_check::CONSCHECK end @@ -78,7 +82,15 @@ end # The model also depends on f_plane_coriolis_frequency(params) # This is a constant Coriolis frequency that is only used if space is flat -function build_cache(Y, atmos, params, surface_setup, sim_info, aerosol_names) +function build_cache( + Y, + atmos, + params, + surface_setup, + sim_info, + aerosol_names, + steady_state_velocity, +) (; dt, start_date, output_dir) = sim_info FT = eltype(params) @@ -179,6 +191,7 @@ function build_cache(Y, atmos, params, surface_setup, sim_info, aerosol_names) tracers, net_energy_flux_toa, net_energy_flux_sfc, + steady_state_velocity, conservation_check, ) diff --git a/src/cache/precomputed_quantities.jl b/src/cache/precomputed_quantities.jl index 194680bc72a..424991b8b7f 100644 --- a/src/cache/precomputed_quantities.jl +++ b/src/cache/precomputed_quantities.jl @@ -48,6 +48,7 @@ function precomputed_quantities(Y, atmos) ᶜspecific = specific_gs.(Y.c), ᶜu = similar(Y.c, C123{FT}), ᶠu³ = similar(Y.f, CT3{FT}), + ᶠu = similar(Y.f, CT123{FT}), ᶜwₜqₜ = similar(Y.c, Geometry.WVector{FT}), ᶜwₕhₜ = similar(Y.c, Geometry.WVector{FT}), ᶜK = similar(Y.c, FT), @@ -391,7 +392,7 @@ NVTX.@annotate function set_precomputed_quantities!(Y, p, t) n = n_mass_flux_subdomains(turbconv_model) thermo_args = (thermo_params, moisture_model, precip_model) (; ᶜΦ) = p.core - (; ᶜspecific, ᶜu, ᶠu³, ᶜK, ᶜts, ᶜp) = p.precomputed + (; ᶜspecific, ᶜu, ᶠu³, ᶠu, ᶜK, ᶜts, ᶜp) = p.precomputed ᶠuₕ³ = p.scratch.ᶠtemp_CT3 @. ᶜspecific = specific_gs(Y.c) @@ -406,6 +407,8 @@ NVTX.@annotate function set_precomputed_quantities!(Y, p, t) set_velocity_at_top!(Y, turbconv_model) set_velocity_quantities!(ᶜu, ᶠu³, ᶜK, Y.f.u₃, Y.c.uₕ, ᶠuₕ³) + ᶜJ = Fields.local_geometry_field(Y.c).J + @. ᶠu = CT123(ᶠwinterp(Y.c.ρ * ᶜJ, CT12(ᶜu))) + CT123(ᶠu³) if n > 0 # TODO: In the following increments to ᶜK, we actually need to add # quantities of the form ᶜρaχ⁰ / ᶜρ⁰ and ᶜρaχʲ / ᶜρʲ to ᶜK, rather than diff --git a/src/diagnostics/core_diagnostics.jl b/src/diagnostics/core_diagnostics.jl index 1ecf457987a..78ca9dec848 100644 --- a/src/diagnostics/core_diagnostics.jl +++ b/src/diagnostics/core_diagnostics.jl @@ -74,9 +74,9 @@ add_diagnostic_variable!( comments = "Eastward (zonal) wind component", compute! = (out, state, cache, time) -> begin if isnothing(out) - return copy(u_component.(Geometry.UVector.(cache.precomputed.ᶜu))) + return copy(u_component.(Geometry.UVector.(cache.precomputed.ᶠu))) else - out .= u_component.(Geometry.UVector.(cache.precomputed.ᶜu)) + out .= u_component.(Geometry.UVector.(cache.precomputed.ᶠu)) end end, ) @@ -92,9 +92,9 @@ add_diagnostic_variable!( comments = "Northward (meridional) wind component", compute! = (out, state, cache, time) -> begin if isnothing(out) - return copy(v_component.(Geometry.VVector.(cache.precomputed.ᶜu))) + return copy(v_component.(Geometry.VVector.(cache.precomputed.ᶠu))) else - out .= v_component.(Geometry.VVector.(cache.precomputed.ᶜu)) + out .= v_component.(Geometry.VVector.(cache.precomputed.ᶠu)) end end, ) @@ -113,9 +113,9 @@ add_diagnostic_variable!( comments = "Vertical wind component", compute! = (out, state, cache, time) -> begin if isnothing(out) - return copy(w_component.(Geometry.WVector.(cache.precomputed.ᶜu))) + return copy(w_component.(Geometry.WVector.(cache.precomputed.ᶠu))) else - out .= w_component.(Geometry.WVector.(cache.precomputed.ᶜu)) + out .= w_component.(Geometry.WVector.(cache.precomputed.ᶠu)) end end, ) @@ -1244,3 +1244,108 @@ add_diagnostic_variable!( comments = "Mass of water vapor per mass of air", compute! = compute_husv!, ) + +### +# Analytic Steady-State Approximations +### + +# These are only available when `check_steady_state` is `true`. + +add_diagnostic_variable!( + short_name = "uapredicted", + long_name = "Predicted Eastward Wind", + standard_name = "predicted_eastward_wind", + units = "m s^-1", + comments = "Predicted steady-state eastward (zonal) wind component", + compute! = (out, state, cache, time) -> begin + if isnothing(out) + u_component.(cache.steady_state_velocity.ᶠu) + else + out .= u_component.(cache.steady_state_velocity.ᶠu) + end + end, +) + +add_diagnostic_variable!( + short_name = "vapredicted", + long_name = "Predicted Northward Wind", + standard_name = "predicted_northward_wind", + units = "m s^-1", + comments = "Predicted steady-state northward (meridional) wind component", + compute! = (out, state, cache, time) -> begin + if isnothing(out) + v_component.(cache.steady_state_velocity.ᶠu) + else + out .= v_component.(cache.steady_state_velocity.ᶠu) + end + end, +) + +add_diagnostic_variable!( + short_name = "wapredicted", + long_name = "Predicted Upward Air Velocity", + standard_name = "predicted_upward_air_velocity", + units = "m s^-1", + comments = "Predicted steady-state vertical wind component", + compute! = (out, state, cache, time) -> begin + if isnothing(out) + w_component.(cache.steady_state_velocity.ᶠu) + else + out .= w_component.(cache.steady_state_velocity.ᶠu) + end + end, +) + +add_diagnostic_variable!( + short_name = "uaerror", + long_name = "Error of Eastward Wind", + standard_name = "error_eastward_wind", + units = "m s^-1", + comments = "Error of steady-state eastward (zonal) wind component", + compute! = (out, state, cache, time) -> begin + if isnothing(out) + u_component.(Geometry.UVWVector.(cache.precomputed.ᶠu)) .- + u_component.(cache.steady_state_velocity.ᶠu) + else + out .= + u_component.(Geometry.UVWVector.(cache.precomputed.ᶠu)) .- + u_component.(cache.steady_state_velocity.ᶠu) + end + end, +) + +add_diagnostic_variable!( + short_name = "vaerror", + long_name = "Error of Northward Wind", + standard_name = "error_northward_wind", + units = "m s^-1", + comments = "Error of steady-state northward (meridional) wind component", + compute! = (out, state, cache, time) -> begin + if isnothing(out) + v_component.(Geometry.UVWVector.(cache.precomputed.ᶠu)) .- + v_component.(cache.steady_state_velocity.ᶠu) + else + out .= + v_component.(Geometry.UVWVector.(cache.precomputed.ᶠu)) .- + v_component.(cache.steady_state_velocity.ᶠu) + end + end, +) + +add_diagnostic_variable!( + short_name = "waerror", + long_name = "Error of Upward Air Velocity", + standard_name = "error_upward_air_velocity", + units = "m s^-1", + comments = "Error of steady-state vertical wind component", + compute! = (out, state, cache, time) -> begin + if isnothing(out) + w_component.(Geometry.UVWVector.(cache.precomputed.ᶠu)) .- + w_component.(cache.steady_state_velocity.ᶠu) + else + out .= + w_component.(Geometry.UVWVector.(cache.precomputed.ᶠu)) .- + w_component.(cache.steady_state_velocity.ᶠu) + end + end, +) diff --git a/src/initial_conditions/InitialConditions.jl b/src/initial_conditions/InitialConditions.jl index 01ef8917a03..dd506e350af 100644 --- a/src/initial_conditions/InitialConditions.jl +++ b/src/initial_conditions/InitialConditions.jl @@ -21,6 +21,7 @@ import ..n_mass_flux_subdomains import ..gcm_driven_profile import ..gcm_height import ..gcm_driven_profile_tmean +import ..constant_buoyancy_frequency_initial_state import Thermodynamics.TemperatureProfiles: DecayingTemperatureProfile, DryAdiabaticProfile diff --git a/src/initial_conditions/initial_conditions.jl b/src/initial_conditions/initial_conditions.jl index ffa9cc1505c..85f586bed09 100644 --- a/src/initial_conditions/initial_conditions.jl +++ b/src/initial_conditions/initial_conditions.jl @@ -90,6 +90,28 @@ end ## Simple Profiles ## +""" + ConstantBuoyancyFrequencyProfile() + +An `InitialCondition` with a constant Brunt-Vaisala frequency and constant wind +velocity, where the pressure profile is hydrostatically balanced. This is +currently the only `InitialCondition` that supports the approximation of a +steady-state solution. +""" +struct ConstantBuoyancyFrequencyProfile <: InitialCondition end +function (::ConstantBuoyancyFrequencyProfile)(params) + function local_state(local_geometry) + FT = eltype(params) + coord = local_geometry.coordinates + return LocalState(; + params, + geometry = local_geometry, + constant_buoyancy_frequency_initial_state(params, coord)..., + ) + end + return local_state +end + """ IsothermalProfile(; temperature = 300) diff --git a/src/solver/type_getters.jl b/src/solver/type_getters.jl index f3386074a41..dd3afd84689 100644 --- a/src/solver/type_getters.jl +++ b/src/solver/type_getters.jl @@ -320,6 +320,7 @@ function get_initial_condition(parsed_args) parsed_args["perturb_initstate"], ) elseif parsed_args["initial_condition"] in [ + "ConstantBuoyancyFrequencyProfile", "IsothermalProfile", "AgnesiHProfile", "DryDensityCurrentProfile", @@ -343,6 +344,41 @@ function get_initial_condition(parsed_args) end end +function get_steady_state_velocity(params, Y, parsed_args) + parsed_args["check_steady_state"] || return nothing + parsed_args["initial_condition"] == "ConstantBuoyancyFrequencyProfile" && + parsed_args["mesh_warp_type"] == "Linear" || + error("The steady-state velocity can currently be computed only for a \ + ConstantBuoyancyFrequencyProfile with Linear mesh warping") + topography = parsed_args["topography"] + steady_state_velocity = if topography == "NoWarp" + steady_state_velocity_no_warp + elseif topography == "Cosine2D" + steady_state_velocity_cosine_2d + elseif topography == "Cosine3D" + steady_state_velocity_cosine_3d + elseif topography == "Agnesi" + steady_state_velocity_agnesi + elseif topography == "Schar" + steady_state_velocity_schar + else + error("The steady-state velocity for $topography topography cannot \ + be computed analytically") + end + top_level = Spaces.nlevels(axes(Y.c)) + Fields.half + z_top = Fields.level(Fields.coordinate_field(Y.f).z, top_level) + + # TODO: This can be very expensive! It should be moved to a separate CI job. + @info "Approximating steady-state velocity" + s = @timed_str begin + ᶜu = steady_state_velocity.(params, Fields.coordinate_field(Y.c), z_top) + ᶠu = + steady_state_velocity.(params, Fields.coordinate_field(Y.f), z_top) + end + @info "Steady-state velocity approximation completed: $s" + return (; ᶜu, ᶠu) +end + function get_surface_setup(parsed_args) parsed_args["surface_setup"] == "GCM" && return SurfaceConditions.GCMDriven( parsed_args["external_forcing_file"], @@ -726,6 +762,8 @@ function get_simulation(config::AtmosConfig) end tracers = get_tracers(config.parsed_args) + steady_state_velocity = + get_steady_state_velocity(params, Y, config.parsed_args) s = @timed_str begin p = build_cache( @@ -735,6 +773,7 @@ function get_simulation(config::AtmosConfig) surface_setup, sim_info, tracers.aerosol_names, + steady_state_velocity, ) end @info "Allocating cache (p): $s" diff --git a/src/topography/steady_state_solutions.jl b/src/topography/steady_state_solutions.jl new file mode 100644 index 00000000000..970faa7ac59 --- /dev/null +++ b/src/topography/steady_state_solutions.jl @@ -0,0 +1,292 @@ +import StaticArrays: @SVector + +background_u(::Type{FT}) where {FT} = FT(10) +background_N(::Type{FT}) where {FT} = FT(0.01) # This needs to be a small value. +background_T_sfc(::Type{FT}) where {FT} = FT(288) +background_T_min(::Type{FT}) where {FT} = FT(100) +background_T_max(::Type{FT}) where {FT} = FT(500) + +function background_p_and_T(params, ζ) + FT = eltype(params) + g = CAP.grav(params) + R_d = CAP.R_d(params) + cp_d = CAP.cp_d(params) + p_sfc = CAP.MSLP(params) + N = background_N(FT) + T_sfc = background_T_sfc(FT) + T_min = background_T_min(FT) + T_max = background_T_max(FT) + + g == 0 && return (p_sfc, T_sfc) + + β = N^2 / g + a = g / (cp_d * T_sfc * β) + p = p_sfc * (1 - a + a * exp(-β * ζ))^(cp_d / R_d) + T = T_sfc * ((1 - a) * exp(β * ζ) + a) + + # Replace the constant-N profile with an isothermal profile above T = T_iso + # to avoid unreasonably small or large values of T. + T_iso = a > 1 ? T_min : T_max + ζ_iso = log((T_iso / T_sfc - a) / (1 - a)) / β + p_iso = p_sfc * ((1 - a) / (1 - a * T_sfc / T_iso))^(cp_d / R_d) + if ζ > ζ_iso + p = p_iso * exp(-g / (R_d * T_iso) * (ζ - ζ_iso)) + T = T_iso + end + + return (p, T) +end + +# Replace ζ with z in the initial state so that it is hydrostatically balanced. +function constant_buoyancy_frequency_initial_state(params, coord) + FT = eltype(params) + thermo_state = TD.PhaseDry_pT( + CAP.thermodynamics_params(params), + background_p_and_T(params, coord.z)..., + ) + velocity = Geometry.UVector(background_u(FT)) + return (; thermo_state, velocity) +end + +""" + FΔU_first_order_approximation(params, ζ, k_x, k_y, Fh, z_top) + +Approximates the Fourier transform of the 3D velocity perturbation `ΔU` to +first order in the maximum topography elevation `h_max`, assuming that the +background state is a `ConstantBuoyancyFrequencyProfile` and the vertical grid +stretching is linear. + +Arguments: + - `params`: `ClimaAtmosParameters` used to define the background state + - `ζ`: generalized vertical coordinate (0 at surface and 1 at `z_top`) + - `k_x`: wavenumber along `x`-direction + - `k_y`: wavenumber along `y`-direction + - `z_top`: elevation at the top of the model domain + - `Fh`: Fourier transform of the topography elevation `h` at `k_x` and `k_y` + +References: + - https://www.cosmo-model.org/content/model/documentation/newsLetters/newsLetter09/cnl9-04.pdf + - http://dx.doi.org/10.1175/1520-0493(2003)131%3C1229:NCOMTI%3E2.0.CO;2 + - https://atmos.uw.edu/academics/classes/2010Q1/536/1503AP_lee_waves.pdf +""" +function FΔU_first_order_approximation(params, ζ, k_x, k_y, Fh, z_top) + FT = eltype(params) + g = CAP.grav(params) + R_d = CAP.R_d(params) + cp_d = CAP.cp_d(params) + p_sfc = CAP.MSLP(params) + N = background_N(FT) + T_sfc = background_T_sfc(FT) + u = background_u(FT) + + if g == 0 + Fh == 0 || + error("the analytic solution for topography without gravity has \ + not been implemented yet") # TODO: find limit of FΔU as g → 0 + return @SVector(FT[0, 0, 0]) + end + + β = N^2 / g + a = g / (cp_d * T_sfc * β) + γ = cp_d / (cp_d - R_d) + α = g / (γ * R_d * T_sfc * β) + r = k_y / k_x + k_h² = k_x^2 + k_y^2 + FΔζ_xh = -im * k_x * Fh # ≈ F(-∂h/∂x / (1 - h/z_top)) to first order in h + FΔζ_yh = -im * k_y * Fh # ≈ F(-∂h/∂y / (1 - h/z_top)) to first order in h + FΔζ_z = Fh / z_top # ≈ F(h/z_top / (1 - h/z_top)) to first order in h + + (p, T) = background_p_and_T(params, ζ) + + ρ_sfc = p_sfc / (R_d * T_sfc) + m_sfc = u^2 / (γ * R_d * T_sfc) + μ_sfc = 1 - k_x^2 / k_h² * m_sfc + ν_sfc = k_x^2 / k_h² * m_sfc / μ_sfc + d_sfc = ρ_sfc / μ_sfc + FΔζ_x_sfc = FΔζ_xh + FΔζ_y_sfc = FΔζ_yh + + ρ = p / (R_d * T) + m = u^2 / (γ * R_d * T) + μ = 1 - k_x^2 / k_h² * m + ν = k_x^2 / k_h² * m / μ + d = ρ / μ + FΔζ_x = FΔζ_xh * (1 - ζ / z_top) + FΔζ_y = FΔζ_yh * (1 - ζ / z_top) + + k_ζ_sfc² = + k_h² * (-μ_sfc + (N / (k_x * u))^2 * (1 + ν_sfc * (1 - a))) - + β^2 / 4 * ( + (1 + α)^2 + + 2 / μ_sfc * α * (1 - a) + + ν_sfc * (1 + 3 / μ_sfc) * (1 - a)^2 + ) + Δf_sfc = + g / u * ( + im * k_h² / k_x * μ_sfc * FΔζ_z + + ((1 - m_sfc) * FΔζ_xh + r * FΔζ_yh) / z_top + + β * r * (-r * FΔζ_x_sfc + FΔζ_y_sfc) * (1 + ν_sfc * (1 - a)) + ) + + k_ζ² = + k_h² * (-μ + (N / (k_x * u))^2 * (1 + ν * (1 - a))) - + β^2 / 4 * + ((1 + α)^2 + 2 / μ * α * (1 - a) + ν * (1 + 3 / μ) * (1 - a)^2) + Δf = + g / u * ( + im * k_h² / k_x * μ * FΔζ_z + + ((1 - m) * FΔζ_xh + r * FΔζ_yh) / z_top + + β * r * (-r * FΔζ_x + FΔζ_y) * (1 + ν * (1 - a)) + ) + + plus_or_minus = k_ζ² > 0 ? sign(k_x * u) : 1 + + # Approximate FΔw and ∂FΔw_∂ζ to zeroth order in β. + FΔw = + Δf / k_ζ² + + (sqrt(d_sfc / d) * (im * k_x * u * Fh - Δf_sfc / k_ζ_sfc²)) * + exp(plus_or_minus * im * sqrt(Complex(k_ζ²)) * ζ) + ∂FΔw_∂ζ = plus_or_minus * im * sqrt(Complex(k_ζ²)) * (FΔw - Δf / k_ζ²) + + FΔp = + -(im * k_x / k_h² * g * d) * + ((1 - m) * FΔζ_x + r * FΔζ_y - m / u * FΔw + u / g * ∂FΔw_∂ζ) + FΔu = -(im / k_x * g * ρ * FΔζ_x + FΔp) / (ρ * u) + FΔv = -(im / k_x * g * ρ * FΔζ_y + r * FΔp) / (ρ * u) + + return @SVector([FΔu, FΔv, FΔw]) +end + +## +## Steady-state solutions for periodic topography +## + +function steady_state_velocity_no_warp(params, coord, z_top) + FT = eltype(params) + u = background_u(FT) + return UVW(u, FT(0), FT(0)) +end + +function steady_state_velocity_cosine_2d(params, coord, z_top) + FT = eltype(params) + (; x, z) = coord + (; h_max, λ) = cosine_params(FT) + y = FT(0) + λ_y = FT(Inf) + return steady_state_velocity_cosine(params, x, y, z, λ, λ_y, h_max, z_top) +end + +function steady_state_velocity_cosine_3d(params, coord, z_top) + FT = eltype(params) + (; x, y, z) = coord + (; h_max, λ) = cosine_params(FT) + return steady_state_velocity_cosine(params, x, y, z, λ, λ, h_max, z_top) +end + +function steady_state_velocity_cosine(params, x, y, z, λ_x, λ_y, h_max, z_top) + FT = eltype(params) + u = background_u(FT) + h = topography_cosine(x, y, λ_x, λ_y, h_max) + ζ = (z - h) / (1 - h / z_top) + k_x = 2 * FT(π) / λ_x + k_y = 2 * FT(π) / λ_y + + # Compute the inverse Fourier transform of FΔU. + # Instead of integrating FΔU * exp(im * (k_x * x + k_y * y)) over all values + # of k_x and k_y, with the Fourier transformed elevation Fh(k_x′, k_y′) = + # h_max (δ(k_x′ + k_x) + δ(k_x′ - k_x)) (δ(k_y′ + k_y) + δ(k_y′ - k_y)) / 4, + # we can drop the delta functions and directly evaluate the integrand at + # (±k_x, ±k_y). Since the integrand is symmetric around the origin, we can + # just evaluate it at (k_x, k_y) and multiply the result by 4. Also, the + # magnitude of FΔU is linear with respect to h_max, so we can replace + # Fh(k_x, k_y) = h_max / 4 with Fh(k_x, k_y) = h_max. + FΔU = FΔU_first_order_approximation(params, ζ, k_x, k_y, h_max, z_top) + (Δu, Δv, Δw) = real.(FΔU * exp(im * (k_x * x + k_y * y))) + return UVW(u + Δu, Δv, Δw) +end + +## +## Steady-state solutions for mountain topography +## + +# Integrate f(x) from x1 to x2 with n_calls function calls. Note that QuadGK is +# not GPU-compatible, so it should not be used here. +function definite_integral(f::F, x1, x2, n_calls) where {F} + dx = (x2 - x1) / n_calls + return sum(index -> f(x1 + index * dx), 2:n_calls; init = f(x1 + dx)) * dx +end + +function steady_state_velocity_mountain_2d( + params, + coord, + z_top, + x_center, + k_x_max, + mountain_elevation::F1, + centered_mountain_elevation_fourier_transform::F2, +) where {F1, F2} + FT = eltype(params) + u = background_u(FT) + h = mountain_elevation(coord) + (; x, z) = coord + ζ = (z - h) / (1 - h / z_top) + k_y = FT(0) + + # Compute the inverse Fourier transform of FΔU. + # Since the integrand FΔU * exp(im * k_x * (x - x_center)) is symmetric + # around the origin, we can just evaluate it over positive values of k_x and + # multiply the result by 2. The integral should go to k_x = Inf, but we can + # assume that FΔU is negligible when k_x > k_x_max because Fh < eps(FT). + (Δu, Δv, Δw) = + 2 * definite_integral(FT(0), k_x_max, 50000) do k_x + # Use the transform of the centered elevation h(x - x_center) instead of + # the actual elevation h(x) to avoid propagating the quantity + # exp(-im * k_x * x_center) through the approximation of FΔU. When + # x_center >> 1, this quantity adds a significant error to the integral. + Fh = centered_mountain_elevation_fourier_transform(k_x) + FΔU = FΔU_first_order_approximation(params, ζ, k_x, k_y, Fh, z_top) + real.(FΔU * exp(im * k_x * (x - x_center))) + end + return UVW(u + Δu, Δv, Δw) +end + +function steady_state_velocity_agnesi(params, coord, z_top) + FT = eltype(params) + (; h_max, x_center, a) = agnesi_params(FT) + topography_agnesi_Fh(k_x) = h_max * a / 2 * exp(-a * abs(k_x)) + n_efolding_intervals = -log(eps(FT)) + k_x_max = n_efolding_intervals / a + return steady_state_velocity_mountain_2d( + params, + coord, + z_top, + x_center, + k_x_max, + topography_agnesi, + topography_agnesi_Fh, + ) +end + +function steady_state_velocity_schar(params, coord, z_top) + FT = eltype(params) + (; h_max, x_center, λ, a) = schar_params(FT) + k_peak = 2 * FT(π) / λ + Fh_coef = h_max * a / (8 * sqrt(FT(π))) + topography_schar_Fh(k_x) = + Fh_coef * ( + exp(-a^2 / 4 * (k_x + k_peak)^2) + + 2 * exp(-a^2 / 4 * k_x^2) + + exp(-a^2 / 4 * (k_x - k_peak)^2) + ) + n_efolding_intervals = -log(eps(FT)) + k_x_max = k_peak + 2 * sqrt(n_efolding_intervals) / a + return steady_state_velocity_mountain_2d( + params, + coord, + z_top, + x_center, + k_x_max, + topography_schar, + topography_schar_Fh, + ) +end diff --git a/src/topography/topography.jl b/src/topography/topography.jl index aa7d6bbbeae..4fc166dd546 100644 --- a/src/topography/topography.jl +++ b/src/topography/topography.jl @@ -1,14 +1,11 @@ """ - topography_dcmip200(λ,ϕ) -λ = longitude (degrees) -ϕ = latitude (degrees) -Given horizontal coordinates in lon-lat coordinates, -compute and return the local elevation of the surface -consistent with the test problem DCMIP-2-0-0. + topography_dcmip200(coord) + +Surface elevation for the DCMIP-2-0-0 test problem. """ -function topography_dcmip200(coords) - λ, ϕ = coords.long, coords.lat - FT = eltype(λ) +function topography_dcmip200(coord) + FT = Geometry.float_type(coord) + λ, ϕ = coord.long, coord.lat ϕₘ = FT(0) # degrees (equator) λₘ = FT(3 / 2 * 180) # degrees rₘ = FT(acos(sind(ϕₘ) * sind(ϕ) + cosd(ϕₘ) * cosd(ϕ) * cosd(λ - λₘ))) # Great circle distance (rads) @@ -24,15 +21,13 @@ function topography_dcmip200(coords) end """ - topography_hughes2023(λ,ϕ) -λ = longitude (degrees) -ϕ = latitude (degrees) -Returns the surface elevation profile used in the baroclinic wave -test problem defined by Hughes and Jablonowski (2023). + topography_hughes2023(coord) + +Surface elevation for baroclinic wave test from Hughes and Jablonowski (2023). """ -function topography_hughes2023(coords) - λ, ϕ = coords.long, coords.lat - FT = eltype(λ) +function topography_hughes2023(coord) + FT = Geometry.float_type(coord) + λ, ϕ = coord.long, coord.lat h₀ = FT(2e3) # Angles in degrees ϕ₁ = FT(45) @@ -56,44 +51,65 @@ function topography_hughes2023(coords) ) end +## +## Topography profiles for 2D and 3D boxes +## + +# The parameters of these profiles should be defined separately so that they +# can also be used to compute analytic solutions. + """ - topography_agnesi(x,z) -x = horizontal coordinate [m] -z = vertical coordinate [m] -h_c = 1 [m] -a_c = 10000 [m] -x_c = 120000 [m] -Generate a single mountain profile (Agnesi mountain) -for use with tests of gravity waves with topography. + topography_agnesi(coord) + +Surface elevation for a 2D Witch of Agnesi mountain, centered at `x = 50 km`. """ -function topography_agnesi(coords) - x = coords.x - FT = eltype(x) - h_c = FT(1) - a_c = FT(10000) - x_c = FT(120000) - zₛ = h_c / (1 + ((x - x_c) / a_c)^2) - return zₛ +function topography_agnesi(coord) + FT = Geometry.float_type(coord) + (; x) = coord + (; h_max, x_center, a) = agnesi_params(FT) + return h_max / (1 + ((x - x_center) / a)^2) end +agnesi_params(::Type{FT}) where {FT} = + (; h_max = FT(25), x_center = FT(50e3), a = FT(5e3)) """ - topography_schar(x,z) -x = horizontal coordinate [m] -z = vertical coordinate [m] -h_c = 250 [m] -a_c = 5000 [m] -x_c = 60000 [m] -Assumes [0, 120] km domain. -Generate a single mountain profile (Schar mountain) -for use with tests of gravity waves with topography. + topography_schar(coord) + +Surface elevation for a 2D Schar mountain, centered at `x = 50 km`. """ -function topography_schar(coords) - x = coords.x - FT = eltype(x) - h_c = FT(250) - λ_c = FT(4000) - a_c = FT(5000) - x_c = FT(60000) - zₛ = h_c * exp(-((x - x_c) / a_c)^2) * (cospi((x - x_c) / λ_c))^2 - return zₛ +function topography_schar(coord) + FT = Geometry.float_type(coord) + (; x) = coord + (; h_max, x_center, λ, a) = schar_params(FT) + return h_max * exp(-(x - x_center)^2 / a^2) * cospi((x - x_center) / λ)^2 end +schar_params(::Type{FT}) where {FT} = + (; h_max = FT(25), x_center = FT(50e3), λ = FT(4e3), a = FT(5e3)) + +""" + topography_cosine_2d(coord) + +Surface elevation for 2D cosine hills. +""" +function topography_cosine_2d(coord) + FT = Geometry.float_type(coord) + (; x) = coord + (; h_max, λ) = cosine_params(FT) + return topography_cosine(x, FT(0), λ, FT(Inf), h_max) +end + +""" + topography_cosine_3d(coord) + +Surface elevation for 3D cosine hills. +""" +function topography_cosine_3d(coord) + FT = Geometry.float_type(coord) + (; x, y) = coord + (; h_max, λ) = cosine_params(FT) + return topography_cosine(x, y, λ, λ, h_max) +end + +topography_cosine(x, y, λ_x, λ_y, h_max) = + h_max * cospi(2 * x / λ_x) * cospi(2 * y / λ_y) +cosine_params(::Type{FT}) where {FT} = (; h_max = FT(25), λ = FT(100e3)) diff --git a/src/utils/common_spaces.jl b/src/utils/common_spaces.jl index 0f2962629d7..352965d333f 100644 --- a/src/utils/common_spaces.jl +++ b/src/utils/common_spaces.jl @@ -97,20 +97,19 @@ function make_hybrid_spaces( z_grid = Grids.FiniteDifferenceGrid(z_topology) topography = parsed_args["topography"] - @assert topography in - ("NoWarp", "DCMIP200", "Earth", "Agnesi", "Schar", "Hughes2023") - if topography == "DCMIP200" - z_surface = SpaceVaryingInput(topography_dcmip200, h_space) - @info "Computing DCMIP200 orography on spectral horizontal space" - elseif topography == "Agnesi" - z_surface = SpaceVaryingInput(topography_agnesi, h_space) - @info "Computing Agnesi orography on spectral horizontal space" - elseif topography == "Schar" - z_surface = SpaceVaryingInput(topography_schar, h_space) - @info "Computing Schar orography on spectral horizontal space" - elseif topography == "Hughes2023" - z_surface = SpaceVaryingInput(topography_hughes2023, h_space) - @info "Computing Hughes2023 orography on spectral horizontal space" + @assert topography in ( + "NoWarp", + "Earth", + "DCMIP200", + "Hughes2023", + "Agnesi", + "Schar", + "Cosine2D", + "Cosine3D", + ) + if topography == "NoWarp" + z_surface = zeros(h_space) + @info "No surface orography warp applied" elseif topography == "Earth" z_surface = SpaceVaryingInput( AA.earth_orography_file_path(; @@ -120,9 +119,22 @@ function make_hybrid_spaces( h_space, ) @info "Remapping Earth orography from ETOPO2022 data onto horizontal space" - elseif topography == "NoWarp" - z_surface = zeros(h_space) - @info "No surface orography warp applied" + else + topography_function = if topography == "DCMIP200" + topography_dcmip200 + elseif topography == "Hughes2023" + topography_hughes2023 + elseif topography == "Agnesi" + topography_agnesi + elseif topography == "Schar" + topography_schar + elseif topography == "Cosine2D" + topography_cosine_2d + elseif topography == "Cosine3D" + topography_cosine_3d + end + z_surface = SpaceVaryingInput(topography_function, h_space) + @info "Using $topography orography" end if topography == "NoWarp" diff --git a/test/coupler_compatibility.jl b/test/coupler_compatibility.jl index 6849baa8dff..6bc418fac0f 100644 --- a/test/coupler_compatibility.jl +++ b/test/coupler_compatibility.jl @@ -83,6 +83,7 @@ const T2 = 290 p.tracers, p.net_energy_flux_toa, p.net_energy_flux_sfc, + p.steady_state_velocity, p.conservation_check, ) diff --git a/toml/analytic_topography_test.toml b/toml/analytic_topography_test.toml new file mode 100644 index 00000000000..da5ffcc6203 --- /dev/null +++ b/toml/analytic_topography_test.toml @@ -0,0 +1,5 @@ +[alpha_rayleigh_w] +value = 0.1 + +[zd_rayleigh] +value = 13000