Surface Conditions Internals
Design rationale, data flow, dispatch chains, extension points, and debugging for the surface-conditions subsystem. For the user-facing guide — the four configuration knobs and how to set them, see Surface Conditions.
Design: one source of truth
Surface behavior lives entirely on atmos.surface. Principles:
- Orthogonality:
flux_scheme,temperature,boundary_overrides, andsurface_albedoare independent axes. Adding an option on one shouldn't touch the others. - Dispatch over branching: behavior is selected by dispatch on concrete types, not
if/elseifon config strings. - Eager resolution: YAML markers and
Default*placeholders resolve to concrete structs at construction, so the hot path sees only concrete types.
Data flow
The entry point update_surface_conditions! (called from set_explicit_precomputed_quantities!) does four things: (1) early-return if isnothing(flux_scheme); (2) resolve the temperature via surface_temperature; (3) resolve the flux scheme via resolve_flux_scheme (once per update); (4) broadcast surface_state_to_conditions over every surface point.
The kernel mixes surface-space and lowest-interior-level values, which live on different spaces, so a normal Field broadcast would error. The code drops to Fields.field_values(...) (raw DataLayouts) so the values broadcast as plain same-shape arrays.
Dispatch chains
Three small families cover all behavior:
surface_temperature (surface_temperature.jl): temperature type → value:
| Type | Returns |
|---|---|
AnalyticTemperature | the struct itself (deferred) |
ExternalTemperature | field_values of the evaluated input |
SlabOceanTemperature | field_values(Y.sfc.T) |
CoupledTemperature | field_values(t.field) |
resolve_T_sfc (surface_conditions.jl): in the per-cell kernel, an AnalyticTemperature is evaluated as t.f(coordinates, surface_temp_params, t_time); scalars and DataLayouts pass through. This two-step design lets analytic formulas see each cell's local coordinates while field-valued temperatures resolve once up front.
Flux scheme → flux specs (in surface_state_to_conditions): branches on ExchangeCoefficients vs MoninObukhov, and within MoninObukhov on whether fluxes are prescribed (HeatFluxes/θAndQFluxes) or derived from roughness.
Constraints
- Scalars must broadcast.
Base.broadcastable(x) = tuple(x)is defined once on the abstract supertypesSurfaceParameterizationandSurfaceTemperature, so every concrete subtype inherits it for free. A new subtype needs nothing extra; the only ways to break this are introducing a parallel hierarchy that isn't a subtype, or removing the supertype method. surface_temperaturereturns aDataLayout, anAnalyticTemperature, or a scalar: nothing else. ReturnFields.field_values(...), not aField. A scalar is permitted (it passes throughresolve_T_sfcunchanged), but no built-in type currently returns one; the four in-tree types return either the struct (AnalyticTemperature) orfield_values(...).- Time-varying fluxes resolve per-update, not per-cell: a
MoninObukhovwith a callablefluxeshas it evaluated once byresolve_flux_scheme, then the resulting numeric scheme is broadcast everywhere. isnothing(flux_scheme)is a supported state: any reader ofatmos.surface.flux_schememust handle it.- Only
SlabOceanTemperatureadds prognostic state:Y.sfcexists only for slab runs, so guardY.sfc.Taccess on that type.
Extending
Both extension points follow the same shape: define a concrete subtype, then add the handful of methods the pipeline dispatches on. Because Base.broadcastable(::SurfaceTemperature) and Base.broadcastable(::SurfaceParameterization) are defined on the abstract supertypes, your subtype inherits broadcastability for free; you do not need to redefine it.
A new temperature source
Define the type as a subtype of
SurfaceConditions.SurfaceTemperature. Store whatever it needs (a function, aField, parameters):struct MyTemperature{F} <: SurfaceConditions.SurfaceTemperature data::F endAdd a
surface_temperaturemethod, the per-update resolver. It must return one of the three broadcastable shapes: a scalar, aFields.DataLayoutof per-cell values, or the struct itself (deferred to the per-cell kernel):# field-valued: resolve once per update SurfaceConditions.surface_temperature(t::MyTemperature, Y, p, t_time) = Fields.field_values(t.data)(Optional) Add a
resolve_T_sfcmethod if you returned the struct in step 2 becauseT_sfcdepends on each cell's coordinates (this is howAnalyticTemperatureworks). It runs inside the broadcast kernel and receives the local coordinates:SurfaceConditions.surface_temperature(t::MyTemperature, Y, p, _) = t # defer SurfaceConditions.resolve_T_sfc(t::MyTemperature, coords, surface_temp_params, t_time) = t.data(coords, surface_temp_params, t_time)(Optional) Wire in prognostic state if
T_sfcshould evolve, mirroringSlabOceanTemperature: add asurface_prognostic_variables(local_geometry, ::MyTemperature)initializer and asurface_kwargs(surface_space, ::MyTemperature)method (soY.sfcis allocated), asurface_temp_tendency!method for the time evolution, and any conservation-diagnostic dispatch indiagnostics/conservation_diagnostics.jl.(Optional) Expose it to configs by extending
AtmosSurface(::AtmosConfig, ...)insrc/config/model_getters.jl(or have a setup return it fromsurface_condition).
A new flux scheme
Define the type as a subtype of
SurfaceConditions.SurfaceParameterization{FT}(the{FT}parameter letsfloat_typerecover the element type):struct MyScheme{FT} <: SurfaceConditions.SurfaceParameterization{FT} coefficient::FT endHandle it in
surface_state_to_conditions: extend theparameterization isa …branch that maps the scheme onto theSurfaceFluxescall (building the appropriateFluxSpecs/SurfaceFluxConfig). This is the one place flux schemes are interpreted.(Optional) Add a
resolve_flux_schememethod if the scheme varies in time, mirroring howMoninObukhovresolves a callablefluxes. It runs once per update (not per-cell) and must return a concrete, time-independent scheme:SurfaceConditions.resolve_flux_scheme(p::MyScheme, t, ::Type{FT}) where {FT} = MyScheme{FT}(p.coefficient * cos(t))(Optional) Expose it to configs/setups as in step 5 above.
Config and cache wiring
AtmosSurface(::AtmosConfig, params, FT; setup_type)(src/config/model_getters.jl) maps YAML keys + setup pieces into a concreteAtmosSurface; setup pieces win via@something.build_cache(src/cache/cache.jl) storesp.sfc_setup = atmos.surface.boundary_overrides(a scalar, or aFieldfor the coupler) and callsinit_sfc_conditions_zero!whenisnothing(flux_scheme).
ClimaAtmos.SurfaceConditions.init_sfc_conditions_zero! — Function
init_sfc_conditions_zero!(p)Zero-initialize p.precomputed.sfc_conditions with safe defaults. Used when the surface flux scheme is nothing (the atmos side does not compute surface conditions) so that the first set_precomputed_quantities! call does not see uninitialized memory in downstream consumers like RRTMGP and diagnostic EDMF.
Debugging checklist
sfc_conditionsNaN/uninitialized under the coupler:init_sfc_conditions_zero!only fires whenisnothing(flux_scheme).T_sfcuniform when it should vary: the temperature must return per-cell values, or be anAnalyticTemperaturewhosefactually readscoordinates.- Space-mismatch error in
update_surface_conditions!: something returned aFieldinstead of aDataLayout/scalar, or a type is missingbroadcastable. Y.sfcnot found: not aSlabOceanTemperaturerun; guard slab-only code.- Time-varying flux not updating:
MoninObukhov.fluxesmust be a callable(t, FT) -> PrescribedFluxes(resolved each update), not a fixedHeatFluxescaptured at construction.