From d99b2b618522d994d01b85f343e97f37fc04142c Mon Sep 17 00:00:00 2001 From: Simone Silvestri Date: Wed, 15 Apr 2026 12:48:44 +0200 Subject: [PATCH 01/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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 #####