From d99b2b618522d994d01b85f343e97f37fc04142c Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 12:48:44 +0200 Subject: [PATCH 01/54] Misc cleanups and refactors - Bathymetry: minor fix in regrid_bathymetry - DataWrangling: inpainting and restoring improvements - Diagnostics: MLD minor update - EarthSystemModels: simplify time stepping, minor earth_system_model cleanup - InterfaceComputations: minor refactors in sea_ice_ocean fluxes and heat flux formulations Note: Ocean simulation refactoring (flux_and_restoring, Oceans.jl, ocean_simulation.jl) and Diagnostics (interface_fluxes) are handled in PR #155 and #158. --- src/Bathymetry/regrid_bathymetry.jl | 3 ++- src/DataWrangling/inpainting.jl | 13 +++++++++---- src/DataWrangling/restoring.jl | 2 ++ src/Diagnostics/mixed_layer_depth.jl | 1 - .../InterfaceComputations/sea_ice_ocean_fluxes.jl | 12 ++++++------ .../sea_ice_ocean_heat_flux_formulations.jl | 6 +++--- src/EarthSystemModels/earth_system_model.jl | 3 +-- .../time_step_earth_system_model.jl | 9 ++------- 8 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/Bathymetry/regrid_bathymetry.jl b/src/Bathymetry/regrid_bathymetry.jl index 430cad426..564d531f3 100644 --- a/src/Bathymetry/regrid_bathymetry.jl +++ b/src/Bathymetry/regrid_bathymetry.jl @@ -341,7 +341,8 @@ end # Fix active cells to be at least `-minimum_depth`. active = z < 0 # it's a wet cell - z = ifelse(active, min(z, -minimum_depth), z) + above_minimum_depth = z > -minimum_depth + z = ifelse(active, ifelse(above_minimum_depth, zero(z), z), z) @inbounds target_z[i, j, 1] = z end diff --git a/src/DataWrangling/inpainting.jl b/src/DataWrangling/inpainting.jl index dde0f1f91..a081fa1d7 100644 --- a/src/DataWrangling/inpainting.jl +++ b/src/DataWrangling/inpainting.jl @@ -51,7 +51,12 @@ function propagate_horizontally!(inpainting::NearestNeighborInpainting, field, m iter += 1 end - launch!(arch, grid, size(field), _fill_nans!, field) + # Fill any remaining NaN values with the mean of valid data. + # Using 0 would be catastrophic for fields like salinity (~34 psu). + valid_sum = sum(x -> ifelse(isnan(x), zero(x), x), field; condition=interior(mask)) + valid_count = sum(x -> !isnan(x), field; condition=interior(mask)) + fill_value = convert(eltype(field), valid_sum / valid_count) + launch!(arch, grid, size(field), _fill_nans!, field, fill_value) fill_halo_regions!(field) return field @@ -80,7 +85,7 @@ end end FT_NaN = convert(FT, NaN) - @inbounds substituting_field[i, j, k] = ifelse(value == 0, FT_NaN, value / donors) + @inbounds substituting_field[i, j, k] = ifelse(donors == 0, FT_NaN, value / donors) end @kernel function _substitute_values!(field, substituting_field) @@ -97,9 +102,9 @@ end @inbounds field[i, j, k] = ifelse(mask[i, j, k], FT_NaN, field[i, j, k]) end -@kernel function _fill_nans!(field) +@kernel function _fill_nans!(field, fill_value) i, j, k = @index(Global, NTuple) - @inbounds field[i, j, k] *= !isnan(field[i, j, k]) + @inbounds field[i, j, k] = ifelse(isnan(field[i, j, k]), fill_value, field[i, j, k]) end """ diff --git a/src/DataWrangling/restoring.jl b/src/DataWrangling/restoring.jl index 918edfae0..d26b7c1f4 100644 --- a/src/DataWrangling/restoring.jl +++ b/src/DataWrangling/restoring.jl @@ -12,6 +12,7 @@ using Dates: Second import NumericalEarth: stateindex import Oceananigans.Forcings: materialize_forcing +import Oceananigans.OutputReaders: extract_field_time_series # Variable names for restorable data struct Temperature end @@ -234,6 +235,7 @@ function Base.show(io::IO, dsr::DatasetRestoring) end materialize_forcing(forcing::DatasetRestoring, field, field_name, model_field_names) = forcing +extract_field_time_series(forcing::DatasetRestoring) = forcing.field_time_series ##### ##### Masks for restoring diff --git a/src/Diagnostics/mixed_layer_depth.jl b/src/Diagnostics/mixed_layer_depth.jl index 0b986312b..3faeb750a 100644 --- a/src/Diagnostics/mixed_layer_depth.jl +++ b/src/Diagnostics/mixed_layer_depth.jl @@ -32,7 +32,6 @@ end function compute!(mld::MixedLayerDepthField, time=nothing) compute_mixed_layer_depth!(mld) - #@apply_regionally compute_mixed_layer_depth!(mld) fill_halo_regions!(mld) return mld end diff --git a/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_fluxes.jl b/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_fluxes.jl index a715493d3..5cbffb333 100644 --- a/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_fluxes.jl +++ b/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_fluxes.jl @@ -175,12 +175,12 @@ end qᶠ = δ𝒬ᶠʳᶻ / ℰ @inbounds begin - Tᴺ = Tᵒᶜ[i, j, Nz] - Sᴺ = Sᵒᶜ[i, j, Nz] + Tᴺ = Tᵒᶜ[i, j, Nz] + Sᴺ = Sᵒᶜ[i, j, Nz] Sˢⁱ = ice_salinity[i, j, 1] hˢⁱ = ice_thickness[i, j, 1] - ℵᵢ = ice_concentration[i, j, 1] - hc = ice_consolidation_thickness[i, j, 1] + ℵᵢ = ice_concentration[i, j, 1] + hc = ice_consolidation_thickness[i, j, 1] end # Extract internal temperature (for ConductiveFluxTEF, zero otherwise) @@ -198,8 +198,8 @@ end # ============================================= # Returns interfacial heat flux, melt rate qᵐ, and interface T, S 𝒬ⁱᵒ, qᵐ, Tᵦ, Sᵦ = compute_interface_heat_flux(flux_formulation, - ocean_surface_state, ice_state, - liquidus, ocean_properties, ℰ, u★) + ocean_surface_state, ice_state, + liquidus, ocean_properties, ℰ, u★) # Store interface values and heat flux @inbounds 𝒬ⁱⁿᵗ[i, j, 1] = 𝒬ⁱᵒ diff --git a/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_heat_flux_formulations.jl b/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_heat_flux_formulations.jl index ebf4f4ce8..915fa64b8 100644 --- a/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_heat_flux_formulations.jl +++ b/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_heat_flux_formulations.jl @@ -113,8 +113,8 @@ References - [holland1999modeling](@citet): Holland, D. M., & Jenkins, A. (1999). Modeling thermodynamic ice–ocean interactions at the base of an ice shelf. *Journal of Physical Oceanography*, 29(8), 1787-1800. -- [hieronymus2021comparison](@citet): Hieronymus, M., et al. (2021). A comparison of ocean-ice flux parametrizations. - *Geosci. Model Dev.*, 14, 4891-4908. +- [shi2021sensitivity](@citet): Shi, X., Notz, D., Liu, J., Yang, H., & Lohmann, G. (2021). Sensitivity of Northern + Hemisphere climate to ice-ocean interface heat flux parameterizations. *Geosci. Model Dev.*, 14, 4891-4908. """ struct ThreeEquationHeatFlux{F, T, FT, U} conductive_flux :: F @@ -139,7 +139,7 @@ Adapt.adapt_structure(to, f::ThreeEquationHeatFlux) = Construct a `ThreeEquationHeatFlux` with the specified parameters. -Default values follow [hieronymus2021comparison](@citet) with ``R = \\alpha_h / \\alpha_s = 35``. +Default values follow [shi2021sensitivity](@citet) with ``R = \\alpha_h / \\alpha_s = 35``. Keyword Arguments ================= diff --git a/src/EarthSystemModels/earth_system_model.jl b/src/EarthSystemModels/earth_system_model.jl index 90469dc04..db5ff93dd 100644 --- a/src/EarthSystemModels/earth_system_model.jl +++ b/src/EarthSystemModels/earth_system_model.jl @@ -44,13 +44,12 @@ function Base.show(io::IO, cm::ESM) return nothing end -# Assumption: We have an ocean! architecture(model::ESM) = model.architecture Base.eltype(model::ESM) = Base.eltype(model.interfaces.exchanger.grid) prettytime(model::ESM) = prettytime(model.clock.time) iteration(model::ESM) = model.clock.iteration timestepper(::ESM) = nothing -default_included_properties(::ESM) = tuple() +default_included_properties(::ESM) = [] prognostic_fields(cm::ESM) = nothing fields(::ESM) = NamedTuple() default_clock(TT) = Oceananigans.TimeSteppers.Clock{TT}(0, 0, 1) diff --git a/src/EarthSystemModels/time_step_earth_system_model.jl b/src/EarthSystemModels/time_step_earth_system_model.jl index 0ec04e6e2..c486171b3 100644 --- a/src/EarthSystemModels/time_step_earth_system_model.jl +++ b/src/EarthSystemModels/time_step_earth_system_model.jl @@ -15,13 +15,8 @@ function time_step!(coupled_model::EarthSystemModel, Δt; callbacks=[]) atmosphere = coupled_model.atmosphere # Eventually, split out into OceanOnlyModel - !isnothing(sea_ice) && time_step!(sea_ice, Δt) - - # TODO after ice time-step: - # - Adjust ocean heat flux if the ice completely melts? - !isnothing(ocean) && time_step!(ocean, Δt) - - # Time step the atmosphere + !isnothing(sea_ice) && time_step!(sea_ice, Δt) + !isnothing(ocean) && time_step!(ocean, Δt) !isnothing(atmosphere) && time_step!(atmosphere, Δt) # TODO: From 97392107afcdc6b1c93bb231226382881f236301 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 12:48:44 +0200 Subject: [PATCH 02/54] Misc cleanups and refactors - Bathymetry: minor fix in regrid_bathymetry - DataWrangling: inpainting and restoring improvements - Diagnostics: interface flux diagnostics and MLD updates - Oceans: refactor ocean simulation, extract flux_and_restoring - EarthSystemModels: simplify time stepping, minor earth_system_model cleanup - InterfaceComputations: minor refactors in sea_ice_ocean fluxes and heat flux formulations --- src/Diagnostics/Diagnostics.jl | 2 + src/Diagnostics/interface_fluxes.jl | 9 ++-- src/Oceans/Oceans.jl | 19 ++++--- src/Oceans/flux_and_restoring.jl | 46 +++++++++++++++++ src/Oceans/ocean_simulation.jl | 77 ++++++++++++++++++----------- 5 files changed, 115 insertions(+), 38 deletions(-) create mode 100644 src/Oceans/flux_and_restoring.jl diff --git a/src/Diagnostics/Diagnostics.jl b/src/Diagnostics/Diagnostics.jl index 86cb7446a..4f698a4b1 100644 --- a/src/Diagnostics/Diagnostics.jl +++ b/src/Diagnostics/Diagnostics.jl @@ -14,7 +14,9 @@ using Oceananigans.BoundaryConditions: FieldBoundaryConditions, fill_halo_region using Oceananigans.Fields: FieldStatus using Oceananigans.Utils: launch! using KernelAbstractions: @index, @kernel +using Oceananigans.BoundaryConditions: DiscreteBoundaryFunction using NumericalEarth.EarthSystemModels: EarthSystemModel +using NumericalEarth.Oceans: FluxAndRestoring import Oceananigans.Fields: compute! diff --git a/src/Diagnostics/interface_fluxes.jl b/src/Diagnostics/interface_fluxes.jl index 4d144d84c..2d5b80758 100644 --- a/src/Diagnostics/interface_fluxes.jl +++ b/src/Diagnostics/interface_fluxes.jl @@ -1,4 +1,8 @@ +@inline flux_field(condition) = condition +@inline flux_field(bc::FluxAndRestoring) = bc.flux_field +@inline flux_field(bc::DiscreteBoundaryFunction) = flux_field(bc.func) + ########################### ### Temperature fluxes ########################### @@ -21,12 +25,11 @@ end Return the net temperature flux (K m s⁻¹) at the ocean's surface in a coupled `esm`. """ function net_ocean_temperature_flux(esm::EarthSystemModel) - Jᵀ = esm.ocean.model.tracers.T.boundary_conditions.top.condition + Jᵀ = flux_field(esm.ocean.model.tracers.T.boundary_conditions.top.condition) net_ocean_temperature_flux = Jᵀ + frazil_temperature_flux(esm) return Field(net_ocean_temperature_flux) end - """ sea_ice_ocean_temperature_flux(esm::EarthSystemModel) @@ -116,7 +119,7 @@ end Return the net salinity flux (g/kg m s⁻¹) at the ocean's surface in a coupled `esm`. """ function net_ocean_salinity_flux(esm::EarthSystemModel) - Jˢ = esm.ocean.model.tracers.S.boundary_conditions.top.condition + Jˢ = flux_field(esm.ocean.model.tracers.S.boundary_conditions.top.condition) return Jˢ end diff --git a/src/Oceans/Oceans.jl b/src/Oceans/Oceans.jl index 93b1d2727..87093e37f 100644 --- a/src/Oceans/Oceans.jl +++ b/src/Oceans/Oceans.jl @@ -1,13 +1,13 @@ module Oceans -export ocean_simulation, SlabOcean +export ocean_simulation, SlabOcean, FluxAndRestoring using Oceananigans using Oceananigans.Units using Oceananigans.Utils using Oceananigans.Utils: with_tracers using Oceananigans.Advection: FluxFormAdvection -using Oceananigans.BoundaryConditions: DefaultBoundaryCondition +using Oceananigans.BoundaryConditions: DefaultBoundaryCondition, DiscreteBoundaryFunction using Oceananigans.ImmersedBoundaries: immersed_peripheral_node, inactive_node, MutableGridOfSomeKind using Oceananigans.OrthogonalSphericalShellGrids using Oceananigans.Operators @@ -62,6 +62,7 @@ default_or_override(override, alternative_default=nothing) = override include("slab_ocean.jl") include("barotropic_potential_forcing.jl") include("radiative_forcing.jl") +include("flux_and_restoring.jl") include("ocean_simulation.jl") include("assemble_net_ocean_fluxes.jl") @@ -74,12 +75,12 @@ ocean_temperature(ocean::Simulation{<:HydrostaticFreeSurfaceModel}) = ocean.mode function ocean_surface_salinity(ocean::Simulation{<:HydrostaticFreeSurfaceModel}) kᴺ = size(ocean.model.grid, 3) - return interior(ocean.model.tracers.S, :, :, kᴺ:kᴺ) + return view(ocean.model.tracers.S.data, :, :, kᴺ:kᴺ) end function ocean_surface_temperature(ocean::Simulation{<:HydrostaticFreeSurfaceModel}) kᴺ = size(ocean.model.grid, 3) - return interior(ocean.model.tracers.T, :, :, kᴺ:kᴺ) + return view(ocean.model.tracers.T.data, :, :, kᴺ:kᴺ) end function ocean_surface_velocities(ocean::Simulation{<:HydrostaticFreeSurfaceModel}) @@ -109,14 +110,18 @@ function ComponentExchanger(ocean::Simulation{<:HydrostaticFreeSurfaceModel}, gr return ComponentExchanger((; u, v, T, S), nothing) end +@inline net_flux(condition) = condition +@inline net_flux(bc::FluxAndRestoring) = bc.flux_field +@inline net_flux(bc::DiscreteBoundaryFunction) = net_flux(bc.func) + function net_fluxes(ocean::Simulation{<:HydrostaticFreeSurfaceModel}) # TODO: Generalize this to work with any ocean model - τˣ = ocean.model.velocities.u.boundary_conditions.top.condition - τʸ = ocean.model.velocities.v.boundary_conditions.top.condition + τˣ = net_flux(ocean.model.velocities.u.boundary_conditions.top.condition) + τʸ = net_flux(ocean.model.velocities.v.boundary_conditions.top.condition) net_ocean_surface_fluxes = (; u=τˣ, v=τʸ) tracers = ocean.model.tracers - ocean_surface_tracer_fluxes = NamedTuple(name => tracers[name].boundary_conditions.top.condition for name in keys(tracers)) + ocean_surface_tracer_fluxes = NamedTuple(name => net_flux(tracers[name].boundary_conditions.top.condition) for name in keys(tracers)) return merge(ocean_surface_tracer_fluxes, net_ocean_surface_fluxes) end diff --git a/src/Oceans/flux_and_restoring.jl b/src/Oceans/flux_and_restoring.jl new file mode 100644 index 000000000..8da53a7f0 --- /dev/null +++ b/src/Oceans/flux_and_restoring.jl @@ -0,0 +1,46 @@ +using Oceananigans.Operators: Δzᶜᶜᶜ + +using Adapt + +""" + FluxAndRestoring(flux_field, restoring) + +A boundary-condition condition (intended to be wrapped in a discrete-form +`FluxBoundaryCondition`) that combines two contributions at a tracer's top +boundary: + +1. `flux_field`: a 2D `Field{Center, Center, Nothing}` that an external flux + solver (e.g. the OMIP coupled atmosphere/sea-ice solver) writes into each + step. This is read at `(i, j, 1)`. + +2. `restoring`: a callable with signature `(i, j, k, grid, clock, fields)` that + returns a tendency in the top cell — typically a `DatasetRestoring`, + evaluating to `r * μ * (ψ_dataset - ψ)`. The tendency is converted to a + surface flux by multiplying by `-Δz` at the top cell, consistent with the + Oceananigans top-flux sign convention (top cell tendency contribution is + `-J / Δz`). + +This lets the coupled flux solver and a dataset restoring share the same top +boundary condition without one clobbering the other. +""" +struct FluxAndRestoring{F, R} <: Function + flux_field :: F + restoring :: R +end + +Adapt.adapt_structure(to, fr::FluxAndRestoring) = + FluxAndRestoring(adapt(to, fr.flux_field), + adapt(to, fr.restoring)) + +@inline function (fr::FluxAndRestoring)(i, j, grid, clock, fields) + Nz = grid.Nz + @inbounds J = fr.flux_field[i, j, 1] + + # Restoring accessed as a tendency forcing (compatible with DatasetRestoring) + G = fr.restoring(i, j, Nz, grid, clock, fields) + + # Top BC convention: tendency contribution = -J / Δz, so to inject + # `G` in the top cell the flux is `-G * Δz`. + Δz = Δzᶜᶜᶜ(i, j, Nz, grid) + return J - G * Δz +end diff --git a/src/Oceans/ocean_simulation.jl b/src/Oceans/ocean_simulation.jl index 88627bb1b..71a24f3a3 100644 --- a/src/Oceans/ocean_simulation.jl +++ b/src/Oceans/ocean_simulation.jl @@ -19,15 +19,18 @@ using Statistics: mean ##### @inline ϕ²(i, j, k, grid, ϕ) = @inbounds ϕ[i, j, k]^2 -@inline spᶠᶜᶜ(i, j, k, grid, Φ) = @inbounds sqrt(Φ.u[i, j, k]^2 + ℑxyᶠᶜᵃ(i, j, k, grid, ϕ², Φ.v)) -@inline spᶜᶠᶜ(i, j, k, grid, Φ) = @inbounds sqrt(Φ.v[i, j, k]^2 + ℑxyᶜᶠᵃ(i, j, k, grid, ϕ², Φ.u)) +@inline spᶠᶜᶜ(i, j, k, grid, Φ, Uᴮ) = @inbounds sqrt(Φ.u[i, j, k]^2 + ℑxyᶠᶜᵃ(i, j, k, grid, ϕ², Φ.v) + Uᴮ^2) +@inline spᶜᶠᶜ(i, j, k, grid, Φ, Uᴮ) = @inbounds sqrt(Φ.v[i, j, k]^2 + ℑxyᶜᶠᵃ(i, j, k, grid, ϕ², Φ.u) + Uᴮ^2) -@inline u_quadratic_bottom_drag(i, j, grid, c, Φ, μ) = @inbounds - μ * Φ.u[i, j, 1] * spᶠᶜᶜ(i, j, 1, grid, Φ) -@inline v_quadratic_bottom_drag(i, j, grid, c, Φ, μ) = @inbounds - μ * Φ.v[i, j, 1] * spᶜᶠᶜ(i, j, 1, grid, Φ) +@inline u_quadratic_bottom_drag(i, j, grid, c, Φ, p) = @inbounds - p.μ * Φ.u[i, j, 1] * spᶠᶜᶜ(i, j, 1, grid, Φ, p.Uᴮ) +@inline v_quadratic_bottom_drag(i, j, grid, c, Φ, p) = @inbounds - p.μ * Φ.v[i, j, 1] * spᶜᶠᶜ(i, j, 1, grid, Φ, p.Uᴮ) # Keep a constant linear drag parameter independent on vertical level -@inline u_immersed_bottom_drag(i, j, k, grid, clock, Φ, μ) = @inbounds - μ * Φ.u[i, j, k] * spᶠᶜᶜ(i, j, k, grid, Φ) -@inline v_immersed_bottom_drag(i, j, k, grid, clock, Φ, μ) = @inbounds - μ * Φ.v[i, j, k] * spᶜᶠᶜ(i, j, k, grid, Φ) +@inline u_immersed_bottom_drag(i, j, k, grid, clock, Φ, p) = @inbounds - p.μ * Φ.u[i, j, k] * spᶠᶜᶜ(i, j, k, grid, Φ, p.Uᴮ) +@inline v_immersed_bottom_drag(i, j, k, grid, clock, Φ, p) = @inbounds - p.μ * Φ.v[i, j, k] * spᶜᶠᶜ(i, j, k, grid, Φ, p.Uᴮ) + +@inline build_top_tracer_bc(flux_field, ::Nothing) = FluxBoundaryCondition(flux_field) +@inline build_top_tracer_bc(flux_field, restoring) = FluxBoundaryCondition(FluxAndRestoring(flux_field, restoring); discrete_form=true) ##### ##### Defaults @@ -100,6 +103,7 @@ end """ ocean_simulation(grid; Δt = estimate_maximum_Δt(grid), + clock = Clock(grid), closure = default_ocean_closure(), tracers = (:T, :S), free_surface = default_free_surface(grid), @@ -107,7 +111,9 @@ end rotation_rate = default_planet_rotation_rate, gravitational_acceleration = default_gravitational_acceleration, bottom_drag_coefficient = Default(0.003), + drag_bulk_velocity = Default(0.1), forcing = NamedTuple(), + surface_restoring = NamedTuple(), biogeochemistry = nothing, timestepper = :SplitRungeKutta3, coriolis = Default(HydrostaticSphericalCoriolis(; rotation_rate)), @@ -126,7 +132,6 @@ consistent defaults for advection, closures, the equation of state, surface flux barotropic pressure–gradient forcing, boundary conditions, and optional biogeochemistry. It then wraps the model into an Oceananigans's `Simulation` with the specified timestepping options. - ## Behaviour and automatic configuration ### Coriolis @@ -161,6 +166,7 @@ defaults on a per-field basis. ## Keyword Arguments - `Δt`: Timestep used by the `Simulation`. Defaults to the maximum stable timestep estimated from the `grid`. +- `clock`: Clock object. Defaults to `Clock(grid)`. - `closure`: A turbulence or mixing closure. Defaults to `default_ocean_closure()`. - `tracers`: Tuple of tracer names. Defaults to `(:T, :S)`. - `free_surface`: Free–surface solver. Defaults to `default_free_surface(grid)`. @@ -168,7 +174,9 @@ defaults on a per-field basis. - `rotation_rate`: Planetary rotation rate used for Coriolis forcing. - `gravitational_acceleration`: Gravitational acceleration, passed to buoyancy. - `bottom_drag_coefficient`: Bottom drag coefficient. May be a `Default` wrapper. +- `drag_bulk_velocity`: a minimum velocity for the bottom drag. - `forcing`: Named tuple of additional forcing(s) for individual fields. +- `surface_restoring`: Named tuple of dataset restorings to apply as part of the tracer top boundary condition. - `biogeochemistry`: A biogeochemical model or `nothing`. - `timestepper`: Time-stepping scheme; options are `:SplitRungeKutta3` (default), or `:QuasiAdamsBashforth2`. - `coriolis`: Coriolis object or `Default(...)` wrapper. @@ -182,6 +190,7 @@ defaults on a per-field basis. """ function ocean_simulation(grid; Δt = estimate_maximum_Δt(grid), + clock = Clock(grid), closure = default_ocean_closure(), tracers = (:T, :S), free_surface = default_free_surface(grid), @@ -189,7 +198,10 @@ function ocean_simulation(grid; rotation_rate = default_planet_rotation_rate, gravitational_acceleration = default_gravitational_acceleration, bottom_drag_coefficient = Default(0.003), + drag_bulk_velocity = Default(0.05), + use_barotropic_potential = true, forcing = NamedTuple(), + surface_restoring = NamedTuple(), biogeochemistry = nothing, timestepper = :SplitRungeKutta3, coriolis = Default(HydrostaticSphericalCoriolis(; rotation_rate)), @@ -198,11 +210,12 @@ function ocean_simulation(grid; equation_of_state = TEOS10EquationOfState(; reference_density), boundary_conditions::NamedTuple = NamedTuple(), radiative_forcing = default_radiative_forcing(grid), + materialize_buoyancy_gradients = true, warn = true, verbose = false) FT = eltype(grid) - + if grid isa RectilinearGrid # turn off Coriolis unless user-supplied coriolis = default_or_override(coriolis, nothing) else @@ -216,6 +229,8 @@ function ocean_simulation(grid; if single_column_simulation # Let users put a bottom drag if they want bottom_drag_coefficient = default_or_override(bottom_drag_coefficient, zero(grid)) + drag_bulk_velocity = default_or_override(drag_bulk_velocity, zero(grid)) + drag_parameters = (μ = bottom_drag_coefficient, Uᴮ = drag_bulk_velocity) # Don't let users use advection in a single column model tracer_advection = nothing @@ -236,21 +251,27 @@ function ocean_simulation(grid; end bottom_drag_coefficient = default_or_override(bottom_drag_coefficient) + drag_bulk_velocity = default_or_override(drag_bulk_velocity) + bottom_drag_coefficient = convert(FT, bottom_drag_coefficient) + drag_bulk_velocity = convert(FT, drag_bulk_velocity) + drag_parameters = (μ = bottom_drag_coefficient, Uᴮ = drag_bulk_velocity) - u_immersed_drag = FluxBoundaryCondition(u_immersed_bottom_drag, discrete_form=true, parameters=bottom_drag_coefficient) - v_immersed_drag = FluxBoundaryCondition(v_immersed_bottom_drag, discrete_form=true, parameters=bottom_drag_coefficient) + u_immersed_drag = FluxBoundaryCondition(u_immersed_bottom_drag, discrete_form=true, parameters=drag_parameters) + v_immersed_drag = FluxBoundaryCondition(v_immersed_bottom_drag, discrete_form=true, parameters=drag_parameters) u_immersed_bc = ImmersedBoundaryCondition(bottom=u_immersed_drag) v_immersed_bc = ImmersedBoundaryCondition(bottom=v_immersed_drag) - # Forcing for u, v - barotropic_potential = Field{Center, Center, Nothing}(grid) - u_forcing = BarotropicPotentialForcing(XDirection(), barotropic_potential) - v_forcing = BarotropicPotentialForcing(YDirection(), barotropic_potential) + if use_barotropic_potential + # Forcing for u, v + barotropic_potential = Field{Center, Center, Nothing}(grid) + u_forcing = BarotropicPotentialForcing(XDirection(), barotropic_potential) + v_forcing = BarotropicPotentialForcing(YDirection(), barotropic_potential) - :u ∈ keys(forcing) && (u_forcing = (u_forcing, forcing[:u])) - :v ∈ keys(forcing) && (v_forcing = (v_forcing, forcing[:v])) - forcing = merge(forcing, (u=u_forcing, v=v_forcing)) + :u ∈ keys(forcing) && (u_forcing = (u_forcing, forcing[:u])) + :v ∈ keys(forcing) && (v_forcing = (v_forcing, forcing[:v])) + forcing = merge(forcing, (u=u_forcing, v=v_forcing)) + end end if !isnothing(radiative_forcing) @@ -262,22 +283,20 @@ function ocean_simulation(grid; forcing = merge(forcing, (; T=T_forcing)) end - bottom_drag_coefficient = convert(FT, bottom_drag_coefficient) - # Set up boundary conditions using Field - top_zonal_momentum_flux = τˣ = Field{Face, Center, Nothing}(grid) - top_meridional_momentum_flux = τʸ = Field{Center, Face, Nothing}(grid) + top_zonal_momentum_flux = τˣ = Field{Face, Center, Nothing}(grid) + top_meridional_momentum_flux = τʸ = Field{Center, Face, Nothing}(grid) top_ocean_heat_flux = Jᵀ = Field{Center, Center, Nothing}(grid) top_salt_flux = Jˢ = Field{Center, Center, Nothing}(grid) # Construct ocean boundary conditions including surface forcing and bottom drag u_top_bc = FluxBoundaryCondition(τˣ) v_top_bc = FluxBoundaryCondition(τʸ) - T_top_bc = FluxBoundaryCondition(Jᵀ) - S_top_bc = FluxBoundaryCondition(Jˢ) + T_top_bc = build_top_tracer_bc(Jᵀ, get(surface_restoring, :T, nothing)) + S_top_bc = build_top_tracer_bc(Jˢ, get(surface_restoring, :S, nothing)) - u_bot_bc = FluxBoundaryCondition(u_quadratic_bottom_drag, discrete_form=true, parameters=bottom_drag_coefficient) - v_bot_bc = FluxBoundaryCondition(v_quadratic_bottom_drag, discrete_form=true, parameters=bottom_drag_coefficient) + u_bot_bc = FluxBoundaryCondition(u_quadratic_bottom_drag, discrete_form=true, parameters=drag_parameters) + v_bot_bc = FluxBoundaryCondition(v_quadratic_bottom_drag, discrete_form=true, parameters=drag_parameters) default_boundary_conditions = (u = FieldBoundaryConditions(top=u_top_bc, bottom=u_bot_bc, immersed=u_immersed_bc), v = FieldBoundaryConditions(top=v_top_bc, bottom=v_bot_bc, immersed=v_immersed_bc), @@ -289,7 +308,8 @@ function ocean_simulation(grid; # conditions even when a user-bc is supplied). boundary_conditions = merge(default_boundary_conditions, boundary_conditions) buoyancy = SeawaterBuoyancy(; gravitational_acceleration, equation_of_state) - + buoyancy = Oceananigans.BuoyancyFormulations.BuoyancyForce(grid, buoyancy; materialize_gradients=materialize_buoyancy_gradients) + if tracer_advection isa NamedTuple tracer_advection = with_tracers(tracers, tracer_advection, default_tracer_advection()) else @@ -297,12 +317,13 @@ function ocean_simulation(grid; end if hasclosure(closure, CATKEVerticalDiffusivity) - # Turn off CATKE tracer advection - tke_advection = (; e=nothing) + # Use the same advection as for temperature + tke_advection = (; e=tracer_advection[1]) tracer_advection = merge(tracer_advection, tke_advection) end ocean_model = HydrostaticFreeSurfaceModel(grid; + clock, buoyancy, closure, biogeochemistry, From c1345d7dd31de06a707fece2288ac9d4dbd00d08 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 12:50:01 +0200 Subject: [PATCH 03/54] Add temperature/snow-dependent sea ice albedo (CCSM3 scheme) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New type SeaIceAlbedo implementing the CCSM3 albedo parameterization (Briegleb et al. 2004): - Broadband albedos: bare ice 0.54, snow-covered 0.83 - Temperature-dependent reduction near melting (implicit melt ponds) - Thin-ice transition to ocean albedo below 0.5 m - Snow cover blending (full snow albedo at hs > 0.02 m) - Handles nothing snow_thickness gracefully Also evaluates state-dependent albedo to scalar via stateindex at the top of the atmosphere-sea ice flux kernel, fixing a potential struct-in-arithmetic bug when the albedo is not a plain number. Changes to atmosphere_sea_ice_fluxes.jl: - Evaluate radiation properties (α, ϵ) via stateindex before iteration - Pass local_interface_properties with scalar values to compute_interface_state - Add hs (snow thickness) to local_interior_state - Rename h → hi for ice thickness in interior state References: - Briegleb et al. (2004): NCAR Tech Note - Briegleb & Light (2007): NCAR/TN-472+STR --- .../InterfaceComputations.jl | 10 ++ .../atmosphere_sea_ice_fluxes.jl | 24 +++- .../InterfaceComputations/sea_ice_albedo.jl | 133 ++++++++++++++++++ 3 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl diff --git a/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl b/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl index ed93f8c9c..0f543cf39 100644 --- a/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl +++ b/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl @@ -9,14 +9,21 @@ export Radiation, ComponentInterfaces, LatitudeDependentAlbedo, + SeaIceAlbedo, SimilarityTheoryFluxes, MomentumRoughnessLength, ScalarRoughnessLength, + NCARMomentumRoughnessLength, + NCARScalarRoughnessLength, + NCARBulkFluxes, CoefficientBasedFluxes, SkinTemperature, BulkTemperature, + LinearStableStabilityFunction, + COARELogarithmicSimilarityProfile, atmosphere_ocean_stability_functions, atmosphere_sea_ice_stability_functions, + ncar_stability_functions, compute_atmosphere_ocean_fluxes!, compute_atmosphere_sea_ice_fluxes!, compute_sea_ice_ocean_fluxes!, @@ -67,13 +74,16 @@ end include("radiation.jl") include("latitude_dependent_albedo.jl") include("tabulated_albedo.jl") +include("sea_ice_albedo.jl") # Turbulent fluxes include("roughness_lengths.jl") +include("ncar_roughness_lengths.jl") include("interface_states.jl") include("compute_interface_state.jl") include("similarity_theory_turbulent_fluxes.jl") include("coefficient_based_turbulent_fluxes.jl") +include("ncar_bulk_fluxes.jl") # State exchanger and interfaces include("state_exchanger.jl") diff --git a/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl b/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl index 1292d6311..667a92804 100644 --- a/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl +++ b/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl @@ -84,18 +84,30 @@ end # Sea ice properties uˢⁱ = zero(FT) # ℑxᶜᵃᵃ(i, j, 1, grid, interior_state.u) vˢⁱ = zero(FT) # ℑyᵃᶜᵃ(i, j, 1, grid, interior_state.v) - hˢⁱ = interior_state.h[i, j, 1] + hˢⁱ = interior_state.hi[i, j, 1] + hˢⁿ = interior_state.hs[i, j, 1] hc = interior_state.hc[i, j, 1] ℵᵢ = interior_state.ℵ[i, j, 1] Tₛ = interface_temperature[i, j, 1] Tₛ = convert_to_kelvin(sea_ice_properties.temperature_units, Tₛ) end + # Evaluate state-dependent radiation properties at this grid point. + # The albedo may be a struct (e.g., CCSM3SeaIceAlbedo) that reads model fields; + # we evaluate it here so the iteration uses a scalar. + time = Time(clock.time) + σ = interface_properties.radiation.σ + α = stateindex(interface_properties.radiation.α, i, j, kᴺ, grid, time, CCC) + ϵ = stateindex(interface_properties.radiation.ϵ, i, j, kᴺ, grid, time, CCC) + local_radiation = (; σ, α, ϵ) + local_interface_properties = InterfaceProperties(local_radiation, + interface_properties.specific_humidity_formulation, + interface_properties.temperature_formulation, + interface_properties.velocity_formulation) + # Build thermodynamic and dynamic states in the atmosphere and interface. - # Notation: - # ⋅ 𝒰 ≡ "dynamic" state vector (thermodynamics + reference height + velocity) ℂᵃᵗ = atmosphere_properties.thermodynamics_parameters - zᵃᵗ = atmosphere_properties.surface_layer_height # elevation of atmos variables relative to interface + zᵃᵗ = atmosphere_properties.surface_layer_height local_atmosphere_state = (z = zᵃᵗ, u = uᵃᵗ, @@ -106,7 +118,7 @@ end h_bℓ = atmosphere_state.h_bℓ) downwelling_radiation = (; ℐꜜˢʷ, ℐꜜˡʷ) - local_interior_state = (u=uˢⁱ, v=vˢⁱ, T=Tᵒᶜ, S=Sᵒᶜ, h=hˢⁱ, hc=hc) + local_interior_state = (u=uˢⁱ, v=vˢⁱ, T=Tᵒᶜ, S=Sᵒᶜ, hi=hˢⁱ, hs=hˢⁿ, hc=hc) # Estimate initial interface state (FP32 compatible) u★ = convert(FT, 1f-4) @@ -132,7 +144,7 @@ end local_atmosphere_state, local_interior_state, downwelling_radiation, - interface_properties, + local_interface_properties, atmosphere_properties, sea_ice_properties) end diff --git a/src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl b/src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl new file mode 100644 index 000000000..ab2fc8442 --- /dev/null +++ b/src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl @@ -0,0 +1,133 @@ +""" + SeaIceAlbedo{FT, HI, HS, TS} + +Sea ice albedo parameterization following the CCSM3 scheme (Briegleb et al. 2004). + +Computes broadband albedo as a function of ice thickness, snow depth, and surface +temperature. The scheme blends between bare ice and snow-covered albedos, with +a temperature-dependent reduction near the melting point to implicitly represent +melt pond formation. + +Algorithm: +1. Base cold albedos: bare ice (0.53) and snow-covered (0.82) +2. Temperature reduction within 1C of melting: Δα_ice = 0.075, Δα_snow = 0.10 +3. Thin-ice transition to ocean albedo below h_amin = 0.5 m +4. Snow cover interpolation: full snow albedo at h_snow > h_smin = 0.02 m + +References: +- Briegleb, B.P., C.M. Bitz, E.C. Hunke, W.H. Lipscomb, and M.M. Schramm (2004): + Scientific description of the sea ice component in CCSM3. NCAR Tech Note. +- Briegleb, B.P. and B. Light (2007): NCAR/TN-472+STR. +""" +struct SeaIceAlbedo{FT, HI, HS, TS} + # Cold base albedos (broadband, approx 0.52 * vis + 0.48 * nir) + ice_albedo :: FT # 0.52*0.73 + 0.48*0.33 = 0.538 ≈ 0.54 + snow_albedo :: FT # 0.52*0.96 + 0.48*0.68 = 0.825 ≈ 0.83 + # Melt reduction + ice_melt_reduction :: FT # 0.075 + snow_melt_reduction :: FT # 0.10 + melting_temperature :: FT # 0 C + temperature_range :: FT # 1 C + # Thickness scales + ocean_albedo :: FT # 0.06 + minimum_ice_thickness :: FT # 0.5 m + minimum_snow_depth :: FT # 0.02 m + # References to model fields + ice_thickness :: HI + snow_thickness :: HS + surface_temperature :: TS +end + +Adapt.adapt_structure(to, α::SeaIceAlbedo) = + SeaIceAlbedo(α.ice_albedo, + α.snow_albedo, + α.ice_melt_reduction, + α.snow_melt_reduction, + α.melting_temperature, + α.temperature_range, + α.ocean_albedo, + α.minimum_ice_thickness, + α.minimum_snow_depth, + Adapt.adapt(to, α.ice_thickness), + Adapt.adapt(to, α.snow_thickness), + Adapt.adapt(to, α.surface_temperature)) + +""" + SeaIceAlbedo(ice_thickness, snow_thickness, surface_temperature; + ice_albedo = 0.54, + snow_albedo = 0.83, + ice_melt_reduction = 0.075, + snow_melt_reduction = 0.10, + melting_temperature = 0.0, + temperature_range = 1.0, + ocean_albedo = 0.06, + minimum_ice_thickness = 0.5, + minimum_snow_depth = 0.02) + +Construct a CCSM3 sea ice albedo parameterization. Requires references to the sea ice +model's ice thickness, snow thickness, and surface temperature fields. + +Broadband albedos are approximate averages of the visible and near-IR bands +weighted by solar spectrum (52% visible, 48% near-IR): +- ice_albedo ≈ 0.52 x 0.73 + 0.48 x 0.33 ≈ 0.54 +- snow_albedo ≈ 0.52 x 0.96 + 0.48 x 0.68 ≈ 0.83 +""" +function SeaIceAlbedo(ice_thickness, snow_thickness, surface_temperature; + FT = Float64, + ice_albedo = 0.54, + snow_albedo = 0.83, + ice_melt_reduction = 0.075, + snow_melt_reduction = 0.10, + melting_temperature = 0.0, + temperature_range = 1.0, + ocean_albedo = 0.06, + minimum_ice_thickness = 0.5, + minimum_snow_depth = 0.02) + + return SeaIceAlbedo(convert(FT, ice_albedo), + convert(FT, snow_albedo), + convert(FT, ice_melt_reduction), + convert(FT, snow_melt_reduction), + convert(FT, melting_temperature), + convert(FT, temperature_range), + convert(FT, ocean_albedo), + convert(FT, minimum_ice_thickness), + convert(FT, minimum_snow_depth), + ice_thickness, + snow_thickness, + surface_temperature) +end + +Base.summary(::SeaIceAlbedo{FT}) where FT = "SeaIceAlbedo{$FT}" +Base.show(io::IO, α::SeaIceAlbedo{FT}) where FT = + print(io, "SeaIceAlbedo{$FT}(ice=", α.ice_albedo, + ", snow=", α.snow_albedo, ")") + +@inline function stateindex(α::SeaIceAlbedo, i, j, k, grid, time, loc, args...) + @inbounds hi = α.ice_thickness[i, j, 1] + @inbounds Ts = α.surface_temperature[i, j, 1] + + # Snow thickness: may be nothing (no snow model) + hs = get_snow_thickness(α.snow_thickness, i, j) + + # Temperature-dependent reduction (implicit melt ponds) + Tm = α.melting_temperature + ΔT = α.temperature_range + fT = clamp((Ts - Tm + ΔT) / ΔT, zero(Ts), one(Ts)) + + αi = α.ice_albedo - α.ice_melt_reduction * fT + αs = α.snow_albedo - α.snow_melt_reduction * fT + + # Thin ice → transition to ocean albedo + αo = α.ocean_albedo + fh = clamp(hi / α.minimum_ice_thickness, zero(hi), one(hi)) + αi = αo + (αi - αo) * fh + + # Snow cover blending + fs = clamp(hs / α.minimum_snow_depth, zero(hs), one(hs)) + return fs * αs + (1 - fs) * αi +end + +# Helper to handle nothing snow thickness (no snow model) +@inline get_snow_thickness(hs::Nothing, i, j, grid) = zero(grid) +@inline get_snow_thickness(hs, i, j, grid) = @inbounds hs[i, j, 1] From ac1b6a20fd635328336cbca8910f9f74374549a7 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 14:31:17 +0200 Subject: [PATCH 04/54] Add Adapt for InterfaceProperties, fix get_snow_thickness call - Add Adapt.adapt_structure for InterfaceProperties so that SeaIceAlbedo Fields are properly adapted for GPU kernels - Fix get_snow_thickness call to pass grid argument Co-Authored-By: Claude Opus 4.6 (1M context) --- .../InterfaceComputations/interface_states.jl | 6 ++++++ .../InterfaceComputations/sea_ice_albedo.jl | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/EarthSystemModels/InterfaceComputations/interface_states.jl b/src/EarthSystemModels/InterfaceComputations/interface_states.jl index 4f1ae6c2d..22b8cb1d2 100644 --- a/src/EarthSystemModels/InterfaceComputations/interface_states.jl +++ b/src/EarthSystemModels/InterfaceComputations/interface_states.jl @@ -16,6 +16,12 @@ struct InterfaceProperties{R, Q, T, V} velocity_formulation :: V end +Adapt.adapt_structure(to, p::InterfaceProperties) = + InterfaceProperties(Adapt.adapt(to, p.radiation), + Adapt.adapt(to, p.specific_humidity_formulation), + Adapt.adapt(to, p.temperature_formulation), + Adapt.adapt(to, p.velocity_formulation)) + ##### ##### Interface specific humidity formulations ##### diff --git a/src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl b/src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl index ab2fc8442..9ffc88417 100644 --- a/src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl +++ b/src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl @@ -108,7 +108,7 @@ Base.show(io::IO, α::SeaIceAlbedo{FT}) where FT = @inbounds Ts = α.surface_temperature[i, j, 1] # Snow thickness: may be nothing (no snow model) - hs = get_snow_thickness(α.snow_thickness, i, j) + hs = get_snow_thickness(α.snow_thickness, i, j, grid) # Temperature-dependent reduction (implicit melt ponds) Tm = α.melting_temperature From 0c355e353816d9c9565ba191f0b47568d41b657d Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 12:50:37 +0200 Subject: [PATCH 05/54] Integrate ClimaSeaIce snow model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snow support for OMIP simulations: Sea ice model changes: - sea_ice_simulation: add with_snow, snow_conductivity, snowfall kwargs - Update imports for ClimaSeaIce ss/snow-model (SlabThermodynamics, sea_ice_slab_thermodynamics, snow_slab_thermodynamics) - default_ai_temperature dispatches on IceSnowConductiveFlux when snow present - Add snowfall to net_fluxes (top fluxes NamedTuple) - Sea ice exchanger state: rename h → hi, add hs (snow thickness) Surface temperature solve: - New flux_balance_temperature dispatch for IceSnowConductiveFlux using combined resistance R = hs/ks + hi/ki - Refactored into shared conductive_flux_balance_temperature (DRY) - ConductiveFlux dispatch updated: Ψi.h → Ψi.hi Snowfall routing: - Atmosphere exchanger: add Jˢⁿ field (interpolated snow flux) - Interpolation kernel: interpolate snow component separately - Sea ice flux assembly: write Jˢⁿ into top_fluxes.snowfall - ClimaSeaIce reads model.snowfall during thermodynamic step Requires ClimaSeaIce >= 0.4.8 (snow-model branch merged). --- .../interpolate_atmospheric_state.jl | 12 ++- .../prescribed_atmosphere_regridder.jl | 3 +- .../InterfaceComputations/interface_states.jl | 69 ++++++++------- src/SeaIces/SeaIces.jl | 17 ++-- src/SeaIces/assemble_net_sea_ice_fluxes.jl | 19 +++-- src/SeaIces/sea_ice_simulation.jl | 83 ++++++++++++++----- 6 files changed, 140 insertions(+), 63 deletions(-) diff --git a/src/Atmospheres/interpolate_atmospheric_state.jl b/src/Atmospheres/interpolate_atmospheric_state.jl index 34e79b6b4..34cf7b640 100644 --- a/src/Atmospheres/interpolate_atmospheric_state.jl +++ b/src/Atmospheres/interpolate_atmospheric_state.jl @@ -31,6 +31,7 @@ function interpolate_state!(exchanger, grid, atmosphere::PrescribedAtmosphere, c ℐꜜˡʷ = atmosphere.downwelling_radiation.longwave downwelling_radiation = (shortwave=ℐꜜˢʷ.data, longwave=ℐꜜˡʷ.data) freshwater_flux = map(ϕ -> ϕ.data, atmosphere.freshwater_flux) + snowfall_flux = atmosphere.freshwater_flux.snow.data atmosphere_pressure = atmosphere.pressure.data # Extract info for time-interpolation @@ -51,7 +52,8 @@ function interpolate_state!(exchanger, grid, atmosphere::PrescribedAtmosphere, c q = atmosphere_fields.q.data, ℐꜜˢʷ = atmosphere_fields.ℐꜜˢʷ.data, ℐꜜˡʷ = atmosphere_fields.ℐꜜˡʷ.data, - Jᶜ = atmosphere_fields.Jᶜ.data) + Jᶜ = atmosphere_fields.Jᶜ.data, + Jˢⁿ = atmosphere_fields.Jˢⁿ.data) kernel_parameters = interface_kernel_parameters(grid) @@ -74,6 +76,7 @@ function interpolate_state!(exchanger, grid, atmosphere::PrescribedAtmosphere, c atmosphere_pressure, downwelling_radiation, freshwater_flux, + snowfall_flux, atmosphere_backend, atmosphere_time_indexing) @@ -128,6 +131,7 @@ end atmos_pressure, downwelling_radiation, prescribed_freshwater_flux, + snowfall_flux, atmos_backend, atmos_time_indexing) @@ -151,9 +155,12 @@ end ℐꜜˢʷ = interp_atmos_time_series(downwelling_radiation.shortwave, atmos_args...) ℐꜜˡʷ = interp_atmos_time_series(downwelling_radiation.longwave, atmos_args...) - # Usually precipitation + # Total precipitation (rain + snow) Mh = interp_atmos_time_series(prescribed_freshwater_flux, atmos_args...) + # Snowfall only (for sea ice snow accumulation) + Ms = interp_atmos_time_series(snowfall_flux, atmos_args...) + # Convert atmosphere velocities (usually defined on a latitude-longitude grid) to # the frame of reference of the native grid kᴺ = size(exchange_grid, 3) # index of the top ocean cell @@ -168,6 +175,7 @@ end surface_atmos_state.ℐꜜˢʷ[i, j, 1] = ℐꜜˢʷ surface_atmos_state.ℐꜜˡʷ[i, j, 1] = ℐꜜˡʷ surface_atmos_state.Jᶜ[i, j, 1] = Mh + surface_atmos_state.Jˢⁿ[i, j, 1] = Ms end end diff --git a/src/Atmospheres/prescribed_atmosphere_regridder.jl b/src/Atmospheres/prescribed_atmosphere_regridder.jl index da93d1766..2c1a8aec9 100644 --- a/src/Atmospheres/prescribed_atmosphere_regridder.jl +++ b/src/Atmospheres/prescribed_atmosphere_regridder.jl @@ -9,7 +9,8 @@ function ComponentExchanger(atmosphere::PrescribedAtmosphere, grid) q = Field{Center, Center, Nothing}(grid), ℐꜜˢʷ = Field{Center, Center, Nothing}(grid), ℐꜜˡʷ = Field{Center, Center, Nothing}(grid), - Jᶜ = Field{Center, Center, Nothing}(grid)) + Jᶜ = Field{Center, Center, Nothing}(grid), + Jˢⁿ = Field{Center, Center, Nothing}(grid)) return ComponentExchanger(state, regridder) end diff --git a/src/EarthSystemModels/InterfaceComputations/interface_states.jl b/src/EarthSystemModels/InterfaceComputations/interface_states.jl index 22b8cb1d2..8072c2361 100644 --- a/src/EarthSystemModels/InterfaceComputations/interface_states.jl +++ b/src/EarthSystemModels/InterfaceComputations/interface_states.jl @@ -270,51 +270,64 @@ end return (Ψᵢ.T * F.κ - (Jᵀ + Ωc * Tᵃᵗ) * F.δ) / (F.κ - Ωc * F.δ) end -# 𝒬ᵛ + ℐꜛˡʷ + Qd + Ωc * (Tᵃᵗ - Tˢ) + k / h * (Tˢ - Tˢⁱ) = 0 -# where Ωc (the sensible heat transfer coefficient) is given by Ωc = 𝒬ᵀ / (Tᵃᵗ - Tˢ) -# ⟹ Tₛ = (Tˢⁱ * k - (𝒬ᵛ + ℐꜛˡʷ + Qd + Ωc * Tᵃᵗ) * h / (k - Ωc * h) -@inline function flux_balance_temperature(st::SkinTemperature{<:ClimaSeaIce.ConductiveFlux}, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) - F = st.internal_flux - k = F.conductivity - h = Ψᵢ.h - hc = Ψᵢ.hc # Critical thickness for ice consolidation - - # Bottom temperature at the melting temperature - Tˢⁱ = ClimaSeaIce.SeaIceThermodynamics.melting_temperature(ℙᵢ.liquidus, Ψᵢ.S) - Tˢⁱ = convert_to_kelvin(ℙᵢ.temperature_units, Tˢⁱ) +# Solve the surface flux balance equation: +# Qa + Ωc (Tᵃᵗ - Tₛ) + (Tₛ - Tᵦ) / R = 0 +# where R is the total thermal resistance (h/k for bare ice, hₛ/kₛ + hᵢ/kᵢ with snow), +# Ωc is the linearized sensible heat coefficient, and Qa is the non-sensible atmospheric flux. +# Solution: Tₛ = (Tᵦ - (Qa + Ωc Tᵃᵗ) R) / (1 - Ωc R) +@inline function conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) + hc = Ψᵢ.hc + + # Bottom temperature at the melting point + Tᵦ = ClimaSeaIce.SeaIceThermodynamics.melting_temperature(ℙᵢ.liquidus, Ψᵢ.S) + Tᵦ = convert_to_kelvin(ℙᵢ.temperature_units, Tᵦ) Tₛ⁻ = Ψₛ.T - # Calculating the atmospheric temperature - # We use to compute the sensible heat flux + # Linearized sensible heat transfer coefficient: Ωc = 𝒬ᵀ / (Tᵃᵗ - Tₛ) Tᵃᵗ = surface_atmosphere_temperature(Ψₐ, ℙₐ) ΔT = Tᵃᵗ - Tₛ⁻ - Ωc = ifelse(ΔT == 0, zero(h), 𝒬ᵀ / ΔT) # Sensible heat transfer coefficient (W/m²K) - Qa = (𝒬ᵛ + ℐꜛˡʷ + Qd) # Net flux excluding sensible heat (positive out of the ocean) - - # Computing the flux balance temperature - T★ = (Tˢⁱ * k - (Qa + Ωc * Tᵃᵗ) * h) / (k - Ωc * h) + Ωc = ifelse(ΔT == 0, zero(R), 𝒬ᵀ / ΔT) + Qa = 𝒬ᵛ + ℐꜛˡʷ + Qd - # Fix a NaN + # Flux balance solution + T★ = (Tᵦ - (Qa + Ωc * Tᵃᵗ) * R) / (1 - Ωc * R) T★ = ifelse(isnan(T★), Tₛ⁻, T★) - # To prevent instabilities in the fixed point iteration - # solver we cap the maximum temperature difference with `max_ΔT` + # Cap the temperature step for iteration stability ΔT★ = T★ - Tₛ⁻ max_ΔT = convert(typeof(T★), st.max_ΔT) - abs_ΔT = min(max_ΔT, abs(ΔT★)) - Tₛ⁺ = Tₛ⁻ + abs_ΔT * sign(ΔT★) + Tₛ⁺ = Tₛ⁻ + clamp(ΔT★, -max_ΔT, max_ΔT) - # Under heating fluxes, cap surface temperature by melting temperature + # Cap at melting temperature Tₘ = ℙᵢ.liquidus.freshwater_melting_temperature Tₘ = convert_to_kelvin(ℙᵢ.temperature_units, Tₘ) Tₛ⁺ = min(Tₛ⁺, Tₘ) - # If the ice is not consolidated, use the bottom temperature - Tₛ⁺ = ifelse(h ≥ hc, Tₛ⁺, Tˢⁱ) - + # If ice is not consolidated, use the bottom temperature + Tₛ⁺ = ifelse(hᵢ ≥ hc, Tₛ⁺, Tᵦ) + return Tₛ⁺ end +# Bare ice: R = hᵢ / kᵢ +@inline function flux_balance_temperature(st::SkinTemperature{<:ClimaSeaIce.ConductiveFlux}, + Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) + k = st.internal_flux.conductivity + hᵢ = Ψᵢ.hi + R = hᵢ / k + return conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) +end + +# Snow + ice: R = hₛ / kₛ + hᵢ / kᵢ +@inline function flux_balance_temperature(st::SkinTemperature{<:ClimaSeaIce.SeaIceThermodynamics.IceSnowConductiveFlux}, + Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) + F = st.internal_flux + hᵢ = Ψᵢ.hi + hₛ = Ψᵢ.hs + R = hₛ / F.snow_conductivity + hᵢ / F.ice_conductivity + return conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) +end + @inline function compute_interface_temperature(st::SkinTemperature, interface_state, atmosphere_state, diff --git a/src/SeaIces/SeaIces.jl b/src/SeaIces/SeaIces.jl index 57909bdc8..2f16a4f46 100644 --- a/src/SeaIces/SeaIces.jl +++ b/src/SeaIces/SeaIces.jl @@ -44,24 +44,31 @@ interpolate_state!(exchanger, grid, ::FreezingLimitedOceanTemperature, coupled_m # ComponentExchangers ComponentExchanger(sea_ice::FreezingLimitedOceanTemperature, grid) = nothing -function ComponentExchanger(sea_ice::Simulation{<:SeaIceModel}, grid) +function ComponentExchanger(sea_ice::Simulation{<:SeaIceModel}, grid) sea_ice_grid = sea_ice.model.grid - + if sea_ice_grid == grid u = sea_ice.model.velocities.u v = sea_ice.model.velocities.v - h = sea_ice.model.ice_thickness + hi = sea_ice.model.ice_thickness hc = sea_ice.model.ice_consolidation_thickness ℵ = sea_ice.model.ice_concentration + hs = sea_ice.model.snow_thickness else u = Field{Center, Center, Nothing}(grid) v = Field{Center, Center, Nothing}(grid) - h = Field{Center, Center, Nothing}(grid) + hi = Field{Center, Center, Nothing}(grid) hc = Field{Center, Center, Nothing}(grid) ℵ = Field{Center, Center, Nothing}(grid) + hs = Field{Center, Center, Nothing}(grid) + end + + # When there's no snow model, use ZeroField so kernels can read hs[i,j,1] = 0 + if isnothing(hs) + hs = ZeroField(eltype(grid)) end - return ComponentExchanger((; u, v, h, hc, ℵ), nothing) + return ComponentExchanger((; u, v, hi, hc, ℵ, hs), nothing) end end diff --git a/src/SeaIces/assemble_net_sea_ice_fluxes.jl b/src/SeaIces/assemble_net_sea_ice_fluxes.jl index 668d3ddee..daf8f00e3 100644 --- a/src/SeaIces/assemble_net_sea_ice_fluxes.jl +++ b/src/SeaIces/assemble_net_sea_ice_fluxes.jl @@ -1,4 +1,4 @@ -using NumericalEarth.EarthSystemModels.InterfaceComputations: computed_fluxes, +using NumericalEarth.EarthSystemModels.InterfaceComputations: computed_fluxes, get_possibly_zero_flux, interface_kernel_parameters, convert_to_kelvin, @@ -27,13 +27,14 @@ function update_net_fluxes!(coupled_model, sea_ice::Simulation{<:SeaIceModel}) ℐꜜˡʷ = atmosphere_fields.ℐꜜˡʷ.data) freshwater_flux = atmosphere_fields.Jᶜ.data + snowfall_flux = atmosphere_fields.Jˢⁿ.data atmos_sea_ice_properties = coupled_model.interfaces.atmosphere_sea_ice_interface.properties sea_ice_properties = coupled_model.interfaces.sea_ice_properties sea_ice_surface_temperature = coupled_model.interfaces.atmosphere_sea_ice_interface.temperature ice_concentration = sea_ice_concentration(sea_ice) - + launch!(arch, grid, :xy, _assemble_net_sea_ice_fluxes!, top_fluxes, @@ -43,6 +44,7 @@ function update_net_fluxes!(coupled_model, sea_ice::Simulation{<:SeaIceModel}) atmosphere_sea_ice_fluxes, sea_ice_ocean_fluxes, freshwater_flux, + snowfall_flux, ice_concentration, sea_ice_surface_temperature, downwelling_radiation, @@ -58,7 +60,8 @@ end clock, atmosphere_sea_ice_fluxes, sea_ice_ocean_fluxes, - freshwater_flux, # Where do we add this one? + freshwater_flux, + snowfall_flux, ice_concentration, surface_temperature, downwelling_radiation, @@ -80,6 +83,7 @@ end 𝒬ᵛ = get_possibly_zero_flux(atmosphere_sea_ice_fluxes, :latent_heat)[i, j, 1] # latent heat flux 𝒬ᶠʳᶻ = get_possibly_zero_flux(sea_ice_ocean_fluxes, :frazil_heat)[i, j, 1] # frazil heat flux 𝒬ⁱⁿᵗ = get_possibly_zero_flux(sea_ice_ocean_fluxes, :interface_heat)[i, j, 1] # interfacial heat flux + Jˢⁿ = snowfall_flux[i, j, 1] end ρτˣ = get_possibly_zero_flux(atmosphere_sea_ice_fluxes, :x_momentum) # zonal momentum flux @@ -99,8 +103,9 @@ end # Mask fluxes over land for convenience inactive = inactive_node(i, j, kᴺ, grid, Center(), Center(), Center()) - @inbounds top_fluxes.heat[i, j, 1] = ifelse(inactive, zero(grid), ΣQt) - @inbounds top_fluxes.u[i, j, 1] = ifelse(inactive, zero(grid), ℑxᶠᵃᵃ(i, j, 1, grid, ρτˣ)) - @inbounds top_fluxes.v[i, j, 1] = ifelse(inactive, zero(grid), ℑyᵃᶠᵃ(i, j, 1, grid, ρτʸ)) - @inbounds bottom_heat_flux[i, j, 1] = ifelse(inactive, zero(grid), ΣQb) + @inbounds top_fluxes.heat[i, j, 1] = ifelse(inactive, zero(grid), ΣQt) + @inbounds top_fluxes.snowfall[i, j, 1] = ifelse(inactive, zero(grid), Jˢⁿ) + @inbounds top_fluxes.u[i, j, 1] = ifelse(inactive, zero(grid), ℑxᶠᵃᵃ(i, j, 1, grid, ρτˣ)) + @inbounds top_fluxes.v[i, j, 1] = ifelse(inactive, zero(grid), ℑyᵃᶠᵃ(i, j, 1, grid, ρτʸ)) + @inbounds bottom_heat_flux[i, j, 1] = ifelse(inactive, zero(grid), ΣQb) end diff --git a/src/SeaIces/sea_ice_simulation.jl b/src/SeaIces/sea_ice_simulation.jl index 0afda344d..97641878b 100644 --- a/src/SeaIces/sea_ice_simulation.jl +++ b/src/SeaIces/sea_ice_simulation.jl @@ -1,18 +1,24 @@ using ClimaSeaIce -using ClimaSeaIce: SeaIceModel, SlabSeaIceThermodynamics, PhaseTransitions, ConductiveFlux +using ClimaSeaIce: SeaIceModel, PhaseTransitions, ConductiveFlux, + sea_ice_slab_thermodynamics, snow_slab_thermodynamics using ClimaSeaIce.SeaIceThermodynamics: IceWaterThermalEquilibrium using ClimaSeaIce.SeaIceDynamics: SplitExplicitSolver, SemiImplicitStress, SeaIceMomentumEquation, StressBalanceFreeDrift using ClimaSeaIce.Rheologies: IceStrength, ElastoViscoPlasticRheology +using Oceananigans.TimeSteppers: SplitRungeKuttaTimeStepper + using NumericalEarth.EarthSystemModels: ocean_surface_salinity, ocean_surface_velocities -using NumericalEarth.Oceans: Default +using NumericalEarth.Oceans: Default, u_immersed_bottom_drag, v_immersed_bottom_drag, reference_density default_rotation_rate = Oceananigans.defaults.planet_rotation_rate +ocean_reference_density(ocean::Simulation, FT) = convert(FT, reference_density(ocean)) +ocean_reference_density(::Nothing, FT) = convert(FT, 1026.0) + function sea_ice_simulation(grid, ocean=nothing; Δt = 5minutes, ice_salinity = 4, # psu - advection = nothing, # for the moment + advection = nothing, tracers = (), ice_heat_capacity = 2100, # J kg⁻¹ K⁻¹ ice_consolidation_thickness = 0.05, # m @@ -20,9 +26,14 @@ function sea_ice_simulation(grid, ocean=nothing; dynamics = sea_ice_dynamics(grid, ocean), bottom_heat_boundary_condition = nothing, top_heat_boundary_condition = nothing, + timestepper = :SplitRungeKutta3, phase_transitions = PhaseTransitions(; ice_heat_capacity, ice_density), - conductivity = 2, # kg m s⁻³ K⁻¹ - internal_heat_flux = ConductiveFlux(; conductivity)) + conductivity = 2, # W m⁻¹ K⁻¹ + internal_heat_flux = ConductiveFlux(; conductivity), + with_snow = false, + snow_conductivity = 0.31, # W m⁻¹ K⁻¹ + snow_density = 330, # kg m⁻³ + snowfall = 0) # Build consistent boundary conditions for the ice model: # - bottom -> flux boundary condition @@ -37,17 +48,23 @@ function sea_ice_simulation(grid, ocean=nothing; if isnothing(ocean) surface_ocean_salinity = 0 else - kᴺ = size(grid, 3) surface_ocean_salinity = ocean_surface_salinity(ocean) end bottom_heat_boundary_condition = IceWaterThermalEquilibrium(surface_ocean_salinity) end - ice_thermodynamics = SlabSeaIceThermodynamics(grid; - internal_heat_flux, - phase_transitions, - top_heat_boundary_condition, - bottom_heat_boundary_condition) + ice_thermodynamics = sea_ice_slab_thermodynamics(grid; + internal_heat_flux, + phase_transitions, + top_heat_boundary_condition, + bottom_heat_boundary_condition) + + # Snow thermodynamics (ClimaSeaIce wires the IceSnowConductiveFlux internally) + snow_thermodynamics = if with_snow + snow_slab_thermodynamics(grid; conductivity = snow_conductivity, density = snow_density) + else + nothing + end bottom_heat_flux = Field{Center, Center, Nothing}(grid) top_heat_flux = Field{Center, Center, Nothing}(grid) @@ -59,29 +76,47 @@ function sea_ice_simulation(grid, ocean=nothing; tracers, ice_consolidation_thickness, ice_thermodynamics, + snow_thermodynamics, + snowfall, dynamics, + timestepper, bottom_heat_flux, top_heat_flux) verbose = false - - # Build the simulation sea_ice = Simulation(sea_ice_model; Δt, verbose) return sea_ice end +default_coriolis(ocean::Simulation) = ocean.model.coriolis +default_coriolis(ocean::Nothing) = HydrostaticSphericalCoriolis(; rotation_rate=default_rotation_rate) + +default_solver(grid, ocean) = SplitExplicitSolver(grid; substeps=120) + +# We assume RK3 has a larger timestep +function default_solver(grid, ocean::Simulation) + substeps = if ocean.model.timestepper isa SplitRungeKuttaTimeStepper + 240 + else + 120 + end + return SplitExplicitSolver(grid; substeps) +end + function sea_ice_dynamics(grid, ocean=nothing; - sea_ice_ocean_drag_coefficient = 5.5e-3, + sea_ice_ocean_drag_coefficient = 3.24e-3, rheology = ElastoViscoPlasticRheology(), - coriolis = HydrostaticSphericalCoriolis(; rotation_rate=default_rotation_rate), + coriolis = default_coriolis(ocean), free_drift = nothing, - solver = SplitExplicitSolver(grid; substeps=120)) + solver = default_solver(grid, ocean)) SSU, SSV = ocean_surface_velocities(ocean) - sea_ice_ocean_drag_coefficient = convert(eltype(grid), sea_ice_ocean_drag_coefficient) + FT = eltype(grid) + sea_ice_ocean_drag_coefficient = convert(FT, sea_ice_ocean_drag_coefficient) + ρₑ = ocean_reference_density(ocean, FT) - τo = SemiImplicitStress(uₑ=SSU, vₑ=SSV, Cᴰ=sea_ice_ocean_drag_coefficient) + τo = SemiImplicitStress(uₑ=SSU, vₑ=SSV, Cᴰ=sea_ice_ocean_drag_coefficient, ρₑ=ρₑ) τua = Field{Face, Center, Nothing}(grid) τva = Field{Center, Face, Nothing}(grid) @@ -119,14 +154,22 @@ function net_fluxes(sea_ice::Simulation{<:SeaIceModel}) (; u, v) end - net_top_sea_ice_fluxes = merge((; heat=sea_ice.model.external_heat_fluxes.top), net_momentum_fluxes) + snowfall = sea_ice.model.snowfall + net_top_sea_ice_fluxes = merge((; heat=sea_ice.model.external_heat_fluxes.top, snowfall), net_momentum_fluxes) net_bottom_sea_ice_fluxes = (; heat=sea_ice.model.external_heat_fluxes.bottom) return (; bottom = net_bottom_sea_ice_fluxes, top = net_top_sea_ice_fluxes) end function default_ai_temperature(sea_ice::Simulation{<:SeaIceModel}) - conductive_flux = sea_ice.model.ice_thermodynamics.internal_heat_flux.parameters.flux + snow_thermo = sea_ice.model.snow_thermodynamics + if isnothing(snow_thermo) + # No snow: use ice-only conductive flux + conductive_flux = sea_ice.model.ice_thermodynamics.internal_heat_flux.parameters.flux + else + # With snow: use combined ice+snow conductive flux from the snow layer + conductive_flux = snow_thermo.internal_heat_flux.parameters.flux + end return SkinTemperature(conductive_flux) end From 8e3cb6b01006940cbedef1427636c7256e7f7be9 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 13:58:21 +0200 Subject: [PATCH 06/54] Fix PhaseTransitions kwargs for updated ClimaSeaIce API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ice_heat_capacity → heat_capacity, ice_density → density Co-Authored-By: Claude Opus 4.6 (1M context) --- src/SeaIces/sea_ice_simulation.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SeaIces/sea_ice_simulation.jl b/src/SeaIces/sea_ice_simulation.jl index 97641878b..10dfabd11 100644 --- a/src/SeaIces/sea_ice_simulation.jl +++ b/src/SeaIces/sea_ice_simulation.jl @@ -27,7 +27,7 @@ function sea_ice_simulation(grid, ocean=nothing; bottom_heat_boundary_condition = nothing, top_heat_boundary_condition = nothing, timestepper = :SplitRungeKutta3, - phase_transitions = PhaseTransitions(; ice_heat_capacity, ice_density), + phase_transitions = PhaseTransitions(; heat_capacity=ice_heat_capacity, density=ice_density), conductivity = 2, # W m⁻¹ K⁻¹ internal_heat_flux = ConductiveFlux(; conductivity), with_snow = false, @@ -140,8 +140,8 @@ end sea_ice_thickness(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_thickness sea_ice_concentration(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_concentration -heat_capacity(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_thermodynamics.phase_transitions.ice_heat_capacity -reference_density(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_thermodynamics.phase_transitions.ice_density +heat_capacity(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_thermodynamics.phase_transitions.heat_capacity +reference_density(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_thermodynamics.phase_transitions.density function net_fluxes(sea_ice::Simulation{<:SeaIceModel}) net_momentum_fluxes = if isnothing(sea_ice.model.dynamics) From 57ba5b19f41cb7ef3116f85f110e52620117f96e Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 14:52:44 +0200 Subject: [PATCH 07/54] fix issue 2 --- test/{tet_diagnostics_2.jl => test_diagnostics_2.jl} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/{tet_diagnostics_2.jl => test_diagnostics_2.jl} (100%) diff --git a/test/tet_diagnostics_2.jl b/test/test_diagnostics_2.jl similarity index 100% rename from test/tet_diagnostics_2.jl rename to test/test_diagnostics_2.jl From 8806d2b38df1660eb8e9308728b517d07fac9342 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 14:59:04 +0200 Subject: [PATCH 08/54] Remove unrelated changes from snow-model-integration PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip out changes that belong to other PRs or branches: - FluxAndRestoring struct and plumbing (separate PR) - Bottom drag rework (drag_bulk_velocity, Uᴮ) - Barotropic potential toggle, clock kwarg, materialize_buoyancy_gradients - TKE advection change, meridional heat transport deletion - Bathymetry minimum depth, inpainting NaN fill, restoring extract_field_time_series - Sea ice albedo (CCSM3 scheme) and Adapt for InterfaceProperties (PR #163) - ocean_surface_salinity/temperature view change Keep only snow model integration: snow thermodynamics, snowfall routing, IceSnowConductiveFlux flux balance, hs in exchanger and interior state. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/meridional_heat_transport_ecco.jl | 119 ++++++++++++++++ src/Bathymetry/regrid_bathymetry.jl | 3 +- src/DataWrangling/inpainting.jl | 13 +- src/DataWrangling/restoring.jl | 2 - src/Diagnostics/Diagnostics.jl | 4 +- src/Diagnostics/interface_fluxes.jl | 9 +- src/Diagnostics/meridional_heat_transport.jl | 95 +++++++++++++ src/Diagnostics/mixed_layer_depth.jl | 1 + .../InterfaceComputations.jl | 10 -- .../atmosphere_sea_ice_fluxes.jl | 15 +- .../InterfaceComputations/interface_states.jl | 6 - .../InterfaceComputations/sea_ice_albedo.jl | 133 ------------------ src/EarthSystemModels/earth_system_model.jl | 3 +- .../time_step_earth_system_model.jl | 9 +- src/NumericalEarth.jl | 3 +- src/Oceans/Oceans.jl | 19 +-- src/Oceans/flux_and_restoring.jl | 46 ------ src/Oceans/ocean_simulation.jl | 77 ++++------ 18 files changed, 272 insertions(+), 295 deletions(-) create mode 100755 examples/meridional_heat_transport_ecco.jl create mode 100644 src/Diagnostics/meridional_heat_transport.jl delete mode 100644 src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl delete mode 100644 src/Oceans/flux_and_restoring.jl diff --git a/examples/meridional_heat_transport_ecco.jl b/examples/meridional_heat_transport_ecco.jl new file mode 100755 index 000000000..58b3e7849 --- /dev/null +++ b/examples/meridional_heat_transport_ecco.jl @@ -0,0 +1,119 @@ +using NumericalEarth +using Oceananigans +using Oceananigans.Units +using Dates +using Statistics +using Printf + +using CUDA; CUDA.device!(3) + +arch = GPU() +Nx = 360 +Ny = 180 +Nz = 50 + +depth = 5000meters +z = ExponentialDiscretization(Nz, -depth, 0; scale = depth/4) + +underlying_grid = TripolarGrid(arch; size = (Nx, Ny, Nz), halo = (5, 5, 4), z) +underlying_grid = LatitudeLongitudeGrid(arch; size = (Nx, Ny, Nz), halo = (5, 5, 4), z, longitude = (0, 360), latitude = (-80, 80)) +bottom_height = regrid_bathymetry(underlying_grid; + minimum_depth = 10, + interpolation_passes = 10, + major_basins = 2) +grid = ImmersedBoundaryGrid(underlying_grid, GridFittedBottom(bottom_height); + active_cells_map=true) + +free_surface = SplitExplicitFreeSurface(grid; substeps=70) +momentum_advection = WENOVectorInvariant(order=5) +tracer_advection = WENO(order=5) +vertical_mixing = NumericalEarth.Oceans.default_ocean_closure() +ocean = ocean_simulation(grid; momentum_advection, tracer_advection, free_surface, + closure=(vertical_mixing,)) +sea_ice = sea_ice_simulation(grid, ocean; advection=tracer_advection) + +date = DateTime(1993, 1, 1) +dataset = ECCO4Monthly() +ecco_temperature = Metadatum(:temperature; date, dataset) +ecco_salinity = Metadatum(:salinity; date, dataset) +ecco_sea_ice_thickness = Metadatum(:sea_ice_thickness; date, dataset) +ecco_sea_ice_concentration = Metadatum(:sea_ice_concentration; date, dataset) + +set!(ocean.model, T=ecco_temperature, S=ecco_salinity) +set!(sea_ice.model, h=ecco_sea_ice_thickness, ℵ=ecco_sea_ice_concentration) + +radiation = Radiation(arch) +atmosphere = JRA55PrescribedAtmosphere(arch; backend=JRA55NetCDFBackend(80), + include_rivers_and_icebergs = false) +esm = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + +simulation = Simulation(esm; Δt=20minutes, stop_time=5*365days) + +wall_time = Ref(time_ns()) + +function progress(sim) + ocean = sim.model.ocean + u, v, w = ocean.model.velocities + T = ocean.model.tracers.T + e = ocean.model.tracers.e + Tmin, Tmax, Tavg = minimum(T), maximum(T), mean(view(T, :, :, ocean.model.grid.Nz)) + emax = maximum(e) + umax = (maximum(abs, u), maximum(abs, v), maximum(abs, w)) + + step_time = 1e-9 * (time_ns() - wall_time[]) + + msg1 = @sprintf("time: %s, iter: %d", prettytime(sim), iteration(sim)) + msg2 = @sprintf(", max|uo|: (%.1e, %.1e, %.1e) m s⁻¹", umax...) + msg3 = @sprintf(", max(e): %.2f m² s⁻²", emax) + msg4 = @sprintf(", wall time: %s \n", prettytime(step_time)) + + @info msg1 * msg2 * msg3 * msg4 + + wall_time[] = time_ns() + + return nothing +end + +# And add it as a callback to the simulation. +add_callback!(simulation, progress, IterationInterval(200)) + +mht = Field(meridional_heat_transport(esm)) + +ocean.output_writers[:mth] = JLD2Writer(ocean.model, (; mht); + schedule = TimeInterval(3hours), + filename = "ocean_one_degree_mht", + overwrite_existing = true) + +run!(simulation) + +## + +using Oceananigans + +mht = FieldTimeSeries("ocean_one_degree_mht.jld2", "mht"; backend = OnDisk()) + +times = mht.times +Nt = length(times) + +grid = mht.grid +Ny = size(mht.grid, 2) + +mht_mean = deepcopy(mht[1][1, :, 1]) + +for iter in 1:Nt + @info "iteration $iter out of $Nt" + mht_mean += mht[iter][1, :, 1] +end + +@. mht_mean = mht_mean / Nt + +using CairoMakie + +fig = Figure() +ax = Axis(fig[1, 1], xlabel="latitude (deg)", ylabel="MHT (PW)") + +φ = φnodes(grid, Face()) + +lines!(ax, φ, mht_mean[1:Ny+1] / 1e15, linewidth=4) + +save("mht.png", fig) diff --git a/src/Bathymetry/regrid_bathymetry.jl b/src/Bathymetry/regrid_bathymetry.jl index 564d531f3..430cad426 100644 --- a/src/Bathymetry/regrid_bathymetry.jl +++ b/src/Bathymetry/regrid_bathymetry.jl @@ -341,8 +341,7 @@ end # Fix active cells to be at least `-minimum_depth`. active = z < 0 # it's a wet cell - above_minimum_depth = z > -minimum_depth - z = ifelse(active, ifelse(above_minimum_depth, zero(z), z), z) + z = ifelse(active, min(z, -minimum_depth), z) @inbounds target_z[i, j, 1] = z end diff --git a/src/DataWrangling/inpainting.jl b/src/DataWrangling/inpainting.jl index a081fa1d7..dde0f1f91 100644 --- a/src/DataWrangling/inpainting.jl +++ b/src/DataWrangling/inpainting.jl @@ -51,12 +51,7 @@ function propagate_horizontally!(inpainting::NearestNeighborInpainting, field, m iter += 1 end - # Fill any remaining NaN values with the mean of valid data. - # Using 0 would be catastrophic for fields like salinity (~34 psu). - valid_sum = sum(x -> ifelse(isnan(x), zero(x), x), field; condition=interior(mask)) - valid_count = sum(x -> !isnan(x), field; condition=interior(mask)) - fill_value = convert(eltype(field), valid_sum / valid_count) - launch!(arch, grid, size(field), _fill_nans!, field, fill_value) + launch!(arch, grid, size(field), _fill_nans!, field) fill_halo_regions!(field) return field @@ -85,7 +80,7 @@ end end FT_NaN = convert(FT, NaN) - @inbounds substituting_field[i, j, k] = ifelse(donors == 0, FT_NaN, value / donors) + @inbounds substituting_field[i, j, k] = ifelse(value == 0, FT_NaN, value / donors) end @kernel function _substitute_values!(field, substituting_field) @@ -102,9 +97,9 @@ end @inbounds field[i, j, k] = ifelse(mask[i, j, k], FT_NaN, field[i, j, k]) end -@kernel function _fill_nans!(field, fill_value) +@kernel function _fill_nans!(field) i, j, k = @index(Global, NTuple) - @inbounds field[i, j, k] = ifelse(isnan(field[i, j, k]), fill_value, field[i, j, k]) + @inbounds field[i, j, k] *= !isnan(field[i, j, k]) end """ diff --git a/src/DataWrangling/restoring.jl b/src/DataWrangling/restoring.jl index d26b7c1f4..918edfae0 100644 --- a/src/DataWrangling/restoring.jl +++ b/src/DataWrangling/restoring.jl @@ -12,7 +12,6 @@ using Dates: Second import NumericalEarth: stateindex import Oceananigans.Forcings: materialize_forcing -import Oceananigans.OutputReaders: extract_field_time_series # Variable names for restorable data struct Temperature end @@ -235,7 +234,6 @@ function Base.show(io::IO, dsr::DatasetRestoring) end materialize_forcing(forcing::DatasetRestoring, field, field_name, model_field_names) = forcing -extract_field_time_series(forcing::DatasetRestoring) = forcing.field_time_series ##### ##### Masks for restoring diff --git a/src/Diagnostics/Diagnostics.jl b/src/Diagnostics/Diagnostics.jl index 4f698a4b1..3c7783ae0 100644 --- a/src/Diagnostics/Diagnostics.jl +++ b/src/Diagnostics/Diagnostics.jl @@ -1,6 +1,7 @@ module Diagnostics export MixedLayerDepthField, MixedLayerDepthOperand +export meridional_heat_transport export frazil_temperature_flux, net_ocean_temperature_flux, sea_ice_ocean_temperature_flux, atmosphere_ocean_temperature_flux, frazil_heat_flux, net_ocean_heat_flux, sea_ice_ocean_heat_flux, atmosphere_ocean_heat_flux, net_ocean_salinity_flux, sea_ice_ocean_salinity_flux, atmosphere_ocean_salinity_flux, @@ -14,13 +15,12 @@ using Oceananigans.BoundaryConditions: FieldBoundaryConditions, fill_halo_region using Oceananigans.Fields: FieldStatus using Oceananigans.Utils: launch! using KernelAbstractions: @index, @kernel -using Oceananigans.BoundaryConditions: DiscreteBoundaryFunction using NumericalEarth.EarthSystemModels: EarthSystemModel -using NumericalEarth.Oceans: FluxAndRestoring import Oceananigans.Fields: compute! include("mixed_layer_depth.jl") +include("meridional_heat_transport.jl") include("interface_fluxes.jl") end # module diff --git a/src/Diagnostics/interface_fluxes.jl b/src/Diagnostics/interface_fluxes.jl index 2d5b80758..4d144d84c 100644 --- a/src/Diagnostics/interface_fluxes.jl +++ b/src/Diagnostics/interface_fluxes.jl @@ -1,8 +1,4 @@ -@inline flux_field(condition) = condition -@inline flux_field(bc::FluxAndRestoring) = bc.flux_field -@inline flux_field(bc::DiscreteBoundaryFunction) = flux_field(bc.func) - ########################### ### Temperature fluxes ########################### @@ -25,11 +21,12 @@ end Return the net temperature flux (K m s⁻¹) at the ocean's surface in a coupled `esm`. """ function net_ocean_temperature_flux(esm::EarthSystemModel) - Jᵀ = flux_field(esm.ocean.model.tracers.T.boundary_conditions.top.condition) + Jᵀ = esm.ocean.model.tracers.T.boundary_conditions.top.condition net_ocean_temperature_flux = Jᵀ + frazil_temperature_flux(esm) return Field(net_ocean_temperature_flux) end + """ sea_ice_ocean_temperature_flux(esm::EarthSystemModel) @@ -119,7 +116,7 @@ end Return the net salinity flux (g/kg m s⁻¹) at the ocean's surface in a coupled `esm`. """ function net_ocean_salinity_flux(esm::EarthSystemModel) - Jˢ = flux_field(esm.ocean.model.tracers.S.boundary_conditions.top.condition) + Jˢ = esm.ocean.model.tracers.S.boundary_conditions.top.condition return Jˢ end diff --git a/src/Diagnostics/meridional_heat_transport.jl b/src/Diagnostics/meridional_heat_transport.jl new file mode 100644 index 000000000..6bc6338db --- /dev/null +++ b/src/Diagnostics/meridional_heat_transport.jl @@ -0,0 +1,95 @@ +using ..EarthSystemModels: EarthSystemModel, reference_density, heat_capacity + +""" + meridional_heat_transport(esm::EarthSystemModel; + reference_temperature = 0) + +Return the meridional heat transport for the coupled `esm::EarthSystemModel` by computing +the meridional heat flux. + +The meridional heat transport is computed via: + +```math +\\mathrm{MHT} ≡ ρᵒᶜ cᵒᶜ ∫ v (T - T_{\\rm ref}) \\, \\mathrm{d}x \\, \\mathrm{d}z +``` + +Above, ``T_{\\rm ref}`` is a reference temperature and ``ρᵒᶜ`` and ``cᵒᶜ`` are the +ocean reference density and specific heat capacity respectively. + +!!! warning "Only works on LatitudeLongitudeGrid" + + The `meridional_heat_transport` diagnostic currently is only supported only on + `LongitudeLatitudeGrid`s. + +Arguments +========= + +* `esm`: An EarthSystemModel. + + +Keyword Arguments +================= + +* `reference_temperature`: The reference temperature (in ᵒC) used for the calculation; default: 0 ᵒC. + + !!! info "Reference temperature" + + The reference temperature is only relevant when we compute the meridional heat transport over a section + where there is a net volume transport. If we are computing the diagnostic globally, i.e., around a whole + latitude circle, then by necessity there is no net volume transport and thus the reference temperature + value is irrelevant. Section-averaged transport could also be considered as a reference temperature to + remove residual barotropic volume fluxes in basin-scale/regional analyses where a net volume transport + is present. + +Example +======= + +```jldoctest +using NumericalEarth +using Oceananigans + +grid = RectilinearGrid(size = (4, 5, 2), extent = (1, 1, 1), + topology = (Periodic, Bounded, Bounded)) + +ocean = ocean_simulation(grid; + momentum_advection = nothing, + tracer_advection = nothing, + closure = nothing, + coriolis = nothing) + +sea_ice = sea_ice_simulation(grid, ocean) + +atmosphere = PrescribedAtmosphere(grid, [0.0]) + +esm = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation = Radiation()) + +mht = meridional_heat_transport(esm) + +# output + +Integral of BinaryOperation at (Center, Face, Center) over dims (1, 3) +└── operand: BinaryOperation at (Center, Face, Center) + └── grid: 4×5×2 RectilinearGrid{Float64, Periodic, Bounded, Bounded} on CPU with 3×3×2 halo +``` +""" +function meridional_heat_transport(esm::EarthSystemModel; reference_temperature=0) + + grid = esm.ocean.model.grid + + validation_grid = grid isa ImmersedBoundaryGrid ? grid.underlying_grid : grid + + grid isa OrthogonalSphericalShellGrid && + throw(ArgumentError("meridional_heat_transport diagnostic does not work on OrthogonalSphericalShellGrid at the moment; use LatitudeLongitudeGrid.")) + + FT = eltype(esm) + reference_temperature = convert(FT, reference_temperature) + + ρᵒᶜ = reference_density(esm.ocean) + cᵒᶜ = heat_capacity(esm.ocean) + + T = esm.ocean.model.tracers.T + v = esm.ocean.model.velocities.v + + MHT = Integral(ρᵒᶜ * cᵒᶜ * v * (T - reference_temperature), dims=(1, 3)) + return MHT +end diff --git a/src/Diagnostics/mixed_layer_depth.jl b/src/Diagnostics/mixed_layer_depth.jl index 3faeb750a..0b986312b 100644 --- a/src/Diagnostics/mixed_layer_depth.jl +++ b/src/Diagnostics/mixed_layer_depth.jl @@ -32,6 +32,7 @@ end function compute!(mld::MixedLayerDepthField, time=nothing) compute_mixed_layer_depth!(mld) + #@apply_regionally compute_mixed_layer_depth!(mld) fill_halo_regions!(mld) return mld end diff --git a/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl b/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl index 0f543cf39..ed93f8c9c 100644 --- a/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl +++ b/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl @@ -9,21 +9,14 @@ export Radiation, ComponentInterfaces, LatitudeDependentAlbedo, - SeaIceAlbedo, SimilarityTheoryFluxes, MomentumRoughnessLength, ScalarRoughnessLength, - NCARMomentumRoughnessLength, - NCARScalarRoughnessLength, - NCARBulkFluxes, CoefficientBasedFluxes, SkinTemperature, BulkTemperature, - LinearStableStabilityFunction, - COARELogarithmicSimilarityProfile, atmosphere_ocean_stability_functions, atmosphere_sea_ice_stability_functions, - ncar_stability_functions, compute_atmosphere_ocean_fluxes!, compute_atmosphere_sea_ice_fluxes!, compute_sea_ice_ocean_fluxes!, @@ -74,16 +67,13 @@ end include("radiation.jl") include("latitude_dependent_albedo.jl") include("tabulated_albedo.jl") -include("sea_ice_albedo.jl") # Turbulent fluxes include("roughness_lengths.jl") -include("ncar_roughness_lengths.jl") include("interface_states.jl") include("compute_interface_state.jl") include("similarity_theory_turbulent_fluxes.jl") include("coefficient_based_turbulent_fluxes.jl") -include("ncar_bulk_fluxes.jl") # State exchanger and interfaces include("state_exchanger.jl") diff --git a/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl b/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl index 667a92804..317b337b9 100644 --- a/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl +++ b/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl @@ -92,19 +92,6 @@ end Tₛ = convert_to_kelvin(sea_ice_properties.temperature_units, Tₛ) end - # Evaluate state-dependent radiation properties at this grid point. - # The albedo may be a struct (e.g., CCSM3SeaIceAlbedo) that reads model fields; - # we evaluate it here so the iteration uses a scalar. - time = Time(clock.time) - σ = interface_properties.radiation.σ - α = stateindex(interface_properties.radiation.α, i, j, kᴺ, grid, time, CCC) - ϵ = stateindex(interface_properties.radiation.ϵ, i, j, kᴺ, grid, time, CCC) - local_radiation = (; σ, α, ϵ) - local_interface_properties = InterfaceProperties(local_radiation, - interface_properties.specific_humidity_formulation, - interface_properties.temperature_formulation, - interface_properties.velocity_formulation) - # Build thermodynamic and dynamic states in the atmosphere and interface. ℂᵃᵗ = atmosphere_properties.thermodynamics_parameters zᵃᵗ = atmosphere_properties.surface_layer_height @@ -144,7 +131,7 @@ end local_atmosphere_state, local_interior_state, downwelling_radiation, - local_interface_properties, + interface_properties, atmosphere_properties, sea_ice_properties) end diff --git a/src/EarthSystemModels/InterfaceComputations/interface_states.jl b/src/EarthSystemModels/InterfaceComputations/interface_states.jl index 8072c2361..8b7b50ae3 100644 --- a/src/EarthSystemModels/InterfaceComputations/interface_states.jl +++ b/src/EarthSystemModels/InterfaceComputations/interface_states.jl @@ -16,12 +16,6 @@ struct InterfaceProperties{R, Q, T, V} velocity_formulation :: V end -Adapt.adapt_structure(to, p::InterfaceProperties) = - InterfaceProperties(Adapt.adapt(to, p.radiation), - Adapt.adapt(to, p.specific_humidity_formulation), - Adapt.adapt(to, p.temperature_formulation), - Adapt.adapt(to, p.velocity_formulation)) - ##### ##### Interface specific humidity formulations ##### diff --git a/src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl b/src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl deleted file mode 100644 index 9ffc88417..000000000 --- a/src/EarthSystemModels/InterfaceComputations/sea_ice_albedo.jl +++ /dev/null @@ -1,133 +0,0 @@ -""" - SeaIceAlbedo{FT, HI, HS, TS} - -Sea ice albedo parameterization following the CCSM3 scheme (Briegleb et al. 2004). - -Computes broadband albedo as a function of ice thickness, snow depth, and surface -temperature. The scheme blends between bare ice and snow-covered albedos, with -a temperature-dependent reduction near the melting point to implicitly represent -melt pond formation. - -Algorithm: -1. Base cold albedos: bare ice (0.53) and snow-covered (0.82) -2. Temperature reduction within 1C of melting: Δα_ice = 0.075, Δα_snow = 0.10 -3. Thin-ice transition to ocean albedo below h_amin = 0.5 m -4. Snow cover interpolation: full snow albedo at h_snow > h_smin = 0.02 m - -References: -- Briegleb, B.P., C.M. Bitz, E.C. Hunke, W.H. Lipscomb, and M.M. Schramm (2004): - Scientific description of the sea ice component in CCSM3. NCAR Tech Note. -- Briegleb, B.P. and B. Light (2007): NCAR/TN-472+STR. -""" -struct SeaIceAlbedo{FT, HI, HS, TS} - # Cold base albedos (broadband, approx 0.52 * vis + 0.48 * nir) - ice_albedo :: FT # 0.52*0.73 + 0.48*0.33 = 0.538 ≈ 0.54 - snow_albedo :: FT # 0.52*0.96 + 0.48*0.68 = 0.825 ≈ 0.83 - # Melt reduction - ice_melt_reduction :: FT # 0.075 - snow_melt_reduction :: FT # 0.10 - melting_temperature :: FT # 0 C - temperature_range :: FT # 1 C - # Thickness scales - ocean_albedo :: FT # 0.06 - minimum_ice_thickness :: FT # 0.5 m - minimum_snow_depth :: FT # 0.02 m - # References to model fields - ice_thickness :: HI - snow_thickness :: HS - surface_temperature :: TS -end - -Adapt.adapt_structure(to, α::SeaIceAlbedo) = - SeaIceAlbedo(α.ice_albedo, - α.snow_albedo, - α.ice_melt_reduction, - α.snow_melt_reduction, - α.melting_temperature, - α.temperature_range, - α.ocean_albedo, - α.minimum_ice_thickness, - α.minimum_snow_depth, - Adapt.adapt(to, α.ice_thickness), - Adapt.adapt(to, α.snow_thickness), - Adapt.adapt(to, α.surface_temperature)) - -""" - SeaIceAlbedo(ice_thickness, snow_thickness, surface_temperature; - ice_albedo = 0.54, - snow_albedo = 0.83, - ice_melt_reduction = 0.075, - snow_melt_reduction = 0.10, - melting_temperature = 0.0, - temperature_range = 1.0, - ocean_albedo = 0.06, - minimum_ice_thickness = 0.5, - minimum_snow_depth = 0.02) - -Construct a CCSM3 sea ice albedo parameterization. Requires references to the sea ice -model's ice thickness, snow thickness, and surface temperature fields. - -Broadband albedos are approximate averages of the visible and near-IR bands -weighted by solar spectrum (52% visible, 48% near-IR): -- ice_albedo ≈ 0.52 x 0.73 + 0.48 x 0.33 ≈ 0.54 -- snow_albedo ≈ 0.52 x 0.96 + 0.48 x 0.68 ≈ 0.83 -""" -function SeaIceAlbedo(ice_thickness, snow_thickness, surface_temperature; - FT = Float64, - ice_albedo = 0.54, - snow_albedo = 0.83, - ice_melt_reduction = 0.075, - snow_melt_reduction = 0.10, - melting_temperature = 0.0, - temperature_range = 1.0, - ocean_albedo = 0.06, - minimum_ice_thickness = 0.5, - minimum_snow_depth = 0.02) - - return SeaIceAlbedo(convert(FT, ice_albedo), - convert(FT, snow_albedo), - convert(FT, ice_melt_reduction), - convert(FT, snow_melt_reduction), - convert(FT, melting_temperature), - convert(FT, temperature_range), - convert(FT, ocean_albedo), - convert(FT, minimum_ice_thickness), - convert(FT, minimum_snow_depth), - ice_thickness, - snow_thickness, - surface_temperature) -end - -Base.summary(::SeaIceAlbedo{FT}) where FT = "SeaIceAlbedo{$FT}" -Base.show(io::IO, α::SeaIceAlbedo{FT}) where FT = - print(io, "SeaIceAlbedo{$FT}(ice=", α.ice_albedo, - ", snow=", α.snow_albedo, ")") - -@inline function stateindex(α::SeaIceAlbedo, i, j, k, grid, time, loc, args...) - @inbounds hi = α.ice_thickness[i, j, 1] - @inbounds Ts = α.surface_temperature[i, j, 1] - - # Snow thickness: may be nothing (no snow model) - hs = get_snow_thickness(α.snow_thickness, i, j, grid) - - # Temperature-dependent reduction (implicit melt ponds) - Tm = α.melting_temperature - ΔT = α.temperature_range - fT = clamp((Ts - Tm + ΔT) / ΔT, zero(Ts), one(Ts)) - - αi = α.ice_albedo - α.ice_melt_reduction * fT - αs = α.snow_albedo - α.snow_melt_reduction * fT - - # Thin ice → transition to ocean albedo - αo = α.ocean_albedo - fh = clamp(hi / α.minimum_ice_thickness, zero(hi), one(hi)) - αi = αo + (αi - αo) * fh - - # Snow cover blending - fs = clamp(hs / α.minimum_snow_depth, zero(hs), one(hs)) - return fs * αs + (1 - fs) * αi -end - -# Helper to handle nothing snow thickness (no snow model) -@inline get_snow_thickness(hs::Nothing, i, j, grid) = zero(grid) -@inline get_snow_thickness(hs, i, j, grid) = @inbounds hs[i, j, 1] diff --git a/src/EarthSystemModels/earth_system_model.jl b/src/EarthSystemModels/earth_system_model.jl index db5ff93dd..90469dc04 100644 --- a/src/EarthSystemModels/earth_system_model.jl +++ b/src/EarthSystemModels/earth_system_model.jl @@ -44,12 +44,13 @@ function Base.show(io::IO, cm::ESM) return nothing end +# Assumption: We have an ocean! architecture(model::ESM) = model.architecture Base.eltype(model::ESM) = Base.eltype(model.interfaces.exchanger.grid) prettytime(model::ESM) = prettytime(model.clock.time) iteration(model::ESM) = model.clock.iteration timestepper(::ESM) = nothing -default_included_properties(::ESM) = [] +default_included_properties(::ESM) = tuple() prognostic_fields(cm::ESM) = nothing fields(::ESM) = NamedTuple() default_clock(TT) = Oceananigans.TimeSteppers.Clock{TT}(0, 0, 1) diff --git a/src/EarthSystemModels/time_step_earth_system_model.jl b/src/EarthSystemModels/time_step_earth_system_model.jl index c486171b3..0ec04e6e2 100644 --- a/src/EarthSystemModels/time_step_earth_system_model.jl +++ b/src/EarthSystemModels/time_step_earth_system_model.jl @@ -15,8 +15,13 @@ function time_step!(coupled_model::EarthSystemModel, Δt; callbacks=[]) atmosphere = coupled_model.atmosphere # Eventually, split out into OceanOnlyModel - !isnothing(sea_ice) && time_step!(sea_ice, Δt) - !isnothing(ocean) && time_step!(ocean, Δt) + !isnothing(sea_ice) && time_step!(sea_ice, Δt) + + # TODO after ice time-step: + # - Adjust ocean heat flux if the ice completely melts? + !isnothing(ocean) && time_step!(ocean, Δt) + + # Time step the atmosphere !isnothing(atmosphere) && time_step!(atmosphere, Δt) # TODO: diff --git a/src/NumericalEarth.jl b/src/NumericalEarth.jl index 8b23f354a..1421eadc5 100644 --- a/src/NumericalEarth.jl +++ b/src/NumericalEarth.jl @@ -55,7 +55,8 @@ export frazil_temperature_flux, net_ocean_temperature_flux, sea_ice_ocean_temperature_flux, atmosphere_ocean_temperature_flux, frazil_heat_flux, net_ocean_heat_flux, sea_ice_ocean_heat_flux, atmosphere_ocean_heat_flux, net_ocean_salinity_flux, sea_ice_ocean_salinity_flux, atmosphere_ocean_salinity_flux, - net_ocean_freshwater_flux, sea_ice_ocean_freshwater_flux, atmosphere_ocean_freshwater_flux + net_ocean_freshwater_flux, sea_ice_ocean_freshwater_flux, atmosphere_ocean_freshwater_flux, + meridional_heat_transport using Oceananigans using Oceananigans.Operators: ℑxyᶠᶜᵃ, ℑxyᶜᶠᵃ diff --git a/src/Oceans/Oceans.jl b/src/Oceans/Oceans.jl index 87093e37f..93b1d2727 100644 --- a/src/Oceans/Oceans.jl +++ b/src/Oceans/Oceans.jl @@ -1,13 +1,13 @@ module Oceans -export ocean_simulation, SlabOcean, FluxAndRestoring +export ocean_simulation, SlabOcean using Oceananigans using Oceananigans.Units using Oceananigans.Utils using Oceananigans.Utils: with_tracers using Oceananigans.Advection: FluxFormAdvection -using Oceananigans.BoundaryConditions: DefaultBoundaryCondition, DiscreteBoundaryFunction +using Oceananigans.BoundaryConditions: DefaultBoundaryCondition using Oceananigans.ImmersedBoundaries: immersed_peripheral_node, inactive_node, MutableGridOfSomeKind using Oceananigans.OrthogonalSphericalShellGrids using Oceananigans.Operators @@ -62,7 +62,6 @@ default_or_override(override, alternative_default=nothing) = override include("slab_ocean.jl") include("barotropic_potential_forcing.jl") include("radiative_forcing.jl") -include("flux_and_restoring.jl") include("ocean_simulation.jl") include("assemble_net_ocean_fluxes.jl") @@ -75,12 +74,12 @@ ocean_temperature(ocean::Simulation{<:HydrostaticFreeSurfaceModel}) = ocean.mode function ocean_surface_salinity(ocean::Simulation{<:HydrostaticFreeSurfaceModel}) kᴺ = size(ocean.model.grid, 3) - return view(ocean.model.tracers.S.data, :, :, kᴺ:kᴺ) + return interior(ocean.model.tracers.S, :, :, kᴺ:kᴺ) end function ocean_surface_temperature(ocean::Simulation{<:HydrostaticFreeSurfaceModel}) kᴺ = size(ocean.model.grid, 3) - return view(ocean.model.tracers.T.data, :, :, kᴺ:kᴺ) + return interior(ocean.model.tracers.T, :, :, kᴺ:kᴺ) end function ocean_surface_velocities(ocean::Simulation{<:HydrostaticFreeSurfaceModel}) @@ -110,18 +109,14 @@ function ComponentExchanger(ocean::Simulation{<:HydrostaticFreeSurfaceModel}, gr return ComponentExchanger((; u, v, T, S), nothing) end -@inline net_flux(condition) = condition -@inline net_flux(bc::FluxAndRestoring) = bc.flux_field -@inline net_flux(bc::DiscreteBoundaryFunction) = net_flux(bc.func) - function net_fluxes(ocean::Simulation{<:HydrostaticFreeSurfaceModel}) # TODO: Generalize this to work with any ocean model - τˣ = net_flux(ocean.model.velocities.u.boundary_conditions.top.condition) - τʸ = net_flux(ocean.model.velocities.v.boundary_conditions.top.condition) + τˣ = ocean.model.velocities.u.boundary_conditions.top.condition + τʸ = ocean.model.velocities.v.boundary_conditions.top.condition net_ocean_surface_fluxes = (; u=τˣ, v=τʸ) tracers = ocean.model.tracers - ocean_surface_tracer_fluxes = NamedTuple(name => net_flux(tracers[name].boundary_conditions.top.condition) for name in keys(tracers)) + ocean_surface_tracer_fluxes = NamedTuple(name => tracers[name].boundary_conditions.top.condition for name in keys(tracers)) return merge(ocean_surface_tracer_fluxes, net_ocean_surface_fluxes) end diff --git a/src/Oceans/flux_and_restoring.jl b/src/Oceans/flux_and_restoring.jl deleted file mode 100644 index 8da53a7f0..000000000 --- a/src/Oceans/flux_and_restoring.jl +++ /dev/null @@ -1,46 +0,0 @@ -using Oceananigans.Operators: Δzᶜᶜᶜ - -using Adapt - -""" - FluxAndRestoring(flux_field, restoring) - -A boundary-condition condition (intended to be wrapped in a discrete-form -`FluxBoundaryCondition`) that combines two contributions at a tracer's top -boundary: - -1. `flux_field`: a 2D `Field{Center, Center, Nothing}` that an external flux - solver (e.g. the OMIP coupled atmosphere/sea-ice solver) writes into each - step. This is read at `(i, j, 1)`. - -2. `restoring`: a callable with signature `(i, j, k, grid, clock, fields)` that - returns a tendency in the top cell — typically a `DatasetRestoring`, - evaluating to `r * μ * (ψ_dataset - ψ)`. The tendency is converted to a - surface flux by multiplying by `-Δz` at the top cell, consistent with the - Oceananigans top-flux sign convention (top cell tendency contribution is - `-J / Δz`). - -This lets the coupled flux solver and a dataset restoring share the same top -boundary condition without one clobbering the other. -""" -struct FluxAndRestoring{F, R} <: Function - flux_field :: F - restoring :: R -end - -Adapt.adapt_structure(to, fr::FluxAndRestoring) = - FluxAndRestoring(adapt(to, fr.flux_field), - adapt(to, fr.restoring)) - -@inline function (fr::FluxAndRestoring)(i, j, grid, clock, fields) - Nz = grid.Nz - @inbounds J = fr.flux_field[i, j, 1] - - # Restoring accessed as a tendency forcing (compatible with DatasetRestoring) - G = fr.restoring(i, j, Nz, grid, clock, fields) - - # Top BC convention: tendency contribution = -J / Δz, so to inject - # `G` in the top cell the flux is `-G * Δz`. - Δz = Δzᶜᶜᶜ(i, j, Nz, grid) - return J - G * Δz -end diff --git a/src/Oceans/ocean_simulation.jl b/src/Oceans/ocean_simulation.jl index 71a24f3a3..88627bb1b 100644 --- a/src/Oceans/ocean_simulation.jl +++ b/src/Oceans/ocean_simulation.jl @@ -19,18 +19,15 @@ using Statistics: mean ##### @inline ϕ²(i, j, k, grid, ϕ) = @inbounds ϕ[i, j, k]^2 -@inline spᶠᶜᶜ(i, j, k, grid, Φ, Uᴮ) = @inbounds sqrt(Φ.u[i, j, k]^2 + ℑxyᶠᶜᵃ(i, j, k, grid, ϕ², Φ.v) + Uᴮ^2) -@inline spᶜᶠᶜ(i, j, k, grid, Φ, Uᴮ) = @inbounds sqrt(Φ.v[i, j, k]^2 + ℑxyᶜᶠᵃ(i, j, k, grid, ϕ², Φ.u) + Uᴮ^2) +@inline spᶠᶜᶜ(i, j, k, grid, Φ) = @inbounds sqrt(Φ.u[i, j, k]^2 + ℑxyᶠᶜᵃ(i, j, k, grid, ϕ², Φ.v)) +@inline spᶜᶠᶜ(i, j, k, grid, Φ) = @inbounds sqrt(Φ.v[i, j, k]^2 + ℑxyᶜᶠᵃ(i, j, k, grid, ϕ², Φ.u)) -@inline u_quadratic_bottom_drag(i, j, grid, c, Φ, p) = @inbounds - p.μ * Φ.u[i, j, 1] * spᶠᶜᶜ(i, j, 1, grid, Φ, p.Uᴮ) -@inline v_quadratic_bottom_drag(i, j, grid, c, Φ, p) = @inbounds - p.μ * Φ.v[i, j, 1] * spᶜᶠᶜ(i, j, 1, grid, Φ, p.Uᴮ) +@inline u_quadratic_bottom_drag(i, j, grid, c, Φ, μ) = @inbounds - μ * Φ.u[i, j, 1] * spᶠᶜᶜ(i, j, 1, grid, Φ) +@inline v_quadratic_bottom_drag(i, j, grid, c, Φ, μ) = @inbounds - μ * Φ.v[i, j, 1] * spᶜᶠᶜ(i, j, 1, grid, Φ) # Keep a constant linear drag parameter independent on vertical level -@inline u_immersed_bottom_drag(i, j, k, grid, clock, Φ, p) = @inbounds - p.μ * Φ.u[i, j, k] * spᶠᶜᶜ(i, j, k, grid, Φ, p.Uᴮ) -@inline v_immersed_bottom_drag(i, j, k, grid, clock, Φ, p) = @inbounds - p.μ * Φ.v[i, j, k] * spᶜᶠᶜ(i, j, k, grid, Φ, p.Uᴮ) - -@inline build_top_tracer_bc(flux_field, ::Nothing) = FluxBoundaryCondition(flux_field) -@inline build_top_tracer_bc(flux_field, restoring) = FluxBoundaryCondition(FluxAndRestoring(flux_field, restoring); discrete_form=true) +@inline u_immersed_bottom_drag(i, j, k, grid, clock, Φ, μ) = @inbounds - μ * Φ.u[i, j, k] * spᶠᶜᶜ(i, j, k, grid, Φ) +@inline v_immersed_bottom_drag(i, j, k, grid, clock, Φ, μ) = @inbounds - μ * Φ.v[i, j, k] * spᶜᶠᶜ(i, j, k, grid, Φ) ##### ##### Defaults @@ -103,7 +100,6 @@ end """ ocean_simulation(grid; Δt = estimate_maximum_Δt(grid), - clock = Clock(grid), closure = default_ocean_closure(), tracers = (:T, :S), free_surface = default_free_surface(grid), @@ -111,9 +107,7 @@ end rotation_rate = default_planet_rotation_rate, gravitational_acceleration = default_gravitational_acceleration, bottom_drag_coefficient = Default(0.003), - drag_bulk_velocity = Default(0.1), forcing = NamedTuple(), - surface_restoring = NamedTuple(), biogeochemistry = nothing, timestepper = :SplitRungeKutta3, coriolis = Default(HydrostaticSphericalCoriolis(; rotation_rate)), @@ -132,6 +126,7 @@ consistent defaults for advection, closures, the equation of state, surface flux barotropic pressure–gradient forcing, boundary conditions, and optional biogeochemistry. It then wraps the model into an Oceananigans's `Simulation` with the specified timestepping options. + ## Behaviour and automatic configuration ### Coriolis @@ -166,7 +161,6 @@ defaults on a per-field basis. ## Keyword Arguments - `Δt`: Timestep used by the `Simulation`. Defaults to the maximum stable timestep estimated from the `grid`. -- `clock`: Clock object. Defaults to `Clock(grid)`. - `closure`: A turbulence or mixing closure. Defaults to `default_ocean_closure()`. - `tracers`: Tuple of tracer names. Defaults to `(:T, :S)`. - `free_surface`: Free–surface solver. Defaults to `default_free_surface(grid)`. @@ -174,9 +168,7 @@ defaults on a per-field basis. - `rotation_rate`: Planetary rotation rate used for Coriolis forcing. - `gravitational_acceleration`: Gravitational acceleration, passed to buoyancy. - `bottom_drag_coefficient`: Bottom drag coefficient. May be a `Default` wrapper. -- `drag_bulk_velocity`: a minimum velocity for the bottom drag. - `forcing`: Named tuple of additional forcing(s) for individual fields. -- `surface_restoring`: Named tuple of dataset restorings to apply as part of the tracer top boundary condition. - `biogeochemistry`: A biogeochemical model or `nothing`. - `timestepper`: Time-stepping scheme; options are `:SplitRungeKutta3` (default), or `:QuasiAdamsBashforth2`. - `coriolis`: Coriolis object or `Default(...)` wrapper. @@ -190,7 +182,6 @@ defaults on a per-field basis. """ function ocean_simulation(grid; Δt = estimate_maximum_Δt(grid), - clock = Clock(grid), closure = default_ocean_closure(), tracers = (:T, :S), free_surface = default_free_surface(grid), @@ -198,10 +189,7 @@ function ocean_simulation(grid; rotation_rate = default_planet_rotation_rate, gravitational_acceleration = default_gravitational_acceleration, bottom_drag_coefficient = Default(0.003), - drag_bulk_velocity = Default(0.05), - use_barotropic_potential = true, forcing = NamedTuple(), - surface_restoring = NamedTuple(), biogeochemistry = nothing, timestepper = :SplitRungeKutta3, coriolis = Default(HydrostaticSphericalCoriolis(; rotation_rate)), @@ -210,12 +198,11 @@ function ocean_simulation(grid; equation_of_state = TEOS10EquationOfState(; reference_density), boundary_conditions::NamedTuple = NamedTuple(), radiative_forcing = default_radiative_forcing(grid), - materialize_buoyancy_gradients = true, warn = true, verbose = false) FT = eltype(grid) - + if grid isa RectilinearGrid # turn off Coriolis unless user-supplied coriolis = default_or_override(coriolis, nothing) else @@ -229,8 +216,6 @@ function ocean_simulation(grid; if single_column_simulation # Let users put a bottom drag if they want bottom_drag_coefficient = default_or_override(bottom_drag_coefficient, zero(grid)) - drag_bulk_velocity = default_or_override(drag_bulk_velocity, zero(grid)) - drag_parameters = (μ = bottom_drag_coefficient, Uᴮ = drag_bulk_velocity) # Don't let users use advection in a single column model tracer_advection = nothing @@ -251,27 +236,21 @@ function ocean_simulation(grid; end bottom_drag_coefficient = default_or_override(bottom_drag_coefficient) - drag_bulk_velocity = default_or_override(drag_bulk_velocity) - bottom_drag_coefficient = convert(FT, bottom_drag_coefficient) - drag_bulk_velocity = convert(FT, drag_bulk_velocity) - drag_parameters = (μ = bottom_drag_coefficient, Uᴮ = drag_bulk_velocity) - u_immersed_drag = FluxBoundaryCondition(u_immersed_bottom_drag, discrete_form=true, parameters=drag_parameters) - v_immersed_drag = FluxBoundaryCondition(v_immersed_bottom_drag, discrete_form=true, parameters=drag_parameters) + u_immersed_drag = FluxBoundaryCondition(u_immersed_bottom_drag, discrete_form=true, parameters=bottom_drag_coefficient) + v_immersed_drag = FluxBoundaryCondition(v_immersed_bottom_drag, discrete_form=true, parameters=bottom_drag_coefficient) u_immersed_bc = ImmersedBoundaryCondition(bottom=u_immersed_drag) v_immersed_bc = ImmersedBoundaryCondition(bottom=v_immersed_drag) - if use_barotropic_potential - # Forcing for u, v - barotropic_potential = Field{Center, Center, Nothing}(grid) - u_forcing = BarotropicPotentialForcing(XDirection(), barotropic_potential) - v_forcing = BarotropicPotentialForcing(YDirection(), barotropic_potential) + # Forcing for u, v + barotropic_potential = Field{Center, Center, Nothing}(grid) + u_forcing = BarotropicPotentialForcing(XDirection(), barotropic_potential) + v_forcing = BarotropicPotentialForcing(YDirection(), barotropic_potential) - :u ∈ keys(forcing) && (u_forcing = (u_forcing, forcing[:u])) - :v ∈ keys(forcing) && (v_forcing = (v_forcing, forcing[:v])) - forcing = merge(forcing, (u=u_forcing, v=v_forcing)) - end + :u ∈ keys(forcing) && (u_forcing = (u_forcing, forcing[:u])) + :v ∈ keys(forcing) && (v_forcing = (v_forcing, forcing[:v])) + forcing = merge(forcing, (u=u_forcing, v=v_forcing)) end if !isnothing(radiative_forcing) @@ -283,20 +262,22 @@ function ocean_simulation(grid; forcing = merge(forcing, (; T=T_forcing)) end + bottom_drag_coefficient = convert(FT, bottom_drag_coefficient) + # Set up boundary conditions using Field - top_zonal_momentum_flux = τˣ = Field{Face, Center, Nothing}(grid) - top_meridional_momentum_flux = τʸ = Field{Center, Face, Nothing}(grid) + top_zonal_momentum_flux = τˣ = Field{Face, Center, Nothing}(grid) + top_meridional_momentum_flux = τʸ = Field{Center, Face, Nothing}(grid) top_ocean_heat_flux = Jᵀ = Field{Center, Center, Nothing}(grid) top_salt_flux = Jˢ = Field{Center, Center, Nothing}(grid) # Construct ocean boundary conditions including surface forcing and bottom drag u_top_bc = FluxBoundaryCondition(τˣ) v_top_bc = FluxBoundaryCondition(τʸ) - T_top_bc = build_top_tracer_bc(Jᵀ, get(surface_restoring, :T, nothing)) - S_top_bc = build_top_tracer_bc(Jˢ, get(surface_restoring, :S, nothing)) + T_top_bc = FluxBoundaryCondition(Jᵀ) + S_top_bc = FluxBoundaryCondition(Jˢ) - u_bot_bc = FluxBoundaryCondition(u_quadratic_bottom_drag, discrete_form=true, parameters=drag_parameters) - v_bot_bc = FluxBoundaryCondition(v_quadratic_bottom_drag, discrete_form=true, parameters=drag_parameters) + u_bot_bc = FluxBoundaryCondition(u_quadratic_bottom_drag, discrete_form=true, parameters=bottom_drag_coefficient) + v_bot_bc = FluxBoundaryCondition(v_quadratic_bottom_drag, discrete_form=true, parameters=bottom_drag_coefficient) default_boundary_conditions = (u = FieldBoundaryConditions(top=u_top_bc, bottom=u_bot_bc, immersed=u_immersed_bc), v = FieldBoundaryConditions(top=v_top_bc, bottom=v_bot_bc, immersed=v_immersed_bc), @@ -308,8 +289,7 @@ function ocean_simulation(grid; # conditions even when a user-bc is supplied). boundary_conditions = merge(default_boundary_conditions, boundary_conditions) buoyancy = SeawaterBuoyancy(; gravitational_acceleration, equation_of_state) - buoyancy = Oceananigans.BuoyancyFormulations.BuoyancyForce(grid, buoyancy; materialize_gradients=materialize_buoyancy_gradients) - + if tracer_advection isa NamedTuple tracer_advection = with_tracers(tracers, tracer_advection, default_tracer_advection()) else @@ -317,13 +297,12 @@ function ocean_simulation(grid; end if hasclosure(closure, CATKEVerticalDiffusivity) - # Use the same advection as for temperature - tke_advection = (; e=tracer_advection[1]) + # Turn off CATKE tracer advection + tke_advection = (; e=nothing) tracer_advection = merge(tracer_advection, tke_advection) end ocean_model = HydrostaticFreeSurfaceModel(grid; - clock, buoyancy, closure, biogeochemistry, From cb8e8dd1276a9e26b10de333b00ad934014c3a71 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 15:17:57 +0200 Subject: [PATCH 09/54] Add unit and integration tests for snow model Unit tests: - sea_ice_simulation with/without snow (with_snow kwarg) - PhaseTransitions API (heat_capacity/density kwargs) - ComponentExchanger includes hs (ZeroField without snow, Field with snow) - default_ai_temperature dispatches on ConductiveFlux vs IceSnowConductiveFlux - net_fluxes includes snowfall - Thermal resistance: R_snow > R_ice Integration tests: - Coupled model without snow (time_step succeeds) - Coupled model with snow (time_step succeeds) - Snowfall routing: Jsn in exchanger, snowfall in net fluxes - Snow insulation: IceSnowConductiveFlux wired with correct conductivities Co-Authored-By: Claude Opus 4.6 (1M context) --- test/test_snow_model_integration.jl | 205 ++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 test/test_snow_model_integration.jl diff --git a/test/test_snow_model_integration.jl b/test/test_snow_model_integration.jl new file mode 100644 index 000000000..1b59b9450 --- /dev/null +++ b/test/test_snow_model_integration.jl @@ -0,0 +1,205 @@ +include("runtests_setup.jl") + +using ClimaSeaIce: SeaIceModel, ConductiveFlux +using ClimaSeaIce.SeaIceThermodynamics: IceSnowConductiveFlux +using NumericalEarth.EarthSystemModels.InterfaceComputations: + ComponentInterfaces, + SkinTemperature, + InterfaceProperties, + conductive_flux_balance_temperature + +using Oceananigans.Fields: ZeroField +using Oceananigans.Units: hours, days + +##### +##### Unit tests +##### + +@testset "Snow model unit tests" begin + for arch in test_architectures + A = typeof(arch) + + grid = RectilinearGrid(arch; + size = (4, 4, 1), + extent = (1, 1, 1), + topology = (Periodic, Periodic, Bounded)) + + @testset "sea_ice_simulation with_snow=false [$A]" begin + sea_ice = sea_ice_simulation(grid; dynamics=nothing) + @test sea_ice isa Simulation + @test sea_ice.model isa SeaIceModel + @test sea_ice.model.snow_thermodynamics === nothing + @test sea_ice.model.ice_thermodynamics.internal_heat_flux isa ConductiveFlux + end + + @testset "sea_ice_simulation with_snow=true [$A]" begin + sea_ice = sea_ice_simulation(grid; dynamics=nothing, with_snow=true) + @test sea_ice isa Simulation + @test sea_ice.model.snow_thermodynamics !== nothing + @test sea_ice.model.snow_thickness isa Field + end + + @testset "PhaseTransitions API [$A]" begin + sea_ice = sea_ice_simulation(grid; dynamics=nothing) + pt = sea_ice.model.ice_thermodynamics.phase_transitions + @test pt.heat_capacity == 2100 + @test pt.density == 900 + end + + @testset "ComponentExchanger includes hs [$A]" begin + using NumericalEarth.EarthSystemModels.InterfaceComputations: ComponentExchanger + + # Without snow: hs should be ZeroField + sea_ice = sea_ice_simulation(grid; dynamics=nothing) + exchanger = ComponentExchanger(sea_ice, grid) + @test haskey(exchanger.state, :hs) + @test haskey(exchanger.state, :hi) + @test exchanger.state.hs isa ZeroField + + # With snow: hs should be a Field + sea_ice_snow = sea_ice_simulation(grid; dynamics=nothing, with_snow=true) + exchanger_snow = ComponentExchanger(sea_ice_snow, grid) + @test exchanger_snow.state.hs isa Field + end + + @testset "default_ai_temperature dispatches on snow [$A]" begin + using NumericalEarth.SeaIces: default_ai_temperature + + sea_ice = sea_ice_simulation(grid; dynamics=nothing) + st = default_ai_temperature(sea_ice) + @test st isa SkinTemperature + @test st.internal_flux isa ConductiveFlux + + sea_ice_snow = sea_ice_simulation(grid; dynamics=nothing, with_snow=true) + st_snow = default_ai_temperature(sea_ice_snow) + @test st_snow isa SkinTemperature + @test st_snow.internal_flux isa IceSnowConductiveFlux + end + + @testset "net_fluxes includes snowfall [$A]" begin + using NumericalEarth.SeaIces: net_fluxes + + sea_ice = sea_ice_simulation(grid; dynamics=nothing, with_snow=true, snowfall=0) + fluxes = net_fluxes(sea_ice) + @test haskey(fluxes.top, :snowfall) + end + + @testset "Conductive flux balance: bare ice vs ice+snow [$A]" begin + # For thick ice with no snow, R = hi/ki. + # With snow on top, R = hs/ks + hi/ki > hi/ki. + # Higher R means more insulation → warmer surface temperature + # (closer to the atmospheric temperature, further from the bottom). + # + # We verify R_snow > R_ice by checking the formula directly. + ki = 2.0 # ice conductivity + ks = 0.31 # snow conductivity + hi = 1.0 # ice thickness + hs = 0.1 # snow depth + + R_ice = hi / ki + R_snow = hs / ks + hi / ki + + @test R_snow > R_ice + # Snow adds significant thermal resistance + @test R_snow / R_ice > 1.5 + end + end +end + +##### +##### Integration tests +##### + +@testset "Snow model integration tests" begin + for arch in test_architectures + A = typeof(arch) + + grid = RectilinearGrid(arch; + size = (4, 4, 2), + extent = (1, 1, 1), + topology = (Periodic, Periodic, Bounded)) + + @testset "Coupled model without snow [$A]" begin + ocean = ocean_simulation(grid; + momentum_advection = nothing, + tracer_advection = nothing, + closure = nothing, + coriolis = nothing) + + sea_ice = sea_ice_simulation(grid, ocean; dynamics=nothing) + atmosphere = PrescribedAtmosphere(grid, [0.0]) + radiation = Radiation() + + @test begin + coupled = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + time_step!(coupled, 1) + true + end + end + + @testset "Coupled model with snow [$A]" begin + ocean = ocean_simulation(grid; + momentum_advection = nothing, + tracer_advection = nothing, + closure = nothing, + coriolis = nothing) + + sea_ice = sea_ice_simulation(grid, ocean; + dynamics = nothing, + with_snow = true) + + atmosphere = PrescribedAtmosphere(grid, [0.0]) + radiation = Radiation() + + @test begin + coupled = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + time_step!(coupled, 1) + true + end + end + + @testset "Snowfall routing [$A]" begin + ocean = ocean_simulation(grid; + momentum_advection = nothing, + tracer_advection = nothing, + closure = nothing, + coriolis = nothing) + + sea_ice = sea_ice_simulation(grid, ocean; + dynamics = nothing, + with_snow = true) + + atmosphere = PrescribedAtmosphere(grid, [0.0]) + radiation = Radiation() + + coupled = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + + # The snowfall field should exist in the exchanger + exchanger = coupled.interfaces.exchanger + @test haskey(exchanger.atmosphere.state, :Jˢⁿ) + + # The net fluxes should include snowfall + top_fluxes = coupled.interfaces.net_fluxes.sea_ice.top + @test haskey(top_fluxes, :snowfall) + end + + @testset "Snow insulation effect [$A]" begin + # With snow, the IceSnowConductiveFlux is used for the surface + # temperature solve. Verify this dispatch is wired correctly. + ocean = ocean_simulation(grid; + momentum_advection = nothing, + tracer_advection = nothing, + closure = nothing, + coriolis = nothing) + + sea_ice = sea_ice_simulation(grid, ocean; + dynamics = nothing, + with_snow = true) + + ai_temp = NumericalEarth.SeaIces.default_ai_temperature(sea_ice) + @test ai_temp.internal_flux isa IceSnowConductiveFlux + @test ai_temp.internal_flux.ice_conductivity == 2.0 + @test ai_temp.internal_flux.snow_conductivity == 0.31 + end + end +end From cff071bb3b0beddba0e08fbf125b153e6dddd57f Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 15:22:56 +0200 Subject: [PATCH 10/54] Replace arithmetic test with snow insulation integration test Build two coupled models (bare ice vs ice+snow), time-step both, verify the snowy surface temperature is warmer due to added thermal resistance from the snow layer. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/test_snow_model_integration.jl | 62 ++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/test/test_snow_model_integration.jl b/test/test_snow_model_integration.jl index 1b59b9450..050b32efe 100644 --- a/test/test_snow_model_integration.jl +++ b/test/test_snow_model_integration.jl @@ -84,24 +84,50 @@ using Oceananigans.Units: hours, days @test haskey(fluxes.top, :snowfall) end - @testset "Conductive flux balance: bare ice vs ice+snow [$A]" begin - # For thick ice with no snow, R = hi/ki. - # With snow on top, R = hs/ks + hi/ki > hi/ki. - # Higher R means more insulation → warmer surface temperature - # (closer to the atmospheric temperature, further from the bottom). - # - # We verify R_snow > R_ice by checking the formula directly. - ki = 2.0 # ice conductivity - ks = 0.31 # snow conductivity - hi = 1.0 # ice thickness - hs = 0.1 # snow depth - - R_ice = hi / ki - R_snow = hs / ks + hi / ki - - @test R_snow > R_ice - # Snow adds significant thermal resistance - @test R_snow / R_ice > 1.5 + @testset "Snow insulates: warmer surface temperature [$A]" begin + # Build two coupled models — one without snow, one with snow and + # nonzero snow thickness — then compare surface temperatures after + # one coupled time step. Snow adds thermal resistance, so the + # surface should be warmer (closer to atmosphere) with snow. + ocean_grid = RectilinearGrid(arch; + size = (1, 1, 2), + extent = (1, 1, 1), + topology = (Flat, Flat, Bounded)) + + function build_coupled(; with_snow) + ocean = ocean_simulation(ocean_grid; + momentum_advection = nothing, + tracer_advection = nothing, + closure = nothing, + coriolis = nothing) + set!(ocean.model, T = -1.8, S = 34) + + sea_ice = sea_ice_simulation(ocean_grid, ocean; + dynamics = nothing, + with_snow) + set!(sea_ice.model, h = 1.0, ℵ = 1.0) + + atmosphere = PrescribedAtmosphere(ocean_grid, [0.0]) + radiation = Radiation() + return OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + end + + bare = build_coupled(with_snow = false) + snowy = build_coupled(with_snow = true) + + # Give the snowy model some snow + set!(snowy.sea_ice.model, hs = 0.2) + + time_step!(bare, 1) + time_step!(snowy, 1) + + Ts_bare = bare.interfaces.atmosphere_sea_ice_interface.temperature + Ts_snowy = snowy.interfaces.atmosphere_sea_ice_interface.temperature + + @allowscalar begin + # Snow insulation → warmer (or equal) surface temperature + @test Ts_snowy[1, 1, 1] ≥ Ts_bare[1, 1, 1] + end end end end From de9d1e78da788b170ef5c739486e8f5453fec2d1 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 15:23:32 +0200 Subject: [PATCH 11/54] Remove redundant coupled-model-without-snow test Already covered by test_ocean_sea_ice_model.jl. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/test_snow_model_integration.jl | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/test/test_snow_model_integration.jl b/test/test_snow_model_integration.jl index 050b32efe..cfa33720e 100644 --- a/test/test_snow_model_integration.jl +++ b/test/test_snow_model_integration.jl @@ -145,24 +145,6 @@ end extent = (1, 1, 1), topology = (Periodic, Periodic, Bounded)) - @testset "Coupled model without snow [$A]" begin - ocean = ocean_simulation(grid; - momentum_advection = nothing, - tracer_advection = nothing, - closure = nothing, - coriolis = nothing) - - sea_ice = sea_ice_simulation(grid, ocean; dynamics=nothing) - atmosphere = PrescribedAtmosphere(grid, [0.0]) - radiation = Radiation() - - @test begin - coupled = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) - time_step!(coupled, 1) - true - end - end - @testset "Coupled model with snow [$A]" begin ocean = ocean_simulation(grid; momentum_advection = nothing, From ca8a547293d495c5df4e861f3157ce2091d0f8e6 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 15:45:57 +0200 Subject: [PATCH 12/54] Remove unused imports of immersed bottom drag functions Co-Authored-By: Claude Opus 4.6 (1M context) --- src/SeaIces/sea_ice_simulation.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SeaIces/sea_ice_simulation.jl b/src/SeaIces/sea_ice_simulation.jl index 10dfabd11..bcbefeb23 100644 --- a/src/SeaIces/sea_ice_simulation.jl +++ b/src/SeaIces/sea_ice_simulation.jl @@ -8,7 +8,7 @@ using ClimaSeaIce.Rheologies: IceStrength, ElastoViscoPlasticRheology using Oceananigans.TimeSteppers: SplitRungeKuttaTimeStepper using NumericalEarth.EarthSystemModels: ocean_surface_salinity, ocean_surface_velocities -using NumericalEarth.Oceans: Default, u_immersed_bottom_drag, v_immersed_bottom_drag, reference_density +using NumericalEarth.Oceans: Default, reference_density default_rotation_rate = Oceananigans.defaults.planet_rotation_rate From caca7a97f2d82a76c666664bc76569461be4d48f Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 15:57:10 +0200 Subject: [PATCH 13/54] Fix ConstantField setindex! error in snowfall flux assembly When with_snow=false, sea_ice.model.snowfall is a ConstantField(0) which cannot be written to. Always allocate a writable Field for the net snowfall flux since the kernel writes into it unconditionally. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/SeaIces/sea_ice_simulation.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SeaIces/sea_ice_simulation.jl b/src/SeaIces/sea_ice_simulation.jl index bcbefeb23..2aa55f1f7 100644 --- a/src/SeaIces/sea_ice_simulation.jl +++ b/src/SeaIces/sea_ice_simulation.jl @@ -154,7 +154,7 @@ function net_fluxes(sea_ice::Simulation{<:SeaIceModel}) (; u, v) end - snowfall = sea_ice.model.snowfall + snowfall = Field{Center, Center, Nothing}(sea_ice.model.grid) net_top_sea_ice_fluxes = merge((; heat=sea_ice.model.external_heat_fluxes.top, snowfall), net_momentum_fluxes) net_bottom_sea_ice_fluxes = (; heat=sea_ice.model.external_heat_fluxes.bottom) From 995d7f3767f75129894a4deaa9bb99fdd0959d01 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 16:03:22 +0200 Subject: [PATCH 14/54] correct the snowfall --- src/SeaIces/sea_ice_simulation.jl | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/SeaIces/sea_ice_simulation.jl b/src/SeaIces/sea_ice_simulation.jl index 2aa55f1f7..f80bae02b 100644 --- a/src/SeaIces/sea_ice_simulation.jl +++ b/src/SeaIces/sea_ice_simulation.jl @@ -32,8 +32,7 @@ function sea_ice_simulation(grid, ocean=nothing; internal_heat_flux = ConductiveFlux(; conductivity), with_snow = false, snow_conductivity = 0.31, # W m⁻¹ K⁻¹ - snow_density = 330, # kg m⁻³ - snowfall = 0) + snow_density = 330) # kg m⁻³ # Build consistent boundary conditions for the ice model: # - bottom -> flux boundary condition @@ -68,6 +67,7 @@ function sea_ice_simulation(grid, ocean=nothing; bottom_heat_flux = Field{Center, Center, Nothing}(grid) top_heat_flux = Field{Center, Center, Nothing}(grid) + snowfall = Field{Center, Center, Nothing}(grid) # Build the sea ice model sea_ice_model = SeaIceModel(grid; @@ -154,8 +154,7 @@ function net_fluxes(sea_ice::Simulation{<:SeaIceModel}) (; u, v) end - snowfall = Field{Center, Center, Nothing}(sea_ice.model.grid) - net_top_sea_ice_fluxes = merge((; heat=sea_ice.model.external_heat_fluxes.top, snowfall), net_momentum_fluxes) + net_top_sea_ice_fluxes = merge((; heat=sea_ice.model.external_heat_fluxes.top, snowfall=sea_ice.model.snowfall), net_momentum_fluxes) net_bottom_sea_ice_fluxes = (; heat=sea_ice.model.external_heat_fluxes.bottom) return (; bottom = net_bottom_sea_ice_fluxes, top = net_top_sea_ice_fluxes) From 142b35ebb1de7be16f621dff52314b3270b0ee99 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 16 Apr 2026 00:11:38 +0200 Subject: [PATCH 15/54] just pass a default snow thermodynamics --- src/SeaIces/sea_ice_simulation.jl | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/SeaIces/sea_ice_simulation.jl b/src/SeaIces/sea_ice_simulation.jl index f80bae02b..48a7d40c2 100644 --- a/src/SeaIces/sea_ice_simulation.jl +++ b/src/SeaIces/sea_ice_simulation.jl @@ -15,6 +15,13 @@ default_rotation_rate = Oceananigans.defaults.planet_rotation_rate ocean_reference_density(ocean::Simulation, FT) = convert(FT, reference_density(ocean)) ocean_reference_density(::Nothing, FT) = convert(FT, 1026.0) +function default_snow_thermodynamics(grid) + FT = eltype(grid) + snow_conductivity = FT(0.31) + snow_density = FT(330) + return snow_slab_thermodynamics(grid; conductivity = snow_conductivity, density = snow_density) +end + function sea_ice_simulation(grid, ocean=nothing; Δt = 5minutes, ice_salinity = 4, # psu @@ -30,9 +37,7 @@ function sea_ice_simulation(grid, ocean=nothing; phase_transitions = PhaseTransitions(; heat_capacity=ice_heat_capacity, density=ice_density), conductivity = 2, # W m⁻¹ K⁻¹ internal_heat_flux = ConductiveFlux(; conductivity), - with_snow = false, - snow_conductivity = 0.31, # W m⁻¹ K⁻¹ - snow_density = 330) # kg m⁻³ + snow_thermodynamics = default_snow_thermodynamics(grid)) # Build consistent boundary conditions for the ice model: # - bottom -> flux boundary condition @@ -58,13 +63,6 @@ function sea_ice_simulation(grid, ocean=nothing; top_heat_boundary_condition, bottom_heat_boundary_condition) - # Snow thermodynamics (ClimaSeaIce wires the IceSnowConductiveFlux internally) - snow_thermodynamics = if with_snow - snow_slab_thermodynamics(grid; conductivity = snow_conductivity, density = snow_density) - else - nothing - end - bottom_heat_flux = Field{Center, Center, Nothing}(grid) top_heat_flux = Field{Center, Center, Nothing}(grid) snowfall = Field{Center, Center, Nothing}(grid) From a76c245799e081960228238e48ba4a61b2915019 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 16 Apr 2026 08:03:02 +0200 Subject: [PATCH 16/54] Backwards-compatible checkpoint restore for ClimaSeaIce >= 0.4.8 Monkey-patch restore_prognostic_state! for SeaIceModel so that old checkpoints (saved before snow_thickness was added to the model) can be restored without error. The snow_thickness field is only restored if present in the checkpoint state; otherwise it is silently skipped. Co-Authored-By: Claude Opus 4.6 (1M context) --- experiments/OMIPSimulations/Project.toml | 25 + .../visualize_omip-checkpoint.ipynb | 630 ++++++++++++++++ experiments/OMIPSimulations/scripts/launch.sh | 271 +++++++ experiments/OMIPSimulations/scripts/store.sh | 194 +++++ .../scripts/visualize_omip.ipynb | 502 +++++++++++++ .../OMIPSimulations/scripts/visualize_omip.jl | 693 ++++++++++++++++++ .../OMIPSimulations/scripts/watchdog.sh | 26 + .../OMIPSimulations/src/OMIPSimulations.jl | 79 ++ experiments/OMIPSimulations/src/atmosphere.jl | 28 + .../OMIPSimulations/src/omip_diagnostics.jl | 187 +++++ .../OMIPSimulations/src/omip_simulation.jl | 463 ++++++++++++ .../OMIPSimulations/src/report_fields.jl | 83 +++ 12 files changed, 3181 insertions(+) create mode 100644 experiments/OMIPSimulations/Project.toml create mode 100644 experiments/OMIPSimulations/scripts/.ipynb_checkpoints/visualize_omip-checkpoint.ipynb create mode 100755 experiments/OMIPSimulations/scripts/launch.sh create mode 100755 experiments/OMIPSimulations/scripts/store.sh create mode 100644 experiments/OMIPSimulations/scripts/visualize_omip.ipynb create mode 100644 experiments/OMIPSimulations/scripts/visualize_omip.jl create mode 100755 experiments/OMIPSimulations/scripts/watchdog.sh create mode 100644 experiments/OMIPSimulations/src/OMIPSimulations.jl create mode 100644 experiments/OMIPSimulations/src/atmosphere.jl create mode 100644 experiments/OMIPSimulations/src/omip_diagnostics.jl create mode 100644 experiments/OMIPSimulations/src/omip_simulation.jl create mode 100644 experiments/OMIPSimulations/src/report_fields.jl diff --git a/experiments/OMIPSimulations/Project.toml b/experiments/OMIPSimulations/Project.toml new file mode 100644 index 000000000..3d02d7783 --- /dev/null +++ b/experiments/OMIPSimulations/Project.toml @@ -0,0 +1,25 @@ +name = "OMIPSimulations" +uuid = "5ac3a3a1-7b1f-4d7e-9c5e-1e6c9d9b2a4d" +version = "0.1.0" +authors = ["NumericalEarth contributors"] + +[deps] +CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" +ClimaSeaIce = "6ba0ff68-24e6-4315-936c-2e99227c95a4" +ConservativeRegridding = "8e50ac2c-eb48-49bc-a402-07c87b949343" +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" +NCDatasets = "85f8d34a-cbdd-5861-8df4-14fed0d494ab" +NumericalEarth = "904d977b-046a-4731-8b86-9235c0d1ef02" +Oceananigans = "9e8cae18-63c1-5223-a75c-80ca9d6e9a09" +Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" +Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +WorldOceanAtlasTools = "04f20302-f1b9-11e8-29d9-7d841cb0a64a" + +[sources] +NumericalEarth = {path = "../.."} + +[compat] +ConservativeRegridding = "0.2.0" +Oceananigans = "0.106.5, 0.107, 0.189" +julia = "1.10" diff --git a/experiments/OMIPSimulations/scripts/.ipynb_checkpoints/visualize_omip-checkpoint.ipynb b/experiments/OMIPSimulations/scripts/.ipynb_checkpoints/visualize_omip-checkpoint.ipynb new file mode 100644 index 000000000..05b9d768d --- /dev/null +++ b/experiments/OMIPSimulations/scripts/.ipynb_checkpoints/visualize_omip-checkpoint.ipynb @@ -0,0 +1,630 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# OMIP Simulation Diagnostics\n", + "\n", + "Post-processing visualization loosely following Adcroft et al. (2019),\n", + "*The GFDL Global Ocean and Sea Ice Model OM4.0*, JAMES.\n", + "\n", + "1. Time-mean SST / SSS and bias vs WOA\n", + "2. SSH, MLD, sea-ice concentration (March & September)\n", + "3. Surface heat and freshwater fluxes\n", + "4. Global-mean T & S drift, horizontal-mean profiles\n", + "5. Zonal-mean T, S, b and difference from initial conditions (WOA),\n", + " computed via `ConservativeRegridding` to a regular lat-lon grid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "run_dir = \"halfdegree_run\" # <-- path to the _run folder\n", + "prefix = replace(basename(run_dir), \"_run\" => \"\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "using CairoMakie\n", + "using Statistics\n", + "using Dates\n", + "using Oceananigans\n", + "using Oceananigans.Grids: znodes, φnodes\n", + "using Oceananigans.Fields: interpolate!\n", + "using ConservativeRegridding\n", + "using NumericalEarth\n", + "using NumericalEarth.DataWrangling: Metadatum\n", + "using NumericalEarth.DataWrangling.WOA: WOAAnnual" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# ── Helpers ──────────────────────────────────────────────────\n", + "\n", + "function find_first_file(run_dir, prefix, group)\n", + " tag = \"$(prefix)_$(group)\"\n", + " candidates = filter(f -> startswith(f, tag) && endswith(f, \".jld2\") &&\n", + " !contains(f, \"checkpoint\"), readdir(run_dir))\n", + " isempty(candidates) && error(\"No $group files found for prefix '$prefix' in $run_dir\")\n", + " return joinpath(run_dir, first(sort(candidates)))\n", + "end\n", + "\n", + "function compute_time_mean(fts)\n", + " Nt = length(fts.times)\n", + " avg = zeros(size(Array(interior(fts[1]))))\n", + " for n in 1:Nt\n", + " avg .+= Array(interior(fts[n]))\n", + " end\n", + " return avg ./ Nt\n", + "end\n", + "\n", + "function compute_monthly_mean(fts, target_months; start_date = DateTime(1958, 1, 1))\n", + " dates = [start_date + Second(round(Int, t)) for t in fts.times]\n", + " idx = findall(d -> month(d) in target_months, dates)\n", + " isempty(idx) && return nothing\n", + " avg = zeros(size(Array(interior(fts[1]))))\n", + " for n in idx\n", + " avg .+= Array(interior(fts[n]))\n", + " end\n", + " return avg ./ length(idx)\n", + "end\n", + "\n", + "function build_land_mask(grid)\n", + " if grid isa ImmersedBoundaryGrid\n", + " bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1))\n", + " return bh .>= 0\n", + " else\n", + " return falses(size(grid, 1), size(grid, 2))\n", + " end\n", + "end\n", + "\n", + "function build_ocean_mask_3d(grid)\n", + " Nx, Ny, Nz = size(grid)\n", + " mask = ones(Nx, Ny, Nz)\n", + " if grid isa ImmersedBoundaryGrid\n", + " bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1))\n", + " zc = znodes(grid, Center())\n", + " for k in 1:Nz, j in 1:Ny, i in 1:Nx\n", + " zc[k] < bh[i, j] && (mask[i, j, k] = 0.0)\n", + " end\n", + " end\n", + " return mask\n", + "end\n", + "\n", + "mask_land!(f, land) = (f[land] .= NaN; f)\n", + "\n", + "function panel!(fig, pos, data;\n", + " title=\"\", colormap=:thermal,\n", + " colorrange=nothing, label=\"\",\n", + " nan_color=:lightgray)\n", + " ax = Axis(fig[pos...]; title)\n", + " kw = isnothing(colorrange) ? (;) : (; colorrange)\n", + " hm = heatmap!(ax, data; colormap, nan_color, kw...)\n", + " Colorbar(fig[pos[1], pos[2]+1], hm; label)\n", + " return ax\n", + "end\n", + "\n", + "function sidebyside!(fig, row, left, right;\n", + " title_l=\"\", title_r=\"\",\n", + " cmap_l=:thermal, cmap_r=:balance,\n", + " cr_l=nothing, cr_r=nothing,\n", + " label_l=\"\", label_r=\"\")\n", + " panel!(fig, [row, 1], left; title=title_l, colormap=cmap_l, colorrange=cr_l, label=label_l)\n", + " panel!(fig, [row, 3], right; title=title_r, colormap=cmap_r, colorrange=cr_r, label=label_r)\n", + "end" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load surface diagnostics" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "surface_file = find_first_file(run_dir, prefix, \"surface\")\n", + "@info \"Surface file: $surface_file\"\n", + "\n", + "tos = FieldTimeSeries(surface_file, \"tos\"; backend = OnDisk())\n", + "sos = FieldTimeSeries(surface_file, \"sos\"; backend = OnDisk())\n", + "zos = FieldTimeSeries(surface_file, \"zos\"; backend = OnDisk())\n", + "mld_fts = FieldTimeSeries(surface_file, \"mlotst\"; backend = OnDisk())\n", + "hfds = FieldTimeSeries(surface_file, \"hfds\"; backend = OnDisk())\n", + "wfo = FieldTimeSeries(surface_file, \"wfo\"; backend = OnDisk())\n", + "sic = FieldTimeSeries(surface_file, \"siconc\"; backend = OnDisk())\n", + "\n", + "grid = tos.grid\n", + "Nx, Ny, Nz = size(grid)\n", + "land = build_land_mask(grid)\n", + "@info \"Grid: $Nx x $Ny x $Nz | $(length(tos.times)) surface snapshots\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "SST = dropdims(compute_time_mean(tos); dims=3)\n", + "SSS = dropdims(compute_time_mean(sos); dims=3)\n", + "SSH = dropdims(compute_time_mean(zos); dims=3)\n", + "MLD_avg = dropdims(compute_time_mean(mld_fts); dims=3)\n", + "HF = dropdims(compute_time_mean(hfds); dims=3)\n", + "FW = dropdims(compute_time_mean(wfo); dims=3)\n", + "SIC = dropdims(compute_time_mean(sic); dims=3)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## WOA comparison" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "T_woa = Field(Metadatum(:temperature; dataset = WOAAnnual()), CPU())\n", + "S_woa = Field(Metadatum(:salinity; dataset = WOAAnnual()), CPU())\n", + "\n", + "T_interp = CenterField(grid)\n", + "S_interp = CenterField(grid)\n", + "interpolate!(T_interp, T_woa)\n", + "interpolate!(S_interp, S_woa)\n", + "\n", + "# Full 3-D WOA on model grid (reused later for zonal-mean bias)\n", + "T_woa_on_grid = Array(interior(T_interp))\n", + "S_woa_on_grid = Array(interior(S_interp))\n", + "\n", + "SST_woa = T_woa_on_grid[:, :, Nz]\n", + "SSS_woa = S_woa_on_grid[:, :, Nz]\n", + "\n", + "δSST = SST .- SST_woa\n", + "δSSS = SSS .- SSS_woa\n", + "\n", + "for f in (SST, SSS, SSH, MLD_avg, HF, FW, SIC, δSST, δSSS)\n", + " mask_land!(f, land)\n", + "end" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 1 -- SST and WOA bias (cf. OM4 Fig. 3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = Figure(size = (1600, 550), fontsize = 14)\n", + "sidebyside!(fig, 1, SST, δSST;\n", + " title_l = \"Time-mean SST\", title_r = \"SST - WOA\",\n", + " cmap_l = :thermal, cr_l = (-2, 32),\n", + " cmap_r = :balance, cr_r = (-5, 5),\n", + " label_l = \"deg C\", label_r = \"deg C\")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 2 -- SSS and WOA bias (cf. OM4 Fig. 4)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = Figure(size = (1600, 550), fontsize = 14)\n", + "sidebyside!(fig, 1, SSS, δSSS;\n", + " title_l = \"Time-mean SSS\", title_r = \"SSS - WOA\",\n", + " cmap_l = :haline, cr_l = (30, 38),\n", + " cmap_r = :balance, cr_r = (-3, 3),\n", + " label_l = \"PSU\", label_r = \"PSU\")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 3 -- SSH (cf. OM4 Fig. 5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = Figure(size = (900, 500), fontsize = 14)\n", + "panel!(fig, [1, 1], SSH;\n", + " title = \"Time-mean SSH\", colormap = :balance,\n", + " colorrange = (-2, 2), label = \"m\")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 4 -- MLD March / September (cf. OM4 Figs. 6-7)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "MLD_mar = compute_monthly_mean(mld_fts, [3])\n", + "MLD_sep = compute_monthly_mean(mld_fts, [9])\n", + "\n", + "fig = Figure(size = (1600, 550), fontsize = 14)\n", + "if !isnothing(MLD_mar)\n", + " d = dropdims(MLD_mar; dims=3); mask_land!(d, land)\n", + " panel!(fig, [1, 1], d; title=\"MLD -- March\",\n", + " colormap=Reverse(:deep), colorrange=(0, 500), label=\"m\")\n", + "end\n", + "if !isnothing(MLD_sep)\n", + " d = dropdims(MLD_sep; dims=3); mask_land!(d, land)\n", + " panel!(fig, [1, 3], d; title=\"MLD -- September\",\n", + " colormap=Reverse(:deep), colorrange=(0, 500), label=\"m\")\n", + "end\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 5 -- Sea-ice concentration March / September (cf. OM4 Figs. 9-10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "SIC_mar = compute_monthly_mean(sic, [3])\n", + "SIC_sep = compute_monthly_mean(sic, [9])\n", + "\n", + "fig = Figure(size = (1600, 550), fontsize = 14)\n", + "if !isnothing(SIC_mar)\n", + " d = dropdims(SIC_mar; dims=3); mask_land!(d, land)\n", + " panel!(fig, [1, 1], d; title=\"Sea-ice conc. -- March\",\n", + " colormap=:ice, colorrange=(0, 1), label=\"fraction\")\n", + "end\n", + "if !isnothing(SIC_sep)\n", + " d = dropdims(SIC_sep; dims=3); mask_land!(d, land)\n", + " panel!(fig, [1, 3], d; title=\"Sea-ice conc. -- September\",\n", + " colormap=:ice, colorrange=(0, 1), label=\"fraction\")\n", + "end\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 6 -- Surface fluxes (cf. OM4 Figs. 11-12)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = Figure(size = (1600, 550), fontsize = 14)\n", + "sidebyside!(fig, 1, HF, FW;\n", + " title_l = \"Net surface heat flux\",\n", + " title_r = \"Net freshwater flux\",\n", + " cmap_l = :balance, cr_l = (-200, 200),\n", + " cmap_r = :balance, cr_r = (-1e-4, 1e-4),\n", + " label_l = \"W/m^2\", label_r = \"kg/m^2/s\")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 7 -- Global-mean T and S drift (cf. OM4 Fig. 13)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "avg_file = find_first_file(run_dir, prefix, \"averages\")\n", + "\n", + "tosga_fts = FieldTimeSeries(avg_file, \"tosga\"; backend = OnDisk())\n", + "soga_fts = FieldTimeSeries(avg_file, \"soga\"; backend = OnDisk())\n", + "\n", + "tosga = [Array(interior(tosga_fts[n]))[1] for n in 1:length(tosga_fts.times)]\n", + "soga = [Array(interior(soga_fts[n]))[1] for n in 1:length(soga_fts.times)]\n", + "t_years = tosga_fts.times ./ (365.25 * 24 * 3600)\n", + "\n", + "fig = Figure(size = (1200, 450), fontsize = 14)\n", + "ax = Axis(fig[1, 1]; xlabel=\"Time (years)\", ylabel=\"dT (deg C)\",\n", + " title=\"Global-mean temperature drift\")\n", + "lines!(ax, t_years, tosga .- tosga[1]; color=:firebrick)\n", + "\n", + "ax = Axis(fig[1, 2]; xlabel=\"Time (years)\", ylabel=\"dS (PSU)\",\n", + " title=\"Global-mean salinity drift\")\n", + "lines!(ax, t_years, soga .- soga[1]; color=:royalblue)\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 8 -- Horizontal-mean T and S profiles (cf. OM4 Fig. 14)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "to_h_fts = FieldTimeSeries(avg_file, \"to_h\"; backend = OnDisk())\n", + "so_h_fts = FieldTimeSeries(avg_file, \"so_h\"; backend = OnDisk())\n", + "\n", + "T_prof = vec(compute_time_mean(to_h_fts))\n", + "S_prof = vec(compute_time_mean(so_h_fts))\n", + "z = collect(znodes(grid, Center()))\n", + "\n", + "fig = Figure(size = (1000, 600), fontsize = 14)\n", + "ax = Axis(fig[1, 1]; xlabel=\"Temperature (deg C)\", ylabel=\"Depth (m)\",\n", + " title=\"Horizontal-mean temperature\")\n", + "lines!(ax, T_prof, z; color=:firebrick)\n", + "ylims!(ax, (-5500, 0))\n", + "\n", + "ax = Axis(fig[1, 2]; xlabel=\"Salinity (PSU)\", ylabel=\"Depth (m)\",\n", + " title=\"Horizontal-mean salinity\")\n", + "lines!(ax, S_prof, z; color=:royalblue)\n", + "ylims!(ax, (-5500, 0))\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Zonal-mean sections\n", + "\n", + "Regrid the 3-D time-mean fields from the native (tripolar / ORCA) grid\n", + "to a regular 1-degree latitude-longitude grid via `ConservativeRegridding`,\n", + "then average over longitude to obtain latitude-depth sections.\n", + "An ocean mask is carried through the regridding so that immersed cells\n", + "do not contaminate the zonal averages." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Target lat-lon grid (1 degree)\n", + "Nlon, Nlat = 360, 180\n", + "latlon_grid = LatitudeLongitudeGrid(CPU();\n", + " size = (Nlon, Nlat, 1),\n", + " longitude = (0, 360),\n", + " latitude = (-90, 90),\n", + " z = (0, 1))\n", + "\n", + "src_f = Field{Center, Center, Nothing}(grid)\n", + "dst_f = Field{Center, Center, Nothing}(latlon_grid)\n", + "\n", + "@info \"Building conservative regridder (this may take a few minutes)...\"\n", + "regridder = ConservativeRegridding.Regridder(dst_f, src_f; progress = true)\n", + "@info \"Regridder ready.\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Regrid a 3-D field level-by-level and compute the\n", + "ocean-area-weighted zonal mean using a carried ocean mask.\"\"\"\n", + "function compute_zonal_mean(data_3d, ocean_mask_3d, regridder, Nlon, Nlat)\n", + " Nz = size(data_3d, 3)\n", + " zonal = fill(NaN, Nlat, Nz)\n", + " dst_data = zeros(Nlon * Nlat)\n", + " dst_mask = zeros(Nlon * Nlat)\n", + " areas = regridder.dst_areas\n", + "\n", + " for k in 1:Nz\n", + " ConservativeRegridding.regrid!(dst_data, regridder,\n", + " vec(data_3d[:, :, k] .* ocean_mask_3d[:, :, k]))\n", + " ConservativeRegridding.regrid!(dst_mask, regridder,\n", + " vec(ocean_mask_3d[:, :, k]))\n", + "\n", + " data_sum = reshape(dst_data .* areas, Nlon, Nlat)\n", + " mask_sum = reshape(dst_mask .* areas, Nlon, Nlat)\n", + "\n", + " for j in 1:Nlat\n", + " m = sum(@view mask_sum[:, j])\n", + " m > 0 && (zonal[j, k] = sum(@view data_sum[:, j]) / m)\n", + " end\n", + " end\n", + " return zonal\n", + "end" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "@info \"Loading 3-D field time series...\"\n", + "fields_file = find_first_file(run_dir, prefix, \"fields\")\n", + "\n", + "to_fts = FieldTimeSeries(fields_file, \"to\"; backend = OnDisk())\n", + "so_fts = FieldTimeSeries(fields_file, \"so\"; backend = OnDisk())\n", + "bo_fts = FieldTimeSeries(fields_file, \"bo\"; backend = OnDisk())\n", + "\n", + "@info \"$(length(to_fts.times)) field snapshots -- computing time means...\"\n", + "T_mean = compute_time_mean(to_fts)\n", + "S_mean = compute_time_mean(so_fts)\n", + "b_mean = compute_time_mean(bo_fts)\n", + "\n", + "# Initial-condition buoyancy (first averaged snapshot)\n", + "b_init = Array(interior(bo_fts[1]))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ocean_mask = build_ocean_mask_3d(grid)\n", + "\n", + "@info \"Computing zonal means (model)...\"\n", + "T_zonal = compute_zonal_mean(T_mean, ocean_mask, regridder, Nlon, Nlat)\n", + "S_zonal = compute_zonal_mean(S_mean, ocean_mask, regridder, Nlon, Nlat)\n", + "b_zonal = compute_zonal_mean(b_mean, ocean_mask, regridder, Nlon, Nlat)\n", + "\n", + "@info \"Computing zonal means (WOA / initial conditions)...\"\n", + "T_woa_zonal = compute_zonal_mean(T_woa_on_grid, ocean_mask, regridder, Nlon, Nlat)\n", + "S_woa_zonal = compute_zonal_mean(S_woa_on_grid, ocean_mask, regridder, Nlon, Nlat)\n", + "b_init_zonal = compute_zonal_mean(b_init, ocean_mask, regridder, Nlon, Nlat)\n", + "\n", + "# Differences from initial conditions\n", + "δT_zonal = T_zonal .- T_woa_zonal\n", + "δS_zonal = S_zonal .- S_woa_zonal\n", + "δb_zonal = b_zonal .- b_init_zonal\n", + "\n", + "# Axes\n", + "φ = collect(φnodes(latlon_grid, Center()))\n", + "z = collect(znodes(grid, Center()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 9 -- Zonal-mean T, S, b" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = Figure(size = (1800, 500), fontsize = 14)\n", + "\n", + "ax = Axis(fig[1, 1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal-mean temperature\")\n", + "hm = heatmap!(ax, φ, z, T_zonal; colormap=:thermal, colorrange=(-2, 30), nan_color=:lightgray)\n", + "Colorbar(fig[1, 2], hm; label=\"deg C\")\n", + "ylims!(ax, (-5500, 0))\n", + "\n", + "ax = Axis(fig[1, 3]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal-mean salinity\")\n", + "hm = heatmap!(ax, φ, z, S_zonal; colormap=:haline, colorrange=(33, 37), nan_color=:lightgray)\n", + "Colorbar(fig[1, 4], hm; label=\"PSU\")\n", + "ylims!(ax, (-5500, 0))\n", + "\n", + "ax = Axis(fig[1, 5]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal-mean buoyancy\")\n", + "hm = heatmap!(ax, φ, z, b_zonal; colormap=:balance, nan_color=:lightgray)\n", + "Colorbar(fig[1, 6], hm; label=\"m/s^2\")\n", + "ylims!(ax, (-5500, 0))\n", + "\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 10 -- Zonal-mean drift from initial conditions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = Figure(size = (1800, 500), fontsize = 14)\n", + "\n", + "ax = Axis(fig[1, 1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal T - WOA\")\n", + "hm = heatmap!(ax, φ, z, δT_zonal; colormap=:balance, colorrange=(-5, 5), nan_color=:lightgray)\n", + "Colorbar(fig[1, 2], hm; label=\"deg C\")\n", + "ylims!(ax, (-5500, 0))\n", + "\n", + "ax = Axis(fig[1, 3]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal S - WOA\")\n", + "hm = heatmap!(ax, φ, z, δS_zonal; colormap=:balance, colorrange=(-1, 1), nan_color=:lightgray)\n", + "Colorbar(fig[1, 4], hm; label=\"PSU\")\n", + "ylims!(ax, (-5500, 0))\n", + "\n", + "ax = Axis(fig[1, 5]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal b - b(t=0)\")\n", + "hm = heatmap!(ax, φ, z, δb_zonal; colormap=:balance, nan_color=:lightgray)\n", + "Colorbar(fig[1, 6], hm; label=\"m/s^2\")\n", + "ylims!(ax, (-5500, 0))\n", + "\n", + "fig" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/experiments/OMIPSimulations/scripts/launch.sh b/experiments/OMIPSimulations/scripts/launch.sh new file mode 100755 index 000000000..06efd78b5 --- /dev/null +++ b/experiments/OMIPSimulations/scripts/launch.sh @@ -0,0 +1,271 @@ +#!/bin/bash +# Submit an OMIP simulation to SLURM. +# +# Usage: +# ./launch.sh halfdegree # half-degree OMIP +# ./launch.sh tenthdegree # 1/10-degree OMIP +# ./launch.sh orca # ORCA OMIP +# PROFILE=true ./launch.sh orca # nsys-profile run +# NODE=2904 ./launch.sh orca # pin to a specific node +# +# Credentials (e.g. ECCO_USERNAME, ECCO_WEBDAV_PASSWORD) are NOT set +# here. Export them in your shell or source a private file before +# launching, e.g.: +# +# source ~/.ecco_credentials && ./launch.sh orca + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: ./launch.sh [extra sbatch args...] + +Configurations: + halfdegree Half-degree TripolarGrid (default fluxes) + orca ORCA grid (default fluxes) + orca_corrected ORCA grid with corrected COARE 3.6 fluxes + orca_ncar ORCA grid with OMIP-2/NCAR bulk formulae + tenthdegree 1/10-degree TripolarGrid (4 GPUs) + +Examples: + ./launch.sh orca + ./launch.sh orca_corrected + ./launch.sh orca_ncar + PROFILE=true ./launch.sh orca + NODE=2904 ./launch.sh orca +USAGE +} + +CONFIG="${1:-}" +if [[ -z "$CONFIG" ]]; then + usage + exit 1 +fi +shift || true + +case "$CONFIG" in + halfdegree) + CONFIG="halfdegree" + ;; + half_degree) + CONFIG="halfdegree" + ;; + orca|tenthdegree) ;; + orca_corrected|orca_ncar|orca_corrected_snow|orca_ncar_snow) ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Error: unknown configuration '$CONFIG'" >&2 + usage + exit 1 + ;; +esac + +REPORT_NAME="${REPORT_NAME:-${CONFIG}_report}" +JOB_NAME="${JOB_NAME:-$CONFIG}" +GPUS_PER_NODE=1 + +case "$CONFIG" in + tenthdegree) + GPUS_PER_NODE=4 + ;; +esac + +SBATCH_ARGS=() +NODE="${NODE:-2904}" +if [[ -n "${NODE}" ]]; then + SBATCH_ARGS+=(-w "node${NODE}") +fi +SBATCH_ARGS+=(--gres="gpu:${GPUS_PER_NODE}") + +if [[ "${PROFILE:-false}" == "true" ]]; then + SBATCH_ARGS+=(-o "${CONFIG}_profile.out") + SBATCH_ARGS+=(-e "${CONFIG}_profile.err") + SBATCH_ARGS+=(-J "${JOB_NAME}_profile") + SBATCH_ARGS+=(--export="ALL,PROFILE=true,REPORT_NAME=${REPORT_NAME},CONFIG=${CONFIG}") +else + SBATCH_ARGS+=(-o "${CONFIG}.out") + SBATCH_ARGS+=(-e "${CONFIG}.err") + SBATCH_ARGS+=(-J "$JOB_NAME") + SBATCH_ARGS+=(--export="ALL,CONFIG=${CONFIG}") +fi + +sbatch "${SBATCH_ARGS[@]}" "$@" <<'EOF' +#!/bin/bash +#SBATCH -N 1 +#SBATCH --ntasks-per-node=1 +#SBATCH -p pi_raffaele +#SBATCH --time=120:00:00 +#SBATCH --mem=150GB + +source /etc/profile.d/modules.sh +module load nvhpc + +JULIA="${JULIA:-$HOME/julia-1.12.5/bin/julia}" + +# Build the Julia expression from the selected config. +case "$CONFIG" in + halfdegree) + JULIA_EXPR='using OMIPSimulations +using Oceananigans +using Oceananigans.Units +using CUDA + +sim = omip_simulation(:halfdegree; + arch = GPU(), + Nz = 70, + depth = 5500, + Δt = 25minutes, + output_dir = "halfdegree_run", + filename_prefix = "halfdegree") + +sim.stop_time = 300 * 365days +run!(sim, pickup=:latest)' + ;; + orca) + JULIA_EXPR='using OMIPSimulations +using Oceananigans +using Oceananigans.Units +using CUDA + +sim = omip_simulation(:orca; + arch = GPU(), + Nz = 70, + depth = 5500, + κ_skew = 500, + κ_symmetric = 250, + biharmonic_timescale = 10days, + Δt = 30minutes, + output_dir = "orca_run", + filename_prefix = "orca") + +sim.stop_time = 300 * 365days +run!(sim; pickup=false)' + ;; + orca_corrected) + JULIA_EXPR='using OMIPSimulations +using Oceananigans +using Oceananigans.Units +using CUDA + +sim = omip_simulation(:orca; + arch = GPU(), + Nz = 70, + depth = 5500, + κ_skew = 500, + κ_symmetric = 250, + biharmonic_timescale = 10days, + Δt = 30minutes, + flux_configuration = :corrected, + output_dir = "orca_corrected_run", + filename_prefix = "orca_corrected") + +sim.stop_time = 300 * 365days +run!(sim; pickup = true)' + ;; + orca_ncar) + JULIA_EXPR='using OMIPSimulations +using Oceananigans +using Oceananigans.Units +using CUDA + +sim = omip_simulation(:orca; + arch = GPU(), + Nz = 70, + depth = 5500, + κ_skew = 500, + κ_symmetric = 250, + biharmonic_timescale = 10days, + Δt = 30minutes, + flux_configuration = :ncar, + output_dir = "orca_ncar_run", + filename_prefix = "orca_ncar") + +sim.stop_time = 300 * 365days +run!(sim; pickup = true)' + ;; + orca_corrected_snow) + JULIA_EXPR='using OMIPSimulations +using Oceananigans +using Oceananigans.Units +using CUDA + +sim = omip_simulation(:orca; + arch = GPU(), + Nz = 70, + depth = 5500, + κ_skew = 500, + κ_symmetric = 250, + biharmonic_timescale = 10days, + Δt = 30minutes, + flux_configuration = :corrected, + with_snow = true, + output_dir = "orca_corrected_snow_run", + filename_prefix = "orca_corrected_snow") + +sim.stop_time = 300 * 365days +run!(sim; pickup = true)' + ;; + orca_ncar_snow) + JULIA_EXPR='using OMIPSimulations +using Oceananigans +using Oceananigans.Units +using CUDA + +sim = omip_simulation(:orca; + arch = GPU(), + Nz = 70, + depth = 5500, + κ_skew = 500, + κ_symmetric = 250, + biharmonic_timescale = 10days, + Δt = 30minutes, + flux_configuration = :ncar, + with_snow = true, + output_dir = "orca_ncar_snow_run", + filename_prefix = "orca_ncar_snow") + +sim.stop_time = 300 * 365days +run!(sim; pickup = true)' + ;; + tenthdegree) + JULIA_EXPR='using OMIPSimulations +using Oceananigans +using Oceananigans.Units +using Oceananigans.DistributedComputations +using CUDA + +# TODO: adjust this block for the 1/10-degree setup details you want. +sim = omip_simulation(:tenthdegree; + arch = Distributed(GPU(), partition=Partition(1, 4)), + Nz = 100, + depth = 5500, + κ_skew = nothing, + κ_symmetric = nothing, + biharmonic_timescale = nothing, + Δt = 8minutes, + output_dir = "tenthdegree_run", + filename_prefix = "tenthdegree", + file_splitting_interval = 180days) + +sim.stop_time = 91days +run!(sim) + +sim.Δt = 15minutes +sim.stop_time = 300 * 365days +run!(sim; pickup = true)' + ;; +esac + +if [[ "${PROFILE:-false}" == "true" ]]; then + echo "Profiling ${CONFIG} configuration -> ${REPORT_NAME}" + nsys profile --trace=cuda \ + --output="$REPORT_NAME" \ + --force-overwrite true \ + "$JULIA" --project=.. --check-bounds=no -e "$JULIA_EXPR" +else + "$JULIA" --project=.. --check-bounds=no -e "$JULIA_EXPR" +fi +EOF diff --git a/experiments/OMIPSimulations/scripts/store.sh b/experiments/OMIPSimulations/scripts/store.sh new file mode 100755 index 000000000..8a0d301ff --- /dev/null +++ b/experiments/OMIPSimulations/scripts/store.sh @@ -0,0 +1,194 @@ +#!/bin/bash +# Move completed OMIP outputs from a live run folder to +# $DATA/OMIP-data/_run while a launch.sh job is still running. +# +# Logic: +# - Part files (*_part.jld2): the highest N per filename group is +# still being written by the running sim, so it is left in place; +# all older parts are moved. +# - Checkpoint files (*_checkpoint_iteration.jld2): the highest +# iteration is kept locally so `run!(sim; pickup=true)` still works; +# older checkpoints are moved. +# - Anything else in the run folder is left untouched. +# +# Must be run from the same directory as launch.sh (i.e. this scripts +# folder) so that _run resolves the same way it does for the +# running simulation. +# +# Usage: +# ./store.sh halfdegree +# ./store.sh tenthdegree +# ./store.sh orca +# +# DATA must be set in the calling shell (it is propagated to the +# sbatch job via --export=ALL). + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: ./store.sh [extra sbatch args...] + +Examples: + ./store.sh halfdegree + ./store.sh tenthdegree + ./store.sh orca +USAGE +} + +CONFIG="${1:-}" +if [[ -z "$CONFIG" ]]; then + usage + exit 1 +fi +shift || true + +case "$CONFIG" in + halfdegree|half_degree) + CONFIG="halfdegree" + ;; + orca|tenthdegree|orca_corrected|orca_ncar|orca_corrected_snow|orca_ncar_snow) ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Error: unknown configuration '$CONFIG'" >&2 + usage + exit 1 + ;; +esac + +if [[ -z "${DATA:-}" ]]; then + echo "Error: DATA environment variable is not set" >&2 + exit 1 +fi + +RUN_DIR="${CONFIG}_run" +DEST_DIR="${DATA}/OMIP-data/${RUN_DIR}" + +if [[ ! -d "$RUN_DIR" ]]; then + echo "Error: run directory '$RUN_DIR' not found in $(pwd)" >&2 + echo " (store.sh must be run from the same directory as launch.sh)" >&2 + exit 1 +fi + +JOB_NAME="${JOB_NAME:-store_${CONFIG}}" + +SBATCH_ARGS=() +SBATCH_ARGS+=(-o "store_${CONFIG}.out") +SBATCH_ARGS+=(-e "store_${CONFIG}.err") +SBATCH_ARGS+=(-J "$JOB_NAME") +SBATCH_ARGS+=(--export="ALL,CONFIG=${CONFIG},RUN_DIR=${RUN_DIR},DEST_DIR=${DEST_DIR}") + +sbatch "${SBATCH_ARGS[@]}" "$@" <<'EOF' +#!/bin/bash +#SBATCH -N 1 +#SBATCH --ntasks-per-node=1 +#SBATCH -p sched_mit_raffaele +#SBATCH --time=24:00:00 +#SBATCH --mem=4GB + +set -euo pipefail + +echo "Storing ${CONFIG} outputs" +echo " source: $(pwd)/${RUN_DIR}" +echo " dest: ${DEST_DIR}" + +if [[ ! -d "$RUN_DIR" ]]; then + echo "Error: run directory '$RUN_DIR' does not exist in $(pwd)" >&2 + exit 1 +fi + +mkdir -p "$DEST_DIR" + +shopt -s nullglob + +# Infinite loop +while true +do + +# ------------------------------------------------------------------ +# Part files: *_part.jld2 +# The highest N per filename group is still being written, so it is +# left in place; everything older is moved. +# ------------------------------------------------------------------ +declare -A max_part +for f in "$RUN_DIR"/*_part[0-9]*.jld2; do + base=$(basename "$f") + tail="${base##*_part}" + n="${tail%.jld2}" + [[ "$n" =~ ^[0-9]+$ ]] || continue + group="${base%_part${n}.jld2}" + current="${max_part[$group]:-0}" + if (( n > current )); then + max_part[$group]=$n + fi +done + +moved_parts=0 +kept_parts=0 + +for f in "$RUN_DIR"/*_part[0-9]*.jld2; do + base=$(basename "$f") + tail="${base##*_part}" + n="${tail%.jld2}" + [[ "$n" =~ ^[0-9]+$ ]] || continue + group="${base%_part${n}.jld2}" + max="${max_part[$group]:-0}" + if (( n == max )); then + echo "skip (active): ${base}" + kept_parts=$((kept_parts + 1)) + continue + fi + echo "move: ${base}" + mv -- "$f" "$DEST_DIR/" + moved_parts=$((moved_parts + 1)) +done + +# ------------------------------------------------------------------ +# Checkpoint files: *_iteration.jld2 +# The latest iteration per group is required for run!(sim; pickup=true) +# so it is kept locally; earlier checkpoints are moved. +# ------------------------------------------------------------------ +declare -A max_ckpt +for f in "$RUN_DIR"/*_iteration[0-9]*.jld2; do + base=$(basename "$f") + tail="${base##*_iteration}" + n="${tail%.jld2}" + [[ "$n" =~ ^[0-9]+$ ]] || continue + group="${base%_iteration${n}.jld2}" + current="${max_ckpt[$group]:-0}" + if (( n > current )); then + max_ckpt[$group]=$n + fi +done + +moved_ckpts=0 +kept_ckpts=0 +for f in "$RUN_DIR"/*_iteration[0-9]*.jld2; do + base=$(basename "$f") + tail="${base##*_iteration}" + n="${tail%.jld2}" + [[ "$n" =~ ^[0-9]+$ ]] || continue + group="${base%_iteration${n}.jld2}" + max="${max_ckpt[$group]:-0}" + if (( n == max )); then + echo "skip (latest): ${base}" + kept_ckpts=$((kept_ckpts + 1)) + continue + fi + echo "move: ${base}" + mv -- "$f" "$DEST_DIR/" + moved_ckpts=$((moved_ckpts + 1)) +done + +echo "Done. Moved ${moved_parts} part file(s) (kept ${kept_parts})," \ + "moved ${moved_ckpts} checkpoint file(s) (kept ${kept_ckpts})." + +sleep 3600 # sleep for 1 hour + +echo "Sleeping for 1 hour" + +done +EOF diff --git a/experiments/OMIPSimulations/scripts/visualize_omip.ipynb b/experiments/OMIPSimulations/scripts/visualize_omip.ipynb new file mode 100644 index 000000000..455a54f5d --- /dev/null +++ b/experiments/OMIPSimulations/scripts/visualize_omip.ipynb @@ -0,0 +1,502 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# OMIP Simulation Diagnostics -- Multi-case comparison\n\nPost-processing visualization loosely following Adcroft et al. (2019),\n*The GFDL Global Ocean and Sea Ice Model OM4.0*, JAMES.\n\nDefine cases, `start_time`, `stop_time` below; every figure shows all cases side by side." + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Configuration" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Configuration\n\ncases = [\n (run_dir = \"halfdegree_run\", prefix = \"halfdegree\", label = \"Half-degree\"),\n (run_dir = \"orca_run\", prefix = \"orca\", label = \"ORCA\"),\n]\n\nstart_time = 0\nstop_time = Inf" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Imports" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "using CairoMakie\nusing Statistics\nusing Dates\nusing Downloads\nusing DelimitedFiles\nusing Oceananigans\nusing Oceananigans.Grids: znodes, φnodes, φnode\nusing Oceananigans.Fields: interpolate!\nusing ConservativeRegridding\nusing NumericalEarth\nusing NumericalEarth.DataWrangling: Metadatum\nusing NumericalEarth.DataWrangling.WOA: WOAAnnual" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Helpers" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "function find_first_file(run_dir, prefix, group)\n tag = \"$(prefix)_$(group)\"\n candidates = filter(f -> startswith(f, tag) && endswith(f, \".jld2\") &&\n !contains(f, \"checkpoint\"), readdir(run_dir))\n isempty(candidates) && error(\"No $group files for prefix '$prefix' in $run_dir\")\n filename = first(sort(candidates))\n basename_no_part = replace(filename, r\"_part\\d+\" => \"\")\n return joinpath(run_dir, basename_no_part)\nend\n\nfunction in_window(fts; start_time = 0, stop_time = Inf)\n return findall(t -> start_time <= t <= stop_time, fts.times)\nend\n\nfunction compute_time_mean(fts; start_time = 0, stop_time = Inf)\n idx = in_window(fts; start_time, stop_time)\n isempty(idx) && error(\"No snapshots in [$start_time, $stop_time]\")\n avg = zeros(size(Array(interior(fts[first(idx)]))))\n for n in idx\n avg .+= Array(interior(fts[n]))\n end\n return avg ./ length(idx)\nend\n\nfunction compute_monthly_mean(fts, target_months;\n start_time = 0, stop_time = Inf,\n reference_date = DateTime(1958, 1, 1))\n dates = [reference_date + Second(round(Int, t)) for t in fts.times]\n idx = findall(i -> month(dates[i]) in target_months &&\n start_time <= fts.times[i] <= stop_time,\n eachindex(dates))\n isempty(idx) && return nothing\n avg = zeros(size(Array(interior(fts[first(idx)]))))\n for n in idx\n avg .+= Array(interior(fts[n]))\n end\n return avg ./ length(idx)\nend\n\nfunction build_land_mask(grid)\n if grid isa ImmersedBoundaryGrid\n bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1))\n return bh .>= 0\n else\n return falses(size(grid, 1), size(grid, 2))\n end\nend\n\nfunction build_ocean_mask_3d(grid)\n Nx, Ny, Nz = size(grid)\n mask = ones(Nx, Ny, Nz)\n if grid isa ImmersedBoundaryGrid\n bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1))\n zc = znodes(grid, Center())\n for k in 1:Nz, j in 1:Ny, i in 1:Nx\n zc[k] < bh[i, j] && (mask[i, j, k] = 0.0)\n end\n end\n return mask\nend\n\nmask_land!(f, land) = (f[land] .= NaN; f)\n\nfunction panel!(fig, pos, data;\n title=\"\", colormap=:thermal,\n colorrange=nothing, label=\"\",\n nan_color=:lightgray)\n ax = Axis(fig[pos...]; title)\n kw = isnothing(colorrange) ? (;) : (; colorrange)\n hm = heatmap!(ax, data; colormap, nan_color, kw...)\n Colorbar(fig[pos[1], pos[2]+1], hm; label)\n return ax\nend\n\ncase_colors = [:firebrick, :royalblue, :seagreen, :darkorange]" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load surface diagnostics" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "function load_surface_case(run_dir, prefix; start_time = 0, stop_time = Inf)\n surface_file = find_first_file(run_dir, prefix, \"surface\")\n @info \" surface: $surface_file\"\n\n tos = FieldTimeSeries(surface_file, \"tos\"; backend = OnDisk())\n sos = FieldTimeSeries(surface_file, \"sos\"; backend = OnDisk())\n zos = FieldTimeSeries(surface_file, \"zos\"; backend = OnDisk())\n mld_fts = FieldTimeSeries(surface_file, \"mlotst\"; backend = OnDisk())\n hfds = FieldTimeSeries(surface_file, \"hfds\"; backend = OnDisk())\n wfo = FieldTimeSeries(surface_file, \"wfo\"; backend = OnDisk())\n sic = FieldTimeSeries(surface_file, \"siconc\"; backend = OnDisk())\n zossq = FieldTimeSeries(surface_file, \"zossq\"; backend = OnDisk())\n\n grid = tos.grid\n Nx, Ny, Nz = size(grid)\n land = build_land_mask(grid)\n\n @info \" averaging window: [$(start_time / (365.25*86400)), $(stop_time / (365.25*86400))] years\"\n\n SST = dropdims(compute_time_mean(tos; start_time, stop_time); dims=3)\n SSS = dropdims(compute_time_mean(sos; start_time, stop_time); dims=3)\n SSH = dropdims(compute_time_mean(zos; start_time, stop_time); dims=3)\n HF = dropdims(compute_time_mean(hfds; start_time, stop_time); dims=3)\n FW = dropdims(compute_time_mean(wfo; start_time, stop_time); dims=3)\n SIC_mean = dropdims(compute_time_mean(sic; start_time, stop_time); dims=3)\n\n SSH_sq = dropdims(compute_time_mean(zossq; start_time, stop_time); dims=3)\n SSH_var = SSH_sq .- SSH .^ 2\n\n MLD_monthly = [compute_monthly_mean(mld_fts, [m]; start_time, stop_time) for m in 1:12]\n avail = findall(!isnothing, MLD_monthly)\n MLD_stack = cat([dropdims(MLD_monthly[m]; dims=3) for m in avail]...; dims=3)\n MLD_min = dropdims(minimum(MLD_stack; dims=3); dims=3)\n MLD_max = dropdims(maximum(MLD_stack; dims=3); dims=3)\n\n SIC_mar = compute_monthly_mean(sic, [3]; start_time, stop_time)\n SIC_sep = compute_monthly_mean(sic, [9]; start_time, stop_time)\n SIC_mar = isnothing(SIC_mar) ? nothing : dropdims(SIC_mar; dims=3)\n SIC_sep = isnothing(SIC_sep) ? nothing : dropdims(SIC_sep; dims=3)\n\n T_woa = Field(Metadatum(:temperature; dataset = WOAAnnual()), CPU())\n S_woa = Field(Metadatum(:salinity; dataset = WOAAnnual()), CPU())\n T_interp = CenterField(grid); interpolate!(T_interp, T_woa)\n S_interp = CenterField(grid); interpolate!(S_interp, S_woa)\n T_woa_on_grid = Array(interior(T_interp))\n S_woa_on_grid = Array(interior(S_interp))\n δSST = SST .- T_woa_on_grid[:, :, Nz]\n δSSS = SSS .- S_woa_on_grid[:, :, Nz]\n\n for f in (SST, SSS, SSH, HF, FW, SIC_mean, SSH_var, MLD_min, MLD_max, δSST, δSSS)\n mask_land!(f, land)\n end\n !isnothing(SIC_mar) && mask_land!(SIC_mar, land)\n !isnothing(SIC_sep) && mask_land!(SIC_sep, land)\n\n return (; grid, Nx, Ny, Nz, land, surface_file,\n SST, SSS, SSH, HF, FW, SIC_mean, SSH_var,\n MLD_min, MLD_max, SIC_mar, SIC_sep,\n δSST, δSSS, T_woa_on_grid, S_woa_on_grid)\nend\n\nD = Dict{String, Any}()\nlabels = [c.label for c in cases]\nfor c in cases\n @info \"Loading surface: $(c.label)...\"\n D[c.label] = load_surface_case(c.run_dir, c.prefix; start_time, stop_time)\nend" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 1: SST bias" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (800 * length(labels), 500), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].δSST;\n title = \"$lab: SST - WOA\", colormap = :balance,\n colorrange = (-5, 5), label = \"deg C\")\nend\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 2: SSS bias" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (800 * length(labels), 500), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].δSSS;\n title = \"$lab: SSS - WOA\", colormap = :balance,\n colorrange = (-3, 3), label = \"PSU\")\nend\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 3: SSH" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (800 * length(labels), 500), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].SSH;\n title = \"$lab: Time-mean SSH\", colormap = :balance,\n colorrange = (-2, 2), label = \"m\")\nend\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 4: MLD min/max" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (800 * length(labels), 900), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].MLD_min;\n title = \"$lab: Min MLD (summer)\",\n colormap = Reverse(:deep), colorrange = (0, 150), label = \"m\")\n panel!(fig, [2, 2i-1], D[lab].MLD_max;\n title = \"$lab: Max MLD (winter)\",\n colormap = Reverse(:deep), colorrange = (10, 3000), label = \"m\")\nend\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 5: Sea-ice concentration" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (800 * length(labels), 900), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n d = D[lab]\n !isnothing(d.SIC_mar) && panel!(fig, [1, 2i-1], d.SIC_mar;\n title = \"$lab: Sea-ice conc. March\",\n colormap = :ice, colorrange = (0, 1), label = \"fraction\")\n !isnothing(d.SIC_sep) && panel!(fig, [2, 2i-1], d.SIC_sep;\n title = \"$lab: Sea-ice conc. September\",\n colormap = :ice, colorrange = (0, 1), label = \"fraction\")\nend\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 6: Surface fluxes" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (800 * length(labels), 900), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].HF;\n title = \"$lab: Net heat flux\", colormap = :balance,\n colorrange = (-200, 200), label = \"W/m^2\")\n panel!(fig, [2, 2i-1], D[lab].FW;\n title = \"$lab: Net freshwater flux\", colormap = :balance,\n colorrange = (-1e-4, 1e-4), label = \"kg/m^2/s\")\nend\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 7: SSH variance" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (800 * length(labels), 500), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].SSH_var;\n title = \"$lab: SSH variance\", colormap = :magma,\n colorrange = (0, 0.05), label = \"m²\")\nend\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Sea-ice diagnostics" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "arctic_condition(i, j, k, grid, args...) = φnode(i, j, k, grid, Center(), Center(), Center()) > 0\nantarctic_condition(i, j, k, grid, args...) = φnode(i, j, k, grid, Center(), Center(), Center()) < 0\n\nfunction compute_ice_diagnostics(run_dir, prefix, grid;\n start_time = 0, stop_time = Inf,\n reference_date = DateTime(1958, 1, 1),\n extent_threshold = 0.15)\n surface_file = find_first_file(run_dir, prefix, \"surface\")\n thickness_fts = FieldTimeSeries(surface_file, \"sithick\"; backend = OnDisk())\n concentration_fts = FieldTimeSeries(surface_file, \"siconc\"; backend = OnDisk())\n\n Nt = length(thickness_fts.times)\n arctic_volume = zeros(Nt)\n antarctic_volume = zeros(Nt)\n arctic_extent = zeros(Nt)\n antarctic_extent = zeros(Nt)\n arctic_area = zeros(Nt)\n antarctic_area = zeros(Nt)\n snapshot_dates = [reference_date + Second(round(Int, t)) for t in thickness_fts.times]\n\n extent_mask = Field{Center, Center, Nothing}(grid)\n arctic_extent_integral = Field(Integral(extent_mask; condition = arctic_condition))\n antarctic_extent_integral = Field(Integral(extent_mask; condition = antarctic_condition))\n\n for n in 1:Nt\n concentration_field = concentration_fts[n]\n\n ice_volume_field = thickness_fts[n] * concentration_field\n arctic_vol_int = Field(Integral(ice_volume_field; condition = arctic_condition))\n antarctic_vol_int = Field(Integral(ice_volume_field; condition = antarctic_condition))\n compute!(arctic_vol_int); compute!(antarctic_vol_int)\n arctic_volume[n] = arctic_vol_int[1, 1, 1]\n antarctic_volume[n] = antarctic_vol_int[1, 1, 1]\n\n arctic_area_int = Field(Integral(concentration_field; condition = arctic_condition))\n antarctic_area_int = Field(Integral(concentration_field; condition = antarctic_condition))\n compute!(arctic_area_int); compute!(antarctic_area_int)\n arctic_area[n] = arctic_area_int[1, 1, 1]\n antarctic_area[n] = antarctic_area_int[1, 1, 1]\n\n concentration_data = Array(interior(concentration_field, :, :, 1))\n set!(extent_mask, Float64.(concentration_data .> extent_threshold))\n compute!(arctic_extent_integral); compute!(antarctic_extent_integral)\n arctic_extent[n] = arctic_extent_integral[1, 1, 1]\n antarctic_extent[n] = antarctic_extent_integral[1, 1, 1]\n end\n\n idx = findall(t -> start_time <= t <= stop_time, thickness_fts.times)\n months_used = month.(snapshot_dates[idx])\n monthly(field) = [mean(field[idx[months_used .== m]]) for m in 1:12]\n\n return (; arctic_volume, antarctic_volume,\n arctic_extent, antarctic_extent,\n arctic_area, antarctic_area, snapshot_dates,\n arctic_volume_monthly = monthly(arctic_volume),\n antarctic_volume_monthly = monthly(antarctic_volume),\n arctic_extent_monthly = monthly(arctic_extent),\n antarctic_extent_monthly = monthly(antarctic_extent),\n arctic_area_monthly = monthly(arctic_area),\n antarctic_area_monthly = monthly(antarctic_area))\nend\n\nICE = Dict{String, Any}()\nfor c in cases\n @info \"Computing sea-ice diagnostics for $(c.label)...\"\n ICE[c.label] = compute_ice_diagnostics(c.run_dir, c.prefix, D[c.label].grid; start_time, stop_time)\nend" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Download observational climatologies" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "piomas_url = \"https://psc.apl.uw.edu/wordpress/wp-content/uploads/schweiger/ice_volume/PIOMAS.monthly.Current.v2.1.csv\"\npiomas_raw = readdlm(Downloads.download(piomas_url), ','; skipstart=1)\npiomas_volume = Float64.(piomas_raw[:, 2:13])\npiomas_volume[piomas_volume .== -1] .= NaN\npiomas_monthly = vec(mapslices(x -> mean(filter(!isnan, x)), piomas_volume; dims=1))\n\nfunction download_nsidc(hemisphere)\n prefix = hemisphere == \"north\" ? \"N\" : \"S\"\n extent_monthly = zeros(12)\n area_monthly = zeros(12)\n for m in 1:12\n url = \"https://noaadata.apps.nsidc.org/NOAA/G02135/$(hemisphere)/monthly/data/$(prefix)_$(lpad(m, 2, '0'))_extent_v4.0.csv\"\n raw = readlines(Downloads.download(url))\n extents = Float64[]; areas = Float64[]\n for line in raw\n parts = split(line, ',')\n length(parts) >= 6 || continue\n ext = tryparse(Float64, strip(parts[5]))\n ar = tryparse(Float64, strip(parts[6]))\n (isnothing(ext) || ext == -9999) && continue\n (isnothing(ar) || ar == -9999) && continue\n push!(extents, ext); push!(areas, ar)\n end\n extent_monthly[m] = mean(extents)\n area_monthly[m] = mean(areas)\n end\n return (; extent_monthly, area_monthly)\nend\n\n@info \"Downloading NSIDC...\"\nnsidc_arctic = download_nsidc(\"north\")\nnsidc_antarctic = download_nsidc(\"south\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "month_names = [\"J\",\"F\",\"M\",\"A\",\"M\",\"J\",\"J\",\"A\",\"S\",\"O\",\"N\",\"D\"]\nm2_to_Mkm2 = 1e-12\nm3_to_1e3km3 = 1e-12" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 8: SIE" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (1200, 500), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Month\", ylabel=\"SIE (Million km²)\", title=\"Arctic SIE Climatology\", xticks=(1:12, month_names))\nlines!(ax, 1:12, nsidc_arctic.extent_monthly; color=:black, linewidth=2, label=\"NSIDC\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, 1:12, ICE[lab].arctic_extent_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:lb)\nax = Axis(fig[1, 2]; xlabel=\"Month\", ylabel=\"SIE (Million km²)\", title=\"Antarctic SIE Climatology\", xticks=(1:12, month_names))\nlines!(ax, 1:12, nsidc_antarctic.extent_monthly; color=:black, linewidth=2, label=\"NSIDC\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, 1:12, ICE[lab].antarctic_extent_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 9: SIA" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (1200, 500), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Month\", ylabel=\"SIA (Million km²)\", title=\"Arctic SIA Climatology\", xticks=(1:12, month_names))\nlines!(ax, 1:12, nsidc_arctic.area_monthly; color=:black, linewidth=2, label=\"NSIDC\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, 1:12, ICE[lab].arctic_area_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:lb)\nax = Axis(fig[1, 2]; xlabel=\"Month\", ylabel=\"SIA (Million km²)\", title=\"Antarctic SIA Climatology\", xticks=(1:12, month_names))\nlines!(ax, 1:12, nsidc_antarctic.area_monthly; color=:black, linewidth=2, label=\"NSIDC\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, 1:12, ICE[lab].antarctic_area_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 10: Arctic volume" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (600, 500), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Month\", ylabel=\"Ice volume (10³ km³)\", title=\"Arctic sea-ice volume\", xticks=(1:12, month_names))\nlines!(ax, 1:12, piomas_monthly; color=:black, linewidth=2, label=\"PIOMAS\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, 1:12, ICE[lab].arctic_volume_monthly .* m3_to_1e3km3; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 11: SIA time series" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (1200, 500), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Time (years)\", ylabel=\"SIA (Million km²)\", title=\"Arctic sea-ice area\")\nfor (i, lab) in enumerate(labels)\n time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates]\n lines!(ax, time_years, ICE[lab].arctic_area .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nax = Axis(fig[1, 2]; xlabel=\"Time (years)\", ylabel=\"SIA (Million km²)\", title=\"Antarctic sea-ice area\")\nfor (i, lab) in enumerate(labels)\n time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates]\n lines!(ax, time_years, ICE[lab].antarctic_area .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 12: Arctic volume time series" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (600, 500), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Time (years)\", ylabel=\"Ice volume (10³ km³)\", title=\"Arctic sea-ice volume\")\nfor (i, lab) in enumerate(labels)\n time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates]\n lines!(ax, time_years, ICE[lab].arctic_volume .* m3_to_1e3km3; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load time series and 3-D fields" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "function load_timeseries_case(run_dir, prefix, grid; start_time = 0, stop_time = Inf)\n averages_file = find_first_file(run_dir, prefix, \"averages\")\n temperature_mean_fts = FieldTimeSeries(averages_file, \"tosga\"; backend = OnDisk())\n salinity_mean_fts = FieldTimeSeries(averages_file, \"soga\"; backend = OnDisk())\n temperature_mean = [Array(interior(temperature_mean_fts[n]))[1] for n in 1:length(temperature_mean_fts.times)]\n salinity_mean = [Array(interior(salinity_mean_fts[n]))[1] for n in 1:length(salinity_mean_fts.times)]\n time_in_years = temperature_mean_fts.times ./ (365.25 * 24 * 3600)\n\n temperature_profile_fts = FieldTimeSeries(averages_file, \"to_h\"; backend = OnDisk())\n salinity_profile_fts = FieldTimeSeries(averages_file, \"so_h\"; backend = OnDisk())\n temperature_profile = vec(compute_time_mean(temperature_profile_fts; start_time, stop_time))\n salinity_profile = vec(compute_time_mean(salinity_profile_fts; start_time, stop_time))\n depth = collect(znodes(grid, Center()))\n\n fields_file = find_first_file(run_dir, prefix, \"fields\")\n tke_fts = FieldTimeSeries(fields_file, \"tke\"; backend = OnDisk())\n ocean_mask = build_ocean_mask_3d(grid)\n ocean_cells = sum(ocean_mask)\n tke_mean = [sum(Array(interior(tke_fts[n])) .* ocean_mask) / ocean_cells\n for n in 1:length(tke_fts.times)]\n tke_time_in_years = tke_fts.times ./ (365.25 * 24 * 3600)\n\n return (; temperature_mean, salinity_mean, time_in_years,\n temperature_profile, salinity_profile, depth,\n tke_mean, tke_time_in_years, ocean_mask, fields_file)\nend\n\nTS = Dict{String, Any}()\nfor c in cases\n @info \"Loading time series: $(c.label)...\"\n TS[c.label] = load_timeseries_case(c.run_dir, c.prefix, D[c.label].grid; start_time, stop_time)\nend" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 13: TKE" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (900, 450), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Time (years)\", ylabel=\"TKE (m²/s²)\", title=\"Global-mean turbulent kinetic energy\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, TS[lab].tke_time_in_years, TS[lab].tke_mean; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rb)\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 14: T and S drift" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (1200, 450), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Time (years)\", ylabel=\"ΔT (deg C)\", title=\"Global-mean temperature drift\")\nfor (i, lab) in enumerate(labels)\n d = TS[lab]\n lines!(ax, d.time_in_years, d.temperature_mean .- d.temperature_mean[1]; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:lb)\nax = Axis(fig[1, 2]; xlabel=\"Time (years)\", ylabel=\"ΔS (PSU)\", title=\"Global-mean salinity drift\")\nfor (i, lab) in enumerate(labels)\n d = TS[lab]\n lines!(ax, d.time_in_years, d.salinity_mean .- d.salinity_mean[1]; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:lb)\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 15: Profiles" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (1000, 600), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Temperature (deg C)\", ylabel=\"Depth (m)\", title=\"Horizontal-mean temperature\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, TS[lab].temperature_profile, TS[lab].depth; color=case_colors[i], label=lab)\nend\nylims!(ax, (-5500, 0)); axislegend(ax; position=:rb)\nax = Axis(fig[1, 2]; xlabel=\"Salinity (PSU)\", ylabel=\"Depth (m)\", title=\"Horizontal-mean salinity\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, TS[lab].salinity_profile, TS[lab].depth; color=case_colors[i], label=lab)\nend\nylims!(ax, (-5500, 0)); axislegend(ax; position=:rb)\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Zonal-mean sections\n\nRegrid 3-D time-mean fields to a regular 1-degree lat-lon grid via\n`ConservativeRegridding`, then average over longitude." + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "Nlon, Nlat = 360, 180\nlatlon_grid = LatitudeLongitudeGrid(CPU();\n size = (Nlon, Nlat, 1), longitude = (0, 360), latitude = (-90, 90), z = (0, 1))\ndst_f = Field{Center, Center, Nothing}(latlon_grid)\n\nfunction compute_zonal_mean(data_3d, ocean_mask_3d, regridder, Nlon, Nlat)\n Nz = size(data_3d, 3)\n zonal = fill(NaN, Nlat, Nz)\n dst_data = zeros(Nlon * Nlat)\n dst_mask = zeros(Nlon * Nlat)\n areas = regridder.dst_areas\n for k in 1:Nz\n ConservativeRegridding.regrid!(dst_data, regridder,\n vec(data_3d[:, :, k] .* ocean_mask_3d[:, :, k]))\n ConservativeRegridding.regrid!(dst_mask, regridder,\n vec(ocean_mask_3d[:, :, k]))\n data_sum = reshape(dst_data .* areas, Nlon, Nlat)\n mask_sum = reshape(dst_mask .* areas, Nlon, Nlat)\n for j in 1:Nlat\n m = sum(@view mask_sum[:, j])\n m > 0 && (zonal[j, k] = sum(@view data_sum[:, j]) / m)\n end\n end\n return zonal\nend" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "ZM = Dict{String, Any}()\nfor c in cases\n lab = c.label\n grid = D[lab].grid\n ocean_mask = TS[lab].ocean_mask\n\n # Build per-case regridder\n @info \"Building regridder for $lab (may take a few minutes)...\"\n src_f = Field{Center, Center, Nothing}(grid)\n regridder = ConservativeRegridding.Regridder(dst_f, src_f; progress = true)\n\n @info \"Loading 3-D fields for $lab...\"\n fields_file = TS[lab].fields_file\n to_fts = FieldTimeSeries(fields_file, \"to\"; backend = OnDisk())\n so_fts = FieldTimeSeries(fields_file, \"so\"; backend = OnDisk())\n bo_fts = FieldTimeSeries(fields_file, \"bo\"; backend = OnDisk())\n\n temperature_mean = compute_time_mean(to_fts; start_time, stop_time)\n salinity_mean = compute_time_mean(so_fts; start_time, stop_time)\n buoyancy_mean = compute_time_mean(bo_fts; start_time, stop_time)\n buoyancy_initial = Array(interior(bo_fts[1]))\n\n @info \"Computing zonal means for $lab...\"\n temperature_zonal = compute_zonal_mean(temperature_mean, ocean_mask, regridder, Nlon, Nlat)\n salinity_zonal = compute_zonal_mean(salinity_mean, ocean_mask, regridder, Nlon, Nlat)\n buoyancy_zonal = compute_zonal_mean(buoyancy_mean, ocean_mask, regridder, Nlon, Nlat)\n temperature_woa_zonal = compute_zonal_mean(D[lab].T_woa_on_grid, ocean_mask, regridder, Nlon, Nlat)\n salinity_woa_zonal = compute_zonal_mean(D[lab].S_woa_on_grid, ocean_mask, regridder, Nlon, Nlat)\n buoyancy_init_zonal = compute_zonal_mean(buoyancy_initial, ocean_mask, regridder, Nlon, Nlat)\n\n depth = collect(znodes(grid, Center()))\n\n ZM[lab] = (; temperature_zonal, salinity_zonal, buoyancy_zonal,\n temperature_woa_zonal, salinity_woa_zonal, buoyancy_init_zonal,\n δtemperature_zonal = temperature_zonal .- temperature_woa_zonal,\n δsalinity_zonal = salinity_zonal .- salinity_woa_zonal,\n δbuoyancy_zonal = buoyancy_zonal .- buoyancy_init_zonal,\n depth)\nend\n\nlatitude = collect(φnodes(latlon_grid, Center()))" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "temperature_levels = -2:2:30\nsalinity_levels = 33:0.25:37\nbuoyancy_levels = range(-0.04, 0.02, length=13)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 16: Zonal-mean T, S, b" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (600 * length(labels), 900), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n zm = ZM[lab]\n ax = Axis(fig[1, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal T\")\n hm = heatmap!(ax, latitude, zm.depth, zm.temperature_zonal; colormap=:thermal, colorrange=(-2,30), nan_color=:lightgray)\n contour!(ax, latitude, zm.depth, zm.temperature_woa_zonal; levels=temperature_levels, color=:grey, linestyle=:dash, linewidth=0.8)\n contour!(ax, latitude, zm.depth, zm.temperature_zonal; levels=temperature_levels, color=:black, linewidth=0.8)\n Colorbar(fig[1, 2i], hm; label=\"deg C\"); ylims!(ax, (-5500, 0))\n\n ax = Axis(fig[2, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal S\")\n hm = heatmap!(ax, latitude, zm.depth, zm.salinity_zonal; colormap=:haline, colorrange=(33,37), nan_color=:lightgray)\n contour!(ax, latitude, zm.depth, zm.salinity_woa_zonal; levels=salinity_levels, color=:grey, linestyle=:dash, linewidth=0.8)\n contour!(ax, latitude, zm.depth, zm.salinity_zonal; levels=salinity_levels, color=:black, linewidth=0.8)\n Colorbar(fig[2, 2i], hm; label=\"PSU\"); ylims!(ax, (-5500, 0))\n\n ax = Axis(fig[3, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal b\")\n hm = heatmap!(ax, latitude, zm.depth, zm.buoyancy_zonal; colormap=:balance, nan_color=:lightgray)\n contour!(ax, latitude, zm.depth, zm.buoyancy_init_zonal; levels=buoyancy_levels, color=:grey, linestyle=:dash, linewidth=0.8)\n contour!(ax, latitude, zm.depth, zm.buoyancy_zonal; levels=buoyancy_levels, color=:black, linewidth=0.8)\n Colorbar(fig[3, 2i], hm; label=\"m/s²\"); ylims!(ax, (-5500, 0))\nend\nfig" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Figure 17: Zonal-mean drift" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fig = Figure(size = (600 * length(labels), 900), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n zm = ZM[lab]\n ax = Axis(fig[1, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal T - WOA\")\n hm = heatmap!(ax, latitude, zm.depth, zm.δtemperature_zonal; colormap=:balance, colorrange=(-5,5), nan_color=:lightgray)\n Colorbar(fig[1, 2i], hm; label=\"deg C\"); ylims!(ax, (-5500, 0))\n\n ax = Axis(fig[2, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal S - WOA\")\n hm = heatmap!(ax, latitude, zm.depth, zm.δsalinity_zonal; colormap=:balance, colorrange=(-1,1), nan_color=:lightgray)\n Colorbar(fig[2, 2i], hm; label=\"PSU\"); ylims!(ax, (-5500, 0))\n\n ax = Axis(fig[3, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal b - b(t=0)\")\n hm = heatmap!(ax, latitude, zm.depth, zm.δbuoyancy_zonal; colormap=:balance, nan_color=:lightgray)\n Colorbar(fig[3, 2i], hm; label=\"m/s²\"); ylims!(ax, (-5500, 0))\nend\nfig\n\n@info \"All 17 figures saved to $output_dir\"" + ], + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "display_name": "Julia", + "language": "julia", + "name": "julia" + }, + "language_info": { + "name": "julia" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/experiments/OMIPSimulations/scripts/visualize_omip.jl b/experiments/OMIPSimulations/scripts/visualize_omip.jl new file mode 100644 index 000000000..9a6994d98 --- /dev/null +++ b/experiments/OMIPSimulations/scripts/visualize_omip.jl @@ -0,0 +1,693 @@ +#!/usr/bin/env julia +# visualize_omip.jl -- Generate all OMIP diagnostic figures as PNGs. +# +# Usage: +# julia --project=.. visualize_omip.jl [output_dir] +# +# Edit the `cases`, `start_time`, `stop_time` below before running. + +# ══════════════════════════════════════════════════════════════ +# Configuration +# ══════════════════════════════════════════════════════════════ + +cases = [ + (run_dir = "halfdegree_run", prefix = "halfdegree", label = "Half-degree"), + (run_dir = "orca_run", prefix = "orca", label = "ORCA"), +] + +start_time = 0 +stop_time = Inf + +output_dir = length(ARGS) >= 1 ? ARGS[1] : "figures" + +# ══════════════════════════════════════════════════════════════ +# Imports +# ══════════════════════════════════════════════════════════════ + +using CairoMakie +using Statistics +using Dates +using Downloads +using DelimitedFiles +using WorldOceanAtlasTools +using Oceananigans +using Oceananigans.Grids: znodes, φnodes, φnode +using Oceananigans.Fields: interpolate! +using ConservativeRegridding +using NumericalEarth +using NumericalEarth.DataWrangling: Metadatum +using NumericalEarth.DataWrangling.WOA: WOAAnnual + +mkpath(output_dir) +@info "Figures will be saved to: $output_dir" + +# ══════════════════════════════════════════════════════════════ +# Helpers +# ══════════════════════════════════════════════════════════════ + +function find_first_file(run_dir, prefix, group) + tag = "$(prefix)_$(group)" + candidates = filter(f -> startswith(f, tag) && endswith(f, ".jld2") && + !contains(f, "checkpoint"), readdir(run_dir)) + isempty(candidates) && error("No $group files for prefix '$prefix' in $run_dir") + filename = first(sort(candidates)) + basename_no_part = replace(filename, r"_part\d+" => "") + return joinpath(run_dir, basename_no_part) +end + +function in_window(fts; start_time = 0, stop_time = Inf) + return findall(t -> start_time <= t <= stop_time, fts.times) +end + +function compute_time_mean(fts; start_time = 0, stop_time = Inf) + idx = in_window(fts; start_time, stop_time) + isempty(idx) && error("No snapshots in [$start_time, $stop_time]") + avg = zeros(size(Array(interior(fts[first(idx)])))) + for n in idx + avg .+= Array(interior(fts[n])) + end + return avg ./ length(idx) +end + +function compute_monthly_mean(fts, target_months; + start_time = 0, stop_time = Inf, + reference_date = DateTime(1958, 1, 1)) + dates = [reference_date + Second(round(Int, t)) for t in fts.times] + idx = findall(i -> month(dates[i]) in target_months && + start_time <= fts.times[i] <= stop_time, + eachindex(dates)) + isempty(idx) && return nothing + avg = zeros(size(Array(interior(fts[first(idx)])))) + for n in idx + avg .+= Array(interior(fts[n])) + end + return avg ./ length(idx) +end + +function build_land_mask(grid) + if grid isa ImmersedBoundaryGrid + bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1)) + return bh .>= 0 + else + return falses(size(grid, 1), size(grid, 2)) + end +end + +function build_ocean_mask_3d(grid) + Nx, Ny, Nz = size(grid) + mask = ones(Nx, Ny, Nz) + if grid isa ImmersedBoundaryGrid + bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1)) + zc = znodes(grid, Center()) + for k in 1:Nz, j in 1:Ny, i in 1:Nx + zc[k] < bh[i, j] && (mask[i, j, k] = 0.0) + end + end + return mask +end + +mask_land!(f, land) = (f[land] .= NaN; f) + +function panel!(fig, pos, data; + title="", colormap=:thermal, + colorrange=nothing, label="", + nan_color=:lightgray) + ax = Axis(fig[pos...]; title) + kw = isnothing(colorrange) ? (;) : (; colorrange) + hm = heatmap!(ax, data; colormap, nan_color, kw...) + Colorbar(fig[pos[1], pos[2]+1], hm; label) + return ax +end + +case_colors = [:firebrick, :royalblue, :seagreen, :darkorange] + +savefig(fig, name) = save(joinpath(output_dir, name), fig) + +# ══════════════════════════════════════════════════════════════ +# Load surface diagnostics +# ══════════════════════════════════════════════════════════════ + +function load_surface_case(run_dir, prefix; start_time = 0, stop_time = Inf) + surface_file = find_first_file(run_dir, prefix, "surface") + @info " surface: $surface_file" + + tos = FieldTimeSeries(surface_file, "tos"; backend = OnDisk()) + sos = FieldTimeSeries(surface_file, "sos"; backend = OnDisk()) + zos = FieldTimeSeries(surface_file, "zos"; backend = OnDisk()) + mld_fts = FieldTimeSeries(surface_file, "mlotst"; backend = OnDisk()) + hfds = FieldTimeSeries(surface_file, "hfds"; backend = OnDisk()) + wfo = FieldTimeSeries(surface_file, "wfo"; backend = OnDisk()) + sic = FieldTimeSeries(surface_file, "siconc"; backend = OnDisk()) + zossq = FieldTimeSeries(surface_file, "zossq"; backend = OnDisk()) + + grid = tos.grid + Nx, Ny, Nz = size(grid) + land = build_land_mask(grid) + + @info " averaging window: [$(start_time / (365.25*86400)), $(stop_time / (365.25*86400))] years" + + SST = dropdims(compute_time_mean(tos; start_time, stop_time); dims=3) + SSS = dropdims(compute_time_mean(sos; start_time, stop_time); dims=3) + SSH = dropdims(compute_time_mean(zos; start_time, stop_time); dims=3) + HF = dropdims(compute_time_mean(hfds; start_time, stop_time); dims=3) + FW = dropdims(compute_time_mean(wfo; start_time, stop_time); dims=3) + SIC_mean = dropdims(compute_time_mean(sic; start_time, stop_time); dims=3) + + SSH_sq = dropdims(compute_time_mean(zossq; start_time, stop_time); dims=3) + SSH_var = SSH_sq .- SSH .^ 2 + + MLD_monthly = [compute_monthly_mean(mld_fts, [m]; start_time, stop_time) for m in 1:12] + avail = findall(!isnothing, MLD_monthly) + MLD_stack = cat([dropdims(MLD_monthly[m]; dims=3) for m in avail]...; dims=3) + MLD_min = dropdims(minimum(MLD_stack; dims=3); dims=3) + MLD_max = dropdims(maximum(MLD_stack; dims=3); dims=3) + + SIC_mar = compute_monthly_mean(sic, [3]; start_time, stop_time) + SIC_sep = compute_monthly_mean(sic, [9]; start_time, stop_time) + SIC_mar = isnothing(SIC_mar) ? nothing : dropdims(SIC_mar; dims=3) + SIC_sep = isnothing(SIC_sep) ? nothing : dropdims(SIC_sep; dims=3) + + T_woa = Field(Metadatum(:temperature; dataset = WOAAnnual()), CPU()) + S_woa = Field(Metadatum(:salinity; dataset = WOAAnnual()), CPU()) + T_interp = CenterField(grid); interpolate!(T_interp, T_woa) + S_interp = CenterField(grid); interpolate!(S_interp, S_woa) + T_woa_on_grid = Array(interior(T_interp)) + S_woa_on_grid = Array(interior(S_interp)) + δSST = SST .- T_woa_on_grid[:, :, Nz] + δSSS = SSS .- S_woa_on_grid[:, :, Nz] + + for f in (SST, SSS, SSH, HF, FW, SIC_mean, SSH_var, MLD_min, MLD_max, δSST, δSSS) + mask_land!(f, land) + end + !isnothing(SIC_mar) && mask_land!(SIC_mar, land) + !isnothing(SIC_sep) && mask_land!(SIC_sep, land) + + return (; grid, Nx, Ny, Nz, land, surface_file, + SST, SSS, SSH, HF, FW, SIC_mean, SSH_var, + MLD_min, MLD_max, SIC_mar, SIC_sep, + δSST, δSSS, T_woa_on_grid, S_woa_on_grid) +end + +D = Dict{String, Any}() +labels = [c.label for c in cases] +for c in cases + @info "Loading surface: $(c.label)..." + D[c.label] = load_surface_case(c.run_dir, c.prefix; start_time, stop_time) +end + +# ══════════════════════════════════════════════════════════════ +# Figures 1-7: Surface diagnostics +# ══════════════════════════════════════════════════════════════ + +# Figure 1: SST bias +@info "Figure 1: SST bias" +fig = Figure(size = (800 * length(labels), 500), fontsize = 14) +for (i, lab) in enumerate(labels) + panel!(fig, [1, 2i-1], D[lab].δSST; + title = "$lab: SST - WOA", colormap = :balance, + colorrange = (-5, 5), label = "deg C") +end +savefig(fig, "fig01_sst_bias.png") + +# Figure 2: SSS bias +@info "Figure 2: SSS bias" +fig = Figure(size = (800 * length(labels), 500), fontsize = 14) +for (i, lab) in enumerate(labels) + panel!(fig, [1, 2i-1], D[lab].δSSS; + title = "$lab: SSS - WOA", colormap = :balance, + colorrange = (-3, 3), label = "PSU") +end +savefig(fig, "fig02_sss_bias.png") + +# Figure 3: SSH +@info "Figure 3: SSH" +fig = Figure(size = (800 * length(labels), 500), fontsize = 14) +for (i, lab) in enumerate(labels) + panel!(fig, [1, 2i-1], D[lab].SSH; + title = "$lab: Time-mean SSH", colormap = :balance, + colorrange = (-2, 2), label = "m") +end +savefig(fig, "fig03_ssh.png") + +# Figure 4: MLD min/max +@info "Figure 4: MLD" +fig = Figure(size = (800 * length(labels), 900), fontsize = 14) +for (i, lab) in enumerate(labels) + panel!(fig, [1, 2i-1], D[lab].MLD_min; + title = "$lab: Min MLD (summer)", + colormap = Reverse(:deep), colorrange = (0, 150), label = "m") + panel!(fig, [2, 2i-1], D[lab].MLD_max; + title = "$lab: Max MLD (winter)", + colormap = Reverse(:deep), colorrange = (10, 3000), label = "m") +end +savefig(fig, "fig04_mld.png") + +# Figure 5: Sea-ice concentration +@info "Figure 5: Sea-ice concentration" +fig = Figure(size = (800 * length(labels), 900), fontsize = 14) +for (i, lab) in enumerate(labels) + d = D[lab] + !isnothing(d.SIC_mar) && panel!(fig, [1, 2i-1], d.SIC_mar; + title = "$lab: Sea-ice conc. March", + colormap = :ice, colorrange = (0, 1), label = "fraction") + !isnothing(d.SIC_sep) && panel!(fig, [2, 2i-1], d.SIC_sep; + title = "$lab: Sea-ice conc. September", + colormap = :ice, colorrange = (0, 1), label = "fraction") +end +savefig(fig, "fig05_seaice_conc.png") + +# Figure 6: Surface fluxes +@info "Figure 6: Surface fluxes" +fig = Figure(size = (800 * length(labels), 900), fontsize = 14) +for (i, lab) in enumerate(labels) + panel!(fig, [1, 2i-1], D[lab].HF; + title = "$lab: Net heat flux", colormap = :balance, + colorrange = (-200, 200), label = "W/m^2") + panel!(fig, [2, 2i-1], D[lab].FW; + title = "$lab: Net freshwater flux", colormap = :balance, + colorrange = (-1e-4, 1e-4), label = "kg/m^2/s") +end +savefig(fig, "fig06_surface_fluxes.png") + +# Figure 7: SSH variance +@info "Figure 7: SSH variance" +fig = Figure(size = (800 * length(labels), 500), fontsize = 14) +for (i, lab) in enumerate(labels) + panel!(fig, [1, 2i-1], D[lab].SSH_var; + title = "$lab: SSH variance", colormap = :magma, + colorrange = (0, 0.05), label = "m²") +end +savefig(fig, "fig07_ssh_variance.png") + +# ══════════════════════════════════════════════════════════════ +# Sea-ice diagnostics +# ══════════════════════════════════════════════════════════════ + +arctic_condition(i, j, k, grid, args...) = φnode(i, j, k, grid, Center(), Center(), Center()) > 0 +antarctic_condition(i, j, k, grid, args...) = φnode(i, j, k, grid, Center(), Center(), Center()) < 0 + +function compute_ice_diagnostics(run_dir, prefix, grid; + start_time = 0, stop_time = Inf, + reference_date = DateTime(1958, 1, 1), + extent_threshold = 0.15) + surface_file = find_first_file(run_dir, prefix, "surface") + thickness_fts = FieldTimeSeries(surface_file, "sithick"; backend = OnDisk()) + concentration_fts = FieldTimeSeries(surface_file, "siconc"; backend = OnDisk()) + + Nt = length(thickness_fts.times) + arctic_volume = zeros(Nt) + antarctic_volume = zeros(Nt) + arctic_extent = zeros(Nt) + antarctic_extent = zeros(Nt) + arctic_area = zeros(Nt) + antarctic_area = zeros(Nt) + snapshot_dates = [reference_date + Second(round(Int, t)) for t in thickness_fts.times] + + extent_mask = Field{Center, Center, Nothing}(grid) + arctic_extent_integral = Field(Integral(extent_mask; condition = arctic_condition)) + antarctic_extent_integral = Field(Integral(extent_mask; condition = antarctic_condition)) + + for n in 1:Nt + concentration_field = concentration_fts[n] + + ice_volume_field = thickness_fts[n] * concentration_field + arctic_vol_int = Field(Integral(ice_volume_field; condition = arctic_condition)) + antarctic_vol_int = Field(Integral(ice_volume_field; condition = antarctic_condition)) + compute!(arctic_vol_int); compute!(antarctic_vol_int) + arctic_volume[n] = arctic_vol_int[1, 1, 1] + antarctic_volume[n] = antarctic_vol_int[1, 1, 1] + + arctic_area_int = Field(Integral(concentration_field; condition = arctic_condition)) + antarctic_area_int = Field(Integral(concentration_field; condition = antarctic_condition)) + compute!(arctic_area_int); compute!(antarctic_area_int) + arctic_area[n] = arctic_area_int[1, 1, 1] + antarctic_area[n] = antarctic_area_int[1, 1, 1] + + concentration_data = Array(interior(concentration_field, :, :, 1)) + set!(extent_mask, Float64.(concentration_data .> extent_threshold)) + compute!(arctic_extent_integral); compute!(antarctic_extent_integral) + arctic_extent[n] = arctic_extent_integral[1, 1, 1] + antarctic_extent[n] = antarctic_extent_integral[1, 1, 1] + end + + idx = findall(t -> start_time <= t <= stop_time, thickness_fts.times) + months_used = month.(snapshot_dates[idx]) + monthly(field) = [mean(field[idx[months_used .== m]]) for m in 1:12] + + return (; arctic_volume, antarctic_volume, + arctic_extent, antarctic_extent, + arctic_area, antarctic_area, snapshot_dates, + arctic_volume_monthly = monthly(arctic_volume), + antarctic_volume_monthly = monthly(antarctic_volume), + arctic_extent_monthly = monthly(arctic_extent), + antarctic_extent_monthly = monthly(antarctic_extent), + arctic_area_monthly = monthly(arctic_area), + antarctic_area_monthly = monthly(antarctic_area)) +end + +ICE = Dict{String, Any}() +for c in cases + @info "Computing sea-ice diagnostics for $(c.label)..." + ICE[c.label] = compute_ice_diagnostics(c.run_dir, c.prefix, D[c.label].grid; start_time, stop_time) +end + +# ── Download observational climatologies ───────────────────── + +piomas_url = "https://psc.apl.uw.edu/wordpress/wp-content/uploads/schweiger/ice_volume/PIOMAS.monthly.Current.v2.1.csv" +piomas_raw = readdlm(Downloads.download(piomas_url), ','; skipstart=1) +piomas_volume = Float64.(piomas_raw[:, 2:13]) +piomas_volume[piomas_volume .== -1] .= NaN +piomas_monthly = vec(mapslices(x -> mean(filter(!isnan, x)), piomas_volume; dims=1)) + +function download_nsidc(hemisphere) + prefix = hemisphere == "north" ? "N" : "S" + extent_monthly = zeros(12) + area_monthly = zeros(12) + for m in 1:12 + url = "https://noaadata.apps.nsidc.org/NOAA/G02135/$(hemisphere)/monthly/data/$(prefix)_$(lpad(m, 2, '0'))_extent_v4.0.csv" + raw = readlines(Downloads.download(url)) + extents = Float64[]; areas = Float64[] + for line in raw + parts = split(line, ',') + length(parts) >= 6 || continue + ext = tryparse(Float64, strip(parts[5])) + ar = tryparse(Float64, strip(parts[6])) + (isnothing(ext) || ext == -9999) && continue + (isnothing(ar) || ar == -9999) && continue + push!(extents, ext); push!(areas, ar) + end + extent_monthly[m] = mean(extents) + area_monthly[m] = mean(areas) + end + return (; extent_monthly, area_monthly) +end + +@info "Downloading NSIDC..." +nsidc_arctic = download_nsidc("north") +nsidc_antarctic = download_nsidc("south") + +# ── Figures 8-12: Sea-ice climatologies and time series ────── + +month_names = ["J","F","M","A","M","J","J","A","S","O","N","D"] +m2_to_Mkm2 = 1e-12 +m3_to_1e3km3 = 1e-12 + +# Figure 8: SIE +@info "Figure 8: SIE" +fig = Figure(size = (1200, 500), fontsize = 14) +ax = Axis(fig[1, 1]; xlabel="Month", ylabel="SIE (Million km²)", title="Arctic SIE Climatology", xticks=(1:12, month_names)) +lines!(ax, 1:12, nsidc_arctic.extent_monthly; color=:black, linewidth=2, label="NSIDC") +for (i, lab) in enumerate(labels) + lines!(ax, 1:12, ICE[lab].arctic_extent_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab) +end +axislegend(ax; position=:lb) +ax = Axis(fig[1, 2]; xlabel="Month", ylabel="SIE (Million km²)", title="Antarctic SIE Climatology", xticks=(1:12, month_names)) +lines!(ax, 1:12, nsidc_antarctic.extent_monthly; color=:black, linewidth=2, label="NSIDC") +for (i, lab) in enumerate(labels) + lines!(ax, 1:12, ICE[lab].antarctic_extent_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab) +end +axislegend(ax; position=:rt) +savefig(fig, "fig08_sie.png") + +# Figure 9: SIA +@info "Figure 9: SIA" +fig = Figure(size = (1200, 500), fontsize = 14) +ax = Axis(fig[1, 1]; xlabel="Month", ylabel="SIA (Million km²)", title="Arctic SIA Climatology", xticks=(1:12, month_names)) +lines!(ax, 1:12, nsidc_arctic.area_monthly; color=:black, linewidth=2, label="NSIDC") +for (i, lab) in enumerate(labels) + lines!(ax, 1:12, ICE[lab].arctic_area_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab) +end +axislegend(ax; position=:lb) +ax = Axis(fig[1, 2]; xlabel="Month", ylabel="SIA (Million km²)", title="Antarctic SIA Climatology", xticks=(1:12, month_names)) +lines!(ax, 1:12, nsidc_antarctic.area_monthly; color=:black, linewidth=2, label="NSIDC") +for (i, lab) in enumerate(labels) + lines!(ax, 1:12, ICE[lab].antarctic_area_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab) +end +axislegend(ax; position=:rt) +savefig(fig, "fig09_sia.png") + +# Figure 10: Arctic volume +@info "Figure 10: Arctic volume" +fig = Figure(size = (600, 500), fontsize = 14) +ax = Axis(fig[1, 1]; xlabel="Month", ylabel="Ice volume (10³ km³)", title="Arctic sea-ice volume", xticks=(1:12, month_names)) +lines!(ax, 1:12, piomas_monthly; color=:black, linewidth=2, label="PIOMAS") +for (i, lab) in enumerate(labels) + lines!(ax, 1:12, ICE[lab].arctic_volume_monthly .* m3_to_1e3km3; color=case_colors[i], label=lab) +end +axislegend(ax; position=:rt) +savefig(fig, "fig10_arctic_volume.png") + +# Figure 11: SIA time series +@info "Figure 11: SIA time series" +fig = Figure(size = (1200, 500), fontsize = 14) +ax = Axis(fig[1, 1]; xlabel="Time (years)", ylabel="SIA (Million km²)", title="Arctic sea-ice area") +for (i, lab) in enumerate(labels) + time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates] + lines!(ax, time_years, ICE[lab].arctic_area .* m2_to_Mkm2; color=case_colors[i], label=lab) +end +axislegend(ax; position=:rt) +ax = Axis(fig[1, 2]; xlabel="Time (years)", ylabel="SIA (Million km²)", title="Antarctic sea-ice area") +for (i, lab) in enumerate(labels) + time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates] + lines!(ax, time_years, ICE[lab].antarctic_area .* m2_to_Mkm2; color=case_colors[i], label=lab) +end +axislegend(ax; position=:rt) +savefig(fig, "fig11_sia_timeseries.png") + +# Figure 12: Arctic volume time series +@info "Figure 12: Arctic volume time series" +fig = Figure(size = (600, 500), fontsize = 14) +ax = Axis(fig[1, 1]; xlabel="Time (years)", ylabel="Ice volume (10³ km³)", title="Arctic sea-ice volume") +for (i, lab) in enumerate(labels) + time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates] + lines!(ax, time_years, ICE[lab].arctic_volume .* m3_to_1e3km3; color=case_colors[i], label=lab) +end +axislegend(ax; position=:rt) +savefig(fig, "fig12_arctic_volume_timeseries.png") + +# ══════════════════════════════════════════════════════════════ +# Load time series and 3-D fields +# ══════════════════════════════════════════════════════════════ + +function load_timeseries_case(run_dir, prefix, grid; start_time = 0, stop_time = Inf) + averages_file = find_first_file(run_dir, prefix, "averages") + temperature_mean_fts = FieldTimeSeries(averages_file, "tosga"; backend = OnDisk()) + salinity_mean_fts = FieldTimeSeries(averages_file, "soga"; backend = OnDisk()) + temperature_mean = [Array(interior(temperature_mean_fts[n]))[1] for n in 1:length(temperature_mean_fts.times)] + salinity_mean = [Array(interior(salinity_mean_fts[n]))[1] for n in 1:length(salinity_mean_fts.times)] + time_in_years = temperature_mean_fts.times ./ (365.25 * 24 * 3600) + + temperature_profile_fts = FieldTimeSeries(averages_file, "to_h"; backend = OnDisk()) + salinity_profile_fts = FieldTimeSeries(averages_file, "so_h"; backend = OnDisk()) + temperature_profile = vec(compute_time_mean(temperature_profile_fts; start_time, stop_time)) + salinity_profile = vec(compute_time_mean(salinity_profile_fts; start_time, stop_time)) + depth = collect(znodes(grid, Center())) + + fields_file = find_first_file(run_dir, prefix, "fields") + tke_fts = FieldTimeSeries(fields_file, "tke"; backend = OnDisk()) + u_fts = FieldTimeSeries(fields_file, "uo"; backend = OnDisk()) + v_fts = FieldTimeSeries(fields_file, "vo"; backend = OnDisk()) + + ocean_mask = build_ocean_mask_3d(grid) + ocean_cells = sum(ocean_mask) + tke_mean = [sum(Array(interior(tke_fts[n])) .* ocean_mask) / ocean_cells + for n in 1:length(tke_fts.times)] + + ke(n) = @at((Center, Center, Center), u^2 + v^2) + ke_mean = [sum(ke(n)) ./ ocean_cells ./ 2 for n in 1:length(u_fts.times)] + tke_time_in_years = tke_fts.times ./ (365.25 * 24 * 3600) + + return (; temperature_mean, salinity_mean, time_in_years, + temperature_profile, salinity_profile, depth, + tke_mean, ke_mean, tke_time_in_years, ocean_mask, fields_file) +end + +TS = Dict{String, Any}() +for c in cases + @info "Loading time series: $(c.label)..." + TS[c.label] = load_timeseries_case(c.run_dir, c.prefix, D[c.label].grid; start_time, stop_time) +end + +# ══════════════════════════════════════════════════════════════ +# Figures 13-15: Time series and profiles +# ══════════════════════════════════════════════════════════════ + +# Figure 13: TKE +@info "Figure 13: TKE and KE" +fig = Figure(size = (900, 600), fontsize = 14) +ax = Axis(fig[1, 1]; xlabel="Time (years)", ylabel="TKE (m²/s²)", title="Global-mean turbulent kinetic energy") +for (i, lab) in enumerate(labels) + lines!(ax, TS[lab].tke_time_in_years, TS[lab].tke_mean; color=case_colors[i], label=lab) +end +axislegend(ax; position=:rb) +ax = Axis(fig[2, 1]; xlabel="Time (years)", ylabel="TKE (m²/s²)", title="Global-mean kinetic energy") +for (i, lab) in enumerate(labels) + lines!(ax, TS[lab].tke_time_in_years, TS[lab].ke_mean; color=case_colors[i], label=lab) +end +axislegend(ax; position=:rb) +savefig(fig, "fig13_tke.png") + +# Figure 14: T and S drift +@info "Figure 14: T and S drift" +fig = Figure(size = (1200, 450), fontsize = 14) +ax = Axis(fig[1, 1]; xlabel="Time (years)", ylabel="ΔT (deg C)", title="Global-mean temperature drift") +for (i, lab) in enumerate(labels) + d = TS[lab] + lines!(ax, d.time_in_years, d.temperature_mean .- d.temperature_mean[1]; color=case_colors[i], label=lab) +end +axislegend(ax; position=:lb) +ax = Axis(fig[1, 2]; xlabel="Time (years)", ylabel="ΔS (PSU)", title="Global-mean salinity drift") +for (i, lab) in enumerate(labels) + d = TS[lab] + lines!(ax, d.time_in_years, d.salinity_mean .- d.salinity_mean[1]; color=case_colors[i], label=lab) +end +axislegend(ax; position=:lb) +savefig(fig, "fig14_drift.png") + +# Figure 15: Profiles +@info "Figure 15: Profiles" +fig = Figure(size = (1000, 600), fontsize = 14) +ax = Axis(fig[1, 1]; xlabel="Temperature (deg C)", ylabel="Depth (m)", title="Horizontal-mean temperature") +for (i, lab) in enumerate(labels) + lines!(ax, TS[lab].temperature_profile, TS[lab].depth; color=case_colors[i], label=lab) +end +ylims!(ax, (-5500, 0)); axislegend(ax; position=:rb) +ax = Axis(fig[1, 2]; xlabel="Salinity (PSU)", ylabel="Depth (m)", title="Horizontal-mean salinity") +for (i, lab) in enumerate(labels) + lines!(ax, TS[lab].salinity_profile, TS[lab].depth; color=case_colors[i], label=lab) +end +ylims!(ax, (-5500, 0)); axislegend(ax; position=:rb) +savefig(fig, "fig15_profiles.png") + +# ══════════════════════════════════════════════════════════════ +# Zonal-mean sections +# ══════════════════════════════════════════════════════════════ + +Nlon, Nlat = 360, 180 +latlon_grid = LatitudeLongitudeGrid(CPU(); + size = (Nlon, Nlat, 1), longitude = (0, 360), latitude = (-90, 90), z = (0, 1)) +dst_f = Field{Center, Center, Nothing}(latlon_grid) + +function compute_zonal_mean(data_3d, ocean_mask_3d, regridder, Nlon, Nlat) + Nz = size(data_3d, 3) + zonal = fill(NaN, Nlat, Nz) + dst_data = zeros(Nlon * Nlat) + dst_mask = zeros(Nlon * Nlat) + areas = regridder.dst_areas + for k in 1:Nz + ConservativeRegridding.regrid!(dst_data, regridder, + vec(data_3d[:, :, k] .* ocean_mask_3d[:, :, k])) + ConservativeRegridding.regrid!(dst_mask, regridder, + vec(ocean_mask_3d[:, :, k])) + data_sum = reshape(dst_data .* areas, Nlon, Nlat) + mask_sum = reshape(dst_mask .* areas, Nlon, Nlat) + for j in 1:Nlat + m = sum(@view mask_sum[:, j]) + m > 0 && (zonal[j, k] = sum(@view data_sum[:, j]) / m) + end + end + return zonal +end + +ZM = Dict{String, Any}() +for c in cases + lab = c.label + grid = D[lab].grid + ocean_mask = TS[lab].ocean_mask + + # Build per-case regridder + @info "Building regridder for $lab (may take a few minutes)..." + src_f = Field{Center, Center, Nothing}(grid) + regridder = ConservativeRegridding.Regridder(dst_f, src_f; progress = true) + + @info "Loading 3-D fields for $lab..." + fields_file = TS[lab].fields_file + to_fts = FieldTimeSeries(fields_file, "to"; backend = OnDisk()) + so_fts = FieldTimeSeries(fields_file, "so"; backend = OnDisk()) + bo_fts = FieldTimeSeries(fields_file, "bo"; backend = OnDisk()) + eo_fts = FieldTimeSeries(fields_file, "tke"; backend = OnDisk()) + + temperature_mean = compute_time_mean(to_fts; start_time, stop_time) + salinity_mean = compute_time_mean(so_fts; start_time, stop_time) + buoyancy_mean = compute_time_mean(bo_fts; start_time, stop_time) + kinetic_energy_mean = compute_time_mean(eo_fts; start_time, stop_time) + buoyancy_initial = Array(interior(bo_fts[1])) + + @info "Computing zonal means for $lab..." + temperature_zonal = compute_zonal_mean(temperature_mean, ocean_mask, regridder, Nlon, Nlat) + salinity_zonal = compute_zonal_mean(salinity_mean, ocean_mask, regridder, Nlon, Nlat) + buoyancy_zonal = compute_zonal_mean(buoyancy_mean, ocean_mask, regridder, Nlon, Nlat) + kinetic_energy_zonal = compute_zonal_mean(kinetic_energy_mean, ocean_mask, regridder, Nlon, Nlat) + temperature_woa_zonal = compute_zonal_mean(D[lab].T_woa_on_grid, ocean_mask, regridder, Nlon, Nlat) + salinity_woa_zonal = compute_zonal_mean(D[lab].S_woa_on_grid, ocean_mask, regridder, Nlon, Nlat) + buoyancy_init_zonal = compute_zonal_mean(buoyancy_initial, ocean_mask, regridder, Nlon, Nlat) + + depth = collect(znodes(grid, Center())) + + ZM[lab] = (; temperature_zonal, salinity_zonal, buoyancy_zonal, kinetic_energy_zonal, + temperature_woa_zonal, salinity_woa_zonal, buoyancy_init_zonal, + δtemperature_zonal = temperature_zonal .- temperature_woa_zonal, + δsalinity_zonal = salinity_zonal .- salinity_woa_zonal, + δbuoyancy_zonal = buoyancy_zonal .- buoyancy_init_zonal, + depth) +end + +latitude = collect(φnodes(latlon_grid, Center())) + +# ══════════════════════════════════════════════════════════════ +# Figures 16-17: Zonal means +# ══════════════════════════════════════════════════════════════ + +temperature_levels = -2:2:30 +salinity_levels = 33:0.25:37 +buoyancy_levels = range(-0.04, 0.02, length=13) + +# Figure 16: Zonal-mean T, S, b +@info "Figure 16: Zonal means" +fig = Figure(size = (600 * length(labels), 1200), fontsize = 14) +for (i, lab) in enumerate(labels) + zm = ZM[lab] + ax = Axis(fig[1, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal T") + hm = heatmap!(ax, latitude, zm.depth, zm.temperature_zonal; colormap=:thermal, colorrange=(-2,30), nan_color=:lightgray) + contour!(ax, latitude, zm.depth, zm.temperature_woa_zonal; levels=temperature_levels, color=:grey, linestyle=:dash, linewidth=0.8) + contour!(ax, latitude, zm.depth, zm.temperature_zonal; levels=temperature_levels, color=:black, linewidth=0.8) + Colorbar(fig[1, 2i], hm; label="deg C"); ylims!(ax, (-5500, 0)) + + ax = Axis(fig[2, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal S") + hm = heatmap!(ax, latitude, zm.depth, zm.salinity_zonal; colormap=:haline, colorrange=(33,37), nan_color=:lightgray) + contour!(ax, latitude, zm.depth, zm.salinity_woa_zonal; levels=salinity_levels, color=:grey, linestyle=:dash, linewidth=0.8) + contour!(ax, latitude, zm.depth, zm.salinity_zonal; levels=salinity_levels, color=:black, linewidth=0.8) + Colorbar(fig[2, 2i], hm; label="PSU"); ylims!(ax, (-5500, 0)) + + ax = Axis(fig[3, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal b") + hm = heatmap!(ax, latitude, zm.depth, zm.buoyancy_zonal; colormap=:balance, nan_color=:lightgray) + contour!(ax, latitude, zm.depth, zm.buoyancy_init_zonal; levels=buoyancy_levels, color=:grey, linestyle=:dash, linewidth=0.8) + contour!(ax, latitude, zm.depth, zm.buoyancy_zonal; levels=buoyancy_levels, color=:black, linewidth=0.8) + Colorbar(fig[3, 2i], hm; label="m/s²"); ylims!(ax, (-5500, 0)) + + ax = Axis(fig[4, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal e") + hm = heatmap!(ax, latitude, zm.depth, zm.kinetic_energy_zonal; colormap=:solar, nan_color=:lightgray) + Colorbar(fig[4, 2i], hm; label="m/s²"); ylims!(ax, (-5500, 0)) +end +savefig(fig, "fig16_zonal_mean.png") + +# Figure 17: Zonal-mean drift +@info "Figure 17: Zonal-mean drift" +fig = Figure(size = (600 * length(labels), 900), fontsize = 14) +for (i, lab) in enumerate(labels) + zm = ZM[lab] + ax = Axis(fig[1, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal T - WOA") + hm = heatmap!(ax, latitude, zm.depth, zm.δtemperature_zonal; colormap=:balance, colorrange=(-5,5), nan_color=:lightgray) + Colorbar(fig[1, 2i], hm; label="deg C"); ylims!(ax, (-5500, 0)) + + ax = Axis(fig[2, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal S - WOA") + hm = heatmap!(ax, latitude, zm.depth, zm.δsalinity_zonal; colormap=:balance, colorrange=(-1,1), nan_color=:lightgray) + Colorbar(fig[2, 2i], hm; label="PSU"); ylims!(ax, (-5500, 0)) + + ax = Axis(fig[3, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal b - b(t=0)") + hm = heatmap!(ax, latitude, zm.depth, zm.δbuoyancy_zonal; colormap=:balance, nan_color=:lightgray) + Colorbar(fig[3, 2i], hm; label="m/s²"); ylims!(ax, (-5500, 0)) +end +savefig(fig, "fig17_zonal_drift.png") + +@info "All 17 figures saved to $output_dir" diff --git a/experiments/OMIPSimulations/scripts/watchdog.sh b/experiments/OMIPSimulations/scripts/watchdog.sh new file mode 100755 index 000000000..e56f8d5d9 --- /dev/null +++ b/experiments/OMIPSimulations/scripts/watchdog.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Watchdog that keeps store.sh jobs alive for the given CONFIGs. +# Usage: ./watchdog.sh orca halfdegree tenthdegree +# Run inside tmux from the same directory as store.sh. + +set -euo pipefail + +if [[ $# -eq 0 ]]; then + echo "Usage: $0 [config2] ..." + echo "Example: $0 orca halfdegree" + exit 1 +fi + +CONFIGS=("$@") + +while true; do + for cfg in "${CONFIGS[@]}"; do + if ! squeue -u "$USER" -n "store_${cfg}" -h | grep -q .; then + echo "$(date): store_${cfg} not found, relaunching" + ./store.sh "$cfg" + else + echo "$(date): store_${cfg} is running" + fi + done + sleep 3600 +done diff --git a/experiments/OMIPSimulations/src/OMIPSimulations.jl b/experiments/OMIPSimulations/src/OMIPSimulations.jl new file mode 100644 index 000000000..fb4a81b3d --- /dev/null +++ b/experiments/OMIPSimulations/src/OMIPSimulations.jl @@ -0,0 +1,79 @@ +module OMIPSimulations + +using Oceananigans +using Oceananigans.Units +using Oceananigans.Grids: znode, Face +using Dates +using NCDatasets +using CUDA + +using NumericalEarth +using NumericalEarth.Oceans: ocean_simulation, default_ocean_closure +using NumericalEarth.SeaIces: sea_ice_simulation +using NumericalEarth.EarthSystemModels: OceanSeaIceModel, Radiation, + SimilarityTheoryFluxes, + COARELogarithmicSimilarityProfile, + LinearStableStabilityFunction, + MomentumBasedFrictionVelocity, + ThreeEquationHeatFlux, + NCARBulkFluxes, + ncar_stability_functions + +using NumericalEarth.EarthSystemModels.InterfaceComputations: + ComponentInterfaces, + CoefficientBasedFluxes, + MomentumRoughnessLength, + ScalarRoughnessLength, + NCARMomentumRoughnessLength, + NCARScalarRoughnessLength, + WindDependentWaveFormulation, + TemperatureDependentAirViscosity, + SimilarityScales, + SeaIceAlbedo, + atmosphere_sea_ice_stability_functions + +using NumericalEarth.Bathymetry: regrid_bathymetry, ORCAGrid +using NumericalEarth.DataWrangling: Metadatum, Metadata, DatasetRestoring, + EN4Monthly, ECCO4Monthly +using NumericalEarth.DataWrangling.WOA: WOAMonthly +using NumericalEarth.DataWrangling.ORCA: ORCA1 +using NumericalEarth.DataWrangling.JRA55: MultiYearJRA55, JRA55NetCDFBackend, + JRA55PrescribedAtmosphere +using NumericalEarth.Diagnostics: MixedLayerDepthField + +export omip_simulation, + add_omip_diagnostics!, + compute_report_fields, + compute_woa_bias + +# Backwards-compatible restore for checkpoints saved before ClimaSeaIce 0.4.8 +# (which added snow_thickness, snow_thermodynamics, snowfall to SeaIceModel). +# Old checkpoints lack :snow_thickness in the saved state; this override +# silently skips the missing field so pickup works across versions. +using ClimaSeaIce: SeaIceModel +import Oceananigans: restore_prognostic_state! + +function restore_prognostic_state!(model::SeaIceModel, state) + restore_prognostic_state!(model.clock, state.clock) + restore_prognostic_state!(model.velocities, state.velocities) + restore_prognostic_state!(model.ice_thickness, state.ice_thickness) + restore_prognostic_state!(model.ice_concentration, state.ice_concentration) + restore_prognostic_state!(model.tracers, state.tracers) + restore_prognostic_state!(model.timestepper, state.timestepper) + restore_prognostic_state!(model.ice_thermodynamics, state.ice_thermodynamics) + restore_prognostic_state!(model.dynamics, state.dynamics) + + # New fields in ClimaSeaIce >= 0.4.8 — restore only if checkpoint contains them + if hasproperty(state, :snow_thickness) + restore_prognostic_state!(model.snow_thickness, state.snow_thickness) + end + + return model +end + +include("atmosphere.jl") +include("omip_simulation.jl") +include("omip_diagnostics.jl") +include("report_fields.jl") + +end # module diff --git a/experiments/OMIPSimulations/src/atmosphere.jl b/experiments/OMIPSimulations/src/atmosphere.jl new file mode 100644 index 000000000..f5cff1de2 --- /dev/null +++ b/experiments/OMIPSimulations/src/atmosphere.jl @@ -0,0 +1,28 @@ +""" + omip_atmosphere(arch; forcing_dir, start_date, end_date, backend_size=30) + +Set up a JRA55 prescribed atmosphere with river and iceberg forcing, +together with a default `Radiation` model. Returns the tuple +`(atmosphere, radiation)`. +""" +function omip_atmosphere(arch; + forcing_dir, + start_date, + end_date, + backend_size = 30) + + dataset = MultiYearJRA55() + backend = JRA55NetCDFBackend(backend_size) + + atmosphere = JRA55PrescribedAtmosphere(arch; + dir = forcing_dir, + dataset, + backend, + include_rivers_and_icebergs = true, + start_date, + end_date) + + radiation = Radiation() + + return atmosphere, radiation +end diff --git a/experiments/OMIPSimulations/src/omip_diagnostics.jl b/experiments/OMIPSimulations/src/omip_diagnostics.jl new file mode 100644 index 000000000..78c471ccb --- /dev/null +++ b/experiments/OMIPSimulations/src/omip_diagnostics.jl @@ -0,0 +1,187 @@ + +using JLD2 + +""" + add_omip_diagnostics!(simulation; kwargs...) + +Attach OMIP-protocol output writers to a coupled ocean--sea-ice +simulation built by [`omip_simulation`](@ref). + +Creates four output writers: + +1. **Surface diagnostics** (`_surface.nc`): 2-D fields averaged + over `surface_averaging_interval` -- SST, SSS, SSH, surface velocities, + squared fields for variance, mixed-layer depth, wind stress, + heat/freshwater fluxes, and sea-ice state variables. +2. **3-D field diagnostics** (`_fields.nc`): full 3-D temperature, + salinity, velocity, buoyancy, and (when present) TKE, averaged over + `field_averaging_interval`. +3. **Averages** (`_averages.nc`): global means of T, S, buoyancy + and horizontal-mean (dims=(1,2)) depth profiles of the same, on the + same `field_averaging_interval` schedule. +4. **Checkpointer** (`_checkpoint`): JLD2 checkpoint of the + coupled model at `checkpoint_interval`. Use `run!(sim; pickup=true)` + to restart from the latest checkpoint. + +# Keyword arguments + +- `surface_averaging_interval`: averaging window for surface output. Default: `5days`. +- `field_averaging_interval`: averaging window for 3-D / averages output. Default: `15days`. +- `checkpoint_interval`: interval between checkpoints. Default: `90days`. +- `output_dir`: directory for all output files. Default: `"."`. +- `filename_prefix`: prefix for output filenames. Default: `"omip"`. +- `file_splitting_interval`: time interval for splitting output files. Default: `360days`. +""" +function add_omip_diagnostics!(simulation; + field_mean_interval = 5days, + surface_averaging_interval = 5days, + field_averaging_interval = 15days, + checkpoint_interval = 720days, + output_dir = ".", + filename_prefix = "omip", + file_splitting_interval = 360days) + + model = simulation.model + ocean = model.ocean + sea_ice = model.sea_ice + grid = ocean.model.grid + Nz = size(grid, 3) + + T, S = ocean.model.tracers.T, ocean.model.tracers.S + u, v, w = ocean.model.velocities + η = ocean.model.free_surface.displacement + + τx = model.interfaces.net_fluxes.ocean.u + τy = model.interfaces.net_fluxes.ocean.v + JT = model.interfaces.net_fluxes.ocean.T + Js = model.interfaces.net_fluxes.ocean.S + Qc = model.interfaces.atmosphere_ocean_interface.fluxes.sensible_heat + Qv = model.interfaces.atmosphere_ocean_interface.fluxes.latent_heat + + JTf = NumericalEarth.Diagnostics.frazil_temperature_flux(model) + JTn = NumericalEarth.Diagnostics.net_ocean_temperature_flux(model) + JTio = NumericalEarth.Diagnostics.sea_ice_ocean_temperature_flux(model) + JTao = NumericalEarth.Diagnostics.atmosphere_ocean_temperature_flux(model) + JSn = NumericalEarth.Diagnostics.net_ocean_salinity_flux(model) + JSio = NumericalEarth.Diagnostics.sea_ice_ocean_salinity_flux(model) + + hi = sea_ice.model.ice_thickness + ℵi = sea_ice.model.ice_concentration + ui, vi = sea_ice.model.velocities + + sitemptop = try + sea_ice.model.ice_thermodynamics.top_surface_temperature + catch + nothing + end + + mld = MixedLayerDepthField(ocean.model.buoyancy, grid, ocean.model.tracers) + + # Surface diagnostics + surface_indices = (:, :, Nz) + + tos = view(T, :, :, Nz) + sos = view(S, :, :, Nz) + uo_surface = view(u, :, :, Nz) + vo_surface = view(v, :, :, Nz) + + tossq = tos * tos + sossq = sos * sos + zossq = Field(η * η) + + surface_outputs = Dict{Symbol, Any}( + :tos => tos, + :sos => sos, + :zos => η, + :uos => uo_surface, + :vos => vo_surface, + :tossq => tossq, + :sossq => sossq, + :zossq => zossq, + :mlotst => mld, + :tauuo => τx, + :tauvo => τy, + :hfds => JT, + :wfo => Js, + :hfss => Qc, + :hfls => Qv, + :siconc => ℵi, + :sithick => hi, + :siu => ui, + :siv => vi, + :JTf => JTf, + :JTn => JTn, + :JTio => JTio, + :JTao => JTao, + :JSn => JSn, + :JSio => JSio + ) + + if !isnothing(sitemptop) + surface_outputs[:sitemptop] = sitemptop + end + + simulation.output_writers[:surface] = JLD2Writer(ocean.model, surface_outputs; + schedule = AveragedTimeInterval(surface_averaging_interval), + dir = output_dir, + filename = filename_prefix * "_surface", + file_splitting = TimeInterval(file_splitting_interval), + overwrite_existing = true, + jld2_kw = Dict(:compress => ZstdFilter())) + + # 3-D fields (including buoyancy) + bop = Oceananigans.Models.buoyancy_operation(ocean.model) + + field_outputs = Dict{Symbol, Any}( + :to => T, + :so => S, + :uo => u, + :vo => v, + :wo => w, + :bo => bop, + ) + + if haskey(ocean.model.tracers, :e) + field_outputs[:tke] = ocean.model.tracers.e + end + + simulation.output_writers[:fields] = JLD2Writer(ocean.model, field_outputs; + schedule = AveragedTimeInterval(field_averaging_interval), + dir = output_dir, + filename = filename_prefix * "_fields", + file_splitting = TimeInterval(file_splitting_interval), + overwrite_existing = true, + jld2_kw = Dict(:compress => ZstdFilter())) + + # Global means and horizontal-mean depth profiles for T, S, b + average_outputs = Dict{Symbol, Any}( + :tosga => Average(T), + :soga => Average(S), + :bga => Average(bop), + :to_h => Average(T, dims=(1, 2)), + :so_h => Average(S, dims=(1, 2)), + :bo_h => Average(bop, dims=(1, 2)), + ) + + simulation.output_writers[:averages] = JLD2Writer(ocean.model, average_outputs; + schedule = AveragedTimeInterval(field_mean_interval), + dir = output_dir, + filename = filename_prefix * "_averages", + file_splitting = TimeInterval(file_splitting_interval), + overwrite_existing = true) + + # Checkpointer (drives `run!(sim; pickup=true)`) + simulation.output_writers[:checkpointer] = Checkpointer(simulation.model; + schedule = TimeInterval(checkpoint_interval), + prefix = joinpath(output_dir, filename_prefix * "_checkpoint"), + cleanup = false, + verbose = true) + + @info "OMIP diagnostics attached:" * + " surface ($(length(surface_outputs)) fields, every $(prettytime(surface_averaging_interval)))," * + " 3-D ($(length(field_outputs)) fields, every $(prettytime(field_averaging_interval)))," * + " averages ($(length(average_outputs)) fields, every $(prettytime(field_averaging_interval)))," * + " checkpointer (every $(prettytime(checkpoint_interval)))" + + return nothing +end diff --git a/experiments/OMIPSimulations/src/omip_simulation.jl b/experiments/OMIPSimulations/src/omip_simulation.jl new file mode 100644 index 000000000..6ea898947 --- /dev/null +++ b/experiments/OMIPSimulations/src/omip_simulation.jl @@ -0,0 +1,463 @@ +using Printf +using Oceananigans.Operators: Δzᶜᶜᶜ +using Oceananigans.TurbulenceClosures: IsopycnalSkewSymmetricDiffusivity + +##### +##### Flux configurations +##### + +""" + corrected_atmosphere_ocean_fluxes(FT = Float64) + +COARE 3.6-consistent atmosphere-ocean flux formulation with: +- Wind-dependent Charnock parameter (Edson et al. 2013, eq. 13) +- COARE logarithmic similarity profile (no ψ(ℓ/L) term) +- Minimum gustiness = 0.2 m/s (Fairall et al. 2003) +- Temperature-dependent air viscosity +""" +function corrected_atmosphere_ocean_fluxes(FT = Float64) + air_kinematic_viscosity = TemperatureDependentAirViscosity(FT) + return SimilarityTheoryFluxes(FT; + similarity_form = COARELogarithmicSimilarityProfile(), + minimum_gustiness = FT(0.2), + momentum_roughness_length = MomentumRoughnessLength(FT; + wave_formulation = WindDependentWaveFormulation(FT), + air_kinematic_viscosity = TemperatureDependentAirViscosity(FT)), + temperature_roughness_length = ScalarRoughnessLength(FT; air_kinematic_viscosity), + water_vapor_roughness_length = ScalarRoughnessLength(FT; air_kinematic_viscosity)) +end + +""" + corrected_atmosphere_sea_ice_fluxes(FT = Float64) + +Atmosphere-sea ice flux formulation with: +- SHEBA/Paulson+Grachev stability functions (existing default, correct) +- Fixed momentum roughness z0 = 5e-4 m (CICE/SHEBA standard; Andreas et al. 2010) +- Fixed scalar roughness z0t = z0q = 5e-5 m (Andreas 1987: z0t ≈ z0/10 at R*≈7) +- COARE logarithmic similarity profile +- Minimum gustiness = 0.2 m/s +""" +corrected_atmosphere_sea_ice_fluxes(FT = Float64) = + SimilarityTheoryFluxes(FT; + stability_functions = atmosphere_sea_ice_stability_functions(FT), + similarity_form = COARELogarithmicSimilarityProfile(), + minimum_gustiness = FT(0.2), + momentum_roughness_length = FT(5e-4), + temperature_roughness_length = FT(5e-5), + water_vapor_roughness_length = FT(5e-5)) + +""" + corrected_ice_ocean_heat_flux() + +Three-equation ice-ocean heat flux with momentum-based friction velocity +computed from actual ice-ocean stress (McPhee 1992, 2008; SHEBA median u*≈0.01 m/s). +""" +corrected_ice_ocean_heat_flux() = ThreeEquationHeatFlux(; friction_velocity = MomentumBasedFrictionVelocity()) + +""" + ncar_atmosphere_ocean_fluxes(FT = Float64) + +OMIP-2 standard atmosphere-ocean flux formulation using the NCAR/Large & Yeager +(2004, 2009) bulk algorithm. Iterates directly on transfer coefficients (Cd, Ch, Ce), +NOT on roughness lengths. Uses 5 fixed iterations with Paulson stability functions. +""" +ncar_atmosphere_ocean_fluxes(FT = Float64) = NCARBulkFluxes(FT) + +""" + ncar_atmosphere_sea_ice_fluxes(FT = Float64) + +NCAR/CORE atmosphere-sea ice flux formulation with full Monin-Obukhov +similarity theory and stability corrections: +- Paulson (1970) + linear stable (-5ζ) stability functions (same as NCAR ocean) +- Fixed z0 = z0t = z0q = 5e-4 m (CICE default; SHEBA standard) +- Wind speed floor at 0.5 m/s +- COARE logarithmic similarity profile (no ψ(ℓ/L) term) + +Over ice the roughness lengths are fixed geometric constants (not wind-dependent), +so the standard MOST roughness-length iteration is consistent here (unlike the +ocean case where the NCAR polynomial Cd requires its own solver). +""" +ncar_atmosphere_sea_ice_fluxes(FT = Float64) = + SimilarityTheoryFluxes(FT; + stability_functions = ncar_stability_functions(FT), + similarity_form = COARELogarithmicSimilarityProfile(), + gustiness_parameter = FT(0), + minimum_gustiness = FT(0.5), + momentum_roughness_length = FT(5e-4), + temperature_roughness_length = FT(5e-4), + water_vapor_roughness_length = FT(5e-4)) + +""" + corrected_radiation(sea_ice) + +Radiation with OMIP-2 standard ocean parameters (emissivity = 1.0, albedo = 0.06) +and CCSM3 temperature/snow/thickness-dependent sea ice albedo. +""" +function corrected_radiation(sea_ice) + hi = sea_ice.model.ice_thickness + hs = sea_ice.model.snow_thickness + + # When snow is present, the snow layer owns the surface temperature; + # otherwise the ice top surface temperature is the atmosphere interface. + snow_thermo = sea_ice.model.snow_thermodynamics + Ts = if isnothing(snow_thermo) + sea_ice.model.ice_thermodynamics.top_surface_temperature + else + snow_thermo.top_surface_temperature + end + + sea_ice_albedo = SeaIceAlbedo(hi, hs, Ts) + + return Radiation(; ocean_emissivity = 1.0, + ocean_albedo = 0.06, + sea_ice_albedo) +end + +""" + build_coupled_model(ocean, sea_ice, atmosphere, radiation, flux_configuration) + +Build the `OceanSeaIceModel` with the specified flux configuration. +Options: `:default`, `:corrected`, `:ncar`. +""" +function build_coupled_model(ocean, sea_ice, atmosphere, radiation, flux_configuration) + if flux_configuration == :default + return OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + end + + FT = eltype(ocean.model.grid) + radiation = corrected_radiation(sea_ice) + + if flux_configuration == :corrected + interfaces = ComponentInterfaces(atmosphere, ocean, sea_ice; + radiation, + atmosphere_ocean_fluxes = corrected_atmosphere_ocean_fluxes(FT), + atmosphere_sea_ice_fluxes = corrected_atmosphere_sea_ice_fluxes(FT), + sea_ice_ocean_heat_flux = corrected_ice_ocean_heat_flux()) + elseif flux_configuration == :ncar + interfaces = ComponentInterfaces(atmosphere, ocean, sea_ice; + radiation, + atmosphere_ocean_fluxes = ncar_atmosphere_ocean_fluxes(FT), + atmosphere_sea_ice_fluxes = ncar_atmosphere_sea_ice_fluxes(FT), + sea_ice_ocean_heat_flux = corrected_ice_ocean_heat_flux()) + else + error("Unknown flux_configuration: $flux_configuration. Options: :default, :corrected, :ncar") + end + + return OceanSeaIceModel(ocean, sea_ice; atmosphere, interfaces) +end + +##### +##### Main simulation builder +##### + +""" + omip_simulation(config::Symbol = :halfdegree; kwargs...) + +Create a fully coupled ocean--sea-ice--atmosphere OMIP simulation. + +The single positional argument selects the grid configuration: + +- `:halfdegree` -- 720x360 `TripolarGrid` +- `:tenthdegree` -- 3600x1800 `TripolarGrid` +- `:orca` -- NEMO eORCA mesh + +Returns a `Simulation` wrapping an `OceanSeaIceModel`. The simulation +already has a progress callback attached, and (when `diagnostics=true`) +the OMIP-protocol output writers from [`add_omip_diagnostics!`](@ref). + +To restart from a previous run, simply call + + run!(sim; pickup = true) + +which uses Oceananigans' built-in `Checkpointer` machinery — no extra +plumbing is needed because `NumericalEarth.EarthSystemModels` provides +`prognostic_state` / `restore_prognostic_state!` for the coupled model. + +# Keyword arguments + +- `arch`: architecture (`CPU()` or `GPU()`). Default: `CPU()`. +- `Nz::Int`: number of vertical levels. Default: `100`. +- `depth`: maximum ocean depth in metres. Default: `5500`. +- `κ_skew`, `κ_symmetric`: GM/Redi diffusivities. Defaults: `500`, `100`. +- `forcing_dir`: directory for JRA55 forcing data. Default: `"forcing_data"`. +- `restoring_dir`: directory for restoring/IC climatology. Default: `"climatology"`. +- `piston_velocity`: surface salinity restoring piston velocity in m/day. Default: `1/6`. + Restoring is automatically masked by sea ice concentration (no restoring under ice). +- `start_date`, `end_date`: bracket for forcing/restoring metadata. Defaults: 1958-01-01 .. 2018-01-01. +- `Δt`: simulation time step. Default: `30minutes`. +- `stop_time`: stop time for the wrapping `Simulation`. Default: `Inf`. +- `flux_configuration`: surface flux formulation. Options: + * `:default` — current defaults (Edson/COARE with constant Charnock 0.02) + * `:corrected` — COARE 3.6 with wind-dependent Charnock, fixed ice roughness, momentum-based u* + * `:ncar` — OMIP-2 standard Large & Yeager (2004) bulk formulae +- `diagnostics::Bool`: whether to attach OMIP diagnostics. Default: `true`. +- `surface_averaging_interval`, `field_averaging_interval`: averaging windows. +- `checkpoint_interval`: interval between checkpoint writes. +- `output_dir`, `filename_prefix`, `file_splitting_interval`: output configuration. +""" +function omip_simulation(config::Symbol = :halfdegree; + arch = CPU(), + Nz = 100, + depth = 5500, + κ_skew = 250, + κ_symmetric = 100, + biharmonic_timescale = 40days, + forcing_dir = "forcing_data", + restoring_dir = "climatology", + piston_velocity = 1 / 6, # m / day + start_date = DateTime(1958, 1, 1), + end_date = DateTime(2018, 1, 1), + Δt = 30minutes, + stop_time = Inf, + flux_configuration = :default, + with_snow = false, + diagnostics = true, + field_mean_interval = 5days, + surface_averaging_interval = 5days, + field_averaging_interval = 15days, + checkpoint_interval = 360days, + output_dir = ".", + filename_prefix = string(config), + file_splitting_interval = 360days) + + cfg = Val(config) + + # Build the grid first so we can allocate the restoring mask + grid = build_grid(cfg, arch, Nz, depth) + + # Pre-allocate restoring mask (1 = open water); updated each step from sea ice concentration + ice_free_fraction = Field{Center, Center, Nothing}(grid) + set!(ice_free_fraction, 1) + + ocean = build_ocean(cfg, grid; + κ_skew, κ_symmetric, + biharmonic_timescale, + restoring_dir, piston_velocity, + restoring_mask = ice_free_fraction, + start_date, end_date) + + sea_ice = build_sea_ice(cfg, grid, ocean; restoring_dir, with_snow) + + atmosphere, radiation = omip_atmosphere(arch; + forcing_dir, + start_date, + end_date) + + coupled = build_coupled_model(ocean, sea_ice, atmosphere, radiation, flux_configuration) + + simulation = Simulation(coupled; Δt, stop_time) + + for dir in [forcing_dir, restoring_dir, output_dir] + if !isdir(dir) + mkdir(dir) + end + end + + # Callback to sync the restoring mask with sea ice concentration each coupled step + ℵ = sea_ice.model.ice_concentration + update_restoring_mask!(sim) = parent(ice_free_fraction) .= 1 .- parent(ℵ) + add_callback!(simulation, update_restoring_mask!, IterationInterval(1)) + + wall_time = Ref(time_ns()) + add_callback!(simulation, omip_progress_callback(wall_time), IterationInterval(10)) + + if diagnostics + add_omip_diagnostics!(simulation; + surface_averaging_interval, + field_averaging_interval, + field_mean_interval, + checkpoint_interval, + output_dir, + filename_prefix, + file_splitting_interval) + end + + return simulation +end + +##### +##### Shared closure utilities +##### + +@inline νhb(i, j, k, grid, ℓx, ℓy, ℓz, clock, fields, λ) = Oceananigans.Operators.Az(i, j, k, grid, ℓx, ℓy, ℓz)^2 / λ + +# Background tracer diffusivity following Henyey et al. (1986). +@inline henyey_diffusivity(x, y, z, t) = max(1e-6, 5e-6 * abs(sind(y))) + +function omip_closure(; κ_skew, κ_symmetric, biharmonic_timescale) + catke = default_ocean_closure() + + eddy = if isnothing(κ_skew) | isnothing(κ_symmetric) + nothing + else + IsopycnalSkewSymmetricDiffusivity(; κ_skew, κ_symmetric) + end + + horizontal_viscosity = if isnothing(biharmonic_timescale) + nothing + else + HorizontalScalarBiharmonicDiffusivity(ν=νhb, + discrete_form=true, + parameters=biharmonic_timescale) + end + + vertical_diffusivity = VerticalScalarDiffusivity(κ=henyey_diffusivity, ν=3e-5) + + return filter(!isnothing, (catke, eddy, horizontal_viscosity, vertical_diffusivity)) +end + +##### +##### Salinity restoring (shared by both configurations) +##### + +function salinity_restoring_forcing(grid, dataset; + restoring_dir, + piston_velocity, + mask = 1) + + Nz = size(grid, 3) + Δz_surface = CUDA.@allowscalar Δzᶜᶜᶜ(1, 1, Nz, grid) + + rate = piston_velocity / (Δz_surface * days) + + Smetadata = Metadata(:salinity; + dir = restoring_dir, + dataset) + + return DatasetRestoring(Smetadata, Oceananigans.Architectures.architecture(grid); + rate, mask, + time_indices_in_memory = 12) +end + +##### +##### Grid builder +##### + +function build_grid(config, arch, Nz, depth) + + Nx = config == Val(:halfdegree) ? 720 : + config == Val(:tenthdegree) ? 3600 : + throw("Configuration $(config) does not exist") + + Ny = Nx ÷ 2 + + z_faces = ExponentialDiscretization(Nz, -depth, 0; scale=1300, mutable=true) + + base_grid = TripolarGrid(arch; + size = (Nx, Ny, Nz), + z = z_faces, + halo = (7, 7, 7)) + + bottom_height = regrid_bathymetry(base_grid; + minimum_depth = 20, + major_basins = 1, + interpolation_passes = 25) + + return ImmersedBoundaryGrid(base_grid, GridFittedBottom(bottom_height); active_cells_map = true) +end + +function build_grid(::Val{:orca}, arch, Nz, depth) + + z_faces = ExponentialDiscretization(Nz, -depth, 0; scale=1300, mutable=true) + + return ORCAGrid(arch; + dataset = ORCA1(), + Nz, + z = z_faces, + halo = (7, 7, 7), + with_bathymetry = true, + active_cells_map = true) +end + +##### +##### ORCA builder +##### + +config_momentum_advection(::Val{:orca}) = VectorInvariant() +config_momentum_advection(::Val{:halfdegree}) = WENOVectorInvariant(order=5) +config_momentum_advection(::Val{:tenthdegree}) = WENOVectorInvariant() + +function build_ocean(config, grid; + κ_skew, κ_symmetric, + restoring_dir, piston_velocity, + biharmonic_timescale, + restoring_mask = 1, + start_date, end_date) + + FS = salinity_restoring_forcing(grid, WOAMonthly(); restoring_dir, piston_velocity, mask = restoring_mask) + + closure = omip_closure(; κ_skew, κ_symmetric, biharmonic_timescale) + coriolis = HydrostaticSphericalCoriolis(scheme = Oceananigans.Coriolis.EnstrophyConserving()) + momentum_advection = config_momentum_advection(config) + + ocean = ocean_simulation(grid; + Δt = 1minutes, + momentum_advection, + tracer_advection = WENO(order=7; minimum_buffer_upwind_order=3), + coriolis, + timestepper = :SplitRungeKutta3, + free_surface = SplitExplicitFreeSurface(grid; substeps=70), + surface_restoring = (; S = FS), + closure) + + set!(ocean.model, + T = Metadatum(:temperature; dir=restoring_dir, dataset=WOAAnnual(), date=start_date), + S = Metadatum(:salinity; dir=restoring_dir, dataset=WOAAnnual(), date=start_date)) + + return ocean +end + +##### +##### Sea Ice builder +##### + +function build_sea_ice(config, grid, ocean; restoring_dir, with_snow = false) + sea_ice = sea_ice_simulation(grid, ocean; + advection = WENO(order=7, minimum_buffer_upwind_order=1), + with_snow) + + set!(sea_ice.model, + h = Metadatum(:sea_ice_thickness; dir=restoring_dir, dataset=ECCO4Monthly()), + ℵ = Metadatum(:sea_ice_concentration; dir=restoring_dir, dataset=ECCO4Monthly())) + + return sea_ice +end + +##### +##### Progress callback +##### + +function omip_progress_callback(wall_time) + function progress(sim) + sea_ice = sim.model.sea_ice + ocean = sim.model.ocean + + hmax = maximum(sea_ice.model.ice_thickness) + ℵmax = maximum(sea_ice.model.ice_concentration) + Tmax = maximum(ocean.model.tracers.T) + Tmin = minimum(ocean.model.tracers.T) + Smax = maximum(ocean.model.tracers.S) + Smin = minimum(ocean.model.tracers.S) + umax = maximum(ocean.model.velocities.u) + vmax = maximum(ocean.model.velocities.v) + wmax = maximum(ocean.model.velocities.w) + + step_time = 1e-9 * (time_ns() - wall_time[]) + + msg1 = @sprintf("time: %s, iteration: %d, Δt: %s, ", + prettytime(sim), iteration(sim), prettytime(sim.Δt)) + msg2 = @sprintf("max(h): %.2e m, max(ℵ): %.2e ", hmax, ℵmax) + msg3 = @sprintf("extrema(T, S): (%.2f, %.2f) ᵒC, (%.2f, %.2f) psu ", + Tmin, Tmax, Smin, Smax) + msg4 = @sprintf("maximum(u): (%.2e, %.2e, %.2e) m/s, ", umax, vmax, wmax) + msg5 = @sprintf("wall time: %s", prettytime(step_time)) + + @info msg1 * msg2 * msg3 * msg4 * msg5 + + wall_time[] = time_ns() + + return nothing + end + + return progress +end diff --git a/experiments/OMIPSimulations/src/report_fields.jl b/experiments/OMIPSimulations/src/report_fields.jl new file mode 100644 index 000000000..79348e6d3 --- /dev/null +++ b/experiments/OMIPSimulations/src/report_fields.jl @@ -0,0 +1,83 @@ +using Oceananigans.AbstractOperations: KernelFunctionOperation +using Oceananigans.Operators: ζ₃ᶠᶠᶜ, ℑxᶜᵃᵃ, ℑyᵃᶜᵃ +using Oceananigans.Architectures: child_architecture +using Oceananigans.Fields: interpolate! +using NumericalEarth.DataWrangling: WOAAnnual +using NumericalEarth.Diagnostics: MixedLayerDepthField +using WorldOceanAtlasTools + +@inline function speedᶜᶜᶜ(i, j, k, grid, u, v) + û = ℑxᶜᵃᵃ(i, j, k, grid, u) + v̂ = ℑyᵃᶜᵃ(i, j, k, grid, v) + return sqrt(û^2 + v̂^2) +end + +""" + compute_report_fields(ocean; dataset = WOAAnnual()) + +Compute a `NamedTuple` of diagnostic fields from the current state of +`ocean`. Returns surface-level slices and zonal averages, plus +differences against the WOA climatology specified by `dataset`. + +Returned fields: +- `SST`, `SSS`: 2-D surface temperature and salinity +- `spd`: surface speed sqrt(u^2 + v^2) +- `ζ`: surface vertical vorticity +- `MLD`: mixed-layer depth +- `T̄`, `S̄`: zonally averaged temperature and salinity (latitude × depth) +- `δT`, `δS`: SST/SSS minus WOA climatology +- `φ`: latitude coordinates of the zonal averages +- `z`: depth coordinates of the zonal averages +""" +function compute_report_fields(ocean; dataset = WOAAnnual()) + grid = ocean.model.grid + arch = child_architecture(grid) + Nz = size(grid, 3) + + u, v, w = ocean.model.velocities + T = ocean.model.tracers.T + S = ocean.model.tracers.S + + SST = Array(interior(T, :, :, Nz)) + SSS = Array(interior(S, :, :, Nz)) + + spd_op = KernelFunctionOperation{Center, Center, Center}(speedᶜᶜᶜ, grid, u, v) + spd_field = Field(spd_op; indices = (:, :, Nz)) + compute!(spd_field) + spd = Array(interior(spd_field, :, :, 1)) + + ζ_op = KernelFunctionOperation{Face, Face, Center}(ζ₃ᶠᶠᶜ, grid, u, v) + ζ_field = Field(ζ_op; indices = (:, :, Nz)) + compute!(ζ_field) + ζ = Array(interior(ζ_field, :, :, 1)) + + h = MixedLayerDepthField(ocean.model.buoyancy, grid, ocean.model.tracers) + compute!(h) + MLD = Array(interior(h, :, :, 1)) + + δT, δS = compute_woa_bias(grid, arch, T, S, Nz, dataset) + + return (; SST, SSS, spd, ζ, MLD, δT, δS) +end + +""" + compute_woa_bias(grid, arch, T, S, Nz, dataset) + +Return `(δT, δS)`, the surface temperature and salinity differences +between the current state and the WOA climatology specified by +`dataset` (default `WOAAnnual()`). +""" +function compute_woa_bias(grid, arch, T, S, Nz, dataset) + Tʷ = Field(Metadatum(:temperature; dataset), arch) + Sʷ = Field(Metadatum(:salinity; dataset), arch) + + Tᵢ = CenterField(grid) + Sᵢ = CenterField(grid) + interpolate!(Tᵢ, Tʷ) + interpolate!(Sᵢ, Sʷ) + + δT = Array(interior(T, :, :, Nz)) .- Array(interior(Tᵢ, :, :, Nz)) + δS = Array(interior(S, :, :, Nz)) .- Array(interior(Sᵢ, :, :, Nz)) + + return δT, δS +end From fea71d9a9888f6e67fe0616ba213ff79a61ed6e0 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 16 Apr 2026 09:57:17 +0200 Subject: [PATCH 17/54] Fix snow NaN: PrescribedTemperature for snow top BC + interface temperature dispatch Two fixes from ss/omip-prototype debugging: 1. default_snow_thermodynamics: pass PrescribedTemperature as the snow top heat BC. Without this, ClimaSeaIce runs its own surface temperature solve (MeltingConstrainedFluxBalance) that conflicts with NumericalEarth's coupled flux solver, producing NaN on the first time step. 2. atmosphere_sea_ice_interface: use snow_thermo.top_surface_temperature when snow is present. The atmosphere interacts with the snow surface, not the ice-snow interface. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../InterfaceComputations/component_interfaces.jl | 9 ++++++++- src/SeaIces/sea_ice_simulation.jl | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl b/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl index f58f607aa..b0ffdf698 100644 --- a/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl +++ b/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl @@ -176,7 +176,14 @@ function atmosphere_sea_ice_interface(grid, temperature_formulation, velocity_formulation) - interface_temperature = sea_ice.model.ice_thermodynamics.top_surface_temperature + # When snow is present, the atmosphere interacts with the snow surface; + # otherwise with the ice top surface. + snow_thermo = sea_ice.model.snow_thermodynamics + interface_temperature = if isnothing(snow_thermo) + sea_ice.model.ice_thermodynamics.top_surface_temperature + else + snow_thermo.top_surface_temperature + end return AtmosphereInterface(fluxes, ai_flux_formulation, interface_temperature, properties) end diff --git a/src/SeaIces/sea_ice_simulation.jl b/src/SeaIces/sea_ice_simulation.jl index 48a7d40c2..009ac1617 100644 --- a/src/SeaIces/sea_ice_simulation.jl +++ b/src/SeaIces/sea_ice_simulation.jl @@ -19,7 +19,13 @@ function default_snow_thermodynamics(grid) FT = eltype(grid) snow_conductivity = FT(0.31) snow_density = FT(330) - return snow_slab_thermodynamics(grid; conductivity = snow_conductivity, density = snow_density) + # Use PrescribedTemperature so ClimaSeaIce does NOT run its own surface solve; + # the coupled flux solver in NumericalEarth handles the snow surface temperature. + snow_surface_temperature = Field{Center, Center, Nothing}(grid) + top_heat_boundary_condition = PrescribedTemperature(snow_surface_temperature.data) + return snow_slab_thermodynamics(grid; conductivity = snow_conductivity, + density = snow_density, + top_heat_boundary_condition) end function sea_ice_simulation(grid, ocean=nothing; From 6f561aabe7ed1872917fc1b027c33c58893ca12b Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 16 Apr 2026 09:57:31 +0200 Subject: [PATCH 18/54] Remove OMIPSimulations files from snow model PR These were accidentally included from the omip-prototype branch. The experiments/ directory belongs in ss/omip-prototype, not here. Co-Authored-By: Claude Opus 4.6 (1M context) --- experiments/OMIPSimulations/Project.toml | 25 - .../visualize_omip-checkpoint.ipynb | 630 ---------------- experiments/OMIPSimulations/scripts/launch.sh | 271 ------- experiments/OMIPSimulations/scripts/store.sh | 194 ----- .../scripts/visualize_omip.ipynb | 502 ------------- .../OMIPSimulations/scripts/visualize_omip.jl | 693 ------------------ .../OMIPSimulations/scripts/watchdog.sh | 26 - .../OMIPSimulations/src/OMIPSimulations.jl | 79 -- experiments/OMIPSimulations/src/atmosphere.jl | 28 - .../OMIPSimulations/src/omip_diagnostics.jl | 187 ----- .../OMIPSimulations/src/omip_simulation.jl | 463 ------------ .../OMIPSimulations/src/report_fields.jl | 83 --- 12 files changed, 3181 deletions(-) delete mode 100644 experiments/OMIPSimulations/Project.toml delete mode 100644 experiments/OMIPSimulations/scripts/.ipynb_checkpoints/visualize_omip-checkpoint.ipynb delete mode 100755 experiments/OMIPSimulations/scripts/launch.sh delete mode 100755 experiments/OMIPSimulations/scripts/store.sh delete mode 100644 experiments/OMIPSimulations/scripts/visualize_omip.ipynb delete mode 100644 experiments/OMIPSimulations/scripts/visualize_omip.jl delete mode 100755 experiments/OMIPSimulations/scripts/watchdog.sh delete mode 100644 experiments/OMIPSimulations/src/OMIPSimulations.jl delete mode 100644 experiments/OMIPSimulations/src/atmosphere.jl delete mode 100644 experiments/OMIPSimulations/src/omip_diagnostics.jl delete mode 100644 experiments/OMIPSimulations/src/omip_simulation.jl delete mode 100644 experiments/OMIPSimulations/src/report_fields.jl diff --git a/experiments/OMIPSimulations/Project.toml b/experiments/OMIPSimulations/Project.toml deleted file mode 100644 index 3d02d7783..000000000 --- a/experiments/OMIPSimulations/Project.toml +++ /dev/null @@ -1,25 +0,0 @@ -name = "OMIPSimulations" -uuid = "5ac3a3a1-7b1f-4d7e-9c5e-1e6c9d9b2a4d" -version = "0.1.0" -authors = ["NumericalEarth contributors"] - -[deps] -CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" -ClimaSeaIce = "6ba0ff68-24e6-4315-936c-2e99227c95a4" -ConservativeRegridding = "8e50ac2c-eb48-49bc-a402-07c87b949343" -Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" -JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" -NCDatasets = "85f8d34a-cbdd-5861-8df4-14fed0d494ab" -NumericalEarth = "904d977b-046a-4731-8b86-9235c0d1ef02" -Oceananigans = "9e8cae18-63c1-5223-a75c-80ca9d6e9a09" -Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" -Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" -WorldOceanAtlasTools = "04f20302-f1b9-11e8-29d9-7d841cb0a64a" - -[sources] -NumericalEarth = {path = "../.."} - -[compat] -ConservativeRegridding = "0.2.0" -Oceananigans = "0.106.5, 0.107, 0.189" -julia = "1.10" diff --git a/experiments/OMIPSimulations/scripts/.ipynb_checkpoints/visualize_omip-checkpoint.ipynb b/experiments/OMIPSimulations/scripts/.ipynb_checkpoints/visualize_omip-checkpoint.ipynb deleted file mode 100644 index 05b9d768d..000000000 --- a/experiments/OMIPSimulations/scripts/.ipynb_checkpoints/visualize_omip-checkpoint.ipynb +++ /dev/null @@ -1,630 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# OMIP Simulation Diagnostics\n", - "\n", - "Post-processing visualization loosely following Adcroft et al. (2019),\n", - "*The GFDL Global Ocean and Sea Ice Model OM4.0*, JAMES.\n", - "\n", - "1. Time-mean SST / SSS and bias vs WOA\n", - "2. SSH, MLD, sea-ice concentration (March & September)\n", - "3. Surface heat and freshwater fluxes\n", - "4. Global-mean T & S drift, horizontal-mean profiles\n", - "5. Zonal-mean T, S, b and difference from initial conditions (WOA),\n", - " computed via `ConservativeRegridding` to a regular lat-lon grid" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "run_dir = \"halfdegree_run\" # <-- path to the _run folder\n", - "prefix = replace(basename(run_dir), \"_run\" => \"\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "using CairoMakie\n", - "using Statistics\n", - "using Dates\n", - "using Oceananigans\n", - "using Oceananigans.Grids: znodes, φnodes\n", - "using Oceananigans.Fields: interpolate!\n", - "using ConservativeRegridding\n", - "using NumericalEarth\n", - "using NumericalEarth.DataWrangling: Metadatum\n", - "using NumericalEarth.DataWrangling.WOA: WOAAnnual" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# ── Helpers ──────────────────────────────────────────────────\n", - "\n", - "function find_first_file(run_dir, prefix, group)\n", - " tag = \"$(prefix)_$(group)\"\n", - " candidates = filter(f -> startswith(f, tag) && endswith(f, \".jld2\") &&\n", - " !contains(f, \"checkpoint\"), readdir(run_dir))\n", - " isempty(candidates) && error(\"No $group files found for prefix '$prefix' in $run_dir\")\n", - " return joinpath(run_dir, first(sort(candidates)))\n", - "end\n", - "\n", - "function compute_time_mean(fts)\n", - " Nt = length(fts.times)\n", - " avg = zeros(size(Array(interior(fts[1]))))\n", - " for n in 1:Nt\n", - " avg .+= Array(interior(fts[n]))\n", - " end\n", - " return avg ./ Nt\n", - "end\n", - "\n", - "function compute_monthly_mean(fts, target_months; start_date = DateTime(1958, 1, 1))\n", - " dates = [start_date + Second(round(Int, t)) for t in fts.times]\n", - " idx = findall(d -> month(d) in target_months, dates)\n", - " isempty(idx) && return nothing\n", - " avg = zeros(size(Array(interior(fts[1]))))\n", - " for n in idx\n", - " avg .+= Array(interior(fts[n]))\n", - " end\n", - " return avg ./ length(idx)\n", - "end\n", - "\n", - "function build_land_mask(grid)\n", - " if grid isa ImmersedBoundaryGrid\n", - " bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1))\n", - " return bh .>= 0\n", - " else\n", - " return falses(size(grid, 1), size(grid, 2))\n", - " end\n", - "end\n", - "\n", - "function build_ocean_mask_3d(grid)\n", - " Nx, Ny, Nz = size(grid)\n", - " mask = ones(Nx, Ny, Nz)\n", - " if grid isa ImmersedBoundaryGrid\n", - " bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1))\n", - " zc = znodes(grid, Center())\n", - " for k in 1:Nz, j in 1:Ny, i in 1:Nx\n", - " zc[k] < bh[i, j] && (mask[i, j, k] = 0.0)\n", - " end\n", - " end\n", - " return mask\n", - "end\n", - "\n", - "mask_land!(f, land) = (f[land] .= NaN; f)\n", - "\n", - "function panel!(fig, pos, data;\n", - " title=\"\", colormap=:thermal,\n", - " colorrange=nothing, label=\"\",\n", - " nan_color=:lightgray)\n", - " ax = Axis(fig[pos...]; title)\n", - " kw = isnothing(colorrange) ? (;) : (; colorrange)\n", - " hm = heatmap!(ax, data; colormap, nan_color, kw...)\n", - " Colorbar(fig[pos[1], pos[2]+1], hm; label)\n", - " return ax\n", - "end\n", - "\n", - "function sidebyside!(fig, row, left, right;\n", - " title_l=\"\", title_r=\"\",\n", - " cmap_l=:thermal, cmap_r=:balance,\n", - " cr_l=nothing, cr_r=nothing,\n", - " label_l=\"\", label_r=\"\")\n", - " panel!(fig, [row, 1], left; title=title_l, colormap=cmap_l, colorrange=cr_l, label=label_l)\n", - " panel!(fig, [row, 3], right; title=title_r, colormap=cmap_r, colorrange=cr_r, label=label_r)\n", - "end" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Load surface diagnostics" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "surface_file = find_first_file(run_dir, prefix, \"surface\")\n", - "@info \"Surface file: $surface_file\"\n", - "\n", - "tos = FieldTimeSeries(surface_file, \"tos\"; backend = OnDisk())\n", - "sos = FieldTimeSeries(surface_file, \"sos\"; backend = OnDisk())\n", - "zos = FieldTimeSeries(surface_file, \"zos\"; backend = OnDisk())\n", - "mld_fts = FieldTimeSeries(surface_file, \"mlotst\"; backend = OnDisk())\n", - "hfds = FieldTimeSeries(surface_file, \"hfds\"; backend = OnDisk())\n", - "wfo = FieldTimeSeries(surface_file, \"wfo\"; backend = OnDisk())\n", - "sic = FieldTimeSeries(surface_file, \"siconc\"; backend = OnDisk())\n", - "\n", - "grid = tos.grid\n", - "Nx, Ny, Nz = size(grid)\n", - "land = build_land_mask(grid)\n", - "@info \"Grid: $Nx x $Ny x $Nz | $(length(tos.times)) surface snapshots\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "SST = dropdims(compute_time_mean(tos); dims=3)\n", - "SSS = dropdims(compute_time_mean(sos); dims=3)\n", - "SSH = dropdims(compute_time_mean(zos); dims=3)\n", - "MLD_avg = dropdims(compute_time_mean(mld_fts); dims=3)\n", - "HF = dropdims(compute_time_mean(hfds); dims=3)\n", - "FW = dropdims(compute_time_mean(wfo); dims=3)\n", - "SIC = dropdims(compute_time_mean(sic); dims=3)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## WOA comparison" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "T_woa = Field(Metadatum(:temperature; dataset = WOAAnnual()), CPU())\n", - "S_woa = Field(Metadatum(:salinity; dataset = WOAAnnual()), CPU())\n", - "\n", - "T_interp = CenterField(grid)\n", - "S_interp = CenterField(grid)\n", - "interpolate!(T_interp, T_woa)\n", - "interpolate!(S_interp, S_woa)\n", - "\n", - "# Full 3-D WOA on model grid (reused later for zonal-mean bias)\n", - "T_woa_on_grid = Array(interior(T_interp))\n", - "S_woa_on_grid = Array(interior(S_interp))\n", - "\n", - "SST_woa = T_woa_on_grid[:, :, Nz]\n", - "SSS_woa = S_woa_on_grid[:, :, Nz]\n", - "\n", - "δSST = SST .- SST_woa\n", - "δSSS = SSS .- SSS_woa\n", - "\n", - "for f in (SST, SSS, SSH, MLD_avg, HF, FW, SIC, δSST, δSSS)\n", - " mask_land!(f, land)\n", - "end" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 1 -- SST and WOA bias (cf. OM4 Fig. 3)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = Figure(size = (1600, 550), fontsize = 14)\n", - "sidebyside!(fig, 1, SST, δSST;\n", - " title_l = \"Time-mean SST\", title_r = \"SST - WOA\",\n", - " cmap_l = :thermal, cr_l = (-2, 32),\n", - " cmap_r = :balance, cr_r = (-5, 5),\n", - " label_l = \"deg C\", label_r = \"deg C\")\n", - "fig" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 2 -- SSS and WOA bias (cf. OM4 Fig. 4)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = Figure(size = (1600, 550), fontsize = 14)\n", - "sidebyside!(fig, 1, SSS, δSSS;\n", - " title_l = \"Time-mean SSS\", title_r = \"SSS - WOA\",\n", - " cmap_l = :haline, cr_l = (30, 38),\n", - " cmap_r = :balance, cr_r = (-3, 3),\n", - " label_l = \"PSU\", label_r = \"PSU\")\n", - "fig" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 3 -- SSH (cf. OM4 Fig. 5)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = Figure(size = (900, 500), fontsize = 14)\n", - "panel!(fig, [1, 1], SSH;\n", - " title = \"Time-mean SSH\", colormap = :balance,\n", - " colorrange = (-2, 2), label = \"m\")\n", - "fig" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 4 -- MLD March / September (cf. OM4 Figs. 6-7)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "MLD_mar = compute_monthly_mean(mld_fts, [3])\n", - "MLD_sep = compute_monthly_mean(mld_fts, [9])\n", - "\n", - "fig = Figure(size = (1600, 550), fontsize = 14)\n", - "if !isnothing(MLD_mar)\n", - " d = dropdims(MLD_mar; dims=3); mask_land!(d, land)\n", - " panel!(fig, [1, 1], d; title=\"MLD -- March\",\n", - " colormap=Reverse(:deep), colorrange=(0, 500), label=\"m\")\n", - "end\n", - "if !isnothing(MLD_sep)\n", - " d = dropdims(MLD_sep; dims=3); mask_land!(d, land)\n", - " panel!(fig, [1, 3], d; title=\"MLD -- September\",\n", - " colormap=Reverse(:deep), colorrange=(0, 500), label=\"m\")\n", - "end\n", - "fig" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 5 -- Sea-ice concentration March / September (cf. OM4 Figs. 9-10)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "SIC_mar = compute_monthly_mean(sic, [3])\n", - "SIC_sep = compute_monthly_mean(sic, [9])\n", - "\n", - "fig = Figure(size = (1600, 550), fontsize = 14)\n", - "if !isnothing(SIC_mar)\n", - " d = dropdims(SIC_mar; dims=3); mask_land!(d, land)\n", - " panel!(fig, [1, 1], d; title=\"Sea-ice conc. -- March\",\n", - " colormap=:ice, colorrange=(0, 1), label=\"fraction\")\n", - "end\n", - "if !isnothing(SIC_sep)\n", - " d = dropdims(SIC_sep; dims=3); mask_land!(d, land)\n", - " panel!(fig, [1, 3], d; title=\"Sea-ice conc. -- September\",\n", - " colormap=:ice, colorrange=(0, 1), label=\"fraction\")\n", - "end\n", - "fig" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 6 -- Surface fluxes (cf. OM4 Figs. 11-12)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = Figure(size = (1600, 550), fontsize = 14)\n", - "sidebyside!(fig, 1, HF, FW;\n", - " title_l = \"Net surface heat flux\",\n", - " title_r = \"Net freshwater flux\",\n", - " cmap_l = :balance, cr_l = (-200, 200),\n", - " cmap_r = :balance, cr_r = (-1e-4, 1e-4),\n", - " label_l = \"W/m^2\", label_r = \"kg/m^2/s\")\n", - "fig" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 7 -- Global-mean T and S drift (cf. OM4 Fig. 13)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "avg_file = find_first_file(run_dir, prefix, \"averages\")\n", - "\n", - "tosga_fts = FieldTimeSeries(avg_file, \"tosga\"; backend = OnDisk())\n", - "soga_fts = FieldTimeSeries(avg_file, \"soga\"; backend = OnDisk())\n", - "\n", - "tosga = [Array(interior(tosga_fts[n]))[1] for n in 1:length(tosga_fts.times)]\n", - "soga = [Array(interior(soga_fts[n]))[1] for n in 1:length(soga_fts.times)]\n", - "t_years = tosga_fts.times ./ (365.25 * 24 * 3600)\n", - "\n", - "fig = Figure(size = (1200, 450), fontsize = 14)\n", - "ax = Axis(fig[1, 1]; xlabel=\"Time (years)\", ylabel=\"dT (deg C)\",\n", - " title=\"Global-mean temperature drift\")\n", - "lines!(ax, t_years, tosga .- tosga[1]; color=:firebrick)\n", - "\n", - "ax = Axis(fig[1, 2]; xlabel=\"Time (years)\", ylabel=\"dS (PSU)\",\n", - " title=\"Global-mean salinity drift\")\n", - "lines!(ax, t_years, soga .- soga[1]; color=:royalblue)\n", - "fig" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 8 -- Horizontal-mean T and S profiles (cf. OM4 Fig. 14)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "to_h_fts = FieldTimeSeries(avg_file, \"to_h\"; backend = OnDisk())\n", - "so_h_fts = FieldTimeSeries(avg_file, \"so_h\"; backend = OnDisk())\n", - "\n", - "T_prof = vec(compute_time_mean(to_h_fts))\n", - "S_prof = vec(compute_time_mean(so_h_fts))\n", - "z = collect(znodes(grid, Center()))\n", - "\n", - "fig = Figure(size = (1000, 600), fontsize = 14)\n", - "ax = Axis(fig[1, 1]; xlabel=\"Temperature (deg C)\", ylabel=\"Depth (m)\",\n", - " title=\"Horizontal-mean temperature\")\n", - "lines!(ax, T_prof, z; color=:firebrick)\n", - "ylims!(ax, (-5500, 0))\n", - "\n", - "ax = Axis(fig[1, 2]; xlabel=\"Salinity (PSU)\", ylabel=\"Depth (m)\",\n", - " title=\"Horizontal-mean salinity\")\n", - "lines!(ax, S_prof, z; color=:royalblue)\n", - "ylims!(ax, (-5500, 0))\n", - "fig" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Zonal-mean sections\n", - "\n", - "Regrid the 3-D time-mean fields from the native (tripolar / ORCA) grid\n", - "to a regular 1-degree latitude-longitude grid via `ConservativeRegridding`,\n", - "then average over longitude to obtain latitude-depth sections.\n", - "An ocean mask is carried through the regridding so that immersed cells\n", - "do not contaminate the zonal averages." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Target lat-lon grid (1 degree)\n", - "Nlon, Nlat = 360, 180\n", - "latlon_grid = LatitudeLongitudeGrid(CPU();\n", - " size = (Nlon, Nlat, 1),\n", - " longitude = (0, 360),\n", - " latitude = (-90, 90),\n", - " z = (0, 1))\n", - "\n", - "src_f = Field{Center, Center, Nothing}(grid)\n", - "dst_f = Field{Center, Center, Nothing}(latlon_grid)\n", - "\n", - "@info \"Building conservative regridder (this may take a few minutes)...\"\n", - "regridder = ConservativeRegridding.Regridder(dst_f, src_f; progress = true)\n", - "@info \"Regridder ready.\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "\"\"\"Regrid a 3-D field level-by-level and compute the\n", - "ocean-area-weighted zonal mean using a carried ocean mask.\"\"\"\n", - "function compute_zonal_mean(data_3d, ocean_mask_3d, regridder, Nlon, Nlat)\n", - " Nz = size(data_3d, 3)\n", - " zonal = fill(NaN, Nlat, Nz)\n", - " dst_data = zeros(Nlon * Nlat)\n", - " dst_mask = zeros(Nlon * Nlat)\n", - " areas = regridder.dst_areas\n", - "\n", - " for k in 1:Nz\n", - " ConservativeRegridding.regrid!(dst_data, regridder,\n", - " vec(data_3d[:, :, k] .* ocean_mask_3d[:, :, k]))\n", - " ConservativeRegridding.regrid!(dst_mask, regridder,\n", - " vec(ocean_mask_3d[:, :, k]))\n", - "\n", - " data_sum = reshape(dst_data .* areas, Nlon, Nlat)\n", - " mask_sum = reshape(dst_mask .* areas, Nlon, Nlat)\n", - "\n", - " for j in 1:Nlat\n", - " m = sum(@view mask_sum[:, j])\n", - " m > 0 && (zonal[j, k] = sum(@view data_sum[:, j]) / m)\n", - " end\n", - " end\n", - " return zonal\n", - "end" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "@info \"Loading 3-D field time series...\"\n", - "fields_file = find_first_file(run_dir, prefix, \"fields\")\n", - "\n", - "to_fts = FieldTimeSeries(fields_file, \"to\"; backend = OnDisk())\n", - "so_fts = FieldTimeSeries(fields_file, \"so\"; backend = OnDisk())\n", - "bo_fts = FieldTimeSeries(fields_file, \"bo\"; backend = OnDisk())\n", - "\n", - "@info \"$(length(to_fts.times)) field snapshots -- computing time means...\"\n", - "T_mean = compute_time_mean(to_fts)\n", - "S_mean = compute_time_mean(so_fts)\n", - "b_mean = compute_time_mean(bo_fts)\n", - "\n", - "# Initial-condition buoyancy (first averaged snapshot)\n", - "b_init = Array(interior(bo_fts[1]))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ocean_mask = build_ocean_mask_3d(grid)\n", - "\n", - "@info \"Computing zonal means (model)...\"\n", - "T_zonal = compute_zonal_mean(T_mean, ocean_mask, regridder, Nlon, Nlat)\n", - "S_zonal = compute_zonal_mean(S_mean, ocean_mask, regridder, Nlon, Nlat)\n", - "b_zonal = compute_zonal_mean(b_mean, ocean_mask, regridder, Nlon, Nlat)\n", - "\n", - "@info \"Computing zonal means (WOA / initial conditions)...\"\n", - "T_woa_zonal = compute_zonal_mean(T_woa_on_grid, ocean_mask, regridder, Nlon, Nlat)\n", - "S_woa_zonal = compute_zonal_mean(S_woa_on_grid, ocean_mask, regridder, Nlon, Nlat)\n", - "b_init_zonal = compute_zonal_mean(b_init, ocean_mask, regridder, Nlon, Nlat)\n", - "\n", - "# Differences from initial conditions\n", - "δT_zonal = T_zonal .- T_woa_zonal\n", - "δS_zonal = S_zonal .- S_woa_zonal\n", - "δb_zonal = b_zonal .- b_init_zonal\n", - "\n", - "# Axes\n", - "φ = collect(φnodes(latlon_grid, Center()))\n", - "z = collect(znodes(grid, Center()))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 9 -- Zonal-mean T, S, b" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = Figure(size = (1800, 500), fontsize = 14)\n", - "\n", - "ax = Axis(fig[1, 1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal-mean temperature\")\n", - "hm = heatmap!(ax, φ, z, T_zonal; colormap=:thermal, colorrange=(-2, 30), nan_color=:lightgray)\n", - "Colorbar(fig[1, 2], hm; label=\"deg C\")\n", - "ylims!(ax, (-5500, 0))\n", - "\n", - "ax = Axis(fig[1, 3]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal-mean salinity\")\n", - "hm = heatmap!(ax, φ, z, S_zonal; colormap=:haline, colorrange=(33, 37), nan_color=:lightgray)\n", - "Colorbar(fig[1, 4], hm; label=\"PSU\")\n", - "ylims!(ax, (-5500, 0))\n", - "\n", - "ax = Axis(fig[1, 5]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal-mean buoyancy\")\n", - "hm = heatmap!(ax, φ, z, b_zonal; colormap=:balance, nan_color=:lightgray)\n", - "Colorbar(fig[1, 6], hm; label=\"m/s^2\")\n", - "ylims!(ax, (-5500, 0))\n", - "\n", - "fig" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 10 -- Zonal-mean drift from initial conditions" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fig = Figure(size = (1800, 500), fontsize = 14)\n", - "\n", - "ax = Axis(fig[1, 1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal T - WOA\")\n", - "hm = heatmap!(ax, φ, z, δT_zonal; colormap=:balance, colorrange=(-5, 5), nan_color=:lightgray)\n", - "Colorbar(fig[1, 2], hm; label=\"deg C\")\n", - "ylims!(ax, (-5500, 0))\n", - "\n", - "ax = Axis(fig[1, 3]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal S - WOA\")\n", - "hm = heatmap!(ax, φ, z, δS_zonal; colormap=:balance, colorrange=(-1, 1), nan_color=:lightgray)\n", - "Colorbar(fig[1, 4], hm; label=\"PSU\")\n", - "ylims!(ax, (-5500, 0))\n", - "\n", - "ax = Axis(fig[1, 5]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"Zonal b - b(t=0)\")\n", - "hm = heatmap!(ax, φ, z, δb_zonal; colormap=:balance, nan_color=:lightgray)\n", - "Colorbar(fig[1, 6], hm; label=\"m/s^2\")\n", - "ylims!(ax, (-5500, 0))\n", - "\n", - "fig" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.2" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/experiments/OMIPSimulations/scripts/launch.sh b/experiments/OMIPSimulations/scripts/launch.sh deleted file mode 100755 index 06efd78b5..000000000 --- a/experiments/OMIPSimulations/scripts/launch.sh +++ /dev/null @@ -1,271 +0,0 @@ -#!/bin/bash -# Submit an OMIP simulation to SLURM. -# -# Usage: -# ./launch.sh halfdegree # half-degree OMIP -# ./launch.sh tenthdegree # 1/10-degree OMIP -# ./launch.sh orca # ORCA OMIP -# PROFILE=true ./launch.sh orca # nsys-profile run -# NODE=2904 ./launch.sh orca # pin to a specific node -# -# Credentials (e.g. ECCO_USERNAME, ECCO_WEBDAV_PASSWORD) are NOT set -# here. Export them in your shell or source a private file before -# launching, e.g.: -# -# source ~/.ecco_credentials && ./launch.sh orca - -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: ./launch.sh [extra sbatch args...] - -Configurations: - halfdegree Half-degree TripolarGrid (default fluxes) - orca ORCA grid (default fluxes) - orca_corrected ORCA grid with corrected COARE 3.6 fluxes - orca_ncar ORCA grid with OMIP-2/NCAR bulk formulae - tenthdegree 1/10-degree TripolarGrid (4 GPUs) - -Examples: - ./launch.sh orca - ./launch.sh orca_corrected - ./launch.sh orca_ncar - PROFILE=true ./launch.sh orca - NODE=2904 ./launch.sh orca -USAGE -} - -CONFIG="${1:-}" -if [[ -z "$CONFIG" ]]; then - usage - exit 1 -fi -shift || true - -case "$CONFIG" in - halfdegree) - CONFIG="halfdegree" - ;; - half_degree) - CONFIG="halfdegree" - ;; - orca|tenthdegree) ;; - orca_corrected|orca_ncar|orca_corrected_snow|orca_ncar_snow) ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Error: unknown configuration '$CONFIG'" >&2 - usage - exit 1 - ;; -esac - -REPORT_NAME="${REPORT_NAME:-${CONFIG}_report}" -JOB_NAME="${JOB_NAME:-$CONFIG}" -GPUS_PER_NODE=1 - -case "$CONFIG" in - tenthdegree) - GPUS_PER_NODE=4 - ;; -esac - -SBATCH_ARGS=() -NODE="${NODE:-2904}" -if [[ -n "${NODE}" ]]; then - SBATCH_ARGS+=(-w "node${NODE}") -fi -SBATCH_ARGS+=(--gres="gpu:${GPUS_PER_NODE}") - -if [[ "${PROFILE:-false}" == "true" ]]; then - SBATCH_ARGS+=(-o "${CONFIG}_profile.out") - SBATCH_ARGS+=(-e "${CONFIG}_profile.err") - SBATCH_ARGS+=(-J "${JOB_NAME}_profile") - SBATCH_ARGS+=(--export="ALL,PROFILE=true,REPORT_NAME=${REPORT_NAME},CONFIG=${CONFIG}") -else - SBATCH_ARGS+=(-o "${CONFIG}.out") - SBATCH_ARGS+=(-e "${CONFIG}.err") - SBATCH_ARGS+=(-J "$JOB_NAME") - SBATCH_ARGS+=(--export="ALL,CONFIG=${CONFIG}") -fi - -sbatch "${SBATCH_ARGS[@]}" "$@" <<'EOF' -#!/bin/bash -#SBATCH -N 1 -#SBATCH --ntasks-per-node=1 -#SBATCH -p pi_raffaele -#SBATCH --time=120:00:00 -#SBATCH --mem=150GB - -source /etc/profile.d/modules.sh -module load nvhpc - -JULIA="${JULIA:-$HOME/julia-1.12.5/bin/julia}" - -# Build the Julia expression from the selected config. -case "$CONFIG" in - halfdegree) - JULIA_EXPR='using OMIPSimulations -using Oceananigans -using Oceananigans.Units -using CUDA - -sim = omip_simulation(:halfdegree; - arch = GPU(), - Nz = 70, - depth = 5500, - Δt = 25minutes, - output_dir = "halfdegree_run", - filename_prefix = "halfdegree") - -sim.stop_time = 300 * 365days -run!(sim, pickup=:latest)' - ;; - orca) - JULIA_EXPR='using OMIPSimulations -using Oceananigans -using Oceananigans.Units -using CUDA - -sim = omip_simulation(:orca; - arch = GPU(), - Nz = 70, - depth = 5500, - κ_skew = 500, - κ_symmetric = 250, - biharmonic_timescale = 10days, - Δt = 30minutes, - output_dir = "orca_run", - filename_prefix = "orca") - -sim.stop_time = 300 * 365days -run!(sim; pickup=false)' - ;; - orca_corrected) - JULIA_EXPR='using OMIPSimulations -using Oceananigans -using Oceananigans.Units -using CUDA - -sim = omip_simulation(:orca; - arch = GPU(), - Nz = 70, - depth = 5500, - κ_skew = 500, - κ_symmetric = 250, - biharmonic_timescale = 10days, - Δt = 30minutes, - flux_configuration = :corrected, - output_dir = "orca_corrected_run", - filename_prefix = "orca_corrected") - -sim.stop_time = 300 * 365days -run!(sim; pickup = true)' - ;; - orca_ncar) - JULIA_EXPR='using OMIPSimulations -using Oceananigans -using Oceananigans.Units -using CUDA - -sim = omip_simulation(:orca; - arch = GPU(), - Nz = 70, - depth = 5500, - κ_skew = 500, - κ_symmetric = 250, - biharmonic_timescale = 10days, - Δt = 30minutes, - flux_configuration = :ncar, - output_dir = "orca_ncar_run", - filename_prefix = "orca_ncar") - -sim.stop_time = 300 * 365days -run!(sim; pickup = true)' - ;; - orca_corrected_snow) - JULIA_EXPR='using OMIPSimulations -using Oceananigans -using Oceananigans.Units -using CUDA - -sim = omip_simulation(:orca; - arch = GPU(), - Nz = 70, - depth = 5500, - κ_skew = 500, - κ_symmetric = 250, - biharmonic_timescale = 10days, - Δt = 30minutes, - flux_configuration = :corrected, - with_snow = true, - output_dir = "orca_corrected_snow_run", - filename_prefix = "orca_corrected_snow") - -sim.stop_time = 300 * 365days -run!(sim; pickup = true)' - ;; - orca_ncar_snow) - JULIA_EXPR='using OMIPSimulations -using Oceananigans -using Oceananigans.Units -using CUDA - -sim = omip_simulation(:orca; - arch = GPU(), - Nz = 70, - depth = 5500, - κ_skew = 500, - κ_symmetric = 250, - biharmonic_timescale = 10days, - Δt = 30minutes, - flux_configuration = :ncar, - with_snow = true, - output_dir = "orca_ncar_snow_run", - filename_prefix = "orca_ncar_snow") - -sim.stop_time = 300 * 365days -run!(sim; pickup = true)' - ;; - tenthdegree) - JULIA_EXPR='using OMIPSimulations -using Oceananigans -using Oceananigans.Units -using Oceananigans.DistributedComputations -using CUDA - -# TODO: adjust this block for the 1/10-degree setup details you want. -sim = omip_simulation(:tenthdegree; - arch = Distributed(GPU(), partition=Partition(1, 4)), - Nz = 100, - depth = 5500, - κ_skew = nothing, - κ_symmetric = nothing, - biharmonic_timescale = nothing, - Δt = 8minutes, - output_dir = "tenthdegree_run", - filename_prefix = "tenthdegree", - file_splitting_interval = 180days) - -sim.stop_time = 91days -run!(sim) - -sim.Δt = 15minutes -sim.stop_time = 300 * 365days -run!(sim; pickup = true)' - ;; -esac - -if [[ "${PROFILE:-false}" == "true" ]]; then - echo "Profiling ${CONFIG} configuration -> ${REPORT_NAME}" - nsys profile --trace=cuda \ - --output="$REPORT_NAME" \ - --force-overwrite true \ - "$JULIA" --project=.. --check-bounds=no -e "$JULIA_EXPR" -else - "$JULIA" --project=.. --check-bounds=no -e "$JULIA_EXPR" -fi -EOF diff --git a/experiments/OMIPSimulations/scripts/store.sh b/experiments/OMIPSimulations/scripts/store.sh deleted file mode 100755 index 8a0d301ff..000000000 --- a/experiments/OMIPSimulations/scripts/store.sh +++ /dev/null @@ -1,194 +0,0 @@ -#!/bin/bash -# Move completed OMIP outputs from a live run folder to -# $DATA/OMIP-data/_run while a launch.sh job is still running. -# -# Logic: -# - Part files (*_part.jld2): the highest N per filename group is -# still being written by the running sim, so it is left in place; -# all older parts are moved. -# - Checkpoint files (*_checkpoint_iteration.jld2): the highest -# iteration is kept locally so `run!(sim; pickup=true)` still works; -# older checkpoints are moved. -# - Anything else in the run folder is left untouched. -# -# Must be run from the same directory as launch.sh (i.e. this scripts -# folder) so that _run resolves the same way it does for the -# running simulation. -# -# Usage: -# ./store.sh halfdegree -# ./store.sh tenthdegree -# ./store.sh orca -# -# DATA must be set in the calling shell (it is propagated to the -# sbatch job via --export=ALL). - -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: ./store.sh [extra sbatch args...] - -Examples: - ./store.sh halfdegree - ./store.sh tenthdegree - ./store.sh orca -USAGE -} - -CONFIG="${1:-}" -if [[ -z "$CONFIG" ]]; then - usage - exit 1 -fi -shift || true - -case "$CONFIG" in - halfdegree|half_degree) - CONFIG="halfdegree" - ;; - orca|tenthdegree|orca_corrected|orca_ncar|orca_corrected_snow|orca_ncar_snow) ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Error: unknown configuration '$CONFIG'" >&2 - usage - exit 1 - ;; -esac - -if [[ -z "${DATA:-}" ]]; then - echo "Error: DATA environment variable is not set" >&2 - exit 1 -fi - -RUN_DIR="${CONFIG}_run" -DEST_DIR="${DATA}/OMIP-data/${RUN_DIR}" - -if [[ ! -d "$RUN_DIR" ]]; then - echo "Error: run directory '$RUN_DIR' not found in $(pwd)" >&2 - echo " (store.sh must be run from the same directory as launch.sh)" >&2 - exit 1 -fi - -JOB_NAME="${JOB_NAME:-store_${CONFIG}}" - -SBATCH_ARGS=() -SBATCH_ARGS+=(-o "store_${CONFIG}.out") -SBATCH_ARGS+=(-e "store_${CONFIG}.err") -SBATCH_ARGS+=(-J "$JOB_NAME") -SBATCH_ARGS+=(--export="ALL,CONFIG=${CONFIG},RUN_DIR=${RUN_DIR},DEST_DIR=${DEST_DIR}") - -sbatch "${SBATCH_ARGS[@]}" "$@" <<'EOF' -#!/bin/bash -#SBATCH -N 1 -#SBATCH --ntasks-per-node=1 -#SBATCH -p sched_mit_raffaele -#SBATCH --time=24:00:00 -#SBATCH --mem=4GB - -set -euo pipefail - -echo "Storing ${CONFIG} outputs" -echo " source: $(pwd)/${RUN_DIR}" -echo " dest: ${DEST_DIR}" - -if [[ ! -d "$RUN_DIR" ]]; then - echo "Error: run directory '$RUN_DIR' does not exist in $(pwd)" >&2 - exit 1 -fi - -mkdir -p "$DEST_DIR" - -shopt -s nullglob - -# Infinite loop -while true -do - -# ------------------------------------------------------------------ -# Part files: *_part.jld2 -# The highest N per filename group is still being written, so it is -# left in place; everything older is moved. -# ------------------------------------------------------------------ -declare -A max_part -for f in "$RUN_DIR"/*_part[0-9]*.jld2; do - base=$(basename "$f") - tail="${base##*_part}" - n="${tail%.jld2}" - [[ "$n" =~ ^[0-9]+$ ]] || continue - group="${base%_part${n}.jld2}" - current="${max_part[$group]:-0}" - if (( n > current )); then - max_part[$group]=$n - fi -done - -moved_parts=0 -kept_parts=0 - -for f in "$RUN_DIR"/*_part[0-9]*.jld2; do - base=$(basename "$f") - tail="${base##*_part}" - n="${tail%.jld2}" - [[ "$n" =~ ^[0-9]+$ ]] || continue - group="${base%_part${n}.jld2}" - max="${max_part[$group]:-0}" - if (( n == max )); then - echo "skip (active): ${base}" - kept_parts=$((kept_parts + 1)) - continue - fi - echo "move: ${base}" - mv -- "$f" "$DEST_DIR/" - moved_parts=$((moved_parts + 1)) -done - -# ------------------------------------------------------------------ -# Checkpoint files: *_iteration.jld2 -# The latest iteration per group is required for run!(sim; pickup=true) -# so it is kept locally; earlier checkpoints are moved. -# ------------------------------------------------------------------ -declare -A max_ckpt -for f in "$RUN_DIR"/*_iteration[0-9]*.jld2; do - base=$(basename "$f") - tail="${base##*_iteration}" - n="${tail%.jld2}" - [[ "$n" =~ ^[0-9]+$ ]] || continue - group="${base%_iteration${n}.jld2}" - current="${max_ckpt[$group]:-0}" - if (( n > current )); then - max_ckpt[$group]=$n - fi -done - -moved_ckpts=0 -kept_ckpts=0 -for f in "$RUN_DIR"/*_iteration[0-9]*.jld2; do - base=$(basename "$f") - tail="${base##*_iteration}" - n="${tail%.jld2}" - [[ "$n" =~ ^[0-9]+$ ]] || continue - group="${base%_iteration${n}.jld2}" - max="${max_ckpt[$group]:-0}" - if (( n == max )); then - echo "skip (latest): ${base}" - kept_ckpts=$((kept_ckpts + 1)) - continue - fi - echo "move: ${base}" - mv -- "$f" "$DEST_DIR/" - moved_ckpts=$((moved_ckpts + 1)) -done - -echo "Done. Moved ${moved_parts} part file(s) (kept ${kept_parts})," \ - "moved ${moved_ckpts} checkpoint file(s) (kept ${kept_ckpts})." - -sleep 3600 # sleep for 1 hour - -echo "Sleeping for 1 hour" - -done -EOF diff --git a/experiments/OMIPSimulations/scripts/visualize_omip.ipynb b/experiments/OMIPSimulations/scripts/visualize_omip.ipynb deleted file mode 100644 index 455a54f5d..000000000 --- a/experiments/OMIPSimulations/scripts/visualize_omip.ipynb +++ /dev/null @@ -1,502 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# OMIP Simulation Diagnostics -- Multi-case comparison\n\nPost-processing visualization loosely following Adcroft et al. (2019),\n*The GFDL Global Ocean and Sea Ice Model OM4.0*, JAMES.\n\nDefine cases, `start_time`, `stop_time` below; every figure shows all cases side by side." - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Configuration" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "# Configuration\n\ncases = [\n (run_dir = \"halfdegree_run\", prefix = \"halfdegree\", label = \"Half-degree\"),\n (run_dir = \"orca_run\", prefix = \"orca\", label = \"ORCA\"),\n]\n\nstart_time = 0\nstop_time = Inf" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Imports" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "using CairoMakie\nusing Statistics\nusing Dates\nusing Downloads\nusing DelimitedFiles\nusing Oceananigans\nusing Oceananigans.Grids: znodes, φnodes, φnode\nusing Oceananigans.Fields: interpolate!\nusing ConservativeRegridding\nusing NumericalEarth\nusing NumericalEarth.DataWrangling: Metadatum\nusing NumericalEarth.DataWrangling.WOA: WOAAnnual" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Helpers" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "function find_first_file(run_dir, prefix, group)\n tag = \"$(prefix)_$(group)\"\n candidates = filter(f -> startswith(f, tag) && endswith(f, \".jld2\") &&\n !contains(f, \"checkpoint\"), readdir(run_dir))\n isempty(candidates) && error(\"No $group files for prefix '$prefix' in $run_dir\")\n filename = first(sort(candidates))\n basename_no_part = replace(filename, r\"_part\\d+\" => \"\")\n return joinpath(run_dir, basename_no_part)\nend\n\nfunction in_window(fts; start_time = 0, stop_time = Inf)\n return findall(t -> start_time <= t <= stop_time, fts.times)\nend\n\nfunction compute_time_mean(fts; start_time = 0, stop_time = Inf)\n idx = in_window(fts; start_time, stop_time)\n isempty(idx) && error(\"No snapshots in [$start_time, $stop_time]\")\n avg = zeros(size(Array(interior(fts[first(idx)]))))\n for n in idx\n avg .+= Array(interior(fts[n]))\n end\n return avg ./ length(idx)\nend\n\nfunction compute_monthly_mean(fts, target_months;\n start_time = 0, stop_time = Inf,\n reference_date = DateTime(1958, 1, 1))\n dates = [reference_date + Second(round(Int, t)) for t in fts.times]\n idx = findall(i -> month(dates[i]) in target_months &&\n start_time <= fts.times[i] <= stop_time,\n eachindex(dates))\n isempty(idx) && return nothing\n avg = zeros(size(Array(interior(fts[first(idx)]))))\n for n in idx\n avg .+= Array(interior(fts[n]))\n end\n return avg ./ length(idx)\nend\n\nfunction build_land_mask(grid)\n if grid isa ImmersedBoundaryGrid\n bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1))\n return bh .>= 0\n else\n return falses(size(grid, 1), size(grid, 2))\n end\nend\n\nfunction build_ocean_mask_3d(grid)\n Nx, Ny, Nz = size(grid)\n mask = ones(Nx, Ny, Nz)\n if grid isa ImmersedBoundaryGrid\n bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1))\n zc = znodes(grid, Center())\n for k in 1:Nz, j in 1:Ny, i in 1:Nx\n zc[k] < bh[i, j] && (mask[i, j, k] = 0.0)\n end\n end\n return mask\nend\n\nmask_land!(f, land) = (f[land] .= NaN; f)\n\nfunction panel!(fig, pos, data;\n title=\"\", colormap=:thermal,\n colorrange=nothing, label=\"\",\n nan_color=:lightgray)\n ax = Axis(fig[pos...]; title)\n kw = isnothing(colorrange) ? (;) : (; colorrange)\n hm = heatmap!(ax, data; colormap, nan_color, kw...)\n Colorbar(fig[pos[1], pos[2]+1], hm; label)\n return ax\nend\n\ncase_colors = [:firebrick, :royalblue, :seagreen, :darkorange]" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Load surface diagnostics" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "function load_surface_case(run_dir, prefix; start_time = 0, stop_time = Inf)\n surface_file = find_first_file(run_dir, prefix, \"surface\")\n @info \" surface: $surface_file\"\n\n tos = FieldTimeSeries(surface_file, \"tos\"; backend = OnDisk())\n sos = FieldTimeSeries(surface_file, \"sos\"; backend = OnDisk())\n zos = FieldTimeSeries(surface_file, \"zos\"; backend = OnDisk())\n mld_fts = FieldTimeSeries(surface_file, \"mlotst\"; backend = OnDisk())\n hfds = FieldTimeSeries(surface_file, \"hfds\"; backend = OnDisk())\n wfo = FieldTimeSeries(surface_file, \"wfo\"; backend = OnDisk())\n sic = FieldTimeSeries(surface_file, \"siconc\"; backend = OnDisk())\n zossq = FieldTimeSeries(surface_file, \"zossq\"; backend = OnDisk())\n\n grid = tos.grid\n Nx, Ny, Nz = size(grid)\n land = build_land_mask(grid)\n\n @info \" averaging window: [$(start_time / (365.25*86400)), $(stop_time / (365.25*86400))] years\"\n\n SST = dropdims(compute_time_mean(tos; start_time, stop_time); dims=3)\n SSS = dropdims(compute_time_mean(sos; start_time, stop_time); dims=3)\n SSH = dropdims(compute_time_mean(zos; start_time, stop_time); dims=3)\n HF = dropdims(compute_time_mean(hfds; start_time, stop_time); dims=3)\n FW = dropdims(compute_time_mean(wfo; start_time, stop_time); dims=3)\n SIC_mean = dropdims(compute_time_mean(sic; start_time, stop_time); dims=3)\n\n SSH_sq = dropdims(compute_time_mean(zossq; start_time, stop_time); dims=3)\n SSH_var = SSH_sq .- SSH .^ 2\n\n MLD_monthly = [compute_monthly_mean(mld_fts, [m]; start_time, stop_time) for m in 1:12]\n avail = findall(!isnothing, MLD_monthly)\n MLD_stack = cat([dropdims(MLD_monthly[m]; dims=3) for m in avail]...; dims=3)\n MLD_min = dropdims(minimum(MLD_stack; dims=3); dims=3)\n MLD_max = dropdims(maximum(MLD_stack; dims=3); dims=3)\n\n SIC_mar = compute_monthly_mean(sic, [3]; start_time, stop_time)\n SIC_sep = compute_monthly_mean(sic, [9]; start_time, stop_time)\n SIC_mar = isnothing(SIC_mar) ? nothing : dropdims(SIC_mar; dims=3)\n SIC_sep = isnothing(SIC_sep) ? nothing : dropdims(SIC_sep; dims=3)\n\n T_woa = Field(Metadatum(:temperature; dataset = WOAAnnual()), CPU())\n S_woa = Field(Metadatum(:salinity; dataset = WOAAnnual()), CPU())\n T_interp = CenterField(grid); interpolate!(T_interp, T_woa)\n S_interp = CenterField(grid); interpolate!(S_interp, S_woa)\n T_woa_on_grid = Array(interior(T_interp))\n S_woa_on_grid = Array(interior(S_interp))\n δSST = SST .- T_woa_on_grid[:, :, Nz]\n δSSS = SSS .- S_woa_on_grid[:, :, Nz]\n\n for f in (SST, SSS, SSH, HF, FW, SIC_mean, SSH_var, MLD_min, MLD_max, δSST, δSSS)\n mask_land!(f, land)\n end\n !isnothing(SIC_mar) && mask_land!(SIC_mar, land)\n !isnothing(SIC_sep) && mask_land!(SIC_sep, land)\n\n return (; grid, Nx, Ny, Nz, land, surface_file,\n SST, SSS, SSH, HF, FW, SIC_mean, SSH_var,\n MLD_min, MLD_max, SIC_mar, SIC_sep,\n δSST, δSSS, T_woa_on_grid, S_woa_on_grid)\nend\n\nD = Dict{String, Any}()\nlabels = [c.label for c in cases]\nfor c in cases\n @info \"Loading surface: $(c.label)...\"\n D[c.label] = load_surface_case(c.run_dir, c.prefix; start_time, stop_time)\nend" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 1: SST bias" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (800 * length(labels), 500), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].δSST;\n title = \"$lab: SST - WOA\", colormap = :balance,\n colorrange = (-5, 5), label = \"deg C\")\nend\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 2: SSS bias" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (800 * length(labels), 500), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].δSSS;\n title = \"$lab: SSS - WOA\", colormap = :balance,\n colorrange = (-3, 3), label = \"PSU\")\nend\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 3: SSH" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (800 * length(labels), 500), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].SSH;\n title = \"$lab: Time-mean SSH\", colormap = :balance,\n colorrange = (-2, 2), label = \"m\")\nend\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 4: MLD min/max" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (800 * length(labels), 900), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].MLD_min;\n title = \"$lab: Min MLD (summer)\",\n colormap = Reverse(:deep), colorrange = (0, 150), label = \"m\")\n panel!(fig, [2, 2i-1], D[lab].MLD_max;\n title = \"$lab: Max MLD (winter)\",\n colormap = Reverse(:deep), colorrange = (10, 3000), label = \"m\")\nend\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 5: Sea-ice concentration" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (800 * length(labels), 900), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n d = D[lab]\n !isnothing(d.SIC_mar) && panel!(fig, [1, 2i-1], d.SIC_mar;\n title = \"$lab: Sea-ice conc. March\",\n colormap = :ice, colorrange = (0, 1), label = \"fraction\")\n !isnothing(d.SIC_sep) && panel!(fig, [2, 2i-1], d.SIC_sep;\n title = \"$lab: Sea-ice conc. September\",\n colormap = :ice, colorrange = (0, 1), label = \"fraction\")\nend\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 6: Surface fluxes" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (800 * length(labels), 900), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].HF;\n title = \"$lab: Net heat flux\", colormap = :balance,\n colorrange = (-200, 200), label = \"W/m^2\")\n panel!(fig, [2, 2i-1], D[lab].FW;\n title = \"$lab: Net freshwater flux\", colormap = :balance,\n colorrange = (-1e-4, 1e-4), label = \"kg/m^2/s\")\nend\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 7: SSH variance" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (800 * length(labels), 500), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n panel!(fig, [1, 2i-1], D[lab].SSH_var;\n title = \"$lab: SSH variance\", colormap = :magma,\n colorrange = (0, 0.05), label = \"m²\")\nend\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Sea-ice diagnostics" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "arctic_condition(i, j, k, grid, args...) = φnode(i, j, k, grid, Center(), Center(), Center()) > 0\nantarctic_condition(i, j, k, grid, args...) = φnode(i, j, k, grid, Center(), Center(), Center()) < 0\n\nfunction compute_ice_diagnostics(run_dir, prefix, grid;\n start_time = 0, stop_time = Inf,\n reference_date = DateTime(1958, 1, 1),\n extent_threshold = 0.15)\n surface_file = find_first_file(run_dir, prefix, \"surface\")\n thickness_fts = FieldTimeSeries(surface_file, \"sithick\"; backend = OnDisk())\n concentration_fts = FieldTimeSeries(surface_file, \"siconc\"; backend = OnDisk())\n\n Nt = length(thickness_fts.times)\n arctic_volume = zeros(Nt)\n antarctic_volume = zeros(Nt)\n arctic_extent = zeros(Nt)\n antarctic_extent = zeros(Nt)\n arctic_area = zeros(Nt)\n antarctic_area = zeros(Nt)\n snapshot_dates = [reference_date + Second(round(Int, t)) for t in thickness_fts.times]\n\n extent_mask = Field{Center, Center, Nothing}(grid)\n arctic_extent_integral = Field(Integral(extent_mask; condition = arctic_condition))\n antarctic_extent_integral = Field(Integral(extent_mask; condition = antarctic_condition))\n\n for n in 1:Nt\n concentration_field = concentration_fts[n]\n\n ice_volume_field = thickness_fts[n] * concentration_field\n arctic_vol_int = Field(Integral(ice_volume_field; condition = arctic_condition))\n antarctic_vol_int = Field(Integral(ice_volume_field; condition = antarctic_condition))\n compute!(arctic_vol_int); compute!(antarctic_vol_int)\n arctic_volume[n] = arctic_vol_int[1, 1, 1]\n antarctic_volume[n] = antarctic_vol_int[1, 1, 1]\n\n arctic_area_int = Field(Integral(concentration_field; condition = arctic_condition))\n antarctic_area_int = Field(Integral(concentration_field; condition = antarctic_condition))\n compute!(arctic_area_int); compute!(antarctic_area_int)\n arctic_area[n] = arctic_area_int[1, 1, 1]\n antarctic_area[n] = antarctic_area_int[1, 1, 1]\n\n concentration_data = Array(interior(concentration_field, :, :, 1))\n set!(extent_mask, Float64.(concentration_data .> extent_threshold))\n compute!(arctic_extent_integral); compute!(antarctic_extent_integral)\n arctic_extent[n] = arctic_extent_integral[1, 1, 1]\n antarctic_extent[n] = antarctic_extent_integral[1, 1, 1]\n end\n\n idx = findall(t -> start_time <= t <= stop_time, thickness_fts.times)\n months_used = month.(snapshot_dates[idx])\n monthly(field) = [mean(field[idx[months_used .== m]]) for m in 1:12]\n\n return (; arctic_volume, antarctic_volume,\n arctic_extent, antarctic_extent,\n arctic_area, antarctic_area, snapshot_dates,\n arctic_volume_monthly = monthly(arctic_volume),\n antarctic_volume_monthly = monthly(antarctic_volume),\n arctic_extent_monthly = monthly(arctic_extent),\n antarctic_extent_monthly = monthly(antarctic_extent),\n arctic_area_monthly = monthly(arctic_area),\n antarctic_area_monthly = monthly(antarctic_area))\nend\n\nICE = Dict{String, Any}()\nfor c in cases\n @info \"Computing sea-ice diagnostics for $(c.label)...\"\n ICE[c.label] = compute_ice_diagnostics(c.run_dir, c.prefix, D[c.label].grid; start_time, stop_time)\nend" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Download observational climatologies" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "piomas_url = \"https://psc.apl.uw.edu/wordpress/wp-content/uploads/schweiger/ice_volume/PIOMAS.monthly.Current.v2.1.csv\"\npiomas_raw = readdlm(Downloads.download(piomas_url), ','; skipstart=1)\npiomas_volume = Float64.(piomas_raw[:, 2:13])\npiomas_volume[piomas_volume .== -1] .= NaN\npiomas_monthly = vec(mapslices(x -> mean(filter(!isnan, x)), piomas_volume; dims=1))\n\nfunction download_nsidc(hemisphere)\n prefix = hemisphere == \"north\" ? \"N\" : \"S\"\n extent_monthly = zeros(12)\n area_monthly = zeros(12)\n for m in 1:12\n url = \"https://noaadata.apps.nsidc.org/NOAA/G02135/$(hemisphere)/monthly/data/$(prefix)_$(lpad(m, 2, '0'))_extent_v4.0.csv\"\n raw = readlines(Downloads.download(url))\n extents = Float64[]; areas = Float64[]\n for line in raw\n parts = split(line, ',')\n length(parts) >= 6 || continue\n ext = tryparse(Float64, strip(parts[5]))\n ar = tryparse(Float64, strip(parts[6]))\n (isnothing(ext) || ext == -9999) && continue\n (isnothing(ar) || ar == -9999) && continue\n push!(extents, ext); push!(areas, ar)\n end\n extent_monthly[m] = mean(extents)\n area_monthly[m] = mean(areas)\n end\n return (; extent_monthly, area_monthly)\nend\n\n@info \"Downloading NSIDC...\"\nnsidc_arctic = download_nsidc(\"north\")\nnsidc_antarctic = download_nsidc(\"south\")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "month_names = [\"J\",\"F\",\"M\",\"A\",\"M\",\"J\",\"J\",\"A\",\"S\",\"O\",\"N\",\"D\"]\nm2_to_Mkm2 = 1e-12\nm3_to_1e3km3 = 1e-12" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 8: SIE" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (1200, 500), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Month\", ylabel=\"SIE (Million km²)\", title=\"Arctic SIE Climatology\", xticks=(1:12, month_names))\nlines!(ax, 1:12, nsidc_arctic.extent_monthly; color=:black, linewidth=2, label=\"NSIDC\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, 1:12, ICE[lab].arctic_extent_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:lb)\nax = Axis(fig[1, 2]; xlabel=\"Month\", ylabel=\"SIE (Million km²)\", title=\"Antarctic SIE Climatology\", xticks=(1:12, month_names))\nlines!(ax, 1:12, nsidc_antarctic.extent_monthly; color=:black, linewidth=2, label=\"NSIDC\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, 1:12, ICE[lab].antarctic_extent_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 9: SIA" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (1200, 500), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Month\", ylabel=\"SIA (Million km²)\", title=\"Arctic SIA Climatology\", xticks=(1:12, month_names))\nlines!(ax, 1:12, nsidc_arctic.area_monthly; color=:black, linewidth=2, label=\"NSIDC\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, 1:12, ICE[lab].arctic_area_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:lb)\nax = Axis(fig[1, 2]; xlabel=\"Month\", ylabel=\"SIA (Million km²)\", title=\"Antarctic SIA Climatology\", xticks=(1:12, month_names))\nlines!(ax, 1:12, nsidc_antarctic.area_monthly; color=:black, linewidth=2, label=\"NSIDC\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, 1:12, ICE[lab].antarctic_area_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 10: Arctic volume" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (600, 500), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Month\", ylabel=\"Ice volume (10³ km³)\", title=\"Arctic sea-ice volume\", xticks=(1:12, month_names))\nlines!(ax, 1:12, piomas_monthly; color=:black, linewidth=2, label=\"PIOMAS\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, 1:12, ICE[lab].arctic_volume_monthly .* m3_to_1e3km3; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 11: SIA time series" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (1200, 500), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Time (years)\", ylabel=\"SIA (Million km²)\", title=\"Arctic sea-ice area\")\nfor (i, lab) in enumerate(labels)\n time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates]\n lines!(ax, time_years, ICE[lab].arctic_area .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nax = Axis(fig[1, 2]; xlabel=\"Time (years)\", ylabel=\"SIA (Million km²)\", title=\"Antarctic sea-ice area\")\nfor (i, lab) in enumerate(labels)\n time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates]\n lines!(ax, time_years, ICE[lab].antarctic_area .* m2_to_Mkm2; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 12: Arctic volume time series" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (600, 500), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Time (years)\", ylabel=\"Ice volume (10³ km³)\", title=\"Arctic sea-ice volume\")\nfor (i, lab) in enumerate(labels)\n time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates]\n lines!(ax, time_years, ICE[lab].arctic_volume .* m3_to_1e3km3; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rt)\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Load time series and 3-D fields" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "function load_timeseries_case(run_dir, prefix, grid; start_time = 0, stop_time = Inf)\n averages_file = find_first_file(run_dir, prefix, \"averages\")\n temperature_mean_fts = FieldTimeSeries(averages_file, \"tosga\"; backend = OnDisk())\n salinity_mean_fts = FieldTimeSeries(averages_file, \"soga\"; backend = OnDisk())\n temperature_mean = [Array(interior(temperature_mean_fts[n]))[1] for n in 1:length(temperature_mean_fts.times)]\n salinity_mean = [Array(interior(salinity_mean_fts[n]))[1] for n in 1:length(salinity_mean_fts.times)]\n time_in_years = temperature_mean_fts.times ./ (365.25 * 24 * 3600)\n\n temperature_profile_fts = FieldTimeSeries(averages_file, \"to_h\"; backend = OnDisk())\n salinity_profile_fts = FieldTimeSeries(averages_file, \"so_h\"; backend = OnDisk())\n temperature_profile = vec(compute_time_mean(temperature_profile_fts; start_time, stop_time))\n salinity_profile = vec(compute_time_mean(salinity_profile_fts; start_time, stop_time))\n depth = collect(znodes(grid, Center()))\n\n fields_file = find_first_file(run_dir, prefix, \"fields\")\n tke_fts = FieldTimeSeries(fields_file, \"tke\"; backend = OnDisk())\n ocean_mask = build_ocean_mask_3d(grid)\n ocean_cells = sum(ocean_mask)\n tke_mean = [sum(Array(interior(tke_fts[n])) .* ocean_mask) / ocean_cells\n for n in 1:length(tke_fts.times)]\n tke_time_in_years = tke_fts.times ./ (365.25 * 24 * 3600)\n\n return (; temperature_mean, salinity_mean, time_in_years,\n temperature_profile, salinity_profile, depth,\n tke_mean, tke_time_in_years, ocean_mask, fields_file)\nend\n\nTS = Dict{String, Any}()\nfor c in cases\n @info \"Loading time series: $(c.label)...\"\n TS[c.label] = load_timeseries_case(c.run_dir, c.prefix, D[c.label].grid; start_time, stop_time)\nend" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 13: TKE" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (900, 450), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Time (years)\", ylabel=\"TKE (m²/s²)\", title=\"Global-mean turbulent kinetic energy\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, TS[lab].tke_time_in_years, TS[lab].tke_mean; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:rb)\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 14: T and S drift" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (1200, 450), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Time (years)\", ylabel=\"ΔT (deg C)\", title=\"Global-mean temperature drift\")\nfor (i, lab) in enumerate(labels)\n d = TS[lab]\n lines!(ax, d.time_in_years, d.temperature_mean .- d.temperature_mean[1]; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:lb)\nax = Axis(fig[1, 2]; xlabel=\"Time (years)\", ylabel=\"ΔS (PSU)\", title=\"Global-mean salinity drift\")\nfor (i, lab) in enumerate(labels)\n d = TS[lab]\n lines!(ax, d.time_in_years, d.salinity_mean .- d.salinity_mean[1]; color=case_colors[i], label=lab)\nend\naxislegend(ax; position=:lb)\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 15: Profiles" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (1000, 600), fontsize = 14)\nax = Axis(fig[1, 1]; xlabel=\"Temperature (deg C)\", ylabel=\"Depth (m)\", title=\"Horizontal-mean temperature\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, TS[lab].temperature_profile, TS[lab].depth; color=case_colors[i], label=lab)\nend\nylims!(ax, (-5500, 0)); axislegend(ax; position=:rb)\nax = Axis(fig[1, 2]; xlabel=\"Salinity (PSU)\", ylabel=\"Depth (m)\", title=\"Horizontal-mean salinity\")\nfor (i, lab) in enumerate(labels)\n lines!(ax, TS[lab].salinity_profile, TS[lab].depth; color=case_colors[i], label=lab)\nend\nylims!(ax, (-5500, 0)); axislegend(ax; position=:rb)\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Zonal-mean sections\n\nRegrid 3-D time-mean fields to a regular 1-degree lat-lon grid via\n`ConservativeRegridding`, then average over longitude." - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "Nlon, Nlat = 360, 180\nlatlon_grid = LatitudeLongitudeGrid(CPU();\n size = (Nlon, Nlat, 1), longitude = (0, 360), latitude = (-90, 90), z = (0, 1))\ndst_f = Field{Center, Center, Nothing}(latlon_grid)\n\nfunction compute_zonal_mean(data_3d, ocean_mask_3d, regridder, Nlon, Nlat)\n Nz = size(data_3d, 3)\n zonal = fill(NaN, Nlat, Nz)\n dst_data = zeros(Nlon * Nlat)\n dst_mask = zeros(Nlon * Nlat)\n areas = regridder.dst_areas\n for k in 1:Nz\n ConservativeRegridding.regrid!(dst_data, regridder,\n vec(data_3d[:, :, k] .* ocean_mask_3d[:, :, k]))\n ConservativeRegridding.regrid!(dst_mask, regridder,\n vec(ocean_mask_3d[:, :, k]))\n data_sum = reshape(dst_data .* areas, Nlon, Nlat)\n mask_sum = reshape(dst_mask .* areas, Nlon, Nlat)\n for j in 1:Nlat\n m = sum(@view mask_sum[:, j])\n m > 0 && (zonal[j, k] = sum(@view data_sum[:, j]) / m)\n end\n end\n return zonal\nend" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "ZM = Dict{String, Any}()\nfor c in cases\n lab = c.label\n grid = D[lab].grid\n ocean_mask = TS[lab].ocean_mask\n\n # Build per-case regridder\n @info \"Building regridder for $lab (may take a few minutes)...\"\n src_f = Field{Center, Center, Nothing}(grid)\n regridder = ConservativeRegridding.Regridder(dst_f, src_f; progress = true)\n\n @info \"Loading 3-D fields for $lab...\"\n fields_file = TS[lab].fields_file\n to_fts = FieldTimeSeries(fields_file, \"to\"; backend = OnDisk())\n so_fts = FieldTimeSeries(fields_file, \"so\"; backend = OnDisk())\n bo_fts = FieldTimeSeries(fields_file, \"bo\"; backend = OnDisk())\n\n temperature_mean = compute_time_mean(to_fts; start_time, stop_time)\n salinity_mean = compute_time_mean(so_fts; start_time, stop_time)\n buoyancy_mean = compute_time_mean(bo_fts; start_time, stop_time)\n buoyancy_initial = Array(interior(bo_fts[1]))\n\n @info \"Computing zonal means for $lab...\"\n temperature_zonal = compute_zonal_mean(temperature_mean, ocean_mask, regridder, Nlon, Nlat)\n salinity_zonal = compute_zonal_mean(salinity_mean, ocean_mask, regridder, Nlon, Nlat)\n buoyancy_zonal = compute_zonal_mean(buoyancy_mean, ocean_mask, regridder, Nlon, Nlat)\n temperature_woa_zonal = compute_zonal_mean(D[lab].T_woa_on_grid, ocean_mask, regridder, Nlon, Nlat)\n salinity_woa_zonal = compute_zonal_mean(D[lab].S_woa_on_grid, ocean_mask, regridder, Nlon, Nlat)\n buoyancy_init_zonal = compute_zonal_mean(buoyancy_initial, ocean_mask, regridder, Nlon, Nlat)\n\n depth = collect(znodes(grid, Center()))\n\n ZM[lab] = (; temperature_zonal, salinity_zonal, buoyancy_zonal,\n temperature_woa_zonal, salinity_woa_zonal, buoyancy_init_zonal,\n δtemperature_zonal = temperature_zonal .- temperature_woa_zonal,\n δsalinity_zonal = salinity_zonal .- salinity_woa_zonal,\n δbuoyancy_zonal = buoyancy_zonal .- buoyancy_init_zonal,\n depth)\nend\n\nlatitude = collect(φnodes(latlon_grid, Center()))" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "temperature_levels = -2:2:30\nsalinity_levels = 33:0.25:37\nbuoyancy_levels = range(-0.04, 0.02, length=13)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 16: Zonal-mean T, S, b" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (600 * length(labels), 900), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n zm = ZM[lab]\n ax = Axis(fig[1, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal T\")\n hm = heatmap!(ax, latitude, zm.depth, zm.temperature_zonal; colormap=:thermal, colorrange=(-2,30), nan_color=:lightgray)\n contour!(ax, latitude, zm.depth, zm.temperature_woa_zonal; levels=temperature_levels, color=:grey, linestyle=:dash, linewidth=0.8)\n contour!(ax, latitude, zm.depth, zm.temperature_zonal; levels=temperature_levels, color=:black, linewidth=0.8)\n Colorbar(fig[1, 2i], hm; label=\"deg C\"); ylims!(ax, (-5500, 0))\n\n ax = Axis(fig[2, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal S\")\n hm = heatmap!(ax, latitude, zm.depth, zm.salinity_zonal; colormap=:haline, colorrange=(33,37), nan_color=:lightgray)\n contour!(ax, latitude, zm.depth, zm.salinity_woa_zonal; levels=salinity_levels, color=:grey, linestyle=:dash, linewidth=0.8)\n contour!(ax, latitude, zm.depth, zm.salinity_zonal; levels=salinity_levels, color=:black, linewidth=0.8)\n Colorbar(fig[2, 2i], hm; label=\"PSU\"); ylims!(ax, (-5500, 0))\n\n ax = Axis(fig[3, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal b\")\n hm = heatmap!(ax, latitude, zm.depth, zm.buoyancy_zonal; colormap=:balance, nan_color=:lightgray)\n contour!(ax, latitude, zm.depth, zm.buoyancy_init_zonal; levels=buoyancy_levels, color=:grey, linestyle=:dash, linewidth=0.8)\n contour!(ax, latitude, zm.depth, zm.buoyancy_zonal; levels=buoyancy_levels, color=:black, linewidth=0.8)\n Colorbar(fig[3, 2i], hm; label=\"m/s²\"); ylims!(ax, (-5500, 0))\nend\nfig" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Figure 17: Zonal-mean drift" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fig = Figure(size = (600 * length(labels), 900), fontsize = 14)\nfor (i, lab) in enumerate(labels)\n zm = ZM[lab]\n ax = Axis(fig[1, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal T - WOA\")\n hm = heatmap!(ax, latitude, zm.depth, zm.δtemperature_zonal; colormap=:balance, colorrange=(-5,5), nan_color=:lightgray)\n Colorbar(fig[1, 2i], hm; label=\"deg C\"); ylims!(ax, (-5500, 0))\n\n ax = Axis(fig[2, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal S - WOA\")\n hm = heatmap!(ax, latitude, zm.depth, zm.δsalinity_zonal; colormap=:balance, colorrange=(-1,1), nan_color=:lightgray)\n Colorbar(fig[2, 2i], hm; label=\"PSU\"); ylims!(ax, (-5500, 0))\n\n ax = Axis(fig[3, 2i-1]; xlabel=\"Latitude\", ylabel=\"Depth (m)\", title=\"$lab: Zonal b - b(t=0)\")\n hm = heatmap!(ax, latitude, zm.depth, zm.δbuoyancy_zonal; colormap=:balance, nan_color=:lightgray)\n Colorbar(fig[3, 2i], hm; label=\"m/s²\"); ylims!(ax, (-5500, 0))\nend\nfig\n\n@info \"All 17 figures saved to $output_dir\"" - ], - "outputs": [], - "execution_count": null - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia", - "language": "julia", - "name": "julia" - }, - "language_info": { - "name": "julia" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} \ No newline at end of file diff --git a/experiments/OMIPSimulations/scripts/visualize_omip.jl b/experiments/OMIPSimulations/scripts/visualize_omip.jl deleted file mode 100644 index 9a6994d98..000000000 --- a/experiments/OMIPSimulations/scripts/visualize_omip.jl +++ /dev/null @@ -1,693 +0,0 @@ -#!/usr/bin/env julia -# visualize_omip.jl -- Generate all OMIP diagnostic figures as PNGs. -# -# Usage: -# julia --project=.. visualize_omip.jl [output_dir] -# -# Edit the `cases`, `start_time`, `stop_time` below before running. - -# ══════════════════════════════════════════════════════════════ -# Configuration -# ══════════════════════════════════════════════════════════════ - -cases = [ - (run_dir = "halfdegree_run", prefix = "halfdegree", label = "Half-degree"), - (run_dir = "orca_run", prefix = "orca", label = "ORCA"), -] - -start_time = 0 -stop_time = Inf - -output_dir = length(ARGS) >= 1 ? ARGS[1] : "figures" - -# ══════════════════════════════════════════════════════════════ -# Imports -# ══════════════════════════════════════════════════════════════ - -using CairoMakie -using Statistics -using Dates -using Downloads -using DelimitedFiles -using WorldOceanAtlasTools -using Oceananigans -using Oceananigans.Grids: znodes, φnodes, φnode -using Oceananigans.Fields: interpolate! -using ConservativeRegridding -using NumericalEarth -using NumericalEarth.DataWrangling: Metadatum -using NumericalEarth.DataWrangling.WOA: WOAAnnual - -mkpath(output_dir) -@info "Figures will be saved to: $output_dir" - -# ══════════════════════════════════════════════════════════════ -# Helpers -# ══════════════════════════════════════════════════════════════ - -function find_first_file(run_dir, prefix, group) - tag = "$(prefix)_$(group)" - candidates = filter(f -> startswith(f, tag) && endswith(f, ".jld2") && - !contains(f, "checkpoint"), readdir(run_dir)) - isempty(candidates) && error("No $group files for prefix '$prefix' in $run_dir") - filename = first(sort(candidates)) - basename_no_part = replace(filename, r"_part\d+" => "") - return joinpath(run_dir, basename_no_part) -end - -function in_window(fts; start_time = 0, stop_time = Inf) - return findall(t -> start_time <= t <= stop_time, fts.times) -end - -function compute_time_mean(fts; start_time = 0, stop_time = Inf) - idx = in_window(fts; start_time, stop_time) - isempty(idx) && error("No snapshots in [$start_time, $stop_time]") - avg = zeros(size(Array(interior(fts[first(idx)])))) - for n in idx - avg .+= Array(interior(fts[n])) - end - return avg ./ length(idx) -end - -function compute_monthly_mean(fts, target_months; - start_time = 0, stop_time = Inf, - reference_date = DateTime(1958, 1, 1)) - dates = [reference_date + Second(round(Int, t)) for t in fts.times] - idx = findall(i -> month(dates[i]) in target_months && - start_time <= fts.times[i] <= stop_time, - eachindex(dates)) - isempty(idx) && return nothing - avg = zeros(size(Array(interior(fts[first(idx)])))) - for n in idx - avg .+= Array(interior(fts[n])) - end - return avg ./ length(idx) -end - -function build_land_mask(grid) - if grid isa ImmersedBoundaryGrid - bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1)) - return bh .>= 0 - else - return falses(size(grid, 1), size(grid, 2)) - end -end - -function build_ocean_mask_3d(grid) - Nx, Ny, Nz = size(grid) - mask = ones(Nx, Ny, Nz) - if grid isa ImmersedBoundaryGrid - bh = Array(interior(grid.immersed_boundary.bottom_height, :, :, 1)) - zc = znodes(grid, Center()) - for k in 1:Nz, j in 1:Ny, i in 1:Nx - zc[k] < bh[i, j] && (mask[i, j, k] = 0.0) - end - end - return mask -end - -mask_land!(f, land) = (f[land] .= NaN; f) - -function panel!(fig, pos, data; - title="", colormap=:thermal, - colorrange=nothing, label="", - nan_color=:lightgray) - ax = Axis(fig[pos...]; title) - kw = isnothing(colorrange) ? (;) : (; colorrange) - hm = heatmap!(ax, data; colormap, nan_color, kw...) - Colorbar(fig[pos[1], pos[2]+1], hm; label) - return ax -end - -case_colors = [:firebrick, :royalblue, :seagreen, :darkorange] - -savefig(fig, name) = save(joinpath(output_dir, name), fig) - -# ══════════════════════════════════════════════════════════════ -# Load surface diagnostics -# ══════════════════════════════════════════════════════════════ - -function load_surface_case(run_dir, prefix; start_time = 0, stop_time = Inf) - surface_file = find_first_file(run_dir, prefix, "surface") - @info " surface: $surface_file" - - tos = FieldTimeSeries(surface_file, "tos"; backend = OnDisk()) - sos = FieldTimeSeries(surface_file, "sos"; backend = OnDisk()) - zos = FieldTimeSeries(surface_file, "zos"; backend = OnDisk()) - mld_fts = FieldTimeSeries(surface_file, "mlotst"; backend = OnDisk()) - hfds = FieldTimeSeries(surface_file, "hfds"; backend = OnDisk()) - wfo = FieldTimeSeries(surface_file, "wfo"; backend = OnDisk()) - sic = FieldTimeSeries(surface_file, "siconc"; backend = OnDisk()) - zossq = FieldTimeSeries(surface_file, "zossq"; backend = OnDisk()) - - grid = tos.grid - Nx, Ny, Nz = size(grid) - land = build_land_mask(grid) - - @info " averaging window: [$(start_time / (365.25*86400)), $(stop_time / (365.25*86400))] years" - - SST = dropdims(compute_time_mean(tos; start_time, stop_time); dims=3) - SSS = dropdims(compute_time_mean(sos; start_time, stop_time); dims=3) - SSH = dropdims(compute_time_mean(zos; start_time, stop_time); dims=3) - HF = dropdims(compute_time_mean(hfds; start_time, stop_time); dims=3) - FW = dropdims(compute_time_mean(wfo; start_time, stop_time); dims=3) - SIC_mean = dropdims(compute_time_mean(sic; start_time, stop_time); dims=3) - - SSH_sq = dropdims(compute_time_mean(zossq; start_time, stop_time); dims=3) - SSH_var = SSH_sq .- SSH .^ 2 - - MLD_monthly = [compute_monthly_mean(mld_fts, [m]; start_time, stop_time) for m in 1:12] - avail = findall(!isnothing, MLD_monthly) - MLD_stack = cat([dropdims(MLD_monthly[m]; dims=3) for m in avail]...; dims=3) - MLD_min = dropdims(minimum(MLD_stack; dims=3); dims=3) - MLD_max = dropdims(maximum(MLD_stack; dims=3); dims=3) - - SIC_mar = compute_monthly_mean(sic, [3]; start_time, stop_time) - SIC_sep = compute_monthly_mean(sic, [9]; start_time, stop_time) - SIC_mar = isnothing(SIC_mar) ? nothing : dropdims(SIC_mar; dims=3) - SIC_sep = isnothing(SIC_sep) ? nothing : dropdims(SIC_sep; dims=3) - - T_woa = Field(Metadatum(:temperature; dataset = WOAAnnual()), CPU()) - S_woa = Field(Metadatum(:salinity; dataset = WOAAnnual()), CPU()) - T_interp = CenterField(grid); interpolate!(T_interp, T_woa) - S_interp = CenterField(grid); interpolate!(S_interp, S_woa) - T_woa_on_grid = Array(interior(T_interp)) - S_woa_on_grid = Array(interior(S_interp)) - δSST = SST .- T_woa_on_grid[:, :, Nz] - δSSS = SSS .- S_woa_on_grid[:, :, Nz] - - for f in (SST, SSS, SSH, HF, FW, SIC_mean, SSH_var, MLD_min, MLD_max, δSST, δSSS) - mask_land!(f, land) - end - !isnothing(SIC_mar) && mask_land!(SIC_mar, land) - !isnothing(SIC_sep) && mask_land!(SIC_sep, land) - - return (; grid, Nx, Ny, Nz, land, surface_file, - SST, SSS, SSH, HF, FW, SIC_mean, SSH_var, - MLD_min, MLD_max, SIC_mar, SIC_sep, - δSST, δSSS, T_woa_on_grid, S_woa_on_grid) -end - -D = Dict{String, Any}() -labels = [c.label for c in cases] -for c in cases - @info "Loading surface: $(c.label)..." - D[c.label] = load_surface_case(c.run_dir, c.prefix; start_time, stop_time) -end - -# ══════════════════════════════════════════════════════════════ -# Figures 1-7: Surface diagnostics -# ══════════════════════════════════════════════════════════════ - -# Figure 1: SST bias -@info "Figure 1: SST bias" -fig = Figure(size = (800 * length(labels), 500), fontsize = 14) -for (i, lab) in enumerate(labels) - panel!(fig, [1, 2i-1], D[lab].δSST; - title = "$lab: SST - WOA", colormap = :balance, - colorrange = (-5, 5), label = "deg C") -end -savefig(fig, "fig01_sst_bias.png") - -# Figure 2: SSS bias -@info "Figure 2: SSS bias" -fig = Figure(size = (800 * length(labels), 500), fontsize = 14) -for (i, lab) in enumerate(labels) - panel!(fig, [1, 2i-1], D[lab].δSSS; - title = "$lab: SSS - WOA", colormap = :balance, - colorrange = (-3, 3), label = "PSU") -end -savefig(fig, "fig02_sss_bias.png") - -# Figure 3: SSH -@info "Figure 3: SSH" -fig = Figure(size = (800 * length(labels), 500), fontsize = 14) -for (i, lab) in enumerate(labels) - panel!(fig, [1, 2i-1], D[lab].SSH; - title = "$lab: Time-mean SSH", colormap = :balance, - colorrange = (-2, 2), label = "m") -end -savefig(fig, "fig03_ssh.png") - -# Figure 4: MLD min/max -@info "Figure 4: MLD" -fig = Figure(size = (800 * length(labels), 900), fontsize = 14) -for (i, lab) in enumerate(labels) - panel!(fig, [1, 2i-1], D[lab].MLD_min; - title = "$lab: Min MLD (summer)", - colormap = Reverse(:deep), colorrange = (0, 150), label = "m") - panel!(fig, [2, 2i-1], D[lab].MLD_max; - title = "$lab: Max MLD (winter)", - colormap = Reverse(:deep), colorrange = (10, 3000), label = "m") -end -savefig(fig, "fig04_mld.png") - -# Figure 5: Sea-ice concentration -@info "Figure 5: Sea-ice concentration" -fig = Figure(size = (800 * length(labels), 900), fontsize = 14) -for (i, lab) in enumerate(labels) - d = D[lab] - !isnothing(d.SIC_mar) && panel!(fig, [1, 2i-1], d.SIC_mar; - title = "$lab: Sea-ice conc. March", - colormap = :ice, colorrange = (0, 1), label = "fraction") - !isnothing(d.SIC_sep) && panel!(fig, [2, 2i-1], d.SIC_sep; - title = "$lab: Sea-ice conc. September", - colormap = :ice, colorrange = (0, 1), label = "fraction") -end -savefig(fig, "fig05_seaice_conc.png") - -# Figure 6: Surface fluxes -@info "Figure 6: Surface fluxes" -fig = Figure(size = (800 * length(labels), 900), fontsize = 14) -for (i, lab) in enumerate(labels) - panel!(fig, [1, 2i-1], D[lab].HF; - title = "$lab: Net heat flux", colormap = :balance, - colorrange = (-200, 200), label = "W/m^2") - panel!(fig, [2, 2i-1], D[lab].FW; - title = "$lab: Net freshwater flux", colormap = :balance, - colorrange = (-1e-4, 1e-4), label = "kg/m^2/s") -end -savefig(fig, "fig06_surface_fluxes.png") - -# Figure 7: SSH variance -@info "Figure 7: SSH variance" -fig = Figure(size = (800 * length(labels), 500), fontsize = 14) -for (i, lab) in enumerate(labels) - panel!(fig, [1, 2i-1], D[lab].SSH_var; - title = "$lab: SSH variance", colormap = :magma, - colorrange = (0, 0.05), label = "m²") -end -savefig(fig, "fig07_ssh_variance.png") - -# ══════════════════════════════════════════════════════════════ -# Sea-ice diagnostics -# ══════════════════════════════════════════════════════════════ - -arctic_condition(i, j, k, grid, args...) = φnode(i, j, k, grid, Center(), Center(), Center()) > 0 -antarctic_condition(i, j, k, grid, args...) = φnode(i, j, k, grid, Center(), Center(), Center()) < 0 - -function compute_ice_diagnostics(run_dir, prefix, grid; - start_time = 0, stop_time = Inf, - reference_date = DateTime(1958, 1, 1), - extent_threshold = 0.15) - surface_file = find_first_file(run_dir, prefix, "surface") - thickness_fts = FieldTimeSeries(surface_file, "sithick"; backend = OnDisk()) - concentration_fts = FieldTimeSeries(surface_file, "siconc"; backend = OnDisk()) - - Nt = length(thickness_fts.times) - arctic_volume = zeros(Nt) - antarctic_volume = zeros(Nt) - arctic_extent = zeros(Nt) - antarctic_extent = zeros(Nt) - arctic_area = zeros(Nt) - antarctic_area = zeros(Nt) - snapshot_dates = [reference_date + Second(round(Int, t)) for t in thickness_fts.times] - - extent_mask = Field{Center, Center, Nothing}(grid) - arctic_extent_integral = Field(Integral(extent_mask; condition = arctic_condition)) - antarctic_extent_integral = Field(Integral(extent_mask; condition = antarctic_condition)) - - for n in 1:Nt - concentration_field = concentration_fts[n] - - ice_volume_field = thickness_fts[n] * concentration_field - arctic_vol_int = Field(Integral(ice_volume_field; condition = arctic_condition)) - antarctic_vol_int = Field(Integral(ice_volume_field; condition = antarctic_condition)) - compute!(arctic_vol_int); compute!(antarctic_vol_int) - arctic_volume[n] = arctic_vol_int[1, 1, 1] - antarctic_volume[n] = antarctic_vol_int[1, 1, 1] - - arctic_area_int = Field(Integral(concentration_field; condition = arctic_condition)) - antarctic_area_int = Field(Integral(concentration_field; condition = antarctic_condition)) - compute!(arctic_area_int); compute!(antarctic_area_int) - arctic_area[n] = arctic_area_int[1, 1, 1] - antarctic_area[n] = antarctic_area_int[1, 1, 1] - - concentration_data = Array(interior(concentration_field, :, :, 1)) - set!(extent_mask, Float64.(concentration_data .> extent_threshold)) - compute!(arctic_extent_integral); compute!(antarctic_extent_integral) - arctic_extent[n] = arctic_extent_integral[1, 1, 1] - antarctic_extent[n] = antarctic_extent_integral[1, 1, 1] - end - - idx = findall(t -> start_time <= t <= stop_time, thickness_fts.times) - months_used = month.(snapshot_dates[idx]) - monthly(field) = [mean(field[idx[months_used .== m]]) for m in 1:12] - - return (; arctic_volume, antarctic_volume, - arctic_extent, antarctic_extent, - arctic_area, antarctic_area, snapshot_dates, - arctic_volume_monthly = monthly(arctic_volume), - antarctic_volume_monthly = monthly(antarctic_volume), - arctic_extent_monthly = monthly(arctic_extent), - antarctic_extent_monthly = monthly(antarctic_extent), - arctic_area_monthly = monthly(arctic_area), - antarctic_area_monthly = monthly(antarctic_area)) -end - -ICE = Dict{String, Any}() -for c in cases - @info "Computing sea-ice diagnostics for $(c.label)..." - ICE[c.label] = compute_ice_diagnostics(c.run_dir, c.prefix, D[c.label].grid; start_time, stop_time) -end - -# ── Download observational climatologies ───────────────────── - -piomas_url = "https://psc.apl.uw.edu/wordpress/wp-content/uploads/schweiger/ice_volume/PIOMAS.monthly.Current.v2.1.csv" -piomas_raw = readdlm(Downloads.download(piomas_url), ','; skipstart=1) -piomas_volume = Float64.(piomas_raw[:, 2:13]) -piomas_volume[piomas_volume .== -1] .= NaN -piomas_monthly = vec(mapslices(x -> mean(filter(!isnan, x)), piomas_volume; dims=1)) - -function download_nsidc(hemisphere) - prefix = hemisphere == "north" ? "N" : "S" - extent_monthly = zeros(12) - area_monthly = zeros(12) - for m in 1:12 - url = "https://noaadata.apps.nsidc.org/NOAA/G02135/$(hemisphere)/monthly/data/$(prefix)_$(lpad(m, 2, '0'))_extent_v4.0.csv" - raw = readlines(Downloads.download(url)) - extents = Float64[]; areas = Float64[] - for line in raw - parts = split(line, ',') - length(parts) >= 6 || continue - ext = tryparse(Float64, strip(parts[5])) - ar = tryparse(Float64, strip(parts[6])) - (isnothing(ext) || ext == -9999) && continue - (isnothing(ar) || ar == -9999) && continue - push!(extents, ext); push!(areas, ar) - end - extent_monthly[m] = mean(extents) - area_monthly[m] = mean(areas) - end - return (; extent_monthly, area_monthly) -end - -@info "Downloading NSIDC..." -nsidc_arctic = download_nsidc("north") -nsidc_antarctic = download_nsidc("south") - -# ── Figures 8-12: Sea-ice climatologies and time series ────── - -month_names = ["J","F","M","A","M","J","J","A","S","O","N","D"] -m2_to_Mkm2 = 1e-12 -m3_to_1e3km3 = 1e-12 - -# Figure 8: SIE -@info "Figure 8: SIE" -fig = Figure(size = (1200, 500), fontsize = 14) -ax = Axis(fig[1, 1]; xlabel="Month", ylabel="SIE (Million km²)", title="Arctic SIE Climatology", xticks=(1:12, month_names)) -lines!(ax, 1:12, nsidc_arctic.extent_monthly; color=:black, linewidth=2, label="NSIDC") -for (i, lab) in enumerate(labels) - lines!(ax, 1:12, ICE[lab].arctic_extent_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab) -end -axislegend(ax; position=:lb) -ax = Axis(fig[1, 2]; xlabel="Month", ylabel="SIE (Million km²)", title="Antarctic SIE Climatology", xticks=(1:12, month_names)) -lines!(ax, 1:12, nsidc_antarctic.extent_monthly; color=:black, linewidth=2, label="NSIDC") -for (i, lab) in enumerate(labels) - lines!(ax, 1:12, ICE[lab].antarctic_extent_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab) -end -axislegend(ax; position=:rt) -savefig(fig, "fig08_sie.png") - -# Figure 9: SIA -@info "Figure 9: SIA" -fig = Figure(size = (1200, 500), fontsize = 14) -ax = Axis(fig[1, 1]; xlabel="Month", ylabel="SIA (Million km²)", title="Arctic SIA Climatology", xticks=(1:12, month_names)) -lines!(ax, 1:12, nsidc_arctic.area_monthly; color=:black, linewidth=2, label="NSIDC") -for (i, lab) in enumerate(labels) - lines!(ax, 1:12, ICE[lab].arctic_area_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab) -end -axislegend(ax; position=:lb) -ax = Axis(fig[1, 2]; xlabel="Month", ylabel="SIA (Million km²)", title="Antarctic SIA Climatology", xticks=(1:12, month_names)) -lines!(ax, 1:12, nsidc_antarctic.area_monthly; color=:black, linewidth=2, label="NSIDC") -for (i, lab) in enumerate(labels) - lines!(ax, 1:12, ICE[lab].antarctic_area_monthly .* m2_to_Mkm2; color=case_colors[i], label=lab) -end -axislegend(ax; position=:rt) -savefig(fig, "fig09_sia.png") - -# Figure 10: Arctic volume -@info "Figure 10: Arctic volume" -fig = Figure(size = (600, 500), fontsize = 14) -ax = Axis(fig[1, 1]; xlabel="Month", ylabel="Ice volume (10³ km³)", title="Arctic sea-ice volume", xticks=(1:12, month_names)) -lines!(ax, 1:12, piomas_monthly; color=:black, linewidth=2, label="PIOMAS") -for (i, lab) in enumerate(labels) - lines!(ax, 1:12, ICE[lab].arctic_volume_monthly .* m3_to_1e3km3; color=case_colors[i], label=lab) -end -axislegend(ax; position=:rt) -savefig(fig, "fig10_arctic_volume.png") - -# Figure 11: SIA time series -@info "Figure 11: SIA time series" -fig = Figure(size = (1200, 500), fontsize = 14) -ax = Axis(fig[1, 1]; xlabel="Time (years)", ylabel="SIA (Million km²)", title="Arctic sea-ice area") -for (i, lab) in enumerate(labels) - time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates] - lines!(ax, time_years, ICE[lab].arctic_area .* m2_to_Mkm2; color=case_colors[i], label=lab) -end -axislegend(ax; position=:rt) -ax = Axis(fig[1, 2]; xlabel="Time (years)", ylabel="SIA (Million km²)", title="Antarctic sea-ice area") -for (i, lab) in enumerate(labels) - time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates] - lines!(ax, time_years, ICE[lab].antarctic_area .* m2_to_Mkm2; color=case_colors[i], label=lab) -end -axislegend(ax; position=:rt) -savefig(fig, "fig11_sia_timeseries.png") - -# Figure 12: Arctic volume time series -@info "Figure 12: Arctic volume time series" -fig = Figure(size = (600, 500), fontsize = 14) -ax = Axis(fig[1, 1]; xlabel="Time (years)", ylabel="Ice volume (10³ km³)", title="Arctic sea-ice volume") -for (i, lab) in enumerate(labels) - time_years = [Dates.value(d - ICE[lab].snapshot_dates[1]) / (365.25 * 86400 * 1000) for d in ICE[lab].snapshot_dates] - lines!(ax, time_years, ICE[lab].arctic_volume .* m3_to_1e3km3; color=case_colors[i], label=lab) -end -axislegend(ax; position=:rt) -savefig(fig, "fig12_arctic_volume_timeseries.png") - -# ══════════════════════════════════════════════════════════════ -# Load time series and 3-D fields -# ══════════════════════════════════════════════════════════════ - -function load_timeseries_case(run_dir, prefix, grid; start_time = 0, stop_time = Inf) - averages_file = find_first_file(run_dir, prefix, "averages") - temperature_mean_fts = FieldTimeSeries(averages_file, "tosga"; backend = OnDisk()) - salinity_mean_fts = FieldTimeSeries(averages_file, "soga"; backend = OnDisk()) - temperature_mean = [Array(interior(temperature_mean_fts[n]))[1] for n in 1:length(temperature_mean_fts.times)] - salinity_mean = [Array(interior(salinity_mean_fts[n]))[1] for n in 1:length(salinity_mean_fts.times)] - time_in_years = temperature_mean_fts.times ./ (365.25 * 24 * 3600) - - temperature_profile_fts = FieldTimeSeries(averages_file, "to_h"; backend = OnDisk()) - salinity_profile_fts = FieldTimeSeries(averages_file, "so_h"; backend = OnDisk()) - temperature_profile = vec(compute_time_mean(temperature_profile_fts; start_time, stop_time)) - salinity_profile = vec(compute_time_mean(salinity_profile_fts; start_time, stop_time)) - depth = collect(znodes(grid, Center())) - - fields_file = find_first_file(run_dir, prefix, "fields") - tke_fts = FieldTimeSeries(fields_file, "tke"; backend = OnDisk()) - u_fts = FieldTimeSeries(fields_file, "uo"; backend = OnDisk()) - v_fts = FieldTimeSeries(fields_file, "vo"; backend = OnDisk()) - - ocean_mask = build_ocean_mask_3d(grid) - ocean_cells = sum(ocean_mask) - tke_mean = [sum(Array(interior(tke_fts[n])) .* ocean_mask) / ocean_cells - for n in 1:length(tke_fts.times)] - - ke(n) = @at((Center, Center, Center), u^2 + v^2) - ke_mean = [sum(ke(n)) ./ ocean_cells ./ 2 for n in 1:length(u_fts.times)] - tke_time_in_years = tke_fts.times ./ (365.25 * 24 * 3600) - - return (; temperature_mean, salinity_mean, time_in_years, - temperature_profile, salinity_profile, depth, - tke_mean, ke_mean, tke_time_in_years, ocean_mask, fields_file) -end - -TS = Dict{String, Any}() -for c in cases - @info "Loading time series: $(c.label)..." - TS[c.label] = load_timeseries_case(c.run_dir, c.prefix, D[c.label].grid; start_time, stop_time) -end - -# ══════════════════════════════════════════════════════════════ -# Figures 13-15: Time series and profiles -# ══════════════════════════════════════════════════════════════ - -# Figure 13: TKE -@info "Figure 13: TKE and KE" -fig = Figure(size = (900, 600), fontsize = 14) -ax = Axis(fig[1, 1]; xlabel="Time (years)", ylabel="TKE (m²/s²)", title="Global-mean turbulent kinetic energy") -for (i, lab) in enumerate(labels) - lines!(ax, TS[lab].tke_time_in_years, TS[lab].tke_mean; color=case_colors[i], label=lab) -end -axislegend(ax; position=:rb) -ax = Axis(fig[2, 1]; xlabel="Time (years)", ylabel="TKE (m²/s²)", title="Global-mean kinetic energy") -for (i, lab) in enumerate(labels) - lines!(ax, TS[lab].tke_time_in_years, TS[lab].ke_mean; color=case_colors[i], label=lab) -end -axislegend(ax; position=:rb) -savefig(fig, "fig13_tke.png") - -# Figure 14: T and S drift -@info "Figure 14: T and S drift" -fig = Figure(size = (1200, 450), fontsize = 14) -ax = Axis(fig[1, 1]; xlabel="Time (years)", ylabel="ΔT (deg C)", title="Global-mean temperature drift") -for (i, lab) in enumerate(labels) - d = TS[lab] - lines!(ax, d.time_in_years, d.temperature_mean .- d.temperature_mean[1]; color=case_colors[i], label=lab) -end -axislegend(ax; position=:lb) -ax = Axis(fig[1, 2]; xlabel="Time (years)", ylabel="ΔS (PSU)", title="Global-mean salinity drift") -for (i, lab) in enumerate(labels) - d = TS[lab] - lines!(ax, d.time_in_years, d.salinity_mean .- d.salinity_mean[1]; color=case_colors[i], label=lab) -end -axislegend(ax; position=:lb) -savefig(fig, "fig14_drift.png") - -# Figure 15: Profiles -@info "Figure 15: Profiles" -fig = Figure(size = (1000, 600), fontsize = 14) -ax = Axis(fig[1, 1]; xlabel="Temperature (deg C)", ylabel="Depth (m)", title="Horizontal-mean temperature") -for (i, lab) in enumerate(labels) - lines!(ax, TS[lab].temperature_profile, TS[lab].depth; color=case_colors[i], label=lab) -end -ylims!(ax, (-5500, 0)); axislegend(ax; position=:rb) -ax = Axis(fig[1, 2]; xlabel="Salinity (PSU)", ylabel="Depth (m)", title="Horizontal-mean salinity") -for (i, lab) in enumerate(labels) - lines!(ax, TS[lab].salinity_profile, TS[lab].depth; color=case_colors[i], label=lab) -end -ylims!(ax, (-5500, 0)); axislegend(ax; position=:rb) -savefig(fig, "fig15_profiles.png") - -# ══════════════════════════════════════════════════════════════ -# Zonal-mean sections -# ══════════════════════════════════════════════════════════════ - -Nlon, Nlat = 360, 180 -latlon_grid = LatitudeLongitudeGrid(CPU(); - size = (Nlon, Nlat, 1), longitude = (0, 360), latitude = (-90, 90), z = (0, 1)) -dst_f = Field{Center, Center, Nothing}(latlon_grid) - -function compute_zonal_mean(data_3d, ocean_mask_3d, regridder, Nlon, Nlat) - Nz = size(data_3d, 3) - zonal = fill(NaN, Nlat, Nz) - dst_data = zeros(Nlon * Nlat) - dst_mask = zeros(Nlon * Nlat) - areas = regridder.dst_areas - for k in 1:Nz - ConservativeRegridding.regrid!(dst_data, regridder, - vec(data_3d[:, :, k] .* ocean_mask_3d[:, :, k])) - ConservativeRegridding.regrid!(dst_mask, regridder, - vec(ocean_mask_3d[:, :, k])) - data_sum = reshape(dst_data .* areas, Nlon, Nlat) - mask_sum = reshape(dst_mask .* areas, Nlon, Nlat) - for j in 1:Nlat - m = sum(@view mask_sum[:, j]) - m > 0 && (zonal[j, k] = sum(@view data_sum[:, j]) / m) - end - end - return zonal -end - -ZM = Dict{String, Any}() -for c in cases - lab = c.label - grid = D[lab].grid - ocean_mask = TS[lab].ocean_mask - - # Build per-case regridder - @info "Building regridder for $lab (may take a few minutes)..." - src_f = Field{Center, Center, Nothing}(grid) - regridder = ConservativeRegridding.Regridder(dst_f, src_f; progress = true) - - @info "Loading 3-D fields for $lab..." - fields_file = TS[lab].fields_file - to_fts = FieldTimeSeries(fields_file, "to"; backend = OnDisk()) - so_fts = FieldTimeSeries(fields_file, "so"; backend = OnDisk()) - bo_fts = FieldTimeSeries(fields_file, "bo"; backend = OnDisk()) - eo_fts = FieldTimeSeries(fields_file, "tke"; backend = OnDisk()) - - temperature_mean = compute_time_mean(to_fts; start_time, stop_time) - salinity_mean = compute_time_mean(so_fts; start_time, stop_time) - buoyancy_mean = compute_time_mean(bo_fts; start_time, stop_time) - kinetic_energy_mean = compute_time_mean(eo_fts; start_time, stop_time) - buoyancy_initial = Array(interior(bo_fts[1])) - - @info "Computing zonal means for $lab..." - temperature_zonal = compute_zonal_mean(temperature_mean, ocean_mask, regridder, Nlon, Nlat) - salinity_zonal = compute_zonal_mean(salinity_mean, ocean_mask, regridder, Nlon, Nlat) - buoyancy_zonal = compute_zonal_mean(buoyancy_mean, ocean_mask, regridder, Nlon, Nlat) - kinetic_energy_zonal = compute_zonal_mean(kinetic_energy_mean, ocean_mask, regridder, Nlon, Nlat) - temperature_woa_zonal = compute_zonal_mean(D[lab].T_woa_on_grid, ocean_mask, regridder, Nlon, Nlat) - salinity_woa_zonal = compute_zonal_mean(D[lab].S_woa_on_grid, ocean_mask, regridder, Nlon, Nlat) - buoyancy_init_zonal = compute_zonal_mean(buoyancy_initial, ocean_mask, regridder, Nlon, Nlat) - - depth = collect(znodes(grid, Center())) - - ZM[lab] = (; temperature_zonal, salinity_zonal, buoyancy_zonal, kinetic_energy_zonal, - temperature_woa_zonal, salinity_woa_zonal, buoyancy_init_zonal, - δtemperature_zonal = temperature_zonal .- temperature_woa_zonal, - δsalinity_zonal = salinity_zonal .- salinity_woa_zonal, - δbuoyancy_zonal = buoyancy_zonal .- buoyancy_init_zonal, - depth) -end - -latitude = collect(φnodes(latlon_grid, Center())) - -# ══════════════════════════════════════════════════════════════ -# Figures 16-17: Zonal means -# ══════════════════════════════════════════════════════════════ - -temperature_levels = -2:2:30 -salinity_levels = 33:0.25:37 -buoyancy_levels = range(-0.04, 0.02, length=13) - -# Figure 16: Zonal-mean T, S, b -@info "Figure 16: Zonal means" -fig = Figure(size = (600 * length(labels), 1200), fontsize = 14) -for (i, lab) in enumerate(labels) - zm = ZM[lab] - ax = Axis(fig[1, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal T") - hm = heatmap!(ax, latitude, zm.depth, zm.temperature_zonal; colormap=:thermal, colorrange=(-2,30), nan_color=:lightgray) - contour!(ax, latitude, zm.depth, zm.temperature_woa_zonal; levels=temperature_levels, color=:grey, linestyle=:dash, linewidth=0.8) - contour!(ax, latitude, zm.depth, zm.temperature_zonal; levels=temperature_levels, color=:black, linewidth=0.8) - Colorbar(fig[1, 2i], hm; label="deg C"); ylims!(ax, (-5500, 0)) - - ax = Axis(fig[2, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal S") - hm = heatmap!(ax, latitude, zm.depth, zm.salinity_zonal; colormap=:haline, colorrange=(33,37), nan_color=:lightgray) - contour!(ax, latitude, zm.depth, zm.salinity_woa_zonal; levels=salinity_levels, color=:grey, linestyle=:dash, linewidth=0.8) - contour!(ax, latitude, zm.depth, zm.salinity_zonal; levels=salinity_levels, color=:black, linewidth=0.8) - Colorbar(fig[2, 2i], hm; label="PSU"); ylims!(ax, (-5500, 0)) - - ax = Axis(fig[3, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal b") - hm = heatmap!(ax, latitude, zm.depth, zm.buoyancy_zonal; colormap=:balance, nan_color=:lightgray) - contour!(ax, latitude, zm.depth, zm.buoyancy_init_zonal; levels=buoyancy_levels, color=:grey, linestyle=:dash, linewidth=0.8) - contour!(ax, latitude, zm.depth, zm.buoyancy_zonal; levels=buoyancy_levels, color=:black, linewidth=0.8) - Colorbar(fig[3, 2i], hm; label="m/s²"); ylims!(ax, (-5500, 0)) - - ax = Axis(fig[4, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal e") - hm = heatmap!(ax, latitude, zm.depth, zm.kinetic_energy_zonal; colormap=:solar, nan_color=:lightgray) - Colorbar(fig[4, 2i], hm; label="m/s²"); ylims!(ax, (-5500, 0)) -end -savefig(fig, "fig16_zonal_mean.png") - -# Figure 17: Zonal-mean drift -@info "Figure 17: Zonal-mean drift" -fig = Figure(size = (600 * length(labels), 900), fontsize = 14) -for (i, lab) in enumerate(labels) - zm = ZM[lab] - ax = Axis(fig[1, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal T - WOA") - hm = heatmap!(ax, latitude, zm.depth, zm.δtemperature_zonal; colormap=:balance, colorrange=(-5,5), nan_color=:lightgray) - Colorbar(fig[1, 2i], hm; label="deg C"); ylims!(ax, (-5500, 0)) - - ax = Axis(fig[2, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal S - WOA") - hm = heatmap!(ax, latitude, zm.depth, zm.δsalinity_zonal; colormap=:balance, colorrange=(-1,1), nan_color=:lightgray) - Colorbar(fig[2, 2i], hm; label="PSU"); ylims!(ax, (-5500, 0)) - - ax = Axis(fig[3, 2i-1]; xlabel="Latitude", ylabel="Depth (m)", title="$lab: Zonal b - b(t=0)") - hm = heatmap!(ax, latitude, zm.depth, zm.δbuoyancy_zonal; colormap=:balance, nan_color=:lightgray) - Colorbar(fig[3, 2i], hm; label="m/s²"); ylims!(ax, (-5500, 0)) -end -savefig(fig, "fig17_zonal_drift.png") - -@info "All 17 figures saved to $output_dir" diff --git a/experiments/OMIPSimulations/scripts/watchdog.sh b/experiments/OMIPSimulations/scripts/watchdog.sh deleted file mode 100755 index e56f8d5d9..000000000 --- a/experiments/OMIPSimulations/scripts/watchdog.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash -# Watchdog that keeps store.sh jobs alive for the given CONFIGs. -# Usage: ./watchdog.sh orca halfdegree tenthdegree -# Run inside tmux from the same directory as store.sh. - -set -euo pipefail - -if [[ $# -eq 0 ]]; then - echo "Usage: $0 [config2] ..." - echo "Example: $0 orca halfdegree" - exit 1 -fi - -CONFIGS=("$@") - -while true; do - for cfg in "${CONFIGS[@]}"; do - if ! squeue -u "$USER" -n "store_${cfg}" -h | grep -q .; then - echo "$(date): store_${cfg} not found, relaunching" - ./store.sh "$cfg" - else - echo "$(date): store_${cfg} is running" - fi - done - sleep 3600 -done diff --git a/experiments/OMIPSimulations/src/OMIPSimulations.jl b/experiments/OMIPSimulations/src/OMIPSimulations.jl deleted file mode 100644 index fb4a81b3d..000000000 --- a/experiments/OMIPSimulations/src/OMIPSimulations.jl +++ /dev/null @@ -1,79 +0,0 @@ -module OMIPSimulations - -using Oceananigans -using Oceananigans.Units -using Oceananigans.Grids: znode, Face -using Dates -using NCDatasets -using CUDA - -using NumericalEarth -using NumericalEarth.Oceans: ocean_simulation, default_ocean_closure -using NumericalEarth.SeaIces: sea_ice_simulation -using NumericalEarth.EarthSystemModels: OceanSeaIceModel, Radiation, - SimilarityTheoryFluxes, - COARELogarithmicSimilarityProfile, - LinearStableStabilityFunction, - MomentumBasedFrictionVelocity, - ThreeEquationHeatFlux, - NCARBulkFluxes, - ncar_stability_functions - -using NumericalEarth.EarthSystemModels.InterfaceComputations: - ComponentInterfaces, - CoefficientBasedFluxes, - MomentumRoughnessLength, - ScalarRoughnessLength, - NCARMomentumRoughnessLength, - NCARScalarRoughnessLength, - WindDependentWaveFormulation, - TemperatureDependentAirViscosity, - SimilarityScales, - SeaIceAlbedo, - atmosphere_sea_ice_stability_functions - -using NumericalEarth.Bathymetry: regrid_bathymetry, ORCAGrid -using NumericalEarth.DataWrangling: Metadatum, Metadata, DatasetRestoring, - EN4Monthly, ECCO4Monthly -using NumericalEarth.DataWrangling.WOA: WOAMonthly -using NumericalEarth.DataWrangling.ORCA: ORCA1 -using NumericalEarth.DataWrangling.JRA55: MultiYearJRA55, JRA55NetCDFBackend, - JRA55PrescribedAtmosphere -using NumericalEarth.Diagnostics: MixedLayerDepthField - -export omip_simulation, - add_omip_diagnostics!, - compute_report_fields, - compute_woa_bias - -# Backwards-compatible restore for checkpoints saved before ClimaSeaIce 0.4.8 -# (which added snow_thickness, snow_thermodynamics, snowfall to SeaIceModel). -# Old checkpoints lack :snow_thickness in the saved state; this override -# silently skips the missing field so pickup works across versions. -using ClimaSeaIce: SeaIceModel -import Oceananigans: restore_prognostic_state! - -function restore_prognostic_state!(model::SeaIceModel, state) - restore_prognostic_state!(model.clock, state.clock) - restore_prognostic_state!(model.velocities, state.velocities) - restore_prognostic_state!(model.ice_thickness, state.ice_thickness) - restore_prognostic_state!(model.ice_concentration, state.ice_concentration) - restore_prognostic_state!(model.tracers, state.tracers) - restore_prognostic_state!(model.timestepper, state.timestepper) - restore_prognostic_state!(model.ice_thermodynamics, state.ice_thermodynamics) - restore_prognostic_state!(model.dynamics, state.dynamics) - - # New fields in ClimaSeaIce >= 0.4.8 — restore only if checkpoint contains them - if hasproperty(state, :snow_thickness) - restore_prognostic_state!(model.snow_thickness, state.snow_thickness) - end - - return model -end - -include("atmosphere.jl") -include("omip_simulation.jl") -include("omip_diagnostics.jl") -include("report_fields.jl") - -end # module diff --git a/experiments/OMIPSimulations/src/atmosphere.jl b/experiments/OMIPSimulations/src/atmosphere.jl deleted file mode 100644 index f5cff1de2..000000000 --- a/experiments/OMIPSimulations/src/atmosphere.jl +++ /dev/null @@ -1,28 +0,0 @@ -""" - omip_atmosphere(arch; forcing_dir, start_date, end_date, backend_size=30) - -Set up a JRA55 prescribed atmosphere with river and iceberg forcing, -together with a default `Radiation` model. Returns the tuple -`(atmosphere, radiation)`. -""" -function omip_atmosphere(arch; - forcing_dir, - start_date, - end_date, - backend_size = 30) - - dataset = MultiYearJRA55() - backend = JRA55NetCDFBackend(backend_size) - - atmosphere = JRA55PrescribedAtmosphere(arch; - dir = forcing_dir, - dataset, - backend, - include_rivers_and_icebergs = true, - start_date, - end_date) - - radiation = Radiation() - - return atmosphere, radiation -end diff --git a/experiments/OMIPSimulations/src/omip_diagnostics.jl b/experiments/OMIPSimulations/src/omip_diagnostics.jl deleted file mode 100644 index 78c471ccb..000000000 --- a/experiments/OMIPSimulations/src/omip_diagnostics.jl +++ /dev/null @@ -1,187 +0,0 @@ - -using JLD2 - -""" - add_omip_diagnostics!(simulation; kwargs...) - -Attach OMIP-protocol output writers to a coupled ocean--sea-ice -simulation built by [`omip_simulation`](@ref). - -Creates four output writers: - -1. **Surface diagnostics** (`_surface.nc`): 2-D fields averaged - over `surface_averaging_interval` -- SST, SSS, SSH, surface velocities, - squared fields for variance, mixed-layer depth, wind stress, - heat/freshwater fluxes, and sea-ice state variables. -2. **3-D field diagnostics** (`_fields.nc`): full 3-D temperature, - salinity, velocity, buoyancy, and (when present) TKE, averaged over - `field_averaging_interval`. -3. **Averages** (`_averages.nc`): global means of T, S, buoyancy - and horizontal-mean (dims=(1,2)) depth profiles of the same, on the - same `field_averaging_interval` schedule. -4. **Checkpointer** (`_checkpoint`): JLD2 checkpoint of the - coupled model at `checkpoint_interval`. Use `run!(sim; pickup=true)` - to restart from the latest checkpoint. - -# Keyword arguments - -- `surface_averaging_interval`: averaging window for surface output. Default: `5days`. -- `field_averaging_interval`: averaging window for 3-D / averages output. Default: `15days`. -- `checkpoint_interval`: interval between checkpoints. Default: `90days`. -- `output_dir`: directory for all output files. Default: `"."`. -- `filename_prefix`: prefix for output filenames. Default: `"omip"`. -- `file_splitting_interval`: time interval for splitting output files. Default: `360days`. -""" -function add_omip_diagnostics!(simulation; - field_mean_interval = 5days, - surface_averaging_interval = 5days, - field_averaging_interval = 15days, - checkpoint_interval = 720days, - output_dir = ".", - filename_prefix = "omip", - file_splitting_interval = 360days) - - model = simulation.model - ocean = model.ocean - sea_ice = model.sea_ice - grid = ocean.model.grid - Nz = size(grid, 3) - - T, S = ocean.model.tracers.T, ocean.model.tracers.S - u, v, w = ocean.model.velocities - η = ocean.model.free_surface.displacement - - τx = model.interfaces.net_fluxes.ocean.u - τy = model.interfaces.net_fluxes.ocean.v - JT = model.interfaces.net_fluxes.ocean.T - Js = model.interfaces.net_fluxes.ocean.S - Qc = model.interfaces.atmosphere_ocean_interface.fluxes.sensible_heat - Qv = model.interfaces.atmosphere_ocean_interface.fluxes.latent_heat - - JTf = NumericalEarth.Diagnostics.frazil_temperature_flux(model) - JTn = NumericalEarth.Diagnostics.net_ocean_temperature_flux(model) - JTio = NumericalEarth.Diagnostics.sea_ice_ocean_temperature_flux(model) - JTao = NumericalEarth.Diagnostics.atmosphere_ocean_temperature_flux(model) - JSn = NumericalEarth.Diagnostics.net_ocean_salinity_flux(model) - JSio = NumericalEarth.Diagnostics.sea_ice_ocean_salinity_flux(model) - - hi = sea_ice.model.ice_thickness - ℵi = sea_ice.model.ice_concentration - ui, vi = sea_ice.model.velocities - - sitemptop = try - sea_ice.model.ice_thermodynamics.top_surface_temperature - catch - nothing - end - - mld = MixedLayerDepthField(ocean.model.buoyancy, grid, ocean.model.tracers) - - # Surface diagnostics - surface_indices = (:, :, Nz) - - tos = view(T, :, :, Nz) - sos = view(S, :, :, Nz) - uo_surface = view(u, :, :, Nz) - vo_surface = view(v, :, :, Nz) - - tossq = tos * tos - sossq = sos * sos - zossq = Field(η * η) - - surface_outputs = Dict{Symbol, Any}( - :tos => tos, - :sos => sos, - :zos => η, - :uos => uo_surface, - :vos => vo_surface, - :tossq => tossq, - :sossq => sossq, - :zossq => zossq, - :mlotst => mld, - :tauuo => τx, - :tauvo => τy, - :hfds => JT, - :wfo => Js, - :hfss => Qc, - :hfls => Qv, - :siconc => ℵi, - :sithick => hi, - :siu => ui, - :siv => vi, - :JTf => JTf, - :JTn => JTn, - :JTio => JTio, - :JTao => JTao, - :JSn => JSn, - :JSio => JSio - ) - - if !isnothing(sitemptop) - surface_outputs[:sitemptop] = sitemptop - end - - simulation.output_writers[:surface] = JLD2Writer(ocean.model, surface_outputs; - schedule = AveragedTimeInterval(surface_averaging_interval), - dir = output_dir, - filename = filename_prefix * "_surface", - file_splitting = TimeInterval(file_splitting_interval), - overwrite_existing = true, - jld2_kw = Dict(:compress => ZstdFilter())) - - # 3-D fields (including buoyancy) - bop = Oceananigans.Models.buoyancy_operation(ocean.model) - - field_outputs = Dict{Symbol, Any}( - :to => T, - :so => S, - :uo => u, - :vo => v, - :wo => w, - :bo => bop, - ) - - if haskey(ocean.model.tracers, :e) - field_outputs[:tke] = ocean.model.tracers.e - end - - simulation.output_writers[:fields] = JLD2Writer(ocean.model, field_outputs; - schedule = AveragedTimeInterval(field_averaging_interval), - dir = output_dir, - filename = filename_prefix * "_fields", - file_splitting = TimeInterval(file_splitting_interval), - overwrite_existing = true, - jld2_kw = Dict(:compress => ZstdFilter())) - - # Global means and horizontal-mean depth profiles for T, S, b - average_outputs = Dict{Symbol, Any}( - :tosga => Average(T), - :soga => Average(S), - :bga => Average(bop), - :to_h => Average(T, dims=(1, 2)), - :so_h => Average(S, dims=(1, 2)), - :bo_h => Average(bop, dims=(1, 2)), - ) - - simulation.output_writers[:averages] = JLD2Writer(ocean.model, average_outputs; - schedule = AveragedTimeInterval(field_mean_interval), - dir = output_dir, - filename = filename_prefix * "_averages", - file_splitting = TimeInterval(file_splitting_interval), - overwrite_existing = true) - - # Checkpointer (drives `run!(sim; pickup=true)`) - simulation.output_writers[:checkpointer] = Checkpointer(simulation.model; - schedule = TimeInterval(checkpoint_interval), - prefix = joinpath(output_dir, filename_prefix * "_checkpoint"), - cleanup = false, - verbose = true) - - @info "OMIP diagnostics attached:" * - " surface ($(length(surface_outputs)) fields, every $(prettytime(surface_averaging_interval)))," * - " 3-D ($(length(field_outputs)) fields, every $(prettytime(field_averaging_interval)))," * - " averages ($(length(average_outputs)) fields, every $(prettytime(field_averaging_interval)))," * - " checkpointer (every $(prettytime(checkpoint_interval)))" - - return nothing -end diff --git a/experiments/OMIPSimulations/src/omip_simulation.jl b/experiments/OMIPSimulations/src/omip_simulation.jl deleted file mode 100644 index 6ea898947..000000000 --- a/experiments/OMIPSimulations/src/omip_simulation.jl +++ /dev/null @@ -1,463 +0,0 @@ -using Printf -using Oceananigans.Operators: Δzᶜᶜᶜ -using Oceananigans.TurbulenceClosures: IsopycnalSkewSymmetricDiffusivity - -##### -##### Flux configurations -##### - -""" - corrected_atmosphere_ocean_fluxes(FT = Float64) - -COARE 3.6-consistent atmosphere-ocean flux formulation with: -- Wind-dependent Charnock parameter (Edson et al. 2013, eq. 13) -- COARE logarithmic similarity profile (no ψ(ℓ/L) term) -- Minimum gustiness = 0.2 m/s (Fairall et al. 2003) -- Temperature-dependent air viscosity -""" -function corrected_atmosphere_ocean_fluxes(FT = Float64) - air_kinematic_viscosity = TemperatureDependentAirViscosity(FT) - return SimilarityTheoryFluxes(FT; - similarity_form = COARELogarithmicSimilarityProfile(), - minimum_gustiness = FT(0.2), - momentum_roughness_length = MomentumRoughnessLength(FT; - wave_formulation = WindDependentWaveFormulation(FT), - air_kinematic_viscosity = TemperatureDependentAirViscosity(FT)), - temperature_roughness_length = ScalarRoughnessLength(FT; air_kinematic_viscosity), - water_vapor_roughness_length = ScalarRoughnessLength(FT; air_kinematic_viscosity)) -end - -""" - corrected_atmosphere_sea_ice_fluxes(FT = Float64) - -Atmosphere-sea ice flux formulation with: -- SHEBA/Paulson+Grachev stability functions (existing default, correct) -- Fixed momentum roughness z0 = 5e-4 m (CICE/SHEBA standard; Andreas et al. 2010) -- Fixed scalar roughness z0t = z0q = 5e-5 m (Andreas 1987: z0t ≈ z0/10 at R*≈7) -- COARE logarithmic similarity profile -- Minimum gustiness = 0.2 m/s -""" -corrected_atmosphere_sea_ice_fluxes(FT = Float64) = - SimilarityTheoryFluxes(FT; - stability_functions = atmosphere_sea_ice_stability_functions(FT), - similarity_form = COARELogarithmicSimilarityProfile(), - minimum_gustiness = FT(0.2), - momentum_roughness_length = FT(5e-4), - temperature_roughness_length = FT(5e-5), - water_vapor_roughness_length = FT(5e-5)) - -""" - corrected_ice_ocean_heat_flux() - -Three-equation ice-ocean heat flux with momentum-based friction velocity -computed from actual ice-ocean stress (McPhee 1992, 2008; SHEBA median u*≈0.01 m/s). -""" -corrected_ice_ocean_heat_flux() = ThreeEquationHeatFlux(; friction_velocity = MomentumBasedFrictionVelocity()) - -""" - ncar_atmosphere_ocean_fluxes(FT = Float64) - -OMIP-2 standard atmosphere-ocean flux formulation using the NCAR/Large & Yeager -(2004, 2009) bulk algorithm. Iterates directly on transfer coefficients (Cd, Ch, Ce), -NOT on roughness lengths. Uses 5 fixed iterations with Paulson stability functions. -""" -ncar_atmosphere_ocean_fluxes(FT = Float64) = NCARBulkFluxes(FT) - -""" - ncar_atmosphere_sea_ice_fluxes(FT = Float64) - -NCAR/CORE atmosphere-sea ice flux formulation with full Monin-Obukhov -similarity theory and stability corrections: -- Paulson (1970) + linear stable (-5ζ) stability functions (same as NCAR ocean) -- Fixed z0 = z0t = z0q = 5e-4 m (CICE default; SHEBA standard) -- Wind speed floor at 0.5 m/s -- COARE logarithmic similarity profile (no ψ(ℓ/L) term) - -Over ice the roughness lengths are fixed geometric constants (not wind-dependent), -so the standard MOST roughness-length iteration is consistent here (unlike the -ocean case where the NCAR polynomial Cd requires its own solver). -""" -ncar_atmosphere_sea_ice_fluxes(FT = Float64) = - SimilarityTheoryFluxes(FT; - stability_functions = ncar_stability_functions(FT), - similarity_form = COARELogarithmicSimilarityProfile(), - gustiness_parameter = FT(0), - minimum_gustiness = FT(0.5), - momentum_roughness_length = FT(5e-4), - temperature_roughness_length = FT(5e-4), - water_vapor_roughness_length = FT(5e-4)) - -""" - corrected_radiation(sea_ice) - -Radiation with OMIP-2 standard ocean parameters (emissivity = 1.0, albedo = 0.06) -and CCSM3 temperature/snow/thickness-dependent sea ice albedo. -""" -function corrected_radiation(sea_ice) - hi = sea_ice.model.ice_thickness - hs = sea_ice.model.snow_thickness - - # When snow is present, the snow layer owns the surface temperature; - # otherwise the ice top surface temperature is the atmosphere interface. - snow_thermo = sea_ice.model.snow_thermodynamics - Ts = if isnothing(snow_thermo) - sea_ice.model.ice_thermodynamics.top_surface_temperature - else - snow_thermo.top_surface_temperature - end - - sea_ice_albedo = SeaIceAlbedo(hi, hs, Ts) - - return Radiation(; ocean_emissivity = 1.0, - ocean_albedo = 0.06, - sea_ice_albedo) -end - -""" - build_coupled_model(ocean, sea_ice, atmosphere, radiation, flux_configuration) - -Build the `OceanSeaIceModel` with the specified flux configuration. -Options: `:default`, `:corrected`, `:ncar`. -""" -function build_coupled_model(ocean, sea_ice, atmosphere, radiation, flux_configuration) - if flux_configuration == :default - return OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) - end - - FT = eltype(ocean.model.grid) - radiation = corrected_radiation(sea_ice) - - if flux_configuration == :corrected - interfaces = ComponentInterfaces(atmosphere, ocean, sea_ice; - radiation, - atmosphere_ocean_fluxes = corrected_atmosphere_ocean_fluxes(FT), - atmosphere_sea_ice_fluxes = corrected_atmosphere_sea_ice_fluxes(FT), - sea_ice_ocean_heat_flux = corrected_ice_ocean_heat_flux()) - elseif flux_configuration == :ncar - interfaces = ComponentInterfaces(atmosphere, ocean, sea_ice; - radiation, - atmosphere_ocean_fluxes = ncar_atmosphere_ocean_fluxes(FT), - atmosphere_sea_ice_fluxes = ncar_atmosphere_sea_ice_fluxes(FT), - sea_ice_ocean_heat_flux = corrected_ice_ocean_heat_flux()) - else - error("Unknown flux_configuration: $flux_configuration. Options: :default, :corrected, :ncar") - end - - return OceanSeaIceModel(ocean, sea_ice; atmosphere, interfaces) -end - -##### -##### Main simulation builder -##### - -""" - omip_simulation(config::Symbol = :halfdegree; kwargs...) - -Create a fully coupled ocean--sea-ice--atmosphere OMIP simulation. - -The single positional argument selects the grid configuration: - -- `:halfdegree` -- 720x360 `TripolarGrid` -- `:tenthdegree` -- 3600x1800 `TripolarGrid` -- `:orca` -- NEMO eORCA mesh - -Returns a `Simulation` wrapping an `OceanSeaIceModel`. The simulation -already has a progress callback attached, and (when `diagnostics=true`) -the OMIP-protocol output writers from [`add_omip_diagnostics!`](@ref). - -To restart from a previous run, simply call - - run!(sim; pickup = true) - -which uses Oceananigans' built-in `Checkpointer` machinery — no extra -plumbing is needed because `NumericalEarth.EarthSystemModels` provides -`prognostic_state` / `restore_prognostic_state!` for the coupled model. - -# Keyword arguments - -- `arch`: architecture (`CPU()` or `GPU()`). Default: `CPU()`. -- `Nz::Int`: number of vertical levels. Default: `100`. -- `depth`: maximum ocean depth in metres. Default: `5500`. -- `κ_skew`, `κ_symmetric`: GM/Redi diffusivities. Defaults: `500`, `100`. -- `forcing_dir`: directory for JRA55 forcing data. Default: `"forcing_data"`. -- `restoring_dir`: directory for restoring/IC climatology. Default: `"climatology"`. -- `piston_velocity`: surface salinity restoring piston velocity in m/day. Default: `1/6`. - Restoring is automatically masked by sea ice concentration (no restoring under ice). -- `start_date`, `end_date`: bracket for forcing/restoring metadata. Defaults: 1958-01-01 .. 2018-01-01. -- `Δt`: simulation time step. Default: `30minutes`. -- `stop_time`: stop time for the wrapping `Simulation`. Default: `Inf`. -- `flux_configuration`: surface flux formulation. Options: - * `:default` — current defaults (Edson/COARE with constant Charnock 0.02) - * `:corrected` — COARE 3.6 with wind-dependent Charnock, fixed ice roughness, momentum-based u* - * `:ncar` — OMIP-2 standard Large & Yeager (2004) bulk formulae -- `diagnostics::Bool`: whether to attach OMIP diagnostics. Default: `true`. -- `surface_averaging_interval`, `field_averaging_interval`: averaging windows. -- `checkpoint_interval`: interval between checkpoint writes. -- `output_dir`, `filename_prefix`, `file_splitting_interval`: output configuration. -""" -function omip_simulation(config::Symbol = :halfdegree; - arch = CPU(), - Nz = 100, - depth = 5500, - κ_skew = 250, - κ_symmetric = 100, - biharmonic_timescale = 40days, - forcing_dir = "forcing_data", - restoring_dir = "climatology", - piston_velocity = 1 / 6, # m / day - start_date = DateTime(1958, 1, 1), - end_date = DateTime(2018, 1, 1), - Δt = 30minutes, - stop_time = Inf, - flux_configuration = :default, - with_snow = false, - diagnostics = true, - field_mean_interval = 5days, - surface_averaging_interval = 5days, - field_averaging_interval = 15days, - checkpoint_interval = 360days, - output_dir = ".", - filename_prefix = string(config), - file_splitting_interval = 360days) - - cfg = Val(config) - - # Build the grid first so we can allocate the restoring mask - grid = build_grid(cfg, arch, Nz, depth) - - # Pre-allocate restoring mask (1 = open water); updated each step from sea ice concentration - ice_free_fraction = Field{Center, Center, Nothing}(grid) - set!(ice_free_fraction, 1) - - ocean = build_ocean(cfg, grid; - κ_skew, κ_symmetric, - biharmonic_timescale, - restoring_dir, piston_velocity, - restoring_mask = ice_free_fraction, - start_date, end_date) - - sea_ice = build_sea_ice(cfg, grid, ocean; restoring_dir, with_snow) - - atmosphere, radiation = omip_atmosphere(arch; - forcing_dir, - start_date, - end_date) - - coupled = build_coupled_model(ocean, sea_ice, atmosphere, radiation, flux_configuration) - - simulation = Simulation(coupled; Δt, stop_time) - - for dir in [forcing_dir, restoring_dir, output_dir] - if !isdir(dir) - mkdir(dir) - end - end - - # Callback to sync the restoring mask with sea ice concentration each coupled step - ℵ = sea_ice.model.ice_concentration - update_restoring_mask!(sim) = parent(ice_free_fraction) .= 1 .- parent(ℵ) - add_callback!(simulation, update_restoring_mask!, IterationInterval(1)) - - wall_time = Ref(time_ns()) - add_callback!(simulation, omip_progress_callback(wall_time), IterationInterval(10)) - - if diagnostics - add_omip_diagnostics!(simulation; - surface_averaging_interval, - field_averaging_interval, - field_mean_interval, - checkpoint_interval, - output_dir, - filename_prefix, - file_splitting_interval) - end - - return simulation -end - -##### -##### Shared closure utilities -##### - -@inline νhb(i, j, k, grid, ℓx, ℓy, ℓz, clock, fields, λ) = Oceananigans.Operators.Az(i, j, k, grid, ℓx, ℓy, ℓz)^2 / λ - -# Background tracer diffusivity following Henyey et al. (1986). -@inline henyey_diffusivity(x, y, z, t) = max(1e-6, 5e-6 * abs(sind(y))) - -function omip_closure(; κ_skew, κ_symmetric, biharmonic_timescale) - catke = default_ocean_closure() - - eddy = if isnothing(κ_skew) | isnothing(κ_symmetric) - nothing - else - IsopycnalSkewSymmetricDiffusivity(; κ_skew, κ_symmetric) - end - - horizontal_viscosity = if isnothing(biharmonic_timescale) - nothing - else - HorizontalScalarBiharmonicDiffusivity(ν=νhb, - discrete_form=true, - parameters=biharmonic_timescale) - end - - vertical_diffusivity = VerticalScalarDiffusivity(κ=henyey_diffusivity, ν=3e-5) - - return filter(!isnothing, (catke, eddy, horizontal_viscosity, vertical_diffusivity)) -end - -##### -##### Salinity restoring (shared by both configurations) -##### - -function salinity_restoring_forcing(grid, dataset; - restoring_dir, - piston_velocity, - mask = 1) - - Nz = size(grid, 3) - Δz_surface = CUDA.@allowscalar Δzᶜᶜᶜ(1, 1, Nz, grid) - - rate = piston_velocity / (Δz_surface * days) - - Smetadata = Metadata(:salinity; - dir = restoring_dir, - dataset) - - return DatasetRestoring(Smetadata, Oceananigans.Architectures.architecture(grid); - rate, mask, - time_indices_in_memory = 12) -end - -##### -##### Grid builder -##### - -function build_grid(config, arch, Nz, depth) - - Nx = config == Val(:halfdegree) ? 720 : - config == Val(:tenthdegree) ? 3600 : - throw("Configuration $(config) does not exist") - - Ny = Nx ÷ 2 - - z_faces = ExponentialDiscretization(Nz, -depth, 0; scale=1300, mutable=true) - - base_grid = TripolarGrid(arch; - size = (Nx, Ny, Nz), - z = z_faces, - halo = (7, 7, 7)) - - bottom_height = regrid_bathymetry(base_grid; - minimum_depth = 20, - major_basins = 1, - interpolation_passes = 25) - - return ImmersedBoundaryGrid(base_grid, GridFittedBottom(bottom_height); active_cells_map = true) -end - -function build_grid(::Val{:orca}, arch, Nz, depth) - - z_faces = ExponentialDiscretization(Nz, -depth, 0; scale=1300, mutable=true) - - return ORCAGrid(arch; - dataset = ORCA1(), - Nz, - z = z_faces, - halo = (7, 7, 7), - with_bathymetry = true, - active_cells_map = true) -end - -##### -##### ORCA builder -##### - -config_momentum_advection(::Val{:orca}) = VectorInvariant() -config_momentum_advection(::Val{:halfdegree}) = WENOVectorInvariant(order=5) -config_momentum_advection(::Val{:tenthdegree}) = WENOVectorInvariant() - -function build_ocean(config, grid; - κ_skew, κ_symmetric, - restoring_dir, piston_velocity, - biharmonic_timescale, - restoring_mask = 1, - start_date, end_date) - - FS = salinity_restoring_forcing(grid, WOAMonthly(); restoring_dir, piston_velocity, mask = restoring_mask) - - closure = omip_closure(; κ_skew, κ_symmetric, biharmonic_timescale) - coriolis = HydrostaticSphericalCoriolis(scheme = Oceananigans.Coriolis.EnstrophyConserving()) - momentum_advection = config_momentum_advection(config) - - ocean = ocean_simulation(grid; - Δt = 1minutes, - momentum_advection, - tracer_advection = WENO(order=7; minimum_buffer_upwind_order=3), - coriolis, - timestepper = :SplitRungeKutta3, - free_surface = SplitExplicitFreeSurface(grid; substeps=70), - surface_restoring = (; S = FS), - closure) - - set!(ocean.model, - T = Metadatum(:temperature; dir=restoring_dir, dataset=WOAAnnual(), date=start_date), - S = Metadatum(:salinity; dir=restoring_dir, dataset=WOAAnnual(), date=start_date)) - - return ocean -end - -##### -##### Sea Ice builder -##### - -function build_sea_ice(config, grid, ocean; restoring_dir, with_snow = false) - sea_ice = sea_ice_simulation(grid, ocean; - advection = WENO(order=7, minimum_buffer_upwind_order=1), - with_snow) - - set!(sea_ice.model, - h = Metadatum(:sea_ice_thickness; dir=restoring_dir, dataset=ECCO4Monthly()), - ℵ = Metadatum(:sea_ice_concentration; dir=restoring_dir, dataset=ECCO4Monthly())) - - return sea_ice -end - -##### -##### Progress callback -##### - -function omip_progress_callback(wall_time) - function progress(sim) - sea_ice = sim.model.sea_ice - ocean = sim.model.ocean - - hmax = maximum(sea_ice.model.ice_thickness) - ℵmax = maximum(sea_ice.model.ice_concentration) - Tmax = maximum(ocean.model.tracers.T) - Tmin = minimum(ocean.model.tracers.T) - Smax = maximum(ocean.model.tracers.S) - Smin = minimum(ocean.model.tracers.S) - umax = maximum(ocean.model.velocities.u) - vmax = maximum(ocean.model.velocities.v) - wmax = maximum(ocean.model.velocities.w) - - step_time = 1e-9 * (time_ns() - wall_time[]) - - msg1 = @sprintf("time: %s, iteration: %d, Δt: %s, ", - prettytime(sim), iteration(sim), prettytime(sim.Δt)) - msg2 = @sprintf("max(h): %.2e m, max(ℵ): %.2e ", hmax, ℵmax) - msg3 = @sprintf("extrema(T, S): (%.2f, %.2f) ᵒC, (%.2f, %.2f) psu ", - Tmin, Tmax, Smin, Smax) - msg4 = @sprintf("maximum(u): (%.2e, %.2e, %.2e) m/s, ", umax, vmax, wmax) - msg5 = @sprintf("wall time: %s", prettytime(step_time)) - - @info msg1 * msg2 * msg3 * msg4 * msg5 - - wall_time[] = time_ns() - - return nothing - end - - return progress -end diff --git a/experiments/OMIPSimulations/src/report_fields.jl b/experiments/OMIPSimulations/src/report_fields.jl deleted file mode 100644 index 79348e6d3..000000000 --- a/experiments/OMIPSimulations/src/report_fields.jl +++ /dev/null @@ -1,83 +0,0 @@ -using Oceananigans.AbstractOperations: KernelFunctionOperation -using Oceananigans.Operators: ζ₃ᶠᶠᶜ, ℑxᶜᵃᵃ, ℑyᵃᶜᵃ -using Oceananigans.Architectures: child_architecture -using Oceananigans.Fields: interpolate! -using NumericalEarth.DataWrangling: WOAAnnual -using NumericalEarth.Diagnostics: MixedLayerDepthField -using WorldOceanAtlasTools - -@inline function speedᶜᶜᶜ(i, j, k, grid, u, v) - û = ℑxᶜᵃᵃ(i, j, k, grid, u) - v̂ = ℑyᵃᶜᵃ(i, j, k, grid, v) - return sqrt(û^2 + v̂^2) -end - -""" - compute_report_fields(ocean; dataset = WOAAnnual()) - -Compute a `NamedTuple` of diagnostic fields from the current state of -`ocean`. Returns surface-level slices and zonal averages, plus -differences against the WOA climatology specified by `dataset`. - -Returned fields: -- `SST`, `SSS`: 2-D surface temperature and salinity -- `spd`: surface speed sqrt(u^2 + v^2) -- `ζ`: surface vertical vorticity -- `MLD`: mixed-layer depth -- `T̄`, `S̄`: zonally averaged temperature and salinity (latitude × depth) -- `δT`, `δS`: SST/SSS minus WOA climatology -- `φ`: latitude coordinates of the zonal averages -- `z`: depth coordinates of the zonal averages -""" -function compute_report_fields(ocean; dataset = WOAAnnual()) - grid = ocean.model.grid - arch = child_architecture(grid) - Nz = size(grid, 3) - - u, v, w = ocean.model.velocities - T = ocean.model.tracers.T - S = ocean.model.tracers.S - - SST = Array(interior(T, :, :, Nz)) - SSS = Array(interior(S, :, :, Nz)) - - spd_op = KernelFunctionOperation{Center, Center, Center}(speedᶜᶜᶜ, grid, u, v) - spd_field = Field(spd_op; indices = (:, :, Nz)) - compute!(spd_field) - spd = Array(interior(spd_field, :, :, 1)) - - ζ_op = KernelFunctionOperation{Face, Face, Center}(ζ₃ᶠᶠᶜ, grid, u, v) - ζ_field = Field(ζ_op; indices = (:, :, Nz)) - compute!(ζ_field) - ζ = Array(interior(ζ_field, :, :, 1)) - - h = MixedLayerDepthField(ocean.model.buoyancy, grid, ocean.model.tracers) - compute!(h) - MLD = Array(interior(h, :, :, 1)) - - δT, δS = compute_woa_bias(grid, arch, T, S, Nz, dataset) - - return (; SST, SSS, spd, ζ, MLD, δT, δS) -end - -""" - compute_woa_bias(grid, arch, T, S, Nz, dataset) - -Return `(δT, δS)`, the surface temperature and salinity differences -between the current state and the WOA climatology specified by -`dataset` (default `WOAAnnual()`). -""" -function compute_woa_bias(grid, arch, T, S, Nz, dataset) - Tʷ = Field(Metadatum(:temperature; dataset), arch) - Sʷ = Field(Metadatum(:salinity; dataset), arch) - - Tᵢ = CenterField(grid) - Sᵢ = CenterField(grid) - interpolate!(Tᵢ, Tʷ) - interpolate!(Sᵢ, Sʷ) - - δT = Array(interior(T, :, :, Nz)) .- Array(interior(Tᵢ, :, :, Nz)) - δS = Array(interior(S, :, :, Nz)) .- Array(interior(Sᵢ, :, :, Nz)) - - return δT, δS -end From 242ce1c447aad2108f8b817719d41c327f48e041 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 16 Apr 2026 10:31:50 +0200 Subject: [PATCH 19/54] just pass a default snow thermodynamics --- test/test_snow_model_integration.jl | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/test/test_snow_model_integration.jl b/test/test_snow_model_integration.jl index cfa33720e..e937fa40e 100644 --- a/test/test_snow_model_integration.jl +++ b/test/test_snow_model_integration.jl @@ -33,7 +33,7 @@ using Oceananigans.Units: hours, days end @testset "sea_ice_simulation with_snow=true [$A]" begin - sea_ice = sea_ice_simulation(grid; dynamics=nothing, with_snow=true) + sea_ice = sea_ice_simulation(grid; dynamics=nothing, snow_thermodynamics=nothing) @test sea_ice isa Simulation @test sea_ice.model.snow_thermodynamics !== nothing @test sea_ice.model.snow_thickness isa Field @@ -50,14 +50,14 @@ using Oceananigans.Units: hours, days using NumericalEarth.EarthSystemModels.InterfaceComputations: ComponentExchanger # Without snow: hs should be ZeroField - sea_ice = sea_ice_simulation(grid; dynamics=nothing) + sea_ice = sea_ice_simulation(grid; dynamics=nothing, snow_thermodynamics=nothing) exchanger = ComponentExchanger(sea_ice, grid) @test haskey(exchanger.state, :hs) @test haskey(exchanger.state, :hi) @test exchanger.state.hs isa ZeroField # With snow: hs should be a Field - sea_ice_snow = sea_ice_simulation(grid; dynamics=nothing, with_snow=true) + sea_ice_snow = sea_ice_simulation(grid; dynamics=nothing) exchanger_snow = ComponentExchanger(sea_ice_snow, grid) @test exchanger_snow.state.hs isa Field end @@ -102,9 +102,10 @@ using Oceananigans.Units: hours, days coriolis = nothing) set!(ocean.model, T = -1.8, S = 34) + snow_thermodynamics = with_snow ? default_snow_thermodynamics(grid) : nothing sea_ice = sea_ice_simulation(ocean_grid, ocean; dynamics = nothing, - with_snow) + snow_thermodynamics) set!(sea_ice.model, h = 1.0, ℵ = 1.0) atmosphere = PrescribedAtmosphere(ocean_grid, [0.0]) @@ -118,7 +119,7 @@ using Oceananigans.Units: hours, days # Give the snowy model some snow set!(snowy.sea_ice.model, hs = 0.2) - time_step!(bare, 1) + time_step!(bare, 1) time_step!(snowy, 1) Ts_bare = bare.interfaces.atmosphere_sea_ice_interface.temperature @@ -153,8 +154,7 @@ end coriolis = nothing) sea_ice = sea_ice_simulation(grid, ocean; - dynamics = nothing, - with_snow = true) + dynamics = nothing) atmosphere = PrescribedAtmosphere(grid, [0.0]) radiation = Radiation() @@ -175,7 +175,7 @@ end sea_ice = sea_ice_simulation(grid, ocean; dynamics = nothing, - with_snow = true) + snow_thermodynamics = nothing) atmosphere = PrescribedAtmosphere(grid, [0.0]) radiation = Radiation() @@ -201,8 +201,7 @@ end coriolis = nothing) sea_ice = sea_ice_simulation(grid, ocean; - dynamics = nothing, - with_snow = true) + dynamics = nothing) ai_temp = NumericalEarth.SeaIces.default_ai_temperature(sea_ice) @test ai_temp.internal_flux isa IceSnowConductiveFlux From 4654b33d14c513fcdd6638b031f27efd03c99792 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 16 Apr 2026 10:55:24 +0200 Subject: [PATCH 20/54] fix reference --- docs/src/NumericalEarth.bib | 6 +++--- docs/src/interface_fluxes.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/NumericalEarth.bib b/docs/src/NumericalEarth.bib index 84e4628c2..f97dcd243 100644 --- a/docs/src/NumericalEarth.bib +++ b/docs/src/NumericalEarth.bib @@ -84,9 +84,9 @@ @article{holland1999modeling doi={10.1175/1520-0485(1999)029<1787:MTIOIA>2.0.CO;2} } -@article{hieronymus2021comparison, - title={A comparison of ocean-ice flux parametrizations}, - author={Hieronymus, Magnus and Holtermann, Peter and Gr{\"a}we, Ulf and Burchard, Hans}, +@article{shi2021sensitivity, + title={Sensitivity of {Northern Hemisphere} climate to ice--ocean interface heat flux parameterizations}, + author={Shi, Xiaoxu and Notz, Dirk and Liu, Jiping and Yang, Hu and Lohmann, Gerrit}, journal={Geoscientific Model Development}, volume={14}, number={8}, diff --git a/docs/src/interface_fluxes.md b/docs/src/interface_fluxes.md index b776bc475..2d0714510 100644 --- a/docs/src/interface_fluxes.md +++ b/docs/src/interface_fluxes.md @@ -817,7 +817,7 @@ where: - ``\lambda_1, \lambda_2`` are liquidus coefficients The ratio ``R = \alpha_h / \alpha_s`` (typically around 35) reflects the different molecular diffusivities of heat and -salt, with heat diffusing faster than salt [hieronymus2021comparison](@citep). +salt, with heat diffusing faster than salt [shi2021sensitivity](@citep). ```@example interface_fluxes using NumericalEarth.EarthSystemModels: ThreeEquationHeatFlux @@ -975,4 +975,4 @@ Note: The `ComponentInterfaces` call above is illustrative; it requires fully co The implementations follow: - [holland1999modeling](@citet): foundational three-equation model for ice shelf-ocean interaction -- [hieronymus2021comparison](@citet): comparison of different ocean-ice flux parameterizations +- [shi2021sensitivity](@citet): sensitivity of Northern Hemisphere climate to ice-ocean interface heat flux parameterizations From 5921ed19dd8eac713a4273e833631a4a08a5ad3c Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 16 Apr 2026 11:46:35 +0200 Subject: [PATCH 21/54] remove with_snow from the tests --- test/test_snow_model_integration.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_snow_model_integration.jl b/test/test_snow_model_integration.jl index e937fa40e..512083997 100644 --- a/test/test_snow_model_integration.jl +++ b/test/test_snow_model_integration.jl @@ -70,7 +70,7 @@ using Oceananigans.Units: hours, days @test st isa SkinTemperature @test st.internal_flux isa ConductiveFlux - sea_ice_snow = sea_ice_simulation(grid; dynamics=nothing, with_snow=true) + sea_ice_snow = sea_ice_simulation(grid; dynamics=nothing) st_snow = default_ai_temperature(sea_ice_snow) @test st_snow isa SkinTemperature @test st_snow.internal_flux isa IceSnowConductiveFlux @@ -79,7 +79,7 @@ using Oceananigans.Units: hours, days @testset "net_fluxes includes snowfall [$A]" begin using NumericalEarth.SeaIces: net_fluxes - sea_ice = sea_ice_simulation(grid; dynamics=nothing, with_snow=true, snowfall=0) + sea_ice = sea_ice_simulation(grid; dynamics=nothing, snowfall=0) fluxes = net_fluxes(sea_ice) @test haskey(fluxes.top, :snowfall) end From 19fb9fabb9ddf512f53664b455b2917900f67668 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 16 Apr 2026 14:35:31 +0200 Subject: [PATCH 22/54] fix the tests --- test/test_snow_model_integration.jl | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/test_snow_model_integration.jl b/test/test_snow_model_integration.jl index 512083997..d8445ff34 100644 --- a/test/test_snow_model_integration.jl +++ b/test/test_snow_model_integration.jl @@ -25,15 +25,15 @@ using Oceananigans.Units: hours, days topology = (Periodic, Periodic, Bounded)) @testset "sea_ice_simulation with_snow=false [$A]" begin - sea_ice = sea_ice_simulation(grid; dynamics=nothing) + sea_ice = sea_ice_simulation(grid; dynamics=nothing, snow_thermodynamics=nothing) @test sea_ice isa Simulation @test sea_ice.model isa SeaIceModel @test sea_ice.model.snow_thermodynamics === nothing - @test sea_ice.model.ice_thermodynamics.internal_heat_flux isa ConductiveFlux + @test sea_ice.model.ice_thermodynamics.internal_heat_flux.parameters.flux isa ConductiveFlux end @testset "sea_ice_simulation with_snow=true [$A]" begin - sea_ice = sea_ice_simulation(grid; dynamics=nothing, snow_thermodynamics=nothing) + sea_ice = sea_ice_simulation(grid; dynamics=nothing) @test sea_ice isa Simulation @test sea_ice.model.snow_thermodynamics !== nothing @test sea_ice.model.snow_thickness isa Field @@ -65,7 +65,7 @@ using Oceananigans.Units: hours, days @testset "default_ai_temperature dispatches on snow [$A]" begin using NumericalEarth.SeaIces: default_ai_temperature - sea_ice = sea_ice_simulation(grid; dynamics=nothing) + sea_ice = sea_ice_simulation(grid; dynamics=nothing, snow_thermodynamics=nothing) st = default_ai_temperature(sea_ice) @test st isa SkinTemperature @test st.internal_flux isa ConductiveFlux @@ -79,7 +79,7 @@ using Oceananigans.Units: hours, days @testset "net_fluxes includes snowfall [$A]" begin using NumericalEarth.SeaIces: net_fluxes - sea_ice = sea_ice_simulation(grid; dynamics=nothing, snowfall=0) + sea_ice = sea_ice_simulation(grid; dynamics=nothing) fluxes = net_fluxes(sea_ice) @test haskey(fluxes.top, :snowfall) end @@ -90,8 +90,8 @@ using Oceananigans.Units: hours, days # one coupled time step. Snow adds thermal resistance, so the # surface should be warmer (closer to atmosphere) with snow. ocean_grid = RectilinearGrid(arch; - size = (1, 1, 2), - extent = (1, 1, 1), + size = 2, + extent = 1, topology = (Flat, Flat, Bounded)) function build_coupled(; with_snow) @@ -102,7 +102,7 @@ using Oceananigans.Units: hours, days coriolis = nothing) set!(ocean.model, T = -1.8, S = 34) - snow_thermodynamics = with_snow ? default_snow_thermodynamics(grid) : nothing + snow_thermodynamics = with_snow ? default_snow_thermodynamics(ocean_grid) : nothing sea_ice = sea_ice_simulation(ocean_grid, ocean; dynamics = nothing, snow_thermodynamics) From ae3b138e6ce0f233c411ade43eb3b9a29bfd30e8 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 16 Apr 2026 14:37:16 +0200 Subject: [PATCH 23/54] update breeze --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 0f01f8982..2e1ef8fd6 100644 --- a/Project.toml +++ b/Project.toml @@ -52,7 +52,7 @@ NumericalEarthWOAExt = "WorldOceanAtlasTools" [compat] Adapt = "4" -Breeze = "0.4.4" +Breeze = "0.4" CDSAPI = "2.2.1" CFTime = "0.1, 0.2" ClimaSeaIce = "0.4.4, 0.5" From 2ef90e40fdc156f4b2b245e9e138c9319d7b223c Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 16 Apr 2026 17:21:29 +0200 Subject: [PATCH 24/54] remove fragile computation of sea ice top temperature --- .../InterfaceComputations/interface_states.jl | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/EarthSystemModels/InterfaceComputations/interface_states.jl b/src/EarthSystemModels/InterfaceComputations/interface_states.jl index 8b7b50ae3..568626069 100644 --- a/src/EarthSystemModels/InterfaceComputations/interface_states.jl +++ b/src/EarthSystemModels/InterfaceComputations/interface_states.jl @@ -255,13 +255,16 @@ end Jᵀ = Qa * λ # Calculating the atmospheric temperature - # We use to compute the sensible heat flux Tᵃᵗ = surface_atmosphere_temperature(Ψₐ, ℙₐ) ΔT = Tᵃᵗ - Ψₛ.T - Ωc = ifelse(ΔT == 0, zero(ΔT), 𝒬ᵀ / ΔT * λ) # Sensible heat transfer coefficient (W/m²K) - # Computing the flux balance temperature - return (Ψᵢ.T * F.κ - (Jᵀ + Ωc * Tᵃᵗ) * F.δ) / (F.κ - Ωc * F.δ) + # Flux balance: T★ = (Tᵢ κ - (Jᵀ + Ωc Tᵃᵗ) δ) / (κ - Ωc δ) + # where Ωc = 𝒬ᵀ λ / ΔT. Multiply through by ΔT to avoid Inf when ΔT → 0. + Ωᵀ = 𝒬ᵀ * λ # unnormalized sensible heat coefficient (= Ωc * ΔT) + D = F.κ * ΔT - Ωᵀ * F.δ + T★ = (Ψᵢ.T * F.κ * ΔT - (Jᵀ * ΔT + Ωᵀ * Tᵃᵗ) * F.δ) / D + + return ifelse(D == 0, Ψₛ.T, T★) end # Solve the surface flux balance equation: @@ -278,14 +281,18 @@ end Tₛ⁻ = Ψₛ.T # Linearized sensible heat transfer coefficient: Ωc = 𝒬ᵀ / (Tᵃᵗ - Tₛ) + # Rewrite to avoid Inf when ΔT → 0: + # T★ = (Tᵦ - (Qa + Ωc Tᵃᵗ) R) / (1 - Ωc R) + # Multiply numerator and denominator by ΔT: + # T★ = (Tᵦ ΔT - (Qa ΔT + 𝒬ᵀ Tᵃᵗ) R) / (ΔT - 𝒬ᵀ R) Tᵃᵗ = surface_atmosphere_temperature(Ψₐ, ℙₐ) ΔT = Tᵃᵗ - Tₛ⁻ - Ωc = ifelse(ΔT == 0, zero(R), 𝒬ᵀ / ΔT) Qa = 𝒬ᵛ + ℐꜛˡʷ + Qd - # Flux balance solution - T★ = (Tᵦ - (Qa + Ωc * Tᵃᵗ) * R) / (1 - Ωc * R) - T★ = ifelse(isnan(T★), Tₛ⁻, T★) + # Flux balance solution (multiplied through by ΔT to avoid Inf) + D = ΔT - 𝒬ᵀ * R + T★ = (Tᵦ * ΔT - (Qa * ΔT + 𝒬ᵀ * Tᵃᵗ) * R) / D + T★ = ifelse(D == 0, Tₛ⁻, T★) # Cap the temperature step for iteration stability ΔT★ = T★ - Tₛ⁻ From 88e7051c9b95b098fe20d0087764c31d77e0ae8d Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 20 Apr 2026 10:27:13 +0200 Subject: [PATCH 25/54] use the correct source --- Project.toml | 5 ++++- test/Project.toml | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index 2e1ef8fd6..f8c9853e2 100644 --- a/Project.toml +++ b/Project.toml @@ -50,6 +50,9 @@ NumericalEarthSpeedyWeatherExt = ["SpeedyWeather", "XESMF"] NumericalEarthVerosExt = ["PythonCall", "CondaPkg"] NumericalEarthWOAExt = "WorldOceanAtlasTools" +[sources] +ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} + [compat] Adapt = "4" Breeze = "0.4" @@ -69,7 +72,7 @@ JLD2 = "0.4, 0.5, 0.6" KernelAbstractions = "0.9" MeshArrays = "0.3, 0.4, 0.5" NCDatasets = "0.12, 0.13, 0.14" -Oceananigans = "0.106.3" +Oceananigans = "0.107, 0.108" OffsetArrays = "1.14" PrecompileTools = "1" PythonCall = "0.9.28" diff --git a/test/Project.toml b/test/Project.toml index 8b9e4d34e..ef6e67c22 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -29,6 +29,7 @@ XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" [sources] NumericalEarth = {path = ".."} +ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} [compat] Breeze = "0.4" @@ -45,7 +46,7 @@ JLD2 = "0.4, 0.5, 0.6" KernelAbstractions = "0.9" MPI = "0.20" NCDatasets = "0.12, 0.13, 0.14" -Oceananigans = "0.106" +Oceananigans = "0.106, 0.107, 0.108" PythonCall = "0.9.28" Reactant = "0.2.235" Scratch = "1" From fe6484bff43f7f6193628373ce88a50a8a2117ed Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 20 Apr 2026 19:04:38 +0200 Subject: [PATCH 26/54] correct source for Breeze --- Project.toml | 1 + docs/Project.toml | 1 + test/Project.toml | 1 + 3 files changed, 3 insertions(+) diff --git a/Project.toml b/Project.toml index f8c9853e2..26f592f10 100644 --- a/Project.toml +++ b/Project.toml @@ -52,6 +52,7 @@ NumericalEarthWOAExt = "WorldOceanAtlasTools" [sources] ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} +Breeze = {url = "https://github.com/NumericalEarth/Breeze.jl", rev = "main"} [compat] Adapt = "4" diff --git a/docs/Project.toml b/docs/Project.toml index c10ac23e9..208a10b7c 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -21,6 +21,7 @@ XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" [sources] NumericalEarth = {path = ".."} +Breeze = {url = "https://github.com/NumericalEarth/Breeze.jl", rev = "main"} [compat] Documenter = "1" diff --git a/test/Project.toml b/test/Project.toml index ef6e67c22..7248b3e94 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -30,6 +30,7 @@ XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" [sources] NumericalEarth = {path = ".."} ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} +Breeze = {url = "https://github.com/NumericalEarth/Breeze.jl", rev = "main"} [compat] Breeze = "0.4" From 691e7c3220d35a7d8e2cbb8a47fe85f3deed5114 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 20 Apr 2026 19:10:25 +0200 Subject: [PATCH 27/54] remove source from weakdep --- Project.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Project.toml b/Project.toml index 26f592f10..f8c9853e2 100644 --- a/Project.toml +++ b/Project.toml @@ -52,7 +52,6 @@ NumericalEarthWOAExt = "WorldOceanAtlasTools" [sources] ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} -Breeze = {url = "https://github.com/NumericalEarth/Breeze.jl", rev = "main"} [compat] Adapt = "4" From b0d47b24a19be51217ccef336d95f52518639bc8 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 20 Apr 2026 21:46:47 +0200 Subject: [PATCH 28/54] some fixes --- Project.toml | 6 +-- .../component_interfaces.jl | 2 +- .../sea_ice_ocean_fluxes.jl | 2 +- src/EarthSystemModels/earth_system_model.jl | 2 +- src/SeaIces/sea_ice_simulation.jl | 37 ++++++++++--------- test/test_snow_model_integration.jl | 4 +- 6 files changed, 27 insertions(+), 26 deletions(-) diff --git a/Project.toml b/Project.toml index f8c9853e2..170956d5f 100644 --- a/Project.toml +++ b/Project.toml @@ -41,6 +41,9 @@ SpeedyWeather = "9e226e20-d153-4fed-8a5b-493def4f21a9" WorldOceanAtlasTools = "04f20302-f1b9-11e8-29d9-7d841cb0a64a" XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" +[sources] +ClimaSeaIce = {rev = "ss/refactor-thermodynamics", url = "https://github.com/CliMA/ClimaSeaIce.jl.git"} + [extensions] NumericalEarthBreezeExt = "Breeze" NumericalEarthCDSAPIExt = "CDSAPI" @@ -50,9 +53,6 @@ NumericalEarthSpeedyWeatherExt = ["SpeedyWeather", "XESMF"] NumericalEarthVerosExt = ["PythonCall", "CondaPkg"] NumericalEarthWOAExt = "WorldOceanAtlasTools" -[sources] -ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} - [compat] Adapt = "4" Breeze = "0.4" diff --git a/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl b/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl index b0ffdf698..20869e7aa 100644 --- a/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl +++ b/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl @@ -342,7 +342,7 @@ function ComponentInterfaces(atmosphere, ocean, sea_ice=nothing; sea_ice_properties = (reference_density = sea_ice_reference_density, heat_capacity = sea_ice_heat_capacity, freshwater_density = freshwater_density, - liquidus = sea_ice.model.ice_thermodynamics.phase_transitions.liquidus, + liquidus = sea_ice.model.phase_transitions.liquidus, temperature_units = sea_ice_temperature_units) else sea_ice_properties = nothing diff --git a/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_fluxes.jl b/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_fluxes.jl index 5cbffb333..f8d5e30a4 100644 --- a/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_fluxes.jl +++ b/src/EarthSystemModels/InterfaceComputations/sea_ice_ocean_fluxes.jl @@ -36,7 +36,7 @@ function compute_sea_ice_ocean_fluxes!(interface, ocean, sea_ice, ocean_properti hˢⁱ = sea_ice.model.ice_thickness hc = sea_ice.model.ice_consolidation_thickness - phase_transitions = sea_ice.model.ice_thermodynamics.phase_transitions + phase_transitions = sea_ice.model.phase_transitions liquidus = phase_transitions.liquidus L = phase_transitions.reference_latent_heat diff --git a/src/EarthSystemModels/earth_system_model.jl b/src/EarthSystemModels/earth_system_model.jl index 90469dc04..80d1f57c9 100644 --- a/src/EarthSystemModels/earth_system_model.jl +++ b/src/EarthSystemModels/earth_system_model.jl @@ -306,7 +306,7 @@ function above_freezing_ocean_temperature!(ocean, grid, sea_ice) T = ocean_temperature(ocean) S = ocean_salinity(ocean) ℵ = sea_ice_concentration(sea_ice) - liquidus = sea_ice.model.ice_thermodynamics.phase_transitions.liquidus + liquidus = sea_ice.model.phase_transitions.liquidus arch = architecture(grid) launch!(arch, grid, :xy, _above_freezing_ocean_temperature!, T, grid, S, ℵ, liquidus) diff --git a/src/SeaIces/sea_ice_simulation.jl b/src/SeaIces/sea_ice_simulation.jl index 009ac1617..c718ab665 100644 --- a/src/SeaIces/sea_ice_simulation.jl +++ b/src/SeaIces/sea_ice_simulation.jl @@ -1,7 +1,7 @@ using ClimaSeaIce using ClimaSeaIce: SeaIceModel, PhaseTransitions, ConductiveFlux, sea_ice_slab_thermodynamics, snow_slab_thermodynamics -using ClimaSeaIce.SeaIceThermodynamics: IceWaterThermalEquilibrium +using ClimaSeaIce.SeaIceThermodynamics: IceWaterThermalEquilibrium, IceSnowConductiveFlux using ClimaSeaIce.SeaIceDynamics: SplitExplicitSolver, SemiImplicitStress, SeaIceMomentumEquation, StressBalanceFreeDrift using ClimaSeaIce.Rheologies: IceStrength, ElastoViscoPlasticRheology @@ -18,14 +18,11 @@ ocean_reference_density(::Nothing, FT) = convert(FT, 1026.0) function default_snow_thermodynamics(grid) FT = eltype(grid) snow_conductivity = FT(0.31) - snow_density = FT(330) # Use PrescribedTemperature so ClimaSeaIce does NOT run its own surface solve; # the coupled flux solver in NumericalEarth handles the snow surface temperature. snow_surface_temperature = Field{Center, Center, Nothing}(grid) top_heat_boundary_condition = PrescribedTemperature(snow_surface_temperature.data) - return snow_slab_thermodynamics(grid; conductivity = snow_conductivity, - density = snow_density, - top_heat_boundary_condition) + return snow_slab_thermodynamics(grid; conductivity = snow_conductivity, top_heat_boundary_condition) end function sea_ice_simulation(grid, ocean=nothing; @@ -35,15 +32,16 @@ function sea_ice_simulation(grid, ocean=nothing; tracers = (), ice_heat_capacity = 2100, # J kg⁻¹ K⁻¹ ice_consolidation_thickness = 0.05, # m - ice_density = 900, # kg m⁻³ + sea_ice_density = 900, # kg m⁻³ + snow_density = 330, # kg m⁻³ dynamics = sea_ice_dynamics(grid, ocean), bottom_heat_boundary_condition = nothing, top_heat_boundary_condition = nothing, timestepper = :SplitRungeKutta3, - phase_transitions = PhaseTransitions(; heat_capacity=ice_heat_capacity, density=ice_density), + phase_transitions = PhaseTransitions(eltype(grid); heat_capacity=ice_heat_capacity, density=sea_ice_density), conductivity = 2, # W m⁻¹ K⁻¹ internal_heat_flux = ConductiveFlux(; conductivity), - snow_thermodynamics = default_snow_thermodynamics(grid)) + snow_thermodynamics = default_snow_thermodynamics(grid)) # Build consistent boundary conditions for the ice model: # - bottom -> flux boundary condition @@ -65,7 +63,6 @@ function sea_ice_simulation(grid, ocean=nothing; ice_thermodynamics = sea_ice_slab_thermodynamics(grid; internal_heat_flux, - phase_transitions, top_heat_boundary_condition, bottom_heat_boundary_condition) @@ -79,6 +76,9 @@ function sea_ice_simulation(grid, ocean=nothing; advection, tracers, ice_consolidation_thickness, + sea_ice_density, + snow_density, + phase_transitions, ice_thermodynamics, snow_thermodynamics, snowfall, @@ -144,8 +144,10 @@ end sea_ice_thickness(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_thickness sea_ice_concentration(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_concentration -heat_capacity(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_thermodynamics.phase_transitions.heat_capacity -reference_density(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_thermodynamics.phase_transitions.density +heat_capacity(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.phase_transitions.heat_capacity +# `sea_ice.model.sea_ice_density` is wrapped as a `ConstantField` by `SeaIceModel`; +# the scalar value lives on `phase_transitions.density`. +reference_density(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.phase_transitions.density function net_fluxes(sea_ice::Simulation{<:SeaIceModel}) net_momentum_fluxes = if isnothing(sea_ice.model.dynamics) @@ -165,15 +167,14 @@ function net_fluxes(sea_ice::Simulation{<:SeaIceModel}) end function default_ai_temperature(sea_ice::Simulation{<:SeaIceModel}) + ice_flux = sea_ice.model.ice_thermodynamics.internal_heat_flux snow_thermo = sea_ice.model.snow_thermodynamics - if isnothing(snow_thermo) - # No snow: use ice-only conductive flux - conductive_flux = sea_ice.model.ice_thermodynamics.internal_heat_flux.parameters.flux + internal_flux = if isnothing(snow_thermo) + ice_flux else - # With snow: use combined ice+snow conductive flux from the snow layer - conductive_flux = snow_thermo.internal_heat_flux.parameters.flux + IceSnowConductiveFlux(snow_thermo.internal_heat_flux.conductivity, ice_flux.conductivity) end - return SkinTemperature(conductive_flux) + return SkinTemperature(internal_flux) end # Constructor that accepts the sea-ice model @@ -182,7 +183,7 @@ function ThreeEquationHeatFlux(sea_ice::Simulation{<:SeaIceModel}, FT::DataType salt_transfer_coefficient = heat_transfer_coefficient / 35, friction_velocity = convert(FT, 0.002)) - conductive_flux = sea_ice.model.ice_thermodynamics.internal_heat_flux.parameters.flux + conductive_flux = sea_ice.model.ice_thermodynamics.internal_heat_flux ice_temperature = sea_ice.model.ice_thermodynamics.top_surface_temperature return ThreeEquationHeatFlux(conductive_flux, diff --git a/test/test_snow_model_integration.jl b/test/test_snow_model_integration.jl index d8445ff34..86b3a18c8 100644 --- a/test/test_snow_model_integration.jl +++ b/test/test_snow_model_integration.jl @@ -29,7 +29,7 @@ using Oceananigans.Units: hours, days @test sea_ice isa Simulation @test sea_ice.model isa SeaIceModel @test sea_ice.model.snow_thermodynamics === nothing - @test sea_ice.model.ice_thermodynamics.internal_heat_flux.parameters.flux isa ConductiveFlux + @test sea_ice.model.ice_thermodynamics.internal_heat_flux isa ConductiveFlux end @testset "sea_ice_simulation with_snow=true [$A]" begin @@ -41,7 +41,7 @@ using Oceananigans.Units: hours, days @testset "PhaseTransitions API [$A]" begin sea_ice = sea_ice_simulation(grid; dynamics=nothing) - pt = sea_ice.model.ice_thermodynamics.phase_transitions + pt = sea_ice.model.phase_transitions @test pt.heat_capacity == 2100 @test pt.density == 900 end From c79ea8b76e4a403df49c40d6b17575b153125729 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 20 Apr 2026 22:02:27 +0200 Subject: [PATCH 29/54] fix all the tests --- test/test_snow_model_integration.jl | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/test/test_snow_model_integration.jl b/test/test_snow_model_integration.jl index 86b3a18c8..7dd451db2 100644 --- a/test/test_snow_model_integration.jl +++ b/test/test_snow_model_integration.jl @@ -88,11 +88,15 @@ using Oceananigans.Units: hours, days # Build two coupled models — one without snow, one with snow and # nonzero snow thickness — then compare surface temperatures after # one coupled time step. Snow adds thermal resistance, so the - # surface should be warmer (closer to atmosphere) with snow. + # surface should be warmer (closer to the warmer atmosphere). + # + # Radiation is disabled (ε=0) so the surface energy balance + # reduces to conductive + turbulent fluxes; otherwise the + # Stefan–Boltzmann loss swamps the small snow-insulation signal. ocean_grid = RectilinearGrid(arch; - size = 2, - extent = 1, - topology = (Flat, Flat, Bounded)) + size = (1, 1, 2), + extent = (1, 1, 1), + topology = (Periodic, Periodic, Bounded)) function build_coupled(; with_snow) ocean = ocean_simulation(ocean_grid; @@ -107,18 +111,19 @@ using Oceananigans.Units: hours, days dynamics = nothing, snow_thermodynamics) set!(sea_ice.model, h = 1.0, ℵ = 1.0) + if with_snow + set!(sea_ice.model, hs = 0.2) + end atmosphere = PrescribedAtmosphere(ocean_grid, [0.0]) - radiation = Radiation() + parent(atmosphere.velocities.u) .= 2.0 + radiation = Radiation(ocean_emissivity = 0, sea_ice_emissivity = 0) return OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) end bare = build_coupled(with_snow = false) snowy = build_coupled(with_snow = true) - # Give the snowy model some snow - set!(snowy.sea_ice.model, hs = 0.2) - time_step!(bare, 1) time_step!(snowy, 1) From e2aa0e47d08020417fd71950dc21f3a4da24249d Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 20 Apr 2026 23:03:49 +0200 Subject: [PATCH 30/54] import correct function --- test/test_snow_model_integration.jl | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_snow_model_integration.jl b/test/test_snow_model_integration.jl index 7dd451db2..a5860e6d9 100644 --- a/test/test_snow_model_integration.jl +++ b/test/test_snow_model_integration.jl @@ -2,6 +2,7 @@ include("runtests_setup.jl") using ClimaSeaIce: SeaIceModel, ConductiveFlux using ClimaSeaIce.SeaIceThermodynamics: IceSnowConductiveFlux +using NumericalEarth.SeaIces: default_snow_thermodynamics using NumericalEarth.EarthSystemModels.InterfaceComputations: ComponentInterfaces, SkinTemperature, From edf43d0ff6e0add433a56219b52e2fc00e8292a4 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 20 Apr 2026 23:34:44 +0200 Subject: [PATCH 31/54] fix more tests --- test/test_ocean_sea_ice_model.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_ocean_sea_ice_model.jl b/test/test_ocean_sea_ice_model.jl index ad7a5112b..95c5f1bc1 100644 --- a/test/test_ocean_sea_ice_model.jl +++ b/test/test_ocean_sea_ice_model.jl @@ -44,7 +44,7 @@ using ClimaSeaIce.Rheologies ocean = ocean_simulation(grid; free_surface) sea_ice = sea_ice_simulation(grid, ocean; advection=WENO(order=7)) - liquidus = sea_ice.model.ice_thermodynamics.phase_transitions.liquidus + liquidus = sea_ice.model.phase_transitions.liquidus # Set the ocean temperature and salinity set!(ocean.model, T=temperature_metadata[1], S=salinity_metadata[1]) From 05defb43b218939ea122e02327e438d35157c71e Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 21 Apr 2026 09:12:04 +0200 Subject: [PATCH 32/54] fix projects to point to correct version of ClimaSeaIce --- docs/Project.toml | 3 ++- test/Project.toml | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index 208a10b7c..2d4b0b04a 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -20,8 +20,9 @@ SpeedyWeather = "9e226e20-d153-4fed-8a5b-493def4f21a9" XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" [sources] -NumericalEarth = {path = ".."} Breeze = {url = "https://github.com/NumericalEarth/Breeze.jl", rev = "main"} +ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} +NumericalEarth = {path = ".."} [compat] Documenter = "1" diff --git a/test/Project.toml b/test/Project.toml index 7248b3e94..73df7d045 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -28,9 +28,9 @@ WorldOceanAtlasTools = "04f20302-f1b9-11e8-29d9-7d841cb0a64a" XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" [sources] -NumericalEarth = {path = ".."} -ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} Breeze = {url = "https://github.com/NumericalEarth/Breeze.jl", rev = "main"} +ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} +NumericalEarth = {path = ".."} [compat] Breeze = "0.4" From caa630c70da4c8b51f58841378161a3cb4d81608 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 21 Apr 2026 12:10:49 +0200 Subject: [PATCH 33/54] fix the snow thingy --- src/Atmospheres/interpolate_atmospheric_state.jl | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Atmospheres/interpolate_atmospheric_state.jl b/src/Atmospheres/interpolate_atmospheric_state.jl index 34cf7b640..82941db4f 100644 --- a/src/Atmospheres/interpolate_atmospheric_state.jl +++ b/src/Atmospheres/interpolate_atmospheric_state.jl @@ -31,7 +31,7 @@ function interpolate_state!(exchanger, grid, atmosphere::PrescribedAtmosphere, c ℐꜜˡʷ = atmosphere.downwelling_radiation.longwave downwelling_radiation = (shortwave=ℐꜜˢʷ.data, longwave=ℐꜜˡʷ.data) freshwater_flux = map(ϕ -> ϕ.data, atmosphere.freshwater_flux) - snowfall_flux = atmosphere.freshwater_flux.snow.data + snowfall_flux = haskey(atmosphere.freshwater_flux, :snow) ? atmosphere.freshwater_flux.snow.data : nothing atmosphere_pressure = atmosphere.pressure.data # Extract info for time-interpolation @@ -208,9 +208,11 @@ end ##### Utility for interpolating tuples of fields ##### +@inline interp_atmos_time_series(::Nothing, X, time, grid, args...) = zero(grid) + # Note: assumes loc = (c, c, nothing) (and the third location should not matter.) -@inline interp_atmos_time_series(J::AbstractArray, x_itp::FractionalIndices, t_itp, args...) = - interpolate(x_itp, t_itp, J, args...) +@inline interp_atmos_time_series(J::AbstractArray, X::FractionalIndices, time, args...) = + interpolate(X, time, J, args...) @inline interp_atmos_time_series(J::AbstractArray, X, time, grid, args...) = interpolate(X, time, J, (Center(), Center(), nothing), grid, args...) From ee1419a7ab6b8b20e7c237c8f4348142b3035eb6 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 21 Apr 2026 15:33:13 +0200 Subject: [PATCH 34/54] just pass 0 --- src/Atmospheres/interpolate_atmospheric_state.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Atmospheres/interpolate_atmospheric_state.jl b/src/Atmospheres/interpolate_atmospheric_state.jl index 82941db4f..9fdd71a94 100644 --- a/src/Atmospheres/interpolate_atmospheric_state.jl +++ b/src/Atmospheres/interpolate_atmospheric_state.jl @@ -208,7 +208,7 @@ end ##### Utility for interpolating tuples of fields ##### -@inline interp_atmos_time_series(::Nothing, X, time, grid, args...) = zero(grid) +@inline interp_atmos_time_series(::Nothing, X, time, grid, args...) = 0 # Note: assumes loc = (c, c, nothing) (and the third location should not matter.) @inline interp_atmos_time_series(J::AbstractArray, X::FractionalIndices, time, args...) = From 6c963339d920182b857c6a87f88b656482189175 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 21 Apr 2026 17:32:45 +0200 Subject: [PATCH 35/54] fix radiation iteration --- .../InterfaceComputations/interface_states.jl | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/EarthSystemModels/InterfaceComputations/interface_states.jl b/src/EarthSystemModels/InterfaceComputations/interface_states.jl index 568626069..55c76245b 100644 --- a/src/EarthSystemModels/InterfaceComputations/interface_states.jl +++ b/src/EarthSystemModels/InterfaceComputations/interface_states.jl @@ -268,11 +268,15 @@ end end # Solve the surface flux balance equation: -# Qa + Ωc (Tᵃᵗ - Tₛ) + (Tₛ - Tᵦ) / R = 0 +# Qa(Tₛ) + Ωc (Tᵃᵗ - Tₛ) + (Tₛ - Tᵦ) / R = 0 # where R is the total thermal resistance (h/k for bare ice, hₛ/kₛ + hᵢ/kᵢ with snow), -# Ωc is the linearized sensible heat coefficient, and Qa is the non-sensible atmospheric flux. -# Solution: Tₛ = (Tᵦ - (Qa + Ωc Tᵃᵗ) R) / (1 - Ωc R) -@inline function conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) +# Ωc = 𝒬ᵀ/(Tᵃᵗ-Tₛ) is the linearized sensible heat coefficient, and Qa = 𝒬ᵛ + ℐꜛˡʷ + Qd. +# The upward longwave ℐꜛˡʷ = σ ε Tₛ⁴ is strongly nonlinear in Tₛ; a pure Picard +# iteration (treating Qa constant) is unstable when 4σεTₛ³ ≳ 1/R (radiation +# dominated). We linearize: Qa(Tₛ) ≈ Qa(Tₛ⁻) + β (Tₛ − Tₛ⁻) with β = 4σεTₛ⁻³, +# yielding the Newton-like semi-implicit update: +# Tₛ = [Tᵦ + β R Tₛ⁻ - Ωc R Tᵃᵗ - Qa R] / [1 + β R - Ωc R] +@inline function conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) hc = Ψᵢ.hc # Bottom temperature at the melting point @@ -280,18 +284,21 @@ end Tᵦ = convert_to_kelvin(ℙᵢ.temperature_units, Tᵦ) Tₛ⁻ = Ψₛ.T - # Linearized sensible heat transfer coefficient: Ωc = 𝒬ᵀ / (Tᵃᵗ - Tₛ) - # Rewrite to avoid Inf when ΔT → 0: - # T★ = (Tᵦ - (Qa + Ωc Tᵃᵗ) R) / (1 - Ωc R) - # Multiply numerator and denominator by ΔT: - # T★ = (Tᵦ ΔT - (Qa ΔT + 𝒬ᵀ Tᵃᵗ) R) / (ΔT - 𝒬ᵀ R) Tᵃᵗ = surface_atmosphere_temperature(Ψₐ, ℙₐ) ΔT = Tᵃᵗ - Tₛ⁻ Qa = 𝒬ᵛ + ℐꜛˡʷ + Qd - # Flux balance solution (multiplied through by ΔT to avoid Inf) - D = ΔT - 𝒬ᵀ * R - T★ = (Tᵦ * ΔT - (Qa * ΔT + 𝒬ᵀ * Tᵃᵗ) * R) / D + # Sensible transfer coefficient Ωc = 𝒬ᵀ/ΔT, safely handling ΔT → 0. + Ωc = ifelse(ΔT == zero(ΔT), zero(Tₛ⁻), 𝒬ᵀ / ΔT) + + # Newton linearization of upwelling longwave: ℐꜛˡʷ(Tₛ) ≈ ℐꜛˡʷ(Tₛ⁻) + β (Tₛ − Tₛ⁻). + σ = ℙₛ.radiation.σ + ϵ = ℙₛ.radiation.ϵ + β = 4 * σ * ϵ * Tₛ⁻^3 + + # Flux balance solution with T⁴ linearization (stable even at ΔT = 0): + D = 1 + β * R - Ωc * R + T★ = (Tᵦ + β * R * Tₛ⁻ - Ωc * R * Tᵃᵗ - Qa * R) / D T★ = ifelse(D == 0, Tₛ⁻, T★) # Cap the temperature step for iteration stability @@ -316,7 +323,7 @@ end k = st.internal_flux.conductivity hᵢ = Ψᵢ.hi R = hᵢ / k - return conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) + return conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) end # Snow + ice: R = hₛ / kₛ + hᵢ / kᵢ @@ -326,7 +333,7 @@ end hᵢ = Ψᵢ.hi hₛ = Ψᵢ.hs R = hₛ / F.snow_conductivity + hᵢ / F.ice_conductivity - return conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) + return conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) end @inline function compute_interface_temperature(st::SkinTemperature, From 508782e1791ccb05efce90b61bac133e6933033a Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 27 Apr 2026 15:07:23 +0200 Subject: [PATCH 36/54] remove breeze custom path --- docs/Project.toml | 2 -- test/Project.toml | 2 -- 2 files changed, 4 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index 32e37aac1..6ba01cd44 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -21,10 +21,8 @@ SpeedyWeather = "9e226e20-d153-4fed-8a5b-493def4f21a9" XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" [sources] -Breeze = {url = "https://github.com/NumericalEarth/Breeze.jl", rev = "main"} ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} NumericalEarth = {path = ".."} -Breeze = {url = "https://github.com/NumericalEarth/Breeze.jl/", rev = "main"} [compat] Documenter = "1" diff --git a/test/Project.toml b/test/Project.toml index 8197431ff..caa8d9364 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -28,10 +28,8 @@ WorldOceanAtlasTools = "04f20302-f1b9-11e8-29d9-7d841cb0a64a" XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" [sources] -Breeze = {url = "https://github.com/NumericalEarth/Breeze.jl", rev = "main"} ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} NumericalEarth = {path = ".."} -Breeze = {url = "https://github.com/NumericalEarth/Breeze.jl/", rev = "main"} [compat] Breeze = "0.4" From 36800b648133b336e4567eb44961487b51bf7779 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 27 Apr 2026 15:24:40 +0200 Subject: [PATCH 37/54] complete merge --- src/SeaIces/assemble_net_sea_ice_fluxes.jl | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/SeaIces/assemble_net_sea_ice_fluxes.jl b/src/SeaIces/assemble_net_sea_ice_fluxes.jl index 205688502..e7141bd41 100644 --- a/src/SeaIces/assemble_net_sea_ice_fluxes.jl +++ b/src/SeaIces/assemble_net_sea_ice_fluxes.jl @@ -1,8 +1,4 @@ using NumericalEarth.EarthSystemModels.InterfaceComputations: computed_fluxes, -<<<<<<< ss/snow-model-integration - get_possibly_zero_flux, -======= ->>>>>>> main interface_kernel_parameters, convert_to_kelvin, emitted_longwave_radiation, @@ -82,18 +78,11 @@ end ℐꜜˢʷ = downwelling_radiation.ℐꜜˢʷ[i, j, 1] ℐꜜˡʷ = downwelling_radiation.ℐꜜˡʷ[i, j, 1] -<<<<<<< ss/snow-model-integration - 𝒬ᵀ = get_possibly_zero_flux(atmosphere_sea_ice_fluxes, :sensible_heat)[i, j, 1] # sensible heat flux - 𝒬ᵛ = get_possibly_zero_flux(atmosphere_sea_ice_fluxes, :latent_heat)[i, j, 1] # latent heat flux - 𝒬ᶠʳᶻ = get_possibly_zero_flux(sea_ice_ocean_fluxes, :frazil_heat)[i, j, 1] # frazil heat flux - 𝒬ⁱⁿᵗ = get_possibly_zero_flux(sea_ice_ocean_fluxes, :interface_heat)[i, j, 1] # interfacial heat flux - Jˢⁿ = snowfall_flux[i, j, 1] -======= 𝒬ᵀ = atmosphere_sea_ice_fluxes.sensible_heat[i, j, 1] # sensible heat flux 𝒬ᵛ = atmosphere_sea_ice_fluxes.latent_heat[i, j, 1] # latent heat flux 𝒬ᶠʳᶻ = sea_ice_ocean_fluxes.frazil_heat[i, j, 1] # frazil heat flux 𝒬ⁱⁿᵗ = sea_ice_ocean_fluxes.interface_heat[i, j, 1] # interfacial heat flux ->>>>>>> main + Jˢⁿ = snowfall_flux[i, j, 1] end ρτˣ = atmosphere_sea_ice_fluxes.x_momentum # zonal momentum flux From fa1b3e6ac4c57cdd6023aa2194f9c8fa339c21f2 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 30 Apr 2026 12:39:57 +0200 Subject: [PATCH 38/54] download also land to avoid conflicts --- test/runtests.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/runtests.jl b/test/runtests.jl index 08a4ce9b9..95e300072 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -72,6 +72,7 @@ function __init__() try atmosphere = JRA55PrescribedAtmosphere(backend=JRA55NetCDFBackend(2)) + land = JRA55PrescribedLand(backend=JRA55NetCDFBackend(2)) catch e @warn "Original JRA55 download failed, trying NumericalEarthArtifacts fallback..." exception=(e, catch_backtrace()) emit_ci_warning("Broken JRA55 download", "Original source failed during init") @@ -79,7 +80,6 @@ function __init__() datum = Metadatum(name; dataset=JRA55.RepeatYearJRA55()) download_from_artifacts(metadata_path(datum)) end - atmosphere = JRA55PrescribedAtmosphere(backend=JRA55NetCDFBackend(2)) end ##### From f22cf1430908985321e614e0bb1301d512aefe8d Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 7 May 2026 11:30:06 +0200 Subject: [PATCH 39/54] merge main --- .gitignore | 1 + Project.toml | 13 +- docs/Project.toml | 2 +- docs/make.jl | 2 +- docs/src/Metadata/metadata_overview.md | 6 + docs/src/Metadata/supported_variables.md | 18 + docs/src/developers/slab_ocean.jl | 3 +- docs/src/earth_system_model.md | 16 +- examples/ERA5_hourly_data.jl | 411 ++++++++ examples/ERA5_winds_and_stokes_drift.jl | 87 -- examples/generate_surface_fluxes.jl | 9 +- examples/global_climate_simulation.jl | 8 +- .../idealized_single_column_simulation.jl | 13 +- examples/meridional_heat_transport_ecco.jl | 8 +- examples/near_global_ocean_simulation.jl | 20 +- examples/one_degree_simulation.jl | 8 +- examples/single_column_os_papa_simulation.jl | 10 +- examples/veros_ocean_forced_simulation.jl | 7 +- experiments/arctic_simulation.jl | 4 +- .../earth_system_coupled_simulation.jl | 9 +- .../flux_climatology/flux_climatology.jl | 2 +- .../one_degree_simulation.jl | 5 +- ext/NumericalEarthArchGDALExt.jl | 50 + ext/NumericalEarthCDSAPIExt.jl | 584 ++++++++++- ext/NumericalEarthReactantExt.jl | 6 +- ext/NumericalEarthWOAExt.jl | 2 +- src/Atmospheres/Atmospheres.jl | 4 +- .../interpolate_atmospheric_state.jl | 26 +- src/Atmospheres/prescribed_atmosphere.jl | 57 +- .../prescribed_atmosphere_regridder.jl | 16 +- src/Bathymetry/Bathymetry.jl | 3 +- src/Bathymetry/orca_grid.jl | 57 +- src/Bathymetry/regrid_bathymetry.jl | 4 +- src/DataWrangling/DataWrangling.jl | 37 +- src/DataWrangling/ECCO/ECCO.jl | 89 +- src/DataWrangling/ECCO/ECCO_atmosphere.jl | 16 +- src/DataWrangling/ECCO/ECCO_radiation.jl | 44 + src/DataWrangling/EN4/EN4.jl | 2 +- src/DataWrangling/ERA5/ERA5.jl | 182 +--- .../ERA5/ERA5_pressure_levels.jl | 262 +++++ src/DataWrangling/ERA5/ERA5_single_levels.jl | 138 +++ src/DataWrangling/ETOPO/ETOPO.jl | 14 +- src/DataWrangling/GEBCO/GEBCO.jl | 125 +++ src/DataWrangling/IBCAO/IBCAO.jl | 102 ++ src/DataWrangling/IBCSO/IBCSO.jl | 87 ++ src/DataWrangling/JRA55/JRA55.jl | 13 +- src/DataWrangling/JRA55/JRA55_metadata.jl | 2 +- .../JRA55/JRA55_prescribed_atmosphere.jl | 26 +- .../JRA55/JRA55_prescribed_radiation.jl | 42 + src/DataWrangling/OSPapa/OSPapa.jl | 4 +- .../OSPapa/OSPapa_flux_observations.jl | 4 +- .../OSPapa/OSPapa_prescribed_atmosphere.jl | 3 - .../OSPapa/OSPapa_prescribed_radiation.jl | 43 + src/DataWrangling/metadata.jl | 11 + src/DataWrangling/metadata_field.jl | 36 +- src/Diagnostics/meridional_heat_transport.jl | 3 +- src/EarthSystemModels/EarthSystemModels.jl | 10 +- .../InterfaceComputations.jl | 27 +- .../atmosphere_ocean_fluxes.jl | 47 +- .../atmosphere_sea_ice_fluxes.jl | 34 +- .../component_interfaces.jl | 59 +- .../compute_interface_state.jl | 8 +- .../InterfaceComputations/interface_states.jl | 25 +- .../InterfaceComputations/radiation.jl | 114 --- .../InterfaceComputations/state_exchanger.jl | 26 +- src/EarthSystemModels/components.jl | 3 + src/EarthSystemModels/earth_system_model.jl | 215 ++-- .../time_step_earth_system_model.jl | 52 +- src/NumericalEarth.jl | 10 +- src/Oceans/assemble_net_ocean_fluxes.jl | 84 +- src/Radiations/Radiations.jl | 55 ++ .../air_sea_interface_radiation_state.jl | 26 + .../apply_air_sea_ice_radiative_fluxes.jl | 93 ++ .../apply_air_sea_radiative_fluxes.jl | 110 +++ src/Radiations/interface_radiation_flux.jl | 34 + src/Radiations/interpolate_radiation_state.jl | 69 ++ .../latitude_dependent_albedo.jl | 0 src/Radiations/prescribed_radiation.jl | 160 +++ .../prescribed_radiation_regridder.jl | 51 + src/Radiations/radiation_kernels.jl | 5 + .../surface_radiation_properties.jl | 29 + .../tabulated_albedo.jl | 0 src/SeaIces/assemble_net_sea_ice_fluxes.jl | 45 +- .../freezing_limited_ocean_temperature.jl | 6 +- test/Project.toml | 6 +- test/runtests.jl | 16 +- test/test_cds_downloading.jl | 930 +++++++++++++++++- test/test_checkpointer.jl | 3 +- test/test_column_field.jl | 49 +- test/test_diagnostics_2.jl | 2 +- test/test_ecco_atmosphere.jl | 17 +- test/test_ocean_only_model.jl | 4 +- test/test_ocean_sea_ice_model.jl | 6 +- test/test_orca_grid.jl | 20 +- test/test_ospapa.jl | 16 +- test/test_polar_bathymetry.jl | 136 +++ test/test_radiations.jl | 82 ++ test/test_reactant.jl | 3 +- test/test_sea_ice_ocean_heat_fluxes.jl | 20 +- test/test_speedy_coupling.jl | 3 +- test/test_surface_fluxes.jl | 13 +- 101 files changed, 4405 insertions(+), 1074 deletions(-) create mode 100644 examples/ERA5_hourly_data.jl delete mode 100644 examples/ERA5_winds_and_stokes_drift.jl create mode 100644 ext/NumericalEarthArchGDALExt.jl create mode 100644 src/DataWrangling/ECCO/ECCO_radiation.jl create mode 100644 src/DataWrangling/ERA5/ERA5_pressure_levels.jl create mode 100644 src/DataWrangling/ERA5/ERA5_single_levels.jl create mode 100644 src/DataWrangling/GEBCO/GEBCO.jl create mode 100644 src/DataWrangling/IBCAO/IBCAO.jl create mode 100644 src/DataWrangling/IBCSO/IBCSO.jl create mode 100644 src/DataWrangling/JRA55/JRA55_prescribed_radiation.jl create mode 100644 src/DataWrangling/OSPapa/OSPapa_prescribed_radiation.jl delete mode 100644 src/EarthSystemModels/InterfaceComputations/radiation.jl create mode 100644 src/Radiations/Radiations.jl create mode 100644 src/Radiations/air_sea_interface_radiation_state.jl create mode 100644 src/Radiations/apply_air_sea_ice_radiative_fluxes.jl create mode 100644 src/Radiations/apply_air_sea_radiative_fluxes.jl create mode 100644 src/Radiations/interface_radiation_flux.jl create mode 100644 src/Radiations/interpolate_radiation_state.jl rename src/{EarthSystemModels/InterfaceComputations => Radiations}/latitude_dependent_albedo.jl (100%) create mode 100644 src/Radiations/prescribed_radiation.jl create mode 100644 src/Radiations/prescribed_radiation_regridder.jl create mode 100644 src/Radiations/radiation_kernels.jl create mode 100644 src/Radiations/surface_radiation_properties.jl rename src/{EarthSystemModels/InterfaceComputations => Radiations}/tabulated_albedo.jl (100%) create mode 100644 test/test_polar_bathymetry.jl create mode 100644 test/test_radiations.jl diff --git a/.gitignore b/.gitignore index 4acc5052a..c553b3bb0 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ CondaPkg.toml # claude .claude +test/Manifest.toml diff --git a/Project.toml b/Project.toml index 6fbdea39e..e6f0c8661 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "NumericalEarth" uuid = "904d977b-046a-4731-8b86-9235c0d1ef02" license = "MIT" -version = "0.3.0" +version = "0.4.0" authors = ["NumericalEarth contributors"] [deps] @@ -33,6 +33,7 @@ Thermodynamics = "b60c26fb-14c3-4610-9d3e-2d17fe7ff00c" ZipFile = "a5390f91-8eb1-5f08-bee0-b1d1ffed6cea" [weakdeps] +ArchGDAL = "c9ce4bd3-c3d5-55b8-8973-c0e20141b8c3" Breeze = "660aa2fb-d4c8-4359-a52c-9c057bc511da" CDSAPI = "8a7b9de3-9c00-473e-88b4-7eccd7ef2fea" CondaPkg = "992eb4ea-22a4-4c89-a5bb-47a3300528ab" @@ -43,10 +44,8 @@ SpeedyWeather = "9e226e20-d153-4fed-8a5b-493def4f21a9" WorldOceanAtlasTools = "04f20302-f1b9-11e8-29d9-7d841cb0a64a" XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" -[sources] -ClimaSeaIce = {rev = "ss/refactor-thermodynamics", url = "https://github.com/CliMA/ClimaSeaIce.jl.git"} - [extensions] +NumericalEarthArchGDALExt = "ArchGDAL" NumericalEarthBreezeExt = "Breeze" NumericalEarthCDSAPIExt = "CDSAPI" NumericalEarthCopernicusMarineExt = "CopernicusMarine" @@ -57,13 +56,15 @@ NumericalEarthWOAExt = "WorldOceanAtlasTools" [compat] Adapt = "4" +ArchGDAL = "0.10" + Breeze = "0.4" CDSAPI = "2.2.1" CFTime = "0.1, 0.2" -ClimaSeaIce = "0.4.4, 0.5" -CubedSphere = "0.3.4" +ClimaSeaIce = "0.5" CondaPkg = "0.2.33" CopernicusMarine = "0.1.1" +CubedSphere = "0.3.4" DataDeps = "0.7" Dates = "<0.0.1, 1" Distances = "0.10" diff --git a/docs/Project.toml b/docs/Project.toml index 6ba01cd44..50dc00fc8 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -18,10 +18,10 @@ Oceananigans = "9e8cae18-63c1-5223-a75c-80ca9d6e9a09" PythonCall = "6099a3de-0909-46bc-b1f4-468b9a2dfc0d" SeawaterPolynomials = "d496a93d-167e-4197-9f49-d3af4ff8fe40" SpeedyWeather = "9e226e20-d153-4fed-8a5b-493def4f21a9" +Suppressor = "fd094767-a336-5f1f-9728-57cf17d0bbfb" XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" [sources] -ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} NumericalEarth = {path = ".."} [compat] diff --git a/docs/make.jl b/docs/make.jl index 2e5c289a4..d936988b9 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -35,7 +35,7 @@ examples = [ Example("Global climate simulation", "global_climate_simulation", false), Example("Veros ocean simulation", "veros_ocean_forced_simulation", false), Example("Breeze over two oceans", "breeze_over_two_oceans", false), - Example("ERA5 winds and Stokes drift", "ERA5_winds_and_stokes_drift", true), + Example("ERA5 hourly data", "ERA5_hourly_data", true), ] # Developer examples from docs/src/developers/ directory diff --git a/docs/src/Metadata/metadata_overview.md b/docs/src/Metadata/metadata_overview.md index a2ed7a07f..96fa06362 100644 --- a/docs/src/Metadata/metadata_overview.md +++ b/docs/src/Metadata/metadata_overview.md @@ -80,12 +80,18 @@ NumericalEarth currently ships connectors for the following data products: | Dataset | Supported Variables | Documentation Link | |--------------------|-----------------------------------------------------------|-----------------------------------------------------------------------------------------------------| +| **Bathymetry** | | | | `ETOPO2022` | [Supported variables](@ref dataset-etopo2022-vars) | [NOAA ETOPO 2022 overview](https://www.ncei.noaa.gov/products/etopo-global-relief-model) | +| `GEBCO2024` | [Supported variables](@ref dataset-gebco2024-vars) | [GEBCO 2024 overview](https://www.gebco.net/data_and_products/gridded_bathymetry_data/) | +| `IBCSOv2` | [Supported variables](@ref dataset-ibcsov2-vars) | [IBCSO overview](https://ibcso.org/ibcso-2024-annual-release/) | +| `IBCAOv5` | [Supported variables](@ref dataset-ibcaov5-vars) | [IBCAO overview](https://www.gebco.net/data_and_products/gridded_bathymetry_data/arctic_ocean/) | +| **Ocean reanalysis** | | | | `ECCO2Monthly` | [Supported variables](@ref dataset-ecco2monthly-vars) | [ECCO2 documentation](https://ecco.jpl.nasa.gov/products/all/) | | `ECCO2Daily` | [Supported variables](@ref dataset-ecco2daily-vars) | [ECCO2 documentation](https://ecco.jpl.nasa.gov/products/all/) | | `ECCO4Monthly` | [Supported variables](@ref dataset-ecco4monthly-vars) | [ECCO V4r4 product guide](https://ecco-group.org/products-ECCO-V4r4.htm) | | `EN4Monthly` | [Supported variables](@ref dataset-en4monthly-vars) | [Met Office EN4 overview](https://www.metoffice.gov.uk/hadobs/en4/) | | `GLORYSDaily` | [Supported variables](@ref dataset-glorysdaily-vars) | [Copernicus GLORYS product page](https://data.marine.copernicus.eu/product/GLOBAL_MULTIYEAR_PHY_001_030/description) | | `GLORYSMonthly` | [Supported variables](@ref dataset-glorysmonthly-vars) | [Copernicus GLORYS product page](https://data.marine.copernicus.eu/product/GLOBAL_MULTIYEAR_PHY_001_030/description) | +| **Atmospheric forcing** | | | | `RepeatYearJRA55` | [Supported variables](@ref dataset-repeatyearjra55-vars) | [JRA-55 Reanalysis](https://www.data.jma.go.jp/jra/html/JRA-55/index_en.html) | | `MultiYearJRA55` | [Supported variables](@ref dataset-multiyearjra55-vars) | [JRA-55 Reanalysis](https://www.data.jma.go.jp/jra/html/JRA-55/index_en.html) | diff --git a/docs/src/Metadata/supported_variables.md b/docs/src/Metadata/supported_variables.md index 7f7c2cb03..7e6cd7497 100644 --- a/docs/src/Metadata/supported_variables.md +++ b/docs/src/Metadata/supported_variables.md @@ -4,13 +4,19 @@ NumericalEarth currently ships connectors for the following data products: | Dataset | Supported Variables | Documentation Link | |--------------------|-----------------------------------------------------------|-----------------------------------------------------------------------------------------------------| +| **Bathymetry** | | | | `ETOPO2022` | [Supported variables](@ref dataset-etopo2022-vars) | [NOAA ETOPO 2022 overview](https://www.ncei.noaa.gov/products/etopo-global-relief-model) | +| `GEBCO2024` | [Supported variables](@ref dataset-gebco2024-vars) | [GEBCO 2024 overview](https://www.gebco.net/data_and_products/gridded_bathymetry_data/) | +| `IBCSOv2` | [Supported variables](@ref dataset-ibcsov2-vars) | [IBCSO overview](https://ibcso.org/ibcso-2024-annual-release/) | +| `IBCAOv5` | [Supported variables](@ref dataset-ibcaov5-vars) | [IBCAO overview](https://www.gebco.net/data_and_products/gridded_bathymetry_data/arctic_ocean/) | +| **Ocean reanalysis** | | | | `ECCO2Monthly` | [Supported variables](@ref dataset-ecco2monthly-vars) | [ECCO2 documentation](https://ecco.jpl.nasa.gov/products/all/) | | `ECCO2Daily` | [Supported variables](@ref dataset-ecco2daily-vars) | [ECCO2 documentation](https://ecco.jpl.nasa.gov/products/all/) | | `ECCO4Monthly` | [Supported variables](@ref dataset-ecco4monthly-vars) | [ECCO V4r4 product guide](https://ecco-group.org/products-ECCO-V4r4.htm) | | `EN4Monthly` | [Supported variables](@ref dataset-en4monthly-vars) | [Met Office EN4 overview](https://www.metoffice.gov.uk/hadobs/en4/) | | `GLORYSDaily` | [Supported variables](@ref dataset-glorysdaily-vars) | [Copernicus GLORYS product page](https://data.marine.copernicus.eu/product/GLOBAL_MULTIYEAR_PHY_001_030/description) | | `GLORYSMonthly` | [Supported variables](@ref dataset-glorysmonthly-vars) | [Copernicus GLORYS product page](https://data.marine.copernicus.eu/product/GLOBAL_MULTIYEAR_PHY_001_030/description) | +| **Atmospheric forcing** | | | | `RepeatYearJRA55` | [Supported variables](@ref dataset-repeatyearjra55-vars) | [JRA-55 Reanalysis](https://www.data.jma.go.jp/jra/html/JRA-55/index_en.html) | | `MultiYearJRA55` | [Supported variables](@ref dataset-multiyearjra55-vars) | [JRA-55 Reanalysis](https://www.data.jma.go.jp/jra/html/JRA-55/index_en.html) | @@ -114,3 +120,15 @@ NumericalEarth currently ships connectors for the following data products: - `:snow_freshwater_flux` - Precipitation flux from snow/ice (kg m⁻² s⁻¹). - `:river_freshwater_flux` - River discharge flux (kg m⁻² s⁻¹). - `:iceberg_freshwater_flux` - Iceberg calving flux (kg m⁻² s⁻¹). + +## [Supported variables for IBCSOv2](@id dataset-ibcsov2-vars) + +- `:bottom_height` - Southern Ocean bathymetry at 500 m resolution, south of 50°S (m). + +## [Supported variables for GEBCO2024](@id dataset-gebco2024-vars) + +- `:bottom_height` - Global bathymetry and topography at 15 arc-second resolution (m). + +## [Supported variables for IBCAOv5](@id dataset-ibcaov5-vars) + +- `:bottom_height` - Arctic Ocean bathymetry at 100 m resolution, north of 64°N, including Greenland ice sheet surface elevation (m). diff --git a/docs/src/developers/slab_ocean.jl b/docs/src/developers/slab_ocean.jl index 82c7dadf0..9f29dd87d 100644 --- a/docs/src/developers/slab_ocean.jl +++ b/docs/src/developers/slab_ocean.jl @@ -120,6 +120,7 @@ end # We use the JRA55 reanalysis for the atmosphere and the ECCO4Monthly dataset to initialize our slab ocean. # We also initialize the sea ice with climatological data and see how the sea ice evolves. +using CUDA using NumericalEarth using Oceananigans using Oceananigans.Units @@ -140,7 +141,7 @@ set!(sea_ice.model, h=Metadatum(:sea_ice_thickness, dataset=ECCO4Monthly()), ℵ=Metadatum(:sea_ice_concentration, dataset=ECCO4Monthly())) interfaces = ComponentInterfaces(atmosphere, slab_ocean, sea_ice; exchange_grid=grid) -coupled_model = NumericalEarth.EarthSystemModel(atmosphere, slab_ocean, sea_ice; interfaces) +coupled_model = NumericalEarth.EarthSystemModel(; atmosphere, sea_ice, ocean=slab_ocean, interfaces) simulation = Simulation(coupled_model, Δt=60minutes, stop_time=120days) run!(simulation) diff --git a/docs/src/earth_system_model.md b/docs/src/earth_system_model.md index f50e40868..d7f7a278c 100644 --- a/docs/src/earth_system_model.md +++ b/docs/src/earth_system_model.md @@ -23,6 +23,7 @@ model = OceanOnlyModel(ocean) # output EarthSystemModel{CPU}(time = 0 seconds, iteration = 0) +├── radiation: Nothing ├── atmosphere: Nothing ├── land: Nothing ├── sea_ice: FreezingLimitedOceanTemperature{ClimaSeaIce.SeaIceThermodynamics.LinearLiquidus{Float64}} @@ -51,6 +52,7 @@ model # output EarthSystemModel{CPU}(time = 1 hour, iteration = 3) +├── radiation: Nothing ├── atmosphere: Nothing ├── land: Nothing ├── sea_ice: FreezingLimitedOceanTemperature{ClimaSeaIce.SeaIceThermodynamics.LinearLiquidus{Float64}} @@ -87,10 +89,11 @@ a sea ice component as positional arguments: ```jldoctest esm ocean = ocean_simulation(grid, timestepper = :QuasiAdamsBashforth2) sea_ice = FreezingLimitedOceanTemperature() -model = OceanSeaIceModel(ocean, sea_ice) +model = OceanSeaIceModel(sea_ice, ocean) # output EarthSystemModel{CPU}(time = 0 seconds, iteration = 0) +├── radiation: Nothing ├── atmosphere: Nothing ├── land: Nothing ├── sea_ice: FreezingLimitedOceanTemperature{ClimaSeaIce.SeaIceThermodynamics.LinearLiquidus{Float64}} @@ -108,9 +111,12 @@ Oceananigans `Simulation`, would also work. EarthSystemModel ``` -The full constructor takes positional arguments `(atmosphere, ocean, sea_ice)` and -gives access to every knob: radiation parameters, reference densities, heat capacities, -and -- most importantly -- the `interfaces` keyword, which controls how fluxes are computed. +The full constructor takes positional arguments +`(radiation, atmosphere, land, sea_ice, ocean)` -- the components in struct +order, top to bottom -- and gives access to every knob: reference densities, +heat capacities, and -- most importantly -- the `interfaces` keyword, which +controls how fluxes are computed. Pass `nothing` for components that are +absent. ## Customizing flux formulations @@ -130,6 +136,7 @@ model = OceanOnlyModel(ocean; interfaces) # output EarthSystemModel{CPU}(time = 0 seconds, iteration = 0) +├── radiation: Nothing ├── atmosphere: Nothing ├── land: Nothing ├── sea_ice: FreezingLimitedOceanTemperature{ClimaSeaIce.SeaIceThermodynamics.LinearLiquidus{Float64}} @@ -148,6 +155,7 @@ model = OceanOnlyModel(ocean; interfaces) # output EarthSystemModel{CPU}(time = 0 seconds, iteration = 0) +├── radiation: Nothing ├── atmosphere: Nothing ├── land: Nothing ├── sea_ice: FreezingLimitedOceanTemperature{ClimaSeaIce.SeaIceThermodynamics.LinearLiquidus{Float64}} diff --git a/examples/ERA5_hourly_data.jl b/examples/ERA5_hourly_data.jl new file mode 100644 index 000000000..cabf9d244 --- /dev/null +++ b/examples/ERA5_hourly_data.jl @@ -0,0 +1,411 @@ +# # ERA5 hourly atmospheric data on single- and pressure-levels +# +# This walkthrough covers downloading ERA5 reanalysis fields from the +# Copernicus Climate Data Store (CDS), with the Rain in Shallow Cumulus Over +# the Ocean (RICO) trade-wind cumulus campaign (Rauber et al. 2007) as a +# unifying case study. We consider both single-level (2-D) and pressure-level +# (3-D) fields with two subsetting approaches (bounding box and column) that +# restrict the amount of data requested through the CDS API. +# +# Our focus is on a week within van Zanten et al.'s *undisturbed period* +# (Dec 27 2004 – Jan 2 2005), during which a mean precipitation was observed. +# Here, we briefly analyze and present the ERA5 data, referring to published +# material where appropriate. +# +# Three scales are demonstrated: +# +# 1. **Global scale** - surface winds and Stokes drift over the entire globe +# 2. **Synoptic scale** — surface precipitation over an Atlantic-centered +# region covering the tropics +# 3. **Microscale** — single- and pressure-level u, v, T, qᵛ over the +# RICO study box; pressure-level qᶜˡ, qʳ in a single column. +# +# ## Install dependencies +# +# ```julia +# using Pkg +# pkg"add NumericalEarth CDSAPI Oceananigans CairoMakie" +# ``` +# +# You also need CDS API credentials in `~/.cdsapirc`. +# See for setup instructions. + +using NumericalEarth +using NumericalEarth.DataWrangling: download_dataset +using NumericalEarth.DataWrangling.ERA5 +using CDSAPI +using Dates +using Oceananigans +using Statistics +using CairoMakie +using Suppressor + +# ## Study definition +# +# For demonstration purposes, we select one week within van Zanten et al. +# (2011)'s undisturbed period, giving us 168 hourly snapshots. This is used +# by both sections below. + +dates = DateTime(2004, 12, 27):Hour(1):DateTime(2005, 1, 2, 23) +nothing #hide + +# To subset the ERA5 data, we define two types of `region`s. +# We introduce two `BoundingBox`es: + +## Synoptic-scale region, cf. Fig. 1 in Rauber et al. 2007 +synoptic_region = BoundingBox(latitude=(-25, 35), longitude=(-110, 30)) + +## RICO study area near Antigua and Barbuda +rico_region = BoundingBox(latitude=(17, 18.5), longitude=(-62.5, -61)) +nothing #hide + +# And a single `Column`, which has no lateral dimensionality: + +rico_column = Column(-61.5, 18) # longitude, latitude +nothing #hide + +# ## §1 Global conditions +# +# This part of the analysis is based on [ERA5 hourly data on single levels](https://cds.climate.copernicus.eu/datasets/reanalysis-era5-single-levels), +# available from 1940 to present. In this section, we evaluate ocean +# surface conditions based on the near-surface wind velocity at 10-m ASL +# and Stokes drift. + +dataset = ERA5HourlySingleLevel() +nothing #hide + +# Note that ERA5 atmospheric variables (wind) live on a **0.25°** grid (1440×721), +# whereas ocean wave variables (Stokes drift) live on a **0.5°** grid (720×361). + +# ### Metadata definition +# +# We first define metadata for each variable at a single date. +# *Note: `Metadatum` is used for single dates while `Metadata` handles +# multiple dates.* No `region` kwarg is specified because we want to +# download global fields. + +date = first(dates) +u_stokes_meta = Metadatum(:eastward_stokes_drift; dataset, date) +v_stokes_meta = Metadatum(:northward_stokes_drift; dataset, date) +u_wind_meta = Metadatum(:eastward_velocity; dataset, date) +v_wind_meta = Metadatum(:northward_velocity; dataset, date) + +# ### Build a grid and create fields +# +# We build a single `LatitudeLongitudeGrid` and use `set!` to download +# and interpolate all four variables onto it. + +grid = LatitudeLongitudeGrid(size = (1440, 720, 1), + longitude = (0, 360), + latitude = (-90, 90), + z = (0, 1)) + +u_stokes = CenterField(grid) +v_stokes = CenterField(grid) +u_wind = CenterField(grid) +v_wind = CenterField(grid) + +set!(u_stokes, u_stokes_meta) +set!(v_stokes, v_stokes_meta) +set!(u_wind, u_wind_meta) +set!(v_wind, v_wind_meta) + +# ### Compute speeds and plot +# +# We use Oceananigans abstract operations to compute the speed fields, +# then plot them directly as heatmaps on latitude–longitude axes. + +stokes_speed = sqrt(u_stokes^2 + v_stokes^2) +wind_speed = sqrt(u_wind^2 + v_wind^2) + +lon, lat, _ = nodes(u_stokes) + +fig = Figure(size=(1200, 600)) + +ax1 = Axis(fig[1, 1]; title="Stokes drift speed (m/s)", + xlabel="Longitude", ylabel="Latitude") +ax2 = Axis(fig[1, 2]; title="10-m wind speed (m/s)", + xlabel="Longitude", ylabel="Latitude") + +hm1 = heatmap!(ax1, lon, lat, stokes_speed; colormap=:speed, colorrange=(0, 0.3)) +hm2 = heatmap!(ax2, lon, lat, wind_speed; colormap=:speed, colorrange=(0, 20)) + +Colorbar(fig[2, 1], hm1; vertical=false, width=Relative(0.8), label="m/s") +Colorbar(fig[2, 2], hm2; vertical=false, width=Relative(0.8), label="m/s") + +Label(fig[0, :], + "ERA5 Stokes Drift and Surface Wind — $(Dates.format(date, "yyyy-mm-dd HH:MM")) UTC"; + fontsize=20) + +fig + +# ## §2 Synoptic conditions +# +# New in this section: +# +# - A `BoundingBox` defined by latitude and longitude ranges is introduced for +# region restriction. +# - We work with a `FieldTimeSeries`, constructed from metadata, to construct +# an ERA5 time series. Field data are downloaded on the fly. + +precip_meta = Metadata(:total_precipitation; dataset, dates, region = synoptic_region) +precip_series = @suppress_out FieldTimeSeries(precip_meta) +nothing #hide + +# For brevity, we plot the time-averaged (rather than instantaneous) precipitation over +# the region. + +Nt = length(dates) +λ, φ, _ = nodes(precip_series[1]) + +## ERA5 `total_precipitation` is in m/hour; convert to mm/day. +to_mm_day = 1000 * 24 +precip_avg = mean(interior(precip_series[n], :, :, 1) for n in 1:Nt) * to_mm_day + +fig1 = Figure(size=(900, 400)) + +ax1 = Axis(fig1[1, 1], + title = "Mean precipitation, $(first(dates)) to $(last(dates))", + xlabel = "Longitude (°)", ylabel = "Latitude (°)", + xticks = -90:30:30) + +hm = heatmap!(ax1, λ, φ, precip_avg; colormap=:rain, colorrange=(0, 12)) + +Colorbar(fig1[1, 2], hm, label="Precipitation (mm/day)") + +fig1 + +# ## §3 Microscale conditions +# +# ### Time history of precipitation at the RICO location +# +# New in this section: +# +# - `Column` replaces `BoundingBox` as the region restriction. This issues +# a smaller CDS request that downloads only the cells needed to linearly +# interpolate (by default) to the requested (longitude, latitude) coordinate. +# A `Column(...; interpolation = Nearest())` option is also available. +# - We explicitly load all fields in the timeseries into memory through +# `FieldTimeSeries...; time_indices_in_memory = Nt)` whereas the default is +# to load only two snapshots at a time. Having the full timeseries in memory +# facilitates any operations we want to perform on the data — in this case, +# a units conversion. +# +# *Note: We could have sliced the `precip_series` from above, but we +# illustrate here a seperate data retrieval path.* + +precip_col_meta = Metadata(:total_precipitation; dataset, dates, region = rico_column) +precip_col_series = @suppress_out FieldTimeSeries(precip_col_meta; time_indices_in_memory = Nt) +nothing #hide + +# ERA5 `total_precipitation` is in m (liquid-water-equivalent depth). Note +# that this is an *accumulated* rather than instantaneous quantity (more +# discussion [here](https://confluence.ecmwf.int/display/CKB/ERA5%3A+data+documentation#ERA5:datadocumentation-Meanrates/fluxesandaccumulations)) +# with an accumulation period of 1 hour. We convert to a latent-heat-equivalent +# flux in W/m² to compare with van Zanten et al.'s reported 21 W m⁻² mean. + +ρᴸ = 1000 # kg/m³ +Lᵛ = 2.5e6 # J/kg, latent heat of vaporization + +to_W_per_m2 = ρᴸ * Lᵛ / 3600 # m/hr → W/m² + +precip_W_m2 = interior(precip_col_series, 1, 1, 1, :) +precip_W_m2 .*= to_W_per_m2 +nothing #hide + +# Now, plot the precipitation time history. + +fig2 = Figure(size=(900, 300)) + +## Tick at each day boundary (00:00 of each day in the window). +day_dts = first(dates):Day(1):last(dates) +day_ticks = (0:length(day_dts)-1) .* 86400.0 # seconds since first(dates) +day_labels = Dates.format.(day_dts, dateformat"u d") + +ax2 = Axis(fig2[1, 1], + title = "Precipitation at $(rico_column.longitude)°E, $(rico_column.latitude)°N", + ylabel = "Precipitation [W m⁻²]", + xticks = (day_ticks, day_labels)) + +lines!(ax2, precip_col_series.times, precip_W_m2; color=:steelblue, label="ERA5 hourly data") +hlines!(ax2, [21.0]; color=:black, linestyle=:dash, label="van Zanten mean (21 W m⁻²)") + +axislegend(ax2; position=:rt) + +fig2 + +# In comparison with observations (van Zanten et al.'s Fig. 1), we see different +# day-to-day variability in the reanalysis, with a wider range of values and greater +# mean precipitation. + +@show mean(precip_W_m2) + +# ### Time-height of cloud liquid and rain water content at the RICO location +# +# This part of the analysis is based on [ERA5 hourly data on pressure levels](https://cds.climate.copernicus.eu/datasets/reanalysis-era5-pressure-levels), +# also available from 1940 to present. What's new: +# +# - We restrict the data retrieval to the lower troposphere (≥ 250 hPa, +# surface up to ~10 km). This returns data with 21 vertical levels +# instead of all 37 standard pressure levels — the full list is given +# by `ERA5_all_pressure_levels`. +# - `download_dataset(variables, dataset, dates; region)` bundles +# multi-variable requests into a single CDS API call — fewer round trips +# than calling `download_dataset` per variable, which is what +# `FieldTimeSeries` does automatically on demand. + +selected_levels = filter(≥(250hPa), ERA5_all_pressure_levels) +ds_pl = ERA5HourlyPressureLevels(selected_levels) + +## Selected pressure levels [hPa] +ds_pl.pressure_levels' / hPa + +# 3-D data are downloaded in a `Column` region, resulting in one 1-D field +# per snapshot. +# +# The download list also includes`:geopotential` because a pressure-level +# `FieldTimeSeries` grid derives its `z`-coordinate from the time-mean +# geopotential height. `FieldTimeSeries` would also have auomatically +# downloaded this field on demand, but pre-downloading saves API calls. +# _Note: If the geopotential field isn't available, the fallback is to +# estimate geopotential heights from the international standard atmosphere._ + +variables = [:specific_cloud_liquid_water_content, + :specific_rain_water_content, + :geopotential] +@suppress_out download_dataset(variables, ds_pl, dates; region = rico_column) +nothing #hide + +# Load the downloaded data and stack the column profile from each time into +# a (Nt × Nz) matrix. + +qᶜ_col_meta = Metadata(:specific_cloud_liquid_water_content; dataset = ds_pl, dates, region = rico_column) +qʳ_col_meta = Metadata(:specific_rain_water_content; dataset = ds_pl, dates, region = rico_column) +qᶜ_col_series = FieldTimeSeries(qᶜ_col_meta) +qʳ_col_series = FieldTimeSeries(qʳ_col_meta) + +z_col = znodes(qᶜ_col_series[1]) +Nz_col = length(z_col) + +qᶜ_data = zeros(Nt, Nz_col) +qʳ_data = zeros(Nt, Nz_col) +for n in 1:Nt + qᶜ_data[n, :] = vec(interior(qᶜ_col_series[n])) + qʳ_data[n, :] = vec(interior(qʳ_col_series[n])) +end +qᶜ_data .*= 1000 # kg/kg → g/kg +qʳ_data .*= 1000 # kg/kg → g/kg +nothing #hide + +# Render the Hovmöller diagram using `heatmap`, with the same x-ticks as `fig2`. + +fig3 = Figure(size=(900, 600)) + +ax_qc = Axis(fig3[1, 1], + title = "Specific cloud liquid water content at $(rico_column.longitude)°E, $(rico_column.latitude)°N", + ylabel = "Height [m]", + xticks = (day_ticks, day_labels)) +ax_qr = Axis(fig3[2, 1], + title = "Specific rain water content at $(rico_column.longitude)°E, $(rico_column.latitude)°N", + ylabel = "Height [m]", + xticks = (day_ticks, day_labels)) + +hm_qc = heatmap!(ax_qc, qᶜ_col_series.times, z_col, qᶜ_data; colormap=:Blues) +hm_qr = heatmap!(ax_qr, qʳ_col_series.times, z_col, qʳ_data; colormap=:Blues) + +Colorbar(fig3[1, 2], hm_qc, label="qᶜˡ [g kg⁻¹]") +Colorbar(fig3[2, 2], hm_qr, label="qʳ [g kg⁻¹]") + +linkaxes!(ax_qc, ax_qr) +ylims!(ax_qc, 0, 4000) +hidexdecorations!(ax_qc, grid=false) + +fig3 + +# This figure tells the same story as `fig2` in a different way. + +# ### Profiles at the RICO location +# +# We use the filtered pressure-levels dataset from before, as well as the +# previously defined `BoundingBox`. As before, we bundle API requests to +# expedite the data retrieval. + +variables = [:temperature, :specific_humidity, + :eastward_velocity, :northward_velocity, + :geopotential] +download_dataset(variables, ds_pl, dates; region = rico_region) + +T_meta = Metadata(:temperature; dataset=ds_pl, dates, region=rico_region) +q_meta = Metadata(:specific_humidity; dataset=ds_pl, dates, region=rico_region) +u_meta = Metadata(:eastward_velocity; dataset=ds_pl, dates, region=rico_region) +v_meta = Metadata(:northward_velocity; dataset=ds_pl, dates, region=rico_region) + +T_series = @suppress_out FieldTimeSeries(T_meta) +q_series = @suppress_out FieldTimeSeries(q_meta) +u_series = @suppress_out FieldTimeSeries(u_meta) +v_series = @suppress_out FieldTimeSeries(v_meta) +nothing #hide + +# Calculate mean profiles and quantities of interest. + +z = znodes(T_series[1]) +Nz = length(z) +p_levs = sort(selected_levels, rev=true) ./ hPa # Pa → hPa, from bottom-to-top + +function horizontal_mean_profiles(series) + profiles = zeros(Nz, Nt) + for n in 1:Nt + profiles[:, n] = mean(interior(series[n], :, :, :), dims=(1, 2)) + end + return profiles +end + +T_profiles = horizontal_mean_profiles(T_series) +q_profiles = horizontal_mean_profiles(q_series) +u_profiles = horizontal_mean_profiles(u_series) +v_profiles = horizontal_mean_profiles(v_series) + +## T → potential temperature: θ = T (p₀/p)^(R/cₚ) +Rᵈ_over_cᵖ = 0.286 +pˢᵗ = 1000 +θ_profiles = T_profiles .* (pˢᵗ ./ p_levs) .^ Rᵈ_over_cᵖ + +## specific humidity: kg/kg → g/kg +q_profiles .*= 1000 +nothing #hide + +# Lastly, plot the profiles (cf. van Zanten et al. 2011, Fig. 2). + +fig4 = Figure(size=(900, 540), fontsize=12) + +fig4_title = string("Mean ± IQR vertical profiles over the RICO box, ", + Dates.format(first(dates), dateformat"u d yyyy"), " – ", + Dates.format(last(dates), dateformat"u d yyyy")) +Label(fig4[0, 1:4], fig4_title; + fontsize=14, font=:bold, halign=:center, tellwidth=false) + +ax_θ = Axis(fig4[1, 1], xlabel="θ [K]", ylabel="Height [m]") +ax_q = Axis(fig4[1, 2], xlabel="qᵛ [g kg⁻¹]", ylabel="Height [m]") +ax_u = Axis(fig4[1, 3], xlabel="u [m s⁻¹]", ylabel="Height [m]", xticks=-10:2:2) +ax_v = Axis(fig4[1, 4], xlabel="v [m s⁻¹]", ylabel="Height [m]", xticks=-10:2:0) + +for (ax, profiles) in [(ax_θ, θ_profiles), (ax_q, q_profiles), + (ax_u, u_profiles), (ax_v, v_profiles)] + μ = vec(mean(profiles, dims=2)) + lo = [quantile(r, 0.25) for r in eachrow(profiles)] + hi = [quantile(r, 0.75) for r in eachrow(profiles)] + band!(ax, z, lo, hi; direction=:y, color=(:gray, 0.4)) + lines!(ax, μ, z; color=:black, linewidth=2) +end + +xlims!(ax_θ, 295, 320) +xlims!(ax_q, 0, 15) +xlims!(ax_u, -10, 2) +xlims!(ax_v, -9, -1) +linkyaxes!(ax_θ, ax_q, ax_u, ax_v) +ylims!(ax_θ, 0, 4000) +hideydecorations!(ax_q, grid=false) +hideydecorations!(ax_u, grid=false) +hideydecorations!(ax_v, grid=false) + +fig4 diff --git a/examples/ERA5_winds_and_stokes_drift.jl b/examples/ERA5_winds_and_stokes_drift.jl deleted file mode 100644 index 7a2a4eb97..000000000 --- a/examples/ERA5_winds_and_stokes_drift.jl +++ /dev/null @@ -1,87 +0,0 @@ -# # ERA5 winds and Stokes drift -# -# In this example, we download ERA5 10-meter wind and Stokes drift data -# from the Copernicus Climate Data Store, and plot global maps of the -# wind speed and Stokes drift speed side by side. -# -# ## Install dependencies -# -# ```julia -# using Pkg -# pkg"add Oceananigans, NumericalEarth, CDSAPI, CairoMakie" -# ``` -# -# You also need CDS API credentials in `~/.cdsapirc`. -# See for setup instructions. - -using NumericalEarth -using NumericalEarth.DataWrangling: Metadatum -using NumericalEarth.DataWrangling.ERA5: ERA5Hourly -using CDSAPI - -using Oceananigans -using CairoMakie -using Dates - -# ## Define metadata -# -# ERA5 atmospheric variables (wind) live on a 0.25° grid (1440×721), -# while ocean wave variables (Stokes drift) live on a 0.5° grid (720×361). -# We define metadata for each variable at a single date. - -dataset = ERA5Hourly() -date = DateTime(2020, 1, 15, 12) # January 15, 2020 at 12:00 UTC - -u_stokes_meta = Metadatum(:eastward_stokes_drift; dataset, date) -v_stokes_meta = Metadatum(:northward_stokes_drift; dataset, date) -u_wind_meta = Metadatum(:eastward_velocity; dataset, date) -v_wind_meta = Metadatum(:northward_velocity; dataset, date) - -# ## Build a grid and create fields -# -# We build a single `LatitudeLongitudeGrid` and use `set!` to download -# and interpolate all four variables onto it. - -grid = LatitudeLongitudeGrid(size = (1440, 721, 1), - longitude = (0, 360), - latitude = (-90, 90), - z = (0, 1)) - -u_stokes = CenterField(grid) -v_stokes = CenterField(grid) -u_wind = CenterField(grid) -v_wind = CenterField(grid) - -set!(u_stokes, u_stokes_meta) -set!(v_stokes, v_stokes_meta) -set!(u_wind, u_wind_meta) -set!(v_wind, v_wind_meta) - -# ## Compute speed and plot -# -# We use Oceananigans abstract operations to compute the speed fields, -# then plot them directly as heatmaps on latitude–longitude axes. - -stokes_speed = sqrt(u_stokes^2 + v_stokes^2) -wind_speed = sqrt(u_wind^2 + v_wind^2) - -lon, lat, _ = nodes(u_stokes) - -fig = Figure(size=(1200, 600)) - -ax1 = Axis(fig[1, 1]; title="Stokes drift speed (m/s)", - xlabel="Longitude", ylabel="Latitude") -ax2 = Axis(fig[1, 2]; title="10m wind speed (m/s)", - xlabel="Longitude", ylabel="Latitude") - -hm1 = heatmap!(ax1, lon, lat, stokes_speed; colormap=:solar, colorrange=(0, 0.3)) -hm2 = heatmap!(ax2, lon, lat, wind_speed; colormap=:solar, colorrange=(0, 20)) - -Colorbar(fig[2, 1], hm1; vertical=false, width=Relative(0.8), label="m/s") -Colorbar(fig[2, 2], hm2; vertical=false, width=Relative(0.8), label="m/s") - -Label(fig[0, :], - "ERA5 Stokes Drift and Surface Wind — $(Dates.format(date, "yyyy-mm-dd HH:MM")) UTC"; - fontsize=20) - -current_figure() diff --git a/examples/generate_surface_fluxes.jl b/examples/generate_surface_fluxes.jl index 3043bef0f..1e09eab1a 100644 --- a/examples/generate_surface_fluxes.jl +++ b/examples/generate_surface_fluxes.jl @@ -62,11 +62,12 @@ S_metadata = ECCOMetadatum(:salinity; date=DateTime(1993, 1, 1)) set!(ocean.model; T=T_metadata, S=S_metadata) # Finally, we construct a coupled model, which will compute fluxes during construction. -# We omit `sea_ice` so the model is ocean-only, and use the default `Radiation()` that -# uses the two-band shortwave (visible and UV) + longwave (mid and far infrared) -# decomposition of the radiation spectrum. +# We omit `sea_ice` so the model is ocean-only, and pair the JRA55 atmosphere with a +# matching `JRA55PrescribedRadiation` that supplies downwelling shortwave and +# longwave radiation as well as ocean / sea-ice surface properties. -coupled_model = OceanOnlyModel(ocean; atmosphere, radiation=Radiation(eltype)) +radiation = JRA55PrescribedRadiation(; backend = JRA55NetCDFBackend(2)) +coupled_model = OceanOnlyModel(ocean; atmosphere, radiation) # Now that the surface fluxes are computed, we can extract and visualize them. # The turbulent fluxes are stored in `coupled_model.interfaces.atmosphere_ocean_interface.fluxes`. diff --git a/examples/global_climate_simulation.jl b/examples/global_climate_simulation.jl index 6fbac8d07..1eac5f6e3 100644 --- a/examples/global_climate_simulation.jl +++ b/examples/global_climate_simulation.jl @@ -98,10 +98,12 @@ nothing #hide nothing #hide # We build the complete coupled `earth_model` and the coupled simulation. -# Since radiation is idealized in this example, we set the emissivities to zero. +# NumericalEarth still computes turbulent (sensible heat, water vapor) fluxes +# here using its own bulk-formula machinery and writes them back to Speedy. +# Top-level radiation, however, is not yet wired up against SpeedyWeather's +# upwelling-LW path, so we leave `radiation = nothing` for now. -radiation = Radiation(ocean_emissivity=0.0, sea_ice_emissivity=0.0) -earth_model = EarthSystemModel(atmosphere, ocean, sea_ice; radiation) +earth_model = EarthSystemModel(; atmosphere, sea_ice, ocean) # ## Building and running the simulation # diff --git a/examples/idealized_single_column_simulation.jl b/examples/idealized_single_column_simulation.jl index c6e4a498c..ec76bece1 100644 --- a/examples/idealized_single_column_simulation.jl +++ b/examples/idealized_single_column_simulation.jl @@ -18,15 +18,20 @@ qᵃᵗ = 0.01 # specific humidity ℐꜜˢʷ = 400 # shortwave radiation (W m⁻², positive means heating right now) # Build the atmosphere -radiation = Radiation(ocean_albedo=0.1) atmosphere_grid = RectilinearGrid(size=(), topology=(Flat, Flat, Flat)) atmosphere_times = range(0, 1days, length=3) atmosphere = PrescribedAtmosphere(atmosphere_grid, atmosphere_times) +# Build the radiation component (lives on the same grid + times as the atmosphere +# in this single-column setup) and prescribe a constant shortwave forcing. +# Override the default ocean albedo (0.05) but keep the default emissivity (0.97). +radiation = PrescribedRadiation(atmosphere_grid, atmosphere_times; + ocean_surface = SurfaceRadiationProperties(albedo=0.1)) + parent(atmosphere.tracers.T) .= Tᵃᵗ # K parent(atmosphere.velocities.u) .= u₁₀ # m/s parent(atmosphere.tracers.q) .= qᵃᵗ # mass ratio -parent(atmosphere.downwelling_radiation.shortwave) .= ℐꜜˢʷ # W +parent(radiation.downwelling_shortwave) .= ℐꜜˢʷ # W # Build ocean model at rest with initial temperature stratification grid = RectilinearGrid(size=20, z=(-100, 0), topology=(Flat, Flat, Bounded)) @@ -40,8 +45,8 @@ Tᵢ(z) = T₀ + dTdz * z set!(ocean.model, T=Tᵢ, S=S₀) atmosphere_ocean_fluxes = SimilarityTheoryFluxes(stability_functions=nothing) -interfaces = NumericalEarth.EarthSystemModels.ComponentInterfaces(atmosphere, ocean; atmosphere_ocean_fluxes) -model = OceanOnlyModel(ocean; atmosphere, interfaces) +interfaces = NumericalEarth.EarthSystemModels.ComponentInterfaces(atmosphere, ocean; atmosphere_ocean_fluxes, radiation) +model = OceanOnlyModel(ocean; atmosphere, radiation, interfaces) 𝒬ᵛ = model.interfaces.atmosphere_ocean_interface.fluxes.latent_heat 𝒬ᵀ = model.interfaces.atmosphere_ocean_interface.fluxes.sensible_heat diff --git a/examples/meridional_heat_transport_ecco.jl b/examples/meridional_heat_transport_ecco.jl index 58b3e7849..fd6ad9766 100755 --- a/examples/meridional_heat_transport_ecco.jl +++ b/examples/meridional_heat_transport_ecco.jl @@ -42,10 +42,10 @@ ecco_sea_ice_concentration = Metadatum(:sea_ice_concentration; date, dataset) set!(ocean.model, T=ecco_temperature, S=ecco_salinity) set!(sea_ice.model, h=ecco_sea_ice_thickness, ℵ=ecco_sea_ice_concentration) -radiation = Radiation(arch) -atmosphere = JRA55PrescribedAtmosphere(arch; backend=JRA55NetCDFBackend(80), - include_rivers_and_icebergs = false) -esm = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) +jra55_backend = JRA55NetCDFBackend(80) +atmosphere = JRA55PrescribedAtmosphere(arch; backend=jra55_backend) +radiation = JRA55PrescribedRadiation(arch; backend=jra55_backend) +esm = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation) simulation = Simulation(esm; Δt=20minutes, stop_time=5*365days) diff --git a/examples/near_global_ocean_simulation.jl b/examples/near_global_ocean_simulation.jl index f57a5e8e0..1ec9c3ce0 100644 --- a/examples/near_global_ocean_simulation.jl +++ b/examples/near_global_ocean_simulation.jl @@ -88,24 +88,14 @@ set!(ocean.model, T=Metadatum(:temperature, dataset=ECCO4Monthly()), # ### Prescribed atmosphere and radiation # -# Next we build a prescribed atmosphere state and radiation model, -# which will drive the ocean simulation. We use the default `Radiation` model, - -# The radiation model specifies an ocean albedo emissivity to compute the net radiative -# fluxes. The default ocean albedo is based on Payne (1982) and depends on cloud cover -# (calculated from the ratio of maximum possible incident solar radiation to actual -# incident solar radiation) and latitude. The ocean emissivity is set to 0.97. - -radiation = Radiation(arch) - -# The atmospheric data is prescribed using the JRA55 dataset. -# The JRA55 dataset provides atmospheric data such as temperature, humidity, and winds -# to calculate turbulent fluxes using bulk formulae, see [`InterfaceComputations`](@ref NumericalEarth.EarthSystemModels.InterfaceComputations). -# The number of snapshots that are loaded into memory is determined by -# the `backend`. Here, we load 41 snapshots at a time into memory. +# Next we build a prescribed atmosphere state and radiation component, +# which together drive the ocean simulation. The atmospheric data and +# downwelling shortwave / longwave radiation are both prescribed using JRA55. +# The number of snapshots loaded into memory is set by the backend. jra55_backend = JRA55NetCDFBackend(41) atmosphere = JRA55PrescribedAtmosphere(arch; backend=jra55_backend) +radiation = JRA55PrescribedRadiation(arch; backend=jra55_backend) land = JRA55PrescribedLand(arch; backend=jra55_backend) # ## The coupled simulation diff --git a/examples/one_degree_simulation.jl b/examples/one_degree_simulation.jl index a08bba029..53e09aaf9 100644 --- a/examples/one_degree_simulation.jl +++ b/examples/one_degree_simulation.jl @@ -95,9 +95,13 @@ set!(sea_ice.model, h=ecco_sea_ice_thickness, ℵ=ecco_sea_ice_concentration) # ### Atmospheric forcing # We force the simulation with a JRA55-do atmospheric reanalysis. -radiation = Radiation(arch) jra55_backend = JRA55NetCDFBackend(80) atmosphere = JRA55PrescribedAtmosphere(arch; backend=jra55_backend) +# Use a latitude-dependent ocean albedo (Large & Yeager 2009); keep the +# default ocean emissivity (0.97) and sea-ice surface (albedo 0.7, +# emissivity 1.0). +radiation = JRA55PrescribedRadiation(arch; backend=jra55_backend, + ocean_surface = SurfaceRadiationProperties(albedo = LatitudeDependentAlbedo())) land = JRA55PrescribedLand(arch; backend=jra55_backend) # ### Coupled simulation @@ -107,7 +111,7 @@ land = JRA55PrescribedLand(arch; backend=jra55_backend) # With Runge-Kutta 3rd order time-stepping we can safely use a timestep of 20 minutes. -coupled_model = OceanSeaIceModel(ocean, sea_ice; atmosphere, land, radiation) +coupled_model = OceanSeaIceModel(sea_ice, ocean; atmosphere, land, radiation) simulation = Simulation(coupled_model; Δt=20minutes, stop_time=365days) # ### A progress messenger diff --git a/examples/single_column_os_papa_simulation.jl b/examples/single_column_os_papa_simulation.jl index d0a38baf6..1420687fd 100644 --- a/examples/single_column_os_papa_simulation.jl +++ b/examples/single_column_os_papa_simulation.jl @@ -68,6 +68,11 @@ atmosphere = JRA55PrescribedAtmosphere(longitude = λ★, end_date = DateTime(1990, 1, 31), # Last day of the simulation backend = InMemory()) +radiation = JRA55PrescribedRadiation(longitude = λ★, + latitude = φ★, + end_date = DateTime(1990, 1, 31), + backend = InMemory()) + # This builds a representation of the atmosphere on the small grid atmosphere.grid @@ -101,7 +106,6 @@ lines!(axq, t_days, qa) current_figure() # We continue constructing a simulation. -radiation = Radiation() coupled_model = OceanOnlyModel(ocean; atmosphere, radiation) simulation = Simulation(coupled_model, Δt=ocean.Δt, stop_time=30days) @@ -205,8 +209,8 @@ ua = atmosphere.velocities.u va = atmosphere.velocities.v Ta = atmosphere.tracers.T qa = atmosphere.tracers.q -ℐꜜˡʷ = atmosphere.downwelling_radiation.longwave -ℐꜜˢʷ = atmosphere.downwelling_radiation.shortwave +ℐꜜˡʷ = radiation.downwelling_longwave +ℐꜜˢʷ = radiation.downwelling_shortwave Pr = atmosphere.freshwater_flux.rain Ps = atmosphere.freshwater_flux.snow diff --git a/examples/veros_ocean_forced_simulation.jl b/examples/veros_ocean_forced_simulation.jl index bb95573f5..598582683 100644 --- a/examples/veros_ocean_forced_simulation.jl +++ b/examples/veros_ocean_forced_simulation.jl @@ -69,12 +69,11 @@ ocean.set_forcing = set_forcing_tke_only # radiation, as well as freshwater fluxes. atmos = JRA55PrescribedAtmosphere(; backend = JRA55NetCDFBackend(10)) +radiation = JRA55PrescribedRadiation(; backend = JRA55NetCDFBackend(10)) -# The coupled ocean--atmosphere model. -# We use the default radiation model and we do not couple an ice model for simplicity. +# The coupled ocean--atmosphere model. We do not couple an ice model for simplicity. -radiation = Radiation() -coupled_model = OceanSeaIceModel(ocean, nothing; atmosphere=atmos, radiation) +coupled_model = OceanSeaIceModel(nothing, ocean; atmosphere=atmos, radiation) simulation = Simulation(coupled_model; Δt = 1800, stop_time = 60days) # We set up a progress callback that will print the current time, iteration, and maximum velocities diff --git a/experiments/arctic_simulation.jl b/experiments/arctic_simulation.jl index afabdf826..eee1fdb48 100644 --- a/experiments/arctic_simulation.jl +++ b/experiments/arctic_simulation.jl @@ -90,13 +90,13 @@ set!(sea_ice.model, h=Metadatum(:sea_ice_thickness; dataset), ##### atmosphere = JRA55PrescribedAtmosphere(arch; backend=JRA55NetCDFBackend(40)) -radiation = Radiation() +radiation = JRA55PrescribedRadiation(arch; backend=JRA55NetCDFBackend(40)) ##### ##### Arctic coupled model ##### -arctic = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) +arctic = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation) arctic = Simulation(arctic, Δt=5minutes, stop_time=365days) # Sea-ice variables diff --git a/experiments/coupled_simulation/earth_system_coupled_simulation.jl b/experiments/coupled_simulation/earth_system_coupled_simulation.jl index 334ebce91..73a81d5d5 100644 --- a/experiments/coupled_simulation/earth_system_coupled_simulation.jl +++ b/experiments/coupled_simulation/earth_system_coupled_simulation.jl @@ -79,14 +79,17 @@ salinity = ECCOMetadata(:salinity; dir="./") ice_thickness = ECCOMetadata(:sea_ice_thickness; dir="./") ice_concentration = ECCOMetadata(:sea_ice_concentration; dir="./") -atmosphere = JRA55PrescribedAtmosphere(arch, backend=JRA55NetCDFBackend(20)) -radiation = Radiation(ocean_albedo = LatitudeDependentAlbedo(), sea_ice_albedo=0.6) +jra55_backend = JRA55NetCDFBackend(20) +atmosphere = JRA55PrescribedAtmosphere(arch; backend=jra55_backend) +radiation = JRA55PrescribedRadiation(arch; backend=jra55_backend, + ocean_surface = SurfaceRadiationProperties(LatitudeDependentAlbedo(), 0.97), + sea_ice_surface = SurfaceRadiationProperties(0.6, 1.0)) set!(ocean.model, T=temperature, S=salinity) set!(sea_ice.model.ice_thickness, ice_thickness, inpainting=NearestNeighborInpainting(1)) set!(sea_ice.model.ice_concentration, ice_concentration, inpainting=NearestNeighborInpainting(1)) -earth_model = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) +earth_model = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation) earth = Simulation(earth_model; Δt=30minutes, stop_iteration=10, stop_time=30days) u, v, _ = ocean.model.velocities diff --git a/experiments/flux_climatology/flux_climatology.jl b/experiments/flux_climatology/flux_climatology.jl index 20ad23d60..dfda99ab8 100644 --- a/experiments/flux_climatology/flux_climatology.jl +++ b/experiments/flux_climatology/flux_climatology.jl @@ -197,7 +197,7 @@ atmosphere = JRA55PrescribedAtmosphere(arch; backend=JRA55NetCDFBackend(1000)) ##### A prescribed earth... ##### -earth_model = OceanOnlyModel(ocean; atmosphere, radiation = Radiation(ocean_emissivity=0, ocean_albedo=1)) +earth_model = OceanOnlyModel(ocean; atmosphere) # radiation off (radiation=nothing default) earth = Simulation(earth_model, Δt=3hours, stop_time=365days) wall_time = Ref(time_ns()) diff --git a/experiments/one_degree_simulation/one_degree_simulation.jl b/experiments/one_degree_simulation/one_degree_simulation.jl index e48bc4d56..215beafd6 100644 --- a/experiments/one_degree_simulation/one_degree_simulation.jl +++ b/experiments/one_degree_simulation/one_degree_simulation.jl @@ -45,8 +45,9 @@ ocean = ocean_simulation(grid; momentum_advection, tracer_advection, free_surfac set!(ocean.model, T=ECCOMetadata(:temperature; dates=first(dates)), S=ECCOMetadata(:salinity; dates=first(dates))) -radiation = Radiation(arch) -atmosphere = JRA55PrescribedAtmosphere(arch; backend=JRA55NetCDFBackend(41)) +jra55_backend = JRA55NetCDFBackend(41) +atmosphere = JRA55PrescribedAtmosphere(arch; backend=jra55_backend) +radiation = JRA55PrescribedRadiation(arch; backend=jra55_backend) coupled_model = OceanOnlyModel(ocean; atmosphere, radiation) simulation = Simulation(coupled_model; Δt=10minutes, stop_iteration=100) diff --git a/ext/NumericalEarthArchGDALExt.jl b/ext/NumericalEarthArchGDALExt.jl new file mode 100644 index 000000000..719aad08c --- /dev/null +++ b/ext/NumericalEarthArchGDALExt.jl @@ -0,0 +1,50 @@ +module NumericalEarthArchGDALExt + +using NumericalEarth +using ArchGDAL +using NCDatasets + +import NumericalEarth.DataWrangling.IBCAO: reproject_ibcao_to_netcdf + +function reproject_ibcao_to_netcdf(tiff_path, nc_path) + ArchGDAL.read(tiff_path) do src + # Warp from EPSG:3996 (Polar Stereographic) to EPSG:4326 (WGS84) + # at 0.01° resolution, clipping to 64–90°N + ArchGDAL.gdalwarp([src], + ["-t_srs", "EPSG:4326", + "-te", "-180", "64", "180", "90", # xmin ymin xmax ymax + "-tr", "0.01", "0.01", # target resolution (degrees) + "-r", "bilinear", # resampling method + "-ot", "Float32"]) do warped + + # ArchGDAL returns data as (Nx, Ny) with y from north to south (GDAL convention) + data = Float32.(ArchGDAL.read(warped, 1)) + data = reverse(data, dims=2) + + Nx, Ny = size(data) # expected: (36000, 2600) + + NCDataset(nc_path, "c") do ds + defDim(ds, "lon", Nx) + defDim(ds, "lat", Ny) + + lon_var = defVar(ds, "lon", Float64, ("lon",); + attrib = ["units" => "degrees_east", + "long_name" => "longitude"]) + lat_var = defVar(ds, "lat", Float64, ("lat",); + attrib = ["units" => "degrees_north", + "long_name" => "latitude"]) + z_var = defVar(ds, "z", Float32, ("lon", "lat"); + attrib = ["long_name" => "elevation", + "units" => "m"]) + + lon_var[:] = range(-180 + 0.005, 180 - 0.005; length=Nx) + lat_var[:] = range(64 + 0.005, 90 - 0.005; length=Ny) + z_var[:, :] = data + end + end + end + + return nothing +end + +end # module NumericalEarthArchGDALExt diff --git a/ext/NumericalEarthCDSAPIExt.jl b/ext/NumericalEarthCDSAPIExt.jl index 8232e6c77..dc0303474 100644 --- a/ext/NumericalEarthCDSAPIExt.jl +++ b/ext/NumericalEarthCDSAPIExt.jl @@ -2,30 +2,142 @@ module NumericalEarthCDSAPIExt using NumericalEarth using CDSAPI +using NCDatasets using Oceananigans using Oceananigans.DistributedComputations: @root using Dates -using NumericalEarth.DataWrangling.ERA5: ERA5Metadata, ERA5Metadatum, ERA5_dataset_variable_names +using NumericalEarth.DataWrangling.ERA5: ERA5Dataset, ERA5Metadata, ERA5Metadatum, + ERA5_dataset_variable_names, ERA5_netcdf_variable_names +using NumericalEarth.DataWrangling.ERA5: ERA5PressureLevelsDataset, + ERA5PressureMetadata, ERA5PressureMetadatum, + ERA5PL_dataset_variable_names, ERA5PL_netcdf_variable_names import NumericalEarth.DataWrangling: download_dataset +##### +##### Dispatch helpers — encapsulate single-level vs pressure-level differences +##### + +cds_product(::ERA5Dataset) = "reanalysis-era5-single-levels" +cds_product(::ERA5PressureLevelsDataset) = "reanalysis-era5-pressure-levels" + +cds_varnames(::ERA5Dataset) = ERA5_dataset_variable_names +cds_varnames(::ERA5PressureLevelsDataset) = ERA5PL_dataset_variable_names + +nc_varnames(::ERA5Dataset) = ERA5_netcdf_variable_names +nc_varnames(::ERA5PressureLevelsDataset) = ERA5PL_netcdf_variable_names + +# Coordinate / dimension variables to propagate into each split file +const ERA5_COORD_VARS = Set(["longitude", "latitude", + "time", "valid_time", + "expver", "number"]) + +const ERA5PL_COORD_VARS = Set(["longitude", "latitude", + "pressure_level", "level", + "time", "valid_time", + "expver", "number"]) + +coord_vars(::ERA5Dataset) = ERA5_COORD_VARS +coord_vars(::ERA5PressureLevelsDataset) = ERA5PL_COORD_VARS + +extra_request_keys!(request, ::ERA5Dataset) = nothing +function extra_request_keys!(request, ds::ERA5PressureLevelsDataset) + p_hPa = [round(Int, p * 1e-2) for p in ds.pressure_levels] + request["pressure_level"] = [string(p) for p in p_hPa] +end + +##### +##### CDS request construction — pure, network-free +##### + """ - download_dataset(metadata::ERA5Metadata; kwargs...) + build_era5_request(name_or_names, dataset, datetimes; region) -> Dict{String, Any} + +Construct the CDS API request dictionary for a single calendar day's worth of ERA5 data. + +`name_or_names` is a `Symbol` or `Vector{Symbol}` of internal variable names. `datetimes` +is a single `DateTime` or a vector of `DateTime`s; all entries must share the same +calendar day (year/month/day are taken from the first entry, and one `time` string is +emitted per entry in input order). `region` is `nothing`, a `BoundingBox`, or a `Column`. -Download ERA5 data for each date in the metadata, returning paths to downloaded files. +The returned dictionary always uses zero-padded month/day/hour strings, sets the `area` +key only when `region` produces one, and adds dataset-specific extras (e.g. +`pressure_level` for pressure-level datasets). """ -function download_dataset(metadata::ERA5Metadata; kwargs...) - paths = Array{String}(undef, length(metadata)) - for (m, metadatum) in enumerate(metadata) - paths[m] = download_dataset(metadatum; kwargs...) +function build_era5_request(name_or_names, dataset, datetimes; region) + names = name_or_names isa Symbol ? [name_or_names] : name_or_names + cds_vars = unique([cds_varnames(dataset)[n] for n in names]) + + dts = datetimes isa AbstractVector ? datetimes : [datetimes] + + dt0 = first(dts) + year = string(Dates.year(dt0)) + month = lpad(string(Dates.month(dt0)), 2, '0') + day = lpad(string(Dates.day(dt0)), 2, '0') + + hours_str = [lpad(string(Dates.hour(dt)), 2, '0') * ":00" for dt in dts] + + request = Dict{String, Any}( + "product_type" => ["reanalysis"], + "variable" => cds_vars, + "year" => [year], + "month" => [month], + "day" => [day], + "time" => hours_str, + "data_format" => "netcdf", + "download_format" => "unarchived", + ) + + extra_request_keys!(request, dataset) + + area = build_era5_area(region) + isnothing(area) || (request["area"] = area) + + return request +end + +##### +##### ZIP detection — CDS returns a ZIP when mixing step types (inst/accum/avg) +##### + +const ZIP_MAGIC = UInt8[0x50, 0x4b, 0x03, 0x04] + +function is_zip(path) + open(path, "r") do io + magic = read(io, 4) + return length(magic) >= 4 && magic == ZIP_MAGIC end - return paths end """ - download_dataset(meta::ERA5Metadatum; skip_existing=true, kwargs...) + foreach_nc(f, download_path, cleanup_dir) + +If `download_path` is a ZIP archive (as CDS returns when mixing variable step types), +extract all NetCDF files and call `f(nc_path)` on each. Otherwise call `f` directly +on `download_path`. +""" +function foreach_nc(f, download_path, cleanup_dir) + if is_zip(download_path) + tmp_dir = mktempdir(cleanup_dir) + run(`unzip -qo $download_path -d $tmp_dir`) + nc_files = filter(p -> endswith(p, ".nc"), readdir(tmp_dir; join=true)) + for nc_file in nc_files + f(nc_file) + end + rm(tmp_dir; recursive=true, force=true) + else + f(download_path) + end +end + +##### +##### Single-date download +##### + +""" + download_dataset(meta::ERA5Metadatum; skip_existing=true) Download ERA5 data for a single date/time using the CDSAPI package. @@ -41,53 +153,422 @@ Before downloading, you must: See https://cds.climate.copernicus.eu/how-to-api for details. """ function download_dataset(meta::ERA5Metadatum; skip_existing=true) + output_path = NumericalEarth.DataWrangling.metadata_path(meta) + + # Skip download if file already exists + skip_existing && isfile(output_path) && return output_path + + mkpath(dirname(output_path)) + + request = build_era5_request(meta.name, meta.dataset, meta.dates; region=meta.region) + + @root CDSAPI.retrieve(cds_product(meta.dataset), request, output_path) + + return output_path +end + +##### +##### Multi-date download — batches by calendar day +##### + +function download_dataset(metadata::ERA5Metadata; skip_existing=true, cleanup=true) + dates = metadata.dates isa AbstractVector ? metadata.dates : [metadata.dates] + grouped = _group_by_calendar_day(dates) + + paths = String[] + for day in sort(collect(keys(grouped))) + path = download_era5_day(metadata.name, metadata.dataset, grouped[day]; + region = metadata.region, + dir = metadata.dir, + skip_existing, cleanup) + append!(paths, path) + end + + return paths +end + +""" + _group_by_calendar_day(datetimes) + +Group an iterable of `DateTime`s by calendar day. Returns a `Dict{Date, Vector}` +where each value is the subset of `datetimes` whose calendar day equals the key. +The `00:00` instant of a day belongs to that day (not the previous one). +""" +function _group_by_calendar_day(datetimes) + return Dict(d => filter(dt -> Dates.Date(dt) == d, datetimes) + for d in unique(Dates.Date.(datetimes))) +end + +""" + plan_era5_day(name, dataset, day_dates; region, dir, skip_existing) -> NamedTuple + +Pure planner for a single-variable, single-day ERA5 download. Computes the per-datetime +output paths, filters to the subset that needs downloading, and (when there is work to +do) builds the CDS request, the temporary download path, and the NetCDF splitting +triples. No I/O beyond `isfile` checks; no network. + +Returned NamedTuple fields: +- `dt_path_pairs`: every `(datetime, path)` pair the caller should report. +- `pending`: subset of `dt_path_pairs` that still need a download. +- `request`, `tmp_path`, `nc_triples`: `nothing` when `pending` is empty; otherwise the + CDS request dict, the temporary multi-step NetCDF path, and the per-datetime split + triples consumed by `split_era5_nc_multistep`. +""" +function plan_era5_day(name, dataset, day_dates; region, dir, skip_existing) + meta_filename = NumericalEarth.DataWrangling.metadata_filename + + dt_path_pairs = [(dt, joinpath(dir, meta_filename(dataset, name, dt, region))) + for dt in day_dates] + + pending = if skip_existing + filter(dt_path -> !isfile(dt_path[2]), dt_path_pairs) + else + dt_path_pairs + end + + if isempty(pending) + return (; dt_path_pairs, pending, + request=nothing, tmp_path=nothing, nc_triples=nothing) + end + + sorted_dts = sort(unique([dt for (dt, _) in pending])) + dt_to_tidx = Dict(dt => i for (i, dt) in enumerate(sorted_dts)) + + request = build_era5_request(name, dataset, sorted_dts; region) + + dt0 = first(sorted_dts) + year = string(Dates.year(dt0)) + month = lpad(string(Dates.month(dt0)), 2, '0') + day = lpad(string(Dates.day(dt0)), 2, '0') + + tmp_path = joinpath(dir, "_tmp_$(year)$(month)$(day).nc") + nc_varname = nc_varnames(dataset)[name] + nc_triples = [(nc_varname, dt_to_tidx[dt], path) for (dt, path) in pending] + + return (; dt_path_pairs, pending, request, tmp_path, nc_triples) +end + +function download_era5_day(name, dataset, day_dates; + region, dir, skip_existing, cleanup) + + plan = plan_era5_day(name, dataset, day_dates; region, dir, skip_existing) + isempty(plan.pending) && return map(dt_path -> dt_path[2], plan.dt_path_pairs) + + mkpath(dir) + time_dimnames = Set(["time", "valid_time"]) + + @root begin + CDSAPI.retrieve(cds_product(dataset), plan.request, plan.tmp_path) + foreach_nc(plan.tmp_path, dir) do nc_path + split_era5_nc_multistep(nc_path, plan.nc_triples, coord_vars(dataset), time_dimnames) + end + cleanup && rm(plan.tmp_path; force=true) + end + + return map(dt_path -> dt_path[2], plan.dt_path_pairs) +end + +##### +##### Multi-variable ERA5 pressure-level download +##### + +""" + download_dataset(names::Vector{Symbol}, metadata::ERA5PressureMetadata; kwargs...) + +Download multiple ERA5 pressure-level variables for each date in `metadata`. +""" +function download_dataset(names::Vector{Symbol}, metadata::ERA5PressureMetadata; kwargs...) + paths = String[] + for metadatum in metadata + append!(paths, download_dataset(names, metadatum; kwargs...)) + end + return paths +end - output_directory = meta.dir - output_filename = meta.filename - output_path = joinpath(output_directory, output_filename) +""" + download_dataset(names::Vector{Symbol}, meta::ERA5PressureMetadatum; skip_existing=true) - # Skip if file already exists - if skip_existing && isfile(output_path) - return output_path +Download multiple ERA5 pressure-level variables for a single date in one CDS API request. +The multi-variable NetCDF is split into individual per-variable files. +""" +function download_dataset(names::Vector{Symbol}, meta::ERA5PressureMetadatum; skip_existing=true) + name_path_pairs = [] + for name in names + metadatum = NumericalEarth.DataWrangling.Metadatum(name; + dataset = meta.dataset, + region = meta.region, + date = meta.dates, + dir = meta.dir) + path = NumericalEarth.DataWrangling.metadata_path(metadatum) + push!(name_path_pairs, (name, path)) end - # Ensure output directory exists - mkpath(output_directory) + pending = if skip_existing + filter(name_path -> !isfile(name_path[2]), name_path_pairs) + else + name_path_pairs + end - # Get the ERA5 variable name - variable_name = ERA5_dataset_variable_names[meta.name] + isempty(pending) && return map(name_path -> name_path[2], name_path_pairs) - # Extract date information - date = meta.dates + pending_names = [name for (name, _) in pending] + request = build_era5_request(pending_names, meta.dataset, meta.dates; region=meta.region) + + date = meta.dates year = string(Dates.year(date)) month = lpad(string(Dates.month(date)), 2, '0') - day = lpad(string(Dates.day(date)), 2, '0') - hour = lpad(string(Dates.hour(date)), 2, '0') * ":00" + day = lpad(string(Dates.day(date)), 2, '0') + hour = lpad(string(Dates.hour(date)), 2, '0') * ":00" - # Build request parameters - request = Dict( - "product_type" => ["reanalysis"], - "variable" => [variable_name], - "year" => [year], - "month" => [month], - "day" => [day], - "time" => [hour], - "data_format" => "netcdf", - "download_format" => "unarchived", - ) + mkpath(meta.dir) + tmp_path = joinpath(meta.dir, "_tmp_multi_$(year)$(month)$(day)T$(hour[1:2]).nc") + + nc_name_path_pairs = [(nc_varnames(meta.dataset)[name], path) for (name, path) in pending] - # Add area constraint from bounding box - area = build_era5_area(meta.region) - if !isnothing(area) - request["area"] = area + @root begin + CDSAPI.retrieve(cds_product(meta.dataset), request, tmp_path) + foreach_nc(tmp_path, meta.dir) do nc_path + split_era5_nc(nc_path, nc_name_path_pairs, coord_vars(meta.dataset)) + end + rm(tmp_path; force=true) end - # Perform the download using CDSAPI + return map(name_path -> name_path[2], name_path_pairs) +end + +""" + download_dataset(names, dataset::ERA5Dataset, datetime; ...) + +Download one or more ERA5 variables at a single datetime. +""" +function download_dataset(names::Vector{Symbol}, dataset::ERA5Dataset, datetime; + region = nothing, + dir = NumericalEarth.DataWrangling.default_download_directory(dataset)) + meta = NumericalEarth.DataWrangling.Metadatum(first(names); dataset, date=datetime, region, dir) + return download_dataset(names, meta) +end + +function download_dataset(name::Symbol, dataset::ERA5Dataset, datetime; + region = nothing, + dir = NumericalEarth.DataWrangling.default_download_directory(dataset)) + return download_dataset([name], dataset, datetime; region, dir) +end + +""" + download_dataset(names, dataset::ERA5Dataset, datetimes::AbstractVector; ...) + +Download one or more ERA5 variables for multiple datetimes, batching by calendar day. +""" +function download_dataset(names::Vector{Symbol}, + dataset::ERA5Dataset, + datetimes::AbstractVector; + region = nothing, + dir = NumericalEarth.DataWrangling.default_download_directory(dataset), + skip_existing = true, + cleanup = true) + + grouped = _group_by_calendar_day(datetimes) + + paths = String[] + for day in sort(collect(keys(grouped))) + path = download_era5_multivar_day(names, dataset, grouped[day]; + region, dir, skip_existing, cleanup) + append!(paths, path) + end + + return paths +end + +function download_dataset(name::Symbol, + dataset::ERA5Dataset, + datetimes::AbstractVector; + region = nothing, + dir = NumericalEarth.DataWrangling.default_download_directory(dataset), + skip_existing = true, + cleanup = true) + return download_dataset([name], dataset, datetimes; region, dir, skip_existing, cleanup) +end + +""" + plan_era5_multivar_day(names, dataset, day_dates; region, dir, skip_existing) -> NamedTuple + +Pure planner for a multi-variable, single-day ERA5 download. Same shape as +[`plan_era5_day`](@ref), but indexed by `(name, datetime, path)` triples so each split +file is identified by both the variable name and the timestep. + +Returned NamedTuple fields: +- `name_dt_paths`: every `(name, datetime, path)` triple the caller should report. +- `pending`: subset that still needs a download. +- `request`, `tmp_path`, `nc_triples`: `nothing` when `pending` is empty; otherwise the + CDS request dict, the temporary multi-step NetCDF path, and the per-(name, time) split + triples consumed by `split_era5_nc_multistep`. +""" +function plan_era5_multivar_day(names, dataset, day_dates; region, dir, skip_existing) + meta_filename = NumericalEarth.DataWrangling.metadata_filename + + name_dt_paths = [(name, dt, joinpath(dir, meta_filename(dataset, name, dt, region))) + for name in names for dt in day_dates] + + pending = if skip_existing + filter(name_dt_path -> !isfile(name_dt_path[3]), name_dt_paths) + else + name_dt_paths + end + + if isempty(pending) + return (; name_dt_paths, pending, + request=nothing, tmp_path=nothing, nc_triples=nothing) + end + + pending_names = unique(map(name_dt_path -> name_dt_path[1], pending)) + sorted_dts = sort(unique(map(name_dt_path -> name_dt_path[2], pending))) + dt_to_tidx = Dict(dt => i for (i, dt) in enumerate(sorted_dts)) + + request = build_era5_request(pending_names, dataset, sorted_dts; region) + + dt0 = first(sorted_dts) + year = string(Dates.year(dt0)) + month = lpad(string(Dates.month(dt0)), 2, '0') + day = lpad(string(Dates.day(dt0)), 2, '0') + + tmp_path = joinpath(dir, "_tmp_multi_$(year)$(month)$(day).nc") + nc_triples = [(nc_varnames(dataset)[name], dt_to_tidx[dt], path) + for (name, dt, path) in pending] + + return (; name_dt_paths, pending, request, tmp_path, nc_triples) +end + +function download_era5_multivar_day(names, dataset, day_dates; + region, dir, skip_existing, cleanup) + + plan = plan_era5_multivar_day(names, dataset, day_dates; region, dir, skip_existing) + isempty(plan.pending) && return map(name_dt_path -> name_dt_path[3], plan.name_dt_paths) + + mkpath(dir) + time_dimnames = Set(["time", "valid_time"]) + @root begin - CDSAPI.retrieve("reanalysis-era5-single-levels", request, output_path) + CDSAPI.retrieve(cds_product(dataset), plan.request, plan.tmp_path) + foreach_nc(plan.tmp_path, dir) do nc_path + split_era5_nc_multistep(nc_path, plan.nc_triples, coord_vars(dataset), time_dimnames) + end + cleanup && rm(plan.tmp_path; force=true) end - return output_path + return map(name_dt_path -> name_dt_path[3], plan.name_dt_paths) +end + +##### +##### NetCDF splitting utilities +##### + +""" + split_era5_nc(src_path, nc_name_path_pairs, coord_vars) + +Split a multi-variable NetCDF into individual per-variable files (single time step). +""" +function split_era5_nc(src_path, nc_name_path_pairs, coord_vars) + NCDatasets.Dataset(src_path, "r") do src + src_varnames = Set(keys(src)) + for (nc_varname, dst_path) in nc_name_path_pairs + nc_varname in src_varnames || continue + NCDatasets.Dataset(dst_path, "c") do dst + unlimited = NCDatasets.unlimited(src) + for (dname, dlen) in src.dim + NCDatasets.defDim(dst, dname, dname in unlimited ? Inf : dlen) + end + + for (k, v) in src.attrib + dst.attrib[k] = v + end + + for (vname, var) in src + (vname in coord_vars || vname == nc_varname) || continue + ncvar_copy!(dst, var, vname) + end + end + end + end +end + +""" + split_era5_nc_multistep(src_path, triples, coord_vars, time_dimnames) + +Split a multi-timestep NetCDF into individual per-variable, per-timestep files. +`triples` is a vector of `(nc_varname, time_index, dst_path)`. +""" +function split_era5_nc_multistep(src_path, nc_varname_tidx_path_triples, coord_vars, time_dimnames) + NCDatasets.Dataset(src_path, "r") do src + src_varnames = Set(keys(src)) + unlimited = NCDatasets.unlimited(src) + + for (nc_varname, tidx, dst_path) in nc_varname_tidx_path_triples + nc_varname in src_varnames || continue + NCDatasets.Dataset(dst_path, "c") do dst + for (dname, dlen) in src.dim + out_len = dname in time_dimnames ? 1 : + dname in unlimited ? Inf : dlen + NCDatasets.defDim(dst, dname, out_len) + end + + for (k, v) in src.attrib + dst.attrib[k] = v + end + + for (vname, var) in src + (vname in coord_vars || vname == nc_varname) || continue + ncvar_copy_tslice!(dst, var, vname, tidx, time_dimnames) + end + end + end + end +end + +function ncvar_copy!(dst, src_var, vname) + dims = NCDatasets.dimnames(src_var) + T = eltype(src_var.var) + attribs = src_var.attrib + fill_val = haskey(attribs, "_FillValue") ? attribs["_FillValue"] : nothing + + dst_var = isnothing(fill_val) ? + NCDatasets.defVar(dst, vname, T, dims) : + NCDatasets.defVar(dst, vname, T, dims; fillvalue=fill_val) + + for (k, v) in attribs + k == "_FillValue" && continue + dst_var.attrib[k] = v + end + + dst_var.var[:] = src_var.var[:] + return nothing +end + +function ncvar_copy_tslice!(dst, src_var, vname, tidx, time_dimnames) + dims = NCDatasets.dimnames(src_var) + T = eltype(src_var.var) + attribs = src_var.attrib + fill_val = haskey(attribs, "_FillValue") ? attribs["_FillValue"] : nothing + + dst_var = isnothing(fill_val) ? + NCDatasets.defVar(dst, vname, T, dims) : + NCDatasets.defVar(dst, vname, T, dims; fillvalue=fill_val) + + for (k, v) in attribs + k == "_FillValue" && continue + dst_var.attrib[k] = v + end + + has_time = any(d -> d in time_dimnames, dims) + if has_time + idx = ntuple(ndims(src_var.var)) do i + dims[i] in time_dimnames ? (tidx:tidx) : Colon() + end + dst_var.var[:] = src_var.var[idx...] + else + dst_var.var[:] = src_var.var[:] + end + + return nothing end ##### @@ -97,11 +578,11 @@ end build_era5_area(::Nothing) = nothing const BBOX = NumericalEarth.DataWrangling.BoundingBox +const COL = NumericalEarth.DataWrangling.Column +const LIN = NumericalEarth.DataWrangling.Linear +const NR = NumericalEarth.DataWrangling.Nearest function build_era5_area(bbox::BBOX) - # CDS API uses [north, west, south, east] ordering - # BoundingBox has longitude = (west, east), latitude = (south, north) - lon = bbox.longitude lat = bbox.latitude @@ -117,4 +598,19 @@ function build_era5_area(bbox::BBOX) return [north, west, south, east] end +# Column with Nearest interpolation: tight box; CDS returns the nearest cell. +function build_era5_area(col::COL{<:Any, <:Any, <:Any, <:NR}) + lon, lat = col.longitude, col.latitude + ε = 1e-3 + return [lat + ε, lon - ε, lat - ε, lon + ε] # [N, W, S, E] +end + +# Column with Linear interpolation: pad by slightly more than ERA5's native +# 0.25° spacing so the file contains the 2x2 stencil bilinear interp needs. +function build_era5_area(col::COL{<:Any, <:Any, <:Any, <:LIN}) + lon, lat = col.longitude, col.latitude + ε = 0.3 + return [lat + ε, lon - ε, lat - ε, lon + ε] +end + end # module NumericalEarthCDSAPIExt diff --git a/ext/NumericalEarthReactantExt.jl b/ext/NumericalEarthReactantExt.jl index 3a5ccab54..93c3a6f06 100644 --- a/ext/NumericalEarthReactantExt.jl +++ b/ext/NumericalEarthReactantExt.jl @@ -13,9 +13,9 @@ const OceananigansReactantExt = Base.get_extension( Oceananigans, :OceananigansReactantExt ) -const ReactantOSIM{I, A, L, O, F, C} = Union{ - EarthSystemModel{I, A, L, O, F, C, <:ReactantState}, - EarthSystemModel{I, A, L, O, F, C, <:Distributed{ReactantState}}, +const ReactantOSIM{R, A, L, I, O, F, C} = Union{ + EarthSystemModel{R, A, L, I, O, F, C, <:ReactantState}, + EarthSystemModel{R, A, L, I, O, F, C, <:Distributed{ReactantState}}, } reconcile_state!(model::ReactantOSIM) = nothing diff --git a/ext/NumericalEarthWOAExt.jl b/ext/NumericalEarthWOAExt.jl index 13d7a0b6c..d65e6a93b 100644 --- a/ext/NumericalEarthWOAExt.jl +++ b/ext/NumericalEarthWOAExt.jl @@ -54,7 +54,7 @@ function download_dataset(metadata::Metadata{<:WOAClimatology}; skip_existing=tr cp(source, linkpath) end - return nothing + return metadata_path(metadata) end end # module diff --git a/src/Atmospheres/Atmospheres.jl b/src/Atmospheres/Atmospheres.jl index 557339ec7..c7aef6a37 100644 --- a/src/Atmospheres/Atmospheres.jl +++ b/src/Atmospheres/Atmospheres.jl @@ -21,7 +21,8 @@ import NumericalEarth.EarthSystemModels: interpolate_state!, update_net_fluxes!, thermodynamics_parameters, surface_layer_height, - boundary_layer_height + boundary_layer_height, + is_prescribed_atmosphere import NumericalEarth.EarthSystemModels.InterfaceComputations: ComponentExchanger, initialize!, net_fluxes @@ -34,5 +35,6 @@ include("prescribed_atmosphere_regridder.jl") include("interpolate_atmospheric_state.jl") net_fluxes(::PrescribedAtmosphere) = nothing +is_prescribed_atmosphere(::PrescribedAtmosphere) = true end # module Atmospheres diff --git a/src/Atmospheres/interpolate_atmospheric_state.jl b/src/Atmospheres/interpolate_atmospheric_state.jl index e0c8248a7..6f8d6679a 100644 --- a/src/Atmospheres/interpolate_atmospheric_state.jl +++ b/src/Atmospheres/interpolate_atmospheric_state.jl @@ -27,9 +27,6 @@ function interpolate_state!(exchanger, grid, atmosphere::PrescribedAtmosphere, c atmosphere_tracers = (T = atmosphere.tracers.T.data, q = atmosphere.tracers.q.data) - ℐꜜˢʷ = atmosphere.downwelling_radiation.shortwave - ℐꜜˡʷ = atmosphere.downwelling_radiation.longwave - downwelling_radiation = (shortwave=ℐꜜˢʷ.data, longwave=ℐꜜˡʷ.data) freshwater_flux = map(ϕ -> ϕ.data, atmosphere.freshwater_flux) snowfall_flux = haskey(atmosphere.freshwater_flux, :snow) ? atmosphere.freshwater_flux.snow.data : nothing atmosphere_pressure = atmosphere.pressure.data @@ -45,15 +42,13 @@ function interpolate_state!(exchanger, grid, atmosphere::PrescribedAtmosphere, c # Simplify NamedTuple to reduce parameter space consumption. # See https://github.com/CliMA/NumericalEarth.jl/issues/116. - atmosphere_data = (u = atmosphere_fields.u.data, - v = atmosphere_fields.v.data, - T = atmosphere_fields.T.data, - p = atmosphere_fields.p.data, - q = atmosphere_fields.q.data, - ℐꜜˢʷ = atmosphere_fields.ℐꜜˢʷ.data, - ℐꜜˡʷ = atmosphere_fields.ℐꜜˡʷ.data, - Jᶜ = atmosphere_fields.Jᶜ.data, - Jˢⁿ = atmosphere_fields.Jˢⁿ.data) + atmosphere_data = (u = atmosphere_fields.u.data, + v = atmosphere_fields.v.data, + T = atmosphere_fields.T.data, + p = atmosphere_fields.p.data, + q = atmosphere_fields.q.data, + Jᶜ = atmosphere_fields.Jᶜ.data, + Jˢⁿ = atmosphere_fields.Jˢⁿ.data) kernel_parameters = interface_kernel_parameters(grid) @@ -74,7 +69,6 @@ function interpolate_state!(exchanger, grid, atmosphere::PrescribedAtmosphere, c atmosphere_velocities, atmosphere_tracers, atmosphere_pressure, - downwelling_radiation, freshwater_flux, snowfall_flux, atmosphere_backend, @@ -102,7 +96,6 @@ end atmos_velocities, atmos_tracers, atmos_pressure, - downwelling_radiation, prescribed_freshwater_flux, snowfall_flux, atmos_backend, @@ -125,9 +118,6 @@ end qᵃᵗ = interp_atmos_time_series(atmos_tracers.q, atmos_args...) pᵃᵗ = interp_atmos_time_series(atmos_pressure, atmos_args...) - ℐꜜˢʷ = interp_atmos_time_series(downwelling_radiation.shortwave, atmos_args...) - ℐꜜˡʷ = interp_atmos_time_series(downwelling_radiation.longwave, atmos_args...) - # Total precipitation (rain + snow) Mh = interp_atmos_time_series(prescribed_freshwater_flux, atmos_args...) @@ -145,8 +135,6 @@ end surface_atmos_state.T[i, j, 1] = Tᵃᵗ surface_atmos_state.p[i, j, 1] = pᵃᵗ surface_atmos_state.q[i, j, 1] = qᵃᵗ - surface_atmos_state.ℐꜜˢʷ[i, j, 1] = ℐꜜˢʷ - surface_atmos_state.ℐꜜˡʷ[i, j, 1] = ℐꜜˡʷ surface_atmos_state.Jᶜ[i, j, 1] = Mh surface_atmos_state.Jˢⁿ[i, j, 1] = Ms end diff --git a/src/Atmospheres/prescribed_atmosphere.jl b/src/Atmospheres/prescribed_atmosphere.jl index b753a506e..bbb5158ca 100644 --- a/src/Atmospheres/prescribed_atmosphere.jl +++ b/src/Atmospheres/prescribed_atmosphere.jl @@ -2,14 +2,13 @@ ##### Prescribed atmosphere (as opposed to dynamically evolving / prognostic) ##### -mutable struct PrescribedAtmosphere{FT, G, T, U, P, C, F, R, TP, TI} +mutable struct PrescribedAtmosphere{FT, G, T, U, P, C, F, TP, TI} grid :: G clock :: Clock{T} velocities :: U pressure :: P tracers :: C freshwater_flux :: F - downwelling_radiation :: R thermodynamics_parameters :: TP times :: TI surface_layer_height :: FT @@ -43,12 +42,6 @@ function default_atmosphere_tracers(grid, times) return (T=Ta, q=qa) end -function default_downwelling_radiation(grid, times) - ℐꜜˡʷ = FieldTimeSeries{Center, Center, Nothing}(grid, times) - ℐꜜˢʷ = FieldTimeSeries{Center, Center, Nothing}(grid, times) - return TwoBandDownwellingRadiation(shortwave=ℐꜜˢʷ, longwave=ℐꜜˡʷ) -end - function default_freshwater_flux(grid, times) rain = FieldTimeSeries{Center, Center, Nothing}(grid, times) snow = FieldTimeSeries{Center, Center, Nothing}(grid, times) @@ -93,27 +86,29 @@ update_net_fluxes!(coupled_model, ::PrescribedAtmosphere) = nothing PrescribedAtmosphere(grid, times=[zero(grid)]; clock = Clock{Float64}(time = 0), surface_layer_height = 10, # meters - boundary_layer_height = 512 # meters, + boundary_layer_height = 512, # meters thermodynamics_parameters = AtmosphereThermodynamicsParameters(eltype(grid)), - velocities = default_atmosphere_velocities(grid, times), - tracers = default_atmosphere_tracers(grid, times), - pressure = default_atmosphere_pressure(grid, times), - freshwater_flux = default_freshwater_flux(grid, times), - downwelling_radiation = default_downwelling_radiation(grid, times)) + velocities = default_atmosphere_velocities(grid, times), + tracers = default_atmosphere_tracers(grid, times), + pressure = default_atmosphere_pressure(grid, times), + freshwater_flux = default_freshwater_flux(grid, times)) Return a representation of a prescribed time-evolving atmospheric state with data given at `times`. + +Note: downwelling shortwave / longwave radiation is now part of the +top-level `radiation` component (see `PrescribedRadiation`, +`JRA55PrescribedRadiation`). """ function PrescribedAtmosphere(grid, times=[zero(grid)]; clock = Clock{Float64}(time = 0), surface_layer_height = 10, boundary_layer_height = 512, thermodynamics_parameters = AtmosphereThermodynamicsParameters(eltype(grid)), - velocities = default_atmosphere_velocities(grid, times), - tracers = default_atmosphere_tracers(grid, times), - pressure = default_atmosphere_pressure(grid, times), - freshwater_flux = default_freshwater_flux(grid, times), - downwelling_radiation = default_downwelling_radiation(grid, times)) + velocities = default_atmosphere_velocities(grid, times), + tracers = default_atmosphere_tracers(grid, times), + pressure = default_atmosphere_pressure(grid, times), + freshwater_flux = default_freshwater_flux(grid, times)) FT = eltype(grid) if isnothing(thermodynamics_parameters) @@ -126,7 +121,6 @@ function PrescribedAtmosphere(grid, times=[zero(grid)]; pressure, tracers, freshwater_flux, - downwelling_radiation, thermodynamics_parameters, times, convert(FT, surface_layer_height), @@ -136,36 +130,17 @@ function PrescribedAtmosphere(grid, times=[zero(grid)]; return atmosphere end -struct TwoBandDownwellingRadiation{SW, LW} - shortwave :: SW - longwave :: LW -end - -""" - TwoBandDownwellingRadiation(shortwave=nothing, longwave=nothing) - -Return a two-band model for downwelling radiation (split into a shortwave band -and a longwave band) that passes through the atmosphere and arrives at the surface of ocean -or sea ice. -""" -TwoBandDownwellingRadiation(; shortwave=nothing, longwave=nothing) = - TwoBandDownwellingRadiation(shortwave, longwave) - -Adapt.adapt_structure(to, tsdr::TwoBandDownwellingRadiation) = - TwoBandDownwellingRadiation(adapt(to, tsdr.shortwave), - adapt(to, tsdr.longwave)) - ##### ##### Chekpointing ##### import Oceananigans: prognostic_state, restore_prognostic_state! -function prognostic_state(atmos::PrescribedAtmosphere) +function prognostic_state(atmos::PrescribedAtmosphere) return (; clock = prognostic_state(atmos.clock)) end -function restore_prognostic_state!(atmos::PrescribedAtmosphere, state) +function restore_prognostic_state!(atmos::PrescribedAtmosphere, state) restore_prognostic_state!(atmos.clock, state.clock) update_state!(atmos) return atmos diff --git a/src/Atmospheres/prescribed_atmosphere_regridder.jl b/src/Atmospheres/prescribed_atmosphere_regridder.jl index 2c1a8aec9..e1d5a797b 100644 --- a/src/Atmospheres/prescribed_atmosphere_regridder.jl +++ b/src/Atmospheres/prescribed_atmosphere_regridder.jl @@ -2,15 +2,13 @@ function ComponentExchanger(atmosphere::PrescribedAtmosphere, grid) regridder = atmosphere_regridder(atmosphere, grid) - state = (; u = Field{Center, Center, Nothing}(grid), - v = Field{Center, Center, Nothing}(grid), - T = Field{Center, Center, Nothing}(grid), - p = Field{Center, Center, Nothing}(grid), - q = Field{Center, Center, Nothing}(grid), - ℐꜜˢʷ = Field{Center, Center, Nothing}(grid), - ℐꜜˡʷ = Field{Center, Center, Nothing}(grid), - Jᶜ = Field{Center, Center, Nothing}(grid), - Jˢⁿ = Field{Center, Center, Nothing}(grid)) + state = (; u = Field{Center, Center, Nothing}(grid), + v = Field{Center, Center, Nothing}(grid), + T = Field{Center, Center, Nothing}(grid), + p = Field{Center, Center, Nothing}(grid), + q = Field{Center, Center, Nothing}(grid), + Jᶜ = Field{Center, Center, Nothing}(grid), + Jˢⁿ = Field{Center, Center, Nothing}(grid)) return ComponentExchanger(state, regridder) end diff --git a/src/Bathymetry/Bathymetry.jl b/src/Bathymetry/Bathymetry.jl index c290a7f44..9dabad723 100644 --- a/src/Bathymetry/Bathymetry.jl +++ b/src/Bathymetry/Bathymetry.jl @@ -19,7 +19,8 @@ using NCDatasets using Printf using Scratch -using ..DataWrangling: Metadatum, native_grid, metadata_path, download_dataset +using ..DataWrangling: Metadatum, native_grid, metadata_path, download_dataset, + dataset_variable_name, validate_dataset_coverage using ..DataWrangling.ETOPO: ETOPO2022 include("regrid_bathymetry.jl") diff --git a/src/Bathymetry/orca_grid.jl b/src/Bathymetry/orca_grid.jl index a0f6e7211..ce5cec635 100644 --- a/src/Bathymetry/orca_grid.jl +++ b/src/Bathymetry/orca_grid.jl @@ -191,9 +191,7 @@ function reconstruct_orca_mesh_from_CC_FF_points(λCC, φCC, λFF, φFF; radius) φCF = similar(φCC, AFT) dev = Oceananigans.Architectures.device(architecture(λFC)) - launch_xy = KernelParameters(1:Nx, 1:Ny) - - _reconstruct_λFC_φFC_λCF_φCF!(dev, (Nx, Ny), (16, 16))(λFC, φFC, λCF, φCF, λCC, φCC, λFFₒ, φFFₒ, Nx, Ny) + _reconstruct_λFC_φFC_λCF_φCF!(dev, (16, 16), (Nx, Ny))(λFC, φFC, λCF, φCF, λCC, φCC, λFFₒ, φFFₒ, Nx, Ny) e1u = similar(λCC, AFT) e2u = similar(λCC, AFT) @@ -204,7 +202,7 @@ function reconstruct_orca_mesh_from_CC_FF_points(λCC, φCC, λFF, φFF; radius) e1t = similar(λCC, AFT) e2t = similar(λCC, AFT) - _reconstruct_e1_e2_metrics!(dev, (Nx, Ny), (16, 16))(e1u, e1v, e1f, e1t, e2u, e2v, e2f, e2t, λCC, φCC, λFFₒ, φFFₒ, λFC, φFC, λCF, φCF, radius, Nx, Ny) + _reconstruct_e1_e2_metrics!(dev, (16, 16), (Nx, Ny))(e1u, e1v, e1f, e1t, e2u, e2v, e2f, e2t, λCC, φCC, λFFₒ, φFFₒ, λFC, φFC, λCF, φCF, radius, Nx, Ny) AzCC = similar(λCC, AFT) AzFC = e1u .* e2u @@ -212,8 +210,8 @@ function reconstruct_orca_mesh_from_CC_FF_points(λCC, φCC, λFF, φFF; radius) AzFF = similar(λCC, AFT) if Ny > 1 - _reconstruct_Az_interior!(dev, (Nx, Ny), (16, 16))(AzCC, AzFF, λCC, φCC, λFFₒ, φFFₒ, radius, Nx, Ny) - _fill_AzCC_boundaries!(dev, Nx, 16)(AzCC, AzFF, Ny) + _reconstruct_Az_interior!(dev, (16, 16), (Nx, Ny))(AzCC, AzFF, λCC, φCC, λFFₒ, φFFₒ, radius, Nx, Ny) + _fill_AzCC_boundaries!(dev, 16, Nx)(AzCC, AzFF, Ny) else AzCC .= e1t .* e2t AzFF .= AzCC @@ -247,18 +245,17 @@ function read_orca_staggered_mesh(ds; radius = Oceananigans.defaults.planet_radi orcaread(data, name) = orient_xy(read_2d_nemo_variable(data, name), Nx, Ny; name) shift_x(data) = shift_face_x(data, overlap) - shift_y(data) = shift_face_y(data) - shift_xy(data) = shift_y(shift_x(data)) + # Face-y: no pre-shift here; halo_filled_data does the +1 y-shift after chop. if has_all_variables(ds, metrics) - λCC, λFC, λCF, λFF = orcaread(ds, "glamt"), shift_x(orcaread(ds, "glamu")), shift_y(orcaread(ds, "glamv")), shift_xy(orcaread(ds, "glamf")) - φCC, φFC, φCF, φFF = orcaread(ds, "gphit"), shift_x(orcaread(ds, "gphiu")), shift_y(orcaread(ds, "gphiv")), shift_xy(orcaread(ds, "gphif")) - e1t, e1u, e1v, e1f = orcaread(ds, "e1t"), shift_x(orcaread(ds, "e1u")), shift_y(orcaread(ds, "e1v")), shift_xy(orcaread(ds, "e1f")) - e2t, e2u, e2v, e2f = orcaread(ds, "e2t"), shift_x(orcaread(ds, "e2u")), shift_y(orcaread(ds, "e2v")), shift_xy(orcaread(ds, "e2f")) + λCC, λFC, λCF, λFF = orcaread(ds, "glamt"), shift_x(orcaread(ds, "glamu")), orcaread(ds, "glamv"), shift_x(orcaread(ds, "glamf")) + φCC, φFC, φCF, φFF = orcaread(ds, "gphit"), shift_x(orcaread(ds, "gphiu")), orcaread(ds, "gphiv"), shift_x(orcaread(ds, "gphif")) + e1t, e1u, e1v, e1f = orcaread(ds, "e1t"), shift_x(orcaread(ds, "e1u")), orcaread(ds, "e1v"), shift_x(orcaread(ds, "e1f")) + e2t, e2u, e2v, e2f = orcaread(ds, "e2t"), shift_x(orcaread(ds, "e2u")), orcaread(ds, "e2v"), shift_x(orcaread(ds, "e2f")) if "e1e2t" in keys(ds) AzCC, AzFC = orcaread(ds, "e1e2t"), shift_x(orcaread(ds, "e1e2u")) - AzCF, AzFF = shift_y(orcaread(ds, "e1e2v")), shift_xy(orcaread(ds, "e1e2f")) + AzCF, AzFF = orcaread(ds, "e1e2v"), shift_x(orcaread(ds, "e1e2f")) else AzCC, AzFC, AzCF, AzFF = e1t .* e2t, e1u .* e2u, e1v .* e2v, e1f .* e2f end @@ -305,33 +302,35 @@ function shift_face_x(data, overlap) return data[vcat(No, 1:Nx-1), :] end -# Reindex y-face fields from NEMO to Oceananigans. -# NEMO V/F are indexed as faces north of T-row j, while Oceananigans Face-y[j] -# is treated as the south face of Center-row j. This is a -1 row shift: -# out[:, j] <- in[:, j-1] for j >= 2. -# Row 1 has no southern source row in the input, so we keep in[:, 1] and let -# halo filling / boundary-condition handling manage the exterior face. +# NEMO V/F (Ny rows) → Oceananigans Face-y (Ny+1 rows). Row 1 is zero so that +# continue_south! fills metrics there while coordinates stay at zero, matching +# the pre-refactor behavior exactly. function shift_face_y(data) Nx, Ny = size(data) - shifted = similar(data) - shifted[:, 1] .= data[:, 1] - if Ny > 1 - shifted[:, 2:Ny] .= data[:, 1:Ny-1] - end + shifted = similar(data, Nx, Ny + 1) + shifted[:, 1] .= zero(eltype(data)) + shifted[:, 2:Ny+1] .= data[:, 1:Ny] return shifted end -# Copy NEMO data into a Field on `helper_grid`, fill halos, return as OffsetArray. -# -# Data is expected to already be in Oceananigans indexing when this is called. +# Copy data into a Field on `helper_grid`, fill halos, return as OffsetArray. +# Accepts either matching row count, or Nj-1 rows for Face-y (NEMO-style: row 1 +# then gets filled by continue_south!). function halo_filled_data(data, helper_grid, bcs, LX, LY) TX, TY, _ = topology(helper_grid) Nx, Ny, _ = size(helper_grid) Ni = Base.length(LX(), TX(), Nx) - Nj = size(data, 2) + Nj = Base.length(LY(), TY(), Ny) + Nj_data = size(data, 2) field = Field{LX, LY, Center}(helper_grid; boundary_conditions = bcs) - field.data[1:Ni, 1:Nj, 1] .= data[1:Ni, 1:Nj] + if Nj_data == Nj + field.data[1:Ni, 1:Nj, 1] .= data[1:Ni, 1:Nj] + elseif LY === Face && Nj_data == Nj - 1 + field.data[1:Ni, 2:Nj, 1] .= data[1:Ni, 1:Nj-1] + else + throw(DimensionMismatch("data has $Nj_data rows but $LY field expects $Nj rows")) + end fill_halo_regions!(field) return deepcopy(dropdims(field.data, dims = 3)) diff --git a/src/Bathymetry/regrid_bathymetry.jl b/src/Bathymetry/regrid_bathymetry.jl index 430cad426..f3b7de2ab 100644 --- a/src/Bathymetry/regrid_bathymetry.jl +++ b/src/Bathymetry/regrid_bathymetry.jl @@ -186,6 +186,8 @@ function regrid_bathymetry(target_grid, metadata; major_basins = 1, cache = true) + validate_dataset_coverage(target_grid, metadata) + config = BathymetryRegridding(target_grid, metadata; height_above_water, minimum_depth, interpolation_passes, major_basins) @@ -240,7 +242,7 @@ function _regrid_bathymetry(target_grid, metadata; filepath = metadata_path(metadata) dataset = Dataset(filepath, "r") - z_data = convert(Array{FT}, dataset["z"][:, :]) + z_data = convert(Array{FT}, dataset[dataset_variable_name(metadata)][:, :]) close(dataset) if !isnothing(height_above_water) diff --git a/src/DataWrangling/DataWrangling.jl b/src/DataWrangling/DataWrangling.jl index 92fa94cd7..6db3f4853 100644 --- a/src/DataWrangling/DataWrangling.jl +++ b/src/DataWrangling/DataWrangling.jl @@ -5,12 +5,13 @@ restoring, or validation. module DataWrangling export Metadata, Metadatum, DatewiseFilename, ECCOMetadatum, EN4Metadatum, all_dates, first_date, last_date +export validate_dataset_coverage, metadata_filename export BoundingBox, Column, Linear, Nearest export WOAClimatology, WOAAnnual, WOAMonthly export metadata_time_step, metadata_epoch export LinearlyTaperedPolarMask export DatasetRestoring, SurfaceFluxRestoring -export ERA5Hourly, ERA5Monthly +export ERA5HourlySingleLevel, ERA5MonthlySingleLevel, ERA5HourlyPressureLevels, ERA5MonthlyPressureLevels export native_grid using Oceananigans @@ -198,6 +199,29 @@ function binary_data_size end default_mask_value(dataset) = NaN +""" + AbstractStaticDataset + +Supertype for datasets without a time dimension. Provides default no-op implementations for the date-related interface +(`all_dates`, `first_date`, `last_date`). +""" +abstract type AbstractStaticDataset end + +all_dates(::AbstractStaticDataset, args...) = nothing +first_date(::AbstractStaticDataset, args...) = nothing +last_date(::AbstractStaticDataset, args...) = nothing + +""" + AbstractStaticBathymetry <: AbstractStaticDataset + +Supertype for static, two-dimensional bathymetry datasets (e.g. ETOPO, GEBCO, IBCSO, IBCAO). +Adds defaults for the degenerate vertical axis and a variable-agnostic `Base.size`. +""" +abstract type AbstractStaticBathymetry <: AbstractStaticDataset end + +z_interfaces(::AbstractStaticBathymetry) = (0, 1) +Base.size(dataset::AbstractStaticBathymetry, variable) = size(dataset) + # Fundamentals include("metadata.jl") include("metadata_field.jl") @@ -231,6 +255,9 @@ include("ORCA/ORCA.jl") include("WOA/WOA.jl") include("JRA55/JRA55.jl") include("OSPapa/OSPapa.jl") +include("IBCSO/IBCSO.jl") +include("GEBCO/GEBCO.jl") +include("IBCAO/IBCAO.jl") using .ETOPO using .ECCO @@ -241,5 +268,13 @@ using .ORCA using .WOA using .JRA55 using .OSPapa +using .IBCSO +using .GEBCO +using .IBCAO + +# Fallback: if no download extension is loaded, check that all files already exist +function download_dataset(metadata::Metadata) + error("No download method for $metadata is available (is the backend package loaded?)") +end end # module diff --git a/src/DataWrangling/ECCO/ECCO.jl b/src/DataWrangling/ECCO/ECCO.jl index 576e81014..3a6cbb0b3 100644 --- a/src/DataWrangling/ECCO/ECCO.jl +++ b/src/DataWrangling/ECCO/ECCO.jl @@ -1,8 +1,8 @@ module ECCO -export ECCOMetadatum, ECCO_immersed_grid, adjusted_ECCO_tracers, initialize! +export ECCOMetadatum, adjusted_ECCO_tracers, initialize! export ECCO2Monthly, ECCO4Monthly, ECCO2Daily -export ECCOPrescribedAtmosphere +export ECCOPrescribedAtmosphere, ECCOPrescribedRadiation export ECCO2DarwinMonthly, ECCO4DarwinMonthly export retrieve_data @@ -172,25 +172,27 @@ available_variables(::ECCO2Daily) = ECCO2_dataset_variable_names available_variables(::ECCO4Monthly) = ECCO4_dataset_variable_names ECCO4_dataset_variable_names = Dict( - :temperature => "THETA", - :salinity => "SALT", - :u_velocity => "EVEL", - :v_velocity => "NVEL", - :free_surface => "SSH", - :sea_ice_thickness => "SIheff", - :sea_ice_concentration => "SIarea", - :net_heat_flux => "oceQnet", - :sensible_heat_flux => "EXFhs", - :latent_heat_flux => "EXFhl", - :net_longwave => "EXFlwnet", - :downwelling_shortwave => "oceQsw", - :downwelling_longwave => "EXFlwdn", - :air_temperature => "EXFatemp", - :air_specific_humidity => "EXFaqh", - :sea_level_pressure => "EXFpress", - :eastward_wind => "EXFewind", - :northward_wind => "EXFnwind", - :rain_freshwater_flux => "EXFpreci", + :temperature => "THETA", + :salinity => "SALT", + :u_velocity => "EVEL", + :v_velocity => "NVEL", + :free_surface => "SSH", + :sea_ice_thickness => "SIheff", + :sea_ice_concentration => "SIarea", + :net_heat_flux => "oceQnet", + :sensible_heat_flux => "EXFhs", + :latent_heat_flux => "EXFhl", + :net_longwave => "EXFlwnet", + :downwelling_shortwave => "oceQsw", + :downwelling_longwave => "EXFlwdn", + :air_temperature => "EXFatemp", + :air_specific_humidity => "EXFaqh", + :sea_level_pressure => "EXFpress", + :eastward_wind => "EXFewind", + :northward_wind => "EXFnwind", + :rain_freshwater_flux => "EXFpreci", + :zonal_wind_stress => "EXFtaue", + :meridional_wind_stress => "EXFtaun", ) ECCO2_dataset_variable_names = Dict( @@ -204,26 +206,28 @@ ECCO2_dataset_variable_names = Dict( :net_heat_flux => "oceQnet", ) -ECCO_location = Dict( - :temperature => (Center, Center, Center), - :salinity => (Center, Center, Center), - :u_velocity => (Face, Center, Center), - :v_velocity => (Center, Face, Center), - :free_surface => (Center, Center, Nothing), - :sea_ice_thickness => (Center, Center, Nothing), - :sea_ice_concentration => (Center, Center, Nothing), - :net_heat_flux => (Center, Center, Nothing), - :sensible_heat_flux => (Center, Center, Nothing), - :latent_heat_flux => (Center, Center, Nothing), - :net_longwave => (Center, Center, Nothing), - :downwelling_longwave => (Center, Center, Nothing), - :downwelling_shortwave => (Center, Center, Nothing), - :air_temperature => (Center, Center, Nothing), - :air_specific_humidity => (Center, Center, Nothing), - :sea_level_pressure => (Center, Center, Nothing), - :eastward_wind => (Center, Center, Nothing), - :northward_wind => (Center, Center, Nothing), - :rain_freshwater_flux => (Center, Center, Nothing), +ECCO_location = Dict( + :temperature => (Center, Center, Center), + :salinity => (Center, Center, Center), + :u_velocity => (Face, Center, Center), + :v_velocity => (Center, Face, Center), + :free_surface => (Center, Center, Nothing), + :sea_ice_thickness => (Center, Center, Nothing), + :sea_ice_concentration => (Center, Center, Nothing), + :net_heat_flux => (Center, Center, Nothing), + :sensible_heat_flux => (Center, Center, Nothing), + :latent_heat_flux => (Center, Center, Nothing), + :net_longwave => (Center, Center, Nothing), + :downwelling_longwave => (Center, Center, Nothing), + :downwelling_shortwave => (Center, Center, Nothing), + :air_temperature => (Center, Center, Nothing), + :air_specific_humidity => (Center, Center, Nothing), + :sea_level_pressure => (Center, Center, Nothing), + :eastward_wind => (Center, Center, Nothing), + :northward_wind => (Center, Center, Nothing), + :rain_freshwater_flux => (Center, Center, Nothing), + :zonal_wind_stress => (Center, Center, Nothing), + :meridional_wind_stress => (Center, Center, Nothing), ) const ECCOMetadata{D} = Metadata{<:ECCODataset, D} @@ -338,7 +342,7 @@ function download_dataset(metadata::ECCOMetadata) end end - return nothing + return metadata_path(metadata) end function inpainted_metadata_filename(metadata::ECCOMetadatum) @@ -370,6 +374,7 @@ end inpainted_metadata_path(metadata::ECCOMetadatum) = joinpath(metadata.dir, inpainted_metadata_filename(metadata)) include("ECCO_atmosphere.jl") +include("ECCO_radiation.jl") ##### ##### Column Field for ECCO datasets (which always download globally) diff --git a/src/DataWrangling/ECCO/ECCO_atmosphere.jl b/src/DataWrangling/ECCO/ECCO_atmosphere.jl index 3481c4e03..6f54ea1d4 100644 --- a/src/DataWrangling/ECCO/ECCO_atmosphere.jl +++ b/src/DataWrangling/ECCO/ECCO_atmosphere.jl @@ -1,6 +1,6 @@ using NumericalEarth.DataWrangling: DatasetBackend using Oceananigans.OutputReaders -using NumericalEarth.Atmospheres: PrescribedAtmosphere, TwoBandDownwellingRadiation +using NumericalEarth.Atmospheres: PrescribedAtmosphere """ ECCOPrescribedAtmosphere([architecture = CPU(), FT = Float32]; @@ -19,7 +19,10 @@ The atmospheric data will be held in `FieldTimeSeries` objects containing - air temperature and humidity: T, q - surface pressure: p - freshwater flux: rain -- downwelling radiation: ℐꜜˢʷ, ℐꜜˡʷ + +Note: downwelling shortwave / longwave radiation is now part of the +top-level `radiation` component. Use [`ECCOPrescribedRadiation`](@ref) to +load ECCO SW/LW into a `PrescribedRadiation`. """ function ECCOPrescribedAtmosphere(architecture = CPU(), FT = Float32; dataset = ECCO4Monthly(), @@ -36,8 +39,6 @@ function ECCOPrescribedAtmosphere(architecture = CPU(), FT = Float32; Ta_meta = Metadata(:air_temperature; dataset, start_date, end_date, dir) qa_meta = Metadata(:air_specific_humidity; dataset, start_date, end_date, dir) pa_meta = Metadata(:sea_level_pressure; dataset, start_date, end_date, dir) - ℐꜜˡʷ_meta = Metadata(:downwelling_longwave; dataset, start_date, end_date, dir) - ℐꜜˢʷ_meta = Metadata(:downwelling_shortwave; dataset, start_date, end_date, dir) Fr_meta = Metadata(:rain_freshwater_flux; dataset, start_date, end_date, dir) kw = (; time_indices_in_memory, time_indexing) @@ -48,10 +49,8 @@ function ECCOPrescribedAtmosphere(architecture = CPU(), FT = Float32; Ta = FieldTimeSeries(Ta_meta, architecture; kw...) qa = FieldTimeSeries(qa_meta, architecture; kw...) pa = FieldTimeSeries(pa_meta, architecture; kw...) - ℐꜜˡʷ = FieldTimeSeries(ℐꜜˡʷ_meta, architecture; kw...) - ℐꜜˢʷ = FieldTimeSeries(ℐꜜˢʷ_meta, architecture; kw...) Fr = FieldTimeSeries(Fr_meta, architecture; kw...) - + freshwater_flux = (; rain = Fr) times = ua.times @@ -61,8 +60,6 @@ function ECCOPrescribedAtmosphere(architecture = CPU(), FT = Float32; tracers = (T = Ta, q = qa) pressure = pa - downwelling_radiation = TwoBandDownwellingRadiation(shortwave=ℐꜜˢʷ, longwave=ℐꜜˡʷ) - FT = eltype(ua) surface_layer_height = convert(FT, surface_layer_height) @@ -70,7 +67,6 @@ function ECCOPrescribedAtmosphere(architecture = CPU(), FT = Float32; velocities, freshwater_flux, tracers, - downwelling_radiation, surface_layer_height, pressure) diff --git a/src/DataWrangling/ECCO/ECCO_radiation.jl b/src/DataWrangling/ECCO/ECCO_radiation.jl new file mode 100644 index 000000000..01a1eebcf --- /dev/null +++ b/src/DataWrangling/ECCO/ECCO_radiation.jl @@ -0,0 +1,44 @@ +using NumericalEarth.Radiations: PrescribedRadiation, SurfaceRadiationProperties, default_stefan_boltzmann_constant + +""" + ECCOPrescribedRadiation([architecture = CPU(), FT = Float32]; + dataset = ECCO4Monthly(), + start_date = first_date(dataset, :downwelling_shortwave), + end_date = last_date(dataset, :downwelling_shortwave), + dir = default_download_directory(dataset), + time_indices_in_memory = 10, + time_indexing = Cyclical(), + ocean_surface = SurfaceRadiationProperties(0.05, 0.97), + sea_ice_surface = SurfaceRadiationProperties(0.7, 1.0), + stefan_boltzmann_constant = default_stefan_boltzmann_constant, + other_kw...) + +Return a [`PrescribedRadiation`](@ref) backed by ECCO downwelling shortwave +and longwave fields. +""" +function ECCOPrescribedRadiation(architecture = CPU(), FT = Float32; + dataset = ECCO4Monthly(), + start_date = first_date(dataset, :downwelling_shortwave), + end_date = last_date(dataset, :downwelling_shortwave), + dir = default_download_directory(dataset), + time_indexing = Cyclical(), + time_indices_in_memory = 10, + ocean_surface = SurfaceRadiationProperties(0.05, 0.97), + sea_ice_surface = SurfaceRadiationProperties(0.7, 1.0), + stefan_boltzmann_constant = default_stefan_boltzmann_constant, + other_kw...) + + ℐꜜˢʷ_meta = Metadata(:downwelling_shortwave; dataset, start_date, end_date, dir) + ℐꜜˡʷ_meta = Metadata(:downwelling_longwave; dataset, start_date, end_date, dir) + + kw = (; time_indices_in_memory, time_indexing) + kw = merge(kw, other_kw) + + ℐꜜˢʷ = FieldTimeSeries(ℐꜜˢʷ_meta, architecture; kw...) + ℐꜜˡʷ = FieldTimeSeries(ℐꜜˡʷ_meta, architecture; kw...) + + return PrescribedRadiation(ℐꜜˢʷ, ℐꜜˡʷ; + ocean_surface, + sea_ice_surface, + stefan_boltzmann_constant) +end diff --git a/src/DataWrangling/EN4/EN4.jl b/src/DataWrangling/EN4/EN4.jl index 851401439..9b2b2f041 100644 --- a/src/DataWrangling/EN4/EN4.jl +++ b/src/DataWrangling/EN4/EN4.jl @@ -229,7 +229,7 @@ function download_dataset(metadata::Metadata{<:EN4Monthly}) end end - return nothing + return metadata_path(metadata) end end # Module diff --git a/src/DataWrangling/ERA5/ERA5.jl b/src/DataWrangling/ERA5/ERA5.jl index 359a2d50e..580fdcc0d 100644 --- a/src/DataWrangling/ERA5/ERA5.jl +++ b/src/DataWrangling/ERA5/ERA5.jl @@ -1,21 +1,29 @@ module ERA5 -export ERA5Hourly, ERA5Monthly +# 2-D data +export ERA5HourlySingleLevel, ERA5MonthlySingleLevel + +# 3-D data +export ERA5HourlyPressureLevels, ERA5MonthlyPressureLevels, ERA5_all_pressure_levels, pressure_field, hPa +export standard_atmosphere_z_interfaces, mean_geopotential_z_interfaces using NCDatasets using Printf using Scratch +using Statistics -using Oceananigans.Fields: Center -using NumericalEarth.DataWrangling: Metadata, Metadatum, metadata_path +using Oceananigans.Fields: Center, set! +using Oceananigans: Field, fill_halo_regions!, CPU +using NumericalEarth.DataWrangling: Metadata, Metadatum, metadata_path, native_grid, InverseGravity, download_dataset using Dates -using Dates: DateTime, Day, Month, Hour +using Dates: DateTime, Month, Hour import NumericalEarth.DataWrangling: all_dates, dataset_variable_name, dataset_location, default_download_directory, + default_inpainting, longitude_interfaces, latitude_interfaces, z_interfaces, @@ -23,8 +31,10 @@ import NumericalEarth.DataWrangling: inpainted_metadata_path, available_variables, retrieve_data, - metadata_path, - reversed_latitude_axis + is_three_dimensional, + reversed_vertical_axis, + reversed_latitude_axis, + conversion_units import Base: eltype @@ -42,133 +52,35 @@ abstract type ERA5Dataset end default_download_directory(::ERA5Dataset) = download_ERA5_cache -struct ERA5Hourly <: ERA5Dataset end -struct ERA5Monthly <: ERA5Dataset end - -dataset_name(::ERA5Hourly) = "ERA5Hourly" -dataset_name(::ERA5Monthly) = "ERA5Monthly" - +# ERA5 stores latitude north-to-south (90 → -90); flip on read reversed_latitude_axis(::ERA5Dataset) = true -# Wave variables are on a 0.5° grid (720×361), atmospheric variables on 0.25° (1440×721) -const ERA5_wave_variables = Set([ - :eastward_stokes_drift, :northward_stokes_drift, - :significant_wave_height, :mean_wave_period, :mean_wave_direction, -]) +const ERA5Metadata{D} = Metadata{<:ERA5Dataset, D} +const ERA5Metadatum = Metadatum{<:ERA5Dataset} -function Base.size(::ERA5Dataset, variable) - if variable in ERA5_wave_variables - return (720, 361, 1) - else - return (1440, 721, 1) - end -end +##### +##### Grid interfaces +##### -# ERA5 reanalysis data available from 1940 to present (we use a practical range here) -all_dates(::ERA5Hourly, var) = range(DateTime("1940-01-01"), stop=DateTime("2024-12-31"), step=Hour(1)) -all_dates(::ERA5Monthly, var) = range(DateTime("1940-01-01"), stop=DateTime("2024-12-01"), step=Month(1)) +# ERA5 global coverage: 0-359.75 longitude, -90 to 90 latitude at 0.25 degree resolution +longitude_interfaces(::ERA5Metadata) = (-0.125, 359.875) +latitude_interfaces(::ERA5Metadata) = (-90, 90) -const ERA5Metadata{D} = Metadata{<:ERA5Dataset, D} -const ERA5Metadatum = Metadatum{<:ERA5Dataset} +# ERA5 single-levels (2-D) data product +z_interfaces(::ERA5Metadata) = (0, 1) -# ERA5 is a spatially 2D dataset (atmospheric surface variables) -is_three_dimensional(::ERA5Metadata) = false - -# Variable name mappings from NumericalEarth names to ERA5/CDS API variable names -ERA5_dataset_variable_names = Dict( - :temperature => "2m_temperature", - :dewpoint_temperature => "2m_dewpoint_temperature", - :eastward_velocity => "10m_u_component_of_wind", - :northward_velocity => "10m_v_component_of_wind", - :surface_pressure => "surface_pressure", - :mean_sea_level_pressure => "mean_sea_level_pressure", - :total_precipitation => "total_precipitation", - :sea_surface_temperature => "sea_surface_temperature", - :downwelling_shortwave_radiation => "surface_solar_radiation_downwards", - :downwelling_longwave_radiation => "surface_thermal_radiation_downwards", - :total_cloud_cover => "total_cloud_cover", - :evaporation => "evaporation", - :specific_humidity => "specific_humidity", - :eastward_stokes_drift => "u_component_stokes_drift", - :northward_stokes_drift => "v_component_stokes_drift", - :significant_wave_height => "significant_height_of_combined_wind_waves_and_swell", - :mean_wave_period => "mean_wave_period", - :mean_wave_direction => "mean_wave_direction", -) - -# Variables available for download -ERA5_variable_names = keys(ERA5_dataset_variable_names) - -available_variables(::ERA5Dataset) = ERA5_dataset_variable_names - -dataset_variable_name(metadata::ERA5Metadata) = ERA5_dataset_variable_names[metadata.name] - -# NetCDF short variable names (what's actually in the downloaded files) -# These differ from the CDS API variable names above -ERA5_netcdf_variable_names = Dict( - :temperature => "t2m", - :dewpoint_temperature => "d2m", - :eastward_velocity => "u10", - :northward_velocity => "v10", - :surface_pressure => "sp", - :mean_sea_level_pressure => "msl", - :total_precipitation => "tp", - :sea_surface_temperature => "sst", - :downwelling_shortwave_radiation => "ssrd", - :downwelling_longwave_radiation => "strd", - :total_cloud_cover => "tcc", - :evaporation => "e", - :specific_humidity => "q", - :eastward_stokes_drift => "ust", - :northward_stokes_drift => "vst", - :significant_wave_height => "swh", - :mean_wave_period => "mwp", - :mean_wave_direction => "mwd", -) - -netcdf_variable_name(metadata::ERA5Metadata) = ERA5_netcdf_variable_names[metadata.name] - -""" - retrieve_data(metadata::ERA5Metadatum) - -Retrieve ERA5 data from NetCDF file according to `metadata`. -ERA5 is 2D surface data, so we return a 2D array with an added singleton z-dimension. -""" -function retrieve_data(metadata::ERA5Metadatum) - path = metadata_path(metadata) - name = netcdf_variable_name(metadata) - - ds = NCDatasets.Dataset(path) - - # ERA5 is 2D + time, we take the first time step - # Data shape is typically (lon, lat) or (lon, lat, time) - raw_data = ds[name] - ndim = ndims(raw_data) - - if ndim == 2 - data_2d = raw_data[:, :] - elseif ndim == 3 - data_2d = raw_data[:, :, 1] - else - error("Unexpected ERA5 data dimensions: $ndim") - end - - close(ds) - - # Add singleton z-dimension for 3D field compatibility - # Return as (Nx, Ny, 1) - return reshape(data_2d, size(data_2d, 1), size(data_2d, 2), 1) -end +# ERA5 data is stored as Float32 +eltype(::ERA5Metadata) = Float32 ##### -##### Metadata filename construction +##### Shared filename utilities ##### function date_str(date::DateTime) y = Dates.year(date) m = lpad(Dates.month(date), 2, '0') - d = lpad(Dates.day(date), 2, '0') - h = lpad(Dates.hour(date), 2, '0') + d = lpad(Dates.day(date), 2, '0') + h = lpad(Dates.hour(date), 2, '0') return "$(y)-$(m)-$(d)T$(h)" end @@ -192,9 +104,7 @@ function bbox_strs(c) return first, second end -function region_suffix(::Nothing) - return "" -end +region_suffix(::Nothing) = "" function region_suffix(region) w, e = bbox_strs(region.longitude) @@ -203,13 +113,18 @@ function region_suffix(region) end function metadata_prefix(dataset::ERA5Dataset, name, date, region) - var = ERA5_dataset_variable_names[name] + var = available_variables(dataset)[name] ds = dataset_name(dataset) start_date = start_date_str(date) end_date = end_date_str(date) suffix = region_suffix(region) - prefix = string(var, "_", ds, "_", start_date, "_", end_date, suffix) + + if start_date == end_date + prefix = string(var, "_", ds, "_", start_date, suffix) + else + prefix = string(var, "_", ds, "_", start_date, "_", end_date, suffix) + end prefix = colon2dash(prefix) prefix = underscore_spaces(prefix) return prefix @@ -228,21 +143,10 @@ end inpainted_metadata_path(metadata::ERA5Metadatum) = joinpath(metadata.dir, inpainted_metadata_filename(metadata)) ##### -##### Grid interfaces +##### Single-level and pressure-level specifics ##### -# ERA5 is a 2D surface dataset — vertical location is Nothing -dataset_location(::ERA5Dataset, name) = (Center, Center, Nothing) - -# ERA5 global coverage: 0-360 longitude, -90 to 90 latitude at 0.25 degree resolution -longitude_interfaces(::ERA5Metadata) = (0, 360) -latitude_interfaces(::ERA5Metadata) = (-90, 90) - -# ERA5 is a 2D surface dataset, so z is a single level at the surface -z_interfaces(::ERA5Metadata) = (0, 1) - -# ERA5 data is stored as Float32 -eltype(::ERA5Metadata) = Float32 +include("ERA5_single_levels.jl") +include("ERA5_pressure_levels.jl") end # module ERA5 - diff --git a/src/DataWrangling/ERA5/ERA5_pressure_levels.jl b/src/DataWrangling/ERA5/ERA5_pressure_levels.jl new file mode 100644 index 000000000..a92e6a593 --- /dev/null +++ b/src/DataWrangling/ERA5/ERA5_pressure_levels.jl @@ -0,0 +1,262 @@ +abstract type ERA5PressureLevelsDataset <: ERA5Dataset end + +# ERA5PressureMetadata is a subtype of ERA5Metadata +const ERA5PressureMetadata{D} = Metadata{<:ERA5PressureLevelsDataset, D} +const ERA5PressureMetadatum = Metadatum{<:ERA5PressureLevelsDataset} + +struct ERA5HourlyPressureLevels <: ERA5PressureLevelsDataset + pressure_levels :: Vector{Float64} + z :: Union{Nothing, Vector{Float64}} + mean_geopotential_height :: Bool + ERA5HourlyPressureLevels(pressure_levels, z=nothing; mean_geopotential_height=true) = + new(sort(pressure_levels, rev=true), z, mean_geopotential_height) +end +ERA5HourlyPressureLevels(; pressure_levels=ERA5_all_pressure_levels, z=nothing, mean_geopotential_height=true) = + ERA5HourlyPressureLevels(pressure_levels, z; mean_geopotential_height) + +struct ERA5MonthlyPressureLevels <: ERA5PressureLevelsDataset + pressure_levels :: Vector{Float64} + z :: Union{Nothing, Vector{Float64}} + mean_geopotential_height :: Bool + ERA5MonthlyPressureLevels(pressure_levels, z=nothing; mean_geopotential_height=true) = + new(sort(pressure_levels, rev=true), z, mean_geopotential_height) +end +ERA5MonthlyPressureLevels(; pressure_levels=ERA5_all_pressure_levels, z=nothing, mean_geopotential_height=true) = + ERA5MonthlyPressureLevels(pressure_levels, z; mean_geopotential_height) + +dataset_name(::ERA5HourlyPressureLevels) = "ERA5HourlyPressureLevels" +dataset_name(::ERA5MonthlyPressureLevels) = "ERA5MonthlyPressureLevels" + +##### +##### ERA5 pressure-level data availability +##### + +# ERA5 reanalysis data available from 1940 to present (we use a practical range here) +all_dates(::ERA5HourlyPressureLevels, var) = range(DateTime("1940-01-01"), stop=DateTime("2024-12-31"), step=Hour(1)) +all_dates(::ERA5MonthlyPressureLevels, var) = range(DateTime("1940-01-01"), stop=DateTime("2024-12-01"), step=Month(1)) + +# ERA5 pressure-level data is a spatially 3-D dataset +is_three_dimensional(::ERA5PressureMetadata) = true + +# TODO: drop once Oceananigans.Units exports `hPa` in a tagged release +const hPa = 100 + +const ERA5_all_pressure_levels = [1, 2, 3, 5, 7, 10, 20, 30, 50, 70, 100, 125, 150, + 175, 200, 225, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 775, 800, + 825, 850, 875, 900, 925, 950, 975, 1000]hPa + +# ERA5 stores pressure levels bottom-to-top +reversed_vertical_axis(::ERA5PressureLevelsDataset) = false + +Base.size(ds::ERA5PressureLevelsDataset, variable) = (1440, 720, length(ds.pressure_levels)) + +##### +##### ERA5 pressure-level variable name mappings +##### + +ERA5PL_dataset_variable_names = Dict( + :temperature => "temperature", + :eastward_velocity => "u_component_of_wind", + :northward_velocity => "v_component_of_wind", + :vertical_velocity => "vertical_velocity", + :geopotential => "geopotential", + :geopotential_height => "geopotential", + :specific_humidity => "specific_humidity", + :relative_humidity => "relative_humidity", + :vorticity => "vorticity", + :divergence => "divergence", + :potential_vorticity => "potential_vorticity", + :ozone_mass_mixing_ratio => "ozone_mass_mixing_ratio", + :fraction_of_cloud_cover => "fraction_of_cloud_cover", + :specific_cloud_liquid_water_content => "specific_cloud_liquid_water_content", + :specific_cloud_ice_water_content => "specific_cloud_ice_water_content", + :specific_rain_water_content => "specific_rain_water_content", + :specific_snow_water_content => "specific_snow_water_content", +) + +# NetCDF short variable names (what's actually in the downloaded files) +# These differ from the CDS API variable names above +ERA5PL_netcdf_variable_names = Dict( + :temperature => "t", + :eastward_velocity => "u", + :northward_velocity => "v", + :vertical_velocity => "w", + :geopotential => "z", + :geopotential_height => "z", + :specific_humidity => "q", + :relative_humidity => "r", + :vorticity => "vo", + :divergence => "d", + :potential_vorticity => "pv", + :ozone_mass_mixing_ratio => "o3", + :fraction_of_cloud_cover => "cc", + :specific_cloud_liquid_water_content => "clwc", + :specific_cloud_ice_water_content => "ciwc", + :specific_rain_water_content => "crwc", + :specific_snow_water_content => "cswc", +) + +# Variables available for download +available_variables(::ERA5PressureLevelsDataset) = ERA5PL_dataset_variable_names + +# `dataset_variable_name` returns the short name as stored in the NetCDF file +# (e.g. "u"). The CDS API catalog name (e.g. "u_component_of_wind") used in +# download requests is accessed via the `ERA5PL_dataset_variable_names` dict +# directly in `NumericalEarthCDSAPIExt`. +dataset_variable_name(md::ERA5PressureMetadata) = ERA5PL_netcdf_variable_names[md.name] + +conversion_units(md::ERA5PressureMetadata) = + md.name == :geopotential_height ? InverseGravity() : nothing + +default_inpainting(md::ERA5PressureMetadata) = nothing + +""" + retrieve_data(metadata::ERA5PressureMetadatum) + +Retrieve ERA5 pressure-level data from a NetCDF file. +Returns a 3D array (lon, lat, level) with levels ordered bottom-to-top +(highest pressure at k=1, lowest pressure at k=Nz). +""" +function retrieve_data(metadata::ERA5PressureMetadatum) + path = metadata_path(metadata) + name = dataset_variable_name(metadata) + ds = NCDatasets.Dataset(path) + data = ds[name][:, :, :, 1] # (lon, lat, pressure_level, time=1) + close(ds) + return reverse(data, dims=2) # Latitude is stored from 90°N → 90°S +end + +##### +##### Pressure-level vertical coordinate +##### + +const ERA5_gravitational_acceleration = 9.80665 + +# International Standard Atmosphere height (m) for a given pressure +function standard_atmosphere_geopotential_height(p) + g = ERA5_gravitational_acceleration + T⁰ = 288.15 # K + p⁰ = 101325 + Rᵈ = 287.0528 # J/(kg-K) + + return (Rᵈ * T⁰ / g) * log(p⁰ / p) +end + +# Build z-interfaces (Nz+1 values) from pressure levels. +# Levels may be in any order; output is sorted so k=1 is highest pressure (lowest altitude). +function standard_atmosphere_z_interfaces(levels) + sorted_levels = sort(levels, rev=true) # highest pressure first → k=1 is bottom + heights = standard_atmosphere_geopotential_height.(Float64.(sorted_levels)) + Nz = length(heights) + + interfaces = Vector{Float64}(undef, Nz + 1) + + if Nz == 1 + interfaces[1] = heights[1] - 0.5 + interfaces[2] = heights[1] + 0.5 + else + interfaces[1] = heights[1] - (heights[2] - heights[1]) / 2 + for k in 2:Nz + interfaces[k] = (heights[k-1] + heights[k]) / 2 + end + interfaces[Nz+1] = heights[Nz] + (heights[Nz] - heights[Nz-1]) / 2 + end + + return interfaces +end + +# ERA5 pressure-levels (3-D) data product +function z_interfaces(metadata::ERA5PressureMetadata) + # Return cached z if already set + !isnothing(metadata.dataset.z) && return metadata.dataset.z + + # If mean_geopotential_height is enabled, try to download and compute + if metadata.dataset.mean_geopotential_height + ϕ_metadata = Metadata(:geopotential; dataset=metadata.dataset, + dates=metadata.dates, region=metadata.region, + dir=metadata.dir) + try + download_dataset(ϕ_metadata) + return mean_geopotential_z_interfaces(metadata) + catch e + @warn "Failed to derive geopotential heights; falling back to standard atmosphere" exception=(e, catch_backtrace()) + end + end + + # Fallback + return standard_atmosphere_z_interfaces(metadata.dataset.pressure_levels) +end + +##### +##### mean_geopotential_heights — data-derived static z-coordinate +##### + +""" + mean_geopotential_heights(metadata::ERA5PressureMetadata) + +Compute spatially and temporally averaged geopotential heights (m) for each +pressure level in `metadata`. + +Downloads the `:geopotential` field for every date in `metadata`, divides by g, +averages over the horizontal domain and all dates, and returns one representative +height per pressure level in bottom-to-top order (k=1 is highest pressure). +""" +function mean_geopotential_heights(metadata::ERA5PressureMetadata) + ϕ_metadata = Metadata(:geopotential; dataset=metadata.dataset, + dates=metadata.dates, region=metadata.region, + dir=metadata.dir) + Nz = length(metadata.dataset.pressure_levels) + heights = zeros(Nz) + # average over time + for ϕ_datum in ϕ_metadata + data = retrieve_data(ϕ_datum) ./ Float32(ERA5_gravitational_acceleration) # Φ → Z (m) + if size(data, 3) != Nz + error("Cached geopotential file at $(metadata_path(ϕ_datum)) has " * + "$(size(data, 3)) pressure levels, but the dataset configuration " * + "expects $Nz. This is most likely a stale cache from a previous " * + "run with different `pressure_levels`. Delete the file and re-run.") + end + # average over horizontal dims + data_mean = mean(data; dims=(1, 2)) + heights .+= dropdims(data_mean; dims=(1, 2)) + end + heights ./= length(ϕ_metadata) + + return sort(heights) +end + +function mean_geopotential_z_interfaces(metadata::ERA5PressureMetadata) + return stagger(mean_geopotential_heights(metadata)) +end + +function stagger(zc::AbstractVector) + # heights are ascending (k=1 = highest pressure = lowest altitude, + # consistent with retrieve_data's reverse() and Oceananigans bottom-to-top + # convention); bottom and top interfaces are extrapolated + zf = (zc[1:end-1] .+ zc[2:end]) / 2 # Nz-1 interior interfaces + pushfirst!(zf, zc[1] - (zf[1] - zc[1])) # bottom interface + push!(zf, zc[end] + (zc[end] - zf[end])) # top interface + return zf +end + +##### +##### pressure_field — synthetic pressure coordinate field +##### + +""" + pressure_field(metadata::ERA5PressureMetadatum, arch=CPU(); halo=(3,3,3)) + +Return a `Field{Nothing, Nothing, Center}` on the native grid of `metadata` +holding the pressure value (Pa) at each vertical level. Levels are ordered +bottom-to-top (k=1 is the highest pressure level). The `Nothing` horizontal +locations make this field broadcast against full 3-D fields without copying. +""" +function pressure_field(metadata::ERA5PressureMetadatum, arch=CPU(); halo=(3,3,3)) + grid = native_grid(metadata, arch; halo) + field = Field{Nothing, Nothing, Center}(grid) + reversed_levels = sort(metadata.dataset.pressure_levels, rev=true) # highest pressure → k=1 + set!(field, reversed_levels) + fill_halo_regions!(field) + return field +end + diff --git a/src/DataWrangling/ERA5/ERA5_single_levels.jl b/src/DataWrangling/ERA5/ERA5_single_levels.jl new file mode 100644 index 000000000..04694894f --- /dev/null +++ b/src/DataWrangling/ERA5/ERA5_single_levels.jl @@ -0,0 +1,138 @@ +struct ERA5HourlySingleLevel <: ERA5Dataset end +struct ERA5MonthlySingleLevel <: ERA5Dataset end + +dataset_name(::ERA5HourlySingleLevel) = "ERA5HourlySingleLevel" +dataset_name(::ERA5MonthlySingleLevel) = "ERA5MonthlySingleLevel" + +# Wave variables are on a 0.5° grid (720×361), atmospheric variables on 0.25° (1440×721) +const ERA5_wave_variables = Set([ + :eastward_stokes_drift, :northward_stokes_drift, + :significant_wave_height, :mean_wave_period, :mean_wave_direction, +]) + +##### +##### ERA5 single-level data availability +##### + +# ERA5 reanalysis data available from 1940 to present (we use a practical range here) +all_dates(::ERA5HourlySingleLevel, var) = range(DateTime("1940-01-01"), stop=DateTime("2024-12-31"), step=Hour(1)) +all_dates(::ERA5MonthlySingleLevel, var) = range(DateTime("1940-01-01"), stop=DateTime("2024-12-01"), step=Month(1)) + +# ERA5 single-level data is a spatially 2-D dataset +is_three_dimensional(::ERA5Metadata) = false + +function Base.size(::ERA5Dataset, variable) + if variable in ERA5_wave_variables + return (720, 360, 1) + else + return (1440, 720, 1) + end +end + +##### +##### ERA5 single-level variable name mappings +##### + +# Variable name mappings from NumericalEarth names to ERA5/CDS API variable names +ERA5_dataset_variable_names = Dict( + :temperature => "2m_temperature", + :dewpoint_temperature => "2m_dewpoint_temperature", + :eastward_velocity => "10m_u_component_of_wind", + :northward_velocity => "10m_v_component_of_wind", + :surface_pressure => "surface_pressure", + :mean_sea_level_pressure => "mean_sea_level_pressure", + :total_precipitation => "total_precipitation", + :mean_surface_momentum_flux_x => "mean_eastward_turbulent_surface_stress", + :mean_surface_momentum_flux_y => "mean_northward_turbulent_surface_stress", + :sea_surface_temperature => "sea_surface_temperature", + :mean_surface_sensible_heat_flux => "mean_surface_sensible_heat_flux", + :mean_surface_latent_heat_flux => "mean_surface_latent_heat_flux", + :downwelling_shortwave_radiation => "surface_solar_radiation_downwards", + :downwelling_longwave_radiation => "surface_thermal_radiation_downwards", + :total_cloud_cover => "total_cloud_cover", + :evaporation => "evaporation", + :mean_evaporation_rate => "mean_evaporation_rate", + :specific_humidity => "specific_humidity", + :eastward_stokes_drift => "u_component_stokes_drift", + :northward_stokes_drift => "v_component_stokes_drift", + :significant_wave_height => "significant_height_of_combined_wind_waves_and_swell", + :mean_wave_period => "mean_wave_period", + :mean_wave_direction => "mean_wave_direction", +) + +# NetCDF short variable names (what's actually in the downloaded files) +# - These differ from the CDS API variable names above +# - The expected "shortName" (see https://confluence.ecmwf.int/display/CKB/ERA5%3A+data+documentation#heading-Parameterlistings) +# did not always match the netcdf variable names?! In those cases, the longName and units were manually verified. +ERA5_netcdf_variable_names = Dict( + :temperature => "t2m", + :dewpoint_temperature => "d2m", + :eastward_velocity => "u10", + :northward_velocity => "v10", + :surface_pressure => "sp", + :mean_sea_level_pressure => "msl", + :total_precipitation => "tp", + :mean_surface_momentum_flux_x => "avg_iews", # shortName: metss + :mean_surface_momentum_flux_y => "avg_inss", # shortName: mntss + :sea_surface_temperature => "sst", + :mean_surface_sensible_heat_flux => "avg_ishf", # shortName: msshf + :mean_surface_latent_heat_flux => "avg_slhtf", # shortName: mslhf + :downwelling_shortwave_radiation => "ssrd", + :downwelling_longwave_radiation => "strd", + :total_cloud_cover => "tcc", + :evaporation => "e", + :mean_evaporation_rate => "avg_ie", # shortName: mer + :specific_humidity => "q", + :eastward_stokes_drift => "ust", + :northward_stokes_drift => "vst", + :significant_wave_height => "swh", + :mean_wave_period => "mwp", + :mean_wave_direction => "mwd", +) + +# Variables available for download +available_variables(::ERA5Dataset) = ERA5_dataset_variable_names + +# `dataset_variable_name` returns the short name as stored in the NetCDF file +# (e.g. "t2m"). The CDS API catalog name (e.g. "2m_temperature") used in +# download requests is accessed via the `ERA5_dataset_variable_names` dict +# directly in `NumericalEarthCDSAPIExt`. +dataset_variable_name(md::ERA5Metadata) = ERA5_netcdf_variable_names[md.name] + +default_inpainting(md::ERA5Metadata) = nothing + +""" + retrieve_data(metadata::ERA5Metadatum) + +Retrieve ERA5 data from NetCDF file according to `metadata`. +ERA5 is 2D surface data, so we return a 2D array with an added singleton z-dimension. +""" +function retrieve_data(metadata::ERA5Metadatum) + path = metadata_path(metadata) + name = dataset_variable_name(metadata) + + ds = NCDatasets.Dataset(path) + + # ERA5 is 2D + time, we take the first time step + # Data shape is typically (lon, lat) or (lon, lat, time) + raw_data = ds[name] + ndim = ndims(raw_data) + + if ndim == 2 + data_2d = raw_data[:, :] + elseif ndim == 3 + data_2d = raw_data[:, :, 1] + else + error("Unexpected ERA5 data dimensions: $ndim") + end + + close(ds) + + # Latitude is stored from 90°N → 90°S + data_2d = reverse(data_2d, dims=2) + + # Add singleton z-dimension for 3D field compatibility + # Return as (Nx, Ny, 1) + return reshape(data_2d, size(data_2d, 1), size(data_2d, 2), 1) +end + diff --git a/src/DataWrangling/ETOPO/ETOPO.jl b/src/DataWrangling/ETOPO/ETOPO.jl index deca7a45f..f4a47e565 100644 --- a/src/DataWrangling/ETOPO/ETOPO.jl +++ b/src/DataWrangling/ETOPO/ETOPO.jl @@ -7,19 +7,15 @@ using Oceananigans using Oceananigans.DistributedComputations: @root using Scratch -using ..DataWrangling: download_progress, Metadatum, metadata_path +using ..DataWrangling: download_progress, Metadatum, metadata_path, AbstractStaticBathymetry import NumericalEarth.DataWrangling: metadata_filename, default_download_directory, - all_dates, - first_date, - last_date, dataset_variable_name, download_dataset, longitude_interfaces, latitude_interfaces, - z_interfaces, reversed_vertical_axis download_ETOPO_cache::String = "" @@ -31,18 +27,13 @@ ETOPO_bathymetry_variable_names = Dict( :bottom_height => "z", ) -struct ETOPO2022 end +struct ETOPO2022 <: AbstractStaticBathymetry end default_download_directory(::ETOPO2022) = download_ETOPO_cache reversed_vertical_axis(::ETOPO2022) = true longitude_interfaces(::ETOPO2022) = (-180, 180) latitude_interfaces(::ETOPO2022) = (-90, 90) Base.size(::ETOPO2022) = (21600, 10800, 1) -Base.size(dataset::ETOPO2022, variable) = size(dataset) - -all_dates(::ETOPO2022, args...) = nothing -first_date(::ETOPO2022, args...) = nothing -last_date(::ETOPO2022, args...) = nothing const ETOPOMetadatum = Metadatum{<:ETOPO2022} @@ -51,7 +42,6 @@ dataset_variable_name(data::ETOPOMetadatum) = ETOPO_bathymetry_variable_names[da const ETOPO_url = "https://www.dropbox.com/scl/fi/6pwalcuuzgtpanysn4h6f/" * "ETOPO_2022_v1_60s_N90W180_surface.nc?rlkey=2t7890ruyk4nd5t5eov5768lt&st=yfxsy1lu&dl=0" -z_interfaces(::ETOPOMetadatum) = (0, 1) metadata_url(::ETOPOMetadatum) = ETOPO_url metadata_filename(::ETOPO2022, name, date, region) = "ETOPO_2022_v1_60s_N90W180_surface.nc" diff --git a/src/DataWrangling/GEBCO/GEBCO.jl b/src/DataWrangling/GEBCO/GEBCO.jl new file mode 100644 index 000000000..58ba7e796 --- /dev/null +++ b/src/DataWrangling/GEBCO/GEBCO.jl @@ -0,0 +1,125 @@ +module GEBCO + +export GEBCO2024 + +using Downloads +using ZipFile +using Oceananigans +using Oceananigans.DistributedComputations: @root +using Scratch + +using ..DataWrangling: download_progress, Metadatum, metadata_path, AbstractStaticBathymetry + +import NumericalEarth.DataWrangling: + metadata_filename, + default_download_directory, + dataset_variable_name, + download_dataset, + longitude_interfaces, + latitude_interfaces, + reversed_vertical_axis + +download_GEBCO_cache::String = "" +function __init__() + global download_GEBCO_cache = @get_scratch!("GEBCO") +end + +GEBCO_bathymetry_variable_names = Dict( + :bottom_height => "elevation", # Variable name in NetCDF +) + +""" + GEBCO2024 + +General Bathymetric Chart of the Oceans 2024 release. +Global bathymetry and topography at 15 arc-second resolution. + +The GEBCO_2024 Grid is a global terrain model for ocean and land, +providing elevation data on a 15 arc-second interval grid. + +Reference: GEBCO Compilation Group (2024) GEBCO 2024 Grid +Data source: https://www.gebco.net/data_and_products/gridded_bathymetry_data/ +""" +struct GEBCO2024 <: AbstractStaticBathymetry end + +default_download_directory(::GEBCO2024) = download_GEBCO_cache +reversed_vertical_axis(::GEBCO2024) = false + +# GEBCO covers the entire globe +longitude_interfaces(::GEBCO2024) = (-180, 180) +latitude_interfaces(::GEBCO2024) = (-90, 90) + +# Grid size for 15 arc-second resolution +# 360° / (15/3600)° = 86400 points in longitude +# 180° / (15/3600)° = 43200 points in latitude +Base.size(::GEBCO2024) = (86400, 43200, 1) + +const GEBCOMetadatum = Metadatum{<:GEBCO2024} + +dataset_variable_name(data::GEBCOMetadatum) = GEBCO_bathymetry_variable_names[data.name] + +# GEBCO 2024 download URL from BODC +# Note: This is a large file (~8 GB zipped, ~22 GB unzipped) +const GEBCO_zip_url = "https://www.bodc.ac.uk/data/open_download/gebco/gebco_2024/zip/" + +# The expected NetCDF filename inside the ZIP +const GEBCO_nc_filename = "GEBCO_2024.nc" +metadata_filename(::GEBCO2024, name, date, bounding_box) = GEBCO_nc_filename + +function download_dataset(metadatum::GEBCOMetadatum) + filepath = metadata_path(metadatum) + download_dir = metadatum.dir + + @root if !isfile(filepath) + @info "Downloading GEBCO data: $(metadatum.name) to $download_dir..." + @info "Note: GEBCO is a large dataset (~8 GB download, ~22 GB uncompressed). This may take a while." + + # Download the ZIP file + zip_path = joinpath(download_dir, "GEBCO_2024.zip") + + try + @info "Downloading from BODC..." + Downloads.download(GEBCO_zip_url, zip_path; progress=download_progress) + + # Extract the NetCDF file from the ZIP using ZipFile.jl + @info "Extracting NetCDF from ZIP archive..." + zf = ZipFile.Reader(zip_path) + extracted = false + for f in zf.files + if endswith(f.name, GEBCO_nc_filename) + open(filepath, "w") do io + write(io, read(f)) + end + extracted = true + break + end + end + close(zf) + + if !extracted + error("Could not find $GEBCO_nc_filename in ZIP archive") + end + + if isfile(filepath) + @info "GEBCO data extracted successfully" + else + error("Failed to extract GEBCO NetCDF file") + end + + # Clean up ZIP file to save space + rm(zip_path; force=true) + + catch e + @warn "Failed to download GEBCO: $e" + + # Clean up any partial download + rm(zip_path; force=true) + + rethrow(e) + end + end + + return filepath +end + +end # module diff --git a/src/DataWrangling/IBCAO/IBCAO.jl b/src/DataWrangling/IBCAO/IBCAO.jl new file mode 100644 index 000000000..f26d1a4b2 --- /dev/null +++ b/src/DataWrangling/IBCAO/IBCAO.jl @@ -0,0 +1,102 @@ +module IBCAO + +export IBCAOv5 + +using Downloads +using Oceananigans +using Oceananigans.DistributedComputations: @root +using Scratch +using NCDatasets + +using ..DataWrangling: download_progress, Metadatum, metadata_path, AbstractStaticBathymetry + +import NumericalEarth.DataWrangling: + metadata_filename, + default_download_directory, + dataset_variable_name, + download_dataset, + longitude_interfaces, + latitude_interfaces, + reversed_vertical_axis, + validate_dataset_coverage + + +download_IBCAO_cache::String = "" +function __init__() + global download_IBCAO_cache = @get_scratch!("IBCAO") +end + +IBCAO_bathymetry_variable_names = Dict( + :bottom_height => "z", +) + +""" + IBCAOv5 + +International Bathymetric Chart of the Arctic Ocean Version 5.1 (2025). +100m resolution bathymetry for the Arctic Ocean (north of 64°N), including +Greenland ice sheet surface elevation. Data is provided in Polar Stereographic +projection (EPSG:3996) and reprojected to WGS84 geographic coordinates at 0.01° +resolution on first use. + +Reference: Jakobsson et al. (2024), https://doi.org/10.1038/s41597-024-04278-w +Data source: https://www.gebco.net/data-products/gridded-bathymetry-data/arctic-ocean/ +""" +struct IBCAOv5 <: AbstractStaticBathymetry end + +default_download_directory(::IBCAOv5) = download_IBCAO_cache +reversed_vertical_axis(::IBCAOv5) = false + +# Geographic bounds after reprojection to WGS84 +longitude_interfaces(::IBCAOv5) = (-180, 180) +latitude_interfaces(::IBCAOv5) = (64, 90) + +# 0.01° resolution: 36000 × 2600 +Base.size(::IBCAOv5) = (36000, 2600, 1) + +const IBCAOMetadatum = Metadatum{<:IBCAOv5} + +dataset_variable_name(data::IBCAOMetadatum) = IBCAO_bathymetry_variable_names[data.name] + +# CEDA BODC direct download — 100m, with Greenland ice sheet elevation (~25 GB) +const IBCAO_tiff_url = "https://dap.ceda.ac.uk/bodc/gebco/ibcao/ibcao_v5.1/" * + "greenland_ice_sheet_elevation_data/100mx100m_grid_cell_spacing/" * + "single_complete_bathymetric_grid/ibcao_5_1_2025_ice_100m.tiff?download=1" + +const IBCAO_tiff_filename = "ibcao_5_1_2025_ice_100m.tiff" +const IBCAO_nc_filename = "ibcao_v5_wgs84_0p01deg.nc" + +metadata_filename(::IBCAOv5, name, date, bounding_box) = IBCAO_nc_filename + +function validate_dataset_coverage(grid, ::IBCAOMetadatum) + φ_south, _ = Oceananigans.Grids.y_domain(grid) + if φ_south < 64 + error("IBCAOv5 only covers the Arctic Ocean (north of 64°N). " * + "The grid extends to $(round(φ_south, digits=1))°N. " * + "Use ETOPO2022() or GEBCO2024() for domains that extend south of 64°N.") + end +end + +function download_dataset(metadatum::IBCAOMetadatum) + nc_path = metadata_path(metadatum) + tiff_path = joinpath(metadatum.dir, IBCAO_tiff_filename) + + @root if !isfile(nc_path) + if !isfile(tiff_path) + @info "Downloading IBCAO V5.1 GeoTIFF (100m, with Greenland ice, ~25 GB)..." + Downloads.download(IBCAO_tiff_url, tiff_path; progress=download_progress) + end + + @info "Reprojecting IBCAO from Polar Stereographic (EPSG:3996) to WGS84 at 0.01°..." + reproject_ibcao_to_netcdf(tiff_path, nc_path) + @info "Reprojection complete. Removing raw GeoTIFF to save disk space..." + rm(tiff_path; force=true) + end + + return nc_path +end + +# Implemented in ext/NumericalEarthArchGDALExt.jl when ArchGDAL is loaded. +function reproject_ibcao_to_netcdf end + +end # module diff --git a/src/DataWrangling/IBCSO/IBCSO.jl b/src/DataWrangling/IBCSO/IBCSO.jl new file mode 100644 index 000000000..080855db8 --- /dev/null +++ b/src/DataWrangling/IBCSO/IBCSO.jl @@ -0,0 +1,87 @@ +module IBCSO + +export IBCSOv2 + +using Downloads +using Oceananigans +using Oceananigans.DistributedComputations: @root +using Scratch + +using ..DataWrangling: download_progress, Metadatum, metadata_path, AbstractStaticBathymetry + +import NumericalEarth.DataWrangling: + metadata_filename, + default_download_directory, + dataset_variable_name, + download_dataset, + longitude_interfaces, + latitude_interfaces, + reversed_vertical_axis, + validate_dataset_coverage + + +download_IBCSO_cache::String = "" +function __init__() + global download_IBCSO_cache = @get_scratch!("IBCSO") +end + +IBCSO_bathymetry_variable_names = Dict( + :bottom_height => "z", # Variable name in NetCDF +) + +""" + IBCSOv2 + +International Bathymetric Chart of the Southern Ocean Version 2 (2024 Annual Release). +High-resolution (500m) bathymetry for the Southern Ocean (south of 50°S). + +Reference: Dorschel et al. (2022), https://doi.org/10.1594/PANGAEA.937574 +Data source: https://ibcso.org/ibcso-2024-annual-release/ +""" +struct IBCSOv2 <: AbstractStaticBathymetry end + +default_download_directory(::IBCSOv2) = download_IBCSO_cache +reversed_vertical_axis(::IBCSOv2) = false + +# WGS84 version covers -180 to 180 longitude, -90 to -50 latitude +longitude_interfaces(::IBCSOv2) = (-180, 180) +latitude_interfaces(::IBCSOv2) = (-90, -50) + +# Grid size for WGS84 version (500m resolution) +# lon: 33812, lat: 3757 (from -180 to 180, -90 to -50) +Base.size(::IBCSOv2) = (33812, 3757, 1) + +const IBCSOMetadatum = Metadatum{<:IBCSOv2} + +dataset_variable_name(data::IBCSOMetadatum) = IBCSO_bathymetry_variable_names[data.name] + +const IBCSO_pangaea_url = "https://download.pangaea.de/dataset/937574/files/IBCSO_v2_bed_WGS84.nc" + +metadata_url(::IBCSOMetadatum) = IBCSO_pangaea_url + +# The expected NetCDF filename inside the ZIP or from PANGAEA +const IBCSO_nc_filename = "IBCSO_v2_bed_WGS84.nc" +metadata_filename(::IBCSOv2, name, date, bounding_box) = IBCSO_nc_filename + +function validate_dataset_coverage(grid, ::IBCSOMetadatum) + _, φ_north = Oceananigans.Grids.y_domain(grid) + if φ_north > -50 + error("IBCSOv2 only covers the Southern Ocean (south of 50°S). " * + "The grid extends to $(round(φ_north, digits=1))°. " * + "Use ETOPO2022() or GEBCO2024() for domains that include latitudes north of 50°S.") + end +end + +function download_dataset(metadatum::IBCSOMetadatum) + filepath = metadata_path(metadatum) + download_dir = metadatum.dir + + @root if !isfile(filepath) + @info "Downloading IBCSO data: $(metadatum.name) to $download_dir..." + Downloads.download(IBCSO_pangaea_url, filepath; progress=download_progress) + end + + return filepath +end + +end # module diff --git a/src/DataWrangling/JRA55/JRA55.jl b/src/DataWrangling/JRA55/JRA55.jl index 5751b9dcb..57cdcd6b4 100644 --- a/src/DataWrangling/JRA55/JRA55.jl +++ b/src/DataWrangling/JRA55/JRA55.jl @@ -1,6 +1,11 @@ module JRA55 -export JRA55FieldTimeSeries, JRA55PrescribedAtmosphere, JRA55PrescribedLand, RepeatYearJRA55, MultiYearJRA55 +export JRA55FieldTimeSeries, + JRA55PrescribedAtmosphere, + JRA55PrescribedLand, + JRA55PrescribedRadiation, + RepeatYearJRA55, + MultiYearJRA55 using Oceananigans using Oceananigans.Units @@ -14,9 +19,8 @@ using Oceananigans.OutputReaders: Cyclical, TotallyInMemory, AbstractInMemoryBac using NumericalEarth -using NumericalEarth.Atmospheres: - PrescribedAtmosphere, - TwoBandDownwellingRadiation +using NumericalEarth.Atmospheres: PrescribedAtmosphere +using NumericalEarth.Radiations: PrescribedRadiation, SurfaceRadiationProperties, default_stefan_boltzmann_constant using GPUArraysCore: @allowscalar @@ -40,5 +44,6 @@ include("JRA55_metadata.jl") include("JRA55_field_time_series.jl") include("JRA55_prescribed_atmosphere.jl") include("JRA55_prescribed_land.jl") +include("JRA55_prescribed_radiation.jl") end # module diff --git a/src/DataWrangling/JRA55/JRA55_metadata.jl b/src/DataWrangling/JRA55/JRA55_metadata.jl index 53f1fa58c..27f7e4fad 100644 --- a/src/DataWrangling/JRA55/JRA55_metadata.jl +++ b/src/DataWrangling/JRA55/JRA55_metadata.jl @@ -201,5 +201,5 @@ function download_dataset(metadata::JRA55Metadata) end end - return nothing + return metadata_path(metadata) end diff --git a/src/DataWrangling/JRA55/JRA55_prescribed_atmosphere.jl b/src/DataWrangling/JRA55/JRA55_prescribed_atmosphere.jl index ae395238d..373566c54 100644 --- a/src/DataWrangling/JRA55/JRA55_prescribed_atmosphere.jl +++ b/src/DataWrangling/JRA55/JRA55_prescribed_atmosphere.jl @@ -15,8 +15,11 @@ JRA55PrescribedAtmosphere(arch::Distributed, FT = Float32; kw...) = other_kw...) Return a [`PrescribedAtmosphere`](@ref) representing JRA55 reanalysis data. -The atmospheric data will be held in `JRA55FieldTimeSeries` objects containing. -For a detailed description of the keyword arguments, see the [`JRA55FieldTimeSeries`](@ref) constructor. +The atmospheric data will be held in `JRA55FieldTimeSeries` objects. + +Note: downwelling shortwave / longwave radiation is now part of the +top-level `radiation` component. Use [`JRA55PrescribedRadiation`](@ref) to +load JRA55 SW/LW into a `PrescribedRadiation`. """ function JRA55PrescribedAtmosphere(architecture = CPU(), FT = Float32; dataset = RepeatYearJRA55(), @@ -30,15 +33,13 @@ function JRA55PrescribedAtmosphere(architecture = CPU(), FT = Float32; kw = (; time_indexing, backend, start_date, end_date, dataset) kw = merge(kw, other_kw) - ua = JRA55FieldTimeSeries(:eastward_velocity, architecture, FT; kw...) - va = JRA55FieldTimeSeries(:northward_velocity, architecture, FT; kw...) - Ta = JRA55FieldTimeSeries(:temperature, architecture, FT; kw...) - qa = JRA55FieldTimeSeries(:specific_humidity, architecture, FT; kw...) - pa = JRA55FieldTimeSeries(:sea_level_pressure, architecture, FT; kw...) - Fra = JRA55FieldTimeSeries(:rain_freshwater_flux, architecture, FT; kw...) - Fsn = JRA55FieldTimeSeries(:snow_freshwater_flux, architecture, FT; kw...) - ℐꜜˡʷ = JRA55FieldTimeSeries(:downwelling_longwave_radiation, architecture, FT; kw...) - ℐꜜˢʷ = JRA55FieldTimeSeries(:downwelling_shortwave_radiation, architecture, FT; kw...) + ua = JRA55FieldTimeSeries(:eastward_velocity, architecture, FT; kw...) + va = JRA55FieldTimeSeries(:northward_velocity, architecture, FT; kw...) + Ta = JRA55FieldTimeSeries(:temperature, architecture, FT; kw...) + qa = JRA55FieldTimeSeries(:specific_humidity, architecture, FT; kw...) + pa = JRA55FieldTimeSeries(:sea_level_pressure, architecture, FT; kw...) + Fra = JRA55FieldTimeSeries(:rain_freshwater_flux, architecture, FT; kw...) + Fsn = JRA55FieldTimeSeries(:snow_freshwater_flux, architecture, FT; kw...) freshwater_flux = (rain = Fra, snow = Fsn) @@ -54,8 +55,6 @@ function JRA55PrescribedAtmosphere(architecture = CPU(), FT = Float32; pressure = pa - downwelling_radiation = TwoBandDownwellingRadiation(shortwave=ℐꜜˢʷ, longwave=ℐꜜˡʷ) - FT = eltype(ua) surface_layer_height = convert(FT, surface_layer_height) @@ -63,7 +62,6 @@ function JRA55PrescribedAtmosphere(architecture = CPU(), FT = Float32; velocities, freshwater_flux, tracers, - downwelling_radiation, surface_layer_height, pressure) diff --git a/src/DataWrangling/JRA55/JRA55_prescribed_radiation.jl b/src/DataWrangling/JRA55/JRA55_prescribed_radiation.jl new file mode 100644 index 000000000..da28be94a --- /dev/null +++ b/src/DataWrangling/JRA55/JRA55_prescribed_radiation.jl @@ -0,0 +1,42 @@ +JRA55PrescribedRadiation(arch::Distributed, FT = Float32; kw...) = + JRA55PrescribedRadiation(child_architecture(arch); kw...) + +""" + JRA55PrescribedRadiation([architecture = CPU(), FT = Float32]; + dataset = RepeatYearJRA55(), + start_date = first_date(dataset, :downwelling_shortwave_radiation), + end_date = last_date(dataset, :downwelling_shortwave_radiation), + backend = JRA55NetCDFBackend(10), + time_indexing = Cyclical(), + ocean_surface = SurfaceRadiationProperties(0.05, 0.97), + sea_ice_surface = SurfaceRadiationProperties(0.7, 1.0), + stefan_boltzmann_constant = default_stefan_boltzmann_constant, + other_kw...) + +Return a [`PrescribedRadiation`](@ref) backed by JRA55 downwelling shortwave +and longwave `JRA55FieldTimeSeries`. Surface radiative properties (albedo, +emissivity) for ocean and sea-ice surfaces default to standard values; pass +`ocean_surface = nothing` (or `sea_ice_surface = nothing`) to omit a surface. +""" +function JRA55PrescribedRadiation(architecture = CPU(), FT = Float32; + dataset = RepeatYearJRA55(), + start_date = first_date(dataset, :downwelling_shortwave_radiation), + end_date = last_date(dataset, :downwelling_shortwave_radiation), + backend = JRA55NetCDFBackend(10), + time_indexing = Cyclical(), + ocean_surface = SurfaceRadiationProperties(0.05, 0.97), + sea_ice_surface = SurfaceRadiationProperties(0.7, 1.0), + stefan_boltzmann_constant = default_stefan_boltzmann_constant, + other_kw...) + + kw = (; time_indexing, backend, start_date, end_date, dataset) + kw = merge(kw, other_kw) + + ℐꜜˢʷ = JRA55FieldTimeSeries(:downwelling_shortwave_radiation, architecture, FT; kw...) + ℐꜜˡʷ = JRA55FieldTimeSeries(:downwelling_longwave_radiation, architecture, FT; kw...) + + return PrescribedRadiation(ℐꜜˢʷ, ℐꜜˡʷ; + ocean_surface, + sea_ice_surface, + stefan_boltzmann_constant) +end diff --git a/src/DataWrangling/OSPapa/OSPapa.jl b/src/DataWrangling/OSPapa/OSPapa.jl index 0499ac0d5..056145019 100644 --- a/src/DataWrangling/OSPapa/OSPapa.jl +++ b/src/DataWrangling/OSPapa/OSPapa.jl @@ -1,6 +1,7 @@ module OSPapa export OSPapaPrescribedAtmosphere +export OSPapaPrescribedRadiation export os_papa_prescribed_fluxes export os_papa_prescribed_flux_boundary_conditions export OSPapaHourly @@ -14,7 +15,7 @@ using Downloads using Thermodynamics: q_vap_from_RH, Liquid using NumericalEarth.DataWrangling: download_progress -using NumericalEarth.Atmospheres: PrescribedAtmosphere, TwoBandDownwellingRadiation, AtmosphereThermodynamicsParameters +using NumericalEarth.Atmospheres: PrescribedAtmosphere, AtmosphereThermodynamicsParameters using NumericalEarth.Oceans: reference_density, heat_capacity using NumericalEarth.DataWrangling: @@ -80,6 +81,7 @@ end include("OSPapa_ocean_observations.jl") include("OSPapa_flux_observations.jl") include("OSPapa_prescribed_atmosphere.jl") +include("OSPapa_prescribed_radiation.jl") include("OSPapa_prescribed_fluxes.jl") end # module diff --git a/src/DataWrangling/OSPapa/OSPapa_flux_observations.jl b/src/DataWrangling/OSPapa/OSPapa_flux_observations.jl index 75cad9f9d..ad9d11ccc 100644 --- a/src/DataWrangling/OSPapa/OSPapa_flux_observations.jl +++ b/src/DataWrangling/OSPapa/OSPapa_flux_observations.jl @@ -96,7 +96,7 @@ build_filename(::OSPapaFluxHourly, name, dates::AbstractArray, region) = function download_dataset(md::OSPapaFluxMetadata) uniform_path = joinpath(md.dir, metadata_filename(md)) - isfile(uniform_path) && return nothing + isfile(uniform_path) && return uniform_path if !(md.dates isa AbstractArray) error("OSPapaFluxHourly uniform cache $(uniform_path) is missing; " * @@ -107,7 +107,7 @@ function download_dataset(md::OSPapaFluxMetadata) end_date = last(md.dates) raw_path = download_ospapa_flux(; start_date, end_date, dir=md.dir) _write_uniform_flux_file(raw_path, uniform_path, start_date, end_date) - return nothing + return uniform_path end function _write_uniform_flux_file(raw_path, uniform_path, start_date, end_date) diff --git a/src/DataWrangling/OSPapa/OSPapa_prescribed_atmosphere.jl b/src/DataWrangling/OSPapa/OSPapa_prescribed_atmosphere.jl index c06d3f747..9990eee11 100644 --- a/src/DataWrangling/OSPapa/OSPapa_prescribed_atmosphere.jl +++ b/src/DataWrangling/OSPapa/OSPapa_prescribed_atmosphere.jl @@ -69,8 +69,6 @@ function OSPapaPrescribedAtmosphere(architecture = CPU(), FT = Float32; va = ospapa_fts(:northward_wind) Ta = ospapa_fts(:air_temperature) # K (Celsius conversion) Pa = ospapa_fts(:sea_level_pressure) # Pa (Millibar conversion) - swa = ospapa_fts(:shortwave_radiation) - lwa = ospapa_fts(:longwave_radiation) rain = ospapa_fts(:rain) # kg/m²/s (MillimetersPerHour conversion) thermo_params = AtmosphereThermodynamicsParameters(FT) @@ -82,7 +80,6 @@ function OSPapaPrescribedAtmosphere(architecture = CPU(), FT = Float32; tracers = (T=Ta, q=qa), pressure = Pa, freshwater_flux = (; rain), - downwelling_radiation = TwoBandDownwellingRadiation(shortwave=swa, longwave=lwa), thermodynamics_parameters = thermo_params, surface_layer_height = convert(FT, surface_layer_height)) end diff --git a/src/DataWrangling/OSPapa/OSPapa_prescribed_radiation.jl b/src/DataWrangling/OSPapa/OSPapa_prescribed_radiation.jl new file mode 100644 index 000000000..04ea5b798 --- /dev/null +++ b/src/DataWrangling/OSPapa/OSPapa_prescribed_radiation.jl @@ -0,0 +1,43 @@ +using NumericalEarth.Radiations: PrescribedRadiation, SurfaceRadiationProperties, default_stefan_boltzmann_constant + +""" + OSPapaPrescribedRadiation(architecture = CPU(), FT = Float32; + start_date = first_date(OSPapaHourly(), :shortwave_radiation), + end_date = last_date(OSPapaHourly(), :shortwave_radiation), + dir = download_OSPapa_cache, + max_gap_hours = 72, + ocean_surface = SurfaceRadiationProperties(0.05, 0.97), + sea_ice_surface = nothing, + stefan_boltzmann_constant = default_stefan_boltzmann_constant) + +Construct a `PrescribedRadiation` from Ocean Station Papa buoy SW/LW +observations on a single-column grid. +""" +function OSPapaPrescribedRadiation(architecture = CPU(), FT = Float32; + start_date = first_date(OSPapaHourly(), :shortwave_radiation), + end_date = last_date(OSPapaHourly(), :shortwave_radiation), + dir = download_OSPapa_cache, + max_gap_hours = 72, + ocean_surface = SurfaceRadiationProperties(0.05, 0.97), + sea_ice_surface = nothing, + stefan_boltzmann_constant = default_stefan_boltzmann_constant) + + mdkw = (; dataset = OSPapaHourly(), start_date, end_date, dir) + surface_grid = RectilinearGrid(architecture, FT; size=(), topology=(Flat, Flat, Flat)) + + function ospapa_fts(name) + md = Metadata(name; mdkw...) + download_dataset(md) + fts = FieldTimeSeries(md, surface_grid; time_indices_in_memory = length(md)) + fill_gaps!(fts; max_gap = max_gap_hours) + return fts + end + + swa = ospapa_fts(:shortwave_radiation) + lwa = ospapa_fts(:longwave_radiation) + + return PrescribedRadiation(swa, lwa; + ocean_surface, + sea_ice_surface, + stefan_boltzmann_constant) +end diff --git a/src/DataWrangling/metadata.jl b/src/DataWrangling/metadata.jl index cb1153e16..4f973fd59 100644 --- a/src/DataWrangling/metadata.jl +++ b/src/DataWrangling/metadata.jl @@ -331,6 +331,16 @@ Return the name used for the variable `metadata.name` in its raw dataset file. """ function dataset_variable_name end +""" + validate_dataset_coverage(grid, metadata) + +Check that `grid` lies within the spatial coverage of `metadata`'s dataset. +Throws an error if the grid extends outside the dataset's domain. +The default implementation does nothing (all grids are accepted). +Dataset-specific methods can override this to enforce coverage constraints. +""" +validate_dataset_coverage(grid, metadata) = nothing + """ dataset_location(dataset, variable_name) @@ -405,6 +415,7 @@ struct NanomolePerKilogram end struct NanomolePerLiter end struct InverseSign end +struct InverseGravity end struct GramPerKilogramMinus35 end # Salinity anomaly struct MilliliterPerLiter end # Sometimes for disssolved_oxygen diff --git a/src/DataWrangling/metadata_field.jl b/src/DataWrangling/metadata_field.jl index 6a7c60b4d..d5656f320 100644 --- a/src/DataWrangling/metadata_field.jl +++ b/src/DataWrangling/metadata_field.jl @@ -26,11 +26,16 @@ restrict(::Nothing, interfaces, N) = interfaces, N # TODO support stretched native grids function restrict(bbox_interfaces, interfaces, N) - extent = interfaces[end] - interfaces[1] - rΔ = bbox_interfaces[2] - bbox_interfaces[1] - rN = round(Int, rΔ / extent * N) - rN = max(rN, 1) # at least one cell - return bbox_interfaces, rN + LΔ = interfaces[2] - interfaces[1] + Δ = LΔ / N + grid_interfaces = (bbox_interfaces[1] - Δ/2, + bbox_interfaces[2] + Δ/2) + + rΔ = grid_interfaces[2] - grid_interfaces[1] + ϵ = rΔ / LΔ + rN = ceil(Int, ϵ * N) # Round up to ensure bounding box is covered + + return grid_interfaces, rN end """ @@ -235,7 +240,12 @@ function set!(target_field::Field, metadata::Metadatum; kw...) Lzt = grid.Lz Lzm = meta_field.grid.Lz - if Lzt > Lzm && is_three_dimensional(metadata) + # Allow up to 1% vertical mismatch for pressure-level datasets with time-varying + # geopotential heights — the per-timestep vertical extent can be slightly smaller + # than the temporal-mean extent used for the target grid (e.g. when the atmosphere + # is compressed). Oceananigans' interpolate! does not extrapolate, so target points + # just outside the source domain will use the nearest interior values. + if is_three_dimensional(metadata) && Lzt > Lzm * (1 + 1e-2) throw("The vertical range of the $(metadata.dataset) dataset ($(Lzm) m) is smaller than " * "the target grid ($(Lzt) m). Some vertical levels cannot be filled with data.") end @@ -272,6 +282,16 @@ function column_field_from_file(metadata, arch; halo=(3, 3, 3), kw...) end _, _, Nz, _ = size(metadata) + + # Validate that the cached file's vertical extent matches the dataset + # configuration. A common cause of mismatch is a stale cache from a previous + # run with a different vertical configuration (e.g. ERA5 `pressure_levels`). + if is_three_dimensional(metadata) && length(data_size) >= 3 && data_size[3] != Nz + error("Cached file $(path) has $(data_size[3]) vertical levels, but the " * + "dataset configuration expects $Nz. This is most likely a stale " * + "cache from a previous run with a different vertical configuration. " * + "Delete the file and re-run.") + end z = z_interfaces(metadata) FT = eltype(metadata) @@ -389,9 +409,12 @@ function set_metadata_field!(field, data, metadatum) arch = architecture(grid) Nx, Ny, Nz = size(metadatum) + mangling = if size(data, 2) == Ny-1 + @debug "Shifting field southward" ShiftSouth() elseif size(data, 2) == Ny+1 + @debug "Averaging field in north-south dir" AverageNorthSouth() else nothing @@ -485,6 +508,7 @@ end @inline convert_units(C::FT, ::Union{NanomolePerLiter, NanomolePerKilogram}) where FT = C * convert(FT, 1e-6) @inline convert_units(C::FT, ::MilliliterPerLiter) where FT = C / convert(FT, 22.3916) @inline convert_units(C::FT, ::GramPerKilogramMinus35) where FT = C + convert(FT, 35) +@inline convert_units(Φ::FT, ::InverseGravity) where FT = Φ / convert(FT, 9.80665) @inline convert_units(V::FT, ::CentimetersPerSecond) where FT = V / convert(FT, 100) diff --git a/src/Diagnostics/meridional_heat_transport.jl b/src/Diagnostics/meridional_heat_transport.jl index 6bc6338db..f3b432e38 100644 --- a/src/Diagnostics/meridional_heat_transport.jl +++ b/src/Diagnostics/meridional_heat_transport.jl @@ -60,8 +60,9 @@ ocean = ocean_simulation(grid; sea_ice = sea_ice_simulation(grid, ocean) atmosphere = PrescribedAtmosphere(grid, [0.0]) +radiation = PrescribedRadiation(grid) -esm = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation = Radiation()) +esm = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation) mht = meridional_heat_transport(esm) diff --git a/src/EarthSystemModels/EarthSystemModels.jl b/src/EarthSystemModels/EarthSystemModels.jl index 5f55ea644..80de85734 100644 --- a/src/EarthSystemModels/EarthSystemModels.jl +++ b/src/EarthSystemModels/EarthSystemModels.jl @@ -8,8 +8,6 @@ export SimilarityTheoryFluxes, CoefficientBasedFluxes, FreezingLimitedOceanTemperature, - Radiation, - LatitudeDependentAlbedo, SkinTemperature, BulkTemperature, compute_atmosphere_ocean_fluxes!, @@ -80,10 +78,10 @@ const NoOceanInterface = ComponentInterfaces{<:Nothing, <:AtmosphereInterface, const NoAtmosInterface = ComponentInterfaces{<:Nothing, <:Nothing, <:SeaIceOceanInterface} const NoInterface = ComponentInterfaces{<:Nothing, <:Nothing, <:Nothing} -const NoSeaIceInterfaceModel = EarthSystemModel{I, A, L, O, <:NoSeaIceInterface} where {I, A, L, O} -const NoAtmosInterfaceModel = EarthSystemModel{I, A, L, O, <:NoAtmosInterface} where {I, A, L, O} -const NoOceanInterfaceModel = EarthSystemModel{I, A, L, O, <:NoOceanInterface} where {I, A, L, O} -const NoInterfaceModel = EarthSystemModel{I, A, L, O, <:NoInterface} where {I, A, L, O} +const NoSeaIceInterfaceModel = EarthSystemModel{R, A, L, I, O, <:NoSeaIceInterface} where {R, A, L, I, O} +const NoAtmosInterfaceModel = EarthSystemModel{R, A, L, I, O, <:NoAtmosInterface} where {R, A, L, I, O} +const NoOceanInterfaceModel = EarthSystemModel{R, A, L, I, O, <:NoOceanInterface} where {R, A, L, I, O} +const NoInterfaceModel = EarthSystemModel{R, A, L, I, O, <:NoInterface} where {R, A, L, I, O} InterfaceComputations.compute_atmosphere_sea_ice_fluxes!(::NoSeaIceInterfaceModel) = nothing InterfaceComputations.compute_sea_ice_ocean_fluxes!(::NoSeaIceInterfaceModel) = nothing diff --git a/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl b/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl index ed93f8c9c..62fb8f15d 100644 --- a/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl +++ b/src/EarthSystemModels/InterfaceComputations/InterfaceComputations.jl @@ -6,9 +6,7 @@ using Oceananigans.Utils: KernelParameters using Adapt export - Radiation, ComponentInterfaces, - LatitudeDependentAlbedo, SimilarityTheoryFluxes, MomentumRoughnessLength, ScalarRoughnessLength, @@ -41,6 +39,26 @@ import Oceananigans.Simulations: initialize! net_fluxes(::Nothing) = nothing +##### +##### Radiation hooks: declared here so the turbulent flux kernels can +##### resolve them at parse time. The `Radiations` module extends them +##### with concrete methods for `PrescribedRadiation`. +##### + +# `nothing` fallback (radiation is off). Concrete methods for +# `PrescribedRadiation` (and future radiation types) are added in `Radiations`. +@inline kernel_radiation_properties(::Nothing) = nothing + +@inline function air_sea_interface_radiation_state(::Nothing, ::Nothing, i, j, k, grid, time) + z = zero(eltype(grid)) + return (σ = z, α = z, ϵ = z, ℐꜜˢʷ = z, ℐꜜˡʷ = z) +end + +@inline function air_sea_ice_interface_radiation_state(::Nothing, ::Nothing, i, j, k, grid, time) + z = zero(eltype(grid)) + return (σ = z, α = z, ϵ = z, ℐꜜˢʷ = z, ℐꜜˡʷ = z) +end + ##### ##### Utilities ##### @@ -63,11 +81,6 @@ function interface_kernel_parameters(grid) return kernel_parameters end -# Radiation -include("radiation.jl") -include("latitude_dependent_albedo.jl") -include("tabulated_albedo.jl") - # Turbulent fluxes include("roughness_lengths.jl") include("interface_states.jl") diff --git a/src/EarthSystemModels/InterfaceComputations/atmosphere_ocean_fluxes.jl b/src/EarthSystemModels/InterfaceComputations/atmosphere_ocean_fluxes.jl index a32f36c01..f0ea3af2b 100644 --- a/src/EarthSystemModels/InterfaceComputations/atmosphere_ocean_fluxes.jl +++ b/src/EarthSystemModels/InterfaceComputations/atmosphere_ocean_fluxes.jl @@ -11,7 +11,7 @@ function compute_atmosphere_ocean_fluxes!(coupled_model) # Simplify NamedTuple to reduce parameter space consumption. # See https://github.com/CliMA/NumericalEarth.jl/issues/116. - atmosphere_data = merge(atmosphere_fields, + atmosphere_data = merge(atmosphere_fields, (; h_bℓ = boundary_layer_height(coupled_model.atmosphere))) flux_formulation = coupled_model.interfaces.atmosphere_ocean_interface.flux_formulation @@ -23,6 +23,15 @@ function compute_atmosphere_ocean_fluxes!(coupled_model) surface_layer_height = surface_layer_height(coupled_model.atmosphere), gravitational_acceleration = coupled_model.interfaces.properties.gravitational_acceleration) + # Radiation state for the interface solve (used by SkinTemperature). + # When `radiation === nothing` these are `nothing`s and the getter + # returns zero-valued radiative state, so SkinTemperature degrades to + # a turbulent-only flux balance. + radiation = coupled_model.radiation + radiation_kernel_props = kernel_radiation_properties(radiation) + radiation_exchanger = exchanger.radiation + radiation_state = isnothing(radiation_exchanger) ? nothing : radiation_exchanger.state + kernel_parameters = interface_kernel_parameters(grid) launch!(arch, grid, kernel_parameters, @@ -36,12 +45,14 @@ function compute_atmosphere_ocean_fluxes!(coupled_model) atmosphere_data, interface_properties, atmosphere_properties, - ocean_properties) + ocean_properties, + radiation_kernel_props, + radiation_state) return nothing end -""" Compute turbulent fluxes between an atmosphere and a interface state using similarity theory """ +""" Compute turbulent fluxes between an atmosphere and an interface state using similarity theory """ @kernel function _compute_atmosphere_ocean_interface_state!(interface_fluxes, interface_temperature, grid, @@ -51,7 +62,9 @@ end atmosphere_state, interface_properties, atmosphere_properties, - ocean_properties) + ocean_properties, + radiation_kernel_props, + radiation_exchanger_state) i, j = @index(Global, NTuple) kᴺ = size(grid, 3) # index of the top ocean cell @@ -63,11 +76,8 @@ end Tᵃᵗ = atmosphere_state.T[i, j, 1] pᵃᵗ = atmosphere_state.p[i, j, 1] qᵃᵗ = atmosphere_state.q[i, j, 1] - ℐꜜˢʷ = atmosphere_state.ℐꜜˢʷ[i, j, 1] - ℐꜜˡʷ = atmosphere_state.ℐꜜˡʷ[i, j, 1] - # Extract state variables at cell centers - # Ocean state + # Ocean state at cell centers uᵒᶜ = ℑxᶜᵃᵃ(i, j, kᴺ, grid, interior_state.u) vᵒᶜ = ℑyᵃᶜᵃ(i, j, kᴺ, grid, interior_state.v) Tᵒᶜ = interior_state.T[i, j, kᴺ] @@ -76,8 +86,6 @@ end end # Build thermodynamic and dynamic states in the atmosphere and interface. - # Notation: - # ⋅ 𝒰 ≡ "dynamic" state vector (thermodynamics + reference height + velocity) ℂᵃᵗ = atmosphere_properties.thermodynamics_parameters zᵃᵗ = atmosphere_properties.surface_layer_height # elevation of atmos variables relative to interface @@ -90,7 +98,12 @@ end h_bℓ = atmosphere_state.h_bℓ) local_interior_state = (u=uᵒᶜ, v=vᵒᶜ, T=Tᵒᶜ, S=Sᵒᶜ) - downwelling_radiation = (; ℐꜜˢʷ, ℐꜜˡʷ) + + # Local radiative state at this cell. Returns zero-valued state when + # radiation is off. + radiation_state = air_sea_interface_radiation_state(radiation_kernel_props, + radiation_exchanger_state, + i, j, kᴺ, grid, time) # Estimate initial interface state FT = typeof(Tᵒᶜ) @@ -106,16 +119,6 @@ end needs_to_converge = stop_criteria isa ConvergenceStopCriteria not_water = inactive_node(i, j, kᴺ, grid, Center(), Center(), Center()) - # Compute local radiative properties and rebuild the interface properties - α = stateindex(interface_properties.radiation.α, i, j, kᴺ, grid, time, (Center, Center, Center), ℐꜜˢʷ) - ϵ = stateindex(interface_properties.radiation.ϵ, i, j, kᴺ, grid, time, (Center, Center, Center)) - σ = interface_properties.radiation.σ - - interface_properties = InterfaceProperties((; α, ϵ, σ), - interface_properties.specific_humidity_formulation, - interface_properties.temperature_formulation, - interface_properties.velocity_formulation) - if needs_to_converge && not_water interface_state = zero_interface_state(FT) else @@ -123,7 +126,7 @@ end initial_interface_state, local_atmosphere_state, local_interior_state, - downwelling_radiation, + radiation_state, interface_properties, atmosphere_properties, ocean_properties) diff --git a/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl b/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl index 317b337b9..7f9c7f5f4 100644 --- a/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl +++ b/src/EarthSystemModels/InterfaceComputations/atmosphere_sea_ice_fluxes.jl @@ -14,9 +14,7 @@ function compute_atmosphere_sea_ice_fluxes!(coupled_model) atmosphere_fields = exchanger.atmosphere.state - # Simplify NamedTuple to reduce parameter space consumption. - # See https://github.com/CliMA/NumericalEarth.jl/issues/116. - atmosphere_data = merge(atmosphere_fields, + atmosphere_data = merge(atmosphere_fields, (; h_bℓ = boundary_layer_height(coupled_model.atmosphere))) flux_formulation = coupled_model.interfaces.atmosphere_sea_ice_interface.flux_formulation @@ -30,6 +28,11 @@ function compute_atmosphere_sea_ice_fluxes!(coupled_model) surface_layer_height = surface_layer_height(coupled_model.atmosphere), gravitational_acceleration = coupled_model.interfaces.properties.gravitational_acceleration) + radiation = coupled_model.radiation + radiation_kernel_props = kernel_radiation_properties(radiation) + radiation_exchanger = exchanger.radiation + radiation_state = isnothing(radiation_exchanger) ? nothing : radiation_exchanger.state + kernel_parameters = interface_kernel_parameters(grid) launch!(arch, grid, kernel_parameters, @@ -44,12 +47,14 @@ function compute_atmosphere_sea_ice_fluxes!(coupled_model) interface_properties, atmosphere_properties, sea_ice_properties, - ocean_properties) + ocean_properties, + radiation_kernel_props, + radiation_state) return nothing end -""" Compute turbulent fluxes between an atmosphere and a interface state using similarity theory """ +""" Compute turbulent fluxes between an atmosphere and an interface state using similarity theory """ @kernel function _compute_atmosphere_sea_ice_interface_state!(interface_fluxes, interface_temperature, grid, @@ -60,11 +65,14 @@ end interface_properties, atmosphere_properties, sea_ice_properties, - ocean_properties) + ocean_properties, + radiation_kernel_props, + radiation_exchanger_state) i, j = @index(Global, NTuple) kᴺ = size(grid, 3) # index of the top ocean cell FT = eltype(grid) + time = Time(clock.time) @inbounds begin uᵃᵗ = atmosphere_state.u[i, j, 1] @@ -72,10 +80,7 @@ end Tᵃᵗ = atmosphere_state.T[i, j, 1] pᵃᵗ = atmosphere_state.p[i, j, 1] qᵃᵗ = atmosphere_state.q[i, j, 1] - ℐꜜˢʷ = atmosphere_state.ℐꜜˢʷ[i, j, 1] - ℐꜜˡʷ = atmosphere_state.ℐꜜˡʷ[i, j, 1] - # Extract state variables at cell centers # Ocean properties below sea ice Tᵒᶜ = interior_state.Tᵒᶜ[i, j, kᴺ] Tᵒᶜ = convert_to_kelvin(ocean_properties.temperature_units, Tᵒᶜ) @@ -104,9 +109,14 @@ end q = qᵃᵗ, h_bℓ = atmosphere_state.h_bℓ) - downwelling_radiation = (; ℐꜜˢʷ, ℐꜜˡʷ) local_interior_state = (u=uˢⁱ, v=vˢⁱ, T=Tᵒᶜ, S=Sᵒᶜ, hi=hˢⁱ, hs=hˢⁿ, hc=hc) - + + # Local radiative state at this cell. Returns zero-valued state when + # radiation is off. + radiation_state = air_sea_ice_interface_radiation_state(radiation_kernel_props, + radiation_exchanger_state, + i, j, kᴺ, grid, time) + # Estimate initial interface state (FP32 compatible) u★ = convert(FT, 1f-4) @@ -130,7 +140,7 @@ end initial_interface_state, local_atmosphere_state, local_interior_state, - downwelling_radiation, + radiation_state, interface_properties, atmosphere_properties, sea_ice_properties) diff --git a/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl b/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl index fa4efa420..adcf8603e 100644 --- a/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl +++ b/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl @@ -70,22 +70,18 @@ struct AtmosphereOceanFluxes{F} friction_velocity :: F temperature_scale :: F water_vapor_scale :: F - upwelling_longwave :: F - downwelling_longwave :: F - downwelling_shortwave :: F end function AtmosphereOceanFluxes(grid) F = Field{Center, Center, Nothing} return AtmosphereOceanFluxes(F(grid), F(grid), F(grid), - F(grid), F(grid), F(grid), F(grid), F(grid), F(grid), F(grid), F(grid)) end -AtmosphereOceanFluxes(::Nothing) = AtmosphereOceanFluxes(ntuple(_ -> ZeroField(), 11)...) +AtmosphereOceanFluxes(::Nothing) = AtmosphereOceanFluxes(ntuple(_ -> ZeroField(), 8)...) -Adapt.adapt_structure(to, fluxes::AtmosphereOceanFluxes) = +Adapt.adapt_structure(to, fluxes::AtmosphereOceanFluxes) = AtmosphereOceanFluxes(Adapt.adapt(to, fluxes.latent_heat), Adapt.adapt(to, fluxes.sensible_heat), Adapt.adapt(to, fluxes.water_vapor), @@ -93,12 +89,9 @@ Adapt.adapt_structure(to, fluxes::AtmosphereOceanFluxes) = Adapt.adapt(to, fluxes.y_momentum), Adapt.adapt(to, fluxes.friction_velocity), Adapt.adapt(to, fluxes.temperature_scale), - Adapt.adapt(to, fluxes.water_vapor_scale), - Adapt.adapt(to, fluxes.upwelling_longwave), - Adapt.adapt(to, fluxes.downwelling_longwave), - Adapt.adapt(to, fluxes.downwelling_shortwave)) + Adapt.adapt(to, fluxes.water_vapor_scale)) -on_architecture(arch, fluxes::AtmosphereOceanFluxes) = +on_architecture(arch, fluxes::AtmosphereOceanFluxes) = AtmosphereOceanFluxes(on_architecture(arch, fluxes.latent_heat), on_architecture(arch, fluxes.sensible_heat), on_architecture(arch, fluxes.water_vapor), @@ -106,10 +99,7 @@ on_architecture(arch, fluxes::AtmosphereOceanFluxes) = on_architecture(arch, fluxes.y_momentum), on_architecture(arch, fluxes.friction_velocity), on_architecture(arch, fluxes.temperature_scale), - on_architecture(arch, fluxes.water_vapor_scale), - on_architecture(arch, fluxes.upwelling_longwave), - on_architecture(arch, fluxes.downwelling_longwave), - on_architecture(arch, fluxes.downwelling_shortwave)) + on_architecture(arch, fluxes.water_vapor_scale)) struct AtmosphereSeaIceFluxes{F} latent_heat :: F @@ -174,7 +164,8 @@ on_architecture(arch, fluxes::SeaIceOceanFluxes) = # ZeroFluxes is returned by computed_fluxes(::Nothing) for absent interfaces. # It contains the union of all flux field names across interface types. struct ZeroFluxes{Z} - # Atmosphere-ocean and atmosphere-sea-ice flux fields + # Atmosphere-ocean and atmosphere-sea-ice flux fields (turbulent only; + # radiative diagnostic fields live on the radiation component) latent_heat :: Z sensible_heat :: Z water_vapor :: Z @@ -183,16 +174,13 @@ struct ZeroFluxes{Z} friction_velocity :: Z temperature_scale :: Z water_vapor_scale :: Z - upwelling_longwave :: Z - downwelling_longwave :: Z - downwelling_shortwave :: Z # Sea ice-ocean flux fields interface_heat :: Z frazil_heat :: Z salt :: Z end -ZeroFluxes() = ZeroFluxes(ntuple(_ -> ZeroField(), 14)...) +ZeroFluxes() = ZeroFluxes(ntuple(_ -> ZeroField(), 11)...) @inline computed_fluxes(::Nothing) = ZeroFluxes() @@ -224,10 +212,9 @@ atmosphere_ocean_interface(grid, ::Nothing, ocean, args...) = nothing atmosphere_ocean_interface(grid, ::Nothing, ::Nothing, args...) = nothing atmosphere_ocean_interface(grid, atmosphere, ::Nothing, args...) = nothing -function atmosphere_ocean_interface(grid, +function atmosphere_ocean_interface(grid, atmosphere, ocean, - radiation, ao_flux_formulation, temperature_formulation, velocity_formulation, @@ -235,13 +222,7 @@ function atmosphere_ocean_interface(grid, ao_fluxes = AtmosphereOceanFluxes(grid) - σ = radiation.stefan_boltzmann_constant - αₐₒ = radiation.reflection.ocean - ϵₐₒ = radiation.emission.ocean - radiation = (σ=σ, α=αₐₒ, ϵ=ϵₐₒ) - - ao_properties = InterfaceProperties(radiation, - specific_humidity_formulation, + ao_properties = InterfaceProperties(specific_humidity_formulation, temperature_formulation, velocity_formulation) @@ -258,26 +239,19 @@ atmosphere_sea_ice_interface(grid, atmos, ::Nothing, args...) = nothing atmosphere_sea_ice_interface(grid, ::Nothing, sea_ice, args...) = nothing atmosphere_sea_ice_interface(grid, ::Nothing, ::Nothing, args...) = nothing -function atmosphere_sea_ice_interface(grid, +function atmosphere_sea_ice_interface(grid, atmosphere, sea_ice, - radiation, ai_flux_formulation, temperature_formulation, velocity_formulation) fluxes = AtmosphereSeaIceFluxes(grid) - σ = radiation.stefan_boltzmann_constant - αₐᵢ = radiation.reflection.sea_ice - ϵₐᵢ = radiation.emission.sea_ice - radiation = (σ=σ, α=αₐᵢ, ϵ=ϵₐᵢ) - phase = AtmosphericThermodynamics.Ice() specific_humidity_formulation = ImpureSaturationSpecificHumidity(phase) - properties = InterfaceProperties(radiation, - specific_humidity_formulation, + properties = InterfaceProperties(specific_humidity_formulation, temperature_formulation, velocity_formulation) @@ -369,6 +343,7 @@ Keyword Arguments - `ThreeEquationHeatFlux()`: coupled heat/salt/freezing point system (default) - `radiation`: radiation component. Default: `Radiation()`. ++ `radiation`: radiation component. Default: `nothing`. - `freshwater_density`: reference density of freshwater. Default: `default_freshwater_density`. - `atmosphere_ocean_fluxes`: flux formulation for atmosphere-ocean interface. Default: `SimilarityTheoryFluxes()`. - `atmosphere_sea_ice_fluxes`: flux formulation for atmosphere-sea ice interface. Default: `SimilarityTheoryFluxes()`. @@ -384,9 +359,9 @@ Keyword Arguments - `gravitational_acceleration`: gravitational acceleration. Default: `default_gravitational_acceleration`. """ function ComponentInterfaces(atmosphere, ocean, sea_ice=nothing; + radiation = nothing, land = nothing, exchange_grid = exchange_grid(atmosphere, ocean, sea_ice), - radiation = Radiation(), freshwater_density = default_freshwater_density, atmosphere_ocean_fluxes = SimilarityTheoryFluxes(eltype(exchange_grid)), atmosphere_sea_ice_fluxes = atmosphere_sea_ice_similarity_theory(eltype(exchange_grid)), @@ -436,7 +411,6 @@ function ComponentInterfaces(atmosphere, ocean, sea_ice=nothing; ao_interface = atmosphere_ocean_interface(exchange_grid, atmosphere, ocean, - radiation, atmosphere_ocean_fluxes, atmosphere_ocean_interface_temperature, atmosphere_ocean_velocity_difference, @@ -444,10 +418,9 @@ function ComponentInterfaces(atmosphere, ocean, sea_ice=nothing; io_interface = sea_ice_ocean_interface(exchange_grid, sea_ice, ocean, sea_ice_ocean_heat_flux) - ai_interface = atmosphere_sea_ice_interface(exchange_grid, + ai_interface = atmosphere_sea_ice_interface(exchange_grid, atmosphere, sea_ice, - radiation, atmosphere_sea_ice_fluxes, atmosphere_sea_ice_interface_temperature, atmosphere_sea_ice_velocity_difference) @@ -456,7 +429,7 @@ function ComponentInterfaces(atmosphere, ocean, sea_ice=nothing; sea_ice = net_fluxes(sea_ice), atmosphere = net_fluxes(atmosphere)) - exchanger = StateExchanger(exchange_grid, atmosphere, land, ocean, sea_ice) + exchanger = StateExchanger(exchange_grid, radiation, atmosphere, land, ocean, sea_ice) properties = (; gravitational_acceleration) diff --git a/src/EarthSystemModels/InterfaceComputations/compute_interface_state.jl b/src/EarthSystemModels/InterfaceComputations/compute_interface_state.jl index e52b5a53a..b8703a79d 100644 --- a/src/EarthSystemModels/InterfaceComputations/compute_interface_state.jl +++ b/src/EarthSystemModels/InterfaceComputations/compute_interface_state.jl @@ -32,7 +32,7 @@ end initial_interface_state, atmosphere_state, interior_state, - downwelling_radiation, + radiation_state, interface_properties, atmosphere_properties, interior_properties) @@ -47,7 +47,7 @@ end Ψₛ⁻ = Ψₛⁿ Ψₛⁿ = iterate_interface_state(flux_formulation, Ψₛ⁻, Ψₐ, Ψᵢ, - downwelling_radiation, + radiation_state, interface_properties, atmosphere_properties, interior_properties) @@ -70,7 +70,7 @@ and interior properties `ℙₛ`, `ℙₐ`, and `ℙᵢ`. approximate_interface_state, atmosphere_state, interior_state, - downwelling_radiation, + radiation_state, interface_properties, atmosphere_properties, interior_properties) @@ -79,7 +79,7 @@ and interior properties `ℙₛ`, `ℙₐ`, and `ℙᵢ`. approximate_interface_state, atmosphere_state, interior_state, - downwelling_radiation, + radiation_state, interface_properties, atmosphere_properties, interior_properties) diff --git a/src/EarthSystemModels/InterfaceComputations/interface_states.jl b/src/EarthSystemModels/InterfaceComputations/interface_states.jl index 55c76245b..5bd0460c8 100644 --- a/src/EarthSystemModels/InterfaceComputations/interface_states.jl +++ b/src/EarthSystemModels/InterfaceComputations/interface_states.jl @@ -9,8 +9,7 @@ using Thermodynamics: Liquid, Ice ##### Interface properties ##### -struct InterfaceProperties{R, Q, T, V} - radiation :: R +struct InterfaceProperties{Q, T, V} specific_humidity_formulation :: Q temperature_formulation :: T velocity_formulation :: V @@ -340,7 +339,7 @@ end interface_state, atmosphere_state, interior_state, - downwelling_radiation, + radiation_state, interface_properties, atmosphere_properties, interior_properties) @@ -356,16 +355,18 @@ end #ℰv = 0 #AtmosphericThermodynamics.latent_heat_vapor(ℂᵃᵗ, Tᵃᵗ) ℒⁱ = AtmosphericThermodynamics.latent_heat_sublim(ℂᵃᵗ, Tᵃᵗ) - # upwelling radiation is calculated explicitly + # upwelling radiation is calculated explicitly. radiation_state is + # produced by `air_sea_interface_radiation_state` (or its sea-ice + # variant) and contains zero-valued σ/α/ϵ/SW/LW when radiation is off. Tₛ⁻ = interface_state.T # approximate interface temperature from previous iteration - σ = interface_properties.radiation.σ - ϵ = interface_properties.radiation.ϵ - α = interface_properties.radiation.α - - ℐꜜˢʷ = downwelling_radiation.ℐꜜˢʷ - ℐꜜˡʷ = downwelling_radiation.ℐꜜˡʷ - ℐꜛˡʷ = emitted_longwave_radiation(Tₛ⁻, σ, ϵ) - Qd = net_absorbed_interface_radiation(ℐꜜˢʷ, ℐꜜˡʷ, α, ϵ) + σ = radiation_state.σ + ϵ = radiation_state.ϵ + α = radiation_state.α + + ℐꜜˢʷ = radiation_state.ℐꜜˢʷ + ℐꜜˡʷ = radiation_state.ℐꜜˡʷ + ℐꜛˡʷ = σ * ϵ * Tₛ⁻^4 + Qd = - (1 - α) * ℐꜜˢʷ - ϵ * ℐꜜˡʷ u★ = interface_state.u★ θ★ = interface_state.θ★ diff --git a/src/EarthSystemModels/InterfaceComputations/radiation.jl b/src/EarthSystemModels/InterfaceComputations/radiation.jl deleted file mode 100644 index 0e60d56af..000000000 --- a/src/EarthSystemModels/InterfaceComputations/radiation.jl +++ /dev/null @@ -1,114 +0,0 @@ -using Oceananigans.Grids: φnode - -@inline hack_cosd(φ) = cos(π * φ / 180) -@inline hack_sind(φ) = sin(π * φ / 180) - -struct Radiation{FT, E, R} - emission :: E - reflection :: R - stefan_boltzmann_constant :: FT -end - -Adapt.adapt_structure(to, r :: Radiation) = Radiation(Adapt.adapt(to, r.emission), - Adapt.adapt(to, r.reflection), - Adapt.adapt(to, r.stefan_boltzmann_constant)) - -""" - Radiation([arch = CPU(), FT=Float64]; - ocean_emissivity = 0.97, - sea_ice_emissivity = 1.0, - ocean_albedo = 0.05, - sea_ice_albedo = 0.7, - stefan_boltzmann_constant = 5.67e-8) - -Constructs a `Radiation` object that represents the radiation properties of the ocean and sea ice. - -Arguments -========= - -- `arch`: The architecture of the system. Default: `CPU()`. -- `FT`: The floating-point type to use. Default: `Float64`. - -Keyword Arguments -================= - -- `ocean_emissivity`: The emissivity of the ocean surface. Default: `0.97`. -- `sea_ice_emissivity`: The emissivity of the sea ice surface. Default: `1.0`. -- `ocean_albedo`: The albedo of the ocean surface. Default: `0.05`. -- `sea_ice_albedo`: The albedo of the sea ice surface. Default: `0.7`. -- `stefan_boltzmann_constant`: The Stefan-Boltzmann constant. Default: `5.67e-8`. -""" -function Radiation(arch = CPU(), FT=Oceananigans.defaults.FloatType; - ocean_emissivity = 0.97, - sea_ice_emissivity = 1.0, - ocean_albedo = 0.05, - sea_ice_albedo = 0.7, - stefan_boltzmann_constant = 5.67e-8) - - ocean_emissivity isa Number && (ocean_emissivity = convert(FT, ocean_emissivity)) - sea_ice_emissivity isa Number && (sea_ice_emissivity = convert(FT, sea_ice_emissivity)) - ocean_albedo isa Number && (ocean_albedo = convert(FT, ocean_albedo)) - sea_ice_albedo isa Number && (sea_ice_albedo = convert(FT, sea_ice_albedo)) - - emission = SurfaceProperties(ocean_emissivity, sea_ice_emissivity) - reflection = SurfaceProperties(ocean_albedo, sea_ice_albedo) - - return Radiation(emission, - reflection, - convert(FT, stefan_boltzmann_constant)) -end - -Base.summary(r::Radiation{FT}) where FT = "Radiation{$FT}" - -function Base.show(io::IO, r::Radiation) - σ = r.stefan_boltzmann_constant - - print(io, summary(r), ":", '\n') - print(io, "├── stefan_boltzmann_constant: ", prettysummary(σ), '\n') - print(io, "├── emission: ", summary(r.emission), '\n') - print(io, "│ ├── ocean: ", prettysummary(r.emission.ocean), '\n') - print(io, "│ └── sea_ice: ", prettysummary(r.emission.ocean), '\n') - print(io, "└── reflection: ", summary(r.reflection), '\n') - print(io, " ├── ocean: ", prettysummary(r.reflection.ocean), '\n') - print(io, " └── sea_ice: ", prettysummary(r.reflection.sea_ice)) -end - -struct SurfaceProperties{O, I} - ocean :: O - sea_ice :: I -end - -Adapt.adapt_structure(to, s :: SurfaceProperties) = - SurfaceProperties(Adapt.adapt(to, s.ocean), - Adapt.adapt(to, s.sea_ice)) - -Base.summary(properties::SurfaceProperties) = "SurfaceProperties" - -function Base.show(io::IO, properties::SurfaceProperties) - print(io, "SurfaceProperties:", '\n') - print(io, "├── ocean: ", summary(properties.ocean), '\n') - print(io, "└── sea_ice: ", summary(properties.sea_ice)) -end - -const CCC = (Center, Center, Center) - -@inline function emitted_longwave_radiation(i, j, k, grid, time, T, σ, ϵ) - ϵi = stateindex(ϵ, i, j, k, grid, time, CCC) - return σ * ϵi * T^4 -end - -# Split the individual bands -@inline function absorbed_longwave_radiation(i, j, k, grid, time, ϵ, ℐꜜˡʷ) - ϵi = stateindex(ϵ, i, j, k, grid, time, CCC) - return - ϵi * ℐꜜˡʷ -end - -@inline function transmitted_shortwave_radiation(i, j, k, grid, time, α, ℐꜜˢʷ) - αi = stateindex(α, i, j, k, grid, time, CCC, ℐꜜˢʷ) - return - (1 - αi) * ℐꜜˢʷ -end - -# Inside the solver we lose both spatial and temporal information, but the -# radiative properties have already been computed correctly -@inline net_absorbed_interface_radiation(ℐꜜˢʷ, ℐꜜˡʷ, α, ϵ) = - (1 - α) * ℐꜜˢʷ - ϵ * ℐꜜˡʷ -@inline emitted_longwave_radiation(T, σ, ϵ) = σ * ϵ * T^4 diff --git a/src/EarthSystemModels/InterfaceComputations/state_exchanger.jl b/src/EarthSystemModels/InterfaceComputations/state_exchanger.jl index fadf03585..85830cb4d 100644 --- a/src/EarthSystemModels/InterfaceComputations/state_exchanger.jl +++ b/src/EarthSystemModels/InterfaceComputations/state_exchanger.jl @@ -2,8 +2,9 @@ ComponentExchanger(component, exchange_grid) Holds a regridder and a buffer of `state` fields used to bring data from a -component (atmosphere, land, ocean, sea ice) onto a shared `exchange_grid`, -where atmosphere--ocean and atmosphere--sea-ice fluxes are computed. +component (radiation, atmosphere, land, ocean, sea ice) onto a shared +`exchange_grid`, where atmosphere--ocean and atmosphere--sea-ice fluxes are +computed. """ struct ComponentExchanger{S, EX} state :: S @@ -11,35 +12,39 @@ struct ComponentExchanger{S, EX} end """ - StateExchanger(grid, atmosphere, land, ocean, sea_ice) + StateExchanger(grid, radiation, atmosphere, land, ocean, sea_ice) Container for one `ComponentExchanger` per component. The `grid` is the shared exchange grid onto which each component's state is regridded each time step. """ -struct StateExchanger{G, A, L, O, S} +struct StateExchanger{G, R, A, L, O, S} grid :: G + radiation :: R atmosphere :: A land :: L ocean :: O sea_ice :: S - function StateExchanger(grid, atmosphere, land, ocean, sea_ice) + function StateExchanger(grid, radiation, atmosphere, land, ocean, sea_ice) + radiation_exchanger = ComponentExchanger(radiation, grid) atmosphere_exchanger = ComponentExchanger(atmosphere, grid) land_exchanger = ComponentExchanger(land, grid) ocean_exchanger = ComponentExchanger(ocean, grid) sea_ice_exchanger = ComponentExchanger(sea_ice, grid) G = typeof(grid) + R = typeof(radiation_exchanger) A = typeof(atmosphere_exchanger) L = typeof(land_exchanger) O = typeof(ocean_exchanger) S = typeof(sea_ice_exchanger) - return new{G, A, L, O, S}(grid, - atmosphere_exchanger, - land_exchanger, - ocean_exchanger, - sea_ice_exchanger) + return new{G, R, A, L, O, S}(grid, + radiation_exchanger, + atmosphere_exchanger, + land_exchanger, + ocean_exchanger, + sea_ice_exchanger) end end @@ -47,6 +52,7 @@ end ComponentExchanger(::Nothing, grid) = nothing function initialize!(exchanger::StateExchanger, model) + initialize!(exchanger.radiation, exchanger.grid, model.radiation) initialize!(exchanger.atmosphere, exchanger.grid, model.atmosphere) initialize!(exchanger.land, exchanger.grid, model.land) initialize!(exchanger.ocean, exchanger.grid, model.ocean) diff --git a/src/EarthSystemModels/components.jl b/src/EarthSystemModels/components.jl index 0ea202ebc..5eca13bb7 100644 --- a/src/EarthSystemModels/components.jl +++ b/src/EarthSystemModels/components.jl @@ -18,6 +18,9 @@ const celsius_to_kelvin = 273.15 exchange_grid(atmosphere, ocean, sea_ice) = ocean.model.grid +# Trait function overloaded by Atmospheres module +is_prescribed_atmosphere(::Any) = false + ##### ##### Functions extended by sea-ice and ocean models ##### diff --git a/src/EarthSystemModels/earth_system_model.jl b/src/EarthSystemModels/earth_system_model.jl index 2823db249..c4048e8b5 100644 --- a/src/EarthSystemModels/earth_system_model.jl +++ b/src/EarthSystemModels/earth_system_model.jl @@ -4,9 +4,10 @@ using Oceananigans: SeawaterBuoyancy using ClimaSeaIce.SeaIceThermodynamics: melting_temperature using KernelAbstractions: @kernel, @index -mutable struct EarthSystemModel{I, A, L, O, F, C, Arch} <: AbstractModel{Nothing, Arch} +mutable struct EarthSystemModel{R, A, L, I, O, F, C, Arch} <: AbstractModel{Nothing, Arch} architecture :: Arch clock :: C + radiation :: R atmosphere :: A land :: L sea_ice :: I @@ -38,6 +39,7 @@ function Base.show(io::IO, cm::ESM) ocean_summary = summary(cm.ocean) end + print(io, "├── radiation: ", summary(cm.radiation), "\n") print(io, "├── atmosphere: ", summary(cm.atmosphere), "\n") print(io, "├── land: ", summary(cm.land), "\n") print(io, "├── sea_ice: ", sea_ice_summary, "\n") @@ -80,9 +82,14 @@ reference_density(unsupported) = heat_capacity(unsupported) = throw(ArgumentError("Cannot deduce the heat capacity from $(typeof(unsupported))")) +# Hook called after `interfaces` is constructed and the exchange grid is known. +# Concrete radiation types (e.g. `PrescribedRadiation`) overload this to +# allocate `interface_fluxes` per-surface on the exchange grid. +allocate_interface_fluxes!(::Any, exchange_grid, surfaces) = nothing +allocate_interface_fluxes!(::Nothing, exchange_grid, surfaces) = nothing + """ - EarthSystemModel(atmosphere, ocean, sea_ice; - radiation = Radiation(), + EarthSystemModel(radiation, atmosphere, land, sea_ice, ocean; clock = Clock{Float64}(time=0), ocean_reference_density = reference_density(ocean), ocean_heat_capacity = heat_capacity(ocean), @@ -90,26 +97,32 @@ heat_capacity(unsupported) = sea_ice_heat_capacity = heat_capacity(sea_ice), interfaces = nothing) -Construct a coupled earth system model with an atmosphere, ocean, and sea ice component. -For simpler configurations, see [`OceanOnlyModel`](@ref) and [`OceanSeaIceModel`](@ref). +Construct a coupled earth system model. Components are passed in struct order +(top to bottom): radiation, atmosphere, land, sea_ice, ocean. Pass `nothing` +for components that are absent. For simpler configurations, see +[`OceanOnlyModel`](@ref), [`OceanSeaIceModel`](@ref), and +[`AtmosphereOceanModel`](@ref). Arguments ========== -- `atmosphere`: A representation of a possibly time-dependent atmospheric state. +- `radiation`: Radiation component, or `nothing` for a radiatively decoupled surface. + Pass a `PrescribedRadiation` (e.g. `JRA55PrescribedRadiation(...)`) to + enable radiative forcing. +- `atmosphere`: A representation of a possibly time-dependent atmospheric state, or `nothing`. For a prognostic atmosphere, use `atmosphere_simulation`. For prescribed atmospheric forcing, use `JRA55PrescribedAtmosphere` or `PrescribedAtmosphere`. -- `ocean`: A representation of a possibly time-dependent ocean state. Currently, only `Oceananigans.Simulation`s - of `Oceananigans.HydrostaticFreeSurfaceModel` are tested. -- `sea_ice`: A representation of a possibly time-dependent sea ice state. +- `land`: Land component, or `nothing`. +- `sea_ice`: A representation of a possibly time-dependent sea ice state, or `nothing`. For example, the minimalist `FreezingLimitedOceanTemperature` represents oceanic latent heating during freezing only, but does not evolve sea ice variables. For prognostic sea ice use an `Oceananigans.Simulation` of `ClimaSeaIce.SeaIceModel`. +- `ocean`: A representation of a possibly time-dependent ocean state. Currently, only `Oceananigans.Simulation`s + of `Oceananigans.HydrostaticFreeSurfaceModel` are tested. Keyword Arguments ================== -- `radiation`: Radiation component used to compute surface fluxes at the bottom of the atmosphere. - `clock`: Keeps track of time. - `ocean_reference_density`: Reference density for the ocean. Defaults to value from ocean model. - `ocean_heat_capacity`: Heat capacity for the ocean. Defaults to value from ocean model. @@ -117,59 +130,30 @@ Keyword Arguments - `sea_ice_heat_capacity`: Heat capacity for sea ice. Defaults to value from sea ice model. - `interfaces`: Component interfaces for coupling. Defaults to `nothing` and will be constructed automatically. To customize the sea ice-ocean heat flux formulation, create interfaces manually using `ComponentInterfaces`. - -Stability Functions -==================== - -The model uses similarity theory for turbulent fluxes between components. You can customize the stability functions -by creating a new [`SimilarityTheoryFluxes`](@ref) object with your desired stability functions. For example: - -```jldoctest earth_system_model -using NumericalEarth -using Oceananigans - -grid = RectilinearGrid(size=10, z=(-100, 0), topology=(Flat, Flat, Bounded)) -ocean = ocean_simulation(grid, timestepper = :QuasiAdamsBashforth2) - -# Three choices for stability function: -# "No stability function", which also apply to neutral boundary layers -stability_functions = nothing - -# Atmosphere-sea ice specific stability functions -stability_functions = NumericalEarth.EarthSystemModels.atmosphere_sea_ice_stability_functions(Float64) - -# Edson et al. (2013) stability functions -stability_functions = NumericalEarth.EarthSystemModels.atmosphere_ocean_stability_functions(Float64) - -atmosphere_ocean_fluxes = SimilarityTheoryFluxes(; stability_functions) -interfaces = NumericalEarth.EarthSystemModels.ComponentInterfaces(nothing, ocean; atmosphere_ocean_fluxes) -model = OceanOnlyModel(ocean; interfaces) - -# output -EarthSystemModel{CPU}(time = 0 seconds, iteration = 0) -├── atmosphere: Nothing -├── land: Nothing -├── sea_ice: FreezingLimitedOceanTemperature{ClimaSeaIce.SeaIceThermodynamics.LinearLiquidus{Float64}} -├── ocean: HydrostaticFreeSurfaceModel{CPU, RectilinearGrid}(time = 0 seconds, iteration = 0) -└── interfaces: ComponentInterfaces -``` - -The available stability function options include: -- `atmosphere_ocean_stability_functions`: Based on [Edson et al. (2013)](@cite edson2013exchange) -- `atmosphere_sea_ice_stability_functions`: Specifically designed for atmosphere-sea ice interactions -- `nothing`: No stability functions will be used -- Custom stability functions can be created by defining functions of the "stability parameter" - (the flux Richardson number), `ζ`. """ -function EarthSystemModel(atmosphere, ocean, sea_ice; - land = nothing, - radiation = Radiation(), +function EarthSystemModel(radiation, atmosphere, land, sea_ice, ocean; clock = Clock{Float64}(time=0), ocean_reference_density = reference_density(ocean), ocean_heat_capacity = heat_capacity(ocean), sea_ice_reference_density = reference_density(sea_ice), sea_ice_heat_capacity = heat_capacity(sea_ice), interfaces = nothing) + if isnothing(radiation) && is_prescribed_atmosphere(atmosphere) + @warn """ + `EarthSystemModel` was constructed with a `PrescribedAtmosphere` but \ + `radiation = nothing`. This means no upwelling longwave (ϵσT⁴), no \ + absorbed downwelling longwave, and no shortwave absorption — \ + results will be physically inconsistent. + + If you previously relied on `Radiation()` defaults: pass \ + `radiation = JRA55PrescribedRadiation(arch; backend, ...)` (or \ + `ECCOPrescribedRadiation` / `OSPapaPrescribedRadiation`) to restore \ + radiative forcing. Pass `radiation = PrescribedRadiation(grid)` for \ + emission-only mode. To suppress this warning, build the model \ + without a `PrescribedAtmosphere` (radiatively decoupled is the \ + intended `nothing` semantics). + """ maxlog=1 + end if ocean isa Simulation if !isnothing(ocean.callbacks) @@ -193,23 +177,28 @@ function EarthSystemModel(atmosphere, ocean, sea_ice; # Contains information about flux contributions: bulk formula, prescribed fluxes, etc. if isnothing(interfaces) && !(isnothing(atmosphere) && isnothing(sea_ice)) interfaces = ComponentInterfaces(atmosphere, ocean, sea_ice; + radiation, land, ocean_reference_density, ocean_heat_capacity, sea_ice_reference_density, - sea_ice_heat_capacity, - radiation) + sea_ice_heat_capacity) end arch = architecture(interfaces.exchanger.grid) + # Allocate per-surface InterfaceRadiationFlux on the exchange grid. + surfaces = present_surfaces(ocean, sea_ice) + allocate_interface_fluxes!(radiation, interfaces.exchanger.grid, surfaces) + earth_system_model = EarthSystemModel(arch, - clock, - atmosphere, - land, - sea_ice, - ocean, - interfaces) + clock, + radiation, + atmosphere, + land, + sea_ice, + ocean, + interfaces) # Make sure the initial temperature of the ocean # is not below freezing and above melting near the surface @@ -220,7 +209,42 @@ function EarthSystemModel(atmosphere, ocean, sea_ice; end """ - OceanOnlyModel(ocean; atmosphere=nothing, radiation=Radiation(), kw...) + EarthSystemModel(; radiation = nothing, + atmosphere = nothing, + land = nothing, + sea_ice = nothing, + ocean = nothing, + kw...) + +Keyword-only constructor for `EarthSystemModel`. Equivalent to the positional +form, but lets you pass only the components you actually have: + +```julia +EarthSystemModel(; atmosphere, ocean) # ocean + atmosphere +EarthSystemModel(; atmosphere, sea_ice, ocean, radiation) # full coupled +``` + +All keyword arguments accepted by the positional constructor are forwarded. +""" +EarthSystemModel(; radiation = nothing, + atmosphere = nothing, + land = nothing, + sea_ice = nothing, + ocean = nothing, + kw...) = + EarthSystemModel(radiation, atmosphere, land, sea_ice, ocean; kw...) + +# Determine which surfaces are present in the model — used to allocate +# per-surface diagnostic radiation flux buffers. +function present_surfaces(ocean, sea_ice) + surfaces = Symbol[] + isnothing(ocean) || push!(surfaces, :ocean) + isnothing(sea_ice) || push!(surfaces, :sea_ice) + return Tuple(surfaces) +end + +""" + OceanOnlyModel(ocean; atmosphere=nothing, radiation=nothing, land=nothing, kw...) Construct an ocean-only model without a sea ice component. This is a convenience constructor for [`EarthSystemModel`](@ref) that sets `sea_ice` @@ -229,58 +253,20 @@ to `FreezingLimitedOceanTemperature` (a simple freezing limiter that does not ev The `atmosphere` keyword can be used to specify a prescribed atmospheric forcing (e.g., `JRA55PrescribedAtmosphere`). All other keyword arguments are forwarded to `EarthSystemModel`. - -```jldoctest -using NumericalEarth -using Oceananigans - -grid = RectilinearGrid(size=10, z=(-100, 0), topology=(Flat, Flat, Bounded)) -ocean = ocean_simulation(grid, timestepper = :QuasiAdamsBashforth2) -model = OceanOnlyModel(ocean) - -# output -EarthSystemModel{CPU}(time = 0 seconds, iteration = 0) -├── atmosphere: Nothing -├── land: Nothing -├── sea_ice: FreezingLimitedOceanTemperature{ClimaSeaIce.SeaIceThermodynamics.LinearLiquidus{Float64}} -├── ocean: HydrostaticFreeSurfaceModel{CPU, RectilinearGrid}(time = 0 seconds, iteration = 0) -└── interfaces: ComponentInterfaces -``` """ -OceanOnlyModel(ocean; atmosphere=nothing, land=nothing, kw...) = - EarthSystemModel(atmosphere, ocean, default_sea_ice(); land, kw...) +OceanOnlyModel(ocean; atmosphere=nothing, land=nothing, radiation=nothing, kw...) = + EarthSystemModel(radiation, atmosphere, land, default_sea_ice(), ocean; kw...) """ - OceanSeaIceModel(ocean, sea_ice; atmosphere=nothing, radiation=Radiation(), kw...) + OceanSeaIceModel(sea_ice, ocean; atmosphere=nothing, radiation=nothing, land=nothing, kw...) Construct a coupled ocean--sea ice model. This is a convenience constructor for [`EarthSystemModel`](@ref) with an explicit sea ice component -and an optional prescribed atmosphere. - -The `atmosphere` keyword can be used to specify a prescribed atmospheric forcing -(e.g., `JRA55PrescribedAtmosphere`). All other keyword arguments are forwarded -to `EarthSystemModel`. - -```jldoctest -using NumericalEarth -using Oceananigans - -grid = RectilinearGrid(size=10, z=(-100, 0), topology=(Flat, Flat, Bounded)) -ocean = ocean_simulation(grid, timestepper = :QuasiAdamsBashforth2) -sea_ice = FreezingLimitedOceanTemperature() -model = OceanSeaIceModel(ocean, sea_ice) - -# output -EarthSystemModel{CPU}(time = 0 seconds, iteration = 0) -├── atmosphere: Nothing -├── land: Nothing -├── sea_ice: FreezingLimitedOceanTemperature{ClimaSeaIce.SeaIceThermodynamics.LinearLiquidus{Float64}} -├── ocean: HydrostaticFreeSurfaceModel{CPU, RectilinearGrid}(time = 0 seconds, iteration = 0) -└── interfaces: ComponentInterfaces -``` +and an optional prescribed atmosphere. Positional arguments follow the +struct convention (top→bottom): `sea_ice` then `ocean`. """ -OceanSeaIceModel(ocean, sea_ice; atmosphere=nothing, land=nothing, kw...) = - EarthSystemModel(atmosphere, ocean, sea_ice; land, kw...) +OceanSeaIceModel(sea_ice, ocean; atmosphere=nothing, land=nothing, radiation=nothing, kw...) = + EarthSystemModel(radiation, atmosphere, land, sea_ice, ocean; kw...) """ AtmosphereOceanModel(atmosphere, ocean; kw...) @@ -289,8 +275,8 @@ Construct a coupled atmosphere--ocean model. Convenience constructor for [`EarthSystemModel`](@ref) with an atmosphere and ocean but no sea ice. All keyword arguments are forwarded to `EarthSystemModel`. """ -AtmosphereOceanModel(atmosphere, ocean; land=nothing, kw...) = - EarthSystemModel(atmosphere, ocean, nothing; land, kw...) +AtmosphereOceanModel(atmosphere, ocean; land=nothing, radiation=nothing, kw...) = + EarthSystemModel(radiation, atmosphere, land, nothing, ocean; kw...) time(coupled_model::EarthSystemModel) = coupled_model.clock.time @@ -334,6 +320,7 @@ above_freezing_ocean_temperature!(ocean, grid, ::Nothing) = nothing function prognostic_state(osm::EarthSystemModel) return (clock = prognostic_state(osm.clock), + radiation = prognostic_state(osm.radiation), ocean = prognostic_state(osm.ocean), atmosphere = prognostic_state(osm.atmosphere), land = prognostic_state(osm.land), @@ -343,6 +330,10 @@ end function restore_prognostic_state!(osm::EarthSystemModel, state) restore_prognostic_state!(osm.clock, state.clock) + # Backwards-compatible: older checkpoints may not have a `radiation` entry + if hasproperty(state, :radiation) + restore_prognostic_state!(osm.radiation, state.radiation) + end restore_prognostic_state!(osm.ocean, state.ocean) restore_prognostic_state!(osm.atmosphere, state.atmosphere) restore_prognostic_state!(osm.land, state.land) diff --git a/src/EarthSystemModels/time_step_earth_system_model.jl b/src/EarthSystemModels/time_step_earth_system_model.jl index bf041418e..acd174ae8 100644 --- a/src/EarthSystemModels/time_step_earth_system_model.jl +++ b/src/EarthSystemModels/time_step_earth_system_model.jl @@ -7,26 +7,26 @@ using ClimaSeaIce: SeaIceModel, SeaIceThermodynamics using Oceananigans.Grids: φnode using Printf +# Hooks called from `update_state!` to apply radiative contributions on top of +# turbulent fluxes. Concrete radiation types overload these (no-op when +# `coupled_model.radiation === nothing`). +apply_air_sea_radiative_fluxes!(::Any) = nothing +apply_air_sea_ice_radiative_fluxes!(::Any) = nothing + function time_step!(coupled_model::EarthSystemModel, Δt; callbacks=[]) maybe_prepare_first_time_step!(coupled_model, callbacks) - - ocean = coupled_model.ocean - sea_ice = coupled_model.sea_ice - atmosphere = coupled_model.atmosphere - land = coupled_model.land - - # Eventually, split out into OceanOnlyModel - !isnothing(sea_ice) && time_step!(sea_ice, Δt) - # TODO after ice time-step: - # - Adjust ocean heat flux if the ice completely melts? - !isnothing(ocean) && time_step!(ocean, Δt) + radiation = coupled_model.radiation + atmosphere = coupled_model.atmosphere + land = coupled_model.land + sea_ice = coupled_model.sea_ice + ocean = coupled_model.ocean - # Time step the atmosphere + !isnothing(radiation) && time_step!(radiation, Δt) !isnothing(atmosphere) && time_step!(atmosphere, Δt) - - # Time step land - !isnothing(land) && time_step!(land, Δt) + !isnothing(land) && time_step!(land, Δt) + !isnothing(sea_ice) && time_step!(sea_ice, Δt) + !isnothing(ocean) && time_step!(ocean, Δt) # TODO: # - Store fractional ice-free / ice-covered _time_ for more @@ -39,31 +39,37 @@ end function update_state!(coupled_model::EarthSystemModel, callbacks=[]) - # The four components - ocean = coupled_model.ocean - sea_ice = coupled_model.sea_ice + radiation = coupled_model.radiation atmosphere = coupled_model.atmosphere land = coupled_model.land + sea_ice = coupled_model.sea_ice + ocean = coupled_model.ocean exchanger = coupled_model.interfaces.exchanger grid = exchanger.grid - # This function needs to be specialized to allow different component models + # Phase 1: bring all component states onto the exchange grid + interpolate_state!(exchanger.radiation, grid, radiation, coupled_model) interpolate_state!(exchanger.atmosphere, grid, atmosphere, coupled_model) interpolate_state!(exchanger.land, grid, land, coupled_model) - interpolate_state!(exchanger.ocean, grid, ocean, coupled_model) interpolate_state!(exchanger.sea_ice, grid, sea_ice, coupled_model) + interpolate_state!(exchanger.ocean, grid, ocean, coupled_model) - # Compute interface states + # Phase 2: compute interface turbulent fluxes compute_atmosphere_ocean_fluxes!(coupled_model) compute_atmosphere_sea_ice_fluxes!(coupled_model) compute_sea_ice_ocean_fluxes!(coupled_model) - # This function needs to be specialized to allow different component models + # Phase 3: assemble net component fluxes (turbulent only) + update_net_fluxes!(coupled_model, radiation) update_net_fluxes!(coupled_model, atmosphere) update_net_fluxes!(coupled_model, land) - update_net_fluxes!(coupled_model, ocean) update_net_fluxes!(coupled_model, sea_ice) + update_net_fluxes!(coupled_model, ocean) + + # Phase 4: add radiative contributions on top + apply_air_sea_radiative_fluxes!(coupled_model) + apply_air_sea_ice_radiative_fluxes!(coupled_model) return nothing end diff --git a/src/NumericalEarth.jl b/src/NumericalEarth.jl index fc5731492..0e8c5873e 100644 --- a/src/NumericalEarth.jl +++ b/src/NumericalEarth.jl @@ -15,8 +15,14 @@ export SlabOcean, default_sea_ice, FreezingLimitedOceanTemperature, - Radiation, + PrescribedRadiation, + SurfaceRadiationProperties, + InterfaceRadiationFlux, LatitudeDependentAlbedo, + TabulatedAlbedo, + JRA55PrescribedRadiation, + ECCOPrescribedRadiation, + OSPapaPrescribedRadiation, SimilarityTheoryFluxes, CoefficientBasedFluxes, MomentumRoughnessLength, @@ -111,6 +117,7 @@ include("EarthSystemModels/EarthSystemModels.jl") include("Oceans/Oceans.jl") include("Atmospheres/Atmospheres.jl") include("Lands/Lands.jl") +include("Radiations/Radiations.jl") include("SeaIces/SeaIces.jl") include("InitialConditions/InitialConditions.jl") include("DataWrangling/DataWrangling.jl") @@ -124,6 +131,7 @@ using .InitialConditions using .EarthSystemModels using .Atmospheres using .Lands +using .Radiations using .Oceans using .SeaIces using .Diagnostics diff --git a/src/Oceans/assemble_net_ocean_fluxes.jl b/src/Oceans/assemble_net_ocean_fluxes.jl index 5fe6f81fe..6c364a434 100644 --- a/src/Oceans/assemble_net_ocean_fluxes.jl +++ b/src/Oceans/assemble_net_ocean_fluxes.jl @@ -6,16 +6,13 @@ using NumericalEarth.EarthSystemModels: EarthSystemModel, NoOceanInterfaceModel, using NumericalEarth.EarthSystemModels.InterfaceComputations: interface_kernel_parameters, computed_fluxes, - sea_ice_concentration, - convert_to_kelvin, - emitted_longwave_radiation, - absorbed_longwave_radiation, - transmitted_shortwave_radiation + sea_ice_concentration @inline τᶜᶜᶜ(i, j, k, grid, ρᵒᶜ⁻¹, ℵ, ρτᶜᶜᶜ) = @inbounds ρᵒᶜ⁻¹ * (1 - ℵ[i, j, k]) * ρτᶜᶜᶜ[i, j, k] ##### -##### Generic flux assembler +##### Generic flux assembler — turbulent + sea-ice contributions only. +##### Radiative contributions are added later by `apply_air_sea_radiative_fluxes!`. ##### # Fallback for an ocean-only model (it has no interfaces!) @@ -24,7 +21,6 @@ update_net_fluxes!(coupled_model::Union{NoOceanInterfaceModel, NoInterfaceModel} update_net_fluxes!(coupled_model, ocean::Simulation{<:HydrostaticFreeSurfaceModel}) = update_net_ocean_fluxes!(coupled_model, ocean, ocean.model.grid) -# A generic ocean flux assembler for a coupled model with both an atmosphere and sea ice function update_net_ocean_fluxes!(coupled_model, ocean_model, grid) sea_ice = coupled_model.sea_ice arch = architecture(grid) @@ -34,13 +30,7 @@ function update_net_ocean_fluxes!(coupled_model, ocean_model, grid) atmos_ocean_fluxes = computed_fluxes(coupled_model.interfaces.atmosphere_ocean_interface) sea_ice_ocean_fluxes = computed_fluxes(coupled_model.interfaces.sea_ice_ocean_interface) - # Simplify NamedTuple to reduce parameter space consumption. - # See https://github.com/CliMA/NumericalEarth.jl/issues/116. atmosphere_fields = coupled_model.interfaces.exchanger.atmosphere.state - - downwelling_radiation = (ℐꜜˢʷ = atmosphere_fields.ℐꜜˢʷ.data, - ℐꜜˡʷ = atmosphere_fields.ℐꜜˡʷ.data) - freshwater_flux = atmosphere_fields.Jᶜ.data # Extract land freshwater flux if land component is present @@ -49,27 +39,19 @@ function update_net_ocean_fluxes!(coupled_model, ocean_model, grid) ice_concentration = sea_ice_concentration(sea_ice) ocean_surface_salinity = EarthSystemModels.ocean_surface_salinity(ocean_model) - atmos_ocean_properties = coupled_model.interfaces.atmosphere_ocean_interface.properties ocean_properties = coupled_model.interfaces.ocean_properties - ocean_surface_temperature = coupled_model.interfaces.atmosphere_ocean_interface.temperature - penetrating_radiation = get_radiative_forcing(ocean_model) - launch!(arch, grid, :xy, _assemble_net_ocean_fluxes!, net_ocean_fluxes, - penetrating_radiation, grid, clock, atmos_ocean_fluxes, sea_ice_ocean_fluxes, ocean_surface_salinity, - ocean_surface_temperature, ice_concentration, - downwelling_radiation, freshwater_flux, land_freshwater_flux, - atmos_ocean_properties, ocean_properties) return nothing @@ -79,23 +61,18 @@ end Base.@propagate_inbounds get_land_freshwater_flux(i, j, flux) = flux[i, j, 1] @kernel function _assemble_net_ocean_fluxes!(net_ocean_fluxes, - penetrating_radiation, grid, clock, atmos_ocean_fluxes, sea_ice_ocean_fluxes, ocean_surface_salinity, - ocean_surface_temperature, sea_ice_concentration, - downwelling_radiation, freshwater_flux, land_freshwater_flux, - atmos_ocean_properties, ocean_properties) i, j = @index(Global, NTuple) kᴺ = size(grid, 3) - time = Time(clock.time) ρτˣᵃᵒ = atmos_ocean_fluxes.x_momentum # atmosphere - ocean zonal momentum flux ρτʸᵃᵒ = atmos_ocean_fluxes.y_momentum # atmosphere - ocean meridional momentum flux ρτˣⁱᵒ = sea_ice_ocean_fluxes.x_momentum # sea_ice - ocean zonal momentum flux @@ -104,54 +81,24 @@ Base.@propagate_inbounds get_land_freshwater_flux(i, j, flux) = flux[i, j, 1] @inbounds begin ℵᵢ = sea_ice_concentration[i, j, 1] Sᵒᶜ = ocean_surface_salinity[i, j, 1] - Tₛ = ocean_surface_temperature[i, j, 1] - Tₛ = convert_to_kelvin(ocean_properties.temperature_units, Tₛ) - - Jᶜ = freshwater_flux[i, j, 1] + get_land_freshwater_flux(i, j, land_freshwater_flux) # Prescribed freshwater flux (atmos + land) - ℐꜜˢʷ = downwelling_radiation.ℐꜜˢʷ[i, j, 1] # Downwelling shortwave radiation - ℐꜜˡʷ = downwelling_radiation.ℐꜜˡʷ[i, j, 1] # Downwelling longwave radiation - 𝒬ᵀ = atmos_ocean_fluxes.sensible_heat[i, j, 1] # sensible or "conductive" heat flux - 𝒬ᵛ = atmos_ocean_fluxes.latent_heat[i, j, 1] # latent heat flux - Jᵛ = atmos_ocean_fluxes.water_vapor[i, j, 1] # mass flux of water vapor - end - - # Compute radiation fluxes (radiation is multiplied by the fraction of ocean, 1 - sea ice concentration) - σ = atmos_ocean_properties.radiation.σ - α = atmos_ocean_properties.radiation.α - ϵ = atmos_ocean_properties.radiation.ϵ - ℐꜛˡʷ = emitted_longwave_radiation(i, j, kᴺ, grid, time, Tₛ, σ, ϵ) - ℐₐˡʷ = absorbed_longwave_radiation(i, j, kᴺ, grid, time, ϵ, ℐꜜˡʷ) - - # Compute the interior + surface absorbed shortwave radiation - ℐₜˢʷ = transmitted_shortwave_radiation(i, j, kᴺ, grid, time, α, ℐꜜˢʷ) - ℐₐˡʷ *= (1 - ℵᵢ) - ℐₜˢʷ *= (1 - ℵᵢ) - - Qss = shortwave_radiative_forcing(i, j, grid, penetrating_radiation, ℐₜˢʷ, ocean_properties) - - # Compute the total heat flux - ΣQao = (ℐꜛˡʷ + 𝒬ᵀ + 𝒬ᵛ) * (1 - ℵᵢ) + ℐₐˡʷ + Qss - - @inbounds begin - # Write radiative components of the heat flux for diagnostic purposes - atmos_ocean_fluxes.upwelling_longwave[i, j, 1] = ℐꜛˡʷ - atmos_ocean_fluxes.downwelling_longwave[i, j, 1] = - ℐₐˡʷ - atmos_ocean_fluxes.downwelling_shortwave[i, j, 1] = - ℐₜˢʷ + Jᶜ = freshwater_flux[i, j, 1] + get_land_freshwater_flux(i, j, land_freshwater_flux) + 𝒬ᵀ = atmos_ocean_fluxes.sensible_heat[i, j, 1] + 𝒬ᵛ = atmos_ocean_fluxes.latent_heat[i, j, 1] + Jᵛ = atmos_ocean_fluxes.water_vapor[i, j, 1] end - # Convert from a mass flux to a volume flux (aka velocity) - # by dividing with the ocean reference density. - # Also switch the sign, for some reason we are given freshwater flux as positive down. + # Turbulent contributions to surface heat flux (radiation added later) + ΣQao = (𝒬ᵀ + 𝒬ᵛ) * (1 - ℵᵢ) + + # Convert mass flux to volume flux; sign-flip (prescribed flux is positive down) ρᵒᶜ⁻¹ = 1 / ocean_properties.reference_density ΣFao = - Jᶜ * ρᵒᶜ⁻¹ - # Add the contribution from the turbulent water vapor flux, which has - # a different sign convention as the prescribed water mass fluxes (positive upwards) + # Add turbulent water vapor flux (positive upward sign convention) Jᵛᵒᶜ = Jᵛ * ρᵒᶜ⁻¹ ΣFao += Jᵛᵒᶜ - # Compute fluxes for u, v, T, and S from momentum, heat, and freshwater fluxes τˣ = net_ocean_fluxes.u τʸ = net_ocean_fluxes.v Jᵀ = net_ocean_fluxes.T @@ -166,7 +113,7 @@ Base.@propagate_inbounds get_land_freshwater_flux(i, j, flux) = flux[i, j, 1] Jᵀao = ΣQao * ρᵒᶜ⁻¹ * cᵒᶜ⁻¹ Jᵀio = 𝒬ⁱⁿᵗ * ρᵒᶜ⁻¹ * cᵒᶜ⁻¹ - # salinity flux > 0 extracts salinity from the ocean --- the opposite of a water vapor flux + # salinity flux > 0 extracts salinity (opposite of water vapor flux sign) Jˢao = - Sᵒᶜ * ΣFao τˣᵃᵒ = ℑxᶠᵃᵃ(i, j, 1, grid, τᶜᶜᶜ, ρᵒᶜ⁻¹, ℵ, ρτˣᵃᵒ) @@ -174,12 +121,11 @@ Base.@propagate_inbounds get_land_freshwater_flux(i, j, flux) = flux[i, j, 1] τˣⁱᵒ = ρτˣⁱᵒ[i, j, 1] * ρᵒᶜ⁻¹ * ℑxᶠᵃᵃ(i, j, 1, grid, ℵ) τʸⁱᵒ = ρτʸⁱᵒ[i, j, 1] * ρᵒᶜ⁻¹ * ℑyᵃᶠᵃ(i, j, 1, grid, ℵ) - # Stresses τˣ[i, j, 1] = ifelse(inactive, zero(grid), τˣᵃᵒ + τˣⁱᵒ) τʸ[i, j, 1] = ifelse(inactive, zero(grid), τʸᵃᵒ + τʸⁱᵒ) - # Tracer fluxes - Jᵀ[i, j, 1] = ifelse(inactive, zero(grid), Jᵀao + Jᵀio) # Jᵀao is already multiplied by the sea ice concentration + # Tracer fluxes — radiative contributions added later by apply_air_sea_radiative_fluxes! + Jᵀ[i, j, 1] = ifelse(inactive, zero(grid), Jᵀao + Jᵀio) Jˢ[i, j, 1] = ifelse(inactive, zero(grid), (1 - ℵᵢ) * Jˢao + Jˢio) end end diff --git a/src/Radiations/Radiations.jl b/src/Radiations/Radiations.jl new file mode 100644 index 000000000..fd31f94c4 --- /dev/null +++ b/src/Radiations/Radiations.jl @@ -0,0 +1,55 @@ +module Radiations + +export PrescribedRadiation, + SurfaceRadiationProperties, + InterfaceRadiationFlux, + LatitudeDependentAlbedo, + TabulatedAlbedo, + default_stefan_boltzmann_constant + +# CODATA 2018 value of the Stefan–Boltzmann constant, in W m⁻² K⁻⁴. +const default_stefan_boltzmann_constant = 5.670374419e-8 + +using Oceananigans +using Oceananigans.Architectures: architecture +using Oceananigans.Fields: Center, Face, Field, ZeroField, FractionalIndices, instantiate, interpolator +using Oceananigans.Grids: grid_name, prettysummary, ηnode, _node, topology, Flat, on_architecture +using Oceananigans.OutputReaders: FieldTimeSeries, update_field_time_series!, extract_field_time_series, cpu_interpolating_time_indices +using Oceananigans.TimeSteppers: Clock, tick! +using Oceananigans.Units: Time +using Oceananigans.Utils: launch! + +using Adapt +using KernelAbstractions: @kernel, @index + +import NumericalEarth: stateindex +using NumericalEarth.EarthSystemModels.InterfaceComputations: interface_kernel_parameters + +import Oceananigans.TimeSteppers: time_step!, update_state! +import Oceananigans.Architectures: on_architecture + +import NumericalEarth.EarthSystemModels: interpolate_state!, + update_net_fluxes!, + apply_air_sea_radiative_fluxes!, + apply_air_sea_ice_radiative_fluxes!, + allocate_interface_fluxes! + +import NumericalEarth.EarthSystemModels.InterfaceComputations: ComponentExchanger, + initialize!, + kernel_radiation_properties, + air_sea_interface_radiation_state, + air_sea_ice_interface_radiation_state + +include("surface_radiation_properties.jl") +include("interface_radiation_flux.jl") +include("radiation_kernels.jl") +include("latitude_dependent_albedo.jl") +include("tabulated_albedo.jl") +include("prescribed_radiation.jl") +include("prescribed_radiation_regridder.jl") +include("interpolate_radiation_state.jl") +include("air_sea_interface_radiation_state.jl") +include("apply_air_sea_radiative_fluxes.jl") +include("apply_air_sea_ice_radiative_fluxes.jl") + +end # module Radiations diff --git a/src/Radiations/air_sea_interface_radiation_state.jl b/src/Radiations/air_sea_interface_radiation_state.jl new file mode 100644 index 000000000..c7748fa8d --- /dev/null +++ b/src/Radiations/air_sea_interface_radiation_state.jl @@ -0,0 +1,26 @@ +# PrescribedRadiation-aware methods for the radiation getter functions +# declared (with `nothing` fallbacks) in InterfaceComputations. + +@inline kernel_radiation_properties(r::PrescribedRadiation) = + (σ = r.stefan_boltzmann_constant, + surface_properties = r.surface_properties) + +@inline function air_sea_interface_radiation_state(rk, exchanger_state, i, j, k, grid, time) + σ = rk.σ + @inbounds ℐꜜˢʷ = exchanger_state.ℐꜜˢʷ[i, j, 1] + @inbounds ℐꜜˡʷ = exchanger_state.ℐꜜˡʷ[i, j, 1] + s = rk.surface_properties.ocean + α = stateindex(s.albedo, i, j, k, grid, time, (Center, Center, Center), ℐꜜˢʷ) + ϵ = stateindex(s.emissivity, i, j, k, grid, time, (Center, Center, Center)) + return (; σ, α, ϵ, ℐꜜˢʷ, ℐꜜˡʷ) +end + +@inline function air_sea_ice_interface_radiation_state(rk, exchanger_state, i, j, k, grid, time) + σ = rk.σ + @inbounds ℐꜜˢʷ = exchanger_state.ℐꜜˢʷ[i, j, 1] + @inbounds ℐꜜˡʷ = exchanger_state.ℐꜜˡʷ[i, j, 1] + s = rk.surface_properties.sea_ice + α = stateindex(s.albedo, i, j, k, grid, time, (Center, Center, Center), ℐꜜˢʷ) + ϵ = stateindex(s.emissivity, i, j, k, grid, time, (Center, Center, Center)) + return (; σ, α, ϵ, ℐꜜˢʷ, ℐꜜˡʷ) +end diff --git a/src/Radiations/apply_air_sea_ice_radiative_fluxes.jl b/src/Radiations/apply_air_sea_ice_radiative_fluxes.jl new file mode 100644 index 000000000..74bb1e66c --- /dev/null +++ b/src/Radiations/apply_air_sea_ice_radiative_fluxes.jl @@ -0,0 +1,93 @@ +using ClimaSeaIce: SeaIceModel + +""" + apply_air_sea_ice_radiative_fluxes!(coupled_model) + +Add the radiative contribution to the net sea-ice top heat flux and write +the diagnostic radiative fluxes (upwelling LW, absorbed LW, transmitted SW) +into `coupled_model.radiation.interface_fluxes.sea_ice`. + +When `coupled_model.radiation === nothing`, this is a no-op. +Also a no-op when sea-ice is not a `Simulation{<:SeaIceModel}`. +""" +apply_air_sea_ice_radiative_fluxes!(::EarthSystemModel{<:Nothing}) = nothing + +apply_air_sea_ice_radiative_fluxes!(coupled_model::EarthSystemModel) = + _apply_air_sea_ice_radiative_fluxes_dispatch!(coupled_model, coupled_model.sea_ice) + +# No sea-ice or non-prognostic sea-ice: nothing to do. +_apply_air_sea_ice_radiative_fluxes_dispatch!(coupled_model, ::Any) = nothing + +function _apply_air_sea_ice_radiative_fluxes_dispatch!(coupled_model::EarthSystemModel, + sea_ice::Simulation{<:SeaIceModel}) + radiation = coupled_model.radiation + interface_fluxes = radiation.interface_fluxes + isnothing(interface_fluxes) && return nothing + haskey(interface_fluxes, :sea_ice) || return nothing + + grid = sea_ice.model.grid + arch = architecture(grid) + clock = coupled_model.clock + + top_heat_flux = coupled_model.interfaces.net_fluxes.sea_ice.top.heat + radiation_state = coupled_model.interfaces.exchanger.radiation.state + rk = kernel_radiation_properties(radiation) + + ice_concentration = sea_ice_concentration(sea_ice) + surface_temperature = coupled_model.interfaces.atmosphere_sea_ice_interface.temperature + sea_ice_properties = coupled_model.interfaces.sea_ice_properties + + launch!(arch, grid, :xy, + _apply_air_sea_ice_radiative_fluxes!, + top_heat_flux, + interface_fluxes.sea_ice, + grid, + clock, + rk, + radiation_state, + ice_concentration, + surface_temperature, + sea_ice_properties) + + return nothing +end + +@kernel function _apply_air_sea_ice_radiative_fluxes!(top_heat_flux, + interface_radiative_flux, + grid, + clock, + rk, + radiation_state, + ice_concentration, + surface_temperature, + sea_ice_properties) + + i, j = @index(Global, NTuple) + kᴺ = size(grid, 3) + time = Time(clock.time) + + @inbounds begin + ℵᵢ = ice_concentration[i, j, 1] + Ts = surface_temperature[i, j, kᴺ] + end + Ts = convert_to_kelvin(sea_ice_properties.temperature_units, Ts) + + rs = air_sea_ice_interface_radiation_state(rk, radiation_state, i, j, kᴺ, grid, time) + + ℐꜛˡʷ = emitted_longwave_radiation(Ts, rs.σ, rs.ϵ) + ℐₐˡʷ = absorbed_longwave_radiation(rs.ϵ, rs.ℐꜜˡʷ) + ℐₜˢʷ = transmitted_shortwave_radiation(rs.α, rs.ℐꜜˢʷ) + + # Sea-ice radiation contributes only where ice exists. + ice_present = ℵᵢ > 0 + ΣQ_rad_ice = (ℐꜛˡʷ + ℐₐˡʷ + ℐₜˢʷ) * ice_present + + inactive = inactive_node(i, j, kᴺ, grid, Center(), Center(), Center()) + + @inbounds begin + top_heat_flux[i, j, 1] += ifelse(inactive, zero(grid), ΣQ_rad_ice) + interface_radiative_flux.upwelling_longwave[i, j, 1] = ℐꜛˡʷ + interface_radiative_flux.downwelling_longwave[i, j, 1] = - ℐₐˡʷ + interface_radiative_flux.downwelling_shortwave[i, j, 1] = - ℐₜˢʷ + end +end diff --git a/src/Radiations/apply_air_sea_radiative_fluxes.jl b/src/Radiations/apply_air_sea_radiative_fluxes.jl new file mode 100644 index 000000000..60e018995 --- /dev/null +++ b/src/Radiations/apply_air_sea_radiative_fluxes.jl @@ -0,0 +1,110 @@ +using Oceananigans.Grids: inactive_node +using NumericalEarth.EarthSystemModels: EarthSystemModel +using NumericalEarth.EarthSystemModels.InterfaceComputations: convert_to_kelvin, sea_ice_concentration +using NumericalEarth.Oceans: shortwave_radiative_forcing, get_radiative_forcing + +""" + apply_air_sea_radiative_fluxes!(coupled_model) + +Add the radiative contribution to the net ocean heat flux `Jᵀ` and write +the diagnostic radiative fluxes (upwelling LW, absorbed LW, transmitted SW) +into `coupled_model.radiation.interface_fluxes.ocean`. + +When `coupled_model.radiation === nothing`, this is a no-op. +""" +apply_air_sea_radiative_fluxes!(::EarthSystemModel{<:Nothing}) = nothing + +function apply_air_sea_radiative_fluxes!(coupled_model::EarthSystemModel) + ocean = coupled_model.ocean + isnothing(ocean) && return nothing + + # No atmosphere--ocean interface (no atmosphere or no ocean): nothing to do. + ao_interface = coupled_model.interfaces.atmosphere_ocean_interface + isnothing(ao_interface) && return nothing + + radiation = coupled_model.radiation + interface_fluxes = radiation.interface_fluxes + isnothing(interface_fluxes) && return nothing + haskey(interface_fluxes, :ocean) || return nothing + + grid = ocean.model.grid + arch = architecture(grid) + clock = coupled_model.clock + + net_ocean_fluxes = coupled_model.interfaces.net_fluxes.ocean + radiation_state = coupled_model.interfaces.exchanger.radiation.state + rk = kernel_radiation_properties(radiation) + + sea_ice = coupled_model.sea_ice + ice_concentration = sea_ice_concentration(sea_ice) + + interface_temperature = ao_interface.temperature + ocean_properties = coupled_model.interfaces.ocean_properties + penetrating_radiation = get_radiative_forcing(ocean.model) + + launch!(arch, grid, :xy, + _apply_air_sea_radiative_fluxes!, + net_ocean_fluxes, + interface_fluxes.ocean, + penetrating_radiation, + grid, + clock, + rk, + radiation_state, + ice_concentration, + interface_temperature, + ocean_properties) + + return nothing +end + +@kernel function _apply_air_sea_radiative_fluxes!(net_ocean_fluxes, + interface_radiative_flux, + penetrating_radiation, + grid, + clock, + rk, + radiation_state, + ice_concentration, + ocean_surface_temperature, + ocean_properties) + + i, j = @index(Global, NTuple) + kᴺ = size(grid, 3) + time = Time(clock.time) + + @inbounds begin + ℵᵢ = ice_concentration[i, j, 1] + Tₛ = ocean_surface_temperature[i, j, 1] + end + Tₛ = convert_to_kelvin(ocean_properties.temperature_units, Tₛ) + + rs = air_sea_interface_radiation_state(rk, radiation_state, i, j, kᴺ, grid, time) + + ℐꜛˡʷ = emitted_longwave_radiation(Tₛ, rs.σ, rs.ϵ) + ℐₐˡʷ = absorbed_longwave_radiation(rs.ϵ, rs.ℐꜜˡʷ) + ℐₜˢʷ = transmitted_shortwave_radiation(rs.α, rs.ℐꜜˢʷ) + + # Multiply by ocean fraction (only the parts not blocked by ice) + ℐₐˡʷ *= (1 - ℵᵢ) + ℐₜˢʷ *= (1 - ℵᵢ) + ℐꜛˡʷ_ocean = ℐꜛˡʷ * (1 - ℵᵢ) + + Qss = shortwave_radiative_forcing(i, j, grid, penetrating_radiation, ℐₜˢʷ, ocean_properties) + + # Total radiative contribution to surface heat flux + ΣQ_rad = ℐꜛˡʷ_ocean + ℐₐˡʷ + Qss + + ρᵒᶜ⁻¹ = 1 / ocean_properties.reference_density + cᵒᶜ⁻¹ = 1 / ocean_properties.heat_capacity + Jᵀ_rad = ΣQ_rad * ρᵒᶜ⁻¹ * cᵒᶜ⁻¹ + + inactive = inactive_node(i, j, kᴺ, grid, Center(), Center(), Center()) + + @inbounds begin + net_ocean_fluxes.T[i, j, 1] += ifelse(inactive, zero(grid), Jᵀ_rad) + interface_radiative_flux.upwelling_longwave[i, j, 1] = ℐꜛˡʷ + interface_radiative_flux.downwelling_longwave[i, j, 1] = - ℐₐˡʷ + interface_radiative_flux.downwelling_shortwave[i, j, 1] = - ℐₜˢʷ + end +end diff --git a/src/Radiations/interface_radiation_flux.jl b/src/Radiations/interface_radiation_flux.jl new file mode 100644 index 000000000..85bf1d376 --- /dev/null +++ b/src/Radiations/interface_radiation_flux.jl @@ -0,0 +1,34 @@ +""" + InterfaceRadiationFlux{F} + +Container for the diagnostic radiative fluxes at an air–surface interface. +The same struct type is instantiated per surface (ocean, sea ice, snow, ...). + +Fields +====== +- `upwelling_longwave :: F` ϵσT⁴ +- `downwelling_longwave :: F` ϵℐꜜˡʷ (absorbed by the surface) +- `downwelling_shortwave :: F` (1−α)ℐꜜˢʷ (transmitted into the surface) +""" +struct InterfaceRadiationFlux{F} + upwelling_longwave :: F + downwelling_longwave :: F + downwelling_shortwave :: F +end + +function InterfaceRadiationFlux(grid) + F = Field{Center, Center, Nothing} + return InterfaceRadiationFlux(F(grid), F(grid), F(grid)) +end + +InterfaceRadiationFlux(::Nothing) = InterfaceRadiationFlux(ntuple(_ -> ZeroField(), 3)...) + +Adapt.adapt_structure(to, fluxes::InterfaceRadiationFlux) = + InterfaceRadiationFlux(Adapt.adapt(to, fluxes.upwelling_longwave), + Adapt.adapt(to, fluxes.downwelling_longwave), + Adapt.adapt(to, fluxes.downwelling_shortwave)) + +on_architecture(arch, fluxes::InterfaceRadiationFlux) = + InterfaceRadiationFlux(on_architecture(arch, fluxes.upwelling_longwave), + on_architecture(arch, fluxes.downwelling_longwave), + on_architecture(arch, fluxes.downwelling_shortwave)) diff --git a/src/Radiations/interpolate_radiation_state.jl b/src/Radiations/interpolate_radiation_state.jl new file mode 100644 index 000000000..277149bb0 --- /dev/null +++ b/src/Radiations/interpolate_radiation_state.jl @@ -0,0 +1,69 @@ +using NumericalEarth.Atmospheres: interp_atmos_time_series + +"""Interpolate the prescribed downwelling radiation onto the exchange grid.""" +function interpolate_state!(exchanger, grid, radiation::PrescribedRadiation, coupled_model) + arch = architecture(grid) + clock = coupled_model.clock + + ℐꜜˢʷ = radiation.downwelling_shortwave + ℐꜜˡʷ = radiation.downwelling_longwave + downwelling = (shortwave = ℐꜜˢʷ.data, longwave = ℐꜜˡʷ.data) + + radiation_times = ℐꜜˢʷ.times + radiation_backend = ℐꜜˢʷ.backend + radiation_time_indexing = ℐꜜˢʷ.time_indexing + + space_fractional_indices = exchanger.regridder + state = exchanger.state + state_data = (ℐꜜˢʷ = state.ℐꜜˢʷ.data, + ℐꜜˡʷ = state.ℐꜜˡʷ.data) + + t = clock.time + time_interpolator = cpu_interpolating_time_indices(arch, radiation_times, + radiation_time_indexing, t) + + kernel_parameters = interface_kernel_parameters(grid) + + launch!(arch, grid, kernel_parameters, + _interpolate_radiation_state!, + state_data, + space_fractional_indices, + time_interpolator, + grid, + downwelling, + radiation_backend, + radiation_time_indexing) + + return nothing +end + +@inline get_fractional_index(i, j, ::Nothing) = nothing +@inline get_fractional_index(i, j, frac) = @inbounds frac[i, j, 1] + +@kernel function _interpolate_radiation_state!(state, + space_fractional_indices, + time_interpolator, + exchange_grid, + downwelling, + rad_backend, + rad_time_indexing) + + i, j = @index(Global, NTuple) + + ii = space_fractional_indices.i + jj = space_fractional_indices.j + fi = get_fractional_index(i, j, ii) + fj = get_fractional_index(i, j, jj) + + x_itp = FractionalIndices(fi, fj, nothing) + t_itp = time_interpolator + args = (x_itp, t_itp, rad_backend, rad_time_indexing) + + ℐꜜˢʷ = interp_atmos_time_series(downwelling.shortwave, args...) + ℐꜜˡʷ = interp_atmos_time_series(downwelling.longwave, args...) + + @inbounds begin + state.ℐꜜˢʷ[i, j, 1] = ℐꜜˢʷ + state.ℐꜜˡʷ[i, j, 1] = ℐꜜˡʷ + end +end diff --git a/src/EarthSystemModels/InterfaceComputations/latitude_dependent_albedo.jl b/src/Radiations/latitude_dependent_albedo.jl similarity index 100% rename from src/EarthSystemModels/InterfaceComputations/latitude_dependent_albedo.jl rename to src/Radiations/latitude_dependent_albedo.jl diff --git a/src/Radiations/prescribed_radiation.jl b/src/Radiations/prescribed_radiation.jl new file mode 100644 index 000000000..0c1a3332a --- /dev/null +++ b/src/Radiations/prescribed_radiation.jl @@ -0,0 +1,160 @@ +""" + PrescribedRadiation{G, T, FT, SW, LW, S, IF, TI} + +Top-level radiation component holding prescribed downwelling shortwave and +longwave radiation as `FieldTimeSeries`, plus per-surface radiative properties +(albedo, emissivity) and the Stefan–Boltzmann constant. Diagnostic radiative +fluxes (one `InterfaceRadiationFlux` per surface) are populated by the +`apply_air_sea_*_radiative_fluxes!` kernels at every step; `interface_fluxes` +is `nothing` until the radiation is paired with an `EarthSystemModel` (which +allocates the per-surface buffers on the exchange grid). +""" +mutable struct PrescribedRadiation{G, T, FT, SW, LW, S, TI} + grid :: G + clock :: Clock{T} + downwelling_shortwave :: SW + downwelling_longwave :: LW + surface_properties :: S + stefan_boltzmann_constant :: FT + # NamedTuple of `InterfaceRadiationFlux`, allocated at `EarthSystemModel` + # construction time once the exchange grid is known. Untyped so the field + # can be reassigned from `nothing` to a populated NamedTuple. + interface_fluxes + times :: TI +end + +function Base.summary(r::PrescribedRadiation) + Nx, Ny, Nz = size(r.grid) + Nt = length(r.times) + sz_str = string(Nx, "×", Ny, "×", Nz, "×", Nt) + return string(sz_str, " PrescribedRadiation") +end + +function Base.show(io::IO, r::PrescribedRadiation) + print(io, summary(r), " on ", grid_name(r.grid), ":", '\n') + print(io, "├── times: ", prettysummary(r.times), '\n') + print(io, "├── stefan_boltzmann_constant: ", prettysummary(r.stefan_boltzmann_constant), '\n') + print(io, "└── surface_properties: ", keys(r.surface_properties)) +end + +# Filter out surfaces whose property kwarg was passed as `nothing`. +@inline function _filter_surface_properties(; ocean=nothing, sea_ice=nothing, snow=nothing, land=nothing) + pairs = () + isnothing(ocean) || (pairs = (pairs..., :ocean => ocean)) + isnothing(sea_ice) || (pairs = (pairs..., :sea_ice => sea_ice)) + isnothing(snow) || (pairs = (pairs..., :snow => snow)) + isnothing(land) || (pairs = (pairs..., :land => land)) + return NamedTuple(pairs) +end + +""" + PrescribedRadiation(downwelling_shortwave, downwelling_longwave; + clock = nothing, + ocean_surface = SurfaceRadiationProperties(0.05, 0.97), + sea_ice_surface = SurfaceRadiationProperties(0.7, 1.0), + snow_surface = nothing, + land_surface = nothing, + stefan_boltzmann_constant = default_stefan_boltzmann_constant) + +Construct a `PrescribedRadiation` component from `FieldTimeSeries` of +downwelling shortwave and longwave radiation. Grid + times are inferred from +the shortwave FTS. + +Pass `*_surface = nothing` to omit that surface from `surface_properties`. +""" +function PrescribedRadiation(downwelling_shortwave::FieldTimeSeries, + downwelling_longwave::FieldTimeSeries; + clock = nothing, + ocean_surface = SurfaceRadiationProperties(0.05, 0.97), + sea_ice_surface = SurfaceRadiationProperties(0.7, 1.0), + snow_surface = nothing, + land_surface = nothing, + stefan_boltzmann_constant = default_stefan_boltzmann_constant) + + grid = downwelling_shortwave.grid + times = downwelling_shortwave.times + FT = eltype(downwelling_shortwave) + + if isnothing(clock) + clock = Clock{FT}(time = 0) + end + + surface_properties = _filter_surface_properties(ocean = ocean_surface, + sea_ice = sea_ice_surface, + snow = snow_surface, + land = land_surface) + + radiation = PrescribedRadiation(grid, + clock, + downwelling_shortwave, + downwelling_longwave, + surface_properties, + convert(FT, stefan_boltzmann_constant), + nothing, # interface_fluxes — populated at ESM construction + times) + update_state!(radiation) + return radiation +end + +""" + PrescribedRadiation(grid, times = [zero(grid)]; kwargs...) + +Construct a `PrescribedRadiation` with zero downwelling shortwave and longwave +fields on `grid`. Useful for emission-only mode (the surface radiates ϵσT⁴ but +no incoming radiation is absorbed). All other keyword arguments are forwarded +to the FTS-form constructor. +""" +function PrescribedRadiation(grid, times = [zero(eltype(grid))]; kwargs...) + sw = FieldTimeSeries{Center, Center, Nothing}(grid, times) + lw = FieldTimeSeries{Center, Center, Nothing}(grid, times) + return PrescribedRadiation(sw, lw; kwargs...) +end + +@inline function update_state!(radiation::PrescribedRadiation) + time = Time(radiation.clock.time) + ftses = extract_field_time_series(radiation) + + for fts in ftses + update_field_time_series!(fts, time) + end + return nothing +end + +@inline function time_step!(radiation::PrescribedRadiation, Δt) + tick!(radiation.clock, Δt) + update_state!(radiation) + return nothing +end + +# Prescribed radiation has no internal state to update from net fluxes. +update_net_fluxes!(coupled_model, ::PrescribedRadiation) = nothing + +""" + allocate_interface_fluxes!(radiation, exchange_grid, surfaces) + +Populate `radiation.interface_fluxes` with one `InterfaceRadiationFlux` +per surface present in the model. +""" +function allocate_interface_fluxes!(radiation::PrescribedRadiation, exchange_grid, surfaces) + pairs = (surface => InterfaceRadiationFlux(exchange_grid) for surface in surfaces) + radiation.interface_fluxes = NamedTuple(pairs) + return nothing +end + +##### +##### Checkpointing +##### + +import Oceananigans: prognostic_state, restore_prognostic_state! + +function prognostic_state(radiation::PrescribedRadiation) + return (; clock = prognostic_state(radiation.clock)) +end + +function restore_prognostic_state!(radiation::PrescribedRadiation, state) + restore_prognostic_state!(radiation.clock, state.clock) + update_state!(radiation) + return radiation +end + +restore_prognostic_state!(radiation::PrescribedRadiation, ::Nothing) = radiation diff --git a/src/Radiations/prescribed_radiation_regridder.jl b/src/Radiations/prescribed_radiation_regridder.jl new file mode 100644 index 000000000..5316fb5eb --- /dev/null +++ b/src/Radiations/prescribed_radiation_regridder.jl @@ -0,0 +1,51 @@ +function ComponentExchanger(radiation::PrescribedRadiation, grid) + + regridder = radiation_regridder(radiation, grid) + + state = (; ℐꜜˢʷ = Field{Center, Center, Nothing}(grid), + ℐꜜˡʷ = Field{Center, Center, Nothing}(grid)) + + return ComponentExchanger(state, regridder) +end + +function radiation_regridder(radiation::PrescribedRadiation, exchange_grid) + rad_grid = radiation.grid + arch = architecture(exchange_grid) + + FT = eltype(rad_grid) + TX, TY, TZ = topology(exchange_grid) + fi = TX() isa Flat ? nothing : Field{Center, Center, Nothing}(exchange_grid, FT) + fj = TY() isa Flat ? nothing : Field{Center, Center, Nothing}(exchange_grid, FT) + return (i = fi, j = fj) +end + +function initialize!(exchanger::ComponentExchanger, grid, radiation::PrescribedRadiation) + frac_indices = exchanger.regridder + # Skip horizontal regridding when both fractional-index buffers are + # absent (purely Flat horizontal exchange grid). + if isnothing(frac_indices.i) && isnothing(frac_indices.j) + return nothing + end + rad_grid = radiation.grid + kernel_parameters = interface_kernel_parameters(grid) + launch!(architecture(grid), grid, kernel_parameters, + _compute_radiation_fractional_indices!, frac_indices, grid, rad_grid) + return nothing +end + +@kernel function _compute_radiation_fractional_indices!(indices_tuple, exchange_grid, rad_grid) + i, j = @index(Global, NTuple) + kᴺ = size(exchange_grid, 3) + X = _node(i, j, kᴺ + 1, exchange_grid, Center(), Center(), Face()) + fractional_indices_ij = FractionalIndices(X, rad_grid, Center(), Center(), Center()) + fi = indices_tuple.i + fj = indices_tuple.j + @inbounds begin + if !isnothing(fi) + fi[i, j, 1] = fractional_indices_ij.i + end + if !isnothing(fj) + fj[i, j, 1] = fractional_indices_ij.j + end + end +end diff --git a/src/Radiations/radiation_kernels.jl b/src/Radiations/radiation_kernels.jl new file mode 100644 index 000000000..edaa39b87 --- /dev/null +++ b/src/Radiations/radiation_kernels.jl @@ -0,0 +1,5 @@ +@inline hack_cosd(φ) = cos(π * φ / 180) + +@inline emitted_longwave_radiation(T, σ, ϵ) = σ * ϵ * T^4 +@inline absorbed_longwave_radiation(ϵ, ℐꜜˡʷ) = - ϵ * ℐꜜˡʷ +@inline transmitted_shortwave_radiation(α, ℐꜜˢʷ) = - (1 - α) * ℐꜜˢʷ diff --git a/src/Radiations/surface_radiation_properties.jl b/src/Radiations/surface_radiation_properties.jl new file mode 100644 index 000000000..0b069189d --- /dev/null +++ b/src/Radiations/surface_radiation_properties.jl @@ -0,0 +1,29 @@ +struct SurfaceRadiationProperties{A, E} + albedo :: A + emissivity :: E +end + +""" + SurfaceRadiationProperties(albedo, emissivity) + +Bundle the radiative properties of a single surface (ocean, sea ice, snow, land) +that participate in radiative flux computation: shortwave reflectivity (`albedo`) +and longwave emissivity (`emissivity`). + +`albedo` may be a `Number`, a `LatitudeDependentAlbedo`, a `TabulatedAlbedo`, or +any other object for which `stateindex` is defined. `emissivity` may be a `Number` +or any other `stateindex`-able object. +""" +SurfaceRadiationProperties(; albedo, emissivity = 0.97) = SurfaceRadiationProperties(albedo, emissivity) + +Adapt.adapt_structure(to, s::SurfaceRadiationProperties) = + SurfaceRadiationProperties(Adapt.adapt(to, s.albedo), + Adapt.adapt(to, s.emissivity)) + +Base.summary(::SurfaceRadiationProperties) = "SurfaceRadiationProperties" + +function Base.show(io::IO, s::SurfaceRadiationProperties) + print(io, summary(s), ":", '\n') + print(io, "├── albedo: ", prettysummary(s.albedo), '\n') + print(io, "└── emissivity: ", prettysummary(s.emissivity)) +end diff --git a/src/EarthSystemModels/InterfaceComputations/tabulated_albedo.jl b/src/Radiations/tabulated_albedo.jl similarity index 100% rename from src/EarthSystemModels/InterfaceComputations/tabulated_albedo.jl rename to src/Radiations/tabulated_albedo.jl diff --git a/src/SeaIces/assemble_net_sea_ice_fluxes.jl b/src/SeaIces/assemble_net_sea_ice_fluxes.jl index e7141bd41..2418e6f35 100644 --- a/src/SeaIces/assemble_net_sea_ice_fluxes.jl +++ b/src/SeaIces/assemble_net_sea_ice_fluxes.jl @@ -1,9 +1,6 @@ using NumericalEarth.EarthSystemModels.InterfaceComputations: computed_fluxes, interface_kernel_parameters, - convert_to_kelvin, - emitted_longwave_radiation, - absorbed_longwave_radiation, - transmitted_shortwave_radiation + convert_to_kelvin update_net_fluxes!(coupled_model, ::FreezingLimitedOceanTemperature) = nothing @@ -18,20 +15,11 @@ function update_net_fluxes!(coupled_model, sea_ice::Simulation{<:SeaIceModel}) sea_ice_ocean_fluxes = computed_fluxes(coupled_model.interfaces.sea_ice_ocean_interface) atmosphere_sea_ice_fluxes = computed_fluxes(coupled_model.interfaces.atmosphere_sea_ice_interface) - # Simplify NamedTuple to reduce parameter space consumption. - # See https://github.com/CliMA/NumericalEarth.jl/issues/116. atmosphere_fields = coupled_model.interfaces.exchanger.atmosphere.state - - downwelling_radiation = (ℐꜜˢʷ = atmosphere_fields.ℐꜜˢʷ.data, - ℐꜜˡʷ = atmosphere_fields.ℐꜜˡʷ.data) - freshwater_flux = atmosphere_fields.Jᶜ.data snowfall_flux = atmosphere_fields.Jˢⁿ.data - atmos_sea_ice_properties = coupled_model.interfaces.atmosphere_sea_ice_interface.properties sea_ice_properties = coupled_model.interfaces.sea_ice_properties - - sea_ice_surface_temperature = coupled_model.interfaces.atmosphere_sea_ice_interface.temperature ice_concentration = sea_ice_concentration(sea_ice) launch!(arch, grid, :xy, @@ -45,10 +33,7 @@ function update_net_fluxes!(coupled_model, sea_ice::Simulation{<:SeaIceModel}) freshwater_flux, snowfall_flux, ice_concentration, - sea_ice_surface_temperature, - downwelling_radiation, - sea_ice_properties, - atmos_sea_ice_properties) + sea_ice_properties) return nothing end @@ -62,24 +47,15 @@ end freshwater_flux, snowfall_flux, ice_concentration, - surface_temperature, - downwelling_radiation, - sea_ice_properties, - atmos_sea_ice_properties) + sea_ice_properties) i, j = @index(Global, NTuple) kᴺ = size(grid, 3) - time = Time(clock.time) @inbounds begin - Ts = surface_temperature[i, j, kᴺ] - Ts = convert_to_kelvin(sea_ice_properties.temperature_units, Ts) ℵi = ice_concentration[i, j, 1] - - ℐꜜˢʷ = downwelling_radiation.ℐꜜˢʷ[i, j, 1] - ℐꜜˡʷ = downwelling_radiation.ℐꜜˡʷ[i, j, 1] - 𝒬ᵀ = atmosphere_sea_ice_fluxes.sensible_heat[i, j, 1] # sensible heat flux - 𝒬ᵛ = atmosphere_sea_ice_fluxes.latent_heat[i, j, 1] # latent heat flux + 𝒬ᵀ = atmosphere_sea_ice_fluxes.sensible_heat[i, j, 1] # sensible heat flux + 𝒬ᵛ = atmosphere_sea_ice_fluxes.latent_heat[i, j, 1] # latent heat flux 𝒬ᶠʳᶻ = sea_ice_ocean_fluxes.frazil_heat[i, j, 1] # frazil heat flux 𝒬ⁱⁿᵗ = sea_ice_ocean_fluxes.interface_heat[i, j, 1] # interfacial heat flux Jˢⁿ = snowfall_flux[i, j, 1] @@ -88,15 +64,8 @@ end ρτˣ = atmosphere_sea_ice_fluxes.x_momentum # zonal momentum flux ρτʸ = atmosphere_sea_ice_fluxes.y_momentum # meridional momentum flux - # Compute radiation fluxes - σ = atmos_sea_ice_properties.radiation.σ - α = atmos_sea_ice_properties.radiation.α - ϵ = atmos_sea_ice_properties.radiation.ϵ - ℐꜛˡʷ = emitted_longwave_radiation(i, j, kᴺ, grid, time, Ts, σ, ϵ) - ℐₜˢʷ = transmitted_shortwave_radiation(i, j, kᴺ, grid, time, α, ℐꜜˢʷ) - ℐₐˡʷ = absorbed_longwave_radiation(i, j, kᴺ, grid, time, ϵ, ℐꜜˡʷ) - - ΣQt = (ℐₜˢʷ + ℐₐˡʷ + ℐꜛˡʷ + 𝒬ᵀ + 𝒬ᵛ) * (ℵi > 0) # If ℵi == 0 there is no heat flux from the top! + # Turbulent contributions only (radiation added later by apply_air_sea_ice_radiative_fluxes!) + ΣQt = (𝒬ᵀ + 𝒬ᵛ) * (ℵi > 0) ΣQb = 𝒬ᶠʳᶻ + 𝒬ⁱⁿᵗ # Mask fluxes over land for convenience diff --git a/src/SeaIces/freezing_limited_ocean_temperature.jl b/src/SeaIces/freezing_limited_ocean_temperature.jl index d721b0b95..2860962f0 100644 --- a/src/SeaIces/freezing_limited_ocean_temperature.jl +++ b/src/SeaIces/freezing_limited_ocean_temperature.jl @@ -24,7 +24,7 @@ The melting temperature is a function of salinity and is controlled by the `liqu FreezingLimitedOceanTemperature(FT::DataType=Oceananigans.defaults.FloatType; liquidus=LinearLiquidus(FT)) = FreezingLimitedOceanTemperature(liquidus) -const FreezingLimitedEarthSystemModel = EarthSystemModel{<:FreezingLimitedOceanTemperature, A, L, O, <:NoSeaIceInterface} where {A, L, O} +const FreezingLimitedEarthSystemModel = EarthSystemModel{R, A, L, <:FreezingLimitedOceanTemperature, O, <:NoSeaIceInterface} where {R, A, L, O} # Extend interface methods to work with a `FreezingLimitedOceanTemperature` sea_ice_concentration(::FreezingLimitedOceanTemperature) = ZeroField() @@ -50,8 +50,8 @@ InterfaceComputations.sea_ice_ocean_interface(grid, ::FreezingLimitedOceanTemper InterfaceComputations.net_fluxes(::FreezingLimitedOceanTemperature) = nothing -const OnlyOceanwithFreezingLimited = EarthSystemModel{<:FreezingLimitedOceanTemperature, <:Nothing, <:Any, <:Any} -const OnlyAtmospherewithFreezingLimited = EarthSystemModel{<:FreezingLimitedOceanTemperature, <:Any, <:Any, <:Nothing} +const OnlyOceanwithFreezingLimited = EarthSystemModel{<:Any, <:Nothing, <:Any, <:FreezingLimitedOceanTemperature, <:Any} +const OnlyAtmospherewithFreezingLimited = EarthSystemModel{<:Any, <:Any, <:Any, <:FreezingLimitedOceanTemperature, <:Nothing} const SingleComponentPlusFreezingLimited = Union{OnlyAtmospherewithFreezingLimited, OnlyOceanwithFreezingLimited} # Also for the ocean nothing really happens here diff --git a/test/Project.toml b/test/Project.toml index 8824c296e..883eac781 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,4 +1,5 @@ [deps] +ArchGDAL = "c9ce4bd3-c3d5-55b8-8973-c0e20141b8c3" Breeze = "660aa2fb-d4c8-4359-a52c-9c057bc511da" CDSAPI = "8a7b9de3-9c00-473e-88b4-7eccd7ef2fea" CFTime = "179af706-886a-5703-950a-314cd64e0468" @@ -9,6 +10,7 @@ CopernicusMarine = "cd43e856-93a3-40c8-bc9e-6146cdce14fa" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" Glob = "c27321d9-0574-5035-807b-f59d2c89b15c" +GPUCompiler = "61eb1bfa-7361-4325-ad38-22787b887f55" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" KernelAbstractions = "63c18a36-062a-441e-b654-da1e3ab1ce7c" MPI = "da04e1cc-30fd-572f-bb4f-1f8673147195" @@ -32,6 +34,7 @@ ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refact NumericalEarth = {path = ".."} [compat] +ArchGDAL = "0.10" Breeze = "0.4" CDSAPI = "2.2.2" CFTime = "0.1, 0.2" @@ -42,13 +45,14 @@ CopernicusMarine = "0.1.1" Dates = "<0.0.1, 1" Downloads = "<0.0.1, 1" Glob = "1" +GPUCompiler = "~1.9" JLD2 = "0.4, 0.5, 0.6" KernelAbstractions = "0.9" MPI = "0.20" NCDatasets = "0.12, 0.13, 0.14" Oceananigans = "0.107, 0.108" PythonCall = "0.9.28" -Reactant = "0.2.235" +Reactant = "=0.2.255" Scratch = "1" SeawaterPolynomials = "0.3.5" SpeedyWeather = "0.19" diff --git a/test/runtests.jl b/test/runtests.jl index 95e300072..448c91a05 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -29,11 +29,18 @@ if filter_tests!(testsuite, args) delete!(testsuite, "test_distributed_utils") delete!(testsuite, "test_reactant") - # Remove CPU-only tests when - # testing on GPUs if gpu_test + # Remove CPU-only tests when testing on GPUs delete!(testsuite, "test_veros") delete!(testsuite, "test_speedy_coupling") + else + # Remove the slowest tests from CPU CI to keep total runtime + # manageable; GPU CI still runs them. See issue #193. + delete!(testsuite, "test_ocean_only_model") + delete!(testsuite, "test_ocean_sea_ice_model") + delete!(testsuite, "test_diagnostics_1") + delete!(testsuite, "test_ecco2_daily") + delete!(testsuite, "test_orca_grid") end end @@ -73,6 +80,9 @@ function __init__() try atmosphere = JRA55PrescribedAtmosphere(backend=JRA55NetCDFBackend(2)) land = JRA55PrescribedLand(backend=JRA55NetCDFBackend(2)) + # Touch the radiation variables (rlds/rsds) too, so a corrupted cached + # download is caught by the same fallback path. + radiation = JRA55PrescribedRadiation(backend=JRA55NetCDFBackend(2)) catch e @warn "Original JRA55 download failed, trying NumericalEarthArtifacts fallback..." exception=(e, catch_backtrace()) emit_ci_warning("Broken JRA55 download", "Original source failed during init") @@ -80,6 +90,8 @@ function __init__() datum = Metadatum(name; dataset=JRA55.RepeatYearJRA55()) download_from_artifacts(metadata_path(datum)) end + atmosphere = JRA55PrescribedAtmosphere(backend=JRA55NetCDFBackend(2)) + radiation = JRA55PrescribedRadiation(backend=JRA55NetCDFBackend(2)) end ##### diff --git a/test/test_cds_downloading.jl b/test/test_cds_downloading.jl index 9802c2be2..0d29e3f71 100644 --- a/test/test_cds_downloading.jl +++ b/test/test_cds_downloading.jl @@ -6,8 +6,16 @@ using Dates using NCDatasets using NumericalEarth.DataWrangling.ERA5 -using NumericalEarth.DataWrangling.ERA5: ERA5Hourly, ERA5Monthly, ERA5_dataset_variable_names -using NumericalEarth.DataWrangling: metadata_path, download_dataset +using NumericalEarth.DataWrangling.ERA5: ERA5HourlySingleLevel, ERA5MonthlySingleLevel, + ERA5_dataset_variable_names, ERA5_netcdf_variable_names +using NumericalEarth.DataWrangling.ERA5: ERA5HourlyPressureLevels, ERA5MonthlyPressureLevels, + ERA5_all_pressure_levels, ERA5PL_dataset_variable_names, + ERA5PL_netcdf_variable_names, pressure_field +using NumericalEarth.DataWrangling: metadata_path, download_dataset, BoundingBox, Column, Linear, Nearest + +# Internal extension module — exposes dispatch helpers and NetCDF utilities +# that are not part of the public API but worth pinning behavior for. +const CDSExt = Base.get_extension(NumericalEarth, :NumericalEarthCDSAPIExt) # Test date: Kyoto Protocol ratification date, February 16, 2005 start_date = DateTime(2005, 2, 16, 12) @@ -15,7 +23,7 @@ start_date = DateTime(2005, 2, 16, 12) @testset "ERA5 data downloading and utilities" begin @info "Testing ERA5 downloading and NetCDF file verification..." - dataset = ERA5Hourly() + dataset = ERA5HourlySingleLevel() # Use a small bounding box to reduce download time region = NumericalEarth.DataWrangling.BoundingBox(longitude=(0, 5), latitude=(40, 45)) @@ -63,8 +71,7 @@ start_date = DateTime(2005, 2, 16, 12) close(ds) - # Clean up - rm(filepath; force=true) + # Note: leave `filepath` in place; downstream surface-level testsets reuse it. end @testset "Availability of ERA5 variables" begin @@ -88,14 +95,14 @@ start_date = DateTime(2005, 2, 16, 12) # Test metadata properties @test metadatum.name == :temperature - @test metadatum.dataset isa ERA5Hourly + @test metadatum.dataset isa ERA5HourlySingleLevel @test metadatum.dates == start_date @test metadatum.region == region # Test size (should be global ERA5 size with 1 time step) Nx, Ny, Nz, Nt = size(metadatum) @test Nx == 1440 # ERA5 longitude points - @test Ny == 721 # ERA5 latitude points + @test Ny == 720 # ERA5 latitude points (poles averaged into adjacent cells) @test Nz == 1 # 2D surface data @test Nt == 1 # Single time step @@ -104,31 +111,31 @@ start_date = DateTime(2005, 2, 16, 12) end @testset "ERA5 wave variable metadata sizes" begin - # Wave variables should be on the 0.5° grid (720×361) + # Wave variables should be on the 0.5° grid (720×360) for wave_var in (:eastward_stokes_drift, :northward_stokes_drift, :significant_wave_height, :mean_wave_period, :mean_wave_direction) metadatum = Metadatum(wave_var; dataset, date=start_date) Nx, Ny, Nz, Nt = size(metadatum) @test Nx == 720 - @test Ny == 361 + @test Ny == 360 @test Nz == 1 @test Nt == 1 end - # Atmospheric variables should remain on the 0.25° grid (1440×721) + # Atmospheric variables should remain on the 0.25° grid (1440×720) for atmos_var in (:temperature, :eastward_velocity, :surface_pressure) metadatum = Metadatum(atmos_var; dataset, date=start_date) Nx, Ny, Nz, Nt = size(metadatum) @test Nx == 1440 - @test Ny == 721 + @test Ny == 720 @test Nz == 1 @test Nt == 1 end end @testset "ERA5 Monthly dataset" begin - monthly_dataset = ERA5Monthly() - @test monthly_dataset isa ERA5Monthly + monthly_dataset = ERA5MonthlySingleLevel() + @test monthly_dataset isa ERA5MonthlySingleLevel # Test that all_dates returns a valid range dates = NumericalEarth.DataWrangling.all_dates(monthly_dataset, :temperature) @@ -136,6 +143,153 @@ start_date = DateTime(2005, 2, 16, 12) @test step(dates) == Month(1) end + @testset "ERA5 single-level all_dates (Hourly)" begin + hourly_dataset = ERA5HourlySingleLevel() + dates = NumericalEarth.DataWrangling.all_dates(hourly_dataset, :temperature) + @test first(dates) == DateTime("1940-01-01") + @test step(dates) == Hour(1) + end + + @testset "ERA5 single-level dispatch helpers" begin + ds = ERA5HourlySingleLevel() + md = Metadatum(:temperature; dataset=ds, region, date=start_date) + + # API-name and netcdf-name dicts cover the same variable set — + # catches forgetting to add a new variable to one of the two + @test keys(ERA5_dataset_variable_names) == keys(ERA5_netcdf_variable_names) + + # available_variables returns the API-name dict (used to build CDS requests), + # not the netcdf short-name dict — guards against the easy swap-mistake + @test NumericalEarth.DataWrangling.available_variables(ds) === ERA5_dataset_variable_names + + # dataset_variable_name returns the netcdf short name (read from file), + # not the API catalog name — same swap risk + @test NumericalEarth.DataWrangling.dataset_variable_name(md) == "t2m" + + # default_inpainting is nothing for ERA5 (vs NearestNeighborInpainting for ECCO); + # accidentally enabling it would massively slow Field construction + @test NumericalEarth.DataWrangling.default_inpainting(md) === nothing + end + + @testset "ERA5 single-level metadata_prefix" begin + ds = ERA5HourlySingleLevel() + mp = NumericalEarth.DataWrangling.ERA5.metadata_prefix + + # Single-date with region: prefix should not duplicate the date + prefix_single = mp(ds, :temperature, start_date, region) + @test occursin("2m_temperature", prefix_single) + @test occursin("ERA5HourlySingleLevel", prefix_single) + @test occursin("2005-02-16", prefix_single) + @test count("2005-02-16", prefix_single) == 1 # date appears once for single-date + @test occursin("0.0", prefix_single) # west bound + @test occursin("5.0", prefix_single) # east bound + @test occursin("40.0", prefix_single) # south bound + @test occursin("45.0", prefix_single) # north bound + # Filename safety + @test !occursin(":", prefix_single) # colons replaced by dashes + @test !occursin(" ", prefix_single) # spaces replaced by underscores + + # Single-date, no region: suffix should be empty + prefix_no_region = mp(ds, :temperature, start_date, nothing) + @test !occursin("0.0", prefix_no_region) + @test !occursin("nothing", prefix_no_region) + + # Multi-date: prefix should include both start and end dates + end_date = start_date + Hour(2) + prefix_multi = mp(ds, :temperature, start_date:Hour(1):end_date, region) + @test occursin("2005-02-16T12", prefix_multi) + @test occursin("2005-02-16T14", prefix_multi) + end + + @testset "ERA5HourlyPressureLevels construction and metadata" begin + # Default constructor uses all 37 standard levels + ds_full = ERA5HourlyPressureLevels() + @test ds_full isa ERA5HourlyPressureLevels + @test length(ds_full.pressure_levels) == 37 + @test Base.size(ds_full, :temperature) == (1440, 720, 37) + + # Subset constructor + ds_sub = ERA5HourlyPressureLevels(pressure_levels=[850, 500]hPa) + @test Base.size(ds_sub, :temperature) == (1440, 720, 2) + + # Monthly variant + ds_monthly = ERA5MonthlyPressureLevels() + @test ds_monthly isa ERA5MonthlyPressureLevels + + # Metadatum size propagates Nz correctly + meta = Metadatum(:temperature; dataset=ds_sub, region=region, date=start_date) + Nx, Ny, Nz, Nt = size(meta) + @test Nz == 2 + @test NumericalEarth.DataWrangling.ERA5.is_three_dimensional(meta) == true + + # Variable name lookups + @test ERA5PL_dataset_variable_names[:temperature] == "temperature" + @test ERA5PL_dataset_variable_names[:geopotential_height] == "geopotential" + end + + @testset "ERA5 pressure-level z_interfaces (standard atmosphere)" begin + levels_2 = [850, 500]hPa + z = standard_atmosphere_z_interfaces(levels_2) + @test length(z) == 3 # Nz+1 interfaces + @test issorted(z) # monotonically increasing with altitude + # 850 hPa ≈ 1457 m, 500 hPa ≈ 5575 m + @test z[1] < 1457.0 < z[2] < 5575.0 < z[3] + + # Single level + z1 = standard_atmosphere_z_interfaces([500]hPa) + @test length(z1) == 2 + @test z1[1] < z1[2] + end + + @testset "ERA5 pressure-level constructors sort levels descending" begin + # Pass ASCENDING input so the test fails if the inner constructor's + # `sort(...; rev=true)` regresses to a no-op or different order. + ds_h = ERA5HourlyPressureLevels([500, 850]hPa) + @test ds_h.pressure_levels == [850 * hPa, 500 * hPa] # stored highest-pressure-first + @test ds_h.z === nothing + @test ds_h.mean_geopotential_height == true # default kwarg + + ds_m = ERA5MonthlyPressureLevels([500, 850]hPa) + @test ds_m.pressure_levels == [850 * hPa, 500 * hPa] + @test ds_m.z === nothing + @test ds_m.mean_geopotential_height == true + + # Already-descending input is preserved (sort is a no-op here) + ds_h2 = ERA5HourlyPressureLevels([850, 500]hPa) + @test ds_h2.pressure_levels == [850 * hPa, 500 * hPa] + + # Three-level shuffled input + ds_h3 = ERA5HourlyPressureLevels([500, 850, 700]hPa) + @test ds_h3.pressure_levels == [850 * hPa, 700 * hPa, 500 * hPa] + end + + @testset "ERA5 pressure-level stagger" begin + stagger = NumericalEarth.DataWrangling.ERA5.stagger + + # Two-element input (evenly spaced): bottom and top faces are + # extrapolated symmetrically; result is Nz+1 monotonic. + zf = stagger([0.0, 1.0]) + @test length(zf) == 3 + @test issorted(zf) + @test zf ≈ [-0.5, 0.5, 1.5] + + # Three-element evenly-spaced: every interior interface is the + # midpoint of the adjacent centers; bottom/top are extrapolated. + zf = stagger([1.0, 3.0, 5.0]) + @test length(zf) == 4 + @test zf ≈ [0.0, 2.0, 4.0, 6.0] + + # Three-element irregularly-spaced: interior midpoints honor the + # actual spacing (not assumed-uniform). + zf = stagger([1.0, 3.0, 7.0]) + @test length(zf) == 4 + @test zf[2] ≈ 2.0 # midpoint(1, 3) + @test zf[3] ≈ 5.0 # midpoint(3, 7) + # Boundaries extrapolated at half the adjacent interior spacing + @test zf[1] ≈ 1.0 - (zf[2] - 1.0) + @test zf[4] ≈ 7.0 + (7.0 - zf[3]) + end + for arch in test_architectures A = typeof(arch) @@ -162,10 +316,7 @@ start_date = DateTime(2005, 2, 16, 12) @test !all(iszero, interior(ψ)) end - # Clean up - rm(filepath; force=true) - inpainted_path = NumericalEarth.DataWrangling.inpainted_metadata_path(metadatum) - isfile(inpainted_path) && rm(inpainted_path; force=true) + # Note: cleanup happens in the last surface-level testset below. end @testset "Setting a field from ERA5 metadata on $A" begin @@ -201,4 +352,749 @@ start_date = DateTime(2005, 2, 16, 12) isfile(inpainted_path) && rm(inpainted_path; force=true) end end + + @testset "ERA5 pressure-level download and Field on CPU" begin + arch = CPU() + ds_pl = ERA5HourlyPressureLevels(pressure_levels=[850, 500]hPa) + + @testset "Download and 3D Field" begin + meta = Metadatum(:temperature; dataset=ds_pl, region, date=start_date) + filepath = metadata_path(meta) + isfile(filepath) && rm(filepath; force=true) + + download_dataset(meta) + @test isfile(filepath) + + # Verify the NetCDF has a pressure_level dimension and the right variable + ds_nc = NCDataset(filepath) + @test haskey(ds_nc, "t") + @test haskey(ds_nc, "pressure_level") || haskey(ds_nc, "level") + close(ds_nc) + + f = Field(meta, arch) + @test f isa Field + Nx, Ny, Nz = size(f) + @test Nz == 2 + + @allowscalar begin + @test !all(iszero, interior(f)) + # Temperature at these levels should be in a plausible range (K) + @test all(x -> 180 < x < 340, filter(!isnan, vec(interior(f)))) + end + + rm(filepath; force=true) + inpainted_path = NumericalEarth.DataWrangling.inpainted_metadata_path(meta) + isfile(inpainted_path) && rm(inpainted_path; force=true) + end + + @testset "Geopotential height conversion" begin + meta_z = Metadatum(:geopotential_height; dataset=ds_pl, region, date=start_date) + filepath = metadata_path(meta_z) + + # Field() downloads if needed; the file may already be on disk from + # the previous testset's z_interfaces side-effect. + fz = Field(meta_z, arch) + + @allowscalar begin + max_z = maximum(filter(!isnan, vec(interior(fz)))) + # 500 hPa geopotential height ≈ 5500 m + @test 4000 < max_z < 7000 + end + + rm(filepath; force=true) + inpainted_path = NumericalEarth.DataWrangling.inpainted_metadata_path(meta_z) + isfile(inpainted_path) && rm(inpainted_path; force=true) + end + + @testset "pressure_field" begin + meta = Metadatum(:temperature; dataset=ds_pl, region, date=start_date) + pf = pressure_field(meta, arch) + @test pf isa Field + Nx, Ny, Nz = size(pf) + @test Nz == 2 + + @allowscalar begin + # k=1 should be 850 hPa = 85000 Pa (highest pressure, lowest altitude) + @test interior(pf)[1, 1, 1] ≈ Float32(850hPa) + # k=2 should be 500 hPa = 50000 Pa + @test interior(pf)[1, 1, 2] ≈ Float32(500hPa) + end + end + end +end + +@testset "ERA5 CDSAPIExt dispatch helpers and area construction" begin + sl = ERA5HourlySingleLevel() + pl = ERA5HourlyPressureLevels(pressure_levels=[500hPa, 850hPa]) + + @testset "cds_product / cds_varnames / nc_varnames" begin + @test CDSExt.cds_product(sl) == "reanalysis-era5-single-levels" + @test CDSExt.cds_product(pl) == "reanalysis-era5-pressure-levels" + + @test CDSExt.cds_varnames(sl) === ERA5_dataset_variable_names + @test CDSExt.cds_varnames(pl) === ERA5PL_dataset_variable_names + + @test CDSExt.nc_varnames(sl) === ERA5_netcdf_variable_names + @test CDSExt.nc_varnames(pl) === ERA5PL_netcdf_variable_names + end + + @testset "coord_vars" begin + sl_coords = CDSExt.coord_vars(sl) + pl_coords = CDSExt.coord_vars(pl) + + @test sl_coords isa Set + @test "longitude" in sl_coords + @test "latitude" in sl_coords + @test "valid_time" in sl_coords + @test !("pressure_level" in sl_coords) + + @test "longitude" in pl_coords + @test "pressure_level" in pl_coords + @test "level" in pl_coords + end + + @testset "extra_request_keys!" begin + # ERA5Dataset (single level): no-op + request = Dict{String, Any}("variable" => ["2m_temperature"]) + CDSExt.extra_request_keys!(request, sl) + @test !haskey(request, "pressure_level") + + # ERA5PressureLevelsDataset: populates `pressure_level` (in hPa, as strings) + CDSExt.extra_request_keys!(request, pl) + @test haskey(request, "pressure_level") + @test Set(request["pressure_level"]) == Set(["500", "850"]) + end + + @testset "build_era5_area" begin + # Nothing → nothing + @test CDSExt.build_era5_area(nothing) === nothing + + # BoundingBox with both axes → [N, W, S, E] + bbox = BoundingBox(longitude=(-10.0, 5.0), latitude=(40.0, 50.0)) + @test CDSExt.build_era5_area(bbox) == [50.0, -10.0, 40.0, 5.0] + + # BoundingBox with one axis missing → nothing (CDS gets the global slab) + bbox_no_lat = BoundingBox(longitude=(-10.0, 5.0)) + @test CDSExt.build_era5_area(bbox_no_lat) === nothing + bbox_no_lon = BoundingBox(latitude=(40.0, 50.0)) + @test CDSExt.build_era5_area(bbox_no_lon) === nothing + + # Column with Nearest interpolation → tight ε=1e-3 box around the point + col_nr = Column(-61.5, 18.0; interpolation=Nearest()) + area_nr = CDSExt.build_era5_area(col_nr) + @test length(area_nr) == 4 + # [N, W, S, E] + @test area_nr[1] ≈ 18.0 + 1e-3 # north + @test area_nr[2] ≈ -61.5 - 1e-3 # west + @test area_nr[3] ≈ 18.0 - 1e-3 # south + @test area_nr[4] ≈ -61.5 + 1e-3 # east + + # Column with Linear interpolation → ε=0.3 padding for 2x2 stencil + col_lin = Column(-61.5, 18.0; interpolation=Linear()) + area_lin = CDSExt.build_era5_area(col_lin) + @test area_lin[1] ≈ 18.0 + 0.3 + @test area_lin[2] ≈ -61.5 - 0.3 + @test area_lin[3] ≈ 18.0 - 0.3 + @test area_lin[4] ≈ -61.5 + 0.3 + # Linear box must enclose more than one ERA5 grid cell (0.25°) + @test (area_lin[1] - area_lin[3]) > 0.25 + @test (area_lin[4] - area_lin[2]) > 0.25 + end +end + +@testset "ERA5 CDSAPIExt build_era5_request" begin + sl = ERA5HourlySingleLevel() + pl = ERA5HourlyPressureLevels(pressure_levels=[500hPa, 850hPa]) + bbox = BoundingBox(longitude=(-10.0, 5.0), latitude=(40.0, 50.0)) + col_nr = Column(-61.5, 18.0; interpolation=Nearest()) + col_lin = Column(-61.5, 18.0; interpolation=Linear()) + dt = DateTime(2005, 2, 16, 12) + + @testset "Single-level dataset: no pressure_level key" begin + req = CDSExt.build_era5_request(:temperature, sl, dt; region=nothing) + @test !haskey(req, "pressure_level") + end + + @testset "Pressure-level dataset: pressure_level is sorted hPa strings" begin + # Constructor sorts levels descending (highest-pressure-first); request preserves that order + req = CDSExt.build_era5_request(:temperature, pl, dt; region=nothing) + @test haskey(req, "pressure_level") + @test req["pressure_level"] == ["850", "500"] + @test all(s -> s isa String, req["pressure_level"]) + end + + @testset "BoundingBox region produces area in [N, W, S, E] order" begin + req = CDSExt.build_era5_request(:temperature, sl, dt; region=bbox) + @test haskey(req, "area") + @test req["area"] == [50.0, -10.0, 40.0, 5.0] + end + + @testset "Column with Nearest interpolation: tight ε=1e-3 box" begin + req = CDSExt.build_era5_request(:temperature, sl, dt; region=col_nr) + @test haskey(req, "area") + area = req["area"] + @test area[1] ≈ 18.0 + 1e-3 # north + @test area[2] ≈ -61.5 - 1e-3 # west + @test area[3] ≈ 18.0 - 1e-3 # south + @test area[4] ≈ -61.5 + 1e-3 # east + end + + @testset "Column with Linear interpolation: ε=0.3 padding" begin + req = CDSExt.build_era5_request(:temperature, sl, dt; region=col_lin) + @test haskey(req, "area") + area = req["area"] + @test area[1] ≈ 18.0 + 0.3 + @test area[2] ≈ -61.5 - 0.3 + @test area[3] ≈ 18.0 - 0.3 + @test area[4] ≈ -61.5 + 0.3 + end + + @testset "region=nothing omits area key" begin + req = CDSExt.build_era5_request(:temperature, sl, dt; region=nothing) + @test !haskey(req, "area") + end + + @testset "Multiple datetimes on same day: time is an array, not a single string" begin + dts = [DateTime(2005, 2, 16, 0), DateTime(2005, 2, 16, 6), DateTime(2005, 2, 16, 18)] + req = CDSExt.build_era5_request(:temperature, sl, dts; region=nothing) + @test req["time"] == ["00:00", "06:00", "18:00"] + # year/month/day are scalars-in-an-array, taken from the first datetime + @test req["year"] == ["2005"] + @test req["month"] == ["02"] + @test req["day"] == ["16"] + end + + @testset "Single datetime still produces a one-element time array" begin + req = CDSExt.build_era5_request(:temperature, sl, dt; region=nothing) + @test req["time"] isa AbstractVector + @test req["time"] == ["12:00"] + end + + @testset "Zero-padded month/day/hour" begin + # Month=2, day=3, hour=4 — must come out "02", "03", "04:00" + req = CDSExt.build_era5_request(:temperature, sl, DateTime(2005, 2, 3, 4); region=nothing) + @test req["year"] == ["2005"] + @test req["month"] == ["02"] + @test req["day"] == ["03"] + @test req["time"] == ["04:00"] + end + + @testset "Single Symbol and Vector{Symbol} inputs are equivalent" begin + req_sym = CDSExt.build_era5_request(:temperature, sl, dt; region=nothing) + req_vec = CDSExt.build_era5_request([:temperature], sl, dt; region=nothing) + @test req_sym["variable"] == req_vec["variable"] == ["2m_temperature"] + end + + @testset "Multi-variable request unique-ifies variable list" begin + req = CDSExt.build_era5_request([:temperature, :temperature], sl, dt; region=nothing) + @test req["variable"] == ["2m_temperature"] + end + + @testset "Constant keys present on every request" begin + req = CDSExt.build_era5_request(:temperature, sl, dt; region=nothing) + @test req["product_type"] == ["reanalysis"] + @test req["data_format"] == "netcdf" + @test req["download_format"] == "unarchived" + end +end + +@testset "ERA5 CDSAPIExt plan_era5_day" begin + region = BoundingBox(longitude=(0, 5), latitude=(40, 45)) + ds = ERA5HourlySingleLevel() + dt1 = DateTime(2005, 2, 16, 0) + dt2 = DateTime(2005, 2, 16, 12) + + mktempdir() do tmp + # Helper: where each datetime's output file would live + function expected_path(date) + md = Metadatum(:temperature; dataset=ds, region, date, dir=tmp) + return metadata_path(md) + end + p1, p2 = expected_path(dt1), expected_path(dt2) + + @testset "all paths missing: all pending, full plan populated" begin + plan = CDSExt.plan_era5_day(:temperature, ds, [dt1, dt2]; + region, dir=tmp, skip_existing=true) + @test plan.dt_path_pairs == [(dt1, p1), (dt2, p2)] + @test length(plan.pending) == 2 + @test plan.request !== nothing + @test plan.request["time"] == ["00:00", "12:00"] + @test plan.tmp_path == joinpath(tmp, "_tmp_20050216.nc") + @test length(plan.nc_triples) == 2 + # All triples carry the netcdf short name for :temperature on single-level + @test all(t -> first(t) == "t2m", plan.nc_triples) + # tidx values map sorted_dts to 1-based indices + @test Set(t[2] for t in plan.nc_triples) == Set([1, 2]) + end + + @testset "partial coverage: pending narrows to missing datetime" begin + mkpath(dirname(p1)); touch(p1) + plan = CDSExt.plan_era5_day(:temperature, ds, [dt1, dt2]; + region, dir=tmp, skip_existing=true) + @test length(plan.dt_path_pairs) == 2 + @test length(plan.pending) == 1 + @test plan.pending[1][1] == dt2 + @test plan.request["time"] == ["12:00"] + @test length(plan.nc_triples) == 1 + rm(p1) + end + + @testset "all paths present: empty pending and nothing fields" begin + for p in (p1, p2) + mkpath(dirname(p)); touch(p) + end + plan = CDSExt.plan_era5_day(:temperature, ds, [dt1, dt2]; + region, dir=tmp, skip_existing=true) + @test length(plan.dt_path_pairs) == 2 + @test isempty(plan.pending) + @test plan.request === nothing + @test plan.tmp_path === nothing + @test plan.nc_triples === nothing + for p in (p1, p2); rm(p); end + end + + @testset "skip_existing=false ignores existing files" begin + mkpath(dirname(p1)); touch(p1) + mkpath(dirname(p2)); touch(p2) + plan = CDSExt.plan_era5_day(:temperature, ds, [dt1, dt2]; + region, dir=tmp, skip_existing=false) + @test length(plan.pending) == 2 + @test plan.request !== nothing + for p in (p1, p2); rm(p); end + end + end +end + +@testset "ERA5 CDSAPIExt plan_era5_multivar_day" begin + region = BoundingBox(longitude=(0, 5), latitude=(40, 45)) + ds_pl = ERA5HourlyPressureLevels(pressure_levels=[850, 500]hPa) + dt1 = DateTime(2005, 2, 16, 0) + dt2 = DateTime(2005, 2, 16, 12) + names = [:temperature, :eastward_velocity] + + mktempdir() do tmp + function expected_path(name, date) + md = Metadatum(name; dataset=ds_pl, region, date, dir=tmp) + return metadata_path(md) + end + + @testset "all missing: full plan with both names and times" begin + plan = CDSExt.plan_era5_multivar_day(names, ds_pl, [dt1, dt2]; + region, dir=tmp, skip_existing=true) + @test length(plan.name_dt_paths) == 4 # 2 names × 2 datetimes + @test length(plan.pending) == 4 + @test plan.request["time"] == ["00:00", "12:00"] + @test Set(plan.request["variable"]) == Set(["temperature", "u_component_of_wind"]) + @test plan.tmp_path == joinpath(tmp, "_tmp_multi_20050216.nc") + @test length(plan.nc_triples) == 4 + # Pressure-level netcdf short names for the two variables + @test Set(first.(plan.nc_triples)) == Set(["t", "u"]) + @test Set(t[2] for t in plan.nc_triples) == Set([1, 2]) + end + + @testset "partial coverage: pending narrows variables, request reflects subset" begin + # Touch only :temperature paths so pending is just the velocity ones + for dt in (dt1, dt2) + p = expected_path(:temperature, dt) + mkpath(dirname(p)); touch(p) + end + plan = CDSExt.plan_era5_multivar_day(names, ds_pl, [dt1, dt2]; + region, dir=tmp, skip_existing=true) + @test length(plan.pending) == 2 + @test all(p -> p[1] == :eastward_velocity, plan.pending) + @test plan.request["variable"] == ["u_component_of_wind"] + @test plan.request["time"] == ["00:00", "12:00"] + for dt in (dt1, dt2); rm(expected_path(:temperature, dt)); end + end + + @testset "all present: empty pending and nothing fields" begin + for name in names, dt in (dt1, dt2) + p = expected_path(name, dt) + mkpath(dirname(p)); touch(p) + end + plan = CDSExt.plan_era5_multivar_day(names, ds_pl, [dt1, dt2]; + region, dir=tmp, skip_existing=true) + @test length(plan.name_dt_paths) == 4 + @test isempty(plan.pending) + @test plan.request === nothing + @test plan.tmp_path === nothing + @test plan.nc_triples === nothing + for name in names, dt in (dt1, dt2) + rm(expected_path(name, dt)) + end + end + + @testset "skip_existing=false: pending is everything regardless" begin + for name in names, dt in (dt1, dt2) + p = expected_path(name, dt) + mkpath(dirname(p)); touch(p) + end + plan = CDSExt.plan_era5_multivar_day(names, ds_pl, [dt1, dt2]; + region, dir=tmp, skip_existing=false) + @test length(plan.pending) == 4 + @test plan.request !== nothing + for name in names, dt in (dt1, dt2); rm(expected_path(name, dt)); end + end + end +end + +@testset "ERA5 CDSAPIExt _group_by_calendar_day" begin + # Single calendar day with multiple hours + same_day = [DateTime(2005, 2, 16, 0), + DateTime(2005, 2, 16, 6), + DateTime(2005, 2, 16, 23)] + g = CDSExt._group_by_calendar_day(same_day) + @test length(g) == 1 + @test Date(2005, 2, 16) in keys(g) + @test length(g[Date(2005, 2, 16)]) == 3 + + # Boundary: 00:00 belongs to its OWN day (not the previous one) + midnight_pair = [DateTime(2005, 2, 16, 23), + DateTime(2005, 2, 17, 0)] + g = CDSExt._group_by_calendar_day(midnight_pair) + @test length(g) == 2 + @test g[Date(2005, 2, 16)] == [DateTime(2005, 2, 16, 23)] + @test g[Date(2005, 2, 17)] == [DateTime(2005, 2, 17, 0)] + + # Multiple days, interleaved order — grouping must be order-independent + mixed = [DateTime(2005, 2, 17, 6), + DateTime(2005, 2, 16, 6), + DateTime(2005, 2, 17, 12), + DateTime(2005, 2, 16, 18)] + g = CDSExt._group_by_calendar_day(mixed) + @test length(g) == 2 + @test Set(g[Date(2005, 2, 16)]) == Set([DateTime(2005, 2, 16, 6), DateTime(2005, 2, 16, 18)]) + @test Set(g[Date(2005, 2, 17)]) == Set([DateTime(2005, 2, 17, 6), DateTime(2005, 2, 17, 12)]) + + # Duplicate datetimes are preserved (CDS will dedupe; we don't) + dups = [DateTime(2005, 2, 16, 12), DateTime(2005, 2, 16, 12)] + g = CDSExt._group_by_calendar_day(dups) + @test length(g) == 1 + @test length(g[Date(2005, 2, 16)]) == 2 + + # Single-element input + g = CDSExt._group_by_calendar_day([DateTime(2005, 2, 16, 12)]) + @test length(g) == 1 + @test g[Date(2005, 2, 16)] == [DateTime(2005, 2, 16, 12)] +end + +@testset "ERA5 CDSAPIExt skip_existing short-circuit" begin + # Build a temporary directory and pre-create the expected output files so + # `download_dataset(...; skip_existing=true)` returns without contacting CDS. + # If the short-circuit ever regresses, these tests will throw a credentials + # error (or 4xx from the CDS API) and fail loudly. + region = NumericalEarth.DataWrangling.BoundingBox(longitude=(0, 5), latitude=(40, 45)) + mktempdir() do tmp + ds_pl = ERA5HourlyPressureLevels(pressure_levels=[850, 500]hPa) + date1 = DateTime(2005, 2, 16, 12) + date2 = DateTime(2005, 2, 16, 18) + names = [:temperature, :eastward_velocity] + + # Helper: pre-create the file that `download_dataset` would write + function touch_expected(name, dataset, date) + md = Metadatum(name; dataset, region, date, dir=tmp) + path = metadata_path(md) + mkpath(dirname(path)) + touch(path) + return path + end + + @testset "multi-variable pressure-level (single date)" begin + paths = [touch_expected(name, ds_pl, date1) for name in names] + meta = Metadatum(:temperature; dataset=ds_pl, region, date=date1, dir=tmp) + + result = download_dataset(names, meta; skip_existing=true) + @test result isa Vector{String} + @test length(result) == length(names) + @test Set(result) == Set(paths) + end + + @testset "single-variable multi-date (download_era5_day)" begin + # All hours of date1, date2 already on disk + ds_sl = ERA5HourlySingleLevel() + expected = [touch_expected(:temperature, ds_sl, dt) for dt in (date1, date2)] + + # Returns the existing paths without raising — the early-return guard fires + result = CDSExt.download_era5_day(:temperature, ds_sl, [date1, date2]; + region, dir=tmp, + skip_existing=true, cleanup=true) + @test result isa Vector{String} + @test Set(result) == Set(expected) + end + + @testset "multi-variable multi-date (download_era5_multivar_day)" begin + ds_sl = ERA5HourlySingleLevel() + expected = [touch_expected(name, ds_sl, dt) for name in names for dt in (date1, date2)] + + result = CDSExt.download_era5_multivar_day(names, ds_sl, [date1, date2]; + region, dir=tmp, + skip_existing=true, cleanup=true) + @test result isa Vector{String} + @test Set(result) == Set(expected) + end + + # Dates spanning two calendar days — exercises the parents' + # path-collection across multiple `_group_by_calendar_day` groups. + # Catches regressions that drop or overwrite paths from one group. + date_day1 = DateTime(2005, 2, 16, 12) + date_day2 = DateTime(2005, 2, 17, 6) + + @testset "ERA5Metadata parent (multi-day)" begin + ds_sl = ERA5HourlySingleLevel() + expected = [touch_expected(:temperature, ds_sl, dt) for dt in (date_day1, date_day2)] + meta = Metadata(:temperature; dataset=ds_sl, dates=[date_day1, date_day2], region, dir=tmp) + + result = download_dataset(meta; skip_existing=true) + @test result isa Vector{String} + @test Set(result) == Set(expected) + end + + @testset "ERA5PressureMetadata parent (multi-day, multi-name)" begin + expected = [touch_expected(name, ds_pl, dt) for name in names for dt in (date_day1, date_day2)] + meta = Metadata(:temperature; dataset=ds_pl, dates=[date_day1, date_day2], region, dir=tmp) + + result = download_dataset(names, meta; skip_existing=true) + @test result isa Vector{String} + @test Set(result) == Set(expected) + end + + @testset "names + dataset + datetimes convenience overload (multi-day)" begin + ds_sl = ERA5HourlySingleLevel() + expected = [touch_expected(name, ds_sl, dt) for name in names for dt in (date_day1, date_day2)] + + result = download_dataset(names, ds_sl, [date_day1, date_day2]; + region, dir=tmp, skip_existing=true, cleanup=true) + @test result isa Vector{String} + @test Set(result) == Set(expected) + end + end +end + +@testset "ERA5 CDSAPIExt NetCDF copy and split helpers" begin + # Helper: write a synthetic ERA5-like NetCDF with `Nt` timesteps and two + # variables (`u`, `v`) on dims (longitude, latitude, valid_time). + function write_synthetic_era5_nc(path; Nx=2, Ny=2, Nt=3) + NCDatasets.Dataset(path, "c") do ds + NCDatasets.defDim(ds, "longitude", Nx) + NCDatasets.defDim(ds, "latitude", Ny) + NCDatasets.defDim(ds, "valid_time", Nt) + ds.attrib["title"] = "synthetic_era5_test" + + lon = NCDatasets.defVar(ds, "longitude", Float64, ("longitude",)) + lat = NCDatasets.defVar(ds, "latitude", Float64, ("latitude",)) + t = NCDatasets.defVar(ds, "valid_time", Int64, ("valid_time",)) + lon[:] = collect(range(-1.0, 1.0; length=Nx)) + lat[:] = collect(range(40.0, 41.0; length=Ny)) + t[:] = collect(1:Nt) + + # u: includes _FillValue and a custom attribute + u = NCDatasets.defVar(ds, "u", Float32, + ("longitude", "latitude", "valid_time"); + fillvalue=Float32(-9999.0)) + u.attrib["units"] = "m s**-1" + u.attrib["long_name"] = "u_component_of_wind" + for k in 1:Nt, j in 1:Ny, i in 1:Nx + u[i, j, k] = Float32(100k + 10j + i) + end + + # v: no fill value, no extra attributes + v = NCDatasets.defVar(ds, "v", Float32, + ("longitude", "latitude", "valid_time")) + for k in 1:Nt, j in 1:Ny, i in 1:Nx + v[i, j, k] = Float32(-(100k + 10j + i)) + end + end + end + + coord_vars = CDSExt.ERA5_COORD_VARS + + @testset "ncvar_copy! preserves data, attributes, fill value" begin + mktempdir() do dir + src_path = joinpath(dir, "src.nc") + dst_path = joinpath(dir, "dst.nc") + write_synthetic_era5_nc(src_path; Nx=3, Ny=2, Nt=1) + + NCDatasets.Dataset(src_path, "r") do src + NCDatasets.Dataset(dst_path, "c") do dst + for (dname, dlen) in src.dim + NCDatasets.defDim(dst, dname, dlen) + end + CDSExt.ncvar_copy!(dst, src["u"], "u") + end + end + + NCDatasets.Dataset(dst_path, "r") do dst + @test haskey(dst, "u") + @test eltype(dst["u"].var) == Float32 + @test dst["u"].attrib["units"] == "m s**-1" + @test dst["u"].attrib["long_name"] == "u_component_of_wind" + @test dst["u"].attrib["_FillValue"] == Float32(-9999.0) + + NCDatasets.Dataset(src_path, "r") do src + @test dst["u"].var[:] == src["u"].var[:] + end + end + end + end + + @testset "ncvar_copy_tslice! extracts a single timestep" begin + mktempdir() do dir + src_path = joinpath(dir, "src.nc") + dst_path = joinpath(dir, "dst.nc") + write_synthetic_era5_nc(src_path; Nx=2, Ny=2, Nt=3) + + tidx = 2 + time_dimnames = Set(["valid_time"]) + + NCDatasets.Dataset(src_path, "r") do src + NCDatasets.Dataset(dst_path, "c") do dst + for (dname, dlen) in src.dim + out_len = dname in time_dimnames ? 1 : dlen + NCDatasets.defDim(dst, dname, out_len) + end + CDSExt.ncvar_copy_tslice!(dst, src["u"], "u", tidx, time_dimnames) + # `valid_time` is a coord variable in the file — copy that too, + # using the same tslice path. Exercises the has_time branch. + CDSExt.ncvar_copy_tslice!(dst, src["valid_time"], "valid_time", tidx, time_dimnames) + # `longitude` has no time dim — exercises the !has_time branch. + CDSExt.ncvar_copy_tslice!(dst, src["longitude"], "longitude", tidx, time_dimnames) + end + end + + NCDatasets.Dataset(dst_path, "r") do dst + @test dst.dim["valid_time"] == 1 + @test size(dst["u"]) == (2, 2, 1) + @test dst["valid_time"][:] == [tidx] + + NCDatasets.Dataset(src_path, "r") do src + @test dst["u"].var[:, :, 1] == src["u"].var[:, :, tidx] + @test dst["longitude"][:] == src["longitude"][:] + end + end + end + end + + @testset "split_era5_nc produces per-variable files" begin + mktempdir() do dir + src_path = joinpath(dir, "src.nc") + write_synthetic_era5_nc(src_path; Nx=2, Ny=2, Nt=1) + + pairs = [ + ("u", joinpath(dir, "u_only.nc")), + ("v", joinpath(dir, "v_only.nc")), + ("missing_var", joinpath(dir, "should_not_exist.nc")), + ] + + CDSExt.split_era5_nc(src_path, pairs, coord_vars) + + @test !isfile(joinpath(dir, "should_not_exist.nc")) + + for (vname, dst_path) in pairs[1:2] + @test isfile(dst_path) + NCDatasets.Dataset(dst_path, "r") do dst + @test haskey(dst, vname) + other = vname == "u" ? "v" : "u" + @test !haskey(dst, other) + NCDatasets.Dataset(src_path, "r") do src + @test dst[vname].var[:] == src[vname].var[:] + end + end + end + end + end + + @testset "split_era5_nc_multistep produces per-(var,timestep) files" begin + mktempdir() do dir + src_path = joinpath(dir, "src.nc") + write_synthetic_era5_nc(src_path; Nx=2, Ny=2, Nt=3) + + triples = [ + ("u", 1, joinpath(dir, "u_t1.nc")), + ("u", 3, joinpath(dir, "u_t3.nc")), + ("v", 2, joinpath(dir, "v_t2.nc")), + # Variable not present in source — silently skipped, no file. + ("missing_var", 1, joinpath(dir, "should_not_exist.nc")), + ] + time_dimnames = Set(["valid_time"]) + + CDSExt.split_era5_nc_multistep(src_path, triples, coord_vars, time_dimnames) + + # The skipped variable produces no output. + @test !isfile(joinpath(dir, "should_not_exist.nc")) + + for (vname, tidx, dst_path) in triples[1:3] + @test isfile(dst_path) + NCDatasets.Dataset(dst_path, "r") do dst + @test haskey(dst, vname) + @test dst.dim["valid_time"] == 1 + @test haskey(dst, "longitude") + @test haskey(dst, "latitude") + # The other ERA5 variable should not have leaked in. + other = vname == "u" ? "v" : "u" + @test !haskey(dst, other) + + NCDatasets.Dataset(src_path, "r") do src + @test dst[vname].var[:, :, 1] == src[vname].var[:, :, tidx] + end + end + end + end + end +end + +@testset "ERA5 CDSAPIExt is_zip and foreach_nc" begin + @testset "is_zip" begin + mktempdir() do tmp + # File starting with the ZIP magic header + zip_path = joinpath(tmp, "fake.zip") + open(zip_path, "w") do io + write(io, UInt8[0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]) + end + @test CDSExt.is_zip(zip_path) == true + + # File with arbitrary non-magic bytes (NetCDF-3 starts with "CDF\x01") + nc_path = joinpath(tmp, "fake.nc") + open(nc_path, "w") do io + write(io, UInt8[0x43, 0x44, 0x46, 0x01]) + end + @test CDSExt.is_zip(nc_path) == false + + # Short file (<4 bytes) — length check guards against false positives + short_path = joinpath(tmp, "short.bin") + open(short_path, "w") do io + write(io, UInt8[0x50, 0x4b]) # only 2 of the 4 magic bytes + end + @test CDSExt.is_zip(short_path) == false + end + end + + @testset "foreach_nc — non-zip path calls f exactly once" begin + mktempdir() do tmp + nc_path = joinpath(tmp, "data.nc") + touch(nc_path) + + received = String[] + CDSExt.foreach_nc(p -> push!(received, p), nc_path, tmp) + + @test received == [nc_path] + end + end + + @testset "foreach_nc — zip path extracts and visits each .nc" begin + mktempdir() do tmp + # Build a ZIP fixture containing two .nc files (and a non-.nc file + # that should be ignored). + nc1 = joinpath(tmp, "a.nc"); touch(nc1) + nc2 = joinpath(tmp, "b.nc"); touch(nc2) + other = joinpath(tmp, "readme.txt"); touch(other) + + zip_path = joinpath(tmp, "bundle.zip") + run(`zip -j -q $zip_path $nc1 $nc2 $other`) + + received = String[] + CDSExt.foreach_nc(p -> push!(received, basename(p)), zip_path, tmp) + + @test sort(received) == ["a.nc", "b.nc"] # readme.txt filtered out + end + end end diff --git a/test/test_checkpointer.jl b/test/test_checkpointer.jl index 3d058af42..ab13917b4 100644 --- a/test/test_checkpointer.jl +++ b/test/test_checkpointer.jl @@ -30,8 +30,9 @@ using Oceananigans.OutputWriters: Checkpointer backend = JRA55NetCDFBackend(4) atmosphere = JRA55PrescribedAtmosphere(arch; backend) land = JRA55PrescribedLand(arch; backend) + radiation = JRA55PrescribedRadiation(arch; backend) - return OceanSeaIceModel(ocean, sea_ice; atmosphere, land) + return OceanSeaIceModel(sea_ice, ocean; atmosphere, land, radiation) end # Reference run: 3 iterations, then continue to 6 diff --git a/test/test_column_field.jl b/test/test_column_field.jl index ea33bad6c..bb9004e15 100644 --- a/test/test_column_field.jl +++ b/test/test_column_field.jl @@ -212,7 +212,7 @@ end @testset "ERA5 Column grid" begin col = Column(200.0, 35.0) - md = Metadatum(:temperature; dataset=ERA5Hourly(), + md = Metadatum(:temperature; dataset=ERA5HourlySingleLevel(), date=DateTime(2020, 1, 1), region=col) grid = native_grid(md) @@ -231,3 +231,50 @@ end @test eltype(grid) == Float32 end end + +@testset "restrict (BoundingBox grid construction helper)" begin + restrict = NumericalEarth.DataWrangling.restrict + + # Identity case: bbox covers the full domain. Grid pads by Δ/2 on each side + # so that face midpoints land on data centers. The padded extent is N+1 + # cells of width Δ exactly, but Float64 rounding can push the ceil one cell + # past that — so allow [N+1, N+2]. + grid_interfaces, rN = restrict((0.0, 360.0), (0.0, 360.0), 1440) + @test grid_interfaces[1] ≈ -0.125 + @test grid_interfaces[2] ≈ 360.125 + @test 1441 <= rN <= 1442 + + # Half-domain bbox: rN should be just over half of N. + _, rN = restrict((0.0, 180.0), (0.0, 360.0), 1440) + @test 720 < rN <= 722 # ceil(0.5 * 1440 + small) = 721 + + # Small bbox (5° wide on a 1440-cell grid): rN should be ceil(20 + small) = 21. + grid_interfaces, rN = restrict((0.0, 5.0), (0.0, 360.0), 1440) + @test grid_interfaces[1] ≈ -0.125 + @test grid_interfaces[2] ≈ 5.125 + @test rN == 21 + + # Off-origin bbox preserves width: 5° wide on a 720-cell, 180°-tall grid → + # rΔ = 5° + Δ = 5.25°, rN = ceil((5.25/180) * 720) = 21. + _, rN_off = restrict((40.0, 45.0), (-90.0, 90.0), 720) + @test rN_off == 21 + + # Pass-through for `nothing` (the no-restriction case). + @test restrict(nothing, (0.0, 360.0), 1440) == ((0.0, 360.0), 1440) +end + +@testset "restrict_location dispatch" begin + bbox = BoundingBox(longitude=(0, 5), latitude=(40, 45)) + col = Column(2.5, 42.5) + + # BoundingBox: locations passed through unchanged + @test restrict_location((Center, Center, Center), bbox) == (Center, Center, Center) + @test restrict_location((Face, Face, Center), bbox) == (Face, Face, Center) + + # Nothing: same — no restriction + @test restrict_location((Center, Center, Center), nothing) == (Center, Center, Center) + + # Column: horizontal locations reduce to Nothing, vertical preserved + @test restrict_location((Center, Center, Center), col) == (Nothing, Nothing, Center) + @test restrict_location((Face, Face, Center), col) == (Nothing, Nothing, Center) +end diff --git a/test/test_diagnostics_2.jl b/test/test_diagnostics_2.jl index 59e6d1c68..28fec3701 100644 --- a/test/test_diagnostics_2.jl +++ b/test/test_diagnostics_2.jl @@ -23,7 +23,7 @@ for arch in test_architectures sea_ice = sea_ice_simulation(grid, ocean) atmosphere = PrescribedAtmosphere(grid, [0.0]) - esm = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation = Radiation()) + esm = OceanSeaIceModel(sea_ice, ocean; atmosphere) T_flux = ocean.model.tracers.T.boundary_conditions.top.condition S_flux = ocean.model.tracers.S.boundary_conditions.top.condition diff --git a/test/test_ecco_atmosphere.jl b/test/test_ecco_atmosphere.jl index 70ac2b8ca..12d120930 100644 --- a/test/test_ecco_atmosphere.jl +++ b/test/test_ecco_atmosphere.jl @@ -2,8 +2,9 @@ include("runtests_setup.jl") include("download_utils.jl") using Statistics: median -using NumericalEarth.Atmospheres: PrescribedAtmosphere, TwoBandDownwellingRadiation -using NumericalEarth.ECCO: ECCOPrescribedAtmosphere, ECCO4Monthly +using NumericalEarth.Atmospheres: PrescribedAtmosphere +using NumericalEarth.Radiations: PrescribedRadiation +using NumericalEarth.ECCO: ECCOPrescribedAtmosphere, ECCOPrescribedRadiation, ECCO4Monthly using NumericalEarth.DataWrangling: download_dataset, metadata_path, higher_bound # Pre-download ECCO4Monthly atmospheric forcing variables through the artifacts @@ -34,7 +35,14 @@ end end_date, time_indices_in_memory = 2) + radiation = ECCOPrescribedRadiation(arch; + dataset, + start_date, + end_date, + time_indices_in_memory = 2) + @test atmosphere isa PrescribedAtmosphere + @test radiation isa PrescribedRadiation # Test that all expected fields are present @test haskey(atmosphere.velocities, :u) @@ -42,12 +50,11 @@ end @test haskey(atmosphere.tracers, :T) @test haskey(atmosphere.tracers, :q) @test !isnothing(atmosphere.pressure) - @test !isnothing(atmosphere.downwelling_radiation) @test haskey(atmosphere.freshwater_flux, :rain) # Test downwelling radiation components - ℐꜜˢʷ = atmosphere.downwelling_radiation.shortwave - ℐꜜˡʷ = atmosphere.downwelling_radiation.longwave + ℐꜜˢʷ = radiation.downwelling_shortwave + ℐꜜˡʷ = radiation.downwelling_longwave @test ℐꜜˢʷ isa FieldTimeSeries @test ℐꜜˡʷ isa FieldTimeSeries diff --git a/test/test_ocean_only_model.jl b/test/test_ocean_only_model.jl index 6254f9e63..09c5e4c4f 100644 --- a/test/test_ocean_only_model.jl +++ b/test/test_ocean_only_model.jl @@ -19,7 +19,7 @@ using Oceananigans.OrthogonalSphericalShellGrids add_callback!(ocean, pushdata) backend = JRA55NetCDFBackend(4) atmosphere = JRA55PrescribedAtmosphere(arch; backend) - radiation = Radiation(arch) + radiation = JRA55PrescribedRadiation(arch; backend) coupled_model = OceanOnlyModel(ocean; atmosphere, radiation) Δt = 60 for n = 1:3 @@ -50,7 +50,7 @@ using Oceananigans.OrthogonalSphericalShellGrids backend = JRA55NetCDFBackend(4) atmosphere = JRA55PrescribedAtmosphere(arch; backend) - radiation = Radiation(arch) + radiation = JRA55PrescribedRadiation(arch; backend) # Fluxes are computed when the model is constructed, so we just test that this works. @test begin diff --git a/test/test_ocean_sea_ice_model.jl b/test/test_ocean_sea_ice_model.jl index acfc1246f..035d7e877 100644 --- a/test/test_ocean_sea_ice_model.jl +++ b/test/test_ocean_sea_ice_model.jl @@ -59,12 +59,12 @@ using ClimaSeaIce.Rheologies backend = JRA55NetCDFBackend(4) atmosphere = JRA55PrescribedAtmosphere(arch; backend) - radiation = Radiation(arch) + radiation = JRA55PrescribedRadiation(arch; backend) # Fluxes are computed when the model is constructed, so we just test that this works. # And that we can time step with sea ice @test begin - coupled_model = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + coupled_model = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation) time_step!(coupled_model, 1) true end @@ -79,7 +79,7 @@ using ClimaSeaIce.Rheologies sea_ice_with_land = sea_ice_simulation(grid, ocean_with_land; advection=WENO(order=7)) above_freezing_ocean_temperature!(ocean_with_land, grid, sea_ice_with_land) - coupled_model = OceanSeaIceModel(ocean_with_land, sea_ice_with_land; atmosphere, land, radiation) + coupled_model = OceanSeaIceModel(sea_ice_with_land, ocean_with_land; atmosphere, land, radiation) @test !isnothing(coupled_model.interfaces.exchanger.land) time_step!(coupled_model, 1) true diff --git a/test/test_orca_grid.jl b/test/test_orca_grid.jl index 8bf50b197..3bf33d03c 100644 --- a/test/test_orca_grid.jl +++ b/test/test_orca_grid.jl @@ -44,6 +44,7 @@ end @test occursin("eORCA12", metadata_path(mesh_meta)) @test occursin("eORCA12", metadata_path(bathy_meta)) end + @testset "ORCAGrid with ORCA1 dataset on $(arch)" for arch in test_architectures south_rows_to_remove = 43 grid = ORCAGrid(arch; dataset=ORCA1(), Nz=5, z=(-5000, 0), halo=(4, 4, 4), south_rows_to_remove) @@ -107,16 +108,23 @@ end @test all(isfinite, Oceananigans.on_architecture(CPU(), data)) == true end - # All interior metrics (Δx, Δy, Az) are strictly positive - # Check only interior points to avoid halo issues - for name in (:Δxᶜᶜᵃ, :Δxᶠᶜᵃ, :Δxᶜᶠᵃ, :Δxᶠᶠᵃ, - :Δyᶜᶜᵃ, :Δyᶠᶜᵃ, :Δyᶜᶠᵃ, :Δyᶠᶠᵃ, - :Azᶜᶜᵃ, :Azᶠᶜᵃ, :Azᶜᶠᵃ, :Azᶠᶠᵃ) + # Metrics strictly positive over the full interior. Face-y fields on + # RightFaceFolded have Ny+1 interior rows; the fold row Ny+1 must be checked. + LYs = Dict(:Δxᶜᶜᵃ => Center, :Δxᶠᶜᵃ => Center, :Δxᶜᶠᵃ => Face, :Δxᶠᶠᵃ => Face, + :Δyᶜᶜᵃ => Center, :Δyᶠᶜᵃ => Center, :Δyᶜᶠᵃ => Face, :Δyᶠᶠᵃ => Face, + :Azᶜᶜᵃ => Center, :Azᶠᶜᵃ => Center, :Azᶜᶠᵃ => Face, :Azᶠᶠᵃ => Face) + for (name, LY) in LYs data = getproperty(grid, name) - interior = Oceananigans.on_architecture(CPU(), data)[1:Nx, 1:Ny] + Njf = Base.length(LY(), Oceananigans.Grids.RightFaceFolded(), Ny) + interior = Oceananigans.on_architecture(CPU(), data)[1:Nx, 1:Njf] @test all(x -> x > 0, interior) == true end + for name in (:Δxᶜᶠᵃ, :Δxᶠᶠᵃ, :Δyᶜᶠᵃ, :Δyᶠᶠᵃ, :Azᶜᶠᵃ, :Azᶠᶠᵃ) + data = Oceananigans.on_architecture(CPU(), getproperty(grid, name)) + @test all(x -> x > 0, data[1:Nx, Ny+1]) + end + # Face-x longitude is west of Center-x longitude (stagger check) # At mid-latitudes (away from poles), Face[i] should be ≤ Center[i] in longitude. # Check a mid-latitude row (away from fold and south boundary). diff --git a/test/test_ospapa.jl b/test/test_ospapa.jl index 81b2cccf9..15a1af718 100644 --- a/test/test_ospapa.jl +++ b/test/test_ospapa.jl @@ -2,6 +2,7 @@ include("runtests_setup.jl") using NumericalEarth.OSPapa using NumericalEarth.Atmospheres: PrescribedAtmosphere +using NumericalEarth.Radiations: PrescribedRadiation using Oceananigans.BoundaryConditions: BoundaryCondition, Flux, getbc using Oceananigans.Units: minutes using CUDA: @allowscalar @@ -18,7 +19,12 @@ const OSPAPA_TEST_END = DateTime(2012, 10, 3) start_date = OSPAPA_TEST_START, end_date = OSPAPA_TEST_END) + radiation = OSPapaPrescribedRadiation(arch; + start_date = OSPAPA_TEST_START, + end_date = OSPAPA_TEST_END) + @test atmosphere isa PrescribedAtmosphere + @test radiation isa PrescribedRadiation # All expected fields are present @test haskey(atmosphere.velocities, :u) @@ -26,12 +32,11 @@ const OSPAPA_TEST_END = DateTime(2012, 10, 3) @test haskey(atmosphere.tracers, :T) @test haskey(atmosphere.tracers, :q) @test !isnothing(atmosphere.pressure) - @test !isnothing(atmosphere.downwelling_radiation) @test haskey(atmosphere.freshwater_flux, :rain) # Radiation sanity checks - ℐꜜˢʷ = atmosphere.downwelling_radiation.shortwave - ℐꜜˡʷ = atmosphere.downwelling_radiation.longwave + ℐꜜˢʷ = radiation.downwelling_shortwave + ℐꜜˡʷ = radiation.downwelling_longwave @allowscalar begin sw_data = interior(ℐꜜˢʷ) @@ -216,8 +221,11 @@ end atmosphere = OSPapaPrescribedAtmosphere(arch; start_date = OSPAPA_TEST_START, end_date = OSPAPA_TEST_END) + radiation = OSPapaPrescribedRadiation(arch; + start_date = OSPAPA_TEST_START, + end_date = OSPAPA_TEST_END) - coupled_model = OceanOnlyModel(ocean; atmosphere, radiation=Radiation(arch)) + coupled_model = OceanOnlyModel(ocean; atmosphere, radiation) simulation = Simulation(coupled_model; Δt=ocean.Δt, stop_iteration=2) @test begin diff --git a/test/test_polar_bathymetry.jl b/test/test_polar_bathymetry.jl new file mode 100644 index 000000000..f1b24b281 --- /dev/null +++ b/test/test_polar_bathymetry.jl @@ -0,0 +1,136 @@ +include("runtests_setup.jl") + +using NumericalEarth.DataWrangling.IBCSO +using NumericalEarth.DataWrangling.GEBCO +using NumericalEarth.DataWrangling.IBCAO +using NumericalEarth.DataWrangling: longitude_interfaces, latitude_interfaces, z_interfaces, + dataset_variable_name, validate_dataset_coverage, + metadata_filename +using NumericalEarth.Bathymetry: regrid_bathymetry + +@testset "Polar bathymetry metadata interfaces" begin + + @testset "IBCSOv2 metadata" begin + ds = IBCSOv2() + @test longitude_interfaces(ds) == (-180, 180) + @test latitude_interfaces(ds) == (-90, -50) + @test z_interfaces(ds) == (0, 1) + @test size(ds) == (33812, 3757, 1) + + meta = Metadatum(:bottom_height, dataset=ds) + @test dataset_variable_name(meta) == "z" + @test endswith(metadata_filename(ds, :bottom_height, nothing, nothing), ".nc") + end + + @testset "IBCSOv2 coverage validation" begin + meta = Metadatum(:bottom_height, dataset=IBCSOv2()) + + # Grid that extends north of -50°S should throw + grid_bad = LatitudeLongitudeGrid(CPU(); + size = (10, 10, 1), + longitude = (0, 360), + latitude = (-60, 0), + z = (-1, 0)) + @test_throws ErrorException validate_dataset_coverage(grid_bad, meta) + + # Grid entirely south of -50°S should pass + grid_ok = LatitudeLongitudeGrid(CPU(); + size = (10, 10, 1), + longitude = (0, 360), + latitude = (-90, -55), + z = (-1, 0)) + @test validate_dataset_coverage(grid_ok, meta) === nothing + end + + @testset "GEBCO2024 metadata" begin + ds = GEBCO2024() + @test longitude_interfaces(ds) == (-180, 180) + @test latitude_interfaces(ds) == (-90, 90) + @test z_interfaces(ds) == (0, 1) + Nx, Ny, Nz = size(ds) + @test Nx == 86400 # 360° at 15 arc-second + @test Ny == 43200 # 180° at 15 arc-second + @test Nz == 1 + + meta = Metadatum(:bottom_height, dataset=ds) + @test dataset_variable_name(meta) == "elevation" + @test endswith(metadata_filename(ds, :bottom_height, nothing, nothing), ".nc") + end + + @testset "IBCAOv5 metadata" begin + ds = IBCAOv5() + @test longitude_interfaces(ds) == (-180, 180) + @test latitude_interfaces(ds) == (64, 90) + @test z_interfaces(ds) == (0, 1) + @test size(ds) == (36000, 2600, 1) + + meta = Metadatum(:bottom_height, dataset=ds) + @test dataset_variable_name(meta) == "z" + @test endswith(metadata_filename(ds, :bottom_height, nothing, nothing), ".nc") + end + + @testset "IBCAOv5 coverage validation" begin + meta = Metadatum(:bottom_height, dataset=IBCAOv5()) + + # Grid that extends south of 64°N should throw + grid_bad = LatitudeLongitudeGrid(CPU(); + size = (10, 10, 1), + longitude = (-20, 20), + latitude = (50, 80), + z = (-1, 0)) + @test_throws ErrorException validate_dataset_coverage(grid_bad, meta) + + # Grid entirely north of 64°N should pass + grid_ok = LatitudeLongitudeGrid(CPU(); + size = (10, 10, 1), + longitude = (-20, 20), + latitude = (70, 85), + z = (-1, 0)) + @test validate_dataset_coverage(grid_ok, meta) === nothing + end + +end + +@testset "validate_dataset_coverage wired into regrid_bathymetry" begin + # Out-of-range grid should throw before any download occurs + meta = Metadatum(:bottom_height, dataset=IBCSOv2()) + grid_bad = LatitudeLongitudeGrid(CPU(); + size = (10, 10, 1), + longitude = (0, 360), + latitude = (-60, 0), + z = (-1, 0)) + @test_throws ErrorException regrid_bathymetry(grid_bad, meta) + + meta = Metadatum(:bottom_height, dataset=IBCAOv5()) + grid_bad = LatitudeLongitudeGrid(CPU(); + size = (10, 10, 1), + longitude = (-20, 20), + latitude = (50, 80), + z = (-1, 0)) + @test_throws ErrorException regrid_bathymetry(grid_bad, meta) +end + +@testset "IBCSO regridding" begin + @info "Testing IBCSO regridding (downloads ~1.5 GB on first run)..." + + # Drake Passage: open deep ocean well within IBCSO coverage + grid = LatitudeLongitudeGrid(CPU(); + size = (20, 20, 1), + longitude = (-70, -60), + latitude = (-60, -55), + z = (-6000, 0)) + + meta = Metadatum(:bottom_height, dataset=IBCSOv2()) + bathy = regrid_bathymetry(grid, meta; cache=false, height_above_water=0) + z = interior(bathy, :, :, 1) + + # All values should be finite (no NaN or Inf from interpolation gaps) + @test all(isfinite, z) + + # With height_above_water=0 all land cells are capped at 0 + @test maximum(z) ≤ 0 + + # Realistic ocean depths: deeper than 500 m, shallower than the deepest ocean + @test minimum(z) > -12000 + @test minimum(z) < -500 +end diff --git a/test/test_radiations.jl b/test/test_radiations.jl new file mode 100644 index 000000000..ac651db89 --- /dev/null +++ b/test/test_radiations.jl @@ -0,0 +1,82 @@ +include("runtests_setup.jl") + +using NumericalEarth.Radiations: PrescribedRadiation, + SurfaceRadiationProperties, + InterfaceRadiationFlux + +@testset "PrescribedRadiation construction" begin + for arch in test_architectures + A = typeof(arch) + + # Form B: grid-only constructor (zero downwelling, surface properties only) + @info "Testing PrescribedRadiation(grid) on $A..." + grid = RectilinearGrid(arch, size = 10, z = (-100, 0), topology = (Flat, Flat, Bounded)) + rad = PrescribedRadiation(grid) + @test rad isa PrescribedRadiation + @test rad.surface_properties isa NamedTuple + @test haskey(rad.surface_properties, :ocean) + @test haskey(rad.surface_properties, :sea_ice) + @test rad.surface_properties.ocean isa SurfaceRadiationProperties + @test rad.surface_properties.ocean.albedo == 0.05 + @test rad.surface_properties.ocean.emissivity == 0.97 + @test rad.surface_properties.sea_ice.albedo == 0.7 + @test rad.surface_properties.sea_ice.emissivity == 1.0 + @test rad.stefan_boltzmann_constant ≈ 5.67e-8 atol=1e-10 + @test isnothing(rad.interface_fluxes) + + # Surfaces can be omitted + @info "Testing PrescribedRadiation(grid; sea_ice_surface=nothing) on $A..." + rad_ocean_only = PrescribedRadiation(grid; sea_ice_surface = nothing) + @test haskey(rad_ocean_only.surface_properties, :ocean) + @test !haskey(rad_ocean_only.surface_properties, :sea_ice) + + # Custom surface properties + custom_ocean = SurfaceRadiationProperties(0.1, 0.95) + rad_custom = PrescribedRadiation(grid; ocean_surface = custom_ocean) + @test rad_custom.surface_properties.ocean.albedo == 0.1 + @test rad_custom.surface_properties.ocean.emissivity == 0.95 + + # time_step! works + @info "Testing time_step!(::PrescribedRadiation) on $A..." + rad2 = PrescribedRadiation(grid) + time_step!(rad2, 60.0) + @test rad2.clock.time == 60.0 + end +end + +@testset "PrescribedRadiation paired with model" begin + for arch in test_architectures + A = typeof(arch) + + @info "Testing OceanOnlyModel with PrescribedRadiation on $A..." + + grid = RectilinearGrid(arch, size = 10, z = (-100, 0), topology = (Flat, Flat, Bounded)) + ocean = ocean_simulation(grid) + radiation = PrescribedRadiation(grid) + model = OceanOnlyModel(ocean; radiation) + + # interface_fluxes are allocated for present surfaces (ocean + sea_ice + # via FreezingLimitedOceanTemperature). + @test !isnothing(model.radiation.interface_fluxes) + @test model.radiation.interface_fluxes.ocean isa InterfaceRadiationFlux + @test model.radiation.interface_fluxes.sea_ice isa InterfaceRadiationFlux + + time_step!(model, 60) + @test iteration(model) == 1 + end +end + +@testset "JRA55PrescribedRadiation" begin + for arch in test_architectures + A = typeof(arch) + @info "Testing JRA55PrescribedRadiation on $A..." + + backend = JRA55NetCDFBackend(2) + radiation = JRA55PrescribedRadiation(arch; backend) + + @test radiation isa PrescribedRadiation + @test radiation.downwelling_shortwave isa FieldTimeSeries + @test radiation.downwelling_longwave isa FieldTimeSeries + @test radiation.surface_properties.ocean isa SurfaceRadiationProperties + end +end diff --git a/test/test_reactant.jl b/test/test_reactant.jl index c37963121..fc32d8dff 100644 --- a/test/test_reactant.jl +++ b/test/test_reactant.jl @@ -36,8 +36,7 @@ end atmos_times = range(0, 360Oceananigans.Units.days, length=10) atmosphere = PrescribedAtmosphere(atmos_grid, atmos_times) - radiation = Radiation(arch) - coupled_model = OceanOnlyModel(ocean; atmosphere, radiation) + coupled_model = OceanOnlyModel(ocean; atmosphere) # Test that Reactant does _not_ initialize in the constructor for EarthSystemModel exchanger = coupled_model.interfaces.exchanger.atmosphere diff --git a/test/test_sea_ice_ocean_heat_fluxes.jl b/test/test_sea_ice_ocean_heat_fluxes.jl index 7506d3d78..b20601ab8 100644 --- a/test/test_sea_ice_ocean_heat_fluxes.jl +++ b/test/test_sea_ice_ocean_heat_fluxes.jl @@ -201,14 +201,14 @@ end backend = JRA55NetCDFBackend(4) atmosphere = JRA55PrescribedAtmosphere(arch; backend) - radiation = Radiation(arch) + radiation = JRA55PrescribedRadiation(arch; backend) for sea_ice_ocean_heat_flux in [IceBathHeatFlux(), ThreeEquationHeatFlux()] @testset "Salt flux with $(nameof(typeof(sea_ice_ocean_heat_flux)))" begin interfaces = ComponentInterfaces(atmosphere, ocean, sea_ice; radiation, sea_ice_ocean_heat_flux) - coupled_model = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation, interfaces) + coupled_model = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation, interfaces) # Test melting conditions: warm ocean above freezing # Freezing point at S=35 is about -1.9°C @@ -259,14 +259,14 @@ end backend = JRA55NetCDFBackend(4) atmosphere = JRA55PrescribedAtmosphere(arch; backend) - radiation = Radiation(arch) + radiation = JRA55PrescribedRadiation(arch; backend) for sea_ice_ocean_heat_flux in [IceBathHeatFlux(), ThreeEquationHeatFlux()] @testset "Flux magnitude with $(nameof(typeof(sea_ice_ocean_heat_flux)))" begin interfaces = ComponentInterfaces(atmosphere, ocean, sea_ice; radiation, sea_ice_ocean_heat_flux) - coupled_model = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation, interfaces) + coupled_model = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation, interfaces) # Set up melting conditions set!(ocean.model, T=2.0, S=35.0) # Warm ocean @@ -403,14 +403,14 @@ end backend = JRA55NetCDFBackend(4) atmosphere = JRA55PrescribedAtmosphere(arch; backend) - radiation = Radiation(arch) + radiation = JRA55PrescribedRadiation(arch; backend) for sea_ice_ocean_heat_flux in [IceBathHeatFlux(), ThreeEquationHeatFlux()] @testset "Frazil with $(nameof(typeof(sea_ice_ocean_heat_flux)))" begin interfaces = ComponentInterfaces(atmosphere, ocean, sea_ice; radiation, sea_ice_ocean_heat_flux) - coupled_model = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation, interfaces) + coupled_model = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation, interfaces) # Set up conditions where frazil might form: # Cold ocean near freezing with ice present @@ -454,11 +454,11 @@ end backend = JRA55NetCDFBackend(4) atmosphere = JRA55PrescribedAtmosphere(arch; backend) - radiation = Radiation(arch) + radiation = JRA55PrescribedRadiation(arch; backend) # Test with ThreeEquationHeatFlux (default) @test begin - coupled_model = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + coupled_model = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation) flux_form = coupled_model.interfaces.sea_ice_ocean_interface.flux_formulation flux_form isa ThreeEquationHeatFlux end @@ -469,7 +469,7 @@ end interfaces = ComponentInterfaces(atmosphere, ocean, sea_ice; radiation, sea_ice_ocean_heat_flux = flux) - coupled_model = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation, interfaces) + coupled_model = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation, interfaces) flux_form = coupled_model.interfaces.sea_ice_ocean_interface.flux_formulation flux_form isa IceBathHeatFlux end @@ -482,7 +482,7 @@ end interfaces = ComponentInterfaces(atmosphere, ocean, sea_ice; radiation, sea_ice_ocean_heat_flux) - coupled_model = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation, interfaces) + coupled_model = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation, interfaces) @test begin time_step!(coupled_model, 60) true diff --git a/test/test_speedy_coupling.jl b/test/test_speedy_coupling.jl index 59cf8c58b..413f030d5 100644 --- a/test/test_speedy_coupling.jl +++ b/test/test_speedy_coupling.jl @@ -15,8 +15,7 @@ Oceananigans.set!(ocean.model, T=EN4Metadatum(:temperature), S=EN4Metadatum(:sal atmos = NumericalEarth.atmosphere_simulation(spectral_grid) -radiation = Radiation(ocean_emissivity=0.0, sea_ice_emissivity=0.0) -earth_model = EarthSystemModel(atmos, ocean, default_sea_ice(); radiation) +earth_model = EarthSystemModel(; atmosphere=atmos, sea_ice=default_sea_ice(), ocean) Qca = atmos.variables.parameterizations.ocean.sensible_heat_flux.data Mva = atmos.variables.parameterizations.ocean.surface_humidity_flux.data diff --git a/test/test_surface_fluxes.jl b/test/test_surface_fluxes.jl index 3ffdd7b60..8f2cf51b6 100644 --- a/test/test_surface_fluxes.jl +++ b/test/test_surface_fluxes.jl @@ -75,15 +75,12 @@ end ρᵃᵗ = Thermodynamics.air_density(ℂᵃᵗ, Tᵃᵗ, pᵃᵗ, qᵃᵗ) ℰv = Thermodynamics.latent_heat_vapor(ℂᵃᵗ, Tᵃᵗ) - # No radiation equivalent - radiation = Radiation(ocean_emissivity=0, ocean_albedo=1) - - # turbulent fluxes that force a specific humidity at the ocean's surface + # No radiation: pass `radiation = nothing` to disable radiative + # contributions wholesale. for atmosphere_ocean_interface_temperature in (BulkTemperature(), SkinTemperature(DiffusiveFlux(1, 1e-2))) @info " Testing zero fluxes with $(atmosphere_ocean_interface_temperature)..." interfaces = ComponentInterfaces(atmosphere, ocean; - radiation, atmosphere_ocean_interface_specific_humidity, atmosphere_ocean_interface_temperature) @@ -246,7 +243,7 @@ end # Always cooling! fill!(atmosphere.tracers.T, 273.15 - 20) - coupled_model = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + coupled_model = OceanSeaIceModel(sea_ice, ocean; atmosphere) # Test that the temperature has snapped up to freezing @test minimum(ocean.model.tracers.T) == 0 @@ -286,7 +283,7 @@ end fill!(ocean.model.tracers.T, -2.0) # Test that we populate the sea-ice ocean stress - earth = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation=Radiation()) + earth = OceanSeaIceModel(sea_ice, ocean; atmosphere) τˣ = earth.interfaces.sea_ice_ocean_interface.fluxes.x_momentum τʸ = earth.interfaces.sea_ice_ocean_interface.fluxes.y_momentum @@ -330,7 +327,7 @@ end # radiation = Radiation(ocean_albedo=0.1, ocean_emissivity=1.0) # sea_ice = nothing -# coupled_model = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) +# coupled_model = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation) # times = 0:1hours:1days # Ntimes = length(times) From 8668b22553874f25ebbb3fc87ce7aecb69b70f36 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 7 May 2026 12:11:18 +0200 Subject: [PATCH 40/54] correct the fluxes --- .../breeze_atmosphere_interface.jl | 28 +++++++------- .../speedy_weather_exchanger.jl | 36 +++++++++++------- src/Atmospheres/Atmospheres.jl | 2 +- .../interpolate_atmospheric_state.jl | 19 +++++----- src/Atmospheres/prescribed_atmosphere.jl | 38 ++++++++++++++++++- .../prescribed_atmosphere_regridder.jl | 2 +- src/DataWrangling/ECCO/ECCO_atmosphere.jl | 4 +- src/DataWrangling/JRA55/JRA55.jl | 2 +- .../JRA55/JRA55_prescribed_atmosphere.jl | 3 +- src/DataWrangling/OSPapa/OSPapa.jl | 2 +- .../OSPapa/OSPapa_prescribed_atmosphere.jl | 2 +- src/Oceans/assemble_net_ocean_fluxes.jl | 27 +++++++------ src/SeaIces/assemble_net_sea_ice_fluxes.jl | 5 +-- test/test_ecco_atmosphere.jl | 6 ++- test/test_ospapa.jl | 6 ++- 15 files changed, 115 insertions(+), 67 deletions(-) diff --git a/ext/NumericalEarthBreezeExt/breeze_atmosphere_interface.jl b/ext/NumericalEarthBreezeExt/breeze_atmosphere_interface.jl index 45add2f8e..ef8be726d 100644 --- a/ext/NumericalEarthBreezeExt/breeze_atmosphere_interface.jl +++ b/ext/NumericalEarthBreezeExt/breeze_atmosphere_interface.jl @@ -31,15 +31,15 @@ boundary_layer_height(::BreezeAtmosphere) = 600 ##### function ComponentExchanger(atmosphere::BreezeAtmosphere, exchange_grid) - state = (; u = Oceananigans.CenterField(exchange_grid), - v = Oceananigans.CenterField(exchange_grid), - T = Oceananigans.CenterField(exchange_grid), - p = Oceananigans.CenterField(exchange_grid), - q = Oceananigans.CenterField(exchange_grid), + state = (; u = Oceananigans.CenterField(exchange_grid), + v = Oceananigans.CenterField(exchange_grid), + T = Oceananigans.CenterField(exchange_grid), + p = Oceananigans.CenterField(exchange_grid), + q = Oceananigans.CenterField(exchange_grid), ℐꜜˢʷ = Oceananigans.CenterField(exchange_grid), ℐꜜˡʷ = Oceananigans.CenterField(exchange_grid), - Jᶜ = Oceananigans.CenterField(exchange_grid), - Mp = Oceananigans.CenterField(exchange_grid)) + Jʳⁿ = Oceananigans.CenterField(exchange_grid), + Jˢⁿ = Oceananigans.CenterField(exchange_grid), return ComponentExchanger(state, nothing) end @@ -52,15 +52,15 @@ end i, j = @index(Global, NTuple) @inbounds begin - state.u[i, j, 1] = u[i, j, 1] - state.v[i, j, 1] = v[i, j, 1] - state.T[i, j, 1] = T[i, j, 1] - state.q[i, j, 1] = ρqᵛᵉ[i, j, 1] / ρ₀[i, j, 1] - state.p[i, j, 1] = p₀ + state.u[i, j, 1] = u[i, j, 1] + state.v[i, j, 1] = v[i, j, 1] + state.T[i, j, 1] = T[i, j, 1] + state.q[i, j, 1] = ρqᵛᵉ[i, j, 1] / ρ₀[i, j, 1] + state.p[i, j, 1] = p₀ state.ℐꜜˢʷ[i, j, 1] = 0 state.ℐꜜˡʷ[i, j, 1] = 0 - state.Jᶜ[i, j, 1] = 0 - state.Mp[i, j, 1] = 0 + state.Jʳⁿ[i, j, 1] = 0 + state.Jˢⁿ[i, j, 1] = 0 end end diff --git a/ext/NumericalEarthSpeedyWeatherExt/speedy_weather_exchanger.jl b/ext/NumericalEarthSpeedyWeatherExt/speedy_weather_exchanger.jl index ec0dfa3e7..f7aedb525 100644 --- a/ext/NumericalEarthSpeedyWeatherExt/speedy_weather_exchanger.jl +++ b/ext/NumericalEarthSpeedyWeatherExt/speedy_weather_exchanger.jl @@ -31,14 +31,15 @@ function ComponentExchanger(atmosphere::SpeedySimulation, exchange_grid) to_atmosphere = XESMF.Regridder(exchange_grid, spectral_grid) regridder = (; to_atmosphere, from_atmosphere) - state = (; u = Field{Center, Center, Nothing}(exchange_grid), - v = Field{Center, Center, Nothing}(exchange_grid), - T = Field{Center, Center, Nothing}(exchange_grid), - p = Field{Center, Center, Nothing}(exchange_grid), - q = Field{Center, Center, Nothing}(exchange_grid), + state = (; u = Field{Center, Center, Nothing}(exchange_grid), + v = Field{Center, Center, Nothing}(exchange_grid), + T = Field{Center, Center, Nothing}(exchange_grid), + p = Field{Center, Center, Nothing}(exchange_grid), + q = Field{Center, Center, Nothing}(exchange_grid), ℐꜜˢʷ = Field{Center, Center, Nothing}(exchange_grid), ℐꜜˡʷ = Field{Center, Center, Nothing}(exchange_grid), - Jᶜ = Field{Center, Center, Nothing}(exchange_grid)) + Jʳⁿ = Field{Center, Center, Nothing}(exchange_grid), + Jˢⁿ = Field{Center, Center, Nothing}(exchange_grid)) return ComponentExchanger(state, regridder) end @@ -52,14 +53,19 @@ function interpolate_state!(exchanger, exchange_grid, atmos::SpeedySimulation, c exchange_state = exchanger.state surface_layer = atmos.model.spectral_grid.nlayers - ua = RingGrids.field_view(atmos.variables.grid.u, :, surface_layer).data - va = RingGrids.field_view(atmos.variables.grid.v, :, surface_layer).data - Ta = RingGrids.field_view(atmos.variables.grid.temperature, :, surface_layer).data - qa = RingGrids.field_view(atmos.variables.grid.humidity, :, surface_layer).data - pa = exp.(atmos.variables.grid.pressure.data) + ua = RingGrids.field_view(atmos.variables.grid.u, :, surface_layer).data + va = RingGrids.field_view(atmos.variables.grid.v, :, surface_layer).data + Ta = RingGrids.field_view(atmos.variables.grid.temperature, :, surface_layer).data + qa = RingGrids.field_view(atmos.variables.grid.humidity, :, surface_layer).data + pa = exp.(atmos.variables.grid.pressure.data) ℐꜜˢʷ = atmos.variables.parameterizations.surface_shortwave_down.data ℐꜜˡʷ = atmos.variables.parameterizations.surface_longwave_down.data - Jᶜ = atmos.variables.parameterizations.rain_rate.data .+ atmos.variables.parameterizations.snow_rate.data + Jʳⁿ = atmos.variables.parameterizations.rain_rate.data + + # `snow_rate` is only registered when SpeedyWeather's large-scale + # condensation parameterization is part of the model + Jˢⁿ = haskey(atmos.variables.parameterizations, :snow_rate) ? + atmos.variables.parameterizations.snow_rate.data : nothing regrid!(exchange_state.u, ua) regrid!(exchange_state.v, va) @@ -68,7 +74,8 @@ function interpolate_state!(exchanger, exchange_grid, atmos::SpeedySimulation, c regrid!(exchange_state.p, pa) regrid!(exchange_state.ℐꜜˢʷ, ℐꜜˢʷ) regrid!(exchange_state.ℐꜜˡʷ, ℐꜜˡʷ) - regrid!(exchange_state.Jᶜ, Jᶜ) + regrid!(exchange_state.Jʳⁿ, Jʳⁿ) + isnothing(Jˢⁿ) || regrid!(exchange_state.Jˢⁿ, Jˢⁿ) arch = architecture(exchange_grid) @@ -83,7 +90,8 @@ function interpolate_state!(exchanger, exchange_grid, atmos::SpeedySimulation, c fill_halo_regions!(exchange_state.p) fill_halo_regions!(exchange_state.ℐꜜˢʷ) fill_halo_regions!(exchange_state.ℐꜜˡʷ) - fill_halo_regions!(exchange_state.Jᶜ) + fill_halo_regions!(exchange_state.Jʳⁿ) + isnothing(Jˢⁿ) || fill_halo_regions!(exchange_state.Jˢⁿ) return nothing end diff --git a/src/Atmospheres/Atmospheres.jl b/src/Atmospheres/Atmospheres.jl index c7aef6a37..e4e4180f8 100644 --- a/src/Atmospheres/Atmospheres.jl +++ b/src/Atmospheres/Atmospheres.jl @@ -1,6 +1,6 @@ module Atmospheres -export atmosphere_simulation, PrescribedAtmosphere +export atmosphere_simulation, PrescribedAtmosphere, PrescribedPrecipitationFlux using Oceananigans using Oceananigans.Fields: Center diff --git a/src/Atmospheres/interpolate_atmospheric_state.jl b/src/Atmospheres/interpolate_atmospheric_state.jl index 6f8d6679a..1d08dcda4 100644 --- a/src/Atmospheres/interpolate_atmospheric_state.jl +++ b/src/Atmospheres/interpolate_atmospheric_state.jl @@ -27,8 +27,8 @@ function interpolate_state!(exchanger, grid, atmosphere::PrescribedAtmosphere, c atmosphere_tracers = (T = atmosphere.tracers.T.data, q = atmosphere.tracers.q.data) - freshwater_flux = map(ϕ -> ϕ.data, atmosphere.freshwater_flux) - snowfall_flux = haskey(atmosphere.freshwater_flux, :snow) ? atmosphere.freshwater_flux.snow.data : nothing + rainfall_flux = surface_rainfall_flux(atmosphere) + snowfall_flux = surface_snowfall_flux(atmosphere) atmosphere_pressure = atmosphere.pressure.data # Extract info for time-interpolation @@ -47,7 +47,7 @@ function interpolate_state!(exchanger, grid, atmosphere::PrescribedAtmosphere, c T = atmosphere_fields.T.data, p = atmosphere_fields.p.data, q = atmosphere_fields.q.data, - Jᶜ = atmosphere_fields.Jᶜ.data, + Jʳⁿ = atmosphere_fields.Jʳⁿ.data, Jˢⁿ = atmosphere_fields.Jˢⁿ.data) kernel_parameters = interface_kernel_parameters(grid) @@ -69,7 +69,7 @@ function interpolate_state!(exchanger, grid, atmosphere::PrescribedAtmosphere, c atmosphere_velocities, atmosphere_tracers, atmosphere_pressure, - freshwater_flux, + rainfall_flux, snowfall_flux, atmosphere_backend, atmosphere_time_indexing) @@ -96,7 +96,7 @@ end atmos_velocities, atmos_tracers, atmos_pressure, - prescribed_freshwater_flux, + rainfall_flux, snowfall_flux, atmos_backend, atmos_time_indexing) @@ -118,10 +118,9 @@ end qᵃᵗ = interp_atmos_time_series(atmos_tracers.q, atmos_args...) pᵃᵗ = interp_atmos_time_series(atmos_pressure, atmos_args...) - # Total precipitation (rain + snow) - Mh = interp_atmos_time_series(prescribed_freshwater_flux, atmos_args...) - - # Snowfall only (for sea ice snow accumulation) + # Rainfall and snowfall are kept separate downstream: Jʳⁿ holds the rain + # and Jˢⁿ holds the snow (used by the sea-ice snow accumulation). + Mr = interp_atmos_time_series(rainfall_flux, atmos_args...) Ms = interp_atmos_time_series(snowfall_flux, atmos_args...) # Convert atmosphere velocities (usually defined on a latitude-longitude grid) to @@ -135,7 +134,7 @@ end surface_atmos_state.T[i, j, 1] = Tᵃᵗ surface_atmos_state.p[i, j, 1] = pᵃᵗ surface_atmos_state.q[i, j, 1] = qᵃᵗ - surface_atmos_state.Jᶜ[i, j, 1] = Mh + surface_atmos_state.Jʳⁿ[i, j, 1] = Mr surface_atmos_state.Jˢⁿ[i, j, 1] = Ms end end diff --git a/src/Atmospheres/prescribed_atmosphere.jl b/src/Atmospheres/prescribed_atmosphere.jl index bbb5158ca..32a482222 100644 --- a/src/Atmospheres/prescribed_atmosphere.jl +++ b/src/Atmospheres/prescribed_atmosphere.jl @@ -42,12 +42,48 @@ function default_atmosphere_tracers(grid, times) return (T=Ta, q=qa) end +""" + PrescribedPrecipitationFlux(; rain=nothing, snow=nothing) + PrescribedPrecipitationFlux(rain, snow) + +Container for prescribed precipitation fluxes. Either component may be `nothing` +to indicate that the corresponding precipitation type is not represented by the +atmosphere (e.g. rain-only datasets). Used as the `freshwater_flux` of a +`PrescribedAtmosphere`; downstream callers query the snow component via +[`surface_snowfall_flux`](@ref) so that prognostic atmospheres with or without +snow can dispatch on this type as well. +""" +struct PrescribedPrecipitationFlux{R, S} + rain :: R + snow :: S +end + +PrescribedPrecipitationFlux(; rain=nothing, snow=nothing) = + PrescribedPrecipitationFlux(rain, snow) + +Adapt.adapt_structure(to, ff::PrescribedPrecipitationFlux) = + PrescribedPrecipitationFlux(adapt(to, ff.rain), adapt(to, ff.snow)) + function default_freshwater_flux(grid, times) rain = FieldTimeSeries{Center, Center, Nothing}(grid, times) snow = FieldTimeSeries{Center, Center, Nothing}(grid, times) - return (; rain, snow) + return PrescribedPrecipitationFlux(rain, snow) end +# `nothing` is returned when the atmosphere has no snow component, so that +# callers (e.g. sea-ice snow accumulation) can branch via dispatch rather than +# `haskey`. +@inline field_data(::Nothing) = nothing +@inline field_data(field) = field.data + +@inline surface_snowfall_flux(::Nothing) = nothing +@inline surface_snowfall_flux(atmos::PrescribedAtmosphere) = surface_snowfall_flux(atmos.freshwater_flux) +@inline surface_snowfall_flux(ff::PrescribedPrecipitationFlux) = field_data(ff.snow) + +@inline surface_rainfall_flux(::Nothing) = nothing +@inline surface_rainfall_flux(atmos::PrescribedAtmosphere) = surface_rainfall_flux(atmos.freshwater_flux) +@inline surface_rainfall_flux(ff::PrescribedPrecipitationFlux) = field_data(ff.rain) + """ The standard unit of atmospheric pressure; 1 standard atmosphere (atm) = 101,325 Pascals (Pa) in SI units. This is approximately equal to the mean sea-level atmospheric pressure on Earth. """ function default_atmosphere_pressure(grid, times) diff --git a/src/Atmospheres/prescribed_atmosphere_regridder.jl b/src/Atmospheres/prescribed_atmosphere_regridder.jl index e1d5a797b..e281b6b0e 100644 --- a/src/Atmospheres/prescribed_atmosphere_regridder.jl +++ b/src/Atmospheres/prescribed_atmosphere_regridder.jl @@ -7,7 +7,7 @@ function ComponentExchanger(atmosphere::PrescribedAtmosphere, grid) T = Field{Center, Center, Nothing}(grid), p = Field{Center, Center, Nothing}(grid), q = Field{Center, Center, Nothing}(grid), - Jᶜ = Field{Center, Center, Nothing}(grid), + Jʳⁿ = Field{Center, Center, Nothing}(grid), Jˢⁿ = Field{Center, Center, Nothing}(grid)) return ComponentExchanger(state, regridder) diff --git a/src/DataWrangling/ECCO/ECCO_atmosphere.jl b/src/DataWrangling/ECCO/ECCO_atmosphere.jl index 6f54ea1d4..671bd3294 100644 --- a/src/DataWrangling/ECCO/ECCO_atmosphere.jl +++ b/src/DataWrangling/ECCO/ECCO_atmosphere.jl @@ -1,6 +1,6 @@ using NumericalEarth.DataWrangling: DatasetBackend using Oceananigans.OutputReaders -using NumericalEarth.Atmospheres: PrescribedAtmosphere +using NumericalEarth.Atmospheres: PrescribedAtmosphere, PrescribedPrecipitationFlux """ ECCOPrescribedAtmosphere([architecture = CPU(), FT = Float32]; @@ -51,7 +51,7 @@ function ECCOPrescribedAtmosphere(architecture = CPU(), FT = Float32; pa = FieldTimeSeries(pa_meta, architecture; kw...) Fr = FieldTimeSeries(Fr_meta, architecture; kw...) - freshwater_flux = (; rain = Fr) + freshwater_flux = PrescribedPrecipitationFlux(rain = Fr) times = ua.times grid = ua.grid diff --git a/src/DataWrangling/JRA55/JRA55.jl b/src/DataWrangling/JRA55/JRA55.jl index 57cdcd6b4..841e84c43 100644 --- a/src/DataWrangling/JRA55/JRA55.jl +++ b/src/DataWrangling/JRA55/JRA55.jl @@ -19,7 +19,7 @@ using Oceananigans.OutputReaders: Cyclical, TotallyInMemory, AbstractInMemoryBac using NumericalEarth -using NumericalEarth.Atmospheres: PrescribedAtmosphere +using NumericalEarth.Atmospheres: PrescribedAtmosphere, PrescribedPrecipitationFlux using NumericalEarth.Radiations: PrescribedRadiation, SurfaceRadiationProperties, default_stefan_boltzmann_constant using GPUArraysCore: @allowscalar diff --git a/src/DataWrangling/JRA55/JRA55_prescribed_atmosphere.jl b/src/DataWrangling/JRA55/JRA55_prescribed_atmosphere.jl index 373566c54..9d1d0cc83 100644 --- a/src/DataWrangling/JRA55/JRA55_prescribed_atmosphere.jl +++ b/src/DataWrangling/JRA55/JRA55_prescribed_atmosphere.jl @@ -41,8 +41,7 @@ function JRA55PrescribedAtmosphere(architecture = CPU(), FT = Float32; Fra = JRA55FieldTimeSeries(:rain_freshwater_flux, architecture, FT; kw...) Fsn = JRA55FieldTimeSeries(:snow_freshwater_flux, architecture, FT; kw...) - freshwater_flux = (rain = Fra, - snow = Fsn) + freshwater_flux = PrescribedPrecipitationFlux(rain = Fra, snow = Fsn) times = ua.times grid = ua.grid diff --git a/src/DataWrangling/OSPapa/OSPapa.jl b/src/DataWrangling/OSPapa/OSPapa.jl index 056145019..ccece9cb9 100644 --- a/src/DataWrangling/OSPapa/OSPapa.jl +++ b/src/DataWrangling/OSPapa/OSPapa.jl @@ -15,7 +15,7 @@ using Downloads using Thermodynamics: q_vap_from_RH, Liquid using NumericalEarth.DataWrangling: download_progress -using NumericalEarth.Atmospheres: PrescribedAtmosphere, AtmosphereThermodynamicsParameters +using NumericalEarth.Atmospheres: PrescribedAtmosphere, PrescribedPrecipitationFlux, AtmosphereThermodynamicsParameters using NumericalEarth.Oceans: reference_density, heat_capacity using NumericalEarth.DataWrangling: diff --git a/src/DataWrangling/OSPapa/OSPapa_prescribed_atmosphere.jl b/src/DataWrangling/OSPapa/OSPapa_prescribed_atmosphere.jl index 9990eee11..180d0c69e 100644 --- a/src/DataWrangling/OSPapa/OSPapa_prescribed_atmosphere.jl +++ b/src/DataWrangling/OSPapa/OSPapa_prescribed_atmosphere.jl @@ -79,7 +79,7 @@ function OSPapaPrescribedAtmosphere(architecture = CPU(), FT = Float32; velocities = (u=ua, v=va), tracers = (T=Ta, q=qa), pressure = Pa, - freshwater_flux = (; rain), + freshwater_flux = PrescribedPrecipitationFlux(; rain), thermodynamics_parameters = thermo_params, surface_layer_height = convert(FT, surface_layer_height)) end diff --git a/src/Oceans/assemble_net_ocean_fluxes.jl b/src/Oceans/assemble_net_ocean_fluxes.jl index 6c364a434..d2171e946 100644 --- a/src/Oceans/assemble_net_ocean_fluxes.jl +++ b/src/Oceans/assemble_net_ocean_fluxes.jl @@ -31,7 +31,8 @@ function update_net_ocean_fluxes!(coupled_model, ocean_model, grid) sea_ice_ocean_fluxes = computed_fluxes(coupled_model.interfaces.sea_ice_ocean_interface) atmosphere_fields = coupled_model.interfaces.exchanger.atmosphere.state - freshwater_flux = atmosphere_fields.Jᶜ.data + rainfall_flux = atmosphere_fields.Jʳⁿ.data + snowfall_flux = atmosphere_fields.Jˢⁿ.data # Extract land freshwater flux if land component is present land_exchanger = coupled_model.interfaces.exchanger.land @@ -50,7 +51,8 @@ function update_net_ocean_fluxes!(coupled_model, ocean_model, grid) sea_ice_ocean_fluxes, ocean_surface_salinity, ice_concentration, - freshwater_flux, + rainfall_flux, + snowfall_flux, land_freshwater_flux, ocean_properties) @@ -67,7 +69,8 @@ Base.@propagate_inbounds get_land_freshwater_flux(i, j, flux) = flux[i, j, 1] sea_ice_ocean_fluxes, ocean_surface_salinity, sea_ice_concentration, - freshwater_flux, + rainfall_flux, + snowfall_flux, land_freshwater_flux, ocean_properties) @@ -82,7 +85,9 @@ Base.@propagate_inbounds get_land_freshwater_flux(i, j, flux) = flux[i, j, 1] ℵᵢ = sea_ice_concentration[i, j, 1] Sᵒᶜ = ocean_surface_salinity[i, j, 1] - Jᶜ = freshwater_flux[i, j, 1] + get_land_freshwater_flux(i, j, land_freshwater_flux) + Jʳⁿ = rainfall_flux[i, j, 1] + Jˢⁿ = snowfall_flux[i, j, 1] + Jˡⁿ = get_land_freshwater_flux(i, j, land_freshwater_flux) 𝒬ᵀ = atmos_ocean_fluxes.sensible_heat[i, j, 1] 𝒬ᵛ = atmos_ocean_fluxes.latent_heat[i, j, 1] Jᵛ = atmos_ocean_fluxes.water_vapor[i, j, 1] @@ -91,13 +96,13 @@ Base.@propagate_inbounds get_land_freshwater_flux(i, j, flux) = flux[i, j, 1] # Turbulent contributions to surface heat flux (radiation added later) ΣQao = (𝒬ᵀ + 𝒬ᵛ) * (1 - ℵᵢ) - # Convert mass flux to volume flux; sign-flip (prescribed flux is positive down) + # Freshwater flux to the ocean per unit cell area (volume flux, positive up = leaving ocean): + # - rain and land runoff reach the ocean everywhere (rain runs through cracks in ice) + # - snow only reaches the ocean through the open-water fraction (1 - ℵᵢ); + # - evaporation acts only over the open-water fraction (1 - ℵᵢ) + # The atmospheric mass-flux convention is positive down; Jᵛ is positive up. ρᵒᶜ⁻¹ = 1 / ocean_properties.reference_density - ΣFao = - Jᶜ * ρᵒᶜ⁻¹ - - # Add turbulent water vapor flux (positive upward sign convention) - Jᵛᵒᶜ = Jᵛ * ρᵒᶜ⁻¹ - ΣFao += Jᵛᵒᶜ + ΣFao = - (Jʳⁿ + Jˡⁿ + (1 - ℵᵢ) * Jˢⁿ) * ρᵒᶜ⁻¹ + (1 - ℵᵢ) * Jᵛ * ρᵒᶜ⁻¹ τˣ = net_ocean_fluxes.u τʸ = net_ocean_fluxes.v @@ -126,6 +131,6 @@ Base.@propagate_inbounds get_land_freshwater_flux(i, j, flux) = flux[i, j, 1] # Tracer fluxes — radiative contributions added later by apply_air_sea_radiative_fluxes! Jᵀ[i, j, 1] = ifelse(inactive, zero(grid), Jᵀao + Jᵀio) - Jˢ[i, j, 1] = ifelse(inactive, zero(grid), (1 - ℵᵢ) * Jˢao + Jˢio) + Jˢ[i, j, 1] = ifelse(inactive, zero(grid), Jˢao + Jˢio) end end diff --git a/src/SeaIces/assemble_net_sea_ice_fluxes.jl b/src/SeaIces/assemble_net_sea_ice_fluxes.jl index 2418e6f35..5c5974469 100644 --- a/src/SeaIces/assemble_net_sea_ice_fluxes.jl +++ b/src/SeaIces/assemble_net_sea_ice_fluxes.jl @@ -16,8 +16,7 @@ function update_net_fluxes!(coupled_model, sea_ice::Simulation{<:SeaIceModel}) atmosphere_sea_ice_fluxes = computed_fluxes(coupled_model.interfaces.atmosphere_sea_ice_interface) atmosphere_fields = coupled_model.interfaces.exchanger.atmosphere.state - freshwater_flux = atmosphere_fields.Jᶜ.data - snowfall_flux = atmosphere_fields.Jˢⁿ.data + snowfall_flux = atmosphere_fields.Jˢⁿ.data sea_ice_properties = coupled_model.interfaces.sea_ice_properties ice_concentration = sea_ice_concentration(sea_ice) @@ -30,7 +29,6 @@ function update_net_fluxes!(coupled_model, sea_ice::Simulation{<:SeaIceModel}) clock, atmosphere_sea_ice_fluxes, sea_ice_ocean_fluxes, - freshwater_flux, snowfall_flux, ice_concentration, sea_ice_properties) @@ -44,7 +42,6 @@ end clock, atmosphere_sea_ice_fluxes, sea_ice_ocean_fluxes, - freshwater_flux, snowfall_flux, ice_concentration, sea_ice_properties) diff --git a/test/test_ecco_atmosphere.jl b/test/test_ecco_atmosphere.jl index 12d120930..59202dd6e 100644 --- a/test/test_ecco_atmosphere.jl +++ b/test/test_ecco_atmosphere.jl @@ -2,7 +2,7 @@ include("runtests_setup.jl") include("download_utils.jl") using Statistics: median -using NumericalEarth.Atmospheres: PrescribedAtmosphere +using NumericalEarth.Atmospheres: PrescribedAtmosphere, PrescribedPrecipitationFlux using NumericalEarth.Radiations: PrescribedRadiation using NumericalEarth.ECCO: ECCOPrescribedAtmosphere, ECCOPrescribedRadiation, ECCO4Monthly using NumericalEarth.DataWrangling: download_dataset, metadata_path, higher_bound @@ -50,7 +50,9 @@ end @test haskey(atmosphere.tracers, :T) @test haskey(atmosphere.tracers, :q) @test !isnothing(atmosphere.pressure) - @test haskey(atmosphere.freshwater_flux, :rain) + @test atmosphere.freshwater_flux isa PrescribedPrecipitationFlux + @test atmosphere.freshwater_flux.rain isa FieldTimeSeries + @test isnothing(atmosphere.freshwater_flux.snow) # Test downwelling radiation components ℐꜜˢʷ = radiation.downwelling_shortwave diff --git a/test/test_ospapa.jl b/test/test_ospapa.jl index 15a1af718..aea8cb22c 100644 --- a/test/test_ospapa.jl +++ b/test/test_ospapa.jl @@ -1,7 +1,7 @@ include("runtests_setup.jl") using NumericalEarth.OSPapa -using NumericalEarth.Atmospheres: PrescribedAtmosphere +using NumericalEarth.Atmospheres: PrescribedAtmosphere, PrescribedPrecipitationFlux using NumericalEarth.Radiations: PrescribedRadiation using Oceananigans.BoundaryConditions: BoundaryCondition, Flux, getbc using Oceananigans.Units: minutes @@ -32,7 +32,9 @@ const OSPAPA_TEST_END = DateTime(2012, 10, 3) @test haskey(atmosphere.tracers, :T) @test haskey(atmosphere.tracers, :q) @test !isnothing(atmosphere.pressure) - @test haskey(atmosphere.freshwater_flux, :rain) + @test atmosphere.freshwater_flux isa PrescribedPrecipitationFlux + @test atmosphere.freshwater_flux.rain isa FieldTimeSeries + @test isnothing(atmosphere.freshwater_flux.snow) # Radiation sanity checks ℐꜜˢʷ = radiation.downwelling_shortwave From 43c50858ba419ad1825f05cec1c175ddb3757769 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 7 May 2026 12:21:08 +0200 Subject: [PATCH 41/54] new ClimaSeaIce --- test/Project.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/Project.toml b/test/Project.toml index 883eac781..f5d7675f9 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -30,7 +30,6 @@ WorldOceanAtlasTools = "04f20302-f1b9-11e8-29d9-7d841cb0a64a" XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" [sources] -ClimaSeaIce = {url = "https://github.com/CliMA/ClimaSeaIce.jl", rev = "ss/refactor-thermodynamics"} NumericalEarth = {path = ".."} [compat] @@ -39,7 +38,7 @@ Breeze = "0.4" CDSAPI = "2.2.2" CFTime = "0.1, 0.2" CUDA = "5.9.5" -ClimaSeaIce = "0.4.4, 0.5" +ClimaSeaIce = "0.5" CondaPkg = "0.2.33" CopernicusMarine = "0.1.1" Dates = "<0.0.1, 1" From 5b415c8c8549716e8ba9ff21d7e6ab2b4b094de5 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Thu, 7 May 2026 13:15:40 +0200 Subject: [PATCH 42/54] typo --- ext/NumericalEarthBreezeExt/breeze_atmosphere_interface.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/NumericalEarthBreezeExt/breeze_atmosphere_interface.jl b/ext/NumericalEarthBreezeExt/breeze_atmosphere_interface.jl index ef8be726d..c6408e02d 100644 --- a/ext/NumericalEarthBreezeExt/breeze_atmosphere_interface.jl +++ b/ext/NumericalEarthBreezeExt/breeze_atmosphere_interface.jl @@ -39,7 +39,7 @@ function ComponentExchanger(atmosphere::BreezeAtmosphere, exchange_grid) ℐꜜˢʷ = Oceananigans.CenterField(exchange_grid), ℐꜜˡʷ = Oceananigans.CenterField(exchange_grid), Jʳⁿ = Oceananigans.CenterField(exchange_grid), - Jˢⁿ = Oceananigans.CenterField(exchange_grid), + Jˢⁿ = Oceananigans.CenterField(exchange_grid)) return ComponentExchanger(state, nothing) end From 99e5e8633f5c2e860e6f712ae5f08939b7edcd5d Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 11 May 2026 09:11:46 +0200 Subject: [PATCH 43/54] test fixes --- .../InterfaceComputations/interface_states.jl | 5 ++--- test/test_snow_model_integration.jl | 7 +++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/EarthSystemModels/InterfaceComputations/interface_states.jl b/src/EarthSystemModels/InterfaceComputations/interface_states.jl index 5bd0460c8..1fe571a9b 100644 --- a/src/EarthSystemModels/InterfaceComputations/interface_states.jl +++ b/src/EarthSystemModels/InterfaceComputations/interface_states.jl @@ -291,9 +291,8 @@ end Ωc = ifelse(ΔT == zero(ΔT), zero(Tₛ⁻), 𝒬ᵀ / ΔT) # Newton linearization of upwelling longwave: ℐꜛˡʷ(Tₛ) ≈ ℐꜛˡʷ(Tₛ⁻) + β (Tₛ − Tₛ⁻). - σ = ℙₛ.radiation.σ - ϵ = ℙₛ.radiation.ϵ - β = 4 * σ * ϵ * Tₛ⁻^3 + # Since ℐꜛˡʷ = σ ϵ Tₛ⁻⁴, we have β = 4 σ ϵ Tₛ⁻³ = 4 ℐꜛˡʷ / Tₛ⁻. + β = 4 * ℐꜛˡʷ / Tₛ⁻ # Flux balance solution with T⁴ linearization (stable even at ΔT = 0): D = 1 + β * R - Ωc * R diff --git a/test/test_snow_model_integration.jl b/test/test_snow_model_integration.jl index a5860e6d9..3e1555589 100644 --- a/test/test_snow_model_integration.jl +++ b/test/test_snow_model_integration.jl @@ -118,8 +118,7 @@ using Oceananigans.Units: hours, days atmosphere = PrescribedAtmosphere(ocean_grid, [0.0]) parent(atmosphere.velocities.u) .= 2.0 - radiation = Radiation(ocean_emissivity = 0, sea_ice_emissivity = 0) - return OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + return OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation=nothing) end bare = build_coupled(with_snow = false) @@ -163,7 +162,7 @@ end dynamics = nothing) atmosphere = PrescribedAtmosphere(grid, [0.0]) - radiation = Radiation() + radiation = nothing @test begin coupled = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) @@ -184,7 +183,7 @@ end snow_thermodynamics = nothing) atmosphere = PrescribedAtmosphere(grid, [0.0]) - radiation = Radiation() + radiation = nothing coupled = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) From 2273c27ca1d4cd700a220bcb0f4d3bfadf04f21e Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 11 May 2026 09:41:40 +0200 Subject: [PATCH 44/54] new climaseaice --- docs/Project.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index 1f870d183..abd5c6eab 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -22,11 +22,10 @@ Suppressor = "fd094767-a336-5f1f-9728-57cf17d0bbfb" XESMF = "2e0b0046-e7a1-486f-88de-807ee8ffabe5" [sources] -Breeze = {rev = "main", url = "https://github.com/NumericalEarth/Breeze.jl/"} NumericalEarth = {path = ".."} [compat] Documenter = "1" DocumenterCitations = "1.3" Literate = "2.2" -ClimaSeaIce = "0.4.10" +ClimaSeaIce = "0.5.0" From f00aef50b95e55812a1baa0127e3a010c69fce9a Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Mon, 11 May 2026 11:39:26 +0200 Subject: [PATCH 45/54] fix snow tests --- test/test_snow_model_integration.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_snow_model_integration.jl b/test/test_snow_model_integration.jl index 3e1555589..14928c312 100644 --- a/test/test_snow_model_integration.jl +++ b/test/test_snow_model_integration.jl @@ -118,7 +118,7 @@ using Oceananigans.Units: hours, days atmosphere = PrescribedAtmosphere(ocean_grid, [0.0]) parent(atmosphere.velocities.u) .= 2.0 - return OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation=nothing) + return OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation=nothing) end bare = build_coupled(with_snow = false) @@ -165,7 +165,7 @@ end radiation = nothing @test begin - coupled = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + coupled = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation) time_step!(coupled, 1) true end @@ -185,7 +185,7 @@ end atmosphere = PrescribedAtmosphere(grid, [0.0]) radiation = nothing - coupled = OceanSeaIceModel(ocean, sea_ice; atmosphere, radiation) + coupled = OceanSeaIceModel(sea_ice, ocean; atmosphere, radiation) # The snowfall field should exist in the exchanger exchanger = coupled.interfaces.exchanger From 488701102b0aa3964604ac44b339592876a8e24b Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 12 May 2026 09:52:24 +0200 Subject: [PATCH 46/54] Apply suggestion from @simone-silvestri --- src/SeaIces/sea_ice_simulation.jl | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/SeaIces/sea_ice_simulation.jl b/src/SeaIces/sea_ice_simulation.jl index c718ab665..526655f10 100644 --- a/src/SeaIces/sea_ice_simulation.jl +++ b/src/SeaIces/sea_ice_simulation.jl @@ -145,8 +145,6 @@ sea_ice_thickness(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_thickn sea_ice_concentration(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.ice_concentration heat_capacity(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.phase_transitions.heat_capacity -# `sea_ice.model.sea_ice_density` is wrapped as a `ConstantField` by `SeaIceModel`; -# the scalar value lives on `phase_transitions.density`. reference_density(sea_ice::Simulation{<:SeaIceModel}) = sea_ice.model.phase_transitions.density function net_fluxes(sea_ice::Simulation{<:SeaIceModel}) From 80b7669302e3755a9d3c5e18d89ab683ed3cb850 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 12 May 2026 09:52:50 +0200 Subject: [PATCH 47/54] Apply suggestion from @simone-silvestri --- .../InterfaceComputations/component_interfaces.jl | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl b/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl index adcf8603e..11c2dd9bb 100644 --- a/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl +++ b/src/EarthSystemModels/InterfaceComputations/component_interfaces.jl @@ -255,8 +255,6 @@ function atmosphere_sea_ice_interface(grid, temperature_formulation, velocity_formulation) - # When snow is present, the atmosphere interacts with the snow surface; - # otherwise with the ice top surface. snow_thermo = sea_ice.model.snow_thermodynamics interface_temperature = if isnothing(snow_thermo) sea_ice.model.ice_thermodynamics.top_surface_temperature From b41284c62af7f99c18870256ca27f028921a6ae2 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 12 May 2026 09:53:10 +0200 Subject: [PATCH 48/54] Apply suggestion from @simone-silvestri --- src/Atmospheres/interpolate_atmospheric_state.jl | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Atmospheres/interpolate_atmospheric_state.jl b/src/Atmospheres/interpolate_atmospheric_state.jl index 1d08dcda4..b781b3c9c 100644 --- a/src/Atmospheres/interpolate_atmospheric_state.jl +++ b/src/Atmospheres/interpolate_atmospheric_state.jl @@ -118,8 +118,6 @@ end qᵃᵗ = interp_atmos_time_series(atmos_tracers.q, atmos_args...) pᵃᵗ = interp_atmos_time_series(atmos_pressure, atmos_args...) - # Rainfall and snowfall are kept separate downstream: Jʳⁿ holds the rain - # and Jˢⁿ holds the snow (used by the sea-ice snow accumulation). Mr = interp_atmos_time_series(rainfall_flux, atmos_args...) Ms = interp_atmos_time_series(snowfall_flux, atmos_args...) From 4ebf0db22c37c11b1ef6d104b47a656ada7aa11e Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 12 May 2026 09:53:50 +0200 Subject: [PATCH 49/54] Apply suggestion from @simone-silvestri --- src/Atmospheres/prescribed_atmosphere.jl | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Atmospheres/prescribed_atmosphere.jl b/src/Atmospheres/prescribed_atmosphere.jl index 32a482222..8ce091d5d 100644 --- a/src/Atmospheres/prescribed_atmosphere.jl +++ b/src/Atmospheres/prescribed_atmosphere.jl @@ -70,9 +70,6 @@ function default_freshwater_flux(grid, times) return PrescribedPrecipitationFlux(rain, snow) end -# `nothing` is returned when the atmosphere has no snow component, so that -# callers (e.g. sea-ice snow accumulation) can branch via dispatch rather than -# `haskey`. @inline field_data(::Nothing) = nothing @inline field_data(field) = field.data From ede012bfd8e2b503822ab0088d5d647e8dac0fb9 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 12 May 2026 09:54:36 +0200 Subject: [PATCH 50/54] Apply suggestion from @simone-silvestri --- src/SeaIces/sea_ice_simulation.jl | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/SeaIces/sea_ice_simulation.jl b/src/SeaIces/sea_ice_simulation.jl index 526655f10..bb1aae401 100644 --- a/src/SeaIces/sea_ice_simulation.jl +++ b/src/SeaIces/sea_ice_simulation.jl @@ -18,8 +18,6 @@ ocean_reference_density(::Nothing, FT) = convert(FT, 1026.0) function default_snow_thermodynamics(grid) FT = eltype(grid) snow_conductivity = FT(0.31) - # Use PrescribedTemperature so ClimaSeaIce does NOT run its own surface solve; - # the coupled flux solver in NumericalEarth handles the snow surface temperature. snow_surface_temperature = Field{Center, Center, Nothing}(grid) top_heat_boundary_condition = PrescribedTemperature(snow_surface_temperature.data) return snow_slab_thermodynamics(grid; conductivity = snow_conductivity, top_heat_boundary_condition) From 604ec8234254129096575cfff1ff81b3110596bc Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 12 May 2026 10:19:58 +0200 Subject: [PATCH 51/54] small change --- .../InterfaceComputations/interface_states.jl | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/EarthSystemModels/InterfaceComputations/interface_states.jl b/src/EarthSystemModels/InterfaceComputations/interface_states.jl index 1fe571a9b..34642667b 100644 --- a/src/EarthSystemModels/InterfaceComputations/interface_states.jl +++ b/src/EarthSystemModels/InterfaceComputations/interface_states.jl @@ -275,7 +275,8 @@ end # dominated). We linearize: Qa(Tₛ) ≈ Qa(Tₛ⁻) + β (Tₛ − Tₛ⁻) with β = 4σεTₛ⁻³, # yielding the Newton-like semi-implicit update: # Tₛ = [Tᵦ + β R Tₛ⁻ - Ωc R Tᵃᵗ - Qa R] / [1 + β R - Ωc R] -@inline function conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) +@inline function conductive_flux_balance_temperature(st, R, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) + hᵢ = Ψᵢ.hi hc = Ψᵢ.hc # Bottom temperature at the melting point @@ -298,6 +299,7 @@ end D = 1 + β * R - Ωc * R T★ = (Tᵦ + β * R * Tₛ⁻ - Ωc * R * Tᵃᵗ - Qa * R) / D T★ = ifelse(D == 0, Tₛ⁻, T★) + T★ = ifelse(isnan(T★), Tₛ⁻, T★) # Cap the temperature step for iteration stability ΔT★ = T★ - Tₛ⁻ @@ -319,19 +321,16 @@ end @inline function flux_balance_temperature(st::SkinTemperature{<:ClimaSeaIce.ConductiveFlux}, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) k = st.internal_flux.conductivity - hᵢ = Ψᵢ.hi - R = hᵢ / k - return conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) + R = Ψᵢ.hi / k + return conductive_flux_balance_temperature(st, R, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) end # Snow + ice: R = hₛ / kₛ + hᵢ / kᵢ @inline function flux_balance_temperature(st::SkinTemperature{<:ClimaSeaIce.SeaIceThermodynamics.IceSnowConductiveFlux}, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) F = st.internal_flux - hᵢ = Ψᵢ.hi - hₛ = Ψᵢ.hs - R = hₛ / F.snow_conductivity + hᵢ / F.ice_conductivity - return conductive_flux_balance_temperature(st, R, hᵢ, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) + R = Ψᵢ.hs / F.snow_conductivity + Ψᵢ.hi / F.ice_conductivity + return conductive_flux_balance_temperature(st, R, Ψₛ, ℙₛ, 𝒬ᵀ, 𝒬ᵛ, ℐꜛˡʷ, Qd, Ψᵢ, ℙᵢ, Ψₐ, ℙₐ) end @inline function compute_interface_temperature(st::SkinTemperature, From e8b53ba8b39cad7267fd97a2c5f85e57a57de2ad Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 12 May 2026 10:20:30 +0200 Subject: [PATCH 52/54] restart CI From c9ac425f45ac3e1282fb16dbfcf41c4390f4a793 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 12 May 2026 12:11:01 +0200 Subject: [PATCH 53/54] fix veros ocean --- ext/NumericalEarthVerosExt/veros_ocean_simulation.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ext/NumericalEarthVerosExt/veros_ocean_simulation.jl b/ext/NumericalEarthVerosExt/veros_ocean_simulation.jl index 041e8e05a..60c782a9b 100644 --- a/ext/NumericalEarthVerosExt/veros_ocean_simulation.jl +++ b/ext/NumericalEarthVerosExt/veros_ocean_simulation.jl @@ -29,7 +29,10 @@ Returns a NamedTuple containing package information if successful. Also patches Veros's signal handling to work with PythonCall. """ function install_veros() - CondaPkg.add("hdf5"; version="<2", channel="conda-forge") # Veros uses hdf5 version < 2. Therefore we pin it to prevent dependency issues + # Veros uses hdf5 < 2. The shenanigans in the following two lines + # are necessary to allow loading compatible hdf5 and h5py. + CondaPkg.add("hdf5"; version="<2", channel="conda-forge") + CondaPkg.add("h5py"; version=">=3.0,<3.13", channel="conda-forge") CondaPkg.add_pip("veros", version="@ https://github.com/team-ocean/veros/archive/refs/heads/main.zip") cli = CondaPkg.which("veros") From 380f6b38cb02488f3655fd8ed0153affa9eee1a3 Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Tue, 12 May 2026 12:21:01 +0200 Subject: [PATCH 54/54] fix radiation for veros --- src/Radiations/apply_air_sea_radiative_fluxes.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Radiations/apply_air_sea_radiative_fluxes.jl b/src/Radiations/apply_air_sea_radiative_fluxes.jl index 60e018995..146ca7c37 100644 --- a/src/Radiations/apply_air_sea_radiative_fluxes.jl +++ b/src/Radiations/apply_air_sea_radiative_fluxes.jl @@ -27,7 +27,7 @@ function apply_air_sea_radiative_fluxes!(coupled_model::EarthSystemModel) isnothing(interface_fluxes) && return nothing haskey(interface_fluxes, :ocean) || return nothing - grid = ocean.model.grid + grid = coupled_model.interfaces.exchanger.grid arch = architecture(grid) clock = coupled_model.clock @@ -40,7 +40,7 @@ function apply_air_sea_radiative_fluxes!(coupled_model::EarthSystemModel) interface_temperature = ao_interface.temperature ocean_properties = coupled_model.interfaces.ocean_properties - penetrating_radiation = get_radiative_forcing(ocean.model) + penetrating_radiation = get_radiative_forcing(ocean) launch!(arch, grid, :xy, _apply_air_sea_radiative_fluxes!,