From 3d8806fb32f70b0b97e7720a7c72e17b123e6e04 Mon Sep 17 00:00:00 2001 From: Dennis Yatunin Date: Tue, 4 Feb 2025 21:18:42 -0800 Subject: [PATCH] Add tests and plots for steady-state velocity --- .buildkite/ci_driver.jl | 56 +++- .buildkite/pipeline.yml | 91 +++++- config/default_configs/default_config.yml | 7 +- .../box_cosine_hills_float64_test.yml | 20 ++ ...truded_plane_cosine_hills_float64_test.yml | 20 ++ .../plane_agnesi_mountain_float64_test.yml | 18 ++ .../plane_agnesi_mountain_test_stretched.yml | 17 - .../plane_agnesi_mountain_test_uniform.yml | 17 - .../plane_cosine_hills_float64_test.yml | 18 ++ .../plane_no_topography_float64_test.yml | 18 ++ .../plane_schar_mountain_float32_test.yml | 18 ++ .../plane_schar_mountain_float64_test.yml | 18 ++ docs/src/api.md | 3 +- post_processing/ci_plots.jl | 209 ++++++++++++- src/ClimaAtmos.jl | 4 +- src/cache/cache.jl | 15 +- src/cache/precomputed_quantities.jl | 5 +- src/diagnostics/core_diagnostics.jl | 109 ++++++- src/initial_conditions/InitialConditions.jl | 1 + src/initial_conditions/initial_conditions.jl | 96 ++---- src/solver/type_getters.jl | 41 ++- src/topography/steady_state_solutions.jl | 295 ++++++++++++++++++ src/topography/topography.jl | 118 ++++--- src/utils/common_spaces.jl | 46 ++- test/coupler_compatibility.jl | 1 + .../plane_agnesi_mountain_test_stretched.toml | 3 - toml/plane_agnesi_mountain_test_uniform.toml | 3 - toml/plane_schar_mountain_test_uniform.toml | 3 - ..._stretched.toml => steady_state_test.toml} | 2 + 29 files changed, 1048 insertions(+), 224 deletions(-) create mode 100644 config/model_configs/box_cosine_hills_float64_test.yml create mode 100644 config/model_configs/extruded_plane_cosine_hills_float64_test.yml create mode 100644 config/model_configs/plane_agnesi_mountain_float64_test.yml delete mode 100644 config/model_configs/plane_agnesi_mountain_test_stretched.yml delete mode 100644 config/model_configs/plane_agnesi_mountain_test_uniform.yml create mode 100644 config/model_configs/plane_cosine_hills_float64_test.yml create mode 100644 config/model_configs/plane_no_topography_float64_test.yml create mode 100644 config/model_configs/plane_schar_mountain_float32_test.yml create mode 100644 config/model_configs/plane_schar_mountain_float64_test.yml create mode 100644 src/topography/steady_state_solutions.jl delete mode 100644 toml/plane_agnesi_mountain_test_stretched.toml delete mode 100644 toml/plane_agnesi_mountain_test_uniform.toml delete mode 100644 toml/plane_schar_mountain_test_uniform.toml rename toml/{plane_schar_mountain_test_stretched.toml => steady_state_test.toml} (53%) diff --git a/.buildkite/ci_driver.jl b/.buildkite/ci_driver.jl index a056f31c41..3207bcd3a0 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,58 @@ end # Write diagnostics that are in DictWriter to text files CA.write_diagnostics_as_txt(simulation) +if config.parsed_args["check_steady_state"] + Y_end = integrator.sol.u[end] + t_end = integrator.sol.t[end] + (; steady_state_velocity, params) = integrator.p + (; zd_rayleigh) = params + FT = eltype(Y_end) + + @info "Comparing velocity fields to predicted steady state at t = $t_end" + ᶜu_normsqr = norm_sqr.(steady_state_velocity.ᶜu) + ᶠu_normsqr = norm_sqr.(steady_state_velocity.ᶠu) + ᶜuₕ_err_normsqr = norm_sqr.(Y_end.c.uₕ .- CA.C12.(steady_state_velocity.ᶜu)) + ᶠu₃_err_normsqr = norm_sqr.(Y_end.f.u₃ .- CA.C3.(steady_state_velocity.ᶠu)) + + # Average all errors below 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_rms = sqrt(sum(ᶜu_normsqr .* ᶜsponge_mask) / sum(ᶜsponge_mask)) + ᶠu_rms = sqrt(sum(ᶠu_normsqr .* ᶠsponge_mask) / sum(ᶠsponge_mask)) + ᶜuₕ_rmse = sqrt(sum(ᶜuₕ_err_normsqr .* ᶜsponge_mask) / sum(ᶜsponge_mask)) + ᶠu₃_rmse = sqrt(sum(ᶠu₃_err_normsqr .* ᶠsponge_mask) / sum(ᶠsponge_mask)) + ᶜuₕ_rel_err = ᶜuₕ_rmse / ᶜu_rms + ᶠu₃_rel_err = ᶠu₃_rmse / ᶠu_rms + + # Average the errors on several levels close to the surface. + n_levels = 3 + level_uₕ_rel_errs = map(1:n_levels) do level + level_u_rms = sqrt(mean(Fields.level(ᶜu_normsqr, level))) + level_uₕ_rmse = sqrt(mean(Fields.level(ᶜuₕ_err_normsqr, level))) + level_uₕ_rmse / level_u_rms + end + level_u₃_rel_errs = map((1:n_levels) .- Fields.half) do level + level_u_rms = sqrt(mean(Fields.level(ᶠu_normsqr, level))) + level_u₃_rmse = sqrt(mean(Fields.level(ᶠu₃_err_normsqr, level))) + level_u₃_rmse / level_u_rms + end + + @info " Absolute RMSE of uₕ below sponge layer: $ᶜuₕ_rmse" + @info " Absolute RMSE of u₃ below sponge layer: $ᶠu₃_rmse" + @info " Relative RMSE of uₕ below sponge layer: $ᶜuₕ_rel_err" + @info " Relative RMSE of u₃ below sponge layer: $ᶠu₃_rel_err" + @info " Relative RMSE of uₕ on $n_levels levels closest to the surface:" + @info " $level_uₕ_rel_errs" + @info " Relative RMSE of u₃ on $n_levels levels closest to the surface:" + @info " $level_u₃_rel_errs" + + if t_end > 24 * 60 * 60 + # TODO: Float32 simulations currently show significant divergence of uₕ. + @test ᶜuₕ_rel_err < (FT == Float32 ? 0.05 : 0.005) + @test ᶠu₃_rel_err < 0.0005 + end +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 36332dcc58..7e21bfde97 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -178,27 +178,94 @@ steps: - group: "Plane Examples" steps: - - label: ":computer: Agnesi linear hydrostatic mountain experiment (uniform)" + + - label: ":computer: Density current experiment" command: > julia --color=yes --project=.buildkite .buildkite/ci_driver.jl - --config_file $CONFIG_PATH/plane_agnesi_mountain_test_uniform.yml - --job_id plane_agnesi_mountain_test_uniform - artifact_paths: "plane_agnesi_mountain_test_uniform/output_active/*" + --config_file $CONFIG_PATH/plane_density_current_test.yml + --job_id plane_density_current_test + artifact_paths: "plane_density_current_test/output_active/*" - - label: ":computer: Agnesi linear hydrostatic mountain experiment (stretched)" + - group: "Analytic Tests" + steps: + - label: "GPU: No Topography Test (2D, Float64, Discrete Balance)" command: > julia --color=yes --project=.buildkite .buildkite/ci_driver.jl - --config_file $CONFIG_PATH/plane_agnesi_mountain_test_stretched.yml - --job_id plane_agnesi_mountain_test_stretched - artifact_paths: "plane_agnesi_mountain_test_stretched/output_active/*" + --config_file $CONFIG_PATH/plane_no_topography_float64_test.yml + --job_id gpu_plane_no_topography_float64_test + artifact_paths: "gpu_plane_no_topography_float64_test/output_active/*" + env: + CLIMACOMMS_DEVICE: "CUDA" + agents: + slurm_gpus: 1 - - label: ":computer: Density current experiment" + - label: "GPU: Cosine Hills Test (2D, Float64)" command: > julia --color=yes --project=.buildkite .buildkite/ci_driver.jl - --config_file $CONFIG_PATH/plane_density_current_test.yml - --job_id plane_density_current_test - artifact_paths: "plane_density_current_test/output_active/*" + --config_file $CONFIG_PATH/plane_cosine_hills_float64_test.yml + --job_id gpu_plane_cosine_hills_float64_test + artifact_paths: "gpu_plane_cosine_hills_float64_test/output_active/*" + env: + CLIMACOMMS_DEVICE: "CUDA" + agents: + slurm_gpus: 1 + + - label: "GPU: Cosine Hills Test (Extruded 2D, Float64)" + command: > + julia --color=yes --project=.buildkite .buildkite/ci_driver.jl + --config_file $CONFIG_PATH/extruded_plane_cosine_hills_float64_test.yml + --job_id gpu_extruded_plane_cosine_hills_float64_test + artifact_paths: "gpu_extruded_plane_cosine_hills_float64_test/output_active/*" + env: + CLIMACOMMS_DEVICE: "CUDA" + agents: + slurm_gpus: 1 + slurm_mem: 48GB + + - label: "GPU: Cosine Hills Test (3D, Float64)" + command: > + julia --color=yes --project=.buildkite .buildkite/ci_driver.jl + --config_file $CONFIG_PATH/box_cosine_hills_float64_test.yml + --job_id gpu_box_cosine_hills_float64_test + artifact_paths: "gpu_box_cosine_hills_float64_test/output_active/*" + env: + CLIMACOMMS_DEVICE: "CUDA" + agents: + slurm_gpus: 1 + slurm_mem: 48GB + + - label: "GPU: Agnesi Mountain Test (2D, Float64)" + command: > + julia --color=yes --project=.buildkite .buildkite/ci_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 .buildkite/ci_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 .buildkite/ci_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: "Conservation check" steps: diff --git a/config/default_configs/default_config.yml b/config/default_configs/default_config.yml index 564f8bda83..726f631e6b 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`]" @@ -182,7 +182,7 @@ surface_temperature: help: "Prescribed surface temperature functional form ['ZonallySymmetric' (default), 'ZonallyAsymmetric', 'RCEMIPII']" value: "ZonallySymmetric" initial_condition: - help: "Initial condition [`DryBaroclinicWave`, `MoistBaroclinicWave`, `DecayingProfile`, `IsothermalProfile`, `Bomex`, `DryDensityCurrentProfile`, `AgnesiHProfile`, `ScharProfile`, `RisingThermalBubbleProfile`, `ISDAC`], or a file path for a NetCDF file (read documentation about requirements)." + help: "Initial condition [`DryBaroclinicWave`, `MoistBaroclinicWave`, `ConstantBuoyancyFrequencyProfile`, `DecayingProfile`, `IsothermalProfile`, `Bomex`, `DryDensityCurrentProfile`, `RisingThermalBubbleProfile`, `ISDAC`], or a file path for a NetCDF file (read documentation about requirements)." value: "DecayingProfile" perturb_initstate: help: "Add a perturbation to the initial condition [`false`, `true` (default)]" @@ -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_float64_test.yml b/config/model_configs/box_cosine_hills_float64_test.yml new file mode 100644 index 0000000000..1b4948a974 --- /dev/null +++ b/config/model_configs/box_cosine_hills_float64_test.yml @@ -0,0 +1,20 @@ +config: "box" +FLOAT_TYPE: "Float64" +initial_condition: "ConstantBuoyancyFrequencyProfile" +topography: "Cosine3D" +x_max: 100e3 +y_max: 25e3 +z_max: 21e3 +x_elem: 100 +y_elem: 25 +z_elem: 100 +dz_bottom: 10 +dt: "0.5secs" # CFL is slightly lower than for 2D case +t_end: "30mins" +rayleigh_sponge: true +toml: [toml/steady_state_test.toml] +check_steady_state: true +output_default_diagnostics: false +diagnostics: + - short_name: [orog, ua, wa, uapredicted, wapredicted, uaerror, waerror] + period: 1mins diff --git a/config/model_configs/extruded_plane_cosine_hills_float64_test.yml b/config/model_configs/extruded_plane_cosine_hills_float64_test.yml new file mode 100644 index 0000000000..8542558ddb --- /dev/null +++ b/config/model_configs/extruded_plane_cosine_hills_float64_test.yml @@ -0,0 +1,20 @@ +config: "box" +FLOAT_TYPE: "Float64" +initial_condition: "ConstantBuoyancyFrequencyProfile" +topography: "Cosine2D" +x_max: 100e3 +y_max: 1e3 +z_max: 21e3 +x_elem: 100 +y_elem: 1 +z_elem: 100 +dz_bottom: 10 +dt: "0.5secs" # CFL is slightly lower than for 2D case +t_end: "9hours" +rayleigh_sponge: true +toml: [toml/steady_state_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 0000000000..0ab10e94e7 --- /dev/null +++ b/config/model_configs/plane_agnesi_mountain_float64_test.yml @@ -0,0 +1,18 @@ +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 +dt: "0.7secs" +t_end: "2days" +rayleigh_sponge: true +toml: [toml/steady_state_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_test_stretched.yml b/config/model_configs/plane_agnesi_mountain_test_stretched.yml deleted file mode 100644 index 9ec384d113..0000000000 --- a/config/model_configs/plane_agnesi_mountain_test_stretched.yml +++ /dev/null @@ -1,17 +0,0 @@ -dt_save_state_to_disk: "3600secs" -rayleigh_sponge: true -initial_condition: "AgnesiHProfile" -x_max: 240000.0 -z_elem: 45 -dt: "1.5secs" -t_end: "14400.0secs" -x_elem: 80 -dz_bottom: 200.0 -config: "plane" -hyperdiff: false -z_max: 25000.0 -topography: "Agnesi" -toml: [toml/plane_agnesi_mountain_test_stretched.toml] -diagnostics: - - short_name: wa - period: 4hours diff --git a/config/model_configs/plane_agnesi_mountain_test_uniform.yml b/config/model_configs/plane_agnesi_mountain_test_uniform.yml deleted file mode 100644 index ddc830cd8c..0000000000 --- a/config/model_configs/plane_agnesi_mountain_test_uniform.yml +++ /dev/null @@ -1,17 +0,0 @@ -dt_save_state_to_disk: "3600secs" -rayleigh_sponge: true -initial_condition: "AgnesiHProfile" -x_max: 240000.0 -z_elem: 90 -dt: "1.5secs" -t_end: "14400.0secs" -z_stretch: false -x_elem: 80 -config: "plane" -hyperdiff: false -z_max: 25000.0 -topography: "Agnesi" -toml: [toml/plane_agnesi_mountain_test_uniform.toml] -diagnostics: - - short_name: wa - period: 4hours 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 0000000000..d5b8ef67af --- /dev/null +++ b/config/model_configs/plane_cosine_hills_float64_test.yml @@ -0,0 +1,18 @@ +config: "plane" +FLOAT_TYPE: "Float64" +initial_condition: "ConstantBuoyancyFrequencyProfile" +topography: "Cosine2D" +x_max: 100e3 +z_max: 21e3 +x_elem: 100 +z_elem: 100 +dz_bottom: 10 +dt: "0.7secs" +t_end: "2days" +rayleigh_sponge: true +toml: [toml/steady_state_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_float64_test.yml b/config/model_configs/plane_no_topography_float64_test.yml new file mode 100644 index 0000000000..7cd38353cd --- /dev/null +++ b/config/model_configs/plane_no_topography_float64_test.yml @@ -0,0 +1,18 @@ +config: "plane" +FLOAT_TYPE: "Float64" +initial_condition: "ConstantBuoyancyFrequencyProfile" +discrete_hydrostatic_balance: true +x_max: 100e3 +z_max: 21e3 +x_elem: 100 +z_elem: 100 +dz_bottom: 10 +dt: "0.7secs" +t_end: "2days" +rayleigh_sponge: true +toml: [toml/steady_state_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 0000000000..99799a5488 --- /dev/null +++ b/config/model_configs/plane_schar_mountain_float32_test.yml @@ -0,0 +1,18 @@ +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 +dt: "0.7secs" +t_end: "2days" +rayleigh_sponge: true +toml: [toml/steady_state_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 0000000000..9f912768c5 --- /dev/null +++ b/config/model_configs/plane_schar_mountain_float64_test.yml @@ -0,0 +1,18 @@ +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 +dt: "0.7secs" +t_end: "2days" +rayleigh_sponge: true +toml: [toml/steady_state_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/docs/src/api.md b/docs/src/api.md index 3c460d7631..cff24a866f 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -14,8 +14,7 @@ ClimaAtmos.InitialConditions.hydrostatic_pressure_profile ### Plane / Box ```@docs -ClimaAtmos.InitialConditions.AgnesiHProfile -ClimaAtmos.InitialConditions.ScharProfile +ClimaAtmos.InitialConditions.ConstantBuoyancyFrequencyProfile ClimaAtmos.InitialConditions.DryDensityCurrentProfile ClimaAtmos.InitialConditions.RisingThermalBubbleProfile ``` diff --git a/post_processing/ci_plots.jl b/post_processing/ci_plots.jl index 43fd7d3ae9..8532eed62c 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 @@ -105,6 +106,7 @@ YLINEARSCALE = Dict( long_name(var) = var.attributes["long_name"] short_name(var) = var.attributes["short_name"] +z_dim_name(var) = haskey(var.dims, "z_reference") ? "z_reference" : "z" """ parse_var_attributes(var) @@ -212,8 +214,7 @@ function make_plots_generic( # Default plotting function needs access to kwargs if isnothing(plot_fn) - plot_fn = - (grid_loc, var) -> viz.plot!(grid_loc, var, args...; kwargs...) + plot_fn = viz.plot! end MAX_PLOTS_PER_PAGE = MAX_NUM_ROWS * MAX_NUM_COLS @@ -254,7 +255,7 @@ function make_plots_generic( grid_pos = 1 end - plot_fn(grid[grid_pos], var) + plot_fn(grid[grid_pos], var, args...; kwargs...) grid_pos += 1 # Flush current page @@ -279,6 +280,39 @@ function make_plots_generic( return output_file end +""" + horizontal_average(var) + +A `ClimaAnalysis.OutputVar` with a horizontal RMS average of the data in `var`. +""" +function horizontal_average(var) + rms(var; dims) = sqrt.(mean(var .^ 2; dims)) + 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"] = long_name * ", Horizontal Average" + end + return reduced_var +end + +""" + vertical_average(var) + +A `ClimaAnalysis.OutputVar` with a vertical RMS average of the data in `var`. +""" +function vertical_average(var) + rms(var; dims) = sqrt.(mean(var .^ 2; dims)) + 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"] = long_name * ", Vertical Average" + end + return reduced_var +end + """ compute_spectrum(var::ClimaAnalysis.OutputVar; mass_weight = nothing) @@ -469,6 +503,81 @@ function plot_spectrum_with_line!(grid_loc, spectrum; exponent = -3.0) return nothing end +""" + plot_contours!(place, var; [n_contours], [kwargs]...) + +Generic alternative to the default plotting function provided in ClimaAnalysis, +which uses a semi-transparent color scheme with appropriately centered contours. +Data with a small but nonempty range is centered around 0 before being plotted. +For constant data, a heatmap is used instead of a contour plot. + +The number of contours is 22 by default, but can also be specified manually. Any +additional keyword arguments are passed to the `CairoMakie` plotting function. +""" +function plot_contours!(place, var; n_contours = 22, kwargs...) + 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 middle color replaced + # by transparent white. + spectral_colors = CairoMakie.to_colormap(:Spectral) + colormap = setindex!(spectral_colors, CairoMakie.RGBA(1, 1, 1, 0), 6) + highclip = extendhigh = spectral_colors[11] + lowclip = extendlow = spectral_colors[1] + + # Center the contour levels around either 0, 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 = if data_min < 0 < data_max + 0 + elseif data_min < data_avg_int < data_max + data_avg_int + else + data_avg + end + 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. + plot_kwargs = (; colormap, highclip, lowclip, kwargs...) + label = "$var_name [$var_units]" + plot = CairoMakie.heatmap!(dim1, dim2, var.data; plot_kwargs...) + else + plot_kwargs = (; colormap, extendhigh, extendlow, kwargs...) + 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, plot_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,25 +699,95 @@ function make_plots( make_plots_generic(output_paths, vars, y = 0.0, time = LAST_SNAP) end -MountainPlots = Union{ - Val{:plane_agnesi_mountain_test_uniform}, - Val{:plane_agnesi_mountain_test_stretched}, - Val{:plane_schar_mountain_test_uniform}, - Val{:plane_schar_mountain_test_stretched}, +const PeriodicTopographyTest2D = Union{ + Val{:gpu_plane_no_topography_float64_test}, + Val{:gpu_plane_cosine_hills_float64_test}, +} +const PeriodicTopographyTest3D = Union{ + Val{:gpu_extruded_plane_cosine_hills_float64_test}, + Val{:gpu_box_cosine_hills_float64_test}, +} +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 SteadyStateTest = + Union{PeriodicTopographyTest2D, PeriodicTopographyTest3D, MountainTest2D} -function make_plots(::MountainPlots, output_paths::Vector{<:AbstractString}) +function make_plots( + val::SteadyStateTest, + output_paths::Vector{<:AbstractString}, +) simdirs = SimDir.(output_paths) - short_names, reduction = ["wa"], "average" - vars = map_comparison(simdirs, short_names) do simdir, short_name - return get(simdir; short_name, reduction) + is_mountain_test = val isa MountainTest2D + is_3d = val isa PeriodicTopographyTest3D + zd_rayleigh = 13e3 # Values inside the Rayleigh sponge shouldn't be plotted. + + rms_error_vars = + Iterators.flatmap((horizontal_average, vertical_average)) do average + 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 = zd_rayleigh) + var = slice(var; time = Inf) + average(var) + end + end + end + orog_vars = Iterators.map(simdirs) do simdir + slice(get(simdir; short_name = "orog"); time = Inf) end make_plots_generic( output_paths, - vars, - time = LAST_SNAP, - more_kwargs = YLINEARSCALE, + [rms_error_vars..., orog_vars...]; + output_name = "final_rms_errors", + ) + + make_contour_plots(get_vars, short_names, output_name) = make_plots_generic( + output_paths, + [Iterators.flatmap(get_vars, short_names)...]; + output_name, + plot_fn = plot_contours!, ) + + for velocity_component in ("ua", "wa") + short_names = velocity_component .* ("error", "", "predicted") + mountain_output_name = "final_mountain_closeup_" * velocity_component + time_series_output_name = "slice_time_series_" * velocity_component + is_mountain_test && + make_contour_plots(short_names, mountain_output_name) do short_name + Iterators.flatmap(simdirs) do simdir + var = get(simdir; short_name) + var = window(var, "x"; left = 35e3, right = 65e3) + var = is_3d ? slice(var; y = 0) : var + var = slice(var; time = Inf) + z_max_values = + endswith(short_name, "error") ? (1e3, zd_rayleigh) : + (zd_rayleigh,) # Add closeup view of errors below 1 km. + Iterators.map(z_max_values) do z_max + window(var, z_dim_name(var); right = z_max) + end + end + end + make_contour_plots(short_names, time_series_output_name) do short_name + Iterators.flatmap(simdirs) do simdir + var = get(simdir; short_name) + var = window(var, z_dim_name(var); right = zd_rayleigh) + var = is_3d ? slice(var; y = 0) : var + time_values = if endswith(short_name, "predicted") + (Inf,) # Predicted values are constant and only need 1 plot. + elseif var.dims["time"][end] > 24 * 3600 + (1, 2, 24, Inf) .* 3600 + elseif var.dims["time"][end] > 3 * 3600 + (1, 2, 4, Inf) .* 3600 + else + (5, 10, 20, Inf) .* 60 + end + Iterators.map(time -> slice(var; time), time_values) + end + end + end end function make_plots( diff --git a/src/ClimaAtmos.jl b/src/ClimaAtmos.jl index eb692ff3e7..46d739cc26 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 c07c463246..ad418dea3b 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 194680bc72..424991b8b7 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 1ecf457987..57621ecbc9 100644 --- a/src/diagnostics/core_diagnostics.jl +++ b/src/diagnostics/core_diagnostics.jl @@ -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 01ef8917a0..dd506e350a 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 7ef533689f..e35fde1d88 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) @@ -187,80 +209,6 @@ function (initial_condition::MoistFromFile)(params) return local_state end -""" - AgnesiHProfile(; perturb = false) - -An `InitialCondition` with a decaying temperature profile -""" -struct AgnesiHProfile <: InitialCondition end - -function (initial_condition::AgnesiHProfile)(params) - function local_state(local_geometry) - FT = eltype(params) - grav = CAP.grav(params) - thermo_params = CAP.thermodynamics_params(params) - (; x, z) = local_geometry.coordinates - cp_d = CAP.cp_d(params) - cv_d = CAP.cv_d(params) - p_0 = CAP.p_ref_theta(params) - R_d = CAP.R_d(params) - T_0 = CAP.T_0(params) - # auxiliary quantities - T_bar = FT(250) - buoy_freq = grav / sqrt(cp_d * T_bar) - π_exn = exp(-grav * z / cp_d / T_bar) - p = p_0 * π_exn^(cp_d / R_d) # pressure - ρ = p / R_d / T_bar # density - velocity = @. Geometry.UVVector(FT(20), FT(0)) - return LocalState(; - params, - geometry = local_geometry, - thermo_state = TD.PhaseDry_pT(thermo_params, p, T_bar), - velocity = velocity, - ) - end - return local_state -end - -""" - ScharProfile(; perturb = false) - -An `InitialCondition` with a prescribed Brunt-Vaisala Frequency -""" -Base.@kwdef struct ScharProfile <: InitialCondition end - -function (initial_condition::ScharProfile)(params) - function local_state(local_geometry) - FT = eltype(params) - - thermo_params = CAP.thermodynamics_params(params) - g = CAP.grav(params) - R_d = CAP.R_d(params) - cp_d = CAP.cp_d(params) - cv_d = CAP.cv_d(params) - p₀ = CAP.p_ref_theta(params) - (; x, z) = local_geometry.coordinates - θ₀ = FT(280.0) - buoy_freq = FT(0.01) - θ = θ₀ * exp(buoy_freq^2 * z / g) - π_exner = - 1 + - g^2 / (cp_d * θ₀ * buoy_freq^2) * (exp(-buoy_freq^2 * z / g) - 1) - T = π_exner * θ # temperature - ρ = p₀ / (R_d * T) * (π_exner)^(cp_d / R_d) - p = ρ * R_d * T - velocity = Geometry.UVVector(FT(10), FT(0)) - - return LocalState(; - params, - geometry = local_geometry, - thermo_state = TD.PhaseDry_pT(thermo_params, p, T), - velocity = velocity, - ) - end - return local_state -end - """ DryDensityCurrentProfile(; perturb = false) diff --git a/src/solver/type_getters.jl b/src/solver/type_getters.jl index f3386074a4..f9b482a187 100644 --- a/src/solver/type_getters.jl +++ b/src/solver/type_getters.jl @@ -320,11 +320,10 @@ function get_initial_condition(parsed_args) parsed_args["perturb_initstate"], ) elseif parsed_args["initial_condition"] in [ + "ConstantBuoyancyFrequencyProfile", "IsothermalProfile", - "AgnesiHProfile", "DryDensityCurrentProfile", "RisingThermalBubbleProfile", - "ScharProfile", "PrecipitatingColumn", ] return getproperty(ICs, Symbol(parsed_args["initial_condition"]))() @@ -343,6 +342,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 +760,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 +771,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 0000000000..8ef9e9c4ca --- /dev/null +++ b/src/topography/steady_state_solutions.jl @@ -0,0 +1,295 @@ +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_∂Fh_approximation(params, η, k_x, k_y, z_top) + +Approximates the derivative `∂FΔU/∂Fh`, where `FΔU` is the Fourier transform of +the 3D velocity perturbation `ΔU`, and `Fh` is the Fourier transform of the +topography elevation `h`. This approximation is first-order in the maximum +topography elevation `h_max` and zeroth-order in the static stability paramter +`β = N^2 / g`. We assume that the topographic grid warping is a `LinearAdaption` +and the perturbation's background state is a `ConstantBuoyancyFrequencyProfile`. + +TODO: Generalize this to work with any `HypsographyAdaption` from ClimaCore. + +Arguments: + - `params`: `ClimaAtmosParameters` used to define the background state + - `η`: nondimensional terrain-following coordinate (0 at surface and 1 at top) + - `k_x`: wavenumber along `x`-direction + - `k_y`: wavenumber along `y`-direction + - `z_top`: elevation at the top of the model domain + +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_∂Fh_approximation(params, η, k_x, k_y, 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) + + g == 0 && return @SVector(FT[0, 0, 0]) + # TODO: This is only correct when h_max is 0. We need to find the limit of + # ∂FΔU/∂Fh as g approaches 0. + + β = 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_∂Fh = -im * k_x # ∂F(-∂h/∂x / (1 - h/z_top))/∂Fh to 1st order in h + ∂FΔη_yh_∂Fh = -im * k_y # ∂F(-∂h/∂y / (1 - h/z_top))/∂Fh to 1st order in h + ∂FΔη_z_∂Fh = 1 / z_top # ∂F(h/z_top / (1 - h/z_top))/∂Fh to 1st 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_∂Fh = ∂FΔη_xh_∂Fh + ∂FΔη_y_sfc_∂Fh = ∂FΔη_yh_∂Fh + + ρ = 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_∂Fh = ∂FΔη_xh_∂Fh * (1 - η / z_top) + ∂FΔη_y_∂Fh = ∂FΔη_yh_∂Fh * (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_∂Fh = + g / u * ( + im * k_h² / k_x * μ_sfc * ∂FΔη_z_∂Fh + + ((1 - m_sfc) * ∂FΔη_xh_∂Fh + r * ∂FΔη_yh_∂Fh) * ∂FΔη_z_∂Fh + + β * + r * + (-r * ∂FΔη_x_sfc_∂Fh + ∂FΔη_y_sfc_∂Fh) * + (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_∂Fh = + g / u * ( + im * k_h² / k_x * μ * ∂FΔη_z_∂Fh + + ((1 - m) * ∂FΔη_xh_∂Fh + r * ∂FΔη_yh_∂Fh) * ∂FΔη_z_∂Fh + + β * r * (-r * ∂FΔη_x_∂Fh + ∂FΔη_y_∂Fh) * (1 + ν * (1 - a)) + ) + + plus_or_minus = k_η² > 0 ? sign(k_x * u) : 1 + + # Approximate the derivatives of FΔw and ∂FΔw_∂η to zeroth order in β. + ∂FΔw_∂Fh = + ∂Δf_∂Fh / k_η² + + (sqrt(d_sfc / d) * (im * k_x * u - ∂Δf_sfc_∂Fh / k_η_sfc²)) * + exp(plus_or_minus * im * sqrt(Complex(k_η²)) * η) + ∂²FΔw_∂Fh∂η = + plus_or_minus * im * sqrt(Complex(k_η²)) * (∂FΔw_∂Fh - ∂Δf_∂Fh / k_η²) + + ∂FΔp_∂Fh = + -(im * k_x / k_h² * g * d) * ( + (1 - m) * ∂FΔη_x_∂Fh + r * ∂FΔη_y_∂Fh - m / u * ∂FΔw_∂Fh + + u / g * ∂²FΔw_∂Fh∂η + ) + ∂FΔu_∂Fh = -(im / k_x * g * ρ * ∂FΔη_x_∂Fh + ∂FΔp_∂Fh) / (ρ * u) + ∂FΔv_∂Fh = -(im / k_x * g * ρ * ∂FΔη_y_∂Fh + r * ∂FΔp_∂Fh) / (ρ * u) + + return @SVector([∂FΔu_∂Fh, ∂FΔv_∂Fh, ∂FΔw_∂Fh]) +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) + (; λ) = cosine_params(FT) + (; x, z) = coord + return steady_state_velocity_cosine(params, x, FT(0), z, λ, FT(Inf), z_top) +end + +function steady_state_velocity_cosine_3d(params, coord, z_top) + FT = eltype(params) + (; λ) = cosine_params(FT) + (; x, y, z) = coord + return steady_state_velocity_cosine(params, x, y, z, λ, λ, z_top) +end + +function steady_state_velocity_cosine(params, x, y, z, λ_x, λ_y, z_top) + FT = eltype(params) + (; h_max) = cosine_params(FT) + 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 ΔU as the inverse Fourier transform of the approximation 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 evaluate the integrand at (±k_x, ±k_y) + # using Fh = h_max / 4. Since the integrand is symmetric around the origin, + # we can also just evaluate it at (k_x, k_y) and multiply the result by 4. + FΔU = ∂FΔU_∂Fh_approximation(params, η, k_x, k_y, z_top) * h_max + (Δ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 by sampling its values n_samples times. We do not +# use QuadGK for this because it is not compatible with GPUs. +function definite_integral(f::F, x1, x2, n_samples) where {F} + dx = (x2 - x1) / n_samples + return sum(index -> f(x1 + index * dx), 2:n_samples; init = f(x1 + dx)) * dx +end + +function steady_state_velocity_mountain_2d( + mountain_elevation::F1, + centered_mountain_elevation_fourier_transform::F2, + params, + coord, + z_top, + x_center, + k_x_max, +) 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 ΔU as the inverse Fourier transform of the approximation 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, 1_000_000) do k_x + # Use the transform of the centered elevation h(x - x_center) rather + # than 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 introduces large numerical errors. + Fh = centered_mountain_elevation_fourier_transform(k_x) + FΔU = ∂FΔU_∂Fh_approximation(params, η, k_x, k_y, z_top) * Fh + 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( + topography_agnesi, + topography_agnesi_Fh, + params, + coord, + z_top, + x_center, + k_x_max, + ) +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( + topography_schar, + topography_schar_Fh, + params, + coord, + z_top, + x_center, + k_x_max, + ) +end diff --git a/src/topography/topography.jl b/src/topography/topography.jl index aa7d6bbbea..3482617b7a 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(25e3)) diff --git a/src/utils/common_spaces.jl b/src/utils/common_spaces.jl index 0f2962629d..352965d333 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 6849baa8df..6bc418fac0 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/plane_agnesi_mountain_test_stretched.toml b/toml/plane_agnesi_mountain_test_stretched.toml deleted file mode 100644 index 7852943ccc..0000000000 --- a/toml/plane_agnesi_mountain_test_stretched.toml +++ /dev/null @@ -1,3 +0,0 @@ -[alpha_rayleigh_w] -value = 0.1 - diff --git a/toml/plane_agnesi_mountain_test_uniform.toml b/toml/plane_agnesi_mountain_test_uniform.toml deleted file mode 100644 index 7852943ccc..0000000000 --- a/toml/plane_agnesi_mountain_test_uniform.toml +++ /dev/null @@ -1,3 +0,0 @@ -[alpha_rayleigh_w] -value = 0.1 - diff --git a/toml/plane_schar_mountain_test_uniform.toml b/toml/plane_schar_mountain_test_uniform.toml deleted file mode 100644 index 7852943ccc..0000000000 --- a/toml/plane_schar_mountain_test_uniform.toml +++ /dev/null @@ -1,3 +0,0 @@ -[alpha_rayleigh_w] -value = 0.1 - diff --git a/toml/plane_schar_mountain_test_stretched.toml b/toml/steady_state_test.toml similarity index 53% rename from toml/plane_schar_mountain_test_stretched.toml rename to toml/steady_state_test.toml index 7852943ccc..da5ffcc620 100644 --- a/toml/plane_schar_mountain_test_stretched.toml +++ b/toml/steady_state_test.toml @@ -1,3 +1,5 @@ [alpha_rayleigh_w] value = 0.1 +[zd_rayleigh] +value = 13000